diff --git a/.agents/notes/archived/feature/2026-10-22-database-multi-schema-tree.md b/.agents/notes/archived/feature/2026-10-22-database-multi-schema-tree.md new file mode 100644 index 0000000000..f5b7944542 --- /dev/null +++ b/.agents/notes/archived/feature/2026-10-22-database-multi-schema-tree.md @@ -0,0 +1,119 @@ +# Agent 笔记:MySQL 连接下的多数据库树 + +状态:已实现 +归档日期:2026-10-24 + +## 归档原因 + +macOS 内置数据库模块已删除,数据库改由官方插件 `dev.lithe.plugin.database` 提供,多数据库树和“显示哪些库”的选择在插件网页里重新实现。本 Note 描述的 SwiftUI 侧栏、`DatabaseFeatureModel` 和对应测试都已不存在。当前决策见[数据库拆成双端共用的独立插件](../../implemented/architecture/2026-10-24-database-provider-plugin.md)。 + +## 先说结论 + +macOS 数据库侧栏保留混合类型的分组文件夹,在每个 MySQL/MariaDB 连接下展示账号可见的数据库,并允许同时展开多个库。展开目录只加载目录,不切换编辑区;打开表、查询或执行右键动作时才选择该节点所属的数据库。各库的加载状态相互独立,不能再用当前编辑区的表列表绘制所有节点。 + +## 问题 + +原侧栏只绘制当前连接的当前数据库。切换连接或数据库时,旧库的表列表被清空,因此即使保留展开箭头也不能真正同时显示多个库。同名表的右键操作如果只携带连接编号,还会使用编辑区当前的库。 + +## 决策 + +数据库功能模型 `DatabaseFeatureModel` 为每个连接保存账号返回的数据库名称,并为每个数据库保存独立的表、对象、加载状态和请求编号。只在展开时读取该库的元数据,重复展开使用内存结果,刷新才重新获取;不预先扫描所有库,也不缓存表数据或密码。连接和数据库目录各自持有后台请求;折叠、断开、强制刷新或模块释放时取消相应 worker,让取消信号到达 macOS 进程组终止路径,同时用请求编号阻止迟到结果恢复已清理的节点。数据库列表刷新后删除不再可见的缓存条目,失败保留其他数据库内容并提供重试。 + +文件夹、连接、数据库(含非 MySQL 的默认库)和 Tables 等对象分组统一采用名称单击选择、名称双击切换展开和收缩、箭头单击独立切换。分组原先每次单击都切换,双击会切换两次回到原状;数据库原先点击总是展开,无法双击收缩。因此不能只修连接层,必须沿各层容器的入口一起修正。单击数据库目录不切换编辑器,打开表或执行操作才选择实际数据库;单击当前连接也不重复刷新。未放入文件夹的连接直接显示在树根层,删除额外的“未分组连接”分区标题;原标题承载的根层拖放入口改为树尾空白落点,全部连接都已分组时也能拖回根层,原“移回根目录”菜单继续保留。 + +表行也区分选择、展开和打开:单击表名只选择,展开箭头先更新展开集合并显示加载动画,双击表名才打开数据编辑器。树展开只取列、索引、外键,不读第一页数据,也不切换活动数据库。每个“连接+库+表”保存结构缓存和唯一的后台请求;编辑器需要同一结构时复用该请求,折叠不取消其他消费者正在等待的加载,迟到结果不能重新展开节点。列、索引、外键的并行读取使用结构化子任务,模块释放取消共享 worker 时不会留下独立的 sidecar。配置变更、断开、删除和模块释放会使请求失效;显式刷新清理已完成的结构缓存,导航则复用缓存。 + +刷新按钮以树的选中节点为范围:表节点只读取该表的列、索引和外键;库或对象分组刷新所属库已加载的结构;连接刷新可见数据库名称和已加载的库、表结构,不重新查询表格记录,也不切换编辑器。连接右键的 Diagnostics → Force Refresh 先清除该连接的结构缓存和旧请求,再重新读取先前已加载的范围;不扫描所有可见库。普通刷新保留旧内容直至结果返回,移除已删除库、表的缓存。当前接口没有结构版本差异,因此不宣称实现 IDEA 的增量检测。 + +数据编辑区和结构页未选择表时复用 `LitheToolWindowStatusText`,只显示操作提示,删除大图标与重复标题。依据 JetBrains 官方 [Empty State](https://plugins.jetbrains.com/docs/intellij/empty-state.html) 的详情区说明及既有 Community StatusText 共享样式;不把专有数据库插件的界面猜作源码基线。 + +同步的钥匙串读取和数据库元数据请求都放在后台,先向界面发布 loading 状态。展开后的加载提示使用现有 `ProgressView` 与树行 token;单个表失败在原节点显示 Retry,不覆盖另一张表的数据编辑器错误。 + +这种拆分依据 Community `platform/ui.tree/src/com/intellij/ui/tree/AsyncTreeModel.java`:`promiseChildren` 先使用缓存,没有缓存时放置 `LoadingNode`,后台读取后再更新界面,并用节点队列和过期检查保护结果。官方表编辑器说明将双击表名定义为打开数据编辑器;通用 `LoadingNodeRenderer` 提供加载文字占位,Lithe 按产品需求使用已有加载动画,并不复制专有数据库插件实现。 + +节点操作必须先通过 `selectDatabaseContext` 选择节点所属的数据库,再进入已有编辑区、导入导出和危险操作确认流程。例如 alpha/shared 与 beta/shared 是不同节点:点击 beta/shared 后的查询、导出或删除必须使用 beta,不能继续使用 alpha。展开 beta 的箭头则不应改变当前查询目标。 + +布局、行高、缩进、图标尺寸、明暗主题、悬停与活动/非活动选中状态复用 `LitheTheme.Tree`、`litheTreeRow`、`LitheIDEAIcon`,右键菜单复用 `litheContextMenu`。不增加独立颜色或另一套树组件。树的单选节点由侧栏独立保存,不能从活动连接、活动数据库和活动表分别推导高亮;点击数据库或表后,其祖先连接不得同时高亮。展开连接和数据库的箭头只改变展开状态。 + +交互依据是 JetBrains 官方 [数据源分组说明](https://www.jetbrains.com/help/idea/managing-data-sources.html) 和 [数据库工具窗口说明](https://www.jetbrains.com/help/idea/database-tool-window.html)。通用树行为核对 Community revision `d8c6714c7a3c110c56994d6b0f10814f902100e7` 的 `platform/platform-impl/src/com/intellij/ide/ui/laf/darcula/ui/DarculaTreeUI.java` 与 `platform/util/ui/src/com/intellij/util/ui/tree/WideSelectionTreeUI.java`;后者处理整行选中及展开控制的选中/焦点状态。数据库插件的专有实现不作为可复制源码;外观继续采用仓库已经批准的共享树 token 和已有 SVG。 + +连接和文件夹的右键使用 Move → To Folder… / To Top Level。Move 用于组织连接配置与分组,不能对 MySQL 实体库执行本地分组移动。没有分组时 To Folder 直接打开名称输入;已有分组时展示目标路径列表,也可新建后移入。名称支持 `/` 嵌套,已有中间层复用,末级重名、空路径段、过期目标及文件夹自身/后代循环均在功能模型拒绝;失败不发布半成品目录。创建后移动连接时,存储先完成两份 JSON 编码,再写原有组织配置键,不改凭据键。 + +新建与重命名分组共用项目区的 `LitheNamePopup`:从现有新建文件/目录内容提取,保留无标题栏、plain 名称框、Enter 提交和 Esc 取消,不新增独立表单样式。目的地列表通过 `LitheDropdownPopover` 的居中菜单入口复用 `LitheContextMenuPresenter`,包含原有键盘导航、选中、滚动和屏幕边界处理;路径仅用于本地组织,不发送给数据库服务。 + +连接旁的 `N of M` 是已选可见库数与账号可访问库总数,不是展开数、序号或已加载元数据数。计数必须原样显示,不能使用全局 `%lld of %lld` 本地化键;该键在中文中表示“第几个,共几个”,语义不同。2026-10-23 直接核对本机 IDEA 2026.2.1(262.9437.185)后,选择器改为无标题、无 Apply/Cancel 的复选列表,底部提示按 Enter 或点击列表外应用;Esc 丢弃草稿。搜索在输入时出现,继续使用原生输入法和共享弹层焦点路由。范围提交仍由功能模型验证,不在页面另存状态。官方语义见[数据库选择说明](https://www.jetbrains.com/help/idea/introspection.html#select-schemas-and-databases-for-introspection)。 + +徽标绘制依据 Community `c7f91397daa3a961b4e78bc634fe467a0a7d9ade` 的 `platform/platform-api/src/com/intellij/ui/SimpleColoredComponent.java`(`drawClickableFrag`)以及同 revision 的 `UIUtil.java`、`ManyIslandsDark/Light.theme.json`:24pt 行内上下内缩 2pt、左右内缩 1pt,圆角直径 6pt,普通描边,悬停且非活动选中行时填充;small 字号为 max(普通字号−2, 11)。颜色复用已存在的 `toolbarHoverBackground`,其深浅值分别为白色 23/255 和黑色 18/255,文字使用共享 Tree 次要文字。连接名称后保留 en space 的间隔。徽标显式使用共享 `lithePointer` 呈现可点击的小手,但没有悬停文字;退出和卸载时由同一 modifier 恢复光标,不修改整棵树的光标策略。选择弹框通过局部环境关闭小手,树行与工具栏均保持普通箭头。弹层外框、搜索框和工具栏优先使用 Lithe 共享控件。范围列表是复选树,不是普通菜单,不能用 `LitheSettingsCheckbox` 加 `LitheDropdownRowStyle` 替代。原 Git 树复选框提升为 `LitheTreeCheckbox`,Git 原调用与数据库共用同一套 24pt SVG(内部绘制 16pt 方框),资源已包含深浅、焦点、禁用及三态变体,不重画勾选符号。列表使用 `litheTreeRow` 的 13pt 文字、24pt 行高、树选择/悬停色;保留 12pt 树边距与 19pt 根节点控制占位,复选框标题旁单行省略,行内空白也能点击。 + +同 revision 的 `CheckboxTreeBase.kt` 通过 `ThreeStateCheckBox` 与 `ColoredTreeCellRenderer` 组成行;`CheckboxTreeHelper.kt` 隐藏根节点但保留根控制,`PopupChooserBuilder.java` 不对树套用普通列表边距。`ManyIslandsDark/Light.theme.json` 的 Tree 继承通用工具窗口背景,故正文使用已有 `sidebar` 色,只有底部说明区使用 `popupBackground`。说明文字采用 Popup.Advertiser 的 UI 字号减 1pt(12pt)、垂直 8pt/水平 12pt 边距;列表采用已有 480pt 高列表视口,少量条目也保留空白,弹层宿主负责屏幕边界。外观依据是公开的通用控件及实际界面,不复制专有数据库插件代码;模式匹配、动态默认 schema 仍不属于当前范围存储模型。 + +连接行的名称与徽标右侧空白必须保持相同的单击选择、双击展开/折叠行为。引入独立范围徽标时不能把名称按钮缩短后留下不响应事件的 Spacer;空白命中区域复用同一连接动作,徽标与展开箭头仍独立处理,避免整行手势使它们重复切换。零选库不等于连接无法展开:展开后使用共享树的次要文字色与 11pt small 字号显示“未选择架构”,缩进对齐下一层图标,不放省略号按钮。 + +显示范围保存在现有连接配置的可选字段 `visibleDatabases`:缺省或 null 表示全部可访问库(新出现的库自动加入),空数组表示一个也不显示,非空数组是固定选择。旧配置没有这个字段时保持之前全部显示的行为。这里不能复用 `database` 字段,因为它属于编辑器当前查询目标;隐藏 beta 后仍可继续编辑 beta 中已打开的表。切换范围只写配置,不读取密码、不测试连接、不查询记录;数据库选择器保存前校验账号当前可见的名称,连接或权限发生变化时拒绝过期选择。连接级刷新跳过隐藏的库,即使该库曾被编辑器加载过。隐藏操作清理相应结构缓存和请求标记,迟到结果不能恢复隐藏节点。元数据仍按展开延迟读取,没有增加全库预扫描。 + +工具栏“+”与侧栏右键 New 共用 `LitheMenu` / `LitheContextMenuItem` 的新建菜单:查询控制台、数据源类型子菜单、分组、DBX 导入。类型来自已有 `DatabaseKind.allCases`,选择后将默认引擎传入原连接编辑器;新建初始化与切换引擎复用同一默认值函数,编辑现有连接不重置参数。云数据源、模板、驱动管理等未实现能力不添加空动作。右下角角标来自 Community `c7f91397daa3a961b4e78bc634fe467a0a7d9ade` 的 `platform/icons/src/expui/general/dropdown{,_dark}.svg`(16pt 画布右下角 4pt 直角三角形);`ActionButton.java` 判断 popup group 后由 `ActionButtonLook.paintDownArrow` 将角标相对主图标偏移 (1, 1)。只补齐现有 IDEAIcons 源资源,不引入下载脚本或运行时写入。菜单外框、字体、选中颜色、子菜单及键盘导航继续由 Lithe 共享菜单拥有。连接编辑器保留共享弹窗尺寸支持,最新分页布局使用稳定内容尺寸,避免切页跳动。新建菜单的数据源入口按用户后续明确要求,复用右侧数据库工具窗口的 `database/expui/dbms@20x20` 圆柱图标,以现有相同资源统一显示;其 DatabaseTools 来源和 Apache-2.0 声明保留在 IDEAIcons/NOTICE,不声称来自 Community。其余菜单图标使用 Community 资源,七种数据库保留现有同 revision 的 `providers` 原图。查询、数据源、分组三行连续使用共享 24pt 行高,仅导入前保留分隔组,避免每项后叠加 11pt 分组留白。Community 无 Nacos 专属图标,因此共享品牌控件改用该 revision 的通用 `expui/general/settings` 原图(本地命名 nacos),删除原 DBX PNG;缺失资源时的备用图标也使用 Community dataTables,取消 SF Symbols 分支。 + +侧栏空白区(包含尚未建立连接的空状态)复用 `litheContextMenu`,提供 New → 连接/分组、DBX 导入、按所选节点刷新和全部折叠。根层空白拖放区域填满视口剩余高度,因此右键不再只有树末的一行能命中;节点菜单保持独立。 + +## 考虑过的备选方案 + +- 每展开一个数据库就切换当前连接配置:代码更短,但多个库仍共享同一份内容,展开也会打断查询,因此不采用。 +- 为每个数据库复制一份连接:会重复保存服务器地址与凭据,改变现有连接和分组语义,因此不采用。 +- 展开连接时读取所有库的所有元数据:首次加载开销随服务器规模增加,且一个无权限对象可能拖累整棵树,因此按库延迟加载。 + +## 后果 + +同一连接的多个库及多个连接能独立展开,分组与混合类型连接继续保留。代价是元数据在模块内存中保存到刷新、配置变更、断开或模块释放;外部数据库结构变化需要刷新,已有进程端口的超时仍负责限制单次数据库调用。当前编辑区仍只有一个活动数据库,树的并行展开不表示为每个库新增独立编辑会话。Windows 的树没有在此次 macOS 界面修改中同步。 + +## 连接编辑器排版与草稿检测 + +连接编辑器保留原有字段和保存流程,名称固定在顶部;常规页承载引擎及连接参数,SSH / TLS 页承载原有网络选项,安全与标记页承载分组、颜色、只读和保护选项。使用现有输入框、选择器、工具窗口分页及操作按钮样式,固定 760×580 内容区,长内容在页内滚动。 + +用户另行授权测试连接:操作栏左侧复用 `LitheBannerActionStyle`,可选颜色仅覆盖本按钮为主题 accent,默认调用者不变;文字及 hover 下划线同色。检测只调用已有 sidecar 接口,不保存连接或密码、不改当前工作区和连接状态。修改有效草稿、密码或关闭弹窗取消 UI 任务,迟到结果不能覆盖新草稿。macOS 同步进程运行器会观察任务取消并沿现有进程组终止路径清理测试 sidecar;30 秒请求超时仍是兜底。编辑连接的空密码沿用已保存密码;新连接为空则使用空密码。成功后自动打开位于测试链接上方的共享 `LitheDropdownPopover`。纠正结果卡片误用菜单表面:仅该调用选择 contentProvidesSurface,复用宿主定位、关闭和事件生命周期,内容自行提供结果气泡表面。底栏保留 Community greenCheckmark 与引擎名称,可重开/收起结果;浮层使用绿色成功标题、主题色复制链接和可选择正文。现有 sidecar 仅返回 connected,因此只展示已知引擎与单调时钟测得的端到端检测耗时,不能把它称为网络 Ping,也不虚构服务端/驱动版本、大小写规则或实际协商的 TLS 状态。 + +## 验证 + +- `./.agents/skills/write-stable-tests/scripts/verify-test-stability.sh` +- `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'DatabaseWorkspaceLifecycleTests/databaseTree|LitheTreeRowStyleTests'` +- `./scripts/verify-service-boundaries.sh` +- `./scripts/verify-runtime-bundle-immutability.sh` +- `./scripts/verify-platform-feature-matrix.sh` +- `./scripts/verify-agent-notes.sh` + +回归使用现有数据库进程端口的可控替身,覆盖 MySQL/MariaDB、混合分组、无默认库、同名表上下文、权限失败重试、刷新移除库以及数据库和表结构在断开/删除/释放后的迟到结果。另用有本地期限的真实 sidecar 子进程验证连接或数据库目录在任务取消、折叠、断开、强制刷新和模块释放后退出;测试不手动放行阻塞请求。受控慢请求还验证结构加载期间立即展开并显示进度、折叠不重开、重复展开命中缓存,编辑器与树共享结构请求且展开不查询表数据。深浅主题宿主测试通过原生辅助功能操作展开和折叠,并验证两个库的表同时出现;这些检查不替代完整工作区的真实连接验收。另用临时 MySQL 8.4.11 实例验证数据库列表的权限过滤,以及两个库中同名表的查询结果和全部对象分组查询;受限账号看不到未授权的第三个库。临时实例及数据在检查后清理。 + +## 适用范围 + +- `macos/Sources/LitheDatabaseModule/Application/DatabaseFeatureModel.swift` +- `macos/Sources/Lithe/Views/Database/DatabaseSidebarView.swift` +- `macos/Tests/LitheTests/DatabaseWorkspaceLifecycleTests.swift` +- `shared/platform-feature-matrix/features/database-connections.json` + +## 成功气泡源码核对(2026-10-23) + +上游统一固定为 Community `c7f91397daa3a961b4e78bc634fe467a0a7d9ade`: + +- `platform/platform-impl/src/com/intellij/ide/ui/laf/LafManagerImpl.kt`:`INTER_SIZE=13`、`Font.PLAIN`;`shouldFallbackToSystemFont` 对 zh/ja/ko 为真,macOS `installMacOSXFonts` 使用 Helvetica Neue 13。结果正文和 Copy 随 locale 使用该分支,成功标题按用户截图单独加粗,不再让菜单动作样式覆盖字体。 +- `platform/platform-impl/src/com/intellij/ui/BalloonImpl.java`:非 dialog 模式 ARC=4,上下方向 pointer width=14、length=10。移植四角和连续底边指针路径,1pt 描边使用 Shaper 的整数坐标和 size - 1 远端边缘(原先错误内收半点已纠正),不能先画完整矩形再叠加三角形而留下内部横线。 +- `platform/platform-resources/src/themes/islands/ManyIslandsDark.theme.json` 及 Light:沿 parent 和颜色别名解析 dialog-bg、popup-border、text-default、Label.successForeground。深色 #191A1C/#33353B/#D1D3D9/#6DB083;浅色 #F7F8F9/#DDDFE4/#000000/#338555。结果卡片采用 dialog surface,不继承菜单 popup-bg,也不直接使用 ToolTip 的反馈灰底。 +- Community plugins 目录无 Database Tools 实现,公开 `DataSourceTestConnectionManager` 堆栈也不能证明私有结果面板的构造参数。因此上述字体、气泡默认几何和主题 token 是可核对的 Community 规则;选择非 dialog 气泡、dialog-bg/popup-border 组合、标题粗体、16pt 内容留白、12pt 标题间距以及跟随本地 760pt 表单减去两侧留白的宽度,是按用户截图进行的独立布局适配,不声称拿到了私有面板源码或其全部规则。 + +验证应覆盖真实资源深浅主题、中文字体回退、正文常规/标题粗体、边框与底部透明区域及指针、重新打开和取消清理;保持其他共享菜单表面不变。不增加服务器版本或驱动元数据请求。 + +### 输入框与原生浮层外沿纠正 + +连接编辑器改用项目已有 `litheSettingsTextField`(共享 28pt 高度、4pt 圆角),Commit 对话框输入样式也按用户后续要求复用共享 4pt 圆角 chrome;所有使用该 modifier 的 Git 和数据库表单一并生效,保留原焦点绑定与 1/2pt 边框宽度。用户名和密码保留与 Host 相同的端口区占位,端口标签按当前语言自然测量,隐藏占位不进入辅助功能树。检测耗时与按钮统一取视图 locale,避免 AppLanguage 英文与系统中文混用。自带表面的结果气泡关闭 AppKit 原生窗口阴影附带的外沿,保留既有独立描边;普通菜单仍使用原生阴影。 + +### 成功浮层淡入 + +Community 同一 revision 的 `BalloonImpl.runAnimation` 按帧提高 alpha,`BalloonPopupBuilderImpl.myAnimationCycle` 默认 500ms;Database Tools 私有调用是否覆盖时长不可确认。结果浮层采用原生 NSWindow 500ms 线性淡入,仅首次展示/重新打开执行,布局更新不重新播放;关闭仍立即移除,不留下延迟回调重开窗口。尊重 macOS 减少动态效果设置,其他菜单默认无淡入。 + +### 两种密码保存范围与复制结果 + +按用户确认仅支持 Until restart / Forever,不展示未实现的 Never / For session 或 IDE 密码设置入口。`DatabaseProfile.passwordPersistence` 为可选解码字段,旧数据回退 Forever。前者由 DatabaseConnectionStore 进程级加锁内存字典持有,跨模块重载仍有效、退出进程自然释放,不写偏好或凭据存储;后者沿用已有安全存储。切换到临时模式先成功删除持久化凭据再发布内存值,切换永久模式先成功写入再清内存;保存配置失败时恢复原密码及原范围。复制连接保留保存范围,删除连接清理两种存储。下次启动临时密码为空,仍通过已有编辑连接入口重新填写,不增加自动凭据弹窗。 + +新建连接用户名留空,不再默认 root/postgres/sa。成功浮层 Copy 使用手形光标,复制后收起,点击结果入口可重新打开;测试恢复用户完整剪贴板内容。 + +### 新连接默认名称 + +按用户提供的 IDEA 界面形式,Name 默认显示 `数据库@主机`,未填写数据库为 `@主机`。新草稿在未手动改名前随 Host/数据库/引擎更新;手动改名或编辑已有连接后保持用户名称。SQLite 使用文件名,未选择文件时使用引擎名称。MongoDB URI 仅提取 host,禁止把账号、密码或 query 拼入名称。当前默认 Host 仍为 127.0.0.1,不为显示名称改变网络连接地址。公开文档不提供完整私有自动命名实现,此处是与截图命名形式一致的本地适配。 diff --git a/.agents/notes/implemented/architecture/2026-09-12-git-execution-and-project-console.md b/.agents/notes/implemented/architecture/2026-09-12-git-execution-and-project-console.md index 137dbcec26..3694927485 100644 --- a/.agents/notes/implemented/architecture/2026-09-12-git-execution-and-project-console.md +++ b/.agents/notes/implemented/architecture/2026-09-12-git-execution-and-project-console.md @@ -253,6 +253,12 @@ JDT 通道曾触发其 15 秒测试期限,同一提交在 SharedRust 通道用 所有测试的时限来隐藏这类问题。计时工具用可控时钟验证普通、集成和总截止 时间,并验证 HTML 与 JUnit 不会把合法的集成耗时误判为失败。 +checkout/clone 完整流程用例 `git_write_executes_checkout_preflight_clone_and_validation` +在 Windows CI 39935852248 中全部断言通过,Rust 内部计时 15.22 秒、测试进程计时 +18.529 秒,超过默认十五秒后触发清缓存重编,继而耗尽原套件期限。该用例创建分叉 +分支,覆盖干净、脏和未跟踪文件预检、智能/强制切换以及本地 clone,因此只为它在 +SharedRust 和真实 JDT 两个入口设置三十秒预算;不延长请求或整套期限,不重试失败断言。 + 取消模块的确定性回归核对 mutation 后正常读取保留原截止时间和取消能力, 已取消或超时请求的独立读取仍受清理期限约束,错误和异常退出后恢复原状态。 真实仓库 rebase 回归核对 edit、conflict、continue、skip、abort 和过期 amendment; diff --git a/.agents/notes/implemented/architecture/2026-09-13-module-runtime-boundaries-and-lifecycle.md b/.agents/notes/implemented/architecture/2026-09-13-module-runtime-boundaries-and-lifecycle.md index 1502f95f28..c0a4201b7c 100644 --- a/.agents/notes/implemented/architecture/2026-09-13-module-runtime-boundaries-and-lifecycle.md +++ b/.agents/notes/implemented/architecture/2026-09-13-module-runtime-boundaries-and-lifecycle.md @@ -89,6 +89,13 @@ MacServiceContainer 错误,不能把残留资源伪装成成功禁用。上述任务仍在主执行器上使用异步等待, 不阻塞线程、不新增插件代码加载能力。 +使用真实 AppModel 的测试也必须遵守项目打开的清理顺序。`openProjectDirectly` 会启动 +模块图关闭;项目文件快照已经发布不代表关闭完成。Safe Mode 测试要手动注入能力时, +先通过有截止的 `WorkbenchTestScope.run` 等待 `awaitModuleRuntimeShutdown()`,再放入 +测试能力。否则迟到的 shutdown 事件可能移除刚注入的能力,让前几次搜索通过、随后 +查询全部为空。不要用睡眠或反复重新注入能力掩盖这个顺序;搜索排除回归与插件页面 +生命周期测试都使用同一个明确的完成边界。 + ### 数据库视图在模块释放后的刷新 退出应用或休眠数据库模块时,SwiftUI(macOS 的声明式界面框架)可能先刷新 diff --git a/.agents/notes/implemented/architecture/2026-09-13-repository-ownership-and-sharing-boundaries.md b/.agents/notes/implemented/architecture/2026-09-13-repository-ownership-and-sharing-boundaries.md index 5a1b0e235e..1b835157eb 100644 --- a/.agents/notes/implemented/architecture/2026-09-13-repository-ownership-and-sharing-boundaries.md +++ b/.agents/notes/implemented/architecture/2026-09-13-repository-ownership-and-sharing-boundaries.md @@ -33,13 +33,12 @@ Swift 源码,也不得依赖 macOS 类型。 | `macos/Sources/Lithe/Core/` | 平台无关端口和类型化 Rust 操作 | | `macos/Sources/Lithe/Platform/MacOS/` | macOS 适配器和组合根 | | `rust/lithe-core/` | 确定性的共享命令、模型、校验和 C ABI | -| `rust/lithe-git-host/` | 原生 Git 子进程、管道、临时输入和有界清理 | +| `rust/lithe-git-host/` | 原生 Git 子进程、管道、临时输入和有界清理;以及长期子进程的进程树归属(`ProcessTree`,Windows Job Object / Unix 进程组) | | `windows/` | React/Tauri Windows 产品和 Windows Rust 适配器 | | `Plugins/mac/` | macOS 所有的插件包 | | `Plugins/win/` | Windows 所有的插件包 | | `frontend/editor/` | 两端共同依赖的 Monaco 表现层、分词与编辑器模型;不调用平台 API | | `shared/` | 跨平台契约和夹具,不放编译实现 | -| `infra/` | 仓库级开发和验证基础设施 | | `third_party/` | 固定版本的上游清单和必要的局部源码补丁 | 应用层依赖方向如下: @@ -128,6 +127,9 @@ Swift 源码。新文件以主要类型 原生 Git 适配器 `rust/lithe-git-host/` 负责子进程、管道、临时输入文件, 以及进程组或 Windows 作业对象的限时清理,不负责 Git 参数策略或界面模型。 +运行会话、调试适配器和 Windows 语言服务器这类宿主自己读写输出的长期进程, +也通过它的 `ProcessTree` 把整棵进程树交给 Job Object 或进程组,不在各自模块里 +重复实现 Windows 作业对象。 现有 Core Git 输出捕获入口调用此适配器,以保留 JSON 和 C ABI(C 语言 调用边界)的兼容性;共享事件解码和凭据脱敏留在 `rust/lithe-core/src/git/`。 认证界面和凭据存储仍由各平台负责。新增 Git 操作应复用此执行边界, @@ -269,5 +271,4 @@ Core 操作会随协议演进持续增加,逐个新增 Tauri command 会在 - `shared/` - `frontend/editor/` - `scripts/` -- `infra/` - `third_party/` diff --git a/.agents/notes/implemented/architecture/2026-09-13-resizable-ui-performance-boundaries.md b/.agents/notes/implemented/architecture/2026-09-13-resizable-ui-performance-boundaries.md index 1777eabb29..daa5ecaa7a 100644 --- a/.agents/notes/implemented/architecture/2026-09-13-resizable-ui-performance-boundaries.md +++ b/.agents/notes/implemented/architecture/2026-09-13-resizable-ui-performance-boundaries.md @@ -96,6 +96,31 @@ Diff 的纵向同步依据 Community `SyncScrollSupport.ScrollHelper` 和 有效范围。记录的组件布局与截图耗时只是本地诊断数据,不能替代整机连续拖动的 帧延迟,也不能把 `split.drag` 手势持续时间当成帧性能。 +### 设置窗口只观察设置相关的变化 + +设置窗口不能整体订阅 `AppModel`。`AppModel` 会把文档、语言服务、Git、搜索、 +运行等十几个功能模块的变化合并转发出去;整体订阅时,主窗口里编辑代码、索引或 +刷新 Git 状态,都会让整个设置页(包括插件列表和详情)重新计算,设置窗口还会 +重新配置一次标题栏。工程越忙,设置页越卡。IDEA 的设置对话框也不随工程事件重绘。 + +正确做法是:设置页根视图通过 `SettingsModelContext` 读取同一个 `AppModel`,只订阅 +`AppModel.settingsChanges`。这条通道包含模型自身的发布属性、插件和模块的直接通知、 +模块激活与释放,以及当前工作区变化;功能模块的高频转发调用 +`scheduleObjectWillChangeRelay(affectsSettings: false)`,不进入设置窗口。新增的转发 +如果会改变设置页显示的内容,保持默认的 `affectsSettings: true`。 + +需要实时状态的页面自己订阅对应功能模型,或者在一个小的叶子视图里订阅 `AppModel`, +例如语言服务状态和项目运行时页面。模块按需激活的页面要把激活结果存进本地状态, +不能指望后续的整体刷新把新功能显示出来;但本地状态只能作为能力缓存填好前的过渡,统一用 `ModuleFeatureSelection`:激活任务以当前能力身份为 key,模块休眠后立即丢弃旧实例并重新激活,页面不能继续读写已释放的功能实例。窗口外观(标题栏、样式掩码)只在标题、 +主题、关闭按钮状态或外观真的变化时才重新应用。插件列表在一次渲染里只排序、 +过滤一次,行的悬停状态留在行内部。 + +设置窗口是项目窗口的子窗口,用来跟随项目窗口移动。但 AppKit 拖动子窗口时由应用 +主线程逐个鼠标事件移动,工程忙时拖动会一格一格地跳,不跟手。因此 +`SettingsWindowChrome` 在标题栏按下鼠标时临时解除父子关系,交给窗口服务器拖动, +松手(或移动结束且鼠标已松开)后再挂回原项目窗口。不要为了跟随而在拖动期间保持 +子窗口关系,也不要用定时器手动同步位置。 + ### 正确做法 ```text @@ -164,6 +189,13 @@ IDEA 的 [BranchesTreeComponent](https://github.com/JetBrains/intellij-community `cd windows/tauri && bun test src/features/tabs/hooks/use-tab-wheel-scroll.test.tsx` Windows CI 显式运行整个 `src/features/tabs` 测试目录,并生成逐项计时报告。 - 代码审查时确认拖动不会导致高频整页重绘 +- `AppModelSettingsChangesTests` 验证只影响工作台的转发不会进入 + `settingsChanges`,直接通知和设置相关的转发仍然会进入,并且合并后的一次转发 + 只要包含设置相关变化就会通知设置窗口。 +- `ModuleFeatureSelectionTests` 验证设置页保持打开时,模块休眠后会丢弃已释放的 + 实例,重新激活后显示新实例。`SettingsAppearanceContainerTests` 用原生鼠标事件 + 验证:按下标题栏会解除子窗口关系,拖动中重新配置不会提前挂回,松手后挂回, + 关闭后重新打开也会挂回。 - `GitGraphInteractionTests.splitPaneBounds` 在两种方向、两种位置及折叠状态 验证原生子视图身份、可见尺寸和传入的确定尺寸,防止恢复为堆栈尺寸探测。 - `LitheSplitPaneCollapseTests.outerDragSuspendsRowChrome` 使用原生分隔条 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 19607a5f84..7666c12be0 100644 --- a/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md +++ b/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md @@ -4,7 +4,7 @@ ## 先说结论 -Agent 对话默认关闭,打开某个项目的 Agent 面板时才启动本机 Agent。每个项目的每种 Agent 只有一个对话连接,一个连接里可以有多个会话;会话历史由 Agent 自己保存;Lithe 负责显示,并单独保存收藏、自定义标题和可恢复的隐藏状态。供应商管理允许 Codex 在自定义 API Key 和本机 ChatGPT 订阅间切换;Claude 仍只支持 API Key。订阅额度放在输入框上下文的右侧,不新增页面。Agent 需要的 Node.js 由用户自己安装,Lithe 只负责检测;ACP 适配器由 Lithe 提供一键安装,安装时用的是用户本机的 npm。ACP(Agent Client Protocol,编辑器与 Agent 之间的对话协议)连接和进程管理写在同一个 Rust crate 里:Mac 通过 C ABI 调用,Windows 通过 `windows/tauri/src-tauri/src/agent.rs` 的连接桥调用,两端只各自实现界面。 +Agent 对话默认关闭,打开某个项目的 Agent 面板时才启动本机 Agent。每个项目的每种 Agent 只有一个对话连接,一个连接里可以有多个会话;会话历史由 Agent 自己保存;Lithe 负责显示,并单独保存收藏、自定义标题和可恢复的隐藏状态。供应商管理允许 Codex 在自定义 API Key 和本机 ChatGPT 订阅间切换,Claude 在自定义 API Key 和本机 Claude Code 已登录的 claude.ai 订阅间切换。订阅额度放在输入框上下文的右侧,不新增页面。Agent 需要的 Node.js 由用户自己安装,Lithe 只负责检测;ACP 适配器由 Lithe 提供一键安装,安装时用的是用户本机的 npm。ACP(Agent Client Protocol,编辑器与 Agent 之间的对话协议)连接和进程管理写在同一个 Rust crate 里:Mac 通过 C ABI 调用,Windows 通过 `windows/tauri/src-tauri/src/agent.rs` 的连接桥调用,两端只各自实现界面。 ## 问题 @@ -30,14 +30,15 @@ Agent 对话默认关闭,打开某个项目的 Agent 面板时才启动本机 - **Codex 兼容入口只补配置与错误信息**:固定适配器覆盖自定义网关配置,`CODEX_CONFIG` 中的重试字段会丢失;混用点号路径和整个表又会受到原生 HashMap 应用顺序影响,不能靠偶尔成功认为已生效。因此单次连接创建一方标准输入/输出转发 helper(只传输公开 App Server 协议),在创建、恢复和分叉会话时补齐原生预算,其他配置照传。AIR 重连警告会丢掉原生错误信息,helper 将结构化 `codexErrorInfo`、原始说明、当前轮次和通知序号保存在失败标题内的 JSON 信封;Host 只在协商后的失败元数据中解码,正常消息原样传递,不扫描模型文本或私有日志。公开通知序号去重、原生当前轮次判断拒绝迟到错误,界面只显示原始说明。`/lithe-codex-retry-/` 由该连接持有,结束进程树或启动失败后删除,无密钥内容;临时目录由平台解析,不写安装包、用户配置或第三方文件,不影响签名与 Sparkle delta。资源注册表将它排除,不能跨工作树复用。适配器未来直接保留这些原生配置并转发错误信息后,应移除此兼容 helper,保留预算和契约测试。 - **重连状态由实际事件驱动**:Host 发出带唯一轮次标识和尝试次数的 `turnRetrying`,macOS 在尚无工作时显示“正在重连 2/5…”并继续累计耗时;已有工作后的原生恢复省略 `maxAttempts`,只显示“正在重连”,不能编造调用上限。首次恢复警告后五分钟显示等待较久的提醒,继续等待只关闭提醒,不重发;后续警告不能推迟提醒。实际输出恢复后切换回复或推理状态;有效计划更新(包括清空计划)也证明重连已恢复,界面退出重连并清空次数,保留轮次计时和忙碌,不覆盖已有回复、推理、权限或停止状态。无法解析的计划不触发此清理。完成、失败、取消、断连清空次数;迟到通知不能复活已结束轮次,取消优先于重连,取消确认前不解锁。Claude 的下一次尝试必须等前次失败且无进度;Codex 的流恢复仍由原生执行。后续接入其他 ACP,应先核对它是否提供结构化失败、明确重连事件及可配置预算;只支持 ACP 不代表可以控制它的内部重试。未经适配的 Agent 保留原生策略,正常轮次不设绝对上限,不能对所有 Agent 盲目套五次 prompt 重放。 - **思考强度档位控件**:按用户给定的 Effort 参考设计,模型菜单底部把纵向强度选项收成一行:左侧按用户要求统一显示 `Effort (当前档位)`,右侧用胶囊轨道、圆点和选中圆块表示上游实际档位。模型滚动区只包含完整模型目录,目录为空或长列表滚动到底时仍能访问强度。档位数量、顺序、ID 与名称都由 Agent 提供,不固定成参考图的四档,也不手工补齐 Low、Medium、High、Xhigh、Max、Ultra。点击圆点、方向键及辅助功能调整均提交同一配置动作;按用户要求,调整 Effort 后保持同一个模型弹窗,等待上游确认期间只禁用控件,确认或失败后恢复操作,不能因提交请求或进入配置等待而收起弹窗。选择模型及其他配置仍关闭菜单,点击外部、按 Esc 和会话切换继续使用共享关闭逻辑;拖动仅在控件内预览,松开后提交一次,不能把每次移动都变成上游请求。空闲时仍等上游确认才改变当前值,下一轮预选继续由既有状态机负责;禁用态不发请求,未知当前值不伪装成第一档。弹窗继续复用 `litheDropdown` 和共享外框,按用户要求,模型弹窗最小宽度设为 300pt,给模型列表与 Effort 行留下更宽松的空间;长标签与轨道仍参与内容测量,最大宽度遵守共享上限,不改成固定宽度;标题与括号按原文渲染,保证中文界面不会因翻译成更宽的字符而超出测量宽度、出现 `思考强…edium)` 截断。控件使用现有主题色和字体,不新增另一套菜单背景。代价是短模型目录的弹窗也保留 300pt 最小宽度,收益是多档强度不再占用多行并且始终可见。只新增视图内手势与焦点状态,不新增缓存、下载或发行包写入。 -- **权限模式呈现**:菜单和底栏保留 Agent 上游名称,例如 Claude 的 Manual、Accept edits、Plan、Auto、Bypass permissions;模型和思考强度也使用上游名称。名称不能进入全局翻译表,否则 `Plan` 会碰到其他界面的“计划”翻译,导致个别选项与其余名称混用语言。模式入口采用纯文本与向下箭头,菜单复用 LitheMenu 的紧凑单行选项与左侧勾选;删除卡片式逐项图标、双行说明及独立滚动/尺寸测量。当前模式说明留在入口 tooltip,Claude 继续显示上游实际提供的编辑、计划等额外选项。依据 [Claude 权限模式说明](https://code.claude.com/docs/en/permissions),说明可按界面语言呈现,但保留实际 Agent 的审批与环境约束,不把 Codex 的沙箱说明直接套到 Claude。只改变显示,不修改选项 ID、数量、顺序或权限命令;上游确认继续决定当前值。未知选项保留其原文,不按列表位置猜测权限,也不把 Claude 的五项折叠成三项。不新增状态、资源或运行时写入。 +- **权限模式呈现**:2026-10-24 用户要求权限模式跟随界面语言。已固定版本适配器的模式按“上游 ID + 上游英文名”同时匹配后显示产品译名:Claude Agent 的 `default`/`acceptEdits`/`plan`/`auto`/`bypassPermissions`(Manual、Accept edits、Plan、Auto、Bypass permissions),Codex 的 `read-only`/`agent`/`agent-full-access`(Ask for approval、Approve for me、Full access);两项都匹配才替换,因此其他 Agent 同样叫 `default` 或 `plan` 的模式、上游改名后的模式及未知模式仍显示 Agent 原名,不会被误译。同一适配器的已知模式全部翻译,不会出现个别选项与其余名称混用语言的情况。模型和思考强度仍使用上游名称。模式入口采用纯文本与向下箭头,菜单复用 LitheMenu 的紧凑单行选项与左侧勾选;删除卡片式逐项图标、双行说明及独立滚动/尺寸测量。当前模式说明留在入口 tooltip,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 通过 `windows/tauri/src-tauri/src/agent.rs` 直接依赖同一个 crate。命令和事件的 JSON 形状由 `shared/fixtures/agent/acp-events-v1.json` 固定。 - **按需启动,跟着项目走**:`LitheAgentConversationModule` 是内置可选模块,默认禁用。每个项目有自己的模块运行时,所以会话天然属于项目。切换标签或窗口不会结束任何会话,后台项目的这一轮会继续跑完;只有关闭项目、关闭功能或退出应用时才停止 Agent。后台项目的会话在等待权限时,项目标签上会显示提醒点。 -- **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 订阅由用户自己的 Claude Code 负责**:维护者决定 Claude 与 Codex 一样支持「API Key / 官方订阅」两种模式(`AgentAuthentication.claudeSubscription`)。Lithe 接入的是上游 ACP 适配器 `claude-agent-acp`,它运行用户本机安装并已登录的官方 Claude Code CLI。订阅模式下 Host(`claude_subscription.rs`)只做三件事:启动已安装的适配器,并用 `CLAUDE_CODE_EXECUTABLE` 指向检测到的 CLI;不传 `gateway`、不注入 `_meta.claudeCode.options`,因此不覆盖 CLI 自己的登录;从 Lithe 继承的环境里移除 `ANTHROPIC_API_KEY`、`ANTHROPIC_AUTH_TOKEN`、`ANTHROPIC_BASE_URL`、`ANTHROPIC_MODEL`、`CLAUDE_CODE_OAUTH_TOKEN` 和继承的 `CLAUDE_CODE_EXECUTABLE`,避免它们悄悄改变计费来源。初始化后读取适配器推送的 `_auth/status_update`(适配器在 initialize 时运行 `claude auth status --json`):`kind == "none"` 时以固定文案失败,提示在终端运行 `claude` 并 `/login`;8 秒内没有报告视为未知,继续连接,由会话请求暴露真实认证失败。Lithe 不发起浏览器登录,不读取、复制或刷新任何令牌,也不传上游的 `--hide-claude-auth`。订阅只属于各自的适配器:Claude 订阅不能用于 Codex,反之亦然,解析时直接拒绝;订阅模式不启用模型调用统计代理(没有可观察的推理地址),也不显示 Codex 的额度。Anthropic 对第三方产品提供 claude.ai 登录有授权要求,是否对外宣传此模式由维护者决定。 +- **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。 @@ -114,9 +115,9 @@ Markdown 表格的表头和正文必须完整展示:长内容在单元格内 复制单个表格时提供 Markdown 和 HTML 两种剪贴板表示,去掉行内样式后必须转义字面 Markdown 语法、反斜杠与 HTML 实体起始符,单元格换行和边缘空白使用数值字符引用保留,避免拆成表外段落或被表格解析器裁掉;例如代码单元格中的 `**literal**` 再次粘贴渲染后仍保留星号,不能变成加粗文字。中文加粗补偿同样不能重新解释转义或实体生成的星号;链接标签保留 Foundation 的解析结果,不能跨两个独立链接配对字面 `**`,否则 `src/**/foo.swift` 一类路径会丢失星号。文件链接只解码一次:`file://` 已有解码后的 `URL.path`,不能再调用百分号解码,也不能从这个路径删除 `#L` 锚点,否则文件名中的 `%20` 或 `#` 会改变;锚点读取自 URL 的独立 fragment。`AgentMarkdownTests` 通过表格导出后重新解析、实体星号和特殊文件名链接验证这些行为,`AgentComposerPasteTests` 挂载真实输入框验证窗口与编辑器归属。上述处理只使用内存,不新增磁盘资源或 bundle 写入。 -转录行抽成按数据比较的 `AgentTranscriptRows`:外层视图会随额度、会话列表、统计等任意发布重新求值,行列表只在消息、已完成轮次、搜索词、推理展开状态变化时才重新分组和比对,滚动时不再被无关发布打断。工具调用参照 Claude 的展示:转录里是一行浅色摘要(按 ACP `kind` 统计“运行了 N 个命令,读取了 N 个文件”),展开为圆角列表,每行一个调用、点开看证据;长列表先显示 8 行。转录内的工具组不再嵌套纵向滚动视图(每组一个 NSScrollView 是滚动卡顿来源),只有活动弹窗保留四行高度的内部视口。订阅登录不经过本地代理,上下文明细和模型调用统计的说明在订阅模式下改为解释原因,而不是提示开启设置。 +转录行抽成按数据比较的 `AgentTranscriptRows`:外层视图会随额度、会话列表、统计等任意发布重新求值,行列表只在消息、已完成轮次、搜索词、推理展开状态变化时才重新分组和比对,滚动时不再被无关发布打断。工具调用在展开的整轮过程区内复用原展示:相邻工具是一行浅色摘要(按 ACP `kind` 统计“运行了 N 个命令,读取了 N 个文件”),展开为圆角列表,每行一个调用、点开看证据;长列表先显示 8 行。转录内的工具组不再嵌套纵向滚动视图(每组一个 NSScrollView 是滚动卡顿来源),只有活动弹窗保留四行高度的内部视口。订阅登录不经过本地代理,上下文明细和模型调用统计的说明在订阅模式下改为解释原因,而不是提示开启设置。 -编辑区内消息内容与输入区共用 80% 居中内容列,输入框的左右 8pt 内边距包含在列内。`AgentEditorColumn` 通过 SwiftUI 的局部布局协议直接从父级尺寸计算列宽和居中位置,不把连续缩放结果写入状态;标题栏和消息滚动容器保持面板整宽,原生滚动条仍在面板右缘。消息滚动区会扣除原生滚动条自身的占位,因此列宽以各自实际可用宽度为准;原生回归从 SwiftUI 内容文档测量列宽与居中位置,不能用可能带额外留白的裁剪视口宽度替代。未配置页也只约束正文与输入框,标题工具栏保留整宽,否则其最小宽度会把窄列撑偏。工具窗口仍使用自身可用宽度,并为右侧导航保留间距。固定 716pt 上限和固定左右 90pt 留白均被否决:用户要求列宽随窗口按比例变化,宽窗口不应留下过多空白。80% 来自用户明确的构图要求,不是 Community 公开 AI 插件常量;代价是超宽窗口的文字行会随面板增长。 +编辑区内消息内容与输入区共用 80% 居中内容列,输入框的左右 8pt 内边距包含在列内。`AgentEditorColumn` 通过 SwiftUI 的局部布局协议直接从父级尺寸计算列宽和居中位置,不把连续缩放结果写入状态;标题栏和消息滚动容器保持面板整宽,原生滚动条仍在面板右缘。消息滚动区会扣除原生滚动条自身的占位,因此列宽以各自实际可用宽度为准;原生回归从 SwiftUI 内容文档测量列宽与居中位置,不能用可能带额外留白的裁剪视口宽度替代。未配置页也只约束正文与输入框,标题工具栏保留整宽,否则其最小宽度会把窄列撑偏。工具窗口仍使用自身可用宽度,正文左侧只保留 12pt 基础边距,额外 28pt 只留在右侧供轮次导航使用;不要把右侧导航留白对称加到左侧,否则窄面板会白白损失正文宽度。固定 716pt 上限和固定左右 90pt 留白均被否决:用户要求列宽随窗口按比例变化,宽窗口不应留下过多空白。80% 来自用户明确的构图要求,不是 Community 公开 AI 插件常量;代价是超宽窗口的文字行会随面板增长。 切换图标使用 Community 915c3c26 的 expui/general/previewVertically.svg 及 dark 变体,16×16;按钮尺寸、选中/hover 与工具提示复用共享 owner。图标作为固定构建资源随包只读发布,不引入运行时下载或缓存。Community 提供通用标签/图标/工具窗口规则,AI 插件的聊天迁移页面未在公开仓库找到,不把当前页面组合与字号布局冒充它的私有源码常量。 @@ -173,9 +174,11 @@ macOS 的审批模式、模型和思考强度在等待、推理、输出、工 除自行附加的项目上下文在用户历史和自动标题中的呈现过滤外,共享 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 回复。活跃思考块的标题显示“思考中…”,底部等待行改为“回复中…”并保留本轮计时,避免重复提示;取消时仍显示“正在停止…”。 +- **思考与执行过程**:`agent_thought_chunk` 继续存为独立的 `thought` 角色,不改写上游记录或导出内容。macOS 与 Windows 在呈现时,把同一轮的思考、工具调用和工具前后的过程说明合成一个“思考过程”入口,默认收起,流式时也不自动展开;收起后只保留这一行入口与后面的回复,不为内部消息预留行或高度。展开后按原顺序显示正文与相邻工具组,内部思考不再各带一层标题;过程区复用消息列表的滚动,不再放进固定高度的推理卡片。 + ACP 没有独立的最终回复与过程说明角色,因此按已观察到的位置分组:最后一条思考或工具以及它之前的模型文字属于过程,它之后的模型文字作为回复直接展示。用户消息与本地已记录的轮次结束各自切断分组,不能跨轮合并;没有思考和工具的直接回答不产生空入口,纯空白文字不产生行。例如“思考 → 先读 README → 读取文件 → 思考 → 最终回答”显示为一个过程入口加最终回答;不要为了合并而删除过程说明或把原始消息改成新的角色。流式模型文字先作为末尾回复呈现,若后续又收到工具或思考,才随新证据移入同一个过程;不猜测文字内容,也不声称上游提供了最终回复标记。 + 搜索命中时临时展开匹配过程与工具组,搜索期间不允许收起命中文字,清空后恢复手动偏好。macOS 偏好继续由 `AgentTranscriptView` 在视图局部内存中按会话与过程首条原始消息 ID 保存,Windows 复用会话对应的 surface store;不能让会被过滤销毁的行拥有该偏好。会话间互不干扰;macOS 在关闭会话或删除原始消息后释放,Windows 沿用工作区 surface store 重置时清理的生命周期。子代理内的独立思考卡片也默认收起,保留其有界内部视口。导出 Markdown 仍以引用块保留思考,历史消息数仍只统计用户与 Agent 回复;不新增持久化、下载或发行包写入,不影响签名和 Sparkle delta。 - **计划(`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 模式,不新增全局位置状态或鼠标监听。输入区的最小高度包含上下栏、可编辑行和外边距,附带文件时再预留附件行;这个值只在附件有无改变时通过视图偏好更新,不跟随拖动逐帧发布。不要让固定高度的命令列表参与分隔条的最小尺寸计算,也不要把逐帧几何尺寸发布到会话模型。 +- **斜杠命令(`available_commands_update`)**:按会话保存上游列表,最多读取前 200 条;同名命令只保留首条定义,保证命令行身份唯一。断连时清空每个会话的旧命令,重连加载后等待新进程重新上报,避免显示已经失效的能力。输入框以 `/` 开头且命令名中还没有空白时弹出过滤结果,前缀匹配排在包含匹配之前;选择后只把 `/name ` 填入草稿,命令文本仍作为普通 prompt 原样发送,由 Agent 自己解释。实测 codex-acp 1.13.1 把 Skill 以 `$name` 形式放进同一个命令列表,而它的命令解析会跳过 `/$name`,Skill 需要以 `$name` 提及。因此名字以 `$` 开头的命令补全为 `$name `;输入 `$` 只列 Skill,Agent 没有上报 Skill 时 `$` 仍是普通文字。对话补全不扫描 SKILL.md 或命令目录,也不内置命令表,避免和 Agent 的真实能力不一致。独立技能设置页可以展示原生发现结果或明确标注的目录清单,但不得据此伪造会话补全和启用状态;管理边界见 [技能管理](../feature/2026-10-20-agent-skill-management.md)。方向键和 Tab 导航依赖 macOS 14 的 `onKeyPress`;macOS 13 保留鼠标选择和 Return 补全。Return、Esc、方向键与 Tab 共用视图局部的补全状态,Return 在命令名未完整时只补全、完整时发送;Esc 先关闭补全,再次 Esc 才停止回复,编辑草稿后重新显示匹配。补全高度由输入区内部的局部布局容器按剩余空间限制,先保留默认两行可编辑文字;最小输入区放不下一整行命令时,列表浮在输入框上方,通过锚点偏好在整个对话布局中绘制,让鼠标点击范围不被较小的输入面板限制;沿用工作台悬停提示的局部 overlay 模式,不新增全局位置状态或鼠标监听。输入区的最小高度包含上下栏、可编辑行和外边距,附带文件时再预留附件行;这个值只在附件有无改变时通过视图偏好更新,不跟随拖动逐帧发布。不要让固定高度的命令列表参与分隔条的最小尺寸计算,也不要把逐帧几何尺寸发布到会话模型。 - **模式通知(`current_mode_update`)**:Agent 自己切换模式(例如退出计划模式)时,只更新 `category == "mode"` 且包含该值的配置选项,不向上游回发设置请求;选项中不存在的模式 ID 忽略,不显示原始 ID。 - **即时通知(`notice`)**:共享 Host 启用固定 SDK 的 `unstable_session_notices` 能力,并声明 `clientCapabilities.session.notices: {}`,让适配器通过独立事件发送警告。macOS 每个会话只保存最新一条通知,用现有 `LitheBanner` 展示和关闭;通知不计入回复、历史导出或轮次统计,下一条 prompt 成功提交及断连时清除。上游通知字段限制为标题 256 字符、详情 4096 字符,未知严重程度和空标题忽略。 @@ -424,7 +427,7 @@ Windows 连接桥已接入共享 Host,面板 UI 也已落地(活动栏入口 - `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'AgentConversationPresentationTests|EditorTabOrderFeatureModelTests.*editorChatCenters'`:在生产输入组件中改变草稿,验证默认两行、四行自然增长、长草稿封顶且末尾可滚至、附件增高及删除/清空后收缩;同一原生窗口从 1100pt 缩至 320pt 后按新宽度换行,工具窗口和编辑页分别覆盖。布局等待只驱动原生事务并观察最终尺寸,每个阶段最多两秒,窗口和内容通过 `defer` 释放。列宽回归在 320/900/1400pt 下检查真实底栏位置,要求编辑页为 80% 居中列、工具窗口使用可用宽度。 -- `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'AgentTranscriptNavigationTests|AgentTranscriptScrollTests'`:真实原生会话视图在独立子进程中驱动四轮溢出历史、工具更新、编辑/执行权限、最终统计与窄宽窗口。宽编辑页另检查滚动容器到达面板右边缘,触发实际轮次导航按钮后首条消息进入视口,阅读列保持可用宽度的 80%;原生视口测试覆盖两轮同时可见、行卸载和已经排队的底部跟随取消。验证主动浏览不被拉走,权限卡片不遮住最新回复,回到底部后继续跟随;子进程有八秒期限,布局死循环也能由父进程结束并报告最后阶段。回放使用独立偏好域并隐藏详情栏,避免用户保存的布局改变消息面板边缘断言;辅助功能测试只启用当前进程的界面树并恢复原值。直接启动当前测试运行器并继承其环境,不能经 `/usr/bin/env` 中转,否则系统可能清除 XCTest 所需的动态库路径。最终窗口宽度变更后的底部检查最多等待两秒的原生布局事务,仍要求差距小于2pt,不强制滚动或放宽精度。完整工作区双窗口回放额外覆盖原卡点与连续面板宽度变化,不把合成事件回放等同于真实模型端到端通过。macOS 13 的源码兼容性与最低部署目标保留,旧系统运行仍须在对应系统复验。 +- `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'AgentTranscriptNavigationTests|AgentTranscriptScrollTests'`:真实原生会话视图在独立子进程中驱动四轮溢出历史、工具更新、编辑/执行权限、最终统计与窄宽窗口。宽编辑页另检查滚动容器到达面板右边缘,触发实际轮次导航按钮后首条消息进入视口,阅读列保持可用宽度的 80%;原生视口测试覆盖两轮同时可见、行卸载和已经排队的底部跟随取消。工具窗口在深浅主题及 280/350/486/620pt 连续缩放中检查实际消息行边界,要求左侧 12pt、右侧 40pt,让正文用上左侧回收的空间并保留导航余量。验证主动浏览不被拉走,权限卡片不遮住最新回复,回到底部后继续跟随;子进程有八秒期限,布局死循环也能由父进程结束并报告最后阶段。回放使用独立偏好域并隐藏详情栏,避免用户保存的布局改变消息面板边缘断言;辅助功能测试只启用当前进程的界面树并恢复原值。直接启动当前测试运行器并继承其环境,不能经 `/usr/bin/env` 中转,否则系统可能清除 XCTest 所需的动态库路径。最终窗口宽度变更后的底部检查最多等待两秒的原生布局事务,仍要求差距小于2pt,不强制滚动或放宽精度。完整工作区双窗口回放额外覆盖原卡点与连续面板宽度变化,不把合成事件回放等同于真实模型端到端通过。macOS 13 的源码兼容性与最低部署目标保留,旧系统运行仍须在对应系统复验。 - `node --test scripts/test-codex-config-relay.mjs`:用受控协议流及进程替身验证创建/恢复/分叉配置、不受点号覆盖影响、其他供应商和工具消息照传、UTF-8 拆包、原生轮次与错误信息、格式损坏的有界停止以及 Windows npm cmd 的启动分支。同时验证恢复票据、真实原生快照、累计量不冒充占用、未知/失败保留原恢复路径、订阅重试不变。此测试进入两端 CI;模拟 Windows 分支不等于 Windows 原生运行验收。 @@ -443,7 +446,7 @@ Windows 连接桥已接入共享 Host,面板 UI 也已落地(活动栏入口 - `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'AgentActivityTests|AgentEditRestorerTests|AgentActivityPresentationTests'`:检查合并、上游统计缺失、部分更新、确认后的新基线、会话隔离、部分失败及取消;临时工作区验证逆序恢复、编码、唯一片段、歧义和重叠匹配、删除冲突及路径越界。原生鼠标验证三项入口、打开文件和保留回调;任务回归覆盖没有计划但已有工具调用、计划到达后显示 1/3、空计划及无计划恢复空态,普通工具不计为子代理,原生子代理展开实际输出且状态刷新不关闭面板,并保留深浅主题及中英文环境的渲染截图;从弹出面板打开真实差异 sheet 与回撤确认,确认后只提交捕获的文件选择;同步编辑器最后一次输入后拒绝脏缓冲区,测试窗口与临时目录均清理。生产包另执行 `./scripts/verify-runtime-bundle-immutability.sh`。 -- `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter AgentThoughtTranscriptTests`:在生产对话容器中通过鼠标展开/收起思考,搜索隐藏行后进入空会话使列表实际销毁,再返回并清空搜索,确认手动选择保留;同时验证命中时保持文字可见、清空后恢复折叠、会话切换独立和关闭后重新加载的默认状态。渲染只等待可观察的原生画面,每次等待最多两秒;比较同一宿主的完整画面,不依赖系统字体快照基线。窗口和功能模型在成功、失败路径都清理。 +- `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'AgentThoughtTranscriptTests|AgentConversationPresentationTests'`:验证整轮思考、过程说明和工具仅有一个默认折叠入口,最终回复保留在外,空白片段不占行;在生产对话容器中通过鼠标展开/收起思考,搜索隐藏行后进入空会话使列表实际销毁,再返回并清空搜索,确认手动选择保留;同时验证命中时保持文字可见、清空后恢复折叠、会话切换独立和关闭后重新加载的默认状态。渲染只等待可观察的原生画面,每次等待最多两秒;比较同一宿主的完整画面,不依赖系统字体快照基线。窗口和功能模型在成功、失败路径都清理。 - `./.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 验证无标题通知、静默等待不猜状态、输出恢复、工具/权限/取消优先级、后台会话隔离、迟到通知和失败/断连清理;注入可推进的单调时钟验证排队、后台会话、取消确认、失败和断连;共享 fixture 校验用量、零值与缺失值,统计行的分组边界与历史回放不伪造。真实 Agent 的 token 口径和连续计时仍需人工验收,不能以合成数据截图代替供应商运行验证。 @@ -451,7 +454,7 @@ Windows 连接桥已接入共享 Host,面板 UI 也已落地(活动栏入口 - `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter AgentBrandIconResourceTests`:临时安装包布局覆盖 Codex/Claude 图标加载、资源包和图标缺失时安全回退、缓存隔离,比较读取前后的文件清单与内容,确认不改写发行资源;原生输入框 Menu 的像素验证同时覆盖 Claude/Codex 和深浅主题,确保橙色/灰色标志实际可见,染色不改变共用模板。 - 输入框的图标裁图与边距回归按辅助功能标识定位真实控件,不依赖按钮顺序或固定横向偏移;添加附件等操作后仍检查同一产品边界。原生辅助功能树只能在布局后读取,因此使用两秒单调时钟期限并在退出时恢复增强辅助功能设置,不用睡眠等待。macOS unit CI 启用已有截图开关,将受控夹具的输入区、Effort 和紧凑摘要深浅主题及窄宽布局随测试报告上传;截图属于当前 revision 的验证证据,不能复用为其他工作树的构建输入,也不能代替真实供应商工作流验收。 - 订阅新增测试覆盖旧配置兼容、显式登录、已有账号、认证通知顺序、登录取消和超时、账号变更、额度真实窗口/缺失值/多 bucket、仅查询不发送 prompt,以及临时失败保留旧值。Linux 已运行 Agent Host 的逐测试计时套件;macOS Swift 编译、真实账号登录及深浅主题/窄宽布局仍须在目标环境验证,不将代码存在等同于运行验证。 -- 真实账号验收:先用 API Key 对话,空闲切换 Codex 订阅,确认请求使用本机账号;未登录时确认打开面板不会启动浏览器,点击登录与取消正确;上下文右侧额度每分钟更新,断网后灰显,换账号后旧值清除,关闭项目不残留探测进程。Claude 不出现订阅入口。Windows 已有共享协议、连接桥与面板 UI,但订阅登录、供应商切换、额度展示、CLI 升级与配置导入的真机验收仍未执行,真实窗口里的系统与项目树拖入(含拖入高亮、文件夹引用与中英词条)及 `@` 选择器也只做过合成事件回归,连接桥还需要在 Windows 上做一次真实适配器的端到端验收。 +- 真实账号验收:先用 API Key 对话,空闲切换 Codex 订阅,确认请求使用本机账号;未登录时确认打开面板不会启动浏览器,点击登录与取消正确;上下文右侧额度每分钟更新,断网后灰显,换账号后旧值清除,关闭项目不残留探测进程。Claude 订阅:本机 Claude Code 已登录时直接对话,退出登录后面板提示用 /login 登录且不打开浏览器。Windows 已有共享协议、连接桥与面板 UI,但订阅登录、供应商切换、额度展示、CLI 升级与配置导入的真机验收仍未执行,真实窗口里的系统与项目树拖入(含拖入高亮、文件夹引用与中英词条)及 `@` 选择器也只做过合成事件回归,连接桥还需要在 Windows 上做一次真实适配器的端到端验收。 - `cargo test -p lithe-agent-host --manifest-path rust/Cargo.toml` - `cargo test --manifest-path windows/tauri/src-tauri/Cargo.toml agent::`(Windows 连接桥:非法启动配置、空或重复连接 ID、启动失败上报、未知连接与非法命令、按窗口释放连接、跨窗口 send/close 被拒且连接仍在、关闭计数在移出连接时即生效、出口等待在有关闭时挂起并在最后一个关闭结束时返回、超时后放弃等待、退出 drain 后拒绝新连接;不需要真实 Agent 或网络。进程树回收用 `fake_acp_adapter` 假适配器覆盖两条路径——关闭连接、销毁窗口——断言外层进程和继承 stdout 的孙进程都已退出) diff --git a/.agents/notes/implemented/architecture/2026-10-12-tool-window-docking.md b/.agents/notes/implemented/architecture/2026-10-12-tool-window-docking.md index 121eab8561..1ce93c11a3 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-tool-window-docking.md +++ b/.agents/notes/implemented/architecture/2026-10-12-tool-window-docking.md @@ -73,7 +73,7 @@ macOS 的工具窗口位置由工作台维护,功能模块继续维护自己 ## 后果 收益是现有内容可以在不同区域显示,位置、顺序与尺寸可以重启恢复。 -代价是新增内置工具窗口必须提供稳定标识和默认布局;Windows 尚未同步此次移动能力,功能矩阵明确记录差距。 +代价是新增内置工具窗口必须提供稳定标识和默认布局;Windows 目前只支持终端底部与右侧互移,完整分组和排序的差距由功能矩阵记录。 移动可能重建原生呈现容器,但功能状态仍归原模块所有;终端与编辑器等原生资源必须继续遵守既有挂载生命周期。 ## 验证 @@ -94,6 +94,41 @@ macOS 的工具窗口位置由工作台维护,功能模块继续维护自己 - `macos/Sources/Lithe/Services/Workbench/WorkbenchLayoutStore.swift` - `macos/Sources/Lithe/Views/Workbench/` - `macos/Tests/LitheTests/ToolWindowDockingTests.swift` +- `windows/tauri/src/features/layout/components/terminal-dock-layout.tsx` +- `windows/tauri/src/features/layout/components/bottom-pane/bottom-pane.tsx` + +## Windows 终端入口拖动与左、右、底部停靠 + +Windows 可直接拖动活动栏的终端图标,停靠到左侧、右侧或底部;入口随位置移动到对应侧栏, +底部使用左侧入口。终端隐藏或其他工具活动时也能开始拖动,只有有效释放才打开终端, +普通点击或键盘激活仍切换显隐。两侧活动栏与内容布局处于同一拖动上下文,侧栏本身也是落点, +因此在右下角释放会选择右侧,而不会误判为底部。终端工具栏的移动按钮保留为补充入口。 +活动栏有效释放后先提交面板显隐和活动工具,React 完成 DOM 更新后才请求创建或聚焦终端; +不能在同一个 pointerup 处理器里对仍隐藏的 xterm 同步 focus。待激活请求绑定当前工作区, +提交期间切换项目或工具会丢弃请求,不使用固定延时,也不重新挂载终端。 +Esc、窗口失焦、窗口大小变化、无效区域释放、 +切换项目及卸载都会取消拖动,不保存位置。落点按拖动开始时的工作区和面板实际边界计算, +底部角落优先保留来源边,避免底部水平拖动突然跳到右侧。 + +布局使用 CSS Grid(网格布局)的区域名移动同一份 BottomPane,不能把它分别放到两个条件分支。 +例如正确做法是只把 panel 区域从底行改到左列或右列;不要销毁 TerminalContainer 再在侧边重建它, +否则会重复注册创建终端的监听器并丢失局部分屏状态。改变底部全宽/编辑区宽度、停靠、 +隐藏和再次展开都保留容器;PTY(终端进程连接)和 xterm 输出仍由原来的 TerminalHost 管理。 +原有终端标签页的拖动继续用于排序或送入编辑器,与整个面板的移动按钮分开。 + +位置、侧边宽度和底部高度使用现有 Windows 设置存储保存,作用于当前应用设置, +并非 macOS 的按工作区六组布局。旧设置默认底部、400px 宽、320px 高,未知位置安全回到底部; +尺寸在加载和使用时分别校验,并按剩余工作区限制。持续调整复用 document-resize-session, +每帧只改局部 DOM,结束才保存;切换位置/项目及卸载清理监听器。左侧分隔条放在面板右边缘, +向右拖动增加宽度;保持同一 DOM 子节点顺序,通过 CSS 排序移动分隔条,防止终端因换位而重建。 +其他底部工具仍使用原来的单活动工具语义,不支持和终端同时展开,也不随终端移到侧边。 +本次没有新增运行时资源目录或原生协议。 + +验证:Windows Frontend 计时 harness 运行 +`windows/tauri/src/features/layout/components/bottom-pane/bottom-pane-terminal-lifecycle.test.tsx`, +覆盖真实左右活动栏入口与 BottomPane 的三边停靠、预览先于保存、取消/迟到释放、宽度边界、 +全宽切换、隐藏再打开、输入 DOM 和请求监听器保留;设置归一化测试覆盖旧数据和损坏数据恢复。 +这些组件测试不等同于 Windows WebView2 实机验收,矩阵保持 pending。 拖动回归补充:`ToolWindowDockingTests.horizontalDragUsesTheBottomPanelHeightAndActualSplitBounds` 覆盖底部平移、编辑区排除、遮罩保持实际高度及不等宽双组; diff --git a/.agents/notes/implemented/architecture/2026-10-12-workbench-ui-contributions.md b/.agents/notes/implemented/architecture/2026-10-12-workbench-ui-contributions.md index 4254e6f9d7..0c627ad9ea 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-workbench-ui-contributions.md +++ b/.agents/notes/implemented/architecture/2026-10-12-workbench-ui-contributions.md @@ -28,7 +28,7 @@ Run、Debug 和 Stop 的主工具栏入口也使用宿主贡献 ID,与插件 多个窗口属于同一模块时,运行时合并在途构造。取消一个 UI 请求只撤回这个窗口的显示意图,另一个窗口仍可等待同一次模块激活。禁用和关闭先阻止新激活,再取消并等待已有激活清理,完成后才释放资源;晚到的激活不能重新发布能力和贡献。宿主发出 `module.activation-joined` 事件使这种等待可观察,正常取消不显示错误通知。这是宿主生命周期行为,不允许插件在清单里运行状态脚本。 -声明式 UI 目前只支持宿主渲染的静态文本窗口和打开本插件窗口的命令。窗口和命令可以声明宿主图标的稳定语义 ID,或包内 SVG/PNG 及可选亮暗变体。宿主统一图标槽位、着色和交互状态;命令面板选中行的模板图标使用宿主选中文字颜色,键盘移动选择时品牌图标仍保持对应亮暗资产的原色;品牌模式只保留资产原色,不改变外层按钮规范;宿主图标声明 original 模式时同样使用原生资源原色,缺少打包资源的调试运行仍使用宿主占位图标。工作台中的窗口与工具栏管理子菜单也复用已注册图标,通过共享下拉组件呈现,不单独定义菜单样式。内部旧 `icon` 字符串仍属于原生模块元数据,声明式字段通过有类型的 `uiIcon` 接入,不能把它解释成 SF Symbol 或任意资源路径。这个阶段范围保证第一条接入路径可以完整验证。API v2 的窗口必填文本、命令必关联窗口,以及无语言/无 UI 的拒绝规则继续保持兼容;这些规则不应复制到下一版公共接口。后续版本化原生组件、独立动作和领域 Provider 的设计见[公共扩展契约与原生 Provider 接入方向](../../proposed/architecture/2026-10-13-public-extension-contract-and-provider-api.md),当前尚未实现。 +API level 2 的声明式 UI 保持宿主渲染的静态文本窗口和打开本插件窗口的命令。ui 3 已新增树/列表、面板工具栏和右键菜单,范围与限制见[声明式原生插件树与动作](../feature/2026-10-21-declarative-native-plugin-ui.md)。窗口和命令可以声明宿主图标的稳定语义 ID,或包内 SVG/PNG 及可选亮暗变体。宿主统一图标槽位、着色和交互状态;命令面板选中行的模板图标使用宿主选中文字颜色,键盘移动选择时品牌图标仍保持对应亮暗资产的原色;品牌模式只保留资产原色,不改变外层按钮规范;宿主图标声明 original 模式时同样使用原生资源原色,缺少打包资源的调试运行仍使用宿主占位图标。工作台中的窗口与工具栏管理子菜单也复用已注册图标,通过共享下拉组件呈现,不单独定义菜单样式。内部旧 `icon` 字符串仍属于原生模块元数据,声明式字段通过有类型的 `uiIcon` 接入,不能把它解释成 SF Symbol 或任意资源路径。这个阶段范围保证第一条接入路径可以完整验证。API v2 的窗口必填文本、命令必关联窗口,以及无语言/无 UI 的拒绝规则继续保持兼容;这些规则不应复制到下一版公共接口。后续版本化原生组件、独立动作和领域 Provider 的设计见[公共扩展契约与原生 Provider 接入方向](../../proposed/architecture/2026-10-13-public-extension-contract-and-provider-api.md),当前尚未实现。 Agent 的配置入口由同一个目录补齐,移动到任何已有停靠区域后仍可找到;禁用 Agent 只停止模块资源,保留宿主配置说明。Services 的显示动作复用数据库按需激活入口,激活成功后只展开 Services,不额外打开数据库侧栏。动作可用性与内容可渲染性分别判断:未激活的 Services 仍能请求 Show,实际停靠内容必须同时满足 renderer 的可见/选中条件和动作可用性;旧布局中的 Maven 在非 Maven 项目不占据停靠区域。 diff --git a/.agents/notes/implemented/architecture/2026-10-13-java-language-server-as-declarative-plugin.md b/.agents/notes/implemented/architecture/2026-10-13-java-language-server-as-declarative-plugin.md index 99beece36e..657e05ee2b 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-java-language-server-as-declarative-plugin.md +++ b/.agents/notes/implemented/architecture/2026-10-13-java-language-server-as-declarative-plugin.md @@ -126,6 +126,15 @@ dev.lithe.plugin.java-support/ - 不要为了恢复某个语言的调试或测试,把能力目录的合并规则改成“插件没声明就保留宿主原有能力”,也不要在目录或注册表里按 `java` 放行。前者让 PHP、Go 的插件停用后宿主重新运行内置工具,后者是按名字的特判。 - 不要往插件包里写任何东西。可写状态用 `${stateDir}`、`${workspaceStateDir}`,或像 JDT 那样由集成放进宿主缓存。 +### Java 调用补全留空实参 + +构造器和方法补全由共享 JDT 适配器设置 `java.completion.guessMethodArguments: "off"`,Windows 和 macOS 一致。输入 `new No` 后接受 Node 候选得到 `Node()`,括号内保留光标,由用户填写实参;方法候选仍可展示签名,自动导入和其他语言的 snippet 不受影响。初始化、配置通知及按 section 拉取配置必须返回相同值,防止 JDT LS 后续恢复参数占位。 + +来源是当前固定的 [JDT LS v1.61.0](https://github.com/eclipse-jdtls/eclipse.jdt.ls/tree/v1.61.0):`Preferences.java` 默认选择 `INSERT_PARAMETER_NAMES`,`CompletionProposalReplacementProvider.appendGuessingCompletion` 在 `OFF` 时只插入光标。正确做法是使用该上游设置,同时保留插件声明的其他 completion 项;不要在 Monaco 中用正则删除参数或禁用通用 snippet。后两种方案会误伤方法体模板、其他语言和补全解析后的自动导入。代价是 Java 默认不再提供可逐个替换的参数名占位,JDT LS 也会将构造器重载候选合并为类型候选;这次不增加新的设置界面。 + +`java_completion_leaves_arguments_empty_across_configuration_channels` 和 `shared/fixtures/lsp/java-completion-v1.json` 验证三个配置入口及插件其他 completion 项的保留;实机验收仍需分别确认两端 Tab 接受候选后的位置和自动导入。 +运行会话保留初始化时插件声明的 completion 对象,配置通知和 `java`、`java.completion` 及其叶节点请求均从同一份对象应用 `guessMethodArguments=off`。例如插件声明的 `favoriteStaticMembers`、`filteredTypes` 不得在初始化后被通知或拉取结果清空;引擎测试同时检查真实会话接线,不能只用默认设置证明三个入口一致。 + ## 考虑过的备选方案 ### 只把文件搬进插件包,宿主继续扫描目录 diff --git a/.agents/notes/implemented/architecture/2026-10-16-windows-project-commit-panel-parity.md b/.agents/notes/implemented/architecture/2026-10-16-windows-project-commit-panel-parity.md index d027033653..77156dd6eb 100644 --- a/.agents/notes/implemented/architecture/2026-10-16-windows-project-commit-panel-parity.md +++ b/.agents/notes/implemented/architecture/2026-10-16-windows-project-commit-panel-parity.md @@ -14,7 +14,7 @@ Windows 项目树区分行选择与编辑器活动文件,提交列表区分行 - 提交面板以当前 `ChangesSidebarView.swift`、`GitChangelistBar.swift`、`CommitAreaView.swift`、`CommitMessageEditor.swift` 及 `LitheTheme.Commit` 为基准。未知版本截图只辅助检查,不能代替这些组件的结构和状态归属。页签使用 28px 圆角选中面、41px 标题栏;40px 操作栏先于 ChangeList,依次为刷新、丢弃选中改动、暂存当前列表、预览首项。Windows 既有比较来源和目录切换保留在紧凑菜单中。 - 默认更改列表与命名列表一视同仁,已跟踪和未跟踪文件放在同一列表分组,多仓库仍先分仓库。仓库标题使用普通树行,显示仓库名和计数,完整路径留在提示中;复选框放在图标前。新配置默认平铺,用户已有目录显示偏好继续保留;宽度不足 300px 时隐藏文件父路径。分组复选框只改该组,工具栏批量暂存只改活动列表;行选择不等于暂存。预览按钮始终打开首项的完整 Diff,不受文件行点击偏好影响。父仓库引用选项由 `GitView` 持有,在列表选择器下方显示,并传给提交审核;重试继续使用原计划的选项。 -- 提交面板与提交历史(Git Log)文件树的同级排序对齐 intellij-community 的 TreeModelBuilder:目录节点先于文件节点(排序权重 5 < 6/7),文件名比较复刻 FileNameComparator(即 NaturalComparator 的 ignoreCase + likeFileNames 语义:数字按数值而非字典序、忽略大小写、大小写仅作平级决断、'-' 与 '_' 等价,见 IDEA-80435)。实现放在 `natural-file-name-compare.ts`,经 `buildPathTree` 的 `compareNames` 参数接入;项目树与文件导航等其他 `buildPathTree` 消费方保持原有 localeCompare 排序,不随本决策改变。 +- 提交面板与提交历史(Git Log)文件树的同级排序对齐 intellij-community 的 TreeModelBuilder:目录节点先于文件节点(排序权重 5 < 6/7),文件名比较复刻 FileNameComparator(即 NaturalComparator 的 ignoreCase + likeFileNames 语义:数字按数值而非字典序、忽略大小写、大小写仅作平级决断、'-' 与 '_' 等价,见 IDEA-80435)。实现放在 `natural-file-name-compare.ts`,经 `buildPathTree` 的 `compareNames` 参数接入;项目树等其他 `buildPathTree` 消费方保持原有 localeCompare 排序,不随本决策改变。2026-10-22 起,Git 文件导航(Commit 面板单文件/批量 Diff 的文件箭头,以及 Git Log 提交预览、完整提交、范围和离散多选 Diff 的文件箭头)直接复用同一展示顺序,不再按 Core 的路径序;具体见 `2026-10-04-windows-commit-file-diff-preview.md`。 - 提交底部保留 Swift 的字体层级:列表、Amend、说明及提交按钮默认 13px,计数 12px,AI 11px;说明使用平台既有等宽编辑器字体,其他控件使用 UI 字体。Windows 延续用户字号缩放和系统字体,不把 macOS 的 Inter/JetBrains Mono 偷换成跨平台字体一致性结论。计数表达当前列表实际已暂存文件数,不能写成行选中数。 - 说明框默认和最小高度均为 50px,已有合法高度偏好不重置。常规底部约 124px;小于 300px 的可用宽度时,Amend 独占一行,AI 与计数放在下一行,底部两个提交按钮与设置保持同一行,长按钮文案可截断但保留完整无障碍名称。拖动仍每帧只写 CSS,结束时保存一次;上方保留 120px 的 ChangeList 区域加 40px 操作栏。AI 使用原始 `expui/diff/magicResolveToolbar.svg`,丢弃使用 `expui/vcs/revert.svg`,不改变其他功能的重做/撤销图标。 - 项目、提交、Stash 和 Dependencies 的树正文共用 `SidebarTreeRow` 的 `ui-text-sm`,默认 13px,并跟随界面字号设置;窗口舒适密度只调整工具栏等 chrome,不单独放大树正文。工具栏与辅助说明保留各自字号层级。刷新动作复用已有 `RefreshIcon` 对应的 IDEA `expui/general/refresh.svg`,不再借用 redo 图标;原有重做动作的图标映射不变。 diff --git a/.agents/notes/implemented/architecture/2026-10-17-build-system-plugin-contract.md b/.agents/notes/implemented/architecture/2026-10-17-build-system-plugin-contract.md index 1a84455315..8262826890 100644 --- a/.agents/notes/implemented/architecture/2026-10-17-build-system-plugin-contract.md +++ b/.agents/notes/implemented/architecture/2026-10-17-build-system-plugin-contract.md @@ -35,7 +35,7 @@ Rust Core 负责校验声明、识别工程、展开每一条命令行,并把 没有声明的能力不报错,也不会出现在界面上。例如 Cargo fixture 没有声明依赖树,Build 工具窗口就不显示依赖区。 -字段全集只写在 `shared/contracts/plugins/v1/README.md` 的 “API level 3: build systems” 一节和 `plugin-manifest.schema.json` 里,本 Note 不重复。四个 fixture(`cargo-build`、`go-build`、`gradle-build`、`maven-build`)是完整示例,`gradle-provider`、`mix-provider` 分别示范 Provider 的两种写法。 +字段全集只写在 `shared/contracts/plugins/v1/README.md` 的 “API level 3: build systems” 一节和 `plugin-manifest.schema.json` 里,本 Note 不重复。四个 fixture 各示范一种模型来源,按能力命名而不是按真实插件命名:`build-static-file`(读项目文件,内容用 Cargo)、`build-command-stdout`(解码命令输出,内容用 Go)、`build-command-output-file`(解码命令写出的文件,内容用 Gradle)、`build-host-integration`(交给宿主集成,内容用 Maven)。`build-provider`、`build-provider-language-server` 分别示范 Provider 的两种写法。 ### 2. Core 与宿主的分工 @@ -69,7 +69,7 @@ Rust Core 负责校验声明、识别工程、展开每一条命令行,并把 插件可以自由描述(不需要改 Core): - **任何构建工具**:ID、标记文件、任务、选项、运行入口、测试过滤器、诊断正则、依赖命令都是数据。 -- **可执行文件**:按偏好顺序列出候选,有四种: +- **可执行文件**:按偏好顺序列出候选,有四种基础来源: - `projectFile`:工程里的 Wrapper(例如 `gradlew`),可以要求某些文件存在。它一律被视为执行仓库代码。 - `environment`:某个环境变量下的相对路径,例如 `MAVEN_HOME` 下的 `bin/mvn`。变量必须列在 `environmentInputs` 里,宿主只传这些变量。 - `command`:在 `PATH` 上查找的裸命令名。 @@ -333,11 +333,11 @@ macOS 接入规则(2026-10-18): ## 验证 - `cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib build_systems`:四个 fixture 通过校验;`build-plans.json` 的全部标准答案;在临时工作区里验证 Cargo 运行入口与内置探测器逐字一致、嵌套、优先级与拒绝、Maven reactor 识别、Gradle 规范格式输出文件、JUnit 报告、同步指纹、Maven 任务参数与内置实现一致,以及互斥条件下允许重复引用选项。 -- `cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib build_systems` 还覆盖 Provider:`build-plans.json` 里 `gradle-provider`、`mix-provider` 的请求计划和 `result` 解码;`provider_processes_are_install_review_programs`(自有进程和共享写法在安装审查中的条目);`launch_plans_name_exactly_one_process`;`provider_models_are_not_read_during_detection`。 +- `cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib build_systems` 还覆盖 Provider:`build-plans.json` 里 `build-provider`、`build-provider-language-server` 的请求计划和 `result` 解码;`provider_processes_are_install_review_programs`(自有进程和共享写法在安装审查中的条目);`launch_plans_name_exactly_one_process`;`provider_models_are_not_read_during_detection`。 - `cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib declarative_plugins`:`invalid-manifests.json` 里的非法清单,包括社区插件声明 `hostIntegration`、未声明权限、路径越界、未保护的模板变量和选项重复放置,以及 Provider 的十种错误写法(没有来源使用、混写、缺少标志、缺少权限、入口越界、不支持的运行时、未声明的设置等),和裁剪后不再接受的九种写法(静态 `yaml`、命令 `json`、任务规则、顶层目标、`parent`、映射标签、`parentDirectory`、依赖 `children`、`layout.generated`);`command-plans.json` 里按 `buildSystemID` 选中的启动计划。 - `declarative_plugins::build_providers_require_their_own_host_capability` 与 `marketplace_plugins::build_provider_versions_require_host_support_before_download` 验证 Provider 能力与声明式构建分开,安装审查的共享 fixture 同时覆盖自有进程和共享写法。 - `cargo test -p lithe-core --lib lsp`:`lsp/interface/build_provider.rs` 验证只有宿主提供过的版本算协商成功、版本 1 只接受两个方法;`engine.rs` 的测试验证协商后的会话写出 `lithe/build/*` 并返回结果,未协商或方法不在版本里时直接失败、不写入进程。 -- `./scripts/test-macos.sh --filter 'BuildSystemServiceTests'` 的 Provider 用例:`providerModelSendsThePlannedRequestAndDecodesItsReply`、`providerDependenciesDecodeTheProvidersReply` 验证请求原样发给按插件、构建系统和工作区确定的目标,回复作为 `providerResult` 交给 Core 解码且不启动命令;`workspaceSwitchAndDisabledPluginStopTheirProviders` 验证停用插件和切换工作区停止进程、插件列表不变时保留;`providerModelFailsWithoutAProviderHost`、`failedProviderRequestReportsItsMessage` 验证失败路径。`providerModelLoadCarriesTheResolvedToolWithoutRefresh`、`providerReloadsAskForARefresh`、`providerLoadsWithoutTheToolWhenResolutionFails`、`commandModelLoadsDoNotResolveAProviderTool` 验证加载原因和工具路径:首次加载不带 `refresh`、重新加载的模型和依赖都带,工具只在 Provider 请求时解析,解析失败时请求照常发出且 Build Output 写明原因;`MacBuildProviderSessionsTests.providerHintsReachTheProcessUnchanged` 验证这两个字段原样到达进程,`PluginWebBridgeTests.pageRefreshReloadsTheModel` 验证网页的 `build.refresh` 算重新加载。`build-plans.json` 的 `gradle provider … refresh …`、`… tool must be an absolute path`、`gradle model command ignores …` 用例覆盖 Core 的写入、校验和命令来源忽略。`PluginMarketplaceServiceTests.buildProvidersAreAcceptedByTheMacOSHost` 验证 `gradle-provider`、`mix-provider` 能通过 macOS 的安装审查。`MacBuildProviderSessionsTests` 用真实的 `MacBuildProviderSessions` 和脚本化 Core 验证:重复请求复用进程,按插件停止后下一次请求重新启动;设置变化后按新参数重启;包内入口不可执行时不启动进程。`PluginPackageStoreTests.unusablePackagedBuildProviderIsRefused`、`buildProviderRuntimeEntryMustBeInThePackage` 验证安装时校验 Provider 的包内入口:缺失、不可执行、经过符号链接都被拒绝,运行时入口缺失也被拒绝。 +- `./scripts/test-macos.sh --filter 'BuildSystemServiceTests'` 的 Provider 用例:`providerModelSendsThePlannedRequestAndDecodesItsReply`、`providerDependenciesDecodeTheProvidersReply` 验证请求原样发给按插件、构建系统和工作区确定的目标,回复作为 `providerResult` 交给 Core 解码且不启动命令;`workspaceSwitchAndDisabledPluginStopTheirProviders` 验证停用插件和切换工作区停止进程、插件列表不变时保留;`providerModelFailsWithoutAProviderHost`、`failedProviderRequestReportsItsMessage` 验证失败路径。`providerModelLoadCarriesTheResolvedToolWithoutRefresh`、`providerReloadsAskForARefresh`、`providerLoadsWithoutTheToolWhenResolutionFails`、`commandModelLoadsDoNotResolveAProviderTool` 验证加载原因和工具路径:首次加载不带 `refresh`、重新加载的模型和依赖都带,工具只在 Provider 请求时解析,解析失败时请求照常发出且 Build Output 写明原因;`MacBuildProviderSessionsTests.providerHintsReachTheProcessUnchanged` 验证这两个字段原样到达进程,`PluginWebBridgeTests.pageRefreshReloadsTheModel` 验证网页的 `build.refresh` 算重新加载。`build-plans.json` 的 `gradle provider … refresh …`、`… tool must be an absolute path`、`gradle model command ignores …` 用例覆盖 Core 的写入、校验和命令来源忽略。`PluginMarketplaceServiceTests.buildProvidersAreAcceptedByTheMacOSHost` 验证 `build-provider`、`build-provider-language-server` 能通过 macOS 的安装审查。`MacBuildProviderSessionsTests` 用真实的 `MacBuildProviderSessions` 和脚本化 Core 验证:重复请求复用进程,按插件停止后下一次请求重新启动;设置变化后按新参数重启;包内入口不可执行时不启动进程。`PluginPackageStoreTests.unusablePackagedBuildProviderIsRefused`、`buildProviderRuntimeEntryMustBeInThePackage` 验证安装时校验 Provider 的包内入口:缺失、不可执行、经过符号链接都被拒绝,运行时入口缺失也被拒绝。 - `./scripts/verify-shared-contracts.sh`:schema 与 fixture 一致;`build-plans.json` 的用例名唯一、操作合法、拒绝码都有文档、不含机器路径;`command-plans.json` 的每个用例只给出 `languageID`、`buildSystemID` 之一。 - `./scripts/verify-rust-core-comments.sh`:`build/` 模块的注释规范。 - `./scripts/test-macos.sh --filter 'BuildSystemToolWindowTests'`:选项根选择保留各实例的候选和生效值,原生菜单动作覆盖自定义单选替换、多选追加及取消已选自定义值;原生 `NSTextField` 提交验证 text/file/directory 清空后提交 `""` 而非重置。设置 `LITHE_BUILD_CAPTURE_DIR` 后可导出选项行的明暗截图。`BuildSystemServiceTests.instanceOptionEditsPreserveSiblingOnlySelections` 验证编辑一个实例不会丢掉另一个实例独有的选择;`languageImportPublishesTheSameEffectiveOptionsAsBuildPlansAndClearsDisabledPlugins` 验证插件配置与构建计划使用同一份生效值、修改后重新发布、停用及重置清理;`RustBuildSystemOperationsTests.languageImportMapsPluginOptionIDsWithoutBuildSystemNameAssumptions` 验证真实 Core 的导入映射和 Maven 可选执行字段解码。本地 Maven、Gradle fixture 副本经真实 Core 校验后读出 `icon`、`presentation`、选项图标和 `category`;模型任务自带的 `category`;每个构建系统推导出的窗口和命令(ID、标题、停靠、命令与窗口分开的动作),以及注册表校验通过。补充回归覆盖窗口所属模块激活、命令 pending 状态、独立窗口停靠与显隐恢复、旧 Maven 贡献及命令移除、参数任务根工程与目标作用范围。临时插件包中的窗口与选项明暗 SVG 加载后,目录清单和每个文件的字节必须完全不变。 @@ -365,3 +365,31 @@ macOS 接入规则(2026-10-18): - `shared/contracts/plugins/v1/` - `shared/contracts/rust-core-api.md` - `scripts/verify-shared-contracts.sh` + + +### 统一的候选解析扩展 + +在原有自动发现来源之上,可选 `executableResolution: 1` 增加 setting 来源与 +确定性排序;候选仍声明在同一个 executables 数组里,不增加新的 Build 覆盖路径。 +数组顺序是默认优先级,不需要路径覆盖的插件不用声明设置。 +共享类型与拓扑排序位于 `plugins::executables`,macOS 由 +`ExecutableCandidateResolver` 注入文件系统、环境、Runtime 和工具发现能力, +区分跳过、解析成功和终止失败。Provider 的可选 tool 提示继续复用宿主解析结果; +无显式选择的自动发现失败仍可让 Provider 自行查找,显式失败则停止本次模型或依赖加载。 +这个限制也覆盖前置 Runtime 先失败的情况:Provider 回退前检查整个调用计划里的 +工具和 Runtime 是否有非空 setting,不能仅依赖失败候选的 terminal 标志。 +例如用户选了工具 A,先解析的 Runtime 不可用时,应报告 Runtime 错误;不能丢掉 +A,发出不带 tool 的请求。空 setting 仍允许自动发现失败后交给 Provider。 +只在解析失败时检查这项约束,前面候选成功后不会被后面未使用的 setting 阻止。 +Runtime 每个候选先构造自己的环境,把所选目录加入 PATH,再以同一环境探测和 +执行;失败候选的 PATH 不传给下一个候选。否则同目录 helper 会在探测时缺失, +合法的 Runtime 就会被误判为版本不足。 +设置值非法时 Core 直接拒绝计划,普通设置的容错显示规则不变。 +自定义 Runtime 候选复用同一机制;其余进程模块的分步迁移边界见 +[插件设置面](2026-10-21-plugin-settings-surfaces.md)。 + +`BuildToolProviderResolutionTests` 使用真实 Core 计划、MacBuildToolResolver 和 +BuildSystemService 验证上述组合:前置 Runtime 缺失或版本不足时,工具或后置 +Runtime 的显式选择均阻止模型和依赖 Provider 请求;全部设置为空时仍发送无 tool 的请求。 +`MacBuildToolResolverTests.customRuntimeProbeAndExecutionUseTheSameSiblingHelper` +用临时可执行脚本验证带空格目录里的 Runtime 在探测和执行时都能找到同目录 helper。 diff --git a/.agents/notes/implemented/architecture/2026-10-17-plugin-capability-versions.md b/.agents/notes/implemented/architecture/2026-10-17-plugin-capability-versions.md index f86ba51a7e..06dc242628 100644 --- a/.agents/notes/implemented/architecture/2026-10-17-plugin-capability-versions.md +++ b/.agents/notes/implemented/architecture/2026-10-17-plugin-capability-versions.md @@ -32,6 +32,7 @@ | `ui` | `ui`;版本 `2` 增加工具窗口里的网页内容 | | `buildSystems` | 声明式构建识别、模型和命令计划 | | `buildProviders` | 任一 `buildSystems[].provider`,包括自有进程和共享语言服务器两种写法 | +| `vcsProviders` | `vcsProviders[]`:插件进程实现的版本控制系统(工作副本标记、元数据路径、命令行工具) | 规则: @@ -46,6 +47,8 @@ 为什么 `serverProcess` 单独作为一个能力:启动字段由语言服务器和构建系统的自有 Provider 共用。以后新增一种启动方式时,只升级 `serverProcess`,`languageServers` 和 `buildSystems` 的其它字段不受牵连。共享写法的 Provider 复用插件里的语言服务器,所以不单独计入 `serverProcess`。两种 Provider 都必须声明 `buildProviders: 1`:能启动语言服务器或执行构建命令,并不代表能完成构建协议协商、请求和生命周期管理。纯模板构建插件不声明 `buildProviders`,否则会因多列能力被拒绝。 +`vcsProviders` 同样依赖 `serverProcess`:每个 VCS Provider 都是插件自己的进程,启动字段按 `serverProcess` 校验,所以清单要同时写 `vcsProviders: 1` 和 `serverProcess: 1`。只写前者,Core 会报少列了 `serverProcess`。工作副本标记和元数据路径留在 `vcsProviders` 里,因为宿主在进程启动前就要靠它们找到工作副本根,见[统一 VCS 模型提案](../../proposed/architecture/2026-10-22-unified-vcs-provider-model.md)。 + ### 2. 宿主自己声明能运行什么 Core 能校验一个能力,不代表宿主能运行它。宿主在 `plugins.declarative.validate`、`plugins.marketplace.validateIndex` 和 `plugins.marketplace.installReview` 里发送 `hostCapabilities: {"<能力>": [版本…]}`,由 Core 判断: @@ -56,7 +59,7 @@ Core 能校验一个能力,不代表宿主能运行它。宿主在 `plugins.de 能力表放在宿主代码里,写在能力实现的旁边,Core 不再按平台去猜。原来按 `darwin-` 前缀和级别写死的判断,就是 Core 替宿主猜、又容易和宿主实现漂移的例子。 -macOS 宿主接受级别 1 到 3(`supportedDeclarativeAPIVersions = [1, 2, 3]`),能力表是 `PluginManifest.hostCapabilities`,在校验、索引和安装审查时都发给 Core;其中的 `buildSystems: [1]` 随 macOS 的 Build 工具窗口一起加入,但 macOS 尚未实现 Provider,所以不声明 `buildProviders`;市场过滤、清单校验和安装审查都会拒绝需要它的插件,纯模板构建插件仍可用。Core 自己支持 `buildProviders: 1`,不传宿主表时仍可校验并生成 Provider 计划。Windows 还没有声明式插件宿主,不发送能力表,所以 Windows 不列出级别 3 的插件。宿主接入某个能力时,要做两件事:让宿主接受级别 3,并在宿主自己的能力表里加上这个能力的版本,而且只在宿主真正能运行它时才加。 +macOS 宿主接受级别 1 到 3(`supportedDeclarativeAPIVersions = [1, 2, 3]`),能力表是 `PluginManifest.hostCapabilities`,在校验、索引和安装审查时都发给 Core;其中的 `buildSystems: [1]` 随 macOS 的 Build 工具窗口一起加入,`buildProviders: [1]` 在 macOS 接入构建 Provider 会话后才加入。`vcsProviders: [1]` 同样等 macOS 接入 VCS Provider 会话(版本控制工具窗口、项目树状态和文件移动)后才加入,见[统一 VCS 模型提案](../../proposed/architecture/2026-10-22-unified-vcs-provider-model.md)的 P3。Core 自己支持 `buildProviders: 1` 和 `vcsProviders: 1`,不传宿主表时仍可校验清单、生成 Provider 计划、识别工作副本根并转发请求。Windows 还没有声明式插件宿主,不发送能力表,所以 Windows 不列出级别 3 的插件。宿主接入某个能力时,要做两件事:让宿主接受级别 3,并在宿主自己的能力表里加上这个能力的版本,而且只在宿主真正能运行它时才加。 ### 3. 市场索引带上能力版本 @@ -69,7 +72,7 @@ macOS 宿主接受级别 1 到 3(`supportedDeclarativeAPIVersions = [1, 2, 3]` 清单回答的问题只有三个:启动什么进程、需要哪些权限、对哪些文件和工程生效。进程启动以后能回答哪些请求、支持哪些可选数据,在 LSP `initialize` 里协商: -- 宿主发送 `capabilities.experimental.litheBuild: {"versions": [1]}`。 +- 宿主发送 `capabilities.experimental.litheBuild: {"versions": [1]}`。VCS Provider 用同样的方式协商 `litheVcs`,回复里还带上它实现了哪些 VCS 能力(例如 `history`、`annotate`),宿主据此决定显示哪些操作,见 `shared/contracts/vcs-provider-v1.md`。 - Provider 从中选一个版本,回复 `{"version": 1}`。没有回复版本、或回复了宿主没提供的版本时,宿主不把它当作 Provider 使用。 - 双方都忽略协商对象、请求参数和结果里自己不认识的字段,所以同一个版本内可以继续增加可选数据。另一方不能忽略的改动,才需要新的协议版本。 @@ -136,3 +139,14 @@ macOS 宿主接受级别 1 到 3(`supportedDeclarativeAPIVersions = [1, 2, 3]` - `rust/lithe-core/src/plugins/marketplace.rs` - `shared/contracts/plugins/v1/` - `shared/contracts/rust-core-api.md` + + +### 共享可执行文件候选能力 + +`executableResolution: 1` 是候选解析机制的可选能力,不是所有构建插件都要列的新字段。 +只有使用 setting 来源或 id/priority/insertBefore/insertAfter 时才声明;自动发现数组 +保持原序和原来的 buildSystems 1。macOS 先通过 Build 与自定义 Runtime 适配接入, +Windows 暂不声明;Language Server、Execution Provider 的新候选语法需各自后续接入后 +才可开放。底层源类型、排序与终止失败规则共用,启动安全约束仍属于各适配器。 +已发布 Cargo 0.1.1 的 buildSystems 2 输入通过兼容适配转换为同一个候选列表, +不维持独立 configuredExecutable 输出;细节见[插件设置面](2026-10-21-plugin-settings-surfaces.md)。 diff --git a/.agents/notes/implemented/architecture/2026-10-17-windows-agent-connection-ownership.md b/.agents/notes/implemented/architecture/2026-10-17-windows-agent-connection-ownership.md index 976e7c7b50..e54777f588 100644 --- a/.agents/notes/implemented/architecture/2026-10-17-windows-agent-connection-ownership.md +++ b/.agents/notes/implemented/architecture/2026-10-17-windows-agent-connection-ownership.md @@ -23,11 +23,14 @@ Windows Agent 面板的一次启动只对应一个 ACP 进程,进程身份由 - 断连清理统一在 `detachConnection()` 完成:结束计时、把未完成工具标为 `interrupted`,并把附着、加载、响应、取消、配置等待和权限队列全部清空;消息、轮次统计和 `hasCompleteHistory` 保留,因为它们属于用户可见的历史,而不是进程状态。 - 编辑区与侧栏同时请求相同连接身份时,共用同一个启动结果,两个调用者都在身份登记后得到完成通知;不同身份才取代旧请求。 - 启动与停止共用串行队列,并用请求代次丢弃已被取代的启动。旧启动已经创建进程时,必须等待关闭它后再执行新请求;不能只跳过身份登记,否则新 Agent 会复用旧进程。停止也进入同一队列,避免紧随其后的重连越过清理。 -- 连接身份用 `launchKey(workspace, agentId, authentication, command, arguments)` 表示。工作区或身份变化时先 `stop()`、再 `reset()`、再重连;失败启动不写入身份,下次设置变化仍会重试。 +- 连接身份还包含供应商 ID、来源、地址、模型、HTTP 许可和模型调用观测开关。工作区或身份变化时先 `stop()`、再 `reset()`、再重连;失败启动不写入身份,下次设置变化仍会重试。 - 切走前用 `captureSnapshot()` 保存当前可见状态,切回时 `restoreSnapshot()` 放回。恢复会清掉全部进程相关标记,`prepareConversation()` 因此走 `loadSession` 重新附着,用户看到的仍是原会话;工作区切换清空全部保留状态,避免跨项目串台。 - 发送条件包含 `pendingConfigToken == null`:会话设置还在确认时不接受新消息,界面沿用「准备中」提示。 -- `draft` 与 `files` 由 `AgentPanel` 持有并提升到 `agent-surface.store`,`AgentComposer` 改为受控组件;第 11 期起工具窗口与编辑区标签共享同一份按会话保存的草稿。 +- `draft` 与 `files` 由 `agent-surface.store` 按 Agent 与会话保存,`AgentComposer` 为受控组件;工具窗口与编辑区标签读取同一份草稿。新会话确认后迁移其草稿,历史加载失败不挪用其他会话的输入。 +- 响应中的模型、权限和 Effort 选择作为下一轮草稿配置;轮次完成后顺序提交并等待各自确认,全部成功才发送下一条消息。不能把菜单显示值提前当成上游已确认值。 - 附件校验在事件处理阶段先算出结果,成功后再写入状态;上限错误只显示提示,不改动原有草稿与附件。 +- 文件菜单跨 Agent 添加附件时,先提交目标配置,再用提交返回的配置内容核对当前设置。连接服务返回本次启动实际生效的设置,包含自己保存的本机 CLI 地址与模型刷新;被取代、启动失败或刷新保存失败时返回空结果。菜单必须核对该结果与当前设置,允许本次刷新,但拒绝外部配置变更。保存会重新规范化并发布对象,不能拿保存前的对象引用当作修订身份;保存失败或工作区切换时,附件草稿保持不变。 +- 子 Agent 的活动记录必须保留到终止,才能继续路由其输出和授权;只有已结束的展示记录限制为最近 64 条。正确做法是清理旧的终态记录后继续接收第 65 个子 Agent;不能因展示名额耗尽而忽略 Host 已挂起的授权。 ## 考虑过的备选方案 @@ -41,7 +44,7 @@ Windows Agent 面板的一次启动只对应一个 ACP 进程,进程身份由 - 切换 Agent 或认证方式仍会重启进程,但各 Agent 的可见会话以连接身份为键保留,切回后重新加载;工作区切换会清空全部保留状态。 - `stop()` 可能比过去多等一次启动完成的耗时,换取“停止返回即无存活进程”。 - 输入状态提升到窗口级放置状态后,关闭侧栏或把对话移动到编辑区都不会清空未发送草稿;工作区切换才清空。 -- 跨 Agent 模型同步仍缺,继续由功能矩阵跟踪;编辑区放置由 `2026-10-18-windows-agent-conversation-editor-placement.md` 承接。 +- 各 Agent 独立保留认证、供应商与模型设置;编辑区放置由 `2026-10-18-windows-agent-conversation-editor-placement.md` 承接。真实供应商切换的运行时验收与实现状态分别由功能矩阵记录。 ## 验证 @@ -49,6 +52,8 @@ Windows Agent 面板的一次启动只对应一个 ACP 进程,进程身份由 - `windows/tauri/tests/agent-connection-service.test.ts` 在独立 Bun 进程验证启动中切换 Agent、认证方式、停止后立即重开;使用可控启动承诺,不依赖真实进程或计时等待。 - `src/features/agent/stores/agent-connection.model.test.ts` 新增五条确定性测试:并发启动只开一个进程、启动中停止会关闭该进程并回到 `idle`、断连清空附着/忙碌/权限状态、快照恢复保留消息但清空进程状态、恢复后的会话先 `loadSession` 而不是直接 prompt。 - CI 同款 Agent 套件(`src/features/agent`、`src/config/backend-capabilities.test.ts`、`src/i18n`、拖放路由与图标测试)通过。 +- `windows/tauri/tests/agent-file-context-menu.test.ts` 在独立 Bun 进程中保留真实连接与启动服务、设置 store 和 CAS 保存队列,只替换原生 transport、CLI 与存储边界。验证跨 Agent 添加附件、Claude/Codex 本机配置刷新后附加成功、刷新保存失败,以及保存或连接期间切换工作区和配置。 +- `src/features/agent/stores/agent-parity.test.ts` 覆盖累计超过 64 个子 Agent、跨轮次复用,以及 70 个活动子 Agent 的授权保留和终态清理。 - `bun run build` 成功,PHP 隔离正则 `intelephense|vendor/bin/phpunit|composer:` 在 `dist` 中零命中。 ## 适用范围 diff --git a/.agents/notes/implemented/architecture/2026-10-18-plugin-web-content-in-host-windows.md b/.agents/notes/implemented/architecture/2026-10-18-plugin-web-content-in-host-windows.md index 84d69aa19d..d31ed74005 100644 --- a/.agents/notes/implemented/architecture/2026-10-18-plugin-web-content-in-host-windows.md +++ b/.agents/notes/implemented/architecture/2026-10-18-plugin-web-content-in-host-windows.md @@ -29,14 +29,16 @@ - 用内容安全策略(CSP,浏览器拦截哪些资源的规则)禁止联网、远程资源、iframe、内联脚本和 `eval`;禁止跳出插件包、弹窗和下载。 - 网页存储不持久,状态通过桥接对象保存在宿主里。这样运行时不会往插件包或 app bundle 写东西,符合运行时只读规则。 - 窗口第一次显示时创建页面,隐藏时可以销毁,禁用、更新或卸载插件时一定销毁。页面要能从保存的状态恢复。 +- 摄像头和麦克风默认没有权限:页面调用 `getUserMedia` 时由宿主弹窗确认,问题写明这是插件并附插件 ID,每个到达宿主的请求都重新询问,宿主什么也不记,只有明确点“Allow”才授权。只响应本插件的主框架;页面不在窗口里(窗口被隐藏而页面还保留着)时直接拒绝,页面使用设备期间窗口被隐藏就结束采集,点“Allow”之后页面才被隐藏时才启动的设备也会被立即停掉。统一的门和实测见[HTML 预览笔记](../feature/2026-10-22-html-preview-embedded-browser.md)的“摄像头和麦克风”。 ### 桥接对象 -宿主在页面脚本执行前注入 `window.lithe`(第 1 版):`context`、`request(method, params)` 和 `on(event, listener)`。方法和事件的全集写在 `shared/contracts/plugins/v1/README.md` 的 “Web content (`ui` 2)” 一节,这里只记录边界: +宿主在页面脚本执行前注入 `window.lithe`(当前第 2 版,`lithe.version` 给出版本号):`context`、`request(method, params)` 和 `on(event, listener)`。方法和事件的全集写在 `shared/contracts/plugins/v1/README.md` 的 “Web content (`ui` 2)” 一节,这里只记录边界: - 页面只能操作本插件声明的构建系统和当前工作区,越界返回 `plugin.web.forbidden`。 - 凡是会启动进程的请求(刷新模型、跑任务、跑测试),都要经过与宿主自身按钮相同的信任和同意流程,输出进入宿主的 Build Output。页面不能提交或修改进程 argv,也不能自己拼命令;模型保留 Core 返回的声明运行项 `run` 和任务 `category` 供展示,执行入口仍只有宿主校验过的任务与测试。 - 选项写入沿用 `build.resolveOptions` 和宿主的选项存储,因此构建选项转交给 JDT 的方式与宿主自己的选项列表一致。 +- 第 2 版只多了文件对话框 `dialog.openFile` / `dialog.saveFile`,供数据库这类带后端的插件导入导出文件。对话框由宿主弹出,说明里写明是哪个插件在请求;同一页面同时只能开一个;页面只拿到用户选中的绝对路径,真正的读写交给插件后端,页面本身仍然碰不到文件。页面在对话框打开期间被关闭,就拿不到选择结果。旧宿主没有这两个方法(返回 `plugin.web.unknownMethod`),所以页面先看 `lithe.version` 再决定是否提供导入导出。之所以加桥接方法而不是让后端自己弹窗,是因为后端是无界面的子进程,弹窗必须由宿主窗口负责,也只有宿主能把插件身份写进对话框,防止页面冒充 Lithe。 ### 正确做法 @@ -48,6 +50,7 @@ - 页面里自己实现停靠、标签栏或窗口开关,或者要求宿主按插件 ID 打开特殊窗口。 - 为了让页面联网而放宽 CSP;需要网络的数据应该由构建命令或 Provider 获取,再经 Core 解码。 - 把宿主的 Swift 视图移进插件包,或者为一个插件在宿主里写 `if pluginID == …`。 +- 让某个插件免于摄像头 / 麦克风确认,或者绕过 `WebMediaCapture.decide` 直接授权;插件名只用在提问文字里,不影响是否询问。 ### macOS 的落点 @@ -57,7 +60,7 @@ - `Application/Features/PluginWebPages.swift`:每个项目窗口一份页面登记表(按工具窗口 ID)。第一次显示时创建,隐藏时只把视图摘下、页面保留;每个容器把登记表弱引用登记到进程共享的 `PluginWebPageLifecycle`;插件管理器在停用、替换包、更新、回滚或卸载之前同步通知所有项目窗口,工作区关闭也释放页面。关闭不依赖窗口当前可见,因此隐藏页面不会继续从被替换的包读取资源。安装和回滚在候选包、签名、入口与版本校验后、实际修改安装记录或包字节前通知;校验失败保留旧页面,开始修改后发生 I/O 失败则保持关闭,防止恢复不完整的包重新加载。登记表同时禁止旧视图在停用中重建页面;关闭工作区保留停用和更新记录,成功重新启用原包才解除停用记录;更新、回滚和卸载使用独立的“直到进程重启”记录,防止另一项目窗口持有的旧管理器误解除。后注册的窗口继承当前记录,弱引用不会让关闭的项目窗口继续存活。 - 原生项目窗口直接关闭也必须释放页面:`AppModel.shutdownProjectSession()` 在第一次 `await` 前同步调用登记表的 `closeAll()`,关闭隐藏页面、拒绝待处理和后续桥接请求,并清除本会话的进程许可。设置窗口可能继续强持有这个项目的 `AppModel`,而直接关闭窗口不会先清空工作区 URL;因此不能只靠工作区切换入口或 `deinit` 清理,否则旧页面仍可通过工作区身份检查,甚至重新激活已经退出的构建模块。正确做法是在会话退出入口立即撤销页面权限;不要等异步模块退出结束或模型释放后再关闭页面。 - 构建服务的任务和测试启动方法返回本次创建的运行 ID,未启动返回空值。页面只登记这个 ID,不能通过读取共享的“当前运行”认领宿主或其他页面的任务;`build.cancel` 也只能停止本页实际启动且仍在运行的任务。`build.runTask` 与 `build.runTests` 在确认返回后、真正调用同步启动方法之前,必须在同一主线程 actor 中检查 `currentRunID` 为空;忙时返回 `plugin.build.unavailable`,保留现有运行和 Build Output。不能只在弹确认前检查,因为等待确认时原生按钮或另一页面可以开始新运行;也不能直接复用构建服务“替换当前运行”的语义,否则会未经许可取消其他调用者的任务并清输出。 -- `Platform/MacOS/Plugins/Web/`:`lithe-plugin:///` 自定义 scheme 只读取已安装包内的普通文件(拒绝空段、`.`、`..`、编码分隔符、符号链接和越出包根的路径,单文件不超过 16 MiB),每个响应都带 CSP、`nosniff` 和 `no-store`;WKWebView 使用 `nonPersistent()` 数据存储,用户脚本在文档开始时定义冻结的 `window.lithe`,只接受主框架、本插件源的消息;跳出本插件源的导航、弹窗、无法显示的 MIME(会变成下载)一律取消。只有本地开发包可以打开 Web Inspector。 +- `Platform/MacOS/Plugins/Web/`:`lithe-plugin:///` 自定义 scheme 只读取已安装包内的普通文件(拒绝空段、`.`、`..`、编码分隔符、符号链接和越出包根的路径,单文件不超过 16 MiB),每个响应都带 CSP、`nosniff` 和 `no-store`;WKWebView 使用 `nonPersistent()` 数据存储,用户脚本在文档开始时定义冻结的 `window.lithe`,只接受主框架、本插件源的消息;跳出本插件源的导航、弹窗、无法显示的 MIME(会变成下载)一律取消。只有本地开发包可以打开 Web Inspector。摄像头 / 麦克风请求经 `WebMediaCapture.decide` 逐次确认,只认本插件主框架(scheme、host 等于插件 ID、端口 0),回答时页面必须仍在窗口里;视图离开窗口时 `MacPluginWebView.viewDidMoveToWindow` 触发结束采集,页面不在窗口里时设备变成使用中(`cameraCaptureState` / `microphoneCaptureState` 的 KVO)也立即停掉。 - `ui.setState` 由 `MacPluginWebStateStore` 写到平台 adapter 解析的 `Application Support/Lithe/PluginState//web-state//<工作区摘要>.json`,单窗口/工作区上限 64 KiB,随插件状态目录卸载清理。它是用户可变状态,没有可复用的构建身份;`worktree-resources.json` 的 `plugin-web-state` 明确禁止任何阶段跨工作树复制。包内网页和脚本始终只读,不改变签名或 Sparkle delta 的源基线。 - 安装校验(`MacPluginPackageStore.validatePackage`)要求每个 `web.entry` 是包内存在的 `.html` 普通文件。 diff --git a/.agents/notes/implemented/architecture/2026-10-20-host-plugin-function-invocation.md b/.agents/notes/implemented/architecture/2026-10-20-host-plugin-function-invocation.md index 8f7987e6c1..0d0e0ba8d2 100644 --- a/.agents/notes/implemented/architecture/2026-10-20-host-plugin-function-invocation.md +++ b/.agents/notes/implemented/architecture/2026-10-20-host-plugin-function-invocation.md @@ -148,13 +148,13 @@ if pluginID == "dev.lithe.maven" { 启动带函数的会话 } // 宿主按插 - 每个 Provider 带独有的 `LITHE_PLUGIN_BACKEND_SCOPE`,会话结束后宿主按该标记优雅结束遗留进程。 - `./scripts/verify-runtime-bundle-immutability.sh`:确认没有新增写入 bundle 的路径。 - macOS 原生验收(2026-10-20,PR #1366 合并 PR #1365 后的 debug 应用): - - 环境:`sdk/node/examples/sample-build` 加一个只用于验收的网页窗口,作为开发包从磁盘安装;另装一个带自己构建系统和网页窗口的验收插件;示例工作区含成功、失败(退出码 3)和长时间运行三个项目。页面把每一步结果写进 `ui.setState`,验收读取 Application Support 中的状态文件核对。 + - 环境:Node SDK 的示例插件 `sdk/node/examples/sample-build`(当时在主仓库,现位于 lithe-plugin-docs)加一个只用于验收的网页窗口,作为开发包从磁盘安装;另装一个带自己构建系统和网页窗口的验收插件;示例工作区含成功、失败(退出码 3)和长时间运行三个项目。页面把每一步结果写进 `ui.setState`,验收读取 Application Support 中的状态文件核对。 - `functions.list` 返回 `snapshot.read`、`workspace.build`;一次成功构建后再列出,多了运行时注册的 `snapshot.clear`。 - `functions.invoke` 带 `call` 时,页面依次收到 `Preparing`、`Building app`、`Done` 三个 `functions.progress`,结果作为 `{ value }` 到达;失败项目返回 `plugin.function.failed`,并带上插件自己的 `pluginErrorCode: sample.buildFailed`。 - `functions.cancel` 后调用以 `requestCancelled` 结束;构建子进程在取消后立刻退出(采样中最后一次出现早于取消约 0.1 秒)。对已结束的 `call` 再取消返回 `{}`。未注册的函数返回 `plugin.function.unknown`,`params` 不是对象返回 `plugin.web.invalidRequest`。 - 另一个插件的页面用 `sample:.` 调用 `functions.list` / `functions.invoke` 都得到 `plugin.web.forbidden`,并且在弹同意框之前就被拒绝;它调用自己实例上的 `snapshot.read` 得到 `plugin.function.unknown`,说明函数按进程隔离。 - 进程清理:调用进行中关闭项目、或在设置里停用插件,Provider 及其构建子进程都会退出(`ps`/`pgrep` 确认);从磁盘替换插件包时旧 Provider 也会停止;退出应用后没有任何 Lithe 或插件进程残留。重启应用后 `snapshot.read` 返回上次会话的快照。 - - 验收发现示例的路径检查在宿主 `rootUri` 带尾部斜杠时拒绝所有路径(macOS 传的是目录 URL 的 `absoluteString`),所有构建都报 `sample.config`。已在示例里用 `path.resolve` 规范化根目录,并让 `rust/lithe-core/tests/plugin_sdk_node.rs` 用带尾部斜杠的 `rootUri`;未修复时该测试失败,修复后通过。 + - 验收发现示例的路径检查在宿主 `rootUri` 带尾部斜杠时拒绝所有路径(macOS 传的是目录 URL 的 `absoluteString`),所有构建都报 `sample.config`。已在示例里用 `path.resolve` 规范化根目录,并让当时主仓库的 `rust/lithe-core/tests/plugin_sdk_node.rs`(现为 lithe-plugin-docs 的 `sdk/e2e/plugin_sdk_node.rs`)用带尾部斜杠的 `rootUri`;未修复时该测试失败,修复后通过。 - `./scripts/verify-platform-feature-matrix.sh`:`plugin-functions` 的 macOS 证据已更新。 ## 适用范围 diff --git a/.agents/notes/implemented/architecture/2026-10-20-plugin-backend-sdk.md b/.agents/notes/implemented/architecture/2026-10-20-plugin-backend-sdk.md index fd18a15874..93ad659a8c 100644 --- a/.agents/notes/implemented/architecture/2026-10-20-plugin-backend-sdk.md +++ b/.agents/notes/implemented/architecture/2026-10-20-plugin-backend-sdk.md @@ -8,7 +8,9 @@ Lithe 有两个官方插件后端 SDK:`sdk/node`(包名 `@lithe/plugin-sdk` SDK 只**实现**契约,不**定义**契约。协议仍以 `shared/contracts/plugins/v1/README.md` 为准,SDK 不新增任何线上字段;不用 SDK 手写的后端同样合法。SDK 的价值是把“不能破坏 Lithe 统一管理”的规则变成默认行为:不发 `lithe/*` 回调、stdout 只走协议、状态只写宿主给的目录、调用结束时进程树一起结束。 -两个 SDK 各有一个示例插件(`sdk/*/examples/sample-build`),在 Rust Core CI lane 里经真实 Core 会话跑**同一套**端到端场景(`rust/lithe-core/tests/plugin_sdk/mod.rs`)。所以任一 SDK 与契约或另一个 SDK 的行为出现分叉时,测试都会失败。 +两个 SDK 各有一个示例插件(`sdk/*/examples/sample-build`),经真实 Core 会话跑**同一套**端到端场景(`sdk/e2e/plugin_sdk/mod.rs`)。所以任一 SDK 与契约或另一个 SDK 的行为出现分叉时,测试都会失败。 + +2026-10-23 起,两个 SDK 的源码、发版和端到端测试都在 [lithe-plugin-docs](https://github.com/Lithe-IDEA/lithe-plugin-docs) 的 `sdk/` 下,主仓库不再保存 SDK。插件开发者只看那个仓库就能拿到契约副本、SDK 和教程。迁移原因见 [插件契约、SDK 与开发者资料的发布](2026-10-23-plugin-contract-publishing.md);下文出现的 `sdk/...` 路径都指 lithe-plugin-docs。 ## 问题 @@ -23,13 +25,15 @@ Issue #1351 的目标是给插件开发者开放官方 SDK,同时“保留创 ## 决策 -### 1. 先做 Node,放在仓库根目录 `sdk/node` +### 1. 先做 Node,SDK 单独成一个目录 -Lithe 的 `runtime` 启动方式已支持 `node` 和 `java`。Node 无需编译、单文件即可分发、启动快,最适合作为第一个 SDK。目录放在根目录 `sdk/`,与 `shared/`(契约)、`Plugins/`(具体插件包)分开:`sdk/` 是可被插件复制使用的库代码,不属于任何一个平台,也不是契约本身。 +Lithe 的 `runtime` 启动方式已支持 `node` 和 `java`。Node 无需编译、单文件即可分发、启动快,最适合作为第一个 SDK。SDK 与 `shared/`(契约)、`Plugins/`(具体插件包)分开:它是可被插件复制使用的库代码,不属于任何一个平台,也不是契约本身。最初放在主仓库根目录 `sdk/`,后来整体移到 lithe-plugin-docs 的 `sdk/`。 ### 2. 零依赖、复制进插件包使用 -插件包安装后只读、没有 `npm install` 步骤、必须能离线运行。所以 SDK 不依赖任何 npm 包,插件作者把 `src/` 复制到包内用相对路径导入。`package.json` 标 `private: true`,暂不发布到 npm;等 API 稳定后再决定发布方式。 +插件包安装后只读、没有 `npm install` 步骤、必须能离线运行。所以 SDK 不依赖任何 npm 包,插件作者把 SDK 复制到包内用相对路径导入。 + +发布方式和 Java SDK 相同:lithe-plugin-docs 打 `sdk-node-v<版本>` 标签,它的 `.github/workflows/release-node-sdk.yml` 先在 Node 20 上跑 SDK 测试,再用 `npm pack` 生成 `lithe-plugin-sdk-node-<版本>.tgz` 和 `.sha256`,附在 GitHub Release 上。插件仓库在构建时按固定版本和 SHA-256 下载、校验并解开,把 `package/` 目录放进插件包;运行时不下载任何东西。这样外部开发者不用克隆主仓库,也不用给主仓库提 PR。`package.json` 仍标 `private: true`,`npm pack` 不受影响,但 SDK 不会被误发到 npm;等 API 稳定后再决定是否发布到 npm。 ### 3. 规则变成默认行为,而不是文档里的建议 @@ -57,7 +61,7 @@ Maven、Gradle 这类插件需要在后端里调用 JVM 生态的工具,并长 - **最低 Java 8。** 很多企业项目仍在 Java 8 上构建,后端最好能直接用项目那个 JDK 启动。代价是 SDK 只能用 Java 8 的 API。 - **零运行时依赖,自带最小 JSON 读写(`Json`)。** 插件要把 SDK shade(打进同一个 JAR)到自己的后端里。如果 SDK 依赖 Gson 或 lsp4j,就会和插件自己的同名依赖冲突,许可证审查范围也会变大。 -- **发布方式:主仓库打 `sdk-java-v<版本>` 标签,Release 附带 JAR 和 `.sha256`。** 工作流是 `.github/workflows/release-java-plugin-sdk.yml`,暂不发到 Maven 仓库。插件仓库在构建时按固定版本和 SHA-256 下载并校验,再 shade 进后端 JAR,运行时不下载任何东西。 +- **发布方式:打 `sdk-java-v<版本>` 标签,Release 附带 JAR 和 `.sha256`。** 0.1.0 是从主仓库发布的,已锁定它的插件不用改;之后的版本由 lithe-plugin-docs 的 `.github/workflows/release-java-sdk.yml` 发布。暂不发到 Maven 仓库。插件仓库在构建时按固定版本和 SHA-256 下载并校验,再 shade 进后端 JAR,运行时不下载任何东西。 - **处理器各自跑在守护线程池上,读循环只负责分帧和派发。** 取消不打断(interrupt)线程,只置位取消令牌,并结束该调用启动的进程。这是因为中断会让处理器里正在进行的文件写入半途失败。 - **进程按调用清理,用进程组,与 Node SDK 一致。** 常驻后端不会因为一次调用结束而退出,宿主按 `LITHE_PLUGIN_BACKEND_SCOPE` 的清理要等会话结束才发生,所以 SDK 必须在调用结束或取消时自己结束工具留下的进程。Java 没有进程组 API,macOS 和 Linux 上 SDK 通过系统 `perl`(`setpgrp(0, 0)` 后 `exec` 成工具,PID 不变)让每个工具成为自己进程组的组长:取消时先给整组发 SIGTERM,宽限期后发 SIGKILL;工具正常退出后立即对整组发 SIGKILL,结束它留下的后台进程。信号经 `/bin/kill -- -` 发出,因此 Java 8 和 Java 9+ 行为相同;只要组里还有成员,组 ID 就不会被复用,所以不会误伤其他并发调用。`PERL5OPT` 会让 perl 启动时加载模块,SDK 先从 perl 的环境里拿掉,再在 `exec` 前还给工具。启动失败时 launcher 以 127 退出并带固定前缀,SDK 仍抛 `IOException`,与 `ProcessBuilder` 一致。Windows 或没有 `perl` 时退回 `ProcessHandle.descendants()`(Java 9+,Windows 不会重挂父进程,工具退出后仍能找到子孙);Java 8 只能结束直接子进程。用 `setsid` 脱离进程组的进程,以及退回路径够不到的进程,仍由宿主按后端标记在会话结束时兜底。 - **取消监听器互相隔离。** 一个 `onCancel` 回调抛异常只记录到 stderr,不影响其余回调;调用收尾放在 `finally` 里,保证请求一定回复,宿主不会只等到超时。 @@ -106,28 +110,23 @@ Maven、Gradle 这类插件需要在后端里调用 JVM 生态的工具,并长 ## 后果 - 收益:插件作者只写处理器;取消、清理、错误映射和状态存储默认正确,Lithe 的统一管理规则不依赖作者自觉。 -- 收益:示例插件经过真实 Core 会话验证,契约改动若破坏 SDK,Rust Core lane 会直接失败。 +- 收益:示例插件经过真实 Core 会话验证。lithe-plugin-docs 每次提交都用契约副本锁定的那个主仓库 commit 跑端到端测试,每周再用主仓库 `preview` 分支的最新 Core 跑一次,Core 改动破坏 SDK 时会在这里失败。 - 代价:Node SDK 以复制源码方式分发,Java SDK 被 shade 进插件 JAR,插件里的副本都不会自动升级;修复安全问题时需要插件重新发布。 -- 代价:Rust Core lane 需要 Node、JDK 和 Maven;CI 用 Java 8 运行时跑 Java SDK 测试和示例后端,用 JDK 21 编译。 -- 代价:仓库新增一个顶层目录和一条 CI 测试,`sdk/*` 改动会触发 Rust Core lane。 +- 代价:SDK 移出主仓库后,主仓库的 PR 不再当场发现“Core 改动破坏 SDK”,最晚由 lithe-plugin-docs 的每周检查发现。改 Core 的插件后端协议时,应在合并前用 lithe-plugin-docs 的 `node scripts/sdk-e2e.mjs --source <本地主仓库> --ref <分支>` 手动跑一次。 +- 代价:lithe-plugin-docs 的端到端测试需要 Rust、Node、JDK 和 Maven,每次都要编译一遍 Core;CI 用 Java 8 运行时跑 Java SDK 测试和示例后端,用 JDK 21 编译。 - 代价:宿主还没有调用函数的界面入口,也没有宿主能力(`lithe/host/*`);Windows 没有插件后端宿主。SDK 目前对用户不可见,只给开发者和后续 PR 使用。 ## 验证 -- `node --test sdk/node/test/*.test.mjs`:协商、ID 规则(对照 `function-protocol.json`)、保留方法、调用/进度/错误/取消、运行时注册、构建结果、生命周期和退出码、分帧、进程树取消、状态存储与只读包拒绝、类型声明与运行时导出一致。 -- `LITHE_PLUGIN_SDK_NODE="$(command -v node)" cargo test --manifest-path rust/Cargo.toml -p lithe-core --test plugin_sdk_node`:示例插件经 Core 校验、启动计划、版本检查、构建模型/依赖、函数结果/进度/失败/取消、重启后状态恢复,最后确认插件包文件清单不变。 +以下命令都在 lithe-plugin-docs 中运行: + +- `node --test sdk/node/test/*.test.mjs`:协商、ID 规则(对照 `contract/v1/fixtures/function-protocol.json`)、保留方法、调用/进度/错误/取消、运行时注册、构建结果、生命周期和退出码、分帧、进程树取消、状态存储与只读包拒绝、类型声明与运行时导出一致。 - `mvn -B -f sdk/java/pom.xml verify [-Djvm=]`:Java SDK 的 JUnit 测试,覆盖与 Node 测试相同的协议场景,另含 JSON、分帧、进程树取消和状态存储。 -- `cargo test … --test plugin_sdk_java`(需要 `LITHE_PLUGIN_SDK_JAVA`、`LITHE_PLUGIN_SDK_JAVA_SAMPLE`、`LITHE_PLUGIN_SDK_NODE`):Java 示例按插件实际打包方式(`plugin.json` + shade 后的 JAR)装进临时包目录,跑同一套 Core 场景。 -- `./scripts/verify-rust-core-crates.sh`:CI 中运行以上全部;设置 `LITHE_PLUGIN_SDK_JAVA_RUNTIME` 时,Java 测试和示例后端在该运行时(CI 为 Java 8)上运行。 -- `bash scripts/test-classify-ci-changes.sh`:`sdk/*` 改动被分到 Rust Core lane。 +- `node scripts/sdk-e2e.mjs`:把 `sdk/e2e` 复制进锁定版本主仓库的 `rust/lithe-core/tests/`,运行 `plugin_sdk_node` 和 `plugin_sdk_java`。Node 示例经 Core 校验、启动计划、版本检查、构建模型/依赖、函数结果/进度/失败/取消、重启后状态恢复,最后确认插件包文件清单不变;Java 示例按插件实际打包方式(`plugin.json` + shade 后的 JAR)装进临时包目录,跑同一套场景。设置 `LITHE_PLUGIN_SDK_JAVA_RUNTIME` 时,Java 示例在该运行时(CI 为 Java 8)上运行。 +- `node scripts/sdk-e2e.mjs --ref preview`:同上,换成主仓库 `preview` 分支的最新 Core;CI 每周运行。 ## 适用范围 -- `sdk/node/` -- `sdk/java/` -- `rust/lithe-core/tests/plugin_sdk/`、`plugin_sdk_node.rs`、`plugin_sdk_java.rs` +- `https://github.com/Lithe-IDEA/lithe-plugin-docs` - `rust/lithe-core/src/plugins/declarative.rs`(运行时启动计划的版本探测参数) -- `.github/workflows/release-java-plugin-sdk.yml` -- `scripts/verify-rust-core-crates.sh` -- `scripts/classify-ci-changes.sh` - `shared/contracts/plugins/v1/README.md` diff --git a/.agents/notes/implemented/architecture/2026-10-21-plugin-settings-surfaces.md b/.agents/notes/implemented/architecture/2026-10-21-plugin-settings-surfaces.md index 97101f79b1..7f19d6e5b7 100644 --- a/.agents/notes/implemented/architecture/2026-10-21-plugin-settings-surfaces.md +++ b/.agents/notes/implemented/architecture/2026-10-21-plugin-settings-surfaces.md @@ -65,9 +65,73 @@ macOS 设置窗口现在可以由插件“注册”页面:插件在 `plugin.js 为了保证“立即生效”真的不需要新进程,Core 在校验时拒绝把 `immediate` 设置用在命令行参数或 `initializationOptions` 里。 -### 5. 宿主能力版本 - -macOS 的 `hostCapabilities` 声明 `settings: [1, 2]` 和 `languageServers: [1, 2]`。Windows 还没有这些界面和推送,所以仍只声明 1,需要 v2 的插件在 Windows 上会被市场隐藏,而不是装上后行为不完整。 +### 5. 构建工具的可执行文件路径 + +路径选择使用同一个 `invocation.executables` 数组。插件需要用户覆盖时,声明 +`kind: "setting"`、`settingID`,引用全局 file 设置(reload 或 nextRun);不需要 +覆盖的插件保持原来的自动发现列表,不额外声明设置。该通用机制按 +`executableResolution: 1` 协商,不要求所有构建插件升级。 + +Core 先校验完整候选集、排定顺序,再按平台过滤。数组从前到后是首选写法; +可选 priority 以较大值优先,同分按原序;insertBefore/insertAfter 使用同集合的稳定 +id 建立相对顺序约束。插入约束优先,不能在同一候选混用 priority 或两个插入字段; +重复 id、未知锚点、自环与循环都拒绝。契约正文定义完整的确定性算法,供后续使用方复用。 + +macOS 的 `ExecutableCandidateResolver`(候选文件解析器)由宿主注入文件系统、 +环境变量、Runtime 和工具发现服务,返回跳过、已解析或终止失败。设置值来自 Core +检查后的同一操作快照;空值跳过,非空无效值直接拒绝,不能经过普通设置解析的 +“无效则回默认值”。普通设置的显示与配置容错不变。平台检查权限与版本,显式路径 +失败时 Provider 不得独立发现另一安装。带空格路径保持完整参数,父目录进入 PATH。 + +共享源类型和排序在 `plugins::executables`;本次 Build 与自定义 Runtime Dependencies +使用共享解析器,已有 Build Provider 继续通过 ServerProcess 适配和 tool 提示复用。 +Language Server、Execution Provider 等后续可接入相同机制,各自保留包内文件边界、 +自动启动限制和工作区信任;当前不在这些清单位置开放新候选语法。 +参考 xiaoyumuxi 的 #1318、#1356、#1382 以及 Maven 插件 #3,宿主仍只负责通用机制, +Provider 继续接受宿主选中的工具,不重新实现一套覆盖路径。 + +已发布预览 Cargo 0.1.1 使用旧 `executableSetting`(buildSystems 2)。保留只读的兼容 +输入适配:转换为数组首位 setting 候选,后续只走共享解析器,不再返回 +configuredExecutable。新插件必须用第五种候选和新能力;既有发行包不覆盖,迁移以新版本发布。 + +路径偏好仍写既有用户存储,候选计划只在操作内存活,没有额外缓存或安装目录写入, +不影响签名和 Sparkle delta。Core、Swift 与独立 Cargo CI 覆盖统一的选择和失败语义。 + +### 6. 宿主能力版本 + +macOS 的 `hostCapabilities` 声明 `settings: [1, 2]`、`languageServers: [1, 2]` 和 `buildSystems: [1, 2]`。Windows 还没有这些界面和推送,所以仍只声明 1,需要 v2 的插件在 Windows 上会被市场隐藏,而不是装上后行为不完整。 + +### 7. 路径选择按钮保留完整宽度 + +插件详情页比完整设置窗口窄。路径输入框若固定占用 220 点,同时右侧作用域和 +生效标签保持完整,SwiftUI 会把“选择…”按钮压成没有文字的灰条。通用路径 +控件因此固定按钮的自然宽度,让输入框随剩余空间缩短;共享设置行的标题列 +以原来的 230 点为上限,窄面板时也允许缩短。宽面板仍保持原来的列宽。 +缩放只调整布局,不替换输入框实例,以免丢失尚未提交的路径。 + +这与 IDEA Community 的文本框加浏览按钮采用相同的空间分配:检查的版本是 +`c7f91397daa3a961b4e78bc634fe467a0a7d9ade`,源码为 +`platform/platform-api/src/com/intellij/openapi/ui/TextFieldWithBrowseButton.java` +及 `ComponentWithBrowseButton.java`。后者把文本框放在 `BorderLayout.CENTER`, +把 `FixedSizeButton` 放在 `BorderLayout.EAST`,让输入部分承担宽度变化。 +macOS 保留既有原生控件和主题,只在通用 owner 中修复压缩规则,不为 Cargo +增加专门的宽度或页面。 + +### 8. 插件详情不能越过分隔线 + +分隔容器分配了确定矩形,不代表子内容一定接受这个宽度。插件详情的表单 +有自己的最小宽度;窗口缩小后,原来的根容器会按更宽的内容居中,导致名称、 +页签和卡片跨进左侧列表,分隔线看起来穿过了详情。 + +详情根视图必须接受分隔容器给出的宽度,并在自己的边界内绘制。设置页的 +原生表单在宽面板填满可用宽度;面板不足以容纳完整设置行时,使用同一个 +滚动容器横向查看,保留输入框、选择器和右侧状态标签。只裁剪超出的表单会 +让部分设置无法访问;提高整个设置窗口的最小宽度又会限制其他页面,因此 +不采用这两种单独修补方式。 + +宽度只在详情正文的小容器里读取,用于分配可见区域。已构建的正文作为值 +传入,不在几何回调里重新加载插件、重建列表或保存宽度。拖动和持久化仍由 +既有 `LitheSplitPaneView` 负责,窄宽切换不替换表单实例。 ## 考虑过的备选方案 @@ -79,6 +143,11 @@ macOS 的 `hostCapabilities` 声明 `settings: [1, 2]` 和 `languageServers: [1, 插件已经可以声明网页 UI,理论上可以自己画设置页。但网页无法和宿主设置窗口的搜索、分组、项目/全局切换保持一致,用户偏好也是原生优先。网页 UI 仍可用于复杂的工具窗口,设置项本身走原生控件。 +### 顶层增加可执行文件覆盖旁路 + +单独增加 executableSetting/configuredExecutable 会让 Build 的设置路径绕过候选顺序, +其它进程模块也不能复用。只为已发布预览包保留输入适配,新 API 使用统一候选与宿主能力注入。 + ### 宿主自己合并取值 Swift 和 Windows 各写一遍合并规则最直接,但两边一旦有细微差异,界面显示的值和实际启动使用的值就会不同。所以合并只在 Core 做,宿主只负责存储和显示。 @@ -97,6 +166,8 @@ Swift 和 Windows 各写一遍合并规则最直接,但两边一旦有细微 - `cargo test -p lithe-core --lib plugin_configuration`:会话按 section 应答、更新推送、宿主会话拒绝更新。 - `./scripts/test-macos.sh --filter PluginSettingsTests`:项目层存储、作用域规则、页面注册、配置展开。 - `./scripts/test-macos.sh --filter SettingsSidebarLayoutTests`:静态页面仍各归一个分组。 +- `./scripts/test-macos.sh --filter PluginSettingsLayoutTests`:480、640、900 点宽的深浅主题下,路径输入框和完整选择按钮都在容器内,按钮可调用选择器并写入所选路径。 +- `./scripts/test-macos.sh --filter PluginDetailLayoutTests`:实际 Java 插件表单在深浅主题中往返缩放,详情边界始终跟随分隔条,窄面板下右侧设置可经原生横向滚动到达。 - 共享夹具:`shared/contracts/plugins/v1/fixtures/settings-v2.plugin.json`。 ## 适用范围 @@ -108,5 +179,6 @@ Swift 和 Windows 各写一遍合并规则最直接,但两边一旦有细微 - `macos/Sources/Lithe/Models/AppModel/AppModel+PluginSettings.swift` - `macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginSettingsStore.swift` - `macos/Sources/Lithe/Views/App/PluginSettingsSection.swift` +- `macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift` - `macos/Sources/Lithe/Views/App/ContributedSettingsPageView.swift` - `shared/contracts/plugins/v1/README.md` diff --git a/.agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md b/.agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md new file mode 100644 index 0000000000..14cca53000 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md @@ -0,0 +1,149 @@ +# Agent 笔记:Windows 声明式插件宿主 + +状态:已实现 + +## 先说结论 + +Windows 新增一套契约 v1 声明式插件宿主,与现有 Worker 扩展模型长期并行:需要宿主代用户拉起进程的语言与构建插件走契约宿主,宿主内运行 UI 与服务行为的扩展(主题、AI provider 等)留在 Worker 模型。契约引擎不需要新写——`lithe-core` 已经编译进 Windows 宿主,经 `core_execute` 就能调用 `plugins.declarative.*` 与 `plugins.marketplace.*`。M1 已落地:包存储、启动扫描与隔离、语言服务器启动计划解析,以及共享 fixtures 的全量验收;M2 已补磁盘导入安装链、设置内独立管理页和 runtime 启动的 `minimumVersion` 探测;M3 已补声明式设置、运行/测试计划命令与契约版 PHP bundled 包;M4 已补 Web 工具窗口(`lithe-plugin://` scheme、CSP、`window.lithe` 桥 v0)。能力声明开放 `languages:1`、`languageServers:[1,2]`、`serverProcess:1`、`settings:[1,2]`、`ui:[1,2]`,其余能力(`executionProviders`、`buildProviders`、`buildSystems`)仍由 Core 自动拒绝。 + +## 问题 + +- 功能矩阵 `declarative-plugin-contract`、`plugin-functions` 对 Windows 记为 missing(2026-10-22 矩阵对齐时确认);[声明式语言插件](2026-10-12-declarative-language-plugins.md) 也写明"Windows 现有扩展清单暂未改造,接入时必须通过同一组 fixture"。 +- Windows 当前插件生态是渲染进程内的 Worker 扩展:`Plugins/win/Official/PhpSupport` 是 Worker 包格式(`main: "plugin.js"`),与契约包不同,无法复用 Lithe Store 面向 macOS/契约发布的语言插件包。 +- Windows 没有插件信任链与仓库源管理;`install_extension_from_url` 前端已调用但 Rust 宿主从未注册,非语言扩展的落盘安装链路实际不可用。 +- 契约的确定性逻辑(清单校验、启动计划展开、安装审查)按仓库规则只在 `rust/lithe-core/src/plugins/` 实现一次,而 `windows/tauri/src-tauri` 已依赖 `lithe-core`——差的只是宿主侧接线,不是契约本身。 + +## 决策 + +### 双模型边界 + +判定句:扩展需要"宿主代表用户拉起进程"(语言服务器、构建系统、运行/测试提供方)就走契约宿主;扩展需要"在宿主内运行自己的代码来表达 UI 或服务行为"(颜色主题、图标主题、AI provider、数据库 sidecar、ACP agent)就留在 Worker 模型。 + +- 正确做法:新增 Go 语言支持时写契约 `plugin.json` 声明 `languageServers` 和 `executionProviders`,由 Core 展开启动与运行计划。 +- 不要这样做:把主题扩展改写成契约包;为 Worker 扩展桥接一套函数通道冒充 litheFunctions;在宿主里按插件 ID 写 `if` 分支。 + +### 能力声明渐进开放 + +宿主在 `plugins.declarative.validate` 的 payload 里声明 `hostCapabilities = { languages:[1], languageServers:[1,2], serverProcess:[1], settings:[1,2], ui:[1,2] }`。Core 的 `capabilities.rs` 会拒绝声明了宿主未开放能力的插件,宿主不需要自己写拒绝逻辑。M3 已把 `languageServers` 与 `settings` 提到 2,注册设置页并下推 `workspaceConfiguration`;M4 已增 `ui:[1,2]`。`executionProviders`、`buildProviders` 与 `buildSystems` 仍未开放,运行/测试计划命令的存在不代表这些插件能力可安装。宿主不声明的能力,市场侧按不支持展示。 + +### 存储、信任与安装(v0) + +- 安装根为 `app_local_data_dir()/plugins///`,`installation.json` 记录版本与启用状态;记录缺失或损坏的包隔离展示,绝不静默加载。 +- bundled 包从 `resource_dir()/OfficialPlugins` 只读加载,多版本目录取最高版本;运行时不写安装目录与资源目录,遵守发行基线只读边界。 +- 每个插件的语言服务器状态目录在 `app_local_data_dir()/plugin-state//`,由宿主注入启动计划请求。 +- 信任模型 v0:官方 ID 防冒用由 Core 校验兜底;bundled 包随安装器分发;磁盘导入(已落地)与市场下载(M3+)一律走 `plugins.marketplace.installReview` 全量审批(权限 + 完整程序清单),审批界面明示"未认证发布者"。publisher Ed25519 验签明确延后,这是 v0 的已知缺口,Windows 验签方案(Authenticode 还是包内 Ed25519)单独立项评估。 +- 磁盘导入的私有快照、staging 重验、内容摘要复查与回滚语义照抄 macOS 侧规则(见声明式语言插件 Note 的磁盘安装节),Windows 不另造审查语义。 + +### 启动接线(M1 落地形状) + +- `plugin_host` 模块(`store.rs` 存储布局、`scan.rs` 扫描与校验、`launch.rs` 启动计划解析)是宿主侧唯一入口;校验与计划展开全部经 `lithe_core::execute_json` 调 Core 命令,宿主不复制任何清单语义。 +- `packaged` 启动入口解析后必须是包内普通文件:拒绝 reparse point(符号链接与 junction)与规范化后的路径逃逸,这是宿主执行每个启动前的统一闸门。 +- `runtime` 与 `systemCommand` 启动经 PATH 探测(复用 `language_tools` 的系统工具查找),并按 macOS 同一约定导出 `LITHE__PATH`、把 packaged runtime 目录前置到 `PATH`;`minimumVersion` 探测随 M2 安装链一起接入。 +- 语言服务器进程仍由共享 Core 的 `lsp.startServer` 拉起:前端 `resolve-editor-lsp-launch.ts` 在 Worker 扩展清单没有对应服务器时,回落到 `declarative_plugins_language_launch`(契约插件排在 Worker 之后,已由 Worker 声明的语言保持现状,直到 M3 包迁移)。 +- 启动扫描在应用 bootstrap 的 "declarative plugins" 步骤执行一次;扫描结果缓存在 Tauri state,前端经 `declarative_plugins_state` 只读消费。 + +### 网页与版本探测的运行边界 + +Tauri 把插件自定义协议页面视为远端来源,空 capability 会在进入 Rust handler 前拒绝所有 IPC。宿主采用 Tauri 官方 inlined plugin(应用内命令插件)扩展点:`declarative-web` 命名空间只声明一个 `request` 权限,仅匹配 `plugin-web-*` 窗口和 `http://lithe-plugin.*/*` 来源;不授予 fs/http/event 等命令,也不启用会改变全部主窗口命令的 app ACL。编译后的 Tauri authority 回归验证这条边界,不能用只检查 handler 的测试代替。 + +插件网页的窗口标签只用于定位所属包,不能单独证明当前文档可信。`web.rs` 使用 Tauri 的导航、新窗口和下载拦截器,只允许本包来源;每次桥接请求还读取 WebView 当前 URL,再核对插件来源、启用状态和窗口声明。宿主脚本也只在本包主框架定义桥,窗口使用非持久浏览会话,避免外站文档继承桥或页面存储。例如本包的 `ui/next.html` 可以导航,`https://example.com`、另一个插件的来源、带用户信息或端口的地址会被拒绝;不要用 CSP 或窗口标签代替导航与请求来源检查。 + +WebView2 的 NavigationStarting 可以阻止外站文档提交,但实测外站服务器仍可能先收到 GET。因此 `web_network.rs` 使用微软的 WebResourceRequested 接口,在请求层把非本包、非 Tauri IPC 的网络请求完成为本地 403。窗口先创建为惰性空白页,五秒期限内安装过滤器成功后才导航到包页面;失败时销毁空白窗口,不能留下可运行但未受保护的页面。Wry 只在初次创建时转换自定义协议,延迟 navigate 必须显式使用同一 http://lithe-plugin.<插件 ID>/ 映射地址,否则页面会停在空白文档;这个地址构造和来源检查共用同一回归。注册跟随 WebView 的生命周期,不在页面每次加载时叠加。复用 Wry 已依赖的 webview2-com 0.38.2 API,独立声明同一固定版本,没有新增另一套浏览器或网络后端。 + +`workspace.openFile` 必须先经原生文件系统解析真实目标,再检查它仍在同样经过解析的工作区根内。前端把这个目标交给既有文件打开入口,保留行列位置,并在等待解析后复查工作区有没有切换。工作区内指向内部文件的链接可以打开,指向外部目录的 junction(Windows 的目录联接)不能借路径文本绕过检查。不能仅比较 `C:/work/link/file.txt` 的文本前缀。 + +网页的语言、浅色或深色状态、颜色和字体变量从已解析的 Windows 工作台主题取得。主窗口创建页面时传入初值,主题注册表与设置的订阅把后续变化送到原生页面;根元素的 `data-theme`、`--lithe-*` 变量和只读 `lithe.context` 随之更新,再发 `ui.themeChanged`。每个 HTML 响应会在插件脚本前加载一个保留路径下的宿主同源外部脚本,由原生协议 handler 按可信窗口标签读取最新快照;因此页面重载、同包导航和带 UTF-8 BOM 的页面在运行插件代码前也能取得当前主题,HTML parser 已创建根元素。宿主脚本插在真实 head 起始标签后;省略 head 时退到 html 后,两者都省略时放在文档声明之后。扫描仅处理 HTML 前导区,跳过注释并识别带引号的属性,保留原有根样式、head 属性、BOM 和文档模式。这种响应字节插入不改包文件,也不给 CSP 增加内联脚本例外。订阅随 bootstrap 清理,不在插件内复制另一套主题值。 + +版本探测复用 `lithe_git_host::run` 的原生进程执行器。虽然它位于 Git adapter crate,其进程树、非阻塞管道与清理能力不解释 Git 参数;探测只设置五秒取消期限和每条输出流 64 KiB 上限,解析仍交 Core。Windows 在进程运行第一条指令之前把它放入 Job Object(内核管理的进程组),因此父进程退出后仍占用 stdout 的后代也会被清理。不要只给 `try_wait` 加超时后无期限 `join` 读取线程,也不要靠退出后遍历父 PID 找后代。代价是异常进程退出后最多还需执行器自身的有界清理时间。 + +### 禁用、替换和卸载必须先收回使用权 + +插件包正在执行时直接删目录,会在 Windows 上遇到可执行文件被占用,并可能只删掉一部分文件。因此所有启停、安装替换、回滚和卸载先经过 `lifecycle.rs`:禁止新的原生启动计划和网页读取,等待已经开始的版本探测、校验进程、网页创建和资源读取退出,再关闭所有所属页面,并要求每个产品窗口确认其运行与 Core LSP 会话都已停止。原生使用计数和禁止新请求共用锁,避免刚检查完就漏进新的操作;等待原生使用者和窗口确认各有 45 秒上限。失败时保留包文件并继续禁止使用,用户重试管理操作后才能恢复。 + +前端在调用第一个异步操作前登记启动归属。停止操作必须覆盖已启动和正在等待 Core 响应的会话;晚到的会话先停止、再拒绝返回。停止失败的会话仍留在登记表中,后续管理操作必须继续等它,不能误报没有进程。包目录与扫描目录只有在变更和重新扫描都成功后才恢复使用。完整扫描从发现到提交缓存共用一把锁,避免不同插件并发管理时,旧扫描覆盖新目录。 + +正确做法:先收回新请求、等待所有使用者退出,再修改包;不要发一个不等待结果的停止事件就删除文件。每个窗口只能确认自己的操作,窗口消失算失败,不能假定它的 Core 子进程已经退出。探测与读取沿用既有 `plugin-state` 和包读取路径,没有新增可跨工作树复用的资源;校验命令在平台可写的插件状态目录执行,发行资源保持只读。 + +### 插件身份、工作区和声明式界面 + +`pluginId` 是 Core 校验配置更新的插件身份,`providerId` 是语言服务实例的来源,两者必须分别传递和保存。设置的即时生效请求必须带插件 ID,并只发送到仍有效且属于该插件的会话;Java 和 Worker 启动继续使用自己的来源。 + +每个网页由创建它的产品窗口、工作区和插件共同持有,窗口标签使用无碰撞的编码。状态按这组归属隔离,关闭工作区或禁用插件时清理;工作区关闭会提升该工作区页面的版本,关闭前已发起但尚未注册的页面也不能复活。状态写入与文件打开的登记和撤销共用所有者锁,旧页面实例不能在重新打开后继续写状态;网页不能通过参数自行指定所属工作区。`workspace.openFile` 只发给所属窗口,并等待带操作 ID 的确认。目标窗口在真实路径解析前后都复核当前工作区,复核目标缓冲区确实存在且处于活动状态后才确认成功;共享文件打开函数在读取失败或请求过期时也可能正常返回,不能仅凭其 Promise 完成判断成功;工作区切换、窗口关闭和十秒确认超时都返回失败。待确认请求最多 128 个,避免插件无限积累请求。 + +启动前检查 Core 的 `requiredPackagePaths`、`runtimeRequirements` 和 `validationArguments`。包资源允许普通目录,但实际可执行入口必须是普通文件;路径逃逸和 reparse point 都拒绝。依赖与发现型启动运行时优先使用用户显式导出,再查系统 PATH;包内 `packagedRuntime` 则严格执行已审批的那个文件,不接受用户覆盖,满足最低版本才继续;校验命令即使输出文本,只要退出码非零仍失败。探测复用现有五秒、每条输出流 64 KiB 的原生执行器和进程树清理能力。 + +`ui:1` 的文本工具窗口由宿主生成转义后的 HTML,`ui.commands` 接入已有的命令、菜单和工具栏展示注册表,工具窗口也有活动栏入口。复用展示注册表不等于进入 Worker 执行模型。禁用时同步撤销入口,异步刷新按插件检查版本,避免撤销 B 时取消 A 的刷新,防止旧结果恢复入口。目前页面仍是独立 WebView 窗口;声明的停靠位置、图标、动作可用/勾选状态和布局持久化尚未与 macOS 对齐,因此矩阵继续记为 partial,不能称为完整的 ui 1/2 产品形态。 + +### 与 Worker 扩展模型共存(产品分工) + +- 两套模型互不桥接:契约插件不进 Worker 宿主,Worker 扩展不冒充契约能力。 +- PhpSupport 迁移(M3 调整):契约版 PHP 包(`Plugins/win/Official/PhpSupportContract`,`sameTeamAsHost` bundled)随安装器分发并完成 validate/launch/runPlan 端到端测试,但 Worker 版**保留不移除**。原因:Worker 版提供 composer scripts 的动态运行动作发现(读取工作区 composer.json 生成动作列表),契约 v1 的 `executionProviders` 是静态声明,没有等价能力;删除 Worker 版会丢失用户可见功能。完整迁移等契约补齐动态运行动作后再做。两包并存时 Worker 版优先(Worker 扩展清单先解析),契约包不重复注册语言。 +- `install_extension_from_url` 等 Worker 非语言市场命令(M2 已处置):在 Rust 宿主注册为显式占位错误(`extensionStore.notAvailable`),调用方得到可诊断的结构化失败而不是"命令不存在";完整落盘后端是独立工作项,不与契约宿主混淆。 +- 管理界面按模型分组展示两类扩展并使用不同文案,避免用户把 Worker 扩展当成契约插件。 + +### 实施切分 + +M1 包存储 + 启动校验 + 语言注册 + LSP 启动(已落地);M2 磁盘安装链路 + 管理界面 v1 + 死链处置(已落地);M3 声明式设置 + 运行/测试计划 + PHP 契约包(已落地,Worker 版保留见上文);M4 Web 工具窗口(已落地,桥 v0 只含 state/openFile/主题,build.* 与 functions.* 等 Windows 构建系统能力接入后开放)。每个里程碑独立 PR,矩阵条目随代码同 PR 更新。总体方向遵循[公共扩展契约与 Provider 接入方向](../../proposed/architecture/2026-10-13-public-extension-contract-and-provider-api.md),本 Note 只落地其中 Windows 契约宿主的第一阶段。 + +## 考虑过的备选方案 + +- 把 Worker 扩展模型整体改造成契约宿主:契约没有主题、AI provider 这些概念,改造成本高且无收益,不采用。 +- 在 Tauri 侧另写一套清单校验:违反"校验只在 Core 实现一次",共享 fixtures 无法复用,双端语义必然漂移,不采用。 +- 把 Worker RPC 桥接成 litheFunctions:两者协议语义不同,会同时存在两套函数协议,等有真实插件需求时再按[插件函数契约](2026-10-20-plugin-function-contract.md)接入。 +- 信任模型一步到位 publisher 签名:Windows 验签载体需要单独评估,v0 以 bundled 加全量审批兜底,签名延后是显式记录的缺口而不是遗漏。 +- 在 `lsp_start` 一类原生命令里拉起插件服务器:现有 LSP 会话全部由 Core 统一管理(会话、取消、事件泵都在 Core),前端只解析可执行路径与参数;另建原生拉起路径会分裂会话所有权,不采用。 + +## 后果 + +- 收益:Windows 用户开始能安装与运行契约 v1 语言插件,语言插件包与 macOS/Lithe Store 同格式;契约验收(同组 fixtures)成为 Windows 持续回归的一部分;后续里程碑只增不改这条链路。 +- 代价:双模型并存是用户可见的复杂度,管理界面必须分组。`minimumVersion`、内容摘要复查与启动前校验已接入;发行者签名仍延后,不能把完整性检查当作发布者身份验证。 +- 已知风险:签名延后意味着 v0 社区包信任完全依赖审批界面(M2),文案必须明示未认证状态;Windows 的 reparse point/junction 语义与 macOS symlink 不完全相同,包内路径逃逸判断已按 Windows 语义单独测试;WebView2 对自定义 scheme 与 CSP 的实现细节与 WKWebView 有差异,`ui:2` 网页链路已用 Windows Release/WebView2 实机验证,停靠、图标、动作状态及布局仍为 partial。 +- M2–M4 里程碑:磁盘/市场安装链路 + 管理界面;声明式设置 + 运行/测试计划 + PHP 契约包替换;Web 工具窗口。每个里程碑独立 PR,能力声明与矩阵条目随代码同 PR 更新。 + +## 验证 + +- 多窗口事件投递必须显式指定当前产品窗口的 `Window` target。Tauri 默认 `Any` 监听也接收发给其他 label 的事件,原生 `emit_to(ownerLabel)` 本身不足以隔离接收者;文件请求与生命周期确认均绑定当前窗口,防止其他工作区误处理请求或重复确认。`plugin-lifecycle-listener.test.ts` 检查两类监听的目标。 +- 2026-10-24 Windows Release 实机验收:Playwright/CDP 连接真实 WebView2,测试包安装、ui 1 文本渲染、工作区关闭重开、状态隔离、两个产品窗口归属、主题/语言与重载、过期工作区 openFile 拒绝、双窗口卸载关闭全部插件页均通过;无重复确认异常,外站 HTTP GET 为零,安装包及发行资源哈希不变,应用正常退出。该测试包不替代真实语言服务器的完整安装→启动→即时设置验收,也不覆盖停靠布局。 +- 管理并发回归:`lifecycle.rs` 覆盖原生使用计数排空、禁止晚到获取、逐窗口确认、停止失败、窗口断开和有界超时;`lsp-core-adapter.test.ts` 用可控 Core 响应覆盖停止期间晚到的服务器,断言先停止再拒绝,且 `pluginId` 没有丢失。`plugin-lifecycle-listener.test.ts` 验证收到停用事件立即撤销入口、等待运行和 LSP 两方后才确认。 +- 工作区与界面回归:`web.rs` 覆盖双工作区窗口/状态键隔离和文本转义;`plugin-lifecycle-listener.test.ts` 验证其他工作区请求、路径解析中切换工作区以及成功确认;`workspace-lifecycle.test.ts` 验证关闭工作区时清理网页。`declarative-plugin-ui.test.ts` 用 Hello Lithe fixture 验证文本窗口与命令面板、菜单、工具栏三处贡献及撤销,受控刷新同时覆盖相关与无关插件的撤销。CI 按文件分进程执行插件套件,避免原生命令和事件 mock 跨套件泄漏。 +- 启动计划回归:`launch.rs` 验证必需资源缺失、运行时依赖缺失、目录资源与普通文件入口的区别,以及有输出但非零退出的校验命令。`tests.rs` 还覆盖完整扫描的有序发布,`web.rs` 用有界通道覆盖写入与撤销交错、旧页面实例和旧工作区版本拒绝。Windows Rust 最终计时运行记录 357 项,其中 354 通过、3 项夹具跳过;前端受影响测试分别在隔离进程中运行,报告保存在 `.artifacts/test-stability/`。这些测试不替代双工作区真实 WebView2、真实语言服务器与停靠界面的产品验收。 + +- 网页隔离与上下文回归:`web.rs` 覆盖同包和外站 URL 的导航/桥接共用检查、真实路径与内部/外部目录链接;Windows 用无需提权的 junction,Unix 用目录符号链接。`plugin-web-appearance.test.ts` 执行原生宿主桥脚本,验证语言、主题、根变量、主题事件与外站不暴露桥;前端文件测试验证原生真实目标传递与拒绝传播。 +- 版本探测回归:`runtime_probe_collects_output_and_rejects_overflow`、`runtime_probe_reclaims_inherited_pipes_after_parent_exit` 使用当前测试二进制作为固定夹具;后者让父进程退出、后代保留管道,断言调用有界结束且后代已退出。测试环境变量仅附加到子进程,不改测试运行器的全局环境。运行 WindowsRust 与 Frontend 计时 harness,检查 `.artifacts/test-stability/index.html`;真实 WebView2 验证还要由本机 HTTP 计数器确认外站 GET 为零,同时检查导航、弹窗/下载、动态主题、包文件和发行目录哈希不变;单凭页面 URL 没变不能证明请求未外发。 + +- M4 Web 工具窗口:`plugin_host/web.rs` 测试覆盖包路径规范化(`..`、编码分隔符、空段拒绝)、CSP 内容断言(default-src none、无 wasm-unsafe-eval、frame-ancestors none)、桥脚本冻结与作用域、状态键隔离;web-panel fixture 随 `ui:[1,2]` 声明提升转为被宿主接受(能力门控测试同步)。桥 v0 的 build.*/functions.* 返回 `plugin.web.unknownMethod`,等构建系统能力接入。 + + +- M3 设置链路:`settings_tests`(`plugin_host/tests.rs`)覆盖项目覆盖往返、其他插件条目保留、损坏 store 报错不重置;能力门控测试随声明提升更新(settings-v2 fixture 被接受)。设置编辑 UI(`declarative-plugin-settings-editor.tsx`)按声明的 scope/effect 渲染并显示值来源徽标。 +- M3 运行计划:契约 PHP 包端到端测试(`bundled_php_contract_package_expands_plans`)走真实 Core 命令验证 validate、runPlan(`${file}`/`${userArguments}` 展开)、testPlan(workspace scope + 项目标记)、resolveSettings。 + + +- M2 磁盘导入链测试(`windows/tauri/src-tauri/src/plugin_host/install_tests.rs`):目录快照隔离(symlink 拒绝)、ZIP 解包与恶意路径(`../` 逃逸条目)拒绝、staging 安装(坏包失败保留现行版本且清空 staging)、回滚版本选择;runtime 启动的版本探测输出解析(node/java/go 真实输出格式)在 `launch.rs` 测试覆盖。 +- 管理界面(`declarative-plugins-section.tsx`)挂在设置的独立一级页,`settings-hierarchy.test.tsx` 的布局断言随导航更新;i18n 提供 en-US 与 zh-CN 全套文案。 + + +- 共享 fixtures 全量验收在 Windows 二进制内完成:`invalid-manifests.json` 全部 154 个用例经宿主校验路径断言同一稳定错误码(不带 hostCapabilities 的契约等价模式),合法样例在不带与带 v0 能力声明两种模式下分别断言通过与 `plugin.api.unsupported` 门控。测试入口:`windows/tauri/src-tauri/src/plugin_host/tests.rs`,运行 `cargo test --manifest-path windows/tauri/src-tauri/Cargo.toml plugin_host`。 +- 存储与启动的单测覆盖安装记录缺失/损坏隔离、bundled 最高版本选择、版本比较(含 prerelease)、扩展与文件名匹配(大小写与前导点)、packaged 路径逃逸拒绝、runtime 的 `LITHE__PATH` 导出与 `PATH` 前置。 +- 前端回落链路测试:`windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.test.ts` 断言契约插件启动映射与无归属文件返回 null;启动扫描步骤在 bootstrap 中执行。 +- M1 尚属静态与进程内验证:真实插件包安装→启用→语言服务器拉起的全链路实机验收随 M2/M3 完成,期间矩阵 `verificationStatus` 保持 pending。`./scripts/verify-windows-boundaries.sh`、`./scripts/verify-platform-feature-matrix-change.sh HEAD` 随 PR 通过。 + +## 适用范围 + +- `windows/tauri/src-tauri/build.rs`、`capabilities/plugin-web.json` 与 `permissions/declarative-web/bridge.toml`(应用内桥插件及最小来源权限) + +- `windows/tauri/src-tauri/src/plugin_host/`(存储、扫描、启动接线与 fixtures 验收测试) +- `windows/tauri/src-tauri/src/main.rs`(模块注册与命令注册) +- `windows/tauri/src-tauri/src/language_tools.rs`(复用的系统工具查找) +- `windows/tauri/src-tauri/src/plugin_host/install.rs`(磁盘导入、卸载、回滚、启停) +- `windows/tauri/src-tauri/src/extension_store_placeholder.rs`(Worker 扩展落盘命令的显式占位) +- `windows/tauri/src/features/plugins/declarative-plugin-launch.ts`(前端宿主 API) +- `windows/tauri/src-tauri/src/plugin_host/settings.rs`(设置双层存储与命令) +- `windows/tauri/src-tauri/src/plugin_host/run_plans.rs`(运行/测试计划命令) +- `windows/tauri/src/features/plugins/declarative-plugin-settings-editor.tsx`(设置编辑) +- `windows/tauri/src/features/plugins/declarative-plugin-run.ts`(契约插件运行入口) +- `windows/tauri/src-tauri/src/plugin_host/web.rs`(Web 工具窗口 scheme、桥与状态) +- `Plugins/win/Official/PhpSupportContract/`(契约版 PHP bundled 包) +- `windows/tauri/src/features/plugins/declarative-plugins-section.tsx`(设置内管理页) +- `windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts`(契约插件回落来源) +- `windows/tauri/src/features/bootstrap/bootstrap-async.ts`(启动扫描步骤) +- `shared/platform-feature-matrix/features/declarative-plugin-contract.json`、`plugin-marketplace.json`、`plugin-extension-management.json`、`declarative-plugin-settings.json`、`php-optional-plugin.json`、`plugin-web-tool-windows.json` diff --git a/.agents/notes/implemented/architecture/2026-10-23-plugin-contract-publishing.md b/.agents/notes/implemented/architecture/2026-10-23-plugin-contract-publishing.md new file mode 100644 index 0000000000..37783dfe71 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-23-plugin-contract-publishing.md @@ -0,0 +1,203 @@ +# Agent 笔记:插件契约在主仓库定义,完整副本、SDK 和开发者资料发布到 lithe-plugin-docs + +状态:已实现 + +## 先说结论 + +插件契约(`shared/contracts/plugins/v1/`,规定 `plugin.json` 每个字段的含义、错误码和标准答案用例)仍然在主仓库定义,由 Rust Core 执行。公开仓库 `Lithe-IDEA/lithe-plugin-docs` 的 `contract/v1/` 保存这个目录的**完整逐字节副本**,另外放开发指南、从 Lithe Store 搬过来的两篇教程、两个官方 SDK(源码、发版和端到端测试)和示例插件列表。插件开发者只看 lithe-plugin-docs 就能开发,写插件、发布插件都不需要克隆主仓库,也不需要给主仓库提 PR。 + +日常开发要记住四件事: + +1. 契约目录下任何文件合并后,都要到 lithe-plugin-docs 运行同步脚本。这条规则也写在根目录 `AGENTS.md`。 +2. 契约 README 里指向目录外的链接必须用绝对 URL,因为副本只包含这个目录。 +3. 契约 fixture(测试用的示例清单)按“演示什么能力”命名,不按真实插件命名。 +4. 主仓库不再保存 SDK。SDK 的修改和发版都在 lithe-plugin-docs;改 Core 的插件后端协议时,合并前要用它的 `scripts/sdk-e2e.mjs` 跑一次 SDK 端到端测试。 + +## 问题 + +主仓库里有很多插件 JSON,看起来像是各个插件仓库的配置副本。逐个盘点后,它们分成三类: + +1. **契约本身**:schema、README 和错误码。Rust Core 的测试用 `include_str!` 直接读取它们,Swift 测试也读取其中的 fixture,所以它们必须和执行它们的代码在同一个提交里修改。 +2. **契约 fixture**:文件名是 `maven-build`、`php-support`、`cargo-build` 这类真实插件名,ID 也是 `dev.lithe.fixture.maven-build`。实际上它们只是借 Cargo、Go、Gradle、Maven 当真实输入,用来演示某种能力,例如“从命令输出解码项目模型”。名字让人误以为主仓库在维护这些插件的配置。 +3. **确实属于插件仓库的副本**:例如 `rust/lithe-core/src/tests/fixtures/community_build_plugins/` 中 Maven、Gradle 插件的快照。这一类本次不处理,留作后续迁移。 + +除此之外,外部开发者需要的东西分散在主仓库和 Lithe Store: + +- 字段语义、错误码和标准用例只在主仓库。 +- schema 的 `$id` 是 `https://lithe.dev/...`,但这个域名不归我们所有,地址解析不到,编辑器没法用它。 +- 两个 SDK 的源码在主仓库,Node SDK 也没有正式发布,只能从主仓库复制 `src/`。 +- 教程在 Lithe Store,Store 还有一份已经过期的 schema 副本。没有一个“我要写插件,从哪里开始”的入口。 + +## 决策 + +### 契约在主仓库定义 + +契约和执行它的代码(Rust Core 的校验和标准答案测试)必须在同一个 PR 里修改。如果把契约的源头移到别的仓库,改一个字段就要两个 PR,中间还会出现“契约写的是 A、Core 做的是 B”的时间窗口。外部开发者只需要**读**契约、不需要**改**契约,读的需求由完整副本满足。 + +### lithe-plugin-docs 是开发者的唯一入口 + +- `README.md`:中文开发指南,讲插件结构、API 等级怎么选、编辑器校验、后端 SDK 和示例插件。 +- `guides/`:从 Lithe Store 搬来的《LSP 插件完整生命周期》和《构建、运行与测试接入》。契约链接改为本仓库的 `contract/v1/`,发布相关链接指向 Store。 +- `contract/v1/`:主仓库 `shared/contracts/plugins/v1/` 整个目录的逐字节副本,包括 README、两份 schema 和全部 fixture。 +- `contract.lock.json`:记录来源仓库、来源目录、commit 和每个文件的 SHA-256,格式与 Cargo 插件的 `sdk.lock.json` 相同。 +- `sdk/node`、`sdk/java`:两个官方 SDK 和各自的示例插件;`sdk/e2e`:两个示例共用的端到端场景(Rust 测试文件)。 +- `scripts/contract.mjs` 提供四个命令: + - `sync --revision `:按来源目录在该 commit 的 git tree 整体替换副本,新增、删除、修改的文件都跟上,然后重写锁文件。 + - `verify`:离线检查锁文件、哈希、schema `$id`、Markdown 相对链接和未锁定文件。 + - `verify --upstream`:用 GitHub API 取锁定 commit 的 tree,按 git blob ID 比较整个目录。 + - `drift`:检查主仓库 `preview` 分支上的契约是否已经变化。不用默认分支:主仓库的默认分支是稳定版 `main`,只在发版时追上 `preview`,拿它比较会让刚同步过的副本每周都报差异。 + + docs 仓库的 CI 每次提交运行前两项检查和脚本测试,每周运行一次 `drift`。 +- `scripts/sdk-e2e.mjs`:SDK 端到端测试,见下文“SDK 整体移到 lithe-plugin-docs”。 + +只复制 schema 不够:字段之间的约束、错误码和预期结果都写在契约 README 和 fixture 里,只有 schema 的话,开发者仍然要回主仓库查。 + +### Lithe Store 只管收录和上架 + +Store 保留 `publishing.md`、`versioning-and-updates.md`、`maintainers.md` 和 `CONTRIBUTING.md`,讲怎么提交收录、怎么追踪版本。两篇教程搬到 lithe-plugin-docs,Store 的 `schema/plugin-index.schema.json` 副本删除。 + +Store 的 `validate-index.mjs` 仍然实现索引规则。它的 CI 原来比较本地 schema 副本和主仓库 preview,现在改为检查 lithe-plugin-docs 上 `plugin-index.schema.json` 的 SHA-256 是否等于工作流里记录的值。不相等说明索引契约变了,Store 维护者更新校验脚本后再更新记录的哈希。 + +### Schema 地址固定为 docs 仓库的 raw 地址 + +两份 schema 的 `$id` 是它在 docs 仓库里的地址: +`https://raw.githubusercontent.com/Lithe-IDEA/lithe-plugin-docs/main/contract/v1/<文件名>`。 + +两边都对这个地址做严格检查: + +- 主仓库的 `scripts/verify-shared-contracts.sh` 用常量 `published_plugin_schema_base` 要求 `$id` 完全相等。 +- docs 仓库的 `contract.mjs` 要求每份 schema 的 `$id` 等于它自己的发布地址。 + +因此,如果主仓库改了地址而 docs 仓库没跟上,或者 docs 仓库里的文件不是从主仓库同步来的,CI 都会失败。 + +### 契约 README 的外链写绝对地址 + +副本只包含契约目录,`../` 开头的相对链接在 docs 仓库里会失效。所以契约 README 指向 `rust-core-api.md`、Agent Notes 的链接都写成 `https://github.com/1lck/Lithe-IDEA/blob/preview/...`,指向 SDK 的链接写成 lithe-plugin-docs 的地址。 + +两边都检查这条规则: + +- 主仓库的 `verify-shared-contracts.sh` 拒绝离开目录或指向不存在文件的相对链接。 +- docs 仓库的 `contract.mjs` 在同步和校验时也拒绝这类链接。 + +### `plugin.json` 里不能写 `$schema` + +Core 解析清单时拒绝未知字段(serde `deny_unknown_fields`),schema 本身也是 `additionalProperties: false`。如果开发者按习惯加一行 `"$schema"`,插件会因为 `plugin.manifest.invalid` 装不上。所以指南要求在编辑器里按文件名关联 schema,例如 VS Code 的 `json.schemas`。是否在以后的契约版本里允许 `$schema` 另行决定:那需要提高能力版本,老版本 Lithe 会拒绝新格式。 + +### fixture 按能力命名 + +`shared/contracts/plugins/v1/fixtures/` 中的 fixture 改为按演示的能力命名,ID 同步改成 `dev.lithe.fixture.<新名字>`: + +| 原名 | 新名 | 演示什么 | +| --- | --- | --- | +| `php-support` | `language-packaged-server` | 随包附带语言服务器 | +| `go-support` | `language-system-command` | 使用用户安装的命令作为语言服务器 | +| `java-support` | `language-host-integration` | 交给宿主集成的语言服务器 | +| `cargo-build` | `build-static-file` | 从项目文件读取构建模型 | +| `go-build` | `build-command-stdout` | 解码命令的标准输出 | +| `gradle-build` | `build-command-output-file` | 解码命令写出的文件 | +| `maven-build` | `build-host-integration` | 把构建模型交给宿主集成 | +| `gradle-provider` | `build-provider` | 构建后端 | +| `mix-provider` | `build-provider-language-server` | 与语言服务器共用进程的构建后端 | + +fixture 的内容仍然用 Cargo、Go、Gradle、Maven 作为真实输入,契约 README 写明“它们不是这些插件”。macOS 测试中同 ID 的展示用副本(`macos/Tests/LitheTests/Fixtures/BuildSystems/`)一起改名。 + +`shared/fixtures/plugins/language-support-v1.json` 保留旧 ID `dev.lithe.fixture.go-support`。它是已退役的 schema 1 格式,只被 `rust/lithe-core/src/tests/plugins.rs` 的旧格式测试读取,与声明式契约无关。 + +### SDK 整体移到 lithe-plugin-docs + +SDK 是给插件开发者用的库,不是契约。开发者要读它的 README、看示例、提问题、下载发布包,这些都应该在 lithe-plugin-docs 完成,所以 `sdk/`、SDK 端到端测试和两个发版工作流都从主仓库移走,主仓库只保留契约。 + +- **发版**:lithe-plugin-docs 打 `sdk-node-v<版本>` 或 `sdk-java-v<版本>` 标签,`release-node-sdk.yml`、`release-java-sdk.yml` 发布包和 `.sha256`。具体规则见 [官方插件后端 SDK](2026-10-20-plugin-backend-sdk.md)。Java SDK 0.1.0 是迁移前从主仓库发布的,那个 Release 保留,Maven 插件锁定的下载地址不变。 +- **单元测试**:SDK 自己的测试读 `contract/v1/fixtures/function-protocol.json`,也就是契约副本,不再读主仓库。 +- **端到端测试**:场景仍是 Rust 测试,要链接 Rust Core。`sdk-e2e.mjs` 把锁文件记录的主仓库 commit 稀疏检出到 `.artifacts/lithe`(只取 `rust/` 和 `shared/`),把 `sdk/e2e` 复制进它的 `rust/lithe-core/tests/`,再用 `cargo test --locked` 运行。`LITHE_PLUGIN_SDK_ROOT` 告诉测试示例插件在哪里。这和 Cargo 插件测 Core 的方式相同。 +- **两个版本的 Core**:每次提交测契约副本锁定的那个 commit,保证 SDK、契约副本和 Core 是同一个版本;每周再用 `--ref preview` 测主仓库 `preview` 分支的最新 Core,提前发现 Core 的改动破坏了 SDK。 + +代价是主仓库的 PR 不再当场发现“Core 改动破坏 SDK”。改插件后端协议(`rust/lithe-core/src/lsp/interface/plugin_function.rs`、`litheBuild` 相关代码)时,合并前要在 lithe-plugin-docs 用 `--source <本地主仓库> --ref <分支>` 手动跑一次。 + +### 正确做法 + +- 修改契约:在同一个 PR 里改 `shared/contracts/plugins/v1/`、Core 和测试。合并后,在 lithe-plugin-docs 运行 `node scripts/contract.mjs sync --revision <合并后的 preview commit>`,然后提交。 +- 改了 `plugin-index.schema.json`:同步 docs 仓库之后,Lithe Store 的 CI 会失败,按 Store 的 `docs/maintainers.md` 更新索引校验和记录的哈希。 +- 在契约 README 里引用目录外的文件:写 `https://github.com/1lck/Lithe-IDEA/blob/preview/<路径>`。 +- 新增 fixture:按演示的能力命名,例如 `build-command-stdout.plugin.json`,ID 使用 `dev.lithe.fixture.` 前缀。 +- 新增面向插件开发者的说明:写进 lithe-plugin-docs 的 README 或 `guides/`。 +- 修改或发布 SDK:在 lithe-plugin-docs 提 PR、打标签。 +- 修改插件后端协议:合并前在 lithe-plugin-docs 运行 `node scripts/sdk-e2e.mjs --source <本地主仓库> --ref <分支>`。 + +### 不要这样做 + +- 在 lithe-plugin-docs 里直接编辑 `contract/` 下的文件,或者让主仓库 schema 的 `$id` 指向别的地址。两边的 CI 都会失败。 +- 在契约 README 里写 `../` 开头的相对链接。 +- 用真实插件名命名 fixture,例如 `maven-build.plugin.json`。读者会以为主仓库在维护那个插件的配置。 +- 在指南或示例里让开发者往 `plugin.json` 写 `$schema`。 +- 把插件开发教程写回 Lithe Store 或主仓库的 `Plugins/`。 +- 在主仓库重新加 `sdk/` 目录,或者让主仓库 CI 检出 lithe-plugin-docs 跑 SDK 测试。依赖方向是 docs 仓库依赖主仓库,反过来会让主仓库的结果取决于另一个仓库的 `main`。 + +## 考虑过的备选方案 + +### 把契约的源头移出主仓库 + +开发者只看一个仓库,这个目标现在由完整副本实现。真正把源头移走有两种做法,都没有采用: + +- **git submodule(子模块)**:主仓库测试读取子模块里的文件。每次改契约都要先在契约仓库合并,再在主仓库更新子模块指针;所有克隆和 CI 都要多一步初始化子模块。 +- **测试时按锁文件下载契约**:主仓库只保存锁文件,构建前下载。Rust 测试用 `include_str!` 在编译期读取 fixture,离线构建和每个 worktree 都要先准备下载物,还要纳入 worktree 资源复用清单。 + +两种做法都让“改契约、改 Core、改测试”变成跨仓库的多步操作,复杂度高于每次合并后同步一次副本。 + +### 只发布两份 schema 副本 + +这是最初的方案,docs 仓库只放 schema。没有采用,因为字段语义、错误码和标准用例都在契约 README 和 fixture 里,开发者遇到 schema 表达不了的规则时仍然要回主仓库。 + +### 把 schema 放到主仓库的 GitHub Pages 或主仓库 raw 地址 + +这样不用多建一个仓库。但 Pages 目前用来发布 Agent Notes 看板,自定义域名 `notes.lithe.top` 还没有 HTTPS 证书。如果用主仓库 raw 地址,开发者的入口仍然是主仓库,而且主仓库调整目录结构就会改变公开地址。 + +### 教程和契约副本都放在 Lithe Store + +Store 原来已经有教程和一份 `schema/plugin-index.schema.json`。没有采用,是因为 Store 负责收录和上架,它的 schema 副本已经和主仓库不一致(`$id` 还是 `lithe.dev`)。把“怎么写插件”和“怎么上架”分开,Store 的收录规则可以保持单一职责。 + +### SDK 留在主仓库,只单独发版 + +这是本次最初的做法:端到端测试留在 Rust Core lane,改 Core 时当场发现 SDK 被破坏。没有采用,因为开发者仍然要到主仓库读 SDK 的 README、看示例、提 issue,改 SDK 也要给主仓库提 PR,和“开发者只看 lithe-plugin-docs”的目标冲突。端到端测试改成按锁文件检出 Core 以后,跨仓库固定 Core 版本的成本可以接受。 + +### 主仓库 CI 检出 lithe-plugin-docs 跑 SDK 端到端测试 + +这样改 Core 时仍能当场发现问题。没有采用:主仓库的测试结果会取决于另一个仓库 `main` 分支的状态,docs 仓库合并一个坏提交就会让主仓库所有 PR 失败;同时形成两个仓库互相依赖。 + +### 把 Node SDK 发布到 npm + +npm 是 Node 开发者最熟悉的方式。但插件包安装后没有 `npm install` 步骤,开发者最终还是要把 SDK 文件复制进插件包。发布到 npm 还要管理组织和令牌,目前没有必要。 + +## 后果 + +- 收益:外部开发者只需要 lithe-plugin-docs;schema 地址可以真正解析,编辑器能直接提示;fixture 名字不再误导;两个 SDK 的源码、发版和问题都在同一个仓库,Node SDK 也有带哈希的正式下载。 +- 代价:契约变更后要多做一次同步。同步之前,docs 仓库的副本会落后于主仓库,每周的 `drift` 检查会失败提醒。 +- 代价:Core 改动破坏 SDK 时,主仓库 PR 不会失败,最晚由 docs 仓库的每周检查发现;docs 仓库的端到端测试每次都要编译一遍 Core。 +- 代价:契约 README 不能用相对链接引用主仓库的其他文件,这些绝对链接指向 `preview`,会随主仓库调整目录而失效。 +- 代价:schema `$id` 绑定了 docs 仓库 `main` 分支的 raw 地址。仓库改名或换默认分支都会改变公开地址,必须同时改两边的检查。 +- 后续工作: + - `community_build_plugins` 中 Maven、Gradle 快照迁回插件仓库。 + - Lua、Gradle、Maven 插件 README 里指向 Store 教程和主仓库契约的链接,改为指向 lithe-plugin-docs。 + - Windows PhpSupport 插件迁移。 + - 其他共享 schema 仍然使用 `lithe.dev` 作为 `$id`。 +- 重新评估的条件:如果契约开始需要外部贡献者直接修改,或者 Rust Core 拆成独立仓库,就重新考虑契约放在哪里。 + +## 验证 + +- `./scripts/verify-shared-contracts.sh`:两份 schema 的 `$id` 必须等于 docs 仓库的发布地址;契约 README 的相对链接不能离开目录;fixture ID 必须以 `dev.lithe.fixture.` 开头。 +- `cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib -- declarative_plugins build_systems marketplace_plugins build_runtime_versions`:改名后的 fixture 仍然得到相同的标准答案。 +- `bash scripts/test-classify-ci-changes.sh`:主仓库的 CI 分类不再有 `sdk/` 路由。 +- 在 lithe-plugin-docs 运行 `node --test scripts/*.test.mjs` 和 `node scripts/contract.mjs verify --upstream`:同步脚本的测试通过,副本与锁定 commit 的契约目录完全一致。 +- 在 lithe-plugin-docs 运行 `node --test sdk/node/test/*.test.mjs`、`mvn -B -f sdk/java/pom.xml verify` 和 `node scripts/sdk-e2e.mjs`:两个 SDK 的单元测试读契约副本通过,两个示例后端在锁定版本的 Core 上跑完端到端场景。 + +## 适用范围 + +- `AGENTS.md` +- `shared/contracts/plugins/v1/` +- `scripts/verify-shared-contracts.sh` +- `scripts/verify-rust-core-crates.sh` +- `scripts/classify-ci-changes.sh` +- `.github/workflows/ci-macos.yml` +- `Plugins/README.md` +- `https://github.com/Lithe-IDEA/lithe-plugin-docs` +- `macos/Tests/LitheTests/Fixtures/BuildSystems/` diff --git a/.agents/notes/implemented/architecture/2026-10-23-shared-i18n-generation.md b/.agents/notes/implemented/architecture/2026-10-23-shared-i18n-generation.md new file mode 100644 index 0000000000..a70fb27a65 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-23-shared-i18n-generation.md @@ -0,0 +1,108 @@ +# Agent 笔记:共享翻译来源与双端构建时生成 + +状态:已实现 + +## 先说结论 + +macOS 和 Windows 的全部自有翻译资源改为在 `shared/i18n/` 维护,再在构建前生成各自的 +资源文件。开发者修改一次翻译即可更新两端,现有页面仍使用各自的翻译接口。 +原生字符串表、Windows 原字典及页面内的中英文文案已完整迁入;上游消息、用户数据和搜索同义词仍由各自所有者维护,不增加在线语言包。 + +## 问题 + +两端原来各自维护中英文,macOS 多用英文原文作 key,Windows 使用语义标识;参数还 +分别使用 `%@` / `%lld` 和 `{name}`。直接合并会丢失语境与参数类型,也会把 Finder +和 File Explorer 等合理差异抹掉。语言选项、Swift 枚举和原生打包列表原本也固定为两项。 + +## 决策 + +共享语言清单声明语言标识、自称名称、回退语言和各平台默认值。生成器输出语言选项、 +Swift 枚举、Windows 类型及字典;macOS 打包从同一清单读取列表。保留用户已经保存的 +`en` / `zh-Hans` 与 `en-US` / `zh-CN`,也保留 macOS 默认英文、Windows 默认中文。 +增加内置语言只需扩充清单和语言文件,再生成资源,不用修改语言选择控件。 + +词条使用描述用途的语义 key。相同英文并不自动表示相同语义,平台适用范围也不代表 +功能已经实现;能力对齐继续由功能矩阵记录。平台覆盖(override,即单个平台需要 +不同措辞)必须说明原因,第一批覆盖保留现有文案,包括文件管理器名称。 + +macOS 继续复用 Foundation 的原生字符串表与格式化能力,Windows 保留现有翻译接口。 +生成器不实现运行时语言引擎:它把共享词条放进既有 `Localizable.strings` 的生成区域, +同时输出语义 key 和旧英文 key 的兼容别名。新旧页面因此读取同一张表,不用一次替换 +全部 SwiftUI 隐式本地化调用。Windows 仅使用生成字典,原独立字典已删除;所有词条在生成时展开显式回退链。页面不再用中英文分支保存翻译,明确语言的呈现经由 AppLanguage 原生资源接口。 + +正确做法:修改 `locales/zh-CN.json` 的 `ui.save`,重新生成,提交源文件和生成结果。 +不要在 macOS 生成区或 Windows 旧字典里再写一个 Save;校验会拒绝双重维护。 +当两个用途需要不同翻译时建立两个语义 key,不能让同一旧别名归属于两个 key。 +同步其他功能分支时,先把新加入的原生旧 key 和 Windows 字典词条迁入共享源, +保留已有译文、参数宽度和位置格式,再重新生成。不要把两套字符串表拼接到生成区域; +以同步分支的实际资源逐 key 比对生成结果,确认没有丢词或改写平台措辞。 + +参数必须声明为字符串或整数。macOS 别名还声明旧调用方传参顺序,生成器转换成 +对应的原生格式;历史 `%d` / `%llu` 的 ABI 类型由显式 formats 保留,不能改变传参宽度。翻译调整顺序或重复参数时使用带位置的格式,并转义普通百分号。 +Windows 只替换一轮占位符,避免把用户传入的 `{name}` 当成另一个参数展开。 +当前不支持复杂复数、任意 printf 指令或 ICU 模板;不在缺少实际需求时自建这些能力。 + +语言文件的覆盖率统计全部共享词条。完整语言缺词会失败;明确允许回退的语言可缺词, +但回退链必须到达完整默认语言。生成器还拒绝重复 key、未知字段、非法平台、越界覆盖、 +参数不匹配、原生位置格式越界或混用、Swift 关键字及生成成员重名、双重定义和过期产物。 +检查模式只读;所有输入及生成计划验证完成后才写文件。 +生成文件通过 `.gitattributes` 固定为 LF 换行,避免 Windows Git 自动转换换行后误报 +产物过期;集成测试在临时仓库中开启 `core.autocrlf`,实际检出后比较生成文件字节。 + +资源生命周期由 Git 和本次构建管理,不创建下载、运行时缓存或可跨工作树复用的生成物。 +原生打包只在目标副本转换二进制 plist,随后签名;运行时不写 bundle 或 Windows 安装 +资源,所以不改变代码签名及 Sparkle 增量更新基线。资源复用清单明确拒绝复制这类文件, +每个工作树根据自己的源版本重新生成。 + +## 考虑过的备选方案 + +- 两端运行时读取共享 JSON:需要新增原生加载与回退路径,破坏现有 SwiftUI 字符串表 + 查找方式,增加启动和语言切换状态,当前没有收益足以承担这个代价。 +- 一次替换全部词条:会混淆同名不同义文案,改动面大,回归难定位。先迁移通用操作和 + 少量设置标签验证架构,再完整迁入原有字典并比对每个原 key 的值,保证迁移不改变现有文案。 +- 为每个平台保留独立共享表:容易使 SwiftUI 的默认表和显式查找得到不同结果,因此 + 在同一个默认表中划出可检查的生成区域。 +- 使用新的多语言运行时库:当前已有原生表和简单命名插值,复用其完整调用边界即可。 + 复杂复数或第三方包出现实际需求后再评估成熟工具,不提前设计下载与热加载协议。 + +## 后果 + +已迁移词条有一个编辑来源,新增语言能贯通选择、生成和打包;平台差异和旧设置值保留。 +代价是提交中包含可复验的生成产物,并需要同时提交共享源与生成结果。macOS 旧英文 key 的语义 +歧义仍存在,新用途应使用独立语义 key;原资源缺少的译文以英文回退,不声称这些句子都已人工翻译。 +现有词条生成检查通过不等于两端整机语言切换验收完成。 + +## 验证 + +- `node scripts/generate-i18n.mjs --check`:清单、参数、覆盖和产物一致性;只读。 +- `node --test --test-timeout=60000 scripts/test-i18n.mjs`:非法输入、生成物所有权、新增语言、 + 平台覆盖、回退链和 macOS 二进制打包;比较打包前后源文件字节。 +- `./.agents/skills/write-stable-tests/scripts/verify-test-stability.sh`:稳定性静态规则。 +- `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'SharedI18nTests|AppLocalizationTests|LocalizationPackagingTests|GitLocalizationTests'`:原生资源查找、兼容 key、实际参数格式化、切换及原有行为。 +- `node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs -- src/i18n/locale.test.ts src/i18n/shared-i18n.test.ts src/features/settings/lib/settings-normalization.test.ts`:Windows 前端资源与设置;宿主平台必须在报告中注明。 +- `./scripts/verify-runtime-bundle-immutability.sh`:同时检查 macOS 与 Windows 运行时资源路径。 +- `node --test --test-timeout=15000 scripts/test-reuse-worktree-resources.mjs`:拒绝跨工作树资源复制。 +- `./scripts/verify-shared-contracts.sh`、`./scripts/verify-windows-boundaries.sh` 和 + `./scripts/verify-platform-feature-matrix.sh`:共享边界与能力记录。 + +生成器测试与原生测试共同消费 `shared/fixtures/i18n/format-v1.json` 和 `native-abi-v1.json`,覆盖32位有符号、64位无符号参数及参数重排、重复、 +大整数、零、Unicode 和用户值中的格式符。独立 CI 在 Linux、macOS、Windows 执行生成器 +测试并输出单项计时;Draft PR 只运行 Linux,转为 ready 后再触发三系统校验, +同一 PR 的旧 i18n 校验会取消。macOS 才运行原生打包用例。完整产品在中英文设置中切换、关闭重开并 +检查保存值和其他模块页面;Windows WebView2 整机验收仍须在 Windows 上执行。 + +Node 22 会把命令行超时同时应用到整份文件。词条增加后,macOS CI 在第 32 项之后 +触及原 15 秒文件预算,单项没有断言失败。文件预算因此为 60 秒;源码中单项最长 +15 秒、打包子进程 5 秒和 CI 步骤两分钟的边界都保留,不能通过扩大单项预算处理套件成本。 + +## 适用范围 + +- `shared/i18n/` +- `shared/fixtures/i18n/format-v1.json` +- `scripts/generate-i18n.mjs` +- `scripts/package-macos-localizations.sh` +- `windows/tauri/src/i18n/` +- `macos/Resources/en.lproj/Localizable.strings` +- `macos/Resources/zh-Hans.lproj/Localizable.strings` +- `macos/Sources/Lithe/Models/Settings/AppLanguage.generated.swift` +- `.github/workflows/verify-i18n.yml` diff --git a/.agents/notes/implemented/architecture/2026-10-24-database-provider-plugin.md b/.agents/notes/implemented/architecture/2026-10-24-database-provider-plugin.md new file mode 100644 index 0000000000..748236ce0c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-24-database-provider-plugin.md @@ -0,0 +1,233 @@ +# Agent 笔记:数据库拆成双端共用的独立插件 + +状态:已实现 + +## 先说结论 + +数据库连接功能已经从 Lithe 主仓库和主安装包中移出,成为组织仓库 `Lithe-IDEA/lithe-plugin-database` 中的社区插件 `io.github.lithe-idea.database`,在 Lithe Store 上架。只装 IDE 时没有任何数据库入口;从插件市场安装后才能使用。插件的界面是包内网页,后端用 Rust 编写,macOS 和 Windows 将共用同一份代码。 + +主仓库只保留通用的插件能力:不依附构建系统的插件后端、宿主凭据存储,以及一个过渡垫片——把旧版内置模块保存的连接、历史和密码迁入插件,并提示老用户安装。macOS 先上线;Windows 等插件宿主和插件市场就绪后,补一轮编译、适配和测试即可使用,插件本体不重写。 + +开发时记住一条:主仓库里不再为“数据库”写任何专属代码。SQL 判定、界面、驱动都只改插件仓库;主仓库只改通用的插件能力。 + +## 问题 + +### 体积:数据库在主包里占多少 + +2026-10-24 在本机 Lithe 0.6.0(arm64)上测量: + +| 部分 | 大小 | +| --- | --- | +| 整个 `Lithe.app` | 557 MB | +| `Contents/Resources/LanguageServers` | 397 MB | +| `Contents/MacOS/Lithe`(主程序,含数据库界面代码) | 105 MB | +| `Contents/Helpers/lithe-db-sidecar` + `lithe-db-mcp` | 约 9.4 MB | + +数据库的体积主要来自两个后端程序。不过,这次拆分的目的不只是体积:数据库还应该能单独安装、更新和卸载,并且两端共用一份实现。 + +### 现状:逻辑分布在哪里 + +| 层 | macOS | Windows | 是否共享 | +| --- | --- | --- | --- | +| 驱动与执行 | `rust/lithe-db-sidecar`,约 5400 行,支持 mysql、mariadb、postgresql、sqlite、sqlserver、mongodb、redis、nacos | `package-database-sidecars.ts` 要构建 `lithe-database` crate,仓库中没有这个 crate | 否 | +| 协议 | Swift 结构体(`DatabaseSidecarService.swift`)与 Rust serde 各自定义 | `features/database/types/` 里另一套 TS 类型 | 否,没有共享契约和测试样例(fixtures) | +| 领域规则 | `LitheDatabaseModule` 的 Models、Services,以及 `DatabaseFeatureModel` | TS store | 否 | +| 界面 | `Views/Database/`,约 6700 行 SwiftUI | `features/database/`,约 9400 行 React | 各写一套 | + +### 已经出现的偏差 + +1. **Windows 从来没有能用的数据库功能。** + - 前端调用 `connect_database`、`run_database_provider_command` 等 9 个 Tauri 命令,`windows/tauri/src-tauri/src/main.rs` 的命令注册表里一个都没有。 + - 这批前端代码在 2026-08-14 “React Tauri 替换 Qt 前端”时一次导入。仓库任何分支的历史里都没有对应后端,Qt 时期也没有数据库功能。 + - 用户能碰到这条坏路径:`file-utils.ts` 把 `.db`、`.sqlite`、`.duckdb` 文件交给数据库查看器,随后调用失败。 + - 功能矩阵把 Windows 的四项数据库能力标为 `implemented`;README 把数据库工作台列为核心功能,没有注明只支持 macOS。 +2. **macOS 把领域规则写在了壳里。** + - SQL 分析、语句拆分和格式化(`DatabaseSQLSupport.swift`); + - 结构对比、按方言生成 DDL(`DatabaseSchemaDiff.swift`); + - DBX 导入解密(`DatabaseDBXImportService.swift`); + - 字段遮罩、查询历史和定时备份。 + + 这些都写在 Swift 里,`DatabaseFeatureModel.swift` 有 2700 多行。 +3. **同一条规则写了三份,并且已经不一致。** + - “SQL 是什么类型、是否危险”分别由 Swift 的 `DatabaseSQLAnalyzer`(先去注释再按词判断)、sidecar 的 `is_dangerous_sql`(字符串匹配)和 mcp 的 `classify_sql`(另一套前缀匹配)判断。 + - 一个例子(由代码推导,未实机复现):连接开启生产保护后,执行 `DELETE FROM users`,换行后再写 `WHERE id = 1`。Swift 判断不需要确认,sidecar 却因为找不到 `" WHERE "` 而拒绝执行。macOS 不处理它返回的 `confirmation_required`,用户只看到报错,没有确认入口。 + - 反过来,开头带一行注释或使用 `WITH … DELETE` 的语句,能绕过 sidecar 的判断。 + +### 现有插件契约为什么装不下 + +- **网页界面只能操作构建系统。** 网页桥接对象(`window.lithe`)只能操作本插件声明的构建系统;插件函数在 macOS 上也只由构建 provider 的会话提供。数据库插件只能伪装成构建系统,违反[公共扩展契约](../../proposed/architecture/2026-10-13-public-extension-contract-and-provider-api.md)的“不要伪造声明”。 +- **插件拿不到宿主的凭据存储。** 插件调用宿主服务(`lithe/host/*`)在协议 v1 中全部被拒绝,没有地方安全地保存密码。 +- **Agent 用不上数据库能力。** `lithe-db-mcp` 只被打包进 `Contents/Helpers`,没有任何代码启动或登记它,Agent 实际调用不到。Lithe 已有自己的 `lithe` MCP(`lithe-mcp` 加 IDE 宿主的能力目录和项目授权),插件能力应当从这里进入,而不是再起一个独立 MCP 进程。 +- **Windows 还没有声明式插件宿主和插件市场。** 声明式插件宿主正在 #1424 中开发;插件市场目前只有 macOS 版。 + +## 决策 + +### 一、最终用户看到什么 + +- 只装 Lithe 时,没有数据库工具窗口、菜单或 Services 条目。 +- 在插件市场安装“数据库”插件后,数据库工具窗口出现:连接树、SQL 控制台、数据表格、结构对比,以及 Redis 和 Nacos 视图,都在宿主管理的窗口里,以插件网页显示。 +- 停用或卸载后,入口消失,插件拥有的进程全部结束。 +- macOS 先提供;Windows 在插件宿主和插件市场就绪后提供,使用同一个插件版本。 + +### 二、代码归属 + +| 位置 | 内容 | +| --- | --- | +| 组织仓库 `Lithe-IDEA/lithe-plugin-database`(新建) | Rust 后端(从 `lithe-db-sidecar` 演进,吸收 `lithe-db-mcp` 的查询能力)、网页界面、`plugin.json`、数据库专属的测试样例和兼容性测试 | +| 主仓库 | 只保留通用能力:契约、Rust Core 校验、两端宿主实现、Rust 后端 SDK 的契约测试 | +| 主仓库最终删除 | `macos/Sources/LitheDatabaseModule/`、`macos/Sources/Lithe/Views/Database/`、`rust/lithe-db-sidecar/`、`rust/lithe-db-mcp/`、`windows/tauri/src/features/database/` 及打包脚本中的数据库部分 | + +插件仓库只依赖公开契约:`lithe-plugin-docs` 中锁定的契约副本加官方 SDK,和 Cargo、SVN 插件的做法相同。主仓库不再按“数据库”写任何分支。 + +### 三、插件身份与分发 + +- 先作为社区插件发布:ID 为 `io.github.lithe-idea.database`,`vendor.signatureRequirement` 为 `"none"`,和 Cargo、SVN 等 7 个插件一样在 Lithe Store 上架。宿主按索引固定的 SHA-256 校验下载包,安装确认列出权限和后端程序,加载时重新核对安装文件的内容摘要。 +- 为什么暂不做官方签名:官方签名的完整路径(`lithe-official-plugins-v2` 公钥、`release-official-plugins.yml`、官方索引 `Plugins/marketplace/index.json`)从未发布过任何插件。v2 私钥在原组织主仓库的维护者手里,该仓库已改名归档,官方索引地址也还指向它。另配一把密钥是信任根变更,需要单独决定,所以先不走这条路。 +- 插件仓库的发布工作流在 `v*` 标签上测试、打包 universal 后端、跑端到端检查,然后发布 GitHub Release(zip 与 SHA-256);上架和升级是向 Lithe Store 提 PR 登记下载地址和 SHA-256。 +- 网页界面和清单在各平台通用,只有 Rust 可执行文件按平台编译;macOS 发一个 universal 包,同时登记 `darwin-arm64` 和 `darwin-x64`,Windows 就绪后再加 Windows 的包。 +- 构建阶段固定依赖,规则沿用[LSP 插件构建与资源归属](2026-09-30-lsp-plugin-build-and-distribution.md)。安装目录只读,运行状态写进平台存储。 + +### 四、主仓库要新增的通用能力 + +每一项都先写进契约、Rust Core 和测试样例,再由 macOS 宿主实现;Windows 宿主(#1424)按同一份契约实现。 + +1. **插件后端(`backend` 1,已在契约中实现)。** 清单的 `backend` 字段只含 `serverProcess` 的启动字段,需要 `backend.launch` 权限和一个网页工具窗。设计取舍: + - **每个插件一个进程,宿主全局,不按工作区划分。** 数据库连接属于用户而不是项目,按工作区起多份进程只会重复连接池;因此后端不能使用 `${workspaceStateDir}`,状态写在 `${stateDir}`,宿主以 `${stateDir}` 作为根目录和工作目录启动它。 + - **按需启动、随插件结束。** 本插件网页第一次调用 `functions.*` 时启动;插件停用、更新、卸载或宿主退出时结束。 + - **授权就是安装审查。** 后端在安装审查中以 `packagedBackend` 等条目列出,调用时不再按工作区弹同意框;带 `instance` 的构建 provider 调用仍沿用原来的同意规则。 + - **不承担领域契约。** 后端只回答插件函数,不回答 `lithe/build/*` 或 VCS 请求,避免再次“伪装成构建系统”。 + - 网页桥接 `functions.list` / `functions.invoke` 省略 `instance` 即调用本插件后端,复用现有[插件函数协议](2026-10-20-plugin-function-contract.md)的注册、调用、进度、取消和超时规则。 +2. **宿主凭据存储(插件函数协议 v2,已在契约中实现)。** v2 是第一个提供宿主服务的协议版本:`lithe/host/credentials/{get,set,delete}`,需要 `credentials.store` 权限(没有后端时声明即无效)。Core 在宿主看到请求前校验协议版本、授权和参数(key 只允许 `[A-Za-z0-9._:-]` 且不超过 128 字节,secret 不超过 16 KiB,拒绝未知字段以便后续版本扩展),通过 `hostRequest` 事件交给宿主,宿主用 `lsp.hostResponse` 应答;每个会话最多 16 个未应答请求。macOS 使用 Keychain,按插件 ID 划分命名空间;插件读不到别的插件或宿主自身的凭据。生命周期:停用和更新时保留,卸载时与 `${stateDir}` 一起删除。 +3. **Agent 工具经由 `lithe` MCP(后续)。** 不新增“插件声明 MCP 服务”的契约,也不保留独立的 `lithe-db-mcp` 进程。之后由插件以插件函数的形式提供 Agent 工具,IDE 宿主把它们登记进现有 `lithe` MCP 的能力目录,沿用其项目授权;插件停用时工具随之消失。这一项在数据库插件上线后单独设计,不阻塞第 1 到第 5 期。 +4. **按平台的可执行文件:不改契约。** 每个平台单独出下载包,可执行文件放在包内固定路径(例如 `bin/backend`),macOS 包可以是 universal 二进制。`${arch}` 本来就不能用于 `packaged` 路径,这样也不需要。Windows 的 `.exe` 等 Windows 宿主就绪时再定。 +5. **Rust 后端 SDK。** 在 `lithe-plugin-docs` 中新增 `sdk/rust`,实现插件函数协议和宿主能力,并补端到端样例,和 Node、Java SDK 对齐。 + +### 五、规则只保留一份 + +- SQL 分析、语句拆分、标识符引用、DDL 生成和危险语句判定,全部只在插件的 Rust 后端里实现。界面要不要弹出确认框、执行时要不要拦截,都使用同一个分析结果。 +- 插件仓库维护数据库专属的黄金样例。必须覆盖:换行或制表符分隔的 `WHERE`、开头的行注释和块注释、`WITH … DELETE`、多语句、无法识别的语句。 +- 主仓库只维护通用能力的样例(`fixtures/backend.plugin.json`、`function-protocol.json` 的 `hostRequests`),不再维护数据库样例。 + +### 六、老用户迁移 + +- **只迁给 Lithe Store 上的那份插件。** 社区包不带 Lithe 签名,插件 ID 谁都能用,所以垫片不认 ID,只认来源:安装记录必须是从 Lithe Store(`PluginMarketplaceSource.communityID`)安装的社区包,且索引条目的仓库地址正是 `https://github.com/Lithe-IDEA/lithe-plugin-database`(`DatabasePluginTransition.repository`)。从磁盘安装、来自其他市场来源或仓库地址不同的同 ID 包得不到任何旧数据和密码,也不写迁移标记,之后改从 Lithe Store 安装仍会迁移。安装后的文件每次加载都按内容摘要复核,安装后被改动的包不会加载。 +- 迁移由宿主的过渡垫片 `MacDatabasePluginMigration` 完成。它挂在插件后端的启动路径上(`MacPluginBackendSessions` 的 `prepareState`),在数据库插件后端第一次启动、读取状态之前运行,所以插件永远不会先看到空状态。 +- 迁移内容:偏好里的连接(`database.profiles.v1`)、文件夹(`database.connection-folders.v1`)和 SQL 历史(`database.sql-history.v1`)写进插件的 `${stateDir}`(`connections.json`、`sql-history.json`);“永久”密码从旧 Keychain 服务移到插件凭据(key 为 `connection::password`)。旧的 `group` 字段变成顶层文件夹;历史只保留属于现存连接的最近 100 条,时间从 2001 年起算的秒换成 1970 年起算的毫秒。“直到重启”的密码本来就不落盘,不迁移。 +- 顺序有讲究:先迁密码,再写历史,最后写 `connections.json`,然后才写迁移标记。这样任何一步失败,下次启动都会重试;`connections.json` 的存在表示插件已有自己的状态,之后永远不会被覆盖。迁不动的密码只记日志(不含密码本身),对应连接改为重新询问。 +- 旧数据原样保留,旧版 Lithe 仍能读到。备份计划和恢复点没有迁移,因为旧模块里没有任何界面能列出或使用它们。 +- 升级后,保存过连接、又没装插件的用户会看到一次通知“数据库已改为插件……”,带“打开插件市场”按钮(`DatabasePluginTransition`、`AppModel+DatabasePlugin`)。不自动在后台下载。通知按钮是通用能力:带动作的工作台通知会一直保留到用户处理。 +- 垫片同时知道旧模块和插件的存储格式,这是有意为之的过渡代码;等所有受支持的 Lithe 版本都不再内置数据库模块后删除。 + +### 七、Windows + +- 功能矩阵中 Windows 的数据库能力为 `missing`;`.db`、`.sqlite`、`.duckdb` 文件按普通文件打开。 +- **暂不适配:** 这次改造不为 Windows 做任何插件侧适配,也不要求 #1424 现在对齐。等 Windows 插件宿主和插件市场改造完成后,再按当时的宿主能力小幅调整插件逻辑,补 Windows 构建、WebView2 回归测试和发布。 +- 原有约 9400 行 Windows 数据库前端没有迁移,已随主仓库的数据库代码一起删除。暂不支持 DuckDB。 + +### 八、分期 + +| 期 | 内容 | 依赖 | +| --- | --- | --- | +| 1 | Windows 矩阵、`.db` 拦截、README 修正;修复 `WHERE` 误判,让 sidecar 与界面使用一致的词法判断,并让 macOS 处理 `confirmation_required` | 无 | +| 2 | 契约:插件后端、函数协议 v2 凭据宿主服务;Rust Core 校验与样例 | 无 | +| 3 | macOS 宿主实现第 2 期能力;`lithe-plugin-docs` 新增 Rust SDK | 第 2 期 | +| 4 | 新建 `lithe-plugin-database`:Rust 后端、网页界面、样例;先在 macOS 上以本地开发包验证 | 第 3 期 | +| 5 | macOS 发布插件,加入迁移提示,从主仓库删除数据库代码 | 第 4 期 | +| 6 | Windows 插件宿主和插件市场就绪后,按当时的宿主能力小幅调整插件,补 Windows 构建后发布 | Windows 插件市场 | + +第 1 到第 5 期不需要等待 Windows,已在同一个 PR(#1470)中分步提交。第 6 期只做小幅调整和 Windows 构建,插件本体不重写。 + +### 九、远程开发 + +第一版数据库后端只在本机运行,连接从本机发起;需要访问远端网络中的数据库时,使用连接自带的 SSH 隧道。是否让后端随远程开发会话运行在远端,等远程开发的插件运行模型确定后再评估。 + +### 正确做法 + +- 新增“执行前检查是否含 DROP”:在插件后端的 SQL 分析中加规则和黄金样例,网页界面根据返回结果显示提示。两端自动一致。 +- 新增一种数据库:只改插件后端和样例。主仓库不改一行代码。 + +### 不要这样做 + +- 在主仓库的 Swift 或 TS 中,为数据库再写判断逻辑或专属界面。 +- 为了让数据库插件通过校验,把它声明成构建系统,或者造一个空语言。 +- 让插件把密码写进自己的状态文件,或者写进插件安装目录。 +- Windows 宿主为数据库插件单独做一套桥接方法。 + +## 考虑过的备选方案 + +### 界面留在宿主,两端各画一套原生界面,驱动做成插件 + +它最吸引人的地方是 macOS 保持 SwiftUI 原生体验,宿主侧改动小。不采用,因为主仓库仍要保留数据库专属界面,两端还是各写一套;“代码移出主仓库、双端一份”的目标达不到。 + +### 宿主提供通用的表格、SQL 编辑器组件,插件只提供数据 + +它最吸引人的地方是两端都能保持原生控件,组件也能给其他插件复用。暂不采用,因为两端都要实现带编辑的数据表格和 SQL 编辑器这类重组件,契约面很大,会拖慢数据库上线。以后可以先从数据库树这类简单结构开始,逐步改成宿主原生渲染。 + +### 带 Lithe 发布者签名的官方插件 + +它最吸引人的地方是信任最强:只有带 Lithe 签名的包才能用这个 ID,迁移密码时不必另外核对来源。最初就是这样设计的。暂不采用,因为官方签名路线从未真正发布过插件,v2 私钥不在当前维护者手中,官方索引也指向已归档的仓库;要用就得另配一把密钥并改信任表,这是信任根变更。改为社区插件后,冒充风险由“迁移只认 Lithe Store 来源和仓库地址”来堵。以后官方签名路线恢复时,可以再把插件迁成官方身份(参照 Go、PHP 的 `migratedToCommunity` 反向处理)。 + +### 只把两个程序改成按需下载 + +它最吸引人的地方是最快就能减小体积。不采用,因为它不解决规则多份和双端不一致的问题,Windows 也用不上,迟早要返工。 + +## 后果 + +- `Lithe.app` 不再包含 `lithe-db-sidecar`、`lithe-db-mcp`、数据库图标和数据库界面代码;主仓库删除了 `LitheDatabaseModule`、`Views/Database/`、两个 Rust crate、Windows 的 `features/database/`、数据库 CI 工作流与分流、打包脚本中的数据库部分和对应测试。 +- **发布顺序是硬约束:** 主程序删除数据库后,老用户只能从插件找回功能。所以插件的第一个版本必须先在插件仓库发布,并在 Lithe Store 的 `lithe-plugin-index.json` 登记,之后才能发布不含数据库的 Lithe。 +- **信任边界:** 不带签名,信任来自 Lithe Store 的收录审查和索引固定的 SHA-256。如果用户移除了 Lithe Store 来源,或只从磁盘安装,旧连接和密码就不会迁入(旧数据仍保留)。Lithe Store 收录这个插件的更新时,维护者要像审查代码一样审查,因为它会拿到迁移过来的密码。 +- **网页界面体验:** macOS 上不再是原生 SwiftUI。插件使用宿主注入的主题变量和语言上下文贴近宿主风格;数据表格和 SQL 编辑器由插件自带,包体积和性能需要随版本实测。 +- **Windows 暂时没有数据库功能:** 第 6 期依赖 Windows 插件宿主和插件市场;在此之前 Windows 用户看不到数据库入口(以前的入口本来就不能用)。 +- Agent 用的数据库工具暂时没有。以后由插件以插件函数提供,登记进现有 `lithe` MCP,不再单独起 MCP 进程。 + +## 验证 + +- `./scripts/test-macos.sh --filter "MacDatabasePluginMigrationTests|MacPluginBackendSessionsTests|WorkbenchNotificationTests"`:迁移的内容、格式、只执行一次、不覆盖插件自有状态、密码迁移失败的处理;磁盘安装、其他来源和其他仓库地址的同 ID 包得不到旧数据,之后从 Lithe Store 安装仍会迁移;迁移先于后端启动并拿到安装记录;带动作的通知和一次性安装提示。 +- `./scripts/verify-module-boundaries.sh` 与 `./scripts/verify-shared-contracts.sh`:内置模块列表和共享模块样例中不再有数据库模块。 +- `bash scripts/test-classify-ci-changes.sh` 与 `node --test scripts/ci-workflow-gates.test.mjs`:CI 分流不再有数据库通道。 +- `./scripts/verify-runtime-bundle-immutability.sh`:运行时不向发行目录写入。 +- 2026-10-24 手工验收(macOS 开发版身份 `app.lithe.desktop.dev`,与正式版偏好和 Keychain 隔离):写入旧模块的连接、文件夹、历史和密码后启动,出现一次性通知,经“打开插件市场”从 Lithe Store 安装 Database 0.1.0,重启后打开工具窗口,连接全部可见;插件状态与凭据按“六、老用户迁移”的规则落地。验收数据随后全部清除。 +- 插件仓库的 `node scripts/e2e.mjs`:用 Lithe 的 Rust Core 启动打包好的插件后端,回答凭据请求,走完 SQLite 连接的全部页面函数,并确认安装包未被改写、状态目录只有 `connections.json` 和 `sql-history.json`。 + +## 已定决策 + +2026-10-24 与用户确认: + +- 界面使用插件网页,不保留 SwiftUI 原生数据库界面; +- 插件放在组织仓库,主仓库不保留数据库专属代码; +- ~~使用带 Lithe 签名的官方身份~~(同日改为先作为社区插件发布,见下); +- 后端为长期运行的 Rust 插件后端; +- macOS 先上线,Windows 等宿主和插件市场就绪后跟上; +- 老用户提示一键安装,不自动下载; +- 现有 Windows 数据库前端不迁移; +- 暂不支持 DuckDB; +- ~~签名在组织仓库 CI 中完成,私钥是插件仓库的受保护 secret~~(随社区插件决定取消); +- 远程开发第一版只在本机运行数据库后端,远端数据库通过 SSH 隧道访问; +- Windows 这次不做插件侧适配,等 Windows 插件市场就绪后再小幅调整; +- 第 1 到第 5 期在同一个 PR 中分步提交,不拆成多个小 PR; +- 官方签名路线从未发布过插件、v2 私钥拿不到,因此数据库插件先当作社区插件,在 Lithe Store 上架,不作为官方插件。 + +按用户授权由实现方确定(2026-10-24): + +- 后端每个插件一个进程、宿主全局,不按工作区划分;授权由安装审查覆盖; +- 凭据停用和更新时保留,卸载时删除; +- 按平台可执行文件不改契约,各平台下载包使用固定路径; +- Agent 工具之后经由现有 `lithe` MCP 暴露,不单独起 MCP 进程; +- 网页界面全新编写(TypeScript,符合 CSP 的打包产物),参照 macOS 现有功能; +- 老用户迁移由宿主侧的过渡垫片完成,挂在插件后端启动路径上,只迁移一次; +- 社区插件下,迁移只认 Lithe Store 来源和插件仓库地址,不认插件 ID。 + +## 适用范围 + +- `macos/Sources/Lithe/Platform/MacOS/Plugins/MacDatabasePluginMigration.swift` +- `macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift` +- `macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginCredentialStore.swift` +- `macos/Sources/Lithe/Application/Features/DatabasePluginTransition.swift` +- `macos/Sources/Lithe/Application/Features/PluginHostServices.swift` +- `macos/Sources/Lithe/Models/AppModel/AppModel+DatabasePlugin.swift` +- `shared/contracts/plugins/v1/` +- `rust/lithe-core/src/plugins/backend.rs` +- `rust/lithe-core/src/lsp/interface/plugin_function.rs` +- `shared/platform-feature-matrix/features/` + +相关 Note:[统一 VCS 模型与 Provider 接入](../../proposed/architecture/2026-10-22-unified-vcs-provider-model.md)(同类 provider 做法)、[模块运行时抽取路线](../../proposed/architecture/2026-09-13-module-runtime-extraction-roadmap.md)(数据库被列为适合插件化)、[插件自带网页界面](2026-10-18-plugin-web-content-in-host-windows.md)、[插件市场](../feature/2026-10-12-plugin-marketplace.md)。 diff --git a/.agents/notes/implemented/architecture/2026-10-24-remote-workspace-routing.md b/.agents/notes/implemented/architecture/2026-10-24-remote-workspace-routing.md new file mode 100644 index 0000000000..1f8dd8d73e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-24-remote-workspace-routing.md @@ -0,0 +1,58 @@ +# Agent 笔记:远程工作区按根目录路由 + +状态:已实现 + +## 先说结论 + +远程文件夹以“本地占位根目录”的身份打开成工作区:根目录是 `Application Support/Lithe/RemoteWorkspaces/` 下的一个空目录,工作台照常拿到一个文件 URL;这个根目录下面的所有文件都在远端。文件树和编辑器经过的两个端口 `WorkspaceOperations` 和 `WorkspaceFileOperations` 外面各包了一层路由:URL 落在远程根目录下就交给远端,其余原样交给本地实现。第一版只让文件树、打开、编辑和保存走远端;Git、搜索、构建、语言服务和终端在远程工作区里看到的仍是本地空目录。 + +## 问题 + +工作台的所有功能都以“工作区根目录是一个本地文件 URL”为前提:窗口、会话恢复、项目设置、文件树、编辑器都按 URL 识别。如果为远程项目换一种 URL(例如自定义 scheme),几乎每个功能都要改;而一次性把所有功能都接到远端,改动面太大,也无法分步验证。 + +## 决策 + +- **占位根目录**:`MacRemoteWorkspaceRoots` 在 `Application Support/Lithe/RemoteWorkspaces/<连接 id>/<远程路径哈希>/<文件夹名>` 创建一个空目录,用它作为工作区根。目录名是远程文件夹名,所以项目名显示正确。它不在 app bundle 里,不影响签名和 Sparkle 增量包;里面没有用户数据,跨启动保留,让按根目录保存的项目状态得以延续。 +- **注册表**:`RemoteWorkspaceRegistry` 记录每个占位根对应的连接、远程路径和 Core 会话 id。打开远程项目时登记,同一个根再次打开时,旧会话会被替换并断开。 +- **路由**:组合根(`MacServiceContainer`,负责组装依赖的地方)把本地实现包进 `RemoteRoutingWorkspaceOperations` 和 `RemoteRoutingFileOperations`。路由层对端口里的**每个**方法都显式转发:如果只覆盖一部分,没覆盖的方法会悄悄走协议扩展里的默认实现,本地文件的行为就变了。 +- **文档版本**:和本地一样,文档标识是读到的原始字节的 SHA-256。远端用大小和修改时间做冲突检测,所以路由层为每个读到的标识记住远端版本(最多 512 个),保存时把它作为 `expected` 传给 `remote.writeFile`;不认识的标识会先读当前内容比对哈希。文件变了就返回冲突,不覆盖。 +- **最近项目**:占位根目录不进“最近项目”,因为它离开连接就只是个空目录;重新打开要走“远程开发”窗口。 + +### 正确做法 + +新增一种需要读写工作区文件的能力时,通过这两个端口访问文件,远程工作区就会自动可用。 + +### 不要这样做 + +- 不要在功能代码里用 `FileManager` 直接读工作区下的文件:在远程工作区里它只会看到空目录。 +- 不要从占位根目录推导“这是远程项目”再写特殊分支,应该询问 `RemoteWorkspaceRegistry`。 + +## 考虑过的备选方案 + +### 为远程文件使用自定义 URL scheme + +语义最清楚,但工作台里几乎所有地方都假定是文件 URL,第一步就要改全部功能。 + +### 把远程目录同步到本地再打开 + +现有功能全都能直接用,但大项目同步慢、两边会不一致,构建和运行也会发生在本地而不是项目所在的机器上,和整体远程开发设计(构建、LSP 都在远端运行)相冲突。 + +## 后果 + +- 远程工作区第一版:文件树按需列目录、打开和保存 UTF-8 文件(含 BOM)、保存时检测冲突。 +- 暂不支持:新建、删除、重命名、复制(报“不支持”);外部修改监听;Git、搜索、构建、语言服务和远端终端。这些要逐个接到 `lithe-remote-client` 已有的远端能力上。 +- 连接在工作区关闭后仍保留,直到 App 退出。 + +## 验证 + +```bash +./scripts/test-macos.sh --filter RemoteConnections +./scripts/verify-service-boundaries.sh +``` + +## 适用范围 + +- `macos/Sources/Lithe/Services/Remote/RemoteWorkspaceRouting.swift` +- `macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteWorkspaceRoots.swift` +- `macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift` +- `macos/Sources/Lithe/Models/AppModel/AppModel+RemoteConnections.swift` diff --git a/.agents/notes/implemented/bug-fix/2026-09-27-windows-git-path-identity.md b/.agents/notes/implemented/bug-fix/2026-09-27-windows-git-path-identity.md index d35c1f5c38..83eb3692ce 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-27-windows-git-path-identity.md +++ b/.agents/notes/implemented/bug-fix/2026-09-27-windows-git-path-identity.md @@ -33,6 +33,7 @@ Issue #627 中,前端先判断绝对路径再替换反斜杠,把原生 UNC - `shared/fixtures/git/windows-paths.json` 由前端与 Rust 测试共同读取。 - `git_path_roundtrip` 原生集成测试使用真实 Git 仓库,把发现结果交给实际前端 TypeScript 规范化函数后回传 Core,核对提交、分支、stash 和链接工作树的监听目录。它需要 Git 和 Node.js 22.6+,子进程通过现有 Git Host 设置本地期限并清理进程树。 + - Windows CI 39933419913 的超长路径用例在 Node 子进程十秒截止时返回 `Cancelled`,没有产生路径结果不匹配。测试仅将 Windows 的真实 Node/TypeScript 加载期限改为二十秒,Git 夹具命令仍为十秒、Core 请求仍为五秒;SharedRust 与真实 JDT 两个入口为 `git_paths_roundtrip_` 设置单例六十秒上限,容纳多次进程交换,不调整其他测试或总套件期限。超时仍走原生进程树清理,并在失败诊断保留进程状态与 stderr;不能通过重试断言、复制前端逻辑或跳过长路径用例来绕开验证。 - 长路径用例先在短路径建立真实仓库,再移入超长目录。Windows CI 已观察到文件系统可访问但创建 Git 进程的工作目录被 `ERROR_DIRECTORY` 拒绝;用例仅接受成功读到原提交,或带原生错误 267 的 `process_start_failed`,不能返回空仓库或访问其他目录。 - Windows 专属测试真实创建末尾点/空格目录及普通同名目录,确认请求明确拒绝,不误用普通目录。 - 原生 watcher 测试用有界事件接收验证外部提交/切换所写的共享引用与独立 HEAD 元数据会通知对应工作树;没有用 sleep 等待防抖。 diff --git a/.agents/notes/implemented/bug-fix/2026-10-05-project-tree-selection-and-moves.md b/.agents/notes/implemented/bug-fix/2026-10-05-project-tree-selection-and-moves.md index e4d49db3ff..8a90feb70e 100644 --- a/.agents/notes/implemented/bug-fix/2026-10-05-project-tree-selection-and-moves.md +++ b/.agents/notes/implemented/bug-fix/2026-10-05-project-tree-selection-and-moves.md @@ -18,6 +18,17 @@ macOS 使用系统多文件拖动,工作区模块继续负责实际文件操 ## 决策 +- Windows 的“打开所选文件”命令为 `file.openSelected`,默认 F4,沿用现有快捷键设置的 + 覆盖与禁用规则。查询输入框复用统一的两段式快捷键状态机;完成、未匹配按键和超时均重置, + 监听器因首段输入重新注册时保留超时,组件卸载时清理计时器。 + 当前获得 DOM 焦点(页面里真正接收键盘输入的元素)的区域提供打开动作, + 项目树仍持有焦点路径,搜索仍持有匹配行列,不把这些状态再复制到快捷键 store。 + 项目树打开有效的可见文件并固定标签页;目录、失效路径、重命名及树搜索输入框不执行。 + 内容搜索的查询框打开当前键盘选中的匹配,结果文件行与片段打开自身文件及一基行列位置; + 搜索等待期间、空结果、替换及过滤输入框、编辑器和终端不借用其他区域的选择。 + 编辑器开启 Vim 时,这些文件区域仍能使用 F4。区域卸载即注销动作,弹窗通过真实 DOM + 焦点隔离。例如搜索选中了 A,但焦点在 B 的文件行时,应打开 B;不要读取侧栏旧路径而打开 A。 + - 文件、目录、项目根和多选右键菜单复用已有项目范围查找与替换入口,沿用整个项目范围、 选中文字预填和既有替换预览/确认流程;树视图只提供入口,实际查找和替换由已有功能处理。 @@ -77,6 +88,8 @@ macOS 使用系统多文件拖动,工作区模块继续负责实际文件操 ## 考虑过的备选方案 +- 在项目树和搜索框分别硬编码 F4:改绑和禁用后仍可能触发,也会与全局捕获监听冲突, + 因此只在集中命令目录注册一次,复用各区域已有的文件打开动作。 - 监听侧栏活动路径来跟随编辑器:该路径同时接受目录点击,无法区分树选择与编辑器切换, 会把刚选中的目录改回编辑文件;改为复用同步 Hook 已有的编辑器目标,不另建缓冲区选择器。 - 在树菜单单独实现搜索或直接替换文件:会绕过已有搜索状态和替换预览/确认流程,不采用。 @@ -89,6 +102,10 @@ macOS 使用系统多文件拖动,工作区模块继续负责实际文件操 ## 后果 +F4 不新增文件解析、导航历史或原生调用;代价是文件区域需要注册和清理自己的焦点动作。 +搜索片段内的代码行按钮以自己的映射行作为 F4 目标,匹配行保留匹配列,上下文行从第一列打开;片段标题或容器仍使用该片段的已选匹配,避免同一片段里 Tab 聚焦到另一行后跳回旧位置。 +本次只补齐 Windows,macOS 的 F4 行为仍需独立实现与验收。 + 独立选择减少误解,目录移动不需要枚举磁盘后代,懒加载避免为屏幕外每行启动图标任务。 代价是维护一个原生输入适配器;真实拖动、上下滚动及大目录性能仍需 macOS 实机验收。 Windows 现已加入 Ctrl/Shift 可见范围多选、批量复制/剪切/删除及已选集合拖动,目录单击选择、双击/箭头展开。拖动预检重名和父子重复,底层用不覆盖发布防止同名文件丢失;逐项操作并在工作区切换后停止后续项。Windows 原生 WebView2 的拖动、焦点与大树验收仍 pending,不能用选择工具函数测试代替。 @@ -96,6 +113,11 @@ Windows 现已加入 Ctrl/Shift 可见范围多选、批量复制/剪切/删除 ## 验证 +- Windows F4 回归使用 `test-stability-windows.ps1 -Scope Frontend` 运行 + `file-explorer-selection.test.tsx`、`f4-file-navigation.integration.test.tsx`、 + `use-keymaps.integration.test.tsx` 与 `tree-dom-event.test.tsx`。 + 验证方向键后的目标、搜索一基行列、焦点文件与旧选择不一致、改绑与禁用、Vim 设置、 + 输入框和无效目标隔离;真实 WebView2 焦点及四项修复的完整 Windows 构建另行验收。 - `./.agents/skills/write-stable-tests/scripts/verify-test-stability.sh` - `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'ProjectTree|batchMove'` - `./scripts/verify-service-boundaries.sh` @@ -129,6 +151,11 @@ Windows 现已加入 Ctrl/Shift 可见范围多选、批量复制/剪切/删除 ## 适用范围 +- `windows/tauri/src/features/keymaps/hooks/use-file-open-target.ts` +- `windows/tauri/src/features/global-search/components/global-search-buffer.tsx` +- `windows/tauri/src/features/global-search/components/global-search-results.tsx` +- `windows/tauri/src/features/global-search/components/search-excerpt-results.tsx` +- `windows/tauri/src/features/global-search/components/f4-file-navigation.integration.test.tsx` - `windows/tauri/src/features/file-explorer/components/file-explorer-tree.tsx` - `windows/tauri/src/features/file-explorer/hooks/use-file-explorer-sync.ts` - `windows/tauri/src/features/file-explorer/components/file-explorer-reveal.test.tsx` diff --git a/.agents/notes/implemented/bug-fix/2026-10-14-child-processes-end-with-the-ide.md b/.agents/notes/implemented/bug-fix/2026-10-14-child-processes-end-with-the-ide.md index 5476640e12..2a9e29bb71 100644 --- a/.agents/notes/implemented/bug-fix/2026-10-14-child-processes-end-with-the-ide.md +++ b/.agents/notes/implemented/bug-fix/2026-10-14-child-processes-end-with-the-ide.md @@ -6,6 +6,8 @@ macOS 上 Lithe 启动的运行、服务、预启动任务、测试、Maven 和插件进程,生命周期都绑定在 Lithe 上:项目会话关闭时由所属模块停掉自己的会话;应用退出前还有一道同步兜底,对所有仍存活的受管进程组先发 SIGTERM、限时后发 SIGKILL。正常退出和更新安装的强制退出两条路径都走这道兜底。 +Windows(#1137)的运行会话、调试适配器和语言服务器现在各自放进一个 Job Object(作业对象,Windows 内核按句柄管理的一组进程):停止时结束整组进程,Lithe 崩溃或被强制结束时内核也会连带结束它们。两端在关闭项目或窗口、退出 Lithe、安装更新而要停止运行服务时,都会先列出这些服务并询问;是否询问由同一条 Rust Core 规则和“退出时运行中的运行服务”设置决定。 + ## 问题 运行 Spring Boot 配置后退出 Lithe,Java 进程留在系统里继续占着 8080 端口,父进程变成 launchd。 @@ -42,10 +44,41 @@ macOS 上 Lithe 启动的运行、服务、预启动任务、测试、Maven 和 - 退出时无限等待子进程:一个卡住的子进程会让应用退不出去。兜底必须有界。 - 在宿主里按插件或语言名字挑进程来杀:兜底针对的是进程组的所有权,与进程属于哪个插件无关。 +### Windows:进程树归属和统一退出入口(#1137) + +Windows 原来有五个漏洞,叠在一起导致“关闭 Lithe 后服务还在跑”: + +1. 运行进程没有放进 Job Object,Lithe 一崩溃,所有子进程都成了孤儿。 +2. 停止靠 `taskkill /F /T /PID`。它沿父进程 PID 链找后代,中间进程(例如 `mvn.cmd` 启动 Java 后自己退出)一断,后代就找不到了,而且失败被静默吞掉。 +3. 窗口销毁只取消预启动任务,应用退出不停运行会话。 +4. 菜单“退出”直接调用 `exit(0)`,跳过了窗口的关闭守卫,包括未保存文件的确认。 +5. 更新器启动安装程序后调用 `std::process::exit(0)`,不触发 `RunEvent::Exit`,退出清理一行都不执行。 + +修复按“谁拥有进程树”来做,而不是给每个入口补一次 `taskkill`: + +- **进程树。** `lithe-git-host` 新增 `ProcessTree`:子进程以挂起状态创建,先加入设置了 `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` 的 Job,再恢复主线程,所以它启动的任何后代都在 Job 里。停止用 `TerminateJobObject`,然后用 `JobObjectBasicAccountingInformation.ActiveProcesses` 确认整组已经清空;超时会报错,不会假装成功。句柄关闭(包括 Lithe 崩溃)时内核结束整组。运行会话、调试适配器(它启动的被调试程序也在同一组)和 Windows 上的语言服务器(批处理启动的 JVM 也在同一组)都用它。Unix 上 `ProcessTree` 是新的进程组,现有 macOS 路径不变。 +- **允许主动脱离。** 运行会话的 Job 设置了 `JOB_OBJECT_LIMIT_BREAKAWAY_OK`:程序显式用 `CREATE_BREAKAWAY_FROM_JOB` 启动的进程可以离开,相当于 Unix 上自己 `setsid` 的守护进程。Git 进程树不允许脱离。 +- **根进程退出即收尾。** 根进程退出后,Lithe 结束它留下的整组进程,再等输出管道读完,和 macOS `MacManagedProcess` 一致。否则残留的后代会一直占着管道,会话永远不结束。 +- **停止失败可见。** 停止超时的会话放回表里,错误里写明服务名和 PID;窗口不会关闭,弹窗显示原因,可以重试或取消。 +- **原生表是唯一来源。** 关闭前列出服务时读原生会话表(`run_list_owned_processes`),而不是前端 store:Maven 目标、已关闭项目留下的服务只在原生表里。Maven 依赖解析这类 Lithe 自己的后台任务标记为 `background`,随窗口结束但不出现在问题里。 +- **一个退出入口。** 菜单“退出”改为通知每个窗口走自己的关闭守卫(未保存文件 → 运行服务),最后一个窗口关闭后应用自然退出。更新安装先下载,再确认并停止所有窗口的运行服务,然后调用原生 `app_prepare_update_install` 结束调试、Agent 和语言工具,最后才运行安装程序。 + +### 关闭和退出前询问运行服务 + +运行服务(运行配置、Maven 目标)的进程树和输出管道属于 Lithe,没法在 Lithe 关闭后继续保留,所以这里只有“停止全部”和“取消关闭”两个选项,不像后台服务那样可以逐项保留。 + +- Rust Core 的 `processLifecycle.resolve` 对 `kind: run`、`ownership: launched` 的记录,在 `quit` 和新的 `closeWorkspace` 场景下返回 `confirmStop`(设置为询问)或 `terminate`(设置为直接停止),原因是 `runServicesSetting`。`closeWorkspace` 场景下其他需要提问的记录一律 `defer`,留到退出时再决定。规则失败时两端都按“询问”处理。 +- macOS 在三个位置询问:退出(排在终端、Agent 任务和后台服务之前)、关闭项目(在文档关闭流程之前,因为文档是逐个关闭的,之后再取消会留下没有文档的项目)、关闭项目窗口(在未保存文件确认之后、清理之前)。 +- Windows 在窗口关闭守卫里询问,顺序是未保存文件在前、运行服务在后;更新安装时询问全部窗口的服务。 +- 设置:macOS 在“进程终止策略”页,Windows 在“运行配置”页(不打开项目也显示),默认询问。 + ## 考虑过的备选方案 - **只修 `ExecutionFeatureGraph.stop()`。** 能解决 Spring Boot 这一例,但下一个漏停的所有者、以及异步升级被进程退出截断的问题仍在。 - **让子进程自己感知父进程死亡。** macOS 没有 Linux `PR_SET_PDEATHSIG` 那样的机制;通过管道或 kqueue 监视父进程需要在子进程里放代码,用户的 Java 程序做不到。 +- **Windows 继续用 `taskkill /T`,只在退出路径多调用几次。** 中间进程退出后 PID 链断开的问题仍在,崩溃时也没有任何兜底,这就是 #1137 一直复现的原因。 +- **Windows 只在前端列出服务(#1404 的第一版)。** 前端 store 只认识运行面板里的会话,Maven 目标和已关闭项目留下的服务不在其中,问题列表会漏项;改为读原生会话表。 +- **给运行服务提供“保留”选项。** 运行服务的输出管道和进程树都归 Lithe,Lithe 退出后保留只会制造无人管理的孤儿,所以确认里只有停止和取消。 ## 后果 @@ -53,9 +86,9 @@ macOS 上 Lithe 启动的运行、服务、预启动任务、测试、Maven 和 未覆盖的情况: -- **Lithe 崩溃或被强制杀死。** 兜底是进程内代码,这时不会执行,子进程仍会变成孤儿。 +- **macOS 上 Lithe 崩溃或被强制杀死。** 本笔记的兜底是进程内代码,这时不会执行;崩溃由 [统一子进程生命周期](../../proposed/architecture/2026-10-16-unified-child-process-lifecycle.md) 中的监护进程处理。Windows 的运行、调试和语言服务器进程树由 Job Object 兜底。 - **不经过 `MacManagedProcess` 的进程。** Rust Core 启动的语言服务器由 `languageToolingSessions.stopAll()` 停止,内置终端由终端自身管理,它们不在这张表里。 -- **Windows。** 退出处理只关闭语言工具和调试会话,没有停止运行会话,运行进程也没有放进 Job Object,尚未对齐。 +- **Windows 终端和 Agent。** ConPTY 终端里启动的程序、Agent 进程树还没有放进 Job Object,Lithe 崩溃时仍可能残留;正常退出由各自的关闭流程处理。 ## 验证 @@ -65,6 +98,12 @@ macOS 上 Lithe 启动的运行、服务、预启动任务、测试、Maven 和 - `macos/Tests/LitheTests/MacProcessRunnerTests.swift`:`applicationExitKillsEveryGroupItsOwnersLeftRunning` 启动一个忽略 SIGTERM 的进程和一个带忽略 SIGTERM 后代的进程,所有者丢弃引用后调用兜底,确认两个进程组在 2 秒内消失。 - 实际应用(2026-10-14,macOS):运行 Spring Boot 配置,Java 进程监听 8080 后从菜单退出 Lithe,1 秒内 Lithe、语言服务器和 Spring Boot 进程全部消失,端口释放。修改前同样的操作会留下 Java 进程。未实测更新安装的强制退出路径。 - `./scripts/test-macos.sh --filter '(MacProcessRunnerTests|ExecutionModule)'`。 +- `rust/lithe-git-host/tests/process.rs`:`process_tree_terminate_ends_the_root_and_its_descendants` 启动一个会再启动后代的进程,确认 `terminate` 返回时两者都已结束;Windows 上 `dropping_a_process_tree_ends_it_like_a_crashed_host` 只丢弃句柄,确认内核结束整组。 +- `windows/tauri/src-tauri/src/run.rs` 测试(仅 Windows):关闭一个窗口只结束它自己的进程树、丢弃会话等同 Lithe 崩溃、原生列表按启动顺序包含全部会话。 + - Windows CI 39919376382 在关闭窗口和丢弃会话两例中,根进程已退出,但立即读取的 `sysinfo` 快照仍包含后代 PID。测试改为停止前打开后代的只等待句柄,停止后分别在两秒内等待根进程和后代的退出信号;另一窗口的后代用零等待确认仍在运行。依据 [Windows 进程终止语义](https://learn.microsoft.com/en-us/windows/win32/procthread/terminating-a-process),退出进程的对象可以因尚未关闭的句柄而继续存在,进程退出信号才是结束依据。保留原来的窗口隔离、重复关闭和崩溃清理断言,不改产品停止流程。夹具的首字节就绪信号有两秒期限,失败路径和断言展开时也按所属会话清理进程树、等待退出并释放管道和句柄;不依赖任意睡眠或 PID 重查。 +- `rust/lithe-core/src/lsp/interface/process.rs` 测试(仅 Windows):结束批处理启动的语言服务器时,脚本启动的子进程一起结束。 +- `shared/fixtures/process-lifecycle/exit-plan-v1.json`、`settle-v1.json`:运行服务在退出、关闭项目、直接停止设置和崩溃恢复下的动作;macOS `ProcessLifecycleFixtureTests` 用 Swift 类型解码同一批 fixture,并覆盖提问顺序、取消和规则失败时仍询问。 +- `windows/tauri/src/features/window/services/run-services-exit.test.ts`:询问、取消、停止失败后重试、直接停止、后台任务不询问、Core 不可用时询问。 ## 适用范围 @@ -76,3 +115,11 @@ macOS 上 Lithe 启动的运行、服务、预启动任务、测试、Maven 和 - `macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift` - `macos/Tests/LitheTests/MacProcessRunnerTests.swift` - `shared/platform-feature-matrix/features/run-processes-end-with-ide.json` +- `rust/lithe-git-host/src/lib.rs`(`ProcessTree`) +- `rust/lithe-core/src/process/lifecycle.rs`、`rust/lithe-core/src/lsp/interface/process.rs` +- `shared/fixtures/process-lifecycle/`、`shared/contracts/rust-core-api.md` +- `macos/Sources/Lithe/Application/Features/ProcessExitCoordinator.swift`、`WorkspaceSessionCoordinator.swift` +- `macos/Sources/Lithe/Views/App/RootView.swift`、`ProcessTerminationSettingsView.swift` +- `windows/tauri/src-tauri/src/run.rs`、`debug.rs`、`owned_processes.rs`、`main.rs` +- `windows/tauri/src/features/window/`(关闭守卫、运行服务确认、退出入口) +- `windows/tauri/src/features/settings/stores/update.store.ts` diff --git a/.agents/notes/implemented/bug-fix/2026-10-17-git-push-target-and-upstream-tracking.md b/.agents/notes/implemented/bug-fix/2026-10-17-git-push-target-and-upstream-tracking.md new file mode 100644 index 0000000000..4a9d028549 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-17-git-push-target-and-upstream-tracking.md @@ -0,0 +1,71 @@ +# Agent 笔记:推送目标与分支跟踪语义 + +状态:已实现 + +## 先说结论 + +Lithe 推送时不再让 Git 的全局配置决定“推到哪”。创建分支不继承 upstream(上游跟踪分支),异名 upstream 必须由用户显式选择目标后才推送,且**预览与推送共用同一个目标解析函数**:用户看到的 `本地分支 → 远端/分支`,就是推送实际校验和更新的那个分支。这次修复堵住了一个真实事故:从 `origin/main` 新建功能分支后推送,会直接把功能分支推到远端 `main`。 + +## 问题 + +从 `origin/main` 新建分支时,Git 默认配置 `branch.autoSetupMerge=true` 会把新分支的 upstream 设为 `refs/heads/main`。Lithe 的推送使用显式 refspec(`refs/heads/feature/topic:refs/heads/main`),绕过了 `push.default=simple` 对异名分支的保护,于是功能分支的提交被写进远端 `main`。 + +三处代码共同造成这个结果:创建分支时未控制跟踪;推送目标解析直接采用 upstream 的分支名;推送时用显式 refspec 拼装目标。同一个文件里的 worktree 创建路径早已用 `--track`/`--no-track` 显式冻结跟踪语义,`createBranch` 却漏了。 + +## 决策 + +- **创建分支显式 `--no-track`**:`createBranch` 不再让 Git 决定上游,结果独立于用户全局配置。“检出远端分支并跟踪”是另一条语义,走 `checkout` 路径,不受影响。两者必须保持分离——合并它们会重新引入隐式跟踪。 +- **推送目标解析返回目标类型**:`resolve_push_target` 返回 `publish`(无 upstream,发布同名分支)、`tracking`(同名 upstream)、`divergent`(异名 upstream)三态。`divergent` 不作为默认推送目的地。 +- **异名必须显式确认,且确认绑定到用户看到的那个分支**:`git.pushPreview` 与 `push` 都接受可选 `pushTarget`(`publish` 或 `upstream`),并用同一个 `resolve_push_target(root, reference, choice)` 解析。预览带着选择取回“该选择会更新的远端分支、跟踪 OID 和提交列表”,客户端再把这份预览的 `expectedPush` 与同一个 `pushTarget` 一起发回;`validate_push_expectation`、`--force-with-lease` 的 OID 与最终 refspec 都来自这个有效目标。未确认时 `push` 返回 `invalid_request`,错误信息写明可行的命令行替代做法。配置不会被静默改写:确认 `upstream` 时原配置保留,确认 `publish` 且推送成功后才改写。 +- **提交并推送由 Core 在写任何提交之前提问**:`git.workspaceCommitPrepare` 对每个待推送的仓库用同一个解析函数检查是否异名,把需要回答的仓库放进 `pushTargetQuestions`(空时不序列化,保持旧客户端与共享 fixture 的线格式不变)。客户端带着 `pushTargets: { 仓库ID: { choice, remote, remoteBranch } }` 重新 prepare;回答必须与当前远端和分支一致才算有效,否则 Core 再问一次。回答按仓库 ID 分别保存在 `plan.pushTargets`,重试时由 Core 从上一次会话恢复并重新校验,客户端无需转发。轮到某个仓库执行时,只要它的异名目标还没有有效的回答,Core 就在写入该仓库的提交之前拒绝这一步,所以不会出现“已提交、却因目标未确认而无法推送”的残留提交,也保护了不处理这些问题的旧客户端。 +- **新增可选字段必须兼容旧调用方**:`expectedPush` 的 `targetKind` 只在调用方提供时才比较。把可选字段做成严格比较,会让所有未升级的调用方被判成“预览已过期”——这是本次实现中真实踩到的回归。`pushTargetQuestions` 同理,空数组不出现在线格式里。 +- **Windows 两个入口都使用 Core 的答案**:推送弹窗选择后用该选择重新取预览,并在重新取回的预览与第一次确认的目的地不一致时撤销选择;“提交并推送”不再在客户端先取预览,而是把请求直接交给工作流,由 Core 返回问题,面板用一个对话框一次回答所有仓库。 +- **macOS 每次推送都先审阅,且推的就是审阅的内容**:分支的“推送”不再弹出只问“要推送吗”的确认框,而是打开 IDEA 式推送弹窗。弹窗先向 Core 取 `git.pushPreview`,顶部显示 `本地分支 → 远端/分支`(需要时注明“将在 origin 上创建新分支”或“将 origin/x 设为上游分支”),左侧列出将发送的提交,右侧显示所选提交改动的文件;用户点“推送”后,弹窗把**这份预览**的 `expectedPush` 与同一个 `pushTarget` 发回,Core 在仓库、目的地或跟踪提交已变化时按过期拒绝。取代的是原来每次都出现、却不告诉用户目的地的确认框,所以没有增加点击次数。 +- **macOS 异名上游在弹窗内选择,选择绑定到当时看到的目的地**:`divergent` 时弹窗不提供默认目的地,“推送”保持禁用,直到用户在“发布到 `origin/<本地分支>`”和“推送到 `origin/<上游>`”中选一个;选择后用它重新取预览,使提交列表和目的地与最终远端一致。若重新取回的目的地与做出选择时看到的不同(上游被改指别处),选择作废并重新询问。没有新提交的“发布分支”仍允许推送,因为它会创建远端分支。 +- **macOS “提交并推送”先出审阅单再提交**:只要请求带 `push`,就始终显示审阅单,列出每个仓库的目的地,而不再只在含子模块时才显示。异名仓库在审阅单里就地选择,选择通过 `pushTargets` 交给 Core 重新 prepare;Core 仍在提交之前再校验一次,答案与当前远端不符就重新提问。审阅单保留已提出过的问题,让用户在继续之前仍可改选;每次重建审阅单(切换父仓库引用、确认)都必须带上已有的 `pushTargets`,因为 Core 不会从 `reviewed` 计划继承它们——漏带会让已回答的仓库再被询问。原生层的 `GitWorkspaceCommitPlan` 也必须保留 `pushTargets` 字段,否则计划经原生层往返后答案丢失,推送步骤会被 Core 拒绝。 + +正确做法:新建分支后首次推送应发布到同名远端分支,远端 `main` 保持不变;异名场景先让用户看到“发布到 `origin/feature/topic`”和“推送到 `origin/main`”,用户选定后以该选择重新取预览,再推送。 + +错误做法:在推送路径上调用 `git push --set-upstream origin ` 之类的固定写法绕过目标解析(`publishBranch` 是 macOS PR 流程的专用入口,其 `origin` 在该语境下是正确的,不属于本条约束);在未确认时用 `--force` 或远端保护规则兜底;让预览用一套目标、推送用另一套目标(第一版实现就因此出现“预览显示同名分支,确认 `upstream` 后实际更新 `main`,而校验仍通过”的缺口);在客户端替 Core 预判“要不要问”,并在预判没有结果时直接中止(会让所有普通“提交并推送”静默失效)。 + +## 考虑过的备选方案 + +- **设置仓库级 `branch.autoSetupMerge=false` 后恢复**:需要临时改用户配置,崩溃或中断会留下脏状态,且影响其他工具。改用命令级 `--no-track`。 +- **在推送时把 `remote_branch` 直接改成同名分支**:会让“有意配置异名 upstream”的用户失去该能力,且预览展示的目标与实际不符。 +- **用布尔 `isPublish` 表示目标**:表达能力不足,无法区分 `tracking`、`publish`、`divergent`,界面无法判断是否需要让用户选择。 +- **为普通同名推送增加二次确认**:连续确认框会让用户机械点击,反而降低警觉性。只有异名目标需要决策。macOS 的推送弹窗不属于这一类:它替换了原有的无信息确认框,承载的是“看到目的地和提交”这个信息,而不是多一次询问。 +- **macOS 只在异名时才弹窗,普通推送沿用原确认框**:原确认框只说“推送所选本地分支到已配置的远程仓库”,用户无法在点击前看到真正的目的地,正是本次事故的盲区。改为每次都展示目的地与提交。 +- **macOS 在客户端自己判断目的地**:与 Windows 的结论相同——判定与实际推送分属两套解析会让预览与推送不一致。弹窗与审阅单只展示 Core 解析的结果。 +- **工作区提交计划里只存一个全局 `pushTarget`**:多个仓库可能各自跟踪不同的分支,一个选择会套用到所有仓库,既可能把答案用到没被问过的仓库,也无法逐个校验。改为按仓库 ID 的 `pushTargets`。 +- **由客户端在提交前先取预览再决定是否询问**:判定与实际推送使用两套解析,预览失败时只能中止或放行,两者都不安全;并且每个客户端都要重写一遍。改由 Core 在 prepare 中统一提问。 +- **修改 `publishBranch` 的远端解析**:它服务于 macOS 在分离 HEAD 下发布 PR 分支,硬编码 `origin` 在该语境正确,且 issue 未涉及,修改会带来无法在本机验证的 macOS 回归风险。 + +## 后果 + +收益:异名推送从“静默覆盖远端主干”变成“必须明确选择”;创建分支的行为不再随用户 Git 配置漂移;推送预览、过期校验、租约和最终更新的分支始终一致;没有任何路径会留下推不出去的提交。 + +代价:从远端新建的分支不再有 upstream,`pushPreview` 改走“排除远端可达提交”的计算路径;这曾有显示 0 个提交的风险,已用集成用例钉住。异名分支的存量仓库在推送时会出现一次选择交互,这是预期行为。 + +macOS 的界面(推送弹窗与提交并推送审阅单)已接入,但**尚未在真机界面上目视验证**:原生层与 Core 的衔接由真实 Core 的集成用例覆盖,SwiftUI 布局、明暗主题和窄窗口表现仍需要有人打开应用确认。 + +## 验证 + +- `rust/lithe-core/tests/git_push.rs` 覆盖:新建分支无 upstream、检出远端分支仍建立跟踪、本地起点不产生 upstream、异名未确认被拒、确认 `publish` 与 `upstream` 各自的落点、旧调用方省略 `targetKind` 仍可推送、`targetKind` 给错仍被拒、无 upstream 分支的预览仍列出自身提交;以及按选择预览的目标分支、跟踪 OID 与 `createsRemoteBranch`、确认后 upstream 被改指另一分支时已审阅的推送被判过期、选择与预览不一致被判过期、`--force-with-lease` 作用在所选 upstream 分支及其 OID 上、对非异名分支忽略 `pushTarget`。 +- `rust/lithe-core/src/tests/git_workspace_commit.rs` 覆盖:异名分支在提交前被询问且不产生提交、确认值随计划传递并在 `/` 分隔的 upstream 上保持原配置、多仓库逐个提问与确认、重试时恢复并重新校验确认值、upstream 在确认后移动会再次提问。 +- `windows/tauri` 的 `git-push-api.test.ts`、`git-push-target-prompt.test.tsx`、`git-workspace-commit-workflow.test.ts`、`git-commit-panel.test.tsx`、`git-commit-panel-push-target.test.tsx` 覆盖:预览携带 `pushTarget`、普通“提交并推送”直接到达工作流、提问先于审阅与提交、答案累加与取消、逐仓库回答并绑定远端分支。 +- `shared/contracts/rust-core-api.md` 记录 `targetKind`、`createsRemoteBranch`、预览与推送的 `pushTarget`、`pushTargetQuestions` 与 `pushTargets`。 +- `macos/Tests/LitheTests/GitPushReviewBridgeTests.swift` 用真实 Core 和本地裸远端覆盖:异名上游的预览为 `divergent`;选 `publish` 后目的地是同名远端分支并创建它、远端 `main` 不变;选 `upstream` 只更新异名分支;预览之后仓库又有新提交时推送被拒绝并带 Core 的“预览已过期”信息(Core 把拒绝放在 `operationError` 而不是退出码里,原生层必须检查它);“提交并推送”先返回问题、带 `pushTargets` 重新 prepare 后问题消失,逐步执行后只更新所选分支。 +- `macos/Tests/LitheTests/GitPushDialogPresentationTests.swift` 覆盖弹窗状态:异名上游在选择前不可推送、选择绑定到看到的目的地并在目的地变化时作废、推送发出的恰是审阅的预览与选择、被拒绝后重新加载并显示原因、没有新提交的发布分支仍可推送、无内容时不可推送、选中提交会加载其改动文件。 +- `macos/Tests/LitheGitModuleTests/GitModuleTests.swift` 覆盖:预览与审阅后的推送经服务层透传,推送失败显示原因并清除忙碌状态;“提交并推送”在 Core 不要求确认时也先停在审阅单、不执行任何步骤;异名问题阻止继续、答案随请求与后续确认一起发给 Core、问题被再次提出时旧答案作废;`pushTargets` 与 `pushTargetQuestions` 的线格式往返。 +- `AppLocalizationTests.pushDestinationUsesTheRequestedLanguage` 覆盖弹窗新增文案的中英文。 +- 运行 `scripts/verify-platform-feature-matrix.sh`、`scripts/verify-agent-notes.sh`、`cargo test --manifest-path rust/lithe-core/Cargo.toml --test git_push` 与 `--lib git`。macOS 通过 `scripts/test-macos.sh --no-parallel --filter` 运行了上述相关套件(Rust Core 已链接);完整套件在本机并行运行时有与本变更无关的超时类用例失败,未作为通过依据。Windows 真机界面未在本次变更中运行。 + +## 适用范围 + +- `rust/lithe-core/src/git/mod.rs`:`createBranch`、`resolve_push_target`、`divergent_push_destination`、`push`、`push_preview`、`validate_push_expectation`。 +- `rust/lithe-core/src/git/workspace_commit.rs`:`pushTargetQuestions`、`pushTargets`、按仓库的确认与重试时的恢复。 +- `rust/lithe-core/src/protocol/contracts.rs`:`GitPushTargetKind`、`GitPushPreviewResponse`。 +- `windows/tauri/src/features/git/`:推送弹窗、目标提示、提交面板、工作区提交工作流与推送 API。 +- `macos/Sources/Lithe/Core/Rust/RustCoreBridge+GitPush.swift`、`macos/Sources/LitheGitModule/Services/GitService.swift`、`GitFeatureModel.swift`、`GitFeatureModel+WorkspaceCommit.swift`、`macos/Sources/LitheGitModule/Models/GitPushModels.swift`、`GitWorkspaceCommitModels.swift`:推送预览、审阅后的推送、`pushTargets` 与 `pushTargetQuestions` 的原生承载;只转发 Core 的解析结果。 +- `macos/Sources/Lithe/Views/Git/GitPushDialog.swift`、`GitLogView.swift`、`WorkbenchView.swift`、`CommitAreaView.swift`:推送弹窗(分支列表、顶栏分支弹窗)与提交并推送审阅单。 +- 不适用于 `publishBranch`(macOS PR 分支发布),其固定 `origin` 是该场景的正确行为。 diff --git a/.agents/notes/implemented/bug-fix/2026-10-21-git-large-untracked-directories.md b/.agents/notes/implemented/bug-fix/2026-10-21-git-large-untracked-directories.md new file mode 100644 index 0000000000..22da2a8665 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-21-git-large-untracked-directories.md @@ -0,0 +1,105 @@ +# Agent 笔记:大型未跟踪目录按目录汇总,变更列表只构建可见行 + +状态:已实现 + +## 先说结论 + +一个没被忽略的构建目录(例如 `rust/lithe-core/target/`)曾让 Commit 面板出现 5,000 多条变更,帧率掉到 2 FPS(Issue #1385)。现在 Core 只把"整个目录都未跟踪、且里面没有嵌套仓库"的大目录报告成一条带文件数的目录汇总,用户展开时再一层一层列出。macOS 和 Windows 都接入了这个汇总。macOS 变更列表的每个标题和每一行都直接交给 `LazyVStack`(SwiftUI 的懒加载栈,只为屏幕上可见的行创建视图),变更再多也只构建可见行;Windows 的列表本来就是虚拟列表。 + +## 问题 + +问题有两层,任何一层单独存在都会让面板随变更数量变慢: + +1. **Core 把一个目录拆成成千上万条记录。** `git.status` 使用 `--untracked-files=all`,Git 会把未跟踪目录下的每个文件都列出来。每次文件监听触发刷新(构建过程中非常频繁),这些记录都要经过 JSON 编解码、比较、分组和项目树着色。 +2. **视图把整组行包在一个子视图里。** 多仓库模式下,每个仓库的标题和全部文件行被包在一个普通 `VStack` 里。外层的 `LazyVStack` 只能按"仓库"这一级懒加载,仓库一出现在屏幕上,它下面的 5,000 行(每行带按钮、复选框、图标、右键菜单和提示)就会全部创建。 + +## 决策 + +**Core 的汇总规则**(`rust/lithe-core/src/git/untracked_directories.rs`): + +- `git.status` 新增可选参数 `summarizeUntrackedDirectories`。打开后改用 Git 的 `--untracked-files=normal` 视图,Git 在每个整体未跟踪的目录处停下,只报告 `dir/`。 +- 对这些目录,Core 用一次 `git ls-files --others --exclude-standard` 拿到其中的文件,再逐个决定:超过 100 个文件就报告成一条汇总(`path` 以 `/` 结尾并带 `untrackedFileCount`);不超过 100 个文件仍按文件逐条报告,所以新建的小源码目录照常显示。 +- **含嵌套仓库的目录绝不汇总。** 例如 `.worktrees/` 里有 linked worktree(关联工作树),暂存 `.worktrees/` 会把这些独立仓库记成 gitlink(父仓库里的子仓库指针)。这种目录会继续向下拆分,直到每个汇总里都没有嵌套仓库。这样,对汇总路径执行暂存、丢弃或 diff,作用范围正好是它统计的那些文件。 +- `git.untrackedDirectory` 每次只列一层:直接子文件,以及每个子目录的汇总。 +- 不传这个参数时行为不变,一次性读取(例如 Windows 的 Shelf)仍拿到逐文件列表。 + +**围绕汇总条目的操作也必须按目录工作**,否则会悄悄出错: + +- `git.diff` 带 `untracked` 且路径是目录时,展开成目录下的每个文件分别做 diff。如果不这样做,"搁置"(Shelve)功能会先给目录生成一个空补丁,然后又把目录清掉,造成数据丢失。 +- 目录 diff 遇到无法导出的文件(例如名字含 `:`,路径校验会拒绝)时,整个 diff 报错,不能跳过。Shelve 保存补丁后会把整个目录清掉,跳过的文件就会被删掉而且没有备份。逐文件操作时,这类文件会被路径校验直接拒绝,所以以前不会出问题。 +- macOS 的 Shelve 必须区分"读不到补丁"和"补丁为空":`workingDiffPatch` / `stagedDiffPatch` 读不到时返回 nil,任何一条读不到,整个搁置就中止,不保存也不清理。以前把读取失败当成空补丁跳过,接着照样清理这条变更。 +- `discardAll` 能把 `dir/` 识别为未跟踪路径,用 `git clean` 删除,而不是对它执行 `git restore`。 + +**macOS 端:** + +- 变更面板使用 `changesSnapshot`;分支对比和 worktree 详情仍拿逐文件列表,行为不变。 +- 展开的目录内容由 `GitFeatureModel` 直接插入到 `gitChanges` 中、紧跟在汇总条目后面,每次刷新都重新列出。因此暂存、丢弃、选择和 diff 都把这些子项当作普通变更处理,不需要另外维护一套状态。 +- `GitTreeStatusProjection`(把 Git 状态映射到项目树和编辑器的那层)对汇总目录下的文件,会合成一条逐文件的未跟踪记录。项目树着色、编辑器行标记和 Show Diff 的结果与之前一致。 +- 计数按文件统计(`gitChangedFileCount`):一条汇总按它的文件数计;已展开的子项已经算在汇总里,不重复计数。 +- 逐条执行的写操作(Rollback、Shelve)先去掉同一批里被汇总覆盖的子项。否则同一个文件会在搁置补丁里写两次,恢复时 `git apply` 失败;或者汇总被清理后,子项的 `git restore` 报错并中断。 +- 把汇总移到 ChangeList 后,ChangeList 只记录 `dir/` 这一个键,目录下的文件继承它的归属。Core 的提交范围检查(`PathScope`)只按字面路径比较,所以 `commitScope` 会把继承归属的文件逐个列出。否则在该 ChangeList 中提交会被拒,而默认列表的提交会把这些文件一起带走。 +- 刷新在等待目录内容时,用户可能折叠或展开目录。列出结果后,如果展开集合变了,就按当前集合重建结果(已经读到的列表直接复用),不能用刷新开始时的旧集合覆盖用户的操作。只有本次读取一开始就请求过、但结果里已经没有的汇总,才会被忘掉。Windows 的 `listExpandedUntrackedDirectories` 遵循同样的规则。 +- `GitChangeSectionsCache.rows` 把仓库标题、ChangeList 标题和每一条变更拍平成一个行数组,`ForEach` 直接放在 `LazyVStack` 中。 + +**Windows 端:** + +- 只有写入 `repositoryStatuses`(Windows 唯一的状态真源)的读取 `getRepositoryGitStatuses` 打开汇总。Shelf、Diff 审查、GitHub 等一次性读取继续使用逐文件的 `getGitStatus`。 +- 展开状态保存在 `git-untracked-directory-expansions.ts`,按仓库记录,不持久化。展开的内容在同一次状态读取中列出,并紧跟汇总写进仓库快照,然后像其他状态一样发布一次。这样 Commit 面板、项目树、页脚和编辑器看到的是同一份结果,没有第二份可写的状态。 +- 文件夹视图中,展开后的内容会形成一个同名文件夹,由这个文件夹代表该汇总:汇总行被移除,折叠这个文件夹就是收起汇总。已记住的文件夹折叠状态不会挡住展开的内容。 +- 项目树颜色、页脚计数、编辑器装订线和行历史查询,都按"汇总目录下的文件是未跟踪文件"处理;ChangeList 的继承规则和提交范围与 macOS 一致;删除时去掉被汇总覆盖的子项;汇总本身不参与 Diff。 + +正确做法:新增大列表时,让每一行都是懒加载栈的直接子项,汇总和分组数据在缓存里一次算好。 + +不要这样做: + +```swift +LazyVStack { + ForEach(groups) { group in + VStack { header(group); ForEach(group.rows) { row($0) } } // 整组被当成一个子项,所有行都会被创建 + } +} +``` + +## 考虑过的备选方案 + +- **只改 `.gitignore`。** 只能解决本仓库这一种目录。用户项目里任何未忽略的 `target/`、`node_modules/` 仍会让面板卡死,没有修到产品本身。 +- **只修视图懒加载。** 渲染会变快,但每次刷新仍要传输和处理与文件数成正比的 JSON(5,280 个文件约 721 KiB);一个未忽略的 `node_modules/` 会让每次刷新都处理几十万条记录,真正有意义的改动也被淹没。 +- **直接使用 `--untracked-files=normal` 的原始结果。** 新建的小源码目录也会变成需要展开的目录行,日常体验变差;而且 `.worktrees/` 这种只含嵌套仓库的目录会被整个折叠,暂存时会产生 gitlink。 +- **对未跟踪文件总数设硬上限后截断。** 截断后计数、暂存范围和用户看到的内容不一致,丢弃和提交操作也难以保证正确。 + +## 后果 + +收益:在 issue 的场景里,变更面板 status 从 5,281 条、721 KiB、49 ms 降到 2 条、不到 1 KiB、22.5 ms;视图开销只与可见行数有关,暂存后出现的数千条已跟踪变更也不会再卡住界面。 + +代价: + +- 汇总目录本身不能直接查看 diff,要展开后逐个文件查看。 +- 展开状态下,每次刷新都要为每个展开的目录多调用一次 Git。 +- 一个已跟踪目录里直接放着大量未跟踪文件(不在子目录中)时,Core 无法安全地汇总,因为暂存这个目录会连同已跟踪文件的修改一起暂存。这种情况只依靠视图懒加载保证流畅。 +- Windows 每次读取状态时,每个展开的目录都要多调用一次 Core。 + +## 验证 + +- `cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib untracked` +- `./scripts/test-macos.sh --filter 'GitChangeSectionsCacheTests|GitLocalChangelistsTests|treeStatus|untrackedDirectorySummary|GitStatusObservationTests'` +- `./scripts/verify-rust-core.sh` +- `./scripts/verify-service-boundaries.sh` +- `./scripts/verify-platform-feature-matrix.sh` +- `./scripts/verify-agent-notes.sh` +- `./scripts/verify-windows-boundaries.sh` +- `./.agents/skills/write-stable-tests/scripts/verify-test-stability.sh` + +实机验收步骤见功能矩阵 `git-large-untracked-directories`。组件测试不能替代真实仓库下的滚动和帧率检查。 + +## 适用范围 + +- `rust/lithe-core/src/git/untracked_directories.rs` +- `rust/lithe-core/src/git/mod.rs` +- `macos/Sources/LitheGitModule/Application/GitFeatureModel.swift` +- `macos/Sources/LitheGitModule/Models/GitModels.swift` +- `macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift` +- `macos/Sources/Lithe/Views/Git/GitChangeSectionsCache.swift` +- `windows/tauri/src/features/git/api/git-status-api.ts` +- `windows/tauri/src/features/git/services/git-untracked-directory-expansions.ts` +- `windows/tauri/src/features/git/utils/git-untracked-directories.ts` +- `windows/tauri/src/features/git/components/status/git-status-panel.tsx` diff --git a/.agents/notes/implemented/bug-fix/2026-10-21-macos-hidden-titlebar-native-fullscreen.md b/.agents/notes/implemented/bug-fix/2026-10-21-macos-hidden-titlebar-native-fullscreen.md new file mode 100644 index 0000000000..0a9e3b97cc --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-21-macos-hidden-titlebar-native-fullscreen.md @@ -0,0 +1,98 @@ +# Agent 笔记:macOS 隐藏标题栏窗口的原生全屏能力守卫 + +状态:已实现 + +## 先说结论 + +SwiftUI 的 `.windowStyle(.hiddenTitleBar)` 窗口在当前 macOS 工具链下默认被系统标记为 +`fullScreenNone`(禁止原生全屏)。表现为:点击绿色交通灯按钮不会进入独立全屏 +Space,而是在主桌面直接最大化;程序调用 `toggleFullScreen(_:)` 也会被静默拒绝。 + +修复位于 `macos/Sources/Lithe/Views/App/RootView.swift` 的 +`WindowFullScreenCapabilityGuard`(NSViewRepresentable)与 +`WindowFullScreenCapabilityView`(宿主 NSView)。它们挂在 `RootView` 的 +background 上,因此欢迎页、项目工作台和独立文件三类主窗口都被覆盖。开发者新增 +隐藏标题栏主窗口时不需要额外操作;如果将来绿色按钮又表现为"最大化",先检查该 +守卫是否仍然挂在窗口根视图上。 + +## 问题 + +用户报告:从 GitHub 下载的 v0.6.3 在项目工作台窗口点击绿色按钮后,窗口只在主 +桌面最大化,无法进入 macOS 标准的独立全屏桌面(Space)。本地从源码构建的 +debug 版本同样复现。 + +进程内探针确认了三个事实: + +1. 窗口的 `collectionBehavior` 含 `fullScreenNone`(原始值 0x10200 = + `FullScreenNone | Primary`),而全仓库源码从未设置过该标志,它来自 + SwiftUI 对 hiddenTitleBar 窗口的默认行为。 +2. 绿色按钮的 target/action 在窗口创建时就被 AppKit 固化为私有的 + `_setNeedsZoom:`(最大化),而不是全屏窗口应有的 `toggleFullScreen:`。 +3. 只要 `fullScreenNone` 还在,`toggleFullScreen(_:)` 会被静默拒绝。 + +一次性剥离 `fullScreenNone` 不够:SwiftUI 会在后续场景更新中反复重新设置该 +标志。 + +## 决策 + +`WindowFullScreenCapabilityView` 挂在 `RootView` 的 background 上,承担三件事: + +1. `viewDidMoveToWindow` 时订阅宿主窗口的 + `NSWindow.didUpdateNotification`。 +2. 每个窗口 update 周期检查一次 `collectionBehavior`:若含 `fullScreenNone` + 则移除之,并补上 `fullScreenPrimary`。SwiftUI 反复设置,守卫就反复剥离; + 用户点击的瞬间标志位是干净的。 +3. 剥离的同时把绿色按钮重接到 + `zoomButtonClicked(_:)`(先剥离再调用 `toggleFullScreen(_:)`),消除 + "按钮 action 已固化为最大化"与"点击瞬间标志位又被设置"两种竞争。 + +`viewDidMoveToWindow` 与 `NSWindow.didUpdateNotification` 的组合是刻意的: +不使用轮询,也不阻塞主线程;`contains(_:)` 位检查在每次窗口 update 中的开销 +可以忽略。 + +进入原生全屏后 macOS 会隐藏红绿灯按钮,但工作台顶栏仍按普通窗口预留 83pt +(`WorkbenchTopBarMetrics.leadingInset`)的按钮空位,左上角出现一段空白。 +`WindowFullScreenStateReader` 用同一套 `didUpdateNotification` 机制把窗口的 +全屏状态同步到 SwiftUI(`@State isWindowFullScreen`),顶栏的全屏态下将 +leading padding 降为 0,退出全屏后自动恢复预留,行为对齐 IDEA。 + +## 考虑过的备选方案 + +- 在 `LitheWindowCoordinator.attach` 中做一次性归一化:已否决。实测 SwiftUI + 会反复重设标志,一次性剥离无效;而且欢迎页窗口没有活跃 session,根本不会 + 创建 coordinator。 +- 剥离标志但不重接绿色按钮:已否决。按钮的 target/action 是窗口创建时由 + AppKit 配置并缓存的,`collectionBehavior` 变化不会触发重新求值,点击仍然是 + 最大化。 +- 放弃 `.windowStyle(.hiddenTitleBar)` 改用普通标题栏:已否决。会破坏整套 + IDEA 风格的窗口视觉与自定义顶栏布局,代价远大于收益。 + +## 后果 + +收益:主窗口恢复 macOS 标准全屏体验(独立 Space、菜单栏/Dock 自动隐藏), +行为对齐 IntelliJ IDEA;对系统未来行为变化保持钝感——若 SwiftUI 不再设置 +`fullScreenNone`,守卫的 `contains` 检查自然变成空操作。 + +代价与风险:绿色按钮的 target/action 被接管为 +`WindowFullScreenCapabilityView.zoomButtonClicked(_:)`。视图与按钮共享窗口的 +生命周期,非零引用的 `NSButton.target` 不会悬垂;option 点击绿色按钮的窗口 +缩放行为仍由按钮子类在 mouseDown 中处理,不受影响。 + +## 验证 + +- 单元测试 `macos/Tests/LitheTests/WindowFullScreenCapabilityGuardTests.swift` + 覆盖:安装即剥离、按钮重接、SwiftUI 重设后再次剥离(通过 + `NSWindow.didUpdateNotification` 同步触发,无轮询、无实时等待)。 +- 手工验证:启动应用,点击主窗口绿色按钮,确认进入独立全屏 Space(窗口 + `styleMask` 出现 `.fullScreen`、frame 覆盖整屏),再次点击或按 Esc 退出后 + 窗口回到原位置。 +- 回归检查:`./scripts/test-macos.sh`(CI 执行);本地受 Swift 6.2.4 工具链 + 类型检查超时限制无法完整编译时,以 CI 结果为准。 + +## 适用范围 + +- `macos/Sources/Lithe/Views/App/RootView.swift` +- `macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift`(工作台顶栏的全屏态留白) +- `macos/Tests/LitheTests/WindowFullScreenCapabilityGuardTests.swift` +- 仅覆盖经由 `RootView` 呈现的主窗口(欢迎页、项目工作台、独立文件窗口)。 + 设置窗口与软件更新窗口不经过 `RootView`,不受本守卫影响。 diff --git a/.agents/notes/implemented/bug-fix/2026-10-22-project-tree-natural-name-order.md b/.agents/notes/implemented/bug-fix/2026-10-22-project-tree-natural-name-order.md new file mode 100644 index 0000000000..e21c67ab6e --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-22-project-tree-natural-name-order.md @@ -0,0 +1,70 @@ +# Agent 笔记:项目树同级文件名使用 IDEA 自然排序 + +状态:已实现 + +## 先说结论 + +macOS 项目树里 `v0.1.12.md` 曾排在 `v0.1.4.md` 前面,因为各处只是把名字转成小写后逐字符比较。现在同一层的目录始终排在文件前,名字则沿用 IDEA 项目视图的“自然排序”:数字按数值比较、忽略大小写、`-` 与 `_` 相邻。Rust Core 的目录列表、macOS 兜底扫描和树内新增文件三处使用同一套规则,由共享 fixture 保证一致。快照 `files` 和搜索索引的遍历顺序不属于显示顺序,保持原样,因此 Windows 快速打开等依赖它的功能不受影响。 + +## 问题 + +项目树有三处各自排序:Rust Core 的 `visible_children`(主路径,只比较小写字节)、macOS 兜底扫描(`localizedCaseInsensitiveCompare`,不把数字当数)和 `FileNodePathLookup` 的插入位置(小写后比较)。它们既都不是 IDEA 的顺序,也彼此不完全一致:版本号文件名、`file2`/`file10` 这类名字会按字典序排列,文件系统事件新增一个文件后还可能落在和重新扫描不同的位置。 + +## 决策 + +IDEA 的项目视图用 `AlphaComparator` 比较同级节点,而它调用的是 `FileNameComparator`,即 `NaturalComparator.naturalCompare(ignoreCase = true, likeFileNames = true)`。Lithe 逐行移植这套算法(参照 intellij-community `d8c6714c7a3c110c56994d6b0f10814f902100e7`): + +- 连续数字按数值比较,位数多的更大:`v0.1.4` < `v0.1.12`;值相同时没有前导零或空格的排在前面:`a1` < `a01`。 +- 先忽略大小写比较;全部相同时再按大小写区分,保证顺序稳定:`A.txt` < `a.txt` < `B.txt`。 +- `-` 与 `_` 相邻(IDEA-80435):`foo.bar` < `foo-bar` < `foo_bar`。 +- 目录先于文件的规则不变,由树负责,比较器不处理。 + +### 正确做法 + +- Rust Core 在 `rust/lithe-core/src/project/file_name_order.rs` 提供 `FileNameKey`,`visible_children` 在 `ChildOrder::Tree` 下用 `sort_by_cached_key` 排序;每个名字只转换一次,大目录排序不会按比较次数分配内存。 +- `visible_children` 必须由调用方说明要哪种顺序(`ChildOrder`):只有 `workspace.listDirectories` 使用 `Tree`;快照(`scan_node`、`collect_snapshot_files`)和搜索索引使用 `Traversal`,仍按小写名字排序。 +- Swift 在 `NaturalNameOrder`(`LitheCoreContracts`)里持有算法。兜底扫描用 `sortedAsProjectTreeSiblings`,插入文件用 `compareFileNames`;Git 的分支名排序(`GitGraphHeadOrdering.naturalCompare`,不带 `likeFileNames`)也委托给它,不再保留第二份拷贝。 +- 新增或调整规则时先改 `shared/fixtures/workspace/file-name-order-v1.json`,再让 Rust 和 Swift 测试一起通过。 + +### 树行顺序与遍历顺序分开 + +快照的 `files` 清单不是显示顺序,而是一条被截断使用的序列:Windows 快速打开(`fffSearchFiles`)先按子串过滤,再只取前 100 项,并以位置给分。如果显示顺序的修改同时改变这条序列,同一个查询在 Windows 上返回的前 100 项会悄悄变化,却没有任何 Windows 改动或验证。所以显示顺序只作用于 `Tree`,`Traversal` 保持原有规则;`workspace_snapshot_keeps_its_traversal_order_while_the_tree_rows_are_natural` 固定这一点。 + +### 不要这样做 + +- 不要在树里再加一处 `lowercased()` 或 `localizedCaseInsensitiveCompare` 排序;它会让重新扫描和增量插入的顺序不同。 +- 不要为了让 Rust 的排序“看起来更简单”改成 `localeCompare` 一类系统排序:它不是确定性的,也不是 IDEA 的顺序。 +- 不要让快照或搜索索引的遍历改用 `ChildOrder::Tree`;要改变快照 `files` 的顺序,必须同时评估 Windows 快速打开等只取前 N 项的使用方。 + +## 考虑过的备选方案 + +### 使用系统的 `localizedStandardCompare` + +它同样把数字当数,仓库其它列表已经在用。没有采用,是因为它依赖系统区域设置,Rust Core 无法复现;而且不处理 `-`/`_` 相邻和大小写决胜,和 IDEA 的树仍会出现差异。 + +### 只改 Swift,不改 Rust Core + +macOS 项目树的主路径是 Rust Core 的目录列表,兜底扫描只在 Core 失败时使用。只改 Swift 看不到效果。 + +## 后果 + +- 项目树和 IDEA 的同级顺序一致,版本号、编号文件名按预期排列。 +- 快照 `files`、其中的 `root` 树节点(`includeTree` 为 true 时)和搜索索引保持小写排序;macOS 项目树只使用 `workspace.listDirectories` 的行,所以不受影响。 +- Rust 的排序不能接受违反全序的比较函数(可能 panic)。测试里用固定种子的随机名字检查反对称与传递性,移植时不要省略空格、`#`、`-`、`_` 这些特殊字符。 +- Windows 项目树(`visible-file-tree-rows.ts`、`file-tree-utils.ts`)仍使用 `localeCompare`,只有 Git 文件树使用 `compareGitFileNames`;是否统一见功能矩阵 `file-explorer`。 + +## 验证 + +- `cargo test --manifest-path rust/lithe-core/Cargo.toml --lib file_name_order`:共享 fixture、特殊字符的全序检查、`workspace.listDirectories` 的目录优先与版本号顺序,以及快照 `files`(含与不含 `includeTree`)保持遍历顺序。 +- `./scripts/test-macos.sh --filter ProjectTreeNameOrderTests`:fixture、兜底扫描和新增文件的位置;另需运行 `GitGraphLayoutTests` 确认分支名排序不变。 +- 在 macOS 打开含 `docs/releases/v0.1.4.md`、`v0.1.12.md` 的仓库,确认树中 `v0.1.4` 在 `v0.1.12` 之前,并在外部新建 `v0.1.5.md` 后插入到 `v0.1.4` 与 `v0.1.12` 之间。 + +## 适用范围 + +- `rust/lithe-core/src/project/file_name_order.rs` +- `rust/lithe-core/src/project/files.rs` +- `rust/lithe-core/src/project/search_index.rs` +- `macos/Sources/LitheCoreContracts/Workspace/NaturalNameOrder.swift` +- `macos/Sources/LitheCoreContracts/Workspace/FileNodePathLookup.swift` +- `macos/Sources/Lithe/Platform/MacOS/FileSystem/MacWorkspaceSnapshotBuilder.swift` +- `shared/fixtures/workspace/file-name-order-v1.json` diff --git a/.agents/notes/implemented/feature/2026-09-27-workspace-git-commit-plans.md b/.agents/notes/implemented/feature/2026-09-27-workspace-git-commit-plans.md index 69bded6517..a46549b4ed 100644 --- a/.agents/notes/implemented/feature/2026-09-27-workspace-git-commit-plans.md +++ b/.agents/notes/implemented/feature/2026-09-27-workspace-git-commit-plans.md @@ -59,6 +59,7 @@ Git 的多个仓库没有跨仓库原子事务,已成功的提交不会因后 - `rust/lithe-core/src/git/workspace_commit/tests.rs` 覆盖干净祖先联动、关闭联动、独立嵌套仓库、过期计划、失败依赖阻塞、仅重推和外部推进后再次推送。 - `rust/lithe-core/src/tests/git_workspace_commit.rs` 使用真实 Git 验证过期索引拒绝、子模块状态、只更新引用且不带入父文件、首次提交前取消暂存保留后续编辑,暂存后在工作树删除的文件仍可见、子仓库元数据被删除后拒绝回退父仓库,以及共享夹具序列化。真实 Git 还验证共享计划执行、推送失败后的重试、过期步骤重放拒绝、取消与成功提交返回竞争。 - 使用 `write-stable-tests` 的 Rust 逐例计时入口生成 HTML/JUnit;macOS 使用该 Skill 的 macOS 计时入口。 +- Windows CI 39914734587 中,逐仓库推送确认用例在未出现断言失败时达到 15.233 秒进程截止;该用例创建两份分叉仓库及远端,执行三轮准备校验和独立提交/推送。SharedRust 与真实 JDT 两个入口只给这一用例 30 秒预算,默认用例和整套截止不变,真实 Git 进程仍按原有界规则清理,不跳过确认或推送断言。 - 运行共享契约、服务边界、功能矩阵、发行包只读边界和 Agent Notes 检查。 - 当前实现环境是 Linux;本地执行共享 Core 和 Windows 前端测试,原生构建及测试交给对应 CI,macOS/Windows 界面实测仍需平台验收,不将逻辑测试通过记作界面已验证。 diff --git a/.agents/notes/implemented/feature/2026-10-04-windows-commit-file-diff-preview.md b/.agents/notes/implemented/feature/2026-10-04-windows-commit-file-diff-preview.md index d1214c5f0e..8397e22e32 100644 --- a/.agents/notes/implemented/feature/2026-10-04-windows-commit-file-diff-preview.md +++ b/.agents/notes/implemented/feature/2026-10-04-windows-commit-file-diff-preview.md @@ -159,6 +159,27 @@ Git 的补丁顺序和历史行号仍是事实来源,`planIndependentCommitDif 通常的统一视图仍复用原生 DiffEditor,必须在本预览范围内把 Monaco 的公开增删行、字符和行号栏颜色变量 接到同一组 Islands 底色,字符边框透明;否则新增文件会落回默认黄绿色、删除落回默认红色。 不覆盖活动语法主题,不修改其他编辑器的颜色;统一视图的修改按原生增删分段呈现。 +2026-10-22 起,显示层在 Git 连续变更块内部再按 IDEA 的 `ComparisonManagerImpl.createInnerWordFragments` → +`ByWord.compareAndSplit` → `LineFragmentSplitter` 语义拆出仅显示的子块(Community +`fb72b4df43aba102479eb0502d20b03586b9c5b8`):块内左右行先按词元相似度做有界配对,完全相同的配对行按上下文不绘制, +只有单侧存在的行重新获得新增绿/删除灰,其余相似配对保持修改蓝;两侧都没有共享词元的相邻子块仍合并为 +一个修改块,对应 `LineFragmentSplitter.shouldMergeChunks`。配对使用词元多重集相似度和带缺口的动态规划, +不是 IDEA 的 Myers 同序匹配,因此只承诺可视分类一致,不声称逐字符复刻其 tie-breaking; +超过配对格数、词元工作量或块大小预算时回退为整个 Git 块,保持原有行为。 +这一步只服务着色、连接带、概览标记和词级范围,`changes`(Git 脚本、差异计数、块包含、回退与导航)保持不变, +单个 Git 块的控件仍只绘制一次并锚定在首个子块。原因是 JSONL 等文件插入若干行会让后续 `order` 全部变化, +Git 会把几乎整个文件报告为一个修改块;直接按 Git 块着色时新增行与修改行同色,与 PyCharm 对照图中 +IDEA 仍把新增行标绿的结果不一致。 +2026-10-22 起,文件导航(左右文件箭头及上下差异箭头跨文件)按用户实际看到的列表顺序,而不是 Core 的路径序: +Commit 面板的单文件与批量 Diff 使用仓库 → changelist →(文件夹树,或 added → modified → deleted → renamed → +untracked 分组)的行顺序;Git Log 的单文件提交预览、完整提交 Diff、范围 Diff 与离散多选 Diff 使用提交文件树的 +顺序(目录节点先于文件,文件名用 IDEA 自然排序)。`orderGitStatusFilesForDisplay` 与 `gitFileTreeOrder` 是 +这两套展示顺序的唯一实现,面板与导航不得各写一份;未跟踪目录汇总行不是可导航文件,导航顺序跳过它但列表仍显示; +Git Log 中重复路径(离散多选里同一文件的多个版本)按树顺序相邻,并保留原相对顺序。 +`workingTreeFileOrder` 仍是打开预览时的快照,状态变化后重新点击文件会按当前列表重建顺序; +不在导航时额外读取 Git,交互预算不变。复用的标签页(Commit 面板的 `diff://working-tree/all-files` 与 +Git Log 的 `diff://commit-file-preview`)在文件箭头切换后必须重新计算标题:标签栏的 chrome 比较键包含 +当前选中文件名,不能只看缓冲区路径,否则正文已切换到新文件而标签仍停在旧文件名。 原生统一 Diff 通过公开 `renderIndicators: false` 关闭行号旁的增删符号;独立行号只绘数字。 这是一项呈现增强,不将 Git 的行级脚本重新交给字符算法推断。锁文件固定来源和校验,未新增运行时下载、 资源目录或可共享缓存;现有资源复用清单已覆盖 Bun 下载缓存,node_modules 仍是各工作树独立可变状态。 @@ -327,10 +348,17 @@ macOS 本次不修改,双端功能矩阵保持目标产品运行验收待验 `monaco-diff-rows.test.ts` 检查隐藏补丁标题后真实内容、行身份和默认暂存投影不变。 `commit-diff-chrome-geometry.test.ts` 检查跨两列数字的连接形状和空范围; `independent-commit-diff.test.ts` 检查独立行流、折叠恢复、双向边界映射及词级预算与颜色分类; + 2026-10-22 起新增显示子块回归:整文件 `order` 重排时仍拆出新插入行、无共享词元的相邻子块保持一个修改块、 + 超过配对预算时回退为原 Git 块; `independent-commit-diff-surface.test.ts` 用可控帧和语言准备验证实际连接几何随独立偏移改变、 外侧轨道、横向双向同步与短侧边界不回拉、纵向滚动/轨道键盘/点击/滚轮对应行同步、三态代码边界、显式双侧导航、 轨道预留空间、中央列留白及 Commit 额外动作宽度、滚轮单位与 Ctrl 滚轮、词级淡底色及超限回退、无垂直接缝的边界, - 反向行号选择、词级设置保持偏移以及卸载/迟到清理。三组都加入 CI 的 Git Log 计时组。 + 反向行号选择、词级设置保持偏移、显示子块的新增/修改整行底色与单个 Git 块控件不重复,以及卸载/迟到清理。 + 三组都加入 CI 的 Git Log 计时组。 +- 2026-10-22 用真实 `D:/work/bigcloud/product/AIMCloud` 仓库的 + `setup/customers/permission/17_menus.jsonl` 走生产解析与投影:Git 将 115 增/113 删报告为一个修改块, + 投影仍只保留 1 个 Git 差异,并将新文件第 2、3 行拆为新增子块、其余行保持修改子块; + 该文件的新增行因此在并排预览中获得新增底色。真实 WebView2 的像素验收未在本机执行。 - 真实浏览器加载生产页面组件和 Monaco,检查上下差异位置、左右文件切换、标题更新, 连续下箭头经过多个文件、跨文件停在首处差异、等待比较时禁用以及集合末尾停止, 删除/二进制禁用状态、左右侧源行映射,以及不同项目根目录下使用预览仓库。 diff --git a/.agents/notes/implemented/feature/2026-10-08-windows-settings-hierarchy.md b/.agents/notes/implemented/feature/2026-10-08-windows-settings-hierarchy.md index 7b749c3d87..bd1e6d9d19 100644 --- a/.agents/notes/implemented/feature/2026-10-08-windows-settings-hierarchy.md +++ b/.agents/notes/implemented/feature/2026-10-08-windows-settings-hierarchy.md @@ -79,7 +79,7 @@ Windows 设置组件使用 `settings-panels.tsx`、`SettingsPanel` 和 `Settings - `windows/tauri/src/features/settings/components/settings-section.tsx` - `windows/tauri/src/features/settings/components/settings-panels.tsx` - `windows/tauri/src/i18n/locale.ts` -- `windows/tauri/src/i18n/ai-commit.ts` +- `shared/i18n/messages.json` - `windows/tauri/src/features/settings/lib/settings-effects.ts` - `windows/tauri/src/features/settings/lib/theme-resolution.ts` - `windows/tauri/src/features/settings/lib/settings-theme-sync.test.ts` diff --git a/.agents/notes/implemented/feature/2026-10-12-plugin-marketplace.md b/.agents/notes/implemented/feature/2026-10-12-plugin-marketplace.md index d90ec7910e..c9df1e6d1b 100644 --- a/.agents/notes/implemented/feature/2026-10-12-plugin-marketplace.md +++ b/.agents/notes/implemented/feature/2026-10-12-plugin-marketplace.md @@ -59,6 +59,17 @@ Lithe 的插件管理改成和 IntelliJ IDEA 一样的“插件市场 / 已安 **展示的版本。** Core 会跳过当前平台或插件 API 不满足的新版本,选择能安装的旧版本。详情里的版本、发布日期和支持平台都描述“点安装会装的那个版本”;最新版本不同时,单独一行注明它在当前 Lithe 或这台 Mac 上无法使用。 +**市场详情的分栏边界。** 市场和“已安装”使用不同的详情视图,必须分别验证。 +市场信息行和来源选择器有最小宽度;直接把它们放在纵向 ScrollView(滚动视口)里, +会让 SwiftUI 按内容宽度重新居中,拖窄后文字和图标就跨到分隔线左侧。 +市场视口现在先接受分栏给出的矩形,再以该宽度布局详情。正文最小宽度由现有 +标签列、间距、来源选择器和提示卡的尺寸推导;较窄时可横向滚动查看完整内容, +较宽时描述仍按可见宽度换行。短详情在高度变化后也保持顶部对齐。 +这里只测量视口的宽高,内容在测量闭包外构建;不在拖动中重读市场、重建模型或写偏好。 +正确做法是约束视口并保留内容的滚动可达性;不要只裁掉越界正文、把详情最小宽度 +传回外层撑宽设置窗口,或因为“已安装”测试通过就认为市场也通过。 +这是原生视图布局修改,没有新增运行时文件、缓存或安装资源写入。 + 正确做法:新增官方插件时登记到 `official-plugins.json`,改插件内容时同时升 `plugin.json` 的版本。 不要这样做:手工改 `index.json` 里的下载地址或 SHA-256,或者把插件包重新挂到 Lithe 的版本 Release 上。 @@ -93,6 +104,7 @@ Lithe 的插件管理改成和 IntelliJ IDEA 一样的“插件市场 / 已安 - `cargo test --manifest-path rust/lithe-core/Cargo.toml --lib marketplace_plugins`:索引校验、平台与 API 判断、命名空间、源地址解析、安装审查的标准答案。 - `./scripts/verify-shared-contracts.sh`:市场 fixture 与错误码登记。 +- `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'PluginMarketplaceLayoutTests|PluginDetailLayoutTests|PluginSettingsLayoutTests'`:市场用内存索引驱动真实页面,覆盖中英文描述、深浅主题、窗口宽高变化、原生分隔条拖动、释放后刷新不移位,以及窄视口横向滚动到正文末端;单独保留“已安装”详情和路径控件回归。可用 `LITHE_MARKETPLACE_LAYOUT_CAPTURES` 导出市场原生视图截图;测试拥有并清理自己的偏好域和窗口。 - `node --test scripts/official-plugin-releases.test.mjs`:只为版本变化的插件生成标签,内容变化未升版本时失败;已发布未登记的版本会被重新登记。 - `node scripts/test-reuse-worktree-resources.mjs`:磁盘快照、安装 staging、版本和审批记录不出现在可复用列表,显式选择时在访问工作树或复制前拒绝。 - `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'PluginMarketplace|PluginDiskInstallation|MacPluginDiskPackageImporter|MacPluginPackageDownloader|PluginManagementPresentation|PluginManager|PluginPackageStore'`:校验和先于解压、下载和展开上限、磁盘快照隔离、源与签名要求、完整程序审查、安装 staging/扫描/回滚摘要复查、来源保留及同 ID 不自动接管。过滤器只是运行入口,必须核对报告确实包含 `MacPluginDiskPackageImporterTests`、`PluginDiskInstallationTests` 和磁盘 FeatureModel 场景,不能以零匹配当作通过。 diff --git a/.agents/notes/implemented/feature/2026-10-15-macos-settings-hierarchy.md b/.agents/notes/implemented/feature/2026-10-15-macos-settings-hierarchy.md index 8821f32d6b..bec522bcc8 100644 --- a/.agents/notes/implemented/feature/2026-10-15-macos-settings-hierarchy.md +++ b/.agents/notes/implemented/feature/2026-10-15-macos-settings-hierarchy.md @@ -22,10 +22,12 @@ macOS 设置侧栏现在只有七个一级入口:外观与行为、插件、 | 构建、执行、部署 | 构建工具、项目 JDK、运行配置、服务运行,以及框架和构建工具注入的配置 | | 语言与框架 | 语言服务(LSP)的开关,以及 Spring Boot 等框架的配置 | | 工具 | 终端、日志与诊断(查看、目录、清理和诊断包导出)等通用工具;插件带来的专用工具(例如 `.http` 请求)可以放进对应工具页 | -| AI | 凡是和 AI 有关的都放这里:MCP、Skill、AI 提交信息、Agents、AI 服务商 | +| AI | 凡是和 AI 有关的都放这里:MCP、Skill、全局指令、AI 提交信息、Agents、AI 服务商 | 插件如果带来了新的设置,默认放进“插件”页;只有它新增的是上表中某个分组本来就该有的能力(例如新的版本控制系统)时,才放进那个分组。 +AI 分组内按“AI 服务商 → Agent 管理 → 指令 → 技能 → Hooks → MCP 配置 → AI 与提交”排列:先放基础配置,再放行为规则和扩展能力,最后放具体应用功能。顺序仍只在 `SettingsGroup.categories` 中声明,搜索过滤后的侧栏也沿用这一顺序。 + ### 插件注册的页面 插件可以在清单里把设置和按钮放到“语言与框架 › <语言>”或“构建、执行、部署 › <构建系统>”。这些页面不是 `SettingsCategory` 的 case,而是由 `PluginSettingsRegistry` 从已启用插件的声明推导,跟在对应分组的静态页面后面;面包屑和搜索同样从注册结果推导。宿主不为任何具体语言或构建工具写专用页面,规则见 [插件设置的通用注册面与生效方式](../architecture/2026-10-21-plugin-settings-surfaces.md)。 @@ -59,7 +61,7 @@ Issue 希望“构建工具 → Maven”“服务运行”“框架支持”也 ## 验证 -- `SettingsSidebarLayoutTests` 保证每个页面恰好属于一个分组或是一级页面,并固定本表的分组内容。 +- `SettingsSidebarLayoutTests` 保证每个页面恰好属于一个分组或是一级页面,并固定本表的分组内容与 AI 页面顺序。 - `SettingsSearchVocabularyTests` 保证每个页面仍能被检索到。 - `./scripts/test-macos.sh --filter SettingsSidebarLayoutTests`。 - 实机查看设置窗口的侧栏、面包屑、搜索展开和从其他入口跳转到某一页时展开所属分组。 diff --git a/.agents/notes/implemented/feature/2026-10-17-mcp-server-library.md b/.agents/notes/implemented/feature/2026-10-17-mcp-server-library.md index 562d0a2c6e..ae441ed0d5 100644 --- a/.agents/notes/implemented/feature/2026-10-17-mcp-server-library.md +++ b/.agents/notes/implemented/feature/2026-10-17-mcp-server-library.md @@ -59,7 +59,110 @@ - env 和 header 值在编辑器中用 `SecureField` 遮蔽,但配置文件中仍是明文;页面明确写出这一点,不制造“已经加密”的错觉。列表摘要及其默认辅助功能文本不显示 stdio 参数,仅显示经过安全字符校验的可执行文件名;HTTP/SSE URL 会移除 userinfo、查询参数和 fragment,无效 URL 仅显示传输方式。例如 `--token=secret` 不能出现在摘要中,`https://user:secret@example.test/mcp?token=secret` 只能显示 `https://example.test/mcp`。 - 错误消息、日志和 Core 返回值都不回显配置内容。解析失败只报告文件路径和错误类型。 -- 首版不自动启动 MCP 服务器做健康检查。启动服务器会执行用户配置的命令,资源库只负责配置,不在用户没有要求时执行项目代码。 +- 打开页面或展开工具区只读;只有点击“检测工具”才连接,stdio 还需确认执行通知。 + 检测会运行配置的命令,可能下载包,因此不能在导入、保存或展开时自动触发。 + +### 紧凑编辑器与工具检测 + +配置弹窗参考 Magpie `ccbc7278180e513bf95461c62eeb15729fc7cae7` 的 +`internal/gui/assets/plugins/library/client.js`;工具排除机制核对其 +`internal/library/mcp_tools_off.go`,并核对 Codex 原生 `disabled_tools` 与 Claude +官方 permissions 文档。独立实现原生页面,不复制 Magpie 的网关和多 Agent 框架。 +命令与参数投影成一行,Rust Core 的 shell-words 1.1.1 只解析/引用 argv,保留空参数、 +内嵌换行和路径空格,不执行 Shell。Agent 用现有品牌图标的选择按钮,帮助仍放在 ⓘ。 + +检测复用仓库已有的官方 rmcp 3.5.0 SDK,初始化、会话、SSE 响应和分页相关语义留给 +SDK;Agent Host 管理执行、取消和进程清理。只增加 client/streamable HTTP 能力, +沿用 reqwest 0.12 的 TLS/网络栈;自定义字节 adapter 在 JSON、错误和 SSE 解码前 +限制 2 MiB,避免 SDK 默认 HTTP JSON 读取无上限。工具最多 1000 项、20 页,重复 +游标拒绝;请求期限 30 秒并有有界清理,stdio 子进程/进程组在结束、失败、取消后回收。 +不发送 tools/call,不处理 OAuth 登录、不读取其他 Agent 的登录令牌;不支持的旧式 +SSE 检测明确报错,仍允许用户编辑原有 SSE 配置。成功只代表这次检测,不能当成现有 +Agent 会话的连接保证。 + +工具区按已注册的 Agent 独立切换。Codex 修改自身条目的 disabled_tools;Claude 修改 +用户 settings.json 的 permissions.deny,只写精确 mcp__规则。外部精确/通配 deny、 +Codex enabled_tools 以及名称规范化冲突保持不可放宽,页面显示只读限制;只移除本库 +登记过的精确禁用项,文件过期时保留外部修改。规则与全局原生文件由现有写入 adapter +备份、条件替换,偏好只登记工具名所有权,不存令牌。Codex 更改规则会改变完整来源 +哈希,页面只推进此前匹配的服务器所有权,不能把一次工具操作当成接管外部服务器。 + +工具规则按原生路径/服务器名保留,即使暂时关闭服务器也保留限制。Codex 删除条目前, +Core 提取原生允许/禁用名单,整批条件写入成功后才把这些工具名保存在原服务器账本的 +`toolFilters` 中;保留记录没有 `sourceHash`,不能授权修改外部重新创建的条目。重新添加 +缺失条目时恢复名单,并根据恢复后的完整内容登记新所有权。现存条目和重命名沿用原生 +规则,不能被旧保留值覆盖;空允许名单仍表示不允许任何工具,格式错误则拒绝删除。 +这份状态随用户资料保留,不保存命令、env、header 或令牌;新页面和应用重启都能恢复。 +Codex 重命名还必须移动相同原生文件路径下、由 Lithe 登记的精确工具名所有权;只搬原生表会让原来可编辑的禁用项被误判为外部限制。迁移在整批文件条件写入成功后执行,失败或恢复不发布新名称所有权;清除旧名称记录,也不合并新名称处可能过期的记录。外部 deny 和 allow-list 不因此取得所有权,其他配置路径和 Agent 保持独立。 +Claude 的规则引用 +服务器名称,原生用户配置存在 MCP 限制时拒绝在本页重命名,避免旧限制未迁移而让 +工具重新可用;用户需先在原生规则中处理。项目/受管规则仍由上游 Agent 决定,页面 +不会显示为已解除全局之外的限制。生命周期退出会取消探测、丢弃迟到结果并释放内存 +快照;这些状态登记在 mcp-tool-probe-state,没有工作树复用阶段或发行目录运行时写入。 + +同一连接新增或移除 Agent 时,服务器行的名称和定义哈希不变,不能只按行 ID 保留旧策略。工具模型记录每行最新来源,来源改变时释放旧策略快照、按现有工具清单重读本地规则;探测尚未返回时也以最新来源读取策略。此刷新不重新连接第三方服务器,保留已检测工具;单个 Agent 的读取失败明确显示错误,其他 Agent 的可用策略保留。 + +检测的 macOS 适配器使用 8 MiB 栈的独立原生线程执行有界 C ABI 调用,避免阻塞 Swift 并发执行器,以及调试版 SDK/TLS 调用在小栈线程上溢出。取消与指令保存共用 `RustCoreCancellation`:Core 登记操作后、执行命令前同步回调,补传注册前到达的取消,不需要重试计时器;页面状态仍拒绝迟到结果。显式设置 `LITHE_MCP_PROBE_INTEGRATION=1` 可运行 Context7 的真实 HTTP 工具列表验证,普通测试不依赖公共网络。 + +### 发现市场与弹窗编辑 + +资源库和“发现 MCP 服务器”在同一页纵向排列,上方管理已添加服务器,下方搜索官方目录;项目级 Lithe 授权在页末。 +添加或编辑使用平铺表单的原生弹窗,只有字段区域滚动,取消和保存固定在底部;资源库超过五项时提供名称与安全摘要搜索。 +发现区标题右侧以灰色普通文字显示“精选,以及官方 MCP Registry”,作为目录来源标记,不提供 Registry 首页直达链接。 +市场卡片直接显示发布者、版本和两行简介,这些是用于选择服务器的目录内容;设置行为和授权解释仍放在 ⓘ。卡片按窗口宽度自动排列为一列或多列。 +标准 `mcpServers` JSON 经既有 Core 解析器生成预览,用户逐个选择条目检查后再保存。 +导入只带入现有编辑器支持的字段,认证与客户端专有字段需另行配置;界面的 ⓘ 明确 +说明这个边界。导入不读环境变量、不写文件,也不接管已有条目。 + +交互参考 Magpie 的 `86254d4049cb6be4a2ccdab02edf0bd59860c603`,目录行为另核对 +`ec7e50b8287462efcb6153fde67308e66b5aaf51` 的 `internal/library/market.go`,尤其 +`internal/gui/assets/plugins/library/client.js` 的发现卡片与编辑流程,以及 +`internal/gui/library.go` 对目录失败的独立提示。只参考公开交互与 API 使用方式, +继续复用 Lithe 的原生控件、配置端口、格式保留与所有权模型,不搬入其 Web UI 或网关。 + +打开页面先展示随版本提供的精选目录,不发网络请求。Magpie 的 `MarketServers` 同样 +先筛选内置 `featured`,只有搜索词非空时才请求 Registry,部分失败仍返回精选结果; +因此“打开时有卡片”不代表该机器已经连通官方目录。Lithe 的精选 JSON 由 Git 管理并 +编译进 Core,`mcp.featuredRegistry` 复用公开目录的校验逻辑返回卡片。包版本从官方 npm +元数据核对后固定,远程地址按发布者文档核对;中文介绍随应用本地化。维护目录时 +必须重新核对包版本、远程地址和文档,不复制 Magpie Image 等产品专属能力。 + +精选包含 Context7、Playwright、Chrome DevTools、Microsoft Learn、GitHub、DeepWiki、 +Exa、Filesystem、Fetch 和 Memory。2026-10-22 核对发布者文档与 npm/PyPI 元数据, +Filesystem/Memory 固定为 2.0.0(需要 Node.js 24+),Fetch 固定为 2026.10.19 +(需要 uv 和 Python 3.10+);GitHub、DeepWiki、Exa 使用发布者的 HTTPS 入口。 +GitHub 的令牌和 Filesystem 的允许目录不能猜测,因此只有版本内的精选可声明 +`setup`,让编辑器显示说明并阻止缺少必填内容的保存。GitHub 预置空 Authorization +请求头,用户填写 Bearer 令牌;Filesystem 必须在包参数后逐行填写至少一个绝对目录, +路径中的空格保留。公开 Registry 不能声明这一可信模板类型,原有复杂输入限制不变。 + +卡片图标使用发布者的品牌标记,不再让所有远程服务都显示地球、所有命令都显示终端。 +图标源文件与来源、审核日期、SHA-256 清单位于 `Resources/McpIcons`,Playwright 的 +Apache-2.0 许可一同保留。它们是 Git 跟踪、SwiftPM 打包的固定输入;运行时只从资源 +bundle 读取并在内存缓存,不访问图片服务器、不下载头像、不写 bundle,因而不改变 +代码签名或 Sparkle delta。三个参考服务器共用 MCP 标记,未认识的条目保留传输方式 +图标。只按可信精选的完整 ID 选择有限文件名,公开条目即便伪装成同名也不使用品牌图标。 +深色页面的品牌标记放在白色小底板上,保留原始颜色和几何;不是工具栏动作图标。 + +搜索时使用官方 `registry.modelcontextprotocol.io/v0.1/servers`,按名称搜索、只查最新 +版本,并使用返回的 opaque cursor(必须原样传回的分页标记)。这是一份目录,不是 +Lithe 的托管服务器或插件市场,也不把收录标成安全审核。Core 的 `mcp.parseRegistry` +把公开元数据转换为可显示的卡片;静态远程地址和没有额外输入的固定版本 npm/PyPI +入口才能生成配置草稿;自定义包源、非默认 runner、npm 可变标签和主/次版本范围都只保留文档。需要密钥、动态地址或复杂参数的记录保留文档入口,不猜命令。 +草稿不默认选择 Agent,用户确认后走原来的保存流程;不会下载、安装或运行服务器。 + +macOS adapter 使用无持久缓存、无 Cookie 和无凭据存储的 URLSession,限制响应为 +1 MiB,请求/总资源期限分别为 15/20 秒。搜索任务归 SwiftUI 页面拥有,离开页面取消; +请求身份防止旧响应覆盖新搜索,分页合并按名称去重,相同 cursor 不再反复加载。 +精选与在线条目按 ID 和不含名称的连接定义去重。连接失败保留已匹配条目,明确区分 +安全连接、超时和网络不可达,并保留手动添加入口,不把失败伪装成“没有服务器”。 +请求默认遵循 macOS 系统代理;只有用户点击“直连重试”才为此页面的公开目录请求 +选择空代理字典的独立 URLSession,继续校验证书,并允许切回系统代理。路由不持久化, +不修改系统、Agent 或实际 MCP 服务器的网络配置,也不自动绕过用户的代理。 + +公开元数据 +的在线响应只保存在进程内存;精选目录属于只读编译输入。两者均无发行目录运行时写入,不影响签名及 Sparkle delta;资源复用 exclusion +将其登记为 `mcp-registry-metadata`,不存在可复制阶段。 ## 考虑过的备选方案 @@ -99,6 +202,13 @@ Windows 的 Agent 配置路径和 Tauri 侧文件访问模型不同,同步实 ## 验证 +- `LITHE_MCP_PROBE_INTEGRATION=1 ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh --max-seconds 45 -- --filter McpArgumentRoundTripTests`(显式联网验收 Context7 的真实工具列表、原生大栈线程、取消前启动保护、精确参数、独立工具规则和过期文件保护;普通测试不依赖公网) +- `node .agents/skills/write-stable-tests/scripts/run-rust-tests-with-timing.mjs --manifest rust/Cargo.toml --package lithe-agent-host --report .artifacts/test-stability/mcp-tools-host.json`(本地 stdio、HTTP JSON/SSE 响应、分页、超大响应拒绝、虚拟超时及取消后进程回收) + +- `./scripts/test-macos.sh --filter "McpRegistryTests|McpServerLibraryTests"`:离线首页零网络请求、失败保留精选、显式选择路由和路由间分页隔离、精选与在线连接去重,Registry 桥接、分页、取消、旧请求淘汰,JSON 预览不写文件也不取得所有权;原生卡片浅色/深色下在 420 与 780 点宽度重排,截图写入 `.artifacts/mcp-discovery/` 供人工检查。 +- `LITHE_RUN_MCP_REGISTRY_INTEGRATION=1 ./scripts/test-macos.sh --filter McpRegistryIntegrationTests`:可选联网集成,通过生产 adapter 显式直连官方目录并分页;普通单元测试不依赖公网。 +- `cargo test --manifest-path rust/Cargo.toml -p lithe-core mcp::registry::`:精选目录无需网络且草稿固定版本,元数据夹具、失效记录、动态输入、危险链接和大小上限。 +- `node --test scripts/test-reuse-worktree-resources.mjs`:进程内目录元数据不得跨 worktree 复用。 - `cargo test --manifest-path rust/Cargo.toml -p lithe-core mcp::`(覆盖删除首项/中间项/末项、同行相邻项、值跨多行且下一个键同行的布局、单行与混合换行布局,JSON 输出重新解析,重复 JSON 键拒绝编辑;Codex 更新保留 `bearer_token_env_var`、`startup_timeout_sec`、未知键、建模键与值的注释、未变化的 args 数组内注释和非字符串 env 值,变量列表清空时也保留非字符串值;内联 `mcp_servers` 表可删除、可编辑;拒绝重复变量键;哈希忽略当前传输不写入的 command/args/env/url/headers 且与重解析一致;重命名保留专属字段) - `./scripts/test-macos.sh --filter McpServerLibraryTests`(覆盖计划后磁盘变化拒绝写入、文件在计划后出现拒绝创建、linked worktree 公共排除文件、关闭会话后仍能清理项目注册、项目切换后清理两个项目的注册、Git 排除失败不回滚账本、注销冲突写入错误横幅、重复变量键校验、重命名外部条目、按 Agent 开启时忽略传输不写入的字段、切换传输方式丢弃隐藏字段后仍保持所有权、重命名保留 Codex 字段、自然排序条目的所有权、两个窗口合并账本) - `./scripts/test-macos.sh --filter "McpWriteProtectionTests|FileStorageReplacementTests|McpFeatureBatchFailureTests"`(备份完成后外部保存被保留并返回 `changedOnDisk`,预检后出现的新文件不被覆盖;`MacFileStorage.replaceData` 拒绝过期快照、按匹配快照原子发布、拒绝覆盖已存在文件;`removeData` 只删除匹配快照的文件;失败/成功路径都不遗留临时文件;临时文件从创建起为 `0600`;回滚换出更新保存或无法恢复时保留并报告路径;不支持交换的卷拒绝写入和删除) diff --git a/.agents/notes/implemented/feature/2026-10-18-windows-agent-guidance-and-mode-sync.md b/.agents/notes/implemented/feature/2026-10-18-windows-agent-guidance-and-mode-sync.md index d6b8746415..6d0ae69dd4 100644 --- a/.agents/notes/implemented/feature/2026-10-18-windows-agent-guidance-and-mode-sync.md +++ b/.agents/notes/implemented/feature/2026-10-18-windows-agent-guidance-and-mode-sync.md @@ -4,7 +4,7 @@ ## 先说结论 -第 12 期把 macOS 已有的三项对话呈现接入 Windows:Agent 上报的执行计划、与回复分开的思考过程文本,以及 Agent 自行切换模式后的选择器同步。三项都只消费 ACP 上游事件,不在 Windows 侧推断或补造状态;没有计划就不渲染计划栏,没有思考文本就不会出现空的思考行。 +第 12 期把 macOS 已有的三项对话呈现接入 Windows:Agent 上报的执行计划、与回复分开的思考过程文本,以及 Agent 自行切换模式后的选择器同步。三项都只消费 ACP 上游事件,不在 Windows 侧推断或补造状态;没有计划就不渲染计划栏,没有思考或工具就不会出现空的过程入口。 ## 问题 @@ -13,8 +13,8 @@ ## 决策 - 思考文本使用独立的 `reasoning` 消息角色。收到 `agent_thought_chunk` 时先冲刷待合并的正文缓冲,再追加思考,保证思考与回复交替时顺序正确且文本不混合。 -- 思考行按 macOS 呈现为安静的一行摘要:流式时显示小号进度和“思考中…”,结束后显示“思考过程”;折叠时不显示正文预览。展开后是最大 240px 的 Markdown 卡片,流式时跟随到底部。 -- 搜索命中时思考行强制展开,搜索期间点击标题不会收起;清空搜索后回到手动偏好或流式默认状态。 +- 思考和工具、工具之前的过程说明按 macOS 合成每轮一个“思考过程”入口。流式时也默认收起,用户展开后才按原顺序展示全部过程,最终回复始终位于入口外。最后一条思考或工具之后的模型文字作为回复;用户消息和已记录轮次结束切断分组,过滤纯空白消息。展开的主过程区复用消息列表滚动,不设固定高度卡片。原始 reasoning 角色与导出不变,位置规则和流式限制见 [共享对话决策](../architecture/2026-09-25-shared-acp-agent-conversation.md)。 +- 搜索命中时思考行强制展开,搜索期间点击标题不会收起;清空搜索后回到手动偏好或默认折叠状态。 - 计划在每次 `plan` 更新时整体替换。空条目数组是合法清空;顶层结构非法时忽略整次更新,保留上一份计划;单条缺少内容或状态不在 `pending/in_progress/completed` 内时丢弃该条,不默认成待办。 - 计划栏固定在活动统计栏上方,折叠时显示单行“计划 1/3”和当前步骤;展开后最多 180px 滚动。已完成使用成功色勾选并加删除线,进行中在 Agent 响应时使用虚线圆环、轮次结束后使用半填充圆环,待办使用空心圆。 - `current_mode_update` 只同步 `category == "mode"` 的已有配置项,并且要求目标 id 已在该项的 choices 中;未知 id 不写入、不回发请求。 @@ -41,8 +41,8 @@ - `windows/tauri`: `bun run typecheck` 通过;`bun run build` 通过;`bun run lint` 通过,仅有既有警告。 - `src/features/agent` 套件 106 条通过,覆盖计划解析与进度、非法计划忽略、模式同步、思考与正文顺序、搜索分组及 Markdown 导出。 - 仓库门禁:功能矩阵 `--check`、矩阵变更门禁、`verify-agent-notes.mjs`、`verify-windows-boundaries.ps1`、插件隔离与构建产物扫描均通过。 -- 真机验收仍需在 Windows 上执行:真实 Agent 上报计划、思考流式展开与结束后收起、搜索保持展开、计划模式往返后选择器同步、中英文文案及深浅主题。 +- 真机验收仍需在 Windows 上执行:真实 Agent 上报计划、整轮过程默认折叠与手动展开、搜索保持展开、计划模式往返后选择器同步、中英文文案及深浅主题。 ## 适用范围 -Windows Tauri 前端的 Agent 对话呈现、ACP 事件归并与历史导出。ACP 协议、Rust host 转发和 macOS 的 `AgentPlanView`/`AgentThoughtRow` 未改动;macOS 继续作为交互参考。 +Windows Tauri 前端的 Agent 对话呈现、ACP 事件归并与历史导出。整轮过程折叠与 macOS 的 `AgentTranscriptView`/`AgentProcessGroupView` 同步;计划和模式继续沿用原有边界,ACP 协议及 Rust host 转发不变。macOS 继续作为交互参考。 diff --git a/.agents/notes/implemented/feature/2026-10-18-windows-agent-steering-and-subscription.md b/.agents/notes/implemented/feature/2026-10-18-windows-agent-steering-and-subscription.md index dded51df52..cd70195637 100644 --- a/.agents/notes/implemented/feature/2026-10-18-windows-agent-steering-and-subscription.md +++ b/.agents/notes/implemented/feature/2026-10-18-windows-agent-steering-and-subscription.md @@ -4,7 +4,7 @@ ## 先说结论 -第 14 期把 macOS Agent 的“回复过程中继续补充”“订阅额度圆环”和“Codex 订阅登录入口”接到 Windows。补充消息直接复用 Rust host 已经实现的 ACP steering 协议,不再等整轮结束;额度只保留一个圆环,细节放进悬停卡片;登录方式切换沿用 macOS 的订阅行,并在忙碌时拒绝切换。供应商仍保持 Windows 的单 active agent + 单 provider 形态,本机 CLI 配置自动读取与多供应商列表留到后续期次。 +Windows 的中途补充直接复用 Rust host 的 ACP steering 协议;额度只保留一个圆环,细节放进悬停卡片;登录方式切换在忙碌时禁用。供应商配置按 Agent 独立保存,并提供命名列表与本机 CLI 联动;本机联动每次连接重新读取地址、模型和显式 API Key,读取失败时拒绝旧路由。CLI 文件始终只读,密钥只在连接内短暂存在或由用户明确保存到 Windows 凭据库。 ## 问题 @@ -37,14 +37,18 @@ - Codex 设置显示 macOS 同款订阅行:未启用时提供 Enable,启用后提供“Use API Key”返回 API Key 模式。 - 切换按钮在连接中、创建会话、回复中、加载历史、等待权限或配置确认时禁用。切换后 `reconnect` 从设置 store 读取刚保存的值再重连,避免闭包里的旧配置。 - 新增内置供应商默认模型:Codex 为 `gpt-6.1-sol`,Claude 为 `claude-fable-5-1`。切换适配器时只替换“空模型或上一适配器自己的默认值”,用户手填的模型保留;已持久化的空模型不会在读取时被回填。 +- 命名供应商的密钥以 Agent ID 和供应商 ID 隔离,地址变化会产生新凭据所有者,不能继续把旧地址的密钥发送到新地址。删除须确认并先完成凭据删除;失败保留供应商。 +- 配置文本通过共享 Core 解析元数据;Codex auth JSON 和 Claude settings JSON 的显式 API Key 在平台客户端短暂提取,不进入普通设置、错误详情或统计缓存。文本切回表单先解析,失败时保留原文和模式。 +- 显式导入配置成功后切回表单,元数据与显式密钥一起留在未保存草稿中;随后保存使用这份草稿。不能继续留在已清空认证文本的模式下重新提取密钥,否则导入成功后直接保存会误报必填项缺失。取消编辑仍丢弃草稿。 +- 关联本机 CLI 时,每次连接重新读取有 64 KiB 限制的配置与认证文件。订阅和自定义供应商不读取这些文件;缺失、Bearer、外部 helper 或云端认证不能回退到已保存的旧 API Key。 ## 考虑过的备选方案 - 回复期间继续拒绝发送、等整轮结束:实现最少,但 Rust host 的 steering 能力等于白做,也无法对齐 macOS。 - 把补充消息排队到 `turnFinished` 再发送:不需要 delivery 状态,但用户看不到 host 是否接受,失败时也无法区分“没发”和“发了没被消费”。 - 继续用 Tooltip 文本展示额度:改动更小,但无法展示分窗口进度条与颜色,和 macOS 的悬停卡片差距明显。 -- 引入完整的多供应商列表与本机 CLI 配置自动读取:更接近 macOS,但需要把 Windows 设置从扁平单 provider 改成按 agent 保存的 profile map,并新增平台文件读取;范围超过本期,先保持单 active agent + 单 provider。 -- 让表单与配置文本双向同步:需要为 Codex TOML 与 Claude JSON 各写序列化器,本期只保留已有的“粘贴配置导入”,避免生成错误格式覆盖用户配置。 +- 始终复制本机密钥到普通供应商:实现更简单,但 CLI 轮换密钥后仍使用旧值,且用户难以识别来源,所以仅在明确粘贴保存时写入凭据库,本机联动使用临时快照。 +- 将配置文本原样持久化:可完整保留 CLI 私有字段,却可能把凭据和无关执行配置带入普通设置,因此只持久化受支持的元数据;生成文本不包含已保存密钥。 ## 后果 @@ -53,15 +57,16 @@ - 断连后补充消息仍留在对话里并显示失败原因,用户可以复制后重发。 - 额度圆环不再显示文字,低视力用户依赖无障碍标签与悬停卡片;未知值不会被画成健康的 0%。 - 订阅切换会重启 Agent 进程,因此忙碌时按钮禁用;空闲切换后旧 API Key 不会带入订阅连接。 -- 供应商配置仍只有当前 agent 的一份 provider;本机 CLI 配置自动读取、多供应商列表和配置文本双向切换仍需后续实现。 +- 供应商列表有数量上限;原生保存通过比较并交换(CAS,即只接受预期修订仍有效的更新)拒绝跨窗口过期修改,并在同一次保存操作中提交元数据与凭据。配置文本只覆盖受支持的供应商元数据,不承诺保留任意 CLI 配置字段。 ## 验证 - `windows/tauri`: `bun run typecheck` 通过;`bun run lint` 与 `bun run build` 通过。 - Agent 与 i18n 测试覆盖:steering 的 `injected`、`promptRequired` 单次续发、停止撤销、拒绝与断连,用户回显去重,供应商默认模型与空模型保留。 +- `windows/tauri/tests/agent-provider-editor.test.tsx` 覆盖 Codex TOML/auth JSON 与 Claude settings JSON 的“文本导入 → 直接保存”,确认保存使用导入的模型、地址与密钥,普通设置不含密钥。 - 仓库门禁:功能矩阵 `--check`、矩阵变更门禁、`verify-agent-notes.mjs`、`verify-windows-boundaries.ps1`、测试稳定性检查与 Windows 插件隔离检查均通过。 - 真机验收仍需在 Windows 上执行:真实 Codex 订阅与 Claude API Key 的输出中补充、工具执行中补充、等待授权时补充、斜杠命令续发;额度圆环颜色与悬停卡片;登录、取消与 API Key 切换。 ## 适用范围 -Windows Tauri Agent 对话的 steering 状态机、Composer 发送/停止、Transcript delivery 展示、订阅额度圆环与 Codex 订阅入口。ACP 协议、Rust host 的 steering 实现、macOS 交互和供应商单 profile 设置形态未改动。 +Windows Tauri Agent 对话的 steering 状态机、Composer、订阅额度与登录入口,以及 `agent-settings-section.tsx`、`agent-launch.ts` 和本机 CLI 读取 adapter。ACP 协议、Rust host 的 steering 实现与 macOS 交互不受影响。 diff --git a/.agents/notes/implemented/feature/2026-10-20-agent-skill-management.md b/.agents/notes/implemented/feature/2026-10-20-agent-skill-management.md new file mode 100644 index 0000000000..623f55f874 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-20-agent-skill-management.md @@ -0,0 +1,102 @@ +# Agent 笔记:技能文件管理与原生 Agent 加载边界 + +状态:已实现 + +## 先说结论 + +技能设置按“全局技能 / 项目技能”组织,Codex 和 Claude 作为列表中的来源标签和筛选项。用户可以查看正文、打开文件、定位目录、新建、导入及启停技能;这些操作使用各 Agent 的原生目录和配置,不维护 Lithe 自己的技能副本或启用状态。技能文件出现在列表中,并不代表当前会话已经加载或执行了它。 + +## 问题 + +输入框的 `$` 和 `/` 补全只能显示当前会话上报的命令,不能回答技能保存在哪里、属于哪个项目、如何添加配套脚本。直接把文件清单当成会话能力,会误导用户;若另存一份技能或增加只影响 UI 的开关,还会出现页面“已关闭”但原生 Agent 仍然读取文件的情况。 + +## 决策 + +### 范围和页面 + +页面放在设置的 AI 分组,Agents 页提供管理入口。主要分类只分全局和项目;项目默认取打开设置的工作区,可选择其他已打开项目,选择动作不切换工作台或会话。新建和导入默认使用正在查看的分类,并明确选择适用 Agent 和显示实际目标目录。 + +「技能」和「指令」是同一 AI 分组中的两个独立页面,必须同时保留入口与各自的功能模型。新增全局指令编辑器不能替换技能设置;预览多个尚未合并的功能时,需要使用包含两者的工作区构建,不能把切到另一开发分支造成的入口缺失误判为原生技能未加载。 + +信息结构参考 PI-Desktop `de57f05aa65b05c93a4113bf5d4db9723884b080` 的 [AgentSkillsPage](https://github.com/vastsa/PI-Desktop/blob/de57f05aa65b05c93a4113bf5d4db9723884b080/apps/desktop/src/components/settings/AgentSkillsPage.tsx) 和 [SkillEditorSheet](https://github.com/vastsa/PI-Desktop/blob/de57f05aa65b05c93a4113bf5d4db9723884b080/apps/desktop/src/components/settings/SkillEditorSheet.tsx)。只参考分类、搜索、紧凑列表与独立编辑面板的交互组织,不复制其代码、样式或状态存储。控件继续使用 Lithe 的设置分段、选择器、搜索框、按钮和菜单。主工作台、标题栏和项目标签不参与此变更。 + +### 发现事实仍由 Agent 拥有 + +Codex 复用已安装 CLI 的官方 `skills/list`,以所选工作区发出强制刷新,保留上游范围、启用状态和插件身份。本机 Codex 0.160.0 与固定适配器依赖的上游 rust-v0.156.1 公开协议均提供这些字段;不需要升级适配器,也不创建会话或发送提示词。生命周期复用既有 App Server 短进程及其有界读取、超时和进程树回收。 + +Claude 固定适配器把 SDK 的 `supportedCommands()` 转成 ACP 命令列表,但缺少可独立查询的完整技能文件路径和范围目录。因此设置页只列出所选项目 `.claude/skills` 和用户配置目录 `skills` 下的文件,并明确显示“目录清单”。用户配置目录服从 `CLAUDE_CONFIG_DIR`,未设置时使用 `~/.claude`。不仿制 Claude 的祖先目录、插件、策略与延迟加载规则;列表 enabled 只表示对应范围的本地配置可见性,不等于原生会话最终可用性。输入框继续仅使用会话实际上报的命令。 + +原生目录依据 [Codex 技能文档](https://developers.openai.com/codex/skills/) 与 [Claude 技能目录说明](https://code.claude.com/docs/en/skills#choose-where-skills-load)。正确示例:在项目分类为 Codex 创建技能,写到该项目 `.agents/skills//SKILL.md`。不要把 Claude 项目目录里的文件改名或移动到全局目录来模拟启停。 + +### 原生启停与配置作用范围 + +纯操作菜单通过共享呈现器保留设置窗口焦点,避免仍开启的原生开关仅因菜单打开而变灰;详见[共享下拉菜单焦点](../simplification/2026-09-30-macos-shared-dropdown-style.md)。每行使用既有原生小开关。点击后立即显示用户选择的状态,后台写入并回读原生配置,不在开关旁增加加载转圈。仅等待确认的技能行禁止重复操作,其他技能保留原生开关的启用状态和颜色;点击其他行时立即显示其选择,并按点击顺序串行执行原生写入与回读,不能并发交换整份清单而覆盖较新的确认。单行失败回滚该行,后续已排队的操作继续执行,错误保留至下一批操作;项目切换或页面离开同时清除未执行队列。不要用页面级忙碌状态禁用全部开关,否则关闭一个技能会让所有仍开启的技能也变灰,误导用户以为它们一起被关闭。用户选择暂存在功能模型,不能提前替换已确认的原生清单或显示“已保存”。确认成功后采用回读结果;失败恢复已确认状态并显示错误,提示刷新核对。未能解析配置时显示“状态未知”,不能把读取失败当作默认启用。项目切换或页面离开会清除临时选择、取消自有请求并拒绝迟到结果,不中断原有 Agent 对话。 + +Codex 使用官方 [skills/config/write](https://developers.openai.com/codex/app-server) 按 SKILL.md 路径设置 enabled。一次有界 App Server 交换先用所选工作区发现技能,拒绝未知路径,再写配置并强制读取列表验证最终状态。Codex 的此接口写入用户配置,因此项目技能的开关也影响所有使用相同路径的工作区;页面提示这一点,不将项目分类误称为独立项目开关。不直接重写 Codex 的 TOML 或插件文件。 + +Claude 使用官方 [skillOverrides](https://code.claude.com/docs/en/skills#override-skill-visibility-from-settings) 的 on/off;固定适配器对应的最低 CLI 2.1.280 已包含该能力,上游 CHANGELOG 从 2.1.129 明确支持。全局开关写 CLAUDE_CONFIG_DIR/settings.json,项目开关写所选项目 .claude/settings.local.json,不修改团队共享 settings.json 或 SKILL.md。该原生配置按名称生效,同名技能共享配置,项目及受管配置可以覆盖全局;界面帮助必须解释这些限制。全局行读取用户配置,项目行合并用户、该项目 settings.json、settings.local.json;这只是所列本地配置的视图,不声称复刻受管、祖先或已连接会话状态。name-only 和 user-invocable-only 仍显示开启,用户关闭再开启会明确改为 on;插件技能需使用 Claude 的插件管理,不通过改名搬目录实现开关。 + +Claude 配置仅合并目标 skillOverrides 键,保留其他 JSON 值与既有权限;无效 JSON、未知枚举、非普通文件、符号链接、超出 1 MiB 的配置均拒绝写入。配置目录内的持久锁文件用非阻塞文件锁串行化 Lithe 实例,写前重新比较原始字节以识别外部修改;临时文件同目录落盘并同步后原子替换,取消或失败回收临时文件。外部编辑器并不共享该锁,字节比较和替换间仍有极短的竞争窗口,不承诺跨所有编辑器的事务。锁与配置由用户目录/所选项目持有,不是可跨 worktree 复用的构建资源,不能写入 app bundle。 + +开关反馈只说明原生配置已保存。成功提示始终预留文字所需的布局空间,仅在原生确认后可见;开始下一次保存时隐藏文字但保留空间,同时从辅助功能树隐藏。不要按保存状态插入或删除整行提示,否则首次保存及连续切换都会让下方技能列表上下跳动。已经加载进会话的内容无法撤回,现有对话应重新连接以刷新命令和技能清单;不自动重连正在执行任务的 Agent。 + +### 文件写入和异步生命周期 + +共享 Core 只负责 `agent.skills` 的类型化分派、取消和错误信封;原生发现与文件操作在 `lithe-agent-host`。Swift 的服务桥离开主线程执行,功能模型保存列表和操作状态,视图只消费模型及显式的原生对话框/打开/定位动作。 + +技能服务与指令服务共用 `RustCoreCancellation`:进入服务和启动后台工作前检查取消,Core 同步发出 `requestStarted` 后补传更早的取消,再分派文件操作。只在取消处理器中发一次 `core.cancel` 不够,因为尚未注册的操作会返回未找到,独立后台任务随后仍可能创建、导入或改配置。功能模型在调用写入服务前也检查取消;安装进度继续通过同一事件回调转发,注册事件不被误当成进度。`AgentSkillsCancellationTests` 在 MainActor 不让出执行权即取消真实桥接任务,并在编码边界触发取消,验证独立项目的技能目录、原生配置和暂存状态均不变,后续合法操作仍能成功。 + +导入保留完整目录中的脚本、模板和文件权限,源目录只读。名称、YAML 文件头、UTF-8、目录深度、文件数和大小都有边界;拒绝导入中的符号链接和特殊文件,以及通过链接的目标容器。先写随机隐藏暂存目录,再以不覆盖模式发布;已有空目录也算冲突。失败或取消回收暂存目录,不改已有技能。查看正文仅返回有界文本,不执行文件中的命令或指令。 + +最终发布必须由操作系统原子地拒绝已有目标,不能只在准备暂存前检查目录是否存在。 +macOS 使用 `RENAME_EXCL`,Linux 使用 `RENAME_NOREPLACE`;Windows 使用 +`windows-sys` 的 `MoveFileExW`,只启用 `MOVEFILE_WRITE_THROUGH`,不启用替换和跨卷复制。 +UTF-16 路径保留 Unicode、空格和长路径前缀,内嵌 NUL 拒绝传入原生 API。 +不能把 Windows 的 `std::fs::rename` 当成不覆盖操作:[Rust 官方文档](https://doc.rust-lang.org/std/fs/fn.rename.html) +说明支持 `FileRenameInfoEx` 的文件系统可替换已有空目录,而 +[MoveFileExW 文档](https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-movefileexw) +提供显式控制替换的标志。Windows CI 曾在这个边界实际覆盖空目标,故回归必须保留失败 +断言;正确做法是让最后一次移动失败并保留源包和已有目标,不是增加另一次易竞争的存在检查。 +未实现排他发布的平台直接拒绝,不退回可能覆盖目标的普通重命名。 + +刷新独立查询两个 Agent,单方不可用不遮住另一方结果;同一项目刷新失败保留旧列表,切项目先清除旧数据。请求与写入捕获项目,迟到结果须核对版本;页面离开时取消自有任务。原生探测的取消必须进入已有交换清理路径,初始化卡住时也先终止并回收子进程,再销毁短生命周期的 Tokio 运行时;不能只丢弃读取 future,把子进程回收留给已经退出的事件循环。保存后刷新列表,是否被运行中的会话发现继续由原生 Agent 决定,不停止会话,也不承诺一次导入会让所有 Agent 都可用。 + +这些文件是用户明确创建或导入的工作资料,不是构建产物、下载缓存或可跨工作树复用资源。它们的生命周期由用户和原生 Agent 管理,写入位置仅为原生用户或所选项目目录;不写应用包、已安装适配器、签名资源或发行目录,不改变代码签名和 Sparkle 增量更新基线。没有新增可复用的构建资源,因此工作树资源清单不登记用户技能。 + +## 考虑过的备选方案 + +- **统一扫描并重建完整 Agent 技能目录**:Codex 已有官方接口,另做一套发现会遗漏插件和策略;Claude 的清单不够完整时应明确说明,而不是复制它的整个解析器。 +- **沿用 PI-Desktop 的独立启用状态和运行时合并**:PI 自己掌握执行器,Lithe 使用外部原生 Agent,自己的布尔值无法证明对方已停用。启停改为写原生配置,不保留独立开关状态;市场、删除或跨范围移动仍不提供。 +- **把所有技能存入 Lithe 数据目录**:会增加原生 CLI 看不到的第二份事实,也让其他工具与 IDE 的结果不一致,因此使用原生技能包目录。 + +## 后果 + +用户能够直接分清全局和项目技能,并通过来源标签理解适用 Agent;管理文件不干扰对话。代价是 Claude 设置清单明确不完整,原生 CLI 不可用时 Codex 的发现会显示错误,技能生效时间仍取决于原生会话。 + +Windows 已能使用共享 Core 与 Host 操作,但设置页面和真实 Windows 文件系统、进程清理尚未验收;功能矩阵保持 partial/pending。组件和受控协议测试不代替真实会话验收。 + +## 验证 + +- `./.agents/skills/write-stable-tests/scripts/verify-test-stability.sh` +- `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'AgentSkillsFeatureModelTests|AgentSkillsRustBridgeTests|AgentSkillsAppearanceTests|SettingsSidebarLayoutTests|SettingsSearchVocabularyTests'` +- `node .agents/skills/write-stable-tests/scripts/run-rust-tests-with-timing.mjs --manifest rust/Cargo.toml --package lithe-agent-host --report .artifacts/test-stability/agent-skills-rust.json` +- Windows 在原生 CI 中运行相同 Host 测试,覆盖暂存后出现的空目录、普通文件和非空目录都不覆盖,缺失的 Unicode 目标完整发布,以及 NUL 路径不按截断名称发布。这些文件系统用例不等于 Windows 设置页或真实 Agent 会话验收。 +- `./scripts/verify-shared-contracts.sh` +- `./scripts/verify-service-boundaries.sh` +- `./scripts/verify-runtime-bundle-immutability.sh` +- `./scripts/verify-platform-feature-matrix.sh` +- `./scripts/verify-agent-notes.sh` + +原生布局测试覆盖窄宽页面及深浅主题,检查菜单锚点落在可见宽度内;开关测试分别阻塞两次原生写入,对比首次点击前、关闭保存中、关闭确认后、再次开启保存中和开启确认后的全部原生开关位置,确保提示变化不移动技能行。可通过 `LITHE_AGENT_SKILLS_SCREENSHOTS` 输出审查截图。夹具验证原生发现与目录清单的区别、原生启用状态、Claude 本地配置可见性、作用范围和插件身份;临时项目通过真实 Core 桥验证创建、查看和冲突。受控假 App Server 验证发现只读,启停只执行发现、原生配置写入和读取核对,拒绝未知路径,均不发送创建会话或提示词请求。临时配置验证全局/项目隔离、其他字段及文件权限保留、无效配置、锁冲突、符号链接和取消清理;功能模型验证点击立即反馈但不提前报告已保存、失败回滚到已确认状态、写入串行和项目切换拒绝旧结果;原生控件回归验证保存期间当前开关立即关闭且不增加加载转圈,其他开启行的原生状态、交互能力与像素外观不变;功能模型控制两次写入的释放次序,验证同一 Agent 的连续保存不丢确认、单项失败后队列继续、重复点击去重,以及切项目/退出时未执行请求不再启动。窗口失去焦点时保留 macOS 原生非活动外观,滑块位置和实际原生配置不随窗口焦点变化。 + +## 适用范围 + +- `rust/lithe-agent-host/src/skills.rs` +- `rust/lithe-agent-host/src/skills/files.rs` +- `rust/lithe-agent-host/src/skills/settings.rs` +- `rust/lithe-core/src/agent/mod.rs` +- `macos/Sources/LitheCoreContracts/Agent/AgentSkills.swift` +- `macos/Sources/Lithe/Application/Features/AgentSkillsFeatureModel.swift` +- `macos/Sources/Lithe/Views/Agent/AgentSkillsSettingsView.swift` +- `macos/Sources/Lithe/Views/Agent/AgentSkillSheets.swift` +- `shared/fixtures/agent/skills-management-v1.json` diff --git a/.agents/notes/implemented/feature/2026-10-21-agent-global-instructions.md b/.agents/notes/implemented/feature/2026-10-21-agent-global-instructions.md new file mode 100644 index 0000000000..2f8fd0deb1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-21-agent-global-instructions.md @@ -0,0 +1,93 @@ +# Agent 笔记:全局指令使用原生用户文件 + +状态:已实现 + +## 先说结论 + +macOS 设置的「AI → 指令」可以编辑 Codex、Claude Code 各自的全局指令。页面同时支持一份共享正文:选择 Agent 后,只把它同步到原生文件中的 Lithe 标记区块,保留用户自有内容。新对话由上游 Agent 加载;Lithe 不额外拼接提示词,也不改项目规则。页面显示实际路径,保存冲突或失败时保留草稿。 + +## 问题 + +Agent 已能继承用户的全局规则,但 Lithe 没有查看和编辑入口。只新增一个文本框并保存到 Lithe 偏好,会让界面显示与 Agent 实际收到的内容不一致;忽略 Codex override 文件,也会出现保存成功却不生效的问题。 + +## 决策 + +- 页面参考 Magpie 的紧凑卡片和 Agent 行:共享正文默认收起,Agent 行显示品牌图标、实际路径、自有非空行数与共享开关,点击展开完整原生编辑器。说明放在 ⓘ,不把长段落放到页面。使用现有设置卡片和原生多行编辑框。Codex、Claude 分别保留内存草稿,切换页面或 Agent 不自动保存。明确选择重新加载并确认放弃草稿后,成功读取才替换草稿。 +- 全局指令和技能管理的入口、功能模型、Core 命令分别保留,在同一 AI 分组中并列可用;预览构建必须包含两个功能后再交给用户。全局指令编辑器不负责加载、启停或复制技能。 +- 原生文件路径由 Agent Host(负责原生文件和进程的 Rust 适配层)解析,使用 Agent 启动时继承的 `CODEX_HOME`、`CLAUDE_CONFIG_DIR` 或平台用户目录。Codex 选择首个非空的 `AGENTS.override.md` / `AGENTS.md`,Claude 选择 `CLAUDE.md`;不支持的自定义 Agent 不猜路径。 +- Core 只分派带类型的请求和稳定错误;Swift 应用模型管理加载、保存、失败和草稿,视图不直接访问文件。原生路径仅作显示和保存校验,不成为跨平台持久标识。 +- 保存比较原文件的路径与摘要(字节内容的 SHA-256 标识),防止覆盖读取后发生的外部修改。临时文件在目标目录内创建,写完同步后原子替换;保留已有权限,拒绝覆盖只读文件,Unix 新文件为 `0600`、新目录为 `0700`。同进程保存使用不等待的锁;其他程序不共享锁,检查与替换之间仍存在很小的外部竞争窗口,不声称提供系统级事务。 +- 清空 override 后重新按原生规则读取,页面可能回到基础 `AGENTS.md`。正确做法是展示这个实际生效的来源;不要保留一个已被原生跳过的空 override 并声称已清空全部指令。 +- 文件只接受至多 1 MiB 的 UTF-8 普通文件,符号链接拒绝编辑。限制用于保护设置页资源;Codex 自身的上下文截断预算仍由原生配置决定,Lithe 不修改它。 +- 仅用户点击保存时写用户配置目录;不写发行 bundle、安装目录、代码签名资源或 Sparkle 增量更新基线。不新增下载、缓存或可跨工作树复用的生成资源,共享正文及同步锁作为用户状态登记到工作树资源排除清单。临时文件由单次保存拥有,成功和失败都清理,异常退出遗留只是一份随机命名临时文件,不会被 Agent 加载。 + +### 共享正文与原生区块 + +用户明确要求共享同步后,增加一份 `Shared.md` 作为已保存共享正文的来源。macOS +组合根通过 `FileStorage.applicationSupportDirectory()` 提供 `Lithe/AgentInstructions/`, +Agent Host 负责原子文件操作,Core 负责严格请求分派,功能模型负责草稿、排队和逐项反馈。 +这是用户配置,不是缓存,不存在版本/平台/架构/工具链身份,也不能跨工作树复制。 +`Shared.md` 和 `.sync.lock` 随用户资料保留;同目录暂存由单次保存拥有,完成或失败清理, +异常退出可能遗留随机命名暂存但 Agent 不读取。目录不从 bundle 或安装目录推导, +因此不影响代码签名与 Sparkle delta。资源复用脚本显式拒绝该用户状态。 + +同步不替换整份规则,而是插入/替换 `LITHE:SHARED-INSTRUCTIONS:BEGIN/END` 注释之间的正文。 +开关由实际文件区块推导,关闭只移除拥有的区块和它自己的换行,原始字节保持。 +非空 Codex override 仍是有效目标;不为了共享功能绕过上游优先级。正文中不能包含标记, +重复、损坏或不完整标记使开关成为未知并提示修复,不猜测删除范围。 + +共享源先保存,再依次更新已开启 Agent。每个文件单独原子替换;这不是多文件事务, +一项权限错误或摘要冲突不能回滚其他已完成项或丢掉共享正文。失败行保留原因, +实际区块与源不同显示待同步,用户显式重试。多个开关按点击顺序串行处理, +只锁定待处理行,其他行不因全局禁用变灰;取消拥有队列的页面任务后不启动后续写入。 +重新进入页面只回读实际状态,不自动同步,也不丢弃未保存草稿。 + +Swift 适配器在启动工作任务前检查取消,并通过 `RustCoreCancellation` 把注册前到达的 +取消保存到同步状态。Core 在登记操作后、分派命令前同步发出 `requestStarted`;回调在这个 +边界补传取消,因此快速保存也不会在注册空隙中漏掉取消。不要只调用一次尚未登记的 +`cancel`,也不要用定时重试保证短写入:写入可能在首次重试前完成。已完成的原子替换 +不会因稍后取消而撤销,跨文件保存仍只在每次写入之间观察取消。 + +正确做法:用户在 Codex 文件写了自有规则,开启共享后两者并存;关闭后自有规则逐字节保留。 +不要把关闭共享解释成关闭 Codex 或删除其整份 AGENTS.md,也不要在启动时自动覆盖外部修改。 + +布局与行为核对 [Magpie 指令页](https://github.com/yetone/magpie/blob/ccbc7278180e513bf95461c62eeb15729fc7cae7/internal/gui/assets/plugins/library/client.js#L1173) +的共享卡片、Agent 行和区块保留方式。本次独立实现,只支持 Lithe 已有原生文件能力的 Codex、Claude Code, +一份共享正文,不引入截图中其他 Agent、多个命名方案、导入私有规则或文件链接。 + +交互结构参考 [PI-Desktop 的全局指令页](https://github.com/vastsa/PI-Desktop/blob/a6f4ba121e524a167c42466c7b4debbb3cfd0744/apps/desktop/src/features/settings/agent-sections.tsx):路径、文本编辑和显式保存。没有复制其源码或视觉样式。原生规则依据 [Codex AGENTS.md](https://learn.chatgpt.com/docs/agent-configuration/agents-md) 和 [Claude Code memory](https://code.claude.com/docs/en/memory)。 + +## 考虑过的备选方案 + +### 把共享正文直接注入每次请求 + +会和原生文件加载产生两份提示词,并需要维护上下文去重和历史恢复语义。因此共享正文通过显式区块同步落到原生用户文件,由上游加载;不修改 Agent 的请求或注入链路。 + +### 保存时停止并重连所有项目 + +会打断正在进行的任务,且不能撤回已进入模型上下文的旧内容。因此只提示新建对话,不自动停止或篡改现有会话。项目规则优先级继续由上游 Agent 决定,不在页面声称全局指令能覆盖所有规则。 + +## 后果 + +用户可以直接管理 CLI 也会读取的全局文件,冲突能保留草稿并显式恢复。代价是符号链接配置需先由用户自行处理,外部编辑不会实时推送到页面,更新也不会改写现有对话。Windows 已可通过共享 Core 调用文件能力,尚无对应设置页和 Windows 实机验证;矩阵保留部分实现、待验证状态。 + +## 验证 + +- `./.agents/skills/write-stable-tests/scripts/verify-test-stability.sh`。 +- `SharedInstructionsFeatureTests` 覆盖共享草稿、切换排队、逐项失败与页面取消;Host shared 测试用临时目录验证真实读写、关闭字节还原、override、损坏标记和跨文件部分成功。 +- `AgentInstructionsCancellationTests` 使用真实 Core 和隔离共享源,确定性控制进入适配器前取消、编码后注册前取消,确认文件及锁未创建、已有字节不变,下一次有效保存仍能完成;不写开发者原生指令文件。 +- 典型共享流程前后比较 app bundle 文件清单与哈希,用户目录不参与发行资源写入;`node --test scripts/test-reuse-worktree-resources.mjs` 确认共享指令用户状态不能复制。 +- `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'AgentInstructionsFeatureModelTests|SettingsSidebarLayoutTests|SettingsSearchVocabularyTests|ContextMenuCoverageTests'`。 +- Agent Host 计时测试覆盖原生优先级、读取不创建文件、保存和清空、外部修改与路径变化、UTF-8/大小限制、符号链接拒绝、权限保留和临时文件清理。 +- Core 的 `global_instruction_contract_dispatches_and_rejects_unknown_agents_without_file_access` 验证共享 fixture 与严格请求边界。 +- 隔离临时用户目录,通过同一 Core 命令保存,再捕获真实 Codex CLI 与 Claude CLI 发出的模型请求;Codex 的保存、更新、override 保存、清空 override、清空基础文件,以及 Claude 的保存、更新、清空共八项均确认新增指令出现、旧指令消失。Codex 五项同时确认项目规则继续存在。响应来自本地受控服务,不是在线模型推理;测试进程和临时目录均回收,不修改用户实际配置。 +- `./scripts/verify-runtime-bundle-immutability.sh`、`./scripts/verify-service-boundaries.sh`、`./scripts/verify-shared-contracts.sh`、`./scripts/verify-agent-notes.sh`、`./scripts/verify-platform-feature-matrix.sh`。 + +## 适用范围 + +- `rust/lithe-agent-host/src/instructions.rs` +- `rust/lithe-agent-host/src/instructions/shared.rs` +- `rust/lithe-core/src/agent/instructions.rs` +- `macos/Sources/Lithe/Application/Features/AgentInstructionsFeatureModel.swift` +- `macos/Sources/Lithe/Views/Agent/AgentInstructionsSettingsView.swift` +- `shared/fixtures/agent/agent-instructions-v1.json` diff --git a/.agents/notes/implemented/feature/2026-10-21-declarative-native-plugin-ui.md b/.agents/notes/implemented/feature/2026-10-21-declarative-native-plugin-ui.md new file mode 100644 index 0000000000..599f2f787b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-21-declarative-native-plugin-ui.md @@ -0,0 +1,186 @@ +# Agent 笔记:声明式原生插件树与动作 + +状态:已实现 + +## 先说结论 + +插件可以声明树、列表、输入框、下拉选择、复选框、工具栏按钮和右键菜单,由 macOS 使用 Lithe 共享控件显示。开发者只提供数据与函数,不必开发前端样式。本轮复用现有插件函数调用和跨项目生命周期,不增加后端执行系统;Windows 原生渲染留待后续独立实现和验证。 + +## 问题 + +文本窗口无法表达节点选择与业务动作;Web 页面则要求插件作者自己维护控件与样式。现有窗口注册、停靠、主题和函数调用已经具备基础能力,重复建设会带来两套状态和清理边界。 + +## 决策 + +在 ui 能力版本 3 下引入独立版本的原生声明,首版支持一棵树、一个列表或一个表单及其面板动作。共享 Core 同时负责初始声明与刷新数据的结构、所有权和预算校验(原生适配器不复制节点规则),应用功能模型负责选择、展开、数据替换和事件,视图只使用已有标题栏、工具栏按钮、菜单、行样式和滚动条。声明不能携带颜色、字体或脚本。树/列表的行容器在共享行背景外提供 Tree.horizontalInset 的左右留白,让悬停与选中保持同一外边界;文字内部仅保留层级缩进。键盘区域复用 litheFocusEffectHidden 关闭系统矩形焦点环,仍通过共享行的活动/非活动选中配色表达焦点,保留方向键和无障碍。 + +数据源和动作复用现有插件函数入口:它负责授权、工作区和插件范围、超时及取消。原生窗口使用与网页相同的会话登记表,插件停用、替换及关闭项目时同步取消请求。隐藏窗口也释放请求,重开从已有状态存储恢复选择和展开。发布包与插件包只读,状态只写平台提供的原有可写存储,不改变签名或 Sparkle 增量更新基线;没有新增可跨工作树复用的缓存资源。 + +正确做法:插件声明树和刷新动作,函数只返回稳定节点 ID 与标签。不要这样做:按插件名在工作台增加视图分支,或为了渲染树另建后端进程与生命周期。 + +## 考虑过的备选方案 + +- 要求所有插件提供 Web 页面:已有能力保留,但不满足免写样式的需求。 +- 执行第三方 Swift 界面代码:增加二进制兼容、签名和隔离问题,不采用。 +- 一次实现表单、任意组件组合与领域迁移:超出本轮范围,先验证最小完整路径。 + +## 后果 + +同一份树与动作声明可以直接使用 IDE 样式。代价是首版只允许每个面板匹配一个已检测构建实例,完整刷新最多 2000 个节点,尚无多实例选择器或按需加载子节点;这些限制在契约中明确,不能默默选择第一个实例。动作限定面板内,不能据此宣称全局独立命令或整个 #1209 已完成。 + +选择与展开按稳定 ID 保存;无效回复保留上一份有效节点并显示错误。入口必须保留稳定的非空宿主容器:不能依赖最初没有子视图的 Group 启动实际工作台中的出现回调。首次内容创建、后续重绘和清理均绑定容器,初始化失败必须显示原因,不能留下无标题的空白面板。 +同一停靠槽会复用相同 SwiftUI 渲染类型;内部会话必须以 contribution.id 为身份边界, +不能只按工作区标识。否则切换两个插件面板会沿用前一个面板的 State,释放回调却拿到 +后一个窗口 ID,遗留会话后再次打开就报重复登记。 +仅增加窗口身份仍不够:隐藏、重开和父容器销毁时,不能依赖 onDisappear 回调再读取 +可能已销毁的 State 存储。StateObject 会话所有者持有准确的 feature 与释放函数, +出现/消失时显式打开/关闭,销毁时在 MainActor 同步释放未结束的会话;登记表仍核对句柄身份, +旧释放不能关闭后来新建的会话。 +外层分隔布局改变结构时,新分支的面板可能先挂载,旧分支才消失;仅靠视图身份与 +销毁回调无法避免这个重叠。登记表允许同一插件同一窗口原子替换:先登记新句柄, +再关闭旧句柄,旧视图随后释放时仍按句柄身份检查。不同插件、网页句柄以及被停用的插件 +不能借此覆盖登记。样式不是插件作者的责任:标题栏 +复用已更新的公共 LitheToolWindowHeader(41pt/13pt Bold、纯文字、无图标和边框/底线),不由插件另画。完整挂载回归需要把插件视图嵌入父容器,不能只渲染已经创建好的内容面板。 + +已在真实 DEBUG 预览中通过磁盘安装的 Sample Build 和测试工作区验证:节点加载、展开、Inspect、右键点其他节点、方向键,以及隐藏重开恢复。真实产品浅深色、窗口失焦、停靠恢复和示例安装验收仍应独立记录,组件测试不代替原生产品验收。 + +真实 UI Lab 预览已验证:静态树、静态列表、空面板同时显示,左右面板分别隐藏重开, +动态树/列表与静态面板切换均正常,没有重复登记错误;标题均为纯文字,无标题图标、边框和底线。 +本轮相关195项 macOS回归通过。桌面工作区曾在 FSEventStreamCreate 监控父目录时阻塞, +因此这轮面板验收使用含同一 marker 的本地临时工作区;没有把该目录监控问题记为已修复。 + +工具栏动作只显示图标,title 用作悬停提示和无障碍名称;复用现有 LitheToolWindowHoverAction, +鼠标进入整个面板时显露,离开隐藏,键盘/无障碍聚焦仍可操作。动作可用既有宿主图标声明 +选择语义图标,原生动作不加载自定义包图片;未声明时 refresh 使用刷新,invoke 使用通用执行。 +不能按插件或动作 ID 猜测业务图标,也不能退回文字按钮。 +本轮52项宿主图标/悬停/原生面板回归与Core图标边界检查通过。实机已确认非悬停时工具栏隐藏; +鼠标坐标自动化返回 windowNotFoundAtPosition,未据此宣称实机进入/移出已通过,仍需用户验收。 + +### 表单只接声明,不复制已有公共控件 + +输入框复用 `litheSettingsTextField`,选择器复用 `LitheSettingsSelect`,复选框复用 +`LitheSettingsCheckbox`,后者仅补充 literal 标题入口,样式不变。新的 `form` 内容容纳 +至多32个字段和16个按钮,避免为了输入三个控件而引入任意布局/脚本/表单引擎。按钮外观仍使用已有公共 owner。 +控件 ID、值类型、选项、动作引用与预算由 Core 校验;宿主恢复已保存字段时还要剔除 +已删除字段、变更类型和失效选项,不能让旧状态绕过新声明。 + +输入每次编辑只存本地值;回车提交,选择器与复选框在改变后提交。onCommit 指向 +已有的非选择 invoke 动作,所有当前值以 values 通过现有函数通道传递,之后来源 +仍返回空 nodes。没有新后端接口;没有 onCommit 的字段仅本地保存。这样保留中文 +输入法编辑过程,避免每个按键都启动插件函数并禁用输入。关闭、停用、替换继续使用 +既有取消与晚到结果防护。树/列表旧函数不增加 values 参数,保留原参数形状。 + +本轮63项 macOS图标/原生面板/公共选择器/菜单覆盖回归通过(8.093s),新增实际 +挂载表单检查耗时46ms;19项声明式Core测试与打包后Node示例测试通过。 +已生成 NativeUILab 0.2.0,由用户手动导入;完整产品中的表单深浅色、输入法、 +弹出选择、禁用与跨工作区人工验收仍 pending,不能由挂载测试代替。 + +### 表单栅格 + +form 可声明1–12列及控件span,省略span占满一行,省略layout维持旧纵向布局。 +按声明顺序排放,剩余列不足时换行;Core拒绝越界跨度、非表单布局及自定义间距。 +宿主通过共享 `LitheFormGrid` 的原生 SwiftUI Layout 测量和放置,不发布逐控件几何 +Preference,也不因为缩放重建不同的视图分支。12pt间距与640pt单列阈值由 +LitheTheme.FormGrid持有,字段值和焦点继续由稳定ID和既有控件保存。 +验收插件0.2.1按用户要求将所有字段各占6列,每行两个完整字段(标题+控件)。 + +本轮24项macOS栅格/原生表单/公共选择器回归通过(0.918s),原生宽窄布局 +高度测量检查耗时55ms;20项声明式Core校验及打包后Node示例检查通过。 +0.2.1声明为每行两个字段,仍由用户手动导入验收;无layout的旧0.2.0声明 +保留单列,不能把仅更新宿主当作测试插件已更新。 + +### 未聚焦输入框的光标 + +表单已复用公共 `litheSettingsTextField`,但原公共修饰器未接入搜索输入框已有的 +`litheTextEditingCursor`,导致鼠标进入未聚焦字段时仍是箭头,点击后才由原生编辑器 +显示I形光标。现在由公共输入框修饰器在完整控件区域安装已有光标语义;禁用时仍为 +箭头,移出恢复箭头,不在插件或各页面补丁,也不改原生编辑、组合输入或提交行为。 + +本轮26项搜索输入/原生表单/选择器回归通过(2.483s),复用既有光标处理。 +组件回归不等同于实机鼠标悬停验收,最终未聚焦/禁用字段的光标仍由用户在新预览核对。 + +### 公共组件验收插件0.3.0 + +以现有通用表单公共组件为本轮完整范围:输入、搜索、多行输入、下拉、分段选择、 +复选框、数字步进器,配合标签信息提示、普通/主要按钮与栅格。搜索新增完整 +`LitheSearchField` 入口,内部组合已有原生输入、光标、焦点、图标和清空动作; +其余字段直接复用既有owner,补充literal标题入口,避免宿主误翻译插件提供的文字。 + +info直接使用LitheInfoTip的延迟、固定、关闭、键盘与辅助功能行为,不额外写Tooltip。 +按钮复用LitheActionButtonStyle;只增加内容宽度/填满容器的布局能力与宽度上限, +默认参数保留既有页面的尺寸与颜色,不向插件开放视觉参数。按钮与值字段分开声明, +不会成为State.values或函数参数里的假字段。执行仍使用现有action与provider桥接。 +多行输入保留Enter换行,以表单按钮提交;其他字段保留回车/修改提交的语义。 +Core对信息长度、字段类型、选项、数字范围、按钮引用、跨度及属性适用范围统一校验。 + +本轮验证:63项Swift公共控件/原生表单/栅格/选择器/样式覆盖回归通过(10.335s), +21项Core声明插件测试通过;源码及打包后Node验收通过。 +Core全量初跑有8项历史测试依赖master,而本机系统Git默认main;仅为测试进程 +设置init.defaultBranch=master后,1069项Core单测及Node/Java集成全通过(16.77s单测)。 +Swift桥接校验通过;Java测试显式使用已安装OpenJDK21,避开系统java启动桩。用户当前运行另一按钮分支 +预览,本轮保留其窗口;0.3.0插件需使用本分支新宿主导入,完整实机视觉验收仍待用户完成。 + +### 完整输入入口与0.3.1验收 + +原生表单、IDE设置和其他使用共享输入样式的页面,都通过完整LitheTextField / +LitheTextEditor调用,不再由调用方组合原生输入与样式修饰器。搜索框统一为 +LitheSearchField,附加动作和外部焦点保留;公共标签与字段间距由LitheFormField负责。 +字段和表单按钮仅禁用实际输入/动作,i说明可悬停、点击固定和键盘打开。 +InfoTip同时处理已有页面禁用整个父区块的情形,不把例外补丁留给插件渲染器。 +0.3.1给每种禁用字段和禁用按钮增加独立说明,方便逐项定位验收;协议不新增字段。 +本轮78项macOS回归通过(10.456s),新增完整入口检查373ms、原生输入深浅色 +两例126ms、实际声明式挂载和禁用提示打开131ms。源码和打包后Node验收均通过; +边界、契约、矩阵、笔记和bundle不可变静态检查通过。保留用户正在操作的预览, +0.3.1新插件由用户导入;完整人工视觉/键盘验收仍pending。 + +### 合并#1422后的文字按钮标准 + +声明式动作直接使用preview已合并的LitheActionButtonStyle,不携带本分支原来的 +fillsWidth、maximumWidth或borderInset扩展。公共按钮独立拥有主按钮蓝色及 +hover/按下/禁用状态、28pt高度、72pt最小宽、14pt水平内边距、字体和焦点外观。 +动作组使用公共6pt间距,横排不足时纵排;字段栅格继续拥有字段布局,不拉伸文字按钮。 + +实验包0.3.0/0.3.1的buttons.width与buttons.span兼容读取,但不再影响动作组布局。 +这样已有测试包能继续加载,同时不会把旧绘制规则带回公共按钮。新0.3.2示例省略 +旧布局提示,以普通/主要按钮的短/长文案、禁用、提示及慢动作验证统一样式。 +旧fixture保留fill提示,原生挂载回归验证实际按钮仍为28pt且不会填满栅格。 + +同步preview时保留新版设置分类/插件贡献页面、已安装插件启停分组/筛选,以及 +Git未跟踪目录摘要和可见行缓存;这些界面仍使用完整公共树/列表和输入入口。 + +本次同步后的验证:macOS 323项/56套件通过(10.561s),覆盖公共按钮、实际声明式挂载、输入/树列表、设置、Git及HTML预览;Rust Core全量1106项通过。UI Lab源码及打包后的SDK检查通过。完整产品人工视觉验收仍pending。 + +## 验证 + +- `./scripts/verify-shared-contracts.sh` +- `cargo test --manifest-path rust/lithe-core/Cargo.toml --lib native_ui_validates_version_ownership_actions_and_budgets` +- `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'PluginNativeUI|PluginWeb|WorkbenchContributionHostTests'` +- `LITHE_PLUGIN_SDK_ROOT= node --test macos/Tests/Fixtures/native-ui-lab/native-ui-lab.test.mjs` +- `./scripts/verify-service-boundaries.sh` +- `./scripts/verify-runtime-bundle-immutability.sh` +- `./scripts/verify-platform-feature-matrix.sh` + +## 适用范围 + +- `shared/contracts/plugins/v1/native-ui.schema.json` +- `rust/lithe-core/src/plugins/native_ui.rs` +- `macos/Sources/LitheModuleAPI/Plugins/PluginNativeUI.swift` +- `macos/Sources/Lithe/Application/Features/PluginNativeUIFeature.swift` +- `macos/Sources/Lithe/Views/Workbench/PluginNativeToolWindowView.swift` +- `macos/Tests/Fixtures/native-ui-lab/`(宿主验收 fixture,打包使用外部官方 SDK) + +## 2026-10-23 SDK 迁移后的验收归属 + +遵循 #1442,主仓库不恢复 SDK 及其端到端测试。UI Lab 作为 macOS 宿主的回归与人工验收 fixture 保留在 `macos/Tests/Fixtures/native-ui-lab/`;Core 继续验证其完整清单。打包和 Node 验收显式通过 `LITHE_PLUGIN_SDK_ROOT` 使用外部 lithe-plugin-docs 的官方 Node SDK,复制结果仅进入忽略的 `.artifacts/native-ui-lab/`,不写入已安装包或应用 bundle。插件开发者说明与 SDK 发版继续归 lithe-plugin-docs。 + +## 与 MCP 和 Agent 设置的公共输入入口兼容 + +MCP 命令行的新编辑行为继续由 MCP 功能层解析并校验引号,公共 `LitheTextField(axis: .vertical)` 仅负责允许换行和共享外观,调用方可限制可见行数。默认单行输入及密码框高度保持原规则。Agent 指令编辑器把外部布尔焦点传入完整公共编辑器,使 Command-S 仍对应当前编辑区;不在外层容器重装焦点或旧样式 modifier。新增的技能、指令和 MCP 页面都使用同一搜索、输入和多行编辑入口,避免上游合并后重新出现并行样式。 + +VCS Provider 的变更分组与文件行也通过 `LitheTreeList` 呈现,使用分组/文件两类稳定 ID 映射原有路径选择;包含勾选仍由业务控制,公共入口负责展开、选择、菜单、键盘及默认无障碍激活。新增文件激活回归应验证最终打开文件,而非仅改变选中状态。 + +## 公共树的外部改选与键盘导航 + +选择绑定是调用方发布当前选中的唯一入口。公共控件记录自身发起的选择,只有外部改选才重置键盘游标和范围锚点,避免画面已到新行而 Return 仍操作旧行;Shift 扩选与缩选保留原锚点,并直接处理 AppKit 的范围选择命令。普通不可选标题不进入上下键遍历,可展开分组仍保留键盘焦点和左右键语义。原生挂载回归验证外部 a→c 后 Return/方向键及 Shift 的最终对象,并在真实 Problems 视图检查 A 诊断→Down→Return 最终打开 B 文件的行列。 + +同步 preview 的会话历史时保留排序/行模型缓存和 Equatable 列表边界,公共树仅接管平铺可见行与交互;新建项目复用已抽出的 PluginFieldControls,侧栏与 Settings 共用公共树,不恢复旧样式 modifier。 diff --git a/.agents/notes/implemented/feature/2026-10-22-cargo-build-plugin.md b/.agents/notes/implemented/feature/2026-10-22-cargo-build-plugin.md new file mode 100644 index 0000000000..242986fc8a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-22-cargo-build-plugin.md @@ -0,0 +1,68 @@ +# Agent 笔记:macOS Cargo 基础构建插件 + +状态:已实现 + +## 先说结论 + +Cargo 基础能力在 [独立社区仓库](https://github.com/Lithe-IDEA/lithe-plugin-cargo) 维护并发布,使用现有原生构建窗口,不给 Swift 宿主新增 Cargo 专用分支。 +工程成员、自动目标和 Features 都询问 Cargo 自己;插件只把答案转换为 Lithe 构建模型。 +Cargo 专属测试、清单与模型金样全部由插件仓库维护;其 CI(自动检查流程)调用固定版本 Core 的公开 JSON 接口验证兼容性。 +主仓库保留通用插件契约回归,不构建、下载或发布 Cargo 插件。 + +## 问题 + +已有 Cargo fixture 是验证契约的静态示例,只读取单个清单,不能作为正式的多包 workspace 支持。 +直接扩展自写 TOML 扫描器会重复 Cargo 的成员、继承和自动目标语义;把插件留在宿主仓库,又会让它的发布和 SDK 引用依赖主仓库布局。 + +## 决策 + +- 项目模型由 Cargo 的 metadata 命令提供。Provider(按宿主请求回答工程信息的独立进程)使用 Node SDK,优先使用宿主提供的 Cargo 路径。 +- 模型只接受构建根以内的成员和源码,实际路径经过符号链接解析;打开子包而真实 workspace 根在上层时明确报错。Features 使用 package/feature,同名 binary/example 通过目标标签区分。 +- Build、Check 和 Test 在 workspace 或所选 package 运行;binary 和 example 通过目标任务执行。Clean 不接收 Features 等不兼容参数。基础测试体验保留 Cargo 输出与退出码,不声称已有逐用例图形报告。 +- 插件仓库包含自己的清单、后端、构建脚本、单元/真实 Cargo 集成测试和 CI。Node SDK 与测试辅助代码按上游 Git 提交及逐文件 SHA-256 固定在该仓库的锁文件中,打包前必须校验;无需相邻主仓库或运行时下载。 +- 安装包放插件仓库的 GitHub Releases,插件市场索引放 Lithe Store。0.1.0 以预发布形式分发,原生界面验收状态保持 pending,不自动作为稳定版本推广。 +- 插件 CI 按 `sdk.lock.json` 的同一宿主提交检出独立 Core 源码,通过 `execute_json`(宿主公开的 JSON 命令接口)执行清单、安装审查、能力拒绝、识别、模型解码、任务和测试计划验证。桥接程序只转发公开接口,不复制 Core 实现或重新实现模板展开。 +- Cargo 专属断言、发布清单和真实 metadata 金样留在插件仓库。集成测试把真实 Provider 回复交给 Core 解码,再执行 Core 根据实际清单生成的命令,并验证编译错误的诊断解码;主仓库不保留另一份社区 Cargo 快照。 + +正确做法:插件 CI 用已固定的真实 Core 和当前插件清单生成 binary/example 参数,再在临时 Cargo workspace 执行。 +不要这样做:在主仓库测试里直接引用隔壁插件 checkout,或在插件测试里手写一套命令代替宿主计划。 + +### 文件生命周期与只读边界 + +插件仓库自己持有 dist 安装包、临时打包 staging 和测试报告,不作为主仓库的可复用下载缓存。 +兼容性验证的宿主源码在插件 checkout 的 `.artifacts/lithe-core`,可变编译结果在 `.artifacts/core-target`;首次构建按宿主 Cargo.lock 下载依赖,测试工程本身离线执行。 +准备脚本拒绝来源、提交或已跟踪文件被改写的 checkout,用 `--locked` 构建,并记录桥接源码和二进制哈希;这些目录不跨工作树共享、不进入插件 ZIP。 +SDK 复制只发生在打包阶段,staging 在成功或失败后清理;发布 ZIP 和校验文件按版本保持不可变。 +运行时后端不保存额外缓存,不写插件安装目录或 app bundle。Cargo 自己的用户缓存、Cargo.lock 和 target 仍放在用户配置或工作区指定的位置。 +这不会改变应用代码签名和 Sparkle 增量更新基线;独立插件集成测试对执行前后插件完整文件清单与哈希作比较。 + +## 考虑过的备选方案 + +- 直接把静态 fixture 改名发行:依赖少,但不能正确描述常见 workspace 和自动目标,未采用。 +- 把插件留在主仓库并引用主仓库 SDK、测试工具:初期验证方便,但独立 clone 无法构建和测试,发布仍依赖宿主源码,未采用。 +- 在共享 Core 增加专用 Cargo 模型解析器:让插件工具知识重新进入宿主,重复上游事实,未采用。 +- 在主仓库保存社区 Cargo 快照和专用兼容性测试:能随 Core 修改及时发现回归,但会让独立插件维护同时修改宿主仓库;本次改为插件 CI 主动调用固定 Core,主仓库继续持有通用插件契约测试。 +- 只删主仓库测试并依赖现有手写 Cargo 命令:不能验证清单经宿主展开后的参数是否有效,会丢失覆盖,未采用。 +- 附带 Rust 编译的 Provider:无需额外 Node,但需维护两种 macOS 架构产物、签名和构建链;基础版复用现成零依赖 Node SDK。 + +## 后果 + +插件可以独立测试、打包和发布,主仓库 CI 无需下载插件或编译其真实 Cargo fixture。 +代价是加载模型需要 Node 20.3+ 和 Cargo 1.70+;插件 CI 还要编译 Core,首次验证需要下载源码和 Rust 依赖。 +固定宿主提交让结果可复验,但不会自动发现未来 preview 的回归;升级支持基线时必须主动更新锁文件并运行完整插件 CI。 +当前 outermost 识别不会单独列出外层 Cargo 根下的独立嵌套 workspace;基础范围没有依赖树、调试器和逐用例图形报告,Windows 尚未接入构建插件宿主。 + +## 验证 + +- 独立插件 CI:`node scripts/prepare-core.mjs` 构建固定宿主公开接口桥接;`LITHE_CARGO_INTEGRATION=1 node scripts/test.mjs` 执行 SDK 哈希、Node 单元、包校验、3 项 Core 兼容性回归及真实 Cargo 双包 workspace。 +- 集成验证覆盖真实模型解码、Core 生成的 Build/Check/Test/Clean 与 binary/example 运行、选项与精确用例选择、失败诊断、后端退出及包文件字节不变;插件仓库上传逐测试 HTML、JUnit 与日志。 +- 主仓库现有 `build_systems` 和 `community_build_plugins` 回归继续验证通用宿主行为,不依赖社区 Cargo 发布清单或外部 checkout。 +- `./scripts/verify-runtime-bundle-immutability.sh`、`./scripts/verify-platform-feature-matrix.sh`、`./scripts/verify-agent-notes.sh`。 +- 原生磁盘安装、窗口交互与进程关闭的验收状态由 cargo-build-plugin 功能记录单独维护。 + +## 适用范围 + +- [Cargo 插件仓库](https://github.com/Lithe-IDEA/lithe-plugin-cargo) 的 `test/`、`scripts/`、`sdk.lock.json` 与 `.github/workflows/` +- `Plugins/mac/README.md` +- `docs/ci-builds.md` +- `shared/platform-feature-matrix/features/cargo-build-plugin.json` diff --git a/.agents/notes/implemented/feature/2026-10-22-html-preview-embedded-browser.md b/.agents/notes/implemented/feature/2026-10-22-html-preview-embedded-browser.md new file mode 100644 index 0000000000..5a99219412 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-22-html-preview-embedded-browser.md @@ -0,0 +1,292 @@ +# Agent 笔记:macOS HTML 预览是一个内嵌的单标签浏览器 + +状态:已实现 + +## 先说结论 + +macOS 的 HTML 预览不再是“把 HTML 字符串塞进 WebView、点什么都取消”的静态画面,而是一个内嵌的单标签浏览器:有后退、前进、刷新/停止、可输入的地址栏、页面缩放(− / 百分比 / +)、常驻滚动条、系统浏览器按钮和开发者工具;页面里的链接、锚点、`alert`/`confirm`/`prompt`、文件选择、下载和 `window.open` 都按浏览器的方式工作;此外还有页内查找、右键菜单、打印 / 导出 PDF,以及页面申请摄像头 / 麦克风时的确认弹窗和地址栏右侧的“设备使用中”指示器。功能范围对标 VS Code 的 Simple Browser,多标签、书签、历史以后做成插件,不要加在这里。当前编辑的文档来自编辑器缓冲区(所以没保存的修改也能看到),其余资源按只读的静态网站从磁盘提供。开发者以后要改预览行为,改 `HTMLPreviewBrowserController` 和它旁边的纯规则文件,不要回到 `loadHTMLString` 加“取消全部导航”的做法。页面(或其子视图)有焦点时,⌘F / ⌘G / ⇧⌘G、⌘P 和缩放快捷键归页面,应用键位监听器必须让路,否则默认键位会先把 ⌘F 交给编辑器(见“页面快捷键与应用键位的先后”)。摄像头 / 麦克风这部分代码已经就位,但目前在运行的应用里是休眠的(Info.plist 没有用途说明,页面里没有 `navigator.mediaDevices`);2026-10-22 决定继续保持休眠,因为 Lithe 启动的子孙进程会继承摄像头授权,不经过这里的任何弹窗,原因和以后启用的前提见“补用途说明之前必须知道的事”。 + +## 问题 + +旧实现用 `loadHTMLString(_:baseURL:)` 加载文本,并在 `WKNavigationDelegate` 里取消所有链接点击,没有设置 `WKUIDelegate`。使用者看到的是“只能看不能用”: + +- 点击链接、表单提交、`#锚点` 全部被取消;`target="_blank"`、`window.open` 没有任何反应。 +- `alert`、`confirm`、`prompt` 和 `` 在没有 UI 委托时被 WebKit 直接当作“取消”,页面脚本拿到的是假结果。 +- 页面实际在 `file://` 或 `about:blank` 来源下运行:`") + + #expect(await harness.waitForTitle("ready:dep:7:lithe-html::true"), "Title was \(harness.webView.title ?? "nil")") + #expect(harness.controller.address == harness.fixture.url("site/page.html").absoluteString) + } + + @Test func pagesCannotFetchHiddenFilesOrEscapeTheRoot() async throws { + let harness = try BrowserHarness(files: [ + "site/page.html": "disk", + ".env": "TOKEN=secret" + ]) + defer { harness.tearDown() } + + harness.show(""" + + """) + + #expect(await harness.waitForTitle("codes:403,404,200"), "Title was \(harness.webView.title ?? "nil")") + } + + @Test func javaScriptDialogsAreAnsweredByTheDialogPresenter() async throws { + let harness = try BrowserHarness(files: ["site/page.html": "disk"]) + defer { harness.tearDown() } + harness.dialogs.confirmAnswer = true + harness.dialogs.promptAnswer = "Ada" + + harness.show(""" + + """) + + #expect(await harness.waitForTitle("done:true:Ada"), "Title was \(harness.webView.title ?? "nil")") + #expect(harness.dialogs.alerts == ["hello"]) + #expect(harness.dialogs.confirms == ["sure?"]) + #expect(harness.dialogs.prompts.map(\.message) == ["name?"]) + #expect(harness.dialogs.prompts.map(\.defaultText) == ["guest"]) + // A dialog is attributed to the file that raised it. + #expect(harness.dialogs.sources == ["page.html"]) + } + + @Test func cancellingDialogsReachesThePage() async throws { + let harness = try BrowserHarness(files: ["site/page.html": "disk"]) + defer { harness.tearDown() } + harness.dialogs.confirmAnswer = false + harness.dialogs.promptAnswer = nil + + harness.show("") + + #expect(await harness.waitForTitle("false:null"), "Title was \(harness.webView.title ?? "nil")") + } + + @Test func anchorLinksScrollWithoutLeavingThePage() async throws { + let harness = try BrowserHarness(files: ["site/page.html": "disk"]) + defer { harness.tearDown() } + + harness.show(""" + anchors + go +
+

bottom

+ """) + #expect(await harness.waitForTitle("anchors")) + _ = try await harness.evaluate("document.getElementById('go').click()") + + #expect(await harness.wait { harness.controller.address.hasSuffix("page.html#bottom") }) + let scrolled = try await harness.evaluate("window.scrollY") as? Double ?? 0 + #expect(scrolled > 1_000, "Expected the page to scroll to the anchor, scrollY was \(scrolled)") + #expect(harness.controller.canGoBack) + } + + @Test func linksNavigateInPlaceAndHistoryWorks() async throws { + let harness = try BrowserHarness(files: [ + "site/page.html": "disk", + "site/other.html": "other

other page

" + ]) + defer { harness.tearDown() } + + harness.show("firstnext") + #expect(await harness.waitForTitle("first")) + #expect(!harness.controller.canGoBack) + + _ = try await harness.evaluate("document.getElementById('next').click()") + #expect(await harness.waitForTitle("other"), "Title was \(harness.webView.title ?? "nil")") + #expect(harness.controller.address == harness.fixture.url("site/other.html").absoluteString) + #expect(harness.controller.canGoBack) + #expect(!harness.controller.canGoForward) + + harness.controller.goBack() + #expect(await harness.waitForTitle("first")) + #expect(await harness.wait { harness.controller.canGoForward }) + + harness.controller.goForward() + #expect(await harness.waitForTitle("other")) + } + + @Test func reloadingAfterAnEditKeepsTheScrollPosition() async throws { + let harness = try BrowserHarness(files: ["site/page.html": "disk"]) + defer { harness.tearDown() } + // The position is restored while the new document loads, so the edited + // page reports it from a scroll event instead of the test guessing when. + func page(_ title: String, reportsScroll: Bool = false) -> String { + let report = reportsScroll + ? "" + : "" + return "\(title)\(report)
" + } + + harness.show(page("v1")) + #expect(await harness.waitForTitle("v1")) + _ = try await harness.evaluate("window.scrollTo(0, 900)") + #expect(try await harness.evaluate("window.scrollY") as? Double == 900) + + harness.show(page("v2", reportsScroll: true)) + #expect( + await harness.waitForTitle("scrolled:900"), + "A buffer edit should not reset the scroll position, title was \(harness.webView.title ?? "nil")" + ) + } + + @Test func editsDoNotYankTheUserOffAnotherPage() async throws { + let harness = try BrowserHarness(files: [ + "site/page.html": "disk", + "site/other.html": "other" + ]) + defer { harness.tearDown() } + + harness.show("first") + #expect(await harness.waitForTitle("first")) + #expect(harness.controller.navigate(toAddress: harness.fixture.url("site/other.html").path)) + #expect(await harness.waitForTitle("other")) + + harness.show("edited") + // The edit is served the next time the document is opened. + #expect(harness.controller.navigate(toAddress: harness.fixture.url("site/page.html").path)) + #expect(await harness.waitForTitle("edited"), "Title was \(harness.webView.title ?? "nil")") + } + + @Test func invalidAddressesAreReportedAndValidOnesLoad() async throws { + let harness = try BrowserHarness(files: ["site/page.html": "disk"]) + defer { harness.tearDown() } + harness.show("first") + #expect(await harness.waitForTitle("first")) + + #expect(!harness.controller.navigate(toAddress: "not an address")) + #expect(harness.controller.loadError != nil) + #expect(!harness.controller.navigate(toAddress: "/etc/hosts")) + + #expect(harness.controller.navigate(toAddress: harness.fixture.url("site/page.html").path)) + #expect(harness.controller.loadError == nil) + } + + @Test func localPagesCanBeOpenedInTheSystemBrowserAsFiles() async throws { + let harness = try BrowserHarness(files: ["site/page.html": "disk"]) + defer { harness.tearDown() } + harness.show("first") + #expect(await harness.waitForTitle("first")) + + #expect(harness.controller.externalOpenTarget == .localFile(harness.fixture.url("site/page.html"))) + } + + @Test func zoomResizesThePageAndSurvivesNavigation() async throws { + let harness = try BrowserHarness(files: [ + "site/page.html": "disk", + "site/other.html": "other" + ]) + defer { harness.tearDown() } + harness.show("first") + #expect(await harness.waitForTitle("first")) + #expect(harness.controller.zoomFactor == 1) + let unzoomedWidth = try await harness.evaluate("window.innerWidth") as? Double ?? 0 + #expect(unzoomedWidth > 0) + + // 100% -> 110% -> 125% -> 150% -> 175% -> 200% + for _ in 0..<5 { harness.controller.zoomIn() } + #expect(harness.controller.zoomFactor == 2) + #expect(harness.webView.pageZoom == 2) + let zoomedWidth = try await harness.evaluate("window.innerWidth") as? Double ?? 0 + #expect(abs(zoomedWidth * 2 - unzoomedWidth) <= 2, "At 200% the page should lay out in half the width: \(zoomedWidth) vs \(unzoomedWidth)") + + // Like a browser tab, the zoom stays when the user goes to another page and back. + #expect(harness.controller.navigate(toAddress: harness.fixture.url("site/other.html").path)) + #expect(await harness.waitForTitle("other")) + #expect(harness.controller.zoomFactor == 2) + #expect(harness.webView.pageZoom == 2) + + harness.controller.resetZoom() + #expect(harness.controller.zoomFactor == 1) + #expect(harness.webView.pageZoom == 1) + } + + @Test func zoomStopsAtTheLimitsAndResetsFromAnywhere() throws { + let harness = try BrowserHarness(files: ["site/page.html": "disk"]) + defer { harness.tearDown() } + + while harness.controller.canZoomOut { harness.controller.zoomOut() } + #expect(harness.controller.zoomFactor == HTMLPreviewZoom.levels.first) + harness.controller.zoomOut() + #expect(harness.controller.zoomFactor == HTMLPreviewZoom.levels.first) + + while harness.controller.canZoomIn { harness.controller.zoomIn() } + #expect(harness.controller.zoomFactor == HTMLPreviewZoom.levels.last) + let appliedZoom = Double(harness.webView.pageZoom) + #expect(abs(appliedZoom - HTMLPreviewZoom.levels[HTMLPreviewZoom.levels.count - 1]) < 0.001, "pageZoom was \(appliedZoom)") + + harness.controller.resetZoom() + #expect(harness.controller.zoomFactor == 1) + #expect(harness.controller.canZoomIn && harness.controller.canZoomOut) + } + + /// How much of the viewport the page's scroll bars take, in CSS pixels. + /// Pages need a doctype: in quirks mode `clientHeight` of the root is the content height. + private func scrollbarSize(_ harness: BrowserHarness) async throws -> (vertical: Double, horizontal: Double) { + let values = try await harness.evaluate( + "[innerWidth - document.documentElement.clientWidth, innerHeight - document.documentElement.clientHeight]" + ) as? [Double] ?? [] + try #require(values.count == 2) + return (values[0], values[1]) + } + + @Test func overflowingPagesShowScrollBarsAtBothEdges() async throws { + let harness = try BrowserHarness(files: [ + "site/page.html": "disk", + "site/other.html": "other
" + ]) + defer { harness.tearDown() } + + harness.show("big
") + #expect(await harness.waitForTitle("big")) + var size = try await scrollbarSize(harness) + #expect(abs(size.vertical - HTMLPreviewScrollbars.thickness) <= 1, "Vertical bar was \(size.vertical)") + #expect(abs(size.horizontal - HTMLPreviewScrollbars.thickness) <= 1, "Horizontal bar was \(size.horizontal)") + + // A page that fits has nothing to scroll and shows no bars. + harness.show("small

short

") + #expect(await harness.waitForTitle("small")) + size = try await scrollbarSize(harness) + #expect(size.vertical == 0 && size.horizontal == 0, "Unexpected bars \(size)") + } + + @Test func scrollBarsKeepTheirSizeOnScreenWhenThePageIsZoomed() async throws { + let harness = try BrowserHarness(files: [ + "site/page.html": "disk", + "site/other.html": "other
" + ]) + defer { harness.tearDown() } + harness.show("big
") + #expect(await harness.waitForTitle("big")) + + // At 200% a CSS pixel is two points, so the same 14-point bar is 7 CSS pixels. + for _ in 0..<5 { harness.controller.zoomIn() } + #expect(harness.controller.zoomFactor == 2) + var size = try await scrollbarSize(harness) + #expect(abs(size.vertical - HTMLPreviewScrollbars.thickness / 2) <= 1, "Vertical bar was \(size.vertical)") + #expect(abs(size.horizontal - HTMLPreviewScrollbars.thickness / 2) <= 1, "Horizontal bar was \(size.horizontal)") + + // The next page is styled before it is parsed, at the zoom in force then. + #expect(harness.controller.navigate(toAddress: harness.fixture.url("site/other.html").path)) + #expect(await harness.waitForTitle("other")) + size = try await scrollbarSize(harness) + #expect(abs(size.vertical - HTMLPreviewScrollbars.thickness / 2) <= 1, "Vertical bar was \(size.vertical)") + + harness.controller.resetZoom() + size = try await scrollbarSize(harness) + #expect(abs(size.horizontal - HTMLPreviewScrollbars.thickness) <= 1, "Horizontal bar was \(size.horizontal)") + } + + /// The width of the scroll bar inside the page's frame, in the frame's CSS + /// pixels, once the frame has loaded. A frame is restyled by a message to its + /// own process, which the web process handles before the script run here. + private func frameScrollbar(_ harness: BrowserHarness) async throws -> Double { + let width = try await harness.evaluateAsync( + """ + await frameReady; + const frame = document.getElementById('f').contentWindow; + return frame.innerWidth - frame.document.documentElement.clientWidth; + """ + ) as? Double + return try #require(width) + } + + @Test func scrollBarsInFramesKeepTheirSizeOnScreenWhenThePageIsZoomed() async throws { + let harness = try BrowserHarness(files: [ + "site/page.html": "disk", + "site/inner.html": "inner
" + ]) + defer { harness.tearDown() } + harness.show(""" + host + + + """) + #expect(await harness.waitForTitle("host")) + var width = try await frameScrollbar(harness) + #expect(abs(width - HTMLPreviewScrollbars.thickness) <= 1, "Frame bar was \(width)") + + // At 200% the frame's bar must become 7 CSS pixels like the page's own. + for _ in 0..<5 { harness.controller.zoomIn() } + #expect(harness.controller.zoomFactor == 2) + width = try await frameScrollbar(harness) + #expect(abs(width - HTMLPreviewScrollbars.thickness / 2) <= 1, "Frame bar was \(width)") + + harness.controller.resetZoom() + width = try await frameScrollbar(harness) + #expect(abs(width - HTMLPreviewScrollbars.thickness) <= 1, "Frame bar was \(width)") + } + + @Test func aPageStylingItsOwnScrollBarsKeepsThem() async throws { + let harness = try BrowserHarness(files: ["site/page.html": "disk"]) + defer { harness.tearDown() } + + harness.show(""" + styled + +
+ """) + #expect(await harness.waitForTitle("styled")) + let size = try await scrollbarSize(harness) + #expect(abs(size.vertical - 20) <= 1, "The page's own bar should win, vertical bar was \(size.vertical)") + } + + @Test func switchingToAnotherDocumentShowsThatDocument() async throws { + let harness = try BrowserHarness(files: ["site/a.html": "disk a", "site/b.html": "disk b"]) + defer { harness.tearDown() } + harness.show("a", document: "site/a.html") + #expect(await harness.waitForTitle("a")) + + // Two updates in one pass, as when a tab switch is followed by the new + // document's text: reloading the page on screen would bring back document a. + harness.show("stale", document: "site/b.html") + harness.show("b", document: "site/b.html") + + #expect(await harness.waitForTitle("b"), "Title was \(harness.webView.title ?? "nil")") + #expect(harness.controller.address == harness.fixture.url("site/b.html").absoluteString) + } + + @Test func newWindowLinksOpenInPlace() async throws { + let harness = try BrowserHarness(files: [ + "site/page.html": "disk", + "site/other.html": "other" + ]) + defer { harness.tearDown() } + harness.show("firstgo") + #expect(await harness.waitForTitle("first")) + + _ = try await harness.evaluate("document.getElementById('go').click()") + + #expect(await harness.waitForTitle("other"), "Title was \(harness.webView.title ?? "nil")") + #expect(harness.controller.canGoBack) + } + + @Test func linkedFilesTheViewCannotShowAreOfferedForSaving() async throws { + let harness = try BrowserHarness(files: [ + "site/page.html": "disk", + "site/archive.zip": "PK-bytes" + ]) + defer { harness.tearDown() } + harness.show("firstget") + #expect(await harness.waitForTitle("first")) + + _ = try await harness.evaluate("document.getElementById('get').click()") + + #expect(await harness.wait { harness.dialogs.downloadNames == ["archive.zip"] }, "Saved: \(harness.dialogs.downloadNames)") + // The page the link was on stays, and so does its address. + let pageAddress = harness.fixture.url("site/page.html").absoluteString + #expect(await harness.wait { harness.controller.address == pageAddress }, "Address was \(harness.controller.address)") + } + + @Test func downloadAttributeOffersTheLinkedFileForSaving() async throws { + let harness = try BrowserHarness(files: ["site/page.html": "disk", "site/data.txt": "plain"]) + defer { harness.tearDown() } + harness.show("firstget") + #expect(await harness.waitForTitle("first")) + + _ = try await harness.evaluate("document.getElementById('get').click()") + + #expect(await harness.wait { harness.dialogs.downloadNames == ["data.txt"] }, "Saved: \(harness.dialogs.downloadNames)") + } + + @Test func aDirectoryAddressLoadsItsIndexWithRelativeLinksIntact() async throws { + let harness = try BrowserHarness(files: [ + "site/page.html": "disk", + "docs/index.html": "docs", + "docs/app.js": "document.title = 'docs:script-loaded';" + ]) + defer { harness.tearDown() } + harness.show("first") + #expect(await harness.waitForTitle("first")) + + #expect(harness.controller.navigate(toAddress: harness.fixture.url("docs").path)) + + // Without the folder's trailing slash `app.js` would be requested from the root. + #expect(await harness.waitForTitle("docs:script-loaded"), "Title was \(harness.webView.title ?? "nil")") + } + + @Test func aLinkToAFolderWithoutASlashLoadsItsIndexWithRelativeLinksIntact() async throws { + let harness = try BrowserHarness(files: [ + "site/page.html": "disk", + "docs/index.html": "docs", + "docs/app.js": "document.title = 'docs:script-loaded';" + ]) + defer { harness.tearDown() } + harness.show("firstdocs") + #expect(await harness.waitForTitle("first")) + + _ = try await harness.evaluate("document.getElementById('go').click()") + + #expect(await harness.waitForTitle("docs:script-loaded"), "Title was \(harness.webView.title ?? "nil")") + #expect(harness.controller.address.hasSuffix("/workspace/docs/"), "Address was \(harness.controller.address)") + // The redirect replaces the link's navigation: one step back is the page it was on. + #expect(harness.controller.canGoBack) + harness.controller.goBack() + #expect(await harness.waitForTitle("first"), "Title was \(harness.webView.title ?? "nil")") + } + + @Test func aFrameShowingAFolderWithoutASlashLoadsItsIndexWithRelativeLinksIntact() async throws { + let harness = try BrowserHarness(files: [ + "site/page.html": "disk", + "docs/index.html": "docs", + "docs/app.js": "document.documentElement.dataset.loaded = 'yes';" + ]) + defer { harness.tearDown() } + harness.show(""" + host + + + """) + #expect(await harness.waitForTitle("host")) + + // The frame is pointed at /docs/ by a script, so it loads a second time. + let loaded = try await harness.evaluateAsync( + """ + await frameReady; + const frame = document.getElementById('f').contentWindow; + return [frame.location.pathname, frame.document.title, frame.document.documentElement.dataset.loaded]; + """ + ) as? [String] + #expect(loaded == ["/docs/", "docs", "yes"], "Frame was \(String(describing: loaded))") + } + + @Test func developerToolsAreOfferedByTheSystemWebKit() throws { + let harness = try BrowserHarness(files: ["site/page.html": "disk"]) + defer { harness.tearDown() } + + // The inspector is private WebKit API; this fails if a system update removes it. + #expect(harness.controller.isDeveloperToolsAvailable) + #expect(!harness.controller.isDeveloperToolsVisible) + } + + // MARK: - WebAssembly + + @Test func wasmModulesLoadThroughFetch() async throws { + let harness = try BrowserHarness(files: ["site/page.html": "disk"]) + defer { harness.tearDown() } + try harness.fixture.write(HTMLPreviewFixture.addModule, to: "assets/add.wasm") + + harness.show(""" + + """) + + let expected = "fetch:200:application/wasm:\(HTMLPreviewFixture.addModule.count):5" + #expect(await harness.waitForTitle(expected), "Title was \(harness.webView.title ?? "nil")") + } + + @Test func wasmModulesCompileWhileStreamingFromRootRelativeAndRelativeAddresses() async throws { + let harness = try BrowserHarness(files: ["site/page.html": "disk"]) + defer { harness.tearDown() } + try harness.fixture.write(HTMLPreviewFixture.addModule, to: "assets/add.wasm") + + harness.show(""" + + """) + + #expect(await harness.waitForTitle("streaming:42,42"), "Title was \(harness.webView.title ?? "nil")") + } + + @Test func streamingCompilationNeedsTheWasmMIMEType() async throws { + let harness = try BrowserHarness(files: ["site/page.html": "disk"]) + defer { harness.tearDown() } + // The same bytes under an extension that is not served as application/wasm. + try harness.fixture.write(HTMLPreviewFixture.addModule, to: "assets/add.bin") + + harness.show(""" + + """) + + #expect(await harness.waitForTitle("TypeError:true:2"), "Title was \(harness.webView.title ?? "nil")") + } +} diff --git a/macos/Tests/LitheTests/HTMLPreviewFindTests.swift b/macos/Tests/LitheTests/HTMLPreviewFindTests.swift new file mode 100644 index 0000000000..6819e1adf0 --- /dev/null +++ b/macos/Tests/LitheTests/HTMLPreviewFindTests.swift @@ -0,0 +1,187 @@ +import AppKit +import Testing +import WebKit +@testable import Lithe + +struct HTMLPreviewFindShortcutTests { + @Test func commandShortcutsMatchBrowsers() { + #expect(HTMLPreviewFind.command(forKey: "f", modifiers: [.command]) == .show) + #expect(HTMLPreviewFind.command(forKey: "g", modifiers: [.command]) == .next) + #expect(HTMLPreviewFind.command(forKey: "G", modifiers: [.command, .shift]) == .previous) + #expect(HTMLPreviewFind.command(forKey: "g", modifiers: [.command, .shift]) == .previous) + // Caps Lock upper-cases the letter without changing the shortcut. + #expect(HTMLPreviewFind.command(forKey: "F", modifiers: [.command, .capsLock]) == .show) + } + + @Test func otherKeyCombinationsAreLeftToTheirOwners() { + #expect(HTMLPreviewFind.command(forKey: "f", modifiers: []) == nil) + #expect(HTMLPreviewFind.command(forKey: "f", modifiers: [.command, .shift]) == nil) + #expect(HTMLPreviewFind.command(forKey: "f", modifiers: [.command, .option]) == nil) + #expect(HTMLPreviewFind.command(forKey: "g", modifiers: [.command, .control]) == nil) + #expect(HTMLPreviewFind.command(forKey: "g", modifiers: [.control]) == nil) + #expect(HTMLPreviewFind.command(forKey: "h", modifiers: [.command]) == nil) + #expect(HTMLPreviewFind.command(forKey: "", modifiers: [.command]) == nil) + } +} + +@MainActor +struct HTMLPreviewFindTests { + private static let page = "ready

alpha beta alpha

" + + private func makeHarness() async throws -> BrowserHarness { + let harness = try BrowserHarness(files: ["site/page.html": "disk", "site/other.html": "other

alpha

"]) + harness.show(Self.page) + #expect(await harness.waitForTitle("ready"), "Title was \(harness.webView.title ?? "nil")") + return harness + } + + /// The text WebKit selected for the match and where it starts in its paragraph. + private func selection(in harness: BrowserHarness) async throws -> (text: String, offset: Int) { + let values = try #require(try await harness.evaluate("[String(getSelection()), getSelection().anchorOffset]") as? [Any]) + return (values[0] as? String ?? "", (values[1] as? NSNumber)?.intValue ?? -1) + } + + /// Runs `action` and waits for WebKit to answer the search it started. + private func searching(_ find: HTMLPreviewFindModel, in harness: BrowserHarness, _ action: () -> Void) async { + let before = find.completedSearches + action() + #expect(await harness.wait { find.completedSearches > before }, "The search was never answered") + } + + @Test func shortcutsAreOnlyAnsweredWhileThePageHasFocus() async throws { + let harness = try BrowserHarness(files: ["site/page.html": "disk"]) + defer { harness.tearDown() } + let surface = try #require(harness.webView as? HTMLPreviewWebViewSurface) + var finds: [HTMLPreviewFind.Command] = [] + var prints: [HTMLPreviewPrint.Command] = [] + surface.onFindCommand = { finds.append($0) } + surface.onPrintCommand = { prints.append($0) } + + // In the app the editor sits beside the preview and owns these shortcuts. + let elsewhere = NSTextField(frame: NSRect(x: 0, y: 0, width: 100, height: 20)) + harness.window.contentView?.addSubview(elsewhere) + #expect(harness.window.makeFirstResponder(elsewhere)) + _ = surface.performKeyEquivalent(with: harness.keyEvent("f", modifiers: .command)) + _ = surface.performKeyEquivalent(with: harness.keyEvent("p", modifiers: .command)) + #expect(finds.isEmpty) + #expect(prints.isEmpty) + + #expect(harness.window.makeFirstResponder(surface)) + #expect(surface.performKeyEquivalent(with: harness.keyEvent("f", modifiers: .command))) + #expect(surface.performKeyEquivalent(with: harness.keyEvent("g", modifiers: .command))) + #expect(surface.performKeyEquivalent(with: harness.keyEvent("G", modifiers: [.command, .shift]))) + #expect(surface.performKeyEquivalent(with: harness.keyEvent("p", modifiers: .command))) + #expect(finds == [.show, .next, .previous]) + #expect(prints == [.print]) + + _ = surface.performKeyEquivalent(with: harness.keyEvent("f", modifiers: [.command, .option])) + #expect(finds.count == 3) + } + + @Test func findSelectsMatchesAndStepsThroughThemWithWrapping() async throws { + let harness = try await makeHarness() + defer { harness.tearDown() } + let find = harness.controller.find + + find.handle(.show) + #expect(find.isVisible) + await searching(find, in: harness) { find.query = "alpha" } + #expect(find.status == .found) + #expect(try await selection(in: harness) == ("alpha", 0)) + + await searching(find, in: harness) { find.step(backwards: false) } + #expect(try await selection(in: harness) == ("alpha", 11)) + await searching(find, in: harness) { find.step(backwards: false) } + #expect(try await selection(in: harness) == ("alpha", 0)) + await searching(find, in: harness) { find.step(backwards: true) } + #expect(try await selection(in: harness) == ("alpha", 11)) + } + + @Test func typingMoreLettersKeepsTheMatchWhereItIs() async throws { + let harness = try await makeHarness() + defer { harness.tearDown() } + let find = harness.controller.find + find.handle(.show) + + for text in ["a", "al", "alp", "alpha b"] { + await searching(find, in: harness) { find.query = text } + #expect(try await selection(in: harness) == (text, 0), "Typing \(text) moved the match") + } + } + + @Test func findReportsMissingTextAndEmptyingTheFieldClearsTheResult() async throws { + let harness = try await makeHarness() + defer { harness.tearDown() } + let find = harness.controller.find + find.handle(.show) + + await searching(find, in: harness) { find.query = "zzz" } + #expect(find.status == .notFound) + await searching(find, in: harness) { find.query = "beta" } + #expect(find.status == .found) + find.query = "" + #expect(find.status == .idle) + } + + @Test func aSearchSupersededWhileItWasPendingDoesNotWinTheRace() async throws { + let harness = try await makeHarness() + defer { harness.tearDown() } + let find = harness.controller.find + find.handle(.show) + + find.query = "zzz" + await searching(find, in: harness) { find.query = "beta" } + #expect(find.status == .found) + #expect(try await selection(in: harness) == ("beta", 6)) + } + + @Test func closingTheBarHidesItAndReopeningFindsTheTextAgain() async throws { + let harness = try await makeHarness() + defer { harness.tearDown() } + let find = harness.controller.find + find.handle(.show) + await searching(find, in: harness) { find.query = "beta" } + + find.hide() + #expect(!find.isVisible) + #expect(find.status == .idle) + // The match stays selected, so the next keystroke or copy acts on it. + #expect(try await selection(in: harness) == ("beta", 6)) + + await searching(find, in: harness) { find.handle(.show) } + #expect(find.isVisible) + #expect(find.status == .found) + #expect(find.query == "beta") + } + + @Test func findNextOpensTheBarAndSearchesOnlyWhenThereIsTextToFind() async throws { + let harness = try await makeHarness() + defer { harness.tearDown() } + let find = harness.controller.find + + find.handle(.next) + #expect(find.isVisible) + #expect(find.completedSearches == 0) + + await searching(find, in: harness) { find.query = "alpha" } + find.hide() + await searching(find, in: harness) { find.handle(.previous) } + #expect(find.isVisible) + #expect(find.status == .found) + } + + @Test func aNewPageClearsTheResultButKeepsTheBarAndItsText() async throws { + let harness = try await makeHarness() + defer { harness.tearDown() } + let find = harness.controller.find + find.handle(.show) + await searching(find, in: harness) { find.query = "alpha" } + #expect(find.status == .found) + + #expect(harness.controller.navigate(toAddress: harness.fixture.url("site/other.html").path)) + #expect(await harness.waitForTitle("other")) + #expect(find.status == .idle) + #expect(find.isVisible) + #expect(find.query == "alpha") + } +} diff --git a/macos/Tests/LitheTests/HTMLPreviewMediaCaptureTests.swift b/macos/Tests/LitheTests/HTMLPreviewMediaCaptureTests.swift new file mode 100644 index 0000000000..678941129d --- /dev/null +++ b/macos/Tests/LitheTests/HTMLPreviewMediaCaptureTests.swift @@ -0,0 +1,196 @@ +import AppKit +import Testing +import WebKit +@testable import Lithe + +struct HTMLPreviewCaptureStateTests { + @Test func captureStatesMapToTheIndicatorStates() { + #expect(HTMLPreviewCaptureState(.none) == .inactive) + #expect(HTMLPreviewCaptureState(.active) == .active) + #expect(HTMLPreviewCaptureState(.muted) == .muted) + } +} + +@MainActor +struct HTMLPreviewMediaCaptureFlowTests { + private func makeController( + _ dialogs: RecordingDialogs, + usage: [String: Any]? = MockCapture.usageDescriptions + ) -> HTMLPreviewBrowserController { + HTMLPreviewBrowserController(dialogs: dialogs, mediaUsageDescriptions: { usage }) + } + + @Test func anAnswerOfYesGrantsAfterExactlyOneQuestionNamingThePage() { + let dialogs = RecordingDialogs() + let controller = makeController(dialogs) + var decisions: [WKPermissionDecision] = [] + + controller.requestMediaCapture(devices: [.camera, .microphone], source: "demo.html") { decisions.append($0) } + + #expect(decisions == [.grant]) + #expect(dialogs.mediaCaptureRequests.count == 1) + #expect(dialogs.mediaCaptureRequests.first?.devices == [.camera, .microphone]) + #expect(dialogs.mediaCaptureRequests.first?.requester == .page(name: "demo.html")) + #expect(dialogs.alerts.isEmpty) + } + + @Test func anAnswerOfNoDeniesTheRequest() { + let dialogs = RecordingDialogs() + dialogs.mediaCaptureAnswer = false + let controller = makeController(dialogs) + var decisions: [WKPermissionDecision] = [] + + controller.requestMediaCapture(devices: .microphone, source: "demo.html") { decisions.append($0) } + + #expect(decisions == [.deny]) + #expect(dialogs.mediaCaptureRequests.count == 1) + } + + @Test func nothingIsRememberedSoEveryRequestAsksAgain() { + let dialogs = RecordingDialogs() + let controller = makeController(dialogs) + var decisions: [WKPermissionDecision] = [] + + controller.requestMediaCapture(devices: .camera, source: "demo.html") { decisions.append($0) } + dialogs.mediaCaptureAnswer = false + controller.requestMediaCapture(devices: .camera, source: "demo.html") { decisions.append($0) } + dialogs.mediaCaptureAnswer = true + controller.requestMediaCapture(devices: .camera, source: "demo.html") { decisions.append($0) } + + #expect(decisions == [.grant, .deny, .grant]) + #expect(dialogs.mediaCaptureRequests.count == 3) + } + + @Test func withoutUsageDescriptionsTheRequestIsDeniedAndTheUserToldOnceWithoutAsking() { + let dialogs = RecordingDialogs() + let controller = makeController(dialogs, usage: [:]) + var decisions: [WKPermissionDecision] = [] + + controller.requestMediaCapture(devices: .camera, source: "demo.html") { decisions.append($0) } + + #expect(decisions == [.deny]) + #expect(dialogs.mediaCaptureRequests.isEmpty) + #expect(dialogs.alerts.count == 1) + } + + @Test func aRequestNamingNoDeviceIsDeniedQuietly() { + let dialogs = RecordingDialogs() + let controller = makeController(dialogs) + var decisions: [WKPermissionDecision] = [] + + controller.requestMediaCapture(devices: [], source: "demo.html") { decisions.append($0) } + + #expect(decisions == [.deny]) + #expect(dialogs.mediaCaptureRequests.isEmpty) + #expect(dialogs.alerts.isEmpty) + } + + @Test func theIndicatorModelStartsWithNoDeviceInUse() { + let controller = makeController(RecordingDialogs()) + #expect(controller.capture.camera == .inactive) + #expect(controller.capture.microphone == .inactive) + #expect(!controller.capture.isCapturing) + } +} + +extension MockCaptureTests { + /// End to end with WebKit's mock cameras and microphones. + /// Each page makes one `getUserMedia` call; a second one on the same page is not reliable in WebKit's mock devices. + @MainActor + @Suite struct HTMLPreviewCapture { + private func makeHarness(answer: Bool = true) async throws -> BrowserHarness { + let harness = try BrowserHarness( + files: ["site/page.html": "disk"], + mediaUsageDescriptions: { MockCapture.usageDescriptions }, + configure: MockCapture.configure + ) + harness.dialogs.mediaCaptureAnswer = answer + harness.show("ready") + #expect(await harness.waitForTitle("ready"), "Title was \(harness.webView.title ?? "nil")") + return harness + } + + /// Starts the request without waiting for it, so a page that never answers cannot hang the test. + private func request(_ constraints: String, in harness: BrowserHarness) async throws { + _ = try await harness.evaluate(""" + navigator.mediaDevices.getUserMedia(\(constraints)).then( + stream => { window.stream = stream; document.title = 'granted'; }, + error => { document.title = 'denied:' + error.name; }); + 0 + """) + } + + @Test(.enabled(if: MockCapture.isAvailable)) + func aGrantedCameraShowsInTheIndicatorAndCanBeMutedAndStopped() async throws { + let harness = try await makeHarness() + defer { harness.tearDown() } + let capture = harness.controller.capture + + try await request("{ video: true }", in: harness) + #expect(await harness.waitForTitle("granted"), "Title was \(harness.webView.title ?? "nil")") + #expect(harness.dialogs.mediaCaptureRequests.count == 1) + #expect(harness.dialogs.mediaCaptureRequests.first?.devices == .camera) + #expect(harness.dialogs.mediaCaptureRequests.first?.requester == .page(name: "page.html")) + #expect(await harness.wait { capture.camera == .active }) + #expect(capture.microphone == .inactive) + #expect(capture.isCapturing) + + capture.setCameraMuted(true) + #expect(await harness.wait { capture.camera == .muted }) + capture.setCameraMuted(false) + #expect(await harness.wait { capture.camera == .active }) + + // WebKit lowers the state of the last device only about three seconds after it stops, + // so the page's own track is what shows the stop at once. + _ = try await harness.evaluate("window.stream.getVideoTracks()[0].addEventListener('ended', () => { document.title = 'ended'; }); 0") + capture.stopCamera() + #expect(await harness.waitForTitle("ended"), "Title was \(harness.webView.title ?? "nil")") + } + + @Test(.enabled(if: MockCapture.isAvailable)) + func cameraAndMicrophoneAreAskedTogetherAndControlledSeparately() async throws { + let harness = try await makeHarness() + defer { harness.tearDown() } + let capture = harness.controller.capture + + try await request("{ video: true, audio: true }", in: harness) + #expect(await harness.waitForTitle("granted"), "Title was \(harness.webView.title ?? "nil")") + #expect(harness.dialogs.mediaCaptureRequests.count == 1) + #expect(harness.dialogs.mediaCaptureRequests.first?.devices == [.camera, .microphone]) + let bothActive = await harness.wait { capture.camera == .active && capture.microphone == .active } + #expect(bothActive, "Camera \(capture.camera), microphone \(capture.microphone)") + + capture.setMicrophoneMuted(true) + #expect(await harness.wait { capture.microphone == .muted }) + #expect(capture.camera == .active) + capture.setMicrophoneMuted(false) + #expect(await harness.wait { capture.microphone == .active }) + + // The camera is still in use, so the stopped microphone leaves the indicator at once. + capture.stopMicrophone() + #expect(await harness.wait { capture.microphone == .inactive }) + #expect(capture.camera == .active) + #expect(capture.isCapturing) + } + + @Test(.enabled(if: MockCapture.isAvailable)) + func aRefusedRequestReachesThePageAsNotAllowedAndShowsNothing() async throws { + let harness = try await makeHarness(answer: false) + defer { harness.tearDown() } + + try await request("{ video: true }", in: harness) + #expect(await harness.waitForTitle("denied:NotAllowedError"), "Title was \(harness.webView.title ?? "nil")") + #expect(harness.dialogs.mediaCaptureRequests.count == 1) + #expect(!harness.controller.capture.isCapturing) + } + + @Test(.enabled(if: MockCapture.isAvailable)) + func pagesAreNotOfferedScreenSharing() async throws { + let harness = try await makeHarness() + defer { harness.tearDown() } + + let kind = try await harness.evaluate("typeof navigator.mediaDevices.getDisplayMedia") as? String + #expect(kind == "undefined") + } + } +} diff --git a/macos/Tests/LitheTests/HTMLPreviewNavigationTests.swift b/macos/Tests/LitheTests/HTMLPreviewNavigationTests.swift new file mode 100644 index 0000000000..1715a510a6 --- /dev/null +++ b/macos/Tests/LitheTests/HTMLPreviewNavigationTests.swift @@ -0,0 +1,120 @@ +import Foundation +import Testing +import WebKit +@testable import Lithe + +struct HTMLPreviewNavigationTests { + private func decide( + _ url: String, + type: WKNavigationType = .linkActivated, + target: HTMLPreviewNavigationRequest.Target = .mainFrame, + download: Bool = false + ) -> HTMLPreviewNavigationDecision { + HTMLPreviewNavigationPolicy.decide(HTMLPreviewNavigationRequest( + url: URL(string: url)!, + type: type, + target: target, + shouldDownload: download + )) + } + + @Test func linksInsideThePreviewAndOnTheWebAreFollowed() { + #expect(decide("lithe-html://preview/other.html") == .allow) + #expect(decide("lithe-html://preview/page.html#section") == .allow) + #expect(decide("https://example.com/") == .allow) + #expect(decide("http://localhost:3000/") == .allow) + #expect(decide("about:blank", target: .subframe) == .allow) + } + + @Test func newWindowRequestsLoadInThePreviewTab() { + let url = URL(string: "https://example.com/docs")! + #expect(decide("https://example.com/docs", target: .newWindow) == .loadInPlace(url)) + #expect(decide("lithe-html://preview/b.html", target: .newWindow) == .loadInPlace(URL(string: "lithe-html://preview/b.html")!)) + } + + @Test func mailAndPhoneLinksLeaveThePreviewOnlyWhenClicked() { + let mail = URL(string: "mailto:team@example.com")! + #expect(decide("mailto:team@example.com") == .openExternally(mail)) + #expect(decide("mailto:team@example.com", type: .other) == .cancel) + #expect(decide("tel:+10000000", type: .other) == .cancel) + } + + @Test func localFilesAndUnknownSchemesAreNeverLoaded() { + #expect(decide("file:///etc/passwd") == .cancel) + #expect(decide("file:///etc/passwd", target: .subframe) == .cancel) + #expect(decide("x-apple.systempreferences:com.apple.preference") == .cancel) + #expect(decide("ssh://example.com") == .cancel) + } + + @Test func dataAndBlobDocumentsAreOnlyAllowedInsideFrames() { + #expect(decide("data:text/html,hi", target: .mainFrame) == .cancel) + #expect(decide("data:text/html,hi", target: .subframe) == .allow) + #expect(decide("blob:https://example.com/id", target: .mainFrame) == .cancel) + #expect(decide("blob:https://example.com/id", target: .subframe) == .allow) + } + + @Test func downloadRequestsAreDownloaded() { + #expect(decide("https://example.com/file.zip", download: true) == .download) + #expect(decide("lithe-html://preview/file.zip", download: true) == .download) + } +} + +struct HTMLPreviewAddressTests { + private func fixture() throws -> (HTMLPreviewFixture, HTMLPreviewScope) { + let fixture = try HTMLPreviewFixture(files: ["site/page.html": "x", "site/other.html": "y"]) + return (fixture, fixture.scope(document: "site/page.html")) + } + + @Test func localPagesAreShownAsTheirFileURL() throws { + let (fixture, scope) = try fixture() + let internalURL = try #require(HTMLPreviewURL.url(forFile: fixture.url("site/other.html"), in: scope)) + #expect(HTMLPreviewAddress.display(for: internalURL, in: scope) == fixture.url("site/other.html").absoluteString) + + let withFragment = try #require(URL(string: internalURL.absoluteString + "?a=1#top")) + #expect(HTMLPreviewAddress.display(for: withFragment, in: scope).hasSuffix("other.html?a=1#top")) + } + + @Test func remoteAndEmptyAddressesAreShownVerbatim() throws { + let (_, scope) = try fixture() + #expect(HTMLPreviewAddress.display(for: URL(string: "https://example.com/a?b=1"), in: scope) == "https://example.com/a?b=1") + #expect(HTMLPreviewAddress.display(for: URL(string: "about:blank"), in: scope) == "") + #expect(HTMLPreviewAddress.display(for: nil, in: scope) == "") + } + + @Test func typedPathsAndFileURLsResolveToPreviewPages() throws { + let (fixture, scope) = try fixture() + let expected = try #require(HTMLPreviewURL.url(forFile: fixture.url("site/other.html"), in: scope)) + #expect(HTMLPreviewAddress.resolve(fixture.url("site/other.html").path, in: scope) == expected) + #expect(HTMLPreviewAddress.resolve(fixture.url("site/other.html").absoluteString, in: scope) == expected) + #expect(HTMLPreviewAddress.resolve(" \(fixture.url("site/other.html").path)\n", in: scope) == expected) + + let withFragment = HTMLPreviewAddress.resolve(fixture.url("site/other.html").absoluteString + "#part", in: scope) + #expect(withFragment?.fragment == "part") + } + + @Test func filesOutsideTheServedRootCannotBeOpened() throws { + let (_, scope) = try fixture() + #expect(HTMLPreviewAddress.resolve("/etc/hosts", in: scope) == nil) + #expect(HTMLPreviewAddress.resolve("file:///etc/hosts", in: scope) == nil) + #expect(HTMLPreviewAddress.resolve("~/Documents/a.html", in: scope) == nil) + } + + @Test func webAddressesGetASchemeWhenOmitted() throws { + let (_, scope) = try fixture() + #expect(HTMLPreviewAddress.resolve("https://example.com/a", in: scope)?.absoluteString == "https://example.com/a") + #expect(HTMLPreviewAddress.resolve("example.com/docs", in: scope)?.absoluteString == "https://example.com/docs") + #expect(HTMLPreviewAddress.resolve("localhost:3000", in: scope)?.absoluteString == "http://localhost:3000") + #expect(HTMLPreviewAddress.resolve("127.0.0.1:8080/x", in: scope)?.absoluteString == "http://127.0.0.1:8080/x") + } + + @Test func textThatIsNotAnAddressIsRejected() throws { + let (_, scope) = try fixture() + #expect(HTMLPreviewAddress.resolve("", in: scope) == nil) + #expect(HTMLPreviewAddress.resolve(" ", in: scope) == nil) + #expect(HTMLPreviewAddress.resolve("hello", in: scope) == nil) + #expect(HTMLPreviewAddress.resolve("hello world.com", in: scope) == nil) + #expect(HTMLPreviewAddress.resolve("javascript:alert(1)", in: scope) == nil) + #expect(HTMLPreviewAddress.resolve("ftp://example.com/x", in: scope) == nil) + #expect(HTMLPreviewAddress.resolve("https://", in: scope) == nil) + } +} diff --git a/macos/Tests/LitheTests/HTMLPreviewPageShortcutTests.swift b/macos/Tests/LitheTests/HTMLPreviewPageShortcutTests.swift new file mode 100644 index 0000000000..ef672d1886 --- /dev/null +++ b/macos/Tests/LitheTests/HTMLPreviewPageShortcutTests.swift @@ -0,0 +1,203 @@ +import AppKit +import Combine +import Testing +import WebKit +@testable import Lithe + +/// The application keymap reads a key press before AppKit offers it to any view, so a +/// focused HTML preview page keeps its own find, zoom and print chords only if the +/// keymap steps aside for them. +@MainActor +@Suite("HTML preview page shortcut policy") +struct HTMLPreviewPageShortcutPolicyTests { + private typealias Chord = (key: String, modifiers: NSEvent.ModifierFlags) + + private func makePage() -> HTMLPreviewWebViewSurface { + HTMLPreviewWebViewSurface( + frame: NSRect(x: 0, y: 0, width: 320, height: 240), + configuration: WKWebViewConfiguration() + ) + } + + private func defers(_ responder: NSResponder?, _ chord: Chord) -> Bool { + MacShortcutInputPolicy.shouldDeferToPreviewPage( + responder, + charactersIgnoringModifiers: chord.key, + modifierFlags: chord.modifiers + ) + } + + @Test + func focusedPageDefersEveryChordItAnswers() { + let page = makePage() + let chords: [Chord] = [ + ("f", .command), ("g", .command), ("G", [.command, .shift]), ("p", .command), + ("=", .command), ("+", [.command, .shift]), ("-", .command), ("0", .command), + // Caps Lock upper-cases the letter without changing the shortcut. + ("F", [.command, .capsLock]) + ] + for chord in chords { + #expect(defers(page, chord), "\(chord.modifiers) \(chord.key) must reach the focused page") + } + } + + @Test + func chordsThePageDoesNotAnswerStayWithTheKeymap() { + let page = makePage() + let chords: [Chord] = [ + ("s", .command), ("o", .command), (",", .command), ("w", [.command, .shift]), + // Search in project and similar commands keep their ⇧⌘ chords. + ("f", [.command, .shift]), ("p", [.command, .shift]), + ("f", [.command, .option]), ("g", [.command, .control]), ("f", .control), + // Typing into the page is not a shortcut. + ("f", []), ("p", []) + ] + for chord in chords { + #expect(!defers(page, chord), "\(chord.modifiers) \(chord.key) belongs to the application keymap") + } + #expect(HTMLPreviewPageShortcut(charactersIgnoringModifiers: nil, modifierFlags: .command) == nil) + } + + @Test + func viewsInsideThePageCountAsThePage() { + let page = makePage() + let inner = NSView(frame: .zero) + let deeper = NSView(frame: .zero) + page.addSubview(inner) + inner.addSubview(deeper) + // WebKit keeps views of its own inside the web view and focuses one of them. + #expect(defers(inner, ("f", .command))) + #expect(defers(deeper, ("f", .command))) + #expect(HTMLPreviewWebViewSurface.containing(deeper) === page) + } + + @Test + func respondersOutsideThePageKeepTheKeymap() { + let page = makePage() + let host = NSView(frame: NSRect(x: 0, y: 0, width: 640, height: 240)) + let findField = NSTextField(frame: NSRect(x: 0, y: 0, width: 100, height: 20)) + host.addSubview(page) + host.addSubview(findField) + let editor = CodeTextView(frame: NSRect(x: 0, y: 0, width: 320, height: 120)) + let terminal = LitheTerminalView(frame: NSRect(x: 0, y: 0, width: 320, height: 120)) + + // The page's own parent and its sibling views are not the page. + let outside: [NSView] = [host, findField, editor, terminal] + for responder in outside { + #expect(!defers(responder, ("f", .command)), "\(type(of: responder)) is not the page") + } + #expect(!defers(nil, ("f", .command))) + } +} + +/// Drives the real key monitor with a real preview page in the window, through +/// `NSApplication.sendEvent` as AppKit does, so the monitor's own use of the policy is +/// covered and not only the policy. +@MainActor +@Suite("HTML preview page shortcuts through the key monitor") +struct HTMLPreviewPageShortcutMonitorTests { + private typealias Chord = (key: String, modifiers: NSEvent.ModifierFlags) + + /// The keymap commands the monitor dispatched, in order, observable without sleeping. + @MainActor + private final class KeymapCommands: ObservableObject { + private(set) var ids: [String] = [] + + func record(_ id: String) { + ids.append(id) + objectWillChange.send() + } + + func waitForCount(_ count: Int) async -> Bool { + await waitForChanges([objectWillChange.eraseToAnyPublisher()], timeout: .seconds(5)) { [self] in + ids.count >= count + } + } + } + + /// Every chord the focused page answers, with the keymap command bound to it below. + private static let pageChords: [(chord: Chord, commandID: String)] = [ + (("f", .command), "find-in-file"), + (("g", .command), "find-next"), + (("G", [.command, .shift]), "find-previous"), + (("p", .command), "custom-print"), + (("=", .command), "custom-zoom-in"), + (("-", .command), "custom-zoom-out"), + (("0", .command), "custom-zoom-reset") + ] + + /// The default bindings of the find commands the page's chords collide with, ⌘S as the + /// chord only the keymap answers, and the print and zoom chords a customized keymap can + /// bind. Nothing else is bound, so the monitor leaves other tests' key presses alone. + private static var registrations: [KeyboardShortcutRegistration] { + let preset = KeyboardShortcutPreset.macOS + let defaults = LitheCommandCatalog.commands + .filter { ["find-in-file", "find-next", "find-previous", "save"].contains($0.id) } + .map { KeyboardShortcutRegistration(commandID: $0.id, bindings: preset.bindings(for: $0)) } + let customized = [ + KeyboardShortcutRegistration(commandID: "custom-print", bindings: [.keyPress(key: "p", modifiers: [.command])]), + KeyboardShortcutRegistration(commandID: "custom-zoom-in", bindings: [.keyPress(key: "=", modifiers: [.command])]), + KeyboardShortcutRegistration(commandID: "custom-zoom-out", bindings: [.keyPress(key: "-", modifiers: [.command])]), + KeyboardShortcutRegistration(commandID: "custom-zoom-reset", bindings: [.keyPress(key: "0", modifiers: [.command])]) + ] + return defaults + customized + } + + @Test + func focusedPageKeepsTheChordsTheKeymapBinds() async throws { + let harness = try BrowserHarness(files: ["site/page.html": "disk"]) + defer { harness.tearDown() } + let page = try #require(harness.webView as? HTMLPreviewWebViewSurface) + var zooms: [HTMLPreviewZoom.Command] = [] + var finds: [HTMLPreviewFind.Command] = [] + var prints: [HTMLPreviewPrint.Command] = [] + page.onZoomCommand = { zooms.append($0) } + page.onFindCommand = { finds.append($0) } + page.onPrintCommand = { prints.append($0) } + // In the app the editor sits beside the preview. + let editorStandIn = NSTextField(frame: NSRect(x: 0, y: 0, width: 100, height: 20)) + harness.window.contentView?.addSubview(editorStandIn) + #expect(harness.window.makeFirstResponder(page)) + + func event(_ chord: Chord) -> NSEvent { + harness.keyEvent(chord.key, modifiers: chord.modifiers) + } + func pressEveryPageChord() { + for entry in Self.pageChords { NSApplication.shared.sendEvent(event(entry.chord)) } + } + + // The page answers each chord of the list. The test host never makes its window the + // key window, so AppKit would not offer these events to the page; ask it directly. + for entry in Self.pageChords { + #expect(page.performKeyEquivalent(with: event(entry.chord)), "The focused page ignored \(entry.chord.key)") + } + #expect(finds == [.show, .next, .previous]) + #expect(prints == [.print]) + #expect(zooms == [.zoomIn, .zoomOut, .reset]) + + let keymap = KeymapCommands() + let detector = MacShortcutDetectorFactory().make { keymap.record($0) } + defer { detector.stop() } + detector.update(registrations: Self.registrations) + detector.start() + + // With the page focused, the keymap stays out of its chords. ⌘S is only the keymap's, + // which shows the monitor is live; commands arrive in press order, so a page chord the + // monitor consumed would be listed before "save". + pressEveryPageChord() + NSApplication.shared.sendEvent(event(("s", .command))) + try #require(await keymap.waitForCount(1), "The keymap never received ⌘S") + try #require(keymap.ids == ["save"], "The keymap took chords from the focused page: \(keymap.ids)") + + // Typed anywhere else, the same chords are the keymap's. That is the collision the + // monitor has to settle in the page's favour, and it is real. + #expect(harness.window.makeFirstResponder(editorStandIn)) + pressEveryPageChord() + let expected = Self.pageChords.map(\.commandID) + try #require( + await keymap.waitForCount(1 + expected.count), + "The keymap never received the page chords typed outside the page: \(keymap.ids)" + ) + #expect(keymap.ids.dropFirst().sorted() == expected.sorted()) + } +} diff --git a/macos/Tests/LitheTests/HTMLPreviewPrintTests.swift b/macos/Tests/LitheTests/HTMLPreviewPrintTests.swift new file mode 100644 index 0000000000..7ed7ddfc98 --- /dev/null +++ b/macos/Tests/LitheTests/HTMLPreviewPrintTests.swift @@ -0,0 +1,201 @@ +import AppKit +import PDFKit +import Testing +import WebKit +@testable import Lithe + +struct HTMLPreviewPrintShortcutTests { + @Test func commandPPrints() { + #expect(HTMLPreviewPrint.command(forKey: "p", modifiers: [.command]) == .print) + #expect(HTMLPreviewPrint.command(forKey: "P", modifiers: [.command, .capsLock]) == .print) + } + + @Test func otherKeyCombinationsAreLeftToTheirOwners() { + #expect(HTMLPreviewPrint.command(forKey: "p", modifiers: []) == nil) + #expect(HTMLPreviewPrint.command(forKey: "p", modifiers: [.command, .shift]) == nil) + #expect(HTMLPreviewPrint.command(forKey: "p", modifiers: [.command, .option]) == nil) + #expect(HTMLPreviewPrint.command(forKey: "p", modifiers: [.control]) == nil) + #expect(HTMLPreviewPrint.command(forKey: "o", modifiers: [.command]) == nil) + #expect(HTMLPreviewPrint.command(forKey: "", modifiers: [.command]) == nil) + } + + @Test func pdfNamesComeFromTheTitleOrTheFallback() { + #expect(HTMLPreviewPDF.fileName(title: "Quarterly Report", fallback: "page") == "Quarterly Report.pdf") + #expect(HTMLPreviewPDF.fileName(title: " Report \n", fallback: "page") == "Report.pdf") + #expect(HTMLPreviewPDF.fileName(title: nil, fallback: "index") == "index.pdf") + #expect(HTMLPreviewPDF.fileName(title: "", fallback: "example.com") == "example.com.pdf") + #expect(HTMLPreviewPDF.name(title: "Report", fallback: "page") == "Report") + } + + @Test func pdfNamesNeverContainPathSeparatorsOrGrowWithoutLimit() { + #expect(HTMLPreviewPDF.fileName(title: "a/b:c\\d", fallback: "page") == "a-b-c-d.pdf") + #expect(HTMLPreviewPDF.fileName(title: "..hidden", fallback: "page") == "hidden.pdf") + #expect(HTMLPreviewPDF.fileName(title: "///", fallback: "page") == "---.pdf") + #expect(HTMLPreviewPDF.fileName(title: "", fallback: "") == "page.pdf") + let long = HTMLPreviewPDF.fileName(title: String(repeating: "x", count: 500), fallback: "page") + #expect(long == String(repeating: "x", count: 100) + ".pdf") + } +} + +@MainActor +struct HTMLPreviewContextMenuTests { + /// Builds a menu the way WebKit does: its items carry `WKMenuItemIdentifier…` identifiers; "-" is a separator. + private func menu(_ entries: [String]) -> NSMenu { + let menu = NSMenu() + for entry in entries { + if entry == "-" { + menu.addItem(.separator()) + continue + } + let item = NSMenuItem(title: entry, action: nil, keyEquivalent: "") + item.identifier = NSUserInterfaceItemIdentifier("WKMenuItemIdentifier" + entry) + menu.addItem(item) + } + return menu + } + + private func layout(_ menu: NSMenu) -> [String] { + menu.items.map { item in + if item.isSeparatorItem { return "-" } + return item.identifier?.rawValue.replacingOccurrences(of: "WKMenuItemIdentifier", with: "") ?? item.title + } + } + + private func customized(_ entries: [String]) -> (controller: HTMLPreviewBrowserController, menu: NSMenu) { + let controller = HTMLPreviewBrowserController(dialogs: RecordingDialogs()) + let menu = menu(entries) + controller.customizeContextMenu(menu, for: nil) + return (controller, menu) + } + + private let printID = HTMLPreviewContextMenu.printIdentifier.rawValue + private let exportID = HTMLPreviewContextMenu.exportPDFIdentifier.rawValue + + // The entries below are the menus WebKit built for a right-click on this macOS, recorded from a real web view. + + @Test func theMenuOfThePageBackgroundGainsPrintAndExportAboveInspectElement() { + let result = customized(["Reload", "-", "-", "InspectElement"]) + #expect(layout(result.menu) == ["Reload", "-", printID, exportID, "-", "InspectElement"]) + #expect(result.menu.items.filter { !$0.isSeparatorItem }.map(\.title).contains(String(localized: "Print…"))) + } + + @Test func historyItemsStayAboveReload() { + let result = customized(["GoBack", "GoForward", "Reload", "-", "InspectElement"]) + #expect(layout(result.menu) == ["GoBack", "GoForward", "Reload", "-", printID, exportID, "-", "InspectElement"]) + } + + @Test func withoutInspectElementTheActionsEndTheMenu() { + #expect(layout(customized(["Reload"]).menu) == ["Reload", "-", printID, exportID]) + } + + @Test func linkMenusLoseOnlyTheNewWindowItem() { + let result = customized([ + "OpenLink", "OpenLinkInNewWindow", "DownloadLinkedFile", "-", "CopyLink", "ShareMenu", "-", "InspectElement" + ]) + #expect(layout(result.menu) == ["OpenLink", "DownloadLinkedFile", "-", "CopyLink", "ShareMenu", "-", "InspectElement"]) + } + + @Test func imageMenusLoseOnlyTheNewWindowItem() { + let result = customized(["OpenImageInNewWindow", "DownloadImage", "CopyImage", "-", "InspectElement"]) + #expect(layout(result.menu) == ["DownloadImage", "CopyImage", "-", "InspectElement"]) + } + + @Test func textMenusAreLeftAlone() { + let entries = ["LookUp", "Translate", "SearchWeb", "-", "Copy", "-", "InspectElement"] + #expect(layout(customized(entries).menu) == entries) + } + + @Test func customizingTwiceDoesNotDuplicateTheActions() { + let result = customized(["Reload", "-", "InspectElement"]) + result.controller.customizeContextMenu(result.menu, for: nil) + #expect(layout(result.menu) == ["Reload", "-", printID, exportID, "-", "InspectElement"]) + } + + @Test func theAddedItemsPrintAndExportThroughTheController() async throws { + let harness = try BrowserHarness(files: ["site/page.html": "disk"]) + defer { harness.tearDown() } + harness.show("Menu Page

hello

") + #expect(await harness.waitForTitle("Menu Page")) + + let menu = menu(["Reload", "-", "InspectElement"]) + harness.controller.customizeContextMenu(menu, for: nil) + let printItem = try #require(menu.items.first { $0.identifier == HTMLPreviewContextMenu.printIdentifier }) + let exportItem = try #require(menu.items.first { $0.identifier == HTMLPreviewContextMenu.exportPDFIdentifier }) + #expect(printItem.target === harness.controller) + #expect(exportItem.target === harness.controller) + + let action = try #require(printItem.action) + #expect(NSApplication.shared.sendAction(action, to: printItem.target, from: printItem)) + #expect(harness.dialogs.printOperations.count == 1) + } +} + +@MainActor +struct HTMLPreviewPrintTests { + @Test func printingHandsTheDialogsAnOperationLaidOutForThePage() async throws { + let harness = try BrowserHarness(files: ["site/page.html": "disk"]) + defer { harness.tearDown() } + harness.show("Report

hello

") + #expect(await harness.waitForTitle("Report")) + + harness.controller.handlePrintCommand(.print) + + let operation = try #require(harness.dialogs.printOperations.first) + #expect(harness.dialogs.printOperations.count == 1) + #expect(operation.jobTitle == "Report") + #expect(operation.view?.frame == harness.webView.bounds) + } + + @Test func thereIsNothingToPrintBeforeAPageIsShown() throws { + let harness = try BrowserHarness(files: ["site/page.html": "disk"]) + defer { harness.tearDown() } + harness.controller.handlePrintCommand(.print) + #expect(harness.dialogs.printOperations.isEmpty) + } + + @Test func exportingWritesTheWholePageToTheChosenFile() async throws { + let harness = try BrowserHarness(files: ["site/page.html": "disk"]) + defer { harness.tearDown() } + harness.show(""" + Long Report +
tall
+ """) + #expect(await harness.waitForTitle("Long Report")) + let destination = harness.fixture.root.appendingPathComponent("out.pdf") + harness.dialogs.downloadDestination = destination + + await harness.controller.exportPageAsPDF() + + #expect(harness.dialogs.downloadNames == ["Long Report.pdf"]) + #expect(harness.controller.loadError == nil) + let document = try #require(PDFDocument(url: destination)) + #expect(document.pageCount >= 1) + let height = try #require(document.page(at: 0)).bounds(for: .mediaBox).height + #expect(height > 1500, "Only \(height) points of a 3000-point page were exported") + } + + @Test func cancellingTheSavePanelWritesNothing() async throws { + let harness = try BrowserHarness(files: ["site/page.html": "disk"]) + defer { harness.tearDown() } + harness.show("Cancelled

hello

") + #expect(await harness.waitForTitle("Cancelled")) + harness.dialogs.downloadDestination = nil + + await harness.controller.exportPageAsPDF() + + #expect(harness.dialogs.downloadNames == ["Cancelled.pdf"]) + #expect(harness.controller.loadError == nil) + } + + @Test func anUnwritableDestinationIsReportedToTheUser() async throws { + let harness = try BrowserHarness(files: ["site/page.html": "disk"]) + defer { harness.tearDown() } + harness.show("Unwritable

hello

") + #expect(await harness.waitForTitle("Unwritable")) + harness.dialogs.downloadDestination = harness.fixture.root.appendingPathComponent("missing/out.pdf") + + await harness.controller.exportPageAsPDF() + + #expect(harness.controller.loadError != nil) + } +} diff --git a/macos/Tests/LitheTests/HTMLPreviewResourcesTests.swift b/macos/Tests/LitheTests/HTMLPreviewResourcesTests.swift new file mode 100644 index 0000000000..45c0d020c1 --- /dev/null +++ b/macos/Tests/LitheTests/HTMLPreviewResourcesTests.swift @@ -0,0 +1,283 @@ +import Foundation +import Testing +@testable import Lithe + +/// A workspace on disk shaped like a small static site, removed when the test ends. +final class HTMLPreviewFixture { + let root: URL + let workspace: URL + + init(files: [String: String] = [:], binaryFiles: [String: Data] = [:]) throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-html-preview-\(UUID().uuidString)", isDirectory: true) + .resolvingSymlinksInPath() + workspace = root.appendingPathComponent("workspace", isDirectory: true) + try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) + for (path, text) in files { try write(text, to: path) } + for (path, data) in binaryFiles { try write(data, to: path) } + } + + deinit { try? FileManager.default.removeItem(at: root) } + + /// `path` is relative to the workspace; `../` reaches the fixture root. + func url(_ path: String) -> URL { + workspace.appendingPathComponent(path).standardizedFileURL + } + + func write(_ text: String, to path: String) throws { + try write(Data(text.utf8), to: path) + } + + /// For files that are not text, such as a WebAssembly module. + func write(_ data: Data, to path: String) throws { + let file = url(path) + try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) + try data.write(to: file) + } + + func scope(document: String) -> HTMLPreviewScope { + HTMLPreviewScope(documentURL: url(document), workspaceURL: workspace) + } + + /// The smallest useful WebAssembly module: it exports `add(i32, i32) -> i32`. + static let addModule = Data([ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, // magic, version 1 + 0x01, 0x07, 0x01, 0x60, 0x02, 0x7f, 0x7f, 0x01, 0x7f, // type section: (i32, i32) -> i32 + 0x03, 0x02, 0x01, 0x00, // function section: one function of that type + 0x07, 0x07, 0x01, 0x03, 0x61, 0x64, 0x64, 0x00, 0x00, // export section: "add" + 0x0a, 0x09, 0x01, 0x07, 0x00, 0x20, 0x00, 0x20, 0x01, 0x6a, 0x0b // code: local.get 0, local.get 1, i32.add + ]) +} + +struct HTMLPreviewResourcesTests { + private func request(_ path: String, method: String = "GET", headers: [String: String] = [:]) -> URLRequest { + var request = URLRequest(url: URL(string: "lithe-html://preview" + path)!) + request.httpMethod = method + for (name, value) in headers { request.setValue(value, forHTTPHeaderField: name) } + return request + } + + private func respond( + _ request: URLRequest, + fixture: HTMLPreviewFixture, + document: String = "site/page.html", + html: String = "

buffer

" + ) -> HTMLPreviewResponse { + HTMLPreviewResponder.respond(to: request, scope: fixture.scope(document: document), html: html) + } + + private func text(_ response: HTMLPreviewResponse) -> String { + String(decoding: response.body, as: UTF8.self) + } + + @Test func rootIsTheWorkspaceWhenTheDocumentIsInsideIt() throws { + let fixture = try HTMLPreviewFixture(files: ["site/page.html": "disk"]) + let scope = fixture.scope(document: "site/page.html") + #expect(scope.rootURL == fixture.workspace) + #expect(HTMLPreviewURL.url(forFile: scope.documentURL, in: scope)?.absoluteString == "lithe-html://preview/site/page.html") + } + + @Test func rootIsTheDocumentDirectoryWhenTheDocumentIsOutsideTheWorkspace() throws { + let fixture = try HTMLPreviewFixture(files: ["page.html": "disk"]) + try fixture.write("elsewhere", to: "../loose/page.html") + let scope = HTMLPreviewScope(documentURL: fixture.url("../loose/page.html"), workspaceURL: fixture.workspace) + #expect(scope.rootURL == fixture.url("../loose")) + } + + @Test func aSiblingDirectorySharingAPrefixIsNotInsideTheRoot() throws { + let fixture = try HTMLPreviewFixture() + try fixture.write("x", to: "../workspace-other/page.html") + let scope = HTMLPreviewScope(documentURL: fixture.url("../workspace-other/page.html"), workspaceURL: fixture.workspace) + #expect(scope.rootURL == fixture.url("../workspace-other")) + } + + @Test func servesTheEditorBufferInsteadOfTheFileOnDisk() throws { + let fixture = try HTMLPreviewFixture(files: ["site/page.html": "disk"]) + let response = respond(request("/site/page.html"), fixture: fixture, html: "

unsaved

") + #expect(response.statusCode == 200) + #expect(text(response) == "

unsaved

") + #expect(response.headers["Content-Type"] == "text/html; charset=utf-8") + #expect(response.headers["Cache-Control"] == "no-store") + } + + @Test func servesRootRelativeAndSiblingFilesWithWebMIMETypes() throws { + let fixture = try HTMLPreviewFixture(files: [ + "site/page.html": "disk", + "assets/app.js": "export const a = 1", + "site/style.css": "body{}", + "data/info.json": "{}" + ]) + let script = respond(request("/assets/app.js"), fixture: fixture) + #expect(script.statusCode == 200) + #expect(script.headers["Content-Type"] == "text/javascript") + #expect(text(script) == "export const a = 1") + #expect(respond(request("/site/style.css"), fixture: fixture).headers["Content-Type"] == "text/css") + #expect(respond(request("/data/info.json"), fixture: fixture).headers["Content-Type"] == "application/json") + } + + @Test func servesWebAssemblyModulesByteForByteWithTheirMIMEType() throws { + let fixture = try HTMLPreviewFixture(files: ["site/page.html": "disk"], binaryFiles: ["assets/add.wasm": HTMLPreviewFixture.addModule]) + let response = respond(request("/assets/add.wasm"), fixture: fixture) + #expect(response.statusCode == 200) + #expect(response.headers["Content-Type"] == "application/wasm") + #expect(response.body == HTMLPreviewFixture.addModule) + } + + @Test func servesTheIndexFileForADirectory() throws { + let fixture = try HTMLPreviewFixture(files: ["site/page.html": "disk", "docs/index.html": "

index

"]) + #expect(text(respond(request("/docs/"), fixture: fixture)) == "

index

") + #expect(respond(request("/empty/"), fixture: fixture).statusCode == 404) + } + + @Test func missingFilesAreNotFound() throws { + let fixture = try HTMLPreviewFixture(files: ["site/page.html": "disk"]) + #expect(respond(request("/nope.js"), fixture: fixture).statusCode == 404) + } + + @Test func refusesHiddenEntriesAndKeyMaterial() throws { + let fixture = try HTMLPreviewFixture(files: [ + "site/page.html": "disk", + ".env": "TOKEN=secret", + ".git/config": "[core]", + "certs/server.pem": "-----BEGIN", + "certs/server.KEY": "-----BEGIN", + "keep/ok.txt": "ok" + ]) + #expect(respond(request("/.env"), fixture: fixture).statusCode == 403) + #expect(respond(request("/.git/config"), fixture: fixture).statusCode == 403) + #expect(respond(request("/certs/server.pem"), fixture: fixture).statusCode == 403) + #expect(respond(request("/certs/server.KEY"), fixture: fixture).statusCode == 403) + #expect(respond(request("/keep/ok.txt"), fixture: fixture).statusCode == 200) + } + + @Test func aDocumentInsideAHiddenDirectoryIsStillServed() throws { + let fixture = try HTMLPreviewFixture(files: [".docs/page.html": "disk"]) + let response = respond(request("/.docs/page.html"), fixture: fixture, document: ".docs/page.html", html: "

b

") + #expect(response.statusCode == 200) + } + + @Test func parentTraversalAndSymbolicLinksCannotLeaveTheRoot() throws { + let fixture = try HTMLPreviewFixture(files: ["site/page.html": "disk"]) + try fixture.write("outside", to: "../secret.txt") + try FileManager.default.createSymbolicLink( + at: fixture.url("link.txt"), + withDestinationURL: fixture.url("../secret.txt") + ) + #expect(respond(request("/../secret.txt"), fixture: fixture).statusCode == 404) + #expect(respond(request("/site/%2e%2e/%2e%2e/secret.txt"), fixture: fixture).statusCode == 404) + #expect(respond(request("/link.txt"), fixture: fixture).statusCode == 404) + } + + @Test func onlyReadMethodsAreAllowed() throws { + let fixture = try HTMLPreviewFixture(files: ["site/page.html": "disk", "a.txt": "abc"]) + let post = respond(request("/a.txt", method: "POST"), fixture: fixture) + #expect(post.statusCode == 405) + #expect(post.headers["Allow"] == "GET, HEAD") + let head = respond(request("/a.txt", method: "HEAD"), fixture: fixture) + #expect(head.statusCode == 200) + #expect(head.body.isEmpty) + #expect(head.headers["Content-Length"] == "3") + } + + @Test func requestsFromOutsideThePreviewAreRefused() throws { + let fixture = try HTMLPreviewFixture(files: ["site/page.html": "disk", "a.txt": "abc"]) + var remoteMain = request("/a.txt") + remoteMain.mainDocumentURL = URL(string: "https://example.com/") + #expect(respond(remoteMain, fixture: fixture).statusCode == 403) + #expect(respond(request("/a.txt", headers: ["Referer": "https://example.com/x"]), fixture: fixture).statusCode == 403) + + var local = request("/a.txt", headers: ["Referer": "lithe-html://preview/site/page.html"]) + local.mainDocumentURL = URL(string: "lithe-html://preview/site/page.html") + #expect(respond(local, fixture: fixture).statusCode == 200) + } + + @Test func rangeRequestsReturnPartialContent() throws { + let fixture = try HTMLPreviewFixture(files: ["site/page.html": "disk", "clip.bin": "0123456789"]) + let partial = respond(request("/clip.bin", headers: ["Range": "bytes=2-4"]), fixture: fixture) + #expect(partial.statusCode == 206) + #expect(text(partial) == "234") + #expect(partial.headers["Content-Range"] == "bytes 2-4/10") + #expect(partial.headers["Content-Length"] == "3") + + let suffix = respond(request("/clip.bin", headers: ["Range": "bytes=-3"]), fixture: fixture) + #expect(text(suffix) == "789") + + let beyond = respond(request("/clip.bin", headers: ["Range": "bytes=50-"]), fixture: fixture) + #expect(beyond.statusCode == 416) + #expect(beyond.headers["Content-Range"] == "bytes */10") + } + + @Test func byteRangeParsingIgnoresMalformedHeaders() { + #expect(HTMLPreviewResponder.ByteRange.parse(nil, length: 10) == .full) + #expect(HTMLPreviewResponder.ByteRange.parse("items=1-2", length: 10) == .full) + #expect(HTMLPreviewResponder.ByteRange.parse("bytes=1-2,4-5", length: 10) == .full) + #expect(HTMLPreviewResponder.ByteRange.parse("bytes=5-2", length: 10) == .full) + #expect(HTMLPreviewResponder.ByteRange.parse("bytes=0-99", length: 10) == .satisfiable(0..<10)) + } + + @Test func fileURLsRoundTripThroughPreviewURLs() throws { + let fixture = try HTMLPreviewFixture(files: ["dir with space/页面 #1.html": "x"]) + let scope = fixture.scope(document: "dir with space/页面 #1.html") + let url = try #require(HTMLPreviewURL.url(forFile: scope.documentURL, in: scope)) + #expect(!url.absoluteString.contains(" ")) + #expect(url.fragment == nil) + #expect(HTMLPreviewURL.fileURL(for: url, in: scope) == scope.documentURL) + } + + @Test func folderURLsEndWithASlashSoRelativeLinksResolveInsideThem() throws { + let fixture = try HTMLPreviewFixture(files: ["docs/index.html": "x", "page.html": "x"]) + let scope = fixture.scope(document: "page.html") + #expect(HTMLPreviewURL.url(forFile: fixture.url("docs"), in: scope)?.absoluteString == "lithe-html://preview/docs/") + #expect(HTMLPreviewURL.url(forFile: fixture.workspace, in: scope)?.absoluteString == "lithe-html://preview/") + #expect(HTMLPreviewURL.url(forFile: fixture.url("docs/index.html"), in: scope)?.absoluteString == "lithe-html://preview/docs/index.html") + } + + @Test func aFolderAddressWithoutASlashIsRedirectedToTheOneWithIt() throws { + let fixture = try HTMLPreviewFixture(files: ["docs/index.html": "x", "docs/a b/index.html": "x", "page.html": "x"]) + let scope = fixture.scope(document: "page.html") + func redirect(_ address: String) -> String? { + HTMLPreviewURL.folderRedirect(for: URL(string: address)!, in: scope)?.absoluteString + } + + #expect(redirect("lithe-html://preview/docs") == "lithe-html://preview/docs/") + // The query and fragment ride along, as with a server's redirect. + #expect(redirect("lithe-html://preview/docs?x=1#top") == "lithe-html://preview/docs/?x=1#top") + #expect(redirect("lithe-html://preview/docs/a%20b") == "lithe-html://preview/docs/a%20b/") + #expect(redirect("lithe-html://preview") == "lithe-html://preview/") + + // Already slashed, files, missing entries and other hosts are left alone. + #expect(redirect("lithe-html://preview/docs/") == nil) + #expect(redirect("lithe-html://preview/docs/index.html") == nil) + #expect(redirect("lithe-html://preview/missing") == nil) + #expect(redirect("https://example.com/docs") == nil) + #expect(redirect("lithe-html://preview/../docs") == nil) + } + + @Test func savingAFileCopiesItAndReplacesAnExistingOne() throws { + let fixture = try HTMLPreviewFixture(files: ["data.txt": "source", "taken.txt": "old"]) + + try HTMLPreviewFileSaver.save(fixture.url("data.txt"), bufferText: nil, to: fixture.url("copy.txt")) + try HTMLPreviewFileSaver.save(fixture.url("data.txt"), bufferText: nil, to: fixture.url("taken.txt")) + + #expect(try String(contentsOf: fixture.url("copy.txt"), encoding: .utf8) == "source") + #expect(try String(contentsOf: fixture.url("taken.txt"), encoding: .utf8) == "source") + } + + @Test func savingAFileOverItselfKeepsIt() throws { + let fixture = try HTMLPreviewFixture(files: ["data.txt": "source"]) + try FileManager.default.createSymbolicLink(at: fixture.url("alias.txt"), withDestinationURL: fixture.url("data.txt")) + + try HTMLPreviewFileSaver.save(fixture.url("data.txt"), bufferText: nil, to: fixture.url("data.txt")) + try HTMLPreviewFileSaver.save(fixture.url("data.txt"), bufferText: nil, to: fixture.url("alias.txt")) + + #expect(try String(contentsOf: fixture.url("data.txt"), encoding: .utf8) == "source") + } + + @Test func savingThePreviewedDocumentWritesTheEditorText() throws { + let fixture = try HTMLPreviewFixture(files: ["page.html": "disk"]) + + try HTMLPreviewFileSaver.save(fixture.url("page.html"), bufferText: "unsaved edit", to: fixture.url("saved.html")) + + #expect(try String(contentsOf: fixture.url("saved.html"), encoding: .utf8) == "unsaved edit") + } +} diff --git a/macos/Tests/LitheTests/HTMLPreviewZoomTests.swift b/macos/Tests/LitheTests/HTMLPreviewZoomTests.swift new file mode 100644 index 0000000000..9d1dd22bf4 --- /dev/null +++ b/macos/Tests/LitheTests/HTMLPreviewZoomTests.swift @@ -0,0 +1,47 @@ +import AppKit +import Testing +@testable import Lithe + +struct HTMLPreviewZoomTests { + @Test func zoomingWalksTheBrowserStopsAndEndsAtTheLimits() { + #expect(HTMLPreviewZoom.zoomedIn(from: 1) == 1.1) + #expect(HTMLPreviewZoom.zoomedOut(from: 1) == 0.9) + #expect(HTMLPreviewZoom.zoomedIn(from: 0.25) == 0.33) + #expect(HTMLPreviewZoom.zoomedOut(from: 0.25) == nil) + #expect(HTMLPreviewZoom.zoomedIn(from: 5) == nil) + #expect(HTMLPreviewZoom.zoomedOut(from: 5) == 4) + } + + @Test func aFactorBetweenStopsSnapsToTheNeighbouringStop() { + #expect(HTMLPreviewZoom.zoomedIn(from: 1.05) == 1.1) + #expect(HTMLPreviewZoom.zoomedOut(from: 1.05) == 1) + // A float the web view rounded slightly off a stop still counts as that stop. + #expect(HTMLPreviewZoom.zoomedIn(from: 1.0000001) == 1.1) + #expect(HTMLPreviewZoom.zoomedOut(from: 0.9999999) == 0.9) + } + + @Test func percentagesAreRoundedForDisplay() { + #expect(HTMLPreviewZoom.percent(1) == 100) + #expect(HTMLPreviewZoom.percent(0.33) == 33) + #expect(HTMLPreviewZoom.percent(1.1) == 110) + #expect(HTMLPreviewZoom.percent(0.67) == 67) + } + + @Test func commandShortcutsMatchBrowsers() { + #expect(HTMLPreviewZoom.command(forKey: "=", modifiers: [.command]) == .zoomIn) + #expect(HTMLPreviewZoom.command(forKey: "+", modifiers: [.command, .shift]) == .zoomIn) + #expect(HTMLPreviewZoom.command(forKey: "-", modifiers: [.command]) == .zoomOut) + #expect(HTMLPreviewZoom.command(forKey: "0", modifiers: [.command]) == .reset) + // The numeric keypad adds its own flag, which must not hide the shortcut. + #expect(HTMLPreviewZoom.command(forKey: "=", modifiers: [.command, .numericPad]) == .zoomIn) + } + + @Test func otherKeyCombinationsAreLeftToTheirOwners() { + #expect(HTMLPreviewZoom.command(forKey: "=", modifiers: []) == nil) + #expect(HTMLPreviewZoom.command(forKey: "-", modifiers: [.control]) == nil) + #expect(HTMLPreviewZoom.command(forKey: "=", modifiers: [.command, .option]) == nil) + #expect(HTMLPreviewZoom.command(forKey: "0", modifiers: [.command, .control]) == nil) + #expect(HTMLPreviewZoom.command(forKey: "a", modifiers: [.command]) == nil) + #expect(HTMLPreviewZoom.command(forKey: "", modifiers: [.command]) == nil) + } +} diff --git a/macos/Tests/LitheTests/LanguageExtensionProcessLifecycleTests.swift b/macos/Tests/LitheTests/LanguageExtensionProcessLifecycleTests.swift index 59a57ad0a9..49d6cbd968 100644 --- a/macos/Tests/LitheTests/LanguageExtensionProcessLifecycleTests.swift +++ b/macos/Tests/LitheTests/LanguageExtensionProcessLifecycleTests.swift @@ -152,7 +152,7 @@ struct LanguageExtensionProcessLifecycleTests { defer { try? FileManager.default.removeItem(at: root) } let operations = DeclarativePluginTestSupport.operations let descriptor = try operations.validate(manifest: Data(contentsOf: DeclarativePluginTestSupport.repositoryRoot - .appendingPathComponent("shared/contracts/plugins/v1/fixtures/java-support.plugin.json"))) + .appendingPathComponent("shared/contracts/plugins/v1/fixtures/language-host-integration.plugin.json"))) let packageURL = root.appendingPathComponent("package", isDirectory: true) let installed = InstalledPluginPackage( descriptor: descriptor, diff --git a/macos/Tests/LitheTests/LitheActionMenuFocusTests.swift b/macos/Tests/LitheTests/LitheActionMenuFocusTests.swift new file mode 100644 index 0000000000..62089808c8 --- /dev/null +++ b/macos/Tests/LitheTests/LitheActionMenuFocusTests.swift @@ -0,0 +1,90 @@ +import AppKit +import SwiftUI +import Testing +@testable import Lithe + +@Suite("Action menu focus", .serialized) +@MainActor +struct LitheActionMenuFocusTests { + @Test func actionKeysRemainScopedToTheOwnerAndEscapeRestoresItsResponder() throws { + let window = makeWindow() + let editor = NSTextView(frame: window.contentView!.bounds) + window.contentView = editor + window.makeKeyAndOrderFront(nil) + window.makeFirstResponder(editor) + let presenter = LitheContextMenuPresenter() + defer { presenter.dismiss(); window.contentView = nil; window.close() } + var calls = 0 + presenter.show(items: [.action("Copy") { calls += 1 }], at: window.frame.origin, + appearance: nil, locale: Locale(identifier: "en"), parentWindow: window) + #expect(window.isKeyWindow) + #expect(window.firstResponder === editor) + try sendKey(125, to: window) + try sendKey(53, to: window) + #expect(calls == 0 && !hasPopup(window)) + #expect(window.firstResponder === editor) + } + + @Test func childActionsReceiveKeysBeforeTheSearchableParentsHandler() throws { + let window = makeWindow() + window.makeKeyAndOrderFront(nil) + let parent = LitheContextMenuPresenter(), child = LitheContextMenuPresenter() + defer { child.dismiss(); parent.dismiss(); window.contentView = nil; window.close() } + var parentKeys = 0, childCalls = 0 + let content = NSHostingController(rootView: Text("Parent") + .frame(width: 200, height: 120) + .litheDropdownKeyHandler { _ in parentKeys += 1; return true }) + parent.show(contentController: content, at: window.frame.origin, appearance: nil, parentWindow: window, onDismiss: {}) + let parentPanel = try #require(window.childWindows?.first(where: { $0.isVisible })) + #expect(parentPanel.isKeyWindow) + child.show(items: [.action("Copy") { childCalls += 1 }], at: parentPanel.frame.origin, + appearance: nil, locale: Locale(identifier: "en"), parentWindow: parentPanel) + #expect(parentPanel.isKeyWindow) + try sendKey(125, to: parentPanel) + try sendKey(36, to: parentPanel) + #expect(childCalls == 1 && parentKeys == 0) + #expect(!hasPopup(parentPanel) && parentPanel.isVisible) + } + + @Test func losingOwnerFocusAndApplicationActivationDismissesTheMenu() { + let window = makeWindow() + window.makeKeyAndOrderFront(nil) + let presenter = LitheContextMenuPresenter() + defer { presenter.dismiss(); window.contentView = nil; window.close() } + func show() { + presenter.show(items: [.action("Copy") {}], at: window.frame.origin, + appearance: nil, locale: Locale(identifier: "en"), parentWindow: window) + } + show() + #expect(hasPopup(window)) + window.resignKey() + #expect(!hasPopup(window)) + window.makeKey() + show() + #expect(hasPopup(window)) + NotificationCenter.default.post(name: NSApplication.didResignActiveNotification, object: NSApp) + #expect(!hasPopup(window)) + } + + private func makeWindow() -> NSWindow { + let window = ActionMenuFocusWindow(contentRect: NSRect(x: 100, y: 100, width: 400, height: 240), + styleMask: [.titled, .nonactivatingPanel], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + return window + } + private func hasPopup(_ window: NSWindow) -> Bool { + window.childWindows?.contains(where: { $0.isVisible }) == true + } + private func sendKey(_ code: UInt16, to window: NSWindow) throws { + NSApp.sendEvent(try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], + timestamp: 0, windowNumber: window.windowNumber, context: nil, characters: "", charactersIgnoringModifiers: "", + isARepeat: false, keyCode: code))) + } +} + +// A nonactivating native host can become key in the isolated test process; +// it exercises real AppKit focus without activating a desktop application. +@MainActor +private final class ActionMenuFocusWindow: NSPanel { + override var canBecomeKey: Bool { true } +} diff --git a/macos/Tests/LitheTests/LitheCenteredPopupTests.swift b/macos/Tests/LitheTests/LitheCenteredPopupTests.swift index c6abd506bd..8407a2f92c 100644 --- a/macos/Tests/LitheTests/LitheCenteredPopupTests.swift +++ b/macos/Tests/LitheTests/LitheCenteredPopupTests.swift @@ -153,11 +153,13 @@ struct LitheCenteredPopupTests { parent.appearance = NSAppearance(named: scheme == .dark ? .darkAqua : .aqua) let presenter = LitheCenteredPopupPresenter() defer { presenter.close(); parent.close() } - for kind in [ProjectItemEditKind.createFile, .createDirectory] { - let host = NSHostingController(rootView: ProjectItemNameDialogContent( - request: ProjectItemEditRequest(kind: kind, targetURL: URL(fileURLWithPath: "/")), - onSubmit: { _ in }, onCancel: {}) - .environment(\.colorScheme, scheme)) + let contents: [(String, AnyView)] = [ + ("file", AnyView(ProjectItemNameDialogContent(request: ProjectItemEditRequest(kind: .createFile, targetURL: URL(fileURLWithPath: "/")), onSubmit: { _ in }, onCancel: {}))), + ("directory", AnyView(ProjectItemNameDialogContent(request: ProjectItemEditRequest(kind: .createDirectory, targetURL: URL(fileURLWithPath: "/")), onSubmit: { _ in }, onCancel: {}))), + ("folder", AnyView(LitheNamePopup(title: "New Folder", onSubmit: { _ in }, onCancel: {}))) + ] + for (kind, content) in contents { + let host = NSHostingController(rootView: content.environment(\.colorScheme, scheme)) presenter.show(contentController: host, parent: parent, onDismiss: {}) let clock = ContinuousClock() let deadline = clock.now.advanced(by: .seconds(1)) @@ -178,7 +180,7 @@ struct LitheCenteredPopupTests { "Focused name input must not draw a separate fill or focus border") } if let directory = ProcessInfo.processInfo.environment["LITHE_POPUP_CAPTURE_DIR"] { - let name = "new-\(kind == .createFile ? "file" : "directory")-\(scheme == .dark ? "dark" : "light").png" + let name = "new-\(kind)-\(scheme == .dark ? "dark" : "light").png" try #require(bitmap.representation(using: .png, properties: [:])).write(to: URL(fileURLWithPath: directory).appendingPathComponent(name)) } diff --git a/macos/Tests/LitheTests/LitheCoreLogicTests.swift b/macos/Tests/LitheTests/LitheCoreLogicTests.swift index 3a5747887c..189955c27b 100644 --- a/macos/Tests/LitheTests/LitheCoreLogicTests.swift +++ b/macos/Tests/LitheTests/LitheCoreLogicTests.swift @@ -4,7 +4,6 @@ import CoreServices import Foundation import SwiftUI import LitheApplicationKernel -@testable import LitheDatabaseModule @testable import LitheGitModule import LitheLocalHistoryModule import LitheModuleAPI @@ -97,12 +96,32 @@ struct LitheCoreLogicTests { #expect(!coordinator.windowShouldClose(window)) #expect(await sessions.projectWindowCleanupFinished.waitUntilOpen()) + #expect(sessions.confirmRunServicesCallCount == 1) #expect(sessions.resetForProjectWindowCloseCallCount == 1) #expect(sessions.requestCloseActiveSessionCallCount == 0) #expect(!sessions.hasActiveProject) #expect(window.performCloseCallCount == 0) } + @Test + @MainActor + func cancellingTheRunServiceQuestionKeepsTheWindowAndItsProjects() async { + let sessions = TestProjectWindowSessions(hasActiveProject: true) + sessions.confirmRunServicesResult = false + let coordinator = LitheWindowCoordinator( + projectSessions: sessions, confirmUnsavedDocuments: { _ in true } + ) + let window = CloseCommandTestWindow() + coordinator.attach(to: window, layout: .workspace) + + #expect(!coordinator.windowShouldClose(window)) + #expect(await sessions.runServicesAsked.waitUntilOpen()) + + #expect(sessions.resetForProjectWindowCloseCallCount == 0) + #expect(sessions.hasActiveProject) + #expect(window.performCloseCallCount == 0) + } + @Test @MainActor func commandWClosesActiveWorkbenchContentBeforeTheWindow() { @@ -1032,1259 +1051,6 @@ struct LitheCoreLogicTests { #expect(window.frame == restoredFrame) } - @Test - func databaseSidecarParsesCapabilitiesWithoutStartingUntilRequested() throws { - let runner = RecordingProcessRunner { request in - let input = try! #require(request.standardInput) - let object = try! JSONSerialization.jsonObject(with: input) as! [String: Any] - let id = object["id"] as! String - return ProcessResult( - output: #"{"id":"\#(id)","ok":true,"result":{"protocolVersion":1,"databaseTypes":["mysql","postgresql","sqlite"],"features":["schema"]}}"#, - exitCode: 0 - ) - } - let service = DatabaseSidecarService( - processRunner: runner, - executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar") - ) - - #expect(runner.requests.isEmpty) - #expect(try service.capabilities() == DatabaseCapabilities( - protocolVersion: 1, - databaseTypes: ["mysql", "postgresql", "sqlite"], - features: ["schema"] - )) - #expect(runner.requests.count == 1) - #expect(runner.requests[0].arguments.isEmpty) - #expect(runner.requests[0].standardInput != nil) - #expect(runner.requests[0].timeoutMilliseconds == 30_000) - } - - @Test - func databaseSidecarSerializesRedisAndNacosWorkspaceRequests() throws { - let runner = RecordingProcessRunner { request in - let input = try! #require(request.standardInput) - let object = try! JSONSerialization.jsonObject(with: input) as! [String: Any] - let id = object["id"] as! String - let method = object["method"] as! String - let result: String - switch method { - case "redisScan": - result = #"{"keys":[{"key":"session:42","type":"string","ttl":60,"size":9}],"nextCursor":"19"}"# - case "nacosListConfigs": - result = #"{"items":[{"dataId":"app.yaml","group":"DEFAULT_GROUP","namespace":"dev","type":"yaml","md5":"abc"}],"totalCount":1}"# - default: - Issue.record("Unexpected sidecar method: \(method)") - result = "{}" - } - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":\#(result)}"#, exitCode: 0) - } - let service = DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar")) - - let redis = DatabaseConnection(kind: .redis, host: "127.0.0.1", port: 6379, database: "0") - let scan = try service.redisScan(connection: redis, cursor: "0", pattern: "session:*", count: 50) - #expect(scan.keys.first?.key == "session:42") - #expect(scan.nextCursor == "19") - - let nacos = DatabaseConnection(kind: .nacos, host: "127.0.0.1", port: 8848, database: "dev", path: "/nacos") - let configs = try service.nacosListConfigs(connection: nacos, dataId: "app", group: "DEFAULT_GROUP") - #expect(configs.items.first?.dataId == "app.yaml") - #expect(configs.totalCount == 1) - - let redisRequest = String(decoding: try #require(runner.requests[0].standardInput), as: UTF8.self) - let nacosRequest = String(decoding: try #require(runner.requests[1].standardInput), as: UTF8.self) - #expect(redisRequest.contains(#""method":"redisScan""#)) - #expect(redisRequest.contains(#""kind":"redis""#)) - #expect(!redisRequest.contains("127.0.0.1:6379")) - #expect(nacosRequest.contains(#""method":"nacosListConfigs""#)) - #expect(nacosRequest.contains(#""kind":"nacos""#)) - } - - @Test - func databaseSidecarSerializesRedisSizePreference() throws { - let runner = RecordingProcessRunner { request in - let input = try! #require(request.standardInput) - let object = try! JSONSerialization.jsonObject(with: input) as! [String: Any] - let id = object["id"] as! String - return ProcessResult( - output: #"{"id":"\#(id)","ok":true,"result":{"keys":[],"nextCursor":"0"}}"#, - exitCode: 0 - ) - } - let service = DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar")) - let redis = DatabaseConnection(kind: .redis, host: "127.0.0.1", port: 6379, database: "0") - - _ = try service.redisScan(connection: redis, includeSize: false) - - let request = String(decoding: try #require(runner.requests[0].standardInput), as: UTF8.self) - #expect(request.contains(#""includeSize":false"#)) - } - - @Test - func databaseSidecarDecodesLegacyMongoMetadataRowsEnvelope() throws { - let runner = RecordingProcessRunner { request in - let input = try! #require(request.standardInput) - let object = try! JSONSerialization.jsonObject(with: input) as! [String: Any] - let id = object["id"] as! String - let method = object["method"] as! String - let result: String - switch method { - case "listTables": result = #"{"rows":[{"table_name":"events","table_type":"collection"}],"truncated":false}"# - case "describeTable": result = #"{"rows":[{"column_name":"_id","data_type":"objectId"}],"truncated":false}"# - case "listIndexes": result = #"{"rows":[{"index_name":"_id_","definition":"{\"_id\":1}"}],"truncated":false}"# - case "listForeignKeys": result = #"{"rows":[],"truncated":false}"# - case "listObjects": result = #"{"rows":[{"object_name":"events","object_kind":"collection"}],"truncated":false}"# - default: result = "[]" - } - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":\#(result)}"#, exitCode: 0) - } - let service = DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar")) - let mongo = DatabaseConnection(kind: .mongodb, host: "127.0.0.1", port: 27017, database: "lithe_test") - func stringValue(_ row: DatabaseRow?, _ key: String) -> String? { - guard case let .string(value) = row?[key] else { return nil } - return value - } - - #expect(stringValue(try service.listTables(connection: mongo).first, "table_name") == "events") - #expect(stringValue(try service.describeTable(connection: mongo, table: "events").first, "column_name") == "_id") - #expect(stringValue(try service.listIndexes(connection: mongo, table: "events").first, "index_name") == "_id_") - #expect((try service.listForeignKeys(connection: mongo, table: "events")).isEmpty) - #expect(stringValue(try service.listObjects(connection: mongo, kind: DatabaseObjectKind.tables).first, "object_name") == "events") - } - - @Test - func databaseSidecarMapsFailedProcessToStableError() { - let runner = RecordingProcessRunner(result: ProcessResult(output: "connection store failed", exitCode: 2)) - let service = DatabaseSidecarService( - processRunner: runner, - executableURL: URL(fileURLWithPath: "/tmp/dbx") - ) - - #expect(throws: DatabaseSidecarError.processFailed(exitCode: 2, output: "connection store failed")) { - try service.capabilities() - } - } - - @Test - func databaseSidecarErrorsAreSingleLineAndBounded() { - let error = DatabaseSidecarError.requestFailed(code: "database_error", message: String(repeating: "x", count: 800) + "\nnext line") - let description = error.localizedDescription - #expect(description.count <= 500 + "Database request failed (database_error): ".count) - #expect(!description.contains("\n")) - #expect(description.hasSuffix("...")) - } - - @Test - func databaseValueDisplayKeepsNullAndEmptyStringDistinct() { - #expect(DatabaseValue.null.displayText == "NULL") - #expect(DatabaseValue.string("").displayText == "\"\"") - #expect(DatabaseValue.string("NULL").displayText == "NULL") - #expect(DatabaseValue.object(["empty": .string("")]).displayText == #"{"empty":""}"#) - } - - @Test - func databaseProfilesKeepPasswordsOutOfPreferences() throws { - let preferences = DatabaseTestKeyValueStore() - let secrets = DatabaseTestSecureStore() - let store = DatabaseConnectionStore(store: preferences, secureStore: secrets) - let profile = DatabaseProfile(name: "Local", kind: .mysql, username: "root", database: "app") - - try store.save([profile]) - try store.savePassword("secret-value", for: profile.id) - - #expect(store.load() == [profile]) - #expect(store.password(for: profile.id) == "secret-value") - let encodedProfiles = try #require(preferences.data(forKey: "database.profiles.v1")) - #expect(!String(decoding: encodedProfiles, as: UTF8.self).contains("secret-value")) - } - - @Test - @MainActor - func databaseRepeatedConnectionEditsPreserveTheSavedPasswordAndProfileID() async throws { - let runner = RecordingProcessRunner { request in - let input = try! #require(request.standardInput) - let object = try! JSONSerialization.jsonObject(with: input) as! [String: Any] - let id = object["id"] as! String - return ProcessResult( - output: #"{"id":"\#(id)","ok":true,"result":{"connected":true}}"#, - exitCode: 0 - ) - } - let preferences = DatabaseTestKeyValueStore() - let secrets = DatabaseTestSecureStore() - let store = DatabaseConnectionStore(store: preferences, secureStore: secrets) - let operations = DatabaseSidecarService( - processRunner: runner, - executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar") - ) - let feature = DatabaseFeatureModel(operations: operations, connectionStore: store) - let profile = DatabaseProfile(name: "Local Redis", kind: .redis, port: 6379) - - #expect(await feature.add(profile, password: "1234")) - var firstRename = profile - firstRename.name = "Renamed Redis" - #expect(await feature.update(firstRename, password: nil)) - var secondRename = firstRename - secondRename.name = "Renamed Again" - #expect(await feature.update(secondRename, password: nil)) - - #expect(feature.profiles.first?.id == profile.id) - #expect(feature.profiles.first?.name == "Renamed Again") - #expect(store.password(for: profile.id) == "1234") - #expect(runner.requests.count == 3) - for request in runner.requests { - let payload = String(decoding: try #require(request.standardInput), as: UTF8.self) - #expect(payload.contains(#""password":"1234""#)) - } - } - - @Test - func databaseDBXImportMapsConnectionsFoldersAndUnsupportedTypes() throws { - let data = Data(dbxPlainConnectionExport.utf8) - let duplicate = DatabaseProfile(name: "Production MySQL", kind: .mysql, host: "db.example.com", port: 3306) - let plan = try DatabaseDBXImportService().parse(data: data, passphrase: nil, existingProfiles: [duplicate]) - - #expect(plan.wasEncrypted == false) - #expect(plan.candidates.count == 2) - #expect(plan.duplicateCount == 1) - #expect(plan.unsupportedTypes == ["oracle": 1]) - #expect(plan.folders.count == 2) - let production = try #require(plan.candidates.first { $0.sourceID == "mysql-1" }) - #expect(production.profile.kind == .mysql) - #expect(production.profile.username == "root") - #expect(production.password == "db-secret") - #expect(production.profile.readOnly) - #expect(production.profile.productionProtection) - #expect(production.profile.sshHost == "jump.example.com") - let sqlite = try #require(plan.candidates.first { $0.sourceID == "sqlite-1" }) - #expect(sqlite.profile.kind == .sqlite) - #expect(sqlite.profile.path == "/tmp/local.sqlite") - let localFolder = try #require(plan.folders.first { $0.id == sqlite.profile.folderID }) - #expect(localFolder.name == "Local") - #expect(localFolder.parentID != nil) - } - - @Test - func databaseDBXImportDecryptsTheVersionOneWebCryptoEnvelope() throws { - let data = Data(dbxEncryptedConnectionExport.utf8) - let service = DatabaseDBXImportService() - #expect(service.isEncrypted(data)) - let plan = try service.parse(data: data, passphrase: "migration-pass", existingProfiles: []) - #expect(plan.wasEncrypted) - #expect(plan.candidates.count == 1) - #expect(plan.candidates[0].profile.name == "M") - #expect(plan.candidates[0].password == "p") - #expect(throws: DatabaseDBXImportError.wrongPassphrase) { - try service.parse(data: data, passphrase: "wrong", existingProfiles: []) - } - } - - @Test - @MainActor - func databaseDBXImportPersistsProfilesFoldersAndKeychainPasswordsOffline() throws { - let preferences = DatabaseTestKeyValueStore() - let secrets = DatabaseTestSecureStore() - let store = DatabaseConnectionStore(store: preferences, secureStore: secrets) - let operations = DatabaseSidecarService( - processRunner: RecordingProcessRunner(result: ProcessResult(output: "", exitCode: 1)), - executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar") - ) - let feature = DatabaseFeatureModel(operations: operations, connectionStore: store) - let plan = try DatabaseDBXImportService().parse( - data: Data(dbxPlainConnectionExport.utf8), - passphrase: nil, - existingProfiles: [] - ) - - let count = feature.importDBXConnections(plan: plan, selectedIDs: Set(plan.candidates.map(\.id))) - #expect(count == 2) - #expect(feature.profiles.count == 2) - #expect(feature.folders.count == 2) - let mysql = try #require(feature.profiles.first { $0.name == "Production MySQL" }) - #expect(store.password(for: mysql.id) == "db-secret") - #expect(mysql.folderID != nil) - let sqlite = try #require(feature.profiles.first { $0.name == "Local SQLite" }) - let localFolder = try #require(feature.folders.first { $0.id == sqlite.folderID }) - #expect(localFolder.parentID == mysql.folderID) - } - - @Test - func databaseConnectionFoldersPersistWithProfiles() throws { - let preferences = DatabaseTestKeyValueStore() - let secrets = DatabaseTestSecureStore() - let store = DatabaseConnectionStore(store: preferences, secureStore: secrets) - let folder = DatabaseConnectionFolder(name: "Development") - let profile = DatabaseProfile(name: "Local MySQL", kind: .mysql, folderID: folder.id) - - try store.saveFolders([folder]) - try store.save([profile]) - - #expect(store.loadFolders() == [folder]) - #expect(store.load().first?.folderID == folder.id) - #expect(store.load().first?.group == "") - } - - @Test - @MainActor - func databaseLegacyGroupsMigrateToFoldersAndFolderRemovalKeepsConnections() throws { - let preferences = DatabaseTestKeyValueStore() - let secrets = DatabaseTestSecureStore() - let store = DatabaseConnectionStore(store: preferences, secureStore: secrets) - let profile = DatabaseProfile(name: "Legacy MySQL", kind: .mysql, group: "Team A") - try store.save([profile]) - - let operations = DatabaseSidecarService( - processRunner: RecordingProcessRunner(result: ProcessResult(output: "", exitCode: 0)), - executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar") - ) - let feature = DatabaseFeatureModel(operations: operations, connectionStore: store) - let folder = try #require(feature.folders.first) - let migrated = try #require(feature.profiles.first) - - #expect(folder.name == "Team A") - #expect(migrated.folderID == folder.id) - #expect(migrated.group.isEmpty) - #expect(store.loadFolders() == [folder]) - - feature.removeFolder(folder) - - let remaining = try #require(feature.profiles.first) - #expect(feature.folders.isEmpty) - #expect(remaining.id == profile.id) - #expect(remaining.folderID == nil) - #expect(store.load().first?.folderID == nil) - } - - @Test - @MainActor - func databaseConnectionMovePersistsFolderAssignment() throws { - let preferences = DatabaseTestKeyValueStore() - let secrets = DatabaseTestSecureStore() - let store = DatabaseConnectionStore(store: preferences, secureStore: secrets) - let first = DatabaseConnectionFolder(name: "One") - let second = DatabaseConnectionFolder(name: "Two") - let profile = DatabaseProfile(name: "Move me", kind: .sqlite, path: "/tmp/test.sqlite") - try store.saveFolders([first, second]) - try store.save([profile]) - - let operations = DatabaseSidecarService( - processRunner: RecordingProcessRunner(result: ProcessResult(output: "", exitCode: 0)), - executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar") - ) - let feature = DatabaseFeatureModel(operations: operations, connectionStore: store) - feature.move(profile, toFolder: second.id) - - #expect(feature.profiles.first?.folderID == second.id) - #expect(store.load().first?.folderID == second.id) - } - - @Test - @MainActor - func databaseNestedFolderAndDuplicateConnectionKeepAssignments() throws { - let preferences = DatabaseTestKeyValueStore() - let secrets = DatabaseTestSecureStore() - let store = DatabaseConnectionStore(store: preferences, secureStore: secrets) - let parent = DatabaseConnectionFolder(name: "Team") - let child = DatabaseConnectionFolder(name: "Backend", parentID: parent.id) - let profile = DatabaseProfile(name: "Local", kind: .sqlite, path: "/tmp/local.sqlite", folderID: child.id) - try store.saveFolders([parent, child]) - try store.save([profile]) - try store.savePassword("password", for: profile.id) - - let feature = DatabaseFeatureModel( - operations: DatabaseSidecarService(processRunner: RecordingProcessRunner(result: ProcessResult(output: "", exitCode: 0)), executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar")), - connectionStore: store - ) - #expect(feature.createFolder(name: "Queries", parentID: parent.id)) - #expect(feature.folders.first(where: { $0.name == "Queries" })?.parentID == parent.id) - let copy = try #require(feature.duplicate(profile)) - #expect(copy.folderID == child.id) - #expect(copy.id != profile.id) - #expect(store.password(for: copy.id) == "password") - - feature.removeFolder(parent) - #expect(feature.folders.first(where: { $0.id == child.id })?.parentID == nil) - #expect(feature.profiles.first(where: { $0.id == profile.id })?.folderID == child.id) - } - - @Test - func databaseBrandIconCatalogCoversSupportedKinds() { - #expect(DatabaseKind.allCases.map(\.brandIconFilename) == [ - "mysql.svg", "mariadb.svg", "postgres.svg", "sqlite.svg", - "sqlserver.svg", "mongodb.svg", "redis.svg", "nacos.png" - ]) - } - - @Test - @MainActor - func databaseDisconnectResetsSelectedConnectionState() async throws { - let preferences = DatabaseTestKeyValueStore() - let store = DatabaseConnectionStore(store: preferences, secureStore: DatabaseTestSecureStore()) - let profile = DatabaseProfile(name: "Disconnect me", kind: .sqlite, path: "/tmp/disconnect.sqlite") - try store.save([profile]) - let runner = RecordingProcessRunner { request in - let input = try! #require(request.standardInput) - let object = try! JSONSerialization.jsonObject(with: input) as! [String: Any] - let id = object["id"] as! String - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":[]}"#, exitCode: 0) - } - let feature = DatabaseFeatureModel(operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar")), connectionStore: store) - await feature.select(profile) - #expect(feature.connectionStatus(for: profile) == .connected) - feature.disconnect(profile) - #expect(feature.connectionStatus(for: profile) == .idle) - #expect(feature.selectedProfileID == nil) - } - - @Test - @MainActor - func databaseProfileSwitchResetsProfileScopedWorkspaceState() async throws { - let preferences = DatabaseTestKeyValueStore() - let store = DatabaseConnectionStore(store: preferences, secureStore: DatabaseTestSecureStore()) - let first = DatabaseProfile(name: "First", kind: .sqlite, host: "first", path: "/tmp/first.sqlite") - let second = DatabaseProfile(name: "Second", kind: .sqlite, host: "second", path: "/tmp/second.sqlite") - try store.save([first, second]) - let tableRequestCounter = TestCounter() - let runner = RecordingProcessRunner { request in - let input = try! #require(request.standardInput) - let object = try! JSONSerialization.jsonObject(with: input) as! [String: Any] - let id = object["id"] as! String - let method = object["method"] as! String - let result: String - switch method { - case "listTables": - tableRequestCounter.value += 1 - let tableName = tableRequestCounter.value == 1 ? "first_table" : "second_table" - result = #"[{"table_name":"\#(tableName)"}]"# - default: - result = "[]" - } - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":\#(result)}"#, exitCode: 0) - } - let feature = DatabaseFeatureModel( - operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar")), - connectionStore: store - ) - - await feature.select(first) - #expect(feature.tables == ["first_table"]) - feature.addSQLTab(sql: "SELECT from_first") - #expect(feature.selectedSQLTab?.sql == "SELECT from_first") - - await feature.select(second) - #expect(feature.tables == ["second_table"]) - #expect(feature.rows.isEmpty) - #expect(feature.selectedTable == nil) - #expect(feature.sqlTabs.count == 1) - #expect(feature.selectedSQLTab?.sql.isEmpty == true) - } - - @Test - @MainActor - func databaseMySQLDatabaseSelectionPersistsAndRefreshesTables() async throws { - let preferences = DatabaseTestKeyValueStore() - let store = DatabaseConnectionStore(store: preferences, secureStore: DatabaseTestSecureStore()) - let profile = DatabaseProfile(name: "MySQL", kind: .mysql, host: "localhost", username: "root") - try store.save([profile]) - let runner = RecordingProcessRunner { request in - let input = try! #require(request.standardInput) - let object = try! JSONSerialization.jsonObject(with: input) as! [String: Any] - let id = object["id"] as! String - let method = object["method"] as! String - let result: String - switch method { - case "listDatabases": result = #"["alpha","beta"]"# - case "listTables": result = #"[{"table_name":"items"}]"# - default: result = "[]" - } - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":\#(result)}"#, exitCode: 0) - } - let feature = DatabaseFeatureModel( - operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar")), - connectionStore: store - ) - - await feature.select(profile) - #expect(feature.databaseOptions == ["alpha", "beta"]) - #expect(feature.connectionStatus(for: profile) == .connected) - await feature.selectDatabase("beta", for: profile) - - #expect(feature.selectedProfile?.database == "beta") - #expect(feature.tables == ["items"]) - #expect(store.load().first?.database == "beta") - } - - @Test - @MainActor - func databaseRefreshingTablesReloadsTheCurrentlyOpenTable() async throws { - let preferences = DatabaseTestKeyValueStore() - let store = DatabaseConnectionStore(store: preferences, secureStore: DatabaseTestSecureStore()) - let profile = DatabaseProfile(name: "SQLite", kind: .sqlite, path: "/tmp/lithe-refresh.sqlite") - try store.save([profile]) - let pageCounter = TestCounter() - let runner = RecordingProcessRunner { request in - let input = try! #require(request.standardInput) - let object = try! JSONSerialization.jsonObject(with: input) as! [String: Any] - let id = object["id"] as! String - let method = object["method"] as! String - let result: String - switch method { - case "listTables": - result = #"[{"table_name":"items"}]"# - case "describeTable": - result = #"[{"column_name":"id","data_type":"integer","column_key":"PRI"}]"# - case "pageTable": - pageCounter.value += 1 - result = #"{"columns":["id"],"rows":[{"id":\#(pageCounter.value)}],"truncated":false,"totalRows":1}"# - default: - result = "[]" - } - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":\#(result)}"#, exitCode: 0) - } - let feature = DatabaseFeatureModel( - operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar")), - connectionStore: store - ) - - await feature.select(profile) - await feature.openTable("items") - #expect(feature.errorMessage == nil) - #expect(feature.rows.first?["id"] == DatabaseValue.integer(1)) - - await feature.refreshTables() - - #expect(feature.tables == ["items"]) - #expect(feature.selectedTable == "items") - #expect(feature.rows.first?["id"] == DatabaseValue.integer(2)) - #expect(pageCounter.value == 2) - } - - @Test - @MainActor - func databaseFailedSQLClearsPreviousResultsAndRecordsFailure() async throws { - let preferences = DatabaseTestKeyValueStore() - let store = DatabaseConnectionStore(store: preferences, secureStore: DatabaseTestSecureStore()) - let profile = DatabaseProfile(name: "SQLite", kind: .sqlite, path: "/tmp/lithe-test.sqlite") - try store.save([profile]) - let runner = RecordingProcessRunner { request in - let input = try! #require(request.standardInput) - let object = try! JSONSerialization.jsonObject(with: input) as! [String: Any] - let id = object["id"] as! String - let method = object["method"] as! String - let result: String - if method == "query", let params = object["params"] as? [String: Any], - (params["sql"] as? String)?.contains("missing_table") == true { - return ProcessResult(output: #"{"id":"\#(id)","ok":false,"error":{"code":"syntax_error","message":"no such table: missing_table"}}"#, exitCode: 0) - } - if method == "query" { - result = #"{"rows":[{"value":1}],"columns":["value"],"truncated":false}"# - } else { - result = "[]" - } - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":\#(result)}"#, exitCode: 0) - } - let feature = DatabaseFeatureModel( - operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar")), - connectionStore: store - ) - - await feature.select(profile) - feature.addSQLTab(sql: "SELECT 1") - let tabID = try #require(feature.selectedSQLTabID) - await feature.runSQL(in: tabID) - #expect(feature.selectedSQLTab?.result?.rows.count == 1) - #expect(feature.selectedSQLTab?.execution != nil) - - feature.updateSQL("SELECT * FROM missing_table", in: tabID) - await feature.runSQL(in: tabID) - #expect(feature.selectedSQLTab?.result == nil) - #expect(feature.selectedSQLTab?.execution?.rowsReturned == nil) - #expect(feature.selectedSQLTab?.execution != nil) - #expect(feature.selectedSQLTab?.statementExecutions.last?.errorMessage?.contains("missing_table") == true) - #expect(feature.sqlHistory.count == 2) - #expect(feature.selectedSQLTab?.rowsAffected == nil) - #expect(feature.selectedSQLTab?.errorMessage != nil) - - // Empty input is rejected before execution and must not create another history entry. - feature.updateSQL(" ", in: tabID) - await feature.runSQL(in: tabID) - #expect(feature.selectedSQLTab?.execution == nil) - #expect(feature.sqlHistory.count == 2) - } - - @Test - @MainActor - func databaseRedisStatusRecoversAfterSuccessfulKeyLoad() async throws { - let preferences = DatabaseTestKeyValueStore() - let store = DatabaseConnectionStore(store: preferences, secureStore: DatabaseTestSecureStore()) - let profile = DatabaseProfile(name: "Redis", kind: .redis, host: "127.0.0.1", port: 6379, database: "0") - try store.save([profile]) - let runner = RecordingProcessRunner { request in - let input = try! #require(request.standardInput) - let object = try! JSONSerialization.jsonObject(with: input) as! [String: Any] - let id = object["id"] as! String - let method = object["method"] as! String - if method == "redisScan" { - return ProcessResult(output: #"{"id":"\#(id)","ok":false,"error":{"code":"redis_error","message":"NOAUTH"}}"#, exitCode: 0) - } - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":{"key":"session:42","type":"string","ttl":60,"size":9,"stringValue":"ready","hashEntries":[]}}"#, exitCode: 0) - } - let feature = DatabaseFeatureModel( - operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar")), - connectionStore: store - ) - - await feature.select(profile) - await feature.loadRedisKeys(pattern: "*") - #expect(feature.connectionStatus(for: profile) == .failed) - await feature.loadRedisKey("session:42") - #expect(feature.connectionStatus(for: profile) == .connected) - #expect(feature.redisSelectedKey?.stringValue == "ready") - } - - @Test - @MainActor - func databaseRedisRescanFailureDoesNotLeaveOldKeysVisible() async throws { - let preferences = DatabaseTestKeyValueStore() - let store = DatabaseConnectionStore(store: preferences, secureStore: DatabaseTestSecureStore()) - let profile = DatabaseProfile(name: "Redis", kind: .redis, host: "127.0.0.1", port: 6379, database: "0") - try store.save([profile]) - let calls = TestCounter() - let runner = RecordingProcessRunner { request in - calls.value += 1 - let input = try! #require(request.standardInput) - let object = try! JSONSerialization.jsonObject(with: input) as! [String: Any] - let id = object["id"] as! String - if calls.value == 1 { - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":{"keys":[{"key":"session:42","type":"string","ttl":60,"size":9}],"nextCursor":"0"}}"#, exitCode: 0) - } - return ProcessResult(output: #"{"id":"\#(id)","ok":false,"error":{"code":"redis_error","message":"NOAUTH"}}"#, exitCode: 0) - } - let feature = DatabaseFeatureModel( - operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar")), - connectionStore: store - ) - - await feature.select(profile) - await feature.loadRedisKeys(pattern: "*") - #expect(feature.redisKeys.map(\.key) == ["session:42"]) - await feature.loadRedisKeys(pattern: "*") - #expect(feature.redisKeys.isEmpty) - #expect(feature.errorMessage?.contains("NOAUTH") == true) - #expect(feature.connectionStatus(for: profile) == .failed) - } - - @Test - @MainActor - func databaseRedisWriteRefreshesKeySummaryAndDetail() async throws { - let preferences = DatabaseTestKeyValueStore() - let store = DatabaseConnectionStore(store: preferences, secureStore: DatabaseTestSecureStore()) - let profile = DatabaseProfile(name: "Redis", kind: .redis, host: "127.0.0.1", port: 6379, database: "0") - try store.save([profile]) - let scanCount = TestCounter() - let runner = RecordingProcessRunner { request in - let input = try! #require(request.standardInput) - let object = try! JSONSerialization.jsonObject(with: input) as! [String: Any] - let id = object["id"] as! String - let method = object["method"] as! String - switch method { - case "redisScan": - scanCount.value += 1 - let key = scanCount.value == 1 ? "session:old" : "session:new" - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":{"keys":[{"key":"\#(key)","type":"string","ttl":60,"size":9}],"nextCursor":"0"}}"#, exitCode: 0) - case "redisSetString": - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":{}}"#, exitCode: 0) - case "redisGetKey": - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":{"key":"session:new","type":"string","ttl":120,"size":12,"stringValue":"updated","hashEntries":[]}}"#, exitCode: 0) - default: - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":{}}"#, exitCode: 0) - } - } - let feature = DatabaseFeatureModel( - operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar")), - connectionStore: store - ) - - await feature.select(profile) - await feature.loadRedisKeys(pattern: "session:*") - #expect(feature.redisKeys.first?.key == "session:old") - - #expect(await feature.saveRedisString(key: "session:new", value: "updated", ttl: 120, confirmed: true)) - #expect(feature.redisKeys.first?.key == "session:new") - #expect(feature.redisSelectedKey?.key == "session:new") - #expect(feature.redisSelectedKey?.ttl == 120) - #expect(scanCount.value == 2) - } - - @Test - @MainActor - func databaseNacosPublishRefreshesConfigListAndDetail() async throws { - let preferences = DatabaseTestKeyValueStore() - let store = DatabaseConnectionStore(store: preferences, secureStore: DatabaseTestSecureStore()) - let profile = DatabaseProfile(name: "Nacos", kind: .nacos, host: "127.0.0.1", port: 8848, database: "public") - try store.save([profile]) - let listCount = TestCounter() - let runner = RecordingProcessRunner { request in - let input = try! #require(request.standardInput) - let object = try! JSONSerialization.jsonObject(with: input) as! [String: Any] - let id = object["id"] as! String - let method = object["method"] as! String - switch method { - case "nacosListConfigs": - listCount.value += 1 - let dataID = listCount.value == 1 ? "old.yaml" : "new.yaml" - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":{"items":[{"dataId":"\#(dataID)","group":"DEFAULT_GROUP","namespace":"public","type":"yaml","md5":"abc"}],"totalCount":1}}"#, exitCode: 0) - case "nacosPublishConfig": - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":{}}"#, exitCode: 0) - case "nacosGetConfig": - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":{"dataId":"new.yaml","group":"DEFAULT_GROUP","namespace":"public","type":"yaml","md5":"def","content":"updated"}}"#, exitCode: 0) - default: - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":{}}"#, exitCode: 0) - } - } - let feature = DatabaseFeatureModel( - operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar")), - connectionStore: store - ) - - await feature.select(profile) - await feature.loadNacosConfigs(dataId: "", group: "") - #expect(feature.nacosConfigs.first?.dataId == "old.yaml") - - #expect(await feature.publishNacosConfig(dataId: "new.yaml", group: "DEFAULT_GROUP", content: "updated", type: "yaml", confirmed: true)) - #expect(feature.nacosConfigs.first?.dataId == "new.yaml") - #expect(feature.nacosSelectedConfig?.dataId == "new.yaml") - #expect(feature.nacosSelectedConfig?.content == "updated") - #expect(listCount.value == 2) - } - - @Test - @MainActor - func databaseConnectionStatusTracksSuccessfulConnection() async throws { - let preferences = DatabaseTestKeyValueStore() - let store = DatabaseConnectionStore(store: preferences, secureStore: DatabaseTestSecureStore()) - let profile = DatabaseProfile(name: "Status success", kind: .sqlite, path: "/tmp/status-success.sqlite") - try store.save([profile]) - - let runner = RecordingProcessRunner { request in - let input = try! #require(request.standardInput) - let object = try! JSONSerialization.jsonObject(with: input) as! [String: Any] - let id = object["id"] as! String - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":[]}"#, exitCode: 0) - } - let feature = DatabaseFeatureModel( - operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar")), - connectionStore: store - ) - - await feature.select(profile) - - #expect(feature.connectionStatus(for: profile) == .connected) - #expect(feature.connectedProfileCount == 1) - } - - @Test - @MainActor - func databaseConnectionStatusRetainsFailure() async throws { - let preferences = DatabaseTestKeyValueStore() - let store = DatabaseConnectionStore(store: preferences, secureStore: DatabaseTestSecureStore()) - let profile = DatabaseProfile(name: "Status failure", kind: .sqlite, path: "/tmp/status-failure.sqlite") - try store.save([profile]) - - let feature = DatabaseFeatureModel( - operations: DatabaseSidecarService( - processRunner: RecordingProcessRunner(result: ProcessResult(output: "connection refused", exitCode: 1)), - executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar") - ), - connectionStore: store - ) - - await feature.select(profile) - - #expect(feature.connectionStatus(for: profile) == .failed) - #expect(feature.connectedProfileCount == 0) - #expect(feature.errorMessage != nil) - } - - @Test - func databaseSQLBackupUsesStdinAndExtendedTimeout() throws { - let runner = RecordingProcessRunner { request in - let input = try! #require(request.standardInput) - let object = try! JSONSerialization.jsonObject(with: input) as! [String: Any] - let id = object["id"] as! String - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":{"encoding":"base64","data":"U0VMRUNUIDE7"}}"#, exitCode: 0) - } - let service = DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar")) - let data = try service.exportSQL(connection: DatabaseConnection(kind: .sqlite, path: "/tmp/test.sqlite")) - - #expect(String(decoding: data, as: UTF8.self) == "SELECT 1;") - #expect(runner.requests[0].arguments.isEmpty) - #expect(runner.requests[0].timeoutMilliseconds == 120_000) - let requestText = String(decoding: try #require(runner.requests[0].standardInput), as: UTF8.self) - #expect(requestText.contains(#""method":"exportSql""#)) - } - - @Test - func databaseFileBackupUsesPathProtocolAndExtendedTimeout() throws { - let runner = RecordingProcessRunner { request in - let input = try! #require(request.standardInput) - let object = try! JSONSerialization.jsonObject(with: input) as! [String: Any] - let id = object["id"] as! String - let method = object["method"] as! String - let result = method == "exportSqlToFile" - ? #"{"path":"/tmp/backup.sql","byteCount":12,"sha256":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}"# - : #"{"rowsAffected":1}"# - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":\#(result)}"#, exitCode: 0) - } - let service = DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar")) - let output = try service.exportSQLToFile(connection: DatabaseConnection(kind: .sqlite, path: "/tmp/test.sqlite"), outputURL: URL(fileURLWithPath: "/tmp/backup.sql")) - #expect(output.byteCount == 12) - #expect(runner.requests[0].timeoutMilliseconds == 120_000) - let requestText = String(decoding: try #require(runner.requests[0].standardInput), as: UTF8.self) - #expect(requestText.contains(#""method":"exportSqlToFile""#)) - #expect(requestText.contains("outputPath")) - - _ = try service.importSQLFile(connection: DatabaseConnection(kind: .sqlite, path: "/tmp/test.sqlite"), fileURL: URL(fileURLWithPath: "/tmp/backup.sql"), confirmed: true, allowWrite: true) - #expect(runner.requests[1].timeoutMilliseconds == 120_000) - - _ = try service.restoreSQLFile(connection: DatabaseConnection(kind: .sqlite, path: "/tmp/test.sqlite"), fileURL: URL(fileURLWithPath: "/tmp/backup.sql"), confirmed: true, allowWrite: true) - #expect(runner.requests[2].timeoutMilliseconds == 120_000) - let restoreRequest = String(decoding: try #require(runner.requests[2].standardInput), as: UTF8.self) - #expect(restoreRequest.contains(#""method":"restoreSqlFile""#)) - #expect(DatabaseSQLExportOptions().limit == 0) - } - - @Test - func databaseSQLAnalyzerProtectsUnqualifiedAndDestructiveStatements() { - let unsafeUpdate = DatabaseSQLAnalyzer.analyze("UPDATE users SET active = 0") - #expect(unsafeUpdate.kind == .mutation) - #expect(unsafeUpdate.requiresConfirmation) - #expect(unsafeUpdate.warning?.contains("WHERE") == true) - - let safeDelete = DatabaseSQLAnalyzer.analyze("delete from users where id = 1") - #expect(safeDelete.kind == .mutation) - #expect(!safeDelete.requiresConfirmation) - - let drop = DatabaseSQLAnalyzer.analyze("-- review first\nDROP TABLE `temporary users`") - #expect(drop.kind == .definition) - #expect(drop.requiresConfirmation) - - let query = DatabaseSQLAnalyzer.analyze("SELECT 'UPDATE users SET x = 1' AS example") - #expect(query.kind == .query) - #expect(!query.requiresConfirmation) - - let invalid = DatabaseSQLAnalyzer.analyze("SELEC 1") - #expect(invalid.kind == .unknown) - #expect(!invalid.requiresConfirmation) - #expect(invalid.warning?.contains("not recognized") == true) - } - - @Test - func databaseSQLAnalyzerSplitsBatchesWithoutBreakingQuotedSemicolons() { - let analysis = DatabaseSQLAnalyzer.analyze("SELECT ';' AS marker; -- keep this comment\nSELECT 2;") - - #expect(analysis.canExecute) - #expect(analysis.statementCount == 2) - #expect(analysis.kind == .batch) - #expect(analysis.statements.first?.contains("';'") == true) - #expect(analysis.statements.last?.contains("SELECT 2") == true) - - let escapedQuote = DatabaseSQLAnalyzer.analyze(#"SELECT 'a\';b'; SELECT 2"#) - #expect(escapedQuote.statementCount == 2) - - let commentsOnly = DatabaseSQLAnalyzer.analyze("-- review later\n/* no statement */") - #expect(!commentsOnly.canExecute) - #expect(commentsOnly.statementCount == 0) - } - - @Test - @MainActor - func databaseSQLBatchRunsInOrderAndAggregatesResults() async throws { - let preferences = DatabaseTestKeyValueStore() - let store = DatabaseConnectionStore(store: preferences, secureStore: DatabaseTestSecureStore()) - let profile = DatabaseProfile(name: "SQLite", kind: .sqlite, path: "/tmp/lithe-batch.sqlite") - let recoveryRoot = FileManager.default.temporaryDirectory - .appendingPathComponent("lithe-batch-recovery-\(UUID().uuidString)") - defer { try? FileManager.default.removeItem(at: recoveryRoot) } - try store.save([profile]) - let runner = RecordingProcessRunner { request in - let input = try! #require(request.standardInput) - let object = try! JSONSerialization.jsonObject(with: input) as! [String: Any] - let id = object["id"] as! String - let method = object["method"] as! String - let params = object["params"] as? [String: Any] - let sql = params?["sql"] as? String ?? "" - switch method { - case "exportSqlToFile": - if let path = params?["outputPath"] as? String { - try? Data().write(to: URL(fileURLWithPath: path)) - } - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":{"path":"/tmp/backup.sql","byteCount":0,"sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}}"#, exitCode: 0) - case "query": - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":{"rows":[{"value":1}],"columns":["value"],"truncated":false}}"#, exitCode: 0) - case "execute": - let affected = sql.contains("UPDATE") ? 3 : 2 - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":{"rowsAffected":\#(affected)}}"#, exitCode: 0) - default: - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":[]}"#, exitCode: 0) - } - } - let feature = DatabaseFeatureModel( - operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar")), - connectionStore: store, - recoveryStore: MacDatabaseRecoveryStore(rootURL: recoveryRoot), - fileStorage: MacFileStorage() - ) - - await feature.select(profile) - feature.addSQLTab(sql: "INSERT INTO items VALUES (1); SELECT 1; UPDATE items SET value = 2 WHERE id = 1;") - let tabID = try #require(feature.selectedSQLTabID) - await feature.runSQL(in: tabID, confirmedRisk: true) - - let sqlRequests = runner.requests.compactMap { request -> (String, String)? in - guard let input = request.standardInput, - let object = try? JSONSerialization.jsonObject(with: input) as? [String: Any], - let method = object["method"] as? String, - method == "query" || method == "execute", - let params = object["params"] as? [String: Any], - let sql = params["sql"] as? String else { return nil } - return (method, sql) - } - #expect(sqlRequests.map(\.0) == ["execute", "query", "execute"]) - #expect(sqlRequests.map(\.1) == [ - "INSERT INTO items\nVALUES(1)", - "SELECT\n 1", - "UPDATE items\nSET value = 2\nWHERE id = 1" - ]) - #expect(feature.selectedSQLTab?.result?.rows.count == 1) - #expect(feature.selectedSQLTab?.rowsAffected == 5) - #expect(feature.selectedSQLTab?.execution?.rowsReturned == 1) - #expect(feature.sqlHistory.first?.sql.contains("SELECT\n 1") == true) - } - - @Test - @MainActor - func databaseSQLBatchStopsAfterTheFirstFailedStatement() async throws { - let preferences = DatabaseTestKeyValueStore() - let store = DatabaseConnectionStore(store: preferences, secureStore: DatabaseTestSecureStore()) - let profile = DatabaseProfile(name: "SQLite", kind: .sqlite, path: "/tmp/lithe-batch-failure.sqlite") - try store.save([profile]) - let runner = RecordingProcessRunner { request in - let input = try! #require(request.standardInput) - let object = try! JSONSerialization.jsonObject(with: input) as! [String: Any] - let id = object["id"] as! String - let method = object["method"] as! String - let params = object["params"] as? [String: Any] - let sql = params?["sql"] as? String ?? "" - if method == "query", sql.contains("bad") { - return ProcessResult(output: #"{"id":"\#(id)","ok":false,"error":{"code":"syntax_error","message":"near bad"}}"#, exitCode: 0) - } - if method == "query" { - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":{"rows":[{"value":1}],"columns":["value"],"truncated":false}}"#, exitCode: 0) - } - return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":[]}"#, exitCode: 0) - } - let feature = DatabaseFeatureModel( - operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar")), - connectionStore: store - ) - - await feature.select(profile) - feature.addSQLTab(sql: "SELECT 1; SELECT bad; SELECT 3;") - let tabID = try #require(feature.selectedSQLTabID) - await feature.runSQL(in: tabID) - - let querySQL = runner.requests.compactMap { request -> String? in - guard let input = request.standardInput, - let object = try? JSONSerialization.jsonObject(with: input) as? [String: Any], - object["method"] as? String == "query", - let params = object["params"] as? [String: Any] else { return nil } - return params["sql"] as? String - } - #expect(querySQL == ["SELECT\n 1", "SELECT\n bad"]) - #expect(feature.selectedSQLTab?.errorMessage?.contains("Statement 2 failed") == true) - #expect(feature.sqlHistory.count == 1) - #expect(feature.sqlHistory.first?.sql == feature.selectedSQLTab?.sql) - #expect(feature.selectedSQLTab?.statementExecutions.count == 2) - #expect(feature.selectedSQLTab?.statementExecutions.last?.errorMessage != nil) - } - - @Test - func databaseQueryResultPreservesProtocolColumnOrder() throws { - let data = Data(#"{"columns":["z_col","a_col"],"rows":[{"z_col":1,"a_col":2}],"truncated":false}"#.utf8) - let result = try JSONDecoder().decode(DatabaseQueryResult.self, from: data) - #expect(result.columns == ["z_col", "a_col"]) - #expect(result.rows.first?["z_col"] == .integer(1)) - } - - @Test - func databaseMongoQueryFixtureDecodesExtendedJSONWithoutLosingDocuments() throws { - let data = Data(#"{"rows":[{"_id":{"$oid":"507f1f77bcf86cd799439011"},"empty":"","null":null,"nested":{"base64":"AAEC"},"array":[1,{"$date":"2024-01-01T00:00:00Z"}],"binary":{"$binary":{"base64":"AAEC","subType":"00"}}}],"truncated":false}"#.utf8) - let result = try JSONDecoder().decode(DatabaseQueryResult.self, from: data) - let row = try #require(result.rows.first) - #expect(row["empty"] == .string("")) - #expect(row["null"] == .null) - #expect(row["_id"] == .object(["$oid": .string("507f1f77bcf86cd799439011")])) - #expect(row["nested"] == .object(["base64": .string("AAEC")])) - #expect(row["binary"] == .object(["$binary": .object(["base64": .string("AAEC"), "subType": .string("00")])])) - } - - @Test - func databaseValuePreservesTaggedDecimalAndBinaryValues() throws { - let data = Data(#"[{"decimal":"0.00"},{"binary":"AAEC"}]"#.utf8) - let values = try JSONDecoder().decode([DatabaseValue].self, from: data) - #expect(values == [.decimal("0.00"), .binary(Data([0, 1, 2]))]) - let encoded = try JSONEncoder().encode(values) - #expect(String(decoding: encoded, as: UTF8.self).contains(#""decimal":"0.00""#)) - #expect(String(decoding: encoded, as: UTF8.self).contains(#""binary":"AAEC""#)) - let mongoObject = try JSONDecoder().decode(DatabaseValue.self, from: Data(#"{"base64":"AAEC"}"#.utf8)) - #expect(mongoObject == .object(["base64": .string("AAEC")])) - } - - @Test - func databaseSQLFormatterPreservesQuotedTextAndWordCase() { - let formatted = DatabaseSQLFormatter.format("select name, 'a b' from users where id=1;") - #expect(formatted.contains("select\n name, 'a b' from users\nwhere id=1;")) - let withComment = DatabaseSQLFormatter.format("select -- keep this text\nfrom users") - #expect(withComment.contains("-- keep this text")) - #expect(withComment.contains("\nfrom users")) - } - - @Test(arguments: [#"SELECT 'C:\', 'a,b';"#, #"SELECT E'it\'s FROM here', 'a,b';"#, #"UPDATE settings SET path='C:\', note='a,b';"#]) - func databaseSQLFormatterLeavesSessionDependentEscapesVerbatim(sql: String) { - #expect(DatabaseSQLFormatter.format(sql) == sql) - } - - @Test(arguments: ["\u{0301}a,b", "\u{0301}a''b, c", "\u{FE0F}a,b; DROP TABLE t; --"]) - func databaseSQLScannersPreserveCombiningMarksInsideLiterals(literal: String) { - let sql = "SELECT '\(literal)' AS value;" - let formatted = DatabaseSQLFormatter.format(sql) - #expect(formatted.contains("'\(literal)'")) - #expect(DatabaseSQLFormatter.format(formatted) == formatted) - let analysis = DatabaseSQLAnalyzer.analyze(formatted) - #expect(analysis.kind == .query) - #expect(analysis.statementCount == 1) - #expect(analysis.statements.first?.contains("'\(literal)'") == true) - } - - @Test(arguments: [ - "select a>=1, b<>2, c!=3, 1.25, t.column::text from t;", - #"select E'it\'s from here', X'AB', [a]]b], `from`, 'where; 😀' from t;"#, - "select 'a'\n'b' from t;", - "select t.order, t.from, t.select from t;", - "select /* outer /* nested */ comment */ a from t;", - "select a # keep FROM here\nfrom t;", - "select $$begin SELECT x FROM t; end$$;" - ]) - func databaseSQLFormatterPreservesLiteralAndOperatorTokens(sql: String) { - let formatted = DatabaseSQLFormatter.format(sql) - #expect(DatabaseSQLFormatter.format(formatted) == formatted) - if sql.contains("$$") { #expect(formatted == sql) } - else { - // Layout may change whitespace/case, but never an operator or a protected literal/comment. - for token in [">=", "<>", "!=", "1.25", "t.column::text", "t.order", "t.from", "t.select", #"E'it\'s from here'"#, "X'AB'", "[a]]b]", "`from`", "'where; 😀'", "'a'\n'b'", "/* outer /* nested */ comment */", "# keep FROM here"] where sql.contains(token) { - #expect(formatted.contains(token)) - } - } - } - - @Test - func databaseSQLHistoryIsBoundedAndKeepsOnlyProfileReferences() throws { - let preferences = DatabaseTestKeyValueStore() - let secrets = DatabaseTestSecureStore() - let store = DatabaseConnectionStore(store: preferences, secureStore: secrets) - let profileID = UUID() - - for index in 0..<105 { - try store.appendSQLHistory(DatabaseSQLHistoryEntry( - profileID: profileID, - sql: "SELECT \(index)", - kind: .query, - executedAt: Date(timeIntervalSince1970: TimeInterval(index)), - durationMilliseconds: index - )) - } - - let history = store.loadSQLHistory() - #expect(history.count == 100) - #expect(history.first?.sql == "SELECT 104") - #expect(history.allSatisfy { $0.profileID == profileID }) - let encoded = try #require(preferences.data(forKey: "database.sql-history.v1")) - #expect(!String(decoding: encoded, as: UTF8.self).contains("password")) - } - - @Test - func databaseRecoveryStoreRoundTripsCompressedSnapshotsAndAudit() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent("lithe-recovery-\(UUID().uuidString)") - defer { try? FileManager.default.removeItem(at: root) } - let store = MacDatabaseRecoveryStore(rootURL: root) - let profileID = UUID() - let snapshot = Data(repeating: 65, count: 128 * 1_024) - let point = try store.createRecoveryPoint(profileID: profileID, reason: "test", data: snapshot) - - #expect(point.profileID == profileID) - #expect(point.isCompressed) - #expect(point.sha256.count == 64) - #expect(try store.data(for: point) == snapshot) - - let audit = DatabaseAuditEntry(id: UUID(), profileID: profileID, action: "test", summary: "snapshot", createdAt: Date(), recoveryPointID: point.id, rowsAffected: nil, succeeded: true, errorMessage: nil) - try store.appendAudit(audit) - let loadedAudit = try #require(store.auditEntries(for: profileID).first) - #expect(loadedAudit.id == audit.id) - #expect(loadedAudit.profileID == profileID) - #expect(loadedAudit.recoveryPointID == point.id) - #expect(loadedAudit.summary == "snapshot") - - let event = DatabaseExecutionEvent( - id: UUID(), profileID: profileID, profileName: "Test DB", source: .sql, - operation: "query", startedAt: Date(timeIntervalSince1970: 1_700_000_000), durationMilliseconds: 12, - status: .failed, rowsReturned: nil, rowsAffected: nil, errorMessage: "syntax error" - ) - try store.appendExecutionEvent(event) - let loadedEvent = try #require(store.executionEvents(for: profileID).first) - #expect(loadedEvent == event) - try store.deleteExecutionEvents(for: profileID) - #expect(store.executionEvents(for: profileID).isEmpty) - } - - @Test - func databaseRecoveryStoreCopiesAndValidatesFileBackups() throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent("lithe-recovery-file-\(UUID().uuidString)") - let source = root.appendingPathComponent("source.sql") - defer { try? FileManager.default.removeItem(at: root) } - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - let contents = Data("CREATE TABLE items (id INTEGER);\n".utf8) - try contents.write(to: source) - - let store = MacDatabaseRecoveryStore(rootURL: root.appendingPathComponent("store")) - let point = try store.createRecoveryPoint(profileID: UUID(), reason: "file", fileURL: source) - #expect(!point.isCompressed) - #expect(point.originalByteCount == contents.count) - #expect(try store.fileURL(for: point).lastPathComponent == point.fileName) - #expect(try store.data(for: point) == contents) - - try Data("tampered".utf8).write(to: root.appendingPathComponent("store").appendingPathComponent(point.fileName)) - #expect(throws: CocoaError(.fileReadCorruptFile)) { try store.data(for: point) } - } - - @Test - func databaseProfilesDecodeLegacyPreferencesWithSafeDefaults() throws { - let legacy = """ - [{"id":"00000000-0000-0000-0000-000000000001","name":"Legacy","kind":"mysql","host":"127.0.0.1","port":3306,"username":"root","database":"app","path":"","ssl":false}] - """ - let preferences = DatabaseTestKeyValueStore() - let secrets = DatabaseTestSecureStore() - preferences.set(Data(legacy.utf8), forKey: "database.profiles.v1") - let store = DatabaseConnectionStore(store: preferences, secureStore: secrets) - let profile = try #require(store.load().first) - - #expect(profile.readOnly == false) - #expect(profile.productionProtection == false) - #expect(profile.maskSensitiveFields == false) - #expect(profile.sensitiveColumnPatterns.contains("password")) - } - - @Test - func databaseSensitiveFieldMaskerMasksConfiguredColumnsWithoutChangingNulls() { - let rows: [DatabaseRow] = [[ - "id": .integer(7), - "email": .string("user@example.com"), - "api_token": .string("secret-value"), - "password": .null - ]] - - let masked = DatabaseSensitiveFieldMasker.mask( - rows: rows, - enabled: true, - patterns: ["token", "password"] - ) - - #expect(masked[0]["id"] == .integer(7)) - #expect(masked[0]["email"] == .string("user@example.com")) - #expect(masked[0]["api_token"] == .string("******")) - #expect(masked[0]["password"] == .null) - #expect(DatabaseSensitiveFieldMasker.mask(rows: rows, enabled: false, patterns: ["token"]) == rows) - } - - @Test - func databaseSchemaDiffReportsTableColumnAndDestructiveChanges() { - let source = DatabaseSchemaSnapshot( - profileID: UUID(), - profileName: "Source", - kind: .sqlite, - schema: "", - tables: [DatabaseSchemaTableSnapshot( - name: "users", - columns: [ - DatabaseSchemaColumnSnapshot(name: "id", dataType: "INTEGER", isNullable: false, defaultValue: nil, isPrimaryKey: true), - DatabaseSchemaColumnSnapshot(name: "email", dataType: "TEXT", isNullable: false, defaultValue: nil, isPrimaryKey: false), - DatabaseSchemaColumnSnapshot(name: "active", dataType: "INTEGER", isNullable: true, defaultValue: "1", isPrimaryKey: false) - ], - indexes: [], - foreignKeys: [] - )] - ) - let target = DatabaseSchemaSnapshot( - profileID: UUID(), - profileName: "Target", - kind: .sqlite, - schema: "", - tables: [DatabaseSchemaTableSnapshot( - name: "users", - columns: [ - DatabaseSchemaColumnSnapshot(name: "id", dataType: "INTEGER", isNullable: false, defaultValue: nil, isPrimaryKey: true), - DatabaseSchemaColumnSnapshot(name: "name", dataType: "TEXT", isNullable: true, defaultValue: nil, isPrimaryKey: false) - ], - indexes: [], - foreignKeys: [] - )] - ) - - let diff = DatabaseSchemaDiffEngine.compare(source: source, target: target) - #expect(diff.items.map(\.kind).contains(.addColumn)) - #expect(diff.items.map(\.kind).contains(.dropColumn)) - #expect(diff.requiresConfirmation) - #expect(diff.migrationSQL.contains("ADD COLUMN \"email\" TEXT NOT NULL")) - #expect(diff.migrationSQL.contains("DROP COLUMN \"name\"")) - } - - @Test - func databaseSchemaDiffCreatesReferencedTablesBeforeIndexes() { - let source = DatabaseSchemaSnapshot( - profileID: UUID(), - profileName: "Source", - kind: .sqlite, - schema: "", - tables: [ - DatabaseSchemaTableSnapshot( - name: "posts", - columns: [ - DatabaseSchemaColumnSnapshot(name: "id", dataType: "INTEGER", isNullable: false, defaultValue: nil, isPrimaryKey: true), - DatabaseSchemaColumnSnapshot(name: "user_id", dataType: "INTEGER", isNullable: false, defaultValue: nil, isPrimaryKey: false) - ], - indexes: [DatabaseSchemaIndexSnapshot(name: "idx_posts_user", definition: "CREATE INDEX \"idx_posts_user\" ON \"posts\" (\"user_id\")")], - foreignKeys: [DatabaseSchemaForeignKeySnapshot(name: "0", column: "user_id", referencedTable: "users", referencedColumn: "id")] - ), - DatabaseSchemaTableSnapshot( - name: "users", - columns: [DatabaseSchemaColumnSnapshot(name: "id", dataType: "INTEGER", isNullable: false, defaultValue: nil, isPrimaryKey: true)], - indexes: [], - foreignKeys: [] - ) - ] - ) - let target = DatabaseSchemaSnapshot(profileID: UUID(), profileName: "Target", kind: .sqlite, schema: "", tables: []) - - let diff = DatabaseSchemaDiffEngine.compare(source: source, target: target) - let createdTables = diff.items.filter { $0.kind == .addTable }.map(\.table) - let indexPosition = try! #require(diff.items.firstIndex { $0.id == "add-index:posts:idx_posts_user" }) - let postPosition = try! #require(diff.items.firstIndex { $0.id == "add-table:posts" }) - - #expect(createdTables == ["users", "posts"]) - #expect(indexPosition > postPosition) - #expect(diff.items[postPosition].sql.contains("FOREIGN KEY (\"user_id\") REFERENCES \"users\" (\"id\")")) - } - @Test func updateDownloadProgressReportsKnownAndUnknownTotals() { let progress = UpdateDownloadProgress(downloadedBytes: 512, totalBytes: 2_048) @@ -2659,6 +1425,103 @@ struct LitheCoreLogicTests { ) } + @Test + func hiddenProjectConfigurationFolderStaysWatchedButOtherHiddenPathsAreDropped() { + let workspace = URL(fileURLWithPath: "/tmp/lithe-visibility-watched-configuration") + let rules = FileVisibilityRules(hiddenDirectoryNames: [], hiddenFilePatterns: [".lithe/*", "**/.lithe/*"]) + let configuration = DirectoryWatchConfiguration(workspaceRoot: workspace, gitContext: nil) + let watcher = MacDirectoryWatcher(configuration: configuration, visibilityRules: rules) { _ in } + let projectJSON = workspace.appendingPathComponent(".lithe/project.json").path + let dependencyIndex = workspace.appendingPathComponent(".lithe/dependencies/index.json").path + let source = workspace.appendingPathComponent("src/App.java").path + let ignored = [ + workspace.appendingPathComponent(".lithe/run/local.json").path, + workspace.appendingPathComponent("modules/a/.lithe/project.json").path, + workspace.appendingPathComponent("dist/bundle.js").path, + ] + let paths = [projectJSON, dependencyIndex, source] + ignored + + // Hiding `.lithe` removes it from the tree, yet its events still reach + // the workspace so edited run configurations reload project services. + let batch = watcher.classify( + paths: paths, + eventFlags: Array(repeating: FSEventStreamEventFlags(0), count: paths.count) + ) + #expect(batch.workspacePaths == [dependencyIndex, projectJSON, source].sorted()) + + #expect(rules.isHiddenPath(workspace.appendingPathComponent(".lithe/project.json"), relativeTo: workspace)) + #expect(rules.isWatchedDespiteHiding(workspace.appendingPathComponent(".lithe"), relativeTo: workspace)) + #expect(!rules.isWatchedDespiteHiding(workspace.appendingPathComponent("src/App.java"), relativeTo: workspace)) + #expect(!FileVisibilityRules.default.isHiddenPath(workspace.appendingPathComponent(".lithe"), relativeTo: workspace)) + + // By default `.lithe` follows the ordinary visibility logic (shown in the + // tree) and is watched; every way of hiding it only changes the display. + let variants: [FileVisibilityRules] = [ + .default, + FileVisibilityRules(hiddenDirectoryNames: [".lithe"], hiddenFilePatterns: []), + FileVisibilityRules(hiddenDirectoryNames: [], hiddenFilePatterns: [".lithe/"]), + FileVisibilityRules(hiddenDirectoryNames: [], hiddenFilePatterns: [".lithe/*"]), + FileVisibilityRules(hiddenDirectoryNames: [], hiddenFilePatterns: [".lithe/**"]), + FileVisibilityRules(hiddenDirectoryNames: [], hiddenFilePatterns: [".*/"]), + FileVisibilityRules(hiddenDirectoryNames: [], hiddenFilePatterns: ["*.json"]), + ] + let rootConfiguration = [projectJSON, dependencyIndex] + for variant in variants { + let variantWatcher = MacDirectoryWatcher(configuration: configuration, visibilityRules: variant) { _ in } + let variantPaths = rootConfiguration + [workspace.appendingPathComponent(".lithe/run/local.json").path] + let variantBatch = variantWatcher.classify( + paths: variantPaths, + eventFlags: Array(repeating: FSEventStreamEventFlags(0), count: variantPaths.count) + ) + #expect(variantBatch.workspacePaths == rootConfiguration.sorted(), "\(variant)") + } + #expect(!FileVisibilityRules.default.isHiddenPath(workspace.appendingPathComponent(".lithe/project.json"), relativeTo: workspace)) + } + + @Test + func folderPatternsHideWholeFoldersFromTheProjectRoot() { + let root = URL(fileURLWithPath: "/tmp/lithe-visibility-folder-pattern-tests") + func hidden(_ path: String, _ patterns: [String], isDirectory: Bool) -> Bool { + FileVisibilityRules(hiddenDirectoryNames: [], hiddenFilePatterns: patterns) + .isHidden(root.appendingPathComponent(path), relativeTo: root, isDirectory: isDirectory) + } + + // `name/`, `name/*` and `name/**` all hide the root folder, its contents + // and nothing else: no empty folder row is left behind. + for pattern in [".lithe/", ".lithe/*", ".lithe/**"] { + #expect(hidden(".lithe", [pattern], isDirectory: true), "\(pattern)") + #expect(hidden(".lithe/dependencies", [pattern], isDirectory: true), "\(pattern)") + #expect(hidden(".lithe/dependencies/index.json", [pattern], isDirectory: false), "\(pattern)") + #expect(!hidden(".lithe", [pattern], isDirectory: false), "\(pattern)") + #expect(!hidden(".lithe-notes.md", [pattern], isDirectory: false), "\(pattern)") + #expect(!hidden("modules/a/.lithe", [pattern], isDirectory: true), "\(pattern)") + } + // A leading `**/` stands for zero or more folders, so it reaches the + // root copy as well as nested ones. + for pattern in ["**/.lithe/", "**/.lithe/*", "**/.lithe/**"] { + #expect(hidden(".lithe", [pattern], isDirectory: true), "\(pattern)") + #expect(hidden(".lithe/run.json", [pattern], isDirectory: false), "\(pattern)") + #expect(hidden("modules/a/.lithe", [pattern], isDirectory: true), "\(pattern)") + #expect(hidden("modules/a/.lithe/run.json", [pattern], isDirectory: false), "\(pattern)") + #expect(!hidden("modules/a/.lithe-notes", [pattern], isDirectory: true), "\(pattern)") + #expect(!hidden("modules/.lithe", [pattern], isDirectory: false), "\(pattern)") + } + // The same prefix applies to file patterns. + #expect(hidden("secret.txt", ["**/secret.txt"], isDirectory: false)) + #expect(hidden("config/secret.txt", ["**/secret.txt"], isDirectory: false)) + #expect(!hidden("config/secret.txt.bak", ["**/secret.txt"], isDirectory: false)) + // Matching ignores case. + #expect(hidden("Modules/A/DIST", ["**/dist/"], isDirectory: true)) + // A plain name or a glob without a folder suffix stays a file pattern. + #expect(hidden(".lithe", [".lithe"], isDirectory: false)) + #expect(!hidden(".lithe", [".lithe"], isDirectory: true)) + #expect(!hidden(".lithe/dependencies/index.json", [".lithe"], isDirectory: false)) + #expect(hidden("src/Name.log", ["*.log"], isDirectory: false)) + #expect(!hidden("src/Name.log", ["*.log"], isDirectory: true)) + // The default rules leave `.lithe` visible. + #expect(!FileVisibilityRules.default.isHidden(root.appendingPathComponent(".lithe"), relativeTo: root, isDirectory: true)) + } + @Test func diffParserPairsChangedRowsAndTracksHunk() { let patch = """ @@ -3695,18 +2558,67 @@ struct LitheCoreLogicTests { let firstID = settings.addFileVisibilityRulesObserver { firstObserverCalls += 1 } _ = settings.addFileVisibilityRulesObserver { secondObserverCalls += 1 } - settings.hiddenDirectoryNames.append("generated") + settings.hiddenPathPatterns.append("**/generated/") #expect(firstObserverCalls == 1) #expect(secondObserverCalls == 1) settings.removeFileVisibilityRulesObserver(firstID) - settings.hiddenFilePatterns.append("*.generated.swift") + settings.hiddenPathPatterns.append("*.generated.swift") #expect(firstObserverCalls == 1) #expect(secondObserverCalls == 2) } + @Test + @MainActor + func hiddenPathsMigrateTheTwoLegacyListsIntoOnePatternList() { + let store = MutableKeyValueStore() + // Older versions saved the built-ins together with the user's entries. + store.set(FileVisibilityRules.builtInHiddenDirectories + ["generated", "Out"], forKey: "settings.hiddenDirectories") + store.set(FileVisibilityRules.builtInHiddenFilePatterns + ["*.generated.swift", " "], forKey: "settings.hiddenFilePatterns") + + let settings = AppSettings(store: store) + + // A folder name keeps hiding at any depth, built-ins are not copied, and + // the file globs carry over unchanged. + #expect(settings.hiddenPathPatterns == ["**/generated/", "**/Out/", "*.generated.swift"]) + let root = URL(fileURLWithPath: "/tmp/lithe-hidden-migration") + #expect(settings.fileVisibilityRules.isHidden( + root.appendingPathComponent("modules/generated"), relativeTo: root, isDirectory: true)) + #expect(settings.fileVisibilityRules.isHidden( + root.appendingPathComponent("node_modules"), relativeTo: root, isDirectory: true)) + + // Once the new list is saved the legacy keys are no longer read. + settings.hiddenPathPatterns = ["docs/"] + #expect(AppSettings(store: store).hiddenPathPatterns == ["docs/"]) + settings.hiddenPathPatterns = [] + #expect(AppSettings(store: store).hiddenPathPatterns.isEmpty) + settings.hiddenPathPatterns = ["docs/"] + settings.restoreDefaults() + #expect(settings.hiddenPathPatterns.isEmpty) + #expect(AppSettings(store: EmptyKeyValueStore()).hiddenPathPatterns.isEmpty) + } + + @Test + func builtInHiddenPatternsShowTheEffectiveRulesInPatternSyntax() { + // The settings page echoes the built-in rules as patterns: every folder + // name as `**/name/` (any depth) followed by the file patterns. + let patterns = FileVisibilityRules.builtInPatterns + #expect(patterns == FileVisibilityRules.builtInHiddenDirectories.map { "**/\($0)/" } + + FileVisibilityRules.builtInHiddenFilePatterns) + #expect(patterns.contains("**/node_modules/")) + #expect(patterns.contains(".DS_Store")) + + // Built-in folders stay hidden at any depth without any user pattern. + let root = URL(fileURLWithPath: "/tmp/lithe-hidden-builtin-patterns") + let rules = FileVisibilityRules(hiddenDirectoryNames: [], hiddenFilePatterns: []) + for directory in FileVisibilityRules.builtInHiddenDirectories { + #expect(rules.isHidden(root.appendingPathComponent(directory), relativeTo: root, isDirectory: true), "\(directory)") + #expect(rules.isHidden(root.appendingPathComponent("a/b/\(directory)/x.txt"), relativeTo: root, isDirectory: false), "\(directory)") + } + } + @Test func runConfigurationMigrationWritesToolchainsIntoServiceOverrides() throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent("lithe-run-migration-\(UUID().uuidString)") @@ -3801,6 +2713,9 @@ private final class TestProjectWindowSessions: ProjectWindowSessionHandling { var hasUnsavedDocuments = false var unsavedDocumentNames: [String] = [] var saveAllDocumentsResult = true + var confirmRunServicesResult = true + private(set) var confirmRunServicesCallCount = 0 + let runServicesAsked = TestGate() var projectWindowCleanupStarted: TestGate? var projectWindowCleanupRelease: TestGate? let projectWindowCleanupFinished = TestGate() @@ -3845,6 +2760,12 @@ private final class TestProjectWindowSessions: ProjectWindowSessionHandling { projectWindowCleanupFinished.open() } + func confirmRunServicesBeforeClose() async -> Bool { + confirmRunServicesCallCount += 1 + runServicesAsked.open() + return confirmRunServicesResult + } + func noteWindowBecameKey() { noteWindowBecameKeyCallCount += 1 } @@ -3869,78 +2790,6 @@ private final class CloseCommandTestWindow: NSWindow { } } -private final class RecordingProcessRunner: ProcessRunner, DatabaseProcessRunning, @unchecked Sendable { - private let lock = NSLock() - private let handler: (ProcessRequest) -> ProcessResult - private let requestsLock = NSLock() - private var recordedRequests: [ProcessRequest] = [] - - var requests: [ProcessRequest] { - requestsLock.lock() - defer { requestsLock.unlock() } - return recordedRequests - } - - init(result: ProcessResult) { - handler = { _ in result } - } - - init(handler: @escaping (ProcessRequest) -> ProcessResult) { - self.handler = handler - } - - func run(_ request: ProcessRequest) -> ProcessResult { - requestsLock.lock() - recordedRequests.append(request) - requestsLock.unlock() - return handler(request) - } - - func runDatabaseProcess(_ request: DatabaseProcessRequest) -> DatabaseProcessResult { - let result = run(ProcessRequest( - executablePath: request.executablePath, - environment: request.environment, - standardInput: request.standardInput, - timeoutMilliseconds: request.timeoutMilliseconds - )) - return DatabaseProcessResult(output: result.output, exitCode: result.exitCode) - } -} - -private final class TestCounter: @unchecked Sendable { - private let lock = NSLock() - private var storage = 0 - - var value: Int { - get { - lock.lock() - defer { lock.unlock() } - return storage - } - set { - lock.lock() - storage = newValue - lock.unlock() - } - } -} - -private final class DatabaseTestKeyValueStore: KeyValueStore, DatabasePreferenceStore, @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 } -} - -private final class DatabaseTestSecureStore: SecureStore, DatabaseSecureStore, @unchecked Sendable { - private var values: [String: String] = [:] - func read(key: String) -> String? { values[key] } - func write(_ value: String, key: String) throws { values[key] = value } - func delete(key: String) throws { values.removeValue(forKey: key) } -} - @Suite("Editor documents") @MainActor struct EditorDocumentTests { @@ -5592,6 +4441,42 @@ struct EditorDocumentTests { #expect(projectReloadCount == 1) } + @Test + @MainActor + func hiddenProjectConfigurationEditsReloadProjectServicesWithoutEnteringTheTree() async throws { + let workspace = URL(fileURLWithPath: "/in-memory/lithe-hidden-configuration") + let source = workspace.appendingPathComponent("src/Main.java") + let configuration = workspace.appendingPathComponent(".lithe/run.json") + let rules = FileVisibilityRules(hiddenDirectoryNames: [], hiddenFilePatterns: [".lithe/*"]) + let watcherFactory = TestDirectoryWatcherFactory() + var projectReloadCount = 0 + var gitRefreshCount = 0 + let model = makeWorkspaceObservationUnitModel( + operations: SequencedWorkspaceOperations(snapshotAvailability: [true], files: [source]), + fileOperations: ExistingWorkspaceFileOperations(paths: [source.path, configuration.path]), + provider: SequencedGitWatchContextProvider([nil]), + watcherFactory: watcherFactory, + refreshGit: { gitRefreshCount += 1 }, + reloadProjectServices: { projectReloadCount += 1 }, + observationDelay: { _ in } + ) + defer { model.reset() } + model.beginWorkspace(at: workspace, visibilityRules: rules) + _ = await model.rebuild(at: workspace, rules: rules, isCurrent: { true }) + let refreshesAfterLoad = gitRefreshCount + let watcher = try #require(watcherFactory.source) + + // The watcher still delivers `.lithe` while the rules hide it, so an + // edited run configuration reloads services but never joins the tree. + watcher.emit([configuration.path]) + + #expect(await waitForWorkspaceObservation(timeout: .seconds(15)) { + projectReloadCount == 1 && gitRefreshCount > refreshesAfterLoad + }, "The configuration edit did not reload project services") + #expect(model.projectFiles == [source]) + #expect(model.rootNode?.fileURLs == [source]) + } + @Test @MainActor func externalFileCreationAndDeletionUpdateTheTreeWithoutRescanning() async throws { @@ -7604,7 +6489,7 @@ private final class TestTerminalTransport: TerminalTransport { } } -private final class InMemoryFileStorage: FileStorage, GitShelfStorage, DatabaseFileStorage, @unchecked Sendable { +private final class InMemoryFileStorage: FileStorage, GitShelfStorage, @unchecked Sendable { private let lock = NSLock() private let support = URL(fileURLWithPath: "/in-memory-application-support", isDirectory: true) private var files: [String: Data] = [:] @@ -7753,31 +6638,6 @@ private final class MutableKeyValueStore: KeyValueStore { func set(_ value: Any?, forKey key: String) { values[key] = value } } -private let dbxPlainConnectionExport = #""" -{ - "connections": [ - { - "id": "mysql-1", "name": "Production MySQL", "db_type": "mysql", - "host": "db.example.com", "port": 3306, "username": "root", - "password": "db-secret", "database": "app", "color": "#ff5500", - "read_only": true, "is_production": true, "ssl": true, - "ca_cert_path": "/tmp/ca.pem", - "transport_layers": [{"type":"ssh","enabled":true,"host":"jump.example.com","port":22,"user":"deploy","key_path":"/tmp/id_ed25519"}] - }, - { "id": "sqlite-1", "name": "Local SQLite", "db_type": "sqlite", "host": "/tmp/local.sqlite", "port": 0, "username": "", "password": "", "database": "" }, - { "id": "oracle-1", "name": "Oracle", "db_type": "oracle", "host": "oracle.example.com", "port": 1521, "username": "scott", "password": "tiger" } - ], - "layout": { - "groups": [{"id":"g1","name":"Production","collapsed":false},{"id":"g2","name":"Local","collapsed":false}], - "order": [{"type":"group","id":"g1","connectionIds":["mysql-1"],"children":[{"type":"group","id":"g2","connectionIds":["sqlite-1"]}]}] - } -} -"""# - -private let dbxEncryptedConnectionExport = #""" -{"format":"dbx-encrypted","version":1,"salt":"AAECAwQFBgcICQoLDA0ODw==","iv":"EBESExQVFhcYGRob","data":"fdwV5NDM/8LXPJqMyQgoQVkuOwMe+0VDPFR8HsEWD1AMIhPz1sHRRkmzd6ZLcBqnfcA57xCJz3Jtnbf+djnYI83EiNkr6iukZq1Ahd8aGy/r61/JdThx/NTaUgzn0mwAIcpxDl9uyBDwI0PO8WAaXbZyWbFumsLn3SJSEb8d"} -"""# - @Suite("Editor session coordination") @MainActor struct EditorSessionCoordinatorTests { diff --git a/macos/Tests/LitheTests/LitheFormGridTests.swift b/macos/Tests/LitheTests/LitheFormGridTests.swift new file mode 100644 index 0000000000..0bbf4fc1b7 --- /dev/null +++ b/macos/Tests/LitheTests/LitheFormGridTests.swift @@ -0,0 +1,46 @@ +import AppKit +import SwiftUI +import Testing +@testable import Lithe + +@MainActor +struct LitheFormGridTests { + @Test func gridSpansShareColumnEdgesAndUseTheTallestCellPerRow() { + let grid = LitheFormGrid(columns: 12) + let frames = grid.frames(width: 1200, spans: [4, 4, 4, 6, 6, 12], heights: [44, 60, 44, 16, 16, 28]) + #expect(frames[0] == CGRect(x: 0, y: 0, width: 392, height: 44)) + #expect(frames[1].minX == 404) + #expect(frames[2].maxX == 1200) + #expect(frames[3].minY == 72) + #expect(frames[3].width == 594) + #expect(frames[4].maxX == 1200) + #expect(frames[5] == CGRect(x: 0, y: 100, width: 1200, height: 28)) + } + + @Test func partialRowsWrapAndNarrowPanelsBecomeSingleColumn() { + let grid = LitheFormGrid(columns: 12) + let partial = grid.frames(width: 1200, spans: [8, 6, 6], heights: [24, 48, 24]) + #expect(partial[1].origin == CGPoint(x: 0, y: 36)) + #expect(partial[2].minY == partial[1].minY) + let narrow = grid.frames(width: 639, spans: [4, 4, 4], heights: [24, 48, 24]) + #expect(narrow.map(\.minX) == [0, 0, 0]) + #expect(narrow.map(\.width) == [639, 639, 639]) + #expect(narrow.map(\.minY) == [0, 36, 96]) + #expect(grid.effectiveColumns(width: 640) == 12) + #expect(grid.frames(width: 0, spans: [12], heights: [24]).first?.width == 0) + #expect(grid.frames(width: 400, spans: [], heights: []).isEmpty) + } + + @Test func nativeLayoutMeasuresWideAndNarrowContentWithoutFixedRowHeights() { + func height(width: CGFloat) -> CGFloat { + let host = NSHostingView(rootView: LitheFormGrid(columns: 12) { + Color.clear.frame(height: 24).layoutValue(key: LitheFormGrid.Span.self, value: 6) + Color.clear.frame(height: 48).layoutValue(key: LitheFormGrid.Span.self, value: 6) + Color.clear.frame(height: 24).layoutValue(key: LitheFormGrid.Span.self, value: 12) + }.frame(width: width)) + return host.fittingSize.height + } + #expect(height(width: 1200) == 84) + #expect(height(width: 400) == 120) + } +} diff --git a/macos/Tests/LitheTests/LitheSplitPaneCollapseTests.swift b/macos/Tests/LitheTests/LitheSplitPaneCollapseTests.swift index 9f3bbfc6b8..2b21279916 100644 --- a/macos/Tests/LitheTests/LitheSplitPaneCollapseTests.swift +++ b/macos/Tests/LitheTests/LitheSplitPaneCollapseTests.swift @@ -133,7 +133,8 @@ struct LitheSplitPaneCollapseTests { VStack(spacing: 0) { ForEach(0..<69) { index in Button("codex/branch-\(index)") {}.buttonStyle(.litheNoPress) - .litheTreeRow() + .frame(height: LitheTheme.Tree.rowHeight) + .modifier(LitheTreeListRowSurface(isSelected: false, isFocused: false)) .workbenchHoverHelp(Text("Branch \(index)")) .litheContextMenu { [.action("Select", action: {})] } .background(CollapseSurface(name: String(index), recorder: surfaces)) diff --git a/macos/Tests/LitheTests/LitheTextInputTests.swift b/macos/Tests/LitheTests/LitheTextInputTests.swift new file mode 100644 index 0000000000..1c87373ca8 --- /dev/null +++ b/macos/Tests/LitheTests/LitheTextInputTests.swift @@ -0,0 +1,109 @@ +import AppKit +import SwiftUI +import Testing +@testable import Lithe + +@MainActor +@Suite("Complete shared text inputs", .serialized) +struct LitheTextInputTests { + @Test func productCallersCannotReassembleRetiredInputRecipes() throws { + let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent() + .appendingPathComponent("Sources/Lithe/Views") + let files = try #require(FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil)) + for case let file as URL in files where file.pathExtension == "swift" { + let source = try String(contentsOf: file, encoding: .utf8) + #expect(!source.contains("litheSettingsTextField("), "Retired input recipe: \(file.lastPathComponent)") + #expect(!source.contains("litheSettingsTextEditor("), "Retired editor recipe: \(file.lastPathComponent)") + #expect(!source.contains("LitheSettingsSearchField"), "Retired search entry: \(file.lastPathComponent)") + if file.lastPathComponent != "LitheSearchField.swift" { + for line in source.components(separatedBy: .newlines) where line.contains("LitheSearchTextField(") { + // Multiline task composition is an editor, not a product search field. + #expect(line.contains("axis: .vertical"), "Use the complete search owner: \(file.lastPathComponent)") + } + #expect(!source.contains(".litheSearchField("), "Search chrome belongs to its owner: \(file.lastPathComponent)") + } + } + } + + @Test(arguments: [ColorScheme.dark, .light]) + func commandFieldGrowsWithinItsLineLimit(scheme: ColorScheme) { + func height(_ text: String) -> CGFloat { + let host = NSHostingView(rootView: + LitheTextField("Command", text: .constant(text), axis: .vertical) + .lineLimit(1...3).frame(width: 240) + .environment(\.colorScheme, scheme)) + host.layoutSubtreeIfNeeded() + return host.fittingSize.height + } + let single = height("command") + let three = height("command\nargument\nvalue") + #expect(single >= SettingsSelectMetrics.controlHeight) + #expect(three > single) + #expect(abs(height("command\nargument\nvalue\nmore\nlines") - three) < 1) + } + + @Test(arguments: [ColorScheme.dark, .light]) + func searchFocusClearAndNativeEditorsSurviveTheCompleteOwner(scheme: ColorScheme) async throws { + let app = NSApplication.shared + let attribute = NSAccessibility.Attribute(rawValue: "AXEnhancedUserInterface") + let previous = (app.accessibilityAttributeValue(attribute) as? Bool) ?? false + app.accessibilitySetValue(true, forAttribute: attribute) + defer { app.accessibilitySetValue(previous, forAttribute: attribute) } + var changes: [String] = [] + let host = NSHostingView(rootView: InputFixture(onChange: { changes.append($0) }).environment(\.colorScheme, scheme)) + let window = NSWindow(contentRect: NSRect(x: 100, y: 100, width: 500, height: 350), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = host + window.makeKeyAndOrderFront(nil) + defer { window.makeFirstResponder(nil); window.contentView = nil; window.close() } + func elements(_ value: Any) -> [AnyObject] { + let item = value as AnyObject + return [item] + (item.accessibilityChildren?() ?? []).flatMap { elements($0) } + } + func views(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(views) } + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(1)) + var accessible: [AnyObject] = [] + repeat { + host.layoutSubtreeIfNeeded() + accessible = elements(host) + if accessible.contains(where: { $0.accessibilityLabel?() == "Focus search" }) { break } + await Task.yield() + } while clock.now < deadline + let focusButton = try #require(accessible.first { $0.accessibilityLabel?() == "Focus search" }) + #expect(focusButton.accessibilityPerformPress?() == true) + await Task.yield() + host.layoutSubtreeIfNeeded() + let search = try #require(views(host).compactMap { $0 as? NSTextField }.first { $0.stringValue == "typed" }) + #expect(search.currentEditor() === window.firstResponder) + #expect(views(host).contains { $0 is NSSecureTextField }) + #expect(accessible.contains { $0.accessibilityRole?() == .textArea }) + let tip = try #require(accessible.first { $0.accessibilityHelp?() == "Disabled field explanation" }) + #expect(tip.isAccessibilityEnabled?() == true) + let clear = try #require(accessible.first { $0.accessibilityLabel?() == "Clear search" }) + #expect(clear.accessibilityPerformPress?() == true) + await Task.yield() + #expect(changes.last == "") + #expect(search.stringValue.isEmpty) + } +} + +private struct InputFixture: View { + @State private var query = "typed" + @State private var text = "draft" + @State private var notes = "中文\nEnglish" + @FocusState private var searchFocused: Bool + let onChange: (String) -> Void + var body: some View { + VStack { + LitheSearchField("Search", text: $query, focus: $searchFocused, onTextChanged: onChange) + LitheTextField("Password", text: $text, secure: true) + LitheTextEditor(text: $notes, height: 90) + LitheTextField(verbatim: "", text: $text, label: "Disabled", info: "Disabled field explanation") + .disabled(true) + Button("Focus search") { searchFocused = true } + }.padding(12) + } +} diff --git a/macos/Tests/LitheTests/LitheTreeListTests.swift b/macos/Tests/LitheTests/LitheTreeListTests.swift new file mode 100644 index 0000000000..9e39fab9f0 --- /dev/null +++ b/macos/Tests/LitheTests/LitheTreeListTests.swift @@ -0,0 +1,605 @@ +import AppKit +import LitheCoreContracts +import LitheExecutionModule +import SwiftUI +import Testing + +@testable import Lithe + +@MainActor +@Suite("Complete shared tree/list", .serialized) +struct LitheTreeListTests { + @Test func selectionPreservesRangeAnchorAndCommandUnion() { + let rows = ["a", "b", "c", "d"] + #expect( + LitheTreeListSelection.select("d", in: rows, selected: ["a"], anchor: "b", extending: true, toggling: false) + == ["b", "c", "d"]) + #expect( + LitheTreeListSelection.select( + "b", in: rows, selected: ["b", "c", "d"], anchor: "b", extending: true, toggling: false) == ["b"]) + #expect( + LitheTreeListSelection.select("d", in: rows, selected: ["a"], anchor: "c", extending: true, toggling: true) + == ["a", "c", "d"]) + #expect( + LitheTreeListSelection.select( + "a", in: rows, selected: ["a", "d"], anchor: "a", extending: false, toggling: true) == ["d"]) + #expect( + LitheTreeListSelection.select( + "b", in: rows, selected: ["a"], anchor: "removed", extending: true, toggling: false) == ["b"]) + #expect( + LitheTreeListSelection.select( + "removed", in: rows, selected: ["a"], anchor: "a", extending: false, toggling: false) == ["a"]) + } + + @Test func growingRangesRevealTheCursorRatherThanTheirAnchor() { + let visible = ["a", "b", "c", "d"] + #expect( + LitheTreeListSelection.revealTarget(cursor: "d", selected: ["a", "b", "c", "d"], visible: visible) == "d") + #expect(LitheTreeListSelection.revealTarget(cursor: "a", selected: ["c"], visible: visible) == "c") + #expect( + LitheTreeListSelection.revealTarget(cursor: "removed", selected: ["removed", "c"], visible: visible) == "c") + #expect(LitheTreeListSelection.revealTarget(cursor: "a", selected: [], visible: visible) == nil) + } + + @Test func navigationUsesVisibleHierarchyAndStopsAtBounds() { + let rows: [LitheTreeListHierarchy] = [ + .init(isBranch: true, isExpanded: true), .init(depth: 1), + .init(depth: 1, isBranch: true), .init(), + ] + #expect(LitheTreeListNavigation.resolve(.up, current: nil, rows: rows) == .select(3)) + #expect(LitheTreeListNavigation.resolve(.down, current: nil, rows: rows) == .select(0)) + #expect(LitheTreeListNavigation.resolve(.up, current: 0, rows: rows) == .select(0)) + #expect(LitheTreeListNavigation.resolve(.down, current: 3, rows: rows) == .select(3)) + #expect(LitheTreeListNavigation.resolve(.right, current: 0, rows: rows) == .select(1)) + #expect(LitheTreeListNavigation.resolve(.left, current: 2, rows: rows) == .select(0)) + #expect(LitheTreeListNavigation.resolve(.right, current: 2, rows: rows) == .expand(2, true)) + #expect(LitheTreeListNavigation.resolve(.left, current: 0, rows: rows) == .expand(0, false)) + #expect(LitheTreeListNavigation.resolve(.right, current: 1, rows: rows) == nil) + #expect(LitheTreeListNavigation.resolve(.down, current: 0, rows: []) == nil) + } + + @Test func branchActivationExpandsWhileLeavesKeepTheirAction() { + var expansions: [Bool] = [] + var activations = 0 + let expand: (Bool) -> Void = { expansions.append($0) } + let activate = { activations += 1 } + LitheTreeListHierarchy(isBranch: true).activate(onExpansion: expand, onActivate: activate) + LitheTreeListHierarchy(isBranch: true, isExpanded: true) + .activate(onExpansion: expand, onActivate: activate) + #expect(expansions == [true, false]) + #expect(activations == 0) + LitheTreeListHierarchy().activate(onExpansion: expand, onActivate: activate) + #expect(expansions == [true, false]) + #expect(activations == 1) + LitheTreeListHierarchy(isBranch: true).activate(onExpansion: nil, onActivate: activate) + #expect(activations == 2) + LitheTreeListHierarchy(isBranch: true, activatesOnDoubleClick: true) + .activate(onExpansion: expand, onActivate: activate) + #expect(activations == 3) + #expect(expansions == [true, false]) + } + + @Test func collapsedBranchesDoNotBuildHiddenDescendants() { + var childBuilds = 0 + let closed = LitheTreeListNode(id: "root", isExpanded: false) { + Text("Root") + } children: { + childBuilds += 1 + return [LitheTreeListNode(id: "child") { Text("Child") }] + } + #expect(LitheTreeListNode.visible([closed]).map(\.id) == ["root"]) + #expect(childBuilds == 0) + var opened = closed + opened.isExpanded = true + let visible = LitheTreeListNode.visible([opened]) + #expect(visible.map(\.id) == ["root", "child"]) + #expect(visible.map(\.depth) == [0, 1]) + #expect(childBuilds == 1) + } + + @Test func navigationSkipsPlainHeadersButKeepsExpandableBranches() { + let rows: [LitheTreeListHierarchy] = [ + .init(isSelectable: false), .init(depth: 1), .init(isSelectable: false), + .init(depth: 1), .init(isBranch: true, isSelectable: false), .init(depth: 1), + .init(isSelectable: false), + ] + #expect(LitheTreeListNavigation.resolve(.down, current: 1, rows: rows) == .select(3)) + #expect(LitheTreeListNavigation.resolve(.up, current: 3, rows: rows) == .select(1)) + #expect(LitheTreeListNavigation.resolve(.down, current: 3, rows: rows) == .select(4)) + #expect(LitheTreeListNavigation.resolve(.right, current: 4, rows: rows) == .expand(4, true)) + #expect(LitheTreeListNavigation.resolve(.down, current: nil, rows: rows) == .select(1)) + #expect(LitheTreeListNavigation.resolve(.up, current: nil, rows: rows) == .select(5)) + #expect(LitheTreeListNavigation.resolve(.down, current: 5, rows: rows) == .select(5)) + #expect(LitheTreeListNavigation.resolve(.down, current: nil, rows: [.init(isSelectable: false)]) == nil) + } + + @Test func externalSelectionRetargetsMountedKeyboardAndPreservesShiftAnchor() async throws { + let app = NSApplication.shared + let attribute = NSAccessibility.Attribute(rawValue: "AXEnhancedUserInterface") + let previous = (app.accessibilityAttributeValue(attribute) as? Bool) ?? false + app.accessibilitySetValue(true, forAttribute: attribute) + defer { app.accessibilitySetValue(previous, forAttribute: attribute) } + let state = TreeListInteractionState() + let host = NSHostingView(rootView: TreeKeyboardFixture(state: state)) + let window = NSWindow(contentRect: NSRect(x: 100, y: 100, width: 300, height: 200), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false; window.contentView = host + window.makeKeyAndOrderFront(nil) + defer { window.makeFirstResponder(nil); window.contentView = nil; window.close() } + #expect(try #require(await keyboardRow(in: host, text: "a")).accessibilityPerformPress?() == true) + #expect(await keyboardSettled(host) { state.selected == ["a"] }) + state.selected = ["c"] + host.layoutSubtreeIfNeeded() + await Task.yield() + try sendTreeKey(36, window: window) + #expect(await keyboardSettled(host) { state.activated.last == "c" }) + try sendTreeKey(125, window: window) + #expect(await keyboardSettled(host) { state.selected == ["d"] }) + state.selected = ["c"] + host.layoutSubtreeIfNeeded() + await Task.yield() + try sendTreeKey(126, window: window, modifiers: .shift) + #expect(await keyboardSettled(host) { state.selected == ["b", "c"] }, "Selected: \(state.selected)") + try sendTreeKey(126, window: window, modifiers: .shift) + #expect(await keyboardSettled(host) { state.selected == ["a", "b", "c"] }, "Selected: \(state.selected)") + try sendTreeKey(125, window: window, modifiers: .shift) + #expect(await keyboardSettled(host) { state.selected == ["b", "c"] }, "Selected: \(state.selected)") + try sendTreeKey(36, window: window) + #expect(await keyboardSettled(host) { state.activated.last == "b" }) + } + + @Test func problemsKeyboardCrossesFileHeaderAndOpensTheNextDiagnostic() async throws { + let app = NSApplication.shared + let attribute = NSAccessibility.Attribute(rawValue: "AXEnhancedUserInterface") + let previous = (app.accessibilityAttributeValue(attribute) as? Bool) ?? false + app.accessibilitySetValue(true, forAttribute: attribute) + defer { app.accessibilitySetValue(previous, forAttribute: attribute) } + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("Lithe.ProblemsKeyboard-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let first = directory.appendingPathComponent("A.java"), second = directory.appendingPathComponent("B.java") + for file in [first, second] { try "class Test {}\n\n\n".write(to: file, atomically: true, encoding: .utf8) } + let domain = "Lithe.ProblemsKeyboard.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: domain)) + defer { defaults.removePersistentDomain(forName: domain) } + let store = MacUserDefaultsStore(defaults: defaults), settings = AppSettings(store: store) + let model = AppModel(settings: settings, + services: MacServiceContainer(store: store, settings: settings, moduleLaunchMode: .safeMode).services) + model.openProjectDirectly(directory) + defer { model.workspaceFeature.reset() } + let diagnostics = EditorDiagnosticsStore() + func diagnostic(_ file: URL, line: Int, message: String) -> EditorDiagnostic { + .init(id: file.lastPathComponent, fileURL: file, line: line, utf16Column: 1, + endLine: line, endUTF16Column: 2, severity: .error, message: message, + source: "test", code: nil, tags: [], relatedInformation: []) + } + diagnostics.replace([first: [diagnostic(first, line: 0, message: "A problem")], + second: [diagnostic(second, line: 2, message: "B problem")]]) + let host = NSHostingView(rootView: ProblemsView(initialScope: .projectErrors) + .environmentObject(model).environmentObject(diagnostics)) + let window = NSWindow(contentRect: NSRect(x: 100, y: 100, width: 500, height: 300), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false; window.contentView = host + window.makeKeyAndOrderFront(nil) + defer { window.makeFirstResponder(nil); window.contentView = nil; window.close() } + #expect(await keyboardSettled(host) { model.projectFiles.contains(first) }) + #expect(try #require(await keyboardRow(in: host, text: "A problem")).accessibilityPerformPress?() == true) + #expect(await keyboardSettled(host) { model.activeDocument?.url.resolvingSymlinksInPath() == first.resolvingSymlinksInPath() }, "Opened: \(String(describing: model.activeDocument?.url))") + try sendTreeKey(125, window: window) + try sendTreeKey(36, window: window) + #expect(await keyboardSettled(host) { model.activeDocument?.url.resolvingSymlinksInPath() == second.resolvingSymlinksInPath() }, "Opened: \(String(describing: model.activeDocument?.url))") + #expect(model.editorNavigationTarget?.url == second) + #expect(model.editorNavigationTarget?.line == 2) + #expect(model.editorNavigationTarget?.utf16Column == 1) + } + + private func keyboardSettled(_ host: NSView, until predicate: () -> Bool) async -> Bool { + let clock = ContinuousClock(), deadline = clock.now.advanced(by: .seconds(2)) + repeat { + host.layoutSubtreeIfNeeded() + if predicate() { return true } + await Task.yield() + } while clock.now < deadline + return predicate() + } + + private func keyboardRow(in host: NSView, text: String) async -> AnyObject? { + func contains(_ value: Any) -> Bool { + let item = value as AnyObject + let string: String? = item.accessibilityValue?() + return string == text || (item.accessibilityLabel?() ?? "").contains(text) + || (item.accessibilityChildren?() ?? []).contains(where: contains) + } + func find(_ value: Any) -> AnyObject? { + let item = value as AnyObject + if item.accessibilityRole?() == .button, contains(item) { return item } + return (item.accessibilityChildren?() ?? []).lazy.compactMap(find).first + } + var result: AnyObject? + _ = await keyboardSettled(host) { result = find(host); return result != nil } + return result + } + + private func sendTreeKey(_ code: UInt16, window: NSWindow, modifiers: NSEvent.ModifierFlags = []) throws { + let text = code == 36 ? "\r" : code == 125 ? "\u{F701}" : "\u{F700}" + let event = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: modifiers, + timestamp: 0, windowNumber: window.windowNumber, context: nil, characters: text, + charactersIgnoringModifiers: text, isARepeat: false, keyCode: code)) + NSApp.sendEvent(event) + } + + @Test func mountedListExposesSelectionAndPreservesEmbeddedActions() async throws { + let application = NSApplication.shared + let attribute = NSAccessibility.Attribute(rawValue: "AXEnhancedUserInterface") + let previous = (application.accessibilityAttributeValue(attribute) as? Bool) ?? false + application.accessibilitySetValue(true, forAttribute: attribute) + defer { application.accessibilitySetValue(previous, forAttribute: attribute) } + let domain = "Lithe.TreeAccessibility.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: domain)) + defer { defaults.removePersistentDomain(forName: domain) } + let store = MacUserDefaultsStore(defaults: defaults) + let settings = AppSettings(store: store) + let model = AppModel(settings: settings, + services: MacServiceContainer(store: store, settings: settings, moduleLaunchMode: .safeMode).services) + let resultRoot = try #require(URL(string: "lithe-fixture://results/")) + let state = TreeListInteractionState() + let host = NSHostingView( + rootView: LitheTreeList( + ["a", "b", "c", "d"], id: \.self, + selection: Binding(get: { state.selected }, set: { state.selected = $0 }), + onActivate: { + state.activated.append($0) + model.documentFeature.openVirtualDocument(resultRoot.appendingPathComponent($0 + ".java"), + text: "class Result {}", displayPath: nil) + } + ) { value in + HStack { + Text(value).accessibilityIdentifier("row-label-" + value) + if value == "a" { + Button("Refresh") { state.activated.append("refresh") } + .accessibilityIdentifier("embedded-refresh") + } + } + } + .frame(width: 260, height: 180).background(LitheTheme.editor)) + let window = NSWindow( + contentRect: NSRect(x: 100, y: 100, width: 260, height: 180), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = host + window.orderFront(nil) + defer { + window.contentView = nil + window.close() + } + func find(_ value: Any, id: String? = nil, containingText text: String? = nil) -> AnyObject? { + let accessible = value as AnyObject + let children = accessible.accessibilityChildren?() ?? [] + if let id, accessible.accessibilityIdentifier?() == id { return accessible } + if let text, accessible.accessibilityRole?() == .button, + children.contains(where: { + ($0 as AnyObject).accessibilityIdentifier?() == "row-label-" + text + }) + { + return accessible + } + return children.lazy.compactMap { find($0, id: id, containingText: text) }.first + } + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(1)) + var row: AnyObject? + repeat { + host.layoutSubtreeIfNeeded() + row = find(host, containingText: "b") + if row != nil { break } + await Task.yield() + } while clock.now < deadline + let selected = try #require(row) + #expect(selected.accessibilityPerformPress?() == true) + #expect(state.selected == ["b"]) + let refresh = try #require(find(host, id: "embedded-refresh")) + #expect(refresh.accessibilityPerformPress?() == true) + #expect(state.activated == ["b", "refresh"]) + #expect(model.activeDocument?.url == resultRoot.appendingPathComponent("b.java")) + #expect(state.selected == ["b"]) + + } + + @Test(arguments: [false, true], ["Run", "Exclude from dependency tree"]) + func emptyNodeMenuPreservesEmbeddedBusinessMenu(multiple: Bool, title: String) async throws { + let app = NSApplication.shared + let attribute = NSAccessibility.Attribute(rawValue: "AXEnhancedUserInterface") + let previous = (app.accessibilityAttributeValue(attribute) as? Bool) ?? false + app.accessibilitySetValue(true, forAttribute: attribute) + defer { app.accessibilitySetValue(previous, forAttribute: attribute) } + var executions = 0 + let node = LitheTreeListNode(id: "row") { + Text("Business row").frame(maxWidth: .infinity, alignment: .leading) + .litheContextMenu { [.action(title, localizesTitle: false) { executions += 1 }] } + } + var single: String? + var selected: Set = [] + let content: AnyView = multiple + ? AnyView(LitheTreeList(nodes: [node], selection: Binding(get: { selected }, set: { selected = $0 }))) + : AnyView(LitheTreeList(nodes: [node], selection: Binding(get: { single }, set: { single = $0 }))) + let host = NSHostingView(rootView: content.frame(width: 300, height: 160)) + let window = NSWindow(contentRect: NSRect(x: 100, y: 100, width: 300, height: 160), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = host + window.makeKeyAndOrderFront(nil) + defer { LitheContextMenuPresenter.shared.dismiss(); window.contentView = nil; window.close() } + host.layoutSubtreeIfNeeded() + let point = NSPoint(x: 120, y: host.isFlipped ? 16 : host.bounds.height - 16) + let rightClick = try #require(NSEvent.mouseEvent(with: .rightMouseDown, + location: host.convert(point, to: nil), modifierFlags: [], timestamp: 0, + windowNumber: window.windowNumber, context: nil, eventNumber: 1, clickCount: 1, pressure: 1)) + func views(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(views) } + let captures = views(host).filter { String(describing: type(of: $0)) == "LitheRightClickCaptureView" } + #expect(captures.count == 1, "An empty node menu must not install a second capture above the business menu") + let capture = try #require(captures.first) + // Invoke the actual AppKit event boundary; do not run a nested application event loop. + capture.rightMouseDown(with: rightClick) + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(1)) + func menu() -> NSPanel? { NSApp.windows.compactMap { $0 as? NSPanel }.first { $0.isVisible } } + while menu() == nil && clock.now < deadline { await Task.yield(); host.layoutSubtreeIfNeeded() } + let popup = try #require(menu(), "An absent node menu must leave Run/Edit/Pin and dependency menus reachable") + func find(_ value: Any) -> AnyObject? { + let item = value as AnyObject + if item.accessibilityRole?() == .button, item.accessibilityLabel?() == title { return item } + return (item.accessibilityChildren?() ?? []).lazy.compactMap(find).first + } + let contentView = try #require(popup.contentView) + let action = try #require(find(contentView)) + #expect(action.accessibilityPerformPress?() == true) + #expect(executions == 1) + } + + @Test func realDependencySidebarMenuExcludesTheConfiguredPath() async throws { + let app = NSApplication.shared + let attribute = NSAccessibility.Attribute(rawValue: "AXEnhancedUserInterface") + let previous = (app.accessibilityAttributeValue(attribute) as? Bool) ?? false + app.accessibilitySetValue(true, forAttribute: attribute) + defer { app.accessibilitySetValue(previous, forAttribute: attribute) } + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: root.appendingPathComponent("acceptance-library"), withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let domain = "Lithe.DependencyMenuAcceptance.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: domain)) + defer { defaults.removePersistentDomain(forName: domain) } + let store = MacUserDefaultsStore(defaults: defaults) + let settings = AppSettings(store: store) + let model = AppModel(settings: settings, + services: MacServiceContainer(store: store, settings: settings, moduleLaunchMode: .normal).services) + do { + let access = try #require(await model.activateExecutionModule()) + let feature = access.runFeature + await feature.loadProject(at: root, files: [], mavenProject: nil) + // Use a registered source with an explicit local path, isolating UI dispatch + // from language-server startup while exercising the real dependency feature. + feature.registerDependencySource(languageID: "acceptance", displayName: "Acceptance dependencies") + feature.updateDependencyPaths(DependencyPathConfiguration(sourcePaths: ["acceptance-library"]), + serviceID: "language:acceptance") + let host = NSHostingView(rootView: DependencySidebarView(refreshRevision: 0) + .environmentObject(model).environmentObject(settings).frame(width: 400, height: 500)) + let window = NSWindow(contentRect: NSRect(x: 100, y: 100, width: 400, height: 500), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = host + window.makeKeyAndOrderFront(nil) + defer { LitheContextMenuPresenter.shared.dismiss(); window.contentView = nil; window.close() } + func hasText(_ value: Any, _ text: String) -> Bool { + let item = value as AnyObject + let stringValue: String? = item.accessibilityValue?() + if stringValue == text || item.accessibilityLabel?() == text { return true } + return (item.accessibilityChildren?() ?? []).contains { hasText($0, text) } + } + func row(_ value: Any, _ text: String) -> AnyObject? { + let item = value as AnyObject + if item.accessibilityRole?() == .button, hasText(item, text) { return item } + return (item.accessibilityChildren?() ?? []).lazy.compactMap { row($0, text) }.first + } + func waitForRow(_ text: String) async -> AnyObject? { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(2)) + repeat { + host.layoutSubtreeIfNeeded() + if let found = row(host, text) { return found } + await Task.yield() + } while clock.now < deadline + return nil + } + let service = try #require(await waitForRow("Acceptance dependencies")) + #expect(service.accessibilityPerformPress?() == true) + let sources = try #require(await waitForRow("Source Code")) + #expect(sources.accessibilityPerformPress?() == true) + let library = try #require(await waitForRow("acceptance-library")) + let rect = try #require(library.accessibilityFrame?()) + let point = window.convertPoint(fromScreen: NSPoint(x: rect.midX, y: rect.midY)) + func views(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(views) } + let capture = try #require(views(host).filter { + String(describing: type(of: $0)) == "LitheRightClickCaptureView" + && $0.convert($0.bounds, to: nil).contains(point) + }.min { $0.bounds.width * $0.bounds.height < $1.bounds.width * $1.bounds.height }) + capture.rightMouseDown(with: try #require(NSEvent.mouseEvent(with: .rightMouseDown, location: point, + modifierFlags: [], timestamp: 0, windowNumber: window.windowNumber, context: nil, + eventNumber: 1, clickCount: 1, pressure: 1))) + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(2)) + func menu() -> NSPanel? { NSApp.windows.compactMap { $0 as? NSPanel }.first { $0.isVisible } } + while menu() == nil && clock.now < deadline { await Task.yield() } + let popup = try #require(menu()) + let content = try #require(popup.contentView) + let action = try #require(row(content, "Exclude from dependency tree")) + #expect(action.accessibilityPerformPress?() == true) + #expect(feature.dependencyPaths(for: "language:acceptance").excludedPaths == ["acceptance-library"]) + let graph = try #require(try await feature.resolveDependencies(serviceID: "language:acceptance")) + #expect(graph.roots.first?.children.first?.children.isEmpty == true) + } catch { await model.shutdownProjectSession(); throw error } + await model.shutdownProjectSession() + } + + @Test func productRowsCannotUseTheRetiredStyleRecipe() throws { + let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().appendingPathComponent("Sources/Lithe") + let enumerator = try #require(FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil)) + var failures: [String] = [] + for case let file as URL in enumerator where file.pathExtension == "swift" { + let source = try String(contentsOf: file, encoding: .utf8) + if source.contains(".litheTreeRow(") || source.contains(".litheSelectionRow(") { + failures.append(file.lastPathComponent) + } + if file.lastPathComponent != "LitheTreeList.swift", source.contains("LitheTreeListRowSurface(") { + failures.append(file.lastPathComponent) + } + } + #expect(failures.isEmpty, "Tree/list callers must use LitheTreeList: \(failures)") + } +} + +@MainActor +private struct TreeKeyboardFixture: View { + @ObservedObject var state: TreeListInteractionState + + var body: some View { + LitheTreeList(["a", "b", "c", "d"], id: \.self, + selection: Binding(get: { state.selected }, set: { state.selected = $0 }), + onActivate: { state.activated.append($0) }) { Text($0) } + } +} + +@MainActor +private final class TreeListInteractionState: ObservableObject { + @Published var selected: Set = [] + var activated: [String] = [] +} + +@MainActor +struct DependencyTreePresentationTests { + @Test func cancelledServiceCannotOverwriteANewerLoadOrUnmount() async throws { + let state = DependencyTreePresentation() + let started = TestGate() + let release = TestGate() + defer { + started.open() + release.open() + state.close() + } + state.update("service") { + $0.expanded = true + $0.expandedNodeIDs = ["dependency"] + } + let oldGeneration = state.begin("service") + var wasCancelled = false + let oldTask = Task { @MainActor in + started.open() + _ = await release.waitUntilOpen() + wasCancelled = Task.isCancelled + } + defer { oldTask.cancel() } + state.retain(oldTask, for: "service") + #expect(await started.waitUntilOpen()) + let nextGeneration = state.begin("service") + state.finish("service", generation: oldGeneration, graph: nil, error: "stale") + #expect(state.state(for: "service").resolving) + #expect(state.state(for: "service").error == nil) + state.finish("service", generation: nextGeneration, graph: nil, error: "current") + #expect(state.state(for: "service").error == "current") + state.close() + state.finish("service", generation: nextGeneration, graph: nil, error: "late") + #expect(state.state(for: "service").error == "current") + #expect(state.state(for: "service").expanded) + state.invalidate() + #expect(state.state(for: "service").error == nil) + #expect(state.state(for: "service").expandedNodeIDs == ["dependency"]) + release.open() + await oldTask.value + #expect(wasCancelled) + state.retainServices([]) + #expect(!state.state(for: "service").expanded) + } +} + +@MainActor +struct LitheTreeListViewportTests { + @Test func nativePointerAdapterKeepsShortTreesAtTheTop() throws { + let probe = TreeListViewportProbeStore() + let host = NSHostingView( + rootView: LitheTreeList( + ["first", "second"], id: \.self, + selection: .constant(Set()), + native: LitheTreeListNativeInteraction( + copy: {}, paste: {}, selectAll: {}, navigate: { _, _ in nil }, isSelected: { _ in false }) + ) { value in + Text(value).background { if value == "first" { TreeListViewportProbe(store: probe) } } + }.frame(width: 300, height: 400)) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 300, height: 400), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = host + defer { + window.contentView = nil + window.close() + } + host.layoutSubtreeIfNeeded() + let firstRow = try #require(probe.view) + let frame = host.convert(firstRow.bounds, from: firstRow) + let top = host.isFlipped ? frame.minY : host.bounds.maxY - frame.maxY + #expect(top >= LitheTheme.Tree.verticalInset) + #expect(top < LitheTheme.Tree.rowHeight, "A short two-axis tree must not center its rows in the viewport") + } + + @Test func latestOutsidePointerReleasesFocusButUnmountedCallbacksCannot() { + var active = false + var ownsFocus = true + var scheduled: [() -> Void] = [] + let view = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 100)) + let window = NSWindow(contentRect: view.bounds, styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = view + let coordinator = LitheToolWindowActivityTracker.Coordinator( + isActive: Binding(get: { active }, set: { active = $0 }), + onPointerActivity: { if !$0 { ownsFocus = false } }, schedule: { scheduled.append($0) }) + coordinator.view = view + defer { + coordinator.stopObservingFocus() + window.contentView = nil + window.close() + } + func drain() { + let callbacks = scheduled + scheduled.removeAll() + callbacks.forEach { $0() } + } + coordinator.queuePointerActivity(false) + #expect(ownsFocus, "Focus changes must be deferred out of the native callback") + drain() + #expect(!ownsFocus) + ownsFocus = true + coordinator.queuePointerActivity(false) + coordinator.queuePointerActivity(true) + drain() + #expect(ownsFocus, "An older outside click must not override the latest click inside") + coordinator.queuePointerActivity(false) + coordinator.stopObservingFocus() + drain() + #expect(ownsFocus, "Unmount invalidates deferred pointer ownership along with activity") + } +} + +@MainActor +private final class TreeListViewportProbeStore { + weak var view: NSView? +} + +private struct TreeListViewportProbe: NSViewRepresentable { + let store: TreeListViewportProbeStore + func makeNSView(context: Context) -> NSView { + let view = NSView() + store.view = view + return view + } + func updateNSView(_ view: NSView, context: Context) {} +} diff --git a/macos/Tests/LitheTests/LitheTreeRowStyleTests.swift b/macos/Tests/LitheTests/LitheTreeRowStyleTests.swift index d1ff960d69..5ce136fffd 100644 --- a/macos/Tests/LitheTests/LitheTreeRowStyleTests.swift +++ b/macos/Tests/LitheTests/LitheTreeRowStyleTests.swift @@ -17,8 +17,11 @@ struct LitheTreeRowStyleTests { Spacer() } let content = tree - ? AnyView(row.litheTreeRow(isSelected: true, isFocused: focused)) - : AnyView(row.frame(height: 40).litheSelectionRow(isSelected: true, isFocused: focused)) + ? AnyView( + row.frame(height: LitheTheme.Tree.rowHeight).modifier( + LitheTreeListRowSurface(isSelected: true, isFocused: focused))) + : AnyView( + row.frame(height: 40).modifier(LitheTreeListRowSurface(isSelected: true, isFocused: focused))) let renderer = ImageRenderer(content: content.frame(width: 220) .environment(\.colorScheme, scheme) .environment(\.controlActiveState, windowState)) diff --git a/macos/Tests/LitheTests/LocalFileSchemeSupportTests.swift b/macos/Tests/LitheTests/LocalFileSchemeSupportTests.swift new file mode 100644 index 0000000000..30aaf2d439 --- /dev/null +++ b/macos/Tests/LitheTests/LocalFileSchemeSupportTests.swift @@ -0,0 +1,39 @@ +import Foundation +import Testing +import WebKit +@testable import Lithe + +/// A scheme task that only exists to have an identity; nothing is ever delivered to it. +private final class StubSchemeTask: NSObject, WKURLSchemeTask { + let request = URLRequest(url: URL(fileURLWithPath: "/")) + func didReceive(_ response: URLResponse) {} + func didReceive(_ data: Data) {} + func didFinish() {} + func didFailWithError(_ error: any Error) {} + func didPerformRedirection(_ response: HTTPURLResponse, newRequest request: URLRequest) {} +} + +struct LocalFileSchemeSupportTests { + @Test func aTaskCanBeClaimedOnceAndNeverAfterItWasStopped() { + let tasks = LocalFileSchemeTasks() + let answered = StubSchemeTask() + let stopped = StubSchemeTask() + + let answeredID = tasks.register(answered) + let stoppedID = tasks.register(stopped) + tasks.stop(stopped) + + #expect(tasks.claim(answeredID)) + #expect(!tasks.claim(answeredID), "A task is answered once") + #expect(!tasks.claim(stoppedID), "A stopped task must not be touched again") + } + + @Test func containmentComparesWholePathComponents() { + let root = URL(fileURLWithPath: "/work/site") + #expect(LocalFileContainment.contains(root, URL(fileURLWithPath: "/work/site"))) + #expect(LocalFileContainment.contains(root, URL(fileURLWithPath: "/work/site/css/app.css"))) + #expect(!LocalFileContainment.contains(root, URL(fileURLWithPath: "/work/site-private/key"))) + #expect(!LocalFileContainment.contains(root, URL(fileURLWithPath: "/work"))) + #expect(LocalFileContainment.contains(URL(fileURLWithPath: "/"), URL(fileURLWithPath: "/work"))) + } +} diff --git a/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift b/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift index 3ce46fa91f..720c256beb 100644 --- a/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift +++ b/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift @@ -325,7 +325,7 @@ private final class ProgressReports { /// setting in its arguments. @MainActor private struct ProviderFixture { - static let pluginID = "dev.lithe.fixture.gradle-provider" + static let pluginID = "dev.lithe.fixture.build-provider" static let executablePath = "provider/bin/provider" let root: URL @@ -348,7 +348,7 @@ private struct ProviderFixture { try FileManager.default.createDirectory(at: workspaceURL, withIntermediateDirectories: true) let fixtureURL = DeclarativePluginTestSupport.repositoryRoot - .appendingPathComponent("shared/contracts/plugins/v1/fixtures/gradle-provider.plugin.json") + .appendingPathComponent("shared/contracts/plugins/v1/fixtures/build-provider.plugin.json") var object = try #require( try JSONSerialization.jsonObject(with: Data(contentsOf: fixtureURL)) as? [String: Any] ) diff --git a/macos/Tests/LitheTests/MacBuildToolResolverTests.swift b/macos/Tests/LitheTests/MacBuildToolResolverTests.swift index 7d95050082..4f425f3ac0 100644 --- a/macos/Tests/LitheTests/MacBuildToolResolverTests.swift +++ b/macos/Tests/LitheTests/MacBuildToolResolverTests.swift @@ -6,6 +6,149 @@ import Testing @Suite("Build tool resolver version output") @MainActor struct MacBuildToolResolverTests { + @Test + func emptySettingSkipsToWrapperAndPassiveSelectionNeverProbes() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let marker = root.appendingPathComponent("unexpected-probe") + let wrapper = root.appendingPathComponent("wrapper") + try Data("#!/bin/sh\ntouch '\(marker.path)'\n".utf8).write(to: wrapper) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: wrapper.path) + let runtime = ProjectRuntimeService(runtimeLocator: BuildProbeRuntimeLocator(home: root), store: BuildProbeEmptyStore()) + let resolver = MacBuildToolResolver(runtime: runtime, processRunner: MacProcessRunner(), fileStorage: MacFileStorage()) + let invocation = BuildInvocation(executables: [.setting(id: "toolPath", path: nil), .projectFile(path: "wrapper", requires: [])], + versionArguments: ["--version"], minimumVersion: "2") + #expect(resolver.languageImportExecutable(invocation, workspaceURL: root) == wrapper) + let result = try await resolver.resolve(invocation, displayName: "Fixture", workspaceURL: root) { _, _ in + Issue.record("A wrapper must not be probed") + return BuildVersionCheck(satisfied: false, version: nil) + } + #expect(result.executableURL == wrapper) + #expect(result.executesProjectCode) + #expect(!FileManager.default.fileExists(atPath: marker.path)) + let invalid = BuildInvocation(executables: [.setting(id: "toolPath", path: root.appendingPathComponent("missing").path), .projectFile(path: "wrapper", requires: [])]) + #expect(resolver.languageImportExecutable(invalid, workspaceURL: root) == nil) + } + + @Test + func sharedResolverUsesInjectedEnvironmentCommandAndRuntimeDiscovery() { + let custom = URL(fileURLWithPath: "/tools/build") + let resolver = ExecutableCandidateResolver(fileExists: { _ in true }, isExecutable: { $0 == custom }, + commands: { $0 == "build" ? [custom] : [] }, runtimes: { $0 == "build" ? custom : nil }) + let workspace = URL(fileURLWithPath: "/workspace") + for candidate in [ExecutableCandidate.command("build"), .runtime("build"), .environment(variable: "BUILD_HOME", path: "build")] { + guard case .resolved(let result) = resolver.resolve(candidate, workspaceURL: workspace, + environment: ["BUILD_HOME": "/tools"], environmentInputs: ["BUILD_HOME"]) else { + Issue.record("The injected source must resolve") + continue + } + #expect(result.first?.url == custom) + } + #expect(resolver.resolve(.environment(variable: "BUILD_HOME", path: "build"), workspaceURL: workspace, + environment: ["BUILD_HOME": "/tools"], environmentInputs: []) == .skip) + } + @Test + func configuredPathWithSpacesWinsOverDiscoveryAndSuppliesItsDirectory() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let executable = root.appendingPathComponent("custom tools/build-tool") + try FileManager.default.createDirectory(at: executable.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("#!/bin/sh\nprintf 'fixture 2.0\\n'\n".utf8).write(to: executable) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) + let runtime = ProjectRuntimeService(runtimeLocator: BuildProbeRuntimeLocator(home: root), store: BuildProbeEmptyStore()) + let resolver = MacBuildToolResolver(runtime: runtime, processRunner: MacProcessRunner(), fileStorage: MacFileStorage()) + let invocation = BuildInvocation( + executables: [.setting(id: "toolPath", path: executable.path), .command("unavailable")], versionArguments: ["--version"], + minimumVersion: "2" + ) + let resolved = try await resolver.resolve(invocation, displayName: "Fixture", workspaceURL: root) { _, output in + BuildVersionCheck(satisfied: output.contains("fixture 2.0"), version: "2.0") + } + #expect(resolved.executableURL == executable) + #expect(resolved.environment["PATH"]?.hasPrefix(executable.deletingLastPathComponent().path + ":") == true) + #expect(resolver.languageImportExecutable(invocation, workspaceURL: root) == executable) + } + + @Test(arguments: ["missing", "non-executable", "old", "directory"]) + func unusableConfiguredPathNeverFallsBackToAvailableWrapper(_ failure: String) async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let configured = root.appendingPathComponent("configured") + if failure == "directory" { + try FileManager.default.createDirectory(at: configured, withIntermediateDirectories: true) + } else if failure != "missing" { + try Data("#!/bin/sh\nprintf 'fixture 1.0\\n'\n".utf8).write(to: configured) + try FileManager.default.setAttributes([.posixPermissions: failure == "old" ? 0o755 : 0o644], ofItemAtPath: configured.path) + } + let fallback = root.appendingPathComponent("wrapper") + try Data("#!/bin/sh\nexit 0\n".utf8).write(to: fallback) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fallback.path) + let runtime = ProjectRuntimeService(runtimeLocator: BuildProbeRuntimeLocator(home: root), store: BuildProbeEmptyStore()) + let resolver = MacBuildToolResolver(runtime: runtime, processRunner: MacProcessRunner(), fileStorage: MacFileStorage()) + let invocation = BuildInvocation( + executables: [.setting(id: "toolPath", path: configured.path), .projectFile(path: "wrapper", requires: [])], versionArguments: ["--version"], + minimumVersion: "2" + ) + await #expect(throws: BuildToolResolutionError.self) { + try await resolver.resolve(invocation, displayName: "Fixture", workspaceURL: root) { _, _ in + BuildVersionCheck(satisfied: false, version: "1.0") + } + } + } + + @Test + func customRuntimeUsesTheSharedSettingCandidateAndDoesNotFallBack() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let runtime = ProjectRuntimeService(runtimeLocator: BuildProbeRuntimeLocator(home: root), store: BuildProbeEmptyStore()) + let resolver = MacBuildToolResolver(runtime: runtime, processRunner: MacProcessRunner(), fileStorage: MacFileStorage()) + let invocation = BuildInvocation(executables: [.runtime("custom")], runtimeDependencies: [ + .init(id: "custom", hostManaged: false, executables: [.setting(id: "runtimePath", path: root.appendingPathComponent("missing").path), .command("sh")]) + ]) + do { + _ = try await resolver.resolve(invocation, displayName: "Fixture", workspaceURL: root) { _, _ in + BuildVersionCheck(satisfied: true, version: nil) + } + Issue.record("An explicit runtime failure must terminate discovery") + } catch let error as BuildToolResolutionError { #expect(error.terminal) } + } + + @Test + func customRuntimeProbeAndExecutionUseTheSameSiblingHelper() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let executable = root.appendingPathComponent("custom runtime/runtime") + let helper = executable.deletingLastPathComponent().appendingPathComponent("fixture-helper") + try FileManager.default.createDirectory(at: executable.deletingLastPathComponent(), withIntermediateDirectories: true) + for (url, script) in [(executable, "#!/bin/sh\nexec fixture-helper\n"), + (helper, "#!/bin/sh\nprintf 'fixture 2.0\\n'\n")] { + try Data(script.utf8).write(to: url) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + } + // The injected environment has no PATH entry for the helper. Only the + // candidate-specific environment can make this version probe succeed. + let runtime = ProjectRuntimeService(runtimeLocator: BuildProbeRuntimeLocator(home: root), store: BuildProbeEmptyStore()) + let runner = MacProcessRunner() + let resolver = MacBuildToolResolver(runtime: runtime, processRunner: runner, fileStorage: MacFileStorage()) + let invocation = BuildInvocation(executables: [.runtime("custom")], runtimeDependencies: [ + .init(id: "custom", hostManaged: false, minimumVersion: "2", + executables: [.setting(id: "runtimePath", path: executable.path)], versionArguments: ["--version"]) + ]) + let resolved = try await resolver.resolve(invocation, displayName: "Fixture", workspaceURL: root) { id, output in + #expect(id == "custom") + #expect(output == "fixture 2.0\n") + return BuildVersionCheck(satisfied: output == "fixture 2.0\n", version: "2.0") + } + #expect(resolved.environment["PATH"] == executable.deletingLastPathComponent().path) + let request = ProcessRequest(executablePath: resolved.executableURL.path, arguments: [], + environment: resolved.environment, timeoutMilliseconds: 2_000) + let output = await Task.detached { runner.run(request).output }.value + #expect(output == "fixture 2.0\n") + } + @Test func languageImportDiscoversWrapperWithoutExecutingItOrRuntimeProbes() throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) diff --git a/macos/Tests/LitheTests/MacDatabasePluginMigrationTests.swift b/macos/Tests/LitheTests/MacDatabasePluginMigrationTests.swift new file mode 100644 index 0000000000..0582ec528b --- /dev/null +++ b/macos/Tests/LitheTests/MacDatabasePluginMigrationTests.swift @@ -0,0 +1,238 @@ +import Foundation +@testable import Lithe +import LitheModuleAPI +import Testing + +/// What the former built-in database module saved, moved once into the +/// database plugin's state and credentials, in the format the plugin reads. +struct MacDatabasePluginMigrationTests { + private static let pluginID = DatabasePluginTransition.pluginID.rawValue + private static let production = "6F1B0B6E-6A8E-4C35-9F0B-1E2D3C4B5A60" + private static let scratch = "0C2A7B1D-3E4F-4A5B-8C6D-7E8F9A0B1C2D" + private static let reporting = "9A8B7C6D-5E4F-4321-8765-0FEDCBA98765" + private static let analytics = "11111111-2222-4333-8444-555555555555" + + @Test + func connectionsHistoryAndForeverPasswordsMoveIntoThePlugin() throws { + let fixture = try Fixture() + defer { fixture.remove() } + fixture.preferences.set(try json([ + ["id": Self.scratch, "name": "scratch", "kind": "sqlite", "path": "/tmp/scratch.db", + "passwordPersistence": "untilRestart"], + ["id": Self.production, "name": "Production", "kind": "postgresql", "host": "db.example", + "port": 5432, "folderID": Self.analytics, "productionProtection": true], + ["id": Self.reporting, "name": "Reporting", "kind": "mysql", "group": " Reports "], + ]), forKey: MacDatabasePluginMigration.profilesKey) + fixture.preferences.set(try json([["id": Self.analytics, "name": "Analytics"]]), + forKey: MacDatabasePluginMigration.foldersKey) + // 2025-01-01T00:00:00Z is 757,382,400 s after 2001. + fixture.preferences.set(try json([ + ["id": "A0000000-0000-4000-8000-000000000001", "profileID": Self.production, "sql": "SELECT 1", + "kind": "query", "executedAt": 757_382_400.25, "durationMilliseconds": 3], + ["id": "A0000000-0000-4000-8000-000000000002", "profileID": "DELETED0-0000-4000-8000-000000000000", + "sql": "SELECT 2", "kind": "query", "executedAt": 757_382_400, "durationMilliseconds": 1], + ]), forKey: MacDatabasePluginMigration.historyKey) + fixture.legacyPasswords["database.connection.\(Self.production).password"] = "pg-secret" + fixture.legacyPasswords["database.connection.\(Self.reporting).password"] = "my-secret" + fixture.legacyPasswords["database.connection.\(Self.scratch).password"] = "never-kept" + + #expect(fixture.migration.prepare(installation: .fromLitheStore(), stateDirectory: fixture.state) + == .migrated(profiles: 3, passwords: 2)) + + #expect(try fixture.credentials.secret(pluginID: Self.pluginID, key: "connection:\(Self.production):password") == "pg-secret") + #expect(try fixture.credentials.secret(pluginID: Self.pluginID, key: "connection:\(Self.reporting):password") == "my-secret") + #expect(try fixture.credentials.secret(pluginID: Self.pluginID, key: "connection:\(Self.scratch):password") == nil) + + let connections = try fixture.object("connections.json") + #expect(connections["schemaVersion"] as? Int == 1) + let profiles = try #require(connections["profiles"] as? [[String: Any]]) + #expect(profiles.compactMap { $0["name"] as? String } == ["Production", "Reporting", "scratch"]) + #expect(profiles[0]["productionProtection"] as? Bool == true) + #expect(profiles[0]["folderID"] as? String == Self.analytics) + // A legacy group becomes a top-level folder, as the old module did. + let folders = try #require(connections["folders"] as? [[String: Any]]) + let reports = try #require(folders.first { $0["name"] as? String == "Reports" }) + #expect(profiles[1]["folderID"] as? String == reports["id"] as? String) + #expect(profiles[1]["group"] as? String == "") + + let history = try #require(try JSONSerialization.jsonObject(with: Data(contentsOf: fixture.state + .appendingPathComponent("sql-history.json"))) as? [[String: Any]]) + #expect(history.count == 1) + #expect(history[0]["executedAt"] as? Int == 1_735_689_600_250) + + let stored = try String(contentsOf: fixture.state.appendingPathComponent("connections.json"), encoding: .utf8) + #expect(!stored.contains("secret")) + // The old data stays for an older Lithe. + #expect(fixture.preferences.data(forKey: MacDatabasePluginMigration.profilesKey) != nil) + } + + @Test + func theMoveHappensOnceAndNeverReplacesThePluginsOwnConnections() throws { + let fixture = try Fixture() + defer { fixture.remove() } + fixture.preferences.set(try json([["id": Self.production, "name": "Production", "kind": "postgresql"]]), + forKey: MacDatabasePluginMigration.profilesKey) + + #expect(fixture.migration.prepare(installation: .fromLitheStore(id: "dev.example.other"), stateDirectory: fixture.state) == .otherPlugin) + #expect(!FileManager.default.fileExists(atPath: fixture.state.path)) + + try FileManager.default.createDirectory(at: fixture.state, withIntermediateDirectories: true) + let own = Data(#"{"schemaVersion":1,"profiles":[],"folders":[]}"#.utf8) + try own.write(to: fixture.state.appendingPathComponent("connections.json")) + #expect(fixture.migration.prepare(installation: .fromLitheStore(), stateDirectory: fixture.state) == .keptPluginState) + #expect(try Data(contentsOf: fixture.state.appendingPathComponent("connections.json")) == own) + + try FileManager.default.removeItem(at: fixture.state) + #expect(fixture.migration.prepare(installation: .fromLitheStore(), stateDirectory: fixture.state) == .alreadyMigrated) + #expect(!FileManager.default.fileExists(atPath: fixture.state.path)) + } + + /// The package is unsigned, so its ID proves nothing: only the copy + /// Lithe Store lists from the plugin's repository receives the data. + @Test(arguments: [ + PluginInstallationRecord.fromDisk(), + .fromLitheStore(sourceID: "example-source"), + .fromLitheStore(repository: "https://github.com/example/lithe-plugin-database"), + ]) + func aCopyFromAnywhereElseReceivesNothing(installation: PluginInstallationRecord) throws { + let fixture = try Fixture() + defer { fixture.remove() } + fixture.preferences.set(try json([["id": Self.production, "name": "Production", "kind": "postgresql"]]), + forKey: MacDatabasePluginMigration.profilesKey) + fixture.legacyPasswords["database.connection.\(Self.production).password"] = "pg-secret" + + #expect(fixture.migration.prepare(installation: installation, stateDirectory: fixture.state) == .untrustedSource) + #expect(!FileManager.default.fileExists(atPath: fixture.state.path)) + #expect(try fixture.credentials.secret(pluginID: Self.pluginID, key: "connection:\(Self.production):password") == nil) + // The copy from Lithe Store still receives everything later. + #expect(fixture.preferences.object(forKey: MacDatabasePluginMigration.markerKey) == nil) + #expect(fixture.migration.prepare(installation: .fromLitheStore(), stateDirectory: fixture.state) + == .migrated(profiles: 1, passwords: 1)) + } + + @Test + func aPasswordThatCannotMoveLeavesItsConnectionAskingAgain() throws { + let fixture = try Fixture(credentials: LockedCredentials()) + defer { fixture.remove() } + fixture.preferences.set(try json([["id": Self.production, "name": "Production", "kind": "postgresql"]]), + forKey: MacDatabasePluginMigration.profilesKey) + fixture.legacyPasswords["database.connection.\(Self.production).password"] = "pg-secret" + + #expect(fixture.migration.prepare(installation: .fromLitheStore(), stateDirectory: fixture.state) + == .migrated(profiles: 1, passwords: 0)) + #expect(fixture.logged.messages.count == 1) + #expect(fixture.logged.messages.allSatisfy { !$0.contains("pg-secret") }) + } + + @Test + func withoutSavedConnectionsOnlyTheMarkerIsWritten() throws { + let fixture = try Fixture() + defer { fixture.remove() } + + #expect(fixture.migration.prepare(installation: .fromLitheStore(), stateDirectory: fixture.state) == .nothingToMigrate) + #expect(!FileManager.default.fileExists(atPath: fixture.state.path)) + #expect(fixture.preferences.object(forKey: MacDatabasePluginMigration.markerKey) != nil) + } + + private func json(_ value: Any) throws -> Data { + try JSONSerialization.data(withJSONObject: value) + } +} + +private struct Fixture { + let root: URL + let state: URL + let preferences = MigrationPreferences() + let credentials: any PluginCredentialStoring + let logged = MigrationLog() + let legacyPasswordBox = LegacyPasswords() + let migration: MacDatabasePluginMigration + + var legacyPasswords: [String: String] { + get { legacyPasswordBox.values } + nonmutating set { legacyPasswordBox.values = newValue } + } + + init(credentials: any PluginCredentialStoring = MemoryPluginCredentialStore()) throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-database-migration-\(UUID().uuidString)", isDirectory: true) + state = root.appendingPathComponent("state/io.github.lithe-idea.database/backend", isDirectory: true) + self.credentials = credentials + let passwords = legacyPasswordBox + let log = logged + migration = MacDatabasePluginMigration( + preferences: preferences, + legacyPassword: { key in passwords.values[key] }, + credentials: credentials, + log: { message in log.append(message) } + ) + } + + func object(_ name: String) throws -> [String: Any] { + try #require(try JSONSerialization.jsonObject(with: Data(contentsOf: state.appendingPathComponent(name))) as? [String: Any]) + } + + func remove() { + try? FileManager.default.removeItem(at: root) + } +} + +private final class MigrationPreferences: 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 } +} + +private final class LegacyPasswords: @unchecked Sendable { + private let lock = NSLock() + private var stored: [String: String] = [:] + var values: [String: String] { + get { lock.withLock { stored } } + set { lock.withLock { stored = newValue } } + } +} + +private final class MigrationLog: @unchecked Sendable { + private let lock = NSLock() + private var recorded: [String] = [] + var messages: [String] { lock.withLock { recorded } } + func append(_ message: String) { lock.withLock { recorded.append(message) } } +} + +private struct LockedCredentials: PluginCredentialStoring { + struct Locked: LocalizedError { var errorDescription: String? { "The Keychain is locked." } } + func secret(pluginID _: String, key _: String) throws -> String? { throw Locked() } + func setSecret(_: String, pluginID _: String, key _: String) throws { throw Locked() } + func deleteSecret(pluginID _: String, key _: String) throws { throw Locked() } + func deleteAll(pluginID _: String) throws { throw Locked() } +} + +private extension PluginInstallationRecord { + static func fromLitheStore( + id: String = DatabasePluginTransition.pluginID.rawValue, + sourceID: String = PluginMarketplaceSource.communityID, + repository: String = DatabasePluginTransition.repository + ) -> PluginInstallationRecord { + PluginInstallationRecord( + pluginID: PluginID(id), + activeVersion: PluginVersion(major: 0, minor: 1, patch: 0), + origin: .community, + marketplace: PluginMarketplaceProvenance( + sourceID: sourceID, sourceName: "Lithe Store", repository: repository, + archiveSHA256: String(repeating: "0", count: 64), contentDigest: "digest", approvedPrograms: [] + ) + ) + } + + static func fromDisk() -> PluginInstallationRecord { + PluginInstallationRecord( + pluginID: DatabasePluginTransition.pluginID, + activeVersion: PluginVersion(major: 0, minor: 1, patch: 0), + origin: .community, + disk: PluginDiskProvenance(fileName: "database.zip", archiveSHA256: nil, contentDigest: "digest", approvedPrograms: []) + ) + } +} diff --git a/macos/Tests/LitheTests/MacPluginBackendSessionsTests.swift b/macos/Tests/LitheTests/MacPluginBackendSessionsTests.swift new file mode 100644 index 0000000000..c79253d730 --- /dev/null +++ b/macos/Tests/LitheTests/MacPluginBackendSessionsTests.swift @@ -0,0 +1,375 @@ +import Foundation +@testable import Lithe +import LitheCoreContracts +import LitheLanguageIntelligenceModule +import LitheModuleAPI +import Testing + +/// The plugin backend table with a scripted runtime core and Rust Core's +/// real launch planning: where a backend starts, which host services it +/// receives, how its credential requests are answered, and when it stops. +@MainActor +struct MacPluginBackendSessionsTests { + /// The first page call starts the backend in its own state directory, + /// outside the package, with the services its permissions grant; later + /// calls reuse the process. + @Test + func aPageCallStartsOneBackendInItsStateDirectoryWithItsHostServices() async throws { + let fixture = try BackendFixture() + defer { fixture.remove() } + + #expect(try await fixture.sessions.pluginFunctions(ofPlugin: BackendFixture.pluginID) == ["query.run"]) + #expect(try await fixture.sessions.pluginFunctions(ofPlugin: BackendFixture.pluginID) == ["query.run"]) + + let starts = fixture.core.starts + #expect(starts.count == 1) + let stateDirectory = fixture.stateRoot + .appendingPathComponent(BackendFixture.pluginID, isDirectory: true) + .appendingPathComponent("backend", isDirectory: true) + #expect(starts.first?.pluginID == BackendFixture.pluginID) + #expect(starts.first?.hostServices == ["credentials"]) + #expect(starts.first?.rootURL.standardizedFileURL.path == stateDirectory.standardizedFileURL.path) + #expect(starts.first?.arguments == ["--state", stateDirectory.standardizedFileURL.path]) + #expect(FileManager.default.fileExists(atPath: stateDirectory.path)) + } + + /// Host migrations into a plugin's state finish before its backend + /// starts, once per start, with the backend's own state directory. + @Test + func stateIsPreparedBeforeTheBackendStarts() async throws { + let prepared = PreparedStates() + let fixture = try BackendFixture { installation, stateDirectory in + try? FileManager.default.createDirectory(at: stateDirectory, withIntermediateDirectories: true) + try? Data("moved".utf8).write(to: stateDirectory.appendingPathComponent("migrated.json")) + prepared.record(pluginID: installation.pluginID.rawValue, stateDirectory: stateDirectory) + } + defer { fixture.remove() } + + _ = try await fixture.sessions.pluginFunctions(ofPlugin: BackendFixture.pluginID) + + let stateDirectory = try #require(fixture.core.starts.first?.rootURL) + #expect(prepared.calls == [.init(pluginID: BackendFixture.pluginID, path: stateDirectory.standardizedFileURL.path)]) + #expect(FileManager.default.fileExists(atPath: stateDirectory.appendingPathComponent("migrated.json").path)) + } + + /// A credential request is answered from the plugin's own entries, and + /// a store failure reaches the backend without the secret. + @Test + func credentialRequestsAreAnsweredFromThePluginsOwnStore() async throws { + let fixture = try BackendFixture() + defer { fixture.remove() } + try fixture.credentials.setSecret("other", pluginID: "dev.example.other", key: "connection.prod") + _ = try await fixture.sessions.pluginFunctions(ofPlugin: BackendFixture.pluginID) + + fixture.core.sendHostRequest( + id: "host-1", method: "lithe/host/credentials/set", + params: .object(["key": .string("connection.prod"), "secret": .string("s3cret")]) + ) + #expect(await fixture.core.waitForResponses(1)) + fixture.core.sendHostRequest( + id: "host-2", method: "lithe/host/credentials/get", params: .object(["key": .string("connection.prod")]) + ) + #expect(await fixture.core.waitForResponses(2)) + fixture.core.sendHostRequest( + id: "host-3", method: "lithe/host/credentials/delete", params: .object(["key": .string("connection.prod")]) + ) + #expect(await fixture.core.waitForResponses(3)) + + #expect(fixture.core.responses == [ + .init(id: "host-1", outcome: .success(.null)), + .init(id: "host-2", outcome: .success(.object(["secret": .string("s3cret")]))), + .init(id: "host-3", outcome: .success(.null)), + ]) + #expect(try fixture.credentials.secret(pluginID: BackendFixture.pluginID, key: "connection.prod") == nil) + #expect(try fixture.credentials.secret(pluginID: "dev.example.other", key: "connection.prod") == "other") + } + + /// Disabling stops the backend and refuses calls until the plugin is + /// enabled again; a replaced package never starts again in this launch. + @Test + func disablingAndReplacingThePackageStopTheBackend() async throws { + let fixture = try BackendFixture() + defer { fixture.remove() } + _ = try await fixture.sessions.pluginFunctions(ofPlugin: BackendFixture.pluginID) + + fixture.sessions.disable(BackendFixture.pluginID) + #expect(fixture.core.stops.count == 1) + await #expect(throws: PluginFunctionFailure.self) { + _ = try await fixture.sessions.pluginFunctions(ofPlugin: BackendFixture.pluginID) + } + + fixture.sessions.enable(BackendFixture.pluginID) + _ = try await fixture.sessions.pluginFunctions(ofPlugin: BackendFixture.pluginID) + #expect(fixture.core.starts.count == 2) + + fixture.sessions.packageWillChange(BackendFixture.pluginID) + #expect(fixture.core.stops.count == 2) + fixture.sessions.enable(BackendFixture.pluginID) + #expect(!fixture.sessions.declaresBackend(BackendFixture.pluginID)) + await #expect(throws: PluginFunctionFailure.self) { + _ = try await fixture.sessions.pluginFunctions(ofPlugin: BackendFixture.pluginID) + } + } + + /// The answer never echoes a secret, and a request outside the + /// credentials service is refused even if Core forwarded it. + @Test + func hostServiceFailuresNeverCarryTheSecret() { + let failing = FailingCredentialStore() + let set = PluginHostServices.answer( + PluginHostRequest( + id: "host-1", method: "lithe/host/credentials/set", service: "credentials", + params: .object(["key": .string("k"), "secret": .string("top-secret")]) + ), + pluginID: BackendFixture.pluginID, credentials: failing + ) + guard case .failure(let failure) = set else { + Issue.record("A store failure must fail the request.") + return + } + #expect(!failure.message.contains("top-secret")) + + let other = PluginHostServices.answer( + PluginHostRequest(id: "host-2", method: "lithe/host/process/run", service: "process", params: .object([:])), + pluginID: BackendFixture.pluginID, credentials: MemoryPluginCredentialStore() + ) + #expect((try? other.get()) == nil) + } +} + +private struct FailingCredentialStore: PluginCredentialStoring { + struct Locked: LocalizedError { var errorDescription: String? { "The Keychain is locked." } } + func secret(pluginID _: String, key _: String) throws -> String? { throw Locked() } + func setSecret(_: String, pluginID _: String, key _: String) throws { throw Locked() } + func deleteSecret(pluginID _: String, key _: String) throws { throw Locked() } + func deleteAll(pluginID _: String) throws { throw Locked() } +} + +/// The state preparations a fixture's sessions ran, from any thread. +private final class PreparedStates: @unchecked Sendable { + struct Call: Equatable { + let pluginID: String + let path: String + } + + private let lock = NSLock() + private var recorded: [Call] = [] + + var calls: [Call] { lock.withLock { recorded } } + + func record(pluginID: String, stateDirectory: URL) { + lock.withLock { recorded.append(Call(pluginID: pluginID, path: stateDirectory.standardizedFileURL.path)) } + } +} + +/// The backend fixture of the shared contract as an installed package. +@MainActor +private struct BackendFixture { + static let pluginID = "dev.lithe.fixture.backend" + + let root: URL + let packageURL: URL + let stateRoot: URL + let credentials = MemoryPluginCredentialStore() + let core = ScriptedBackendCore() + let sessions: MacPluginBackendSessions + + init(prepareState: @escaping @Sendable (_ installation: PluginInstallationRecord, _ stateDirectory: URL) -> Void = { _, _ in }) throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-plugin-backend-\(UUID().uuidString)", isDirectory: true) + packageURL = root.appendingPathComponent("package", isDirectory: true) + stateRoot = root.appendingPathComponent("state", isDirectory: true) + let fixtureURL = DeclarativePluginTestSupport.repositoryRoot + .appendingPathComponent("shared/contracts/plugins/v1/fixtures/backend.plugin.json") + let object = try #require( + try JSONSerialization.jsonObject(with: Data(contentsOf: fixtureURL)) as? [String: Any] + ) + _ = try DeclarativePluginTestSupport.writePackage(at: packageURL, manifest: object) + let executable = packageURL.appendingPathComponent("bin/backend") + try FileManager.default.createDirectory(at: executable.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("#!/bin/sh\n".utf8).write(to: executable) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) + let operations = DeclarativePluginTestSupport.operations + let descriptor = try operations.validate(manifest: Data(contentsOf: packageURL.appendingPathComponent("plugin.json"))) + #expect(descriptor.declaresBackend) + + sessions = MacPluginBackendSessions( + launchResolver: MacPluginLanguageServerLaunchResolver( + planner: operations, + directories: MacPluginStateDirectories(rootURL: stateRoot), + settings: InMemoryPluginSettings() + ), + tools: UnusedBackendTools(), + versionChecker: operations, + runtime: EmptyBackendToolRuntime(), + core: core, + processRegistry: nil, + credentials: credentials, + prepareState: prepareState, + endBackendProcesses: { _, _ in } + ) + sessions.register([InstalledPluginPackage( + descriptor: descriptor, + installation: PluginInstallationRecord( + pluginID: descriptor.id, activeVersion: descriptor.pluginManifest.version, origin: .localDevelopment + ), + packageURL: packageURL + )]) + } + + func remove() { + sessions.packageWillChange(Self.pluginID) + try? FileManager.default.removeItem(at: root) + } +} + +/// Starts ready plugin backends, queues host requests on demand, and +/// records how the host answered them. +private final class ScriptedBackendCore: LanguageServerRuntimeCore, @unchecked Sendable { + struct Start { + let pluginID: String + let arguments: [String] + let rootURL: URL + let hostServices: [String] + } + + struct Response: Equatable { + let id: String + let outcome: Result + } + + private let lock = NSLock() + private var nextSession = 0 + private var events: [String: [LanguageServerRuntimeEvent]] = [:] + private var _starts: [Start] = [] + private var _stops: [String] = [] + private var _responses: [Response] = [] + private var waiters: [UUID: (count: Int, continuation: CheckedContinuation)] = [:] + + var starts: [Start] { lock.withLock { _starts } } + var stops: [String] { lock.withLock { _stops } } + var responses: [Response] { lock.withLock { _responses } } + + func startPluginLanguageServer( + pluginID: String, providerID _: String, executableURL _: URL, arguments: [String], + environment _: [String: String], rootURL: URL, initializationOptions _: ToolingJSONValue?, + hostServices: [String], initializeTimeout _: TimeInterval, requestTimeout _: TimeInterval, + shutdownTimeout _: TimeInterval + ) -> Result { + lock.withLock { + nextSession += 1 + let sessionID = "backend-\(nextSession)" + _starts.append(Start(pluginID: pluginID, arguments: arguments, rootURL: rootURL, hostServices: hostServices)) + events[sessionID, default: []] += [ + LanguageServerRuntimeEvent( + type: "functionsChanged", + result: .object(["functions": .array([.string("query.run")]), "removed": .array([])]) + ), + LanguageServerRuntimeEvent(type: "stateChanged", state: "ready"), + ] + return .success(LanguageServerRuntimeStart(sessionID: sessionID, state: "initializing", processID: nil)) + } + } + + /// Queues a request Core accepted on the newest session. + func sendHostRequest(id: String, method: String, params: ToolingJSONValue) { + lock.withLock { + events["backend-\(nextSession)", default: []].append(LanguageServerRuntimeEvent( + type: "hostRequest", + result: .object(["hostRequestID": .string(id), "service": .string("credentials"), "params": params]), + method: method + )) + } + } + + func respondToPluginHostRequest( + sessionID _: String, hostRequestID: String, outcome: Result + ) -> Result { + let satisfied = lock.withLock { + _responses.append(Response(id: hostRequestID, outcome: outcome)) + let ready = waiters.filter { $0.value.count <= _responses.count } + ready.keys.forEach { waiters.removeValue(forKey: $0) } + return ready.values.map(\.continuation) + } + satisfied.forEach { $0.resume(returning: true) } + return .success(()) + } + + /// Answers arrive off the main actor. The next answer resumes the + /// waiter; a watchdog fails it after five seconds instead of hanging. + func waitForResponses(_ count: Int) async -> Bool { + await withCheckedContinuation { continuation in + let id = UUID() + let ready: Bool = lock.withLock { + guard _responses.count < count else { return true } + waiters[id] = (count, continuation) + return false + } + if ready { return continuation.resume(returning: true) } + DispatchQueue.global().asyncAfter(deadline: .now() + 5) { [self] in + lock.withLock { waiters.removeValue(forKey: id) }?.continuation.resume(returning: false) + } + } + } + + func startLanguageServer( + providerID _: String, executableURL _: URL, arguments _: [String], environment _: [String: String], + rootURL _: URL, workingDirectoryURL _: URL, initializationOptions _: ToolingJSONValue?, + runtimeExecutableURL _: URL?, jdtlsLaunchResources _: JDTLSLaunchResources?, cacheDirectoryURL _: URL?, + workspaceFingerprint _: String?, initializeTimeout _: TimeInterval, requestTimeout _: TimeInterval, + shutdownTimeout _: TimeInterval + ) -> Result { + .failure(LanguageServerRuntimeFailure(code: "unexpected", message: "A backend is plugin-owned.")) + } + + func stopLanguageServer(sessionID: String) { + lock.withLock { + _stops.append(sessionID) + events[sessionID, default: []].append(LanguageServerRuntimeEvent(type: "stateChanged", state: "stopped")) + } + } + + func pollLanguageServerEvents(sessionID: String) -> [LanguageServerRuntimeEvent] { + lock.withLock { events.removeValue(forKey: sessionID) ?? [] } + } + + func syncLanguageServerDocument( + sessionID _: String, fileURL _: URL, languageID _: String, text _: String + ) -> Result { + .success(LanguageServerDocumentSync(documentVersion: 1, changed: false)) + } + + func closeLanguageServerDocument(sessionID _: String, fileURL _: URL) {} + + func requestLanguageServerOperation( + sessionID _: String, operation _: LanguageServerOperation, fileURL _: URL?, virtualURI _: String?, + position _: LanguageServerPosition?, newName _: String?, range _: LanguageServerRange?, + diagnostics _: [LanguageServerDiagnostic], completionItem _: LanguageServerCompletionItem?, + codeAction _: LanguageServerCodeAction?, command _: LanguageServerCommand? + ) -> Result { + .success(LanguageServerRuntimeOperation(operationID: "unused")) + } + + func cancelLanguageServerOperation(sessionID _: String, operationID _: String) {} + func destroyLanguageServer(sessionID _: String) {} +} + +/// The packaged backend never goes through tool discovery. +private final class UnusedBackendTools: BuildToolResolving { + struct Unexpected: Error {} + + func resolve( + _: BuildInvocation, displayName _: String, workspaceURL _: URL, + checkVersion _: @escaping @Sendable (String?, String) throws -> BuildVersionCheck + ) async throws -> ResolvedBuildTool { + throw Unexpected() + } +} + +private final class EmptyBackendToolRuntime: LanguageToolRuntimePort { + func executableOnPath(_: String) -> URL? { nil } + func executableURL(at _: String) -> URL? { nil } + func executableCandidates(_: String) -> [RuntimeToolCandidate] { [] } + func languageToolProcessEnvironment(runtimeTools _: [String]) -> [String: String] { [:] } + func missingLanguageToolMessage(_ name: String) -> String { name } +} diff --git a/macos/Tests/LitheTests/MacProcessRunnerTests.swift b/macos/Tests/LitheTests/MacProcessRunnerTests.swift index 21addf08d8..8d9ccb7a2f 100644 --- a/macos/Tests/LitheTests/MacProcessRunnerTests.swift +++ b/macos/Tests/LitheTests/MacProcessRunnerTests.swift @@ -127,6 +127,31 @@ struct MacProcessRunnerTests { #expect(waitUntilProcessExitsSynchronously(descendantPID)) } + @Test + func cancellingWorkerTerminatesItsProcessGroupBeforeTheRequestTimeout() async throws { + let fixture = try makeDescendantFixture() + defer { fixture.cleanup() } + let request = fixture.request(operationID: "runner-cancel", timeoutMilliseconds: 5_000) + let worker = Task.detached { MacProcessRunner().run(request) } + defer { worker.cancel() } + + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(2)) + while (try? fixture.descendantPID()) == nil && clock.now < deadline { + // test-stability: allow(swift-real-sleep) reason: OS process startup exposes only a PID file, so poll it with a local deadline. + try? await Task.sleep(for: .milliseconds(10)) + } + let started = clock.now + worker.cancel() + let result = await worker.value + let descendantPID = try fixture.descendantPID() + defer { terminateIfRunning(descendantPID) } + + #expect(result.exitCode == 130) + #expect(clock.now - started < .seconds(2)) + #expect(!processIsRunning(descendantPID)) + } + @Test func rawSessionTimeoutTerminatesDescendantThatClosesPipesAndIgnoresTermination() async throws { let fixture = try makeDescendantFixture() diff --git a/macos/Tests/LitheTests/MacTerminalBackgroundReportingTests.swift b/macos/Tests/LitheTests/MacTerminalBackgroundReportingTests.swift new file mode 100644 index 0000000000..648a8389fd --- /dev/null +++ b/macos/Tests/LitheTests/MacTerminalBackgroundReportingTests.swift @@ -0,0 +1,147 @@ +import AppKit +import Testing +@testable import Lithe + +/// Notifications emitted on background changes for programs subscribed to kitty DEC mode 2031. +@Suite("Terminal background change reporting") +struct MacTerminalBackgroundReportingTests { + @Test + func payloadMatchesSwiftTermOsc11AnswerFormat() { + #expect(MacTerminalBackgroundReporting.osc11Payload(for: .white) == "rgb:ffff/ffff/ffff") + #expect(MacTerminalBackgroundReporting.osc11Payload(for: .black) == "rgb:0000/0000/0000") + // SwiftTerm converts via UInt16(component * 65535), so Lithe's dark editor #191A1C reports 1919/1a1a/1c1c. + let editorDark = NSColor(srgbRed: 25 / 255, green: 26 / 255, blue: 28 / 255, alpha: 1) + #expect(MacTerminalBackgroundReporting.osc11Payload(for: editorDark) == "rgb:1919/1a1a/1c1c") + } + + @Test + func wallpaperTransparencyIsReportedAsBlack() { + let transparentBlack = NSColor.black.withAlphaComponent(0) + #expect(MacTerminalBackgroundReporting.osc11Payload(for: transparentBlack) == "rgb:0000/0000/0000") + } + + @Test + func doesNotNotifyWhenUnsubscribed() { + #expect(MacTerminalBackgroundReporting.changeNotification(for: .white, isSubscribed: false) == nil) + } + + @Test + func changeNotificationReportsLightStateForLightBackground() { + let notification = MacTerminalBackgroundReporting.changeNotification(for: .white, isSubscribed: true) + #expect(notification == "\u{1b}]11;rgb:ffff/ffff/ffff\u{1b}\\\u{1b}[?997;2n") + } + + @Test + func changeNotificationReportsDarkStateForDarkBackground() { + let notification = MacTerminalBackgroundReporting.changeNotification(for: .black, isSubscribed: true) + #expect(notification == "\u{1b}]11;rgb:0000/0000/0000\u{1b}\\\u{1b}[?997;1n") + } + + @Test + func changeNotificationReportsDarkStateForWallpaperTransparency() { + let transparentBlack = NSColor.black.withAlphaComponent(0) + let notification = MacTerminalBackgroundReporting.changeNotification(for: transparentBlack, isSubscribed: true) + #expect(notification == "\u{1b}]11;rgb:0000/0000/0000\u{1b}\\\u{1b}[?997;1n") + } +} + +@Suite("Kitty background-change subscription") +struct MacTerminalBackgroundSubscriptionTests { + @Test + func startsUnsubscribed() { + #expect(!MacTerminalBackgroundSubscription().isSubscribed) + } + + @Test + func subscribesOnDecMode2031Set() { + var subscription = MacTerminalBackgroundSubscription() + subscription.feed("\u{1b}[?2031h".utf8) + #expect(subscription.isSubscribed) + } + + @Test + func subscribesWhenMode2031IsCombinedWithOtherDecModes() { + var subscription = MacTerminalBackgroundSubscription() + subscription.feed("\u{1b}[?1;2031h".utf8) + #expect(subscription.isSubscribed) + } + + @Test + func doesNotSubscribeToModeWithSimilarNumber() { + var subscription = MacTerminalBackgroundSubscription() + subscription.feed("\u{1b}[?20310h".utf8) + #expect(!subscription.isSubscribed) + } + + @Test + func unsubscribesOnDecMode2031Reset() { + var subscription = MacTerminalBackgroundSubscription() + subscription.feed("\u{1b}[?2031h".utf8) + subscription.feed("\u{1b}[?2031l".utf8) + #expect(!subscription.isSubscribed) + } + + @Test + func detectsMarkerSplitAcrossChunks() { + var subscription = MacTerminalBackgroundSubscription() + subscription.feed("\u{1b}[?20".utf8) + #expect(!subscription.isSubscribed) + subscription.feed("31h".utf8) + #expect(subscription.isSubscribed) + } + + @Test + func terminalResetClearsSubscription() { + var subscription = MacTerminalBackgroundSubscription() + subscription.feed("\u{1b}[?2031h".utf8) + subscription.feed("\u{1b}c".utf8) + #expect(!subscription.isSubscribed) + } + + @Test + func processRestartClearsSubscription() { + var subscription = MacTerminalBackgroundSubscription() + subscription.feed("\u{1b}[?2031h".utf8) + subscription.reset() + #expect(!subscription.isSubscribed) + } + + @Test + func altScreen1049ExitClearsSubscription() { + var subscription = MacTerminalBackgroundSubscription() + subscription.feed("\u{1b}[?2031h".utf8) + subscription.feed("\u{1b}[?1049l".utf8) + #expect(!subscription.isSubscribed) + } + + @Test + func altScreen47ExitClearsSubscription() { + var subscription = MacTerminalBackgroundSubscription() + subscription.feed("\u{1b}[?2031h".utf8) + subscription.feed("\u{1b}[?47l".utf8) + #expect(!subscription.isSubscribed) + } + + @Test + func altScreen1047ExitClearsSubscription() { + var subscription = MacTerminalBackgroundSubscription() + subscription.feed("\u{1b}[?2031h".utf8) + subscription.feed("\u{1b}[?1047l".utf8) + #expect(!subscription.isSubscribed) + } + + @Test + func altScreenEnterKeepsSubscription() { + var subscription = MacTerminalBackgroundSubscription() + subscription.feed("\u{1b}[?2031h".utf8) + subscription.feed("\u{1b}[?1049h".utf8) + #expect(subscription.isSubscribed) + } + + @Test + func plainOutputStaysUnsubscribed() { + var subscription = MacTerminalBackgroundSubscription() + subscription.feed("ls -la\r\nhello world".utf8) + #expect(!subscription.isSubscribed) + } +} diff --git a/macos/Tests/LitheTests/McpArgumentRoundTripTests.swift b/macos/Tests/LitheTests/McpArgumentRoundTripTests.swift index f0155250b2..d7c0a0a6b0 100644 --- a/macos/Tests/LitheTests/McpArgumentRoundTripTests.swift +++ b/macos/Tests/LitheTests/McpArgumentRoundTripTests.swift @@ -1,10 +1,308 @@ +import AppKit import Foundation +import SwiftUI import Testing @testable import Lithe /// Exercise discovery -> draft -> feature save -> Core reparse using isolated real files. @MainActor struct McpArgumentRoundTripTests { + @Test func cancellingBeforeTheNativeWorkerStartsDoesNotConnect() async throws { + let fixture = try ArgumentFixture() + defer { fixture.cleanUp() } + let service = try #require(fixture.mcpTools) + let definition = McpServerDefinition(name: "cancelled", transport: .http, command: nil, args: [], env: [], + url: "https://example.invalid/mcp", headers: [], definitionHash: "fixture") + // Both tasks inherit MainActor: cancellation precedes the first probe + // instruction, without a scheduling delay or a public-network fixture. + let task = Task { try await service.probe(definition) } + task.cancel() + do { + _ = try await task.value + Issue.record("A cancelled probe must not publish a result") + } catch { + #expect(error is CancellationError) + } + } + + @Test(.enabled(if: ProcessInfo.processInfo.environment["LITHE_MCP_PROBE_INTEGRATION"] == "1")) + func publicContext7ProbeRunsOnTheNativeWorker() async throws { + let fixture = try ArgumentFixture() + defer { fixture.cleanUp() } + let service = try #require(fixture.mcpTools) + let definition = McpServerDefinition(name: "context7", transport: .http, command: nil, args: [], env: [], + url: "https://mcp.context7.com/mcp", headers: [], definitionHash: "integration") + // Explicit integration opt-in: verifies the real linked Swift/SDK path, + // including proxy/TLS stack depth, without calling any remote tool. + let result = try await service.probe(definition) + #expect(result.tools.contains { $0.name == "resolve-library-id" }) + #expect(result.tools.contains { $0.name == "query-docs" }) + } + + @Test func renamingDoesNotBypassClaudeToolRestrictions() throws { + let fixture = try ArgumentFixture() + defer { fixture.cleanUp() } + try fixture.seed(.claudeCode, arguments: []) + try fixture.externalPolicyChange(.claudeCode) + let feature = fixture.feature(); feature.refresh() + let row = try #require(feature.rows.first) + var draft = feature.draft(for: row); draft.name = "renamed" + #expect(!feature.save(draft)) + #expect(feature.error != nil) + #expect(try fixture.reparse(.claudeCode).name == "alpha") + } + + @Test func codexRenamePreservesOnlyOwnedExactToolRulesAndAllowsReenabling() throws { + let fixture = try ArgumentFixture() + defer { fixture.cleanUp() } + let feature = fixture.feature() + var draft = feature.makeDraft() + draft.name = "alpha"; draft.command = "/bin/echo"; draft.enabledAgents = [.codex] + try #require(feature.save(draft)) + // The user owns read's deny and allow-list; Lithe owns only write's deny. + try fixture.codexFilters(allow: ["read", "write"], deny: ["read"]) + feature.refresh() + feature.takeOwnership(of: try #require(feature.rows.first), agent: .codex) + let row = try #require(feature.rows.first) + let service = try #require(fixture.mcpTools) + let target = McpToolTarget(definition: row.definition, files: row.globalSources.map(\.file)) + let off = try service.setEnabled(false, tool: "write", target: target, + snapshot: service.policy(for: target, agent: .codex, tools: ["read", "write", "outside"])) + feature.tools.didChangePolicy?(row, off) + service.release([off]) + var renamed = feature.draft(for: try #require(feature.rows.first)); renamed.name = "beta" + try #require(feature.save(renamed)) + #expect(try fixture.reparse(.codex, name: "beta").name == "beta") + let beta = try #require(feature.rows.first) + let betaTarget = McpToolTarget(definition: beta.definition, files: beta.globalSources.map(\.file)) + let policy = try service.policy(for: betaTarget, agent: .codex, tools: ["read", "write", "outside"]) + #expect(policy.tools.first { $0.name == "write" }?.enabled == false) + #expect(policy.tools.first { $0.name == "write" }?.editable == true) + #expect(policy.tools.first { $0.name == "read" }?.editable == false) + #expect(policy.tools.first { $0.name == "outside" }?.editable == false) + let on = try service.setEnabled(true, tool: "write", target: betaTarget, snapshot: policy) + feature.tools.didChangePolicy?(beta, on) + service.release([on]) + #expect(on.tools.first { $0.name == "write" }?.enabled == true) + #expect(on.tools.first { $0.name == "read" }?.enabled == false) + + // Reusing alpha must not inherit the ownership moved to beta. + try #require(feature.save(draft)) + try fixture.codexFilters(allow: ["read", "write"], deny: ["write"]) + feature.refresh() + let alpha = try #require(feature.rows.first { $0.name == "alpha" }) + let alphaPolicy = try service.policy(for: McpToolTarget(definition: alpha.definition, + files: alpha.globalSources.map(\.file)), agent: .codex, tools: ["write"]) + defer { service.release([alphaPolicy]) } + #expect(alphaPolicy.tools.first?.enabled == false) + #expect(alphaPolicy.tools.first?.editable == false) + } + + @Test func compactEditorFitsBothNativeAppearances() throws { + let fixture = try ArgumentFixture() + defer { fixture.cleanUp() } + let feature = fixture.feature() + let entries = try MacMcpRegistry(core: RustCoreBridge()).featured().entries + let entry = try #require(entries.first { $0.id == "lithe.featured/playwright" }) + var draft = try #require(McpServerDraft(entry: entry)); draft.enabledAgents = Set(McpAgent.allCases) + let directory = DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent(".artifacts/test-stability/mcp-ui") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + for dark in [false, true] { + let view = McpServerEditorView(feature: feature, draft: .constant(draft), onClose: {}) + .padding(20).frame(width: 640, height: 500) + .environment(\.colorScheme, dark ? .dark : .light) + .background(LitheTheme.settingsSurface) + let host = NSHostingView(rootView: view) + host.appearance = NSAppearance(named: dark ? .darkAqua : .aqua) + #expect(host.fittingSize == NSSize(width: 640, height: 500)) + host.frame = NSRect(x: 0, y: 0, width: 640, height: 500); host.layoutSubtreeIfNeeded() + let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: bitmap) + try #require(bitmap.representation(using: .png, properties: [:])).write(to: directory.appendingPathComponent("editor-\(dark ? "dark" : "light").png")) + } + } + + @Test(arguments: McpAgent.allCases) + func toolExclusionsRoundTripAndKeepOwnedServerRemovalWorking(agent: McpAgent) throws { + let fixture = try ArgumentFixture() + defer { fixture.cleanUp() } + let feature = fixture.feature() + var draft = feature.makeDraft(); draft.name = "alpha"; draft.command = "/bin/echo"; draft.enabledAgents = [agent] + try #require(feature.save(draft)) + let row = try #require(feature.rows.first) + let service = try #require(fixture.mcpTools) + let target = McpToolTarget(definition: row.definition, files: row.globalSources.map(\.file)) + let policy = try service.policy(for: target, agent: agent, tools: ["read", "write"]) + let off = try service.setEnabled(false, tool: "write", target: target, snapshot: policy) + #expect(off.tools.first { $0.name == "write" }?.enabled == false) + let reopened = try service.policy(for: target, agent: agent, tools: ["read", "write"]) + #expect(reopened.tools.first { $0.name == "write" }?.enabled == false) + let on = try service.setEnabled(true, tool: "write", target: target, snapshot: reopened) + #expect(on.tools.allSatisfy { $0.enabled }) + service.release([off, on]) + feature.refresh() + // Direct adapter changes intentionally do not adopt server ownership. + // The page model advances the matching source hash after its own edit. + let current = try #require(feature.rows.first) + let page = feature.tools + page.didChangePolicy?(row, off) + page.didChangePolicy?(current, on) + #expect(feature.rows.first?.definition.name == "alpha") + try #require(feature.setAgent(agent, enabled: false, for: try #require(feature.rows.first))) + #expect(feature.rows.isEmpty) + } + + @Test(arguments: McpAgent.allCases) + func staleToolPolicySnapshotPreservesExternalNativeEdits(agent: McpAgent) throws { + let fixture = try ArgumentFixture() + defer { fixture.cleanUp() } + try fixture.seed(agent, arguments: []) + let feature = fixture.feature(); feature.refresh() + let row = try #require(feature.rows.first) + let service = try #require(fixture.mcpTools) + let target = McpToolTarget(definition: row.definition, files: row.globalSources.map(\.file)) + let snapshot = try service.policy(for: target, agent: agent, tools: ["read"]) + try fixture.externalPolicyChange(agent) + #expect(throws: (any Error).self) { + try service.setEnabled(false, tool: "read", target: target, snapshot: snapshot) + } + let latest = try service.policy(for: target, agent: agent, tools: ["read"]) + #expect(latest.tools.first?.enabled == false) + #expect(latest.tools.first?.editable == false) + service.release([snapshot, latest]) + } + + @Test(arguments: ["switch", "editor", "delete"]) + func disabledToolsSurviveServerRemovalAndReopening(action: String) throws { + let fixture = try ArgumentFixture() + defer { fixture.cleanUp() } + let feature = fixture.feature() + var draft = feature.makeDraft() + draft.name = "alpha"; draft.command = "/bin/echo" + draft.env = [McpKeyValue(key: "TOKEN", value: "fixture-secret-must-not-be-archived")] + draft.enabledAgents = Set(McpAgent.allCases) + try #require(feature.save(draft)) + try fixture.codexFilters(allow: ["read", "write"], deny: ["read"]) + feature.refresh() + feature.takeOwnership(of: try #require(feature.rows.first), agent: .codex) + let row = try #require(feature.rows.first) + let service = try #require(fixture.mcpTools) + let target = McpToolTarget(definition: row.definition, files: row.globalSources.map(\.file)) + let names = ["read", "write", "outside-allowlist"] + let policy = try service.policy(for: target, agent: .codex, tools: names) + let off = try service.setEnabled(false, tool: "write", target: target, snapshot: policy) + feature.tools.didChangePolicy?(row, off) + service.release([off]) + let claudeBefore = try fixture.nativeText(.claudeCode) + let current = try #require(feature.rows.first) + switch action { + case "switch": try #require(feature.setAgent(.codex, enabled: false, for: current)) + case "editor": + var edit = feature.draft(for: current); edit.enabledAgents.remove(.codex) + try #require(feature.save(edit)) + default: try #require(feature.delete(current)) + } + #expect(try !fixture.nativeText(.codex).contains("mcp_servers.alpha")) + #expect(!fixture.ledgerText().contains("fixture-secret-must-not-be-archived")) + + // A new page reads durable state, rather than the old policy snapshot. + let reopened = fixture.feature(); reopened.refresh() + if action == "switch" { + try #require(reopened.setAgent(.codex, enabled: true, for: try #require(reopened.rows.first))) + } else { + var add = McpServerDraft(definition: row.definition) + add.enabledAgents = Set(McpAgent.allCases) + try #require(reopened.save(add)) + } + let restored = try #require(reopened.rows.first) + let restoredTarget = McpToolTarget(definition: restored.definition, files: restored.globalSources.map(\.file)) + let restoredPolicy = try service.policy(for: restoredTarget, agent: .codex, tools: names) + defer { service.release([restoredPolicy]) } + #expect(restoredPolicy.tools.allSatisfy { !$0.enabled }) + #expect(restoredPolicy.tools.first { $0.name == "read" }?.editable == false) + #expect(restoredPolicy.tools.first { $0.name == "write" }?.editable == true) + #expect(restoredPolicy.tools.first { $0.name == "outside-allowlist" }?.editable == false) + let claude = try service.policy(for: restoredTarget, agent: .claudeCode, tools: names) + service.release([claude]) + #expect(claude.tools.allSatisfy { $0.enabled }) + if action != "delete" { #expect(try fixture.nativeText(.claudeCode) == claudeBefore) } + + let on = try service.setEnabled(true, tool: "write", target: restoredTarget, snapshot: restoredPolicy) + service.release([on]); reopened.tools.didChangePolicy?(restored, on) + #expect(on.tools.first { $0.name == "write" }?.enabled == true) + #expect(on.tools.first { $0.name == "read" }?.enabled == false) + try #require(reopened.setAgent(.codex, enabled: false, for: try #require(reopened.rows.first))) + } + + @Test func retainedFiltersStayWithTheirNativeProfilePath() throws { + let ledger = ArgumentKeyValueStore() + let first = try ArgumentFixture(ledger: ledger) + defer { first.cleanUp() } + let second = try ArgumentFixture(ledger: ledger) + defer { second.cleanUp() } + var draft = McpServerDraft(); draft.name = "alpha"; draft.command = "/bin/echo" + draft.enabledAgents = Set(McpAgent.allCases) + let original = first.feature() + try #require(original.save(draft)) + try first.codexFilters(allow: [], deny: ["read"]) + original.refresh() + original.takeOwnership(of: try #require(original.rows.first), agent: .codex) + try #require(original.setAgent(.codex, enabled: false, for: try #require(original.rows.first))) + + let otherProfile = second.feature() + draft.enabledAgents = [.codex] + try #require(otherProfile.save(draft)) + #expect(try !second.nativeText(.codex).contains("enabled_tools")) + #expect(try !second.nativeText(.codex).contains("disabled_tools")) + try #require(otherProfile.delete(try #require(otherProfile.rows.first))) + + // Returning to the original native path restores its empty allow-list, + // even after a different profile reused the same name and preferences. + let reopened = first.feature(); reopened.refresh() + try #require(reopened.setAgent(.codex, enabled: true, for: try #require(reopened.rows.first))) + let row = try #require(reopened.rows.first) + let service = try #require(first.mcpTools) + let policy = try service.policy(for: McpToolTarget(definition: row.definition, files: row.globalSources.map(\.file)), + agent: .codex, tools: ["read", "write"]) + defer { service.release([policy]) } + #expect(policy.tools.allSatisfy { !$0.enabled && !$0.editable }) + } + + @Test(arguments: McpAgent.allCases, ["github", "deepwiki", "exa", "filesystem", "fetch", "memory"]) + func featuredSetupIsExplicitAndRoundTripsThroughNativeConfiguration(agent: McpAgent, name: String) throws { + let fixture = try ArgumentFixture() + defer { fixture.cleanUp() } + let feature = fixture.feature() + let entries = try MacMcpRegistry(core: RustCoreBridge()).featured().entries + let entry = try #require(entries.first { $0.id == "lithe.featured/\(name)" }) + var draft = try #require(McpServerDraft(entry: entry)) + #expect(draft.enabledAgents.isEmpty) + #expect(!feature.save(draft)) + #expect(try fixture.hasConfiguration(agent) == false) + draft.enabledAgents = [agent] + switch draft.setup { + case .bearerToken: + #expect(!feature.save(draft)) + draft.headers[0].value = "Bearer " + #expect(!feature.save(draft)) + #expect(try fixture.hasConfiguration(agent) == false) + draft.headers[0].value = "Bearer fixture-token" + case .allowedDirectories: + #expect(!feature.save(draft)) + draft.argumentsText += "\nrelative-folder" + #expect(!feature.save(draft)) + #expect(try fixture.hasConfiguration(agent) == false) + draft.argumentsText = try #require(entry.draft).args.joined(separator: "\n") + "\n" + fixture.allowedFolder() + case nil: break + } + try #require(feature.save(draft)) + let reparsed = try fixture.reparse(agent, name: name) + #expect(reparsed.args == draft.arguments) + #expect(reparsed.url == (draft.url.isEmpty ? nil : draft.url)) + #expect(reparsed.headers == draft.headers) + #expect(feature.rows.first?.enabledGlobalAgents == [agent]) + } + @Test( arguments: McpAgent.allCases, [ @@ -94,13 +392,18 @@ struct McpArgumentRoundTripTests { private final class ArgumentFixture: McpServerLibraryActions { private let root: URL private let store: MacAgentMcpConfigurationStore - private let ledger = ArgumentKeyValueStore() + private let ledger: ArgumentKeyValueStore + private lazy var toolService = MacMcpTools(core: RustCoreBridge(), store: store, ledger: ledger, + storage: ArgumentFileStorage(root: root), environment: ["CLAUDE_CONFIG_DIR": root.appendingPathComponent("claude").path]) + var mcpTools: (any McpToolsManaging)? { toolService } + var mcpRegistry: (any McpRegistryProviding)? { MacMcpRegistry(core: RustCoreBridge()) } var mcpConfigurationStore: (any McpConfigurationStore)? { store } var mcpWorkspaceURL: URL? { nil } var mcpLedgerStore: any KeyValueStore { ledger } - init() throws { + init(ledger: ArgumentKeyValueStore = ArgumentKeyValueStore()) throws { + self.ledger = ledger let directory = FileManager.default.temporaryDirectory .appendingPathComponent("mcp-arguments-\(UUID().uuidString)", isDirectory: true) root = directory @@ -151,12 +454,50 @@ private final class ArgumentFixture: McpServerLibraryActions { try Data(content.utf8).write(to: file.url) } - func reparse(_ agent: McpAgent) throws -> McpServerDefinition { + func hasConfiguration(_ agent: McpAgent) throws -> Bool { + try store.read(file(for: agent)) != nil + } + + func nativeText(_ agent: McpAgent) throws -> String { try store.read(file(for: agent)) ?? "" } + + func codexFilters(allow: [String], deny: [String]) throws { + let file = try file(for: .codex) + let encode = { (values: [String]) in String(decoding: try JSONEncoder().encode(values), as: UTF8.self) } + let before = try nativeText(.codex) + let header = "[mcp_servers.alpha]\n" + try #require(before.contains(header)) + let text = before.replacingOccurrences(of: header, with: header + "enabled_tools = \(try encode(allow))\ndisabled_tools = \(try encode(deny))\n") + try Data(text.utf8).write(to: file.url) + } + + func ledgerText() -> String { + ledger.data(forKey: "settings.mcpServerRegistrations").map { String(decoding: $0, as: UTF8.self) } ?? "" + } + + func allowedFolder() throws -> String { + let directory = root.appendingPathComponent("allowed folder", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory.path + } + + func externalPolicyChange(_ agent: McpAgent) throws { + if agent == .codex { + let file = try file(for: agent) + let original = try #require(try store.read(file)) + try Data((original + "\ndisabled_tools = ['read']\n").utf8).write(to: file.url) + } else { + let path = root.appendingPathComponent("claude/settings.json") + try FileManager.default.createDirectory(at: path.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data(#"{"keep":"external","permissions":{"deny":["mcp__alpha__read"]}}"#.utf8).write(to: path) + } + } + + func reparse(_ agent: McpAgent, name: String = "alpha") throws -> McpServerDefinition { let file = try file(for: agent) let content = try #require(try store.read(file)) let definitions = try store.parse(file, content: content) try #require(definitions.count == 1) - return try #require(definitions.first { $0.name == "alpha" }) + return try #require(definitions.first { $0.name == name }) } func cleanUp() { diff --git a/macos/Tests/LitheTests/McpBatchTransactionTests.swift b/macos/Tests/LitheTests/McpBatchTransactionTests.swift index 54832fda5d..53e1d212c5 100644 --- a/macos/Tests/LitheTests/McpBatchTransactionTests.swift +++ b/macos/Tests/LitheTests/McpBatchTransactionTests.swift @@ -75,6 +75,39 @@ struct McpBatchTransactionTests { #expect(feature.rows.isEmpty) } + @Test + func failedRenameKeepsToolOwnershipUntilTheWholeBatchCommits() throws { + let fixture = try BatchFixture() + defer { fixture.cleanUp() } + let feature = McpServerLibraryFeatureModel(model: fixture) + var draft = feature.makeDraft() + draft.name = "alpha"; draft.command = "/bin/echo"; draft.enabledAgents = Set(McpAgent.allCases) + try #require(feature.save(draft)) + let row = try #require(feature.rows.first) + let service = try #require(fixture.mcpTools) + let target = McpToolTarget(definition: row.definition, files: row.globalSources.map(\.file)) + let off = try service.setEnabled(false, tool: "write", target: target, + snapshot: service.policy(for: target, agent: .codex, tools: ["write"])) + feature.tools.didChangePolicy?(row, off) + service.release([off]) + let before = fixture.ledger.values + fixture.store.written.removeAll() + fixture.store.beforeSecondWrite = { _, current in + throw McpConfigurationError.notWritable(current.url.path) + } + var edit = feature.draft(for: try #require(feature.rows.first)); edit.name = "beta" + #expect(!feature.save(edit)) + #expect(fixture.store.restored.count == 1) + #expect(NSDictionary(dictionary: fixture.ledger.values).isEqual(to: before)) + let restored = try #require(feature.rows.first) + #expect(restored.name == "alpha") + let restoredTarget = McpToolTarget(definition: restored.definition, files: restored.globalSources.map(\.file)) + let policy = try service.policy(for: restoredTarget, agent: .codex, tools: ["write"]) + defer { service.release([policy]) } + #expect(policy.tools.first?.enabled == false) + #expect(policy.tools.first?.editable == true) + } + @Test func failedProjectRegistrationDoesNotPublishEitherAgentOrLedger() throws { let fixture = try BatchFixture() @@ -104,6 +137,9 @@ private final class BatchFixture: McpServerLibraryActions { let root: URL let store: FailingBatchStore let ledger = BatchLedger() + private lazy var tools = MacMcpTools(core: RustCoreBridge(), store: store, ledger: ledger, + storage: BatchFileStorage(root: root), environment: ["CLAUDE_CONFIG_DIR": root.appendingPathComponent("claude").path]) + var mcpTools: (any McpToolsManaging)? { tools } var mcpWorkspaceURL: URL? { root.appendingPathComponent("project") } var mcpConfigurationStore: (any McpConfigurationStore)? { store } var mcpLedgerStore: any KeyValueStore { ledger } diff --git a/macos/Tests/LitheTests/McpRegistryIntegrationTests.swift b/macos/Tests/LitheTests/McpRegistryIntegrationTests.swift new file mode 100644 index 0000000000..bc22bcdb39 --- /dev/null +++ b/macos/Tests/LitheTests/McpRegistryIntegrationTests.swift @@ -0,0 +1,18 @@ +import Foundation +import Testing +@testable import Lithe + +/// Opt-in public-network verification; ordinary unit tests remain offline. +@MainActor +struct McpRegistryIntegrationTests { + @Test(.enabled(if: ProcessInfo.processInfo.environment["LITHE_RUN_MCP_REGISTRY_INTEGRATION"] == "1")) + func explicitlySelectedDirectRouteReadsAndPaginatesTheOfficialRegistry() async throws { + let registry = MacMcpRegistry(core: RustCoreBridge()) + let first = try await registry.search(query: "mcp", cursor: nil, route: .direct) + #expect(!first.entries.isEmpty) + let cursor = try #require(first.nextCursor) + let second = try await registry.search(query: "mcp", cursor: cursor, route: .direct) + #expect(!second.entries.isEmpty) + #expect(Set(first.entries.map(\.id)) != Set(second.entries.map(\.id))) + } +} diff --git a/macos/Tests/LitheTests/McpRegistryTests.swift b/macos/Tests/LitheTests/McpRegistryTests.swift new file mode 100644 index 0000000000..14908261a2 --- /dev/null +++ b/macos/Tests/LitheTests/McpRegistryTests.swift @@ -0,0 +1,194 @@ +import AppKit +import SwiftUI +import Testing +@testable import Lithe + +@MainActor +struct McpRegistryTests { + @Test func nativeDirectoryCardsReflowInLightAndDarkAppearances() throws { + struct Payload: Encodable {} + let root = DeclarativePluginTestSupport.repositoryRoot + let page: McpRegistryPage = try RustCoreBridge().executeResult(command: "mcp.featuredRegistry", payload: Payload()).get() + let directory = root.appendingPathComponent(".artifacts/mcp-discovery") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + for dark in [false, true] { + var heights: [CGFloat] = [] + for width: CGFloat in [420, 780] { + let view = McpRegistryCardsView(entries: page.entries, add: { _ in }) + .padding(16).frame(width: width) + .environment(\.colorScheme, dark ? .dark : .light) + .environment(\.locale, Locale(identifier: "zh-Hans")) + .background(LitheTheme.settingsSurface) + let host = NSHostingView(rootView: view) + host.appearance = NSAppearance(named: dark ? .darkAqua : .aqua) + let size = host.fittingSize + #expect(abs(size.width - width) < 1) + heights.append(size.height) + host.frame = NSRect(origin: .zero, size: size) + host.layoutSubtreeIfNeeded() + let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: bitmap) + let png = try #require(bitmap.representation(using: .png, properties: [:])) + try png.write(to: directory.appendingPathComponent("cards-\(dark ? "dark" : "light")-\(Int(width)).png")) + } + #expect(heights[0] > heights[1], "Narrow settings pages must stack the catalog cards.") + } + } + + @Test func landingCatalogDoesNotDependOnRegistryConnectivity() async throws { + let registry = RegistryStub() + registry.featuredEntries = try MacMcpRegistry(core: RustCoreBridge()).featured().entries + registry.fail = true + let feature = McpRegistryFeatureModel(registry: registry) + #expect(feature.entries.count == 10) + #expect(feature.entries.allSatisfy { $0.source == .featured && $0.draft != nil }) + await feature.search(" ") + #expect(registry.requests.isEmpty) + #expect(feature.entries.count == 10 && feature.error == nil) + #expect(!feature.isLoading && feature.nextCursor == nil) + } + + @Test func featuredMarksLoadOfflineAndPublicEntriesUseTheFallback() throws { + let entries = try MacMcpRegistry(core: RustCoreBridge()).featured().entries + for entry in entries { + let image = try #require(McpBrandIconLoader.image(for: entry), "Missing bundled mark for \(entry.id)") + #expect(!image.isTemplate) + #expect(image.size == NSSize(width: 28, height: 28)) + var publicCopy = entry + publicCopy.source = .registry + #expect(McpBrandIconLoader.filename(for: publicCopy) == nil) + #expect(McpBrandIconLoader.image(for: publicCopy) == nil) + publicCopy.setup = .bearerToken + #expect(McpServerDraft(entry: publicCopy)?.setup == nil) + } + } + + @Test func sharedSetupFixtureDecodesWithoutSelectingAgentsOrInventingInputs() throws { + let path = DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent("shared/fixtures/mcp/featured-setup-v1.json") + let page = try JSONDecoder().decode(McpRegistryPage.self, from: Data(contentsOf: path)) + #expect(page.entries.map(\.setup) == [.bearerToken, .allowedDirectories]) + let token = try #require(McpServerDraft(entry: page.entries[0])) + let folders = try #require(McpServerDraft(entry: page.entries[1])) + #expect(token.headers == [McpKeyValue(key: "Authorization", value: "")]) + #expect(folders.arguments == ["-y", "@example/filesystem@1.2.3"]) + #expect(token.enabledAgents.isEmpty && folders.enabledAgents.isEmpty) + } + + @Test func connectionFailureKeepsMatchesAndDirectRetryMustBeExplicit() async throws { + let registry = RegistryStub() + registry.featuredEntries = try MacMcpRegistry(core: RustCoreBridge()).featured().entries + registry.failure = McpRegistryError.secureConnection + let feature = McpRegistryFeatureModel(registry: registry) + await feature.search("Context7") + #expect(feature.entries.map(\.title) == ["Context7"]) + #expect(feature.error != nil && feature.canRetryDirectly) + #expect(registry.routes == [.system]) + + // The route changes only after a second explicit action, never on failure. + registry.failure = nil + registry.returnedEntries = [] + await feature.search("Context7", route: .direct) + #expect(registry.routes == [.system, .direct]) + #expect(feature.error == nil && !feature.canRetryDirectly) + #expect(feature.entries.map(\.title) == ["Context7"]) + } + + @Test func registryAndFeaturedEntriesShareOneConnectionCard() async throws { + let registry = RegistryStub() + let featured = try MacMcpRegistry(core: RustCoreBridge()).featured().entries + registry.featuredEntries = featured + registry.returnedEntries = [McpRegistryEntry(id: "io.example/context7", title: "Registry copy", summary: "", version: "", website: nil, draft: featured[0].draft)] + let feature = McpRegistryFeatureModel(registry: registry) + await feature.search("Context7") + #expect(feature.entries.count == 1) + #expect(feature.entries[0].source == .featured) + #expect(feature.entries[0].publisher == "Upstash") + } + + @Test func switchingRoutesDoesNotReuseAnotherRoutesPaginationCursor() async { + let registry = RegistryStub() + let feature = McpRegistryFeatureModel(registry: registry) + await feature.search("docs") + await feature.search("docs", loadMore: true, route: .direct) + #expect(registry.routes == [.system, .direct]) + #expect(registry.requests[1].1 == nil) + #expect(feature.entries.map(\.id) == ["one"]) + } + + @Test func coreRegistryFixtureProducesReviewableDrafts() throws { + struct Payload: Encodable { let content: String } + let url = DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent("shared/contracts/mcp-registry-page.json") + let page: McpRegistryPage = try RustCoreBridge().executeResult(command: "mcp.parseRegistry", payload: Payload(content: String(contentsOf: url, encoding: .utf8))).get() + #expect(page.entries.count == 3) + #expect(page.nextCursor == "opaque/+ cursor?") + let draft = McpServerDraft(definition: try #require(page.entries[1].draft)) + #expect(draft.arguments == ["-y", "@example/browser@2.0.0"]) + #expect(draft.enabledAgents.isEmpty) + #expect(draft.originalSources.isEmpty) + #expect(page.entries[2].draft == nil) + } + + @Test func paginationDeduplicatesAndStopsRepeatedCursors() async { + let registry = RegistryStub() + let feature = McpRegistryFeatureModel(registry: registry) + await feature.search(" docs ") + #expect(feature.entries.map(\.id) == ["one"]) + #expect(feature.nextCursor == "cursor") + await feature.search("docs", loadMore: true) + #expect(feature.entries.map(\.id) == ["one", "two"]) + #expect(feature.nextCursor == nil) + #expect(registry.requests.map(\.0) == ["docs", "docs"]) + #expect(registry.requests[1].1 == "cursor") + registry.fail = true + await feature.search("other") + #expect(feature.entries.isEmpty && feature.error != nil && !feature.isLoading) + } + + @Test func cancelledSearchNeverPublishesItsResponse() async { + let registry = RegistryStub() + let feature = McpRegistryFeatureModel(registry: registry) + let task = Task { await feature.search("cancelled") } + task.cancel() + await task.value + #expect(feature.entries.isEmpty) + #expect(feature.error == nil) + #expect(!feature.isLoading) + } + + @Test func anOlderResponseCannotReplaceANewerSearch() async { + let registry = RegistryStub() + let feature = McpRegistryFeatureModel(registry: registry) + registry.beforeFirstResponse = { await feature.search("new") } + await feature.search("old") + #expect(feature.entries.map(\.id) == ["one"]) + #expect(registry.requests.map(\.0) == ["old", "new"]) + #expect(!feature.isLoading && feature.error == nil) + } +} + +@MainActor +private final class RegistryStub: McpRegistryProviding { + var requests: [(String, String?)] = [] + var fail = false + var failure: (any Error)? + var featuredEntries: [McpRegistryEntry] = [] + var returnedEntries: [McpRegistryEntry]? + var routes: [McpRegistryRoute] = [] + var beforeFirstResponse: (() async -> Void)? + func featured() throws -> McpRegistryPage { McpRegistryPage(entries: featuredEntries, nextCursor: nil) } + func search(query: String, cursor: String?, route: McpRegistryRoute) async throws -> McpRegistryPage { + requests.append((query, cursor)) + routes.append(route) + if let callback = beforeFirstResponse { + beforeFirstResponse = nil + await callback() + return McpRegistryPage(entries: [McpRegistryEntry(id: "old", title: "old", summary: "", version: "1", website: nil, draft: nil)], nextCursor: nil) + } + if let failure { throw failure } + if fail { throw URLError(.notConnectedToInternet) } + let entries = returnedEntries ?? (cursor == nil ? ["one"] : ["one", "two"]).map { + McpRegistryEntry(id: $0, title: $0, summary: "", version: "1", website: nil, draft: nil) + } + return McpRegistryPage(entries: entries, nextCursor: "cursor") + } +} diff --git a/macos/Tests/LitheTests/McpServerLibraryTests.swift b/macos/Tests/LitheTests/McpServerLibraryTests.swift index 5e6233727b..68095feea7 100644 --- a/macos/Tests/LitheTests/McpServerLibraryTests.swift +++ b/macos/Tests/LitheTests/McpServerLibraryTests.swift @@ -8,6 +8,18 @@ import Testing /// ownership ledger, backups and project registration around it. @MainActor struct McpServerLibraryTests { + @Test func jsonImportPreviewsWithoutWritingOrTakingOwnership() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let feature = fixture.feature() + let drafts = try feature.importedDrafts(#"{"mcpServers":{"demo":{"command":"npx","args":["-y","@example/server","space value"],"env":{"TOKEN":"private"}},"remote":{"type":"http","url":"https://example.invalid/mcp"}}}"#) + #expect(drafts.map(\.name) == ["demo", "remote"]) + #expect(drafts[0].arguments == ["-y", "@example/server", "space value"]) + #expect(drafts.allSatisfy { $0.enabledAgents.isEmpty && $0.originalSources.isEmpty }) + #expect(!FileManager.default.fileExists(atPath: fixture.codexURL.path)) + #expect(!FileManager.default.fileExists(atPath: fixture.claudeURL.path)) + #expect(throws: (any Error).self) { try feature.importedDrafts("invalid json private") } + } @Test(arguments: McpAgent.allCases, [false, true]) func staleEditorCannotOverwriteAnotherWindowsSave(agent: McpAgent, refreshBeforeSave: Bool) throws { let fixture = try Fixture() diff --git a/macos/Tests/LitheTests/McpToolsFeatureTests.swift b/macos/Tests/LitheTests/McpToolsFeatureTests.swift new file mode 100644 index 0000000000..7468e9ebd1 --- /dev/null +++ b/macos/Tests/LitheTests/McpToolsFeatureTests.swift @@ -0,0 +1,162 @@ +import AppKit +import Combine +import Foundation +import SwiftUI +import Testing +@testable import Lithe + +@MainActor +struct McpToolsFeatureTests { + @Test func explicitDetectionPublishesRealInventoryAndKeepsAgentsIndependent() async throws { + let service = ToolStub() + let feature = McpToolsFeatureModel(service: service) + let row = ToolStub.row() + #expect(service.probes == 0) + let finished = TestGate() + let observer = feature.$states.sink { state in if state[row.id]?.isReady == true { finished.open() } } + defer { observer.cancel(); feature.leavePage() } + let task = try #require(feature.detect(row)) + #expect(await finished.waitUntilOpen()) + await task.value + #expect(service.probes == 1) + #expect(feature.state(for: row).tools.map(\.name) == ["read"]) + feature.setEnabled(false, tool: "read", agent: .codex, row: row) + #expect(feature.state(for: row).policies[.codex]?.tools.first?.enabled == false) + #expect(feature.state(for: row).policies[.claudeCode]?.tools.first?.enabled == true) + service.fails = true + feature.setEnabled(false, tool: "read", agent: .claudeCode, row: row) + #expect(feature.state(for: row).error != nil) + #expect(feature.state(for: row).policies[.claudeCode]?.tools.first?.enabled == true) + } + + @Test func leavingPageCancelsTheProbeAndRejectsItsLateInventory() async throws { + let service = ToolStub(); service.waits = true + let feature = McpToolsFeatureModel(service: service) + let row = ToolStub.row() + let task = try #require(feature.detect(row)) + defer { service.release.open(); task.cancel(); feature.leavePage() } + #expect(await service.started.waitUntilOpen()) + feature.leavePage() + service.release.open() + await task.value + #expect(feature.states.isEmpty) + #expect(service.policies == 0) + } + + @Test func registrationChangesReloadLocalPoliciesAndReleaseOldSnapshotsWithoutProbing() async throws { + let service = ToolStub() + let feature = McpToolsFeatureModel(service: service) + defer { feature.leavePage() } + let codex = ToolStub.row(agents: [.codex]) + let task = try #require(feature.detect(codex)) + await task.value + let original = try #require(feature.state(for: codex).policies[.codex]) + let both = ToolStub.row() + #expect(codex.id == both.id) + feature.reconcile([both]) + let combined = feature.state(for: both) + #expect(combined.isReady && combined.error == nil) + #expect(Set(combined.policies.keys) == Set(McpAgent.allCases)) + #expect(combined.policies[.claudeCode]?.tools.first?.enabled == true) + #expect(combined.policies[.claudeCode]?.tools.first?.editable == true) + #expect(service.released.contains(original.id)) + let claude = try #require(combined.policies[.claudeCode]) + feature.reconcile([codex]) + #expect(Set(feature.state(for: codex).policies.keys) == [.codex]) + #expect(service.released.contains(claude.id)) + let calls = service.policies + feature.reconcile([codex]) + #expect(service.policies == calls, "unchanged sources do not reload") + #expect(service.probes == 1) + #expect(feature.state(for: codex).tools.map(\.name) == ["read"]) + } + + @Test func registrationChangesDuringDetectionUseTheLatestSources() async throws { + let service = ToolStub(); service.waits = true + let feature = McpToolsFeatureModel(service: service) + let task = try #require(feature.detect(ToolStub.row(agents: [.codex]))) + defer { service.release.open(); task.cancel(); feature.leavePage() } + #expect(await service.started.waitUntilOpen()) + let both = ToolStub.row() + feature.reconcile([both]) + service.release.open() + await task.value + #expect(Set(feature.state(for: both).policies.keys) == Set(McpAgent.allCases)) + #expect(feature.state(for: both).error == nil) + #expect(service.probes == 1) + } + + @Test func changedRegistrationPolicyFailuresAreVisibleAndKeepOtherAgentsUsable() async throws { + let service = ToolStub() + let feature = McpToolsFeatureModel(service: service) + defer { feature.leavePage() } + let codex = ToolStub.row(agents: [.codex]) + let task = try #require(feature.detect(codex)) + await task.value + service.policyFailure = .claudeCode + let both = ToolStub.row() + feature.reconcile([both]) + #expect(feature.state(for: both).error != nil) + #expect(feature.state(for: both).policies[.codex]?.tools.first?.enabled == true) + #expect(feature.state(for: both).policies[.claudeCode] == nil) + service.policyFailure = nil + feature.loadPolicies(both) + #expect(feature.state(for: both).error == nil) + #expect(feature.state(for: both).policies[.claudeCode]?.tools.first?.editable == true) + #expect(service.probes == 1) + } + + @Test func nativeToolPanelFitsWideAndNarrowDarkAndLightLayouts() async throws { + let service = ToolStub(); let feature = McpToolsFeatureModel(service: service); let row = ToolStub.row() + let ready = TestGate() + let observer = feature.$states.sink { states in if states[row.id]?.isReady == true { ready.open() } } + defer { observer.cancel(); feature.leavePage() } + let task = try #require(feature.detect(row)); #expect(await ready.waitUntilOpen()); await task.value + let directory = DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent(".artifacts/test-stability/mcp-ui") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + for width: CGFloat in [420, 760] { + for dark in [false, true] { + let view = McpToolsPanel(feature: feature, row: row).padding(16).frame(width: width) + .environment(\.colorScheme, dark ? .dark : .light).background(LitheTheme.settingsSurface) + let host = NSHostingView(rootView: view); host.appearance = NSAppearance(named: dark ? .darkAqua : .aqua) + let size = host.fittingSize; #expect(abs(size.width - width) < 1) + host.frame = NSRect(origin: .zero, size: size); host.layoutSubtreeIfNeeded() + let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: bitmap) + try #require(bitmap.representation(using: .png, properties: [:])).write(to: directory.appendingPathComponent("tools-\(dark ? "dark" : "light")-\(Int(width)).png")) + } + } + } +} + +@MainActor +private final class ToolStub: McpToolsManaging { + let started = TestGate(), release = TestGate() + var waits = false, fails = false + var probes = 0, policies = 0 + var policyFailure: McpAgent? + var released: [UUID] = [] + func probe(_ definition: McpServerDefinition) async throws -> McpProbeResult { + probes += 1; started.open() + if waits { _ = await release.waitUntilOpen() } + return McpProbeResult(tools: [McpToolDescription(name: "read", description: "Read the current library documentation.")]) + } + func policy(for target: McpToolTarget, agent: McpAgent, tools: [String]) throws -> McpToolPolicySnapshot { + policies += 1 + if policyFailure == agent { throw CocoaError(.fileReadUnknown) } + return McpToolPolicySnapshot(id: UUID(), agent: agent, tools: tools.map { McpToolPermission(name: $0, enabled: true, editable: true) }, previousSourceHash: nil, sourceHash: nil) + } + func setEnabled(_ enabled: Bool, tool: String, target: McpToolTarget, snapshot: McpToolPolicySnapshot) throws -> McpToolPolicySnapshot { + if fails { throw CocoaError(.fileWriteUnknown) } + return McpToolPolicySnapshot(id: UUID(), agent: snapshot.agent, tools: [McpToolPermission(name: tool, enabled: enabled, editable: true)], previousSourceHash: nil, sourceHash: nil) + } + func release(_ snapshots: [McpToolPolicySnapshot]) { released += snapshots.map(\.id) } + func commandLine(command: String, args: [String]) throws -> String { command } + func parseCommandLine(_ line: String) throws -> (command: String, args: [String]) { (line, []) } + static func row(agents: Set = Set(McpAgent.allCases)) -> McpServerRow { + let server = McpServerDefinition(name: "docs", transport: .http, command: nil, args: [], env: [], url: "https://example.invalid/mcp", headers: [], definitionHash: "fixture") + let sources = McpAgent.allCases.filter { agents.contains($0) }.map { agent in McpServerSource(file: McpConfigurationFile(agent: agent, scope: .global, + url: URL(fileURLWithPath: "/fixture/\(agent.rawValue)"), projectPath: nil), definitionHash: "fixture", isOwnedByLithe: false) } + return McpServerRow(id: server.id, definition: server, sources: sources, hasDefinitionConflict: false) + } +} diff --git a/macos/Tests/LitheTests/ModuleFeatureSelectionTests.swift b/macos/Tests/LitheTests/ModuleFeatureSelectionTests.swift new file mode 100644 index 0000000000..d1794d7fbf --- /dev/null +++ b/macos/Tests/LitheTests/ModuleFeatureSelectionTests.swift @@ -0,0 +1,68 @@ +import Testing +@testable import Lithe + +/// A settings page left open across module idle sleep must show the +/// re-activated feature, never the released one. +@MainActor +struct ModuleFeatureSelectionTests { + @Test + func activationResultShowsBeforeTheCapabilityCacheFills() { + var selection = ModuleFeatureSelection() + let first = FeatureStub() + + selection.beginActivation(current: nil) + #expect(selection.feature(current: nil) == nil) + #expect(selection.isActivating) + selection.finishActivation(first) + + #expect(selection.feature(current: nil) === first) + #expect(selection.feature(current: first) === first) + } + + @Test + func sleepThenReactivationReplacesTheReleasedFeature() { + var selection = ModuleFeatureSelection() + let released = FeatureStub() + selection.beginActivation(current: nil) + selection.finishActivation(released) + + // The module sleeps: the capability cache is cleared and the page's + // activation task runs again with no current capability. + selection.beginActivation(current: nil) + #expect(selection.feature(current: nil) == nil) + #expect(selection.activated == nil) + + let reactivated = FeatureStub() + selection.finishActivation(reactivated) + #expect(selection.feature(current: nil) === reactivated) + #expect(selection.feature(current: reactivated) === reactivated) + } + + @Test + func currentCapabilityWinsOverAStaleActivation() { + var selection = ModuleFeatureSelection() + let stale = FeatureStub() + let current = FeatureStub() + selection.beginActivation(current: nil) + selection.finishActivation(stale) + + #expect(selection.feature(current: current) === current) + selection.beginActivation(current: current) + #expect(selection.activated == nil) + } + + @Test + func failedReactivationShowsNothing() { + var selection = ModuleFeatureSelection() + selection.beginActivation(current: nil) + selection.finishActivation(FeatureStub()) + + selection.beginActivation(current: nil) + selection.finishActivation(nil) + + #expect(selection.feature(current: nil) == nil) + #expect(!selection.isActivating) + } +} + +private final class FeatureStub {} diff --git a/macos/Tests/LitheTests/ModuleSessionCoordinatorTests.swift b/macos/Tests/LitheTests/ModuleSessionCoordinatorTests.swift index a6f4d880dd..d21f55a4b9 100644 --- a/macos/Tests/LitheTests/ModuleSessionCoordinatorTests.swift +++ b/macos/Tests/LitheTests/ModuleSessionCoordinatorTests.swift @@ -8,15 +8,14 @@ import Testing @MainActor struct ModuleSessionCoordinatorTests { @Test - func releaseEventsClearOnlyTheirModuleAndCloseTheDatabaseDock() { + func releaseEventsClearOnlyTheirModule() { let runtime = ModuleRuntime() let store = ModuleCapabilityStore() let workbench = WorkbenchFeatureModel() - let database = SessionTestCapability() + let search = SessionTestCapability() let git = SessionTestCapability() - store.cache(database, id: .databaseWorkspace, moduleID: .database) + store.cache(search, id: .searchWorkspace, moduleID: .search) store.cache(git, id: .gitWorkspace, moduleID: .git) - workbench.setVisibility(.database, isVisible: true) var released: [ModuleID] = [] var changes = 0 let coordinator = ModuleSessionCoordinator( @@ -26,17 +25,16 @@ struct ModuleSessionCoordinatorTests { ) defer { coordinator.stopObserving() } - runtime.publish(ModuleEvent(source: .database, name: "module.sleeping")) + runtime.publish(ModuleEvent(source: .search, name: "module.sleeping")) - #expect(store.capability(.databaseWorkspace, as: SessionTestCapability.self) == nil) + #expect(store.capability(.searchWorkspace, as: SessionTestCapability.self) == nil) #expect(store.capability(.gitWorkspace) === git) #expect(workbench.selectedSidebar == .project) - #expect(!workbench.isVisible(.database)) - #expect(released == [.database]) + #expect(released == [.search]) #expect(changes == 1) runtime.publish(ModuleEvent(source: .git, name: ModuleEvent.stateChangedName)) #expect(changes == 2) - #expect(released == [.database]) + #expect(released == [.search]) coordinator.stopObserving() coordinator.stopObserving() @@ -76,13 +74,10 @@ struct ModuleSessionCoordinatorTests { func repeatedShutdownRequestsShareWorkAndWaitersResumeAfterCleanup() async { let started = TestGate() let release = TestGate() - let store = ModuleCapabilityStore() - store.cache(SessionTestCapability(), id: .databaseWorkspace, moduleID: .database) var shutdowns = 0 - var released: [ModuleID] = [] let coordinator = ModuleSessionCoordinator( - runtime: ModuleRuntime(), capabilities: store, workbench: WorkbenchFeatureModel(), - onModuleReleased: { released.append($0) }, + runtime: ModuleRuntime(), capabilities: ModuleCapabilityStore(), workbench: WorkbenchFeatureModel(), + onModuleReleased: { _ in }, onChange: {}, shutdownRuntime: { shutdowns += 1 @@ -98,17 +93,14 @@ struct ModuleSessionCoordinatorTests { coordinator.beginShutdown() #expect(await started.waitUntilOpen()) #expect(shutdowns == 1) - #expect(released.isEmpty) - #expect(store.capability(.databaseWorkspace, as: SessionTestCapability.self) != nil) release.open() await coordinator.awaitShutdown() - #expect(released == [.database]) - #expect(store.capability(.databaseWorkspace, as: SessionTestCapability.self) == nil) + #expect(shutdowns == 1) + // A finished shutdown no longer counts as running. await coordinator.shutdown() #expect(shutdowns == 2) - #expect(released == [.database, .database]) } @Test diff --git a/macos/Tests/LitheTests/OutputTimestamperTests.swift b/macos/Tests/LitheTests/OutputTimestamperTests.swift index d79c8d5fc2..d7f15a469b 100644 --- a/macos/Tests/LitheTests/OutputTimestamperTests.swift +++ b/macos/Tests/LitheTests/OutputTimestamperTests.swift @@ -8,8 +8,8 @@ import Testing @Suite("Output Copy cursor ownership") @MainActor struct OutputCopyCursorTests { - @Test(arguments: [false, true]) - func databaseTextAndCopyIgnoreLateEventsOverDivider(editable: Bool) throws { + @Test + func outputTextAndCopyIgnoreLateEventsOverDivider() throws { let previousCursor = NSCursor.current let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), styleMask: [.borderless], backing: .buffered, defer: false) window.isReleasedWhenClosed = false @@ -17,12 +17,10 @@ struct OutputCopyCursorTests { let content = try #require(window.contentView) let output = OutputNativeTextView(frame: NSRect(x: 0, y: 0, width: 160, height: 240)) output.isEditable = false; output.isSelectable = true; output.string = "output" - let console = SQLTextView(frame: NSRect(x: 180, y: 0, width: 180, height: 240)) - console.isEditable = editable; console.string = "select 1;" let copy = OutputCopyButtonHost(rootView: AnyView(Button("Copy") {})) copy.frame = NSRect(x: 100, y: 200, width: 50, height: 22) let handle = SplitHandleInteractionView(frame: NSRect(x: 160, y: 0, width: 10, height: 240)) - for view in [output, console, copy, handle] { content.addSubview(view) } + for view in [output, copy, handle] { content.addSubview(view) } output.copyButtonView = copy let point = NSPoint(x: 165, y: 120) #expect(content.hitTest(point) === handle) @@ -35,7 +33,7 @@ struct OutputCopyCursorTests { let exited = try tracking(.mouseExited, at: point) let moved = try #require(NSEvent.mouseEvent(with: .mouseMoved, location: point, modifierFlags: [], timestamp: 0, windowNumber: window.windowNumber, context: nil, eventNumber: 0, clickCount: 0, pressure: 0)) - for view in [output, console, copy] { + for view in [output, copy] { for event in [cursor, entered, moved, exited] { handle.cursorUpdate(with: cursor) switch event.type { @@ -47,16 +45,14 @@ struct OutputCopyCursorTests { #expect(NSCursor.current === NSCursor.resizeLeftRight) } } - // Normal body tracking still supplies the native text cursor (or the read-only preview arrow). + // Normal body tracking still supplies the native text cursor. output.cursorUpdate(with: try tracking(.cursorUpdate, at: NSPoint(x: 20, y: 120))) #expect(NSCursor.current === NSCursor.iBeam) - console.cursorUpdate(with: try tracking(.cursorUpdate, at: NSPoint(x: 200, y: 120))) - #expect(NSCursor.current === (editable ? NSCursor.iBeam : NSCursor.arrow)) copy.cursorUpdate(with: try tracking(.cursorUpdate, at: NSPoint(x: 125, y: 211))) #expect(NSCursor.current === NSCursor.arrow) content.subviews.forEach { $0.removeFromSuperview() } NSCursor.resizeLeftRight.set() - output.cursorUpdate(with: cursor); console.cursorUpdate(with: cursor); copy.cursorUpdate(with: cursor) + output.cursorUpdate(with: cursor); copy.cursorUpdate(with: cursor) #expect(NSCursor.current === NSCursor.resizeLeftRight) } diff --git a/macos/Tests/LitheTests/PluginBuildOptionPersistenceTests.swift b/macos/Tests/LitheTests/PluginBuildOptionPersistenceTests.swift index a65ed17050..d2ec248b62 100644 --- a/macos/Tests/LitheTests/PluginBuildOptionPersistenceTests.swift +++ b/macos/Tests/LitheTests/PluginBuildOptionPersistenceTests.swift @@ -146,7 +146,7 @@ struct PluginBuildOptionPersistenceTests { try Data("[package]\nname = \"\(root)\"\nversion = \"0.1.0\"\n[features]\n\(feature) = []\n".utf8) .write(to: directory.appendingPathComponent("Cargo.toml")) } - let fixture = DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent("shared/contracts/plugins/v1/fixtures/cargo-build.plugin.json") + let fixture = DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent("shared/contracts/plugins/v1/fixtures/build-static-file.plugin.json") var manifest = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: fixture)) as? [String: Any]) var systems = try #require(manifest["buildSystems"] as? [[String: Any]]) var options = try #require(systems[0]["options"] as? [[String: Any]]) diff --git a/macos/Tests/LitheTests/PluginDetailLayoutTests.swift b/macos/Tests/LitheTests/PluginDetailLayoutTests.swift new file mode 100644 index 0000000000..245d8d297e --- /dev/null +++ b/macos/Tests/LitheTests/PluginDetailLayoutTests.swift @@ -0,0 +1,184 @@ +import AppKit +import LitheCoreContracts +import LitheModuleAPI +import SwiftUI +import Testing +@testable import Lithe + +/// A real plugin form must respect the same edge as the split handle when resized. +@MainActor +@Suite(.serialized) +struct PluginDetailLayoutTests { + @Test(arguments: [false, true]) + func settingsStayBesideTheDividerWhenTheWindowShrinks(dark: Bool) throws { + let descriptor = try DeclarativePluginTestSupport.officialDescriptor("JavaSupport") + var plugin = PluginManagementSnapshot( + manifest: descriptor.pluginManifest, origin: .bundled, installationStatus: .installed, + isEnabled: true, isRequired: false, isRunning: false, isQuarantined: false, + isSuppressedBySafeMode: false, requiresRestart: false, canRollback: false, + statusMessage: "Enabled", marketplace: nil + ) + plugin.settings = descriptor.settings.map { .resolve($0, global: nil, project: nil) } + let markers = PluginDetailLayoutMarkers() + let host = NSHostingView(rootView: LitheSplitPaneView( + axis: .horizontal, placement: .leading, defaultSize: 420, + minimum: PluginManagementView.listMinimumWidth, maximum: PluginManagementView.listMaximumWidth, + flexibleMinimum: PluginManagementView.detailMinimumWidth, + sized: { + Text("Installed plugins").frame(maxWidth: .infinity, maxHeight: .infinity) + .background(PluginDetailLayoutMarker { markers.sidebar = $0 }) + }, + flexible: { + PluginDetailView( + plugin: plugin, summary: "", isEnabled: true, hasPendingChange: false, + isBusy: false, isApplyingChanges: false, updateVersion: nil, sourceName: nil, error: nil, + actions: PluginDetailActions( + toggleEnabled: {}, update: nil, reinstall: nil, uninstall: {}, restore: nil, rollback: nil, + settings: PluginSettingsHandlers(setValue: { _, _, _ in nil }, + restartLanguageServers: {}, runAction: { _ in }, choosePath: { _ in nil }), + hasProject: true, openSettings: { _ in } + ), tab: .constant(.settings) + ) { + Image(systemName: "cup.and.saucer").frame(width: 48, height: 48) + } + .background(PluginDetailLayoutMarker { markers.detail = $0 }) + } + ).environment(\.colorScheme, dark ? .dark : .light)) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 1100, height: 650), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: dark ? .darkAqua : .aqua) + window.contentView = host + defer { window.close() } + host.layoutSubtreeIfNeeded() + let originalField = try #require(descendants(host).compactMap { $0 as? NSTextField }.first { $0.isEditable }) + var initialTopInset: CGFloat? + for (width, height) in [(1100.0, 650.0), (900, 500), (700, 700), (1000, 650)] { + window.setContentSize(CGSize(width: width, height: height)) + host.layoutSubtreeIfNeeded() + let sidebar = try #require(markers.sidebar) + let detail = try #require(markers.detail) + let left = sidebar.convert(sidebar.bounds, to: host) + let right = detail.convert(detail.bounds, to: host) + let divider = left.maxX + SplitHandleView.thickness + #expect(abs(right.minX - divider) < 1, "\(width): detail \(right) must start after sidebar \(left)") + #expect(right.maxX <= host.bounds.maxX + 1) + for field in descendants(host).compactMap({ $0 as? NSTextField }).filter(\.isEditable) { + let frame = field.convert(field.bounds, to: host) + #expect(frame.minX >= divider) + #expect(field === originalField) + } + let scroller = try #require(descendants(host).compactMap { $0 as? NSScrollView }.last { + $0.hasHorizontalScroller && $0.hasVerticalScroller + }) + let document = try #require(scroller.documentView) + let fieldFrame = originalField.convert(originalField.bounds, to: host) + let viewport = scroller.convert(scroller.bounds, to: host) + let topInset = host.isFlipped ? fieldFrame.minY - viewport.minY : viewport.maxY - fieldFrame.maxY + if let initialTopInset { + #expect(abs(topInset - initialTopInset) < 1, "A short form stays at the top when the pane height changes") + } else { + initialTopInset = topInset + } + scroller.contentView.scroll(to: CGPoint(x: document.bounds.maxX - scroller.contentView.bounds.width, y: 0)) + scroller.reflectScrolledClipView(scroller.contentView) + #expect(abs(scroller.contentView.bounds.maxX - document.bounds.maxX) < 1, + "The trailing settings remain reachable rather than being clipped") + if let directory = ProcessInfo.processInfo.environment["LITHE_PLUGIN_DETAIL_LAYOUT_CAPTURES"] { + 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: [:])) + let output = URL(fileURLWithPath: directory, isDirectory: true) + try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true) + try data.write(to: output.appendingPathComponent("detail-\(Int(width))-\(dark ? "dark" : "light").png")) + } + } + } + + @Test(arguments: ["en", "zh-Hans"], [ColorScheme.dark, .light]) + func longVersionAndAllActionsFitTheMinimumSettingsWindow(language: String, scheme: ColorScheme) async throws { + MacBundledFontRegistry.registerFonts() + let manifest = try DeclarativePluginTestSupport.descriptor( + DeclarativePluginTestSupport.manifestObject(version: PluginVersion(major: 1, minor: 0, patch: 0)) + ).pluginManifest + var plugin = PluginManagementSnapshot(manifest: manifest, origin: .community, + installationStatus: .installed, isEnabled: true, isRequired: false, isRunning: true, + isQuarantined: false, isSuppressedBySafeMode: false, requiresRestart: true, + canRollback: false, statusMessage: "Running", marketplace: nil) + plugin.hasChangesPendingRestart = true + var updates = 0 + let actions = PluginDetailActions(toggleEnabled: {}, update: { updates += 1 }, reinstall: {}, + uninstall: {}, restore: nil, rollback: nil, + settings: PluginSettingsHandlers(setValue: { _, _, _ in nil }, restartLanguageServers: {}, + runAction: { _ in }, choosePath: { _ in nil }), + hasProject: false, openSettings: { _ in }) + let host = NSHostingView(rootView: PluginDetailView(plugin: plugin, summary: "", isEnabled: true, + hasPendingChange: false, isBusy: false, isApplyingChanges: false, + updateVersion: "2026.10.22-preview.1234567890", sourceName: nil, error: nil, + actions: actions, tab: .constant(.overview), icon: { Color.clear.frame(width: 48, height: 48) }) + .environment(\.locale, Locale(identifier: language)).environment(\.colorScheme, scheme) + .environment(\.restartApplication, ApplicationRestartAction(perform: {}))) + host.sizingOptions = [] + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 900, height: 668), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = host + window.orderBack(nil) + defer { window.contentView = nil; window.close() } + // The 900pt Settings window shares width with its category sidebar and + // plugin list. Also exercise the actual detail splitter's 160pt limit. + let detailWidth = 900 - SettingsView.categoryMinimumWidth - PluginManagementView.listMinimumWidth + - 2 * SplitHandleView.thickness + for width in [CGFloat(1000), detailWidth, PluginManagementView.detailMinimumWidth, CGFloat(1000)] { + host.setFrameSize(NSSize(width: width, height: 668)) + host.layoutSubtreeIfNeeded() + var frames: [NSRect] = [] + for identifier in ["toggle", "update", "reinstall", "remove", "restart"] { + let frame = try await AgentNativeControlTestSupport.frame(of: "plugin-detail-\(identifier)", in: host) + #expect(frame.minX >= -0.5 && frame.maxX <= width + 0.5, "\(identifier) overflows \(width)pt") + #expect(frame.minY >= 0 && frame.maxY <= host.bounds.height) + #expect(abs(frame.height - LitheTheme.ActionButton.height) < 0.5) + #expect(frame.width >= LitheTheme.ActionButton.minimumWidth - 0.5) + #expect(frames.allSatisfy { !$0.intersects(frame) }) + frames.append(frame) + } + if width == 1000 { + #expect(Set(frames.map { Int($0.midY.rounded()) }).count == 1, "Wide actions should stay in one row") + #expect(frames[1].width > 200, "Wide update action must show the full target version") + } + if width == PluginManagementView.detailMinimumWidth { + #expect(Set(frames.map { Int($0.midY.rounded()) }).count == 5, "All actions must stack") + // The compact label still invokes the original update action. + let frame = frames[1] + let point = host.convert(NSPoint(x: frame.midX, y: frame.midY), to: nil) + for type in [NSEvent.EventType.leftMouseDown, .leftMouseUp] { + let event = try #require(NSEvent.mouseEvent(with: type, location: point, modifierFlags: [], + timestamp: 0, windowNumber: window.windowNumber, context: nil, eventNumber: 0, + clickCount: 1, pressure: 1)) + window.sendEvent(event) + } + #expect(updates == 1) + } + } + } + + private func descendants(_ view: NSView) -> [NSView] { + view.subviews.flatMap { [$0] + descendants($0) } + } +} + +@MainActor +private final class PluginDetailLayoutMarkers { + weak var sidebar: NSView? + weak var detail: NSView? +} + +private struct PluginDetailLayoutMarker: NSViewRepresentable { + let record: (NSView) -> Void + func makeNSView(context: Context) -> NSView { + let view = NSView() + record(view) + return view + } + func updateNSView(_ view: NSView, context: Context) {} +} diff --git a/macos/Tests/LitheTests/PluginDiskInstallationTests.swift b/macos/Tests/LitheTests/PluginDiskInstallationTests.swift index 6177a7ba5c..caee8ddc2c 100644 --- a/macos/Tests/LitheTests/PluginDiskInstallationTests.swift +++ b/macos/Tests/LitheTests/PluginDiskInstallationTests.swift @@ -73,7 +73,7 @@ struct PluginDiskInstallationTests { signatureRequirement: PluginSignatureRequirement = .unsigned ) throws -> URL { let data = try Data(contentsOf: DeclarativePluginTestSupport.repositoryRoot - .appendingPathComponent("shared/contracts/plugins/v1/fixtures/php-support.plugin.json")) + .appendingPathComponent("shared/contracts/plugins/v1/fixtures/language-packaged-server.plugin.json")) var manifest = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) manifest["id"] = id.rawValue manifest["displayName"] = "Disk Fixture" diff --git a/macos/Tests/LitheTests/PluginLanguageServerLaunchResolverTests.swift b/macos/Tests/LitheTests/PluginLanguageServerLaunchResolverTests.swift index 3e24a3c993..72996b9add 100644 --- a/macos/Tests/LitheTests/PluginLanguageServerLaunchResolverTests.swift +++ b/macos/Tests/LitheTests/PluginLanguageServerLaunchResolverTests.swift @@ -402,7 +402,7 @@ struct PluginLanguageServerLaunchResolverTests { private func makeFixture( root: URL, - manifest: String = "java-support.plugin.json", + manifest: String = "language-host-integration.plugin.json", languageID: String = "java", settings: any PluginSettingsStoring = InMemoryPluginSettings() ) throws -> Fixture { @@ -458,7 +458,7 @@ struct PluginLanguageServerLaunchResolverTests { } private func javaFixtureManifest() throws -> Data { - try fixtureManifest("java-support.plugin.json") + try fixtureManifest("language-host-integration.plugin.json") } private func fixtureManifest(_ name: String) throws -> Data { diff --git a/macos/Tests/LitheTests/PluginManagerTests.swift b/macos/Tests/LitheTests/PluginManagerTests.swift index fe55a47fc0..6ffec84919 100644 --- a/macos/Tests/LitheTests/PluginManagerTests.swift +++ b/macos/Tests/LitheTests/PluginManagerTests.swift @@ -67,37 +67,34 @@ struct PluginManagerTests { } @Test - func internalLifecycleModulesAreNotShownAsInstalledPlugins() { - let root = temporaryRoot() - defer { try? FileManager.default.removeItem(at: root) } - let preferences = PluginManagerKeyValueStore() - let configuration = MacModuleConfigurationStore(store: preferences) - let manager = MacPluginManager( - packageStore: makeStore(root: root), - moduleRuntime: ModuleRuntime(configurationStore: configuration, recoveryStore: configuration), - configurationStore: configuration, - settings: InMemoryPluginSettings(), - launchMode: .normal, - startup: MacPluginStartupResult( - installedPlugins: [], - registeredPlugins: [], - issues: [] - ) + func vcsProviderPluginGetsOneDisabledModuleForItsProviders() throws { + // VCS providers come from community plugins; the contract's + // manifest fixture stands in for one. Core accepts it because this + // host declares `vcsProviders`, and one module carries its enablement. + #expect(PluginManifest.hostCapabilities["vcsProviders"] == [1]) + let manifest = try Data(contentsOf: DeclarativePluginTestSupport.repositoryRoot + .appendingPathComponent("shared/contracts/plugins/v1/fixtures/vcs-provider.plugin.json")) + let sample = try DeclarativePluginTestSupport.operations.validate(manifest: manifest) + #expect(sample.vcsProviders == [DeclarativeVcsProvider(id: "svn", displayName: "Subversion")]) + #expect(sample.pluginManifest.modules.map(\.manifest.id) == [.vcsProvidersExtension(sample.id.rawValue)]) + #expect(sample.pluginManifest.modules.allSatisfy { $0.manifest.defaultState == .disabled }) + _ = try ValidatedPluginCatalog( + manifests: BuiltInPluginCatalog.manifests + + BundledLanguagePluginCatalog.manifests + + [sample.pluginManifest], + hostVersion: BuiltInPluginCatalog.hostVersion ) - - #expect(manager.snapshots.isEmpty) } @Test - func internalDatabaseModuleIsNotShownAsAPlugin() async throws { + func internalLifecycleModulesAreNotShownAsInstalledPlugins() { let root = temporaryRoot() defer { try? FileManager.default.removeItem(at: root) } let preferences = PluginManagerKeyValueStore() let configuration = MacModuleConfigurationStore(store: preferences) - let runtime = ModuleRuntime(configurationStore: configuration, recoveryStore: configuration) let manager = MacPluginManager( packageStore: makeStore(root: root), - moduleRuntime: runtime, + moduleRuntime: ModuleRuntime(configurationStore: configuration, recoveryStore: configuration), configurationStore: configuration, settings: InMemoryPluginSettings(), launchMode: .normal, @@ -105,13 +102,10 @@ struct PluginManagerTests { installedPlugins: [], registeredPlugins: [], issues: [] - ), - managedBuiltInPlugins: [] + ) ) #expect(manager.snapshots.isEmpty) - #expect(try #require(BuiltInModuleCatalog.manifests.first { $0.id == .database }).defaultState == .enabled) - #expect(configuration.enabledState(for: .database) == nil) } @Test diff --git a/macos/Tests/LitheTests/PluginMarketplaceLayoutTests.swift b/macos/Tests/LitheTests/PluginMarketplaceLayoutTests.swift new file mode 100644 index 0000000000..073ecfe212 --- /dev/null +++ b/macos/Tests/LitheTests/PluginMarketplaceLayoutTests.swift @@ -0,0 +1,174 @@ +import AppKit +import LitheCoreContracts +import LitheModuleAPI +import SwiftUI +import Testing +@testable import Lithe + +@MainActor +@Suite("Marketplace split bounds", .serialized) +struct PluginMarketplaceLayoutTests { + @Test(arguments: ["en", "zh-Hans"], [ColorScheme.light, .dark]) + func marketplaceContentStaysOnItsSideDuringResizeAndDrag(language: String, scheme: ColorScheme) async throws { + let service = MarketplaceLayoutService() + let feature = PluginMarketplaceFeatureModel(service: service, pluginManager: MarketplaceLayoutManager()) + await feature.refreshIfNeeded() + let domain = "lithe-marketplace-layout-\(UUID().uuidString)" + let preferences = try #require(UserDefaults(suiteName: domain)) + defer { preferences.removePersistentDomain(forName: domain); feature.endInstallationSession() } + preferences.set(420.0, forKey: "lithe.settings.pluginMarketplaceListWidth") + let host = NSHostingView(rootView: PluginMarketplaceView(feature: feature) + .defaultAppStorage(preferences) + .environment(\.locale, Locale(identifier: language)).environment(\.colorScheme, scheme)) + host.sizingOptions = [] + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 1100, height: 700), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: scheme == .dark ? .darkAqua : .aqua) + window.contentView = host + window.orderBack(nil) + defer { window.contentView = nil; window.close() } + for (width, height) in [(1100.0, 700.0), (900, 500), (700, 700), (585, 700), (900, 900)] { + window.setContentSize(NSSize(width: width, height: height)) + host.layoutSubtreeIfNeeded() + if width == 585 { try Self.capture(host, name: "narrow-\(language)-\(scheme == .dark ? "dark" : "light")") } + try await Self.checkBounds(host) + } + let handle = try #require(Self.descendants(host).compactMap { $0 as? SplitHandleInteractionView }.first) + for distance in [-180.0, 240, -80] { + let original = handle.convert(handle.bounds, to: host) + let origin = handle.convert(NSPoint(x: handle.bounds.midX, y: handle.bounds.midY), to: nil) + func event(_ type: NSEvent.EventType, distance: CGFloat = 0) throws -> NSEvent { + try #require(NSEvent.mouseEvent(with: type, location: NSPoint(x: origin.x + distance, y: origin.y), + modifierFlags: [], timestamp: 0, windowNumber: window.windowNumber, context: nil, + eventNumber: 0, clickCount: 1, pressure: type == .leftMouseUp ? 0 : 1)) + } + // Go through the product's native hit target and the window's drag + // delivery, rather than invoking a layout callback directly. + window.sendEvent(try event(.leftMouseDown)) + window.sendEvent(try event(.leftMouseDragged, distance: distance)) + window.sendEvent(try event(.leftMouseUp, distance: distance)) + try await Self.settle(host) { abs(handle.convert(handle.bounds, to: host).midX - original.midX) > 1 } + try await Self.checkBounds(host) + let moved = handle.convert(handle.bounds, to: host) + // A late feature refresh must not move the divider after release. + await feature.refreshAll() + host.layoutSubtreeIfNeeded() + #expect(abs(handle.convert(handle.bounds, to: host).midX - moved.midX) < 1) + try await Self.checkBounds(host) + } + try Self.capture(host, name: "marketplace-\(language)-\(scheme == .dark ? "dark" : "light")") + } + + private static func capture(_ host: NSView, name: String) throws { + if let directory = ProcessInfo.processInfo.environment["LITHE_MARKETPLACE_LAYOUT_CAPTURES"] { + let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: bitmap) + let output = URL(fileURLWithPath: directory, isDirectory: true) + try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true) + try #require(bitmap.representation(using: .png, properties: [:])) + .write(to: output.appendingPathComponent("\(name).png")) + } + } + + private static func checkBounds(_ host: NSView) async throws { + host.layoutSubtreeIfNeeded() + let handle = try #require(descendants(host).compactMap { $0 as? SplitHandleInteractionView }.first) + let divider = handle.convert(handle.bounds, to: host).midX + let expected = (host.window?.contentView?.bounds.width ?? host.bounds.width) + #expect(divider >= PluginManagementView.listMinimumWidth) + #expect(divider < expected - PluginManagementView.detailMinimumWidth) + let scrolls = descendants(host).compactMap { $0 as? NSScrollView } + let detail = try #require(scrolls.max { $0.convert($0.bounds, to: host).minX < $1.convert($1.bounds, to: host).minX }) + let viewport = detail.convert(detail.bounds, to: host) + #expect(abs(viewport.minX - divider - SplitHandleView.thickness / 2) < 1) + #expect(abs(viewport.maxX - host.bounds.maxX) < 1) + let document = try #require(detail.documentView) + let content = document.convert(document.bounds, to: host) + for id in ["plugin-marketplace-name", "plugin-marketplace-install", "plugin-marketplace-description"] { + let frame = try await AgentNativeControlTestSupport.frame(of: id, in: host) + #expect(frame.minX >= divider, "\(id) crosses divider \(divider): \(frame)") + #expect(frame.maxX <= content.maxX + 1, "\(id) overflows its scroll document: \(frame)") + if id == "plugin-marketplace-name" { + let top = host.isFlipped ? frame.minY - viewport.minY : viewport.maxY - frame.maxY + #expect(top < 40, "Short plugin details must stay at the top: \(top)") + } + } + for scroll in scrolls { + let frame = scroll.convert(scroll.bounds, to: host) + #expect(frame.minX >= -1 && frame.maxX <= host.bounds.maxX + 1) + } + // At 160pt the full source picker cannot fit. All content remains + // reachable, with the native clip view still confined to the detail. + if document.bounds.width > detail.contentView.bounds.width + 1 { + detail.contentView.scroll(to: NSPoint(x: document.bounds.maxX - detail.contentView.bounds.width, y: 0)) + detail.reflectScrolledClipView(detail.contentView) + #expect(abs(detail.contentView.documentVisibleRect.maxX - document.bounds.maxX) < 1) + detail.contentView.scroll(to: .zero) + detail.reflectScrolledClipView(detail.contentView) + } + } + + private static func settle(_ host: NSView, until predicate: () -> Bool) async throws { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(2)) + repeat { + host.layoutSubtreeIfNeeded() + if predicate() { return } + await Task.yield() + } while clock.now < deadline + Issue.record("Marketplace split did not finish the native drag within two seconds") + } + + private static func descendants(_ view: NSView) -> [NSView] { + view.subviews.flatMap { [$0] + descendants($0) } + } +} + +@MainActor +private final class MarketplaceLayoutService: PluginMarketplaceServicing { + let platform = "darwin-arm64" + private let source = PluginMarketplaceSource(id: "layout-source", name: "Lithe Store", + input: "https://github.com/example/plugins", indexURL: "https://example.invalid/index.json", + repository: "https://github.com/example/plugins", isEnabled: true) + private var index: PluginMarketplaceIndex { + .init(plugins: [.init(id: "io.example.cargo", name: "Cargo", author: "Lithe Community", + repository: "https://github.com/example/lithe-plugin-cargo", homepage: "https://example.invalid/cargo", + description: "Cargo build integration with workspace and package tasks, release profiles, features and offline builds.", + localizedDescriptions: ["zh-Hans": "Cargo 构建预发布插件:原生 workspace/包编译、检查、测试、清理及 binary/example 运行,支持 Release、Features 和 Offline。较新的版本需要支持对应能力的宿主。"], + tags: ["build", "cargo", "rust", "testing"], license: "Apache-2.0", icon: nil, + platforms: ["darwin-arm64", "darwin-x64"], latestVersion: "0.1.1", latestReleaseDate: "2026-10-22", + availability: .available(.init(version: "0.1.0", pluginAPIVersion: 3, releaseDate: "2026-10-21", + downloadURL: "https://example.invalid/plugin.zip", sha256: String(repeating: "a", count: 64))), + versions: [.init(version: "0.1.0", pluginAPIVersion: 3, releaseDate: "2026-10-21", platforms: ["darwin-arm64", "darwin-x64"])])], rejected: []) + } + func loadSources() -> [PluginMarketplaceSource] { + [source, .init(id: "other-layout-source", name: "Other Store", input: "https://github.com/example/other", + indexURL: "https://example.invalid/other.json", repository: "https://github.com/example/other", isEnabled: true)] + } + func saveSources(_ sources: [PluginMarketplaceSource]) throws {} + func resolveSource(_ input: String) throws -> PluginMarketplaceSourceResolution { throw PluginMarketplaceIconRejected() } + func cachedIndex(for source: PluginMarketplaceSource) -> PluginMarketplaceIndexSnapshot? { .init(index: index, fetchedAt: nil) } + func refreshIndex(for source: PluginMarketplaceSource) async throws -> PluginMarketplaceIndexSnapshot { .init(index: index, fetchedAt: nil) } + func removeCache(for source: PluginMarketplaceSource) {} + func iconData(from icon: String) async throws -> Data { throw PluginMarketplaceIconRejected() } + func prepareInstall(entry: PluginMarketplaceEntry, download: PluginMarketplaceDownload, source: PluginMarketplaceSource, + approvedPrograms: [PluginProgram]?) async throws -> PreparedPluginPackage { throw PluginMarketplaceIconRejected() } + func discard(_ prepared: PreparedPluginPackage) {} + func prepareDiskInstall(at url: URL) async throws -> PreparedDiskPluginPackage { throw PluginMarketplaceIconRejected() } + func discard(_ prepared: PreparedDiskPluginPackage) {} +} + +@MainActor +private final class MarketplaceLayoutManager: PluginManaging { + let snapshots: [PluginManagementSnapshot] = [] + let issues: [PluginManagementIssue] = [] + func setEnabled(_ enabled: Bool, for pluginID: PluginID) async throws {} + func setSetting(_ value: ToolingJSONValue?, id settingID: String, for pluginID: PluginID) throws {} + func installMarketplacePackage(at packageURL: URL, origin: PluginInstallationOrigin, provenance: PluginMarketplaceProvenance) throws {} + func installDiskPackage(at packageURL: URL, provenance: PluginDiskProvenance) throws {} + func installPackage(at packageURL: URL) throws {} + func rollback(_ pluginID: PluginID) throws {} + func uninstall(_ pluginID: PluginID) async throws {} + func restore(_ pluginID: PluginID) throws {} +} diff --git a/macos/Tests/LitheTests/PluginMarketplaceServiceTests.swift b/macos/Tests/LitheTests/PluginMarketplaceServiceTests.swift index 8edbe9af4e..32f73872b5 100644 --- a/macos/Tests/LitheTests/PluginMarketplaceServiceTests.swift +++ b/macos/Tests/LitheTests/PluginMarketplaceServiceTests.swift @@ -361,7 +361,7 @@ struct PluginMarketplaceServiceTests { sourceKind: .official, platform: "darwin-arm64" ) - let php = try #require(index.plugins.first { $0.id == "dev.lithe.fixture.php-support" }) + let php = try #require(index.plugins.first { $0.id == "dev.lithe.fixture.language-packaged-server" }) #expect(php.latestVersion == "1.1.0") #expect(php.availability.download?.version == "1.0.0") #expect(php.installableVersion?.version == "1.0.0") @@ -374,7 +374,7 @@ struct PluginMarketplaceServiceTests { #expect(future.presentedVersion.version == "3.0.0") } - @Test(arguments: ["gradle-provider", "mix-provider"]) + @Test(arguments: ["build-provider", "build-provider-language-server"]) func buildProvidersAreAcceptedByTheMacOSHost(_ fixture: String) throws { let fixtures = DeclarativePluginTestSupport.repositoryRoot .appendingPathComponent("shared/contracts/plugins/v1/fixtures") @@ -393,7 +393,7 @@ struct PluginMarketplaceServiceTests { func changedRunArgumentsNeedApprovalAgainAfterStorage() throws { let fixtures = DeclarativePluginTestSupport.repositoryRoot .appendingPathComponent("shared/contracts/plugins/v1/fixtures") - let original = try Data(contentsOf: fixtures.appendingPathComponent("php-support.plugin.json")) + let original = try Data(contentsOf: fixtures.appendingPathComponent("language-packaged-server.plugin.json")) var manifest = try #require(JSONSerialization.jsonObject(with: original) as? [String: Any]) var providers = try #require(manifest["executionProviders"] as? [[String: Any]]) var run = try #require(providers[0]["run"] as? [String: Any]) diff --git a/macos/Tests/LitheTests/PluginNativeUITests.swift b/macos/Tests/LitheTests/PluginNativeUITests.swift new file mode 100644 index 0000000000..8b3657cbf1 --- /dev/null +++ b/macos/Tests/LitheTests/PluginNativeUITests.swift @@ -0,0 +1,648 @@ +import AppKit +import SwiftUI +import Foundation +import Testing +import LitheApplicationKernel +import LitheCoreContracts +import LitheModuleAPI +@testable import Lithe + +@MainActor +private struct NativePanelDockFixture: View { + let visible: Set + let panel: (String) -> AnyView + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 0) { + if visible.contains("left") { panel("left") } + if visible.contains("right") { panel("right") } + } + if visible.contains("bottom") { panel("bottom") } + } + } +} + +@MainActor +@Suite("Native plugin UI", .serialized) +struct PluginNativeUITests { + private func declaration(kind: String = "tree") throws -> PluginNativeUI { + try JSONDecoder().decode(PluginNativeUI.self, from: Data(""" + {"schemaVersion":1,"id":"explorer","kind":"\(kind)","nodes":[], + "source":{"buildSystemID":"sample","function":"ui.nodes"}, + "actions":[{"id":"refresh","title":"Refresh","kind":"refresh","requiresSelection":false}, + {"id":"inspect","title":"Inspect","kind":"invoke","function":"ui.inspect","requiresSelection":true}], + "toolbar":["refresh","inspect"],"contextMenu":["inspect"]} + """.utf8)) + } + private var data: ToolingJSONValue { + .object(["nodes": .array([.object(["id": .string("parent"), "label": .string("Parent"), + "children": .array([.object(["id": .string("child"), "label": .string("Child")])])])])]) + } + private var instances: ToolingJSONValue { + .object(["instances": .array([.object(["id": .string("own"), "buildSystemID": .string("sample")])])]) + } + + private func formDeclaration(dynamic: Bool = false, disabled: Bool = false) throws -> PluginNativeUI { + let data = try Data(contentsOf: DeclarativePluginTestSupport.repositoryRoot + .appendingPathComponent("shared/contracts/plugins/v1/fixtures/native-form.plugin.json")) + let descriptor = try DeclarativePluginTestSupport.operations.validate(manifest: data) + let form = try #require(descriptor.pluginManifest.modules.flatMap(\.contributions).first { $0.nativeUI != nil }?.nativeUI) + guard dynamic || disabled else { return form } + var value = try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(form)) as? [String: Any]) + value["source"] = ["buildSystemID": "sample", "function": "form.nodes"] + value["actions"] = [["id": "changed", "title": "Changed", "kind": "invoke", "function": "form.changed", "requiresSelection": false]] + var controls = try #require(value["controls"] as? [[String: Any]]) + controls[0]["onCommit"] = "changed" + if disabled { controls = controls.map { $0.merging(["enabled": false]) { _, new in new } } } + value["controls"] = controls + return try JSONDecoder().decode(PluginNativeUI.self, from: JSONSerialization.data(withJSONObject: value)) + } + + @Test func formValuesRestoreWithCurrentTypesOptionsAndBudgets() throws { + let declaration = try formDeclaration() + // States saved before forms were added must remain decodable. + let old = try JSONDecoder().decode(PluginNativeUIFeature.State.self, from: Data("{\"expanded\":[]}".utf8)) + #expect(old.values == nil) + let state = PluginNativeUIFeature.State(values: ["name": .text("保存的中文 😀"), "environment": .text("removed"), + "notify": .text("wrong type"), "stale": .checked(true)]) + var saves = 0 + let feature = PluginNativeUIFeature(declaration: declaration, state: state, + validateNodes: DeclarativePluginTestSupport.operations.validateNativeNodes, + request: { _, _ in .null }, saveState: { _ in saves += 1 }) + defer { feature.close() } + let name = try #require(declaration.controls?.first { $0.id == "name" }) + let environment = try #require(declaration.controls?.first { $0.id == "environment" }) + let notify = try #require(declaration.controls?.first { $0.id == "notify" }) + #expect(feature.value(for: name) == .text("保存的中文 😀")) + #expect(feature.value(for: environment) == .text("dev")) + #expect(feature.value(for: notify) == .checked(true)) + #expect(feature.state.values?.count == 1) + #expect(!feature.setValue(.checked(false), for: name)) + #expect(!feature.setValue(.text("not-an-option"), for: environment)) + #expect(!feature.setValue(.text(String(repeating: "😀", count: 257)), for: name)) + #expect(feature.setValue(.text("更新 English / 中文 / 😀"), for: name)) + #expect(saves == 1) + feature.close() + #expect(!feature.setValue(.text("after-close"), for: name)) + #expect(saves == 1) + } + + @Test func disabledFormControlsCannotEditOrCommit() throws { + let declaration = try formDeclaration(dynamic: true, disabled: true) + let feature = PluginNativeUIFeature(declaration: declaration, + validateNodes: DeclarativePluginTestSupport.operations.validateNativeNodes, + request: { _, _ in Issue.record("Disabled controls must not invoke the bridge"); return .null }) + defer { feature.close() } + for control in declaration.controls ?? [] { + let changed: PluginNativeUI.ControlValue = control.kind == .checkbox ? .checked(false) : .text("test") + #expect(!feature.setValue(changed, for: control)) + #expect(feature.commit(control) == nil) + #expect(feature.value(for: control) == control.value) + } + } + + @Test func formCommitUsesExistingBridgeAndCapturesAllCurrentValues() async throws { + let declaration = try formDeclaration(dynamic: true) + var calls: [ToolingJSONValue] = [] + let feature = PluginNativeUIFeature(declaration: declaration, + validateNodes: DeclarativePluginTestSupport.operations.validateNativeNodes, request: { method, params in + if method == "build.instances" { return instances } + calls.append(params) + if case .object(let body) = params, body["function"] == .string("form.changed") { + return .object(["value": .object(["message": .string("Received")])]) + } + return .object(["value": .object(["nodes": .array([])])]) + }) + defer { feature.close() } + let controls = try #require(declaration.controls) + let name = try #require(controls.first { $0.id == "name" }) + let environment = try #require(controls.first { $0.id == "environment" }) + let notify = try #require(controls.first { $0.id == "notify" }) + #expect(feature.setValue(.text("中文 😀"), for: name)) + #expect(feature.setValue(.text("prod"), for: environment)) + #expect(feature.setValue(.checked(false), for: notify)) + let pending = try #require(feature.commit(name)) + #expect(!feature.setValue(.text("while-busy"), for: name)) + await pending.value + let values: ToolingJSONValue = .object(["name": .string("中文 😀"), "query": .string(""), + "environment": .string("prod"), "notify": .bool(false)]) + #expect(calls == [ + .object(["instance": .string("own"), "function": .string("form.changed"), + "params": .object(["selectedID": .null, "values": values])]), + .object(["instance": .string("own"), "function": .string("form.nodes"), "params": .object(["values": values])]), + ]) + #expect(feature.message == "Received") + #expect(feature.error == nil) + let restored = try JSONDecoder().decode(PluginNativeUIFeature.State.self, from: JSONEncoder().encode(feature.state)) + #expect(restored.values?["name"] == .text("中文 😀")) + } + + @Test func mountedFormUsesNativeFieldsSelectorAndCheckbox() async throws { + let application = NSApplication.shared + let attribute = NSAccessibility.Attribute(rawValue: "AXEnhancedUserInterface") + let previous = (application.accessibilityAttributeValue(attribute) as? Bool) ?? false + application.accessibilitySetValue(true, forAttribute: attribute) + defer { application.accessibilitySetValue(previous, forAttribute: attribute) } + let declaration = try formDeclaration() + let feature = PluginNativeUIFeature(declaration: declaration, + validateNodes: DeclarativePluginTestSupport.operations.validateNativeNodes, request: { _, _ in .null }) + let host = NSHostingView(rootView: PluginNativePanel( + contribution: ModuleContribution(id: "example.form", kind: .toolWindow, title: "Form", nativeUI: declaration), + feature: feature, onMinimize: {}).frame(width: 400, height: 420)) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 400, height: 420), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = host + window.orderFront(nil) + defer { feature.close(); window.contentView = nil; window.close() } + func find(_ value: Any, role: NSAccessibility.Role) -> AnyObject? { + let item = value as AnyObject + if item.accessibilityRole?() == role { return item } + return (item.accessibilityChildren?() ?? []).lazy.compactMap { find($0, role: role) }.first + } + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(1)) + var checkbox: AnyObject? + repeat { + host.layoutSubtreeIfNeeded() + checkbox = find(host, role: .checkBox) + if checkbox != nil { break } + await Task.yield() + } while clock.now < deadline + #expect(find(host, role: .textField) != nil) + let check = try #require(checkbox) + #expect(check.accessibilityPerformPress?() == true) + let notify = try #require(declaration.controls?.first { $0.id == "notify" }) + #expect(feature.value(for: notify) == .checked(false)) + // The selector's trigger is a real shared button, rather than a drawn dropdown. + func hasSelector(_ value: Any) -> Bool { + let item = value as AnyObject + if item.accessibilityRole?() == .button, item.accessibilityLabel?() == "环境(切换后自动提交)" { return true } + return (item.accessibilityChildren?() ?? []).contains { hasSelector($0) } + } + #expect(hasSelector(host)) + } + + private func publicControlsDeclaration() throws -> PluginNativeUI { + let data = try Data(contentsOf: DeclarativePluginTestSupport.repositoryRoot + .appendingPathComponent("shared/contracts/plugins/v1/fixtures/native-controls.plugin.json")) + let descriptor = try DeclarativePluginTestSupport.operations.validate(manifest: data) + return try #require(descriptor.pluginManifest.modules.flatMap(\.contributions).compactMap(\.nativeUI).first) + } + + @Test func publicControlsKeepTypedStateAndButtonsInvokeWithoutBecomingValues() async throws { + let declaration = try publicControlsDeclaration() + var calls: [ToolingJSONValue] = [] + let feature = PluginNativeUIFeature(declaration: declaration, + validateNodes: DeclarativePluginTestSupport.operations.validateNativeNodes, request: { method, params in + if method == "build.instances" { return instances } + calls.append(params) + return .object(["value": .object(["nodes": .array([]), "message": .string("Done")])]) + }) + defer { feature.close() } + let controls = try #require(declaration.controls) + let editor = try #require(controls.first { $0.kind == .editor }) + let segmented = try #require(controls.first { $0.kind == .segmented }) + let stepper = try #require(controls.first { $0.kind == .stepper }) + #expect(feature.setValue(.text("中文\nEnglish 😀"), for: editor)) + #expect(feature.commit(editor) == nil) + #expect(feature.setValue(.text("debug"), for: segmented)) + #expect(!feature.setValue(.text("missing"), for: segmented)) + #expect(!feature.setValue(.number(.infinity), for: stepper)) + #expect(!feature.setValue(.number(9), for: stepper)) + #expect(feature.setValue(.number(3), for: stepper)) + let buttons = try #require(declaration.buttons) + let disabled = try #require(buttons.first { $0.enabled == false }) + #expect(!feature.canPerform(disabled)) + #expect(feature.perform(disabled) == nil) + #expect(calls.isEmpty) + let submit = try #require(buttons.first) + let pending = try #require(feature.perform(submit)) + #expect(!feature.canPerform(submit)) + await pending.value + let first = try #require(calls.first) + guard case .object(let invoke) = first, case .object(let params)? = invoke["params"], + case .object(let values)? = params["values"] else { + Issue.record("Form buttons must use the existing function envelope"); return + } + #expect(values[editor.id] == .string("中文\nEnglish 😀")) + #expect(values[stepper.id] == .number(3)) + #expect(values[segmented.id] == .string("debug")) + #expect(values.count == controls.count) + #expect(buttons.allSatisfy { values[$0.id] == nil }) + let restored = try JSONDecoder().decode(PluginNativeUIFeature.State.self, from: JSONEncoder().encode(feature.state)) + #expect(restored.values?[stepper.id] == .number(3)) + feature.close() + #expect(feature.perform(submit) == nil) + } + + @Test func mountedPublicControlsExposeInfoAndAllSharedControlKinds() async throws { + let application = NSApplication.shared + let attribute = NSAccessibility.Attribute(rawValue: "AXEnhancedUserInterface") + let previous = (application.accessibilityAttributeValue(attribute) as? Bool) ?? false + application.accessibilitySetValue(true, forAttribute: attribute) + defer { application.accessibilitySetValue(previous, forAttribute: attribute) } + let declaration = try publicControlsDeclaration() + let feature = PluginNativeUIFeature(declaration: declaration, + validateNodes: DeclarativePluginTestSupport.operations.validateNativeNodes, request: { _, _ in .null }) + let host = NSHostingView(rootView: PluginNativePanel( + contribution: ModuleContribution(id: "example.controls", kind: .toolWindow, title: "Controls", nativeUI: declaration), + feature: feature, onMinimize: {}).frame(width: 1000, height: 1400)) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 1000, height: 1400), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = host + window.orderFront(nil) + defer { feature.close(); window.contentView = nil; window.close() } + func descendants(_ value: Any) -> [AnyObject] { + let item = value as AnyObject + return [item] + (item.accessibilityChildren?() ?? []).flatMap { descendants($0) } + } + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(1)) + var elements: [AnyObject] = [] + repeat { + host.layoutSubtreeIfNeeded() + elements = descendants(host) + if elements.contains(where: { $0.accessibilityIdentifier?() == "native-button-secondary-fill" }) { break } + await Task.yield() + } while clock.now < deadline + #expect(elements.contains { $0.accessibilityRole?() == .textArea }) + #expect(elements.contains { $0.accessibilityHelp?() == declaration.controls?.first?.info }) + for id in ["search", "notes", "mode", "concurrency"] { + #expect(elements.contains { $0.accessibilityIdentifier?() == "native-control-" + id }) + } + let legacyFill = try #require(elements.first { + $0.accessibilityIdentifier?() == "native-button-secondary-fill" && $0.accessibilityRole?() == .button + }) + let buttonFrame = try #require(legacyFill.accessibilityFrame?()) + #expect(buttonFrame.height == 28, "Declarative actions must not reintroduce the old 3pt external inset") + #expect(buttonFrame.width >= 72 && buttonFrame.width < 300, "Legacy fill hints must not stretch shared buttons") + let disabled = try #require(elements.first { + $0.accessibilityIdentifier?() == "native-button-disabled-button" && $0.accessibilityRole?() == .button + }) + #expect(disabled.isAccessibilityEnabled?() == false) + // Disabled controls must not disable the separate explanatory buttons. + let disabledHints = Set((declaration.controls ?? []).filter { $0.enabled == false }.compactMap(\.info)) + let tips = elements.filter { $0.accessibilityRole?() == .button && disabledHints.contains($0.accessibilityHelp?() ?? "") } + #expect(tips.count >= disabledHints.count) + #expect(tips.allSatisfy { $0.isAccessibilityEnabled?() == true }) + let tip = try #require(tips.first) + #expect(tip.accessibilityPerformPress?() == true) + let tipDeadline = clock.now.advanced(by: .seconds(1)) + while window.childWindows?.contains(where: \.isVisible) != true && clock.now < tipDeadline { await Task.yield() } + #expect(window.childWindows?.contains(where: \.isVisible) == true) + #expect(tip.accessibilityPerformPress?() == true) + } + + @Test func sharedFixtureRegistersItsNativeRenderer() throws { + let data = try Data(contentsOf: DeclarativePluginTestSupport.repositoryRoot + .appendingPathComponent("shared/contracts/plugins/v1/fixtures/native-panel.plugin.json")) + let descriptor = try DeclarativePluginTestSupport.operations.validate(manifest: data) + let window = try #require(descriptor.pluginManifest.modules.flatMap(\.contributions) + .first(where: { $0.nativeUI != nil })) + #expect(window.hasDeclarativeContent) + #expect(window.nativeUI?.source?.buildSystemID == "sample") + let catalog = try ValidatedPluginCatalog(manifests: BuiltInPluginCatalog.manifests + [descriptor.pluginManifest], + hostVersion: BuiltInPluginCatalog.hostVersion) + let registry = try WorkbenchModuleUIComposition.registry(for: catalog) + #expect(registry.renderer(for: window) != nil) + } + + @Test func mountedPanelKeepsItsFeatureWhenInitialContentAppears() async throws { + let loaded = TestGate() + defer { loaded.open() } + let declaration = try declaration() + var creations = 0 + var releases = 0 + var feature: PluginNativeUIFeature? + let contribution = ModuleContribution(id: "example.panel", kind: .toolWindow, title: "Sample Explorer", nativeUI: declaration) + let view = PluginNativeToolWindowView(contribution: contribution, makeFeature: { + creations += 1 + let created = PluginNativeUIFeature(declaration: declaration, + validateNodes: DeclarativePluginTestSupport.operations.validateNativeNodes, request: { method, _ in + if method == "build.instances" { return instances } + return .object(["value": data]) + }, saveState: { _ in loaded.open() }) + feature = created + return created + }, releaseFeature: { releases += 1; $0.close() }, onMinimize: {}) + let host = NSHostingView(rootView: AnyView(VStack(spacing: 0) { view })) + host.frame = NSRect(x: 0, y: 0, width: 420, height: 280) + let window = NSWindow(contentRect: host.frame, styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = host + window.makeKeyAndOrderFront(nil) + defer { feature?.close(); host.rootView = AnyView(EmptyView()); window.contentView = nil; window.close() } + host.layoutSubtreeIfNeeded() + #expect(await loaded.waitUntilOpen()) + // Publishing the feature changes the child from empty to a real panel; + // that must not tear down the window's own request lifecycle. + host.layoutSubtreeIfNeeded() + #expect(creations == 1) + #expect(releases == 0) + #expect(feature?.rows.map(\.id) == ["parent"]) + #expect(feature?.error == nil) + } + + @Test func switchingNativePanelsInOneDockSlotReleasesTheCorrectWindow() async throws { + let pages = PluginWebPages() + let host = NSHostingView(rootView: AnyView(EmptyView())) + host.frame = NSRect(x: 0, y: 0, width: 420, height: 280) + let window = NSWindow(contentRect: host.frame, styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = host + window.makeKeyAndOrderFront(nil) + defer { host.rootView = AnyView(EmptyView()); pages.closeAll(); window.contentView = nil; window.close() } + var previousRelease: TestGate? + var failures: [String] = [] + var gates: [TestGate] = [] + defer { gates.forEach { $0.open() } } + // All panels use the same SwiftUI renderer type in a reused dock slot. + // A single-panel mount cannot detect release using a different window ID. + for id in ["tree", "static-tree", "list", "empty", "tree", "static-tree"] { + let appeared = TestGate() + let released = TestGate() + gates += [appeared, released] + let declaration = try JSONDecoder().decode(PluginNativeUI.self, from: Data(""" + {"schemaVersion":1,"id":"\(id)","kind":"list","nodes":[{"id":"item","label":"\(id)"}], + "actions":[],"toolbar":[],"contextMenu":[]} + """.utf8)) + let contribution = ModuleContribution(id: "example.\(id)", kind: .toolWindow, title: id, nativeUI: declaration) + let view = PluginNativeToolWindowView(contribution: contribution, makeFeature: { + defer { appeared.open() } + let feature = PluginNativeUIFeature(declaration: declaration, + validateNodes: DeclarativePluginTestSupport.operations.validateNativeNodes, request: { _, _ in .null }) + guard pages.registerNative(feature, windowID: contribution.id, pluginID: "example") else { + feature.close() + failures.append(id) + throw PluginWebBridgeFailure.unavailable("Duplicate panel registration") + } + return feature + }, releaseFeature: { + pages.releaseNative($0, windowID: contribution.id) + released.open() + }, onMinimize: {}) + host.rootView = AnyView(VStack(spacing: 0) { view }) + host.layoutSubtreeIfNeeded() + #expect(await appeared.waitUntilOpen()) + if let previousRelease { #expect(await previousRelease.waitUntilOpen()) } + #expect(pages.openWindowIDs == [contribution.id]) + #expect(failures.isEmpty) + previousRelease = released + } + host.rootView = AnyView(EmptyView()) + host.layoutSubtreeIfNeeded() + if let previousRelease { #expect(await previousRelease.waitUntilOpen()) } + #expect(pages.openWindowIDs.isEmpty) + } + + @Test func threeDockedPanelsCanHideAndReopenWithoutLeakingRegistrations() async throws { + let pages = PluginWebPages() + let declaration = try JSONDecoder().decode(PluginNativeUI.self, from: Data(""" + {"schemaVersion":1,"id":"static","kind":"list","nodes":[{"id":"item","label":"Item"}], + "actions":[],"toolbar":[],"contextMenu":[]} + """.utf8)) + let host = NSHostingView(rootView: AnyView(EmptyView())) + host.frame = NSRect(x: 0, y: 0, width: 720, height: 480) + let window = NSWindow(contentRect: host.frame, styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = host + window.makeKeyAndOrderFront(nil) + defer { host.rootView = AnyView(EmptyView()); pages.closeAll(); window.contentView = nil; window.close() } + var appearances: [String: TestGate] = [:] + var releases: [String: TestGate] = [:] + var gates: [TestGate] = [] + var failures: [String] = [] + defer { gates.forEach { $0.open() } } + func panel(_ id: String) -> AnyView { + let contribution = ModuleContribution(id: id, kind: .toolWindow, title: id, nativeUI: declaration) + return AnyView(PluginNativeToolWindowView(contribution: contribution, makeFeature: { + defer { appearances[id]?.open() } + let feature = PluginNativeUIFeature(declaration: declaration, + validateNodes: DeclarativePluginTestSupport.operations.validateNativeNodes, request: { _, _ in .null }) + guard pages.registerNative(feature, windowID: id, pluginID: "example") else { + feature.close() + failures.append(id) + throw PluginWebBridgeFailure.unavailable("Duplicate panel registration") + } + return feature + }, releaseFeature: { + pages.releaseNative($0, windowID: id) + releases[id]?.open() + }, onMinimize: {})) + } + var previous: Set = [] + let scenarios: [Set] = [ + ["left", "right", "bottom"], ["right", "bottom"], ["left", "right", "bottom"], + ["left", "bottom"], ["left", "right", "bottom"], [], ["left", "right", "bottom"], [] + ] + for visible in scenarios { + let added = visible.subtracting(previous) + let removed = previous.subtracting(visible) + for id in added { let gate = TestGate(); appearances[id] = gate; gates.append(gate) } + for id in removed { let gate = TestGate(); releases[id] = gate; gates.append(gate) } + host.rootView = AnyView(NativePanelDockFixture(visible: visible, panel: panel)) + host.layoutSubtreeIfNeeded() + for id in added { #expect(await appearances[id]!.waitUntilOpen()) } + for id in removed { #expect(await releases[id]!.waitUntilOpen()) } + #expect(pages.openWindowIDs == visible) + #expect(failures.isEmpty) + previous = visible + } + } + + @Test func nativePanelsRenderInBothAppearances() async throws { + for kind in ["tree", "list"] { + let flat: ToolingJSONValue = .object(["nodes": .array([ + .object(["id": .string("first"), "label": .string("First item")]), + .object(["id": .string("second"), "label": .string("Second item")])])]) + let feature = PluginNativeUIFeature(declaration: try declaration(kind: kind), validateNodes: DeclarativePluginTestSupport.operations.validateNativeNodes, request: { method, _ in + if method == "build.instances" { return instances } + return .object(["value": kind == "tree" ? data : flat]) + }) + defer { feature.close() } + await feature.refresh()?.value + if kind == "tree" { feature.toggle("parent") } + feature.select(kind == "tree" ? "child" : "first") + for dark in [false, true] { + let contribution = ModuleContribution(id: "example.panel", kind: .toolWindow, title: "Sample Explorer", + nativeUI: feature.declaration) + let host = NSHostingView(rootView: PluginNativePanel(contribution: contribution, + feature: feature, onMinimize: {}).environment(\.colorScheme, dark ? .dark : .light)) + host.frame = NSRect(x: 0, y: 0, width: 420, height: 280) + let window = NSWindow(contentRect: host.frame, styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: dark ? .darkAqua : .aqua) + window.contentView = host + defer { window.contentView = nil; window.close() } + host.layoutSubtreeIfNeeded() + #expect(host.fittingSize.width > 0 && host.fittingSize.height > 0) + let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: bitmap) + let scale = CGFloat(bitmap.pixelsWide) / host.bounds.width + let selectedY = LitheTheme.Metrics.toolWindowHeaderHeight + LitheTheme.Tree.verticalInset + + LitheTheme.Tree.rowHeight * (kind == "tree" ? 1.5 : 0.5) + let leftX = Int(LitheTheme.Tree.horizontalInset / 2 * scale) + let rightX = bitmap.pixelsWide - leftX - 1 + let y = Int(selectedY * scale) + let background = try #require(bitmap.colorAt(x: leftX, y: bitmap.pixelsHigh - 8)) + // The shared hover/selection background must leave both outer gutters clear. + #expect(bitmap.colorAt(x: leftX, y: y) == background) + #expect(bitmap.colorAt(x: rightX, y: y) == background) + #expect(bitmap.colorAt(x: Int((LitheTheme.Tree.horizontalInset + 4) * scale), y: y) != background) + if let directory = ProcessInfo.processInfo.environment["LITHE_NATIVE_UI_CAPTURE_DIR"] { + try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true) + let png = try #require(bitmap.representation(using: .png, properties: [:])) + try png.write(to: URL(fileURLWithPath: directory).appendingPathComponent("native-\(kind)-\(dark ? "dark" : "light").png")) + } + } + } + } + + @Test(arguments: [false, true]) func sharedHeaderOwnsCurrentHeightWithoutTitleIconsOrBorders(dark: Bool) throws { + let host = NSHostingView(rootView: LitheToolWindowHeader(title: "UI Lab", usesWorkbenchSurface: false) { + EmptyView() + }.environment(\.colorScheme, dark ? .dark : .light)) + host.frame = NSRect(x: 0, y: 0, width: 320, height: 41) + let window = NSWindow(contentRect: host.frame, styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: dark ? .darkAqua : .aqua) + window.contentView = host + defer { window.contentView = nil; window.close() } + host.layoutSubtreeIfNeeded() + #expect(host.fittingSize.height == 41) + let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: bitmap) + let scale = CGFloat(bitmap.pixelsWide) / host.bounds.width + let bottom = bitmap.pixelsHigh - 1 + let innerX = Int(4 * scale) + // A title-only header has no icon, outline or bottom rule. + #expect(bitmap.colorAt(x: innerX, y: bottom) == bitmap.colorAt(x: 0, y: bottom)) + #expect(bitmap.colorAt(x: innerX, y: bottom) == bitmap.colorAt(x: innerX, y: bottom - Int(2 * scale))) + #expect(bitmap.colorAt(x: 0, y: bottom) == bitmap.colorAt(x: bitmap.pixelsWide - 1, y: bottom)) + } + + @Test func refreshSelectionActionAndExpansionUseStableIDs() async throws { + var calls: [ToolingJSONValue] = [] + var saved = PluginNativeUIFeature.State() + let feature = PluginNativeUIFeature(declaration: try declaration(), validateNodes: DeclarativePluginTestSupport.operations.validateNativeNodes, request: { method, params in + if method == "build.instances" { return instances } + calls.append(params) + if case .object(let body) = params, body["function"] == .string("ui.inspect") { + return .object(["value": .object(["message": .string("Done")])]) + } + return .object(["value": data]) + }, saveState: { saved = $0 }) + defer { feature.close() } + let inspect = try #require(feature.declaration.actions.last) + #expect(!feature.canPerform(inspect)) + await feature.refresh()?.value + #expect(feature.rows.map(\.id) == ["parent"]) + feature.toggle("parent") + feature.select("child") + #expect(feature.canPerform(inspect)) + await feature.perform(inspect)?.value + #expect(calls.count == 3) + #expect(calls[1] == .object(["instance": .string("own"), "function": .string("ui.inspect"), + "params": .object(["selectedID": .string("child")])])) + #expect(feature.message == "Done") + #expect(saved.selection == "child") + feature.toggle("parent") + #expect(feature.state.selection == "parent") + } + + @Test func invalidAndOversizedDataNeverReplaceDisplayedRows() async throws { + let valid = try DeclarativePluginTestSupport.operations.validateNativeNodes(ui: declaration(), data: data) + #expect(valid.count == 1) + #expect(throws: (any Error).self) { try DeclarativePluginTestSupport.operations.validateNativeNodes(ui: declaration(kind: "list"), data: data) } + let duplicate: ToolingJSONValue = .object(["nodes": .array([ + .object(["id": .string("same"), "label": .string("One")]), + .object(["id": .string("same"), "label": .string("Two")])])]) + #expect(throws: (any Error).self) { try DeclarativePluginTestSupport.operations.validateNativeNodes(ui: declaration(), data: duplicate) } + var fail = false + let feature = PluginNativeUIFeature(declaration: try declaration(), validateNodes: DeclarativePluginTestSupport.operations.validateNativeNodes, request: { method, _ in + if method == "build.instances" { return instances } + return .object(["value": fail ? duplicate : data]) + }) + defer { feature.close() } + await feature.refresh()?.value + fail = true + await feature.refresh()?.value + #expect(feature.nodes == valid) + #expect(feature.error != nil) + let oversized: ToolingJSONValue = .object(["nodes": .array((0..<2001).map { + .object(["id": .string("node.\($0)"), "label": .string("X")]) + })]) + #expect(throws: (any Error).self) { try DeclarativePluginTestSupport.operations.validateNativeNodes(ui: declaration(), data: oversized) } + } + + @Test(arguments: [0, 2]) func ambiguousOrMissingInstancesNeverInvokeAFunction(count: Int) async throws { + var invocations = 0 + let feature = PluginNativeUIFeature(declaration: try declaration(), + validateNodes: DeclarativePluginTestSupport.operations.validateNativeNodes, request: { method, _ in + guard method == "build.instances" else { invocations += 1; return .null } + return .object(["instances": .array((0..".utf8).write(to: package.appendingPathComponent("ui/index.html")) + let executable = package.appendingPathComponent("bin/backend") + try FileManager.default.createDirectory(at: executable.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("#!/bin/sh\n".utf8).write(to: executable) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) + switch defect { + case "missing": try FileManager.default.removeItem(at: executable) + case "notExecutable": try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: executable.path) + default: break + } + + let pluginID = PluginID("dev.lithe.fixture.backend") + guard defect != "usable" else { + #expect(try store.installPackage(from: package).descriptor.declaresBackend) + return + } + #expect(throws: PluginPackageStoreError.invalidInstalledPlugin( + pluginID, PluginPackageStoreError.backendLaunchMissing(pluginID, path: "bin/backend").localizedDescription + )) { + _ = try store.installPackage(from: package) + } + } + /// A runtime launch needs its packaged entry; the runtime itself is /// discovered on the machine and is not part of the package. @Test @@ -521,27 +557,36 @@ struct PluginPackageStoreTests { let root = temporaryRoot() defer { try? FileManager.default.removeItem(at: root) } let directories = MacPluginStateDirectories(rootURL: root.appendingPathComponent("state", isDirectory: true)) - let store = makeStore(root: root, stateDirectories: directories) + // Backend credentials share the state's lifetime. + let credentials = MemoryPluginCredentialStore() + let store = makeStore(root: root, stateDirectories: directories, credentials: credentials) let version = PluginVersion(major: 0, minor: 3, patch: 0) func writeState(for plugin: InstalledPluginPackage) throws -> URL { let state = directories.stateDirectory(pluginID: plugin.manifest.id.rawValue, languageID: "fixture") try FileManager.default.createDirectory(at: state, withIntermediateDirectories: true) try Data("index".utf8).write(to: state.appendingPathComponent("index")) + try credentials.setSecret("s3cret", pluginID: plugin.manifest.id.rawValue, key: "connection") return directories.pluginDirectory(pluginID: plugin.manifest.id.rawValue) } + func secret(of plugin: InstalledPluginPackage) throws -> String? { + try credentials.secret(pluginID: plugin.manifest.id.rawValue, key: "connection") + } let immediate = try store.installPackage(from: makePackage(root: root, name: "one", version: version)) let immediateState = try writeState(for: immediate) try store.uninstall(immediate.manifest.id) #expect(!FileManager.default.fileExists(atPath: immediateState.path)) + #expect(try secret(of: immediate) == nil) // A staged uninstall keeps the state until the package itself goes. let staged = try store.installPackage(from: makePackage(root: root, name: "two", version: version)) let stagedState = try writeState(for: staged) try store.stageUninstall(staged.manifest.id) #expect(FileManager.default.fileExists(atPath: stagedState.path)) + #expect(try secret(of: staged) == "s3cret") try store.prepareForLaunch() #expect(!FileManager.default.fileExists(atPath: stagedState.path)) + #expect(try secret(of: staged) == nil) } /// An installed schema-1 native PHP package is reported as needing an @@ -742,12 +787,14 @@ struct PluginPackageStoreTests { root: URL, bundledRoot: URL? = nil, stateDirectories: MacPluginStateDirectories? = nil, + credentials: (any PluginCredentialStoring)? = nil, verifier: any PluginPackageSignatureVerifying = TestPluginSignatureVerifier() ) -> MacPluginPackageStore { MacPluginPackageStore( rootURL: installedRoot(root), bundledRootURL: bundledRoot, stateDirectories: stateDirectories, + credentials: credentials, validator: DeclarativePluginTestSupport.operations, verifier: verifier ) @@ -793,7 +840,7 @@ struct PluginPackageStoreTests { /// `launch` is given. A packaged launch gets an executable script. private func makeProviderPackage(root: URL, launch: [String: Any]?) throws -> URL { let fixtureURL = DeclarativePluginTestSupport.repositoryRoot - .appendingPathComponent("shared/contracts/plugins/v1/fixtures/gradle-provider.plugin.json") + .appendingPathComponent("shared/contracts/plugins/v1/fixtures/build-provider.plugin.json") var manifest = try #require( try JSONSerialization.jsonObject(with: Data(contentsOf: fixtureURL)) as? [String: Any] ) diff --git a/macos/Tests/LitheTests/PluginRepositoryPresentationTests.swift b/macos/Tests/LitheTests/PluginRepositoryPresentationTests.swift new file mode 100644 index 0000000000..5ea372063c --- /dev/null +++ b/macos/Tests/LitheTests/PluginRepositoryPresentationTests.swift @@ -0,0 +1,137 @@ +import Foundation +import Testing +@testable import Lithe + +@Suite("Plugin repository sheet presentation") +struct PluginRepositoryPresentationTests { + private static let now = Date(timeIntervalSince1970: 1_800_000_000) + private static let locale = Locale(identifier: "en_US") + + private func refreshedTime(secondsAgo: TimeInterval) -> String { + PluginMarketplacePresentation.refreshedTime( + Self.now.addingTimeInterval(-secondsAgo), + now: Self.now, + locale: Self.locale + ) + } + + private static func source( + id: String, + input: String, + indexURL: String + ) -> PluginMarketplaceSource { + PluginMarketplaceSource( + id: id, name: "Example", input: input, indexURL: indexURL, repository: nil, isEnabled: true + ) + } + + /// Lithe Store is listed by its GitHub repository, as the user would paste + /// it, and the built-in source by its index path in the Lithe repository; + /// neither shows the long raw.githubusercontent.com address. + @Test + func rowsShowWhatTheUserRecognizesInsteadOfTheRawIndexURL() { + let official = Self.source( + id: PluginMarketplaceSource.officialID, + input: "https://raw.githubusercontent.com/Lithe-IDEA/Lithe-IDEA/HEAD/Plugins/marketplace/index.json", + indexURL: "https://raw.githubusercontent.com/Lithe-IDEA/Lithe-IDEA/HEAD/Plugins/marketplace/index.json" + ) + let store = Self.source( + id: PluginMarketplaceSource.communityID, + input: "https://github.com/Lithe-IDEA/Lithe-store", + indexURL: PluginMarketplaceSource.communityIndexURL + ) + let custom = Self.source( + id: "source-custom", + input: "https://plugins.example.dev/lithe-plugin-index.json", + indexURL: "https://plugins.example.dev/lithe-plugin-index.json" + ) + + #expect(PluginMarketplacePresentation.sourceLocation(official) == "Plugins/marketplace/index.json") + #expect(PluginMarketplacePresentation.sourceLocation(store) == "https://github.com/Lithe-IDEA/Lithe-store") + #expect(PluginMarketplacePresentation.sourceLocation(custom) == "https://plugins.example.dev/lithe-plugin-index.json") + } + + /// An official index that does not live on raw.githubusercontent.com has + /// no repository path to cut out, so the row falls back to the address. + @Test + func officialSourceOnAnotherHostKeepsItsAddress() { + let official = Self.source( + id: PluginMarketplaceSource.officialID, + input: "https://mirror.example.dev/lithe/index.json", + indexURL: "https://mirror.example.dev/lithe/index.json" + ) + + #expect(PluginMarketplacePresentation.sourceLocation(official) == "https://mirror.example.dev/lithe/index.json") + } + + /// The sheet shows "Refreshed just now" right after a refresh; a fetch time + /// slightly ahead of the clock (the user moved it back) must not turn into + /// "in 30 seconds". + @Test + func fetchUnderAMinuteOldReadsJustNow() { + #expect(refreshedTime(secondsAgo: 0) == "just now") + #expect(refreshedTime(secondsAgo: 59) == "just now") + #expect(refreshedTime(secondsAgo: -30) == "just now") + } + + @Test + func fetchUnderAWeekOldReadsAsRelativePhrase() { + #expect(refreshedTime(secondsAgo: 5 * 60) == "5 minutes ago") + #expect(refreshedTime(secondsAgo: 3 * 60 * 60) == "3 hours ago") + #expect(refreshedTime(secondsAgo: 2 * 24 * 60 * 60) == "2 days ago") + } + + /// "3 weeks ago" would hide which day the index was fetched, so older + /// fetches fall back to the date. + @Test + func fetchOlderThanAWeekReadsAsDate() { + let fetchedAt = Self.now.addingTimeInterval(-8 * 24 * 60 * 60) + let text = PluginMarketplacePresentation.refreshedTime(fetchedAt, now: Self.now, locale: Self.locale) + + #expect(!text.contains("ago")) + #expect(text == fetchedAt.formatted(Date.FormatStyle(date: .abbreviated, time: .shortened).locale(Self.locale))) + } + + /// The built-in source and the preset Lithe Store carry the "Official" + /// tag; a repository the user added, even another GitHub one, is + /// "Community". + @Test + func litheRunSourcesAreTaggedOfficialAndOthersCommunity() { + let official = Self.source( + id: PluginMarketplaceSource.officialID, + input: "https://raw.githubusercontent.com/Lithe-IDEA/Lithe-IDEA/HEAD/Plugins/marketplace/index.json", + indexURL: "https://raw.githubusercontent.com/Lithe-IDEA/Lithe-IDEA/HEAD/Plugins/marketplace/index.json" + ) + let store = Self.source( + id: PluginMarketplaceSource.communityID, + input: "https://github.com/Lithe-IDEA/Lithe-store", + indexURL: PluginMarketplaceSource.communityIndexURL + ) + let userAdded = Self.source( + id: PluginMarketplaceSource.id(forIndexURL: "https://raw.githubusercontent.com/someone/plugins/HEAD/lithe-plugin-index.json"), + input: "https://github.com/someone/plugins", + indexURL: "https://raw.githubusercontent.com/someone/plugins/HEAD/lithe-plugin-index.json" + ) + + #expect(PluginMarketplacePresentation.isLitheRun(official)) + #expect(PluginMarketplacePresentation.isLitheRun(store)) + #expect(!PluginMarketplacePresentation.isLitheRun(userAdded)) + } + + /// Formatters are shared per locale; a second call, and a call in another + /// locale between them, must still format in the locale asked for. + @Test + func sharedFormatterKeepsEachLocaleSeparate() { + let fetchedAt = Self.now.addingTimeInterval(-5 * 60) + let chinese = Locale(identifier: "zh_Hans") + + let first = PluginMarketplacePresentation.refreshedTime(fetchedAt, now: Self.now, locale: Self.locale) + let other = PluginMarketplacePresentation.refreshedTime(fetchedAt, now: Self.now, locale: chinese) + let again = PluginMarketplacePresentation.refreshedTime(fetchedAt, now: Self.now, locale: Self.locale) + + #expect(first == "5 minutes ago") + #expect(again == first) + #expect(other != first) + #expect(other.contains("5")) + } +} diff --git a/macos/Tests/LitheTests/PluginSettingsLayoutTests.swift b/macos/Tests/LitheTests/PluginSettingsLayoutTests.swift new file mode 100644 index 0000000000..fc73e565a6 --- /dev/null +++ b/macos/Tests/LitheTests/PluginSettingsLayoutTests.swift @@ -0,0 +1,101 @@ +import AppKit +import LitheCoreContracts +import LitheModuleAPI +import SwiftUI +import Testing +@testable import Lithe + +/// A file picker remains a usable native button when plugin details are narrow. +@Suite("Plugin settings path layout", .serialized) +@MainActor +struct PluginSettingsLayoutTests { + @Test(arguments: [480.0, 640.0, 900.0], [false, true]) + func pathPickerFitsAndStillWritesTheSelectedPath(width: Double, dark: Bool) async throws { + var pickedDirectory: Bool? + var saved: ToolingJSONValue? + let selected = URL(fileURLWithPath: "/fixture/custom tools/build-tool") + let declaration = DeclarativePluginSetting( + id: "toolPath", title: "Build executable", summary: "Select a file or leave empty for discovery.", + kind: .path(directory: false, placeholder: "Automatic"), defaultValue: .string(""), + scope: .global, effect: .reload + ) + let view = PluginSettingsSection( + settings: [.resolve(declaration, global: nil, project: nil)], hasProject: true, + handlers: PluginSettingsHandlers( + setValue: { id, value, layer in + #expect(id == "toolPath" && layer == .global) + saved = value + return nil + }, + restartLanguageServers: {}, runAction: { _ in }, + choosePath: { directory in pickedDirectory = directory; return selected } + ) + ) + .padding(14) + .frame(width: width, height: 180, alignment: .topLeading) + .environment(\.colorScheme, dark ? .dark : .light) + let host = NSHostingView(rootView: view) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: width, height: 180), + styleMask: [.borderless], backing: .buffered, defer: false + ) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: dark ? .darkAqua : .aqua) + window.contentView = host + defer { window.close() } + let application = NSApplication.shared + let attribute = NSAccessibility.Attribute(rawValue: "AXEnhancedUserInterface") + let previous = (application.accessibilityAttributeValue(attribute) as? Bool) ?? false + application.accessibilitySetValue(true, forAttribute: attribute) + defer { application.accessibilitySetValue(previous, forAttribute: attribute) } + host.layoutSubtreeIfNeeded() + let button = try #require(await chooseButton(in: host)) + let field = try #require(descendants(host).compactMap { $0 as? NSTextField }.first { $0.isEditable }) + let screenFrame = try #require(button.accessibilityFrame?()) + let buttonFrame = host.convert(window.convertFromScreen(screenFrame), from: nil) + let fieldFrame = field.convert(field.bounds, to: host) + let titleWidth = (String(localized: "Choose…") as NSString).size(withAttributes: [.font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize)]).width + #expect(buttonFrame.width >= titleWidth) + #expect(buttonFrame.minX >= fieldFrame.maxX) + #expect(host.bounds.contains(buttonFrame)) + #expect(host.bounds.contains(fieldFrame)) + #expect(fieldFrame.width > 0) + #expect(button.accessibilityPerformPress?() == true) + #expect(pickedDirectory == false) + #expect(saved == .string(selected.path)) + + if let directory = ProcessInfo.processInfo.environment["LITHE_PLUGIN_SETTINGS_LAYOUT_CAPTURES"] { + 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: [:])) + let output = URL(fileURLWithPath: directory, isDirectory: true) + try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true) + try data.write(to: output.appendingPathComponent("path-\(Int(width))-\(dark ? "dark" : "light").png")) + } + } + + private func descendants(_ view: NSView) -> [NSView] { + view.subviews.flatMap { [$0] + descendants($0) } + } + + private func chooseButton(in host: NSView) async -> AnyObject? { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(2)) + repeat { + host.layoutSubtreeIfNeeded() + if let button = accessibleElements(in: host).first(where: { + $0.accessibilityRole?() == .button + && ($0.accessibilityLabel?() ?? $0.accessibilityTitle?()) == String(localized: "Choose…") + }) { return button } + await Task.yield() + } while clock.now < deadline + return nil + } + + private func accessibleElements(in element: Any) -> [AnyObject] { + let accessible = element as AnyObject + let children = accessible.accessibilityChildren?() ?? [] + let descendants: [Any] = children.isEmpty ? ((element as? NSView)?.subviews ?? []) : children + return [accessible] + descendants.flatMap { accessibleElements(in: $0) } + } +} diff --git a/macos/Tests/LitheTests/PluginUIIconTests.swift b/macos/Tests/LitheTests/PluginUIIconTests.swift index 6e00fee200..9c1100ae66 100644 --- a/macos/Tests/LitheTests/PluginUIIconTests.swift +++ b/macos/Tests/LitheTests/PluginUIIconTests.swift @@ -210,7 +210,7 @@ struct PluginUIIconTests { } @Test(arguments: [PluginUIIcon.Mode.template, .original], [false, true]) - func registeredWindowHeaderRendersTheDeclaredThemeVariant(mode: PluginUIIcon.Mode, dark: Bool) async throws { + func registeredWindowHeaderOmitsIconsInEveryTheme(mode: PluginUIIcon.Mode, dark: Bool) async throws { let descriptor = try DeclarativePluginTestSupport.operations.validate(manifest: manifest(icon: [ "path": "light.png", "darkPath": "dark.png", "mode": mode.rawValue])) let declaration = try #require(descriptor.pluginManifest.modules.first) @@ -231,11 +231,17 @@ struct PluginUIIconTests { let bitmap = try snapshot(renderer.content(model).environmentObject(model).environmentObject(settings), dark: dark, size: CGSize(width: 300, height: 150)) let scale = CGFloat(bitmap.pixelsWide) / 300 - let center = try #require(bitmap.colorAt(x: Int(18.5 * scale), y: Int(15 * scale))?.usingColorSpace(.deviceRGB)) - if mode == .original { - if dark { #expect(center.blueComponent > center.redComponent + 0.6) } - else { #expect(center.redComponent > center.blueComponent + 0.6) } - } else { #expect(abs(center.redComponent - center.blueComponent) < 0.15) } + var brandPixels = 0 + for y in 0.. color.greenComponent + 0.4 && color.redComponent > color.blueComponent + 0.4) + || (color.blueComponent > color.greenComponent + 0.4 && color.blueComponent > color.redComponent + 0.4) { + brandPixels += 1 + } + } + } + #expect(brandPixels == 0, "A declared rail icon must never appear in the text-only panel header") } } @@ -301,7 +307,7 @@ struct PluginUIIconTests { captured = bitmap let scale = CGFloat(bitmap.pixelsWide) / content.bounds.width func color(row: Int) throws -> NSColor { - try #require(bitmap.colorAt(x: Int(18 * scale), y: Int((100 + CGFloat(row) * 24) * scale))?.usingColorSpace(.deviceRGB)) + try #require(bitmap.colorAt(x: Int((LitheTheme.Tree.horizontalInset + 10 + LitheTheme.Metrics.toolbarIconSize / 2) * scale), y: Int((100 + CGFloat(row) * LitheTheme.Tree.rowHeight) * scale))?.usingColorSpace(.deviceRGB)) } return try (color(row: 0), color(row: 1)) } @@ -393,11 +399,11 @@ struct PluginUIIconTests { @Test(arguments: [PluginUIIcon.Mode.template, .original], [false, true]) func hostIconsHonorDeclaredColorModeAndTheme(mode: PluginUIIcon.Mode, dark: Bool) throws { let asset = WorkbenchContributionIcon.Asset(.init(host: "run", mode: mode), resources: nil) - let hasHostAsset = LitheIcons.ideaImage(resourcePath: asset.hostPath!) != nil + let hasHostAsset = LitheIcons.ideaImage(resourcePath: asset.hostPath(for: .action)!) != nil if Bundle.main.bundleURL.pathExtension == "app" { #expect(hasHostAsset, "Packaged tests must resolve the real host asset") } let bitmap = try snapshot(WorkbenchContributionIcon(asset: asset, role: .action) .foregroundStyle(Color(red: 1, green: 0, blue: 1)), dark: dark) - let original = try snapshot(LitheIDEAIcon(resourcePath: asset.hostPath!, size: WorkbenchContributionIcon.Role.action.size, + let original = try snapshot(LitheIDEAIcon(resourcePath: asset.hostPath(for: .action)!, size: WorkbenchContributionIcon.Role.action.size, preservesOriginalColors: true), dark: dark) var paintedPixels = 0 var minimumTintMargin: CGFloat = 1, maximumOriginalDifference: CGFloat = 0 @@ -422,6 +428,41 @@ struct PluginUIIconTests { else { #expect(maximumOriginalDifference < 0.02) } } + @Test(arguments: [false, true]) + func toolbarOwnsTemplateTintWithoutCallerModifiers(dark: Bool) throws { + let resources = try PluginUIIconResources(light: image("svg", color: .red)) + let asset = WorkbenchContributionIcon.Asset(.init(path: "icon.svg"), resources: resources) + let actual = try snapshot(Button {} label: { + WorkbenchContributionIcon(asset: asset, role: .action) + }.litheToolbarIconButton(), dark: dark) + let expected = try snapshot(Rectangle().fill(LitheTheme.ActionToolbar.icon) + .frame(width: 16, height: 16), dark: dark) + let color = try #require(actual.colorAt(x: actual.pixelsWide / 2, y: actual.pixelsHigh / 2)?.usingColorSpace(.deviceRGB)) + let reference = try #require(expected.colorAt(x: expected.pixelsWide / 2, y: expected.pixelsHigh / 2)?.usingColorSpace(.deviceRGB)) + #expect(abs(color.redComponent - reference.redComponent) < 0.01) + #expect(abs(color.greenComponent - reference.greenComponent) < 0.01) + #expect(abs(color.blueComponent - reference.blueComponent) < 0.01) + } + + @Test func actionAssetsKeepNativeSixteenPointGeometry() throws { + for (railPath, actionPath) in WorkbenchContributionIcon.Asset.actionPaths { + let id = try #require(WorkbenchContributionIcon.Asset.hostPaths.first { $0.value == railPath }?.key) + let asset = WorkbenchContributionIcon.Asset(.init(host: id), resources: nil) + #expect(asset.hostPath(for: .toolWindow) == railPath) + #expect(asset.hostPath(for: .action) == actionPath) + for path in [actionPath, LitheIcons.darkIdeaAssetPath(for: actionPath)] { + let source = DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent("macos/Resources/IDEAIcons/" + path) + // A template-only stroke can share its source across both themes. + let resolved = FileManager.default.fileExists(atPath: source.path) ? source + : DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent("macos/Resources/IDEAIcons/" + actionPath) + let icon = try #require(NSImage(contentsOf: resolved)) + #expect(icon.size == NSSize(width: 16, height: 16)) + let svg = try String(contentsOf: resolved, encoding: .utf8) + #expect(!svg.contains("#253627"), "Template action assets must not turn a dark interior fill into a solid glyph") + } + } + } + @Test func hostCatalogAndMissingIconUseExistingAssets() throws { for (id, path) in WorkbenchContributionIcon.Asset.hostPaths { let source = DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent("macos/Resources/IDEAIcons/" + path) diff --git a/macos/Tests/LitheTests/PluginWebBridgeTests.swift b/macos/Tests/LitheTests/PluginWebBridgeTests.swift index 8979de7e6c..5829c883f0 100644 --- a/macos/Tests/LitheTests/PluginWebBridgeTests.swift +++ b/macos/Tests/LitheTests/PluginWebBridgeTests.swift @@ -98,6 +98,36 @@ struct PluginWebBridgeTests { #expect(harness.host.consents.allSatisfy { $0.pluginID == "dev.example.web" }) } + /// Without `instance` a call goes to the plugin's own backend, which the + /// install review covered, so no consent is asked; a plugin without a + /// backend is refused. + @Test func functionsWithoutAnInstanceGoToThePluginsBackendWithoutConsent() async throws { + let harness = try BridgeHarness() + defer { harness.cleanUp() } + #expect(try await harness.error("functions.list") == "plugin.web.forbidden") + + let backends = FakeBackends(declared: ["dev.example.web"]) + harness.host.backends = backends + var events: [(String, ToolingJSONValue)] = [] + harness.bridge.emit = { events.append(($0, $1)) } + + #expect(try await harness.ok("functions.list") == .object(["functions": .array([.string("query.run")])])) + let value = try await harness.ok("functions.invoke", [ + "function": .string("query.run"), "params": .object(["sql": .string("select 1")]), "call": .string("c1") + ]) + #expect(value == .object(["value": .object(["sql": .string("select 1")])])) + #expect(backends.invocations == ["dev.example.web/query.run"]) + #expect(events.map(\.0) == ["functions.progress"]) + #expect(events.first?.1 == .object([ + "call": .string("c1"), "function": .string("query.run"), "message": .string("Running"), + ])) + #expect(harness.host.consents.isEmpty) + #expect(harness.build.functionListings == 0) + + backends.declared = [] + #expect(try await harness.error("functions.invoke", ["function": .string("query.run")]) == "plugin.web.forbidden") + } + @Test func functionResultsProgressAndPluginFailuresReachThePage() async throws { let harness = try BridgeHarness() defer { harness.cleanUp() } @@ -392,7 +422,7 @@ struct PluginWebBridgeTests { @Test func cargoExampleSelectionReachesTheRealCorePlan() async throws { let manifest = try Data(contentsOf: DeclarativePluginTestSupport.repositoryRoot - .appendingPathComponent("shared/contracts/plugins/v1/fixtures/cargo-build.plugin.json")) + .appendingPathComponent("shared/contracts/plugins/v1/fixtures/build-static-file.plugin.json")) let descriptor = try DeclarativePluginTestSupport.operations.validate(manifest: manifest) let source = BuildSystemPluginSource( pluginID: descriptor.id.rawValue, manifest: descriptor.normalizedManifest, @@ -519,6 +549,54 @@ struct PluginWebBridgeTests { } } + @Test func fileDialogsNameThePluginAndReturnOnlyTheChosenPath() async throws { + let harness = try BridgeHarness() + defer { harness.cleanUp() } + let chosen = harness.root.appendingPathComponent("exports/rows.csv") + harness.host.chooseFile = { _ in chosen } + + #expect(try await harness.ok("dialog.saveFile", [ + "title": .string("Export rows"), "suggestedName": .string("rows.csv"), "extensions": .array([.string("csv")]) + ]) == .object(["path": .string(chosen.path)])) + #expect(harness.host.fileRequests == [PluginWebFileRequest( + kind: .save, pluginID: "dev.example.web", pluginName: "Fixture", title: "Export rows", + suggestedName: "rows.csv", allowedExtensions: ["csv"] + )]) + + harness.host.chooseFile = { _ in nil } + #expect(try await harness.ok("dialog.openFile") == .object(["path": .null])) + #expect(harness.host.fileRequests.last?.kind == .open) + + #expect(try await harness.error("dialog.saveFile", ["suggestedName": .string("../rows.csv")]) + == "plugin.web.invalidRequest") + #expect(try await harness.error("dialog.openFile", ["extensions": .array([.string(".csv")])]) + == "plugin.web.invalidRequest") + #expect(try await harness.error("dialog.openFile", ["suggestedName": .string("rows.csv")]) + == "plugin.web.invalidRequest") + #expect(harness.host.fileRequests.count == 2) + } + + @Test func oneFileDialogAtATimeAndAClosedPageLearnsNoChoice() async throws { + let harness = try BridgeHarness() + defer { harness.cleanUp() } + let shown = TestGate() + let answer = TestGate() + harness.host.chooseFile = { _ in + shown.open() + _ = await answer.waitUntilOpen(timeout: .seconds(10)) + return URL(fileURLWithPath: "/tmp/chosen.sql") + } + + let pending = Task { try await harness.error("dialog.openFile") } + defer { pending.cancel() } + #expect(await shown.waitUntilOpen(), "The dialog was never shown") + #expect(try await harness.error("dialog.saveFile") == "plugin.web.invalidRequest") + harness.bridge.close() + answer.open() + #expect(try await pending.value == "plugin.web.forbidden") + #expect(harness.host.fileRequests.count == 1) + } + @Test func stateIsKeptByTheHostWithinItsLimit() async throws { let harness = try BridgeHarness() defer { harness.cleanUp() } @@ -669,7 +747,7 @@ struct PluginWebBridgeTests { store: base.store, fileStorage: base.fileStorage, fileOperations: base.fileOperations, agentEditRestorer: base.agentEditRestorer, binaryFileViewerRegistry: base.binaryFileViewerRegistry, projectRuntimeService: base.projectRuntimeService, gitWatchContextProvider: base.gitWatchContextProvider, - githubService: base.githubService, secureStore: base.secureStore, databaseSecureStore: base.databaseSecureStore, + githubService: base.githubService, secureStore: base.secureStore, diagnosticsExportService: base.diagnosticsExportService, credentialResolver: base.credentialResolver, aiConfigurationSources: base.aiConfigurationSources, recentProjectsStore: base.recentProjectsStore, recentProjectBranchReader: base.recentProjectBranchReader, workspaceSessionStore: base.workspaceSessionStore, @@ -928,6 +1006,9 @@ private final class RetainedProjectWebHost: PluginWebBridgeHost { func pluginWebConfirmProcessStart(_ consent: PluginWebProcessConsent) async -> Bool { await model.pluginWebConfirmProcessStart(consent) } + func pluginWebChooseFile(_ request: PluginWebFileRequest) async -> URL? { + await model.pluginWebChooseFile(request) + } } /// Suspends the real manager's teardown so revocation is checked before shutdown ends. @@ -967,6 +1048,10 @@ private final class FakeHost: PluginWebBridgeHost { var outputShown = 0 var onAccess: (() -> Void)? var confirmProcessStart: (@MainActor () async -> Bool)? + var fileRequests: [PluginWebFileRequest] = [] + var chooseFile: (@MainActor (PluginWebFileRequest) async -> URL?)? + var backends: FakeBackends? + var pluginWebBackends: (any PluginBackendRequesting)? { backends } init(workspaceURL: URL, build: FakeBuildAccess) { pluginWebWorkspaceURL = workspaceURL @@ -984,6 +1069,30 @@ private final class FakeHost: PluginWebBridgeHost { if let confirmProcessStart { return await confirmProcessStart() } return allowsProcesses } + func pluginWebChooseFile(_ request: PluginWebFileRequest) async -> URL? { + fileRequests.append(request) + return await chooseFile?(request) + } +} + +/// Echoes a function's params after one progress report. +@MainActor +private final class FakeBackends: PluginBackendRequesting { + var declared: Set + var invocations: [String] = [] + + init(declared: Set) { self.declared = declared } + + func declaresBackend(_ pluginID: String) -> Bool { declared.contains(pluginID) } + func pluginFunctions(ofPlugin _: String) async throws -> [String] { ["query.run"] } + func invokePluginFunction( + _ functionID: String, params: ToolingJSONValue, ofPlugin pluginID: String, timeout _: Duration, + progress: @escaping @MainActor @Sendable (PluginFunctionProgress) -> Void + ) async throws -> ToolingJSONValue { + invocations.append("\(pluginID)/\(functionID)") + progress(PluginFunctionProgress(message: "Running", percentage: nil)) + return params + } } @MainActor @@ -1204,7 +1313,7 @@ struct PluginWebPageTests { let fetched = 'blocked'; try { await fetch('https://example.invalid/'); fetched = 'allowed'; } catch (error) {} let replaced = false; - try { window.lithe = {}; replaced = window.lithe.version !== 1; } catch (error) {} + try { window.lithe = {}; replaced = window.lithe.version !== 2; } catch (error) {} lithe.on('ui.themeChanged', (event) => lithe.request('ui.setState', { state: { themeChanged: event.theme } })); await lithe.request('ui.setState', { state: { version: lithe.version, @@ -1236,7 +1345,7 @@ struct PluginWebPageTests { let webView = try #require(page.webView) #expect(!webView.configuration.websiteDataStore.isPersistent) let report = try #require(await store.next(timeout: .seconds(10)), "The page never reported its state") - #expect(report["version"] == .integer(1)) + #expect(report["version"] == .integer(2)) #expect(report["pluginID"] == .string("dev.example.web")) #expect(report["theme"] == .string("light")) #expect(report["background"].map { $0 != .string("") } == true) diff --git a/macos/Tests/LitheTests/PluginWebCaptureTests.swift b/macos/Tests/LitheTests/PluginWebCaptureTests.swift new file mode 100644 index 0000000000..8f0aebd537 --- /dev/null +++ b/macos/Tests/LitheTests/PluginWebCaptureTests.swift @@ -0,0 +1,312 @@ +import AppKit +import Combine +import Foundation +import Testing +import WebKit +@testable import Lithe + +/// Answers the camera and microphone question the way a test says, at once or only +/// when the test lets it, and records who asked. +@MainActor +private final class RecordingConfirmer: WebMediaCaptureConfirming { + /// nil keeps the question open until `answerOpenQuestion`. + var answer: Bool? = true + private(set) var requests: [(devices: WebMediaDevices, requester: WebMediaCaptureRequester)] = [] + private var open: [(Bool) -> Void] = [] + let changes = PassthroughSubject() + + func confirmMediaCapture( + devices: WebMediaDevices, + requester: WebMediaCaptureRequester, + in window: NSWindow?, + completion: @escaping (Bool) -> Void + ) { + requests.append((devices, requester)) + if let answer { completion(answer) } else { open.append(completion) } + changes.send() + } + + func answerOpenQuestion(_ yes: Bool) { + open.removeFirst()(yes) + } +} + +/// A host with nothing to offer: these pages only ask for the camera and microphone. +@MainActor +private final class IdleHost: PluginWebBridgeHost { + var pluginWebWorkspaceURL: URL? + func pluginWebBuildSystems(activate: Bool) async -> (any PluginWebBuildAccess)? { nil } + func pluginWebShowBuildOutput() {} + func pluginWebOpenFile(_ url: URL, line: Int?, column: Int?) -> Bool { false } + func pluginWebConfirmProcessStart(_ consent: PluginWebProcessConsent) async -> Bool { false } + func pluginWebChooseFile(_ request: PluginWebFileRequest) async -> URL? { nil } +} + +/// A plugin's own page, loaded through the package scheme into a window of its own. +@MainActor +private final class PluginPageFixture { + static let pluginID = "dev.example.web" + let confirmer = RecordingConfirmer() + let page: MacPluginWebPage + let hostWindow: NSWindow + private let root: URL + + init(usage: [String: Any]? = MockCapture.usageDescriptions, attached: Bool = true) throws { + root = FileManager.default.temporaryDirectory.appendingPathComponent("lithe-plugin-capture-\(UUID().uuidString)") + let ui = root.appendingPathComponent("ui") + try FileManager.default.createDirectory(at: ui, withIntermediateDirectories: true) + try Data("ready".utf8) + .write(to: ui.appendingPathComponent("index.html")) + let window = PluginWebWindow( + pluginID: Self.pluginID, pluginName: "Fixture", windowID: Self.pluginID + ".window", + entry: "ui/index.html", packageURL: root, buildSystemIDs: [], isInspectable: false + ) + page = MacPluginWebPage( + window: window, + host: IdleHost(), + stateStore: MemoryPluginWebStateStore(), + theme: MacPluginWebTheme(colorTheme: .lithe, isDark: false, codeFontFamily: "Menlo", codeFontSize: 13), + mediaUsageDescriptions: { usage }, + mediaConfirmer: confirmer, + configure: MockCapture.configure + ) + hostWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), styleMask: [.titled], backing: .buffered, defer: false + ) + hostWindow.isReleasedWhenClosed = false + if attached { attach() } + } + + var webView: WKWebView { page.webView! } + + func attach() { + hostWindow.contentView = webView + hostWindow.orderFront(nil) + } + + /// The page leaves its window the way hiding the tool window takes it off screen. + func detach() { + hostWindow.contentView = nil + } + + func tearDown() { + hostWindow.contentView = nil + page.close() + hostWindow.close() + try? FileManager.default.removeItem(at: root) + } + + /// Waits for `isSatisfied`, re-checking after every change of the web view and the confirmer. + func wait(until isSatisfied: @escaping @MainActor () -> Bool) async -> Bool { + await waitForChanges(webViewChanges(webView) + [confirmer.changes.eraseToAnyPublisher()], until: isSatisfied) + } + + func waitForTitle(_ title: String) async -> Bool { + await wait { [webView] in webView.title == title } + } + + /// Starts the request without waiting for it, so a page that never answers cannot hang the test. + /// `watchingEnd` makes the page title `ended` once the device stops, which is how a test sees + /// a stop at once: WebKit lowers the capture state of the last device only about three seconds later. + func request(_ constraints: String, watchingEnd: Bool = false) async throws { + let onEnded = watchingEnd + ? "stream.getTracks()[0].addEventListener('ended', () => { document.title = 'ended'; });" + : "" + _ = try await webView.evaluateJavaScript(""" + navigator.mediaDevices.getUserMedia(\(constraints)).then( + stream => { window.stream = stream; \(onEnded) document.title = 'granted'; }, + error => { document.title = 'denied:' + error.name; }); + 0 + """) + } +} + +@MainActor +struct PluginMediaCaptureDecisionTests { + @Test func aRequestFromAnyOtherFrameIsRefusedWithoutAQuestion() throws { + let fixture = try PluginPageFixture() + defer { fixture.tearDown() } + var decisions: [WKPermissionDecision] = [] + + fixture.page.requestMediaCapture(devices: .camera, fromOwnPage: false) { decisions.append($0) } + + #expect(decisions == [.deny]) + #expect(fixture.confirmer.requests.isEmpty) + } + + @Test func aPageOnScreenAsksTheUserNamingThePluginAndItsID() throws { + let fixture = try PluginPageFixture() + defer { fixture.tearDown() } + var decisions: [WKPermissionDecision] = [] + + fixture.page.requestMediaCapture(devices: [.camera, .microphone], fromOwnPage: true) { decisions.append($0) } + + #expect(decisions == [.grant]) + #expect(fixture.confirmer.requests.count == 1) + #expect(fixture.confirmer.requests.first?.devices == [.camera, .microphone]) + #expect(fixture.confirmer.requests.first?.requester == .plugin(name: "Fixture", id: PluginPageFixture.pluginID)) + } + + @Test func aPageThatIsNotInAWindowIsRefusedWithoutAQuestion() throws { + let fixture = try PluginPageFixture(attached: false) + defer { fixture.tearDown() } + _ = fixture.webView + var decisions: [WKPermissionDecision] = [] + + fixture.page.requestMediaCapture(devices: .camera, fromOwnPage: true) { decisions.append($0) } + + #expect(decisions == [.deny]) + #expect(fixture.confirmer.requests.isEmpty) + } + + @Test func aClosedPageIsRefusedWithoutAQuestion() throws { + let fixture = try PluginPageFixture() + defer { fixture.tearDown() } + var decisions: [WKPermissionDecision] = [] + + fixture.page.close() + fixture.page.requestMediaCapture(devices: .camera, fromOwnPage: true) { decisions.append($0) } + + #expect(decisions == [.deny]) + #expect(fixture.confirmer.requests.isEmpty) + } + + @Test func withoutUsageDescriptionsAPluginIsRefusedWithoutAQuestion() throws { + let fixture = try PluginPageFixture(usage: [:]) + defer { fixture.tearDown() } + var decisions: [WKPermissionDecision] = [] + + fixture.page.requestMediaCapture(devices: .microphone, fromOwnPage: true) { decisions.append($0) } + + #expect(decisions == [.deny]) + #expect(fixture.confirmer.requests.isEmpty) + } + + @Test func aYesGivenAfterThePageWasHiddenDoesNotGrantAnything() throws { + let fixture = try PluginPageFixture() + defer { fixture.tearDown() } + fixture.confirmer.answer = nil + var decisions: [WKPermissionDecision] = [] + + fixture.page.requestMediaCapture(devices: .camera, fromOwnPage: true) { decisions.append($0) } + #expect(decisions.isEmpty) + fixture.detach() + fixture.confirmer.answerOpenQuestion(true) + + #expect(decisions == [.deny]) + } + + @Test func aYesGivenAfterThePageWasClosedDoesNotGrantAnything() throws { + let fixture = try PluginPageFixture() + defer { fixture.tearDown() } + fixture.confirmer.answer = nil + var decisions: [WKPermissionDecision] = [] + + fixture.page.requestMediaCapture(devices: .camera, fromOwnPage: true) { decisions.append($0) } + fixture.page.close() + fixture.confirmer.answerOpenQuestion(true) + + #expect(decisions == [.deny]) + } +} + +extension MockCaptureTests { + /// End to end with WebKit's mock cameras and microphones, through the real package scheme. + @MainActor + @Suite struct PluginPage { + private func makeFixture( + answer: Bool? = true, + usage: [String: Any]? = MockCapture.usageDescriptions, + attached: Bool = true + ) async throws -> PluginPageFixture { + let fixture = try PluginPageFixture(usage: usage, attached: attached) + fixture.confirmer.answer = answer + #expect(await fixture.waitForTitle("ready"), "Title was \(fixture.webView.title ?? "nil")") + return fixture + } + + @Test(.enabled(if: MockCapture.isAvailable)) + func aPluginPageSeesTheCameraOnlyAfterTheUserSaysYes() async throws { + let fixture = try await makeFixture() + defer { fixture.tearDown() } + + try await fixture.request("{ video: true }") + #expect(await fixture.waitForTitle("granted"), "Title was \(fixture.webView.title ?? "nil")") + #expect(fixture.confirmer.requests.count == 1) + #expect(fixture.confirmer.requests.first?.devices == .camera) + #expect(fixture.confirmer.requests.first?.requester == .plugin(name: "Fixture", id: PluginPageFixture.pluginID)) + #expect(await fixture.wait { fixture.webView.cameraCaptureState == .active }) + } + + @Test(.enabled(if: MockCapture.isAvailable)) + func aPluginPageThatIsRefusedIsToldNotAllowed() async throws { + let fixture = try await makeFixture(answer: false) + defer { fixture.tearDown() } + + try await fixture.request("{ video: true, audio: true }") + #expect(await fixture.waitForTitle("denied:NotAllowedError"), "Title was \(fixture.webView.title ?? "nil")") + #expect(fixture.confirmer.requests.count == 1) + #expect(fixture.webView.cameraCaptureState == .none) + #expect(fixture.webView.microphoneCaptureState == .none) + } + + @Test(.enabled(if: MockCapture.isAvailable)) + func aPluginPageIsRefusedWithoutAQuestionWhenTheAppCannotOfferTheDevice() async throws { + let fixture = try await makeFixture(usage: [:]) + defer { fixture.tearDown() } + + try await fixture.request("{ audio: true }") + #expect(await fixture.waitForTitle("denied:NotAllowedError"), "Title was \(fixture.webView.title ?? "nil")") + #expect(fixture.confirmer.requests.isEmpty) + } + + @Test(.enabled(if: MockCapture.isAvailable)) + func aDeviceThatStartsAfterTheUserSaidYesWhileThePageWasHiddenIsStopped() async throws { + let fixture = try await makeFixture(answer: nil) + defer { fixture.tearDown() } + + try await fixture.request("{ video: true }", watchingEnd: true) + #expect(await fixture.wait { fixture.confirmer.requests.count == 1 }) + // WebKit starts the device only once the answer reaches it, and the page can leave its + // window before that: hiding then finds no capture to stop. + fixture.confirmer.answerOpenQuestion(true) + fixture.detach() + + #expect(await fixture.waitForTitle("ended"), "Title was \(fixture.webView.title ?? "nil")") + } + + @Test(.enabled(if: MockCapture.isAvailable)) + func hidingThePluginPageEndsTheCaptureInProgress() async throws { + let fixture = try await makeFixture() + defer { fixture.tearDown() } + + try await fixture.request("{ video: true }") + #expect(await fixture.waitForTitle("granted"), "Title was \(fixture.webView.title ?? "nil")") + #expect(await fixture.wait { fixture.webView.cameraCaptureState == .active }) + // WebKit lowers the state of the last device only about three seconds after it stops, + // so the page's own track is what shows the stop at once. + _ = try await fixture.webView.evaluateJavaScript( + "window.stream.getVideoTracks()[0].addEventListener('ended', () => { document.title = 'ended'; }); 0" + ) + + fixture.detach() + + #expect(await fixture.waitForTitle("ended"), "Title was \(fixture.webView.title ?? "nil")") + } + + @Test(.enabled(if: MockCapture.isAvailable)) + func aYesGivenAfterThePluginPageWasHiddenReachesThePageAsNotAllowed() async throws { + let fixture = try await makeFixture(answer: nil) + defer { fixture.tearDown() } + + try await fixture.request("{ video: true }") + #expect(await fixture.wait { fixture.confirmer.requests.count == 1 }) + #expect(fixture.webView.title == "ready") + fixture.detach() + fixture.confirmer.answerOpenQuestion(true) + + #expect(await fixture.waitForTitle("denied:NotAllowedError"), "Title was \(fixture.webView.title ?? "nil")") + #expect(fixture.webView.cameraCaptureState == .none) + } + } +} diff --git a/macos/Tests/LitheTests/ProcessLifecycleFixtureTests.swift b/macos/Tests/LitheTests/ProcessLifecycleFixtureTests.swift index aba59d5cb3..7d5acf1002 100644 --- a/macos/Tests/LitheTests/ProcessLifecycleFixtureTests.swift +++ b/macos/Tests/LitheTests/ProcessLifecycleFixtureTests.swift @@ -20,6 +20,30 @@ struct ProcessLifecycleFixtureTests { } } + @Test func exitPlanFixturesDecodeIntoSwiftTypes() throws { + let core = RustCoreBridge() + for testCase in try cases("exit-plan-v1.json") { + let result: ProcessLifecyclePlan = try core.executeResult( + command: "processLifecycle.resolve", + payload: testCase.request + ).get() + #expect(result == (try testCase.expected.decode(ProcessLifecyclePlan.self)), "\(testCase.name)") + } + } + + @Test func runServicePolicySurvivesSettingsRoundTripAndOlderSettings() throws { + var settings = ProcessTerminationSettings() + settings.runServicesOnExit = .terminate + let decoded = try JSONDecoder().decode( + ProcessTerminationSettings.self, + from: JSONEncoder().encode(settings) + ) + #expect(decoded.runServicesOnExit == .terminate) + let older = try JSONDecoder().decode(ProcessTerminationSettings.self, from: Data(#"{"startupSweep":"terminate"}"#.utf8)) + #expect(older.runServicesOnExit == .ask) + #expect(older.startupSweep == .terminate) + } + @Test func settleFixturesDecodeIntoSwiftTypes() throws { let core = RustCoreBridge() for testCase in try cases("settle-v1.json") { @@ -140,6 +164,93 @@ struct ProcessExitCoordinatorTests { #expect(approval.gracefulStopMilliseconds == 5_000) } + @Test func quitListsRunServicesOnceBeforeAnyOtherQuestion() async { + let presenter = ScriptedPresenter(terminal: .terminate, agentTask: .keep, resources: nil) + let coordinator = ProcessExitCoordinator(core: RustProcessLifecycleDecider(core: RustCoreBridge()), presenter: presenter) + let busy = terminal(programs: [TerminalRunningProgram(processID: 10, commandLine: "npm run dev")]) + + let (outcome, _) = await coordinator.confirmQuit( + settings: ProcessTerminationSettings(), + runServices: [service("run:api"), service("maven:install")], + terminals: [busy], + agentTasks: [], + resources: [] + ) + + #expect(presenter.asked == ["runServices:quit:run:api,maven:install", "terminal:\(busy.sessionID)"]) + guard case .proceed = outcome else { + Issue.record("Expected to proceed, got \(outcome)") + return + } + } + + @Test func cancellingTheRunServiceQuestionKeepsEverything() async { + let presenter = ScriptedPresenter(terminal: .terminate, agentTask: .keep, resources: nil, runServices: false) + let coordinator = ProcessExitCoordinator(core: RustProcessLifecycleDecider(core: RustCoreBridge()), presenter: presenter) + + let (outcome, _) = await coordinator.confirmQuit( + settings: ProcessTerminationSettings(), + runServices: [service("run:api")], + terminals: [terminal(programs: [TerminalRunningProgram(processID: 10, commandLine: "top")])], + agentTasks: [], + resources: [] + ) + + #expect(outcome == .cancelled) + #expect(presenter.asked == ["runServices:quit:run:api"]) + } + + @Test func runServicesStopWithoutAQuestionWhenTheSettingSaysSo() async { + let presenter = ScriptedPresenter(terminal: .terminate, agentTask: .keep, resources: nil, runServices: false) + let coordinator = ProcessExitCoordinator(core: RustProcessLifecycleDecider(core: RustCoreBridge()), presenter: presenter) + var settings = ProcessTerminationSettings() + settings.runServicesOnExit = .terminate + + let closes = await coordinator.confirmCloseProject(settings: settings, runServices: [service("run:api")]) + let (outcome, _) = await coordinator.confirmQuit( + settings: settings, + runServices: [service("run:api")], + terminals: [], + agentTasks: [], + resources: [] + ) + + #expect(closes) + #expect(outcome == .proceed(ProcessExitApproval())) + #expect(presenter.asked.isEmpty) + } + + @Test func closingAProjectAsksAboutItsRunServicesAndCanBeCancelled() async { + let agreeing = ScriptedPresenter(terminal: .cancel, agentTask: .cancel, resources: nil) + let refusing = ScriptedPresenter(terminal: .cancel, agentTask: .cancel, resources: nil, runServices: false) + let decider = RustProcessLifecycleDecider(core: RustCoreBridge()) + + let closes = await ProcessExitCoordinator(core: decider, presenter: agreeing) + .confirmCloseProject(settings: ProcessTerminationSettings(), runServices: [service("run:api")]) + let cancelled = await ProcessExitCoordinator(core: decider, presenter: refusing) + .confirmCloseProject(settings: ProcessTerminationSettings(), runServices: [service("run:api")]) + let idle = await ProcessExitCoordinator(core: decider, presenter: refusing) + .confirmCloseProject(settings: ProcessTerminationSettings(), runServices: []) + + #expect(closes) + #expect(agreeing.asked == ["runServices:closeProject:run:api"]) + #expect(!cancelled) + #expect(idle) + } + + @Test func runServicesAreStillAskedAboutWhenTheRustRuleFails() async { + let presenter = ScriptedPresenter(terminal: .cancel, agentTask: .cancel, resources: nil) + let coordinator = ProcessExitCoordinator(core: FailingLifecycleCore(), presenter: presenter) + + let closes = await coordinator.confirmCloseProject( + settings: ProcessTerminationSettings(), + runServices: [service("run:api")] + ) + + #expect(closes) + #expect(presenter.asked == ["runServices:closeProject:run:api"]) + } + @Test func cancellingAnAgentTaskStopsAskingAndKeepsEverything() async { let presenter = ScriptedPresenter(terminal: .terminate, agentTask: .cancel, resources: nil) let coordinator = ProcessExitCoordinator(core: RustProcessLifecycleDecider(core: RustCoreBridge()), presenter: presenter) @@ -196,6 +307,10 @@ struct ProcessExitCoordinatorTests { ) } + private func service(_ id: String) -> RunServiceExitCandidate { + RunServiceExitCandidate(id: id, title: id, projectName: "demo") + } + private func task(_ id: String) -> AgentTaskCandidate { AgentTaskCandidate( id: id, @@ -245,12 +360,24 @@ private final class ScriptedPresenter: ProcessExitConfirmationPresenting { private let terminal: TerminalExitAnswer private let agentTask: AgentTaskExitAnswer private let resources: BackgroundResourceAnswer? + private let runServices: Bool private(set) var asked: [String] = [] - init(terminal: TerminalExitAnswer, agentTask: AgentTaskExitAnswer, resources: BackgroundResourceAnswer?) { + init( + terminal: TerminalExitAnswer, + agentTask: AgentTaskExitAnswer, + resources: BackgroundResourceAnswer?, + runServices: Bool = true + ) { self.terminal = terminal self.agentTask = agentTask self.resources = resources + self.runServices = runServices + } + + func confirmStopRunServices(_ services: [RunServiceExitCandidate], purpose: RunServiceStopPurpose) async -> Bool { + asked.append("runServices:\(purpose == .quit ? "quit" : "closeProject"):" + services.map(\.id).joined(separator: ",")) + return runServices } func confirmTerminalOnQuit(_ terminal: TerminalExitCandidate, position: Int, count: Int) async -> TerminalExitAnswer { @@ -281,3 +408,24 @@ private final class ScriptedPresenter: ProcessExitConfirmationPresenting { return self.resources } } + +/// A Rust Core that cannot answer, as when the bridge fails to load. +private struct FailingLifecycleCore: ProcessLifecycleClassifying { + struct Unavailable: Error {} + + func resolve( + context: ProcessLifecycleContext, + settings: ProcessTerminationSettings, + records: [ProcessLifecycleRecord] + ) throws -> ProcessLifecyclePlan { + throw Unavailable() + } + + func inventory(_ request: ProcessInventoryRequest) throws -> ProcessInventory { + throw Unavailable() + } + + func settle(_ request: ProcessSettlementRequest) throws -> ProcessSettlement { + throw Unavailable() + } +} diff --git a/macos/Tests/LitheTests/ProjectGenerationTests.swift b/macos/Tests/LitheTests/ProjectGenerationTests.swift new file mode 100644 index 0000000000..aa0fcd935d --- /dev/null +++ b/macos/Tests/LitheTests/ProjectGenerationTests.swift @@ -0,0 +1,601 @@ +import Foundation +import LitheCoreContracts +import LitheModuleAPI +import Testing +@testable import Lithe + +@Suite("New project generation") +@MainActor +struct ProjectGenerationTests { + private static let javaSupportDirectory = "JavaSupport" + /// Generator-only copy of Java Support's entries, so window tests can + /// keep the template plugin and the language provider apart the way a + /// community template plugin would be. + private static let templatesPluginID = PluginID("dev.lithe.test.java-templates") + private static let jdk = JavaRuntimeCandidate(homePath: "/opt/jdk-21", version: "21.0.4", vendor: "Temurin") + + // MARK: Plugin declaration + + @Test + func javaSupportDeclaresGeneratorsAndItsOwnModule() throws { + let descriptor = try DeclarativePluginDescriptor( + normalizedManifest: DeclarativePluginTestSupport.officialDescriptor(Self.javaSupportDirectory).normalizedManifest, + enabledByDefault: true + ) + #expect(descriptor.projectGenerators.map(\.id) == ["java", "maven-archetype", "spring-boot"]) + let module = try #require(descriptor.pluginManifest.modules.first { + $0.manifest.id == .projectGeneratorsExtension(descriptor.id.rawValue) + }) + // The window runs without a workspace, and a bundled package is on + // from the first launch. + #expect(module.manifest.scope == .application) + #expect(module.manifest.defaultState == .enabled) + let capabilities = PluginCapability.capabilities(of: descriptor.pluginManifest, descriptor: descriptor) + #expect(capabilities.filter { $0.kind == .projectGenerator }.map(\.title) == ["Java", "Maven Archetype", "Spring Boot"]) + } + + /// Startup refuses a plugin that declares a module the host does not + /// register, so every Java Support module, the project-generators one + /// included, needs a factory. + @Test + func everyJavaSupportModuleIsRegisteredAtStartup() throws { + let directory = Self.javaSupportDirectory + let descriptor = try DeclarativePluginTestSupport.officialDescriptor(directory) + let installed = InstalledPluginPackage( + descriptor: descriptor, + installation: PluginInstallationRecord( + pluginID: descriptor.id, + activeVersion: descriptor.pluginManifest.version, + origin: .localDevelopment + ), + packageURL: DeclarativePluginTestSupport.officialPackageURL(directory) + ) + let registered = Set(MacServiceContainer.declarativePluginFactories( + [installed], + executionHost: MacLanguageExecutionHost(processRegistry: ManagedProcessRegistry()), + planner: DeclarativePluginTestSupport.operations + ).map(\.manifest.id)) + #expect(registered.contains(.projectGeneratorsExtension(descriptor.id.rawValue))) + #expect(Set(descriptor.pluginManifest.modules.map(\.manifest.id)).isSubset(of: registered)) + } + + // MARK: Rust Core + + @Test + func catalogListsProvidersOfEachRequirement() throws { + let split = try Self.splitJavaSupport() + let catalog = try Self.planner.catalog([ + split.templates, + split.language.with(enabled: false), + ]) + #expect(catalog.generators.map(\.generator.id) == ["java", "maven-archetype", "spring-boot"]) + let java = try #require(catalog.generators.first) + #expect(java.generator.section == .project) + #expect(java.requirements.first?.subject == .language("java")) + #expect(java.requirements.first?.providers == [ + ProjectRequirementProvider(pluginID: "dev.lithe.plugin.java-support", pluginName: "Java Support", enabled: false) + ]) + // No installed plugin declares the Maven build system. + #expect(java.requirements.dropFirst().allSatisfy { $0.providers.isEmpty }) + } + + @Test + func bundledJavaSupportProvidesTheLanguageItsTemplatesNeed() throws { + let catalog = try Self.planner.catalog([try Self.source(Self.javaSupportDirectory, enabled: true)]) + let java = try #require(catalog.generators.first) + #expect(java.pluginEnabled) + #expect(java.requirements.first?.providers == [ + ProjectRequirementProvider(pluginID: "dev.lithe.plugin.java-support", pluginName: "Java Support", enabled: true) + ]) + } + + @Test + func formDerivesDefaultsAndHidesFieldsOfOtherBuildSystems() throws { + let target = try Self.target("spring-boot") + var input = ProjectGeneratorFormInput(projectName: "Order-Service") + input.runtimes["jdk"] = ProjectRuntimeSelection(home: Self.jdk.homePath, version: Self.jdk.version) + let form = try Self.planner.form(target, input: input) + #expect(form.field("artifactId")?.value == .string("Order-Service")) + #expect(form.field("packageName")?.value == .string("com.example.orderservice")) + #expect(form.valid) + + let java = try Self.planner.form( + try Self.target("java"), + input: ProjectGeneratorFormInput(projectName: "demo", values: ["buildSystem": .string("none")]) + ) + #expect(java.field("groupId")?.visible == false) + #expect(java.field("jdk")?.problem == .required) + } + + // MARK: Creating projects + + @Test + func createsAMavenProjectFromTemplates() async throws { + let directory = try TemporaryDirectory() + defer { directory.remove() } + let recorder = JavaHomeRecorder() + let service = Self.service(recorder: recorder) + var input = ProjectGeneratorFormInput(projectName: "demo") + input.runtimes["jdk"] = ProjectRuntimeSelection(home: Self.jdk.homePath, version: Self.jdk.version) + + let project = try await service.create( + try Self.target("java"), input: input, in: directory.url, createGitRepository: false + ) + + let root = directory.url.appendingPathComponent("demo") + #expect(project.url.path == root.path) + let pom = try String(contentsOf: root.appendingPathComponent("pom.xml"), encoding: .utf8) + #expect(pom.contains("21")) + #expect(FileManager.default.fileExists(atPath: root.appendingPathComponent(".gitignore").path)) + #expect(project.openFiles.map(\.path) == [root.appendingPathComponent("src/main/java/org/example/Main.java").path]) + #expect(await recorder.records == [Record(root: root.path, home: Self.jdk.homePath)]) + #expect(try Self.stagingEntries(in: directory.url).isEmpty) + } + + @Test + func collectsTheDirectoryAScaffoldingToolCreated() async throws { + let directory = try TemporaryDirectory() + defer { directory.remove() } + let runner = ScaffoldingRunner(creates: "demo/pom.xml") + let service = Self.service(runner: runner) + var input = ProjectGeneratorFormInput(projectName: "demo") + input.runtimes["jdk"] = ProjectRuntimeSelection(home: Self.jdk.homePath, version: Self.jdk.version) + + _ = try await service.create( + try Self.target("maven-archetype"), input: input, in: directory.url, createGitRepository: false + ) + + let request = try #require(runner.requests.first) + #expect(request.executableURL.path == "/usr/local/bin/mvn") + #expect(request.environment["JAVA_HOME"] == Self.jdk.homePath) + #expect(request.environment["PATH"]?.hasPrefix(Self.jdk.homePath + "/bin:") == true) + #expect(request.arguments.contains("-DartifactId=demo")) + let root = directory.url.appendingPathComponent("demo") + #expect(FileManager.default.fileExists(atPath: root.appendingPathComponent("pom.xml").path)) + #expect(!FileManager.default.fileExists(atPath: root.appendingPathComponent("demo").path)) + } + + @Test + func aFailedStepLeavesNothingBehind() async throws { + let directory = try TemporaryDirectory() + defer { directory.remove() } + let service = Self.service(runner: ScaffoldingRunner(creates: "demo/pom.xml", exitStatus: 1)) + var input = ProjectGeneratorFormInput(projectName: "demo") + input.runtimes["jdk"] = ProjectRuntimeSelection(home: Self.jdk.homePath, version: Self.jdk.version) + + await #expect(throws: ProjectGenerationError.self) { + try await service.create( + try Self.target("maven-archetype"), input: input, in: directory.url, createGitRepository: false + ) + } + #expect(try FileManager.default.contentsOfDirectory(atPath: directory.url.path).isEmpty) + } + + @Test + func refusesAnExistingDestination() async throws { + let directory = try TemporaryDirectory() + defer { directory.remove() } + let existing = directory.url.appendingPathComponent("demo") + try FileManager.default.createDirectory(at: existing, withIntermediateDirectories: false) + try Data("keep".utf8).write(to: existing.appendingPathComponent("notes.txt")) + + await #expect(throws: ProjectGenerationError.self) { + try await Self.service().createEmptyProject(named: "demo", in: directory.url, createGitRepository: false) + } + #expect(try String(contentsOf: existing.appendingPathComponent("notes.txt"), encoding: .utf8) == "keep") + #expect(try Self.stagingEntries(in: directory.url).isEmpty) + } + + @Test + func extractsADownloadedArchive() async throws { + let directory = try TemporaryDirectory() + defer { directory.remove() } + let archive = DeclarativePluginTestSupport.repositoryRoot + .appendingPathComponent("macos/Tests/LitheTests/Fixtures/ProjectGeneration/starter.zip") + let platform = DownloadStubPlatform(base: Self.platform(), archive: archive) + let service = ProjectGenerationService( + planner: Self.planner, platform: platform, + initializeGitRepository: { _ in nil }, recordJavaHome: { _, _ in } + ) + var input = ProjectGeneratorFormInput(projectName: "demo") + input.runtimes["jdk"] = ProjectRuntimeSelection(home: Self.jdk.homePath, version: Self.jdk.version) + + let project = try await service.create( + try Self.target("spring-boot"), input: input, in: directory.url, createGitRepository: false + ) + + #expect(platform.requestedURL?.host == "start.spring.io") + let mvnw = project.url.appendingPathComponent("mvnw").path + #expect(FileManager.default.isExecutableFile(atPath: mvnw)) + #expect(project.openFiles.map(\.path) == [project.url.appendingPathComponent("pom.xml").path]) + // The downloaded copy is removed after extraction. + #expect(!FileManager.default.fileExists(atPath: platform.downloadedCopy?.path ?? "/nonexistent")) + } + + // MARK: Downloading archives + + /// An oversized body is cut off while it streams, whether or not the + /// server declares its length, and a small one is kept whole. + @Test + func downloadsStopAtTheBudgetWhileStreaming() async throws { + let platform = Self.downloadPlatform(maximumBytes: 1024) + let small = try await platform.download(GeneratorServerURLProtocol.url(status: 200, bytes: 512)) + defer { try? FileManager.default.removeItem(at: small) } + #expect(try Data(contentsOf: small).count == 512) + for declaresLength in [false, true] { + let url = GeneratorServerURLProtocol.url(status: 200, bytes: 64 * 1024, declaresLength: declaresLength) + await #expect(throws: ProjectGenerationError.self) { _ = try await platform.download(url) } + } + } + + /// A refused request shows a short JSON explanation, but a large error + /// body is neither read whole nor shown. + @Test + func refusedDownloadsReadABoundedExplanation() async throws { + let platform = Self.downloadPlatform(maximumBytes: 1024) + do { + _ = try await platform.download(GeneratorServerURLProtocol.url(status: 400, bytes: 0, json: true)) + Issue.record("A refused request must fail") + } catch let error as ProjectGenerationError { + #expect(error.message.contains("Invalid Java version")) + } + do { + let huge = MacProjectGenerationPlatform.maximumErrorBodyBytes * 4 + _ = try await platform.download(GeneratorServerURLProtocol.url(status: 500, bytes: huge)) + Issue.record("A refused request must fail") + } catch let error as ProjectGenerationError { + #expect(error.message.hasSuffix("status 500. ")) + } + } + + // MARK: Window state + + @Test + func offersToEnableDisabledProvidersAndFindsMissingOnes() async throws { + let plugins = PluginStates(enabled: [Self.templatesPluginID]) + let feature = NewProjectFeatureModel(environment: try Self.environment(plugins: plugins)) + await feature.load() + #expect(feature.selectedEntry?.generator.id == "java") + #expect(feature.requirementNotices.map(\.action) == [ + .enable(PluginID("dev.lithe.plugin.java-support"), name: "Java Support"), + .findInMarketplace("maven"), + ]) + // A requirement never blocks creation. + #expect(feature.canCreate) + + await feature.enablePlugin(PluginID("dev.lithe.plugin.java-support")) + #expect(feature.requirementNotices.map(\.action) == [.findInMarketplace("maven")]) + } + + @Test + func aDisabledTemplatePluginMustBeEnabledBeforeCreating() async throws { + let feature = NewProjectFeatureModel(environment: try Self.environment(plugins: PluginStates(enabled: []))) + await feature.load() + #expect(feature.selectedEntry?.pluginEnabled == false) + #expect(!feature.canCreate) + + await feature.enablePlugin(Self.templatesPluginID) + #expect(feature.selectedEntry?.pluginEnabled == true) + #expect(feature.canCreate) + } + + @Test + func aDisabledJavaSupportIsOfferedOnceForItsOwnTemplates() async throws { + let javaSupport = try Self.source(Self.javaSupportDirectory, enabled: true) + let environment = try Self.environment(plugins: PluginStates(enabled: []), sources: [javaSupport]) + let feature = NewProjectFeatureModel(environment: environment) + await feature.load() + #expect(feature.selectedEntry?.pluginEnabled == false) + // The Java requirement is Java Support itself, so only the Maven + // build system is left to find. + #expect(feature.requirementNotices.map(\.action) == [.findInMarketplace("maven")]) + } + + @Test + func derivedFieldsFollowTheProjectNameUntilEdited() async throws { + let plugins = PluginStates(enabled: [Self.templatesPluginID]) + let feature = NewProjectFeatureModel(environment: try Self.environment(plugins: plugins)) + await feature.load() + let spring = try #require(feature.entries.first { $0.generator.id == "spring-boot" }) + feature.selection = .generator(spring.id) + let artifact = try #require(spring.generator.fields.first { $0.id == "artifactId" }) + + feature.projectName = "billing" + #expect(feature.value(of: artifact) == .string("billing")) + feature.setValue(.string("billing-api"), for: artifact) + feature.projectName = "payments" + #expect(feature.value(of: artifact) == .string("billing-api")) + } + + // MARK: Helpers + + private static var planner: RustProjectGeneratorOperations { + RustProjectGeneratorOperations(core: RustCoreBridge()) + } + + private static func source(_ directory: String, enabled: Bool) throws -> ProjectGeneratorPluginSource { + let descriptor = try DeclarativePluginTestSupport.officialDescriptor(directory) + return ProjectGeneratorPluginSource( + pluginID: descriptor.id, + manifest: descriptor.normalizedManifest, + packageURL: DeclarativePluginTestSupport.officialPackageURL(directory), + isEnabled: enabled + ) + } + + /// Java Support split into a language-only plugin with its real ID and a + /// generator-only plugin, both enabled. + private static func splitJavaSupport() throws -> (language: ProjectGeneratorPluginSource, templates: ProjectGeneratorPluginSource) { + let javaSupport = try source(javaSupportDirectory, enabled: true) + guard let manifest = try JSONSerialization.jsonObject(with: javaSupport.manifest) as? [String: Any], + var languageRequires = manifest["requires"] as? [String: Any] else { + throw CocoaError(.coderReadCorrupt) + } + var language = manifest + language["projectGenerators"] = nil + language["permissions"] = ["languageServer.launch"] + languageRequires["projectGenerators"] = nil + language["requires"] = languageRequires + var templates = manifest + for key in ["languages", "languageServers", "settings"] { templates[key] = nil } + templates["id"] = templatesPluginID.rawValue + templates["displayName"] = "Java Templates" + templates["permissions"] = ["project.generate"] + templates["requires"] = ["projectGenerators": 1] + templates["activationEvents"] = [String]() + func plugin(_ object: [String: Any], id: PluginID) throws -> ProjectGeneratorPluginSource { + ProjectGeneratorPluginSource( + pluginID: id, + manifest: try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]), + packageURL: javaSupport.packageURL, + isEnabled: true + ) + } + return (try plugin(language, id: javaSupport.pluginID), try plugin(templates, id: templatesPluginID)) + } + + private static func target(_ generatorID: String) throws -> ProjectGeneratorTarget { + let source = try source(javaSupportDirectory, enabled: true) + return ProjectGeneratorTarget( + pluginID: source.pluginID, generatorID: generatorID, manifest: source.manifest, packageURL: source.packageURL + ) + } + + private static func downloadPlatform(maximumBytes: Int64) -> MacProjectGenerationPlatform { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [GeneratorServerURLProtocol.self] + configuration.timeoutIntervalForRequest = 10 + return MacProjectGenerationPlatform( + environment: { $0 }, + executable: { _ in nil }, + runner: ScaffoldingRunner(creates: nil), + session: URLSession(configuration: configuration), + maximumDownloadBytes: maximumBytes + ) + } + + private static func platform(runner: any BuildCommandRunning = ScaffoldingRunner(creates: nil)) -> MacProjectGenerationPlatform { + MacProjectGenerationPlatform( + environment: { $0.merging(["PATH": "/usr/bin:/bin"]) { override, _ in override } }, + executable: { $0 == "mvn" ? URL(fileURLWithPath: "/usr/local/bin/mvn") : nil }, + runner: runner + ) + } + + private static func service( + runner: any BuildCommandRunning = ScaffoldingRunner(creates: nil), + recorder: JavaHomeRecorder = JavaHomeRecorder() + ) -> ProjectGenerationService { + ProjectGenerationService( + planner: planner, + platform: platform(runner: runner), + initializeGitRepository: { _ in nil }, + recordJavaHome: { root, home in await recorder.record(root: root, home: home) } + ) + } + + private static func environment( + plugins: PluginStates, + sources: [ProjectGeneratorPluginSource]? = nil + ) throws -> NewProjectEnvironment { + let split = try splitJavaSupport() + let installed = sources ?? [split.templates, split.language] + return NewProjectEnvironment( + sources: { + installed.map { + ProjectGeneratorPluginSource( + pluginID: $0.pluginID, manifest: $0.manifest, packageURL: $0.packageURL, + isEnabled: plugins.enabled.contains($0.pluginID) + ) + } + }, + iconResources: [:], + javaRuntimes: { [jdk] }, + service: service(), + setPluginEnabled: { _ = plugins.enabled.insert($0) }, + openMarketplace: { _ in }, + openProject: { _ in }, + store: InMemoryStore() + ) + } + + private static func stagingEntries(in directory: URL) throws -> [String] { + try FileManager.default.contentsOfDirectory(atPath: directory.path).filter { $0.contains(".lithe-creating-") } + } +} + +// MARK: - Test doubles + +/// Serves `https://generator.invalid//` as that many zero +/// bytes, optionally with a Content-Length header, or a Spring Initializr +/// style JSON error, so downloads are exercised without a network. +private final class GeneratorServerURLProtocol: URLProtocol { + static func url(status: Int, bytes: Int, declaresLength: Bool = false, json: Bool = false) -> URL { + let query = json ? "?json" : declaresLength ? "?length" : "" + return URL(string: "https://generator.invalid/\(status)/\(bytes)\(query)")! + } + + override class func canInit(with request: URLRequest) -> Bool { + request.url?.host == "generator.invalid" + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + let parts = request.url?.pathComponents.dropFirst().compactMap { Int($0) } ?? [] + guard let url = request.url, parts.count == 2 else { + client?.urlProtocol(self, didFailWithError: URLError(.badURL)) + return + } + let body = url.query == "json" + ? Data(#"{"message":"Invalid Java version"}"#.utf8) + : Data(count: parts[1]) + var headers: [String: String] = [:] + if url.query == "length" { headers["Content-Length"] = String(body.count) } + let response = HTTPURLResponse(url: url, statusCode: parts[0], httpVersion: "HTTP/1.1", headerFields: headers)! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + // Several chunks, like a real body. + var offset = 0 + while offset < body.count { + let end = min(offset + 4096, body.count) + client?.urlProtocol(self, didLoad: body.subdata(in: offset.. Self { + Self(pluginID: pluginID, manifest: manifest, packageURL: packageURL, isEnabled: enabled) + } +} + +private struct Record: Equatable, Sendable { + let root: String + let home: String +} + +private actor JavaHomeRecorder { + private(set) var records: [Record] = [] + + func record(root: URL, home: String) { + records.append(Record(root: root.path, home: home)) + } +} + +@MainActor +private final class PluginStates { + var enabled: Set + + init(enabled: Set) { self.enabled = enabled } +} + +/// Stands in for a scaffolding tool: records the request and creates one +/// file in its working directory, as `mvn archetype:generate` creates the +/// project in a subdirectory. +private final class ScaffoldingRunner: BuildCommandRunning, @unchecked Sendable { + private let creates: String? + private let exitStatus: Int32 + private let lock = NSLock() + private var recorded: [BuildCommandRequest] = [] + + init(creates: String?, exitStatus: Int32 = 0) { + self.creates = creates + self.exitStatus = exitStatus + } + + var requests: [BuildCommandRequest] { lock.withLock { recorded } } + + func run(_ request: BuildCommandRequest, onOutput: @escaping @Sendable (String) -> Void) async throws + -> BuildCommandResult { + lock.withLock { recorded.append(request) } + if let creates { + let file = request.workingDirectory.appendingPathComponent(creates) + try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("".utf8).write(to: file) + } + onOutput("[INFO] done\n") + return BuildCommandResult(termination: .exited(exitStatus)) + } +} + +/// Serves a local zip for an archive step instead of the network. +private final class DownloadStubPlatform: ProjectGenerationPlatform, @unchecked Sendable { + private let base: MacProjectGenerationPlatform + private let archive: URL + private let lock = NSLock() + private var requested: URL? + private var copy: URL? + + init(base: MacProjectGenerationPlatform, archive: URL) { + self.base = base + self.archive = archive + } + + var requestedURL: URL? { lock.withLock { requested } } + var downloadedCopy: URL? { lock.withLock { copy } } + + func download(_ url: URL) async throws -> URL { + let destination = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-project-test-\(UUID().uuidString).zip") + try FileManager.default.copyItem(at: archive, to: destination) + lock.withLock { + requested = url + copy = destination + } + return destination + } + + func makeStagingDirectory(in parent: URL, for projectName: String) throws -> URL { + try base.makeStagingDirectory(in: parent, for: projectName) + } + + func writeFile(_ contents: String, to url: URL, executable: Bool) throws { + try base.writeFile(contents, to: url, executable: executable) + } + + func createDirectory(at url: URL) throws { try base.createDirectory(at: url) } + + func runCommand( + _ command: ProjectGenerationPlan.Command, + in directory: URL, + environment: [String: String], + onOutput: @escaping @Sendable (String) -> Void + ) async throws { + try await base.runCommand(command, in: directory, environment: environment, onOutput: onOutput) + } + + func collect(_ subdirectory: URL, into root: URL) throws { try base.collect(subdirectory, into: root) } + func moveItem(at source: URL, to destination: URL) throws { try base.moveItem(at: source, to: destination) } + func removeItem(at url: URL) { base.removeItem(at: url) } + func itemExists(at url: URL) -> Bool { base.itemExists(at: url) } +} + +private final class InMemoryStore: KeyValueStore, @unchecked Sendable { + private var values: [String: Any] = [:] + + 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 data(forKey key: String) -> Data? { values[key] as? Data } + func set(_ value: Any?, forKey key: String) { values[key] = value } +} + +/// A directory below the system temporary directory, removed by `remove()`. +private struct TemporaryDirectory { + let url: URL + + init() throws { + url = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-new-project-\(UUID().uuidString)", isDirectory: true) + .resolvingSymlinksInPath() + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + } + + func remove() { + try? FileManager.default.removeItem(at: url) + } +} diff --git a/macos/Tests/LitheTests/ProjectTreeNameOrderTests.swift b/macos/Tests/LitheTests/ProjectTreeNameOrderTests.swift new file mode 100644 index 0000000000..67af08654f --- /dev/null +++ b/macos/Tests/LitheTests/ProjectTreeNameOrderTests.swift @@ -0,0 +1,81 @@ +import Foundation +import LitheCoreContracts +import Testing +@testable import Lithe + +@Suite("Project tree natural name order") +struct ProjectTreeNameOrderTests { + /// The shared fixture also drives Rust Core's `file_name_order` tests, so the + /// Core listing, the Swift fallback scan and tree insertion cannot drift apart. + @Test + func sharedFixtureGroupsSortStrictlyInTheListedOrder() throws { + let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent() + let data = try Data(contentsOf: root.appendingPathComponent("shared/fixtures/workspace/file-name-order-v1.json")) + let fixture = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let groups = try #require(fixture["orderedGroups"] as? [[String: Any]]) + #expect(!groups.isEmpty) + for group in groups { + let id = try #require(group["id"] as? String) + let names = try #require(group["names"] as? [String]) + for (index, earlier) in names.enumerated() { + for later in names[(index + 1)...] { + #expect(NaturalNameOrder.compareFileNames(earlier, later) < 0, "\(id): \(earlier) should precede \(later)") + #expect(NaturalNameOrder.compareFileNames(later, earlier) > 0, "\(id): \(later) should follow \(earlier)") + } + } + } + } + + @Test + func branchNameOrderKeepsDashAndUnderscoreDistinctFromFileNameOrder() { + // Refs use NaturalComparator without likeFileNames: '-' stays below '.'. + let dashed = Array("foo-bar".utf16), dotted = Array("foo.bar".utf16) + #expect(NaturalNameOrder.compare(dashed, dotted) < 0) + #expect(NaturalNameOrder.compareFileNames("foo-bar", "foo.bar") > 0) + } + + @Test + func fallbackScanListsVersionedNamesByNumberWithDirectoriesFirst() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let fileManager = FileManager.default + try fileManager.createDirectory(at: root.appendingPathComponent("zeta/part10"), withIntermediateDirectories: true) + try fileManager.createDirectory(at: root.appendingPathComponent("zeta/part2"), withIntermediateDirectories: true) + try fileManager.createDirectory(at: root.appendingPathComponent("Alpha"), withIntermediateDirectories: true) + for name in ["v0.1.12.md", "v0.1.4.md", "v0.1.14.md", "TEMPLATE.md", "v0.1.13.md", "v0.2.0.md"] { + try Data().write(to: root.appendingPathComponent(name)) + } + let rules = FileVisibilityRules(hiddenDirectoryNames: [], hiddenFilePatterns: []) + let builder = FileSystemWorkspaceSnapshotBuilder() + + let snapshot = try #require(builder.snapshot(at: root, visibilityRules: rules)) + let rootNames = (snapshot.root.children ?? []).map(\.name) + #expect(rootNames == ["Alpha", "zeta", "TEMPLATE.md", "v0.1.4.md", "v0.1.12.md", "v0.1.13.md", "v0.1.14.md", "v0.2.0.md"]) + let zeta = try #require(snapshot.root.children?.first { $0.name == "zeta" }) + #expect((zeta.children ?? []).map(\.name) == ["part2", "part10"]) + + let listed = builder.listDirectories([root], at: root, visibilityRules: rules) + let listedNames = try #require(listed[root.standardizedFileURL.path]).map { $0.url.lastPathComponent } + #expect(listedNames == rootNames) + } + + @Test + func insertedFilesLandInVersionOrder() throws { + let root = URL(fileURLWithPath: "/in-memory/release-notes") + func row(_ name: String) -> FileNode { + FileNode(url: root.appendingPathComponent(name), isDirectory: false, children: nil) + } + var tree = FileNode(url: root, isDirectory: true, children: [row("v0.1.4.md"), row("v0.1.13.md"), row("v0.2.0.md")]) + for name in ["v0.1.5.md", "v0.1.12.md", "TEMPLATE.md"] { + guard case .updated(let updated) = tree.inserting(file: root.appendingPathComponent(name)) else { + Issue.record("Inserting \(name) should update the loaded directory") + return + } + tree = updated + } + #expect((tree.children ?? []).map(\.name) == [ + "TEMPLATE.md", "v0.1.4.md", "v0.1.5.md", "v0.1.12.md", "v0.1.13.md", "v0.2.0.md", + ]) + } +} diff --git a/macos/Tests/LitheTests/RemoteConnectionsTests.swift b/macos/Tests/LitheTests/RemoteConnectionsTests.swift new file mode 100644 index 0000000000..9e264f2424 --- /dev/null +++ b/macos/Tests/LitheTests/RemoteConnectionsTests.swift @@ -0,0 +1,434 @@ +import Combine +import Foundation +import Testing +import LitheCoreContracts +@testable import Lithe + +// Note: .agents/notes/implemented/feature/2026-10-24-remote-ssh-connections.md + +private final class MemoryKeyValueStore: KeyValueStore, @unchecked Sendable { + 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 } +} + +private final class MemorySecureStore: SecureStore, @unchecked Sendable { + private let lock = NSLock() + private var values: [String: String] = [:] + func read(key: String) -> String? { lock.withLock { values[key] } } + func write(_ value: String, key: String) throws { lock.withLock { values[key] = value } } + func delete(key: String) throws { lock.withLock { _ = values.removeValue(forKey: key) } } + var keys: [String] { lock.withLock { Array(values.keys) } } +} + +/// Plays OpenSSH: asks `prompts` one at a time, each only after the previous +/// one was answered, then reports `result`. +private final class ScriptedProber: RemoteConnectionProbing, RemoteSessionConnecting, @unchecked Sendable { + let prompts: [RemoteAuthenticationPrompt] + let result: RemoteProbeResult + private let lock = NSLock() + private var recorded: [(id: String, answer: String?)] = [] + private let stream: AsyncStream + private let continuation: AsyncStream.Continuation + + init(prompts: [RemoteAuthenticationPrompt], result: RemoteProbeResult) { + self.prompts = prompts + self.result = result + (stream, continuation) = AsyncStream.makeStream() + } + + var answers: [(id: String, answer: String?)] { lock.withLock { recorded } } + + func probe( + _ target: RemoteSSHTarget, onPrompt: @escaping @Sendable (RemoteAuthenticationPrompt) -> Void + ) async -> Result { + var iterator = stream.makeAsyncIterator() + for prompt in prompts { + onPrompt(prompt) + _ = await iterator.next() + } + return .success(result) + } + + func answer(_ requestID: String, with answer: String?) -> Bool { + lock.withLock { recorded.append((requestID, answer)) } + continuation.yield() + return true + } + + func connect( + _ target: RemoteSSHTarget, servers: RemoteServerBundle, + onPrompt: @escaping @Sendable (RemoteAuthenticationPrompt) -> Void + ) async -> Result { + .failure(RemoteProbeFailure(code: "unused", message: "unused")) + } + + func readDirectory(_ path: String, session id: String) async -> Result<[RemoteDirectoryEntry], RemoteProbeFailure> { + .success([]) + } + + func disconnect(session id: String) async {} +} + +private struct NoLocator: RemoteHostLocating { + func resolveAddress(of host: String) async -> String? { nil } + func locate(_ address: String) async -> Result { + .failure(RemoteProbeFailure(code: "unused", message: "unused")) + } +} + +/// Waits for `condition` on the main actor, failing after a short local +/// deadline instead of hanging the suite. +@MainActor +private func eventually(_ condition: @MainActor () -> Bool) async -> Bool { + let deadline = ContinuousClock.now + .seconds(5) + while !condition() { + guard ContinuousClock.now < deadline else { return false } + await Task.yield() + } + return true +} + +private func prompt(_ id: String, _ text: String, secret: Bool = true, attempt: Int = 1) -> RemoteAuthenticationPrompt { + RemoteAuthenticationPrompt(requestId: id, prompt: text, secret: secret, attempt: attempt) +} + +private let connectedResult = RemoteProbeResult( + status: .connected, + host: RemoteProbedHost( + system: "Linux", machine: "x86_64", homeDirectory: "/root", + serverTarget: "x86_64-unknown-linux-musl", details: RemoteHostDetails() + ), + diagnostics: "" +) + +@MainActor +private func passwordFeature( + prompts: [RemoteAuthenticationPrompt], savedSecret: String? +) throws -> (RemoteConnectionsFeatureModel, ScriptedProber) { + let secure = MemorySecureStore() + let store = RemoteConnectionStore(store: MemoryKeyValueStore(), secureStore: secure) + let connection = RemoteSSHConnection(host: "devbox", user: "root", authentication: .password, remembersSecret: savedSecret != nil) + try store.save([connection]) + if let savedSecret { try store.saveSecret(savedSecret, for: connection.id) } + let prober = ScriptedProber(prompts: prompts, result: connectedResult) + let feature = RemoteConnectionsFeatureModel( + store: store, prober: prober, sessions: prober, locator: NoLocator(), servers: nil + ) + return (feature, prober) +} + +@Suite("RemoteConnections") +struct RemoteConnectionsTests { + @Test + func storeKeepsSecretsOutOfPreferencesAndReadsOlderEntries() throws { + let preferences = MemoryKeyValueStore() + let secure = MemorySecureStore() + let store = RemoteConnectionStore(store: preferences, secureStore: secure) + let connection = RemoteSSHConnection(name: "Tokyo", host: "43.128.251.102", port: 22, user: "root", + authentication: .password, remembersSecret: true) + try store.save([connection]) + try store.saveSecret("hunter2", for: connection.id) + + #expect(store.load() == [connection]) + #expect(store.secret(for: connection.id) == "hunter2") + let stored = String(decoding: preferences.data(forKey: "lithe.remote.ssh-connections.v1") ?? Data(), as: UTF8.self) + #expect(!stored.contains("hunter2")) + + // An entry saved before later fields existed still loads with defaults. + let older = #"[{"id":"\#(UUID().uuidString)","host":"devbox"}]"# + preferences.set(Data(older.utf8), forKey: "lithe.remote.ssh-connections.v1") + let loaded = try #require(store.load().first) + #expect(loaded.authentication == .auto) + #expect(loaded.remoteDirectory.isEmpty) + #expect(loaded.lastKnownHost == nil) + } + + @Test + func connectionProblemsAndTargetsFollowTheForm() { + #expect(RemoteSSHConnection().problem == .missingHost) + #expect(RemoteSSHConnection(host: "root@box").problem == .invalidHost) + #expect(RemoteSSHConnection(host: "box", port: 70_000).problem == .invalidPort) + #expect(RemoteSSHConnection(host: "box", authentication: .privateKey).problem == .missingIdentityFile) + let target = RemoteSSHConnection(host: " box ", user: "", authentication: .agent, identityFile: "~/.ssh/key").target + #expect(target.host == "box") + #expect(target.user == nil) + // A key path only matters for private-key authentication. + #expect(target.identityFile == nil) + } + + // A remembered password answers only the first question that asks for + // it; the one-time code that follows is the user's to answer. + @Test @MainActor + func rememberedSecretAnswersOnlyTheFirstPasswordPrompt() async throws { + let (feature, prober) = try passwordFeature( + prompts: [prompt("p1", "root@devbox's password:"), prompt("c1", "Verification code:")], + savedSecret: "hunter2" + ) + feature.testSelection() + #expect(await eventually { !feature.prompts.isEmpty }) + #expect(prober.answers.map(\.answer) == ["hunter2"]) + #expect(feature.prompts.first?.requestId == "c1") + + feature.answerPrompt("123456") + #expect(await eventually { feature.selectionTestState == .finished(connectedResult) }) + #expect(prober.answers.map(\.id) == ["p1", "c1"]) + #expect(prober.answers.last?.answer == "123456") + } + + // When the server asks again, the remembered secret was wrong; it must + // not be sent a second time. + @Test @MainActor + func aRepeatedPasswordPromptGoesToTheUser() async throws { + let (feature, prober) = try passwordFeature( + prompts: [prompt("p1", "Password:"), prompt("p2", "Password:", attempt: 2)], + savedSecret: "wrong" + ) + feature.testSelection() + #expect(await eventually { feature.prompts.first?.requestId == "p2" }) + #expect(prober.answers.map(\.answer) == ["wrong"]) + feature.answerPrompt(nil) + #expect(await eventually { feature.selectionTestState == .idle }) + #expect(prober.answers.last?.answer == nil) + } + + @Test @MainActor + func savingForgetsSecretsOfRemovedAndNonRememberingConnections() throws { + let secure = MemorySecureStore() + let store = RemoteConnectionStore(store: MemoryKeyValueStore(), secureStore: secure) + let kept = RemoteSSHConnection(host: "a", authentication: .password, remembersSecret: true) + let removed = RemoteSSHConnection(host: "b", authentication: .password, remembersSecret: true) + try store.save([kept, removed]) + try store.saveSecret("one", for: kept.id) + try store.saveSecret("two", for: removed.id) + let prober = ScriptedProber(prompts: [], result: connectedResult) + let feature = RemoteConnectionsFeatureModel( + store: store, prober: prober, sessions: prober, locator: NoLocator(), servers: nil + ) + + feature.selectedID = removed.id + feature.removeSelection() + #expect(feature.save()) + #expect(store.secret(for: removed.id) == nil) + #expect(store.secret(for: kept.id) == "one") + + feature.selectedID = kept.id + feature.update { $0.remembersSecret = false } + #expect(feature.save()) + #expect(secure.keys.isEmpty) + } +} + +// MARK: Remote workspace routing + +/// A host file system: writes succeed only for the version last read. +private final class FakeTransport: RemoteWorkspaceTransport, @unchecked Sendable { + private let lock = NSLock() + private var files: [String: (Data, RemoteFileVersion)] = [:] + var directories: [String: [RemoteDirectoryEntry]] = [:] + private(set) var expectedVersions: [RemoteFileVersion?] = [] + private var clock: Int64 = 1 + + func put(_ text: String, at path: String) { + lock.withLock { + clock += 1 + files[path] = (Data(text.utf8), RemoteFileVersion(size: Int64(text.utf8.count), modifiedMs: clock)) + } + } + + func text(at path: String) -> String? { + lock.withLock { files[path].map { String(decoding: $0.0, as: UTF8.self) } } + } + + private(set) var listCount = 0 + + func listDirectory(_ path: String, session id: String) throws -> [RemoteDirectoryEntry] { + lock.withLock { listCount += 1 } + return directories[path] ?? [] + } + + func readFile(_ path: String, session id: String) throws -> (data: Data, version: RemoteFileVersion) { + try lock.withLock { + guard let file = files[path] else { throw RemoteProbeFailure(code: "workspace_not_found", message: "missing") } + return file + } + } + + func writeFile(_ data: Data, to path: String, expected: RemoteFileVersion?, session id: String) throws -> RemoteWriteOutcome { + lock.withLock { + expectedVersions.append(expected) + guard files[path]?.1 == expected else { return .conflict } + clock += 1 + let version = RemoteFileVersion(size: Int64(data.count), modifiedMs: clock) + files[path] = (data, version) + return .saved(version) + } + } +} + +/// Local operations that must not be reached for remote roots. +private struct NoLocalWorkspace: WorkspaceOperations { + func snapshot(at rootURL: URL, visibilityRules: FileVisibilityRules) -> WorkspaceSnapshot? { nil } + func readFile(at rootURL: URL, relativePath: String) -> String? { nil } + func writeFile(_ text: String, at rootURL: URL, relativePath: String) -> Bool { false } +} + +private func entry(_ name: String, _ kind: String) -> RemoteDirectoryEntry { + RemoteDirectoryEntry(name: name, metadata: .init(kind: kind, symlinkTargetKind: nil)) +} + +@Suite("RemoteConnections routing") +struct RemoteWorkspaceRoutingTests { + private let root = URL(fileURLWithPath: "/tmp/lithe-tests/RemoteWorkspaces/c/h/app", isDirectory: true) + + private func routing() -> (RemoteRoutingFileOperations, RemoteRoutingWorkspaceOperations, FakeTransport, RemoteWorkspaceRegistry) { + let registry = RemoteWorkspaceRegistry() + registry.register(RemoteWorkspaceBinding(root: root, remoteRoot: "/srv/app", sessionID: "s", connectionID: UUID())) + let transport = FakeTransport() + let files = RemoteRoutingFileOperations(local: MacWorkspaceFileOperations(), registry: registry, transport: transport) + let tree = RemoteRoutingWorkspaceOperations(local: NoLocalWorkspace(), registry: registry, transport: transport) + return (files, tree, transport, registry) + } + + @Test + func urlsBelowARemoteRootMapToHostPaths() { + let (_, _, _, registry) = routing() + #expect(registry.resolve(root.appendingPathComponent("src/main.rs"))?.remotePath == "/srv/app/src/main.rs") + #expect(registry.resolve(root)?.remotePath == "/srv/app") + #expect(registry.resolve(URL(fileURLWithPath: "/tmp/lithe-tests/RemoteWorkspaces/c/h/application")) == nil) + } + + // Saving names the version that was read; a file changed on the host in + // between is reported as a conflict with its current content. + @Test + func savesAreCheckedAgainstTheVersionThatWasRead() throws { + let (files, _, transport, _) = routing() + transport.put("one", at: "/srv/app/a.txt") + let url = root.appendingPathComponent("a.txt") + let read = try #require(try files.readDocumentDetails(from: url, encoding: nil)) + #expect(read.text == "one") + + let saved = try files.writeDocumentText("two", to: url, expectedContent: nil, encoding: .utf8, expectedIdentity: read.identity) + guard case .saved(let identity) = saved else { Issue.record("expected a save, got \(saved)"); return } + #expect(transport.text(at: "/srv/app/a.txt") == "two") + #expect(transport.expectedVersions.first??.size == 3) + + transport.put("changed elsewhere", at: "/srv/app/a.txt") + let conflict = try files.writeDocumentText("three", to: url, expectedContent: nil, encoding: .utf8, expectedIdentity: identity) + guard case .conflict(let content, _) = conflict else { Issue.record("expected a conflict, got \(conflict)"); return } + #expect(content == "changed elsewhere") + #expect(transport.text(at: "/srv/app/a.txt") == "changed elsewhere") + } + + @Test + func theTreeListsFoldersFirstAndHidesConfiguredNames() throws { + let (_, tree, transport, _) = routing() + transport.directories["/srv/app"] = [entry("b.txt", "file"), entry("src", "directory"), entry(".git", "directory"), entry("A.md", "file")] + let rules = FileVisibilityRules(hiddenDirectoryNames: [".git"], hiddenFilePatterns: []) + let snapshot = try #require(tree.snapshot(at: root, visibilityRules: rules)) + #expect(snapshot.root.children?.map(\.url.lastPathComponent) == ["src", "A.md", "b.txt"]) + #expect(snapshot.root.children?.first?.children == nil) + } + + // Two files with equal bytes have different versions; saving the first + // must not send the second one's version to the host. + @Test + func equalContentInTwoFilesKeepsSeparateVersions() throws { + let (files, _, transport, _) = routing() + transport.put("same", at: "/srv/app/a.txt") + transport.put("same", at: "/srv/app/b.txt") + let a = root.appendingPathComponent("a.txt") + let b = root.appendingPathComponent("b.txt") + let readA = try #require(try files.readDocumentDetails(from: a, encoding: nil)) + _ = try files.readDocumentDetails(from: b, encoding: nil) + let saved = try files.writeDocumentText("edited", to: a, expectedContent: nil, encoding: .utf8, expectedIdentity: readA.identity) + guard case .saved = saved else { Issue.record("expected a save, got \(saved)"); return } + #expect(transport.text(at: "/srv/app/a.txt") == "edited") + } + + @Test + func existenceAndKindComeFromTheHostListing() { + let (files, _, transport, _) = routing() + transport.directories["/srv/app"] = [entry("src", "directory"), entry("a.txt", "file")] + #expect(files.fileExists(at: root)) + #expect(files.isDirectory(at: root)) + #expect(files.fileExists(at: root.appendingPathComponent("src"))) + #expect(files.isDirectory(at: root.appendingPathComponent("src"))) + #expect(files.fileExists(at: root.appendingPathComponent("a.txt"))) + #expect(!files.isDirectory(at: root.appendingPathComponent("a.txt"))) + #expect(!files.fileExists(at: root.appendingPathComponent("missing.txt"))) + } + + @Test + func creatingRemoteFilesIsUnsupported() { + let (files, tree, transport, _) = routing() + let url = root.appendingPathComponent("new.txt") + #expect(throws: CocoaError.self) { try files.createFile(at: url) } + #expect(throws: CocoaError.self) { try files.writeText("x", to: url) } + #expect(!tree.writeFile("x", at: root, relativePath: "new.txt")) + #expect(transport.expectedVersions.isEmpty) + #expect(transport.text(at: "/srv/app/new.txt") == nil) + } + + @Test + func unsupportedChangesToRemoteFilesFailInsteadOfTouchingTheDisk() { + let (files, _, _, _) = routing() + #expect(throws: CocoaError.self) { try files.removeItem(at: root.appendingPathComponent("a.txt")) } + #expect(throws: CocoaError.self) { try files.moveItem(at: root.appendingPathComponent("a"), to: root.appendingPathComponent("b")) } + } + + @Test + func placeholderFoldersNeverCollapseOntoTheirParent() throws { + let support = FileManager.default.temporaryDirectory.appendingPathComponent("lithe-roots-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: support) } + let id = UUID() + let root = try MacRemoteWorkspaceRoots.prepare(in: support, connection: id, remotePath: "/srv/app/..") + #expect(root.lastPathComponent == "root") + } + + @Test + func nonPublicAddressesAreNeverLookedUp() { + for address in ["10.0.0.1", "::ffff:192.168.1.5", "::", "0.0.0.0", "198.18.0.1", "224.0.0.1", "fd00::1", "ff02::1"] { + #expect(MacRemoteHostLocator.isPrivate(address), "\(address)") + } + #expect(!MacRemoteHostLocator.isPrivate("8.8.8.8")) + } + + @Test + func repeatedExistenceChecksShareOneListing() { + let (files, _, transport, _) = routing() + transport.directories["/srv/app"] = [entry("a.txt", "file")] + for _ in 0..<5 { _ = files.fileExists(at: root.appendingPathComponent("a.txt")) } + #expect(transport.listCount == 1) + } + + // A lost connection is announced once, however many requests fail. + @Test + func aLostConnectionIsReportedOnce() { + let (_, _, _, registry) = routing() + let closed = ClosedTransport() + let watching = ConnectionWatchingTransport(base: closed, registry: registry) + var announced = 0 + let observer = NotificationCenter.default.addObserver( + forName: .remoteWorkspaceConnectionLost, object: nil, queue: nil + ) { _ in announced += 1 } + defer { NotificationCenter.default.removeObserver(observer) } + for _ in 0..<3 { #expect(throws: RemoteProbeFailure.self) { try watching.listDirectory("/srv/app", session: "s") } } + #expect(announced == 1) + } +} + +private struct ClosedTransport: RemoteWorkspaceTransport { + func listDirectory(_ path: String, session id: String) throws -> [RemoteDirectoryEntry] { + throw RemoteProbeFailure(code: ConnectionWatchingTransport.closedCode, message: "closed") + } + func readFile(_ path: String, session id: String) throws -> (data: Data, version: RemoteFileVersion) { + throw RemoteProbeFailure(code: ConnectionWatchingTransport.closedCode, message: "closed") + } + func writeFile(_ data: Data, to path: String, expected: RemoteFileVersion?, session id: String) throws -> RemoteWriteOutcome { + throw RemoteProbeFailure(code: ConnectionWatchingTransport.closedCode, message: "closed") + } +} diff --git a/macos/Tests/LitheTests/RunEntryPointTests.swift b/macos/Tests/LitheTests/RunEntryPointTests.swift index 97de1d8660..a0062c5872 100644 --- a/macos/Tests/LitheTests/RunEntryPointTests.swift +++ b/macos/Tests/LitheTests/RunEntryPointTests.swift @@ -1,4 +1,6 @@ +import AppKit import Foundation +import SwiftUI import LitheCoreContracts import Testing @testable import Lithe @@ -10,6 +12,44 @@ import Testing @Suite("Run entry points", .serialized) @MainActor struct RunEntryPointTests { + @Test(arguments: [AppLanguage.english, .simplifiedChinese], [false, true]) + func javaBuildDecisionButtonsStayInsideTheDialog(language: AppLanguage, offersRecovery: Bool) async throws { + let model = makeAppModel(workspaceOperations: SequencedWorkspaceOperations.neverScans()) + defer { model.closeProject() } + model.settings.language = language + let request = PendingJavaLaunchDecision( + id: UUID(), workspaceURL: URL(fileURLWithPath: "/fixture/workspace"), + failure: JavaLaunchBuildFailure(code: "javaBuildFailed", message: "Build failed", report: JavaBuildReport( + markerScope: .launchTarget, builderFailedEarlier: true, elapsedMilliseconds: 100, + recovery: offersRecovery ? .rebuildJavaIndex : .none))) + let host = NSHostingView(rootView: JavaLaunchDecisionDialog(request: request) + .environmentObject(model).environmentObject(model.settings) + .environment(\.locale, language.locale)) + host.layoutSubtreeIfNeeded() + let window = NSWindow(contentRect: NSRect(origin: .zero, size: host.fittingSize), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = host + window.orderBack(nil) + defer { window.contentView = nil; window.close() } + var identifiers = ["java-build-run-anyway", "java-build-always-continue", "java-build-open-logs", "java-build-cancel"] + if offersRecovery { identifiers.insert("java-build-rebuild-index", at: 2) } + var frames: [NSRect] = [] + for identifier in identifiers { + let frame = try await AgentNativeControlTestSupport.frame(of: identifier, in: host) + // Check actual product controls, including the long English label and optional recovery action. + #expect(frame.minX >= 19.5 && frame.maxX <= host.bounds.width - 19.5, "\(identifier) overflows horizontally") + #expect(frame.minY >= 19.5 && frame.maxY <= host.bounds.height - 19.5, "\(identifier) overflows vertically") + #expect(abs(frame.height - 28) < 0.5) + #expect(frame.width >= 71.5) + #expect(!frames.contains { $0.intersects(frame) }, "Actions must not overlap") + frames.append(frame) + } + if language == .english && offersRecovery { + #expect(abs(frames[0].midY - frames[1].midY) >= 33.5, "The recovery row must wrap instead of clipping") + } + } + @Test func debugSelectedTestValidatesItsSourceInsteadOfLaunchingTheUnderlyingMain() throws { let workspace = try JavaWorkspaceFixture() diff --git a/macos/Tests/LitheTests/RustBuildSystemOperationsTests.swift b/macos/Tests/LitheTests/RustBuildSystemOperationsTests.swift index 85cd849038..e52a9b53d3 100644 --- a/macos/Tests/LitheTests/RustBuildSystemOperationsTests.swift +++ b/macos/Tests/LitheTests/RustBuildSystemOperationsTests.swift @@ -12,7 +12,7 @@ struct RustBuildSystemOperationsTests { @Test func descriptorDeclaresBuildSystemsAndTheirModule() throws { - let descriptor = try Self.descriptor("cargo-build") + let descriptor = try Self.descriptor("build-static-file") let cargo = try #require(descriptor.buildSystems.first) #expect(cargo.id == "cargo") @@ -26,7 +26,7 @@ struct RustBuildSystemOperationsTests { #expect(descriptor.pluginManifest.modules.map(\.manifest.id) .contains(.buildSystemsExtension(descriptor.id.rawValue))) - let maven = try #require(Self.descriptor("maven-build").buildSystems.first) + let maven = try #require(Self.descriptor("build-host-integration").buildSystems.first) #expect(maven.modelSource == .hostIntegration) // A host-integration dependency source declares no scopes of its own. #expect(maven.dependencyScopes == [.workspace, .project]) @@ -36,7 +36,7 @@ struct RustBuildSystemOperationsTests { func detectionReadsStaticModelAndResolvesOptions() throws { let workspace = try Self.cargoWorkspace() defer { try? FileManager.default.removeItem(at: workspace) } - let source = try Self.source("cargo-build") + let source = try Self.source("build-static-file") let detection = try operations.detect( workspaceURL: workspace, @@ -68,9 +68,17 @@ struct RustBuildSystemOperationsTests { #expect(resolution.dropped == ["features"]) } + @Test + func invocationUsesTheGlobalExecutableSetting() throws { + let target = try Self.target("build-tool-path", "fixture") + let custom = try operations.invocationPlan(target, root: ".", settings: ["toolPath": .string("/opt/custom tools/build")]) + #expect(custom.executables.first == .setting(id: "toolPath", path: "/opt/custom tools/build")) + #expect(try operations.invocationPlan(target, root: ".").executables.first == .setting(id: "toolPath", path: nil)) + } + @Test func invocationAndVersionProbe() throws { - let target = try Self.target("cargo-build", "cargo") + let target = try Self.target("build-static-file", "cargo") let invocation = try operations.invocationPlan(target, root: ".") #expect(invocation.executables == [.command("cargo")]) @@ -82,7 +90,7 @@ struct RustBuildSystemOperationsTests { let old = try operations.checkToolVersion(target, runtime: nil, output: "cargo 1.60.0 (d1fd9fe 2022-03-01)\n") #expect(old.satisfied == false) - let go = try Self.target("go-build", "go") + let go = try Self.target("build-command-stdout", "go") let goInvocation = try operations.invocationPlan(go, root: ".") #expect(goInvocation.executables == [.runtime("go")]) #expect(goInvocation.runtimeDependencies.first?.hostManaged == true) @@ -93,7 +101,7 @@ struct RustBuildSystemOperationsTests { @Test func taskAndTestPlansCarryOptionsAndSelection() throws { - let target = try Self.target("cargo-build", "cargo") + let target = try Self.target("build-static-file", "cargo") let project = BuildModel.Project(id: "cargo:demo", path: "crates/demo", name: "demo") let context = BuildPlanContext(root: "crates/demo", options: ["release": .bool(true)]) @@ -134,7 +142,7 @@ struct RustBuildSystemOperationsTests { /// `refresh` and `tool` fields Core copies into the provider params. @Test func providerPlansCarryTheReloadReasonAndTheResolvedTool() throws { - let target = try Self.target("gradle-provider", "gradle") + let target = try Self.target("build-provider", "gradle") let context = BuildPlanContext(root: ".", options: [:]) let executable = URL(fileURLWithPath: "/opt/tools/gradle/bin/gradle") @@ -159,7 +167,7 @@ struct RustBuildSystemOperationsTests { @Test func commandModelPlansAndDecodesStandardOutput() throws { - let target = try Self.target("go-build", "go") + let target = try Self.target("build-command-stdout", "go") let workspace = URL(fileURLWithPath: "/tmp/lithe-go-workspace") let plan = try operations.modelPlan( @@ -189,7 +197,7 @@ struct RustBuildSystemOperationsTests { func diagnosticsAndSyncFingerprint() throws { let workspace = try Self.cargoWorkspace() defer { try? FileManager.default.removeItem(at: workspace) } - let target = try Self.target("cargo-build", "cargo") + let target = try Self.target("build-static-file", "cargo") let diagnostics = try operations.decodeDiagnostics( target, @@ -223,7 +231,7 @@ struct RustBuildSystemOperationsTests { to: workspace.appendingPathComponent("gradle/wrapper/gradle-wrapper.properties") ) for kind in ["maven", "gradle"] { - var manifest = try #require(JSONSerialization.jsonObject(with: Self.fixtureData("maven-build")) as? [String: Any]) + var manifest = try #require(JSONSerialization.jsonObject(with: Self.fixtureData("build-host-integration")) as? [String: Any]) var systems = try #require(manifest["buildSystems"] as? [[String: Any]]) systems[0]["languageImport"] = kind == "maven" ? ["kind": kind, "options": ["profiles": "profiles", "settingsFile": "settingsFile", "localRepository": "localRepository"]] @@ -251,7 +259,7 @@ struct RustBuildSystemOperationsTests { } } #expect(try operations.languageImport( - Self.target("cargo-build", "cargo"), workspaceURL: workspace, root: ".", options: [:], executableURL: nil + Self.target("build-static-file", "cargo"), workspaceURL: workspace, root: ".", options: [:], executableURL: nil ) == nil) } diff --git a/macos/Tests/LitheTests/SearchEverywhereBehaviorTests.swift b/macos/Tests/LitheTests/SearchEverywhereBehaviorTests.swift index 6cdddeb614..029c9ecb29 100644 --- a/macos/Tests/LitheTests/SearchEverywhereBehaviorTests.swift +++ b/macos/Tests/LitheTests/SearchEverywhereBehaviorTests.swift @@ -268,7 +268,15 @@ struct SearchEverywhereBehaviorTests { try await WorkbenchTestScope.withCleanup({ await model.shutdownProjectSession() }) { scope in model.openProjectDirectly(root) try #require(await loaded.waitUntilOpen()) + // Project inventory can arrive before the module teardown started + // by opening the workspace finishes. Join that boundary before + // injecting the Safe Mode capability, or a late shutdown removes + // it between queries and all later results become empty. + try await scope.run("initial workspace module teardown", timeout: .seconds(5)) { + await model.awaitModuleRuntimeShutdown() + } model.cacheModuleCapability(SearchModuleCapability(feature: feature), id: .searchWorkspace, moduleID: .search) + try #require(model.searchFeatureIfActive === feature) var bare: [AgentFileMention] = [], named: [AgentFileMention] = [] try await scope.run("bare and named agent mentions", timeout: .seconds(5)) { bare = await model.agentFileMentions(matching: "") @@ -445,7 +453,7 @@ struct SearchEverywhereBehaviorTests { func filterRowsAndBulkButtonsKeepTheirCheckedState(scheme: ColorScheme) async throws { let session = SearchSessionFeatureModel() let host = NSHostingView(rootView: ProviderFilterHarness(session: session).environment(\.colorScheme, scheme)) - let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 236, height: 200), + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 238, height: 200), styleMask: [.borderless], backing: .buffered, defer: false) window.isReleasedWhenClosed = false window.appearance = NSAppearance(named: scheme == .dark ? .darkAqua : .aqua) @@ -455,7 +463,7 @@ struct SearchEverywhereBehaviorTests { window.orderFront(nil) defer { window.contentView = nil; window.close() } host.layoutSubtreeIfNeeded() - #expect(host.fittingSize == NSSize(width: 236, height: 200)) + #expect(host.fittingSize == NSSize(width: 238, height: 200)) let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) host.cacheDisplay(in: host.bounds, to: bitmap) let scale = CGFloat(bitmap.pixelsWide) / host.bounds.width @@ -714,6 +722,8 @@ struct SearchEverywhereBehaviorTests { session.enabledEverywhereProviders = [.files, .actions] // Exclusion edits below exercise a workspace source. func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) } func click(_ window: NSWindow, point: NSPoint) throws { + // A reopened hosting view can be attached before its hit-test frames are laid out. + window.contentView?.layoutSubtreeIfNeeded() for type in [NSEvent.EventType.leftMouseDown, .leftMouseUp] { NSApp.sendEvent(try #require(NSEvent.mouseEvent(with: type, location: point, modifierFlags: [], timestamp: 0, windowNumber: window.windowNumber, context: nil, eventNumber: 0, clickCount: 1, pressure: 1))) @@ -731,10 +741,10 @@ struct SearchEverywhereBehaviorTests { point = NSPoint(x: window.frame.width - LitheDropdownMetrics.popupPadding - 11, y: window.frame.height - LitheDropdownMetrics.popupPadding - 12) default: - let buttonWidth = LitheTheme.Commit.buttonMinimumWidth + 2 * LitheTheme.Commit.buttonBorderInset + let buttonWidth = LitheTheme.ActionButton.minimumWidth point = NSPoint(x: window.frame.width - 17 - buttonWidth / 2 - - (identifier == "search-exclusions-cancel" ? buttonWidth : 0), - y: 12 + LitheTheme.Commit.buttonBorderInset + LitheTheme.Commit.buttonHeight / 2) + - (identifier == "search-exclusions-cancel" ? buttonWidth + LitheTheme.ActionButton.spacing : 0), + y: 12 + LitheTheme.ActionButton.height / 2) } try click(window, point: point) } diff --git a/macos/Tests/LitheTests/SettingsAppearanceContainerTests.swift b/macos/Tests/LitheTests/SettingsAppearanceContainerTests.swift index b61d2db92f..c67bb2965f 100644 --- a/macos/Tests/LitheTests/SettingsAppearanceContainerTests.swift +++ b/macos/Tests/LitheTests/SettingsAppearanceContainerTests.swift @@ -109,6 +109,81 @@ struct SettingsAppearanceContainerTests { #expect(firstOwner.childWindows?.contains(settingsWindow) != true) } + @Test + func titlebarDragDetachesFromOwnerAndRejoinsOnRelease() throws { + let (owner, _) = makeWindow(theme: .dark) + let (settingsWindow, _) = makeWindow(theme: .dark) + defer { + settingsWindow.parent?.removeChildWindow(settingsWindow) + SettingsWindowChrome.ownerWindow = nil + SettingsWindowChrome.settingsWindow = nil + settingsWindow.close() + owner.close() + } + SettingsWindowChrome.ownerWindow = owner + SettingsWindowChrome.configure(settingsWindow, title: "Settings", themePreference: .dark) + #expect(settingsWindow.parent === owner) + + // A press in the content area keeps the child relationship. + let contentPoint = NSPoint(x: 20, y: settingsWindow.contentLayoutRect.minY + 10) + SettingsWindowChrome.handleTitlebarMouseEvent(try mouseEvent(.leftMouseDown, at: contentPoint, in: settingsWindow)) + #expect(settingsWindow.parent === owner) + SettingsWindowChrome.handleTitlebarMouseEvent(try mouseEvent(.leftMouseUp, at: contentPoint, in: settingsWindow)) + + // A titlebar press hands the drag to the window server. + let titlebarPoint = NSPoint(x: 120, y: settingsWindow.contentLayoutRect.maxY + 4) + SettingsWindowChrome.handleTitlebarMouseEvent(try mouseEvent(.leftMouseDown, at: titlebarPoint, in: settingsWindow)) + #expect(settingsWindow.parent == nil) + #expect(owner.childWindows?.contains(settingsWindow) != true) + + // Reconfiguring mid-drag must not reattach and reintroduce the stutter. + SettingsWindowChrome.configure(settingsWindow, title: "Settings", themePreference: .dark) + #expect(settingsWindow.parent == nil) + + SettingsWindowChrome.handleTitlebarMouseEvent(try mouseEvent(.leftMouseUp, at: titlebarPoint, in: settingsWindow)) + #expect(settingsWindow.parent === owner) + } + + @Test + func settingsWindowClosedDuringTitlebarPressRejoinsOwnerWhenReopened() throws { + let (owner, _) = makeWindow(theme: .dark) + let (settingsWindow, _) = makeWindow(theme: .dark) + defer { + settingsWindow.parent?.removeChildWindow(settingsWindow) + SettingsWindowChrome.ownerWindow = nil + SettingsWindowChrome.settingsWindow = nil + settingsWindow.close() + owner.close() + } + SettingsWindowChrome.ownerWindow = owner + SettingsWindowChrome.configure(settingsWindow, title: "Settings", themePreference: .dark) + + // Pressing the titlebar close button detaches, then closes the window. + let titlebarPoint = NSPoint(x: 10, y: settingsWindow.contentLayoutRect.maxY + 4) + SettingsWindowChrome.handleTitlebarMouseEvent(try mouseEvent(.leftMouseDown, at: titlebarPoint, in: settingsWindow)) + settingsWindow.orderOut(nil) + SettingsWindowChrome.handleTitlebarMouseEvent(try mouseEvent(.leftMouseUp, at: titlebarPoint, in: settingsWindow)) + #expect(settingsWindow.parent == nil) + + settingsWindow.orderFront(nil) + SettingsWindowChrome.configure(settingsWindow, title: "Settings", themePreference: .dark) + #expect(settingsWindow.parent === owner) + } + + private func mouseEvent(_ type: NSEvent.EventType, at point: NSPoint, in window: NSWindow) throws -> NSEvent { + try #require(NSEvent.mouseEvent( + with: type, + location: point, + modifierFlags: [], + timestamp: 0, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 0, + clickCount: 1, + pressure: type == .leftMouseDown ? 1 : 0 + )) + } + @Test func applyingPluginChangesBlocksNativeWindowClose() { let (window, _) = makeWindow(theme: .dark) diff --git a/macos/Tests/LitheTests/SettingsSearchVocabularyTests.swift b/macos/Tests/LitheTests/SettingsSearchVocabularyTests.swift index e6eeda279c..0a7a285737 100644 --- a/macos/Tests/LitheTests/SettingsSearchVocabularyTests.swift +++ b/macos/Tests/LitheTests/SettingsSearchVocabularyTests.swift @@ -11,6 +11,12 @@ import Testing @Suite("Settings search vocabulary") @MainActor struct SettingsSearchVocabularyTests { + @Test func globalInstructionsAreSearchableByNativeFileAndChineseName() throws { + let translations = try simplifiedChineseTranslations() + for query in ["Global instructions", "AGENTS.md", "AGENTS.override.md", "CLAUDE.md", "全局指令"] { + #expect(SettingsSearchVocabulary.matches(query: query, category: .instructions, localize: { translations[$0] ?? $0 })) + } + } @Test func everySettingsPageKeepsSearchTerms() { for category in SettingsCategory.allCases { #expect( @@ -43,6 +49,12 @@ struct SettingsSearchVocabularyTests { #expect(!SettingsSearchVocabulary.matches(query: "ACP adapter", category: .providers)) } + @Test func skillsPageMatchesSkillScopeAndImportQueries() { + for query in ["Skills", "SKILL.md", "Global skills", "Project skills", "Import skill"] { + #expect(SettingsSearchVocabulary.matches(query: query, category: .skills)) + } + } + @Test func editorPageMatchesTheNewFontQueries() { #expect(SettingsSearchVocabulary.matches(query: "font", category: .editor)) #expect(SettingsSearchVocabulary.matches(query: "fonts", category: .editor)) @@ -103,6 +115,8 @@ struct SettingsSearchVocabularyTests { let translations = try simplifiedChineseTranslations() let localize: (String) -> String = { translations[$0] ?? $0 } + #expect(SettingsSearchVocabulary.matches(query: "技能", category: .skills, localize: localize)) + #expect(SettingsSearchVocabulary.matches(query: "全局技能", category: .skills, localize: localize)) #expect(translations["Editor tabs"] == "编辑器标签栏") #expect(translations["Font"] == "字体") #expect(translations["Search fonts"] == "搜索字体") diff --git a/macos/Tests/LitheTests/SettingsSidebarLayoutTests.swift b/macos/Tests/LitheTests/SettingsSidebarLayoutTests.swift index 88b3268839..71dbc05b94 100644 --- a/macos/Tests/LitheTests/SettingsSidebarLayoutTests.swift +++ b/macos/Tests/LitheTests/SettingsSidebarLayoutTests.swift @@ -37,6 +37,6 @@ struct SettingsSidebarLayoutTests { #expect(SettingsGroup.build.categories == [.project, .run]) #expect(SettingsGroup.languages.categories == [.lsp]) #expect(SettingsGroup.tools.categories == [.terminal, .diagnostics]) - #expect(SettingsGroup.ai.categories == [.mcp, .ai, .agents, .providers]) + #expect(SettingsGroup.ai.categories == [.providers, .agents, .instructions, .skills, .hooks, .mcp, .ai]) } } diff --git a/macos/Tests/LitheTests/SharedI18nTests.swift b/macos/Tests/LitheTests/SharedI18nTests.swift new file mode 100644 index 0000000000..a6189ab509 --- /dev/null +++ b/macos/Tests/LitheTests/SharedI18nTests.swift @@ -0,0 +1,88 @@ +import Foundation +import Testing +@testable import Lithe + +@Suite("Shared built-in translations") +struct SharedI18nTests { + private var repository: URL { + URL(fileURLWithPath: #filePath).deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + } + + @Test + func languageRegistryPreservesPersistedIdentifiersAndDefault() { + #expect(AppLanguage.defaultLanguage == .english) + #expect(AppLanguage(rawValue: "en") == .english) + #expect(AppLanguage(rawValue: "zh-Hans") == .simplifiedChinese) + #expect(AppLanguage(rawValue: "unknown") == nil) + #expect(AppLanguage.english.title == "English") + #expect(AppLanguage.simplifiedChinese.title == "简体中文") + } + + @Test(arguments: AppLanguage.allCases) + func semanticKeysAndLegacyAliasesUseTheSameNativeTable(language: AppLanguage) throws { + let resources = repository.appendingPathComponent("macos/Resources") + let bundle = try #require(Bundle(url: resources)) + let chinese = language == .simplifiedChinese + #expect(language.localizedString("ui.save", bundle: bundle) == (chinese ? "保存" : "Save")) + #expect(language.localizedString("Save", bundle: bundle) == (chinese ? "保存" : "Save")) + #expect(language.localizedString("settings.mac.language", bundle: bundle) == (chinese ? "语言" : "Language")) + #expect(language.localizedString("files.reveal", bundle: bundle) == (chinese ? "在 Finder 中显示" : "Show in Finder")) + // Legacy and platform-specific wording remains unchanged by migration. + #expect(language.localizedString("OK", bundle: bundle) == (chinese ? "好" : "OK")) + #expect(language.localizedString("Terminal", bundle: bundle) == (chinese ? "终端" : "Terminal")) + #expect(language.localizedString("unknown.fixture", bundle: bundle) == "unknown.fixture") + for key in ["quickOpen.filesCount", "%lld files"] { + let format = language.localizedString(key, bundle: bundle) + for count in [Int64(0), 4, 9_000_000_000] { + #expect(String(format: format, count) == (chinese ? "\(count) 个文件" : "\(count) files")) + } + } + } + + @Test + func nativeFormatsHonorSharedReorderingAndLiteralData() throws { + struct Fixture: Decodable { + struct Case: Decodable { + let nativeTemplate: String + let stringValue: String + let integerValue: Int64 + let expected: String + } + let cases: [Case] + } + let data = try Data(contentsOf: repository.appendingPathComponent("shared/fixtures/i18n/format-v1.json")) + let fixture = try JSONDecoder().decode(Fixture.self, from: data) + for item in fixture.cases { + #expect(String(format: item.nativeTemplate, item.stringValue, item.integerValue) == item.expected) + } + } + + @Test + func nativeABIFormatsPreserveTheWholeUnsignedRange() throws { + struct Fixture: Decodable { + let nativeTemplate: String + let unsignedValue: String + let integerValue: Int32 + let expected: String + } + let data = try Data(contentsOf: repository.appendingPathComponent("shared/fixtures/i18n/native-abi-v1.json")) + let fixture = try JSONDecoder().decode(Fixture.self, from: data) + let rows = try #require(UInt64(fixture.unsignedValue)) + #expect(String(format: fixture.nativeTemplate, rows, fixture.integerValue) == fixture.expected) + } + + @Test + func switchingLanguageReadsResourcesWithoutChangingTheirBytes() throws { + let resources = repository.appendingPathComponent("macos/Resources") + let tables = AppLanguage.allCases.map { + resources.appendingPathComponent("\($0.rawValue).lproj/Localizable.strings") + } + let before = try tables.map { try Data(contentsOf: $0) } + let bundle = try #require(Bundle(url: resources)) + for language in [AppLanguage.english, .simplifiedChinese, .english] { + #expect(language.localizedString("ui.cancel", bundle: bundle) == (language == .english ? "Cancel" : "取消")) + } + #expect(try tables.map { try Data(contentsOf: $0) } == before) + } +} diff --git a/macos/Tests/LitheTests/SharedInstructionsFeatureTests.swift b/macos/Tests/LitheTests/SharedInstructionsFeatureTests.swift new file mode 100644 index 0000000000..f5b3be6e15 --- /dev/null +++ b/macos/Tests/LitheTests/SharedInstructionsFeatureTests.swift @@ -0,0 +1,139 @@ +import Foundation +import Testing +import LitheCoreContracts +@testable import Lithe + +@Suite("Shared instruction synchronization") +@MainActor +struct SharedInstructionsFeatureTests { + @Test func loadingAndExpandingIsReadOnlyAndKeepsIndependentDrafts() async throws { + let service = SharedInstructionsStub() + let feature = AgentInstructionsFeatureModel(service: service) + await feature.loadShared() + #expect(feature.sharedDraft == "Shared original") + #expect(feature.files.count == 2) + feature.toggleEditor(.codex) + feature.edit("Unsaved native draft") + feature.toggleEditor(.codex) + #expect(!feature.isEditorExpanded) + feature.toggleEditor(.claude) + feature.toggleEditor(.codex) + #expect(feature.draft == "Unsaved native draft") + #expect(!feature.canToggleShared(.codex)) + feature.editShared("New shared text") + #expect(feature.canSaveShared) + #expect(!feature.canToggleShared(.claude), "save source changes before switching targets") + await feature.saveShared() + #expect(feature.sharedLibrary?.file.content == "New shared text") + #expect(feature.draft == "Unsaved native draft") + #expect(await service.operations == ["save"]) + } + + @Test func rapidAgentChoicesUseFreshSnapshotsAndOneFailureDoesNotClearAnotherChoice() async { + let service = SharedInstructionsStub() + let feature = AgentInstructionsFeatureModel(service: service) + await feature.loadShared() + await service.setFailsCodex(true) + await service.onToggle { + await MainActor.run { + #expect(!feature.canToggleShared(.codex)) + #expect(feature.canToggleShared(.claude), "another row stays enabled and retains its native colors") + } + await feature.setSharedEnabled(true, agent: .claude) + } + await feature.setSharedEnabled(true, agent: .codex) + #expect(feature.sharedState(.codex)?.enabled == false) + #expect(feature.sharedState(.claude)?.enabled == true) + #expect(feature.sharedSyncErrors[.codex] == "External file changed") + #expect(feature.pendingSharedAgents.isEmpty) + #expect(await service.operations == ["codex-acp", "claude-acp"]) + await service.setFailsCodex(false) + await feature.setSharedEnabled(true, agent: .codex) + #expect(feature.sharedState(.codex)?.enabled == true) + #expect(feature.sharedSyncErrors[.codex] == nil) + } + + @Test func sourceConflictsKeepDraftAndFailedReloadDoesNotDiscardIt() async { + let service = SharedInstructionsStub() + let feature = AgentInstructionsFeatureModel(service: service) + await feature.loadShared() + feature.editShared("Keep this shared draft") + await service.setFailsSource(true) + await feature.saveShared() + #expect(feature.sharedPhase == .failed("External file changed")) + #expect(feature.sharedDraft == "Keep this shared draft") + await feature.loadShared(reload: true) + #expect(feature.sharedDraft == "Keep this shared draft") + await service.setFailsSource(false) + await feature.loadShared(reload: true) + #expect(feature.sharedDraft == "Shared original") + #expect(!feature.isSharedDirty) + } + + @Test func cancellingTheOwnerDropsQueuedSwitchesAndDoesNotWriteThemLater() async { + let service = SharedInstructionsStub() + let feature = AgentInstructionsFeatureModel(service: service) + let started = TestGate(), released = TestGate() + await service.onToggle { + started.open() + let wasReleased = await released.waitUntilOpen() + if !Task.isCancelled { #expect(wasReleased) } + } + await feature.loadShared() + let task = Task { await feature.setSharedEnabled(true, agent: .codex) } + defer { released.open(); task.cancel() } + #expect(await started.waitUntilOpen()) + await feature.setSharedEnabled(true, agent: .claude) + task.cancel() + released.open() + await task.value + #expect(feature.pendingSharedAgents.isEmpty) + #expect(feature.sharedState(.claude)?.enabled == false) + #expect(await service.operations == ["codex-acp"]) + } +} + +private actor SharedInstructionsStub: AgentInstructionsService { + struct Conflict: LocalizedError { var errorDescription: String? { "External file changed" } } + var operations: [String] = [] + private var text = "Shared original" + private var enabled: Set = [] + private var failsCodex = false, failsSource = false + private var callback: (@Sendable () async -> Void)? + func onToggle(_ value: @escaping @Sendable () async -> Void) { callback = value } + func setFailsCodex(_ value: Bool) { failsCodex = value } + func setFailsSource(_ value: Bool) { failsSource = value } + func read(agent: AgentInstructionKind) async throws -> AgentInstructionFile { native(agent) } + func save(file: AgentInstructionFile, content: String) async throws -> AgentInstructionFile { file } + func readShared() async throws -> SharedInstructionLibrary { + if failsSource { throw Conflict() } + return library() + } + func saveShared(content: String, library: SharedInstructionLibrary) async throws -> SharedInstructionLibrary { + operations.append("save") + if failsSource { throw Conflict() } + text = content + return self.library() + } + func setSharedEnabled(_ value: Bool, agent: AgentInstructionKind, library: SharedInstructionLibrary) async throws -> SharedInstructionLibrary { + operations.append(agent.rawValue) + let called = callback; callback = nil + await called?() + try Task.checkCancellation() + if failsCodex && agent == .codex { throw Conflict() } + if value { enabled.insert(agent) } else { enabled.remove(agent) } + return self.library() + } + private func native(_ agent: AgentInstructionKind) -> AgentInstructionFile { + .init(agentId: agent.rawValue, path: "/fixture/\(agent.rawValue)/instructions.md", + content: "Own \(agent.title)", exists: true, revision: "native-\(agent.rawValue)") + } + private func library() -> SharedInstructionLibrary { + .init(file: .init(agentId: "shared", path: "/fixture/library/Shared.md", content: text, + exists: true, revision: "source-\(text)"), + agents: AgentInstructionKind.allCases.map { agent in + SharedInstructionAgent(agentId: agent.rawValue, file: native(agent), enabled: enabled.contains(agent), + needsSync: false, ownLineCount: 1, issue: nil) + }) + } +} diff --git a/macos/Tests/LitheTests/TerminalInputFocusTests.swift b/macos/Tests/LitheTests/TerminalInputFocusTests.swift index ab42f79fd2..a5970482fb 100644 --- a/macos/Tests/LitheTests/TerminalInputFocusTests.swift +++ b/macos/Tests/LitheTests/TerminalInputFocusTests.swift @@ -1,9 +1,11 @@ import AppKit +import SwiftUI import MetalKit import os import SwiftTerm import Testing @testable import Lithe +import LitheTerminalModule @Suite("Terminal input focus") @MainActor @@ -36,6 +38,20 @@ struct TerminalInputFocusTests { #expect(missing.ansiColors.allSatisfy { $0 == nil }) } + @Test + func terminalPaletteRefreshesOnThemeChange() { + let terminal = LitheTerminalView(frame: .zero) + terminal.applyThemeColors(theme: .lithe, isDark: true) + let litheBackground = terminal.nativeBackgroundColor + let litheTerminalBackground = terminal.terminal.backgroundColor + + terminal.applyThemeColors(theme: .codex, isDark: true) + + #expect(terminal.nativeBackgroundColor != litheBackground) + #expect(terminal.terminal.backgroundColor != litheTerminalBackground) + #expect(terminal.nativeBackgroundColor == LitheTheme.nsColor(.editor, theme: .codex, isDark: true)) + } + @Test func terminalPaletteAppliesContrastGatePerImportedEntry() throws { func archive(_ color: NSColor) throws -> Data { @@ -315,29 +331,38 @@ struct TerminalInputFocusTests { } } -@Suite("Terminal canvas compositing") +@Suite("Terminal canvas compositing", .serialized) @MainActor struct TerminalCanvasCompositingTests { + @Test + func unmountedTerminalUsesAppAppearancePreference() { + let terminal = LitheTerminalView(frame: .zero) + terminal.appearance = NSAppearance(named: .darkAqua) + terminal.preferredAppearanceIsDark = { false } + + terminal.applyThemeColors(theme: .lithe) + + let expected = LitheTheme.nsColor(.editor, theme: .lithe, isDark: false) + #expect(terminal.window == nil) + #expect(terminal.nativeBackgroundColor.isEqual(expected)) + } + @Test func canvasLeavesSharedPaneBackgroundVisibleWithWallpaper() { let terminal = LitheTerminalView(frame: NSRect(x: 0, y: 0, width: 400, height: 200)) - terminal.appearance = NSAppearance(named: .aqua) - terminal.setWorkbenchBackgroundVisible(true) - terminal.applyThemeColors() + terminal.updateWorkbenchSurface(isVisible: true, theme: .lithe, isDark: false) #expect(terminal.nativeBackgroundColor.alphaComponent == 0) // Metal clear colors are premultiplied, so a transparent canvas clears to black rather than the workbench color. let clearColor = terminal.nativeBackgroundColor.usingColorSpace(.sRGB) #expect(clearColor?.redComponent == 0) #expect(clearColor?.greenComponent == 0) #expect(clearColor?.blueComponent == 0) - // The layer must stay clear as well: the Core Graphics fallback paints its background from this layer. #expect(terminal.layer?.backgroundColor == NSColor.clear.cgColor) #expect(terminal.layer?.isOpaque == false) let renderer = MTKView(frame: terminal.bounds, device: nil) terminal.addSubview(renderer) - terminal.applyThemeColors() + terminal.applyThemeColors(theme: .lithe, isDark: false) #expect(renderer.layer?.isOpaque == false) - // Focus changes repaint the paused Metal surface, so the decision must survive them too. terminal.hasFocus = false #expect(renderer.layer?.isOpaque == false) } @@ -347,7 +372,7 @@ struct TerminalCanvasCompositingTests { for name in [NSAppearance.Name.aqua, .darkAqua] { let terminal = LitheTerminalView(frame: NSRect(x: 0, y: 0, width: 400, height: 200)) terminal.appearance = NSAppearance(named: name) - terminal.applyThemeColors() + terminal.applyThemeColors(theme: .lithe, isDark: name == .darkAqua) let editorColor = LitheTheme.nsColor(.editor, isDark: name == .darkAqua) #expect(terminal.nativeBackgroundColor.alphaComponent == 1) #expect(terminal.nativeBackgroundColor.isEqual(editorColor)) @@ -355,7 +380,7 @@ struct TerminalCanvasCompositingTests { #expect(terminal.layer?.isOpaque == true) let renderer = MTKView(frame: terminal.bounds, device: nil) terminal.addSubview(renderer) - terminal.applyThemeColors() + terminal.applyThemeColors(theme: .lithe, isDark: name == .darkAqua) #expect(renderer.layer?.isOpaque == true) } } @@ -364,18 +389,16 @@ struct TerminalCanvasCompositingTests { func canvasFollowsWorkbenchBackgroundChanges() { let terminal = LitheTerminalView(frame: NSRect(x: 0, y: 0, width: 400, height: 200)) terminal.appearance = NSAppearance(named: .aqua) - terminal.applyThemeColors() + terminal.applyThemeColors(theme: .lithe, isDark: false) let renderer = MTKView(frame: terminal.bounds, device: nil) terminal.addSubview(renderer) - - terminal.setWorkbenchBackgroundVisible(true) + terminal.updateWorkbenchSurface(isVisible: true, theme: .lithe, isDark: false) #expect(terminal.nativeBackgroundColor.alphaComponent == 0) #expect(terminal.nativeBackgroundColor.usingColorSpace(.sRGB)?.redComponent == 0) #expect(terminal.layer?.backgroundColor == NSColor.clear.cgColor) #expect(terminal.layer?.isOpaque == false) #expect(renderer.layer?.isOpaque == false) - - terminal.setWorkbenchBackgroundVisible(false) + terminal.updateWorkbenchSurface(isVisible: false, theme: .lithe, isDark: false) #expect(terminal.nativeBackgroundColor.alphaComponent == 1) #expect(terminal.layer?.isOpaque == true) #expect(renderer.layer?.isOpaque == true) @@ -383,24 +406,124 @@ struct TerminalCanvasCompositingTests { @Test func osc11ReportedBackgroundMatchesCanvasClearColor() throws { - // SwiftTerm answers OSC 11 from the terminal background color, which mirrors the canvas clear color. - // Reporting black while a wallpaper is visible is the documented tradeoff of the premultiplied clear. let terminal = LitheTerminalView(frame: NSRect(x: 0, y: 0, width: 400, height: 200)) terminal.appearance = NSAppearance(named: .aqua) - terminal.applyThemeColors() - let editorColor = LitheTheme.nsColor(.editor, isDark: false).usingColorSpace(.sRGB) + terminal.applyThemeColors(theme: .lithe, isDark: false) + let editorColor = LitheTheme.nsColor(.editor, theme: .lithe, isDark: false).usingColorSpace(.sRGB) let expected = try #require(editorColor) let reported = terminal.terminal.backgroundColor #expect(reported.red == UInt16((expected.redComponent * 65535).rounded())) #expect(reported.green == UInt16((expected.greenComponent * 65535).rounded())) #expect(reported.blue == UInt16((expected.blueComponent * 65535).rounded())) - - terminal.setWorkbenchBackgroundVisible(true) + terminal.updateWorkbenchSurface(isVisible: true, theme: .lithe, isDark: false) let transparent = terminal.terminal.backgroundColor #expect(transparent.red == 0) #expect(transparent.green == 0) #expect(transparent.blue == 0) } + + @Test + func mountedTerminalRefreshesTheSameSurfaceInDarkAppearance() async throws { + try await assertMountedTerminalThemeRefresh(scheme: .dark) + } + + @Test + func mountedTerminalRefreshesTheSameSurfaceInLightAppearance() async throws { + try await assertMountedTerminalThemeRefresh(scheme: .light) + } + + private func assertMountedTerminalThemeRefresh(scheme: ColorScheme) async throws { + let domain = "Lithe.TerminalThemeRefresh.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: domain)) + let originalTheme = AppThemeRuntime.shared.activeTheme + defer { + defaults.removePersistentDomain(forName: domain) + AppThemeRuntime.shared.activate(originalTheme) + } + let isDark = scheme == .dark + let store = MacUserDefaultsStore(defaults: defaults) + let settings = AppSettings(store: store) + settings.themePreference = isDark ? .dark : .light + settings.colorTheme = .lithe + let model = AppModel(settings: settings, + services: MacServiceContainer(store: store, settings: settings, moduleLaunchMode: .safeMode).services) + let terminal = LitheTerminalView(frame: .zero) + let transport = ThemeRefreshTestTransport(view: terminal) + let session = TerminalSession(transport: transport) + session.start(in: URL(fileURLWithPath: "/workspace/theme-refresh"), shellPath: "/bin/zsh") + let host = NSHostingView(rootView: TerminalSurfaceView(session: session) + .environmentObject(model).environmentObject(settings).environment(\.colorScheme, scheme)) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 480, height: 240), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: isDark ? .darkAqua : .aqua) + window.contentView = host + let initialBackground = LitheTheme.nsColor(.editor, theme: .lithe, isDark: isDark) + let initialForeground = LitheTheme.nsColor(.primaryText, theme: .lithe, isDark: isDark) + let initialDeadline = ContinuousClock().now.advanced(by: .seconds(2)) + while (!terminal.nativeBackgroundColor.isEqual(initialBackground) + || !terminal.nativeForegroundColor.isEqual(initialForeground)), + ContinuousClock().now < initialDeadline { + host.layoutSubtreeIfNeeded() + await Task.yield() + } + #expect(terminal.nativeBackgroundColor.isEqual(initialBackground)) + #expect(terminal.nativeForegroundColor.isEqual(initialForeground)) + #expect(session.isRunning) + let clock = ContinuousClock() + for theme in [AppColorTheme.codex, .linear, .lithe] { + settings.colorTheme = theme + let expected = LitheTheme.nsColor(.editor, theme: theme, isDark: isDark) + let expectedForeground = LitheTheme.nsColor(.primaryText, theme: theme, isDark: isDark) + let deadline = clock.now.advanced(by: .seconds(2)) + while (!terminal.nativeBackgroundColor.isEqual(expected) + || !terminal.nativeForegroundColor.isEqual(expectedForeground)), + clock.now < deadline { + host.layoutSubtreeIfNeeded() + await Task.yield() + } + let sRGB = try #require(expected.usingColorSpace(.sRGB)) + let reported = terminal.terminal.backgroundColor + let reportedForeground = terminal.terminal.foregroundColor + let expectedForegroundSRGB = try #require(expectedForeground.usingColorSpace(.sRGB)) + #expect(terminal.nativeBackgroundColor.isEqual(expected)) + #expect(terminal.nativeForegroundColor.isEqual(expectedForeground)) + #expect(reported.red == UInt16(sRGB.redComponent * 65535)) + #expect(reported.green == UInt16(sRGB.greenComponent * 65535)) + #expect(reported.blue == UInt16(sRGB.blueComponent * 65535)) + #expect(abs(Int(reportedForeground.red) - Int(expectedForegroundSRGB.redComponent * 65535)) <= 1) + #expect(abs(Int(reportedForeground.green) - Int(expectedForegroundSRGB.greenComponent * 65535)) <= 1) + #expect(abs(Int(reportedForeground.blue) - Int(expectedForegroundSRGB.blueComponent * 65535)) <= 1) + #expect(session.isRunning) + } + session.stop() + window.contentView = nil + window.close() + await model.shutdownProjectSession() + } +} +@MainActor +private final class ThemeRefreshTestTransport: TerminalTransport { + let nativeView: AnyObject + var isRunning = false + var processID: Int32? { isRunning ? 1234 : nil } + var shellName = "zsh" + var onTermination: ((Int32?) -> Void)? + var onOutput: ((Data) -> Void)? + var onTitle: ((String) -> Void)? + var onDirectoryUpdate: ((String?) -> Void)? + var onLink: ((String, [String: String]) -> Void)? + + init(view: LitheTerminalView) { nativeView = view } + func defaultShellPath() -> String { "/bin/zsh" } + func defaultEnvironment() -> [String: String] { [:] } + func start(workingDirectory: String, shellPath: String, environment: [String: String]) throws { isRunning = true } + func startProcess(_ launch: TerminalProcessLaunch, environment: [String: String]) throws -> Int32 { isRunning = true; return 1234 } + func send(_ input: Data) throws {} + func interrupt() throws {} + func focus() {} + func clear() {} + func stop() { isRunning = false } } @MainActor @@ -447,3 +570,39 @@ private final class CursorDrawingRecorder: NSObject, MTKViewDelegate { private final class TerminalOtherResponderView: NSView { override var acceptsFirstResponder: Bool { true } } + +/// Appearance resolution behind the exported COLORFGBG and the OSC 11 answer. +@Suite("MacOS appearance resolution") +@MainActor +struct MacTerminalAppearanceResolutionTests { + @Test + func themePreferenceOverridesSystemAppearanceBeforeMount() { + let transport = MacTerminalTransport(preferredAppearanceIsDark: { true }) + let view = transport.nativeView as! LitheTerminalView + view.appearance = NSAppearance(named: .aqua) + view.applyThemeColors(theme: LitheTheme.activeTheme) + #expect(transport.reportsLightBackground == false) + } + + @Test + func effectiveAppearanceUsedWhenNoPreference() { + let transport = MacTerminalTransport(preferredAppearanceIsDark: { nil }) + let view = transport.nativeView as! LitheTerminalView + view.appearance = NSAppearance(named: .aqua) + view.applyThemeColors(theme: LitheTheme.activeTheme) + #expect(transport.reportsLightBackground == true) + } + + @Test + func wallpaperConfiguredBeforeSpawnReportsDarkAndAnswersBlack() { + let transport = MacTerminalTransport(preferredAppearanceIsDark: { nil }) + let view = transport.nativeView as! LitheTerminalView + view.appearance = NSAppearance(named: .aqua) + view.applyThemeColors(theme: LitheTheme.activeTheme) + #expect(transport.reportsLightBackground == true) + transport.configureInitialSurface(showsWorkbenchBackground: true) + #expect(transport.reportsLightBackground == false) + // The canvas is transparent; OSC 11 answers this same black, so the env export stays consistent. + #expect(view.nativeBackgroundColor == NSColor.black.withAlphaComponent(0)) + } +} diff --git a/macos/Tests/LitheTests/TerminalPlacementFeatureModelTests.swift b/macos/Tests/LitheTests/TerminalPlacementFeatureModelTests.swift index 0291ab1c58..6f61eab491 100644 --- a/macos/Tests/LitheTests/TerminalPlacementFeatureModelTests.swift +++ b/macos/Tests/LitheTests/TerminalPlacementFeatureModelTests.swift @@ -120,8 +120,8 @@ struct TerminalPlacementFeatureModelTests { await model.shutdownProjectSession() } - @Test(arguments: [false, true]) - func documentFocusRestoresSelectionAfterTerminalOrDatabase(database: Bool) async throws { + @Test + func documentFocusRestoresSelectionAfterTerminal() async throws { let context = makeTerminalCloseContext() let model = context.model do { @@ -132,22 +132,15 @@ struct TerminalPlacementFeatureModelTests { model.editorTabOrderFeature.moveToEnd(.terminal(context.session.id)) #expect(model.editorTabOrderFeature.drop(.terminal(context.session.id), into: group, side: .right)) model.selectEditorTerminalSession(context.session) - if database { - model.editorTabOrderFeature.moveToEnd(.database(nil)) - model.editorTabOrderFeature.databaseSelected = true - model.editorTabOrderFeature.focus(.database(nil)) - } #expect(model.activeDocumentID == document.id) model.editorDidFocus(document) #expect(model.activeEditorTerminalSession == nil) - #expect(!model.isDatabaseWorkspaceSelected) #expect(model.editorTabOrderFeature.selectedItem(in: group) == .document(document.id)) model.showFindBar() #expect(model.isFindBarVisible) #expect(model.requestCloseActiveWorkbenchItem()) #expect(!model.openDocuments.contains { $0.id == document.id }) #expect(model.editorTerminalSessions.contains { $0.id == context.session.id }) - if database { #expect(model.editorTabItems.contains(.database(nil))) } } catch { await model.shutdownProjectSession(); throw error } await model.shutdownProjectSession() } @@ -250,6 +243,98 @@ struct TerminalPlacementFeatureModelTests { #expect(!toolSession.isRunning) } + @Test + func shellExitClosesTabAndHidesPanelForLastToolTerminal() async throws { + let context = makeTerminalCloseContext() + let model = context.model + let feature = try #require(model.terminalFeature) + defer { feature.stopAllSessions() } + await settleFixtureActivation() + model.terminalPlacementFeature.moveToTool(context.session.id) + model.workbenchFeature.setVisibility(.terminal, isVisible: true) + model.configureInteractiveTerminalSession(context.session) + + context.transport.onTermination?(0) + await settleFixtureActivation() + + #expect(model.terminalSessions.isEmpty) + #expect(!model.workbenchFeature.isVisible(.terminal)) + #expect(!context.session.isRunning) + #expect(context.transport.stopCount == 1) + } + + @Test + func shellExitKeepsPanelAndSurvivingToolTerminalsRunning() async throws { + let context = makeTerminalCloseContext() + let model = context.model + let feature = try #require(model.terminalFeature) + defer { feature.stopAllSessions() } + let survivor = feature.createSession( + in: URL(fileURLWithPath: "/tmp/lithe-terminal-close-tests"), shellPath: "/bin/zsh" + ) + model.terminalPlacementFeature.registerSession(survivor.id) + model.terminalPlacementFeature.moveToTool(context.session.id) + model.terminalPlacementFeature.moveToTool(survivor.id) + await settleFixtureActivation() + model.workbenchFeature.setVisibility(.terminal, isVisible: true) + model.configureInteractiveTerminalSession(context.session) + + context.transport.onTermination?(0) + await settleFixtureActivation() + + #expect(model.workbenchFeature.isVisible(.terminal)) + #expect(model.toolTerminalSessions.map(\.id) == [survivor.id]) + #expect(survivor.isRunning) + } + + @Test + func shellExitClosesEditorAreaTerminalTab() async throws { + let context = makeTerminalCloseContext() + let model = context.model + let feature = try #require(model.terminalFeature) + defer { feature.stopAllSessions() } + await settleFixtureActivation() + model.configureInteractiveTerminalSession(context.session) + + context.transport.onTermination?(0) + await settleFixtureActivation() + + #expect(model.terminalSessions.isEmpty) + #expect(model.terminalPlacementFeature.editorSessionIDs.isEmpty) + #expect(model.terminalPlacementFeature.toolSessionIDs.isEmpty) + } + + @Test + func restartAfterShellExitKeepsTheRestartedSession() async throws { + let context = makeTerminalCloseContext() + let model = context.model + let feature = try #require(model.terminalFeature) + defer { feature.stopAllSessions() } + model.terminalPlacementFeature.moveToTool(context.session.id) + await settleFixtureActivation() + model.workbenchFeature.setVisibility(.terminal, isVisible: true) + model.configureInteractiveTerminalSession(context.session) + + context.transport.onTermination?(0) + context.session.restart() + await settleFixtureActivation() + + #expect(model.toolTerminalSessions.map(\.id) == [context.session.id]) + #expect(context.session.isRunning) + #expect(model.workbenchFeature.isVisible(.terminal)) + } + + /// The fixture's deferred workspace-module activation reconciles tool + /// window visibility once it lands, so tests that assert visibility must + /// let that activation settle first. + private func settleFixtureActivation() async { + for _ in 0..<3 { + await withCheckedContinuation { continuation in + DispatchQueue.main.async { continuation.resume() } + } + } + } + @Test(arguments: [ColorScheme.dark, .light]) func retainedTerminalRestoresFocusAndWallpaperWithoutRecreatingItsSurface(scheme: ColorScheme) async throws { let context = makeTerminalCloseContext(), state = RetainedTerminalReviewState() @@ -259,7 +344,8 @@ struct TerminalPlacementFeatureModelTests { let feature = try #require(model.terminalFeature) let store = WorkbenchToolContentStore() let host = NSHostingView(rootView: RetainedTerminalReviewHost(state: state, store: store, - model: model, feature: feature).environment(\.colorScheme, scheme)) + model: model, feature: feature).environmentObject(model).environmentObject(model.settings) + .environment(\.colorScheme, scheme)) host.sizingOptions = [] host.frame = NSRect(x: 0, y: 0, width: 480, height: 400) let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 480, height: 440), @@ -343,9 +429,9 @@ struct TerminalPlacementFeatureModelTests { model.selectTerminalSession(b) let store = WorkbenchToolContentStore() let host = NSHostingView(rootView: VStack(spacing: 0) { - TerminalSurfaceView(session: c).environmentObject(model).frame(height: 160) + TerminalSurfaceView(session: c).environmentObject(model).environmentObject(model.settings).frame(height: 160) RetainedTerminalReviewHost(state: state, store: store, model: model, feature: feature) - }) + }.environmentObject(model).environmentObject(model.settings)) host.sizingOptions = [] let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 480, height: 440), styleMask: [.borderless], backing: .buffered, defer: false) @@ -558,7 +644,7 @@ private struct RetainedTerminalReviewHost: View { Color(nsColor: NSColor(srgbRed: 0.8, green: 0.1, blue: 0.6, alpha: 1)) if state.visible { RetainedWorkbenchToolContent(store: store, tool: .terminal, - content: TerminalView(feature: feature).environmentObject(model), + content: TerminalView(feature: feature).environmentObject(model).environmentObject(model.settings), onActivate: { model.activeToolTerminalSession?.focus() }) .environment(\.workbenchHasBackgroundImage, state.hasImage) } diff --git a/macos/Tests/LitheTests/VcsProviderHostTests.swift b/macos/Tests/LitheTests/VcsProviderHostTests.swift new file mode 100644 index 0000000000..26e3e18419 --- /dev/null +++ b/macos/Tests/LitheTests/VcsProviderHostTests.swift @@ -0,0 +1,1018 @@ +import Foundation +import AppKit +import SwiftUI +import LitheCoreContracts +import LitheModuleAPI +import Testing +@testable import Lithe + +/// The macOS host side of plugin VCS providers: the feature model against +/// a fake Core and fake provider processes, the real Core adapter, the +/// install review, and the tool window's pure presentation rules. +@Suite("VCS provider host") +@MainActor +struct VcsProviderHostTests { + @Test + func sharedChangesTreeActivatesAnUnversionedFile() async throws { + let fixture = try VcsFeatureFixture(roots: [("", "svn")]) + defer { fixture.close() } + fixture.providers.replies["status"] = try VcsTestData.statusReply([("new.txt", "unversioned")]) + #expect(await fixture.open()) + var opened: URL? + let app = NSApplication.shared + let attribute = NSAccessibility.Attribute(rawValue: "AXEnhancedUserInterface") + let previous = (app.accessibilityAttributeValue(attribute) as? Bool) ?? false + app.accessibilitySetValue(true, forAttribute: attribute) + defer { app.accessibilitySetValue(previous, forAttribute: attribute) } + let host = NSHostingView(rootView: VcsProviderToolWindowView( + feature: fixture.feature, title: "Subversion", pluginID: VcsTestData.pluginID, vcs: "svn", + actions: .init(openFile: { opened = $0 }, revealInFinder: { _ in }, copyPath: { _ in }, hide: {}))) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 420, height: 500), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = host + defer { window.contentView = nil; window.close() } + func elements(_ value: Any) -> [AnyObject] { + let item = value as AnyObject + return [item] + (item.accessibilityChildren?() ?? []).flatMap(elements) + } + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(2)) + var row: AnyObject? + repeat { + host.layoutSubtreeIfNeeded() + row = elements(host).first { item in + item.accessibilityRole?() == .button && elements(item).contains { + ($0.accessibilityLabel?() ?? "").contains("new.txt") + } + } + if row != nil { break } + await Task.yield() + } while clock.now < deadline + #expect(try #require(row).accessibilityPerformPress?() == true) + #expect(opened == fixture.url("new.txt")) + } + + // MARK: Feature model + + @Test + func detectsPluginRootsReadsStatusAndColorsOnlyTheFilesTheRootOwns() async throws { + // A Git repository nested in the working copy takes part in + // ownership but is not listed; its directory is the Git root's. + let fixture = try VcsFeatureFixture(roots: [("", "svn"), ("vendor/lib", "git")]) + defer { fixture.close() } + fixture.providers.replies["status"] = try VcsTestData.statusReply([ + ("a.txt", "modified"), ("build", "unversioned"), ("vendor/lib", "unversioned"), ("debug.log", "ignored") + ]) + + #expect(await fixture.open()) + #expect(fixture.feature.roots.map(\.id) == [""]) + #expect(fixture.feature.roots.first?.title == "workspace") + #expect(fixture.feature.roots.first?.snapshot?.head.revision?.display == "r7") + #expect(fixture.feature.vcsNames == ["svn": "Subversion", "git": "Git"]) + let feature = fixture.feature + let modifiedURL = fixture.url("a.txt") + let colored = await awaitChange(on: feature) { feature.treeStatus.status(for: modifiedURL) == .modified } + #expect(colored) + let tree = feature.treeStatus + // Rows below an unversioned directory share its status. + #expect(tree.status(for: fixture.url("build/classes/Main.class")) == .unversioned) + #expect(tree.status(for: fixture.url("vendor/lib")) == nil) + #expect(tree.status(for: fixture.url("vendor/lib/x.c")) == nil) + #expect(tree.status(for: fixture.url("debug.log")) == nil) + #expect(fixture.providers.requests == [.init(operation: .status(includeIgnored: false), root: fixture.workspaceURL.path)]) + #expect(fixture.providers.retainCount > 0) + } + + @Test + func refreshesOnlyForWorkingFilesAndDeclaredMetadataOutsideHostActions() async throws { + let fixture = try VcsFeatureFixture(roots: [("", "svn"), ("module", "svn")]) + defer { fixture.close() } + fixture.providers.replies["status"] = try VcsTestData.statusReply([]) + let workspace = fixture.workspaceURL.path + let module = fixture.url("module").path + #expect(await fixture.open()) + #expect(fixture.providers.statusCount(root: workspace) == 1) + #expect(fixture.providers.statusCount(root: module) == 1) + fixture.clock.advance(by: .seconds(3)) + + // Metadata the provider did not declare never refreshes; a working + // file does. Both arrive before the delay, so one refresh covers them. + fixture.metadataWatchers.deliver([fixture.url(".svn/tmp/x")]) + fixture.changed("module/File.java") + #expect(await fixture.settled { fixture.providers.statusCount(root: module) == 2 }) + #expect(fixture.providers.statusCount(root: workspace) == 1) + + // The host's own action writes metadata; those events are ignored + // for a short while, working files still count. + #expect(await fixture.feature.cleanup("module")) + #expect(await fixture.settled { fixture.providers.statusCount(root: module) == 3 }) + fixture.metadataWatchers.deliver([fixture.url(".svn/wc.db")]) + fixture.changed("module/Other.java") + #expect(await fixture.settled { fixture.providers.statusCount(root: module) == 4 }) + #expect(fixture.providers.statusCount(root: workspace) == 1) + + // Once the quiet period ends, the declared metadata path refreshes + // its root, as an update from the command line would. + fixture.clock.advance(by: .seconds(3)) + fixture.metadataWatchers.deliver([fixture.url(".svn/wc.db")]) + #expect(await fixture.settled { fixture.providers.statusCount(root: workspace) == 2 }) + #expect(fixture.providers.statusCount(root: module) == 4) + } + + /// The workspace watcher hides VCS metadata as the project tree does, + /// and never sees `../.svn` of a working copy that encloses the + /// workspace, so every root's metadata directory is watched on its own. + @Test + func watchesTheMetadataDirectoryOfEveryRoot() async throws { + let fixture = try VcsFeatureFixture( + workspaceURL: URL(fileURLWithPath: "/virtual/wc/app", isDirectory: true), + roots: [("..", "svn"), ("lib", "svn")] + ) + defer { fixture.close() } + fixture.providers.replies["status"] = try VcsTestData.statusReply([]) + let enclosing = fixture.workspaceURL.appendingPathComponent("..").path + let lib = fixture.url("lib").path + let metadata = URL(fileURLWithPath: "/virtual/wc/.svn", isDirectory: true) + #expect(await fixture.open()) + #expect(fixture.metadataWatchers.running == [metadata.path, fixture.url("lib/.svn").path]) + #expect(fixture.providers.statusCount(root: enclosing) == 1) + fixture.clock.advance(by: .seconds(3)) + + fixture.metadataWatchers.deliver([metadata.appendingPathComponent("wc.db")]) + #expect(await fixture.settled { fixture.providers.statusCount(root: enclosing) == 2 }) + #expect(fixture.providers.statusCount(root: lib) == 1) + // That status read starts the host's quiet period for every root. + fixture.clock.advance(by: .seconds(3)) + fixture.metadataWatchers.deliver([fixture.url("lib/.svn/wc.db")]) + #expect(await fixture.settled { fixture.providers.statusCount(root: lib) == 2 }) + #expect(fixture.providers.statusCount(root: enclosing) == 2) + + // A watch that lost its events detects the roots again. + fixture.clock.advance(by: .seconds(3)) + fixture.metadataWatchers.deliver(DirectoryChangeBatch(requiresFullRescan: true)) + #expect(await fixture.settled { fixture.planner.detectCount == 2 }) + #expect(fixture.metadataWatchers.running == [metadata.path, fixture.url("lib/.svn").path]) + + fixture.close() + #expect(fixture.metadataWatchers.running.isEmpty) + } + + /// The workspace watcher hides `.svn`, so a working copy checked out in + /// the workspace shows only as created entries beside its marker. + @Test + func detectsAWorkingCopyThatAppearsInTheWorkspace() async throws { + let fixture = try VcsFeatureFixture(roots: [("", "svn")]) + defer { fixture.close() } + fixture.providers.replies["status"] = try VcsTestData.statusReply([]) + fixture.files.add(fixture.url(".svn"), fixture.url("plugins/.svn")) + #expect(await fixture.open()) + fixture.clock.advance(by: .seconds(3)) + + // A created entry and the directory holding it are looked up unless + // they are a known root; a changed file is not looked up at all. + let lookedUp = fixture.files.gate(afterLookups: 3) + fixture.changed("plugins/Plugin.java") + fixture.created("README.md", "src/Main.java") + #expect(await lookedUp.waitUntilOpen(timeout: .seconds(5))) + #expect(Set(fixture.files.lookups) + == Set(["README.md/.svn", "src/Main.java/.svn", "src/.svn"].map { fixture.url($0).path })) + + fixture.created("plugins/Plugin.java") + #expect(await fixture.settled { fixture.planner.detectCount == 2 }) + #expect(!fixture.files.lookups.contains(fixture.url(".svn").path)) + } + + @Test + func runsOneWorkingCopyActionAtATime() async throws { + let fixture = try VcsFeatureFixture(roots: [("", "svn")]) + defer { fixture.close() } + fixture.providers.replies["status"] = try VcsTestData.statusReply([("a.txt", "modified")]) + fixture.providers.replies["checkin"] = try VcsTestData.json(["revision": ["id": "8", "display": "r8", "kind": "number"]]) + #expect(await fixture.open()) + let checkinHeld = TestGate() + fixture.providers.holds["checkin"] = checkinHeld + defer { checkinHeld.open() } + + let feature = fixture.feature + let commit = Task { await feature.checkin(["a.txt"], message: "Fix", in: "") } + #expect(await awaitChange(on: feature) { feature.runningOperation?.rootID == "" }) + #expect(await feature.rollback(["a.txt"], in: "") == false) + #expect(feature.failure?.message == String(localized: "Wait for the running version control action to finish.")) + + checkinHeld.open() + #expect(await commit.value?.revision?.display == "r8") + #expect(feature.runningOperation == nil) + #expect(fixture.providers.requests.map(\.operation.name).filter { $0 != "status" } == ["checkin"]) + #expect(fixture.providers.requests.contains( + .init(operation: .checkin(paths: ["a.txt"], message: "Fix"), root: fixture.workspaceURL.path) + )) + } + + @Test + func movesTrackedItemsThroughTheProviderAndLeavesTheRestToTheDisk() async throws { + let fixture = try VcsFeatureFixture( + roots: [("", "svn"), ("module", "svn"), ("lib", "git")], ambiguous: ["mixed"] + ) + defer { fixture.close() } + fixture.providers.replies["status"] = try VcsTestData.statusReply([ + ("a.txt", "modified"), ("new.txt", "unversioned"), ("out", "unversioned") + ]) + #expect(await fixture.open()) + let feature = fixture.feature + #expect(await awaitChange(on: feature) { feature.treeStatus.status(for: fixture.url("new.txt")) != nil }) + + #expect(await feature.move(from: fixture.url("a.txt"), to: fixture.url("docs/a.txt"))) + #expect(fixture.providers.requests.contains( + .init(operation: .move(from: "a.txt", to: "docs/a.txt"), root: fixture.workspaceURL.path) + )) + // The VCS does not track an unversioned item, cannot move into an + // unversioned directory, and cannot move between working copies. + #expect(await feature.move(from: fixture.url("new.txt"), to: fixture.url("docs/new.txt")) == false) + #expect(await feature.move(from: fixture.url("b.txt"), to: fixture.url("out/b.txt")) == false) + #expect(await feature.move(from: fixture.url("b.txt"), to: fixture.url("module/b.txt")) == false) + // A nested Git repository and a directory with no VCS chosen are not + // the working copy's, on either end of the move. + #expect(await feature.move(from: fixture.url("b.txt"), to: fixture.url("lib/b.txt")) == false) + #expect(await feature.move(from: fixture.url("lib/c.txt"), to: fixture.url("c.txt")) == false) + #expect(await feature.move(from: fixture.url("b.txt"), to: fixture.url("mixed/b.txt")) == false) + #expect(await feature.move(from: fixture.url("mixed/c.txt"), to: fixture.url("c.txt")) == false) + #expect(fixture.providers.requests.filter { $0.operation.name == "fileOperation" }.count == 1) + + // A refusal is reported; the caller then moves the item on disk. + fixture.providers.failures["fileOperation"] = VcsProviderFailure(code: "vcs.provider.failed", message: "E155010") + #expect(await feature.move(from: fixture.url("b.txt"), to: fixture.url("c.txt")) == false) + #expect(feature.failure?.message == "E155010") + } + + /// An action that starts while a move asks Core who owns its paths + /// leaves the move to the disk, as one running before would, instead of + /// reporting the move as refused. + @Test + func moveYieldsToAnActionThatStartsWhileOwnershipIsChecked() async throws { + let fixture = try VcsFeatureFixture(roots: [("", "svn")]) + defer { fixture.close() } + fixture.providers.replies["status"] = try VcsTestData.statusReply([("a.txt", "modified")]) + fixture.providers.replies["checkin"] = try VcsTestData.json(["revision": ["id": "8", "display": "r8", "kind": "number"]]) + #expect(await fixture.open()) + let ownersEntered = TestGate() + let ownersReleased = TestGate() + let checkinHeld = TestGate() + fixture.planner.heldOwners = (paths: ["b.txt"], entered: ownersEntered, released: ownersReleased) + fixture.providers.holds["checkin"] = checkinHeld + defer { + ownersReleased.open() + checkinHeld.open() + } + + let feature = fixture.feature + let source = fixture.url("b.txt") + let destination = fixture.url("docs/b.txt") + let move = Task { await feature.move(from: source, to: destination) } + #expect(await ownersEntered.waitUntilOpen(timeout: .seconds(5))) + let commit = Task { await feature.checkin(["a.txt"], message: "Fix", in: "") } + #expect(await awaitChange(on: feature) { feature.runningOperation?.rootID == "" }) + ownersReleased.open() + + #expect(await move.value == false) + #expect(feature.failure == nil) + checkinHeld.open() + #expect(await commit.value?.revision?.display == "r8") + #expect(!fixture.providers.requests.contains { $0.operation.name == "fileOperation" }) + #expect(!fixture.planner.ownersTimedOut) + } + + @Test + func remembersTheChosenVcsPerWorkspaceAndForgetsChoicesNoLongerNeeded() async throws { + let fixture = try VcsFeatureFixture(roots: [("", "svn")], ambiguous: ["mixed"]) + defer { fixture.close() } + fixture.providers.replies["status"] = try VcsTestData.statusReply([]) + #expect(await fixture.open()) + #expect(fixture.feature.ambiguous.map(\.id) == ["mixed"]) + let store = MacVcsRootMappingStore(store: fixture.keyValues) + let choice = VcsRootMapping(id: "mixed", vcs: "svn") + + fixture.feature.map("mixed", to: "svn") + #expect(await fixture.settled { fixture.planner.detectCount == 2 }) + #expect(fixture.planner.lastMappings == [choice]) + #expect(store.mappings(workspaceURL: fixture.workspaceURL) == [choice]) + #expect(store.mappings(workspaceURL: fixture.url("other")).isEmpty) + + // Core reports the choice unused once the directory is no longer + // ambiguous, and the host drops it. + fixture.planner.detection = try VcsTestData.detection( + roots: [("", "svn")], workspaceURL: fixture.workspaceURL, unusedMappings: [choice] + ) + fixture.feature.detect() + #expect(await fixture.settled { fixture.planner.detectCount == 3 }) + #expect(store.mappings(workspaceURL: fixture.workspaceURL).isEmpty) + #expect(fixture.keyValues.isEmpty) + } + + @Test + func mappingStoreKeepsChoicesSortedPerWorkspace() { + let keyValues = VcsTestKeyValueStore() + let store = MacVcsRootMappingStore(store: keyValues) + let workspace = URL(fileURLWithPath: "/virtual/a") + let mappings = [VcsRootMapping(id: "z", vcs: "svn"), VcsRootMapping(id: "a", vcs: "git")] + + store.setMappings(mappings, workspaceURL: workspace) + #expect(store.mappings(workspaceURL: workspace) == mappings.reversed()) + #expect(store.mappings(workspaceURL: URL(fileURLWithPath: "/virtual/b")).isEmpty) + store.setMappings([], workspaceURL: workspace) + #expect(keyValues.isEmpty) + } + + @Test + func providerFailuresLeadWithTheHintTheContractNames() { + let outOfDate = VcsProviderFailure(code: "vcs.outOfDate", message: "E160024: out of date") + #expect(VcsProvidersFeatureModel.message(for: outOfDate) + == String(localized: "The working copy is out of date. Update it, then commit again.") + "\nE160024: out of date") + #expect(VcsProvidersFeatureModel.message(for: VcsProviderFailure(code: "vcs.workingCopy.locked", message: "")) + == String(localized: "The working copy is locked by an interrupted action. Run Cleanup, then try again.")) + #expect(VcsProvidersFeatureModel.message(for: VcsProviderFailure(code: "vcs.provider.failed", message: "boom")) + == "boom") + } + + @Test + func rootPathsMapToTheWorkspaceEvenFromAnEnclosingWorkingCopy() { + let workspace = URL(fileURLWithPath: "/virtual/wc/app/web") + #expect(VcsProvidersFeatureModel.workspacePath("a.txt", rootID: "", workspaceURL: workspace) == "a.txt") + #expect(VcsProvidersFeatureModel.workspacePath("a.txt", rootID: "lib", workspaceURL: workspace) == "lib/a.txt") + // The workspace opened two levels inside the working copy. + #expect(VcsProvidersFeatureModel.workspacePath("app/web/src/a.txt", rootID: "../..", workspaceURL: workspace) + == "src/a.txt") + #expect(VcsProvidersFeatureModel.workspacePath("app/web", rootID: "../..", workspaceURL: workspace) == "") + #expect(VcsProvidersFeatureModel.workspacePath("app/webapp/a.txt", rootID: "../..", workspaceURL: workspace) == nil) + #expect(VcsProvidersFeatureModel.workspacePath("README", rootID: "../..", workspaceURL: workspace) == nil) + // Core's rule: only IDs made of `..` segments enclose the workspace; + // a folder inside it named `..repo` is an ordinary nested root. + #expect(VcsProvidersFeatureModel.workspacePath("a.txt", rootID: "..repo", workspaceURL: workspace) == "..repo/a.txt") + #expect(VcsRootState.isEnclosingRootID("..") && VcsRootState.isEnclosingRootID("../..")) + #expect(!VcsRootState.isEnclosingRootID("..repo") && !VcsRootState.isEnclosingRootID("../..repo") + && !VcsRootState.isEnclosingRootID("")) + } + + // MARK: Tool window rules + + @Test + func changeListPutsConflictsFirstAndIncludesOnlyVersionedChangesByDefault() throws { + let changes = [ + VcsChange(path: "z.txt", status: .modified), + VcsChange(path: "new.txt", status: .unversioned), + VcsChange(path: "b.txt", status: .modified, flags: ["treeConflict"]), + VcsChange(path: "a.txt", status: .conflicted), + VcsChange(path: "c.txt", status: .added), + VcsChange(path: "debug.log", status: .ignored) + ] + let groups = VcsProviderToolWindowView.groups(changes) + + #expect(groups.map(\.group) == [.conflicts, .changes, .unversioned]) + #expect(groups.map { $0.changes.map(\.path) } == [["a.txt", "b.txt"], ["c.txt", "z.txt"], ["new.txt"]]) + #expect(changes.filter(VcsProviderToolWindowView.isIncludedByDefault).map(\.path) == ["z.txt", "c.txt"]) + } + + /// A provider declares no icon; its window and command show the host's + /// version control icon, not the generic tool window one. + @Test + func providerWindowsShowTheVersionControlIcon() { + let contributions = DeclarativeVcsProvider(id: "svn", displayName: "Subversion") + .contributions(pluginID: VcsTestData.pluginID, order: 0) + #expect(contributions.map(\.uiIcon) == [PluginUIIcon(host: "git"), PluginUIIcon(host: "git")]) + let asset = WorkbenchContributionIcon.Asset(contributions[0].uiIcon, resources: nil) + #expect(asset.hostPath == "expui/toolwindows/vcs@20x20.svg") + } + + @Test + func commitCarriesBothHalvesOfAMoveOnce() { + let paths = VcsProviderToolWindowView.commitPaths([ + VcsChange(path: "src/New.java", oldPath: "src/Old.java", status: .renamed), + VcsChange(path: "src/Old.java", status: .deleted), + VcsChange(path: "README", status: .modified) + ]) + #expect(paths == ["src/New.java", "src/Old.java", "README"]) + } + + @Test + func headAndHistoryDatesReadAsTheProviderSentThem() throws { + let head = try JSONDecoder().decode(VcsHead.self, from: Data( + #"{"revision":{"id":"7","display":"r7","kind":"number"},"branch":"trunk","location":null}"#.utf8 + )) + #expect(VcsProviderToolWindowView.headSummary(head) == "r7 · trunk") + + let date = try #require(ISO8601DateFormatter().date(from: "2026-10-22T08:30:00Z")) + let expected = date.formatted(date: .abbreviated, time: .shortened) + #expect(VcsHistoryListView.displayDate("2026-10-22T08:30:00Z") == expected) + #expect(VcsHistoryListView.displayDate("2026-10-22T08:30:00.250Z") == expected) + #expect(VcsHistoryListView.displayDate("yesterday") == "yesterday") + } + + // MARK: Rust Core and install review + + @Test + func rustCoreDetectsAPluginRootBesideNestedGitAndValidatesReplies() throws { + let workspace = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-vcs-host-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: workspace) } + try FileManager.default.createDirectory(at: workspace.appendingPathComponent(".svn"), withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: workspace.appendingPathComponent("vendor/lib/.git"), withIntermediateDirectories: true + ) + let descriptor = try DeclarativePluginTestSupport.operations.validate(manifest: VcsTestData.svnProviderManifest()) + let planner = RustVcsOperations(core: RustCoreBridge()) + + let detection = try planner.detectRoots( + workspaceURL: workspace, manifests: [descriptor.normalizedManifest], mappings: [] + ) + #expect(Set(detection.roots.map { "\($0.vcs):\($0.id)" }) == ["svn:", "git:vendor/lib"]) + let svn = try #require(detection.providers.first { $0.vcs == "svn" }) + #expect(svn.origin.pluginID == descriptor.id.rawValue) + #expect(svn.metadataPaths == [".svn/wc.db"]) + #expect(try planner.owners( + roots: detection.roots.map(\.id), ambiguous: [], paths: ["a.txt", "vendor/lib/b.c", "vendor/c.txt"] + ) == ["", "vendor/lib", ""]) + + let snapshot = try planner.decodeReply( + .status(includeIgnored: false), capabilities: ["status": 1], + reply: VcsTestData.statusReply([("z.txt", "modified"), ("a.txt", "added")]), as: VcsStatusSnapshot.self + ) + #expect(snapshot.changes.map(\.path) == ["a.txt", "z.txt"]) + do { + _ = try planner.decodeReply( + .status(includeIgnored: false), capabilities: ["status": 1], + reply: VcsTestData.statusReply([("a.txt", "obstructed")]), as: VcsStatusSnapshot.self + ) + Issue.record("Core accepted an unknown status") + } catch let failure as VcsProviderFailure { + #expect(failure.code == "plugin.vcs.invalidOutput") + } + } + + /// A VCS client that cannot be found is the contract's + /// `vcs.tool.unavailable`, so the tool window leads with its install hint. + @Test + func aVcsClientThatCannotBeFoundIsReportedAsUnavailable() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-vcs-tool-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let packageURL = root.appendingPathComponent("package", isDirectory: true) + let workspaceURL = root.appendingPathComponent("workspace", isDirectory: true) + try FileManager.default.createDirectory(at: workspaceURL, withIntermediateDirectories: true) + let object = try #require( + try JSONSerialization.jsonObject(with: VcsTestData.svnProviderManifest()) as? [String: Any] + ) + _ = try DeclarativePluginTestSupport.writePackage(at: packageURL, manifest: object) + let entry = packageURL.appendingPathComponent("provider/main.mjs") + try FileManager.default.createDirectory(at: entry.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data().write(to: entry) + let operations = DeclarativePluginTestSupport.operations + let descriptor = try operations.validate(manifest: Data(contentsOf: packageURL.appendingPathComponent("plugin.json"))) + let sessions = MacVcsProviderSessions( + launchResolver: MacPluginLanguageServerLaunchResolver( + planner: operations, + directories: MacPluginStateDirectories(rootURL: root.appendingPathComponent("state", isDirectory: true)), + settings: InMemoryPluginSettings() + ), + tools: VcsTestTools(), + versionChecker: operations, + runtime: VcsTestToolRuntime(), + core: RustCoreBridge(), + processRegistry: nil, + endBackendProcesses: { _, _ in } + ) + let target = VcsProviderTarget( + pluginID: descriptor.id.rawValue, vcs: "svn", manifest: descriptor.normalizedManifest, + packageURL: packageURL, workspaceURL: workspaceURL + ) + defer { sessions.retainProviders { _ in false } } + + do { + _ = try await sessions.negotiation(for: target) + Issue.record("The provider started without its VCS client") + } catch let failure as VcsProviderFailure { + #expect(failure.code == "vcs.tool.unavailable") + #expect(failure.message == VcsTestTools.NotFound().localizedDescription) + } + } + + @Test + func installReviewShowsTheProviderCommandClientAndNetworkUse() throws { + let review = try PluginMarketplaceTestSupport.evaluator.installReview( + manifest: VcsTestData.svnProviderManifest(), + expectedID: nil, expectedVersion: nil, approvedPrograms: nil + ) + let provider = try #require(review.programs.first { $0.kind == .runtimeVcsProvider }) + #expect(provider.vcs == "svn") + #expect(provider.tool == "node") + #expect(provider.vcsClientCommands == ["svn"]) + #expect(provider.mayUseNetwork) + + let description = PluginMarketplacePresentation.programDescription(provider, locale: Locale(identifier: "en")) + #expect(description.title == "svn provider (runs with node)") + let lines = description.detail.components(separatedBy: "\n") + #expect(lines.first?.hasPrefix("node ") == true, "\(lines)") + #expect(lines.first?.contains("provider/main.mjs") == true, "\(lines)") + #expect(lines.dropFirst() == ["Client: svn", "May connect to the repository server"]) + } +} + +/// The provider feature through the production app model: project tree +/// moves and the project window's teardown. +@Suite("VCS providers in the app model", .serialized) +@MainActor +struct VcsProviderAppModelTests { + /// A tracked file goes to the provider, an unversioned one stays a plain + /// disk move. + @Test + func projectTreeMovesGoThroughTheProviderThatTracksTheFile() async throws { + let root = try Self.makeWorkspace() + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root.appendingPathComponent("docs"), withIntermediateDirectories: true) + try Data("alpha".utf8).write(to: root.appendingPathComponent("a.txt")) + try Data("beta".utf8).write(to: root.appendingPathComponent("b.txt")) + func url(_ path: String) -> URL { root.appendingPathComponent(path).standardizedFileURL } + + let fixture = try VcsFeatureFixture(workspaceURL: root, roots: [("", "svn")]) + defer { fixture.close() } + fixture.providers.replies["status"] = try VcsTestData.statusReply([("b.txt", "unversioned")]) + // Like `svn move`, the provider moves the file itself. + fixture.providers.onFileOperation = { operation, rootPath in + guard case .move(let from, let to) = operation else { return } + let base = URL(fileURLWithPath: rootPath) + try FileManager.default.moveItem(at: base.appendingPathComponent(from), to: base.appendingPathComponent(to)) + } + let model = Self.makeModel(fixture) + defer { model.workspaceFeature.reset() } + + model.openProjectDirectly(root) + #expect(await awaitChange(on: model) { model.projectFiles.contains(url("b.txt")) }) + model.loadVcsProviders(at: root) + #expect(await fixture.settled()) + #expect(await awaitChange(on: fixture.feature) { fixture.feature.treeStatus.status(for: url("b.txt")) != nil }) + + await model.moveProjectItems([url("a.txt")], to: url("docs")) + #expect(fixture.providers.requests.contains(.init(operation: .move(from: "a.txt", to: "docs/a.txt"), root: root.path))) + #expect(FileManager.default.fileExists(atPath: url("docs/a.txt").path)) + #expect(!FileManager.default.fileExists(atPath: url("a.txt").path)) + + await model.moveProjectItems([url("b.txt")], to: url("docs")) + #expect(fixture.providers.requests.filter { $0.operation.name == "fileOperation" }.count == 1) + #expect(FileManager.default.fileExists(atPath: url("docs/b.txt").path)) + // Not tracking a file is no failure; the disk move reports as usual. + #expect(fixture.feature.failure == nil) + } + + /// Closing the native project window ends the session without closing + /// the workspace. The workspace's providers stop all the same, without + /// waiting for a status read still in flight, and a workspace load that + /// lands after the close does not start them again. + @Test + func closingTheProjectWindowStopsTheWorkspaceProviders() async throws { + let root = try Self.makeWorkspace() + defer { try? FileManager.default.removeItem(at: root) } + let file = root.appendingPathComponent("a.txt").standardizedFileURL + try Data("alpha".utf8).write(to: file) + let fixture = try VcsFeatureFixture(workspaceURL: root, roots: [("", "svn")]) + defer { fixture.close() } + fixture.providers.replies["status"] = try VcsTestData.statusReply([("a.txt", "modified")]) + let model = Self.makeModel(fixture) + defer { model.workspaceFeature.reset() } + + model.openProjectDirectly(root) + #expect(await awaitChange(on: model) { model.projectFiles.contains(file) }) + model.loadVcsProviders(at: root) + #expect(await fixture.settled()) + #expect(fixture.providers.running.map(\.workspaceURL) == [root]) + let statusHeld = TestGate() + fixture.providers.holds["status"] = statusHeld + defer { statusHeld.open() } + let feature = fixture.feature + feature.refresh("") + #expect(feature.root("")?.isRefreshing == true) + + await model.shutdownProjectSession() + // A scan that was still loading the workspace finishes afterwards. + model.loadVcsProviders(at: root) + + #expect(fixture.providers.running.isEmpty) + #expect(feature.workspaceURL == nil) + #expect(feature.roots.isEmpty) + #expect(!feature.isDetecting) + } + + private static func makeWorkspace() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-vcs-model-\(UUID().uuidString)", isDirectory: true) + .standardizedFileURL + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } + + private static func makeModel(_ fixture: VcsFeatureFixture) -> AppModel { + let store = VcsTestKeyValueStore() + let settings = AppSettings(store: store) + let services = MacServiceContainer( + store: store, settings: settings, moduleLaunchMode: .safeMode, platformUI: VcsMoveTestUI() + ).services + services.vcsProviders = fixture.feature + return AppModel(settings: settings, services: services) + } +} + +// MARK: - Test doubles + +private enum VcsTestData { + static let pluginID = "dev.example.svn" + + /// The contract's Subversion provider manifest. The real plugin is a + /// community plugin in its own repository. + static func svnProviderManifest() throws -> Data { + try Data(contentsOf: DeclarativePluginTestSupport.repositoryRoot + .appendingPathComponent("shared/contracts/plugins/v1/fixtures/vcs-provider.plugin.json")) + } + + static func detection( + roots: [(id: String, vcs: String)], + workspaceURL: URL, + ambiguous: [String] = [], + unusedMappings: [VcsRootMapping] = [] + ) throws -> VcsDetection { + let object: [String: Any] = [ + "providers": [ + [ + "vcs": "svn", "displayName": "Subversion", + "origin": ["kind": "plugin", "pluginID": pluginID], + "markers": [["name": ".svn", "type": "directory"]], "metadataPaths": [".svn/wc.db"] + ], + [ + "vcs": "git", "displayName": "Git", "origin": ["kind": "builtIn"], + "markers": [["name": ".git", "type": "directory"]] + ] + ], + "roots": roots.map { root -> [String: Any] in + let path = root.id.isEmpty ? workspaceURL.path : workspaceURL.appendingPathComponent(root.id).path + return ["id": root.id, "vcs": root.vcs, "path": path, "mapped": false] + }, + "ambiguous": ambiguous.map { ["id": $0, "vcs": ["git", "svn"]] }, + "unusedMappings": unusedMappings.map { ["id": $0.id, "vcs": $0.vcs] }, + "truncated": false, "rejected": [], "conflicts": [] + ] + return try JSONDecoder().decode(VcsDetection.self, from: JSONSerialization.data(withJSONObject: object)) + } + + static func json(_ object: Any) throws -> ToolingJSONValue { + try JSONDecoder().decode(ToolingJSONValue.self, from: JSONSerialization.data(withJSONObject: object)) + } + + static func statusReply(_ changes: [(path: String, status: String)]) throws -> ToolingJSONValue { + try json([ + "head": [ + "revision": ["id": "7", "display": "r7", "kind": "number"], + "branch": "trunk", "location": "file:///repo/trunk" + ], + "changes": changes.map { ["path": $0.path, "oldPath": NSNull(), "status": $0.status, "flags": []] }, + "truncated": false + ]) + } +} + +/// Stands in for Rust Core: detection answers from a fixed value, ownership +/// follows Core's deepest-root rule, replies decode as sent. +private final class FakeVcsPlanner: VcsRootPlanning, @unchecked Sendable { + private let lock = NSLock() + private var storedDetection: VcsDetection + private var detections = 0 + private var mappings: [VcsRootMapping] = [] + private var hold: (paths: Set, entered: TestGate, released: TestGate)? + private var holdTimedOut = false + + init(detection: VcsDetection) { + storedDetection = detection + } + + var detection: VcsDetection { + get { lock.withLock { storedDetection } } + set { lock.withLock { storedDetection = newValue } } + } + + var detectCount: Int { lock.withLock { detections } } + var lastMappings: [VcsRootMapping] { lock.withLock { mappings } } + var ownersTimedOut: Bool { lock.withLock { holdTimedOut } } + + /// Ownership asked for any of `paths` opens `entered`, then answers once + /// `released` opens. + var heldOwners: (paths: Set, entered: TestGate, released: TestGate)? { + get { lock.withLock { hold } } + set { lock.withLock { hold = newValue } } + } + + func detectRoots(workspaceURL: URL, manifests: [Data], mappings: [VcsRootMapping]) throws -> VcsDetection { + lock.withLock { + detections += 1 + self.mappings = mappings + return storedDetection + } + } + + func owners(roots: [String], ambiguous: [String], paths: [String]) throws -> [String?] { + if let hold = heldOwners, paths.contains(where: hold.paths.contains) { + hold.entered.open() + // This synchronous protocol runs on the feature's detached ownership task. + let released = hold.released.waitSynchronously(timeout: 5) + lock.withLock { holdTimedOut = holdTimedOut || !released } + } + func contains(_ id: String, _ path: String) -> Bool { + id.isEmpty || id.hasPrefix("..") || path == id || path.hasPrefix(id + "/") + } + func depth(_ id: String) -> Int { + id.isEmpty ? 0 : id.split(separator: "/").count * (id.hasPrefix("..") ? -1 : 1) + } + return paths.map { path in + let root = roots.filter { contains($0, path) }.max { depth($0) < depth($1) } + let directory = ambiguous.filter { contains($0, path) }.map(depth).max() + if let root, let directory, directory > depth(root) { return nil } + return root + } + } + + func decodeReply( + _ operation: VcsOperation, + capabilities: [String: Int], + reply: ToolingJSONValue, + as type: Value.Type + ) throws -> Value { + try JSONDecoder().decode(Value.self, from: JSONEncoder().encode(reply)) + } +} + +/// Provider processes that answer every capability from canned replies. +@MainActor +private final class FakeVcsProviders: VcsProviderRequesting { + struct Request: Equatable { + let operation: VcsOperation + let root: String + } + + var replies: [String: ToolingJSONValue] = [:] + var failures: [String: VcsProviderFailure] = [:] + /// Requests that wait for the gate before answering. + var holds: [String: TestGate] = [:] + var onFileOperation: ((VcsOperation, String) throws -> Void)? + private(set) var requests: [Request] = [] + private(set) var retainCount = 0 + /// Targets that answered a request and were not stopped since. + private(set) var running: Set = [] + + func statusCount(root: String) -> Int { + requests.filter { $0.operation.name == "status" && $0.root == root }.count + } + + func negotiation(for target: VcsProviderTarget) async throws -> VcsProviderNegotiation { + Self.negotiation(target.vcs) + } + + func send( + _ operation: VcsOperation, + root: String, + to target: VcsProviderTarget, + timeout: Duration + ) async throws -> VcsProviderReply { + requests.append(Request(operation: operation, root: root)) + running.insert(target) + if let hold = holds[operation.name] { + _ = await hold.waitUntilOpen(timeout: .seconds(5)) + } + if let failure = failures[operation.name] { throw failure } + if operation.name == "fileOperation" { try onFileOperation?(operation, root) } + return VcsProviderReply(value: replies[operation.name] ?? .object([:]), negotiation: Self.negotiation(target.vcs)) + } + + func retainProviders(_ keep: (VcsProviderTarget) -> Bool) { + retainCount += 1 + running = running.filter(keep) + } + + private static func negotiation(_ vcs: String) -> VcsProviderNegotiation { + VcsProviderNegotiation( + vcs: vcs, capabilities: Dictionary(uniqueKeysWithValues: VcsCapability.allCases.map { ($0.rawValue, 1) }) + ) + } +} + +/// Metadata directory watchers that deliver only what a test sends. +private final class FakeMetadataWatchers: DirectoryWatcherFactory, @unchecked Sendable { + final class Watcher: DirectoryChangeSource, @unchecked Sendable { + let directory: URL + let onChange: @Sendable (DirectoryChangeBatch) -> Void + private(set) var isRunning = false + + init(directory: URL, onChange: @escaping @Sendable (DirectoryChangeBatch) -> Void) { + self.directory = directory + self.onChange = onChange + } + + func start() { isRunning = true } + func stop() { isRunning = false } + } + + private(set) var watchers: [Watcher] = [] + + /// The paths of the directories watched now. + var running: [String] { watchers.filter(\.isRunning).map(\.directory.path).sorted() } + + func make( + configuration: DirectoryWatchConfiguration, + visibilityRules: FileVisibilityRules, + onChange: @escaping @Sendable (DirectoryChangeBatch) -> Void + ) -> any DirectoryChangeSource { + let watcher = Watcher(directory: configuration.workspaceRoot, onChange: onChange) + watchers.append(watcher) + return watcher + } + + /// Delivers each path to the running watcher of the directory holding it. + func deliver(_ urls: [URL]) { + for watcher in watchers where watcher.isRunning { + let paths = urls.map(\.path).filter { $0.hasPrefix(watcher.directory.path + "/") } + if !paths.isEmpty { watcher.onChange(DirectoryChangeBatch(workspacePaths: paths)) } + } + } + + func deliver(_ batch: DirectoryChangeBatch) { + watchers.filter(\.isRunning).forEach { $0.onChange(batch) } + } +} + +/// A feature model over the fakes. The refresh delay passes at once and +/// the clock moves only when a test advances it. +@MainActor +private final class VcsFeatureFixture { + let workspaceURL: URL + let planner: FakeVcsPlanner + let providers = FakeVcsProviders() + let metadataWatchers = FakeMetadataWatchers() + let files = VcsTestFiles() + let keyValues = VcsTestKeyValueStore() + let clock = VcsTestClock() + let feature: VcsProvidersFeatureModel + + init( + workspaceURL: URL = URL(fileURLWithPath: "/virtual/workspace", isDirectory: true), + roots: [(id: String, vcs: String)], + ambiguous: [String] = [] + ) throws { + self.workspaceURL = workspaceURL.standardizedFileURL + planner = FakeVcsPlanner(detection: try VcsTestData.detection( + roots: roots, workspaceURL: self.workspaceURL, ambiguous: ambiguous + )) + let clock = clock + let files = files + feature = VcsProvidersFeatureModel( + planner: planner, + providers: providers, + mappingStore: MacVcsRootMappingStore(store: keyValues), + fileExists: { files.exists($0) }, + sleep: { _ in try Task.checkCancellation() }, + now: { clock.now }, + metadataWatcherFactory: metadataWatchers, + sources: { + [VcsProviderPluginSource( + pluginID: VcsTestData.pluginID, manifest: Data("{}".utf8), + packageURL: URL(fileURLWithPath: "/virtual/plugin", isDirectory: true), + providers: [DeclarativeVcsProvider(id: "svn", displayName: "Subversion")] + )] + } + ) + } + + func url(_ path: String) -> URL { + workspaceURL.appendingPathComponent(path).standardizedFileURL + } + + /// Reports changed workspace entries, as the workspace watcher does. + func changed(_ paths: String...) { + feature.workspaceFilesChanged(paths.map { WorkspaceFileChange(fileURL: url($0), kind: .changed) }) + } + + /// Reports created workspace entries, as the workspace watcher does. + func created(_ paths: String...) { + feature.workspaceFilesChanged(paths.map { WorkspaceFileChange(fileURL: url($0), kind: .created) }) + } + + /// Opens the workspace and waits until every root has read its status. + func open() async -> Bool { + feature.reset(workspaceURL: workspaceURL) + return await settled() + } + + /// Waits until detection and every status read have finished and + /// `condition` holds, so a refresh that should not happen would have + /// been requested by then. + func settled(_ condition: @escaping @MainActor @Sendable () -> Bool = { true }) async -> Bool { + await awaitChange(on: feature) { [feature] in + condition() && !feature.isDetecting && !feature.roots.isEmpty + && feature.roots.allSatisfy { $0.snapshot != nil && !$0.isRefreshing } + } + } + + func close() { + feature.reset(workspaceURL: nil) + } +} + +/// Finds the provider's runtime but no VCS client. +private final class VcsTestTools: BuildToolResolving { + struct NotFound: LocalizedError { + var errorDescription: String? { "svn was not found." } + } + + func resolve( + _ invocation: BuildInvocation, displayName _: String, workspaceURL _: URL, + checkVersion _: @escaping @Sendable (String?, String) throws -> BuildVersionCheck + ) async throws -> ResolvedBuildTool { + guard invocation.executables == [.runtime("node")] else { throw NotFound() } + return ResolvedBuildTool( + executableURL: URL(fileURLWithPath: "/usr/bin/false"), environment: [:], executesProjectCode: false, version: nil + ) + } +} + +private final class VcsTestToolRuntime: LanguageToolRuntimePort { + func executableOnPath(_: String) -> URL? { nil } + func executableURL(at _: String) -> URL? { nil } + func executableCandidates(_: String) -> [RuntimeToolCandidate] { [] } + func languageToolProcessEnvironment(runtimeTools _: [String]) -> [String: String] { [:] } + func missingLanguageToolMessage(_ name: String) -> String { name } +} + +/// The paths a marker lookup finds, and the lookups made. +private final class VcsTestFiles: @unchecked Sendable { + private let lock = NSLock() + private var existing: Set = [] + private var recorded: [String] = [] + private var waiter: (count: Int, gate: TestGate)? + + var lookups: [String] { lock.withLock { recorded } } + + func add(_ urls: URL...) { + lock.withLock { existing.formUnion(urls.map(\.path)) } + } + + /// A gate that opens once `count` lookups have been made. + func gate(afterLookups count: Int) -> TestGate { + let gate = TestGate() + let reached = lock.withLock { + waiter = (count, gate) + return recorded.count >= count + } + if reached { gate.open() } + return gate + } + + func exists(_ url: URL) -> Bool { + let (found, gate) = lock.withLock { () -> (Bool, TestGate?) in + recorded.append(url.path) + let gate = waiter.flatMap { recorded.count >= $0.count ? $0.gate : nil } + return (existing.contains(url.path), gate) + } + gate?.open() + return found + } +} + +private final class VcsTestClock: @unchecked Sendable { + private let lock = NSLock() + private var instant = ContinuousClock.now + + var now: ContinuousClock.Instant { lock.withLock { instant } } + + func advance(by duration: Duration) { + lock.withLock { instant += duration } + } +} + +private final class VcsTestKeyValueStore: KeyValueStore, @unchecked Sendable { + private var values: [String: Any] = [:] + + var isEmpty: Bool { values.isEmpty } + + 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 } +} + +private final class VcsMoveTestUI: PlatformUI { + func copyFilesToClipboard(_ urls: [URL]) -> Bool { false } + func fileURLsFromClipboard() -> [URL] { [] } + 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) {} +} diff --git a/macos/Tests/LitheTests/WebMediaCaptureTests.swift b/macos/Tests/LitheTests/WebMediaCaptureTests.swift new file mode 100644 index 0000000000..1edf39beda --- /dev/null +++ b/macos/Tests/LitheTests/WebMediaCaptureTests.swift @@ -0,0 +1,312 @@ +import AppKit +import Foundation +import Testing +import WebKit +@testable import Lithe + +struct WebMediaAccessTests { + @Test func captureTypesMapToTheDevicesTheyNeed() { + #expect(WebMediaDevices(.camera) == .camera) + #expect(WebMediaDevices(.microphone) == .microphone) + #expect(WebMediaDevices(.cameraAndMicrophone) == [.camera, .microphone]) + } + + @Test func aRequestIsAskedOnlyWhenEveryRequestedDeviceHasAUsageDescription() { + let both = MockCapture.usageDescriptions + #expect(WebMediaAccess.outcome(for: .camera, usageDescriptions: both) == .ask) + #expect(WebMediaAccess.outcome(for: .microphone, usageDescriptions: both) == .ask) + #expect(WebMediaAccess.outcome(for: [.camera, .microphone], usageDescriptions: both) == .ask) + + let cameraOnly: [String: Any] = [WebMediaAccess.cameraUsageKey: "Camera"] + #expect(WebMediaAccess.outcome(for: .camera, usageDescriptions: cameraOnly) == .ask) + #expect(WebMediaAccess.outcome(for: .microphone, usageDescriptions: cameraOnly) == .unavailable(missing: .microphone)) + // One missing description is enough: the system would end the app when the microphone is touched. + #expect( + WebMediaAccess.outcome(for: [.camera, .microphone], usageDescriptions: cameraOnly) + == .unavailable(missing: .microphone) + ) + } + + @Test func missingBlankOrMalformedDescriptionsCountAsMissing() { + #expect(WebMediaAccess.outcome(for: .camera, usageDescriptions: nil) == .unavailable(missing: .camera)) + #expect(WebMediaAccess.outcome(for: .camera, usageDescriptions: [:]) == .unavailable(missing: .camera)) + #expect( + WebMediaAccess.outcome(for: .camera, usageDescriptions: [WebMediaAccess.cameraUsageKey: " \n"]) + == .unavailable(missing: .camera) + ) + #expect( + WebMediaAccess.outcome(for: .camera, usageDescriptions: [WebMediaAccess.cameraUsageKey: 1]) + == .unavailable(missing: .camera) + ) + #expect( + WebMediaAccess.outcome(for: [.camera, .microphone], usageDescriptions: nil) + == .unavailable(missing: [.camera, .microphone]) + ) + } + + @Test func aRequestForNoKnownDeviceIsNeverAsked() { + #expect(WebMediaAccess.outcome(for: [], usageDescriptions: MockCapture.usageDescriptions) == .unavailable(missing: [])) + } +} + +/// Holds each question until the test answers it, to show that nothing is granted before then. +@MainActor +private final class PendingConfirmer: WebMediaCaptureConfirming { + private(set) var questions: [(devices: WebMediaDevices, requester: WebMediaCaptureRequester)] = [] + private var completions: [(Bool) -> Void] = [] + + func confirmMediaCapture( + devices: WebMediaDevices, + requester: WebMediaCaptureRequester, + in window: NSWindow?, + completion: @escaping (Bool) -> Void + ) { + questions.append((devices, requester)) + completions.append(completion) + } + + func answer(_ yes: Bool) { + completions.removeFirst()(yes) + } +} + +@MainActor +struct WebMediaCaptureDecisionTests { + private final class Decisions { + var all: [WKPermissionDecision] = [] + } + + private func decide( + _ devices: WebMediaDevices, + by requester: WebMediaCaptureRequester = .page(name: "demo.html"), + usage: [String: Any]? = MockCapture.usageDescriptions, + confirmer: PendingConfirmer + ) -> (decisions: Decisions, outcome: WebMediaAccess.Outcome) { + let decisions = Decisions() + let outcome = WebMediaCapture.decide( + devices: devices, + requester: requester, + in: nil, + usageDescriptions: usage, + confirmer: confirmer + ) { decisions.all.append($0) } + return (decisions, outcome) + } + + @Test func nothingIsGrantedWhileTheQuestionIsOpen() { + let confirmer = PendingConfirmer() + let (decisions, outcome) = decide([.camera, .microphone], confirmer: confirmer) + + #expect(outcome == .ask) + #expect(confirmer.questions.count == 1) + #expect(decisions.all.isEmpty) + + confirmer.answer(true) + #expect(decisions.all == [.grant]) + } + + @Test func onlyAnExplicitYesGrants() { + let confirmer = PendingConfirmer() + let (decisions, _) = decide(.microphone, confirmer: confirmer) + + confirmer.answer(false) + + #expect(decisions.all == [.deny]) + } + + @Test(arguments: [ + WebMediaCaptureRequester.page(name: "demo.html"), + .plugin(name: "Acme", id: "dev.acme.tools") + ]) + func everyRequestAsksAgainAndNamesWhoAsks(requester: WebMediaCaptureRequester) { + let confirmer = PendingConfirmer() + let first = decide(.camera, by: requester, confirmer: confirmer) + confirmer.answer(true) + let second = decide(.camera, by: requester, confirmer: confirmer) + confirmer.answer(false) + let third = decide(.camera, by: requester, confirmer: confirmer) + confirmer.answer(true) + + #expect(first.decisions.all == [.grant]) + #expect(second.decisions.all == [.deny]) + #expect(third.decisions.all == [.grant]) + #expect(confirmer.questions.count == 3) + #expect(confirmer.questions.allSatisfy { $0.requester == requester && $0.devices == .camera }) + } + + @Test func aRequestNamingNoDeviceIsDeniedWithoutAskingAnyone() { + let confirmer = PendingConfirmer() + let (decisions, outcome) = decide([], confirmer: confirmer) + + #expect(decisions.all == [.deny]) + #expect(outcome == .unavailable(missing: [])) + #expect(confirmer.questions.isEmpty) + } + + /// An app whose Info.plist lacks the camera description, in the ways it can lack it. + enum WithoutCameraDescription: CaseIterable { + case noInfoPlist + case noEntries + case microphoneOnly + + var usageDescriptions: [String: Any]? { + switch self { + case .noInfoPlist: nil + case .noEntries: [:] + case .microphoneOnly: [WebMediaAccess.microphoneUsageKey: "Microphone"] + } + } + } + + @Test(arguments: WithoutCameraDescription.allCases) + func withoutTheUsageDescriptionTheRequestIsDeniedWithoutAskingAnyone(info: WithoutCameraDescription) { + let confirmer = PendingConfirmer() + let (decisions, outcome) = decide(.camera, usage: info.usageDescriptions, confirmer: confirmer) + + #expect(decisions.all == [.deny]) + #expect(outcome == .unavailable(missing: .camera)) + #expect(confirmer.questions.isEmpty) + } + + @Test func withoutAWindowTheAppKitConfirmerDeniesExactlyOnce() { + var answers: [Bool] = [] + AppKitWebMediaCaptureConfirmer().confirmMediaCapture( + devices: .camera, requester: .page(name: "demo.html"), in: nil + ) { answers.append($0) } + #expect(answers == [false]) + } +} + +@MainActor +struct WebMediaCaptureQuestionTests { + private let devices: [WebMediaDevices] = [.camera, .microphone, [.camera, .microphone]] + + @Test func theQuestionNamesWhoAsksAndWhichDevices() { + let requesters: [WebMediaCaptureRequester] = [.page(name: "demo.html"), .plugin(name: "demo.html", id: "dev.acme")] + var questions: Set = [] + for requester in requesters { + for set in devices { + let question = AppKitWebMediaCaptureConfirmer.question(for: set, requester: requester) + #expect(question.contains("demo.html")) + questions.insert(question) + } + } + // Three device sets for a page and three for a plugin: all different, so a plugin cannot pass as a page. + #expect(questions.count == 6) + } + + @Test func aPluginsExplanationShowsItsID() { + let explanation = AppKitWebMediaCaptureConfirmer.explanation(for: .plugin(name: "Acme", id: "dev.acme.tools")) + #expect(explanation.contains("dev.acme.tools")) + #expect(explanation != AppKitWebMediaCaptureConfirmer.explanation(for: .page(name: "Acme"))) + } + + @Test func aNameCannotReshapeOrImitateTheQuestion() { + let text = AppKitWebMediaCaptureConfirmer.displayText(" Lithe\u{202E}\nwants\u{200B} nothing\t ") + #expect(text == "Lithewants nothing") + + let long = String(repeating: "a", count: 200) + let shortened = AppKitWebMediaCaptureConfirmer.displayText(long) + #expect(shortened == String(repeating: "a", count: 80) + "…") + #expect(AppKitWebMediaCaptureConfirmer.displayText("文档.html") == "文档.html") + + let question = AppKitWebMediaCaptureConfirmer.question( + for: .camera, requester: .plugin(name: "Evil\nInject", id: "dev.acme") + ) + #expect(!question.contains("\n")) + } +} + +/// An unanswered request is not a refusal: a web view whose delegate leaves it +/// unanswered shows WebKit's own prompt, so every place that builds a web view has +/// to name who answers, and only the gate may turn a request into a grant. +struct WebMediaCaptureCoverageTests { + /// Each file that builds a web view, with the file and text that answer its requests. + private static let answers: [String: (file: String, marker: String)] = [ + "Lithe/Platform/MacOS/MarkdownPreviewWebView.swift": ("MarkdownPreviewWebView.swift", "WebMediaCaptureDenier.shared"), + "Lithe/Platform/MacOS/MonacoDiffEditor.swift": ("MonacoDiffEditor.swift", "WebMediaCaptureDenier.shared"), + "Lithe/Platform/MacOS/MonacoWorkbenchEditor.swift": ("MonacoWorkbenchEditor.swift", "WebMediaCaptureDenier.shared"), + "Lithe/Platform/MacOS/Updates/ReleaseNotesWebView.swift": ("ReleaseNotesWebView.swift", "WebMediaCaptureDenier.shared"), + "Lithe/Platform/MacOS/HTMLPreviewZoom.swift": ("HTMLPreviewMediaCapture.swift", "WebMediaCapture.decide("), + "Lithe/Platform/MacOS/Plugins/Web/MacPluginWebPage.swift": ("MacPluginWebPage.swift", "WebMediaCapture.decide(") + ] + + private func swiftSources() throws -> [(path: String, text: String)] { + let sources = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent().appendingPathComponent("Sources") + let files = try #require(FileManager.default.enumerator(at: sources, includingPropertiesForKeys: [.isRegularFileKey])) + var result: [(String, String)] = [] + for case let file as URL in files where file.pathExtension == "swift" { + result.append((String(file.path.dropFirst(sources.path.count + 1)), try String(contentsOf: file, encoding: .utf8))) + } + return result + } + + @Test func everyWebViewNamesWhoAnswersItsCameraAndMicrophoneRequests() throws { + let sources = try swiftSources() + let builders = sources.filter { + $0.text.contains("WKWebView(") || $0.text.range(of: #"class\s+\w+\s*:\s*WKWebView\b"#, options: .regularExpression) != nil + } + #expect( + Set(builders.map(\.path)) == Set(Self.answers.keys), + "A file builds a web view without naming who answers its camera and microphone requests, or one listed here no longer does." + ) + for (creator, answer) in Self.answers { + let file = sources.first { $0.path.hasSuffix("/" + answer.file) || $0.path == answer.file } + #expect(file?.text.contains(answer.marker) == true, "\(creator) is not answered by \(answer.file)") + } + } + + @Test func onlyTheGateGrantsARequest() throws { + let grants = try swiftSources().filter { $0.text.range(of: #"\.grant\b"#, options: .regularExpression) != nil } + #expect(grants.map(\.path) == ["Lithe/Platform/MacOS/WebMediaCapture.swift"]) + } +} + +@MainActor +struct WebMediaCaptureDenierTests { + @Test func theDenierAnswersTheRequestWebKitSends() { + let selector = NSSelectorFromString("webView:requestMediaCapturePermissionForOrigin:initiatedByFrame:type:decisionHandler:") + #expect(WebMediaCaptureDenier.shared.responds(to: selector)) + } +} + +extension MockCaptureTests { + @MainActor + @Suite struct LithePage { + @Test(.enabled(if: MockCapture.isAvailable)) + func aPageOfLitheIsRefusedWithoutAnyQuestion() async throws { + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .nonPersistent() + MockCapture.configure(configuration) + let webView = WKWebView(frame: NSRect(x: 0, y: 0, width: 400, height: 300), configuration: configuration) + webView.uiDelegate = WebMediaCaptureDenier.shared + let window = NSWindow(contentRect: webView.frame, styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = webView + window.orderFront(nil) + defer { + webView.stopLoading() + window.contentView = nil + window.close() + } + + webView.loadHTMLString("ready", baseURL: URL(string: "https://lithe.example/")) + let loaded = await waitForChanges(webViewChanges(webView)) { webView.title == "ready" } + #expect(loaded, "Title was \(webView.title ?? "nil")") + + // Started without waiting, so a web view that asked someone instead of refusing cannot hang the test. + _ = try await webView.evaluateJavaScript(""" + navigator.mediaDevices.getUserMedia({ video: true, audio: true }).then( + stream => { document.title = 'granted'; }, + error => { document.title = 'denied:' + error.name; }); + 0 + """) + let answered = await waitForChanges(webViewChanges(webView)) { webView.title?.hasPrefix("denied:") == true } + #expect(answered, "Title was \(webView.title ?? "nil")") + #expect(webView.title == "denied:NotAllowedError") + #expect(webView.cameraCaptureState == .none) + #expect(webView.microphoneCaptureState == .none) + } + } +} diff --git a/macos/Tests/LitheTests/WindowFullScreenCapabilityGuardTests.swift b/macos/Tests/LitheTests/WindowFullScreenCapabilityGuardTests.swift new file mode 100644 index 0000000000..fa8bfb55a3 --- /dev/null +++ b/macos/Tests/LitheTests/WindowFullScreenCapabilityGuardTests.swift @@ -0,0 +1,185 @@ +import AppKit +import Testing +@testable import Lithe + +@Suite("Window fullscreen capability guard") +@MainActor +struct WindowFullScreenCapabilityGuardTests { + @Test + func guardStripsSwiftUIFullScreenRefusalWhenInstalled() throws { + let window = Self.makeWindow() + defer { window.close() } + window.collectionBehavior.insert(.fullScreenNone) + + window.contentView?.addSubview(WindowFullScreenCapabilityView()) + + #expect(!window.collectionBehavior.contains(.fullScreenNone)) + #expect(window.collectionBehavior.contains(.fullScreenPrimary)) + } + + @Test + func guardRewiresZoomButtonToTheGuardedFullscreenToggle() throws { + let window = Self.makeWindow() + defer { window.close() } + window.collectionBehavior.insert(.fullScreenNone) + + let guardView = WindowFullScreenCapabilityView() + window.contentView?.addSubview(guardView) + + // AppKit configures the zoom button while the window is created, so a + // button configured under the refusal would keep maximizing until the + // guard rewires it. + let zoomButton = try #require(window.standardWindowButton(.zoomButton)) + #expect(zoomButton.target as? WindowFullScreenCapabilityView === guardView) + #expect(zoomButton.action == #selector(WindowFullScreenCapabilityView.zoomButtonClicked(_:))) + } + + @Test + func guardRestripsTheRefusalWhenSwiftUIReappliesIt() throws { + let window = Self.makeWindow() + defer { window.close() } + window.collectionBehavior.insert(.fullScreenNone) + + window.contentView?.addSubview(WindowFullScreenCapabilityView()) + #expect(!window.collectionBehavior.contains(.fullScreenNone)) + + // SwiftUI reapplies the flag during scene updates; the next window + // update pass must strip it again so the zoom button stays usable. + window.collectionBehavior.insert(.fullScreenNone) + NotificationCenter.default.post(name: NSWindow.didUpdateNotification, object: window) + + #expect(!window.collectionBehavior.contains(.fullScreenNone)) + #expect(window.collectionBehavior.contains(.fullScreenPrimary)) + } + + private static func makeWindow() -> NSWindow { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + return window + } +} + +@Suite("Window fullscreen state reader") +@MainActor +struct WindowFullScreenStateReaderTests { + @Test + func readerTracksFullScreenTransitionsThroughWindowUpdates() throws { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + defer { window.close() } + + var reported: [Bool] = [] + let reader = WindowFullScreenStateReader.ReaderView() + reader.onRefresh = { value in reported.append(value) } + window.contentView?.addSubview(reader) + #expect(reported == [false]) + + // AppKit throws when tests write the style mask outside a real + // fullscreen transition, so the state is driven through the + // injectable predicate instead. + reader.isFullScreen = { _ in true } + NotificationCenter.default.post(name: NSWindow.didUpdateNotification, object: window) + #expect(reported == [false, true]) + + reader.isFullScreen = { _ in false } + NotificationCenter.default.post(name: NSWindow.didUpdateNotification, object: window) + #expect(reported == [false, true, false]) + } +} + +@Suite("Window layout transition while fullscreen") +@MainActor +struct WindowLayoutFullScreenTransitionTests { + private final class Sessions: ProjectWindowSessionHandling { + var hasUnsavedDocuments = false + var closingDocuments: [EditorDocument] = [] + var unsavedDocumentNames: [String] = [] + var hasActiveProject = false + var hasActiveStandaloneFile = false + var shouldDismissWindowWhenClosingActiveSession = false + var windowScope: ProjectWindowScope = .primary + + @discardableResult + func saveAllDocuments() async -> Bool { true } + func closeActiveProject() {} + func requestCloseActiveWorkbenchItem() -> Bool { false } + func requestCloseActiveSession() -> Bool { true } + func resetForProjectWindowClose() async {} + func confirmRunServicesBeforeClose() async -> Bool { true } + func noteWindowBecameKey() {} + } + + @Test + func layoutTransitionWhileFullScreenDefersFrameSwapUntilExit() throws { + let window = NSWindow( + contentRect: NSRect(x: 100, y: 100, width: 800, height: 650), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + defer { window.close() } + + let coordinator = LitheWindowCoordinator(projectSessions: Sessions()) + coordinator.attach(to: window, layout: .welcome, title: "Welcome") + let welcomeWidth = window.frame.width + #expect(abs(welcomeWidth - LitheWindowLayout.welcomeContentSize.width) < 1) + + // 欢迎页全屏后打开项目:全屏期间不得把窗口缩成居中的工作台默认尺寸 + coordinator.isInFullScreen = { _ in true } + coordinator.attach(to: window, layout: .workspace, title: "Project") + #expect(abs(window.frame.width - welcomeWidth) < 1) + + // 退出全屏后补一次换框,落回工作台默认尺寸。期望值必须经过与 + // applyFrame 相同的屏幕钳制:CI 的虚拟显示器只有 1024pt 宽, + // 不能直接断言 1440。 + coordinator.isInFullScreen = { _ in false } + coordinator.windowDidExitFullScreen( + Notification(name: NSWindow.didExitFullScreenNotification, object: window) + ) + let expectedWorkspaceWidth: CGFloat + if let visibleFrame = (window.screen ?? NSScreen.main)?.visibleFrame { + expectedWorkspaceWidth = LitheWindowLayout.frame( + NSRect(origin: .zero, size: LitheWindowLayout.workspaceContentSize), + fitting: visibleFrame + ).width + } else { + // applyFrame 只在有可见屏幕时钳制;无屏幕环境不缩放。 + expectedWorkspaceWidth = LitheWindowLayout.workspaceContentSize.width + } + #expect(abs(window.frame.width - expectedWorkspaceWidth) < 1) + } + + @Test + func directFullScreenExitWithoutLayoutChangeKeepsRestoredFrame() throws { + let window = NSWindow( + contentRect: NSRect(x: 100, y: 100, width: 800, height: 650), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + defer { window.close() } + + let coordinator = LitheWindowCoordinator(projectSessions: Sessions()) + coordinator.attach(to: window, layout: .welcome, title: "Welcome") + let welcomeWidth = window.frame.width + + // 全屏前全屏后布局未变:退出时不做换框,保留退出前的窗口尺寸 + coordinator.isInFullScreen = { _ in true } + coordinator.windowDidExitFullScreen( + Notification(name: NSWindow.didExitFullScreenNotification, object: window) + ) + #expect(abs(window.frame.width - welcomeWidth) < 1) + } +} diff --git a/macos/Tests/LitheTests/WorkbenchContributionHostTests.swift b/macos/Tests/LitheTests/WorkbenchContributionHostTests.swift index 3087b18b77..92b4475848 100644 --- a/macos/Tests/LitheTests/WorkbenchContributionHostTests.swift +++ b/macos/Tests/LitheTests/WorkbenchContributionHostTests.swift @@ -279,11 +279,11 @@ struct WorkbenchContributionHostTests { let model = WorkbenchFeatureModel() model.reconcileContributions([owner: [declaration]]) model.setVisibility(.terminal, isVisible: true) - model.setVisibility(.database, isVisible: true) + model.setVisibility(.agent, isVisible: true) model.setVisibility(window, isVisible: true) #expect(model.activeLeftToolWindow == window) #expect(model.activeToolWindow == .terminal) - #expect(model.activeRightToolWindow == .database) + #expect(model.activeRightToolWindow == .agent) model.customizeContribution(id, placement: .rightSidebar) #expect(model.activeLeftToolWindow == nil) #expect(model.activeRightToolWindow == window) @@ -655,29 +655,34 @@ struct WorkbenchContributionHostTests { let store = WorkbenchLayoutStore(store: MacUserDefaultsStore(defaults: defaults)) let workspace = URL(fileURLWithPath: "/fixture/workspace") let workbench = WorkbenchFeatureModel(layoutStore: store) - let windows = BuiltInModuleCatalog.contributions(for: .database) - workbench.reconcileContributions([.database: windows]) + let owner = ModuleID("org.example.ui") + let windows = [ + ModuleContribution(id: "org.example.sidebar", kind: .toolWindow, title: "Sidebar", placement: .rightSidebar), + ModuleContribution(id: "org.example.services", kind: .toolWindow, title: "Services") + ] + let sidebar = WorkbenchFeatureModel.ToolWindow(rawValue: "org.example.sidebar") + workbench.reconcileContributions([owner: windows]) _ = workbench.loadLayout(for: workspace) - workbench.setVisibility(.database, isVisible: true) - workbench.customizeContribution("database.workspace", placement: .activityBar) + workbench.setVisibility(sidebar, isVisible: true) + workbench.customizeContribution(sidebar.rawValue, placement: .activityBar) #expect(workbench.activeRightToolWindow == nil) - #expect(workbench.activeToolWindow == .database) - #expect(workbench.contributions(windows, at: .activityBar).contains { $0.id == "database.workspace" }) + #expect(workbench.activeToolWindow == sidebar) + #expect(workbench.contributions(windows, at: .activityBar).contains { $0.id == sidebar.rawValue }) store.save(WorkbenchLayout(sidebarWidth: 444, topPaneHeight: 555), for: workspace) let restored = WorkbenchFeatureModel(layoutStore: store) - restored.reconcileContributions([.database: windows]) + restored.reconcileContributions([owner: windows]) let layout = restored.loadLayout(for: workspace) #expect(layout.sidebarWidth == 444) - #expect(layout.visibleToolWindowIDs == ["database.workspace"]) - #expect(restored.contributionPreferences["database.workspace"]?.placement == .activityBar) - restored.setVisibility(.database, isVisible: true) - restored.customizeContribution("database.workspace", isHidden: true) - #expect(!restored.isVisible(.database)) - #expect(restored.contributions(windows, at: .activityBar).map(\.id) == ["database.services"]) + #expect(layout.visibleToolWindowIDs == [sidebar.rawValue]) + #expect(restored.contributionPreferences[sidebar.rawValue]?.placement == .activityBar) + restored.setVisibility(sidebar, isVisible: true) + restored.customizeContribution(sidebar.rawValue, isHidden: true) + #expect(!restored.isVisible(sidebar)) + #expect(restored.contributions(windows, at: .activityBar).map(\.id) == ["org.example.services"]) restored.reconcileContributions([:]) - restored.reconcileContributions([.database: windows]) - #expect(restored.contributionPreferences["database.workspace"]?.isHidden == true) - restored.customizeContribution("database.workspace", isHidden: false) + restored.reconcileContributions([owner: windows]) + #expect(restored.contributionPreferences[sidebar.rawValue]?.isHidden == true) + restored.customizeContribution(sidebar.rawValue, isHidden: false) #expect(restored.contributions(windows, at: .activityBar).count == 2) } @@ -862,46 +867,6 @@ struct WorkbenchContributionHostTests { } } - @Test(arguments: [false, true]) - func savedServicesWindowActivatesItsOwnerWithoutAnotherEntry(useShow: Bool) async throws { - let suite = "lithe-services-restore-" + UUID().uuidString - let defaults = try #require(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } - let store = MacUserDefaultsStore(defaults: defaults), settings = AppSettings(store: store) - let workspace = URL(fileURLWithPath: "/fixture/services-restore") - let firstContainer = MacServiceContainer(store: store, settings: settings) - defer { firstContainer.moduleLifecycleCoordinator.stop() } - let first = AppModel(settings: settings, services: firstContainer.services) - try await WorkbenchTestScope.withCleanup({ await first.shutdownProjectSession() }) { scope in - first.workspaceSessionCoordinator.beginWorkspace(at: workspace) - _ = first.loadWorkbenchLayout(for: workspace) - try await scope.run("initial Services activation") { - await first.activateDatabaseModule(toolWindow: .databaseServices) - } - #expect(first.databaseFeatureIfActive != nil) - #expect(first.workbenchFeature.isVisible(.databaseServices)) - } - let container = MacServiceContainer(store: store, settings: settings) - defer { container.moduleLifecycleCoordinator.stop() } - let restored = AppModel(settings: settings, services: container.services) - try await WorkbenchTestScope.withCleanup({ await restored.shutdownProjectSession() }) { scope in - restored.workspaceSessionCoordinator.beginWorkspace(at: workspace) - let layout = restored.loadWorkbenchLayout(for: workspace) - let services = try #require(restored.workbenchContributions.first { $0.id == "database.services" }) - #expect(restored.workbenchFeature.isVisible(.databaseServices)) - #expect(restored.databaseFeatureIfActive == nil) - if useShow { restored.showContributionEntry(services) } - else { restored.restoreRegisteredToolWindows(layout.toolWindows?.filter(\.isVisible).map(\.id) ?? []) } - let activation = try #require(restored.contributionActivationTasks[services.id]) - try await scope.run("restored Services activation") { await activation.value } - #expect(restored.databaseFeatureIfActive != nil) - #expect(restored.workbenchFeature.isVisible(.databaseServices)) - #expect(!restored.workbenchFeature.isVisible(.database)) - #expect(restored.workbenchUIRegistry.isContentVisible(for: services, model: restored)) - #expect(try container.services.moduleRuntime.snapshot(for: .database).isInstantiated) - } - } - @Test func rebuildJavaIndexIsEnabledOnlyWithAWorkspace() throws { let suite = "lithe-rebuild-index-" + UUID().uuidString let defaults = try #require(UserDefaults(suiteName: suite)) @@ -1014,45 +979,17 @@ struct WorkbenchContributionHostTests { let workbench = WorkbenchFeatureModel() let build = DeclarativeBuildSystem(id: "make", displayName: "Make", modelSource: nil, tasks: [], testScopes: [], dependencyScopes: []) workbench.reconcileContributions([ - .database: BuiltInModuleCatalog.contributions(for: .database), .agentConversation: BuiltInModuleCatalog.contributions(for: .agentConversation), .buildSystemsExtension("org.example.make"): build.contributions(pluginID: "org.example.make", order: 0)]) let position = DockPosition(anchor: .right, group: .primary) let window = WorkbenchFeatureModel.ToolWindow( rawValue: DeclarativeBuildSystem.toolWindowID(pluginID: "org.example.make", buildSystemID: "make")) // A build system's window docks on the right like the Maven window it replaces. - #expect(workbench.tools(in: position) == [.database, .agent, window]) + #expect(workbench.tools(in: position) == [.agent, window]) workbench.moveContribution(window.rawValue, earlier: true) - #expect(workbench.tools(in: position) == [.database, window, .agent]) + #expect(workbench.tools(in: position) == [window, .agent]) workbench.moveContribution(window.rawValue, earlier: false) - #expect(workbench.tools(in: position) == [.database, .agent, window]) - } - - @Test func servicesCommandActivatesDatabaseWithoutOpeningItsSidebar() async throws { - let suite = "lithe-ui-services-" + UUID().uuidString - let defaults = try #require(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } - let store = MacUserDefaultsStore(defaults: defaults) - let settings = AppSettings(store: store) - let container = MacServiceContainer(store: store, settings: settings) - defer { container.moduleLifecycleCoordinator.stop() } - let model = AppModel(settings: settings, services: container.services) - try await WorkbenchTestScope.withCleanup({ await model.shutdownProjectSession() }) { _ in - model.workspaceSessionCoordinator.beginWorkspace(at: URL(fileURLWithPath: "/fixture/workspace")) - #expect(model.databaseFeatureIfActive == nil) - let services = try #require(model.workbenchContributions.first { $0.id == "database.services" }) - let opened = TestGate() - let observation = model.workbenchFeature.$toolWindowStates.sink { states in - if states.contains(where: { $0.id == WorkbenchFeatureModel.ToolWindow.databaseServices.rawValue && $0.isVisible }) { opened.open() } - } - defer { observation.cancel(); opened.open() } - model.workbenchUIRegistry.perform(services, model: model) - #expect(await opened.waitUntilOpen(), "Services never completed database activation") - #expect(model.databaseFeatureIfActive != nil) - #expect(model.workbenchFeature.isVisible(.databaseServices)) - #expect(!model.workbenchFeature.isVisible(.database)) - #expect(model.workbenchUIRegistry.selectedToolContent(from: model.activityBarContributions, model: model).rendererID == "database.services") - } + #expect(workbench.tools(in: position) == [.agent, window]) } @Test func duplicateIDsFailBeforeRenderingOrChangingTheRuntime() throws { diff --git a/macos/Tests/LitheTests/WorkbenchFeatureModelTests.swift b/macos/Tests/LitheTests/WorkbenchFeatureModelTests.swift index 74bf0ae916..aecfda5d36 100644 --- a/macos/Tests/LitheTests/WorkbenchFeatureModelTests.swift +++ b/macos/Tests/LitheTests/WorkbenchFeatureModelTests.swift @@ -36,58 +36,57 @@ struct WorkbenchFeatureModelTests { @Test func pluginsOpenInSettingsWhileARightToolRemainsVisible() { let model = WorkbenchFeatureModel() - model.setVisibility(.database, isVisible: true) + model.setVisibility(.agent, isVisible: true) model.presentSettings(category: .plugins) #expect(model.isSettingsPresented) #expect(model.requestedSettingsCategory == .plugins) - #expect(model.isVisible(.database)) + #expect(model.isVisible(.agent)) } @Test func rightDockedToolDoesNotReplaceBottomTools() { let model = WorkbenchFeatureModel() model.setVisibility(.terminal, isVisible: true) - model.setVisibility(.database, isVisible: true) + model.setVisibility(.agent, isVisible: true) #expect(model.activeToolWindow == .terminal) - #expect(model.isVisible(.database)) + #expect(model.isVisible(.agent)) model.setVisibility(.run, isVisible: true) - #expect(model.isVisible(.database)) + #expect(model.isVisible(.agent)) #expect(model.isVisible(.run)) #expect(!model.isVisible(.terminal)) model.setVisibility(.debug, isVisible: true) - #expect(model.isVisible(.database)) + #expect(model.isVisible(.agent)) #expect(model.isVisible(.debug)) } @Test func rightDockedToolAndBuildOutputCloseIndependently() { let model = WorkbenchFeatureModel() - model.setVisibility(.database, isVisible: true) + model.setVisibility(.agent, isVisible: true) model.setVisibility(.buildOutput, isVisible: true) model.setVisibility(.buildOutput, isVisible: false) - #expect(model.isVisible(.database)) + #expect(model.isVisible(.agent)) #expect(model.activeToolWindow == nil) model.setVisibility(.buildOutput, isVisible: true) - model.toggleVisibility(.database) - #expect(!model.isVisible(.database)) + model.toggleVisibility(.agent) + #expect(!model.isVisible(.agent)) #expect(model.isVisible(.buildOutput)) - model.toggleVisibility(.database) + model.toggleVisibility(.agent) model.hideAllToolWindows() - #expect(!model.isVisible(.database)) + #expect(!model.isVisible(.agent)) #expect(model.activeToolWindow == nil) } @Test - func agentDocksBesideTheEditorAndSharesTheRightSlotWithDatabase() { + func agentDocksBesideTheEditor() { // The Agent conversation must keep the editor visible, so it takes the - // right dock instead of the bottom tool window. Database and Agent share - // that dock, and neither replaces a bottom tool window. + // right dock instead of the bottom tool window and does not replace one. let model = WorkbenchFeatureModel() model.setVisibility(.terminal, isVisible: true) model.setVisibility(.agent, isVisible: true) @@ -95,15 +94,6 @@ struct WorkbenchFeatureModelTests { #expect(model.activeToolWindow == .terminal) #expect(model.activeRightToolWindow == .agent) - model.setVisibility(.database, isVisible: true) - #expect(model.isVisible(.database)) - #expect(!model.isVisible(.agent)) - #expect(model.isVisible(.terminal)) - - model.toggleVisibility(.agent) - #expect(model.isVisible(.agent)) - #expect(!model.isVisible(.database)) - model.hideBottomToolWindow() #expect(model.isVisible(.agent)) @@ -114,10 +104,10 @@ struct WorkbenchFeatureModelTests { @Test func workspaceResetClearsRightAndBottomAreas() { let model = WorkbenchFeatureModel() - model.setVisibility(.database, isVisible: true) + model.setVisibility(.agent, isVisible: true) model.setVisibility(.buildOutput, isVisible: true) model.reset() - #expect(!model.isVisible(.database)) + #expect(!model.isVisible(.agent)) #expect(!model.isVisible(.buildOutput)) } @@ -131,19 +121,19 @@ struct WorkbenchFeatureModelTests { moduleLaunchMode: .safeMode ).services let model = AppModel(settings: settings, services: services) - model.workbenchFeature.setVisibility(.database, isVisible: true) + model.workbenchFeature.setVisibility(.agent, isVisible: true) model.isTerminalVisible = true model.presentLanguageNavigationResults(.references) - #expect(model.workbenchFeature.isVisible(.database)) + #expect(model.workbenchFeature.isVisible(.agent)) #expect(model.isReferencesVisible) #expect(!model.isTerminalVisible) model.isTerminalVisible = true model.presentLanguageNavigationResults(.implementations) - #expect(model.workbenchFeature.isVisible(.database)) + #expect(model.workbenchFeature.isVisible(.agent)) #expect(model.isImplementationChooserVisible) #expect(!model.isReferencesVisible) #expect(!model.isTerminalVisible) diff --git a/macos/Tests/LitheTests/WorkbenchModuleUIRegistryTests.swift b/macos/Tests/LitheTests/WorkbenchModuleUIRegistryTests.swift index 294dbc843c..5c87d7637f 100644 --- a/macos/Tests/LitheTests/WorkbenchModuleUIRegistryTests.swift +++ b/macos/Tests/LitheTests/WorkbenchModuleUIRegistryTests.swift @@ -121,26 +121,6 @@ struct WorkbenchModuleUIRegistryTests { try registry.validate(contributions: contributions) } - @Test func databaseUsesTheRightDockWithoutReplacingTheProjectTreeOrTerminal() throws { - let contributions = BuiltInModuleCatalog.contributions(for: .database) - let database = try #require(contributions.first { $0.id == "database.workspace" }) - let registry = WorkbenchModuleUIComposition.builtIn - #expect(database.placement == .rightSidebar) - #expect(registry.renderer(for: database)?.rightSidebarBehavior == .docked) - try registry.validate(contributions: contributions) - - let workbench = WorkbenchFeatureModel() - workbench.setVisibility(.terminal, isVisible: true) - workbench.setVisibility(.database, isVisible: true) - #expect(workbench.selectedSidebar == .project) - #expect(workbench.isSidebarVisible) - #expect(workbench.activeToolWindow == .terminal) - #expect(workbench.activeRightToolWindow == .database) - workbench.setVisibility(.agent, isVisible: true) - #expect(!workbench.isVisible(.database)) - #expect(workbench.isVisible(.terminal)) - } - @Test func duplicateActionIDsAreRejected() { let first = WorkbenchModuleUIRegistry.Registration(actions: [ .init(id: "test.action", perform: { _ in }) diff --git a/macos/Tests/LitheTests/WorkbenchNotificationTests.swift b/macos/Tests/LitheTests/WorkbenchNotificationTests.swift index a2931ffa81..23ccf40c7a 100644 --- a/macos/Tests/LitheTests/WorkbenchNotificationTests.swift +++ b/macos/Tests/LitheTests/WorkbenchNotificationTests.swift @@ -3,6 +3,7 @@ import AppKit import SwiftUI import Testing @testable import Lithe +import LitheModuleAPI @Suite("Workbench notifications", .serialized) @MainActor @@ -30,6 +31,44 @@ struct WorkbenchNotificationTests { } } + /// A notification with an action never times out; using the action + /// runs it, closes the balloon, and keeps the history entry. + @Test + func anActionStaysUntilUsed() { + let clock = NotificationTestClock() + let feature = WorkbenchNotificationFeatureModel(now: { clock.now }, sleep: { try await clock.sleep($0) }) + defer { feature.clear(); clock.releaseAll() } + var opened = 0 + feature.show("Database is now a plugin.", arguments: [], + action: WorkbenchNotification.Action(title: "Open Marketplace") { opened += 1 }) + #expect(clock.pending.isEmpty) + clock.advance(.seconds(100)) + let id = feature.activeNotifications[0].id + #expect(feature.activeNotifications.count == 1) + + feature.perform(id) + #expect(opened == 1) + #expect(feature.activeNotifications.isEmpty) + #expect(feature.notifications.first?.isRead == true) + #expect(feature.notifications.first?.action?.title == "Open Marketplace") + } + + @Test + func theDatabasePluginIsSuggestedOnceToPeopleWithSavedConnections() throws { + let store = NotificationTestStore() + let none = Set() + #expect(!DatabasePluginTransition.shouldSuggest(store: store, installedPluginIDs: none)) + store.set(try JSONSerialization.data(withJSONObject: []), forKey: DatabasePluginTransition.legacyProfilesKey) + #expect(!DatabasePluginTransition.shouldSuggest(store: store, installedPluginIDs: none)) + store.set(try JSONSerialization.data(withJSONObject: [["id": "A", "name": "Production"]]), + forKey: DatabasePluginTransition.legacyProfilesKey) + #expect(DatabasePluginTransition.shouldSuggest(store: store, installedPluginIDs: none)) + #expect(!DatabasePluginTransition.shouldSuggest(store: store, + installedPluginIDs: [DatabasePluginTransition.pluginID])) + store.set(true, forKey: DatabasePluginTransition.suggestionKey) + #expect(!DatabasePluginTransition.shouldSuggest(store: store, installedPluginIDs: none)) + } + @Test func mavenRuntimeErrorUsesApplicationLanguage() throws { let resources = URL(fileURLWithPath: #filePath).deletingLastPathComponent() .deletingLastPathComponent().deletingLastPathComponent().appendingPathComponent("Resources") @@ -448,3 +487,12 @@ private final class WorkbenchNotificationTestStore: KeyValueStore, @unchecked Se func stringArray(forKey key: String) -> [String]? { values[key] as? [String] } func set(_ value: Any?, forKey key: String) { values[key] = value } } + +private final class NotificationTestStore: KeyValueStore { + 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/WorkspaceSessionCoordinatorTests.swift b/macos/Tests/LitheTests/WorkspaceSessionCoordinatorTests.swift index ec65e998c6..bff6088b05 100644 --- a/macos/Tests/LitheTests/WorkspaceSessionCoordinatorTests.swift +++ b/macos/Tests/LitheTests/WorkspaceSessionCoordinatorTests.swift @@ -25,6 +25,7 @@ struct WorkspaceSessionCoordinatorTests { closeRequests += 1 return true }, + runServicesCloseConfirmation: { nil }, restoreDebugBreakpoints: { _ in }, visibilityRules: { .default } )) @@ -70,6 +71,7 @@ struct WorkspaceSessionCoordinatorTests { closeRequests += 1 return true }, + runServicesCloseConfirmation: { nil }, restoreDebugBreakpoints: { _ in }, visibilityRules: { .default } )) @@ -90,6 +92,63 @@ struct WorkspaceSessionCoordinatorTests { #expect(platform.stoppedURLs == [url]) } + @Test + func runServiceQuestionComesBeforeTheDocumentsAndCancelKeepsTheProject() async { + let platform = CoordinatorTestPlatformUI() + let coordinator = WorkspaceSessionCoordinator( + recentProjectsStore: RecentProjectsStore(store: CoordinatorTestStore()), + platformUI: platform + ) + defer { coordinator.clearSession() } + let url = URL(fileURLWithPath: "/tmp/lithe-run-services-close", isDirectory: true) + var answers = [false, true] + var questions = 0 + var closeRequests = 0 + var closedURLs: [URL] = [] + coordinator.configureLifecycle(with: .init( + prepareForWorkspaceOpen: { _ in }, + prepareForWorkspaceClose: { closedURLs.append($0) }, + prepareForStandaloneOpen: { _ in }, + finishStandaloneClose: {}, + beginDocumentClose: { + closeRequests += 1 + return false + }, + runServicesCloseConfirmation: { + { + questions += 1 + return answers.removeFirst() + } + }, + restoreDebugBreakpoints: { _ in }, + visibilityRules: { .default } + )) + coordinator.beginWorkspace(at: url) + + coordinator.requestCloseWorkspace() + #expect(coordinator.pendingCloseRequest == .workspace) + #expect(closeRequests == 0) + await waitUntil { coordinator.pendingCloseRequest == nil } + #expect(questions == 1) + #expect(closeRequests == 0) + #expect(coordinator.workspaceURL == url) + #expect(platform.stoppedURLs.isEmpty) + + coordinator.requestCloseWorkspace() + await waitUntil { coordinator.workspaceURL == nil } + #expect(questions == 2) + #expect(closeRequests == 1) + #expect(closedURLs == [url]) + } + + /// Yields to the main actor until `condition` holds, for a bounded number of turns. + private func waitUntil(_ condition: () -> Bool) async { + for _ in 0..<1_000 where !condition() { + await Task.yield() + } + #expect(condition()) + } + @Test func standaloneCloseWithoutPendingDocumentsFinishesExactlyOnce() { let platform = CoordinatorTestPlatformUI() @@ -115,6 +174,7 @@ struct WorkspaceSessionCoordinatorTests { closeRequests += 1 return false }, + runServicesCloseConfirmation: { nil }, restoreDebugBreakpoints: { _ in }, visibilityRules: { .default } )) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 07cf58ec9e..d15dd23d97 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -49,7 +49,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d595a79e1665d02c91c7dd1bedd118501e75cc0a3d5b1651b5150ec4c5c1e0d" dependencies = [ "anyhow", - "derive_more 2.1.1", + "derive_more", "schemars 1.2.2", "serde", "serde_json", @@ -58,30 +58,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "ahash" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" -dependencies = [ - "getrandom 0.2.17", - "once_cell", - "version_check", -] - -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "getrandom 0.3.4", - "once_cell", - "version_check", - "zerocopy", -] - [[package]] name = "aho-corasick" version = "1.1.5" @@ -91,12 +67,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - [[package]] name = "ammonia" version = "4.1.4" @@ -133,21 +103,6 @@ dependencies = [ "derive_arbitrary", ] -[[package]] -name = "arc-swap" -version = "1.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" -dependencies = [ - "rustversion", -] - -[[package]] -name = "arrayvec" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" - [[package]] name = "async-channel" version = "2.5.0" @@ -231,39 +186,6 @@ version = "4.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" -[[package]] -name = "async-trait" -version = "0.1.92" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "asynchronous-codec" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4057f2c32adbb2fc158e22fb38433c8e9bbf76b75a4732c7c0cbaf695fb65568" -dependencies = [ - "bytes", - "futures-sink", - "futures-util", - "memchr", - "pin-project-lite", -] - -[[package]] -name = "atoi" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" -dependencies = [ - "num-traits", -] - [[package]] name = "atomic-waker" version = "1.1.2" @@ -330,21 +252,15 @@ dependencies = [ [[package]] name = "base64" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" - -[[package]] -name = "base64" -version = "0.21.7" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64" -version = "0.22.1" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" [[package]] name = "base64ct" @@ -361,7 +277,7 @@ dependencies = [ "bitflags 2.13.1", "cexpr", "clang-sys", - "itertools 0.12.1", + "itertools", "lazy_static", "lazycell", "log", @@ -386,21 +302,6 @@ name = "bitflags" version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" -dependencies = [ - "serde_core", -] - -[[package]] -name = "bitvec" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" -dependencies = [ - "funty", - "radium", - "tap", - "wyz", -] [[package]] name = "block-buffer" @@ -424,30 +325,6 @@ dependencies = [ "piper", ] -[[package]] -name = "borsh" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88b7ea17d208c4193f2c1e6de3c35fe71f98c96982d5ced308bdcc749ff6e1f" -dependencies = [ - "borsh-derive", - "bytes", - "cfg_aliases", -] - -[[package]] -name = "borsh-derive" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8f347189c62a579b8cd5f80714efa178f52e461dc2e6d701d264f5ff22e566c" -dependencies = [ - "once_cell", - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "bs58" version = "0.5.1" @@ -457,63 +334,12 @@ dependencies = [ "tinyvec", ] -[[package]] -name = "bson" -version = "2.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969a9ba84b0ff843813e7249eed1678d9b6607ce5a3b8f0a47af3fcf7978e6e" -dependencies = [ - "ahash 0.8.12", - "base64 0.22.1", - "bitvec", - "getrandom 0.2.17", - "getrandom 0.3.4", - "hex", - "indexmap 2.14.0", - "js-sys", - "once_cell", - "rand 0.9.5", - "serde", - "serde_bytes", - "serde_json", - "time", - "uuid", -] - [[package]] name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" -[[package]] -name = "bytecheck" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" -dependencies = [ - "bytecheck_derive", - "ptr_meta", - "simdutf8", -] - -[[package]] -name = "bytecheck_derive" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "bytes" version = "1.12.1" @@ -578,10 +404,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", - "js-sys", "num-traits", "serde", - "wasm-bindgen", "windows-link", ] @@ -596,20 +420,6 @@ dependencies = [ "libloading", ] -[[package]] -name = "combine" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" -dependencies = [ - "bytes", - "futures-core", - "memchr", - "pin-project-lite", - "tokio", - "tokio-util", -] - [[package]] name = "comrak" version = "0.54.0" @@ -637,44 +447,12 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "connection-string" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "510ca239cf13b7f8d16a2b48f263de7b4f8c566f0af58d901031473c76afb1e3" - [[package]] name = "const-oid" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" -[[package]] -name = "const-random" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" -dependencies = [ - "const-random-macro", -] - -[[package]] -name = "const-random-macro" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" -dependencies = [ - "getrandom 0.2.17", - "once_cell", - "tiny-keccak", -] - -[[package]] -name = "convert_case" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" - [[package]] name = "convert_case" version = "0.10.0" @@ -684,16 +462,6 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -718,21 +486,6 @@ dependencies = [ "libc", ] -[[package]] -name = "crc" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" -dependencies = [ - "crc-catalog", -] - -[[package]] -name = "crc-catalog" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" - [[package]] name = "crc32fast" version = "1.5.0" @@ -742,27 +495,12 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "crossbeam-queue" -version = "0.3.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-utils" version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - [[package]] name = "crypto-common" version = "0.1.7" @@ -784,27 +522,6 @@ dependencies = [ "smallvec", ] -[[package]] -name = "csv" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" -dependencies = [ - "csv-core", - "itoa", - "ryu", - "serde_core", -] - -[[package]] -name = "csv-core" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" -dependencies = [ - "memchr", -] - [[package]] name = "darling" version = "0.23.0" @@ -873,7 +590,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" dependencies = [ - "thiserror 2.0.20", + "thiserror", ] [[package]] @@ -896,28 +613,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "derive-syn-parse" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d65d7ce8132b7c0e54497a4d9a55a1c2a0912a0d786cf894472ba818fba45762" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "derive-where" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -929,19 +624,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "derive_more" -version = "0.99.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" -dependencies = [ - "convert_case 0.4.0", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.119", -] - [[package]] name = "derive_more" version = "2.1.1" @@ -957,7 +639,7 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ - "convert_case 0.10.0", + "convert_case", "proc-macro2", "quote", "rustc_version", @@ -974,7 +656,6 @@ dependencies = [ "block-buffer", "const-oid", "crypto-common", - "subtle", ] [[package]] @@ -988,12 +669,6 @@ dependencies = [ "syn 3.0.3", ] -[[package]] -name = "dotenvy" -version = "0.15.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" - [[package]] name = "dtoa" version = "1.0.11" @@ -1020,9 +695,6 @@ name = "either" version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" -dependencies = [ - "serde", -] [[package]] name = "emojis" @@ -1033,15 +705,6 @@ dependencies = [ "phf", ] -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - [[package]] name = "entities" version = "1.0.1" @@ -1049,69 +712,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca" [[package]] -name = "enum-as-inner" -version = "0.6.1" +name = "equivalent" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.119", -] +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] -name = "enumflags2" -version = "0.7.12" +name = "errno" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ - "enumflags2_derive", + "libc", + "windows-sys 0.61.2", ] [[package]] -name = "enumflags2_derive" -version = "0.7.12" +name = "event-listener" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "etcetera" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" -dependencies = [ - "cfg-if", - "home", - "windows-sys 0.48.0", -] - -[[package]] -name = "event-listener" -version = "5.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ "parking", "pin-project-lite", @@ -1161,23 +781,6 @@ dependencies = [ "miniz_oxide", ] -[[package]] -name = "flume" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" -dependencies = [ - "futures-core", - "futures-sink", - "spin", -] - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1197,12 +800,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "funty" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" - [[package]] name = "futures" version = "0.3.34" @@ -1258,17 +855,6 @@ dependencies = [ "futures-util", ] -[[package]] -name = "futures-intrusive" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" -dependencies = [ - "futures-core", - "lock_api", - "parking_lot", -] - [[package]] name = "futures-io" version = "0.3.34" @@ -1358,11 +944,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 5.3.0", "wasip2", - "wasm-bindgen", ] [[package]] @@ -1390,20 +974,6 @@ name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" -dependencies = [ - "ahash 0.7.8", -] - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", -] [[package]] name = "hashbrown" @@ -1411,15 +981,6 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" -[[package]] -name = "hashlink" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" -dependencies = [ - "hashbrown 0.15.5", -] - [[package]] name = "heck" version = "0.5.0" @@ -1438,69 +999,6 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "hickory-proto" -version = "0.24.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92652067c9ce6f66ce53cc38d1169daa36e6e7eb7dd3b63b5103bd9d97117248" -dependencies = [ - "async-trait", - "cfg-if", - "data-encoding", - "enum-as-inner", - "futures-channel", - "futures-io", - "futures-util", - "idna", - "ipnet", - "once_cell", - "rand 0.8.7", - "thiserror 1.0.69", - "tinyvec", - "tokio", - "tracing", - "url", -] - -[[package]] -name = "hickory-resolver" -version = "0.24.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbb117a1ca520e111743ab2f6688eddee69db4e0ea242545a604dce8a66fd22e" -dependencies = [ - "cfg-if", - "futures-util", - "hickory-proto", - "ipconfig", - "lru-cache", - "once_cell", - "parking_lot", - "rand 0.8.7", - "resolv-conf", - "smallvec", - "thiserror 1.0.69", - "tokio", - "tracing", -] - -[[package]] -name = "hkdf" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" -dependencies = [ - "hmac", -] - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - [[package]] name = "home" version = "0.5.12" @@ -1595,11 +1093,11 @@ dependencies = [ "http", "hyper", "hyper-util", - "rustls 0.23.43", + "rustls", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tower-service", - "webpki-roots 1.0.9", + "webpki-roots", ] [[package]] @@ -1619,7 +1117,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.5", + "socket2", "tokio", "tower-service", "tracing", @@ -1781,19 +1279,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "ipconfig" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" -dependencies = [ - "socket2 0.6.5", - "widestring", - "windows-registry", - "windows-result", - "windows-sys 0.61.2", -] - [[package]] name = "ipnet" version = "2.12.1" @@ -1809,15 +1294,6 @@ dependencies = [ "either", ] -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - [[package]] name = "itoa" version = "1.0.18" @@ -1944,35 +1420,6 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" -[[package]] -name = "libredox" -version = "0.1.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" -dependencies = [ - "bitflags 2.13.1", - "libc", - "plain", - "redox_syscall 0.9.1", -] - -[[package]] -name = "libsqlite3-sys" -version = "0.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" -dependencies = [ - "cc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "linked-hash-map" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" - [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -1998,21 +1445,28 @@ dependencies = [ "agent-client-protocol", "bytes", "flate2", + "fs2", "futures", + "http", "http-body-util", "hyper", "hyper-util", "kill_tree", "libc", "reqwest", + "rmcp", "semver", "serde", "serde_json", + "serde_yaml_ng", + "sha2", + "sse-stream", "tokio", "tokio-util", "tungstenite", "url", "uuid", + "windows-sys 0.61.2", ] [[package]] @@ -2026,6 +1480,8 @@ dependencies = [ "lithe-agent-host", "lithe-git-host", "lithe-ide-host", + "lithe-remote-client", + "lithe-remote-protocol", "quick-xml", "rand 0.8.7", "regex", @@ -2036,6 +1492,7 @@ dependencies = [ "serde_yaml_ng", "sha1", "sha2", + "shell-words", "toml", "toml_edit", "tree-sitter", @@ -2045,62 +1502,60 @@ dependencies = [ ] [[package]] -name = "lithe-db-mcp" +name = "lithe-git-host" version = "0.1.0" dependencies = [ + "libc", + "rand 0.8.7", "serde", "serde_json", - "uuid", + "windows-sys 0.61.2", ] [[package]] -name = "lithe-db-sidecar" +name = "lithe-ide-host" version = "0.1.0" dependencies = [ - "base64 0.22.1", - "chrono", - "csv", - "futures-util", - "hex", - "mongodb", - "redis", + "axum", + "fs2", "reqwest", - "rust_decimal", + "rmcp", "serde", "serde_json", "sha2", - "sqlx", - "tiberius", "tokio", - "tokio-util", - "urlencoding", "uuid", ] [[package]] -name = "lithe-git-host" +name = "lithe-remote-client" version = "0.1.0" dependencies = [ - "libc", - "rand 0.8.7", + "lithe-remote-protocol", "serde", "serde_json", - "windows-sys 0.61.2", + "sha2", ] [[package]] -name = "lithe-ide-host" +name = "lithe-remote-protocol" version = "0.1.0" dependencies = [ - "axum", - "fs2", - "reqwest", - "rmcp", + "base64 0.22.1", + "serde", + "serde_json", +] + +[[package]] +name = "lithe-remote-server" +version = "0.1.0" +dependencies = [ + "libc", + "lithe-remote-client", + "lithe-remote-protocol", "serde", "serde_json", "sha2", - "tokio", - "uuid", ] [[package]] @@ -2118,69 +1573,12 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" -[[package]] -name = "lru-cache" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" -dependencies = [ - "linked-hash-map", -] - [[package]] name = "lru-slab" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "macro_magic" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc33f9f0351468d26fbc53d9ce00a096c8522ecb42f19b50f34f2c422f76d21d" -dependencies = [ - "macro_magic_core", - "macro_magic_macros", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "macro_magic_core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1687dc887e42f352865a393acae7cf79d98fab6351cde1f58e9e057da89bf150" -dependencies = [ - "const-random", - "derive-syn-parse", - "macro_magic_core_macros", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "macro_magic_core_macros" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b02abfe41815b5bd98dbd4260173db2c116dda171dc0fe7838cb206333b83308" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "macro_magic_macros" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ea28ee64b88876bf45277ed9a5817c1817df061a74f2b988971a12570e5869" -dependencies = [ - "macro_magic_core", - "quote", - "syn 2.0.119", -] - [[package]] name = "maplit" version = "1.0.2" @@ -2204,16 +1602,6 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" -[[package]] -name = "md-5" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" -dependencies = [ - "cfg-if", - "digest", -] - [[package]] name = "memchr" version = "2.8.3" @@ -2254,77 +1642,16 @@ dependencies = [ ] [[package]] -name = "mongodb" -version = "3.2.5" +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6e3788f35159bbcf461227af84711950525343b64451fdd90de4e237a0b8a13" -dependencies = [ - "async-trait", - "base64 0.13.1", - "bitflags 1.3.2", - "bson", - "chrono", - "derive-where", - "derive_more 0.99.20", - "futures-core", - "futures-executor", - "futures-io", - "futures-util", - "hex", - "hickory-proto", - "hickory-resolver", - "hmac", - "macro_magic", - "md-5", - "mongodb-internal-macros", - "once_cell", - "pbkdf2", - "percent-encoding", - "rand 0.8.7", - "rustc_version_runtime", - "rustls 0.21.12", - "rustls-pemfile 1.0.4", - "serde", - "serde_bytes", - "serde_with", - "sha1", - "sha2", - "socket2 0.5.10", - "stringprep", - "strsim", - "take_mut", - "thiserror 1.0.69", - "tokio", - "tokio-rustls 0.24.1", - "tokio-util", - "typed-builder", - "uuid", - "webpki-roots 0.25.4", -] - -[[package]] -name = "mongodb-internal-macros" -version = "3.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f736d2fbc56e0011a341fbb9172bd822fda75c5f93b82fae1c7aab1e2613c810" -dependencies = [ - "macro_magic", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "new_debug_unreachable" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" - -[[package]] -name = "nix" -version = "0.27.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" +checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" dependencies = [ "bitflags 2.13.1", "cfg-if", @@ -2341,16 +1668,6 @@ dependencies = [ "minimal-lexical", ] -[[package]] -name = "num-bigint" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" -dependencies = [ - "num-integer", - "num-traits", -] - [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -2408,12 +1725,6 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -[[package]] -name = "openssl-probe" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - [[package]] name = "parking" version = "2.2.1" @@ -2438,7 +1749,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.18", + "redox_syscall", "smallvec", "windows-link", ] @@ -2449,15 +1760,6 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" -[[package]] -name = "pbkdf2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" -dependencies = [ - "digest", -] - [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -2570,18 +1872,6 @@ dependencies = [ "spki", ] -[[package]] -name = "pkg-config" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" - -[[package]] -name = "plain" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" - [[package]] name = "polling" version = "3.11.0" @@ -2641,12 +1931,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" -[[package]] -name = "pretty-hex" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6fa0831dd7cc608c38a5e323422a0077678fa5744aa2be4ad91c4ece8eec8d5" - [[package]] name = "prettyplease" version = "0.2.37" @@ -2657,15 +1941,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "proc-macro-crate" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" -dependencies = [ - "toml_edit", -] - [[package]] name = "proc-macro2" version = "1.0.107" @@ -2675,26 +1950,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "ptr_meta" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" -dependencies = [ - "ptr_meta_derive", -] - -[[package]] -name = "ptr_meta_derive" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "quick-xml" version = "0.37.5" @@ -2716,9 +1971,9 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash 2.1.3", - "rustls 0.23.43", - "socket2 0.6.5", - "thiserror 2.0.20", + "rustls", + "socket2", + "thiserror", "tokio", "tracing", "web-time", @@ -2737,10 +1992,10 @@ dependencies = [ "rand_pcg", "ring", "rustc-hash 2.1.3", - "rustls 0.23.43", + "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.20", + "thiserror", "tinyvec", "tracing", "web-time", @@ -2755,7 +2010,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.5", + "socket2", "tracing", "windows-sys 0.61.2", ] @@ -2781,12 +2036,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "radium" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" - [[package]] name = "rand" version = "0.8.7" @@ -2872,33 +2121,6 @@ dependencies = [ "rand_core 0.10.1", ] -[[package]] -name = "redis" -version = "0.27.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09d8f99a4090c89cc489a94833c901ead69bfbf3877b4867d5482e321ee875bc" -dependencies = [ - "arc-swap", - "async-trait", - "bytes", - "combine", - "futures-util", - "itertools 0.13.0", - "itoa", - "num-bigint", - "percent-encoding", - "pin-project-lite", - "rustls 0.23.43", - "rustls-native-certs 0.7.3", - "rustls-pemfile 2.2.0", - "rustls-pki-types", - "ryu", - "tokio", - "tokio-rustls 0.26.4", - "tokio-util", - "url", -] - [[package]] name = "redox_syscall" version = "0.5.18" @@ -2908,15 +2130,6 @@ dependencies = [ "bitflags 2.13.1", ] -[[package]] -name = "redox_syscall" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07507be7b4a5f9f26eeb41eeaebb1f5a7ff29dfb29739facc21d35bf8b11c21e" -dependencies = [ - "bitflags 2.13.1", -] - [[package]] name = "ref-cast" version = "1.0.26" @@ -2966,15 +2179,6 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" -[[package]] -name = "rend" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" -dependencies = [ - "bytecheck", -] - [[package]] name = "reqwest" version = "0.12.28" @@ -2997,14 +2201,14 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.43", + "rustls", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -3014,15 +2218,9 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots 1.0.9", + "webpki-roots", ] -[[package]] -name = "resolv-conf" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" - [[package]] name = "ring" version = "0.17.14" @@ -3037,51 +2235,27 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rkyv" -version = "0.7.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" -dependencies = [ - "bitvec", - "bytecheck", - "bytes", - "hashbrown 0.12.3", - "ptr_meta", - "rend", - "rkyv_derive", - "seahash", - "tinyvec", - "uuid", -] - -[[package]] -name = "rkyv_derive" -version = "0.7.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "rmcp" version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fae7019994ae0fe4ada40b732f798f3ff26f0f04facb1477f1bf37eb4f18a2d3" dependencies = [ + "base64 0.23.1", + "bytes", "chrono", "futures", + "http", "indexmap 2.14.0", "pastey", "pin-project-lite", "schemars 1.2.2", "serde", "serde_json", - "thiserror 2.0.20", + "sse-stream", + "thiserror", "tokio", + "tokio-stream", "tokio-util", "tracing", "uuid", @@ -3107,23 +2281,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rust_decimal" -version = "1.42.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a" -dependencies = [ - "arrayvec", - "borsh", - "bytes", - "num-traits", - "rand 0.8.7", - "rkyv", - "serde", - "serde_json", - "wasm-bindgen", -] - [[package]] name = "rustc-hash" version = "1.1.0" @@ -3145,16 +2302,6 @@ dependencies = [ "semver", ] -[[package]] -name = "rustc_version_runtime" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dd18cd2bae1820af0b6ad5e54f4a51d0f3fcc53b05f845675074efcc7af071d" -dependencies = [ - "rustc_version", - "semver", -] - [[package]] name = "rustix" version = "0.38.44" @@ -3181,18 +2328,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "rustls" -version = "0.21.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" -dependencies = [ - "log", - "ring", - "rustls-webpki 0.101.7", - "sct", -] - [[package]] name = "rustls" version = "0.23.43" @@ -3202,54 +2337,11 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.14", + "rustls-webpki", "subtle", "zeroize", ] -[[package]] -name = "rustls-native-certs" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" -dependencies = [ - "openssl-probe", - "rustls-pemfile 1.0.4", - "schannel", - "security-framework", -] - -[[package]] -name = "rustls-native-certs" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" -dependencies = [ - "openssl-probe", - "rustls-pemfile 2.2.0", - "rustls-pki-types", - "schannel", - "security-framework", -] - -[[package]] -name = "rustls-pemfile" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" -dependencies = [ - "base64 0.21.7", -] - -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "rustls-pki-types" version = "1.15.1" @@ -3260,16 +2352,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-webpki" -version = "0.101.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" -dependencies = [ - "ring", - "untrusted", -] - [[package]] name = "rustls-webpki" version = "0.103.14" @@ -3293,15 +2375,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "schemars" version = "0.9.0" @@ -3346,45 +2419,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "sct" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" -dependencies = [ - "ring", - "untrusted", -] - -[[package]] -name = "seahash" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" - -[[package]] -name = "security-framework" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" -dependencies = [ - "bitflags 2.13.1", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "semver" version = "1.0.28" @@ -3401,16 +2435,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "serde_bytes" -version = "0.11.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" -dependencies = [ - "serde", - "serde_core", -] - [[package]] name = "serde_core" version = "1.0.229" @@ -3600,12 +2624,6 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - [[package]] name = "siphasher" version = "1.0.3" @@ -3616,255 +2634,51 @@ checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" name = "slab" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" -dependencies = [ - "serde", -] - -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - -[[package]] -name = "socket2" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "spin" -version = "0.9.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" -dependencies = [ - "lock_api", -] - -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - -[[package]] -name = "sqlx" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" -dependencies = [ - "sqlx-core", - "sqlx-macros", - "sqlx-mysql", - "sqlx-postgres", - "sqlx-sqlite", -] - -[[package]] -name = "sqlx-core" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" -dependencies = [ - "base64 0.22.1", - "bytes", - "chrono", - "crc", - "crossbeam-queue", - "either", - "event-listener", - "futures-core", - "futures-intrusive", - "futures-io", - "futures-util", - "hashbrown 0.15.5", - "hashlink", - "indexmap 2.14.0", - "log", - "memchr", - "once_cell", - "percent-encoding", - "rust_decimal", - "rustls 0.23.43", - "serde", - "serde_json", - "sha2", - "smallvec", - "thiserror 2.0.20", - "tokio", - "tokio-stream", - "tracing", - "url", - "uuid", - "webpki-roots 0.26.11", -] - -[[package]] -name = "sqlx-macros" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" -dependencies = [ - "proc-macro2", - "quote", - "sqlx-core", - "sqlx-macros-core", - "syn 2.0.119", -] - -[[package]] -name = "sqlx-macros-core" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" -dependencies = [ - "dotenvy", - "either", - "heck", - "hex", - "once_cell", - "proc-macro2", - "quote", - "serde", - "serde_json", - "sha2", - "sqlx-core", - "sqlx-mysql", - "sqlx-postgres", - "sqlx-sqlite", - "syn 2.0.119", - "tokio", - "url", -] +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] -name = "sqlx-mysql" -version = "0.8.6" +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ - "atoi", - "base64 0.22.1", - "bitflags 2.13.1", - "byteorder", - "bytes", - "chrono", - "crc", - "digest", - "dotenvy", - "either", - "futures-channel", - "futures-core", - "futures-io", - "futures-util", - "generic-array", - "hex", - "hkdf", - "hmac", - "itoa", - "log", - "md-5", - "memchr", - "once_cell", - "percent-encoding", - "rand 0.8.7", - "rsa", - "rust_decimal", - "serde", - "sha1", - "sha2", - "smallvec", - "sqlx-core", - "stringprep", - "thiserror 2.0.20", - "tracing", - "uuid", - "whoami", + "libc", + "windows-sys 0.61.2", ] [[package]] -name = "sqlx-postgres" -version = "0.8.6" +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ - "atoi", - "base64 0.22.1", - "bitflags 2.13.1", - "byteorder", - "chrono", - "crc", - "dotenvy", - "etcetera", - "futures-channel", - "futures-core", - "futures-util", - "hex", - "hkdf", - "hmac", - "home", - "itoa", - "log", - "md-5", - "memchr", - "once_cell", - "rand 0.8.7", - "rust_decimal", - "serde", - "serde_json", - "sha2", - "smallvec", - "sqlx-core", - "stringprep", - "thiserror 2.0.20", - "tracing", - "uuid", - "whoami", + "base64ct", + "der", ] [[package]] -name = "sqlx-sqlite" -version = "0.8.6" +name = "sse-stream" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +checksum = "c25ac7aff0abd1dbc474536e40416e1102c7dd9bfba0b9861c6d357f835dcfb4" dependencies = [ - "atoi", - "chrono", - "flume", - "futures-channel", - "futures-core", - "futures-executor", - "futures-intrusive", + "bytes", "futures-util", - "libsqlite3-sys", - "log", - "percent-encoding", - "serde", - "serde_urlencoded", - "sqlx-core", - "thiserror 2.0.20", - "tracing", - "url", - "uuid", + "http-body", + "http-body-util", + "pin-project-lite", ] [[package]] @@ -3903,17 +2717,6 @@ dependencies = [ "quote", ] -[[package]] -name = "stringprep" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" -dependencies = [ - "unicode-bidi", - "unicode-normalization", - "unicode-properties", -] - [[package]] name = "strsim" version = "0.11.1" @@ -3947,17 +2750,6 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "syn" version = "2.0.119" @@ -4000,18 +2792,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "take_mut" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f764005d11ee5f36500a149ace24e00e3da98b0158b3e2d53a7495660d3f4d60" - -[[package]] -name = "tap" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" - [[package]] name = "tendril" version = "0.5.1" @@ -4021,33 +2801,13 @@ dependencies = [ "new_debug_unreachable", ] -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - [[package]] name = "thiserror" version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.20", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "thiserror-impl", ] [[package]] @@ -4061,36 +2821,6 @@ dependencies = [ "syn 3.0.3", ] -[[package]] -name = "tiberius" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1446cb4198848d1562301a3340424b4f425ef79f35ef9ee034769a9dd92c10d" -dependencies = [ - "async-trait", - "asynchronous-codec", - "byteorder", - "bytes", - "chrono", - "connection-string", - "encoding_rs", - "enumflags2", - "futures-util", - "num-traits", - "once_cell", - "pin-project-lite", - "pretty-hex", - "rust_decimal", - "rustls-native-certs 0.6.3", - "rustls-pemfile 1.0.4", - "thiserror 1.0.69", - "tokio", - "tokio-rustls 0.24.1", - "tokio-util", - "tracing", - "uuid", -] - [[package]] name = "time" version = "0.3.55" @@ -4121,15 +2851,6 @@ dependencies = [ "time-core", ] -[[package]] -name = "tiny-keccak" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" -dependencies = [ - "crunchy", -] - [[package]] name = "tinystr" version = "0.8.3" @@ -4167,7 +2888,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.5", + "socket2", "tokio-macros", "windows-sys 0.61.2", ] @@ -4183,23 +2904,13 @@ dependencies = [ "syn 3.0.3", ] -[[package]] -name = "tokio-rustls" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" -dependencies = [ - "rustls 0.21.12", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.23.43", + "rustls", "tokio", ] @@ -4406,7 +3117,7 @@ dependencies = [ "log", "rand 0.9.5", "sha1", - "thiserror 2.0.20", + "thiserror", "utf-8", ] @@ -4416,29 +3127,12 @@ version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" -[[package]] -name = "typed-builder" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89851716b67b937e393b3daa8423e67ddfc4bbbf1654bcf05488e95e0828db0c" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "typenum" version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" -[[package]] -name = "unicode-bidi" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" - [[package]] name = "unicode-ident" version = "1.0.24" @@ -4454,12 +3148,6 @@ dependencies = [ "tinyvec", ] -[[package]] -name = "unicode-properties" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" - [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -4496,12 +3184,6 @@ dependencies = [ "serde", ] -[[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - [[package]] name = "utf-8" version = "0.7.6" @@ -4522,16 +3204,9 @@ checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ "getrandom 0.4.3", "js-sys", - "serde_core", "wasm-bindgen", ] -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "version_check" version = "0.9.5" @@ -4562,12 +3237,6 @@ dependencies = [ "wit-bindgen", ] -[[package]] -name = "wasite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" - [[package]] name = "wasm-bindgen" version = "0.2.127" @@ -4577,7 +3246,6 @@ dependencies = [ "cfg-if", "once_cell", "rustversion", - "serde", "wasm-bindgen-macro", "wasm-bindgen-shared", ] @@ -4669,21 +3337,6 @@ dependencies = [ "string_cache_codegen", ] -[[package]] -name = "webpki-roots" -version = "0.25.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" - -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.9", -] - [[package]] name = "webpki-roots" version = "1.0.9" @@ -4705,22 +3358,6 @@ dependencies = [ "rustix 0.38.44", ] -[[package]] -name = "whoami" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" -dependencies = [ - "libredox", - "wasite", -] - -[[package]] -name = "widestring" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" - [[package]] name = "winapi" version = "0.3.9" @@ -4750,7 +3387,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" dependencies = [ "windows-core 0.52.0", - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -4759,7 +3396,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -4803,17 +3440,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link", - "windows-result", - "windows-strings", -] - [[package]] name = "windows-result" version = "0.4.1" @@ -4832,22 +3458,13 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -4859,67 +3476,34 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", "windows_i686_gnullvm", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -4932,48 +3516,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -5001,15 +3561,6 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" -[[package]] -name = "wyz" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" -dependencies = [ - "tap", -] - [[package]] name = "yoke" version = "0.8.3" @@ -5126,7 +3677,7 @@ dependencies = [ "flate2", "indexmap 2.14.0", "memchr", - "thiserror 2.0.20", + "thiserror", "zopfli", ] diff --git a/rust/Cargo.toml b/rust/Cargo.toml index f5536e4c34..bf2995801d 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["lithe-core", "lithe-git-host", "lithe-db-sidecar", "lithe-db-mcp", "lithe-agent-host", "lithe-ide-host"] +members = ["lithe-core", "lithe-git-host", "lithe-agent-host", "lithe-ide-host", "lithe-remote-protocol", "lithe-remote-server", "lithe-remote-client"] resolver = "2" [profile.release] diff --git a/rust/lithe-agent-host/Cargo.toml b/rust/lithe-agent-host/Cargo.toml index e8dcf5b1c8..bfef35936f 100644 --- a/rust/lithe-agent-host/Cargo.toml +++ b/rust/lithe-agent-host/Cargo.toml @@ -9,6 +9,7 @@ description = "Shared ACP session and agent process host for Lithe" agent-client-protocol = { version = "=2.2.0", features = ["unstable_end_turn_token_usage", "unstable_session_notices"] } bytes = "1" futures = "0.3" +fs2 = "0.4" flate2 = { version = "1", default-features = false, features = ["rust_backend"] } http-body-util = "0.1" hyper = { version = "1", features = ["http1", "server"] } @@ -16,7 +17,12 @@ hyper-util = { version = "0.1", features = ["tokio"] } kill_tree = { version = "0.2", features = ["tokio"] } serde = { version = "1", features = ["derive"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] } +rmcp = { version = "=3.5.0", default-features = false, features = ["client", "transport-async-rw", "transport-streamable-http-client"] } +http = "1" +sse-stream = "=0.2.6" serde_json = "1" +sha2 = "0.10" +serde_yaml_ng = "0.10.0" semver = "1" tokio = { version = "1", features = ["io-util", "net", "process", "rt", "sync", "time"] } tokio-util = { version = "0.7", features = ["compat"] } @@ -27,6 +33,9 @@ uuid = { version = "1", features = ["v4"] } [target.'cfg(unix)'.dependencies] libc = "0.2" +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] } + [dev-dependencies] tokio = { version = "1", features = ["macros", "test-util"] } tungstenite = { version = "0.28", default-features = false, features = ["handshake"] } diff --git a/rust/lithe-agent-host/src/claude_desktop.rs b/rust/lithe-agent-host/src/claude_desktop.rs new file mode 100644 index 0000000000..6b8cd1a759 --- /dev/null +++ b/rust/lithe-agent-host/src/claude_desktop.rs @@ -0,0 +1,805 @@ +//! Readable replay of sessions started in Claude Desktop. +//! +//! Claude Desktop runs Claude Code with its own MCP servers (`ccd_turn`, +//! `ccd_session`, `ccd_pr`, `ccd_view`, `visualize`, `terminal`, …). The model +//! there reports progress, ends turns, proposes follow-up tasks and binds pull +//! requests through those tools, and Desktop draws each as part of its UI. +//! Resumed in Lithe, they would be anonymous `mcp__…` rows in the collapsed +//! process area and the transcript would look empty or cryptic. +//! +//! This presenter turns them into what Desktop shows, in two ways: +//! +//! - Standard ACP where it has a shape: `wrap_up` becomes the turn's agent +//! message, `update_plan` a `plan` update, files a pane showed become tool +//! locations. +//! - A card for mechanisms ACP has no shape for, as `_meta.lithe.claudeDesktop` +//! on the tool call (see `shared/contracts/application-boundary.md`). Every +//! such call keeps a readable English title and kind, so a client that does +//! not draw the card still shows a sensible tool row. +//! +//! It applies to the adapter's replay and to the earlier history read from disk +//! alike. Lithe never offers these tools, so live turns are unaffected. + +use std::collections::{HashMap, HashSet}; + +use serde_json::{json, Value}; + +/// Tool Claude Desktop's model uses to end a turn with a recap and a question. +const WRAP_UP_TOOL: &str = "mcp__ccd_turn__wrap_up"; +/// Tool Claude Desktop's model uses to set or advance its step checklist. +const UPDATE_PLAN_TOOL: &str = "mcp__ccd_turn__update_plan"; +/// Widget source above this many characters is not carried in the card. +const WIDGET_CODE_LIMIT: usize = 256 * 1024; +/// Limit for prompts, summaries and other free text carried in a card. +const CARD_TEXT_LIMIT: usize = 16 * 1024; + +/// One checklist step; `status` is an ACP plan status +/// (`pending`, `in_progress` or `completed`). +#[derive(Clone)] +struct Step { + content: String, + status: &'static str, +} + +#[derive(Default)] +pub(crate) struct Presenter { + /// Tool call ids already shown another way; their follow-up + /// `tool_call_update`s (status, the tool's acknowledgement) are dropped. + absorbed: HashSet, + /// Latest checklist per session. `update_plan` may only advance or tick + /// steps of the list set earlier, so each call is applied to this state. + plans: HashMap>, + /// Readable title and kind per retitled call, kept so a later update that + /// restates the adapter's own title does not undo them. + titles: HashMap, + /// Task cards by tool call id, kept so a later call can change their state. + tasks: HashMap, + /// Desktop task id (`task_…`) → tool call id of the card that proposed it. + task_ids: HashMap, + /// Calls that change a task card once their result arrives, by tool call id. + task_changes: HashMap, +} + +/// A `dismiss_task` or `start_session` call held back until its result says +/// whether the card it targets really changed. +struct TaskChange { + task: String, + /// State a successful result means. + state: &'static str, + /// The call as a readable tool row, shown only if the call fails. + call: Value, +} + +impl Presenter { + /// `update` as the client should see it: usually one update, none when it + /// is absorbed, two when a turn's recap also offers suggested replies. + pub(crate) fn present(&mut self, session_id: &str, update: Value) -> Vec { + if update["sessionUpdate"] == "agent_message_chunk" { + return vec![present_pr_created(update)]; + } + let Some(id) = update["toolCallId"].as_str().map(str::to_owned) else { + return vec![update]; + }; + match update["sessionUpdate"].as_str() { + Some("tool_call") => self.tool_call(session_id, id, update), + Some("tool_call_update") => self.tool_call_update(id, update), + _ => vec![update], + } + } + + fn tool_call(&mut self, session_id: &str, id: String, update: Value) -> Vec { + let Some(name) = update["name"].as_str().map(str::to_owned) else { + return vec![update]; + }; + let input = update["rawInput"].clone(); + match name.as_str() { + WRAP_UP_TOOL => { + let Some(text) = wrap_up_text(&input) else { + return vec![update]; + }; + self.absorbed.insert(id.clone()); + let mut shown = vec![json!({ + "sessionUpdate": "agent_message_chunk", + "content": {"type": "text", "text": text}, + })]; + // The suggested replies are buttons in Desktop's next-step box. + if let Some(actions) = next_step_actions(&input) { + let mut replies = json!({ + "sessionUpdate": "tool_call", + "toolCallId": format!("{id}#next"), + "title": "Suggested replies", + "kind": "other", + "status": "completed", + }); + set_card(&mut replies, json!({"card": "nextStep", "actions": actions})); + shown.push(replies); + } + shown + } + UPDATE_PLAN_TOOL => { + let steps = self.plans.entry(session_id.to_owned()).or_default(); + if apply_plan(steps, &input) { + self.absorbed.insert(id); + return vec![plan_update(steps)]; + } + self.retitle(id, update, "Updated the task list", "think") + } + "mcp__ccd_session__dismiss_task" => { + // Shown as the state of the card it withdraws, once the result confirms it. + if let Some(task) = str_field(&input, "task_id") { + let call = readable_call(update, "Withdrew a suggested task"); + self.task_changes.insert(id.clone(), TaskChange { task: task.to_owned(), state: "withdrawn", call }); + } + self.absorbed.insert(id); + Vec::new() + } + "mcp__ccd_session__start_session" if str_field(&input, "proposal_id").is_some() => { + // Starting a proposed card: the card itself changes state. + let task = str_field(&input, "proposal_id").unwrap_or_default().to_owned(); + let call = readable_call(update, "Started a suggested task"); + self.task_changes.insert(id.clone(), TaskChange { task, state: "started", call }); + self.absorbed.insert(id); + Vec::new() + } + _ => { + let Some(described) = describe(&name, &input) else { + return vec![update]; + }; + let mut update = update; + if !described.locations.is_empty() { + update["locations"] = Value::Array(described.locations); + } + if let Some(card) = described.card { + if card["card"] == "task" { + self.tasks.insert(id.clone(), card.clone()); + } + set_card(&mut update, card); + } + self.retitle(id, update, &described.title, described.kind) + } + } + } + + fn tool_call_update(&mut self, id: String, mut update: Value) -> Vec { + if self.task_changes.contains_key(&id) { + return self.finish_task_change(&id, update); + } + if self.absorbed.contains(&id) { + return Vec::new(); + } + if self.tasks.contains_key(&id) { + if let Some(task) = task_id_in(&result_text(&update)) { + self.task_ids.insert(task, id.clone()); + } + } + if let Some((title, kind)) = self.titles.get(&id) { + if update.get("title").is_some() { + update["title"] = json!(title); + update["kind"] = json!(kind); + } + } + vec![update] + } + + /// A held-back task call got an update: wait out progress, apply a success + /// to its card, and keep a failure visible, since nothing changed. + fn finish_task_change(&mut self, id: &str, update: Value) -> Vec { + let status = update["status"].as_str(); + if !matches!(status, Some("completed" | "failed")) { + return Vec::new(); + } + let Some(change) = self.task_changes.remove(id) else { + return vec![update]; + }; + self.absorbed.remove(id); + if status == Some("failed") { + return vec![change.call, update]; + } + // dismiss_task reports a task the user already started instead of withdrawing it. + let text = result_text(&update); + let state = if text.contains("already started") || text.contains("no longer pending") { + "started" + } else { + change.state + }; + self.change_task(&change.task, state) + } + + fn retitle(&mut self, id: String, mut update: Value, title: &str, kind: &'static str) -> Vec { + update["title"] = json!(title); + update["kind"] = json!(kind); + self.titles.insert(id, (title.to_owned(), kind)); + vec![update] + } + + /// The card that proposed `task`, now in `state`. + fn change_task(&mut self, task: &str, state: &'static str) -> Vec { + let Some(call) = self.task_ids.get(task).cloned() else { + return Vec::new(); + }; + let Some(card) = self.tasks.get_mut(&call) else { + return Vec::new(); + }; + card["state"] = json!(state); + let mut update = json!({"sessionUpdate": "tool_call_update", "toolCallId": call}); + set_card(&mut update, card.clone()); + vec![update] + } +} + +/// `call` as an ordinary tool row with a readable title. +fn readable_call(mut call: Value, title: &str) -> Value { + call["title"] = json!(title); + call["kind"] = json!("other"); + call +} + +/// Whether a tool's input feeds a card, plan or reply, so a replay must carry +/// it whole instead of cut for a tool-detail preview. +pub(crate) fn carries_card_input(name: &str) -> bool { + name.strip_prefix("mcp__") + .and_then(|rest| rest.split_once("__")) + .is_some_and(|(server, _)| { + matches!( + server, + "ccd_turn" | "ccd_session" | "ccd_pr" | "ccd_directory" | "ccd_view" | "ccd_host" | "visualize" + ) + }) +} + +/// Largest string of such an input a replay carries; the widget source limit +/// is the largest of the card fields. +pub(crate) const CARD_INPUT_LIMIT: usize = WIDGET_CODE_LIMIT; + +/// Readable presentation of one Desktop tool call. +struct Described { + title: String, + kind: &'static str, + locations: Vec, + card: Option, +} + +impl Described { + fn row(title: impl Into, kind: &'static str) -> Self { + Self { title: title.into(), kind, locations: Vec::new(), card: None } + } + + fn card(title: impl Into, card: Value) -> Self { + Self { card: Some(card), ..Self::row(title, "other") } + } +} + +/// Title, kind and card for a Desktop tool, or None for any other tool. +fn describe(name: &str, input: &Value) -> Option { + let (server, tool) = name.strip_prefix("mcp__")?.split_once("__")?; + let field = |key: &str| str_field(input, key); + let described = match (server, tool) { + ("ccd_session", "mark_chapter") => { + let title = field("title")?; + Described::card( + format!("Chapter: {title}"), + json!({"card": "chapter", "title": title, "summary": field("summary")}), + ) + } + ("ccd_session", "spawn_task") => { + task_card(field("title")?, field("tldr"), field("prompt"), "proposed") + } + ("ccd_session", "start_session") => task_card( + field("title")?, + None, + field("prompt"), + // The model may only propose; a start the user asked for runs at once. + if field("initiation") == Some("own_initiative") { "proposed" } else { "started" }, + ), + ("ccd_session", "hand_off_to_session") => { + let plan = field("remaining_plan"); + let summary = plan.and_then(|plan| plan.lines().map(str::trim).find(|line| !line.is_empty())); + task_card(field("title")?, summary, plan, "handedOff") + } + ("ccd_session", "read_widget_context") => Described::row("Read the widget's context", "read"), + ("ccd_pr", "get_status") => Described::row("Checked the pull request status", "read"), + ("ccd_pr", action @ ("bind_pr" | "unbind_pr" | "set_monitor" | "set_auto_merge")) => { + let url = field("url"); + let action = match action { + "bind_pr" => "bound", + "unbind_pr" => "unbound", + "set_monitor" => "monitored", + _ => "autoMerge", + }; + let mut card = json!({"card": "pullRequest", "action": action, "url": url}); + if let Some(number) = url.and_then(pr_number) { + card["number"] = json!(number); + } + match action { + "monitored" => card["autoFix"] = json!(input["auto_fix"] == true), + "autoMerge" => card["enabled"] = json!(input["enabled"] != false), + _ => {} + } + let title = url.map_or("Pull request".to_owned(), |url| format!("Pull request {url}")); + Described::card(title, card) + } + ("ccd_directory", action @ ("change_directory" | "request_directory")) => { + let path = field("path")?; + let action = if action == "change_directory" { "changed" } else { "requested" }; + Described::card( + format!("Working folder {path}"), + json!({"card": "directory", "action": action, "path": path}), + ) + } + ("ccd_view", "show_pane") => match field("path") { + Some(path) => Described { + locations: vec![json!({"path": path})], + ..Described::row(format!("Showed {path}"), "read") + }, + None => Described::row(format!("Showed the {} pane", field("pane").unwrap_or("side")), "other"), + }, + ("ccd_host", tool @ ("open_in_editor" | "reveal_path")) => { + let path = field("path")?; + let verb = if tool == "open_in_editor" { "Opened" } else { "Revealed" }; + let mut location = json!({"path": path}); + if let Some(line) = input["line"].as_u64().filter(|line| *line > 0) { + location["line"] = json!(line); + } + Described { locations: vec![location], ..Described::row(format!("{verb} {path}"), "read") } + } + ("visualize", "show_widget") => { + let title = humanize(field("title").unwrap_or("visualization")); + let code = field("widget_code").filter(|code| code.chars().count() <= WIDGET_CODE_LIMIT); + Described::card( + format!("Visualization: {title}"), + json!({"card": "widget", "title": title, "code": code}), + ) + } + ("visualize", "read_me") => Described::row("Read the visualization guide", "read"), + ("terminal", "run_in_terminal") => Described::row( + field("command").map_or("Ran a command in a terminal tab".to_owned(), first_line), + "execute", + ), + ("terminal", "read_terminal") => Described::row("Read a terminal tab", "read"), + ("Claude_Browser", "navigate") => Described::row( + field("url").map_or("Opened a page".to_owned(), |url| format!("Opened {url}")), + "fetch", + ), + (server, tool) => { + let area = match server { + "ccd_turn" | "ccd_session" | "ccd_session_mgmt" | "ccd_sidebar" | "ccd_window" + | "ccd_view" | "ccd_settings" | "ccd_host" | "ccd_pr" | "ccd_directory" + | "ccd_connectors" => "Claude Desktop", + "Claude_Browser" => "Browser", + "computer-use" => "Computer use", + "terminal" => "Terminal", + "scheduled-tasks" => "Scheduled tasks", + "visualize" => "Visualization", + // Desktop names a claude.ai connector by its id, which says nothing to the user. + _ if is_connector_id(server) => "Connector", + _ => return None, + }; + let kind = match tool.split('_').next() { + Some("list" | "get" | "read" | "search" | "find") => "read", + _ => "other", + }; + Described::row(format!("{area}: {}", humanize(tool)), kind) + } + }; + Some(described) +} + +/// A UUID in its 8-4-4-4-12 form. +fn is_connector_id(server: &str) -> bool { + let groups: Vec<_> = server.split('-').collect(); + groups.len() == 5 + && groups.iter().zip([8, 4, 4, 4, 12]).all(|(group, len)| { + group.len() == len && group.chars().all(|c| c.is_ascii_hexdigit()) + }) +} + +fn task_card(title: &str, summary: Option<&str>, prompt: Option<&str>, state: &str) -> Described { + Described::card( + format!("Suggested task: {title}"), + json!({ + "card": "task", + "title": title, + "summary": summary.map(|text| truncate(text, CARD_TEXT_LIMIT)), + "prompt": prompt.map(|text| truncate(text, CARD_TEXT_LIMIT)), + "state": state, + }), + ) +} + +fn set_card(update: &mut Value, card: Value) { + if !update["_meta"].is_object() { + update["_meta"] = json!({}); + } + if !update["_meta"]["lithe"].is_object() { + update["_meta"]["lithe"] = json!({}); + } + update["_meta"]["lithe"]["claudeDesktop"] = card; +} + +fn str_field<'a>(input: &'a Value, key: &str) -> Option<&'a str> { + input[key].as_str().map(str::trim).filter(|value| !value.is_empty()) +} + +/// The recap followed by the question, as separate paragraphs. +fn wrap_up_text(input: &Value) -> Option { + let text = ["recap", "ask"] + .iter() + .filter_map(|key| str_field(input, key)) + .collect::>() + .join("\n\n"); + (!text.is_empty()).then_some(text) +} + +/// Suggested replies that send a message. A label-only action only closes +/// Desktop's box, so it has nothing to offer here. +fn next_step_actions(input: &Value) -> Option> { + let actions: Vec = input["actions"] + .as_array()? + .iter() + .filter_map(|action| { + let label = str_field(action, "label")?; + let message = str_field(action, "message")?; + Some(json!({"label": label, "message": truncate(message, CARD_TEXT_LIMIT)})) + }) + .take(4) + .collect(); + (!actions.is_empty()).then_some(actions) +} + +/// Desktop asks the model to wrap a created PR's URL in `` so it +/// can draw a status card; elsewhere the tag is noise around a link. +fn present_pr_created(mut update: Value) -> Value { + const OPEN: &str = ""; + const CLOSE: &str = ""; + let Some(text) = update["content"]["text"].as_str().filter(|text| text.contains(OPEN)) else { + return update; + }; + let mut out = String::with_capacity(text.len()); + let mut rest = text; + while let Some(start) = rest.find(OPEN) { + let after = &rest[start + OPEN.len()..]; + let Some(end) = after.find(CLOSE) else { break }; + let url = after[..end].trim(); + out.push_str(&rest[..start]); + if url.starts_with("https://") && !url.contains(char::is_whitespace) { + match pr_number(url) { + Some(number) => out.push_str(&format!("[Pull request #{number}]({url})")), + None => out.push_str(&format!("[{url}]({url})")), + } + } else { + out.push_str(&rest[start..start + OPEN.len() + end + CLOSE.len()]); + } + rest = &after[end + CLOSE.len()..]; + } + out.push_str(rest); + update["content"]["text"] = json!(out); + update +} + +/// Number of a GitHub-style `…/pull/` URL. +fn pr_number(url: &str) -> Option { + let (_, tail) = url.rsplit_once("/pull/")?; + tail.split(['/', '#', '?']).next()?.parse().ok() +} + +/// Text of a tool result update's content blocks. +fn result_text(update: &Value) -> String { + update["content"] + .as_array() + .into_iter() + .flatten() + .filter_map(|item| item["content"]["text"].as_str()) + .collect::>() + .join("\n") +} + +/// First `task_` Desktop names in a tool result, past its `task_id:` label. +fn task_id_in(text: &str) -> Option { + text.match_indices("task_") + .map(|(start, _)| { + text[start..] + .chars() + .take_while(|c| c.is_ascii_alphanumeric() || *c == '_') + .collect::() + }) + .find(|id| id != "task_id" && id.len() > "task_".len()) +} + +/// `list_sessions` → `list sessions`. +fn humanize(name: &str) -> String { + name.replace(['_', '-'], " ") +} + +fn first_line(text: &str) -> String { + truncate(text.lines().next().unwrap_or(text), 200) +} + +fn truncate(text: &str, limit: usize) -> String { + match text.char_indices().nth(limit) { + Some((index, _)) => format!("{}…", &text[..index]), + None => text.to_owned(), + } +} + +/// Applies one `update_plan` input and reports whether it changed anything. +/// Inputs either replace the list (`items`), tick steps by one-based index +/// (`done`) and start one (`start`), or `advance`: finish the running step +/// and start the next pending one. +fn apply_plan(steps: &mut Vec, input: &Value) -> bool { + if let Some(items) = input["items"].as_array() { + *steps = items + .iter() + .filter_map(|item| { + Some(Step { + content: item["content"].as_str()?.to_owned(), + status: status(item["status"].as_str()), + }) + }) + .collect(); + return !steps.is_empty(); + } + if steps.is_empty() { + return false; + } + let mut changed = false; + let mut set = |index: usize, value: &'static str| { + if let Some(step) = index.checked_sub(1).and_then(|i| steps.get_mut(i)) { + step.status = value; + changed = true; + } + }; + for index in input["done"].as_array().into_iter().flatten() { + set(index.as_u64().unwrap_or(0) as usize, "completed"); + } + if let Some(index) = input["start"].as_u64() { + set(index as usize, "in_progress"); + } + if input["advance"] == true { + let running = steps.iter().position(|s| s.status == "in_progress"); + if let Some(index) = running { + steps[index].status = "completed"; + } + let from = running.map_or(0, |index| index + 1); + if let Some(next) = steps[from..].iter_mut().find(|s| s.status == "pending") { + next.status = "in_progress"; + } + changed = true; + } + changed +} + +fn status(value: Option<&str>) -> &'static str { + match value { + Some("completed") => "completed", + Some("in_progress") => "in_progress", + _ => "pending", + } +} + +fn plan_update(steps: &[Step]) -> Value { + let entries: Vec = steps + .iter() + .map(|step| json!({"content": step.content, "status": step.status, "priority": "medium"})) + .collect(); + json!({"sessionUpdate": "plan", "entries": entries}) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn call(name: &str, id: &str, input: Value) -> Value { + json!({"sessionUpdate": "tool_call", "toolCallId": id, "name": name, + "title": name, "status": "pending", "rawInput": input}) + } + + fn done(id: &str) -> Value { + json!({"sessionUpdate": "tool_call_update", "toolCallId": id, "status": "completed"}) + } + + fn result(id: &str, text: &str) -> Value { + json!({"sessionUpdate": "tool_call_update", "toolCallId": id, "status": "completed", + "content": [{"type": "content", "content": {"type": "text", "text": text}}]}) + } + + fn one(mut shown: Vec) -> Value { + assert_eq!(shown.len(), 1, "{shown:?}"); + shown.remove(0) + } + + fn card(update: &Value) -> &Value { + &update["_meta"]["lithe"]["claudeDesktop"] + } + + fn statuses(update: &Value) -> Vec<&str> { + update["entries"] + .as_array() + .unwrap() + .iter() + .map(|e| e["status"].as_str().unwrap()) + .collect() + } + + #[test] + fn a_wrap_up_becomes_the_turn_answer_with_its_suggested_replies() { + let mut presenter = Presenter::default(); + let input = json!({"recap": "PR opened.", "ask": "Mark it ready?", + "actions": [{"label": "Yes", "message": "yes"}, {"label": "Not now"}]}); + let shown = presenter.present("s", call(WRAP_UP_TOOL, "w", input)); + assert_eq!(shown.len(), 2); + assert_eq!(shown[0]["sessionUpdate"], "agent_message_chunk"); + assert_eq!(shown[0]["content"]["text"], "PR opened.\n\nMark it ready?"); + assert_eq!(shown[1]["toolCallId"], "w#next"); + assert_eq!(card(&shown[1])["card"], "nextStep"); + assert_eq!(card(&shown[1])["actions"], json!([{"label": "Yes", "message": "yes"}])); + assert!(presenter.present("s", done("w")).is_empty()); + + let recap_only = presenter.present("s", call(WRAP_UP_TOOL, "v", json!({"recap": "Done."}))); + assert_eq!(recap_only.len(), 1); + } + + #[test] + fn plan_calls_become_plan_updates_that_track_incremental_progress() { + let mut presenter = Presenter::default(); + let items = json!({"items": [ + {"content": "a", "status": "in_progress"}, + {"content": "b", "status": "pending"}, + {"content": "c", "status": "pending"}, + ]}); + let set = one(presenter.present("s", call(UPDATE_PLAN_TOOL, "p1", items))); + assert_eq!(set["sessionUpdate"], "plan"); + assert_eq!(statuses(&set), ["in_progress", "pending", "pending"]); + assert!(presenter.present("s", done("p1")).is_empty()); + + let advanced = one(presenter.present("s", call(UPDATE_PLAN_TOOL, "p2", json!({"advance": true})))); + assert_eq!(statuses(&advanced), ["completed", "in_progress", "pending"]); + + let ticked = call(UPDATE_PLAN_TOOL, "p3", json!({"done": [2], "start": 3})); + let ticked = one(presenter.present("s", ticked)); + assert_eq!(statuses(&ticked), ["completed", "completed", "in_progress"]); + } + + #[test] + fn other_tools_empty_calls_and_unknown_sessions_pass_through() { + let mut presenter = Presenter::default(); + let empty = call(WRAP_UP_TOOL, "w", json!({})); + assert_eq!(presenter.present("s", empty.clone()), vec![empty]); + assert_eq!(presenter.present("s", done("w")), vec![done("w")]); + // An increment without a list to apply it to stays a tool call, now titled. + let orphan = call(UPDATE_PLAN_TOOL, "p", json!({"advance": true})); + assert_eq!(one(presenter.present("t", orphan))["title"], "Updated the task list"); + let bash = call("Bash", "b", json!({"command": "ls"})); + assert_eq!(presenter.present("s", bash.clone()), vec![bash]); + let other_server = call("mcp__dbx__dbx_list_connections", "d", json!({})); + assert_eq!(presenter.present("s", other_server.clone()), vec![other_server]); + // A claude.ai connector is named by its id; a server merely resembling one is not. + let connector = call("mcp__1a59c906-04da-521d-bda7-7f71b9f9e01c__read", "k", json!({})); + let connector = one(presenter.present("s", connector)); + assert_eq!((connector["title"].as_str(), connector["kind"].as_str()), (Some("Connector: read"), Some("read"))); + let lookalike = call("mcp__1a59c906-04da-521d-bda7__read", "l", json!({})); + assert_eq!(presenter.present("s", lookalike.clone()), vec![lookalike]); + let text = json!({"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "hi"}}); + assert_eq!(presenter.present("s", text.clone()), vec![text]); + } + + #[test] + fn chapters_pull_requests_folders_and_widgets_become_cards_with_readable_titles() { + let mut presenter = Presenter::default(); + let input = json!({"title": "Core", "summary": "Rust first"}); + let chapter = one(presenter.present("s", call("mcp__ccd_session__mark_chapter", "c", input))); + assert_eq!(chapter["title"], "Chapter: Core"); + assert_eq!(card(&chapter), &json!({"card": "chapter", "title": "Core", "summary": "Rust first"})); + // Card calls keep their own results, so a failed call stays visible as one. + assert_eq!(presenter.present("s", done("c")), vec![done("c")]); + // A later update restating the adapter's title keeps the readable one. + let restated = json!({"sessionUpdate": "tool_call_update", "toolCallId": "c", + "title": "mcp__ccd_session__mark_chapter", "kind": "other"}); + assert_eq!(one(presenter.present("s", restated))["title"], "Chapter: Core"); + + let input = json!({"url": "https://github.com/o/r/pull/31", "auto_fix": true}); + let pr = one(presenter.present("s", call("mcp__ccd_pr__set_monitor", "p", input))); + assert_eq!(card(&pr)["card"], "pullRequest"); + assert_eq!(card(&pr)["action"], "monitored"); + assert_eq!(card(&pr)["number"], 31); + assert_eq!(card(&pr)["autoFix"], true); + + let input = json!({"path": "/w/other"}); + let folder = one(presenter.present("s", call("mcp__ccd_directory__change_directory", "d", input))); + assert_eq!(card(&folder), &json!({"card": "directory", "action": "changed", "path": "/w/other"})); + + let input = json!({"title": "vcs_layers", "widget_code": ""}); + let widget = one(presenter.present("s", call("mcp__visualize__show_widget", "v", input))); + assert_eq!(widget["title"], "Visualization: vcs layers"); + assert_eq!(card(&widget)["code"], ""); + } + + #[test] + fn panes_and_editor_requests_become_file_locations() { + let mut presenter = Presenter::default(); + let input = json!({"pane": "file", "path": "notes/a.md"}); + let pane = one(presenter.present("s", call("mcp__ccd_view__show_pane", "v", input))); + assert_eq!(pane["kind"], "read"); + assert_eq!(pane["locations"], json!([{"path": "notes/a.md"}])); + let input = json!({"path": "/w/a.rs", "line": 12}); + let editor = one(presenter.present("s", call("mcp__ccd_host__open_in_editor", "e", input))); + assert_eq!(editor["locations"], json!([{"path": "/w/a.rs", "line": 12}])); + let layout = one(presenter.present("s", call("mcp__ccd_window__get_window_layout", "l", json!({})))); + assert_eq!(layout["title"], "Claude Desktop: get window layout"); + assert_eq!(layout["kind"], "read"); + let shot = one(presenter.present("s", call("mcp__computer-use__app_screenshot", "x", json!({})))); + assert_eq!(shot["title"], "Computer use: app screenshot"); + } + + #[test] + fn task_cards_follow_their_proposal_withdrawal_and_start() { + let mut presenter = Presenter::default(); + let input = json!({"title": "Fix", "tldr": "Why", "prompt": "Do it"}); + let proposed = one(presenter.present("s", call("mcp__ccd_session__spawn_task", "t1", input))); + assert_eq!(card(&proposed)["state"], "proposed"); + assert_eq!(card(&proposed)["prompt"], "Do it"); + presenter.present("s", result("t1", "Noted (position 1, task_id: task_9edecafe). A chip is showing")); + + // The withdrawal call itself is absorbed; its result restates the card. + let dismiss = call("mcp__ccd_session__dismiss_task", "x", json!({"task_id": "task_9edecafe"})); + assert!(presenter.present("s", dismiss).is_empty()); + let withdrawn = one(presenter.present("s", result("x", "Task task_9edecafe withdrawn"))); + assert_eq!(withdrawn["toolCallId"], "t1"); + assert_eq!(card(&withdrawn)["state"], "withdrawn"); + + let input = json!({"initiation": "own_initiative", "title": "Move", "prompt": "Go"}); + presenter.present("s", call("mcp__ccd_session__start_session", "t2", input)); + presenter.present("s", result("t2", "Proposed \"Move\" (task_id: task_0f1ad1a7). The user sees a card")); + let start = call("mcp__ccd_session__start_session", "y", json!({"proposal_id": "task_0f1ad1a7"})); + assert!(presenter.present("s", start).is_empty()); + assert_eq!(card(&one(presenter.present("s", done("y"))))["state"], "started"); + + let dismiss = call("mcp__ccd_session__dismiss_task", "z", json!({"task_id": "task_0f1ad1a7"})); + presenter.present("s", dismiss); + let late = one(presenter.present("s", result("z", "Task task_0f1ad1a7 was already started"))); + assert_eq!(card(&late)["state"], "started"); + + let input = json!({"title": "Stats", "remaining_plan": "\nDone: x\nNext: y"}); + let handoff = one(presenter.present("s", call("mcp__ccd_session__hand_off_to_session", "h", input))); + assert_eq!(card(&handoff)["state"], "handedOff"); + assert_eq!(card(&handoff)["summary"], "Done: x"); + } + + #[test] + fn failed_task_actions_stay_visible_and_progress_does_not_change_the_card() { + let mut presenter = Presenter::default(); + presenter.present("s", call("mcp__ccd_session__spawn_task", "t1", json!({"title": "Fix", "prompt": "Do it"}))); + presenter.present("s", result("t1", "Noted (task_id: task_9edecafe).")); + + let dismiss = call("mcp__ccd_session__dismiss_task", "x", json!({"task_id": "task_9edecafe"})); + assert!(presenter.present("s", dismiss).is_empty()); + let progress = json!({"sessionUpdate": "tool_call_update", "toolCallId": "x", "status": "in_progress"}); + assert!(presenter.present("s", progress).is_empty(), "Progress must not withdraw the card"); + let failed = json!({"sessionUpdate": "tool_call_update", "toolCallId": "x", "status": "failed", + "content": [{"type": "content", "content": {"type": "text", "text": "Denied"}}]}); + let shown = presenter.present("s", failed); + assert_eq!(shown.len(), 2, "{shown:?}"); + assert_eq!(shown[0]["sessionUpdate"], "tool_call"); + assert_eq!(shown[0]["toolCallId"], "x"); + assert_eq!(shown[0]["title"], "Withdrew a suggested task"); + assert_eq!(shown[1]["status"], "failed"); + + let start = call("mcp__ccd_session__start_session", "y", json!({"proposal_id": "task_9edecafe"})); + assert!(presenter.present("s", start).is_empty()); + let failed = json!({"sessionUpdate": "tool_call_update", "toolCallId": "y", "status": "failed"}); + assert_eq!(presenter.present("s", failed)[0]["title"], "Started a suggested task"); + } + + #[test] + fn created_pull_request_tags_become_links() { + let mut presenter = Presenter::default(); + let chunk = |text: &str| { + json!({"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": text}}) + }; + let tagged = chunk("Opened.\nhttps://github.com/o/r/pull/7\n"); + let shown = one(presenter.present("s", tagged)); + assert_eq!(shown["content"]["text"], "Opened.\n[Pull request #7](https://github.com/o/r/pull/7)\n"); + // Anything but a URL is left as the model wrote it. + let odd = chunk("not a url"); + assert_eq!(one(presenter.present("s", odd.clone())), odd); + } +} diff --git a/rust/lithe-agent-host/src/claude_earlier_history.rs b/rust/lithe-agent-host/src/claude_earlier_history.rs new file mode 100644 index 0000000000..288ef796ca --- /dev/null +++ b/rust/lithe-agent-host/src/claude_earlier_history.rs @@ -0,0 +1,544 @@ +//! Transcript segments that Claude Code compaction hides from `session/load`. +//! +//! The Claude Agent SDK replays only the active parent chain. A compaction +//! boundary has no parent, so everything before the latest boundary is on disk +//! but never reaches the client. This module reads those earlier segments from +//! the account's project transcript, read-only and bounded, and renders them as +//! the same ACP updates a replay would carry. The adapter's own replay then +//! continues from the latest boundary, so nothing is shown twice. +//! +//! Only `/projects/*/.jsonl` is opened. Credentials and +//! the rest of the account directory stay untouched, and nothing is written. + +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; + +use serde_json::{json, Value}; + +use crate::claude_desktop; + +/// Larger transcripts are skipped rather than read: the recap alone is shown. +const MAX_TRANSCRIPT_BYTES: u64 = 128 * 1024 * 1024; +/// Upper bound on emitted updates, keeping the replay event queue bounded. +const MAX_UPDATES: usize = 30_000; +/// Per-field text limit, in characters, for tool output and tool input values. +const TEXT_LIMIT: usize = 4_000; +/// Longest base64 image replayed, in bytes: the client's 10 MB decoded limit +/// (`AgentImage.maximumBytes`) inflated by a third. A larger screenshot would only +/// be dropped on arrival, so it is not sent. +const IMAGE_DATA_LIMIT: usize = 10_000_000 / 3 * 4; + +/// Projects directory of the account the adapter runs under: the explicit +/// account directory, then `CLAUDE_CONFIG_DIR`, then `~/.claude`. +pub(crate) fn projects_directory(config_dir: Option<&Path>) -> Option { + let base = match config_dir { + Some(directory) => directory.to_path_buf(), + None => match std::env::var_os("CLAUDE_CONFIG_DIR").filter(|v| !v.is_empty()) { + Some(directory) => PathBuf::from(directory), + None => PathBuf::from(std::env::var_os(if cfg!(windows) { + "USERPROFILE" + } else { + "HOME" + })?) + .join(".claude"), + }, + }; + base.is_absolute().then(|| base.join("projects")) +} + +/// Updates for every transcript row before the latest compaction boundary, in +/// order. Empty when the session was never compacted or the file is unusable. +pub(crate) fn earlier_updates(projects: &Path, cwd: &Path, session_id: &str) -> Vec { + // The id becomes a file name, so anything but a plain identifier is refused. + if session_id.is_empty() + || !session_id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + return Vec::new(); + } + let Some(path) = locate(projects, cwd, session_id) else { + return Vec::new(); + }; + let Some(boundary) = last_boundary_line(&path) else { + return Vec::new(); + }; + let Ok(file) = File::open(&path) else { + return Vec::new(); + }; + let mut converter = Converter::default(); + for line in BufReader::new(file).lines().take(boundary) { + let Ok(line) = line else { break }; + if let Ok(row) = serde_json::from_str::(&line) { + converter.row(&row); + } + if converter.updates.len() >= MAX_UPDATES { + break; + } + } + converter.updates +} + +/// Claude names a project directory after its workspace with every character +/// outside `[A-Za-z0-9]` replaced by `-`; other directories are searched when +/// that name does not match, for example after Claude shortens a long path. +fn locate(projects: &Path, cwd: &Path, session_id: &str) -> Option { + let name = format!("{session_id}.jsonl"); + let encoded: String = cwd + .to_string_lossy() + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect(); + let direct = projects.join(encoded).join(&name); + let found = if direct.is_file() { + Some(direct) + } else { + std::fs::read_dir(projects) + .ok()? + .filter_map(Result::ok) + .map(|entry| entry.path().join(&name)) + .find(|candidate| candidate.is_file()) + }?; + (found.metadata().ok()?.len() <= MAX_TRANSCRIPT_BYTES).then_some(found) +} + +/// Zero-based line of the latest `compact_boundary` record. +fn last_boundary_line(path: &Path) -> Option { + let mut latest = None; + for (index, line) in BufReader::new(File::open(path).ok()?).lines().enumerate() { + let Ok(line) = line else { break }; + // Cheap substring test first; most rows are never parsed. + if !line.contains("compact_boundary") { + continue; + } + if let Ok(row) = serde_json::from_str::(&line) { + if row["type"] == "system" && row["subtype"] == "compact_boundary" { + latest = Some(index); + } + } + } + latest +} + +/// Which kind of text chunk was emitted last, so a new chunk of the same kind +/// starts a new paragraph instead of fusing with the previous message. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Chunk { + User, + Agent, + Thought, +} + +#[derive(Default)] +struct Converter { + updates: Vec, + last: Option, + /// `uuid` of the row being converted; stamps user chunks so the client can + /// tell consecutive user messages apart, as the adapter's own replay does. + row_id: Option, +} + +impl Converter { + fn row(&mut self, row: &Value) { + // Subagent sidechains and meta rows (hooks, reminders) are not conversation. + if row["isSidechain"] == true || row["isMeta"] == true { + return; + } + self.row_id = row["uuid"].as_str().map(str::to_owned); + let role = match row["type"].as_str() { + Some("user") => Chunk::User, + Some("assistant") => Chunk::Agent, + // A prompt sent while a turn was running is stored only as this + // attachment, never as a user row, so it is the message itself. + Some("attachment") if row["attachment"]["type"] == "queued_command" => { + return self.content(Chunk::User, &row["attachment"]["prompt"]); + } + _ => return, + }; + // API error stand-ins are synthesized by the CLI, not model output. + if row["message"]["model"] == "" { + return; + } + self.content(role, &row["message"]["content"]); + } + + fn content(&mut self, role: Chunk, content: &Value) { + match content { + Value::String(text) if role == Chunk::User => self.text(Chunk::User, text), + Value::Array(blocks) => { + for block in blocks { + self.block(role, block); + } + } + _ => {} + } + } + + fn block(&mut self, role: Chunk, block: &Value) { + match (role, block["type"].as_str()) { + (Chunk::User, Some("text")) => { + self.text(Chunk::User, block["text"].as_str().unwrap_or("")) + } + (Chunk::Agent, Some("text")) => { + self.text(Chunk::Agent, block["text"].as_str().unwrap_or("")) + } + (Chunk::Agent, Some("thinking")) => { + self.text(Chunk::Thought, block["thinking"].as_str().unwrap_or("")) + } + (Chunk::Agent, Some("tool_use")) => self.tool_call(block), + (Chunk::User, Some("tool_result")) => self.tool_result(block), + (Chunk::User, Some("image")) => self.user_image(block), + _ => {} + } + } + + /// A pasted screenshot stays attached to the user message that references + /// it. Only inline base64 sources are replayed; the client bounds the size. + fn user_image(&mut self, block: &Value) { + let source = &block["source"]; + let (Some("base64"), Some(data)) = (source["type"].as_str(), source["data"].as_str()) + else { + return; + }; + if data.is_empty() || data.len() > IMAGE_DATA_LIMIT { + return; + } + self.last = Some(Chunk::User); + let mut update = json!({ + "sessionUpdate": "user_message_chunk", + "content": { + "type": "image", + "data": data, + "mimeType": source["media_type"].as_str().unwrap_or("image/png"), + }, + }); + if let Some(id) = &self.row_id { + update["messageId"] = json!(id); + } + self.updates.push(update); + } + + fn text(&mut self, kind: Chunk, text: &str) { + if text.trim().is_empty() { + return; + } + let name = match kind { + Chunk::User => "user_message_chunk", + Chunk::Agent => "agent_message_chunk", + Chunk::Thought => "agent_thought_chunk", + }; + // Same-kind chunks of one reply are fused by the client, so a new + // reply needs a break; user messages are told apart by message id. + let text = if kind != Chunk::User && self.last == Some(kind) { + format!("\n\n{text}") + } else { + text.to_owned() + }; + self.last = Some(kind); + let mut update = json!({"sessionUpdate": name, "content": {"type": "text", "text": text}}); + if let (Chunk::User, Some(id)) = (kind, &self.row_id) { + update["messageId"] = json!(id); + } + self.updates.push(update); + } + + fn tool_call(&mut self, block: &Value) { + let Some(id) = block["id"].as_str() else { + return; + }; + let name = block["name"].as_str().unwrap_or("Tool"); + let input = &block["input"]; + self.last = None; + self.updates.push(json!({ + "sessionUpdate": "tool_call", + "toolCallId": id, + "name": name, + "title": title(name, input), + "kind": kind(name), + "status": "completed", + // A card, plan or reply is built from this input, so it is not cut to a preview. + "rawInput": if claude_desktop::carries_card_input(name) { + bounded(input, claude_desktop::CARD_INPUT_LIMIT) + } else { + bounded(input, TEXT_LIMIT) + }, + })); + } + + fn tool_result(&mut self, block: &Value) { + let Some(id) = block["tool_use_id"].as_str() else { + return; + }; + let text = match &block["content"] { + Value::String(text) => text.clone(), + Value::Array(parts) => parts + .iter() + .filter_map(|part| part["text"].as_str()) + .collect::>() + .join("\n"), + _ => String::new(), + }; + self.last = None; + let mut update = json!({ + "sessionUpdate": "tool_call_update", + "toolCallId": id, + "status": if block["is_error"] == true { "failed" } else { "completed" }, + }); + if !text.trim().is_empty() { + update["content"] = + json!([{"type": "content", "content": {"type": "text", "text": truncate(&text)}}]); + } + self.updates.push(update); + } +} + +/// ACP tool kind of a Claude Code tool, as the adapter classifies them. +fn kind(name: &str) -> &'static str { + match name { + "Bash" | "BashOutput" | "KillShell" => "execute", + "Read" | "NotebookRead" => "read", + "Edit" | "MultiEdit" | "Write" | "NotebookEdit" => "edit", + "Grep" | "Glob" => "search", + "WebFetch" | "WebSearch" => "fetch", + "Task" | "Agent" | "TodoWrite" => "think", + _ => "other", + } +} + +/// Row title in the adapter's style: the command or target, not the tool name alone. +fn title(name: &str, input: &Value) -> String { + let field = |key: &str| input[key].as_str().filter(|v| !v.is_empty()); + let detail = match name { + "Bash" => field("description").or_else(|| field("command")), + "Read" | "Edit" | "MultiEdit" | "Write" | "NotebookEdit" => { + field("file_path").or_else(|| field("notebook_path")) + } + "Grep" | "Glob" => field("pattern"), + "WebFetch" => field("url"), + "WebSearch" => field("query"), + "Task" | "Agent" => field("description"), + _ => None, + }; + match detail { + Some(detail) => { + let first = detail.lines().next().unwrap_or(detail); + truncate_to(&format!("{name} {first}"), 200) + } + None => name.to_owned(), + } +} + +/// Copy of `value` whose strings are cut to `limit` characters, so a whole-file +/// `Write` input does not travel through the replay queue. +fn bounded(value: &Value, limit: usize) -> Value { + match value { + Value::String(text) => Value::String(truncate_to(text, limit)), + Value::Array(items) => Value::Array(items.iter().map(|item| bounded(item, limit)).collect()), + Value::Object(map) => Value::Object( + map.iter().map(|(k, v)| (k.clone(), bounded(v, limit))).collect(), + ), + other => other.clone(), + } +} + +fn truncate(text: &str) -> String { + truncate_to(text, TEXT_LIMIT) +} + +fn truncate_to(text: &str, limit: usize) -> String { + match text.char_indices().nth(limit) { + Some((index, _)) => format!("{}…", &text[..index]), + None => text.to_owned(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + /// Writes `rows` as a transcript under a fresh projects directory and + /// returns that directory together with the workspace it belongs to. + fn transcript(rows: &[Value]) -> (tempdir::Dir, PathBuf) { + let dir = tempdir::Dir::new(); + let cwd = PathBuf::from("/work/my.app"); + let project = dir.path().join("-work-my-app"); + std::fs::create_dir_all(&project).unwrap(); + let mut file = File::create(project.join("abc-1.jsonl")).unwrap(); + for row in rows { + writeln!(file, "{row}").unwrap(); + } + (dir, cwd) + } + + fn user(text: &str) -> Value { + json!({"type": "user", "uuid": text, "message": {"role": "user", "content": text}}) + } + + fn boundary() -> Value { + json!({"type": "system", "subtype": "compact_boundary", "parentUuid": null}) + } + + #[test] + fn replays_only_what_precedes_the_latest_boundary() { + let (dir, cwd) = transcript(&[ + user("first question"), + json!({"type": "assistant", "message": {"content": [ + {"type": "thinking", "thinking": "hmm"}, + {"type": "text", "text": "first answer"}, + {"type": "tool_use", "id": "t1", "name": "Bash", "input": {"command": "ls", "description": "List files"}}, + ]}}), + json!({"type": "user", "message": {"content": [ + {"type": "tool_result", "tool_use_id": "t1", "content": "a\nb"}, + ]}}), + boundary(), + user("This session is being continued from a previous conversation"), + user("second question"), + boundary(), + user("after the last boundary"), + ]); + let updates = earlier_updates(dir.path(), &cwd, "abc-1"); + let kinds: Vec<_> = updates + .iter() + .map(|u| u["sessionUpdate"].as_str().unwrap()) + .collect(); + assert_eq!( + kinds, + [ + "user_message_chunk", + "agent_thought_chunk", + "agent_message_chunk", + "tool_call", + "tool_call_update", + "user_message_chunk", + "user_message_chunk", + ] + ); + assert_eq!(updates[3]["title"], "Bash List files"); + assert_eq!(updates[3]["kind"], "execute"); + assert_eq!(updates[4]["content"][0]["content"]["text"], "a\nb"); + // Two user rows in a row must not fuse into one bubble. + assert_ne!(updates[5]["messageId"], updates[6]["messageId"]); + assert!(updates[5]["messageId"].is_string()); + } + + #[test] + fn an_uncompacted_or_unsafe_session_yields_nothing() { + let (dir, cwd) = transcript(&[user("hi")]); + assert!(earlier_updates(dir.path(), &cwd, "abc-1").is_empty()); + assert!(earlier_updates(dir.path(), &cwd, "../abc-1").is_empty()); + assert!(earlier_updates(dir.path(), &cwd, "missing").is_empty()); + } + + #[test] + fn sidechains_meta_rows_and_failed_results_are_classified() { + let (dir, cwd) = transcript(&[ + json!({"type": "user", "isMeta": true, "message": {"content": "reminder"}}), + json!({"type": "assistant", "isSidechain": true, "message": {"content": [{"type": "text", "text": "child"}]}}), + json!({"type": "assistant", "message": {"content": [{"type": "tool_use", "id": "t", "name": "Read", "input": {"file_path": "/a/b.rs"}}]}}), + json!({"type": "user", "message": {"content": [{"type": "tool_result", "tool_use_id": "t", "is_error": true, "content": [{"type": "text", "text": "denied"}]}]}}), + boundary(), + ]); + let updates = earlier_updates(dir.path(), &cwd, "abc-1"); + assert_eq!(updates.len(), 2); + assert_eq!(updates[0]["title"], "Read /a/b.rs"); + assert_eq!(updates[1]["status"], "failed"); + } + + #[test] + fn prompts_queued_during_a_turn_and_tool_names_are_kept() { + let (dir, cwd) = transcript(&[ + json!({"type": "assistant", "message": {"content": [{"type": "tool_use", "id": "w", "name": "mcp__ccd_turn__wrap_up", "input": {"recap": "done"}}]}}), + json!({"type": "attachment", "uuid": "q1", "attachment": {"type": "queued_command", "prompt": "go faster"}}), + json!({"type": "attachment", "uuid": "r1", "attachment": {"type": "total_tokens_reminder"}}), + boundary(), + ]); + let updates = earlier_updates(dir.path(), &cwd, "abc-1"); + assert_eq!(updates.len(), 2); + assert_eq!(updates[0]["name"], "mcp__ccd_turn__wrap_up"); + assert_eq!(updates[1]["sessionUpdate"], "user_message_chunk"); + assert_eq!(updates[1]["content"]["text"], "go faster"); + assert_eq!(updates[1]["messageId"], "q1"); + } + + #[test] + fn card_inputs_survive_the_replay_whole_while_other_tool_inputs_are_cut() { + let widget = format!("{}", "x".repeat(TEXT_LIMIT * 2)); + let (dir, cwd) = transcript(&[ + json!({"type": "assistant", "message": {"content": [ + {"type": "tool_use", "id": "v", "name": "mcp__visualize__show_widget", + "input": {"title": "chart", "widget_code": widget}}, + {"type": "tool_use", "id": "w", "name": "Write", + "input": {"file_path": "/a/b.rs", "content": "y".repeat(TEXT_LIMIT * 2)}}, + ]}}), + boundary(), + ]); + let updates = earlier_updates(dir.path(), &cwd, "abc-1"); + let mut presenter = claude_desktop::Presenter::default(); + let shown = presenter.present("s", updates[0].clone()); + assert_eq!(shown[0]["_meta"]["lithe"]["claudeDesktop"]["code"], widget.as_str()); + let write = updates[1]["rawInput"]["content"].as_str().unwrap(); + assert_eq!(write.chars().count(), TEXT_LIMIT + 1, "Ordinary inputs stay previews"); + } + + #[test] + fn pasted_screenshots_stay_on_the_user_message_that_references_them() { + let (dir, cwd) = transcript(&[ + json!({"type": "user", "uuid": "u1", "message": {"content": [ + {"type": "text", "text": "[Image #1] look"}, + {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": "/9j/4A=="}}, + {"type": "image", "source": {"type": "url", "url": "https://example.test/a.png"}}, + {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": ""}}, + {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "A".repeat(IMAGE_DATA_LIMIT + 4)}}, + ]}}), + boundary(), + ]); + let updates = earlier_updates(dir.path(), &cwd, "abc-1"); + assert_eq!(updates.len(), 2, "only the inline base64 image within the limit is replayed"); + assert_eq!(updates[1]["sessionUpdate"], "user_message_chunk"); + assert_eq!(updates[1]["content"]["type"], "image"); + assert_eq!(updates[1]["content"]["data"], "/9j/4A=="); + assert_eq!(updates[1]["content"]["mimeType"], "image/jpeg"); + assert_eq!(updates[1]["messageId"], updates[0]["messageId"]); + } + + #[test] + fn long_text_is_cut_on_a_character_boundary() { + let long = "é".repeat(TEXT_LIMIT + 10); + let cut = truncate(&long); + assert_eq!(cut.chars().count(), TEXT_LIMIT + 1); + assert!(cut.ends_with('…')); + } + + /// Minimal self-cleaning temporary directory; the crate has no tempfile dependency. + mod tempdir { + use std::path::{Path, PathBuf}; + + pub struct Dir(PathBuf); + + impl Dir { + pub fn new() -> Self { + use std::sync::atomic::{AtomicU32, Ordering}; + static NEXT: AtomicU32 = AtomicU32::new(0); + let path = std::env::temp_dir().join(format!( + "lithe-earlier-history-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&path).unwrap(); + Self(path) + } + + pub fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for Dir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + } +} diff --git a/rust/lithe-agent-host/src/claude_subscription.rs b/rust/lithe-agent-host/src/claude_subscription.rs new file mode 100644 index 0000000000..fd77a16dbf --- /dev/null +++ b/rust/lithe-agent-host/src/claude_subscription.rs @@ -0,0 +1,117 @@ +//! Claude Code subscription access through the user's own signed-in CLI. +//! +//! The installed ACP adapter runs the user's Claude Code, which owns the +//! claude.ai login and its credentials. Lithe passes no key, sets no route and +//! never reads, copies or refreshes a token: this mode only refrains from the +//! API-key overrides and reports whether the CLI is signed in. + +use super::*; + +/// Shown when the adapter reports a signed-out CLI. Lithe does not start a +/// browser login; the user signs in with the CLI they already use. +pub(super) const SIGN_IN_REQUIRED: &str = + "Claude Code is not signed in. Run `claude` in a terminal, sign in with /login, then retry."; + +/// The adapter probes `claude auth status` at initialization with a 5 second +/// bound of its own. A missing report is "unknown", never "signed out". +const STATUS_TIMEOUT: Duration = Duration::from_secs(8); + +/// Variables inherited from Lithe's environment that would outrank the +/// subscription login or pin a model the restored session does not own. +pub(super) const INHERITED_OVERRIDES: [&str; 6] = [ + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_BASE_URL", + "ANTHROPIC_MODEL", + "CLAUDE_CODE_OAUTH_TOKEN", + // An inherited executable can be an unusable Windows batch shim; the + // resolved CLI path is added back through the launch environment. + "CLAUDE_CODE_EXECUTABLE", +]; + +/// Claude Code's own switch for selecting an isolated account directory. +pub(super) const CONFIG_DIR_ENVIRONMENT: &str = "CLAUDE_CONFIG_DIR"; + +/// Launch the installed Claude adapter against the detected Claude Code CLI, +/// without an API provider. +pub(super) fn resolve( + launch: AgentLaunch, + find_cli: &dyn Fn(&str) -> Option, +) -> Result { + if launch.agent_id.as_deref() != Some("claude-acp") { + return Err("Claude subscription sign-in is only available for Claude Agent".into()); + } + if !launch.cwd.is_absolute() { + return Err("The workspace path must be absolute".into()); + } + let data = launch + .data_directory + .ok_or("The Agent install directory is not configured")?; + let agent = catalog::find("claude-acp").ok_or("Claude Agent is unavailable")?; + if install::installed_version(&data, agent).is_none() { + return Err("Claude Agent is not installed. Install it in Agent Settings.".into()); + } + let cli = agent.cli.as_ref().ok_or("Claude Code is unavailable")?; + let detected = find_cli(cli.command); + if let Some(issue) = install::cli_issue(cli, detected.as_ref()) { + return Err(issue); + } + let detected = detected.ok_or("Claude Code is unavailable")?; + let mut env: Vec<(String, String)> = cli_path_env(cli, &detected).into_iter().collect(); + if let Some(directory) = launch.claude_config_dir { + // A relative path would resolve against the adapter's working directory + // and silently select a different account than the one the user chose. + if !directory.is_absolute() { + return Err("The Claude account directory must be an absolute path".into()); + } + env.retain(|(name, _)| name != CONFIG_DIR_ENVIRONMENT); + env.push(( + CONFIG_DIR_ENVIRONMENT.into(), + directory.to_string_lossy().into_owned(), + )); + } + let claude_projects = crate::claude_earlier_history::projects_directory( + env.iter() + .find(|(name, _)| name == CONFIG_DIR_ENVIRONMENT) + .map(|(_, value)| std::path::Path::new(value)), + ); + Ok(ResolvedLaunch { + command: install::installed_command(&data, agent), + args: launch.args, + cwd: launch.cwd, + env, + gateway: None, + secret: String::new(), + subscription_cli: None, + claude_subscription: true, + codex_home: None, + claude_projects, + }) +} + +/// Fail the connection with an actionable message when the CLI is signed out. +/// A status that never arrives, or any signed-in kind (account, API key, +/// external backend), lets the connection continue unchanged. A claude.ai +/// account the adapter reported is returned for display only. +pub(super) async fn ensure_signed_in( + status: &mut tokio::sync::watch::Receiver>, +) -> Result, String> { + let reported = tokio::time::timeout(STATUS_TIMEOUT, async { + loop { + if let Some(value) = status.borrow_and_update().clone() { + return Some(value); + } + if status.changed().await.is_err() { + return None; + } + } + }) + .await + .ok() + .flatten(); + match reported { + Some(value) if value.kind == "none" => Err(SIGN_IN_REQUIRED.into()), + Some(value) if value.kind == "account" => Ok(value.account), + _ => Ok(None), + } +} diff --git a/rust/lithe-agent-host/src/codex_history.rs b/rust/lithe-agent-host/src/codex_history.rs index f977a50ae5..e36dfbdac2 100644 --- a/rust/lithe-agent-host/src/codex_history.rs +++ b/rust/lithe-agent-host/src/codex_history.rs @@ -11,6 +11,7 @@ use serde_json::{json, Value}; use tokio::io::{AsyncBufRead, AsyncWrite, AsyncWriteExt, BufReader}; const PROBE_TIMEOUT: Duration = Duration::from_secs(30); +const CANCEL_POLL_INTERVAL: Duration = Duration::from_millis(50); const PAGE_SIZE: u32 = 50; /// Source kinds codex-acp lists, so archived and active history share a scope. @@ -34,9 +35,89 @@ pub(crate) struct CodexHistory { executable: PathBuf, cwd: PathBuf, model_provider: &'static str, + /// Account directory, so archived history belongs to the selected account. + codex_home: Option, } impl CodexHistory { + /// Validate the selected path against native discovery before changing its + /// configuration, then return freshly reported state from the same server. + pub(crate) async fn set_skill_enabled( + executable: PathBuf, + cwd: PathBuf, + path: PathBuf, + enabled: bool, + codex_home: Option, + cancelled: &dyn Fn() -> bool, + ) -> Result { + let probe = Self { + executable, + cwd: cwd.clone(), + model_provider: SUBSCRIPTION_PROVIDER, + codex_home, + }; + probe + .exchange_cancellable( + |mut input, mut output| async move { + let params = json!({"cwds": [cwd], "forceReload": true}); + let before = subscription::request( + &mut input, + &mut output, + 2, + "skills/list", + params.clone(), + ) + .await?; + let path = crate::skills::codex_skill_path(&before, &cwd, &path) + .ok_or("unknown skill")?; + if cancelled() { + return Err("cancelled"); + } + subscription::request( + &mut input, + &mut output, + 3, + "skills/config/write", + json!({"path":path,"enabled":enabled}), + ) + .await?; + subscription::request(&mut input, &mut output, 4, "skills/list", params).await + }, + cancelled, + ) + .await + } + + /// Reuse the bounded, credential-free App Server lifecycle for native skill discovery. + pub(crate) async fn skills( + executable: PathBuf, + cwd: PathBuf, + codex_home: Option, + cancelled: &dyn Fn() -> bool, + ) -> Result { + let probe = Self { + executable, + cwd: cwd.clone(), + model_provider: SUBSCRIPTION_PROVIDER, + codex_home, + }; + probe + .exchange_cancellable( + |mut input, mut output| async move { + subscription::request( + &mut input, + &mut output, + 2, + "skills/list", + json!({"cwds": [cwd], "forceReload": true}), + ) + .await + }, + cancelled, + ) + .await + } + /// Only Codex connections have archived history; other Agents return None. pub(crate) fn for_launch(launch: &ResolvedLaunch) -> Option { let (executable, model_provider) = match &launch.subscription_cli { @@ -54,6 +135,7 @@ impl CodexHistory { executable, cwd: launch.cwd.clone(), model_provider, + codex_home: launch.codex_home.clone(), }) } @@ -142,10 +224,28 @@ impl CodexHistory { where F: FnOnce(tokio::process::ChildStdin, BufReader) -> Fut, Fut: std::future::Future>, + { + self.exchange_cancellable(operation, || false).await + } + + /// Cancellation participates in the exchange so cleanup is awaited before + /// a short-lived caller drops its Tokio runtime, including during initialization. + async fn exchange_cancellable( + &self, + operation: F, + cancelled: C, + ) -> Result + where + F: FnOnce(tokio::process::ChildStdin, BufReader) -> Fut, + Fut: std::future::Future>, + C: Fn() -> bool, { let mut process = std::process::Command::new(&self.executable); // History needs no credentials; never hand API overrides to this process. subscription::isolate_environment(&mut process); + if let Some(home) = &self.codex_home { + process.env(subscription::CODEX_HOME_ENVIRONMENT, home); + } process .arg("app-server") .current_dir(&self.cwd) @@ -167,15 +267,25 @@ impl CodexHistory { } process.kill_on_drop(true); let mut child = subscription::ProbeProcess(process.spawn().map_err(|_| "unavailable")?); - let result = async { - let mut input = child.0.stdin.take().ok_or("unavailable")?; - let mut output = BufReader::new(child.0.stdout.take().ok_or("unavailable")?); - initialize(&mut input, &mut output).await?; - operation(input, output).await + let result = { + let exchange = async { + let mut input = child.0.stdin.take().ok_or("unavailable")?; + let mut output = BufReader::new(child.0.stdout.take().ok_or("unavailable")?); + initialize(&mut input, &mut output).await?; + operation(input, output).await + }; + let bounded = tokio::time::timeout(PROBE_TIMEOUT, exchange); + tokio::pin!(bounded); + loop { + if cancelled() { + break Err("cancelled"); + } + tokio::select! { + result = &mut bounded => break result.unwrap_or(Err("timeout")), + _ = tokio::time::sleep(CANCEL_POLL_INTERVAL) => {} + } + } }; - let result = tokio::time::timeout(PROBE_TIMEOUT, result) - .await - .unwrap_or(Err("timeout")); terminate_tree(&mut child.0).await; result } diff --git a/rust/lithe-agent-host/src/codex_history/tests.rs b/rust/lithe-agent-host/src/codex_history/tests.rs index bdfc058566..44ebc9a6d1 100644 --- a/rust/lithe-agent-host/src/codex_history/tests.rs +++ b/rust/lithe-agent-host/src/codex_history/tests.rs @@ -13,6 +13,9 @@ fn launch(env: Vec<(String, String)>, subscription_cli: Option) -> Reso gateway: None, secret: String::new(), subscription_cli, + claude_subscription: false, + codex_home: None, + claude_projects: None, } } @@ -378,6 +381,7 @@ async fn active_and_archived_listing_only_read_official_activity_metadata() { executable: fake_app_server(&directory.0, &reply), cwd: directory.0.clone(), model_provider: SUBSCRIPTION_PROVIDER, + codex_home: None, }; let workspace = workspace_context::WorkspaceContext::new(&directory.0).unwrap(); let emit: Emit = Arc::new(|_| {}); @@ -434,6 +438,7 @@ async fn unarchive_sends_only_the_selected_thread_to_the_users_cli() { executable: fake_app_server(&directory.0, "\"result\":{\"thread\":{}}"), cwd: directory.0.clone(), model_provider: SUBSCRIPTION_PROVIDER, + codex_home: None, }; tokio::time::timeout( Duration::from_secs(10), @@ -466,6 +471,7 @@ async fn upstream_errors_never_reach_the_ui() { ), cwd: directory.0.clone(), model_provider: GATEWAY_PROVIDER, + codex_home: None, }; let error = tokio::time::timeout( Duration::from_secs(10), diff --git a/rust/lithe-agent-host/src/hooks.rs b/rust/lithe-agent-host/src/hooks.rs new file mode 100644 index 0000000000..16077b74a0 --- /dev/null +++ b/rust/lithe-agent-host/src/hooks.rs @@ -0,0 +1,637 @@ +//! User-owned Hook library and explicit synchronization into native Agent files. +//! Agent runtimes own execution, JSON stdin, timeouts and trust. This adapter +//! never executes commands or grants trust, and preserves native group positions. + +mod storage; +#[cfg(test)] +mod tests; + +use crate::instructions::{self, InstructionFile}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::path::PathBuf; +use std::sync::Mutex; + +const AGENTS: [&str; 2] = ["codex-acp", "claude-acp"]; +static WRITE_LOCK: Mutex<()> = Mutex::new(()); + +/// Portable lifecycle events implemented by both supported native runtimes. +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum Event { + TurnEnd, + AfterTool, + BeforeTool, + Prompt, + SessionStart, +} + +impl Event { + fn native(self) -> &'static str { + match self { + Self::TurnEnd => "Stop", + Self::AfterTool => "PostToolUse", + Self::BeforeTool => "PreToolUse", + Self::Prompt => "UserPromptSubmit", + Self::SessionStart => "SessionStart", + } + } +} + +/// Portable tool families; upstream tool names remain provider-specific. +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum Matcher { + #[default] + All, + Shell, + Edit, +} + +/// One user-authored command. Names/descriptions stay in the library only. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Hook { + pub id: String, + pub name: String, + pub description: String, + pub event: Event, + pub matcher: Matcher, + pub command: String, + /// None inherits the Agent's own timeout; supplied values are seconds. + pub timeout_seconds: Option, + pub agents: Vec, + pub enabled: bool, + /// A failed native removal retains a visible, retryable library entry. + #[serde(default)] + pub pending_removal: bool, +} + +/// Guard against external edits and changes to the native profile directory. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct FileGuard { + pub agent_id: String, + pub path: PathBuf, + pub revision: String, +} + +/// Native file metadata; configuration contents may contain secrets and stay private. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Target { + pub agent_id: String, + pub file: Option, + pub issue: Option, + pub disabled: bool, +} + +/// Actual native placement is independent of the library's desired state. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HookView { + #[serde(flatten)] + pub hook: Hook, + pub configured_agents: Vec, + pub needs_sync: bool, +} + +/// Read-only snapshot used to guard the next explicit operation. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Library { + pub revision: String, + pub hooks: Vec, + pub targets: Vec, +} + +/// Platform-owned writable directory and one explicit library operation. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Request { + pub data_directory: PathBuf, + #[serde(flatten)] + pub action: Action, +} + +/// Mutations always carry the source and native snapshots the user reviewed. +#[derive(Deserialize)] +#[serde(tag = "action", rename_all = "camelCase", deny_unknown_fields)] +pub enum Action { + Read {}, + Save { + hook: Hook, + #[serde(rename = "expectedRevision")] + expected_revision: String, + files: Vec, + }, + Remove { + id: String, + #[serde(rename = "expectedRevision")] + expected_revision: String, + files: Vec, + }, + Sync { + #[serde(rename = "expectedRevision")] + expected_revision: String, + files: Vec, + }, +} + +/// Stable reason and credential-free diagnostic for Core's shared error envelope. +#[derive(Debug)] +pub struct Error { + pub reason: &'static str, + pub message: &'static str, +} + +fn error(reason: &'static str, message: &'static str) -> Error { + Error { reason, message } +} + +/// Execute without running any Hook command or modifying native trust settings. +pub fn execute(request: Request, cancelled: &impl Fn() -> bool) -> Result { + execute_in(request, &instructions::profile, cancelled, &WRITE_LOCK) +} + +#[derive(Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Stored { + hooks: Vec, + /// Includes planned placements until native replacement is confirmed. A + /// crash between source/native commits therefore never loses write ownership. + placements: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Placement { + id: String, + agent_id: String, + path: PathBuf, + event: String, + index: usize, + entry: Value, +} + +type Resolver<'a> = dyn Fn(&str) -> Result + 'a; + +fn execute_in( + request: Request, + resolve: &Resolver<'_>, + cancelled: &impl Fn() -> bool, + write_lock: &Mutex<()>, +) -> Result { + if !request.data_directory.is_absolute() { + return Err(error( + "invalid_request", + "The Hook library directory must be absolute.", + )); + } + check_cancelled(cancelled)?; + let path = request.data_directory.join("hooks.json"); + if matches!(request.action, Action::Read {}) { + return view(storage::read("library", path)?, resolve); + } + let _guard = write_lock + .try_lock() + .map_err(|_| error("busy", "Another Hook update is running. Try again."))?; + let (expected_revision, guards) = match &request.action { + Action::Save { + hook, + expected_revision, + files, + } => { + validate(hook)?; + if hook.pending_removal { + return Err(error("invalid_request", "A removed Hook cannot be saved.")); + } + (expected_revision, files) + } + Action::Remove { + expected_revision, + files, + .. + } + | Action::Sync { + expected_revision, + files, + } => (expected_revision, files), + Action::Read {} => unreachable!(), + }; + if guards.len() > AGENTS.len() + || guards + .iter() + .any(|g| !AGENTS.contains(&g.agent_id.as_str())) + || guards + .iter() + .enumerate() + .any(|(i, g)| guards[..i].iter().any(|other| other.agent_id == g.agent_id)) + { + return Err(error("invalid_request", "Invalid Hook file guards.")); + } + let mut source = storage::read("library", path.clone())?; + if &source.revision != expected_revision { + return Err(storage::changed()); + } + let mut stored = decode(&source)?; + check_cancelled(cancelled)?; + let _file_lock = storage::lock(&request.data_directory)?; + if storage::read("library", path.clone())?.revision != source.revision { + return Err(storage::changed()); + } + match &request.action { + Action::Save { hook, .. } => { + let mut hook = hook.clone(); + if stored + .hooks + .iter() + .any(|h| h.id != hook.id && h.name.eq_ignore_ascii_case(&hook.name)) + { + return Err(error( + "invalid_request", + "A Hook with this name already exists.", + )); + } + hook.agents.sort(); + if let Some(existing) = stored.hooks.iter_mut().find(|h| h.id == hook.id) { + *existing = hook; + } else { + if stored.hooks.len() >= 200 { + return Err(error( + "limit", + "The Hook library can contain at most 200 Hooks.", + )); + } + stored.hooks.push(hook); + } + } + Action::Remove { id, .. } => { + let hook = stored + .hooks + .iter_mut() + .find(|h| &h.id == id) + .ok_or_else(|| { + error( + "invalid_request", + "This Hook no longer exists. Reload the library.", + ) + })?; + hook.pending_removal = true; + } + _ => {} + } + // Commit desired state first; a failed target remains explicitly out of sync. + source = storage::save(&source, &stored)?; + let guards = match &request.action { + Action::Save { files, .. } | Action::Remove { files, .. } | Action::Sync { files, .. } => { + files + } + _ => unreachable!(), + }; + let mut failures = Vec::new(); + for agent in AGENTS { + let result = (|| { + check_cancelled(cancelled)?; + let native = native_file(agent, resolve)?; + let (next, placements) = plan(&stored, &native)?; + if next == native.content { + stored.placements.retain(|p| p.agent_id != agent); + stored.placements.extend(placements); + source = storage::save(&source, &stored)?; + return Ok(()); + } + let guard = guards + .iter() + .find(|g| g.agent_id == agent) + .ok_or_else(storage::changed)?; + if guard.path != native.path || guard.revision != native.revision { + return Err(storage::changed()); + } + // Journal both versions BEFORE writing the target. On retry the + // exact matching version is recognized; no entry is claimed by name. + for placement in &placements { + if !stored.placements.contains(placement) { + stored.placements.push(placement.clone()); + } + } + source = storage::save(&source, &stored)?; + check_cancelled(cancelled)?; + storage::write_native(&native, &next)?; + stored.placements.retain(|p| p.agent_id != agent); + stored.placements.extend(placements); + source = storage::save(&source, &stored)?; + Ok::<(), Error>(()) + })(); + if let Err(issue) = result { + failures.push((agent, issue.message)); + } + } + stored + .hooks + .retain(|h| !h.pending_removal || stored.placements.iter().any(|p| p.id == h.id)); + source = storage::save(&source, &stored)?; + let mut result = view(source, resolve)?; + for (agent, issue) in failures { + if let Some(target) = result.targets.iter_mut().find(|t| t.agent_id == agent) { + target.issue = Some(issue.into()); + } + } + Ok(result) +} + +fn check_cancelled(cancelled: &impl Fn() -> bool) -> Result<(), Error> { + if cancelled() { + Err(error("cancelled", "Hook synchronization was cancelled.")) + } else { + Ok(()) + } +} + +fn decode(source: &InstructionFile) -> Result { + if !source.exists { + return Ok(Stored::default()); + } + let stored: Stored = serde_json::from_str(&source.content).map_err(|_| { + error( + "invalid_library", + "The Hook library is invalid. Restore it before making changes.", + ) + })?; + if stored.hooks.len() > 200 || stored.placements.len() > 2000 { + return Err(error("limit", "The Hook library exceeded its size limit.")); + } + for hook in &stored.hooks { + validate(hook)?; + } + if stored.hooks.iter().enumerate().any(|(i, h)| { + stored.hooks[..i] + .iter() + .any(|other| other.id == h.id || other.name.eq_ignore_ascii_case(&h.name)) + }) { + return Err(error( + "invalid_library", + "The Hook library contains duplicate Hooks.", + )); + } + Ok(stored) +} + +fn validate(hook: &Hook) -> Result<(), Error> { + if uuid::Uuid::parse_str(&hook.id).is_err() + || hook.name.is_empty() + || hook.name.len() > 80 + || !hook + .name + .bytes() + .all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_') + || hook.description.chars().count() > 200 + || hook.command.trim().is_empty() + || hook.command.len() > 32768 + || hook.command.contains('\0') + || hook + .timeout_seconds + .is_some_and(|seconds| seconds == 0 || seconds > 86400) + || hook.agents.is_empty() + || hook.agents.len() > 2 + || hook.agents.iter().any(|a| !AGENTS.contains(&a.as_str())) + || (hook.agents.len() == 2 && hook.agents[0] == hook.agents[1]) + || (!matches!(hook.event, Event::BeforeTool | Event::AfterTool) + && hook.matcher != Matcher::All) + { + return Err(error( + "invalid_request", + "Check the Hook name, command, event, Agents and timeout (1–86400 seconds).", + )); + } + Ok(()) +} + +fn native_file(agent: &str, resolve: &Resolver<'_>) -> Result { + let directory = resolve(agent).map_err(storage::file_error)?; + storage::read( + agent, + directory.join(if agent == "codex-acp" { + "hooks.json" + } else { + "settings.json" + }), + ) +} + +fn document(file: &InstructionFile) -> Result { + let value = if !file.exists { + json!({}) + } else { + storage::parse_json(&file.content).map_err(|_| { + error( + "invalid_json", + "The native Hook configuration must be valid JSON. Existing contents were kept.", + ) + })? + }; + if !value.is_object() || value.get("hooks").is_some_and(|h| !h.is_object()) { + return Err(error( + "invalid_json", + "The native hooks field must be a JSON object. Existing contents were kept.", + )); + } + Ok(value) +} + +fn entry(hook: &Hook, agent: &str) -> Value { + let mut command = json!({"type": "command", "command": hook.command}); + if let Some(timeout) = hook.timeout_seconds { + command["timeout"] = json!(timeout); + } + let mut value = json!({"hooks": [command]}); + let matcher = match hook.matcher { + Matcher::All => None, + Matcher::Shell => Some("Bash"), + Matcher::Edit if agent == "codex-acp" => Some("apply_patch"), + Matcher::Edit => Some("Edit|Write|MultiEdit"), + }; + if let Some(matcher) = matcher { + value["matcher"] = json!(matcher); + } + value +} + +fn group<'a>(doc: &'a Value, placement: &Placement) -> Option<&'a Value> { + doc.get("hooks")? + .get(&placement.event)? + .as_array()? + .get(placement.index) +} + +// Removing a group by compaction would move Codex's index-based trust to another +// command. Keep empty groups as tombstones and append new commands only. +fn plan(stored: &Stored, native: &InstructionFile) -> Result<(String, Vec), Error> { + let mut doc = document(native)?; + let original = doc.clone(); + let mut kept = Vec::new(); + for placement in stored + .placements + .iter() + .filter(|p| p.agent_id == native.agent_id) + { + if placement.path != native.path { + return Err(error("profile_changed", "The Agent profile changed. Restore the previous profile before synchronizing its Hooks.")); + } + let current = group(&doc, placement); + let matches = current == Some(&placement.entry); + let known_variant = stored.placements.iter().any(|p| { + p.agent_id == placement.agent_id + && p.path == placement.path + && p.event == placement.event + && p.index == placement.index + && group(&doc, p) == Some(&p.entry) + }); + if !matches { + if current.is_some_and(|g| g != &json!({"hooks": []})) && !known_variant { + return Err(error("ownership_changed", "A managed Hook was edited outside Lithe. Restore or remove that native entry before synchronizing.")); + } + continue; + } + let wanted = stored.hooks.iter().find(|h| { + h.id == placement.id + && !h.pending_removal + && h.enabled + && h.agents.contains(&native.agent_id) + }); + if wanted.is_some_and(|h| { + h.event.native() == placement.event && entry(h, &native.agent_id) == placement.entry + }) && !kept.iter().any(|p: &Placement| p.id == placement.id) + { + kept.push(placement.clone()); + } else { + doc["hooks"][&placement.event][placement.index] = json!({"hooks": []}); + } + } + for hook in stored + .hooks + .iter() + .filter(|h| !h.pending_removal && h.enabled && h.agents.contains(&native.agent_id)) + { + if kept.iter().any(|p| p.id == hook.id) { + continue; + } + let event = hook.event.native(); + if doc.get("hooks").is_none() { + doc["hooks"] = json!({}); + } + if doc["hooks"].get(event).is_none() { + doc["hooks"][event] = json!([]); + } + let groups = doc["hooks"][event].as_array_mut().ok_or_else(|| { + error( + "invalid_json", + "The native Hook event must contain an array. Existing contents were kept.", + ) + })?; + if groups.contains(&entry(hook, &native.agent_id)) { + return Err(error( + "duplicate_native", + "An identical native Hook already exists. Lithe kept it unchanged.", + )); + } + let placement = Placement { + id: hook.id.clone(), + agent_id: native.agent_id.clone(), + path: native.path.clone(), + event: event.into(), + index: groups.len(), + entry: entry(hook, &native.agent_id), + }; + groups.push(placement.entry.clone()); + kept.push(placement); + } + let content = if doc == original { + native.content.clone() + } else { + serde_json::to_string_pretty(&doc) + .map_err(|_| error("invalid_json", "Cannot encode Hook configuration."))? + + "\n" + }; + Ok((content, kept)) +} + +fn view(source: InstructionFile, resolve: &Resolver<'_>) -> Result { + let stored = decode(&source)?; + let mut targets = Vec::new(); + let mut native_docs = Vec::new(); + for agent in AGENTS { + let result = native_file(agent, resolve).and_then(|file| { + let doc = document(&file)?; + Ok((file, doc)) + }); + match result { + Ok((file, doc)) => { + let issue = plan(&stored, &file).err().map(|e| e.message.into()); + targets.push(Target { + agent_id: agent.into(), + file: Some(FileGuard { + agent_id: agent.into(), + path: file.path.clone(), + revision: file.revision, + }), + issue, + disabled: doc.get("disableAllHooks").and_then(Value::as_bool) == Some(true), + }); + native_docs.push((agent, file.path, doc)); + } + Err(issue) => targets.push(Target { + agent_id: agent.into(), + file: None, + issue: Some(issue.message.into()), + disabled: false, + }), + } + } + let hooks = stored + .hooks + .iter() + .map(|hook| { + let mut configured_agents = Vec::new(); + let mut stale = false; + for placement in stored.placements.iter().filter(|p| p.id == hook.id) { + let current = native_docs + .iter() + .find(|(agent, path, _)| { + *agent == placement.agent_id && *path == placement.path + }) + .and_then(|(_, _, doc)| group(doc, placement)); + if current == Some(&placement.entry) { + if hook.event.native() == placement.event + && entry(hook, &placement.agent_id) == placement.entry + { + if !configured_agents.contains(&placement.agent_id) { + configured_agents.push(placement.agent_id.clone()); + } + } else { + stale = true; + } + } else if current.is_some_and(|g| g != &json!({"hooks": []})) { + stale = true; + } + } + configured_agents.sort(); + let mut wanted = if hook.enabled && !hook.pending_removal { + hook.agents.clone() + } else { + Vec::new() + }; + wanted.sort(); + HookView { + hook: hook.clone(), + needs_sync: stale || configured_agents != wanted, + configured_agents, + } + }) + .collect(); + Ok(Library { + revision: source.revision, + hooks, + targets, + }) +} diff --git a/rust/lithe-agent-host/src/hooks/storage.rs b/rust/lithe-agent-host/src/hooks/storage.rs new file mode 100644 index 0000000000..b0146ad8a1 --- /dev/null +++ b/rust/lithe-agent-host/src/hooks/storage.rs @@ -0,0 +1,150 @@ +//! Bounded, guarded file access shared with the native instruction adapter. + +use super::*; +use fs2::FileExt; +use std::fs::{self, File, OpenOptions}; +use std::path::Path; + +// Reject duplicate object keys instead of silently discarding the earlier +// value while rewriting an unrelated Hook in a user's native settings. +pub(super) fn parse_json(content: &str) -> Result { + use serde::de::{self, MapAccess, SeqAccess, Visitor}; + struct Unique(Value); + impl<'de> Deserialize<'de> for Unique { + fn deserialize>(deserializer: D) -> Result { + struct UniqueVisitor; + impl<'de> Visitor<'de> for UniqueVisitor { + type Value = Unique; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("JSON with unique object keys") + } + fn visit_bool(self, v: bool) -> Result { + Ok(Unique(Value::Bool(v))) + } + fn visit_i64(self, v: i64) -> Result { + Ok(Unique(v.into())) + } + fn visit_u64(self, v: u64) -> Result { + Ok(Unique(v.into())) + } + fn visit_f64(self, v: f64) -> Result { + serde_json::Number::from_f64(v) + .map(|n| Unique(Value::Number(n))) + .ok_or_else(|| E::custom("invalid number")) + } + fn visit_str(self, v: &str) -> Result { + Ok(Unique(Value::String(v.into()))) + } + fn visit_string(self, v: String) -> Result { + Ok(Unique(Value::String(v))) + } + fn visit_unit(self) -> Result { + Ok(Unique(Value::Null)) + } + fn visit_seq>(self, mut seq: A) -> Result { + let mut values = Vec::new(); + while let Some(value) = seq.next_element::()? { + values.push(value.0); + } + Ok(Unique(Value::Array(values))) + } + fn visit_map>(self, mut map: A) -> Result { + let mut values = serde_json::Map::new(); + while let Some((key, value)) = map.next_entry::()? { + if values.insert(key, value.0).is_some() { + return Err(de::Error::custom("duplicate object key")); + } + } + Ok(Unique(Value::Object(values))) + } + } + deserializer.deserialize_any(UniqueVisitor) + } + } + serde_json::from_str::(content).map(|value| value.0) +} + +pub(super) fn file_error(issue: instructions::InstructionError) -> Error { + let message = match issue.reason { + "file_changed" => "Hook configuration changed outside this editor. Reload before saving.", + "unsafe_file" => { + "Hook configuration must be a regular file, not a symbolic link or directory." + } + "file_too_large" => "Hook configuration must be no larger than 1 MiB.", + "invalid_utf8" => "Hook configuration must use UTF-8 encoding.", + _ => "Cannot access Hook configuration. Check the file permissions and Agent profile.", + }; + error(issue.reason, message) +} + +pub(super) fn changed() -> Error { + error( + "file_changed", + "Hook configuration changed outside this editor. Reload before saving.", + ) +} + +pub(super) fn read(agent: &str, path: PathBuf) -> Result { + instructions::snapshot(agent, path).map_err(file_error) +} + +pub(super) fn write_native(previous: &InstructionFile, content: &str) -> Result<(), Error> { + instructions::write_snapshot(previous, content, || { + instructions::snapshot(&previous.agent_id, previous.path.clone()) + }) + .map_err(file_error) +} + +pub(super) fn save(previous: &InstructionFile, stored: &Stored) -> Result { + let content = serde_json::to_string_pretty(stored) + .map_err(|_| error("invalid_library", "Cannot encode the Hook library."))? + + "\n"; + if previous.content != content { + write_native(previous, &content)?; + } + read("library", previous.path.clone()) +} + +// The empty lock belongs to user state. Nonblocking OS locking coordinates +// distinct Lithe processes; it never relies on a stale PID or timeout sleep. +pub(super) fn lock(directory: &Path) -> Result { + let access = |_| { + error( + "file_access", + "Cannot access the Hook library. Check its permissions.", + ) + }; + let mut directories = fs::DirBuilder::new(); + directories.recursive(true); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + directories.mode(0o700); + } + directories.create(directory).map_err(access)?; + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true).truncate(false); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(0x00200000); + } + let lock = options.open(directory.join(".sync.lock")).map_err(access)?; + let kind = lock.metadata().map_err(access)?.file_type(); + if !kind.is_file() || kind.is_symlink() { + return Err(error( + "unsafe_file", + "The Hook library lock must be a regular file.", + )); + } + lock.try_lock_exclusive() + .map_err(|_| error("busy", "Another Hook update is running. Try again."))?; + Ok(lock) +} diff --git a/rust/lithe-agent-host/src/hooks/tests.rs b/rust/lithe-agent-host/src/hooks/tests.rs new file mode 100644 index 0000000000..9f958214ae --- /dev/null +++ b/rust/lithe-agent-host/src/hooks/tests.rs @@ -0,0 +1,451 @@ +use super::*; +use std::fs; + +struct Fixture(PathBuf); +impl Fixture { + fn new() -> Self { + Self(std::env::temp_dir().join(format!("lithe-hooks-test-{}", uuid::Uuid::new_v4()))) + } + fn directory(&self) -> PathBuf { + self.0.join("library") + } + fn profile(&self, agent: &str) -> PathBuf { + self.0.join("profiles").join(agent) + } + fn native(&self, agent: &str) -> PathBuf { + self.profile(agent).join(if agent == "codex-acp" { + "hooks.json" + } else { + "settings.json" + }) + } + fn write(&self, agent: &str, content: &str) { + fs::create_dir_all(self.profile(agent)).unwrap(); + fs::write(self.native(agent), content).unwrap(); + } + fn run(&self, action: Action) -> Result { + execute_in( + Request { + data_directory: self.directory(), + action, + }, + &|agent| Ok(self.profile(agent)), + &|| false, + &Mutex::new(()), + ) + } + fn read(&self) -> Library { + self.run(Action::Read {}).unwrap() + } + fn save(&self, hook: Hook, before: &Library) -> Library { + self.run(Action::Save { + hook, + expected_revision: before.revision.clone(), + files: guards(before), + }) + .unwrap() + } + fn sync(&self, before: &Library) -> Library { + self.run(Action::Sync { + expected_revision: before.revision.clone(), + files: guards(before), + }) + .unwrap() + } + fn doc(&self, agent: &str) -> Value { + serde_json::from_slice(&fs::read(self.native(agent)).unwrap()).unwrap() + } +} +impl Drop for Fixture { + fn drop(&mut self) { + if self.0.exists() { + fs::remove_dir_all(&self.0).expect("remove owned Hook fixture"); + } + } +} +fn guards(library: &Library) -> Vec { + library + .targets + .iter() + .filter_map(|t| t.file.clone()) + .collect() +} +fn hook() -> Hook { + Hook { + id: uuid::Uuid::new_v4().to_string(), + name: "notify".into(), + description: "一轮结束时提醒".into(), + event: Event::TurnEnd, + matcher: Matcher::All, + command: "printf done".into(), + timeout_seconds: None, + agents: AGENTS.iter().map(|a| (*a).into()).collect(), + enabled: true, + pending_removal: false, + } +} + +#[test] +fn opening_the_library_is_read_only_and_does_not_expose_native_secrets() { + let fixture = Fixture::new(); + assert!(fixture.read().hooks.is_empty()); + assert!(!fixture.0.exists()); + fixture.write("claude-acp", r#"{"env":{"API_KEY":"fixture-secret"}}"#); + let encoded = serde_json::to_string(&fixture.read()).unwrap(); + assert!(!encoded.contains("fixture-secret")); +} + +#[test] +fn saves_both_native_formats_without_executing_commands_or_replacing_other_settings() { + let fixture = Fixture::new(); + let own = json!({"hooks": [{"type": "command", "command": "own-hook"}], "matcher": "custom"}); + fixture.write("claude-acp", &json!({"env": {"TOKEN": "fixture"}, "hooks": {"Stop": [own.clone()]}, "disableAllHooks": true}).to_string()); + let mut wanted = hook(); + wanted.command = format!("touch {}", fixture.0.join("must-not-exist").display()); + let saved = fixture.save(wanted.clone(), &fixture.read()); + assert!(!saved.hooks[0].needs_sync); + assert_eq!(saved.hooks[0].configured_agents.len(), 2); + assert_eq!(fixture.doc("claude-acp")["env"]["TOKEN"], "fixture"); + assert_eq!(fixture.doc("claude-acp")["hooks"]["Stop"][0], own); + assert_eq!( + fixture.doc("codex-acp")["hooks"]["Stop"][0], + entry(&wanted, "codex-acp") + ); + assert!( + saved + .targets + .iter() + .find(|t| t.agent_id == "claude-acp") + .unwrap() + .disabled + ); + assert!(!fixture.0.join("must-not-exist").exists()); + assert!(fixture.doc("codex-acp")["hooks"]["Stop"][0]["hooks"][0] + .get("timeout") + .is_none()); +} + +#[test] +fn portable_tool_filters_and_timeouts_use_the_agents_own_names() { + let fixture = Fixture::new(); + let mut wanted = hook(); + wanted.event = Event::AfterTool; + wanted.matcher = Matcher::Edit; + wanted.timeout_seconds = Some(30); + fixture.save(wanted, &fixture.read()); + assert_eq!( + fixture.doc("codex-acp")["hooks"]["PostToolUse"][0]["matcher"], + "apply_patch" + ); + assert_eq!( + fixture.doc("claude-acp")["hooks"]["PostToolUse"][0]["matcher"], + "Edit|Write|MultiEdit" + ); + assert_eq!( + fixture.doc("claude-acp")["hooks"]["PostToolUse"][0]["hooks"][0]["timeout"], + 30 + ); + for event in [ + Event::TurnEnd, + Event::AfterTool, + Event::BeforeTool, + Event::Prompt, + Event::SessionStart, + ] { + let mut wanted = hook(); + wanted.event = event; + wanted.matcher = Matcher::All; + assert!(entry(&wanted, "codex-acp").get("matcher").is_none()); + } +} + +#[test] +fn editing_disabling_and_deleting_preserve_codex_trust_positions_and_user_hooks() { + let fixture = Fixture::new(); + let own = json!({"hooks": [{"type": "command", "command": "user-command"}]}); + fixture.write( + "codex-acp", + &json!({"hooks": {"Stop": [own.clone()]}}).to_string(), + ); + let trust_path = fixture.profile("codex-acp").join("config.toml"); + fs::write( + &trust_path, + "# user-owned trust state\n[features]\nhooks = true\n", + ) + .unwrap(); + let trust = fs::read(&trust_path).unwrap(); + let mut wanted = hook(); + let first = fixture.save(wanted.clone(), &fixture.read()); + wanted.command = "printf updated".into(); + let second = fixture.save(wanted.clone(), &first); + assert_eq!(fixture.doc("codex-acp")["hooks"]["Stop"][0], own); + assert_eq!( + fixture.doc("codex-acp")["hooks"]["Stop"][1], + json!({"hooks": []}) + ); + assert_eq!( + fixture.doc("codex-acp")["hooks"]["Stop"][2]["hooks"][0]["command"], + wanted.command + ); + wanted.enabled = false; + let disabled = fixture.save(wanted.clone(), &second); + assert!(disabled.hooks[0].configured_agents.is_empty()); + assert!(!disabled.hooks[0].needs_sync); + wanted.enabled = true; + let enabled = fixture.save(wanted.clone(), &disabled); + let removed = fixture + .run(Action::Remove { + id: wanted.id, + expected_revision: enabled.revision.clone(), + files: guards(&enabled), + }) + .unwrap(); + assert!(removed.hooks.is_empty()); + assert_eq!(fixture.doc("codex-acp")["hooks"]["Stop"][0], own); + assert_eq!(fs::read(trust_path).unwrap(), trust); +} + +#[test] +fn saving_an_unrelated_hook_keeps_an_existing_trusted_command_at_its_position() { + let fixture = Fixture::new(); + let first = fixture.save(hook(), &fixture.read()); + let original = fixture.doc("codex-acp")["hooks"]["Stop"][0].clone(); + let mut other = hook(); + other.name = "other".into(); + other.command = "printf other".into(); + let second = fixture.save(other, &first); + assert!(second.hooks.iter().all(|h| !h.needs_sync)); + assert_eq!(fixture.doc("codex-acp")["hooks"]["Stop"][0], original); + assert_eq!( + fixture.doc("codex-acp")["hooks"]["Stop"] + .as_array() + .unwrap() + .len(), + 2 + ); +} + +#[test] +fn stale_source_is_rejected_and_native_conflicts_report_partial_success_then_retry() { + let fixture = Fixture::new(); + let before = fixture.read(); + fixture.write("codex-acp", r#"{"external":true}"#); + let saved = fixture.save(hook(), &before); + assert!(saved.hooks[0].needs_sync); + assert_eq!(saved.hooks[0].configured_agents, ["claude-acp"]); + assert!(saved.targets[0].issue.is_some()); + assert_eq!(fixture.doc("codex-acp"), json!({"external": true})); + assert_eq!( + fixture + .run(Action::Save { + hook: hook(), + expected_revision: before.revision, + files: vec![] + }) + .unwrap_err() + .reason, + "file_changed" + ); + let retried = fixture.sync(&fixture.read()); + assert!(!retried.hooks[0].needs_sync); + assert_eq!(fixture.doc("codex-acp")["external"], true); +} + +#[test] +fn malformed_native_settings_are_preserved_while_the_other_agent_can_sync() { + let fixture = Fixture::new(); + fixture.write("claude-acp", "{ invalid settings"); + let saved = fixture.save(hook(), &fixture.read()); + assert!(saved.hooks[0].needs_sync); + assert_eq!(saved.hooks[0].configured_agents, ["codex-acp"]); + assert_eq!( + fs::read_to_string(fixture.native("claude-acp")).unwrap(), + "{ invalid settings" + ); +} + +#[test] +fn externally_edited_owned_commands_are_never_removed_or_duplicated() { + let fixture = Fixture::new(); + let saved = fixture.save(hook(), &fixture.read()); + let mut changed = fixture.doc("codex-acp"); + changed["hooks"]["Stop"][0]["hooks"][0]["command"] = json!("user-modified"); + fixture.write("codex-acp", &changed.to_string()); + let reread = fixture.read(); + assert!(reread.hooks[0].needs_sync); + let removed = fixture + .run(Action::Remove { + id: saved.hooks[0].hook.id.clone(), + expected_revision: reread.revision.clone(), + files: guards(&reread), + }) + .unwrap(); + assert!(removed.hooks[0].hook.pending_removal); + assert!(removed.targets[0].issue.is_some()); + assert_eq!(fixture.doc("codex-acp"), changed); + // Removing the external entry explicitly makes the pending operation retryable. + fixture.write("codex-acp", "{}"); + assert!(fixture.sync(&fixture.read()).hooks.is_empty()); +} + +#[test] +fn journals_survive_a_crash_before_or_after_a_native_write_without_duplicate_execution() { + for native_committed in [false, true] { + let fixture = Fixture::new(); + let saved = fixture.save(hook(), &fixture.read()); + let source = storage::read("library", fixture.directory().join("hooks.json")).unwrap(); + let mut stored = decode(&source).unwrap(); + stored.hooks[0].command = "printf replacement".into(); + let native = native_file("codex-acp", &|agent| Ok(fixture.profile(agent))).unwrap(); + let (next, planned) = plan(&stored, &native).unwrap(); + stored.placements.extend(planned); + storage::save(&source, &stored).unwrap(); + if native_committed { + storage::write_native(&native, &next).unwrap(); + } + let recovered = fixture.sync(&fixture.read()); + assert!(recovered.hooks.iter().all(|h| !h.needs_sync)); + let doc = fixture.doc("codex-acp"); + let running = doc["hooks"]["Stop"] + .as_array() + .unwrap() + .iter() + .flat_map(|g| g["hooks"].as_array().unwrap()); + assert_eq!(running.count(), 1); + assert_eq!(recovered.hooks[0].hook.id, saved.hooks[0].hook.id); + } +} + +#[test] +fn cancellation_before_start_has_no_side_effects_and_between_targets_is_retryable() { + let fixture = Fixture::new(); + let request = Request { + data_directory: fixture.directory(), + action: Action::Read {}, + }; + assert_eq!( + execute_in( + request, + &|agent| Ok(fixture.profile(agent)), + &|| true, + &Mutex::new(()) + ) + .unwrap_err() + .reason, + "cancelled" + ); + assert!(!fixture.0.exists()); + let before = fixture.read(); + let result = execute_in( + Request { + data_directory: fixture.directory(), + action: Action::Save { + hook: hook(), + expected_revision: before.revision.clone(), + files: guards(&before), + }, + }, + &|agent| Ok(fixture.profile(agent)), + &|| fixture.native("codex-acp").exists(), + &Mutex::new(()), + ) + .unwrap(); + assert!(result.hooks[0].needs_sync); + assert!(!fixture.native("claude-acp").exists()); + assert!(!fixture.sync(&fixture.read()).hooks[0].needs_sync); +} + +#[test] +fn validates_names_events_timeout_agent_selection_and_strict_requests_before_writing() { + let mut invalid = hook(); + invalid.name = "../escape".into(); + assert!(validate(&invalid).is_err()); + let mut invalid = hook(); + invalid.timeout_seconds = Some(0); + assert!(validate(&invalid).is_err()); + let mut invalid = hook(); + invalid.timeout_seconds = Some(86401); + assert!(validate(&invalid).is_err()); + let mut invalid = hook(); + invalid.agents = vec!["unknown".into()]; + assert!(validate(&invalid).is_err()); + let mut invalid = hook(); + invalid.matcher = Matcher::Shell; + assert!(validate(&invalid).is_err()); + let mut invalid = hook(); + invalid.description = "汉".repeat(201); + assert!(validate(&invalid).is_err()); + assert!(serde_json::from_value::( + json!({"dataDirectory": "/fixture", "action": "read", "command": "must-not-run"}) + ) + .is_err()); +} + +#[test] +fn duplicate_json_keys_and_identical_user_owned_commands_are_never_overwritten_or_claimed() { + let fixture = Fixture::new(); + let wanted = hook(); + fixture.write( + "codex-acp", + &json!({"hooks": {"Stop": [entry(&wanted, "codex-acp")]}}).to_string(), + ); + let duplicate = "{\"env\":{\"TOKEN\":\"first\",\"TOKEN\":\"second\"}}"; + fixture.write("claude-acp", duplicate); + let result = fixture.save(wanted.clone(), &fixture.read()); + assert!(result.targets.iter().all(|t| t.issue.is_some())); + assert!(result.hooks[0].configured_agents.is_empty()); + assert_eq!( + fixture.doc("codex-acp")["hooks"]["Stop"] + .as_array() + .unwrap() + .len(), + 1 + ); + assert_eq!( + fs::read_to_string(fixture.native("claude-acp")).unwrap(), + duplicate + ); + let source = storage::read("library", fixture.directory().join("hooks.json")).unwrap(); + assert!(decode(&source).unwrap().placements.is_empty()); +} + +#[test] +fn os_library_lock_is_nonblocking_and_released_with_its_owner() { + let fixture = Fixture::new(); + let first = storage::lock(&fixture.directory()).unwrap(); + assert_eq!( + storage::lock(&fixture.directory()).unwrap_err().reason, + "busy" + ); + drop(first); + assert!(storage::lock(&fixture.directory()).is_ok()); +} + +#[cfg(unix)] +#[test] +fn symlinked_native_files_are_not_followed_and_readonly_permissions_are_preserved() { + use std::os::unix::fs::{symlink, PermissionsExt}; + let fixture = Fixture::new(); + fixture.write("codex-acp", "{}"); + let outside = fixture.0.join("outside.json"); + fs::write(&outside, "{}").unwrap(); + fs::remove_file(fixture.native("codex-acp")).unwrap(); + symlink(&outside, fixture.native("codex-acp")).unwrap(); + fixture.write("claude-acp", "{}"); + fs::set_permissions( + fixture.native("claude-acp"), + fs::Permissions::from_mode(0o400), + ) + .unwrap(); + let saved = fixture.save(hook(), &fixture.read()); + assert!(saved.targets.iter().all(|t| t.issue.is_some())); + assert_eq!(fs::read_to_string(outside).unwrap(), "{}"); + assert_eq!( + fs::metadata(fixture.native("claude-acp")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o400 + ); +} diff --git a/rust/lithe-agent-host/src/instructions.rs b/rust/lithe-agent-host/src/instructions.rs new file mode 100644 index 0000000000..0b6b8cc611 --- /dev/null +++ b/rust/lithe-agent-host/src/instructions.rs @@ -0,0 +1,324 @@ +//! Native global instruction files. The upstream Agent owns prompt loading; +//! this adapter only resolves, reads and atomically saves the user's file. + +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +pub mod shared; + +const MAX_BYTES: usize = 1024 * 1024; +static SAVE_LOCK: Mutex<()> = Mutex::new(()); + +/// Native user file snapshot; paths are display/write guards, not arbitrary inputs. +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct InstructionFile { + pub agent_id: String, + pub path: PathBuf, + pub content: String, + pub exists: bool, + /// Digest includes absence, so creating an empty file invalidates an old draft. + pub revision: String, +} + +/// A save must match both the selected native file and its previously read bytes. +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SaveRequest { + pub agent_id: String, + pub content: String, + pub expected_path: PathBuf, + pub expected_revision: String, +} + +/// Stable instruction-specific reason, carried in the Core envelope's details. +#[derive(Debug)] +pub struct InstructionError { + pub reason: &'static str, + pub message: &'static str, +} + +fn error(reason: &'static str, message: &'static str) -> InstructionError { + InstructionError { reason, message } +} + +fn io_error(_: std::io::Error) -> InstructionError { + error( + "file_access", + "Cannot access the global instruction file. Check its permissions and try again.", + ) +} + +/// Resolve the same environment-based profile inherited by native Agent launches. +pub fn read(agent_id: &str) -> Result { + read_in(agent_id, &profile(agent_id)?) +} + +/// Save user text without modifying repository instructions or Agent configuration. +pub fn save(request: SaveRequest) -> Result { + let directory = profile(&request.agent_id)?; + save_in(request, &directory) +} + +// Resolution stays outside Core and never derives a destination from packaged resources. +pub(crate) fn profile(agent_id: &str) -> Result { + let (variable, fallback) = match agent_id { + "codex-acp" => ("CODEX_HOME", ".codex"), + "claude-acp" => ("CLAUDE_CONFIG_DIR", ".claude"), + _ => { + return Err(error( + "unsupported_agent", + "Global instructions are available for Codex and Claude Code.", + )) + } + }; + resolve_profile( + std::env::var_os(variable).map(PathBuf::from), + user_home().map(|home| home.join(fallback)), + ) +} + +fn user_home() -> Option { + let variable = if cfg!(windows) { "USERPROFILE" } else { "HOME" }; + std::env::var_os(variable) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) +} + +fn resolve_profile( + configured: Option, + fallback: Option, +) -> Result { + let path = configured + .filter(|path| !path.as_os_str().is_empty()) + .or(fallback) + .ok_or_else(|| error("profile_missing", "Cannot locate the Agent user directory."))?; + if !path.is_absolute() { + return Err(error("profile_relative", "The Agent user directory must be an absolute path. Check CODEX_HOME or CLAUDE_CONFIG_DIR.")); + } + Ok(path) +} + +fn read_in(agent_id: &str, directory: &Path) -> Result { + let filename = match agent_id { + "codex-acp" => { + let override_file = snapshot(agent_id, directory.join("AGENTS.override.md"))?; + if !override_file.content.trim().is_empty() { + return Ok(override_file); + } + "AGENTS.md" + } + "claude-acp" => "CLAUDE.md", + _ => { + return Err(error( + "unsupported_agent", + "Global instructions are available for Codex and Claude Code.", + )) + } + }; + snapshot(agent_id, directory.join(filename)) +} + +pub(crate) fn snapshot(agent_id: &str, path: PathBuf) -> Result { + let metadata = match fs::symlink_metadata(&path) { + Ok(metadata) => Some(metadata), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(io_error(error)), + }; + if metadata + .as_ref() + .is_some_and(|metadata| !metadata.file_type().is_file()) + { + return Err(error( + "unsafe_file", + "The instruction path must be a regular file, not a symbolic link or directory.", + )); + } + let mut bytes = Vec::new(); + if metadata.is_some() { + open_regular(&path)? + .take(MAX_BYTES as u64 + 1) + .read_to_end(&mut bytes) + .map_err(io_error)?; + } + if bytes.len() > MAX_BYTES { + return Err(error( + "file_too_large", + "Global instructions must be no larger than 1 MiB.", + )); + } + let content = String::from_utf8(bytes).map_err(|_| { + error( + "invalid_utf8", + "The global instruction file must use UTF-8 encoding.", + ) + })?; + let exists = metadata.is_some(); + let revision = format!( + "{:x}", + Sha256::digest([&[u8::from(exists)][..], content.as_bytes()].concat()) + ); + Ok(InstructionFile { + agent_id: agent_id.into(), + path, + content, + exists, + revision, + }) +} + +fn open_regular(path: &Path) -> Result { + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + // Do not follow a replacement symlink or block on a replacement FIFO. + options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(0x00200000); // FILE_FLAG_OPEN_REPARSE_POINT + } + let file = options.open(path).map_err(io_error)?; + let kind = file.metadata().map_err(io_error)?.file_type(); + if !kind.is_file() || kind.is_symlink() { + return Err(error( + "unsafe_file", + "The instruction path must be a regular file, not a symbolic link or directory.", + )); + } + Ok(file) +} + +fn save_in(request: SaveRequest, directory: &Path) -> Result { + let _guard = SAVE_LOCK + .try_lock() + .map_err(|_| error("busy", "Another instruction save is running. Try again."))?; + save_unlocked(request, directory) +} + +fn save_unlocked( + request: SaveRequest, + directory: &Path, +) -> Result { + if request.content.len() > MAX_BYTES { + return Err(error( + "file_too_large", + "Global instructions must be no larger than 1 MiB.", + )); + } + let previous = read_in(&request.agent_id, directory)?; + check_revision(&previous, &request)?; + write_snapshot(&previous, &request.content, || { + read_in(&request.agent_id, directory) + })?; + // Clearing an override can make AGENTS.md effective again. Return that actual source. + read_in(&request.agent_id, directory) +} + +// Both the shared library and native files use the same guarded atomic replacement. +// The caller holds SAVE_LOCK; the recheck also protects native precedence changes. +pub(crate) fn write_snapshot( + previous: &InstructionFile, + content: &str, + recheck: impl Fn() -> Result, +) -> Result<(), InstructionError> { + if content.len() > MAX_BYTES { + return Err(error( + "file_too_large", + "Global instructions must be no larger than 1 MiB.", + )); + } + let directory = previous + .path + .parent() + .ok_or_else(|| io_error(std::io::Error::other("missing parent")))?; + let permissions = if previous.exists { + let permissions = fs::metadata(&previous.path) + .map_err(io_error)? + .permissions(); + if permissions.readonly() { + return Err(error( + "file_access", + "Cannot access the global instruction file. Check its permissions and try again.", + )); + } + Some(permissions) + } else { + None + }; + let mut directories = fs::DirBuilder::new(); + directories.recursive(true); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + directories.mode(0o700); + } + directories.create(directory).map_err(io_error)?; + let parent = fs::canonicalize(directory).map_err(io_error)?; + let stage_path = parent.join(format!(".lithe-instructions-{}.tmp", uuid::Uuid::new_v4())); + let stage = StagedFile(stage_path.clone()); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(&stage_path).map_err(io_error)?; + file.write_all(content.as_bytes()).map_err(io_error)?; + if let Some(permissions) = permissions { + file.set_permissions(permissions).map_err(io_error)?; + } + file.sync_all().map_err(io_error)?; + drop(file); + // Detect edits/profile precedence changes made while preparing the write. + // Other applications do not share our lock, so this is an optimistic guard. + check_revision( + &recheck()?, + &SaveRequest { + agent_id: previous.agent_id.clone(), + content: String::new(), + expected_path: previous.path.clone(), + expected_revision: previous.revision.clone(), + }, + )?; + if fs::canonicalize(directory).map_err(io_error)? != parent { + return Err(error( + "file_changed", + "The instruction directory changed. Reload before saving.", + )); + } + let target = parent.join( + previous + .path + .file_name() + .expect("native instruction filename"), + ); + fs::rename(&stage.0, target).map_err(io_error)?; + Ok(()) +} + +fn check_revision(file: &InstructionFile, request: &SaveRequest) -> Result<(), InstructionError> { + if file.path != request.expected_path || file.revision != request.expected_revision { + return Err(error("file_changed", "Global instructions changed outside this editor. Your draft is kept; reload before saving.")); + } + Ok(()) +} + +struct StagedFile(PathBuf); +impl Drop for StagedFile { + fn drop(&mut self) { + let _ = fs::remove_file(&self.0); + } +} + +#[cfg(test)] +mod tests; diff --git a/rust/lithe-agent-host/src/instructions/shared.rs b/rust/lithe-agent-host/src/instructions/shared.rs new file mode 100644 index 0000000000..40c6481404 --- /dev/null +++ b/rust/lithe-agent-host/src/instructions/shared.rs @@ -0,0 +1,416 @@ +//! User-owned shared text and guarded, per-Agent synchronization of a marked block. +//! The platform supplies its writable application-support directory; opening the +//! page is read-only. No prompt injection, package writes or background sync occurs. + +use super::*; +use fs2::FileExt; + +const BEGIN: &str = ""; +const END: &str = ""; +const AGENTS: [&str; 2] = ["codex-acp", "claude-acp"]; + +/// Platform-selected storage plus an explicit read, text save or Agent switch. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Request { + pub data_directory: PathBuf, + #[serde(flatten)] + pub action: Action, +} + +/// Writes always name the source revision and the native snapshots being changed. +#[derive(Deserialize)] +#[serde(tag = "action", rename_all = "camelCase", deny_unknown_fields)] +pub enum Action { + Read {}, + Save { + content: String, + #[serde(rename = "expectedRevision")] + expected_revision: String, + files: Vec, + }, + SetEnabled { + #[serde(rename = "agentId")] + agent_id: String, + enabled: bool, + #[serde(rename = "expectedRevision")] + expected_revision: String, + file: FileGuard, + }, +} + +/// A native path is only a snapshot guard; destinations are resolved by the host. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct FileGuard { + pub agent_id: String, + pub path: PathBuf, + pub revision: String, +} + +/// Enabled state comes from actual file bytes, never from a persisted switch value. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentState { + pub agent_id: String, + pub file: Option, + pub enabled: Option, + pub needs_sync: bool, + pub own_line_count: usize, + pub issue: Option, +} + +/// A text save can succeed while one Agent fails; every failure stays visible. +#[derive(Serialize)] +pub struct Library { + pub file: InstructionFile, + pub agents: Vec, +} + +/// Execute a bounded operation. Cancellation is observed between native writes; +/// completed file replacements remain committed and are reported by the next read. +pub fn execute( + request: Request, + cancelled: &impl Fn() -> bool, +) -> Result { + if !request.data_directory.is_absolute() { + return Err(error( + "profile_relative", + "The shared instruction directory must be absolute.", + )); + } + execute_in(request, &profile, cancelled, &SAVE_LOCK) +} + +fn execute_in( + request: Request, + resolve: &impl Fn(&str) -> Result, + cancelled: &impl Fn() -> bool, + write_lock: &Mutex<()>, +) -> Result { + let path = request.data_directory.join("Shared.md"); + if matches!(request.action, Action::Read {}) { + return library(snapshot("shared", path)?, resolve); + } + let _guard = write_lock + .try_lock() + .map_err(|_| error("busy", "Another instruction save is running. Try again."))?; + let before = snapshot("shared", path.clone())?; + let expected = match &request.action { + Action::Save { + expected_revision, .. + } + | Action::SetEnabled { + expected_revision, .. + } => expected_revision, + Action::Read {} => unreachable!(), + }; + if &before.revision != expected { + return Err(error( + "file_changed", + "Shared instructions changed outside this editor. Reload before saving.", + )); + } + // One nonblocking OS lock protects separate Lithe processes sharing this library. + // Its persistent empty file is user state, never a transferable build resource. + match &request.action { + Action::Save { content, files, .. } => { + validate_text(content)?; + let ids: std::collections::HashSet<_> = + files.iter().map(|file| file.agent_id.as_str()).collect(); + if files.len() > AGENTS.len() + || ids.len() != files.len() + || ids.iter().any(|id| !AGENTS.contains(id)) + { + return Err(error( + "invalid_request", + "Invalid shared instruction request.", + )); + } + } + Action::SetEnabled { agent_id, file, .. } + if !AGENTS.contains(&agent_id.as_str()) || file.agent_id != *agent_id => + { + return Err(error( + "unsupported_agent", + "Global instructions are available for Codex and Claude Code.", + )); + } + _ => {} + } + if cancelled() { + return Err(error( + "cancelled", + "Instruction synchronization was cancelled.", + )); + } + let mut directories = fs::DirBuilder::new(); + directories.recursive(true); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + directories.mode(0o700); + } + directories + .create(&request.data_directory) + .map_err(io_error)?; + let lock_path = request.data_directory.join(".sync.lock"); + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true).truncate(false); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(0x00200000); // FILE_FLAG_OPEN_REPARSE_POINT + } + let lock = options.open(lock_path).map_err(io_error)?; + let kind = lock.metadata().map_err(io_error)?.file_type(); + if !kind.is_file() || kind.is_symlink() { + return Err(error( + "unsafe_file", + "The instruction path must be a regular file, not a symbolic link or directory.", + )); + } + lock.try_lock_exclusive() + .map_err(|_| error("busy", "Another instruction save is running. Try again."))?; + if snapshot("shared", path.clone())?.revision != before.revision { + return Err(error( + "file_changed", + "Shared instructions changed outside this editor. Reload before saving.", + )); + } + let mut failures = Vec::new(); + match request.action { + Action::Save { content, files, .. } => { + validate_text(&content)?; + if files.len() > AGENTS.len() { + return Err(error( + "invalid_request", + "Invalid shared instruction request.", + )); + } + write_snapshot(&before, &content, || snapshot("shared", path.clone()))?; + // Commit the source first. Failed targets retain their old block and + // expose needsSync; retrying is explicit and does not lose user text. + for agent in AGENTS { + let result = (|| { + if cancelled() { + return Err(error( + "cancelled", + "Instruction synchronization was cancelled.", + )); + } + let directory = resolve(agent)?; + let current = read_in(agent, &directory)?; + if block(¤t.content)?.is_none() { + return Ok(()); + } + let guard = files + .iter() + .find(|file| file.agent_id == agent) + .ok_or_else(|| { + error( + "file_changed", + "Reload this Agent before synchronizing instructions.", + ) + })?; + sync(¤t, guard, Some(&content), &directory) + })(); + if let Err(err) = result { + failures.push((agent.to_string(), err.message.to_string())); + } + } + } + Action::SetEnabled { + agent_id, + enabled, + file, + .. + } => { + if !AGENTS.contains(&agent_id.as_str()) || file.agent_id != agent_id { + return Err(error( + "unsupported_agent", + "Global instructions are available for Codex and Claude Code.", + )); + } + if cancelled() { + return Err(error( + "cancelled", + "Instruction synchronization was cancelled.", + )); + } + let directory = resolve(&agent_id)?; + let current = read_in(&agent_id, &directory)?; + sync( + ¤t, + &file, + enabled.then_some(before.content.as_str()), + &directory, + )?; + } + Action::Read {} => unreachable!(), + } + let mut result = library(snapshot("shared", path)?, resolve)?; + for (agent, message) in failures { + if let Some(state) = result + .agents + .iter_mut() + .find(|state| state.agent_id == agent) + { + state.issue = Some(message); + } + } + Ok(result) +} + +fn library( + file: InstructionFile, + resolve: &impl Fn(&str) -> Result, +) -> Result { + let agents = AGENTS + .iter() + .map(|agent| { + let result = resolve(agent).and_then(|directory| read_in(agent, &directory)); + match result { + Ok(native) => match block(&native.content) { + Ok(managed) => { + let own = without_block(&native.content, managed.as_ref()); + AgentState { + agent_id: (*agent).into(), + enabled: Some(managed.is_some()), + needs_sync: managed + .as_ref() + .is_some_and(|block| block.text != file.content), + own_line_count: own + .lines() + .filter(|line| !line.trim().is_empty()) + .count(), + file: Some(native), + issue: None, + } + } + Err(err) => AgentState { + agent_id: (*agent).into(), + file: Some(native), + enabled: None, + needs_sync: false, + own_line_count: 0, + issue: Some(err.message.into()), + }, + }, + Err(err) => AgentState { + agent_id: (*agent).into(), + file: None, + enabled: None, + needs_sync: false, + own_line_count: 0, + issue: Some(err.message.into()), + }, + } + }) + .collect(); + Ok(Library { file, agents }) +} + +fn sync( + current: &InstructionFile, + guard: &FileGuard, + text: Option<&str>, + directory: &Path, +) -> Result<(), InstructionError> { + let request = SaveRequest { + agent_id: current.agent_id.clone(), + content: String::new(), + expected_path: guard.path.clone(), + expected_revision: guard.revision.clone(), + }; + check_revision(current, &request)?; + let managed = block(¤t.content)?; + if let Some(text) = text { + validate_text(text)?; + } + let replacement = text + .map(|text| format!("\n{BEGIN}\n{text}\n{END}\n")) + .unwrap_or_default(); + let content = if let Some(managed) = managed { + format!( + "{}{}{}", + ¤t.content[..managed.start], + replacement, + ¤t.content[managed.end..] + ) + } else { + format!("{}{}", current.content, replacement) + }; + if content == current.content { + return Ok(()); + } + save_unlocked(SaveRequest { content, ..request }, directory)?; + Ok(()) +} + +struct Block<'a> { + start: usize, + end: usize, + text: &'a str, +} + +fn block(content: &str) -> Result>, InstructionError> { + let opens = content.matches(BEGIN).count(); + let closes = content.matches(END).count(); + if opens == 0 && closes == 0 { + return Ok(None); + } + let invalid = || { + error("invalid_markers", "The Lithe shared instruction markers were changed. Repair the native file before syncing.") + }; + if opens != 1 || closes != 1 { + return Err(invalid()); + } + // Include our own leading/trailing newline in the range. Removing a block + // restores the exact original bytes, even when the user file had no newline. + let open = format!("\n{BEGIN}\n"); + let close = format!("\n{END}\n"); + let start = content.find(&open).ok_or_else(invalid)?; + let text_start = start + open.len(); + let close_start = content[text_start..] + .find(&close) + .map(|index| text_start + index) + .ok_or_else(invalid)?; + Ok(Some(Block { + start, + end: close_start + close.len(), + text: &content[text_start..close_start], + })) +} + +fn without_block(content: &str, managed: Option<&Block<'_>>) -> String { + managed + .map(|block| format!("{}{}", &content[..block.start], &content[block.end..])) + .unwrap_or_else(|| content.into()) +} + +fn validate_text(content: &str) -> Result<(), InstructionError> { + if content.len() > MAX_BYTES { + return Err(error( + "file_too_large", + "Global instructions must be no larger than 1 MiB.", + )); + } + if content.contains(BEGIN) || content.contains(END) { + return Err(error( + "invalid_markers", + "Shared instructions cannot contain Lithe synchronization markers.", + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/rust/lithe-agent-host/src/instructions/shared/tests.rs b/rust/lithe-agent-host/src/instructions/shared/tests.rs new file mode 100644 index 0000000000..4939fc717c --- /dev/null +++ b/rust/lithe-agent-host/src/instructions/shared/tests.rs @@ -0,0 +1,228 @@ +use super::*; + +struct Fixture { + path: PathBuf, + write_lock: Mutex<()>, +} +impl Fixture { + fn new() -> Self { + Self { + path: std::env::temp_dir().join(format!("lithe-shared-{}", uuid::Uuid::new_v4())), + write_lock: Mutex::new(()), + } + } + fn profile(&self, agent: &str) -> Result { + Ok(self.path.join(agent)) + } + fn read(&self) -> Library { + self.run(Action::Read {}).unwrap() + } + fn run(&self, action: Action) -> Result { + execute_in( + Request { + data_directory: self.path.join("library"), + action, + }, + &|agent| self.profile(agent), + &|| false, + &self.write_lock, + ) + } + fn save(&self, library: &Library, text: &str) -> Library { + self.run(Action::Save { + content: text.into(), + expected_revision: library.file.revision.clone(), + files: library + .agents + .iter() + .filter_map(|agent| agent.file.as_ref().map(guard)) + .collect(), + }) + .unwrap() + } + fn toggle( + &self, + library: &Library, + agent: &str, + enabled: bool, + ) -> Result { + self.run(Action::SetEnabled { + agent_id: agent.into(), + enabled, + expected_revision: library.file.revision.clone(), + file: guard( + library + .agents + .iter() + .find(|row| row.agent_id == agent) + .unwrap() + .file + .as_ref() + .unwrap(), + ), + }) + } +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} +fn guard(file: &InstructionFile) -> FileGuard { + FileGuard { + agent_id: file.agent_id.clone(), + path: file.path.clone(), + revision: file.revision.clone(), + } +} +fn enabled(library: &Library, agent: &str) -> bool { + library + .agents + .iter() + .find(|row| row.agent_id == agent) + .unwrap() + .enabled + == Some(true) +} + +#[test] +fn shared_sync_preserves_own_bytes_checks_revisions_and_reports_partial_failures() { + let fixture = Fixture::new(); + let first = fixture.read(); + assert!( + !fixture.path.exists(), + "opening settings must not create any files" + ); + let locked = fixture.write_lock.try_lock().unwrap(); + assert_eq!( + fixture + .run(Action::Save { + content: "not written".into(), + expected_revision: first.file.revision.clone(), + files: vec![] + }) + .err() + .unwrap() + .reason, + "busy" + ); + assert!(!fixture.path.exists()); + drop(locked); + let codex = fixture.profile("codex-acp").unwrap(); + let claude = fixture.profile("claude-acp").unwrap(); + fs::create_dir_all(&codex).unwrap(); + fs::create_dir_all(&claude).unwrap(); + let own_codex = "# 私有规则\r\n无结尾换行"; + fs::write(codex.join("AGENTS.md"), own_codex).unwrap(); + fs::write(claude.join("CLAUDE.md"), "Claude only\n").unwrap(); + let library = fixture.save(&first, "共享中文\nKeep identifiers."); + assert!(!enabled(&library, "codex-acp")); + let library = fixture.toggle(&library, "codex-acp", true).unwrap(); + let library = fixture.toggle(&library, "claude-acp", true).unwrap(); + assert!(enabled(&library, "codex-acp") && enabled(&library, "claude-acp")); + let library = fixture.save(&library, "第二版"); + assert!(library + .agents + .iter() + .all(|row| !row.needs_sync && row.issue.is_none())); + assert!(fs::read_to_string(codex.join("AGENTS.md")) + .unwrap() + .starts_with(own_codex)); + let stale = library; + fs::write( + codex.join("AGENTS.md"), + format!( + "外部修改{}", + fs::read_to_string(codex.join("AGENTS.md")).unwrap() + ), + ) + .unwrap(); + let library = fixture.save(&stale, "第三版"); + let row = &library.agents[0]; + assert!(row.needs_sync && row.issue.is_some()); + assert!( + !library.agents[1].needs_sync, + "another Agent can complete despite one conflict" + ); + assert_eq!( + fixture + .toggle(&stale, "codex-acp", false) + .err() + .unwrap() + .reason, + "file_changed" + ); + let library = fixture.save(&library, "第三版"); + assert!(library.agents.iter().all(|row| !row.needs_sync)); + let library = fixture.toggle(&library, "codex-acp", false).unwrap(); + assert!(!enabled(&library, "codex-acp") && enabled(&library, "claude-acp")); + assert_eq!( + fs::read_to_string(codex.join("AGENTS.md")).unwrap(), + format!("外部修改{own_codex}") + ); + let _ = fixture.toggle(&library, "claude-acp", false).unwrap(); + assert_eq!( + fs::read_to_string(claude.join("CLAUDE.md")).unwrap(), + "Claude only\n" + ); + // Reopening uses disk snapshots; no in-memory preference can claim a stale switch. + let reopened = fixture.read(); + assert!(reopened.agents.iter().all(|row| row.enabled == Some(false))); + assert_eq!(reopened.file.content, "第三版"); + fs::write(codex.join("AGENTS.override.md"), "override text").unwrap(); + let library = fixture.read(); + let library = fixture.toggle(&library, "codex-acp", true).unwrap(); + assert!(library.agents[0] + .file + .as_ref() + .unwrap() + .path + .ends_with("AGENTS.override.md")); + let _ = fixture.toggle(&library, "codex-acp", false).unwrap(); + assert_eq!( + fs::read_to_string(codex.join("AGENTS.override.md")).unwrap(), + "override text" + ); + // Malformed or duplicate markers are left untouched and the switch becomes unknown. + fs::write(claude.join("CLAUDE.md"), BEGIN).unwrap(); + let library = fixture.read(); + assert!(library.agents[1].enabled.is_none() && library.agents[1].issue.is_some()); + assert_eq!( + fixture + .toggle(&library, "claude-acp", true) + .err() + .unwrap() + .reason, + "invalid_markers" + ); + assert_eq!(fs::read_to_string(claude.join("CLAUDE.md")).unwrap(), BEGIN); +} + +#[test] +fn marked_ranges_roundtrip_and_reject_ambiguous_boundaries() { + for own in ["", "no newline", "ends in LF\n", "CRLF\r\n", "中文\n\n"] { + for shared in ["", "中文", "line\n", "line\r\n"] { + let combined = format!("{own}\n{BEGIN}\n{shared}\n{END}\nextra"); + let managed = block(&combined).unwrap().unwrap(); + assert_eq!(managed.text, shared); + assert_eq!( + without_block(&combined, Some(&managed)), + format!("{own}extra") + ); + } + } + for malformed in [ + BEGIN.to_string(), + END.to_string(), + format!("\n{BEGIN}\n{BEGIN}\nx\n{END}\n"), + format!("{BEGIN}\nx\n{END}"), + ] { + assert!(block(&malformed).is_err()); + } + assert!(validate_text(BEGIN).is_err()); + assert!(validate_text(&"x".repeat(MAX_BYTES + 1)).is_err()); + let request: Request = + serde_json::from_value(serde_json::json!({"dataDirectory":"/fixture", "action":"read"})) + .unwrap(); + assert!(matches!(request.action, Action::Read {})); +} diff --git a/rust/lithe-agent-host/src/instructions/tests.rs b/rust/lithe-agent-host/src/instructions/tests.rs new file mode 100644 index 0000000000..dc8b00b562 --- /dev/null +++ b/rust/lithe-agent-host/src/instructions/tests.rs @@ -0,0 +1,203 @@ +use super::*; + +struct Directory(PathBuf); +impl Directory { + fn new() -> Self { + Self(std::env::temp_dir().join(format!("lithe-instructions-{}", uuid::Uuid::new_v4()))) + } + fn write(&self, name: &str, bytes: &[u8]) { + fs::create_dir_all(&self.0).unwrap(); + fs::write(self.0.join(name), bytes).unwrap(); + } +} +impl Drop for Directory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn request(file: &InstructionFile, content: &str) -> SaveRequest { + SaveRequest { + agent_id: file.agent_id.clone(), + content: content.into(), + expected_path: file.path.clone(), + expected_revision: file.revision.clone(), + } +} + +#[test] +fn native_sources_follow_override_and_agent_boundaries_without_creating_files() { + let directory = Directory::new(); + let empty = read_in("codex-acp", &directory.0).unwrap(); + assert!(!empty.exists); + assert!(!directory.0.exists(), "opening settings is read only"); + directory.write("AGENTS.md", "base 中文\n".as_bytes()); + directory.write("CLAUDE.md", b"claude"); + directory.write("AGENTS.override.md", b" \n"); + assert_eq!( + read_in("codex-acp", &directory.0).unwrap().content, + "base 中文\n" + ); + directory.write("AGENTS.override.md", b"override"); + assert_eq!( + read_in("codex-acp", &directory.0).unwrap().content, + "override" + ); + assert_eq!( + read_in("claude-acp", &directory.0).unwrap().content, + "claude" + ); + assert_eq!( + read_in("custom", &directory.0).unwrap_err().reason, + "unsupported_agent" + ); +} + +#[test] +fn save_roundtrip_guards_external_edits_and_native_precedence() { + // All saves in this case share the native save lock; no environment mutation. + let directory = Directory::new(); + let missing = read_in("codex-acp", &directory.0).unwrap(); + let saved = save_in(request(&missing, "中文\r\nexact bytes\n"), &directory.0).unwrap(); + assert_eq!(saved.content, "中文\r\nexact bytes\n"); + assert!(saved.exists); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(&saved.path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + assert_eq!( + fs::metadata(&directory.0).unwrap().permissions().mode() & 0o777, + 0o700 + ); + fs::set_permissions(&saved.path, fs::Permissions::from_mode(0o640)).unwrap(); + } + let updated = save_in(request(&saved, "base"), &directory.0).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(&updated.path).unwrap().permissions().mode() & 0o777, + 0o640 + ); + } + let original_permissions = fs::metadata(&updated.path).unwrap().permissions(); + let mut readonly = original_permissions.clone(); + readonly.set_readonly(true); + fs::set_permissions(&updated.path, readonly).unwrap(); + assert_eq!( + save_in(request(&updated, "read only overwrite"), &directory.0) + .unwrap_err() + .reason, + "file_access" + ); + assert_eq!(fs::read_to_string(&updated.path).unwrap(), "base"); + fs::set_permissions(&updated.path, original_permissions).unwrap(); + directory.write("AGENTS.md", b"external edit"); + assert_eq!( + save_in(request(&updated, "overwrite"), &directory.0) + .unwrap_err() + .reason, + "file_changed" + ); + assert_eq!(fs::read_to_string(&updated.path).unwrap(), "external edit"); + directory.write("AGENTS.override.md", b"override"); + assert_eq!( + save_in(request(&updated, "overwrite"), &directory.0) + .unwrap_err() + .reason, + "file_changed" + ); + let active = read_in("codex-acp", &directory.0).unwrap(); + let effective = save_in(request(&active, ""), &directory.0).unwrap(); + assert!(effective.path.ends_with("AGENTS.md")); + assert_eq!(effective.content, "external edit"); + let cleared = save_in(request(&effective, ""), &directory.0).unwrap(); + assert_eq!(cleared.content, ""); + let claude = read_in("claude-acp", &directory.0).unwrap(); + let claude = save_in(request(&claude, "Claude only"), &directory.0).unwrap(); + assert_eq!(claude.content, "Claude only"); + assert_eq!(read_in("codex-acp", &directory.0).unwrap().content, ""); + let mut wrong_path = request(&claude, "bad"); + wrong_path.expected_path = directory.0.join("arbitrary.md"); + assert_eq!( + save_in(wrong_path, &directory.0).unwrap_err().reason, + "file_changed" + ); + let oversized = "x".repeat(MAX_BYTES + 1); + assert_eq!( + save_in(request(&claude, &oversized), &directory.0) + .unwrap_err() + .reason, + "file_too_large" + ); + assert!(!fs::read_dir(&directory.0).unwrap().any(|entry| entry + .unwrap() + .file_name() + .to_string_lossy() + .starts_with(".lithe-"))); +} + +#[test] +fn profile_resolution_respects_explicit_configuration_and_rejects_relative_paths() { + let directory = Directory::new(); + assert_eq!( + resolve_profile(Some(directory.0.clone()), None).unwrap(), + directory.0 + ); + assert_eq!( + resolve_profile(None, Some(directory.0.clone())).unwrap(), + directory.0 + ); + assert_eq!( + resolve_profile(Some(PathBuf::from("relative")), Some(directory.0.clone())) + .unwrap_err() + .reason, + "profile_relative" + ); + assert_eq!( + resolve_profile(None, None).unwrap_err().reason, + "profile_missing" + ); +} + +#[test] +fn rejects_non_text_and_non_regular_files_and_distinguishes_absence() { + let directory = Directory::new(); + let absent = read_in("claude-acp", &directory.0).unwrap(); + directory.write("CLAUDE.md", b""); + assert_ne!( + read_in("claude-acp", &directory.0).unwrap().revision, + absent.revision + ); + directory.write("CLAUDE.md", &[0xff]); + assert_eq!( + read_in("claude-acp", &directory.0).unwrap_err().reason, + "invalid_utf8" + ); + directory.write("CLAUDE.md", &vec![b'x'; MAX_BYTES + 1]); + assert_eq!( + read_in("claude-acp", &directory.0).unwrap_err().reason, + "file_too_large" + ); + fs::remove_file(directory.0.join("CLAUDE.md")).unwrap(); + fs::create_dir(directory.0.join("CLAUDE.md")).unwrap(); + assert_eq!( + read_in("claude-acp", &directory.0).unwrap_err().reason, + "unsafe_file" + ); + #[cfg(unix)] + { + fs::remove_dir(directory.0.join("CLAUDE.md")).unwrap(); + directory.write("target.md", b"keep"); + std::os::unix::fs::symlink(directory.0.join("target.md"), directory.0.join("CLAUDE.md")) + .unwrap(); + assert_eq!( + read_in("claude-acp", &directory.0).unwrap_err().reason, + "unsafe_file" + ); + assert_eq!(fs::read(directory.0.join("target.md")).unwrap(), b"keep"); + } +} diff --git a/rust/lithe-agent-host/src/lib.rs b/rust/lithe-agent-host/src/lib.rs index 6ac9c20d15..e90b9f7f2c 100644 --- a/rust/lithe-agent-host/src/lib.rs +++ b/rust/lithe-agent-host/src/lib.rs @@ -7,12 +7,18 @@ //! See `.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md`. pub mod catalog; +mod claude_desktop; +mod claude_earlier_history; +mod claude_subscription; pub mod cli_update; mod codex_context; mod codex_history; mod codex_retry; pub mod environment; +pub mod hooks; pub mod install; +pub mod instructions; +pub mod mcp_probe; mod model_calls; mod model_proxy; mod prompt; @@ -20,6 +26,7 @@ mod prompt_retry; mod session_defaults; mod session_restore; mod session_routing; +pub mod skills; mod steering; mod subagents; mod subscription; @@ -111,15 +118,26 @@ pub struct AgentLaunch { /// Exported as [`AGENT_SCOPE_ENVIRONMENT`] to the agent process tree. #[serde(default)] pub process_scope: Option, + /// Claude Code configuration directory of one account, exported as + /// `CLAUDE_CONFIG_DIR`. Only the Claude subscription mode accepts it; the + /// directory holds the official CLI's own login and Lithe never reads it. + #[serde(default)] + pub claude_config_dir: Option, + /// Codex home directory of one account, exported as `CODEX_HOME`, including + /// to the quota probe. Only the Codex subscription mode accepts it. + #[serde(default)] + pub codex_home: Option, } -/// Subscription access is explicit and limited to the installed Codex adapter. +/// Subscription access is explicit: each mode is limited to its own installed adapter. #[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub enum AgentAuthentication { #[default] ApiKey, CodexSubscription, + /// The user's signed-in Claude Code CLI pays; see [`claude_subscription`]. + ClaudeSubscription, } /// User-supplied AI provider endpoint and API key. @@ -209,6 +227,13 @@ struct ResolvedLaunch { secret: String, /// The already detected Codex executable; never a separately downloaded runtime. subscription_cli: Option, + /// The signed-in Claude Code CLI pays: no route, key or token is injected. + claude_subscription: bool, + /// Account directory the Codex quota probe must share with the adapter. + codex_home: Option, + /// Claude transcript root of the adapter's account, for history that + /// compaction hides from `session/load`; None for every other agent. + claude_projects: Option, } #[cfg(test)] @@ -233,8 +258,12 @@ fn resolve_with( launch: AgentLaunch, find_cli: &dyn Fn(&str) -> Option, ) -> Result { - if launch.authentication == AgentAuthentication::CodexSubscription { - return subscription::resolve(launch, find_cli); + match launch.authentication { + AgentAuthentication::CodexSubscription => return subscription::resolve(launch, find_cli), + AgentAuthentication::ClaudeSubscription => { + return claude_subscription::resolve(launch, find_cli) + } + AgentAuthentication::ApiKey => {} } let provider = launch.provider.ok_or("An API provider is required")?; if provider.api_key.trim().is_empty() { @@ -285,6 +314,9 @@ fn resolve_with( gateway: Some(gateway(&provider)?), secret: provider.api_key, subscription_cli: None, + claude_subscription: false, + codex_home: None, + claude_projects: None, }); }; let agent = catalog::find(&agent_id).ok_or_else(|| format!("Unknown agent `{agent_id}`"))?; @@ -341,6 +373,11 @@ fn resolve_with( gateway, secret: provider.api_key, subscription_cli: None, + claude_subscription: false, + codex_home: None, + claude_projects: (agent.id == "claude-acp") + .then(|| claude_earlier_history::projects_directory(None)) + .flatten(), }) } @@ -1039,7 +1076,11 @@ async fn run_agent( None }; let mut command = std::process::Command::new(&launch.command); - if launch + if launch.claude_subscription { + for name in claude_subscription::INHERITED_OVERRIDES { + command.env_remove(name); + } + } else if launch .gateway .as_ref() .is_some_and(|route| route.protocol == ProviderProtocol::AnthropicMessages) @@ -1109,7 +1150,10 @@ async fn run_agent( } } }); - let subscription = launch.subscription_cli.is_some(); + // Account credentials belong to the CLI in both subscription modes. + let subscription = launch.subscription_cli.is_some() || launch.claude_subscription; + let claude_subscription = launch.claude_subscription; + let claude_projects = launch.claude_projects.clone(); let secret = launch.secret.clone(); let transport = ByteStreams::new(stdin.compat_write(), stdout.compat()); let result = run_connection( @@ -1117,6 +1161,9 @@ async fn run_agent( launch.cwd, launch.gateway, launch.subscription_cli, + launch.codex_home, + claude_subscription, + claude_projects, history, context_recovery, observe_subagents, @@ -1140,6 +1187,11 @@ async fn run_agent( child_pid.store(0, Ordering::SeqCst); let _ = tokio::time::timeout(STOP_TIMEOUT, stderr_task).await; result.map_err(|message| { + // The protocol error wraps this text as JSON data; return it verbatim so + // clients can localize the one actionable sign-in instruction. + if claude_subscription && message.contains(claude_subscription::SIGN_IN_REQUIRED) { + return claude_subscription::SIGN_IN_REQUIRED.to_owned(); + } let message = redact(&message, &secret); // Subscription credentials belong to Codex, so Lithe has no secret to // redact. Do not surface arbitrary upstream stderr for this mode. @@ -1260,6 +1312,9 @@ async fn run_connection( cwd: PathBuf, gateway: Option, subscription_cli: Option, + codex_home: Option, + claude_subscription: bool, + claude_projects: Option, history: Option, context_recovery: Option>, observe_subagents: bool, @@ -1279,6 +1334,8 @@ where let session_meta = session_routing::metadata(gateway.as_ref())?; let workspace = Arc::new(workspace_context::WorkspaceContext::new(&cwd)?); let update_workspace = workspace.clone(); + let desktop = Arc::new(Mutex::new(claude_desktop::Presenter::default())); + let update_desktop = desktop.clone(); let restored_meta = session_routing::restored_metadata(gateway.as_ref())?; let (auth_tx, mut auth_rx) = tokio::sync::watch::channel(None::); let updates = emit.clone(); @@ -1323,30 +1380,33 @@ where let Some(update) = update_workspace.present_update(update) else { return Ok(()); }; - if update["sessionUpdate"] == "usage_update" { - if let Some(recovery) = &context_updates { recovery.prefer_upstream_report(&session_id); } - } - let mut reconnect = None; - if let Ok(turns) = update_turns.lock() { - if let Some(turn) = owner.as_ref().and_then(|id| turns.get(id)) { - if owner.as_deref() == Some(session_id.as_str()) { turn.steering.observe(&update); } - turn.retry.send_modify(|state| { - if prompt_retry::is_progress(&update) { state.progress(); } - // A child failure is the child's outcome, not a new - // failure or retry of the main Agent's model call. - if owner.as_deref() == Some(session_id.as_str()) { - state.observe_failure(&update); - reconnect = state.observe_native_retry(&update); - } - }); + let presented = update_desktop.lock().map_err(|_| internal("Replay presentation is unavailable"))?.present(&session_id, update); + for update in presented { + if update["sessionUpdate"] == "usage_update" { + if let Some(recovery) = &context_updates { recovery.prefer_upstream_report(&session_id); } + } + let mut reconnect = None; + if let Ok(turns) = update_turns.lock() { + if let Some(turn) = owner.as_ref().and_then(|id| turns.get(id)) { + if owner.as_deref() == Some(session_id.as_str()) { turn.steering.observe(&update); } + turn.retry.send_modify(|state| { + if prompt_retry::is_progress(&update) { state.progress(); } + // A child failure is the child's outcome, not a new + // failure or retry of the main Agent's model call. + if owner.as_deref() == Some(session_id.as_str()) { + state.observe_failure(&update); + reconnect = state.observe_native_retry(&update); + } + }); + } + } + updates(AgentEvent::Update { + session_id: session_id.clone(), + update, + }); + if let Some((turn_id, attempt, max_attempts)) = reconnect { + updates(AgentEvent::TurnRetrying { session_id: session_id.clone(), turn_id, attempt, max_attempts }); } - } - updates(AgentEvent::Update { - session_id: session_id.clone(), - update, - }); - if let Some((turn_id, attempt, max_attempts)) = reconnect { - updates(AgentEvent::TurnRetrying { session_id, turn_id, attempt, max_attempts }); } } Ok(()) @@ -1486,6 +1546,14 @@ where .await .map_err(|_| internal("The Agent did not finish API key sign-in in time"))??; } + if claude_subscription { + let account = claude_subscription::ensure_signed_in(&mut auth_rx) + .await + .map_err(|message| internal(&message))?; + if let Some(account) = account { + emit(AgentEvent::Account { account }); + } + } let account = if subscription_cli.is_some() { if !initialized.auth_methods.iter().any(|method| method.id().0.as_ref() == "chat-gpt") { return Err(internal("This Codex adapter does not support ChatGPT sign-in")); @@ -1563,9 +1631,9 @@ where if quota_running.load(Ordering::SeqCst) || last_quota.is_some_and(|time| time.elapsed() < subscription::QUOTA_INTERVAL) { continue; } quota_running.store(true, Ordering::SeqCst); last_quota = Some(tokio::time::Instant::now()); - let (cli, cwd, account, emit, running) = (cli.clone(), cwd.clone(), account.clone(), emit.clone(), quota_running.clone()); + let (cli, cwd, account, emit, running, home) = (cli.clone(), cwd.clone(), account.clone(), emit.clone(), quota_running.clone(), codex_home.clone()); tasks.spawn(async move { - let result = subscription::read_quota(&cli, &cwd, &account).await; + let result = subscription::read_quota(&cli, &cwd, &account, home.as_deref()).await; emit(match result { Ok(snapshot) => AgentEvent::Quota { snapshot }, Err(code) => AgentEvent::QuotaFailed { code: code.into() }, @@ -1615,7 +1683,27 @@ where let session_meta = restored_meta.clone(); let confirm_model = session_meta.is_some(); let capture = context_recovery.as_ref().and_then(|recovery| recovery.capture(&session_id)); + let earlier = claude_projects.clone(); + let desktop = desktop.clone(); tasks.spawn(async move { + // Compaction hides earlier segments from the adapter's replay; + // they precede it, so the transcript reads in order. + if let Some(projects) = earlier { + let (workspace, id) = (cwd.clone(), session_id.clone()); + let updates = tokio::task::spawn_blocking(move || { + claude_earlier_history::earlier_updates(&projects, &workspace, &id) + }) + .await + .unwrap_or_default(); + // Collected first: the guard must not live across `emit`. + let updates: Vec<_> = match desktop.lock() { + Ok(mut desktop) => updates.into_iter().flat_map(|u| desktop.present(&session_id, u)).collect(), + Err(_) => updates, + }; + for update in updates { + emit(AgentEvent::Update { session_id: session_id.clone(), update }); + } + } let mut request = LoadSessionRequest::new(session_id.clone(), cwd); request.meta = session_meta; let result = request_with_timeout( diff --git a/rust/lithe-agent-host/src/mcp_probe.rs b/rust/lithe-agent-host/src/mcp_probe.rs new file mode 100644 index 0000000000..8652534e98 --- /dev/null +++ b/rust/lithe-agent-host/src/mcp_probe.rs @@ -0,0 +1,284 @@ +//! Explicit, short-lived MCP inventory probes using the official MCP SDK. +//! +//! Probes list metadata only. They never call tools, persist credentials or +//! start automatically while the settings page is opening. + +use rmcp::{model::PaginatedRequestParams, ServiceExt}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::{collections::HashSet, process::Stdio, time::Duration}; +use tokio::{io::AsyncReadExt, process::Command}; +mod http_client; + +/// User-reviewed connection, supplied by a platform's native configuration store. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Request { + /// `stdio`, `http`, or the legacy `sse` configuration type. + pub transport: String, + /// Executable, without shell evaluation. + pub command: Option, + /// Exact argument boundaries, including empty and space-containing values. + #[serde(default)] + pub args: Vec, + /// Environment overrides for this child only. + #[serde(default)] + pub env: Vec, + /// Remote endpoint; HTTPS or loopback HTTP only. + pub url: Option, + /// Explicit request headers. Values are never returned in errors. + #[serde(default)] + pub headers: Vec, +} + +/// One environment or HTTP header entry; never logged. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Entry { + /// Environment or header name. + pub key: String, + /// User-supplied value; never included in diagnostics. + pub value: String, +} + +/// Stable diagnostic category, deliberately independent of upstream error text. +#[derive(Debug, PartialEq)] +pub enum Error { + Invalid, + Unsupported, + Start, + Connection, + Limit, + Timeout, + Cancelled, +} + +const DEADLINE: Duration = Duration::from_secs(30); +const MAX_BYTES: u64 = 2 * 1024 * 1024; +const MAX_TOOLS: usize = 1000; + +/// Runs one bounded probe on the calling worker thread. The caller owns cancel. +pub fn execute(mut request: Request, cancelled: &dyn Fn() -> bool) -> Result { + validate(&request)?; + if cancelled() { + return Err(Error::Cancelled); + } + if request.transport == "stdio" && !request.env.iter().any(|e| e.key == "PATH") { + if let Some(path) = crate::environment::search_path_with_cancel(cancelled) { + request.env.push(Entry { + key: "PATH".into(), + value: path.to_string_lossy().into_owned(), + }); + } + } + if cancelled() { + return Err(Error::Cancelled); + } + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|_| Error::Start)?; + runtime.block_on(run(request, cancelled)) +} + +fn validate(r: &Request) -> Result<(), Error> { + let bytes = r.args.iter().map(String::len).sum::() + + r.env + .iter() + .chain(&r.headers) + .map(|e| e.key.len() + e.value.len()) + .sum::(); + if bytes > 1024 * 1024 || r.args.len() > 256 || r.env.len() > 128 || r.headers.len() > 128 { + return Err(Error::Invalid); + } + match r.transport.as_str() { + "stdio" + if r.command + .as_ref() + .is_some_and(|s| !s.is_empty() && !s.contains('\0')) => + { + Ok(()) + } + "http" => { + let u = url::Url::parse(r.url.as_deref().unwrap_or("")).map_err(|_| Error::Invalid)?; + let loopback = u.host_str().is_some_and(|s| { + s == "localhost" || s == "127.0.0.1" || s == "[::1]" || s == "::1" + }); + if (u.scheme() != "https" && !(u.scheme() == "http" && loopback)) + || u.host_str().is_none() + || !u.username().is_empty() + || u.password().is_some() + || u.fragment().is_some() + { + return Err(Error::Invalid); + } + Ok(()) + } + "sse" => Err(Error::Unsupported), + _ => Err(Error::Invalid), + } +} + +async fn run(r: Request, cancelled: &dyn Fn() -> bool) -> Result { + let mut child = None; + let transport = r.transport.clone(); + let mut command = if transport == "stdio" { + let path = r + .env + .iter() + .find(|e| e.key == "PATH") + .map(|e| std::ffi::OsStr::new(&e.value)); + let executable = + crate::environment::find_executable(r.command.as_deref().ok_or(Error::Invalid)?, path) + .ok_or(Error::Start)?; + let mut cmd = Command::new(executable); + cmd.args(&r.args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true); + for e in &r.env { + cmd.env(&e.key, &e.value); + } + #[cfg(unix)] + cmd.process_group(0); + let mut spawned = cmd.spawn().map_err(|_| Error::Start)?; + let output = spawned.stdout.take().ok_or(Error::Start)?; + let input = spawned.stdin.take().ok_or(Error::Start)?; + child = Some(spawned); + // Bound the complete stdio metadata exchange, including an unterminated + // JSON line; SDK parsing cannot grow forever on a malicious child. + Some((output.take(MAX_BYTES), input)) + } else { + None + }; + let result = async { + if let Some(streams) = command.take() { + inventory(streams, cancelled).await + } else { + use rmcp::transport::streamable_http_client::{ + StreamableHttpClientTransport, StreamableHttpClientTransportConfig, + }; + let mut headers = http::HeaderMap::new(); + for e in &r.headers { + let key = http::header::HeaderName::from_bytes(e.key.as_bytes()) + .map_err(|_| Error::Invalid)?; + // Session and content framing belong to the SDK, not user headers. + if matches!( + key.as_str(), + "host" + | "content-length" + | "content-type" + | "accept" + | "mcp-session-id" + | "mcp-protocol-version" + ) { + return Err(Error::Invalid); + } + let mut value = + http::header::HeaderValue::from_str(&e.value).map_err(|_| Error::Invalid)?; + value.set_sensitive(true); + headers.insert(key, value); + } + let client = reqwest::Client::builder() + .default_headers(headers) + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(Duration::from_secs(8)) + .timeout(DEADLINE) + .build() + .map_err(|_| Error::Start)?; + let mut config = + StreamableHttpClientTransportConfig::with_uri(r.url.ok_or(Error::Invalid)?); + config.max_sse_event_size = MAX_BYTES as usize; + let http = StreamableHttpClientTransport::with_client( + http_client::BoundedClient(client), + config, + ); + inventory(http, cancelled).await + } + } + .await; + if let Some(mut child) = child { + if let Some(pid) = child.id() { + crate::force_kill_tree(pid); + } + let _ = tokio::time::timeout(Duration::from_secs(3), child.wait()).await; + } + result +} + +#[cfg(test)] +mod tests; + +async fn inventory(transport: T, cancelled: &dyn Fn() -> bool) -> Result +where + T: rmcp::transport::IntoTransport, + E: std::error::Error + Send + Sync + 'static, +{ + let deadline = tokio::time::Instant::now() + DEADLINE; + let cancel = async { + let mut ticks = tokio::time::interval(Duration::from_millis(50)); + loop { + ticks.tick().await; + if cancelled() { + return; + } + } + }; + tokio::pin!(cancel); + let service = tokio::select! { + _ = &mut cancel => return Err(Error::Cancelled), + _ = tokio::time::sleep_until(deadline) => return Err(Error::Timeout), + value = ().serve(transport) => value.map_err(|_| Error::Connection)?, + }; + let listing = async { + let mut tools = Vec::new(); + let mut names = HashSet::new(); + let mut cursors = HashSet::new(); + let mut cursor = None; + let mut size = 0; + for _ in 0..20 { + let mut params = PaginatedRequestParams::default(); + params.cursor = cursor; + let page = service + .list_tools(Some(params)) + .await + .map_err(|_| Error::Connection)?; + for tool in page.tools { + let name = tool.name.to_string(); + let description = tool.description.unwrap_or_default().to_string(); + size += name.len() + description.len(); + if name.is_empty() + || name.len() > 256 + || description.len() > 16384 + || size > MAX_BYTES as usize + || tools.len() >= MAX_TOOLS + { + return Err(Error::Limit); + } + if names.insert(name.clone()) { + tools.push(json!({"name": name, "description": description})); + } + } + cursor = page.next_cursor; + if cursor.is_none() { + tools.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str())); + return Ok(json!({"tools": tools})); + } + if cursor.as_ref().is_some_and(|c| c.len() > 2048) { + return Err(Error::Limit); + } + if !cursors.insert(cursor.clone()) { + return Err(Error::Limit); + } + } + Err(Error::Limit) + }; + let result = tokio::select! { + _ = &mut cancel => Err(Error::Cancelled), + _ = tokio::time::sleep_until(deadline) => Err(Error::Timeout), + value = listing => value, + }; + let _ = tokio::time::timeout(Duration::from_secs(2), service.cancel()).await; + result +} diff --git a/rust/lithe-agent-host/src/mcp_probe/http_client.rs b/rust/lithe-agent-host/src/mcp_probe/http_client.rs new file mode 100644 index 0000000000..8fd6bf35d8 --- /dev/null +++ b/rust/lithe-agent-host/src/mcp_probe/http_client.rs @@ -0,0 +1,174 @@ +//! Bounded HTTP byte adapter for the official SDK's session/SSE transport. +//! Keeps the existing reqwest/TLS stack and caps JSON, errors and SSE bodies +//! before decoding; upstream's default JSON adapter reads an unbounded body. + +use super::MAX_BYTES; +use futures::{stream::BoxStream, StreamExt}; +use http::{HeaderName, HeaderValue}; +use rmcp::{ + model::{ClientJsonRpcMessage, ServerJsonRpcMessage}, + transport::streamable_http_client::{ + StreamableHttpClient, StreamableHttpError, StreamableHttpPostResponse, + }, +}; +use sse_stream::{Error as SseError, Sse, SseStream}; +use std::{collections::HashMap, io, sync::Arc}; + +#[derive(Clone)] +pub(super) struct BoundedClient(pub reqwest::Client); +type Error = StreamableHttpError; +fn failed() -> Error { + StreamableHttpError::Client(io::Error::other("MCP HTTP request failed")) +} + +fn stream(response: reqwest::Response) -> BoxStream<'static, Result> { + response + .bytes_stream() + .scan(0_u64, |size, chunk| { + let value = chunk.map_err(|_| io::Error::other("MCP response failed")); + if let Ok(bytes) = &value { + *size += bytes.len() as u64; + } + let value = if *size > MAX_BYTES { + Err(io::Error::other("MCP response limit")) + } else { + value + }; + futures::future::ready(Some(value)) + }) + .boxed() +} + +fn request( + client: &reqwest::Client, + method: reqwest::Method, + uri: &str, + session: Option>, + auth: Option, + headers: HashMap, +) -> reqwest::RequestBuilder { + let mut r = client + .request(method, uri) + .header("accept", "application/json, text/event-stream"); + if let Some(session) = session { + r = r.header("mcp-session-id", session.as_ref()); + } + if let Some(auth) = auth { + r = r.bearer_auth(auth); + } + for (key, value) in headers { + r = r.header(key, value); + } + r +} + +impl StreamableHttpClient for BoundedClient { + type Error = io::Error; + + async fn post_message( + &self, + uri: Arc, + message: ClientJsonRpcMessage, + session: Option>, + auth: Option, + headers: HashMap, + ) -> Result { + let encoded = serde_json::to_vec(&message).map_err(|_| failed())?; + let response = request( + &self.0, + reqwest::Method::POST, + &uri, + session.clone(), + auth, + headers, + ) + .header("content-type", "application/json") + .body(encoded) + .send() + .await + .map_err(|_| failed())?; + let status = response.status(); + if status == reqwest::StatusCode::NOT_FOUND && session.is_some() { + return Err(StreamableHttpError::SessionExpired); + } + if matches!(status.as_u16(), 202 | 204) { + return Ok(StreamableHttpPostResponse::Accepted); + } + let session = response + .headers() + .get("mcp-session-id") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + let sse = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .is_some_and(|s| s.starts_with("text/event-stream")); + if response + .content_length() + .is_some_and(|size| size > MAX_BYTES) + { + return Err(failed()); + } + if status.is_success() && sse { + return Ok(StreamableHttpPostResponse::Sse( + SseStream::from_bytes_stream(stream(response)).boxed(), + session, + )); + } + let mut bytes = Vec::new(); + let mut input = stream(response); + while let Some(chunk) = input.next().await { + bytes.extend_from_slice(&chunk.map_err(|_| failed())?); + } + if bytes.is_empty() && status.is_success() { + return Ok(StreamableHttpPostResponse::Accepted); + } + // Keep valid JSON-RPC errors for SDK negotiation/fallback, but never + // return an arbitrary HTML/auth body in a user-visible diagnostic. + let parsed: ServerJsonRpcMessage = serde_json::from_slice(&bytes).map_err(|_| failed())?; + Ok(StreamableHttpPostResponse::Json(parsed, session)) + } + + async fn delete_session( + &self, + uri: Arc, + session: Arc, + auth: Option, + headers: HashMap, + ) -> Result<(), Error> { + request( + &self.0, + reqwest::Method::DELETE, + &uri, + Some(session), + auth, + headers, + ) + .send() + .await + .map_err(|_| failed())?; + Ok(()) + } + + async fn get_stream( + &self, + uri: Arc, + session: Option>, + last: Option, + auth: Option, + headers: HashMap, + ) -> Result>, Error> { + let mut request = request(&self.0, reqwest::Method::GET, &uri, session, auth, headers); + if let Some(last) = last { + request = request.header("last-event-id", last); + } + let response = request + .send() + .await + .map_err(|_| failed())? + .error_for_status() + .map_err(|_| failed())?; + Ok(SseStream::from_bytes_stream(stream(response)).boxed()) + } +} diff --git a/rust/lithe-agent-host/src/mcp_probe/tests.rs b/rust/lithe-agent-host/src/mcp_probe/tests.rs new file mode 100644 index 0000000000..5870359ab2 --- /dev/null +++ b/rust/lithe-agent-host/src/mcp_probe/tests.rs @@ -0,0 +1,236 @@ +//! Local transport fixtures never call a public server or an installed runtime. +use super::*; +use std::io::{BufRead, Write}; + +fn fixture_request() -> Request { + Request { + transport: "stdio".into(), + command: Some( + std::env::current_exe() + .unwrap() + .to_string_lossy() + .into_owned(), + ), + args: vec![ + "--exact".into(), + "mcp_probe::tests::fixture_server".into(), + "--nocapture".into(), + ], + env: vec![ + Entry { + key: "LITHE_MCP_PROBE_FIXTURE".into(), + value: "1".into(), + }, + Entry { + key: "PATH".into(), + value: std::env::var("PATH").unwrap_or_default(), + }, + ], + url: None, + headers: vec![], + } +} + +fn response(message: &Value) -> Option { + let id = message.get("id")?; + let result = match message["method"].as_str()? { + "server/discover" => { + return Some( + json!({"jsonrpc":"2.0","id":id,"error":{"code":-32601,"message":"Use initialize"}}), + ) + } + "initialize" => { + json!({"protocolVersion":"2025-11-25","capabilities":{"tools":{}},"serverInfo":{"name":"fixture","version":"1"}}) + } + "tools/list" if message["params"]["cursor"] == "next" => { + json!({"tools":[{"name":"second","description":"Fixture page two","inputSchema":{"type":"object"}}]}) + } + "tools/list" => { + json!({"tools":[{"name":"first","description":std::process::id().to_string(),"inputSchema":{"type":"object"}}],"nextCursor":"next"}) + } + _ => panic!("Probe attempted a method outside metadata listing"), + }; + Some(json!({"jsonrpc":"2.0","id":id,"result":result})) +} + +#[test] +fn fixture_server() { + if std::env::var("LITHE_MCP_PROBE_FIXTURE").as_deref() != Ok("1") { + return; + } + // Child-only blocking stdin is owned by the parent probe's 30-second + // deadline and mandatory process-tree kill, never the test executor. + for line in std::io::stdin().lock().lines() { + let Ok(line) = line else { + break; + }; + let Ok(value) = serde_json::from_str::(&line) else { + continue; + }; + if value["method"] == "tools/list" { + if let Ok(address) = std::env::var("LITHE_MCP_FIXTURE_SIGNAL") { + let mut signal = std::net::TcpStream::connect(address).unwrap(); + write!(signal, "{}", std::process::id()).unwrap(); + continue; + } + } + if let Some(value) = response(&value) { + println!("{value}"); + std::io::stdout().flush().unwrap(); + } + } +} + +#[test] +fn real_stdio_probe_lists_pages_and_reaps_its_child() { + let value = execute(fixture_request(), &|| false).unwrap(); + assert_eq!(value["tools"][0]["name"], "first"); + assert_eq!(value["tools"][1]["name"], "second"); + #[cfg(unix)] + { + let pid: i32 = value["tools"][0]["description"] + .as_str() + .unwrap() + .parse() + .unwrap(); + // SAFETY: signal zero only checks whether the already-reaped child exists. + assert_eq!(unsafe { libc::kill(pid, 0) }, -1); + } +} + +#[test] +fn cancellation_and_invalid_remote_inputs_do_not_launch_anything() { + assert_eq!( + execute(fixture_request(), &|| true).unwrap_err(), + Error::Cancelled + ); + for url in [ + "http://example.invalid/mcp", + "https://user:token@example.invalid/mcp", + "file:///tmp/mcp", + ] { + let mut r = fixture_request(); + r.transport = "http".into(); + r.url = Some(url.into()); + assert_eq!(execute(r, &|| false).unwrap_err(), Error::Invalid); + } +} + +#[tokio::test(start_paused = true)] +async fn silent_server_hits_virtual_deadline_and_cancellation_is_observed() { + let (client, _server) = tokio::io::duplex(4096); + let start = tokio::time::Instant::now(); + let result = inventory(client, &|| false).await; + assert_eq!(result.unwrap_err(), Error::Timeout); + assert!(start.elapsed() <= DEADLINE + Duration::from_secs(2)); + let (client, _server) = tokio::io::duplex(4096); + assert_eq!( + inventory(client, &|| true).await.unwrap_err(), + Error::Cancelled + ); +} + +#[tokio::test] +async fn cancellation_after_stdio_handshake_reaps_the_live_child() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let mut request = fixture_request(); + request.env.push(Entry { + key: "LITHE_MCP_FIXTURE_SIGNAL".into(), + value: listener.local_addr().unwrap().to_string(), + }); + let cancelled = std::cell::Cell::new(false); + let cancel = || cancelled.get(); + let signal = async { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut pid = String::new(); + stream.read_to_string(&mut pid).await.unwrap(); + cancelled.set(true); + pid + }; + let (result, pid) = tokio::time::timeout(Duration::from_secs(5), async { + tokio::join!(run(request, &cancel), signal) + }) + .await + .unwrap(); + assert_eq!(result.unwrap_err(), Error::Cancelled); + #[cfg(unix)] + { + let pid: i32 = pid.parse().unwrap(); + // SAFETY: signal zero only checks for the reaped fixture process. + assert_eq!(unsafe { libc::kill(pid, 0) }, -1); + } + #[cfg(not(unix))] + let _ = pid; +} + +#[tokio::test] +async fn real_loopback_http_probe_uses_the_same_inventory_protocol() { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + for mode in ["json", "sse", "oversized-header", "oversized-body"] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + loop { + let (stream, _) = listener.accept().await.unwrap(); + let mut reader = BufReader::new(stream); + let mut first = String::new(); + reader.read_line(&mut first).await.unwrap(); + let mut length = 0; + loop { + let mut line = String::new(); + reader.read_line(&mut line).await.unwrap(); + if line == "\r\n" { + break; + } + if let Some(value) = line.to_lowercase().strip_prefix("content-length:") { + length = value.trim().parse::().unwrap(); + } + } + assert!(length < 16384); + let mut bytes = vec![0; length]; + reader.read_exact(&mut bytes).await.unwrap(); + let body = if first.starts_with("POST") { + response(&serde_json::from_slice(&bytes).unwrap()) + } else { + None + }; + let mut text = body.map(|v| v.to_string()).unwrap_or_default(); + let content_type = if mode == "sse" { + "text/event-stream" + } else { + "application/json" + }; + if mode == "sse" && !text.is_empty() { + text = format!("event: message\ndata: {text}\n\n"); + } + let reply = if mode == "oversized-header" { + format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + MAX_BYTES + 1 + ) + } else if mode == "oversized-body" { + // No length header: enforce the cap while streaming, before JSON decode. + format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{}", "x".repeat(MAX_BYTES as usize + 1)) + } else { + format!("HTTP/1.1 {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", if text.is_empty() { "202 Accepted" } else { "200 OK" }, content_type, text.len(), text) + }; + // The client may reject the oversized response before all bytes are sent. + if reader.get_mut().write_all(reply.as_bytes()).await.is_err() { + break; + } + } + }); + let mut request = fixture_request(); + request.transport = "http".into(); + request.url = Some(format!("http://{address}/mcp")); + let result = tokio::time::timeout(Duration::from_secs(5), run(request, &|| false)).await; + server.abort(); + let _ = server.await; + let result = result.unwrap(); + if mode.starts_with("oversized") { + assert_eq!(result.unwrap_err(), Error::Connection); + } else { + assert_eq!(result.unwrap()["tools"].as_array().unwrap().len(), 2); + } + } +} diff --git a/rust/lithe-agent-host/src/skills.rs b/rust/lithe-agent-host/src/skills.rs new file mode 100644 index 0000000000..ddc17a6925 --- /dev/null +++ b/rust/lithe-agent-host/src/skills.rs @@ -0,0 +1,288 @@ +//! Native Agent skill discovery and explicit user-owned skill installation. +//! +//! Codex owns discovery through skills/list. Claude's directory inventory is +//! deliberately not advertised as a complete or enabled session capability list. +//! Explicit toggles use native settings; discovery never changes configuration. + +mod files; +mod settings; +#[cfg(test)] +mod tests; + +use crate::{codex_history::CodexHistory, environment}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; + +/// Explicit settings action. Reading a list never installs or enables a skill. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Request { + pub action: Action, + pub agent_id: String, + /// Current opened project, or None for user-only settings. + pub workspace: Option, + pub scope: Option, + pub name: Option, + pub description: Option, + pub instructions: Option, + /// User-selected SKILL.md or a directory containing it; never an archive or URL. + pub source: Option, + /// Desired native visibility; required only for setEnabled. + pub enabled: Option, + /// Selected Codex account directory (`CODEX_HOME`); None uses the default account. + pub codex_home: Option, + /// Selected Claude account directory (`CLAUDE_CONFIG_DIR`); None uses the environment or default. + pub claude_config_dir: Option, +} + +/// Mutations require an explicit settings action, never a discovery request. +#[derive(Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum Action { + List, + Directories, + Read, + Create, + Import, + SetEnabled, +} + +/// A native path is transient platform metadata, not a persistent shared identifier. +#[derive(Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct Skill { + name: String, + description: String, + path: PathBuf, + scope: String, + /// Native Codex state, or Claude's configured visibility in this scope. + /// None means configuration could not be read, never an inferred default. + enabled: Option, + plugin_id: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct Listing { + skills: Vec, + /// "agent" means native discovery; "directory" means an explicitly partial inventory. + source: &'static str, + issues: Vec, + directories: std::collections::BTreeMap, +} + +/// Failures retain a stable category without returning raw subprocess output. +#[derive(Debug)] +pub enum Error { + Invalid(String), + Failed(String), + MissingCLI, + Cancelled, +} + +impl Error { + pub fn message(&self) -> &str { + match self { + Self::Invalid(message) | Self::Failed(message) => message, + Self::MissingCLI => "Install Codex CLI to discover its skills.", + Self::Cancelled => "Skill operation was cancelled.", + } + } +} + +/// Run on a platform worker. The caller owns cancellation and the current workspace. +pub fn execute(request: Request, cancelled: &dyn Fn() -> bool) -> Result { + let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }) + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .ok_or_else(|| Error::Failed("The user home directory is unavailable.".into()))?; + let claude = request + .claude_config_dir + .clone() + .or_else(|| std::env::var_os("CLAUDE_CONFIG_DIR").map(PathBuf::from)) + .unwrap_or_else(|| home.join(".claude")); + execute_at(request, &home, &claude, cancelled) +} + +fn execute_at( + request: Request, + home: &Path, + claude: &Path, + cancelled: &dyn Fn() -> bool, +) -> Result { + check(cancelled)?; + if !matches!(request.agent_id.as_str(), "codex-acp" | "claude-acp") { + return Err(Error::Invalid( + "Skill management is available for Codex and Claude.".into(), + )); + } + if request + .workspace + .as_ref() + .is_some_and(|p| !p.is_absolute() || !p.is_dir()) + || !claude.is_absolute() + || request.codex_home.as_ref().is_some_and(|p| !p.is_absolute()) + { + return Err(Error::Invalid( + "The skill workspace and configuration directory must be absolute directories.".into(), + )); + } + if request.action == Action::Directories { + return Ok(json!({"directories": files::directories(&request, home, claude)})); + } + if request.action == Action::Read { + let path = request + .source + .as_deref() + .filter(|p| p.is_absolute()) + .ok_or_else(|| Error::Invalid("Choose a skill document.".into()))?; + return files::read_document(path).map(|content| json!({"content": content})); + } + if request.action == Action::SetEnabled { + return settings::set_enabled(&request, home, claude, cancelled); + } + if request.action != Action::List { + return files::install(&request, home, claude, cancelled).map(|path| json!({"path": path})); + } + let mut listing = if request.agent_id == "codex-acp" { + let cli = environment::detect_tool("codex", cancelled).ok_or(Error::MissingCLI)?; + check(cancelled)?; + let cwd = request.workspace.as_deref().unwrap_or(home); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|_| Error::Failed("Skill discovery could not start.".into()))?; + let result = runtime.block_on(CodexHistory::skills(cli.path, cwd.to_path_buf(), request.codex_home.clone(), cancelled)); + check(cancelled)?; + let value = result.map_err(|_| { + Error::Failed("Codex skill discovery failed. Check the CLI and retry.".into()) + })?; + parse_codex(&value, cwd)? + } else { + files::claude_inventory(request.workspace.as_deref(), claude, cancelled)? + }; + if request.agent_id == "claude-acp" { + settings::annotate(&mut listing, request.workspace.as_deref(), claude); + } + check(cancelled)?; + listing_value(listing, &request, home, claude) +} + +fn listing_value( + mut listing: Listing, + request: &Request, + home: &Path, + claude: &Path, +) -> Result { + listing.directories = files::directories(request, home, claude); + listing.skills.sort_by(|a, b| { + a.name + .to_lowercase() + .cmp(&b.name.to_lowercase()) + .then(a.path.cmp(&b.path)) + }); + serde_json::to_value(listing) + .map_err(|_| Error::Failed("Skill metadata could not be returned.".into())) +} + +fn check(cancelled: &dyn Fn() -> bool) -> Result<(), Error> { + if cancelled() { + Err(Error::Cancelled) + } else { + Ok(()) + } +} + +fn parse_codex(value: &Value, cwd: &Path) -> Result { + let entries = value["data"] + .as_array() + .ok_or_else(|| Error::Failed("Codex returned invalid skill metadata.".into()))?; + let entry = entries + .iter() + .find(|entry| { + entry["cwd"].as_str().is_some_and(|path| { + Path::new(path) == cwd + || std::fs::canonicalize(path) + .ok() + .zip(std::fs::canonicalize(cwd).ok()) + .is_some_and(|(a, b)| a == b) + }) + }) + .ok_or_else(|| { + Error::Failed("Codex did not report skills for the selected project.".into()) + })?; + let rows = entry["skills"] + .as_array() + .ok_or_else(|| Error::Failed("Codex returned invalid skill metadata.".into()))?; + if rows.len() > files::MAX_ENTRIES { + return Err(Error::Failed("Too many skills to display.".into())); + } + let mut listing = Listing { + skills: vec![], + source: "agent", + issues: vec![], + directories: Default::default(), + }; + for row in rows { + let path = row["path"].as_str().map(PathBuf::from); + match (row["name"].as_str(), row["description"].as_str(), path) { + (Some(name), Some(description), Some(path)) + if path.is_absolute() && !name.is_empty() => + { + if !matches!( + row["scope"].as_str(), + Some("user" | "repo" | "system" | "admin") + ) { + listing + .issues + .push("A skill has an unknown scope and cannot be classified.".into()); + continue; + } + if listing.skills.iter().any(|skill| skill.path == path) { + continue; + } + listing.skills.push(Skill { + name: name.into(), + description: description.into(), + path, + scope: row["scope"].as_str().unwrap_or("unknown").into(), + enabled: row["enabled"].as_bool(), + plugin_id: row["pluginId"].as_str().map(str::to_owned), + }); + } + _ => listing + .issues + .push("A skill was omitted because its native metadata is incomplete.".into()), + } + } + if entry["errors"] + .as_array() + .is_some_and(|errors| !errors.is_empty()) + { + listing.issues.push( + "Codex reported skill discovery errors. Check the affected SKILL.md files and refresh." + .into(), + ); + } + Ok(listing) +} + +/// Restrict config writes to a path that this workspace's native discovery reports. +pub(crate) fn codex_skill_path(value: &Value, cwd: &Path, path: &Path) -> Option { + parse_codex(value, cwd).ok().and_then(|listing| { + listing + .skills + .into_iter() + .find(|skill| same_path(&skill.path, path) && skill.enabled.is_some()) + .map(|skill| skill.path) + }) +} + +fn same_path(left: &Path, right: &Path) -> bool { + left == right + || std::fs::canonicalize(left) + .ok() + .zip(std::fs::canonicalize(right).ok()) + .is_some_and(|(left, right)| left == right) +} diff --git a/rust/lithe-agent-host/src/skills/files.rs b/rust/lithe-agent-host/src/skills/files.rs new file mode 100644 index 0000000000..5c2d15875e --- /dev/null +++ b/rust/lithe-agent-host/src/skills/files.rs @@ -0,0 +1,523 @@ +//! Bounded directory inventory and non-overwriting publication in native user/project roots. + +use super::*; +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Write}; + +pub(super) const MAX_ENTRIES: usize = 2_000; +const MAX_FILE_BYTES: u64 = 8 * 1024 * 1024; +const MAX_DOCUMENT_BYTES: u64 = 128 * 1024; +const MAX_IMPORT_BYTES: u64 = 32 * 1024 * 1024; +const MAX_DEPTH: usize = 20; + +fn failure(message: &str) -> Error { + Error::Failed(message.into()) +} + +fn read(path: &Path) -> Result, Error> { + read_limited(path, MAX_FILE_BYTES) +} + +fn read_limited(path: &Path, limit: u64) -> Result, Error> { + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK); + } + let file = options + .open(path) + .map_err(|_| failure("The skill file could not be opened."))?; + if !file + .metadata() + .map_err(|_| failure("The skill file could not be inspected."))? + .is_file() + { + return Err(failure("Skills must contain regular files.")); + } + let mut bytes = Vec::new(); + file.take(limit + 1) + .read_to_end(&mut bytes) + .map_err(|_| failure("The skill file could not be read."))?; + if bytes.len() as u64 > limit { + return Err(failure(if limit == MAX_DOCUMENT_BYTES { + "SKILL.md exceeds the 128 KiB limit." + } else { + "A skill file exceeds the 8 MiB limit." + })); + } + Ok(bytes) +} + +pub(super) fn metadata(bytes: &[u8], fallback: &str) -> Result<(String, String), Error> { + let text = std::str::from_utf8(bytes).map_err(|_| failure("SKILL.md must use UTF-8 text."))?; + let mut lines = text.trim_start_matches('\u{feff}').lines(); + if lines.next().map(str::trim) != Some("---") { + return Err(failure("SKILL.md needs YAML frontmatter.")); + } + let mut yaml = String::new(); + let mut closed = false; + for line in lines.by_ref() { + if line.trim() == "---" { + closed = true; + break; + } + yaml.push_str(line); + yaml.push('\n'); + } + if !closed { + return Err(failure("SKILL.md frontmatter is not closed.")); + } + if !lines.any(|line| !line.trim().is_empty()) { + return Err(failure( + "SKILL.md needs instructions after its frontmatter.", + )); + } + let value: serde_yaml_ng::Value = serde_yaml_ng::from_str(&yaml) + .map_err(|_| failure("SKILL.md has invalid YAML frontmatter."))?; + let name = value["name"].as_str().unwrap_or(fallback).trim(); + let description = value["description"].as_str().unwrap_or("").trim(); + if name.is_empty() || description.is_empty() { + return Err(failure("SKILL.md needs a name and description.")); + } + Ok((name.into(), description.into())) +} + +/// Inventory only these two explicit locations. Do not emulate Claude's policy, +/// plugin resolution, ancestor discovery or lazy nested skill loading. +pub(super) fn claude_inventory( + workspace: Option<&Path>, + claude: &Path, + cancelled: &dyn Fn() -> bool, +) -> Result { + let mut result = Listing { + source: "directory", + skills: vec![], + issues: vec![], + directories: Default::default(), + }; + let mut roots = vec![(claude.join("skills"), "user")]; + if let Some(workspace) = workspace { + roots.push((workspace.join(".claude/skills"), "repo")); + } + let mut inspected = 0usize; + 'roots: for (root, scope) in roots { + check(cancelled)?; + let entries = match fs::read_dir(&root) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(_) => { + result + .issues + .push(format!("Cannot read {}", root.display())); + continue; + } + }; + for entry in entries { + check(cancelled)?; + inspected += 1; + if inspected > MAX_ENTRIES { + result + .issues + .push("Only the first 2000 directory entries were inspected.".into()); + break 'roots; + } + let entry = match entry { + Ok(entry) => entry, + Err(_) => { + result + .issues + .push("A skill directory could not be read.".into()); + continue; + } + }; + if entry.file_name().to_string_lossy().starts_with('.') { + continue; + } + let path = entry.path().join("SKILL.md"); + if !path.exists() { + continue; + } + // A native skill directory may be a link. Resolve only for reading; + // importing links or writing through linked destinations is forbidden. + let parsed = fs::canonicalize(&path) + .map_err(|_| failure("A skill path could not be resolved.")) + .and_then(|canonical| read_limited(&canonical, MAX_DOCUMENT_BYTES)) + .and_then(|bytes| metadata(&bytes, &entry.file_name().to_string_lossy())); + match parsed { + Ok((name, description)) => result.skills.push(Skill { + name, + description, + path, + scope: scope.into(), + enabled: None, + plugin_id: None, + }), + Err(error) => { + result + .issues + .push(format!("{}: {}", path.display(), error.message())) + } + } + } + } + Ok(result) +} + +fn valid_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= 64 + && name.split('-').all(|part| { + !part.is_empty() + && part + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) + }) +} + +/// Create native containers without following a linked intermediate directory. +fn destination(request: &Request, home: &Path, claude: &Path) -> Result { + let project = request.scope.as_deref() == Some("repo"); + if !matches!(request.scope.as_deref(), Some("user" | "repo")) { + return Err(Error::Invalid( + "Choose Global or Project for the skill.".into(), + )); + } + let (base, components) = if project { + ( + request.workspace.as_deref().ok_or_else(|| { + Error::Invalid("Open a project before creating project skills.".into()) + })?, + vec![ + if request.agent_id == "codex-acp" { + ".agents" + } else { + ".claude" + }, + "skills", + ], + ) + } else if request.agent_id == "codex-acp" { + (home, vec![".agents", "skills"]) + } else { + // CLAUDE_CONFIG_DIR is the user's native configuration override. + fs::create_dir_all(claude) + .map_err(|_| failure("The Claude configuration directory could not be created."))?; + (claude, vec!["skills"]) + }; + let mut path = + fs::canonicalize(base).map_err(|_| failure("The skill destination is unavailable."))?; + for component in components { + path.push(component); + match fs::create_dir(&path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(_) => return Err(failure("The skill destination could not be created.")), + } + let metadata = fs::symlink_metadata(&path) + .map_err(|_| failure("The skill destination could not be inspected."))?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err(failure("The skill destination contains a symbolic link or non-directory. Choose a regular native skill directory.")); + } + } + Ok(path) +} + +struct Staging(PathBuf); +impl Drop for Staging { + fn drop(&mut self) { + if let Err(error) = fs::remove_dir_all(&self.0) { + if error.kind() != std::io::ErrorKind::NotFound { + eprintln!("Skill staging cleanup failed: {error}"); + } + } + } +} + +pub(super) fn install( + request: &Request, + home: &Path, + claude: &Path, + cancelled: &dyn Fn() -> bool, +) -> Result { + let (name, content, source) = match request.action { + Action::Create => { + let name = request.name.as_deref().unwrap_or(""); + let description = request.description.as_deref().unwrap_or("").trim(); + let instructions = request.instructions.as_deref().unwrap_or("").trim(); + if description.is_empty() || instructions.is_empty() { + return Err(Error::Invalid( + "Enter a description and instructions.".into(), + )); + } + // JSON strings are valid YAML scalars, preserving quotes and newlines. + let text = format!( + "---\nname: {}\ndescription: {}\n---\n\n{}\n", + json!(name), + json!(description), + instructions + ); + if text.len() as u64 > MAX_DOCUMENT_BYTES { + return Err(Error::Invalid("The skill is too large.".into())); + } + (name.to_owned(), Some(text.into_bytes()), None) + } + Action::Import => { + let source = request + .source + .as_deref() + .filter(|p| p.is_absolute()) + .ok_or_else(|| { + Error::Invalid("Choose a SKILL.md file or skill directory.".into()) + })?; + let info = fs::symlink_metadata(source) + .map_err(|_| failure("The import source is unavailable."))?; + if info.file_type().is_symlink() { + return Err(failure( + "Import a regular file or directory instead of a symbolic link.", + )); + } + let file = if info.is_dir() { + source.join("SKILL.md") + } else { + source.to_path_buf() + }; + if file.file_name().and_then(|v| v.to_str()) != Some("SKILL.md") { + return Err(Error::Invalid( + "Select SKILL.md or its containing directory.".into(), + )); + } + let bytes = read_limited(&file, MAX_DOCUMENT_BYTES)?; + let fallback = if info.is_dir() { + source.file_name().and_then(|v| v.to_str()).unwrap_or("") + } else { + "" + }; + let (name, _) = metadata(&bytes, fallback)?; + if request.agent_id == "codex-acp" { + // Codex requires an explicit frontmatter name, unlike Claude's directory fallback. + let (explicit, _) = metadata(&bytes, "")?; + if explicit.is_empty() { + return Err(failure("Codex requires a skill name.")); + } + } + ( + name, + Some(bytes), + info.is_dir().then(|| source.to_path_buf()), + ) + } + Action::List | Action::Read | Action::Directories | Action::SetEnabled => unreachable!(), + }; + if !valid_name(&name) { + return Err(Error::Invalid( + "Use up to 64 lowercase letters, digits and single hyphens for the skill name.".into(), + )); + } + if request.agent_id == "claude-acp" && matches!(name.as_str(), "synced" | "anthropic-skills") { + return Err(Error::Invalid( + "That skill name is reserved by Claude. Choose a different name.".into(), + )); + } + check(cancelled)?; + let root = destination(request, home, claude)?; + if source + .as_ref() + .and_then(|source| fs::canonicalize(source).ok()) + .is_some_and(|source| root.starts_with(source)) + { + return Err(Error::Invalid( + "The import source cannot contain the destination directory.".into(), + )); + } + let target = root.join(&name); + if target.symlink_metadata().is_ok() { + return Err(failure("A skill with this directory name already exists. Existing skills are never overwritten.")); + } + let staging = Staging(root.join(format!(".lithe-skill-{}", uuid::Uuid::new_v4()))); + fs::create_dir(&staging.0) + .map_err(|_| failure("The skill staging directory could not be created."))?; + if let Some(source) = source { + let mut budget = Budget { + entries: 0, + bytes: 0, + }; + copy_directory(&source, &staging.0, 0, &mut budget, cancelled)?; + // Validate the actual staged bytes, including concurrent source changes. + let (staged_name, _) = metadata( + &read_limited(&staging.0.join("SKILL.md"), MAX_DOCUMENT_BYTES)?, + &name, + )?; + if staged_name != name { + return Err(failure( + "SKILL.md changed while it was being imported. Retry the import.", + )); + } + } else if let Some(content) = content { + let mut file = File::create(staging.0.join("SKILL.md")) + .map_err(|_| failure("SKILL.md could not be created."))?; + file.write_all(&content) + .map_err(|_| failure("SKILL.md could not be saved."))?; + } + check(cancelled)?; + publish(&staging.0, &target)?; + Ok(target.join("SKILL.md")) +} + +struct Budget { + entries: usize, + bytes: u64, +} +fn copy_directory( + source: &Path, + destination: &Path, + depth: usize, + budget: &mut Budget, + cancelled: &dyn Fn() -> bool, +) -> Result<(), Error> { + if depth > MAX_DEPTH { + return Err(failure("The skill directory is too deeply nested.")); + } + let entries = + fs::read_dir(source).map_err(|_| failure("The skill directory could not be read."))?; + for entry in entries { + check(cancelled)?; + let entry = entry.map_err(|_| failure("A skill entry could not be read."))?; + budget.entries += 1; + if budget.entries > MAX_ENTRIES { + return Err(failure("The skill contains too many files.")); + } + let kind = entry + .file_type() + .map_err(|_| failure("A skill entry could not be inspected."))?; + let path = destination.join(entry.file_name()); + if kind.is_dir() { + fs::create_dir(&path).map_err(|_| failure("A skill directory could not be copied."))?; + copy_directory(&entry.path(), &path, depth + 1, budget, cancelled)?; + } else if kind.is_file() { + let bytes = read(&entry.path())?; + budget.bytes += bytes.len() as u64; + if budget.bytes > MAX_IMPORT_BYTES { + return Err(failure("The skill exceeds the 32 MiB import limit.")); + } + fs::write(&path, bytes).map_err(|_| failure("A skill file could not be copied."))?; + let permissions = entry + .metadata() + .map_err(|_| failure("Skill permissions could not be read."))? + .permissions(); + fs::set_permissions(&path, permissions) + .map_err(|_| failure("Skill permissions could not be preserved."))?; + } else { + return Err(failure( + "Skill imports cannot contain symbolic links or special files.", + )); + } + } + Ok(()) +} + +/// Atomic no-replace publication closes the existence-check race with another window. +pub(super) fn publish(source: &Path, target: &Path) -> Result<(), Error> { + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + let from = CString::new(source.as_os_str().as_bytes()) + .map_err(|_| failure("Invalid skill path."))?; + let to = CString::new(target.as_os_str().as_bytes()) + .map_err(|_| failure("Invalid skill path."))?; + #[cfg(target_os = "macos")] + // SAFETY: both C strings remain alive for the call; flags refuse replacement. + let result = unsafe { + libc::renameatx_np( + libc::AT_FDCWD, + from.as_ptr(), + libc::AT_FDCWD, + to.as_ptr(), + libc::RENAME_EXCL, + ) + }; + #[cfg(target_os = "linux")] + // SAFETY: both C strings remain alive for the call; flags refuse replacement. + let result = unsafe { + libc::renameat2( + libc::AT_FDCWD, + from.as_ptr(), + libc::AT_FDCWD, + to.as_ptr(), + libc::RENAME_NOREPLACE, + ) + }; + if result != 0 { + return Err(failure( + "The skill could not be published. Check permissions and existing skill names.", + )); + } + Ok(()) + } + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{MoveFileExW, MOVEFILE_WRITE_THROUGH}; + let from: Vec = source.as_os_str().encode_wide().chain(Some(0)).collect(); + let to: Vec = target.as_os_str().encode_wide().chain(Some(0)).collect(); + if from[..from.len() - 1].contains(&0) || to[..to.len() - 1].contains(&0) { + return Err(failure("Invalid skill path.")); + } + // std::fs::rename can replace empty directories via FileRenameInfoEx. + // Omit REPLACE_EXISTING and COPY_ALLOWED: publication must stay an + // exclusive same-volume move, including when another writer races us. + // SAFETY: both terminated UTF-16 buffers stay alive throughout the call. + if unsafe { MoveFileExW(from.as_ptr(), to.as_ptr(), MOVEFILE_WRITE_THROUGH) } == 0 { + return Err(failure( + "The skill could not be published. Check permissions and existing skill names.", + )); + } + Ok(()) + } + #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] + Err(failure( + "Atomic skill publication is unavailable on this platform.", + )) +} + +/// Native destinations are returned as display metadata; discovery creates no directories. +pub(super) fn directories( + request: &Request, + home: &Path, + claude: &Path, +) -> std::collections::BTreeMap { + let mut directories = std::collections::BTreeMap::new(); + directories.insert( + "user".into(), + if request.agent_id == "codex-acp" { + home.join(".agents/skills") + } else { + claude.join("skills") + }, + ); + if let Some(workspace) = &request.workspace { + directories.insert( + "repo".into(), + workspace.join(if request.agent_id == "codex-acp" { + ".agents/skills" + } else { + ".claude/skills" + }), + ); + } + directories +} + +pub(super) fn read_document(path: &Path) -> Result { + if path.file_name().and_then(|name| name.to_str()) != Some("SKILL.md") { + return Err(Error::Invalid( + "Select SKILL.md or its containing directory.".into(), + )); + } + let canonical = fs::canonicalize(path) + .map_err(|_| failure("The skill document is unavailable. Refresh and retry."))?; + String::from_utf8(read_limited(&canonical, MAX_DOCUMENT_BYTES)?) + .map_err(|_| failure("SKILL.md must use UTF-8 text.")) +} diff --git a/rust/lithe-agent-host/src/skills/settings.rs b/rust/lithe-agent-host/src/skills/settings.rs new file mode 100644 index 0000000000..d466cb5701 --- /dev/null +++ b/rust/lithe-agent-host/src/skills/settings.rs @@ -0,0 +1,299 @@ +//! Native visibility settings. Never move skill packages or keep a Lithe enabled flag. + +use super::*; +use fs2::FileExt; +use std::fs::{self, OpenOptions}; +use std::io::{Read, Write}; + +const MAX_SETTINGS_BYTES: u64 = 1024 * 1024; + +fn failed() -> Error { + Error::Failed( + "Skill settings could not be updated. Check the settings file and refresh.".into(), + ) +} + +/// These are configured states, not a reconstruction of Claude's effective +/// managed/ancestor/session policy. Project-local overrides take precedence. +pub(super) fn annotate(listing: &mut Listing, workspace: Option<&Path>, claude: &Path) { + let user = load(&claude.join("settings.json")); + let project = workspace + .map(|root| { + Ok::<_, Error>(( + load(&root.join(".claude/settings.json"))?, + load(&root.join(".claude/settings.local.json"))?, + )) + }) + .transpose(); + for skill in &mut listing.skills { + skill.enabled = user.as_ref().ok().and_then(|user| { + let mut state = visibility(user, &skill.name)?; + if skill.scope == "repo" { + if let Some((shared, local)) = project.as_ref().ok()?.as_ref() { + state = override_visibility(shared, &skill.name)?.unwrap_or(state); + state = override_visibility(local, &skill.name)?.unwrap_or(state); + } + } + Some(state) + }); + } + if user.is_err() || project.is_err() { + listing.issues.push("Claude skill settings could not be read. Fix invalid settings and refresh before toggling.".into()); + } +} + +fn override_visibility(value: &Value, name: &str) -> Option> { + match value + .get("skillOverrides") + .and_then(|values| values.get(name)) + .and_then(Value::as_str) + { + None => Some(None), + Some("off") => Some(Some(false)), + Some("on" | "name-only" | "user-invocable-only") => Some(Some(true)), + Some(_) => None, + } +} + +fn visibility(value: &Value, name: &str) -> Option { + Some(override_visibility(value, name)?.unwrap_or(true)) +} + +pub(super) fn set_enabled( + request: &Request, + home: &Path, + claude: &Path, + cancelled: &dyn Fn() -> bool, +) -> Result { + let enabled = request + .enabled + .ok_or_else(|| Error::Invalid("Choose whether to enable the skill.".into()))?; + let path = request + .source + .as_deref() + .filter(|path| path.is_absolute()) + .ok_or_else(|| Error::Invalid("Choose a skill document.".into()))?; + if request.agent_id == "codex-acp" { + let cli = environment::detect_tool("codex", cancelled).ok_or(Error::MissingCLI)?; + check(cancelled)?; + let cwd = request.workspace.as_deref().unwrap_or(home); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|_| failed())?; + let value = runtime.block_on(CodexHistory::set_skill_enabled( + cli.path, + cwd.to_owned(), + path.to_owned(), + enabled, + request.codex_home.clone(), + cancelled, + )); + check(cancelled)?; + let listing = parse_codex(&value.map_err(|_| Error::Failed("Codex skill settings could not be confirmed. Refresh to check the current state.".into()))?, cwd)?; + if !listing + .skills + .iter() + .any(|skill| same_path(&skill.path, path) && skill.enabled == Some(enabled)) + { + return Err(Error::Failed("The Agent did not apply the skill setting. Refresh and check project or managed configuration.".into())); + } + return listing_value(listing, request, home, claude); + } + let mut listing = files::claude_inventory(request.workspace.as_deref(), claude, cancelled)?; + let discovery_issue_count = listing.issues.len(); + annotate(&mut listing, request.workspace.as_deref(), claude); + let skill = listing + .skills + .iter() + .find(|skill| { + same_path(&skill.path, path) && Some(skill.scope.as_str()) == request.scope.as_deref() + }) + .ok_or_else(|| { + Error::Invalid( + "The skill is no longer available in this scope. Refresh and retry.".into(), + ) + })?; + if skill.enabled.is_none() { + return Err(failed()); + } + // SkillOverrides keys are names. Duplicate names in the same scope share one setting. + if skill.name.contains(':') { + return Err(Error::Invalid( + "Claude plugin skills must be managed through Claude's plugin settings.".into(), + )); + } + let target = if skill.scope == "repo" { + request + .workspace + .as_ref() + .ok_or_else(failed)? + .join(".claude/settings.local.json") + } else { + claude.join("settings.json") + }; + write_override(&target, &skill.name, enabled, cancelled)?; + listing.issues.truncate(discovery_issue_count); + annotate(&mut listing, request.workspace.as_deref(), claude); + if !listing + .skills + .iter() + .any(|skill| same_path(&skill.path, path) && skill.enabled == Some(enabled)) + { + return Err(Error::Failed("The Agent did not apply the skill setting. Refresh and check project or managed configuration.".into())); + } + listing_value(listing, request, home, claude) +} + +fn options() -> OpenOptions { + let mut options = OpenOptions::new(); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) + .mode(0o600); + } + options +} + +fn snapshot(path: &Path) -> Result>, Error> { + let file = match options().read(true).open(path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(_) => return Err(failed()), + }; + if !file.metadata().map_err(|_| failed())?.is_file() + || fs::symlink_metadata(path) + .map_err(|_| failed())? + .file_type() + .is_symlink() + { + return Err(failed()); + } + let mut bytes = vec![]; + file.take(MAX_SETTINGS_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|_| failed())?; + if bytes.len() as u64 > MAX_SETTINGS_BYTES { + return Err(failed()); + } + Ok(Some(bytes)) +} + +fn parse(bytes: Option<&[u8]>) -> Result { + let value: Value = match bytes { + Some(bytes) => serde_json::from_slice(bytes).map_err(|_| failed())?, + None => json!({}), + }; + if !value.is_object() { + return Err(failed()); + } + if let Some(overrides) = value.get("skillOverrides") { + if !overrides.is_object() + || !overrides.as_object().unwrap().values().all(|value| { + matches!( + value.as_str(), + Some("on" | "off" | "name-only" | "user-invocable-only") + ) + }) + { + return Err(failed()); + } + } + Ok(value) +} + +fn load(path: &Path) -> Result { + parse(snapshot(path)?.as_deref()) +} + +struct Temporary(PathBuf); +impl Drop for Temporary { + fn drop(&mut self) { + if let Err(error) = fs::remove_file(&self.0) { + if error.kind() != std::io::ErrorKind::NotFound { + eprintln!("Skill settings staging cleanup failed: {error}"); + } + } + } +} + +pub(super) fn write_override( + path: &Path, + name: &str, + enabled: bool, + cancelled: &dyn Fn() -> bool, +) -> Result<(), Error> { + let parent = path.parent().ok_or_else(failed)?; + // Reject linked parents before writing, including a linked .claude directory. + let mut ancestors: Vec<_> = parent.ancestors().collect(); + ancestors.reverse(); + for ancestor in ancestors { + if !ancestor.exists() { + fs::create_dir(ancestor).map_err(|_| failed())?; + } + let metadata = fs::symlink_metadata(ancestor).map_err(|_| failed())?; + // macOS /var is a system link; resolve existing ancestors, but the actual + // settings directory itself must remain a regular native directory. + if !ancestor.is_dir() || (ancestor == parent && metadata.file_type().is_symlink()) { + return Err(failed()); + } + } + let lock_path = parent.join(".lithe-skill-settings.lock"); + let lock = options() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&lock_path) + .map_err(|_| failed())?; + if !lock.metadata().map_err(|_| failed())?.is_file() + || fs::symlink_metadata(&lock_path) + .map_err(|_| failed())? + .file_type() + .is_symlink() + { + return Err(failed()); + } + // Nonblocking lock serializes Lithe windows; native/editor changes are + // checked against the exact input snapshot before atomic publication. + lock.try_lock_exclusive().map_err(|_| { + Error::Failed("Skill settings are busy. Retry after the other change finishes.".into()) + })?; + let before = snapshot(path)?; + let mut value = parse(before.as_deref())?; + value + .as_object_mut() + .ok_or_else(failed)? + .entry("skillOverrides") + .or_insert_with(|| json!({}))[name] = json!(if enabled { "on" } else { "off" }); + let mut bytes = serde_json::to_vec_pretty(&value).map_err(|_| failed())?; + bytes.push(b'\n'); + if bytes.len() as u64 > MAX_SETTINGS_BYTES { + return Err(failed()); + } + check(cancelled)?; + let staging = Temporary(parent.join(format!(".lithe-skill-settings-{}", uuid::Uuid::new_v4()))); + let mut file = options() + .write(true) + .create_new(true) + .open(&staging.0) + .map_err(|_| failed())?; + if let Ok(metadata) = fs::metadata(path) { + file.set_permissions(metadata.permissions()) + .map_err(|_| failed())?; + } + file.write_all(&bytes) + .and_then(|_| file.sync_all()) + .map_err(|_| failed())?; + drop(file); + check(cancelled)?; + if snapshot(path)? != before { + return Err(Error::Failed( + "Skill settings changed elsewhere. Refresh and retry.".into(), + )); + } + fs::rename(&staging.0, path).map_err(|_| failed())?; + Ok(()) +} diff --git a/rust/lithe-agent-host/src/skills/tests.rs b/rust/lithe-agent-host/src/skills/tests.rs new file mode 100644 index 0000000000..3c89d2a7ea --- /dev/null +++ b/rust/lithe-agent-host/src/skills/tests.rs @@ -0,0 +1,655 @@ +use super::*; +use std::fs; +use std::time::Duration; + +struct Sandbox(PathBuf); +impl Sandbox { + fn new() -> Self { + let root = std::env::temp_dir().join(format!("lithe-skills-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(root.join("home")).unwrap(); + fs::create_dir_all(root.join("project")).unwrap(); + Self(fs::canonicalize(root).unwrap()) + } + fn run(&self, payload: Value) -> Result { + let mut payload = payload; + payload["workspace"] = json!(self.0.join("project")); + execute_at( + serde_json::from_value(payload).unwrap(), + &self.0.join("home"), + &self.0.join("home/.claude"), + &|| false, + ) + } +} +impl Drop for Sandbox { + fn drop(&mut self) { + fs::remove_dir_all(&self.0).unwrap(); + } +} +fn create(agent: &str, scope: &str, name: &str) -> Value { + json!({"action":"create", "agentId":agent, "scope":scope, "name":name, + "description":"Use for Unicode 中文, quotes \" and newlines\nonly", "instructions":"# Workflow\n\n1. Inspect\n2. Explain"}) +} + +#[test] +fn claude_toggles_preserve_documents_other_settings_and_scope() { + let fixture = Sandbox::new(); + let user = fixture + .run(create("claude-acp", "user", "same-name")) + .unwrap(); + let repo = fixture + .run(create("claude-acp", "repo", "same-name")) + .unwrap(); + let user_settings = fixture.0.join("home/.claude/settings.json"); + let local_settings = fixture.0.join("project/.claude/settings.local.json"); + fs::write( + &user_settings, + r#"{"env":{"EXAMPLE":"keep"},"skillOverrides":{"unrelated":"name-only"}}"#, + ) + .unwrap(); + let document = fs::read(user["path"].as_str().unwrap()).unwrap(); + let toggle = |scope: &str, source: &Value, enabled: bool| { + fixture.run(json!({"action":"setEnabled", "agentId":"claude-acp", "scope":scope,"source":source,"enabled":enabled})) + }; + let result = toggle("user", &user["path"], false).unwrap(); + assert!(result["skills"] + .as_array() + .unwrap() + .iter() + .all(|skill| skill["enabled"] == false)); + assert!( + !local_settings.exists(), + "a global toggle must not write project settings" + ); + let result = toggle("repo", &repo["path"], true).unwrap(); + assert!(result["skills"] + .as_array() + .unwrap() + .iter() + .all(|skill| skill["enabled"] == (skill["scope"] == "repo"))); + assert!(!fixture.0.join("project/.claude/settings.json").exists()); + toggle("user", &user["path"], true).unwrap(); + let config: Value = serde_json::from_slice(&fs::read(user_settings).unwrap()).unwrap(); + assert_eq!(config["env"]["EXAMPLE"], "keep"); + assert_eq!(config["skillOverrides"]["unrelated"], "name-only"); + assert_eq!(config["skillOverrides"]["same-name"], "on"); + assert_eq!(fs::read(user["path"].as_str().unwrap()).unwrap(), document); +} + +#[test] +fn claude_invalid_settings_are_never_replaced_and_hide_unknown_state() { + let fixture = Sandbox::new(); + let result = fixture + .run(create("claude-acp", "repo", "test-skill")) + .unwrap(); + let target = fixture.0.join("project/.claude/settings.local.json"); + for invalid in [ + "invalid JSON", + "[]", + r#"{"skillOverrides":[]}"#, + r#"{"skillOverrides":{"other":"unexpected"}}"#, + ] { + fs::write(&target, invalid).unwrap(); + let listing = fixture + .run(json!({"action":"list","agentId":"claude-acp"})) + .unwrap(); + assert_eq!(listing["skills"][0]["enabled"], Value::Null); + assert!(!listing["issues"].as_array().unwrap().is_empty()); + assert!(fixture.run(json!({"action":"setEnabled","agentId":"claude-acp","scope":"repo","source":result["path"],"enabled":false})).is_err()); + assert_eq!(fs::read_to_string(&target).unwrap(), invalid); + } +} + +#[test] +fn claude_toggles_reject_missing_or_foreign_paths_and_missing_state() { + let fixture = Sandbox::new(); + let result = fixture + .run(create("claude-acp", "repo", "test-skill")) + .unwrap(); + for payload in [ + json!({"action":"setEnabled","agentId":"claude-acp","scope":"repo","source":result["path"]}), + json!({"action":"setEnabled","agentId":"claude-acp","scope":"user","source":result["path"],"enabled":false}), + json!({"action":"setEnabled","agentId":"claude-acp","scope":"repo","source":fixture.0.join("foreign/SKILL.md"),"enabled":false}), + ] { + assert!(fixture.run(payload).is_err()); + } + assert!(!fixture + .0 + .join("project/.claude/settings.local.json") + .exists()); +} + +#[test] +fn claude_settings_cancel_and_busy_lock_never_change_existing_bytes() { + use fs2::FileExt; + let fixture = Sandbox::new(); + let target = fixture.0.join("home/.claude/settings.json"); + fs::create_dir_all(target.parent().unwrap()).unwrap(); + fs::write(&target, "{\"other\":true}").unwrap(); + assert!(settings::write_override(&target, "test", false, &|| true).is_err()); + let lock = fs::OpenOptions::new() + .read(true) + .write(true) + .open(target.parent().unwrap().join(".lithe-skill-settings.lock")) + .unwrap(); + lock.try_lock_exclusive().unwrap(); + assert!(settings::write_override(&target, "test", false, &|| false).is_err()); + drop(lock); + assert_eq!(fs::read_to_string(&target).unwrap(), "{\"other\":true}"); + assert_eq!( + fs::read_dir(target.parent().unwrap()).unwrap().count(), + 2, + "no temporary settings survive failure" + ); +} + +#[cfg(unix)] +#[test] +fn claude_settings_reject_symlinks_and_preserve_permissions() { + use std::os::unix::fs::{symlink, PermissionsExt}; + let fixture = Sandbox::new(); + let target = fixture.0.join("home/.claude/settings.json"); + fs::create_dir_all(target.parent().unwrap()).unwrap(); + let outside = fixture.0.join("outside.json"); + fs::write(&outside, "{}").unwrap(); + symlink(&outside, &target).unwrap(); + assert!(settings::write_override(&target, "test", false, &|| false).is_err()); + assert_eq!(fs::read_to_string(&outside).unwrap(), "{}"); + fs::remove_file(&target).unwrap(); + fs::write(&target, "{}").unwrap(); + fs::set_permissions(&target, fs::Permissions::from_mode(0o640)).unwrap(); + settings::write_override(&target, "test", false, &|| false).unwrap(); + assert_eq!( + fs::metadata(&target).unwrap().permissions().mode() & 0o777, + 0o640 + ); +} +const DOCUMENT: &str = + "---\nname: sample-skill\ndescription: Test skill\n---\n\nRead the supporting files.\n"; + +#[test] +fn create_is_native_scoped_non_overwriting_and_round_trips_document() { + let fixture = Sandbox::new(); + for agent in ["codex-acp", "claude-acp"] { + for scope in ["user", "repo"] { + let result = fixture.run(create(agent, scope, "new-skill")).unwrap(); + let path = PathBuf::from(result["path"].as_str().unwrap()); + let parent = if scope == "user" { + fixture.0.join("home") + } else { + fixture.0.join("project") + }; + assert!(path.starts_with(parent)); + assert!(path.to_string_lossy().contains(if agent == "codex-acp" { + ".agents" + } else { + ".claude" + })); + let bytes = fs::read(&path).unwrap(); + let parsed = files::read_document(&path).unwrap(); + assert!(parsed.contains("中文")); + assert_eq!(files::metadata(&bytes, "").unwrap().0, "new-skill"); + assert!(fixture.run(create(agent, scope, "new-skill")).is_err()); + assert_eq!(fs::read(&path).unwrap(), bytes); + } + } +} + +#[test] +fn import_keeps_full_package_and_never_changes_the_source() { + let fixture = Sandbox::new(); + let source = fixture.0.join("external"); + fs::create_dir_all(source.join("scripts")).unwrap(); + fs::write(source.join("SKILL.md"), DOCUMENT).unwrap(); + fs::write(source.join("scripts/helper.sh"), "#!/bin/sh\nexit 0\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions( + source.join("scripts/helper.sh"), + fs::Permissions::from_mode(0o755), + ) + .unwrap(); + } + let result = fixture + .run(json!({"action":"import", "agentId":"codex-acp", "scope":"repo", "source":source})) + .unwrap(); + let copied = PathBuf::from(result["path"].as_str().unwrap()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(copied.parent().unwrap().join("scripts/helper.sh")) + .unwrap() + .permissions() + .mode(), + fs::metadata(source.join("scripts/helper.sh")) + .unwrap() + .permissions() + .mode() + ); + } + assert_eq!(fs::read(&copied).unwrap(), DOCUMENT.as_bytes()); + assert_eq!( + fs::read(copied.parent().unwrap().join("scripts/helper.sh")).unwrap(), + fs::read(source.join("scripts/helper.sh")).unwrap() + ); + assert_eq!( + fs::read(source.join("SKILL.md")).unwrap(), + DOCUMENT.as_bytes() + ); + assert!(fixture + .run(json!({"action":"import", "agentId":"codex-acp", "scope":"repo", "source":source})) + .is_err()); +} + +#[test] +fn single_file_import_and_claude_directory_fallback_are_supported() { + let fixture = Sandbox::new(); + let source = fixture.0.join("SKILL.md"); + fs::write(&source, DOCUMENT).unwrap(); + assert!(fixture + .run(json!({"action":"import", "agentId":"claude-acp", "scope":"user", "source":source})) + .is_ok()); + let directory = fixture.0.join("folder-skill"); + fs::create_dir(&directory).unwrap(); + fs::write( + directory.join("SKILL.md"), + "---\ndescription: Folder fallback\n---\n\nInstructions\n", + ) + .unwrap(); + assert!(fixture + .run(json!({"action":"import", "agentId":"codex-acp", "scope":"user", "source":directory})) + .is_err()); + let result = fixture + .run(json!({"action":"import", "agentId":"claude-acp", "scope":"user", "source":directory})) + .unwrap(); + assert!(result["path"].as_str().unwrap().contains("folder-skill")); +} + +#[test] +fn directory_inventory_reports_configured_visibility_and_malformed_issues() { + let fixture = Sandbox::new(); + fixture + .run(create("claude-acp", "user", "user-skill")) + .unwrap(); + fixture + .run(create("claude-acp", "repo", "project-skill")) + .unwrap(); + let bad = fixture.0.join("project/.claude/skills/broken"); + fs::create_dir(&bad).unwrap(); + fs::write(bad.join("SKILL.md"), "---\nname: [invalid\n---\n").unwrap(); + let result = fixture + .run(json!({"action":"list", "agentId":"claude-acp"})) + .unwrap(); + assert_eq!(result["source"], "directory"); + assert_eq!(result["skills"].as_array().unwrap().len(), 2); + assert!(result["skills"] + .as_array() + .unwrap() + .iter() + .all(|row| row["enabled"] == true)); + assert_eq!(result["issues"].as_array().unwrap().len(), 1); + let directory = PathBuf::from(result["directories"]["repo"].as_str().unwrap()); + assert!(directory.ends_with(PathBuf::from(".claude").join("skills"))); +} + +#[test] +fn invalid_names_yaml_and_missing_workspace_do_not_publish_any_skill() { + let fixture = Sandbox::new(); + for name in ["../escape", "UPPER", "", "a--b", "/absolute", "a/b"] { + assert!(fixture.run(create("codex-acp", "repo", name)).is_err()); + } + let payload = create("codex-acp", "repo", "valid-name"); + assert!(execute_at( + serde_json::from_value(payload).unwrap(), + &fixture.0.join("home"), + &fixture.0.join("home/.claude"), + &|| false + ) + .is_err()); + let source = fixture.0.join("SKILL.md"); + for text in [ + "No frontmatter", + "---\nname: no-close", + "---\n[]\n---\n", + "---\nname: test\n---\nBody", + ] { + fs::write(&source, text).unwrap(); + assert!(fixture + .run(json!({"action":"import", "agentId":"codex-acp", "scope":"user", "source":source})) + .is_err()); + } + assert!(!fixture.0.join("project/.agents").exists()); +} + +#[cfg(unix)] +#[test] +fn linked_destinations_and_import_resources_are_rejected_without_partial_publish() { + use std::os::unix::fs::symlink; + let fixture = Sandbox::new(); + let outside = fixture.0.join("outside"); + fs::create_dir(&outside).unwrap(); + symlink(&outside, fixture.0.join("project/.agents")).unwrap(); + assert!(fixture + .run(create("codex-acp", "repo", "linked-skill")) + .is_err()); + assert_eq!(fs::read_dir(&outside).unwrap().count(), 0); + let source = fixture.0.join("package"); + fs::create_dir(&source).unwrap(); + fs::write(source.join("SKILL.md"), DOCUMENT).unwrap(); + symlink(&outside, source.join("assets")).unwrap(); + assert!(fixture + .run(json!({"action":"import", "agentId":"codex-acp", "scope":"user", "source":source})) + .is_err()); + let root = fixture.0.join("home/.agents/skills"); + assert_eq!( + fs::read_dir(root).unwrap().count(), + 0, + "failed import cleans staging and publishes nothing" + ); +} + +#[test] +fn cancel_during_package_copy_cleans_staging_and_preserves_existing_skill() { + let fixture = Sandbox::new(); + fixture + .run(create("codex-acp", "user", "existing")) + .unwrap(); + let source = fixture.0.join("package"); + fs::create_dir(&source).unwrap(); + fs::write(source.join("SKILL.md"), DOCUMENT).unwrap(); + fs::write(source.join("other.txt"), "resource").unwrap(); + let calls = std::cell::Cell::new(0); + let cancel = || { + calls.set(calls.get() + 1); + calls.get() >= 5 + }; + let request: Request = serde_json::from_value( + json!({"action":"import", "agentId":"codex-acp", "scope":"user", "source":source}), + ) + .unwrap(); + assert!(matches!( + execute_at( + request, + &fixture.0.join("home"), + &fixture.0.join("home/.claude"), + &cancel + ), + Err(Error::Cancelled) + )); + let root = fixture.0.join("home/.agents/skills"); + assert_eq!(fs::read_dir(&root).unwrap().count(), 1); + assert!(root.join("existing/SKILL.md").is_file()); +} + +#[test] +fn publication_cannot_replace_an_existing_empty_directory() { + let fixture = Sandbox::new(); + let source = fixture.0.join("stage"); + let target = fixture.0.join("target"); + fs::create_dir(&source).unwrap(); + fs::write(source.join("SKILL.md"), DOCUMENT).unwrap(); + // The final native move must reject a destination created after staging, + // even when the competing directory is empty. + fs::create_dir(&target).unwrap(); + assert!(files::publish(&source, &target).is_err()); + assert_eq!(fs::read_dir(target).unwrap().count(), 0); + assert!(source.join("SKILL.md").is_file()); +} + +#[test] +fn publication_preserves_existing_files_and_directory_contents() { + for directory in [false, true] { + let fixture = Sandbox::new(); + let source = fixture.0.join("stage"); + let target = fixture.0.join("target"); + fs::create_dir(&source).unwrap(); + fs::write(source.join("SKILL.md"), DOCUMENT).unwrap(); + let existing = if directory { + fs::create_dir(&target).unwrap(); + target.join("keep.txt") + } else { + target.clone() + }; + fs::write(&existing, "keep existing bytes").unwrap(); + assert!(files::publish(&source, &target).is_err()); + assert_eq!(fs::read_to_string(existing).unwrap(), "keep existing bytes"); + assert_eq!( + fs::read_to_string(source.join("SKILL.md")).unwrap(), + DOCUMENT + ); + } +} + +#[test] +fn publication_moves_a_complete_package_to_a_missing_unicode_destination() { + let fixture = Sandbox::new(); + let source = fixture.0.join("stage with spaces"); + let target = fixture.0.join("skill 中文"); + fs::create_dir_all(source.join("scripts")).unwrap(); + fs::write(source.join("SKILL.md"), DOCUMENT).unwrap(); + fs::write(source.join("scripts/example.txt"), "resource bytes").unwrap(); + files::publish(&source, &target).unwrap(); + assert!(!source.exists()); + assert_eq!( + fs::read_to_string(target.join("SKILL.md")).unwrap(), + DOCUMENT + ); + assert_eq!( + fs::read_to_string(target.join("scripts/example.txt")).unwrap(), + "resource bytes" + ); +} + +#[cfg(windows)] +#[test] +fn publication_rejects_nul_paths_without_publishing_the_truncated_name() { + use std::os::windows::ffi::{OsStrExt, OsStringExt}; + let fixture = Sandbox::new(); + let source = fixture.0.join("stage"); + let prefix = fixture.0.join("target"); + let mut name: Vec = prefix.as_os_str().encode_wide().collect(); + name.extend([0, b'x' as u16]); + let target = PathBuf::from(std::ffi::OsString::from_wide(&name)); + fs::create_dir(&source).unwrap(); + fs::write(source.join("SKILL.md"), DOCUMENT).unwrap(); + assert!(files::publish(&source, &target).is_err()); + assert!(!prefix.exists()); + assert_eq!( + fs::read_to_string(source.join("SKILL.md")).unwrap(), + DOCUMENT + ); +} + +#[test] +fn native_response_keeps_disabled_scope_and_rejects_foreign_workspace() { + let fixture = Sandbox::new(); + let cwd = fixture.0.join("project"); + let value = json!({"data":[{"cwd":cwd, "skills":[ + {"name":"native-skill", "description":"Reported", "path":fixture.0.join("SKILL.md"), "scope":"system", "enabled":false}],"errors":[]}]}); + let listing = parse_codex(&value, &cwd).unwrap(); + assert_eq!(listing.skills[0].enabled, Some(false)); + assert_eq!(listing.skills[0].scope, "system"); + assert!(parse_codex(&value, &fixture.0.join("foreign")).is_err()); +} + +#[cfg(unix)] +#[tokio::test] +async fn native_discovery_uses_the_selected_account_directory() { + use std::os::unix::fs::PermissionsExt; + let fixture = Sandbox::new(); + let executable = fixture.0.join("fake-codex"); + let home = fixture.0.join("account"); + let response = json!({"data":[{"cwd":fixture.0.join("project"), "skills":[], "errors":[]}]}); + let script = format!("#!/bin/sh\nprintf '%s' \"$CODEX_HOME\" > '{}'/home.log\nwhile IFS= read -r line; do\n case \"$line\" in\n *'\"method\":\"initialize\"'*) printf '%s\\n' '{{\"id\":1,\"result\":{{}}}}' ;;\n *'\"method\":\"skills/list\"'*) printf '%s\\n' '{{\"id\":2,\"result\":{response}}}' ;;\n esac\ndone\n", fixture.0.display()); + fs::write(&executable, script).unwrap(); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap(); + tokio::time::timeout( + Duration::from_secs(5), + CodexHistory::skills(executable, fixture.0.join("project"), Some(home.clone()), &|| false), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(fs::read_to_string(fixture.0.join("home.log")).unwrap(), home.to_str().unwrap()); +} + +#[cfg(unix)] +#[tokio::test] +async fn native_discovery_only_initializes_and_lists_without_creating_or_prompting() { + use std::os::unix::fs::PermissionsExt; + let fixture = Sandbox::new(); + let executable = fixture.0.join("fake-codex"); + let response = json!({"data":[{"cwd":fixture.0.join("project"), "skills":[], "errors":[]}]}); + let script = format!("#!/bin/sh\nwhile IFS= read -r line; do\n printf '%s\\n' \"$line\" >> '{}'/requests.log\n case \"$line\" in\n *'\"method\":\"initialize\"'*) printf '%s\\n' '{{\"id\":1,\"result\":{{}}}}' ;;\n *'\"method\":\"skills/list\"'*) printf '%s\\n' '{{\"id\":2,\"result\":{response}}}' ;;\n esac\ndone\n", fixture.0.display()); + fs::write(&executable, script).unwrap(); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap(); + let result = tokio::time::timeout( + Duration::from_secs(5), + CodexHistory::skills(executable, fixture.0.join("project"), None, &|| false), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(result, response); + let requests: Vec = fs::read_to_string(fixture.0.join("requests.log")) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!( + requests + .iter() + .map(|row| row["method"].as_str().unwrap()) + .collect::>(), + ["initialize", "initialized", "skills/list"] + ); + assert_eq!(requests[2]["params"]["forceReload"], true); +} + +#[cfg(unix)] +#[tokio::test] +async fn native_toggle_validates_path_writes_native_config_and_reads_back() { + use std::os::unix::fs::PermissionsExt; + let fixture = Sandbox::new(); + let executable = fixture.0.join("fake-codex"); + let path = fixture.0.join("project/.agents/skills/test/SKILL.md"); + let listing = |enabled| json!({"data":[{"cwd":fixture.0.join("project"),"skills":[{"name":"test","description":"Test","path":path,"scope":"repo","enabled":enabled}],"errors":[]}]}); + let script = format!("#!/bin/sh\nwhile IFS= read -r line; do\n printf '%s\\n' \"$line\" >> '{}'/requests.log\n case \"$line\" in\n *'\"method\":\"initialize\"'*) printf '%s\\n' '{{\"id\":1,\"result\":{{}}}}' ;;\n *'\"id\":2,'*) printf '%s\\n' '{{\"id\":2,\"result\":{}}}' ;;\n *'\"id\":3,'*) printf '%s\\n' '{{\"id\":3,\"result\":{{}}}}' ;;\n *'\"id\":4,'*) printf '%s\\n' '{{\"id\":4,\"result\":{}}}' ;;\n esac\ndone\n", fixture.0.display(), listing(true), listing(false)); + fs::write(&executable, script).unwrap(); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap(); + let result = tokio::time::timeout( + Duration::from_secs(5), + CodexHistory::set_skill_enabled( + executable.clone(), + fixture.0.join("project"), + path.clone(), + false, + None, + &|| false, + ), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(result, listing(false)); + let requests: Vec = fs::read_to_string(fixture.0.join("requests.log")) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!( + requests + .iter() + .map(|row| row["method"].as_str().unwrap()) + .collect::>(), + [ + "initialize", + "initialized", + "skills/list", + "skills/config/write", + "skills/list" + ] + ); + assert_eq!(requests[3]["params"], json!({"path":path,"enabled":false})); + fs::remove_file(fixture.0.join("requests.log")).unwrap(); + let rejected = tokio::time::timeout( + Duration::from_secs(5), + CodexHistory::set_skill_enabled( + executable, + fixture.0.join("project"), + fixture.0.join("foreign/SKILL.md"), + false, + None, + &|| false, + ), + ) + .await + .unwrap(); + assert_eq!(rejected, Err("unknown skill")); + assert!(!fs::read_to_string(fixture.0.join("requests.log")) + .unwrap() + .contains("skills/config/write")); +} + +#[cfg(unix)] +#[test] +fn shared_skill_fixture_preserves_native_scopes_plugins_and_disabled_state() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/agent/skills-management-v1.json" + )) + .unwrap(); + let listing = parse_codex(&fixture["nativeCodex"], Path::new("/fixture/workspace")).unwrap(); + assert_eq!( + serde_json::to_value(listing).unwrap(), + fixture["normalizedCodex"] + ); +} + +#[test] +fn oversized_import_is_bounded_and_never_published() { + let fixture = Sandbox::new(); + let source = fixture.0.join("SKILL.md"); + let file = fs::File::create(&source).unwrap(); + file.set_len(8 * 1024 * 1024 + 1).unwrap(); + assert!(fixture + .run(json!({"action":"import", "agentId":"codex-acp", "scope":"user", "source":source})) + .is_err()); + assert!(!fixture.0.join("home/.agents").exists()); +} + +#[cfg(unix)] +#[tokio::test] +async fn cancelled_discovery_reaps_the_probe_even_while_initialization_is_stalled() { + use std::os::unix::fs::PermissionsExt; + let fixture = Sandbox::new(); + let executable = fixture.0.join("stalled-codex"); + let pid_file = fixture.0.join("probe.pid"); + fs::write( + &executable, + format!( + "#!/bin/sh\nprintf '%s' \"$$\" > '{}'\nwhile IFS= read -r line; do :; done\n", + pid_file.display() + ), + ) + .unwrap(); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap(); + // The child announces startup before the cancellation check becomes true. + // No real-time sleep coordinates the test; the production poll remains bounded. + let cancelled = || pid_file.exists(); + let result = tokio::time::timeout( + Duration::from_secs(5), + CodexHistory::skills(executable, fixture.0.join("project"), None, &cancelled), + ) + .await + .unwrap(); + assert_eq!(result, Err("cancelled")); + let pid: i32 = fs::read_to_string(pid_file).unwrap().parse().unwrap(); + // A terminated but unreaped child would still answer kill(pid, 0). + assert_eq!( + unsafe { libc::kill(pid, 0) }, + -1, + "probe must be reaped before discovery returns" + ); +} diff --git a/rust/lithe-agent-host/src/subscription.rs b/rust/lithe-agent-host/src/subscription.rs index 269831d623..0c9a450b5e 100644 --- a/rust/lithe-agent-host/src/subscription.rs +++ b/rust/lithe-agent-host/src/subscription.rs @@ -15,6 +15,9 @@ const CHATGPT_BASE_URL: &str = "https://chatgpt.com/backend-api/"; /// after the proxy capability prefix, not appended to this upstream twice. pub(super) const INFERENCE_ORIGIN: &str = "https://chatgpt.com"; +/// Codex's own switch for selecting an isolated account directory. +pub(super) const CODEX_HOME_ENVIRONMENT: &str = "CODEX_HOME"; + const PROBE_TIMEOUT: Duration = Duration::from_secs(20); const MAX_REPLY_BYTES: u64 = 1024 * 1024; pub(super) const LOGIN_TIMEOUT: Duration = Duration::from_secs(300); @@ -155,6 +158,12 @@ pub(super) fn resolve( return Err(issue); } let executable = detected.ok_or("Codex CLI is unavailable")?.path; + let codex_home = launch.codex_home; + if codex_home.as_ref().is_some_and(|home| !home.is_absolute()) { + // A relative path would resolve against the adapter's working directory + // and silently select a different account than the one the user chose. + return Err("The Codex account directory must be an absolute path".into()); + } let mut config = codex_session_config(None); config["model_provider"] = json!("openai"); // Empty disables a saved openai_base_url override in Codex. @@ -173,10 +182,21 @@ pub(super) fn resolve( ), ("MODEL_PROVIDER".into(), "openai".into()), ("CODEX_CONFIG".into(), config.to_string()), - ], + ] + .into_iter() + .chain(codex_home.as_ref().map(|home| { + ( + CODEX_HOME_ENVIRONMENT.into(), + home.to_string_lossy().into_owned(), + ) + })) + .collect(), gateway: None, secret: String::new(), subscription_cli: Some(executable), + claude_subscription: false, + codex_home, + claude_projects: None, }) } @@ -230,9 +250,14 @@ pub(super) async fn read_quota( command: &Path, cwd: &Path, account: &Account, + codex_home: Option<&Path>, ) -> Result { let mut process = std::process::Command::new(command); isolate_environment(&mut process); + if let Some(home) = codex_home { + // The probe must read the same account the adapter is signed in to. + process.env(CODEX_HOME_ENVIRONMENT, home); + } process .args([ "-c", diff --git a/rust/lithe-agent-host/src/tests.rs b/rust/lithe-agent-host/src/tests.rs index 11464c6daf..d3354a7335 100644 --- a/rust/lithe-agent-host/src/tests.rs +++ b/rust/lithe-agent-host/src/tests.rs @@ -61,6 +61,8 @@ fn custom_launch(command: &str, provider: ProviderCredentials) -> AgentLaunch { observe_model_calls: false, observe_subagents: false, process_scope: None, + claude_config_dir: None, + codex_home: None, } } @@ -123,9 +125,15 @@ impl MockAgent { #[tokio::test(flavor = "current_thread")] async fn native_subagents_keep_child_permissions_owned_by_the_root_turn() { - let mut harness = Harness::start_observing_subagents(false, gateway(), std::env::temp_dir(), true); + let mut harness = + Harness::start_observing_subagents(false, gateway(), std::env::temp_dir(), true); let initialize = harness.agent.expect("initialize").await; - assert!(initialize["params"]["clientCapabilities"]["_meta"]["jetbrains"]["air"]["capabilities"].as_array().unwrap().contains(&json!("nativeSubagentSessions"))); + assert!( + initialize["params"]["clientCapabilities"]["_meta"]["jetbrains"]["air"]["capabilities"] + .as_array() + .unwrap() + .contains(&json!("nativeSubagentSessions")) + ); harness.agent.reply(&initialize, json!({"protocolVersion":1,"agentCapabilities":{},"authMethods":[{"id":"gateway","name":"Gateway"}]})).await; let auth = harness.agent.expect("authenticate").await; harness.agent.reply(&auth, json!({})).await; @@ -134,7 +142,12 @@ async fn native_subagents_keep_child_permissions_owned_by_the_root_turn() { harness.send(json!({"kind":"prompt","sessionId":"session-1","text":"Inspect the sample"})); let prompt = harness.agent.expect("session/prompt").await; let fixture = fixture(); - for name in ["subagentSpawned", "nestedSubagentSpawned", "subagentToolCall", "subagentMessageChunk"] { + for name in [ + "subagentSpawned", + "nestedSubagentSpawned", + "subagentToolCall", + "subagentMessageChunk", + ] { let event = &fixture["events"][name]; harness.agent.write(json!({"jsonrpc":"2.0","method":"session/update","params":{"sessionId":event["sessionId"],"update":event["update"]}})).await; assert_eq!(serde_json::to_value(harness.event().await).unwrap(), *event); @@ -142,7 +155,14 @@ async fn native_subagents_keep_child_permissions_owned_by_the_root_turn() { let mut request = fixture["events"]["permission"]["request"].clone(); request["sessionId"] = json!("child-2"); harness.agent.write(json!({"jsonrpc":"2.0","id":"child-permission","method":"session/request_permission","params":request})).await; - let AgentEvent::Permission { session_id, request_id, request: received } = harness.event().await else { panic!("child permission must reach the owning turn") }; + let AgentEvent::Permission { + session_id, + request_id, + request: received, + } = harness.event().await + else { + panic!("child permission must reach the owning turn") + }; assert_eq!(session_id, "session-1"); assert_eq!(received["sessionId"], "child-2"); answer_permission(&harness.permissions, &request_id, Some("allow_once".into())).unwrap(); @@ -152,19 +172,31 @@ async fn native_subagents_keep_child_permissions_owned_by_the_root_turn() { // Parent cancellation must settle the child's next permission without a timer. harness.agent.write(json!({"jsonrpc":"2.0","id":"pending-child","method":"session/request_permission","params":request})).await; - assert!(matches!(harness.event().await, AgentEvent::Permission { session_id, .. } if session_id == "session-1")); + assert!( + matches!(harness.event().await, AgentEvent::Permission { session_id, .. } if session_id == "session-1") + ); harness.send(json!({"kind":"cancel","sessionId":"session-1"})); let mut cancelled_permission = false; let mut cancelled_turn = false; for _ in 0..2 { let message = harness.agent.next().await; - cancelled_permission |= message["id"] == "pending-child" && message["result"]["outcome"]["outcome"] == "cancelled"; - cancelled_turn |= message["method"] == "session/cancel" && message["params"]["sessionId"] == "session-1"; + cancelled_permission |= message["id"] == "pending-child" + && message["result"]["outcome"]["outcome"] == "cancelled"; + cancelled_turn |= + message["method"] == "session/cancel" && message["params"]["sessionId"] == "session-1"; } assert!(cancelled_permission && cancelled_turn); - assert!(matches!(harness.event().await, AgentEvent::TurnCancelling { session_id } if session_id == "session-1")); - harness.agent.reply(&prompt, json!({"stopReason":"cancelled"})).await; - assert!(matches!(harness.event().await, AgentEvent::TurnFinished { .. })); + assert!( + matches!(harness.event().await, AgentEvent::TurnCancelling { session_id } if session_id == "session-1") + ); + harness + .agent + .reply(&prompt, json!({"stopReason":"cancelled"})) + .await; + assert!(matches!( + harness.event().await, + AgentEvent::TurnFinished { .. } + )); assert_eq!(harness.stop().await, Ok(())); } @@ -595,7 +627,12 @@ impl Harness { Self::start_observing_subagents(subscription, route, cwd, false) } - fn start_observing_subagents(subscription: bool, route: Option, cwd: PathBuf, observe_subagents: bool) -> Self { + fn start_observing_subagents( + subscription: bool, + route: Option, + cwd: PathBuf, + observe_subagents: bool, + ) -> Self { let (client, peer) = tokio::io::duplex(64 * 1024); let (client_reader, client_writer) = tokio::io::split(client); let (peer_reader, peer_writer) = tokio::io::split(peer); @@ -608,6 +645,9 @@ impl Harness { route, subscription.then(|| PathBuf::from("/fixture/codex")), None, + false, + None, + None, None, observe_subagents, receiver, @@ -3175,6 +3215,8 @@ fn catalog_agents_resolve_to_their_install_and_key_delivery() { observe_model_calls: false, observe_subagents: false, process_scope: None, + claude_config_dir: None, + codex_home: None, }; let anthropic = ProviderCredentials { protocol: ProviderProtocol::AnthropicMessages, @@ -3226,6 +3268,30 @@ fn catalog_agents_resolve_to_their_install_and_key_delivery() { assert!(config.get("model").is_none()); assert_eq!(config["tools"]["update_plan"]["enabled"], true); assert!(config.get("collaboration_mode").is_none()); + assert!( + subscription + .env + .iter() + .all(|(name, _)| name != "CODEX_HOME"), + "the default account keeps the CLI's own home" + ); + // An account directory selects an isolated Codex login for the adapter and + // for the quota probe; a relative path would pick an account by accident. + let home = std::env::temp_dir().join("lithe-codex-account"); + let mut isolated = launch("codex-acp", provider()); + isolated.authentication = AgentAuthentication::CodexSubscription; + isolated.provider = None; + isolated.codex_home = Some(home.clone()); + let isolated = resolve(isolated).unwrap(); + assert!(isolated + .env + .contains(&("CODEX_HOME".to_owned(), home.to_string_lossy().into_owned()))); + assert_eq!(isolated.codex_home, Some(home)); + let mut relative_home = launch("codex-acp", provider()); + relative_home.authentication = AgentAuthentication::CodexSubscription; + relative_home.provider = None; + relative_home.codex_home = Some(PathBuf::from("relative/account")); + assert!(resolve(relative_home).err().unwrap().contains("absolute")); // Statistics changes only this process's inference route; native account, // quota and model ownership survive without installing a second provider. @@ -4127,3 +4193,164 @@ fn closing_without_preserved_tasks_still_ends_the_agent_tree() { agent.task_may_run = !gone; assert!(gone, "agent task outlived the connection"); } + +// Claude subscription mode must launch the signed-in CLI without any API route, +// key or model pin, and must stay limited to the Claude adapter. +#[test] +fn claude_subscription_launches_the_signed_in_cli_without_a_route() { + struct Cleanup(PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let data = + std::env::temp_dir().join(format!("lithe-claude-subscription-{}", std::process::id())); + let _cleanup = Cleanup(data.clone()); + let _ = std::fs::remove_dir_all(&data); + let launch = |agent_id: &str| AgentLaunch { + agent_id: Some(agent_id.into()), + command: None, + args: vec![], + cwd: std::env::temp_dir(), + data_directory: Some(data.clone()), + provider: None, + authentication: AgentAuthentication::ClaudeSubscription, + observe_model_calls: false, + observe_subagents: false, + process_scope: None, + claude_config_dir: None, + codex_home: None, + }; + let claude_cli = |command: &str| { + (command == "claude").then(|| environment::DetectedTool { + version: "2.1.309".into(), + path: "/opt/example/bin/claude".into(), + }) + }; + let not_installed = resolve_with(launch("claude-acp"), &claude_cli) + .err() + .unwrap(); + assert!(not_installed.contains("not installed"), "{not_installed}"); + fake_install(&data, "claude-acp"); + fake_install(&data, "codex-acp"); + + let resolved = resolve_with(launch("claude-acp"), &claude_cli).unwrap(); + assert!(resolved.claude_subscription); + assert!( + resolved.gateway.is_none(), + "no API route may reach the adapter" + ); + assert!(resolved.secret.is_empty()); + assert!( + resolved.subscription_cli.is_none(), + "Codex quota and login must not run" + ); + assert!( + resolved + .env + .iter() + .all(|(name, _)| !name.starts_with("ANTHROPIC_")), + "{:?}", + resolved.env + ); + if !cfg!(windows) { + assert_eq!( + resolved.env, + vec![( + "CLAUDE_CODE_EXECUTABLE".to_owned(), + "/opt/example/bin/claude".to_owned() + )] + ); + } + // Neither session option nor restore metadata carries a route or key. + assert!(session_routing::metadata(resolved.gateway.as_ref()) + .unwrap() + .is_none()); + assert!( + session_routing::restored_metadata(resolved.gateway.as_ref()) + .unwrap() + .is_none() + ); + + let codex = resolve_with(launch("codex-acp"), &claude_cli) + .err() + .unwrap(); + assert!(codex.contains("only available for Claude Agent"), "{codex}"); + let missing_cli = resolve_with(launch("claude-acp"), &|_| None).err().unwrap(); + assert!(!missing_cli.is_empty()); + // An account directory selects the official CLI's isolated login and is + // exported unchanged; a relative path would pick an account by accident. + let account = std::env::temp_dir().join("lithe-claude-account"); + let mut isolated = launch("claude-acp"); + isolated.claude_config_dir = Some(account.clone()); + let resolved = resolve_with(isolated, &claude_cli).unwrap(); + assert!(resolved.env.contains(&( + "CLAUDE_CONFIG_DIR".to_owned(), + account.to_string_lossy().into_owned() + ))); + let mut relative_account = launch("claude-acp"); + relative_account.claude_config_dir = Some(PathBuf::from("relative/account")); + assert!(resolve_with(relative_account, &claude_cli) + .err() + .unwrap() + .contains("absolute")); + let mut relative = launch("claude-acp"); + relative.cwd = PathBuf::from("relative/workspace"); + assert!(resolve_with(relative, &claude_cli) + .err() + .unwrap() + .contains("absolute")); +} + +// The adapter's own sign-in report decides; an absent report never blocks. +#[tokio::test(flavor = "current_thread", start_paused = true)] +async fn claude_subscription_rejects_only_a_signed_out_cli() { + let status = |kind: &str| subscription::AuthStatus { + kind: kind.into(), + account: None, + }; + + let (tx, mut rx) = tokio::sync::watch::channel(Some(status("none"))); + assert_eq!( + claude_subscription::ensure_signed_in(&mut rx).await, + Err(claude_subscription::SIGN_IN_REQUIRED.to_owned()) + ); + for kind in ["account", "api_key", "external", "gateway"] { + tx.send_replace(Some(status(kind))); + assert_eq!( + claude_subscription::ensure_signed_in(&mut rx).await, + Ok(None), + "{kind}" + ); + } + // The reported claude.ai account travels to the UI for display only. + let reported = subscription::Account { + email: Some("user@example.com".into()), + plan: Some("Claude Pro".into()), + }; + tx.send_replace(Some(subscription::AuthStatus { + kind: "account".into(), + account: Some(reported.clone()), + })); + assert_eq!( + claude_subscription::ensure_signed_in(&mut rx).await, + Ok(Some(reported)) + ); + + // A report that arrives after initialization is still honored. + let (late_tx, mut late_rx) = tokio::sync::watch::channel(None::); + let waiter = + tokio::spawn(async move { claude_subscription::ensure_signed_in(&mut late_rx).await }); + tokio::time::sleep(Duration::from_secs(1)).await; + late_tx.send_replace(Some(status("none"))); + assert!(waiter.await.unwrap().is_err()); + + // No report within the bound is "unknown": the connection continues and + // the session request surfaces any real authentication failure. + let (_silent_tx, mut silent_rx) = tokio::sync::watch::channel(None::); + assert_eq!( + claude_subscription::ensure_signed_in(&mut silent_rx).await, + Ok(None) + ); +} diff --git a/rust/lithe-agent-host/tests/claude_api_failure.rs b/rust/lithe-agent-host/tests/claude_api_failure.rs index fc8b160723..54ce5ef7d1 100644 --- a/rust/lithe-agent-host/tests/claude_api_failure.rs +++ b/rust/lithe-agent-host/tests/claude_api_failure.rs @@ -228,6 +228,8 @@ fn run_local_api_turn_with(status: u16, observe_model_calls: bool) { observe_model_calls, observe_subagents: false, process_scope: None, + claude_config_dir: None, + codex_home: None, provider: Some(ProviderCredentials { protocol: ProviderProtocol::AnthropicMessages, base_url: format!("http://{}", listener.local_addr().unwrap()), @@ -456,6 +458,8 @@ fn local_model_phase( observe_model_calls: false, observe_subagents: false, process_scope: None, + claude_config_dir: None, + codex_home: None, provider: Some(ProviderCredentials { protocol: ProviderProtocol::AnthropicMessages, base_url: format!("http://{}", listener.local_addr().unwrap()), diff --git a/rust/lithe-agent-host/tests/codex_api_failure.rs b/rust/lithe-agent-host/tests/codex_api_failure.rs index 8d4b4b0bdd..b6ab258524 100644 --- a/rust/lithe-agent-host/tests/codex_api_failure.rs +++ b/rust/lithe-agent-host/tests/codex_api_failure.rs @@ -160,6 +160,8 @@ fn run_with( observe_model_calls, observe_subagents: false, process_scope: None, + claude_config_dir: None, + codex_home: None, provider: Some(ProviderCredentials { protocol: ProviderProtocol::Responses, base_url: format!("http://{}/v1", listener.local_addr().unwrap()), diff --git a/rust/lithe-agent-host/tests/real_agent.rs b/rust/lithe-agent-host/tests/real_agent.rs index 29d73edaf1..d5de95c29d 100644 --- a/rust/lithe-agent-host/tests/real_agent.rs +++ b/rust/lithe-agent-host/tests/real_agent.rs @@ -159,6 +159,8 @@ fn open(workspace: &std::path::Path) -> Session { observe_model_calls: false, observe_subagents: false, process_scope: None, + claude_config_dir: None, + codex_home: None, provider: Some(ProviderCredentials { protocol, base_url: required("LITHE_ACP_E2E_BASE_URL"), diff --git a/rust/lithe-core/Cargo.toml b/rust/lithe-core/Cargo.toml index 70a34ebdea..d331e6eb4f 100644 --- a/rust/lithe-core/Cargo.toml +++ b/rust/lithe-core/Cargo.toml @@ -12,6 +12,8 @@ crate-type = ["rlib", "staticlib", "cdylib"] lithe-git-host = { path = "../lithe-git-host" } lithe-agent-host = { path = "../lithe-agent-host" } lithe-ide-host = { path = "../lithe-ide-host" } +lithe-remote-client = { path = "../lithe-remote-client" } +lithe-remote-protocol = { path = "../lithe-remote-protocol" } ammonia = "4.1.4" base64 = "0.22" chrono = { version = "0.4", default-features = false, features = ["std"] } @@ -25,6 +27,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" sha1 = "0.10" sha2 = "0.10" +shell-words = "=1.1.1" toml = { version = "1.1.4", default-features = false, features = ["parse", "serde"] } toml_edit = "0.25" tree-sitter = "0.25" diff --git a/rust/lithe-core/src/agent/hooks.rs b/rust/lithe-core/src/agent/hooks.rs new file mode 100644 index 0000000000..285ecd2a83 --- /dev/null +++ b/rust/lithe-core/src/agent/hooks.rs @@ -0,0 +1,20 @@ +//! Typed Hook management dispatch. Native files and execution belong to Agent Host. + +use crate::protocol::{cancellation, CoreError, ErrorCode}; +use lithe_agent_host::hooks; +use serde_json::Value; + +/// Synchronizes explicit user edits without executing commands or granting trust. +pub(crate) fn execute(request: hooks::Request) -> Result { + cancellation::check()?; + let library = hooks::execute(request, &|| cancellation::check().is_err()).map_err(|issue| { + let code = match issue.reason { + "cancelled" => ErrorCode::Cancelled, + "file_access" | "unsafe_file" => ErrorCode::PermissionDenied, + _ => ErrorCode::InvalidRequest, + }; + CoreError::new(code, issue.message).with_details(issue.reason) + })?; + serde_json::to_value(library) + .map_err(|issue| CoreError::new(ErrorCode::Unknown, issue.to_string())) +} diff --git a/rust/lithe-core/src/agent/instructions.rs b/rust/lithe-core/src/agent/instructions.rs new file mode 100644 index 0000000000..5e1ebb46d8 --- /dev/null +++ b/rust/lithe-core/src/agent/instructions.rs @@ -0,0 +1,51 @@ +//! Typed global-instruction requests; native file ownership stays in Agent Host. + +use crate::protocol::{cancellation, CoreError, ErrorCode}; +use lithe_agent_host::instructions::{self, InstructionError, SaveRequest}; +use serde::Deserialize; +use serde_json::Value; + +/// Selects a supported upstream Agent, never an arbitrary filesystem path. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct ReadRequest { + agent_id: String, +} + +/// Reads the effective native global file without creating directories or files. +pub(crate) fn read(request: ReadRequest) -> Result { + cancellation::check()?; + encode(instructions::read(&request.agent_id)) +} + +/// Atomically saves against the snapshot the user edited; preserves native loading. +pub(crate) fn save(request: SaveRequest) -> Result { + cancellation::check()?; + encode(instructions::save(request)) +} + +fn encode( + result: Result, +) -> Result { + let file = result.map_err(core_error)?; + serde_json::to_value(file) + .map_err(|error| CoreError::new(ErrorCode::Unknown, error.to_string())) +} + +/// Reads or synchronizes a platform-owned shared instruction library. +pub(crate) fn shared(request: instructions::shared::Request) -> Result { + cancellation::check()?; + let result = instructions::shared::execute(request, &|| cancellation::check().is_err()) + .map_err(core_error)?; + serde_json::to_value(result) + .map_err(|error| CoreError::new(ErrorCode::Unknown, error.to_string())) +} + +fn core_error(error: InstructionError) -> CoreError { + let code = match error.reason { + "unsupported_agent" => ErrorCode::NotSupported, + "file_access" | "unsafe_file" => ErrorCode::PermissionDenied, + _ => ErrorCode::InvalidRequest, + }; + CoreError::new(code, error.message).with_details(error.reason) +} diff --git a/rust/lithe-core/src/agent/mod.rs b/rust/lithe-core/src/agent/mod.rs index 1ccba7d197..43173cc838 100644 --- a/rust/lithe-core/src/agent/mod.rs +++ b/rust/lithe-core/src/agent/mod.rs @@ -5,6 +5,9 @@ use std::path::PathBuf; +pub(crate) mod hooks; +pub(crate) mod instructions; + use serde::Deserialize; use serde_json::Value; @@ -146,3 +149,19 @@ fn emit_progress(progress: install::InstallProgress) { }).to_string()); } } + +/// Execute explicit native skill discovery, installation or visibility configuration. +pub(crate) fn skills(request: lithe_agent_host::skills::Request) -> Result { + use lithe_agent_host::skills::Error; + let result = lithe_agent_host::skills::execute(request, &cancelled); + cancellation::check()?; + result.map_err(|error| { + let code = match &error { + Error::Invalid(_) => ErrorCode::InvalidRequest, + Error::MissingCLI => ErrorCode::RuntimeMissing, + Error::Cancelled => ErrorCode::Cancelled, + Error::Failed(_) => ErrorCode::ProcessFailed, + }; + CoreError::new(code, error.message()) + }) +} diff --git a/rust/lithe-core/src/build/commands.rs b/rust/lithe-core/src/build/commands.rs index ca59ff02eb..e8d7181236 100644 --- a/rust/lithe-core/src/build/commands.rs +++ b/rust/lithe-core/src/build/commands.rs @@ -17,7 +17,8 @@ use super::outputs::{ }; use super::plans::{self, OutputPlanRequest, TaskPlanRequest, TestPlanRequest}; use crate::plugins::declarative::{ - decode_payload, outcome, validate_manifest_value, PluginRejection, Validation, + decode_payload, outcome, validate_manifest_value, DeclarativePluginManifest, PluginRejection, + Validation, }; use crate::protocol::CoreError; use serde::de::DeserializeOwned; @@ -51,6 +52,17 @@ fn with_build_system( command: &str, key: &'static str, run: impl FnOnce(&BuildSystemDeclaration, &T) -> Validation, +) -> Result { + with_manifest(payload, command, key, |build_system, request, _| { + run(build_system, request) + }) +} + +fn with_manifest( + payload: Value, + command: &str, + key: &'static str, + run: impl FnOnce(&BuildSystemDeclaration, &T, &DeclarativePluginManifest) -> Validation, ) -> Result { let payload: BuildPayload = decode_payload(payload, command)?; Ok(outcome(validate_manifest_value(payload.manifest).and_then( @@ -68,7 +80,7 @@ fn with_build_system( ), ) })?; - run(build_system, &payload.request).map(|value| (key, value)) + run(build_system, &payload.request, &manifest).map(|value| (key, value)) }, ))) } @@ -84,12 +96,12 @@ pub(crate) fn build_detect_command(payload: Value) -> Result { /// `build.invocationPlan`: executable candidates and version probes. pub(crate) fn build_invocation_plan_command(payload: Value) -> Result { - with_build_system( + with_manifest( payload, "build.invocationPlan", "invocation", - |build_system, request: &InvocationRequest| { - invocation::invocation_plan(build_system, request) + |build_system, request: &InvocationRequest, manifest| { + invocation::invocation_plan(build_system, request, manifest) }, ) } diff --git a/rust/lithe-core/src/build/invocation.rs b/rust/lithe-core/src/build/invocation.rs index 19a9f422c7..4fddc96485 100644 --- a/rust/lithe-core/src/build/invocation.rs +++ b/rust/lithe-core/src/build/invocation.rs @@ -8,13 +8,17 @@ use super::decode::{self, join_relative}; use super::manifest::{ - BuildSystemDeclaration, ExecutableCandidate, Platform, RuntimeDependency, VersionProbe, + BuildSystemDeclaration, ExecutableCandidate, ExecutableSource, Platform, RuntimeDependency, + VersionProbe, }; use crate::plugins::declarative::{ - reported_version, validate_minimum_version, version_components, PluginRejection, Validation, + reported_version, validate_minimum_version, version_components, DeclarativePluginManifest, + PluginRejection, Validation, }; +use crate::plugins::executables::ordered_indices; use serde::Deserialize; use serde_json::{json, Value}; +use std::collections::BTreeMap; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -22,6 +26,9 @@ pub(crate) struct InvocationRequest { /// Instance root, workspace-relative. pub(crate) root: String, pub(crate) platform: Platform, + /// Global plugin settings. Project files cannot select a local executable. + #[serde(default)] + pub(crate) settings: BTreeMap, } #[derive(Debug, Deserialize)] @@ -34,34 +41,29 @@ pub(crate) struct VersionCheckRequest { pub(crate) output: String, } -fn candidates(candidates: &[ExecutableCandidate], root: &str, platform: Platform) -> Vec { - candidates - .iter() - .filter(|candidate| { - candidate.platforms().is_empty() || candidate.platforms().contains(&platform) - }) - .map(|candidate| match candidate { - ExecutableCandidate::ProjectFile { path, requires, .. } => json!({ - "kind": "projectFile", - "path": join_relative(root, path), - "requires": requires.iter().map(|file| join_relative(root, file)).collect::>(), - "executesProjectCode": true, - }), - ExecutableCandidate::Environment { variable, path, .. } => json!({ - "kind": "environment", - "variable": variable, - "path": path, - }), - ExecutableCandidate::Command { command, .. } => json!({ - "kind": "command", - "command": command, - }), - ExecutableCandidate::Runtime { runtime, .. } => json!({ - "kind": "runtime", - "runtime": runtime, - }), - }) - .collect() +fn candidates( + candidates: &[ExecutableCandidate], + root: &str, + platform: Platform, + manifest: &DeclarativePluginManifest, + settings: &BTreeMap, +) -> Validation> { + ordered_indices(candidates, "executables")?.into_iter() + .filter(|&index| candidates[index].platforms.is_empty() || candidates[index].platforms.contains(&platform)) + .map(|index| { + let candidate = &candidates[index]; + Ok(match &candidate.source { + ExecutableSource::ProjectFile { path, requires } => json!({ + "kind": "projectFile", "path": join_relative(root, path), + "requires": requires.iter().map(|file| join_relative(root, file)).collect::>(), "executesProjectCode": true, + }), + ExecutableSource::Environment { variable, path } => json!({ "kind": "environment", "variable": variable, "path": path }), + ExecutableSource::Command { command } => json!({ "kind": "command", "command": command }), + ExecutableSource::Runtime { runtime } => json!({ "kind": "runtime", "runtime": runtime }), + ExecutableSource::Setting { setting_id } => json!({ "kind": "setting", "settingID": setting_id, + "path": manifest.executable_setting_value(setting_id, settings)? }), + }) + }).collect() } /// Arguments that make a host-managed runtime print its version and exit. @@ -78,7 +80,13 @@ pub(crate) fn host_runtime_version_arguments(id: &str) -> Option<&'static [&'sta } } -fn runtime(runtime: &RuntimeDependency, root: &str, platform: Platform) -> Value { +fn runtime( + runtime: &RuntimeDependency, + root: &str, + platform: Platform, + manifest: &DeclarativePluginManifest, + settings: &BTreeMap, +) -> Validation { let version = if runtime.is_host_managed() { host_runtime_version_arguments(&runtime.id) .map(|arguments| json!({ "arguments": arguments })) @@ -88,14 +96,14 @@ fn runtime(runtime: &RuntimeDependency, root: &str, platform: Platform) -> Value .as_ref() .map(|probe| json!({ "arguments": probe.arguments })) }; - json!({ + Ok(json!({ "id": runtime.id, "displayName": runtime.display_name, "hostManaged": runtime.is_host_managed(), "minimumVersion": runtime.minimum_version, - "executables": candidates(&runtime.executables, root, platform), + "executables": candidates(&runtime.executables, root, platform, manifest, settings)?, "version": version, - }) + })) } /// `build.invocationPlan`: the candidates for this platform, in order, with @@ -103,10 +111,21 @@ fn runtime(runtime: &RuntimeDependency, root: &str, platform: Platform) -> Value pub(crate) fn invocation_plan( build_system: &BuildSystemDeclaration, request: &InvocationRequest, + manifest: &DeclarativePluginManifest, ) -> Validation { let root = decode::request_path(&request.root, "root")?; let invocation = &build_system.invocation; - let executables = candidates(&invocation.executables, &root, request.platform); + let mut executables = candidates( + &invocation.executables, + &root, + request.platform, + manifest, + &request.settings, + )?; + // Published v2 preview manifests adapt into the same candidate path. + if let Some(id) = &invocation.executable_setting { + executables.insert(0, json!({ "kind": "setting", "settingID": id, "path": manifest.executable_setting_value(id, &request.settings)? })); + } if executables.is_empty() { return Err(PluginRejection::new( "plugin.build.unavailable", @@ -124,8 +143,8 @@ pub(crate) fn invocation_plan( "runtimeDependencies": invocation .runtime_dependencies .iter() - .map(|dependency| runtime(dependency, &root, request.platform)) - .collect::>(), + .map(|dependency| runtime(dependency, &root, request.platform, manifest, &request.settings)) + .collect::>>()?, "version": invocation.version.as_ref().map(|probe| json!({ "arguments": probe.arguments })), "minimumVersion": invocation.minimum_version, })) diff --git a/rust/lithe-core/src/build/manifest.rs b/rust/lithe-core/src/build/manifest.rs index 79cab6abbc..5a9bd8760d 100644 --- a/rust/lithe-core/src/build/manifest.rs +++ b/rust/lithe-core/src/build/manifest.rs @@ -192,6 +192,10 @@ pub(crate) enum Nesting { #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct Invocation { + /// Compatibility adapter for published buildSystems 2 previews. New + /// plugins declare a setting candidate in executables instead. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) executable_setting: Option, /// Candidates in preference order; the host takes the first that exists. pub(crate) executables: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -207,74 +211,7 @@ pub(crate) struct Invocation { pub(crate) minimum_version: Option, } -/// One place an executable may come from. -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] -#[serde( - tag = "kind", - rename_all = "camelCase", - rename_all_fields = "camelCase", - deny_unknown_fields -)] -pub(crate) enum ExecutableCandidate { - /// A file inside the instance root, such as a wrapper script. Running it - /// always counts as running project code. - ProjectFile { - path: String, - /// Further files that must exist for the candidate to apply. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - requires: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - platforms: Vec, - }, - /// A path below a directory named by a declared environment input. - Environment { - variable: String, - path: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - platforms: Vec, - }, - /// A bare command resolved through the host's tool discovery. - Command { - command: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - platforms: Vec, - }, - /// The executable of a declared runtime dependency itself. - Runtime { - runtime: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - platforms: Vec, - }, -} - -impl ExecutableCandidate { - pub(crate) fn platforms(&self) -> &[Platform] { - match self { - Self::ProjectFile { platforms, .. } - | Self::Environment { platforms, .. } - | Self::Command { platforms, .. } - | Self::Runtime { platforms, .. } => platforms, - } - } - - fn platforms_mut(&mut self) -> &mut Vec { - match self { - Self::ProjectFile { platforms, .. } - | Self::Environment { platforms, .. } - | Self::Command { platforms, .. } - | Self::Runtime { platforms, .. } => platforms, - } - } -} - -/// Host operating systems a candidate applies to; empty means all. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] -#[serde(rename_all = "lowercase")] -pub(crate) enum Platform { - Macos, - Linux, - Windows, -} +pub(crate) use crate::plugins::executables::{ExecutableCandidate, ExecutableSource, Platform}; /// A runtime the tool needs. Runtimes the host already manages (`java`, /// `node`, `go`) take only an ID and an optional minimum version and follow @@ -965,6 +902,7 @@ fn check_target_tags(tags: &mut Vec, scopes: &[Scope], field: &str) -> V /// against. pub(crate) struct DeclarationContext<'a> { pub(crate) plugin_api_version: u32, + pub(crate) build_systems_version: u32, pub(crate) official: bool, /// Setting IDs a provider's launch templates may name. pub(crate) declared_settings: &'a crate::plugins::settings::DeclaredSettings, @@ -1242,7 +1180,7 @@ impl BuildSystemDeclaration { self.validate_provider(field, context)?; self.validate_detection(field)?; let option_ids = self.validate_options(field)?; - self.validate_invocation(field, &option_ids)?; + self.validate_invocation(field, &option_ids, context)?; let model_templates = self.validate_tasks(field, &option_ids)?; self.validate_model(field, &option_ids)?; self.validate_run(field, &option_ids, &model_templates)?; @@ -1411,9 +1349,29 @@ impl BuildSystemDeclaration { Ok(ids) } - fn validate_invocation(&mut self, field: &str, options: &BTreeSet) -> Validation<()> { + fn validate_invocation( + &mut self, + field: &str, + options: &BTreeSet, + context: &DeclarationContext<'_>, + ) -> Validation<()> { let invocation = &mut self.invocation; let field = format!("{field}.invocation"); + if let Some(id) = &invocation.executable_setting { + if invocation + .executables + .iter() + .any(ExecutableCandidate::uses_resolution) + { + return Err(invalid(&field, "Do not combine the legacy executableSetting adapter with new candidate metadata or setting sources.")); + } + if context.build_systems_version < 2 || !context.declared_settings.is_executable(id) { + return Err(invalid( + format!("{field}.executableSetting"), + "An executable setting requires buildSystems 2 and a global file setting with effect reload or nextRun.", + )); + } + } if invocation.environment_inputs.len() > MAX_LIST { return Err(invalid( format!("{field}.environmentInputs"), @@ -1471,7 +1429,7 @@ impl BuildSystemDeclaration { } for (candidate_index, candidate) in runtime.executables.iter_mut().enumerate() { let candidate_field = format!("{field}.executables[{candidate_index}]"); - if matches!(candidate, ExecutableCandidate::Runtime { .. }) { + if matches!(candidate.source, ExecutableSource::Runtime { .. }) { return Err(invalid( candidate_field, "A runtime cannot be provided by another runtime.", @@ -1510,6 +1468,25 @@ impl BuildSystemDeclaration { &runtimes, )?; } + for (list, location) in + std::iter::once((&invocation.executables, format!("{field}.executables"))).chain( + invocation.runtime_dependencies.iter().map(|runtime| { + ( + &runtime.executables, + format!("{field}.runtimeDependencies.{}.executables", runtime.id), + ) + }), + ) + { + crate::plugins::executables::ordered_indices(list, &location)?; + for candidate in list { + if let ExecutableSource::Setting { setting_id } = &candidate.source { + if !context.declared_settings.is_executable(setting_id) { + return Err(invalid(&location, "A setting candidate needs a global file setting with effect reload or nextRun.")); + } + } + } + } if let Some(probe) = &invocation.version { probe.validate(&format!("{field}.version"))?; } @@ -2020,8 +1997,8 @@ fn check_candidate( environment_inputs: &[String], runtimes: &BTreeSet, ) -> Validation<()> { - match candidate { - ExecutableCandidate::ProjectFile { path, requires, .. } => { + match &mut candidate.source { + ExecutableSource::ProjectFile { path, requires, .. } => { *requires = sorted_unique(std::mem::take(requires)); if !is_safe_relative_path(path) || !requires.iter().all(|value| is_safe_relative_path(value)) @@ -2029,7 +2006,7 @@ fn check_candidate( return Err(executable_path(field)); } } - ExecutableCandidate::Environment { variable, path, .. } => { + ExecutableSource::Environment { variable, path, .. } => { if !environment_inputs.contains(variable) { return Err(invalid( format!("{field}.variable"), @@ -2040,7 +2017,7 @@ fn check_candidate( return Err(executable_path(field)); } } - ExecutableCandidate::Command { command, .. } => { + ExecutableSource::Command { command, .. } => { if !is_bare_command(command) { return Err(invalid( format!("{field}.command"), @@ -2048,7 +2025,12 @@ fn check_candidate( )); } } - ExecutableCandidate::Runtime { runtime, .. } => { + ExecutableSource::Setting { setting_id } => { + if !is_setting_identifier(setting_id) { + return Err(invalid(format!("{field}.settingID"), "Invalid setting ID.")); + } + } + ExecutableSource::Runtime { runtime, .. } => { if !runtimes.contains(runtime) { return Err(invalid( format!("{field}.runtime"), @@ -2057,7 +2039,7 @@ fn check_candidate( } } } - let platforms = candidate.platforms_mut(); + let platforms = &mut candidate.platforms; platforms.sort(); platforms.dedup(); Ok(()) diff --git a/rust/lithe-core/src/build/plans.rs b/rust/lithe-core/src/build/plans.rs index 6c5f0179a1..15e4244980 100644 --- a/rust/lithe-core/src/build/plans.rs +++ b/rust/lithe-core/src/build/plans.rs @@ -816,9 +816,11 @@ pub(crate) fn dependency_plan( /// entries then need no executable resolution of their own. fn sole_command(build_system: &BuildSystemDeclaration) -> Option<&str> { match build_system.invocation.executables.as_slice() { - [ExecutableCandidate::Command { command, platforms }] if platforms.is_empty() => { - Some(command) - } + [ExecutableCandidate { + source: super::manifest::ExecutableSource::Command { command }, + platforms, + .. + }] if platforms.is_empty() => Some(command), _ => None, } } diff --git a/rust/lithe-core/src/git/execution_events.rs b/rust/lithe-core/src/git/execution_events.rs index 8aca812c6d..027900a8d5 100644 --- a/rust/lithe-core/src/git/execution_events.rs +++ b/rust/lithe-core/src/git/execution_events.rs @@ -457,7 +457,7 @@ fn send(sink: &EventSink, operation_id: &str, kind: Kind<'_>) { } /// Sends structured native diagnostics on the same ordered request channel. -pub(super) fn emit(mut value: serde_json::Value) { +pub(crate) fn emit(mut value: serde_json::Value) { let context = CURRENT.with(|current| { current.borrow().as_ref().map(|context| { ( @@ -477,7 +477,7 @@ pub(super) fn emit(mut value: serde_json::Value) { } /// Redacts complete records, including credentials split across native chunks. -pub(super) fn redact(text: &str) -> String { +pub(crate) fn redact(text: &str) -> String { static URL_AUTH: OnceLock = OnceLock::new(); let pattern = URL_AUTH.get_or_init(|| { regex::Regex::new(r"(?i)(https?|ssh)://[^\s/@]+(?::[^\s/@]*)?@") diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index 3ea8fe84ac..cf7439add0 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -21,8 +21,11 @@ mod progress; mod rebase_session; mod rewrite; mod setup; +mod untracked_directories; pub use fetch::{plan as fetch_plan, GitFetchOptions, GitFetchPlanRequest}; +pub(crate) use untracked_directories::UNTRACKED_DIRECTORY_SUMMARY_THRESHOLD; +pub use untracked_directories::{untracked_directory, GitUntrackedDirectoryRequest}; pub use setup::{ configure_identity, initialize as initialize_repository, inspect as repository_setup, @@ -54,10 +57,10 @@ use crate::protocol::{ GitDiffHunkResponse, GitDiffResponse, GitDiffRowResponse, GitFileResponse, GitFilesResponse, GitIntegrationPreflightResponse, GitLineHistoryCommit, GitLineHistoryRange, GitLineHistoryResponse, GitLineHistoryUnmappedLine, GitOperationStateResponse, - GitPullPreflightResponse, GitPushPreviewResponse, GitPushTagResponse, GitReferenceResponse, - GitStashResponse, GitStashesResponse, GitStatusResponse, GitWatchContextResponse, - GitWorktreeResponse, GitWorktreesResponse, WorkspaceRepositoriesResponse, - WorkspaceRepositoryResponse, + GitPullPreflightResponse, GitPushPreviewResponse, GitPushTagResponse, GitPushTargetKind, + GitReferenceResponse, GitStashResponse, GitStashesResponse, GitStatusResponse, + GitWatchContextResponse, GitWorktreeResponse, GitWorktreesResponse, + WorkspaceRepositoriesResponse, WorkspaceRepositoryResponse, }; use serde::{Deserialize, Serialize}; use std::cell::RefCell; @@ -118,6 +121,11 @@ pub struct GitStatusRequest { /// Discovered native root bindings used to exclude independently owned untracked paths. #[serde(default)] pub repository_roots: Vec, + /// Report large wholly untracked directories as one entry with + /// `untrackedFileCount` instead of one entry per file. Omission preserves + /// the file-level `--untracked-files=all` projection. + #[serde(default)] + pub summarize_untracked_directories: bool, } #[derive(Debug, Deserialize)] @@ -425,6 +433,10 @@ pub struct GitPushExpectationRequest { pub remote: String, /// Destination branch name on the resolved remote. pub remote_branch: String, + /// Destination relationship recorded by the preview. Optional so an older + /// caller that predates target kinds keeps its previous behaviour. + #[serde(default)] + pub target_kind: Option, /// Locally observed destination OID, or `None` when the remote branch was absent. #[serde(default)] pub remote_tracking_oid: Option, @@ -515,6 +527,10 @@ pub struct GitWriteRequest { /// Verify submodule commits are published before a workspace branch push. #[serde(default)] pub check_submodules: bool, + /// Destination the user explicitly confirmed. Required only when the branch + /// tracks a divergent upstream; every other destination stays implicit. + #[serde(default)] + pub push_target: Option, #[serde(default)] pub auto_stash: bool, /// Mandatory immutable preview for undo, reword, squash, and drop. @@ -658,6 +674,11 @@ pub struct GitPushPreviewRequest { /// Tag scope to resolve into an immutable preview: `none`, `all`, or `reachable`. #[serde(default)] pub push_tags: Option, + /// Destination the user picked for a divergent upstream. The preview then + /// describes that exact remote branch, so a reviewed push can be bound to it. + /// Ignored when the branch is not divergent. + #[serde(default)] + pub push_target: Option, } #[derive(Debug, Deserialize)] @@ -1171,10 +1192,24 @@ fn write_with_trace(request: GitWriteRequest) -> Result { let name = validated_branch_name(&root, request.name.as_deref())?; let reference = write_request_reference(&root, &request)?; + // Creating a branch is not "checking out a remote branch": it must not + // inherit an upstream. With Git's default `branch.autoSetupMerge`, a + // branch started at `origin/main` would otherwise track `refs/heads/main`, + // and the next push would carry the explicit `refs/heads/:refs/heads/main` + // refspec straight over the remote main branch (issue #1272). Freezing the + // result with `--no-track` keeps it independent of user Git preferences, + // matching the worktree creation path. Deliberate upstream setup stays + // available through `setUpstream` and the `checkout` path. arguments = if request.checkout { - vec!["switch".into(), "-c".into(), name, reference] + vec![ + "switch".into(), + "-c".into(), + name, + "--no-track".into(), + reference, + ] } else { - vec!["branch".into(), name, reference] + vec!["branch".into(), "--no-track".into(), name, reference] }; } "publishBranch" => { @@ -1434,6 +1469,7 @@ fn write_with_trace(request: GitWriteRequest) -> Result return checkout(&root, request), @@ -1974,6 +2010,13 @@ pub fn diff(request: GitDiffRequest) -> Result { } let include_untracked_with_reference = reference.is_some() && request.untracked; + // `diff --no-index` compares one file with another, so an untracked + // directory summary is diffed as the untracked files below it. + let expands_untracked_directories = reference.is_none() + && request.commit.is_none() + && request.untracked + && request.pathspecs.iter().any(|path| path.ends_with('/')); + let lists_untracked_paths = include_untracked_with_reference || expands_untracked_directories; let mut arguments = if let Some(commit) = request.commit { validate_revision(&commit)?; vec![ @@ -2019,8 +2062,16 @@ pub fn diff(request: GitDiffRequest) -> Result { arguments.extend(request.pathspecs.clone()); let root = validate_root(&request.root)?; - let mut output = capture_git_with_options(&root, &arguments, None, true)?; - if include_untracked_with_reference { + let mut output = if expands_untracked_directories { + GitProcessOutput { + stdout: Vec::new(), + stderr: Vec::new(), + exit_code: 0, + } + } else { + capture_git_with_options(&root, &arguments, None, true)? + }; + if lists_untracked_paths { let untracked_paths = readonly_command(GitCommandRequest { root: root.clone(), arguments: vec![ @@ -2043,7 +2094,19 @@ pub fn diff(request: GitDiffRequest) -> Result { .with_details(untracked_paths.output)); } for path in untracked_paths.stdout.split('\0') { - if path.is_empty() || !is_safe_pathspec(path) { + if path.is_empty() { + continue; + } + if !is_safe_pathspec(path) { + // Shelve cleans a whole summarized directory after saving this + // patch, so a file it cannot export must fail the diff rather + // than be dropped from the patch and then deleted. + if expands_untracked_directories { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Git diff cannot export an untracked file with an unsupported name", + )); + } continue; } let mut untracked_arguments = vec![ @@ -5539,7 +5602,13 @@ fn discard_all(root: &str, paths: &[String]) -> Result>(); for path in paths { - if untracked_paths.contains(path.as_str()) { + // A summarized untracked directory (`dir/`) is reported file by file. + let is_untracked = untracked_paths.contains(path.as_str()) + || (path.ends_with('/') + && untracked_paths + .iter() + .any(|untracked| untracked.starts_with(path.as_str()))); + if is_untracked { untracked.push(path.clone()); } else { tracked.push(path.clone()); @@ -5804,8 +5873,18 @@ fn find_stash_reference(root: &str, message: &str) -> Result, Cor struct PushTarget { local_branch: String, remote: String, + /// Remote branch the push updates. For a divergent upstream this follows the + /// confirmed choice, so previews, expectation checks, leases and refspecs all + /// describe the same branch. remote_branch: String, + /// Relationship between the local branch and its upstream; independent of the + /// confirmed choice. + target_kind: PushTargetKind, + /// Whether the destination branch does not exist on the remote yet. + creates_remote_branch: bool, upstream: Option, + /// Branch name of a divergent upstream on `remote`; `None` for every other kind. + divergent_upstream_branch: Option, comparison_reference: Option, } @@ -5828,7 +5907,7 @@ pub fn push_preview(request: GitPushPreviewRequest) -> Result Result Result CoreError { + CoreError::new( + ErrorCode::InvalidRequest, + format!( + "Push destination needs confirmation: '{local_branch}' tracks '{upstream}', which has a different name. \ + Run `git push -u {remote} {local_branch}` to publish it under its own name, \ + or confirm the destination in a client that offers the choice." + ), + ) +} + fn push( root: &str, reference: Option<&str>, @@ -5876,6 +5988,7 @@ fn push( push_tags: Option<&str>, expected_push: Option<&GitPushExpectationRequest>, check_submodules: bool, + push_target: Option, ) -> Result { let tag_scope = push_tags.unwrap_or("none"); let tag_argument = match tag_scope { @@ -5889,11 +6002,28 @@ fn push( )) } }; - let target = resolve_push_target(root, reference)?; - let should_set_upstream = target.upstream.is_none(); + // Issue #1272: the effective destination is resolved once, with the caller's + // confirmed choice, by the same function the preview uses. A reviewed push is + // therefore compared against exactly the branch the user was shown. + let target = resolve_push_target(root, reference, push_target)?; + if target.target_kind == PushTargetKind::Divergent && push_target.is_none() { + return Err(push_destination_needs_confirmation( + &target.local_branch, + &target.remote, + target.upstream.as_deref().unwrap_or_default(), + )); + } if let Some(expected_push) = expected_push { validate_push_expectation(root, &target, tag_scope, expected_push)?; } + // Only an upstream that was missing or deliberately replaced is repointed after + // a successful push. Confirming "upstream" keeps the foreign branch name and + // leaves the user's tracking configuration untouched. + let should_set_upstream = match target.target_kind { + PushTargetKind::Tracking => false, + PushTargetKind::Publish => target.upstream.is_none(), + PushTargetKind::Divergent => push_target == Some(PushTargetChoiceRequest::Publish), + }; let mut arguments = vec!["push".to_string()]; if check_submodules { arguments.push("--recurse-submodules=check".into()); @@ -6375,11 +6505,21 @@ fn validate_push_expectation( .iter() .map(|tag| (&tag.full_name, &tag.object_id)) .collect::>(); + // A reviewed push records the destination relationship too, so a branch that + // started tracking `main` after the preview cannot slip through the check. + // `targetKind` is compared only when the caller supplied it: a caller that + // predates target kinds keeps its previous behaviour instead of being rejected + // as stale. + let target_kind_changed = expected + .target_kind + .as_ref() + .is_some_and(|kind| *kind != target.target_kind.as_contract()); if expected.local_branch != target.local_branch || expected.local_head != local_head || expected.remote != target.remote || expected.remote_branch != target.remote_branch || expected.remote_tracking_oid != remote_tracking_oid + || target_kind_changed || expected_tags != actual_tags { return Err(CoreError::new( @@ -6463,7 +6603,16 @@ fn resolve_push_tags( Ok(tags) } -fn resolve_push_target(root: &str, reference: Option<&str>) -> Result { +/// Resolves the single destination used by both `pushPreview` and `push`. +/// +/// `choice` only matters for a divergent upstream. Keeping the preview and the push +/// on this one function is what lets a reviewed push compare the exact remote +/// branch, tracking OID and lease the user was shown. +fn resolve_push_target( + root: &str, + reference: Option<&str>, + choice: Option, +) -> Result { let local_branch = match reference { Some(reference) => local_branch_name(&validated_reference(Some(reference))?)?, None => current_branch(root)?, @@ -6503,11 +6652,28 @@ fn resolve_push_target(root: &str, reference: Option<&str>) -> Result { + let remote_branch = match choice { + Some(PushTargetChoiceRequest::Upstream) => branch.clone(), + Some(PushTargetChoiceRequest::Publish) | None => local_branch.clone(), + }; + (PushTargetKind::Divergent, remote_branch, Some(branch)) + } + Some(branch) => (PushTargetKind::Tracking, branch, None), + None => (PushTargetKind::Publish, local_branch.clone(), None), + }; let target_reference = format!("refs/remotes/{remote}/{remote_branch}"); let comparison_reference = if reference_exists(root, &target_reference)? { Some(target_reference) @@ -6519,11 +6685,87 @@ fn resolve_push_target(root: &str, reference: Option<&str>) -> Result &str { + match choice { + PushTargetChoiceRequest::Publish => &self.local_branch, + PushTargetChoiceRequest::Upstream => &self.upstream_branch, + } + } +} + +/// Reports whether pushing `reference` needs a destination choice, using the same +/// resolution as `push` so a plan can ask before it commits anything. +pub(crate) fn divergent_push_destination( + root: &str, + reference: &str, +) -> Result, CoreError> { + let target = resolve_push_target(root, Some(reference), None)?; + Ok( + match ( + target.target_kind, + target.upstream, + target.divergent_upstream_branch, + ) { + (PushTargetKind::Divergent, Some(upstream), Some(upstream_branch)) => { + Some(DivergentPushDestination { + local_branch: target.local_branch, + remote: target.remote, + upstream, + upstream_branch, + }) + } + _ => None, + }, + ) +} + +/// Push destination relationship, mirroring [`GitPushTargetKind`] inside Core. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PushTargetKind { + /// No upstream on the selected remote; publish to the same-named branch. + Publish, + /// Upstream and local branch share a name. + Tracking, + /// Upstream branch name differs from the local branch name. + Divergent, +} + +impl PushTargetKind { + /// Contract representation returned to callers. + fn as_contract(self) -> GitPushTargetKind { + match self { + Self::Publish => GitPushTargetKind::Publish, + Self::Tracking => GitPushTargetKind::Tracking, + Self::Divergent => GitPushTargetKind::Divergent, + } + } +} + fn read_git_config_value(root: &str, key: &str) -> Result, CoreError> { // This is an internal preflight, not a user command. A missing optional // value returns 1 normally and must not appear as a failed console action. @@ -7642,6 +7884,14 @@ pub fn status(request: GitStatusRequest) -> Result .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string()) .filter(|branch| !branch.is_empty()) .or_else(|| Some("detached".to_string())); + // `normal` lets Git stop at each wholly untracked directory instead of + // enumerating every file below it; the projection below restores file + // entries for directories that are small or contain nested repositories. + let untracked_files = if request.summarize_untracked_directories { + "--untracked-files=normal" + } else { + "--untracked-files=all" + }; let status_output = run_git( &repository_root, &[ @@ -7651,7 +7901,7 @@ pub fn status(request: GitStatusRequest) -> Result "--porcelain=v2", "--ignore-submodules=none", "-z", - "--untracked-files=all", + untracked_files, ], )?; if !status_output.status.success() { @@ -7661,23 +7911,14 @@ pub fn status(request: GitStatusRequest) -> Result ); } let mut changes = parse_status(&status_output.stdout, request.include_index_only_changes); - // Native bindings may use macOS /var aliases or Windows short/verbatim - // paths. Compare the same canonical identity as Git's reported root. - let known_roots = request - .repository_roots - .iter() - .map(|root| canonicalize_or_original(Path::new(root)).map_err(git_path_error)) - .collect::, _>>()?; - // An embedded independent repository must not become a gitlink through a - // parent Stage All. Gitlinks already tracked by the parent remain visible. - changes.retain(|change| { - !change.untracked - || !known_roots.iter().any(|other| { - other != &repository_root - && other.starts_with(&repository_root) - && repository_root.join(&change.path).starts_with(other) - }) - }); + if request.summarize_untracked_directories { + changes = untracked_directories::expand_collapsed_directories(&repository_root, changes)?; + } + untracked_directories::retain_outside_known_roots( + &mut changes, + &repository_root, + &request.repository_roots, + )?; let (ahead, behind) = tracking_counts(&repository_root); Ok(GitStatusResponse { repository_root: Some(relative_or_absolute(&repository_root, &root)), @@ -7883,6 +8124,7 @@ fn parse_status(output: &[u8], include_index_only_changes: bool) -> Vec, +} + +/// One untracked record produced while projecting a directory. +#[derive(Debug, Clone, PartialEq, Eq)] +enum UntrackedEntry { + File(String), + /// A nested repository, reported by Git as its directory with a trailing `/`. + NestedRepository(String), + /// A directory without nested repositories and the number of files below it. + Directory { + path: String, + file_count: usize, + }, +} + +impl UntrackedEntry { + fn into_change(self) -> GitChange { + let (path, untracked_file_count) = match self { + Self::File(path) | Self::NestedRepository(path) => (path, None), + Self::Directory { path, file_count } => (path, Some(file_count)), + }; + // Same projection `parse_status` gives a `?` record. + GitChange { + path, + original_path: None, + status: "??".to_string(), + staged: false, + worktree: false, + untracked: true, + submodule: None, + can_toggle_staging: true, + untracked_file_count, + } + } +} + +/// Replaces the collapsed `dir/` records of a `--untracked-files=normal` status +/// with file entries, nested repositories, or summaries. +/// +/// `changes` must come from a `normal` status of `repository_root`; its untracked +/// directories are disjoint, so each listed file belongs to exactly one of them. +pub(super) fn expand_collapsed_directories( + repository_root: &Path, + changes: Vec, +) -> Result, CoreError> { + let directories = changes + .iter() + .filter(|change| change.untracked && change.path.ends_with('/')) + .map(|change| change.path.clone()) + .collect::>(); + if directories.is_empty() { + return Ok(changes); + } + let indices = directories + .iter() + .enumerate() + .map(|(index, directory)| (directory.as_str(), index)) + .collect::>(); + let mut contents = vec![Vec::new(); directories.len()]; + for path in list_untracked(repository_root, &directories)? { + let owner = owning_directory(&path).find_map(|prefix| indices.get(prefix).copied()); + if let Some(owner) = owner { + contents[owner].push(path); + } + } + + let mut expanded = changes + .into_iter() + .filter(|change| !(change.untracked && change.path.ends_with('/'))) + .collect::>(); + for (directory, contents) in directories.iter().zip(&contents) { + let mut entries = Vec::new(); + summarize( + directory, + contents, + UNTRACKED_DIRECTORY_SUMMARY_THRESHOLD, + &mut entries, + ); + expanded.extend(entries.into_iter().map(UntrackedEntry::into_change)); + } + expanded.sort_by(|left, right| left.path.cmp(&right.path)); + Ok(expanded) +} + +/// Lists the immediate files and subdirectories of one summarized directory. +/// +/// Every subdirectory is returned as a summary, so expanding a tree costs one +/// level per request regardless of how many files lie below it. +pub fn untracked_directory( + request: GitUntrackedDirectoryRequest, +) -> Result { + if !request.directory.ends_with('/') || !is_safe_pathspec(&request.directory) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Untracked directory must be a repository-relative path ending with /", + )); + } + let root = canonicalize_simplified(Path::new(&request.root)).map_err(git_path_error)?; + let output = run_git(&root, &["rev-parse", "--show-toplevel"])?; + if !output.status.success() { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Git repository lookup failed") + .with_details(String::from_utf8_lossy(&output.stderr)), + ); + } + let repository_root = canonicalize_or_original(&PathBuf::from( + String::from_utf8_lossy(&output.stdout).trim_end_matches(['\r', '\n']), + )) + .map_err(git_path_error)?; + + let entries = list_untracked(&repository_root, std::slice::from_ref(&request.directory))?; + let mut children = Vec::new(); + for (child, group) in immediate_children(&request.directory, &entries) { + match child { + Child::File(path) => children.push(UntrackedEntry::File(path)), + Child::NestedRepository(path) => children.push(UntrackedEntry::NestedRepository(path)), + // A zero threshold summarizes every subdirectory that is safe to + // collapse and splits one that contains a nested repository. + Child::Directory(path) => summarize(&path, group, 0, &mut children), + } + } + let mut changes = children + .into_iter() + .map(UntrackedEntry::into_change) + .collect::>(); + retain_outside_known_roots(&mut changes, &repository_root, &request.repository_roots)?; + changes.sort_by(|left, right| left.path.cmp(&right.path)); + Ok(GitUntrackedDirectoryResponse { changes }) +} + +/// Removes untracked entries owned by another discovered repository. +/// +/// An embedded independent repository must not become a gitlink through a +/// parent Stage All. Gitlinks already tracked by the parent remain visible. +pub(super) fn retain_outside_known_roots( + changes: &mut Vec, + repository_root: &Path, + repository_roots: &[String], +) -> Result<(), CoreError> { + // Native bindings may use macOS /var aliases or Windows short/verbatim + // paths. Compare the same canonical identity as Git's reported root. + let known_roots = repository_roots + .iter() + .map(|root| canonicalize_or_original(Path::new(root)).map_err(git_path_error)) + .collect::, _>>()?; + changes.retain(|change| { + !change.untracked + || !known_roots.iter().any(|other| { + other != repository_root + && other.starts_with(repository_root) + && repository_root.join(&change.path).starts_with(other) + }) + }); + Ok(()) +} + +/// Returns every non-ignored untracked file below `directories`, plus nested +/// repositories as `path/` records, exactly as `--untracked-files=all` would. +fn list_untracked( + repository_root: &Path, + directories: &[String], +) -> Result, CoreError> { + let mut paths = Vec::new(); + for batch in pathspec_batches(directories, LS_FILES_PATHSPEC_BUDGET) { + let mut arguments = vec![ + "--literal-pathspecs", + "ls-files", + "-z", + "--others", + "--exclude-standard", + "--", + ]; + arguments.extend(batch.iter().map(String::as_str)); + let output = run_git(repository_root, &arguments)?; + if !output.status.success() { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Git untracked file listing failed", + ) + .with_details(String::from_utf8_lossy(&output.stderr))); + } + paths.extend( + output + .stdout + .split(|byte| *byte == 0) + .filter(|record| !record.is_empty()) + .map(|record| String::from_utf8_lossy(record).into_owned()), + ); + } + paths.sort(); + Ok(paths) +} + +/// Yields each directory prefix of `path` with a trailing `/`, outermost first. +/// A nested repository record `dir/` yields `dir/` itself last. +fn owning_directory(path: &str) -> impl Iterator { + path.match_indices('/') + .map(move |(index, _)| &path[..=index]) +} + +/// Emits the entries for `directory`, whose contents are `entries`. +/// +/// A directory without nested repositories becomes one summary when it holds +/// more than `threshold` files and stays file-level otherwise. A directory that +/// contains a nested repository is split one level further, because acting on +/// its path would also act on that repository. +fn summarize( + directory: &str, + entries: &[String], + threshold: usize, + output: &mut Vec, +) { + if entries.is_empty() { + return; + } + if !entries.iter().any(|entry| entry.ends_with('/')) { + if entries.len() > threshold { + output.push(UntrackedEntry::Directory { + path: directory.to_string(), + file_count: entries.len(), + }); + } else { + output.extend(entries.iter().cloned().map(UntrackedEntry::File)); + } + return; + } + for (child, group) in immediate_children(directory, entries) { + match child { + Child::File(path) => output.push(UntrackedEntry::File(path)), + Child::NestedRepository(path) => output.push(UntrackedEntry::NestedRepository(path)), + Child::Directory(path) => summarize(&path, group, threshold, output), + } + } +} + +/// One immediate child of a directory being projected. +enum Child { + File(String), + NestedRepository(String), + Directory(String), +} + +/// Groups sorted `entries` below `directory` by their immediate child. +/// +/// A `Child::Directory` is paired with every entry below it; other children are +/// paired with an empty slice. +fn immediate_children<'a>(directory: &str, entries: &'a [String]) -> Vec<(Child, &'a [String])> { + let mut children = Vec::new(); + let mut index = 0; + while index < entries.len() { + let entry = &entries[index]; + let remainder = entry.strip_prefix(directory).unwrap_or(entry); + match remainder.find('/') { + // `directory` itself is a nested repository. + _ if remainder.is_empty() => { + children.push(( + Child::NestedRepository(entry.clone()), + &entries[index..index], + )); + index += 1; + } + None => { + children.push((Child::File(entry.clone()), &entries[index..index])); + index += 1; + } + Some(slash) if slash + 1 == remainder.len() => { + children.push(( + Child::NestedRepository(entry.clone()), + &entries[index..index], + )); + index += 1; + } + Some(slash) => { + let child = format!("{directory}{}", &remainder[..=slash]); + let end = index + + entries[index..] + .iter() + .take_while(|candidate| candidate.starts_with(&child)) + .count(); + children.push((Child::Directory(child), &entries[index..end])); + index = end; + } + } + } + children +} + +#[cfg(test)] +mod tests { + use super::{immediate_children, owning_directory, summarize, Child, UntrackedEntry}; + + fn paths(values: &[&str]) -> Vec { + let mut values = values + .iter() + .map(|value| value.to_string()) + .collect::>(); + values.sort(); + values + } + + fn summarized(directory: &str, entries: &[&str], threshold: usize) -> Vec { + let mut output = Vec::new(); + summarize(directory, &paths(entries), threshold, &mut output); + output + } + + #[test] + fn large_directory_without_nested_repositories_becomes_one_summary() { + assert_eq!( + summarized( + "target/", + &["target/a", "target/debug/b", "target/debug/c"], + 2 + ), + vec![UntrackedEntry::Directory { + path: "target/".into(), + file_count: 3 + }] + ); + } + + #[test] + fn directory_within_threshold_keeps_file_entries() { + // A new source folder must still list its files like `-uall`. + assert_eq!( + summarized("src/feature/", &["src/feature/a.rs", "src/feature/b.rs"], 2), + vec![ + UntrackedEntry::File("src/feature/a.rs".into()), + UntrackedEntry::File("src/feature/b.rs".into()), + ] + ); + } + + #[test] + fn directory_containing_a_nested_repository_is_never_summarized() { + // Staging `.worktrees/` would record every linked worktree as a gitlink. + assert_eq!( + summarized( + ".worktrees/", + &[ + ".worktrees/a/", + ".worktrees/b/", + ".worktrees/notes.txt", + ".worktrees/cache/x", + ".worktrees/cache/y", + ".worktrees/cache/z", + ], + 2 + ), + vec![ + UntrackedEntry::NestedRepository(".worktrees/a/".into()), + UntrackedEntry::NestedRepository(".worktrees/b/".into()), + UntrackedEntry::Directory { + path: ".worktrees/cache/".into(), + file_count: 3 + }, + UntrackedEntry::File(".worktrees/notes.txt".into()), + ] + ); + } + + #[test] + fn nested_repository_deeper_in_the_tree_splits_only_its_ancestors() { + assert_eq!( + summarized( + "mixed/", + &["mixed/m.txt", "mixed/sub/q.txt", "mixed/sub/repo/"], + 0 + ), + vec![ + UntrackedEntry::File("mixed/m.txt".into()), + UntrackedEntry::File("mixed/sub/q.txt".into()), + UntrackedEntry::NestedRepository("mixed/sub/repo/".into()), + ] + ); + } + + #[test] + fn collapsed_record_that_is_itself_a_repository_stays_a_repository() { + assert_eq!( + summarized("inner/", &["inner/"], 0), + vec![UntrackedEntry::NestedRepository("inner/".into())] + ); + } + + #[test] + fn immediate_children_group_descendants_under_their_first_component() { + let entries = paths(&["d/a-b/x", "d/a/y", "d/a/z", "d/f", "d/r/"]); + let children = immediate_children("d/", &entries) + .into_iter() + .map(|(child, group)| match child { + Child::File(path) => format!("file {path}"), + Child::NestedRepository(path) => format!("repo {path}"), + Child::Directory(path) => format!("dir {path} {}", group.len()), + }) + .collect::>(); + assert_eq!( + children, + ["dir d/a-b/ 1", "dir d/a/ 2", "file d/f", "repo d/r/"] + ); + } + + #[test] + fn owning_directories_include_a_repository_record_itself() { + assert_eq!( + owning_directory("a/b/c").collect::>(), + ["a/", "a/b/"] + ); + assert_eq!(owning_directory("a/r/").collect::>(), ["a/", "a/r/"]); + } +} diff --git a/rust/lithe-core/src/git/workspace_commit.rs b/rust/lithe-core/src/git/workspace_commit.rs index 40fda47962..26269c7d1d 100644 --- a/rust/lithe-core/src/git/workspace_commit.rs +++ b/rust/lithe-core/src/git/workspace_commit.rs @@ -4,7 +4,10 @@ mod path_scope; pub use path_scope::PathScope; -use super::{commit_state, GitCommitGitlink, GitCommitState, GitWriteRequest}; +use super::{ + commit_state, DivergentPushDestination, GitCommitGitlink, GitCommitState, GitWriteRequest, + PushTargetChoiceRequest, +}; use crate::protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; @@ -31,6 +34,41 @@ pub struct Relation { pub path: String, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +/// A divergent push destination the user confirmed for one repository. +/// +/// The confirmation names the exact remote branch the user was shown, so a tracking +/// configuration that changes before the push is detected instead of followed. +pub struct ConfirmedPushTarget { + /// Which of the two offered destinations the user picked. + pub choice: PushTargetChoiceRequest, + /// Remote the destination lives on. + pub remote: String, + /// Remote branch the push is allowed to update. + pub remote_branch: String, +} + +impl ConfirmedPushTarget { + /// Whether this confirmation still describes what `destination` would push to now. + fn matches(&self, destination: &DivergentPushDestination) -> bool { + self.remote == destination.remote + && self.remote_branch == destination.remote_branch_for(self.choice) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +/// A repository in the plan whose branch tracks a differently named remote branch +/// and has no still-valid confirmation. Clients answer with a [`ConfirmedPushTarget`]. +pub struct PushTargetQuestion { + /// Workspace-relative repository identity, matching [`RepositoryBinding::id`]. + pub repository_id: String, + /// The two destinations on offer. + #[serde(flatten)] + pub destination: DivergentPushDestination, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] /// Exact repositories, options, and Git state presented for review. @@ -39,6 +77,11 @@ pub struct Plan { pub message: String, pub amend: bool, pub push: bool, + /// Confirmed destinations by repository ID, only for branches whose upstream has + /// a different name. Each repository is guarded separately because two + /// repositories in one batch can track unrelated branches. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub push_targets: BTreeMap, pub include_parent_references: bool, /// Optional local selection guard; omitted by clients without changelists. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -92,6 +135,10 @@ pub struct PrepareRequest { pub amend: bool, #[serde(default)] pub push: bool, + /// Destinations the user confirmed by repository ID, answering earlier + /// [`PushTargetQuestion`]s. A retry also inherits the previous plan's entries. + #[serde(default)] + pub push_targets: BTreeMap, pub include_parent_references: bool, /// Restricts commits without rewriting the user's index. #[serde(default)] @@ -109,6 +156,13 @@ pub struct Preparation { pub session: Session, pub review_changed: bool, pub requires_confirmation: bool, + /// Pushes that still need a destination choice. While this is non-empty the + /// session must not be stepped: committing first would leave the push unable to + /// proceed. Core also refuses the step of an unanswered repository, so a client + /// that ignores this field fails before it writes that repository's commit. Omitted when empty so routine + /// workflows keep the exact wire shape older clients already decode. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub push_target_questions: Vec, } #[derive(Debug, Deserialize)] @@ -156,12 +210,17 @@ pub fn prepare(mut request: PrepareRequest) -> Result { commit_state::inspect_root(&binding.root)?, ); } - build_plan(request, states) + build_plan_with(request, states, &super::divergent_push_destination) } -fn build_plan( +/// Looks up whether pushing `(root, branch reference)` needs a destination choice. +type DestinationResolver<'a> = + &'a dyn Fn(&str, &str) -> Result, CoreError>; + +fn build_plan_with( request: PrepareRequest, states: BTreeMap, + resolve_destination: DestinationResolver<'_>, ) -> Result { let message = request.message.trim().to_string(); if message.is_empty() { @@ -313,11 +372,25 @@ fn build_plan( .collect(), &dependencies, )?; + let (push_targets, push_target_questions) = if request.push { + settle_push_targets( + &request, + previous, + &order, + &states, + &pending_push, + &results, + resolve_destination, + )? + } else { + Default::default() + }; let plan = Plan { repositories: request.repositories, message, amend: request.amend, push: request.push, + push_targets, include_parent_references: request.include_parent_references, path_scope, is_retry: previous.is_some(), @@ -363,9 +436,67 @@ fn build_plan( }, review_changed, requires_confirmation, + push_target_questions, }) } +/// Splits the repositories that will push into confirmed destinations and open +/// questions. A confirmation is reused only while it still names the branch the +/// repository would push to, so a retry after the upstream moved asks again. +fn settle_push_targets( + request: &PrepareRequest, + previous: Option<&Session>, + order: &[String], + states: &BTreeMap, + pending_push: &BTreeSet, + results: &BTreeMap, + resolve_destination: DestinationResolver<'_>, +) -> Result< + ( + BTreeMap, + Vec, + ), + CoreError, +> { + let mut confirmed = BTreeMap::new(); + let mut questions = Vec::new(); + for id in order { + let already_pushed = + results.get(id).is_some_and(|r| r.pushed) && !pending_push.contains(id); + let Some(branch) = states.get(id).and_then(|state| state.branch.as_deref()) else { + // A detached HEAD cannot push; the push step reports that on its own. + continue; + }; + let Some(binding) = request.repositories.iter().find(|b| &b.id == id) else { + continue; + }; + if already_pushed { + continue; + } + // A destination that cannot be resolved (for example a repository without any + // remote) is not a question for the user. The push step reports that failure + // with Git's own context, and `push` still refuses an unconfirmed divergent + // destination if resolution recovers in between. + let Ok(Some(destination)) = resolve_destination(&binding.root, branch) else { + continue; + }; + let answer = request + .push_targets + .get(id) + .or_else(|| previous.and_then(|s| s.plan.push_targets.get(id))); + match answer.filter(|answer| answer.matches(&destination)) { + Some(answer) => { + confirmed.insert(id.clone(), answer.clone()); + } + None => questions.push(PushTargetQuestion { + repository_id: id.clone(), + destination, + }), + } + } + Ok((confirmed, questions)) +} + fn relations( bindings: &[RepositoryBinding], states: &BTreeMap, @@ -599,6 +730,14 @@ fn prepare_step( expected_commit_state: Some(expected.clone()), ..Default::default() }; + // Checked before the commit as well as before the push: a plan that cannot push + // must not write a commit it then leaves behind. This also protects clients + // that never learned to answer `pushTargetQuestions`. + let confirmed_push_target = if session.plan.push { + confirmed_push_choice(session, id, root)? + } else { + None + }; if !session.results[id].committed { if !expected.conflicted_paths.is_empty() { return Err(invalid("Resolve conflicts before committing")); @@ -643,11 +782,41 @@ fn prepare_step( invalid("Committed; branch changed or detached. Review and retry push.") })?); request.check_submodules = true; + request.push_target = confirmed_push_target; } else { return Ok(None); } Ok(Some(request)) } +/// Returns the confirmed destination choice for a divergent branch, `None` when the +/// branch needs no choice, and an error when the choice is missing or no longer +/// describes the branch that would be updated. +fn confirmed_push_choice( + session: &Session, + id: &str, + root: &str, +) -> Result, CoreError> { + let Some(branch) = session.states[id].branch.as_deref() else { + return Ok(None); + }; + // Unresolvable destinations are reported by the push step itself; see + // `settle_push_targets`. + let Ok(Some(destination)) = super::divergent_push_destination(root, branch) else { + return Ok(None); + }; + match session.plan.push_targets.get(id) { + Some(confirmed) if confirmed.matches(&destination) => Ok(Some(confirmed.choice)), + Some(_) => Err(invalid( + "The push destination changed after it was confirmed; review and retry", + )), + None => Err(super::push_destination_needs_confirmation( + &destination.local_branch, + &destination.remote, + &destination.upstream, + )), + } +} + #[cfg(test)] mod tests; diff --git a/rust/lithe-core/src/git/workspace_commit/tests.rs b/rust/lithe-core/src/git/workspace_commit/tests.rs index 406228d16f..ce7e208e29 100644 --- a/rust/lithe-core/src/git/workspace_commit/tests.rs +++ b/rust/lithe-core/src/git/workspace_commit/tests.rs @@ -2,6 +2,14 @@ use super::*; use serde_json::json; +/// Plans without consulting Git: none of these fixtures has a divergent upstream. +fn build_plan( + request: PrepareRequest, + states: BTreeMap, +) -> Result { + build_plan_with(request, states, &|_, _| Ok(None)) +} + fn state(staged: &[&str], links: &[&str]) -> GitCommitState { GitCommitState { head: Some("initial".into()), diff --git a/rust/lithe-core/src/lib.rs b/rust/lithe-core/src/lib.rs index a29c869700..dd25d65386 100644 --- a/rust/lithe-core/src/lib.rs +++ b/rust/lithe-core/src/lib.rs @@ -18,8 +18,11 @@ mod mcp; pub mod plugins; pub mod process; mod project; +mod project_generators; mod protocol; +mod remote; mod runtime; +mod vcs; pub use protocol::{ CoreCommand, CoreError, CoreEvent, CoreRequest, CoreResponse, ErrorCode, ResponseData, diff --git a/rust/lithe-core/src/lsp/interface/client.rs b/rust/lithe-core/src/lsp/interface/client.rs index 348be598d6..b56d5918dd 100644 --- a/rust/lithe-core/src/lsp/interface/client.rs +++ b/rust/lithe-core/src/lsp/interface/client.rs @@ -532,6 +532,38 @@ pub(super) fn client_apply_server_message_for_plugin( responses.push(json_rpc_result(id, Value::Null)?); } } + _ if plugin_owned && request_id.is_some() && method.starts_with("lithe/host/") => { + let id = request_id.expect("guarded above"); + match plugin_function::host_request( + method, + message.get("params"), + state.plugin_function_version.unwrap_or(0), + &state.host_services, + ) { + // The engine answers once the host has; the request + // carries the backend's own ID back to it. + Ok(service) => events.push(LspClientEvent { + kind: "hostRequest".to_string(), + request_id: None, + method: Some(method.to_string()), + uri: None, + version: None, + diagnostics: None, + result: Some(json!({ + "id": id, + "service": service, + "params": message.get("params").cloned().unwrap_or(Value::Null), + })), + error: None, + }), + Err(refusal) => responses.push(json_rpc_error_with_code( + id, + refusal.rpc_code, + refusal.message, + refusal.code, + )?), + } + } _ => { if let Some(id) = request_id { responses.push(match plugin_function::refused_backend_request(method) { @@ -575,8 +607,9 @@ pub(super) fn client_apply_server_message_for_plugin( ) }) .flatten(); - if let Some(functions) = functions { + if let Some((version, functions)) = functions { state.plugin_functions = functions; + state.plugin_function_version = Some(version); events.push(LspClientEvent { kind: "functionsChanged".to_string(), request_id: None, @@ -589,10 +622,35 @@ pub(super) fn client_apply_server_message_for_plugin( }); } else { state.plugin_functions.clear(); + state.plugin_function_version = None; state .server_capabilities .retain(|feature| feature != plugin_function::FUNCTIONS_FEATURE); } + // Only a plugin's own process may act as a VCS provider; a + // host-owned server that happens to answer is ignored. + state.vcs_provider = plugin_owned + .then(|| { + super::vcs_provider::negotiate( + result.get("capabilities").unwrap_or(&Value::Null), + ) + }) + .flatten(); + match &state.vcs_provider { + Some(negotiation) => events.push(LspClientEvent { + kind: super::vcs_provider::NEGOTIATED_EVENT.to_string(), + request_id: None, + method: Some("initialize".to_string()), + uri: None, + version: None, + diagnostics: None, + result: Some(json!(negotiation)), + error: None, + }), + None => state + .server_capabilities + .retain(|feature| feature != super::vcs_provider::VCS_PROVIDER_FEATURE), + } state.text_document_sync = text_document_sync_kind(result.get("capabilities").unwrap_or(&Value::Null)); if let Some(options) = result @@ -619,6 +677,8 @@ pub(super) fn client_apply_server_message_for_plugin( state.diagnostics.clear(); state.diagnostic_versions.clear(); state.plugin_functions.clear(); + state.plugin_function_version = None; + state.vcs_provider = None; responses.push(json_rpc_message_without_params(None, "exit")?); } let result = if pending.as_deref() == Some("textDocument/semanticTokens/full") { @@ -709,7 +769,7 @@ fn json_rpc_message_without_params(id: Option<&str>, method: &str) -> Result Result { +pub(super) fn json_rpc_result(id: &Value, result: Value) -> Result { encode_json_rpc(json!({ "jsonrpc": "2.0", "id": id, @@ -724,12 +784,14 @@ fn experimental_capabilities(plugin_owned: bool) -> Value { if plugin_owned { let (functions_key, functions_offer) = plugin_function::client_experimental_capabilities(); experimental[functions_key] = functions_offer; + let (vcs_key, vcs_offer) = super::vcs_provider::client_experimental_capabilities(); + experimental[vcs_key] = vcs_offer; } experimental } /// A JSON-RPC error whose `data.code` carries a stable plugin-contract code. -fn json_rpc_error_with_code( +pub(super) fn json_rpc_error_with_code( id: &Value, code: i64, message: &str, @@ -1671,6 +1733,9 @@ fn feature_names_from_capabilities(capabilities: &Value) -> Vec { if plugin_function::negotiate(capabilities).is_some() { insert_unique(&mut values, plugin_function::FUNCTIONS_FEATURE); } + if super::vcs_provider::negotiate(capabilities).is_some() { + insert_unique(&mut values, super::vcs_provider::VCS_PROVIDER_FEATURE); + } values } diff --git a/rust/lithe-core/src/lsp/interface/engine.rs b/rust/lithe-core/src/lsp/interface/engine.rs index 32eb50caa4..60b26ce11b 100644 --- a/rust/lithe-core/src/lsp/interface/engine.rs +++ b/rust/lithe-core/src/lsp/interface/engine.rs @@ -1,6 +1,9 @@ //! Stateful language-server sessions coordinating client state and child processes. -use super::client::{client_apply_server_message_for_plugin, client_initialize_for_plugin}; +use super::client::{ + client_apply_server_message_for_plugin, client_initialize_for_plugin, json_rpc_error_with_code, + json_rpc_result, +}; use super::process::{LspProcessHandle, LspProcessLauncher, LspProcessSpec, SystemProcessLauncher}; use super::{ client_change_document, client_close_document, client_feature_request_canonical, @@ -59,6 +62,10 @@ const DEFAULT_SHUTDOWN_TIMEOUT_MS: u64 = 2_000; /// large build, so it may outlive the ordinary request timeout, but never /// stays pending without a deadline. const MAX_BUILD_PROVIDER_TIMEOUT_MS: u64 = 60 * 60_000; +/// Upper bound of one VCS-provider request. An update or check-in of a large +/// working copy over a slow link can take long, but never stays pending +/// without a deadline. +const MAX_VCS_PROVIDER_TIMEOUT_MS: u64 = 60 * 60_000; const MONITOR_INTERVAL_MS: u64 = 10; /// JSON-RPC method carrying Java Debug Server build commands. const JAVA_BUILD_METHOD: &str = "workspace/executeCommand"; @@ -95,6 +102,12 @@ pub struct StartServerRequest { /// host-owned servers leave it out. #[serde(default)] pub plugin_id: Option, + /// Host services the plugin's permissions grant the session, as the + /// launch plan lists them (`backend.hostServices`). Only a plugin-owned + /// session may name any; Core forwards only these `lithe/host/*` + /// requests to the host. + #[serde(default)] + pub host_services: Vec, pub executable_path: String, #[serde(default)] pub arguments: Vec, @@ -390,6 +403,63 @@ pub struct BuildProviderRequest { pub timeout_milliseconds: Option, } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// One VCS-provider request. Core plans the method and params from the +/// operation and its arguments, so the host never writes a raw method. The +/// result arrives as a `requestCompleted` event whose `result.value` is the +/// provider's reply, which the host passes to `vcs.decodeReply`; a failure +/// carries a `vcs.*` code. +pub struct VcsProviderRequest { + pub session_id: String, + #[serde(default)] + pub operation_id: Option, + /// The VCS the host launched the session for; a session that negotiated + /// another VCS is refused. + pub vcs: String, + /// Operation name, such as `status` or `checkin`. + pub operation: String, + /// Native absolute path of the working-copy root. + pub root: String, + /// Absolute path of the VCS client the host resolved, when the provider + /// declares one. + #[serde(default)] + pub tool: Option, + /// Operation arguments; see `crate::vcs` for each operation's shape. + #[serde(default)] + pub arguments: Value, + /// Deadline of this request, in milliseconds, capped at + /// `MAX_VCS_PROVIDER_TIMEOUT_MS`; the session's request timeout when absent. + #[serde(default)] + pub timeout_milliseconds: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// The host's answer to one `hostRequest` event: the outcome of a host +/// service a plugin backend asked for. Exactly one of `result` and `error`. +pub struct PluginHostResponse { + pub session_id: String, + /// The `hostRequestID` of the `hostRequest` event being answered, + /// spelled as the event spells it. + #[serde(rename = "hostRequestID")] + pub host_request_id: String, + /// The service's result; `null` for a write or delete. + #[serde(default)] + pub result: Option, + /// The host could not perform the service. Its message reaches the + /// backend, bounded; the code is always `plugin.host.failed`. + #[serde(default)] + pub error: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// Why the host could not perform a forwarded host-service request. +pub struct PluginHostFailure { + pub message: String, +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] /// One plugin-function invocation. The result arrives as a `requestCompleted` @@ -657,6 +727,10 @@ struct SessionState { deadline_cancellations: Vec, /// Bounds an outbound maintenance write independently of caller deadlines. maintenance_write_deadline: Option, + /// Backend requests for a host service awaiting `lsp.hostResponse`, by + /// the opaque ID the host sees, with the backend's own JSON-RPC ID. + host_requests: BTreeMap, + next_host_request_id: u64, } struct RuntimeSession { @@ -680,6 +754,9 @@ struct RuntimeSession { plugin_configuration: Mutex>>, /// JDKs JDT LS may bind projects to, one per execution environment. jdt_java_runtimes: Vec, + /// Plugin completion options fixed at initialize, retained for later Java + /// configuration notifications and section pulls through the same adapter. + jdt_completion: Option, /// Gradle import settings JDT LS holds. Fixed for the session's lifetime /// because the wrapper is a property of the workspace root at startup. jdt_gradle_configuration: Option, @@ -925,6 +1002,7 @@ impl RuntimeSession { /// Java settings Lithe owns for this session, as sent to JDT LS. fn jdt_settings<'a>(&'a self, maven: Option<&'a JdtMavenConfiguration>) -> JdtSettings<'a> { JdtSettings { + completion: self.jdt_completion.as_ref(), maven, gradle: self.jdt_gradle_configuration.as_ref(), java_runtimes: &self.jdt_java_runtimes, @@ -1222,6 +1300,26 @@ pub fn build_provider_request( Ok(OperationResponse { operation_id }) } +/// Sends one VCS-provider request to a session that negotiated the protocol. +pub fn vcs_provider_request(request: VcsProviderRequest) -> Result { + let operation_id = request + .operation_id + .clone() + .unwrap_or_else(|| engine().next_operation_id()); + engine() + .session(&request.session_id)? + .vcs_request(request, operation_id.clone())?; + Ok(OperationResponse { operation_id }) +} + +/// Answers one `hostRequest` event of a plugin backend with the outcome of +/// the host service it asked for. +pub fn plugin_host_response(request: PluginHostResponse) -> Result<(), CoreError> { + engine() + .session(&request.session_id)? + .host_response(request) +} + /// Invokes one plugin function on a session of the function's plugin. pub fn plugin_function_request( request: PluginFunctionRequest, @@ -1505,6 +1603,12 @@ impl LspEngine { let java_build_timeout = Duration::from_millis(request.java_build_timeout_milliseconds); let shutdown_timeout = Duration::from_millis(request.shutdown_timeout_milliseconds); let jdt_java_runtimes = jdt_java_runtimes(&request.java_runtimes); + let jdt_completion = request + .initialization_options + .as_ref() + .and_then(|options| options.pointer("/settings/java/completion")) + .filter(|completion| completion.is_object()) + .cloned(); let workspace_configuration = checked_workspace_configuration(request.workspace_configuration)?; let initialize = client_initialize_for_plugin( @@ -1517,6 +1621,7 @@ impl LspEngine { request.initialization_options, &java_extension_bundle_paths, JdtSettings { + completion: jdt_completion.as_ref(), maven: jdt_maven_configuration.as_deref(), gradle: request.java_gradle.as_ref(), java_runtimes: &jdt_java_runtimes, @@ -1548,12 +1653,16 @@ impl LspEngine { maven_update_order: Mutex::new(()), plugin_configuration: Mutex::new(workspace_configuration.map(Arc::new)), jdt_java_runtimes, + jdt_completion, jdt_gradle_configuration: request.java_gradle, root_uri: request.root_uri, outbound_order: Mutex::new(()), state: Mutex::new(SessionState { lifecycle: LspLifecycleState::Created, - client: initialize.state, + client: LspClientState { + host_services: request.host_services.into_iter().collect(), + ..initialize.state + }, pending: BTreeMap::from([( request_id, PendingRequest { @@ -1597,6 +1706,8 @@ impl LspEngine { java_build_timeout, deadline_cancellations: Vec::new(), maintenance_write_deadline: None, + host_requests: BTreeMap::new(), + next_host_request_id: 1, }), event_signal: Condvar::new(), process: process.handle, @@ -2021,6 +2132,84 @@ impl RuntimeSession { self.send_request_or_fail(&outbound_order, messages, &request_id) } + /// Writes a VCS-provider request planned by `crate::vcs::plan`, which + /// checks the operation's arguments and negotiated capability. + fn vcs_request( + &self, + request: VcsProviderRequest, + operation_id: String, + ) -> Result<(), CoreError> { + let operation = crate::vcs::Operation::parse(&request.operation).ok_or_else(|| { + CoreError::new(ErrorCode::InvalidRequest, "Unknown VCS operation") + .with_details(request.operation.clone()) + })?; + let outbound_order = self.lock_outbound_order()?; + let (messages, request_id) = { + let mut state = self.lock_state()?; + if state.lifecycle != LspLifecycleState::Ready || !state.client.initialized { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Language server is not ready.", + )); + } + if state.request_by_operation.contains_key(&operation_id) + || state.java_builds.contains(&operation_id) + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The language-server operation ID is already pending.", + )); + } + let negotiation = state + .client + .vcs_provider + .as_ref() + .filter(|negotiation| negotiation.vcs == request.vcs) + .ok_or_else(|| { + CoreError::new( + ErrorCode::NotSupported, + format!("The process is not a negotiated {} provider.", request.vcs), + ) + .with_details("vcs.provider.unavailable") + })?; + let planned = crate::vcs::plan( + operation, + &request.root, + request.tool.as_deref(), + request.arguments, + &negotiation.capabilities, + )?; + let timeout = request + .timeout_milliseconds + .map(|milliseconds| { + Duration::from_millis(milliseconds.clamp(1, MAX_VCS_PROVIDER_TIMEOUT_MS)) + }) + .unwrap_or(state.request_timeout); + let response = + allocate_raw_request(state.client.clone(), planned.method, planned.params)?; + let request_id = (response.state.next_request_id - 1).to_string(); + let now = Instant::now(); + state.client = response.state; + state.pending.insert( + request_id.clone(), + PendingRequest { + kind: PendingKind::Feature, + operation_id: Some(operation_id.clone()), + method: planned.method.to_string(), + document_uri: None, + document_version: None, + created_at: now, + deadline: now + timeout, + }, + ); + state + .request_by_operation + .insert(operation_id, request_id.clone()); + (response.messages, request_id) + }; + self.send_request_or_fail(&outbound_order, messages, &request_id) + } + /// Writes a `lithe/function/invoke` request. The plan comes from /// `plugin_function::plan_invocation`, so plugin identity, negotiation, /// and registration are checked before anything reaches the process. @@ -2106,6 +2295,36 @@ impl RuntimeSession { self.send_request_or_fail(&outbound_order, messages, &request_id) } + /// Sends the backend the host's answer to one forwarded host-service + /// request. An unknown ID, including one already answered or of a session + /// that restarted, is refused so a late answer never reaches another + /// request. + fn host_response(&self, response: PluginHostResponse) -> Result<(), CoreError> { + let outbound_order = self.lock_outbound_order()?; + let message = { + let mut state = self.lock_state()?; + let backend_id = state + .host_requests + .remove(&response.host_request_id) + .ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "No host-service request with this ID is waiting for an answer.", + ) + })?; + match (response.result, response.error) { + (_, Some(failure)) => json_rpc_error_with_code( + &backend_id, + -32603, + &super::plugin_function::bounded_host_message(&failure.message), + super::plugin_function::codes::HOST_FAILED, + )?, + (result, None) => json_rpc_result(&backend_id, result.unwrap_or(Value::Null))?, + } + }; + self.send_messages_or_fail(&outbound_order, vec![message], "hostResponse") + } + fn request_with_kind( &self, request: SemanticRequest, @@ -2904,6 +3123,40 @@ impl RuntimeSession { ); } } + Some(PendingKind::Feature) + if pending_before + .as_ref() + .is_some_and(|pending| crate::vcs::is_vcs_method(&pending.method)) => + { + if let Some(pending) = pending_before.as_ref() { + if let Some(operation_id) = &pending.operation_id { + let (result, error) = match super::vcs_provider::reply_outcome(&value) { + Ok(result) => (Some(result), None), + Err(failure) => ( + None, + Some(runtime_error( + self, + failure.code, + "request", + Some(&pending.method), + None, + &failure.message, + None, + None, + )), + ), + }; + push_request_event( + self, + &mut state, + operation_id, + &pending.method, + result, + error, + ); + } + } + } Some(PendingKind::Feature) => { if let Some(pending) = pending_before.as_ref() { if let Some(operation_id) = &pending.operation_id { @@ -3526,6 +3779,15 @@ impl RuntimeSession { push_functions_event(self, &mut state, result); } else if event.kind == "functionProgress" { push_function_progress_locked(self, &mut state, event.result); + } else if event.kind == "hostRequest" { + outbound.extend(forward_host_request_locked( + self, + &mut state, + event.method.unwrap_or_default(), + event.result.unwrap_or_default(), + )?); + } else if event.kind == super::vcs_provider::NEGOTIATED_EVENT { + push_vcs_provider_event(self, &mut state, event.result.unwrap_or_default()); } else if event.kind == "diagnostics" { if let Some(uri) = event.uri.as_deref() { push_diagnostics_event( @@ -4602,6 +4864,17 @@ fn validate_start_request(request: &StartServerRequest) -> Result<(), CoreError> if request.provider_id.trim().is_empty() { return Err(invalid_field("providerId")); } + // A host-owned server is no plugin's backend, and a service Core does not + // know could never be forwarded. + if !request.host_services.is_empty() + && (request.plugin_id.is_none() + || request + .host_services + .iter() + .any(|service| service != super::plugin_function::HOST_SERVICE_CREDENTIALS)) + { + return Err(invalid_field("hostServices")); + } if request.executable_path.trim().is_empty() || request.executable_path.contains('\0') || request.working_directory.trim().is_empty() @@ -5276,7 +5549,94 @@ fn push_function_progress_locked( ); } +/// Publishes the VCS and capabilities a provider negotiated, so the host can +/// check the VCS against the manifest entry and decide which actions to show. +fn push_vcs_provider_event(session: &RuntimeSession, state: &mut SessionState, result: Value) { + let sequence = take_sequence(state); + enqueue_runtime_event( + session, + state, + LspRuntimeEvent { + kind: "vcsProvider".to_string(), + sequence, + provider_id: session.provider_id.clone(), + session_id: session.id.clone(), + state: None, + operation_id: None, + method: None, + uri: None, + version: None, + diagnostics: None, + result: Some(result), + error: None, + capabilities: None, + server_info: None, + level: None, + message: None, + detail: None, + maven_profile_project: None, + maven_profile_task: None, + }, + ); +} + /// Publishes the session's registered functions after a registry change. +/// Publishes a backend's well-formed host-service request as `hostRequest`, +/// or answers it at once when the session already has the most requests the +/// host may owe it. Returns the messages to send to the backend. +fn forward_host_request_locked( + session: &RuntimeSession, + state: &mut SessionState, + method: String, + request: Value, +) -> Result, CoreError> { + let backend_id = request.get("id").cloned().unwrap_or(Value::Null); + if state.host_requests.len() >= super::plugin_function::MAX_PENDING_HOST_REQUESTS { + return Ok(vec![json_rpc_error_with_code( + &backend_id, + -32603, + "Too many host-service requests are waiting for the host.", + super::plugin_function::codes::HOST_FAILED, + )?]); + } + let host_request_id = format!("host-{}", state.next_host_request_id); + state.next_host_request_id += 1; + state + .host_requests + .insert(host_request_id.clone(), backend_id); + let sequence = take_sequence(state); + enqueue_runtime_event( + session, + state, + LspRuntimeEvent { + kind: "hostRequest".to_string(), + sequence, + provider_id: session.provider_id.clone(), + session_id: session.id.clone(), + state: None, + operation_id: None, + method: Some(method), + uri: None, + version: None, + diagnostics: None, + result: Some(json!({ + "hostRequestID": host_request_id, + "service": request.get("service").cloned().unwrap_or(Value::Null), + "params": request.get("params").cloned().unwrap_or(Value::Null), + })), + error: None, + capabilities: None, + server_info: None, + level: None, + message: None, + detail: None, + maven_profile_project: None, + maven_profile_task: None, + }, + ); + Ok(Vec::new()) +} + fn push_functions_event(session: &RuntimeSession, state: &mut SessionState, result: Value) { let sequence = take_sequence(state); enqueue_runtime_event( @@ -5860,6 +6220,7 @@ fn clear_runtime_state(session: &RuntimeSession, state: &mut SessionState) { state.request_by_operation.clear(); state.function_by_operation.clear(); clear_function_registry(session, state); + state.client.vcs_provider = None; state.linked_document_paths = LinkedDocumentPaths::default(); state.java_navigation_marker_batches.clear(); state.java_navigation_marker_cache.clear(); @@ -5961,6 +6322,7 @@ mod tests { // `adapt_start` is covered by its own tests. provider_id: "gopls".to_string(), plugin_id: None, + host_services: Vec::new(), executable_path: "/usr/bin/scripted-server".to_string(), arguments: vec!["--stdio".to_string()], environment: BTreeMap::new(), @@ -8478,6 +8840,10 @@ mod tests { #[test] fn java_start_enables_and_normalizes_class_file_navigation() { + let completion_fixture: Value = serde_json::from_str(include_str!( + "../../../../../shared/fixtures/lsp/java-completion-v1.json" + )) + .unwrap(); let root = std::env::temp_dir().join(format!( "lithe-core-java-navigation-tests-{}", std::process::id() @@ -8500,7 +8866,8 @@ mod tests { }); request.initialization_options = Some(json!({ "extendedClientCapabilities": { "customCapability": true }, - "bundles": ["/plugins/catalog.jar"] + "bundles": ["/plugins/catalog.jar"], + "settings": { "java": { "completion": completion_fixture["pluginCompletion"] } } })); }); let initialize = harness @@ -8532,6 +8899,48 @@ mod tests { .server .complete_java_initialize(json!({ "definitionProvider": true })); harness.await_state(LspLifecycleState::Ready); + let expected_completion = &completion_fixture["javaSettings"]["completion"]; + assert_eq!( + initialize["params"]["initializationOptions"]["settings"]["java"]["completion"], + *expected_completion, + ); + assert!(harness + .server + .await_notification("workspace/didChangeConfiguration")); + let notification = harness + .server + .messages() + .into_iter() + .find(|message| message["method"] == "workspace/didChangeConfiguration") + .unwrap(); + assert_eq!( + notification["params"]["settings"]["java"]["completion"], + *expected_completion + ); + harness.server.send(json!({ + "jsonrpc": "2.0", "id": "java-completion-config", "method": "workspace/configuration", + "params": { "items": completion_fixture["workspaceConfigurationSections"] + .as_array().unwrap().iter().map(|section| json!({ "section": section })).collect::>() } + })); + let configuration = await_reply(&harness, "java-completion-config"); + assert_eq!( + configuration["result"][0]["completion"], + *expected_completion + ); + assert_eq!(configuration["result"][1], *expected_completion); + assert_eq!( + configuration["result"][2], + expected_completion["guessMethodArguments"] + ); + assert_eq!( + configuration["result"][3], + expected_completion["favoriteStaticMembers"] + ); + assert_eq!( + configuration["result"][4], + expected_completion["filteredTypes"] + ); + assert_eq!(configuration["result"][5], Value::Null); let uri = "file:///workspace/Main.java"; harness.sync(uri, "class Main { String value; }"); let operation_id = harness.request(LspSemanticOperation::Definition, uri); @@ -9487,6 +9896,7 @@ public class Main { .start_server(StartServerRequest { provider_id: "java".to_string(), plugin_id: None, + host_services: Vec::new(), executable_path, arguments: Vec::new(), environment: BTreeMap::from([( @@ -10222,6 +10632,7 @@ public class Main { let request = StartServerRequest { provider_id: String::new(), plugin_id: None, + host_services: Vec::new(), executable_path: "/bin/server".to_string(), arguments: Vec::new(), environment: BTreeMap::new(), @@ -10826,6 +11237,260 @@ public class Main { assert!(harness.server.await_notification("$/cancelRequest")); } + // VCS providers against a scripted backend. Only a plugin-owned session + // is offered `litheVcs`, and only one whose process answered with a VCS + // ID and every required capability receives requests, each planned by + // Core from an operation name. + + const VCS_PLUGIN: &str = "dev.lithe.fixture.vcs-provider"; + + fn vcs_capabilities() -> Value { + json!({ "status": 1, "baseContent": 1, "checkin": 1, "rollback": 1, "update": 1 }) + } + + fn vcs_backend(answer: Value) -> Harness { + let mut harness = Harness::start(|request| { + request.plugin_id = Some(VCS_PLUGIN.to_string()); + }); + harness + .server + .complete_initialize(json!({ "experimental": { "litheVcs": answer } })); + harness.await_state(LspLifecycleState::Ready); + harness + } + + fn negotiated_vcs_provider() -> Harness { + vcs_backend(json!({ "version": 1, "vcs": "svn", "capabilities": vcs_capabilities() })) + } + + /// A native absolute path on every platform the tests run on. + fn working_copy() -> String { + std::env::temp_dir() + .join("lithe-vcs-working-copy") + .to_string_lossy() + .into_owned() + } + + fn vcs_request( + harness: &Harness, + vcs: &str, + operation: &str, + arguments: Value, + ) -> Result { + let operation_id = harness.engine.next_operation_id(); + harness + .session() + .vcs_request( + VcsProviderRequest { + session_id: harness.session_id.clone(), + operation_id: Some(operation_id.clone()), + vcs: vcs.to_string(), + operation: operation.to_string(), + root: working_copy(), + tool: None, + arguments, + timeout_milliseconds: None, + }, + operation_id.clone(), + ) + .map(|()| operation_id) + } + + /// Feature names the session recorded from the provider's answer. + fn vcs_features(harness: &Harness) -> Vec { + let session = harness.session(); + let state = session.lock_state().expect("state"); + state.client.server_capabilities.clone() + } + + fn no_vcs_request_written(harness: &Harness) -> bool { + harness.server.messages().iter().all(|message| { + !message["method"] + .as_str() + .is_some_and(|method| method.starts_with("lithe/vcs/")) + }) + } + + #[test] + fn host_sessions_are_not_offered_the_vcs_protocol() { + let harness = Harness::ready(); + let initialize = harness + .server + .messages() + .into_iter() + .find(|message| message["method"] == "initialize") + .expect("initialize should have been written"); + assert!(initialize["params"]["capabilities"]["experimental"] + .get("litheVcs") + .is_none()); + let error = vcs_request(&harness, "svn", "status", json!({})) + .expect_err("a host session is never a provider"); + assert!(matches!(error.code, ErrorCode::NotSupported)); + assert_eq!(error.details.as_deref(), Some("vcs.provider.unavailable")); + assert!(no_vcs_request_written(&harness)); + } + + /// The negotiated VCS and capabilities reach the host as one event, so + /// it can check the VCS against the manifest and pick the actions to show. + #[test] + fn a_negotiated_provider_is_announced_with_its_capabilities() { + let mut harness = negotiated_vcs_provider(); + let event = harness + .await_event(|event| event.kind == "vcsProvider") + .clone(); + assert_eq!( + event.result, + Some(json!({ "vcs": "svn", "capabilities": vcs_capabilities() })) + ); + assert!(vcs_features(&harness) + .iter() + .any(|feature| feature == "litheVcs")); + } + + /// Version 1 has no host side for credentials or console routing yet: an + /// authentication request is refused so the provider fails with + /// `vcs.authentication.failed`, and an execution report is passed on as a + /// session log entry. + #[test] + fn provider_authentication_is_refused_and_execution_reports_pass_through() { + let mut harness = negotiated_vcs_provider(); + harness.server.send(json!({ + "jsonrpc": "2.0", + "id": "auth-1", + "method": "lithe/vcs/authenticate", + "params": { "realm": " Example" } + })); + assert_eq!(await_reply(&harness, "auth-1")["error"]["code"], -32601); + harness.server.send(json!({ + "jsonrpc": "2.0", + "method": "lithe/vcs/execution", + "params": { "kind": "started", "arguments": ["status", "--xml"] } + })); + let event = harness + .await_event(|event| { + event.kind == "log" && event.message.as_deref() == Some("lithe/vcs/execution") + }) + .clone(); + assert!(event + .detail + .as_deref() + .is_some_and(|detail| detail.contains("--xml"))); + } + + /// An answer without a required capability makes the process a plain + /// plugin session: no announcement, no feature, and no request. + #[test] + fn an_incomplete_vcs_answer_is_not_a_provider() { + let mut harness = vcs_backend(json!({ + "version": 1, "vcs": "svn", "capabilities": { "status": 1, "baseContent": 1 } + })); + harness.poll(); + assert!(harness + .events + .iter() + .all(|event| event.kind != "vcsProvider")); + assert!(vcs_features(&harness) + .iter() + .all(|feature| feature != "litheVcs")); + let error = vcs_request(&harness, "svn", "status", json!({})).expect_err("refused"); + assert_eq!(error.details.as_deref(), Some("vcs.provider.unavailable")); + assert!(no_vcs_request_written(&harness)); + } + + #[test] + fn a_vcs_request_is_planned_from_its_operation_and_returns_the_reply() { + let mut harness = negotiated_vcs_provider(); + let operation = vcs_request( + &harness, + "svn", + "checkin", + json!({ "paths": ["src/b.txt", "src/a.txt"], "message": "Fix" }), + ) + .expect("accepted"); + let request_id = harness + .server + .await_request("lithe/vcs/checkin") + .expect("the request should reach the provider"); + assert_eq!( + harness.written_request(&request_id)["params"], + json!({ + "paths": ["src/a.txt", "src/b.txt"], + "message": "Fix", + "root": { "path": working_copy() }, + "tool": null + }) + ); + let reply = json!({ "revision": { "id": "12", "display": "r12", "kind": "number" } }); + harness + .server + .send(json!({ "jsonrpc": "2.0", "id": request_id, "result": reply })); + let completed = harness + .await_event(|event| { + event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str()) + }) + .clone(); + assert!(completed.error.is_none(), "{:?}", completed.error); + assert_eq!(completed.result, Some(json!({ "value": reply }))); + assert!(harness.snapshot().pending_operation_ids.is_empty()); + } + + /// The host names an operation, never a method, so the session cannot + /// be used as a tunnel; another VCS, an unknown operation, a capability + /// the provider lacks, or a path leaving the root never reach the wire. + #[test] + fn vcs_requests_are_refused_before_they_are_written() { + let harness = negotiated_vcs_provider(); + let error = vcs_request(&harness, "hg", "status", json!({})).expect_err("wrong VCS"); + assert_eq!(error.details.as_deref(), Some("vcs.provider.unavailable")); + let error = + vcs_request(&harness, "svn", "shutdown", json!({})).expect_err("no such operation"); + assert!(matches!(error.code, ErrorCode::InvalidRequest)); + let error = vcs_request(&harness, "svn", "annotate", json!({ "path": "a.txt" })) + .expect_err("not negotiated"); + assert!(matches!(error.code, ErrorCode::NotSupported)); + assert_eq!(error.details.as_deref(), Some("vcs.capability.unsupported")); + let error = vcs_request(&harness, "svn", "rollback", json!({ "paths": ["../x"] })) + .expect_err("outside the root"); + assert!(matches!(error.code, ErrorCode::InvalidRequest)); + assert!(no_vcs_request_written(&harness)); + } + + /// A provider may name one of the contract's failure codes; any other + /// code, including one of Lithe's own, becomes the generic failure. + #[test] + fn vcs_failures_keep_only_contract_codes() { + let mut harness = negotiated_vcs_provider(); + for (index, (data, expected)) in [ + (json!({ "code": "vcs.outOfDate" }), "vcs.outOfDate"), + ( + json!({ "code": "plugin.vcs.invalidOutput" }), + "vcs.provider.failed", + ), + ] + .into_iter() + .enumerate() + { + let operation = vcs_request(&harness, "svn", "update", json!({})).expect("sent"); + let request_id = harness + .server + .await_request_at("lithe/vcs/update", index) + .expect("the request should reach the provider"); + harness.server.send(json!({ + "jsonrpc": "2.0", + "id": request_id, + "error": { "code": -32000, "message": "Working copy is out of date.", "data": data } + })); + let error = harness + .await_event(|event| event.operation_id.as_deref() == Some(operation.as_str())) + .error + .clone() + .expect("an error reply fails the request"); + assert_eq!(error.code, expected); + assert_eq!(error.message, "Working copy is out of date."); + } + } + // Plugin functions against a scripted backend. The scripted server plays // the plugin process, so every outcome (result, failure, cancellation, // deadline, late reply, revocation, and compatibility with a backend that @@ -10992,7 +11657,11 @@ public class Main { .expect("initialize should have been written"); assert_eq!( initialize["params"]["capabilities"]["experimental"], - json!({ "litheBuild": { "versions": [1] }, "litheFunctions": { "versions": [1] } }) + json!({ + "litheBuild": { "versions": [1] }, + "litheFunctions": { "versions": [1, 2] }, + "litheVcs": { "versions": [1] } + }) ); // Another plugin's reference never reaches this process. @@ -11234,12 +11903,13 @@ public class Main { revoked.error.as_ref().map(|error| error.code.as_str()), Some("plugin.function.revoked") ); - assert!(harness + // The revocation is published before the cancellation is written, so + // the write is awaited rather than expected to be there already. + let cancel = harness .server - .messages() - .iter() - .any(|message| message["method"] == "$/cancelRequest" - && message["params"]["id"] == request_id)); + .await_notification_at("$/cancelRequest", 0) + .expect("the revoked call should be cancelled on the wire"); + assert_eq!(cancel["params"]["id"], request_id); let error = invoke(&harness, FUNCTION_PLUGIN, "snapshot.clear", None) .expect_err("a withdrawn function is unknown"); assert_eq!(error.details.as_deref(), Some("plugin.function.unknown")); @@ -11741,4 +12411,141 @@ public class Main { assert_eq!(reply["error"]["data"]["code"], code, "{method}"); } } + + fn host_service_backend(version: u64, host_services: &[&str]) -> Harness { + let host_services: Vec = host_services.iter().map(ToString::to_string).collect(); + let mut harness = Harness::start(move |request| { + request.plugin_id = Some(FUNCTION_PLUGIN.to_string()); + request.host_services = host_services.clone(); + }); + harness.server.complete_initialize(json!({ + "experimental": { "litheFunctions": { "version": version } } + })); + harness.await_state(LspLifecycleState::Ready); + harness + } + + fn host_response( + harness: &Harness, + host_request_id: &str, + result: Option, + error: Option<&str>, + ) -> Result<(), CoreError> { + // Decoded from the wire shape `lsp.hostResponse` documents, so the + // field spelling is checked with the round trip. + let mut wire = json!({ + "sessionId": harness.session_id, + "hostRequestID": host_request_id, + }); + if let Some(result) = result { + wire["result"] = result; + } + if let Some(message) = error { + wire["error"] = json!({ "message": message }); + } + let response: PluginHostResponse = + serde_json::from_value(wire).expect("documented wire shape"); + harness.session().host_response(response) + } + + /// A granted credential request reaches the host as `hostRequest`. The + /// backend receives exactly the host's answer under its own ID, and a + /// second answer to the same request is refused instead of reaching a + /// later request. + #[test] + fn a_granted_host_service_request_round_trips_through_the_host() { + let mut harness = host_service_backend(2, &["credentials"]); + harness.server.send(json!({ + "jsonrpc": "2.0", "id": "read", + "method": "lithe/host/credentials/get", + "params": { "key": "connection.prod" } + })); + let event = harness + .await_event(|event| event.kind == "hostRequest") + .clone(); + assert_eq!(event.method.as_deref(), Some("lithe/host/credentials/get")); + let request = event.result.expect("the request is described"); + assert_eq!(request["service"], "credentials"); + assert_eq!(request["params"], json!({ "key": "connection.prod" })); + let read_id = request["hostRequestID"].as_str().unwrap().to_string(); + host_response( + &harness, + &read_id, + Some(json!({ "secret": "s3cret" })), + None, + ) + .expect("the host answers once"); + assert_eq!( + await_reply(&harness, "read")["result"], + json!({ "secret": "s3cret" }) + ); + assert!(host_response(&harness, &read_id, None, None).is_err()); + + harness.server.send(json!({ + "jsonrpc": "2.0", "id": "write", + "method": "lithe/host/credentials/set", + "params": { "key": "connection.prod", "secret": "s3cret" } + })); + let event = harness + .await_event(|event| { + event.kind == "hostRequest" + && event.method.as_deref() == Some("lithe/host/credentials/set") + }) + .clone(); + let write_id = event.result.unwrap()["hostRequestID"] + .as_str() + .unwrap() + .to_string(); + assert_ne!(write_id, read_id); + host_response(&harness, &write_id, None, Some("The keychain is locked.")) + .expect("the host reports its failure"); + let reply = await_reply(&harness, "write"); + assert_eq!(reply["error"]["data"]["code"], "plugin.host.failed"); + assert_eq!(reply["error"]["message"], "The keychain is locked."); + } + + /// Host services start with protocol version 2 and only for the services + /// the plugin's permissions grant; refusals never reach the host. + #[test] + fn host_services_need_version_2_and_a_grant() { + for (version, services, code) in [ + (1, &["credentials"][..], "plugin.host.unavailable"), + (2, &[][..], "plugin.host.permissionDenied"), + ] { + let mut harness = host_service_backend(version, services); + harness.server.send(json!({ + "jsonrpc": "2.0", "id": "read", + "method": "lithe/host/credentials/get", + "params": { "key": "connection.prod" } + })); + let reply = await_reply(&harness, "read"); + assert_eq!(reply["error"]["data"]["code"], code, "version {version}"); + harness.poll(); + assert!(harness + .events + .iter() + .all(|event| event.kind != "hostRequest")); + } + } + + /// A host that stops answering cannot make a backend grow the session's + /// state: requests beyond the bound fail at once. + #[test] + fn unanswered_host_requests_are_bounded() { + let harness = host_service_backend(2, &["credentials"]); + let limit = super::super::plugin_function::MAX_PENDING_HOST_REQUESTS; + for index in 0..=limit { + harness.server.send(json!({ + "jsonrpc": "2.0", "id": format!("read-{index}"), + "method": "lithe/host/credentials/get", + "params": { "key": "connection.prod" } + })); + } + let reply = await_reply(&harness, &format!("read-{limit}")); + assert_eq!(reply["error"]["data"]["code"], "plugin.host.failed"); + assert_eq!( + harness.session().lock_state().unwrap().host_requests.len(), + limit + ); + } } diff --git a/rust/lithe-core/src/lsp/interface/engine_real_jdt_tests.rs b/rust/lithe-core/src/lsp/interface/engine_real_jdt_tests.rs index 167da2f1ac..2384079038 100644 --- a/rust/lithe-core/src/lsp/interface/engine_real_jdt_tests.rs +++ b/rust/lithe-core/src/lsp/interface/engine_real_jdt_tests.rs @@ -335,6 +335,7 @@ fn real_start_request( StartServerRequest { provider_id: "java".to_string(), plugin_id: None, + host_services: Vec::new(), executable_path: toolchain .root .join("bin") diff --git a/rust/lithe-core/src/lsp/interface/mod.rs b/rust/lithe-core/src/lsp/interface/mod.rs index 7fe3b90fab..7214d5a168 100644 --- a/rust/lithe-core/src/lsp/interface/mod.rs +++ b/rust/lithe-core/src/lsp/interface/mod.rs @@ -9,6 +9,7 @@ mod process; mod scripted; mod transport; mod types; +mod vcs_provider; pub(crate) use client::*; pub(crate) use engine::*; diff --git a/rust/lithe-core/src/lsp/interface/plugin_function.rs b/rust/lithe-core/src/lsp/interface/plugin_function.rs index 0ad3c1b11a..e10f0f8960 100644 --- a/rust/lithe-core/src/lsp/interface/plugin_function.rs +++ b/rust/lithe-core/src/lsp/interface/plugin_function.rs @@ -20,8 +20,11 @@ use std::collections::BTreeSet; /// Key under `capabilities.experimental` on both sides of the exchange. const EXPERIMENTAL_KEY: &str = "litheFunctions"; -/// Plugin-function protocol versions this client speaks, in preference order. -const SUPPORTED_VERSIONS: [u64; 1] = [1]; +/// Plugin-function protocol versions this client speaks. Version 2 keeps +/// every rule of version 1 and adds the host services of `host_request`. +const SUPPORTED_VERSIONS: [u64; 2] = [1, 2]; +/// First protocol version that offers host services. +const HOST_SERVICES_VERSION: u64 = 2; /// Feature name recorded in the session's server capabilities once a backend /// answered a supported version with a valid function list. @@ -49,6 +52,18 @@ const MAX_RESULT_BYTES: usize = 1 << 20; /// Upper bound of one invocation's deadline, matching build-provider requests. pub(crate) const MAX_FUNCTION_TIMEOUT_MS: u64 = 60 * 60_000; +/// Host service that keeps secrets for the plugin, private to its ID. +pub(crate) const HOST_SERVICE_CREDENTIALS: &str = "credentials"; +const CREDENTIALS_GET: &str = "lithe/host/credentials/get"; +const CREDENTIALS_SET: &str = "lithe/host/credentials/set"; +const CREDENTIALS_DELETE: &str = "lithe/host/credentials/delete"; +const MAX_CREDENTIAL_KEY_BYTES: usize = 128; +/// Bound of one stored secret, in UTF-8 bytes; enough for a password, a +/// token, or a private key, not for documents. +pub(crate) const MAX_CREDENTIAL_SECRET_BYTES: usize = 16 * 1024; +/// Host requests one session may have awaiting the host's answer. +pub(crate) const MAX_PENDING_HOST_REQUESTS: usize = 16; + /// Stable codes of the plugin-function contract. Synchronous refusals carry /// them in `CoreError::details`; asynchronous outcomes in the runtime error's /// `code`; refused backend requests in the JSON-RPC error's `data.code`. @@ -77,6 +92,14 @@ pub(crate) mod codes { pub const RECURSIVE_CALL: &str = "plugin.function.recursiveCall"; /// The backend asked for a host capability this host does not offer. pub const HOST_UNAVAILABLE: &str = "plugin.host.unavailable"; + /// The host offers the service, but the plugin's permissions do not + /// grant it to this session. + pub const HOST_PERMISSION_DENIED: &str = "plugin.host.permissionDenied"; + /// A host-service request has malformed or unknown params. + pub const HOST_INVALID_PARAMS: &str = "plugin.host.invalidParams"; + /// The host could not perform the service, or too many requests of + /// the session were already waiting for it. + pub const HOST_FAILED: &str = "plugin.host.failed"; } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] @@ -125,10 +148,11 @@ pub(crate) fn client_experimental_capabilities() -> (&'static str, Value) { (EXPERIMENTAL_KEY, json!({ "versions": SUPPORTED_VERSIONS })) } -/// Functions of a backend that answered a version this client offered, or -/// `None` when the protocol was not negotiated. A malformed function list -/// rejects the whole answer, so a session never starts with half a registry. -pub(crate) fn negotiate(capabilities: &Value) -> Option> { +/// The version and functions of a backend that answered a version this +/// client offered, or `None` when the protocol was not negotiated. A +/// malformed function list rejects the whole answer, so a session never +/// starts with half a registry. +pub(crate) fn negotiate(capabilities: &Value) -> Option<(u64, BTreeSet)> { let answer = capabilities .get("experimental") .and_then(|experimental| experimental.get(EXPERIMENTAL_KEY))?; @@ -136,10 +160,11 @@ pub(crate) fn negotiate(capabilities: &Value) -> Option> { if !SUPPORTED_VERSIONS.contains(&version) { return None; } - match answer.get("functions") { - None => Some(BTreeSet::new()), - Some(descriptors) => descriptor_ids(descriptors, &BTreeSet::new()).ok(), - } + let functions = match answer.get("functions") { + None => BTreeSet::new(), + Some(descriptors) => descriptor_ids(descriptors, &BTreeSet::new()).ok()?, + }; + Some((version, functions)) } /// The registry after a `lithe/function/register` request. The request is @@ -311,6 +336,98 @@ pub(crate) fn refused_backend_request(method: &str) -> Option<&'static str> { } } +/// Why a backend's `lithe/host/*` request is not forwarded to the host. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct HostRequestRefusal { + /// JSON-RPC error code of the answer. + pub rpc_code: i64, + /// Stable contract code, sent as `error.data.code`. + pub code: &'static str, + pub message: &'static str, +} + +/// The host service a backend request asks for, or why it is refused. +/// `version` is the negotiated protocol version (0 when not negotiated) and +/// `granted` the services the plugin's permissions grant the session. The +/// params are checked here, so the host only ever sees a well-formed +/// request, and unknown params are refused so a later version can add them. +pub(crate) fn host_request( + method: &str, + params: Option<&Value>, + version: u64, + granted: &BTreeSet, +) -> Result<&'static str, HostRequestRefusal> { + let unavailable = HostRequestRefusal { + rpc_code: -32601, + code: codes::HOST_UNAVAILABLE, + message: "The host offers no such service to this plugin process.", + }; + if version < HOST_SERVICES_VERSION { + return Err(unavailable); + } + let (service, with_secret) = match method { + CREDENTIALS_GET | CREDENTIALS_DELETE => (HOST_SERVICE_CREDENTIALS, false), + CREDENTIALS_SET => (HOST_SERVICE_CREDENTIALS, true), + _ => return Err(unavailable), + }; + if !granted.contains(service) { + return Err(HostRequestRefusal { + rpc_code: -32601, + code: codes::HOST_PERMISSION_DENIED, + message: "The plugin does not declare the permission for this host service.", + }); + } + let invalid = |message| HostRequestRefusal { + rpc_code: -32602, + code: codes::HOST_INVALID_PARAMS, + message, + }; + let params = params + .and_then(Value::as_object) + .ok_or(invalid("Params must be an object."))?; + let allowed: &[&str] = if with_secret { + &["key", "secret"] + } else { + &["key"] + }; + if params.keys().any(|key| !allowed.contains(&key.as_str())) { + return Err(invalid("Params name a field this service does not define.")); + } + if !params + .get("key") + .and_then(Value::as_str) + .is_some_and(is_credential_key) + { + return Err(invalid( + "A credential key is 1-128 ASCII letters, digits, '.', '_', ':' or '-'.", + )); + } + if with_secret + && !params + .get("secret") + .and_then(Value::as_str) + .is_some_and(|secret| secret.len() <= MAX_CREDENTIAL_SECRET_BYTES) + { + return Err(invalid("A secret is a string of at most 16 KiB.")); + } + Ok(service) +} + +/// Key of a stored secret. The alphabet excludes path separators so a host +/// may use the key in a store item name without escaping. +fn is_credential_key(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_CREDENTIAL_KEY_BYTES + && value.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | ':' | '-') + }) +} + +/// Truncates a host's failure message like a backend's. +pub(crate) fn bounded_host_message(value: &str) -> String { + bounded_message(value) +} + /// IDs of a descriptor array, refusing malformed descriptors, duplicates, /// IDs already in `existing`, and a registry beyond the session limit. fn descriptor_ids( @@ -453,7 +570,10 @@ mod tests { match expected["functions"].as_array() { Some(_) => assert_eq!( negotiated, - Some(id_set(&expected["functions"])), + Some(( + expected["version"].as_u64().unwrap_or(1), + id_set(&expected["functions"]) + )), "{}", name(&case) ), @@ -579,6 +699,49 @@ mod tests { } } + #[test] + fn host_requests_follow_the_fixture() { + for case in cases("hostRequests") { + let granted: BTreeSet = id_set(&case["hostServices"]); + let outcome = host_request( + case["method"].as_str().unwrap(), + case.get("params"), + case["version"].as_u64().unwrap(), + &granted, + ); + let expected = &case["expected"]; + match expected["forward"].as_str() { + Some(service) => assert_eq!(outcome, Ok(service), "{}", name(&case)), + None => { + let refusal = outcome.expect_err(name(&case)); + assert_eq!(refusal.code, expected["code"], "{}", name(&case)); + assert_eq!(refusal.rpc_code, expected["rpcCode"], "{}", name(&case)); + } + } + } + } + + /// The secret bound is inclusive and measured in UTF-8 bytes. + #[test] + fn credential_secrets_are_bounded() { + let granted = BTreeSet::from([HOST_SERVICE_CREDENTIALS.to_string()]); + let set = |secret: String| { + host_request( + CREDENTIALS_SET, + Some(&json!({ "key": "k", "secret": secret })), + 2, + &granted, + ) + }; + assert!(set("x".repeat(MAX_CREDENTIAL_SECRET_BYTES)).is_ok()); + assert_eq!( + set("x".repeat(MAX_CREDENTIAL_SECRET_BYTES + 1)) + .unwrap_err() + .code, + codes::HOST_INVALID_PARAMS + ); + } + /// A message over the bound is cut at a character boundary, never inside /// a multi-byte character. #[test] diff --git a/rust/lithe-core/src/lsp/interface/process.rs b/rust/lithe-core/src/lsp/interface/process.rs index 69f0806084..b028c52fa6 100644 --- a/rust/lithe-core/src/lsp/interface/process.rs +++ b/rust/lithe-core/src/lsp/interface/process.rs @@ -5,6 +5,9 @@ //! place for the Windows client to substitute, and it lets the engine's //! lifecycle rules -- deadlines, crash handling, restart isolation -- be tested //! against a scripted server instead of a real one. +//! +//! On Windows a server and everything it starts share one Job Object, so a +//! batch launcher's JVM ends with it and none outlives a crashed host. use crate::protocol::{CoreError, ErrorCode}; use std::collections::BTreeMap; @@ -78,7 +81,8 @@ impl LspProcessLauncher for SystemProcessLauncher { .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); - apply_language_server_creation_flags(&mut command); + #[cfg(target_os = "windows")] + lithe_git_host::ProcessTree::prepare(&mut command); let mut child = command.spawn().map_err(|error| { CoreError::new( ErrorCode::ProcessStartFailed, @@ -86,6 +90,14 @@ impl LspProcessLauncher for SystemProcessLauncher { ) .with_details(error.to_string()) })?; + #[cfg(target_os = "windows")] + let tree = lithe_git_host::ProcessTree::adopt(&mut child).map_err(|error| { + CoreError::new( + ErrorCode::ProcessStartFailed, + "Could not take ownership of the language-server process tree.", + ) + .with_details(error.to_string()) + })?; let stdin = child.stdin.take().ok_or_else(|| missing_stream("stdin"))?; let stdout = child .stdout @@ -99,6 +111,8 @@ impl LspProcessLauncher for SystemProcessLauncher { handle: Arc::new(SystemProcess { input: Mutex::new(Some(stdin)), child: Mutex::new(child), + #[cfg(target_os = "windows")] + tree, }), output: Box::new(stdout), errors: Box::new(stderr), @@ -184,23 +198,12 @@ fn windows_batch_env_value(value: &str) -> String { escaped } -fn apply_language_server_creation_flags(command: &mut Command) { - #[cfg(target_os = "windows")] - command.creation_flags(language_server_process_creation_flags()); - - #[cfg(not(target_os = "windows"))] - let _ = command; -} - -#[cfg(target_os = "windows")] -fn language_server_process_creation_flags() -> u32 { - const CREATE_NO_WINDOW: u32 = 0x0800_0000; - CREATE_NO_WINDOW -} - struct SystemProcess { input: Mutex>, child: Mutex, + /// The server and its descendants; dropping the handle ends all of them. + #[cfg(target_os = "windows")] + tree: lithe_git_host::ProcessTree, } impl LspProcessHandle for SystemProcess { @@ -249,6 +252,8 @@ impl LspProcessHandle for SystemProcess { } fn terminate(&self) { + #[cfg(target_os = "windows")] + let _ = self.tree.kill(); if let Ok(mut child) = self.child.lock() { let _ = child.kill(); } @@ -272,14 +277,11 @@ mod tests { use std::collections::BTreeMap; use std::ffi::OsStr; use std::fs; - use std::io::Read; + use std::io::{Read, Write}; use std::path::PathBuf; - use std::time::{SystemTime, UNIX_EPOCH}; - - #[test] - fn background_language_servers_do_not_create_windows_console() { - assert_eq!(super::language_server_process_creation_flags(), 0x0800_0000); - } + use std::sync::mpsc; + use std::thread; + use std::time::{Duration, SystemTime, UNIX_EPOCH}; #[test] fn batch_language_server_uses_cmd_exe() { @@ -325,6 +327,98 @@ mod tests { fs::write(output, arguments.join("\r\n")).expect("argument echo output"); } + /// Libtest name of [`stdin_hold_helper`], which the termination test + /// re-launches from a batch script. + const STDIN_HOLD_TEST: &str = "lsp::interface::process::tests::stdin_hold_helper"; + const STDIN_HOLD_ENV: &str = "LITHE_TEST_STDIN_HOLD"; + const STDIN_HOLD_READY: &str = "lithe-stdin-hold-ready"; + + /// Not a scenario of its own: a no-op unless the termination test launches + /// this test binary with it. It then stays alive until its inherited stdin + /// closes, like the JVM a batch language-server launcher starts. + #[test] + fn stdin_hold_helper() { + if std::env::var_os(STDIN_HOLD_ENV).is_none() { + return; + } + // Written past libtest's output capture, so the parent sees it at once. + let mut stdout = std::io::stdout().lock(); + writeln!(stdout, "{STDIN_HOLD_READY}").expect("ready marker"); + stdout.flush().expect("ready marker"); + let _ = std::io::stdin().read_to_end(&mut Vec::new()); + } + + #[test] + fn terminating_a_batch_language_server_ends_the_process_it_started() { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let root = std::env::temp_dir().join(format!("lithe lsp tree {stamp}")); + fs::create_dir_all(&root).expect("temp directory"); + let executable = root.join("holding server.cmd"); + fs::write( + &executable, + format!( + "@echo off\r\n\"%{ARGV_ECHO_EXECUTABLE_ENV}%\" --exact {STDIN_HOLD_TEST} --test-threads 1\r\n" + ), + ) + .expect("batch script"); + let spec = LspProcessSpec { + executable, + arguments: Vec::new(), + working_directory: root.clone(), + environment: BTreeMap::from([ + ( + ARGV_ECHO_EXECUTABLE_ENV.to_string(), + std::env::current_exe() + .expect("test executable") + .to_string_lossy() + .into_owned(), + ), + (STDIN_HOLD_ENV.to_string(), "1".to_string()), + ]), + }; + + let streams = SystemProcessLauncher + .launch(spec) + .expect("launch batch script"); + let mut output = streams.output; + let (events, received) = mpsc::channel(); + thread::spawn(move || { + let mut seen = String::new(); + let mut buffer = [0_u8; 1024]; + let mut ready = false; + while let Ok(count) = output.read(&mut buffer) { + if count == 0 { + break; + } + seen.push_str(&String::from_utf8_lossy(&buffer[..count])); + if !ready && seen.contains(STDIN_HOLD_READY) { + ready = true; + let _ = events.send("ready"); + } + } + let _ = events.send("closed"); + }); + assert_eq!( + received.recv_timeout(Duration::from_secs(10)), + Ok("ready"), + "the batch script should start its native child" + ); + + streams.handle.terminate(); + + // The child holds the stdout pipe; it closes only once the child is + // gone too, not just the cmd.exe that started it. + assert_eq!( + received.recv_timeout(Duration::from_secs(10)), + Ok("closed"), + "terminating the server should end the process its script started" + ); + fs::remove_dir_all(root).ok(); + } + #[test] fn batch_language_server_preserves_spaced_paths_and_shell_metacharacters() { let stamp = SystemTime::now() diff --git a/rust/lithe-core/src/lsp/interface/types.rs b/rust/lithe-core/src/lsp/interface/types.rs index 95f5083d6f..fa40091754 100644 --- a/rust/lithe-core/src/lsp/interface/types.rs +++ b/rust/lithe-core/src/lsp/interface/types.rs @@ -139,6 +139,18 @@ pub struct LspClientState { /// negotiated; empty otherwise. #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] pub plugin_functions: BTreeSet, + /// The `litheFunctions` version the backend chose; `None` when the + /// protocol was not negotiated. Version 2 adds host services. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugin_function_version: Option, + /// Host services the plugin's permissions grant this session, from + /// `lsp.startServer.hostServices`. Empty for host-owned sessions. + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + pub host_services: BTreeSet, + /// The VCS and capabilities a plugin process answered under + /// `litheVcs`; `None` when the protocol was not negotiated. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vcs_provider: Option, } impl Default for LspClientState { @@ -156,6 +168,9 @@ impl Default for LspClientState { diagnostics: BTreeMap::new(), diagnostic_versions: BTreeMap::new(), plugin_functions: BTreeSet::new(), + plugin_function_version: None, + host_services: BTreeSet::new(), + vcs_provider: None, } } } diff --git a/rust/lithe-core/src/lsp/interface/vcs_provider.rs b/rust/lithe-core/src/lsp/interface/vcs_provider.rs new file mode 100644 index 0000000000..23a6f6266c --- /dev/null +++ b/rust/lithe-core/src/lsp/interface/vcs_provider.rs @@ -0,0 +1,183 @@ +//! The VCS-provider protocol a plugin's LSP base-protocol process speaks. +//! +//! The manifest says how to start the process and which VCS it provides; +//! the protocol version, the VCS ID, and the provider's capabilities are +//! negotiated in `initialize` under `capabilities.experimental.litheVcs`. +//! Only plugin-owned sessions are offered the protocol, and requests are +//! written only to a session whose answer named a version this client +//! offered, a valid VCS ID, and every required capability. The rules live in +//! `shared/contracts/vcs-provider-v1.md`. + +use crate::vcs::{negotiated_capabilities, Capabilities}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +/// Key under `capabilities.experimental` on both sides of the exchange. +const EXPERIMENTAL_KEY: &str = "litheVcs"; + +/// VCS-provider protocol versions this client speaks, in preference order. +const SUPPORTED_VERSIONS: [u64; 1] = [1]; + +/// Feature name recorded in the session's server capabilities once a +/// provider answered a supported version. +pub(crate) const VCS_PROVIDER_FEATURE: &str = "litheVcs"; + +/// Client event that carries the negotiated provider to the engine. +pub(crate) const NEGOTIATED_EVENT: &str = "vcsProviderNegotiated"; + +/// Longest provider error message passed to the host, in UTF-8 bytes. +const MAX_MESSAGE_BYTES: usize = 4096; + +/// Contract codes a provider may put in `error.data.code`. Any other code, +/// or none, becomes [`codes::FAILED`]. +const PROVIDER_ERROR_CODES: &[&str] = &[ + "vcs.authentication.failed", + "vcs.conflict", + "vcs.outOfDate", + "vcs.tool.unavailable", + "vcs.workingCopy.locked", + "vcs.workingCopy.unsupported", +]; + +/// Error codes Core assigns to a failed VCS request. +pub(crate) mod codes { + /// The provider failed without one of the contract's specific codes. + pub const FAILED: &str = "vcs.provider.failed"; + /// The reply is not a JSON-RPC result. + pub const INVALID_OUTPUT: &str = "plugin.vcs.invalidOutput"; +} + +/// What a provider answered in `initialize`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VcsNegotiation { + /// The VCS the process provides; the host checks it against the + /// manifest entry it launched. + pub vcs: String, + /// Capabilities of protocol version 1 the provider supports. + pub capabilities: Capabilities, +} + +/// The client half of the negotiation, placed under `capabilities.experimental`. +pub(crate) fn client_experimental_capabilities() -> (&'static str, Value) { + (EXPERIMENTAL_KEY, json!({ "versions": SUPPORTED_VERSIONS })) +} + +/// The provider's answer, or `None` when it chose no offered version, named +/// no valid VCS ID, or lacks a required capability. Unknown fields and +/// capabilities are ignored, as the contract requires. +pub(crate) fn negotiate(capabilities: &Value) -> Option { + let answer = capabilities + .get("experimental") + .and_then(|experimental| experimental.get(EXPERIMENTAL_KEY))?; + let version = answer.get("version").and_then(Value::as_u64)?; + if !SUPPORTED_VERSIONS.contains(&version) { + return None; + } + let vcs = answer + .get("vcs") + .and_then(Value::as_str) + .filter(|vcs| crate::vcs::is_vcs_id(vcs))?; + Some(VcsNegotiation { + vcs: vcs.to_string(), + capabilities: negotiated_capabilities(answer.get("capabilities")?)?, + }) +} + +/// A failed VCS request: a contract code and the provider's message. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct VcsFailure { + pub(crate) code: &'static str, + pub(crate) message: String, +} + +fn bounded_message(value: &str) -> String { + let mut end = value.len().min(MAX_MESSAGE_BYTES); + while !value.is_char_boundary(end) { + end -= 1; + } + value[..end].to_string() +} + +/// Normalizes the provider's JSON-RPC response. A success is wrapped as +/// `{ "value": result }` like build-provider replies, so the host passes +/// `value` to `vcs.decodeReply` unchanged. +pub(crate) fn reply_outcome(response: &Value) -> Result { + if let Some(error) = response.get("error") { + let message = error + .get("message") + .and_then(Value::as_str) + .map(bounded_message) + .filter(|message| !message.trim().is_empty()) + .unwrap_or_else(|| "The VCS provider failed.".to_string()); + let code = error + .get("data") + .and_then(|data| data.get("code")) + .and_then(Value::as_str) + .and_then(|code| PROVIDER_ERROR_CODES.iter().find(|known| **known == code)) + .copied() + .unwrap_or(codes::FAILED); + return Err(VcsFailure { code, message }); + } + match response.get("result") { + Some(result) => Ok(json!({ "value": result })), + None => Err(VcsFailure { + code: codes::INVALID_OUTPUT, + message: "The VCS provider returned no result.".to_string(), + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn answer(value: Value) -> Value { + json!({ "experimental": { "litheVcs": value } }) + } + + const REQUIRED: &str = r#"{ "status": 1, "baseContent": 1, "checkin": 1, "rollback": 1 }"#; + + #[test] + fn only_a_complete_answer_to_an_offered_version_counts() { + let capabilities: Value = serde_json::from_str(REQUIRED).expect("JSON"); + let negotiation = negotiate(&answer(json!({ + "version": 1, "vcs": "svn", "capabilities": capabilities, "future": true + }))) + .expect("negotiated"); + assert_eq!(negotiation.vcs, "svn"); + assert_eq!(negotiation.capabilities.len(), 4); + // A version the host never offered, a malformed VCS ID, a missing + // capability map, or a missing required capability must keep VCS + // requests off the session. + for rejected in [ + json!({ "version": 2, "vcs": "svn", "capabilities": capabilities }), + json!({ "version": 1, "vcs": "SVN", "capabilities": capabilities }), + json!({ "version": 1, "vcs": "svn" }), + json!({ "version": 1, "vcs": "svn", "capabilities": { "status": 1 } }), + ] { + assert_eq!(negotiate(&answer(rejected.clone())), None, "{rejected}"); + } + assert_eq!(negotiate(&json!({})), None); + } + + #[test] + fn provider_errors_keep_only_contract_codes() { + let failure = reply_outcome(&json!({ + "error": { "code": -32000, "message": "Out of date", "data": { "code": "vcs.outOfDate" } } + })) + .expect_err("failed"); + assert_eq!(failure.code, "vcs.outOfDate"); + // A plugin cannot make its own code look like one of Lithe's. + let failure = reply_outcome(&json!({ + "error": { "code": -32000, "message": "", "data": { "code": "plugin.vcs.invalidOutput" } } + })) + .expect_err("failed"); + assert_eq!(failure.code, codes::FAILED); + assert_eq!(failure.message, "The VCS provider failed."); + assert_eq!( + reply_outcome(&json!({ "result": null })).expect("ok"), + json!({ "value": null }) + ); + } +} diff --git a/rust/lithe-core/src/lsp/languages/jdt.rs b/rust/lithe-core/src/lsp/languages/jdt.rs index 0126f7382b..a396d984fe 100644 --- a/rust/lithe-core/src/lsp/languages/jdt.rs +++ b/rust/lithe-core/src/lsp/languages/jdt.rs @@ -162,6 +162,9 @@ pub(crate) struct JdtJavaRuntime { /// Values Lithe owns in JDT LS's `settings.java`, borrowed from one session. #[derive(Debug, Clone, Copy, Default)] pub(crate) struct JdtSettings<'a> { + /// Plugin-declared completion options from this session's initialization; + /// all leaves survive except the provider-owned guessMethodArguments. + pub completion: Option<&'a Value>, pub maven: Option<&'a JdtMavenConfiguration>, /// Gradle import settings, present only for Gradle workspaces. pub gradle: Option<&'a JdtGradleConfiguration>, @@ -443,7 +446,7 @@ pub(crate) fn adapt_initialization_options( /// initialization options. /// /// Catalog keys that the provider does not own are preserved, while the Maven, -/// build, and code-lens values Lithe depends on stay authoritative. +/// build, completion, and code-lens values Lithe depends on stay authoritative. fn merge_java_settings(options: &mut Map, provider_settings: Value) { let settings = options.entry("settings").or_insert_with(|| json!({})); if !settings.is_object() { @@ -467,6 +470,19 @@ fn merge_java_settings(options: &mut Map, provider_settings: Valu return; }; for (key, value) in provider_java { + if key == "completion" { + let completion = java.entry(key).or_insert_with(|| json!({})); + if !completion.is_object() { + *completion = json!({}); + } + completion.as_object_mut().unwrap().extend( + value + .as_object() + .expect("completion settings are an object") + .clone(), + ); + continue; + } // `java.import.gradle` is the only `import` leaf Lithe owns. The options // Lithe deliberately never guesses (`java.home`, `version`, // `offline.enabled`, ...) have to survive, so the owned leaves merge @@ -1074,6 +1090,9 @@ fn java_settings(settings: JdtSettings<'_>) -> Value { "configuration": { "updateBuildConfiguration": "automatic" }, + // JDT LS otherwise inserts parameter names into calls. Its "off" + // mode keeps the argument list empty and the snippet cursor inside. + "completion": java_completion_settings(settings.completion), "inlayHints": { "parameterNames": { "enabled": "all" @@ -1104,6 +1123,17 @@ fn java_settings(settings: JdtSettings<'_>) -> Value { java } +/// Retains the plugin's completion leaves while applying Lithe's argument policy. +/// Invalid section shapes are normalized without mutating the session input. +fn java_completion_settings(declared: Option<&Value>) -> Value { + let mut completion = declared + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + completion.insert("guessMethodArguments".to_string(), json!("off")); + Value::Object(completion) +} + fn java_runtimes_value(runtimes: &[JdtJavaRuntime]) -> Value { Value::Array( runtimes @@ -1163,6 +1193,15 @@ fn java_configuration_for_section(section: Option<&str>, settings: JdtSettings<' let gradle_configuration = settings.gradle; match section { Some("java") => java_settings(settings)["java"].clone(), + Some("java.completion") => java_settings(settings)["java"]["completion"].clone(), + Some(section) if section.starts_with("java.completion.") => { + let completion = java_completion_settings(settings.completion); + section["java.completion.".len()..] + .split('.') + .try_fold(&completion, |value, key| value.get(key)) + .cloned() + .unwrap_or(Value::Null) + } Some("java.inlayHints") => json!({ "parameterNames": { "enabled": "all" @@ -1424,6 +1463,7 @@ mod tests { maven: None, gradle: None, java_runtimes: &runtimes, + ..Default::default() }; let expected = json!([{ "name": "JavaSE-25", "path": "C:/Program Files/Java/jdk-25" }]); let options = adapt_initialization_options("java", None, &[], settings).unwrap(); @@ -1462,6 +1502,7 @@ mod tests { maven: Some(configuration), gradle: None, java_runtimes: &[], + ..Default::default() } } @@ -1470,6 +1511,7 @@ mod tests { maven: None, gradle: Some(configuration), java_runtimes: &[], + ..Default::default() } } @@ -1968,6 +2010,63 @@ mod tests { assert_eq!(options["workspace"]["custom"], true); } + #[test] + fn java_completion_leaves_arguments_empty_across_configuration_channels() { + // Initialization, notifications and pull configuration must agree, or + // JDT LS can restore parameter placeholders after the first completion. + let fixture: Value = serde_json::from_str(include_str!( + "../../../../../shared/fixtures/lsp/java-completion-v1.json" + )) + .expect("Java completion fixture should be valid"); + let expected = &fixture["javaSettings"]["completion"]; + let settings = JdtSettings { + completion: Some(&fixture["pluginCompletion"]), + ..Default::default() + }; + let options = adapt_initialization_options( + "java", + Some(json!({ "settings": { "java": { "completion": fixture["pluginCompletion"] } } })), + &[], + settings, + ) + .unwrap(); + assert_eq!( + options["settings"]["java"]["completion"]["guessMethodArguments"], + expected["guessMethodArguments"] + ); + assert_eq!( + options["settings"]["java"]["completion"]["favoriteStaticMembers"], + json!(["java.util.Objects.*"]) + ); + + assert_eq!(options["settings"]["java"]["completion"], *expected); + let notification = initialized_notification("java", settings).unwrap(); + assert_eq!( + notification.params["settings"]["java"]["completion"], + *expected + ); + let items = fixture["workspaceConfigurationSections"] + .as_array() + .unwrap() + .iter() + .map(|section| WorkspaceConfigurationItem { + scope_uri: None, + section: Some(section.as_str().unwrap().to_string()), + }) + .collect::>(); + let values = workspace_configuration("java", &items, settings).unwrap(); + assert_eq!(values[0]["completion"], *expected); + assert_eq!(values[1], *expected); + assert_eq!(values[2], expected["guessMethodArguments"]); + assert_eq!(values[3], expected["favoriteStaticMembers"]); + assert_eq!(values[4], expected["filteredTypes"]); + assert_eq!(values[5], Value::Null); + assert_eq!( + fixture["pluginCompletion"]["guessMethodArguments"], + "insertParameterNames" + ); + } + #[test] fn java_initialization_options_carry_settings_without_a_maven_context() { let options = diff --git a/rust/lithe-core/src/mcp/featured.json b/rust/lithe-core/src/mcp/featured.json new file mode 100644 index 0000000000..c697d34d1f --- /dev/null +++ b/rust/lithe-core/src/mcp/featured.json @@ -0,0 +1,183 @@ +{ + "reviewed": "2026-10-22", + "servers": [ + { + "server": { + "name": "lithe.featured/context7", + "title": "Context7", + "publisher": "Upstash", + "description": "Current library documentation and code examples for coding agents.", + "websiteUrl": "https://github.com/upstash/context7", + "remotes": [ + { + "type": "streamable-http", + "url": "https://mcp.context7.com/mcp" + } + ] + } + }, + { + "server": { + "name": "lithe.featured/playwright", + "title": "Playwright", + "publisher": "Microsoft", + "description": "Open pages, click, type and inspect a browser through its accessibility tree.", + "version": "0.0.83", + "websiteUrl": "https://github.com/microsoft/playwright-mcp", + "packages": [ + { + "registryType": "npm", + "identifier": "@playwright/mcp", + "version": "0.0.83", + "transport": { + "type": "stdio" + } + } + ] + } + }, + { + "server": { + "name": "lithe.featured/chrome-devtools", + "title": "Chrome DevTools", + "publisher": "Google", + "description": "Inspect Chrome pages, console messages, network requests and performance.", + "version": "1.11.0", + "websiteUrl": "https://github.com/ChromeDevTools/chrome-devtools-mcp", + "packages": [ + { + "registryType": "npm", + "identifier": "chrome-devtools-mcp", + "version": "1.11.0", + "transport": { + "type": "stdio" + } + } + ] + } + }, + { + "server": { + "name": "lithe.featured/microsoft-learn", + "title": "Microsoft Learn", + "publisher": "Microsoft", + "description": "Search official Microsoft and Azure documentation and code samples.", + "websiteUrl": "https://github.com/MicrosoftDocs/mcp", + "remotes": [ + { + "type": "streamable-http", + "url": "https://learn.microsoft.com/api/mcp" + } + ] + } + }, + { + "server": { + "name": "lithe.featured/github", + "title": "GitHub", + "publisher": "GitHub", + "description": "Work with GitHub repositories, issues, pull requests and Actions. A personal access token is required.", + "websiteUrl": "https://github.com/github/github-mcp-server", + "remotes": [ + { + "type": "streamable-http", + "url": "https://api.githubcopilot.com/mcp/" + } + ] + }, + "setup": "bearer-token" + }, + { + "server": { + "name": "lithe.featured/deepwiki", + "title": "DeepWiki", + "publisher": "Cognition", + "description": "Ask questions about public GitHub repositories and read their generated documentation.", + "websiteUrl": "https://docs.devin.ai/work-with-devin/deepwiki-mcp", + "remotes": [ + { + "type": "streamable-http", + "url": "https://mcp.deepwiki.com/mcp" + } + ] + } + }, + { + "server": { + "name": "lithe.featured/exa", + "title": "Exa", + "publisher": "Exa", + "description": "Search the web and code, and retrieve page content for coding agents.", + "websiteUrl": "https://exa.ai/docs/get-started/exa-mcp", + "remotes": [ + { + "type": "streamable-http", + "url": "https://mcp.exa.ai/mcp" + } + ] + } + }, + { + "server": { + "name": "lithe.featured/filesystem", + "title": "Filesystem", + "publisher": "Model Context Protocol", + "description": "Read, write and search files only in the directories you allow. Requires Node.js 24 or later.", + "websiteUrl": "https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem", + "version": "2.0.0", + "packages": [ + { + "registryType": "npm", + "identifier": "@modelcontextprotocol/server-filesystem", + "version": "2.0.0", + "transport": { + "type": "stdio" + } + } + ] + }, + "setup": "allowed-directories" + }, + { + "server": { + "name": "lithe.featured/fetch", + "title": "Fetch", + "publisher": "Model Context Protocol", + "description": "Fetch web pages and convert their content to Markdown. Requires uv and Python 3.10 or later.", + "websiteUrl": "https://github.com/modelcontextprotocol/servers/tree/main/src/fetch", + "version": "2026.10.19", + "packages": [ + { + "registryType": "pypi", + "identifier": "mcp-server-fetch", + "version": "2026.10.19", + "transport": { + "type": "stdio" + } + } + ] + } + }, + { + "server": { + "name": "lithe.featured/memory", + "title": "Memory", + "publisher": "Model Context Protocol", + "description": "Keep a persistent knowledge graph between conversations. Requires Node.js 24 or later.", + "websiteUrl": "https://github.com/modelcontextprotocol/servers/tree/main/src/memory", + "version": "2.0.0", + "packages": [ + { + "registryType": "npm", + "identifier": "@modelcontextprotocol/server-memory", + "version": "2.0.0", + "transport": { + "type": "stdio" + } + } + ] + } + } + ], + "metadata": {} +} diff --git a/rust/lithe-core/src/mcp/mod.rs b/rust/lithe-core/src/mcp/mod.rs index d44eb12bfd..557e219a30 100644 --- a/rust/lithe-core/src/mcp/mod.rs +++ b/rust/lithe-core/src/mcp/mod.rs @@ -8,12 +8,57 @@ mod json_edit; mod model; mod parse; mod plan; +mod registry; mod source_hash; mod toml_edit; +mod tools; pub use model::{McpKeyValue, McpServer, McpTransport}; pub use parse::parse_configuration; pub use plan::plan_write; +pub use registry::{featured_registry, parse_registry}; +pub use tools::{command_line, tool_policy}; + +/// Bridges a user-requested probe to the process/network owner in Agent Host. +pub(crate) fn probe( + request: lithe_agent_host::mcp_probe::Request, +) -> Result { + use crate::protocol::{cancellation, CoreError, ErrorCode}; + cancellation::check()?; + let result = lithe_agent_host::mcp_probe::execute(request, &|| cancellation::check().is_err()); + cancellation::check()?; + result.map_err(|error| { + use lithe_agent_host::mcp_probe::Error; + let (code, message) = match error { + Error::Invalid => ( + ErrorCode::InvalidRequest, + "Check the MCP command, URL and headers.", + ), + Error::Unsupported => ( + ErrorCode::NotSupported, + "Legacy SSE probing is unavailable. Use the publisher's Streamable HTTP endpoint.", + ), + Error::Start => ( + ErrorCode::RuntimeMissing, + "Could not start the MCP command. Check its executable and runtime.", + ), + Error::Connection => ( + ErrorCode::ProcessFailed, + "MCP connection failed. Check the server, network and authentication.", + ), + Error::Limit => ( + ErrorCode::InvalidRequest, + "MCP tool metadata exceeded the inspection limit.", + ), + Error::Timeout => ( + ErrorCode::TimedOut, + "MCP detection timed out after 30 seconds.", + ), + Error::Cancelled => (ErrorCode::Cancelled, "MCP detection was cancelled."), + }; + CoreError::new(code, message) + }) +} #[cfg(test)] mod tests; diff --git a/rust/lithe-core/src/mcp/plan.rs b/rust/lithe-core/src/mcp/plan.rs index 759d460527..d610a1634c 100644 --- a/rust/lithe-core/src/mcp/plan.rs +++ b/rust/lithe-core/src/mcp/plan.rs @@ -11,7 +11,7 @@ use super::parse::{ use super::toml_edit; use super::{McpKeyValue, McpServer}; use crate::protocol::{CoreError, ErrorCode}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; #[derive(Deserialize)] @@ -55,6 +55,21 @@ struct WriteRequest { /// Entries the host recorded as written by Lithe. #[serde(default)] owned: Vec, + /// Non-secret native filters retained when this server was removed. + #[serde(default)] + tool_filters: Option, +} + +/// Native Codex restrictions, kept separately from credentials and connection fields. +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ToolFilters { + /// Absence differs from an empty allow-list, which allows no tools. + #[serde(skip_serializing_if = "Option::is_none")] + enabled_tools: Option>, + /// Preserve external exclusions as well as those owned by Lithe. + #[serde(skip_serializing_if = "Option::is_none")] + disabled_tools: Option>, } fn invalid_request() -> CoreError { @@ -167,7 +182,16 @@ fn plan_set( }; let next = match request.source.as_str() { "claude" => write_claude(content, project_path, &server.name, server, previous)?, - "codex" => write_codex(content, server, previous)?, + "codex" => write_codex( + content, + server, + previous, + if action == "inserted" { + request.tool_filters.as_ref() + } else { + None + }, + )?, _ => return Err(invalid_request()), }; // Unknown fields survive edits, so hash the actual output rather than the @@ -228,14 +252,50 @@ fn plan_remove( if next == content { return Ok(with_hashes(unchanged(content, "absent", None), current)); } + // Capture before deletion; malformed native restrictions must fail closed + // rather than disappearing when an Agent is switched back on. + let tool_filters = if request.source == "codex" { + codex_tool_filters(content, name)? + } else { + None + }; Ok(json!({ "content": next, "changed": true, "action": "removed", "conflict": Value::Null, + "definitionHash": current.definition_hash, + "toolFilters": tool_filters, })) } +/// Extract only the native tool lists; never archive env, headers or auth values. +fn codex_tool_filters(content: &str, name: &str) -> Result, CoreError> { + let document: toml::Value = toml::from_str(content).map_err(|_| invalid_request())?; + let entry = document + .get("mcp_servers") + .and_then(|v| v.get(name)) + .ok_or_else(invalid_request)?; + let list = |key: &str| -> Result>, CoreError> { + entry + .get(key) + .map(|value| { + value + .as_array() + .ok_or_else(invalid_request)? + .iter() + .map(|v| v.as_str().map(str::to_owned).ok_or_else(invalid_request)) + .collect() + }) + .transpose() + }; + let filters = ToolFilters { + enabled_tools: list("enabled_tools")?, + disabled_tools: list("disabled_tools")?, + }; + Ok((filters.enabled_tools.is_some() || filters.disabled_tools.is_some()).then_some(filters)) +} + fn existing_servers( request: &WriteRequest, content: &str, @@ -407,9 +467,29 @@ fn write_codex( content: &str, server: &McpServer, previous: Option<&str>, + filters: Option<&ToolFilters>, ) -> Result { let mut document = toml_edit::document(content)?; toml_edit::set_server(&mut document, server, previous)?; + if let Some(filters) = filters { + let entry = document + .get_mut("mcp_servers") + .and_then(|v| v.get_mut(&server.name)) + .and_then(::toml_edit::Item::as_table_like_mut) + .ok_or_else(invalid_request)?; + for (key, values) in [ + ("enabled_tools", &filters.enabled_tools), + ("disabled_tools", &filters.disabled_tools), + ] { + if let Some(values) = values { + let mut array = ::toml_edit::Array::new(); + for value in values { + array.push(value.as_str()); + } + entry.insert(key, ::toml_edit::value(array)); + } + } + } Ok(document.to_string()) } diff --git a/rust/lithe-core/src/mcp/registry.rs b/rust/lithe-core/src/mcp/registry.rs new file mode 100644 index 0000000000..b68cae34be --- /dev/null +++ b/rust/lithe-core/src/mcp/registry.rs @@ -0,0 +1,361 @@ +//! Normalize public MCP Registry metadata without fetching or executing servers. + +use super::model::{McpServer, McpTransport}; +use crate::protocol::{CoreError, ErrorCode}; +use serde_json::{json, Value}; + +/// Returns the release-owned catalog without reading files or using the network. +/// Entries pass through the same draft validation as public Registry responses. +pub fn featured_registry() -> Result { + let content = include_str!("featured.json"); + let catalog: Value = serde_json::from_str(content) + .map_err(|_| CoreError::new(ErrorCode::InvalidRequest, "Invalid featured MCP catalog"))?; + let mut page = parse_registry(json!({"content": content}))?; + if let Some(entries) = page.get_mut("entries").and_then(Value::as_array_mut) { + for entry in entries { + entry["source"] = json!("featured"); + // Only the release-owned catalog may declare an incomplete template. + // Public Registry input must still contain a complete launch recipe. + let setup = catalog["servers"] + .as_array() + .and_then(|servers| { + servers + .iter() + .find(|item| item["server"]["name"] == entry["id"]) + }) + .and_then(|item| item.get("setup")) + .and_then(Value::as_str); + if let Some(setup) = setup { + if !matches!(setup, "bearer-token" | "allowed-directories") + || entry["draft"].is_null() + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid featured MCP setup", + )); + } + entry["setup"] = json!(setup); + } + } + } + Ok(page) +} + +/// Reads one bounded v0.1 registry page. Only simple, fully specified launch +/// recipes become drafts; complex inputs remain documentation-only entries. +pub fn parse_registry(payload: Value) -> Result { + let content = payload.get("content").and_then(Value::as_str).unwrap_or(""); + let invalid = || CoreError::new(ErrorCode::InvalidRequest, "Invalid MCP registry response"); + if content.len() > 1024 * 1024 { + return Err(invalid()); + } + let page: Value = serde_json::from_str(content).map_err(|_| invalid())?; + let servers = page + .get("servers") + .and_then(Value::as_array) + .ok_or_else(invalid)?; + if servers.len() > 100 { + return Err(invalid()); + } + let mut entries = Vec::new(); + for item in servers { + let status = item + .pointer("/_meta/io.modelcontextprotocol.registry~1official/status") + .and_then(Value::as_str); + if status.is_some_and(|s| s != "active") { + continue; + } + let Some(server) = item.get("server").filter(|s| s.is_object()) else { + continue; + }; + let Some(name) = server + .get("name") + .and_then(Value::as_str) + .filter(|s| !s.is_empty() && s.len() < 256) + else { + continue; + }; + let title = server.get("title").and_then(Value::as_str).unwrap_or(name); + let mut draft_name: String = name + .rsplit('/') + .next() + .unwrap_or(name) + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || "._-".contains(c) { + c + } else { + '-' + } + }) + .collect(); + if !draft_name.starts_with(|c: char| c.is_ascii_alphanumeric()) { + draft_name.insert_str(0, "mcp-"); + } + let mut draft = None; + if let Some(remotes) = server.get("remotes").and_then(Value::as_array) { + for remote in remotes { + let transport = match remote.get("type").and_then(Value::as_str) { + Some("streamable-http") => McpTransport::Http, + Some("sse") => McpTransport::Sse, + _ => continue, + }; + // Dynamic URLs, headers and authentication require the publisher's + // setup instructions; never turn unresolved placeholders into a config. + if !empty_array(remote, "headers") + || remote + .get("variables") + .is_some_and(|v| v.as_object().is_none_or(|o| !o.is_empty())) + { + continue; + } + if let Some(url) = safe_url(remote.get("url")) { + draft = Some(McpServer::new( + draft_name.clone(), + transport, + None, + vec![], + vec![], + Some(url), + vec![], + )); + break; + } + } + } + if draft.is_none() { + if let Some(packages) = server.get("packages").and_then(Value::as_array) { + for package in packages { + if package.pointer("/transport/type").and_then(Value::as_str) != Some("stdio") + || [ + "runtimeArguments", + "packageArguments", + "environmentVariables", + ] + .iter() + .any(|key| !empty_array(package, key)) + { + continue; + } + let Some(id) = package.get("identifier").and_then(Value::as_str) else { + continue; + }; + let Some(version) = package.get("version").and_then(Value::as_str) else { + continue; + }; + // Dist-tags such as "latest" are mutable and cannot be pinned. + if !safe_package(id) + || !safe_package(version) + || !version.starts_with(|c: char| c.is_ascii_digit()) + { + continue; + } + let (command, args, base) = + match package.get("registryType").and_then(Value::as_str) { + Some("npm") if exact_npm_version(version) => ( + "npx", + vec!["-y".into(), format!("{id}@{version}")], + "https://registry.npmjs.org", + ), + Some("pypi") => { + ("uvx", vec![format!("{id}=={version}")], "https://pypi.org") + } + _ => continue, + }; + // A package on another registry must not be resolved from the + // public default merely because its identifier looks familiar. + if package.get("registryBaseUrl").is_some_and(|url| { + safe_url(Some(url)).is_none_or(|url| url.trim_end_matches('/') != base) + }) { + continue; + } + if package + .get("runtimeHint") + .is_some_and(|hint| hint.as_str() != Some(command)) + { + continue; + } + draft = Some(McpServer::new( + draft_name.clone(), + McpTransport::Stdio, + Some(command.into()), + args, + vec![], + None, + vec![], + )); + break; + } + } + } + entries.push(json!({"id": name, "title": title, "summary": server.get("description").and_then(Value::as_str).unwrap_or(""), + "publisher": server.get("publisher").and_then(Value::as_str), "source": "registry", + "version": server.get("version").and_then(Value::as_str).unwrap_or(""), + "website": safe_url(server.get("websiteUrl")).or_else(|| safe_url(server.pointer("/repository/url"))), "draft": draft})); + } + let cursor = page + .pointer("/metadata/nextCursor") + .and_then(Value::as_str) + .filter(|s| !s.is_empty() && s.len() <= 2048); + Ok(json!({"entries": entries, "nextCursor": cursor})) +} + +fn empty_array(value: &Value, key: &str) -> bool { + value + .get(key) + .is_none_or(|v| v.as_array().is_some_and(Vec::is_empty)) +} + +fn safe_package(value: &str) -> bool { + !value.is_empty() + && value.len() < 256 + && !value.starts_with('-') + && value + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b"@/._-+".contains(&c)) +} + +fn exact_npm_version(version: &str) -> bool { + let components: Vec<_> = version + .split(['-', '+']) + .next() + .unwrap_or("") + .split('.') + .collect(); + components.len() == 3 + && components + .iter() + .all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit())) + && version + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b".-+".contains(&b)) +} + +fn safe_url(value: Option<&Value>) -> Option { + let raw = value?.as_str()?; + if raw.contains(['{', '}']) { + return None; + } + let url = url::Url::parse(raw).ok()?; + (url.scheme() == "https" + && url.host_str().is_some() + && url.username().is_empty() + && url.password().is_none()) + .then(|| url.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn featured_catalog_is_available_offline_with_pinned_reviewable_drafts() { + let page = featured_registry().unwrap(); + let entries = page["entries"].as_array().unwrap(); + assert_eq!(entries.len(), 10); + assert!(page["nextCursor"].is_null()); + assert!(entries + .iter() + .all(|e| e["source"] == "featured" && e["draft"].is_object())); + assert_eq!(entries[0]["publisher"], "Upstash"); + assert_eq!( + entries[1]["draft"]["args"], + json!(["-y", "@playwright/mcp@0.0.83"]) + ); + assert_eq!( + entries[2]["draft"]["args"], + json!(["-y", "chrome-devtools-mcp@1.11.0"]) + ); + let find = |name: &str| entries.iter().find(|e| e["draft"]["name"] == name).unwrap(); + assert_eq!(find("github")["setup"], "bearer-token"); + assert_eq!( + find("github")["draft"]["url"], + "https://api.githubcopilot.com/mcp/" + ); + assert_eq!( + find("deepwiki")["draft"]["url"], + "https://mcp.deepwiki.com/mcp" + ); + assert_eq!(find("exa")["draft"]["url"], "https://mcp.exa.ai/mcp"); + assert_eq!(find("filesystem")["setup"], "allowed-directories"); + assert_eq!( + find("filesystem")["draft"]["args"], + json!(["-y", "@modelcontextprotocol/server-filesystem@2.0.0"]) + ); + assert_eq!( + find("memory")["draft"]["args"], + json!(["-y", "@modelcontextprotocol/server-memory@2.0.0"]) + ); + assert_eq!( + find("fetch")["draft"]["args"], + json!(["mcp-server-fetch==2026.10.19"]) + ); + } + + #[test] + fn public_registry_cannot_declare_trusted_featured_setup() { + let page = parse_registry(json!({"content": json!({"servers": [{ + "setup": "bearer-token", "server": {"name": "lithe.featured/github", + "setup": "bearer-token", "remotes": [{"type": "streamable-http", "url": "https://example.invalid/mcp"}]} + }]}).to_string()})).unwrap(); + assert_eq!(page["entries"][0]["source"], "registry"); + assert!(page["entries"][0].get("setup").is_none()); + } + + #[test] + fn registry_page_keeps_versions_cursor_and_only_complete_drafts() { + let content = include_str!("../../../../shared/contracts/mcp-registry-page.json"); + let page = parse_registry(json!({"content": content})).unwrap(); + assert_eq!(page["entries"].as_array().unwrap().len(), 3); + assert_eq!(page["nextCursor"], "opaque/+ cursor?"); + assert_eq!(page["entries"][0]["draft"]["transport"], "http"); + assert_eq!( + page["entries"][1]["draft"]["args"], + json!(["-y", "@example/browser@2.0.0"]) + ); + assert!(page["entries"][2]["draft"].is_null()); + } + + #[test] + fn untrusted_links_dynamic_urls_and_package_options_are_not_launch_recipes() { + for url in [ + "javascript:alert(1)", + "https://user:secret@example.invalid/mcp", + "https://example.invalid/{tenant}", + ] { + let page = parse_registry(json!({"content": json!({"servers": [{"server": { + "name": "io.example/untrusted", "websiteUrl": url, + "remotes": [{"type": "streamable-http", "url": url}], + "packages": [{"registryType":"npm", "identifier":"--eval", "version":"1", "transport":{"type":"stdio"}}] + }}]}).to_string()})).unwrap(); + assert!(page["entries"][0]["draft"].is_null()); + assert!(page["entries"][0]["website"].is_null()); + } + for (version, hint, base) in [ + ("latest", "npx", "https://registry.npmjs.org"), + ("1", "npx", "https://registry.npmjs.org"), + ("1.0.0", "node", "https://registry.npmjs.org"), + ("1.0.0", "npx", "https://example.invalid/npm"), + ] { + let page = parse_registry(json!({"content": json!({"servers": [{"server": { + "name": "io.example/needs-setup", + "packages": [{"registryType":"npm", "identifier":"example", "version":version, + "runtimeHint":hint, "registryBaseUrl":base, "transport":{"type":"stdio"}}] + }}]}).to_string()})) + .unwrap(); + assert!(page["entries"][0]["draft"].is_null()); + } + } + + #[test] + fn malformed_and_oversized_pages_fail_without_echoing_contents() { + for content in [ + "secret invalid json".to_string(), + "x".repeat(1024 * 1024 + 1), + "{}".to_string(), + ] { + let error = parse_registry(json!({"content": content})).unwrap_err(); + assert!(!error.message.contains("secret")); + } + } +} diff --git a/rust/lithe-core/src/mcp/tests.rs b/rust/lithe-core/src/mcp/tests.rs index 5dd12651b1..7aaa7089f9 100644 --- a/rust/lithe-core/src/mcp/tests.rs +++ b/rust/lithe-core/src/mcp/tests.rs @@ -46,6 +46,61 @@ fn owned_entry(parsed: &Value, name: &str) -> Value { }) } +#[test] +fn removed_codex_filters_restore_without_credentials_or_allowlist_widening() { + for allowed in ["[]", "['read', 'write']"] { + let content = format!("model = 'keep'\n[mcp_servers.alpha]\ncommand = 'echo'\nenabled_tools = {allowed}\ndisabled_tools = ['external', 'write']\nenv = {{ TOKEN = 'secret' }}\n"); + let parsed = parse("codex", "global", &content, None); + let removed = plan(json!({"source":"codex","scope":"global","content":content, + "operation":"remove","name":"alpha","owned":[owned_entry(&parsed,"alpha")]})); + assert_eq!(removed["action"], "removed"); + let filters = &removed["toolFilters"]; + assert_eq!(filters["disabledTools"], json!(["external", "write"])); + assert!(!filters.to_string().contains("secret")); + let restored = plan( + json!({"source":"codex","scope":"global","content":removed["content"], + "operation":"set","server":parsed["servers"][0],"owned":[],"toolFilters":filters}), + ); + let next: toml::Value = toml::from_str(restored["content"].as_str().unwrap()).unwrap(); + let original: toml::Value = toml::from_str(&content).unwrap(); + assert_eq!( + next["mcp_servers"]["alpha"], + original["mcp_servers"]["alpha"] + ); + assert_eq!(next["model"].as_str(), Some("keep")); + let reparsed = parse( + "codex", + "global", + restored["content"].as_str().unwrap(), + None, + ); + assert_eq!(restored["sourceHash"], source_hash(&reparsed, "alpha")); + } +} + +#[test] +fn retained_codex_filters_never_replace_live_native_policy() { + let content = "[mcp_servers.alpha]\ncommand = 'echo'\ndisabled_tools = ['external']\nenabled_tools = []\n"; + let parsed = parse("codex", "global", content, None); + let restored = plan(json!({"source":"codex","scope":"global","content":content, + "operation":"set","server":parsed["servers"][0],"owned":[owned_entry(&parsed,"alpha")], + "toolFilters":{"disabledTools":[],"enabledTools":["write"]}})); + assert_eq!(restored["content"], content); + assert_eq!(restored["changed"], false); +} + +#[test] +fn malformed_native_tool_lists_cannot_be_deleted_and_forgotten() { + let content = "[mcp_servers.alpha]\ncommand = 'echo'\ndisabled_tools = ['read', 1]\n"; + let parsed = parse("codex", "global", content, None); + let result = plan_write(json!({"source":"codex","scope":"global","content":content, + "operation":"remove","name":"alpha","owned":[owned_entry(&parsed,"alpha")]})); + assert!(matches!( + result.unwrap_err().code, + ErrorCode::InvalidRequest + )); +} + fn plan(request: Value) -> Value { plan_write(request).expect("write plan should succeed") } diff --git a/rust/lithe-core/src/mcp/tools.rs b/rust/lithe-core/src/mcp/tools.rs new file mode 100644 index 0000000000..b239797cc9 --- /dev/null +++ b/rust/lithe-core/src/mcp/tools.rs @@ -0,0 +1,254 @@ +//! Deterministic command-line editing and native per-tool exclusion planning. +//! Hosts own file snapshots and remember only the exact exclusions they added. + +use super::{json_edit::JsonDocument, parse::MAX_CONFIGURATION_BYTES}; +use crate::protocol::{CoreError, ErrorCode}; +use serde::Deserialize; +use serde_json::{json, Value}; + +fn invalid() -> CoreError { + CoreError::new(ErrorCode::InvalidRequest, "Invalid MCP tool configuration") +} + +/// Parses or formats one terminal-like argv projection without invoking a shell. +pub fn command_line(payload: Value) -> Result { + let line = if payload["action"] == "format" { + let command = payload["command"].as_str().ok_or_else(invalid)?; + let args = strings(payload.get("args"))?; + if command.is_empty() && args.is_empty() { + return Ok(json!({"line":""})); + } + if command.len() + args.iter().map(String::len).sum::() > MAX_CONFIGURATION_BYTES { + return Err(invalid()); + } + let all = std::iter::once(command).chain(args.iter().map(String::as_str)); + return Ok(json!({"line": shell_words::join(all)})); + } else if payload["action"] == "parse" { + payload["line"].as_str().ok_or_else(invalid)? + } else { + return Err(invalid()); + }; + if line.len() > MAX_CONFIGURATION_BYTES { + return Err(invalid()); + } + let parts = shell_words::split(line).map_err(|_| invalid())?; + Ok( + json!({"command": parts.first().cloned().unwrap_or_default(), "args": parts.into_iter().skip(1).collect::>() }), + ) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PolicyRequest { + /// Native owner, `codex` or `claude`. + source: String, + /// Current bounded UTF-8 text from the platform snapshot. + content: String, + /// Native MCP entry name. + server: String, + /// Names reported by this server, never a wildcard expression. + tools: Vec, + /// Exclusions previously added by this Lithe library for this native path. + #[serde(default)] + owned: Vec, + /// Optional explicit one-tool mutation; absence reads policy only. + tool: Option, + enabled: Option, +} + +/// Returns effective local exclusions and an optional format-preserving edit. +/// External exact/wildcard denies and allow-lists are never relaxed here. +pub fn tool_policy(payload: Value) -> Result { + let r: PolicyRequest = serde_json::from_value(payload).map_err(|_| invalid())?; + if r.content.len() > MAX_CONFIGURATION_BYTES + || r.tools.len() > 1000 + || r.owned.len() > 1000 + || !valid_name(&r.server) + || r.tools.iter().chain(&r.owned).any(|s| !valid_name(s)) + || r.tool.as_ref().is_some_and(|s| !r.tools.contains(s)) + || r.tool.is_some() != r.enabled.is_some() + { + return Err(invalid()); + } + let (mut denied, allow) = match r.source.as_str() { + "codex" => { + let doc: toml::Value = toml::from_str(&r.content).map_err(|_| invalid())?; + let entry = doc + .get("mcp_servers") + .and_then(|v| v.get(&r.server)) + .ok_or_else(invalid)?; + let list = |key: &str| -> Result, CoreError> { + match entry.get(key) { + None => Ok(vec![]), + Some(v) => v + .as_array() + .ok_or_else(invalid)? + .iter() + .map(|s| s.as_str().map(str::to_owned).ok_or_else(invalid)) + .collect(), + } + }; + ( + list("disabled_tools")?, + if entry.get("enabled_tools").is_some() { + Some(list("enabled_tools")?) + } else { + None + }, + ) + } + "claude" => { + let doc: Value = serde_json::from_str(if r.content.is_empty() { + "{}" + } else { + &r.content + }) + .map_err(|_| invalid())?; + if !doc.is_object() || doc.get("permissions").is_some_and(|v| !v.is_object()) { + return Err(invalid()); + } + (strings(doc.pointer("/permissions/deny"))?, None) + } + _ => return Err(invalid()), + }; + let rule = |tool: &str| -> String { + if r.source == "claude" { + format!("mcp__{}__{}", normalize(&r.server), normalize(tool)) + } else { + tool.to_owned() + } + }; + if r.source == "claude" + && r.tool.as_ref().is_some_and(|tool| { + r.tools + .iter() + .any(|other| other != tool && normalize(other) == normalize(tool)) + }) + { + return Err(invalid()); + } + let is_blocked = |tool: &str, list: &[String]| { + if r.source == "codex" { + list.iter().any(|s| s == tool) + } else { + let exact = rule(tool); + list.iter() + .any(|s| wildcard(s, &exact) || s == &format!("mcp__{}", normalize(&r.server))) + } + }; + let mut owned = r.owned.clone(); + let mut changed = false; + if let (Some(tool), Some(enabled)) = (&r.tool, r.enabled) { + let exact = rule(tool); + if enabled { + if allow.as_ref().is_some_and(|a| !a.contains(tool)) + || (is_blocked(tool, &denied) && !owned.contains(tool)) + { + return Err(CoreError::new(ErrorCode::PermissionDenied, "This tool is restricted outside Lithe. Edit the native Agent policy to enable it.")); + } + let length = denied.len(); + denied.retain(|s| s != &exact); + changed = length != denied.len(); + owned.retain(|s| s != tool); + } else if !is_blocked(tool, &denied) { + denied.push(exact); + if !owned.contains(tool) { + owned.push(tool.clone()); + } + changed = true; + } + } + let content = if changed { + if r.source == "codex" { + let mut doc: toml_edit::DocumentMut = r.content.parse().map_err(|_| invalid())?; + let entry = doc + .get_mut("mcp_servers") + .and_then(|v| v.get_mut(&r.server)) + .and_then(toml_edit::Item::as_table_like_mut) + .ok_or_else(invalid)?; + let mut array = toml_edit::Array::new(); + for name in &denied { + array.push(name.as_str()); + } + entry.insert("disabled_tools", toml_edit::value(array)); + doc.to_string() + } else { + let mut doc = JsonDocument::parse(if r.content.is_empty() { + "{}" + } else { + &r.content + })?; + let object = doc.ensure_object(&["permissions"])?; + doc.set_member( + object, + "deny", + &serde_json::to_string(&denied).map_err(|_| invalid())?, + )?; + doc.into_string()? + } + } else { + r.content.clone() + }; + let tools: Vec<_> = r.tools.iter().map(|tool| { + let blocked = is_blocked(tool, &denied); + let outside_allow = allow.as_ref().is_some_and(|a| !a.contains(tool)); + // An owned exact rule does not authorize removing a broader user deny. + let other_deny = denied.iter().any(|s| s != &rule(tool) && is_blocked(tool, std::slice::from_ref(s))); + let ambiguous = r.source == "claude" && r.tools.iter().any(|other| other != tool && normalize(other) == normalize(tool)); + json!({"name":tool,"enabled":!blocked && !outside_allow,"editable":!ambiguous && !outside_allow && !other_deny && (!blocked || owned.contains(tool))}) + }).collect(); + let has_restrictions = if r.source == "claude" { + denied + .iter() + .any(|s| s.starts_with("mcp") || s.starts_with('*')) + } else { + !denied.is_empty() || allow.is_some() + }; + Ok( + json!({"content":content,"changed":changed,"owned":owned,"tools":tools,"hasRestrictions":has_restrictions}), + ) +} + +fn strings(value: Option<&Value>) -> Result, CoreError> { + match value { + None => Ok(vec![]), + Some(v) => v + .as_array() + .ok_or_else(invalid)? + .iter() + .map(|s| s.as_str().map(str::to_owned).ok_or_else(invalid)) + .collect(), + } +} + +fn valid_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= 256 + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || "_.-".contains(c)) +} + +fn normalize(name: &str) -> String { + name.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || "_-".contains(c) { + c + } else { + '_' + } + }) + .collect() +} + +fn wildcard(pattern: &str, value: &str) -> bool { + let pattern = pattern + .split('*') + .map(regex::escape) + .collect::>() + .join(".*"); + regex::Regex::new(&format!("^{pattern}$")).is_ok_and(|re| re.is_match(value)) +} + +#[cfg(test)] +mod tests; diff --git a/rust/lithe-core/src/mcp/tools/tests.rs b/rust/lithe-core/src/mcp/tools/tests.rs new file mode 100644 index 0000000000..e336b90ffb --- /dev/null +++ b/rust/lithe-core/src/mcp/tools/tests.rs @@ -0,0 +1,114 @@ +//! Per-tool policy and command projections protect native ownership boundaries. +use super::*; + +fn request(source: &str, content: &str) -> Value { + json!({"source":source,"content":content,"server":"docs","tools":["read","write"],"owned":[]}) +} + +#[test] +fn quoted_command_line_preserves_every_argument_without_shell_execution() { + let args = vec![ + "", + "a b", + "it's quoted", + "line\nnext", + "$(touch forbidden)", + "a;rm -f file", + "é", + ]; + let formatted = + command_line(json!({"action":"format","command":"/fake tools/server","args":args})) + .unwrap(); + let parsed = command_line(json!({"action":"parse","line":formatted["line"]})).unwrap(); + assert_eq!(parsed["command"], "/fake tools/server"); + assert_eq!(parsed["args"], json!(args)); + assert!(command_line(json!({"action":"parse","line":"npx 'unterminated"})).is_err()); +} + +#[test] +fn codex_only_removes_own_deny_and_retains_unknown_settings() { + let content = "# user formatting\n[mcp_servers.docs]\ncommand = 'node'\nargs = ['server']\ncustom = 9\ndisabled_tools = ['write']\n"; + let mut r = request("codex", content); + let read = tool_policy(r.clone()).unwrap(); + assert_eq!( + read["tools"][1], + json!({"name":"write","enabled":false,"editable":false}) + ); + r["tool"] = json!("write"); + r["enabled"] = json!(true); + assert!(tool_policy(r.clone()).is_err()); + r["tool"] = json!("read"); + r["enabled"] = json!(false); + let off = tool_policy(r.clone()).unwrap(); + assert!(off["content"].as_str().unwrap().contains("custom = 9")); + assert!(off["content"] + .as_str() + .unwrap() + .starts_with("# user formatting\n")); + r["content"] = off["content"].clone(); + r["owned"] = off["owned"].clone(); + r["enabled"] = json!(true); + let on = tool_policy(r).unwrap(); + let doc: toml::Value = toml::from_str(on["content"].as_str().unwrap()).unwrap(); + assert_eq!( + doc["mcp_servers"]["docs"]["disabled_tools"] + .as_array() + .unwrap()[0] + .as_str(), + Some("write") + ); + assert_eq!(on["tools"][0]["enabled"], true); +} + +#[test] +fn claude_exact_and_wildcard_denies_are_not_relaxed() { + let content = "{\n \"user\": {\"keep\": [1, 2]},\n \"permissions\": {\"deny\": [\"Bash(rm *)\", \"mcp__docs__write\"],\"allow\": [\"Read\"]}\n}"; + let mut r = request("claude", content); + r["tool"] = json!("read"); + r["enabled"] = json!(false); + let off = tool_policy(r.clone()).unwrap(); + assert!(off["content"] + .as_str() + .unwrap() + .contains("\"user\": {\"keep\": [1, 2]}")); + r["content"] = off["content"].clone(); + r["owned"] = off["owned"].clone(); + r["enabled"] = json!(true); + let on = tool_policy(r).unwrap(); + assert_eq!( + serde_json::from_str::(on["content"].as_str().unwrap()).unwrap()["permissions"] + ["deny"], + json!(["Bash(rm *)", "mcp__docs__write"]) + ); + let mut broad = request( + "claude", + r#"{"permissions":{"deny":["mcp__d*__*write", "mcp__docs__read"]}}"#, + ); + broad["owned"] = json!(["write"]); + let state = tool_policy(broad).unwrap(); + assert_eq!(state["tools"][1]["editable"], false); + assert_eq!(state["tools"][1]["enabled"], false); +} + +#[test] +fn allow_lists_ambiguous_names_and_malformed_policy_fail_closed() { + let r = request( + "codex", + "[mcp_servers.docs]\ncommand='node'\nenabled_tools=['write']\n", + ); + assert_eq!(tool_policy(r).unwrap()["tools"][0]["editable"], false); + let mut r = request("claude", "{}"); + r["tools"] = json!(["read.one", "read_one"]); + assert!(tool_policy(r).unwrap()["tools"] + .as_array() + .unwrap() + .iter() + .all(|t| t["editable"] == false)); + for content in [r#"{"permissions":[]}"#, r#"{"permissions":{"deny":[1]}}"#] { + assert!(tool_policy(request("claude", content)).is_err()); + } + let mut r = request("claude", "{}"); + r["tool"] = json!("*"); + r["enabled"] = json!(false); + assert!(tool_policy(r).is_err()); +} diff --git a/rust/lithe-core/src/plugins/backend.rs b/rust/lithe-core/src/plugins/backend.rs new file mode 100644 index 0000000000..5c4972ed30 --- /dev/null +++ b/rust/lithe-core/src/plugins/backend.rs @@ -0,0 +1,88 @@ +//! `backend` of an API level 3 plugin manifest: one long-lived process a +//! plugin starts for its own pages, independent of any build system, +//! language, or workspace. +//! +//! A build provider or VCS provider exists for one domain contract and one +//! workspace. A backend serves only the plugin itself: its web pages call the +//! backend's [plugin functions](crate::lsp::interface::plugin_function), and +//! the backend may ask the host for the primitive services its permissions +//! grant, such as private credential storage. The host starts it on the first +//! call from one of the plugin's pages, keeps one process per plugin, and ends +//! it when the plugin is disabled, updated, or uninstalled. +//! +//! The process shares the launch fields, templates, and install review of +//! every plugin server process. It is not bound to a workspace, so +//! `${workspaceStateDir}` is refused. +//! +//! Note: the decision is recorded in .agents/notes/implemented/architecture/2026-10-24-database-provider-plugin.md + +use super::declarative::{invalid, ServerProcess, ServerRole, Validation}; +use super::settings::DeclaredSettings; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +/// The plugin's own backend process. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct BackendDeclaration { + #[serde(flatten)] + pub(crate) process: ServerProcess, +} + +/// Primitive host services a backend may request, each granted by one +/// manifest permission. +pub(crate) const HOST_SERVICE_CREDENTIALS: &str = "credentials"; + +/// What backend validation needs from the rest of the manifest. +pub(crate) struct DeclarationContext<'a> { + pub(crate) plugin_api_version: u32, + pub(crate) declared_settings: &'a DeclaredSettings, + /// Whether a tool window of the plugin shows web content, the only + /// caller of a backend in `backend` 1. + pub(crate) has_web_window: bool, +} + +impl BackendDeclaration { + pub(crate) fn validate(&self, context: &DeclarationContext<'_>) -> Validation<()> { + if context.plugin_api_version < 3 { + return Err(invalid( + "backend", + "A plugin backend requires plugin API level 3.", + )); + } + // Only the plugin's own pages call a backend in version 1, so one + // without a web window could never start. + if !context.has_web_window { + return Err(invalid( + "backend", + "A plugin backend serves the plugin's own web pages; declare a web tool window.", + )); + } + self.process.validate( + "backend", + context.plugin_api_version, + context.declared_settings, + ServerRole::Backend, + )?; + if self.process.uses_workspace_state_directory() { + return Err(invalid( + "backend", + "A plugin backend is not bound to a workspace and cannot use ${workspaceStateDir}.", + )); + } + Ok(()) + } +} + +/// Install-review entries: the backend process with the runtimes it needs. +pub(crate) fn programs(backend: &BackendDeclaration) -> Vec { + let mut programs = vec![backend.process.program(ServerRole::Backend)]; + programs.extend(backend.process.runtime_programs()); + programs +} + +/// The `backend` member of a launch plan: the host services the session may +/// request, which the host passes to `lsp.startServer` unchanged. +pub(crate) fn plan_descriptor(host_services: &[&str]) -> Value { + json!({ "hostServices": host_services }) +} diff --git a/rust/lithe-core/src/plugins/capabilities.rs b/rust/lithe-core/src/plugins/capabilities.rs index cfae7aad21..33e7a4aadd 100644 --- a/rust/lithe-core/src/plugins/capabilities.rs +++ b/rust/lithe-core/src/plugins/capabilities.rs @@ -41,18 +41,35 @@ pub(crate) const UI: &str = "ui"; pub(crate) const BUILD_SYSTEMS: &str = "buildSystems"; /// `buildSystems[].provider`, whether it owns or shares a language server process. pub(crate) const BUILD_PROVIDERS: &str = "buildProviders"; +/// `vcsProviders[]`: version control systems implemented by a plugin +/// process; see `crate::vcs`. +pub(crate) const VCS_PROVIDERS: &str = "vcsProviders"; + +/// `backend`: the plugin's own long-lived process serving its pages; see +/// `super::backend`. +pub(crate) const BACKEND: &str = "backend"; + +/// `projectGenerators[]`: entries of the New Project window. +pub(crate) const PROJECT_GENERATORS: &str = "projectGenerators"; + +/// Shared executable candidate order and strict explicit selection. +pub(crate) const EXECUTABLE_RESOLUTION: &str = "executableResolution"; /// Capability versions this Core build validates and plans. Version 1 of a /// capability keeps the meaning its section had at API level 2. pub const SUPPORTED_CAPABILITIES: &[(&str, &[u32])] = &[ + (BACKEND, &[1]), (BUILD_PROVIDERS, &[1]), - (BUILD_SYSTEMS, &[1]), + (BUILD_SYSTEMS, &[1, 2]), (EXECUTION_PROVIDERS, &[1]), + (EXECUTABLE_RESOLUTION, &[1]), (LANGUAGE_SERVERS, &[1, 2]), (LANGUAGES, &[1]), + (PROJECT_GENERATORS, &[1]), (SERVER_PROCESS, &[1]), (SETTINGS, &[1, 2]), - (UI, &[1, 2]), + (UI, &[1, 2, 3]), + (VCS_PROVIDERS, &[1]), ]; /// Longest capability ID, so a hostile index cannot grow host state. diff --git a/rust/lithe-core/src/plugins/declarative.rs b/rust/lithe-core/src/plugins/declarative.rs index 5dabae94f1..1a4bd9518a 100644 --- a/rust/lithe-core/src/plugins/declarative.rs +++ b/rust/lithe-core/src/plugins/declarative.rs @@ -44,13 +44,28 @@ const PERMISSION_UI_WEB: &str = "ui.web"; /// Running a declared build system's executable: model, task, test, and /// dependency commands. API level 3. const PERMISSION_BUILD: &str = "process.build"; +/// Letting a VCS provider change the working copy and contact its server. +/// API level 3. +const PERMISSION_VCS: &str = "process.vcs"; +/// Creating projects: writing template files, running scaffolding tools, +/// and downloading project archives. API level 3. +const PERMISSION_PROJECT_GENERATE: &str = "project.generate"; +/// Starting the plugin's own backend process. `backend` 1. +const PERMISSION_BACKEND: &str = "backend.launch"; +/// Letting the backend keep secrets in the host's credential store, private +/// to the plugin. `backend` 1. +const PERMISSION_CREDENTIALS: &str = "credentials.store"; const KNOWN_PERMISSIONS: &[&str] = &[ + PERMISSION_BACKEND, PERMISSION_BUILD, + PERMISSION_CREDENTIALS, PERMISSION_LANGUAGE_SERVER, + PERMISSION_PROJECT_GENERATE, PERMISSION_RUN, PERMISSION_TEST, PERMISSION_UI, PERMISSION_UI_WEB, + PERMISSION_VCS, ]; /// Runtime tools the host knows how to discover and pass to a language /// server. The host exports each as `LITHE__PATH` and puts its @@ -156,6 +171,18 @@ pub struct DeclarativePluginManifest { /// shape and its validation live in `crate::build`. #[serde(default, skip_serializing_if = "Vec::is_empty")] build_systems: Vec, + /// Version control systems the plugin implements. API level 3; the + /// shape and its validation live in `crate::vcs`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + vcs_providers: Vec, + /// Entries of the New Project window. API level 3; the shape and its + /// validation live in `crate::project_generators`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + project_generators: Vec, + /// The plugin's own process serving its pages. `backend` 1; see + /// `super::backend`. + #[serde(default, skip_serializing_if = "Option::is_none")] + backend: Option, } /// UI supplied by plugins. The host owns every window shell: docking, title, @@ -189,6 +216,9 @@ struct UIToolWindow { /// The plugin's own page shown in the window body. `ui` 2. #[serde(default, skip_serializing_if = "Option::is_none")] web: Option, + /// Versioned host-rendered tree/list content. `ui` 3. + #[serde(default, skip_serializing_if = "Option::is_none")] + native: Option, #[serde(default, skip_serializing_if = "Option::is_none")] icon: Option, } @@ -257,6 +287,11 @@ enum UIIconMode { } impl UIIcon { + /// Native actions reuse the host vocabulary, without package resource loading. + pub(crate) fn is_valid_host_icon(&self) -> bool { + self.host.is_some() && self.validate().is_ok() + } + fn validate(&self) -> Validation<()> { self.validate_at("ui.icon") } @@ -272,6 +307,7 @@ impl UIIcon { "terminal", "search", "refresh", + "reset", "run", "debug", "settings", @@ -416,6 +452,8 @@ pub(crate) struct ServerProcess { pub(crate) enum ServerRole { LanguageServer, BuildProvider, + VcsProvider, + Backend, } impl ServerRole { @@ -423,6 +461,8 @@ impl ServerRole { match self { Self::LanguageServer => "language server", Self::BuildProvider => "build provider", + Self::VcsProvider => "VCS provider", + Self::Backend => "plugin backend", } } } @@ -758,7 +798,12 @@ impl DeclarativePluginManifest { if self.plugin_api_version >= CAPABILITIES_API_VERSION { capabilities::check_used(&self.requires, &self.used_capabilities())?; } - if self.languages.is_empty() && self.ui.is_none() && self.build_systems.is_empty() { + if self.languages.is_empty() + && self.ui.is_none() + && self.build_systems.is_empty() + && self.vcs_providers.is_empty() + && self.project_generators.is_empty() + { return Err(invalid("languages", "The plugin declares no languages.")); } @@ -884,6 +929,8 @@ impl DeclarativePluginManifest { && self.execution_providers.is_empty() && self.ui.is_none() && self.build_systems.is_empty() + && self.vcs_providers.is_empty() + && self.project_generators.is_empty() { return Err(invalid( "languages", @@ -891,24 +938,67 @@ impl DeclarativePluginManifest { )); } self.validate_ui()?; + if !self.project_generators.is_empty() && self.plugin_api_version < CAPABILITIES_API_VERSION + { + return Err(invalid( + "projectGenerators", + "Project generators require plugin API level 3.", + )); + } + crate::project_generators::validate_declarations(&self.project_generators)?; crate::build::validate_declarations( &mut self.build_systems, &crate::build::DeclarationContext { plugin_api_version: self.plugin_api_version, + build_systems_version: self.requires.get("buildSystems").copied().unwrap_or(1), official: is_official_namespace(&self.id), declared_settings: &declared_settings, language_servers: &server_languages, }, )?; + crate::vcs::validate_declarations( + &mut self.vcs_providers, + &crate::vcs::DeclarationContext { + plugin_api_version: self.plugin_api_version, + declared_settings: &declared_settings, + }, + )?; + if let Some(backend) = &self.backend { + backend.validate(&super::backend::DeclarationContext { + plugin_api_version: self.plugin_api_version, + declared_settings: &declared_settings, + has_web_window: self + .ui + .as_ref() + .is_some_and(|ui| ui.tool_windows.iter().any(|window| window.web.is_some())), + })?; + } self.validate_permissions()?; self.validate_activation_events()?; Ok(self) } /// Capabilities whose sections the manifest declares. Every language - /// server and every own build provider is a `serverProcess`. + /// server, own build provider, and VCS provider is a `serverProcess`. fn used_capabilities(&self) -> BTreeSet<&'static str> { let sections = [ + ( + capabilities::EXECUTABLE_RESOLUTION, + self.build_systems.iter().any(|system| { + system + .invocation + .executables + .iter() + .chain( + system + .invocation + .runtime_dependencies + .iter() + .flat_map(|runtime| &runtime.executables), + ) + .any(|candidate| candidate.uses_resolution()) + }), + ), (capabilities::LANGUAGES, !self.languages.is_empty()), ( capabilities::LANGUAGE_SERVERS, @@ -917,6 +1007,8 @@ impl DeclarativePluginManifest { ( capabilities::SERVER_PROCESS, !self.language_servers.is_empty() + || !self.vcs_providers.is_empty() + || self.backend.is_some() || self .build_systems .iter() @@ -932,12 +1024,18 @@ impl DeclarativePluginManifest { ), (capabilities::UI, self.ui.is_some()), (capabilities::BUILD_SYSTEMS, !self.build_systems.is_empty()), + ( + capabilities::PROJECT_GENERATORS, + !self.project_generators.is_empty(), + ), ( capabilities::BUILD_PROVIDERS, self.build_systems .iter() .any(|system| system.provider.is_some()), ), + (capabilities::VCS_PROVIDERS, !self.vcs_providers.is_empty()), + (capabilities::BACKEND, self.backend.is_some()), ]; sections .into_iter() @@ -972,6 +1070,22 @@ impl DeclarativePluginManifest { icon.validate()?; } validate_window_body(window, index, version)?; + if let Some(source) = window + .native + .as_ref() + .and_then(|native| native.source.as_ref()) + { + if !self + .build_systems + .iter() + .any(|system| system.id == source.build_system_id) + { + return Err(invalid( + "ui.toolWindows.native.source", + "The native source must belong to this plugin's build systems.", + )); + } + } if !is_plugin_id(&window.id) || !window.id.starts_with(&prefix) || window.title.trim().is_empty() @@ -1026,10 +1140,12 @@ impl DeclarativePluginManifest { } // A version only adds; declaring one without using it only hides // the plugin from hosts that could run it. - if version >= 2 && ui.tool_windows.iter().all(|window| window.web.is_none()) { + if (version == 2 && ui.tool_windows.iter().all(|window| window.web.is_none())) + || (version == 3 && ui.tool_windows.iter().all(|window| window.native.is_none())) + { return Err(invalid( "requires.ui", - "ui 2 adds web content; a manifest without it requires ui 1.", + "The declared ui version must be used: ui 2 adds web, ui 3 adds native content.", )); } ui.tool_windows @@ -1060,6 +1176,7 @@ impl DeclarativePluginManifest { // A provider process of its own is a server process like a language // server, so it needs the same consent. if !self.language_servers.is_empty() + || !self.vcs_providers.is_empty() || self .build_systems .iter() @@ -1078,6 +1195,22 @@ impl DeclarativePluginManifest { if !self.build_systems.is_empty() { required.insert(PERMISSION_BUILD); } + if !self.vcs_providers.is_empty() { + required.insert(PERMISSION_VCS); + } + if !self.project_generators.is_empty() { + required.insert(PERMISSION_PROJECT_GENERATE); + } + if self.backend.is_some() { + required.insert(PERMISSION_BACKEND); + } else if self.permissions.iter().any(|p| p == PERMISSION_CREDENTIALS) { + // Only a backend can use the store; listing the permission + // without one would show the user a grant nothing exercises. + return Err(invalid( + "permissions", + "credentials.store is used by a plugin backend; declare one or remove the permission.", + )); + } for permission in required { if !self .permissions @@ -1110,6 +1243,9 @@ impl DeclarativePluginManifest { for build_system in &self.build_systems { expected.insert(crate::build::activation_event(build_system)); } + for provider in &self.vcs_providers { + expected.insert(crate::vcs::activation_event(provider)); + } if let Some(ui) = &self.ui { for window in &ui.tool_windows { expected.insert(format!("onView:{}", window.id)); @@ -1157,6 +1293,23 @@ impl DeclarativePluginManifest { &self.version } + /// Name shown to the user. + pub(crate) fn display_name(&self) -> &str { + &self.display_name + } + + /// IDs of the languages the plugin declares. + pub(crate) fn language_ids(&self) -> impl Iterator { + self.languages.iter().map(|language| language.id.as_str()) + } + + /// Validated project generators, in declaration order. + pub(crate) fn project_generators( + &self, + ) -> &[crate::project_generators::ProjectGeneratorDeclaration] { + &self.project_generators + } + /// Normalized, sorted permission list. pub(crate) fn permissions(&self) -> &[String] { &self.permissions @@ -1172,6 +1325,21 @@ impl DeclarativePluginManifest { &self.build_systems } + /// Validated VCS providers, sorted by VCS ID. + pub(crate) fn vcs_providers(&self) -> &[crate::vcs::VcsProviderDeclaration] { + &self.vcs_providers + } + + /// Host services the plugin's backend may request, sorted; empty without + /// a backend. Each is granted by one permission. + pub(crate) fn backend_host_services(&self) -> Vec<&'static str> { + let mut services = Vec::new(); + if self.backend.is_some() && self.permissions.iter().any(|p| p == PERMISSION_CREDENTIALS) { + services.push(super::backend::HOST_SERVICE_CREDENTIALS); + } + services + } + /// Checks the declared settings, sorts them by ID, and returns the IDs /// launch templates may refer to. fn validate_settings(&mut self) -> Validation { @@ -1248,6 +1416,26 @@ impl DeclarativePluginManifest { Ok(()) } + /// Strict executable selection preserves invalid stored values instead of + /// applying the UI settings resolver's tolerant fallback-to-default rule. + pub(crate) fn executable_setting_value( + &self, + id: &str, + values: &BTreeMap, + ) -> Validation> { + let setting = self + .settings + .iter() + .find(|setting| setting.id == id) + .ok_or_else(|| { + invalid( + "executables.settingID", + "The executable setting is not declared.", + ) + })?; + setting.executable_value(values.get(id)) + } + /// Effective value of every declared setting and the layer it came from. pub(crate) fn resolve_settings(&self, layers: SettingLayers<'_>) -> Vec { super::settings::resolve(&self.settings, layers) @@ -1301,6 +1489,15 @@ impl DeclarativePluginManifest { for build_system in &self.build_systems { programs.extend(crate::build::programs(build_system)); } + for provider in &self.vcs_providers { + programs.extend(crate::vcs::programs(provider)); + } + for generator in &self.project_generators { + programs.extend(crate::project_generators::programs(generator)); + } + if let Some(backend) = &self.backend { + programs.extend(super::backend::programs(backend)); + } // Order by kind, then language or tool, then the full serialized // value as a total tie-breaker; duplicates (two servers needing the // same runtime) collapse to one entry. @@ -1726,6 +1923,8 @@ impl ServerProcess { let suffix = match role { ServerRole::LanguageServer => "LanguageServer", ServerRole::BuildProvider => "BuildProvider", + ServerRole::VcsProvider => "VcsProvider", + ServerRole::Backend => "Backend", }; let mut program = match &self.launch { LanguageServerLaunch::Packaged { path } => json!({ @@ -1764,6 +1963,46 @@ impl ServerProcess { program } + /// Whether any start template names `${workspaceStateDir}`, which only + /// a process bound to one workspace can be given. + pub(crate) fn uses_workspace_state_directory(&self) -> bool { + let runtime_arguments = match &self.launch { + LanguageServerLaunch::Runtime { + runtime_arguments, .. + } => runtime_arguments.as_slice(), + _ => &[], + }; + let mentions = |text: &str| { + parse_template_with(text, LaunchVariable::parse) + .unwrap_or_default() + .iter() + .any(|part| { + matches!( + part, + TemplatePart::Variable(LaunchVariable::WorkspaceStateDir) + ) + }) + }; + fn strings<'a>(value: &'a Value, out: &mut Vec<&'a str>) { + match value { + Value::String(text) => out.push(text), + Value::Array(items) => items.iter().for_each(|item| strings(item, out)), + Value::Object(members) => members.values().for_each(|item| strings(item, out)), + _ => {} + } + } + let mut option_strings = Vec::new(); + if let Some(options) = &self.initialization_options { + strings(options, &mut option_strings); + } + self.arguments + .iter() + .chain(runtime_arguments) + .map(String::as_str) + .chain(option_strings) + .any(mentions) + } + /// Install-review entries of the runtimes the host discovers for it. pub(crate) fn runtime_programs(&self) -> Vec { self.runtime_requirements() @@ -1831,6 +2070,17 @@ fn is_packaged_runtime_path(value: &str) -> bool { /// A window body is literal text or, from `ui` 2 on, one package HTML page. fn validate_window_body(window: &UIToolWindow, index: usize, version: u32) -> Validation<()> { let field = format!("ui.toolWindows[{index}]"); + if let Some(native) = &window.native { + if version < 3 || window.text.is_some() || window.web.is_some() { + return Err(invalid( + &field, + "Native content requires ui 3 and cannot coexist with text or web.", + )); + } + return native + .validate() + .map_err(|message| invalid(&field, message)); + } match (&window.text, &window.web) { (Some(text), None) if text.len() <= 65536 => Ok(()), (Some(_), None) => Err(invalid( @@ -2430,7 +2680,10 @@ pub struct LanguageServerLaunchPlanRequest { /// Absolute writable directory the host keeps for this plugin's server. state_directory: String, /// Absolute writable directory for the server's state of this workspace. - workspace_state_directory: String, + /// A host without a workspace, such as one starting a plugin backend, + /// omits it; a launch that names it then has no usable plan. + #[serde(default)] + workspace_state_directory: Option, /// Host CPU architecture, one of `KNOWN_ARCHITECTURES`. architecture: String, /// Values the user stored for the plugin's settings, by setting ID. A @@ -2596,6 +2849,47 @@ fn build_provider_launch_plan( Ok(plan) } +/// The launch plan of a VCS provider. `vcsProvider` repeats what the host +/// needs to resolve the client tool and to check the provider's answer in +/// `initialize`, which must name the same VCS. +fn vcs_provider_launch_plan( + manifest: &DeclarativePluginManifest, + vcs_id: &str, + request: &LanguageServerLaunchPlanRequest, +) -> Validation { + let provider = manifest + .vcs_providers + .iter() + .find(|provider| provider.id == vcs_id) + .ok_or_else(|| { + PluginRejection::new( + "plugin.launch.unavailable", + format!("The plugin declares no VCS provider {vcs_id}."), + ) + })?; + let mut plan = server_launch_plan(manifest, &provider.process, None, request)?; + plan["vcsProvider"] = provider.descriptor(&manifest.id); + Ok(plan) +} + +/// The launch plan of the plugin's backend. `backend.hostServices` lists the +/// host services its permissions grant, which the host passes to +/// `lsp.startServer` so Core admits only those requests. +fn backend_launch_plan( + manifest: &DeclarativePluginManifest, + request: &LanguageServerLaunchPlanRequest, +) -> Validation { + let backend = manifest.backend.as_ref().ok_or_else(|| { + PluginRejection::new( + "plugin.launch.unavailable", + "The plugin declares no backend.", + ) + })?; + let mut plan = server_launch_plan(manifest, &backend.process, None, request)?; + plan["backend"] = super::backend::plan_descriptor(&manifest.backend_host_services()); + Ok(plan) +} + fn server_launch_plan( manifest: &DeclarativePluginManifest, process: &ServerProcess, @@ -2606,10 +2900,16 @@ fn server_launch_plan( let values = LaunchValues { plugin_directory: launch_directory(&request.plugin_directory, "pluginDirectory")?, state_directory: launch_directory(&request.state_directory, "stateDirectory")?, - workspace_state_directory: launch_directory( - &request.workspace_state_directory, - "workspaceStateDirectory", - )?, + workspace_state_directory: match &request.workspace_state_directory { + Some(directory) => launch_directory(directory, "workspaceStateDirectory")?, + None if process.uses_workspace_state_directory() => { + return Err(PluginRejection::new( + "plugin.launch.invalidRequest", + "The launch request has no usable workspaceStateDirectory.", + )) + } + None => "", + }, architecture: &request.architecture, settings: &effective_settings, }; @@ -3130,8 +3430,8 @@ struct PlanPayload { request: Request, } -/// A launch plan names the process by the language whose server it is, or -/// by the build system whose provider it is. +/// A launch plan names the process by the language whose server it is, by +/// the build system whose provider it is, or by the VCS it provides. #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct LaunchPlanPayload { @@ -3140,12 +3440,19 @@ struct LaunchPlanPayload { language_id: Option, #[serde(default, rename = "buildSystemID")] build_system_id: Option, + #[serde(default, rename = "vcsProviderID")] + vcs_provider_id: Option, + /// Selects the plugin's backend; `backend` 1. + #[serde(default)] + backend: bool, request: LanguageServerLaunchPlanRequest, } enum LaunchSelection<'a> { Language(&'a str), BuildSystem(&'a str), + VcsProvider(&'a str), + Backend, } pub(crate) fn decode_payload Deserialize<'de>>( @@ -3228,15 +3535,24 @@ pub fn test_plan_command(payload: Value) -> Result { pub fn language_server_launch_plan_command(payload: Value) -> Result { const COMMAND: &str = "plugins.declarative.languageServerLaunchPlan"; let payload: LaunchPlanPayload = decode_payload(payload, COMMAND)?; - let selected = match (&payload.language_id, &payload.build_system_id) { - (Some(language_id), None) => LaunchSelection::Language(language_id), - (None, Some(build_system_id)) => LaunchSelection::BuildSystem(build_system_id), + let selected = match ( + &payload.language_id, + &payload.build_system_id, + &payload.vcs_provider_id, + payload.backend, + ) { + (Some(language_id), None, None, false) => LaunchSelection::Language(language_id), + (None, Some(build_system_id), None, false) => LaunchSelection::BuildSystem(build_system_id), + (None, None, Some(vcs_id), false) => LaunchSelection::VcsProvider(vcs_id), + (None, None, None, true) => LaunchSelection::Backend, _ => { return Err(CoreError::new( ErrorCode::InvalidRequest, format!("Invalid {COMMAND} payload"), ) - .with_details("Name exactly one of languageID and buildSystemID.")) + .with_details( + "Name exactly one of languageID, buildSystemID, vcsProviderID, and backend.", + )) } }; Ok(outcome(validate_manifest_value(payload.manifest).and_then( @@ -3248,6 +3564,10 @@ pub fn language_server_launch_plan_command(payload: Value) -> Result { build_provider_launch_plan(&manifest, build_system_id, &payload.request) } + LaunchSelection::VcsProvider(vcs_id) => { + vcs_provider_launch_plan(&manifest, vcs_id, &payload.request) + } + LaunchSelection::Backend => backend_launch_plan(&manifest, &payload.request), } .map(|plan| ("plan", plan)) }, diff --git a/rust/lithe-core/src/plugins/executables.rs b/rust/lithe-core/src/plugins/executables.rs new file mode 100644 index 0000000000..57fddb5a7d --- /dev/null +++ b/rust/lithe-core/src/plugins/executables.rs @@ -0,0 +1,200 @@ +//! Shared executable candidate declarations and deterministic ordering. +//! Native discovery, trust and probing remain injected host responsibilities. + +use super::declarative::{invalid, is_identifier, Validation}; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet}; + +/// A source plus optional ordering constraints within one plugin-owned set. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ExecutableCandidate { + #[serde(flatten)] + pub(crate) source: ExecutableSource, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) id: Option, + /// Higher priorities run first; absent means 0. Ties use array order. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) priority: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) insert_before: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) insert_after: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub(crate) platforms: Vec, +} + +impl<'de> Deserialize<'de> for ExecutableCandidate { + fn deserialize>(deserializer: D) -> Result { + let mut value = Value::deserialize(deserializer)?; + let map = value + .as_object_mut() + .ok_or_else(|| serde::de::Error::custom("An executable is an object."))?; + // Decode metadata separately so source-specific unknown fields still + // fail closed instead of disappearing through serde flatten. + let id = map + .remove("id") + .map(serde_json::from_value) + .transpose() + .map_err(serde::de::Error::custom)?; + let priority = map + .remove("priority") + .map(serde_json::from_value) + .transpose() + .map_err(serde::de::Error::custom)?; + let insert_before = map + .remove("insertBefore") + .map(serde_json::from_value) + .transpose() + .map_err(serde::de::Error::custom)?; + let insert_after = map + .remove("insertAfter") + .map(serde_json::from_value) + .transpose() + .map_err(serde::de::Error::custom)?; + let platforms = map + .remove("platforms") + .map(serde_json::from_value) + .transpose() + .map_err(serde::de::Error::custom)? + .unwrap_or_default(); + let source = serde_json::from_value(value).map_err(serde::de::Error::custom)?; + Ok(Self { + source, + id, + priority, + insert_before, + insert_after, + platforms, + }) + } +} + +/// Platform-neutral source. Adapters supply each native discovery capability. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +pub(crate) enum ExecutableSource { + ProjectFile { + path: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + requires: Vec, + }, + Environment { + variable: String, + path: String, + }, + Command { + command: String, + }, + Runtime { + runtime: String, + }, + /// Global file preference; empty skips, any invalid nonempty value fails. + Setting { + #[serde(rename = "settingID")] + setting_id: String, + }, +} + +/// Operating system filter, applied after validating the complete candidate set. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum Platform { + Macos, + Linux, + Windows, +} + +impl ExecutableCandidate { + pub(crate) fn uses_resolution(&self) -> bool { + matches!(self.source, ExecutableSource::Setting { .. }) + || self.id.is_some() + || self.priority.is_some() + || self.insert_before.is_some() + || self.insert_after.is_some() + } +} + +/// Stable topological sort: explicit edges win, otherwise higher priority then +/// original array index wins. No cross-set anchors or arbitrary host code. +pub(crate) fn ordered_indices( + candidates: &[ExecutableCandidate], + field: &str, +) -> Validation> { + let mut ids = BTreeMap::new(); + for (index, candidate) in candidates.iter().enumerate() { + if let Some(id) = &candidate.id { + if !is_identifier(id) || ids.insert(id.as_str(), index).is_some() { + return Err(invalid( + field, + "Candidate IDs must be valid and unique within their set.", + )); + } + } + if (candidate.priority.is_some() + && (candidate.insert_before.is_some() || candidate.insert_after.is_some())) + || (candidate.insert_before.is_some() && candidate.insert_after.is_some()) + { + return Err(invalid( + field, + "Use priority or one relative insertion constraint, not both.", + )); + } + } + let mut edges = vec![BTreeSet::new(); candidates.len()]; + let mut incoming = vec![0usize; candidates.len()]; + for (index, candidate) in candidates.iter().enumerate() { + for (anchor, before) in [ + (candidate.insert_before.as_deref(), true), + (candidate.insert_after.as_deref(), false), + ] { + let Some(anchor) = anchor else { continue }; + let other = *ids.get(anchor).ok_or_else(|| { + invalid( + field, + "An insertion anchor must name a candidate in this set.", + ) + })?; + let (from, to) = if before { + (index, other) + } else { + (other, index) + }; + if edges[from].insert(to) { + incoming[to] += 1; + } + } + } + let mut result = Vec::with_capacity(candidates.len()); + let mut remaining: BTreeSet<_> = (0..candidates.len()).collect(); + while !remaining.is_empty() { + let next = remaining + .iter() + .copied() + .filter(|&index| incoming[index] == 0) + .min_by_key(|&index| { + ( + std::cmp::Reverse(candidates[index].priority.unwrap_or(0)), + index, + ) + }) + .ok_or_else(|| { + invalid( + field, + "Candidate insertion constraints contain a cycle or contradiction.", + ) + })?; + remaining.remove(&next); + result.push(next); + for &to in &edges[next] { + incoming[to] -= 1; + } + } + Ok(result) +} diff --git a/rust/lithe-core/src/plugins/mod.rs b/rust/lithe-core/src/plugins/mod.rs index 7431ebc539..a7ea21d897 100644 --- a/rust/lithe-core/src/plugins/mod.rs +++ b/rust/lithe-core/src/plugins/mod.rs @@ -1,8 +1,11 @@ //! Plugin manifest parsing, compatibility checks, and deterministic catalog merging. +pub(crate) mod backend; pub mod capabilities; pub mod declarative; +pub(crate) mod executables; pub mod marketplace; +pub(crate) mod native_ui; pub(crate) mod settings; use serde::Deserialize; diff --git a/rust/lithe-core/src/plugins/native_ui.rs b/rust/lithe-core/src/plugins/native_ui.rs new file mode 100644 index 0000000000..6bd8f54a0f --- /dev/null +++ b/rust/lithe-core/src/plugins/native_ui.rs @@ -0,0 +1,431 @@ +//! Versioned native plugin presentation: bounded data, stable identities and named actions. +//! +//! Note: ownership is recorded in .agents/notes/implemented/feature/2026-10-21-declarative-native-plugin-ui.md + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; + +/// A host-rendered tree, list or form; styling is deliberately absent from the contract. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct NativeUI { + schema_version: u32, + id: String, + kind: Kind, + nodes: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + controls: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + buttons: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + layout: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) source: Option, + actions: Vec, + toolbar: Vec, + context_menu: Vec, +} + +/// Tree permits nesting; list requires flat rows; form has controls and no rows. +#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +enum Kind { + Tree, + List, + Form, +} + +/// Stable row identity, literal label and optional children. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Node { + id: String, + label: String, + #[serde(skip_serializing_if = "Option::is_none")] + children: Option>, +} + +/// A bounded scalar owned by the host; no arbitrary JSON or executable content. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(untagged)] +enum ControlValue { + Text(String), + Checked(bool), + Number(f64), +} + +/// The initial form controls reuse native IDE fields, selectors and checkboxes. +#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +enum ControlKind { + Input, + Search, + Editor, + Select, + Segmented, + Checkbox, + Stepper, +} + +/// Stable option value and a literal user-visible label. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct SelectOption { + id: String, + label: String, +} + +/// A bounded column grid. The host owns gaps, sizing and the narrow-panel fallback. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct GridLayout { + columns: u8, +} + +/// Form semantics only. onCommit names an existing non-selection invoke action. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Control { + id: String, + kind: ControlKind, + label: String, + value: ControlValue, + #[serde(skip_serializing_if = "Option::is_none")] + placeholder: Option, + #[serde(skip_serializing_if = "Option::is_none")] + options: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + on_commit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + span: Option, + #[serde(skip_serializing_if = "Option::is_none")] + info: Option, + #[serde(skip_serializing_if = "Option::is_none")] + rows: Option, + #[serde(skip_serializing_if = "Option::is_none")] + minimum: Option, + #[serde(skip_serializing_if = "Option::is_none")] + maximum: Option, + #[serde(skip_serializing_if = "Option::is_none")] + step: Option, +} + +/// Button layout policy; all visual metrics stay with the host button owner. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +enum ButtonWidth { + Content, + Fill, +} + +/// A form action placement, deliberately separate from value-bearing controls. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct FormButton { + id: String, + label: String, + action: String, + #[serde(skip_serializing_if = "Option::is_none")] + width: Option, + #[serde(skip_serializing_if = "Option::is_none")] + primary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + span: Option, + #[serde(skip_serializing_if = "Option::is_none")] + info: Option, +} + +/// Existing plugin function that returns `{ nodes }` for a detected build instance. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct Source { + #[serde(rename = "buildSystemID")] + pub(super) build_system_id: String, + function: String, +} + +/// An independent action referenced by toolbar and context-menu placements. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Action { + id: String, + title: String, + #[serde(skip_serializing_if = "Option::is_none")] + icon: Option, + kind: ActionKind, + #[serde(skip_serializing_if = "Option::is_none")] + function: Option, + requires_selection: bool, +} + +/// Refresh reloads the source; invoke calls a declared plugin function. +#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +enum ActionKind { + Refresh, + Invoke, +} + +fn identifier(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte)) +} + +fn valid_info(value: Option<&str>) -> bool { + value.is_none_or(|value| !value.trim().is_empty() && value.len() <= 1024) +} + +impl NativeUI { + pub(super) fn validate(&self) -> Result<(), &'static str> { + if self.schema_version != 1 || !identifier(&self.id) || self.actions.len() > 64 { + return Err("Native UI requires schemaVersion 1, a stable ID and at most 64 actions."); + } + if self + .source + .as_ref() + .is_some_and(|source| !identifier(&source.function)) + { + return Err("Invalid native UI source function."); + } + if self.controls.len() > 32 + || self.buttons.len() > 16 + || (self.kind == Kind::Form + && ((self.controls.is_empty() && self.buttons.is_empty()) + || !self.nodes.is_empty() + || !self.context_menu.is_empty())) + || (self.kind != Kind::Form && (!self.controls.is_empty() || !self.buttons.is_empty())) + { + return Err( + "A native form needs controls or buttons, at most 32 controls and 16 buttons, and no rows.", + ); + } + if self + .layout + .as_ref() + .is_some_and(|layout| self.kind != Kind::Form || !(1..=12).contains(&layout.columns)) + || self + .controls + .iter() + .map(|control| control.span) + .chain(self.buttons.iter().map(|button| button.span)) + .any(|span| { + span.is_some_and(|span| { + self.layout + .as_ref() + .is_none_or(|layout| span == 0 || span > layout.columns) + }) + }) + { + return Err("Native form grids need 1–12 columns; each span needs a grid and must fit its columns."); + } + let mut ids = BTreeSet::from([self.id.as_str()]); + let mut pending: Vec<_> = self.nodes.iter().map(|node| (node, 0)).collect(); + let mut count = 0; + while let Some((node, depth)) = pending.pop() { + count += 1; + if count > 2000 + || depth > 16 + || !identifier(&node.id) + || !ids.insert(&node.id) + || node.label.trim().is_empty() + || node.label.len() > 1024 + { + return Err( + "Native rows need unique IDs and labels; limit 2000 nodes and depth 16.", + ); + } + if let Some(children) = &node.children { + if self.kind == Kind::List && !children.is_empty() { + return Err("A native list cannot contain nested rows."); + } + pending.extend(children.iter().map(|child| (child, depth + 1))); + } + } + let mut actions = BTreeSet::new(); + for action in &self.actions { + if !identifier(&action.id) + || !ids.insert(&action.id) + || !actions.insert(&action.id) + || action.title.trim().is_empty() + || action.title.len() > 256 + || action + .icon + .as_ref() + .is_some_and(|icon| !icon.is_valid_host_icon()) + || self.source.is_none() + || match action.kind { + ActionKind::Refresh => action.function.is_some() || action.requires_selection, + ActionKind::Invoke => !action.function.as_deref().is_some_and(identifier), + } + { + return Err( + "Native actions need unique IDs, a source and a valid refresh/invoke handler.", + ); + } + } + for control in &self.controls { + if !identifier(&control.id) + || !ids.insert(&control.id) + || control.label.trim().is_empty() + || control.label.len() > 256 + || control + .placeholder + .as_ref() + .is_some_and(|value| value.len() > 256) + || !valid_info(control.info.as_deref()) + || (control.kind != ControlKind::Editor && control.rows.is_some()) + || (control.kind == ControlKind::Editor && control.on_commit.is_some()) + || (control.kind != ControlKind::Stepper + && (control.minimum.is_some() + || control.maximum.is_some() + || control.step.is_some())) + || control.on_commit.as_ref().is_some_and(|id| { + !self.actions.iter().any(|action| { + &action.id == id + && action.kind == ActionKind::Invoke + && !action.requires_selection + }) + }) + { + return Err("Native controls need unique IDs, bounded labels and a declared non-selection invoke action."); + } + match (&control.kind, &control.value) { + (ControlKind::Input | ControlKind::Search, ControlValue::Text(value)) + if value.len() <= 1024 && control.options.is_none() => {} + (ControlKind::Editor, ControlValue::Text(value)) + if value.len() <= 8192 + && control.options.is_none() + && control.placeholder.is_none() + && control.rows.is_none_or(|rows| (2..=12).contains(&rows)) => {} + (ControlKind::Stepper, ControlValue::Number(value)) + if control.options.is_none() && control.placeholder.is_none() => + { + let (Some(minimum), Some(maximum), Some(step)) = + (control.minimum, control.maximum, control.step) + else { + return Err("A native stepper requires minimum, maximum and step."); + }; + if !minimum.is_finite() + || !maximum.is_finite() + || !step.is_finite() + || minimum.abs() > 1e9 + || maximum.abs() > 1e9 + || minimum > maximum + || step <= 0.0 + || step > 1e9 + || !value.is_finite() + || *value < minimum + || *value > maximum + { + return Err("Native stepper values must fit a finite bounded range and positive step."); + } + } + (ControlKind::Checkbox, ControlValue::Checked(_)) + if control.options.is_none() && control.placeholder.is_none() => {} + (ControlKind::Select | ControlKind::Segmented, ControlValue::Text(value)) + if control.placeholder.is_none() => + { + let Some(options) = &control.options else { + return Err("A native selector needs options."); + }; + let mut option_ids = BTreeSet::new(); + if options.is_empty() + || options.len() + > if control.kind == ControlKind::Segmented { + 8 + } else { + 128 + } + || options.iter().any(|option| { + !identifier(&option.id) + || !option_ids.insert(&option.id) + || option.label.trim().is_empty() + || option.label.len() > 256 + }) + || !options.iter().any(|option| &option.id == value) + { + return Err("Native selectors need 1–128 unique options and an existing selected value."); + } + } + _ => return Err("Native control values and properties must match their kind."), + } + } + for button in &self.buttons { + if !identifier(&button.id) + || !ids.insert(&button.id) + || button.label.trim().is_empty() + || button.label.len() > 256 + || !actions.contains(&button.action) + || !valid_info(button.info.as_deref()) + || self + .actions + .iter() + .any(|action| action.id == button.action && action.requires_selection) + { + return Err("Native form buttons need unique IDs, bounded labels and a non-selection action."); + } + } + for placement in [&self.toolbar, &self.context_menu] { + let mut seen = BTreeSet::new(); + if placement.len() > 64 + || placement + .iter() + .any(|id| !actions.contains(id) || !seen.insert(id)) + { + return Err("Native action placements must reference distinct declared actions."); + } + } + Ok(()) + } +} + +/// Validates a refreshed node set with the same identity and budget rules as +/// the initial declaration. Platform renderers never maintain a second validator. +pub(crate) fn validate_data_command( + payload: serde_json::Value, +) -> Result { + use serde_json::{json, Value}; + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Payload { + ui: NativeUI, + data: Value, + } + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Data { + nodes: Vec, + } + let mut payload: Payload = serde_json::from_value(payload).map_err(|error| { + crate::protocol::CoreError::new( + crate::protocol::ErrorCode::InvalidRequest, + error.to_string(), + ) + })?; + let validate = || -> Result { + if serde_json::to_vec(&payload.data) + .map_err(|error| error.to_string())? + .len() + > 1024 * 1024 + { + return Err("Native data exceeds 1 MiB.".into()); + } + let data: Data = serde_json::from_value(payload.data).map_err(|error| error.to_string())?; + payload.ui.nodes = data.nodes; + payload.ui.validate().map_err(String::from)?; + Ok(json!({"status":"ok", "nodes": payload.ui.nodes})) + }; + Ok(match validate() { + Ok(nodes) => nodes, + Err(message) => { + json!({"status":"rejected", "error":{"code":"plugin.ui.invalidData", "message":message}}) + } + }) +} diff --git a/rust/lithe-core/src/plugins/settings.rs b/rust/lithe-core/src/plugins/settings.rs index f3044c4e48..da196f9069 100644 --- a/rust/lithe-core/src/plugins/settings.rs +++ b/rust/lithe-core/src/plugins/settings.rs @@ -15,7 +15,7 @@ //! //! Note: the decision is recorded in .agents/notes/implemented/architecture/2026-10-21-plugin-settings-surfaces.md -use super::declarative::{invalid, Validation}; +use super::declarative::{invalid, PluginRejection, Validation}; use regex::Regex; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -246,6 +246,8 @@ struct SettingOption { /// Setting IDs a template may name, with the restrictions their kinds carry. #[derive(Debug, Clone, Default)] pub(crate) struct DeclaredSettings { + /// Global paths that may explicitly select a build executable. + executables: BTreeSet, /// Settings a launch reads when it starts. launch: BTreeSet, /// Settings that are only pushed to a running server. @@ -256,6 +258,11 @@ pub(crate) struct DeclaredSettings { } impl DeclaredSettings { + /// Whether the global file setting may select the build executable. + pub(crate) fn is_executable(&self, id: &str) -> bool { + self.executables.contains(id) + } + /// Whether a start-time template may name the setting. pub(crate) fn contains(&self, id: &str) -> bool { self.launch.contains(id) @@ -317,6 +324,31 @@ pub(crate) struct SettingLayers<'a> { } impl SettingDeclaration { + /// Unlike display/configuration settings, an explicit invalid executable + /// value must terminate resolution rather than silently choose a default. + pub(crate) fn executable_value(&self, stored: Option<&Value>) -> Validation> { + let value = stored.unwrap_or(&self.default); + let valid = self.accepts(value) + && value.as_str().is_some_and(|path| { + path.is_empty() + || (path.trim() == path + && !path.chars().any(char::is_control) + && (path.starts_with('/') + || (path.len() >= 3 + && path.as_bytes()[0].is_ascii_alphabetic() + && path.as_bytes()[1] == b':' + && matches!(path.as_bytes()[2], b'/' | b'\\')) + || path.starts_with("\\\\"))) + }); + if !valid { + return Err(PluginRejection::at("plugin.build.invalidSelection", format!("settings.{}", self.id), "The configured executable must be an absolute file path satisfying the setting constraints.")); + } + Ok(value + .as_str() + .filter(|path| !path.is_empty()) + .map(str::to_owned)) + } + fn is_version_2_kind(&self) -> bool { matches!( self.kind.as_str(), @@ -606,6 +638,15 @@ impl SettingDeclaration { pub(crate) fn declared(settings: &[SettingDeclaration]) -> DeclaredSettings { let mut declared = DeclaredSettings::default(); for setting in settings { + if setting.kind == "file" + && setting.scope() == SettingScope::Global + && matches!( + setting.effect(), + SettingEffect::Reload | SettingEffect::NextRun + ) + { + declared.executables.insert(setting.id.clone()); + } if setting.effect() == SettingEffect::Immediate { declared.immediate.insert(setting.id.clone()); } else { diff --git a/rust/lithe-core/src/process/lifecycle.rs b/rust/lithe-core/src/process/lifecycle.rs index 55995b7ce3..5f7e522665 100644 --- a/rust/lithe-core/src/process/lifecycle.rs +++ b/rust/lithe-core/src/process/lifecycle.rs @@ -35,6 +35,9 @@ pub enum LifecycleContext { /// background services must wait for startup confirmation unless their /// exact identities, or a setting, already approved ending them. FinalSweep, + /// The user closes a project or window while Lithe keeps running. Only + /// what that workspace owns ends; everything else is decided on quit. + CloseWorkspace, } /// Category of a ledger record; it selects the default policy. @@ -107,6 +110,9 @@ pub enum LifecycleAction { Defer, /// Not owned by this instance; do not show or touch it. Ignore, + /// List it in one confirmation that stops every listed record or cancels + /// the close; there is no option to keep a single record running. + ConfirmStop, } /// Choice remembered for every record matched by one recognizer. @@ -156,6 +162,20 @@ pub enum BackgroundResourcePolicy { Keep, } +/// Exit behavior of Run sessions when their window or Lithe closes. +/// +/// A Run session's output pipes and process tree belong to Lithe, so it cannot +/// be kept running after its owner closes; the only question is whether to ask. +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum RunServicePolicy { + /// List running services and stop them only after the user confirms. + #[default] + Ask, + /// Stop them without asking. + Terminate, +} + /// Handling of an earlier instance's leftovers found at startup. #[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] @@ -179,6 +199,8 @@ pub struct TerminationSettings { pub agent_tasks: AgentTaskPolicy, /// Daemons and containers when quitting. pub background_resources: BackgroundResourcePolicy, + /// Run sessions when quitting or closing the project or window they belong to. + pub run_services_on_exit: RunServicePolicy, /// Choices remembered from the background-resource dialog, by recognizer ID. pub remembered_choices: BTreeMap, /// Leftovers of an earlier instance. @@ -194,6 +216,7 @@ impl Default for TerminationSettings { confirm_terminal_tab_close: true, agent_tasks: AgentTaskPolicy::default(), background_resources: BackgroundResourcePolicy::default(), + run_services_on_exit: RunServicePolicy::default(), remembered_choices: BTreeMap::new(), startup_sweep: StartupSweepPolicy::default(), graceful_stop_seconds: 5, @@ -389,9 +412,32 @@ fn apply_settings( policy: ExitPolicy, source: &'static str, ) -> (LifecycleAction, &'static str) { - use LifecycleAction::{Ask, Confirm, Defer, Retain, Terminate}; + use LifecycleAction::{Ask, Confirm, ConfirmStop, Defer, Retain, Terminate}; + // Only sessions the host launched and tracks itself; a `run` item that an + // inventory merely found in the instance scope keeps the default. + let tracked_run = record.kind == RecordKind::Run && record.ownership == Ownership::Launched; + if context == LifecycleContext::CloseWorkspace + && policy != ExitPolicy::Terminate + && policy != ExitPolicy::External + { + // Terminals, Agent tasks and background services have their own close + // or are decided when Lithe quits. + return (Defer, "decidedOnQuit"); + } match policy { ExitPolicy::External => (LifecycleAction::Ignore, source), + ExitPolicy::Terminate + if tracked_run + && matches!( + context, + LifecycleContext::Quit | LifecycleContext::CloseWorkspace + ) => + { + match settings.run_services_on_exit { + RunServicePolicy::Ask => (ConfirmStop, "runServicesSetting"), + RunServicePolicy::Terminate => (Terminate, "runServicesSetting"), + } + } ExitPolicy::Terminate => (Terminate, source), ExitPolicy::ConfirmEach if record.kind == RecordKind::AgentTask => match context { // Nobody can confirm after a crash, and an interrupted migration is @@ -418,6 +464,7 @@ fn apply_settings( | LifecycleContext::AgentDisconnect | LifecycleContext::FinalSweep => (Terminate, source), LifecycleContext::StartupSweep => startup_sweep(settings), + LifecycleContext::CloseWorkspace => (Defer, "decidedOnQuit"), }, ExitPolicy::AskOnExit => { let remembered = record @@ -433,6 +480,7 @@ fn apply_settings( (_, Some(RememberedChoice::Keep)) => (Retain, "rememberedChoice"), (_, Some(RememberedChoice::Terminate)) => (Terminate, "rememberedChoice"), (LifecycleContext::StartupSweep, None) => startup_sweep(settings), + (LifecycleContext::CloseWorkspace, _) => (Defer, "decidedOnQuit"), ( LifecycleContext::Quit | LifecycleContext::CrashRecovery @@ -513,6 +561,8 @@ pub struct Settlement { /// A question without an answer keeps its record: the host only calls this /// once the user went through every question, so a missing answer means the /// prompt could not be shown, and keeping a process is the reversible choice. +/// `confirmStop` records end: their confirmation has no keep option, and +/// cancelling it cancels the whole close before anything is settled. /// Deferred and ignored records are in neither list. pub fn settle(request: &SettleRequest) -> Settlement { let mut settlement = Settlement { @@ -522,7 +572,8 @@ pub fn settle(request: &SettleRequest) -> Settlement { }; for decision in &request.decisions { let terminate = match decision.action { - LifecycleAction::Terminate => true, + // The host settles only after the user approved stopping the list. + LifecycleAction::Terminate | LifecycleAction::ConfirmStop => true, LifecycleAction::Retain => false, LifecycleAction::Confirm | LifecycleAction::Ask => { let answer = request diff --git a/rust/lithe-core/src/project/file_name_order.rs b/rust/lithe-core/src/project/file_name_order.rs new file mode 100644 index 0000000000..0be61de199 --- /dev/null +++ b/rust/lithe-core/src/project/file_name_order.rs @@ -0,0 +1,189 @@ +//! Sibling ordering for workspace tree rows, aligned with IntelliJ IDEA's Project view. +//! +//! IDEA ranks same-level nodes with `AlphaComparator`, which compares names through +//! `FileNameComparator`, i.e. `NaturalComparator.naturalCompare(ignoreCase = true, +//! likeFileNames = true)`. Digit runs compare by value (`v0.1.4` before `v0.1.12`), +//! letter case is ignored until every other difference ties, and `-` ranks next to +//! `_` (IDEA-80435). +//! +//! Adapted from intellij-community `NaturalComparator` and `FileNameComparator` at +//! d8c6714c7a3c110c56994d6b0f10814f902100e7 (Apache-2.0, Copyright JetBrains s.r.o. +//! and contributors). The macOS Swift port (`NaturalNameOrder`) and the Windows +//! TypeScript port follow the same source and share +//! `shared/fixtures/workspace/file-name-order-v1.json`. + +use std::cmp::Ordering; + +const SPACE: u16 = 0x20; +const ZERO: u16 = 0x30; +const DASH: u16 = 0x2D; +const UNDERSCORE: u16 = 0x5F; + +/// A name pre-converted to UTF-16 code units so that sorting a large directory +/// converts each name once instead of once per comparison. +/// +/// Java compares `char` values, so UTF-16 units keep the upstream order for +/// supplementary characters as well. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct FileNameKey(Vec); + +impl FileNameKey { + pub(crate) fn new(name: &str) -> Self { + Self(name.encode_utf16().collect()) + } +} + +impl Ord for FileNameKey { + fn cmp(&self, other: &Self) -> Ordering { + compare_utf16(&self.0, &other.0) + } +} + +impl PartialOrd for FileNameKey { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +/// Compares two file names in IDEA's Project view order. +#[cfg(test)] +pub(crate) fn compare_file_names(left: &str, right: &str) -> Ordering { + FileNameKey::new(left).cmp(&FileNameKey::new(right)) +} + +fn compare_utf16(left: &[u16], right: &[u16]) -> Ordering { + natural_compare(left, right, true) +} + +fn natural_compare(left: &[u16], right: &[u16], ignore_case: bool) -> Ordering { + let (mut i, mut j) = (0, 0); + while i < left.len() && j < right.len() { + let (first, second) = (left[i], right[j]); + if (is_digit(first) || first == SPACE) && (is_digit(second) || second == SPACE) { + let start_left = skip_unit(left, skip_unit(left, i, SPACE), ZERO); + let start_right = skip_unit(right, skip_unit(right, j, SPACE), ZERO); + let end_left = skip_digits(left, start_left); + let end_right = skip_digits(right, start_right); + + // A number with more significant digits is always greater. + let digits = (end_left - start_left).cmp(&(end_right - start_right)); + if digits != Ordering::Equal { + return digits; + } + let value = left[start_left..end_left].cmp(&right[start_right..end_right]); + if value != Ordering::Equal { + return value; + } + // Equal values: the run with fewer leading spaces and zeroes comes first, + // then the leading characters themselves decide. + let full_length = (end_left - i).cmp(&(end_right - j)); + if full_length != Ordering::Equal { + return full_length; + } + let leading = left[i..start_left].cmp(&right[j..start_right]); + if leading != Ordering::Equal { + return leading; + } + // Both runs end at the same offset; the loop increment below steps past them. + i = end_left - 1; + j = end_right - 1; + } else if first != second { + let order = compare_name_units(first, second, ignore_case); + if order != Ordering::Equal { + return order; + } + } + i += 1; + j += 1; + } + // One side can still have characters left when the other ended inside a number. + if i < left.len() { + return Ordering::Greater; + } + if j < right.len() { + return Ordering::Less; + } + if left.len() != right.len() { + return left.len().cmp(&right.len()); + } + // Names that tie while ignoring case are ordered by one case-sensitive pass. + if ignore_case { + natural_compare(left, right, false) + } else { + Ordering::Equal + } +} + +/// The "likeFileNames" character comparison: a `-` ranks like `_` unless the other +/// character already is `_`, so `foo-bar` and `foo_bar` stay together (IDEA-80435). +fn compare_name_units(first: u16, second: u16, ignore_case: bool) -> Ordering { + if first == DASH && second != UNDERSCORE { + compare_units(UNDERSCORE, second, ignore_case) + } else if second == DASH && first != UNDERSCORE { + compare_units(first, UNDERSCORE, ignore_case) + } else { + compare_units(first, second, ignore_case) + } +} + +fn compare_units(first: u16, second: u16, ignore_case: bool) -> Ordering { + // Upstream transitivity fix: without it a space and characters between ' ' and + // '0' (such as '#') form a cycle with digits. + if first == SPACE && second > SPACE && second < ZERO { + return Ordering::Greater; + } + if second == SPACE && first > SPACE && first < ZERO { + return Ordering::Less; + } + let direct = first.cmp(&second); + if direct == Ordering::Equal || !ignore_case { + return direct; + } + // Same folding as `String.equalsIgnoreCase`: compare uppercased units first and, when + // those differ, their lowercase forms, because uppercasing alone misorders + // some Georgian letters. + let (upper_first, upper_second) = (map_case(first, true), map_case(second, true)); + if upper_first == upper_second { + return Ordering::Equal; + } + map_case(upper_first, false).cmp(&map_case(upper_second, false)) +} + +/// Java `Character.toUpperCase/toLowerCase(char)`: the simple one-to-one mapping, +/// never a multi-character expansion and never leaving the BMP. +fn map_case(unit: u16, uppercase: bool) -> u16 { + // U+0130 has a two-character full lowercase mapping, but Java maps it to 'i'. + if !uppercase && unit == 0x0130 { + return 0x0069; + } + let Some(character) = char::from_u32(u32::from(unit)) else { + return unit; + }; + let mut mapped = if uppercase { + character.to_uppercase().collect::>() + } else { + character.to_lowercase().collect::>() + }; + match (mapped.pop(), mapped.is_empty()) { + (Some(single), true) => u16::try_from(u32::from(single)).unwrap_or(unit), + _ => unit, + } +} + +fn is_digit(unit: u16) -> bool { + (ZERO..=ZERO + 9).contains(&unit) +} + +fn skip_unit(text: &[u16], from: usize, unit: u16) -> usize { + from + text[from..] + .iter() + .take_while(|&&value| value == unit) + .count() +} + +fn skip_digits(text: &[u16], from: usize) -> usize { + from + text[from..] + .iter() + .take_while(|&&value| is_digit(value)) + .count() +} diff --git a/rust/lithe-core/src/project/files.rs b/rust/lithe-core/src/project/files.rs index fab096631e..c9eaed5ac5 100644 --- a/rust/lithe-core/src/project/files.rs +++ b/rust/lithe-core/src/project/files.rs @@ -1,5 +1,6 @@ //! Workspace traversal, file operations, search, and replacement previews. +use super::file_name_order::FileNameKey; use super::search_index::{self, UpdateOutcome, WorkspaceSearchIndex}; use crate::protocol::{invalid_relative_path, CoreError, ErrorCode}; use crate::protocol::{ @@ -16,6 +17,7 @@ use std::path::{Path, PathBuf}; /// Default workspace exclusions, also used by Git discovery for generated directories. pub(crate) const BUILT_IN_HIDDEN_DIRECTORIES: &[&str] = &[ ".git", + ".svn", ".worktree", ".worktrees", ".build", @@ -228,7 +230,7 @@ pub fn list_directories( let Some(directory) = listable_directory(&root, &relative, &rules) else { continue; }; - let Ok(listing) = visible_children(&directory, &root, &rules) else { + let Ok(listing) = visible_children(&directory, &root, &rules, ChildOrder::Tree) else { continue; }; let children = listing @@ -438,7 +440,7 @@ pub fn search_everywhere(request: SearchRequest) -> Result, hidden_files: Vec) -> Self { + pub(crate) fn new(hidden_directories: Vec, hidden_files: Vec) -> Self { let mut directories = BUILT_IN_HIDDEN_DIRECTORIES .iter() .map(|value| (*value).to_string()) @@ -783,13 +785,22 @@ impl VisibilityRules { return true; } let directory_count = components.len().saturating_sub(usize::from(!is_directory)); - for glob in self + let mut folder_globs = self .hidden_file_patterns .iter() - .filter_map(|pattern| pattern.strip_suffix('/')) - { - if (1..=directory_count).any(|count| glob_matches(glob, &components[..count].join("/"))) - { + .filter_map(|pattern| folder_glob(pattern)) + .peekable(); + if folder_globs.peek().is_some() { + // Each ancestor prefix is joined once and shared by every folder + // rule, instead of being rebuilt per rule. + let prefixes = (1..=directory_count) + .map(|count| components[..count].join("/")) + .collect::>(); + if folder_globs.any(|glob| { + prefixes + .iter() + .any(|prefix| path_glob_matches(glob, prefix)) + }) { return true; } } @@ -799,10 +810,36 @@ impl VisibilityRules { let last = components.last().copied().unwrap_or_default(); self.hidden_file_patterns .iter() - .any(|pattern| glob_matches(pattern, last) || glob_matches(pattern, path)) + .any(|pattern| path_glob_matches(pattern, last) || path_glob_matches(pattern, path)) } } +/// Matches a hidden-path glob, letting a leading `**/` stand for zero or more +/// folders, so `**/dist/` covers a root-level `dist` as well as `a/b/dist`. +/// +/// `*` already spans `/`, which covers the non-root case; the root case needs +/// the second attempt because the glob would otherwise demand a literal `/`. +fn path_glob_matches(pattern: &str, value: &str) -> bool { + glob_matches(pattern, value) + || pattern + .strip_prefix("**/") + .is_some_and(|rest| glob_matches(rest, value)) +} + +/// The folder glob of a hidden-path entry that ends in `/`, `/*` or `/**`. +/// +/// Such an entry names a folder from the project root (or at any depth when it +/// starts with `**/`), so the folder and everything below it is hidden instead +/// of leaving an empty folder behind. Other entries keep their file-only +/// meaning, as pinned by `shared/fixtures/search/exclusions.json`. +fn folder_glob(pattern: &str) -> Option<&str> { + let glob = pattern + .strip_suffix("/**") + .or_else(|| pattern.strip_suffix("/*")) + .or_else(|| pattern.strip_suffix('/'))?; + (!glob.is_empty()).then_some(glob) +} + fn scan_node( path: &Path, root: &Path, @@ -828,7 +865,7 @@ fn scan_node( }); } - let listing = visible_children(path, root, rules)?; + let listing = visible_children(path, root, rules, ChildOrder::Traversal)?; nested_worktrees.extend( listing .nested_worktrees @@ -859,7 +896,7 @@ fn collect_snapshot_files( nested_worktrees: &mut Vec, ) -> Result<(), CoreError> { crate::protocol::cancellation::check()?; - let listing = visible_children(path, root, rules)?; + let listing = visible_children(path, root, rules, ChildOrder::Traversal)?; nested_worktrees.extend( listing .nested_worktrees @@ -884,11 +921,23 @@ fn node_name(path: &Path) -> String { .to_string() } -/// Visible children of one directory in tree order, plus the nested linked +/// How `visible_children` orders the siblings it returns. Directories always come first. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ChildOrder { + /// The rows the project tree displays: IDEA's natural file-name order, where digit + /// runs compare by value (see `file_name_order`). + Tree, + /// The sequence behind flat file lists and the search index: lowercased names in + /// byte order. Consumers that keep only the first N entries of that sequence, such + /// as Windows Quick Open (the first 100 matches of the snapshot list), depend on it, + /// so a display-order change must not reorder it. + Traversal, +} + +/// Visible children of one directory in the requested order, plus the nested linked /// worktrees that were left out of it. pub(crate) struct VisibleDirectory { - /// Child paths and whether each is a directory: directories first, then - /// case-insensitive names. + /// Child paths and whether each is a directory, sorted by the requested `ChildOrder`. pub(crate) children: Vec<(PathBuf, bool)>, pub(crate) nested_worktrees: Vec, } @@ -905,6 +954,7 @@ pub(crate) fn visible_children( path: &Path, root: &Path, rules: &VisibilityRules, + order: ChildOrder, ) -> Result { #[cfg(test)] DIRECTORY_VISITS.with_borrow_mut(|visits| { @@ -918,23 +968,37 @@ pub(crate) fn visible_children( .filter_map(Result::ok) .filter_map(|entry| visible_child(entry, root, rules, &mut nested_worktrees)) .collect::>(); - children.sort_by(|left, right| { - right.1.cmp(&left.1).then_with(|| { - left.0 - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or_default() - .to_lowercase() - .cmp( - &right - .0 - .file_name() + match order { + // Siblings follow IDEA's Project view: directories first, then natural file-name + // order, so `v0.1.4.md` precedes `v0.1.12.md`. + ChildOrder::Tree => children.sort_by_cached_key(|(path, is_directory)| { + ( + std::cmp::Reverse(*is_directory), + FileNameKey::new( + path.file_name() .and_then(|value| value.to_str()) - .unwrap_or_default() - .to_lowercase(), - ) - }) - }); + .unwrap_or_default(), + ), + ) + }), + ChildOrder::Traversal => children.sort_by(|left, right| { + right.1.cmp(&left.1).then_with(|| { + left.0 + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_lowercase() + .cmp( + &right + .0 + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_lowercase(), + ) + }) + }), + } nested_worktrees.sort(); Ok(VisibleDirectory { children, diff --git a/rust/lithe-core/src/project/history.rs b/rust/lithe-core/src/project/history.rs index c5d554920f..3e99091b1e 100644 --- a/rust/lithe-core/src/project/history.rs +++ b/rust/lithe-core/src/project/history.rs @@ -1,5 +1,6 @@ //! Versioned local-history snapshots with bounded retention and storage validation. +use crate::project::files::VisibilityRules; use crate::protocol::{invalid_relative_path, CoreError, ErrorCode}; use crate::protocol::{HistoryEntriesResponse, HistoryEntryResponse}; use serde::{Deserialize, Serialize}; @@ -536,42 +537,3 @@ fn parse_iso_timestamp(value: &str) -> i64 { let days = era * 146_097 + day_of_era - 719_468; days * 86_400 + hour * 3_600 + minute * 60 + second } - -struct VisibilityRules { - directories: Vec, - files: Vec, -} - -impl VisibilityRules { - fn new(hidden_directories: Vec, hidden_files: Vec) -> Self { - let mut directories = vec![".git", ".build", ".swiftpm", "node_modules", "target"] - .into_iter() - .map(String::from) - .collect::>(); - directories.extend(hidden_directories); - let mut files = vec![".DS_Store".to_string()]; - files.extend(hidden_files); - Self { directories, files } - } - - fn is_hidden(&self, path: &str, is_directory: bool) -> bool { - let components = path - .split('/') - .filter(|value| !value.is_empty()) - .collect::>(); - if components.iter().any(|component| { - self.directories - .iter() - .any(|hidden| hidden.eq_ignore_ascii_case(component)) - }) { - return true; - } - if is_directory { - return false; - } - let last = components.last().copied().unwrap_or_default(); - self.files - .iter() - .any(|pattern| pattern.eq_ignore_ascii_case(last)) - } -} diff --git a/rust/lithe-core/src/project/mod.rs b/rust/lithe-core/src/project/mod.rs index c4ef02d369..4210f0f712 100644 --- a/rust/lithe-core/src/project/mod.rs +++ b/rust/lithe-core/src/project/mod.rs @@ -1,6 +1,7 @@ //! Project files, search, local history, and document rendering services. mod document_lifecycle; +pub(crate) mod file_name_order; pub(crate) mod files; mod history; mod markdown; diff --git a/rust/lithe-core/src/project/search_index.rs b/rust/lithe-core/src/project/search_index.rs index 2863d40f08..e372aab461 100644 --- a/rust/lithe-core/src/project/search_index.rs +++ b/rust/lithe-core/src/project/search_index.rs @@ -2,7 +2,7 @@ use crate::project::files::{ is_inside_nested_linked_worktree, java_symbols, read_searchable_text, relative_path, - visible_children, VisibilityRules, + visible_children, ChildOrder, VisibilityRules, }; use crate::protocol::{CoreError, ErrorCode, SearchMatch}; use std::collections::{HashMap, HashSet}; @@ -416,7 +416,7 @@ fn collect_files( files: &mut Vec, ) -> Result<(), CoreError> { crate::protocol::cancellation::check()?; - let children = match visible_children(path, root, rules) { + let children = match visible_children(path, root, rules, ChildOrder::Traversal) { Ok(listing) => listing.children, Err(error) if path != root @@ -523,9 +523,12 @@ mod tests { } let _cleanup = Cleanup(root.clone()); fs::write(root.join("src/Name.java"), "class Name {}\n").unwrap(); + // This guards enumeration decisions, not filesystem throughput. Both generated + // roots only need enough entries to exceed the result cap on every platform. + let generated_files_per_directory = 16; for directory in [".build", "node_modules"] { fs::create_dir(root.join(directory)).unwrap(); - for number in 0..1000 { + for number in 0..generated_files_per_directory { fs::write(root.join(directory).join(format!("Name{number}.bin")), "").unwrap(); } } @@ -564,7 +567,8 @@ mod tests { .sum(); assert_eq!( resident_files, - usize::from(files > 0) * 2001 + usize::from(content > 0 || symbols > 0) + usize::from(files > 0) * (2 * generated_files_per_directory + 1) + + usize::from(content > 0 || symbols > 0) ); eprintln!("Cold source caps files={files} content={content} symbols={symbols}: {:?}, indexes={}, resident files={resident_files}, results={}", started.elapsed(), built.len(), response.matches.len()); @@ -702,19 +706,21 @@ mod tests { fs::create_dir_all(file.parent().unwrap()).unwrap(); fs::write(file, "Name before").unwrap(); } - let rules = VisibilityRules { - hidden_directories: vec![], - hidden_file_patterns: vec![ - "cache/".into(), - "**/.*/".into(), - ".*/".into(), - "*.log".into(), - ], - }; let fixture: serde_json::Value = serde_json::from_str(include_str!( "../../../../shared/fixtures/search/exclusions.json" )) .unwrap(); + // The patterns come from the shared fixture, so Rust and Swift always + // judge the same list. + let rules = VisibilityRules { + hidden_directories: vec![], + hidden_file_patterns: fixture["hiddenFilePatterns"] + .as_array() + .unwrap() + .iter() + .map(|pattern| pattern.as_str().unwrap().to_string()) + .collect(), + }; for case in fixture["cases"].as_array().unwrap() { assert_eq!( rules.is_hidden( diff --git a/rust/lithe-core/src/project_generators/archive.rs b/rust/lithe-core/src/project_generators/archive.rs new file mode 100644 index 0000000000..7628f1a90f --- /dev/null +++ b/rust/lithe-core/src/project_generators/archive.rs @@ -0,0 +1,235 @@ +//! Extracting a downloaded project archive into the staging directory. +//! +//! The archive comes from a server the user can change, so it is untrusted: +//! every entry must stay inside the destination, symbolic links are refused, +//! existing files are never replaced, and the bytes actually written are +//! bounded regardless of what the headers claim. + +use crate::plugins::declarative::{PluginRejection, Validation}; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read, Write}; +use std::path::{Component, Path, PathBuf}; + +const MAX_ENTRIES: usize = 20_000; +const MAX_EXTRACTED_BYTES: u64 = 512 * 1024 * 1024; +const ARCHIVE_INVALID: &str = "plugin.project.archiveInvalid"; + +/// `S_IFMT` and `S_IFLNK` of a Unix mode stored in a zip entry. +const FILE_TYPE_MASK: u32 = 0o170000; +const SYMLINK_TYPE: u32 = 0o120000; + +/// Extracts `archive` into `destination`, which must be an existing +/// directory. Returns the number of files written. +pub(crate) fn extract(archive: &Path, destination: &Path) -> Validation { + let rejected = |message: String| PluginRejection::at(ARCHIVE_INVALID, "archive", message); + let destination_metadata = fs::symlink_metadata(destination) + .map_err(|error| rejected(format!("The destination cannot be read: {error}")))?; + if !destination_metadata.is_dir() { + return Err(rejected("The destination is not a directory.".into())); + } + let file = File::open(archive) + .map_err(|error| rejected(format!("The archive cannot be read: {error}")))?; + let mut zip = zip::ZipArchive::new(file) + .map_err(|error| rejected(format!("The download is not a zip archive: {error}")))?; + if zip.len() > MAX_ENTRIES { + return Err(rejected("The archive has too many entries.".into())); + } + + let mut written: u64 = 0; + let mut files = 0; + for index in 0..zip.len() { + let mut entry = zip + .by_index(index) + .map_err(|error| rejected(format!("Entry {index} cannot be read: {error}")))?; + let name = entry.name().to_string(); + let relative = entry + .enclosed_name() + .filter(|path| is_plain_relative(path)) + .ok_or_else(|| rejected(format!("{name} leaves the project directory.")))?; + let mode = entry.unix_mode(); + if mode.is_some_and(|mode| mode & FILE_TYPE_MASK == SYMLINK_TYPE) { + return Err(rejected(format!("{name} is a symbolic link."))); + } + let target = destination.join(&relative); + ensure_directories(destination, relative.parent().unwrap_or(Path::new(""))) + .map_err(|error| rejected(format!("{name}: {error}")))?; + if entry.is_dir() { + ensure_directories(destination, &relative) + .map_err(|error| rejected(format!("{name}: {error}")))?; + continue; + } + let mut output = OpenOptions::new() + .write(true) + .create_new(true) + .open(&target) + .map_err(|error| rejected(format!("{name} cannot be created: {error}")))?; + let remaining = MAX_EXTRACTED_BYTES - written; + let copied = io::copy(&mut (&mut entry).take(remaining + 1), &mut output) + .map_err(|error| rejected(format!("{name} cannot be extracted: {error}")))?; + if copied > remaining { + return Err(rejected( + "The archive expands beyond the size limit.".into(), + )); + } + output + .flush() + .map_err(|error| rejected(format!("{name}: {error}")))?; + written += copied; + files += 1; + set_executable(&target, mode).map_err(|error| rejected(format!("{name}: {error}")))?; + } + Ok(files) +} + +fn is_plain_relative(path: &Path) -> bool { + path.components().next().is_some() + && path + .components() + .all(|component| matches!(component, Component::Normal(_))) +} + +/// Creates each directory below `root` along `relative`, refusing to walk +/// through anything that is not a real directory. +fn ensure_directories(root: &Path, relative: &Path) -> io::Result<()> { + let mut current = PathBuf::from(root); + for component in relative.components() { + current.push(component); + match fs::symlink_metadata(¤t) { + Ok(metadata) if metadata.is_dir() => {} + Ok(_) => { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + format!("{} is not a directory", current.display()), + )) + } + Err(error) if error.kind() == io::ErrorKind::NotFound => fs::create_dir(¤t)?, + Err(error) => return Err(error), + } + } + Ok(()) +} + +#[cfg(unix)] +fn set_executable(path: &Path, mode: Option) -> io::Result<()> { + use std::os::unix::fs::PermissionsExt; + if mode.is_some_and(|mode| mode & 0o111 != 0) { + let mut permissions = fs::metadata(path)?.permissions(); + permissions.set_mode(permissions.mode() | 0o111); + fs::set_permissions(path, permissions)?; + } + Ok(()) +} + +#[cfg(not(unix))] +fn set_executable(_: &Path, _: Option) -> io::Result<()> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use zip::write::SimpleFileOptions; + + fn archive(entries: &[(&str, &[u8], Option)]) -> (tempfile_like::Dir, PathBuf) { + let directory = tempfile_like::Dir::new(); + let path = directory.path().join("archive.zip"); + let mut writer = zip::ZipWriter::new(File::create(&path).unwrap()); + for (name, contents, mode) in entries { + let mut options = SimpleFileOptions::default(); + if let Some(mode) = mode { + options = options.unix_permissions(*mode); + } + writer.start_file(*name, options).unwrap(); + writer.write_all(contents).unwrap(); + } + writer.finish().unwrap(); + (directory, path) + } + + /// A self-cleaning directory under the system temporary directory. + mod tempfile_like { + use std::path::{Path, PathBuf}; + + pub(super) struct Dir(PathBuf); + + impl Dir { + pub(super) fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "lithe-generator-archive-{}-{}", + std::process::id(), + rand::random::() + )); + std::fs::create_dir_all(&path).unwrap(); + Self(path) + } + + pub(super) fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for Dir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + } + + #[test] + fn extracts_files_and_keeps_executable_bits() { + let (directory, path) = archive(&[ + ("pom.xml", b"", None), + ("mvnw", b"#!/bin/sh", Some(0o755)), + ("src/main/java/App.java", b"class App {}", None), + ]); + let destination = directory.path().join("project"); + fs::create_dir(&destination).unwrap(); + assert_eq!(extract(&path, &destination).unwrap(), 3); + assert_eq!( + fs::read_to_string(destination.join("src/main/java/App.java")).unwrap(), + "class App {}" + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = fs::metadata(destination.join("mvnw")) + .unwrap() + .permissions() + .mode(); + assert_ne!(mode & 0o100, 0); + } + } + + #[test] + fn refuses_entries_outside_the_destination_and_overwrites() { + let (directory, path) = archive(&[("../escape.txt", b"x", None)]); + let destination = directory.path().join("project"); + fs::create_dir(&destination).unwrap(); + assert!(extract(&path, &destination).is_err()); + assert!(!directory.path().join("escape.txt").exists()); + + let (directory, path) = archive(&[("pom.xml", b"new", None)]); + let destination = directory.path().join("project"); + fs::create_dir(&destination).unwrap(); + fs::write(destination.join("pom.xml"), "old").unwrap(); + assert!(extract(&path, &destination).is_err()); + assert_eq!( + fs::read_to_string(destination.join("pom.xml")).unwrap(), + "old" + ); + } + + #[test] + fn refuses_symbolic_links() { + let directory = tempfile_like::Dir::new(); + let path = directory.path().join("archive.zip"); + let mut writer = zip::ZipWriter::new(File::create(&path).unwrap()); + writer + .add_symlink("link", "/etc/passwd", SimpleFileOptions::default()) + .unwrap(); + writer.finish().unwrap(); + let destination = directory.path().join("project"); + fs::create_dir(&destination).unwrap(); + assert!(extract(&path, &destination).is_err()); + } +} diff --git a/rust/lithe-core/src/project_generators/commands.rs b/rust/lithe-core/src/project_generators/commands.rs new file mode 100644 index 0000000000..425eb1cc13 --- /dev/null +++ b/rust/lithe-core/src/project_generators/commands.rs @@ -0,0 +1,225 @@ +//! The `project.*` commands: JSON in, outcome envelope out. +//! +//! Commands that take a manifest validate it again, as the `build.*` +//! commands do, so a host never has to trust its own cache of a manifest. +//! Malformed payloads fail the call; rejected declarations and inputs come +//! back as `{status: "rejected", error}`. + +use super::archive; +use super::form::{self, FormInput}; +use super::manifest::{GeneratorSection, ProjectGeneratorDeclaration}; +use super::plan; +use crate::plugins::capabilities::HostCapabilities; +use crate::plugins::declarative::{ + decode_payload, outcome, validate_manifest_value, DeclarativePluginManifest, PluginRejection, + Validation, +}; +use crate::protocol::CoreError; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::path::PathBuf; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct CatalogPayload { + plugins: Vec, + #[serde(default)] + host_capabilities: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct CatalogPlugin { + manifest: Value, + /// Whether the user has the plugin turned on. Generators of a disabled + /// plugin are still listed, so the host can offer to enable it. + enabled: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct FormPayload { + manifest: Value, + #[serde(rename = "generatorID")] + generator_id: String, + form: FormInput, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PlanPayload { + manifest: Value, + #[serde(rename = "generatorID")] + generator_id: String, + /// Installed package directory the template files are read from. + package_path: PathBuf, + form: FormInput, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct CheckNamePayload { + project_name: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ExtractPayload { + archive_path: PathBuf, + /// Existing staging directory the archive is extracted into. + destination: PathBuf, +} + +/// `project.generatorCatalog`: every generator of the given plugins, with +/// the plugins that provide each requirement. +pub(crate) fn project_generator_catalog_command(payload: Value) -> Result { + let payload: CatalogPayload = decode_payload(payload, "project.generatorCatalog")?; + let mut manifests = Vec::new(); + let mut issues = Vec::new(); + for plugin in payload.plugins { + let plugin_id = plugin + .manifest + .get("id") + .and_then(Value::as_str) + .map(str::to_string); + let validated = validate_manifest_value(plugin.manifest).and_then(|manifest| { + if let Some(host) = &payload.host_capabilities { + crate::plugins::capabilities::check_hosted(manifest.requires(), host)?; + } + Ok(manifest) + }); + match validated { + Ok(manifest) => manifests.push((manifest, plugin.enabled)), + Err(error) => issues.push(json!({"pluginID": plugin_id, "error": error})), + } + } + + let providers = |matches: &dyn Fn(&DeclarativePluginManifest) -> bool| -> Vec { + manifests + .iter() + .filter(|(manifest, _)| matches(manifest)) + .map(|(manifest, enabled)| { + json!({ + "pluginID": manifest.id(), + "pluginName": manifest.display_name(), + "enabled": enabled, + }) + }) + .collect() + }; + + let mut entries: Vec<(GeneratorSection, u32, String, Value)> = Vec::new(); + for (manifest, enabled) in &manifests { + for generator in manifest.project_generators() { + let requirements: Vec = generator + .requirements + .iter() + .map(|requirement| { + let (key, id, found) = + match (&requirement.language_id, &requirement.build_system_id) { + (Some(id), _) => ( + "languageID", + id, + providers(&|candidate| { + candidate.language_ids().any(|language| language == id) + }), + ), + (_, Some(id)) => ( + "buildSystemID", + id, + providers(&|candidate| { + candidate + .build_systems() + .iter() + .any(|system| &system.id == id) + }), + ), + _ => unreachable!("validated requirement names one ID"), + }; + json!({ key: id, "providers": found }) + }) + .collect(); + entries.push(( + generator.section, + generator.order, + generator.display_name.to_lowercase(), + json!({ + "pluginID": manifest.id(), + "pluginName": manifest.display_name(), + "pluginEnabled": enabled, + "generator": generator, + "requirements": requirements, + "usesNetwork": generator.steps.iter().any(|step| step.uses_network()), + }), + )); + } + } + entries.sort_by(|left, right| { + let section = |section: GeneratorSection| section != GeneratorSection::Project; + (section(left.0), left.1, &left.2).cmp(&(section(right.0), right.1, &right.2)) + }); + let generators: Vec = entries.into_iter().map(|(_, _, _, entry)| entry).collect(); + Ok(outcome(Ok(( + "catalog", + json!({"generators": generators, "issues": issues}), + )))) +} + +/// `project.generatorForm`: field values, visibility, and problems. +pub(crate) fn project_generator_form_command(payload: Value) -> Result { + let payload: FormPayload = decode_payload(payload, "project.generatorForm")?; + Ok(outcome(with_generator( + payload.manifest, + &payload.generator_id, + |generator| Ok(("form", form::evaluate(generator, &payload.form).to_json())), + ))) +} + +/// `project.generationPlan`: the steps that create the project. +pub(crate) fn project_generation_plan_command(payload: Value) -> Result { + let payload: PlanPayload = decode_payload(payload, "project.generationPlan")?; + Ok(outcome(with_generator( + payload.manifest, + &payload.generator_id, + |generator| { + plan::plan(generator, &payload.package_path, &payload.form).map(|plan| ("plan", plan)) + }, + ))) +} + +/// `project.checkName`: whether a name can be a new project's directory. +pub(crate) fn project_check_name_command(payload: Value) -> Result { + let payload: CheckNamePayload = decode_payload(payload, "project.checkName")?; + Ok(outcome(Ok(( + "name", + json!({"problem": form::project_name_problem(&payload.project_name)}), + )))) +} + +/// `project.extractArchive`: extracts a downloaded archive into staging. +pub(crate) fn project_extract_archive_command(payload: Value) -> Result { + let payload: ExtractPayload = decode_payload(payload, "project.extractArchive")?; + Ok(outcome( + archive::extract(&payload.archive_path, &payload.destination) + .map(|files| ("extracted", json!({"files": files}))), + )) +} + +fn with_generator( + manifest: Value, + generator_id: &str, + run: impl FnOnce(&ProjectGeneratorDeclaration) -> Validation<(&'static str, Value)>, +) -> Validation<(&'static str, Value)> { + let manifest = validate_manifest_value(manifest)?; + let generator = manifest + .project_generators() + .iter() + .find(|generator| generator.id == generator_id) + .ok_or_else(|| { + PluginRejection::new( + "plugin.project.unavailable", + format!("The plugin declares no project generator {generator_id}."), + ) + })?; + run(generator) +} diff --git a/rust/lithe-core/src/project_generators/form.rs b/rust/lithe-core/src/project_generators/form.rs new file mode 100644 index 0000000000..23e52c4cd6 --- /dev/null +++ b/rust/lithe-core/src/project_generators/form.rs @@ -0,0 +1,489 @@ +//! Evaluating a generator's form: which fields are shown, the value each +//! holds (derived defaults included), and what is wrong with it. +//! +//! Hosts send what the user changed and draw what comes back, so derived +//! defaults ("package = group + artifact") and visibility rules behave the +//! same on every platform without host code per generator. + +use super::manifest::{ + Conditions, GeneratorField, ProjectGeneratorDeclaration, FIELD_BOOLEAN, FIELD_ENUM, + FIELD_MULTI_SELECT, FIELD_RUNTIME, FIELD_TEXT, +}; +use super::template::{FieldValue, Values}; +use crate::plugins::declarative::version_components; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet}; + +/// Longest project name, in characters. +const MAX_PROJECT_NAME: usize = 128; + +/// A runtime installation the host discovered and the user selected. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct RuntimeSelection { + pub(crate) home: String, + pub(crate) version: String, +} + +/// What the user has entered so far. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct FormInput { + pub(crate) project_name: String, + /// Values by field ID. A text value counts only if its ID is in + /// `edited`; otherwise the field keeps following its default. + #[serde(default)] + pub(crate) values: BTreeMap, + #[serde(default)] + pub(crate) edited: BTreeSet, + /// Selected installations of runtime fields. + #[serde(default)] + pub(crate) runtimes: BTreeMap, +} + +/// Why a value is not accepted. Hosts localize by code. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum FieldProblem { + Required, + TooLong, + InvalidFormat, + UnsupportedValue, + RuntimeTooOld, + /// The value names a newer version than `maximumVersion` allows. + VersionTooHigh, +} + +impl FieldProblem { + pub(crate) fn code(self) -> &'static str { + match self { + Self::Required => "required", + Self::TooLong => "tooLong", + Self::InvalidFormat => "invalidFormat", + Self::UnsupportedValue => "unsupportedValue", + Self::RuntimeTooOld => "runtimeTooOld", + Self::VersionTooHigh => "versionTooHigh", + } + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct FieldState { + pub(crate) id: String, + pub(crate) visible: bool, + pub(crate) value: Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) problem: Option, +} + +/// The evaluated form. +#[derive(Debug, Clone)] +pub(crate) struct FormState { + pub(crate) project_name_problem: Option, + pub(crate) fields: Vec, + /// Values of visible fields, as templates read them. + pub(crate) values: BTreeMap, + /// Indices of requirements whose conditions hold. + pub(crate) active_requirements: Vec, +} + +impl FormState { + pub(crate) fn is_valid(&self) -> bool { + self.project_name_problem.is_none() + && self.fields.iter().all(|field| field.problem.is_none()) + } + + pub(crate) fn to_json(&self) -> Value { + json!({ + "projectName": {"problem": self.project_name_problem}, + "fields": self.fields, + "activeRequirements": self.active_requirements, + "valid": self.is_valid(), + }) + } +} + +pub(crate) fn evaluate(generator: &ProjectGeneratorDeclaration, input: &FormInput) -> FormState { + let mut values: BTreeMap = BTreeMap::new(); + let mut fields = Vec::with_capacity(generator.fields.len()); + for field in &generator.fields { + let visible = holds(field.visible_when.as_ref(), &values); + let (value, template_value, problem) = resolve(field, input, &values); + let problem = problem.or_else(|| version_too_high(field, input, &values, &template_value)); + if visible { + values.insert(field.id.clone(), template_value); + } + fields.push(FieldState { + id: field.id.clone(), + visible, + value, + problem: problem.filter(|_| visible), + }); + } + let active_requirements = generator + .requirements + .iter() + .enumerate() + .filter(|(_, requirement)| holds(requirement.when.as_ref(), &values)) + .map(|(index, _)| index) + .collect(); + FormState { + project_name_problem: project_name_problem(&input.project_name), + fields, + values, + active_requirements, + } +} + +/// Whether every condition holds. A condition on a hidden field is false, +/// so nothing depends on a value the user cannot see. +pub(crate) fn holds( + conditions: Option<&Conditions>, + values: &BTreeMap, +) -> bool { + let Some(conditions) = conditions else { + return true; + }; + conditions.as_slice().iter().all(|condition| { + let Some(value) = values.get(&condition.field) else { + return false; + }; + let matches = |expected: &Value| match (value, expected) { + (FieldValue::Flag(flag), Value::Bool(expected)) => flag == expected, + (_, Value::String(expected)) => value.text() == *expected, + _ => false, + }; + match (&condition.equals, &condition.not_equals, &condition.one_of) { + (Some(expected), _, _) => matches(expected), + (_, Some(expected), _) => !matches(expected), + (_, _, Some(options)) => options.iter().any(matches), + _ => false, + } + }) +} + +fn resolve( + field: &GeneratorField, + input: &FormInput, + earlier: &BTreeMap, +) -> (Value, FieldValue, Option) { + let given = input.values.get(&field.id); + match field.kind.as_str() { + FIELD_TEXT => { + let text = match given { + Some(Value::String(text)) if input.edited.contains(&field.id) => text.clone(), + _ => field + .default_template() + .map(|parts| { + Values { + project_name: &input.project_name, + fields: earlier, + } + .expand(&parts) + }) + .unwrap_or_default(), + }; + let problem = text_problem(field, &text); + (json!(text), FieldValue::Text(text), problem) + } + FIELD_ENUM => { + let fallback = field + .default + .as_ref() + .and_then(Value::as_str) + .or_else(|| field.options.first().map(|option| option.value.as_str())) + .unwrap_or_default() + .to_string(); + let (choice, problem) = match given { + None => (fallback, None), + Some(Value::String(choice)) if field.offers(choice) => (choice.clone(), None), + Some(_) => (fallback, Some(FieldProblem::UnsupportedValue)), + }; + (json!(choice), FieldValue::Text(choice), problem) + } + FIELD_BOOLEAN => { + let flag = match given { + Some(Value::Bool(flag)) => *flag, + _ => field + .default + .as_ref() + .and_then(Value::as_bool) + .unwrap_or(false), + }; + (json!(flag), FieldValue::Flag(flag), None) + } + FIELD_MULTI_SELECT => { + let (items, problem) = match given { + None => ( + field + .default + .as_ref() + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(|item| item.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(), + None, + ), + Some(Value::Array(items)) => { + let chosen: Vec = items + .iter() + .filter_map(|item| item.as_str()) + .filter(|value| field.offers(value)) + .map(str::to_string) + .collect(); + let problem = + (chosen.len() != items.len()).then_some(FieldProblem::UnsupportedValue); + // Keep declaration order, so plans do not depend on + // the order the user clicked. + let ordered = field + .options + .iter() + .filter(|option| chosen.contains(&option.value)) + .map(|option| option.value.clone()) + .collect(); + (ordered, problem) + } + Some(_) => (Vec::new(), Some(FieldProblem::UnsupportedValue)), + }; + (json!(items), FieldValue::List(items), problem) + } + FIELD_RUNTIME => match input.runtimes.get(&field.id) { + Some(selection) if !selection.home.trim().is_empty() => { + let too_old = field.minimum_version.as_deref().is_some_and(|minimum| { + version_components(&selection.version) < version_components(minimum) + }); + ( + json!(selection.home), + FieldValue::Runtime { + home: selection.home.clone(), + version: selection.version.clone(), + }, + too_old.then_some(FieldProblem::RuntimeTooOld), + ) + } + _ => ( + json!(""), + FieldValue::Runtime { + home: String::new(), + version: String::new(), + }, + (!field.optional).then_some(FieldProblem::Required), + ), + }, + _ => (Value::Null, FieldValue::Text(String::new()), None), + } +} + +/// Compares a value with the field's `maximumVersion`, expanded over the +/// earlier fields. An empty value or bound (no JDK chosen yet) is left to the +/// fields' own checks. +fn version_too_high( + field: &GeneratorField, + input: &FormInput, + earlier: &BTreeMap, + value: &FieldValue, +) -> Option { + let parts = field.maximum_version_template()?; + let bound = Values { + project_name: &input.project_name, + fields: earlier, + } + .expand(&parts); + let value = value.text(); + if bound.is_empty() || value.is_empty() { + return None; + } + (version_components(&value) > version_components(&bound)) + .then_some(FieldProblem::VersionTooHigh) +} + +fn text_problem(field: &GeneratorField, text: &str) -> Option { + if text.is_empty() { + return (!field.optional).then_some(FieldProblem::Required); + } + if text.chars().count() > field.max_text_length() as usize { + return Some(FieldProblem::TooLong); + } + // Free text never starts like an option and never carries control + // characters, so it cannot become a flag or split a line in argv. + let safe = !text.starts_with('-') && !text.chars().any(char::is_control); + let matches = field + .text_pattern() + .is_none_or(|pattern| pattern.is_match(text)); + (!safe || !matches).then_some(FieldProblem::InvalidFormat) +} + +/// Checks a name that becomes a directory name and appears in argv. +pub(crate) fn project_name_problem(name: &str) -> Option { + if name.is_empty() { + return Some(FieldProblem::Required); + } + if name.chars().count() > MAX_PROJECT_NAME { + return Some(FieldProblem::TooLong); + } + let invalid = name == "." + || name == ".." + || name.starts_with('-') + || name.trim() != name + || name + .chars() + .any(|character| character.is_control() || matches!(character, '/' | '\\' | ':')); + invalid.then_some(FieldProblem::InvalidFormat) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn generator(fields: Value) -> ProjectGeneratorDeclaration { + serde_json::from_value(json!({ + "id": "demo", + "displayName": "Demo", + "section": "generator", + "fields": fields, + "steps": [{"kind": "files", "source": "templates"}], + })) + .unwrap() + } + + fn input(project_name: &str, values: Value, edited: &[&str]) -> FormInput { + FormInput { + project_name: project_name.into(), + values: serde_json::from_value(values).unwrap(), + edited: edited.iter().map(|id| id.to_string()).collect(), + runtimes: BTreeMap::new(), + } + } + + fn field<'a>(state: &'a FormState, id: &str) -> &'a FieldState { + state.fields.iter().find(|field| field.id == id).unwrap() + } + + #[test] + fn derived_defaults_follow_until_edited() { + let generator = generator(json!([ + {"id": "groupId", "type": "text", "title": "Group", "default": "com.example"}, + {"id": "artifactId", "type": "text", "title": "Artifact", "default": "${project.name}"}, + {"id": "packageName", "type": "text", "title": "Package", + "default": "${field.groupId}.${field.artifactId|package}"} + ])); + let state = evaluate(&generator, &input("My-App", json!({}), &[])); + assert_eq!(field(&state, "artifactId").value, json!("My-App")); + assert_eq!( + field(&state, "packageName").value, + json!("com.example.myapp") + ); + + let state = evaluate( + &generator, + &input( + "My-App", + json!({"packageName": "org.custom", "artifactId": "ignored"}), + &["packageName"], + ), + ); + assert_eq!(field(&state, "artifactId").value, json!("My-App")); + assert_eq!(field(&state, "packageName").value, json!("org.custom")); + } + + #[test] + fn hidden_fields_are_not_validated_or_read() { + let generator = generator(json!([ + {"id": "buildSystem", "type": "enum", "title": "Build", "options": [ + {"value": "none", "title": "None"}, {"value": "maven", "title": "Maven"}]}, + {"id": "groupId", "type": "text", "title": "Group", + "visibleWhen": {"field": "buildSystem", "equals": "maven"}} + ])); + let state = evaluate(&generator, &input("demo", json!({}), &[])); + assert!(!field(&state, "groupId").visible); + assert!(state.is_valid()); + assert!(!state.values.contains_key("groupId")); + + let state = evaluate( + &generator, + &input("demo", json!({"buildSystem": "maven"}), &[]), + ); + assert_eq!( + field(&state, "groupId").problem, + Some(FieldProblem::Required) + ); + } + + #[test] + fn rejects_unsafe_text_and_old_runtimes() { + let generator = generator(json!([ + {"id": "groupId", "type": "text", "title": "Group", "pattern": "[a-z.]+"}, + {"id": "jdk", "type": "runtime", "title": "JDK", "runtime": "java", "minimumVersion": "17"} + ])); + let mut form = input("demo", json!({"groupId": "-Dx=y"}), &["groupId"]); + form.runtimes.insert( + "jdk".into(), + RuntimeSelection { + home: "/jdk".into(), + version: "11.0.2".into(), + }, + ); + let state = evaluate(&generator, &form); + assert_eq!( + field(&state, "groupId").problem, + Some(FieldProblem::InvalidFormat) + ); + assert_eq!( + field(&state, "jdk").problem, + Some(FieldProblem::RuntimeTooOld) + ); + } + + /// A target release newer than the selected JDK cannot compile, so the + /// bound follows the JDK; before a JDK is chosen nothing is compared. + #[test] + fn maximum_version_follows_an_earlier_runtime() { + let generator = generator(json!([ + {"id": "jdk", "type": "runtime", "title": "JDK", "runtime": "java", "minimumVersion": "17"}, + {"id": "javaVersion", "type": "enum", "title": "Java", "maximumVersion": "${field.jdk.major}", + "options": [{"value": "17", "title": "17"}, {"value": "21", "title": "21"}]} + ])); + let mut form = input("demo", json!({"javaVersion": "21"}), &[]); + assert_eq!( + field(&evaluate(&generator, &form), "javaVersion").problem, + None + ); + for (version, problem) in [ + ("17.0.12", Some(FieldProblem::VersionTooHigh)), + ("21.0.4", None), + ("25", None), + ] { + form.runtimes.insert( + "jdk".into(), + RuntimeSelection { + home: "/jdk".into(), + version: version.into(), + }, + ); + assert_eq!( + field(&evaluate(&generator, &form), "javaVersion").problem, + problem, + "{version}" + ); + } + } + + #[test] + fn project_names_are_single_directory_names() { + assert_eq!(project_name_problem("demo"), None); + assert_eq!(project_name_problem(""), Some(FieldProblem::Required)); + for name in ["a/b", "..", "-x", " demo", "a:b"] { + assert_eq!( + project_name_problem(name), + Some(FieldProblem::InvalidFormat), + "{name}" + ); + } + } +} diff --git a/rust/lithe-core/src/project_generators/manifest.rs b/rust/lithe-core/src/project_generators/manifest.rs new file mode 100644 index 0000000000..c2c9b4aca7 --- /dev/null +++ b/rust/lithe-core/src/project_generators/manifest.rs @@ -0,0 +1,958 @@ +//! The `projectGenerators` section of a manifest and its validation. + +use super::template::{self, Source, Variable}; +use crate::plugins::declarative::{ + invalid, is_bare_command, is_safe_relative_path, is_setting_identifier, TemplatePart, UIIcon, + Validation, +}; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet}; + +/// Most generators one plugin may declare. +const MAX_GENERATORS: usize = 16; +/// Most fields, requirements, steps, and files to open per generator. +const MAX_FIELDS: usize = 32; +const MAX_REQUIREMENTS: usize = 8; +const MAX_STEPS: usize = 16; +const MAX_OPEN_FILES: usize = 8; +/// Most choices of one enum field, and of one multiSelect field (a +/// dependency list is longer than a choice of build tools). +const MAX_ENUM_OPTIONS: usize = 16; +const MAX_MULTI_SELECT_OPTIONS: usize = 128; +/// Longest ID, title, option value, and short label. +const MAX_LABEL: usize = 120; +/// Longest description. +const MAX_DESCRIPTION: usize = 480; +/// Longest pattern a text field may declare, and longest text value. +const MAX_PATTERN: usize = 256; +pub(crate) const DEFAULT_TEXT_LENGTH: u32 = 256; +const MAX_TEXT_LENGTH: u32 = 1024; +/// Most arguments, environment entries, and query parameters per step. +const MAX_ARGUMENTS: usize = 64; +const MAX_ENVIRONMENT: usize = 16; +const MAX_QUERY: usize = 32; +/// Longest step timeout, in seconds. +const MAX_TIMEOUT_SECONDS: u32 = 1800; + +/// One way to start a project, listed in the New Project window. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct ProjectGeneratorDeclaration { + /// Lowercase letters, digits, and hyphens; unique within the plugin. + pub(crate) id: String, + pub(crate) display_name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) description: Option, + /// The list the entry appears in. + pub(crate) section: GeneratorSection, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) icon: Option, + /// Position within the section; lower first, ties by name. + #[serde(default = "default_order")] + pub(crate) order: u32, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) fields: Vec, + /// Languages and build systems the created project needs. The host + /// tells the user which plugin provides each and offers to enable it. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) requirements: Vec, + pub(crate) steps: Vec, + /// Project-relative files the host opens after creating the project. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) open_files: Vec, +} + +fn default_order() -> u32 { + 100 +} + +/// IDEA's two lists: language projects, and framework generators. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum GeneratorSection { + /// "New Project": a plain project of a language. + Project, + /// "Generators": a framework or scaffolding tool. + Generator, +} + +pub(crate) const FIELD_TEXT: &str = "text"; +pub(crate) const FIELD_ENUM: &str = "enum"; +pub(crate) const FIELD_BOOLEAN: &str = "boolean"; +pub(crate) const FIELD_MULTI_SELECT: &str = "multiSelect"; +pub(crate) const FIELD_RUNTIME: &str = "runtime"; +/// Host-managed runtimes a runtime field may select. The host discovers +/// installations; Core only checks the version. +const KNOWN_RUNTIMES: &[&str] = &["java"]; + +/// One input of a generator. The types follow plugin settings, minus +/// integer and paths, plus `runtime`. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct GeneratorField { + /// lowerCamelCase; templates read it as `${field.}`. + pub(crate) id: String, + #[serde(rename = "type")] + pub(crate) kind: String, + pub(crate) title: String, + /// Explanation the host shows behind an info tip. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) description: Option, + /// Shown under the collapsed "Advanced Settings" group. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub(crate) advanced: bool, + /// Shown, validated, and readable only while these hold. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) visible_when: Option, + /// Initial value. For a text field it is a template over the project + /// name and earlier fields, re-evaluated until the user edits the field. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) default: Option, + /// Choices of an enum or multiSelect field, in display order. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) options: Vec, + /// How an enum field is drawn. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) presentation: Option, + /// Regular expression a text value must match as a whole. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) pattern: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) max_length: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) placeholder: Option, + /// A text or runtime field may be left empty. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub(crate) optional: bool, + /// Runtime a runtime field selects. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) runtime: Option, + /// Oldest acceptable runtime version. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) minimum_version: Option, + /// Highest version a text or enum value may name, as a template over + /// earlier fields, such as `${field.jdk.major}` so a target Java + /// release never exceeds the selected JDK. An empty bound checks nothing. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) maximum_version: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct FieldOption { + pub(crate) value: String, + pub(crate) title: String, + /// Heading a multiSelect option is listed under, such as "Web". + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) group: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum FieldPresentation { + /// A row of buttons, for a few short choices. + Segmented, + /// A drop-down menu. + Menu, +} + +/// One condition or a list that must all hold. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(untagged)] +pub(crate) enum Conditions { + One(Condition), + All(Vec), +} + +impl Conditions { + pub(crate) fn as_slice(&self) -> &[Condition] { + match self { + Self::One(condition) => std::slice::from_ref(condition), + Self::All(conditions) => conditions, + } + } +} + +/// A test of one field's value. Exactly one of the comparisons is set. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct Condition { + pub(crate) field: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) equals: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) not_equals: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) one_of: Option>, +} + +/// A language or build system the created project needs. Exactly one of +/// the two is set. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct GeneratorRequirement { + #[serde( + default, + rename = "languageID", + skip_serializing_if = "Option::is_none" + )] + pub(crate) language_id: Option, + #[serde( + default, + rename = "buildSystemID", + skip_serializing_if = "Option::is_none" + )] + pub(crate) build_system_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) when: Option, +} + +/// A path template, optionally guarded. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(untagged)] +pub(crate) enum ConditionalPath { + Literal(String), + Conditional { path: String, when: Conditions }, +} + +impl ConditionalPath { + pub(crate) fn path(&self) -> &str { + match self { + Self::Literal(path) | Self::Conditional { path, .. } => path, + } + } + + pub(crate) fn when(&self) -> Option<&Conditions> { + match self { + Self::Literal(_) => None, + Self::Conditional { when, .. } => Some(when), + } + } +} + +/// One argv element or query value, optionally guarded. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(untagged)] +pub(crate) enum ConditionalValue { + Literal(String), + Conditional { value: String, when: Conditions }, +} + +impl ConditionalValue { + pub(crate) fn value(&self) -> &str { + match self { + Self::Literal(value) | Self::Conditional { value, .. } => value, + } + } + + pub(crate) fn when(&self) -> Option<&Conditions> { + match self { + Self::Literal(_) => None, + Self::Conditional { when, .. } => Some(when), + } + } +} + +/// One query parameter of an archive URL. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct QueryParameter { + pub(crate) name: String, + pub(crate) value: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) when: Option, + /// Leave the parameter out when its value expands to an empty string. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub(crate) omit_empty: bool, +} + +/// One step of creating a project, run in order inside the staging +/// directory that becomes the project root. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)] +pub(crate) enum GeneratorStep { + /// Copies a template directory of the package, expanding `{{ ... }}` + /// variables in file contents and `${...}` in path names. + Files { + /// Package-relative directory. + source: String, + /// Project-relative directory the files go to; the root if absent. + #[serde(default, skip_serializing_if = "Option::is_none")] + target: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + when: Option, + }, + /// Runs a scaffolding tool with the staging directory as its working + /// directory. + #[serde(rename_all = "camelCase")] + Command { + /// Bare executable name the host resolves like a build tool. + command: String, + #[serde(default)] + arguments: Vec, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + environment: BTreeMap, + /// The tool writes the project into this subdirectory (Maven's + /// archetype plugin uses the artifact ID); its contents become the + /// project root. + #[serde(default, skip_serializing_if = "Option::is_none")] + collect: Option, + /// Shown in the install review and before creating. + may_use_network: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + timeout_seconds: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + when: Option, + }, + /// Downloads a zip archive and extracts it into the project root. + #[serde(rename_all = "camelCase")] + Archive { + /// HTTPS address without a query; `http` only for loopback hosts. + url: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + query: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + when: Option, + }, +} + +impl GeneratorStep { + pub(crate) fn when(&self) -> Option<&Conditions> { + match self { + Self::Files { when, .. } | Self::Command { when, .. } | Self::Archive { when, .. } => { + when.as_ref() + } + } + } + + pub(crate) fn uses_network(&self) -> bool { + match self { + Self::Files { .. } => false, + Self::Command { + may_use_network, .. + } => *may_use_network, + Self::Archive { .. } => true, + } + } +} + +/// Install-review entries: one per scaffolding command. The whole step +/// (executable, argument templates, environment, guard, and network flag) +/// is the approved value, so an update that adds a command or changes what +/// one runs is a new program the user must approve. File and archive steps +/// start no process and are covered by the `project.generate` permission. +pub(crate) fn programs(generator: &ProjectGeneratorDeclaration) -> Vec { + generator + .steps + .iter() + .filter_map(|step| match step { + GeneratorStep::Command { + command, + arguments, + environment, + collect, + may_use_network, + timeout_seconds, + when, + } => Some(json!({ + "kind": "projectGenerator", + "generatorID": generator.id, + "command": command, + "arguments": arguments, + "environment": environment, + "collect": collect, + "mayUseNetwork": may_use_network, + "timeoutSeconds": timeout_seconds, + "when": when, + })), + GeneratorStep::Files { .. } | GeneratorStep::Archive { .. } => None, + }) + .collect() +} + +impl GeneratorField { + pub(crate) fn is(&self, kind: &str) -> bool { + self.kind == kind + } + + pub(crate) fn offers(&self, value: &str) -> bool { + self.options.iter().any(|option| option.value == value) + } + + pub(crate) fn max_text_length(&self) -> u32 { + self.max_length.unwrap_or(DEFAULT_TEXT_LENGTH) + } + + pub(crate) fn text_pattern(&self) -> Option { + self.pattern + .as_ref() + .and_then(|pattern| Regex::new(&format!("^(?:{pattern})$")).ok()) + } + + pub(crate) fn maximum_version_template(&self) -> Option>> { + self.maximum_version.as_deref().and_then(template::parse) + } + + pub(crate) fn default_template(&self) -> Option>> { + match (&self.default, self.is(FIELD_TEXT)) { + (Some(Value::String(text)), true) => template::parse(text), + _ => None, + } + } +} + +/// Validates every generator and sorts fields-free collections that have no +/// meaningful order. Field and step order are kept: fields are drawn and +/// defaults evaluated in order, steps run in order. +pub(crate) fn validate_declarations(generators: &[ProjectGeneratorDeclaration]) -> Validation<()> { + if generators.len() > MAX_GENERATORS { + return Err(invalid( + "projectGenerators", + format!("A plugin declares at most {MAX_GENERATORS} project generators."), + )); + } + let mut ids = BTreeSet::new(); + for (index, generator) in generators.iter().enumerate() { + let field = format!("projectGenerators[{index}]"); + if !ids.insert(generator.id.as_str()) { + return Err(invalid( + format!("{field}.id"), + "A project generator is declared more than once.", + )); + } + generator.validate(&field)?; + } + Ok(()) +} + +impl ProjectGeneratorDeclaration { + fn validate(&self, field: &str) -> Validation<()> { + if !is_generator_id(&self.id) { + return Err(invalid( + format!("{field}.id"), + "A generator ID uses lowercase letters, digits, and hyphens.", + )); + } + check_label(&self.display_name, &format!("{field}.displayName"))?; + check_description(self.description.as_deref(), &format!("{field}.description"))?; + if let Some(icon) = &self.icon { + icon.validate_at(&format!("{field}.icon"))?; + } + if self.order > 1000 { + return Err(invalid(format!("{field}.order"), "order is 0-1000.")); + } + if self.fields.len() > MAX_FIELDS + || self.requirements.len() > MAX_REQUIREMENTS + || self.steps.is_empty() + || self.steps.len() > MAX_STEPS + || self.open_files.len() > MAX_OPEN_FILES + { + return Err(invalid( + field, + format!( + "A generator has at most {MAX_FIELDS} fields, {MAX_REQUIREMENTS} requirements, \ + {MAX_OPEN_FILES} files to open, and 1-{MAX_STEPS} steps." + ), + )); + } + + let mut declared: BTreeMap<&str, &GeneratorField> = BTreeMap::new(); + for (index, item) in self.fields.iter().enumerate() { + let item_field = format!("{field}.fields[{index}]"); + // A field reads only fields declared before it, so defaults are + // evaluated in one pass and visibility never forms a cycle. + item.validate(&item_field, &declared)?; + if declared.insert(item.id.as_str(), item).is_some() { + return Err(invalid( + format!("{item_field}.id"), + "A field is declared more than once.", + )); + } + } + + for (index, requirement) in self.requirements.iter().enumerate() { + let item_field = format!("{field}.requirements[{index}]"); + let id = match (&requirement.language_id, &requirement.build_system_id) { + (Some(id), None) | (None, Some(id)) => id, + _ => { + return Err(invalid( + item_field, + "A requirement names exactly one of languageID and buildSystemID.", + )) + } + }; + if !is_generator_id(id) { + return Err(invalid( + item_field, + "A requirement names a language or build system ID.", + )); + } + check_conditions( + requirement.when.as_ref(), + &format!("{item_field}.when"), + &declared, + )?; + } + + for (index, step) in self.steps.iter().enumerate() { + step.validate(&format!("{field}.steps[{index}]"), &declared)?; + } + + for (index, path) in self.open_files.iter().enumerate() { + let item_field = format!("{field}.openFiles[{index}]"); + check_path_template(path.path(), &item_field, &declared)?; + check_conditions(path.when(), &format!("{item_field}.when"), &declared)?; + } + Ok(()) + } +} + +impl GeneratorField { + fn validate(&self, field: &str, earlier: &BTreeMap<&str, &GeneratorField>) -> Validation<()> { + if !is_setting_identifier(&self.id) { + return Err(invalid( + format!("{field}.id"), + "A field ID starts with a lowercase letter and contains only letters and digits.", + )); + } + check_label(&self.title, &format!("{field}.title"))?; + check_description(self.description.as_deref(), &format!("{field}.description"))?; + if let Some(placeholder) = &self.placeholder { + check_label(placeholder, &format!("{field}.placeholder"))?; + } + check_conditions( + self.visible_when.as_ref(), + &format!("{field}.visibleWhen"), + earlier, + )?; + + let kind = self.kind.as_str(); + let allowed = |name: &str| match name { + "options" => matches!(kind, FIELD_ENUM | FIELD_MULTI_SELECT), + "presentation" => kind == FIELD_ENUM, + "text" => kind == FIELD_TEXT, + "optional" => matches!(kind, FIELD_TEXT | FIELD_RUNTIME), + "runtime" => kind == FIELD_RUNTIME, + "maximumVersion" => matches!(kind, FIELD_TEXT | FIELD_ENUM), + _ => false, + }; + let present = [ + ("options", !self.options.is_empty()), + ("presentation", self.presentation.is_some()), + ( + "text", + self.pattern.is_some() || self.max_length.is_some() || self.placeholder.is_some(), + ), + ("optional", self.optional), + ( + "runtime", + self.runtime.is_some() || self.minimum_version.is_some(), + ), + ("maximumVersion", self.maximum_version.is_some()), + ]; + if let Some((name, _)) = present + .iter() + .find(|(name, present)| *present && !allowed(name)) + { + return Err(invalid( + field, + format!("A {kind} field does not take {name} properties."), + )); + } + + if let Some(maximum) = &self.maximum_version { + let parts = template::parse(maximum).ok_or_else(|| { + invalid( + format!("{field}.maximumVersion"), + "maximumVersion has an unknown or unterminated variable.", + ) + })?; + check_variables(&parts, &format!("{field}.maximumVersion"), earlier)?; + } + + match kind { + FIELD_TEXT => { + let max_length = self.max_text_length(); + if max_length == 0 || max_length > MAX_TEXT_LENGTH { + return Err(invalid( + format!("{field}.maxLength"), + format!("maxLength is 1-{MAX_TEXT_LENGTH}."), + )); + } + if let Some(pattern) = &self.pattern { + if pattern.len() > MAX_PATTERN || self.text_pattern().is_none() { + return Err(invalid( + format!("{field}.pattern"), + "The pattern is not a valid regular expression.", + )); + } + } + match &self.default { + None => {} + Some(Value::String(text)) => { + let parts = template::parse(text).ok_or_else(|| { + invalid( + format!("{field}.default"), + "The default has an unknown or unterminated variable.", + ) + })?; + check_variables(&parts, &format!("{field}.default"), earlier)?; + } + Some(_) => { + return Err(invalid( + format!("{field}.default"), + "A text default is a string.", + )) + } + } + } + FIELD_ENUM | FIELD_MULTI_SELECT => { + let limit = if kind == FIELD_ENUM { + MAX_ENUM_OPTIONS + } else { + MAX_MULTI_SELECT_OPTIONS + }; + if self.options.is_empty() || self.options.len() > limit { + return Err(invalid( + format!("{field}.options"), + format!("A {kind} field offers 1-{limit} options."), + )); + } + let mut values = BTreeSet::new(); + for (index, option) in self.options.iter().enumerate() { + let option_field = format!("{field}.options[{index}]"); + if !is_option_value(&option.value) || !values.insert(option.value.as_str()) { + return Err(invalid( + format!("{option_field}.value"), + "Option values are unique and use letters, digits, dots, underscores, and hyphens.", + )); + } + check_label(&option.title, &format!("{option_field}.title"))?; + if let Some(group) = &option.group { + check_label(group, &format!("{option_field}.group"))?; + } + } + let default_ok = match (&self.default, kind) { + (None, _) => true, + (Some(Value::String(value)), FIELD_ENUM) => self.offers(value), + (Some(Value::Array(items)), FIELD_MULTI_SELECT) => items + .iter() + .all(|item| item.as_str().is_some_and(|value| self.offers(value))), + _ => false, + }; + if !default_ok { + return Err(invalid( + format!("{field}.default"), + "The default must be one of the options.", + )); + } + } + FIELD_BOOLEAN => { + if !matches!(self.default, None | Some(Value::Bool(_))) { + return Err(invalid( + format!("{field}.default"), + "A boolean default is true or false.", + )); + } + } + FIELD_RUNTIME => { + if !self + .runtime + .as_deref() + .is_some_and(|runtime| KNOWN_RUNTIMES.contains(&runtime)) + { + return Err(invalid( + format!("{field}.runtime"), + format!( + "A runtime field selects one of: {}.", + KNOWN_RUNTIMES.join(", ") + ), + )); + } + if let Some(version) = &self.minimum_version { + crate::plugins::declarative::validate_minimum_version( + version, + &format!("{field}.minimumVersion"), + )?; + } + if self.default.is_some() { + return Err(invalid( + format!("{field}.default"), + "The host chooses the initial runtime.", + )); + } + } + _ => { + return Err(invalid( + format!("{field}.type"), + "A field type is text, enum, boolean, multiSelect, or runtime.", + )) + } + } + Ok(()) + } +} + +impl GeneratorStep { + fn validate(&self, field: &str, fields: &BTreeMap<&str, &GeneratorField>) -> Validation<()> { + check_conditions(self.when(), &format!("{field}.when"), fields)?; + match self { + Self::Files { source, target, .. } => { + if !is_safe_relative_path(source) || source.contains('$') { + return Err(invalid( + format!("{field}.source"), + "source is a package-relative directory.", + )); + } + if let Some(target) = target { + check_path_template(target, &format!("{field}.target"), fields)?; + } + } + Self::Command { + command, + arguments, + environment, + collect, + timeout_seconds, + .. + } => { + if !is_bare_command(command) { + return Err(invalid( + format!("{field}.command"), + "command is a bare executable name the host resolves.", + )); + } + if arguments.len() > MAX_ARGUMENTS || environment.len() > MAX_ENVIRONMENT { + return Err(invalid( + field, + format!( + "A command has at most {MAX_ARGUMENTS} arguments and {MAX_ENVIRONMENT} environment entries." + ), + )); + } + for (index, argument) in arguments.iter().enumerate() { + let item = format!("{field}.arguments[{index}]"); + check_value_template(argument.value(), &item, fields)?; + check_conditions(argument.when(), &format!("{item}.when"), fields)?; + } + for (name, value) in environment { + let item = format!("{field}.environment.{name}"); + if !is_environment_name(name) { + return Err(invalid(item, "An environment name uses A-Z, 0-9, and _.")); + } + check_value_template(value, &item, fields)?; + } + if let Some(collect) = collect { + check_path_template(collect, &format!("{field}.collect"), fields)?; + } + if timeout_seconds + .is_some_and(|seconds| seconds == 0 || seconds > MAX_TIMEOUT_SECONDS) + { + return Err(invalid( + format!("{field}.timeoutSeconds"), + format!("timeoutSeconds is 1-{MAX_TIMEOUT_SECONDS}."), + )); + } + } + Self::Archive { url, query, .. } => { + let parts = check_value_template(url, &format!("{field}.url"), fields)?; + // The scheme and host must be visible in the declaration: + // only a field may supply the whole address, which the host + // shows to the user (a configurable server URL). + let literal_start = match parts.first() { + Some(TemplatePart::Text(text)) => Some(text.as_str()), + _ => None, + }; + if let Some(start) = literal_start { + if !(start.starts_with("https://") || start.starts_with("http://")) { + return Err(invalid( + format!("{field}.url"), + "An archive URL is an http(s) address.", + )); + } + } + if url.contains('?') || url.contains('#') { + return Err(invalid( + format!("{field}.url"), + "Query parameters go in query, not in the URL.", + )); + } + if query.len() > MAX_QUERY { + return Err(invalid( + format!("{field}.query"), + format!("An archive URL has at most {MAX_QUERY} query parameters."), + )); + } + for (index, parameter) in query.iter().enumerate() { + let item = format!("{field}.query[{index}]"); + if !is_option_value(¶meter.name) { + return Err(invalid( + format!("{item}.name"), + "A query parameter name uses letters, digits, dots, underscores, and hyphens.", + )); + } + check_value_template(¶meter.value, &format!("{item}.value"), fields)?; + check_conditions(parameter.when.as_ref(), &format!("{item}.when"), fields)?; + } + } + } + Ok(()) + } +} + +fn check_conditions( + conditions: Option<&Conditions>, + field: &str, + fields: &BTreeMap<&str, &GeneratorField>, +) -> Validation<()> { + let Some(conditions) = conditions else { + return Ok(()); + }; + if conditions.as_slice().is_empty() { + return Err(invalid(field, "A condition list is not empty.")); + } + for condition in conditions.as_slice() { + let Some(target) = fields.get(condition.field.as_str()) else { + return Err(invalid( + field, + format!( + "The condition reads {}, which is not an earlier field.", + condition.field + ), + )); + }; + let comparisons: Vec<&Value> = + match (&condition.equals, &condition.not_equals, &condition.one_of) { + (Some(value), None, None) | (None, Some(value), None) => vec![value], + (None, None, Some(values)) if !values.is_empty() => values.iter().collect(), + _ => { + return Err(invalid( + field, + "A condition sets exactly one of equals, notEquals, and oneOf.", + )) + } + }; + let comparable = |value: &Value| match target.kind.as_str() { + FIELD_ENUM => value.as_str().is_some_and(|text| target.offers(text)), + FIELD_BOOLEAN => value.is_boolean(), + FIELD_TEXT | FIELD_RUNTIME => value.is_string(), + _ => false, + }; + if !comparisons.into_iter().all(comparable) { + return Err(invalid( + field, + format!( + "The condition compares {} with a value that field cannot hold.", + condition.field + ), + )); + } + } + Ok(()) +} + +fn check_value_template( + value: &str, + field: &str, + fields: &BTreeMap<&str, &GeneratorField>, +) -> Validation>> { + let parts = template::parse(value).ok_or_else(|| { + invalid( + field, + "The template has an unknown or unterminated variable.", + ) + })?; + check_variables(&parts, field, fields)?; + Ok(parts) +} + +fn check_path_template( + value: &str, + field: &str, + fields: &BTreeMap<&str, &GeneratorField>, +) -> Validation<()> { + if !is_safe_relative_path(value) { + return Err(invalid( + field, + "The path is relative and stays inside the project.", + )); + } + check_value_template(value, field, fields).map(|_| ()) +} + +fn check_variables( + parts: &[TemplatePart], + field: &str, + fields: &BTreeMap<&str, &GeneratorField>, +) -> Validation<()> { + for part in parts { + let TemplatePart::Variable(variable) = part else { + continue; + }; + if !variable_is_known(variable, |id| fields.get(id).copied()) { + return Err(invalid( + field, + "The template reads a field that is not declared earlier, or a runtime version of a field that is not a runtime.", + )); + } + } + Ok(()) +} + +/// Whether a variable names a field `lookup` knows, with a runtime suffix +/// only on runtime fields. +pub(crate) fn variable_is_known<'a>( + variable: &Variable, + lookup: impl Fn(&str) -> Option<&'a GeneratorField>, +) -> bool { + match &variable.source { + Source::ProjectName => true, + Source::Field(id) => lookup(id).is_some(), + Source::RuntimeVersion(id) | Source::RuntimeMajor(id) => { + lookup(id).is_some_and(|field| field.is(FIELD_RUNTIME)) + } + } +} + +fn check_label(text: &str, field: &str) -> Validation<()> { + if text.trim().is_empty() + || text.chars().count() > MAX_LABEL + || text.chars().any(char::is_control) + { + return Err(invalid(field, "A label is short text on one line.")); + } + Ok(()) +} + +fn check_description(text: Option<&str>, field: &str) -> Validation<()> { + if text.is_some_and(|text| text.trim().is_empty() || text.chars().count() > MAX_DESCRIPTION) { + return Err(invalid(field, "The description is empty or too long.")); + } + Ok(()) +} + +pub(crate) fn is_generator_id(value: &str) -> bool { + let mut chars = value.chars(); + value.len() <= 64 + && chars.next().is_some_and(|first| first.is_ascii_lowercase()) + && chars.all(|rest| rest.is_ascii_lowercase() || rest.is_ascii_digit() || rest == '-') +} + +fn is_option_value(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_LABEL + && value.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-') + }) +} + +fn is_environment_name(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && !value.starts_with(|character: char| character.is_ascii_digit()) + && value.chars().all(|character| { + character.is_ascii_uppercase() || character.is_ascii_digit() || character == '_' + }) +} diff --git a/rust/lithe-core/src/project_generators/mod.rs b/rust/lithe-core/src/project_generators/mod.rs new file mode 100644 index 0000000000..98e1167547 --- /dev/null +++ b/rust/lithe-core/src/project_generators/mod.rs @@ -0,0 +1,28 @@ +//! Project generators declared by API level 3 plugins: the entries of the +//! New Project window and the deterministic half of creating a project. +//! +//! A plugin describes a way to start a project as data (`projectGenerators` +//! in `plugin.json`): where it is listed, which fields the user fills in, +//! which plugins the result needs, and the steps that produce it — template +//! files from the package, a scaffolding command such as `mvn +//! archetype:generate`, or a zip archive such as Spring Initializr's. Core +//! validates the declaration, evaluates the form (derived defaults, +//! visibility, errors), and turns a filled form into a plan: expanded files, +//! argv, and URLs. Hosts own the window, resolve executables, download, run +//! processes in a staging directory, and move the finished project into +//! place. +//! +//! Core reads template files from the plugin package and extracts archives +//! into a staging directory the host created; it never writes anywhere else. +//! +//! Note: the decision is recorded in .agents/notes/implemented/feature/2026-10-23-plugin-project-generators.md + +mod archive; +mod commands; +mod form; +mod manifest; +mod plan; +mod template; + +pub(crate) use commands::*; +pub(crate) use manifest::{programs, validate_declarations, ProjectGeneratorDeclaration}; diff --git a/rust/lithe-core/src/project_generators/plan.rs b/rust/lithe-core/src/project_generators/plan.rs new file mode 100644 index 0000000000..4864204210 --- /dev/null +++ b/rust/lithe-core/src/project_generators/plan.rs @@ -0,0 +1,384 @@ +//! Turning a filled-in form into the steps a host runs. + +use super::form::{self, FormInput, FormState}; +use super::manifest::{ + variable_is_known, GeneratorStep, ProjectGeneratorDeclaration, FIELD_RUNTIME, +}; +use super::template::{self, FieldValue, Values, Variable}; +use crate::plugins::declarative::{is_safe_relative_path, PluginRejection, Validation}; +use serde_json::{json, Value}; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Limits of one generator's template files, so a damaged package cannot +/// make Core read without bound. +const MAX_TEMPLATE_FILES: usize = 1024; +const MAX_TEMPLATE_FILE_BYTES: u64 = 1024 * 1024; +const MAX_TEMPLATE_BYTES: u64 = 8 * 1024 * 1024; +const MAX_TEMPLATE_DEPTH: usize = 32; +/// Template path segments starting with this become dotfiles +/// (`dot-gitignore` → `.gitignore`). Packages keep no hidden files, because +/// package scans skip them. +const DOTFILE_PREFIX: &str = "dot-"; + +pub(crate) const INVALID_INPUT: &str = "plugin.project.invalidInput"; +const TEMPLATE_INVALID: &str = "plugin.project.templateInvalid"; +const INVALID_URL: &str = "plugin.project.invalidURL"; + +/// Builds the plan for `input`, or reports the first problem. +pub(crate) fn plan( + generator: &ProjectGeneratorDeclaration, + package_path: &Path, + input: &FormInput, +) -> Validation { + let state = form::evaluate(generator, input); + reject_invalid_form(&state)?; + let values = Values { + project_name: &input.project_name, + fields: &state.values, + }; + let known = |variable: &Variable| { + variable_is_known(variable, |id| { + generator.fields.iter().find(|field| field.id == id) + }) + }; + + let mut steps = Vec::new(); + let mut file_budget = FileBudget::default(); + for (index, step) in generator.steps.iter().enumerate() { + if !form::holds(step.when(), &state.values) { + continue; + } + let field = format!("projectGenerators.{}.steps[{index}]", generator.id); + steps.push(match step { + GeneratorStep::Files { source, target, .. } => { + let target = target + .as_deref() + .map(|target| expand_path(target, &values, &field)) + .transpose()?; + let files = template_files( + package_path, + source, + target.as_deref(), + &values, + &known, + &mut file_budget, + &field, + )?; + json!({"kind": "writeFiles", "files": files}) + } + GeneratorStep::Command { + command, + arguments, + environment, + collect, + may_use_network, + timeout_seconds, + .. + } => { + let arguments: Vec = arguments + .iter() + .filter(|argument| form::holds(argument.when(), &state.values)) + .map(|argument| expand_value(argument.value(), &values)) + .filter(|argument| !argument.is_empty()) + .collect(); + let environment: serde_json::Map = environment + .iter() + .map(|(name, value)| (name.clone(), json!(expand_value(value, &values)))) + .collect(); + let collect = collect + .as_deref() + .map(|collect| expand_path(collect, &values, &field)) + .transpose()?; + json!({ + "kind": "command", + "command": command, + "arguments": arguments, + "environment": environment, + "collect": collect, + "mayUseNetwork": may_use_network, + "timeoutSeconds": timeout_seconds.unwrap_or(600), + }) + } + GeneratorStep::Archive { url, query, .. } => { + let mut address = archive_url(&expand_value(url, &values), &field)?; + let parameters: Vec<(String, String)> = query + .iter() + .filter(|parameter| form::holds(parameter.when.as_ref(), &state.values)) + .map(|parameter| { + ( + parameter.name.clone(), + expand_value(¶meter.value, &values), + parameter.omit_empty, + ) + }) + .filter(|(_, value, omit_empty)| !(value.is_empty() && *omit_empty)) + .map(|(name, value, _)| (name, value)) + .collect(); + if !parameters.is_empty() { + address.query_pairs_mut().extend_pairs(parameters); + } + json!({ + "kind": "archive", + "url": address.as_str(), + "host": address.host_str().unwrap_or_default(), + "format": "zip", + }) + } + }); + } + + let mut open_files = Vec::new(); + for (index, path) in generator.open_files.iter().enumerate() { + if form::holds(path.when(), &state.values) { + let field = format!("projectGenerators.{}.openFiles[{index}]", generator.id); + open_files.push(expand_path(path.path(), &values, &field)?); + } + } + + let runtimes: Vec = generator + .fields + .iter() + .filter(|field| field.is(FIELD_RUNTIME)) + .filter_map(|field| match state.values.get(&field.id) { + Some(FieldValue::Runtime { home, version }) if !home.is_empty() => Some(json!({ + "field": field.id, + "runtime": field.runtime, + "home": home, + "version": version, + })), + _ => None, + }) + .collect(); + + let uses_network = generator + .steps + .iter() + .filter(|step| form::holds(step.when(), &state.values)) + .any(GeneratorStep::uses_network); + + Ok(json!({ + "projectName": input.project_name, + "steps": steps, + "openFiles": open_files, + "runtimes": runtimes, + "usesNetwork": uses_network, + })) +} + +fn reject_invalid_form(state: &FormState) -> Validation<()> { + if let Some(problem) = state.project_name_problem { + return Err(PluginRejection::at( + INVALID_INPUT, + "projectName", + format!("The project name is not accepted ({}).", problem.code()), + )); + } + if let Some(field) = state.fields.iter().find(|field| field.problem.is_some()) { + return Err(PluginRejection::at( + INVALID_INPUT, + format!("values.{}", field.id), + format!( + "The value of {} is not accepted ({}).", + field.id, + field.problem.expect("found by problem").code() + ), + )); + } + Ok(()) +} + +fn expand_value(value: &str, values: &Values<'_>) -> String { + // Declarations are validated, so every template parses. + template::parse(value) + .map(|parts| values.expand(&parts)) + .unwrap_or_default() +} + +/// Expands a project-relative path and checks the result still stays +/// inside the project: a field value may contain `/`, never `..`. +fn expand_path(value: &str, values: &Values<'_>, field: &str) -> Validation { + let expanded = expand_value(value, values); + if !is_safe_relative_path(&expanded) + || expanded + .split('/') + .any(|part| part.is_empty() || part == ".") + { + return Err(PluginRejection::at( + INVALID_INPUT, + field, + format!("{expanded} is not a path inside the project."), + )); + } + Ok(expanded) +} + +fn archive_url(text: &str, field: &str) -> Validation { + let rejected = || { + PluginRejection::at( + INVALID_URL, + field, + format!("{text} is not an HTTPS address of a project archive."), + ) + }; + let address = url::Url::parse(text).map_err(|_| rejected())?; + let loopback = matches!( + address.host_str(), + Some("localhost" | "127.0.0.1" | "[::1]") + ); + let scheme_ok = address.scheme() == "https" || (address.scheme() == "http" && loopback); + if !scheme_ok + || address.host_str().is_none_or(str::is_empty) + || !address.username().is_empty() + || address.password().is_some() + || address.query().is_some() + || address.fragment().is_some() + { + return Err(rejected()); + } + Ok(address) +} + +#[derive(Default)] +struct FileBudget { + files: usize, + bytes: u64, +} + +fn template_files( + package_path: &Path, + source: &str, + target: Option<&str>, + values: &Values<'_>, + known: &dyn Fn(&Variable) -> bool, + budget: &mut FileBudget, + field: &str, +) -> Validation> { + let template_error = |message: String| PluginRejection::at(TEMPLATE_INVALID, field, message); + let root = package_path.join(source); + let metadata = fs::symlink_metadata(&root) + .map_err(|_| template_error(format!("The package has no template directory {source}.")))?; + if !metadata.is_dir() { + return Err(template_error(format!("{source} is not a directory."))); + } + let mut relative_files = Vec::new(); + collect_files(&root, PathBuf::new(), 0, &mut relative_files) + .map_err(|message| template_error(format!("{source}: {message}")))?; + relative_files.sort(); + + let mut files = Vec::with_capacity(relative_files.len()); + for relative in relative_files { + let relative_text = relative + .to_str() + .ok_or_else(|| template_error("A template path is not UTF-8.".into()))? + .replace('\\', "/"); + let full = root.join(&relative); + let metadata = fs::symlink_metadata(&full) + .map_err(|error| template_error(format!("{relative_text}: {error}")))?; + budget.files += 1; + budget.bytes += metadata.len(); + if budget.files > MAX_TEMPLATE_FILES + || metadata.len() > MAX_TEMPLATE_FILE_BYTES + || budget.bytes > MAX_TEMPLATE_BYTES + { + return Err(template_error("The templates are too large.".into())); + } + + let parts = template::parse(&relative_text) + .filter(|parts| { + parts.iter().all(|part| match part { + crate::plugins::declarative::TemplatePart::Variable(variable) => { + known(variable) + } + crate::plugins::declarative::TemplatePart::Text(_) => true, + }) + }) + .ok_or_else(|| template_error(format!("{relative_text} names an unknown variable.")))?; + let expanded = values + .expand(&parts) + .split('/') + .map(|segment| match segment.strip_prefix(DOTFILE_PREFIX) { + Some(rest) if !rest.is_empty() => format!(".{rest}"), + _ => segment.to_string(), + }) + .collect::>() + .join("/"); + let path = match target { + Some(target) => format!("{target}/{expanded}"), + None => expanded, + }; + if !is_safe_relative_path(&path) + || path.split('/').any(|part| part.is_empty() || part == ".") + { + return Err(PluginRejection::at( + INVALID_INPUT, + field, + format!("{path} is not a path inside the project."), + )); + } + + let bytes = + fs::read(&full).map_err(|error| template_error(format!("{relative_text}: {error}")))?; + let text = String::from_utf8(bytes) + .map_err(|_| template_error(format!("{relative_text} is not UTF-8 text.")))?; + let contents = values.expand_file(&text, known).map_err(|variable| { + template_error(format!( + "{relative_text} uses an unknown variable {variable}." + )) + })?; + files.push(json!({ + "path": path, + "contents": contents, + "executable": is_executable(&metadata), + })); + } + Ok(files) +} + +/// Lists regular files below `directory`, refusing symbolic links and +/// skipping hidden entries such as `.DS_Store`. +fn collect_files( + directory: &Path, + relative: PathBuf, + depth: usize, + files: &mut Vec, +) -> Result<(), String> { + if depth > MAX_TEMPLATE_DEPTH { + return Err("templates are nested too deeply".into()); + } + let entries = fs::read_dir(directory).map_err(|error| error.to_string())?; + for entry in entries { + let entry = entry.map_err(|error| error.to_string())?; + let name = entry.file_name(); + let Some(name_text) = name.to_str() else { + return Err("a template name is not UTF-8".into()); + }; + if name_text.starts_with('.') { + continue; + } + let file_type = entry.file_type().map_err(|error| error.to_string())?; + let child = relative.join(&name); + if file_type.is_symlink() { + return Err(format!("{} is a symbolic link", child.display())); + } else if file_type.is_dir() { + collect_files(&entry.path(), child, depth + 1, files)?; + } else if file_type.is_file() { + files.push(child); + } + if files.len() > MAX_TEMPLATE_FILES { + return Err("too many template files".into()); + } + } + Ok(()) +} + +#[cfg(unix)] +fn is_executable(metadata: &fs::Metadata) -> bool { + use std::os::unix::fs::PermissionsExt; + metadata.permissions().mode() & 0o111 != 0 +} + +#[cfg(not(unix))] +fn is_executable(_: &fs::Metadata) -> bool { + false +} diff --git a/rust/lithe-core/src/project_generators/template.rs b/rust/lithe-core/src/project_generators/template.rs new file mode 100644 index 0000000000..ec7edc14bc --- /dev/null +++ b/rust/lithe-core/src/project_generators/template.rs @@ -0,0 +1,290 @@ +//! Variables of project generator templates. +//! +//! Strings in the manifest use the `${...}` syntax every plugin template +//! uses. Template files in the package use `{{ ... }}` instead, because Maven +//! POMs, Gradle scripts, and Spring properties already contain `${...}` that +//! must reach the project unchanged. In files, only `{{` followed by +//! `project.` or `field.` is a variable; any other `{{` is copied as text. + +use crate::plugins::declarative::{parse_template_with, TemplatePart}; +use std::collections::BTreeMap; + +/// What a variable reads. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Source { + /// The project name the user typed. + ProjectName, + /// A field's value: text, the chosen option, `true`/`false`, a + /// comma-separated list, or a runtime's home directory. + Field(String), + /// The full version of a runtime field's selection. + RuntimeVersion(String), + /// The major version of a runtime field's selection, such as `21`. + RuntimeMajor(String), +} + +/// A transformation applied after the value is read, in order. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Modifier { + /// Dots become `/`: a package name as a source directory. + Path, + /// A valid Java-style package: lowercase, only letters, digits, + /// underscores, and single dots, each segment starting with a letter or + /// underscore. `com.example.my-app` becomes `com.example.myapp`. + Package, + /// Lowercase. + Lower, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Variable { + pub(crate) source: Source, + pub(crate) modifiers: Vec, +} + +impl Variable { + pub(crate) fn parse(text: &str) -> Option { + let mut parts = text.split('|').map(str::trim); + let name = parts.next()?; + let source = if name == "project.name" { + Source::ProjectName + } else { + let rest = name.strip_prefix("field.")?; + match rest.split_once('.') { + None => Source::Field(rest.to_string()), + Some((id, "version")) => Source::RuntimeVersion(id.to_string()), + Some((id, "major")) => Source::RuntimeMajor(id.to_string()), + Some(_) => return None, + } + }; + let modifiers = parts + .map(|modifier| match modifier { + "path" => Some(Modifier::Path), + "package" => Some(Modifier::Package), + "lower" => Some(Modifier::Lower), + _ => None, + }) + .collect::>>()?; + Some(Self { source, modifiers }) + } + + /// The field the variable reads, if any. + #[cfg(test)] + pub(crate) fn field(&self) -> Option<&str> { + match &self.source { + Source::ProjectName => None, + Source::Field(id) | Source::RuntimeVersion(id) | Source::RuntimeMajor(id) => Some(id), + } + } +} + +/// Parses a manifest string. `None` for an unknown or unterminated variable. +pub(crate) fn parse(value: &str) -> Option>> { + parse_template_with(value, Variable::parse) +} + +/// A filled-in field, as templates read it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum FieldValue { + Text(String), + Flag(bool), + List(Vec), + Runtime { home: String, version: String }, +} + +impl FieldValue { + pub(crate) fn text(&self) -> String { + match self { + Self::Text(text) => text.clone(), + Self::Flag(flag) => flag.to_string(), + Self::List(items) => items.join(","), + Self::Runtime { home, .. } => home.clone(), + } + } +} + +/// Everything a template may read. +pub(crate) struct Values<'a> { + pub(crate) project_name: &'a str, + pub(crate) fields: &'a BTreeMap, +} + +impl Values<'_> { + pub(crate) fn read(&self, variable: &Variable) -> String { + let raw = match &variable.source { + Source::ProjectName => self.project_name.to_string(), + Source::Field(id) => self + .fields + .get(id) + .map(FieldValue::text) + .unwrap_or_default(), + Source::RuntimeVersion(id) => match self.fields.get(id) { + Some(FieldValue::Runtime { version, .. }) => version.clone(), + _ => String::new(), + }, + Source::RuntimeMajor(id) => match self.fields.get(id) { + Some(FieldValue::Runtime { version, .. }) => major_version(version), + _ => String::new(), + }, + }; + variable + .modifiers + .iter() + .fold(raw, |value, modifier| apply(*modifier, &value)) + } + + pub(crate) fn expand(&self, parts: &[TemplatePart]) -> String { + parts + .iter() + .map(|part| match part { + TemplatePart::Text(text) => text.clone(), + TemplatePart::Variable(variable) => self.read(variable), + }) + .collect() + } + + /// Expands a template file. Fails with the offending text when a + /// `{{ project.… }}` or `{{ field.… }}` variable cannot be read. + pub(crate) fn expand_file( + &self, + contents: &str, + known_field: impl Fn(&Variable) -> bool, + ) -> Result { + let mut output = String::with_capacity(contents.len()); + let mut rest = contents; + while let Some(start) = rest.find("{{") { + output.push_str(&rest[..start]); + let after = &rest[start + 2..]; + let inner_start = after.trim_start(); + if !(inner_start.starts_with("project.") || inner_start.starts_with("field.")) { + output.push_str("{{"); + rest = after; + continue; + } + let Some(end) = after.find("}}") else { + return Err(format!("{{{{{}", first_line(after))); + }; + let inner = &after[..end]; + match Variable::parse(inner).filter(|variable| known_field(variable)) { + Some(variable) => output.push_str(&self.read(&variable)), + None => return Err(format!("{{{{{inner}}}}}")), + } + rest = &after[end + 2..]; + } + output.push_str(rest); + Ok(output) + } +} + +fn first_line(text: &str) -> &str { + text.lines().next().unwrap_or_default() +} + +fn apply(modifier: Modifier, value: &str) -> String { + match modifier { + Modifier::Path => value.replace('.', "/"), + Modifier::Lower => value.to_lowercase(), + Modifier::Package => package_name(value), + } +} + +/// `1.8.0_412` → `8`, `21.0.2` → `21`, `25` → `25`. +pub(crate) fn major_version(version: &str) -> String { + let mut numbers = version + .split(|character: char| !character.is_ascii_digit()) + .filter(|part| !part.is_empty()); + match (numbers.next(), numbers.next()) { + (Some("1"), Some(minor)) => minor.to_string(), + (Some(major), _) => major.to_string(), + _ => String::new(), + } +} + +fn package_name(value: &str) -> String { + value + .to_lowercase() + .split('.') + .map(|segment| { + segment + .chars() + .filter(|character| character.is_ascii_alphanumeric() || *character == '_') + .collect::() + }) + .filter(|segment| !segment.is_empty()) + .map(|segment| { + if segment.starts_with(|character: char| character.is_ascii_digit()) { + format!("_{segment}") + } else { + segment + } + }) + .collect::>() + .join(".") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn values(fields: &[(&str, FieldValue)]) -> BTreeMap { + fields + .iter() + .map(|(id, value)| (id.to_string(), value.clone())) + .collect() + } + + #[test] + fn parses_sources_and_modifiers() { + assert_eq!( + Variable::parse("field.packageName | path"), + Some(Variable { + source: Source::Field("packageName".into()), + modifiers: vec![Modifier::Path], + }) + ); + assert_eq!( + Variable::parse("field.jdk.major").map(|variable| variable.source), + Some(Source::RuntimeMajor("jdk".into())) + ); + assert!(Variable::parse("field.jdk.home").is_none()); + assert!(Variable::parse("project.path").is_none()); + assert!(Variable::parse("field.name|upper").is_none()); + } + + #[test] + fn package_modifier_makes_a_valid_package() { + assert_eq!(package_name("com.Example.my-app"), "com.example.myapp"); + assert_eq!(package_name("org..2fa.demo"), "org._2fa.demo"); + } + + #[test] + fn major_versions_follow_java_numbering() { + assert_eq!(major_version("1.8.0_412"), "8"); + assert_eq!(major_version("25.0.3"), "25"); + assert_eq!(major_version(""), ""); + } + + #[test] + fn files_only_expand_project_and_field_variables() { + let fields = values(&[("groupId", FieldValue::Text("com.example".into()))]); + let values = Values { + project_name: "demo", + fields: &fields, + }; + let expanded = values + .expand_file( + "{{ field.groupId }}${project.version} {{other}} {{project.name}}", + |_| true, + ) + .unwrap(); + assert_eq!( + expanded, + "com.example${project.version} {{other}} demo" + ); + assert_eq!( + values.expand_file("{{ field.missing }}", |variable| variable.field() + != Some("missing")), + Err("{{ field.missing }}".into()) + ); + } +} diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index 91fbb7c7a7..7d87d668b3 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -38,6 +38,16 @@ pub enum CoreCommand { Ping, /// Detects Node.js and npm and lists catalog agent installs (`agent.status`). AgentStatus, + /// Reads the effective native global instruction file (`agent.instructions.read`). + AgentInstructionsRead, + /// Saves a guarded native global instruction draft (`agent.instructions.save`). + AgentInstructionsSave, + /// Synchronizes a marked shared block in native user files (`agent.instructions.shared`). + AgentInstructionsShared, + /// Lists native skills or creates/imports one explicitly (`agent.skills`). + AgentSkills, + /// Manages a user Hook library and native Agent configuration (`agent.hooks`). + AgentHooks, /// Installs a catalog ACP adapter with the user's npm (`agent.install`). AgentInstall, /// Removes a Lithe-managed ACP adapter install (`agent.uninstall`). @@ -48,10 +58,22 @@ pub enum CoreCommand { AgentParseProviderConfiguration, /// Parses one Agent MCP configuration file into normalized servers (`mcp.parseConfiguration`). McpParseConfiguration, + /// Normalizes a public Registry response into discoverable server drafts (`mcp.parseRegistry`). + McpParseRegistry, + /// Returns the bundled, offline discovery catalog (`mcp.featuredRegistry`). + McpFeaturedRegistry, /// Plans a format-preserving MCP entry write or removal (`mcp.planWrite`). McpPlanWrite, + /// Probes an explicitly selected server without calling its tools. + McpProbe, + /// Reads or plans native per-tool exclusion lists. + McpToolPolicy, + /// Round-trips a quoted command line into exact argv boundaries. + McpCommandLine, /// Validates and normalizes a declarative plugin manifest (`plugins.declarative.validate`). PluginsDeclarativeValidate, + /// Validates native plugin UI data with declaration identity and node budgets. + PluginsNativeUIValidateData, /// Expands a declarative plugin's run template (`plugins.declarative.runPlan`). PluginsDeclarativeRunPlan, /// Lists a declarative plugin's workspace and file tests (`plugins.declarative.discoverTests`). @@ -94,6 +116,22 @@ pub enum CoreCommand { BuildSyncFingerprint, /// Maps build options to the Java language server's project import (`build.languageImport`). BuildLanguageImport, + /// Detects VCS roots and registered providers of a workspace (`vcs.detectRoots`). + VcsDetectRoots, + /// Assigns workspace paths to detected VCS roots (`vcs.ownerOf`). + VcsOwnerOf, + /// Validates and normalizes a VCS provider's reply (`vcs.decodeReply`). + VcsDecodeReply, + /// Lists plugin project generators and who provides their requirements (`project.generatorCatalog`). + ProjectGeneratorCatalog, + /// Derives a generator form's defaults, visibility, and field errors (`project.generatorForm`). + ProjectGeneratorForm, + /// Expands a generator's steps into files, argv, and URLs (`project.generationPlan`). + ProjectGenerationPlan, + /// Validates a new project's name against its parent directory (`project.checkName`). + ProjectCheckName, + /// Extracts a downloaded archive inside a bounded staging directory (`project.extractArchive`). + ProjectExtractArchive, /// Validates and filters a marketplace index (`plugins.marketplace.validateIndex`). PluginsMarketplaceValidateIndex, /// Resolves a user-entered marketplace source URL (`plugins.marketplace.resolveSource`). @@ -258,8 +296,12 @@ pub enum CoreCommand { LspRequest, /// Queues one build-provider request (`lsp.buildRequest`). LspBuildRequest, + /// Queues one VCS-provider request (`lsp.vcsRequest`). + LspVcsRequest, /// Invokes one plugin function (`lsp.functionRequest`). LspFunctionRequest, + /// Answers a plugin backend's host-service request (`lsp.hostResponse`). + LspHostResponse, /// Queues shared Java gutter marker resolution (`java.navigationMarkers`). JavaNavigationMarkers, /// Queues click-time Java navigation resolution (`java.resolveNavigation`). @@ -304,6 +346,8 @@ pub enum CoreCommand { MybatisIndex, /// Reads normalized repository and working-tree state (`git.status`). GitStatus, + /// Lists one level of a summarized untracked directory (`git.untrackedDirectory`). + GitUntrackedDirectory, /// Reads HEAD and index preconditions for workspace commits (`git.commitState`). GitCommitState, /// Builds a reviewed multi-repository plan (`git.workspaceCommitPrepare`). @@ -336,6 +380,22 @@ pub enum CoreCommand { GitExecutionConfigure, /// Answers a currently owned authentication prompt (`git.authRespond`). GitAuthRespond, + /// Checks that an SSH connection reaches a supported host (`remote.probe`). + RemoteProbe, + /// Answers a prompt relayed by a running `remote.probe` (`remote.authRespond`). + RemoteAuthRespond, + /// Opens a remote connection, installing the server when needed (`remote.connect`). + RemoteConnect, + /// Lists a directory over an open remote connection (`remote.readDir`). + RemoteReadDir, + /// Closes a remote connection (`remote.disconnect`). + RemoteDisconnect, + /// Closes every open remote connection at application exit (`remote.closeAll`). + RemoteCloseAll, + /// Reads a file over an open remote connection (`remote.readFile`). + RemoteReadFile, + /// Saves a file over an open remote connection (`remote.writeFile`). + RemoteWriteFile, /// Inspects repository initialization and scoped commit identity. GitRepositorySetup, /// Initializes an existing folder outside any repository. @@ -418,13 +478,24 @@ impl CoreCommand { "core.ping" => Some(Self::Ping), "ideHost.control" => Some(Self::IdeHostControl), "agent.status" => Some(Self::AgentStatus), + "agent.instructions.read" => Some(Self::AgentInstructionsRead), + "agent.instructions.save" => Some(Self::AgentInstructionsSave), + "agent.instructions.shared" => Some(Self::AgentInstructionsShared), + "agent.skills" => Some(Self::AgentSkills), + "agent.hooks" => Some(Self::AgentHooks), "agent.install" => Some(Self::AgentInstall), "agent.uninstall" => Some(Self::AgentUninstall), "agent.installCli" => Some(Self::AgentInstallCli), "agent.parseProviderConfiguration" => Some(Self::AgentParseProviderConfiguration), "mcp.parseConfiguration" => Some(Self::McpParseConfiguration), + "mcp.parseRegistry" => Some(Self::McpParseRegistry), + "mcp.featuredRegistry" => Some(Self::McpFeaturedRegistry), "mcp.planWrite" => Some(Self::McpPlanWrite), + "mcp.probe" => Some(Self::McpProbe), + "mcp.toolPolicy" => Some(Self::McpToolPolicy), + "mcp.commandLine" => Some(Self::McpCommandLine), "plugins.declarative.validate" => Some(Self::PluginsDeclarativeValidate), + "plugins.nativeUI.validateData" => Some(Self::PluginsNativeUIValidateData), "plugins.declarative.runPlan" => Some(Self::PluginsDeclarativeRunPlan), "plugins.declarative.discoverTests" => Some(Self::PluginsDeclarativeDiscoverTests), "plugins.declarative.testPlan" => Some(Self::PluginsDeclarativeTestPlan), @@ -452,6 +523,14 @@ impl CoreCommand { "build.decodeDependencies" => Some(Self::BuildDecodeDependencies), "build.syncFingerprint" => Some(Self::BuildSyncFingerprint), "build.languageImport" => Some(Self::BuildLanguageImport), + "vcs.detectRoots" => Some(Self::VcsDetectRoots), + "vcs.ownerOf" => Some(Self::VcsOwnerOf), + "vcs.decodeReply" => Some(Self::VcsDecodeReply), + "project.generatorCatalog" => Some(Self::ProjectGeneratorCatalog), + "project.generatorForm" => Some(Self::ProjectGeneratorForm), + "project.generationPlan" => Some(Self::ProjectGenerationPlan), + "project.checkName" => Some(Self::ProjectCheckName), + "project.extractArchive" => Some(Self::ProjectExtractArchive), "plugins.marketplace.validateIndex" => Some(Self::PluginsMarketplaceValidateIndex), "plugins.marketplace.resolveSource" => Some(Self::PluginsMarketplaceResolveSource), "plugins.marketplace.installReview" => Some(Self::PluginsMarketplaceInstallReview), @@ -532,7 +611,9 @@ impl CoreCommand { "lsp.closeDocument" => Some(Self::LspCloseDocument), "lsp.request" => Some(Self::LspRequest), "lsp.buildRequest" => Some(Self::LspBuildRequest), + "lsp.vcsRequest" => Some(Self::LspVcsRequest), "lsp.functionRequest" => Some(Self::LspFunctionRequest), + "lsp.hostResponse" => Some(Self::LspHostResponse), "java.resolveNavigation" => Some(Self::JavaResolveNavigation), "lsp.cancelOperation" => Some(Self::LspCancelOperation), "lsp.pollEvents" => Some(Self::LspPollEvents), @@ -555,6 +636,7 @@ impl CoreCommand { "spring.index" => Some(Self::SpringIndex), "mybatis.index" => Some(Self::MybatisIndex), "git.status" => Some(Self::GitStatus), + "git.untrackedDirectory" => Some(Self::GitUntrackedDirectory), "git.commitState" => Some(Self::GitCommitState), "git.workspaceCommitPrepare" => Some(Self::GitWorkspaceCommitPrepare), "git.workspaceCommitStep" => Some(Self::GitWorkspaceCommitStep), @@ -571,6 +653,14 @@ impl CoreCommand { "git.executionInspect" => Some(Self::GitExecutionInspect), "git.executionConfigure" => Some(Self::GitExecutionConfigure), "git.authRespond" => Some(Self::GitAuthRespond), + "remote.probe" => Some(Self::RemoteProbe), + "remote.authRespond" => Some(Self::RemoteAuthRespond), + "remote.connect" => Some(Self::RemoteConnect), + "remote.readDir" => Some(Self::RemoteReadDir), + "remote.disconnect" => Some(Self::RemoteDisconnect), + "remote.closeAll" => Some(Self::RemoteCloseAll), + "remote.readFile" => Some(Self::RemoteReadFile), + "remote.writeFile" => Some(Self::RemoteWriteFile), "git.historyRewritePreview" => Some(Self::GitHistoryRewritePreview), "git.rebasePreview" => Some(Self::GitRebasePreview), "git.repositorySetup" => Some(Self::GitRepositorySetup), @@ -628,7 +718,9 @@ mod tests { "lsp.closeDocument", "lsp.request", "lsp.buildRequest", + "lsp.vcsRequest", "lsp.functionRequest", + "lsp.hostResponse", "lsp.cancelOperation", "lsp.pollEvents", "lsp.waitEvents", diff --git a/rust/lithe-core/src/protocol/contracts.rs b/rust/lithe-core/src/protocol/contracts.rs index 4f582bad5e..618ce5a593 100644 --- a/rust/lithe-core/src/protocol/contracts.rs +++ b/rust/lithe-core/src/protocol/contracts.rs @@ -1,7 +1,7 @@ //! Serializable response models shared across every host boundary. use crate::protocol::CoreError; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use serde_json::Value; #[derive(Debug, Serialize)] @@ -582,6 +582,11 @@ pub struct GitChange { pub submodule: Option, /// False for parent gitlinks with only uncommitted child content. pub can_toggle_staging: bool, + /// Present only on a summarized untracked directory (`path` ends with `/`): + /// the number of non-ignored files below it. The directory contains no + /// nested repository, so staging or cleaning `path` acts on exactly these files. + #[serde(skip_serializing_if = "Option::is_none")] + pub untracked_file_count: Option, } #[derive(Debug, Clone, Serialize)] @@ -604,6 +609,13 @@ pub struct GitStatusResponse { pub changes: Vec, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Immediate files and subdirectory summaries of one summarized untracked directory. +pub struct GitUntrackedDirectoryResponse { + pub changes: Vec, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// Worktree-aware Git paths that a platform watcher should observe. @@ -728,6 +740,26 @@ pub struct GitHistoryCursorCloseResponse { pub closed: bool, } +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +/// How the resolved push destination relates to the local branch's upstream. +/// +/// Callers use this to decide whether a push is a routine repeat of the same +/// branch name or a decision the user has to confirm explicitly. +pub enum GitPushTargetKind { + /// The branch has no upstream; pushing publishes it to the same-named + /// remote branch. + #[serde(rename = "publish")] + Publish, + /// The upstream branch has the same name as the local branch. + #[serde(rename = "tracking")] + Tracking, + /// The upstream branch name differs from the local branch name. Pushing + /// here would update a different branch, so it must not happen implicitly. + #[serde(rename = "divergent")] + Divergent, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// Resolved destination and bounded commits for one branch push. @@ -740,6 +772,10 @@ pub struct GitPushPreviewResponse { pub remote: String, /// Branch name created or updated on the remote. pub remote_branch: String, + /// How the destination relates to the configured upstream. + pub target_kind: GitPushTargetKind, + /// Whether the destination branch does not exist on the remote yet. + pub creates_remote_branch: bool, /// Locally observed destination OID, or `None` before first publication. pub remote_tracking_oid: Option, /// Configured upstream short name, or `None` before first publication. diff --git a/rust/lithe-core/src/protocol/error.rs b/rust/lithe-core/src/protocol/error.rs index 08b5c1a156..c7f4bd4983 100644 --- a/rust/lithe-core/src/protocol/error.rs +++ b/rust/lithe-core/src/protocol/error.rs @@ -29,6 +29,9 @@ pub enum ErrorCode { Cancelled, /// The operation exceeded its declared deadline. TimedOut, + /// A long-lived connection the request names is closed or was lost; the + /// caller can reconnect and retry. + ConnectionClosed, /// A failure does not fit a more stable cross-platform category. Unknown, } diff --git a/rust/lithe-core/src/remote/mod.rs b/rust/lithe-core/src/remote/mod.rs new file mode 100644 index 0000000000..4f48bc6f69 --- /dev/null +++ b/rust/lithe-core/src/remote/mod.rs @@ -0,0 +1,384 @@ +//! Remote development connections: checking that a saved SSH connection +//! reaches a usable host before any workspace is opened on it. +//! +//! The command builds the transport chain with `lithe-remote-client`, runs +//! its platform probe through the system OpenSSH client, and relays every +//! question OpenSSH asks (passwords, key passphrases, one-time codes, unknown +//! host keys) to the host UI through the shared AskPass session. Core never +//! stores an answer; hosts keep saved secrets in their own secure storage. +//! +//! Note: .agents/notes/implemented/feature/2026-10-24-remote-ssh-connections.md + +use crate::git::execution_events; +use crate::protocol::{CoreError, ErrorCode}; +use lithe_remote_client::bootstrap::{parse_probe, probe_command}; +use lithe_remote_client::{Hop, SshHop, TransportChain}; +use serde::{Deserialize, Serialize}; +use std::process::{Command, ExitStatus}; +use std::time::{Duration, Instant}; + +pub(crate) mod sessions; + +/// Seconds OpenSSH waits for the TCP connection when the profile sets none. +const DEFAULT_CONNECT_TIMEOUT_SECONDS: u32 = 15; + +/// Upper bound for one probe, including the time the user needs to answer +/// prompts. Each prompt is additionally bounded by the AskPass session. +const PROBE_DEADLINE: Duration = Duration::from_secs(5 * 60); + +/// Bytes of OpenSSH's standard error kept as diagnostics. +const DIAGNOSTICS_LIMIT: usize = 4 * 1024; + +/// How the connection proves the user's identity. Every method still lets +/// OpenSSH ask follow-up questions, such as a one-time code. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(crate) enum Authentication { + /// OpenSSH's own order: keys and agent first, then interactive methods. + #[default] + Auto, + /// Keys offered by the running SSH agent or the SSH configuration. + Agent, + /// Only the private key named by `identityFile`. + PrivateKey, + /// Password or keyboard-interactive prompts, without trying keys. + Password, +} + +/// One saved SSH connection. Only facts that change the target or the +/// authentication are sent; names and colors stay in the host. +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct SshConnection { + /// Host name, address, or a `Host` alias from the user's SSH configuration. + host: String, + /// `None` keeps the port from the SSH configuration (22 by default). + #[serde(default)] + port: Option, + /// `None` keeps the user from the SSH configuration. + #[serde(default)] + user: Option, + #[serde(default)] + authentication: Authentication, + /// Private key path; required for [`Authentication::PrivateKey`]. OpenSSH + /// expands a leading `~` itself. + #[serde(default)] + identity_file: Option, + #[serde(default)] + connect_timeout_seconds: Option, + /// `None` keeps the transport's keep-alive default. + #[serde(default)] + keep_alive_seconds: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct ProbeRequest { + connection: SshConnection, +} + +/// Stable outcome of a probe. Failures to connect are results, not Core +/// errors, so the host can show a specific explanation for each. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum ProbeStatus { + /// A shell ran on the host and a server build exists for it. + Connected, + /// The host answered, but no server build exists for its platform. + UnsupportedPlatform, + /// The server rejected every offered credential. + AuthenticationFailed, + /// The host key is unknown and was declined, or it changed. + HostKeyRejected, + /// The name did not resolve, or the host could not be reached in time. + Unreachable, + /// The connection failed for another reason; see `diagnostics`. + Failed, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProbedHost { + /// `uname -s`, for example `Linux` or `Darwin`. + system: String, + /// `uname -m`, for example `x86_64` or `arm64`. + machine: String, + home_directory: Option, + /// Rust target of the server build for this host, or `None`. + server_target: Option<&'static str>, + /// Facts for display, such as the distribution and memory. + details: lithe_remote_client::bootstrap::HostDetails, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProbeResult { + status: ProbeStatus, + /// Present when the host's shell ran the probe. + host: Option, + /// Redacted tail of OpenSSH's standard error. + diagnostics: String, +} + +/// Probes the host named by the request's connection. +pub(crate) fn probe(request: ProbeRequest) -> Result { + probe_with(request, None) +} + +/// `ssh_program` replaces the `ssh` found on `PATH`; tests use a stand-in. +fn probe_with(request: ProbeRequest, ssh_program: Option<&str>) -> Result { + let mut hop = ssh_hop(&request.connection)?; + if let Some(program) = ssh_program { + hop.program = program.into(); + } + let chain = TransportChain::new(vec![Hop::Ssh(hop)]); + let argv = chain.command(&probe_command()).map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid SSH connection") + .with_details(error.to_string()) + })?; + let (program, arguments) = argv + .split_first() + .expect("an SSH hop always produces a command"); + let mut process = Command::new(program); + process.args(arguments); + + let mut authentication = lithe_git_host::authentication::Session::new().map_err(|error| { + CoreError::new( + ErrorCode::ProcessStartFailed, + "Could not prepare SSH authentication", + ) + .with_details(error.to_string()) + })?; + authentication.configure(&mut process).map_err(|error| { + CoreError::new( + ErrorCode::ProcessStartFailed, + "Could not configure SSH authentication", + ) + .with_details(error.to_string()) + })?; + + let deadline = Instant::now() + PROBE_DEADLINE; + let mut stop = None; + let output = lithe_git_host::run( + &mut process, + None, + || { + if let Err(error) = crate::protocol::cancellation::check() { + stop = Some(error); + } else if Instant::now() >= deadline { + stop = Some(CoreError::new( + ErrorCode::TimedOut, + "The SSH connection did not finish in time", + )); + } else { + match authentication.poll() { + Ok(challenges) => { + for challenge in challenges { + execution_events::emit(serde_json::json!({ + "type": "authentication", + "requestId": challenge.request_id, + "prompt": execution_events::redact(&challenge.prompt), + "secret": challenge.secret, + "attempt": challenge.attempt, + })); + } + } + Err(error) => { + stop = Some( + CoreError::new( + ErrorCode::ProcessFailed, + "SSH authentication transport failed", + ) + .with_details(error.to_string()), + ) + } + } + } + stop.is_some() + }, + || {}, + |_, _| {}, + ); + if let Some(error) = stop { + return Err(error); + } + match output.failure { + None => {} + Some(lithe_git_host::Failure::Start(error)) => { + return Err( + CoreError::new(ErrorCode::RuntimeMissing, "Could not start the SSH client") + .with_details(error.to_string()), + ) + } + Some(failure) => { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "The SSH client failed") + .with_details(format!("{failure:?}")), + ) + } + } + Ok(classify(output.status, &output.stdout, &output.stderr)) +} + +/// Answers a prompt that a running probe relayed. +pub(crate) fn respond(request_id: &str, answer: Option) -> bool { + lithe_git_host::authentication::respond(request_id, answer) +} + +fn ssh_hop(connection: &SshConnection) -> Result { + let invalid = |reason: &str| CoreError::new(ErrorCode::InvalidRequest, reason.to_string()); + let host = connection.host.trim(); + if host.is_empty() || host.contains('@') { + return Err(invalid("Enter a host name or address without a user")); + } + let destination = match connection.user.as_deref().map(str::trim) { + Some(user) if !user.is_empty() => { + if user.starts_with('-') + || user + .chars() + .any(|character| matches!(character, '@' | ':') || character.is_whitespace()) + { + return Err(invalid("The user name contains characters SSH cannot use")); + } + format!("{user}@{host}") + } + _ => host.to_string(), + }; + let mut hop = SshHop::new(destination); + hop.port = connection.port.filter(|port| *port != 0); + let mut option = |key: &str, value: String| hop.options.push((key.into(), value)); + option( + "ConnectTimeout", + connection + .connect_timeout_seconds + .filter(|seconds| *seconds > 0) + .unwrap_or(DEFAULT_CONNECT_TIMEOUT_SECONDS) + .to_string(), + ); + if let Some(seconds) = connection.keep_alive_seconds { + option("ServerAliveInterval", seconds.to_string()); + } + // Prompts must reach AskPass even when the user's configuration sets + // BatchMode for scripts. + option("BatchMode", "no".into()); + match connection.authentication { + Authentication::Auto => {} + Authentication::Agent => option("PreferredAuthentications", "publickey".into()), + Authentication::PrivateKey => { + let identity = connection + .identity_file + .as_deref() + .map(str::trim) + .filter(|path| !path.is_empty()) + .ok_or_else(|| invalid("Choose a private key file"))?; + if identity.chars().any(char::is_control) { + return Err(invalid("The private key path is invalid")); + } + // ssh splits an option value on whitespace, so a path such as + // "~/My Keys/id_rsa" must be quoted (ssh_config quoting rules). + let identity = if identity + .chars() + .any(|c| c.is_whitespace() || c == '"' || c == '\\') + { + format!( + "\"{}\"", + identity.replace('\\', "\\\\").replace('"', "\\\"") + ) + } else { + identity.to_string() + }; + option("IdentityFile", identity); + // Without this, keys from the agent are tried first and can use + // up the server's attempt limit before the chosen key is offered. + option("IdentitiesOnly", "yes".into()); + option("PreferredAuthentications", "publickey".into()); + } + Authentication::Password => { + option( + "PreferredAuthentications", + "keyboard-interactive,password".into(), + ); + option("PubkeyAuthentication", "no".into()); + } + } + Ok(hop) +} + +/// Interprets the finished probe. OpenSSH reports connection failures only +/// as text, so the categories match its stable English messages. +fn classify(exit: Option, stdout: &[u8], stderr: &[u8]) -> ProbeResult { + let stderr_text = String::from_utf8_lossy(stderr); + let diagnostics = diagnostics(&stderr_text); + if let Some(report) = parse_probe(stdout) { + let server_target = report.target().map(|target| target.rust_target()); + return ProbeResult { + status: if server_target.is_some() { + ProbeStatus::Connected + } else { + ProbeStatus::UnsupportedPlatform + }, + host: Some(ProbedHost { + system: report.system, + machine: report.machine, + home_directory: report.home, + server_target, + details: report.details, + }), + diagnostics, + }; + } + let lower = stderr_text.to_ascii_lowercase(); + let mentions = |needles: &[&str]| needles.iter().any(|needle| lower.contains(needle)); + let status = if mentions(&[ + "host key verification failed", + "remote host identification has changed", + ]) { + ProbeStatus::HostKeyRejected + } else if mentions(&[ + "permission denied", + "too many authentication failures", + "no supported authentication methods", + ]) { + ProbeStatus::AuthenticationFailed + } else if mentions(&[ + "could not resolve hostname", + "connection refused", + "connection timed out", + "operation timed out", + "no route to host", + "network is unreachable", + ]) { + ProbeStatus::Unreachable + } else { + ProbeStatus::Failed + }; + let diagnostics = if diagnostics.is_empty() { + // Some failures, such as a remote shell that exits early, print + // nothing; the exit status is then the only clue. + match exit.and_then(|exit| exit.code()) { + Some(code) => format!("ssh exited with status {code}"), + None => "ssh was terminated by a signal".into(), + } + } else { + diagnostics + }; + ProbeResult { + status, + host: None, + diagnostics, + } +} + +/// Keeps the end of the output, where OpenSSH prints the reason it gave up, +/// and removes credentials and home directory paths. +fn diagnostics(stderr: &str) -> String { + let trimmed = stderr.trim(); + let mut start = trimmed.len().saturating_sub(DIAGNOSTICS_LIMIT); + while !trimmed.is_char_boundary(start) { + start += 1; + } + execution_events::redact(&trimmed[start..]) +} + +#[cfg(test)] +mod tests; diff --git a/rust/lithe-core/src/remote/sessions.rs b/rust/lithe-core/src/remote/sessions.rs new file mode 100644 index 0000000000..5c8e627592 --- /dev/null +++ b/rust/lithe-core/src/remote/sessions.rs @@ -0,0 +1,733 @@ +//! Long-lived remote connections. +//! +//! `remote.connect` starts one OpenSSH process that probes the host, installs +//! the server when needed and starts it (see `lithe_remote_client::launch`), +//! so the user authenticates once. The connection then stays open in this +//! registry until `remote.disconnect`; requests such as `remote.readDir` use +//! it by id. OpenSSH runs in its own process tree, which closing a connection +//! ends. + +use super::{classify, diagnostics, ssh_hop, ProbeStatus, ProbedHost, SshConnection}; +use crate::git::execution_events; +use crate::protocol::{CoreError, ErrorCode}; +use lithe_git_host::ProcessTree; +use lithe_remote_client::launch::{launch, launch_command, LaunchError, Launched}; +use lithe_remote_client::session::{ClientError, ConnectOptions}; +use lithe_remote_client::{Hop, RemoteSession, ServerArtifact, ServerTarget, TransportChain}; +use lithe_remote_protocol::methods; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, VecDeque}; +use std::io::Read; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::mpsc::{self, RecvTimeoutError}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +/// Upper bound for connecting, including the time the user needs to answer +/// prompts and the upload of the server. +const CONNECT_DEADLINE: Duration = Duration::from_secs(5 * 60); + +/// Interval for relaying prompts and checking cancellation while the remote +/// shell runs; it adds no noticeable latency to a network connection. +const POLL_INTERVAL: Duration = Duration::from_millis(20); + +/// Time the protocol handshake may take once the server started. +const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(30); + +/// Deadline for one request over an open connection. +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +/// Time OpenSSH gets to exit and print its reason after the stream ended. +const EXIT_GRACE: Duration = Duration::from_secs(2); + +/// Bytes of OpenSSH's standard error kept while a connection is open. +const STDERR_TAIL_BYTES: usize = 8 * 1024; + +/// A server build shipped with the host. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ServerInput { + /// Rust target triple, for example `x86_64-unknown-linux-musl`. + target: String, + /// Absolute path of the read-only binary inside the installed app. + path: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct ConnectRequest { + connection: SshConnection, + servers: Vec, + server_version: String, + /// Install root on the remote; `None` is `$HOME/.lithe-server`. + #[serde(default)] + install_root: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct OpenedSession { + id: String, + /// Whether this connection uploaded the server. + uploaded: bool, + host_name: String, + user_name: Option, + home_directory: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ConnectResult { + /// `connected` only when `session` is set. + status: ProbeStatus, + session: Option, + /// Platform reported by the remote shell, when it ran. + host: Option, + diagnostics: String, +} + +struct Connection { + session: RemoteSession, + child: Mutex, + tree: ProcessTree, +} + +impl Connection { + fn close(&self) { + let _ = self.session.request_value( + methods::SERVER_SHUTDOWN, + serde_json::Value::Null, + EXIT_GRACE, + ); + let _ = self.tree.terminate(EXIT_GRACE); + let mut child = self + .child + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let _ = child.kill(); + let _ = child.wait(); + } +} + +/// Set once the application is quitting; connections still being opened stop. +static CLOSING: AtomicBool = AtomicBool::new(false); + +/// Number of `connect` calls that have not returned yet. +static CONNECTING: AtomicUsize = AtomicUsize::new(0); + +/// Counts a connection attempt so application exit can wait for its OpenSSH +/// tree to be killed. +struct ConnectingGuard; + +impl ConnectingGuard { + fn enter() -> Self { + CONNECTING.fetch_add(1, Ordering::SeqCst); + Self + } +} + +impl Drop for ConnectingGuard { + fn drop(&mut self) { + CONNECTING.fetch_sub(1, Ordering::SeqCst); + } +} + +fn registry() -> &'static Mutex>> { + static CONNECTIONS: OnceLock>>> = OnceLock::new(); + CONNECTIONS.get_or_init(Mutex::default) +} + +fn lookup(id: &str) -> Result, CoreError> { + registry() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .get(id) + .cloned() + .ok_or_else(|| { + CoreError::new( + ErrorCode::ConnectionClosed, + "The remote connection is closed", + ) + }) +} + +/// Opens a connection and registers it. +pub(crate) fn connect(request: ConnectRequest) -> Result { + connect_with(request, None) +} + +/// `ssh_program` replaces the `ssh` found on `PATH`; tests use a stand-in. +pub(super) fn connect_with( + request: ConnectRequest, + ssh_program: Option<&str>, +) -> Result { + // Only the build for the host's platform is read and hashed, once the + // probe reports it; here the target names are just validated. + let servers = request + .servers + .iter() + .map(|server| { + let target = server_target(&server.target).ok_or_else(|| { + CoreError::new(ErrorCode::InvalidRequest, "Unknown remote server target") + .with_details(server.target.clone()) + })?; + Ok((target, server.path.clone())) + }) + .collect::, CoreError>>()?; + let server_version = request.server_version.clone(); + let unreadable = Arc::new(Mutex::new(None::)); + let unreadable_in_launch = Arc::clone(&unreadable); + let _connecting = ConnectingGuard::enter(); + + let mut hop = ssh_hop(&request.connection)?; + if let Some(program) = ssh_program { + hop.program = program.into(); + } + let argv = TransportChain::new(vec![Hop::Ssh(hop)]) + .command(&launch_command( + request.install_root.as_deref(), + &["--stdio"], + )) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid SSH connection") + .with_details(error.to_string()) + })?; + let (program, arguments) = argv + .split_first() + .expect("an SSH hop always produces a command"); + let mut process = Command::new(program); + process + .args(arguments) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut authentication = lithe_git_host::authentication::Session::new().map_err(|error| { + CoreError::new( + ErrorCode::ProcessStartFailed, + "Could not prepare SSH authentication", + ) + .with_details(error.to_string()) + })?; + authentication.configure(&mut process).map_err(|error| { + CoreError::new( + ErrorCode::ProcessStartFailed, + "Could not configure SSH authentication", + ) + .with_details(error.to_string()) + })?; + ProcessTree::prepare(&mut process); + let mut child = process.spawn().map_err(|error| { + CoreError::new(ErrorCode::RuntimeMissing, "Could not start the SSH client") + .with_details(error.to_string()) + })?; + let tree = ProcessTree::adopt(&mut child).map_err(|error| { + CoreError::new( + ErrorCode::ProcessStartFailed, + "Could not supervise the SSH client", + ) + .with_details(error.to_string()) + })?; + let stderr_tail = Arc::new(Mutex::new(VecDeque::new())); + let stderr_reader = Mutex::new( + child + .stderr + .take() + .map(|stderr| keep_tail(stderr, Arc::clone(&stderr_tail))), + ); + let mut input = child.stdin.take().expect("stdin is piped"); + let mut output = child.stdout.take().expect("stdout is piped"); + + // The remote shell is driven on a helper thread with blocking reads; + // this thread keeps relaying prompts and observing cancellation. Killing + // the tree ends the helper's reads. + let (finished, launched) = mpsc::channel(); + std::thread::spawn(move || { + let result = launch(&mut output, &mut input, |report| { + let target = report.target()?; + let (_, path) = servers.iter().find(|(known, _)| *known == target)?; + match ServerArtifact::from_file(target, path.clone().into(), server_version.clone()) { + Ok(artifact) => Some(artifact), + Err(error) => { + *unreadable_in_launch + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(error.to_string()); + None + } + } + }); + let _ = finished.send((result, output, input)); + }); + let deadline = Instant::now() + CONNECT_DEADLINE; + let abandon = |error: CoreError, child: &mut Child| { + let _ = tree.kill(); + let _ = child.wait(); + Err(error) + }; + let (result, output, input) = loop { + if let Err(error) = crate::protocol::cancellation::check() { + return abandon(error, &mut child); + } + if CLOSING.load(Ordering::SeqCst) { + return abandon( + CoreError::new(ErrorCode::Cancelled, "The application is closing"), + &mut child, + ); + } + if Instant::now() >= deadline { + return abandon( + CoreError::new( + ErrorCode::TimedOut, + "The SSH connection did not finish in time", + ), + &mut child, + ); + } + match authentication.poll() { + Ok(challenges) => { + for challenge in challenges { + execution_events::emit(serde_json::json!({ + "type": "authentication", + "requestId": challenge.request_id, + "prompt": execution_events::redact(&challenge.prompt), + "secret": challenge.secret, + "attempt": challenge.attempt, + })); + } + } + Err(error) => { + return abandon( + CoreError::new( + ErrorCode::ProcessFailed, + "SSH authentication transport failed", + ) + .with_details(error.to_string()), + &mut child, + ) + } + } + match launched.recv_timeout(POLL_INTERVAL) { + Ok(value) => break value, + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => { + return abandon( + CoreError::new( + ErrorCode::ProcessFailed, + "The remote shell stopped unexpectedly", + ), + &mut child, + ) + } + } + }; + + // Only called once the OpenSSH tree has ended, so joining the reader + // cannot block; it guarantees the last bytes OpenSSH printed (the reason + // it gave up) are in the buffer before the failure is classified. + let tail = |tail: &Mutex>| { + let reader = stderr_reader + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take(); + if let Some(reader) = reader { + let _ = reader.join(); + } + let bytes: Vec = tail + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .iter() + .copied() + .collect(); + bytes + }; + let failed = + |status: ProbeStatus, host: Option, diagnostics: String| ConnectResult { + status, + session: None, + host, + diagnostics, + }; + let launched = match result { + Ok(launched) => launched, + Err(LaunchError::NoShell) => { + // OpenSSH prints why it gave up only as it exits. + let status = wait_for_exit(&mut child, EXIT_GRACE); + let _ = tree.kill(); + let _ = child.wait(); + let probe = classify(status, &[], &tail(&stderr_tail)); + return Ok(failed(probe.status, None, probe.diagnostics)); + } + Err(LaunchError::NoArtifact(report)) => { + let report = *report; + let _ = tree.kill(); + let _ = child.wait(); + let unreadable = unreadable + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take(); + if let Some(details) = unreadable { + return Err(CoreError::new( + ErrorCode::RuntimeMissing, + "A bundled remote server is unreadable", + ) + .with_details(details)); + } + let host = ProbedHost { + server_target: report.target().map(ServerTarget::rust_target), + system: report.system, + machine: report.machine, + home_directory: report.home, + details: report.details, + }; + return Ok(failed( + ProbeStatus::UnsupportedPlatform, + Some(host), + String::new(), + )); + } + Err(error) => { + let _ = tree.kill(); + let _ = child.wait(); + let stderr = diagnostics(&String::from_utf8_lossy(&tail(&stderr_tail))); + return Ok(failed( + ProbeStatus::Failed, + None, + join_diagnostics(&error.to_string(), &stderr), + )); + } + }; + let Launched { report, uploaded } = launched; + let host = ProbedHost { + server_target: report.target().map(ServerTarget::rust_target), + system: report.system, + machine: report.machine, + home_directory: report.home, + details: report.details, + }; + let options = ConnectOptions { + handshake_timeout: HANDSHAKE_TIMEOUT, + ..ConnectOptions::default() + }; + let (session, events) = match RemoteSession::from_streams(output, input, &options) { + Ok(value) => value, + Err(error) => { + let _ = wait_for_exit(&mut child, EXIT_GRACE); + let _ = tree.kill(); + let _ = child.wait(); + let stderr = diagnostics(&String::from_utf8_lossy(&tail(&stderr_tail))); + return Ok(failed( + ProbeStatus::Failed, + Some(host), + join_diagnostics(&error.to_string(), &stderr), + )); + } + }; + // Process events are not used yet; draining keeps the session's reader + // from blocking on a full channel. + std::thread::spawn(move || for _ in events {}); + let capabilities = session.hello().host.clone(); + let id = nonce(); + registry() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert( + id.clone(), + Arc::new(Connection { + session, + child: Mutex::new(child), + tree, + }), + ); + Ok(ConnectResult { + status: ProbeStatus::Connected, + session: Some(OpenedSession { + id, + uploaded, + host_name: capabilities.host_name, + user_name: capabilities.user_name, + home_directory: capabilities.home_directory, + }), + host: Some(host), + diagnostics: String::new(), + }) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct ReadDirRequest { + session_id: String, + /// Absolute path on the remote host. + path: String, +} + +/// Lists a remote directory; the result is the protocol's `fs.readDir`. +pub(crate) fn read_dir(request: ReadDirRequest) -> Result { + let connection = lookup(&request.session_id)?; + connection + .session + .request_value( + methods::FS_READ_DIR, + serde_json::json!({ "path": request.path }), + REQUEST_TIMEOUT, + ) + .map_err(|error| request_error(&request.session_id, error)) +} + +/// Slowest link a save is expected to survive, in bytes per second; it +/// extends the request deadline for large files. +const WRITE_BYTES_PER_SECOND: u64 = 64 * 1024; + +/// Largest file opened for editing; bigger files stay on the host. +const MAX_EDIT_BYTES: u64 = 16 * 1024 * 1024; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct ReadFileRequest { + session_id: String, + path: String, +} + +/// Reads a whole file for the editor: `{ data (base64), size, modifiedMs }`. +/// The version identifies the read for a later conflict-checked write. +pub(crate) fn read_file(request: ReadFileRequest) -> Result { + let connection = lookup(&request.session_id)?; + let metadata = connection + .session + .stat(&request.path) + .map_err(|error| request_error(&request.session_id, error))?; + if metadata.size > MAX_EDIT_BYTES { + return Err( + CoreError::new(ErrorCode::NotSupported, "The file is too large to open") + .with_details(format!("{} bytes", metadata.size)), + ); + } + let (content, metadata) = connection + .session + .read_file(&request.path) + .map_err(|error| request_error(&request.session_id, error))?; + // The file may have grown since `stat`. + if content.len() as u64 > MAX_EDIT_BYTES { + return Err( + CoreError::new(ErrorCode::NotSupported, "The file is too large to open") + .with_details(format!("{} bytes", content.len())), + ); + } + Ok(serde_json::json!({ + "data": methods::encode_bytes(&content), + "size": metadata.size, + "modifiedMs": metadata.modified_ms, + })) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct WriteFileRequest { + session_id: String, + path: String, + /// Base64 content that replaces the whole file. + data: String, + /// Version last read. When present, a changed file is not replaced and + /// the result is `conflict`; when absent, the file must not exist yet. + #[serde(default)] + expected: Option, +} + +/// Saves a file atomically on the host: `{ status: "saved", size, +/// modifiedMs }`, or `{ status: "conflict" }` when it changed since it was +/// read or, for a new file, when one appeared. +pub(crate) fn write_file(request: WriteFileRequest) -> Result { + let connection = lookup(&request.session_id)?; + let content = methods::decode_bytes(&request.data).map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "File content is not valid base64", + ) + .with_details(error.to_string()) + })?; + let options = lithe_remote_client::api::WriteOptions { + expected: request.expected, + overwrite: false, + create_parents: false, + }; + // The whole file travels in one request, so the deadline grows with it. + let timeout = + REQUEST_TIMEOUT + Duration::from_secs(content.len() as u64 / WRITE_BYTES_PER_SECOND); + let params = methods::WriteFileParams { + path: request.path.clone(), + data: methods::encode_bytes(&content), + expected: options.expected, + overwrite: options.overwrite, + create_parents: options.create_parents, + }; + let written = connection + .session + .request_value( + methods::FS_WRITE_FILE, + serde_json::to_value(¶ms).expect("write parameters serialize"), + timeout, + ) + .and_then(|value| { + serde_json::from_value::(value) + .map_err(|error| ClientError::Protocol(error.to_string())) + }); + match written { + Ok(metadata) => Ok(serde_json::json!({ + "status": "saved", + "size": metadata.size, + "modifiedMs": metadata.modified_ms, + })), + Err(ClientError::Remote(error)) + if matches!( + error.code, + lithe_remote_protocol::ErrorCode::Conflict + | lithe_remote_protocol::ErrorCode::AlreadyExists + ) => + { + Ok(serde_json::json!({ "status": "conflict" })) + } + Err(error) => Err(request_error(&request.session_id, error)), + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct DisconnectRequest { + session_id: String, +} + +/// Closes a connection; closing an unknown or closed one succeeds. +pub(crate) fn disconnect(request: DisconnectRequest) -> bool { + let connection = registry() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .remove(&request.session_id); + let Some(connection) = connection else { + return false; + }; + connection.close(); + true +} + +/// Closes every open connection, for application exit. Connections close in +/// parallel so a dead host costs one grace period, not one per connection. +pub(crate) fn close_all() -> usize { + CLOSING.store(true, Ordering::SeqCst); + let count = close_registered(); + // Attempts still opening notice the flag within one poll interval and + // kill their OpenSSH tree; give them a moment to do so. + let deadline = Instant::now() + EXIT_GRACE; + while CONNECTING.load(Ordering::SeqCst) > 0 && Instant::now() < deadline { + std::thread::sleep(POLL_INTERVAL); + } + count +} + +/// Closes the registered connections in parallel. +pub(super) fn close_registered() -> usize { + let connections: Vec> = registry() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .drain() + .map(|(_, connection)| connection) + .collect(); + let count = connections.len(); + let closers: Vec<_> = connections + .into_iter() + .map(|connection| std::thread::spawn(move || connection.close())) + .collect(); + for closer in closers { + let _ = closer.join(); + } + count +} + +fn request_error(session_id: &str, error: ClientError) -> CoreError { + match error { + ClientError::Remote(error) => { + let code = match error.code { + lithe_remote_protocol::ErrorCode::NotFound => ErrorCode::WorkspaceNotFound, + lithe_remote_protocol::ErrorCode::PermissionDenied => ErrorCode::PermissionDenied, + _ => ErrorCode::ProcessFailed, + }; + CoreError::new(code, error.message) + } + ClientError::Timeout => CoreError::new( + ErrorCode::TimedOut, + "The remote host did not answer in time", + ), + ClientError::Disconnected { diagnostics } => { + // A broken connection cannot recover; forget it so the host + // reconnects instead of retrying a dead session. + if let Some(connection) = registry() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .remove(session_id) + { + std::thread::spawn(move || connection.close()); + } + CoreError::new(ErrorCode::ConnectionClosed, "The remote connection closed") + .with_details(diagnostics) + } + other => CoreError::new(ErrorCode::ProcessFailed, "The remote request failed") + .with_details(other.to_string()), + } +} + +fn server_target(name: &str) -> Option { + [ + ServerTarget::LinuxX86_64, + ServerTarget::LinuxAarch64, + ServerTarget::MacosX86_64, + ServerTarget::MacosAarch64, + ] + .into_iter() + .find(|target| target.rust_target() == name) +} + +fn wait_for_exit(child: &mut Child, grace: Duration) -> Option { + let deadline = Instant::now() + grace; + loop { + match child.try_wait() { + Ok(Some(status)) => return Some(status), + Ok(None) if Instant::now() < deadline => std::thread::sleep(POLL_INTERVAL), + _ => return None, + } + } +} + +fn join_diagnostics(summary: &str, stderr: &str) -> String { + if stderr.is_empty() { + summary.to_owned() + } else { + format!("{summary}\n{stderr}") + } +} + +fn keep_tail( + mut stderr: impl Read + Send + 'static, + tail: Arc>>, +) -> std::thread::JoinHandle<()> { + std::thread::spawn(move || { + let mut buffer = [0_u8; 4096]; + loop { + match stderr.read(&mut buffer) { + Ok(0) | Err(_) => return, + Ok(read) => { + let mut tail = tail.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + tail.extend(&buffer[..read]); + let excess = tail.len().saturating_sub(STDERR_TAIL_BYTES); + tail.drain(..excess); + } + } + } + }) +} + +/// Unguessable connection id, so one host request cannot address a +/// connection it did not open by counting. +fn nonce() -> String { + use rand::RngCore; + let mut bytes = [0_u8; 16]; + rand::rngs::OsRng.fill_bytes(&mut bytes); + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} diff --git a/rust/lithe-core/src/remote/tests.rs b/rust/lithe-core/src/remote/tests.rs new file mode 100644 index 0000000000..db5c753fd8 --- /dev/null +++ b/rust/lithe-core/src/remote/tests.rs @@ -0,0 +1,387 @@ +use super::*; +#[cfg(unix)] +use crate::tests::support::temporary_root; +use serde_json::json; +#[cfg(unix)] +use std::path::{Path, PathBuf}; + +fn connection(value: serde_json::Value) -> SshConnection { + serde_json::from_value(value).expect("valid connection") +} + +/// The local command line the connection produces for the probe. +fn command_line(value: serde_json::Value) -> Vec { + let hop = ssh_hop(&connection(value)).expect("valid hop"); + TransportChain::new(vec![Hop::Ssh(hop)]) + .command(&probe_command()) + .unwrap() +} + +fn option_value(command: &[String], key: &str) -> Option { + command + .windows(2) + .filter(|pair| pair[0] == "-o") + .find_map(|pair| pair[1].strip_prefix(&format!("{key}=")).map(str::to_owned)) +} + +#[test] +fn private_key_connections_offer_only_the_chosen_key() { + let command = command_line(json!({ + "host": "devbox.example", + "port": 2222, + "user": "dev", + "authentication": "privateKey", + "identityFile": "~/.ssh/id_ed25519", + "connectTimeoutSeconds": 20, + "keepAliveSeconds": 30, + })); + assert!(command.windows(2).any(|pair| pair == ["-p", "2222"])); + assert_eq!(&command[command.len() - 2], "dev@devbox.example"); + assert_eq!( + option_value(&command, "IdentityFile").as_deref(), + Some("~/.ssh/id_ed25519") + ); + assert_eq!( + option_value(&command, "IdentitiesOnly").as_deref(), + Some("yes") + ); + assert_eq!( + option_value(&command, "PreferredAuthentications").as_deref(), + Some("publickey") + ); + assert_eq!( + option_value(&command, "ConnectTimeout").as_deref(), + Some("20") + ); + // OpenSSH keeps the first value, so the profile's interval must come + // before the transport's default. + assert_eq!( + option_value(&command, "ServerAliveInterval").as_deref(), + Some("30") + ); + assert_eq!(option_value(&command, "BatchMode").as_deref(), Some("no")); +} + +#[test] +fn other_authentication_methods_select_matching_ssh_options() { + let password = command_line(json!({ "host": "h", "authentication": "password" })); + assert_eq!( + option_value(&password, "PreferredAuthentications").as_deref(), + Some("keyboard-interactive,password") + ); + assert_eq!( + option_value(&password, "PubkeyAuthentication").as_deref(), + Some("no") + ); + + let agent = command_line(json!({ "host": "h", "authentication": "agent" })); + assert_eq!( + option_value(&agent, "PreferredAuthentications").as_deref(), + Some("publickey") + ); + assert_eq!(option_value(&agent, "IdentityFile"), None); + + // Automatic leaves the order to OpenSSH and the user's configuration, + // and an alias without a user or port stays untouched. + let automatic = command_line(json!({ "host": "devbox" })); + assert_eq!(option_value(&automatic, "PreferredAuthentications"), None); + assert_eq!(&automatic[automatic.len() - 2], "devbox"); + assert!(!automatic.iter().any(|argument| argument == "-p")); + assert_eq!( + option_value(&automatic, "ConnectTimeout").as_deref(), + Some(DEFAULT_CONNECT_TIMEOUT_SECONDS.to_string().as_str()) + ); +} + +#[test] +fn malformed_connections_are_rejected_before_ssh_starts() { + for value in [ + json!({ "host": "" }), + json!({ "host": "dev@box" }), + json!({ "host": "-oProxyCommand=touch x" }), + json!({ "host": "box", "user": "-oProxyCommand=x" }), + json!({ "host": "box", "user": "a b" }), + json!({ "host": "box", "authentication": "privateKey" }), + ] { + let hop = ssh_hop(&connection(value.clone())); + let command = hop.and_then(|hop| { + TransportChain::new(vec![Hop::Ssh(hop)]) + .command(&probe_command()) + .map_err(|error| CoreError::new(ErrorCode::InvalidRequest, error.to_string())) + }); + assert!( + matches!(command, Err(ref error) if matches!(error.code, ErrorCode::InvalidRequest)), + "{value} was accepted" + ); + } + assert!( + serde_json::from_value::(json!({ "host": "h", "password": "x" })).is_err() + ); +} + +#[test] +fn ssh_failures_map_to_stable_statuses() { + let cases: [(&str, ProbeStatus); 6] = [ + ( + "dev@box: Permission denied (publickey,password).", + ProbeStatus::AuthenticationFailed, + ), + ( + "Received disconnect: Too many authentication failures", + ProbeStatus::AuthenticationFailed, + ), + ( + "Host key verification failed.", + ProbeStatus::HostKeyRejected, + ), + ( + "ssh: Could not resolve hostname nowhere: nodename nor servname provided", + ProbeStatus::Unreachable, + ), + ( + "ssh: connect to host 10.0.0.1 port 22: Operation timed out", + ProbeStatus::Unreachable, + ), + ( + "kex_exchange_identification: read: Connection reset by peer", + ProbeStatus::Failed, + ), + ]; + for (stderr, expected) in cases { + let result = classify(None, b"", stderr.as_bytes()); + assert_eq!(result.status, expected, "{stderr}"); + assert_eq!(result.host, None); + assert_eq!(result.diagnostics, stderr); + } +} + +#[test] +fn a_reported_platform_decides_between_connected_and_unsupported() { + let connected = classify( + None, + b"motd\nLITHE-PROBE-BEGIN\nLinux\naarch64\n/home/dev\nLITHE-PROBE-END\n", + b"", + ); + assert_eq!(connected.status, ProbeStatus::Connected); + assert_eq!( + connected.host, + Some(ProbedHost { + system: "Linux".into(), + machine: "aarch64".into(), + home_directory: Some("/home/dev".into()), + server_target: Some("aarch64-unknown-linux-musl"), + details: Default::default(), + }) + ); + let unsupported = classify( + None, + b"LITHE-PROBE-BEGIN\nFreeBSD\namd64\n/home/dev\nLITHE-PROBE-END\n", + b"", + ); + assert_eq!(unsupported.status, ProbeStatus::UnsupportedPlatform); + assert_eq!(unsupported.host.unwrap().server_target, None); +} + +#[test] +fn diagnostics_keep_the_redacted_end_of_long_output() { + let noise = "debug1: x\n".repeat(1_000); + let result = classify( + None, + format!("{noise}Permission denied for /Users/someone/.ssh/id_ed25519").as_bytes(), + b"", + ); + // Standard output without a probe block is not the shell's report. + assert_eq!(result.status, ProbeStatus::Failed); + let result = classify( + None, + b"", + format!("{noise}Permission denied for /Users/someone/.ssh/id_ed25519").as_bytes(), + ); + assert!(result.diagnostics.len() <= DIAGNOSTICS_LIMIT); + assert!( + result.diagnostics.ends_with("/.ssh/id_ed25519"), + "{}", + result.diagnostics + ); +} + +/// Stand-in for OpenSSH: ignores its options and runs the remote command +/// with the local shell, or fails like a rejecting server. +#[cfg(unix)] +struct FakeSsh { + root: PathBuf, + program: PathBuf, +} + +#[cfg(unix)] +impl FakeSsh { + fn new(label: &str, body: &str) -> Self { + use std::os::unix::fs::PermissionsExt; + let root = temporary_root(label); + std::fs::create_dir_all(&root).unwrap(); + let program = root.join("ssh"); + std::fs::write(&program, format!("#!/bin/sh\n{body}\n")).unwrap(); + std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).unwrap(); + Self { root, program } + } + + fn path(&self) -> &str { + self.program.to_str().unwrap() + } +} + +#[cfg(unix)] +impl Drop for FakeSsh { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } +} + +#[cfg(unix)] +fn probe_request() -> ProbeRequest { + serde_json::from_value(json!({ "connection": { "host": "devbox", "user": "dev" } })).unwrap() +} + +#[cfg(unix)] +fn local_uname(flag: &str) -> String { + let output = Command::new("uname").arg(flag).output().unwrap(); + String::from_utf8(output.stdout).unwrap().trim().to_owned() +} + +#[cfg(unix)] +#[test] +fn probing_runs_the_platform_report_through_the_ssh_client() { + let ssh = FakeSsh::new( + "remote-probe", + "for last; do :; done\nexec /bin/sh -c \"$last\"", + ); + let result = probe_with(probe_request(), Some(ssh.path())).unwrap(); + let host = result.host.expect("the probe block was read"); + assert_eq!(host.system, local_uname("-s")); + assert_eq!(host.machine, local_uname("-m")); + assert!(host + .home_directory + .as_deref() + .is_some_and(|home| Path::new(home).is_absolute())); + assert_eq!(result.status, ProbeStatus::Connected); + // The details block ran with the real shell and tools. + assert_eq!( + host.details.kernel.as_deref(), + Some(local_uname("-r").as_str()) + ); + assert!(host.details.cpu_count.is_some_and(|count| count > 0)); + assert!(host.details.disk_total_bytes.is_some()); +} + +#[cfg(unix)] +#[test] +fn a_rejecting_server_is_reported_as_an_authentication_failure() { + let ssh = FakeSsh::new( + "remote-probe-denied", + "echo 'dev@devbox: Permission denied (publickey).' >&2\nexit 255", + ); + let result = probe_with(probe_request(), Some(ssh.path())).unwrap(); + assert_eq!(result.status, ProbeStatus::AuthenticationFailed); + assert_eq!( + result.diagnostics, + "dev@devbox: Permission denied (publickey)." + ); +} + +#[cfg(unix)] +#[test] +fn a_missing_ssh_client_is_a_runtime_error() { + let missing = temporary_root("remote-probe-missing").join("ssh"); + let error = probe_with(probe_request(), missing.to_str()).unwrap_err(); + assert!(matches!(error.code, ErrorCode::RuntimeMissing), "{error:?}"); +} + +#[cfg(unix)] +fn connect_request(servers: serde_json::Value) -> sessions::ConnectRequest { + serde_json::from_value(json!({ + "connection": { "host": "devbox", "user": "dev" }, + "servers": servers, + "serverVersion": "0.1.0", + })) + .unwrap() +} + +// A rejected login ends before the remote shell runs; the result names the +// reason instead of failing the request. +#[cfg(unix)] +#[test] +fn connecting_to_a_rejecting_server_reports_the_reason() { + let ssh = FakeSsh::new( + "remote-connect-denied", + "echo 'dev@devbox: Permission denied (password).' >&2\nexit 255", + ); + let result = sessions::connect_with(connect_request(json!([])), Some(ssh.path())).unwrap(); + let value = serde_json::to_value(result).unwrap(); + assert_eq!(value["status"], "authenticationFailed"); + assert_eq!(value["session"], serde_json::Value::Null); + assert_eq!( + value["diagnostics"], + "dev@devbox: Permission denied (password)." + ); +} + +// The shell runs and reports its platform, but the host ships no server for +// it, so nothing is uploaded and the platform is reported. +#[cfg(unix)] +#[test] +fn connecting_without_a_matching_server_reports_the_platform() { + let ssh = FakeSsh::new( + "remote-connect-platform", + "for last; do :; done\nexec /bin/sh -c \"$last\"", + ); + let result = sessions::connect_with(connect_request(json!([])), Some(ssh.path())).unwrap(); + let value = serde_json::to_value(result).unwrap(); + assert_eq!(value["status"], "unsupportedPlatform"); + assert_eq!(value["host"]["system"], local_uname("-s")); +} + +#[test] +fn unknown_server_targets_are_rejected() { + let request: sessions::ConnectRequest = serde_json::from_value(json!({ + "connection": { "host": "devbox" }, + "servers": [{ "target": "riscv64-unknown-linux-musl", "path": "/nowhere" }], + "serverVersion": "0.1.0", + })) + .unwrap(); + let error = sessions::connect(request).unwrap_err(); + assert!(matches!(error.code, ErrorCode::InvalidRequest), "{error:?}"); +} + +#[test] +fn closed_connections_cannot_be_used() { + let request: sessions::ReadDirRequest = + serde_json::from_value(json!({ "sessionId": "missing", "path": "/" })).unwrap(); + assert!(matches!( + sessions::read_dir(request).unwrap_err().code, + ErrorCode::ConnectionClosed + )); + let request: sessions::DisconnectRequest = + serde_json::from_value(json!({ "sessionId": "missing" })).unwrap(); + assert!(!sessions::disconnect(request)); +} + +// Application exit closes whatever is open and must be safe with nothing open. +#[test] +fn closing_all_connections_with_none_open_succeeds() { + assert_eq!(sessions::close_registered(), 0); +} + +// ssh splits an option value on whitespace, so a key path with spaces must +// reach it quoted. +#[test] +fn private_key_paths_with_spaces_are_quoted() { + let command = command_line(json!({ + "host": "devbox.example", + "authentication": "privateKey", + "identityFile": "/Users/me/My Keys/id_rsa", + })); + assert_eq!( + option_value(&command, "IdentityFile").as_deref(), + Some("\"/Users/me/My Keys/id_rsa\"") + ); +} diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index d551ebee86..c33c2d39df 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -14,9 +14,9 @@ use crate::git::{ GitOperationStateRequest, GitPullPreflightRequest, GitPullRequestContextRequest, GitPushPreviewRequest, GitRebaseControlRequest, GitRebasePreviewRequest, GitRebaseSessionRequest, GitRebaseStartRequest, GitReferencesRequest, GitRepositoryRootRequest, - GitStashesRequest, GitStatusRequest, GitWatchContextRequest, GitWorktreesRequest, - GitWriteRequest, PatchApplyRequest, PatchExportRequest, PatchPreviewRequest, - WorkspaceRepositoriesRequest, + GitStashesRequest, GitStatusRequest, GitUntrackedDirectoryRequest, GitWatchContextRequest, + GitWorktreesRequest, GitWriteRequest, PatchApplyRequest, PatchExportRequest, + PatchPreviewRequest, WorkspaceRepositoriesRequest, }; use crate::github::{NormalizeResponseRequest, ParseRemoteRequest, RequestPlanRequest}; use crate::languages::{ @@ -139,6 +139,17 @@ fn execute(request: &str) -> CoreResponse { }), ), CoreCommand::AgentStatus => agent_response(id, parsed.payload, crate::agent::status), + CoreCommand::AgentInstructionsRead => { + agent_response(id, parsed.payload, crate::agent::instructions::read) + } + CoreCommand::AgentInstructionsSave => { + agent_response(id, parsed.payload, crate::agent::instructions::save) + } + CoreCommand::AgentInstructionsShared => { + agent_response(id, parsed.payload, crate::agent::instructions::shared) + } + CoreCommand::AgentSkills => agent_response(id, parsed.payload, crate::agent::skills), + CoreCommand::AgentHooks => agent_response(id, parsed.payload, crate::agent::hooks::execute), CoreCommand::AgentParseProviderConfiguration => { match crate::ai::parse_provider_configuration(parsed.payload) { Ok(data) => CoreResponse::success(id, data), @@ -151,10 +162,32 @@ fn execute(request: &str) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::McpParseRegistry => match crate::mcp::parse_registry(parsed.payload) { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error), + }, + CoreCommand::McpFeaturedRegistry => match crate::mcp::featured_registry() { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error), + }, + CoreCommand::McpProbe => agent_response(id, parsed.payload, crate::mcp::probe), + CoreCommand::McpToolPolicy => match crate::mcp::tool_policy(parsed.payload) { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error), + }, + CoreCommand::McpCommandLine => match crate::mcp::command_line(parsed.payload) { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error), + }, CoreCommand::McpPlanWrite => match crate::mcp::plan_write(parsed.payload) { Ok(data) => CoreResponse::success(id, data), Err(error) => CoreResponse::failure(id, error), }, + CoreCommand::PluginsNativeUIValidateData => plugin_response( + id, + parsed.payload, + crate::plugins::native_ui::validate_data_command, + ), CoreCommand::PluginsDeclarativeValidate => plugin_response( id, parsed.payload, @@ -255,6 +288,40 @@ fn execute(request: &str) -> CoreResponse { parsed.payload, crate::build::build_language_import_command, ), + CoreCommand::VcsDetectRoots => { + plugin_response(id, parsed.payload, crate::vcs::vcs_detect_roots_command) + } + CoreCommand::VcsOwnerOf => { + plugin_response(id, parsed.payload, crate::vcs::vcs_owner_of_command) + } + CoreCommand::VcsDecodeReply => { + plugin_response(id, parsed.payload, crate::vcs::vcs_decode_reply_command) + } + CoreCommand::ProjectGeneratorCatalog => plugin_response( + id, + parsed.payload, + crate::project_generators::project_generator_catalog_command, + ), + CoreCommand::ProjectGeneratorForm => plugin_response( + id, + parsed.payload, + crate::project_generators::project_generator_form_command, + ), + CoreCommand::ProjectGenerationPlan => plugin_response( + id, + parsed.payload, + crate::project_generators::project_generation_plan_command, + ), + CoreCommand::ProjectCheckName => plugin_response( + id, + parsed.payload, + crate::project_generators::project_check_name_command, + ), + CoreCommand::ProjectExtractArchive => plugin_response( + id, + parsed.payload, + crate::project_generators::project_extract_archive_command, + ), CoreCommand::PluginsMarketplaceValidateIndex => plugin_response( id, parsed.payload, @@ -1584,6 +1651,21 @@ fn execute(request: &str) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::LspVcsRequest => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid VCS-provider request") + .with_details(error.to_string()) + }) + .and_then(crate::lsp::vcs_provider_request) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("VCS-provider response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::LspFunctionRequest => { match serde_json::from_value::(parsed.payload) .map_err(|error| { @@ -1637,6 +1719,18 @@ fn execute(request: &str) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::LspHostResponse => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid host-service response") + .with_details(error.to_string()) + }) + .and_then(crate::lsp::plugin_host_response) + { + Ok(()) => CoreResponse::success(id, json!({})), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::LspCancelOperation => { match serde_json::from_value::(parsed.payload) .map_err(|error| { @@ -1999,6 +2093,24 @@ fn execute(request: &str) -> CoreResponse { ), Err(error) => CoreResponse::failure(id, error), }, + CoreCommand::GitUntrackedDirectory => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git untracked directory request", + ) + .with_details(error.to_string()) + }) + .and_then(git::untracked_directory) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Git untracked directory should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::GitWatchContext => { match serde_json::from_value::(parsed.payload) .map_err(|error| { @@ -2293,6 +2405,128 @@ fn execute(request: &str) -> CoreResponse { CoreCommand::GitExecutable => { CoreResponse::success(id, git::configuration::locate_executable()) } + CoreCommand::RemoteProbe => { + match serde_json::from_value::(parsed.payload) { + Ok(request) => match crate::remote::probe(request) { + Ok(result) => CoreResponse::success( + id, + serde_json::to_value(result).expect("remote probe result should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + }, + Err(error) => CoreResponse::failure( + id, + CoreError::new(ErrorCode::InvalidRequest, "Invalid remote probe request") + .with_details(error.to_string()), + ), + } + } + CoreCommand::RemoteConnect => { + match serde_json::from_value::(parsed.payload) + { + Ok(request) => match crate::remote::sessions::connect(request) { + Ok(result) => CoreResponse::success( + id, + serde_json::to_value(result).expect("remote connect result should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + }, + Err(error) => CoreResponse::failure( + id, + CoreError::new(ErrorCode::InvalidRequest, "Invalid remote connect request") + .with_details(error.to_string()), + ), + } + } + CoreCommand::RemoteReadDir => { + match serde_json::from_value::(parsed.payload) + { + Ok(request) => match crate::remote::sessions::read_dir(request) { + Ok(result) => CoreResponse::success(id, result), + Err(error) => CoreResponse::failure(id, error), + }, + Err(error) => CoreResponse::failure( + id, + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid remote directory request", + ) + .with_details(error.to_string()), + ), + } + } + CoreCommand::RemoteReadFile => { + match serde_json::from_value::(parsed.payload) + { + Ok(request) => match crate::remote::sessions::read_file(request) { + Ok(result) => CoreResponse::success(id, result), + Err(error) => CoreResponse::failure(id, error), + }, + Err(error) => CoreResponse::failure( + id, + CoreError::new(ErrorCode::InvalidRequest, "Invalid remote file request") + .with_details(error.to_string()), + ), + } + } + CoreCommand::RemoteWriteFile => { + match serde_json::from_value::( + parsed.payload, + ) { + Ok(request) => match crate::remote::sessions::write_file(request) { + Ok(result) => CoreResponse::success(id, result), + Err(error) => CoreResponse::failure(id, error), + }, + Err(error) => CoreResponse::failure( + id, + CoreError::new(ErrorCode::InvalidRequest, "Invalid remote file request") + .with_details(error.to_string()), + ), + } + } + CoreCommand::RemoteDisconnect => { + match serde_json::from_value::( + parsed.payload, + ) { + Ok(request) => CoreResponse::success( + id, + json!({ "closed": crate::remote::sessions::disconnect(request) }), + ), + Err(error) => CoreResponse::failure( + id, + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid remote disconnect request", + ) + .with_details(error.to_string()), + ), + } + } + CoreCommand::RemoteCloseAll => CoreResponse::success( + id, + json!({ "closed": crate::remote::sessions::close_all() }), + ), + CoreCommand::RemoteAuthRespond => { + #[derive(serde::Deserialize)] + #[serde(rename_all = "camelCase", deny_unknown_fields)] + struct Reply { + request_id: String, + answer: Option, + } + match serde_json::from_value::(parsed.payload) { + Ok(reply) => CoreResponse::success( + id, + json!({ "accepted": crate::remote::respond(&reply.request_id, reply.answer) }), + ), + Err(_) => CoreResponse::failure( + id, + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid remote authentication response", + ), + ), + } + } CoreCommand::GitExecutionInspect | CoreCommand::GitExecutionConfigure => { match git::configuration::dispatch( parsed.payload, diff --git a/rust/lithe-core/src/tests/agent.rs b/rust/lithe-core/src/tests/agent.rs index 155e3e952a..e61c108140 100644 --- a/rust/lithe-core/src/tests/agent.rs +++ b/rust/lithe-core/src/tests/agent.rs @@ -1,11 +1,73 @@ use crate::execute_json; use serde_json::{json, Value}; +#[test] +fn global_instruction_contract_dispatches_and_rejects_unknown_agents_without_file_access() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/agent/agent-instructions-v1.json" + )) + .unwrap(); + for key in ["readRequest", "saveRequest"] { + let command = fixture[key]["command"].as_str().unwrap(); + assert!(crate::protocol::CoreCommand::parse(command).is_some()); + let mut payload = fixture[key]["payload"].clone(); + payload["agentId"] = json!("unknown-agent"); + let response = run(command, payload); + assert_eq!(response["ok"], false); + assert_eq!(response["error"]["code"], "not_supported"); + assert_eq!(response["error"]["details"], "unsupported_agent"); + } + let response = run( + "agent.instructions.save", + json!({"agentId":"codex-acp","content":"no guard"}), + ); + assert_eq!(response["error"]["code"], "invalid_request"); + let response = run( + "agent.instructions.read", + json!({"agentId":"codex-acp","path":"/untrusted/arbitrary.md"}), + ); + assert_eq!(response["error"]["code"], "invalid_request"); +} + +#[test] +fn shared_instruction_requests_have_strict_typed_dispatch_without_arbitrary_native_paths() { + assert!(crate::protocol::CoreCommand::parse("agent.instructions.shared").is_some()); + for payload in [ + json!({"dataDirectory":"relative", "action":"read"}), + json!({"dataDirectory":"/fixture", "action":"unknown"}), + json!({"dataDirectory":"/fixture", "action":"read", "path":"/arbitrary"}), + json!({"dataDirectory":"/fixture", "action":"setEnabled", "agentId":"codex-acp", "enabled":true}), + ] { + let response = run("agent.instructions.shared", payload); + assert_eq!(response["ok"], false, "{response}"); + assert_eq!(response["error"]["code"], "invalid_request"); + } +} + fn run(command: &str, payload: Value) -> Value { let request = json!({ "id": "agent-test", "command": command, "payload": payload }); serde_json::from_str(&execute_json(&request.to_string())).expect("core response JSON") } +#[test] +fn hook_contract_dispatch_is_strict_before_accessing_native_files() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/agent/hooks-management-v1.json" + )) + .unwrap(); + assert!(crate::protocol::CoreCommand::parse(fixture["command"].as_str().unwrap()).is_some()); + for payload in [ + json!({"dataDirectory":"relative", "action":"read"}), + json!({"dataDirectory":"/fixture", "action":"unknown"}), + json!({"dataDirectory":"/fixture", "action":"read", "path":"/arbitrary"}), + json!({"dataDirectory":"/fixture", "action":"save", "hook":fixture["hook"]}), + ] { + let response = run("agent.hooks", payload); + assert_eq!(response["ok"], false, "{response}"); + assert_eq!(response["error"]["code"], "invalid_request", "{response}"); + } +} + fn fixture() -> Value { serde_json::from_str(include_str!( "../../../../shared/fixtures/agent/agent-management-v1.json" diff --git a/rust/lithe-core/src/tests/build_runtime_versions.rs b/rust/lithe-core/src/tests/build_runtime_versions.rs index 0c34076a58..d92dd50de9 100644 --- a/rust/lithe-core/src/tests/build_runtime_versions.rs +++ b/rust/lithe-core/src/tests/build_runtime_versions.rs @@ -6,7 +6,7 @@ use serde_json::{json, Value}; fn manifest() -> Value { serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/contracts/plugins/v1/fixtures/maven-build.plugin.json" + "/../../shared/contracts/plugins/v1/fixtures/build-host-integration.plugin.json" ))) .expect("Maven fixture is valid JSON") } diff --git a/rust/lithe-core/src/tests/build_systems.rs b/rust/lithe-core/src/tests/build_systems.rs index 33df31c95c..55b69c4b42 100644 --- a/rust/lithe-core/src/tests/build_systems.rs +++ b/rust/lithe-core/src/tests/build_systems.rs @@ -26,22 +26,23 @@ macro_rules! contract_file { }; } -const CARGO_FIXTURE: &str = contract_file!("fixtures/cargo-build.plugin.json"); -const GO_FIXTURE: &str = contract_file!("fixtures/go-build.plugin.json"); -const GRADLE_FIXTURE: &str = contract_file!("fixtures/gradle-build.plugin.json"); -const MAVEN_FIXTURE: &str = contract_file!("fixtures/maven-build.plugin.json"); -const GRADLE_PROVIDER_FIXTURE: &str = contract_file!("fixtures/gradle-provider.plugin.json"); -const MIX_PROVIDER_FIXTURE: &str = contract_file!("fixtures/mix-provider.plugin.json"); +const CARGO_FIXTURE: &str = contract_file!("fixtures/build-static-file.plugin.json"); +const GO_FIXTURE: &str = contract_file!("fixtures/build-command-stdout.plugin.json"); +const GRADLE_FIXTURE: &str = contract_file!("fixtures/build-command-output-file.plugin.json"); +const MAVEN_FIXTURE: &str = contract_file!("fixtures/build-host-integration.plugin.json"); +const GRADLE_PROVIDER_FIXTURE: &str = contract_file!("fixtures/build-provider.plugin.json"); +const MIX_PROVIDER_FIXTURE: &str = + contract_file!("fixtures/build-provider-language-server.plugin.json"); const BUILD_PLANS: &str = contract_file!("fixtures/build-plans.json"); fn fixture(name: &str) -> Value { let source = match name { - "cargo-build.plugin.json" => CARGO_FIXTURE, - "go-build.plugin.json" => GO_FIXTURE, - "gradle-build.plugin.json" => GRADLE_FIXTURE, - "maven-build.plugin.json" => MAVEN_FIXTURE, - "gradle-provider.plugin.json" => GRADLE_PROVIDER_FIXTURE, - "mix-provider.plugin.json" => MIX_PROVIDER_FIXTURE, + "build-static-file.plugin.json" => CARGO_FIXTURE, + "build-command-stdout.plugin.json" => GO_FIXTURE, + "build-command-output-file.plugin.json" => GRADLE_FIXTURE, + "build-host-integration.plugin.json" => MAVEN_FIXTURE, + "build-provider.plugin.json" => GRADLE_PROVIDER_FIXTURE, + "build-provider-language-server.plugin.json" => MIX_PROVIDER_FIXTURE, other => panic!("unknown fixture {other}"), }; serde_json::from_str(source).expect("fixture manifest is JSON") @@ -126,12 +127,12 @@ fn substitute(value: &mut Value, workspace: &str) { #[test] fn build_fixtures_validate() { for name in [ - "cargo-build.plugin.json", - "go-build.plugin.json", - "gradle-build.plugin.json", - "maven-build.plugin.json", - "gradle-provider.plugin.json", - "mix-provider.plugin.json", + "build-static-file.plugin.json", + "build-command-stdout.plugin.json", + "build-command-output-file.plugin.json", + "build-host-integration.plugin.json", + "build-provider.plugin.json", + "build-provider-language-server.plugin.json", ] { let outcome = validate(fixture(name)); assert_eq!(outcome["status"], "ok", "{name}: {outcome}"); @@ -228,7 +229,7 @@ fn cargo_run_entries_match_the_builtin_detector() { ); let detection = detect( &workspace, - vec![fixture("cargo-build.plugin.json")], + vec![fixture("build-static-file.plugin.json")], json!({}), ); let mut planned: Vec = detection["instances"] @@ -316,7 +317,7 @@ fn cargo_implicit_main_survives_test_and_example_targets() { .write("cli/src/bin/cli-bin.rs", "fn main() {}\n"); let detection = detect( &workspace, - vec![fixture("cargo-build.plugin.json")], + vec![fixture("build-static-file.plugin.json")], json!({}), ); let targets = |id: &str| -> Vec<(String, String)> { @@ -376,18 +377,18 @@ fn detection_applies_nesting_priority_and_rejections() { ) .write("target/debug/Cargo.toml", "[package]\nname = \"ignored\"\n"); - let mut preferred = fixture("cargo-build.plugin.json"); + let mut preferred = fixture("build-static-file.plugin.json"); preferred["id"] = json!("dev.lithe.fixture.cargo-preferred"); preferred["buildSystems"][0]["detection"]["priority"] = json!(80); - let mut broken = fixture("go-build.plugin.json"); + let mut broken = fixture("build-command-stdout.plugin.json"); broken["permissions"] = json!([]); let detection = detect( &workspace, vec![ - fixture("cargo-build.plugin.json"), + fixture("build-static-file.plugin.json"), broken, - fixture("gradle-build.plugin.json"), + fixture("build-command-output-file.plugin.json"), preferred, ], json!({}), @@ -408,7 +409,7 @@ fn detection_applies_nesting_priority_and_rejections() { assert_eq!( detection["shadowed"], json!([{ - "pluginID": "dev.lithe.fixture.cargo-build", + "pluginID": "dev.lithe.fixture.build-static-file", "buildSystemID": "cargo", "by": "dev.lithe.fixture.cargo-preferred", }]) @@ -455,7 +456,7 @@ fn maven_method_durations_survive_report_aggregation_and_build_decoding() { ); let decoded = call( "decodeTestResults", - fixture("maven-build.plugin.json"), + fixture("build-host-integration.plugin.json"), "maven", json!({ "workspaceRoot": workspace.root(), "root": ".", "project": ".", "output": "", "notBeforeMillis": 0 @@ -492,7 +493,7 @@ fn maven_reactor_detects_through_the_host_integration() { .write("backend/api/src/main/java/demo/App.java", "class App {}\n"); let detection = detect( &workspace, - vec![fixture("maven-build.plugin.json")], + vec![fixture("build-host-integration.plugin.json")], json!({ "maven": { "profiles": ["dev", "removed"] } }), ); assert_eq!(instance_ids(&detection), ["maven:backend"]); @@ -527,7 +528,7 @@ fn maven_task_plans_match_the_builtin_arguments() { let settings = "/opt/maven/settings.xml"; let outcome = call( "taskPlan", - fixture("maven-build.plugin.json"), + fixture("build-host-integration.plugin.json"), "maven", json!({ "root": ".", @@ -598,7 +599,7 @@ fn gradle_canonical_outputs_are_read_from_the_output_file() { let model = call( "decodeModel", - fixture("gradle-build.plugin.json"), + fixture("build-command-output-file.plugin.json"), "gradle", json!({ "workspaceRoot": workspace.root(), @@ -641,7 +642,7 @@ fn gradle_canonical_outputs_are_read_from_the_output_file() { let dependencies = call( "decodeDependencies", - fixture("gradle-build.plugin.json"), + fixture("build-command-output-file.plugin.json"), "gradle", json!({ "workspaceRoot": workspace.root(), @@ -661,7 +662,7 @@ fn gradle_canonical_outputs_are_read_from_the_output_file() { let missing = call( "decodeModel", - fixture("gradle-build.plugin.json"), + fixture("build-command-output-file.plugin.json"), "gradle", json!({ "workspaceRoot": workspace.root(), "root": "." }), ); @@ -692,7 +693,7 @@ fn junit_reports_become_case_results() { ); let outcome = call( "decodeTestResults", - fixture("gradle-build.plugin.json"), + fixture("build-command-output-file.plugin.json"), "gradle", json!({ "workspaceRoot": workspace.root(), @@ -737,7 +738,7 @@ fn junit_reports_become_case_results() { let later = call( "decodeTestResults", - fixture("gradle-build.plugin.json"), + fixture("build-command-output-file.plugin.json"), "gradle", json!({ "workspaceRoot": workspace.root(), @@ -770,7 +771,7 @@ fn workspace_test_results_cover_every_model_project() { let decode = |request: Value| { call( "decodeTestResults", - fixture("gradle-build.plugin.json"), + fixture("build-command-output-file.plugin.json"), "gradle", request, ) @@ -848,7 +849,7 @@ fn canonical_model_paths_stay_inside_the_instance_root() { workspace.write("model.json", &document.to_string()); let result = call( "decodeModel", - fixture("gradle-build.plugin.json"), + fixture("build-command-output-file.plugin.json"), "gradle", json!({ "workspaceRoot": workspace.root(), "root": root, "outputFile": workspace.0.join("model.json") @@ -881,7 +882,7 @@ fn canonical_model_paths_stay_inside_the_instance_root() { #[test] fn selector_model_paths_stay_inside_the_instance_root() { let workspace = TemporaryWorkspace::new("selector-model-boundary"); - let mut manifest = fixture("gradle-build.plugin.json"); + let mut manifest = fixture("build-command-output-file.plugin.json"); manifest["buildSystems"][0]["model"] = json!({ "source": "command", "arguments": ["model"], "output": "stdout", "decoder": "jsonStream", "executesProjectCode": false, "mayUseNetwork": false, @@ -942,7 +943,7 @@ fn selector_model_paths_stay_inside_the_instance_root() { #[test] fn rejected_selector_paths_do_not_suppress_valid_fallbacks() { let workspace = TemporaryWorkspace::new("selector-model-boundary-fallback"); - let mut manifest = fixture("gradle-build.plugin.json"); + let mut manifest = fixture("build-command-output-file.plugin.json"); manifest["buildSystems"][0]["model"] = json!({ "source": "command", "arguments": ["model"], "output": "stdout", "decoder": "jsonStream", "executesProjectCode": false, "mayUseNetwork": false, @@ -1001,7 +1002,7 @@ fn canonical_content_roots_are_relative_to_the_instance_root() { ); let outcome = call( "decodeModel", - fixture("gradle-build.plugin.json"), + fixture("build-command-output-file.plugin.json"), "gradle", json!({ "workspaceRoot": workspace.root(), @@ -1042,7 +1043,7 @@ fn sync_fingerprint_follows_watched_files() { let fingerprint = || { let outcome = call( "syncFingerprint", - fixture("cargo-build.plugin.json"), + fixture("build-static-file.plugin.json"), "cargo", json!({ "workspaceRoot": workspace.root(), "root": ".", "projects": ["."] }), ); @@ -1070,7 +1071,7 @@ fn sync_fingerprint_follows_watched_files() { fn resolve_options_describe_explicit_empty_text_values() { let outcome = call( "resolveOptions", - fixture("go-build.plugin.json"), + fixture("build-command-stdout.plugin.json"), "go", json!({ "options": { "tags": "" } }), ); @@ -1087,7 +1088,7 @@ fn resolve_options_describe_explicit_empty_text_values() { #[test] fn option_arguments_may_repeat_under_opposite_conditions() { - let mut manifest = fixture("cargo-build.plugin.json"); + let mut manifest = fixture("build-static-file.plugin.json"); manifest["buildSystems"][0]["test"]["arguments"] = json!([ "test", { "value": "${optionArguments}", "when": "targetSelected" }, @@ -1117,7 +1118,7 @@ fn provider_processes_are_install_review_programs() { assert_eq!(outcome["status"], "ok", "{outcome}"); outcome["review"]["programs"].as_array().unwrap().clone() }; - let own = programs("gradle-provider.plugin.json"); + let own = programs("build-provider.plugin.json"); let provider = own .iter() .find(|program| program["kind"] == "runtimeBuildProvider") @@ -1131,7 +1132,7 @@ fn provider_processes_are_install_review_programs() { assert_eq!(provider["executesProjectCode"], true); assert!(own.contains(&json!({ "kind": "runtime", "tool": "java", "minimumVersion": "17" }))); - let shared = programs("mix-provider.plugin.json"); + let shared = programs("build-provider-language-server.plugin.json"); let build_system = shared .iter() .find(|program| program["kind"] == "buildSystem") @@ -1158,7 +1159,7 @@ fn cargo_run_entries_drop_features_removed_from_the_model() { "[package]\nname = \"app\"\nversion = \"0.1.0\"\n[features]\ncli = []\n", ) .write("src/main.rs", "fn main() {}\n"); - let manifest = fixture("cargo-build.plugin.json"); + let manifest = fixture("build-static-file.plugin.json"); let options = json!({ "features": ["cli", "removed"] }); let detection = detect( &workspace, @@ -1192,7 +1193,7 @@ fn cargo_run_entries_drop_features_removed_from_the_model() { /// A changed default can select a different script without changing its argv template. #[test] fn build_option_defaults_require_install_review_again() { - let mut manifest = fixture("gradle-build.plugin.json"); + let mut manifest = fixture("build-command-output-file.plugin.json"); manifest["buildSystems"][0]["options"] .as_array_mut() .unwrap() @@ -1238,7 +1239,7 @@ fn static_models_and_sync_fingerprints_reject_escaping_symlinks() { let outside = TemporaryWorkspace::new("build-external-files"); outside.write("Cargo.toml", "[package]\nname = \"private\"\n"); symlink(outside.0.join("Cargo.toml"), workspace.0.join("Cargo.toml")).unwrap(); - let manifest = fixture("cargo-build.plugin.json"); + let manifest = fixture("build-static-file.plugin.json"); let decoded = call( "decodeModel", manifest.clone(), @@ -1299,7 +1300,7 @@ fn maven_models_do_not_read_poms_outside_the_workspace() { let outside = TemporaryWorkspace::new("build-external-maven"); outside.write("pom.xml", "4.0.0private-artifact9.9"); symlink(outside.0.join("pom.xml"), workspace.0.join("pom.xml")).unwrap(); - let manifest = fixture("maven-build.plugin.json"); + let manifest = fixture("build-host-integration.plugin.json"); let decoded = call( "decodeModel", manifest.clone(), @@ -1340,7 +1341,7 @@ fn junit_reports_do_not_follow_escaping_project_directories() { symlink(outside.0.join("app"), workspace.0.join("app")).unwrap(); let outcome = call( "decodeTestResults", - fixture("gradle-build.plugin.json"), + fixture("build-command-output-file.plugin.json"), "gradle", json!({ "workspaceRoot": workspace.root(), "root": ".", "project": "app", "output": "", @@ -1368,7 +1369,7 @@ fn reported_output_paths_reject_escaping_symlinks() { symlink(workspace.0.join("src"), workspace.0.join("alias")).unwrap(); symlink(workspace.0.join("src"), workspace.0.join("app/alias")).unwrap(); - let manifest = fixture("gradle-build.plugin.json"); + let manifest = fixture("build-command-output-file.plugin.json"); let diagnostics = |manifest: &Value, output: &str| { call( "decodeDiagnostics", @@ -1467,8 +1468,7 @@ fn launch_plans_name_exactly_one_process() { json!({}), json!({ "languageID": "elixir", "buildSystemID": "mix" }), ] { - let mut payload = - json!({ "manifest": fixture("mix-provider.plugin.json"), "request": request }); + let mut payload = json!({ "manifest": fixture("build-provider-language-server.plugin.json"), "request": request }); payload .as_object_mut() .unwrap() @@ -1489,7 +1489,7 @@ fn provider_models_are_not_read_during_detection() { workspace.write("mix.exs", "").write("apps/web/mix.exs", ""); let detection = detect( &workspace, - vec![fixture("mix-provider.plugin.json")], + vec![fixture("build-provider-language-server.plugin.json")], json!({}), ); assert_eq!(instance_ids(&detection), ["mix:."]); @@ -1504,7 +1504,7 @@ fn provider_models_are_not_read_during_detection() { #[test] fn task_categories_and_option_placements_are_display_only() { let workspace = TemporaryWorkspace::new("build-task-category"); - let mut gradle = fixture("gradle-build.plugin.json"); + let mut gradle = fixture("build-command-output-file.plugin.json"); let build_system = &mut gradle["buildSystems"][0]; build_system["icon"] = json!({ "path": "icons/gradle.svg" }); build_system["tasks"][0]["category"] = json!(["Tasks", "other"]); @@ -1592,3 +1592,195 @@ fn task_categories_and_option_placements_are_display_only() { assert_eq!(plan["status"], "ok", "{plan}"); assert!(plan.to_string().contains("\":run\""), "{plan}"); } + +/// A global executable selection is validated by the same settings rules as +/// launch settings. No filesystem access or per-tool branch belongs in Core. +#[test] +fn explicit_build_tool_path_requires_supported_global_file_setting() { + let manifest: Value = + serde_json::from_str(contract_file!("fixtures/build-tool-path.plugin.json")).unwrap(); + assert_eq!(validate(manifest.clone())["status"], "ok"); + let invoke = |settings: Value| { + call( + "invocationPlan", + manifest.clone(), + "fixture", + json!({ + "root": ".", "platform": "macos", "settings": settings + }), + ) + }; + assert_eq!( + invoke(json!({}))["invocation"]["executables"][0]["path"], + Value::Null + ); + assert_eq!( + invoke(json!({"toolPath": ""}))["invocation"]["executables"][0]["path"], + Value::Null + ); + assert_eq!( + invoke(json!({"toolPath": "/opt/custom tools/build-tool"}))["invocation"]["executables"][0] + ["path"], + "/opt/custom tools/build-tool" + ); + for value in [ + json!("relative"), + json!(42), + json!(null), + json!([]), + json!(" /bad"), + json!("/bad\npath"), + json!("/".repeat(1025)), + ] { + assert_eq!( + invoke(json!({"toolPath": value}))["status"], + "rejected", + "{value}" + ); + } + assert_eq!( + invoke(json!({"toolPath": "C:\\Tools\\build.exe"}))["status"], + "ok" + ); + for (field, value) in [ + ("scope", json!("project")), + ("type", json!("directory")), + ("effect", json!("restart")), + ] { + let mut invalid = manifest.clone(); + invalid["settings"][0][field] = value; + assert_eq!(validate(invalid)["status"], "rejected", "{field}"); + } + let mut old = manifest.clone(); + old["requires"] + .as_object_mut() + .unwrap() + .remove("executableResolution"); + assert_eq!(validate(old)["status"], "rejected"); + let mut missing = manifest; + missing["buildSystems"][0]["invocation"]["executables"][0]["settingID"] = json!("missing"); + assert_eq!(validate(missing)["status"], "rejected"); +} + +#[test] +fn executable_candidates_order_and_constraints_are_shared_and_deterministic() { + let base: Value = + serde_json::from_str(contract_file!("fixtures/build-tool-path.plugin.json")).unwrap(); + let invoke = |candidates: Value| { + let mut manifest = base.clone(); + manifest["buildSystems"][0]["invocation"]["executables"] = candidates; + call( + "invocationPlan", + manifest, + "fixture", + json!({"root":".","platform":"macos"}), + ) + }; + let result = invoke(json!([ + {"id":"a","kind":"command","command":"a"}, + {"id":"b","kind":"command","command":"b","priority":10}, + {"kind":"command","command":"c","insertBefore":"b"}, + {"kind":"command","command":"d","insertAfter":"a"} + ])); + assert_eq!(result["status"], "ok", "{result}"); + let commands: Vec<_> = result["invocation"]["executables"] + .as_array() + .unwrap() + .iter() + .map(|item| item["command"].as_str().unwrap()) + .collect(); + // Relative constraints are edges, not adjacency: ready-node ties retain + // array order, so a precedes c; c unlocks the higher-priority b. + assert_eq!(commands, ["a", "c", "b", "d"]); + for candidates in [ + json!([{"id":"a","kind":"command","command":"a"},{"id":"a","kind":"command","command":"b"}]), + json!([{"kind":"command","command":"a","insertBefore":"missing"}]), + json!([{"id":"a","kind":"command","command":"a","insertBefore":"a"}]), + json!([{"id":"a","kind":"command","command":"a","insertBefore":"b"},{"id":"b","kind":"command","command":"b","insertBefore":"a"}]), + json!([{"id":"a","kind":"command","command":"a","priority":2,"insertAfter":"a"}]), + json!([{"id":"a","kind":"command","command":"a","insertBefore":"a","insertAfter":"a"}]), + ] { + assert_eq!( + invoke(candidates.clone())["status"], + "rejected", + "{candidates}" + ); + } + let result = invoke(json!([ + {"kind":"command","command":"a","priority":1}, + {"kind":"command","command":"b","priority":9}, + {"kind":"command","command":"c","priority":9} + ])); + assert_eq!(result["invocation"]["executables"][0]["command"], "b"); + assert_eq!(result["invocation"]["executables"][1]["command"], "c"); +} + +#[test] +fn published_build_v2_paths_adapt_to_candidates_and_preserve_invalid_values() { + let mut manifest: Value = + serde_json::from_str(contract_file!("fixtures/build-tool-path.plugin.json")).unwrap(); + manifest["requires"] + .as_object_mut() + .unwrap() + .remove("executableResolution"); + manifest["buildSystems"][0]["invocation"]["executables"] + .as_array_mut() + .unwrap() + .remove(0); + manifest["requires"]["buildSystems"] = json!(2); + manifest["buildSystems"][0]["invocation"]["executableSetting"] = json!("toolPath"); + let run = |value| { + call( + "invocationPlan", + manifest.clone(), + "fixture", + json!({"root":".","platform":"macos","settings":{"toolPath":value}}), + ) + }; + let result = run(json!("/tools/custom build")); + assert_eq!(result["status"], "ok", "{result}"); + assert_eq!(result["invocation"]["executables"][0]["kind"], "setting"); + assert!(result["invocation"].get("configuredExecutable").is_none()); + assert_eq!(run(json!(false))["status"], "rejected"); + // The existing settings UI keeps its tolerant display behavior. + let displayed = crate::plugins::declarative::resolve_settings_command( + json!({"manifest":manifest,"settings":{"toolPath":false}}), + ) + .unwrap(); + assert_eq!(displayed["status"], "ok", "{displayed}"); + assert_eq!(displayed["settings"][0]["value"], "", "{displayed}"); +} + +#[test] +fn executable_resolution_is_opt_in_and_custom_runtimes_share_the_same_plan() { + let mut manifest: Value = + serde_json::from_str(contract_file!("fixtures/build-tool-path.plugin.json")).unwrap(); + let old_host = validate_command( + json!({"manifest":manifest,"hostCapabilities":{"buildSystems":[1,2],"settings":[1,2]}}), + ) + .unwrap(); + assert_eq!(old_host["error"]["code"], "plugin.api.unsupported"); + manifest["buildSystems"][0]["invocation"]["runtimeDependencies"] = json!([{ + "id":"custom", "executables":[ + {"id":"selected","kind":"setting","settingID":"toolPath"}, + {"kind":"command","command":"custom"} + ] + }]); + let result = call( + "invocationPlan", + manifest.clone(), + "fixture", + json!({"root":".","platform":"macos","settings":{"toolPath":"/custom runtime/tool"}}), + ); + assert_eq!(result["status"], "ok", "{result}"); + assert_eq!( + result["invocation"]["runtimeDependencies"][0]["executables"][0]["path"], + "/custom runtime/tool" + ); + // Anchors never escape into a different candidate collection. + manifest["buildSystems"][0]["invocation"]["executables"][1]["insertBefore"] = + json!("runtime-only"); + manifest["buildSystems"][0]["invocation"]["runtimeDependencies"][0]["executables"][0]["id"] = + json!("runtime-only"); + assert_eq!(validate(manifest)["status"], "rejected"); +} diff --git a/rust/lithe-core/src/tests/declarative_plugins.rs b/rust/lithe-core/src/tests/declarative_plugins.rs index 230b408bf3..c8629895f1 100644 --- a/rust/lithe-core/src/tests/declarative_plugins.rs +++ b/rust/lithe-core/src/tests/declarative_plugins.rs @@ -15,19 +15,21 @@ macro_rules! contract_file { }; } -const PHP_FIXTURE: &str = contract_file!("fixtures/php-support.plugin.json"); +const PHP_FIXTURE: &str = contract_file!("fixtures/language-packaged-server.plugin.json"); const HELLO_FIXTURE: &str = contract_file!("fixtures/hello-lithe.plugin.json"); const WEB_PANEL_FIXTURE: &str = contract_file!("fixtures/web-panel.plugin.json"); -const GO_FIXTURE: &str = contract_file!("fixtures/go-support.plugin.json"); -const JAVA_FIXTURE: &str = contract_file!("fixtures/java-support.plugin.json"); +const GO_FIXTURE: &str = contract_file!("fixtures/language-system-command.plugin.json"); +const JAVA_FIXTURE: &str = contract_file!("fixtures/language-host-integration.plugin.json"); const NODE_RUNTIME_FIXTURE: &str = contract_file!("fixtures/node-runtime.plugin.json"); -const CARGO_BUILD_FIXTURE: &str = contract_file!("fixtures/cargo-build.plugin.json"); -const GO_BUILD_FIXTURE: &str = contract_file!("fixtures/go-build.plugin.json"); -const GRADLE_BUILD_FIXTURE: &str = contract_file!("fixtures/gradle-build.plugin.json"); -const MAVEN_BUILD_FIXTURE: &str = contract_file!("fixtures/maven-build.plugin.json"); -const GRADLE_PROVIDER_FIXTURE: &str = contract_file!("fixtures/gradle-provider.plugin.json"); -const MIX_PROVIDER_FIXTURE: &str = contract_file!("fixtures/mix-provider.plugin.json"); +const CARGO_BUILD_FIXTURE: &str = contract_file!("fixtures/build-static-file.plugin.json"); +const GO_BUILD_FIXTURE: &str = contract_file!("fixtures/build-command-stdout.plugin.json"); +const GRADLE_BUILD_FIXTURE: &str = contract_file!("fixtures/build-command-output-file.plugin.json"); +const MAVEN_BUILD_FIXTURE: &str = contract_file!("fixtures/build-host-integration.plugin.json"); +const GRADLE_PROVIDER_FIXTURE: &str = contract_file!("fixtures/build-provider.plugin.json"); +const MIX_PROVIDER_FIXTURE: &str = + contract_file!("fixtures/build-provider-language-server.plugin.json"); const SETTINGS_V2_FIXTURE: &str = contract_file!("fixtures/settings-v2.plugin.json"); +const BACKEND_FIXTURE: &str = contract_file!("fixtures/backend.plugin.json"); const INVALID_MANIFESTS: &str = contract_file!("fixtures/invalid-manifests.json"); const COMMAND_PLANS: &str = contract_file!("fixtures/command-plans.json"); const RUNTIME_VERSIONS: &str = contract_file!("fixtures/runtime-versions.json"); @@ -38,19 +40,20 @@ const OFFICIAL_JAVA: &str = include_str!(concat!( fn fixture(name: &str) -> Value { let source = match name { - "php-support.plugin.json" => PHP_FIXTURE, - "go-support.plugin.json" => GO_FIXTURE, + "language-packaged-server.plugin.json" => PHP_FIXTURE, + "language-system-command.plugin.json" => GO_FIXTURE, "hello-lithe.plugin.json" => HELLO_FIXTURE, "web-panel.plugin.json" => WEB_PANEL_FIXTURE, - "java-support.plugin.json" => JAVA_FIXTURE, + "language-host-integration.plugin.json" => JAVA_FIXTURE, "node-runtime.plugin.json" => NODE_RUNTIME_FIXTURE, - "cargo-build.plugin.json" => CARGO_BUILD_FIXTURE, - "go-build.plugin.json" => GO_BUILD_FIXTURE, - "gradle-build.plugin.json" => GRADLE_BUILD_FIXTURE, - "maven-build.plugin.json" => MAVEN_BUILD_FIXTURE, - "gradle-provider.plugin.json" => GRADLE_PROVIDER_FIXTURE, - "mix-provider.plugin.json" => MIX_PROVIDER_FIXTURE, + "build-static-file.plugin.json" => CARGO_BUILD_FIXTURE, + "build-command-stdout.plugin.json" => GO_BUILD_FIXTURE, + "build-command-output-file.plugin.json" => GRADLE_BUILD_FIXTURE, + "build-host-integration.plugin.json" => MAVEN_BUILD_FIXTURE, + "build-provider.plugin.json" => GRADLE_PROVIDER_FIXTURE, + "build-provider-language-server.plugin.json" => MIX_PROVIDER_FIXTURE, "settings-v2.plugin.json" => SETTINGS_V2_FIXTURE, + "backend.plugin.json" => BACKEND_FIXTURE, other => panic!("unknown fixture {other}"), }; serde_json::from_str(source).expect("fixture manifest is JSON") @@ -69,6 +72,7 @@ fn contract_fixtures_and_official_plugins_validate() { WEB_PANEL_FIXTURE, JAVA_FIXTURE, NODE_RUNTIME_FIXTURE, + BACKEND_FIXTURE, OFFICIAL_JAVA, ] { let outcome = validate(serde_json::from_str(source).unwrap()); @@ -78,7 +82,7 @@ fn contract_fixtures_and_official_plugins_validate() { #[test] fn validation_normalizes_order_and_spelling_without_touching_arguments() { - let mut manifest = fixture("php-support.plugin.json"); + let mut manifest = fixture("language-packaged-server.plugin.json"); manifest["languages"][0]["fileExtensions"] = json!(["PHTML", ".php", "php"]); manifest["permissions"] = json!(["process.test", "process.run", "languageServer.launch"]); let outcome = validate(manifest); @@ -103,7 +107,7 @@ fn validation_normalizes_order_and_spelling_without_touching_arguments() { /// `.toolrc` keep matching after normalization. #[test] fn validation_keeps_the_leading_dot_of_hidden_file_names() { - let mut manifest = fixture("go-support.plugin.json"); + let mut manifest = fixture("language-system-command.plugin.json"); manifest["languages"][0]["fileExtensions"] = json!([".go"]); manifest["languages"][0]["fileNames"] = json!([".toolrc"]); manifest["languages"][0]["projectFileNames"] = json!(["go.mod", ".env.example"]); @@ -149,7 +153,7 @@ fn invalid_manifest_fixtures_report_their_stable_codes() { /// feature that integration has, with its files, and with no second owner. #[test] fn host_integration_capabilities_are_normalized_and_checked_against_the_integration() { - let mut manifest = fixture("java-support.plugin.json"); + let mut manifest = fixture("language-host-integration.plugin.json"); let capabilities = "/languageServers/0/hostIntegration/capabilities"; *manifest.pointer_mut(capabilities).unwrap() = json!(["testing", "debug", "testing"]); let outcome = validate(manifest.clone()); @@ -178,12 +182,12 @@ fn host_integration_capabilities_are_normalized_and_checked_against_the_integrat }; let field = "languageServers[0].hostIntegration.capabilities"; - let mut unknown = fixture("java-support.plugin.json"); + let mut unknown = fixture("language-host-integration.plugin.json"); *unknown.pointer_mut(capabilities).unwrap() = json!(["formatting"]); assert_eq!(rejected_field(unknown), field); // Debugging loads the debugger as a JDT LS extension bundle. - let mut without_bundles = fixture("java-support.plugin.json"); + let mut without_bundles = fixture("language-host-integration.plugin.json"); *without_bundles.pointer_mut(capabilities).unwrap() = json!(["debug"]); let resources = "/languageServers/0/hostIntegration/resources"; let resources = without_bundles.pointer_mut(resources).unwrap(); @@ -193,7 +197,7 @@ fn host_integration_capabilities_are_normalized_and_checked_against_the_integrat .remove("extensionBundleDirectories"); assert_eq!(rejected_field(without_bundles), field); - let mut owned_twice = fixture("java-support.plugin.json"); + let mut owned_twice = fixture("language-host-integration.plugin.json"); owned_twice["permissions"] = json!(["languageServer.launch", "process.run"]); owned_twice["activationEvents"] = json!(["onLanguage:java", "onRun:java"]); owned_twice["executionProviders"] = json!([{ @@ -248,7 +252,7 @@ fn command_plan_fixtures_match_golden_expansions() { /// would change the command line of an installed plugin. #[test] fn api_level_1_language_server_arguments_stay_literal() { - let mut manifest = fixture("php-support.plugin.json"); + let mut manifest = fixture("language-packaged-server.plugin.json"); manifest["languageServers"][0]["arguments"] = json!(["--stdio", "--log=${pluginDir}"]); assert_eq!(validate(manifest.clone())["status"], "ok"); let outcome = language_server_launch_plan_command(json!({ @@ -274,7 +278,7 @@ fn api_level_1_language_server_arguments_stay_literal() { #[test] fn runtime_launch_and_initialization_options_are_reviewed_programs() { let outcome = crate::plugins::marketplace::install_review_command(json!({ - "manifest": fixture("java-support.plugin.json"), + "manifest": fixture("language-host-integration.plugin.json"), })) .expect("payload is well formed"); let programs = outcome["review"]["programs"].as_array().unwrap(); @@ -327,7 +331,7 @@ fn runtime_version_fixtures_match_golden_outcomes() { /// so it cannot depend on one. #[test] fn validation_probe_cannot_use_settings() { - let mut manifest = fixture("go-support.plugin.json"); + let mut manifest = fixture("language-system-command.plugin.json"); manifest["pluginAPIVersion"] = json!(2); manifest["settings"] = json!([ { "id": "verbose", "type": "boolean", "title": "Verbose", "default": false } @@ -370,14 +374,14 @@ fn level_3_manifests_list_exactly_the_capabilities_they_use() { outcome["error"]["field"].as_str().unwrap().to_string(), ) }; - let mix = fixture("mix-provider.plugin.json"); + let mix = fixture("build-provider-language-server.plugin.json"); let outcome = validate(mix.clone()); assert_eq!(outcome["status"], "ok", "{outcome}"); assert_eq!( outcome["plugin"]["requires"], json!({ "buildProviders": 1, "buildSystems": 1, "languageServers": 1, "languages": 1, "serverProcess": 1 }) ); - let legacy = validate(fixture("php-support.plugin.json")); + let legacy = validate(fixture("language-packaged-server.plugin.json")); assert!(legacy["plugin"].get("requires").is_none(), "{legacy}"); let mut future = mix.clone(); @@ -404,7 +408,7 @@ fn level_3_manifests_list_exactly_the_capabilities_they_use() { ); // A level 3 plugin without build systems never depends on them. - let mut language_only = fixture("php-support.plugin.json"); + let mut language_only = fixture("language-packaged-server.plugin.json"); language_only["pluginAPIVersion"] = json!(3); language_only["requires"] = json!({ "executionProviders": 1, "languageServers": 1, "languages": 1, "serverProcess": 1 @@ -433,7 +437,7 @@ fn level_3_manifests_list_exactly_the_capabilities_they_use() { assert_eq!(validate_on(&mix, &host_with_builds)["status"], "ok"); // Capability versions do not apply below level 3. assert_eq!( - validate_on(&fixture("php-support.plugin.json"), &json!({}))["status"], + validate_on(&fixture("language-packaged-server.plugin.json"), &json!({}))["status"], "ok" ); } @@ -447,14 +451,14 @@ fn build_providers_require_their_own_host_capability() { "serverProcess": [1], "settings": [1] }); let template = validate_command(json!({ - "manifest": fixture("cargo-build.plugin.json"), "hostCapabilities": host + "manifest": fixture("build-static-file.plugin.json"), "hostCapabilities": host })) .unwrap(); assert_eq!(template["status"], "ok", "{template}"); for (name, build_system_id) in [ - ("gradle-provider.plugin.json", "gradle"), - ("mix-provider.plugin.json", "mix"), + ("build-provider.plugin.json", "gradle"), + ("build-provider-language-server.plugin.json", "mix"), ] { let manifest = fixture(name); for versions in [None, Some(json!([2])), Some(json!([1]))] { @@ -604,6 +608,96 @@ fn web_tool_windows_need_ui_2_their_permission_and_a_package_page() { } } +/// A plugin backend is a reviewed server process of the plugin itself. It is +/// planned without a workspace, its plan names exactly the host services its +/// permissions grant, and a host that does not run `backend` never gets it. +#[test] +fn plugin_backend_is_planned_without_a_workspace_and_names_its_host_services() { + let outcome = validate(fixture("backend.plugin.json")); + assert_eq!(outcome["status"], "ok", "{outcome}"); + assert_eq!( + outcome["plugin"]["backend"]["launch"], + json!({ "kind": "packaged", "path": "bin/backend" }) + ); + + let plan_for = |manifest: Value, request: Value| { + language_server_launch_plan_command(json!({ + "manifest": manifest, + "backend": true, + "request": request, + })) + .expect("payload is well formed") + }; + let request = json!({ + "pluginDirectory": "/plugins/backend", + "stateDirectory": "/state/backend", + "architecture": "arm64" + }); + let launch = plan_for(fixture("backend.plugin.json"), request.clone()); + assert_eq!(launch["status"], "ok", "{launch}"); + assert_eq!( + launch["plan"]["launch"], + json!({ "kind": "packaged", "path": "bin/backend" }) + ); + assert_eq!( + launch["plan"]["arguments"], + json!(["--state", "/state/backend"]) + ); + assert_eq!(launch["plan"]["requiredDirectories"], json!(["state"])); + assert_eq!( + launch["plan"]["backend"], + json!({ "hostServices": ["credentials"] }) + ); + + // Without credentials.store the session may request no host service. + let mut manifest = fixture("backend.plugin.json"); + manifest["permissions"] = json!(["backend.launch", "ui.contribute", "ui.web"]); + let launch = plan_for(manifest, request.clone()); + assert_eq!(launch["plan"]["backend"], json!({ "hostServices": [] })); + + // A plugin without a backend has nothing to start. + let launch = plan_for(fixture("web-panel.plugin.json"), request.clone()); + assert_eq!(launch["error"]["code"], "plugin.launch.unavailable"); + + // A selection names one process only. + assert!(language_server_launch_plan_command(json!({ + "manifest": fixture("backend.plugin.json"), + "backend": true, + "languageID": "php", + "request": request, + })) + .is_err()); + + let review = crate::plugins::marketplace::install_review_command(json!({ + "manifest": fixture("backend.plugin.json"), + })) + .expect("payload is well formed"); + assert!(review["review"]["programs"] + .as_array() + .unwrap() + .contains(&json!({ + "kind": "packagedBackend", + "path": "bin/backend", + "arguments": ["--state", "${stateDir}"], + "validationArguments": [], + }))); + + let hosted = |host: Value| { + validate_command(json!({ + "manifest": fixture("backend.plugin.json"), + "hostCapabilities": host, + })) + .expect("payload is well formed") + }; + let without_backend = hosted(json!({ "ui": [1, 2], "serverProcess": [1] })); + assert_eq!(without_backend["error"]["code"], "plugin.api.unsupported"); + assert_eq!(without_backend["error"]["field"], "requires.backend"); + assert_eq!( + hosted(json!({ "backend": [1], "ui": [1, 2], "serverProcess": [1] }))["status"], + "ok" + ); +} + /// Icon trust boundaries are shared; native decoding remains with the macOS host. #[test] fn ui_icon_declarations_validate_and_preserve_theme_resources() { @@ -648,6 +742,246 @@ fn ui_icon_declarations_validate_and_preserve_theme_resources() { } } +/// Validate the shipped acceptance plugin through the same boundary as installation. +#[test] +fn native_ui_lab_package_manifest_is_installable() { + let manifest: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../macos/Tests/Fixtures/native-ui-lab/plugin.json" + ))) + .unwrap(); + let result = validate(manifest); + assert_eq!(result["status"], "ok", "{result}"); +} + +/// Native controls are opt-in; legacy hosts reject them before presentation. +#[test] +fn native_ui_validates_version_ownership_actions_and_budgets() { + let manifest: Value = + serde_json::from_str(contract_file!("fixtures/native-panel.plugin.json")).unwrap(); + let result = validate(manifest.clone()); + assert_eq!(result["status"], "ok", "{result}"); + for icon in [json!({"host":"search"}), json!({"host":"reset"})] { + let mut with_icon = manifest.clone(); + with_icon["ui"]["toolWindows"][0]["native"]["actions"][1]["icon"] = icon; + assert_eq!(validate(with_icon)["status"], "ok"); + } + for icon in [ + json!({"host":"unknown"}), + json!({"path":"icons/custom.svg"}), + json!({"host":"reset","mode":"original"}), + ] { + let mut invalid = manifest.clone(); + invalid["ui"]["toolWindows"][0]["native"]["actions"][1]["icon"] = icon; + assert_eq!(validate(invalid)["status"], "rejected"); + } + let old_host = validate_command(json!({"manifest": manifest, "hostCapabilities": {"ui":[1,2], "buildSystems":[1], "buildProviders":[1], "serverProcess":[1]}})).unwrap(); + assert_eq!(old_host["error"]["code"], "plugin.api.unsupported"); + for (pointer, value) in [ + ("/ui/toolWindows/0/native/schemaVersion", json!(2)), + ( + "/ui/toolWindows/0/native/source/buildSystemID", + json!("foreign"), + ), + ("/ui/toolWindows/0/native/toolbar", json!(["missing"])), + ( + "/ui/toolWindows/0/native/contextMenu", + json!(["inspect", "inspect"]), + ), + ( + "/ui/toolWindows/0/native/nodes", + json!([{"id":"same","label":"A"},{"id":"same","label":"B"}]), + ), + ( + "/ui/toolWindows/0/native/nodes", + json!(vec![json!({"id":"row","label":"x"}); 2001]), + ), + ("/requires/ui", json!(2)), + ] { + let mut invalid = manifest.clone(); + *invalid.pointer_mut(pointer).unwrap() = value; + assert_eq!(validate(invalid)["status"], "rejected", "{pointer}"); + } + let mut styled = manifest.clone(); + styled["ui"]["toolWindows"][0]["native"]["color"] = json!("red"); + assert_eq!(validate(styled)["status"], "rejected"); + let mut mixed = manifest; + mixed["ui"]["toolWindows"][0]["text"] = json!("cannot mix body types"); + assert_eq!(validate(mixed)["status"], "rejected"); +} + +#[test] +fn native_ui_forms_validate_controls_values_actions_and_budgets() { + let manifest: Value = + serde_json::from_str(contract_file!("fixtures/native-form.plugin.json")).unwrap(); + assert_eq!(validate(manifest.clone())["status"], "ok"); + for (pointer, value) in [ + ("/ui/toolWindows/0/native/kind", json!("list")), + ( + "/ui/toolWindows/0/native/nodes", + json!([{"id":"row","label":"Row"}]), + ), + ("/ui/toolWindows/0/native/controls", json!([])), + ( + "/ui/toolWindows/0/native/controls", + json!(vec![ + json!({"id":"field","kind":"input","label":"Field","value":""}); + 33 + ]), + ), + ("/ui/toolWindows/0/native/controls/0/kind", json!("html")), + ("/ui/toolWindows/0/native/controls/0/id", json!("form")), + ("/ui/toolWindows/0/native/controls/0/value", json!(false)), + ( + "/ui/toolWindows/0/native/controls/0/value", + json!("😀".repeat(257)), + ), + ( + "/ui/toolWindows/0/native/controls/2/value", + json!("missing"), + ), + ("/ui/toolWindows/0/native/controls/2/options", json!([])), + ( + "/ui/toolWindows/0/native/controls/2/options", + json!([{"id":"same","label":"A"},{"id":"same","label":"B"}]), + ), + ( + "/ui/toolWindows/0/native/controls/2/options", + json!(vec![json!({"id":"option","label":"Option"}); 129]), + ), + ("/ui/toolWindows/0/native/controls/3/value", json!("true")), + ] { + let mut invalid = manifest.clone(); + *invalid.pointer_mut(pointer).unwrap() = value; + assert_eq!(validate(invalid)["status"], "rejected", "{pointer}"); + } + for (key, value) in [ + ("onCommit", json!("missing")), + ("options", json!([])), + ("color", json!("red")), + ] { + let mut invalid = manifest.clone(); + invalid["ui"]["toolWindows"][0]["native"]["controls"][0][key] = value; + assert_eq!(validate(invalid)["status"], "rejected", "{key}"); + } + let mut dynamic = manifest; + let native = &mut dynamic["ui"]["toolWindows"][0]["native"]; + native["source"] = json!({"buildSystemID":"sample","function":"form.nodes"}); + native["actions"] = json!([{"id":"changed","title":"Changed","kind":"invoke","function":"form.changed","requiresSelection":false}]); + native["controls"][0]["onCommit"] = json!("changed"); + assert_eq!(validate(dynamic.clone())["status"], "ok"); + dynamic["ui"]["toolWindows"][0]["native"]["actions"][0]["requiresSelection"] = json!(true); + assert_eq!(validate(dynamic)["status"], "rejected"); +} + +#[test] +fn native_ui_form_grid_validates_columns_spans_and_scope() { + let manifest: Value = + serde_json::from_str(contract_file!("fixtures/native-form.plugin.json")).unwrap(); + assert_eq!(validate(manifest.clone())["status"], "ok"); + for (pointer, value) in [ + ("/ui/toolWindows/0/native/layout/columns", json!(0)), + ("/ui/toolWindows/0/native/layout/columns", json!(13)), + ("/ui/toolWindows/0/native/layout/columns", json!(1.5)), + ("/ui/toolWindows/0/native/controls/0/span", json!(0)), + ("/ui/toolWindows/0/native/controls/0/span", json!(13)), + ("/ui/toolWindows/0/native/controls/0/span", json!("6")), + ] { + let mut invalid = manifest.clone(); + *invalid.pointer_mut(pointer).unwrap() = value; + assert_eq!(validate(invalid)["status"], "rejected", "{pointer}"); + } + let mut smaller_grid = manifest.clone(); + smaller_grid["ui"]["toolWindows"][0]["native"]["layout"]["columns"] = json!(4); + assert_eq!(validate(smaller_grid)["status"], "rejected"); + let mut missing_grid = manifest.clone(); + missing_grid["ui"]["toolWindows"][0]["native"] + .as_object_mut() + .unwrap() + .remove("layout"); + assert_eq!(validate(missing_grid.clone())["status"], "rejected"); + for control in missing_grid["ui"]["toolWindows"][0]["native"]["controls"] + .as_array_mut() + .unwrap() + { + control.as_object_mut().unwrap().remove("span"); + } + assert_eq!(validate(missing_grid)["status"], "ok"); + let mut styled = manifest; + styled["ui"]["toolWindows"][0]["native"]["layout"]["gap"] = json!(99); + assert_eq!(validate(styled)["status"], "rejected"); + let mut tree: Value = + serde_json::from_str(contract_file!("fixtures/native-panel.plugin.json")).unwrap(); + tree["ui"]["toolWindows"][0]["native"]["layout"] = json!({"columns":12}); + assert_eq!(validate(tree)["status"], "rejected"); +} + +#[test] +fn native_public_controls_validate_types_ranges_info_and_buttons() { + let manifest: Value = + serde_json::from_str(contract_file!("fixtures/native-controls.plugin.json")).unwrap(); + assert_eq!(validate(manifest.clone())["status"], "ok"); + for (pointer, value) in [ + ("/ui/toolWindows/0/native/controls/0/info", json!(" ")), + ( + "/ui/toolWindows/0/native/controls/0/info", + json!("x".repeat(1025)), + ), + ("/ui/toolWindows/0/native/controls/8/value", json!(true)), + ( + "/ui/toolWindows/0/native/controls/9/value", + json!("😀".repeat(2049)), + ), + ("/ui/toolWindows/0/native/controls/9/rows", json!(13)), + ( + "/ui/toolWindows/0/native/controls/10/value", + json!("unknown"), + ), + ( + "/ui/toolWindows/0/native/controls/10/options", + json!(vec![json!({"id":"option","label":"Option"}); 9]), + ), + ("/ui/toolWindows/0/native/controls/11/value", json!("2")), + ("/ui/toolWindows/0/native/controls/11/value", json!(9)), + ("/ui/toolWindows/0/native/controls/11/minimum", json!(10)), + ("/ui/toolWindows/0/native/controls/11/step", json!(0)), + ("/ui/toolWindows/0/native/buttons/0/id", json!("name")), + ( + "/ui/toolWindows/0/native/buttons/0/action", + json!("unknown"), + ), + ("/ui/toolWindows/0/native/buttons/0/width", json!(200)), + ("/ui/toolWindows/0/native/buttons/0/span", json!(13)), + ( + "/ui/toolWindows/0/native/buttons", + json!(vec![ + json!({"id":"button","label":"Button","action":"changed"}); + 17 + ]), + ), + ] { + let mut invalid = manifest.clone(); + *invalid.pointer_mut(pointer).unwrap() = value; + assert_eq!(validate(invalid)["status"], "rejected", "{pointer}"); + } + for (index, key, value) in [ + (0, "minimum", json!(0)), + (0, "rows", json!(2)), + (9, "onCommit", json!("changed")), + (11, "placeholder", json!("hint")), + ] { + let mut invalid = manifest.clone(); + invalid["ui"]["toolWindows"][0]["native"]["controls"][index][key] = value; + assert_eq!(validate(invalid)["status"], "rejected"); + } + let mut styled = manifest.clone(); + styled["ui"]["toolWindows"][0]["native"]["buttons"][0]["color"] = json!("red"); + assert_eq!(validate(styled)["status"], "rejected"); + let mut button_only = manifest; + button_only["ui"]["toolWindows"][0]["native"]["controls"] = json!([]); + assert_eq!(validate(button_only)["status"], "ok"); +} + /// A level 3 manifest using settings v2: scoped, page-placed settings with an /// `immediate` one that only the server's `workspaceConfiguration` may read. /// The shared settings v2 fixture: project and immediate settings on a diff --git a/rust/lithe-core/src/tests/file_name_order.rs b/rust/lithe-core/src/tests/file_name_order.rs new file mode 100644 index 0000000000..165a0cbfba --- /dev/null +++ b/rust/lithe-core/src/tests/file_name_order.rs @@ -0,0 +1,196 @@ +use super::support::temporary_root; +use crate::execute_json; +use crate::project::file_name_order::compare_file_names; +use serde_json::Value; +use std::cmp::Ordering; +use std::fs; + +fn fixture() -> Value { + serde_json::from_str(include_str!( + "../../../../shared/fixtures/workspace/file-name-order-v1.json" + )) + .expect("file-name order fixture should be JSON") +} + +#[test] +fn shared_fixture_groups_sort_strictly_in_the_listed_order() { + let fixture = fixture(); + for group in fixture["orderedGroups"].as_array().unwrap() { + let names = group["names"] + .as_array() + .unwrap() + .iter() + .map(|name| name.as_str().unwrap()) + .collect::>(); + for (index, earlier) in names.iter().enumerate() { + for later in &names[index + 1..] { + assert_eq!( + compare_file_names(earlier, later), + Ordering::Less, + "{}: {earlier:?} should precede {later:?}", + group["id"] + ); + assert_eq!( + compare_file_names(later, earlier), + Ordering::Greater, + "{}: {later:?} should follow {earlier:?}", + group["id"] + ); + } + } + } +} + +// Rust's sort may panic on an inconsistent order, so the port must stay a total +// preorder even for the characters upstream treats specially: spaces, zeroes, +// digits, '#', '-', '_' and mixed-case letters. The generator is a fixed LCG, +// so a failure reproduces exactly. +#[test] +fn comparison_is_antisymmetric_and_transitive_for_special_characters() { + const ALPHABET: [char; 11] = [' ', '0', '1', '2', 'a', 'A', 'b', '-', '_', '#', '.']; + let mut state = 0x2545_F491_4F6C_DD1Du64; + let mut next = |bound: usize| { + state = state + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); + ((state >> 33) as usize) % bound + }; + let names = (0..72) + .map(|_| { + let length = next(6); + (0..length) + .map(|_| ALPHABET[next(ALPHABET.len())]) + .collect::() + }) + .collect::>(); + + for left in &names { + for right in &names { + assert_eq!( + compare_file_names(left, right), + compare_file_names(right, left).reverse(), + "{left:?} against {right:?}" + ); + } + } + for first in &names { + for second in &names { + if compare_file_names(first, second) == Ordering::Greater { + continue; + } + for third in &names { + if compare_file_names(second, third) != Ordering::Greater { + assert_ne!( + compare_file_names(first, third), + Ordering::Greater, + "{first:?} <= {second:?} <= {third:?}" + ); + } + } + } + } +} + +#[test] +fn list_directories_orders_versioned_names_by_number_with_directories_first() { + let root = temporary_root("natural-directory-order"); + for name in [ + "v0.1.12.md", + "v0.1.4.md", + "v0.1.14.md", + "TEMPLATE.md", + "v0.1.13.md", + "v0.2.0.md", + ] { + fs::create_dir_all(&root).unwrap(); + fs::write(root.join(name), "").unwrap(); + } + fs::create_dir_all(root.join("zeta/part10")).unwrap(); + fs::create_dir_all(root.join("zeta/part2")).unwrap(); + fs::create_dir_all(root.join("Alpha")).unwrap(); + + let response: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "natural-directory-order", + "command": "workspace.listDirectories", + "payload": {"root": root, "paths": ["", "zeta"]} + }) + .to_string(), + )) + .expect("core response should be JSON"); + let removed = fs::remove_dir_all(&root); + assert_eq!(response["ok"], true, "{response}"); + removed.expect("temporary fixture should be removable"); + + let names = |index: usize| { + response["data"]["directories"][index]["children"] + .as_array() + .unwrap() + .iter() + .map(|child| child["name"].as_str().unwrap().to_string()) + .collect::>() + }; + assert_eq!( + names(0), + [ + "Alpha", + "zeta", + "TEMPLATE.md", + "v0.1.4.md", + "v0.1.12.md", + "v0.1.13.md", + "v0.1.14.md", + "v0.2.0.md", + ] + ); + assert_eq!(names(1), ["part2", "part10"]); +} + +/// The snapshot `files` sequence is not a display order: Windows Quick Open keeps the +/// first 100 matches of it, so the natural tree order must not leak into it. +#[test] +fn workspace_snapshot_keeps_its_traversal_order_while_the_tree_rows_are_natural() { + let root = temporary_root("snapshot-traversal-order"); + fs::create_dir_all(&root).unwrap(); + for name in ["v0.1.12.md", "v0.1.4.md", "v0.1.14.md", "TEMPLATE.md"] { + fs::write(root.join(name), "").unwrap(); + } + for part in ["part10", "part2"] { + fs::create_dir_all(root.join("zeta").join(part)).unwrap(); + fs::write(root.join("zeta").join(part).join("a.md"), "").unwrap(); + } + + let files = |include_tree: bool| { + let response: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "snapshot-traversal-order", + "command": "workspace.snapshot", + "payload": {"root": root, "includeTree": include_tree} + }) + .to_string(), + )) + .expect("core response should be JSON"); + assert_eq!(response["ok"], true, "{response}"); + response["data"]["files"] + .as_array() + .unwrap() + .iter() + .map(|file| file.as_str().unwrap().to_string()) + .collect::>() + }; + let with_tree = files(true); + let without_tree = files(false); + let removed = fs::remove_dir_all(&root); + removed.expect("temporary fixture should be removable"); + + let expected = [ + "zeta/part10/a.md", + "zeta/part2/a.md", + "TEMPLATE.md", + "v0.1.12.md", + "v0.1.14.md", + "v0.1.4.md", + ]; + assert_eq!(with_tree, expected); + assert_eq!(without_tree, expected); +} diff --git a/rust/lithe-core/src/tests/git_untracked_directories.rs b/rust/lithe-core/src/tests/git_untracked_directories.rs new file mode 100644 index 0000000000..f253d8b3cf --- /dev/null +++ b/rust/lithe-core/src/tests/git_untracked_directories.rs @@ -0,0 +1,268 @@ +use super::support::temporary_root; +use crate::execute_json; +use crate::git::UNTRACKED_DIRECTORY_SUMMARY_THRESHOLD; +use serde_json::{json, Value}; +use std::{ + fs, + path::{Path, PathBuf}, + process::Command, +}; + +// Real Git runs through Core's bounded process owner; Drop also cleans the +// fixture when an assertion fails. +struct Repository(PathBuf); + +impl Repository { + fn new(label: &str) -> Self { + let root = temporary_root(label); + fs::create_dir_all(&root).unwrap(); + let repository = Self(root); + repository.git(&["init", "--template=", "-q", "-b", "main"]); + repository.git(&["config", "core.autocrlf", "false"]); + repository.write("tracked/kept.txt", "kept\n"); + repository.write(".gitignore", "*.log\n"); + repository.git(&["add", "."]); + repository.git(&[ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "-qm", + "initial", + ]); + repository + } + + fn write(&self, path: &str, contents: &str) { + let path = self.0.join(path); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, contents).unwrap(); + } + + /// Writes `count` files spread over two directory levels below `directory`. + fn write_many(&self, directory: &str, count: usize) { + for index in 0..count { + self.write(&format!("{directory}/deps/{index}.o"), "object\n"); + } + } + + fn nested_repository(&self, path: &str) { + let root = self.0.join(path); + fs::create_dir_all(&root).unwrap(); + let output = Command::new("git") + .args(["init", "--template=", "-q"]) + .current_dir(&root) + .output() + .unwrap(); + assert!(output.status.success()); + fs::write(root.join("inner.txt"), "inner\n").unwrap(); + } + + fn git(&self, arguments: &[&str]) { + let result = data( + "git.command", + json!({"root": self.0, "arguments": arguments}), + ); + assert_eq!(result["exitCode"], 0, "{result}"); + } +} + +impl Drop for Repository { + fn drop(&mut self) { + if let Err(error) = fs::remove_dir_all(&self.0) { + eprintln!("Could not remove untracked directory fixture: {error}"); + } + } +} + +fn data(command: &str, payload: Value) -> Value { + let response: Value = serde_json::from_str(&execute_json( + &json!({"command": command, "timeoutMilliseconds": 10_000, "payload": payload}).to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], true, "{response}"); + response["data"].clone() +} + +/// Returns `(path, untrackedFileCount)` for every reported change. +fn entries(changes: &Value) -> Vec<(String, Option)> { + changes + .as_array() + .unwrap() + .iter() + .map(|change| { + ( + change["path"].as_str().unwrap().to_string(), + change["untrackedFileCount"].as_u64(), + ) + }) + .collect() +} + +fn status(root: &Path, summarize: bool) -> Value { + data( + "git.status", + json!({"root": root, "summarizeUntrackedDirectories": summarize}), + ) +} + +#[test] +fn status_summarizes_large_untracked_directories_and_keeps_small_ones_file_level() { + // Regression for #1385: one unignored build directory produced one status + // entry per artifact and flooded the changes panel. + let repository = Repository::new("untracked-summary"); + let large = UNTRACKED_DIRECTORY_SUMMARY_THRESHOLD + 1; + repository.write_many("rust/core/target", large); + repository.write("rust/core/target/build.log", "ignored\n"); + repository.write("src/feature/a.rs", "a\n"); + repository.write("src/feature/b.rs", "b\n"); + repository.write("tracked/new.txt", "new\n"); + + assert_eq!( + entries(&status(&repository.0, true)["changes"]), + [ + ("rust/".to_string(), Some(large as u64)), + ("src/feature/a.rs".to_string(), None), + ("src/feature/b.rs".to_string(), None), + ("tracked/new.txt".to_string(), None), + ] + ); + // Omitting the flag keeps the file-level projection Windows consumes. + let legacy = status(&repository.0, false); + assert_eq!(legacy["changes"].as_array().unwrap().len(), large + 3); + assert!(legacy["changes"][0].get("untrackedFileCount").is_none()); +} + +#[test] +fn status_never_summarizes_a_directory_containing_a_nested_repository() { + // Staging `.worktrees/` as a unit would record linked checkouts as gitlinks. + let repository = Repository::new("untracked-nested"); + repository.nested_repository(".worktrees/feature"); + repository.write_many( + ".worktrees/cache", + UNTRACKED_DIRECTORY_SUMMARY_THRESHOLD + 1, + ); + repository.write(".worktrees/notes.txt", "notes\n"); + + let summary = status(&repository.0, true); + assert_eq!( + entries(&summary["changes"]), + [ + ( + ".worktrees/cache/".to_string(), + Some(UNTRACKED_DIRECTORY_SUMMARY_THRESHOLD as u64 + 1) + ), + (".worktrees/feature/".to_string(), None), + (".worktrees/notes.txt".to_string(), None), + ] + ); + + // A discovered root is excluded exactly as in the file-level projection. + let with_roots = data( + "git.status", + json!({ + "root": repository.0, + "summarizeUntrackedDirectories": true, + "repositoryRoots": [repository.0, repository.0.join(".worktrees/feature")], + }), + ); + assert!(!entries(&with_roots["changes"]) + .iter() + .any(|(path, _)| path == ".worktrees/feature/")); +} + +#[test] +fn untracked_directory_lists_one_level_with_subdirectory_summaries() { + let repository = Repository::new("untracked-children"); + repository.write_many("target/debug", 3); + repository.write("target/.rustc_info.json", "{}\n"); + repository.write("target/skip.log", "ignored\n"); + + let children = data( + "git.untrackedDirectory", + json!({"root": repository.0, "directory": "target/"}), + ); + assert_eq!( + entries(&children["changes"]), + [ + ("target/.rustc_info.json".to_string(), None), + ("target/debug/".to_string(), Some(3)), + ] + ); + let child = &children["changes"][1]; + assert_eq!(child["status"], "??"); + assert_eq!(child["untracked"], true); + assert_eq!(child["canToggleStaging"], true); + + let invalid: Value = serde_json::from_str(&execute_json( + &json!({ + "command": "git.untrackedDirectory", + "payload": {"root": repository.0, "directory": "../outside/"} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(invalid["ok"], false, "{invalid}"); +} + +#[test] +fn summarized_directory_supports_diff_stage_and_discard_as_a_unit() { + let repository = Repository::new("untracked-actions"); + repository.write("generated/a.txt", "alpha\n"); + repository.write("generated/nested/b.txt", "beta\n"); + + // Shelve captures the directory's patch before cleaning it; an empty patch + // here would silently lose the files. + let diff = data( + "git.diff", + json!({"root": repository.0, "pathspecs": ["generated/"], "untracked": true}), + ); + let patch = diff["patch"].as_str().unwrap(); + assert!(patch.contains("+alpha"), "{patch}"); + assert!(patch.contains("+beta"), "{patch}"); + + let discarded = data( + "git.write", + json!({"root": repository.0, "operation": "discardAll", "paths": ["generated/"]}), + ); + assert_eq!(discarded["exitCode"], 0, "{discarded}"); + assert!(!repository.0.join("generated/a.txt").exists()); + assert!(!repository.0.join("generated/nested/b.txt").exists()); + + repository.write("generated/c.txt", "gamma\n"); + let staged = data( + "git.write", + json!({"root": repository.0, "operation": "stage", "paths": ["generated/"]}), + ); + assert_eq!(staged["exitCode"], 0, "{staged}"); + assert_eq!( + entries(&status(&repository.0, true)["changes"]), + [("generated/c.txt".to_string(), None)] + ); +} + +// `:` cannot appear in a Windows file name. +#[cfg(not(windows))] +#[test] +fn summarized_directory_diff_fails_instead_of_dropping_a_file_it_cannot_export() { + let repository = Repository::new("untracked-unexportable"); + repository.write("generated/a.txt", "alpha\n"); + repository.write("generated/bad:name.txt", "kept\n"); + + // Shelve would save the patch and then clean `generated/`, so a patch + // without `bad:name.txt` would delete that file without a copy. + let response: Value = serde_json::from_str(&execute_json( + &json!({ + "command": "git.diff", + "timeoutMilliseconds": 10_000, + "payload": {"root": repository.0, "pathspecs": ["generated/"], "untracked": true}, + }) + .to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], false, "{response}"); + assert!(repository.0.join("generated/bad:name.txt").exists()); +} diff --git a/rust/lithe-core/src/tests/git_workspace_commit.rs b/rust/lithe-core/src/tests/git_workspace_commit.rs index c54d995ec9..02f80cea97 100644 --- a/rust/lithe-core/src/tests/git_workspace_commit.rs +++ b/rust/lithe-core/src/tests/git_workspace_commit.rs @@ -588,3 +588,351 @@ fn git_workspace_changelist_guard_catches_a_rename_after_the_ui_snapshot() { assert_eq!(result["ok"], false, "{result}"); assert_eq!(state(&repo.0), before); } + +/// Bare `origin` that a divergent-upstream scenario publishes to. +fn bare_remote(name: &str) -> Repository { + let remote = Repository(temporary_root(name)); + fs::create_dir_all(&remote.0).unwrap(); + git(&remote.0, &["init", "--bare", "-q"]); + remote +} + +/// Leaves `repo` on `feature/topic`, whose upstream is `origin/` even +/// though the names differ. Git produces this state when a branch is started from +/// a remote branch, which is how issue #1272 reaches the push. +fn divergent_feature_branch(repo: &Path, remote: &Path, upstream: &str) { + fs::write(repo.join("base.txt"), "base").unwrap(); + git(repo, &["add", "."]); + git(repo, &["commit", "-qm", "base"]); + git(repo, &["branch", "-M", "main"]); + git(repo, &["remote", "add", "origin", remote.to_str().unwrap()]); + git( + repo, + &[ + "push", + "-q", + "origin", + &format!("main:refs/heads/{upstream}"), + ], + ); + git(repo, &["fetch", "-q", "origin"]); + git( + repo, + &[ + "switch", + "-q", + "-c", + "feature/topic", + &format!("origin/{upstream}"), + ], + ); + git( + repo, + &[ + "branch", + "-q", + &format!("--set-upstream-to=origin/{upstream}"), + "feature/topic", + ], + ); +} + +fn prepare_workspace_with(root: &Path, repositories: &[(&str, &Path)], extra: Value) -> Value { + let repositories: Vec<_> = repositories + .iter() + .map(|(id, path)| json!({"id":id,"root":path})) + .collect(); + let mut payload = json!({ + "repositories":repositories, "message":"topic work", "push":true, + "includeParentReferences":false + }); + for (key, value) in extra.as_object().cloned().unwrap_or_default() { + payload[key] = value; + } + let response = request(root, "git.workspaceCommitPrepare", payload); + assert_eq!(response["ok"], true, "{response}"); + response["data"].clone() +} + +fn confirmation(choice: &str, remote_branch: &str) -> Value { + json!({"choice":choice, "remote":"origin", "remoteBranch":remote_branch}) +} + +fn run_workspace(root: &Path, mut session: Value) -> Value { + for _ in 0..6 { + if session["finished"] == true { + break; + } + session = next_workspace(root, session); + } + session +} + +fn remote_head(remote: &Path, branch: &str) -> String { + git(remote, &["rev-parse", &format!("refs/heads/{branch}")]) + .trim() + .to_string() +} + +/// Issue #1272: the plan, not the client, finds the repositories whose push needs a +/// destination choice. It asks before committing, and refuses to run the steps for +/// a client that ignored the question, so no commit is written and then stranded. +#[test] +fn git_workspace_commit_push_asks_before_a_divergent_branch_commits() { + let repo = repository("workspace-commit-divergent-ask"); + let remote = bare_remote("workspace-commit-divergent-ask-remote"); + divergent_feature_branch(&repo.0, &remote.0, "main"); + staged_file(&repo.0, "topic.txt", "topic"); + let main_before = remote_head(&remote.0, "main"); + let head_before = git(&repo.0, &["rev-parse", "HEAD"]); + + let asked = prepare_workspace_with(&repo.0, &[(".", &repo.0)], json!({})); + let questions = asked["pushTargetQuestions"].as_array().unwrap(); + assert_eq!(questions.len(), 1, "{asked}"); + assert_eq!(questions[0]["repositoryId"], "."); + assert_eq!(questions[0]["localBranch"], "feature/topic"); + assert_eq!(questions[0]["remote"], "origin"); + assert_eq!(questions[0]["upstream"], "origin/main"); + assert_eq!(questions[0]["upstreamBranch"], "main"); + + // A client that skips the question still must not commit. + let session = next_workspace(&repo.0, asked["session"].clone()); + assert_eq!( + session["results"]["."]["status"], "reviewRequired", + "{session}" + ); + assert_eq!(session["results"]["."]["committed"], false); + assert!(session["results"]["."]["detail"] + .as_str() + .unwrap() + .contains("needs confirmation")); + assert_eq!(git(&repo.0, &["rev-parse", "HEAD"]), head_before); + assert_eq!(remote_head(&remote.0, "main"), main_before); +} + +/// Issue #1272: commit-and-push has no reviewed preview, so the plan carries the +/// destination the user confirmed. Publishing must leave the branch the local one +/// diverges from untouched. +#[test] +fn git_workspace_commit_push_carries_the_confirmed_target() { + let repo = repository("workspace-commit-divergent-push"); + let remote = bare_remote("workspace-commit-divergent-remote"); + divergent_feature_branch(&repo.0, &remote.0, "main"); + staged_file(&repo.0, "topic.txt", "topic"); + let main_before = remote_head(&remote.0, "main"); + + let prepared = prepare_workspace_with( + &repo.0, + &[(".", &repo.0)], + json!({"pushTargets":{".": confirmation("publish", "feature/topic")}}), + ); + assert!(prepared.get("pushTargetQuestions").is_none(), "{prepared}"); + assert_eq!( + prepared["session"]["plan"]["pushTargets"]["."]["choice"], + "publish" + ); + + let session = run_workspace(&repo.0, prepared["session"].clone()); + assert_eq!(session["succeeded"], true, "{session}"); + assert_eq!(remote_head(&remote.0, "main"), main_before); + assert_eq!( + remote_head(&remote.0, "feature/topic"), + git(&repo.0, &["rev-parse", "HEAD"]).trim() + ); +} + +/// Issue #1272: a slashed upstream branch name must not be cut at its last segment. +#[test] +fn git_workspace_commit_push_keeps_a_slashed_confirmed_upstream() { + let repo = repository("workspace-commit-slashed-upstream"); + let remote = bare_remote("workspace-commit-slashed-remote"); + divergent_feature_branch(&repo.0, &remote.0, "team/release"); + staged_file(&repo.0, "topic.txt", "topic"); + let release_before = remote_head(&remote.0, "team/release"); + + let asked = prepare_workspace_with(&repo.0, &[(".", &repo.0)], json!({})); + assert_eq!( + asked["pushTargetQuestions"][0]["upstreamBranch"], + "team/release" + ); + let prepared = prepare_workspace_with( + &repo.0, + &[(".", &repo.0)], + json!({"pushTargets":{".": confirmation("upstream", "team/release")}}), + ); + let session = run_workspace(&repo.0, prepared["session"].clone()); + assert_eq!(session["succeeded"], true, "{session}"); + + let head = git(&repo.0, &["rev-parse", "HEAD"]); + assert_eq!(remote_head(&remote.0, "team/release"), head.trim()); + assert_ne!(remote_head(&remote.0, "team/release"), release_before); + let misresolved = request( + &remote.0, + "git.command", + json!({"arguments":["rev-parse", "--verify", "--quiet", "refs/heads/release"]}), + ); + assert_ne!( + misresolved["data"]["exitCode"], 0, + "a mis-resolved destination branch must not be created: {misresolved}" + ); + // Confirming "upstream" must not rewrite the user's tracking configuration. + assert_eq!( + git( + &repo.0, + &["rev-parse", "--abbrev-ref", "feature/topic@{upstream}"] + ) + .trim(), + "origin/team/release" + ); +} + +/// Issue #1272: two repositories in one batch can track unrelated branches, so each +/// is asked and guarded on its own instead of sharing one batch-wide answer. +#[test] +fn git_workspace_commit_push_confirms_each_repository_separately() { + let parent = repository("workspace-commit-divergent-multi"); + let child = parent.0.join("B"); + let parent_remote = bare_remote("workspace-commit-divergent-multi-a"); + let child_remote = bare_remote("workspace-commit-divergent-multi-b"); + // The parent commits its base before the nested repository exists, because + // `git add .` rejects an embedded repository that has no commit yet. + divergent_feature_branch(&parent.0, &parent_remote.0, "release"); + init(&child); + divergent_feature_branch(&child, &child_remote.0, "main"); + staged_file(&parent.0, "a.txt", "a"); + staged_file(&child, "b.txt", "b"); + let child_main = remote_head(&child_remote.0, "main"); + let repositories: [(&str, &Path); 2] = [(".", &parent.0), ("B", &child)]; + + let asked = prepare_workspace_with(&parent.0, &repositories, json!({})); + let ids: Vec<_> = asked["pushTargetQuestions"] + .as_array() + .unwrap() + .iter() + .map(|question| question["repositoryId"].as_str().unwrap().to_string()) + .collect(); + assert_eq!(ids, [".", "B"], "{asked}"); + + // Answering one repository leaves the other open: an answer is not batch-wide. + let partial = prepare_workspace_with( + &parent.0, + &repositories, + json!({"pushTargets":{".": confirmation("upstream", "release")}}), + ); + assert_eq!(partial["pushTargetQuestions"].as_array().unwrap().len(), 1); + assert_eq!(partial["pushTargetQuestions"][0]["repositoryId"], "B"); + + let prepared = prepare_workspace_with( + &parent.0, + &repositories, + json!({"pushTargets":{ + ".": confirmation("upstream", "release"), + "B": confirmation("publish", "feature/topic"), + }}), + ); + assert!(prepared.get("pushTargetQuestions").is_none(), "{prepared}"); + let session = run_workspace(&parent.0, prepared["session"].clone()); + assert_eq!(session["succeeded"], true, "{session}"); + assert_eq!( + remote_head(&parent_remote.0, "release"), + git(&parent.0, &["rev-parse", "HEAD"]).trim() + ); + assert_eq!(remote_head(&child_remote.0, "main"), child_main); + assert_eq!( + remote_head(&child_remote.0, "feature/topic"), + git(&child, &["rev-parse", "HEAD"]).trim() + ); +} + +/// Issue #1272: a retry after a failed push reuses the confirmed destination without +/// recommitting, but asks again once the tracking configuration no longer matches it. +#[test] +fn git_workspace_commit_retry_restores_and_revalidates_the_confirmed_target() { + let repo = repository("workspace-commit-divergent-retry"); + let remote = bare_remote("workspace-commit-divergent-retry-remote"); + divergent_feature_branch(&repo.0, &remote.0, "main"); + staged_file(&repo.0, "topic.txt", "topic"); + let repositories: [(&str, &Path); 1] = [(".", &repo.0)]; + let remote_path = remote.0.to_str().unwrap().to_string(); + + let prepared = prepare_workspace_with( + &repo.0, + &repositories, + json!({"pushTargets":{".": confirmation("upstream", "main")}}), + ); + // Break the remote after the confirmation so the push step fails after the commit. + git( + &repo.0, + &["remote", "set-url", "origin", "/lithe/missing/remote"], + ); + let failed = run_workspace(&repo.0, prepared["session"].clone()); + assert_eq!(failed["canRetry"], true, "{failed}"); + assert_eq!(failed["results"]["."]["committed"], true); + assert_eq!(failed["results"]["."]["pushed"], false); + git(&repo.0, &["remote", "set-url", "origin", &remote_path]); + + // The client forwards only the previous session; the confirmation is restored. + let retried = prepare_workspace_with(&repo.0, &repositories, json!({"previous":failed})); + assert!(retried.get("pushTargetQuestions").is_none(), "{retried}"); + assert_eq!( + retried["session"]["plan"]["pushTargets"]["."]["remoteBranch"], + "main" + ); + let resumed = run_workspace(&repo.0, retried["session"].clone()); + assert_eq!(resumed["succeeded"], true, "{resumed}"); + assert_eq!(git(&repo.0, &["rev-list", "--count", "HEAD"]).trim(), "2"); + assert_eq!( + remote_head(&remote.0, "main"), + git(&repo.0, &["rev-parse", "HEAD"]).trim() + ); +} + +/// Issue #1272: the confirmation names a remote branch. When the upstream moves to +/// another branch before the retry, the old answer must not authorize the new one. +#[test] +fn git_workspace_commit_retry_asks_again_when_the_upstream_moved() { + let repo = repository("workspace-commit-divergent-moved"); + let remote = bare_remote("workspace-commit-divergent-moved-remote"); + divergent_feature_branch(&repo.0, &remote.0, "release"); + git(&repo.0, &["push", "-q", "origin", "main:refs/heads/main"]); + git(&repo.0, &["fetch", "-q", "origin"]); + staged_file(&repo.0, "topic.txt", "topic"); + let repositories: [(&str, &Path); 1] = [(".", &repo.0)]; + let remote_path = remote.0.to_str().unwrap().to_string(); + + let prepared = prepare_workspace_with( + &repo.0, + &repositories, + json!({"pushTargets":{".": confirmation("upstream", "release")}}), + ); + git( + &repo.0, + &["remote", "set-url", "origin", "/lithe/missing/remote"], + ); + let failed = run_workspace(&repo.0, prepared["session"].clone()); + assert_eq!(failed["canRetry"], true, "{failed}"); + git(&repo.0, &["remote", "set-url", "origin", &remote_path]); + git( + &repo.0, + &[ + "branch", + "-q", + "--set-upstream-to=origin/main", + "feature/topic", + ], + ); + let main_before = remote_head(&remote.0, "main"); + + let retried = prepare_workspace_with(&repo.0, &repositories, json!({"previous":failed})); + let questions = retried["pushTargetQuestions"].as_array().unwrap(); + assert_eq!(questions.len(), 1, "{retried}"); + assert_eq!(questions[0]["upstreamBranch"], "main"); + + // Stepping anyway is refused instead of following the moved upstream. + let refused = next_workspace(&repo.0, retried["session"].clone()); + assert_eq!( + refused["results"]["."]["status"], "reviewRequired", + "{refused}" + ); + assert_eq!(remote_head(&remote.0, "main"), main_before); +} diff --git a/rust/lithe-core/src/tests/marketplace_plugins.rs b/rust/lithe-core/src/tests/marketplace_plugins.rs index 170b1b29a2..38ec2e802a 100644 --- a/rust/lithe-core/src/tests/marketplace_plugins.rs +++ b/rust/lithe-core/src/tests/marketplace_plugins.rs @@ -18,11 +18,11 @@ const INDEX_CASES: &str = contract_file!("fixtures/marketplace/index-cases.json" const INVALID_ENTRIES: &str = contract_file!("fixtures/marketplace/invalid-index-entries.json"); const SOURCE_URLS: &str = contract_file!("fixtures/marketplace/source-urls.json"); const INSTALL_REVIEWS: &str = contract_file!("fixtures/marketplace/install-reviews.json"); -const PHP_FIXTURE: &str = contract_file!("fixtures/php-support.plugin.json"); -const GO_FIXTURE: &str = contract_file!("fixtures/go-support.plugin.json"); -const MIX_FIXTURE: &str = contract_file!("fixtures/mix-provider.plugin.json"); -const GRADLE_FIXTURE: &str = contract_file!("fixtures/gradle-provider.plugin.json"); -const CARGO_FIXTURE: &str = contract_file!("fixtures/cargo-build.plugin.json"); +const PHP_FIXTURE: &str = contract_file!("fixtures/language-packaged-server.plugin.json"); +const GO_FIXTURE: &str = contract_file!("fixtures/language-system-command.plugin.json"); +const MIX_FIXTURE: &str = contract_file!("fixtures/build-provider-language-server.plugin.json"); +const GRADLE_FIXTURE: &str = contract_file!("fixtures/build-provider.plugin.json"); +const CARGO_FIXTURE: &str = contract_file!("fixtures/build-static-file.plugin.json"); const OFFICIAL_INDEX: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/../../Plugins/marketplace/index.json" @@ -34,11 +34,11 @@ fn json_file(source: &str) -> Value { fn manifest_fixture(name: &str) -> Value { match name { - "php-support.plugin.json" => json_file(PHP_FIXTURE), - "go-support.plugin.json" => json_file(GO_FIXTURE), - "mix-provider.plugin.json" => json_file(MIX_FIXTURE), - "gradle-provider.plugin.json" => json_file(GRADLE_FIXTURE), - "cargo-build.plugin.json" => json_file(CARGO_FIXTURE), + "language-packaged-server.plugin.json" => json_file(PHP_FIXTURE), + "language-system-command.plugin.json" => json_file(GO_FIXTURE), + "build-provider-language-server.plugin.json" => json_file(MIX_FIXTURE), + "build-provider.plugin.json" => json_file(GRADLE_FIXTURE), + "build-static-file.plugin.json" => json_file(CARGO_FIXTURE), other => panic!("unknown fixture {other}"), } } diff --git a/rust/lithe-core/src/tests/mod.rs b/rust/lithe-core/src/tests/mod.rs index f48fbb5ea0..27f124b6da 100644 --- a/rust/lithe-core/src/tests/mod.rs +++ b/rust/lithe-core/src/tests/mod.rs @@ -4,6 +4,7 @@ mod build_systems; mod community_build_plugins; mod declarative_plugins; mod detectors; +mod file_name_order; mod git; mod git_fetch; mod git_history_rewrite; @@ -11,6 +12,7 @@ mod git_patch_exchange; mod git_remote_checkout; mod git_repository_discovery; mod git_repository_setup; +mod git_untracked_directories; mod git_workspace_commit; mod github; mod languages; @@ -18,7 +20,9 @@ mod marketplace_plugins; mod mybatis; mod plugins; mod project; +mod project_generators; mod protocol; mod run_configuration; mod spring; pub(crate) mod support; +mod vcs; diff --git a/rust/lithe-core/src/tests/project.rs b/rust/lithe-core/src/tests/project.rs index 26bffef3d0..e4080233dc 100644 --- a/rust/lithe-core/src/tests/project.rs +++ b/rust/lithe-core/src/tests/project.rs @@ -558,6 +558,84 @@ fn list_directories_returns_one_visible_level_per_requested_directory() { fs::remove_dir_all(root).expect("temporary fixture should be removable"); } +#[test] +fn folder_patterns_hide_folders_without_reading_them() { + // `name/`, `name/*` and `name/**` hide a folder from the project root, and a + // leading `**/` hides it at any depth, so the tree must not enumerate it. A + // plain name stays a file pattern. + let root = temporary_root("folder-patterns"); + write_fixture_file(&root.join("src/App.java"), "class App {}"); + write_fixture_file(&root.join(".lithe/dependencies/index.json"), "{}"); + write_fixture_file(&root.join("generated/Out.java"), "class Out {}"); + write_fixture_file(&root.join("docs/api/index.html"), ""); + write_fixture_file(&root.join("modules/.lithe/config.json"), "{}"); + write_fixture_file(&root.join("notes/todo.md"), "kept"); + write_fixture_file(&root.join("tmp/scratch.txt"), "root copy"); + write_fixture_file(&root.join("modules/tmp/scratch.txt"), "nested copy"); + write_fixture_file(&root.join("modules/keep.txt"), "kept"); + let patterns = [".lithe/*", "generated/", "docs/**", "notes", "**/tmp/"]; + let rules = serde_json::json!({"root": root, "hiddenFilePatterns": patterns}); + + crate::project::files::DIRECTORY_VISITS.with_borrow_mut(|visits| *visits = Some(Vec::new())); + let snapshot = core_data( + "folder-patterns-snapshot", + "workspace.snapshot", + rules.clone(), + ); + let root_listing = core_data( + "folder-patterns-listing", + "workspace.listDirectories", + serde_json::json!({ + "root": root, + "paths": ["", ".lithe", "generated", "docs", "tmp", "modules", "modules/tmp"], + "hiddenFilePatterns": patterns + }), + ); + let visits = crate::project::files::DIRECTORY_VISITS + .with_borrow_mut(Option::take) + .expect("visit trace should be installed"); + + let mut snapshot_files = snapshot["files"] + .as_array() + .expect("files should be an array") + .iter() + .map(|file| file.as_str().unwrap_or_default()) + .collect::>(); + snapshot_files.sort_unstable(); + assert_eq!( + snapshot_files, + [ + "modules/.lithe/config.json", + "modules/keep.txt", + "notes/todo.md", + "src/App.java" + ] + ); + let listed = root_listing["directories"] + .as_array() + .expect("directories should be an array") + .iter() + .map(|directory| directory["path"].as_str().unwrap_or_default()) + .collect::>(); + assert_eq!(listed, ["", "modules"]); + let root_children = root_listing["directories"][0]["children"] + .as_array() + .expect("children should be an array") + .iter() + .map(|child| child["name"].as_str().unwrap_or_default()) + .collect::>(); + assert_eq!(root_children, ["modules", "notes", "src"]); + for hidden in [".lithe", "generated", "docs", "tmp", "modules/tmp"] { + assert!( + visits + .iter() + .all(|path| !path.starts_with(root.join(hidden))), + "hidden folder {hidden} must not be enumerated: {visits:?}" + ); + } + fs::remove_dir_all(root).expect("temporary fixture should be removable"); +} + #[test] fn workspace_snapshot_keeps_worktrees_outside_their_main_checkout() { // Opening a folder that holds a main checkout beside one of its linked @@ -1166,6 +1244,60 @@ fn local_history_records_deduplicates_lists_and_relocates() { fs::remove_dir_all(root).expect("history workspace should be removable"); } +#[test] +fn local_history_honors_the_unified_hidden_path_patterns() { + let root = temporary_root("history-patterns"); + fs::create_dir_all(&root).expect("history workspace should be creatable"); + let storage = root.join("history-storage"); + let patterns = serde_json::json!(["**/generated/", ".lithe/*", "*.log", "secret.txt"]); + + let record = |path: &str| -> Value { + let request = serde_json::json!({ + "id": "record", + "command": "history.record", + "payload": { + "workspaceRoot": root, + "storageRoot": storage, + "path": path, + "reason": "saved", + "content": "text\n", + "hiddenDirectoryNames": [], + "hiddenFilePatterns": patterns + } + }); + serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("history request should encode"), + )) + .expect("history response should be JSON") + }; + + // Folder rules stop snapshots below the folder at any depth (`**/`) or only + // at the project root, and file rules match by glob or exact name. + for hidden in [ + "generated/Out.java", + "modules/a/generated/Out.java", + ".lithe/project.json", + "build.log", + "src/secret.txt", + ] { + let response = record(hidden); + assert_eq!(response["ok"], true, "{hidden}: {response}"); + assert!( + response["data"].is_null(), + "{hidden} should not be recorded" + ); + } + for visible in ["src/Main.java", "modules/.lithe/project.json"] { + let response = record(visible); + assert_eq!(response["ok"], true, "{visible}: {response}"); + assert!( + response["data"]["id"].as_str().is_some(), + "{visible} should be recorded" + ); + } + fs::remove_dir_all(root).expect("history workspace should be removable"); +} + #[test] fn file_commands_round_trip_and_reject_traversal() { let root = temporary_root("file"); diff --git a/rust/lithe-core/src/tests/project_generators.rs b/rust/lithe-core/src/tests/project_generators.rs new file mode 100644 index 0000000000..934a8a8291 --- /dev/null +++ b/rust/lithe-core/src/tests/project_generators.rs @@ -0,0 +1,409 @@ +use crate::plugins::declarative::validate_command; +use crate::plugins::marketplace::install_review_command; +use crate::project_generators::{ + project_check_name_command, project_generation_plan_command, project_generator_catalog_command, + project_generator_form_command, +}; +use serde_json::{json, Value}; +use std::path::PathBuf; + +const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/contracts/plugins/v1/fixtures/project-generator.plugin.json" +)); +const PLANS: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/contracts/plugins/v1/fixtures/project-plans.json" +)); +const MAVEN_BUILD_FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/contracts/plugins/v1/fixtures/build-host-integration.plugin.json" +)); +const OFFICIAL_JAVA: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../Plugins/mac/Official/JavaSupport/plugin.json" +)); + +fn json(source: &str) -> Value { + serde_json::from_str(source).expect("fixture is JSON") +} + +fn repository_path(relative: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join(relative) +} + +fn fixtures_path() -> PathBuf { + repository_path("shared/contracts/plugins/v1/fixtures") +} + +/// Java Support without its own New Project entries, so catalog tests see +/// it only as the provider of the Java language. +fn java_language_only() -> Value { + let mut manifest = json(OFFICIAL_JAVA); + let object = manifest.as_object_mut().unwrap(); + object.remove("projectGenerators"); + object["requires"] + .as_object_mut() + .unwrap() + .remove("projectGenerators"); + object["permissions"] = json!(["languageServer.launch"]); + manifest +} + +fn official_package_path() -> PathBuf { + repository_path("Plugins/mac/Official/JavaSupport") +} + +fn host() -> Value { + json!({"languages": [1], "languageServers": [1, 2], "serverProcess": [1], "settings": [1, 2], + "buildSystems": [1], "projectGenerators": [1]}) +} + +#[test] +fn golden_project_plans_match() { + let plans = json(PLANS); + assert_eq!(plans["schemaVersion"], 1); + let manifest = json(FIXTURE); + for case in plans["cases"].as_array().unwrap() { + let name = case["name"].as_str().unwrap(); + let actual = match case["operation"].as_str().unwrap() { + "generatorForm" => project_generator_form_command(json!({ + "manifest": manifest, + "generatorID": case["generatorID"], + "form": case["form"], + })), + "generationPlan" => project_generation_plan_command(json!({ + "manifest": manifest, + "generatorID": case["generatorID"], + "packagePath": fixtures_path(), + "form": case["form"], + })), + "checkName" => project_check_name_command(json!({"projectName": case["projectName"]})), + operation => panic!("{name}: unknown operation {operation}"), + } + .unwrap_or_else(|error| panic!("{name}: {error:?}")); + assert_eq!(actual, case["expected"], "{name}"); + } +} + +#[test] +fn catalog_lists_generators_with_requirement_providers() { + let result = project_generator_catalog_command(json!({ + "plugins": [ + {"manifest": json(FIXTURE), "enabled": true}, + {"manifest": json(MAVEN_BUILD_FIXTURE), "enabled": false}, + {"manifest": java_language_only(), "enabled": true}, + {"manifest": {"schemaVersion": 2, "id": "broken"}, "enabled": true} + ], + "hostCapabilities": host(), + })) + .unwrap(); + assert_eq!(result["status"], "ok"); + let generators = result["catalog"]["generators"].as_array().unwrap(); + let ids: Vec<&str> = generators + .iter() + .map(|entry| entry["generator"]["id"].as_str().unwrap()) + .collect(); + // Project section first, then generators by order and name. + assert_eq!(ids, ["app", "initializr", "archetype"]); + let app = &generators[0]; + assert_eq!(app["pluginEnabled"], true); + assert_eq!( + app["requirements"], + json!([ + {"languageID": "java", "providers": [ + {"pluginID": "dev.lithe.plugin.java-support", "pluginName": "Java Support", "enabled": true} + ]}, + {"buildSystemID": "maven", "providers": [ + {"pluginID": "dev.lithe.fixture.build-host-integration", "pluginName": "Host Integration Build Fixture", "enabled": false} + ]} + ]) + ); + assert_eq!(generators[1]["usesNetwork"], true); + assert_eq!(result["catalog"]["issues"].as_array().unwrap().len(), 1); +} + +#[test] +fn catalog_skips_plugins_the_host_cannot_run() { + let result = project_generator_catalog_command(json!({ + "plugins": [{"manifest": json(FIXTURE), "enabled": true}], + "hostCapabilities": {"languages": [1]}, + })) + .unwrap(); + assert_eq!(result["catalog"]["generators"], json!([])); + assert_eq!( + result["catalog"]["issues"][0]["error"]["code"], + "plugin.api.unsupported" + ); +} + +#[test] +fn official_java_templates_validate_and_plan() { + let manifest = json(OFFICIAL_JAVA); + let validated = + validate_command(json!({"manifest": manifest, "hostCapabilities": host()})).unwrap(); + assert_eq!(validated["status"], "ok", "{validated}"); + + let runtimes = json!({"jdk": {"home": "/Library/Java/JavaVirtualMachines/temurin-25.jdk/Contents/Home", "version": "25.0.3"}}); + let plan = |generator: &str, values: Value| { + project_generation_plan_command(json!({ + "manifest": manifest, + "generatorID": generator, + "packagePath": official_package_path(), + "form": {"projectName": "demo-app", "values": values, "runtimes": runtimes}, + })) + .unwrap() + }; + let files = |plan: &Value| -> Vec<(String, String)> { + plan["plan"]["steps"] + .as_array() + .unwrap() + .iter() + .flat_map(|step| step["files"].as_array().cloned().unwrap_or_default()) + .map(|file| { + ( + file["path"].as_str().unwrap().to_string(), + file["contents"].as_str().unwrap().to_string(), + ) + }) + .collect() + }; + + let maven = plan("java", json!({})); + assert_eq!(maven["status"], "ok", "{maven}"); + let maven_files = files(&maven); + let paths: Vec<&str> = maven_files.iter().map(|(path, _)| path.as_str()).collect(); + assert!(paths.contains(&"pom.xml")); + assert!(paths.contains(&".gitignore")); + assert!(paths.contains(&"src/main/java/org/example/Main.java")); + let pom = &maven_files + .iter() + .find(|(path, _)| path == "pom.xml") + .unwrap() + .1; + assert!(pom.contains("demo-app")); + assert!(pom.contains("25")); + // Maven 3.8's default Compiler 3.1 ignores the release property. + assert!(pom.contains("maven-compiler-plugin")); + assert_eq!( + maven["plan"]["openFiles"], + json!(["src/main/java/org/example/Main.java"]) + ); + assert_eq!(maven["plan"]["usesNetwork"], false); + + let gradle = plan( + "java", + json!({"buildSystem": "gradle", "addSampleCode": false}), + ); + let gradle_paths: Vec = files(&gradle).into_iter().map(|(path, _)| path).collect(); + assert!(gradle_paths.contains(&"build.gradle.kts".to_string())); + assert!(gradle_paths.contains(&"src/main/java/.gitkeep".to_string())); + assert_eq!(gradle["plan"]["openFiles"], json!([])); + + let plain = plan("java", json!({"buildSystem": "none"})); + let plain_paths: Vec = files(&plain).into_iter().map(|(path, _)| path).collect(); + assert_eq!(plain_paths, [".gitignore", "src/Main.java"]); + + let archetype = plan( + "maven-archetype", + json!({"archetype": "maven-archetype-webapp"}), + ); + let step = &archetype["plan"]["steps"][0]; + assert_eq!(step["command"], "mvn"); + assert_eq!(step["collect"], "demo-app"); + assert_eq!(step["environment"]["JAVA_HOME"], runtimes["jdk"]["home"]); + let arguments: Vec<&str> = step["arguments"] + .as_array() + .unwrap() + .iter() + .map(|argument| argument.as_str().unwrap()) + .collect(); + assert!(arguments.contains(&"-DarchetypeArtifactId=maven-archetype-webapp")); + assert!(arguments.contains(&"-Dpackage=org.example.demoapp")); + assert!(!arguments + .iter() + .any(|argument| argument.starts_with("-DarchetypeVersion"))); + + let spring = plan( + "spring-boot", + json!({"dependencies": ["web", "lombok"], "type": "gradle-project-kotlin"}), + ); + let url = spring["plan"]["steps"][0]["url"].as_str().unwrap(); + assert!(url.starts_with("https://start.spring.io/starter.zip?type=gradle-project-kotlin&language=java&groupId=com.example&artifactId=demo-app&name=demo-app&packageName=com.example.demoapp"), "{url}"); + assert!( + url.ends_with("&dependencies=lombok%2Cweb") || url.ends_with("&dependencies=web%2Clombok"), + "{url}" + ); + assert!(!url.contains("bootVersion")); + assert_eq!(spring["plan"]["openFiles"], json!(["build.gradle.kts"])); + assert_eq!(spring["plan"]["usesNetwork"], true); +} + +#[test] +fn spring_boot_refuses_a_jdk_older_than_17() { + let result = project_generation_plan_command(json!({ + "manifest": json(OFFICIAL_JAVA), + "generatorID": "spring-boot", + "packagePath": official_package_path(), + "form": {"projectName": "demo", "runtimes": {"jdk": {"home": "/jdk11", "version": "11.0.22"}}}, + })) + .unwrap(); + assert_eq!(result["status"], "rejected"); + assert_eq!(result["error"]["code"], "plugin.project.invalidInput"); + assert_eq!(result["error"]["field"], "values.jdk"); +} + +#[test] +fn rejects_invalid_generator_declarations() { + let base = json(FIXTURE); + let mutate = |change: &dyn Fn(&mut Value)| { + let mut manifest = base.clone(); + change(&mut manifest); + validate_command(json!({"manifest": manifest})).unwrap() + }; + type Mutation = Box; + let cases: Vec<(&str, Mutation, &str)> = vec![ + ( + "missing capability", + Box::new(|manifest| { + manifest["requires"] = json!({"languages": 1}); + }), + "plugin.manifest.invalid", + ), + ( + "missing permission", + Box::new(|manifest| manifest["permissions"] = json!([])), + "plugin.permission.undeclared", + ), + ( + "field read before declaration", + Box::new(|manifest| { + manifest["projectGenerators"][0]["fields"][0]["visibleWhen"] = + json!({"field": "sample", "equals": true}); + }), + "plugin.manifest.invalid", + ), + ( + "template escapes the package", + Box::new(|manifest| { + manifest["projectGenerators"][0]["steps"][0]["source"] = json!("../secrets"); + }), + "plugin.manifest.invalid", + ), + ( + "unknown variable", + Box::new(|manifest| { + manifest["projectGenerators"][1]["steps"][0]["arguments"][2] = + json!("-DartifactId=${field.missing}"); + }), + "plugin.manifest.invalid", + ), + ( + "archive URL with query", + Box::new(|manifest| { + manifest["projectGenerators"][2]["steps"][0]["url"] = + json!("https://start.example.com/starter.zip?type=x"); + }), + "plugin.manifest.invalid", + ), + ( + "command path", + Box::new(|manifest| { + manifest["projectGenerators"][1]["steps"][0]["command"] = json!("/usr/bin/mvn"); + }), + "plugin.manifest.invalid", + ), + ]; + for (name, change, code) in cases { + let result = mutate(&*change); + assert_eq!(result["status"], "rejected", "{name}: {result}"); + assert_eq!(result["error"]["code"], code, "{name}: {result}"); + } +} + +#[test] +fn project_generators_need_api_level_3() { + let mut manifest = json(FIXTURE); + manifest["pluginAPIVersion"] = json!(2); + manifest.as_object_mut().unwrap().remove("requires"); + let result = validate_command(json!({"manifest": manifest})).unwrap(); + assert_eq!(result["status"], "rejected"); +} + +/// A generator command runs a program on the user's Mac, so the install +/// review must list it, and an update that adds one or changes its argv must +/// ask again; template-only edits must not. +#[test] +fn generator_commands_require_install_review_again() { + let mut manifest = json(FIXTURE); + let generators = manifest["projectGenerators"].as_array_mut().unwrap(); + let archetype = generators + .iter() + .position(|g| g["id"] == "archetype") + .unwrap(); + let command_generator = generators.remove(archetype); + let first = install_review_command(json!({ "manifest": manifest })).unwrap(); + assert_eq!(first["status"], "ok", "{first}"); + let approved = first["review"]["programs"].clone(); + assert_eq!(approved, json!([]), "files and archives start no program"); + + manifest["version"] = json!("1.0.1"); + manifest["projectGenerators"] + .as_array_mut() + .unwrap() + .push(command_generator); + let added = install_review_command(json!({ + "manifest": manifest, "approvedPrograms": approved, + })) + .unwrap(); + assert_eq!(added["status"], "ok", "{added}"); + let programs = added["review"]["newPrograms"].as_array().unwrap(); + assert_eq!(programs.len(), 1, "{added}"); + assert_eq!(programs[0]["kind"], "projectGenerator"); + assert_eq!(programs[0]["generatorID"], "archetype"); + let approved = added["review"]["programs"].clone(); + + let last = manifest["projectGenerators"].as_array().unwrap().len() - 1; + manifest["projectGenerators"][last]["displayName"] = json!("Renamed"); + let renamed = install_review_command(json!({ + "manifest": manifest, "approvedPrograms": approved, + })) + .unwrap(); + assert_eq!(renamed["review"]["newPrograms"], json!([]), "{renamed}"); + + manifest["projectGenerators"][last]["steps"][0]["arguments"] + .as_array_mut() + .unwrap() + .push(json!("-Dchanged=true")); + let changed = install_review_command(json!({ + "manifest": manifest, "approvedPrograms": approved, + })) + .unwrap(); + assert_eq!( + changed["review"]["newPrograms"].as_array().unwrap().len(), + 1, + "{changed}" + ); +} + +/// Spring Boot's target Java release follows the selected JDK, so a project +/// the saved JDK cannot compile is refused before anything is downloaded. +#[test] +fn spring_boot_refuses_a_java_release_newer_than_the_jdk() { + let plan = |jdk: &str, java: &str| { + project_generation_plan_command(json!({ + "manifest": json(OFFICIAL_JAVA), + "generatorID": "spring-boot", + "packagePath": official_package_path(), + "form": {"projectName": "demo", "values": {"javaVersion": java}, + "runtimes": {"jdk": {"home": "/jdk", "version": jdk}}}, + })) + .unwrap() + }; + let refused = plan("17.0.12", "21"); + assert_ne!(refused["status"], "ok", "{refused}"); + assert!(refused.to_string().contains("versionTooHigh"), "{refused}"); + assert_eq!(plan("21.0.4", "21")["status"], "ok"); +} diff --git a/rust/lithe-core/src/tests/vcs.rs b/rust/lithe-core/src/tests/vcs.rs new file mode 100644 index 0000000000..6fad30d8d2 --- /dev/null +++ b/rust/lithe-core/src/tests/vcs.rs @@ -0,0 +1,401 @@ +//! VCS providers declared by API level 3 plugins: manifest validation, +//! install review, launch plans, root detection over real directories, and +//! reply decoding, checked against `shared/fixtures/vcs/model-v1.json` and +//! the Subversion provider manifest fixture. + +use super::support::temporary_root; +use crate::plugins::declarative::{language_server_launch_plan_command, validate_command}; +use crate::plugins::marketplace::install_review_command; +use crate::protocol::ErrorCode; +use crate::vcs::{ + negotiated_capabilities, vcs_decode_reply_command, vcs_detect_roots_command, + vcs_owner_of_command, +}; +use serde_json::{json, Value}; +use std::fs; +use std::path::{Path, PathBuf}; + +const MODEL: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/vcs/model-v1.json" +)); +const SVN_PROVIDER: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/contracts/plugins/v1/fixtures/vcs-provider.plugin.json" +)); + +fn model() -> Value { + serde_json::from_str(MODEL).expect("the VCS model fixture is JSON") +} + +fn svn_provider() -> Value { + serde_json::from_str(SVN_PROVIDER).expect("the provider fixture is JSON") +} + +fn validate(manifest: Value) -> Value { + validate_command(json!({ "manifest": manifest })).expect("well-formed payload") +} + +/// Removes its directory when dropped, so a failed assertion cleans up too. +struct Sandbox(PathBuf); + +impl Sandbox { + fn new(label: &str) -> Self { + let root = temporary_root(label); + fs::create_dir_all(&root).unwrap(); + // Detection reports resolved paths, and the temporary directory is + // itself behind a link on macOS. + Self(root.canonicalize().unwrap()) + } + + fn directory(&self, relative: &str) -> PathBuf { + let path = self.0.join(relative); + fs::create_dir_all(&path).unwrap(); + path + } +} + +impl Drop for Sandbox { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +#[test] +fn the_provider_fixture_validates_and_is_reviewed_with_its_tool() { + let outcome = validate(svn_provider()); + assert_eq!(outcome["status"], "ok", "{outcome}"); + + let review = install_review_command(json!({ "manifest": svn_provider() })).unwrap(); + assert_eq!(review["status"], "ok", "{review}"); + let programs = review["review"]["programs"].as_array().unwrap(); + let provider = programs + .iter() + .find(|program| program["kind"] == "runtimeVcsProvider") + .unwrap_or_else(|| panic!("no provider program: {programs:?}")); + assert_eq!(provider["vcs"], "svn"); + assert_eq!(provider["tool"], "node", "{provider}"); + assert_eq!(provider["entry"], "provider/main.mjs"); + assert_eq!( + provider["vcsTool"], + json!({ "commands": ["svn"], "validationArguments": ["--version", "--quiet"] }) + ); + assert_eq!(provider["mayUseNetwork"], true); + assert!( + programs.contains(&json!({ "kind": "runtime", "tool": "node", "minimumVersion": "20.3" })), + "{programs:?}" + ); +} + +/// A provider changes the working copy and starts its own process, so the +/// manifest must ask for both, name its activation event, and say which +/// contract versions it needs; Git stays reserved for the built-in support. +#[test] +fn provider_manifests_state_permissions_activation_and_requirements() { + let rejected = |mutate: fn(&mut Value)| { + let mut manifest = svn_provider(); + mutate(&mut manifest); + let outcome = validate(manifest); + assert_eq!(outcome["status"], "rejected", "{outcome}"); + outcome["error"].clone() + }; + let missing_permission = rejected(|manifest| { + manifest["permissions"] = json!(["languageServer.launch"]); + }); + assert_eq!( + missing_permission["field"], "permissions", + "{missing_permission}" + ); + let missing_launch = rejected(|manifest| manifest["permissions"] = json!(["process.vcs"])); + assert_eq!(missing_launch["field"], "permissions", "{missing_launch}"); + let missing_activation = rejected(|manifest| manifest["activationEvents"] = json!([])); + assert_eq!( + missing_activation["field"], "activationEvents", + "{missing_activation}" + ); + let missing_requirement = rejected(|manifest| { + manifest["requires"] = json!({ "serverProcess": 1 }); + }); + assert!( + missing_requirement.to_string().contains("vcsProviders"), + "{missing_requirement}" + ); + let reserved = rejected(|manifest| { + manifest["vcsProviders"][0]["id"] = json!("git"); + manifest["activationEvents"] = json!(["onVcsRoot:git"]); + }); + assert_eq!(reserved["code"], "plugin.vcs.reserved", "{reserved}"); + let level_two = rejected(|manifest| manifest["pluginAPIVersion"] = json!(2)); + assert_ne!(level_two["code"], "plugin.vcs.reserved", "{level_two}"); + let escaping = rejected(|manifest| { + manifest["vcsProviders"][0]["metadataPaths"] = json!(["../wc.db"]); + }); + assert_eq!( + escaping["field"], "vcsProviders[0].metadataPaths[0]", + "{escaping}" + ); + let path_tool = rejected(|manifest| { + manifest["vcsProviders"][0]["tool"]["commands"] = json!(["/usr/bin/svn"]); + }); + assert_eq!( + path_tool["field"], "vcsProviders[0].tool.commands", + "{path_tool}" + ); +} + +#[test] +fn a_launch_plan_names_the_vcs_provider_it_starts() { + let request = json!({ + "pluginDirectory": "/plugins/svn", + "stateDirectory": "/state/svn", + "workspaceStateDirectory": "/state/svn/workspace", + "architecture": "arm64" + }); + let plan = language_server_launch_plan_command(json!({ + "manifest": svn_provider(), + "vcsProviderID": "svn", + "request": request, + })) + .unwrap(); + assert_eq!(plan["status"], "ok", "{plan}"); + let plan = &plan["plan"]; + assert_eq!(plan["vcsProvider"]["vcs"], "svn", "{plan}"); + assert_eq!( + plan["vcsProvider"]["origin"], + json!({ "kind": "plugin", "pluginID": "dev.lithe.fixture.vcs-provider" }) + ); + assert_eq!(plan["vcsProvider"]["metadataPaths"], json!([".svn/wc.db"])); + + let unknown = language_server_launch_plan_command(json!({ + "manifest": svn_provider(), + "vcsProviderID": "hg", + "request": request, + })) + .unwrap(); + assert_eq!(unknown["status"], "rejected", "{unknown}"); + let both = language_server_launch_plan_command(json!({ + "manifest": svn_provider(), + "languageID": "java", + "vcsProviderID": "svn", + "request": request, + })) + .unwrap_err(); + assert!(matches!(both.code, ErrorCode::InvalidRequest), "{both:?}"); +} + +fn under(path: &Value, root: &Path) -> bool { + path.as_str() + .is_some_and(|path| Path::new(path).starts_with(root)) +} + +/// Builds each case's markers in a fresh directory and checks the roots, +/// ambiguous directories, unused mappings, and file ownership. Roots found +/// above the case's directory belong to the machine running the test and +/// are left out. +#[test] +fn root_detection_matches_the_contract_cases() { + let model = model(); + for case in model["rootDetectionCases"].as_array().unwrap() { + let name = case["name"].as_str().unwrap(); + let sandbox = Sandbox::new("vcs-roots"); + for marker in case["markers"].as_array().unwrap() { + let directory = sandbox.directory(marker["directory"].as_str().unwrap()); + let path = directory.join(marker["name"].as_str().unwrap()); + match marker["type"].as_str().unwrap() { + "directory" => fs::create_dir_all(path).unwrap(), + _ => fs::write(path, "gitdir: elsewhere\n").unwrap(), + } + } + let workspace = sandbox.directory(case["workspace"].as_str().unwrap_or("")); + let outcome = vcs_detect_roots_command(json!({ + "workspaceRoot": workspace.to_string_lossy(), + "plugins": [{ "manifest": svn_provider() }], + "mappings": case.get("mappings").cloned().unwrap_or(json!([])), + })) + .unwrap(); + assert_eq!(outcome["status"], "ok", "{name}: {outcome}"); + let detection = &outcome["detection"]; + assert_eq!(detection["truncated"], false, "{name}"); + + let roots: Vec<&Value> = detection["roots"] + .as_array() + .unwrap() + .iter() + .filter(|root| under(&root["path"], &sandbox.0)) + .collect(); + let found: Vec = roots + .iter() + .map(|root| json!({ "id": root["id"], "vcs": root["vcs"], "mapped": root["mapped"] })) + .collect(); + let expected: Vec = case["expectedRoots"] + .as_array() + .unwrap() + .iter() + .map(|root| { + json!({ + "id": root["id"], + "vcs": root["vcs"], + "mapped": root.get("mapped").cloned().unwrap_or(json!(false)), + }) + }) + .collect(); + assert_eq!(found, expected, "{name}: {detection}"); + assert_eq!(detection["ambiguous"], case["expectedAmbiguous"], "{name}"); + assert_eq!( + detection["unusedMappings"], + case.get("expectedUnusedMappings") + .cloned() + .unwrap_or(json!([])), + "{name}" + ); + + let ownership = case["expectedOwnership"].as_array().unwrap(); + let owners = vcs_owner_of_command(json!({ + "roots": roots.iter().map(|root| root["id"].clone()).collect::>(), + "ambiguous": detection["ambiguous"] + .as_array() + .unwrap() + .iter() + .map(|directory| directory["id"].clone()) + .collect::>(), + "paths": ownership.iter().map(|entry| entry["path"].clone()).collect::>(), + })) + .unwrap(); + assert_eq!(owners["status"], "ok", "{name}: {owners}"); + let expected: Vec = ownership + .iter() + .map(|entry| json!({ "path": entry["path"], "root": entry["root"] })) + .collect(); + assert_eq!(owners["owners"], json!(expected), "{name}"); + } +} + +/// The built-in Git entry and the plugin's descriptor are what a host shows; +/// a second plugin claiming the same VCS loses to the official namespace +/// whatever order the host lists plugins in, and a malformed manifest is +/// reported by its position without hiding the others. +#[test] +fn detection_lists_providers_and_reports_conflicts_and_rejections() { + let model = model(); + let sandbox = Sandbox::new("vcs-providers"); + // `com.` sorts before `dev.`, so only the namespace rule lets the + // official fixture win. + let mut community = svn_provider(); + community["id"] = json!("com.example.svn"); + let mut broken = svn_provider(); + broken["vcsProviders"][0]["id"] = json!("SVN"); + for plugins in [ + vec![community.clone(), broken.clone(), svn_provider()], + vec![svn_provider(), broken.clone(), community.clone()], + ] { + let outcome = vcs_detect_roots_command(json!({ + "workspaceRoot": sandbox.0.to_string_lossy(), + "plugins": plugins + .into_iter() + .map(|manifest| json!({ "manifest": manifest })) + .collect::>(), + })) + .unwrap(); + let detection = &outcome["detection"]; + let providers = detection["providers"].as_array().unwrap(); + assert_eq!(providers[0], model["providers"][0], "{detection}"); + assert_eq!(providers.len(), 2, "{detection}"); + assert_eq!(providers[1], { + let mut descriptor = model["providers"][1].clone(); + descriptor.as_object_mut().unwrap().remove("capabilities"); + descriptor["metadataPaths"] = json!([".svn/wc.db"]); + descriptor["tool"] = + json!({ "commands": ["svn"], "validationArguments": ["--version", "--quiet"] }); + descriptor["mayUseNetwork"] = json!(true); + descriptor + }); + assert_eq!( + detection["conflicts"], + json!([{ + "pluginID": "com.example.svn", + "vcs": "svn", + "by": "dev.lithe.fixture.vcs-provider" + }]) + ); + let rejected = detection["rejected"].as_array().unwrap(); + assert_eq!(rejected.len(), 1, "{detection}"); + assert_eq!(rejected[0]["index"], 1); + } + + let error = vcs_detect_roots_command(json!({ "workspaceRoot": "relative/path" })) + .expect_err("a relative workspace is a malformed call"); + assert!(matches!(error.code, ErrorCode::InvalidRequest), "{error:?}"); +} + +#[test] +fn ownership_queries_reject_malformed_ids_and_paths() { + for payload in [ + json!({ "roots": ["../a"], "paths": ["x"] }), + json!({ "roots": [""], "ambiguous": ["/abs"], "paths": ["x"] }), + json!({ "roots": [""], "paths": ["../x"] }), + json!({ "roots": [""], "paths": ["a\\b"] }), + ] { + let error = vcs_owner_of_command(payload.clone()).expect_err("malformed"); + assert!(matches!(error.code, ErrorCode::InvalidRequest), "{payload}"); + } +} + +fn decode(operation: &str, capabilities: &Value, request: &Value, reply: &Value) -> Value { + vcs_decode_reply_command(json!({ + "operation": operation, + "capabilities": capabilities, + "request": request, + "reply": reply, + })) + .expect("well-formed payload") +} + +/// A normalized snapshot is a fixed point: decoding it again changes +/// nothing, so hosts can rely on the fixture as the decoded shape. +#[test] +fn status_snapshots_are_the_decoded_shape() { + let model = model(); + for case in model["statusSnapshots"].as_array().unwrap() { + let name = case["name"].as_str().unwrap(); + let capabilities = &case["capabilities"]; + assert!( + negotiated_capabilities(capabilities).is_some(), + "{name}: the capabilities must be a valid negotiation" + ); + let outcome = decode("status", capabilities, &json!({}), &case["snapshot"]); + assert_eq!(outcome["status"], "ok", "{name}: {outcome}"); + assert_eq!(outcome["result"], case["snapshot"], "{name}"); + } + // The Subversion provider's capabilities survive negotiation unchanged. + let svn = &model["providers"][1]["capabilities"]; + assert_eq!(json!(negotiated_capabilities(svn).expect("complete")), *svn); +} + +#[test] +fn plugin_replies_match_the_validation_cases() { + let model = model(); + let capabilities = &model["providers"][1]["capabilities"]; + for case in model["pluginReplyValidationCases"].as_array().unwrap() { + let name = case["name"].as_str().unwrap(); + let operation = case["operation"].as_str().unwrap_or("status"); + let request = case.get("request").cloned().unwrap_or(json!({})); + let outcome = decode(operation, capabilities, &request, &case["reply"]); + if let Some(code) = case.get("expectedError") { + assert_eq!(outcome["status"], "rejected", "{name}: {outcome}"); + assert_eq!(outcome["error"]["code"], *code, "{name}"); + } else if let Some(changes) = case.get("expectedChanges") { + assert_eq!(outcome["status"], "ok", "{name}: {outcome}"); + assert_eq!(outcome["result"]["changes"], *changes, "{name}"); + } else { + assert_eq!(outcome["status"], "ok", "{name}: {outcome}"); + assert_eq!(outcome["result"], case["expectedResult"], "{name}"); + } + } + let error = vcs_decode_reply_command(json!({ + "operation": "push", + "capabilities": capabilities, + "reply": {}, + })) + .expect_err("push has no reply shape in version 1"); + assert!(matches!(error.code, ErrorCode::InvalidRequest), "{error:?}"); +} diff --git a/rust/lithe-core/src/vcs/commands.rs b/rust/lithe-core/src/vcs/commands.rs new file mode 100644 index 0000000000..9f989092dd --- /dev/null +++ b/rust/lithe-core/src/vcs/commands.rs @@ -0,0 +1,263 @@ +//! The `vcs.*` commands: JSON in, outcome envelope out. +//! +//! Like `build.detect`, `vcs.detectRoots` takes the manifests of the enabled +//! plugins and validates each again, so a host never trusts its own cache of +//! a manifest. Malformed payloads fail the call; rejected manifests and +//! replies come back as `{status: "rejected", error}`. + +use super::manifest::{built_in_git_markers, VcsProviderDeclaration, BUILT_IN_GIT}; +use super::model::{is_root_relative, Capabilities}; +use super::requests::{self, Operation}; +use super::roots::{self, Mapping, MarkerSet}; +use crate::plugins::declarative::{ + decode_payload, is_official_namespace, outcome, validate_manifest_value, +}; +use crate::protocol::{CoreError, ErrorCode}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use std::path::PathBuf; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct DetectRootsRequest { + /// Absolute workspace root. + workspace_root: String, + /// Manifests of the enabled plugins, in any order. + #[serde(default)] + plugins: Vec, + /// The user's choices for ambiguous directories. + #[serde(default)] + mappings: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct DetectPlugin { + manifest: Value, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct MappingInput { + id: String, + vcs: String, +} + +/// The declaration that answers for one VCS ID. +struct Owner<'a> { + plugin_id: &'a str, + declaration: &'a VcsProviderDeclaration, +} + +/// Official plugins win a VCS ID, then the lower plugin ID, so the result +/// does not depend on the order a host lists plugins in. +fn precedes(candidate: &Owner<'_>, current: &Owner<'_>) -> bool { + ( + !is_official_namespace(candidate.plugin_id), + candidate.plugin_id, + ) < (!is_official_namespace(current.plugin_id), current.plugin_id) +} + +/// `vcs.detectRoots`: registered providers, the roots of every VCS with a +/// provider (and of the built-in Git), ambiguous directories, and which +/// mappings applied. +pub(crate) fn vcs_detect_roots_command(payload: Value) -> Result { + let request: DetectRootsRequest = decode_payload(payload, "vcs.detectRoots")?; + let workspace = PathBuf::from(&request.workspace_root); + if !workspace.is_absolute() || request.workspace_root.contains('\0') { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The workspace root must be an absolute path.", + )); + } + // Ancestors are read from the resolved location: a workspace opened + // through a link sits in the link target's working copy. + let workspace = workspace.canonicalize().unwrap_or(workspace); + + let mut rejected = Vec::new(); + let mut manifests = Vec::new(); + for (index, plugin) in request.plugins.into_iter().enumerate() { + match validate_manifest_value(plugin.manifest) { + Ok(manifest) => manifests.push(manifest), + Err(error) => rejected.push(json!({ "index": index, "error": error })), + } + } + let mut owners: BTreeMap<&str, Owner<'_>> = BTreeMap::new(); + let mut conflicts = Vec::new(); + for manifest in &manifests { + for declaration in manifest.vcs_providers() { + let candidate = Owner { + plugin_id: manifest.id(), + declaration, + }; + let (winner, loser) = match owners.remove(declaration.id.as_str()) { + None => { + owners.insert(&declaration.id, candidate); + continue; + } + Some(current) if precedes(&candidate, ¤t) => (candidate, current), + Some(current) => (current, candidate), + }; + conflicts.push(json!({ + "pluginID": loser.plugin_id, + "vcs": declaration.id, + "by": winner.plugin_id, + })); + owners.insert(&declaration.id, winner); + } + } + conflicts.sort_by_key(Value::to_string); + + let git_markers = built_in_git_markers(); + let mut providers = vec![json!({ + "vcs": BUILT_IN_GIT, + "displayName": "Git", + "origin": { "kind": "builtIn" }, + "markers": git_markers, + })]; + let mut sets = vec![MarkerSet { + vcs: BUILT_IN_GIT, + markers: &git_markers, + }]; + for owner in owners.values() { + providers.push(owner.declaration.descriptor(owner.plugin_id)); + sets.push(MarkerSet { + vcs: &owner.declaration.id, + markers: &owner.declaration.detection.markers, + }); + } + let mappings: Vec = request + .mappings + .into_iter() + .map(|mapping| Mapping { + id: mapping.id, + vcs: mapping.vcs, + }) + .collect(); + let detection = roots::detect(&workspace, &sets, &mappings); + let roots: Vec = detection + .roots + .iter() + .map(|root| { + json!({ + "id": root.id, + "vcs": root.vcs, + "path": root.path.to_string_lossy(), + "mapped": root.mapped, + }) + }) + .collect(); + let ambiguous: Vec = detection + .ambiguous + .iter() + .map(|directory| json!({ "id": directory.id, "vcs": directory.vcs })) + .collect(); + let unused: Vec = detection + .unused_mappings + .iter() + .map(|mapping| json!({ "id": mapping.id, "vcs": mapping.vcs })) + .collect(); + Ok(outcome(Ok(( + "detection", + json!({ + "providers": providers, + "roots": roots, + "ambiguous": ambiguous, + "unusedMappings": unused, + "truncated": detection.truncated, + "rejected": rejected, + "conflicts": conflicts, + }), + )))) +} + +/// Most paths one `vcs.ownerOf` call may ask about. +const MAX_OWNER_PATHS: usize = 100_000; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct OwnerOfRequest { + /// Root IDs of one `vcs.detectRoots` result. + roots: Vec, + /// Its unmapped ambiguous directories. + #[serde(default)] + ambiguous: Vec, + /// Workspace-relative paths with `/` separators; `""` is the workspace. + paths: Vec, +} + +/// `vcs.ownerOf`: the root that owns each path under the rule of +/// `vcs.detectRoots`, so every host assigns files the same way. +pub(crate) fn vcs_owner_of_command(payload: Value) -> Result { + let request: OwnerOfRequest = decode_payload(payload, "vcs.ownerOf")?; + if request.paths.len() > MAX_OWNER_PATHS { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + format!("One call asks about at most {MAX_OWNER_PATHS} paths."), + )); + } + if let Some(id) = request + .roots + .iter() + .chain(&request.ambiguous) + .find(|id| !roots::is_directory_id(id)) + { + return Err( + CoreError::new(ErrorCode::InvalidRequest, "Malformed root ID").with_details(id.clone()), + ); + } + if let Some(path) = request + .paths + .iter() + .find(|path| !path.is_empty() && !is_root_relative(path)) + { + return Err( + CoreError::new(ErrorCode::InvalidRequest, "Malformed workspace path") + .with_details(path.clone()), + ); + } + let owners: Vec = request + .paths + .iter() + .map(|path| { + json!({ + "path": path, + "root": roots::owner(&request.roots, &request.ambiguous, path), + }) + }) + .collect(); + Ok(outcome(Ok(("owners", json!(owners))))) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct DecodeReplyRequest { + operation: String, + /// Capabilities the session negotiated, as Core reported them. + capabilities: Capabilities, + /// The arguments the host sent with the request. + #[serde(default)] + request: Value, + /// The provider's result, unwrapped from the session's `value`. + reply: Value, +} + +/// `vcs.decodeReply`: a provider's reply as the normalized result, or +/// `plugin.vcs.invalidOutput`. +pub(crate) fn vcs_decode_reply_command(payload: Value) -> Result { + let request: DecodeReplyRequest = decode_payload(payload, "vcs.decodeReply")?; + let operation = Operation::parse(&request.operation).ok_or_else(|| { + CoreError::new(ErrorCode::InvalidRequest, "Unknown VCS operation") + .with_details(request.operation.clone()) + })?; + Ok(outcome( + requests::decode( + operation, + &request.request, + &request.reply, + &request.capabilities, + ) + .map(|result| ("result", result)), + )) +} diff --git a/rust/lithe-core/src/vcs/manifest.rs b/rust/lithe-core/src/vcs/manifest.rs new file mode 100644 index 0000000000..9b0d024254 --- /dev/null +++ b/rust/lithe-core/src/vcs/manifest.rs @@ -0,0 +1,286 @@ +//! `vcsProviders[]` of an API level 3 plugin manifest: what a host must know +//! about a VCS before starting any process for it. +//! +//! A declaration names the VCS, the static markers that identify its roots, +//! the metadata the host watches to refresh status, the client tool the +//! provider drives, and the provider process with the launch fields every +//! plugin server process shares. Capabilities are not declared here: they +//! belong to the running provider and are negotiated in `initialize`. + +use super::model::is_vcs_id; +use crate::plugins::declarative::{ + invalid, is_bare_command, is_safe_relative_path, sorted_unique, PluginRejection, ServerProcess, + ServerRole, Validation, +}; +use crate::plugins::settings::DeclaredSettings; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::BTreeSet; + +/// The built-in VCS ID. Git is not part of the unified layer yet, but Core +/// recognizes its roots so a nested working copy is assigned correctly, and +/// no plugin may claim the ID. +pub(crate) const BUILT_IN_GIT: &str = "git"; + +/// Most providers one plugin may declare. +const MAX_PROVIDERS: usize = 8; +/// Most markers one provider may declare. +const MAX_MARKERS: usize = 8; +/// Most metadata paths one provider may ask the host to watch. +const MAX_METADATA_PATHS: usize = 16; +/// Most tool command names one provider may list. +const MAX_TOOL_COMMANDS: usize = 8; +/// Longest display name, in characters. +const MAX_DISPLAY_NAME: usize = 64; + +/// One VCS a plugin implements. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct VcsProviderDeclaration { + /// Stable lowercase VCS ID (`svn`, `hg`); the provider must answer the + /// same ID in `initialize`. + pub(crate) id: String, + pub(crate) display_name: String, + pub(crate) detection: VcsDetection, + /// Root-relative files or directories whose change means the status may + /// be stale, such as `.svn/wc.db`. Sorted and unique once validated. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) metadata_paths: Vec, + /// The VCS client the provider runs, which the host resolves and passes + /// with every request. Absent for a provider that needs no client. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) tool: Option, + #[serde(flatten)] + pub(crate) process: ServerProcess, + /// Whether operations may contact a server; shown in the install review. + pub(crate) may_use_network: bool, +} + +/// Static markers of a root. Detection never starts a process. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct VcsDetection { + pub(crate) markers: Vec, +} + +/// An entry directly inside a root that marks it, such as the `.svn` +/// directory. The kind matters: a Git worktree has a `.git` file. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct VcsMarker { + pub(crate) name: String, + #[serde(rename = "type")] + pub(crate) kind: MarkerKind, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum MarkerKind { + Directory, + File, +} + +/// A client executable the host discovers like a `systemCommand` launch. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct VcsTool { + /// Bare command names, tried in order. + pub(crate) commands: Vec, + /// Arguments of a probe that must exit successfully before the host + /// trusts a candidate; empty means no probe. + #[serde(default)] + pub(crate) validation_arguments: Vec, +} + +/// The markers Core uses for the built-in Git support: a `.git` directory, +/// or the `.git` file of a linked worktree or submodule. +pub(crate) fn built_in_git_markers() -> Vec { + vec![ + VcsMarker { + name: ".git".to_string(), + kind: MarkerKind::Directory, + }, + VcsMarker { + name: ".git".to_string(), + kind: MarkerKind::File, + }, + ] +} + +fn is_marker_name(value: &str) -> bool { + !value.is_empty() + && value != "." + && value != ".." + && value.len() <= 255 + && !value.contains(['/', '\\', '\0']) +} + +/// What the declaration context needs from the manifest. +pub(crate) struct DeclarationContext<'a> { + pub(crate) plugin_api_version: u32, + pub(crate) declared_settings: &'a DeclaredSettings, +} + +/// Validates every declaration and sorts them by ID. +pub(crate) fn validate_declarations( + providers: &mut [VcsProviderDeclaration], + context: &DeclarationContext<'_>, +) -> Validation<()> { + if providers.is_empty() { + return Ok(()); + } + if context.plugin_api_version < 3 { + return Err(invalid( + "vcsProviders", + "VCS providers require plugin API level 3.", + )); + } + if providers.len() > MAX_PROVIDERS { + return Err(invalid( + "vcsProviders", + format!("A plugin declares at most {MAX_PROVIDERS} VCS providers."), + )); + } + let mut ids = BTreeSet::new(); + for (index, provider) in providers.iter_mut().enumerate() { + let field = format!("vcsProviders[{index}]"); + provider.validate(&field, context)?; + if !ids.insert(provider.id.clone()) { + return Err(invalid( + format!("{field}.id"), + "A VCS is declared more than once.", + )); + } + } + providers.sort_by(|left, right| left.id.cmp(&right.id)); + Ok(()) +} + +impl VcsProviderDeclaration { + fn validate(&mut self, field: &str, context: &DeclarationContext<'_>) -> Validation<()> { + if !is_vcs_id(&self.id) { + return Err(invalid( + format!("{field}.id"), + "A VCS ID is a lowercase letter followed by up to 31 lowercase letters, digits, or hyphens.", + )); + } + if self.id == BUILT_IN_GIT { + return Err(PluginRejection::at( + "plugin.vcs.reserved", + format!("{field}.id"), + "Git is built into Lithe and cannot be provided by a plugin yet.", + )); + } + let display_name = self.display_name.trim(); + if display_name.is_empty() + || display_name.chars().count() > MAX_DISPLAY_NAME + || display_name.chars().any(char::is_control) + { + return Err(invalid( + format!("{field}.displayName"), + "The display name is one line of 1-64 characters.", + )); + } + + let markers = &mut self.detection.markers; + if markers.is_empty() || markers.len() > MAX_MARKERS { + return Err(invalid( + format!("{field}.detection.markers"), + format!("Detection needs 1-{MAX_MARKERS} markers."), + )); + } + if let Some(index) = markers + .iter() + .position(|marker| !is_marker_name(&marker.name)) + { + return Err(invalid( + format!("{field}.detection.markers[{index}].name"), + "A marker is a name directly inside the root.", + )); + } + markers.sort(); + markers.dedup(); + + if self.metadata_paths.len() > MAX_METADATA_PATHS { + return Err(invalid( + format!("{field}.metadataPaths"), + format!("At most {MAX_METADATA_PATHS} metadata paths are watched."), + )); + } + if let Some(index) = self.metadata_paths.iter().position(|path| { + !is_safe_relative_path(path) || path.split('/').any(|s| s.is_empty() || s == ".") + }) { + return Err(invalid( + format!("{field}.metadataPaths[{index}]"), + "A metadata path must stay inside the root.", + )); + } + self.metadata_paths = sorted_unique(std::mem::take(&mut self.metadata_paths)); + + if let Some(tool) = &self.tool { + if tool.commands.is_empty() + || tool.commands.len() > MAX_TOOL_COMMANDS + || tool + .commands + .iter() + .any(|command| !is_bare_command(command)) + { + return Err(invalid( + format!("{field}.tool.commands"), + format!("A tool names 1-{MAX_TOOL_COMMANDS} bare executable names."), + )); + } + if tool + .validation_arguments + .iter() + .any(|argument| argument.contains('\0')) + { + return Err(invalid( + format!("{field}.tool.validationArguments"), + "Tool arguments must not contain NUL.", + )); + } + } + + self.process.validate( + field, + context.plugin_api_version, + context.declared_settings, + ServerRole::VcsProvider, + ) + } + + /// Display data and requirements a host keeps for a provider; the + /// process launch is planned separately. + pub(crate) fn descriptor(&self, plugin_id: &str) -> Value { + json!({ + "vcs": self.id, + "displayName": self.display_name, + "origin": { "kind": "plugin", "pluginID": plugin_id }, + "markers": self.detection.markers, + "metadataPaths": self.metadata_paths, + "tool": self.tool, + "mayUseNetwork": self.may_use_network, + }) + } +} + +/// Activation event the host raises before starting a VCS provider, once a +/// root of its VCS was detected. +pub(crate) fn activation_event(provider: &VcsProviderDeclaration) -> String { + format!("onVcsRoot:{}", provider.id) +} + +/// Install-review entries: the provider process with the runtimes it needs. +/// The tool is part of the provider entry, so pointing it at another client +/// is a new program the user must approve. +pub(crate) fn programs(provider: &VcsProviderDeclaration) -> Vec { + let mut program = provider.process.program(ServerRole::VcsProvider); + program["vcs"] = json!(provider.id); + // `tool` already names a runtime launch's runtime. + program["vcsTool"] = json!(provider.tool); + program["mayUseNetwork"] = json!(provider.may_use_network); + let mut programs = vec![program]; + programs.extend(provider.process.runtime_programs()); + programs +} diff --git a/rust/lithe-core/src/vcs/mod.rs b/rust/lithe-core/src/vcs/mod.rs new file mode 100644 index 0000000000..18dc26112c --- /dev/null +++ b/rust/lithe-core/src/vcs/mod.rs @@ -0,0 +1,29 @@ +//! The unified VCS layer: one model of roots, file status, history, and +//! check-in that every version control system maps onto, and the +//! deterministic half of talking to a plugin VCS provider. +//! +//! A provider is a plugin process speaking the LSP base protocol, like a +//! build provider. Core validates its `vcsProviders` declaration, detects +//! roots from static markers, plans each request from an operation name and +//! checked arguments, and validates every reply before a host renders it. +//! Hosts resolve the client tool, start and stop processes, watch metadata, +//! and own every piece of UI and state such as changelists. +//! +//! Git is not part of this layer yet: Core recognizes `.git` markers so a +//! nested working copy is assigned to the right root, and the existing +//! `git.*` commands keep serving Git roots unchanged. +//! +//! Note: the design is recorded in .agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md + +mod commands; +mod manifest; +mod model; +mod requests; +mod roots; + +pub(crate) use commands::*; +pub(crate) use manifest::{ + activation_event, programs, validate_declarations, DeclarationContext, VcsProviderDeclaration, +}; +pub(crate) use model::{is_vcs_id, negotiated_capabilities, Capabilities}; +pub(crate) use requests::{is_vcs_method, plan, Operation}; diff --git a/rust/lithe-core/src/vcs/model.rs b/rust/lithe-core/src/vcs/model.rs new file mode 100644 index 0000000000..9fc128da66 --- /dev/null +++ b/rust/lithe-core/src/vcs/model.rs @@ -0,0 +1,726 @@ +//! The VCS-neutral model: capability IDs, file statuses, revisions, and the +//! validation that turns a plugin provider's reply into a result both hosts +//! can render without trusting the provider. +//! +//! Replies are untrusted input. A value outside the contract fails the reply +//! with `plugin.vcs.invalidOutput`; optional data Core does not know (a flag, +//! a field) is dropped, so a newer provider can add it within version 1. +//! Lists the host renders as sets are sorted here, and every list is capped, +//! so both hosts show the same result for the same reply. + +use crate::plugins::declarative::{PluginRejection, Validation}; +use serde_json::{json, Map, Value}; +use std::collections::{BTreeMap, BTreeSet}; + +/// Error code of every reply that fails validation or exceeds a limit. +pub(crate) const INVALID_OUTPUT: &str = "plugin.vcs.invalidOutput"; + +/// Capability IDs of protocol version 1. `true` marks the capabilities +/// every provider must support; a provider without one of them is not used. +pub(crate) const CAPABILITIES: &[(&str, bool)] = &[ + ("annotate", false), + ("baseContent", true), + ("checkin", true), + ("cleanup", false), + ("conflicts", false), + ("fileHistory", false), + ("fileOperations", false), + ("history", false), + ("push", false), + ("rollback", true), + ("staging", false), + ("status", true), + ("update", false), +]; + +/// Version of each capability this Core build understands. +const CAPABILITY_VERSION: u64 = 1; + +/// A snapshot keeps at most this many changes and is then marked truncated. +pub(crate) const MAX_CHANGES: usize = 100_000; +/// Largest base-revision content, in UTF-8 bytes, a reply may carry. +pub(crate) const MAX_BASE_CONTENT_BYTES: usize = 32 * 1024 * 1024; +/// Most entries one history page may return; the request limit is lower. +pub(crate) const MAX_HISTORY_PAGE: usize = 500; +/// Most changed paths one history entry may list. +const MAX_ENTRY_CHANGES: usize = 10_000; +/// Most lines an annotation may cover. +pub(crate) const MAX_ANNOTATED_LINES: usize = 500_000; +/// Most distinct revisions one annotation may reference. +const MAX_ANNOTATION_REVISIONS: usize = 100_000; +/// Longest revision ID, opaque cursor, branch, or author, in characters. +const MAX_SHORT_TEXT: usize = 256; +/// Longest revision display text, in characters. +const MAX_REVISION_DISPLAY: usize = 64; +/// Longest commit message, in UTF-8 bytes. +pub(crate) const MAX_MESSAGE_BYTES: usize = 1024 * 1024; +/// Longest remote location after sanitizing, in characters. +const MAX_LOCATION: usize = 2048; +/// Longest root-relative path, in UTF-8 bytes. +const MAX_PATH_BYTES: usize = 4096; + +/// File status values of version 1. A reply with any other value fails. +const STATUSES: &[&str] = &[ + "added", + "conflicted", + "copied", + "deleted", + "ignored", + "missing", + "modified", + "renamed", + "replaced", + "switched", + "typeChanged", + "unversioned", +]; + +/// Statuses whose change names the path it came from. +const STATUSES_WITH_ORIGIN: &[&str] = &["copied", "renamed"]; + +/// Refinements of a status. Unknown flags are dropped, not rejected. +const FLAGS: &[&str] = &[ + "external", + "locked", + "lockedByOther", + "propertiesModified", + "submodule", + "treeConflict", +]; + +/// Staged state of a change; only providers with `staging` report it. +const STAGING: &[&str] = &["partial", "staged", "unstaged"]; + +/// How a host presents a revision ID; never used to order revisions. +const REVISION_KINDS: &[&str] = &["hash", "number", "opaque"]; + +/// Capabilities a provider negotiated, by ID, with their versions. +pub(crate) type Capabilities = BTreeMap; + +fn invalid_output(message: impl Into) -> PluginRejection { + PluginRejection::new(INVALID_OUTPUT, message) +} + +/// Whether `id` is a capability of protocol version 1. +pub(crate) fn is_capability(id: &str) -> bool { + CAPABILITIES.iter().any(|(known, _)| *known == id) +} + +/// The capabilities of a provider's `initialize` answer that this build +/// understands, or `None` when a required one is missing or the value is +/// not an object. Unknown IDs and versions are dropped: a newer provider may +/// offer more, and the host then hides only what Core cannot validate. +pub(crate) fn negotiated_capabilities(value: &Value) -> Option { + let offered = value.as_object()?; + let capabilities: Capabilities = offered + .iter() + .filter(|(id, version)| is_capability(id) && version.as_u64() == Some(CAPABILITY_VERSION)) + .map(|(id, _)| (id.clone(), CAPABILITY_VERSION)) + .collect(); + CAPABILITIES + .iter() + .filter(|(_, required)| *required) + .all(|(id, _)| capabilities.contains_key(*id)) + .then_some(capabilities) +} + +/// Whether a stable VCS ID is well formed: a lowercase letter, then up to +/// 31 lowercase letters, digits, or hyphens (`git`, `svn`, `hg`). +pub(crate) fn is_vcs_id(value: &str) -> bool { + let mut characters = value.chars(); + characters + .next() + .is_some_and(|first| first.is_ascii_lowercase()) + && value.len() <= 32 + && characters.all(|character| { + character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-' + }) +} + +/// Whether `value` is a path inside a root: relative, `/`-separated, and +/// without empty, `.`, or `..` segments that could leave or alias the root. +pub(crate) fn is_root_relative(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_PATH_BYTES + && !value.starts_with('/') + && !value.contains('\\') + && !value.chars().any(char::is_control) + && value + .split('/') + .all(|segment| !segment.is_empty() && segment != "." && segment != "..") + // A drive prefix would make the path absolute on Windows. + && !value.split('/').next().is_some_and(|first| first.ends_with(':')) +} + +/// Removes user info, query, and fragment from a remote location, so a URL +/// that carries credentials never leaves Core. Locations without a scheme +/// (a local repository path) are kept apart from the query and fragment. +pub(crate) fn sanitize_location(value: &str) -> Option { + let value = value.split(['?', '#']).next().unwrap_or_default(); + let sanitized = match value.split_once("://") { + Some((scheme, rest)) => { + let (authority, path) = rest + .find('/') + .map_or((rest, ""), |index| rest.split_at(index)); + let host = authority + .rsplit_once('@') + .map_or(authority, |(_, host)| host); + format!("{scheme}://{host}{path}") + } + None => value.to_string(), + }; + (!sanitized.is_empty() + && sanitized.chars().count() <= MAX_LOCATION + && !sanitized.chars().any(char::is_control)) + .then_some(sanitized) +} + +fn object<'a>(value: &'a Value, what: &str) -> Validation<&'a Map> { + value + .as_object() + .ok_or_else(|| invalid_output(format!("The {what} must be an object."))) +} + +fn short_text(value: &str, limit: usize) -> bool { + !value.is_empty() && value.chars().count() <= limit && !value.chars().any(char::is_control) +} + +/// An optional string field: absent and `null` are the same, anything but a +/// short single-line string fails. +fn optional_text( + members: &Map, + key: &str, + limit: usize, + what: &str, +) -> Validation> { + match members.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(text)) if short_text(text, limit) => Ok(Some(text.clone())), + Some(_) => Err(invalid_output(format!( + "The {what} {key} must be a single-line string of at most {limit} characters." + ))), + } +} + +fn root_path(value: Option<&Value>, what: &str) -> Validation { + match value { + Some(Value::String(path)) if is_root_relative(path) => Ok(path.clone()), + _ => Err(invalid_output(format!( + "The {what} must be a path inside the root." + ))), + } +} + +/// A revision as `{id, display, kind}`. `display` falls back to the ID. +pub(crate) fn revision(value: &Value) -> Validation { + let members = object(value, "revision")?; + let id = match members.get("id") { + Some(Value::String(id)) if short_text(id, MAX_SHORT_TEXT) => id.clone(), + _ => { + return Err(invalid_output( + "A revision ID must be a single-line string of at most 256 characters.", + )) + } + }; + let display = optional_text(members, "display", MAX_REVISION_DISPLAY, "revision")? + .unwrap_or_else(|| id.chars().take(MAX_REVISION_DISPLAY).collect()); + let kind = match members.get("kind") { + None | Some(Value::Null) => "opaque", + Some(Value::String(kind)) if REVISION_KINDS.contains(&kind.as_str()) => kind.as_str(), + Some(_) => { + return Err(invalid_output( + "A revision kind must be hash, number, or opaque.", + )) + } + }; + Ok(json!({ "id": id, "display": display, "kind": kind })) +} + +fn optional_revision(value: Option<&Value>) -> Validation { + match value { + None | Some(Value::Null) => Ok(Value::Null), + Some(value) => revision(value), + } +} + +/// One change of a status snapshot or a history entry. +fn change(value: &Value, staging: bool) -> Validation { + let members = object(value, "change")?; + let path = root_path(members.get("path"), "change path")?; + let status = match members.get("status") { + Some(Value::String(status)) if STATUSES.contains(&status.as_str()) => status.clone(), + Some(Value::String(status)) => { + return Err(invalid_output(format!( + "The file status {status} is not part of the contract." + ))) + } + _ => return Err(invalid_output("A change needs a status.")), + }; + let old_path = match members.get("oldPath") { + None | Some(Value::Null) => Value::Null, + // Only a copy or rename names where the file came from; anything + // else would make the host show a move that never happened. + Some(value) if STATUSES_WITH_ORIGIN.contains(&status.as_str()) => { + json!(root_path(Some(value), "original path")?) + } + Some(_) => { + return Err(invalid_output(format!( + "A {status} change cannot name an original path." + ))) + } + }; + let flags: BTreeSet<&str> = match members.get("flags") { + None | Some(Value::Null) => BTreeSet::new(), + Some(Value::Array(flags)) => flags + .iter() + .filter_map(Value::as_str) + .filter(|flag| FLAGS.contains(flag)) + .collect(), + Some(_) => return Err(invalid_output("Change flags must be an array.")), + }; + let mut normalized = json!({ + "path": path, + "oldPath": old_path, + "status": status, + "flags": flags, + }); + match (members.get("staging"), staging) { + (None | Some(Value::Null), false) => {} + (Some(Value::String(state)), true) if STAGING.contains(&state.as_str()) => { + normalized["staging"] = json!(state); + } + (_, true) => return Err(invalid_output( + "A provider with staging must report staged, unstaged, or partial for every change.", + )), + (Some(_), false) => { + return Err(invalid_output( + "A change carries staging although the provider did not negotiate staging.", + )) + } + } + Ok(normalized) +} + +/// Validated changes sorted by path; duplicate paths fail the reply. +fn changes(value: Option<&Value>, staging: bool, limit: usize) -> Validation<(Vec, bool)> { + let items = match value { + Some(Value::Array(items)) => items, + None | Some(Value::Null) => return Ok((Vec::new(), false)), + Some(_) => return Err(invalid_output("Changes must be an array.")), + }; + let mut by_path = BTreeMap::new(); + for item in items { + let change = change(item, staging)?; + let path = change["path"].as_str().unwrap_or_default().to_string(); + if by_path.insert(path.clone(), change).is_some() { + return Err(invalid_output(format!( + "The path {path} is reported more than once." + ))); + } + } + let truncated = by_path.len() > limit; + Ok((by_path.into_values().take(limit).collect(), truncated)) +} + +fn boolean(members: &Map, key: &str) -> Validation { + match members.get(key) { + None | Some(Value::Null) => Ok(false), + Some(Value::Bool(value)) => Ok(*value), + Some(_) => Err(invalid_output(format!("{key} must be a boolean."))), + } +} + +/// `lithe/vcs/status`: `{head, changes, truncated}` with changes sorted by +/// path and capped at [`MAX_CHANGES`]. +pub(crate) fn decode_status(reply: &Value, capabilities: &Capabilities) -> Validation { + let members = object(reply, "status reply")?; + let head = match members.get("head") { + None | Some(Value::Null) => json!({ "revision": null, "branch": null, "location": null }), + Some(head) => { + let head = object(head, "head")?; + let location = optional_text(head, "location", MAX_LOCATION, "head")? + .map(|location| { + sanitize_location(&location) + .ok_or_else(|| invalid_output("The head location is not a location.")) + }) + .transpose()?; + json!({ + "revision": optional_revision(head.get("revision"))?, + "branch": optional_text(head, "branch", MAX_SHORT_TEXT, "head")?, + "location": location, + }) + } + }; + let (changes, capped) = changes( + members.get("changes"), + capabilities.contains_key("staging"), + MAX_CHANGES, + )?; + Ok(json!({ + "head": head, + "changes": changes, + // A provider that stopped early says so; Core adds its own cap. + "truncated": capped || boolean(members, "truncated")?, + })) +} + +/// `lithe/vcs/baseContent`: the file's text at its base revision, `null` +/// content when it has none (an added file), and `binary` when the host +/// must not diff it as text. +pub(crate) fn decode_base_content(reply: &Value) -> Validation { + let members = object(reply, "base content reply")?; + let binary = boolean(members, "binary")?; + let content = match members.get("content") { + None | Some(Value::Null) => Value::Null, + Some(Value::String(_)) if binary => { + return Err(invalid_output( + "Binary base content is reported without text.", + )) + } + Some(Value::String(text)) if text.len() <= MAX_BASE_CONTENT_BYTES => json!(text), + Some(Value::String(_)) => return Err(invalid_output("The base content exceeds 32 MiB.")), + Some(_) => return Err(invalid_output("content must be a string or null.")), + }; + Ok(json!({ "content": content, "binary": binary })) +} + +fn message(members: &Map) -> Validation { + match members.get("message") { + None | Some(Value::Null) => Ok(String::new()), + Some(Value::String(text)) if text.len() <= MAX_MESSAGE_BYTES && !text.contains('\0') => { + Ok(text.clone()) + } + Some(_) => Err(invalid_output( + "A message must be a string of at most 1 MiB without NUL.", + )), + } +} + +/// Author, date, and message of one revision. Dates are passed through as +/// the provider wrote them (RFC 3339); hosts format but never compare them. +fn revision_details(members: &Map) -> Validation { + Ok(json!({ + "revision": revision(members.get("revision").unwrap_or(&Value::Null))?, + "author": optional_text(members, "author", MAX_SHORT_TEXT, "revision")?, + "date": optional_text(members, "date", MAX_SHORT_TEXT, "revision")?, + "message": message(members)?, + })) +} + +/// `lithe/vcs/history` and `lithe/vcs/fileHistory`: one page of entries in +/// the provider's order (newest first), and the cursor of the next page. +pub(crate) fn decode_history(reply: &Value, limit: usize) -> Validation { + let members = object(reply, "history reply")?; + let items = match members.get("entries") { + Some(Value::Array(items)) => items, + _ => return Err(invalid_output("A history reply needs an entries array.")), + }; + // The provider received the limit; more entries means it ignored it. + if items.len() > limit.min(MAX_HISTORY_PAGE) { + return Err(invalid_output(format!( + "The history page has more than the {limit} requested entries." + ))); + } + let mut entries = Vec::with_capacity(items.len()); + for item in items { + let entry = object(item, "history entry")?; + let mut normalized = revision_details(entry)?; + let (changes, truncated) = changes(entry.get("changes"), false, MAX_ENTRY_CHANGES)?; + normalized["changes"] = json!(changes); + normalized["changesTruncated"] = json!(truncated || boolean(entry, "changesTruncated")?); + entries.push(normalized); + } + let next_cursor = optional_text(members, "nextCursor", MAX_SHORT_TEXT, "history")?; + Ok(json!({ "entries": entries, "nextCursor": next_cursor })) +} + +/// `lithe/vcs/annotate`: the revisions a file's lines come from, and per +/// line the ID of one of them, or `null` for a line not yet checked in. +pub(crate) fn decode_annotation(reply: &Value) -> Validation { + let members = object(reply, "annotation reply")?; + let (Some(Value::Array(revisions)), Some(Value::Array(lines))) = + (members.get("revisions"), members.get("lines")) + else { + return Err(invalid_output( + "An annotation needs revisions and lines arrays.", + )); + }; + if revisions.len() > MAX_ANNOTATION_REVISIONS || lines.len() > MAX_ANNOTATED_LINES { + return Err(invalid_output("The annotation exceeds its size limit.")); + } + let mut by_id = BTreeMap::new(); + for item in revisions { + let details = revision_details(object(item, "annotation revision")?)?; + let id = details["revision"]["id"] + .as_str() + .unwrap_or_default() + .to_string(); + if by_id.insert(id.clone(), details).is_some() { + return Err(invalid_output(format!( + "The revision {id} is listed more than once." + ))); + } + } + let mut used = BTreeSet::new(); + let mut normalized_lines = Vec::with_capacity(lines.len()); + for line in lines { + match line { + Value::Null => normalized_lines.push(Value::Null), + Value::String(id) if by_id.contains_key(id) => { + used.insert(id.clone()); + normalized_lines.push(json!(id)); + } + _ => { + return Err(invalid_output( + "Every annotated line names a listed revision or null.", + )) + } + } + } + // Revisions no line uses would show up in a legend with nothing to point + // at; sorting by ID keeps the list stable without ordering revisions. + let revisions: Vec = by_id + .into_iter() + .filter(|(id, _)| used.contains(id)) + .map(|(_, details)| details) + .collect(); + Ok(json!({ "revisions": revisions, "lines": normalized_lines })) +} + +/// `lithe/vcs/checkin`: the new revision, or `null` when the provider +/// cannot name it. +pub(crate) fn decode_checkin(reply: &Value) -> Validation { + let members = object(reply, "check-in reply")?; + Ok(json!({ "revision": optional_revision(members.get("revision"))? })) +} + +/// `lithe/vcs/update`: the revision the working copy is at now, and the +/// paths the update left conflicted, sorted. +pub(crate) fn decode_update(reply: &Value) -> Validation { + let members = object(reply, "update reply")?; + let conflicts: BTreeSet = match members.get("conflicts") { + None | Some(Value::Null) => BTreeSet::new(), + Some(Value::Array(paths)) => paths + .iter() + .map(|path| root_path(Some(path), "conflicted path")) + .collect::>()?, + Some(_) => return Err(invalid_output("conflicts must be an array of paths.")), + }; + if conflicts.len() > MAX_CHANGES { + return Err(invalid_output("The update reports too many conflicts.")); + } + Ok(json!({ + "revision": optional_revision(members.get("revision"))?, + "conflicts": conflicts, + })) +} + +/// Replies that carry no data. Anything in them is ignored, so a provider +/// may report extra details without breaking an older host. +pub(crate) fn decode_empty(reply: &Value) -> Validation { + match reply { + Value::Null | Value::Object(_) => Ok(json!({})), + _ => Err(invalid_output("The reply must be an object or null.")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn capabilities(staging: bool) -> Capabilities { + let mut capabilities: Capabilities = CAPABILITIES + .iter() + .filter(|(_, required)| *required) + .map(|(id, _)| (id.to_string(), 1)) + .collect(); + if staging { + capabilities.insert("staging".to_string(), 1); + } + capabilities + } + + #[test] + fn negotiation_keeps_known_version_one_capabilities_and_requires_the_core_set() { + let negotiated = negotiated_capabilities(&json!({ + "status": 1, "baseContent": 1, "checkin": 1, "rollback": 1, + "annotate": 2, "futureThing": 1, "update": 1 + })) + .expect("required set present"); + assert_eq!( + negotiated.keys().map(String::as_str).collect::>(), + ["baseContent", "checkin", "rollback", "status", "update"] + ); + // A provider without rollback cannot back the generic commit UI. + assert!( + negotiated_capabilities(&json!({ "status": 1, "baseContent": 1, "checkin": 1 })) + .is_none() + ); + assert!(negotiated_capabilities(&json!(["status"])).is_none()); + } + + #[test] + fn root_relative_paths_cannot_leave_or_alias_the_root() { + for valid in ["a.txt", "src/Main.java", ".svnignore", "a b/c"] { + assert!(is_root_relative(valid), "{valid}"); + } + for invalid in [ + "", + "/etc/passwd", + "../x", + "a/../b", + "a//b", + "./a", + "a/", + "a\\b", + "C:/x", + "a\u{0}b", + ] { + assert!(!is_root_relative(invalid), "{invalid:?}"); + } + } + + #[test] + fn locations_lose_credentials_query_and_fragment() { + assert_eq!( + sanitize_location("https://user:secret@svn.example.invalid/repo/trunk?p=1#f") + .as_deref(), + Some("https://svn.example.invalid/repo/trunk") + ); + assert_eq!( + sanitize_location("svn+ssh://me@host.invalid").as_deref(), + Some("svn+ssh://host.invalid") + ); + assert_eq!( + sanitize_location("file:///var/svn/repo").as_deref(), + Some("file:///var/svn/repo") + ); + assert_eq!(sanitize_location("?only=query"), None); + } + + #[test] + fn status_reply_is_normalized_and_capped() { + let reply = json!({ + "head": { + "revision": { "id": "12", "kind": "number" }, + "location": "https://u:p@svn.example.invalid/r", + "branch": null + }, + "changes": [ + { "path": "b", "status": "modified", "flags": ["propertiesModified", "locked", "x"] }, + { "path": "a", "oldPath": "old", "status": "renamed" } + ] + }); + let snapshot = decode_status(&reply, &capabilities(false)).expect("valid"); + assert_eq!( + snapshot, + json!({ + "head": { + "revision": { "id": "12", "display": "12", "kind": "number" }, + "branch": null, + "location": "https://svn.example.invalid/r" + }, + "changes": [ + { "path": "a", "oldPath": "old", "status": "renamed", "flags": [] }, + { "path": "b", "oldPath": null, "status": "modified", "flags": ["locked", "propertiesModified"] } + ], + "truncated": false + }) + ); + } + + #[test] + fn status_reply_rejects_contract_violations() { + for reply in [ + json!({ "changes": [{ "path": "a", "status": "modified" }, { "path": "a", "status": "added" }] }), + json!({ "changes": [{ "path": "a", "oldPath": "b", "status": "modified" }] }), + json!({ "changes": [{ "path": "a", "status": "modified", "flags": "locked" }] }), + json!({ "changes": "a" }), + json!({ "head": { "revision": { "id": "" } } }), + json!({ "head": { "revision": { "id": "1", "kind": "date" } } }), + ] { + let error = decode_status(&reply, &capabilities(false)).expect_err("rejected"); + assert_eq!(error.code, INVALID_OUTPUT, "{reply}"); + } + // With staging negotiated every change must say how it is staged. + let error = decode_status( + &json!({ "changes": [{ "path": "a", "status": "modified" }] }), + &capabilities(true), + ) + .expect_err("missing staging"); + assert_eq!(error.code, INVALID_OUTPUT); + } + + #[test] + fn oversized_snapshots_are_marked_truncated() { + let changes: Vec = (0..=MAX_CHANGES) + .map(|index| json!({ "path": format!("f{index:06}"), "status": "unversioned" })) + .collect(); + let snapshot = + decode_status(&json!({ "changes": changes }), &capabilities(false)).expect("valid"); + assert_eq!( + snapshot["changes"].as_array().map(Vec::len), + Some(MAX_CHANGES) + ); + assert_eq!(snapshot["truncated"], json!(true)); + } + + #[test] + fn base_content_distinguishes_text_binary_and_absent() { + assert_eq!( + decode_base_content(&json!({ "content": "x\n" })).expect("text"), + json!({ "content": "x\n", "binary": false }) + ); + assert_eq!( + decode_base_content(&json!({ "content": null, "binary": true })).expect("binary"), + json!({ "content": null, "binary": true }) + ); + assert!(decode_base_content(&json!({ "content": "x", "binary": true })).is_err()); + } + + #[test] + fn history_page_keeps_provider_order_and_enforces_the_limit() { + let reply = json!({ + "entries": [ + { "revision": { "id": "9", "kind": "number" }, "author": "ann", "date": "2026-10-22T08:00:00Z", "message": "second", + "changes": [{ "path": "b", "status": "modified" }, { "path": "a", "status": "added" }] }, + { "revision": { "id": "3", "kind": "number" }, "message": "first" } + ], + "nextCursor": "3" + }); + let page = decode_history(&reply, 2).expect("valid"); + assert_eq!(page["entries"][0]["revision"]["id"], "9"); + assert_eq!(page["entries"][0]["changes"][0]["path"], "a"); + assert_eq!(page["entries"][1]["author"], Value::Null); + assert_eq!(page["nextCursor"], "3"); + assert!(decode_history(&reply, 1).is_err()); + } + + #[test] + fn annotation_lines_must_reference_listed_revisions() { + let reply = json!({ + "revisions": [ + { "revision": { "id": "2" }, "author": "bo" }, + { "revision": { "id": "1" }, "author": "al" }, + { "revision": { "id": "7" } } + ], + "lines": ["1", "2", null, "1"] + }); + let annotation = decode_annotation(&reply).expect("valid"); + assert_eq!(annotation["lines"], json!(["1", "2", null, "1"])); + // Unused revision 7 is dropped; the rest are ordered by ID. + assert_eq!(annotation["revisions"][0]["revision"]["id"], "1"); + assert_eq!(annotation["revisions"].as_array().map(Vec::len), Some(2)); + assert!(decode_annotation(&json!({ "revisions": [], "lines": ["1"] })).is_err()); + } + + #[test] + fn update_reports_sorted_conflicts_inside_the_root() { + assert_eq!( + decode_update(&json!({ "revision": { "id": "5" }, "conflicts": ["b", "a", "b"] })) + .expect("valid"), + json!({ "revision": { "id": "5", "display": "5", "kind": "opaque" }, "conflicts": ["a", "b"] }) + ); + assert!(decode_update(&json!({ "conflicts": ["/abs"] })).is_err()); + } +} diff --git a/rust/lithe-core/src/vcs/requests.rs b/rust/lithe-core/src/vcs/requests.rs new file mode 100644 index 0000000000..4923fe42c4 --- /dev/null +++ b/rust/lithe-core/src/vcs/requests.rs @@ -0,0 +1,503 @@ +//! Operations of the VCS provider protocol: which method and capability each +//! one uses, how a host's arguments become request params, and how the +//! provider's reply is decoded. +//! +//! The host names an operation, never a method, so a session that negotiated +//! the protocol cannot be used as a generic JSON-RPC tunnel. Arguments come +//! from the host and are checked as strictly as replies: paths stay inside the +//! root, lists are bounded, and unknown fields fail, because a provider runs +//! the VCS client with them. + +use super::model::{self, Capabilities, MAX_HISTORY_PAGE, MAX_MESSAGE_BYTES}; +use crate::plugins::declarative::Validation; +use crate::protocol::{CoreError, ErrorCode}; +use serde::de::DeserializeOwned; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::BTreeSet; +use std::path::Path; + +/// Most paths one mutation may name. +const MAX_PATHS: usize = 10_000; +/// History page size when the host does not ask for one. +const DEFAULT_HISTORY_LIMIT: usize = 100; + +/// One operation of protocol version 1. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Operation { + Status, + BaseContent, + History, + FileHistory, + Annotate, + Checkin, + Rollback, + Update, + FileOperation, + Resolve, + Cleanup, +} + +/// Every operation, for method checks and contract tests. +pub(crate) const OPERATIONS: [Operation; 11] = [ + Operation::Status, + Operation::BaseContent, + Operation::History, + Operation::FileHistory, + Operation::Annotate, + Operation::Checkin, + Operation::Rollback, + Operation::Update, + Operation::FileOperation, + Operation::Resolve, + Operation::Cleanup, +]; + +impl Operation { + /// Parses the operation name a host sends, which is also the last + /// segment of its method. + pub(crate) fn parse(name: &str) -> Option { + OPERATIONS + .into_iter() + .find(|operation| operation.name() == name) + } + + pub(crate) fn name(self) -> &'static str { + match self { + Self::Status => "status", + Self::BaseContent => "baseContent", + Self::History => "history", + Self::FileHistory => "fileHistory", + Self::Annotate => "annotate", + Self::Checkin => "checkin", + Self::Rollback => "rollback", + Self::Update => "update", + Self::FileOperation => "fileOperation", + Self::Resolve => "resolve", + Self::Cleanup => "cleanup", + } + } + + /// JSON-RPC method the host writes to the provider. + pub(crate) fn method(self) -> &'static str { + match self { + Self::Status => "lithe/vcs/status", + Self::BaseContent => "lithe/vcs/baseContent", + Self::History => "lithe/vcs/history", + Self::FileHistory => "lithe/vcs/fileHistory", + Self::Annotate => "lithe/vcs/annotate", + Self::Checkin => "lithe/vcs/checkin", + Self::Rollback => "lithe/vcs/rollback", + Self::Update => "lithe/vcs/update", + Self::FileOperation => "lithe/vcs/fileOperation", + Self::Resolve => "lithe/vcs/resolve", + Self::Cleanup => "lithe/vcs/cleanup", + } + } + + /// Capability the provider must have negotiated for this operation. + pub(crate) fn capability(self) -> &'static str { + match self { + Self::FileOperation => "fileOperations", + Self::Resolve => "conflicts", + other => other.name(), + } + } +} + +/// Whether `method` belongs to the protocol; its replies are wrapped like +/// command results so no LSP normalizer reinterprets them. +pub(crate) fn is_vcs_method(method: &str) -> bool { + OPERATIONS + .into_iter() + .any(|operation| operation.method() == method) +} + +fn invalid_request(message: impl Into) -> CoreError { + CoreError::new(ErrorCode::InvalidRequest, message) +} + +fn arguments(operation: Operation, value: Value) -> Result { + // An operation without arguments may omit them entirely. + let value = if value.is_null() { json!({}) } else { value }; + serde_json::from_value(value).map_err(|error| { + invalid_request(format!("Invalid {} arguments", operation.name())) + .with_details(error.to_string()) + }) +} + +fn root_path(path: &str) -> Result { + if model::is_root_relative(path) { + Ok(path.to_string()) + } else { + Err(invalid_request("A VCS path must be relative to its root.").with_details(path)) + } +} + +/// Root-relative paths, sorted and unique; at least one, at most +/// [`MAX_PATHS`]. +fn path_list(paths: &[String]) -> Result, CoreError> { + if paths.is_empty() || paths.len() > MAX_PATHS { + return Err(invalid_request(format!( + "An operation names 1-{MAX_PATHS} paths." + ))); + } + let unique: BTreeSet = paths + .iter() + .map(|path| root_path(path)) + .collect::>()?; + Ok(unique.into_iter().collect()) +} + +fn absolute_path(value: &str, what: &str) -> Result { + if Path::new(value).is_absolute() && !value.contains('\0') { + Ok(value.to_string()) + } else { + Err(invalid_request(format!( + "The {what} must be an absolute path." + ))) + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct StatusArguments { + #[serde(default)] + include_ignored: bool, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct PathArguments { + path: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct PathsArguments { + paths: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct HistoryArguments { + #[serde(default)] + path: Option, + /// Opaque cursor from the previous page; absent for the first page. + #[serde(default)] + cursor: Option, + #[serde(default)] + limit: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CheckinArguments { + paths: Vec, + message: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct UpdateArguments { + /// Revision ID to update to, as the provider reported it; absent for + /// the newest revision. + #[serde(default)] + revision: Option, +} + +/// A change made in the project tree that the VCS must record. `move` +/// carries both ends so the VCS keeps the file's history. +#[derive(Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)] +enum FileOperationArguments { + Add { paths: Vec }, + Delete { paths: Vec }, + Move { from: String, to: String }, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct NoArguments {} + +/// The params of one request, without `root` and `tool`. +fn operation_params(operation: Operation, value: Value) -> Result { + Ok(match operation { + Operation::Status => { + let arguments: StatusArguments = arguments(operation, value)?; + json!({ "includeIgnored": arguments.include_ignored }) + } + Operation::BaseContent | Operation::Annotate => { + let arguments: PathArguments = arguments(operation, value)?; + json!({ "path": root_path(&arguments.path)? }) + } + Operation::History | Operation::FileHistory => { + let arguments: HistoryArguments = arguments(operation, value)?; + let limit = arguments.limit.unwrap_or(DEFAULT_HISTORY_LIMIT); + if limit == 0 || limit > MAX_HISTORY_PAGE { + return Err(invalid_request(format!( + "A history page has 1-{MAX_HISTORY_PAGE} entries." + ))); + } + if let Some(cursor) = &arguments.cursor { + if cursor.is_empty() || cursor.len() > 256 || cursor.chars().any(char::is_control) { + return Err(invalid_request("The history cursor is malformed.")); + } + } + let mut params = json!({ "cursor": arguments.cursor, "limit": limit }); + match (operation, arguments.path) { + (Operation::FileHistory, Some(path)) => params["path"] = json!(root_path(&path)?), + (Operation::History, None) => {} + _ => { + return Err(invalid_request( + "fileHistory needs a path; history of the whole root takes none.", + )) + } + } + params + } + Operation::Checkin => { + let arguments: CheckinArguments = arguments(operation, value)?; + if arguments.message.trim().is_empty() + || arguments.message.len() > MAX_MESSAGE_BYTES + || arguments.message.contains('\0') + { + return Err(invalid_request( + "A check-in message is non-empty text of at most 1 MiB.", + )); + } + json!({ "paths": path_list(&arguments.paths)?, "message": arguments.message }) + } + Operation::Rollback | Operation::Resolve => { + let arguments: PathsArguments = arguments(operation, value)?; + json!({ "paths": path_list(&arguments.paths)? }) + } + Operation::Update => { + let arguments: UpdateArguments = arguments(operation, value)?; + if let Some(revision) = &arguments.revision { + if revision.is_empty() + || revision.len() > 256 + || revision.chars().any(char::is_control) + { + return Err(invalid_request("The revision ID is malformed.")); + } + } + json!({ "revision": arguments.revision }) + } + Operation::FileOperation => match arguments(operation, value)? { + FileOperationArguments::Add { paths } => { + json!({ "kind": "add", "paths": path_list(&paths)? }) + } + FileOperationArguments::Delete { paths } => { + json!({ "kind": "delete", "paths": path_list(&paths)? }) + } + FileOperationArguments::Move { from, to } => { + let (from, to) = (root_path(&from)?, root_path(&to)?); + if from == to { + return Err(invalid_request("A move needs two different paths.")); + } + json!({ "kind": "move", "from": from, "to": to }) + } + }, + Operation::Cleanup => { + let _: NoArguments = arguments(operation, value)?; + json!({}) + } + }) +} + +/// A request ready to be written to a provider session. +#[derive(Debug)] +pub(crate) struct PlannedRequest { + pub(crate) method: &'static str, + pub(crate) params: Value, +} + +/// Plans one request. `root` is the native path of the working-copy root and +/// `tool` the client executable the host resolved for the provider; both are +/// sent with every request so the provider never searches for either. +pub(crate) fn plan( + operation: Operation, + root: &str, + tool: Option<&str>, + arguments: Value, + capabilities: &Capabilities, +) -> Result { + if !capabilities.contains_key(operation.capability()) { + return Err(CoreError::new( + ErrorCode::NotSupported, + format!( + "The VCS provider did not negotiate {}.", + operation.capability() + ), + ) + .with_details("vcs.capability.unsupported")); + } + let mut params = operation_params(operation, arguments)?; + params["root"] = json!({ "path": absolute_path(root, "VCS root")? }); + params["tool"] = match tool { + Some(tool) => json!({ "executable": absolute_path(tool, "VCS tool")? }), + None => Value::Null, + }; + Ok(PlannedRequest { + method: operation.method(), + params, + }) +} + +/// Decodes the reply to `operation`. `request` is the arguments the host +/// sent, which bound what a valid reply may contain. +pub(crate) fn decode( + operation: Operation, + request: &Value, + reply: &Value, + capabilities: &Capabilities, +) -> Validation { + match operation { + Operation::Status => model::decode_status(reply, capabilities), + Operation::BaseContent => model::decode_base_content(reply), + Operation::History | Operation::FileHistory => { + let limit = request + .get("limit") + .and_then(Value::as_u64) + .and_then(|limit| usize::try_from(limit).ok()) + .unwrap_or(DEFAULT_HISTORY_LIMIT) + .min(MAX_HISTORY_PAGE); + model::decode_history(reply, limit) + } + Operation::Annotate => model::decode_annotation(reply), + Operation::Checkin => model::decode_checkin(reply), + Operation::Update => model::decode_update(reply), + Operation::Rollback + | Operation::FileOperation + | Operation::Resolve + | Operation::Cleanup => model::decode_empty(reply), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn all_capabilities() -> Capabilities { + model::CAPABILITIES + .iter() + .map(|(id, _)| (id.to_string(), 1)) + .collect() + } + + fn root() -> String { + std::env::temp_dir() + .join("lithe-vcs-root") + .to_string_lossy() + .into_owned() + } + + #[test] + fn every_operation_round_trips_its_name_and_uses_a_contract_capability() { + for operation in OPERATIONS { + assert_eq!(Operation::parse(operation.name()), Some(operation)); + assert!(is_vcs_method(operation.method())); + assert!( + model::is_capability(operation.capability()), + "{operation:?}" + ); + } + assert!(!is_vcs_method("workspace/executeCommand")); + assert_eq!(Operation::parse("push"), None); + } + + #[test] + fn a_plan_carries_root_tool_and_checked_arguments() { + let tool = std::env::temp_dir() + .join("svn") + .to_string_lossy() + .into_owned(); + let planned = plan( + Operation::Checkin, + &root(), + Some(&tool), + json!({ "paths": ["b", "a", "b"], "message": "Fix" }), + &all_capabilities(), + ) + .expect("planned"); + assert_eq!(planned.method, "lithe/vcs/checkin"); + assert_eq!(planned.params["paths"], json!(["a", "b"])); + assert_eq!(planned.params["root"]["path"], json!(root())); + assert_eq!(planned.params["tool"]["executable"], json!(tool)); + } + + #[test] + fn a_plan_rejects_unsafe_or_unknown_arguments() { + let capabilities = all_capabilities(); + for (operation, arguments) in [ + (Operation::Rollback, json!({ "paths": ["../x"] })), + (Operation::Rollback, json!({ "paths": [] })), + ( + Operation::Checkin, + json!({ "paths": ["a"], "message": " " }), + ), + (Operation::Status, json!({ "recursive": false })), + (Operation::History, json!({ "path": "a" })), + (Operation::FileHistory, json!({})), + (Operation::History, json!({ "limit": 0 })), + ( + Operation::FileOperation, + json!({ "kind": "move", "from": "a", "to": "a" }), + ), + (Operation::Cleanup, json!({ "force": true })), + ] { + let error = plan(operation, &root(), None, arguments.clone(), &capabilities) + .expect_err("rejected"); + assert!( + matches!(error.code, ErrorCode::InvalidRequest), + "{operation:?} {arguments}" + ); + } + assert!(plan( + Operation::Status, + "relative", + None, + Value::Null, + &capabilities + ) + .is_err()); + } + + #[test] + fn an_operation_needs_its_negotiated_capability() { + let mut capabilities = all_capabilities(); + capabilities.remove("conflicts"); + let error = plan( + Operation::Resolve, + &root(), + None, + json!({ "paths": ["a"] }), + &capabilities, + ) + .expect_err("unsupported"); + assert!(matches!(error.code, ErrorCode::NotSupported)); + assert_eq!(error.details.as_deref(), Some("vcs.capability.unsupported")); + } + + #[test] + fn history_replies_are_bounded_by_the_requested_limit() { + let reply = json!({ "entries": [ + { "revision": { "id": "2" } }, { "revision": { "id": "1" } } + ] }); + assert!(decode( + Operation::History, + &json!({ "limit": 2 }), + &reply, + &all_capabilities() + ) + .is_ok()); + assert!(decode( + Operation::History, + &json!({ "limit": 1 }), + &reply, + &all_capabilities() + ) + .is_err()); + } +} diff --git a/rust/lithe-core/src/vcs/roots.rs b/rust/lithe-core/src/vcs/roots.rs new file mode 100644 index 0000000000..51a7e64d02 --- /dev/null +++ b/rust/lithe-core/src/vcs/roots.rs @@ -0,0 +1,376 @@ +//! Which VCS owns which directory of a workspace. +//! +//! Roots are found from static markers only, so opening a workspace never +//! starts a provider. One bounded walk serves every provider: it records the +//! markers directly inside each directory, never descends into a VCS's own +//! metadata directory or through a symbolic link, and stops at a depth and +//! directory budget. When the workspace root itself carries no marker, its +//! ancestors are checked too, because a Subversion working copy has a single +//! `.svn` at its top and a workspace may be opened below it. +//! +//! A file belongs to the deepest root that contains it. A directory with +//! markers of several providers is ambiguous and owns nothing until the +//! user maps it to one of them; Core never guesses. + +use super::manifest::{MarkerKind, VcsMarker}; +use super::model::is_root_relative; +use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::fs::{self, FileType}; +use std::path::{Path, PathBuf}; + +/// Deepest directory, below the workspace root, whose markers are read. +const MAX_DEPTH: usize = 8; +/// Most directories one detection reads before reporting a partial result. +const MAX_DIRECTORIES: usize = 20_000; + +/// Directories that hold generated or downloaded files and never a working +/// copy worth tracking, and whose size would dominate the walk. Lithe's own +/// worktrees are pruned so they are not listed as nested repositories. +const PRUNED: &[&str] = &[ + ".build", + ".cargo", + ".dart_tool", + ".gradle", + ".idea", + ".lithe", + ".mypy_cache", + ".next", + ".nuxt", + ".pytest_cache", + ".stack-work", + ".svelte-kit", + ".swiftpm", + ".terraform", + ".tox", + ".venv", + ".vscode", + ".worktree", + ".worktrees", + "DerivedData", + "Pods", + "__pycache__", + "bower_components", + "node_modules", + "target", +]; + +/// The markers of one VCS, as detection sees them. +pub(crate) struct MarkerSet<'a> { + pub(crate) vcs: &'a str, + pub(crate) markers: &'a [VcsMarker], +} + +/// A detected root. `id` is workspace-relative with `/` separators: `""` is +/// the workspace root and `..` segments name an enclosing directory. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Root { + pub(crate) id: String, + pub(crate) vcs: String, + pub(crate) path: PathBuf, + /// Whether the user's mapping, not a single marker, chose the VCS. + pub(crate) mapped: bool, +} + +/// A directory with markers of several VCSs and no mapping. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Ambiguous { + pub(crate) id: String, + pub(crate) vcs: Vec, +} + +/// The user's choice for an ambiguous directory. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Mapping { + pub(crate) id: String, + pub(crate) vcs: String, +} + +#[derive(Debug, Default)] +pub(crate) struct Detection { + pub(crate) roots: Vec, + pub(crate) ambiguous: Vec, + /// Mappings that name no ambiguous directory, or a VCS whose markers + /// are not there. Hosts may offer to forget them. + pub(crate) unused_mappings: Vec, + /// Whether the directory budget cut the walk short, so a root may be + /// missing. Roots deeper than [`MAX_DEPTH`] are never looked for. + pub(crate) truncated: bool, +} + +/// Whether `id` names a directory enclosing the workspace: only `..` +/// segments. A directory inside the workspace may still be named `..repo`. +fn is_enclosing(id: &str) -> bool { + !id.is_empty() && id.split('/').all(|segment| segment == "..") +} + +/// Sort key that puts enclosing roots first, then inner roots by depth. +pub(crate) fn depth(id: &str) -> i64 { + if id.is_empty() { + return 0; + } + let segments = id.split('/').count() as i64; + if is_enclosing(id) { + -segments + } else { + segments + } +} + +/// Whether `id` has the shape of a root or ambiguous-directory ID: `""`, +/// only `..` segments, or a path inside the workspace. +pub(crate) fn is_directory_id(id: &str) -> bool { + id.is_empty() || is_enclosing(id) || is_root_relative(id) +} + +/// Whether the root or ambiguous directory `id` contains the +/// workspace-relative `path`. +fn contains(id: &str, path: &str) -> bool { + id.is_empty() + || is_enclosing(id) + || path == id + || path + .strip_prefix(id) + .is_some_and(|rest| rest.starts_with('/')) +} + +/// The root ID that owns a workspace-relative path, or `None` when the path +/// is outside every root or inside an unmapped ambiguous directory. +pub(crate) fn owner<'a>(roots: &'a [String], ambiguous: &[String], path: &str) -> Option<&'a str> { + let deepest_root = roots + .iter() + .filter(|root| contains(root, path)) + .max_by_key(|root| depth(root)); + let deepest_ambiguous = ambiguous + .iter() + .filter(|directory| contains(directory, path)) + .map(|directory| depth(directory)) + .max(); + match (deepest_root, deepest_ambiguous) { + (Some(root), Some(ambiguous)) if ambiguous > depth(root) => None, + (root, _) => root.map(String::as_str), + } +} + +/// Resolves a directory entry's kind, following a link only for the +/// marker check itself (a `.git` that links elsewhere still marks a root). +fn marker_kind(path: &Path, file_type: FileType) -> Option { + let file_type = if file_type.is_symlink() { + fs::metadata(path).ok()?.file_type() + } else { + file_type + }; + if file_type.is_dir() { + Some(MarkerKind::Directory) + } else if file_type.is_file() { + Some(MarkerKind::File) + } else { + None + } +} + +fn matches(sets: &[MarkerSet<'_>], name: &str, kind: MarkerKind) -> Vec { + sets.iter() + .filter(|set| { + set.markers + .iter() + .any(|marker| marker.name == name && marker.kind == kind) + }) + .map(|set| set.vcs.to_string()) + .collect() +} + +/// VCSs whose markers sit directly in `directory`, checked by name. +fn markers_at(directory: &Path, sets: &[MarkerSet<'_>]) -> BTreeSet { + let names: BTreeSet<&str> = sets + .iter() + .flat_map(|set| set.markers.iter().map(|marker| marker.name.as_str())) + .collect(); + let mut found = BTreeSet::new(); + for name in names { + let path = directory.join(name); + let Ok(metadata) = fs::symlink_metadata(&path) else { + continue; + }; + if let Some(kind) = marker_kind(&path, metadata.file_type()) { + found.extend(matches(sets, name, kind)); + } + } + found +} + +/// Walks the workspace and its ancestors. `workspace` must be absolute. +pub(crate) fn detect(workspace: &Path, sets: &[MarkerSet<'_>], mappings: &[Mapping]) -> Detection { + let metadata_directories: BTreeSet<&str> = sets + .iter() + .flat_map(|set| set.markers.iter()) + .filter(|marker| marker.kind == MarkerKind::Directory) + .map(|marker| marker.name.as_str()) + .collect(); + let mut found: BTreeMap)> = BTreeMap::new(); + let mut truncated = false; + let mut visited = 0usize; + let mut queue = VecDeque::from([(workspace.to_path_buf(), String::new(), 0usize)]); + // Breadth first, children sorted by name: when the budget runs out the + // shallow roots are kept, and two runs over one tree agree. + while let Some((directory, relative, level)) = queue.pop_front() { + if visited == MAX_DIRECTORIES { + truncated = true; + break; + } + visited += 1; + let Ok(entries) = fs::read_dir(&directory) else { + continue; + }; + let mut entries: Vec<_> = entries + .flatten() + .filter_map(|entry| { + let name = entry.file_name().to_str()?.to_string(); + Some((name, entry.path(), entry.file_type().ok()?)) + }) + .collect(); + entries.sort_by(|left, right| left.0.cmp(&right.0)); + let mut here = BTreeSet::new(); + for (name, path, file_type) in entries { + if let Some(kind) = marker_kind(&path, file_type) { + here.extend(matches(sets, &name, kind)); + } + // Source trees are routinely deeper than the limit, so stopping + // there is the rule rather than a partial result. + if !file_type.is_dir() + || level == MAX_DEPTH + || metadata_directories.contains(name.as_str()) + || PRUNED.contains(&name.as_str()) + { + continue; + } + let child = if relative.is_empty() { + name + } else { + format!("{relative}/{name}") + }; + queue.push_back((path, child, level + 1)); + } + if !here.is_empty() { + found.insert(relative, (directory, here)); + } + } + + if !found.contains_key("") { + let mut id = String::from(".."); + let mut ancestor = workspace.parent(); + while let Some(directory) = ancestor { + let here = markers_at(directory, sets); + if !here.is_empty() { + found.insert(id, (directory.to_path_buf(), here)); + break; + } + id.push_str("/.."); + ancestor = directory.parent(); + } + } + + let mut detection = Detection { + truncated, + ..Detection::default() + }; + let mut used = BTreeSet::new(); + for (id, (path, vcs)) in found { + if vcs.len() == 1 { + let vcs = vcs.into_iter().next().unwrap_or_default(); + detection.roots.push(Root { + id, + vcs, + path, + mapped: false, + }); + continue; + } + match mappings + .iter() + .find(|mapping| mapping.id == id && vcs.contains(&mapping.vcs)) + { + Some(mapping) => { + used.insert((mapping.id.clone(), mapping.vcs.clone())); + detection.roots.push(Root { + id, + vcs: mapping.vcs.clone(), + path, + mapped: true, + }); + } + None => detection.ambiguous.push(Ambiguous { + id, + vcs: vcs.into_iter().collect(), + }), + } + } + detection.unused_mappings = mappings + .iter() + .filter(|mapping| !used.contains(&(mapping.id.clone(), mapping.vcs.clone()))) + .cloned() + .collect(); + detection + .unused_mappings + .sort_by(|left, right| (&left.id, &left.vcs).cmp(&(&right.id, &right.vcs))); + detection.unused_mappings.dedup(); + detection + .roots + .sort_by(|left, right| (depth(&left.id), &left.id).cmp(&(depth(&right.id), &right.id))); + detection + .ambiguous + .sort_by(|left, right| (depth(&left.id), &left.id).cmp(&(depth(&right.id), &right.id))); + detection +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ids(values: &[&str]) -> Vec { + values.iter().map(ToString::to_string).collect() + } + + #[test] + fn the_deepest_root_owns_a_path_and_ambiguity_blocks_fallback() { + let roots = ids(&["..", "", "a/b"]); + let ambiguous = ids(&["m"]); + let owner_of = |path| owner(&roots, &ambiguous, path); + assert_eq!(owner_of("README.md"), Some("")); + assert_eq!(owner_of("a/b/c.txt"), Some("a/b")); + assert_eq!(owner_of("a/bc.txt"), Some("")); + // Files below an unmapped ambiguous directory belong to nobody, not + // to the enclosing Git root. + assert_eq!(owner_of("m/x.txt"), None); + assert_eq!(owner_of("mx.txt"), Some("")); + } + + #[test] + fn a_directory_named_like_dot_dot_is_inside_the_workspace() { + let roots = ids(&["", "..repo"]); + let owner_of = |path| owner(&roots, &[], path); + assert_eq!(owner_of("..repo/a.txt"), Some("..repo")); + assert_eq!(owner_of("README.md"), Some("")); + // Without a workspace root, `..repo` owns only its own files. + let inner = ids(&["..repo"]); + assert_eq!(owner(&inner, &[], "src/main.rs"), None); + assert_eq!(depth("..repo"), 1); + assert!(is_directory_id("..repo/x")); + } + + #[test] + fn enclosing_roots_sort_before_inner_ones() { + let mut ids = vec!["a/b", "", "../..", "a", ".."]; + ids.sort_by_key(|id| (depth(id), *id)); + assert_eq!(ids, ["../..", "..", "", "a", "a/b"]); + } + + #[test] + fn directory_ids_are_enclosing_or_inside_the_workspace() { + for id in ["", "..", "../..", "a", "a/b"] { + assert!(is_directory_id(id), "{id}"); + } + for id in ["/a", "../a", "a/..", "a//b", "./a", "a\\b"] { + assert!(!is_directory_id(id), "{id}"); + } + } +} diff --git a/rust/lithe-core/tests/git_path_roundtrip.rs b/rust/lithe-core/tests/git_path_roundtrip.rs index 58bfa986c9..9b8c9e1c48 100644 --- a/rust/lithe-core/tests/git_path_roundtrip.rs +++ b/rust/lithe-core/tests/git_path_roundtrip.rs @@ -32,9 +32,11 @@ impl Drop for Fixture { } fn run(command: &mut Command) -> String { - // Git/Node startup can exceed five seconds on Windows CI. Keep a local - // deadline inside the timing harness's 15-second case watchdog. - let deadline = Instant::now() + Duration::from_secs(10); + run_with_timeout(command, Duration::from_secs(10)) +} + +fn run_with_timeout(command: &mut Command, timeout: Duration) -> String { + let deadline = Instant::now() + timeout; let result = lithe_git_host::run( command, None, @@ -44,8 +46,10 @@ fn run(command: &mut Command) -> String { ); assert!( result.failure.is_none(), - "{command:?}: {:?}", - result.failure + "{command:?} within {timeout:?}: {:?}; status: {:?}; stderr: {}", + result.failure, + result.status, + String::from_utf8_lossy(&result.stderr) ); assert!( result.status.is_some_and(|status| status.success()), @@ -88,11 +92,15 @@ fn frontend_path(path: &str) -> String { .join("../../windows/tauri/src/features/git/api/git-repository-path.ts"); // Node is already required by both platform timing harnesses. Import the // production TypeScript function rather than duplicating it in Rust. - run(Command::new("node").args([ + // Windows CI reached the old 10s deadline in this Node/TypeScript process. + // Keep Git and Core request deadlines unchanged; + // only this frontend subprocess gets 20s within the scoped 60s case budget. + let timeout = Duration::from_secs(if cfg!(windows) { 20 } else { 10 }); + run_with_timeout(Command::new("node").args([ "--experimental-strip-types", "--input-type=module", "-e", "import { pathToFileURL } from 'node:url'; const { normalizeRepositoryPath } = await import(pathToFileURL(process.argv[1]).href); console.log(normalizeRepositoryPath(process.argv[2]));", module.to_str().unwrap(), path, - ])) + ]), timeout) } fn initialize(root: &Path) -> String { diff --git a/rust/lithe-core/tests/git_push.rs b/rust/lithe-core/tests/git_push.rs index 7b4d32a8b4..31991cf232 100644 --- a/rust/lithe-core/tests/git_push.rs +++ b/rust/lithe-core/tests/git_push.rs @@ -101,11 +101,86 @@ fn push_expectation(preview: &Value) -> Value { "localHead": preview["data"]["localHead"], "remote": preview["data"]["remote"], "remoteBranch": preview["data"]["remoteBranch"], + "targetKind": preview["data"]["targetKind"], "remoteTrackingOid": preview["data"]["remoteTrackingOid"], "tags": preview["data"]["tags"], }) } +/// The same payload without `targetKind`, shaped like a caller that predates it. +fn legacy_push_expectation(preview: &Value) -> Value { + json!({ + "localBranch": preview["data"]["localBranch"], + "localHead": preview["data"]["localHead"], + "remote": preview["data"]["remote"], + "remoteBranch": preview["data"]["remoteBranch"], + "remoteTrackingOid": preview["data"]["remoteTrackingOid"], + "tags": preview["data"]["tags"], + }) +} + +/// Issue #1272: `targetKind` is an additive review field. A caller that omits it +/// must keep pushing exactly as before — treating the omission as a mismatch would +/// turn every reviewed push from an un-upgraded client into a stale-preview error. +#[test] +fn reviewed_push_without_target_kind_keeps_working() { + let fixture = GitFixture::new(); + let (repository, _) = initialize_repository(&fixture); + fs::write(repository.join("tracked.txt"), "initial\nsecond\n") + .expect("fixture should be writable"); + require_git(&repository, &["commit", "-q", "-am", "second"]); + + let preview = core("git.pushPreview", &repository, json!({})); + assert_eq!(preview["ok"], true, "response: {preview}"); + + let pushed = core( + "git.write", + &repository, + json!({ + "operation": "push", + "expectedPush": legacy_push_expectation(&preview) + }), + ); + assert_eq!(pushed["ok"], true, "response: {pushed}"); + assert!( + pushed["data"]["operationError"].is_null(), + "omitting targetKind must not mark the preview stale: {pushed}" + ); + assert_eq!(pushed["data"]["exitCode"], 0, "response: {pushed}"); +} + +/// Issue #1272: an explicit but wrong `targetKind` is still a real mismatch, so the +/// compatibility allowance above must not weaken the review check. +#[test] +fn reviewed_push_with_a_wrong_target_kind_is_rejected() { + let fixture = GitFixture::new(); + let (repository, _) = initialize_repository(&fixture); + fs::write(repository.join("tracked.txt"), "initial\nsecond\n") + .expect("fixture should be writable"); + require_git(&repository, &["commit", "-q", "-am", "second"]); + + let preview = core("git.pushPreview", &repository, json!({})); + assert_eq!(preview["data"]["targetKind"], "tracking"); + let mut expectation = push_expectation(&preview); + expectation["targetKind"] = json!("divergent"); + + let pushed = core( + "git.write", + &repository, + json!({ "operation": "push", "expectedPush": expectation }), + ); + assert_eq!( + pushed["data"]["operationError"]["code"], "invalid_request", + "a wrong targetKind must be rejected: {pushed}" + ); + assert!( + pushed["data"]["operationError"]["message"] + .as_str() + .is_some_and(|message| message.contains("stale")), + "the rejection must report a stale preview: {pushed}" + ); +} + #[test] fn push_preview_and_write_share_destination_and_safe_options() { let fixture = GitFixture::new(); @@ -455,3 +530,790 @@ fn typed_remote_deletion_preserves_remote_names_with_slashes() { .status .success()); } + +/// Regression for issue #1272: the fixed branch-creation path must not turn +/// "check out a remote branch" into a tracking-less checkout. Those are two +/// distinct semantics and the guard here keeps a future refactor from merging +/// them and silently dropping upstream tracking. +#[test] +fn checkout_of_remote_branch_still_creates_tracking_branch() { + let fixture = GitFixture::new(); + let (repository, _) = initialize_repository(&fixture); + require_git(&repository, &["switch", "-q", "-c", "topic"]); + fs::write(repository.join("topic.txt"), "topic\n").expect("fixture should be writable"); + require_git(&repository, &["add", "topic.txt"]); + require_git(&repository, &["commit", "-q", "-m", "topic work"]); + require_git(&repository, &["push", "-q", "-u", "origin", "topic"]); + require_git(&repository, &["switch", "-q", "main"]); + require_git(&repository, &["branch", "-D", "topic"]); + + let checked_out = core( + "git.write", + &repository, + json!({ + "operation": "checkout", + "gitReference": { + "fullName": "refs/remotes/origin/topic", + "shortName": "origin/topic", + "kind": "remote" + } + }), + ); + assert_eq!(checked_out["ok"], true, "response: {checked_out}"); + assert_eq!( + checked_out["data"]["exitCode"], 0, + "response: {checked_out}" + ); + assert_eq!( + String::from_utf8_lossy( + &git(&repository, &["rev-parse", "--abbrev-ref", "@{upstream}"]).stdout + ) + .trim(), + "origin/topic", + "checking out a remote branch must still establish tracking" + ); +} + +/// Issue #1272: a branch started at a local branch or an explicit commit never +/// had a remote upstream to inherit, and the fix must not introduce one. +#[test] +fn branch_created_from_local_start_has_no_upstream() { + let fixture = GitFixture::new(); + let (repository, _) = initialize_repository(&fixture); + require_git(&repository, &["switch", "-q", "-c", "topic"]); + fs::write(repository.join("topic.txt"), "topic\n").expect("fixture should be writable"); + require_git(&repository, &["add", "topic.txt"]); + require_git(&repository, &["commit", "-q", "-m", "topic work"]); + require_git(&repository, &["switch", "-q", "main"]); + + let from_local = core( + "git.write", + &repository, + json!({ + "operation": "createBranch", + "name": "feature/from-local", + "reference": "refs/heads/topic", + "checkout": false + }), + ); + assert_eq!(from_local["ok"], true, "response: {from_local}"); + assert_eq!(from_local["data"]["exitCode"], 0, "response: {from_local}"); + assert_no_upstream(&repository, "feature/from-local"); + + let head_oid = String::from_utf8_lossy(&git(&repository, &["rev-parse", "HEAD"]).stdout) + .trim() + .to_string(); + let from_commit = core( + "git.write", + &repository, + json!({ + "operation": "createBranch", + "name": "feature/from-commit", + "reference": head_oid, + "checkout": false + }), + ); + assert_eq!(from_commit["ok"], true, "response: {from_commit}"); + assert_eq!( + from_commit["data"]["exitCode"], 0, + "response: {from_commit}" + ); + assert_no_upstream(&repository, "feature/from-commit"); +} + +/// Issue #1272: branches created from a remote no longer carry an upstream, so +/// the push preview falls back to excluding commits reachable from the selected +/// remote. That fallback must still list the branch's own new commits instead of +/// collapsing to an empty preview. +#[test] +fn preview_of_untracked_branch_still_lists_its_commits() { + let fixture = GitFixture::new(); + let (repository, _) = initialize_repository(&fixture); + + let created = create_branch_from_remote(&repository, "feature/topic", "main"); + assert_eq!(created["ok"], true, "response: {created}"); + fs::write(repository.join("topic.txt"), "topic\n").expect("fixture should be writable"); + require_git(&repository, &["add", "topic.txt"]); + require_git(&repository, &["commit", "-q", "-m", "topic work"]); + + let preview = core("git.pushPreview", &repository, json!({})); + assert_eq!(preview["ok"], true, "response: {preview}"); + assert_eq!(preview["data"]["upstream"], Value::Null); + assert_eq!( + preview["data"]["commits"].as_array().map(Vec::len), + Some(1), + "the branch's own commit must appear in the preview: {preview}" + ); + assert_eq!(preview["data"]["commits"][0]["subject"], "topic work"); +} + +/// Asserts the branch has no configured upstream. Issue #1272: creating a +/// feature branch from `origin/main` must not inherit `main` as its upstream. +fn assert_no_upstream(repository: &Path, branch: &str) { + let lookup = git( + repository, + &[ + "rev-parse", + "--abbrev-ref", + &format!("{branch}@{{upstream}}"), + ], + ); + assert!( + !lookup.status.success(), + "{branch} unexpectedly tracks {:?}", + String::from_utf8_lossy(&lookup.stdout).trim() + ); +} + +/// Issues the same Core request shape the Windows client sends when the user +/// picks "new branch from " on a remote reference. +fn create_branch_from_remote(repository: &Path, name: &str, remote_branch: &str) -> Value { + core( + "git.write", + repository, + json!({ + "operation": "createBranch", + "name": name, + "gitReference": { + "fullName": format!("refs/remotes/origin/{remote_branch}"), + "shortName": format!("origin/{remote_branch}"), + "kind": "remote" + }, + "checkout": true + }), + ) +} + +/// Regression for issue #1272: the first push of a branch created from +/// `origin/main` must never update the remote `main`. +#[test] +fn branch_created_from_remote_main_does_not_track_main() { + let fixture = GitFixture::new(); + let (repository, remote) = initialize_repository(&fixture); + let main_oid_before = + String::from_utf8_lossy(&git(&remote, &["rev-parse", "refs/heads/main"]).stdout) + .trim() + .to_string(); + + let created = create_branch_from_remote(&repository, "feature/topic", "main"); + assert_eq!(created["ok"], true, "response: {created}"); + assert_eq!(created["data"]["exitCode"], 0, "response: {created}"); + assert_no_upstream(&repository, "feature/topic"); + + fs::write(repository.join("topic.txt"), "topic\n").expect("fixture should be writable"); + require_git(&repository, &["add", "topic.txt"]); + require_git(&repository, &["commit", "-q", "-m", "topic work"]); + + let preview = core("git.pushPreview", &repository, json!({})); + assert_eq!(preview["ok"], true, "response: {preview}"); + assert_eq!(preview["data"]["remote"], "origin"); + assert_eq!(preview["data"]["remoteBranch"], "feature/topic"); + assert_eq!(preview["data"]["upstream"], Value::Null); + + let pushed = core("git.write", &repository, json!({ "operation": "push" })); + assert_eq!(pushed["ok"], true, "response: {pushed}"); + assert_eq!(pushed["data"]["exitCode"], 0, "response: {pushed}"); + + assert!( + git( + &remote, + &["show-ref", "--verify", "refs/heads/feature/topic"] + ) + .status + .success(), + "the feature branch should be published to its own remote branch" + ); + let main_oid_after = + String::from_utf8_lossy(&git(&remote, &["rev-parse", "refs/heads/main"]).stdout) + .trim() + .to_string(); + assert_eq!( + main_oid_after, main_oid_before, + "push from the feature branch must not move remote main" + ); +} + +/// Regression for issue #1272: an already divergent upstream +/// (`feature/topic` tracking `origin/main`) must not be pushed to silently. +#[test] +fn divergent_upstream_requires_an_explicit_choice() { + let fixture = GitFixture::new(); + let (repository, remote) = initialize_repository(&fixture); + let main_oid_before = + String::from_utf8_lossy(&git(&remote, &["rev-parse", "refs/heads/main"]).stdout) + .trim() + .to_string(); + + // Reproduce the state Git itself leaves behind with autoSetupMerge enabled. + require_git( + &repository, + &[ + "switch", + "-q", + "-c", + "feature/topic", + "refs/remotes/origin/main", + ], + ); + require_git( + &repository, + &[ + "branch", + "-q", + "--set-upstream-to=origin/main", + "feature/topic", + ], + ); + fs::write(repository.join("topic.txt"), "topic\n").expect("fixture should be writable"); + require_git(&repository, &["add", "topic.txt"]); + require_git(&repository, &["commit", "-q", "-m", "topic work"]); + + let preview = core("git.pushPreview", &repository, json!({})); + assert_eq!(preview["ok"], true, "response: {preview}"); + assert_eq!(preview["data"]["upstream"], "origin/main"); + assert_eq!(preview["data"]["targetKind"], "divergent"); + assert_eq!(preview["data"]["remoteBranch"], "feature/topic"); + assert_eq!(preview["data"]["createsRemoteBranch"], true); + + let refused = core("git.write", &repository, json!({ "operation": "push" })); + assert!( + refused["data"]["operationError"]["code"] == "invalid_request", + "an unconfirmed divergent push must be refused: {refused}" + ); + assert!( + !git( + &remote, + &["show-ref", "--verify", "refs/heads/feature/topic"] + ) + .status + .success(), + "a refused push must not publish the branch" + ); + assert_eq!( + String::from_utf8_lossy( + &git( + &repository, + &["config", "--get", "branch.feature/topic.merge"] + ) + .stdout + ) + .trim(), + "refs/heads/main", + "a refused push must not rewrite the user's upstream configuration" + ); + assert_eq!( + String::from_utf8_lossy(&git(&remote, &["rev-parse", "refs/heads/main"]).stdout).trim(), + main_oid_before, + "a refused push must not move remote main" + ); +} + +/// Issue #1272: after confirming "publish to the same-named branch", a divergent +/// branch lands on its own remote branch and points its upstream there, while the +/// branch it used to track stays untouched. +#[test] +fn confirmed_publish_redirects_a_divergent_branch() { + let fixture = GitFixture::new(); + let (repository, remote) = initialize_repository(&fixture); + let main_oid_before = + String::from_utf8_lossy(&git(&remote, &["rev-parse", "refs/heads/main"]).stdout) + .trim() + .to_string(); + require_git( + &repository, + &[ + "switch", + "-q", + "-c", + "feature/topic", + "refs/remotes/origin/main", + ], + ); + require_git( + &repository, + &[ + "branch", + "-q", + "--set-upstream-to=origin/main", + "feature/topic", + ], + ); + fs::write(repository.join("topic.txt"), "topic\n").expect("fixture should be writable"); + require_git(&repository, &["add", "topic.txt"]); + require_git(&repository, &["commit", "-q", "-m", "topic work"]); + + let pushed = core( + "git.write", + &repository, + json!({ "operation": "push", "pushTarget": "publish" }), + ); + assert_eq!(pushed["ok"], true, "response: {pushed}"); + assert_eq!(pushed["data"]["exitCode"], 0, "response: {pushed}"); + + assert!( + git( + &remote, + &["show-ref", "--verify", "refs/heads/feature/topic"] + ) + .status + .success(), + "the confirmed publish must create the same-named remote branch" + ); + assert_eq!( + String::from_utf8_lossy(&git(&remote, &["rev-parse", "refs/heads/main"]).stdout).trim(), + main_oid_before, + "publishing must not move remote main" + ); + assert_eq!( + String::from_utf8_lossy( + &git(&repository, &["rev-parse", "--abbrev-ref", "@{upstream}"]).stdout + ) + .trim(), + "origin/feature/topic", + "a successful publish repoints the upstream at the same-named branch" + ); +} + +/// Issue #1272: confirming "keep the existing upstream" must still be possible, +/// because some users intentionally publish a branch under another name. +#[test] +fn confirmed_upstream_push_keeps_the_divergent_destination() { + let fixture = GitFixture::new(); + let (repository, remote) = initialize_repository(&fixture); + require_git( + &repository, + &[ + "switch", + "-q", + "-c", + "feature/topic", + "refs/remotes/origin/main", + ], + ); + require_git( + &repository, + &[ + "branch", + "-q", + "--set-upstream-to=origin/main", + "feature/topic", + ], + ); + fs::write(repository.join("topic.txt"), "topic\n").expect("fixture should be writable"); + require_git(&repository, &["add", "topic.txt"]); + require_git(&repository, &["commit", "-q", "-m", "topic work"]); + + let pushed = core( + "git.write", + &repository, + json!({ "operation": "push", "pushTarget": "upstream" }), + ); + assert_eq!(pushed["ok"], true, "response: {pushed}"); + assert_eq!(pushed["data"]["exitCode"], 0, "response: {pushed}"); + let remote_main = + String::from_utf8_lossy(&git(&remote, &["rev-parse", "refs/heads/main"]).stdout) + .trim() + .to_string(); + let local_head = String::from_utf8_lossy(&git(&repository, &["rev-parse", "HEAD"]).stdout) + .trim() + .to_string(); + assert_eq!( + remote_main, local_head, + "an explicitly confirmed upstream push is the one path allowed to update a foreign branch: {pushed}" + ); + assert_eq!( + String::from_utf8_lossy( + &git( + &repository, + &["config", "--get", "branch.feature/topic.merge"] + ) + .stdout + ) + .trim(), + "refs/heads/main", + "an intentional divergent upstream is preserved" + ); +} + +/// Issue #1272: the confirmed upstream destination must be resolved with the same +/// remote/branch splitter the rest of the push path uses. An upstream short name is +/// `remote/branch` and either side may contain slashes, so taking the last segment +/// resolves a slashed branch name to the wrong remote branch. +#[test] +fn confirmed_upstream_push_resolves_a_slashed_branch_name() { + let fixture = GitFixture::new(); + let (repository, remote) = initialize_repository(&fixture); + require_git(&repository, &["remote", "rename", "origin", "team/origin"]); + // Publish a remote branch whose own name contains slashes. + require_git( + &repository, + &["push", "-q", "team/origin", "main:refs/heads/team/release"], + ); + require_git(&repository, &["fetch", "-q", "team/origin"]); + require_git( + &repository, + &[ + "switch", + "-q", + "-c", + "feature/topic", + "team/origin/team/release", + ], + ); + require_git( + &repository, + &[ + "branch", + "-q", + "--set-upstream-to=team/origin/team/release", + "feature/topic", + ], + ); + fs::write(repository.join("topic.txt"), "topic\n").expect("fixture should be writable"); + require_git(&repository, &["add", "topic.txt"]); + require_git(&repository, &["commit", "-q", "-m", "topic work"]); + let local_head = String::from_utf8_lossy(&git(&repository, &["rev-parse", "HEAD"]).stdout) + .trim() + .to_string(); + + let preview = core("git.pushPreview", &repository, json!({})); + assert_eq!(preview["data"]["upstream"], "team/origin/team/release"); + assert_eq!(preview["data"]["targetKind"], "divergent"); + + let pushed = core( + "git.write", + &repository, + json!({ "operation": "push", "pushTarget": "upstream" }), + ); + assert_eq!(pushed["ok"], true, "response: {pushed}"); + assert_eq!(pushed["data"]["exitCode"], 0, "response: {pushed}"); + + let confirmed_remote_branch = + String::from_utf8_lossy(&git(&remote, &["rev-parse", "refs/heads/team/release"]).stdout) + .trim() + .to_string(); + assert_eq!( + confirmed_remote_branch, local_head, + "the confirmed upstream branch receives the commit: {pushed}" + ); + let misresolved = + String::from_utf8_lossy(&git(&remote, &["rev-parse", "refs/heads/release"]).stdout) + .trim() + .to_string(); + assert_ne!( + misresolved, local_head, + "a mis-resolved destination branch must not receive the commit: {pushed}" + ); + assert_eq!( + String::from_utf8_lossy( + &git( + &repository, + &["config", "--get", "branch.feature/topic.merge"] + ) + .stdout + ) + .trim(), + "refs/heads/team/release", + "an intentional divergent upstream is preserved" + ); +} + +/// Builds the state Git itself leaves behind with `autoSetupMerge` enabled: a +/// feature branch with one commit of its own that tracks `origin/main`. +fn divergent_feature_branch(fixture: &GitFixture) -> (PathBuf, PathBuf) { + let (repository, remote) = initialize_repository(fixture); + require_git( + &repository, + &[ + "switch", + "-q", + "-c", + "feature/topic", + "refs/remotes/origin/main", + ], + ); + require_git( + &repository, + &[ + "branch", + "-q", + "--set-upstream-to=origin/main", + "feature/topic", + ], + ); + fs::write(repository.join("topic.txt"), "topic\n").expect("fixture should be writable"); + require_git(&repository, &["add", "topic.txt"]); + require_git(&repository, &["commit", "-q", "-m", "topic work"]); + (repository, remote) +} + +fn rev_parse(directory: &Path, revision: &str) -> String { + String::from_utf8_lossy(&git(directory, &["rev-parse", revision]).stdout) + .trim() + .to_string() +} + +/// The preview must describe the destination the user is about to choose, not a +/// fixed guess: the same request with each `pushTarget` shows the branch that +/// choice will update, so a confirmation can bind to what was displayed. +#[test] +fn preview_follows_the_chosen_destination_of_a_divergent_branch() { + let fixture = GitFixture::new(); + let (repository, _) = divergent_feature_branch(&fixture); + let main_oid = rev_parse(&repository, "origin/main"); + + let upstream = core( + "git.pushPreview", + &repository, + json!({ "pushTarget": "upstream" }), + ); + assert_eq!(upstream["ok"], true, "response: {upstream}"); + assert_eq!(upstream["data"]["targetKind"], "divergent"); + assert_eq!(upstream["data"]["remote"], "origin"); + assert_eq!(upstream["data"]["remoteBranch"], "main"); + assert_eq!(upstream["data"]["upstream"], "origin/main"); + assert_eq!(upstream["data"]["createsRemoteBranch"], false); + assert_eq!(upstream["data"]["remoteTrackingOid"], main_oid); + + let publish = core( + "git.pushPreview", + &repository, + json!({ "pushTarget": "publish" }), + ); + assert_eq!(publish["ok"], true, "response: {publish}"); + assert_eq!(publish["data"]["targetKind"], "divergent"); + assert_eq!(publish["data"]["remoteBranch"], "feature/topic"); + assert_eq!(publish["data"]["createsRemoteBranch"], true); + assert_eq!(publish["data"]["remoteTrackingOid"], Value::Null); +} + +/// Reviewed push bound to the upstream choice: the lease must be taken on the +/// upstream branch the user saw, not on the same-named branch, otherwise the +/// "reviewed" force push would lease an unrelated (missing) ref. +#[test] +fn reviewed_force_push_to_the_chosen_upstream_leases_that_branch() { + let fixture = GitFixture::new(); + let (repository, remote) = divergent_feature_branch(&fixture); + let main_oid = rev_parse(&repository, "origin/main"); + let local_head = rev_parse(&repository, "HEAD"); + + let preview = core( + "git.pushPreview", + &repository, + json!({ "pushTarget": "upstream" }), + ); + let pushed = core( + "git.write", + &repository, + json!({ + "operation": "push", + "pushTarget": "upstream", + "force": true, + "expectedPush": push_expectation(&preview) + }), + ); + assert_eq!(pushed["ok"], true, "response: {pushed}"); + assert_eq!(pushed["data"]["exitCode"], 0, "response: {pushed}"); + let arguments = pushed["data"]["arguments"] + .as_array() + .expect("push arguments should be present") + .iter() + .filter_map(Value::as_str) + .collect::>(); + let lease = format!("--force-with-lease=refs/heads/main:{main_oid}"); + assert!( + arguments.contains(&lease.as_str()), + "the lease must name the upstream branch and its observed oid: {arguments:?}" + ); + assert_eq!(rev_parse(&remote, "refs/heads/main"), local_head); +} + +/// The exact stale-preview hole from review: the user reviews "update +/// `origin/main`", then the upstream is repointed at another remote branch before +/// the push. The reviewed push must be rejected, because the effective +/// destination is no longer the one that was shown. +#[test] +fn reviewed_upstream_push_is_rejected_when_the_upstream_moves() { + let fixture = GitFixture::new(); + let (repository, remote) = divergent_feature_branch(&fixture); + let main_before = rev_parse(&remote, "refs/heads/main"); + + let preview = core( + "git.pushPreview", + &repository, + json!({ "pushTarget": "upstream" }), + ); + assert_eq!(preview["data"]["remoteBranch"], "main"); + + // Same commit on both branches, so only the branch name can tell them apart. + require_git( + &repository, + &["push", "-q", "origin", "main:refs/heads/release"], + ); + require_git(&repository, &["fetch", "-q", "origin"]); + require_git( + &repository, + &[ + "branch", + "-q", + "--set-upstream-to=origin/release", + "feature/topic", + ], + ); + + let pushed = core( + "git.write", + &repository, + json!({ + "operation": "push", + "pushTarget": "upstream", + "expectedPush": push_expectation(&preview) + }), + ); + assert_eq!( + pushed["data"]["operationError"]["message"], + "Git push preview is stale; refresh and try again.", + "response: {pushed}" + ); + assert_eq!(rev_parse(&remote, "refs/heads/main"), main_before); + assert_eq!(rev_parse(&remote, "refs/heads/release"), main_before); +} + +/// A confirmation answers one question. Reusing a "publish" preview with the +/// "upstream" choice (or the reverse) is a different destination and must be stale. +#[test] +fn reviewed_push_rejects_a_different_destination_choice() { + let fixture = GitFixture::new(); + let (repository, remote) = divergent_feature_branch(&fixture); + let main_before = rev_parse(&remote, "refs/heads/main"); + + let publish_preview = core( + "git.pushPreview", + &repository, + json!({ "pushTarget": "publish" }), + ); + let pushed = core( + "git.write", + &repository, + json!({ + "operation": "push", + "pushTarget": "upstream", + "expectedPush": push_expectation(&publish_preview) + }), + ); + assert_eq!( + pushed["data"]["operationError"]["message"], + "Git push preview is stale; refresh and try again.", + "response: {pushed}" + ); + assert_eq!(rev_parse(&remote, "refs/heads/main"), main_before); +} + +/// Choices only mean something for a divergent upstream: a routine branch keeps +/// pushing to its own tracked branch no matter what a stale client sends. +#[test] +fn push_target_choice_is_ignored_for_a_branch_that_tracks_its_own_name() { + let fixture = GitFixture::new(); + let (repository, remote) = initialize_repository(&fixture); + fs::write(repository.join("tracked.txt"), "initial\nsecond\n") + .expect("fixture should be writable"); + require_git(&repository, &["commit", "-q", "-am", "second"]); + + for choice in ["publish", "upstream"] { + let preview = core( + "git.pushPreview", + &repository, + json!({ "pushTarget": choice }), + ); + assert_eq!(preview["data"]["targetKind"], "tracking", "{choice}"); + assert_eq!(preview["data"]["remoteBranch"], "main", "{choice}"); + } + let pushed = core( + "git.write", + &repository, + json!({ "operation": "push", "pushTarget": "publish" }), + ); + assert_eq!(pushed["data"]["exitCode"], 0, "response: {pushed}"); + assert_eq!( + rev_parse(&remote, "refs/heads/main"), + rev_parse(&repository, "HEAD") + ); +} + +/// Issue #1272 asks whether new worktrees carry the same implicit-tracking risk. +/// The worktree path already freezes tracking explicitly: a remote start tracks +/// that remote branch, and an explicit commit start does not track anything. +#[test] +fn new_worktree_freezes_tracking_semantics() { + let fixture = GitFixture::new(); + let (repository, _) = initialize_repository(&fixture); + require_git(&repository, &["switch", "-q", "-c", "topic"]); + fs::write(repository.join("topic.txt"), "topic\n").expect("fixture should be writable"); + require_git(&repository, &["add", "topic.txt"]); + require_git(&repository, &["commit", "-q", "-m", "topic work"]); + require_git(&repository, &["push", "-q", "-u", "origin", "topic"]); + require_git(&repository, &["switch", "-q", "main"]); + + let remote_worktree = fixture.root.join("worktree-remote"); + let from_remote = core( + "git.write", + &repository, + json!({ + "operation": "createWorktree", + "destination": remote_worktree.to_string_lossy(), + "name": "feature/from-remote", + "gitReference": { + "fullName": "refs/remotes/origin/topic", + "shortName": "origin/topic", + "kind": "remote" + } + }), + ); + assert_eq!(from_remote["ok"], true, "response: {from_remote}"); + assert_eq!( + from_remote["data"]["exitCode"], 0, + "response: {from_remote}" + ); + assert_eq!( + String::from_utf8_lossy( + &git( + &repository, + &[ + "rev-parse", + "--abbrev-ref", + "feature/from-remote@{upstream}" + ] + ) + .stdout + ) + .trim(), + "origin/topic", + "a remote-started worktree branch keeps tracking that remote branch" + ); + + let head_oid = String::from_utf8_lossy(&git(&repository, &["rev-parse", "HEAD"]).stdout) + .trim() + .to_string(); + let pinned_worktree = fixture.root.join("worktree-pinned"); + let from_commit = core( + "git.write", + &repository, + json!({ + "operation": "createWorktree", + "destination": pinned_worktree.to_string_lossy(), + "name": "feature/from-commit", + "gitReference": { + "fullName": "refs/heads/main", + "shortName": "main", + "kind": "local" + }, + "revision": head_oid + }), + ); + assert_eq!(from_commit["ok"], true, "response: {from_commit}"); + assert_eq!( + from_commit["data"]["exitCode"], 0, + "response: {from_commit}" + ); + assert_no_upstream(&repository, "feature/from-commit"); +} diff --git a/rust/lithe-core/tests/plugin_sdk/mod.rs b/rust/lithe-core/tests/plugin_sdk/mod.rs deleted file mode 100644 index cad0fd45c3..0000000000 --- a/rust/lithe-core/tests/plugin_sdk/mod.rs +++ /dev/null @@ -1,550 +0,0 @@ -//! End-to-end scenario shared by the official plugin SDKs (`sdk/node`, -//! `sdk/java`), run against Rust Core through the same JSON commands a host -//! sends. -//! -//! Each SDK's sample backend is launched exactly as a host launches a build -//! provider: Core validates its manifest and expands the launch plan, the -//! runtime version is checked, and `lsp.startServer` starts it. The scenario -//! then drives the typed build requests, a multi-step plugin function with -//! progress, a plugin failure, cancellation of a running tool, runtime -//! registration, and state recovery across a restart. Both samples answer it -//! identically, which keeps the SDKs' behaviour aligned. - -// Each test binary includes this module and uses only the helpers its SDK needs. -#![allow(dead_code)] - -use lithe_core::execute_json; -use serde_json::{json, Value}; -use std::collections::BTreeMap; -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::time::{Duration, Instant}; - -const BUILD_SYSTEM: &str = "sample"; -/// Bound for any single wait; a broken backend fails here instead of in CI. -const WAIT: Duration = Duration::from_secs(20); - -fn core(command: &str, payload: Value) -> Value { - let request = json!({ "id": format!("sdk-{command}"), "command": command, "payload": payload }); - let response: Value = serde_json::from_str(&execute_json(&request.to_string())) - .expect("Core response should be JSON"); - assert_eq!(response["ok"], true, "{command} failed: {response}"); - response["data"].clone() -} - -fn repository_root() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../..") - .canonicalize() - .expect("repository root") -} - -/// Every file of a directory with its contents, to prove the package is unchanged. -fn listing(root: &Path) -> BTreeMap> { - let mut files = BTreeMap::new(); - let mut pending = vec![root.to_path_buf()]; - while let Some(directory) = pending.pop() { - for entry in fs::read_dir(&directory).expect("readable directory") { - let path = entry.expect("directory entry").path(); - if path.is_dir() { - pending.push(path); - } else { - let contents = fs::read(&path).expect("readable file"); - files.insert(path.strip_prefix(root).unwrap().to_path_buf(), contents); - } - } - } - files -} - -/// Temporary workspace and host state directories, removed even when an -/// assertion fails. -struct Fixture { - root: PathBuf, -} - -impl Fixture { - fn new(label: &str) -> Self { - let root = - std::env::temp_dir().join(format!("lithe-plugin-sdk-{label}-{}", std::process::id())); - let _ = fs::remove_dir_all(&root); - fs::create_dir_all(&root).expect("fixture directory"); - // The backend sees canonical paths (macOS /var is /private/var). - Self { - root: root.canonicalize().expect("fixture path"), - } - } - - fn workspace(&self) -> PathBuf { - self.root.join("workspace") - } -} - -impl Drop for Fixture { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.root); - } -} - -/// One running backend session. Dropping it stops the process and removes -/// the session, so a failed assertion leaves no backend behind. -struct Session { - id: String, - plugin_id: String, - events: Vec, -} - -impl Session { - fn start(plugin_id: &str, executable: &str, arguments: &[Value], workspace: &Path) -> Self { - let started = core( - "lsp.startServer", - json!({ - "providerId": format!("buildProvider:{plugin_id}:{BUILD_SYSTEM}"), - "pluginId": plugin_id, - "executablePath": executable, - "arguments": arguments, - // Directory URI with a trailing slash, as the macOS host sends it. - "rootUri": format!("file://{}/", workspace.display()), - "workingDirectory": workspace, - "initializeTimeoutMilliseconds": WAIT.as_millis() as u64, - "requestTimeoutMilliseconds": WAIT.as_millis() as u64, - "shutdownTimeoutMilliseconds": 5000, - }), - ); - let mut session = Self { - id: started["sessionId"] - .as_str() - .expect("session ID") - .to_string(), - plugin_id: plugin_id.to_string(), - events: Vec::new(), - }; - session.await_event("the session to become ready", |event| { - event["type"] == "stateChanged" && event["state"] == "ready" - }); - session - } - - /// Waits for the first event, already received or new, that `matches`. - fn await_event(&mut self, description: &str, matches: impl Fn(&Value) -> bool) -> Value { - let deadline = Instant::now() + WAIT; - loop { - if let Some(event) = self.events.iter().find(|event| matches(event)) { - return event.clone(); - } - let remaining = deadline.saturating_duration_since(Instant::now()); - assert!( - !remaining.is_zero(), - "timed out waiting for {description}; events: {:#?}", - self.events - ); - let drained = core( - "lsp.waitEvents", - json!({ "sessionId": self.id, "timeoutMilliseconds": remaining.as_millis().min(1000) as u64 }), - ); - self.events - .extend(drained["events"].as_array().cloned().unwrap_or_default()); - } - } - - fn completion(&mut self, operation: &str) -> Value { - self.await_event(&format!("operation {operation} to end"), |event| { - // Every terminal outcome, cancellation included, is one requestCompleted. - event["operationId"] == operation && event["type"] == "requestCompleted" - }) - } - - fn invoke(&mut self, operation: &str, function: &str, params: Value) { - core( - "lsp.functionRequest", - json!({ - "sessionId": self.id, - "operationId": operation, - "function": { "pluginID": self.plugin_id, "functionID": function }, - "params": params, - }), - ); - } - - fn call(&mut self, operation: &str, function: &str, params: Value) -> Value { - self.invoke(operation, function, params); - self.completion(operation) - } - - /// The registry after the latest `functionsChanged` event. - fn functions(&mut self) -> Vec { - let latest = self - .events - .iter() - .rev() - .find(|event| event["type"] == "functionsChanged") - .expect("a functionsChanged event"); - serde_json::from_value(latest["result"]["functions"].clone()).expect("function IDs") - } - - fn build_request(&mut self, operation: &str, plan: &Value) -> Value { - core( - "lsp.buildRequest", - json!({ - "sessionId": self.id, - "operationId": operation, - "method": plan["provider"]["method"], - "params": plan["provider"]["params"], - }), - ); - let completed = self.completion(operation); - assert!(completed.get("error").is_none(), "{completed}"); - completed["result"]["value"].clone() - } - - fn stop(&mut self) { - core("lsp.stopServer", json!({ "sessionId": self.id })); - self.await_event("the backend to stop", |event| { - event["type"] == "stateChanged" && event["state"] == "stopped" - }); - } -} - -impl Drop for Session { - fn drop(&mut self) { - let request = json!({ "id": "sdk-cleanup", "command": "lsp.stopServer", "payload": { "sessionId": self.id } }); - let _ = execute_json(&request.to_string()); - let deadline = Instant::now() + Duration::from_secs(10); - while Instant::now() < deadline { - let destroy = json!({ "id": "sdk-cleanup", "command": "lsp.destroyServer", "payload": { "sessionId": self.id } }); - let response: Value = - serde_json::from_str(&execute_json(&destroy.to_string())).unwrap_or(Value::Null); - if response["ok"] == true { - return; - } - let wait = json!({ "id": "sdk-cleanup", "command": "lsp.waitEvents", "payload": { "sessionId": self.id, "timeoutMilliseconds": 200 } }); - let _ = execute_json(&wait.to_string()); - } - } -} - -#[cfg(unix)] -fn is_alive(pid: u32) -> bool { - Command::new("kill") - .args(["-0", &pid.to_string()]) - .status() - .is_ok_and(|status| status.success()) -} - -/// One SDK's sample backend, packaged as a host installs it. -pub struct Sample { - /// `launch.tool` of the sample's provider: `node` or `java`. - pub tool: &'static str, - /// The runtime executable the host would resolve for that tool. - pub runtime: String, - /// The installed plugin package: `plugin.json` and the launch entry. - pub package: PathBuf, - /// Node.js, which runs the sample workspace's build tools for both SDKs. - pub node: String, -} - -/// Node.js from `LITHE_PLUGIN_SDK_NODE`, or `None` with a skip notice. -pub fn node_from_environment(test: &str) -> Option { - let Some(node) = std::env::var_os("LITHE_PLUGIN_SDK_NODE") else { - eprintln!("skipped: set LITHE_PLUGIN_SDK_NODE to run {test}"); - return None; - }; - Some(node.into_string().expect("UTF-8 Node.js path")) -} - -pub fn repository_path(relative: &str) -> PathBuf { - repository_root().join(relative) -} - -/// Copies `files` (package-relative destination, source) into a fresh package -/// directory under the system temporary directory, removed on drop. -pub struct TemporaryPackage { - pub root: PathBuf, -} - -impl TemporaryPackage { - pub fn new(label: &str, files: &[(&str, PathBuf)]) -> Self { - let root = std::env::temp_dir().join(format!( - "lithe-plugin-sdk-package-{label}-{}", - std::process::id() - )); - let _ = fs::remove_dir_all(&root); - for (destination, source) in files { - let target = root.join(destination); - fs::create_dir_all(target.parent().expect("package file parent")) - .expect("package directory"); - fs::copy(source, &target) - .unwrap_or_else(|error| panic!("copy {}: {error}", source.display())); - } - Self { - root: root.canonicalize().expect("package path"), - } - } -} - -impl Drop for TemporaryPackage { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.root); - } -} - -/// Runs the shared scenario against `sample`. -pub fn run_sample_scenario(sample: &Sample) { - let package = &sample.package; - let node = &sample.node; - let runtime = &sample.runtime; - let manifest: Value = - serde_json::from_slice(&fs::read(package.join("plugin.json")).expect("sample manifest")) - .expect("sample manifest JSON"); - let plugin_id = manifest["id"].as_str().expect("plugin ID").to_string(); - let package_before = listing(&package); - - // The manifest passes the same validation as an installed plugin. - let validated = core( - "plugins.declarative.validate", - json!({ "manifest": manifest }), - ); - assert_eq!(validated["status"], "ok", "{validated}"); - - let fixture = Fixture::new(sample.tool); - let workspace = fixture.workspace(); - for project in ["app", "broken", "slow"] { - fs::create_dir_all(workspace.join(project)).expect("project directory"); - } - let slow_pid = workspace.join("slow/slow.pid"); - fs::write( - workspace.join("lithe-sample.json"), - json!({ - "projects": [ - { - "name": "app", "path": "app", - "build": [node, "-e", "console.log('built app')"], - "dependencies": [{ "name": "left-pad", "version": "1.3.0" }] - }, - { "name": "broken", "path": "broken", "build": [node, "-e", "process.exit(2)"] }, - { - "name": "slow", "path": "slow", - "build": [node, "-e", "require('fs').writeFileSync('slow.pid', String(process.pid)); setInterval(() => {}, 1000)"] - } - ] - }) - .to_string(), - ) - .expect("workspace file"); - - // The host expands the launch plan and checks the runtime it found. - let plan = core( - "plugins.declarative.languageServerLaunchPlan", - json!({ - "manifest": manifest, - "buildSystemID": BUILD_SYSTEM, - "request": { - "pluginDirectory": package, - "stateDirectory": fixture.root.join("state"), - "workspaceStateDirectory": fixture.root.join("workspace-state"), - "architecture": if cfg!(target_arch = "aarch64") { "arm64" } else { "x86_64" }, - } - }), - ); - assert_eq!(plan["status"], "ok", "{plan}"); - let plan = &plan["plan"]; - assert_eq!(plan["launch"]["tool"], sample.tool); - let version = Command::new(runtime) - .args( - plan["launch"]["versionArguments"] - .as_array() - .expect("version probe") - .iter() - .map(|argument| argument.as_str().unwrap()), - ) - .output() - .expect("runtime version probe"); - // Hosts capture stderr with stdout: `java -version` prints to stderr. - let output = format!( - "{}{}", - String::from_utf8_lossy(&version.stdout), - String::from_utf8_lossy(&version.stderr) - ); - let check = core( - "plugins.declarative.checkRuntimeVersion", - json!({ - "tool": sample.tool, - "minimumVersion": plan["launch"]["minimumVersion"], - "output": output, - }), - ); - assert_eq!(check["check"]["satisfied"], true, "{check}"); - for directory in plan["requiredDirectories"].as_array().unwrap() { - let path = match directory.as_str() { - Some("state") => fixture.root.join("state"), - _ => fixture.root.join("workspace-state"), - }; - fs::create_dir_all(path).expect("host state directory"); - } - let arguments = plan["arguments"].as_array().unwrap().clone(); - - let mut session = Session::start(&plugin_id, runtime, &arguments, &workspace); - assert_eq!(session.functions(), ["snapshot.read", "workspace.build"]); - - // Typed build requests: Core plans them and decodes the answers. - let build = |operation: &str, request: Value| { - core( - operation, - json!({ "manifest": manifest, "buildSystemID": BUILD_SYSTEM, "request": request }), - ) - }; - let model_plan = build("build.modelPlan", json!({ "root": "." }))["plan"].clone(); - assert_eq!(model_plan["provider"]["method"], "lithe/build/model"); - let model_result = session.build_request("model", &model_plan); - let model = build( - "build.decodeModel", - json!({ "workspaceRoot": workspace, "root": ".", "result": model_result }), - ); - let names: Vec<_> = model["model"]["projects"] - .as_array() - .expect("decoded projects") - .iter() - .map(|project| project["name"].as_str().unwrap().to_string()) - .collect(); - assert_eq!(names, ["app", "broken", "slow"], "{model}"); - - let dependency_plan = build( - "build.dependencyPlan", - json!({ "root": ".", "project": { "name": "app", "path": "app" } }), - )["plan"] - .clone(); - let dependencies_result = session.build_request("dependencies", &dependency_plan); - let dependencies = build( - "build.decodeDependencies", - json!({ "workspaceRoot": workspace, "root": ".", "project": "app", "result": dependencies_result }), - ); - assert_eq!( - dependencies["dependencies"]["dependencies"][0]["name"], "left-pad", - "{dependencies}" - ); - - // A multi-step function: prepare, run the tool, record a snapshot. - let built = session.call( - "build-app", - "workspace.build", - json!({ "projects": ["app"] }), - ); - assert_eq!( - built["result"]["value"], - json!({ "generation": 1, "status": "succeeded", "projects": [{ "name": "app", "exitCode": 0 }] }), - "{built}" - ); - let progress: Vec<_> = session - .events - .iter() - .filter(|event| event["type"] == "requestProgress" && event["operationId"] == "build-app") - .map(|event| event["result"]["message"].clone()) - .collect(); - assert_eq!(progress, ["Preparing", "Building app", "Done"]); - // The backend registered snapshot.clear once a snapshot existed. - session.await_event("snapshot.clear to be registered", |event| { - event["type"] == "functionsChanged" - && event["result"]["functions"] - .as_array() - .is_some_and(|functions| functions.contains(&json!("snapshot.clear"))) - }); - - // A plugin failure keeps Lithe's stable code and forwards the plugin's own. - let failed = session.call( - "build-broken", - "workspace.build", - json!({ "projects": ["broken"] }), - ); - assert_eq!( - failed["error"]["code"], "plugin.function.failed", - "{failed}" - ); - assert_eq!(failed["error"]["pluginErrorCode"], "sample.buildFailed"); - - // Cancelling a running build ends the tool the backend started. - session.invoke( - "build-slow", - "workspace.build", - json!({ "projects": ["slow"] }), - ); - let deadline = Instant::now() + WAIT; - let pid = loop { - if let Some(pid) = fs::read_to_string(&slow_pid) - .ok() - .and_then(|text| text.trim().parse::().ok()) - { - break pid; - } - assert!(Instant::now() < deadline, "the slow tool never started"); - session.events.extend( - core( - "lsp.waitEvents", - json!({ "sessionId": session.id, "timeoutMilliseconds": 50 }), - )["events"] - .as_array() - .cloned() - .unwrap_or_default(), - ); - }; - core( - "lsp.cancelOperation", - json!({ "sessionId": session.id, "operationId": "build-slow" }), - ); - assert_eq!( - session.completion("build-slow")["error"]["code"], - "requestCancelled" - ); - #[cfg(unix)] - { - let deadline = Instant::now() + WAIT; - while is_alive(pid) { - assert!( - Instant::now() < deadline, - "the cancelled tool {pid} is still running" - ); - // test-stability: allow(rust-real-sleep) reason: the cancelled tool is a separate OS process that offers no notification channel to the test; the deadline above bounds the loop. - std::thread::sleep(Duration::from_millis(20)); - } - } - #[cfg(not(unix))] - let _ = pid; - - // State survives a restart: the new process reads the recorded snapshot. - session.stop(); - drop(session); - let mut restarted = Session::start(&plugin_id, runtime, &arguments, &workspace); - restarted.await_event("snapshot.clear to be offered again", |event| { - event["type"] == "functionsChanged" - && event["result"]["functions"] - .as_array() - .is_some_and(|functions| functions.contains(&json!("snapshot.clear"))) - }); - let snapshot = restarted.call("read", "snapshot.read", json!({})); - assert_eq!(snapshot["result"]["value"]["generation"], 2, "{snapshot}"); - assert_eq!(snapshot["result"]["value"]["status"], "failed"); - assert_eq!( - restarted.call("clear", "snapshot.clear", json!({}))["result"]["value"], - Value::Null - ); - assert_eq!( - restarted.call("read-again", "snapshot.read", json!({}))["result"]["value"], - Value::Null - ); - restarted.stop(); - drop(restarted); - - // The backend wrote only to the host's state directory, never the package. - assert!( - listing(&package) == package_before, - "the plugin package changed at runtime" - ); - let state: Vec<_> = fs::read_dir(fixture.root.join("workspace-state")) - .expect("workspace state directory") - .map(|entry| entry.unwrap().file_name()) - .collect(); - assert!( - state.is_empty(), - "cleared state left files behind: {state:?}" - ); -} diff --git a/rust/lithe-core/tests/plugin_sdk_java.rs b/rust/lithe-core/tests/plugin_sdk_java.rs deleted file mode 100644 index 09744d3100..0000000000 --- a/rust/lithe-core/tests/plugin_sdk_java.rs +++ /dev/null @@ -1,43 +0,0 @@ -//! End-to-end check of the official Java plugin SDK (`sdk/java`) against Rust -//! Core, with the scenario shared with the Node.js SDK in `plugin_sdk/mod.rs`. -//! -//! The sample is installed the way a plugin ships: `plugin.json` and the -//! shaded `backend/sample-build.jar` in one package. It runs only when -//! `LITHE_PLUGIN_SDK_JAVA` names a Java 8 or newer executable, -//! `LITHE_PLUGIN_SDK_JAVA_SAMPLE` the built sample JAR, and -//! `LITHE_PLUGIN_SDK_NODE` Node.js for the sample workspace's build tools; -//! `scripts/verify-rust-core-crates.sh` sets all three. - -mod plugin_sdk; - -#[test] -fn the_java_sdk_sample_backend_serves_build_requests_and_plugin_functions() { - let (Some(java), Some(jar)) = ( - std::env::var_os("LITHE_PLUGIN_SDK_JAVA"), - std::env::var_os("LITHE_PLUGIN_SDK_JAVA_SAMPLE"), - ) else { - eprintln!( - "skipped: set LITHE_PLUGIN_SDK_JAVA and LITHE_PLUGIN_SDK_JAVA_SAMPLE to run the Java SDK end-to-end check" - ); - return; - }; - let Some(node) = plugin_sdk::node_from_environment("the Java SDK end-to-end check") else { - return; - }; - let package = plugin_sdk::TemporaryPackage::new( - "java", - &[ - ( - "plugin.json", - plugin_sdk::repository_path("sdk/java/examples/sample-build/plugin.json"), - ), - ("backend/sample-build.jar", jar.into()), - ], - ); - plugin_sdk::run_sample_scenario(&plugin_sdk::Sample { - tool: "java", - runtime: java.into_string().expect("UTF-8 Java path"), - package: package.root.clone(), - node, - }); -} diff --git a/rust/lithe-core/tests/plugin_sdk_node.rs b/rust/lithe-core/tests/plugin_sdk_node.rs deleted file mode 100644 index 15322fd13a..0000000000 --- a/rust/lithe-core/tests/plugin_sdk_node.rs +++ /dev/null @@ -1,19 +0,0 @@ -//! End-to-end check of the official Node.js plugin SDK (`sdk/node`) against -//! Rust Core; the scenario is shared with the Java SDK in `plugin_sdk/mod.rs`. -//! It runs only when `LITHE_PLUGIN_SDK_NODE` names a Node.js 20.3 or newer -//! executable; `scripts/verify-rust-core-crates.sh` sets it. - -mod plugin_sdk; - -#[test] -fn the_node_sdk_sample_backend_serves_build_requests_and_plugin_functions() { - let Some(node) = plugin_sdk::node_from_environment("the Node.js SDK end-to-end check") else { - return; - }; - plugin_sdk::run_sample_scenario(&plugin_sdk::Sample { - tool: "node", - runtime: node.clone(), - package: plugin_sdk::repository_path("sdk/node/examples/sample-build"), - node, - }); -} diff --git a/rust/lithe-db-mcp/src/main.rs b/rust/lithe-db-mcp/src/main.rs deleted file mode 100644 index 07f9234621..0000000000 --- a/rust/lithe-db-mcp/src/main.rs +++ /dev/null @@ -1,678 +0,0 @@ -use serde_json::{json, Map, Value}; -use std::{ - env, - fs::{self, OpenOptions}, - io::{self, BufRead, Write}, - path::PathBuf, - process::{Command, Stdio}, -}; - -fn main() { - let connections = load_json_env("LITHE_DB_MCP_CONNECTIONS").unwrap_or_else(|| json!({})); - let policy = load_json_env("LITHE_DB_MCP_POLICY").unwrap_or_else(|| json!({})); - let audit_path = env::var_os("LITHE_DB_MCP_AUDIT_LOG").map(PathBuf::from); - let recovery_dir = env::var_os("LITHE_DB_MCP_RECOVERY_DIR") - .map(PathBuf::from) - .unwrap_or_else(|| env::temp_dir().join("lithe-db-mcp-recovery")); - - let stdin = io::stdin(); - let mut stdout = io::stdout(); - for line in stdin.lock().lines() { - let line = match line { - Ok(line) if !line.trim().is_empty() => line, - _ => continue, - }; - let request: Value = match serde_json::from_str(&line) { - Ok(request) => request, - Err(error) => { - write_response( - &mut stdout, - &json!({"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":error.to_string()}}), - ); - continue; - } - }; - if request.get("method").and_then(Value::as_str) == Some("notifications/initialized") { - continue; - } - let response = handle_request( - &request, - &connections, - &policy, - &recovery_dir, - audit_path.as_ref(), - ); - if request.get("id").is_some() { - write_response(&mut stdout, &response); - } - } -} - -fn handle_request( - request: &Value, - connections: &Value, - policy: &Value, - recovery_dir: &PathBuf, - audit_path: Option<&PathBuf>, -) -> Value { - let id = request.get("id").cloned().unwrap_or(Value::Null); - let method = request - .get("method") - .and_then(Value::as_str) - .unwrap_or_default(); - let params = request.get("params").cloned().unwrap_or_else(|| json!({})); - let result = match method { - "initialize" => Ok(json!({ - "protocolVersion": "2024-11-05", - "capabilities": {"tools": {"listChanged": false}}, - "serverInfo": {"name": "lithe-db-mcp", "version": env!("CARGO_PKG_VERSION")} - })), - "tools/list" => Ok(tools()), - "tools/call" => call_tool(¶ms, connections, policy, recovery_dir, audit_path), - _ => Err(format!("Unknown MCP method: {method}")), - }; - match result { - Ok(value) => json!({"jsonrpc":"2.0","id":id,"result":value}), - Err(message) => json!({"jsonrpc":"2.0","id":id,"error":{"code":-32000,"message":message}}), - } -} - -fn tools() -> Value { - let tool = |name: &str, description: &str, properties: Value, required: &[&str]| json!({"name":name,"description":description,"inputSchema":{"type":"object","properties":properties,"required":required}}); - json!({"tools":[ - tool("db_list_connections", "List configured database aliases without revealing credentials.", json!({}), &[]), - tool("db_list_tables", "List tables, views, or MongoDB collections for a configured connection.", json!({"connection":{"type":"string"}}), &["connection"]), - tool("db_describe_table", "Read columns, indexes, foreign keys, or sampled MongoDB document fields.", json!({"connection":{"type":"string"},"table":{"type":"string"}}), &["connection","table"]), - tool("db_list_objects", "List views, routines, triggers, or sequences.", json!({"connection":{"type":"string"},"kind":{"type":"string","enum":["views","routines","triggers","sequences"]}}), &["connection","kind"]), - tool("db_plan_sql", "Classify SQL and report whether explicit write confirmation is required.", json!({"sql":{"type":"string"}}), &["sql"]), - tool("db_query", "Execute a bounded read-only SQL query, or a JSON filter against a MongoDB collection.", json!({"connection":{"type":"string"},"table":{"type":"string","description":"Required for MongoDB and interpreted as the collection name."},"sql":{"type":"string","description":"SQL text, or a MongoDB JSON filter object."},"values":{"type":"array"},"limit":{"type":"integer"}}), &["connection","sql"]), - tool("db_explain", "Inspect the database query plan without executing the query.", json!({"connection":{"type":"string"},"sql":{"type":"string"},"format":{"type":"string"}}), &["connection","sql"]), - tool("db_diagnostics", "Read table size, locks, slow queries, indexes, data quality, or schema impact diagnostics.", json!({"connection":{"type":"string"},"kind":{"type":"string"},"table":{"type":"string"}}), &["connection","kind"]), - tool("db_backup", "Create a recoverable SQL backup before an approved change.", json!({"connection":{"type":"string"},"tables":{"type":"array"}}), &["connection"]), - tool("db_execute", "Execute one approved SQL mutation. Requires policy permission and confirmed=true.", json!({"connection":{"type":"string"},"sql":{"type":"string"},"values":{"type":"array"},"confirmed":{"type":"boolean"}}), &["connection","sql","confirmed"]), - tool("db_transaction", "Execute multiple statements atomically after policy approval.", json!({"connection":{"type":"string"},"statements":{"type":"array"},"confirmed":{"type":"boolean"}}), &["connection","statements","confirmed"]), - tool("db_schema_change", "Apply a structured schema change after policy approval.", json!({"connection":{"type":"string"},"operation":{"type":"string"},"table":{"type":"string"},"name":{"type":"string"},"confirmed":{"type":"boolean"}}), &["connection","operation","confirmed"]), - tool("db_restore", "Restore a previously created backup reference after explicit confirmation.", json!({"connection":{"type":"string"},"reference":{"type":"string"},"confirmed":{"type":"boolean"}}), &["connection","reference","confirmed"]) - ]}) -} - -fn call_tool( - params: &Value, - connections: &Value, - policy: &Value, - recovery_dir: &PathBuf, - audit_path: Option<&PathBuf>, -) -> Result { - let name = params - .get("name") - .and_then(Value::as_str) - .ok_or("tools/call requires a tool name")?; - let args = params - .get("arguments") - .cloned() - .unwrap_or_else(|| json!({})); - let result = match name { - "db_list_connections" => { - let rows = connections.as_object().unwrap_or(&Map::new()).iter().map(|(alias, value)| json!({"connection":alias,"kind":value.get("kind").and_then(Value::as_str).unwrap_or("unknown"),"host":value.get("host").and_then(Value::as_str).unwrap_or(""),"database":value.get("database").and_then(Value::as_str).unwrap_or("")})).collect::>(); - Ok(json!({"rows":rows})) - } - "db_plan_sql" => Ok(classify_sql( - args.get("sql").and_then(Value::as_str).unwrap_or_default(), - )), - "db_list_tables" => read_call("listTables", &args, connections, policy, None), - "db_describe_table" => { - let table = required_string(&args, "table")?; - read_call("describeTable", &args, connections, policy, Some(table)) - } - "db_list_objects" => read_call("listObjects", &args, connections, policy, None), - "db_query" => read_call( - "query", - &args, - connections, - policy, - args.get("table").and_then(Value::as_str), - ), - "db_explain" => read_call("explain", &args, connections, policy, None), - "db_diagnostics" => read_call("diagnostics", &args, connections, policy, None), - "db_backup" => backup_call(&args, connections, policy, recovery_dir, audit_path), - "db_execute" => write_call( - "execute", - &args, - connections, - policy, - recovery_dir, - audit_path, - ), - "db_transaction" => write_call( - "transaction", - &args, - connections, - policy, - recovery_dir, - audit_path, - ), - "db_schema_change" => write_call( - "schemaChange", - &args, - connections, - policy, - recovery_dir, - audit_path, - ), - "db_restore" => restore_call(&args, connections, policy, recovery_dir, audit_path), - _ => Err(format!("Unknown tool: {name}")), - }?; - Ok(tool_result(result)) -} - -fn read_call( - method: &str, - args: &Value, - connections: &Value, - policy: &Value, - table: Option<&str>, -) -> Result { - let alias = required_string(args, "connection")?; - check_policy(policy, alias, table, "read", false)?; - let connection = connection_for(connections, alias)?; - let mut params = args.as_object().cloned().unwrap_or_default(); - params.remove("connection"); - params.insert("connection".into(), connection); - if method == "listObjects" { - params - .entry("objectKind") - .or_insert_with(|| json!(args.get("kind").and_then(Value::as_str).unwrap_or("views"))); - } - if method == "diagnostics" { - params.entry("diagnosticKind").or_insert_with(|| { - json!(args - .get("kind") - .and_then(Value::as_str) - .unwrap_or("tableSize")) - }); - } - let response = sidecar_call(method, Value::Object(params))?; - unwrap_sidecar(response) -} - -fn backup_call( - args: &Value, - connections: &Value, - policy: &Value, - recovery_dir: &PathBuf, - audit_path: Option<&PathBuf>, -) -> Result { - let alias = required_string(args, "connection")?; - check_policy(policy, alias, None, "backup", true)?; - let connection = connection_for(connections, alias)?; - let tables = args.get("tables").cloned().unwrap_or_else(|| json!([])); - fs::create_dir_all(recovery_dir).map_err(|error| error.to_string())?; - let reference = recovery_dir.join(format!("{}.sql", uuid::Uuid::new_v4())); - let response = sidecar_call( - "exportSqlToFile", - json!({"connection":connection,"selectedTables":tables,"includeStructure":true,"includeData":true,"limit":0,"outputPath":reference}), - )?; - let result = unwrap_sidecar(response)?; - let output_path = result - .get("path") - .and_then(Value::as_str) - .ok_or("Sidecar backup did not return an output path")?; - if PathBuf::from(output_path) != reference || !reference.is_file() { - return Err("Sidecar backup did not create the requested recovery file".into()); - } - let bytes = result - .get("byteCount") - .and_then(Value::as_u64) - .ok_or("Sidecar backup did not return a byte count")?; - if fs::metadata(&reference) - .map_err(|error| error.to_string())? - .len() - != bytes - { - return Err("Sidecar backup size did not match the created recovery file".into()); - } - let sha256 = result - .get("sha256") - .and_then(Value::as_str) - .ok_or("Sidecar backup did not return a SHA-256 checksum")?; - append_audit( - audit_path, - json!({"action":"backup","connection":alias,"reference":reference,"bytes":bytes,"sha256":sha256,"succeeded":true}), - )?; - Ok(json!({"reference":reference,"bytes":bytes,"sha256":sha256})) -} - -fn write_call( - method: &str, - args: &Value, - connections: &Value, - policy: &Value, - recovery_dir: &PathBuf, - audit_path: Option<&PathBuf>, -) -> Result { - let alias = required_string(args, "connection")?; - let confirmed = args - .get("confirmed") - .and_then(Value::as_bool) - .unwrap_or(false); - check_policy( - policy, - alias, - args.get("table").and_then(Value::as_str), - "write", - confirmed, - )?; - if !confirmed { - return Err("Write operations require confirmed=true".into()); - } - let backup = backup_call( - &json!({"connection":alias}), - connections, - policy, - recovery_dir, - audit_path, - )?; - let connection = connection_for(connections, alias)?; - let mut params = args.as_object().cloned().unwrap_or_default(); - params.remove("connection"); - params.insert("connection".into(), connection); - params.insert("allowWrite".into(), json!(true)); - params.insert("confirmed".into(), json!(true)); - if method == "transaction" { - if let Some(statements) = params.get_mut("statements") { - normalize_statements(statements); - } - } - let response = sidecar_call(method, Value::Object(params))?; - let result = unwrap_sidecar(response)?; - append_audit( - audit_path, - json!({"action":method,"connection":alias,"backup":backup,"succeeded":true}), - )?; - Ok(result) -} - -fn restore_call( - args: &Value, - connections: &Value, - policy: &Value, - recovery_dir: &PathBuf, - audit_path: Option<&PathBuf>, -) -> Result { - let alias = required_string(args, "connection")?; - let confirmed = args - .get("confirmed") - .and_then(Value::as_bool) - .unwrap_or(false); - check_policy(policy, alias, None, "write", confirmed)?; - if !confirmed { - return Err("Restore requires confirmed=true".into()); - } - let reference = recovery_reference(recovery_dir, required_string(args, "reference")?)?; - let backup = backup_call( - &json!({"connection":alias}), - connections, - policy, - recovery_dir, - audit_path, - )?; - let connection = connection_for(connections, alias)?; - let response = sidecar_call( - "restoreSqlFile", - json!({"connection":connection,"outputPath":reference,"allowWrite":true,"confirmed":true}), - )?; - let result = unwrap_sidecar(response)?; - append_audit( - audit_path, - json!({"action":"restore","connection":alias,"reference":reference,"backup":backup,"succeeded":true}), - )?; - Ok(result) -} - -fn normalize_statements(statements: &mut Value) { - if let Some(items) = statements.as_array_mut() { - for item in items { - if item.get("values").is_none() { - item.as_object_mut() - .map(|object| object.insert("values".into(), json!([]))); - } - } - } -} - -fn check_policy( - policy: &Value, - connection: &str, - table: Option<&str>, - action: &str, - confirmed: bool, -) -> Result<(), String> { - let default = policy.get("default").and_then(Value::as_object); - let allowed_default = default - .and_then(|value| value.get(action)) - .and_then(Value::as_bool) - .unwrap_or(action == "read" || action == "backup"); - let mut allowed = allowed_default; - if let Some(connection_policy) = policy - .get("connections") - .and_then(|value| value.get(connection)) - .and_then(Value::as_object) - { - if let Some(value) = connection_policy.get(action).and_then(Value::as_bool) { - allowed = value; - } - if let Some(table_name) = table { - if let Some(table_policy) = connection_policy - .get("tables") - .and_then(|value| value.get(table_name)) - .and_then(Value::as_object) - { - if let Some(value) = table_policy.get(action).and_then(Value::as_bool) { - allowed = value; - } - } - } - } - if !allowed { - return Err(format!( - "Policy denied {action} access for connection {connection}" - )); - } - if action == "write" && !confirmed { - return Err("Write approval is required".into()); - } - Ok(()) -} - -fn classify_sql(sql: &str) -> Value { - let trimmed = sql.trim_start(); - let upper = trimmed.to_ascii_uppercase(); - let kind = if upper.starts_with("SELECT") - || upper.starts_with("SHOW") - || upper.starts_with("DESCRIBE") - || upper.starts_with("EXPLAIN") - || upper.starts_with("PRAGMA") - { - "query" - } else if upper.starts_with("INSERT") - || upper.starts_with("UPDATE") - || upper.starts_with("DELETE") - || upper.starts_with("MERGE") - || upper.starts_with("REPLACE") - { - "mutation" - } else if upper.starts_with("CREATE") - || upper.starts_with("ALTER") - || upper.starts_with("DROP") - || upper.starts_with("TRUNCATE") - { - "definition" - } else { - "unknown" - }; - let has_where = upper.split_whitespace().any(|token| token == "WHERE"); - let dangerous = upper.starts_with("DROP ") - || upper.starts_with("TRUNCATE ") - || (upper.starts_with("UPDATE ") && !has_where) - || (upper.starts_with("DELETE ") && !has_where); - json!({"kind":kind,"requiresConfirmation":dangerous || kind == "unknown","dangerous":dangerous,"statementCount":count_sql_statements(sql)}) -} - -fn count_sql_statements(sql: &str) -> usize { - let mut count = 0; - let mut has_content = false; - let mut quote = None; - let mut line_comment = false; - let mut block_comment = false; - let characters: Vec = sql.chars().collect(); - let mut index = 0; - - while index < characters.len() { - let character = characters[index]; - let next = characters.get(index + 1).copied(); - if line_comment { - if character == '\n' { - line_comment = false; - } - index += 1; - continue; - } - if block_comment { - if character == '*' && next == Some('/') { - block_comment = false; - index += 2; - } else { - index += 1; - } - continue; - } - if let Some(active_quote) = quote { - if character == active_quote { - if next == Some(active_quote) { - index += 2; - continue; - } - quote = None; - } - has_content = true; - index += 1; - continue; - } - if character == '-' && next == Some('-') { - line_comment = true; - index += 2; - continue; - } - if character == '/' && next == Some('*') { - block_comment = true; - index += 2; - continue; - } - if matches!(character, '\'' | '"' | '`') { - quote = Some(character); - has_content = true; - index += 1; - continue; - } - if character == ';' { - if has_content { - count += 1; - has_content = false; - } - } else if !character.is_whitespace() { - has_content = true; - } - index += 1; - } - if has_content { - count += 1; - } - count -} - -fn connection_for(connections: &Value, alias: &str) -> Result { - connections - .get(alias) - .cloned() - .ok_or_else(|| format!("Unknown database connection alias: {alias}")) -} - -fn recovery_reference(recovery_dir: &PathBuf, requested: &str) -> Result { - let root = fs::canonicalize(recovery_dir).map_err(|error| error.to_string())?; - let reference = fs::canonicalize(requested).map_err(|error| error.to_string())?; - if !reference.starts_with(&root) { - return Err("Restore reference is outside the configured recovery directory".into()); - } - Ok(reference) -} - -fn required_string<'a>(value: &'a Value, key: &str) -> Result<&'a str, String> { - value - .get(key) - .and_then(Value::as_str) - .filter(|value| !value.is_empty()) - .ok_or_else(|| format!("Missing required argument: {key}")) -} - -fn sidecar_call(method: &str, params: Value) -> Result { - let sidecar = env::var_os("LITHE_DB_SIDECAR_EXECUTABLE") - .map(PathBuf::from) - .or_else(|| { - env::current_exe() - .ok() - .and_then(|path| path.parent().map(|parent| parent.join("lithe-db-sidecar"))) - }) - .ok_or("Database sidecar executable was not found")?; - let request = json!({"id":uuid::Uuid::new_v4().to_string(),"method":method,"params":params}); - let mut child = Command::new(sidecar) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|error| error.to_string())?; - child - .stdin - .take() - .ok_or("Could not open sidecar stdin")? - .write_all(request.to_string().as_bytes()) - .map_err(|error| error.to_string())?; - let output = child - .wait_with_output() - .map_err(|error| error.to_string())?; - let response: Value = serde_json::from_slice(&output.stdout) - .map_err(|error| format!("Invalid sidecar response: {error}"))?; - if !output.status.success() { - return Err(response - .get("error") - .and_then(|error| error.get("message")) - .and_then(Value::as_str) - .unwrap_or("Sidecar failed") - .into()); - } - Ok(response) -} - -fn unwrap_sidecar(response: Value) -> Result { - if response.get("ok").and_then(Value::as_bool) != Some(true) { - return Err(response - .get("error") - .and_then(|error| error.get("message")) - .and_then(Value::as_str) - .unwrap_or("Sidecar request failed") - .into()); - } - response - .get("result") - .cloned() - .ok_or_else(|| "Sidecar returned no result".into()) -} - -fn tool_result(value: Value) -> Value { - json!({"content":[{"type":"text","text":serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string())}]}) -} - -fn append_audit(path: Option<&PathBuf>, value: Value) -> Result<(), String> { - let Some(path) = path else { - return Ok(()); - }; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|error| error.to_string())?; - } - let mut file = OpenOptions::new() - .create(true) - .append(true) - .open(path) - .map_err(|error| error.to_string())?; - writeln!(file, "{}", value).map_err(|error| error.to_string()) -} - -fn load_json_env(key: &str) -> Option { - env::var(key) - .ok() - .and_then(|value| serde_json::from_str(&value).ok()) -} - -fn write_response(stdout: &mut impl Write, response: &Value) { - let _ = writeln!(stdout, "{}", response); - let _ = stdout.flush(); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn sql_planning_counts_real_statements_and_protects_writes() { - let select = classify_sql("SELECT 'a;b';"); - assert_eq!(select["statementCount"], 1); - assert_eq!(select["requiresConfirmation"], false); - - let update = classify_sql("UPDATE items SET name = 'x'\nWHERE id = 1"); - assert_eq!(update["statementCount"], 1); - assert_eq!(update["dangerous"], false); - - let delete = classify_sql("DELETE FROM items"); - assert_eq!(delete["dangerous"], true); - assert_eq!(delete["requiresConfirmation"], true); - - let multiple = classify_sql("SELECT 1; /* ; */ SELECT 2;"); - assert_eq!(multiple["statementCount"], 2); - } - - #[test] - fn policy_defaults_to_read_and_can_scope_writes() { - let policy = json!({ - "default": {"read": true, "write": false}, - "connections": { - "local": { - "write": true, - "tables": {"audit": {"write": false}} - } - } - }); - assert!(check_policy(&policy, "local", Some("items"), "read", false).is_ok()); - assert!(check_policy(&policy, "local", Some("items"), "write", true).is_ok()); - assert!(check_policy(&policy, "local", Some("audit"), "write", true).is_err()); - assert!(check_policy(&policy, "unknown", None, "write", true).is_err()); - } - - #[test] - fn tools_expose_automation_only_database_operations() { - let listed = tools(); - let names = listed["tools"] - .as_array() - .unwrap() - .iter() - .filter_map(|tool| tool["name"].as_str()) - .collect::>(); - assert!(names.contains(&"db_query")); - assert!(names.contains(&"db_execute")); - assert!(!names.iter().any(|name| name.contains("chat"))); - assert!(!names.iter().any(|name| name.contains("agent"))); - } - - #[test] - fn recovery_reference_rejects_paths_outside_recovery_directory() { - let root = - std::env::temp_dir().join(format!("lithe-mcp-recovery-{}", uuid::Uuid::new_v4())); - let nested = root.join("nested"); - let outside = root.parent().unwrap().join(format!( - "{}-outside", - root.file_name().unwrap().to_string_lossy() - )); - std::fs::create_dir_all(&nested).unwrap(); - std::fs::write(nested.join("ok.sql"), "SELECT 1;").unwrap(); - std::fs::write(&outside, "SELECT 2;").unwrap(); - - let valid = recovery_reference(&root, nested.join("ok.sql").to_str().unwrap()).unwrap(); - assert!(valid.starts_with(std::fs::canonicalize(&root).unwrap())); - let escape = nested - .join("..") - .join("..") - .join(outside.file_name().unwrap()); - assert!(recovery_reference(&root, escape.to_str().unwrap()).is_err()); - - let _ = std::fs::remove_dir_all(&root); - let _ = std::fs::remove_file(outside); - } -} diff --git a/rust/lithe-db-sidecar/Cargo.toml b/rust/lithe-db-sidecar/Cargo.toml deleted file mode 100644 index a754211870..0000000000 --- a/rust/lithe-db-sidecar/Cargo.toml +++ /dev/null @@ -1,26 +0,0 @@ -[package] -name = "lithe-db-sidecar" -version = "0.1.0" -edition = "2021" -license = "Apache-2.0" -publish = false - -[dependencies] -base64 = "0.22" -csv = "1" -futures-util = "0.3" -hex = "0.4" -chrono = { version = "0.4", features = ["serde"] } -redis = { version = "0.27", default-features = false, features = ["tokio-rustls-comp"] } -mongodb = "=3.2.5" -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } -rust_decimal = "1" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -sha2 = "0.10" -sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "any", "mysql", "postgres", "sqlite", "json", "chrono", "rust_decimal", "uuid"] } -tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } -tokio-util = { version = "0.7", features = ["compat"] } -tiberius = { version = "0.12.3", default-features = false, features = ["tds73", "chrono", "rust_decimal", "rustls", "sql-browser-tokio"] } -urlencoding = "2" -uuid = { version = "1", features = ["serde"] } diff --git a/rust/lithe-db-sidecar/src/main.rs b/rust/lithe-db-sidecar/src/main.rs deleted file mode 100644 index cb697072db..0000000000 --- a/rust/lithe-db-sidecar/src/main.rs +++ /dev/null @@ -1,4239 +0,0 @@ -mod mongodb_adapter; -mod sqlserver_adapter; - -use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; -use futures_util::TryStreamExt; -use redis::AsyncCommands; -use reqwest::{Client as HttpClient, Method as HttpMethod, Url}; -use serde::{Deserialize, Serialize}; -use serde_json::{json, Map, Value}; -use sha2::{Digest, Sha256}; -use sqlx::{ - any::AnyPoolOptions, - mysql::{MySqlPoolOptions, MySqlRow}, - postgres::{PgPoolOptions, PgRow}, - sqlite::{SqlitePoolOptions, SqliteRow}, - Any, AnyPool, Column, MySql, MySqlPool, PgPool, Postgres, Row, Sqlite, SqlitePool, TypeInfo, - ValueRef, -}; -use std::{ - fs::File, - io::{self, BufReader, BufWriter, Read, Write}, - process::{Child, Command, Stdio}, - time::Duration, -}; - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct Request { - id: String, - method: String, - #[serde(default)] - params: Value, -} - -#[derive(Serialize)] -struct Response { - id: String, - ok: bool, - #[serde(skip_serializing_if = "Option::is_none")] - result: Option, - #[serde(skip_serializing_if = "Option::is_none")] - error: Option, -} - -#[derive(Serialize)] -struct ErrorBody { - code: String, - message: String, -} - -#[derive(Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -struct Connection { - kind: String, - #[serde(default)] - host: String, - #[serde(default)] - port: u16, - #[serde(default)] - username: String, - #[serde(default)] - password: String, - #[serde(default)] - database: String, - #[serde(default)] - path: String, - #[serde(default)] - ssl: bool, - #[serde(default)] - ca_certificate_path: String, - #[serde(default)] - server_name: String, - #[serde(default)] - ssh_host: String, - #[serde(default)] - ssh_port: u16, - #[serde(default)] - ssh_username: String, - #[serde(default)] - ssh_key_path: String, - #[serde(default)] - ssh_local_port: u16, - #[serde(default)] - proxy_url: String, - #[serde(default)] - read_only: bool, - #[serde(default)] - production_protection: bool, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct Params { - connection: Connection, - #[serde(default)] - schema: String, - #[serde(default)] - table: String, - #[serde(default)] - sql: String, - #[serde(default)] - values: Vec, - #[serde(default = "default_limit")] - limit: u32, - #[serde(default)] - offset: u32, - #[serde(default)] - mutations: Vec, - #[serde(default)] - data: String, - #[serde(default)] - output_path: String, - #[serde(default)] - filters: Vec, - #[serde(default)] - sort: Vec, - #[serde(default)] - selected_tables: Vec, - #[serde(default = "default_true")] - include_structure: bool, - #[serde(default = "default_true")] - include_data: bool, - #[serde(default)] - allow_write: bool, - #[serde(default)] - confirmed: bool, - #[serde(default)] - operation: String, - #[serde(default)] - object_kind: String, - #[serde(default)] - name: String, - #[serde(default)] - old_name: String, - #[serde(default)] - data_type: String, - #[serde(default)] - nullable: bool, - #[serde(default)] - default_value: String, - #[serde(default)] - index_name: String, - #[serde(default)] - index_columns: Vec, - #[serde(default)] - constraint_name: String, - #[serde(default)] - referenced_table: String, - #[serde(default)] - referenced_columns: Vec, - #[serde(default)] - explain_format: String, - #[serde(default)] - diagnostic_kind: String, - #[serde(default)] - statements: Vec, - #[serde(default)] - cursor: String, - #[serde(default)] - pattern: String, - #[serde(default)] - count: usize, - #[serde(default = "default_true")] - include_size: bool, - #[serde(default)] - key: String, - #[serde(default)] - value: String, - #[serde(default)] - new_key: String, - #[serde(default)] - ttl: Option, - #[serde(default)] - entries: Vec, - #[serde(default)] - data_id: String, - #[serde(default)] - group: String, - #[serde(default)] - content: String, - #[serde(default)] - r#type: String, - #[serde(default)] - service_name: String, - #[serde(default)] - page: u32, - #[serde(default)] - page_size: u32, -} - -#[derive(Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -struct RedisHashEntry { - field: String, - value: String, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct Statement { - sql: String, - #[serde(default)] - values: Vec, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct Filter { - column: String, - operator: String, - #[serde(default)] - value: Value, - #[serde(default = "default_filter_join")] - join: String, -} - -#[derive(Deserialize)] -struct Sort { - column: String, - #[serde(default)] - descending: bool, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct Mutation { - action: String, - table: String, - #[serde(default)] - values: Map, - #[serde(default)] - key: Map, -} - -type DbResult = Result; -fn default_limit() -> u32 { - 200 -} -fn default_true() -> bool { - true -} -fn default_filter_join() -> String { - "and".into() -} - -#[tokio::main] -async fn main() { - sqlx::any::install_default_drivers(); - let response = match read_request() { - Ok(request) => dispatch(request).await, - Err(message) => Response::failure("unknown", "invalid_request", message), - }; - println!( - "{}", - serde_json::to_string(&response).expect("serialize response") - ); - if !response.ok { - std::process::exit(1); - } -} - -fn read_request() -> Result { - let mut input = String::new(); - io::stdin() - .read_to_string(&mut input) - .map_err(|e| e.to_string())?; - serde_json::from_str(&input).map_err(|e| format!("Invalid request JSON: {e}")) -} - -async fn dispatch(request: Request) -> Response { - let id = request.id; - let result = if request.method == "capabilities" { - Ok(json!({ - "protocolVersion": 1, - "databaseTypes": ["mysql", "mariadb", "postgresql", "sqlite", "sqlserver", "mongodb", "redis", "nacos"], - "features": ["testConnection", "schema", "indexes", "foreignKeys", "objects", "schemaChanges", "pagedFilterSort", "transactionalCrud", "query", "explain", "diagnostics", "csvImportExport", "jsonImportExport", "sqlBackupRestore", "writeProtection", "sqlServerDataGrid", "mongoCollections", "mongoDocumentEditor", "redisScan", "redisStringHashEditor", "redisTTL", "redisFlushDatabase", "nacosConfigManagement", "nacosServiceDiscovery"] - })) - } else { - database_method(&request.method, request.params).await - }; - match result { - Ok(value) => Response::success(id, value), - Err((code, message)) => Response::failure(id, code, message), - } -} - -async fn database_method(method: &str, value: Value) -> DbResult { - let params: Params = - serde_json::from_value(value).map_err(|e| ("invalid_params".into(), e.to_string()))?; - match params.connection.kind.as_str() { - "redis" => redis_method(method, params).await, - "nacos" => nacos_method(method, params).await, - "sqlserver" => sqlserver_adapter::method(method, params).await, - "mongodb" => mongodb_adapter::method(method, params).await, - _ => sql_database_method(method, params).await, - } -} - -async fn sql_database_method(method: &str, params: Params) -> DbResult { - let tunnel = open_ssh_tunnel(¶ms.connection).await?; - let connection = if tunnel.is_some() { - params.connection.with_tunnel()? - } else { - params.connection.clone() - }; - let kind = normalized_kind(&connection.kind)?; - let needs_direct = matches!( - method, - "query" - | "pageTable" - | "applyChanges" - | "importCsv" - | "importJson" - | "exportCsv" - | "exportJson" - | "exportSql" - | "exportSqlToFile" - | "schemaChange" - | "transaction" - ); - let direct = if needs_direct { - Some(connect_direct(&connection).await?) - } else { - None - }; - let pool = connect(&connection).await?; - let result = match method { - "testConnection" => Ok(json!({"connected": true})), - "listDatabases" => list_databases(&pool, kind).await, - "listTables" => list_tables(&pool, kind, ¶ms.schema).await, - "describeTable" => describe_table(&pool, kind, ¶ms.schema, ¶ms.table).await, - "listIndexes" => list_indexes(&pool, kind, ¶ms.schema, ¶ms.table).await, - "listForeignKeys" => list_foreign_keys(&pool, kind, ¶ms.schema, ¶ms.table).await, - "listObjects" => list_objects(&pool, kind, ¶ms.schema, ¶ms.object_kind).await, - "query" => { - query_direct( - direct.as_ref().unwrap(), - ¶ms.sql, - ¶ms.values, - params.limit, - ) - .await - } - "pageTable" => { - page_table( - &pool, - direct.as_ref().unwrap(), - kind, - ¶ms.schema, - ¶ms.table, - params.limit, - params.offset, - ¶ms.filters, - ¶ms.sort, - ) - .await - } - "execute" => { - ensure_write_allowed( - &connection, - ¶ms.sql, - params.confirmed, - params.allow_write, - )?; - execute(&pool, ¶ms.sql, ¶ms.values).await - } - "applyChanges" => { - ensure_mutations_allowed( - &connection, - ¶ms.mutations, - params.confirmed, - params.allow_write, - )?; - apply_changes( - direct.as_ref().unwrap(), - kind, - ¶ms.schema, - ¶ms.mutations, - ) - .await - } - "schemaChange" => { - ensure_write_allowed( - &connection, - "ALTER TABLE", - params.confirmed, - params.allow_write, - )?; - schema_change(direct.as_ref().unwrap(), kind, ¶ms).await - } - "explain" => explain(&pool, kind, ¶ms.sql, ¶ms.explain_format).await, - "diagnostics" => diagnostics(&pool, kind, ¶ms).await, - "transaction" => { - ensure_transaction_allowed( - &connection, - ¶ms.statements, - params.confirmed, - params.allow_write, - )?; - run_transaction(direct.as_ref().unwrap(), kind, ¶ms.statements).await - } - "exportCsv" => { - export_csv( - direct.as_ref().unwrap(), - ¶ms.sql, - ¶ms.values, - params.limit, - ) - .await - } - "exportJson" => { - export_json( - direct.as_ref().unwrap(), - ¶ms.sql, - ¶ms.values, - params.limit, - ) - .await - } - "importCsv" => { - ensure_write_allowed(&connection, "INSERT", params.confirmed, params.allow_write)?; - import_data( - direct.as_ref().unwrap(), - kind, - ¶ms.schema, - ¶ms.table, - ¶ms.data, - "csv", - ) - .await - } - "importJson" => { - ensure_write_allowed(&connection, "INSERT", params.confirmed, params.allow_write)?; - import_data( - direct.as_ref().unwrap(), - kind, - ¶ms.schema, - ¶ms.table, - ¶ms.data, - "json", - ) - .await - } - "exportSql" => { - export_sql( - &pool, - direct.as_ref().unwrap(), - kind, - ¶ms.schema, - ¶ms.selected_tables, - params.include_structure, - params.include_data, - params.limit, - ) - .await - } - "exportSqlToFile" => { - export_sql_to_file( - &pool, - direct.as_ref().unwrap(), - kind, - ¶ms.schema, - ¶ms.selected_tables, - params.include_structure, - params.include_data, - params.limit, - ¶ms.output_path, - ) - .await - } - "importSql" => { - ensure_write_allowed(&connection, "RESTORE", params.confirmed, params.allow_write)?; - import_sql(&pool, ¶ms.data).await - } - "importSqlFile" => { - ensure_write_allowed(&connection, "RESTORE", params.confirmed, params.allow_write)?; - import_sql_file(&pool, ¶ms.output_path).await - } - "restoreSql" => { - ensure_write_allowed(&connection, "RESTORE", params.confirmed, params.allow_write)?; - restore_sql(&pool, kind, ¶ms.schema, ¶ms.data).await - } - "restoreSqlFile" => { - ensure_write_allowed(&connection, "RESTORE", params.confirmed, params.allow_write)?; - restore_sql_file(&pool, kind, ¶ms.schema, ¶ms.output_path).await - } - _ => Err(("unknown_method".into(), format!("Unknown method: {method}"))), - }; - pool.close().await; - if let Some(direct) = direct { - direct.close().await; - } - if let Some(mut tunnel) = tunnel { - let _ = tunnel.child.kill(); - let _ = tunnel.child.wait(); - } - result -} - -// MARK: - Redis specialised workspace - -async fn redis_method(method: &str, params: Params) -> DbResult { - let tunnel = open_ssh_tunnel(¶ms.connection).await?; - let connection = if tunnel.is_some() { - params.connection.with_tunnel()? - } else { - params.connection.clone() - }; - let result = redis_method_with_connection(method, ¶ms, &connection).await; - if let Some(mut tunnel) = tunnel { - let _ = tunnel.child.kill(); - let _ = tunnel.child.wait(); - } - result -} - -async fn redis_method_with_connection( - method: &str, - params: &Params, - connection: &Connection, -) -> DbResult { - let mut redis = redis_connect(connection).await?; - let result = match method { - "testConnection" => { - let _: String = redis::cmd("PING") - .query_async(&mut redis) - .await - .map_err(|e| redis_error(e, connection))?; - Ok(json!({"connected": true})) - } - "redisScan" => redis_scan(&mut redis, params, connection).await, - "redisGetKey" => redis_get_key(&mut redis, ¶ms.key, connection).await, - "redisSetString" => { - ensure_specialized_write( - connection, - "Update Redis key", - params.confirmed, - params.allow_write, - )?; - redis_set_string(&mut redis, params, connection).await?; - Ok(json!({})) - } - "redisReplaceHash" => { - ensure_specialized_write( - connection, - "Update Redis hash", - params.confirmed, - params.allow_write, - )?; - redis_replace_hash(&mut redis, params, connection).await?; - Ok(json!({})) - } - "redisDeleteKey" => { - ensure_specialized_write( - connection, - "Delete Redis key", - params.confirmed, - params.allow_write, - )?; - require_nonempty(¶ms.key, "Redis key")?; - let _: i64 = redis::cmd("DEL") - .arg(¶ms.key) - .query_async(&mut redis) - .await - .map_err(|e| redis_error(e, connection))?; - Ok(json!({})) - } - "redisRenameKey" => { - ensure_specialized_write( - connection, - "Rename Redis key", - params.confirmed, - params.allow_write, - )?; - require_nonempty(¶ms.key, "Redis key")?; - require_nonempty(¶ms.new_key, "New Redis key")?; - let _: String = redis::cmd("RENAME") - .arg(¶ms.key) - .arg(¶ms.new_key) - .query_async(&mut redis) - .await - .map_err(|e| redis_error(e, connection))?; - Ok(json!({})) - } - "redisSetTTL" => { - ensure_specialized_write( - connection, - "Update Redis TTL", - params.confirmed, - params.allow_write, - )?; - require_nonempty(¶ms.key, "Redis key")?; - let ttl = params - .ttl - .ok_or_else(|| ("invalid_params".into(), "Redis TTL is required".into()))?; - if ttl < -1 { - return Err(( - "invalid_params".into(), - "Redis TTL must be -1 or a non-negative number of seconds".into(), - )); - } - if ttl == -1 { - let _: i64 = redis::cmd("PERSIST") - .arg(¶ms.key) - .query_async(&mut redis) - .await - .map_err(|e| redis_error(e, connection))?; - } else { - let _: i64 = redis::cmd("EXPIRE") - .arg(¶ms.key) - .arg(ttl) - .query_async(&mut redis) - .await - .map_err(|e| redis_error(e, connection))?; - } - Ok(json!({})) - } - "redisFlushDatabase" => { - ensure_specialized_write( - connection, - "Clear Redis database", - params.confirmed, - params.allow_write, - )?; - let _: String = redis::cmd("FLUSHDB") - .query_async(&mut redis) - .await - .map_err(|e| redis_error(e, connection))?; - Ok(json!({})) - } - _ => Err(( - "unknown_method".into(), - format!("Unknown Redis method: {method}"), - )), - }; - result -} - -async fn redis_connect( - connection: &Connection, -) -> Result { - let url = redis_url(connection)?; - let client = redis::Client::open(url.as_str()).map_err(|e| redis_error(e, connection))?; - let mut redis = tokio::time::timeout( - Duration::from_secs(8), - client.get_multiplexed_tokio_connection(), - ) - .await - .map_err(|_| { - ( - "connection_failed".into(), - "Redis connection timed out after 8 seconds".into(), - ) - })? - .map_err(|e| redis_error(e, connection))?; - let db = redis_database(connection)?; - if db != 0 { - let _: String = redis::cmd("SELECT") - .arg(db) - .query_async(&mut redis) - .await - .map_err(|e| redis_error(e, connection))?; - } - Ok(redis) -} - -fn redis_url(connection: &Connection) -> Result { - let host = connection.host.trim(); - if host.is_empty() { - return Err(("invalid_connection".into(), "Redis host is required".into())); - } - if !connection.proxy_url.is_empty() && connection.ssh_host.is_empty() { - return Err(( - "unsupported_proxy".into(), - "A proxy URL requires an SSH tunnel in the current Redis driver".into(), - )); - } - let scheme = if connection.ssl { "rediss" } else { "redis" }; - let port = if connection.port == 0 { - 6379 - } else { - connection.port - }; - let db = redis_database(connection)?; - let credential = if connection.password.is_empty() { - String::new() - } else if connection.username.is_empty() { - format!(":{}@", urlencoding::encode(&connection.password)) - } else { - format!( - "{}:{}@", - urlencoding::encode(&connection.username), - urlencoding::encode(&connection.password) - ) - }; - Url::parse(&format!("{scheme}://{credential}{host}:{port}/{db}")).map_err(|e| { - ( - "invalid_connection".into(), - format!("Redis address is invalid: {e}"), - ) - }) -} - -fn redis_database(connection: &Connection) -> Result { - if connection.database.trim().is_empty() { - return Ok(0); - } - connection.database.trim().parse::().map_err(|_| { - ( - "invalid_connection".into(), - "Redis database index must be a non-negative integer".into(), - ) - }) -} - -async fn redis_scan( - redis: &mut redis::aio::MultiplexedConnection, - params: &Params, - connection: &Connection, -) -> DbResult { - let cursor = if params.cursor.trim().is_empty() { - 0 - } else { - params.cursor.parse::().map_err(|_| { - ( - "invalid_params".into(), - "Redis scan cursor is invalid".into(), - ) - })? - }; - let pattern = if params.pattern.trim().is_empty() { - "*" - } else { - params.pattern.trim() - }; - let count = params.count.clamp(1, 250); - let (next_cursor, keys): (u64, Vec) = redis::cmd("SCAN") - .arg(cursor) - .arg("MATCH") - .arg(pattern) - .arg("COUNT") - .arg(count) - .query_async(redis) - .await - .map_err(|e| redis_error(e, connection))?; - let (replies, size_enabled) = match redis_scan_metadata(redis, &keys, params.include_size).await - { - Ok(replies) => (replies, params.include_size), - Err(error) if params.include_size && redis_memory_usage_unsupported(&error) => { - let replies = redis_scan_metadata(redis, &keys, false) - .await - .map_err(|e| redis_error(e, connection))?; - (replies, false) - } - Err(error) => return Err(redis_error(error, connection)), - }; - let stride = if size_enabled { 3 } else { 2 }; - if replies.len() != keys.len() * stride { - return Err(( - "redis_error".into(), - "Redis returned an incomplete scan metadata response".into(), - )); - } - let mut summaries = Vec::with_capacity(keys.len()); - for (index, key) in keys.into_iter().enumerate() { - let offset = index * stride; - let kind: String = redis::from_redis_value(&replies[offset]).map_err(|e| { - ( - "redis_error".into(), - format!("Redis returned an invalid key type: {e}"), - ) - })?; - if kind == "none" { - continue; - } - let ttl: i64 = redis::from_redis_value(&replies[offset + 1]).map_err(|e| { - ( - "redis_error".into(), - format!("Redis returned an invalid key TTL: {e}"), - ) - })?; - let size = if size_enabled { - redis::from_redis_value::>(&replies[offset + 2]) - .unwrap_or(None) - .unwrap_or(-1) - } else { - -1 - }; - summaries.push(json!({"key": key, "type": kind, "ttl": ttl, "size": size})); - } - Ok(json!({"keys": summaries, "nextCursor": next_cursor.to_string()})) -} - -async fn redis_scan_metadata( - redis: &mut redis::aio::MultiplexedConnection, - keys: &[String], - include_size: bool, -) -> redis::RedisResult> { - let mut pipeline = redis::pipe(); - for key in keys { - pipeline.cmd("TYPE").arg(key); - pipeline.cmd("TTL").arg(key); - if include_size { - pipeline.cmd("MEMORY").arg("USAGE").arg(key); - } - } - pipeline.query_async(redis).await -} - -fn redis_memory_usage_unsupported(error: &redis::RedisError) -> bool { - let message = error.to_string().to_ascii_lowercase(); - message.contains("unknown command") - || message.contains("unknown subcommand") - || message.contains("unsupported") -} - -async fn redis_key_size( - redis: &mut redis::aio::MultiplexedConnection, - key: &str, - kind: &str, - connection: &Connection, -) -> Result { - let command = match kind { - "string" => "STRLEN", - "hash" => "HLEN", - "list" => "LLEN", - "set" => "SCARD", - "zset" => "ZCARD", - "stream" => "XLEN", - _ => return Ok(0), - }; - redis::cmd(command) - .arg(key) - .query_async(redis) - .await - .map_err(|e| redis_error(e, connection)) -} - -async fn redis_get_key( - redis: &mut redis::aio::MultiplexedConnection, - key: &str, - connection: &Connection, -) -> DbResult { - require_nonempty(key, "Redis key")?; - let kind: String = redis::cmd("TYPE") - .arg(key) - .query_async(redis) - .await - .map_err(|e| redis_error(e, connection))?; - if kind == "none" { - return Err(("not_found".into(), "The Redis key no longer exists".into())); - } - let ttl: i64 = redis::cmd("TTL") - .arg(key) - .query_async(redis) - .await - .map_err(|e| redis_error(e, connection))?; - let size = redis_key_size(redis, key, &kind, connection).await?; - let string_value = if kind == "string" { - Some( - redis::cmd("GET") - .arg(key) - .query_async::>(redis) - .await - .map_err(|e| redis_error(e, connection))? - .unwrap_or_default(), - ) - } else { - None - }; - let hash_entries = if kind == "hash" { - let pairs: Vec<(String, String)> = redis - .hgetall(key) - .await - .map_err(|e| redis_error(e, connection))?; - pairs - .into_iter() - .map(|(field, value)| json!({"field": field, "value": value})) - .collect() - } else { - Vec::new() - }; - Ok( - json!({"key": key, "type": kind, "ttl": ttl, "size": size, "stringValue": string_value, "hashEntries": hash_entries}), - ) -} - -async fn redis_set_string( - redis: &mut redis::aio::MultiplexedConnection, - params: &Params, - connection: &Connection, -) -> Result<(), (String, String)> { - require_nonempty(¶ms.key, "Redis key")?; - let prior_ttl: i64 = redis::cmd("PTTL") - .arg(¶ms.key) - .query_async(redis) - .await - .map_err(|e| redis_error(e, connection))?; - let _: String = redis::cmd("SET") - .arg(¶ms.key) - .arg(¶ms.value) - .query_async(redis) - .await - .map_err(|e| redis_error(e, connection))?; - redis_apply_ttl(redis, ¶ms.key, params.ttl, prior_ttl, connection).await -} - -async fn redis_replace_hash( - redis: &mut redis::aio::MultiplexedConnection, - params: &Params, - connection: &Connection, -) -> Result<(), (String, String)> { - require_nonempty(¶ms.key, "Redis key")?; - if params.entries.iter().any(|entry| entry.field.is_empty()) { - return Err(( - "invalid_params".into(), - "Redis hash fields cannot be empty".into(), - )); - } - let prior_ttl: i64 = redis::cmd("PTTL") - .arg(¶ms.key) - .query_async(redis) - .await - .map_err(|e| redis_error(e, connection))?; - let _: i64 = redis::cmd("DEL") - .arg(¶ms.key) - .query_async(redis) - .await - .map_err(|e| redis_error(e, connection))?; - if params.entries.is_empty() { - return Ok(()); - } - let mut command = redis::cmd("HSET"); - command.arg(¶ms.key); - for entry in ¶ms.entries { - command.arg(&entry.field).arg(&entry.value); - } - let _: i64 = command - .query_async(redis) - .await - .map_err(|e| redis_error(e, connection))?; - redis_apply_ttl(redis, ¶ms.key, None, prior_ttl, connection).await -} - -async fn redis_apply_ttl( - redis: &mut redis::aio::MultiplexedConnection, - key: &str, - requested_ttl: Option, - prior_ttl: i64, - connection: &Connection, -) -> Result<(), (String, String)> { - match requested_ttl { - Some(-1) => { - let _: i64 = redis::cmd("PERSIST") - .arg(key) - .query_async(redis) - .await - .map_err(|e| redis_error(e, connection))?; - } - Some(ttl) if ttl >= 0 => { - let _: i64 = redis::cmd("EXPIRE") - .arg(key) - .arg(ttl) - .query_async(redis) - .await - .map_err(|e| redis_error(e, connection))?; - } - Some(_) => { - return Err(( - "invalid_params".into(), - "Redis TTL must be -1 or a non-negative number of seconds".into(), - )) - } - None if prior_ttl >= 0 => { - let _: i64 = redis::cmd("PEXPIRE") - .arg(key) - .arg(prior_ttl) - .query_async(redis) - .await - .map_err(|e| redis_error(e, connection))?; - } - None => {} - } - Ok(()) -} - -fn redis_error(error: redis::RedisError, connection: &Connection) -> (String, String) { - ( - "redis_error".into(), - redact(error.to_string(), &connection.password), - ) -} - -// MARK: - Nacos specialised workspace - -struct NacosClient { - http: HttpClient, - base: Url, - token: Option, -} - -async fn nacos_method(method: &str, params: Params) -> DbResult { - let tunnel = open_ssh_tunnel(¶ms.connection).await?; - let connection = if tunnel.is_some() { - params.connection.with_tunnel()? - } else { - params.connection.clone() - }; - let client = nacos_connect(&connection).await; - let result = match client { - Ok(mut client) => nacos_method_with_client(method, ¶ms, &connection, &mut client).await, - Err(error) => Err(error), - }; - if let Some(mut tunnel) = tunnel { - let _ = tunnel.child.kill(); - let _ = tunnel.child.wait(); - } - result -} - -async fn nacos_method_with_client( - method: &str, - params: &Params, - connection: &Connection, - client: &mut NacosClient, -) -> DbResult { - match method { - "testConnection" => { - let _ = nacos_list_configs(client, connection, "", "", 1, 1).await?; - Ok(json!({"connected": true})) - } - "nacosListConfigs" => { - nacos_list_configs( - client, - connection, - ¶ms.data_id, - ¶ms.group, - normalized_page(params.page), - normalized_page_size(params.page_size), - ) - .await - } - "nacosGetConfig" => { - nacos_get_config(client, connection, ¶ms.data_id, ¶ms.group).await - } - "nacosPublishConfig" => { - ensure_specialized_write( - connection, - "Publish Nacos configuration", - params.confirmed, - params.allow_write, - )?; - nacos_publish_config(client, connection, params).await?; - Ok(json!({})) - } - "nacosDeleteConfig" => { - ensure_specialized_write( - connection, - "Delete Nacos configuration", - params.confirmed, - params.allow_write, - )?; - nacos_delete_config(client, connection, params).await?; - Ok(json!({})) - } - "nacosListServices" => { - nacos_list_services( - client, - connection, - ¶ms.service_name, - ¶ms.group, - normalized_page(params.page), - normalized_page_size(params.page_size), - ) - .await - } - "nacosListInstances" => { - nacos_list_instances(client, connection, ¶ms.service_name, ¶ms.group).await - } - _ => Err(( - "unknown_method".into(), - format!("Unknown Nacos method: {method}"), - )), - } -} - -async fn nacos_connect(connection: &Connection) -> Result { - let base = nacos_base_url(connection)?; - let http = HttpClient::builder() - .timeout(Duration::from_secs(12)) - .build() - .map_err(|e| ("connection_failed".into(), e.to_string()))?; - let mut client = NacosClient { - http, - base, - token: None, - }; - if !connection.username.trim().is_empty() { - client.token = Some(nacos_login(&client, connection).await?); - } - Ok(client) -} - -fn nacos_base_url(connection: &Connection) -> Result { - let host = connection.host.trim(); - if host.is_empty() { - return Err(( - "invalid_connection".into(), - "Nacos server address is required".into(), - )); - } - if !connection.proxy_url.is_empty() && connection.ssh_host.is_empty() { - return Err(( - "unsupported_proxy".into(), - "A proxy URL requires an SSH tunnel in the current Nacos driver".into(), - )); - } - let scheme = if connection.ssl { "https" } else { "http" }; - let port = if connection.port == 0 { - 8848 - } else { - connection.port - }; - let mut url = if host.starts_with("http://") || host.starts_with("https://") { - Url::parse(host).map_err(|e| { - ( - "invalid_connection".into(), - format!("Nacos server address is invalid: {e}"), - ) - })? - } else { - Url::parse(&format!("{scheme}://{host}:{port}")).map_err(|e| { - ( - "invalid_connection".into(), - format!("Nacos server address is invalid: {e}"), - ) - })? - }; - if url.port().is_none() { - url.set_port(Some(port)) - .map_err(|_| ("invalid_connection".into(), "Nacos port is invalid".into()))?; - } - let context = if connection.path.trim().is_empty() { - "/nacos" - } else { - connection.path.trim() - }; - let context = format!("/{}", context.trim_matches('/')); - url.set_path(&format!("{context}/")); - url.set_query(None); - Ok(url) -} - -async fn nacos_login( - client: &NacosClient, - connection: &Connection, -) -> Result { - let form = vec![ - ("username".to_string(), connection.username.clone()), - ("password".to_string(), connection.password.clone()), - ]; - let mut errors = Vec::new(); - for path in ["/v1/auth/login", "/v1/auth/users/login"] { - let response = nacos_request_raw( - &client.http, - &client.base, - HttpMethod::POST, - path, - &[], - Some(&form), - ) - .await; - match response { - Ok(response) if response.status().is_success() => { - let value: Value = response.json().await.map_err(|e| { - ( - "nacos_auth_failed".into(), - format!("Nacos login response is invalid: {e}"), - ) - })?; - if let Some(token) = value - .get("accessToken") - .and_then(Value::as_str) - .filter(|value| !value.is_empty()) - { - return Ok(token.to_string()); - } - errors.push("Nacos login response did not contain an access token".to_string()); - } - Ok(response) => errors.push(nacos_http_error(response, connection).await.1), - Err(error) => errors.push(error.1), - } - } - Err(("nacos_auth_failed".into(), errors.join("; "))) -} - -async fn nacos_list_configs( - client: &NacosClient, - connection: &Connection, - data_id: &str, - group: &str, - page: u32, - page_size: u32, -) -> DbResult { - let response = nacos_request( - client, - HttpMethod::GET, - "/v1/cs/configs", - &[ - ("search".to_string(), "blur".to_string()), - ("dataId".to_string(), data_id.to_string()), - ("group".to_string(), group.to_string()), - ("tenant".to_string(), connection.database.clone()), - ("pageNo".to_string(), page.to_string()), - ("pageSize".to_string(), page_size.to_string()), - ], - None, - ) - .await?; - let value = nacos_json(response, connection).await?; - let items = value.get("pageItems").or_else(|| value.get("items")).and_then(Value::as_array).cloned().unwrap_or_default().into_iter().map(|item| json!({ - "dataId": item.get("dataId").and_then(Value::as_str).unwrap_or_default(), - "group": item.get("group").or_else(|| item.get("groupName")).and_then(Value::as_str).unwrap_or("DEFAULT_GROUP"), - "namespace": item.get("tenant").or_else(|| item.get("namespace")).or_else(|| item.get("namespaceId")).and_then(Value::as_str).unwrap_or(&connection.database), - "type": item.get("type").or_else(|| item.get("configType")).and_then(Value::as_str), - "md5": item.get("md5").and_then(Value::as_str) - })).collect::>(); - let total_count = value - .get("totalCount") - .or_else(|| value.get("count")) - .and_then(Value::as_i64) - .unwrap_or(items.len() as i64); - Ok(json!({"items": items, "totalCount": total_count.max(0)})) -} - -async fn nacos_get_config( - client: &NacosClient, - connection: &Connection, - data_id: &str, - group: &str, -) -> DbResult { - require_nonempty(data_id, "Nacos data ID")?; - let group = nonempty_or(group, "DEFAULT_GROUP"); - let response = nacos_request( - client, - HttpMethod::GET, - "/v1/cs/configs", - &[ - ("dataId".to_string(), data_id.to_string()), - ("group".to_string(), group.to_string()), - ("tenant".to_string(), connection.database.clone()), - ("show".to_string(), "all".to_string()), - ], - None, - ) - .await?; - if !response.status().is_success() { - return Err(nacos_http_error(response, connection).await); - } - let text = response.text().await.map_err(|e| { - ( - "nacos_error".into(), - format!("Unable to read Nacos configuration: {e}"), - ) - })?; - let parsed = serde_json::from_str::(&text).ok(); - let content = parsed - .as_ref() - .and_then(|value| value.get("content")) - .and_then(Value::as_str) - .unwrap_or(&text) - .to_string(); - Ok(json!({ - "dataId": parsed.as_ref().and_then(|value| value.get("dataId")).and_then(Value::as_str).unwrap_or(data_id), - "group": parsed.as_ref().and_then(|value| value.get("group")).or_else(|| parsed.as_ref().and_then(|value| value.get("groupName"))).and_then(Value::as_str).unwrap_or(group), - "namespace": parsed.as_ref().and_then(|value| value.get("tenant")).or_else(|| parsed.as_ref().and_then(|value| value.get("namespace"))).and_then(Value::as_str).unwrap_or(&connection.database), - "content": content, - "type": parsed.as_ref().and_then(|value| value.get("type")).or_else(|| parsed.as_ref().and_then(|value| value.get("configType"))).and_then(Value::as_str), - "md5": parsed.as_ref().and_then(|value| value.get("md5")).and_then(Value::as_str) - })) -} - -async fn nacos_publish_config( - client: &NacosClient, - connection: &Connection, - params: &Params, -) -> Result<(), (String, String)> { - require_nonempty(¶ms.data_id, "Nacos data ID")?; - let group = nonempty_or(¶ms.group, "DEFAULT_GROUP"); - let mut form = vec![ - ("dataId".to_string(), params.data_id.clone()), - ("group".to_string(), group.to_string()), - ("tenant".to_string(), connection.database.clone()), - ("content".to_string(), params.content.clone()), - ]; - if !params.r#type.trim().is_empty() { - form.push(("type".to_string(), params.r#type.clone())); - } - let response = - nacos_request(client, HttpMethod::POST, "/v1/cs/configs", &[], Some(&form)).await?; - if response.status().is_success() { - Ok(()) - } else { - Err(nacos_http_error(response, connection).await) - } -} - -async fn nacos_delete_config( - client: &NacosClient, - connection: &Connection, - params: &Params, -) -> Result<(), (String, String)> { - require_nonempty(¶ms.data_id, "Nacos data ID")?; - let group = nonempty_or(¶ms.group, "DEFAULT_GROUP"); - let response = nacos_request( - client, - HttpMethod::DELETE, - "/v1/cs/configs", - &[ - ("dataId".to_string(), params.data_id.clone()), - ("group".to_string(), group.to_string()), - ("tenant".to_string(), connection.database.clone()), - ], - None, - ) - .await?; - if response.status().is_success() { - Ok(()) - } else { - Err(nacos_http_error(response, connection).await) - } -} - -async fn nacos_list_services( - client: &NacosClient, - connection: &Connection, - service_name: &str, - group: &str, - page: u32, - page_size: u32, -) -> DbResult { - let mut query = vec![ - ("namespaceId".to_string(), connection.database.clone()), - ("pageNo".to_string(), page.to_string()), - ("pageSize".to_string(), page_size.to_string()), - ]; - if !group.trim().is_empty() { - query.push(("groupNameParam".to_string(), group.to_string())); - } - if !service_name.trim().is_empty() { - query.push(("serviceNameParam".to_string(), service_name.to_string())); - } - let response = nacos_request( - client, - HttpMethod::GET, - "/v1/ns/catalog/services", - &query, - None, - ) - .await?; - let value = nacos_json(response, connection).await?; - let items = value.get("serviceList").or_else(|| value.get("doms")).and_then(Value::as_array).cloned().unwrap_or_default().into_iter().map(|item| { - let raw_name = item.get("name").or_else(|| item.get("serviceName")).and_then(Value::as_str).unwrap_or_default(); - let group_name = item.get("groupName").and_then(Value::as_str).unwrap_or_else(|| raw_name.split("@@").next().filter(|_| raw_name.contains("@@")).unwrap_or("DEFAULT_GROUP")); - let name = raw_name.strip_prefix(&format!("{group_name}@@")).unwrap_or(raw_name); - json!({"name": name, "group": group_name, "clusterCount": item.get("clusterCount").and_then(Value::as_i64).unwrap_or(0).max(0)}) - }).collect::>(); - let total_count = value - .get("count") - .or_else(|| value.get("totalCount")) - .and_then(Value::as_i64) - .unwrap_or(items.len() as i64); - Ok(json!({"items": items, "totalCount": total_count.max(0)})) -} - -async fn nacos_list_instances( - client: &NacosClient, - connection: &Connection, - service_name: &str, - group: &str, -) -> DbResult { - require_nonempty(service_name, "Nacos service name")?; - let mut query = vec![ - ("serviceName".to_string(), service_name.to_string()), - ("namespaceId".to_string(), connection.database.clone()), - ]; - if !group.trim().is_empty() { - query.push(("groupName".to_string(), group.to_string())); - } - let response = nacos_request( - client, - HttpMethod::GET, - "/v1/ns/instance/list", - &query, - None, - ) - .await?; - let value = nacos_json(response, connection).await?; - let hosts = value - .get("hosts") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); - Ok(Value::Array( - hosts - .into_iter() - .map(|host| { - json!({ - "ip": host.get("ip").and_then(Value::as_str).unwrap_or_default(), - "port": host.get("port").and_then(Value::as_i64).unwrap_or(0).max(0), - "healthy": host.get("healthy").and_then(Value::as_bool).unwrap_or(false), - "enabled": host.get("enabled").and_then(Value::as_bool).unwrap_or(true), - "ephemeral": host.get("ephemeral").and_then(Value::as_bool).unwrap_or(true), - "clusterName": host.get("clusterName").and_then(Value::as_str) - }) - }) - .collect(), - )) -} - -async fn nacos_request( - client: &NacosClient, - method: HttpMethod, - path: &str, - query: &[(String, String)], - form: Option<&[(String, String)]>, -) -> Result { - let mut query = query.to_vec(); - if let Some(token) = &client.token { - query.push(("accessToken".to_string(), token.clone())); - } - nacos_request_raw(&client.http, &client.base, method, path, &query, form).await -} - -async fn nacos_request_raw( - http: &HttpClient, - base: &Url, - method: HttpMethod, - path: &str, - query: &[(String, String)], - form: Option<&[(String, String)]>, -) -> Result { - let endpoint = base.join(path.trim_start_matches('/')).map_err(|e| { - ( - "invalid_connection".into(), - format!("Nacos endpoint is invalid: {e}"), - ) - })?; - let mut request = http.request(method, endpoint).query(query); - if let Some(form) = form { - request = request.form(form); - } - request - .send() - .await - .map_err(|e| ("nacos_error".into(), format!("Nacos request failed: {e}"))) -} - -async fn nacos_json( - response: reqwest::Response, - connection: &Connection, -) -> Result { - if !response.status().is_success() { - return Err(nacos_http_error(response, connection).await); - } - response.json().await.map_err(|e| { - ( - "nacos_error".into(), - format!("Nacos response is not valid JSON: {e}"), - ) - }) -} - -async fn nacos_http_error( - response: reqwest::Response, - connection: &Connection, -) -> (String, String) { - let status = response.status(); - let text = response.text().await.unwrap_or_default(); - ( - "nacos_error".into(), - redact( - format!("Nacos server returned {status}: {}", text.trim()), - &connection.password, - ), - ) -} - -fn normalized_page(page: u32) -> u32 { - page.max(1) -} -fn normalized_page_size(page_size: u32) -> u32 { - if page_size == 0 { - 100 - } else { - page_size.clamp(1, 500) - } -} -fn nonempty_or<'a>(value: &'a str, fallback: &'a str) -> &'a str { - if value.trim().is_empty() { - fallback - } else { - value.trim() - } -} -fn require_nonempty(value: &str, label: &str) -> Result<(), (String, String)> { - if value.trim().is_empty() { - Err(("invalid_params".into(), format!("{label} is required"))) - } else { - Ok(()) - } -} - -fn ensure_specialized_write( - connection: &Connection, - action: &str, - confirmed: bool, - allow_write: bool, -) -> Result<(), (String, String)> { - if connection.read_only && !allow_write { - return Err(("read_only".into(), "This connection is read-only".into())); - } - if connection.production_protection && !confirmed { - return Err(( - "write_confirmation_required".into(), - format!("{action} requires confirmation because production protection is enabled"), - )); - } - Ok(()) -} - -enum DirectPool { - MySql(MySqlPool), - Postgres(PgPool), - Sqlite(SqlitePool), -} - -impl DirectPool { - async fn close(self) { - match self { - Self::MySql(pool) => pool.close().await, - Self::Postgres(pool) => pool.close().await, - Self::Sqlite(pool) => pool.close().await, - } - } -} - -async fn connect_direct(connection: &Connection) -> Result { - let url = connection.url()?; - let error = |error: sqlx::Error| { - ( - "connection_failed".into(), - redact(error.to_string(), &connection.password), - ) - }; - match normalized_kind(&connection.kind)? { - "mysql" => MySqlPoolOptions::new() - .max_connections(2) - .acquire_timeout(Duration::from_secs(8)) - .connect(&url) - .await - .map(DirectPool::MySql) - .map_err(error), - "postgresql" => PgPoolOptions::new() - .max_connections(2) - .acquire_timeout(Duration::from_secs(8)) - .connect(&url) - .await - .map(DirectPool::Postgres) - .map_err(error), - _ => SqlitePoolOptions::new() - .max_connections(1) - .acquire_timeout(Duration::from_secs(8)) - .connect(&url) - .await - .map(DirectPool::Sqlite) - .map_err(error), - } -} - -fn normalized_kind(kind: &str) -> Result<&str, (String, String)> { - match kind { - "mysql" | "mariadb" => Ok("mysql"), - "postgres" | "postgresql" => Ok("postgresql"), - "sqlite" => Ok("sqlite"), - _ => Err(( - "unsupported_database".into(), - format!("Unsupported database type: {kind}"), - )), - } -} - -impl Connection { - fn url(&self) -> Result { - match normalized_kind(&self.kind)? { - "sqlite" if self.path.is_empty() => Err(( - "invalid_connection".into(), - "SQLite path is required".into(), - )), - "sqlite" => Ok(format!("sqlite://{}?mode=rwc", self.path)), - kind if self.host.is_empty() || self.username.is_empty() => Err(( - "invalid_connection".into(), - format!("Host and username are required for {kind}"), - )), - kind => { - let port = if self.port > 0 { - self.port - } else if kind == "mysql" { - 3306 - } else { - 5432 - }; - let scheme = if kind == "mysql" { "mysql" } else { "postgres" }; - if !self.proxy_url.is_empty() && self.ssh_host.is_empty() { - return Err(( - "unsupported_proxy".into(), - "A proxy URL requires an SSH tunnel in the current database driver".into(), - )); - } - let mut query = Vec::new(); - if kind == "postgresql" { - query.push(if self.ssl { - "sslmode=require".to_string() - } else { - "sslmode=disable".to_string() - }); - if !self.ca_certificate_path.is_empty() { - query.push(format!( - "sslrootcert={}", - urlencoding::encode(&self.ca_certificate_path) - )); - } - if !self.server_name.is_empty() { - query.push(format!("host={}", urlencoding::encode(&self.server_name))); - } - } else if self.ssl { - query.push("ssl-mode=REQUIRED".to_string()); - if !self.ca_certificate_path.is_empty() { - query.push(format!( - "ssl-ca={}", - urlencoding::encode(&self.ca_certificate_path) - )); - } - } - let suffix = if query.is_empty() { - String::new() - } else { - format!("?{}", query.join("&")) - }; - Ok(format!( - "{scheme}://{}:{}@{}:{port}/{}{suffix}", - urlencoding::encode(&self.username), - urlencoding::encode(&self.password), - self.host, - self.database - )) - } - } - } - - fn with_tunnel(&self) -> Result { - if self.ssh_local_port == 0 { - return Err(( - "invalid_ssh_tunnel".into(), - "SSH local port is required when using a tunnel".into(), - )); - } - let mut connection = self.clone(); - connection.host = "127.0.0.1".into(); - connection.port = self.ssh_local_port; - Ok(connection) - } -} - -struct SshTunnel { - child: Child, -} - -async fn open_ssh_tunnel(connection: &Connection) -> Result, (String, String)> { - if connection.ssh_host.is_empty() { - return Ok(None); - } - if connection.ssh_username.is_empty() || connection.ssh_local_port == 0 { - return Err(( - "invalid_ssh_tunnel".into(), - "SSH host, username, and local port are required".into(), - )); - } - let target_port = if connection.port > 0 { - connection.port - } else { - match connection.kind.as_str() { - "mysql" | "mariadb" => 3306, - "sqlserver" => 1433, - "mongodb" => 27017, - "redis" => 6379, - "nacos" => 8848, - _ => 5432, - } - }; - let target = format!("{}:{target_port}", connection.host); - let destination = format!("{}@{}", connection.ssh_username, connection.ssh_host); - let mut command = Command::new("ssh"); - command - .arg("-N") - .arg("-o") - .arg("BatchMode=yes") - .arg("-o") - .arg("ExitOnForwardFailure=yes") - .arg("-L") - .arg(format!("{}:{}", connection.ssh_local_port, target)) - .arg("-p") - .arg( - if connection.ssh_port == 0 { - 22 - } else { - connection.ssh_port - } - .to_string(), - ); - if !connection.ssh_key_path.is_empty() { - command.arg("-i").arg(&connection.ssh_key_path); - } - if !connection.proxy_url.is_empty() { - command - .arg("-o") - .arg(format!("ProxyCommand={}", connection.proxy_url)); - } - let mut child = command - .arg(destination) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|error| ("ssh_tunnel_failed".into(), error.to_string()))?; - tokio::time::sleep(Duration::from_millis(180)).await; - if let Some(status) = child - .try_wait() - .map_err(|error| ("ssh_tunnel_failed".into(), error.to_string()))? - { - return Err(( - "ssh_tunnel_failed".into(), - format!("SSH tunnel exited with status {status}"), - )); - } - Ok(Some(SshTunnel { child })) -} - -async fn connect(connection: &Connection) -> Result { - AnyPoolOptions::new() - .max_connections(2) - .acquire_timeout(Duration::from_secs(8)) - .connect(&connection.url()?) - .await - .map_err(|e| { - ( - "connection_failed".into(), - redact(e.to_string(), &connection.password), - ) - }) -} - -async fn list_tables(pool: &AnyPool, kind: &str, schema: &str) -> DbResult { - let sql = match kind { - "mysql" => "SELECT table_name AS table_name, CAST(table_type AS CHAR) AS table_type FROM information_schema.tables WHERE table_schema = DATABASE() ORDER BY table_name", - "postgresql" => "SELECT CAST(table_name AS TEXT) AS table_name, CAST(table_type AS TEXT) AS table_type FROM information_schema.tables WHERE table_schema = $1 ORDER BY table_name", - _ => "SELECT name AS table_name, type AS table_type FROM sqlite_master WHERE type IN ('table','view') AND name NOT LIKE 'sqlite_%' ORDER BY name", - }; - let mut q = sqlx::query(sql); - if kind == "postgresql" { - q = q.bind(if schema.is_empty() { "public" } else { schema }); - } - rows_value(q.fetch_all(pool).await.map_err(db_error)?) -} - -async fn list_databases(pool: &AnyPool, kind: &str) -> DbResult { - let sql = match kind { - "mysql" => "SELECT SCHEMA_NAME AS name FROM information_schema.schemata ORDER BY SCHEMA_NAME", - "postgresql" => "SELECT schema_name AS name FROM information_schema.schemata WHERE catalog_name = current_database() ORDER BY schema_name", - _ => return Ok(json!([])), - }; - let rows = rows_value(sqlx::query(sql).fetch_all(pool).await.map_err(db_error)?)?; - let names = rows - .as_array() - .into_iter() - .flat_map(|rows| rows.iter()) - .filter_map(|row| row.get("name").and_then(Value::as_str)) - .map(|name| Value::String(name.to_string())) - .collect(); - Ok(Value::Array(names)) -} - -async fn describe_table(pool: &AnyPool, kind: &str, schema: &str, table: &str) -> DbResult { - validate_identifier(table)?; - let sql = match kind { - "mysql" => "SELECT column_name AS column_name, CAST(data_type AS CHAR) AS data_type, CAST(is_nullable AS CHAR) AS is_nullable, CAST(column_default AS CHAR) AS column_default, CAST(column_key AS CHAR) AS column_key FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ? ORDER BY ordinal_position".to_string(), - "postgresql" => r#"SELECT CAST(c.column_name AS TEXT) AS column_name, CAST(c.data_type AS TEXT) AS data_type, - CAST(c.is_nullable AS TEXT) AS is_nullable, CAST(c.column_default AS TEXT) AS column_default, - CASE WHEN EXISTS ( - SELECT 1 FROM information_schema.table_constraints tc - JOIN information_schema.key_column_usage kcu - ON tc.constraint_name=kcu.constraint_name AND tc.constraint_schema=kcu.constraint_schema - WHERE tc.constraint_type='PRIMARY KEY' AND tc.table_schema=c.table_schema - AND tc.table_name=c.table_name AND kcu.column_name=c.column_name - ) THEN 'PRI' ELSE '' END AS column_key - FROM information_schema.columns c - WHERE c.table_schema=$1 AND c.table_name=$2 ORDER BY c.ordinal_position"#.to_string(), - _ => format!("PRAGMA table_info({})", quote_identifier(kind, table)?), - }; - let mut q = sqlx::query(&sql); - if kind == "mysql" { - q = q.bind(table); - } - if kind == "postgresql" { - q = q - .bind(if schema.is_empty() { "public" } else { schema }) - .bind(table); - } - metadata_rows_value(q.fetch_all(pool).await.map_err(db_error)?) -} - -async fn list_indexes(pool: &AnyPool, kind: &str, schema: &str, table: &str) -> DbResult { - validate_identifier(table)?; - let sql = match kind { - "mysql" => "SELECT CAST(index_name AS CHAR) AS index_name, CAST(CONCAT('CREATE ', CASE WHEN index_type = 'FULLTEXT' THEN 'FULLTEXT ' WHEN index_type = 'SPATIAL' THEN 'SPATIAL ' WHEN non_unique = 0 THEN 'UNIQUE ' ELSE '' END, 'INDEX `', REPLACE(index_name, '`', '``'), '` ON `', REPLACE(table_name, '`', '``'), '` (', GROUP_CONCAT(CONCAT('`', REPLACE(column_name, '`', '``'), '`', CASE WHEN sub_part IS NULL THEN '' ELSE CONCAT('(', sub_part, ')') END, CASE WHEN collation = 'D' THEN ' DESC' ELSE '' END) ORDER BY seq_in_index SEPARATOR ', '), ')') AS CHAR) AS definition FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = ? AND index_name <> 'PRIMARY' GROUP BY index_name, index_type, non_unique, table_name ORDER BY index_name".to_string(), - "postgresql" => "SELECT CAST(indexname AS TEXT) AS index_name, CAST(indexdef AS TEXT) AS definition FROM pg_indexes WHERE schemaname = $1 AND tablename = $2 ORDER BY indexname".to_string(), - _ => "SELECT name AS index_name, sql AS definition FROM sqlite_master WHERE type='index' AND tbl_name = ? AND sql IS NOT NULL ORDER BY name".to_string(), - }; - let mut q = sqlx::query(&sql); - if kind == "mysql" { - q = q.bind(table); - } - if kind == "sqlite" { - q = q.bind(table); - } - if kind == "postgresql" { - q = q - .bind(if schema.is_empty() { "public" } else { schema }) - .bind(table); - } - rows_value(q.fetch_all(pool).await.map_err(db_error)?) -} - -async fn list_foreign_keys(pool: &AnyPool, kind: &str, schema: &str, table: &str) -> DbResult { - validate_identifier(table)?; - let sql = match kind { - "mysql" => "SELECT constraint_name, column_name, referenced_table_name, referenced_column_name FROM information_schema.key_column_usage WHERE table_schema = DATABASE() AND table_name = ? AND referenced_table_name IS NOT NULL ORDER BY constraint_name, ordinal_position".to_string(), - "postgresql" => "SELECT CAST(tc.constraint_name AS TEXT) AS constraint_name, CAST(kcu.column_name AS TEXT) AS column_name, CAST(ccu.table_name AS TEXT) AS referenced_table_name, CAST(ccu.column_name AS TEXT) AS referenced_column_name FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage kcu ON tc.constraint_name=kcu.constraint_name AND tc.constraint_schema=kcu.constraint_schema JOIN information_schema.constraint_column_usage ccu ON ccu.constraint_name=tc.constraint_name AND ccu.constraint_schema=tc.constraint_schema WHERE tc.constraint_type='FOREIGN KEY' AND tc.table_schema=$1 AND tc.table_name=$2 ORDER BY tc.constraint_name, kcu.ordinal_position".to_string(), - _ => format!("PRAGMA foreign_key_list({})", quote_identifier(kind, table)?), - }; - let mut q = sqlx::query(&sql); - if kind == "mysql" { - q = q.bind(table); - } - if kind == "postgresql" { - q = q - .bind(if schema.is_empty() { "public" } else { schema }) - .bind(table); - } - rows_value(q.fetch_all(pool).await.map_err(db_error)?) -} - -async fn list_objects(pool: &AnyPool, kind: &str, schema: &str, object_kind: &str) -> DbResult { - let requested = if object_kind.is_empty() { - "all" - } else { - object_kind - }; - let sql = match (kind, requested) { - ("mysql", "views") => "SELECT table_name AS object_name, 'view' AS object_kind, view_definition AS definition FROM information_schema.views WHERE table_schema=DATABASE() ORDER BY table_name".to_string(), - ("mysql", "routines" | "procedures" | "functions") => "SELECT routine_name AS object_name, routine_type AS object_kind, routine_definition AS definition FROM information_schema.routines WHERE routine_schema=DATABASE() ORDER BY routine_name".to_string(), - ("mysql", "triggers") => "SELECT trigger_name AS object_name, 'trigger' AS object_kind, action_statement AS definition FROM information_schema.triggers WHERE trigger_schema=DATABASE() ORDER BY trigger_name".to_string(), - ("mysql", "sequences") => "SELECT CAST(NULL AS CHAR) AS object_name, 'sequence' AS object_kind, CAST(NULL AS CHAR) AS definition WHERE 1=0".to_string(), - ("mysql", _) => "SELECT table_name AS object_name, table_type AS object_kind, CAST(NULL AS CHAR) AS definition FROM information_schema.tables WHERE table_schema=DATABASE() ORDER BY table_name".to_string(), - ("postgresql", "views") => "SELECT CAST(viewname AS TEXT) AS object_name, 'view' AS object_kind, CAST(definition AS TEXT) AS definition FROM pg_views WHERE schemaname=$1 ORDER BY viewname".to_string(), - ("postgresql", "routines" | "procedures" | "functions") => "SELECT CAST(p.proname AS TEXT) AS object_name, CASE WHEN p.prokind='p' THEN 'procedure' ELSE 'function' END AS object_kind, CAST(pg_get_functiondef(p.oid) AS TEXT) AS definition FROM pg_proc p JOIN pg_namespace n ON n.oid=p.pronamespace WHERE n.nspname=$1 ORDER BY p.proname".to_string(), - ("postgresql", "triggers") => "SELECT CAST(t.tgname AS TEXT) AS object_name, 'trigger' AS object_kind, CAST(pg_get_triggerdef(t.oid) AS TEXT) AS definition FROM pg_trigger t JOIN pg_class c ON c.oid=t.tgrelid JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname=$1 AND NOT t.tgisinternal ORDER BY t.tgname".to_string(), - ("postgresql", "sequences") => "SELECT CAST(c.relname AS TEXT) AS object_name, 'sequence' AS object_kind, CAST(NULL AS TEXT) AS definition FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname=$1 AND c.relkind='S' ORDER BY c.relname".to_string(), - ("postgresql", _) => "SELECT CAST(c.relname AS TEXT) AS object_name, CASE c.relkind WHEN 'r' THEN 'table' WHEN 'v' THEN 'view' WHEN 'S' THEN 'sequence' ELSE c.relkind::TEXT END AS object_kind, CAST(NULL AS TEXT) AS definition FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname=$1 AND c.relkind IN ('r','v','S') ORDER BY c.relname".to_string(), - ("sqlite", "views") => "SELECT name AS object_name, 'view' AS object_kind, sql AS definition FROM sqlite_master WHERE type='view' ORDER BY name".to_string(), - ("sqlite", "triggers") => "SELECT name AS object_name, 'trigger' AS object_kind, sql AS definition FROM sqlite_master WHERE type='trigger' ORDER BY name".to_string(), - ("sqlite", "sequences" | "routines" | "procedures" | "functions") => "SELECT NULL AS object_name, 'unsupported' AS object_kind, NULL AS definition WHERE 1=0".to_string(), - ("sqlite", _) => "SELECT name AS object_name, type AS object_kind, sql AS definition FROM sqlite_master WHERE type IN ('table','view','trigger') AND name NOT LIKE 'sqlite_%' ORDER BY name".to_string(), - _ => return Err(("unsupported_database".into(), "Unsupported database type".into())), - }; - let mut query = sqlx::query(&sql); - if kind == "postgresql" { - query = query.bind(if schema.is_empty() { "public" } else { schema }); - } - rows_value(query.fetch_all(pool).await.map_err(db_error)?) -} - -async fn schema_change(direct: &DirectPool, kind: &str, params: &Params) -> DbResult { - let table = params.table.trim(); - validate_identifier(table)?; - let qualified = if !params.schema.is_empty() && kind != "mysql" { - format!( - "{}.{}", - quote_identifier(kind, ¶ms.schema)?, - quote_identifier(kind, table)? - ) - } else { - quote_identifier(kind, table)? - }; - let operation = params.operation.as_str(); - let sql = match operation { - "addColumn" => { - validate_identifier(¶ms.name)?; - let data_type = safe_sql_fragment(¶ms.data_type, "column type")?; - let mut sql = format!( - "ALTER TABLE {qualified} ADD COLUMN {} {data_type}", - quote_identifier(kind, ¶ms.name)? - ); - if !params.nullable { - sql.push_str(" NOT NULL"); - } - if !params.default_value.is_empty() { - sql.push_str(&format!( - " DEFAULT {}", - safe_sql_fragment(¶ms.default_value, "default")? - )); - } - sql - } - "renameColumn" => { - validate_identifier(¶ms.old_name)?; - validate_identifier(¶ms.name)?; - format!( - "ALTER TABLE {qualified} RENAME COLUMN {} TO {}", - quote_identifier(kind, ¶ms.old_name)?, - quote_identifier(kind, ¶ms.name)? - ) - } - "dropColumn" => { - validate_identifier(¶ms.name)?; - format!( - "ALTER TABLE {qualified} DROP COLUMN {}", - quote_identifier(kind, ¶ms.name)? - ) - } - "createIndex" => { - validate_identifier(¶ms.index_name)?; - let columns = quoted_columns(kind, ¶ms.index_columns)?; - if columns.is_empty() { - return Err(( - "invalid_schema_change".into(), - "At least one index column is required".into(), - )); - } - format!( - "CREATE INDEX {} ON {qualified} ({})", - quote_identifier(kind, ¶ms.index_name)?, - columns.join(", ") - ) - } - "dropIndex" => { - validate_identifier(¶ms.index_name)?; - if kind == "mysql" { - format!( - "DROP INDEX {} ON {qualified}", - quote_identifier(kind, ¶ms.index_name)? - ) - } else { - format!("DROP INDEX {}", quote_identifier(kind, ¶ms.index_name)?) - } - } - "addForeignKey" => { - validate_identifier(¶ms.constraint_name)?; - validate_identifier(¶ms.referenced_table)?; - let columns = quoted_columns(kind, ¶ms.index_columns)?; - let referenced = quoted_columns(kind, ¶ms.referenced_columns)?; - if columns.is_empty() || referenced.is_empty() || columns.len() != referenced.len() { - return Err(( - "invalid_schema_change".into(), - "Foreign-key columns must be paired".into(), - )); - } - format!( - "ALTER TABLE {qualified} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {} ({})", - quote_identifier(kind, ¶ms.constraint_name)?, - columns.join(", "), - quote_identifier(kind, ¶ms.referenced_table)?, - referenced.join(", ") - ) - } - "dropForeignKey" => { - validate_identifier(¶ms.constraint_name)?; - if kind == "mysql" { - format!( - "ALTER TABLE {qualified} DROP FOREIGN KEY {}", - quote_identifier(kind, ¶ms.constraint_name)? - ) - } else { - format!( - "ALTER TABLE {qualified} DROP CONSTRAINT {}", - quote_identifier(kind, ¶ms.constraint_name)? - ) - } - } - "createView" => { - validate_identifier(¶ms.name)?; - if params.sql.trim().is_empty() { - return Err(( - "invalid_schema_change".into(), - "View SQL is required".into(), - )); - } - format!( - "CREATE VIEW {} AS {}", - quote_identifier(kind, ¶ms.name)?, - require_single_sql(¶ms.sql)? - ) - } - "dropView" => { - validate_identifier(¶ms.name)?; - format!("DROP VIEW {}", quote_identifier(kind, ¶ms.name)?) - } - _ => { - return Err(( - "invalid_schema_change".into(), - format!("Unsupported schema operation: {operation}"), - )) - } - }; - execute_direct(direct, &sql, &[]).await -} - -fn quoted_columns(kind: &str, columns: &[String]) -> Result, (String, String)> { - columns - .iter() - .map(|column| { - validate_identifier(column)?; - quote_identifier(kind, column) - }) - .collect() -} - -fn safe_sql_fragment(value: &str, label: &str) -> Result { - let value = value.trim(); - if value.is_empty() - || value.contains(';') - || value.contains("--") - || value.contains("/*") - || value.contains("*/") - || value.chars().any(|character| character.is_control()) - { - return Err(("invalid_schema_change".into(), format!("Invalid {label}"))); - } - Ok(value.to_string()) -} - -fn require_single_sql(sql: &str) -> Result { - let trimmed = sql.trim(); - if trimmed.is_empty() || trimmed.contains(';') { - return Err(( - "invalid_query".into(), - "A single SQL statement is required".into(), - )); - } - Ok(trimmed.to_string()) -} - -async fn page_table( - pool: &AnyPool, - direct: &DirectPool, - kind: &str, - schema: &str, - table: &str, - limit: u32, - offset: u32, - filters: &[Filter], - sort: &[Sort], -) -> DbResult { - validate_identifier(table)?; - let qualified = if !schema.is_empty() && kind != "mysql" { - format!( - "{}.{}", - quote_identifier(kind, schema)?, - quote_identifier(kind, table)? - ) - } else { - quote_identifier(kind, table)? - }; - let (where_sql, values) = filter_clause(kind, filters)?; - let order_sql = sort_clause(kind, sort)?; - let limit = limit.clamp(1, 1000); - let sql = - format!("SELECT * FROM {qualified}{where_sql}{order_sql} LIMIT {limit} OFFSET {offset}"); - let count_sql = format!("SELECT COUNT(*) AS total_rows FROM {qualified}{where_sql}"); - let mut result = query_direct(direct, &sql, &values, limit).await?; - let count = bind_values(sqlx::query(&count_sql), &values)? - .fetch_one(pool) - .await - .map_err(db_error)?; - let total = count.try_get::(0).map_err(db_error)?; - result - .as_object_mut() - .expect("query result object") - .insert("totalRows".into(), json!(total)); - Ok(result) -} - -fn filter_clause(kind: &str, filters: &[Filter]) -> Result<(String, Vec), (String, String)> { - let mut predicates = Vec::new(); - let mut values = Vec::new(); - for filter in filters { - let column = quote_identifier(kind, &filter.column)?; - let index = values.len() + 1; - let predicate = match filter.operator.as_str() { - "equals" => { - values.push(filter.value.clone()); - format!("{column} = {}", placeholder(kind, index)) - } - "notEquals" => { - values.push(filter.value.clone()); - format!("{column} <> {}", placeholder(kind, index)) - } - "greaterThan" => { - values.push(filter.value.clone()); - format!("{column} > {}", placeholder(kind, index)) - } - "lessThan" => { - values.push(filter.value.clone()); - format!("{column} < {}", placeholder(kind, index)) - } - "contains" => { - values.push(Value::String(format!( - "%{}%", - scalar_string(&filter.value)? - ))); - format!("{column} LIKE {}", placeholder(kind, index)) - } - "startsWith" => { - values.push(Value::String(format!("{}%", scalar_string(&filter.value)?))); - format!("{column} LIKE {}", placeholder(kind, index)) - } - "isNull" => format!("{column} IS NULL"), - "isNotNull" => format!("{column} IS NOT NULL"), - value => { - return Err(( - "invalid_filter".into(), - format!("Unsupported filter operator: {value}"), - )) - } - }; - let join = match filter.join.as_str() { - "and" => "AND", - "or" => "OR", - value => { - return Err(( - "invalid_filter".into(), - format!("Unsupported filter join: {value}"), - )) - } - }; - predicates.push((join, predicate)); - } - Ok(( - if predicates.is_empty() { - String::new() - } else { - let mut clause = predicates[0].1.clone(); - for (join, predicate) in predicates.iter().skip(1) { - clause.push_str(&format!(" {join} {predicate}")); - } - format!(" WHERE ({clause})") - }, - values, - )) -} - -fn sort_clause(kind: &str, sort: &[Sort]) -> Result { - let mut items = Vec::new(); - for item in sort { - items.push(format!( - "{} {}", - quote_identifier(kind, &item.column)?, - if item.descending { "DESC" } else { "ASC" } - )); - } - Ok(if items.is_empty() { - String::new() - } else { - format!(" ORDER BY {}", items.join(", ")) - }) -} - -fn scalar_string(value: &Value) -> Result { - match value { - Value::String(value) => Ok(value.clone()), - Value::Number(value) => Ok(value.to_string()), - Value::Bool(value) => Ok(value.to_string()), - _ => Err(( - "invalid_filter".into(), - "Text filters require a scalar value".into(), - )), - } -} - -async fn query_direct(pool: &DirectPool, sql: &str, values: &[Value], limit: u32) -> DbResult { - require_sql(sql)?; - let maximum = limit.clamp(1, 100_000) as usize; - let mut rows = Vec::with_capacity(maximum.min(200)); - let (truncated, columns) = match pool { - DirectPool::MySql(pool) => { - let mut stream = bind_mysql(sqlx::query(sql), values)?.fetch(pool); - collect_query_rows(&mut stream, maximum, &mut rows, mysql_row_json).await? - } - DirectPool::Postgres(pool) => { - let mut stream = bind_postgres(sqlx::query(sql), values)?.fetch(pool); - collect_query_rows(&mut stream, maximum, &mut rows, postgres_row_json).await? - } - DirectPool::Sqlite(pool) => { - let mut stream = bind_sqlite(sqlx::query(sql), values)?.fetch(pool); - collect_query_rows(&mut stream, maximum, &mut rows, sqlite_row_json).await? - } - }; - Ok(json!({"columns": columns, "truncated": truncated, "rows": rows})) -} - -async fn collect_query_rows<'a, R, S>( - stream: &mut S, - maximum: usize, - destination: &mut Vec, - decode: fn(&R) -> Result, -) -> Result<(bool, Vec), (String, String)> -where - S: futures_util::TryStream + Unpin, - R: Row, -{ - let mut columns = Vec::new(); - while let Some(row) = stream.try_next().await.map_err(db_error)? { - if columns.is_empty() { - columns = row - .columns() - .iter() - .map(|column| column.name().to_string()) - .collect(); - } - if destination.len() == maximum { - return Ok((true, columns)); - } - destination.push(decode(&row)?); - } - Ok((false, columns)) -} - -fn bind_mysql<'q>( - mut query: sqlx::query::Query<'q, MySql, sqlx::mysql::MySqlArguments>, - values: &'q [Value], -) -> Result, (String, String)> { - for value in values { - query = bind_mysql_value(query, value); - } - Ok(query) -} -fn bind_postgres<'q>( - mut query: sqlx::query::Query<'q, Postgres, sqlx::postgres::PgArguments>, - values: &'q [Value], -) -> Result, (String, String)> { - for value in values { - query = bind_postgres_value(query, value); - } - Ok(query) -} -fn bind_sqlite<'q>( - mut query: sqlx::query::Query<'q, Sqlite, sqlx::sqlite::SqliteArguments<'q>>, - values: &'q [Value], -) -> Result>, (String, String)> { - for value in values { - query = bind_sqlite_value(query, value); - } - Ok(query) -} - -fn binary_input(object: &Map) -> Option<&str> { - if object.len() != 1 { - return None; - } - object - .get("binary") - .or_else(|| object.get("base64")) - .and_then(Value::as_str) -} - -fn tagged_binary(bytes: impl AsRef<[u8]>) -> Value { - json!({"binary": BASE64.encode(bytes.as_ref())}) -} - -macro_rules! bind_value_fn { - ($name:ident, $db:ty, $args:ty) => { - fn $name<'q>( - query: sqlx::query::Query<'q, $db, $args>, - value: &'q Value, - ) -> sqlx::query::Query<'q, $db, $args> { - match value { - Value::Null => query.bind(Option::::None), - Value::Bool(v) => query.bind(*v), - Value::Number(v) if v.is_i64() => query.bind(v.as_i64().unwrap()), - Value::Number(v) if v.is_u64() => query.bind(v.as_u64().unwrap() as i64), - Value::Number(v) => query.bind(v.as_f64().unwrap()), - Value::String(v) => query.bind(v), - Value::Object(v) if v.get("decimal").and_then(Value::as_str).is_some() => { - match v["decimal"] - .as_str() - .unwrap() - .parse::() - { - Ok(value) => query.bind(value), - Err(_) => query.bind(v["decimal"].as_str().unwrap()), - } - } - Value::Object(v) if v.get("date").and_then(Value::as_str).is_some() => { - match v["date"].as_str().unwrap().parse::() { - Ok(value) => query.bind(value), - Err(_) => query.bind(v["date"].as_str().unwrap()), - } - } - Value::Object(v) if v.get("time").and_then(Value::as_str).is_some() => { - match v["time"].as_str().unwrap().parse::() { - Ok(value) => query.bind(value), - Err(_) => query.bind(v["time"].as_str().unwrap()), - } - } - Value::Object(v) if v.get("datetime").and_then(Value::as_str).is_some() => { - match v["datetime"] - .as_str() - .unwrap() - .parse::() - { - Ok(value) => query.bind(value), - Err(_) => query.bind(v["datetime"].as_str().unwrap()), - } - } - Value::Object(v) - if v.get("timestampWithTimeZone") - .and_then(Value::as_str) - .is_some() => - { - match chrono::DateTime::parse_from_rfc3339( - v["timestampWithTimeZone"].as_str().unwrap(), - ) { - Ok(value) => query.bind(value.with_timezone(&chrono::Utc)), - Err(_) => query.bind(v["timestampWithTimeZone"].as_str().unwrap()), - } - } - Value::Object(v) if v.get("uuid").and_then(Value::as_str).is_some() => { - match v["uuid"].as_str().unwrap().parse::() { - Ok(value) => query.bind(value), - Err(_) => query.bind(v["uuid"].as_str().unwrap()), - } - } - Value::Object(v) if v.get("json").is_some() => { - query.bind(sqlx::types::Json(v["json"].clone())) - } - Value::Object(v) if binary_input(v).is_some() => { - match BASE64.decode(binary_input(v).unwrap()) { - Ok(value) => query.bind(value), - Err(_) => query.bind(Vec::::new()), - } - } - other => query.bind(other.to_string()), - } - } - }; -} -bind_value_fn!(bind_mysql_value, MySql, sqlx::mysql::MySqlArguments); -bind_value_fn!(bind_postgres_value, Postgres, sqlx::postgres::PgArguments); - -fn bind_sqlite_value<'q>( - query: sqlx::query::Query<'q, Sqlite, sqlx::sqlite::SqliteArguments<'q>>, - value: &'q Value, -) -> sqlx::query::Query<'q, Sqlite, sqlx::sqlite::SqliteArguments<'q>> { - match value { - Value::Null => query.bind(Option::::None), - Value::Bool(v) => query.bind(*v), - Value::Number(v) if v.is_i64() => query.bind(v.as_i64().unwrap()), - Value::Number(v) if v.is_u64() => query.bind(v.as_u64().unwrap() as i64), - Value::Number(v) => query.bind(v.as_f64().unwrap()), - Value::String(v) => query.bind(v), - Value::Object(v) if binary_input(v).is_some() => { - query.bind(BASE64.decode(binary_input(v).unwrap()).unwrap_or_default()) - } - Value::Object(v) if v.get("json").is_some() => query.bind(v["json"].to_string()), - Value::Object(v) => { - let tagged = [ - "decimal", - "date", - "time", - "datetime", - "timestampWithTimeZone", - "uuid", - ] - .iter() - .find_map(|key| v.get(*key).and_then(Value::as_str)); - query.bind(tagged.unwrap_or_else(|| "")) - } - other => query.bind(other.to_string()), - } -} - -fn mysql_row_json(row: &MySqlRow) -> Result { - typed_row_json(row, mysql_value) -} -fn postgres_row_json(row: &PgRow) -> Result { - typed_row_json(row, postgres_value) -} -fn sqlite_row_json(row: &SqliteRow) -> Result { - typed_row_json(row, sqlite_value) -} - -fn typed_row_json( - row: &R, - decode: fn(&R, usize, &str) -> Value, -) -> Result -where - R: Row, - usize: sqlx::ColumnIndex, -{ - let mut object = Map::new(); - for (index, column) in row.columns().iter().enumerate() { - let raw = row - .try_get_raw(index) - .map_err(|e| ("database_error".into(), e.to_string()))?; - let value = if raw.is_null() { - Value::Null - } else { - decode(row, index, column.type_info().name()) - }; - object.insert(column.name().to_string(), value); - } - Ok(Value::Object(object)) -} - -fn mysql_value(row: &MySqlRow, index: usize, kind: &str) -> Value { - match kind.to_ascii_uppercase().as_str() { - "DECIMAL" | "NEWDECIMAL" => row - .try_get::(index) - .map(|v| json!({"decimal": v.to_string()})) - .unwrap_or_else(|_| mysql_text(row, index)), - "DATE" => row - .try_get::(index) - .map(|v| Value::String(v.to_string())) - .unwrap_or_else(|_| mysql_text(row, index)), - "TIME" => row - .try_get::(index) - .map(|v| Value::String(v.to_string())) - .unwrap_or_else(|_| mysql_text(row, index)), - "DATETIME" => row - .try_get::(index) - .map(|v| Value::String(v.to_string())) - .or_else(|_| { - row.try_get::, _>(index) - .map(|v| Value::String(v.to_rfc3339())) - }) - .unwrap_or_else(|_| mysql_text(row, index)), - "TIMESTAMP" => row - .try_get::, _>(index) - .map(|v| Value::String(v.to_rfc3339())) - .or_else(|_| { - row.try_get::(index) - .map(|v| Value::String(v.to_string())) - }) - .or_else(|_| { - row.try_get::, _>(index) - .map(|v| Value::String(v.to_rfc3339())) - }) - .unwrap_or_else(|_| mysql_text(row, index)), - "BOOLEAN" | "BOOL" => row - .try_get::(index) - .map(Value::Bool) - .or_else(|_| row.try_get::(index).map(|v| Value::Bool(v != 0))) - .or_else(|_| row.try_get::(index).map(|v| Value::Bool(v != 0))) - .or_else(|_| row.try_get::(index).map(|v| Value::Bool(v != 0))) - .unwrap_or_else(|_| mysql_text(row, index)), - "JSON" => row - .try_get::(index) - .unwrap_or_else(|_| mysql_text(row, index)), - "BLOB" | "BINARY" | "VARBINARY" => row - .try_get::, _>(index) - .map(tagged_binary) - .unwrap_or_else(|_| mysql_text(row, index)), - "FLOAT" | "DOUBLE" => row - .try_get::(index) - .map(|v| json!(v)) - .unwrap_or_else(|_| mysql_text(row, index)), - value if value == "TINYINT" => row - .try_get::(index) - .map(|v| json!(v)) - .or_else(|_| row.try_get::(index).map(|v| json!(v))) - .or_else(|_| row.try_get::(index).map(|v| json!(v))) - .or_else(|_| row.try_get::(index).map(|v| json!(v))) - .or_else(|_| row.try_get::(index).map(|v| json!(v))) - .unwrap_or_else(|_| mysql_text(row, index)), - value if value.contains("INT") || value == "YEAR" => row - .try_get::(index) - .map(|v| json!(v)) - .or_else(|_| row.try_get::(index).map(|v| json!(v))) - .unwrap_or_else(|_| mysql_text(row, index)), - _ => mysql_text(row, index), - } -} -fn mysql_text(row: &MySqlRow, index: usize) -> Value { - row.try_get::(index) - .map(Value::String) - .or_else(|_| row.try_get_unchecked::(index).map(Value::String)) - .or_else(|_| { - row.try_get::, _>(index) - .map(|value| mysql_bytes_value(&value)) - }) - .or_else(|_| { - row.try_get_unchecked::, _>(index) - .map(|value| mysql_bytes_value(&value)) - }) - .or_else(|_| row.try_get::(index).map(|value| json!(value))) - .or_else(|_| row.try_get::(index).map(|value| json!(value))) - .or_else(|_| row.try_get::(index).map(|value| json!(value))) - .unwrap_or_else(|_| Value::String("".into())) -} - -fn mysql_bytes_value(value: &[u8]) -> Value { - match String::from_utf8(value.to_vec()) { - Ok(text) => Value::String(text), - Err(_) => tagged_binary(value), - } -} - -fn postgres_value(row: &PgRow, index: usize, kind: &str) -> Value { - match kind.to_ascii_uppercase().as_str() { - "NUMERIC" => row - .try_get::(index) - .map(|v| json!({"decimal": v.to_string()})) - .unwrap_or_else(|_| pg_text(row, index)), - "DATE" => row - .try_get::(index) - .map(|v| Value::String(v.to_string())) - .unwrap_or_else(|_| pg_text(row, index)), - "TIME" => row - .try_get::(index) - .map(|v| Value::String(v.to_string())) - .unwrap_or_else(|_| pg_text(row, index)), - "TIMESTAMP" => row - .try_get::(index) - .map(|v| Value::String(v.to_string())) - .unwrap_or_else(|_| pg_text(row, index)), - "TIMESTAMPTZ" => row - .try_get::, _>(index) - .map(|v| Value::String(v.to_rfc3339())) - .unwrap_or_else(|_| pg_text(row, index)), - "UUID" => row - .try_get::(index) - .map(|v| Value::String(v.to_string())) - .unwrap_or_else(|_| pg_text(row, index)), - "JSON" | "JSONB" => row - .try_get::(index) - .unwrap_or_else(|_| pg_text(row, index)), - "BYTEA" => row - .try_get::, _>(index) - .map(tagged_binary) - .unwrap_or_else(|_| pg_text(row, index)), - "BOOL" => row - .try_get::(index) - .map(Value::Bool) - .unwrap_or_else(|_| pg_text(row, index)), - "INT2" => row - .try_get::(index) - .map(|v| json!(v)) - .unwrap_or_else(|_| pg_text(row, index)), - "INT4" => row - .try_get::(index) - .map(|v| json!(v)) - .unwrap_or_else(|_| pg_text(row, index)), - "INT8" => row - .try_get::(index) - .map(|v| json!(v)) - .unwrap_or_else(|_| pg_text(row, index)), - "FLOAT4" => row - .try_get::(index) - .map(|v| json!(v)) - .unwrap_or_else(|_| pg_text(row, index)), - "FLOAT8" => row - .try_get::(index) - .map(|v| json!(v)) - .unwrap_or_else(|_| pg_text(row, index)), - _ => pg_text(row, index), - } -} -fn pg_text(row: &PgRow, index: usize) -> Value { - row.try_get::(index) - .map(Value::String) - .unwrap_or_else(|_| Value::String("".into())) -} - -fn sqlite_value(row: &SqliteRow, index: usize, kind: &str) -> Value { - match kind.to_ascii_uppercase().as_str() { - "INTEGER" | "INT" | "INT64" => row - .try_get::(index) - .map(|v| json!(v)) - .unwrap_or_else(|_| sqlite_text(row, index)), - "REAL" | "FLOAT" | "DOUBLE" => row - .try_get::(index) - .map(|v| json!(v)) - .unwrap_or_else(|_| sqlite_text(row, index)), - "BLOB" => row - .try_get::, _>(index) - .map(tagged_binary) - .unwrap_or_else(|_| sqlite_text(row, index)), - "BOOL" | "BOOLEAN" => row - .try_get::(index) - .map(Value::Bool) - .unwrap_or_else(|_| sqlite_text(row, index)), - _ => sqlite_text(row, index), - } -} -fn sqlite_text(row: &SqliteRow, index: usize) -> Value { - row.try_get::(index) - .map(Value::String) - .unwrap_or_else(|_| Value::String("".into())) -} - -async fn execute(pool: &AnyPool, sql: &str, values: &[Value]) -> DbResult { - require_sql(sql)?; - let result = bind_values(sqlx::query(sql), values)? - .execute(pool) - .await - .map_err(db_error)?; - Ok(json!({"rowsAffected": result.rows_affected()})) -} - -async fn execute_direct(pool: &DirectPool, sql: &str, values: &[Value]) -> DbResult { - require_sql(sql)?; - let affected = match pool { - DirectPool::MySql(pool) => bind_mysql(sqlx::query(sql), values)? - .execute(pool) - .await - .map_err(db_error)? - .rows_affected(), - DirectPool::Postgres(pool) => bind_postgres(sqlx::query(sql), values)? - .execute(pool) - .await - .map_err(db_error)? - .rows_affected(), - DirectPool::Sqlite(pool) => bind_sqlite(sqlx::query(sql), values)? - .execute(pool) - .await - .map_err(db_error)? - .rows_affected(), - }; - Ok(json!({"rowsAffected": affected})) -} - -async fn explain(pool: &AnyPool, kind: &str, sql: &str, format: &str) -> DbResult { - let sql = require_single_sql(sql)?; - let explain_sql = match kind { - "postgresql" if format.eq_ignore_ascii_case("json") || format.is_empty() => { - format!("EXPLAIN (FORMAT JSON) {sql}") - } - "postgresql" => format!("EXPLAIN {sql}"), - "sqlite" => format!("EXPLAIN QUERY PLAN {sql}"), - _ => format!("EXPLAIN {sql}"), - }; - let rows = sqlx::query(&explain_sql) - .fetch_all(pool) - .await - .map_err(db_error)?; - Ok( - json!({"format": if kind == "postgresql" && (format.is_empty() || format.eq_ignore_ascii_case("json")) { "json" } else { "rows" }, "rows": rows_value(rows)?, "truncated": false}), - ) -} - -async fn diagnostics(pool: &AnyPool, kind: &str, params: &Params) -> DbResult { - let diagnostic_kind = if params.diagnostic_kind.is_empty() { - "tableSize" - } else { - params.diagnostic_kind.as_str() - }; - if diagnostic_kind == "indexes" { - let rows = list_indexes(pool, kind, ¶ms.schema, ¶ms.table).await?; - return Ok(json!({"rows": rows, "truncated": false})); - } - let sql = match (kind, diagnostic_kind) { - ("mysql", "tableSize") => "SELECT table_name, table_rows, data_length, index_length, (data_length + index_length) AS total_bytes FROM information_schema.tables WHERE table_schema=DATABASE() AND (? = '' OR table_name = ?) ORDER BY total_bytes DESC".to_string(), - ("postgresql", "tableSize") => "SELECT c.relname AS table_name, c.reltuples AS estimated_rows, pg_total_relation_size(c.oid) AS total_bytes, pg_relation_size(c.oid) AS table_bytes FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname=$1 AND ($2='' OR c.relname=$2) AND c.relkind IN ('r','m') ORDER BY total_bytes DESC".to_string(), - ("sqlite", "tableSize") => "SELECT name AS table_name, (SELECT page_count * page_size FROM pragma_page_count, pragma_page_size) AS total_bytes FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND (?='' OR name=?) ORDER BY name".to_string(), - ("mysql", "locks") => "SELECT trx_id, trx_started, trx_state, trx_mysql_thread_id, trx_query FROM information_schema.innodb_trx ORDER BY trx_started".to_string(), - ("postgresql", "locks") => "SELECT pid, locktype, mode, granted, relation::regclass AS relation, transactionid, query FROM pg_locks l LEFT JOIN pg_stat_activity a ON a.pid=l.pid WHERE NOT granted OR l.relation IS NOT NULL ORDER BY granted, pid".to_string(), - ("sqlite", "locks") => "SELECT 'SQLite uses file-level locking; active lock state is not exposed by the driver' AS message".to_string(), - ("mysql", "slowQueries") => "SELECT DIGEST_TEXT AS query, COUNT_STAR AS executions, AVG_TIMER_WAIT/1000000 AS avg_ms, SUM_TIMER_WAIT/1000000 AS total_ms, SUM_ROWS_AFFECTED AS rows_affected FROM performance_schema.events_statements_summary_by_digest WHERE DIGEST_TEXT IS NOT NULL ORDER BY SUM_TIMER_WAIT DESC LIMIT 100".to_string(), - ("postgresql", "slowQueries") => "SELECT query, calls AS executions, mean_exec_time AS avg_ms, total_exec_time AS total_ms, rows FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 100".to_string(), - ("sqlite", "slowQueries") => "SELECT NULL AS query, 0 AS executions, 0 AS avg_ms, 0 AS total_ms WHERE 1=0".to_string(), - ("mysql", "schemaImpact") => "SELECT TABLE_NAME AS table_name, CONSTRAINT_NAME AS constraint_name, REFERENCED_TABLE_NAME AS referenced_table_name FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA=DATABASE() AND REFERENCED_TABLE_NAME IS NOT NULL AND (?='' OR REFERENCED_TABLE_NAME=?) ORDER BY TABLE_NAME".to_string(), - ("postgresql", "schemaImpact") => "SELECT CAST(tc.table_name AS TEXT) AS table_name, CAST(tc.constraint_name AS TEXT) AS constraint_name, CAST(ccu.table_name AS TEXT) AS referenced_table_name FROM information_schema.table_constraints tc JOIN information_schema.constraint_column_usage ccu ON ccu.constraint_name=tc.constraint_name AND ccu.constraint_schema=tc.constraint_schema WHERE tc.constraint_type='FOREIGN KEY' AND tc.table_schema=$1 AND ($2='' OR ccu.table_name=$2) ORDER BY tc.table_name".to_string(), - ("sqlite", "schemaImpact") => "SELECT m.name AS table_name, f.id AS foreign_key_id, f.table AS referenced_table_name FROM sqlite_master m, pragma_foreign_key_list(m.name) f WHERE m.type='table' AND (?='' OR f.table=?) ORDER BY m.name".to_string(), - (_, "dataQuality") => return data_quality(pool, kind, params).await, - _ => return Err(("unsupported_diagnostic".into(), format!("Unsupported diagnostic: {diagnostic_kind}"))), - }; - let mut query = sqlx::query(&sql); - match (kind, diagnostic_kind) { - ("postgresql", "tableSize" | "locks" | "slowQueries") => {} - ("postgresql", "schemaImpact") => { - query = query - .bind(if params.schema.is_empty() { - "public" - } else { - params.schema.as_str() - }) - .bind(¶ms.table); - } - ("mysql" | "sqlite", "tableSize" | "schemaImpact") => { - query = query.bind(¶ms.table).bind(¶ms.table); - } - _ => {} - } - if kind == "postgresql" && diagnostic_kind == "tableSize" { - query = query - .bind(if params.schema.is_empty() { - "public" - } else { - params.schema.as_str() - }) - .bind(¶ms.table); - } - rows_value(query.fetch_all(pool).await.map_err(db_error)?) -} - -async fn data_quality(pool: &AnyPool, kind: &str, params: &Params) -> DbResult { - validate_identifier(¶ms.table)?; - let description = describe_table(pool, kind, ¶ms.schema, ¶ms.table).await?; - let rows = description.as_array().cloned().unwrap_or_default(); - let mut results = Vec::new(); - for row in rows { - let column = row - .get("column_name") - .and_then(Value::as_str) - .or_else(|| row.get("name").and_then(Value::as_str)) - .unwrap_or_default(); - if column.is_empty() { - continue; - } - let qualified = if !params.schema.is_empty() && kind != "mysql" { - format!( - "{}.{}", - quote_identifier(kind, ¶ms.schema)?, - quote_identifier(kind, ¶ms.table)? - ) - } else { - quote_identifier(kind, ¶ms.table)? - }; - let sql = format!("SELECT COUNT(*) AS total_rows, SUM(CASE WHEN {} IS NULL THEN 1 ELSE 0 END) AS null_rows FROM {qualified}", quote_identifier(kind, column)?); - let values = rows_value(sqlx::query(&sql).fetch_all(pool).await.map_err(db_error)?)?; - if let Some(value) = values.as_array().and_then(|items| items.first()) { - results.push(json!({"column": column, "metrics": value})); - } - } - Ok(json!({"rows": results, "truncated": false})) -} - -async fn run_transaction(pool: &DirectPool, _kind: &str, statements: &[Statement]) -> DbResult { - if statements.is_empty() { - return Ok(json!({"rowsAffected": 0})); - } - let mut affected = 0_u64; - match pool { - DirectPool::MySql(pool) => { - let mut transaction = pool.begin().await.map_err(db_error)?; - for statement in statements { - affected += bind_mysql(sqlx::query(&statement.sql), &statement.values)? - .execute(&mut *transaction) - .await - .map_err(db_error)? - .rows_affected(); - } - transaction.commit().await.map_err(db_error)?; - } - DirectPool::Postgres(pool) => { - let mut transaction = pool.begin().await.map_err(db_error)?; - for statement in statements { - affected += bind_postgres(sqlx::query(&statement.sql), &statement.values)? - .execute(&mut *transaction) - .await - .map_err(db_error)? - .rows_affected(); - } - transaction.commit().await.map_err(db_error)?; - } - DirectPool::Sqlite(pool) => { - let mut transaction = pool.begin().await.map_err(db_error)?; - for statement in statements { - affected += bind_sqlite(sqlx::query(&statement.sql), &statement.values)? - .execute(&mut *transaction) - .await - .map_err(db_error)? - .rows_affected(); - } - transaction.commit().await.map_err(db_error)?; - } - } - Ok(json!({"rowsAffected": affected})) -} - -fn ensure_write_allowed( - connection: &Connection, - sql: &str, - confirmed: bool, - allow_write: bool, -) -> Result<(), (String, String)> { - if connection.read_only && !allow_write { - return Err(( - "read_only".into(), - "This connection is configured as read-only".into(), - )); - } - if connection.production_protection && is_dangerous_sql(sql) && !confirmed { - return Err(( - "confirmation_required".into(), - "Production protection requires explicit confirmation".into(), - )); - } - Ok(()) -} - -fn ensure_mutations_allowed( - connection: &Connection, - mutations: &[Mutation], - confirmed: bool, - allow_write: bool, -) -> Result<(), (String, String)> { - if connection.read_only && !allow_write { - return Err(( - "read_only".into(), - "This connection is configured as read-only".into(), - )); - } - if connection.production_protection - && mutations.iter().any(|mutation| mutation.action == "delete") - && !confirmed - { - return Err(( - "confirmation_required".into(), - "Production protection requires explicit confirmation".into(), - )); - } - Ok(()) -} - -fn ensure_transaction_allowed( - connection: &Connection, - statements: &[Statement], - confirmed: bool, - allow_write: bool, -) -> Result<(), (String, String)> { - if connection.read_only && !allow_write { - return Err(( - "read_only".into(), - "This connection is configured as read-only".into(), - )); - } - if connection.production_protection - && statements - .iter() - .any(|statement| is_dangerous_sql(&statement.sql)) - && !confirmed - { - return Err(( - "confirmation_required".into(), - "Production protection requires explicit confirmation".into(), - )); - } - Ok(()) -} - -fn is_dangerous_sql(sql: &str) -> bool { - let upper = sql.trim_start().to_ascii_uppercase(); - upper == "RESTORE" - || upper.starts_with("DROP ") - || upper.starts_with("TRUNCATE ") - || (upper.starts_with("UPDATE ") && !upper.contains(" WHERE ")) - || (upper.starts_with("DELETE ") && !upper.contains(" WHERE ")) -} - -async fn apply_changes( - pool: &DirectPool, - kind: &str, - schema: &str, - mutations: &[Mutation], -) -> DbResult { - if mutations.is_empty() { - return Ok(json!({"rowsAffected": 0})); - } - let mut affected = 0_u64; - match pool { - DirectPool::MySql(pool) => { - let mut transaction = pool.begin().await.map_err(db_error)?; - for mutation in mutations { - let (sql, values) = mutation_sql(kind, schema, mutation)?; - affected += bind_mysql(sqlx::query(&sql), &values)? - .execute(&mut *transaction) - .await - .map_err(db_error)? - .rows_affected(); - } - transaction.commit().await.map_err(db_error)?; - } - DirectPool::Postgres(pool) => { - let mut transaction = pool.begin().await.map_err(db_error)?; - for mutation in mutations { - let (sql, values) = mutation_sql(kind, schema, mutation)?; - affected += bind_postgres(sqlx::query(&sql), &values)? - .execute(&mut *transaction) - .await - .map_err(db_error)? - .rows_affected(); - } - transaction.commit().await.map_err(db_error)?; - } - DirectPool::Sqlite(pool) => { - let mut transaction = pool.begin().await.map_err(db_error)?; - for mutation in mutations { - let (sql, values) = mutation_sql(kind, schema, mutation)?; - affected += bind_sqlite(sqlx::query(&sql), &values)? - .execute(&mut *transaction) - .await - .map_err(db_error)? - .rows_affected(); - } - transaction.commit().await.map_err(db_error)?; - } - } - Ok(json!({"rowsAffected": affected})) -} - -fn mutation_sql( - kind: &str, - schema: &str, - mutation: &Mutation, -) -> Result<(String, Vec), (String, String)> { - validate_identifier(&mutation.table)?; - let table = if !schema.is_empty() && kind != "mysql" { - format!( - "{}.{}", - quote_identifier(kind, schema)?, - quote_identifier(kind, &mutation.table)? - ) - } else { - quote_identifier(kind, &mutation.table)? - }; - let columns = |map: &Map| -> Result, (String, String)> { - map.keys().map(|key| quote_identifier(kind, key)).collect() - }; - match mutation.action.as_str() { - "insert" => { - if mutation.values.is_empty() { - let sql = if kind == "mysql" { - format!("INSERT INTO {table} () VALUES ()") - } else { - format!("INSERT INTO {table} DEFAULT VALUES") - }; - return Ok((sql, Vec::new())); - } - let names = columns(&mutation.values)?; - let placeholders = placeholders(kind, 1, names.len()); - Ok(( - format!( - "INSERT INTO {table} ({}) VALUES ({})", - names.join(", "), - placeholders.join(", ") - ), - mutation.values.values().cloned().collect(), - )) - } - "update" => { - require_key(&mutation.key)?; - if mutation.values.is_empty() { - return Err(( - "invalid_mutation".into(), - "Update values cannot be empty".into(), - )); - } - let names = columns(&mutation.values)?; - let key_names = columns(&mutation.key)?; - let assignments: Vec = names - .iter() - .enumerate() - .map(|(index, name)| format!("{name} = {}", placeholder(kind, index + 1))) - .collect(); - let predicates: Vec = key_names - .iter() - .enumerate() - .map(|(index, name)| { - format!("{name} = {}", placeholder(kind, names.len() + index + 1)) - }) - .collect(); - let mut values: Vec = mutation.values.values().cloned().collect(); - values.extend(mutation.key.values().cloned()); - Ok(( - format!( - "UPDATE {table} SET {} WHERE {}", - assignments.join(", "), - predicates.join(" AND ") - ), - values, - )) - } - "delete" => { - require_key(&mutation.key)?; - let names = columns(&mutation.key)?; - let predicates: Vec = names - .iter() - .enumerate() - .map(|(index, name)| format!("{name} = {}", placeholder(kind, index + 1))) - .collect(); - Ok(( - format!("DELETE FROM {table} WHERE {}", predicates.join(" AND ")), - mutation.key.values().cloned().collect(), - )) - } - action => Err(( - "invalid_mutation".into(), - format!("Unsupported mutation action: {action}"), - )), - } -} - -fn require_key(key: &Map) -> Result<(), (String, String)> { - if key.is_empty() { - Err(( - "unsafe_mutation".into(), - "Update and delete require a primary key".into(), - )) - } else { - Ok(()) - } -} - -fn placeholders(kind: &str, start: usize, count: usize) -> Vec { - (start..start + count) - .map(|index| placeholder(kind, index)) - .collect() -} - -fn placeholder(kind: &str, index: usize) -> String { - if kind == "postgresql" { - format!("${index}") - } else { - "?".into() - } -} - -async fn export_csv(pool: &DirectPool, sql: &str, values: &[Value], limit: u32) -> DbResult { - let result = query_direct(pool, sql, values, limit).await?; - let rows = result["rows"].as_array().cloned().unwrap_or_default(); - let headers: Vec = result["columns"] - .as_array() - .map(|columns| { - columns - .iter() - .filter_map(Value::as_str) - .map(str::to_owned) - .collect() - }) - .filter(|columns: &Vec| !columns.is_empty()) - .unwrap_or_else(|| { - rows.first() - .and_then(Value::as_object) - .map(|row| row.keys().cloned().collect()) - .unwrap_or_default() - }); - let mut writer = csv::Writer::from_writer(Vec::new()); - writer.write_record(&headers).map_err(csv_error)?; - for row in rows { - let object = row - .as_object() - .ok_or_else(|| ("export_failed".into(), "Invalid row".into()))?; - writer - .write_record( - headers - .iter() - .map(|key| csv_value(object.get(key).unwrap_or(&Value::Null))), - ) - .map_err(csv_error)?; - } - let bytes = writer - .into_inner() - .map_err(|e| ("export_failed".into(), e.to_string()))?; - Ok(json!({"encoding": "base64", "data": BASE64.encode(bytes)})) -} - -async fn export_json(pool: &DirectPool, sql: &str, values: &[Value], limit: u32) -> DbResult { - let result = query_direct(pool, sql, values, limit).await?; - let bytes = serde_json::to_vec_pretty(&result["rows"]) - .map_err(|e| ("export_failed".into(), e.to_string()))?; - Ok(json!({"encoding": "base64", "data": BASE64.encode(bytes)})) -} - -async fn import_data( - pool: &DirectPool, - kind: &str, - schema: &str, - table: &str, - data: &str, - format: &str, -) -> DbResult { - let bytes = BASE64 - .decode(data) - .map_err(|e| ("invalid_import".into(), format!("Invalid base64 data: {e}")))?; - let rows = if format == "csv" { - parse_csv(&bytes)? - } else { - parse_json(&bytes)? - }; - if rows.is_empty() { - return Ok(json!({"rowsAffected": 0})); - } - if rows.len() > 100_000 { - return Err(( - "import_too_large".into(), - "A single import is limited to 100000 rows".into(), - )); - } - let mutations: Vec = rows - .into_iter() - .map(|values| Mutation { - action: "insert".into(), - table: table.into(), - values, - key: Map::new(), - }) - .collect(); - apply_changes(pool, kind, schema, &mutations).await -} - -fn parse_csv(bytes: &[u8]) -> Result>, (String, String)> { - let mut reader = csv::ReaderBuilder::new().flexible(false).from_reader(bytes); - let headers: Vec = reader - .headers() - .map_err(csv_error)? - .iter() - .map(str::to_string) - .collect(); - for header in &headers { - validate_identifier(header)?; - } - reader - .records() - .map(|record| { - let record = record.map_err(csv_error)?; - Ok(headers - .iter() - .zip(record.iter()) - .map(|(key, value)| (key.clone(), Value::String(value.into()))) - .collect()) - }) - .collect() -} - -fn parse_json(bytes: &[u8]) -> Result>, (String, String)> { - let rows: Vec> = serde_json::from_slice(bytes).map_err(|e| { - ( - "invalid_import".into(), - format!("Expected an array of JSON objects: {e}"), - ) - })?; - for row in &rows { - for key in row.keys() { - validate_identifier(key)?; - } - } - Ok(rows) -} - -async fn import_sql(pool: &AnyPool, data: &str) -> DbResult { - let bytes = BASE64 - .decode(data) - .map_err(|e| ("invalid_import".into(), format!("Invalid base64 data: {e}")))?; - import_sql_bytes(pool, &bytes).await -} - -async fn import_sql_file(pool: &AnyPool, path: &str) -> DbResult { - if path.trim().is_empty() { - return Err(("invalid_import".into(), "SQL file path is required".into())); - } - let bytes = tokio::fs::read(path) - .await - .map_err(|error| ("invalid_import".into(), error.to_string()))?; - import_sql_bytes(pool, &bytes).await -} - -async fn restore_sql(pool: &AnyPool, kind: &str, schema: &str, data: &str) -> DbResult { - let bytes = BASE64 - .decode(data) - .map_err(|e| ("invalid_import".into(), format!("Invalid base64 data: {e}")))?; - restore_sql_bytes(pool, kind, schema, &bytes).await -} - -async fn restore_sql_file(pool: &AnyPool, kind: &str, schema: &str, path: &str) -> DbResult { - if path.trim().is_empty() { - return Err(("invalid_import".into(), "SQL file path is required".into())); - } - let bytes = tokio::fs::read(path) - .await - .map_err(|error| ("invalid_import".into(), error.to_string()))?; - restore_sql_bytes(pool, kind, schema, &bytes).await -} - -async fn restore_sql_bytes(pool: &AnyPool, kind: &str, schema: &str, bytes: &[u8]) -> DbResult { - let script = validated_sql_script(bytes)?; - if script.trim().is_empty() { - return Err(( - "invalid_import".into(), - "SQL restore file cannot be empty".into(), - )); - } - drop_database_objects(pool, kind, schema).await?; - execute_sql_script(pool, script).await -} - -async fn import_sql_bytes(pool: &AnyPool, bytes: &[u8]) -> DbResult { - let script = validated_sql_script(bytes)?; - execute_sql_script(pool, script).await -} - -fn validated_sql_script(bytes: &[u8]) -> Result<&str, (String, String)> { - if bytes.len() > 256 * 1024 * 1024 { - return Err(( - "import_too_large".into(), - "SQL import is limited to 256 MB per request".into(), - )); - } - let script = std::str::from_utf8(&bytes).map_err(|e| { - ( - "invalid_import".into(), - format!("SQL file must be UTF-8: {e}"), - ) - })?; - Ok(script) -} - -async fn execute_sql_script(pool: &AnyPool, script: &str) -> DbResult { - if script.trim().is_empty() { - return Ok(json!({"rowsAffected": 0})); - } - let result = sqlx::raw_sql(script) - .execute(pool) - .await - .map_err(db_error)?; - Ok(json!({"rowsAffected": result.rows_affected()})) -} - -async fn export_sql( - pool: &AnyPool, - direct: &DirectPool, - kind: &str, - schema: &str, - selected: &[String], - include_structure: bool, - include_data: bool, - limit: u32, -) -> DbResult { - let mut output = Vec::new(); - write_sql_backup( - &mut output, - pool, - direct, - kind, - schema, - selected, - include_structure, - include_data, - limit, - ) - .await?; - Ok(json!({"encoding": "base64", "data": BASE64.encode(output)})) -} - -async fn write_sql_backup( - output: &mut W, - pool: &AnyPool, - direct: &DirectPool, - kind: &str, - schema: &str, - selected: &[String], - include_structure: bool, - include_data: bool, - limit: u32, -) -> Result<(), (String, String)> { - let schema = if schema.is_empty() && kind == "postgresql" { - "public" - } else { - schema - }; - let objects = database_objects(pool, kind, schema).await?; - let selected_set: std::collections::HashSet<&str> = - selected.iter().map(String::as_str).collect(); - for name in selected { - validate_identifier(name)?; - } - let objects: Vec<(String, String)> = objects - .into_iter() - .filter(|(name, _)| selected_set.is_empty() || selected_set.contains(name.as_str())) - .collect(); - write_export( - output, - "-- Lithe SQL backup\n-- Generated by lithe-db-sidecar\n\n", - )?; - match kind { - "mysql" => write_export(output, "SET FOREIGN_KEY_CHECKS=0;\n\n")?, - "sqlite" => write_export(output, "PRAGMA foreign_keys=OFF;\nBEGIN TRANSACTION;\n\n")?, - "postgresql" => write_export(output, "BEGIN;\n\n")?, - _ => {} - } - if include_structure { - write_export(output, "-- Remove existing objects before restore\n")?; - for (name, object_type) in objects.iter().rev() { - if let Some(statement) = object_drop_sql(kind, schema, name, object_type)? { - write_export(output, &statement)?; - write_export(output, ";\n")?; - } - } - write_export(output, "\n")?; - for (name, object_type) in &objects { - let ddl = object_ddl(pool, kind, schema, name, object_type).await?; - if !ddl.trim().is_empty() { - write_export( - output, - &format!( - "-- Structure for {name}\n{};\n\n", - ddl.trim().trim_end_matches(';') - ), - )?; - } - } - if kind == "postgresql" { - let table_names: Vec<&str> = objects - .iter() - .filter(|(_, object_type)| object_type.eq_ignore_ascii_case("base table")) - .map(|(name, _)| name.as_str()) - .collect(); - let foreign_keys = postgres_foreign_key_ddls(pool, schema, &table_names).await?; - if !foreign_keys.is_empty() { - write_export(output, "-- Foreign key constraints\n")?; - for statement in foreign_keys { - write_export(output, &statement)?; - write_export(output, ";\n")?; - } - write_export(output, "\n")?; - } - } - } - if include_data { - for (name, object_type) in &objects { - if !object_type.eq_ignore_ascii_case("table") - && !object_type.eq_ignore_ascii_case("base table") - { - continue; - } - write_export(output, &format!("-- Data for {name}\n"))?; - write_table_insert_sql(output, direct, kind, schema, name, limit).await?; - if kind == "postgresql" { - write_export(output, &postgres_sequence_sql(pool, schema, name).await?)?; - } - write_export(output, "\n")?; - } - } - match kind { - "mysql" => write_export(output, "SET FOREIGN_KEY_CHECKS=1;\n")?, - "sqlite" => write_export(output, "COMMIT;\nPRAGMA foreign_keys=ON;\n")?, - "postgresql" => write_export(output, "COMMIT;\n")?, - _ => {} - } - Ok(()) -} - -async fn export_sql_to_file( - pool: &AnyPool, - direct: &DirectPool, - kind: &str, - schema: &str, - selected: &[String], - include_structure: bool, - include_data: bool, - limit: u32, - path: &str, -) -> DbResult { - if path.trim().is_empty() { - return Err(( - "invalid_export".into(), - "Output file path is required".into(), - )); - } - let file = File::create(path).map_err(export_io_error)?; - let mut output = BufWriter::new(file); - write_sql_backup( - &mut output, - pool, - direct, - kind, - schema, - selected, - include_structure, - include_data, - limit, - ) - .await?; - output.flush().map_err(export_io_error)?; - drop(output); - let (byte_count, sha256) = sha256_file(path)?; - Ok(json!({ - "path": path, - "byteCount": byte_count, - "sha256": sha256 - })) -} - -fn write_export(output: &mut W, text: &str) -> Result<(), (String, String)> { - output.write_all(text.as_bytes()).map_err(export_io_error) -} - -fn export_io_error(error: io::Error) -> (String, String) { - ("export_failed".into(), error.to_string()) -} - -fn sha256_file(path: &str) -> Result<(u64, String), (String, String)> { - let file = File::open(path).map_err(export_io_error)?; - let mut reader = BufReader::new(file); - let mut digest = Sha256::new(); - let mut buffer = [0u8; 64 * 1024]; - let mut byte_count = 0u64; - loop { - let bytes_read = reader.read(&mut buffer).map_err(export_io_error)?; - if bytes_read == 0 { - break; - } - digest.update(&buffer[..bytes_read]); - byte_count += bytes_read as u64; - } - Ok((byte_count, hex::encode(digest.finalize()))) -} - -async fn database_objects( - pool: &AnyPool, - kind: &str, - schema: &str, -) -> Result, (String, String)> { - let (sql, bind_schema) = match kind { - "mysql" => ("SELECT table_name, table_type FROM information_schema.tables WHERE table_schema=DATABASE() ORDER BY CASE WHEN table_type='BASE TABLE' THEN 0 ELSE 1 END, table_name", false), - "postgresql" => ("SELECT CAST(table_name AS TEXT), CAST(table_type AS TEXT) FROM information_schema.tables WHERE table_schema=$1 ORDER BY CASE WHEN table_type='BASE TABLE' THEN 0 ELSE 1 END, table_name", true), - _ => ("SELECT name, type FROM sqlite_master WHERE type IN ('table','view') AND name NOT LIKE 'sqlite_%' ORDER BY CASE type WHEN 'table' THEN 0 ELSE 1 END, name", false), - }; - let mut query = sqlx::query(sql); - if bind_schema { - query = query.bind(schema); - } - let rows = query.fetch_all(pool).await.map_err(db_error)?; - rows.iter() - .map(|row| Ok((row_string(row, 0)?, row_string(row, 1)?))) - .collect() -} - -async fn drop_database_objects( - pool: &AnyPool, - kind: &str, - schema: &str, -) -> Result<(), (String, String)> { - let schema = if schema.is_empty() && kind == "postgresql" { - "public" - } else { - schema - }; - match kind { - "mysql" => { - sqlx::query("SET FOREIGN_KEY_CHECKS=0") - .execute(pool) - .await - .map_err(db_error)?; - } - "sqlite" => { - sqlx::query("PRAGMA foreign_keys=OFF") - .execute(pool) - .await - .map_err(db_error)?; - } - _ => {} - } - let objects = database_objects(pool, kind, schema).await?; - for (name, object_type) in objects.iter().rev() { - if let Some(statement) = object_drop_sql(kind, schema, name, object_type)? { - sqlx::query(&statement) - .execute(pool) - .await - .map_err(db_error)?; - } - } - Ok(()) -} - -fn object_drop_sql( - kind: &str, - schema: &str, - name: &str, - object_type: &str, -) -> Result, (String, String)> { - let qualified = if kind == "postgresql" { - format!( - "{}.{}", - quote_identifier(kind, schema)?, - quote_identifier(kind, name)? - ) - } else { - quote_identifier(kind, name)? - }; - if object_type.eq_ignore_ascii_case("view") { - return Ok(Some(format!("DROP VIEW IF EXISTS {qualified}"))); - } - if object_type.eq_ignore_ascii_case("table") || object_type.eq_ignore_ascii_case("base table") { - return Ok(Some(format!( - "DROP TABLE IF EXISTS {qualified}{}", - if kind == "postgresql" { " CASCADE" } else { "" } - ))); - } - Ok(None) -} - -async fn object_ddl( - pool: &AnyPool, - kind: &str, - schema: &str, - name: &str, - object_type: &str, -) -> Result { - validate_identifier(name)?; - match kind { - "sqlite" => { - let row = sqlx::query( - "SELECT sql FROM sqlite_master WHERE name=? AND type IN ('table','view')", - ) - .bind(name) - .fetch_one(pool) - .await - .map_err(db_error)?; - let mut ddl = row_string(&row, 0)?; - if object_type.eq_ignore_ascii_case("table") { - let indexes = sqlx::query("SELECT sql FROM sqlite_master WHERE type='index' AND tbl_name=? AND sql IS NOT NULL ORDER BY name").bind(name).fetch_all(pool).await.map_err(db_error)?; - for index in indexes { - ddl.push_str(";\n"); - ddl.push_str(&row_string(&index, 0)?); - } - } - Ok(ddl) - } - "mysql" => { - let sql = format!( - "SHOW CREATE {} {}", - if object_type.eq_ignore_ascii_case("view") { - "VIEW" - } else { - "TABLE" - }, - quote_identifier(kind, name)? - ); - let row = sqlx::query(&sql).fetch_one(pool).await.map_err(db_error)?; - row_string(&row, 1) - } - "postgresql" if object_type.eq_ignore_ascii_case("view") => { - let row = sqlx::query("SELECT 'CREATE OR REPLACE VIEW ' || quote_ident($1) || '.' || quote_ident($2) || ' AS ' || pg_get_viewdef((quote_ident($1)||'.'||quote_ident($2))::regclass, true)") - .bind(schema).bind(name).fetch_one(pool).await.map_err(db_error)?; - row_string(&row, 0) - } - "postgresql" => postgres_table_ddl(pool, schema, name).await, - _ => Err(("unsupported_database".into(), kind.into())), - } -} - -async fn postgres_table_ddl( - pool: &AnyPool, - schema: &str, - table: &str, -) -> Result { - let column_sql = r#"SELECT quote_ident(a.attname) || ' ' || pg_catalog.format_type(a.atttypid,a.atttypmod) || - CASE WHEN a.attidentity='a' THEN ' GENERATED ALWAYS AS IDENTITY' WHEN a.attidentity='d' THEN ' GENERATED BY DEFAULT AS IDENTITY' - WHEN d.adbin IS NOT NULL THEN ' DEFAULT ' || pg_get_expr(d.adbin,d.adrelid) ELSE '' END || - CASE WHEN a.attnotnull THEN ' NOT NULL' ELSE '' END - FROM pg_attribute a JOIN pg_class c ON c.oid=a.attrelid JOIN pg_namespace n ON n.oid=c.relnamespace - LEFT JOIN pg_attrdef d ON d.adrelid=a.attrelid AND d.adnum=a.attnum - WHERE n.nspname=$1 AND c.relname=$2 AND a.attnum>0 AND NOT a.attisdropped ORDER BY a.attnum"#; - let rows = sqlx::query(column_sql) - .bind(schema) - .bind(table) - .fetch_all(pool) - .await - .map_err(db_error)?; - let mut definitions: Vec = rows - .iter() - .map(|row| row_string(row, 0)) - .collect::>()?; - let constraints = sqlx::query("SELECT 'CONSTRAINT '||quote_ident(con.conname)||' '||pg_get_constraintdef(con.oid,true) FROM pg_constraint con JOIN pg_class c ON c.oid=con.conrelid JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname=$1 AND c.relname=$2 AND con.contype <> 'f' ORDER BY con.conname") - .bind(schema).bind(table).fetch_all(pool).await.map_err(db_error)?; - definitions.extend( - constraints - .iter() - .map(|row| row_string(row, 0)) - .collect::, _>>()?, - ); - let qualified = format!( - "{}.{}", - quote_identifier("postgresql", schema)?, - quote_identifier("postgresql", table)? - ); - let sequence_ddl = postgres_table_sequence_ddl(pool, schema, table).await?; - let mut ddl = String::new(); - if !sequence_ddl.is_empty() { - ddl.push_str(&sequence_ddl); - ddl.push_str(";\n"); - } - ddl.push_str(&format!( - "CREATE TABLE {qualified} (\n {}\n)", - definitions.join(",\n ") - )); - let indexes = sqlx::query("SELECT pg_get_indexdef(i.indexrelid) FROM pg_index i JOIN pg_class t ON t.oid=i.indrelid JOIN pg_namespace n ON n.oid=t.relnamespace WHERE n.nspname=$1 AND t.relname=$2 AND NOT i.indisprimary AND NOT EXISTS (SELECT 1 FROM pg_constraint c WHERE c.conindid=i.indexrelid) ORDER BY i.indexrelid") - .bind(schema).bind(table).fetch_all(pool).await.map_err(db_error)?; - for index in indexes { - ddl.push_str(";\n"); - ddl.push_str(&row_string(&index, 0)?); - } - Ok(ddl) -} - -async fn postgres_table_sequence_ddl( - pool: &AnyPool, - schema: &str, - table: &str, -) -> Result { - // Identity columns create their own sequence as part of CREATE TABLE. For - // legacy serial columns PostgreSQL stores a nextval() default that refers - // to a separate, owned sequence, so restore must recreate it first. - let rows = sqlx::query("SELECT quote_ident(sn.nspname)||'.'||quote_ident(seq.relname), quote_ident(a.attname) FROM pg_attribute a JOIN pg_class t ON t.oid=a.attrelid JOIN pg_namespace tn ON tn.oid=t.relnamespace JOIN pg_attrdef d ON d.adrelid=a.attrelid AND d.adnum=a.attnum JOIN pg_depend dep ON dep.refobjid=a.attrelid AND dep.refobjsubid=a.attnum AND dep.deptype='a' JOIN pg_class seq ON seq.oid=dep.objid AND seq.relkind='S' JOIN pg_namespace sn ON sn.oid=seq.relnamespace WHERE tn.nspname=$1 AND t.relname=$2 AND a.attnum>0 AND NOT a.attisdropped AND a.attidentity='' ORDER BY a.attnum") - .bind(schema) - .bind(table) - .fetch_all(pool) - .await - .map_err(db_error)?; - let qualified_table = format!( - "{}.{}", - quote_identifier("postgresql", schema)?, - quote_identifier("postgresql", table)? - ); - let mut statements = Vec::new(); - for row in rows { - let sequence = row_string(&row, 0)?; - let column = row_string(&row, 1)?; - statements.push(format!( - "CREATE SEQUENCE {sequence};\nALTER SEQUENCE {sequence} OWNED BY {qualified_table}.{column}" - )); - } - Ok(statements.join(";\n")) -} - -async fn postgres_foreign_key_ddls( - pool: &AnyPool, - schema: &str, - selected_tables: &[&str], -) -> Result, (String, String)> { - if selected_tables.is_empty() { - return Ok(Vec::new()); - } - let selected: std::collections::HashSet<&str> = selected_tables.iter().copied().collect(); - let table_rows = sqlx::query("SELECT CAST(c.relname AS TEXT), 'ALTER TABLE '||quote_ident(n.nspname)||'.'||quote_ident(c.relname)||' ADD CONSTRAINT '||quote_ident(con.conname)||' '||pg_get_constraintdef(con.oid,true) FROM pg_constraint con JOIN pg_class c ON c.oid=con.conrelid JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname=$1 AND con.contype='f' ORDER BY c.relname, con.conname") - .bind(schema).fetch_all(pool).await.map_err(db_error)?; - let mut statements = Vec::new(); - for row in &table_rows { - let table = row_string(row, 0)?; - if selected.contains(table.as_str()) { - statements.push(row_string(row, 1)?); - } - } - Ok(statements) -} - -async fn postgres_sequence_sql( - pool: &AnyPool, - schema: &str, - table: &str, -) -> Result { - let rows = sqlx::query(r#"SELECT CAST(a.attname AS TEXT), CAST(pg_get_serial_sequence(format('%I.%I',$1,$2),a.attname) AS TEXT) - FROM pg_attribute a JOIN pg_class c ON c.oid=a.attrelid JOIN pg_namespace n ON n.oid=c.relnamespace - LEFT JOIN pg_attrdef d ON d.adrelid=a.attrelid AND d.adnum=a.attnum - WHERE n.nspname=$1 AND c.relname=$2 AND a.attnum>0 AND NOT a.attisdropped - AND (a.attidentity<>'' OR pg_get_expr(d.adbin,d.adrelid) LIKE 'nextval(%')"#) - .bind(schema).bind(table).fetch_all(pool).await.map_err(db_error)?; - let qualified = format!( - "{}.{}", - quote_identifier("postgresql", schema)?, - quote_identifier("postgresql", table)? - ); - let mut output = String::new(); - for row in &rows { - let column = row_string(row, 0)?; - let sequence = row_string(row, 1)?; - output.push_str(&format!( - "SELECT setval({}::regclass, COALESCE(MAX({}), 1), COUNT(*) > 0) FROM {};\n", - sql_literal("postgresql", &Value::String(sequence))?, - quote_identifier("postgresql", &column)?, - qualified - )); - } - Ok(output) -} - -async fn write_table_insert_sql( - output: &mut W, - pool: &DirectPool, - kind: &str, - schema: &str, - table: &str, - limit: u32, -) -> Result<(), (String, String)> { - let qualified = if !schema.is_empty() && kind == "postgresql" { - format!( - "{}.{}", - quote_identifier(kind, schema)?, - quote_identifier(kind, table)? - ) - } else { - quote_identifier(kind, table)? - }; - let sql = if limit == 0 { - format!("SELECT * FROM {qualified}") - } else { - format!( - "SELECT * FROM {qualified} LIMIT {}", - limit.clamp(1, 100_000) - ) - }; - let mut raw_columns = None; - match pool { - DirectPool::MySql(pool) => { - let mut rows = sqlx::query(&sql).fetch(pool); - while let Some(row) = rows.try_next().await.map_err(db_error)? { - write_insert_row( - output, - kind, - &qualified, - &mut raw_columns, - mysql_row_json(&row)?, - )?; - } - } - DirectPool::Postgres(pool) => { - let mut rows = sqlx::query(&sql).fetch(pool); - while let Some(row) = rows.try_next().await.map_err(db_error)? { - write_insert_row( - output, - kind, - &qualified, - &mut raw_columns, - postgres_row_json(&row)?, - )?; - } - } - DirectPool::Sqlite(pool) => { - let mut rows = sqlx::query(&sql).fetch(pool); - while let Some(row) = rows.try_next().await.map_err(db_error)? { - write_insert_row( - output, - kind, - &qualified, - &mut raw_columns, - sqlite_row_json(&row)?, - )?; - } - } - } - Ok(()) -} - -fn write_insert_row( - output: &mut W, - kind: &str, - qualified: &str, - raw_columns: &mut Option>, - row: Value, -) -> Result<(), (String, String)> { - let object = row.as_object().ok_or_else(|| { - ( - "export_failed".into(), - "A database row could not be represented as an object".into(), - ) - })?; - let columns = raw_columns.get_or_insert_with(|| object.keys().cloned().collect()); - let quoted_columns = columns - .iter() - .map(|column| quote_identifier(kind, column)) - .collect::, _>>()?; - let values = columns - .iter() - .map(|column| sql_literal(kind, object.get(column).unwrap_or(&Value::Null))) - .collect::, _>>()?; - write_export( - output, - &format!( - "INSERT INTO {qualified} ({}) VALUES ({});\n", - quoted_columns.join(", "), - values.join(", ") - ), - ) -} - -fn sql_literal(kind: &str, value: &Value) -> Result { - match value { - Value::Null => Ok("NULL".into()), - Value::Bool(value) => Ok(if *value { "TRUE" } else { "FALSE" }.into()), - Value::Number(value) => Ok(value.to_string()), - Value::String(value) => Ok(format!("'{}'", value.replace('\'', "''"))), - Value::Object(object) if binary_input(object).is_some() => { - let bytes = BASE64 - .decode(binary_input(object).unwrap()) - .map_err(|e| ("export_failed".into(), e.to_string()))?; - let hex = bytes - .iter() - .map(|byte| format!("{byte:02x}")) - .collect::(); - Ok(match kind { - "mysql" => format!("0x{hex}"), - "postgresql" => format!("decode('{hex}','hex')"), - _ => format!("X'{hex}'"), - }) - } - other => Ok(format!("'{}'", other.to_string().replace('\'', "''"))), - } -} - -fn row_string(row: &sqlx::any::AnyRow, index: usize) -> Result { - row.try_get::(index).map_err(db_error) -} - -fn bind_values<'q>( - mut query: sqlx::query::Query<'q, Any, sqlx::any::AnyArguments<'q>>, - values: &'q [Value], -) -> Result>, (String, String)> { - for value in values { - query = match value { - Value::Null => query.bind(Option::::None), - Value::Bool(v) => query.bind(*v), - Value::Number(v) if v.is_i64() => query.bind(v.as_i64().unwrap()), - Value::Number(v) if v.is_u64() => query.bind(v.as_u64().unwrap() as i64), - Value::Number(v) => query.bind(v.as_f64().unwrap()), - Value::String(v) => query.bind(v), - other => query.bind(other.to_string()), - }; - } - Ok(query) -} - -fn rows_value(rows: Vec) -> DbResult { - Ok(Value::Array( - rows.iter().map(row_json).collect::>()?, - )) -} - -fn metadata_rows_value(rows: Vec) -> DbResult { - let mut values = Vec::with_capacity(rows.len()); - for row in &rows { - let mut value = row_json(row)?; - if let Value::Object(object) = &mut value { - for key in ["data_type", "is_nullable", "column_key", "column_default"] { - let Some(Value::Object(binary)) = object.get(key) else { - continue; - }; - let Some(encoded) = binary_input(binary) else { - continue; - }; - let Ok(bytes) = BASE64.decode(encoded) else { - continue; - }; - let Ok(text) = String::from_utf8(bytes) else { - continue; - }; - object.insert(key.to_string(), Value::String(text)); - } - } - values.push(value); - } - Ok(Value::Array(values)) -} - -fn row_json(row: &sqlx::any::AnyRow) -> Result { - let mut object = Map::new(); - for (index, column) in row.columns().iter().enumerate() { - let raw = row.try_get_raw(index).map_err(db_error)?; - let value = if raw.is_null() { - Value::Null - } else { - // SQLite PRAGMA/expression columns may declare NULL despite a typed value. - typed_value(row, index, raw.type_info().name()) - }; - object.insert(column.name().to_string(), value); - } - Ok(Value::Object(object)) -} - -fn typed_value(row: &sqlx::any::AnyRow, index: usize, kind: &str) -> Value { - match kind.to_ascii_uppercase().as_str() { - "BOOL" | "BOOLEAN" => row - .try_get::(index) - .map(Value::Bool) - .unwrap_or_else(|_| text_value(row, index)), - "INT" | "INT2" | "INT4" | "INT8" | "INT16" | "INT32" | "INT64" | "INTEGER" | "BIGINT" - | "SMALLINT" | "TINYINT" => row - .try_get::(index) - .map(|v| json!(v)) - .unwrap_or_else(|_| text_value(row, index)), - "FLOAT" | "FLOAT4" | "FLOAT8" | "REAL" | "DOUBLE" | "DOUBLE PRECISION" => row - .try_get::(index) - .map(|v| json!(v)) - .unwrap_or_else(|_| text_value(row, index)), - "BLOB" | "BYTEA" | "BINARY" | "VARBINARY" => row - .try_get::, _>(index) - .map(tagged_binary) - .unwrap_or_else(|_| text_value(row, index)), - _ => text_value(row, index), - } -} - -fn text_value(row: &sqlx::any::AnyRow, index: usize) -> Value { - row.try_get::(index) - .map(Value::String) - .unwrap_or_else(|_| Value::String("".into())) -} - -fn validate_identifier(value: &str) -> Result<(), (String, String)> { - if !value.is_empty() && !value.contains('\0') { - Ok(()) - } else { - Err(( - "invalid_identifier".into(), - format!("Invalid SQL identifier: {value}"), - )) - } -} -fn quote_identifier(kind: &str, value: &str) -> Result { - validate_identifier(value)?; - Ok(if kind == "mysql" { - format!("`{}`", value.replace('`', "``")) - } else { - format!("\"{}\"", value.replace('"', "\"\"")) - }) -} -fn require_sql(sql: &str) -> Result<(), (String, String)> { - if sql.trim().is_empty() { - Err(("invalid_query".into(), "SQL is required".into())) - } else { - Ok(()) - } -} -fn csv_value(value: &Value) -> String { - match value { - Value::Null => String::new(), - Value::String(v) => v.clone(), - other => other.to_string(), - } -} -fn db_error(error: sqlx::Error) -> (String, String) { - ("database_error".into(), error.to_string()) -} -fn csv_error(error: csv::Error) -> (String, String) { - ("export_failed".into(), error.to_string()) -} -fn redact(mut message: String, password: &str) -> String { - if !password.is_empty() { - message = message.replace(password, "***"); - } - message -} - -impl Response { - fn success(id: impl Into, result: Value) -> Self { - Self { - id: id.into(), - ok: true, - result: Some(result), - error: None, - } - } - fn failure(id: impl Into, code: impl Into, message: impl Into) -> Self { - Self { - id: id.into(), - ok: false, - result: None, - error: Some(ErrorBody { - code: code.into(), - message: message.into(), - }), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn sqlite_metadata_preserves_primary_key_ordinals() { - sqlx::any::install_default_drivers(); - // In-memory metadata protects both the grid icon and keyed mutations. - tokio::time::timeout(Duration::from_secs(5), async { - let pool = AnyPoolOptions::new() - .max_connections(1) - .connect("sqlite::memory:") - .await - .unwrap(); - sqlx::query("CREATE TABLE single_key (id INTEGER PRIMARY KEY, label TEXT)") - .execute(&pool) - .await - .unwrap(); - sqlx::query( - "CREATE TABLE composite_key (a INTEGER, b INTEGER, label TEXT, PRIMARY KEY (a, b))", - ) - .execute(&pool) - .await - .unwrap(); - let single = describe_table(&pool, "sqlite", "", "single_key") - .await - .unwrap(); - let composite = describe_table(&pool, "sqlite", "", "composite_key") - .await - .unwrap(); - pool.close().await; - assert_eq!(single[0]["pk"], json!(1)); - assert_eq!(single[1]["pk"], json!(0)); - assert_eq!(single[0]["cid"], json!(0)); - assert_eq!(single[1]["notnull"], json!(0)); - assert_eq!(single[1]["dflt_value"], Value::Null); - assert_eq!(composite[0]["pk"], json!(1)); - assert_eq!(composite[1]["pk"], json!(2)); - assert_eq!(composite[2]["pk"], json!(0)); - }) - .await - .expect("SQLite metadata test exceeded its local deadline"); - } - - #[test] - fn rejects_update_without_key() { - let mutation = Mutation { - action: "update".into(), - table: "items".into(), - values: Map::from_iter([("name".into(), json!("changed"))]), - key: Map::new(), - }; - assert_eq!( - mutation_sql("mysql", "", &mutation).unwrap_err().0, - "unsafe_mutation" - ); - } - - #[test] - fn postgres_mutations_use_numbered_placeholders() { - let mutation = Mutation { - action: "update".into(), - table: "items".into(), - values: Map::from_iter([("name".into(), json!("changed"))]), - key: Map::from_iter([("id".into(), json!(7))]), - }; - let (sql, values) = mutation_sql("postgresql", "public", &mutation).unwrap(); - assert_eq!( - sql, - "UPDATE \"public\".\"items\" SET \"name\" = $1 WHERE \"id\" = $2" - ); - assert_eq!(values, vec![json!("changed"), json!(7)]); - } - - #[test] - fn empty_inserts_use_database_defaults() { - let mutation = Mutation { - action: "insert".into(), - table: "items".into(), - values: Map::new(), - key: Map::new(), - }; - assert_eq!( - mutation_sql("mysql", "", &mutation).unwrap().0, - "INSERT INTO `items` () VALUES ()" - ); - assert_eq!( - mutation_sql("postgresql", "public", &mutation).unwrap().0, - "INSERT INTO \"public\".\"items\" DEFAULT VALUES" - ); - assert_eq!( - mutation_sql("sqlite", "", &mutation).unwrap().0, - "INSERT INTO \"items\" DEFAULT VALUES" - ); - } - - #[test] - fn filters_quote_identifiers_and_bind_values() { - let filters = vec![Filter { - column: "name".into(), - operator: "contains".into(), - value: json!("lit"), - join: "and".into(), - }]; - let (sql, values) = filter_clause("sqlite", &filters).unwrap(); - assert_eq!(sql, " WHERE (\"name\" LIKE ?)"); - assert_eq!(values, vec![json!("%lit%")]); - let unusual = vec![Filter { - column: "显示 \"名称\"".into(), - operator: "equals".into(), - value: json!(1), - join: "and".into(), - }]; - assert_eq!( - filter_clause("sqlite", &unusual).unwrap().0, - " WHERE (\"显示 \"\"名称\"\"\" = ?)" - ); - assert_eq!(quote_identifier("mysql", "a`b").unwrap(), "`a``b`"); - assert_eq!(quote_identifier("sqlite", "a\"b").unwrap(), "\"a\"\"b\""); - assert_eq!( - quote_identifier("sqlite", "bad\0name").unwrap_err().0, - "invalid_identifier" - ); - } - - #[test] - fn filters_support_or_join() { - let filters = vec![ - Filter { - column: "name".into(), - operator: "equals".into(), - value: json!("missing"), - join: "and".into(), - }, - Filter { - column: "score".into(), - operator: "equals".into(), - value: json!(1), - join: "or".into(), - }, - ]; - let (sql, values) = filter_clause("sqlite", &filters).unwrap(); - assert_eq!(sql, " WHERE (\"name\" = ? OR \"score\" = ?)"); - assert_eq!(values, vec![json!("missing"), json!(1)]); - } - - #[test] - fn sql_literals_preserve_quotes_and_binary_values() { - assert_eq!( - sql_literal("mysql", &json!("O'Reilly")).unwrap(), - "'O''Reilly'" - ); - assert_eq!( - sql_literal("sqlite", &json!({"base64":"AP8="})).unwrap(), - "X'00ff'" - ); - assert_eq!( - sql_literal("sqlite", &json!({"binary":"AP8="})).unwrap(), - "X'00ff'" - ); - assert_eq!( - sql_literal("postgresql", &json!({"base64":"AP8="})).unwrap(), - "decode('00ff','hex')" - ); - } - - #[test] - fn binary_values_use_an_explicit_tag_without_accepting_mixed_objects() { - assert_eq!(tagged_binary([0, 255]), json!({"binary":"AP8="})); - let explicit = json!({"binary":"AP8="}); - assert_eq!(binary_input(explicit.as_object().unwrap()), Some("AP8=")); - let legacy = json!({"base64":"AP8="}); - assert_eq!(binary_input(legacy.as_object().unwrap()), Some("AP8=")); - let mixed = json!({"binary":"AP8=","label":"payload"}); - assert_eq!(binary_input(mixed.as_object().unwrap()), None); - } - - #[test] - fn mysql_text_fallback_preserves_utf8_names_and_binary_payloads() { - assert_eq!(mysql_bytes_value(b"analytics"), json!("analytics")); - assert_eq!(mysql_bytes_value(&[0, 255]), json!({"binary":"AP8="})); - } - - #[test] - fn restore_drops_existing_objects_before_replaying_snapshot() { - assert_eq!( - object_drop_sql("sqlite", "", "records", "table").unwrap(), - Some("DROP TABLE IF EXISTS \"records\"".into()) - ); - assert_eq!( - object_drop_sql("mysql", "", "orders", "BASE TABLE").unwrap(), - Some("DROP TABLE IF EXISTS `orders`".into()) - ); - assert_eq!( - object_drop_sql("postgresql", "public", "active_users", "view").unwrap(), - Some("DROP VIEW IF EXISTS \"public\".\"active_users\"".into()) - ); - assert_eq!( - object_drop_sql("postgresql", "public", "users", "base table").unwrap(), - Some("DROP TABLE IF EXISTS \"public\".\"users\" CASCADE".into()) - ); - } - - #[test] - fn write_protection_classifies_dangerous_sql() { - assert!(is_dangerous_sql("RESTORE")); - assert!(is_dangerous_sql("DROP TABLE users")); - assert!(is_dangerous_sql("UPDATE users SET active = 0")); - assert!(!is_dangerous_sql( - "UPDATE users SET active = 0 WHERE id = 1" - )); - assert!(!is_dangerous_sql( - "INSERT INTO users (name) VALUES ('drop table')" - )); - } - - #[test] - fn tls_url_contains_requested_postgres_options_without_password_leakage() { - let connection = Connection { - kind: "postgresql".into(), - host: "db.example".into(), - port: 5432, - username: "alice".into(), - password: "secret".into(), - database: "app".into(), - path: String::new(), - ssl: true, - ca_certificate_path: "/tmp/ca.pem".into(), - server_name: "db.example".into(), - ssh_host: String::new(), - ssh_port: 0, - ssh_username: String::new(), - ssh_key_path: String::new(), - ssh_local_port: 0, - proxy_url: String::new(), - read_only: false, - production_protection: false, - }; - let url = connection.url().unwrap(); - assert!(url.contains("sslmode=require")); - assert!(url.contains("sslrootcert=%2Ftmp%2Fca.pem")); - assert!(redact("connection failed: secret".to_string(), "secret").contains("***")); - } -} diff --git a/rust/lithe-db-sidecar/src/mongodb_adapter.rs b/rust/lithe-db-sidecar/src/mongodb_adapter.rs deleted file mode 100644 index 877c1462e4..0000000000 --- a/rust/lithe-db-sidecar/src/mongodb_adapter.rs +++ /dev/null @@ -1,534 +0,0 @@ -use futures_util::TryStreamExt; -use mongodb::{ - bson::{doc, Bson, Document}, - options::ClientOptions, - Client, IndexModel, -}; -use serde_json::{json, Map, Value}; -use std::{collections::BTreeMap, time::Duration}; - -use super::{ - ensure_mutations_allowed, ensure_write_allowed, open_ssh_tunnel, Connection, DbResult, Filter, - Mutation, Params, Sort, -}; - -pub(super) async fn method(method: &str, params: Params) -> DbResult { - let tunnel = open_ssh_tunnel(¶ms.connection).await?; - let connection = if tunnel.is_some() { - params.connection.with_tunnel()? - } else { - params.connection.clone() - }; - let client = connect(&connection).await?; - let database_name = if connection.database.trim().is_empty() { - "admin" - } else { - connection.database.trim() - }; - let database = client.database(database_name); - match method { - "testConnection" => { - database - .run_command(doc! { "ping": 1 }) - .await - .map_err(driver_error)?; - Ok(json!({"connected": true})) - } - "listTables" => { - let names = database - .list_collection_names() - .await - .map_err(driver_error)?; - Ok(Value::Array( - names - .into_iter() - .map(|name| json!({"table_name": name, "table_type": "collection"})) - .collect(), - )) - } - "describeTable" => describe_collection(&database, ¶ms.table).await, - "listIndexes" => list_indexes(&database, ¶ms.table).await, - "listForeignKeys" => Ok(Value::Array(Vec::new())), - "listObjects" => { - if !params.object_kind.is_empty() && params.object_kind != "tables" { - return Ok(Value::Array(Vec::new())); - } - let names = database - .list_collection_names() - .await - .map_err(driver_error)?; - Ok(Value::Array( - names - .into_iter() - .map(|name| json!({"object_name": name, "object_kind": "collection"})) - .collect(), - )) - } - "pageTable" => page_collection(&database, ¶ms).await, - "query" => find_json(&database, ¶ms).await, - "applyChanges" => { - ensure_mutations_allowed( - &connection, - ¶ms.mutations, - params.confirmed, - params.allow_write, - )?; - apply_changes(&database, ¶ms.mutations).await - } - "execute" => { - ensure_write_allowed( - &connection, - ¶ms.sql, - params.confirmed, - params.allow_write, - )?; - Err(( - "unsupported_operation".into(), - "Use the MongoDB collection editor for document writes.".into(), - )) - } - _ => Err(( - "unsupported_operation".into(), - format!("{method} is not available for MongoDB in this version."), - )), - } -} - -async fn connect(connection: &Connection) -> Result { - let host = connection.host.trim(); - let uri = mongo_uri(connection); - let timeout = Duration::from_secs(8); - let mut options = tokio::time::timeout(timeout, ClientOptions::parse(&uri)) - .await - .map_err(|_| { - ( - "connection_failed".into(), - "MongoDB connection timed out.".into(), - ) - })? - .map_err(|error| { - ( - "connection_failed".into(), - redact(error.to_string(), &connection.password), - ) - })?; - options.connect_timeout = Some(timeout); - options.server_selection_timeout = Some(timeout); - if !host.starts_with("mongodb+srv://") && !host.contains(',') { - options.direct_connection = Some(true); - } - Client::with_options(options).map_err(|error| { - ( - "connection_failed".into(), - redact(error.to_string(), &connection.password), - ) - }) -} - -fn mongo_uri(connection: &Connection) -> String { - let host = connection.host.trim(); - if host.starts_with("mongodb://") || host.starts_with("mongodb+srv://") { - if connection.ssl && !host.to_ascii_lowercase().contains("tls=") { - format!( - "{host}{}tls=true", - if host.contains('?') { "&" } else { "?" } - ) - } else { - host.to_string() - } - } else { - let credentials = if connection.username.is_empty() { - String::new() - } else { - format!( - "{}:{}@", - urlencoding::encode(&connection.username), - urlencoding::encode(&connection.password) - ) - }; - let port = if connection.port == 0 { - 27017 - } else { - connection.port - }; - let database = if connection.database.trim().is_empty() { - "admin" - } else { - connection.database.trim() - }; - let mut query = Vec::new(); - if !connection.username.is_empty() { - // MongoDB users are commonly created in admin while the selected database is an app database. - query.push("authSource=admin"); - } - if connection.ssl { - query.push("tls=true"); - } - let suffix = if query.is_empty() { - String::new() - } else { - format!("?{}", query.join("&")) - }; - format!("mongodb://{credentials}{host}:{port}/{database}{suffix}") - } -} - -async fn describe_collection(database: &mongodb::Database, collection_name: &str) -> DbResult { - let collection = database.collection::(collection_name); - let mut cursor = collection - .find(doc! {}) - .limit(50) - .await - .map_err(driver_error)?; - let mut fields = BTreeMap::::new(); - while let Some(document) = cursor.try_next().await.map_err(driver_error)? { - for (name, value) in document { - fields - .entry(name) - .or_insert_with(|| bson_type(&value).to_string()); - } - } - if fields.is_empty() { - fields.insert("_id".into(), "objectId".into()); - } - Ok(Value::Array( - fields - .into_iter() - .map(|(name, data_type)| { - json!({ - "column_name": name, - "data_type": data_type, - "is_nullable": "YES", - "column_default": Value::Null, - "column_key": if name == "_id" { "PRI" } else { "" } - }) - }) - .collect(), - )) -} - -async fn list_indexes(database: &mongodb::Database, collection_name: &str) -> DbResult { - let collection = database.collection::(collection_name); - let mut cursor = collection.list_indexes().await.map_err(driver_error)?; - let mut rows = Vec::new(); - while let Some(index) = cursor.try_next().await.map_err(driver_error)? { - rows.push(index_json(index)); - } - Ok(Value::Array(rows)) -} - -fn index_json(index: IndexModel) -> Value { - let name = index - .options - .as_ref() - .and_then(|options| options.name.clone()) - .unwrap_or_default(); - json!({"index_name": name, "definition": serde_json::to_string(&index.keys).unwrap_or_default()}) -} - -async fn page_collection(database: &mongodb::Database, params: &Params) -> DbResult { - let collection = database.collection::(¶ms.table); - let filter = mongo_filter(¶ms.filters)?; - let sort = mongo_sort(¶ms.sort); - let limit = params.limit.clamp(1, 1_000); - let total = collection - .count_documents(filter.clone()) - .await - .map_err(driver_error)?; - let mut action = collection - .find(filter) - .skip(params.offset as u64) - .limit(limit as i64); - if !sort.is_empty() { - action = action.sort(sort); - } - let mut cursor = action.await.map_err(driver_error)?; - let mut rows = Vec::new(); - while let Some(document) = cursor.try_next().await.map_err(driver_error)? { - rows.push(document_json(document)); - } - Ok(json!({"rows": rows, "truncated": false, "totalRows": total})) -} - -async fn find_json(database: &mongodb::Database, params: &Params) -> DbResult { - let source = params.sql.trim(); - let filter = if source.is_empty() { - Document::new() - } else { - let value: Value = serde_json::from_str(source).map_err(|error| { - ( - "invalid_query".into(), - format!("MongoDB filter must be JSON: {error}"), - ) - })?; - json_object_to_document(value)? - }; - let collection_name = params.table.trim(); - if collection_name.is_empty() { - return Err(( - "invalid_query".into(), - "Select a MongoDB collection first.".into(), - )); - } - let collection = database.collection::(collection_name); - let limit = params.limit.clamp(1, 10_000); - let mut cursor = collection - .find(filter) - .limit(limit as i64) - .await - .map_err(driver_error)?; - let mut rows = Vec::new(); - while let Some(document) = cursor.try_next().await.map_err(driver_error)? { - rows.push(document_json(document)); - } - Ok(json!({"rows": rows, "truncated": false})) -} - -async fn apply_changes(database: &mongodb::Database, mutations: &[Mutation]) -> DbResult { - let mut affected = 0_u64; - for mutation in mutations { - let collection = database.collection::(&mutation.table); - match mutation.action.as_str() { - "insert" => { - collection - .insert_one(map_to_document(&mutation.values)?) - .await - .map_err(driver_error)?; - affected += 1; - } - "update" => { - let result = collection - .update_one( - map_to_document(&mutation.key)?, - doc! { "$set": map_to_document(&mutation.values)? }, - ) - .await - .map_err(driver_error)?; - affected += result.modified_count; - } - "delete" => { - let result = collection - .delete_one(map_to_document(&mutation.key)?) - .await - .map_err(driver_error)?; - affected += result.deleted_count; - } - action => { - return Err(( - "invalid_mutation".into(), - format!("Unsupported MongoDB mutation: {action}"), - )) - } - } - } - Ok(json!({"rowsAffected": affected})) -} - -fn mongo_filter(filters: &[Filter]) -> Result { - let mut result: Option = None; - for filter in filters { - let value = json_to_bson(filter.value.clone())?; - let expression = match filter.operator.as_str() { - "equals" => value, - "notEquals" => Bson::Document(doc! { "$ne": value }), - "greaterThan" => Bson::Document(doc! { "$gt": value }), - "lessThan" => Bson::Document(doc! { "$lt": value }), - "contains" => Bson::Document( - doc! { "$regex": regex_escape(&bson_scalar_text(&value)?), "$options": "i" }, - ), - "startsWith" => Bson::Document( - doc! { "$regex": format!("^{}", regex_escape(&bson_scalar_text(&value)?)), "$options": "i" }, - ), - "isNull" => Bson::Null, - "isNotNull" => Bson::Document(doc! { "$ne": Bson::Null }), - operator => { - return Err(( - "invalid_filter".into(), - format!("Unsupported MongoDB filter operator: {operator}"), - )) - } - }; - let mut predicate = Document::new(); - predicate.insert(filter.column.clone(), expression); - result = Some(match result { - None => predicate, - Some(previous) => { - let operator = match filter.join.as_str() { - "and" => "$and", - "or" => "$or", - value => { - return Err(( - "invalid_filter".into(), - format!("Unsupported MongoDB filter join: {value}"), - )) - } - }; - let mut combined = Document::new(); - combined.insert( - operator, - Bson::Array(vec![Bson::Document(previous), Bson::Document(predicate)]), - ); - combined - } - }); - } - Ok(result.unwrap_or_default()) -} - -fn mongo_sort(sort: &[Sort]) -> Document { - sort.iter() - .map(|item| { - ( - item.column.clone(), - if item.descending { - Bson::Int32(-1) - } else { - Bson::Int32(1) - }, - ) - }) - .collect() -} - -fn map_to_document(map: &Map) -> Result { - map.iter() - .map(|(key, value)| Ok((key.clone(), json_to_bson(value.clone())?))) - .collect() -} - -fn json_object_to_document(value: Value) -> Result { - match json_to_bson(value)? { - Bson::Document(document) => Ok(document), - _ => Err(( - "invalid_value".into(), - "A MongoDB document must be a JSON object.".into(), - )), - } -} - -fn json_to_bson(value: Value) -> Result { - Bson::try_from(value).map_err(|error| ("invalid_value".into(), error.to_string())) -} - -fn document_json(document: Document) -> Value { - serde_json::to_value(document).unwrap_or_else(|_| Value::Object(Map::new())) -} - -fn bson_type(value: &Bson) -> &'static str { - match value { - Bson::Double(_) => "double", - Bson::String(_) => "string", - Bson::Array(_) => "array", - Bson::Document(_) => "document", - Bson::Boolean(_) => "bool", - Bson::Null => "null", - Bson::Int32(_) => "int", - Bson::Int64(_) => "long", - Bson::ObjectId(_) => "objectId", - Bson::DateTime(_) => "date", - Bson::Binary(_) => "binary", - Bson::Decimal128(_) => "decimal", - _ => "value", - } -} - -fn regex_escape(value: &str) -> String { - value - .chars() - .flat_map(|character| { - if ".*+?^${}()|[]\\".contains(character) { - vec!['\\', character] - } else { - vec![character] - } - }) - .collect() -} - -fn bson_scalar_text(value: &Bson) -> Result { - match value { - Bson::String(value) => Ok(value.clone()), - Bson::Int32(value) => Ok(value.to_string()), - Bson::Int64(value) => Ok(value.to_string()), - Bson::Double(value) => Ok(value.to_string()), - Bson::Boolean(value) => Ok(value.to_string()), - _ => Err(( - "invalid_filter".into(), - "MongoDB text filters require a scalar value.".into(), - )), - } -} - -fn driver_error(error: mongodb::error::Error) -> (String, String) { - ("mongodb_error".into(), error.to_string()) -} -fn redact(mut message: String, password: &str) -> String { - if !password.is_empty() { - message = message.replace(password, "***"); - } - message -} - -#[cfg(test)] -mod tests { - use super::*; - - fn connection( - host: &str, - username: &str, - password: &str, - database: &str, - ssl: bool, - ) -> Connection { - Connection { - kind: "mongodb".into(), - host: host.into(), - port: 27017, - username: username.into(), - password: password.into(), - database: database.into(), - path: String::new(), - ssl, - ca_certificate_path: String::new(), - server_name: String::new(), - ssh_host: String::new(), - ssh_port: 0, - ssh_username: String::new(), - ssh_key_path: String::new(), - ssh_local_port: 0, - proxy_url: String::new(), - read_only: false, - production_protection: false, - } - } - - #[test] - fn host_connections_authenticate_against_admin_by_default() { - let uri = mongo_uri(&connection( - "127.0.0.1", - "root", - "secret", - "lithe_test", - false, - )); - assert_eq!( - uri, - "mongodb://root:secret@127.0.0.1:27017/lithe_test?authSource=admin" - ); - } - - #[test] - fn explicit_mongodb_uri_keeps_its_options() { - let uri = mongo_uri(&connection( - "mongodb://root:secret@db.example/lithe_test?authSource=custom", - "", - "", - "", - true, - )); - assert_eq!( - uri, - "mongodb://root:secret@db.example/lithe_test?authSource=custom&tls=true" - ); - } -} diff --git a/rust/lithe-db-sidecar/src/sqlserver_adapter.rs b/rust/lithe-db-sidecar/src/sqlserver_adapter.rs deleted file mode 100644 index d6bbac71ca..0000000000 --- a/rust/lithe-db-sidecar/src/sqlserver_adapter.rs +++ /dev/null @@ -1,682 +0,0 @@ -use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; -use serde_json::{json, Map, Value}; -use std::{borrow::Cow, time::Duration}; -use tiberius::{AuthMethod, Client, ColumnData, Config, FromSql}; -use tokio::net::TcpStream; -use tokio_util::compat::{Compat, TokioAsyncWriteCompatExt}; - -use super::{ - ensure_mutations_allowed, ensure_transaction_allowed, ensure_write_allowed, open_ssh_tunnel, - parse_csv, parse_json, scalar_string, validate_identifier, Connection, DbResult, Filter, - Mutation, Params, Sort, -}; - -type SqlServerClient = Client>; - -pub(super) async fn method(method: &str, params: Params) -> DbResult { - let tunnel = open_ssh_tunnel(¶ms.connection).await?; - let connection = if tunnel.is_some() { - params.connection.with_tunnel()? - } else { - params.connection.clone() - }; - let mut client = connect(&connection).await?; - match method { - "testConnection" => { - query(&mut client, "SELECT 1 AS connected", 1).await?; - Ok(json!({"connected": true})) - } - "listTables" => query( - &mut client, - "SELECT TABLE_NAME AS table_name, TABLE_TYPE AS table_type FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = 'dbo' ORDER BY TABLE_NAME", - 10_000, - ).await, - "describeTable" => { - let table = sql_string(¶ms.table); - query(&mut client, &format!( - "SELECT c.COLUMN_NAME AS column_name, c.DATA_TYPE AS data_type, c.IS_NULLABLE AS is_nullable, c.COLUMN_DEFAULT AS column_default, CASE WHEN tc.CONSTRAINT_TYPE='PRIMARY KEY' THEN 'PRI' ELSE '' END AS column_key FROM INFORMATION_SCHEMA.COLUMNS c LEFT JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu ON kcu.TABLE_SCHEMA=c.TABLE_SCHEMA AND kcu.TABLE_NAME=c.TABLE_NAME AND kcu.COLUMN_NAME=c.COLUMN_NAME LEFT JOIN INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc ON tc.CONSTRAINT_NAME=kcu.CONSTRAINT_NAME AND tc.CONSTRAINT_TYPE='PRIMARY KEY' WHERE c.TABLE_SCHEMA='dbo' AND c.TABLE_NAME={table} ORDER BY c.ORDINAL_POSITION" - ), 10_000).await - } - "listIndexes" => { - let table = sql_string(¶ms.table); - query(&mut client, &format!( - "SELECT i.name AS index_name, i.type_desc AS definition FROM sys.indexes i JOIN sys.tables t ON t.object_id=i.object_id JOIN sys.schemas s ON s.schema_id=t.schema_id WHERE s.name='dbo' AND t.name={table} AND i.name IS NOT NULL ORDER BY i.name" - ), 10_000).await - } - "listForeignKeys" => { - let table = sql_string(¶ms.table); - query(&mut client, &format!( - "SELECT fk.name AS constraint_name, pc.name AS column_name, rt.name AS referenced_table_name, rc.name AS referenced_column_name FROM sys.foreign_key_columns fkc JOIN sys.foreign_keys fk ON fk.object_id=fkc.constraint_object_id JOIN sys.tables pt ON pt.object_id=fkc.parent_object_id JOIN sys.schemas ps ON ps.schema_id=pt.schema_id JOIN sys.columns pc ON pc.object_id=pt.object_id AND pc.column_id=fkc.parent_column_id JOIN sys.tables rt ON rt.object_id=fkc.referenced_object_id JOIN sys.columns rc ON rc.object_id=rt.object_id AND rc.column_id=fkc.referenced_column_id WHERE ps.name='dbo' AND pt.name={table} ORDER BY fk.name, fkc.constraint_column_id" - ), 10_000).await - } - "listObjects" => list_objects(&mut client, ¶ms.object_kind).await, - "pageTable" => page_table(&mut client, ¶ms).await, - "query" => { - if !params.values.is_empty() { - return Err(("unsupported_parameters".into(), "SQL Server parameter values are not available in this version.".into())); - } - query(&mut client, ¶ms.sql, params.limit.clamp(1, 10_000) as usize).await - } - "execute" => { - ensure_write_allowed(&connection, ¶ms.sql, params.confirmed, params.allow_write)?; - execute(&mut client, ¶ms.sql).await - } - "applyChanges" => { - ensure_mutations_allowed(&connection, ¶ms.mutations, params.confirmed, params.allow_write)?; - apply_changes(&mut client, ¶ms.mutations).await - } - "schemaChange" => { - ensure_write_allowed(&connection, "ALTER TABLE", params.confirmed, params.allow_write)?; - let sql = schema_change_sql(¶ms)?; - execute(&mut client, &sql).await - } - "transaction" => { - ensure_transaction_allowed(&connection, ¶ms.statements, params.confirmed, params.allow_write)?; - run_transaction(&mut client, ¶ms).await - } - "exportCsv" => export_csv(&mut client, ¶ms.sql, params.limit).await, - "exportJson" => export_json(&mut client, ¶ms.sql, params.limit).await, - "importCsv" | "importJson" => { - ensure_write_allowed(&connection, "INSERT", params.confirmed, params.allow_write)?; - import_data(&mut client, ¶ms, method == "importCsv").await - } - "diagnostics" if params.diagnostic_kind == "tableSize" => query( - &mut client, - "SELECT t.name AS table_name, SUM(p.rows) AS estimated_rows, SUM(a.total_pages) * 8192 AS total_bytes FROM sys.tables t JOIN sys.indexes i ON t.object_id=i.object_id JOIN sys.partitions p ON i.object_id=p.object_id AND i.index_id=p.index_id JOIN sys.allocation_units a ON p.partition_id=a.container_id GROUP BY t.name ORDER BY total_bytes DESC", - 1_000, - ).await, - _ => Err(("unsupported_operation".into(), format!("{method} is not available for SQL Server in this version."))), - } -} - -async fn connect(connection: &Connection) -> Result { - let mut config = Config::new(); - config.host(connection.host.trim()); - config.port(if connection.port == 0 { - 1433 - } else { - connection.port - }); - config.authentication(AuthMethod::sql_server( - &connection.username, - &connection.password, - )); - if !connection.database.trim().is_empty() { - config.database(connection.database.trim()); - } - if connection.ssl { - config.encryption(tiberius::EncryptionLevel::Required); - // SQL Server installations commonly use an internal/self-signed leaf. - // The connection remains encrypted; custom CA verification can be added - // without changing the persisted profile format. - config.trust_cert(); - } else { - config.encryption(tiberius::EncryptionLevel::Off); - } - let timeout = Duration::from_secs(8); - let tcp = tokio::time::timeout(timeout, TcpStream::connect(config.get_addr())) - .await - .map_err(|_| { - ( - "connection_failed".into(), - "SQL Server connection timed out.".into(), - ) - })? - .map_err(|error| { - ( - "connection_failed".into(), - redact(error.to_string(), &connection.password), - ) - })?; - tokio::time::timeout(timeout, Client::connect(config, tcp.compat_write())) - .await - .map_err(|_| { - ( - "connection_failed".into(), - "SQL Server handshake timed out.".into(), - ) - })? - .map_err(|error| { - ( - "connection_failed".into(), - redact(error.to_string(), &connection.password), - ) - }) -} - -async fn query(client: &mut SqlServerClient, sql: &str, limit: usize) -> DbResult { - let stream = client.simple_query(sql).await.map_err(driver_error)?; - let rows = stream.into_first_result().await.map_err(driver_error)?; - let truncated = rows.len() > limit; - let values = rows - .into_iter() - .take(limit) - .map(|row| { - let mut object = Map::new(); - for (column, cell) in row.cells() { - object.insert(column.name().to_string(), cell_json(cell)); - } - Value::Object(object) - }) - .collect::>(); - Ok(json!({"rows": values, "truncated": truncated})) -} - -async fn execute(client: &mut SqlServerClient, sql: &str) -> DbResult { - let result = client.execute(sql, &[]).await.map_err(driver_error)?; - Ok(json!({"rowsAffected": result.total()})) -} - -async fn list_objects(client: &mut SqlServerClient, requested: &str) -> DbResult { - let sql = match requested { - "views" => "SELECT v.name AS object_name, 'view' AS object_kind, OBJECT_DEFINITION(v.object_id) AS definition FROM sys.views v JOIN sys.schemas s ON s.schema_id=v.schema_id WHERE s.name='dbo' ORDER BY v.name", - "routines" => "SELECT o.name AS object_name, o.type_desc AS object_kind, OBJECT_DEFINITION(o.object_id) AS definition FROM sys.objects o JOIN sys.schemas s ON s.schema_id=o.schema_id WHERE s.name='dbo' AND o.type IN ('P','FN','IF','TF') ORDER BY o.name", - "triggers" => "SELECT tr.name AS object_name, 'trigger' AS object_kind, OBJECT_DEFINITION(tr.object_id) AS definition FROM sys.triggers tr WHERE tr.parent_class=1 ORDER BY tr.name", - "sequences" => "SELECT seq.name AS object_name, 'sequence' AS object_kind, CAST(NULL AS nvarchar(max)) AS definition FROM sys.sequences seq JOIN sys.schemas s ON s.schema_id=seq.schema_id WHERE s.name='dbo' ORDER BY seq.name", - _ => "SELECT TABLE_NAME AS object_name, TABLE_TYPE AS object_kind, CAST(NULL AS nvarchar(max)) AS definition FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA='dbo' ORDER BY TABLE_NAME", - }; - query(client, sql, 10_000).await -} - -async fn page_table(client: &mut SqlServerClient, params: &Params) -> DbResult { - validate_identifier(¶ms.table)?; - let table = quote_identifier(¶ms.table); - let where_sql = filter_clause(¶ms.filters)?; - let order_sql = sort_clause(¶ms.sort)?; - let limit = params.limit.clamp(1, 1_000); - let sql = format!("SELECT * FROM [dbo].{table}{where_sql}{order_sql} OFFSET {} ROWS FETCH NEXT {limit} ROWS ONLY", params.offset); - let mut result = query(client, &sql, limit as usize).await?; - let count_sql = format!("SELECT COUNT_BIG(*) AS total_rows FROM [dbo].{table}{where_sql}"); - let count = query(client, &count_sql, 1).await?; - let total = count - .get("rows") - .and_then(Value::as_array) - .and_then(|rows| rows.first()) - .and_then(|row| row.get("total_rows")) - .and_then(Value::as_i64) - .unwrap_or(0); - result - .as_object_mut() - .expect("query result") - .insert("totalRows".into(), json!(total)); - Ok(result) -} - -async fn apply_changes(client: &mut SqlServerClient, mutations: &[Mutation]) -> DbResult { - execute(client, "BEGIN TRANSACTION").await?; - let mut affected = 0_u64; - for mutation in mutations { - let sql = mutation_sql(mutation)?; - match client.execute(sql, &[]).await { - Ok(result) => affected += result.total(), - Err(error) => { - let _ = client.execute("ROLLBACK TRANSACTION", &[]).await; - return Err(driver_error(error)); - } - } - } - client - .execute("COMMIT TRANSACTION", &[]) - .await - .map_err(driver_error)?; - Ok(json!({"rowsAffected": affected})) -} - -async fn run_transaction(client: &mut SqlServerClient, params: &Params) -> DbResult { - client - .execute("BEGIN TRANSACTION", &[]) - .await - .map_err(driver_error)?; - let mut affected = 0_u64; - for statement in ¶ms.statements { - if !statement.values.is_empty() { - let _ = client.execute("ROLLBACK TRANSACTION", &[]).await; - return Err(( - "unsupported_parameters".into(), - "SQL Server transaction parameters are not available in this version.".into(), - )); - } - match client.execute(&statement.sql, &[]).await { - Ok(result) => affected += result.total(), - Err(error) => { - let _ = client.execute("ROLLBACK TRANSACTION", &[]).await; - return Err(driver_error(error)); - } - } - } - client - .execute("COMMIT TRANSACTION", &[]) - .await - .map_err(driver_error)?; - Ok(json!({"rowsAffected": affected})) -} - -async fn export_csv(client: &mut SqlServerClient, sql: &str, limit: u32) -> DbResult { - let result = query(client, sql, limit.clamp(1, 100_000) as usize).await?; - let rows = result["rows"].as_array().cloned().unwrap_or_default(); - let headers: Vec = rows - .first() - .and_then(Value::as_object) - .map(|row| row.keys().cloned().collect()) - .unwrap_or_default(); - let mut writer = csv::Writer::from_writer(Vec::new()); - writer.write_record(&headers).map_err(super::csv_error)?; - for row in rows { - let object = row - .as_object() - .ok_or_else(|| ("export_failed".into(), "Invalid SQL Server row.".into()))?; - writer - .write_record( - headers - .iter() - .map(|key| super::csv_value(object.get(key).unwrap_or(&Value::Null))), - ) - .map_err(super::csv_error)?; - } - let bytes = writer - .into_inner() - .map_err(|error| ("export_failed".into(), error.to_string()))?; - Ok(json!({"encoding": "base64", "data": BASE64.encode(bytes)})) -} - -async fn export_json(client: &mut SqlServerClient, sql: &str, limit: u32) -> DbResult { - let result = query(client, sql, limit.clamp(1, 100_000) as usize).await?; - let bytes = serde_json::to_vec_pretty(&result["rows"]) - .map_err(|error| ("export_failed".into(), error.to_string()))?; - Ok(json!({"encoding": "base64", "data": BASE64.encode(bytes)})) -} - -async fn import_data(client: &mut SqlServerClient, params: &Params, is_csv: bool) -> DbResult { - let bytes = BASE64.decode(¶ms.data).map_err(|error| { - ( - "invalid_import".into(), - format!("Invalid base64 data: {error}"), - ) - })?; - let rows = if is_csv { - parse_csv(&bytes)? - } else { - parse_json(&bytes)? - }; - let mutations = rows - .into_iter() - .map(|values| Mutation { - action: "insert".into(), - table: params.table.clone(), - values, - key: Map::new(), - }) - .collect::>(); - apply_changes(client, &mutations).await -} - -fn schema_change_sql(params: &Params) -> Result { - validate_identifier(¶ms.table)?; - let table = format!("[dbo].{}", quote_identifier(¶ms.table)); - match params.operation.as_str() { - "addColumn" => { - validate_identifier(¶ms.name)?; - let data_type = super::safe_sql_fragment(¶ms.data_type, "column type")?; - Ok(format!( - "ALTER TABLE {table} ADD {} {data_type}{}{}", - quote_identifier(¶ms.name), - if params.nullable { - " NULL" - } else { - " NOT NULL" - }, - if params.default_value.is_empty() { - String::new() - } else { - format!( - " DEFAULT {}", - super::safe_sql_fragment(¶ms.default_value, "default")? - ) - } - )) - } - "renameColumn" => { - validate_identifier(¶ms.old_name)?; - validate_identifier(¶ms.name)?; - Ok(format!( - "EXEC sp_rename N'dbo.{}.{}', {}, 'COLUMN'", - params.table.replace('\'', "''"), - params.old_name.replace('\'', "''"), - sql_string(¶ms.name) - )) - } - "dropColumn" => { - validate_identifier(¶ms.name)?; - Ok(format!( - "ALTER TABLE {table} DROP COLUMN {}", - quote_identifier(¶ms.name) - )) - } - "createIndex" => { - validate_identifier(¶ms.index_name)?; - let columns = params - .index_columns - .iter() - .map(|column| { - validate_identifier(column)?; - Ok(quote_identifier(column)) - }) - .collect::, (String, String)>>()?; - if columns.is_empty() { - return Err(( - "invalid_schema_change".into(), - "At least one index column is required.".into(), - )); - } - Ok(format!( - "CREATE INDEX {} ON {table} ({})", - quote_identifier(¶ms.index_name), - columns.join(", ") - )) - } - "dropIndex" => { - validate_identifier(¶ms.index_name)?; - Ok(format!( - "DROP INDEX {} ON {table}", - quote_identifier(¶ms.index_name) - )) - } - "addForeignKey" => { - validate_identifier(¶ms.constraint_name)?; - validate_identifier(¶ms.referenced_table)?; - let columns = quoted_names(¶ms.index_columns)?; - let referenced = quoted_names(¶ms.referenced_columns)?; - if columns.is_empty() || columns.len() != referenced.len() { - return Err(( - "invalid_schema_change".into(), - "Foreign-key columns must be paired.".into(), - )); - } - Ok(format!( - "ALTER TABLE {table} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES [dbo].{} ({})", - quote_identifier(¶ms.constraint_name), - columns.join(", "), - quote_identifier(¶ms.referenced_table), - referenced.join(", ") - )) - } - "dropForeignKey" => { - validate_identifier(¶ms.constraint_name)?; - Ok(format!( - "ALTER TABLE {table} DROP CONSTRAINT {}", - quote_identifier(¶ms.constraint_name) - )) - } - operation => Err(( - "invalid_schema_change".into(), - format!("Unsupported SQL Server schema operation: {operation}"), - )), - } -} - -fn quoted_names(values: &[String]) -> Result, (String, String)> { - values - .iter() - .map(|value| { - validate_identifier(value)?; - Ok(quote_identifier(value)) - }) - .collect() -} - -fn mutation_sql(mutation: &Mutation) -> Result { - validate_identifier(&mutation.table)?; - let table = quote_identifier(&mutation.table); - match mutation.action.as_str() { - "insert" => { - if mutation.values.is_empty() { - return Ok(format!("INSERT INTO [dbo].{table} DEFAULT VALUES")); - } - let columns = mutation - .values - .keys() - .map(|name| { - validate_identifier(name)?; - Ok(quote_identifier(name)) - }) - .collect::, (String, String)>>()?; - let values = mutation - .values - .values() - .map(sql_value) - .collect::, _>>()?; - Ok(format!( - "INSERT INTO [dbo].{table} ({}) VALUES ({})", - columns.join(", "), - values.join(", ") - )) - } - "update" => { - let values = assignments(&mutation.values)?; - let keys = predicates(&mutation.key)?; - Ok(format!("UPDATE [dbo].{table} SET {values} WHERE {keys}")) - } - "delete" => Ok(format!( - "DELETE FROM [dbo].{table} WHERE {}", - predicates(&mutation.key)? - )), - action => Err(( - "invalid_mutation".into(), - format!("Unsupported mutation: {action}"), - )), - } -} - -fn assignments(values: &Map) -> Result { - if values.is_empty() { - return Err(( - "invalid_mutation".into(), - "Update values are required.".into(), - )); - } - values - .iter() - .map(|(name, value)| { - validate_identifier(name)?; - Ok(format!( - "{} = {}", - quote_identifier(name), - sql_value(value)? - )) - }) - .collect::, _>>() - .map(|items| items.join(", ")) -} - -fn predicates(values: &Map) -> Result { - if values.is_empty() { - return Err(("invalid_mutation".into(), "A row key is required.".into())); - } - values - .iter() - .map(|(name, value)| { - validate_identifier(name)?; - Ok(if value.is_null() { - format!("{} IS NULL", quote_identifier(name)) - } else { - format!("{} = {}", quote_identifier(name), sql_value(value)?) - }) - }) - .collect::, _>>() - .map(|items| items.join(" AND ")) -} - -fn filter_clause(filters: &[Filter]) -> Result { - let mut predicates = Vec::new(); - for filter in filters { - validate_identifier(&filter.column)?; - let column = quote_identifier(&filter.column); - let predicate = match filter.operator.as_str() { - "equals" => format!("{column} = {}", sql_value(&filter.value)?), - "notEquals" => format!("{column} <> {}", sql_value(&filter.value)?), - "greaterThan" => format!("{column} > {}", sql_value(&filter.value)?), - "lessThan" => format!("{column} < {}", sql_value(&filter.value)?), - "contains" => format!( - "{column} LIKE {}", - sql_string(&format!("%{}%", scalar_string(&filter.value)?)) - ), - "startsWith" => format!( - "{column} LIKE {}", - sql_string(&format!("{}%", scalar_string(&filter.value)?)) - ), - "isNull" => format!("{column} IS NULL"), - "isNotNull" => format!("{column} IS NOT NULL"), - value => { - return Err(( - "invalid_filter".into(), - format!("Unsupported filter operator: {value}"), - )) - } - }; - let join = match filter.join.as_str() { - "and" => "AND", - "or" => "OR", - value => { - return Err(( - "invalid_filter".into(), - format!("Unsupported filter join: {value}"), - )) - } - }; - predicates.push((join, predicate)); - } - Ok(if predicates.is_empty() { - String::new() - } else { - let mut clause = predicates[0].1.clone(); - for (join, predicate) in predicates.iter().skip(1) { - clause.push_str(&format!(" {join} {predicate}")); - } - format!(" WHERE ({clause})") - }) -} - -fn sort_clause(sort: &[Sort]) -> Result { - if sort.is_empty() { - return Ok(" ORDER BY (SELECT NULL)".into()); - } - let items = sort - .iter() - .map(|item| { - validate_identifier(&item.column)?; - Ok(format!( - "{} {}", - quote_identifier(&item.column), - if item.descending { "DESC" } else { "ASC" } - )) - }) - .collect::, (String, String)>>()?; - Ok(format!(" ORDER BY {}", items.join(", "))) -} - -fn quote_identifier(value: &str) -> String { - format!("[{}]", value.replace(']', "]]")) -} -fn sql_string(value: &str) -> String { - format!("N'{}'", value.replace('\'', "''")) -} -fn sql_value(value: &Value) -> Result { - Ok(match value { - Value::Null => "NULL".into(), - Value::Bool(value) => { - if *value { - "1".into() - } else { - "0".into() - } - } - Value::Number(value) => value.to_string(), - Value::String(value) => sql_string(value), - Value::Object(object) if object.len() == 1 && object.contains_key("datetime") => { - sql_string(object["datetime"].as_str().unwrap_or("")) - } - Value::Object(object) if object.len() == 1 && object.contains_key("uuid") => { - sql_string(object["uuid"].as_str().unwrap_or("")) - } - _ => { - return Err(( - "invalid_value".into(), - "SQL Server table edits require scalar values.".into(), - )) - } - }) -} - -fn cell_json(cell: &ColumnData<'static>) -> Value { - if let Ok(Some(value)) = <&str as FromSql>::from_sql(cell) { - return Value::String(value.to_string()); - } - if let Ok(Some(value)) = ::from_sql(cell) { - return json!(value); - } - if let Ok(Some(value)) = ::from_sql(cell) { - return json!(value); - } - if let Ok(Some(value)) = ::from_sql(cell) { - return json!(value); - } - if let Ok(Some(value)) = ::from_sql(cell) { - return json!(value); - } - if let Ok(Some(value)) = ::from_sql(cell) { - return json!(value); - } - if let Ok(Some(value)) = ::from_sql(cell) { - return json!(value); - } - if let Ok(Some(value)) = ::from_sql(cell) { - return json!(value); - } - if let Ok(Some(value)) = ::from_sql(cell) { - return Value::String(value.to_string()); - } - if let Ok(Some(value)) = ::from_sql(cell) { - return Value::String(value.to_string()); - } - if let Ok(Some(value)) = ::from_sql(cell) { - return Value::String(value.to_string()); - } - if let Ok(Some(value)) = ::from_sql(cell) { - return Value::String(value.to_string()); - } - if let ColumnData::Numeric(Some(value)) = cell { - return Value::String(format_numeric(value.value(), value.scale())); - } - if let ColumnData::Binary(Some(value)) = cell { - return json!({"binary": BASE64.encode(value.as_ref())}); - } - if let ColumnData::String(Some(Cow::Borrowed(value))) = cell { - return Value::String((*value).to_string()); - } - Value::Null -} - -fn format_numeric(value: i128, scale: u8) -> String { - if scale == 0 { - return value.to_string(); - } - let digits = value.unsigned_abs().to_string(); - let scale = scale as usize; - let sign = if value < 0 { "-" } else { "" }; - if digits.len() > scale { - let (integer, fraction) = digits.split_at(digits.len() - scale); - format!("{sign}{integer}.{fraction}") - } else { - format!("{sign}0.{digits:0>scale$}") - } -} - -fn driver_error(error: tiberius::error::Error) -> (String, String) { - ("sqlserver_error".into(), error.to_string()) -} -fn redact(mut message: String, password: &str) -> String { - if !password.is_empty() { - message = message.replace(password, "***"); - } - message -} diff --git a/rust/lithe-git-host/src/lib.rs b/rust/lithe-git-host/src/lib.rs index 3db68a45bc..effb7592df 100644 --- a/rust/lithe-git-host/src/lib.rs +++ b/rust/lithe-git-host/src/lib.rs @@ -3,6 +3,8 @@ //! This adapter does not construct Git arguments or interpret Git output. //! The compatibility Core caller supplies policy and cancellation; both hosts //! share this native adapter while the existing Git command ABI is retained. +//! [`ProcessTree`] exposes the same process-tree ownership to long-lived +//! processes whose output a host streams itself. pub mod authentication; pub mod configuration; @@ -189,21 +191,15 @@ struct OwnedChild { } impl OwnedChild { fn new(mut child: Child) -> io::Result { - match platform::Group::new(&child) { + // Git helpers must never outlive the command, so none may break away. + match platform::Group::new(&child, false) { Ok(group) => Ok(Self { child, group, cleaned: false, }), Err(error) => { - let _ = child.kill(); - let deadline = Instant::now() + CLEANUP_TIMEOUT; - while Instant::now() < deadline { - if child.try_wait()?.is_some() { - break; - } - std::thread::sleep(POLL_INTERVAL); - } + discard_unowned_child(&mut child)?; Err(error) } } @@ -245,6 +241,89 @@ impl Drop for OwnedChild { } } +/// A prepared child the host could not take ownership of is still suspended +/// on Windows; it is killed and reaped instead of being left behind. +fn discard_unowned_child(child: &mut Child) -> io::Result<()> { + let _ = child.kill(); + let deadline = Instant::now() + CLEANUP_TIMEOUT; + while Instant::now() < deadline { + if child.try_wait()?.is_some() { + break; + } + std::thread::sleep(POLL_INTERVAL); + } + Ok(()) +} + +/// A child process together with every descendant it starts. +/// +/// Long-lived processes whose output a host streams itself, such as Run +/// sessions, debug adapters and language servers, use this instead of [`run`]. +/// +/// On Windows the suspended child joins a Job Object with +/// `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` before its first instruction runs, so +/// no descendant can start outside the tree, and ending the tree does not +/// depend on walking parent process IDs that break when an intermediate +/// process exits. The kernel also ends the tree when this value is dropped, +/// including when the host process itself crashes or is killed. A descendant +/// that explicitly asks to leave (`CREATE_BREAKAWAY_FROM_JOB`) is allowed to, +/// matching a Unix process that starts its own session. +/// +/// On Unix the child leads a new process group. Only [`ProcessTree::kill`] +/// and [`ProcessTree::terminate`] end it; crash cleanup belongs to the host. +pub struct ProcessTree { + group: platform::Group, +} + +impl ProcessTree { + /// Configures `command` so [`ProcessTree::adopt`] can claim the child + /// before it runs. Call it after any other creation flags are set: on + /// Windows it replaces them with `CREATE_NO_WINDOW | CREATE_SUSPENDED`. + pub fn prepare(command: &mut Command) { + platform::prepare(command); + } + + /// Takes ownership of the tree of a child spawned from a prepared command + /// and lets the child start. On failure the child is killed and reaped, so + /// a suspended process is never left behind. + pub fn adopt(child: &mut Child) -> io::Result { + match platform::Group::new(child, true) { + Ok(group) => Ok(Self { group }), + Err(error) => { + discard_unowned_child(child)?; + Err(error) + } + } + } + + /// Asks every process of the tree to end now, without waiting. + pub fn kill(&self) -> io::Result<()> { + self.group.kill() + } + + /// Ends every process of the tree and waits until none is left. + /// + /// Returns `ErrorKind::TimedOut` when processes are still alive after + /// `timeout`. On Unix a root that its owner has not reaped yet still + /// counts, so the owner must keep waiting on its `Child` concurrently. + pub fn terminate(&self, timeout: Duration) -> io::Result<()> { + self.group.kill()?; + let deadline = Instant::now() + timeout; + loop { + if !self.group.has_live_processes()? { + return Ok(()); + } + if Instant::now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "Processes of the tree are still running after they were told to end", + )); + } + std::thread::sleep(POLL_INTERVAL); + } + } +} + struct InputFile { file: File, path: PathBuf, @@ -314,7 +393,9 @@ mod platform { } pub struct Group(i32); impl Group { - pub fn new(child: &Child) -> io::Result { + /// A process that starts its own session leaves the group on its own, + /// so Unix has nothing to configure for breakaway. + pub fn new(child: &Child, _allow_breakaway: bool) -> io::Result { Ok(Self(child.id() as i32)) } pub fn terminate(&self, force: bool) { @@ -323,6 +404,30 @@ mod platform { libc::kill(-self.0, if force { libc::SIGKILL } else { libc::SIGTERM }); } } + pub fn kill(&self) -> io::Result<()> { + // SAFETY: a negative PID addresses only the process group we created. + if unsafe { libc::kill(-self.0, libc::SIGKILL) } == 0 { + return Ok(()); + } + let error = io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::ESRCH) { + return Ok(()); + } + Err(error) + } + pub fn has_live_processes(&self) -> io::Result { + // SAFETY: signal 0 only checks whether the group still has members. + if unsafe { libc::kill(-self.0, 0) } == 0 { + return Ok(true); + } + let error = io::Error::last_os_error(); + match error.raw_os_error() { + Some(libc::ESRCH) => Ok(false), + // A member exists but belongs to another user, so it is alive. + Some(libc::EPERM) => Ok(true), + _ => Err(error), + } + } } } @@ -369,8 +474,12 @@ mod platform { reader.read(&mut buffer[..count]) } pub struct Group(HANDLE); + // SAFETY: a Job Object handle may be used and closed from any thread; the + // group is its only owner and closes it exactly once in `Drop`. + unsafe impl Send for Group {} + unsafe impl Sync for Group {} impl Group { - pub fn new(child: &Child) -> io::Result { + pub fn new(child: &Child, allow_breakaway: bool) -> io::Result { unsafe { let handle = CreateJobObjectW(std::ptr::null(), std::ptr::null()); if handle.is_null() { @@ -378,7 +487,12 @@ mod platform { } let group = Self(handle); let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed(); - info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + | if allow_breakaway { + JOB_OBJECT_LIMIT_BREAKAWAY_OK + } else { + 0 + }; if SetInformationJobObject( handle, JobObjectExtendedLimitInformation, @@ -398,6 +512,30 @@ mod platform { TerminateJobObject(self.0, 1); } } + pub fn kill(&self) -> io::Result<()> { + // SAFETY: the job handle is owned by this group and still open. + if unsafe { TerminateJobObject(self.0, 1) } == 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) + } + pub fn has_live_processes(&self) -> io::Result { + // SAFETY: the job handle is open and the buffer matches the class. + unsafe { + let mut info: JOBOBJECT_BASIC_ACCOUNTING_INFORMATION = std::mem::zeroed(); + if QueryInformationJobObject( + self.0, + JobObjectBasicAccountingInformation, + &mut info as *mut _ as *mut _, + std::mem::size_of_val(&info) as u32, + std::ptr::null_mut(), + ) == 0 + { + return Err(io::Error::last_os_error()); + } + Ok(info.ActiveProcesses > 0) + } + } } fn resume_child(process_id: u32) -> io::Result<()> { // SAFETY: each OS handle is checked, borrowed only while valid, and diff --git a/rust/lithe-git-host/tests/process.rs b/rust/lithe-git-host/tests/process.rs index 3f961dc8b7..c1b442e17d 100644 --- a/rust/lithe-git-host/tests/process.rs +++ b/rust/lithe-git-host/tests/process.rs @@ -1,10 +1,13 @@ //! Native integration fixtures execute only this test binary, never installed tools. -use lithe_git_host::{run, Failure}; -use std::io::Write; -use std::process::Command; +use lithe_git_host::{run, Failure, ProcessTree}; +use std::io::{BufRead, BufReader, Read, Write}; +use std::process::{Child, Command, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc; use std::time::{Duration, Instant}; +const TREE_DEADLINE: Duration = Duration::from_secs(5); + #[test] fn process_fixture() { let Ok(mode) = std::env::var("LITHE_GIT_PROCESS_FIXTURE") else { @@ -15,6 +18,10 @@ fn process_fixture() { eprintln!("fixture error"); std::process::exit(23); } + if mode == "tree-parent" || mode == "tree-child" { + run_tree_fixture(&mode); + return; + } // Output is the synchronization signal. The helper also has its own hard // deadline, so a broken parent cancellation path cannot leave it running. let deadline = Instant::now() + Duration::from_secs(3); @@ -79,3 +86,118 @@ fn nonzero_exit_retains_both_streams() { assert!(String::from_utf8_lossy(&outcome.stdout).contains("fixture output")); assert!(String::from_utf8_lossy(&outcome.stderr).contains("fixture error")); } + +/// A two-level tree like `cmd -> mvn -> java`. Both levels block on the +/// test's stdin pipe, so they also end on their own once the test drops it. +fn run_tree_fixture(mode: &str) { + let mut descendant = (mode == "tree-parent").then(|| { + let descendant = Command::new(std::env::current_exe().expect("test executable")) + .args(["--exact", "process_fixture", "--nocapture"]) + .env("LITHE_GIT_PROCESS_FIXTURE", "tree-child") + .stdin(Stdio::inherit()) + .stdout(Stdio::null()) + .spawn() + .expect("spawn descendant"); + println!("descendant-pid {}", descendant.id()); + std::io::stdout().flush().expect("announce descendant"); + descendant + }); + let _ = std::io::stdin().read_to_end(&mut Vec::new()); + if let Some(descendant) = descendant.as_mut() { + let _ = descendant.wait(); + } +} + +struct TreeFixture { + tree: ProcessTree, + descendant: u32, + root_exit: mpsc::Receiver<()>, + // Dropped last: closing it lets any survivor exit on its own. + _input: std::process::ChildStdin, +} + +fn spawn_tree_fixture() -> TreeFixture { + let mut command = Command::new(std::env::current_exe().expect("test executable")); + command + .args(["--exact", "process_fixture", "--nocapture"]) + .env("LITHE_GIT_PROCESS_FIXTURE", "tree-parent") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()); + ProcessTree::prepare(&mut command); + let mut root = command.spawn().expect("spawn tree root"); + let input = root.stdin.take().expect("root input"); + let output = root.stdout.take().expect("root output"); + let tree = ProcessTree::adopt(&mut root).expect("adopt tree"); + let descendant = read_descendant_pid(output); + TreeFixture { + tree, + descendant, + root_exit: reap_in_background(root), + _input: input, + } +} + +fn read_descendant_pid(output: std::process::ChildStdout) -> u32 { + let (sender, receiver) = mpsc::channel(); + std::thread::spawn(move || { + for line in BufReader::new(output).lines() { + let Ok(line) = line else { return }; + if let Some(pid) = line.strip_prefix("descendant-pid ") { + let _ = sender.send(pid.trim().parse::().expect("descendant pid")); + return; + } + } + }); + receiver + .recv_timeout(TREE_DEADLINE) + .expect("tree fixture did not start its descendant") +} + +/// A Unix group still counts an unreaped root, so the owner reaps it while +/// the tree is being ended, as the Run host's exit waiter does. +fn reap_in_background(mut root: Child) -> mpsc::Receiver<()> { + let (sender, receiver) = mpsc::channel(); + std::thread::spawn(move || { + let _ = root.wait(); + let _ = sender.send(()); + }); + receiver +} + +#[test] +fn process_tree_terminate_ends_the_root_and_its_descendants() { + let fixture = spawn_tree_fixture(); + assert_ne!(fixture.descendant, 0); + + fixture + .tree + .terminate(TREE_DEADLINE) + .expect("every process of the tree ends"); + fixture + .root_exit + .recv_timeout(TREE_DEADLINE) + .expect("root ended"); + // A second request after the tree is empty is harmless. + fixture + .tree + .terminate(TREE_DEADLINE) + .expect("an empty tree terminates"); +} + +#[cfg(windows)] +#[test] +fn dropping_a_process_tree_ends_it_like_a_crashed_host() { + // The kernel closes the job handle when the host dies; dropping the tree + // closes it the same way, without an explicit terminate. + let fixture = spawn_tree_fixture(); + let TreeFixture { + tree, + root_exit, + _input, + .. + } = fixture; + drop(tree); + root_exit + .recv_timeout(TREE_DEADLINE) + .expect("closing the job ends the root"); +} diff --git a/rust/lithe-remote-client/Cargo.toml b/rust/lithe-remote-client/Cargo.toml new file mode 100644 index 0000000000..96c3dfb73c --- /dev/null +++ b/rust/lithe-remote-client/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "lithe-remote-client" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Transport chains, server bootstrap and sessions for Lithe remote workspaces" +publish = false + +[dependencies] +lithe-remote-protocol = { path = "../lithe-remote-protocol" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" diff --git a/rust/lithe-remote-client/src/api.rs b/rust/lithe-remote-client/src/api.rs new file mode 100644 index 0000000000..8c1f0ca5c6 --- /dev/null +++ b/rust/lithe-remote-client/src/api.rs @@ -0,0 +1,737 @@ +//! Typed operations on a [`RemoteSession`]. +//! +//! Each long-lived resource gets an id from [`RemoteSession::allocate_id`] +//! and a subscription *before* it is created, so no early event is lost. +//! Dropping a handle only ends the subscription; the remote resource keeps +//! running until it is stopped explicitly, which is what lets terminals and +//! builds survive a reconnect. + +use crate::session::{ClientError, Event, RemoteSession}; +use lithe_remote_protocol::methods::{ + self, decode_bytes, encode_bytes, CopyParams, CopyResult, CreateDirParams, + EnvironmentResolveParams, EnvironmentResolveResult, EnvironmentSource, FileMetadata, + FileVersion, KillParams, ListeningPort, ListeningPortsResult, OutputStream, PathParams, + ProcessExitEvent, ProcessOutputEvent, ProcessParams, ReadDirResult, ReadFileParams, + ReadFileResult, RealPathResult, RemoveParams, RenameParams, ServerState, SetPermissionsParams, + SpawnParams, SpawnResult, TerminalExitEvent, TerminalOpenParams, TerminalOpenResult, + TerminalOutputEvent, TerminalParams, TerminalResizeParams, TerminalWriteParams, + TunnelClosedEvent, TunnelDataEvent, TunnelOpenParams, TunnelParams, TunnelTarget, + TunnelWriteParams, VolumeInfo, WalkDoneEvent, WalkEntriesEvent, WalkEntry, WalkParams, + WalkStartParams, WatchChangedEvent, WatchErrorEvent, WatchParams, WatchStartParams, + WatchStartResult, WriteFileParams, MAX_TRANSFER_BYTES, +}; +use lithe_remote_protocol::{ErrorCode, RemoteError}; +use serde::de::DeserializeOwned; +use serde::Serialize; +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use std::sync::mpsc::{Receiver, RecvTimeoutError}; +use std::thread; +use std::time::{Duration, Instant}; + +/// Deadline for ordinary requests. +pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +/// Resolution may start the login shell twice with a 10-second bound each. +const ENVIRONMENT_TIMEOUT: Duration = Duration::from_secs(60); + +/// Bound for retrying input the remote side is not reading yet (`BUSY`). +const INPUT_RETRY_DEADLINE: Duration = Duration::from_secs(30); +const INPUT_RETRY_START: Duration = Duration::from_millis(1); +const INPUT_RETRY_MAX: Duration = Duration::from_millis(50); + +/// Options of [`RemoteSession::write_file`]. +#[derive(Clone, Debug, Default)] +pub struct WriteOptions { + /// Version last read; the write fails with `CONFLICT` if the file changed. + pub expected: Option, + pub overwrite: bool, + pub create_parents: bool, +} + +/// What to run in [`RemoteSession::spawn`]. +#[derive(Clone, Debug, Default)] +pub struct SpawnSpec { + pub argv: Vec, + pub cwd: Option, + pub environment: EnvironmentSource, + pub env: BTreeMap, + pub pipe_stdin: bool, +} + +/// What to open in [`RemoteSession::open_terminal`]. +#[derive(Clone, Debug)] +pub struct TerminalSpec { + /// `None` starts the user's login shell. + pub argv: Option>, + pub cwd: Option, + pub env: BTreeMap, + pub columns: u16, + pub rows: u16, +} + +/// A finished process's collected output. +#[derive(Debug, Default)] +pub struct ProcessOutput { + pub stdout: Vec, + pub stderr: Vec, + pub exit: Option, +} + +#[derive(Debug)] +pub enum ProcessEvent { + Output(OutputStream, Vec), + Exit(ProcessExitEvent), +} + +#[derive(Debug)] +pub enum TerminalEvent { + Output(Vec), + Exit(TerminalExitEvent), +} + +#[derive(Debug)] +pub enum WatchEvent { + Changed(WatchChangedEvent), + Error(WatchErrorEvent), +} + +#[derive(Debug)] +pub enum TunnelEvent { + Data(Vec), + Closed(TunnelClosedEvent), +} + +/// Every entry of a finished walk. +#[derive(Debug)] +pub struct WalkOutcome { + pub entries: Vec, + pub done: WalkDoneEvent, +} + +impl RemoteSession { + fn call( + &self, + method: &str, + params: &P, + ) -> Result { + self.request(method, params, REQUEST_TIMEOUT) + } + + fn call_unit(&self, method: &str, params: &P) -> Result<(), ClientError> { + self.request::(method, params, REQUEST_TIMEOUT) + .map(|_| ()) + } + + /// Sends input, retrying with backoff while the remote side reports + /// `BUSY` because the receiving program is not reading yet. + fn call_input(&self, method: &str, params: &P) -> Result<(), ClientError> { + let deadline = Instant::now() + INPUT_RETRY_DEADLINE; + let mut delay = INPUT_RETRY_START; + loop { + match self.call_unit(method, params) { + Err(ClientError::Remote(error)) + if error.code == ErrorCode::Busy && Instant::now() < deadline => + { + thread::sleep(delay); + delay = (delay * 2).min(INPUT_RETRY_MAX); + } + result => return result, + } + } + } + + pub fn ping(&self) -> Result { + let started = Instant::now(); + self.call_unit(methods::SERVER_PING, &json!({}))?; + Ok(started.elapsed()) + } + + pub fn state(&self) -> Result { + self.call(methods::SERVER_STATE, &json!({})) + } + + pub fn stat(&self, path: &str) -> Result { + self.call(methods::FS_STAT, &PathParams { path: path.into() }) + } + + pub fn read_dir(&self, path: &str) -> Result { + self.call(methods::FS_READ_DIR, &PathParams { path: path.into() }) + } + + /// Reads a whole file in chunks. Fails with `CONFLICT` when the file + /// changes while it is being read, rather than returning a mixture. + pub fn read_file(&self, path: &str) -> Result<(Vec, FileMetadata), ClientError> { + let mut content = Vec::new(); + let mut first: Option = None; + loop { + let chunk: ReadFileResult = self.call( + methods::FS_READ_FILE, + &ReadFileParams { + path: path.into(), + offset: content.len() as u64, + length: MAX_TRANSFER_BYTES, + }, + )?; + if let Some(first) = &first { + if first.size != chunk.metadata.size + || first.modified_ms != chunk.metadata.modified_ms + { + return Err(ClientError::Remote(RemoteError::new( + ErrorCode::Conflict, + "the file changed while it was read", + ))); + } + } + content.extend(decode(&chunk.data)?); + let eof = chunk.eof; + first.get_or_insert(chunk.metadata); + if eof { + return Ok((content, first.expect("set above"))); + } + } + } + + pub fn write_file( + &self, + path: &str, + content: &[u8], + options: WriteOptions, + ) -> Result { + self.call( + methods::FS_WRITE_FILE, + &WriteFileParams { + path: path.into(), + data: encode_bytes(content), + expected: options.expected, + overwrite: options.overwrite, + create_parents: options.create_parents, + }, + ) + } + + pub fn create_dir(&self, path: &str, recursive: bool) -> Result<(), ClientError> { + self.call_unit( + methods::FS_CREATE_DIR, + &CreateDirParams { + path: path.into(), + recursive, + }, + ) + } + + pub fn remove(&self, path: &str, recursive: bool) -> Result<(), ClientError> { + self.call_unit( + methods::FS_REMOVE, + &RemoveParams { + path: path.into(), + recursive, + }, + ) + } + + pub fn rename(&self, from: &str, to: &str, overwrite: bool) -> Result<(), ClientError> { + self.call_unit( + methods::FS_RENAME, + &RenameParams { + from: from.into(), + to: to.into(), + overwrite, + }, + ) + } + + /// Copies a file or, with `recursive`, a tree. Special files inside a + /// tree are skipped and counted in the result. + pub fn copy( + &self, + from: &str, + to: &str, + recursive: bool, + overwrite: bool, + ) -> Result { + self.call( + methods::FS_COPY, + &CopyParams { + from: from.into(), + to: to.into(), + recursive, + overwrite, + }, + ) + } + + pub fn real_path(&self, path: &str) -> Result { + self.call::<_, RealPathResult>(methods::FS_REAL_PATH, &PathParams { path: path.into() }) + .map(|result| result.path) + } + + pub fn set_permissions(&self, path: &str, mode: u32) -> Result<(), ClientError> { + self.call_unit( + methods::FS_SET_PERMISSIONS, + &SetPermissionsParams { + path: path.into(), + mode, + }, + ) + } + + pub fn volume_info(&self, path: &str) -> Result { + self.call(methods::FS_VOLUME_INFO, &PathParams { path: path.into() }) + } + + /// Walks `root` and collects every entry until the walk finishes or + /// `timeout` elapses, in which case the walk is cancelled. + pub fn walk( + &self, + root: &str, + exclude_names: &[&str], + max_entries: Option, + timeout: Duration, + ) -> Result { + let walk_id = self.allocate_id(); + let events = self.subscribe("walk", walk_id); + let result = (|| { + self.call_unit( + methods::WALK_START, + &WalkStartParams { + walk_id, + root: root.into(), + exclude_names: exclude_names.iter().map(|name| name.to_string()).collect(), + max_entries, + }, + )?; + let deadline = Instant::now() + timeout; + let mut entries = Vec::new(); + loop { + let event = match receive(&events, deadline) { + Ok(event) => event, + Err(error) => { + let _ = self.call_unit(methods::WALK_CANCEL, &WalkParams { walk_id }); + return Err(error); + } + }; + match event.event.as_str() { + methods::EVENT_WALK_ENTRIES => { + entries.extend(event.decode::()?.entries) + } + methods::EVENT_WALK_DONE => { + return Ok(WalkOutcome { + entries, + done: event.decode()?, + }) + } + _ => {} + } + } + })(); + self.unsubscribe("walk", walk_id); + result + } + + pub fn spawn(&self, spec: SpawnSpec) -> Result, ClientError> { + let process_id = self.allocate_id(); + let events = self.subscribe("process", process_id); + let result: Result = self.call( + methods::PROCESS_SPAWN, + &SpawnParams { + process_id, + argv: spec.argv, + cwd: spec.cwd, + environment: spec.environment, + env: spec.env, + pipe_stdin: spec.pipe_stdin, + }, + ); + match result { + Ok(spawned) => Ok(ProcessHandle { + session: self, + id: process_id, + pid: spawned.pid, + events, + }), + Err(error) => { + self.unsubscribe("process", process_id); + Err(error) + } + } + } + + pub fn open_terminal(&self, spec: TerminalSpec) -> Result, ClientError> { + let terminal_id = self.allocate_id(); + let events = self.subscribe("terminal", terminal_id); + let result: Result = self.call( + methods::TERMINAL_OPEN, + &TerminalOpenParams { + terminal_id, + argv: spec.argv, + cwd: spec.cwd, + env: spec.env, + columns: spec.columns, + rows: spec.rows, + }, + ); + match result { + Ok(opened) => Ok(TerminalHandle { + session: self, + id: terminal_id, + pid: opened.pid, + events, + }), + Err(error) => { + self.unsubscribe("terminal", terminal_id); + Err(error) + } + } + } + + /// Attaches to a terminal that already runs in a persistent session, + /// for example after reconnecting. + pub fn attach_terminal(&self, terminal_id: u64, pid: u32) -> TerminalHandle<'_> { + TerminalHandle { + session: self, + id: terminal_id, + pid, + events: self.subscribe("terminal", terminal_id), + } + } + + pub fn watch( + &self, + root: &str, + exclude_names: &[&str], + ) -> Result, ClientError> { + let watch_id = self.allocate_id(); + let events = self.subscribe("watch", watch_id); + let result: Result = self.call( + methods::WATCH_START, + &WatchStartParams { + watch_id, + root: root.into(), + exclude_names: exclude_names.iter().map(|name| name.to_string()).collect(), + }, + ); + match result { + Ok(started) => Ok(WatchHandle { + session: self, + id: watch_id, + watched_directories: started.watched_directories, + events, + }), + Err(error) => { + self.unsubscribe("watch", watch_id); + Err(error) + } + } + } + + pub fn resolve_environment( + &self, + refresh: bool, + ) -> Result { + self.request( + methods::ENVIRONMENT_RESOLVE, + &EnvironmentResolveParams { refresh }, + ENVIRONMENT_TIMEOUT, + ) + } + + pub fn listening_ports(&self) -> Result, ClientError> { + self.call::<_, ListeningPortsResult>(methods::PORTS_LISTENING, &json!({})) + .map(|result| result.ports) + } + + pub fn open_tunnel(&self, target: TunnelTarget) -> Result, ClientError> { + let tunnel_id = self.allocate_id(); + let events = self.subscribe("tunnel", tunnel_id); + match self.call_unit( + methods::TUNNEL_OPEN, + &TunnelOpenParams { tunnel_id, target }, + ) { + Ok(()) => Ok(TunnelHandle { + session: self, + id: tunnel_id, + events, + }), + Err(error) => { + self.unsubscribe("tunnel", tunnel_id); + Err(error) + } + } + } +} + +pub struct ProcessHandle<'a> { + session: &'a RemoteSession, + pub id: u64, + pub pid: u32, + events: Receiver, +} + +impl ProcessHandle<'_> { + pub fn write_stdin(&self, data: &[u8]) -> Result<(), ClientError> { + self.session.call_input( + methods::PROCESS_WRITE_STDIN, + &json!({ "processId": self.id, "data": encode_bytes(data) }), + ) + } + + pub fn close_stdin(&self) -> Result<(), ClientError> { + self.session.call_unit( + methods::PROCESS_CLOSE_STDIN, + &ProcessParams { + process_id: self.id, + }, + ) + } + + pub fn kill(&self, force: bool) -> Result<(), ClientError> { + self.session.call_unit( + methods::PROCESS_KILL, + &KillParams { + process_id: self.id, + force, + }, + ) + } + + /// Next event, or `None` once the subscription ended. + pub fn next_event(&self, timeout: Duration) -> Result, ClientError> { + let Some(event) = receive_optional(&self.events, timeout)? else { + return Ok(None); + }; + Ok(Some(match event.event.as_str() { + methods::EVENT_PROCESS_EXIT => ProcessEvent::Exit(event.decode()?), + _ => { + let output: ProcessOutputEvent = event.decode()?; + ProcessEvent::Output(output.stream, decode(&output.data)?) + } + })) + } + + /// Collects output until the process exits or `timeout` elapses. + pub fn wait(&self, timeout: Duration) -> Result { + let deadline = Instant::now() + timeout; + let mut output = ProcessOutput::default(); + loop { + match self.next_event(deadline.saturating_duration_since(Instant::now()))? { + Some(ProcessEvent::Output(OutputStream::Stdout, bytes)) => { + output.stdout.extend(bytes) + } + Some(ProcessEvent::Output(OutputStream::Stderr, bytes)) => { + output.stderr.extend(bytes) + } + Some(ProcessEvent::Exit(exit)) => { + output.exit = Some(exit); + return Ok(output); + } + None => return Err(self.session_closed()), + } + } + } + + fn session_closed(&self) -> ClientError { + ClientError::Disconnected { + diagnostics: String::new(), + } + } +} + +impl Drop for ProcessHandle<'_> { + fn drop(&mut self) { + self.session.unsubscribe("process", self.id); + } +} + +pub struct TerminalHandle<'a> { + session: &'a RemoteSession, + pub id: u64, + pub pid: u32, + events: Receiver, +} + +impl TerminalHandle<'_> { + pub fn write(&self, data: &[u8]) -> Result<(), ClientError> { + self.session.call_input( + methods::TERMINAL_WRITE, + &TerminalWriteParams { + terminal_id: self.id, + data: encode_bytes(data), + }, + ) + } + + pub fn resize(&self, columns: u16, rows: u16) -> Result<(), ClientError> { + self.session.call_unit( + methods::TERMINAL_RESIZE, + &TerminalResizeParams { + terminal_id: self.id, + columns, + rows, + }, + ) + } + + /// Hangs up the terminal; its exit event follows. + pub fn close(&self) -> Result<(), ClientError> { + self.session.call_unit( + methods::TERMINAL_CLOSE, + &TerminalParams { + terminal_id: self.id, + }, + ) + } + + pub fn next_event(&self, timeout: Duration) -> Result, ClientError> { + let Some(event) = receive_optional(&self.events, timeout)? else { + return Ok(None); + }; + Ok(Some(match event.event.as_str() { + methods::EVENT_TERMINAL_EXIT => TerminalEvent::Exit(event.decode()?), + _ => TerminalEvent::Output(decode(&event.decode::()?.data)?), + })) + } +} + +impl Drop for TerminalHandle<'_> { + fn drop(&mut self) { + self.session.unsubscribe("terminal", self.id); + } +} + +pub struct WatchHandle<'a> { + session: &'a RemoteSession, + pub id: u64, + /// Directories registered by a per-directory backend. + pub watched_directories: Option, + events: Receiver, +} + +impl WatchHandle<'_> { + pub fn next_event(&self, timeout: Duration) -> Result, ClientError> { + let Some(event) = receive_optional(&self.events, timeout)? else { + return Ok(None); + }; + Ok(Some(match event.event.as_str() { + methods::EVENT_WATCH_ERROR => WatchEvent::Error(event.decode()?), + _ => WatchEvent::Changed(event.decode()?), + })) + } + + pub fn stop(&self) -> Result<(), ClientError> { + self.session + .call_unit(methods::WATCH_STOP, &WatchParams { watch_id: self.id }) + } +} + +impl Drop for WatchHandle<'_> { + fn drop(&mut self) { + self.session.unsubscribe("watch", self.id); + } +} + +pub struct TunnelHandle<'a> { + session: &'a RemoteSession, + pub id: u64, + events: Receiver, +} + +impl<'a> TunnelHandle<'a> { + pub fn write(&self, data: &[u8]) -> Result<(), ClientError> { + self.writer().write(data) + } + + pub fn shutdown_write(&self) -> Result<(), ClientError> { + self.writer().shutdown_write() + } + + pub fn close(&self) -> Result<(), ClientError> { + self.writer().close() + } + + /// Write-only half that can move to another thread, while this handle + /// keeps receiving. + pub fn writer(&self) -> TunnelWriter<'a> { + TunnelWriter { + session: self.session, + id: self.id, + } + } + + pub fn next_event(&self, timeout: Duration) -> Result, ClientError> { + let Some(event) = receive_optional(&self.events, timeout)? else { + return Ok(None); + }; + Ok(Some(match event.event.as_str() { + methods::EVENT_TUNNEL_CLOSED => { + TunnelEvent::Closed(event.decode::()?) + } + _ => TunnelEvent::Data(decode(&event.decode::()?.data)?), + })) + } +} + +#[derive(Clone, Copy)] +pub struct TunnelWriter<'a> { + session: &'a RemoteSession, + id: u64, +} + +impl TunnelWriter<'_> { + pub fn write(&self, data: &[u8]) -> Result<(), ClientError> { + self.session.call_input( + methods::TUNNEL_WRITE, + &TunnelWriteParams { + tunnel_id: self.id, + data: encode_bytes(data), + }, + ) + } + + /// Ends the outgoing direction after the queued data, like shutting down + /// a socket's write side; the remote reply keeps arriving. + pub fn shutdown_write(&self) -> Result<(), ClientError> { + self.session.call_unit( + methods::TUNNEL_SHUTDOWN_WRITE, + &TunnelParams { tunnel_id: self.id }, + ) + } + + /// Aborts both directions; queued data is discarded. + pub fn close(&self) -> Result<(), ClientError> { + self.session + .call_unit(methods::TUNNEL_CLOSE, &TunnelParams { tunnel_id: self.id }) + } +} + +impl Drop for TunnelHandle<'_> { + fn drop(&mut self) { + self.session.unsubscribe("tunnel", self.id); + } +} + +fn decode(data: &str) -> Result, ClientError> { + decode_bytes(data).map_err(|error| ClientError::Protocol(error.to_string())) +} + +fn receive(events: &Receiver, deadline: Instant) -> Result { + match events.recv_timeout(deadline.saturating_duration_since(Instant::now())) { + Ok(event) => Ok(event), + Err(RecvTimeoutError::Timeout) => Err(ClientError::Timeout), + Err(RecvTimeoutError::Disconnected) => Err(ClientError::Disconnected { + diagnostics: String::new(), + }), + } +} + +/// Like [`receive`], but a closed subscription is `None` instead of an error. +fn receive_optional( + events: &Receiver, + timeout: Duration, +) -> Result, ClientError> { + match events.recv_timeout(timeout) { + Ok(event) => Ok(Some(event)), + Err(RecvTimeoutError::Timeout) => Err(ClientError::Timeout), + Err(RecvTimeoutError::Disconnected) => Ok(None), + } +} diff --git a/rust/lithe-remote-client/src/bootstrap.rs b/rust/lithe-remote-client/src/bootstrap.rs new file mode 100644 index 0000000000..739b3f1ad5 --- /dev/null +++ b/rust/lithe-remote-client/src/bootstrap.rs @@ -0,0 +1,601 @@ +//! Installing `lithe-remote-server` at the end of a transport chain. +//! +//! The client always uploads the server itself instead of letting the remote +//! download it, because many SSH hosts and containers have no internet access. +//! The upload goes through the same chain as the session, so it works for +//! every kind of hop without extra file-transfer tooling. +//! +//! Installed binaries are reused only when they report the exact SHA-256 of +//! the artifact this client ships. Each version lives in its own directory, so +//! clients of different releases can share a host. + +use crate::command::{self, CommandOutput}; +use crate::transport::{TransportChain, TransportError}; +use lithe_remote_protocol::methods::ServerIdentity; +use lithe_remote_protocol::PROTOCOL_VERSION; +use sha2::{Digest, Sha256}; +use std::fmt; +use std::fs::File; +use std::io; +use std::path::PathBuf; +use std::time::Duration; + +const PROBE_BEGIN: &str = "LITHE-PROBE-BEGIN"; +const PROBE_END: &str = "LITHE-PROBE-END"; + +/// Reports system, machine and home directory between markers, so output +/// from the remote user's shell startup files is ignored. +const PROBE_SCRIPT: &str = concat!( + host_details_script!(), + "echo LITHE-PROBE-BEGIN; uname -s; uname -m; echo \"$HOME\"; echo LITHE-PROBE-END" +); + +/// Reports facts shown to the user as `key=value` lines between markers. +/// Every command is optional: a missing tool leaves its line empty. Raw +/// command output is parsed here rather than on the host, because the +/// script must avoid the characters that cannot cross an SSH hop. +macro_rules! host_details_script { + () => { + concat!( + "echo LITHE-DETAILS-BEGIN; ", + "echo \"hostName=$(uname -n)\"; echo \"kernel=$(uname -r)\"; ", + "if [ -r /etc/os-release ]; then ( . /etc/os-release; echo \"operatingSystem=$PRETTY_NAME\" ); ", + "elif command -v sw_vers >/dev/null 2>&1; then echo \"operatingSystem=macOS $(sw_vers -productVersion)\"; fi; ", + "echo \"cpuCount=$(getconf _NPROCESSORS_ONLN 2>/dev/null)\"; ", + "if [ -r /proc/meminfo ]; then echo \"memInfo=$(grep MemTotal /proc/meminfo)\"; ", + "else echo \"memoryBytes=$(sysctl -n hw.memsize 2>/dev/null)\"; fi; ", + "echo \"disk=$(df -Pk \"$HOME\" 2>/dev/null | tail -n 1)\"; ", + "if [ -r /proc/uptime ]; then echo \"uptime=$(cat /proc/uptime)\"; fi; ", + "echo LITHE-DETAILS-END; " + ) + }; +} +pub(crate) use host_details_script; + +/// Receives the binary on standard input. It is written under a temporary +/// name and renamed into place, so a broken upload never leaves a truncated +/// executable that a later identity check could run. `umask 077` keeps other +/// users on a shared host from replacing it. +const INSTALL_SCRIPT: &str = "umask 077 && mkdir -p \"$1\" && cat > \"$1/.upload.$$\" && chmod 700 \"$1/.upload.$$\" && mv -f \"$1/.upload.$$\" \"$1/lithe-remote-server\""; + +/// Runs the binary passed as `$0` with `--identity`. +const IDENTITY_SCRIPT: &str = "exec \"$0\" --identity"; + +pub const SERVER_EXECUTABLE: &str = "lithe-remote-server"; + +/// Hex digits of the SHA-256 used in the install directory name. Sixteen +/// keep paths short while making accidental collisions between builds of the +/// same version practically impossible; the full hash is still verified. +pub(crate) const INSTALL_HASH_PREFIX: usize = 16; + +/// Server build target selected from the remote's `uname` output. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub enum ServerTarget { + LinuxX86_64, + LinuxAarch64, + MacosX86_64, + MacosAarch64, +} + +impl ServerTarget { + /// Rust target triple the artifact is built for. Linux uses musl so one + /// static binary runs on glibc and musl distributions alike. + pub fn rust_target(self) -> &'static str { + match self { + Self::LinuxX86_64 => "x86_64-unknown-linux-musl", + Self::LinuxAarch64 => "aarch64-unknown-linux-musl", + Self::MacosX86_64 => "x86_64-apple-darwin", + Self::MacosAarch64 => "aarch64-apple-darwin", + } + } + + /// Maps `uname -s` and `uname -m`. Architecture comes from the remote, + /// not the local machine: an amd64 container on an Apple silicon Mac + /// needs the x86_64 server. + pub fn from_uname(system: &str, machine: &str) -> Option { + let arm = matches!(machine, "aarch64" | "arm64" | "aarch64_be"); + let intel = matches!(machine, "x86_64" | "amd64"); + match system { + "Linux" if intel => Some(Self::LinuxX86_64), + "Linux" if arm => Some(Self::LinuxAarch64), + "Darwin" if intel => Some(Self::MacosX86_64), + "Darwin" if arm => Some(Self::MacosAarch64), + _ => None, + } + } +} + +/// A server binary available on the local machine. +#[derive(Clone, Debug)] +pub struct ServerArtifact { + pub target: ServerTarget, + pub path: PathBuf, + pub version: String, + pub sha256: String, +} + +impl ServerArtifact { + /// Describes a binary on disk, hashing it. + pub fn from_file( + target: ServerTarget, + path: PathBuf, + version: impl Into, + ) -> io::Result { + let mut hasher = Sha256::new(); + io::copy(&mut File::open(&path)?, &mut hasher)?; + Ok(Self { + target, + path, + version: version.into(), + sha256: hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(), + }) + } +} + +/// Where the server is installed on the remote. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum InstallLocation { + /// `$HOME/.lithe-server`, the default. + Home, + /// An explicit private directory, for hosts with a read-only home or for + /// tests. It must not be writable by other users. + Directory(String), +} + +/// Result of probing the end of a chain. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct HostProbe { + pub target: ServerTarget, + pub home: Option, +} + +/// An installed, verified server. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InstalledServer { + /// Absolute path of the executable on the remote. + pub path: String, + pub target: ServerTarget, + /// Whether this call uploaded it. + pub uploaded: bool, +} + +impl InstalledServer { + /// Command line of a session that ends with the connection. + pub fn stdio_command(&self) -> Vec { + vec![self.path.clone(), "--stdio".into()] + } + + /// Command line of the persistent session `key`, which survives a + /// dropped connection. `key` must be 1–64 characters from + /// `[A-Za-z0-9._-]`, for example a hash of the workspace path. + pub fn connect_command(&self, key: &str) -> Vec { + vec![self.path.clone(), "--connect".into(), key.into()] + } +} + +#[derive(Debug)] +pub enum BootstrapError { + Transport(TransportError), + /// A local command could not run or exceeded its deadline. + Command(io::Error), + /// The chain did not reach a POSIX shell. `stderr` usually explains why, + /// for example an SSH authentication failure. + ProbeFailed { + stderr: String, + }, + UnsupportedPlatform { + system: String, + machine: String, + }, + /// This client ships no server for the remote's platform. + NoArtifact(ServerTarget), + /// The remote reported no home directory and no explicit location was set. + NoInstallLocation, + InstallFailed { + stderr: String, + }, + /// The installed binary did not report the uploaded artifact's identity. + VerificationFailed { + expected: String, + found: Option, + }, +} + +impl fmt::Display for BootstrapError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Transport(error) => error.fmt(formatter), + Self::Command(error) => write!(formatter, "connection command failed: {error}"), + Self::ProbeFailed { stderr } => { + write!(formatter, "cannot reach a shell on the remote: {stderr}") + } + Self::UnsupportedPlatform { system, machine } => { + write!( + formatter, + "remote platform {system} {machine} is not supported" + ) + } + Self::NoArtifact(target) => write!( + formatter, + "no remote server is available for {}", + target.rust_target() + ), + Self::NoInstallLocation => formatter.write_str("the remote user has no home directory"), + Self::InstallFailed { stderr } => { + write!(formatter, "installing the remote server failed: {stderr}") + } + Self::VerificationFailed { expected, found } => write!( + formatter, + "installed remote server reports {} instead of {expected}", + found.as_deref().unwrap_or("no identity") + ), + } + } +} + +impl std::error::Error for BootstrapError {} + +impl From for BootstrapError { + fn from(error: TransportError) -> Self { + Self::Transport(error) + } +} + +/// Probes the end of a chain and installs the server there. +pub struct Bootstrapper { + pub chain: TransportChain, + pub location: InstallLocation, + /// Deadline for each probe or identity command. + pub command_timeout: Duration, + /// Deadline for the upload, which moves several megabytes. + pub upload_timeout: Duration, +} + +impl Bootstrapper { + pub fn new(chain: TransportChain) -> Self { + Self { + chain, + location: InstallLocation::Home, + command_timeout: Duration::from_secs(30), + upload_timeout: Duration::from_secs(300), + } + } + + pub fn probe(&self) -> Result { + let argv = probe_command(); + let argv: Vec<&str> = argv.iter().map(String::as_str).collect(); + let output = self.run(&argv, None, self.command_timeout)?; + let Some(report) = parse_probe(&output.stdout) else { + return Err(BootstrapError::ProbeFailed { + stderr: output.stderr_text(), + }); + }; + let target = report + .target() + .ok_or_else(|| BootstrapError::UnsupportedPlatform { + system: report.system.clone(), + machine: report.machine.clone(), + })?; + Ok(HostProbe { + target, + home: report.home, + }) + } + + /// Returns a verified server, uploading `artifact_for(target)` when the + /// remote has no binary with the same identity. + pub fn ensure_installed( + &self, + artifact_for: impl Fn(ServerTarget) -> Option, + ) -> Result { + let probe = self.probe()?; + let artifact = + artifact_for(probe.target).ok_or(BootstrapError::NoArtifact(probe.target))?; + let root = match &self.location { + InstallLocation::Directory(directory) => directory.trim_end_matches('/').to_owned(), + InstallLocation::Home => format!( + "{}/.lithe-server", + probe + .home + .as_deref() + .ok_or(BootstrapError::NoInstallLocation)? + .trim_end_matches('/') + ), + }; + let directory = format!( + "{root}/bin/{}-{}", + artifact.version, + &artifact.sha256[..INSTALL_HASH_PREFIX.min(artifact.sha256.len())] + ); + let path = format!("{directory}/{SERVER_EXECUTABLE}"); + let installed = |uploaded| InstalledServer { + path: path.clone(), + target: probe.target, + uploaded, + }; + + if self.identity(&path)?.as_deref() == Some(artifact.sha256.as_str()) { + return Ok(installed(false)); + } + let binary = std::fs::read(&artifact.path).map_err(BootstrapError::Command)?; + let output = self.run( + &["sh", "-c", INSTALL_SCRIPT, "sh", &directory], + Some(&binary), + self.upload_timeout, + )?; + if !output.success() { + return Err(BootstrapError::InstallFailed { + stderr: output.stderr_text(), + }); + } + match self.identity(&path)? { + Some(found) if found == artifact.sha256 => Ok(installed(true)), + found => Err(BootstrapError::VerificationFailed { + expected: artifact.sha256, + found, + }), + } + } + + /// SHA-256 reported by the binary at `path`, or `None` when it is absent, + /// does not run, or speaks another protocol version. + fn identity(&self, path: &str) -> Result, BootstrapError> { + // Run through `sh` so that a missing binary is an ordinary non-zero + // exit on every chain, including the local one, where executing a + // missing path directly would fail before any process starts. + let output = self.run( + &["sh", "-c", IDENTITY_SCRIPT, path], + None, + self.command_timeout, + )?; + if !output.success() { + return Ok(None); + } + let text = String::from_utf8_lossy(&output.stdout); + Ok(text + .lines() + .rev() + .find_map(|line| serde_json::from_str::(line).ok()) + .filter(|identity| identity.protocol_version == PROTOCOL_VERSION) + .map(|identity| identity.sha256)) + } + + fn run( + &self, + argv: &[&str], + stdin: Option<&[u8]>, + timeout: Duration, + ) -> Result { + let argv: Vec = argv.iter().map(|argument| argument.to_string()).collect(); + let command = self.chain.command(&argv)?; + command::run(&command, stdin, timeout).map_err(BootstrapError::Command) + } +} + +/// What the probe reported about the end of a chain. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProbeReport { + /// `uname -s`, for example `Linux` or `Darwin`. + pub system: String, + /// `uname -m`, for example `x86_64` or `arm64`. + pub machine: String, + /// `$HOME` when it is an absolute path. + pub home: Option, + pub details: HostDetails, +} + +/// Facts about the host for display; each is `None` when it could not be read. +#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HostDetails { + /// `uname -n`. + pub host_name: Option, + /// For example `Ubuntu 22.04.4 LTS` or `macOS 15.1`. + pub operating_system: Option, + /// `uname -r`. + pub kernel: Option, + pub cpu_count: Option, + pub memory_bytes: Option, + /// Size of the file system holding the home directory. + pub disk_total_bytes: Option, + pub disk_available_bytes: Option, + pub uptime_seconds: Option, +} + +/// Reads the details block; missing or malformed lines leave fields unset. +fn parse_details(text: &str) -> HostDetails { + let mut details = HostDetails::default(); + let mut lines = text.lines().map(str::trim_end); + if lines + .by_ref() + .find(|line| *line == "LITHE-DETAILS-BEGIN") + .is_none() + { + return details; + } + let present = |value: &str| Some(value.trim().to_owned()).filter(|value| !value.is_empty()); + for line in lines { + if line == "LITHE-DETAILS-END" { + break; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + match key { + "hostName" => details.host_name = present(value), + "kernel" => details.kernel = present(value), + "operatingSystem" => details.operating_system = present(value), + "cpuCount" => details.cpu_count = value.trim().parse().ok(), + "memoryBytes" => details.memory_bytes = value.trim().parse().ok(), + // `MemTotal: 16303780 kB` + "memInfo" => { + details.memory_bytes = value + .split_whitespace() + .nth(1) + .and_then(|kilobytes| kilobytes.parse::().ok()) + .map(|kilobytes| kilobytes * 1024) + } + // `df -Pk`: file system, 1024-blocks, used, available, capacity, mount. + "disk" => { + let fields: Vec<&str> = value.split_whitespace().collect(); + if fields.len() >= 6 { + let kilobytes = + |index: usize| fields[index].parse::().ok().map(|value| value * 1024); + details.disk_total_bytes = kilobytes(1); + details.disk_available_bytes = kilobytes(3); + } + } + // `/proc/uptime`: seconds since boot, then idle seconds. + "uptime" => { + details.uptime_seconds = value + .split_whitespace() + .next() + .and_then(|seconds| seconds.parse::().ok()) + .map(|seconds| seconds as u64) + } + _ => {} + } + } + details +} + +impl ProbeReport { + /// Server build for this platform, or `None` when none is shipped. + pub fn target(&self) -> Option { + ServerTarget::from_uname(&self.system, &self.machine) + } +} + +/// Command that reports the remote platform. Callers that run it with their +/// own process handling, for example to answer SSH prompts, pass it to +/// [`TransportChain::command`] and read the output with [`parse_probe`]. +pub fn probe_command() -> Vec { + vec!["sh".into(), "-c".into(), PROBE_SCRIPT.into()] +} + +/// Reads the output of [`probe_command`]; `None` when the marked block is +/// missing or incomplete, for example because the shell never ran. +pub fn parse_probe(stdout: &[u8]) -> Option { + let lines = probe_lines(stdout)?; + let [system, machine, home] = lines.as_slice() else { + return None; + }; + Some(ProbeReport { + system: system.clone(), + machine: machine.clone(), + home: Some(home.clone()).filter(|home| home.starts_with('/')), + details: parse_details(&String::from_utf8_lossy(stdout)), + }) +} + +fn probe_lines(stdout: &[u8]) -> Option> { + let text = String::from_utf8_lossy(stdout); + let mut lines = text.lines().map(str::trim_end); + lines.by_ref().find(|line| *line == PROBE_BEGIN)?; + let mut collected = Vec::new(); + for line in lines { + if line == PROBE_END { + return Some(collected); + } + collected.push(line.to_owned()); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn host_details_are_read_from_raw_command_output() { + let stdout = b"LITHE-DETAILS-BEGIN\nhostName=devbox\nkernel=6.8.0-45-generic\n\ +operatingSystem=Ubuntu 22.04.4 LTS\ncpuCount=4\nmemInfo=MemTotal: 16303780 kB\n\ +disk=/dev/vda1 82045336 23412820 55282748 30% /\nuptime=93784.52 351234.11\nLITHE-DETAILS-END\n\ +LITHE-PROBE-BEGIN\nLinux\nx86_64\n/root\nLITHE-PROBE-END\n"; + let details = parse_probe(stdout).unwrap().details; + assert_eq!( + details, + HostDetails { + host_name: Some("devbox".into()), + operating_system: Some("Ubuntu 22.04.4 LTS".into()), + kernel: Some("6.8.0-45-generic".into()), + cpu_count: Some(4), + memory_bytes: Some(16_303_780 * 1024), + disk_total_bytes: Some(82_045_336 * 1024), + disk_available_bytes: Some(55_282_748 * 1024), + uptime_seconds: Some(93_784), + } + ); + // macOS reports memory directly and has no /proc; empty values stay unset. + let mac = parse_details( + "LITHE-DETAILS-BEGIN\nmemoryBytes=17179869184\ncpuCount=\nLITHE-DETAILS-END\n", + ); + assert_eq!(mac.memory_bytes, Some(17_179_869_184)); + assert_eq!(mac.cpu_count, None); + } + + // The probe crosses SSH hops through the transport's quoting, which + // rejects these characters instead of escaping them. + #[test] + fn the_probe_can_cross_an_ssh_hop() { + assert!(!PROBE_SCRIPT + .chars() + .any(|character| matches!(character, '\'' | '\\' | '!') || character.is_control())); + } + + #[test] + fn uname_values_map_to_server_targets() { + assert_eq!( + ServerTarget::from_uname("Linux", "x86_64"), + Some(ServerTarget::LinuxX86_64) + ); + assert_eq!( + ServerTarget::from_uname("Linux", "aarch64"), + Some(ServerTarget::LinuxAarch64) + ); + assert_eq!( + ServerTarget::from_uname("Darwin", "arm64"), + Some(ServerTarget::MacosAarch64) + ); + assert_eq!( + ServerTarget::from_uname("Darwin", "x86_64"), + Some(ServerTarget::MacosX86_64) + ); + assert_eq!(ServerTarget::from_uname("Linux", "armv7l"), None); + assert_eq!(ServerTarget::from_uname("FreeBSD", "amd64"), None); + } + + // Shell startup files may print before and after the probe; only the + // marked block is trusted. + #[test] + fn probe_output_is_read_between_markers() { + let stdout = + b"Welcome!\nLITHE-PROBE-BEGIN\nLinux\nx86_64\n/home/dev\nLITHE-PROBE-END\nbye\n"; + assert_eq!( + probe_lines(stdout).unwrap(), + vec!["Linux".to_string(), "x86_64".into(), "/home/dev".into()] + ); + assert_eq!(probe_lines(b"LITHE-PROBE-BEGIN\nLinux\n"), None); + assert_eq!(probe_lines(b"Permission denied\n"), None); + assert_eq!( + parse_probe(stdout).unwrap(), + ProbeReport { + system: "Linux".into(), + machine: "x86_64".into(), + home: Some("/home/dev".into()), + details: HostDetails::default(), + } + ); + // A relative or empty `$HOME` is not a usable install location. + assert_eq!( + parse_probe(b"LITHE-PROBE-BEGIN\nLinux\nx86_64\n\nLITHE-PROBE-END\n") + .unwrap() + .home, + None + ); + } +} diff --git a/rust/lithe-remote-client/src/command.rs b/rust/lithe-remote-client/src/command.rs new file mode 100644 index 0000000000..60e09e7256 --- /dev/null +++ b/rust/lithe-remote-client/src/command.rs @@ -0,0 +1,144 @@ +//! Bounded one-shot commands, used for probing and installing the server. + +use std::io::{self, Read, Write}; +use std::process::{Command, Stdio}; +use std::sync::mpsc::{self, Receiver}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::{Duration, Instant}; + +/// Output bytes kept per stream. Probe and install output is small; anything +/// larger is a misbehaving remote and only its beginning is useful. +const CAPTURE_LIMIT_BYTES: usize = 1024 * 1024; + +/// Interval between exit checks. Bootstrap commands run for seconds over a +/// network, so this adds no noticeable latency. +const EXIT_POLL_INTERVAL: Duration = Duration::from_millis(20); + +/// How long output pipes may stay open after the command exited. An SSH +/// `ControlPersist` master or a daemonized remote process can inherit them and +/// keep them open indefinitely. +const OUTPUT_DRAIN_GRACE: Duration = Duration::from_millis(500); + +#[derive(Debug)] +pub struct CommandOutput { + /// Exit code; `None` when the command was terminated by a signal. + pub status: Option, + pub stdout: Vec, + pub stderr: Vec, +} + +impl CommandOutput { + pub fn success(&self) -> bool { + self.status == Some(0) + } + + /// Standard error for user-facing diagnostics. + pub fn stderr_text(&self) -> String { + String::from_utf8_lossy(&self.stderr).trim().to_owned() + } +} + +/// Runs `argv` locally, feeding it `stdin`, and kills it after `timeout`. +pub fn run(argv: &[String], stdin: Option<&[u8]>, timeout: Duration) -> io::Result { + let (program, arguments) = argv + .split_first() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "empty command"))?; + let mut child = Command::new(program) + .args(arguments) + .stdin(if stdin.is_some() { + Stdio::piped() + } else { + Stdio::null() + }) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + + let writer = match (child.stdin.take(), stdin) { + (Some(mut pipe), Some(bytes)) => { + let bytes = bytes.to_vec(); + // A remote that exits early closes the pipe; its exit status and + // standard error then explain why, so the write error is not used. + Some(thread::spawn(move || { + let _ = pipe.write_all(&bytes); + })) + } + _ => None, + }; + let stdout = capture(child.stdout.take()); + let stderr = capture(child.stderr.take()); + + let deadline = Instant::now() + timeout; + let status = loop { + if let Some(status) = child.try_wait()? { + break status; + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return Err(io::Error::new( + io::ErrorKind::TimedOut, + format!( + "{program} did not finish within {} seconds", + timeout.as_secs() + ), + )); + } + thread::sleep(EXIT_POLL_INTERVAL); + }; + // The writer thread is detached: it ends when the pipe closes, which + // the exited command already did unless a descendant inherited it. + drop(writer); + let drain_deadline = Instant::now() + OUTPUT_DRAIN_GRACE; + Ok(CommandOutput { + status: status.code(), + stdout: stdout.collect(drain_deadline), + stderr: stderr.collect(drain_deadline), + }) +} + +struct Capture { + bytes: Arc>>, + finished: Receiver<()>, +} + +impl Capture { + /// Returns what was read once the pipe closed, or at `deadline` if a + /// descendant still holds it open. + fn collect(self, deadline: Instant) -> Vec { + let _ = self + .finished + .recv_timeout(deadline.saturating_duration_since(Instant::now())); + let bytes = self + .bytes + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + bytes.clone() + } +} + +fn capture(pipe: Option) -> Capture { + let bytes = Arc::new(Mutex::new(Vec::new())); + let (finished_sender, finished) = mpsc::channel(); + let shared = Arc::clone(&bytes); + thread::spawn(move || { + if let Some(mut pipe) = pipe { + let mut buffer = [0_u8; 8192]; + loop { + match pipe.read(&mut buffer) { + Ok(0) | Err(_) => break, + Ok(read) => { + let mut captured = shared + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let room = CAPTURE_LIMIT_BYTES.saturating_sub(captured.len()); + captured.extend_from_slice(&buffer[..read.min(room)]); + } + } + } + } + let _ = finished_sender.send(()); + }); + Capture { bytes, finished } +} diff --git a/rust/lithe-remote-client/src/forward.rs b/rust/lithe-remote-client/src/forward.rs new file mode 100644 index 0000000000..5e40660be1 --- /dev/null +++ b/rust/lithe-remote-client/src/forward.rs @@ -0,0 +1,162 @@ +//! Local port forwarding through tunnels. +//! +//! A [`PortForward`] listens on a local address. Every accepted connection +//! opens its own tunnel to the remote target and copies bytes both ways, so +//! `http://127.0.0.1:` in a browser reaches a server that only +//! listens on the remote machine's loopback interface. + +use crate::api::TunnelEvent; +use crate::session::{ClientError, RemoteSession}; +use lithe_remote_protocol::methods::TunnelTarget; +use std::io::{self, Read, Write}; +use std::net::{Shutdown, SocketAddr, TcpListener, TcpStream}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::thread::{self, JoinHandle}; +use std::time::Duration; + +const READ_CHUNK_BYTES: usize = 32 * 1024; + +/// Pause after a failed `accept`. +const ACCEPT_FAILURE_BACKOFF: Duration = Duration::from_millis(200); + +/// How long a tunnel waits for remote data before checking whether the +/// local side has gone away. Data ends the wait immediately. +const IDLE_CHECK: Duration = Duration::from_secs(1); + +pub struct PortForward { + local_address: SocketAddr, + stopping: Arc, + acceptor: Option>, +} + +impl PortForward { + /// Starts forwarding `bind` to `target`. Use port 0 to let the system + /// choose a free local port, then read [`PortForward::local_address`]. + pub fn start( + session: Arc, + bind: SocketAddr, + target: TunnelTarget, + ) -> io::Result { + let listener = TcpListener::bind(bind)?; + let local_address = listener.local_addr()?; + let stopping = Arc::new(AtomicBool::new(false)); + let acceptor = { + let stopping = Arc::clone(&stopping); + thread::Builder::new() + .name("remote-forward".into()) + .spawn(move || { + for connection in listener.incoming() { + if stopping.load(Ordering::Acquire) { + return; + } + let connection = match connection { + Ok(connection) => connection, + Err(_) => { + // Failures such as EMFILE repeat immediately; + // pausing keeps the acceptor from spinning. + thread::sleep(ACCEPT_FAILURE_BACKOFF); + continue; + } + }; + let session = Arc::clone(&session); + let target = target.clone(); + thread::spawn(move || { + if let Err(error) = bridge(&session, connection, target) { + eprintln!( + "lithe-remote-client: forwarded connection failed: {error}" + ); + } + }); + } + })? + }; + Ok(Self { + local_address, + stopping, + acceptor: Some(acceptor), + }) + } + + pub fn local_address(&self) -> SocketAddr { + self.local_address + } +} + +impl Drop for PortForward { + /// Stops accepting new connections. Connections already bridged continue + /// until either side closes them. + fn drop(&mut self) { + self.stopping.store(true, Ordering::Release); + // A blocking accept only returns for a connection, so make one. + let _ = TcpStream::connect(self.local_address); + if let Some(acceptor) = self.acceptor.take() { + let _ = acceptor.join(); + } + } +} + +fn bridge( + session: &RemoteSession, + local: TcpStream, + target: TunnelTarget, +) -> Result<(), ClientError> { + let _ = local.set_nodelay(true); + let tunnel = match session.open_tunnel(target) { + Ok(tunnel) => tunnel, + Err(error) => { + let _ = local.shutdown(Shutdown::Both); + return Err(error); + } + }; + let mut to_remote = local.try_clone().map_err(ClientError::Spawn)?; + let mut to_local = local; + let local_done = Arc::new(AtomicBool::new(false)); + + thread::scope(|scope| { + let local_done_writer = Arc::clone(&local_done); + let writer = tunnel.writer(); + scope.spawn(move || { + let mut buffer = vec![0_u8; READ_CHUNK_BYTES]; + let local_ended = loop { + match to_remote.read(&mut buffer) { + Ok(0) => break true, + Ok(read) => { + if writer.write(&buffer[..read]).is_err() { + break false; + } + } + Err(_) => break false, + } + }; + local_done_writer.store(true, Ordering::Release); + if local_ended { + // The local client finished sending, as HTTP/1.0 clients and + // `nc -N` do, and now waits for the reply: pass the half-close + // on after the queued data, and keep receiving. + let _ = writer.shutdown_write(); + } else { + let _ = writer.close(); + } + }); + loop { + match tunnel.next_event(IDLE_CHECK) { + Ok(Some(TunnelEvent::Data(bytes))) => { + if to_local.write_all(&bytes).is_err() { + let _ = tunnel.close(); + break; + } + } + Ok(Some(TunnelEvent::Closed(_))) | Ok(None) => break, + Err(ClientError::Timeout) => { + if local_done.load(Ordering::Acquire) && session.is_closed() { + break; + } + } + Err(_) => break, + } + } + let _ = to_local.shutdown(Shutdown::Both); + }); + Ok(()) +} diff --git a/rust/lithe-remote-client/src/launch.rs b/rust/lithe-remote-client/src/launch.rs new file mode 100644 index 0000000000..64d6c58b47 --- /dev/null +++ b/rust/lithe-remote-client/src/launch.rs @@ -0,0 +1,200 @@ +//! Probing, installing and starting the server over one connection. +//! +//! [`crate::bootstrap::Bootstrapper`] runs one command per step, so an SSH +//! host that asks for a password asks once per step. This module does all +//! steps inside one remote shell on one stream instead: +//! +//! 1. the shell reports the platform between markers; +//! 2. the client picks the artifact for it and sends one line naming its +//! version, SHA-256 and size; +//! 3. the shell keeps an installed binary that reports that SHA-256, or +//! receives exactly `size` bytes, installs them under a temporary name and +//! verifies the result; +//! 4. the shell `exec`s the server, whose preamble starts the protocol on the +//! same stream, ready for [`crate::RemoteSession::from_streams`]. +//! +//! The script crosses SSH hops, so it is a single line without `'`, `\` or +//! `!` (see [`crate::transport`]). Install directories match the bootstrapper's, +//! so both paths reuse each other's installs. + +use crate::bootstrap::{parse_probe, ProbeReport, ServerArtifact, INSTALL_HASH_PREFIX}; +use std::fmt; +use std::io::{self, Read, Write}; + +const INSTALL_PRESENT: &str = "LITHE-INSTALL-PRESENT"; +const INSTALL_NEEDED: &str = "LITHE-INSTALL-NEEDED"; +const PROBE_END: &str = "LITHE-PROBE-END"; + +/// Output accepted before the probe block ends, for example a login banner. +const NOISE_LIMIT: usize = 64 * 1024; + +/// `$1` is the install root; empty means `$HOME/.lithe-server`. The remaining +/// arguments are passed to the server, for example `--stdio`. +const LAUNCH_SCRIPT: &str = concat!( + crate::bootstrap::host_details_script!(), + "echo LITHE-PROBE-BEGIN; uname -s; uname -m; echo \"$HOME\"; echo LITHE-PROBE-END; ", + "read version sha size name || exit 90; ", + "root=\"$1\"; [ -n \"$root\" ] || root=\"$HOME/.lithe-server\"; ", + "dir=\"$root/bin/$name\"; exe=\"$dir/lithe-remote-server\"; ", + "if \"$exe\" --identity 2>/dev/null | grep -q \"$sha\"; then echo LITHE-INSTALL-PRESENT; else ", + "echo LITHE-INSTALL-NEEDED; umask 077; ", + "mkdir -p \"$dir\" && head -c \"$size\" > \"$dir/.upload.$$\" && chmod 700 \"$dir/.upload.$$\" ", + "&& mv -f \"$dir/.upload.$$\" \"$exe\" || { echo LITHE-INSTALL-FAILED >&2; exit 92; }; ", + "\"$exe\" --identity 2>/dev/null | grep -q \"$sha\" || { echo LITHE-INSTALL-FAILED >&2; exit 93; }; ", + "fi; shift; exec \"$exe\" \"$@\"" +); + +/// Command to run at the end of a chain. `root` overrides the install root, +/// for hosts with a read-only home or for tests; `server_arguments` start +/// the server, for example `["--stdio"]`. +pub fn launch_command(root: Option<&str>, server_arguments: &[&str]) -> Vec { + let mut command = vec![ + "sh".into(), + "-c".into(), + LAUNCH_SCRIPT.into(), + "lithe-launch".into(), + root.unwrap_or_default().into(), + ]; + command.extend(server_arguments.iter().map(|argument| argument.to_string())); + command +} + +/// What happened before the server took over the stream. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Launched { + pub report: ProbeReport, + /// Whether this launch uploaded the binary. + pub uploaded: bool, +} + +#[derive(Debug)] +pub enum LaunchError { + /// The stream ended before the shell reported its platform, usually + /// because the connection or authentication failed. + NoShell, + /// The caller has no artifact for the reported platform. + NoArtifact(Box), + /// The shell answered something other than this script's protocol. + Protocol(String), + Io(io::Error), +} + +impl fmt::Display for LaunchError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NoShell => formatter.write_str("the remote shell did not start"), + Self::NoArtifact(report) => write!( + formatter, + "no remote server is available for {} {}", + report.system, report.machine + ), + Self::Protocol(message) => write!(formatter, "unexpected remote output: {message}"), + Self::Io(error) => write!(formatter, "remote stream failed: {error}"), + } + } +} + +impl std::error::Error for LaunchError {} + +/// Drives the script on a started connection. `output` is read one byte at +/// a time so that nothing after the script's lines is consumed; the session +/// reads the rest. Blocking reads end when the caller closes the connection. +pub fn launch( + output: &mut impl Read, + input: &mut impl Write, + artifact_for: impl FnOnce(&ProbeReport) -> Option, +) -> Result { + let mut probe_output = Vec::new(); + loop { + let line = read_line(output, NOISE_LIMIT.saturating_sub(probe_output.len()))? + .ok_or(LaunchError::NoShell)?; + probe_output.extend_from_slice(&line); + probe_output.push(b'\n'); + if String::from_utf8_lossy(&line).trim_end() == PROBE_END { + break; + } + } + let report = parse_probe(&probe_output).ok_or(LaunchError::NoShell)?; + let artifact = + artifact_for(&report).ok_or_else(|| LaunchError::NoArtifact(Box::new(report.clone())))?; + let binary = std::fs::read(&artifact.path).map_err(LaunchError::Io)?; + let name = format!( + "{}-{}", + artifact.version, + &artifact.sha256[..INSTALL_HASH_PREFIX.min(artifact.sha256.len())] + ); + for field in [&artifact.version, &artifact.sha256, &name] { + if field.is_empty() + || !field + .chars() + .all(|c| c.is_ascii_alphanumeric() || "._-".contains(c)) + { + return Err(LaunchError::Protocol(format!( + "invalid artifact field {field:?}" + ))); + } + } + writeln!( + input, + "{} {} {} {name}", + artifact.version, + artifact.sha256, + binary.len() + ) + .and_then(|()| input.flush()) + .map_err(LaunchError::Io)?; + + let answer = read_line(output, NOISE_LIMIT)?.ok_or(LaunchError::NoShell)?; + let answer = String::from_utf8_lossy(&answer).trim_end().to_owned(); + let uploaded = match answer.as_str() { + INSTALL_PRESENT => false, + INSTALL_NEEDED => { + input + .write_all(&binary) + .and_then(|()| input.flush()) + .map_err(LaunchError::Io)?; + true + } + other => return Err(LaunchError::Protocol(other.to_owned())), + }; + Ok(Launched { report, uploaded }) +} + +/// Reads up to a newline. `None` at end of stream before any byte. +fn read_line(output: &mut impl Read, limit: usize) -> Result>, LaunchError> { + let mut line = Vec::new(); + let mut byte = [0_u8; 1]; + loop { + match output.read(&mut byte) { + Ok(0) if line.is_empty() => return Ok(None), + Ok(0) => return Ok(Some(line)), + Ok(_) if byte[0] == b'\n' => return Ok(Some(line)), + Ok(_) => { + if line.len() >= limit { + return Err(LaunchError::Protocol( + "too much output before the server".into(), + )); + } + line.push(byte[0]); + } + Err(error) if error.kind() == io::ErrorKind::Interrupted => {} + Err(error) => return Err(LaunchError::Io(error)), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // The script crosses SSH hops through the transport's quoting, which + // rejects these characters instead of escaping them. + #[test] + fn the_script_can_cross_an_ssh_hop() { + assert!(!LAUNCH_SCRIPT + .chars() + .any(|character| matches!(character, '\'' | '\\' | '!') || character.is_control())); + let chain = crate::TransportChain::new(vec![crate::Hop::Ssh(crate::SshHop::new("box"))]); + chain.command(&launch_command(None, &["--stdio"])).unwrap(); + } +} diff --git a/rust/lithe-remote-client/src/lib.rs b/rust/lithe-remote-client/src/lib.rs new file mode 100644 index 0000000000..325dc11a45 --- /dev/null +++ b/rust/lithe-remote-client/src/lib.rs @@ -0,0 +1,31 @@ +//! Client side of Lithe remote workspaces. +//! +//! - [`transport`] turns a chain of hops (SSH, container exec) into the local +//! command line that runs a program at the end of the chain. +//! - [`bootstrap`] makes sure a matching `lithe-remote-server` is installed at +//! the end of the chain, uploading it through the same chain if needed. +//! - [`launch`] probes, installs and starts the server over one connection, +//! so a host that asks for a password asks only once. +//! - [`session`] starts the server, exchanges protocol messages with it and +//! routes each resource's events to its subscriber. +//! - [`api`] offers typed operations and resource handles on a session. +//! - [`forward`] forwards local ports through tunnels. +//! +//! The transport layer deliberately knows nothing about files or processes. +//! Supporting a new kind of remote means adding a hop, not new capabilities. +//! +//! Note: .agents/notes/proposed/architecture/2026-10-24-remote-development-workspace-host.md + +pub mod api; +pub mod bootstrap; +pub mod command; +pub mod forward; +pub mod launch; +pub mod session; +pub mod transport; + +pub use api::{SpawnSpec, TerminalSpec, WriteOptions}; +pub use bootstrap::{Bootstrapper, InstallLocation, ServerArtifact, ServerTarget}; +pub use forward::PortForward; +pub use session::{ClientError, ConnectOptions, Event, RemoteSession, ResumeMode}; +pub use transport::{ContainerEngine, ContainerHop, Hop, SshHop, TransportChain, TransportError}; diff --git a/rust/lithe-remote-client/src/session.rs b/rust/lithe-remote-client/src/session.rs new file mode 100644 index 0000000000..8c03b794f2 --- /dev/null +++ b/rust/lithe-remote-client/src/session.rs @@ -0,0 +1,729 @@ +//! A connected remote server. +//! +//! One reader thread owns the incoming stream. It routes each response to the +//! request waiting for it, and each event either to the subscriber of its +//! resource (`terminal.output` with `terminalId` 7 goes to the subscription +//! for terminal 7) or, without one, to the session's general event channel. +//! +//! Every channel is bounded and the reader blocks when one is full, which +//! stops reading the server's stream and so applies backpressure all the way +//! to the remote process. Consumers must therefore drain what they subscribe +//! to, and either drain the general channel or drop its receiver. +//! +//! A writer thread owns the outgoing stream. Requests queue their frames for +//! it, so a peer that stops reading blocks only that thread, and every request +//! still ends at its own deadline. + +use crate::transport::{TransportChain, TransportError}; +use lithe_remote_protocol::frame::{read_frame, skip_preamble, write_frame}; +use lithe_remote_protocol::methods::{ + self, AckParams, HelloParams, HelloResult, ResumeParams, ResumeResult, +}; +use lithe_remote_protocol::{FrameError, Message, RemoteError, PROTOCOL_VERSION}; +use serde::de::DeserializeOwned; +use serde::Serialize; +use serde_json::Value; +use std::collections::{HashMap, VecDeque}; +use std::fmt; +use std::io::{self, BufReader, Read, Write}; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::mpsc::{self, Receiver, SyncSender}; +use std::sync::{Arc, Condvar, Mutex}; +use std::thread; +use std::time::{Duration, Instant}; + +/// Events buffered per channel before the reader blocks. +const EVENT_CAPACITY: usize = 1024; + +/// Bytes accepted before the preamble, for example a login banner. +const PREAMBLE_NOISE_LIMIT: usize = 64 * 1024; + +/// Tail of the transport's standard error kept for diagnostics. +const STDERR_TAIL_BYTES: usize = 8 * 1024; + +/// Events received between two acknowledgements. The session keeps every +/// unacknowledged event for redelivery, so this bounds what a reconnecting +/// client may receive twice in the worst case and what the server retains. +const ACK_INTERVAL: u64 = 256; + +/// Bytes of request frames queued for the writer thread. Requests wait for +/// room within their deadline; one frame larger than this is still accepted +/// when the queue is empty. +const OUTGOING_QUEUE_BYTES: usize = 32 * 1024 * 1024; + +/// Time the server gets to exit after `server.shutdown` before it is killed. +const SHUTDOWN_GRACE: Duration = Duration::from_secs(2); +const EXIT_POLL_INTERVAL: Duration = Duration::from_millis(20); + +/// Server-initiated notification. +#[derive(Clone, Debug, PartialEq)] +pub struct Event { + pub event: String, + pub params: Value, + /// Position in the session's event stream. + pub seq: Option, +} + +impl Event { + /// Resource this event belongs to: `("terminal", 7)` for a + /// `terminal.output` event with `terminalId` 7. + pub fn resource(&self) -> Option<(&str, u64)> { + let family = self.event.split('.').next()?; + let id = self.params.get(format!("{family}Id"))?.as_u64()?; + Some((family, id)) + } + + /// Decodes the parameters into a typed event. + pub fn decode(&self) -> Result { + serde_json::from_value(self.params.clone()) + .map_err(|error| ClientError::Protocol(format!("{}: {error}", self.event))) + } +} + +#[derive(Debug)] +pub enum ClientError { + /// The server answered with an error. + Remote(RemoteError), + Transport(TransportError), + /// The transport command could not be started. + Spawn(io::Error), + /// The stream ended or broke. `diagnostics` holds the transport's last + /// standard-error output, which names SSH or container failures. + Disconnected { + diagnostics: String, + }, + /// No response arrived in time. A late response is discarded. + Timeout, + /// The peer sent something that is not this protocol. + Protocol(String), +} + +impl fmt::Display for ClientError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Remote(error) => error.fmt(formatter), + Self::Transport(error) => error.fmt(formatter), + Self::Spawn(error) => write!(formatter, "cannot start the connection: {error}"), + Self::Disconnected { diagnostics } if diagnostics.is_empty() => { + formatter.write_str("the remote connection closed") + } + Self::Disconnected { diagnostics } => { + write!(formatter, "the remote connection closed: {diagnostics}") + } + Self::Timeout => formatter.write_str("the remote server did not answer in time"), + Self::Protocol(message) => write!(formatter, "remote protocol error: {message}"), + } + } +} + +impl std::error::Error for ClientError {} + +type Reply = Result; + +#[derive(Default)] +struct Pending { + /// Set once the stream ended; new requests fail immediately. + closed: bool, + waiting: HashMap>, +} + +type Routes = Mutex>>; + +/// Options for establishing a session. +#[derive(Clone, Debug)] +pub struct ConnectOptions { + pub client_name: String, + /// Bounds transport start-up, the preamble and `server.hello` together. + pub handshake_timeout: Duration, + /// How long a persistent session keeps running without a client. + pub idle_timeout: Option, + pub resume: ResumeMode, +} + +/// When event delivery starts after the handshake. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ResumeMode { + /// Right after `server.hello`, with every kept event after the given + /// sequence number (`None`: every kept event). Suits new sessions. + Immediately { after_seq: Option }, + /// Only when the caller calls [`RemoteSession::resume`]. A reconnecting + /// client first reads `server.state` and subscribes to the resources it + /// reattaches to, so their missed events reach those subscriptions. + Deferred, +} + +impl Default for ConnectOptions { + fn default() -> Self { + Self { + client_name: "lithe".into(), + handshake_timeout: Duration::from_secs(30), + idle_timeout: None, + resume: ResumeMode::Immediately { after_seq: None }, + } + } +} + +/// Frames waiting for the writer thread. +#[derive(Default)] +struct OutgoingState { + frames: VecDeque<(u64, Vec)>, + bytes: usize, + /// Set when the session is dropped or a write failed. + closed: bool, +} + +#[derive(Default)] +struct Outgoing { + state: Mutex, + changed: Condvar, +} + +impl Outgoing { + /// Queues a frame, waiting until `deadline` for room. `Ok(false)` means + /// the queue is closed. + fn push(&self, id: u64, payload: Vec, deadline: Instant) -> Result { + let mut state = lock(&self.state); + loop { + if state.closed { + return Ok(false); + } + if state.frames.is_empty() || state.bytes + payload.len() <= OUTGOING_QUEUE_BYTES { + break; + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(ClientError::Timeout); + } + state = self + .changed + .wait_timeout(state, remaining) + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .0; + } + state.bytes += payload.len(); + state.frames.push_back((id, payload)); + self.changed.notify_all(); + Ok(true) + } + + /// Queues a frame only if there is room now. Used by the reader thread, + /// which must never block on the writer. + fn try_push(&self, id: u64, payload: Vec) -> bool { + let mut state = lock(&self.state); + if state.closed || state.bytes + payload.len() > OUTGOING_QUEUE_BYTES { + return false; + } + state.bytes += payload.len(); + state.frames.push_back((id, payload)); + self.changed.notify_all(); + true + } + + /// Drops a frame that was not written yet, for a request that timed out. + fn withdraw(&self, id: u64) { + let mut state = lock(&self.state); + if let Some(index) = state.frames.iter().position(|(queued, _)| *queued == id) { + if let Some((_, payload)) = state.frames.remove(index) { + state.bytes -= payload.len(); + self.changed.notify_all(); + } + } + } + + fn close(&self) { + let mut state = lock(&self.state); + state.closed = true; + state.frames.clear(); + state.bytes = 0; + self.changed.notify_all(); + } + + /// Next frame for the writer thread, or `None` once closed. + fn pop(&self) -> Option> { + let mut state = lock(&self.state); + loop { + if state.closed { + return None; + } + if let Some((_, payload)) = state.frames.pop_front() { + state.bytes -= payload.len(); + self.changed.notify_all(); + return Some(payload); + } + state = self + .changed + .wait(state) + .unwrap_or_else(|poisoned| poisoned.into_inner()); + } + } +} + +pub struct RemoteSession { + outgoing: Arc, + pending: Arc>, + routes: Arc, + next_request: Arc, + next_resource: AtomicU64, + /// Sequence number of the latest event received. + last_seq: Arc, + child: Mutex>, + stderr_tail: Arc>>, + hello: Option, +} + +impl RemoteSession { + /// Runs `server_command` at the end of `chain` and completes the handshake. + pub fn connect( + chain: &TransportChain, + server_command: &[String], + options: &ConnectOptions, + ) -> Result<(Self, Receiver), ClientError> { + let command = chain + .command(server_command) + .map_err(ClientError::Transport)?; + let (program, arguments) = command + .split_first() + .ok_or_else(|| ClientError::Protocol("empty server command".into()))?; + let mut child = Command::new(program) + .args(arguments) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(ClientError::Spawn)?; + let stdin = child.stdin.take().expect("stdin is piped"); + let stdout = child.stdout.take().expect("stdout is piped"); + let stderr_tail = Arc::new(Mutex::new(VecDeque::new())); + if let Some(stderr) = child.stderr.take() { + keep_tail(stderr, Arc::clone(&stderr_tail)); + } + Self::establish(stdout, Box::new(stdin), Some(child), stderr_tail, options) + } + + /// Uses existing streams, for example an in-process server in tests. + pub fn from_streams( + reader: impl Read + Send + 'static, + writer: impl Write + Send + 'static, + options: &ConnectOptions, + ) -> Result<(Self, Receiver), ClientError> { + Self::establish(reader, Box::new(writer), None, Arc::default(), options) + } + + fn establish( + reader: impl Read + Send + 'static, + writer: Box, + child: Option, + stderr_tail: Arc>>, + options: &ConnectOptions, + ) -> Result<(Self, Receiver), ClientError> { + let deadline = Instant::now() + options.handshake_timeout; + let pending = Arc::new(Mutex::new(Pending::default())); + let routes: Arc = Arc::default(); + let (event_sender, events) = mpsc::sync_channel(EVENT_CAPACITY); + let (ready_sender, ready) = mpsc::channel(); + let outgoing = Arc::new(Outgoing::default()); + let next_request = Arc::new(AtomicU64::new(0)); + let last_seq = Arc::new(AtomicU64::new(0)); + { + let outgoing = Arc::clone(&outgoing); + let pending = Arc::clone(&pending); + thread::Builder::new() + .name("remote-writer".into()) + .spawn(move || write_loop(writer, &outgoing, &pending)) + .map_err(ClientError::Spawn)?; + } + { + let reader_context = ReaderContext { + pending: Arc::clone(&pending), + routes: Arc::clone(&routes), + events: event_sender, + outgoing: Arc::clone(&outgoing), + next_request: Arc::clone(&next_request), + last_seq: Arc::clone(&last_seq), + }; + thread::Builder::new() + .name("remote-reader".into()) + .spawn(move || read_loop(reader, reader_context, ready_sender)) + .map_err(ClientError::Spawn)?; + } + let mut session = Self { + outgoing, + pending, + routes, + next_request, + next_resource: AtomicU64::new(1), + last_seq, + child: Mutex::new(child), + stderr_tail, + hello: None, + }; + + match ready.recv_timeout(deadline.saturating_duration_since(Instant::now())) { + Ok(Ok(())) => {} + Ok(Err(FrameError::PreambleNotFound { noise })) => { + let mut output = String::from_utf8_lossy(&noise).trim().to_owned(); + session.wait_for_transport_exit(SHUTDOWN_GRACE); + let stderr = session.diagnostics(); + if !stderr.is_empty() { + output = format!("{output}\n{stderr}").trim().to_owned(); + } + return Err(ClientError::Disconnected { + diagnostics: output, + }); + } + Ok(Err(_)) | Err(mpsc::RecvTimeoutError::Disconnected) => { + session.wait_for_transport_exit(SHUTDOWN_GRACE); + return Err(ClientError::Disconnected { + diagnostics: session.diagnostics(), + }); + } + Err(mpsc::RecvTimeoutError::Timeout) => return Err(ClientError::Timeout), + } + + let hello: HelloResult = session.request( + methods::SERVER_HELLO, + &HelloParams { + protocol_version: PROTOCOL_VERSION, + client_name: options.client_name.clone(), + idle_timeout_seconds: options.idle_timeout.map(|timeout| timeout.as_secs()), + }, + deadline.saturating_duration_since(Instant::now()), + )?; + session + .next_resource + .store(hello.session.next_resource_id.max(1), Ordering::Release); + session.hello = Some(hello); + if let ResumeMode::Immediately { after_seq } = options.resume { + session.resume(after_seq)?; + } + Ok((session, events)) + } + + /// Starts event delivery, first with every kept event after `after_seq`, + /// the [`RemoteSession::last_event_seq`] of an earlier connection to the + /// same session. Call it once, after subscribing to the resources whose + /// missed events should reach their subscriptions. + pub fn resume(&self, after_seq: Option) -> Result { + self.request( + methods::SERVER_RESUME, + &ResumeParams { after_seq }, + Duration::from_secs(30), + ) + } + + /// Sequence number of the latest event this connection received. Pass it + /// to [`RemoteSession::resume`] on the next connection to continue + /// without gaps or repeats. + pub fn last_event_seq(&self) -> u64 { + self.last_seq.load(Ordering::Acquire) + } + + /// Handshake result, including the host capabilities. + pub fn hello(&self) -> &HelloResult { + self.hello.as_ref().expect("set by establish") + } + + /// Returns a resource id that is unused in this session, also across + /// reconnections. + pub fn allocate_id(&self) -> u64 { + self.next_resource.fetch_add(1, Ordering::AcqRel) + } + + /// Routes the events of one resource to a dedicated channel. Subscribe + /// before creating the resource, so its first events are not missed. + /// Dropping the receiver ends the subscription. + pub fn subscribe(&self, family: &str, id: u64) -> Receiver { + let (sender, receiver) = mpsc::sync_channel(EVENT_CAPACITY); + lock(&self.routes).insert((family.to_owned(), id), sender); + receiver + } + + pub fn unsubscribe(&self, family: &str, id: u64) { + lock(&self.routes).remove(&(family.to_owned(), id)); + } + + /// Sends a request and waits for its typed result. + pub fn request( + &self, + method: &str, + params: &P, + timeout: Duration, + ) -> Result { + let params = serde_json::to_value(params) + .map_err(|error| ClientError::Protocol(error.to_string()))?; + let result = self.request_value(method, params, timeout)?; + serde_json::from_value(result).map_err(|error| ClientError::Protocol(error.to_string())) + } + + /// Sends a request and waits for its raw result. + pub fn request_value( + &self, + method: &str, + params: Value, + timeout: Duration, + ) -> Result { + let deadline = Instant::now() + timeout; + let id = self.next_request.fetch_add(1, Ordering::Relaxed) + 1; + let (reply_sender, reply) = mpsc::sync_channel(1); + { + let mut pending = lock(&self.pending); + if pending.closed { + return Err(self.disconnected()); + } + pending.waiting.insert(id, reply_sender); + } + let message = Message::Request { + id, + method: method.into(), + params, + }; + if let Err(error) = self.send(id, &message, deadline) { + lock(&self.pending).waiting.remove(&id); + return Err(error); + } + match reply.recv_timeout(deadline.saturating_duration_since(Instant::now())) { + Ok(Ok(value)) => Ok(value), + Ok(Err(ClientError::Disconnected { .. })) + | Err(mpsc::RecvTimeoutError::Disconnected) => Err(self.disconnected()), + Ok(Err(error)) => Err(error), + Err(mpsc::RecvTimeoutError::Timeout) => { + // Removing the waiter makes the reader drop a late response; + // a frame still queued is not sent at all. + lock(&self.pending).waiting.remove(&id); + self.outgoing.withdraw(id); + Err(ClientError::Timeout) + } + } + } + + /// Whether the stream has ended. + pub fn is_closed(&self) -> bool { + lock(&self.pending).closed + } + + /// Asks the server to stop. A `--stdio` server ends; a persistent + /// session ends together with everything it runs. + pub fn shutdown(self, timeout: Duration) { + let _ = self.request_value(methods::SERVER_SHUTDOWN, Value::Null, timeout); + // Drop closes the stream and reaps the transport process. + } + + fn send(&self, id: u64, message: &Message, deadline: Instant) -> Result<(), ClientError> { + let payload = serde_json::to_vec(message) + .map_err(|error| ClientError::Protocol(error.to_string()))?; + match self.outgoing.push(id, payload, deadline)? { + true => Ok(()), + false => Err(self.disconnected()), + } + } + + fn disconnected(&self) -> ClientError { + ClientError::Disconnected { + diagnostics: self.diagnostics(), + } + } + + fn diagnostics(&self) -> String { + let tail = lock(&self.stderr_tail); + let bytes: Vec = tail.iter().copied().collect(); + String::from_utf8_lossy(&bytes).trim().to_owned() + } + + /// Waits up to `grace` for the transport process to exit, so its final + /// standard-error output is captured for diagnostics. + fn wait_for_transport_exit(&self, grace: Duration) -> bool { + let mut child = lock(&self.child); + let Some(process) = child.as_mut() else { + return true; + }; + let deadline = Instant::now() + grace; + loop { + match process.try_wait() { + Ok(Some(_)) | Err(_) => return true, + Ok(None) if Instant::now() >= deadline => return false, + Ok(None) => thread::sleep(EXIT_POLL_INTERVAL), + } + } + } +} + +impl Drop for RemoteSession { + fn drop(&mut self) { + // The writer thread then drops standard input, which is the server's + // disconnect signal. A writer blocked on a peer that stopped reading + // is released when the transport is killed below. + self.outgoing.close(); + if !self.wait_for_transport_exit(SHUTDOWN_GRACE) { + if let Some(child) = lock(&self.child).as_mut() { + let _ = child.kill(); + let _ = child.wait(); + } + } + } +} + +/// What the reader thread shares with the session. +struct ReaderContext { + pending: Arc>, + routes: Arc, + events: SyncSender, + outgoing: Arc, + next_request: Arc, + last_seq: Arc, +} + +fn read_loop( + reader: impl Read, + context: ReaderContext, + ready: mpsc::Sender>, +) { + let ReaderContext { + pending, + routes, + events, + outgoing, + next_request, + last_seq, + } = context; + let mut reader = BufReader::new(reader); + if let Err(error) = skip_preamble(&mut reader, PREAMBLE_NOISE_LIMIT) { + let _ = ready.send(Err(error)); + close(&pending); + return; + } + let _ = ready.send(Ok(())); + let mut acknowledged = 0; + while let Ok(Some(frame)) = read_frame(&mut reader) { + match serde_json::from_slice::(&frame) { + Ok(Message::Response { id, result, error }) => { + let waiter = lock(&pending).waiting.remove(&id); + if let Some(waiter) = waiter { + let reply = match (result, error) { + (_, Some(error)) => Err(ClientError::Remote(error)), + (result, None) => Ok(result.unwrap_or(Value::Null)), + }; + let _ = waiter.send(reply); + } + } + Ok(Message::Event { event, params, seq }) => { + route(&routes, &events, Event { event, params, seq }); + if let Some(seq) = seq { + last_seq.store(seq, Ordering::Release); + if seq >= acknowledged + ACK_INTERVAL + && acknowledge(&outgoing, &next_request, seq) + { + acknowledged = seq; + } + } + } + Ok(Message::Request { .. }) | Err(_) => break, + } + } + close(&pending); + // Ending every subscription tells consumers the stream is gone. + lock(&routes).clear(); +} + +/// Queues `server.ack` without waiting for its response, which the reader +/// discards because no request waits for it. Returns `false` when the queue +/// is full; the next acknowledgement then covers this one. +fn acknowledge(outgoing: &Outgoing, next_request: &AtomicU64, seq: u64) -> bool { + let id = next_request.fetch_add(1, Ordering::Relaxed) + 1; + let message = Message::Request { + id, + method: methods::SERVER_ACK.into(), + params: serde_json::to_value(AckParams { seq }).unwrap_or(Value::Null), + }; + match serde_json::to_vec(&message) { + Ok(payload) => outgoing.try_push(id, payload), + Err(_) => false, + } +} + +fn write_loop(mut writer: Box, outgoing: &Outgoing, pending: &Mutex) { + while let Some(payload) = outgoing.pop() { + if write_frame(&mut writer, &payload).is_err() { + // A broken stream fails every waiting request now instead of at + // its deadline. + outgoing.close(); + close(pending); + return; + } + } +} + +fn route(routes: &Routes, events: &SyncSender, event: Event) { + let key = event.resource().map(|(family, id)| (family.to_owned(), id)); + // The sender is cloned so that a blocking send does not hold the lock + // a consumer needs to subscribe or unsubscribe. + let subscriber = key.as_ref().and_then(|key| lock(routes).get(key).cloned()); + match subscriber { + Some(subscriber) => { + if subscriber.send(event).is_err() { + if let Some(key) = key { + lock(routes).remove(&key); + } + } + } + // A dropped general receiver means nobody consumes these events. + None => { + let _ = events.send(event); + } + } +} + +fn close(pending: &Mutex) { + let mut pending = lock(pending); + pending.closed = true; + for (_, waiter) in pending.waiting.drain() { + let _ = waiter.send(Err(ClientError::Disconnected { + diagnostics: String::new(), + })); + } +} + +fn keep_tail(mut stderr: impl Read + Send + 'static, tail: Arc>>) { + thread::spawn(move || { + let mut buffer = [0_u8; 4096]; + loop { + match stderr.read(&mut buffer) { + Ok(0) | Err(_) => return, + Ok(read) => { + let mut tail = lock(&tail); + tail.extend(&buffer[..read]); + let excess = tail.len().saturating_sub(STDERR_TAIL_BYTES); + tail.drain(..excess); + } + } + } + }); +} + +fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn events_name_their_resource_by_family_and_id() { + let event = Event { + event: "terminal.output".into(), + params: json!({ "terminalId": 7, "data": "" }), + seq: Some(1), + }; + assert_eq!(event.resource(), Some(("terminal", 7))); + let unrelated = Event { + event: "watch.changed".into(), + params: json!({ "terminalId": 7 }), + seq: Some(2), + }; + assert_eq!(unrelated.resource(), None); + } +} diff --git a/rust/lithe-remote-client/src/transport.rs b/rust/lithe-remote-client/src/transport.rs new file mode 100644 index 0000000000..fb84dfebd6 --- /dev/null +++ b/rust/lithe-remote-client/src/transport.rs @@ -0,0 +1,556 @@ +//! Transport chains. +//! +//! A chain is an ordered list of hops from the local machine to the place +//! where the workspace lives: `[]` is the local machine, `[ssh devbox]` a +//! remote host, `[ssh devbox, docker api]` a container on that host. The only +//! thing a chain does is build the local command line that runs a program at +//! its end with standard streams connected end to end. +//! +//! An SSH hop passes its remote command to the remote user's login shell as +//! one string. That shell may be sh, bash, zsh, fish or csh, which disagree on +//! escapes, so arguments that cross an SSH hop are single-quoted and the +//! characters any of those shells treat specially inside single quotes are +//! rejected instead of being escaped. The one exception is the command line +//! an inner SSH hop generates: its own single quotes are escaped as `'"'"'`, +//! which every supported shell reads back unchanged, so SSH hops can nest. + +use std::collections::BTreeMap; +use std::fmt; + +/// Seconds between SSH keep-alive probes. Together with +/// [`SSH_KEEPALIVE_MISSES`] it bounds how long a dead network path stays +/// undetected. +const SSH_KEEPALIVE_SECONDS: u32 = 15; +const SSH_KEEPALIVE_MISSES: u32 = 3; + +/// Ordered hops from the local machine to the workspace host. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct TransportChain { + pub hops: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Hop { + Ssh(SshHop), + Container(ContainerHop), +} + +/// Connection through the system OpenSSH client. Host aliases, jump hosts, +/// keys and agents come from the user's own SSH configuration, so the hop +/// only carries what the user typed. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SshHop { + /// SSH executable, resolved by the platform adapter. Defaults to `ssh`. + pub program: String, + /// `host`, `user@host` or a `Host` alias from the SSH configuration. + pub destination: String, + pub port: Option, + /// Extra `-o key=value` options, in order. + pub options: Vec<(String, String)>, +} + +impl SshHop { + pub fn new(destination: impl Into) -> Self { + Self { + program: "ssh".into(), + destination: destination.into(), + port: None, + options: Vec::new(), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ContainerEngine { + Docker, + Podman, +} + +impl ContainerEngine { + fn name(self) -> &'static str { + match self { + Self::Docker => "docker", + Self::Podman => "podman", + } + } +} + +/// `exec` into a running container. Docker-compatible CLIs such as OrbStack +/// and Colima use the Docker engine with their own `program`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ContainerHop { + pub engine: ContainerEngine, + /// Engine CLI executable. Defaults to the engine name. + pub program: String, + /// Container name or id. + pub container: String, + /// `user`, `uid` or `user:group`; the image default when absent. + pub user: Option, + pub working_directory: Option, + pub environment: BTreeMap, +} + +impl ContainerHop { + pub fn new(engine: ContainerEngine, container: impl Into) -> Self { + Self { + engine, + program: engine.name().into(), + container: container.into(), + user: None, + working_directory: None, + environment: BTreeMap::new(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum TransportError { + /// An argument that must cross an SSH hop contains a character that + /// cannot be passed safely to every supported login shell. + UnsafeArgument { argument: String }, + /// A hop field is malformed, for example a destination starting with `-` + /// that SSH would parse as an option. + InvalidHop { reason: String }, +} + +impl fmt::Display for TransportError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnsafeArgument { argument } => write!( + formatter, + "argument {argument:?} contains a character that cannot cross an SSH hop" + ), + Self::InvalidHop { reason } => formatter.write_str(reason), + } + } +} + +impl std::error::Error for TransportError {} + +impl TransportChain { + pub fn local() -> Self { + Self::default() + } + + pub fn new(hops: Vec) -> Self { + Self { hops } + } + + /// Local command line that runs `argv` at the end of the chain. + pub fn command(&self, argv: &[String]) -> Result, TransportError> { + let mut current: Vec = argv.iter().cloned().map(Word::Raw).collect(); + for hop in self.hops.iter().rev() { + current = match hop { + Hop::Ssh(hop) => ssh_command(hop, ¤t)?, + Hop::Container(hop) => container_command(hop, current)?, + }; + } + Ok(current.into_iter().map(Word::into_string).collect()) + } + + /// Stable identity of the chain, for example `ssh:devbox/docker:api`. + /// Recent-project lists and per-host storage key on it, so it ignores + /// settings such as keep-alive options that do not change the target. + pub fn identity(&self) -> String { + if self.hops.is_empty() { + return "local".into(); + } + self.hops + .iter() + .map(|hop| match hop { + Hop::Ssh(hop) => match hop.port { + Some(port) => format!("ssh:{}:{port}", hop.destination), + None => format!("ssh:{}", hop.destination), + }, + Hop::Container(hop) => format!("{}:{}", hop.engine.name(), hop.container), + }) + .collect::>() + .join("/") + } +} + +/// One argument of a command line under construction. +#[derive(Clone, Debug)] +enum Word { + /// Caller or hop input, validated before it crosses an SSH hop. + Raw(String), + /// Remote command string built by [`shell_join`] for an inner SSH hop. + Generated(String), +} + +impl Word { + fn into_string(self) -> String { + match self { + Self::Raw(text) | Self::Generated(text) => text, + } + } +} + +fn ssh_command(hop: &SshHop, remote: &[Word]) -> Result, TransportError> { + validate_ssh_destination(&hop.destination)?; + let mut command: Vec = vec![ + hop.program.clone(), + // No pseudo-terminal: the protocol stream must stay byte-exact. + "-T".into(), + // Never interpret `~.` style escapes inside the binary stream. + "-e".into(), + "none".into(), + ]; + if let Some(port) = hop.port { + command.push("-p".into()); + command.push(port.to_string()); + } + // ssh keeps the first value it sees for each option, so the hop's own + // options precede the keep-alive defaults and can override them. + for (key, value) in &hop.options { + if key.is_empty() + || !key + .chars() + .all(|character| character.is_ascii_alphanumeric()) + { + return Err(TransportError::InvalidHop { + reason: format!("invalid SSH option name {key:?}"), + }); + } + command.push("-o".into()); + command.push(format!("{key}={value}")); + } + command.extend([ + "-o".into(), + format!("ServerAliveInterval={SSH_KEEPALIVE_SECONDS}"), + "-o".into(), + format!("ServerAliveCountMax={SSH_KEEPALIVE_MISSES}"), + ]); + command.push("--".into()); + command.push(hop.destination.clone()); + let remote_command = shell_join(remote)?; + let mut words: Vec = command.into_iter().map(Word::Raw).collect(); + words.push(Word::Generated(remote_command)); + Ok(words) +} + +fn container_command(hop: &ContainerHop, inner: Vec) -> Result, TransportError> { + if !is_container_reference(&hop.container) { + return Err(TransportError::InvalidHop { + reason: format!("invalid container name {:?}", hop.container), + }); + } + let mut command = vec![hop.program.clone(), "exec".into(), "-i".into()]; + if let Some(user) = &hop.user { + if user.is_empty() + || user.starts_with('-') + || !user + .chars() + .all(|character| character.is_ascii_alphanumeric() || "_.:-".contains(character)) + { + return Err(TransportError::InvalidHop { + reason: format!("invalid container user {user:?}"), + }); + } + command.push("-u".into()); + command.push(user.clone()); + } + if let Some(directory) = &hop.working_directory { + command.push("-w".into()); + command.push(directory.clone()); + } + for (key, value) in &hop.environment { + if !is_environment_name(key) { + return Err(TransportError::InvalidHop { + reason: format!("invalid environment variable name {key:?}"), + }); + } + command.push("-e".into()); + command.push(format!("{key}={value}")); + } + // The engine stops parsing its own options at the container name, so the + // inner command needs no separator. + command.push(hop.container.clone()); + let mut words: Vec = command.into_iter().map(Word::Raw).collect(); + words.extend(inner); + Ok(words) +} + +fn validate_ssh_destination(destination: &str) -> Result<(), TransportError> { + if destination.is_empty() + || destination.starts_with('-') + || destination + .chars() + .any(|character| character.is_whitespace() || character.is_control()) + { + return Err(TransportError::InvalidHop { + reason: format!("invalid SSH destination {destination:?}"), + }); + } + Ok(()) +} + +fn is_container_reference(name: &str) -> bool { + let mut characters = name.chars(); + matches!(characters.next(), Some(first) if first.is_ascii_alphanumeric()) + && characters + .all(|character| character.is_ascii_alphanumeric() || "_.-".contains(character)) +} + +fn is_environment_name(name: &str) -> bool { + let mut characters = name.chars(); + matches!(characters.next(), Some(first) if first.is_ascii_alphabetic() || first == '_') + && characters.all(|character| character.is_ascii_alphanumeric() || character == '_') +} + +/// Joins arguments into one string that every supported login shell splits +/// back into the same arguments. +fn shell_join(argv: &[Word]) -> Result { + argv.iter() + .map(|word| match word { + Word::Raw(argument) => shell_quote(argument), + Word::Generated(command) => Ok(quote_generated(command)), + }) + .collect::, _>>() + .map(|quoted| quoted.join(" ")) +} + +/// Quotes a command line produced by [`shell_join`]. It is built only from +/// validated arguments, so it holds no `\`, `!` or control characters, and +/// its own single quotes leave the quote, appear inside double quotes, and +/// reopen it. +fn quote_generated(command: &str) -> String { + format!("'{}'", command.replace('\'', "'\"'\"'")) +} + +fn shell_quote(argument: &str) -> Result { + // `'` ends the quote everywhere, `\` escapes inside quotes in fish, `!` + // triggers history expansion in csh, and line breaks end commands. + if argument + .chars() + .any(|character| matches!(character, '\'' | '\\' | '!') || character.is_control()) + { + return Err(TransportError::UnsafeArgument { + argument: argument.into(), + }); + } + let plain = !argument.is_empty() + && argument + .chars() + .all(|character| character.is_ascii_alphanumeric() || "_./:=@%+,-".contains(character)); + Ok(if plain { + argument.into() + } else { + format!("'{argument}'") + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn strings(values: &[&str]) -> Vec { + values.iter().map(|value| value.to_string()).collect() + } + + #[test] + fn local_chain_runs_the_command_unchanged() { + let argv = strings(&["lithe-remote-server", "--stdio"]); + assert_eq!(TransportChain::local().command(&argv).unwrap(), argv); + assert_eq!(TransportChain::local().identity(), "local"); + } + + #[test] + fn ssh_hop_quotes_the_remote_command_and_guards_the_destination() { + let mut hop = SshHop::new("dev@box"); + hop.port = Some(2222); + hop.options.push(("ConnectTimeout".into(), "10".into())); + let chain = TransportChain::new(vec![Hop::Ssh(hop)]); + let command = chain + .command(&strings(&["sh", "-c", "echo \"$HOME\" && uname -s"])) + .unwrap(); + assert_eq!( + command, + strings(&[ + "ssh", + "-T", + "-e", + "none", + "-p", + "2222", + "-o", + "ConnectTimeout=10", + "-o", + "ServerAliveInterval=15", + "-o", + "ServerAliveCountMax=3", + "--", + "dev@box", + "sh -c 'echo \"$HOME\" && uname -s'", + ]) + ); + assert_eq!(chain.identity(), "ssh:dev@box:2222"); + } + + // A destination such as `-oProxyCommand=...` would otherwise be parsed by + // ssh as an option and run an arbitrary local command. + #[test] + fn option_like_destinations_are_rejected() { + let chain = TransportChain::new(vec![Hop::Ssh(SshHop::new("-oProxyCommand=touch x"))]); + assert!(matches!( + chain.command(&strings(&["true"])), + Err(TransportError::InvalidHop { .. }) + )); + } + + #[test] + fn arguments_that_shells_disagree_on_are_rejected() { + let chain = TransportChain::new(vec![Hop::Ssh(SshHop::new("box"))]); + for argument in ["it's", "a\\b", "hi!", "two\nlines"] { + assert_eq!( + chain.command(&strings(&["echo", argument])), + Err(TransportError::UnsafeArgument { + argument: argument.into() + }) + ); + } + assert_eq!( + chain + .command(&strings(&["printf", ""])) + .unwrap() + .last() + .unwrap(), + "printf ''" + ); + } + + // Each SSH hop hands its remote command to a login shell, so the command + // an inner hop generates must survive one more round of parsing. A fake + // `ssh` runs that string with a real shell, for every supported login + // shell installed here. + #[test] + fn nested_ssh_hops_deliver_arguments_unchanged() { + use std::os::unix::fs::PermissionsExt; + + let directory = std::env::temp_dir().join(format!( + "lithe-remote-nested-ssh-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + std::fs::create_dir_all(&directory).unwrap(); + let _cleanup = RemoveOnDrop(directory.clone()); + let argv = strings(&[ + "sh", + "-c", + "printf %s/%s \"$0\" \"$1\"", + "two words", + "$HOME \"quoted\" * ; |", + ]); + let mut tested = 0; + for shell in [ + "/bin/sh", + "/bin/bash", + "/bin/zsh", + "/bin/csh", + "/usr/bin/fish", + ] { + if !std::path::Path::new(shell).exists() { + continue; + } + let fake_ssh = directory.join(format!("ssh-{}", shell.rsplit('/').next().unwrap())); + std::fs::write( + &fake_ssh, + format!("#!/bin/sh\nfor last; do :; done\nexec {shell} -c \"$last\"\n"), + ) + .unwrap(); + std::fs::set_permissions(&fake_ssh, std::fs::Permissions::from_mode(0o755)).unwrap(); + let hop = || { + let mut hop = SshHop::new("box"); + hop.program = fake_ssh.to_str().unwrap().into(); + Hop::Ssh(hop) + }; + let chain = TransportChain::new(vec![hop(), hop(), hop()]); + let output = crate::command::run( + &chain.command(&argv).unwrap(), + None, + std::time::Duration::from_secs(10), + ) + .unwrap(); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + "two words/$HOME \"quoted\" * ; |", + "through {shell}: {}", + output.stderr_text() + ); + tested += 1; + } + assert!(tested > 0, "no supported shell found"); + } + + struct RemoveOnDrop(std::path::PathBuf); + + impl Drop for RemoveOnDrop { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn local_container_hop_passes_arguments_without_a_shell() { + let mut hop = ContainerHop::new(ContainerEngine::Podman, "api-1"); + hop.user = Some("1000:1000".into()); + hop.working_directory = Some("/workspace".into()); + hop.environment.insert("LANG".into(), "C.UTF-8".into()); + let chain = TransportChain::new(vec![Hop::Container(hop)]); + // No SSH hop, so characters that are unsafe for shells are allowed. + assert_eq!( + chain.command(&strings(&["echo", "it's"])).unwrap(), + strings(&[ + "podman", + "exec", + "-i", + "-u", + "1000:1000", + "-w", + "/workspace", + "-e", + "LANG=C.UTF-8", + "api-1", + "echo", + "it's", + ]) + ); + assert_eq!(chain.identity(), "podman:api-1"); + } + + #[test] + fn container_behind_ssh_nests_the_exec_inside_the_remote_command() { + let chain = TransportChain::new(vec![ + Hop::Ssh(SshHop::new("devbox")), + Hop::Container(ContainerHop::new(ContainerEngine::Docker, "api")), + ]); + let command = chain + .command(&strings(&[ + "/home/dev/.lithe-server/bin/1/lithe-remote-server", + "--stdio", + ])) + .unwrap(); + assert_eq!( + command.last().unwrap(), + "docker exec -i api /home/dev/.lithe-server/bin/1/lithe-remote-server --stdio" + ); + assert_eq!(&command[command.len() - 2], "devbox"); + assert_eq!(chain.identity(), "ssh:devbox/docker:api"); + } + + #[test] + fn malformed_container_fields_are_rejected() { + for hop in [ + ContainerHop::new(ContainerEngine::Docker, "-it"), + ContainerHop { + user: Some("--privileged".into()), + ..ContainerHop::new(ContainerEngine::Docker, "api") + }, + ] { + assert!(matches!( + TransportChain::new(vec![Hop::Container(hop)]).command(&strings(&["true"])), + Err(TransportError::InvalidHop { .. }) + )); + } + } +} diff --git a/rust/lithe-db-mcp/Cargo.toml b/rust/lithe-remote-protocol/Cargo.toml similarity index 57% rename from rust/lithe-db-mcp/Cargo.toml rename to rust/lithe-remote-protocol/Cargo.toml index 00afd2c7dc..569bf20afa 100644 --- a/rust/lithe-db-mcp/Cargo.toml +++ b/rust/lithe-remote-protocol/Cargo.toml @@ -1,11 +1,12 @@ [package] -name = "lithe-db-mcp" +name = "lithe-remote-protocol" version = "0.1.0" edition = "2021" license = "Apache-2.0" +description = "Wire protocol between Lithe and its remote workspace server" publish = false [dependencies] +base64 = "0.22" serde = { version = "1", features = ["derive"] } serde_json = "1" -uuid = { version = "1", features = ["v4"] } diff --git a/rust/lithe-remote-protocol/src/frame.rs b/rust/lithe-remote-protocol/src/frame.rs new file mode 100644 index 0000000000..4257be9bf5 --- /dev/null +++ b/rust/lithe-remote-protocol/src/frame.rs @@ -0,0 +1,207 @@ +//! Length-prefixed framing and the stream preamble. +//! +//! A remote command's standard output is not guaranteed to be clean: shell +//! startup files on the remote host may print banners before the server runs. +//! The server therefore writes [`PREAMBLE`] once, and the client discards any +//! bytes before it. After the preamble, every frame is a 4-byte big-endian +//! payload length followed by that many bytes of UTF-8 JSON. + +use std::fmt; +use std::io::{self, Read, Write}; + +/// Marker written by the server before its first frame. +/// +/// It starts with a newline so that it is recognized even when a startup +/// banner did not end its last line. +pub const PREAMBLE: &[u8] = b"\nLITHE-REMOTE-PROTOCOL/1\n"; + +/// Largest accepted frame payload in bytes. +/// +/// Bulk transfers are chunked below this size, so a larger length prefix +/// means the stream is corrupt or not a Lithe server at all. +pub const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024; + +/// Framing failure on a protocol stream. +#[derive(Debug)] +pub enum FrameError { + /// The underlying stream failed. + Io(io::Error), + /// The stream ended inside a frame or before the preamble. + UnexpectedEof, + /// A length prefix exceeded [`MAX_FRAME_BYTES`]. + TooLarge(usize), + /// More than the allowed number of bytes arrived before the preamble. + /// The captured bytes usually explain the failure, for example + /// `lithe-remote-server: not found`. + PreambleNotFound { noise: Vec }, +} + +impl fmt::Display for FrameError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(error) => write!(formatter, "protocol stream failed: {error}"), + Self::UnexpectedEof => formatter.write_str("protocol stream ended unexpectedly"), + Self::TooLarge(length) => write!( + formatter, + "protocol frame of {length} bytes exceeds the {MAX_FRAME_BYTES}-byte limit" + ), + Self::PreambleNotFound { .. } => { + formatter.write_str("the remote command did not start a Lithe server") + } + } + } +} + +impl std::error::Error for FrameError {} + +impl From for FrameError { + fn from(error: io::Error) -> Self { + Self::Io(error) + } +} + +/// Writes one frame and flushes it. +pub fn write_frame(writer: &mut impl Write, payload: &[u8]) -> Result<(), FrameError> { + if payload.len() > MAX_FRAME_BYTES { + return Err(FrameError::TooLarge(payload.len())); + } + let length = u32::try_from(payload.len()).map_err(|_| FrameError::TooLarge(payload.len()))?; + writer.write_all(&length.to_be_bytes())?; + writer.write_all(payload)?; + writer.flush()?; + Ok(()) +} + +/// Reads one frame. Returns `Ok(None)` when the stream ends cleanly between +/// frames, which is how a peer signals disconnection. +pub fn read_frame(reader: &mut impl Read) -> Result>, FrameError> { + let mut prefix = [0_u8; 4]; + let mut filled = 0; + while filled < prefix.len() { + match reader.read(&mut prefix[filled..]) { + Ok(0) if filled == 0 => return Ok(None), + Ok(0) => return Err(FrameError::UnexpectedEof), + Ok(read) => filled += read, + Err(error) if error.kind() == io::ErrorKind::Interrupted => {} + Err(error) => return Err(error.into()), + } + } + let length = u32::from_be_bytes(prefix) as usize; + if length > MAX_FRAME_BYTES { + return Err(FrameError::TooLarge(length)); + } + let mut payload = vec![0_u8; length]; + reader.read_exact(&mut payload).map_err(|error| { + if error.kind() == io::ErrorKind::UnexpectedEof { + FrameError::UnexpectedEof + } else { + FrameError::Io(error) + } + })?; + Ok(Some(payload)) +} + +/// Discards bytes until [`PREAMBLE`] has been read. +/// +/// At most `noise_limit` bytes may precede the preamble. The caller bounds +/// the wall-clock wait separately, because a silent stream never fails here. +pub fn skip_preamble(reader: &mut impl Read, noise_limit: usize) -> Result<(), FrameError> { + let mut window: Vec = Vec::with_capacity(PREAMBLE.len()); + let mut noise: Vec = Vec::new(); + let mut byte = [0_u8; 1]; + loop { + match reader.read(&mut byte) { + Ok(0) => { + noise.extend_from_slice(&window); + return Err(if noise.is_empty() { + FrameError::UnexpectedEof + } else { + FrameError::PreambleNotFound { noise } + }); + } + Ok(_) => {} + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + Err(error) => return Err(error.into()), + } + window.push(byte[0]); + if window.len() > PREAMBLE.len() { + noise.push(window.remove(0)); + } + if window == PREAMBLE { + return Ok(()); + } + if noise.len() > noise_limit { + return Err(FrameError::PreambleNotFound { noise }); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn frames_round_trip_and_clean_eof_is_not_an_error() { + let mut stream = Vec::new(); + write_frame(&mut stream, b"{\"a\":1}").unwrap(); + write_frame(&mut stream, b"").unwrap(); + let mut reader = stream.as_slice(); + assert_eq!(read_frame(&mut reader).unwrap().unwrap(), b"{\"a\":1}"); + assert_eq!(read_frame(&mut reader).unwrap().unwrap(), b""); + assert!(read_frame(&mut reader).unwrap().is_none()); + } + + #[test] + fn truncated_frame_is_reported() { + let mut stream = Vec::new(); + write_frame(&mut stream, b"abcdef").unwrap(); + stream.truncate(stream.len() - 2); + assert!(matches!( + read_frame(&mut stream.as_slice()), + Err(FrameError::UnexpectedEof) + )); + assert!(matches!( + read_frame(&mut [0_u8, 0].as_slice()), + Err(FrameError::UnexpectedEof) + )); + } + + #[test] + fn oversized_length_prefix_is_rejected_before_allocation() { + let prefix = ((MAX_FRAME_BYTES + 1) as u32).to_be_bytes(); + assert!(matches!( + read_frame(&mut prefix.as_slice()), + Err(FrameError::TooLarge(_)) + )); + } + + // Shell startup files often print banners without a trailing newline; + // the preamble must still be found and the frame after it intact. + #[test] + fn preamble_is_found_after_shell_noise() { + let mut stream = b"Welcome to devbox\nlast login: today".to_vec(); + stream.extend_from_slice(PREAMBLE); + write_frame(&mut stream, b"{}").unwrap(); + let mut reader = stream.as_slice(); + skip_preamble(&mut reader, 1024).unwrap(); + assert_eq!(read_frame(&mut reader).unwrap().unwrap(), b"{}"); + } + + #[test] + fn missing_server_reports_the_captured_output() { + let output = b"sh: 1: lithe-remote-server: not found\n"; + match skip_preamble(&mut output.as_slice(), 1024) { + Err(FrameError::PreambleNotFound { noise }) => assert_eq!(noise, output), + other => panic!("unexpected result: {other:?}"), + } + } + + #[test] + fn unbounded_noise_stops_at_the_limit() { + let output = vec![b'x'; 4096]; + match skip_preamble(&mut output.as_slice(), 100) { + Err(FrameError::PreambleNotFound { noise }) => assert_eq!(noise.len(), 101), + other => panic!("unexpected result: {other:?}"), + } + } +} diff --git a/rust/lithe-remote-protocol/src/lib.rs b/rust/lithe-remote-protocol/src/lib.rs new file mode 100644 index 0000000000..4af13c990f --- /dev/null +++ b/rust/lithe-remote-protocol/src/lib.rs @@ -0,0 +1,23 @@ +//! Wire protocol shared by the Lithe client and `lithe-remote-server`. +//! +//! The protocol is transport-agnostic: SSH, `docker exec`, chained hops and a +//! plain local child process all deliver the same byte stream. Every +//! capability lives behind this protocol, so a transport only has to start the +//! server and connect its standard input and output. +//! +//! Note: the design and its trade-offs are recorded in +//! .agents/notes/proposed/architecture/2026-10-24-remote-development-workspace-host.md + +pub mod frame; +pub mod message; +pub mod methods; + +pub use frame::{FrameError, MAX_FRAME_BYTES, PREAMBLE}; +pub use message::{ErrorCode, Message, RemoteError}; + +/// Protocol revision negotiated by `server.hello`. +/// +/// Increment it only for incompatible changes. Additive fields and new methods +/// are advertised through [`methods::Feature`] instead, so an older server +/// keeps working with a newer client that checks the feature list. +pub const PROTOCOL_VERSION: u32 = 1; diff --git a/rust/lithe-remote-protocol/src/message.rs b/rust/lithe-remote-protocol/src/message.rs new file mode 100644 index 0000000000..f959c6c20a --- /dev/null +++ b/rust/lithe-remote-protocol/src/message.rs @@ -0,0 +1,178 @@ +//! Envelope types carried inside frames. +//! +//! Requests and responses are correlated by a client-chosen `id`. Events are +//! server-initiated notifications, such as process output, and carry no id. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::fmt; + +/// One protocol message. +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum Message { + /// Client-to-server call. `id` is unique per connection. + Request { + id: u64, + method: String, + #[serde(default)] + params: Value, + }, + /// Server reply to the request with the same `id`. Exactly one of + /// `result` and `error` is present. + Response { + id: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + result: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + error: Option, + }, + /// Server-initiated notification. + Event { + event: String, + #[serde(default)] + params: Value, + /// Position in the session's event stream, increasing by one per + /// event. Clients acknowledge it with `server.ack` and resume after + /// it with `server.resume`, so no event is lost or repeated across a + /// reconnection. + #[serde(default, skip_serializing_if = "Option::is_none")] + seq: Option, + }, +} + +impl Message { + /// Builds a successful response. + pub fn success(id: u64, result: Value) -> Self { + Self::Response { + id, + result: Some(result), + error: None, + } + } + + /// Builds a failed response. + pub fn failure(id: u64, error: RemoteError) -> Self { + Self::Response { + id, + result: None, + error: Some(error), + } + } +} + +/// Stable failure category. Clients branch on the code; `message` is only for +/// people and may change between releases. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum ErrorCode { + /// The peers do not share a protocol version. + ProtocolMismatch, + /// The message was malformed, sent before `server.hello`, or had invalid + /// parameters. + InvalidRequest, + /// The method name is not known to this server. + UnknownMethod, + /// A path argument was not an absolute remote path. + InvalidPath, + NotFound, + PermissionDenied, + AlreadyExists, + NotADirectory, + IsADirectory, + /// A directory removal without `recursive` found entries. + DirectoryNotEmpty, + /// A guarded write found that the file changed since the client read it. + Conflict, + /// A requested transfer exceeds the per-call size limit. + TooLarge, + /// The process, terminal, watch, tunnel or walk id is unknown, or the + /// resource already ended. + UnknownResource, + /// A resource with the client-chosen id already exists in the session. + ResourceIdInUse, + /// The program could not be started. + SpawnFailed, + /// A tunnel could not connect to its remote target. + ConnectFailed, + /// The server has too many requests in flight. + Busy, + /// The operation is not available on this host. + Unsupported, + /// Any other operating-system failure. `details` carries the OS error. + Io, + /// Client-side: no response arrived before the request deadline. + Timeout, + /// Client-side: the connection closed before a response arrived. + Disconnected, +} + +/// Failure carried in a response. +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteError { + pub code: ErrorCode, + pub message: String, + /// Host-specific diagnostics, such as the OS error number. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +impl RemoteError { + pub fn new(code: ErrorCode, message: impl Into) -> Self { + Self { + code, + message: message.into(), + details: None, + } + } + + pub fn with_details(mut self, details: Value) -> Self { + self.details = Some(details); + self + } +} + +impl fmt::Display for RemoteError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{:?}: {}", self.code, self.message) + } +} + +impl std::error::Error for RemoteError {} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + // The JSON shape is a compatibility surface between independently + // released client and server binaries; these literals pin it. + #[test] + fn envelopes_keep_their_wire_shape() { + let request = Message::Request { + id: 7, + method: "fs.stat".into(), + params: json!({"path": "/srv"}), + }; + assert_eq!( + serde_json::to_value(&request).unwrap(), + json!({"type": "request", "id": 7, "method": "fs.stat", "params": {"path": "/srv"}}) + ); + let failure = Message::failure(7, RemoteError::new(ErrorCode::NotFound, "missing")); + assert_eq!( + serde_json::to_value(&failure).unwrap(), + json!({"type": "response", "id": 7, "error": {"code": "NOT_FOUND", "message": "missing"}}) + ); + let event: Message = + serde_json::from_value(json!({"type": "event", "event": "process.exit"})).unwrap(); + assert_eq!( + event, + Message::Event { + event: "process.exit".into(), + params: Value::Null, + seq: None, + } + ); + } +} diff --git a/rust/lithe-remote-protocol/src/methods.rs b/rust/lithe-remote-protocol/src/methods.rs new file mode 100644 index 0000000000..170a131484 --- /dev/null +++ b/rust/lithe-remote-protocol/src/methods.rs @@ -0,0 +1,885 @@ +//! Method names, event names and their typed parameters. +//! +//! Paths in this protocol are absolute paths on the remote host, using the +//! host's own spelling. This is deliberate: the protocol is a filesystem and +//! process adapter, and toolchains, terminals and debuggers need real host +//! paths. Workspace-relative identifiers are built above this layer. +//! +//! Long-lived resources (processes, terminals, watches, tunnels, walks) are +//! identified by ids the *client* chooses, unique per session. A client can +//! therefore subscribe to a resource's events before creating it, and keeps +//! the same ids after reconnecting. Every event is named `.` and +//! carries `Id`, for example `terminal.output` with `terminalId`. + +use base64::Engine; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +pub const SERVER_HELLO: &str = "server.hello"; +pub const SERVER_SHUTDOWN: &str = "server.shutdown"; +pub const SERVER_PING: &str = "server.ping"; +pub const SERVER_STATE: &str = "server.state"; +pub const SERVER_RESUME: &str = "server.resume"; +pub const SERVER_ACK: &str = "server.ack"; + +pub const FS_STAT: &str = "fs.stat"; +pub const FS_READ_DIR: &str = "fs.readDir"; +pub const FS_READ_FILE: &str = "fs.readFile"; +pub const FS_WRITE_FILE: &str = "fs.writeFile"; +pub const FS_CREATE_DIR: &str = "fs.createDir"; +pub const FS_REMOVE: &str = "fs.remove"; +pub const FS_RENAME: &str = "fs.rename"; +pub const FS_COPY: &str = "fs.copy"; +pub const FS_REAL_PATH: &str = "fs.realPath"; +pub const FS_SET_PERMISSIONS: &str = "fs.setPermissions"; +pub const FS_VOLUME_INFO: &str = "fs.volumeInfo"; + +pub const WALK_START: &str = "walk.start"; +pub const WALK_CANCEL: &str = "walk.cancel"; + +pub const WATCH_START: &str = "watch.start"; +pub const WATCH_STOP: &str = "watch.stop"; + +pub const PROCESS_SPAWN: &str = "process.spawn"; +pub const PROCESS_WRITE_STDIN: &str = "process.writeStdin"; +pub const PROCESS_CLOSE_STDIN: &str = "process.closeStdin"; +pub const PROCESS_KILL: &str = "process.kill"; + +pub const TERMINAL_OPEN: &str = "terminal.open"; +pub const TERMINAL_WRITE: &str = "terminal.write"; +pub const TERMINAL_RESIZE: &str = "terminal.resize"; +pub const TERMINAL_CLOSE: &str = "terminal.close"; + +pub const ENVIRONMENT_RESOLVE: &str = "environment.resolve"; + +pub const TUNNEL_OPEN: &str = "tunnel.open"; +pub const TUNNEL_WRITE: &str = "tunnel.write"; +pub const TUNNEL_SHUTDOWN_WRITE: &str = "tunnel.shutdownWrite"; +pub const TUNNEL_CLOSE: &str = "tunnel.close"; +pub const PORTS_LISTENING: &str = "ports.listening"; + +pub const EVENT_PROCESS_OUTPUT: &str = "process.output"; +pub const EVENT_PROCESS_EXIT: &str = "process.exit"; +pub const EVENT_TERMINAL_OUTPUT: &str = "terminal.output"; +pub const EVENT_TERMINAL_EXIT: &str = "terminal.exit"; +pub const EVENT_WATCH_CHANGED: &str = "watch.changed"; +pub const EVENT_WATCH_ERROR: &str = "watch.error"; +pub const EVENT_WALK_ENTRIES: &str = "walk.entries"; +pub const EVENT_WALK_DONE: &str = "walk.done"; +pub const EVENT_TUNNEL_DATA: &str = "tunnel.data"; +pub const EVENT_TUNNEL_CLOSED: &str = "tunnel.closed"; + +/// Largest byte count one `fs.readFile` or `fs.writeFile` call may move. +/// Base64 inflates it by a third, which keeps the frame under +/// [`crate::MAX_FRAME_BYTES`]. +pub const MAX_TRANSFER_BYTES: u64 = 8 * 1024 * 1024; + +/// Encodes bytes for a JSON `data` field. +pub fn encode_bytes(bytes: &[u8]) -> String { + base64::engine::general_purpose::STANDARD.encode(bytes) +} + +/// Decodes a JSON `data` field. +pub fn decode_bytes(data: &str) -> Result, base64::DecodeError> { + base64::engine::general_purpose::STANDARD.decode(data) +} + +// --------------------------------------------------------------------------- +// Server and session +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HelloParams { + pub protocol_version: u32, + /// Free-form client identification for server diagnostics. + pub client_name: String, + /// Seconds a persistent session keeps its processes and terminals while + /// no client is attached. Ignored by `--stdio` servers, whose session ends + /// with the connection. The server applies the latest value it received. + #[serde(default)] + pub idle_timeout_seconds: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HelloResult { + pub protocol_version: u32, + pub server_version: String, + pub host: HostCapabilities, + pub session: SessionInfo, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionInfo { + /// Whether the session survives disconnection (`--connect` servers). + pub persistent: bool, + /// Whether a client was attached to this session before. Its unseen + /// events are delivered after `server.resume`. + pub resumed: bool, + /// Smallest resource id not used yet in this session. + pub next_resource_id: u64, + /// Sequence number of the latest event the session produced, `0` before + /// the first one. + pub last_event_seq: u64, +} + +/// Starts event delivery to this connection. Until it is called, events are +/// kept for the connection, so a client can subscribe to the resources it +/// knows before their missed events arrive. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ResumeParams { + /// Last event sequence number this client received, from an earlier + /// connection to the same session. Kept events after it are sent first. + /// `None` sends every event still kept. + #[serde(default)] + pub after_seq: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ResumeResult { + /// Events after `afterSeq` were dropped because the session's retention + /// limit was reached. The client must re-read state, for example call + /// `server.state` and rescan watched trees. + pub truncated: bool, +} + +/// Confirms that the client received every event up to `seq`, which the +/// session may then forget. Clients send it periodically; it has no result. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AckParams { + pub seq: u64, +} + +/// Facts about the remote host that clients must use instead of assuming a +/// platform. A client never branches on "is this macOS"; it reads the +/// capability that the behavior actually depends on. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HostCapabilities { + pub os: OperatingSystem, + /// Normalized CPU architecture of the running server: `x86_64` or + /// `aarch64`. + pub arch: String, + pub host_name: String, + pub user_name: Option, + pub home_directory: Option, + /// Login shell of the remote user, used for terminals and for resolving + /// the user's environment. + pub default_shell: Option, + /// Separator used by host paths. Always `/` for current hosts; carried so + /// that clients do not hard-code it. + pub path_separator: String, + pub process: ProcessCapabilities, + pub watch: WatchCapabilities, + /// Optional method groups this server implements, sorted. + pub features: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum OperatingSystem { + Linux, + Macos, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProcessCapabilities { + pub orphan_protection: OrphanProtection, +} + +/// What stops spawned processes from outliving the server. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum OrphanProtection { + /// The kernel kills each process group leader when the server dies, even + /// if the server is killed without a chance to clean up (Linux). + ParentDeathSignal, + /// Process groups are terminated when the server shuts down or its + /// session expires. A server killed with `SIGKILL` can leave them running + /// (macOS). + ServerShutdown, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WatchCapabilities { + pub backend: WatchBackend, + /// Upper bound of watched directories imposed by the host, when the + /// backend watches directories individually (inotify). Large trees beyond + /// it degrade to partial watching and report `watch.error`. + pub directory_limit: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum WatchBackend { + Inotify, + Fsevents, +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum Feature { + /// `fs.*` methods. + Filesystem, + /// `walk.*` methods and events. + Walk, + /// `watch.*` methods and events. + Watch, + /// `process.*` methods and events. + Process, + /// `terminal.*` methods and events. + Terminal, + /// `environment.resolve`. + Environment, + /// `tunnel.*` and `ports.listening`. + Tunnel, + /// The session survives disconnection when started with `--connect`. + Persistence, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ServerState { + pub processes: Vec, + pub terminals: Vec, + pub watches: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProcessState { + pub process_id: u64, + pub pid: u32, + pub argv: Vec, + /// The process itself exited, but members of its session still run; + /// `process.kill` still reaches them. + pub leader_exited: bool, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalState { + pub terminal_id: u64, + pub pid: u32, + pub columns: u16, + pub rows: u16, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WatchState { + pub watch_id: u64, + pub root: String, +} + +// --------------------------------------------------------------------------- +// Filesystem +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PathParams { + pub path: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum FileKind { + File, + Directory, + Symlink, + /// Sockets, devices and FIFOs. + Other, +} + +/// Metadata of the path itself; symbolic links are not followed. +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FileMetadata { + pub kind: FileKind, + pub size: u64, + /// Modification time in milliseconds since the Unix epoch. Milliseconds + /// stay exact in JavaScript numbers, which a future TypeScript client + /// relies on. + pub modified_ms: Option, + pub readonly: bool, + /// Permission bits (`0o755` style), for executable markers and + /// `fs.setPermissions`. + pub mode: u32, + /// Link target as stored in the link, when it is valid UTF-8. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub symlink_target: Option, + /// Kind of the resolved target; `None` for a dangling link. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub symlink_target_kind: Option, +} + +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DirectoryEntry { + pub name: String, + pub metadata: FileMetadata, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReadDirResult { + /// Sorted by the byte order of `name`. + pub entries: Vec, + /// Entries whose names are not valid UTF-8. They cannot be addressed + /// through this protocol version, so they are counted rather than + /// silently dropped. + pub skipped_non_utf8: u32, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReadFileParams { + pub path: String, + #[serde(default)] + pub offset: u64, + /// Requested byte count; at most [`MAX_TRANSFER_BYTES`]. + pub length: u64, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReadFileResult { + /// Base64 bytes starting at the requested offset. + pub data: String, + /// Whether the read reached the end of the file. + pub eof: bool, + pub metadata: FileMetadata, +} + +/// Identity of the content a client last read, used to reject a write that +/// would overwrite someone else's change. +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FileVersion { + pub size: u64, + pub modified_ms: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WriteFileParams { + pub path: String, + /// Base64 content that replaces the whole file. + pub data: String, + /// When present, the write fails with `CONFLICT` unless the current file + /// matches it. When absent, the file must not exist yet unless + /// `overwrite` is set. + #[serde(default)] + pub expected: Option, + #[serde(default)] + pub overwrite: bool, + #[serde(default)] + pub create_parents: bool, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateDirParams { + pub path: String, + #[serde(default)] + pub recursive: bool, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoveParams { + pub path: String, + #[serde(default)] + pub recursive: bool, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RenameParams { + pub from: String, + pub to: String, + #[serde(default)] + pub overwrite: bool, +} + +/// Copies a file, or a directory tree when `recursive` is set. Symbolic +/// links are recreated as links, never followed, so a copy cannot escape the +/// copied tree. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CopyParams { + pub from: String, + pub to: String, + #[serde(default)] + pub recursive: bool, + #[serde(default)] + pub overwrite: bool, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CopyResult { + /// Sockets, FIFOs and device files inside a copied tree. They are not + /// copied: reading a FIFO can block forever and devices are not content. + pub skipped_special_files: u64, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SetPermissionsParams { + pub path: String, + /// Permission bits, for example `0o755`. + pub mode: u32, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RealPathResult { + pub path: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VolumeInfo { + /// Whether names on the volume containing the path are case-sensitive. + /// APFS is usually not; most Linux filesystems are. `None` when the + /// server could not determine it. + pub case_sensitive: Option, +} + +// --------------------------------------------------------------------------- +// Walk +// --------------------------------------------------------------------------- + +/// Streams every entry below `root` in depth-first, name-sorted order. Used +/// for file indexes and "go to file"; symbolic links are reported but not +/// followed. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WalkStartParams { + pub walk_id: u64, + pub root: String, + /// Entry names that are reported but not descended into, such as + /// `node_modules` or `target`. + #[serde(default)] + pub exclude_names: Vec, + /// Stops the walk after this many entries and reports `truncated`. + #[serde(default)] + pub max_entries: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WalkParams { + pub walk_id: u64, +} + +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WalkEntry { + /// Path relative to the walk root with `/` separators. + pub path: String, + pub kind: FileKind, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WalkEntriesEvent { + pub walk_id: u64, + pub entries: Vec, +} + +/// Last event of a walk. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WalkDoneEvent { + pub walk_id: u64, + pub total: u64, + pub truncated: bool, + pub cancelled: bool, + pub skipped_non_utf8: u64, + /// Directories that could not be read, usually for lack of permission. + pub unreadable_directories: u64, +} + +// --------------------------------------------------------------------------- +// Watch +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WatchStartParams { + pub watch_id: u64, + /// Directory watched recursively. + pub root: String, + /// Directory names whose contents are not watched, such as `.git/objects` + /// style noise or build output. Matched against each path component. + #[serde(default)] + pub exclude_names: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WatchStartResult { + /// Directories registered with a per-directory backend; `None` for + /// backends that watch whole trees. + pub watched_directories: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WatchParams { + pub watch_id: u64, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ChangeKind { + Created, + Modified, + Deleted, +} + +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FileChange { + /// Absolute host path. + pub path: String, + pub kind: ChangeKind, +} + +/// Coalesced changes, sorted by path. With `overflow` some changes were lost +/// and the client must rescan the watched tree. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WatchChangedEvent { + pub watch_id: u64, + pub changes: Vec, + pub overflow: bool, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WatchErrorEvent { + pub watch_id: u64, + pub code: WatchErrorCode, + pub message: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum WatchErrorCode { + /// The host's watch limit was reached; part of the tree is unwatched. + LimitReached, + /// The watched root was deleted or moved; the watch has ended. + RootRemoved, +} + +// --------------------------------------------------------------------------- +// Processes +// --------------------------------------------------------------------------- + +/// Base environment of a spawned process or terminal. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum EnvironmentSource { + /// The server's own environment, as the transport started it. Over SSH + /// this usually lacks what login profiles add to `PATH`. + #[default] + Server, + /// The user's login environment from `environment.resolve`, so tools + /// installed through nvm, SDKMAN or Homebrew are found. + Login, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SpawnParams { + pub process_id: u64, + /// Program and arguments. The program is resolved through the base + /// environment's `PATH` when it contains no `/`. + pub argv: Vec, + #[serde(default)] + pub cwd: Option, + #[serde(default)] + pub environment: EnvironmentSource, + /// Variables added to, or replacing, the base environment. + #[serde(default)] + pub env: BTreeMap, + /// Keep standard input open for `process.writeStdin`. Otherwise the + /// process reads end-of-file immediately. + #[serde(default)] + pub pipe_stdin: bool, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SpawnResult { + /// Operating-system pid, for diagnostics only. + pub pid: u32, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WriteStdinParams { + pub process_id: u64, + pub data: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProcessParams { + pub process_id: u64, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct KillParams { + pub process_id: u64, + /// `SIGKILL` instead of `SIGTERM`. Either signal goes to every process + /// of the session the process leads, including job-control groups it + /// created, so build tools do not leave workers behind. + #[serde(default)] + pub force: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum OutputStream { + Stdout, + Stderr, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProcessOutputEvent { + pub process_id: u64, + pub stream: OutputStream, + pub data: String, +} + +/// Sent once per process, after all of its output events. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProcessExitEvent { + pub process_id: u64, + pub exit_code: Option, + /// Terminating signal number when the process did not exit normally. + pub signal: Option, +} + +// --------------------------------------------------------------------------- +// Terminals +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalOpenParams { + pub terminal_id: u64, + /// Program and arguments. Defaults to the user's login shell started as + /// a login shell, which loads the same profile files as an SSH login. + #[serde(default)] + pub argv: Option>, + #[serde(default)] + pub cwd: Option, + /// Variables added to the server environment. `TERM` defaults to + /// `xterm-256color`. + #[serde(default)] + pub env: BTreeMap, + pub columns: u16, + pub rows: u16, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalOpenResult { + pub pid: u32, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalWriteParams { + pub terminal_id: u64, + pub data: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalResizeParams { + pub terminal_id: u64, + pub columns: u16, + pub rows: u16, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalParams { + pub terminal_id: u64, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalOutputEvent { + pub terminal_id: u64, + pub data: String, +} + +/// Sent once per terminal, after all of its output. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalExitEvent { + pub terminal_id: u64, + pub exit_code: Option, + pub signal: Option, +} + +// --------------------------------------------------------------------------- +// Environment +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EnvironmentResolveParams { + /// Re-run the login shell instead of returning the cached result, for + /// example after the user edited a profile file. + #[serde(default)] + pub refresh: bool, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EnvironmentResolveResult { + pub environment: BTreeMap, + pub source: ResolvedEnvironmentSource, + /// Why a better source was not used, for example a profile that timed out. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub diagnostics: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ResolvedEnvironmentSource { + /// An interactive login shell, which also reads files such as `.zshrc` + /// and `.bashrc` where version managers usually add themselves. + InteractiveLoginShell, + /// A non-interactive login shell; interactive start-up failed. + LoginShell, + /// The server environment; the shell could not be used. + Server, +} + +// --------------------------------------------------------------------------- +// Tunnels and ports +// --------------------------------------------------------------------------- + +/// Where a tunnel connects on the remote side. +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum TunnelTarget { + Tcp { + host: String, + port: u16, + }, + /// A Unix domain socket, such as a Docker or database socket. + Unix { + path: String, + }, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TunnelOpenParams { + pub tunnel_id: u64, + pub target: TunnelTarget, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TunnelWriteParams { + pub tunnel_id: u64, + pub data: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TunnelParams { + pub tunnel_id: u64, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TunnelDataEvent { + pub tunnel_id: u64, + pub data: String, +} + +/// Last event of a tunnel. `error` explains an abnormal end. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TunnelClosedEvent { + pub tunnel_id: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ListeningPort { + /// Bound address as reported by the host, such as `127.0.0.1`, `::` or + /// `0.0.0.0`. + pub address: String, + pub port: u16, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ListeningPortsResult { + /// Sorted and without duplicates. + pub ports: Vec, +} + +/// Output of `lithe-remote-server --identity`, used by the bootstrapper to +/// decide whether an installed binary can be reused. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ServerIdentity { + pub server_version: String, + pub protocol_version: u32, + /// Lower-case hex SHA-256 of the executable file itself. + pub sha256: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn tunnel_targets_are_tagged_by_kind() { + assert_eq!( + serde_json::to_value(TunnelTarget::Tcp { + host: "127.0.0.1".into(), + port: 8080 + }) + .unwrap(), + json!({"kind": "tcp", "host": "127.0.0.1", "port": 8080}) + ); + let unix: TunnelTarget = + serde_json::from_value(json!({"kind": "unix", "path": "/var/run/docker.sock"})) + .unwrap(); + assert_eq!( + unix, + TunnelTarget::Unix { + path: "/var/run/docker.sock".into() + } + ); + } +} diff --git a/rust/lithe-remote-server/Cargo.toml b/rust/lithe-remote-server/Cargo.toml new file mode 100644 index 0000000000..47236c27e3 --- /dev/null +++ b/rust/lithe-remote-server/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "lithe-remote-server" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Workspace server that Lithe starts on SSH hosts and in containers" +publish = false + +# The server runs on Linux and macOS hosts only. Linux builds target musl so a +# single static binary works across distributions and minimal images. +[dependencies] +libc = "0.2" +lithe-remote-protocol = { path = "../lithe-remote-protocol" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" + +[dev-dependencies] +lithe-remote-client = { path = "../lithe-remote-client" } diff --git a/rust/lithe-remote-server/src/connection.rs b/rust/lithe-remote-server/src/connection.rs new file mode 100644 index 0000000000..78f37227bf --- /dev/null +++ b/rust/lithe-remote-server/src/connection.rs @@ -0,0 +1,283 @@ +//! One client connection: handshake, request routing and the writer thread. +//! +//! Requests that change a running resource in order (terminal input, standard +//! input, resizes) run inline on the reading thread, so they are applied +//! exactly in the order the client sent them. They never block: each one only +//! queues data. Requests that can take time run on worker threads and may +//! complete in any order; `process.kill` and `terminal.close` are among them +//! because finding every member of a session scans the process table. + +use crate::link::{Outbox, Outgoing}; +use crate::session::Session; +use crate::{environment, fs, parse, platform, ports, to_value}; +use lithe_remote_protocol::frame::{read_frame, write_frame}; +use lithe_remote_protocol::methods::{ + self, AckParams, HelloParams, HelloResult, ResumeParams, ResumeResult, SessionInfo, +}; +use lithe_remote_protocol::{ErrorCode, Message, RemoteError, PREAMBLE, PROTOCOL_VERSION}; +use serde_json::{json, Value}; +use std::io::{self, Read, Write}; +use std::sync::mpsc::{self, Receiver}; +use std::sync::Arc; +use std::thread; + +/// Messages buffered for the writer thread. A full queue blocks producers; +/// see [`crate::link::Link::emit`]. +const OUTBOX_CAPACITY: usize = 256; + +/// How a connection ended. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum Ended { + Disconnected, + /// The client called `server.shutdown`; the session must end too. + Shutdown, +} + +/// What a request handler knows about its origin. +#[derive(Clone)] +pub(crate) struct Context { + pub(crate) session: Arc, + /// Owner of connection-bound resources such as tunnels. + pub(crate) connection: u64, +} + +pub(crate) fn serve( + session: &Arc, + input: impl Read, + mut output: impl Write + Send + 'static, +) -> io::Result { + output.write_all(PREAMBLE)?; + output.flush()?; + + let (sender, receiver) = mpsc::sync_channel(OUTBOX_CAPACITY); + let writer = thread::Builder::new() + .name("remote-writer".into()) + .spawn(move || write_loop(output, receiver))?; + let outbox = Outbox(sender.clone()); + let context = Context { + session: Arc::clone(session), + connection: session.next_connection_id(), + }; + let mut input = io::BufReader::new(input); + let mut handshaken = false; + let mut resumed = false; + let mut ended = Ended::Disconnected; + + loop { + let frame = match read_frame(&mut input) { + Ok(Some(frame)) => frame, + Ok(None) => break, + Err(error) => { + eprintln!("lithe-remote-server: {error}"); + break; + } + }; + let (id, method, params) = match serde_json::from_slice::(&frame) { + Ok(Message::Request { id, method, params }) => (id, method, params), + Ok(_) => { + eprintln!("lithe-remote-server: ignoring a non-request message"); + continue; + } + Err(error) => { + // Without a parsable id there is no response to send. + eprintln!("lithe-remote-server: malformed message: {error}"); + continue; + } + }; + + if !handshaken { + if method == methods::SERVER_HELLO { + handshaken = hello(&context, id, params, &outbox); + } else { + outbox.send(Message::failure( + id, + RemoteError::new(ErrorCode::InvalidRequest, "server.hello must come first"), + )); + } + continue; + } + match method.as_str() { + methods::SERVER_HELLO => { + outbox.send(Message::failure( + id, + RemoteError::new(ErrorCode::InvalidRequest, "already said hello"), + )); + } + methods::SERVER_RESUME if resumed => { + outbox.send(Message::failure( + id, + RemoteError::new(ErrorCode::InvalidRequest, "already resumed"), + )); + } + methods::SERVER_RESUME => match parse::(params) { + Ok(params) => { + resumed = true; + session.link.resume( + context.connection, + outbox.clone(), + params.after_seq.unwrap_or(0), + |truncated| respond(id, to_value(ResumeResult { truncated })), + ); + } + Err(error) => { + outbox.send(Message::failure(id, error)); + } + }, + methods::SERVER_SHUTDOWN => { + session.begin_shutdown(); + outbox.send(Message::success(id, Value::Null)); + ended = Ended::Shutdown; + break; + } + method if is_inline(method) => { + outbox.send(respond(id, handle(&context, method, params))); + } + _ => dispatch_to_worker(&context, id, method, params, &outbox), + } + } + + session.link.detach(context.connection); + session.release_connection_resources(context.connection); + let _ = sender.send(Outgoing::Close); + match writer.join() { + Ok(Ok(())) => Ok(ended), + // The client vanished mid-write; that is an ordinary disconnect. + Ok(Err(_)) => Ok(ended), + Err(_) => Err(io::Error::other("writer thread panicked")), + } +} + +/// Handles `server.hello`. Events start flowing only after `server.resume`. +/// Returns whether the handshake succeeded. +fn hello(context: &Context, id: u64, params: Value, outbox: &Outbox) -> bool { + let params: HelloParams = match parse(params) { + Ok(params) => params, + Err(error) => { + outbox.send(Message::failure(id, error)); + return false; + } + }; + if params.protocol_version != PROTOCOL_VERSION { + outbox.send(Message::failure( + id, + RemoteError::new( + ErrorCode::ProtocolMismatch, + format!( + "client speaks protocol {}, server speaks {PROTOCOL_VERSION}", + params.protocol_version + ), + ) + .with_details(json!({ "serverProtocolVersion": PROTOCOL_VERSION })), + )); + return false; + } + let session = &context.session; + if let Some(seconds) = params.idle_timeout_seconds { + session.set_idle_timeout(seconds); + } + let resumed = session.mark_client(); + outbox.send(respond( + id, + to_value(HelloResult { + protocol_version: PROTOCOL_VERSION, + server_version: env!("CARGO_PKG_VERSION").into(), + host: platform::host_capabilities(), + session: SessionInfo { + persistent: session.persistent, + resumed, + next_resource_id: session.ids.next(), + last_event_seq: session.link.last_seq(), + }, + }), + )); + true +} + +fn is_inline(method: &str) -> bool { + matches!( + method, + methods::SERVER_PING + | methods::SERVER_STATE + | methods::SERVER_ACK + | methods::PROCESS_WRITE_STDIN + | methods::PROCESS_CLOSE_STDIN + | methods::TERMINAL_WRITE + | methods::TERMINAL_RESIZE + | methods::TUNNEL_WRITE + | methods::TUNNEL_SHUTDOWN_WRITE + | methods::TUNNEL_CLOSE + | methods::WATCH_STOP + | methods::WALK_CANCEL + ) +} + +fn handle(context: &Context, method: &str, params: Value) -> Result { + let session = &context.session; + match method { + methods::SERVER_PING => Ok(json!({})), + methods::SERVER_STATE => to_value(session.state()), + methods::SERVER_ACK => { + let params: AckParams = parse(params)?; + session.link.acknowledge(params.seq); + Ok(Value::Null) + } + method if method.starts_with("fs.") => fs::handle(method, params), + method if method.starts_with("walk.") => session.walks.handle(context, method, params), + method if method.starts_with("watch.") => session.watches.handle(context, method, params), + method if method.starts_with("process.") => { + session.processes.handle(context, method, params) + } + method if method.starts_with("terminal.") => { + session.terminals.handle(context, method, params) + } + method if method.starts_with("tunnel.") => session.tunnels.handle(context, method, params), + methods::ENVIRONMENT_RESOLVE => environment::handle(session, params), + methods::PORTS_LISTENING => ports::handle(session), + _ => Err(RemoteError::new( + ErrorCode::UnknownMethod, + format!("unknown method {method}"), + )), + } +} + +fn dispatch_to_worker(context: &Context, id: u64, method: String, params: Value, outbox: &Outbox) { + let workers = &context.session.workers; + if !workers.try_acquire() { + outbox.send(Message::failure( + id, + RemoteError::new(ErrorCode::Busy, "too many requests in flight"), + )); + return; + } + let worker_outbox = outbox.clone(); + let worker_context = context.clone(); + let spawned = thread::Builder::new() + .name("remote-worker".into()) + .spawn(move || { + let reply = handle(&worker_context, &method, params); + worker_outbox.send(respond(id, reply)); + worker_context.session.workers.release(); + }); + if let Err(error) = spawned { + workers.release(); + outbox.send(Message::failure( + id, + RemoteError::new(ErrorCode::Busy, format!("cannot start worker: {error}")), + )); + } +} + +fn write_loop(mut output: impl Write, receiver: Receiver) -> io::Result<()> { + while let Ok(Outgoing::Message(message)) = receiver.recv() { + let payload = serde_json::to_vec(&message).map_err(io::Error::from)?; + write_frame(&mut output, &payload).map_err(|error| io::Error::other(error.to_string()))?; + } + Ok(()) +} + +pub(crate) fn respond(id: u64, reply: Result) -> Message { + match reply { + Ok(result) => Message::success(id, result), + Err(error) => Message::failure(id, error), + } +} diff --git a/rust/lithe-remote-server/src/daemon.rs b/rust/lithe-remote-server/src/daemon.rs new file mode 100644 index 0000000000..7a01de79a5 --- /dev/null +++ b/rust/lithe-remote-server/src/daemon.rs @@ -0,0 +1,399 @@ +//! Persistent sessions that survive a dropped connection. +//! +//! The transport runs `lithe-remote-server --connect `, a small proxy. +//! It connects to the session daemon for `` over a Unix socket, starting +//! the daemon first if none runs, and then copies bytes between its standard +//! streams and the socket. When SSH drops, only the proxy dies: the daemon +//! keeps terminals and builds running, buffers their events, and hands them +//! to the next connection. A daemon without clients ends its session after +//! the idle timeout the client requested. +//! +//! The socket lives in a directory only the user can access, and the daemon +//! also verifies the peer's user id, so other users on a shared host cannot +//! attach to a session. + +use crate::connection::{self, Ended}; +use crate::platform; +use crate::session::Session; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read, Write}; +use std::os::fd::AsRawFd; +use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt, PermissionsExt}; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::os::unix::process::CommandExt; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::{Duration, Instant}; + +/// Bound for the proxy to see a freshly started daemon's socket. It covers a +/// predecessor that is still ending its session (terminating process groups +/// and draining requests takes up to about 13 seconds) before the new daemon +/// can take the lock. +const DAEMON_START_TIMEOUT: Duration = Duration::from_secs(30); + +/// Bound for a new daemon to wait for a predecessor's lock. +const LOCK_WAIT_TIMEOUT: Duration = Duration::from_secs(25); + +/// Pause after a failed `accept`, such as `EMFILE`, which otherwise repeats +/// immediately and spins. +const ACCEPT_FAILURE_BACKOFF: Duration = Duration::from_millis(200); +const DAEMON_START_POLL: Duration = Duration::from_millis(20); + +/// Longest wait for a connection before the daemon re-checks its idle +/// deadline and shutdown flag. A connection ends the wait immediately. +const IDLE_CHECK_INTERVAL: Duration = Duration::from_millis(200); + +/// Socket paths are limited to about 104 bytes on macOS and 108 on Linux. +const MAX_SOCKET_PATH_BYTES: usize = 100; + +/// Short, always-present directory for sockets whose regular path is too +/// long. Its per-user subdirectory is private, like the regular location. +const SHORT_SOCKET_ROOT: &str = "/tmp"; + +/// Session key and where its state lives. +#[derive(Clone, Debug)] +pub struct PersistentOptions { + /// Client-chosen session name: 1–64 characters from `[A-Za-z0-9._-]`. + pub key: String, + /// Defaults to `$HOME/.lithe-server`. + pub state_directory: Option, +} + +struct Paths { + socket: PathBuf, + lock: PathBuf, + log: PathBuf, +} + +impl PersistentOptions { + fn paths(&self) -> io::Result { + if self.key.is_empty() + || self.key.len() > 64 + || !self + .key + .chars() + .all(|character| character.is_ascii_alphanumeric() || "._-".contains(character)) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid session key", + )); + } + let state = match &self.state_directory { + Some(directory) => directory.clone(), + None => PathBuf::from( + platform::current_account() + .home_directory + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no home directory"))?, + ) + .join(".lithe-server"), + }; + // The server version is part of the name: a client upgrade starts a + // new daemon instead of talking to an older one, which ends on its + // own once idle. + let name = format!("{}-{}", self.key, env!("CARGO_PKG_VERSION")); + let run = state.join("run"); + private_directory(&run)?; + let mut socket = run.join(format!("{name}.sock")); + if socket.as_os_str().len() > MAX_SOCKET_PATH_BYTES { + // `/tmp` rather than the temporary directory: on macOS the latter + // is itself too long for a socket path. + let fallback = PathBuf::from(SHORT_SOCKET_ROOT) + .join(format!("lithe-server-{}", platform::current_uid())); + private_directory(&fallback)?; + socket = fallback.join(format!("{name}.sock")); + if socket.as_os_str().len() > MAX_SOCKET_PATH_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "the session key is too long for a socket path", + )); + } + } + let log_directory = state.join("log"); + private_directory(&log_directory)?; + Ok(Paths { + socket, + lock: run.join(format!("{name}.lock")), + log: log_directory.join(format!("{name}.log")), + }) + } +} + +/// Creates `path` with mode 0700, or verifies an existing one is a real +/// directory owned by the user that nobody else can write to. +fn private_directory(path: &Path) -> io::Result<()> { + match fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(path) + { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(error), + } + let metadata = fs::symlink_metadata(path)?; + if !metadata.is_dir() + || metadata.uid() != platform::current_uid() + || metadata.mode() & 0o022 != 0 + { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + format!( + "{} must be a private directory owned by the user", + path.display() + ), + )); + } + Ok(()) +} + +/// Implements `--connect`: relays standard streams to the session daemon, +/// starting it when needed. +pub fn run_proxy(options: &PersistentOptions) -> io::Result<()> { + let paths = options.paths()?; + let stream = match UnixStream::connect(&paths.socket) { + Ok(stream) => stream, + Err(_) => { + start_daemon(options, &paths)?; + wait_for_socket(&paths.socket)? + } + }; + relay(stream) +} + +fn start_daemon(options: &PersistentOptions, paths: &Paths) -> io::Result<()> { + let log = OpenOptions::new() + .create(true) + .append(true) + .mode(0o600) + .open(&paths.log)?; + let mut command = Command::new(std::env::current_exe()?); + command.arg("--daemon").arg(&options.key); + if let Some(directory) = &options.state_directory { + command.arg("--state-dir").arg(directory); + } + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::from(log)); + // A new session detaches the daemon from the SSH session, so the hangup + // that ends the SSH session does not reach it. + // SAFETY: setsid is async-signal-safe. + unsafe { + command.pre_exec(|| { + if libc::setsid() == -1 { + return Err(io::Error::last_os_error()); + } + Ok(()) + }); + } + // The daemon is not waited for: it outlives this proxy and is adopted by + // init when the proxy exits. + command.spawn().map(|_| ()) +} + +fn wait_for_socket(socket: &Path) -> io::Result { + let deadline = Instant::now() + DAEMON_START_TIMEOUT; + loop { + match UnixStream::connect(socket) { + Ok(stream) => return Ok(stream), + Err(error) if Instant::now() >= deadline => return Err(error), + Err(_) => thread::sleep(DAEMON_START_POLL), + } + } +} + +/// Copies bytes in both directions until the daemon closes the connection. +fn relay(stream: UnixStream) -> io::Result<()> { + let mut to_daemon = stream.try_clone()?; + thread::spawn(move || { + let _ = io::copy(&mut io::stdin().lock(), &mut to_daemon); + // End-of-file from the transport is the client's disconnect. + let _ = to_daemon.shutdown(std::net::Shutdown::Write); + }); + let mut from_daemon = stream; + let mut stdout = io::stdout().lock(); + let mut buffer = vec![0_u8; 64 * 1024]; + loop { + let read = match from_daemon.read(&mut buffer) { + Ok(0) => return Ok(()), + Ok(read) => read, + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + Err(error) => return Err(error), + }; + // Flushing every chunk keeps interactive output latency low. + if stdout + .write_all(&buffer[..read]) + .and_then(|()| stdout.flush()) + .is_err() + { + return Ok(()); + } + } +} + +/// Implements `--daemon`: serves the persistent session for `options.key`. +pub fn run_daemon(options: &PersistentOptions) -> io::Result<()> { + let paths = options.paths()?; + // The lock decides which daemon owns the key. + let lock = OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .mode(0o600) + .open(&paths.lock)?; + if !acquire_lock(&lock, &paths.socket) { + return Ok(()); + } + write_pid(&lock)?; + // A socket left by a crashed daemon refuses connections; the lock proves + // nobody serves it. + match fs::remove_file(&paths.socket) { + Err(error) if error.kind() != io::ErrorKind::NotFound => return Err(error), + _ => {} + } + let listener = UnixListener::bind(&paths.socket)?; + fs::set_permissions(&paths.socket, fs::Permissions::from_mode(0o600))?; + listener.set_nonblocking(true)?; + + let session = Session::new(true)?; + let shutdown = Arc::new(std::sync::atomic::AtomicBool::new(false)); + { + // A client that reconnects right after `server.shutdown` must not + // reach this ending session: the socket disappears before the + // response, so its proxy starts a new daemon, which waits for the lock. + let socket = paths.socket.clone(); + let shutdown = Arc::clone(&shutdown); + session.set_shutdown_hook(move || { + shutdown.store(true, Ordering::Release); + let _ = fs::remove_file(socket); + }); + } + let attached = Arc::new(AtomicUsize::new(0)); + // The attached connection's id and a copy of its socket, kept so that a + // replacing connection can disconnect it. + let current: Arc>> = Arc::new(Mutex::new(None)); + let mut next_connection: u64 = 0; + let mut idle_since = Instant::now(); + + while !shutdown.load(Ordering::Acquire) { + match listener.accept() { + Ok((stream, _)) => { + if platform::peer_uid(stream.as_raw_fd()).ok() != Some(platform::current_uid()) { + eprintln!("lithe-remote-server: rejected a connection from another user"); + continue; + } + stream.set_nonblocking(false)?; + // A new connection replaces the current one. The old one may + // be half-dead after a network change and not yet noticed. + next_connection += 1; + let connection_id = next_connection; + if let Some((_, previous)) = + crate::lock(¤t).replace((connection_id, stream.try_clone()?)) + { + let _ = previous.shutdown(std::net::Shutdown::Both); + } + let current = Arc::clone(¤t); + attached.fetch_add(1, Ordering::AcqRel); + let session = Arc::clone(&session); + let attached = Arc::clone(&attached); + let shutdown = Arc::clone(&shutdown); + thread::Builder::new() + .name("remote-connection".into()) + .spawn(move || { + let input = match stream.try_clone() { + Ok(input) => input, + Err(error) => { + eprintln!("lithe-remote-server: {error}"); + attached.fetch_sub(1, Ordering::AcqRel); + return; + } + }; + let ended = connection::serve(&session, input, stream); + // The copy kept for replacement is the last open + // descriptor of this socket. Closing it delivers + // end-of-file to the proxy, which would otherwise + // linger on the host after every disconnect. + // (`shutdown` is not enough: BSD rejects it with + // ENOTCONN once the peer has half-closed.) + let mut current = crate::lock(¤t); + if matches!(&*current, Some((id, _)) if *id == connection_id) { + current.take(); + } + drop(current); + if matches!(ended, Ok(Ended::Shutdown)) { + shutdown.store(true, Ordering::Release); + } + attached.fetch_sub(1, Ordering::AcqRel); + })?; + } + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + if attached.load(Ordering::Acquire) > 0 { + idle_since = Instant::now(); + } else if idle_since.elapsed() >= session.idle_timeout() { + break; + } + wait_for_connection(&listener, IDLE_CHECK_INTERVAL); + } + Err(error) => { + // Persistent failures such as EMFILE return immediately; the + // pause keeps the loop from spinning and flooding the log. + eprintln!("lithe-remote-server: accept failed: {error}"); + thread::sleep(ACCEPT_FAILURE_BACKOFF); + } + } + } + + let _ = fs::remove_file(&paths.socket); + session.terminate_all(); + if let Some((_, stream)) = crate::lock(¤t).take() { + let _ = stream.shutdown(std::net::Shutdown::Both); + } + drop(lock); + Ok(()) +} + +/// Takes the exclusive lock for the key. Another daemon holding it is either +/// serving the key, then this daemon is redundant and exits, or ending its +/// session after removing the socket, then this daemon waits for it so the +/// proxy that started it finds a fresh session instead of nothing. +fn acquire_lock(lock: &File, socket: &Path) -> bool { + let deadline = Instant::now() + LOCK_WAIT_TIMEOUT; + loop { + // SAFETY: flock on a descriptor this process owns. + if unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 { + return true; + } + // The socket's presence is the signal, not a connection to it: the + // serving daemon would accept a probe connection as a new client and + // disconnect the real one it replaces. + if fs::symlink_metadata(socket).is_ok() { + eprintln!("lithe-remote-server: another daemon serves this session; exiting"); + return false; + } + if Instant::now() >= deadline { + eprintln!("lithe-remote-server: the previous daemon did not release the session lock"); + return false; + } + thread::sleep(DAEMON_START_POLL); + } +} + +fn wait_for_connection(listener: &UnixListener, timeout: Duration) { + let mut descriptor = libc::pollfd { + fd: listener.as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }; + // SAFETY: one live pollfd is passed with its count. + unsafe { libc::poll(&mut descriptor, 1, timeout.as_millis() as libc::c_int) }; +} + +fn write_pid(mut lock: &File) -> io::Result<()> { + lock.set_len(0)?; + writeln!(lock, "{}", std::process::id())?; + lock.flush() +} diff --git a/rust/lithe-remote-server/src/environment.rs b/rust/lithe-remote-server/src/environment.rs new file mode 100644 index 0000000000..d07742fbfe --- /dev/null +++ b/rust/lithe-remote-server/src/environment.rs @@ -0,0 +1,197 @@ +//! The remote user's login environment. +//! +//! A command run over SSH or `docker exec` gets a minimal environment: login +//! profiles, and the files where nvm, SDKMAN, pyenv or Homebrew add +//! themselves to `PATH`, are often not read. Builds started from the IDE +//! would then fail with "command not found" although the same command works +//! in the user's terminal. The server therefore asks the user's own shell, +//! started as an interactive login shell, for the environment it ends up +//! with, and caches the answer for the session. + +use crate::platform::{self, ChildKind}; +use crate::session::Session; +use crate::{parse, to_value}; +use lithe_remote_protocol::methods::{ + EnvironmentResolveParams, EnvironmentResolveResult, ResolvedEnvironmentSource, +}; +use lithe_remote_protocol::RemoteError; +use serde_json::Value; +use std::collections::BTreeMap; +use std::io::{self, Write}; +use std::process::{Command, Stdio}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +const BEGIN_MARKER: &str = "LITHE-ENVIRONMENT-BEGIN"; +const END_MARKER: &str = "LITHE-ENVIRONMENT-END"; + +/// Bound for one shell start-up. Profiles that start `tmux`, wait for input +/// or run slow network checks hit it instead of hanging the server. +const SHELL_TIMEOUT: Duration = Duration::from_secs(10); + +/// Variables that describe the resolving shell itself, not the user's +/// environment, and would be wrong in any other process. +const SHELL_LOCAL_VARIABLES: &[&str] = &["PWD", "OLDPWD", "SHLVL", "_", "TERM", "PS1", "PS2"]; + +#[derive(Default)] +pub(crate) struct EnvironmentCache { + resolved: Mutex>, + /// Serializes resolutions so concurrent spawns start one shell, not many. + resolving: Mutex<()>, +} + +impl EnvironmentCache { + pub(crate) fn resolve( + &self, + session: &Arc, + refresh: bool, + ) -> EnvironmentResolveResult { + let _resolving = crate::lock(&self.resolving); + if !refresh { + if let Some(cached) = crate::lock(&self.resolved).clone() { + return cached; + } + } + let result = resolve_uncached(session); + *crate::lock(&self.resolved) = Some(result.clone()); + result + } +} + +pub(crate) fn handle(session: &Arc, params: Value) -> Result { + let params: EnvironmentResolveParams = if params.is_null() { + EnvironmentResolveParams::default() + } else { + parse(params)? + }; + to_value(session.environment.resolve(session, params.refresh)) +} + +fn resolve_uncached(session: &Arc) -> EnvironmentResolveResult { + let server_environment = || std::env::vars().collect::>(); + let account = platform::current_account(); + let Some(shell) = account.shell else { + return EnvironmentResolveResult { + environment: server_environment(), + source: ResolvedEnvironmentSource::Server, + diagnostics: Some("the account has no login shell".into()), + }; + }; + let executable = match std::env::current_exe() { + Ok(executable) => executable, + Err(error) => { + return EnvironmentResolveResult { + environment: server_environment(), + source: ResolvedEnvironmentSource::Server, + diagnostics: Some(format!("cannot locate the server executable: {error}")), + } + } + }; + // The shell runs our own binary to print the environment, so the result + // does not depend on `env` flags that differ between GNU and BSD. + let Some(script) = executable + .to_str() + .filter(|path| !path.contains('\'')) + .map(|path| format!("'{path}' --print-environment")) + else { + return EnvironmentResolveResult { + environment: server_environment(), + source: ResolvedEnvironmentSource::Server, + diagnostics: Some("the server path cannot be quoted for the shell".into()), + }; + }; + + let mut diagnostics = Vec::new(); + for (flags, source) in [ + ( + &["-l", "-i", "-c"][..], + ResolvedEnvironmentSource::InteractiveLoginShell, + ), + (&["-l", "-c"][..], ResolvedEnvironmentSource::LoginShell), + ] { + match run_shell(session, &shell, flags, &script) { + Ok(environment) => { + return EnvironmentResolveResult { + environment, + source, + diagnostics: (!diagnostics.is_empty()).then(|| diagnostics.join("; ")), + } + } + Err(error) => diagnostics.push(format!("{shell} {}: {error}", flags.join(" "))), + } + } + EnvironmentResolveResult { + environment: server_environment(), + source: ResolvedEnvironmentSource::Server, + diagnostics: Some(diagnostics.join("; ")), + } +} + +fn run_shell( + session: &Arc, + shell: &str, + flags: &[&str], + script: &str, +) -> io::Result> { + let mut command = Command::new(shell); + command + .args(flags) + .arg(script) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + // A new session without a controlling terminal: an interactive shell + // cannot steal the user's terminal or stop on job-control signals. + platform::prepare_child(&mut command, ChildKind::Plain); + let child = session.spawner.run(move || command.spawn())?; + let (_status, stdout) = platform::collect_output(child, SHELL_TIMEOUT)?; + parse_environment(&stdout) + .ok_or_else(|| io::Error::other("the shell did not print an environment")) +} + +/// Extracts the JSON object printed between the markers; everything else +/// the profiles printed is ignored. +fn parse_environment(output: &[u8]) -> Option> { + let text = String::from_utf8_lossy(output); + let (_, rest) = text.split_once(BEGIN_MARKER)?; + let (json, _) = rest.split_once(END_MARKER)?; + let mut environment: BTreeMap = serde_json::from_str(json.trim()).ok()?; + for name in SHELL_LOCAL_VARIABLES { + environment.remove(*name); + } + Some(environment) +} + +/// Implements `--print-environment`: the server's own environment between +/// markers, as one JSON object. Variables that are not valid UTF-8 are left +/// out because the protocol cannot carry them. +pub fn print_environment() -> io::Result<()> { + let environment: BTreeMap = std::env::vars_os() + .filter_map(|(name, value)| Some((name.into_string().ok()?, value.into_string().ok()?))) + .collect(); + let mut stdout = io::stdout().lock(); + writeln!(stdout, "{BEGIN_MARKER}")?; + serde_json::to_writer(&mut stdout, &environment).map_err(io::Error::from)?; + writeln!(stdout, "\n{END_MARKER}")?; + stdout.flush() +} + +#[cfg(test)] +mod tests { + use super::*; + + // Profiles print greetings, version-manager notices and prompts around + // the payload; only the marked object counts, minus shell-local state. + #[test] + fn environment_is_read_between_markers() { + let output = b"Welcome!\nnvm: using node 20\nLITHE-ENVIRONMENT-BEGIN\n{\"PATH\":\"/home/dev/.nvm/bin:/usr/bin\",\"SHLVL\":\"2\",\"LANG\":\"C.UTF-8\"}\nLITHE-ENVIRONMENT-END\nbye\n"; + let environment = parse_environment(output).unwrap(); + assert_eq!( + environment.get("PATH").map(String::as_str), + Some("/home/dev/.nvm/bin:/usr/bin") + ); + assert!(!environment.contains_key("SHLVL")); + assert_eq!(environment.len(), 2); + assert!(parse_environment(b"zsh: command not found").is_none()); + } +} diff --git a/rust/lithe-remote-server/src/fs.rs b/rust/lithe-remote-server/src/fs.rs new file mode 100644 index 0000000000..82e67d9499 --- /dev/null +++ b/rust/lithe-remote-server/src/fs.rs @@ -0,0 +1,548 @@ +//! Filesystem operations on the remote host. +//! +//! The server acts with the remote user's own permissions, exactly like the +//! shell the user could open over the same transport. It therefore does not +//! confine paths to the workspace; the client decides which paths it shows. + +use crate::{parse, platform, to_value}; +use lithe_remote_protocol::methods::{ + self, decode_bytes, encode_bytes, CopyParams, CopyResult, CreateDirParams, DirectoryEntry, + FileKind, FileMetadata, PathParams, ReadDirResult, ReadFileParams, ReadFileResult, + RealPathResult, RemoveParams, RenameParams, SetPermissionsParams, VolumeInfo, WriteFileParams, + MAX_TRANSFER_BYTES, +}; +use lithe_remote_protocol::{ErrorCode, RemoteError}; +use serde_json::{json, Value}; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read, Seek, SeekFrom, Write}; +use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::UNIX_EPOCH; + +static TEMPORARY_FILE_COUNTER: AtomicU64 = AtomicU64::new(0); + +pub(crate) fn handle(method: &str, params: Value) -> Result { + match method { + methods::FS_STAT => { + let params: PathParams = parse(params)?; + to_value(stat(&host_path(¶ms.path)?, ¶ms.path)?) + } + methods::FS_READ_DIR => to_value(read_dir(parse(params)?)?), + methods::FS_READ_FILE => to_value(read_file(parse(params)?)?), + methods::FS_WRITE_FILE => to_value(write_file(parse(params)?)?), + methods::FS_CREATE_DIR => create_dir(parse(params)?).map(|()| Value::Null), + methods::FS_REMOVE => remove(parse(params)?).map(|()| Value::Null), + methods::FS_RENAME => rename(parse(params)?).map(|()| Value::Null), + methods::FS_COPY => to_value(copy(parse(params)?)?), + methods::FS_REAL_PATH => { + let params: PathParams = parse(params)?; + let resolved = fs::canonicalize(host_path(¶ms.path)?) + .map_err(|error| io_error(error, ¶ms.path))?; + let path = resolved.into_os_string().into_string().map_err(|_| { + RemoteError::new( + ErrorCode::InvalidPath, + "the resolved path is not valid UTF-8", + ) + })?; + to_value(RealPathResult { path }) + } + methods::FS_SET_PERMISSIONS => { + let params: SetPermissionsParams = parse(params)?; + if params.mode > 0o7777 { + return Err(RemoteError::new( + ErrorCode::InvalidRequest, + "mode has bits beyond 0o7777", + )); + } + fs::set_permissions( + host_path(¶ms.path)?, + fs::Permissions::from_mode(params.mode), + ) + .map_err(|error| io_error(error, ¶ms.path)) + .map(|()| Value::Null) + } + methods::FS_VOLUME_INFO => { + let params: PathParams = parse(params)?; + let path = host_path(¶ms.path)?; + to_value(VolumeInfo { + case_sensitive: platform::case_sensitive(&path), + }) + } + _ => Err(RemoteError::new( + ErrorCode::UnknownMethod, + format!("unknown method {method}"), + )), + } +} + +/// Accepts only absolute paths. A relative path would resolve against the +/// server's working directory, which depends on how the transport started it. +pub(crate) fn host_path(path: &str) -> Result { + if !path.starts_with('/') || path.contains('\0') { + return Err( + RemoteError::new(ErrorCode::InvalidPath, "paths must be absolute host paths") + .with_details(json!({ "path": path })), + ); + } + Ok(PathBuf::from(path)) +} + +fn stat(path: &Path, shown: &str) -> Result { + let metadata = fs::symlink_metadata(path).map_err(|error| io_error(error, shown))?; + Ok(describe(path, &metadata)) +} + +fn describe(path: &Path, metadata: &fs::Metadata) -> FileMetadata { + let kind = kind_of(&metadata.file_type()); + let (symlink_target, symlink_target_kind) = if kind == FileKind::Symlink { + ( + fs::read_link(path) + .ok() + .and_then(|target| target.into_os_string().into_string().ok()), + fs::metadata(path) + .ok() + .map(|target| kind_of(&target.file_type())), + ) + } else { + (None, None) + }; + FileMetadata { + kind, + size: metadata.len(), + modified_ms: metadata + .modified() + .ok() + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .and_then(|duration| i64::try_from(duration.as_millis()).ok()), + readonly: metadata.permissions().readonly(), + mode: metadata.mode() & 0o7777, + symlink_target, + symlink_target_kind, + } +} + +pub(crate) fn kind_of(file_type: &fs::FileType) -> FileKind { + if file_type.is_symlink() { + FileKind::Symlink + } else if file_type.is_dir() { + FileKind::Directory + } else if file_type.is_file() { + FileKind::File + } else { + FileKind::Other + } +} + +fn read_dir(params: PathParams) -> Result { + let path = host_path(¶ms.path)?; + let listing = fs::read_dir(&path).map_err(|error| io_error(error, ¶ms.path))?; + let mut entries = Vec::new(); + let mut skipped_non_utf8 = 0; + for entry in listing { + let entry = entry.map_err(|error| io_error(error, ¶ms.path))?; + let Ok(name) = entry.file_name().into_string() else { + skipped_non_utf8 += 1; + continue; + }; + // An entry deleted between listing and stat no longer exists, so it + // is omitted like any other entry created or deleted after the call. + let Ok(metadata) = fs::symlink_metadata(entry.path()) else { + continue; + }; + entries.push(DirectoryEntry { + metadata: describe(&entry.path(), &metadata), + name, + }); + } + entries.sort_by(|left, right| left.name.cmp(&right.name)); + Ok(ReadDirResult { + entries, + skipped_non_utf8, + }) +} + +fn read_file(params: ReadFileParams) -> Result { + if params.length > MAX_TRANSFER_BYTES { + return Err(too_large(params.length)); + } + let path = host_path(¶ms.path)?; + let mut file = File::open(&path).map_err(|error| io_error(error, ¶ms.path))?; + let metadata = file + .metadata() + .map_err(|error| io_error(error, ¶ms.path))?; + if metadata.is_dir() { + return Err(RemoteError::new( + ErrorCode::IsADirectory, + "cannot read a directory", + )); + } + file.seek(SeekFrom::Start(params.offset)) + .map_err(|error| io_error(error, ¶ms.path))?; + let mut data = Vec::new(); + file.take(params.length) + .read_to_end(&mut data) + .map_err(|error| io_error(error, ¶ms.path))?; + let eof = params.offset.saturating_add(data.len() as u64) >= metadata.len(); + Ok(ReadFileResult { + data: encode_bytes(&data), + eof, + metadata: describe(&path, &metadata), + }) +} + +/// Replaces a file atomically: the content goes to a temporary sibling that +/// is renamed over the target, so readers and crashes never observe a +/// partially written file. Symbolic links are written through, keeping the +/// link itself intact. The rename gives the file a new inode, which breaks +/// hard links, the same trade-off other editors' safe-write mode makes. +fn write_file(params: WriteFileParams) -> Result { + let data = decode_bytes(¶ms.data) + .map_err(|error| RemoteError::new(ErrorCode::InvalidRequest, error.to_string()))?; + if data.len() as u64 > MAX_TRANSFER_BYTES { + return Err(too_large(data.len() as u64)); + } + let requested = host_path(¶ms.path)?; + let target = match fs::symlink_metadata(&requested) { + Ok(metadata) if metadata.file_type().is_symlink() => { + fs::canonicalize(&requested).map_err(|error| io_error(error, ¶ms.path))? + } + _ => requested, + }; + let current = match fs::metadata(&target) { + Ok(metadata) => Some(metadata), + Err(error) if error.kind() == io::ErrorKind::NotFound => None, + Err(error) => return Err(io_error(error, ¶ms.path)), + }; + if let Some(metadata) = ¤t { + if metadata.is_dir() { + return Err(RemoteError::new( + ErrorCode::IsADirectory, + "cannot replace a directory with a file", + )); + } + } + match (¶ms.expected, ¤t) { + (Some(expected), Some(metadata)) => { + let found = describe(&target, metadata); + if found.size != expected.size || found.modified_ms != expected.modified_ms { + return Err(RemoteError::new( + ErrorCode::Conflict, + "the file changed since it was read", + ) + .with_details(json!({ "size": found.size, "modifiedMs": found.modified_ms }))); + } + } + (Some(_), None) => { + return Err(RemoteError::new( + ErrorCode::Conflict, + "the file was deleted since it was read", + )) + } + (None, Some(_)) if !params.overwrite => { + return Err(RemoteError::new( + ErrorCode::AlreadyExists, + "the file already exists", + )) + } + _ => {} + } + + let parent = target + .parent() + .ok_or_else(|| RemoteError::new(ErrorCode::InvalidPath, "cannot write the root"))?; + if params.create_parents { + fs::create_dir_all(parent).map_err(|error| io_error(error, ¶ms.path))?; + } + let name = target + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(); + let temporary = parent.join(format!( + ".{name}.lithe-{}-{}.tmp", + std::process::id(), + TEMPORARY_FILE_COUNTER.fetch_add(1, Ordering::Relaxed) + )); + // Without `expected` or `overwrite` the request only creates. The + // existence check above cannot protect the commit from a concurrent + // creation of the same path, so the commit itself must refuse to replace. + let create_only = params.expected.is_none() && !params.overwrite; + let written = write_temporary(&temporary, &data, current.as_ref()).and_then(|()| { + if create_only { + platform::rename_no_replace(&temporary, &target) + } else { + fs::rename(&temporary, &target) + } + }); + if let Err(error) = written { + let _ = fs::remove_file(&temporary); + return Err(io_error(error, ¶ms.path)); + } + stat(&target, ¶ms.path) +} + +/// Writes the replacement content. When a file is replaced, the temporary +/// file is created with at most the original's permission bits, so content +/// of an owner-only file is never readable by other users through the +/// temporary name; the umask can only remove bits, and the exact original +/// mode is restored before the rename publishes the file. +fn write_temporary(path: &Path, data: &[u8], replaced: Option<&fs::Metadata>) -> io::Result<()> { + let mut file = create_temporary(path, replaced)?; + file.write_all(data)?; + if let Some(metadata) = replaced { + file.set_permissions(metadata.permissions())?; + } + file.sync_all() +} + +fn create_temporary(path: &Path, replaced: Option<&fs::Metadata>) -> io::Result { + let mode = replaced.map_or(0o666, |metadata| metadata.permissions().mode() & 0o777); + OpenOptions::new() + .write(true) + .create_new(true) + .mode(mode) + .open(path) +} + +fn create_dir(params: CreateDirParams) -> Result<(), RemoteError> { + let path = host_path(¶ms.path)?; + let created = if params.recursive { + fs::create_dir_all(&path) + } else { + fs::create_dir(&path) + }; + created.map_err(|error| io_error(error, ¶ms.path)) +} + +fn remove(params: RemoveParams) -> Result<(), RemoteError> { + let path = host_path(¶ms.path)?; + let metadata = fs::symlink_metadata(&path).map_err(|error| io_error(error, ¶ms.path))?; + // `symlink_metadata` keeps a link to a directory classified as a link, so + // removal deletes the link and never the directory it points to. + let removed = if metadata.is_dir() { + if params.recursive { + fs::remove_dir_all(&path) + } else { + fs::remove_dir(&path) + } + } else { + fs::remove_file(&path) + }; + removed.map_err(|error| io_error(error, ¶ms.path)) +} + +fn rename(params: RenameParams) -> Result<(), RemoteError> { + let from = host_path(¶ms.from)?; + let to = host_path(¶ms.to)?; + let renamed = if params.overwrite { + fs::rename(&from, &to) + } else { + platform::rename_no_replace(&from, &to) + }; + renamed.map_err(|error| io_error(error, ¶ms.from)) +} + +fn copy(params: CopyParams) -> Result { + let from = host_path(¶ms.from)?; + let to = host_path(¶ms.to)?; + let source = fs::symlink_metadata(&from).map_err(|error| io_error(error, ¶ms.from))?; + let kind = kind_of(&source.file_type()); + if kind == FileKind::Other { + return Err(RemoteError::new( + ErrorCode::Unsupported, + "sockets, FIFOs and devices cannot be copied", + )); + } + if kind == FileKind::Directory && !params.recursive { + return Err(RemoteError::new( + ErrorCode::IsADirectory, + "copying a directory requires recursive", + )); + } + if kind == FileKind::Directory && is_inside(&from, &to) { + // Copying a directory into itself would recurse until the disk is full. + return Err(RemoteError::new( + ErrorCode::InvalidRequest, + "cannot copy a directory into itself", + )); + } + match fs::symlink_metadata(&to) { + Ok(_) if !params.overwrite => { + return Err(RemoteError::new( + ErrorCode::AlreadyExists, + "the target already exists", + )) + } + Ok(existing) if existing.is_dir() && kind != FileKind::Directory => { + return Err(RemoteError::new( + ErrorCode::IsADirectory, + "cannot replace a directory with a file", + )) + } + Ok(_) if same_file(&from, &to) => { + // Overwriting truncates the target before the source is read, so + // copying a file onto itself would erase it. + return Err(RemoteError::new( + ErrorCode::InvalidRequest, + "the source and the target are the same file", + )); + } + _ => {} + } + let mut result = CopyResult::default(); + copy_entry(&from, &to, &source, &mut result).map_err(|error| io_error(error, ¶ms.from))?; + Ok(result) +} + +/// Whether copying `from` onto the existing `to` would destroy the source. +/// +/// A link source replaces only the entry at `to`, so it is destructive when +/// that entry is the link itself or the file the link points to. A file +/// source is written through `to` with truncation, so it is destructive when +/// `to` resolves to the source file, directly, through a link or as a hard +/// link. +fn same_file(from: &Path, to: &Path) -> bool { + let identity = |metadata: io::Result| { + metadata + .ok() + .map(|metadata| (metadata.dev(), metadata.ino())) + }; + let source_entry = identity(fs::symlink_metadata(from)); + let source_file = identity(fs::metadata(from)); + let is_link = + fs::symlink_metadata(from).is_ok_and(|metadata| metadata.file_type().is_symlink()); + if is_link { + let target_entry = identity(fs::symlink_metadata(to)); + target_entry.is_some() && (target_entry == source_entry || target_entry == source_file) + } else { + let target_file = identity(fs::metadata(to)); + target_file.is_some() && target_file == source_file + } +} + +/// Whether `target` is `directory` or lies inside it once links are +/// resolved, for example `/w/link-to-src/inner` with `link-to-src -> src`. +fn is_inside(directory: &Path, target: &Path) -> bool { + let Ok(directory) = fs::canonicalize(directory) else { + return target.starts_with(directory); + }; + let resolved = fs::canonicalize(target).or_else(|_| { + let parent = target + .parent() + .ok_or_else(|| io::Error::from(io::ErrorKind::NotFound))?; + let name = target + .file_name() + .ok_or_else(|| io::Error::from(io::ErrorKind::NotFound))?; + fs::canonicalize(parent).map(|parent| parent.join(name)) + }); + match resolved { + Ok(resolved) => resolved.starts_with(&directory), + Err(_) => target.starts_with(&directory), + } +} + +fn copy_entry( + from: &Path, + to: &Path, + metadata: &fs::Metadata, + result: &mut CopyResult, +) -> io::Result<()> { + let file_type = metadata.file_type(); + if file_type.is_symlink() { + let target = fs::read_link(from)?; + if fs::symlink_metadata(to).is_ok() { + fs::remove_file(to)?; + } + return std::os::unix::fs::symlink(target, to); + } + if file_type.is_dir() { + match fs::create_dir(to) { + Err(error) if error.kind() != io::ErrorKind::AlreadyExists => return Err(error), + _ => {} + } + let mut children: Vec<_> = fs::read_dir(from)?.collect::>()?; + children.sort_by_key(|entry| entry.file_name()); + for child in children { + let child_metadata = fs::symlink_metadata(child.path())?; + copy_entry( + &child.path(), + &to.join(child.file_name()), + &child_metadata, + result, + )?; + } + return fs::set_permissions(to, metadata.permissions()); + } + if !file_type.is_file() { + // Opening a FIFO for reading blocks until a writer appears, which may + // be never, and devices are not file content. + result.skipped_special_files += 1; + return Ok(()); + } + if fs::symlink_metadata(to).is_ok() && same_file(from, to) { + // A merge into an existing tree can meet a hard link of the source. + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("{} and its target are the same file", from.display()), + )); + } + // `fs::copy` keeps permission bits and uses copy-on-write clones where + // the filesystem supports them. + fs::copy(from, to).map(|_| ()) +} + +fn too_large(length: u64) -> RemoteError { + RemoteError::new( + ErrorCode::TooLarge, + format!("{length} bytes exceed the {MAX_TRANSFER_BYTES}-byte transfer limit"), + ) +} + +pub(crate) fn io_error(error: io::Error, path: &str) -> RemoteError { + let code = match error.kind() { + io::ErrorKind::NotFound => ErrorCode::NotFound, + io::ErrorKind::PermissionDenied => ErrorCode::PermissionDenied, + io::ErrorKind::AlreadyExists => ErrorCode::AlreadyExists, + io::ErrorKind::NotADirectory => ErrorCode::NotADirectory, + io::ErrorKind::IsADirectory => ErrorCode::IsADirectory, + io::ErrorKind::DirectoryNotEmpty => ErrorCode::DirectoryNotEmpty, + _ => ErrorCode::Io, + }; + RemoteError::new(code, error.to_string()) + .with_details(json!({ "path": path, "osError": error.raw_os_error() })) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Other users must not be able to open the temporary file while it holds + // the new content of an owner-only file; tightening the mode afterwards + // would not revoke a descriptor they already opened. + #[test] + fn temporary_file_for_an_owner_only_file_starts_owner_only() { + let directory = std::env::temp_dir().join(format!( + "lithe-remote-fs-mode-{}-{}", + std::process::id(), + TEMPORARY_FILE_COUNTER.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&directory).unwrap(); + let _cleanup = RemoveOnDrop(directory.clone()); + let original = directory.join("secret"); + fs::write(&original, b"old").unwrap(); + fs::set_permissions(&original, fs::Permissions::from_mode(0o600)).unwrap(); + let replaced = fs::metadata(&original).unwrap(); + + let temporary = directory.join("secret.tmp"); + let file = create_temporary(&temporary, Some(&replaced)).unwrap(); + assert_eq!(file.metadata().unwrap().permissions().mode() & 0o777, 0o600); + } + + struct RemoveOnDrop(PathBuf); + + impl Drop for RemoveOnDrop { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } +} diff --git a/rust/lithe-remote-server/src/lib.rs b/rust/lithe-remote-server/src/lib.rs new file mode 100644 index 0000000000..1223e28d7f --- /dev/null +++ b/rust/lithe-remote-server/src/lib.rs @@ -0,0 +1,90 @@ +//! Remote workspace server. +//! +//! The server owns every host-side capability of a remote workspace: +//! filesystem access, walks, file watching, processes, terminals, the user's +//! login environment and tunnels. It is started by a transport (SSH, +//! `docker exec`, a chain of them, or a local child process in tests) and +//! speaks the `lithe-remote-protocol`. +//! +//! State lives in a [`session::Session`], not in a connection. In `--stdio` +//! mode the session ends with its only connection. In `--connect` mode a +//! daemon keeps the session alive across disconnections, so terminals and +//! builds survive a dropped network link, and a reconnecting client receives +//! the events it missed. +//! +//! Host differences live in `platform`; the other modules only use the +//! capabilities that module exposes. +//! +//! Note: .agents/notes/proposed/architecture/2026-10-24-remote-development-workspace-host.md + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +compile_error!("lithe-remote-server supports Linux and macOS hosts only"); + +mod connection; +mod daemon; +mod environment; +mod fs; +mod link; +mod platform; +mod ports; +mod process; +mod resources; +mod session; +mod spawner; +mod terminal; +mod tunnel; +mod walk; +mod watch; + +pub use daemon::{run_daemon, run_proxy, PersistentOptions}; +pub use environment::print_environment; + +use lithe_remote_protocol::methods::ServerIdentity; +use lithe_remote_protocol::{ErrorCode, RemoteError, PROTOCOL_VERSION}; +use serde::de::DeserializeOwned; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::io::{self, Read, Write}; + +/// Serves one client on the given streams. The session, and everything it +/// started, ends when the client disconnects or calls `server.shutdown`. +pub fn serve_stdio(input: impl Read, output: impl Write + Send + 'static) -> io::Result<()> { + let session = session::Session::new(false)?; + let result = connection::serve(&session, input, output).map(|_| ()); + session.terminate_all(); + result +} + +/// Identity printed by `--identity`. +pub fn identity() -> io::Result { + let executable = std::env::current_exe()?; + let mut file = std::fs::File::open(executable)?; + let mut hasher = Sha256::new(); + io::copy(&mut file, &mut hasher)?; + Ok(ServerIdentity { + server_version: env!("CARGO_PKG_VERSION").into(), + protocol_version: PROTOCOL_VERSION, + sha256: hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(), + }) +} + +pub(crate) fn parse(params: Value) -> Result { + serde_json::from_value(params) + .map_err(|error| RemoteError::new(ErrorCode::InvalidRequest, error.to_string())) +} + +pub(crate) fn to_value(value: impl serde::Serialize) -> Result { + serde_json::to_value(value).map_err(|error| RemoteError::new(ErrorCode::Io, error.to_string())) +} + +/// Locks a mutex, recovering the data if a panicking thread poisoned it. The +/// server keeps serving other resources rather than failing every request. +pub(crate) fn lock(mutex: &std::sync::Mutex) -> std::sync::MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} diff --git a/rust/lithe-remote-server/src/link.rs b/rust/lithe-remote-server/src/link.rs new file mode 100644 index 0000000000..a6994c4cf7 --- /dev/null +++ b/rust/lithe-remote-server/src/link.rs @@ -0,0 +1,223 @@ +//! Delivery of events to whichever connection is attached to the session. +//! +//! Responses belong to the connection that sent the request and go straight +//! to its outbox. Events belong to the session. Each event gets the next +//! sequence number and is kept until the client acknowledges it with +//! `server.ack`, because "written to the socket" is not "received": when a +//! connection dies, events can still sit in its outbox or in kernel buffers. +//! A connection receives events only after `server.resume`, which first +//! sends every kept event after the sequence number the client last saw. +//! This lets a reconnecting client subscribe before its missed events arrive, +//! and gives it each event exactly once. + +use lithe_remote_protocol::Message; +use serde::Serialize; +use std::collections::VecDeque; +use std::sync::mpsc::SyncSender; +use std::sync::Mutex; + +/// Bytes of serialized, unacknowledged events kept for redelivery. Beyond it +/// the oldest are dropped, and a client resuming before them is told to +/// re-read state. +const RETENTION_LIMIT_BYTES: usize = 8 * 1024 * 1024; + +/// Item consumed by a connection's writer thread. +pub(crate) enum Outgoing { + Message(Message), + /// Ends the writer even while other threads still hold senders. + Close, +} + +/// Handle used to queue messages for one connection. +#[derive(Clone)] +pub(crate) struct Outbox(pub(crate) SyncSender); + +impl Outbox { + /// Queues a message. Returns `false` when the writer is gone because the + /// client disconnected. + pub(crate) fn send(&self, message: Message) -> bool { + self.0.send(Outgoing::Message(message)).is_ok() + } +} + +pub(crate) struct Link { + state: Mutex, +} + +#[derive(Default)] +struct LinkState { + attached: Option<(u64, Outbox)>, + /// Sequence number of the latest event. + last_seq: u64, + /// Unacknowledged events, oldest first. + retained: VecDeque, + retained_bytes: usize, + /// Highest sequence number dropped before it was acknowledged. + dropped_through: u64, +} + +struct Retained { + seq: u64, + message: Message, + size: usize, +} + +impl Link { + pub(crate) fn new() -> Self { + Self { + state: Mutex::new(LinkState::default()), + } + } + + /// Records an event and sends it to the resumed client, if any. + /// + /// The lock is held while sending, so a slow client blocks producers. + /// That is the intended backpressure: output readers stop reading and + /// the remote processes block on their pipes instead of growing memory. + pub(crate) fn emit(&self, event: &str, params: impl Serialize) { + let params = match serde_json::to_value(params) { + Ok(params) => params, + Err(error) => { + eprintln!("lithe-remote-server: cannot encode {event}: {error}"); + return; + } + }; + let mut state = crate::lock(&self.state); + state.last_seq += 1; + let seq = state.last_seq; + let message = Message::Event { + event: event.into(), + params, + seq: Some(seq), + }; + let size = serde_json::to_vec(&message).map_or(0, |bytes| bytes.len()); + state.retained.push_back(Retained { + seq, + message: message.clone(), + size, + }); + state.retained_bytes += size; + while state.retained_bytes > RETENTION_LIMIT_BYTES { + let Some(dropped) = state.retained.pop_front() else { + break; + }; + state.retained_bytes -= dropped.size; + state.dropped_through = dropped.seq; + } + if let Some((_, outbox)) = &state.attached { + if !outbox.send(message) { + // The writer died before the connection noticed. The event + // stays retained for the next client. + state.attached = None; + } + } + } + + pub(crate) fn last_seq(&self) -> u64 { + crate::lock(&self.state).last_seq + } + + /// Starts delivery to `outbox`. Under one lock it sends the response + /// built by `respond` (which learns whether events after `after_seq` were + /// dropped), then every kept event after `after_seq`, so later events + /// cannot overtake them. + pub(crate) fn resume( + &self, + connection: u64, + outbox: Outbox, + after_seq: u64, + respond: impl FnOnce(bool) -> Message, + ) { + let mut state = crate::lock(&self.state); + let truncated = state.dropped_through > after_seq; + outbox.send(respond(truncated)); + for retained in state + .retained + .iter() + .filter(|retained| retained.seq > after_seq) + { + outbox.send(retained.message.clone()); + } + state.attached = Some((connection, outbox)); + } + + /// Forgets events up to `seq`, which the client confirmed. + pub(crate) fn acknowledge(&self, seq: u64) { + let mut state = crate::lock(&self.state); + while state + .retained + .front() + .is_some_and(|retained| retained.seq <= seq) + { + if let Some(acknowledged) = state.retained.pop_front() { + state.retained_bytes -= acknowledged.size; + } + } + } + + /// Detaches `connection` if it is still the attached one. A replacing + /// connection may already have resumed, and must not be detached. + pub(crate) fn detach(&self, connection: u64) { + let mut state = crate::lock(&self.state); + if matches!(&state.attached, Some((attached, _)) if *attached == connection) { + state.attached = None; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::sync::mpsc; + + fn outbox() -> (Outbox, mpsc::Receiver) { + let (sender, receiver) = mpsc::sync_channel(1024); + (Outbox(sender), receiver) + } + + fn seqs(receiver: &mpsc::Receiver) -> Vec { + receiver + .try_iter() + .filter_map(|outgoing| match outgoing { + Outgoing::Message(Message::Event { seq, .. }) => seq, + _ => None, + }) + .collect() + } + + // A connection that died after its outbox accepted events must not lose + // them: the next connection resumes after the last event the client + // actually received, and gets each later event exactly once. + #[test] + fn resuming_redelivers_everything_after_the_last_received_event() { + let link = Link::new(); + let (first, first_received) = outbox(); + link.resume(1, first, 0, |_| Message::success(0, json!(null))); + for index in 0..5 { + link.emit("process.output", json!({ "index": index })); + } + // The client confirmed only the first two before the link died. + link.acknowledge(2); + assert_eq!(seqs(&first_received), [1, 2, 3, 4, 5]); + link.detach(1); + link.emit("process.exit", json!({})); + + let (second, second_received) = outbox(); + link.resume(2, second, 3, |truncated| { + assert!(!truncated); + Message::success(0, json!(null)) + }); + assert_eq!(seqs(&second_received), [4, 5, 6]); + assert_eq!(link.last_seq(), 6); + } + + #[test] + fn events_are_kept_until_a_client_resumes() { + let link = Link::new(); + link.emit("terminal.output", json!({})); + let (outbox, received) = outbox(); + link.resume(1, outbox, 0, |_| Message::success(0, json!(null))); + assert_eq!(seqs(&received), [1]); + } +} diff --git a/rust/lithe-remote-server/src/main.rs b/rust/lithe-remote-server/src/main.rs new file mode 100644 index 0000000000..bc8980993a --- /dev/null +++ b/rust/lithe-remote-server/src/main.rs @@ -0,0 +1,63 @@ +//! Command-line entry of the remote workspace server. +//! +//! - `--stdio`: one session on standard input and output, ending with it. +//! - `--connect `: relay standard streams to the persistent session +//! ``, starting its daemon when needed. This is what transports run. +//! - `--daemon `: the persistent session itself, started by `--connect`. +//! - `--identity`, `--version`, `--print-environment`: helpers for the +//! bootstrapper and for environment resolution. +//! +//! Nothing but protocol bytes may reach standard output in `--stdio` and +//! `--connect` modes; diagnostics go to standard error. + +use lithe_remote_server::PersistentOptions; +use std::io::{self, Write}; +use std::path::PathBuf; +use std::process::ExitCode; + +const USAGE: &str = "usage: lithe-remote-server --stdio | --connect [--state-dir ] | --daemon [--state-dir ] | --identity | --version | --print-environment"; + +fn main() -> ExitCode { + let arguments: Vec = std::env::args().skip(1).collect(); + let arguments: Vec<&str> = arguments.iter().map(String::as_str).collect(); + let result = match arguments.as_slice() { + ["--stdio"] => lithe_remote_server::serve_stdio(io::stdin(), io::stdout()), + ["--connect", key, rest @ ..] => { + persistent(key, rest).and_then(|options| lithe_remote_server::run_proxy(&options)) + } + ["--daemon", key, rest @ ..] => { + persistent(key, rest).and_then(|options| lithe_remote_server::run_daemon(&options)) + } + ["--identity"] => lithe_remote_server::identity().and_then(|identity| { + let mut stdout = io::stdout(); + serde_json::to_writer(&mut stdout, &identity) + .map_err(io::Error::from) + .and_then(|()| writeln!(stdout)) + }), + ["--version"] => writeln!(io::stdout(), "{}", env!("CARGO_PKG_VERSION")), + ["--print-environment"] => lithe_remote_server::print_environment(), + _ => { + eprintln!("{USAGE}"); + return ExitCode::from(2); + } + }; + match result { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("lithe-remote-server: {error}"); + ExitCode::FAILURE + } + } +} + +fn persistent(key: &str, rest: &[&str]) -> io::Result { + let state_directory = match rest { + [] => None, + ["--state-dir", directory] => Some(PathBuf::from(directory)), + _ => return Err(io::Error::new(io::ErrorKind::InvalidInput, USAGE)), + }; + Ok(PersistentOptions { + key: key.into(), + state_directory, + }) +} diff --git a/rust/lithe-remote-server/src/platform/linux.rs b/rust/lithe-remote-server/src/platform/linux.rs new file mode 100644 index 0000000000..8a6cc6feb0 --- /dev/null +++ b/rust/lithe-remote-server/src/platform/linux.rs @@ -0,0 +1,236 @@ +//! Linux host adapter, used for SSH hosts and containers. + +use super::{unix, ChildKind}; +use lithe_remote_protocol::methods::{ListeningPort, OperatingSystem, OrphanProtection}; +use std::collections::HashSet; +use std::io; +use std::net::{Ipv4Addr, Ipv6Addr}; +use std::os::fd::RawFd; +use std::os::unix::process::CommandExt; +use std::path::Path; +use std::process::Command; + +pub(crate) const OPERATING_SYSTEM: OperatingSystem = OperatingSystem::Linux; +pub(crate) const ORPHAN_PROTECTION: OrphanProtection = OrphanProtection::ParentDeathSignal; + +/// TCP socket state `LISTEN` in `/proc/net/tcp`. +const TCP_LISTEN_STATE: &str = "0A"; + +/// Configures a child to lead its own session and process group and to be +/// killed by the kernel if the server dies. +/// +/// `PR_SET_PDEATHSIG` fires when the *thread* that forked the child exits, +/// not the process, which is why every spawn runs on the spawner thread. +pub(crate) fn prepare_child(command: &mut Command, kind: ChildKind) { + // SAFETY: getpid has no preconditions. + let server = unsafe { libc::getpid() }; + // SAFETY: the closure only calls async-signal-safe functions, as required + // between fork and exec. + unsafe { + command.pre_exec(move || { + unix::start_new_session()?; + if kind == ChildKind::Terminal { + unix::acquire_controlling_terminal()?; + } + if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) == -1 { + return Err(io::Error::last_os_error()); + } + // The signal is not delivered retroactively: if the server died + // before prctl, the child has already been re-parented. + if libc::getppid() != server { + libc::_exit(1); + } + Ok(()) + }); + } +} + +/// Atomic rename that fails when `to` exists. `renameat2` is called through +/// `syscall` because musl and older glibc builds may lack the wrapper. +pub(crate) fn rename_no_replace(from: &Path, to: &Path) -> io::Result<()> { + let from_c = unix::c_path(from)?; + let to_c = unix::c_path(to)?; + // SAFETY: both pointers reference NUL-terminated strings that outlive the + // call; the remaining arguments are plain integers. + let status = unsafe { + libc::syscall( + libc::SYS_renameat2, + libc::AT_FDCWD, + from_c.as_ptr(), + libc::AT_FDCWD, + to_c.as_ptr(), + libc::RENAME_NOREPLACE, + ) + }; + if status == 0 { + return Ok(()); + } + let error = io::Error::last_os_error(); + match error.raw_os_error() { + // Kernels before 3.15 lack the call; some filesystems reject the flag. + Some(libc::ENOSYS) | Some(libc::EINVAL) => unix::rename_if_absent(from, to), + _ => Err(error), + } +} + +pub(crate) fn case_sensitive(path: &Path) -> Option { + unix::probe_case_sensitivity(path) +} + +/// Processes whose session id is `session`, read from `/proc//stat`. +pub(crate) fn session_members(session: u32) -> Vec { + process_sessions() + .filter(|(_, member_session)| *member_session == session) + .map(|(pid, _)| pid) + .collect() +} + +/// The sessions among `sessions` that have a member, from one scan. +pub(crate) fn sessions_with_members(sessions: &HashSet) -> HashSet { + process_sessions() + .map(|(_, session)| session) + .filter(|session| sessions.contains(session)) + .collect() +} + +/// `(pid, session id)` of every process. +fn process_sessions() -> impl Iterator { + std::fs::read_dir("/proc") + .into_iter() + .flatten() + .flatten() + .filter_map(|entry| entry.file_name().to_str()?.parse::().ok()) + .filter_map(|pid| { + let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; + Some((pid, stat_session(&stat)?)) + }) +} + +/// The session id is the fourth field after the command name, which is +/// parenthesized and may itself contain spaces and parentheses. +fn stat_session(stat: &str) -> Option { + let (_, after_name) = stat.rsplit_once(')')?; + after_name.split_whitespace().nth(3)?.parse().ok() +} + +/// User id of the process on the other end of a Unix socket. +pub(crate) fn peer_uid(socket: RawFd) -> io::Result { + let mut credentials = libc::ucred { + pid: 0, + uid: 0, + gid: 0, + }; + let mut length = std::mem::size_of::() as libc::socklen_t; + // SAFETY: the out-pointers reference live locals of the declared size. + let status = unsafe { + libc::getsockopt( + socket, + libc::SOL_SOCKET, + libc::SO_PEERCRED, + (&mut credentials as *mut libc::ucred).cast(), + &mut length, + ) + }; + if status == -1 { + return Err(io::Error::last_os_error()); + } + Ok(credentials.uid) +} + +/// Listening TCP sockets in the server's network namespace, which inside a +/// container is the container's own. Sockets of other users are included: +/// Docker's port proxies, for example, run as root. +pub(crate) fn listening_ports() -> io::Result> { + let mut ports = Vec::new(); + for (table, ipv6) in [("/proc/net/tcp", false), ("/proc/net/tcp6", true)] { + match std::fs::read_to_string(table) { + Ok(contents) => ports.extend(parse_proc_net_tcp(&contents, ipv6)), + // Kernels without IPv6 have no tcp6 table. + Err(error) if error.kind() == io::ErrorKind::NotFound && ipv6 => {} + Err(error) => return Err(error), + } + } + Ok(ports) +} + +fn parse_proc_net_tcp(contents: &str, ipv6: bool) -> Vec { + contents + .lines() + .skip(1) + .filter_map(|line| { + let mut fields = line.split_whitespace(); + let local = fields.nth(1)?; + let state = fields.nth(1)?; + if state != TCP_LISTEN_STATE { + return None; + } + let (address, port) = local.split_once(':')?; + Some(ListeningPort { + address: if ipv6 { + parse_ipv6(address)?.to_string() + } else { + parse_ipv4(address)?.to_string() + }, + port: u16::from_str_radix(port, 16).ok()?, + }) + }) + .collect() +} + +/// The kernel prints each 32-bit word of an address in host byte order. +fn parse_ipv4(hex: &str) -> Option { + let word = u32::from_str_radix(hex, 16).ok()?; + Some(Ipv4Addr::from(word.to_ne_bytes())) +} + +fn parse_ipv6(hex: &str) -> Option { + if hex.len() != 32 { + return None; + } + let mut bytes = [0_u8; 16]; + for (index, chunk) in bytes.chunks_mut(4).enumerate() { + let word = u32::from_str_radix(&hex[index * 8..index * 8 + 8], 16).ok()?; + chunk.copy_from_slice(&word.to_ne_bytes()); + } + Some(Ipv6Addr::from(bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Fixture lines copied from a little-endian host: 127.0.0.1:8080 and + // 0.0.0.0:22 listening, plus an established connection that is ignored. + #[test] + fn session_ids_are_read_after_the_parenthesized_name() { + let stat = "4242 (my (odd) name) S 1 4242 4242 34816 4242 4194560 0"; + assert_eq!(stat_session(stat), Some(4242)); + } + + #[test] + fn proc_net_tables_yield_listening_sockets_only() { + let ipv4 = " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n 0: 0100007F:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 1 1 0 100 0 0 10 0\n 1: 00000000:0016 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 2 1 0 100 0 0 10 0\n 2: 0100007F:1F90 0100007F:D431 01 00000000:00000000 00:00000000 00000000 1000 0 3 1 0 20 4 30 10 -1\n"; + let ports = parse_proc_net_tcp(ipv4, false); + assert_eq!( + ports, + vec![ + ListeningPort { + address: "127.0.0.1".into(), + port: 8080 + }, + ListeningPort { + address: "0.0.0.0".into(), + port: 22 + }, + ] + ); + let ipv6 = " sl local_address remote_address st\n 0: 00000000000000000000000001000000:1538 00000000000000000000000000000000:0000 0A\n"; + assert_eq!( + parse_proc_net_tcp(ipv6, true), + vec![ListeningPort { + address: "::1".into(), + port: 5432 + }] + ); + } +} diff --git a/rust/lithe-remote-server/src/platform/linux_watch.rs b/rust/lithe-remote-server/src/platform/linux_watch.rs new file mode 100644 index 0000000000..96e468f8a5 --- /dev/null +++ b/rust/lithe-remote-server/src/platform/linux_watch.rs @@ -0,0 +1,300 @@ +//! inotify backend for recursive watching on Linux. +//! +//! inotify watches single directories, so the backend registers every +//! directory of the tree and follows creations, moves and deletions to keep +//! the registration current. Directories created after the walk are +//! registered on their creation event, and their existing contents are +//! reported as created, because files written before the registration +//! produce no events of their own. + +use crate::watch::{Excludes, RawEvent, Sink}; +use lithe_remote_protocol::methods::{ChangeKind, WatchBackend}; +use std::collections::HashMap; +use std::ffi::OsStr; +use std::io; +use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}; +use std::os::unix::ffi::OsStrExt; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::thread::{self, JoinHandle}; + +pub(crate) const BACKEND: WatchBackend = WatchBackend::Inotify; + +const WATCH_MASK: u32 = libc::IN_CREATE + | libc::IN_DELETE + | libc::IN_MOVED_FROM + | libc::IN_MOVED_TO + | libc::IN_MODIFY + | libc::IN_CLOSE_WRITE + | libc::IN_ATTRIB + | libc::IN_DELETE_SELF + | libc::IN_MOVE_SELF + | libc::IN_ONLYDIR + | libc::IN_DONT_FOLLOW + | libc::IN_EXCL_UNLINK; + +/// Read buffer for events; one event is at most a header plus `NAME_MAX`. +const EVENT_BUFFER_BYTES: usize = 64 * 1024; + +/// Per-user directory limit configured on the host. +pub(crate) fn directory_limit() -> Option { + std::fs::read_to_string("/proc/sys/fs/inotify/max_user_watches") + .ok()? + .trim() + .parse() + .ok() +} + +/// Running backend. Dropping it stops the thread and closes the inotify +/// descriptor, which releases every registration at once. +pub(crate) struct Watcher { + stop: Option, + thread: Option>, +} + +impl Drop for Watcher { + fn drop(&mut self) { + // Closing the write end makes the stop pipe readable (end-of-file). + self.stop.take(); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +pub(crate) fn start( + root: PathBuf, + excludes: Arc, + sink: Sink, +) -> io::Result<(Watcher, Option)> { + // SAFETY: inotify_init1 has no memory-safety preconditions. + let fd = unsafe { libc::inotify_init1(libc::IN_NONBLOCK | libc::IN_CLOEXEC) }; + if fd == -1 { + return Err(io::Error::last_os_error()); + } + // SAFETY: the descriptor was just created and is owned here. + let inotify = unsafe { OwnedFd::from_raw_fd(fd) }; + let mut tree = Tree { + fd: inotify.as_raw_fd(), + root: root.clone(), + by_descriptor: HashMap::new(), + limit_reported: false, + excludes, + }; + let root_descriptor = tree.add(&root)?; + tree.add_below(&root, &sink, false); + let watched = tree.by_descriptor.len() as u64; + + let (stop_read, stop_write) = pipe()?; + let thread = thread::Builder::new() + .name("remote-inotify".into()) + .spawn(move || { + let _inotify = inotify; + tree.run(root_descriptor, stop_read, sink); + })?; + Ok(( + Watcher { + stop: Some(stop_write), + thread: Some(thread), + }, + Some(watched), + )) +} + +struct Tree { + fd: RawFd, + root: PathBuf, + by_descriptor: HashMap, + limit_reported: bool, + excludes: Arc, +} + +impl Tree { + fn add(&mut self, directory: &Path) -> io::Result { + let path = std::ffi::CString::new(directory.as_os_str().as_bytes()) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains NUL"))?; + // SAFETY: the path is a NUL-terminated string that outlives the call. + let descriptor = unsafe { libc::inotify_add_watch(self.fd, path.as_ptr(), WATCH_MASK) }; + if descriptor == -1 { + return Err(io::Error::last_os_error()); + } + self.by_descriptor + .insert(descriptor, directory.to_path_buf()); + Ok(descriptor) + } + + /// Registers every directory below `directory`. With `report`, every + /// entry found is also reported as created. + fn add_below(&mut self, directory: &Path, sink: &Sink, report: bool) { + let mut pending = vec![directory.to_path_buf()]; + while let Some(current) = pending.pop() { + let Ok(entries) = std::fs::read_dir(¤t) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if report { + let _ = sink.send(RawEvent::Change(path.clone(), ChangeKind::Created)); + } + let is_directory = entry.file_type().is_ok_and(|kind| kind.is_dir()); + if !is_directory || self.excludes.is_excluded_name(&entry.file_name()) { + continue; + } + if self.limit_reported { + continue; + } + match self.add(&path) { + Ok(_) => pending.push(path), + Err(error) if error.raw_os_error() == Some(libc::ENOSPC) => { + self.limit_reported = true; + let _ = sink.send(RawEvent::LimitReached(format!( + "the inotify watch limit was reached after {} directories; raise fs.inotify.max_user_watches", + self.by_descriptor.len() + ))); + } + // Removed or unreadable meanwhile; nothing to watch. + Err(_) => {} + } + } + } + } + + fn forget_below(&mut self, directory: &Path) { + let stale: Vec = self + .by_descriptor + .iter() + .filter(|(_, path)| path.starts_with(directory)) + .map(|(descriptor, _)| *descriptor) + .collect(); + for descriptor in stale { + // SAFETY: removing a registration has no memory-safety + // preconditions; a stale descriptor only yields EINVAL. + unsafe { libc::inotify_rm_watch(self.fd, descriptor) }; + self.by_descriptor.remove(&descriptor); + } + } + + fn run(&mut self, root_descriptor: i32, stop: OwnedFd, sink: Sink) { + let mut buffer = vec![0_u8; EVENT_BUFFER_BYTES]; + loop { + let mut descriptors = [ + libc::pollfd { + fd: self.fd, + events: libc::POLLIN, + revents: 0, + }, + libc::pollfd { + fd: stop.as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }, + ]; + // SAFETY: the array is live and its length is passed. + let ready = unsafe { libc::poll(descriptors.as_mut_ptr(), 2, -1) }; + if ready == -1 { + if io::Error::last_os_error().kind() == io::ErrorKind::Interrupted { + continue; + } + return; + } + if descriptors[1].revents != 0 { + return; + } + // SAFETY: the buffer is writable for its full length. + let read = unsafe { libc::read(self.fd, buffer.as_mut_ptr().cast(), buffer.len()) }; + if read <= 0 { + continue; + } + if !self.dispatch(&buffer[..read as usize], root_descriptor, &sink) { + return; + } + } + } + + /// Handles one read of events. Returns `false` when the root is gone. + fn dispatch(&mut self, mut bytes: &[u8], root_descriptor: i32, sink: &Sink) -> bool { + let header = std::mem::size_of::(); + while bytes.len() >= header { + // SAFETY: at least one header is available; read_unaligned does + // not require the byte buffer to be aligned. + let event: libc::inotify_event = + unsafe { std::ptr::read_unaligned(bytes.as_ptr().cast()) }; + let name_length = event.len as usize; + let Some(name_bytes) = bytes.get(header..header + name_length) else { + return true; + }; + let name = name_bytes + .split(|byte| *byte == 0) + .next() + .unwrap_or_default(); + bytes = &bytes[header + name_length..]; + + if event.mask & libc::IN_Q_OVERFLOW != 0 { + let _ = sink.send(RawEvent::Overflow); + continue; + } + if event.wd == root_descriptor + && event.mask & (libc::IN_DELETE_SELF | libc::IN_MOVE_SELF) != 0 + { + let _ = sink.send(RawEvent::RootRemoved); + return false; + } + if event.mask & libc::IN_IGNORED != 0 { + self.by_descriptor.remove(&event.wd); + continue; + } + let Some(directory) = self.by_descriptor.get(&event.wd).cloned() else { + continue; + }; + if name.is_empty() { + continue; + } + let path = directory.join(OsStr::from_bytes(name)); + if self.excludes.contains(&path) && path != self.root { + continue; + } + let is_directory = event.mask & libc::IN_ISDIR != 0; + if event.mask & (libc::IN_CREATE | libc::IN_MOVED_TO) != 0 { + let _ = sink.send(RawEvent::Change(path.clone(), ChangeKind::Created)); + if is_directory + && !self.excludes.is_excluded_name(OsStr::from_bytes(name)) + && !self.limit_reported + { + match self.add(&path) { + Ok(_) => self.add_below(&path, sink, true), + Err(error) if error.raw_os_error() == Some(libc::ENOSPC) => { + self.limit_reported = true; + let _ = sink.send(RawEvent::LimitReached( + "the inotify watch limit was reached; raise fs.inotify.max_user_watches".into(), + )); + } + Err(_) => {} + } + } + } else if event.mask & (libc::IN_DELETE | libc::IN_MOVED_FROM) != 0 { + let _ = sink.send(RawEvent::Change(path.clone(), ChangeKind::Deleted)); + if is_directory { + self.forget_below(&path); + } + } else if event.mask & (libc::IN_MODIFY | libc::IN_CLOSE_WRITE | libc::IN_ATTRIB) != 0 { + let _ = sink.send(RawEvent::Change(path, ChangeKind::Modified)); + } + } + true + } +} + +fn pipe() -> io::Result<(OwnedFd, OwnedFd)> { + let mut descriptors = [0 as RawFd; 2]; + // SAFETY: the array holds the two descriptors pipe2 writes. + if unsafe { libc::pipe2(descriptors.as_mut_ptr(), libc::O_CLOEXEC) } == -1 { + return Err(io::Error::last_os_error()); + } + // SAFETY: pipe2 succeeded, so both descriptors are open and owned here. + Ok(unsafe { + ( + OwnedFd::from_raw_fd(descriptors[0]), + OwnedFd::from_raw_fd(descriptors[1]), + ) + }) +} diff --git a/rust/lithe-remote-server/src/platform/macos.rs b/rust/lithe-remote-server/src/platform/macos.rs new file mode 100644 index 0000000000..55f82f68ee --- /dev/null +++ b/rust/lithe-remote-server/src/platform/macos.rs @@ -0,0 +1,207 @@ +//! macOS host adapter, used for remote Macs reached over SSH. + +use super::{unix, ChildKind}; +use lithe_remote_protocol::methods::{ListeningPort, OperatingSystem, OrphanProtection}; +use std::collections::HashSet; +use std::io; +use std::os::fd::RawFd; +use std::os::unix::process::CommandExt; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::time::Duration; + +pub(crate) const OPERATING_SYSTEM: OperatingSystem = OperatingSystem::Macos; + +/// macOS has no parent-death signal. Process groups are terminated when the +/// server shuts down or its session expires; a kqueue-based supervisor that +/// also covers a killed server is a later addition. +pub(crate) const ORPHAN_PROTECTION: OrphanProtection = OrphanProtection::ServerShutdown; + +/// Bound for `lsof`, which inspects every open file of the user. +const LSOF_TIMEOUT: Duration = Duration::from_secs(5); + +pub(crate) fn prepare_child(command: &mut Command, kind: ChildKind) { + // SAFETY: the closure only calls setsid and ioctl, which are + // async-signal-safe. + unsafe { + command.pre_exec(move || { + unix::start_new_session()?; + if kind == ChildKind::Terminal { + unix::acquire_controlling_terminal()?; + } + Ok(()) + }); + } +} + +pub(crate) fn rename_no_replace(from: &Path, to: &Path) -> io::Result<()> { + let from_c = unix::c_path(from)?; + let to_c = unix::c_path(to)?; + // SAFETY: both pointers reference NUL-terminated strings that outlive the + // call. + let status = unsafe { libc::renamex_np(from_c.as_ptr(), to_c.as_ptr(), libc::RENAME_EXCL) }; + if status == 0 { + return Ok(()); + } + let error = io::Error::last_os_error(); + match error.raw_os_error() { + // Volumes such as SMB or FAT mounts may not support RENAME_EXCL. + Some(libc::ENOTSUP) | Some(libc::EINVAL) => unix::rename_if_absent(from, to), + _ => Err(error), + } +} + +/// APFS and HFS+ volumes are case-insensitive by default but can be +/// formatted case-sensitive, so the answer is per volume. +pub(crate) fn case_sensitive(path: &Path) -> Option { + let Ok(c_path) = unix::c_path(path) else { + return None; + }; + // SAFETY: the pointer references a NUL-terminated string that outlives + // the call. + match unsafe { libc::pathconf(c_path.as_ptr(), libc::_PC_CASE_SENSITIVE) } { + 1 => Some(true), + 0 => Some(false), + _ => unix::probe_case_sensitivity(path), + } +} + +/// Processes whose session id is `session`. macOS has no `/proc`; the pid +/// list comes from libproc and each session id from `getsid`. +pub(crate) fn session_members(session: u32) -> Vec { + process_sessions() + .into_iter() + .filter(|(_, member_session)| *member_session == session) + .map(|(pid, _)| pid) + .collect() +} + +/// The sessions among `sessions` that have a member, from one scan. +pub(crate) fn sessions_with_members(sessions: &HashSet) -> HashSet { + process_sessions() + .into_iter() + .map(|(_, session)| session) + .filter(|session| sessions.contains(session)) + .collect() +} + +/// `(pid, session id)` of every process. +fn process_sessions() -> Vec<(u32, u32)> { + // SAFETY: a null buffer asks for the number of pids. + let count = unsafe { libc::proc_listallpids(std::ptr::null_mut(), 0) }; + if count <= 0 { + return Vec::new(); + } + // Room for processes started between the two calls. + let mut pids = vec![0 as libc::pid_t; count as usize + 64]; + let bytes = (pids.len() * std::mem::size_of::()) as libc::c_int; + // SAFETY: the buffer is writable for `bytes` bytes. + let filled = unsafe { libc::proc_listallpids(pids.as_mut_ptr().cast(), bytes) }; + if filled <= 0 { + return Vec::new(); + } + pids.truncate(filled as usize); + pids.into_iter() + .filter(|pid| *pid > 0) + .filter_map(|pid| { + // SAFETY: getsid has no memory-safety preconditions. + let session = unsafe { libc::getsid(pid) }; + (session > 0).then_some((pid as u32, session as u32)) + }) + .collect() +} + +pub(crate) fn peer_uid(socket: RawFd) -> io::Result { + let mut uid: libc::uid_t = 0; + let mut gid: libc::gid_t = 0; + // SAFETY: the out-pointers reference live locals. + if unsafe { libc::getpeereid(socket, &mut uid, &mut gid) } == -1 { + return Err(io::Error::last_os_error()); + } + Ok(uid) +} + +/// Listening TCP sockets of the remote user's processes. macOS has no +/// `/proc`; `lsof` ships with the system and is the supported way to list +/// sockets without elevated rights. +pub(crate) fn listening_ports() -> io::Result> { + let mut command = Command::new("lsof"); + command + .args(["-nP", "-iTCP", "-sTCP:LISTEN", "-Fn"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + let child = command.spawn()?; + let (status, stdout) = unix::collect_output(child, LSOF_TIMEOUT)?; + interpret_lsof(status.code(), &stdout) +} + +/// lsof exits with 1 both when nothing matches and when it could not +/// inspect some processes, which is normal without root. Its output is +/// complete for the user's own sockets either way. Other statuses are real +/// failures. +fn interpret_lsof(status: Option, stdout: &[u8]) -> io::Result> { + match status { + Some(0) | Some(1) => Ok(parse_lsof(&String::from_utf8_lossy(stdout))), + other => Err(io::Error::other(format!( + "lsof failed with status {other:?}" + ))), + } +} + +fn parse_lsof(output: &str) -> Vec { + output + .lines() + .filter_map(|line| line.strip_prefix('n')) + .filter_map(|name| { + let (address, port) = name.rsplit_once(':')?; + let address = address.trim_start_matches('[').trim_end_matches(']'); + Some(ListeningPort { + address: if address == "*" { + "0.0.0.0".into() + } else { + address.into() + }, + port: port.parse().ok()?, + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + // As a regular user lsof exits 1 because other users' processes are not + // inspectable, yet it printed the user's own sockets. + #[test] + fn lsof_partial_success_still_lists_ports() { + let ports = interpret_lsof(Some(1), b"p1\nf3\nn127.0.0.1:8080\n").unwrap(); + assert_eq!(ports.len(), 1); + assert!(interpret_lsof(Some(1), b"").unwrap().is_empty()); + assert!(interpret_lsof(Some(2), b"").is_err()); + assert!(interpret_lsof(None, b"").is_err()); + } + + #[test] + fn lsof_field_output_yields_addresses_and_ports() { + let output = "p123\nf5\nn127.0.0.1:5432\nf6\nn[::1]:5432\np456\nf9\nn*:3000\n"; + assert_eq!( + parse_lsof(output), + vec![ + ListeningPort { + address: "127.0.0.1".into(), + port: 5432 + }, + ListeningPort { + address: "::1".into(), + port: 5432 + }, + ListeningPort { + address: "0.0.0.0".into(), + port: 3000 + }, + ] + ); + } +} diff --git a/rust/lithe-remote-server/src/platform/macos_watch.rs b/rust/lithe-remote-server/src/platform/macos_watch.rs new file mode 100644 index 0000000000..2be685176b --- /dev/null +++ b/rust/lithe-remote-server/src/platform/macos_watch.rs @@ -0,0 +1,293 @@ +//! FSEvents backend for recursive watching on macOS. +//! +//! FSEvents watches whole trees with one stream and works in SSH sessions, +//! because the system daemon `fseventsd` records changes, not the session. +//! Its per-file flags are cumulative within a delivery, so the kind of a +//! change is decided by whether the path exists now rather than by flags +//! alone. The bindings are declared here directly, which keeps the server +//! free of framework wrapper crates. + +use crate::watch::{Excludes, RawEvent, Sink}; +use lithe_remote_protocol::methods::{ChangeKind, WatchBackend}; +use std::collections::HashSet; +use std::ffi::{c_char, c_void, CStr}; +use std::io; +use std::os::unix::ffi::OsStrExt; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +pub(crate) const BACKEND: WatchBackend = WatchBackend::Fsevents; + +/// Created paths remembered to correct cumulative flags; beyond this the +/// memory is cleared, and a later write may again be reported as a creation, +/// which clients treat like a modification of a known file. +const CREATED_MEMORY_LIMIT: usize = 100_000; + +/// FSEvents has no per-directory limit. +pub(crate) fn directory_limit() -> Option { + None +} + +/// Seconds FSEvents waits to batch changes. The coalescer adds its own short +/// quiet period, so this only needs to bound syscall chatter. +const LATENCY_SECONDS: f64 = 0.05; + +type FSEventStreamRef = *mut c_void; +type CFRef = *const c_void; + +#[repr(C)] +struct FSEventStreamContext { + version: isize, + info: *mut c_void, + retain: *const c_void, + release: *const c_void, + copy_description: *const c_void, +} + +type FSEventStreamCallback = extern "C" fn( + stream: *const c_void, + info: *mut c_void, + count: usize, + paths: *mut c_void, + flags: *const u32, + ids: *const u64, +); + +const EVENT_ID_SINCE_NOW: u64 = u64::MAX; +const CREATE_FLAG_NO_DEFER: u32 = 0x0000_0002; +const CREATE_FLAG_WATCH_ROOT: u32 = 0x0000_0004; +const CREATE_FLAG_FILE_EVENTS: u32 = 0x0000_0010; +const EVENT_FLAG_MUST_SCAN_SUBDIRS: u32 = 0x0000_0001; +const EVENT_FLAG_USER_DROPPED: u32 = 0x0000_0002; +const EVENT_FLAG_KERNEL_DROPPED: u32 = 0x0000_0004; +const EVENT_FLAG_ROOT_CHANGED: u32 = 0x0000_0020; +const EVENT_FLAG_ITEM_CREATED: u32 = 0x0000_0100; +const STRING_ENCODING_UTF8: u32 = 0x0800_0100; + +#[link(name = "CoreFoundation", kind = "framework")] +extern "C" { + static kCFTypeArrayCallBacks: c_void; + fn CFStringCreateWithBytes( + allocator: CFRef, + bytes: *const u8, + length: isize, + encoding: u32, + external: u8, + ) -> CFRef; + fn CFArrayCreate( + allocator: CFRef, + values: *const CFRef, + count: isize, + callbacks: *const c_void, + ) -> CFRef; + fn CFRelease(object: CFRef); +} + +#[link(name = "CoreServices", kind = "framework")] +extern "C" { + fn FSEventStreamCreate( + allocator: CFRef, + callback: FSEventStreamCallback, + context: *const FSEventStreamContext, + paths: CFRef, + since: u64, + latency: f64, + flags: u32, + ) -> FSEventStreamRef; + fn FSEventStreamSetDispatchQueue(stream: FSEventStreamRef, queue: *mut c_void); + fn FSEventStreamStart(stream: FSEventStreamRef) -> u8; + fn FSEventStreamStop(stream: FSEventStreamRef); + fn FSEventStreamInvalidate(stream: FSEventStreamRef); + fn FSEventStreamRelease(stream: FSEventStreamRef); +} + +// libdispatch is part of libSystem, which every macOS binary links. +extern "C" { + fn dispatch_queue_create(label: *const c_char, attributes: *const c_void) -> *mut c_void; + fn dispatch_sync_f(queue: *mut c_void, context: *mut c_void, work: extern "C" fn(*mut c_void)); + fn dispatch_release(object: *mut c_void); +} + +/// State the callback reads through the stream's `info` pointer. +struct CallbackState { + sink: Sink, + excludes: Arc, + /// Root as the client spelled it, and as FSEvents reports it (resolved + /// through links such as `/var` → `/private/var`). + requested_root: PathBuf, + reported_root: PathBuf, + /// Paths reported as created and not deleted since. FSEvents keeps the + /// "created" flag on every later event of such a file, so without this + /// each save of a new file would look like another creation. + created: Mutex>, +} + +pub(crate) struct Watcher { + stream: FSEventStreamRef, + queue: *mut c_void, + state: *mut CallbackState, +} + +// SAFETY: the stream and queue are only used from `Drop`, and FSEvents +// documents its stream functions as callable from any thread. +unsafe impl Send for Watcher {} + +impl Drop for Watcher { + fn drop(&mut self) { + // SAFETY: the pointers were created in `start` and are released + // exactly once here. The empty synchronous block runs after any + // callback still executing on the serial queue, so the state is no + // longer referenced when it is freed. + unsafe { + FSEventStreamStop(self.stream); + FSEventStreamInvalidate(self.stream); + FSEventStreamRelease(self.stream); + dispatch_sync_f(self.queue, std::ptr::null_mut(), drain); + dispatch_release(self.queue); + drop(Box::from_raw(self.state)); + } + } +} + +extern "C" fn drain(_: *mut c_void) {} + +pub(crate) fn start( + root: PathBuf, + excludes: Arc, + sink: Sink, +) -> io::Result<(Watcher, Option)> { + let reported_root = std::fs::canonicalize(&root)?; + let state = Box::into_raw(Box::new(CallbackState { + sink, + excludes, + requested_root: root, + reported_root: reported_root.clone(), + created: Mutex::new(HashSet::new()), + })); + let context = FSEventStreamContext { + version: 0, + info: state.cast(), + retain: std::ptr::null(), + release: std::ptr::null(), + copy_description: std::ptr::null(), + }; + let bytes = reported_root.as_os_str().as_bytes(); + // SAFETY: every CoreFoundation object created here is released before + // returning; the stream retains the path array it needs. The context is + // copied by FSEventStreamCreate, and `state` outlives the stream. + unsafe { + let path = CFStringCreateWithBytes( + std::ptr::null(), + bytes.as_ptr(), + bytes.len() as isize, + STRING_ENCODING_UTF8, + 0, + ); + if path.is_null() { + drop(Box::from_raw(state)); + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "path is not UTF-8", + )); + } + let paths = CFArrayCreate(std::ptr::null(), &path, 1, &kCFTypeArrayCallBacks); + let stream = FSEventStreamCreate( + std::ptr::null(), + callback, + &context, + paths, + EVENT_ID_SINCE_NOW, + LATENCY_SECONDS, + CREATE_FLAG_NO_DEFER | CREATE_FLAG_WATCH_ROOT | CREATE_FLAG_FILE_EVENTS, + ); + CFRelease(paths); + CFRelease(path); + if stream.is_null() { + drop(Box::from_raw(state)); + return Err(io::Error::other("FSEventStreamCreate failed")); + } + let queue = dispatch_queue_create(c"dev.lithe.remote.fsevents".as_ptr(), std::ptr::null()); + FSEventStreamSetDispatchQueue(stream, queue); + if FSEventStreamStart(stream) == 0 { + FSEventStreamInvalidate(stream); + FSEventStreamRelease(stream); + dispatch_release(queue); + drop(Box::from_raw(state)); + return Err(io::Error::other("FSEventStreamStart failed")); + } + Ok(( + Watcher { + stream, + queue, + state, + }, + None, + )) + } +} + +extern "C" fn callback( + _stream: *const c_void, + info: *mut c_void, + count: usize, + paths: *mut c_void, + flags: *const u32, + _ids: *const u64, +) { + // SAFETY: FSEvents passes the `info` pointer from our context, which + // stays valid until `Drop` drains the queue, and arrays of `count` C + // strings and flags, as documented for streams without CF types. + unsafe { + let state = &*(info as *const CallbackState); + let paths = paths as *const *const c_char; + for index in 0..count { + let flag = *flags.add(index); + let raw = CStr::from_ptr(*paths.add(index)); + state.handle( + PathBuf::from(std::ffi::OsStr::from_bytes(raw.to_bytes())), + flag, + ); + } + } +} + +impl CallbackState { + fn handle(&self, reported: PathBuf, flag: u32) { + if flag + & (EVENT_FLAG_MUST_SCAN_SUBDIRS | EVENT_FLAG_USER_DROPPED | EVENT_FLAG_KERNEL_DROPPED) + != 0 + { + let _ = self.sink.send(RawEvent::Overflow); + return; + } + if flag & EVENT_FLAG_ROOT_CHANGED != 0 { + if !self.requested_root.exists() { + let _ = self.sink.send(RawEvent::RootRemoved); + } + return; + } + let path = match reported.strip_prefix(&self.reported_root) { + Ok(relative) if relative.as_os_str().is_empty() => return, + Ok(relative) => self.requested_root.join(relative), + Err(_) => return, + }; + if self.excludes.contains(&path) { + return; + } + let mut created = crate::lock(&self.created); + let kind = if std::fs::symlink_metadata(&path).is_err() { + created.remove(&path); + ChangeKind::Deleted + } else if flag & EVENT_FLAG_ITEM_CREATED != 0 && !created.contains(&path) { + if created.len() >= CREATED_MEMORY_LIMIT { + created.clear(); + } + created.insert(path.clone()); + ChangeKind::Created + } else { + ChangeKind::Modified + }; + drop(created); + let _ = self.sink.send(RawEvent::Change(path, kind)); + } +} diff --git a/rust/lithe-remote-server/src/platform/mod.rs b/rust/lithe-remote-server/src/platform/mod.rs new file mode 100644 index 0000000000..be4f42cc2d --- /dev/null +++ b/rust/lithe-remote-server/src/platform/mod.rs @@ -0,0 +1,80 @@ +//! Host adapters. +//! +//! Every behavior that differs between Linux and macOS hosts is implemented +//! here and reported to clients as a capability. Code outside this module +//! must not test the target OS itself; it calls the functions re-exported +//! below, which each host module implements with the same signatures. + +mod unix; + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "linux")] +pub(crate) mod linux_watch; +#[cfg(target_os = "linux")] +use linux as host; +#[cfg(target_os = "linux")] +pub(crate) use linux_watch as watcher; + +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "macos")] +pub(crate) mod macos_watch; +#[cfg(target_os = "macos")] +use macos as host; +#[cfg(target_os = "macos")] +pub(crate) use macos_watch as watcher; + +pub(crate) use host::{ + case_sensitive, listening_ports, peer_uid, prepare_child, rename_no_replace, +}; +pub(crate) use unix::{ + collect_output, current_account, current_uid, live_sessions, open_pty, set_window_size, + signal_process_group, signal_session, +}; + +use lithe_remote_protocol::methods::{ + Feature, HostCapabilities, ProcessCapabilities, WatchCapabilities, +}; + +/// How a child is attached to its parent's session. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ChildKind { + /// A new session and process group without a controlling terminal. + Plain, + /// A new session whose controlling terminal is the child's standard + /// input, which must be a pseudo-terminal slave. + Terminal, +} + +pub(crate) fn host_capabilities() -> HostCapabilities { + let account = unix::current_account(); + let mut features = vec![ + Feature::Filesystem, + Feature::Walk, + Feature::Watch, + Feature::Process, + Feature::Terminal, + Feature::Environment, + Feature::Tunnel, + Feature::Persistence, + ]; + features.sort(); + HostCapabilities { + os: host::OPERATING_SYSTEM, + arch: std::env::consts::ARCH.into(), + host_name: unix::host_name(), + user_name: account.user_name, + home_directory: account.home_directory, + default_shell: account.shell, + path_separator: "/".into(), + process: ProcessCapabilities { + orphan_protection: host::ORPHAN_PROTECTION, + }, + watch: WatchCapabilities { + backend: watcher::BACKEND, + directory_limit: watcher::directory_limit(), + }, + features, + } +} diff --git a/rust/lithe-remote-server/src/platform/unix.rs b/rust/lithe-remote-server/src/platform/unix.rs new file mode 100644 index 0000000000..9441a3aabb --- /dev/null +++ b/rust/lithe-remote-server/src/platform/unix.rs @@ -0,0 +1,310 @@ +//! POSIX behavior shared by Linux and macOS hosts. + +use std::ffi::{CStr, OsStr}; +use std::fs; +use std::io; +use std::os::fd::{FromRawFd, OwnedFd, RawFd}; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::fs::MetadataExt; +use std::path::Path; + +/// Remote user facts. Environment variables win because `docker exec` and +/// SSH set them for the session; the password database is the fallback for +/// containers that start without them. +pub(crate) struct Account { + pub(crate) user_name: Option, + pub(crate) home_directory: Option, + pub(crate) shell: Option, +} + +pub(crate) fn current_account() -> Account { + let from_environment = |name: &str| std::env::var(name).ok().filter(|value| !value.is_empty()); + let mut account = Account { + user_name: from_environment("USER").or_else(|| from_environment("LOGNAME")), + home_directory: from_environment("HOME"), + shell: from_environment("SHELL"), + }; + if account.user_name.is_some() && account.home_directory.is_some() && account.shell.is_some() { + return account; + } + // SAFETY: getpwuid returns a pointer to static storage or null. It is + // only called from the dispatcher thread during the handshake, and every + // field is copied before returning. + unsafe { + let entry = libc::getpwuid(libc::getuid()); + if !entry.is_null() { + let copy = |field: *const libc::c_char| { + (!field.is_null()) + .then(|| CStr::from_ptr(field).to_string_lossy().into_owned()) + .filter(|value| !value.is_empty()) + }; + account.user_name = account.user_name.or_else(|| copy((*entry).pw_name)); + account.home_directory = account.home_directory.or_else(|| copy((*entry).pw_dir)); + account.shell = account.shell.or_else(|| copy((*entry).pw_shell)); + } + } + account +} + +pub(crate) fn host_name() -> String { + let mut buffer = [0 as libc::c_char; 256]; + // SAFETY: the buffer is writable for its full length, and the last byte + // is reserved so the result is always NUL-terminated. + let status = unsafe { libc::gethostname(buffer.as_mut_ptr(), buffer.len() - 1) }; + if status != 0 { + return String::new(); + } + // SAFETY: gethostname wrote a NUL-terminated string into the buffer. + unsafe { CStr::from_ptr(buffer.as_ptr()) } + .to_string_lossy() + .into_owned() +} + +/// Puts the spawned program in its own session and process group, so that +/// signals reach every process it starts and none of them can read the +/// server's controlling terminal, if it has one. +pub(crate) fn start_new_session() -> io::Result<()> { + // SAFETY: setsid is async-signal-safe and is called between fork and exec. + if unsafe { libc::setsid() } == -1 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +/// Sends `signal` to the process group led by `leader`. +pub(crate) fn signal_process_group(leader: u32, signal: libc::c_int) -> io::Result<()> { + let group = libc::pid_t::try_from(leader) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "pid out of range"))?; + // SAFETY: killpg has no memory-safety preconditions. + if unsafe { libc::killpg(group, signal) } == -1 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +/// Whether any process is still in the process group `group`. A zombie +/// member counts until it is reaped; `EPERM` means a member exists that the +/// server may not signal. +pub(crate) fn process_group_exists(group: u32) -> bool { + let Ok(group) = libc::pid_t::try_from(group) else { + return false; + }; + // SAFETY: signal 0 performs only the existence and permission check. + if unsafe { libc::killpg(group, 0) } == 0 { + return true; + } + io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) +} + +/// The leaders among `leaders` whose sessions still have a member. Groups +/// are checked with a signal first; the process table is scanned at most +/// once, for the leaders whose own group is already empty. +pub(crate) fn live_sessions(leaders: &[u32]) -> std::collections::HashSet { + let mut live: std::collections::HashSet = leaders + .iter() + .copied() + .filter(|leader| process_group_exists(*leader)) + .collect(); + let remaining: std::collections::HashSet = leaders + .iter() + .copied() + .filter(|leader| !live.contains(leader)) + .collect(); + if !remaining.is_empty() { + live.extend(super::host::sessions_with_members(&remaining)); + } + live +} + +/// Sends `signal` to every process of the session led by `leader`: its own +/// process group and the separate groups that job control gives background +/// jobs. Shells differ in whether they forward hangups to their jobs, so the +/// server does it for them. Fails with `ESRCH` when nothing was signalled. +pub(crate) fn signal_session(leader: u32, signal: libc::c_int) -> io::Result<()> { + let mut signalled = signal_process_group(leader, signal).is_ok(); + for pid in super::host::session_members(leader) { + // SAFETY: kill has no memory-safety preconditions. + signalled |= unsafe { libc::kill(pid as libc::pid_t, signal) } == 0; + } + if signalled { + Ok(()) + } else { + Err(io::Error::from_raw_os_error(libc::ESRCH)) + } +} + +/// Determines case sensitivity without writing: it looks up the nearest +/// component with letters under its swapped-case spelling and checks whether +/// that resolves to the same file. Only components on the same device as +/// `path` are used, because a mount point can change the answer. +pub(crate) fn probe_case_sensitivity(path: &Path) -> Option { + let mut current = fs::canonicalize(path).ok()?; + let device = fs::metadata(¤t).ok()?.dev(); + loop { + let original = fs::symlink_metadata(¤t).ok()?; + if original.dev() != device { + return None; + } + let swapped = current.file_name().map(swap_ascii_case); + if let Some(swapped) = swapped.filter(|swapped| { + Some(swapped.as_slice()) != current.file_name().map(|name| name.as_bytes()) + }) { + let sibling = current.with_file_name(OsStr::from_bytes(&swapped)); + return match fs::symlink_metadata(&sibling) { + Ok(other) => Some(other.dev() != original.dev() || other.ino() != original.ino()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Some(true), + Err(_) => None, + }; + } + if !current.pop() { + return None; + } + } +} + +fn swap_ascii_case(name: &OsStr) -> Vec { + name.as_bytes() + .iter() + .map(|byte| { + if byte.is_ascii_lowercase() { + byte.to_ascii_uppercase() + } else { + byte.to_ascii_lowercase() + } + }) + .collect() +} + +/// Fallback for hosts or filesystems without an atomic no-replace rename. +/// The check and the rename are separate calls, so a concurrent creation of +/// `to` can still be overwritten. +pub(crate) fn rename_if_absent(from: &Path, to: &Path) -> io::Result<()> { + match fs::symlink_metadata(to) { + Ok(_) => Err(io::Error::from(io::ErrorKind::AlreadyExists)), + Err(error) if error.kind() == io::ErrorKind::NotFound => fs::rename(from, to), + Err(error) => Err(error), + } +} + +pub(crate) fn c_path(path: &Path) -> io::Result { + std::ffi::CString::new(path.as_os_str().as_bytes()) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains NUL")) +} + +pub(crate) fn current_uid() -> u32 { + // SAFETY: getuid has no preconditions. + unsafe { libc::getuid() } +} + +/// Opens a pseudo-terminal pair sized `columns` x `rows`. Both descriptors +/// are close-on-exec, so no other child inherits the master; the child that +/// uses the slave receives it as its standard streams, which exec keeps. +pub(crate) fn open_pty(columns: u16, rows: u16) -> io::Result<(OwnedFd, OwnedFd)> { + let mut master: RawFd = -1; + let mut slave: RawFd = -1; + let mut size = window_size(columns, rows); + // SAFETY: the out-pointers reference live locals; a null name and + // terminal attributes request the defaults. + let status = unsafe { + libc::openpty( + &mut master, + &mut slave, + std::ptr::null_mut(), + std::ptr::null_mut(), + // Linux declares the parameter const and macOS mutable; a raw + // mutable pointer fits both. + std::ptr::addr_of_mut!(size), + ) + }; + if status != 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: openpty succeeded, so both descriptors are open and owned here. + let (master, slave) = unsafe { (OwnedFd::from_raw_fd(master), OwnedFd::from_raw_fd(slave)) }; + for fd in [&master, &slave] { + set_close_on_exec(std::os::fd::AsRawFd::as_raw_fd(fd))?; + } + Ok((master, slave)) +} + +pub(crate) fn set_window_size(master: RawFd, columns: u16, rows: u16) -> io::Result<()> { + let size = window_size(columns, rows); + // SAFETY: TIOCSWINSZ reads a winsize from the pointer, which references a + // live local. + if unsafe { libc::ioctl(master, libc::TIOCSWINSZ as _, &size) } == -1 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +fn window_size(columns: u16, rows: u16) -> libc::winsize { + libc::winsize { + ws_row: rows.max(1), + ws_col: columns.max(1), + ws_xpixel: 0, + ws_ypixel: 0, + } +} + +fn set_close_on_exec(fd: RawFd) -> io::Result<()> { + // SAFETY: fcntl on a descriptor this process owns. + let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) }; + if flags == -1 || unsafe { libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC) } == -1 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +/// Makes the child's standard input, a pseudo-terminal slave, its +/// controlling terminal. Must run after `setsid` between fork and exec. +pub(crate) fn acquire_controlling_terminal() -> io::Result<()> { + // SAFETY: ioctl is async-signal-safe; descriptor 0 is the slave. + if unsafe { libc::ioctl(0, libc::TIOCSCTTY as _, 0) } == -1 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +/// Interval between exit checks of a bounded helper command. +const HELPER_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(10); + +/// Reads a helper's standard output and waits for it with a deadline. On +/// timeout the helper's whole process group is killed, so shells that started +/// background jobs from profile files do not linger. +pub(crate) fn collect_output( + mut child: std::process::Child, + timeout: std::time::Duration, +) -> io::Result<(std::process::ExitStatus, Vec)> { + use std::io::Read; + let (sender, receiver) = std::sync::mpsc::channel(); + if let Some(mut stdout) = child.stdout.take() { + std::thread::spawn(move || { + let mut bytes = Vec::new(); + let _ = stdout.read_to_end(&mut bytes); + let _ = sender.send(bytes); + }); + } else { + let _ = sender.send(Vec::new()); + } + let deadline = std::time::Instant::now() + timeout; + let status = loop { + if let Some(status) = child.try_wait()? { + break status; + } + if std::time::Instant::now() >= deadline { + let _ = signal_process_group(child.id(), libc::SIGKILL); + let _ = child.kill(); + let _ = child.wait(); + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "helper command timed out", + )); + } + std::thread::sleep(HELPER_POLL_INTERVAL); + }; + // Descendants may keep the pipe open; their output is not waited for. + let stdout = receiver + .recv_timeout(std::time::Duration::from_millis(500)) + .unwrap_or_default(); + Ok((status, stdout)) +} diff --git a/rust/lithe-remote-server/src/ports.rs b/rust/lithe-remote-server/src/ports.rs new file mode 100644 index 0000000000..ce475bc7d2 --- /dev/null +++ b/rust/lithe-remote-server/src/ports.rs @@ -0,0 +1,16 @@ +//! Listening ports on the remote, for automatic port forwarding. + +use crate::platform; +use crate::session::Session; +use crate::to_value; +use lithe_remote_protocol::methods::ListeningPortsResult; +use lithe_remote_protocol::RemoteError; +use serde_json::Value; +use std::sync::Arc; + +pub(crate) fn handle(_session: &Arc) -> Result { + let mut ports = platform::listening_ports().map_err(|error| crate::fs::io_error(error, ""))?; + ports.sort(); + ports.dedup(); + to_value(ListeningPortsResult { ports }) +} diff --git a/rust/lithe-remote-server/src/process.rs b/rust/lithe-remote-server/src/process.rs new file mode 100644 index 0000000000..f4f1beba67 --- /dev/null +++ b/rust/lithe-remote-server/src/process.rs @@ -0,0 +1,471 @@ +//! Processes spawned on behalf of the client. +//! +//! Each process leads its own session and process group. Output is streamed +//! as events in bounded chunks; the exit event is sent after the process's +//! own output has drained, so clients can treat it as the end of the stream. +//! +//! The exit event reports the leader. Other members of its session, such as +//! build daemons started in the background, may outlive it; the server keeps +//! owning the session until it is empty, so `process.kill` and the final +//! cleanup still reach them. + +use crate::connection::Context; +use crate::fs::{host_path, io_error}; +use crate::platform::{self, ChildKind}; +use crate::resources::Registry; +use crate::session::Session; +use crate::{parse, to_value}; +use lithe_remote_protocol::methods::{ + self, decode_bytes, encode_bytes, EnvironmentSource, KillParams, OutputStream, + ProcessExitEvent, ProcessOutputEvent, ProcessParams, ProcessState, SpawnParams, SpawnResult, + WriteStdinParams, +}; +use lithe_remote_protocol::{ErrorCode, RemoteError}; +use serde_json::{json, Value}; +use std::io::{self, Read, Write}; +use std::os::unix::process::ExitStatusExt; +use std::process::{Child, Command, ExitStatus, Stdio}; +use std::sync::mpsc::{self, Receiver, SyncSender, TrySendError}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::{Duration, Instant}; + +pub(crate) const OUTPUT_CHUNK_BYTES: usize = 32 * 1024; + +/// Pending standard-input writes per process. A full queue answers `BUSY` +/// instead of blocking the dispatcher on a child that stopped reading. +pub(crate) const INPUT_QUEUE_CAPACITY: usize = 64; + +/// How long output readers may keep a pipe open after the process exited. +/// Grandchildren that inherited the pipe can hold it open indefinitely; their +/// later output is dropped so the exit event remains the last event. +pub(crate) const OUTPUT_DRAIN_GRACE: Duration = Duration::from_millis(500); + +/// Interval for checking whether signalled sessions are empty during +/// cleanup. Members that are not the server's children report no exit to +/// it, so the wait polls them within the caller's deadline. +pub(crate) const SESSION_POLL_INTERVAL: Duration = Duration::from_millis(50); + +/// The leader of a session the server started, kept after the leader exits +/// for as long as the session has members. +/// +/// POSIX does not reuse a process id while a process group or session with +/// that id exists, so signalling a kept leader id cannot reach an unrelated +/// process. +pub(crate) struct Leader { + pub(crate) pid: u32, + /// Set when the leader exited and its exit event was sent. + pub(crate) exited: bool, +} + +impl Leader { + pub(crate) fn new(pid: u32) -> Self { + Self { pid, exited: false } + } +} + +/// Forgets entries whose leader exited and whose session is empty. One call +/// checks all exited leaders together, scanning the process table at most +/// once. +pub(crate) fn prune(registry: &Registry, leader: impl Fn(&T) -> &Leader) { + let exited: Vec = registry + .lock() + .values() + .map(&leader) + .filter(|leader| leader.exited) + .map(|leader| leader.pid) + .collect(); + if exited.is_empty() { + return; + } + let live = platform::live_sessions(&exited); + registry.retain(|entry| { + let leader = leader(entry); + !leader.exited || live.contains(&leader.pid) + }); +} + +/// Whether any entry is kept only for its session's remaining members. +pub(crate) fn has_exited_leaders( + registry: &Registry, + leader: impl Fn(&T) -> &Leader, +) -> bool { + registry.lock().values().any(|entry| leader(entry).exited) +} + +pub(crate) struct ProcessTable { + registry: Registry, +} + +struct RunningProcess { + leader: Leader, + argv: Vec, + /// `None` when standard input was not piped or has been closed. + stdin: Option>>, +} + +impl ProcessTable { + pub(crate) fn new() -> Self { + Self { + registry: Registry::new("process"), + } + } + + pub(crate) fn handle( + &self, + context: &Context, + method: &str, + params: Value, + ) -> Result { + match method { + methods::PROCESS_SPAWN => to_value(self.spawn(&context.session, parse(params)?)?), + methods::PROCESS_WRITE_STDIN => self.write_stdin(parse(params)?).map(|()| Value::Null), + methods::PROCESS_CLOSE_STDIN => self.close_stdin(parse(params)?).map(|()| Value::Null), + methods::PROCESS_KILL => self.kill(parse(params)?).map(|()| Value::Null), + _ => Err(RemoteError::new( + ErrorCode::UnknownMethod, + format!("unknown method {method}"), + )), + } + } + + pub(crate) fn states(&self) -> Vec { + self.registry + .lock() + .iter() + .map(|(process_id, process)| ProcessState { + process_id: *process_id, + pid: process.leader.pid, + argv: process.argv.clone(), + leader_exited: process.leader.exited, + }) + .collect() + } + + fn spawn( + &self, + session: &Arc, + params: SpawnParams, + ) -> Result { + session.ids.observe(params.process_id); + prune(&self.registry, |process| &process.leader); + self.registry.ensure_free(params.process_id)?; + let Some((program, arguments)) = params.argv.split_first() else { + return Err(RemoteError::new(ErrorCode::InvalidRequest, "argv is empty")); + }; + let mut command = Command::new(program); + command + .args(arguments) + .stdin(if params.pipe_stdin { + Stdio::piped() + } else { + Stdio::null() + }) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if params.environment == EnvironmentSource::Login { + let login = session.environment.resolve(session, false); + command.env_clear().envs(login.environment); + } + command.envs(¶ms.env); + if let Some(cwd) = ¶ms.cwd { + command.current_dir(host_path(cwd)?); + } + platform::prepare_child(&mut command, ChildKind::Plain); + let program_name = program.clone(); + let mut child = session + .spawner + .run(move || command.spawn()) + .map_err(|error| spawn_error(&program_name, error))?; + + let pid = child.id(); + let stdin = child.stdin.take().map(start_input_writer); + let entry = RunningProcess { + leader: Leader::new(pid), + argv: params.argv.clone(), + stdin, + }; + if let Err(error) = self.registry.insert(params.process_id, entry) { + // Another spawn with the same id won the race; this child was + // never visible to the client, so it must not keep running. + let _ = platform::signal_process_group(pid, libc::SIGKILL); + let _ = child.wait(); + return Err(error); + } + self.start_monitor(Arc::clone(session), params.process_id, child); + Ok(SpawnResult { pid }) + } + + fn start_monitor(&self, session: Arc, process_id: u64, mut child: Child) { + let mut gate = OutputGate::new(); + let pipes = [ + child + .stdout + .take() + .map(|pipe| (Box::new(pipe) as Box, OutputStream::Stdout)), + child + .stderr + .take() + .map(|pipe| (Box::new(pipe) as Box, OutputStream::Stderr)), + ]; + for (pipe, stream) in pipes.into_iter().flatten() { + let session = Arc::clone(&session); + gate.start_reader(pipe, move |chunk| { + session.link.emit( + methods::EVENT_PROCESS_OUTPUT, + ProcessOutputEvent { + process_id, + stream, + data: encode_bytes(chunk), + }, + ); + }); + } + thread::spawn(move || { + let status = child.wait(); + gate.finish(|| { + let registry = &session.processes.registry; + if let Some(process) = registry.lock().get_mut(&process_id) { + process.leader.exited = true; + process.stdin = None; + } + prune(registry, |process| &process.leader); + let (exit_code, signal) = exit_parts(status); + session.link.emit( + methods::EVENT_PROCESS_EXIT, + ProcessExitEvent { + process_id, + exit_code, + signal, + }, + ); + }); + }); + } + + fn write_stdin(&self, params: WriteStdinParams) -> Result<(), RemoteError> { + let data = decode_bytes(¶ms.data) + .map_err(|error| RemoteError::new(ErrorCode::InvalidRequest, error.to_string()))?; + let processes = self.registry.lock(); + let process = processes + .get(¶ms.process_id) + .filter(|process| !process.leader.exited) + .ok_or_else(|| self.registry.unknown(params.process_id))?; + let Some(stdin) = &process.stdin else { + return Err(RemoteError::new( + ErrorCode::InvalidRequest, + "standard input is not open", + )); + }; + queue_input(stdin, data) + } + + fn close_stdin(&self, params: ProcessParams) -> Result<(), RemoteError> { + let mut processes = self.registry.lock(); + let process = processes + .get_mut(¶ms.process_id) + .filter(|process| !process.leader.exited) + .ok_or_else(|| self.registry.unknown(params.process_id))?; + process.stdin = None; + Ok(()) + } + + /// Signals the whole session, also after the leader exited while other + /// members still run. + fn kill(&self, params: KillParams) -> Result<(), RemoteError> { + // A session that emptied since the last check must not be signalled: + // its id may already belong to an unrelated process. + prune(&self.registry, |process| &process.leader); + let pid = self + .registry + .lock() + .get(¶ms.process_id) + .map(|process| process.leader.pid) + .ok_or_else(|| self.registry.unknown(params.process_id))?; + let signal = if params.force { + libc::SIGKILL + } else { + libc::SIGTERM + }; + match platform::signal_session(pid, signal) { + Ok(()) => Ok(()), + Err(error) if error.raw_os_error() == Some(libc::ESRCH) => { + prune(&self.registry, |process| &process.leader); + Err(self.registry.unknown(params.process_id)) + } + Err(error) => Err(io_error(error, "")), + } + } + + /// Forgets sessions that emptied after their leader exited. + pub(crate) fn reap(&self) { + if has_exited_leaders(&self.registry, |process| &process.leader) { + prune(&self.registry, |process| &process.leader); + } + } + + /// Ends every owned session: `SIGTERM`, a bounded grace period, then + /// `SIGKILL` for sessions that still have members. + pub(crate) fn terminate_all(&self, grace: Duration) { + terminate_groups( + &self.registry, + grace, + |process| &process.leader, + libc::SIGTERM, + ); + } +} + +/// Signals every session in `registry` with `first`, then `SIGKILL`, waiting +/// up to `grace` after each signal until the sessions are empty. A session +/// counts as gone only when no member is left, not when its leader exited. +pub(crate) fn terminate_groups( + registry: &Registry, + grace: Duration, + leader: impl Fn(&T) -> &Leader + Copy, + first: libc::c_int, +) { + for signal in [first, libc::SIGKILL] { + prune(registry, leader); + let leaders: Vec = registry + .lock() + .values() + .map(|entry| leader(entry).pid) + .collect(); + if leaders.is_empty() { + return; + } + for pid in leaders { + let _ = platform::signal_session(pid, signal); + } + let deadline = Instant::now() + grace; + loop { + prune(registry, leader); + let remaining = deadline.saturating_duration_since(Instant::now()); + if registry.lock().is_empty() { + return; + } + if remaining.is_zero() { + break; + } + // Leader exits wake the wait; other members are only observed by + // polling. + registry.wait_for_change(remaining.min(SESSION_POLL_INTERVAL)); + } + } + prune(registry, leader); + let survivors = registry.lock().len(); + if survivors > 0 { + eprintln!("lithe-remote-server: {survivors} process groups did not exit after SIGKILL"); + } +} + +pub(crate) fn spawn_error(program: &str, error: io::Error) -> RemoteError { + RemoteError::new( + ErrorCode::SpawnFailed, + format!("cannot start {program}: {error}"), + ) + .with_details(json!({ "program": program, "osError": error.raw_os_error() })) +} + +pub(crate) fn exit_parts(status: io::Result) -> (Option, Option) { + match status { + Ok(status) => (status.code(), status.signal()), + Err(error) => { + eprintln!("lithe-remote-server: waiting for a child failed: {error}"); + (None, None) + } + } +} + +/// Starts a thread that writes queued chunks to `pipe`. Dropping the sender +/// (close or exit) ends the loop after queued writes, and dropping the pipe +/// delivers end-of-file to the child. +pub(crate) fn start_input_writer(mut pipe: impl Write + Send + 'static) -> SyncSender> { + let (sender, receiver): (_, Receiver>) = mpsc::sync_channel(INPUT_QUEUE_CAPACITY); + thread::spawn(move || { + while let Ok(chunk) = receiver.recv() { + if pipe.write_all(&chunk).is_err() { + break; + } + } + }); + sender +} + +pub(crate) fn queue_input(queue: &SyncSender>, data: Vec) -> Result<(), RemoteError> { + match queue.try_send(data) { + Ok(()) => Ok(()), + Err(TrySendError::Full(_)) => Err(RemoteError::new( + ErrorCode::Busy, + "the process is not reading its input", + )), + Err(TrySendError::Disconnected(_)) => Err(RemoteError::new( + ErrorCode::InvalidRequest, + "input is closed", + )), + } +} + +/// Orders a child's output before its exit event. +/// +/// Readers forward chunks while holding the gate; `finish` waits briefly for +/// the readers to reach end-of-file, then closes the gate and runs the exit +/// action under it. Output read after that is dropped, because it can only +/// come from descendants that inherited the pipe. +pub(crate) struct OutputGate { + closed: Arc>, + drained: (mpsc::Sender<()>, Receiver<()>), + readers: usize, +} + +impl OutputGate { + pub(crate) fn new() -> Self { + Self { + closed: Arc::new(Mutex::new(false)), + drained: mpsc::channel(), + readers: 0, + } + } + + pub(crate) fn start_reader( + &mut self, + mut pipe: impl Read + Send + 'static, + forward: impl Fn(&[u8]) + Send + 'static, + ) { + self.readers += 1; + let closed = Arc::clone(&self.closed); + let drained = self.drained.0.clone(); + thread::spawn(move || { + let mut buffer = vec![0_u8; OUTPUT_CHUNK_BYTES]; + loop { + let read = match pipe.read(&mut buffer) { + Ok(0) => break, + Ok(read) => read, + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + // A terminal reports EIO once every slave descriptor is + // closed, which is its end-of-file. + Err(_) => break, + }; + let closed = crate::lock(&closed); + if !*closed { + forward(&buffer[..read]); + } + } + let _ = drained.send(()); + }); + } + + pub(crate) fn finish(self, exit: impl FnOnce()) { + let deadline = Instant::now() + OUTPUT_DRAIN_GRACE; + for _ in 0..self.readers { + let remaining = deadline.saturating_duration_since(Instant::now()); + if self.drained.1.recv_timeout(remaining).is_err() { + break; + } + } + let mut closed = crate::lock(&self.closed); + *closed = true; + exit(); + } +} diff --git a/rust/lithe-remote-server/src/resources.rs b/rust/lithe-remote-server/src/resources.rs new file mode 100644 index 0000000000..db6369551c --- /dev/null +++ b/rust/lithe-remote-server/src/resources.rs @@ -0,0 +1,148 @@ +//! Shared bookkeeping for client-identified resources. + +use lithe_remote_protocol::{ErrorCode, RemoteError}; +use std::collections::BTreeMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Condvar, Mutex, MutexGuard}; +use std::time::{Duration, Instant}; + +/// Highest resource id any client used in this session, so a reconnecting or +/// restarted client can continue numbering without collisions. +#[derive(Default)] +pub(crate) struct IdWatermark(AtomicU64); + +impl IdWatermark { + pub(crate) fn observe(&self, id: u64) { + self.0.fetch_max(id, Ordering::AcqRel); + } + + pub(crate) fn next(&self) -> u64 { + self.0.load(Ordering::Acquire) + 1 + } +} + +/// Live resources of one family, keyed by the client's id. The condition +/// variable lets shutdown wait, with a deadline, until resources have ended. +pub(crate) struct Registry { + family: &'static str, + entries: Mutex>, + changed: Condvar, +} + +impl Registry { + pub(crate) fn new(family: &'static str) -> Self { + Self { + family, + entries: Mutex::new(BTreeMap::new()), + changed: Condvar::new(), + } + } + + pub(crate) fn lock(&self) -> MutexGuard<'_, BTreeMap> { + crate::lock(&self.entries) + } + + /// Fails when the id is already live. The check and the later insert are + /// separated by the resource's start-up, so callers insert a placeholder + /// through [`Registry::reserve`] when start-up can be slow. + pub(crate) fn ensure_free(&self, id: u64) -> Result<(), RemoteError> { + if self.lock().contains_key(&id) { + return Err(self.in_use(id)); + } + Ok(()) + } + + pub(crate) fn insert(&self, id: u64, entry: T) -> Result<(), RemoteError> { + let mut entries = self.lock(); + if entries.contains_key(&id) { + return Err(self.in_use(id)); + } + entries.insert(id, entry); + Ok(()) + } + + pub(crate) fn remove(&self, id: u64) -> Option { + let removed = self.lock().remove(&id); + self.changed.notify_all(); + removed + } + + /// Keeps only the entries for which `keep` returns true. + pub(crate) fn retain(&self, keep: impl Fn(&T) -> bool) { + let mut entries = self.lock(); + let before = entries.len(); + entries.retain(|_, entry| keep(entry)); + let changed = entries.len() != before; + drop(entries); + if changed { + self.changed.notify_all(); + } + } + + pub(crate) fn unknown(&self, id: u64) -> RemoteError { + RemoteError::new( + ErrorCode::UnknownResource, + format!("{} {id} is not active", self.family), + ) + } + + fn in_use(&self, id: u64) -> RemoteError { + RemoteError::new( + ErrorCode::ResourceIdInUse, + format!("{} {id} already exists", self.family), + ) + } + + /// Waits until an entry is removed or `timeout` elapses. + pub(crate) fn wait_for_change(&self, timeout: Duration) { + let entries = self.lock(); + let _ = self.changed.wait_timeout(entries, timeout); + } +} + +/// Upper bound of requests executing on worker threads. Further requests +/// fail with `BUSY` rather than spawning unbounded threads. +const MAX_IN_FLIGHT_REQUESTS: usize = 64; + +/// Requests currently executing on worker threads. +#[derive(Default)] +pub(crate) struct InFlight { + count: Mutex, + changed: Condvar, +} + +impl InFlight { + /// Reserves a slot, or returns `false` when the limit is reached. + pub(crate) fn try_acquire(&self) -> bool { + let mut count = crate::lock(&self.count); + if *count >= MAX_IN_FLIGHT_REQUESTS { + return false; + } + *count += 1; + true + } + + pub(crate) fn release(&self) { + *crate::lock(&self.count) -= 1; + self.changed.notify_all(); + } + + /// Waits until no request is running or `grace` has passed, and returns + /// how many are still running. + pub(crate) fn drain(&self, grace: Duration) -> usize { + let deadline = Instant::now() + grace; + let mut count = crate::lock(&self.count); + while *count > 0 { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + break; + } + count = self + .changed + .wait_timeout(count, remaining) + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .0; + } + *count + } +} diff --git a/rust/lithe-remote-server/src/session.rs b/rust/lithe-remote-server/src/session.rs new file mode 100644 index 0000000000..bf2c8a5ea6 --- /dev/null +++ b/rust/lithe-remote-server/src/session.rs @@ -0,0 +1,158 @@ +//! Session state that outlives individual connections. + +use crate::environment::EnvironmentCache; +use crate::link::Link; +use crate::process::ProcessTable; +use crate::resources::{IdWatermark, InFlight}; +use crate::spawner::Spawner; +use crate::terminal::TerminalTable; +use crate::tunnel::TunnelTable; +use crate::walk::WalkTable; +use crate::watch::WatchTable; +use lithe_remote_protocol::methods::ServerState; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +/// Default time a persistent session survives without a client. +pub(crate) const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_secs(10 * 60); + +/// Upper bound clients may request, so a forgotten session does not keep +/// builds running on a shared host for days. +const MAX_IDLE_TIMEOUT: Duration = Duration::from_secs(24 * 60 * 60); + +/// Time process groups get to exit after `SIGTERM` before `SIGKILL`. +pub(crate) const TERMINATION_GRACE: Duration = Duration::from_secs(2); + +/// Time requests still running when the session ends get to finish. A write +/// that is cut off leaves its temporary sibling next to the user's file; one +/// request moves at most 8 MiB, so a healthy disk finishes well within this. +const WORKER_DRAIN_GRACE: Duration = Duration::from_secs(5); + +/// How often sessions kept for their remaining members are checked. A +/// process id can only be reused after its session emptied, so forgetting an +/// emptied session promptly keeps a later signal from reaching an unrelated +/// process that received the same id. +const REAP_INTERVAL: Duration = Duration::from_millis(500); + +/// Prunes emptied sessions until the session is dropped. +fn start_reaper(session: std::sync::Weak) -> std::io::Result<()> { + std::thread::Builder::new() + .name("remote-reaper".into()) + .spawn(move || loop { + std::thread::sleep(REAP_INTERVAL); + let Some(session) = session.upgrade() else { + return; + }; + session.processes.reap(); + session.terminals.reap(); + }) + .map(|_| ()) +} + +pub(crate) struct Session { + pub(crate) persistent: bool, + pub(crate) link: Link, + pub(crate) spawner: Spawner, + pub(crate) ids: IdWatermark, + pub(crate) environment: EnvironmentCache, + pub(crate) processes: ProcessTable, + pub(crate) terminals: TerminalTable, + pub(crate) watches: WatchTable, + pub(crate) tunnels: TunnelTable, + pub(crate) walks: WalkTable, + /// Requests running on worker threads, drained before the session ends. + pub(crate) workers: InFlight, + /// Whether any client completed a handshake before, which makes the + /// next one a resumption. + had_client: AtomicBool, + next_connection: AtomicU64, + idle_timeout: Mutex, + /// Runs once when a client requests `server.shutdown`, before the + /// response. The daemon uses it to stop being reachable at once. + shutdown_hook: Mutex>>, +} + +impl Session { + pub(crate) fn new(persistent: bool) -> std::io::Result> { + let session = Arc::new(Self { + persistent, + link: Link::new(), + spawner: Spawner::new()?, + ids: IdWatermark::default(), + environment: EnvironmentCache::default(), + processes: ProcessTable::new(), + terminals: TerminalTable::new(), + watches: WatchTable::new(), + tunnels: TunnelTable::new(), + walks: WalkTable::new(), + workers: InFlight::default(), + had_client: AtomicBool::new(false), + next_connection: AtomicU64::new(0), + idle_timeout: Mutex::new(DEFAULT_IDLE_TIMEOUT), + shutdown_hook: Mutex::new(None), + }); + start_reaper(Arc::downgrade(&session))?; + Ok(session) + } + + pub(crate) fn set_shutdown_hook(&self, hook: impl FnOnce() + Send + 'static) { + *crate::lock(&self.shutdown_hook) = Some(Box::new(hook)); + } + + /// Called for `server.shutdown` before it is answered. + pub(crate) fn begin_shutdown(&self) { + if let Some(hook) = crate::lock(&self.shutdown_hook).take() { + hook(); + } + } + + pub(crate) fn next_connection_id(&self) -> u64 { + self.next_connection.fetch_add(1, Ordering::Relaxed) + 1 + } + + /// Records a completed handshake; returns whether a client was attached + /// before. + pub(crate) fn mark_client(&self) -> bool { + self.had_client.swap(true, Ordering::AcqRel) + } + + pub(crate) fn set_idle_timeout(&self, seconds: u64) { + *crate::lock(&self.idle_timeout) = Duration::from_secs(seconds).min(MAX_IDLE_TIMEOUT); + } + + pub(crate) fn idle_timeout(&self) -> Duration { + *crate::lock(&self.idle_timeout) + } + + pub(crate) fn state(&self) -> ServerState { + ServerState { + processes: self.processes.states(), + terminals: self.terminals.states(), + watches: self.watches.states(), + } + } + + /// Resources bound to a client connection. Tunnels end because their + /// local sockets are gone; walks end because their results would only + /// fill the replay buffer. + pub(crate) fn release_connection_resources(&self, connection: u64) { + self.tunnels.close_owned_by(connection); + self.walks.cancel_owned_by(connection); + } + + /// Ends everything the session started. + pub(crate) fn terminate_all(&self) { + self.tunnels.close_all(); + self.walks.cancel_all(); + self.watches.stop_all(); + self.terminals.terminate_all(TERMINATION_GRACE); + self.processes.terminate_all(TERMINATION_GRACE); + // Running writes finish, and clean up their temporary files on + // failure, before the process exits and stops their threads. + let unfinished = self.workers.drain(WORKER_DRAIN_GRACE); + if unfinished > 0 { + eprintln!("lithe-remote-server: {unfinished} requests did not finish"); + } + } +} diff --git a/rust/lithe-remote-server/src/spawner.rs b/rust/lithe-remote-server/src/spawner.rs new file mode 100644 index 0000000000..2b0d2213e2 --- /dev/null +++ b/rust/lithe-remote-server/src/spawner.rs @@ -0,0 +1,50 @@ +//! The thread that starts every child process. +//! +//! On Linux, `PR_SET_PDEATHSIG` fires when the *thread* that forked a child +//! exits, not the process. Connections and worker threads come and go, so +//! spawning from them would kill children early. All spawns are therefore +//! executed on one thread that lives as long as the session. + +use std::sync::mpsc::{self, Sender}; +use std::sync::Mutex; +use std::thread; + +type Job = Box; + +pub(crate) struct Spawner { + jobs: Mutex>, +} + +impl Spawner { + pub(crate) fn new() -> std::io::Result { + let (sender, receiver) = mpsc::channel::(); + // The thread ends when the session drops the sender. Children it + // started then receive their parent-death signal, which is exactly + // the end of the session. + thread::Builder::new() + .name("remote-spawner".into()) + .spawn(move || { + while let Ok(job) = receiver.recv() { + job(); + } + })?; + Ok(Self { + jobs: Mutex::new(sender), + }) + } + + /// Runs `job` on the spawner thread and returns its result. Jobs must be + /// short: they only fork and exec, never wait for a child. + pub(crate) fn run(&self, job: impl FnOnce() -> R + Send + 'static) -> R { + let (reply, result) = mpsc::channel(); + let job: Job = Box::new(move || { + let _ = reply.send(job()); + }); + crate::lock(&self.jobs) + .send(job) + .expect("the spawner thread lives as long as the session"); + result + .recv() + .expect("the spawner thread completes every job it receives") + } +} diff --git a/rust/lithe-remote-server/src/terminal.rs b/rust/lithe-remote-server/src/terminal.rs new file mode 100644 index 0000000000..b670489ea4 --- /dev/null +++ b/rust/lithe-remote-server/src/terminal.rs @@ -0,0 +1,308 @@ +//! Interactive terminals backed by pseudo-terminals. +//! +//! The program, by default the user's login shell, becomes a session leader +//! with the pseudo-terminal as its controlling terminal, exactly as an SSH +//! login would set it up. Job control, `Ctrl-C` and window-size changes +//! therefore behave as users expect. + +use crate::connection::Context; +use crate::fs::host_path; +use crate::platform::{self, ChildKind}; +use crate::process::{ + exit_parts, has_exited_leaders, prune, queue_input, spawn_error, start_input_writer, + terminate_groups, Leader, OutputGate, +}; +use crate::resources::Registry; +use crate::session::{Session, TERMINATION_GRACE}; +use crate::{parse, to_value}; +use lithe_remote_protocol::methods::{ + self, decode_bytes, encode_bytes, TerminalExitEvent, TerminalOpenParams, TerminalOpenResult, + TerminalOutputEvent, TerminalParams, TerminalResizeParams, TerminalState, TerminalWriteParams, +}; +use lithe_remote_protocol::{ErrorCode, RemoteError}; +use serde_json::Value; +use std::fs::File; +use std::os::fd::{AsRawFd, OwnedFd}; +use std::os::unix::process::CommandExt; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::sync::mpsc::SyncSender; +use std::sync::Arc; +use std::thread; + +const DEFAULT_TERM: &str = "xterm-256color"; + +/// Shell used when the account has none, which happens in minimal images. +const FALLBACK_SHELL: &str = "/bin/sh"; + +pub(crate) struct TerminalTable { + registry: Registry, +} + +struct RunningTerminal { + /// Kept after the shell exits while jobs it started still run, so that + /// closing the terminal or ending the session still reaches them. + leader: Leader, + columns: u16, + rows: u16, + master: Arc, + /// `None` after `terminal.close`. + input: Option>>, +} + +impl TerminalTable { + pub(crate) fn new() -> Self { + Self { + registry: Registry::new("terminal"), + } + } + + pub(crate) fn handle( + &self, + context: &Context, + method: &str, + params: Value, + ) -> Result { + match method { + methods::TERMINAL_OPEN => to_value(self.open(&context.session, parse(params)?)?), + methods::TERMINAL_WRITE => self.write(parse(params)?).map(|()| Value::Null), + methods::TERMINAL_RESIZE => self.resize(parse(params)?).map(|()| Value::Null), + methods::TERMINAL_CLOSE => self + .close(&context.session, parse(params)?) + .map(|()| Value::Null), + _ => Err(RemoteError::new( + ErrorCode::UnknownMethod, + format!("unknown method {method}"), + )), + } + } + + pub(crate) fn states(&self) -> Vec { + self.registry + .lock() + .iter() + .filter(|(_, terminal)| !terminal.leader.exited) + .map(|(terminal_id, terminal)| TerminalState { + terminal_id: *terminal_id, + pid: terminal.leader.pid, + columns: terminal.columns, + rows: terminal.rows, + }) + .collect() + } + + fn open( + &self, + session: &Arc, + params: TerminalOpenParams, + ) -> Result { + session.ids.observe(params.terminal_id); + self.registry.ensure_free(params.terminal_id)?; + let account = platform::current_account(); + let mut command = match ¶ms.argv { + Some(argv) => { + let Some((program, arguments)) = argv.split_first() else { + return Err(RemoteError::new(ErrorCode::InvalidRequest, "argv is empty")); + }; + let mut command = Command::new(program); + command.args(arguments); + command + } + None => { + let shell = account + .shell + .clone() + .unwrap_or_else(|| FALLBACK_SHELL.into()); + let mut command = Command::new(&shell); + // A leading `-` in argv[0] is the POSIX convention for a login + // shell, understood by sh, bash, zsh, fish and csh alike. + let name = Path::new(&shell) + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "sh".into()); + command.arg0(format!("-{name}")); + command + } + }; + let directory = match ¶ms.cwd { + Some(cwd) => host_path(cwd)?, + None => account + .home_directory + .as_deref() + .map(std::path::PathBuf::from) + .unwrap_or_else(|| "/".into()), + }; + let (master, slave) = platform::open_pty(params.columns, params.rows) + .map_err(|error| crate::fs::io_error(error, ""))?; + let stdio = |fd: &OwnedFd| -> Result { + fd.try_clone() + .map(Stdio::from) + .map_err(|error| crate::fs::io_error(error, "")) + }; + command + .current_dir(directory) + .env("TERM", DEFAULT_TERM) + .envs(¶ms.env) + .stdin(stdio(&slave)?) + .stdout(stdio(&slave)?) + .stderr(stdio(&slave)?); + platform::prepare_child(&mut command, ChildKind::Terminal); + let program = command.get_program().to_string_lossy().into_owned(); + // The command, and with it the parent's copies of the slave side, is + // dropped on the spawner thread right after the fork. + let child = session + .spawner + .run(move || command.spawn()) + .map_err(|error| spawn_error(&program, error))?; + drop(slave); + + let master = Arc::new(master); + let reader = File::from( + master + .try_clone() + .map_err(|error| crate::fs::io_error(error, ""))?, + ); + let writer = File::from( + master + .try_clone() + .map_err(|error| crate::fs::io_error(error, ""))?, + ); + let pid = child.id(); + let entry = RunningTerminal { + leader: Leader::new(pid), + columns: params.columns, + rows: params.rows, + master, + input: Some(start_input_writer(writer)), + }; + if let Err(error) = self.registry.insert(params.terminal_id, entry) { + let _ = platform::signal_process_group(pid, libc::SIGKILL); + return Err(error); + } + + let terminal_id = params.terminal_id; + let mut gate = OutputGate::new(); + let output_session = Arc::clone(session); + gate.start_reader(reader, move |chunk| { + output_session.link.emit( + methods::EVENT_TERMINAL_OUTPUT, + TerminalOutputEvent { + terminal_id, + data: encode_bytes(chunk), + }, + ); + }); + let session = Arc::clone(session); + let mut child = child; + thread::spawn(move || { + let status = child.wait(); + gate.finish(|| { + let registry = &session.terminals.registry; + if let Some(terminal) = registry.lock().get_mut(&terminal_id) { + terminal.leader.exited = true; + terminal.input = None; + } + prune(registry, |terminal| &terminal.leader); + let (exit_code, signal) = exit_parts(status); + session.link.emit( + methods::EVENT_TERMINAL_EXIT, + TerminalExitEvent { + terminal_id, + exit_code, + signal, + }, + ); + }); + }); + Ok(TerminalOpenResult { pid }) + } + + fn write(&self, params: TerminalWriteParams) -> Result<(), RemoteError> { + let data = decode_bytes(¶ms.data) + .map_err(|error| RemoteError::new(ErrorCode::InvalidRequest, error.to_string()))?; + let terminals = self.registry.lock(); + let terminal = terminals + .get(¶ms.terminal_id) + .filter(|terminal| !terminal.leader.exited) + .ok_or_else(|| self.registry.unknown(params.terminal_id))?; + let Some(input) = &terminal.input else { + return Err(RemoteError::new( + ErrorCode::InvalidRequest, + "the terminal is closing", + )); + }; + queue_input(input, data) + } + + fn resize(&self, params: TerminalResizeParams) -> Result<(), RemoteError> { + let mut terminals = self.registry.lock(); + let terminal = terminals + .get_mut(¶ms.terminal_id) + .filter(|terminal| !terminal.leader.exited) + .ok_or_else(|| self.registry.unknown(params.terminal_id))?; + // The kernel sends SIGWINCH to the foreground process group. + platform::set_window_size(terminal.master.as_raw_fd(), params.columns, params.rows) + .map_err(|error| crate::fs::io_error(error, ""))?; + terminal.columns = params.columns; + terminal.rows = params.rows; + Ok(()) + } + + /// Hangs up the terminal like closing a terminal window: `SIGHUP` to its + /// whole session, including background jobs, and `SIGKILL` after a grace + /// period to whatever ignored it. + fn close(&self, session: &Arc, params: TerminalParams) -> Result<(), RemoteError> { + prune(&self.registry, |terminal| &terminal.leader); + let pid = { + let mut terminals = self.registry.lock(); + let terminal = terminals + .get_mut(¶ms.terminal_id) + .ok_or_else(|| self.registry.unknown(params.terminal_id))?; + terminal.input = None; + terminal.leader.pid + }; + let _ = platform::signal_session(pid, libc::SIGHUP); + let session = Arc::clone(session); + let terminal_id = params.terminal_id; + thread::spawn(move || { + let terminals = &session.terminals.registry; + let deadline = std::time::Instant::now() + TERMINATION_GRACE; + let owned = || { + prune(terminals, |terminal| &terminal.leader); + terminals + .lock() + .get(&terminal_id) + .is_some_and(|terminal| terminal.leader.pid == pid) + }; + while std::time::Instant::now() < deadline { + if !owned() { + return; + } + // Jobs that are not the server's children report no exit, so + // the wait also wakes periodically to re-check the session. + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + terminals.wait_for_change(remaining.min(crate::process::SESSION_POLL_INTERVAL)); + } + if owned() { + let _ = platform::signal_session(pid, libc::SIGKILL); + } + }); + Ok(()) + } + + /// Forgets sessions that emptied after their shell exited. + pub(crate) fn reap(&self) { + if has_exited_leaders(&self.registry, |terminal| &terminal.leader) { + prune(&self.registry, |terminal| &terminal.leader); + } + } + + pub(crate) fn terminate_all(&self, grace: std::time::Duration) { + terminate_groups( + &self.registry, + grace, + |terminal| &terminal.leader, + libc::SIGHUP, + ); + } +} diff --git a/rust/lithe-remote-server/src/tunnel.rs b/rust/lithe-remote-server/src/tunnel.rs new file mode 100644 index 0000000000..c8ec061727 --- /dev/null +++ b/rust/lithe-remote-server/src/tunnel.rs @@ -0,0 +1,254 @@ +//! Byte streams to TCP ports and Unix sockets on the remote side. +//! +//! The client listens locally and opens one tunnel per accepted connection, +//! which is how a web server, database or debugger on the remote becomes +//! reachable from the user's machine. Tunnels travel inside the protocol +//! stream, so the server never opens a listening port of its own. + +use crate::connection::Context; +use crate::parse; +use crate::process::{queue_input, INPUT_QUEUE_CAPACITY}; +use crate::resources::Registry; +use crate::session::Session; +use lithe_remote_protocol::methods::{ + self, decode_bytes, encode_bytes, TunnelClosedEvent, TunnelDataEvent, TunnelOpenParams, + TunnelParams, TunnelTarget, TunnelWriteParams, +}; +use lithe_remote_protocol::{ErrorCode, RemoteError}; +use serde_json::{json, Value}; +use std::io::{self, Read, Write}; +use std::net::{Shutdown, TcpStream, ToSocketAddrs}; +use std::os::unix::net::UnixStream; +use std::sync::mpsc::SyncSender; +use std::sync::Arc; +use std::thread; +use std::time::Duration; + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const READ_CHUNK_BYTES: usize = 32 * 1024; + +pub(crate) struct TunnelTable { + registry: Registry, +} + +struct OpenTunnel { + connection: u64, + /// `None` after `tunnel.shutdownWrite`: the writer then sends what is + /// queued and half-closes the stream. + input: Option>>, + stream: Stream, +} + +enum Stream { + Tcp(TcpStream), + Unix(UnixStream), +} + +impl Stream { + fn try_clone(&self) -> io::Result { + Ok(match self { + Self::Tcp(stream) => Self::Tcp(stream.try_clone()?), + Self::Unix(stream) => Self::Unix(stream.try_clone()?), + }) + } + + fn shutdown(&self, how: Shutdown) { + let _ = match self { + Self::Tcp(stream) => stream.shutdown(how), + Self::Unix(stream) => stream.shutdown(how), + }; + } +} + +impl Read for Stream { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + match self { + Self::Tcp(stream) => stream.read(buffer), + Self::Unix(stream) => stream.read(buffer), + } + } +} + +impl Write for Stream { + fn write(&mut self, bytes: &[u8]) -> io::Result { + match self { + Self::Tcp(stream) => stream.write(bytes), + Self::Unix(stream) => stream.write(bytes), + } + } + + fn flush(&mut self) -> io::Result<()> { + match self { + Self::Tcp(stream) => stream.flush(), + Self::Unix(stream) => stream.flush(), + } + } +} + +impl TunnelTable { + pub(crate) fn new() -> Self { + Self { + registry: Registry::new("tunnel"), + } + } + + pub(crate) fn handle( + &self, + context: &Context, + method: &str, + params: Value, + ) -> Result { + match method { + methods::TUNNEL_OPEN => self.open(context, parse(params)?).map(|()| Value::Null), + methods::TUNNEL_WRITE => { + let params: TunnelWriteParams = parse(params)?; + let data = decode_bytes(¶ms.data).map_err(|error| { + RemoteError::new(ErrorCode::InvalidRequest, error.to_string()) + })?; + let tunnels = self.registry.lock(); + let tunnel = tunnels + .get(¶ms.tunnel_id) + .ok_or_else(|| self.registry.unknown(params.tunnel_id))?; + let Some(input) = &tunnel.input else { + return Err(RemoteError::new( + ErrorCode::InvalidRequest, + "the tunnel's write side is shut down", + )); + }; + queue_input(input, data).map(|()| Value::Null) + } + methods::TUNNEL_SHUTDOWN_WRITE => { + // Like `shutdown(SHUT_WR)` on a socket: the peer reads + // end-of-file after the queued data, and its reply still + // arrives as `tunnel.data` until it closes. + let params: TunnelParams = parse(params)?; + let mut tunnels = self.registry.lock(); + let tunnel = tunnels + .get_mut(¶ms.tunnel_id) + .ok_or_else(|| self.registry.unknown(params.tunnel_id))?; + tunnel.input = None; + Ok(Value::Null) + } + methods::TUNNEL_CLOSE => { + // Aborts both directions; queued data is discarded. + let params: TunnelParams = parse(params)?; + let tunnels = self.registry.lock(); + let tunnel = tunnels + .get(¶ms.tunnel_id) + .ok_or_else(|| self.registry.unknown(params.tunnel_id))?; + tunnel.stream.shutdown(Shutdown::Both); + Ok(Value::Null) + } + _ => Err(RemoteError::new( + ErrorCode::UnknownMethod, + format!("unknown method {method}"), + )), + } + } + + pub(crate) fn close_owned_by(&self, connection: u64) { + for tunnel in self.registry.lock().values() { + if tunnel.connection == connection { + tunnel.stream.shutdown(Shutdown::Both); + } + } + } + + pub(crate) fn close_all(&self) { + for tunnel in self.registry.lock().values() { + tunnel.stream.shutdown(Shutdown::Both); + } + } + + fn open(&self, context: &Context, params: TunnelOpenParams) -> Result<(), RemoteError> { + let session = &context.session; + session.ids.observe(params.tunnel_id); + self.registry.ensure_free(params.tunnel_id)?; + let stream = connect(¶ms.target).map_err(|error| { + RemoteError::new(ErrorCode::ConnectFailed, format!("cannot connect: {error}")) + .with_details(json!({ "osError": error.raw_os_error() })) + })?; + let io_failure = |error: io::Error| crate::fs::io_error(error, ""); + let reader = stream.try_clone().map_err(io_failure)?; + let writer = stream.try_clone().map_err(io_failure)?; + self.registry.insert( + params.tunnel_id, + OpenTunnel { + connection: context.connection, + input: Some(start_tunnel_writer(writer)), + stream, + }, + )?; + let session = Arc::clone(session); + let tunnel_id = params.tunnel_id; + thread::Builder::new() + .name("remote-tunnel".into()) + .spawn(move || forward(&session, tunnel_id, reader)) + .map_err(io_failure)?; + Ok(()) + } +} + +/// Writes queued chunks to the remote stream. When the queue's sender is +/// dropped (`tunnel.shutdownWrite`), the queued data is still written and +/// then only the write side is shut down, so the peer can still answer. +fn start_tunnel_writer(mut stream: Stream) -> SyncSender> { + let (sender, receiver) = std::sync::mpsc::sync_channel::>(INPUT_QUEUE_CAPACITY); + thread::spawn(move || { + while let Ok(chunk) = receiver.recv() { + if stream.write_all(&chunk).is_err() { + return; + } + } + stream.shutdown(Shutdown::Write); + }); + sender +} + +fn connect(target: &TunnelTarget) -> io::Result { + match target { + TunnelTarget::Tcp { host, port } => { + let mut last_error = None; + for address in (host.as_str(), *port).to_socket_addrs()? { + match TcpStream::connect_timeout(&address, CONNECT_TIMEOUT) { + Ok(stream) => { + // Interactive protocols such as debuggers and + // terminals in browsers send small writes. + let _ = stream.set_nodelay(true); + return Ok(Stream::Tcp(stream)); + } + Err(error) => last_error = Some(error), + } + } + Err(last_error.unwrap_or_else(|| { + io::Error::new(io::ErrorKind::NotFound, "the host has no address") + })) + } + TunnelTarget::Unix { path } => UnixStream::connect(path).map(Stream::Unix), + } +} + +fn forward(session: &Session, tunnel_id: u64, mut reader: Stream) { + let mut buffer = vec![0_u8; READ_CHUNK_BYTES]; + let error = loop { + match reader.read(&mut buffer) { + Ok(0) => break None, + Ok(read) => session.link.emit( + methods::EVENT_TUNNEL_DATA, + TunnelDataEvent { + tunnel_id, + data: encode_bytes(&buffer[..read]), + }, + ), + Err(error) if error.kind() == io::ErrorKind::Interrupted => {} + Err(error) => break Some(error.to_string()), + } + }; + if let Some(tunnel) = session.tunnels.registry.remove(tunnel_id) { + tunnel.stream.shutdown(Shutdown::Both); + } + session.link.emit( + methods::EVENT_TUNNEL_CLOSED, + TunnelClosedEvent { tunnel_id, error }, + ); +} diff --git a/rust/lithe-remote-server/src/walk.rs b/rust/lithe-remote-server/src/walk.rs new file mode 100644 index 0000000000..828143b914 --- /dev/null +++ b/rust/lithe-remote-server/src/walk.rs @@ -0,0 +1,226 @@ +//! Streaming recursive listings for file indexes and "go to file". +//! +//! One `walk.start` replaces thousands of `fs.readDir` round trips, which over +//! a high-latency link is the difference between seconds and minutes for a +//! large repository. Entries arrive in depth-first, name-sorted batches. + +use crate::connection::Context; +use crate::fs::{host_path, kind_of}; +use crate::parse; +use crate::resources::Registry; +use crate::session::Session; +use lithe_remote_protocol::methods::{ + self, FileKind, WalkDoneEvent, WalkEntriesEvent, WalkEntry, WalkParams, WalkStartParams, +}; +use lithe_remote_protocol::{ErrorCode, RemoteError}; +use serde_json::Value; +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::thread; + +/// Entries per `walk.entries` event: large enough to amortize framing, +/// small enough to keep each frame far below the size limit. +const BATCH_ENTRIES: usize = 2000; + +pub(crate) struct WalkTable { + registry: Registry, +} + +struct RunningWalk { + connection: u64, + cancelled: Arc, +} + +impl WalkTable { + pub(crate) fn new() -> Self { + Self { + registry: Registry::new("walk"), + } + } + + pub(crate) fn handle( + &self, + context: &Context, + method: &str, + params: Value, + ) -> Result { + match method { + methods::WALK_START => self.start(context, parse(params)?).map(|()| Value::Null), + methods::WALK_CANCEL => { + let params: WalkParams = parse(params)?; + let walks = self.registry.lock(); + let walk = walks + .get(¶ms.walk_id) + .ok_or_else(|| self.registry.unknown(params.walk_id))?; + walk.cancelled.store(true, Ordering::Release); + Ok(Value::Null) + } + _ => Err(RemoteError::new( + ErrorCode::UnknownMethod, + format!("unknown method {method}"), + )), + } + } + + pub(crate) fn cancel_owned_by(&self, connection: u64) { + for walk in self.registry.lock().values() { + if walk.connection == connection { + walk.cancelled.store(true, Ordering::Release); + } + } + } + + pub(crate) fn cancel_all(&self) { + for walk in self.registry.lock().values() { + walk.cancelled.store(true, Ordering::Release); + } + } + + fn start(&self, context: &Context, params: WalkStartParams) -> Result<(), RemoteError> { + let session = &context.session; + session.ids.observe(params.walk_id); + let root = host_path(¶ms.root)?; + if !std::fs::metadata(&root) + .map_err(|error| crate::fs::io_error(error, ¶ms.root))? + .is_dir() + { + return Err(RemoteError::new( + ErrorCode::NotADirectory, + "only directories can be walked", + )); + } + let cancelled = Arc::new(AtomicBool::new(false)); + self.registry.insert( + params.walk_id, + RunningWalk { + connection: context.connection, + cancelled: Arc::clone(&cancelled), + }, + )?; + let session = Arc::clone(session); + let walker = Walker { + walk_id: params.walk_id, + root, + excludes: params.exclude_names.into_iter().collect(), + limit: params.max_entries.unwrap_or(u64::MAX), + cancelled, + }; + thread::Builder::new() + .name("remote-walk".into()) + .spawn(move || { + let done = walker.run(&session); + session.walks.registry.remove(done.walk_id); + session.link.emit(methods::EVENT_WALK_DONE, done); + }) + .map_err(|error| crate::fs::io_error(error, ""))?; + Ok(()) + } +} + +struct Walker { + walk_id: u64, + root: PathBuf, + excludes: BTreeSet, + limit: u64, + cancelled: Arc, +} + +impl Walker { + fn run(&self, session: &Session) -> WalkDoneEvent { + let mut done = WalkDoneEvent { + walk_id: self.walk_id, + total: 0, + truncated: false, + cancelled: false, + skipped_non_utf8: 0, + unreadable_directories: 0, + }; + let mut batch = Vec::with_capacity(BATCH_ENTRIES); + // Each stack frame is a directory's remaining, reverse-sorted + // children, so popping yields name order depth-first. + let mut stack: Vec> = Vec::new(); + match self.children(&self.root, "", &mut done) { + Some(children) => stack.push(children), + None => done.unreadable_directories += 1, + } + while let Some(frame) = stack.last_mut() { + if self.cancelled.load(Ordering::Acquire) { + done.cancelled = true; + break; + } + let Some((relative, path, kind)) = frame.pop() else { + stack.pop(); + continue; + }; + if done.total >= self.limit { + done.truncated = true; + break; + } + done.total += 1; + let descend = kind == FileKind::Directory + && !path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| self.excludes.contains(name)); + batch.push(WalkEntry { + path: relative.clone(), + kind, + }); + if batch.len() >= BATCH_ENTRIES { + self.flush(session, &mut batch); + } + if descend { + match self.children(&path, &relative, &mut done) { + Some(children) => stack.push(children), + None => done.unreadable_directories += 1, + } + } + } + self.flush(session, &mut batch); + done + } + + fn children( + &self, + directory: &Path, + relative: &str, + done: &mut WalkDoneEvent, + ) -> Option> { + let entries = std::fs::read_dir(directory).ok()?; + let mut children = Vec::new(); + for entry in entries.flatten() { + let Ok(name) = entry.file_name().into_string() else { + done.skipped_non_utf8 += 1; + continue; + }; + // `DirEntry::file_type` does not follow links, so a link to a + // directory is reported as a link and never descended into. + let Ok(file_type) = entry.file_type() else { + continue; + }; + let path_relative = if relative.is_empty() { + name.clone() + } else { + format!("{relative}/{name}") + }; + children.push((path_relative, entry.path(), kind_of(&file_type))); + } + children.sort_by(|left, right| right.0.cmp(&left.0)); + Some(children) + } + + fn flush(&self, session: &Session, batch: &mut Vec) { + if batch.is_empty() { + return; + } + session.link.emit( + methods::EVENT_WALK_ENTRIES, + WalkEntriesEvent { + walk_id: self.walk_id, + entries: std::mem::take(batch), + }, + ); + } +} diff --git a/rust/lithe-remote-server/src/watch.rs b/rust/lithe-remote-server/src/watch.rs new file mode 100644 index 0000000000..3d32056459 --- /dev/null +++ b/rust/lithe-remote-server/src/watch.rs @@ -0,0 +1,385 @@ +//! Recursive file watching. +//! +//! The platform backend (inotify or FSEvents) reports raw changes to a +//! coalescer, which merges bursts, such as a build writing thousands of files +//! or an editor's save sequence, into sorted batches. A batch is sent after +//! [`QUIET_PERIOD`] without new changes, or at the latest [`MAX_BATCH_DELAY`] +//! after its first change. When changes are lost, the batch carries +//! `overflow` and the client rescans instead of trusting partial data. + +use crate::connection::Context; +use crate::fs::host_path; +use crate::platform::watcher; +use crate::resources::Registry; +use crate::session::Session; +use crate::{parse, to_value}; +use lithe_remote_protocol::methods::{ + self, ChangeKind, FileChange, WatchChangedEvent, WatchErrorCode, WatchErrorEvent, WatchParams, + WatchStartParams, WatchStartResult, WatchState, +}; +use lithe_remote_protocol::{ErrorCode, RemoteError}; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; +use std::sync::Arc; +use std::thread; +use std::time::{Duration, Instant}; + +const QUIET_PERIOD: Duration = Duration::from_millis(50); +const MAX_BATCH_DELAY: Duration = Duration::from_millis(250); + +/// Changes per batch before the batch degrades to `overflow`. Beyond this +/// a rescan is cheaper for the client than applying individual changes. +const MAX_BATCH_CHANGES: usize = 5000; + +/// A change as reported by a backend, before coalescing. +pub(crate) enum RawEvent { + Change(PathBuf, ChangeKind), + /// The backend dropped events. + Overflow, + /// Only per-directory backends (inotify) have a limit. + #[cfg_attr(target_os = "macos", allow(dead_code))] + LimitReached(String), + RootRemoved, +} + +/// Directory names below the root that are neither watched nor reported. +pub(crate) struct Excludes { + root: PathBuf, + names: BTreeSet, +} + +impl Excludes { + pub(crate) fn new(root: PathBuf, names: Vec) -> Self { + Self { + root, + names: names.into_iter().collect(), + } + } + + pub(crate) fn contains(&self, path: &Path) -> bool { + let Ok(relative) = path.strip_prefix(&self.root) else { + return false; + }; + relative.components().any(|component| { + component + .as_os_str() + .to_str() + .is_some_and(|name| self.names.contains(name)) + }) + } + + #[cfg_attr(target_os = "macos", allow(dead_code))] + pub(crate) fn is_excluded_name(&self, name: &std::ffi::OsStr) -> bool { + name.to_str().is_some_and(|name| self.names.contains(name)) + } +} + +/// Receives raw events from a backend thread. +pub(crate) type Sink = Sender; + +pub(crate) struct WatchTable { + registry: Registry, +} + +struct RunningWatch { + root: String, + /// Dropping the backend handle stops the backend, which closes the + /// coalescer's channel and ends its thread. + _backend: watcher::Watcher, +} + +impl WatchTable { + pub(crate) fn new() -> Self { + Self { + registry: Registry::new("watch"), + } + } + + pub(crate) fn handle( + &self, + context: &Context, + method: &str, + params: Value, + ) -> Result { + match method { + methods::WATCH_START => to_value(self.start(&context.session, parse(params)?)?), + methods::WATCH_STOP => { + let params: WatchParams = parse(params)?; + self.registry + .remove(params.watch_id) + .map(|_| Value::Null) + .ok_or_else(|| self.registry.unknown(params.watch_id)) + } + _ => Err(RemoteError::new( + ErrorCode::UnknownMethod, + format!("unknown method {method}"), + )), + } + } + + pub(crate) fn states(&self) -> Vec { + self.registry + .lock() + .iter() + .map(|(watch_id, watch)| WatchState { + watch_id: *watch_id, + root: watch.root.clone(), + }) + .collect() + } + + pub(crate) fn stop_all(&self) { + let watches = std::mem::take(&mut *self.registry.lock()); + drop(watches); + } + + fn start( + &self, + session: &Arc, + params: WatchStartParams, + ) -> Result { + session.ids.observe(params.watch_id); + self.registry.ensure_free(params.watch_id)?; + let root = host_path(¶ms.root)?; + if !std::fs::metadata(&root) + .map_err(|error| crate::fs::io_error(error, ¶ms.root))? + .is_dir() + { + return Err(RemoteError::new( + ErrorCode::NotADirectory, + "only directories can be watched", + )); + } + let excludes = Arc::new(Excludes::new(root.clone(), params.exclude_names)); + let (sink, raw) = mpsc::channel(); + let (backend, watched_directories) = watcher::start(root, excludes, sink) + .map_err(|error| crate::fs::io_error(error, ¶ms.root))?; + self.registry.insert( + params.watch_id, + RunningWatch { + root: params.root, + _backend: backend, + }, + )?; + let session = Arc::clone(session); + let watch_id = params.watch_id; + thread::Builder::new() + .name("remote-watch".into()) + .spawn(move || coalesce(raw, |batch| deliver(&session, watch_id, batch))) + .map_err(|error| crate::fs::io_error(error, ""))?; + Ok(WatchStartResult { + watched_directories, + }) + } +} + +/// What the coalescer hands to delivery. +pub(crate) enum Delivery { + Changes { + changes: Vec, + overflow: bool, + }, + Error(WatchErrorCode, String), +} + +fn deliver(session: &Session, watch_id: u64, delivery: Delivery) { + match delivery { + Delivery::Changes { changes, overflow } => session.link.emit( + methods::EVENT_WATCH_CHANGED, + WatchChangedEvent { + watch_id, + changes, + overflow, + }, + ), + Delivery::Error(code, message) => { + if code == WatchErrorCode::RootRemoved { + session.watches.registry.remove(watch_id); + } + session.link.emit( + methods::EVENT_WATCH_ERROR, + WatchErrorEvent { + watch_id, + code, + message, + }, + ); + } + } +} + +#[derive(Default)] +struct Batch { + changes: BTreeMap, + overflow: bool, +} + +impl Batch { + fn add(&mut self, path: PathBuf, kind: ChangeKind) { + if self.overflow { + return; + } + let merged = match (self.changes.get(&path), kind) { + // A file created and then written in one burst is still new. + (Some(ChangeKind::Created), ChangeKind::Modified) => ChangeKind::Created, + // Deleted and recreated, as atomic saves do: the client keeps + // its entry and reloads the content. + (Some(ChangeKind::Deleted), ChangeKind::Created) => ChangeKind::Modified, + (_, kind) => kind, + }; + self.changes.insert(path, merged); + if self.changes.len() > MAX_BATCH_CHANGES { + self.changes.clear(); + self.overflow = true; + } + } + + fn take(&mut self) -> Option { + let batch = std::mem::take(self); + if batch.changes.is_empty() && !batch.overflow { + return None; + } + let mut overflow = batch.overflow; + let mut changes = Vec::with_capacity(batch.changes.len()); + for (path, kind) in batch.changes { + match path.into_os_string().into_string() { + Ok(path) => changes.push(FileChange { path, kind }), + // Not addressable in this protocol version; a rescan shows it. + Err(_) => overflow = true, + } + } + Some(Delivery::Changes { changes, overflow }) + } +} + +/// Merges raw events into batches until the backend closes the channel. +pub(crate) fn coalesce(raw: Receiver, mut deliver: impl FnMut(Delivery)) { + let mut batch = Batch::default(); + loop { + // Wait for the first event of a batch without a deadline. + let Ok(first) = raw.recv() else { + return; + }; + let started = Instant::now(); + let mut next = Some(first); + loop { + match next.take() { + Some(RawEvent::Change(path, kind)) => batch.add(path, kind), + Some(RawEvent::Overflow) => { + batch.changes.clear(); + batch.overflow = true; + } + Some(RawEvent::LimitReached(message)) => { + if let Some(changes) = batch.take() { + deliver(changes); + } + deliver(Delivery::Error(WatchErrorCode::LimitReached, message)); + } + Some(RawEvent::RootRemoved) => { + if let Some(changes) = batch.take() { + deliver(changes); + } + deliver(Delivery::Error( + WatchErrorCode::RootRemoved, + "the watched directory was removed".into(), + )); + return; + } + None => {} + } + let waited = started.elapsed(); + if waited >= MAX_BATCH_DELAY { + break; + } + match raw.recv_timeout(QUIET_PERIOD.min(MAX_BATCH_DELAY - waited)) { + Ok(event) => next = Some(event), + Err(RecvTimeoutError::Timeout) => break, + Err(RecvTimeoutError::Disconnected) => { + if let Some(changes) = batch.take() { + deliver(changes); + } + return; + } + } + } + if let Some(changes) = batch.take() { + deliver(changes); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn changes_of(deliveries: &[Delivery]) -> Vec<(String, ChangeKind)> { + deliveries + .iter() + .flat_map(|delivery| match delivery { + Delivery::Changes { changes, .. } => changes + .iter() + .map(|change| (change.path.clone(), change.kind)) + .collect(), + Delivery::Error(..) => Vec::new(), + }) + .collect() + } + + // An editor's atomic save deletes and recreates the file, and a new file + // is usually written right after creation. Clients must see one + // `modified` and one `created`, not the raw sequence. + #[test] + fn bursts_merge_into_one_sorted_batch() { + let (sender, receiver) = mpsc::channel(); + for (path, kind) in [ + ("/w/b.txt", ChangeKind::Deleted), + ("/w/a.txt", ChangeKind::Created), + ("/w/b.txt", ChangeKind::Created), + ("/w/a.txt", ChangeKind::Modified), + ] { + sender.send(RawEvent::Change(path.into(), kind)).unwrap(); + } + drop(sender); + let mut deliveries = Vec::new(); + coalesce(receiver, |delivery| deliveries.push(delivery)); + assert_eq!( + changes_of(&deliveries), + vec![ + ("/w/a.txt".into(), ChangeKind::Created), + ("/w/b.txt".into(), ChangeKind::Modified), + ] + ); + } + + #[test] + fn lost_events_turn_the_batch_into_an_overflow() { + let (sender, receiver) = mpsc::channel(); + sender + .send(RawEvent::Change("/w/a".into(), ChangeKind::Created)) + .unwrap(); + sender.send(RawEvent::Overflow).unwrap(); + sender + .send(RawEvent::Change("/w/b".into(), ChangeKind::Created)) + .unwrap(); + drop(sender); + let mut deliveries = Vec::new(); + coalesce(receiver, |delivery| deliveries.push(delivery)); + assert!(matches!( + deliveries.as_slice(), + [Delivery::Changes { changes, overflow: true }] if changes.is_empty() + )); + } + + #[test] + fn excluded_names_match_any_component_below_the_root() { + let excludes = Excludes::new("/w".into(), vec!["node_modules".into(), "target".into()]); + assert!(excludes.contains(Path::new("/w/node_modules/x/index.js"))); + assert!(excludes.contains(Path::new("/w/app/target/classes"))); + assert!(!excludes.contains(Path::new("/w/src/target.rs"))); + // The root's own ancestors never count, even if one is named target. + let nested = Excludes::new("/srv/target/w".into(), vec!["target".into()]); + assert!(!nested.contains(Path::new("/srv/target/w/src/main.rs"))); + } +} diff --git a/rust/lithe-remote-server/tests/persistence.rs b/rust/lithe-remote-server/tests/persistence.rs new file mode 100644 index 0000000000..3a93ec933b --- /dev/null +++ b/rust/lithe-remote-server/tests/persistence.rs @@ -0,0 +1,420 @@ +//! Persistent sessions: surviving disconnection, replay and expiry. + +mod support; + +use lithe_remote_client::api::{ProcessEvent, TerminalEvent}; +use lithe_remote_client::{SpawnSpec, TerminalSpec}; +use lithe_remote_protocol::methods::{self, OutputStream, ProcessExitEvent, ProcessOutputEvent}; +use std::collections::BTreeMap; +use std::time::{Duration, Instant}; +use support::{wait_until_gone, Daemon, TempDir, STEP_TIMEOUT}; + +fn argv(values: &[&str]) -> Vec { + values.iter().map(|value| value.to_string()).collect() +} + +// A build keeps running while the network is down, its output produced in +// the meantime is replayed, and the exit arrives after the output. +#[test] +fn processes_survive_a_disconnect_and_their_output_is_replayed() { + let daemon = Daemon::new("replay"); + let gate = TempDir::new("gate"); + let release = gate.join("release"); + let (session, events) = daemon.connect(None); + assert!(session.hello().session.persistent); + assert!(!session.hello().session.resumed); + // The process waits for a file the test creates while disconnected, so + // its output is produced only when no client is attached. + let script = format!("while [ ! -e '{release}' ]; do sleep 0.05; done; echo after-release"); + let process = session + .spawn(SpawnSpec { + argv: argv(&["sh", "-c", &script]), + ..SpawnSpec::default() + }) + .unwrap(); + let (process_id, pid) = (process.id, process.pid); + drop(process); + drop(events); + drop(session); + + std::fs::write(&release, b"").unwrap(); + // Wait for the process to finish while detached, so the replay must + // contain both its output and its exit. + assert!(wait_until_gone(pid), "process {pid} did not finish"); + + let (session, events) = daemon.connect(None); + let hello = session.hello(); + assert!(hello.session.resumed); + assert!(hello.session.next_resource_id > process_id); + + let deadline = Instant::now() + STEP_TIMEOUT; + let mut stdout = Vec::new(); + loop { + let event = events + .recv_timeout(deadline.saturating_duration_since(Instant::now())) + .unwrap_or_else(|_| panic!("no replayed exit; output {stdout:?}\n{}", daemon.log())); + match event.event.as_str() { + methods::EVENT_PROCESS_OUTPUT => { + let output: ProcessOutputEvent = event.decode().unwrap(); + assert_eq!(output.process_id, process_id); + assert_eq!(output.stream, OutputStream::Stdout); + stdout.extend(methods::decode_bytes(&output.data).unwrap()); + } + methods::EVENT_PROCESS_EXIT => { + let exit: ProcessExitEvent = event.decode().unwrap(); + assert_eq!(exit.process_id, process_id); + assert_eq!(exit.exit_code, Some(0)); + break; + } + _ => {} + } + } + assert_eq!(stdout, b"after-release\n"); + session.shutdown(STEP_TIMEOUT); + assert!( + daemon.wait_until_stopped(), + "the daemon kept running after shutdown" + ); +} + +// After an app restart the client learns from `server.state` which +// terminals still run, attaches to them, and only then resumes, so output +// produced while it was away reaches the terminal handle instead of the +// general channel. +#[test] +fn terminals_can_be_reattached_after_reconnecting() { + let daemon = Daemon::new("reattach"); + let gate = TempDir::new("reattach-gate"); + let release = gate.join("release"); + let (session, _events) = daemon.connect(None); + let terminal = session + .open_terminal(TerminalSpec { + argv: Some(vec!["/bin/sh".into()]), + cwd: None, + env: BTreeMap::from([ + ("PS1".into(), "$ ".into()), + ("ENV".into(), String::new()), + ("HISTFILE".into(), "/dev/null".into()), + ]), + columns: 80, + rows: 24, + }) + .unwrap(); + // The answer is printed only after the test creates the gate file while + // no client is connected. + let command = + format!("while [ ! -e '{release}' ]; do sleep 0.05; done; echo later-$((40+2))\n"); + terminal.write(command.as_bytes()).unwrap(); + let terminal_id = terminal.id; + drop(terminal); + let seen = session.last_event_seq(); + drop(session); + std::fs::write(&release, b"").unwrap(); + + let (session, events) = daemon.connect_deferred(); + let state = session.state().unwrap(); + let running = state + .terminals + .iter() + .find(|running| running.terminal_id == terminal_id) + .expect("the terminal survived the disconnect"); + let terminal = session.attach_terminal(running.terminal_id, running.pid); + let resumed = session.resume(Some(seen)).unwrap(); + assert!(!resumed.truncated); + let deadline = Instant::now() + STEP_TIMEOUT; + let mut output = Vec::new(); + while !String::from_utf8_lossy(&output).contains("later-42") { + match terminal.next_event(deadline.saturating_duration_since(Instant::now())) { + Ok(Some(TerminalEvent::Output(bytes))) => output.extend(bytes), + other => panic!("{other:?}; output {:?}", String::from_utf8_lossy(&output)), + } + } + assert!( + events + .try_iter() + .all(|event| event.resource() != Some(("terminal", terminal_id))), + "replayed terminal output went to the general channel" + ); + terminal.write(b"echo still-$((40+3))\n").unwrap(); + while !String::from_utf8_lossy(&output).contains("still-43") { + match terminal.next_event(deadline.saturating_duration_since(Instant::now())) { + Ok(Some(TerminalEvent::Output(bytes))) => output.extend(bytes), + other => panic!("{other:?}; output {:?}", String::from_utf8_lossy(&output)), + } + } + drop(terminal); + session.shutdown(STEP_TIMEOUT); + assert!(daemon.wait_until_stopped()); +} + +// Regression: events the dying connection's outbox or socket had already +// accepted were lost. The client resumes after the last event it actually +// processed and must then receive every later event exactly once. +#[test] +fn resuming_after_a_lost_connection_delivers_every_event_exactly_once() { + let daemon = Daemon::new("lossless"); + let lines = 20_000; + let (session, _events) = daemon.connect(None); + let process_id = session.allocate_id(); + let subscription = session.subscribe("process", process_id); + session + .request_value( + methods::PROCESS_SPAWN, + serde_json::json!({ + "processId": process_id, + "argv": ["awk", format!("BEGIN {{ for (i = 1; i <= {lines}; i++) print i }}")], + }), + STEP_TIMEOUT, + ) + .unwrap(); + // Process only the first event. Every later event, up to the exit, is + // sent over this connection but treated as lost in transit, as events in + // a dying connection's outbox or socket buffers are. + let first = subscription + .recv_timeout(STEP_TIMEOUT) + .expect("first output"); + let output: ProcessOutputEvent = first.decode().unwrap(); + let mut stdout = methods::decode_bytes(&output.data).unwrap(); + let last_processed = first.seq.unwrap(); + let deadline = Instant::now() + STEP_TIMEOUT; + loop { + let event = subscription + .recv_timeout(deadline.saturating_duration_since(Instant::now())) + .expect("the process exit on the first connection"); + if event.event == methods::EVENT_PROCESS_EXIT { + break; + } + } + drop(subscription); + drop(session); + + let (session, _events) = daemon.connect_deferred(); + let subscription = session.subscribe("process", process_id); + assert!(!session.resume(Some(last_processed)).unwrap().truncated); + let deadline = Instant::now() + STEP_TIMEOUT; + let mut previous = last_processed; + loop { + let event = subscription + .recv_timeout(deadline.saturating_duration_since(Instant::now())) + .expect("the process exit after resuming"); + let seq = event.seq.unwrap(); + assert!( + seq > previous, + "event {seq} repeated or out of order after {previous}" + ); + previous = seq; + match event.event.as_str() { + methods::EVENT_PROCESS_OUTPUT => { + let output: ProcessOutputEvent = event.decode().unwrap(); + stdout.extend(methods::decode_bytes(&output.data).unwrap()); + } + methods::EVENT_PROCESS_EXIT => break, + _ => {} + } + } + let expected: String = (1..=lines).map(|line| format!("{line}\n")).collect(); + assert!( + stdout == expected.as_bytes(), + "output has {} of {} bytes", + stdout.len(), + expected.len() + ); + drop(subscription); + session.shutdown(STEP_TIMEOUT); + assert!(daemon.wait_until_stopped()); +} + +// A session nobody returns to must not keep processes running forever. +#[test] +fn idle_sessions_end_after_their_timeout() { + let daemon = Daemon::new("idle"); + let (session, _events) = daemon.connect(Some(Duration::from_secs(1))); + let pid = session + .spawn(SpawnSpec { + argv: argv(&["sleep", "60"]), + ..SpawnSpec::default() + }) + .unwrap() + .pid; + drop(session); + assert!( + daemon.wait_until_stopped(), + "the idle daemon did not stop\n{}", + daemon.log() + ); + assert!(wait_until_gone(pid), "process {pid} outlived its session"); +} + +// Two proxies racing to start the daemon end up in one daemon. A session +// has one client at a time: the later connection replaces the earlier one, +// whose handshake may then fail, which is the documented outcome. +#[test] +fn concurrent_connections_share_one_daemon() { + let daemon = Daemon::new("race"); + let attempt = || { + lithe_remote_client::RemoteSession::connect( + &lithe_remote_client::TransportChain::local(), + &daemon.command(), + &support::options(), + ) + }; + let results = std::thread::scope(|scope| { + let first = scope.spawn(attempt); + let second = scope.spawn(attempt); + [first.join().unwrap(), second.join().unwrap()] + }); + let survivor = results + .into_iter() + .filter_map(Result::ok) + .map(|(session, _events)| session) + .find(|session| session.ping().is_ok()) + .unwrap_or_else(|| panic!("no connection survived\n{}", daemon.log())); + let process = survivor + .spawn(SpawnSpec { + argv: argv(&["sh", "-c", "printf shared"]), + ..SpawnSpec::default() + }) + .unwrap(); + let output = process.wait(STEP_TIMEOUT).unwrap(); + assert_eq!(output.stdout, b"shared"); + drop(process); + // Exactly one daemon owns the key; the other exited at the lock. + let run = daemon.state.path().join("run"); + let locks = std::fs::read_dir(run) + .unwrap() + .flatten() + .filter(|entry| entry.file_name().to_string_lossy().ends_with(".lock")) + .count(); + assert_eq!(locks, 1); + assert!(daemon.pid().is_some()); + survivor.shutdown(STEP_TIMEOUT); + assert!(daemon.wait_until_stopped()); +} + +#[test] +fn subscribed_events_bypass_the_general_channel() { + let daemon = Daemon::new("general"); + let (session, events) = daemon.connect(None); + let process = session + .spawn(SpawnSpec { + argv: argv(&["sh", "-c", "printf routed"]), + ..SpawnSpec::default() + }) + .unwrap(); + // The handle's subscription receives the events, not the general channel. + match process.next_event(STEP_TIMEOUT).unwrap() { + Some(ProcessEvent::Output(_, bytes)) => assert_eq!(bytes, b"routed"), + other => panic!("unexpected {other:?}"), + } + assert!(events.try_recv().is_err()); + drop(process); + session.shutdown(STEP_TIMEOUT); + assert!(daemon.wait_until_stopped()); +} + +// Regression: the daemon kept a copy of each connection's socket, so the +// proxy never saw end-of-file and stayed on the host after every disconnect. +#[test] +fn the_proxy_exits_by_itself_when_its_client_disconnects() { + let daemon = Daemon::new("proxy-exit"); + // Start the daemon through an ordinary session first. + let (session, _events) = daemon.connect(None); + drop(session); + + let command = daemon.command(); + let mut proxy = std::process::Command::new(&command[0]) + .args(&command[1..]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::null()) + .spawn() + .unwrap(); + // Closing standard input is what a dropped SSH connection does. + drop(proxy.stdin.take()); + let deadline = Instant::now() + STEP_TIMEOUT; + let status = loop { + if let Some(status) = proxy.try_wait().unwrap() { + break status; + } + if Instant::now() >= deadline { + let _ = proxy.kill(); + let _ = proxy.wait(); + panic!("the proxy kept running after its input closed"); + } + // test-stability: allow(rust-real-sleep) reason: the proxy is a child process whose exit is polled with try_wait under a monotonic deadline; std offers no timed wait. + std::thread::sleep(Duration::from_millis(20)); + }; + assert!(status.success(), "{status}"); + let (session, _events) = daemon.connect(None); + session.shutdown(STEP_TIMEOUT); + assert!(daemon.wait_until_stopped()); +} + +// Regression: a daemon ending its session (here waiting out a process that +// ignores SIGTERM) removed its socket but still held the lock, so a client +// reconnecting in that window got a daemon that exited at once and a +// connection failure. +#[test] +fn reconnecting_while_the_previous_daemon_shuts_down_starts_a_fresh_session() { + let daemon = Daemon::new("handover"); + let (session, _events) = daemon.connect(None); + let stubborn = session + .spawn(SpawnSpec { + argv: argv(&["sh", "-c", "trap '' TERM; while :; do sleep 0.1; done"]), + ..SpawnSpec::default() + }) + .unwrap(); + let stubborn_pid = stubborn.pid; + drop(stubborn); + session.shutdown(STEP_TIMEOUT); + + let (session, _events) = daemon.connect(None); + assert!(!session.hello().session.resumed, "reached the old session"); + assert!( + wait_until_gone(stubborn_pid), + "the old session's process survived" + ); + session.shutdown(STEP_TIMEOUT); + assert!(daemon.wait_until_stopped()); +} + +// Regression: a session kept after its leader exited was forgotten only on +// the next spawn or kill. Until then its id could be reused by an unrelated +// process, which a later kill or the final cleanup would have signalled. +#[test] +fn emptied_sessions_are_forgotten_without_further_requests() { + let daemon = Daemon::new("reaper"); + let (session, _events) = daemon.connect(None); + let process = session + .spawn(SpawnSpec { + argv: argv(&["sh", "-c", "sleep 0.3 >/dev/null 2>&1 & echo $!"]), + ..SpawnSpec::default() + }) + .unwrap(); + let output = process.wait(STEP_TIMEOUT).unwrap(); + let member: u32 = String::from_utf8(output.stdout) + .unwrap() + .trim() + .parse() + .unwrap(); + let process_id = process.id; + drop(process); + assert!(wait_until_gone(member)); + // `server.state` only reads; nothing else would prune the entry. + let deadline = Instant::now() + STEP_TIMEOUT; + while session + .state() + .unwrap() + .processes + .iter() + .any(|running| running.process_id == process_id) + { + assert!( + Instant::now() < deadline, + "the emptied session was never forgotten" + ); + // test-stability: allow(rust-real-sleep) reason: the reaper runs on its own interval inside another process; the poll observes it through server.state under a monotonic deadline. + std::thread::sleep(Duration::from_millis(50)); + } + session.shutdown(STEP_TIMEOUT); + assert!(daemon.wait_until_stopped()); +} diff --git a/rust/lithe-remote-server/tests/processes.rs b/rust/lithe-remote-server/tests/processes.rs new file mode 100644 index 0000000000..6a2f323c1b --- /dev/null +++ b/rust/lithe-remote-server/tests/processes.rs @@ -0,0 +1,225 @@ +//! Process execution and the login environment. + +mod support; + +use lithe_remote_client::api::ProcessEvent; +use lithe_remote_client::session::ClientError; +use lithe_remote_client::SpawnSpec; +use lithe_remote_protocol::methods::{ + self, EnvironmentSource, OutputStream, ResolvedEnvironmentSource, +}; +use lithe_remote_protocol::ErrorCode; +use serde_json::json; +use std::collections::BTreeMap; +use support::{connect, connect_with_environment, wait_until_gone, TempDir, STEP_TIMEOUT}; + +fn argv(values: &[&str]) -> Vec { + values.iter().map(|value| value.to_string()).collect() +} + +#[test] +fn process_output_precedes_its_exit_event() { + let (session, _events) = connect(); + let process = session + .spawn(SpawnSpec { + argv: argv(&["sh", "-c", "printf out; printf err >&2; exit 3"]), + env: BTreeMap::from([("LITHE_TEST".into(), "1".into())]), + ..SpawnSpec::default() + }) + .unwrap(); + let output = process.wait(STEP_TIMEOUT).unwrap(); + assert_eq!(output.stdout, b"out"); + assert_eq!(output.stderr, b"err"); + assert_eq!(output.exit.unwrap().exit_code, Some(3)); + drop(process); + session.shutdown(STEP_TIMEOUT); +} + +#[test] +fn standard_input_is_forwarded_and_closed() { + let (session, _events) = connect(); + let process = session + .spawn(SpawnSpec { + argv: argv(&["cat"]), + pipe_stdin: true, + ..SpawnSpec::default() + }) + .unwrap(); + process.write_stdin(b"echoed").unwrap(); + process.close_stdin().unwrap(); + let output = process.wait(STEP_TIMEOUT).unwrap(); + assert_eq!(output.stdout, b"echoed"); + assert_eq!(output.exit.unwrap().exit_code, Some(0)); + drop(process); + session.shutdown(STEP_TIMEOUT); +} + +// Build tools start worker processes; killing only the leader would leave +// them running on the remote host. +#[test] +fn kill_reaches_the_whole_process_group() { + let (session, _events) = connect(); + let process = session + .spawn(SpawnSpec { + argv: argv(&["sh", "-c", "sleep 60 & echo $!; wait"]), + ..SpawnSpec::default() + }) + .unwrap(); + let mut line = Vec::new(); + while !line.contains(&b'\n') { + match process.next_event(STEP_TIMEOUT).unwrap() { + Some(ProcessEvent::Output(OutputStream::Stdout, bytes)) => line.extend(bytes), + other => panic!("unexpected event {other:?}"), + } + } + let worker: u32 = String::from_utf8(line).unwrap().trim().parse().unwrap(); + process.kill(false).unwrap(); + let exit = process.wait(STEP_TIMEOUT).unwrap().exit.unwrap(); + assert_eq!(exit.signal, Some(libc::SIGTERM)); + assert!( + wait_until_gone(worker), + "worker {worker} survived the group kill" + ); + match process.kill(false) { + Err(ClientError::Remote(error)) => assert_eq!(error.code, ErrorCode::UnknownResource), + other => panic!("expected UNKNOWN_RESOURCE, got {other:?}"), + } + drop(process); + session.shutdown(STEP_TIMEOUT); +} + +#[test] +fn a_live_resource_id_cannot_be_reused() { + let (session, _events) = connect(); + let process = session + .spawn(SpawnSpec { + argv: argv(&["sleep", "60"]), + ..SpawnSpec::default() + }) + .unwrap(); + let duplicate = session.request_value( + methods::PROCESS_SPAWN, + json!({ "processId": process.id, "argv": ["true"] }), + STEP_TIMEOUT, + ); + match duplicate { + Err(ClientError::Remote(error)) => assert_eq!(error.code, ErrorCode::ResourceIdInUse), + other => panic!("expected RESOURCE_ID_IN_USE, got {other:?}"), + } + let state = session.state().unwrap(); + assert_eq!(state.processes.len(), 1); + assert_eq!(state.processes[0].argv, ["sleep", "60"]); + process.kill(true).unwrap(); + process.wait(STEP_TIMEOUT).unwrap(); + drop(process); + session.shutdown(STEP_TIMEOUT); +} + +#[test] +fn disconnecting_a_stdio_session_terminates_its_processes() { + let (session, events) = connect(); + let pid = session + .spawn(SpawnSpec { + argv: argv(&["sleep", "60"]), + ..SpawnSpec::default() + }) + .unwrap() + .pid; + drop(events); + // Dropping without shutdown is what a lost network connection looks like. + drop(session); + assert!(wait_until_gone(pid), "process {pid} outlived the session"); +} + +/// Starts a session whose leader exits right away while a member keeps +/// running, as a build tool that leaves a daemon behind does. Returns the +/// handle and the surviving member's pid after the leader's exit event. +fn spawn_session_that_outlives_its_leader( + session: &lithe_remote_client::RemoteSession, +) -> (lithe_remote_client::api::ProcessHandle<'_>, u32) { + let process = session + .spawn(SpawnSpec { + argv: argv(&["sh", "-c", "sleep 60 >/dev/null 2>&1 & echo $!"]), + ..SpawnSpec::default() + }) + .unwrap(); + let output = process.wait(STEP_TIMEOUT).unwrap(); + assert_eq!(output.exit.unwrap().exit_code, Some(0)); + let member = String::from_utf8(output.stdout) + .unwrap() + .trim() + .parse() + .unwrap(); + (process, member) +} + +#[test] +fn kill_reaches_members_that_outlive_the_leader() { + let (session, _events) = connect(); + let (process, member) = spawn_session_that_outlives_its_leader(&session); + let state = session.state().unwrap(); + assert!(state + .processes + .iter() + .any(|running| running.process_id == process.id && running.leader_exited)); + process.kill(true).unwrap(); + assert!(wait_until_gone(member), "member {member} survived the kill"); + drop(process); + session.shutdown(STEP_TIMEOUT); +} + +#[test] +fn disconnecting_terminates_members_that_outlive_the_leader() { + let (session, events) = connect(); + let (process, member) = spawn_session_that_outlives_its_leader(&session); + drop(process); + drop(events); + drop(session); + assert!( + wait_until_gone(member), + "member {member} outlived the session" + ); +} + +// Tools that version managers add to PATH in profile files must be found by +// builds started from the IDE, not only in an interactive terminal. The +// profile and shell are fixed here so the test does not run the developer's +// own shell configuration. +#[test] +fn login_environment_includes_what_profiles_export() { + let home = TempDir::new("profile"); + std::fs::write( + home.path().join(".profile"), + "echo 'profile banner'\nexport LITHE_PROFILE_MARK=from-profile\n", + ) + .unwrap(); + let (session, _events) = + connect_with_environment(&[("HOME", home.str()), ("SHELL", "/bin/sh"), ("ENV", "")]); + + let resolved = session.resolve_environment(false).unwrap(); + assert_ne!( + resolved.source, + ResolvedEnvironmentSource::Server, + "{:?}", + resolved.diagnostics + ); + assert_eq!( + resolved + .environment + .get("LITHE_PROFILE_MARK") + .map(String::as_str), + Some("from-profile") + ); + assert!(!resolved.environment.contains_key("SHLVL")); + + let process = session + .spawn(SpawnSpec { + argv: argv(&["sh", "-c", "printf %s \"$LITHE_PROFILE_MARK\""]), + environment: EnvironmentSource::Login, + ..SpawnSpec::default() + }) + .unwrap(); + assert_eq!(process.wait(STEP_TIMEOUT).unwrap().stdout, b"from-profile"); + drop(process); + session.shutdown(STEP_TIMEOUT); +} diff --git a/rust/lithe-remote-server/tests/server.rs b/rust/lithe-remote-server/tests/server.rs new file mode 100644 index 0000000000..a575632166 --- /dev/null +++ b/rust/lithe-remote-server/tests/server.rs @@ -0,0 +1,649 @@ +//! Handshake, filesystem and bootstrap behavior of the server binary over the +//! local transport. + +mod support; + +use lithe_remote_client::bootstrap::Bootstrapper; +use lithe_remote_client::session::ClientError; +use lithe_remote_client::{ + InstallLocation, RemoteSession, ServerArtifact, TransportChain, WriteOptions, +}; +use lithe_remote_protocol::frame::{read_frame, skip_preamble, write_frame}; +use lithe_remote_protocol::methods::{ + self, encode_bytes, FileKind, FileVersion, OperatingSystem, OrphanProtection, WatchBackend, +}; +use lithe_remote_protocol::{ErrorCode, Message}; +use serde_json::json; +use std::io::BufReader; +use std::process::{Command, Stdio}; +use support::{connect, connect_command, options, TempDir, SERVER, STEP_TIMEOUT}; + +fn code(result: Result) -> ErrorCode { + match result { + Err(ClientError::Remote(error)) => error.code, + other => panic!("expected a remote error, got {other:?}"), + } +} + +#[test] +fn handshake_reports_the_host_as_capabilities() { + let (session, _events) = connect(); + let hello = session.hello(); + let host = &hello.host; + if cfg!(target_os = "macos") { + assert_eq!(host.os, OperatingSystem::Macos); + assert_eq!(host.watch.backend, WatchBackend::Fsevents); + assert_eq!( + host.process.orphan_protection, + OrphanProtection::ServerShutdown + ); + } else { + assert_eq!(host.os, OperatingSystem::Linux); + assert_eq!(host.watch.backend, WatchBackend::Inotify); + assert_eq!( + host.process.orphan_protection, + OrphanProtection::ParentDeathSignal + ); + } + assert_eq!(host.arch, std::env::consts::ARCH); + assert_eq!(host.path_separator, "/"); + assert!(host + .home_directory + .as_deref() + .is_some_and(|home| home.starts_with('/'))); + let mut sorted = host.features.clone(); + sorted.sort(); + assert_eq!(host.features, sorted); + // A `--stdio` session ends with its connection. + assert!(!hello.session.persistent); + assert!(!hello.session.resumed); + assert!(session.ping().is_ok()); + session.shutdown(STEP_TIMEOUT); +} + +// The handshake gate and protocol-version check are observed on the raw +// stream, because the client library always says hello first. +#[test] +fn requests_before_hello_and_foreign_protocol_versions_are_rejected() { + let mut child = Command::new(SERVER) + .arg("--stdio") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + let mut input = child.stdin.take().unwrap(); + let mut output = BufReader::new(child.stdout.take().unwrap()); + skip_preamble(&mut output, 0).unwrap(); + let mut exchange = |message: Message| -> Message { + write_frame(&mut input, &serde_json::to_vec(&message).unwrap()).unwrap(); + serde_json::from_slice(&read_frame(&mut output).unwrap().unwrap()).unwrap() + }; + + let early = exchange(Message::Request { + id: 1, + method: methods::FS_STAT.into(), + params: json!({ "path": "/" }), + }); + assert!( + matches!(early, Message::Response { error: Some(ref error), .. } if error.code == ErrorCode::InvalidRequest) + ); + let mismatch = exchange(Message::Request { + id: 2, + method: methods::SERVER_HELLO.into(), + params: json!({ "protocolVersion": 999, "clientName": "test" }), + }); + assert!( + matches!(mismatch, Message::Response { error: Some(ref error), .. } if error.code == ErrorCode::ProtocolMismatch) + ); + + drop(input); + assert!(child.wait().unwrap().success()); +} + +#[test] +fn files_are_written_atomically_and_guarded_against_concurrent_changes() { + let directory = TempDir::new("files"); + let (session, _events) = connect(); + let file = directory.join("nested/a.txt"); + let create = WriteOptions { + create_parents: true, + ..WriteOptions::default() + }; + + let created = session.write_file(&file, b"one", create).unwrap(); + assert_eq!((created.kind, created.size), (FileKind::File, 3)); + // Without a version guard an existing file is never replaced silently. + assert_eq!( + code(session.write_file(&file, b"x", WriteOptions::default())), + ErrorCode::AlreadyExists + ); + let guarded = WriteOptions { + expected: Some(FileVersion { + size: created.size, + modified_ms: created.modified_ms, + }), + ..WriteOptions::default() + }; + session + .write_file(&file, b"second", guarded.clone()) + .unwrap(); + // The first version is now stale: another editor's change must not be lost. + assert_eq!( + code(session.write_file(&file, b"third", guarded)), + ErrorCode::Conflict + ); + + let (content, metadata) = session.read_file(&file).unwrap(); + assert_eq!(content, b"second"); + assert_eq!(metadata.size, 6); + // No temporary files are left next to the target. + let names: Vec<_> = session + .read_dir(&directory.join("nested")) + .unwrap() + .entries + .into_iter() + .map(|entry| entry.name) + .collect(); + assert_eq!(names, ["a.txt"]); + session.shutdown(STEP_TIMEOUT); +} + +// Two creations of one path both pass the existence check before either +// commits. The commit must refuse to replace, or the first file is lost +// while both requests report success. +#[test] +fn concurrent_creations_of_one_path_keep_exactly_one_file() { + let directory = TempDir::new("create-race"); + let (session, _events) = connect(); + for round in 0..20 { + let file = directory.join(&format!("{round}.txt")); + let results: Vec<_> = std::thread::scope(|scope| { + let writers: Vec<_> = [b"left".as_slice(), b"right".as_slice()] + .into_iter() + .map(|data| { + let (session, file) = (&session, &file); + scope.spawn(move || { + ( + data, + session.write_file(file, data, WriteOptions::default()), + ) + }) + }) + .collect(); + writers + .into_iter() + .map(|writer| writer.join().unwrap()) + .collect() + }); + let winners: Vec<&[u8]> = results + .iter() + .filter(|(_, result)| result.is_ok()) + .map(|(data, _)| *data) + .collect(); + assert_eq!(winners.len(), 1, "round {round}: {results:?}"); + for (_, result) in results.into_iter().filter(|(_, result)| result.is_err()) { + assert_eq!(code(result), ErrorCode::AlreadyExists); + } + assert_eq!(std::fs::read(&file).unwrap(), winners[0]); + } + session.shutdown(STEP_TIMEOUT); +} + +// A client that disconnects right after sending a write must not leave the +// write's temporary file behind: the server lets running requests finish +// before it exits. +#[test] +fn a_write_in_flight_at_disconnect_completes_without_leftovers() { + let directory = TempDir::new("drain"); + let file = directory.join("large.bin"); + let data = vec![7_u8; 8 << 20]; + let mut child = Command::new(SERVER) + .arg("--stdio") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + let mut input = child.stdin.take().unwrap(); + let mut output = BufReader::new(child.stdout.take().unwrap()); + skip_preamble(&mut output, 0).unwrap(); + let mut send = |id, method: &str, params| { + let message = Message::Request { + id, + method: method.into(), + params, + }; + write_frame(&mut input, &serde_json::to_vec(&message).unwrap()).unwrap(); + }; + send( + 1, + methods::SERVER_HELLO, + json!({ "protocolVersion": lithe_remote_protocol::PROTOCOL_VERSION, "clientName": "test" }), + ); + send( + 2, + methods::FS_WRITE_FILE, + json!({ "path": file, "data": encode_bytes(&data) }), + ); + drop(input); + // Responses are drained so the server never blocks on a full pipe. + while read_frame(&mut output).ok().flatten().is_some() {} + assert!(child.wait().unwrap().success()); + + assert_eq!(std::fs::read(&file).unwrap(), data); + let names: Vec<_> = std::fs::read_dir(directory.path()) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + assert_eq!(names, ["large.bin"]); +} + +/// Reader that blocks while its gate is closed, standing in for a server +/// whose input stopped being consumed. +struct GatedReader { + inner: std::io::PipeReader, + gate: std::sync::Arc, +} + +#[derive(Default)] +struct Gate { + closed: std::sync::Mutex, + changed: std::sync::Condvar, +} + +impl Gate { + fn set_closed(&self, closed: bool) { + *self.closed.lock().unwrap() = closed; + self.changed.notify_all(); + } +} + +impl std::io::Read for GatedReader { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + let mut closed = self.gate.closed.lock().unwrap(); + while *closed { + closed = self.gate.changed.wait(closed).unwrap(); + } + drop(closed); + self.inner.read(buffer) + } +} + +/// Opens the gate on drop so the server thread can finish after a failure. +struct OpenOnDrop(std::sync::Arc); + +impl Drop for OpenOnDrop { + fn drop(&mut self) { + self.0.set_closed(false); + } +} + +// A request whose frame cannot be written because the peer stopped reading +// must still end at its timeout, and must not hold up later requests. +#[test] +fn a_peer_that_stops_reading_cannot_block_requests_past_their_timeout() { + let (server_input, client_output) = std::io::pipe().unwrap(); + let (client_input, server_output) = std::io::pipe().unwrap(); + let gate = std::sync::Arc::new(Gate::default()); + let _open = OpenOnDrop(std::sync::Arc::clone(&gate)); + let (served, server_done) = std::sync::mpsc::channel(); + { + let reader = GatedReader { + inner: server_input, + gate: std::sync::Arc::clone(&gate), + }; + std::thread::spawn(move || { + let _ = served.send(lithe_remote_server::serve_stdio(reader, server_output)); + }); + } + let (session, _events) = + RemoteSession::from_streams(client_input, client_output, &options()).unwrap(); + let session = std::sync::Arc::new(session); + let directory = TempDir::new("stalled"); + + gate.set_closed(true); + // The requests run on a helper thread so that a regression fails at the + // test's own deadline instead of hanging the suite. + let (finished, stalled) = std::sync::mpsc::channel(); + { + let session = std::sync::Arc::clone(&session); + // Far larger than a pipe buffer, so writing it blocks. + let large = + json!({ "path": directory.join("large"), "data": encode_bytes(&vec![0; 1 << 20]) }); + std::thread::spawn(move || { + let short = std::time::Duration::from_millis(200); + let first = session.request_value(methods::FS_WRITE_FILE, large, short); + let second = session.request_value(methods::FS_STAT, json!({ "path": "/" }), short); + // Released before reporting, so the test can take the session back. + drop(session); + let _ = finished.send((first, second)); + }); + } + let (first, second) = stalled + .recv_timeout(STEP_TIMEOUT) + .expect("requests blocked past their timeout"); + assert!(matches!(first, Err(ClientError::Timeout)), "{first:?}"); + assert!(matches!(second, Err(ClientError::Timeout)), "{second:?}"); + + gate.set_closed(false); + let session = std::sync::Arc::try_unwrap(session) + .ok() + .expect("the helper thread released the session"); + session.stat("/").unwrap(); + session.shutdown(STEP_TIMEOUT); + server_done + .recv_timeout(STEP_TIMEOUT) + .expect("server did not stop after shutdown") + .unwrap(); +} + +// Files larger than one transfer are read in chunks and reassembled. +#[test] +fn large_files_are_read_in_chunks() { + let directory = TempDir::new("large"); + let path = directory.path().join("big.bin"); + let content: Vec = (0..(9 * 1024 * 1024)) + .map(|index| (index % 251) as u8) + .collect(); + std::fs::write(&path, &content).unwrap(); + let (session, _events) = connect(); + let (read, metadata) = session.read_file(path.to_str().unwrap()).unwrap(); + assert_eq!(metadata.size, content.len() as u64); + assert!(read == content, "chunked read differs from the file"); + session.shutdown(STEP_TIMEOUT); +} + +#[test] +fn writing_through_a_symbolic_link_keeps_the_link() { + let directory = TempDir::new("links"); + std::fs::write(directory.path().join("target.txt"), b"old").unwrap(); + std::os::unix::fs::symlink("target.txt", directory.path().join("link.txt")).unwrap(); + let (session, _events) = connect(); + + let overwrite = WriteOptions { + overwrite: true, + ..WriteOptions::default() + }; + session + .write_file(&directory.join("link.txt"), b"new", overwrite) + .unwrap(); + let link = session.stat(&directory.join("link.txt")).unwrap(); + assert_eq!(link.kind, FileKind::Symlink); + assert_eq!(link.symlink_target.as_deref(), Some("target.txt")); + assert_eq!(link.symlink_target_kind, Some(FileKind::File)); + assert_eq!( + std::fs::read(directory.path().join("target.txt")).unwrap(), + b"new" + ); + assert_eq!( + session.real_path(&directory.join("link.txt")).unwrap(), + directory.join("target.txt") + ); + session.shutdown(STEP_TIMEOUT); +} + +#[test] +fn directory_operations_report_stable_error_codes() { + let directory = TempDir::new("dirs"); + let (session, _events) = connect(); + let tree = directory.join("tree"); + session.create_dir(&format!("{tree}/b/c"), true).unwrap(); + std::fs::write(format!("{tree}/a.txt"), b"a").unwrap(); + + let names: Vec<_> = session + .read_dir(&tree) + .unwrap() + .entries + .into_iter() + .map(|entry| entry.name) + .collect(); + assert_eq!(names, ["a.txt", "b"]); + assert_eq!( + code(session.rename(&format!("{tree}/b"), &format!("{tree}/a.txt"), false)), + ErrorCode::AlreadyExists + ); + session + .rename(&format!("{tree}/b"), &format!("{tree}/d"), false) + .unwrap(); + assert_eq!( + code(session.remove(&format!("{tree}/d"), false)), + ErrorCode::DirectoryNotEmpty + ); + session.remove(&format!("{tree}/d"), true).unwrap(); + assert_eq!( + code(session.stat(&format!("{tree}/d"))), + ErrorCode::NotFound + ); + assert_eq!(code(session.stat("relative/path")), ErrorCode::InvalidPath); + assert!(session.volume_info(&tree).unwrap().case_sensitive.is_some()); + session.shutdown(STEP_TIMEOUT); +} + +// Pasting a folder in the project tree copies it recursively; links inside +// must stay links rather than duplicating what they point to. +#[test] +fn copies_keep_links_and_permissions_and_refuse_to_nest() { + use std::os::unix::fs::PermissionsExt; + let directory = TempDir::new("copy"); + let source = directory.path().join("source"); + std::fs::create_dir_all(source.join("bin")).unwrap(); + std::fs::write(source.join("bin/run.sh"), b"#!/bin/sh\n").unwrap(); + std::fs::set_permissions( + source.join("bin/run.sh"), + std::fs::Permissions::from_mode(0o750), + ) + .unwrap(); + std::os::unix::fs::symlink("bin/run.sh", source.join("start")).unwrap(); + let (session, _events) = connect(); + + session + .copy( + &directory.join("source"), + &directory.join("copy"), + true, + false, + ) + .unwrap(); + let script = session.stat(&directory.join("copy/bin/run.sh")).unwrap(); + assert_eq!(script.mode, 0o750); + let link = session.stat(&directory.join("copy/start")).unwrap(); + assert_eq!(link.kind, FileKind::Symlink); + assert_eq!(link.symlink_target.as_deref(), Some("bin/run.sh")); + + assert_eq!( + code(session.copy( + &directory.join("source"), + &directory.join("source/inner"), + true, + false + )), + ErrorCode::InvalidRequest + ); + assert_eq!( + code(session.copy( + &directory.join("source"), + &directory.join("copy"), + true, + false + )), + ErrorCode::AlreadyExists + ); + session + .set_permissions(&directory.join("copy/bin/run.sh"), 0o700) + .unwrap(); + assert_eq!( + session + .stat(&directory.join("copy/bin/run.sh")) + .unwrap() + .mode, + 0o700 + ); + session.shutdown(STEP_TIMEOUT); +} + +// Regression: overwriting truncates the target before the source is read, +// so copying a file onto itself, through a link or a hard link, erased it. +#[test] +fn copying_a_file_onto_itself_is_refused_and_keeps_the_file() { + let directory = TempDir::new("self-copy"); + let file = directory.path().join("a.txt"); + std::fs::write(&file, b"precious").unwrap(); + std::os::unix::fs::symlink("a.txt", directory.path().join("link")).unwrap(); + std::fs::hard_link(&file, directory.path().join("hard")).unwrap(); + let (session, _events) = connect(); + for (from, to) in [ + ("a.txt", "a.txt"), + ("a.txt", "link"), + ("link", "a.txt"), + ("a.txt", "hard"), + ("hard", "a.txt"), + ] { + assert_eq!( + code(session.copy(&directory.join(from), &directory.join(to), false, true)), + ErrorCode::InvalidRequest, + "{from} -> {to}" + ); + assert_eq!(std::fs::read(&file).unwrap(), b"precious", "{from} -> {to}"); + } + // Two links to one file may still replace each other. + std::os::unix::fs::symlink("a.txt", directory.path().join("other")).unwrap(); + session + .copy( + &directory.join("link"), + &directory.join("other"), + false, + true, + ) + .unwrap(); + assert_eq!(std::fs::read(&file).unwrap(), b"precious"); + session.shutdown(STEP_TIMEOUT); +} + +// Regression: copying a tree that contains a FIFO blocked forever on opening +// it and kept a worker slot. +#[test] +fn copies_skip_special_files() { + let directory = TempDir::new("fifo-copy"); + let source = directory.path().join("source"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(source.join("data.txt"), b"data").unwrap(); + let fifo = std::ffi::CString::new(source.join("pipe").to_str().unwrap()).unwrap(); + // SAFETY: the path is a NUL-terminated string that outlives the call. + assert_eq!(unsafe { libc::mkfifo(fifo.as_ptr(), 0o600) }, 0); + let (session, _events) = connect(); + + let result = session + .copy( + &directory.join("source"), + &directory.join("copy"), + true, + false, + ) + .unwrap(); + assert_eq!(result.skipped_special_files, 1); + assert_eq!( + std::fs::read(directory.path().join("copy/data.txt")).unwrap(), + b"data" + ); + assert!(!directory.path().join("copy/pipe").exists()); + assert_eq!( + code(session.copy( + &directory.join("source/pipe"), + &directory.join("pipe"), + false, + false + )), + ErrorCode::Unsupported + ); + session.shutdown(STEP_TIMEOUT); +} + +#[test] +fn shell_banners_before_the_server_are_ignored() { + let (session, _events) = connect_command(&[ + "sh".into(), + "-c".into(), + "printf 'Welcome to devbox'; exec \"$0\" --stdio".into(), + SERVER.into(), + ]); + session.stat("/").unwrap(); + session.shutdown(STEP_TIMEOUT); +} + +#[test] +fn a_missing_server_reports_the_transport_output() { + let result = RemoteSession::connect( + &TransportChain::local(), + &[ + "sh".into(), + "-c".into(), + "echo 'lithe-remote-server: not found' >&2; exit 127".into(), + ], + &options(), + ); + match result { + Err(ClientError::Disconnected { diagnostics }) => { + assert!(diagnostics.contains("not found"), "{diagnostics}") + } + Err(other) => panic!("unexpected error {other:?}"), + Ok(_) => panic!("connected to a missing server"), + } +} + +#[test] +fn bootstrap_uploads_once_and_reuses_the_verified_binary() { + let install = TempDir::new("install"); + let mut bootstrapper = Bootstrapper::new(TransportChain::local()); + bootstrapper.location = InstallLocation::Directory(install.str().into()); + let probe = bootstrapper.probe().unwrap(); + let artifact = + ServerArtifact::from_file(probe.target, SERVER.into(), env!("CARGO_PKG_VERSION")).unwrap(); + let artifact_for = |target| (target == artifact.target).then(|| artifact.clone()); + + let first = bootstrapper.ensure_installed(artifact_for).unwrap(); + assert!(first.uploaded); + assert!(first.path.starts_with(install.str())); + let second = bootstrapper.ensure_installed(artifact_for).unwrap(); + assert!(!second.uploaded); + assert_eq!(first.path, second.path); + + let (session, _events) = connect_command(&first.stdio_command()); + session.stat("/").unwrap(); + session.shutdown(STEP_TIMEOUT); +} + +// One connection probes, installs and starts the server, so a host that +// asks for a password asks once; the second launch reuses the install. +#[test] +fn one_connection_installs_once_and_starts_the_server() { + use lithe_remote_client::launch::{launch, launch_command}; + let install = TempDir::new("launch"); + let root = install.path().to_str().unwrap().to_owned(); + for expected_upload in [true, false] { + let mut child = Command::new("sh") + .args(&launch_command(Some(&root), &["--stdio"])[1..]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + let mut input = child.stdin.take().unwrap(); + let mut output = child.stdout.take().unwrap(); + let launched = launch(&mut output, &mut input, |report| { + let target = report.target()?; + Some( + ServerArtifact::from_file(target, SERVER.into(), env!("CARGO_PKG_VERSION")) + .unwrap(), + ) + }) + .unwrap(); + assert_eq!(launched.uploaded, expected_upload); + let options = lithe_remote_client::session::ConnectOptions { + client_name: "test".into(), + handshake_timeout: STEP_TIMEOUT, + ..Default::default() + }; + let (session, _events) = RemoteSession::from_streams(output, input, &options).unwrap(); + session.stat("/").unwrap(); + session.shutdown(STEP_TIMEOUT); + assert!(child.wait().unwrap().success()); + } +} diff --git a/rust/lithe-remote-server/tests/support/mod.rs b/rust/lithe-remote-server/tests/support/mod.rs new file mode 100644 index 0000000000..fc5b86231c --- /dev/null +++ b/rust/lithe-remote-server/tests/support/mod.rs @@ -0,0 +1,212 @@ +//! Shared helpers for end-to-end tests that drive the real server binary +//! through the local transport, exactly as an SSH or container hop would. + +#![allow(dead_code)] + +use lithe_remote_client::session::{ConnectOptions, Event}; +use lithe_remote_client::{RemoteSession, TransportChain}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::mpsc::Receiver; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +pub const SERVER: &str = env!("CARGO_BIN_EXE_lithe-remote-server"); + +/// Deadline for any single request or event wait. Local operations finish in +/// milliseconds; this only bounds a broken test. +pub const STEP_TIMEOUT: Duration = Duration::from_secs(10); + +pub fn options() -> ConnectOptions { + ConnectOptions { + client_name: "lithe-remote-server-tests".into(), + handshake_timeout: STEP_TIMEOUT, + idle_timeout: None, + ..ConnectOptions::default() + } +} + +pub fn connect() -> (RemoteSession, Receiver) { + connect_command(&[SERVER.to_string(), "--stdio".into()]) +} + +pub fn connect_command(command: &[String]) -> (RemoteSession, Receiver) { + RemoteSession::connect(&TransportChain::local(), command, &options()) + .expect("connect to the server") +} + +/// Starts a `--stdio` server with extra environment variables, through +/// `env` so the test process's own environment stays untouched. +pub fn connect_with_environment(variables: &[(&str, &str)]) -> (RemoteSession, Receiver) { + let mut command = vec!["env".to_string()]; + command.extend( + variables + .iter() + .map(|(name, value)| format!("{name}={value}")), + ); + command.extend([SERVER.to_string(), "--stdio".into()]); + connect_command(&command) +} + +/// Polls until no process with `pid` exists. Process exit is observed by the +/// kernel, not through an event we could wait on, so a bounded poll is the +/// only option here. +pub fn wait_until_gone(pid: u32) -> bool { + let deadline = Instant::now() + STEP_TIMEOUT; + while Instant::now() < deadline { + // SAFETY: signal 0 only checks for existence. + let alive = unsafe { libc::kill(pid as libc::pid_t, 0) } == 0; + if !alive { + return true; + } + // test-stability: allow(rust-real-sleep) reason: process exit of an unrelated pid has no waitable handle in this process; the loop has a monotonic deadline. + std::thread::sleep(Duration::from_millis(20)); + } + false +} + +/// Private directory removed on drop, also after a failed assertion. +pub struct TempDir(PathBuf); + +impl TempDir { + pub fn new(label: &str) -> Self { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.subsec_nanos()) + .unwrap_or_default(); + let path = std::env::temp_dir().join(format!( + "lithe-remote-{label}-{}-{}-{nanos}", + std::process::id(), + COUNTER.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&path).unwrap(); + // macOS places the temporary directory behind a symbolic link; + // canonical paths keep comparisons and link tests exact. + Self(std::fs::canonicalize(path).unwrap()) + } + + pub fn path(&self) -> &Path { + &self.0 + } + + pub fn join(&self, name: &str) -> String { + self.0.join(name).to_str().unwrap().to_owned() + } + + pub fn str(&self) -> &str { + self.0.to_str().unwrap() + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// A persistent session's daemon, killed on drop so a failed test never +/// leaves it running. The state directory holds its socket, lock and log. +pub struct Daemon { + pub key: String, + pub state: TempDir, +} + +impl Daemon { + pub fn new(label: &str) -> Self { + static COUNTER: AtomicU64 = AtomicU64::new(0); + Self { + key: format!( + "test-{label}-{}-{}", + std::process::id(), + COUNTER.fetch_add(1, Ordering::Relaxed) + ), + state: TempDir::new(label), + } + } + + pub fn command(&self) -> Vec { + vec![ + SERVER.into(), + "--connect".into(), + self.key.clone(), + "--state-dir".into(), + self.state.str().into(), + ] + } + + pub fn connect(&self, idle_timeout: Option) -> (RemoteSession, Receiver) { + let mut options = options(); + options.idle_timeout = idle_timeout; + self.connect_with(&options) + } + + /// Connects without starting event delivery; the caller subscribes and + /// then calls `resume`, as a reconnecting client does. + pub fn connect_deferred(&self) -> (RemoteSession, Receiver) { + let mut options = options(); + options.resume = lithe_remote_client::ResumeMode::Deferred; + self.connect_with(&options) + } + + pub fn connect_with(&self, options: &ConnectOptions) -> (RemoteSession, Receiver) { + RemoteSession::connect(&TransportChain::local(), &self.command(), options).unwrap_or_else( + |error| panic!("connect to the persistent session: {error}\n{}", self.log()), + ) + } + + fn lock_path(&self) -> Option { + let run = self.state.path().join("run"); + std::fs::read_dir(run) + .ok()? + .flatten() + .find(|entry| entry.file_name().to_string_lossy().ends_with(".lock")) + .map(|entry| entry.path()) + } + + /// Pid of the running daemon. The daemon holds an exclusive lock on its + /// lock file for its whole life, so a lock we can take means it is gone + /// and the recorded pid may already belong to another process. + pub fn pid(&self) -> Option { + use std::os::fd::AsRawFd; + let path = self.lock_path()?; + let file = std::fs::File::open(&path).ok()?; + // SAFETY: flock on a descriptor this test owns. + if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_SH | libc::LOCK_NB) } == 0 { + return None; + } + std::fs::read_to_string(path).ok()?.trim().parse().ok() + } + + /// Waits until the daemon released its lock, which it does when exiting. + pub fn wait_until_stopped(&self) -> bool { + let deadline = Instant::now() + STEP_TIMEOUT; + while Instant::now() < deadline { + if self.pid().is_none() { + return true; + } + // test-stability: allow(rust-real-sleep) reason: the daemon is another process whose exit has no waitable handle here; the loop has a monotonic deadline. + std::thread::sleep(Duration::from_millis(20)); + } + false + } + + pub fn log(&self) -> String { + let log = self.state.path().join("log"); + std::fs::read_dir(log) + .into_iter() + .flatten() + .flatten() + .filter_map(|entry| std::fs::read_to_string(entry.path()).ok()) + .collect() + } +} + +impl Drop for Daemon { + fn drop(&mut self) { + if let Some(pid) = self.pid() { + // SAFETY: sending a signal has no memory-safety preconditions. + unsafe { libc::kill(pid as libc::pid_t, libc::SIGKILL) }; + wait_until_gone(pid); + } + } +} diff --git a/rust/lithe-remote-server/tests/terminals.rs b/rust/lithe-remote-server/tests/terminals.rs new file mode 100644 index 0000000000..64651d2098 --- /dev/null +++ b/rust/lithe-remote-server/tests/terminals.rs @@ -0,0 +1,125 @@ +//! Pseudo-terminal sessions. + +mod support; + +use lithe_remote_client::api::{TerminalEvent, TerminalHandle}; +use lithe_remote_client::TerminalSpec; +use std::collections::BTreeMap; +use std::time::Instant; +use support::{connect, wait_until_gone, STEP_TIMEOUT}; + +/// A plain `sh` with a fixed prompt, so the test does not depend on the +/// developer's login shell and its configuration. `HISTFILE` keeps an +/// interactive bash (`/bin/sh` on macOS) away from the user's real history. +fn plain_shell() -> TerminalSpec { + TerminalSpec { + argv: Some(vec!["/bin/sh".into()]), + cwd: None, + env: BTreeMap::from([ + ("PS1".into(), "$ ".into()), + ("ENV".into(), String::new()), + ("HISTFILE".into(), "/dev/null".into()), + ]), + columns: 100, + rows: 40, + } +} + +/// Reads output until it contains `needle`. +fn read_until(terminal: &TerminalHandle<'_>, needle: &str) -> String { + let deadline = Instant::now() + STEP_TIMEOUT; + let mut output = Vec::new(); + while !String::from_utf8_lossy(&output).contains(needle) { + let remaining = deadline.saturating_duration_since(Instant::now()); + match terminal.next_event(remaining) { + Ok(Some(TerminalEvent::Output(bytes))) => output.extend(bytes), + other => panic!( + "waiting for {needle:?}: {other:?}; output so far: {:?}", + String::from_utf8_lossy(&output) + ), + } + } + String::from_utf8_lossy(&output).into_owned() +} + +fn read_until_exit( + terminal: &TerminalHandle<'_>, +) -> lithe_remote_protocol::methods::TerminalExitEvent { + let deadline = Instant::now() + STEP_TIMEOUT; + loop { + match terminal.next_event(deadline.saturating_duration_since(Instant::now())) { + Ok(Some(TerminalEvent::Output(_))) => {} + Ok(Some(TerminalEvent::Exit(exit))) => return exit, + other => panic!("terminal did not exit: {other:?}"), + } + } +} + +// The command's own output, not the echoed input line, proves execution: +// the input contains `$((6*7))` while only the shell can print `42`. +#[test] +fn commands_run_on_a_controlling_terminal() { + let (session, _events) = connect(); + let terminal = session.open_terminal(plain_shell()).unwrap(); + terminal.write(b"echo result-$((6*7)); tty\n").unwrap(); + let output = read_until(&terminal, "result-42"); + let output = output + &read_until(&terminal, "/dev/"); + assert!(!output.contains("not a tty"), "{output}"); + + terminal.write(b"exit 7\n").unwrap(); + assert_eq!(read_until_exit(&terminal).exit_code, Some(7)); + drop(terminal); + session.shutdown(STEP_TIMEOUT); +} + +#[test] +fn resizing_changes_the_size_programs_see() { + let (session, _events) = connect(); + let terminal = session.open_terminal(plain_shell()).unwrap(); + terminal.write(b"stty size\n").unwrap(); + read_until(&terminal, "40 100"); + terminal.resize(132, 50).unwrap(); + terminal.write(b"stty size\n").unwrap(); + read_until(&terminal, "50 132"); + terminal.close().unwrap(); + read_until_exit(&terminal); + drop(terminal); + session.shutdown(STEP_TIMEOUT); +} + +// Closing a terminal window hangs up the shell and its jobs. +#[test] +fn closing_hangs_up_the_shell_and_its_jobs() { + let (session, _events) = connect(); + let terminal = session.open_terminal(plain_shell()).unwrap(); + // The echoed input reads `job: $! :end`; only the shell's output has a + // number there. The spaces around `$!` keep interactive bash from + // treating `!` as a history expansion. + terminal.write(b"sleep 60 & echo job: $! :end\n").unwrap(); + let deadline = Instant::now() + STEP_TIMEOUT; + let mut output = String::new(); + let job = loop { + let found = output.split("job:").skip(1).find_map(|rest| { + let (pid, _) = rest.split_once(":end")?; + pid.trim().parse::().ok() + }); + if let Some(job) = found { + break job; + } + match terminal.next_event(deadline.saturating_duration_since(Instant::now())) { + Ok(Some(TerminalEvent::Output(bytes))) => { + output.push_str(&String::from_utf8_lossy(&bytes)) + } + other => panic!("no job pid: {other:?} in {output:?}"), + } + }; + terminal.close().unwrap(); + read_until_exit(&terminal); + assert!(wait_until_gone(terminal.pid)); + assert!( + wait_until_gone(job), + "background job {job} survived the hangup" + ); + drop(terminal); + session.shutdown(STEP_TIMEOUT); +} diff --git a/rust/lithe-remote-server/tests/tunnels.rs b/rust/lithe-remote-server/tests/tunnels.rs new file mode 100644 index 0000000000..af9a18c448 --- /dev/null +++ b/rust/lithe-remote-server/tests/tunnels.rs @@ -0,0 +1,245 @@ +//! Tunnels, local port forwarding and listening-port discovery. + +mod support; + +use lithe_remote_client::api::TunnelEvent; +use lithe_remote_client::PortForward; +use lithe_remote_protocol::methods::TunnelTarget; +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::os::unix::net::UnixListener; +use std::sync::Arc; +use std::thread; +use support::{connect, STEP_TIMEOUT}; + +/// A one-connection echo server that uppercases what it reads, so the test +/// can tell the server's answer from its own request. +fn spawn_tcp_echo() -> (u16, thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream.set_read_timeout(Some(STEP_TIMEOUT)).unwrap(); + let mut buffer = [0_u8; 1024]; + loop { + match stream.read(&mut buffer) { + Ok(0) | Err(_) => return, + Ok(read) => { + let upper = buffer[..read].to_ascii_uppercase(); + if stream.write_all(&upper).is_err() { + return; + } + } + } + } + }); + (port, server) +} + +#[test] +fn tunnels_reach_tcp_ports_on_the_remote() { + let (port, server) = spawn_tcp_echo(); + let (session, _events) = connect(); + let tunnel = session + .open_tunnel(TunnelTarget::Tcp { + host: "127.0.0.1".into(), + port, + }) + .unwrap(); + tunnel.write(b"hello").unwrap(); + let mut received = Vec::new(); + while received.len() < 5 { + match tunnel.next_event(STEP_TIMEOUT).unwrap() { + Some(TunnelEvent::Data(bytes)) => received.extend(bytes), + other => panic!("unexpected {other:?}"), + } + } + assert_eq!(received, b"HELLO"); + tunnel.close().unwrap(); + loop { + match tunnel.next_event(STEP_TIMEOUT).unwrap() { + Some(TunnelEvent::Closed(closed)) => { + assert!(closed.error.is_none(), "{closed:?}"); + break; + } + Some(TunnelEvent::Data(_)) => {} + None => panic!("the subscription ended without a closed event"), + } + } + server.join().unwrap(); + drop(tunnel); + session.shutdown(STEP_TIMEOUT); +} + +// Docker and database sockets are Unix sockets on the remote. +/// A private directory directly under `/tmp`, removed on drop. +struct ShortDir(std::path::PathBuf); + +impl ShortDir { + fn new() -> Self { + let path = std::path::PathBuf::from(format!("/tmp/lithe-tunnel-{}", std::process::id())); + std::fs::create_dir_all(&path).unwrap(); + Self(path) + } +} + +impl Drop for ShortDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +#[test] +fn tunnels_reach_unix_sockets_on_the_remote() { + // Socket paths are limited to about 100 bytes, which the temporary + // directory on macOS already nearly exhausts. + let directory = ShortDir::new(); + let path = directory.0.join("service.sock"); + let listener = UnixListener::bind(&path).unwrap(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream.write_all(b"greeting").unwrap(); + }); + let (session, _events) = connect(); + let tunnel = session + .open_tunnel(TunnelTarget::Unix { + path: path.to_str().unwrap().into(), + }) + .unwrap(); + let mut received = Vec::new(); + loop { + match tunnel.next_event(STEP_TIMEOUT).unwrap() { + Some(TunnelEvent::Data(bytes)) => received.extend(bytes), + Some(TunnelEvent::Closed(_)) => break, + None => panic!("the subscription ended without a closed event"), + } + } + assert_eq!(received, b"greeting"); + server.join().unwrap(); + drop(tunnel); + session.shutdown(STEP_TIMEOUT); +} + +#[test] +fn connecting_to_a_closed_port_fails_with_a_stable_code() { + // Binding then dropping a listener yields a port nobody listens on. + let port = TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port(); + let (session, _events) = connect(); + match session.open_tunnel(TunnelTarget::Tcp { + host: "127.0.0.1".into(), + port, + }) { + Err(lithe_remote_client::ClientError::Remote(error)) => { + assert_eq!(error.code, lithe_remote_protocol::ErrorCode::ConnectFailed) + } + Err(other) => panic!("unexpected {other:?}"), + Ok(_) => panic!("connected to a closed port"), + } + session.shutdown(STEP_TIMEOUT); +} + +// The full path a browser takes: local port → tunnel → remote port. +#[test] +fn port_forwards_bridge_local_connections_to_the_remote() { + let (port, server) = spawn_tcp_echo(); + let (session, _events) = connect(); + let session = Arc::new(session); + let forward = PortForward::start( + Arc::clone(&session), + "127.0.0.1:0".parse().unwrap(), + TunnelTarget::Tcp { + host: "127.0.0.1".into(), + port, + }, + ) + .unwrap(); + + let mut local = TcpStream::connect(forward.local_address()).unwrap(); + local.set_read_timeout(Some(STEP_TIMEOUT)).unwrap(); + local.write_all(b"through the tunnel").unwrap(); + let mut answer = vec![0_u8; b"through the tunnel".len()]; + local.read_exact(&mut answer).unwrap(); + assert_eq!(answer, b"THROUGH THE TUNNEL"); + drop(local); + server.join().unwrap(); + drop(forward); + // Bridge threads finish asynchronously once the remote side reports the + // tunnel closed, and may still hold the session; shut it down through + // the shared handle instead of waiting for sole ownership. + session + .request_value( + lithe_remote_protocol::methods::SERVER_SHUTDOWN, + serde_json::Value::Null, + STEP_TIMEOUT, + ) + .unwrap(); +} + +// Regression: a local client that half-closes after its request, as +// HTTP/1.0 clients and `nc -N` do, had the tunnel aborted. Queued data was +// lost and the reply never arrived. +#[test] +fn half_closed_requests_still_get_their_reply() { + // The server answers only after reading the whole request to its end. + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream.set_read_timeout(Some(STEP_TIMEOUT)).unwrap(); + let mut request = Vec::new(); + stream.read_to_end(&mut request).unwrap(); + stream + .write_all(format!("received {} bytes", request.len()).as_bytes()) + .unwrap(); + }); + let (session, _events) = connect(); + let session = Arc::new(session); + let forward = PortForward::start( + Arc::clone(&session), + "127.0.0.1:0".parse().unwrap(), + TunnelTarget::Tcp { + host: "127.0.0.1".into(), + port, + }, + ) + .unwrap(); + + let mut local = TcpStream::connect(forward.local_address()).unwrap(); + local.set_read_timeout(Some(STEP_TIMEOUT)).unwrap(); + // Several chunks, so some are still queued when the half-close arrives. + let request = vec![b'x'; 1 << 20]; + local.write_all(&request).unwrap(); + local.shutdown(std::net::Shutdown::Write).unwrap(); + let mut reply = String::new(); + local.read_to_string(&mut reply).unwrap(); + assert_eq!(reply, format!("received {} bytes", request.len())); + server.join().unwrap(); + drop(forward); + session + .request_value( + lithe_remote_protocol::methods::SERVER_SHUTDOWN, + serde_json::Value::Null, + STEP_TIMEOUT, + ) + .unwrap(); +} + +#[test] +fn listening_ports_include_a_local_listener() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let (session, _events) = connect(); + let ports = session.listening_ports().unwrap(); + assert!( + ports + .iter() + .any(|listening| listening.port == port && listening.address == "127.0.0.1"), + "{port} not in {ports:?}" + ); + drop(listener); + session.shutdown(STEP_TIMEOUT); +} diff --git a/rust/lithe-remote-server/tests/watch_and_walk.rs b/rust/lithe-remote-server/tests/watch_and_walk.rs new file mode 100644 index 0000000000..db6cc8995b --- /dev/null +++ b/rust/lithe-remote-server/tests/watch_and_walk.rs @@ -0,0 +1,197 @@ +//! File watching and tree walks. + +mod support; + +use lithe_remote_client::api::{WatchEvent, WatchHandle}; +use lithe_remote_protocol::methods::{ChangeKind, FileKind, WatchErrorCode}; +use std::collections::BTreeMap; +use std::time::{Duration, Instant}; +use support::{connect, TempDir, STEP_TIMEOUT}; + +/// Collects changes until every path in `expected` was reported with its +/// kind. Watch batches are delivered asynchronously, so the wait is bounded +/// by events rather than by time. +fn wait_for_changes( + watch: &WatchHandle<'_>, + expected: &[(&str, ChangeKind)], +) -> BTreeMap { + let deadline = Instant::now() + STEP_TIMEOUT; + let mut seen = BTreeMap::new(); + while !expected + .iter() + .all(|(path, kind)| seen.get(*path) == Some(kind)) + { + let remaining = deadline.saturating_duration_since(Instant::now()); + match watch.next_event(remaining) { + Ok(Some(WatchEvent::Changed(batch))) => { + assert!(!batch.overflow, "unexpected overflow"); + for change in batch.changes { + seen.insert(change.path, change.kind); + } + } + other => panic!("waiting for {expected:?}: {other:?}; seen {seen:?}"), + } + } + seen +} + +/// Asserts that no batch mentioning `path` arrives within `quiet`. +fn assert_not_reported(watch: &WatchHandle<'_>, path: &str, quiet: Duration) { + let deadline = Instant::now() + quiet; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return; + } + match watch.next_event(remaining) { + Ok(Some(WatchEvent::Changed(batch))) => assert!( + batch + .changes + .iter() + .all(|change| !change.path.starts_with(path)), + "{path} was reported: {batch:?}" + ), + Ok(Some(other)) => panic!("unexpected {other:?}"), + Ok(None) => panic!("the watch subscription ended"), + Err(_) => return, + } + } +} + +#[test] +fn watches_report_creation_modification_and_deletion() { + let root = TempDir::new("watch"); + let (session, _events) = connect(); + let watch = session.watch(root.str(), &[]).unwrap(); + + let file = root.join("a.txt"); + std::fs::write(&file, b"one").unwrap(); + wait_for_changes(&watch, &[(&file, ChangeKind::Created)]); + + std::fs::write(&file, b"two").unwrap(); + wait_for_changes(&watch, &[(&file, ChangeKind::Modified)]); + + std::fs::remove_file(&file).unwrap(); + wait_for_changes(&watch, &[(&file, ChangeKind::Deleted)]); + + watch.stop().unwrap(); + drop(watch); + session.shutdown(STEP_TIMEOUT); +} + +// A directory tree created at once, as `git checkout` or `mkdir -p` do, +// must be fully reported, including files written before a per-directory +// backend could register the new directories. +#[test] +fn new_directory_trees_are_reported_completely() { + let root = TempDir::new("watch-tree"); + let (session, _events) = connect(); + let watch = session.watch(root.str(), &[]).unwrap(); + + std::fs::create_dir_all(root.path().join("src/main/java")).unwrap(); + std::fs::write(root.path().join("src/main/java/App.java"), b"class App {}").unwrap(); + let app = root.join("src/main/java/App.java"); + let seen = wait_for_changes(&watch, &[(&app, ChangeKind::Created)]); + assert!(seen.contains_key(&root.join("src")), "{seen:?}"); + + // The new directories are watched as well, not just reported. + std::fs::write(&app, b"class App { }").unwrap(); + wait_for_changes(&watch, &[(&app, ChangeKind::Modified)]); + drop(watch); + session.shutdown(STEP_TIMEOUT); +} + +#[test] +fn excluded_directories_are_not_reported() { + let root = TempDir::new("watch-exclude"); + std::fs::create_dir_all(root.path().join("node_modules/pkg")).unwrap(); + let (session, _events) = connect(); + let watch = session.watch(root.str(), &["node_modules"]).unwrap(); + + std::fs::write(root.path().join("node_modules/pkg/index.js"), b"x").unwrap(); + std::fs::write(root.path().join("visible.txt"), b"x").unwrap(); + wait_for_changes(&watch, &[(&root.join("visible.txt"), ChangeKind::Created)]); + assert_not_reported( + &watch, + &root.join("node_modules"), + Duration::from_millis(400), + ); + drop(watch); + session.shutdown(STEP_TIMEOUT); +} + +#[test] +fn removing_the_watched_root_ends_the_watch() { + let parent = TempDir::new("watch-root"); + let root = parent.path().join("root"); + std::fs::create_dir(&root).unwrap(); + let (session, _events) = connect(); + let watch = session.watch(root.to_str().unwrap(), &[]).unwrap(); + std::fs::remove_dir(&root).unwrap(); + + let deadline = Instant::now() + STEP_TIMEOUT; + loop { + match watch.next_event(deadline.saturating_duration_since(Instant::now())) { + Ok(Some(WatchEvent::Error(error))) => { + assert_eq!(error.code, WatchErrorCode::RootRemoved); + break; + } + Ok(Some(WatchEvent::Changed(_))) => {} + other => panic!("expected the root removal: {other:?}"), + } + } + assert!(session.state().unwrap().watches.is_empty()); + drop(watch); + session.shutdown(STEP_TIMEOUT); +} + +#[test] +fn walks_list_trees_in_order_without_following_links() { + let root = TempDir::new("walk"); + for path in [ + "b/x.txt", + "a/z.txt", + "a/y/deep.txt", + "target/out.class", + "c.txt", + ] { + let path = root.path().join(path); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, b"").unwrap(); + } + std::os::unix::fs::symlink("/", root.path().join("link-to-root")).unwrap(); + let (session, _events) = connect(); + + let outcome = session + .walk(root.str(), &["target"], None, STEP_TIMEOUT) + .unwrap(); + let listed: Vec<(&str, FileKind)> = outcome + .entries + .iter() + .map(|entry| (entry.path.as_str(), entry.kind)) + .collect(); + assert_eq!( + listed, + [ + ("a", FileKind::Directory), + ("a/y", FileKind::Directory), + ("a/y/deep.txt", FileKind::File), + ("a/z.txt", FileKind::File), + ("b", FileKind::Directory), + ("b/x.txt", FileKind::File), + ("c.txt", FileKind::File), + ("link-to-root", FileKind::Symlink), + // Excluded directories are listed but not descended into. + ("target", FileKind::Directory), + ] + ); + assert!(!outcome.done.truncated); + assert_eq!(outcome.done.total, 9); + + let limited = session + .walk(root.str(), &[], Some(3), STEP_TIMEOUT) + .unwrap(); + assert_eq!(limited.entries.len(), 3); + assert!(limited.done.truncated); + session.shutdown(STEP_TIMEOUT); +} diff --git a/scripts/build-database-mcp.sh b/scripts/build-database-mcp.sh deleted file mode 100755 index 0f8874537b..0000000000 --- a/scripts/build-database-mcp.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/bin/zsh - -set -euo pipefail - -ROOT_DIR="${0:A:h:h}" -ARCH="${LITHE_ARCH:-$(uname -m)}" -MANIFEST="$ROOT_DIR/rust/Cargo.toml" -DIST_ROOT="${LITHE_DIST_ROOT:-$ROOT_DIR/dist}" -OUTPUT_DIR="$DIST_ROOT/database-mcp" - -export MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-13.0}" -mkdir -p "$OUTPUT_DIR" - -build_arch() { - local architecture="$1" - local target - case "$architecture" in - arm64) target="aarch64-apple-darwin" ;; - x86_64) target="x86_64-apple-darwin" ;; - *) print -u2 -- "Unsupported database MCP architecture: $architecture"; exit 1 ;; - esac - - cargo build --manifest-path "$MANIFEST" --release --target "$target" -p lithe-db-mcp >&2 - print -r -- "$ROOT_DIR/rust/target/$target/release/lithe-db-mcp" -} - -case "$ARCH" in - arm64|x86_64) - binary=$(build_arch "$ARCH") - output="$OUTPUT_DIR/lithe-db-mcp-$ARCH" - cp "$binary" "$output" - print -r -- "$output" - ;; - universal) - arm_binary=$(build_arch arm64) - intel_binary=$(build_arch x86_64) - output="$OUTPUT_DIR/lithe-db-mcp" - lipo -create "$arm_binary" "$intel_binary" -output "$output" - print -r -- "$output" - ;; - *) - print -u2 -- "Unsupported database MCP architecture: $ARCH" - exit 1 - ;; -esac diff --git a/scripts/build-database-sidecar.sh b/scripts/build-database-sidecar.sh deleted file mode 100755 index 61315c4e11..0000000000 --- a/scripts/build-database-sidecar.sh +++ /dev/null @@ -1,47 +0,0 @@ -#!/bin/zsh - -set -euo pipefail - -ROOT_DIR="${0:A:h:h}" -ARCH="${LITHE_ARCH:-$(uname -m)}" -MANIFEST="$ROOT_DIR/rust/Cargo.toml" -DIST_ROOT="${LITHE_DIST_ROOT:-$ROOT_DIR/dist}" -OUTPUT_DIR="$DIST_ROOT/database-sidecar" - -export MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-13.0}" -mkdir -p "$OUTPUT_DIR" - -build_arch() { - local architecture="$1" - local target - case "$architecture" in - arm64) target="aarch64-apple-darwin" ;; - x86_64) target="x86_64-apple-darwin" ;; - esac - local rust_sysroot - rust_sysroot=$(rustc --print sysroot) - if [[ ! -d "$rust_sysroot/lib/rustlib/$target" ]]; then - print -u2 -- "Rust target $target is not installed. Install it with: rustup target add $target" - return 2 - fi - cargo build --manifest-path "$MANIFEST" --release --target "$target" -p lithe-db-sidecar >&2 - print -r -- "$ROOT_DIR/rust/target/$target/release/lithe-db-sidecar" -} - -case "$ARCH" in - arm64|x86_64) - binary=$(build_arch "$ARCH") - cp "$binary" "$OUTPUT_DIR/lithe-db-sidecar-$ARCH" - print -r -- "$OUTPUT_DIR/lithe-db-sidecar-$ARCH" - ;; - universal) - arm_binary=$(build_arch arm64) - intel_binary=$(build_arch x86_64) - lipo -create "$arm_binary" "$intel_binary" -output "$OUTPUT_DIR/lithe-db-sidecar" - print -r -- "$OUTPUT_DIR/lithe-db-sidecar" - ;; - *) - print -u2 -- "Unsupported database sidecar architecture: $ARCH" - exit 1 - ;; -esac diff --git a/scripts/build-remote-servers.sh b/scripts/build-remote-servers.sh new file mode 100755 index 0000000000..3354329821 --- /dev/null +++ b/scripts/build-remote-servers.sh @@ -0,0 +1,46 @@ +#!/bin/zsh +# Builds the remote workspace servers that the macOS app ships read-only in +# Contents/Resources/RemoteServers and uploads to SSH hosts on first connect. +# +# Only Linux servers are shipped for now: they are static musl ELF files, which +# code signing treats as plain resources. macOS servers are Mach-O executables +# and need Developer ID signing and notarization in the release pipeline first. +# +# Rust's bundled lld links the static binaries on macOS hosts, so no cross +# toolchain is needed. Prints the output directory: /lithe-remote-server +# for each target, plus manifest.json with the server version. +set -euo pipefail + +ROOT_DIR="${0:A:h:h}" +TARGETS=(x86_64-unknown-linux-musl aarch64-unknown-linux-musl) +# Build output stays in this worktree's Cargo target directory; it is rebuilt +# from source and never shared between worktrees. +OUTPUT_DIR="$ROOT_DIR/rust/target/remote-servers" + +installed_targets="$(rustup target list --installed 2>/dev/null || true)" +for target in "${TARGETS[@]}"; do + if ! grep -qx "$target" <<<"$installed_targets"; then + print -u2 -- "build-remote-servers: Rust target $target is missing; run: rustup target add $target" + exit 1 + fi +done + +rm -rf "$OUTPUT_DIR" +mkdir -p "$OUTPUT_DIR" +for target in "${TARGETS[@]}"; do + linker_variable="CARGO_TARGET_$(tr '[:lower:]-' '[:upper:]_' <<<"$target")_LINKER" + # The server is uploaded over slow links, so it is always an optimized build. + env "$linker_variable=rust-lld" \ + cargo build --locked --release --manifest-path "$ROOT_DIR/rust/Cargo.toml" \ + -p lithe-remote-server --target "$target" >&2 + mkdir -p "$OUTPUT_DIR/$target" + cp "$ROOT_DIR/rust/target/$target/release/lithe-remote-server" "$OUTPUT_DIR/$target/lithe-remote-server" +done + +version="$(sed -n 's/^version = "\(.*\)"$/\1/p' "$ROOT_DIR/rust/lithe-remote-server/Cargo.toml" | head -1)" +if [[ -z "$version" ]]; then + print -u2 -- "build-remote-servers: could not read the server version" + exit 1 +fi +printf '{"serverVersion":"%s"}\n' "$version" > "$OUTPUT_DIR/manifest.json" +print -- "$OUTPUT_DIR" diff --git a/scripts/ci-workflow-gates.test.mjs b/scripts/ci-workflow-gates.test.mjs index 08c76f6bf1..18e92cae38 100644 --- a/scripts/ci-workflow-gates.test.mjs +++ b/scripts/ci-workflow-gates.test.mjs @@ -15,7 +15,7 @@ const commonBindings = { const workflows = [ { id: 'macos', - selected: ['swift', 'swift_database', 'rust_core', 'macos_release'], + selected: ['swift', 'rust_core', 'macos_release'], lanes: [ { lane: 'swift', job: 'swift-tests', run: 'run_swift' }, { lane: 'rust', job: 'rust-core-tests', run: 'run_rust' }, @@ -29,7 +29,7 @@ const workflows = [ RUN_SWIFT: 'needs.changes.outputs.run_swift', RUN_RUST: 'needs.changes.outputs.run_rust', RUN_RELEASE: 'needs.changes.outputs.run_release', - SWIFT_SELECTED: "needs.changes.outputs.swift == 'true' || needs.changes.outputs.swift_database == 'true' || needs.changes.outputs.rust_core == 'true'", + SWIFT_SELECTED: "needs.changes.outputs.swift == 'true' || needs.changes.outputs.rust_core == 'true'", RUST_CORE_SELECTED: 'needs.changes.outputs.rust_core', RELEASE_SELECTED: 'needs.changes.outputs.macos_release', }, @@ -69,13 +69,13 @@ const workflows = [ }, }, { - id: 'database', - selected: ['rust_database'], - lanes: [{ lane: 'database', job: 'rust-database-tests' }], + id: 'remote', + selected: ['rust_remote'], + lanes: [{ lane: 'remote', job: 'rust-remote-tests' }], bindings: { CHANGES_RESULT: 'needs.changes.result', - RUST_DATABASE_SELECTED: 'needs.changes.outputs.rust_database', - RUST_DATABASE_RESULT: 'needs.rust-database-tests.result', + RUST_REMOTE_SELECTED: 'needs.changes.outputs.rust_remote', + RUST_REMOTE_RESULT: 'needs.rust-remote-tests.result', }, }, ]; @@ -281,7 +281,8 @@ for (const workflow of workflows) { } } - if (workflow.id === 'database') continue; + // Lanes without a run output cannot be reused from an earlier revision. + if (!workflow.lanes.some((lane) => lane.run)) continue; test(`${workflow.id}: reuse checkout and verifier preserve their conditions, credentials and deadline`, testOptions, () => { const checkout = block(workflow.gate, '- name: Check out the reused-result verifier', 6); const verifier = block(workflow.gate, '- name: Verify reused lane results', 6); diff --git a/scripts/classify-ci-changes.sh b/scripts/classify-ci-changes.sh index 738a6d2c61..d03a2acc1e 100755 --- a/scripts/classify-ci-changes.sh +++ b/scripts/classify-ci-changes.sh @@ -40,9 +40,8 @@ rust_diff_is_comment_only() { swift=false plugins=false -swift_database=false rust_core=false -rust_database=false +rust_remote=false macos_release=false windows=false windows_rust=false @@ -54,9 +53,8 @@ git_validation=false enable_all_validation() { swift=true plugins=true - swift_database=true rust_core=true - rust_database=true + rust_remote=true macos_release=true windows=true windows_rust=true @@ -90,10 +88,7 @@ classify_path() { enable_all_validation ;; .github/workflows/ci-macos.yml) - # The shared Swift build job also hosts the database filtered - # suite, so a workflow change exercises both. swift=true - swift_database=true rust_core=true macos_release=true git_validation=true @@ -101,9 +96,8 @@ classify_path() { .github/workflows/ci-plugins.yml) plugins=true ;; - .github/workflows/ci-database.yml) - swift_database=true - rust_database=true + .github/workflows/ci-remote.yml|scripts/verify-remote-workspace.sh) + rust_remote=true ;; .github/workflows/ci-windows.yml) windows=true @@ -118,7 +112,6 @@ classify_path() { .github/actions/prepare-macos-dependency-cache/*|.github/actions/save-macos-cargo-build-cache/*|.github/actions/setup-macos-toolchain/*|.swift-version) swift=true plugins=true - swift_database=true rust_core=true macos_release=true git_validation=true @@ -128,7 +121,6 @@ classify_path() { # product test lane, but does not affect release packaging. swift=true plugins=true - swift_database=true rust_core=true windows=true windows_rust=true @@ -137,11 +129,10 @@ classify_path() { .github/*|docs/*|.agents/*|.idea/*|.gitignore|license) ;; package.swift|package.resolved) - # SwiftPM graph changes can affect the main suite and both isolated - # module suites even when no source path changes. + # SwiftPM graph changes can affect the main and plugin suites even + # when no source path changes. swift=true plugins=true - swift_database=true macos_release=true git_validation=true ;; @@ -174,50 +165,31 @@ classify_path() { swift=true plugins=true ;; - macos/sources/lithedatabasemodule/*|macos/tests/lithedatabasemoduletests/*) - swift_database=true - if [[ "$lowercase_path" == macos/sources/* ]]; then - macos_release=true - fi - ;; - macos/sources/lithe/platform/macos/persistence/macdatabaserecoverystore.swift|macos/sources/lithe/platform/macos/process/macdatabasesidecarlocator.swift|macos/sources/lithe/platform/macos/storage/macdatabaseadapters.swift|macos/sources/lithe/theme/databasebrandicon.swift|macos/sources/lithe/views/database/*|macos/resources/databaseicons/*) - swift_database=true - macos_release=true - ;; macos/sources/lithe/models/appmodel/appmodel.swift) - # This is a shared app composition point for the database module. + # The app model composes every workbench feature, so a change is + # also verified in the packaged app. swift=true - swift_database=true macos_release=true ;; macos/sources/lithe/platform/macos/macservicecontainer.swift) - # The service container composes both database and plugin runtime dependencies. + # The service container composes the plugin runtime dependencies. swift=true plugins=true - swift_database=true macos_release=true ;; macos/sources/lithe/views/workbench/workbenchview.swift) - # The root workbench composes both database and plugin-owned UI. + # The root workbench composes plugin-owned UI. # Plugin tests cover plugin models and packages rather than this # view, and it is edited by most workbench pull requests, so the # Swift lane's full compile covers it without the plugin runner. swift=true - swift_database=true - macos_release=true - ;; - macos/sources/lithemoduleapi/catalog/builtinmodulecatalog.swift) - swift=true - plugins=true - swift_database=true macos_release=true ;; macos/sources/lithemoduleapi/*|macos/sources/litheapplicationkernel/*|macos/sources/lithecorecontracts/*) # These contracts and runtime primitives are direct dependencies of - # both isolated Swift module suites. + # both the main and the plugin Swift suites. swift=true plugins=true - swift_database=true macos_release=true ;; macos/sources/lithelanguageintelligencemodule/*) @@ -226,21 +198,6 @@ classify_path() { plugins=true macos_release=true ;; - macos/tests/lithetests/lithecorelogictests.swift) - # This legacy mixed suite still contains the database integration - # tests; both filtered lanes must run until the suite is separated. - swift=true - swift_database=true - ;; - macos/resources/*.lproj/*) - # Localization files contain database copy and are assembled into - # the package. Their plugin-facing coverage lives in - # AppLocalizationTests, which the Swift lane runs, so UI copy - # changes do not reserve the separate plugin runner. - swift=true - swift_database=true - macos_release=true - ;; macos/sources/litherustcore/*) rust_core=true swift=true @@ -303,22 +260,21 @@ classify_path() { ;; rust/cargo.toml|rust/cargo.lock) rust_core=true - rust_database=true + rust_remote=true macos_release=true windows=true windows_rust=true ;; - rust/lithe-db-mcp/*|rust/lithe-db-sidecar/*) - rust_database=true - # Database helpers are shipped as macOS executables, so Linux crate - # tests are not sufficient to validate their production target. - macos_release=true + rust/lithe-remote-protocol/*|rust/lithe-remote-server/*|rust/lithe-remote-client/*) + # The remote workspace crates are not linked into a shipped product + # yet. Their Linux lane also exercises the server's Linux adapter, + # which macOS runners cannot. + rust_remote=true ;; rust/*) # Unknown Rust workspace paths fail closed across all Rust-backed # products because workspace configuration can affect every crate. rust_core=true - rust_database=true macos_release=true windows=true windows_rust=true @@ -349,7 +305,6 @@ classify_path() { scripts/test-macos.sh|scripts/test-git-performance-baseline.sh|scripts/run-git-performance-verifier.mjs) swift=true plugins=true - swift_database=true git_validation=true ;; scripts/package-macos-localizations.sh) @@ -360,7 +315,7 @@ classify_path() { ;; scripts/measure-macos-performance-baseline.sh) # The baseline harness assembles a macOS release app, without changing - # shared Core behavior or the isolated database/plugin test lanes. + # shared Core behavior or the isolated plugin test lane. macos_release=true ;; scripts/verify-official-plugins.sh|scripts/build-official-plugins.sh) @@ -376,20 +331,12 @@ classify_path() { windows=true windows_rust=true ;; - scripts/build-database-mcp.sh|scripts/build-database-sidecar.sh) - rust_database=true - macos_release=true - ;; - scripts/database-sidecar-smoke.sh|scripts/database-validation-smoke.sh) - rust_database=true - ;; scripts/*sparkle*|scripts/import-macos-signing.sh|scripts/build-macos.sh|scripts/verify-macos-app-build-safety.sh|scripts/verify-runtime-bundle-immutability.sh|scripts/verify-macos-package.sh|scripts/macos13sdkcompatibility.h|scripts/ld-macos13-compat.sh|scripts/package-app.sh|scripts/preview.sh|scripts/stamp-macos-app-build-info.sh|scripts/create-dmg.sh|scripts/create-macos-update-manifest.rb|scripts/test-macos-update-manifest.rb|scripts/prepare-jdtls.sh|scripts/prepare-jdk.sh) macos_release=true ;; scripts/verify-download-cache.mjs|scripts/test-verify-download-cache.mjs|scripts/reuse-worktree-resources.mjs|scripts/test-reuse-worktree-resources.mjs|scripts/worktree-resources.json) swift=true plugins=true - swift_database=true rust_core=true macos_release=true windows=true @@ -402,7 +349,6 @@ classify_path() { scripts/validate-macos-dependency-caches.sh|scripts/prepare-macos-dependencies.sh) swift=true plugins=true - swift_database=true rust_core=true macos_release=true ;; @@ -411,14 +357,6 @@ classify_path() { ;; 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) ;; - infra/docker/*) - rust_database=true - ;; - sdk/*) - # Plugin backend SDKs are verified in the Rust Core lane, which runs - # their unit tests and drives their samples through Core sessions. - rust_core=true - ;; *) # New or unclassified repository areas are validated conservatively # until their ownership is made explicit above. @@ -470,7 +408,6 @@ if [[ "${LITHE_CI_DRAFT_PULL_REQUEST:-false}" == "true" ]]; then # Marking the pull request ready for review triggers a full classification. swift=false plugins=false - swift_database=false rust_core=false macos_release=false windows=false @@ -490,14 +427,13 @@ case "$ci_scope" in pull_request) ;; post_merge) package_affected=false - for lane in "$swift" "$plugins" "$swift_database" "$rust_core" "$macos_release"; do + for lane in "$swift" "$plugins" "$rust_core" "$macos_release"; do if [[ "$lane" == true ]]; then package_affected=true; fi done swift=false plugins=false - swift_database=false rust_core=false - rust_database=false + rust_remote=false macos_release="$package_affected" windows=false windows_rust=false @@ -509,9 +445,8 @@ case "$ci_scope" in release_promotion) swift=false plugins=false - swift_database=false rust_core=false - rust_database=false + rust_remote=false macos_release=false windows=false windows_rust=false @@ -528,9 +463,8 @@ esac printf 'swift=%s\n' "$swift" printf 'plugins=%s\n' "$plugins" -printf 'swift_database=%s\n' "$swift_database" printf 'rust_core=%s\n' "$rust_core" -printf 'rust_database=%s\n' "$rust_database" +printf 'rust_remote=%s\n' "$rust_remote" printf 'macos_release=%s\n' "$macos_release" printf 'windows=%s\n' "$windows" printf 'windows_rust=%s\n' "$windows_rust" diff --git a/scripts/database-sidecar-smoke.sh b/scripts/database-sidecar-smoke.sh deleted file mode 100755 index b4ad82bc3e..0000000000 --- a/scripts/database-sidecar-smoke.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/bin/zsh - -set -euo pipefail - -ROOT_DIR="${0:A:h:h}" -SIDECAR="${LITHE_DB_SIDECAR_EXECUTABLE:-$ROOT_DIR/rust/target/debug/lithe-db-sidecar}" - -if [[ ! -x "$SIDECAR" ]]; then - print -u2 -- "Database sidecar is not executable: $SIDECAR" - exit 2 -fi - -response=$(print -rn -- '{"id":"smoke","method":"capabilities","params":{}}' | "$SIDECAR") -for database_type in mysql postgresql sqlite; do - if ! print -r -- "$response" | grep -q '"'$database_type'"'; then - print -u2 -- "Capabilities did not include $database_type." - exit 1 - fi -done -for feature in transactionalCrud csvImportExport jsonImportExport; do - if ! print -r -- "$response" | grep -q '"'$feature'"'; then - print -u2 -- "Capabilities did not include $feature." - exit 1 - fi -done -print -r -- "$response" diff --git a/scripts/database-validation-smoke.sh b/scripts/database-validation-smoke.sh deleted file mode 100755 index 276b5f76d2..0000000000 --- a/scripts/database-validation-smoke.sh +++ /dev/null @@ -1,146 +0,0 @@ -#!/bin/zsh - -set -euo pipefail - -ROOT_DIR="${0:A:h:h}" -COMPOSE_FILE="$ROOT_DIR/infra/docker/database-validation/compose.yaml" -SIDECAR="${LITHE_DB_SIDECAR_EXECUTABLE:-$ROOT_DIR/rust/target/debug/lithe-db-sidecar}" -BIND_ADDRESS="${LITHE_BIND_ADDRESS:-127.0.0.1}" -MARIADB_PORT="${LITHE_MARIADB_PORT:-53307}" -MONGODB_PORT="${LITHE_MONGODB_PORT:-57019}" -REDIS_PORT="${LITHE_REDIS_PORT:-6381}" -DB_PASSWORD="${LITHE_DB_PASSWORD:-lithe_root}" - -if [[ ! -x "$SIDECAR" ]]; then - print -u2 -- "Database sidecar is not executable: $SIDECAR" - print -u2 -- "Build it with: cargo build --manifest-path $ROOT_DIR/rust/Cargo.toml -p lithe-db-sidecar" - exit 2 -fi - -docker compose -f "$COMPOSE_FILE" config --quiet -docker compose -f "$COMPOSE_FILE" up -d mariadb mongodb redis - -wait_for_health() { - local container="$1" - local attempt - local health_status - for attempt in {1..60}; do - health_status=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container" 2>/dev/null || true) - if [[ "$health_status" == "healthy" ]]; then - return 0 - fi - if [[ "$health_status" == "unhealthy" || "$health_status" == "exited" || "$health_status" == "dead" ]]; then - docker logs "$container" >&2 || true - print -u2 -- "$container did not become healthy (status: $health_status)" - return 1 - fi - sleep 1 - done - docker logs "$container" >&2 || true - print -u2 -- "$container healthcheck timed out" - return 1 -} - -wait_for_health lithe-database-validation-mariadb -wait_for_health lithe-database-validation-mongodb -wait_for_health lithe-database-validation-redis -docker compose -f "$COMPOSE_FILE" up --no-deps redis-seed - -typeset -g LAST_RESPONSE - -request() { - local request_id="$1" - local method="$2" - local params="$3" - LAST_RESPONSE=$(print -rn -- "{\"id\":\"$request_id\",\"method\":\"$method\",\"params\":$params}" | "$SIDECAR" || true) - print -r -- "$LAST_RESPONSE" - print -rn -- "$LAST_RESPONSE" | python3 -c ' -import json -import sys -response = json.load(sys.stdin) -if not response.get("ok"): - error = response.get("error", {}) - code = error.get("code", "unknown") - message = error.get("message", "unknown error") - raise SystemExit(f"sidecar request failed: {code}: {message}") -' -} - -assert_result_contains() { - local needle="$1" - print -rn -- "$LAST_RESPONSE" | python3 -c ' -import json -import sys -response = json.load(sys.stdin) -needle = sys.argv[1] -if needle not in json.dumps(response.get("result"), ensure_ascii=False): - raise SystemExit(f"expected result to contain {needle!r}") -' "$needle" -} - -assert_result() { - local check="$1" - print -rn -- "$LAST_RESPONSE" | python3 -c ' -import json -import sys -response = json.load(sys.stdin) -check = sys.argv[1] -result = response.get("result") -if check == "mariadb_rows": - rows = result.get("rows", []) - assert len(rows) == 2 and rows[0]["empty_value"] == "" and rows[0]["nullable_note"] is None -elif check == "mariadb_page": - assert result.get("totalRows") == 2 and len(result.get("rows", [])) == 1 -elif check == "mongo_page": - assert result.get("totalRows") == 2 and len(result.get("rows", [])) == 2 -elif check == "mongo_query": - assert len(result.get("rows", [])) == 1 and result["rows"][0]["name"] == "中文 / emoji 🚀" -elif check == "redis_scan": - keys = {item["key"] for item in result.get("keys", [])} - assert {"profile:42", "session:42", "queue:jobs", "ttl:short"}.issubset(keys) -elif check == "redis_hash": - assert result["type"] == "hash" and {entry["field"] for entry in result["hashEntries"]} == {"user_id", "locale"} -else: - raise SystemExit(f"unknown assertion {check!r}") -' "$check" -} - -mariadb='{"kind":"mariadb","host":"'$BIND_ADDRESS'","port":'$MARIADB_PORT',"username":"root","password":"'$DB_PASSWORD'","database":"lithe_test"}' -mongodb='{"kind":"mongodb","host":"'$BIND_ADDRESS'","port":'$MONGODB_PORT',"username":"root","password":"'$DB_PASSWORD'","database":"lithe_test"}' -redis='{"kind":"redis","host":"'$BIND_ADDRESS'","port":'$REDIS_PORT',"password":"'$DB_PASSWORD'","database":"0"}' - -print -r -- "MariaDB: test connection, metadata, typed values, and pagination" -request mariadb-connect testConnection '{"connection":'$mariadb'}' -request mariadb-tables listTables '{"connection":'$mariadb'}' -assert_result_contains records -request mariadb-columns describeTable '{"connection":'$mariadb',"table":"records"}' -assert_result_contains empty_value -assert_result_contains tiny_value -request mariadb-query query '{"connection":'$mariadb',"sql":"SELECT id, empty_value, nullable_note, flag, tiny_value, amount FROM records ORDER BY id","limit":10}' -assert_result mariadb_rows -request mariadb-page pageTable '{"connection":'$mariadb',"table":"records","limit":1,"offset":1,"sort":[{"column":"id"}]}' -assert_result mariadb_page - -print -r -- "MongoDB: test connection, collections, indexes, documents, and filters" -request mongodb-connect testConnection '{"connection":'$mongodb'}' -request mongodb-tables listTables '{"connection":'$mongodb'}' -assert_result_contains items -request mongodb-indexes listIndexes '{"connection":'$mongodb',"table":"items"}' -assert_result_contains items_name_idx -request mongodb-page pageTable '{"connection":'$mongodb',"table":"items","limit":10,"sort":[{"column":"score","descending":true}]}' -assert_result mongo_page -request mongodb-query query '{"connection":'$mongodb',"table":"items","sql":"{\"name\":\"中文 / emoji 🚀\"}","limit":10}' -assert_result mongo_query - -print -r -- "Redis: test connection, scan, string/hash/list metadata, and key detail" -request redis-connect testConnection '{"connection":'$redis'}' -request redis-scan redisScan '{"connection":'$redis',"cursor":"0","pattern":"*","count":100,"includeSize":true}' -assert_result redis_scan -request redis-string redisGetKey '{"connection":'$redis',"key":"profile:42"}' -assert_result_contains 'Alice / demo' -request redis-hash redisGetKey '{"connection":'$redis',"key":"session:42"}' -assert_result redis_hash -request redis-list redisGetKey '{"connection":'$redis',"key":"queue:jobs"}' -assert_result_contains 'list' - -print -r -- "Database validation smoke passed. Containers remain running for manual Lithe verification." diff --git a/scripts/generate-i18n.mjs b/scripts/generate-i18n.mjs new file mode 100644 index 0000000000..cc09be1e41 --- /dev/null +++ b/scripts/generate-i18n.mjs @@ -0,0 +1,509 @@ +#!/usr/bin/env node +// Build-time only. Note: .agents/notes/implemented/architecture/2026-10-23-shared-i18n-generation.md +import { readFile, writeFile, mkdir, readdir } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +export const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const PLATFORMS = ["macos", "windows"]; +const BEGIN = "/* BEGIN GENERATED SHARED I18N — DO NOT EDIT */"; +const END = "/* END GENERATED SHARED I18N */"; +const HEADER = "// Generated by scripts/generate-i18n.mjs. Edit shared/i18n instead.\n"; +const fail = (message) => { + throw new Error(`i18n: ${message}`); +}; +const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value); +const own = (object, key) => Object.hasOwn(object, key); +function fields(value, allowed, label) { + if (!record(value)) fail(`${label} must be an object`); + for (const key of Object.keys(value)) + if (!allowed.includes(key)) fail(`${label}: unknown field ${key}`); +} +function nonempty(value, label) { + if ( + typeof value !== "string" || + !value.trim() || + /[\u0000-\u0008\u000b\u000c\u000e-\u001f]/u.test(value) + ) + fail(`${label} must be nonempty text`); +} +function unique(values, label) { + if (new Set(values).size !== values.length) fail(`${label} must be unique`); +} + +// JSON.parse silently keeps the last duplicate. Reject duplicates before a +// translator can lose a value, including escaped spellings of the same key. +export function parseJSON(source, label) { + let parsed; + try { + parsed = JSON.parse(source); + } catch (error) { + fail(`${label}: ${error.message}`); + } + const tokens = + source.match( + /"(?:\\.|[^"\\])*"|[{}\[\]:,]|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null/g, + ) ?? []; + const stack = []; + tokens.forEach((token, index) => { + if (token === "{") stack.push(new Set()); + else if (token === "[") stack.push(null); + else if (token === "}" || token === "]") stack.pop(); + else if (token.startsWith('"') && tokens[index + 1] === ":") { + const key = JSON.parse(token); + if (stack.at(-1).has(key)) fail(`${label}: duplicate JSON key ${key}`); + stack.at(-1).add(key); + } + }); + return parsed; +} +async function json(root, relative) { + return parseJSON(await readFile(path.join(root, relative), "utf8"), relative); +} + +function parameters(text, message, label) { + if (!(message.allowEmptyTranslation && text === "")) nonempty(text, label); + const names = [...text.matchAll(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g)].map((match) => match[1]); + if (!message.literalBraces && /[{}]/.test(text.replace(/\{[a-zA-Z][a-zA-Z0-9]*\}/g, ""))) + fail(`${label}: malformed placeholder`); + const expected = Object.keys(message.parameters ?? {}); + const optional = Object.keys(message.optionalParameters ?? {}); + if (names.some((name) => !expected.includes(name)) && !message.literalBraces) + fail(`${label}: unknown placeholder`); + if (expected.some((name) => !optional.includes(name) && !names.includes(name))) + fail(`${label}: placeholder names differ from ${expected.join(", ")}`); +} + +export async function loadContract(root = ROOT) { + const manifest = await json(root, "shared/i18n/manifest.json"); + fields(manifest, ["schemaVersion", "defaultLocale", "platformDefaults", "locales"], "manifest"); + if (manifest.schemaVersion !== 1 || !Array.isArray(manifest.locales) || !manifest.locales.length) + fail("unsupported manifest"); + fields(manifest.platformDefaults, PLATFORMS, "platformDefaults"); + for (const locale of manifest.locales) { + fields(locale, ["id", "title", "macos", "swiftCase", "fallback", "coverage"], "locale"); + if (typeof locale.id !== "string" || !/^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(locale.id)) + fail("invalid locale id"); + if ( + typeof locale.macos !== "string" || + !/^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(locale.macos) + ) + fail(`invalid macOS locale: ${locale.id}`); + if ( + typeof locale.swiftCase !== "string" || + !/^[a-z][a-zA-Z0-9]*$/.test(locale.swiftCase) || + [ + "default", + "case", + "self", + "enum", + "var", + "let", + "switch", + "class", + "struct", + "return", + "static", + "import", + "protocol", + "as", "associatedtype", "break", "catch", "continue", "defer", "deinit", "do", + "else", "extension", "fallthrough", "false", "fileprivate", "for", "func", + "guard", "if", "in", "init", "inout", "internal", "is", "nil", "open", + "operator", "precedencegroup", "private", "public", "repeat", "rethrows", "subscript", "super", + "throw", "throws", "true", "try", "typealias", "where", "while", + // These names are emitted by AppLanguage or its synthesized conformances. + "allCases", "rawValue", "defaultLanguage", "id", "locale", "title", + "localizedFormat", "localizedString", + ].includes(locale.swiftCase) + ) + fail(`invalid Swift case: ${locale.id}`); + nonempty(locale.title, `${locale.id} title`); + if (!["complete", "fallback"].includes(locale.coverage)) fail(`invalid coverage: ${locale.id}`); + } + for (const field of ["id", "macos", "swiftCase"]) + unique( + manifest.locales.map((locale) => locale[field].toLowerCase()), + `locale ${field}`, + ); + const locales = new Map(manifest.locales.map((locale) => [locale.id, locale])); + if (!locales.has(manifest.defaultLocale)) fail("unknown default locale"); + const defaultLocale = locales.get(manifest.defaultLocale); + if (defaultLocale.fallback !== null || defaultLocale.coverage !== "complete") + fail("default locale must be complete and have null fallback"); + for (const platform of PLATFORMS) + if (!locales.has(manifest.platformDefaults[platform])) fail(`unknown ${platform} default`); + for (const locale of manifest.locales) { + const visited = new Set(); + let current = locale; + while (current.id !== manifest.defaultLocale) { + if (visited.has(current.id)) fail(`fallback cycle: ${locale.id}`); + visited.add(current.id); + current = locales.get(current.fallback); + if (!current) fail(`fallback must reach default locale: ${locale.id}`); + } + } + const registry = await json(root, "shared/i18n/messages.json"); + fields(registry, ["schemaVersion", "messages"], "messages"); + if ( + registry.schemaVersion !== 1 || + !Array.isArray(registry.messages) || + !registry.messages.length + ) + fail("unsupported messages registry"); + const aliases = new Set(); + for (const message of registry.messages) { + fields( + message, + [ + "id", + "description", + "platforms", + "parameters", + "macos", + "windows", + "optionalParameters", + "literalBraces", + "allowEmptyTranslation", + ], + "message", + ); + if ( + typeof message.id !== "string" || + !/^[a-z][a-zA-Z0-9]*(?:\.[a-zA-Z][a-zA-Z0-9]*)+$/.test(message.id) + ) + fail("invalid semantic key"); + nonempty(message.description, `${message.id} description`); + if ( + !Array.isArray(message.platforms) || + !message.platforms.length || + message.platforms.some((platform) => !PLATFORMS.includes(platform)) + ) + fail(`${message.id}: invalid platform scope`); + unique(message.platforms, `${message.id} platforms`); + if (message.parameters !== undefined) { + if (!record(message.parameters)) fail(`${message.id}: invalid parameters`); + for (const [name, type] of Object.entries(message.parameters)) + if ( + !/^[a-zA-Z][a-zA-Z0-9]*$/.test(name) || + !["string", "integer", "unsignedInteger"].includes(type) + ) + fail(`${message.id}: invalid parameter ${name}`); + } + if (message.allowEmptyTranslation !== undefined && message.allowEmptyTranslation !== true) + fail(`${message.id}: allowEmptyTranslation must be true`); + if (message.literalBraces !== undefined && message.literalBraces !== true) + fail(`${message.id}: literalBraces must be true`); + if (message.optionalParameters !== undefined) { + if (!record(message.optionalParameters)) fail(`${message.id}: invalid optional parameters`); + for (const [name, reason] of Object.entries(message.optionalParameters)) { + if (!own(message.parameters ?? {}, name)) + fail(`${message.id}: undeclared optional parameter`); + nonempty(reason, `${message.id}/${name} omission reason`); + } + } + if (message.windows !== undefined) { + fields(message.windows, ["key"], `${message.id} windows`); + if (!message.platforms.includes("windows")) + fail(`${message.id}: Windows alias outside scope`); + nonempty(message.windows.key, `${message.id} Windows key`); + } + if (message.macos !== undefined) { + if (!message.platforms.includes("macos")) fail(`${message.id}: macOS alias outside scope`); + fields(message.macos, ["key", "arguments", "formats"], `${message.id} macos`); + nonempty(message.macos.key, `${message.id} macOS key`); + if (aliases.has(message.macos.key)) fail(`duplicate macOS alias: ${message.macos.key}`); + aliases.add(message.macos.key); + if (!Array.isArray(message.macos.arguments)) + fail(`${message.id}: alias needs argument order`); + const argumentNames = message.macos.arguments; + unique(argumentNames, `${message.id} alias arguments`); + if ( + JSON.stringify([...argumentNames].sort()) !== + JSON.stringify(Object.keys(message.parameters ?? {}).sort()) + ) + fail(`${message.id}: alias arguments differ`); + const formats = message.macos.formats ?? {}; + if (!record(formats) || Object.keys(formats).some((name) => !argumentNames.includes(name))) + fail(`${message.id}: invalid native formats`); + for (const name of argumentNames) { + const format = formats[name] ?? defaultFormat(message.parameters[name]); + const type = { "@": "string", d: "integer", lld: "integer", llu: "unsignedInteger" }[ + format + ]; + if (type !== message.parameters[name]) fail(`${message.id}: native format type differs`); + } + const directives = [ + ...message.macos.key.matchAll(/%%|%(?:([1-9]\d*)\$)?(lld|llu|d|@)/g), + ].filter((match) => match[0] !== "%%"); + const positions = directives.map((match, index) => Number(match[1] ?? index + 1) - 1); + const positioned = directives.filter((match) => match[1] !== undefined).length; + if ( + /%[a-zA-Z]/.test(message.macos.key.replace(/%%|%(?:[1-9]\d*\$)?(?:lld|llu|d|@)/g, "")) || + directives.length !== argumentNames.length || + (positioned !== 0 && positioned !== directives.length) || + new Set(positions).size !== positions.length || + positions.some((position) => !Number.isSafeInteger(position) || position < 0 || position >= argumentNames.length) || + directives.some( + (match, index) => + match[2] !== + (formats[argumentNames[positions[index]]] ?? + defaultFormat(message.parameters[argumentNames[positions[index]]])), + ) + ) + fail(`${message.id}: alias format differs from parameter types`); + } + } + unique( + registry.messages.map((message) => message.id), + "semantic keys", + ); + if (registry.messages.some((message) => aliases.has(message.id))) + fail("semantic key collides with macOS alias"); + const messages = [...registry.messages].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + unique( + messages + .filter((message) => message.platforms.includes("windows")) + .map((message) => message.windows?.key ?? message.id), + "Windows keys", + ); + const byID = new Map(messages.map((message) => [message.id, message])); + const catalogs = new Map(); + const localeFiles = (await readdir(path.join(root, "shared/i18n/locales"))).sort(); + if ( + JSON.stringify(localeFiles) !== + JSON.stringify([...locales.keys()].map((id) => `${id}.json`).sort()) + ) + fail("locale files differ from manifest"); + for (const locale of manifest.locales) { + const catalog = await json(root, `shared/i18n/locales/${locale.id}.json`); + fields(catalog, ["messages", "overrides"], locale.id); + if (!record(catalog.messages)) fail(`${locale.id}: missing messages`); + for (const [key, text] of Object.entries(catalog.messages)) { + if (!byID.has(key)) fail(`${locale.id}: unknown key ${key}`); + parameters(text, byID.get(key), `${locale.id}/${key}`); + } + if ( + locale.coverage === "complete" && + messages.some((message) => !own(catalog.messages, message.id)) + ) + fail(`${locale.id}: incomplete translation coverage`); + fields(catalog.overrides ?? {}, PLATFORMS, `${locale.id} overrides`); + for (const [platform, overrides] of Object.entries(catalog.overrides ?? {})) { + if (!record(overrides)) fail(`${locale.id}/${platform}: invalid overrides`); + for (const [key, override] of Object.entries(overrides)) { + const message = byID.get(key); + if (!message || !message.platforms.includes(platform)) + fail(`${locale.id}/${platform}: override outside scope: ${key}`); + fields(override, ["value", "reason", "nativeFormat"], `${locale.id}/${platform}/${key}`); + nonempty(override.reason, `${key} override reason`); + if ( + override.nativeFormat !== undefined && + (platform !== "macos" || override.nativeFormat !== "positional") + ) + fail(`${key}: invalid native format policy`); + parameters(override.value, message, `${locale.id}/${platform}/${key}`); + } + } + catalogs.set(locale.id, catalog); + } + return { manifest, locales, messages, catalogs }; +} + +export function resolveMessage(contract, localeID, platform, key) { + const catalog = contract.catalogs.get(localeID); + const override = catalog.overrides?.[platform]?.[key]; + if (override) return override.value; + if (own(catalog.messages, key)) return catalog.messages[key]; + return resolveMessage(contract, contract.locales.get(localeID).fallback, platform, key); +} + +function defaultFormat(type) { + return type === "integer" ? "lld" : type === "unsignedInteger" ? "llu" : "@"; +} + +export function nativeTemplate( + text, + message, + argumentOrder = Object.keys(message.parameters ?? {}), + positional = true, +) { + // Escape literal percent signs before inserting directives. Positional formats + // permit translators to reorder or repeat arguments without changing callers. + return text + .replaceAll("%", "%%") + .replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (token, name) => + own(message.parameters ?? {}, name) + ? `%${positional ? `${argumentOrder.indexOf(name) + 1}$` : ""}${message.macos?.formats?.[name] ?? defaultFormat(message.parameters[name])}` + : token, + ); +} +const quote = (value) => + JSON.stringify(value).replaceAll("\u2028", "\\u2028").replaceAll("\u2029", "\\u2029"); +function stringsEntry(key, value) { + return `${quote(key)} = ${quote(value)};`; +} +function swiftQuote(value) { + const escapes = { + "\\": "\\\\", + '"': '\\"', + "\n": "\\n", + "\r": "\\r", + "\t": "\\t", + "\u2028": "\\u{2028}", + "\u2029": "\\u{2029}", + }; + return `"${[...value].map((character) => escapes[character] ?? character).join("")}"`; +} + +export async function planGeneration(root = ROOT) { + const contract = await loadContract(root); + const { manifest, messages } = contract; + const windowsKeys = new Set( + messages + .filter((message) => message.platforms.includes("windows")) + .map((message) => message.windows?.key ?? message.id), + ); + const legacyDirectory = path.join(root, "windows/tauri/src/i18n"); + for (const name of (await readdir(legacyDirectory)).filter( + (name) => name.endsWith(".ts") && !name.endsWith(".test.ts") && !name.endsWith(".generated.ts"), + )) { + const text = await readFile(path.join(legacyDirectory, name), "utf8"); + for (const match of text.matchAll(/^\s*"([^"\n]+)"\s*:/gm)) { + if (windowsKeys.has(match[1])) + fail(`${name}: migrated key still manually defined: ${match[1]}`); + } + } + const outputs = new Map(); + const options = manifest.locales.map((locale) => ({ value: locale.id, label: locale.title })); + const windowsCatalogs = Object.fromEntries( + manifest.locales.map((locale) => [ + locale.id, + Object.fromEntries( + messages + .filter((message) => message.platforms.includes("windows")) + .map((message) => [ + message.windows?.key ?? message.id, + resolveMessage(contract, locale.id, "windows", message.id), + ]), + ), + ]), + ); + outputs.set( + "windows/tauri/src/i18n/shared.generated.ts", + `${HEADER}export const LANGUAGE_OPTIONS = ${JSON.stringify(options, null, 2)} as const;\n\nexport const DISPLAY_LANGUAGES = ${JSON.stringify(options.map((option) => option.value))} as const;\nexport type DisplayLanguage = (typeof DISPLAY_LANGUAGES)[number];\nexport const DEFAULT_LOCALE: DisplayLanguage = ${quote(manifest.defaultLocale)};\nexport const DEFAULT_DISPLAY_LANGUAGE: DisplayLanguage = ${quote(manifest.platformDefaults.windows)};\n\nexport const sharedCatalogs = ${JSON.stringify(windowsCatalogs, null, 2)} as const;\n`, + ); + const defaultCase = contract.locales.get(manifest.platformDefaults.macos).swiftCase; + const swift = `${HEADER}import Foundation\n\nenum AppLanguage: String, CaseIterable, Identifiable {\n${manifest.locales.map((locale) => ` case ${locale.swiftCase} = ${swiftQuote(locale.macos)}`).join("\n")}\n\n static let defaultLanguage: AppLanguage = .${defaultCase}\n var id: String { rawValue }\n var locale: Locale { Locale(identifier: rawValue) }\n\n var title: String {\n switch self {\n${manifest.locales.map((locale) => ` case .${locale.swiftCase}: ${swiftQuote(locale.title)}`).join("\n")}\n }\n }\n\n func localizedFormat(_ key: String, _ arguments: CVarArg..., bundle: Bundle = .main) -> String {\n String(format: localizedString(key, bundle: bundle), arguments: arguments)\n }\n\n func localizedString(_ key: String, bundle: Bundle = .main) -> String {\n (bundle.url(forResource: rawValue, withExtension: "lproj").flatMap(Bundle.init(url:)) ?? bundle)\n .localizedString(forKey: key, value: key, table: nil)\n }\n}\n`; + outputs.set("macos/Sources/Lithe/Models/Settings/AppLanguage.generated.swift", swift); + // The complete native table is generated from shared source. Existing SwiftUI + // and native callers keep their historical aliases in the same default table. + for (const locale of manifest.locales) { + const relative = `macos/Resources/${locale.macos}.lproj/Localizable.strings`; + let existing; + try { + existing = await readFile(path.join(root, relative), "utf8"); + } catch (error) { + if (error.code !== "ENOENT") throw error; + existing = ""; + } + const begin = existing.indexOf(BEGIN); + const end = existing.indexOf(END); + if ( + begin < 0 !== end < 0 || + (begin >= 0 && + (end < begin || + existing.indexOf(BEGIN, begin + BEGIN.length) >= 0 || + existing.indexOf(END, end + END.length) >= 0)) + ) + fail(`${relative}: invalid generated region`); + const legacy = ( + begin < 0 ? existing : existing.slice(0, begin) + existing.slice(end + END.length) + ).trimEnd(); + if (legacy.replace(/\/\*[\s\S]*?\*\//g, "").trim()) + fail(`${relative}: native translations must be maintained in shared/i18n`); + const entries = new Map(); + for (const message of messages.filter((message) => message.platforms.includes("macos"))) { + const text = resolveMessage(contract, locale.id, "macos", message.id); + entries.set( + message.id, + Object.keys(message.parameters ?? {}).length ? nativeTemplate(text, message) : text, + ); + if (message.macos) { + const sequence = [...text.matchAll(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g)].map((match) => match[1]); + const unchangedOrder = JSON.stringify(sequence) === JSON.stringify(message.macos.arguments); + const translated = message.macos.arguments.length + ? nativeTemplate( + text, + message, + message.macos.arguments, + !unchangedOrder || + contract.catalogs.get(locale.id).overrides?.macos?.[message.id]?.nativeFormat === + "positional", + ) + : text; + entries.set(message.macos.key, translated); + } + } + // Detect manual definitions even when they differ only by escaping. + for (const match of legacy.matchAll(/^\s*("(?:\\.|[^"\\])*")\s*=/gm)) { + if (entries.has(JSON.parse(match[1]))) + fail(`${relative}: migrated key still manually defined: ${JSON.parse(match[1])}`); + } + outputs.set( + relative, + `${BEGIN}\n${[...entries] + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([key, value]) => stringsEntry(key, value)) + .join("\n")}\n${END}\n`, + ); + } + return { outputs, contract }; +} + +export async function generate(root = ROOT, check = false) { + const { outputs, contract } = await planGeneration(root); + const stale = []; + // Finish all validation before the first write. --check is strictly read-only. + for (const [relative, expected] of outputs) { + let actual; + try { + actual = await readFile(path.join(root, relative), "utf8"); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + if (actual === expected) continue; + stale.push(relative); + if (!check) { + await mkdir(path.dirname(path.join(root, relative)), { recursive: true }); + await writeFile(path.join(root, relative), expected); + } + } + if (check && stale.length) + fail(`generated files are stale; run node scripts/generate-i18n.mjs:\n${stale.join("\n")}`); + return contract; +} + +async function main() { + const args = process.argv.slice(2); + if ( + args.length > 1 || + args.some((arg) => !["--check", "--macos-locales", "--coverage"].includes(arg)) + ) + fail("usage: node scripts/generate-i18n.mjs [--check|--macos-locales|--coverage]"); + const contract = + args[0] === "--macos-locales" || args[0] === "--coverage" + ? await loadContract() + : await generate(ROOT, args[0] === "--check"); + if (args[0] === "--macos-locales") + console.log(contract.manifest.locales.map((locale) => locale.macos).join("\n")); + else + for (const locale of contract.manifest.locales) + console.log( + `${locale.id}: ${Object.keys(contract.catalogs.get(locale.id).messages).length}/${contract.messages.length} shared translations (${locale.coverage})`, + ); +} +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) + main().catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); diff --git a/scripts/measure-macos-performance-baseline.sh b/scripts/measure-macos-performance-baseline.sh index 4e7f865915..5241ec2c03 100755 --- a/scripts/measure-macos-performance-baseline.sh +++ b/scripts/measure-macos-performance-baseline.sh @@ -109,7 +109,6 @@ prepare_baseline_app() { cp -R "$project_root/.artifacts/editor/macos" "$app_dir/Contents/Resources/MonacoEditor" cp -R "$project_root/macos/Resources/IDEAIcons" "$app_dir/Contents/Resources/IDEAIcons" cp -R "$project_root/macos/Resources/GitGraph" "$app_dir/Contents/Resources/GitGraph" - cp -R "$project_root/macos/Resources/DatabaseIcons" "$app_dir/Contents/Resources/DatabaseIcons" zsh "$project_root/scripts/package-macos-localizations.sh" \ "$project_root/macos/Resources" "$app_dir/Contents/Resources" /usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier $bundle_identifier" \ diff --git a/scripts/package-app.sh b/scripts/package-app.sh index cc732e297f..96a93eac57 100755 --- a/scripts/package-app.sh +++ b/scripts/package-app.sh @@ -49,40 +49,6 @@ fi rm -rf "$APP_DIR" mkdir -p "$APP_DIR/Contents/MacOS" "$APP_DIR/Contents/Resources" -database_sidecar="${LITHE_DB_SIDECAR_EXECUTABLE:-}" -if [[ -z "$database_sidecar" && "${LITHE_SKIP_DATABASE_SIDECAR:-0}" != "1" ]]; then - database_sidecar=$(LITHE_ARCH="$ARCH" "$ROOT_DIR/scripts/build-database-sidecar.sh") -fi -if [[ -n "$database_sidecar" ]]; then - if [[ ! -x "$database_sidecar" ]]; then - print -u2 -- "LITHE_DB_SIDECAR_EXECUTABLE is not executable: $database_sidecar" - exit 1 - fi - if [[ "$ARCH" == "universal" ]] && \ - ! lipo "$database_sidecar" -verify_arch arm64 x86_64 >/dev/null 2>&1; then - print -u2 -- "Universal packaging requires a fat database helper with arm64 and x86_64 slices" - exit 1 - fi - mkdir -p "$APP_DIR/Contents/Helpers" - cp "$database_sidecar" "$APP_DIR/Contents/Helpers/lithe-db-sidecar" -fi -database_mcp="${LITHE_DB_MCP_EXECUTABLE:-}" -if [[ -z "$database_mcp" && "${LITHE_SKIP_DATABASE_MCP:-0}" != "1" ]]; then - database_mcp=$(LITHE_ARCH="$ARCH" "$ROOT_DIR/scripts/build-database-mcp.sh") -fi -if [[ -n "$database_mcp" ]]; then - if [[ ! -x "$database_mcp" ]]; then - print -u2 -- "LITHE_DB_MCP_EXECUTABLE is not executable: $database_mcp" - exit 1 - fi - if [[ "$ARCH" == "universal" ]] && \ - ! lipo "$database_mcp" -verify_arch arm64 x86_64 >/dev/null 2>&1; then - print -u2 -- "Universal packaging requires a fat database MCP helper with arm64 and x86_64 slices" - exit 1 - fi - mkdir -p "$APP_DIR/Contents/Helpers" - cp "$database_mcp" "$APP_DIR/Contents/Helpers/lithe-db-mcp" -fi ide_mcp=$(LITHE_ARCH="$ARCH" "$ROOT_DIR/scripts/build-ide-mcp.sh") mkdir -p "$APP_DIR/Contents/Helpers" cp "$ide_mcp" "$APP_DIR/Contents/Helpers/lithe-mcp" @@ -135,6 +101,11 @@ for plugin_package in "$plugin_root"/*(/N); do cp -R "$plugin_package" "$OFFICIAL_PLUGIN_DESTINATION/${plugin_package:t}" done +# Remote workspace servers ship read-only; Lithe uploads them to SSH hosts +# and never writes into this directory. +remote_server_root=$(scripts/build-remote-servers.sh) +cp -R "$remote_server_root" "$APP_DIR/Contents/Resources/RemoteServers" + # The official marketplace index ships read-only so the catalog works # offline; refreshed copies are written to the user's Caches at runtime. mkdir -p "$APP_DIR/Contents/Resources/PluginMarketplace" @@ -149,7 +120,6 @@ zsh "$ROOT_DIR/scripts/configure-sparkle-app.sh" "$APP_DIR/Contents/Info.plist" cp "$ROOT_DIR/macos/Resources/AppIcon.icns" "$APP_DIR/Contents/Resources/AppIcon.icns" cp -R "$ROOT_DIR/macos/Resources/IDEAIcons" "$APP_DIR/Contents/Resources/IDEAIcons" cp -R "$ROOT_DIR/macos/Resources/GitGraph" "$APP_DIR/Contents/Resources/GitGraph" -cp -R "$ROOT_DIR/macos/Resources/DatabaseIcons" "$APP_DIR/Contents/Resources/DatabaseIcons" cp -R "$ROOT_DIR/macos/Resources/Fonts" "$APP_DIR/Contents/Resources/Fonts" cp -R "$ROOT_DIR/.artifacts/editor/macos" "$APP_DIR/Contents/Resources/MonacoEditor" zsh "$ROOT_DIR/scripts/package-macos-localizations.sh" \ diff --git a/scripts/package-macos-localizations.sh b/scripts/package-macos-localizations.sh index 1b63bf83b6..f602d577ae 100644 --- a/scripts/package-macos-localizations.sh +++ b/scripts/package-macos-localizations.sh @@ -6,6 +6,13 @@ if (( $# != 2 )); then exit 2 fi +ROOT_DIR="${0:A:h:h}" +# Checked-in outputs are generated before packaging/signing, never at runtime. +node "$ROOT_DIR/scripts/generate-i18n.mjs" --check +locale_ids=$(node "$ROOT_DIR/scripts/generate-i18n.mjs" --macos-locales) +localizations=() +for locale_id in ${(f)locale_ids}; do localizations+=("$locale_id.lproj"); done + source_resources=${1:A} destination_resources=${2:A} if [[ "$source_resources" == "$destination_resources" ]]; then @@ -13,7 +20,7 @@ if [[ "$source_resources" == "$destination_resources" ]]; then exit 2 fi -for localization in en.lproj zh-Hans.lproj; do +for localization in "${localizations[@]}"; do if [[ ! -f "$source_resources/$localization/Localizable.strings" ]]; then print -u2 -- "Missing localization: $source_resources/$localization/Localizable.strings" exit 1 @@ -21,7 +28,7 @@ for localization in en.lproj zh-Hans.lproj; do done mkdir -p "$destination_resources" -for localization in en.lproj zh-Hans.lproj; do +for localization in "${localizations[@]}"; do mkdir -p "$destination_resources/$localization" cp -R "$source_resources/$localization/" "$destination_resources/$localization/" # Explicit SwiftUI locales can reload string tables on every lookup. Binary diff --git a/scripts/plan-ci-lane-reuse.mjs b/scripts/plan-ci-lane-reuse.mjs index fa083bcc9e..e78387d41a 100644 --- a/scripts/plan-ci-lane-reuse.mjs +++ b/scripts/plan-ci-lane-reuse.mjs @@ -24,7 +24,7 @@ import { pathToFileURL } from 'node:url'; // outputs that select it. Keep in sync with the workflows' job `if:` lines. export const WORKFLOW_LANES = Object.freeze({ macos: [ - { lane: 'swift', job: 'Swift tests', selected: (o) => o.swift || o.swift_database || o.rust_core }, + { lane: 'swift', job: 'Swift tests', selected: (o) => o.swift || o.rust_core }, { lane: 'rust', job: 'Rust Core tests', selected: (o) => o.rust_core }, { lane: 'release', job: 'Release package verification', prefix: true, selected: (o) => o.macos_release }, ], diff --git a/scripts/preview.sh b/scripts/preview.sh index ccbddaba87..c8bafe9796 100755 --- a/scripts/preview.sh +++ b/scripts/preview.sh @@ -49,6 +49,9 @@ plugin_root=$(scripts/build-official-plugins.sh \ for plugin_package in "$plugin_root"/*(/N); do cp -R "$plugin_package" "$APP_DIR/Contents/Resources/OfficialPlugins/${plugin_package:t}" done +# Read-only remote workspace servers, uploaded to SSH hosts on connect. +remote_server_root=$(scripts/build-remote-servers.sh) +cp -R "$remote_server_root" "$APP_DIR/Contents/Resources/RemoteServers" # Read-only official marketplace index; refreshed copies go to Caches. mkdir -p "$APP_DIR/Contents/Resources/PluginMarketplace" cp "$ROOT_DIR/Plugins/marketplace/index.json" "$APP_DIR/Contents/Resources/PluginMarketplace/index.json" @@ -56,14 +59,6 @@ case "$TRIPLE" in arm64-apple-macosx) RUST_TARGET="aarch64-apple-darwin" ;; x86_64-apple-macosx) RUST_TARGET="x86_64-apple-darwin" ;; esac -MACOSX_DEPLOYMENT_TARGET=13.0 \ - CARGO_TARGET_DIR="$ROOT_DIR/rust/target/macos" \ - cargo build --manifest-path "$ROOT_DIR/rust/Cargo.toml" -p lithe-db-sidecar --target "$RUST_TARGET" -cp "rust/target/macos/$RUST_TARGET/debug/lithe-db-sidecar" "$APP_DIR/Contents/Helpers/lithe-db-sidecar" -MACOSX_DEPLOYMENT_TARGET=13.0 \ - CARGO_TARGET_DIR="$ROOT_DIR/rust/target/macos" \ - cargo build --manifest-path "$ROOT_DIR/rust/Cargo.toml" -p lithe-db-mcp --target "$RUST_TARGET" -cp "rust/target/macos/$RUST_TARGET/debug/lithe-db-mcp" "$APP_DIR/Contents/Helpers/lithe-db-mcp" MACOSX_DEPLOYMENT_TARGET=13.0 \ CARGO_TARGET_DIR="$ROOT_DIR/rust/target/macos" \ cargo build --manifest-path "$ROOT_DIR/rust/Cargo.toml" -p lithe-ide-host --features mcp --bin lithe-mcp --target "$RUST_TARGET" @@ -74,7 +69,6 @@ zsh "$ROOT_DIR/scripts/embed-sparkle.sh" "$APP_DIR" cp macos/Resources/AppIcon.icns "$APP_DIR/Contents/Resources/AppIcon.icns" cp -R macos/Resources/IDEAIcons "$APP_DIR/Contents/Resources/IDEAIcons" cp -R macos/Resources/GitGraph "$APP_DIR/Contents/Resources/GitGraph" -cp -R macos/Resources/DatabaseIcons "$APP_DIR/Contents/Resources/DatabaseIcons" cp -R macos/Resources/Fonts "$APP_DIR/Contents/Resources/Fonts" cp -R "$ROOT_DIR/.artifacts/editor/macos" "$APP_DIR/Contents/Resources/MonacoEditor" zsh "$ROOT_DIR/scripts/package-macos-localizations.sh" \ diff --git a/scripts/reuse-worktree-resources.mjs b/scripts/reuse-worktree-resources.mjs index 9853394d34..5f0747da99 100644 --- a/scripts/reuse-worktree-resources.mjs +++ b/scripts/reuse-worktree-resources.mjs @@ -81,6 +81,8 @@ async function readRegistry() { // are mutable user state, never download artifacts. Runtime profile import is // owned by the platform startup adapters; this build reuse route always rejects // both original state and migration snapshots and must not gain a validator. + // Shared i18n source and generated tables take this same refusal route: + // rebuild from the target checkout, never copy another worktree's translations. const excludedResources = registry.excludedResources ?? []; if (!Array.isArray(excludedResources)) throw new Error("Invalid excluded worktree resources"); for (const resource of excludedResources) { @@ -387,7 +389,16 @@ async function main() { // Includes agent-ui-test-evidence: fixture screenshots belong to one test // revision/theme/viewport, never to a reusable build or runtime resource. // Includes plugin-ui-icons: signed runtime packages are never build-cache inputs. + // Includes agent-hook-library-state: user commands, native ownership journals and locks have no copy stage. + // Includes agent-instruction-library-state: user text and sync locks have no build identity or copy stage. + // Includes mcp-tool-probe-state: SDK sessions, native tool exclusions and + // retained Codex tool lists and in-memory snapshots belong to one user + // profile/page, never build reuse (including after server removal). + // Includes mcp-registry-metadata: responses have no copy stage; featured.json + // and McpIcons follow Git source, never a downloaded/generated resource validator. // Includes plugin-web-state: per-window/workspace user JSON has no build identity or copy stage. + // Includes remote-server-artifacts: packaging rebuilds them from this worktree's sources. + // Includes remote-connection-state: SSH settings, keychain secrets and placeholder roots are user-owned. // Includes plugin-disk-install-state: fileStorage temporary snapshots, install staging // and active/previous approvals are runtime-owned. Reject before resolving worktrees // or calling validatorArguments; a content digest is neither a build stamp nor consent. @@ -395,6 +406,7 @@ async function main() { // passing runtime preflight never admits marketplace or disk snapshots to a copy stage. // Per-launch Codex helpers also own memory-only context replay tickets and // loopback capabilities; they stay on this exclusion route, never a copy stage. + // Agent edit-and-paste state is user-owned runtime data, without a copy stage. if (excludedResources.some((resource) => resource.id === identifier)) { const excluded = excludedResources.find((resource) => resource.id === identifier); throw new Error(`Resource ${identifier} is isolated (${excluded.locations.join(", ")}): ${excluded.identity}; ${excluded.reason}; it cannot be reused across worktrees`); diff --git a/scripts/test-classify-ci-changes.sh b/scripts/test-classify-ci-changes.sh index 545ee97049..662c6ba473 100755 --- a/scripts/test-classify-ci-changes.sh +++ b/scripts/test-classify-ci-changes.sh @@ -23,21 +23,16 @@ mkdir -p \ Plugins/win/ExampleSupport \ macos/Sources/Lithe/Platform/MacOS/Plugins \ macos/Sources/Lithe/Views/Community \ - macos/Sources/Lithe/Views/Database \ macos/Sources/Lithe/Core/Rust \ macos/Sources/LitheCoreContracts \ - macos/Sources/LitheDatabaseModule \ Plugins/mac/Official/GoSupport/Sources/LitheGoSupportModule \ - macos/Tests/LitheDatabaseModuleTests \ Plugins/mac/Official/GoSupport/Tests/LitheGoSupportModuleTests \ macos/Tests/LitheTests \ macos/Sources/LitheGitModule \ macos/Tests/LitheGitPerformanceTests \ - infra/docker/database-validation \ rust/lithe-core/src/tests \ rust/lithe-core/src/lsp/languages \ rust/lithe-core/tests \ - rust/lithe-db-sidecar/src \ shared/fixtures/core \ windows/tauri/src \ windows/tauri/src-tauri/src @@ -45,22 +40,16 @@ printf '%s\n' '//! Test module.' 'pub fn value() -> u8 { 1 }' > rust/lithe-core/ printf '%s\n' '#[test]' 'fn source_test() { assert_eq!(1, 1); }' > rust/lithe-core/src/lsp/tests.rs printf '%s\n' 'pub fn normalize() {}' > rust/lithe-core/src/lsp/languages/java_tests.rs printf '%s\n' '#[test]' 'fn value_is_one() { assert_eq!(1, 1); }' > rust/lithe-core/tests/value.rs -printf '%s\n' 'fn main() {}' > rust/lithe-db-sidecar/src/main.rs -printf '%s\n' 'services: {}' > infra/docker/database-validation/compose.yaml printf '%s\n' '#!/bin/zsh' 'print -- package' > scripts/verify-macos-package.sh printf '%s\n' 'struct App {}' > macos/Sources/Lithe/App.swift printf '%s\n' 'struct RustCoreBridge {}' > macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift printf '%s\n' 'struct PluginManager {}' > macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginManager.swift -printf '%s\n' 'struct DatabaseView {}' > macos/Sources/Lithe/Views/Database/DatabaseView.swift printf '%s\n' 'struct CoreContracts {}' > macos/Sources/LitheCoreContracts/CoreContracts.swift -printf '%s\n' 'struct DatabaseModule {}' > macos/Sources/LitheDatabaseModule/DatabaseModule.swift printf '%s\n' 'struct GoSupportModule {}' > Plugins/mac/Official/GoSupport/Sources/LitheGoSupportModule/GoSupportModule.swift printf '%s\n' 'struct AppTests {}' > macos/Tests/LitheTests/AppTests.swift printf '%s\n' 'struct GitFeature {}' > macos/Sources/LitheGitModule/GitFeature.swift printf '%s\n' 'struct GitPerformanceTests {}' > macos/Tests/LitheGitPerformanceTests/GitPerformanceTests.swift printf '%s\n' 'struct PluginManagerTests {}' > macos/Tests/LitheTests/PluginManagerTests.swift -printf '%s\n' 'struct LitheCoreLogicTests {}' > macos/Tests/LitheTests/LitheCoreLogicTests.swift -printf '%s\n' 'struct DatabaseTests {}' > macos/Tests/LitheDatabaseModuleTests/DatabaseTests.swift printf '%s\n' 'struct GoSupportTests {}' > Plugins/mac/Official/GoSupport/Tests/LitheGoSupportModuleTests/GoSupportTests.swift printf '%s\n' '{"id":"dev.lithe.go-support"}' > Plugins/mac/Official/GoSupport/plugin.json printf '%s\n' '{"id":"dev.lithe.win.example-support"}' > Plugins/win/ExampleSupport/plugin.json @@ -76,23 +65,21 @@ BASE_REVISION="$(git rev-parse HEAD)" classification() { local swift="$1" local plugins="$2" - local swift_database="$3" - local rust_core="$4" - local rust_database="$5" - local macos_release="$6" - local windows="$7" - local windows_rust="$8" - local rust_comments="$9" - local metadata="${10}" - local git_validation="${11:-false}" - local java_jdt="${12:-false}" - local ci_scope="${13:-pull_request}" + local rust_core="$3" + local macos_release="$4" + local windows="$5" + local windows_rust="$6" + local rust_comments="$7" + local metadata="${8}" + local git_validation="${9:-false}" + local java_jdt="${10:-false}" + local ci_scope="${11:-pull_request}" + local rust_remote="${12:-false}" printf 'swift=%s\n' "$swift" printf 'plugins=%s\n' "$plugins" - printf 'swift_database=%s\n' "$swift_database" printf 'rust_core=%s\n' "$rust_core" - printf 'rust_database=%s\n' "$rust_database" + printf 'rust_remote=%s\n' "$rust_remote" printf 'macos_release=%s\n' "$macos_release" printf 'windows=%s\n' "$windows" printf 'windows_rust=%s\n' "$windows_rust" @@ -127,10 +114,15 @@ modify_readme() { printf '%s\n' '# Updated' > README.md; } modify_rust_comment() { printf '%s\n' '//! Updated test module.' 'pub fn value() -> u8 { 1 }' > rust/lithe-core/src/lib.rs; } modify_rust_code() { printf '%s\n' '//! Test module.' 'pub fn value() -> u8 { 2 }' > rust/lithe-core/src/lib.rs; } modify_rust_test() { printf '%s\n' '#[test]' 'fn value_is_two() { assert_eq!(2, 2); }' > rust/lithe-core/tests/value.rs; } -modify_plugin_sdk() { mkdir -p sdk/node/src && printf '%s\n' 'export const value = 2;' > sdk/node/src/index.mjs; } modify_rust_source_test() { printf '%s\n' '#[test]' 'fn source_test() { assert_eq!(2, 2); }' > rust/lithe-core/src/lsp/tests.rs; } -modify_database_rust() { printf '%s\n' 'fn main() { println!("updated"); }' > rust/lithe-db-sidecar/src/main.rs; } -modify_database_docker() { printf '%s\n' 'services:' ' mariadb: {}' > infra/docker/database-validation/compose.yaml; } +modify_remote_rust() { + mkdir -p rust/lithe-remote-server/src + printf '%s\n' '//! Remote server.' > rust/lithe-remote-server/src/lib.rs +} +modify_remote_workflow() { + mkdir -p .github/workflows + printf '%s\n' 'name: Remote Workspace CI' > .github/workflows/ci-remote.yml +} modify_macos_package_verifier() { printf '%s\n' '#!/bin/zsh' 'print -- updated-package' > scripts/verify-macos-package.sh; } modify_localization_packaging() { printf '%s\n' '#!/bin/zsh' 'print -- localizations' > scripts/package-macos-localizations.sh; } modify_macos_performance_baseline() { printf '%s\n' '#!/bin/zsh' 'print -- baseline' > scripts/measure-macos-performance-baseline.sh; } @@ -147,10 +139,6 @@ modify_windows_plugin_manifest() { printf '%s\n' '{"id":"dev.lithe.win.example-s modify_plugin_host_source() { printf '%s\n' 'struct UpdatedPluginManager {}' > macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginManager.swift; } modify_plugin_host_test() { printf '%s\n' 'struct UpdatedPluginManagerTests {}' > macos/Tests/LitheTests/PluginManagerTests.swift; } modify_shared_swift_contract() { printf '%s\n' 'struct UpdatedCoreContracts {}' > macos/Sources/LitheCoreContracts/CoreContracts.swift; } -modify_database_swift() { printf '%s\n' 'struct UpdatedDatabaseModule {}' > macos/Sources/LitheDatabaseModule/DatabaseModule.swift; } -modify_database_swift_test() { printf '%s\n' 'struct UpdatedDatabaseTests {}' > macos/Tests/LitheDatabaseModuleTests/DatabaseTests.swift; } -modify_database_app_source() { printf '%s\n' 'struct UpdatedDatabaseView {}' > macos/Sources/Lithe/Views/Database/DatabaseView.swift; } -modify_mixed_core_logic_test() { printf '%s\n' 'struct UpdatedLitheCoreLogicTests {}' > macos/Tests/LitheTests/LitheCoreLogicTests.swift; } modify_feature_matrix() { mkdir -p shared/platform-feature-matrix/features printf '%s\n' '{"id":"updated"}' > shared/platform-feature-matrix/features/example.json @@ -203,10 +191,6 @@ add_bundled_jdt_manifest() { } add_jdk_preparation_script() { printf '%s\n' '#!/bin/zsh' 'print -- jdk' > scripts/prepare-jdk.sh; } add_windows_java_source() { printf '%s\n' 'export const java = true;' > windows/tauri/src/java.ts; } -modify_swift_and_database() { - modify_swift_source - modify_database_swift -} rename_rust_to_markdown() { mkdir -p docs git mv rust/lithe-core/src/lib.rs docs/lib.md @@ -215,153 +199,136 @@ add_chinese_swift_source() { printf '%s\n' 'struct Example {}' > 'macos/Sources/ rename_swift_source() { git mv macos/Sources/Lithe/App.swift macos/Sources/Lithe/RenamedApp.swift } -rename_swift_into_database_module() { - git mv macos/Sources/Lithe/App.swift macos/Sources/LitheDatabaseModule/App.swift +rename_swift_into_contracts_module() { + git mv macos/Sources/Lithe/App.swift macos/Sources/LitheCoreContracts/App.swift } modify_rust_bridge_verifier() { printf '%s\n' '#!/bin/zsh' > scripts/verify-rust-core-swift-bridge.sh; } assert_classification readme \ - "$(classification false false false false false false false false false false)" \ + "$(classification false false false false false false false false)" \ modify_readme assert_classification rust-comment \ - "$(classification false false false false false false false false true false)" \ + "$(classification false false false false false false true false)" \ modify_rust_comment assert_classification rust-code \ - "$(classification false false false true false true true true false false)" \ + "$(classification false false true true true true false false)" \ modify_rust_code assert_classification rust-test \ - "$(classification false false false true false false false false false false)" \ + "$(classification false false true false false false false false)" \ modify_rust_test -assert_classification plugin-sdk \ - "$(classification false false false true false false false false false false)" \ - modify_plugin_sdk assert_classification rust-source-test \ - "$(classification false false false true false false false false false false)" \ + "$(classification false false true false false false false false)" \ modify_rust_source_test -assert_classification database-rust \ - "$(classification false false false false true true false false false false)" \ - modify_database_rust -assert_classification database-docker \ - "$(classification false false false false true false false false false false)" \ - modify_database_docker +# Remote workspace crates select only their own Linux lane. +assert_classification remote-rust \ + "$(classification false false false false false false false false false false pull_request true)" \ + modify_remote_rust +assert_classification remote-workflow \ + "$(classification false false false false false false false false false false pull_request true)" \ + modify_remote_workflow assert_classification macos-package-verifier \ - "$(classification false false false false false true false false false false)" \ + "$(classification false false false true false false false false)" \ modify_macos_package_verifier assert_classification swift-source \ - "$(classification true false false false false false false false false false)" \ + "$(classification true false false false false false false false)" \ modify_swift_source assert_classification swift-bridge \ - "$(classification true false false false false true false false false false)" \ + "$(classification true false false true false false false false)" \ modify_swift_bridge assert_classification git-source \ - "$(classification true false false false false false false false false false true)" \ + "$(classification true false false false false false false false true)" \ modify_git_source assert_classification git-performance-test \ - "$(classification true false false false false false false false false false true)" \ + "$(classification true false false false false false false false true)" \ modify_git_performance_test assert_classification localization-packaging \ - "$(classification true false false false false true false false false false)" \ + "$(classification true false false true false false false false)" \ modify_localization_packaging assert_classification macos-performance-baseline \ - "$(classification false false false false false true false false false false)" \ + "$(classification false false false true false false false false)" \ modify_macos_performance_baseline assert_classification unknown-script \ - "$(classification true true true true true true true true false false true true)" \ + "$(classification true true true true true true false false true true pull_request true)" \ modify_unknown_script assert_classification swift-test \ - "$(classification true false false false false false false false false false)" \ + "$(classification true false false false false false false false)" \ modify_swift_test assert_classification plugin-manifest \ - "$(classification false true false true false false false false false false)" \ + "$(classification false true true false false false false false)" \ modify_plugin_manifest assert_classification plugin-source \ - "$(classification false true false true false false false false false false)" \ + "$(classification false true true false false false false false)" \ modify_plugin_source assert_classification plugin-test \ - "$(classification false true false true false false false false false false)" \ + "$(classification false true true false false false false false)" \ modify_plugin_test assert_classification windows-plugin-manifest \ - "$(classification false false false false false false true true false false)" \ + "$(classification false false false false true true false false)" \ modify_windows_plugin_manifest assert_classification plugin-host-source \ - "$(classification true true false false false true false false false false)" \ + "$(classification true true false true false false false false)" \ modify_plugin_host_source assert_classification plugin-host-test \ - "$(classification false true false false false false false false false false)" \ + "$(classification false true false false false false false false)" \ modify_plugin_host_test assert_classification shared-swift-contract \ - "$(classification true true true false false true false false false false)" \ + "$(classification true true false true false false false false)" \ modify_shared_swift_contract -assert_classification database-swift \ - "$(classification false false true false false true false false false false)" \ - modify_database_swift -assert_classification database-swift-test \ - "$(classification false false true false false false false false false false)" \ - modify_database_swift_test -assert_classification database-app-source \ - "$(classification false false true false false true false false false false)" \ - modify_database_app_source -assert_classification mixed-core-logic-test \ - "$(classification true false true false false false false false false false)" \ - modify_mixed_core_logic_test -assert_classification swift-and-database \ - "$(classification true false true false false true false false false false)" \ - modify_swift_and_database assert_classification shared-fixture \ - "$(classification true false false true false false true true false false)" \ + "$(classification true false true false true true false false)" \ modify_shared_fixture assert_classification feature-matrix \ - "$(classification false false false false false false false false false false)" \ + "$(classification false false false false false false false false)" \ modify_feature_matrix assert_classification feature-matrix-with-swift \ - "$(classification true false false false false false false false false false)" \ + "$(classification true false false false false false false false)" \ modify_feature_matrix_and_swift assert_classification shared-editor \ - "$(classification false false false false false true true false false false)" \ + "$(classification false false false true true false false false)" \ modify_shared_editor assert_classification workbench-view \ - "$(classification true false true false false true false false false false)" \ + "$(classification true false false true false false false false)" \ modify_workbench_view assert_classification localization-strings \ - "$(classification true false true false false true false false false false)" \ + "$(classification true false false true false false false false)" \ modify_localization_strings assert_classification windows-frontend \ - "$(classification false false false false false false true false false false)" \ + "$(classification false false false false true false false false)" \ modify_windows_frontend assert_classification windows-rust \ - "$(classification false false false false false false true true false false)" \ + "$(classification false false false false true true false false)" \ modify_windows_rust # Swift Java adapters compile in the Swift lane; only the shared engine and # its Windows consumers select the separate real-JDT integration lane. assert_classification macos-java-source \ - "$(classification true false false false false false false false false false)" \ + "$(classification true false false false false false false false)" \ add_macos_java_source assert_classification macos-java-test \ - "$(classification true false false false false false false false false false)" \ + "$(classification true false false false false false false false)" \ add_macos_java_test assert_classification java-jdt \ - "$(classification false false false true false true true true false false false true)" \ + "$(classification false false true true true true false false false true)" \ modify_java_jdt assert_classification shared-jdt-engine \ - "$(classification false false false true false true true true false false false true)" \ + "$(classification false false true true true true false false false true)" \ add_shared_jdt_engine assert_classification bundled-jdt-manifest \ - "$(classification true true true true true true true true false false true true)" \ + "$(classification true true true true true true false false true true pull_request true)" \ add_bundled_jdt_manifest assert_classification jdk-preparation \ - "$(classification false false false false false true false false false false false true)" \ + "$(classification false false false true false false false false false true)" \ add_jdk_preparation_script assert_classification windows-java-source \ - "$(classification false false false false false false true false false false false true)" \ + "$(classification false false false false true false false false false true)" \ add_windows_java_source assert_classification download-cache-validator \ - "$(classification true true true true false true true true false false)" \ + "$(classification true true true true true true false false)" \ modify_download_cache_validator assert_classification worktree-resource-registry \ - "$(classification true true true true false true true true false false)" \ + "$(classification true true true true true true false false)" \ modify_worktree_resource_registry assert_classification test-stability-runner \ - "$(classification true true true true false false true true false false true)" \ + "$(classification true true true false true true false false true)" \ modify_test_stability_runner modify_swift_version() { printf '%s\n' '6.3.3' > .swift-version; } modify_macos_toolchain_action() { @@ -369,28 +336,28 @@ modify_macos_toolchain_action() { printf '%s\n' 'name: Toolchain fixture' > .github/actions/setup-macos-toolchain/action.yml } assert_classification swift-version \ - "$(classification true true true true false true false false false false true)" \ + "$(classification true true true true false false false false true)" \ modify_swift_version assert_classification macos-toolchain-action \ - "$(classification true true true true false true false false false false true)" \ + "$(classification true true true true false false false false true)" \ modify_macos_toolchain_action assert_classification macos-cache-action \ - "$(classification true true true true false true false false false false true)" \ + "$(classification true true true true false false false false true)" \ modify_macos_cache_action assert_classification macos-cache-save-action \ - "$(classification true true true true false true false false false false true)" \ + "$(classification true true true true false false false false true)" \ modify_macos_cache_save_action assert_classification metadata \ - "$(classification false false false false false false false false false true)" \ + "$(classification false false false false false false false true)" \ modify_metadata assert_classification classifier \ - "$(classification true true true true true true true true false false true true)" \ + "$(classification true true true true true true false false true true pull_request true)" \ modify_classifier LITHE_CI_DRAFT_PULL_REQUEST=true assert_classification draft-defers-runner-lanes \ - "$(classification false false false false true false false false false false false false)" \ + "$(classification false false false false false false false false false false pull_request true)" \ modify_unknown_script LITHE_CI_DRAFT_PULL_REQUEST=true assert_classification draft-keeps-linux-lanes \ - "$(classification false false false false false false false false true false)" \ + "$(classification false false false false false false true false)" \ modify_rust_comment # A rename classifies both endpoints instead of selecting every lane: the old # Rust source still selects Rust Core, packaging, and Windows Rust. @@ -403,42 +370,42 @@ ONLY_PATHS_DIR="$(mktemp -d)" trap 'rm -rf "$TEST_ROOT" "$ONLY_PATHS_DIR"' EXIT printf '%s\n' 'macos/Sources/Lithe/App.swift' > "$ONLY_PATHS_DIR/only-swift.txt" LITHE_CI_ONLY_PATHS="$ONLY_PATHS_DIR/only-swift.txt" assert_classification only-paths-limits-classification \ - "$(classification true false false false false false false false false false)" \ + "$(classification true false false false false false false false)" \ modify_swift_and_rust assert_classification chinese-swift-source-keeps-its-raw-path \ - "$(classification true false false false false false false false false false)" \ + "$(classification true false false false false false false false)" \ add_chinese_swift_source printf '%s\n' 'macos/Sources/Lithe/示例 "quoted".swift' > "$ONLY_PATHS_DIR/only-chinese.txt" LITHE_CI_ONLY_PATHS="$ONLY_PATHS_DIR/only-chinese.txt" assert_classification only-paths-matches-raw-chinese-path \ - "$(classification true false false false false false false false false false)" \ + "$(classification true false false false false false false false)" \ add_chinese_swift_source : > "$ONLY_PATHS_DIR/only-none.txt" LITHE_CI_ONLY_PATHS="$ONLY_PATHS_DIR/only-none.txt" assert_classification only-paths-empty-selects-nothing \ - "$(classification false false false false false false false false false false)" \ + "$(classification false false false false false false false false)" \ modify_swift_and_rust LITHE_CI_SCOPE=post_merge assert_classification post-merge-builds-only-the-package \ - "$(classification false false false false false true false false false false false false post_merge)" \ + "$(classification false false false true false false false false false false post_merge)" \ modify_swift_source LITHE_CI_SCOPE=post_merge assert_classification post-merge-skips-windows-only-changes \ - "$(classification false false false false false false false false false false false false post_merge)" \ + "$(classification false false false false false false false false false false post_merge)" \ modify_windows_frontend LITHE_CI_SCOPE=post_merge assert_classification post-merge-skips-documentation \ - "$(classification false false false false false false false false false false false false post_merge)" \ + "$(classification false false false false false false false false false false post_merge)" \ modify_readme LITHE_CI_SCOPE=release_promotion assert_classification release-promotion-reruns-nothing \ - "$(classification false false false false false false false false false false false false release_promotion)" \ + "$(classification false false false false false false false false false false release_promotion)" \ modify_classifier assert_classification rename-rust-to-markdown \ - "$(classification false false false true false true true true false false)" \ + "$(classification false false true true true true false false)" \ rename_rust_to_markdown assert_classification rename-swift-source \ - "$(classification true false false false false false false false false false)" \ + "$(classification true false false false false false false false)" \ rename_swift_source assert_classification rename-swift-across-modules \ - "$(classification true false true false false true false false false false)" \ - rename_swift_into_database_module + "$(classification true true false true false false false false)" \ + rename_swift_into_contracts_module assert_classification rust-bridge-verifier \ - "$(classification false false false true false true false false false false)" \ + "$(classification false false true true false false false false)" \ modify_rust_bridge_verifier printf '%s\n' 'CI change classifier tests passed' diff --git a/scripts/test-i18n.mjs b/scripts/test-i18n.mjs new file mode 100644 index 0000000000..ae77deca84 --- /dev/null +++ b/scripts/test-i18n.mjs @@ -0,0 +1,472 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { cp, mkdir, mkdtemp, readFile, writeFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { + ROOT, + generate, + loadContract, + nativeTemplate, + parseJSON, + planGeneration, + resolveMessage, +} from "./generate-i18n.mjs"; + +test("locale titles remain literal in generated Swift source", { timeout: 15000 }, async (t) => { + const root = await fixture(t); + await edit(root, "shared/i18n/manifest.json", (manifest) => { + manifest.locales[0].title = 'English \\(user)\u2028"line"\n'; + }); + const { outputs } = await planGeneration(root); + const source = outputs.get("macos/Sources/Lithe/Models/Settings/AppLanguage.generated.swift"); + assert.ok(source.includes('English \\\\(user)\\u{2028}\\"line\\"\\n')); +}); + +async function fixture(t) { + const root = await mkdtemp(path.join(os.tmpdir(), "lithe-i18n-")); + t.after(() => rm(root, { recursive: true, force: true })); + for (const relative of [ + "shared/i18n", + "macos/Resources/en.lproj", + "macos/Resources/zh-Hans.lproj", + "windows/tauri/src/i18n", + ]) { + await mkdir(path.dirname(path.join(root, relative)), { recursive: true }); + await cp(path.join(ROOT, relative), path.join(root, relative), { recursive: true }); + } + await mkdir(path.join(root, "scripts"), { recursive: true }); + for (const name of ["generate-i18n.mjs", "package-macos-localizations.sh"]) + await cp(path.join(ROOT, "scripts", name), path.join(root, "scripts", name)); + await generate(root); + return root; +} +async function edit(root, file, change) { + const target = path.join(root, file); + const json = JSON.parse(await readFile(target, "utf8")); + change(json); + await writeFile(target, `${JSON.stringify(json, null, 2)}\n`); +} +async function addLocale(root) { + await edit(root, "shared/i18n/manifest.json", (manifest) => + manifest.locales.push({ + id: "ja-JP", + title: "日本語", + macos: "ja", + swiftCase: "japanese", + fallback: "zh-CN", + coverage: "fallback", + }), + ); + await writeFile( + path.join(root, "shared/i18n/locales/ja-JP.json"), + JSON.stringify({ messages: { "ui.save": "保存する" } }), + ); +} + +test( + "checked-in generation is current and checking never changes bytes", + { timeout: 15000 }, + async () => { + const { outputs } = await planGeneration(); + await generate(ROOT, true); + for (const [relative, expected] of outputs) + assert.equal(await readFile(path.join(ROOT, relative), "utf8"), expected); + }, +); + +test( + "Git Windows checkout preserves generated bytes with autocrlf enabled (integration)", + { timeout: 15000 }, + async (t) => { + const root = await fixture(t); + await cp(path.join(ROOT, ".gitattributes"), path.join(root, ".gitattributes")); + const { outputs } = await planGeneration(root); + for (const args of [ + ["init", "--quiet"], + ["config", "core.autocrlf", "true"], + ["add", "--", ".gitattributes", ...outputs.keys()], + ["checkout-index", "--all", "--force"], + ]) { + const result = spawnSync("git", args, { cwd: root, encoding: "utf8", timeout: 5000 }); + assert.equal(result.status, 0, `${result.error ?? ""}\n${result.stderr}`); + } + await generate(root, true); + for (const [relative, expected] of outputs) + assert.equal(await readFile(path.join(root, relative), "utf8"), expected); + }, +); + +test( + "duplicate JSON keys include escaped spellings and nested overrides", + { timeout: 1000 }, + () => { + assert.throws( + () => parseJSON('{"ui.save":1,"ui.\\u0073ave":2}', "fixture"), + /duplicate JSON key ui.save/, + ); + assert.throws( + () => parseJSON('{"overrides":{"macos":{"x":1,"x":2}}}', "fixture"), + /duplicate JSON key x/, + ); + assert.deepEqual(parseJSON('{"a":{"x":1},"b":{"x":2},"c":["x",{}]}', "fixture"), { + a: { x: 1 }, + b: { x: 2 }, + c: ["x", {}], + }); + }, +); + +test( + "generation rejects manual native translations and detects stale outputs", + { timeout: 15000 }, + async (t) => { + const root = await fixture(t); + const table = path.join(root, "macos/Resources/en.lproj/Localizable.strings"); + await writeFile( + table, + `"Unmigrated fixture" = "Original value";\n${await readFile(table, "utf8")}`, + ); + await assert.rejects(generate(root), /native translations must be maintained in shared/); + await writeFile( + table, + (await readFile(table, "utf8")).replace('"Unmigrated fixture" = "Original value";\n', ""), + ); + await generate(root); + const generated = path.join(root, "windows/tauri/src/i18n/shared.generated.ts"); + await writeFile(generated, "outdated"); + await assert.rejects(generate(root, true), /generated files are stale/); + assert.equal(await readFile(generated, "utf8"), "outdated"); + await generate(root); + const { outputs } = await planGeneration(root); + await generate(root); + for (const [relative, expected] of outputs) + assert.equal(await readFile(path.join(root, relative), "utf8"), expected); + await writeFile(table, `"Save" = "Hand edited";\n${await readFile(table, "utf8")}`); + await assert.rejects(generate(root), /native translations must be maintained in shared/); + }, +); + +test( + "new locale supplies settings options, Swift cases, tables and explicit fallback without product edits", + { timeout: 15000 }, + async (t) => { + const root = await fixture(t); + await addLocale(root); + await generate(root); + await generate(root, true); + const contract = await loadContract(root); + assert.equal(resolveMessage(contract, "ja-JP", "macos", "ui.save"), "保存する"); + assert.equal(resolveMessage(contract, "ja-JP", "windows", "ui.cancel"), "取消"); + assert.equal( + resolveMessage(contract, "ja-JP", "windows", "files.reveal"), + "在资源管理器中显示", + ); + assert.match( + await readFile(path.join(root, "macos/Resources/ja.lproj/Localizable.strings"), "utf8"), + /"Save" = "保存する";/, + ); + assert.match( + await readFile( + path.join(root, "macos/Sources/Lithe/Models/Settings/AppLanguage.generated.swift"), + "utf8", + ), + /case japanese = "ja"/, + ); + assert.match( + await readFile(path.join(root, "windows/tauri/src/i18n/shared.generated.ts"), "utf8"), + /"value": "ja-JP"/, + ); + }, +); + +test( + "typed formats follow the shared native fixture for reordering and repetition", + { timeout: 1000 }, + async () => { + const fixture = JSON.parse( + await readFile(path.join(ROOT, "shared/fixtures/i18n/format-v1.json"), "utf8"), + ); + for (const item of fixture.cases) + assert.equal(nativeTemplate(item.template, fixture, fixture.arguments), item.nativeTemplate); + assert.equal( + nativeTemplate("literal %1$ {name}", { parameters: { name: "string" } }, ["name"], false), + "literal %%1$ %@", + ); + }, +); + +const invalidCases = [ + ...["inout", "allCases"].map((name) => [ + `Swift case colliding with ${name}`, + "shared/i18n/manifest.json", + (data) => (data.locales[0].swiftCase = name), + /invalid Swift case/, + ]), + [ + "out-of-range native argument position", + "shared/i18n/messages.json", + (data) => { + const message = data.messages.find( + (item) => item.macos?.arguments.length === 1 && + Object.values(item.parameters)[0] === "string", + ); + message.macos.key = "Fixture out of range %2$@"; + }, + /alias format/, + ], + [ + "duplicate semantic id", + "shared/i18n/messages.json", + (data) => data.messages.push(data.messages[0]), + /unique|duplicate/, + ], + [ + "duplicate native alias", + "shared/i18n/messages.json", + (data) => + (data.messages.find((m) => m.id === "ui.open").macos = data.messages.find( + (m) => m.id === "ui.save", + ).macos), + /duplicate macOS alias/, + ], + [ + "unsafe locale path", + "shared/i18n/manifest.json", + (data) => (data.locales[1].macos = "../escape"), + /invalid macOS locale/, + ], + [ + "case insensitive locale collision", + "shared/i18n/manifest.json", + (data) => (data.locales[1].macos = "EN"), + /invalid macOS locale|unique/, + ], + [ + "unknown default", + "shared/i18n/manifest.json", + (data) => (data.defaultLocale = "xx-XX"), + /unknown default/, + ], + [ + "invalid platform default", + "shared/i18n/manifest.json", + (data) => (data.platformDefaults.windows = "xx-XX"), + /unknown windows default/, + ], + [ + "fallback cycle", + "shared/i18n/manifest.json", + (data) => (data.locales[1].fallback = "zh-CN"), + /fallback cycle/, + ], + [ + "unknown fallback", + "shared/i18n/manifest.json", + (data) => (data.locales[1].fallback = "xx-XX"), + /fallback must reach/, + ], + [ + "unknown platform", + "shared/i18n/messages.json", + (data) => (data.messages[0].platforms = ["linux"]), + /invalid platform/, + ], + [ + "missing translation", + "shared/i18n/locales/zh-CN.json", + (data) => delete data.messages["ui.cancel"], + /incomplete translation/, + ], + [ + "unknown translation", + "shared/i18n/locales/en-US.json", + (data) => (data.messages["ui.typo"] = "Typo"), + /unknown key/, + ], + [ + "wrong placeholder", + "shared/i18n/locales/zh-CN.json", + (data) => (data.messages["quickOpen.filesCount"] = "{amount} 文件"), + /placeholder/, + ], + [ + "malformed placeholder", + "shared/i18n/locales/zh-CN.json", + (data) => (data.messages["quickOpen.filesCount"] = "{{count}}"), + /malformed placeholder/, + ], + [ + "unsupported native format", + "shared/i18n/messages.json", + (data) => (data.messages.find((m) => m.id === "quickOpen.filesCount").macos.key = "%n files"), + /alias format/, + ], + [ + "missing alias argument", + "shared/i18n/messages.json", + (data) => (data.messages.find((m) => m.id === "quickOpen.filesCount").macos.arguments = []), + /alias arguments differ/, + ], + [ + "undocumented override", + "shared/i18n/locales/en-US.json", + (data) => delete data.overrides.windows["files.reveal"].reason, + /override reason/, + ], + [ + "override outside scope", + "shared/i18n/messages.json", + (data) => { + const m = data.messages.find((m) => m.id === "files.reveal"); + m.platforms = ["macos"]; + }, + /override outside scope/, + ], + [ + "invalid override parameter", + "shared/i18n/locales/en-US.json", + (data) => + (data.overrides.windows["quickOpen.filesCount"] = { value: "{name}", reason: "fixture" }), + /placeholder/, + ], + [ + "typo in registry field", + "shared/i18n/manifest.json", + (data) => (data.defaultLocle = "en-US"), + /unknown field/, + ], +]; +for (const [name, file, mutation, error] of invalidCases) + test(`rejects ${name} before writing any output`, { timeout: 15000 }, async (t) => { + const root = await fixture(t); + const target = path.join(root, "windows/tauri/src/i18n/shared.generated.ts"); + const before = await readFile(target, "utf8"); + await edit(root, file, mutation); + await assert.rejects(generate(root), error); + assert.equal(await readFile(target, "utf8"), before); + }); + +test( + "platform-only messages are emitted only for their declared consumer", + { timeout: 15000 }, + async (t) => { + const root = await fixture(t); + await edit(root, "shared/i18n/messages.json", (data) => + data.messages.push({ + id: "fixture.windows", + description: "Windows only fixture.", + platforms: ["windows"], + }), + ); + for (const locale of ["en-US", "zh-CN"]) + await edit( + root, + `shared/i18n/locales/${locale}.json`, + (data) => (data.messages["fixture.windows"] = "Windows only"), + ); + const { outputs } = await planGeneration(root); + assert.match(outputs.get("windows/tauri/src/i18n/shared.generated.ts"), /fixture.windows/); + assert.ok( + !outputs.get("macos/Resources/en.lproj/Localizable.strings").includes("fixture.windows"), + ); + }, +); + +test( + "a migrated Windows key cannot be silently shadowed by legacy catalogs", + { timeout: 15000 }, + async (t) => { + const root = await fixture(t); + await writeFile( + path.join(root, "windows/tauri/src/i18n/accidental.ts"), + 'const entries = {\n "ui.save": "Changed manually",\n};\n', + ); + await assert.rejects(generate(root), /migrated key still manually defined: ui.save/); + }, +); + +test( + "unregistered locale files and broken generated regions fail explicitly", + { timeout: 15000 }, + async (t) => { + const root = await fixture(t); + await writeFile(path.join(root, "shared/i18n/locales/fr-FR.json"), "{}"); + await assert.rejects(generate(root), /locale files differ/); + await rm(path.join(root, "shared/i18n/locales/fr-FR.json")); + const table = path.join(root, "macos/Resources/en.lproj/Localizable.strings"); + await writeFile( + table, + (await readFile(table, "utf8")).replace("/* END GENERATED SHARED I18N */", ""), + ); + await assert.rejects(generate(root), /invalid generated region/); + }, +); + +test( + "macOS packaging discovers new locales and preserves the source baseline", + { timeout: 15000, skip: process.platform !== "darwin" }, + async (t) => { + const root = await fixture(t); + await addLocale(root); + await generate(root); + const { outputs } = await planGeneration(root); + const script = path.join(root, "scripts/package-macos-localizations.sh"); + const destination = path.join(root, "packaged.bundle/Contents/Resources"); + for (let attempt = 0; attempt < 2; attempt++) { + const result = spawnSync( + "/bin/zsh", + [script, path.join(root, "macos/Resources"), destination], + { encoding: "utf8", timeout: 5000 }, + ); + assert.equal(result.status, 0, `${result.error ?? ""}\n${result.stderr}`); + } + for (const id of ["en", "zh-Hans", "ja"]) { + const packaged = await readFile(path.join(destination, `${id}.lproj/Localizable.strings`)); + assert.equal(packaged.subarray(0, 8).toString(), "bplist00"); + } + for (const [relative, expected] of outputs) + assert.equal(await readFile(path.join(root, relative), "utf8"), expected); + }, +); + +test( + "native aliases require complete positions and reject mixed or repeated slots", + { timeout: 15000 }, + async (t) => { + const root = await fixture(t); + await edit(root, "shared/i18n/messages.json", (data) => data.messages.push({ + id: "fixture.positionedAlias", + description: "Native argument-position validation fixture.", + platforms: ["macos"], + parameters: { name: "string", count: "integer" }, + macos: { key: "%2$lld / %1$@", arguments: ["name", "count"] }, + })); + for (const locale of ["en-US", "zh-CN"]) + await edit(root, `shared/i18n/locales/${locale}.json`, (data) => { + data.messages["fixture.positionedAlias"] = "{name} / {count}"; + }); + await generate(root); + for (const key of ["%1$@ / %lld", "%1$@ / %1$@", "%1$@ / %3$lld"]) { + await edit(root, "shared/i18n/messages.json", (data) => { + data.messages.find((message) => message.id === "fixture.positionedAlias").macos.key = key; + }); + await assert.rejects(generate(root), /alias format/); + } + }, +); + +test( + "native ABI preserves 32-bit signed and 64-bit unsigned formats", + { timeout: 1000 }, + async () => { + const fixture = JSON.parse( + await readFile(path.join(ROOT, "shared/fixtures/i18n/native-abi-v1.json"), "utf8"), + ); + assert.equal( + nativeTemplate(fixture.template, fixture, fixture.arguments), + fixture.nativeTemplate, + ); + }, +); diff --git a/scripts/test-macos.sh b/scripts/test-macos.sh index d789b93165..47a73069c3 100755 --- a/scripts/test-macos.sh +++ b/scripts/test-macos.sh @@ -20,6 +20,8 @@ if [[ "$LITHE_TEST_WINDOW_MODE" == isolated && "${LITHE_RUN_GIT_COMPOSITOR_TESTS exit 2 fi +node "$ROOT_DIR/scripts/generate-i18n.mjs" --check + "$ROOT_DIR/scripts/verify-runtime-bundle-immutability.sh" SWIFT_ARGS=( diff --git a/scripts/test-reuse-worktree-resources.mjs b/scripts/test-reuse-worktree-resources.mjs index 5baaddc6d6..0bdabe9e56 100644 --- a/scripts/test-reuse-worktree-resources.mjs +++ b/scripts/test-reuse-worktree-resources.mjs @@ -64,6 +64,18 @@ function reuse(extraArguments = []) { } try { + await test("shared translations are regenerated locally rather than reused", { timeout: 15000 }, async () => { + const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); + const resource = registry.excludedResources.find(item => item.id === "shared-i18n"); + assert.equal(resource.reusable, false); + assert.ok(resource.locations.includes("windows/tauri/src/i18n/shared.generated.ts")); + const listed = run(process.execPath, [reuseScript, "--list"]); + assertSucceeded(listed); + assert.ok(!listed.stdout.includes(resource.id)); + const refused = reuse(["--resource", resource.id]); + assert.notEqual(refused.status, 0); + assert.match(diagnostics(refused), /shared-i18n.*cannot be reused/); + }); await test("distribution runtime state cannot be listed or imported as a build cache", { timeout: 15000 }, async () => { const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); const state = registry.excludedResources.find(item => item.id === "distribution-runtime-state"); @@ -205,6 +217,11 @@ try { assert.notEqual(refused.status, 0); assert.match(diagnostics(refused), /agent-history-metadata.*lithe\.agent-history\.v1.*cannot be reused/); }); + await test("Agent attachments and recoverable edits cannot enter a worktree copy stage", { timeout: 15000 }, () => { + const refused = reuse(["--resource", "agent-edit-and-paste-state"]); + assert.notEqual(refused.status, 0); + assert.match(diagnostics(refused), /agent-edit-and-paste-state.*pasted-images.*recovery.*cannot be reused/); + }); await test("JDT Maven settings snapshots cannot be copied between worktrees", { timeout: 15000 }, () => { const refused = reuse(["--resource", "jdt-maven-settings"]); assert.notEqual(refused.status, 0); @@ -225,13 +242,34 @@ try { await test("plugin packages and marketplace state stay isolated in each worktree", { timeout: 15000 }, () => { const listed = run(process.execPath, [reuseScript, "--list"]); assertSucceeded(listed); - for (const id of ["official-plugin-packages", "plugin-marketplace-state"]) { + for (const id of ["official-plugin-packages", "plugin-marketplace-state", "mcp-registry-metadata", "mcp-tool-probe-state", "agent-instruction-library-state", "agent-hook-library-state"]) { assert.ok(!listed.stdout.includes(id)); const refused = reuse(["--resource", id]); assert.notEqual(refused.status, 0); assert.match(diagnostics(refused), new RegExp(`${id}.*cannot be reused`)); } }); + await test("retained MCP filters and native review screenshots remain user/run state", { timeout: 15000 }, async () => { + const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); + const policies = registry.excludedResources.find(item => item.id === "mcp-tool-probe-state"); + assert.ok(policies.locations.some(location => location.includes("settings.mcpServerRegistrations") && location.endsWith(".toolFilters"))); + const evidence = registry.excludedResources.find(item => item.id === "agent-ui-test-evidence"); + assert.ok(evidence.locations.includes(".artifacts/test-stability/{mcp-ui,instructions-ui}/*.png")); + for (const resource of [policies, evidence]) { + const refused = reuse(["--resource", resource.id]); + assert.notEqual(refused.status, 0); + assert.match(diagnostics(refused), new RegExp(`${resource.id}.*cannot be reused`)); + } + }); + await test("featured catalog and bundled MCP marks follow Git source instead of resource reuse", { timeout: 15000 }, async () => { + const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); + const row = registry.excludedResources.find(item => item.id === "mcp-registry-metadata"); + assert.ok(row.locations.includes("macos/Sources/Lithe/Resources/McpIcons")); + assert.ok(row.locations.includes("rust/lithe-core/src/mcp/featured.json")); + const refused = reuse(["--resource", row.id]); + assert.notEqual(refused.status, 0); + assert.match(diagnostics(refused), /mcp-registry-metadata.*cannot be reused/); + }); await test("local disk snapshots and approvals are rejected before any worktree access", { timeout: 15000 }, async () => { const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); const resource = registry.excludedResources.find(item => item.id === "plugin-disk-install-state"); diff --git a/scripts/test-windows-agent-recycle.ps1 b/scripts/test-windows-agent-recycle.ps1 new file mode 100644 index 0000000000..61365aa5de --- /dev/null +++ b/scripts/test-windows-agent-recycle.ps1 @@ -0,0 +1,118 @@ +[CmdletBinding()] +param([ValidateRange(15, 1200)][int]$BuildTimeoutSeconds = 600) + +# Opt-in native acceptance: never empty the user's Recycle Bin. The driver owns +# one unique workspace and restores only entries whose original parent is it. +$ErrorActionPreference = "Stop" +if (-not $IsWindows) { throw "This acceptance requires an interactive Windows session." } +$root = Split-Path -Parent $PSScriptRoot +$reportRoot = Join-Path $root ".artifacts/test-stability/windows-agent-recycle" +New-Item -ItemType Directory -Path $reportRoot -Force | Out-Null + +function Invoke-BoundedProcess([string]$Executable, [string[]]$Arguments, [int]$TimeoutMs) { + $info = [System.Diagnostics.ProcessStartInfo]::new($Executable) + $info.WorkingDirectory = $root + $info.UseShellExecute = $false + $info.CreateNoWindow = $true + $info.RedirectStandardOutput = $true + $info.RedirectStandardError = $true + foreach ($argument in $Arguments) { $info.ArgumentList.Add($argument) } + $process = [System.Diagnostics.Process]::new() + $process.StartInfo = $info + $started = $false + try { + $started = $process.Start() + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit($TimeoutMs)) { + throw "Native acceptance process exceeded $TimeoutMs ms." + } + if (-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout, $stderr), 5000)) { + throw "Native acceptance output did not close within 5 seconds." + } + $output = $stdout.GetAwaiter().GetResult() + $diagnostics = $stderr.GetAwaiter().GetResult() + if ($process.ExitCode -ne 0) { throw "$output`n$diagnostics" } + return $output + } finally { + try { + if ($started -and -not $process.HasExited) { + $process.Kill($true) + if (-not $process.WaitForExit(5000)) { throw "Native acceptance process survived cleanup." } + } + } finally { + $process.Dispose() + } + } +} + +$compiled = Invoke-BoundedProcess "cargo" @("test", "--manifest-path", "windows/tauri/src-tauri/Cargo.toml", "--package", "lithe-project", "--lib", "--no-run", "--message-format=json") ($BuildTimeoutSeconds * 1000) +$binary = @($compiled -split "`n" | ForEach-Object { if ($_.Trim()) { $_ | ConvertFrom-Json } } | Where-Object { + $_.reason -eq "compiler-artifact" -and $_.profile.test -and $_.executable -and $_.target.name -eq "lithe_project" +} | Select-Object -ExpandProperty executable) +if ($binary.Count -ne 1) { throw "Expected one lithe-project unit-test binary." } + +$workspace = Join-Path ([IO.Path]::GetTempPath()) ("lithe-agent-recycle-acceptance-" + [guid]::NewGuid().ToString("N")) +New-Item -ItemType Directory -Path $workspace | Out-Null +$previousRoot = $env:LITHE_AGENT_RECYCLE_ACCEPTANCE_ROOT +$env:LITHE_AGENT_RECYCLE_ACCEPTANCE_ROOT = $workspace +$shell = $null +$bin = $null +$timer = [Diagnostics.Stopwatch]::StartNew() +$outcome = "passed" +$details = "" +try { + $shell = New-Object -ComObject Shell.Application + $bin = $shell.NameSpace(10) + if ($null -eq $bin) { throw "The Windows Recycle Bin namespace is unavailable." } + Invoke-BoundedProcess $binary[0] @("document_file::tests::agent_recycle_real_shell_acceptance", "--exact", "--ignored", "--nocapture") 15000 | + Set-Content -LiteralPath (Join-Path $reportRoot "native.log") -Encoding utf8 + $owned = @($bin.Items() | Where-Object { $_.ExtendedProperty("System.Recycle.DeletedFrom") -eq $workspace }) + if ($owned.Count -ne 1) { throw "Expected exactly one recoverable fixture in the real Recycle Bin." } + if ([IO.File]::ReadAllText($owned[0].Path) -ne "agent recycle acceptance") { throw "Recycled fixture bytes changed." } + $details = "Verbatim workspace path recycled by SHFileOperationW; one real Recycle Bin entry retained the expected bytes." +} catch { + $outcome = "failed" + $details = $_.ToString() + throw +} finally { + try { + $owned = if ($bin) { @($bin.Items() | Where-Object { $_.ExtendedProperty("System.Recycle.DeletedFrom") -eq $workspace }) } else { @() } + foreach ($item in $owned) { + $restoreName = $item.Name + $recycledPath = $item.Path + $item.InvokeVerb("undelete") + $deadline = [Diagnostics.Stopwatch]::StartNew() + # Shell verbs complete asynchronously; poll the owned native entry, + # with a local deadline, rather than assuming a delay means success. + while (Test-Path -LiteralPath $recycledPath) { + if ($deadline.ElapsedMilliseconds -ge 10000) { throw "Fixture restore exceeded 10 seconds: $restoreName" } + Start-Sleep -Milliseconds 25 + } + $restored = @(Get-ChildItem -LiteralPath $workspace -File -Force) + if (-not @($restored | Where-Object { [IO.File]::ReadAllText($_.FullName) -eq "agent recycle acceptance" }).Count) { + throw "The fixture was not restored before cleanup." + } + } + $resolved = [IO.Path]::GetFullPath($workspace) + if ([IO.Path]::GetDirectoryName($resolved) -ne [IO.Path]::GetTempPath().TrimEnd('\') -or + -not [IO.Path]::GetFileName($resolved).StartsWith("lithe-agent-recycle-acceptance-")) { + throw "Refusing cleanup outside the owned temporary workspace." + } + Remove-Item -LiteralPath $resolved -Recurse -Force + } catch { + $outcome = "failed" + $details += " Cleanup: $($_.ToString()); workspace: $workspace" + throw + } finally { + $env:LITHE_AGENT_RECYCLE_ACCEPTANCE_ROOT = $previousRoot + if ($bin) { [Runtime.InteropServices.Marshal]::FinalReleaseComObject($bin) | Out-Null } + if ($shell) { [Runtime.InteropServices.Marshal]::FinalReleaseComObject($shell) | Out-Null } + @{ schemaVersion = 1; runner = "rust"; package = "lithe-project-native-shell"; warnMs = 5000; maxMs = 30000; + tests = @(@{ name = "agent_recycle_real_shell_acceptance"; target = "lithe-project"; status = $outcome; durationMs = $timer.ElapsedMilliseconds; details = $details }) + } | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath (Join-Path $reportRoot "acceptance.json") -Encoding utf8NoBOM + & node (Join-Path $root ".agents/skills/write-stable-tests/scripts/generate-test-report.mjs") --input-dir $reportRoot + if ($LASTEXITCODE -ne 0) { throw "Native acceptance report generation failed." } + } +} +Write-Output "Windows Agent Recycle Bin acceptance passed; fixture restored and cleaned." diff --git a/scripts/verify-macos-package.sh b/scripts/verify-macos-package.sh index 571f789de3..9576163936 100755 --- a/scripts/verify-macos-package.sh +++ b/scripts/verify-macos-package.sh @@ -139,8 +139,6 @@ required_executables=( "$app_path/Contents/Frameworks/Sparkle.framework/Sparkle" "$app_path/Contents/Frameworks/Sparkle.framework/Versions/B/Autoupdate" "$app_path/Contents/Frameworks/Sparkle.framework/Versions/B/Updater.app/Contents/MacOS/Updater" - "$app_path/Contents/Helpers/lithe-db-sidecar" - "$app_path/Contents/Helpers/lithe-db-mcp" "$app_path/Contents/Helpers/lithe-mcp" ) for executable in "${required_executables[@]}"; do @@ -223,6 +221,8 @@ required_resources=( "$app_path/$java_plugin_package/runtime/arm64/lib" "$app_path/$java_plugin_package/runtime/x86_64/bin/java" "$app_path/$java_plugin_package/runtime/x86_64/lib" + "$app_path/$java_plugin_package/icons/java.svg" + "$app_path/$java_plugin_package/templates/java/maven/pom.xml" ) for resource in "${required_resources[@]}"; do if [[ ! -e "$resource" ]]; then diff --git a/scripts/verify-module-boundaries.sh b/scripts/verify-module-boundaries.sh index 382a2360a8..401fed982f 100755 --- a/scripts/verify-module-boundaries.sh +++ b/scripts/verify-module-boundaries.sh @@ -4,9 +4,9 @@ set -euo pipefail ROOT_DIR="${0:A:h:h}" cd "$ROOT_DIR" -module_ids=(workspace git search localHistory languageIntelligence execution debug terminal database aiAssistance) -module_types=(WorkspaceFoundation Database Git Search History LanguageIntelligence Execution Debug Terminal AIAssistance) -module_targets=(LitheWorkspaceModule LitheDatabaseModule LitheGitModule LitheSearchModule LitheLocalHistoryModule LitheLanguageIntelligenceModule LitheExecutionModule LitheDebugModule LitheTerminalModule LitheAIAssistanceModule) +module_ids=(workspace git search localHistory languageIntelligence execution debug terminal aiAssistance) +module_types=(WorkspaceFoundation Git Search History LanguageIntelligence Execution Debug Terminal AIAssistance) +module_targets=(LitheWorkspaceModule LitheGitModule LitheSearchModule LitheLocalHistoryModule LitheLanguageIntelligenceModule LitheExecutionModule LitheDebugModule LitheTerminalModule LitheAIAssistanceModule) for id in "${module_ids[@]}"; do if ! rg -q "static let ${id} = ModuleID" macos/Sources/LitheModuleAPI/Lifecycle/ModuleTypes.swift; then @@ -32,7 +32,7 @@ for index in {1..${#module_types[@]}}; do fi done -for contribution_type in Database Git Search History LanguageIntelligence Execution Debug Terminal AIAssistance; do +for contribution_type in Git Search History LanguageIntelligence Execution Debug Terminal AIAssistance; do if ! rg -q "${contribution_type}Module\.moduleContributions" macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift; then print -u2 "Missing lazy contribution registration: ${contribution_type}Module" exit 1 @@ -57,7 +57,7 @@ if rg -n 'Contents/PlugIns' scripts/package-app.sh scripts/preview.sh; then exit 1 fi -if rg -n "final class (WorkspaceFoundation|Database|Git|Search|History|LanguageIntelligence|Execution|Debug|Terminal|AIAssistance)Module([[:space:]:{])" macos/Sources/Lithe; then +if rg -n "final class (WorkspaceFoundation|Git|Search|History|LanguageIntelligence|Execution|Debug|Terminal|AIAssistance)Module([[:space:]:{])" macos/Sources/Lithe; then print -u2 "Module lifecycle implementation leaked back into the executable target" exit 1 fi @@ -203,32 +203,6 @@ if rg -n 'terminal\.sessions|git\.log|language\.problems|execution\.(maven|run|t exit 1 fi -for legacy_database_file in \ - macos/Sources/Lithe/Application/DatabaseFeatureModel.swift \ - macos/Sources/Lithe/Application/DatabaseSQLSupport.swift \ - macos/Sources/Lithe/Application/DatabaseSchemaDiff.swift \ - macos/Sources/Lithe/Services/DatabaseConnectionStore.swift \ - macos/Sources/Lithe/Services/DatabaseDBXImportService.swift \ - macos/Sources/Lithe/Services/DatabaseSidecarService.swift \ - macos/Sources/Lithe/Core/Ports/DatabaseRecovery.swift; do - if [[ -e "$legacy_database_file" ]]; then - print -u2 "Database implementation leaked outside LitheDatabaseModule: $legacy_database_file" - exit 1 - fi -done - -for database_type in DatabaseFeatureModel DatabaseSidecarService DatabaseConnectionStore DatabaseProcessRunning DatabaseRecoveryStoring; do - rg -q "(class|struct|protocol|enum) ${database_type}" macos/Sources/LitheDatabaseModule || { - print -u2 "Database module is missing real implementation: ${database_type}" - exit 1 - } -done - -if rg -n 'FeatureModuleHandle|ProcessRunner|KeyValueStore|SecureStore|FileStorage' macos/Sources/LitheDatabaseModule | rg -v 'Database(ProcessRunner|PreferenceStore|SecureStore|FileStorage)'; then - print -u2 "Database must own its graph through database-scoped ports" - exit 1 -fi - for language_type in LanguageIntelligenceModule LanguageIntelligenceCapability LanguageIntelligenceServiceGraph; do rg -q "(class|struct|protocol|enum) ${language_type}" macos/Sources/LitheLanguageIntelligenceModule || { print -u2 "Language Intelligence module is missing its owned lifecycle boundary: ${language_type}" diff --git a/scripts/verify-remote-workspace.sh b/scripts/verify-remote-workspace.sh new file mode 100755 index 0000000000..25182e2c40 --- /dev/null +++ b/scripts/verify-remote-workspace.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Verifies the remote workspace crates: protocol, server and client. +# +# --static formatting, clippy, type checks of the server for every remote +# host target, and static Linux release builds held to a size +# budget, because the server is uploaded to every remote +# --tests unit and end-to-end tests with per-test timing reports +# +# Without arguments both run. The end-to-end tests start the real server +# through the local transport, so they exercise the adapter of the machine +# running them: CI covers Linux, local runs on a Mac cover macOS. +set -euo pipefail + +ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +MANIFEST=rust/Cargo.toml +PACKAGES=(lithe-remote-protocol lithe-remote-server lithe-remote-client) +SERVER_TARGETS=(x86_64-unknown-linux-musl aarch64-unknown-linux-musl aarch64-apple-darwin x86_64-apple-darwin) +LINUX_TARGETS=(x86_64-unknown-linux-musl aarch64-unknown-linux-musl) + +# Largest accepted stripped release binary. The server is uploaded on first +# connection to every host and container, often over slow links; the first +# measured build was about 0.8 MiB. Raising the budget needs a measured reason +# in the remote development Agent Note. +SERVER_SIZE_BUDGET_BYTES=$((1536 * 1024)) + +run_static=false +run_tests=false +case "${1:-}" in + --static) run_static=true ;; + --tests) run_tests=true ;; + "") run_static=true; run_tests=true ;; + *) printf 'usage: %s [--static|--tests]\n' "$0" >&2; exit 2 ;; +esac + +package_arguments=() +for package in "${PACKAGES[@]}"; do + package_arguments+=(-p "$package") +done + +if [[ "$run_static" == true ]]; then + cargo fmt --manifest-path "$MANIFEST" "${package_arguments[@]}" -- --check + cargo clippy --locked --manifest-path "$MANIFEST" "${package_arguments[@]}" --tests -- -D warnings + installed_targets="$(rustup target list --installed 2>/dev/null || true)" + for target in "${SERVER_TARGETS[@]}"; do + if ! grep -qx "$target" <<<"$installed_targets"; then + printf 'verify-remote-workspace: Rust target %s is missing; run: rustup target add %s\n' "$target" "$target" >&2 + exit 1 + fi + # Type checking needs no cross linker, so every host target is checked + # from any machine, including the adapter the tests cannot run here. + cargo check --locked --manifest-path "$MANIFEST" -p lithe-remote-server --target "$target" --tests + done + # Rust's bundled lld links static musl binaries on Linux and macOS hosts + # alike, so no cross toolchain, Docker image or zig is needed. + for target in "${LINUX_TARGETS[@]}"; do + linker_variable="CARGO_TARGET_$(tr '[:lower:]-' '[:upper:]_' <<<"$target")_LINKER" + env "$linker_variable=rust-lld" \ + cargo build --locked --release --manifest-path "$MANIFEST" -p lithe-remote-server --target "$target" + binary="rust/target/$target/release/lithe-remote-server" + size="$(wc -c <"$binary" | tr -d ' ')" + printf 'lithe-remote-server %s: %s bytes (budget %s)\n' "$target" "$size" "$SERVER_SIZE_BUDGET_BYTES" + if (( size > SERVER_SIZE_BUDGET_BYTES )); then + printf 'verify-remote-workspace: %s exceeds the size budget\n' "$binary" >&2 + exit 1 + fi + done +fi + +if [[ "$run_tests" == true ]]; then + for package in "${PACKAGES[@]}"; do + node .agents/skills/write-stable-tests/scripts/run-rust-tests-with-timing.mjs \ + --manifest "$MANIFEST" --package "$package" \ + --suite-timeout-ms 180000 \ + --report ".artifacts/test-stability/${package#lithe-}-rust.json" + done +fi + +printf 'Remote workspace verification passed\n' diff --git a/scripts/verify-rust-core-crates.sh b/scripts/verify-rust-core-crates.sh index 3a195347f7..9bfe4b8da0 100755 --- a/scripts/verify-rust-core-crates.sh +++ b/scripts/verify-rust-core-crates.sh @@ -21,30 +21,14 @@ node .agents/skills/write-stable-tests/scripts/run-rust-tests-with-timing.mjs \ --manifest rust/Cargo.toml --package lithe-git-host \ --suite-timeout-ms 120000 \ --report .artifacts/test-stability/git-host-rust.json +# Agent Host's ACP/MCP dependencies need the runner's default cold-build budget; +# its suite deadline includes compilation, while each test remains capped at 15s. node .agents/skills/write-stable-tests/scripts/run-rust-tests-with-timing.mjs \ --manifest rust/Cargo.toml --package lithe-agent-host \ - --suite-timeout-ms 120000 \ --report .artifacts/test-stability/agent-host-rust.json node .agents/skills/write-stable-tests/scripts/run-rust-tests-with-timing.mjs \ --manifest rust/Cargo.toml --package lithe-ide-host \ --report .artifacts/test-stability/ide-host-rust.json cargo build --locked --manifest-path rust/Cargo.toml -p lithe-ide-host --features mcp --bin lithe-mcp node --test scripts/test-ide-mcp.mjs -node --test --test-reporter=spec --test-reporter-destination=stdout \ - --test-reporter=junit --test-reporter-destination=.artifacts/test-stability/plugin-sdk-node.xml \ - sdk/node/test/*.test.mjs -# The Java plugin SDK compiles for Java 8 with any newer JDK. When -# LITHE_PLUGIN_SDK_JAVA_RUNTIME names an older `java` (CI sets a Java 8 one), -# its tests and the sample backend run on that runtime instead of the build JDK. -for tool in java mvn; do - command -v "$tool" >/dev/null || { print -u2 "verify-rust-core-crates: $tool is required for sdk/java."; exit 1; } -done -java_runtime="${LITHE_PLUGIN_SDK_JAVA_RUNTIME:-$(command -v java)}" -mvn -B -ntp -f sdk/java/pom.xml verify -Djvm="$java_runtime" -cp sdk/java/plugin-sdk/target/surefire-reports/TEST-*.xml .artifacts/test-stability/ -# Opts the plugin SDK end-to-end tests in; they drive each sample backend -# through Core's real plugin session. -LITHE_PLUGIN_SDK_NODE="$(command -v node)" \ - LITHE_PLUGIN_SDK_JAVA="$java_runtime" \ - LITHE_PLUGIN_SDK_JAVA_SAMPLE="$ROOT_DIR/sdk/java/examples/sample-build/target/sample-build.jar" \ - cargo test --manifest-path rust/Cargo.toml -p lithe-core +cargo test --manifest-path rust/Cargo.toml -p lithe-core diff --git a/scripts/verify-shared-contracts.sh b/scripts/verify-shared-contracts.sh index 91b749033f..185c8e1e93 100755 --- a/scripts/verify-shared-contracts.sh +++ b/scripts/verify-shared-contracts.sh @@ -4,6 +4,8 @@ set -euo pipefail ROOT_DIR="${0:A:h:h}" cd "$ROOT_DIR" +node scripts/generate-i18n.mjs --check + for fixture in shared/fixtures/**/*.json; do /usr/bin/ruby -rjson -e 'JSON.parse(File.read(ARGV.fetch(0)))' "$fixture" done @@ -93,7 +95,6 @@ fi abort "AI must be disabled by default" unless modules.find { |m| m.fetch("id") == "dev.lithe.ai-assistance" }.fetch("defaultState") == "disabled" agent = modules.find { |m| m.fetch("id") == "dev.lithe.agent-conversation" } abort "Agent must be disabled and on demand" unless agent.fetch("defaultState") == "disabled" && agent.fetch("activationPolicy") == "onDemand" - abort "Database must be disabled by default" unless modules.find { |m| m.fetch("id") == "dev.lithe.database" }.fetch("defaultState") == "disabled" allowed_states = ["enabled", "disabled"] allowed_scopes = ["application", "workspace"] allowed_policies = ["eager", "onDemand", "manual"] @@ -151,15 +152,27 @@ fi # Declarative plugin contract v1. Semantics are executed by the Rust Core # tests against these files; this gate keeps the contract self-consistent. declarative_plugin_contract="shared/contracts/plugins/v1" +# Plugin developers read this contract from the public lithe-plugin-docs +# repository, which mirrors this directory byte for byte under contract/v1. +published_plugin_schema_base="https://raw.githubusercontent.com/Lithe-IDEA/lithe-plugin-docs/main/contract/v1" for contract_file in "$declarative_plugin_contract"/**/*.json; do /usr/bin/ruby -rjson -e 'JSON.parse(File.read(ARGV.fetch(0)))' "$contract_file" done /usr/bin/ruby -rjson -e ' root = ARGV.fetch(0) + published_base = ARGV.fetch(1) schema = JSON.parse(File.read(File.join(root, "plugin-manifest.schema.json"))) - abort "plugin manifest schema ID mismatch" unless schema.fetch("$id").end_with?("/plugins/v1/plugin-manifest.schema.json") + abort "plugin manifest schema ID mismatch" unless schema.fetch("$id") == "#{published_base}/plugin-manifest.schema.json" + native_schema = JSON.parse(File.read(File.join(root, "native-ui.schema.json"))) + abort "native UI schema ID mismatch" unless native_schema.fetch("$id") == "#{published_base}/native-ui.schema.json" abort "declarative manifests must use schemaVersion 2" unless schema.fetch("properties").fetch("schemaVersion").fetch("const") == 2 readme = File.read(File.join(root, "README.md"), encoding: "UTF-8") + # The mirror only holds this directory, so a relative link must resolve here. + readme.scan(/\]\(([^)\s]+)\)/).flatten.each do |target| + next if target.start_with?("#") || target.match?(%r{\A[a-z][a-z0-9+.-]*:}i) + path = File.expand_path(target.split("#").first, root) + abort "contract README link #{target} must be absolute or stay inside #{root}" unless path.start_with?("#{File.expand_path(root)}/") && File.exist?(path) + end documented = readme.scan(/`(plugin\.[a-zA-Z.]+)`/).flatten manifests = Dir[File.join(root, "fixtures", "*.plugin.json")] abort "declarative plugin fixtures are missing" if manifests.empty? @@ -204,7 +217,7 @@ done functions = JSON.parse(File.read(File.join(root, "fixtures", "function-protocol.json"))) abort "function protocol fixture schema must be 1" unless functions.fetch("schemaVersion") == 1 abort "function protocol fixture must describe litheFunctions" unless functions.fetch("protocol") == "litheFunctions" - function_sections = %w[negotiation registration invocations outcomes progress backendRequests] + function_sections = %w[negotiation registration invocations outcomes progress backendRequests hostRequests] function_names = function_sections.flat_map { |section| functions.fetch(section).map { |item| item.fetch("name") } } abort "function protocol case names must be unique" unless function_names.uniq.length == function_names.length function_codes = [] @@ -215,12 +228,13 @@ done functions.fetch("invocations").each { |item| function_codes << item.fetch("expected")["code"] } functions.fetch("outcomes").each { |item| function_codes << item.fetch("expected").fetch("error", {})["code"] } functions.fetch("backendRequests").each { |item| function_codes << item.fetch("expected")["code"] } + functions.fetch("hostRequests").each { |item| function_codes << item.fetch("expected")["code"] } function_codes.compact.uniq.each do |code| abort "function protocol fixture uses an undocumented code #{code}" unless documented.include?(code) end abort "function protocol fixture contains a machine path" if JSON.generate(functions).match?(%r{/(?:Users|home|var/folders|private/var)/|[A-Za-z]:\\\\}) index_schema = JSON.parse(File.read(File.join(root, "plugin-index.schema.json"))) - abort "plugin index schema ID mismatch" unless index_schema.fetch("$id").end_with?("/plugins/v1/plugin-index.schema.json") + abort "plugin index schema ID mismatch" unless index_schema.fetch("$id") == "#{published_base}/plugin-index.schema.json" abort "plugin indexes must use schemaVersion 1" unless index_schema.fetch("properties").fetch("schemaVersion").fetch("const") == 1 marketplace = File.join(root, "fixtures", "marketplace") index = JSON.parse(File.read(File.join(marketplace, "index.json"))) @@ -246,7 +260,7 @@ done code = item["expectedCode"] abort "install review case #{item.fetch("name")} uses an undocumented code" if code && !documented.include?(code) end -' "$declarative_plugin_contract" +' "$declarative_plugin_contract" "$published_plugin_schema_base" /usr/bin/ruby -rjson -e ' data = JSON.parse(File.read(ARGV.fetch(0))) diff --git a/scripts/verify-windows-boundaries.ps1 b/scripts/verify-windows-boundaries.ps1 index dca02c73ad..a254f1b39a 100644 --- a/scripts/verify-windows-boundaries.ps1 +++ b/scripts/verify-windows-boundaries.ps1 @@ -5,10 +5,8 @@ Set-Location $root node scripts/verify-java-semantic-ownership.mjs if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } -$cppFiles = Get-ChildItem windows -Recurse -File -Include *.cpp,*.h | - Where-Object { - $_.FullName -notmatch '[\\/](node_modules|target|dist)[\\/]' - } +$cppFiles = @(git ls-files --cached --others --exclude-standard -- windows | + Where-Object { $_ -match '\.(cpp|h)$' }) if ($cppFiles.Count -gt 0) { throw "Windows product must not restore the retired Qt/C++ implementation." } diff --git a/scripts/verify-windows-boundaries.sh b/scripts/verify-windows-boundaries.sh index 729a729fbd..5fadce7070 100755 --- a/scripts/verify-windows-boundaries.sh +++ b/scripts/verify-windows-boundaries.sh @@ -3,6 +3,8 @@ set -euo pipefail cd "$(dirname "$0")/.." +node scripts/generate-i18n.mjs --check + if find windows \ \( -path '*/node_modules' -o -path '*/target' -o -path '*/dist' \) -prune -o \ -type f \( -name '*.cpp' -o -name '*.h' \) -print -quit | grep -q .; then diff --git a/scripts/worktree-resources.json b/scripts/worktree-resources.json index 74a75aff74..89ea9c4c36 100644 --- a/scripts/worktree-resources.json +++ b/scripts/worktree-resources.json @@ -33,6 +33,16 @@ } ], "excludedResources": [ + { + "id": "agent-edit-and-paste-state", + "reusable": false, + "locations": [ + "/agent/pasted-images/pasted-.", + "/.lithe-agent-recovery---" + ], + "identity": "One user attachment or guarded revert operation owns these mutable bytes. No reusable version/platform/architecture/toolchain identity stamp exists.", + "reason": "The Windows attachment adapter writes only to platform application cache; abandoned batches delete their own files, accepted references remain available to history. Revert stages a same-directory file until Windows recycling succeeds, restoring on failure without overwriting a recreated destination. A conflicting restoration reports the recovery location for manual action. Neither resource may enter any worktree copy stage or installation directory; signatures and update baselines remain unchanged." + }, { "id": "distribution-runtime-state", "reusable": false, @@ -281,8 +291,10 @@ "reusable": false, "locations": [ ".artifacts/test-stability/*.png", + ".artifacts/test-stability/{mcp-ui,instructions-ui}/*.png", ".artifacts//screenshots/*.png", - "/lithe-localized-logging.*/Lithe Logging UI.app/" + "/lithe-localized-logging.*/Lithe Logging UI.app/", + ".artifacts/test-stability/hooks-ui/*.png" ], "identity": "One first-party native test run at an exact source revision, platform, theme, viewport and fixture parameters. No reusable build identity stamp or installed runtime ownership. Localized log-page hosts additionally bind the selected Xcode SwiftPM runner and the product string tables.", "reason": "Native fixture views produce these sanitized review/diagnostic screenshots inside the owning worktree report directory. CI uploads them with test-stability-macos and applies artifact retention; local reports remain until removed by the developer. They never capture the user desktop, enter a package, modify a signed bundle or participate in any worktree copy stage. scripts/probe-macos-logging-ui.sh copies the stock runner and compiled product localizations into one disposable temporary app; its EXIT trap removes the host after bounded native rendering. The original toolchain and installed app bundles remain read-only." @@ -299,6 +311,89 @@ ], "identity": "Mutable distribution-, process-session- and operation-owned diagnostic evidence. No reliable version/platform/architecture/toolchain identity stamp; immutable previews contain redacted bytes for one explicit confirmation only.", "reason": "Native adapters create module logs in user log storage, expire at 30 days and enforce disk budgets. macOS owns one cache staging directory until export/cancel; Windows keeps at most four five-minute previews only in memory and atomically publishes a destination-owned temporary ZIP. Never reuse, copy or share at any worktree stage, and never write into signed packages or installed resources." + }, + { + "id": "mcp-registry-metadata", + "reusable": false, + "locations": [ + "/MCPRegistry/search-results", + "rust/lithe-core/src/mcp/featured.json", + "macos/Sources/Lithe/Resources/McpIcons" + ], + "identity": "Fetched public directory responses are bounded to 1 MiB, normalized by Rust Core and owned by one cancellable search; there is no persistent cache or build identity stamp. The separate featured.json catalog is Git-owned compile-time source, not a reusable generated or downloaded resource. Bundled McpIcons are Git source assets with reviewed source URLs and SHA-256 in NOTICE.json, not generated cache entries; they have no reusable version/platform/architecture/toolchain stamp.", + "reason": "The macOS ephemeral URLSessions keep fetched directory metadata in memory only. Never copy, bundle or share fetched search responses between worktrees; query, pagination and the explicitly selected system/direct route belong to the current page. The static rust/lithe-core/src/mcp/featured.json catalog follows the source checkout and is compiled read-only into Core; never copy it from a runtime cache. McpIcons are copied from the same checkout only during SwiftPM packaging, read-only at runtime, and never extracted from another worktree or installed app. No server packages or images are downloaded at runtime, and no runtime writes touch the installed bundle or Sparkle delta baseline." + }, + { + "id": "agent-instruction-library-state", + "reusable": false, + "locations": [ + "/Lithe/AgentInstructions/Shared.md", + "/Lithe/AgentInstructions/.sync.lock", + "/Lithe/AgentInstructions/.lithe-instructions-.tmp", + "/{AGENTS.md,AGENTS.override.md,CLAUDE.md}" + ], + "identity": "Mutable user-authored instruction text, native snapshots and nonblocking synchronization locks; no version/platform/architecture/toolchain build identity.", + "reason": "The platform storage adapter supplies the writable application-support root. Agent Host owns bounded UTF-8 reads and guarded atomic saves; only explicit saves and switches write. Temporary siblings are removed after success/failure, the empty lock stays with the user library, and native files retain upstream ownership outside the Lithe block. Never copy, bundle, share or validate these as worktree build resources; installed packages and Sparkle baselines remain read-only." + }, + { + "id": "mcp-tool-probe-state", + "reusable": false, + "locations": [ + "/MCPTools/{connections,tool-descriptions,policy-snapshots}", + "/settings.mcpToolExclusions..", + "/settings.mcpServerRegistrations...toolFilters", + "/{config.toml,settings.json}" + ], + "identity": "User-requested, bounded SDK connections and mutable native exclusion rules. No generated build identity, version/platform/architecture/toolchain stamp or reusable artifact.", + "reason": "Probes run only after an explicit action, list metadata without tool calls, and cancel on page exit. Process trees and HTTP sessions are cleaned up; temporary policy snapshots stay in memory. Native rules use the existing platform atomic writer and Application Support backups. User preferences remember added exclusion ownership and retained Codex allow/deny tool names after removal, never credentials. Retained filters survive page/process exit until re-registration and do not prove live source ownership. No copy stage, runtime bundle writes, or cross-worktree transfer is allowed. Upstream package caches created by an explicitly run command remain owned by that runtime, not Lithe build resources." + }, + { + "id": "agent-hook-library-state", + "reusable": false, + "locations": [ + "/Lithe/AgentHooks/hooks.json", + "/Lithe/AgentHooks/.sync.lock", + "/Lithe/AgentHooks/.lithe-instructions-.tmp", + "/{hooks.json,settings.json}" + ], + "identity": "Mutable user-authored Hook definitions, guarded native snapshots and write-ahead ownership placements; no reusable version/platform/architecture/toolchain build identity.", + "reason": "Platform storage supplies the writable application-support directory. Agent Host performs bounded explicit JSON writes; pending placements survive interrupted native commits and only exact matching native groups can be retired. Sibling temporary files are removed on success/failure, an empty OS lock remains user-owned. Never copy or share at any worktree stage. Native Agent runtimes own execution and trust; installed bundles and Sparkle baselines remain read-only." + }, + { + "id": "remote-server-artifacts", + "reusable": false, + "locations": [ + "rust/target/remote-servers//lithe-remote-server", + "rust/target/remote-servers/manifest.json", + "/Contents/Resources/RemoteServers" + ], + "identity": "Release builds of lithe-remote-server for the Linux musl targets, rebuilt from this worktree's sources by scripts/build-remote-servers.sh; the manifest carries only the crate version, not a toolchain or source stamp.", + "reason": "Packaging rebuilds the servers on every run (about 25 seconds) inside this worktree's Cargo target directory, so a copy would only risk shipping binaries from other sources. The bundle copy is read-only: the app streams it to SSH hosts and never writes into RemoteServers, keeping code signing and Sparkle deltas intact." + }, + { + "id": "remote-connection-state", + "reusable": false, + "locations": [ + "/lithe.remote.ssh-connections.v1", + "//remote.ssh..secret", + "/Lithe/RemoteWorkspaces///", + "/~/.lithe-server/bin/-" + ], + "identity": "User-owned SSH connection settings, remembered secrets, empty placeholder roots of opened remote workspaces, and servers installed on remote hosts; no build identity.", + "reason": "Connections and secrets belong to the user's profile and the keychain. Placeholder roots are empty directories that key per-project state for remote folders and hold no files. Remote installs live on the hosts and are verified by SHA-256 before reuse. None has a copy stage between worktrees, and none is written into the app bundle or installation directory." + }, + { + "id": "shared-i18n", + "reusable": false, + "locations": [ + "shared/i18n", + ".artifacts/i18n-windows/", + "macos/Resources/.lproj/Localizable.strings", + "macos/Sources/Lithe/Models/Settings/AppLanguage.generated.swift", + "windows/tauri/src/i18n/shared.generated.ts" + ], + "identity": "Current worktree shared locale manifest, message registry, per-locale translations and all generated native string-table bytes. Generated outputs have no sealed version/platform/architecture/toolchain reuse identity stamp.", + "reason": "Git owns source and checked-in generated outputs. Regenerate and validate them in each target worktree with scripts/generate-i18n.mjs; no download, cache validator or cross-worktree copy stage is permitted. Packaging copies registered native tables into its own build destination before signing and converts only that copy to binary plist. Runtime only reads bundled tables; it creates no state and changes neither code-signature bytes nor the Sparkle delta baseline." } ] } diff --git a/sdk/java/README.md b/sdk/java/README.md deleted file mode 100644 index 7231d39191..0000000000 --- a/sdk/java/README.md +++ /dev/null @@ -1,153 +0,0 @@ -# Lithe plugin backend SDK for Java - -`lithe-plugin-sdk-java` is the official way to write a plugin backend in Java: -the process a plugin starts through a `runtime` launch -(`{ "kind": "runtime", "tool": "java", "minimumVersion": "8", "entry": "backend/app.jar" }`). -It is the Java twin of the [Node.js SDK](../node/README.md). Both implement the -same two protocols from -[plugin contract v1](../../shared/contracts/plugins/v1/README.md) with the -same rules and error mapping: - -- **`litheBuild` 1**: typed `lithe/build/model` and `lithe/build/dependencies` - requests, for a build system whose `model` or `dependencies` source is - `provider`. -- **`litheFunctions` 1**: plugin functions, which are plugin-private actions - that Lithe invokes for the same plugin. They support progress, - cancellation, and registration at runtime. - -The SDK requires Java 8 or newer and has no runtime dependencies. JSON values -are plain `Map`, `List`, `String`, `Long`, `Double`, -`Boolean`, and `null` (see `Json`). - -## A minimal backend - -```java -Backend backend = Backend.create("example-backend", "1.0.0"); - -backend.onModel((params, context) -> { - Map project = new LinkedHashMap<>(); - project.put("name", "app"); - project.put("path", Json.string(params, "root")); - Map model = new LinkedHashMap<>(); - model.put("projects", Collections.singletonList(project)); - return model; -}); - -backend.function("snapshot.refresh", (params, context) -> { - context.progress("Refreshing", 10.0); - Processes.Result result = context.run(Arrays.asList("example-tool", "refresh")); - if (result.exitCode() != 0) { - throw new PluginError("example.refreshFailed", "The refresh failed."); - } - return Collections.singletonMap("refreshedAt", System.currentTimeMillis()); -}); - -backend.listen(); -``` - -[`examples/sample-build`](examples/sample-build) is a complete plugin. It has -a build provider, functions, persistent state, a function registered at -runtime, and cancellation. Rust Core's -`rust/lithe-core/tests/plugin_sdk_java.rs` drives it through a real Core -session, and runs the same scenario that `plugin_sdk_node.rs` runs for the -Node.js sample. - -## Getting the SDK - -Each release is tagged `sdk-java-v`. A release ships -`lithe-plugin-sdk-java-.jar` and a `.sha256` file holding the JAR's -SHA-256. The SDK is not published to a Maven repository. Use it like this: - -1. Download the JAR during your plugin's build, never at runtime. -2. Check the JAR against the SHA-256 you pinned in your repository. -3. Shade it into your backend JAR, as the sample's `pom.xml` does. - -A plugin package is installed read-only and must run offline, so the backend -JAR it starts must hold everything it loads. - -## API - -| API | Purpose | -| --- | --- | -| `Backend.create(name, version[, capabilities])` | Creates the backend. `capabilities` are standard LSP server capabilities. `experimental.litheBuild` and `experimental.litheFunctions` are reserved for the SDK. | -| `onModel(handler)`, `onDependencies(handler)` | Implement the typed build requests. The SDK stamps `format` and `version`. Core validates the document. `litheBuild` is answered only when a handler exists. | -| `function(id, handler)` | Declares a function offered in `initialize`. | -| `registerFunction(id, handler)`, `unregisterFunction(id)` | Add or withdraw a function. Called before the `initialize` answer (for example from `onInitialize`), they change the initial list. Later calls send `lithe/function/register` or `lithe/function/unregister`, in order. Both return a `CompletableFuture`. Withdrawing a function revokes its running calls. | -| `onInitialize(hook)`, `onShutdown(hook)` | Lifecycle hooks. `onInitialize` receives the `initialize` params and what was negotiated. | -| `onRequest(method, handler)`, `onNotification(method, handler)` | Ordinary LSP methods. Lifecycle and `lithe/*` methods are reserved. | -| `sendRequest`, `sendNotification` | Ordinary LSP messages to the host. `lithe/*` methods are reserved. | -| `log(message[, error])` | Writes to stderr, which the host keeps in its log. | -| `listen()` | Serves stdio until `exit` or the end of input, then exits the JVM with the protocol's exit code. `listen(in, out, err)` serves other streams and returns the code instead. | -| `PluginError(code, message)` | A failure meant for your own UI. | -| `Processes.run(command[, options])` | Runs a process without a shell. Its stdin is closed. `Options` set the directory, environment, output limits, a cancellation, and output callbacks. | -| `StateStore.open(directory)` | A small atomic JSON store in a host-provided directory. | - -Handlers run on their own threads. Every handler receives a `CallContext`: - -| Member | Meaning | -| --- | --- | -| `cancellation()`, `isCancelled()` | Cancelled when any of these happens: Lithe cancels the call, its deadline passes, the function is withdrawn, the session ends, or the handler finishes. Finishing the handler stops the work it left running. | -| `throwIfCancelled()` | Throws `CancelledException` once the call is cancelled. | -| `run(command[, options])` | `Processes.run` bound to the call: cancelling or ending the call stops the process. | -| `log(message)` | Same as `Backend.log`. | -| `functionId()`, `operationId()` | Functions only. `operationId` is opaque; never derive anything from it. | -| `progress(message, percentage)` | Functions only. Either argument may be `null`. The message is cut to 1024 bytes and the percentage is clamped to 0–100. Reports sent after the call ends are dropped. | - -Results and errors map exactly as in the Node.js SDK: - -- A returned value becomes `result.value`. -- `PluginError` becomes `plugin.function.failed` with your message and - `pluginErrorCode`. -- Any other exception is reported with a generic message, and its stack trace - goes to stderr. -- `CancelledException` ends the call as cancelled. -- A result larger than 1 MiB of JSON fails like an unexpected error. - -## Rules that keep Lithe in charge - -These are the [Node.js SDK's rules](../node/README.md#rules-that-keep-lithe-in-charge). -In Java: - -1. **Domain capabilities stay typed.** Build models and dependency trees go - through `onModel` and `onDependencies`, never through a function. -2. **No calls back into Lithe's dispatch.** The SDK refuses to send any - `lithe/*` method. -3. **stdout belongs to the protocol.** `listen()` keeps the real stdout for - protocol frames and points `System.out` at stderr. Never write to - `FileDescriptor.out`. -4. **The package is read-only.** Keep state under the directories Lithe - passes through launch templates (`${stateDir}`, `${workspaceStateDir}`). - `StateStore.open` refuses a directory inside the plugin package. It finds - the package by walking up from the SDK's own JAR to `plugin.json`. -5. **Work ends with its call.** Start processes with `context.run`, and check - `isCancelled()` in long loops. On cancellation the SDK first asks the - process to stop, then stops it forcibly after a grace period. - - **macOS and Linux, every Java version:** each tool leads its own process - group, as in the Node SDK. The SDK starts it through the system `perl`, - which calls `setpgrp` and then becomes the tool. A stop reaches the whole - group, and when the tool exits on its own, whatever it left running in - its group is killed. - - **Windows, or no `perl`:** on Java 9 or newer the SDK stops the - descendants still attached to the tool; Java 8 stops only the direct - child. - - A descendant that leaves the group (`setsid`) or that the fallback cannot - reach is ended by Lithe's process scope together with the backend. - A cancellation listener that throws is logged and does not keep the other - listeners or the reply from running. - - On `shutdown`, `exit`, or the end of input the SDK ends every process it - started. `Processes.run` keeps Lithe's process-scope markers even when - `Options.environment` replaces the environment. -6. **Errors stay presentable.** Throw `PluginError` for failures the user - should see; every other exception is reported generically. - -## Testing - -```bash -mvn -B -f sdk/java/pom.xml verify -``` - -This builds the SDK and the sample for Java 8 with any newer JDK, then runs -the JUnit tests. Add `-Djvm=/path/to/java8/bin/java` to run the tests on a -Java 8 runtime, as CI does. diff --git a/sdk/java/examples/sample-build/plugin.json b/sdk/java/examples/sample-build/plugin.json deleted file mode 100644 index 8e93ebc10a..0000000000 --- a/sdk/java/examples/sample-build/plugin.json +++ /dev/null @@ -1,87 +0,0 @@ -{ - "schemaVersion": 2, - "id": "org.example.sample-build-java", - "displayName": "Sample Build (Java)", - "version": "0.1.0", - "pluginAPIVersion": 3, - "requires": { - "buildProviders": 1, - "buildSystems": 1, - "serverProcess": 1 - }, - "vendor": { - "id": "org.example", - "displayName": "Example", - "signatureRequirement": "publisherPackage" - }, - "permissions": [ - "languageServer.launch", - "process.build" - ], - "activationEvents": [ - "onBuildSystem:sample" - ], - "languages": [], - "buildSystems": [ - { - "id": "sample", - "displayName": "Sample", - "detection": { - "markers": [ - "lithe-sample.json" - ], - "nesting": "outermost" - }, - "invocation": { - "executables": [ - { - "kind": "command", - "command": "java" - } - ] - }, - "provider": { - "launch": { - "kind": "runtime", - "tool": "java", - "minimumVersion": "8", - "entry": "backend/sample-build.jar" - }, - "arguments": [ - "--state", - "${workspaceStateDir}" - ], - "executesProjectCode": true, - "mayUseNetwork": false - }, - "model": { - "source": "provider" - }, - "tasks": [ - { - "id": "build", - "displayName": "Build", - "group": "build", - "scopes": [ - "workspace" - ], - "arguments": [ - "-version" - ] - } - ], - "dependencies": { - "source": "provider", - "scopes": [ - "workspace", - "project" - ] - }, - "sync": { - "watch": [ - "lithe-sample.json" - ] - } - } - ] -} diff --git a/sdk/java/examples/sample-build/pom.xml b/sdk/java/examples/sample-build/pom.xml deleted file mode 100644 index cacbb00d09..0000000000 --- a/sdk/java/examples/sample-build/pom.xml +++ /dev/null @@ -1,51 +0,0 @@ - - - 4.0.0 - - - io.github.lithe-idea - lithe-plugin-sdk-java-parent - 0.1.0 - ../../pom.xml - - - sample-build - Lithe sample build provider (Java) - - - - io.github.lithe-idea - lithe-plugin-sdk-java - ${project.version} - - - - - sample-build - - - - org.apache.maven.plugins - maven-shade-plugin - - - package - - shade - - - false - - - org.example.samplebuild.SampleBuild - - - - - - - - - diff --git a/sdk/java/examples/sample-build/src/main/java/org/example/samplebuild/SampleBuild.java b/sdk/java/examples/sample-build/src/main/java/org/example/samplebuild/SampleBuild.java deleted file mode 100644 index 492f9e210d..0000000000 --- a/sdk/java/examples/sample-build/src/main/java/org/example/samplebuild/SampleBuild.java +++ /dev/null @@ -1,241 +0,0 @@ -package org.example.samplebuild; - -import io.github.litheidea.plugin.sdk.Backend; -import io.github.litheidea.plugin.sdk.CallContext; -import io.github.litheidea.plugin.sdk.Json; -import io.github.litheidea.plugin.sdk.PluginError; -import io.github.litheidea.plugin.sdk.Processes; -import io.github.litheidea.plugin.sdk.StateStore; -import java.io.IOException; -import java.net.URI; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -/** - * Sample Lithe plugin backend built on the official Java SDK; the Java twin of - * {@code sdk/node/examples/sample-build}. - * - *

It is the build provider of the fictional "sample" build system, whose - * workspace file {@code lithe-sample.json} lists projects: - * - *

- * { "projects": [{ "name": "app", "path": "app",
- *                  "build": ["/usr/bin/make"],
- *                  "dependencies": [{ "name": "left-pad", "version": "1.3.0" }] }] }
- * 
- * - *

It shows the three things a backend does: - *

    - *
  • answers Lithe's typed build requests ({@code lithe/build/model}, - * {@code lithe/build/dependencies}), whose results Lithe itself understands;
  • - *
  • offers plugin functions for its own UI: {@code workspace.build} runs a - * multi-step build (prepare, run each project's tool, record a snapshot), - * and {@code snapshot.read} returns the last snapshot, also after a restart;
  • - *
  • registers {@code snapshot.clear} at runtime once there is a snapshot to clear.
  • - *
- * - *

A packaged plugin shades the SDK into its own JAR, as this module's POM does. - */ -public final class SampleBuild { - private final Backend backend = Backend.create("sample-build", "0.1.0"); - private final String stateDirectory; - private volatile Path workspaceRoot; - private volatile StateStore store; - - private SampleBuild(String stateDirectory) { - this.stateDirectory = stateDirectory; - } - - public static void main(String[] arguments) { - new SampleBuild(argument(arguments, "--state")).serve(); - } - - private void serve() { - backend.onInitialize((params, negotiated) -> { - // `Paths.get(URI)` accepts the trailing slash a directory URI may carry. - workspaceRoot = Paths.get(URI.create((String) params.get("rootUri"))).toAbsolutePath().normalize(); - // `--state ${workspaceStateDir}` in plugin.json: the host's writable - // directory for this workspace. The package itself is read-only. - store = StateStore.open(Paths.get(stateDirectory)); - if (store.read("snapshot", null) != null) { - offerClear(); - } - }); - - backend.onModel((params, context) -> { - List projects = new ArrayList<>(); - for (Map project : readConfig(Json.string(params, "root"))) { - projects.add(object("name", project.get("name"), "path", project.get("path"))); - } - return object("projects", projects); - }); - - backend.onDependencies((params, context) -> { - Object selected = params.get("project"); - String path = selected instanceof Map ? Json.string(Json.object(selected), "path") : null; - List dependencies = new ArrayList<>(); - for (Map project : readConfig(Json.string(params, "root"))) { - if (path != null && !path.equals(project.get("path"))) { - continue; - } - Object declared = project.get("dependencies"); - if (declared instanceof List) { - for (Object entry : (List) declared) { - Map dependency = Json.object(entry); - dependencies.add(object("name", dependency.get("name"), "version", dependency.get("version"), - "children", new ArrayList<>())); - } - } - } - return object("dependencies", dependencies); - }); - - backend.function("workspace.build", this::build); - backend.function("snapshot.read", (params, context) -> store.read("snapshot", null)); - backend.listen(); - } - - private Object build(Map params, CallContext context) throws IOException { - // Step 1: prepare. Read the workspace file and resolve the selection. - context.progress("Preparing", 0.0); - List> projects = selectProjects(readConfig("."), params.get("projects")); - - // Step 2: run each project's tool. `context.run` stops the tool when - // Lithe cancels the call, so a cancelled build leaves nothing behind. - List results = new ArrayList<>(); - for (int index = 0; index < projects.size(); index++) { - Map project = projects.get(index); - String name = (String) project.get("name"); - context.progress("Building " + name, 100.0 * index / projects.size()); - List command = new ArrayList<>(); - for (Object part : (List) project.get("build")) { - command.add((String) part); - } - Processes.Result result = context.run(command, new Processes.Options() - .directory(inside(workspaceRoot, (String) project.get("path"))) - .onStdout(text -> context.log("[" + name + "] " + text.trim()))); - results.add(object("name", name, "exitCode", result.exitCode())); - if (result.exitCode() != 0) { - record(results, "failed"); - throw new PluginError("sample.buildFailed", name + " failed with exit code " + result.exitCode() + "."); - } - } - - // Step 3: record the outcome, so `snapshot.read` returns it after a restart. - Object snapshot = record(results, "succeeded"); - context.progress("Done", 100.0); - return snapshot; - } - - /** - * Reads the latest snapshot when the build ends, not when it starts: two - * overlapping builds then get consecutive generations instead of the same - * one. Handlers run on their own threads, so the read and the write hold - * one lock. - */ - private synchronized Object record(List projects, String status) throws IOException { - Object previous = store.read("snapshot", null); - long generation = previous instanceof Map - ? ((Number) Json.object(previous).get("generation")).longValue() + 1 - : 1; - Map snapshot = object("generation", generation, "status", status, "projects", projects); - store.write("snapshot", snapshot); - offerClear(); - return snapshot; - } - - private void offerClear() { - if (backend.functionIds().contains("snapshot.clear")) { - return; - } - backend.registerFunction("snapshot.clear", (params, context) -> { - store.delete("snapshot"); - return null; - }).whenComplete((ignored, error) -> { - if (error != null) { - backend.log("snapshot.clear stays unavailable: " + error.getMessage()); - } - }); - } - - /** Every project, or exactly the named ones; an unknown name is an error, not an empty build. */ - private static List> selectProjects(List> projects, Object selected) { - if (selected == null) { - return projects; - } - if (!(selected instanceof List) || ((List) selected).isEmpty() - || !((List) selected).stream().allMatch(name -> name instanceof String)) { - throw new PluginError("sample.selection", "\"projects\" must be a non-empty list of project names."); - } - List unknown = new ArrayList<>(); - for (Object name : (List) selected) { - if (projects.stream().noneMatch(project -> name.equals(project.get("name")))) { - unknown.add((String) name); - } - } - if (!unknown.isEmpty()) { - throw new PluginError("sample.unknownProject", "Unknown project: " + String.join(", ", unknown) + "."); - } - List> chosen = new ArrayList<>(); - for (Map project : projects) { - if (((List) selected).contains(project.get("name"))) { - chosen.add(project); - } - } - return chosen; - } - - private List> readConfig(String root) { - Object config; - try { - Path file = inside(workspaceRoot, root == null ? "." : root).resolve("lithe-sample.json"); - config = Json.parse(new String(Files.readAllBytes(file), StandardCharsets.UTF_8)); - } catch (IOException | RuntimeException error) { - if (error instanceof PluginError) { - throw (PluginError) error; - } - throw new PluginError("sample.config", "lithe-sample.json is missing or is not valid JSON."); - } - Object projects = config instanceof Map ? ((Map) config).get("projects") : null; - if (!(projects instanceof List)) { - throw new PluginError("sample.config", "lithe-sample.json needs a \"projects\" list."); - } - List> result = new ArrayList<>(); - for (Object project : (List) projects) { - result.add(Json.object(project)); - } - return result; - } - - /** {@code relative} resolved inside {@code base}; workspace files never name paths outside it. */ - private static Path inside(Path base, String relative) { - Path path = base.resolve(relative).normalize(); - if (Paths.get(relative).isAbsolute() || !path.startsWith(base)) { - throw new PluginError("sample.path", relative + " is outside the workspace."); - } - return path; - } - - private static Map object(Object... entries) { - Map map = new LinkedHashMap<>(); - for (int index = 0; index < entries.length; index += 2) { - map.put((String) entries[index], entries[index + 1]); - } - return map; - } - - private static String argument(String[] arguments, String name) { - for (int index = 0; index + 1 < arguments.length; index++) { - if (arguments[index].equals(name)) { - return arguments[index + 1]; - } - } - return null; - } -} diff --git a/sdk/java/plugin-sdk/pom.xml b/sdk/java/plugin-sdk/pom.xml deleted file mode 100644 index 6ad56d196b..0000000000 --- a/sdk/java/plugin-sdk/pom.xml +++ /dev/null @@ -1,41 +0,0 @@ - - - 4.0.0 - - - io.github.lithe-idea - lithe-plugin-sdk-java-parent - 0.1.0 - - - lithe-plugin-sdk-java - Lithe plugin SDK for Java - Plugin functions and build providers over the LSP base protocol, for Lithe plugin backends. - - - - org.junit.jupiter - junit-jupiter - - - - - - - org.apache.maven.plugins - maven-jar-plugin - - - false - - lithe-plugin-sdk-java - ${project.version} - - - - - - - diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java deleted file mode 100644 index ad32a637e1..0000000000 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java +++ /dev/null @@ -1,1062 +0,0 @@ -package io.github.litheidea.plugin.sdk; - -import java.io.FileDescriptor; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.io.PrintStream; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.function.Supplier; - -/** - * The plugin backend session: one process the host starts from the plugin's - * manifest and talks to over stdio with the LSP base protocol. - * - *

The SDK owns everything Lithe manages uniformly -- the lifecycle, protocol - * negotiation, cancellation, error codes, and the {@code lithe/*} namespace -- - * and leaves the plugin free to decide what its handlers do. Declare handlers, - * then call {@link #listen()}. Handlers run on their own threads, so several - * calls can be in flight at once; shared plugin state needs its own locking. - * - *

Contract: {@code shared/contracts/plugins/v1/README.md} ("Build providers" - * and "Plugin functions"). Behaviour and error codes match the Node.js SDK. - */ -public final class Backend { - /** Answers a typed build request or a plugin function. */ - @FunctionalInterface - public interface Handler { - Object handle(Map params, CallContext context) throws Exception; - } - - /** Answers a standard LSP request, for a backend that is also a language server. */ - @FunctionalInterface - public interface RequestHandler { - Object handle(Object params, CallContext context) throws Exception; - } - - /** Handles a standard LSP notification. */ - @FunctionalInterface - public interface NotificationHandler { - void handle(Object params) throws Exception; - } - - /** Runs before the {@code initialize} answer. */ - @FunctionalInterface - public interface InitializeHook { - void run(Map params, Negotiated negotiated) throws Exception; - } - - /** Runs on {@code shutdown}, after running calls were cancelled. */ - @FunctionalInterface - public interface ShutdownHook { - void run() throws Exception; - } - - /** Protocols the host agreed to in {@code initialize}. */ - public static final class Negotiated { - static final Negotiated NONE = new Negotiated(false, false); - - private final boolean functions; - private final boolean build; - - Negotiated(boolean functions, boolean build) { - this.functions = functions; - this.build = build; - } - - /** Plugin functions, {@code litheFunctions} version 1. */ - public boolean functions() { - return functions; - } - - /** Typed build requests, {@code litheBuild} version 1. */ - public boolean build() { - return build; - } - } - - private enum Phase { IDLE, LISTENING, INITIALIZING, READY, SHUTTING_DOWN, EXITED } - - /** Canonical result format of each typed build request. */ - private static final Map BUILD_FORMATS; - - static { - Map formats = new HashMap<>(); - formats.put(Protocol.BUILD_MODEL, "litheBuildModel"); - formats.put(Protocol.BUILD_DEPENDENCIES, "litheDependencies"); - BUILD_FORMATS = Collections.unmodifiableMap(formats); - } - - private final String name; - private final String version; - private final Map capabilities; - private final Object lock = new Object(); - private final Object writeLock = new Object(); - private final Map functions = new HashMap<>(); - private final Map buildHandlers = new HashMap<>(); - private final Map requestHandlers = new HashMap<>(); - private final Map notificationHandlers = new HashMap<>(); - private final List initializeHooks = new ArrayList<>(); - private final List shutdownHooks = new ArrayList<>(); - /** Host requests being handled, by JSON-RPC ID. */ - private final Map incoming = new HashMap<>(); - /** Requests this backend sent to the host, by JSON-RPC ID. */ - private final Map outgoing = new HashMap<>(); - private final AtomicInteger nextRequestId = new AtomicInteger(1); - private final ExecutorService executor = Executors.newCachedThreadPool(new ThreadNamer()); - /** Serializes register/unregister so the host sees them in call order. */ - private CompletableFuture registrations = CompletableFuture.completedFuture(null); - private volatile Phase phase = Phase.IDLE; - private volatile Negotiated negotiated = Negotiated.NONE; - private volatile Map initializeParams; - private volatile OutputStream output; - private volatile PrintStream errorOutput = System.err; - private int exitCode = 1; - - private Backend(String name, String version, Map capabilities) { - Object experimental = capabilities.get("experimental"); - if (experimental instanceof Map - && (((Map) experimental).containsKey("litheBuild") - || ((Map) experimental).containsKey("litheFunctions"))) { - throw new PluginSdkError("sdk.capabilities.reserved", - "litheBuild and litheFunctions are negotiated by the SDK; declare handlers instead."); - } - this.name = name == null ? "lithe-plugin-backend" : name; - this.version = version; - this.capabilities = new LinkedHashMap<>(capabilities); - } - - /** - * Creates a backend. - * - * @param name reported as {@code serverInfo.name} and used as the log prefix - * @param version reported as {@code serverInfo.version}; may be null - */ - public static Backend create(String name, String version) { - return new Backend(name, version, Collections.emptyMap()); - } - - /** - * Creates a backend that also declares standard LSP server capabilities. - * {@code experimental.litheBuild} and {@code experimental.litheFunctions} - * are negotiated by the SDK and cannot be set here. - */ - public static Backend create(String name, String version, Map capabilities) { - return new Backend(name, version, capabilities == null ? Collections.emptyMap() : capabilities); - } - - /** Protocols the host agreed to in {@code initialize}; both false before it. */ - public Negotiated negotiated() { - return negotiated; - } - - /** The host's {@code initialize} params, or null before it arrives. */ - public Map initializeParams() { - return initializeParams; - } - - /** IDs of the functions this backend currently serves, sorted. */ - public List functionIds() { - synchronized (lock) { - List ids = new ArrayList<>(functions.keySet()); - Collections.sort(ids); - return ids; - } - } - - /** Answers {@code lithe/build/model} with a {@code litheBuildModel} document. */ - public Backend onModel(Handler handler) { - return declareBuild(Protocol.BUILD_MODEL, handler); - } - - /** Answers {@code lithe/build/dependencies} with a {@code litheDependencies} document. */ - public Backend onDependencies(Handler handler) { - return declareBuild(Protocol.BUILD_DEPENDENCIES, handler); - } - - /** - * Declares a plugin function offered from the start, in the {@code initialize} - * answer. To add one later, use {@link #registerFunction}. - * - * @param id dot-separated lowerCamelCase segments, such as {@code snapshot.refresh} - */ - public Backend function(String id, Handler handler) { - synchronized (lock) { - if (!beforeAnswer()) { - throw new PluginSdkError("sdk.function.lateDeclaration", - "After initialize, add functions with registerFunction()."); - } - checkNewFunction(id, handler); - functions.put(id, handler); - } - return this; - } - - /** Handles a standard LSP request, for a backend that is also a language server. */ - public Backend onRequest(String method, RequestHandler handler) { - checkDeclared(method, handler); - synchronized (lock) { - requestHandlers.put(method, handler); - } - return this; - } - - /** Handles a standard LSP notification. */ - public Backend onNotification(String method, NotificationHandler handler) { - checkDeclared(method, handler); - synchronized (lock) { - notificationHandlers.put(method, handler); - } - return this; - } - - /** Runs before the {@code initialize} answer, with the host's params and the negotiation. */ - public Backend onInitialize(InitializeHook hook) { - synchronized (lock) { - initializeHooks.add(hook); - } - return this; - } - - /** Runs on {@code shutdown}, after running calls were cancelled. */ - public Backend onShutdown(ShutdownHook hook) { - synchronized (lock) { - shutdownHooks.add(hook); - } - return this; - } - - /** - * Adds a function. Before the {@code initialize} answer (including from an - * {@link #onInitialize} hook) it joins the initial list; afterwards the host - * must accept it first. The future completes once the function is served. - */ - public CompletableFuture registerFunction(String id, Handler handler) { - synchronized (lock) { - if (beforeAnswer()) { - try { - checkNewFunction(id, handler); - functions.put(id, handler); - return CompletableFuture.completedFuture(null); - } catch (RuntimeException error) { - return failed(error); - } - } - } - return queueRegistration(() -> { - synchronized (lock) { - requireFunctions(); - checkNewFunction(id, handler); - // Installed before the request: the host may invoke it as soon as - // its registry changed, before this backend reads the reply. - functions.put(id, handler); - } - Map entry = new LinkedHashMap<>(); - entry.put("id", id); - Map params = new LinkedHashMap<>(); - params.put("functions", Collections.singletonList(entry)); - return request(Protocol.FUNCTION_REGISTER, params).handle((result, error) -> { - if (error != null) { - synchronized (lock) { - functions.remove(id); - } - throw new CompletionException(unwrap(error)); - } - return null; - }); - }); - } - - /** Withdraws a function. The host ends its running calls as revoked. */ - public CompletableFuture unregisterFunction(String id) { - synchronized (lock) { - if (beforeAnswer()) { - if (functions.remove(id) == null) { - return failed(unknownFunction(id)); - } - return CompletableFuture.completedFuture(null); - } - } - return queueRegistration(() -> { - synchronized (lock) { - requireFunctions(); - if (!functions.containsKey(id)) { - throw unknownFunction(id); - } - } - Map params = new LinkedHashMap<>(); - params.put("ids", Collections.singletonList(id)); - return request(Protocol.FUNCTION_UNREGISTER, params).thenApply(result -> { - List running = new ArrayList<>(); - synchronized (lock) { - functions.remove(id); - for (Call call : incoming.values()) { - if (id.equals(call.functionId)) { - running.add(call); - } - } - } - for (Call call : running) { - call.cancellation.cancel("The function was withdrawn."); - } - return null; - }); - }); - } - - /** Sends a standard LSP request to the host. {@code lithe/*} methods are reserved. */ - public CompletableFuture sendRequest(String method, Object params) { - checkOutgoing(method); - return request(method, params); - } - - /** Sends a standard LSP notification to the host. {@code lithe/*} methods are reserved. */ - public void sendNotification(String method, Object params) { - checkOutgoing(method); - Map message = message(null, method); - if (params != null) { - message.put("params", params); - } - write(message); - } - - /** Writes a diagnostic line to stderr, which the host keeps in its log. */ - public void log(String message) { - errorOutput.println("[" + name + "] " + message); - } - - /** Writes a diagnostic line and the failure's stack trace to stderr. */ - public void log(String message, Throwable error) { - PrintStream stream = errorOutput; - synchronized (stream) { - stream.println("[" + name + "] " + message + " " + error); - error.printStackTrace(stream); - } - } - - /** - * Serves the host over stdio until it sends {@code exit} or closes stdin, - * then exits the process with the session's exit code: 0 after a - * {@code shutdown}, 1 otherwise. {@code System.out} is redirected to stderr - * first, because stdout carries the protocol and a stray line there would - * end the session. - */ - public void listen() { - OutputStream protocol = new FileOutputStream(FileDescriptor.out); - System.setOut(System.err); - int code = listen(System.in, protocol, System.err); - System.exit(code); - } - - /** - * Serves the host over the given streams and returns the exit code once the - * session ended, without exiting the process. Blocks the calling thread. - */ - public int listen(InputStream input, OutputStream output, PrintStream errorOutput) { - synchronized (lock) { - if (phase != Phase.IDLE) { - throw new PluginSdkError("sdk.listen.repeated", "A backend listens once."); - } - phase = Phase.LISTENING; - this.output = output; - this.errorOutput = errorOutput; - } - Framing.Decoder decoder = new Framing.Decoder(); - byte[] chunk = new byte[64 * 1024]; - try { - while (phase != Phase.EXITED) { - int count = input.read(chunk); - if (count < 0) { - finish(phase == Phase.SHUTTING_DOWN ? 0 : 1); - break; - } - List messages; - try { - messages = decoder.push(chunk, 0, count); - } catch (Framing.FramingException error) { - log("Ending the session: " + error.getMessage()); - finish(1); - break; - } - for (Object message : messages) { - if (phase == Phase.EXITED) { - break; - } - dispatch(message); - } - } - } catch (IOException error) { - finish(1); - } - synchronized (lock) { - return exitCode; - } - } - - private void dispatch(Object message) { - if (message == Framing.MALFORMED) { - respond(null, null, error(Protocol.PARSE_ERROR, "The message is not valid JSON.")); - return; - } - // Valid JSON that is not a message object, such as `null` or a batch. - if (!(message instanceof Map)) { - respond(null, null, error(Protocol.INVALID_REQUEST, "The message is not a JSON-RPC object.")); - return; - } - Map object = Json.object(message); - Object id = object.get("id"); - Object method = object.get("method"); - if (!(method instanceof String)) { - if (id != null) { - settleOutgoing(object); - } - return; - } - if (id != null) { - handleRequest(id, (String) method, object.get("params")); - } else { - handleNotification((String) method, object.get("params")); - } - } - - private void handleRequest(Object id, String method, Object params) { - if (method.equals(Protocol.INITIALIZE)) { - initialize(id, params instanceof Map ? Json.object(params) : new LinkedHashMap<>()); - return; - } - if (method.equals(Protocol.SHUTDOWN)) { - shutdown(id); - return; - } - Phase current = phase; - if (current == Phase.LISTENING || current == Phase.INITIALIZING) { - respond(id, null, error(Protocol.SERVER_NOT_INITIALIZED, "The backend is not initialized.")); - return; - } - if (current != Phase.READY) { - respond(id, null, error(Protocol.INVALID_REQUEST, "The backend is shutting down.")); - return; - } - if (method.equals(Protocol.FUNCTION_INVOKE)) { - invokeFunction(id, params); - return; - } - Handler build; - RequestHandler handler; - synchronized (lock) { - build = negotiated.build() ? buildHandlers.get(method) : null; - handler = requestHandlers.get(method); - } - if (build != null) { - if (params != null && !(params instanceof Map)) { - respond(id, null, error(Protocol.INVALID_PARAMS, method + " params must be an object.")); - return; - } - Map arguments = Json.object(params); - run(id, null, null, context -> build.handle(arguments, context), result -> buildResult(method, result), null); - return; - } - if (handler != null) { - run(id, null, null, context -> handler.handle(params, context), result -> result, null); - return; - } - respond(id, null, error(Protocol.METHOD_NOT_FOUND, "Unsupported method: " + method)); - } - - private void handleNotification(String method, Object params) { - if (method.equals(Protocol.EXIT)) { - finish(phase == Phase.SHUTTING_DOWN ? 0 : 1); - return; - } - if (method.equals(Protocol.CANCEL_REQUEST)) { - Object id = params instanceof Map ? ((Map) params).get("id") : null; - Call call; - synchronized (lock) { - call = incoming.get(requestKey(id)); - } - if (call != null) { - call.cancellation.cancel("The request was cancelled."); - } - return; - } - if (phase != Phase.READY || method.equals(Protocol.INITIALIZED)) { - return; - } - NotificationHandler handler; - synchronized (lock) { - handler = notificationHandlers.get(method); - } - if (handler == null) { - return; - } - executor.execute(() -> { - try { - handler.handle(params); - } catch (Throwable error) { - log("Notification " + method + " failed:", error); - } - }); - } - - private void initialize(Object id, Map params) { - Negotiated agreed; - List hooks; - synchronized (lock) { - if (phase != Phase.LISTENING) { - respond(id, null, error(Protocol.INVALID_REQUEST, "initialize was already received.")); - return; - } - phase = Phase.INITIALIZING; - initializeParams = params; - Map offered = experimental(params.get("capabilities")); - agreed = new Negotiated( - offers(offered.get("litheFunctions"), Protocol.FUNCTIONS_VERSION), - !buildHandlers.isEmpty() && offers(offered.get("litheBuild"), Protocol.BUILD_VERSION)); - negotiated = agreed; - hooks = new ArrayList<>(initializeHooks); - } - executor.execute(() -> { - try { - for (InitializeHook hook : hooks) { - hook.run(params, agreed); - } - } catch (Throwable failure) { - log("initialize failed:", failure); - synchronized (lock) { - if (phase == Phase.INITIALIZING) { - phase = Phase.LISTENING; - } - } - respond(id, null, toResponseError(failure, null)); - return; - } - synchronized (lock) { - // A shutdown or exit that arrived while a hook ran ended the - // session; finishing initialize must not reopen it. - if (phase != Phase.INITIALIZING) { - respond(id, null, error(Protocol.INVALID_REQUEST, "The session ended during initialize.")); - return; - } - if (!agreed.functions() && !functions.isEmpty()) { - log("The host did not offer litheFunctions version 1; functions stay unavailable."); - } - Map experimental = new LinkedHashMap<>(experimental(capabilities)); - if (agreed.build()) { - experimental.put("litheBuild", singleton("version", Protocol.BUILD_VERSION)); - } - if (agreed.functions()) { - List offered = new ArrayList<>(); - for (String functionId : functionIds()) { - offered.add(singleton("id", functionId)); - } - Map entry = singleton("version", Protocol.FUNCTIONS_VERSION); - entry.put("functions", offered); - experimental.put("litheFunctions", entry); - } - Map answered = new LinkedHashMap<>(capabilities); - answered.put("experimental", experimental); - Map serverInfo = singleton("name", name); - if (version != null) { - serverInfo.put("version", version); - } - Map result = singleton("capabilities", answered); - result.put("serverInfo", serverInfo); - phase = Phase.READY; - respond(id, result, null); - } - }); - } - - private void shutdown(Object id) { - List hooks; - synchronized (lock) { - if (phase != Phase.READY && phase != Phase.LISTENING && phase != Phase.INITIALIZING) { - respond(id, null, error(Protocol.INVALID_REQUEST, "shutdown was already received.")); - return; - } - phase = Phase.SHUTTING_DOWN; - hooks = new ArrayList<>(shutdownHooks); - } - cancelAll("The backend is shutting down."); - executor.execute(() -> { - for (ShutdownHook hook : hooks) { - try { - hook.run(); - } catch (Throwable error) { - log("A shutdown hook failed:", error); - } - } - Processes.killAll(); - respond(id, null, null); - }); - } - - private void invokeFunction(Object id, Object raw) { - Map invocation = raw instanceof Map ? Json.object(raw) : new LinkedHashMap<>(); - Object functionId = invocation.get("functionID"); - Object operationId = invocation.get("operationID"); - Object params = invocation.get("params"); - Handler handler; - synchronized (lock) { - handler = negotiated.functions() && functionId instanceof String ? functions.get(functionId) : null; - } - if (handler == null) { - respond(id, null, error(Protocol.INVALID_PARAMS, "Function " + functionId + " is not registered.")); - return; - } - if (params != null && !(params instanceof Map)) { - respond(id, null, error(Protocol.INVALID_PARAMS, "Function " + functionId + " params must be an object.")); - return; - } - String function = (String) functionId; - String operation = operationId instanceof String ? (String) operationId : null; - Map arguments = Json.object(params); - AtomicBoolean reporting = new AtomicBoolean(true); - CallContext.ProgressSink progress = (message, percentage) -> { - if (!reporting.get()) { - return; - } - Map report = new LinkedHashMap<>(); - report.put("operationID", operationId); - if (message != null) { - report.put("message", Protocol.boundedText(message)); - } - if (percentage != null && !percentage.isNaN() && !percentage.isInfinite()) { - report.put("percentage", Math.min(100L, Math.max(0L, Math.round(percentage)))); - } - Map notification = message(null, Protocol.FUNCTION_PROGRESS); - notification.put("params", report); - write(notification); - }; - run(id, function, operation, context -> handler.handle(arguments, context), Backend::functionResult, - () -> reporting.set(false), progress); - } - - private interface Invocation { - Object invoke(CallContext context) throws Exception; - } - - private interface Shape { - Object apply(Object result); - } - - private void run(Object id, String functionId, String operationId, Invocation invoke, Shape shape, Runnable onSettled) { - run(id, functionId, operationId, invoke, shape, onSettled, null); - } - - /** Runs one handler with its own cancellation and answers exactly once. */ - private void run(Object id, String functionId, String operationId, Invocation invoke, Shape shape, - Runnable onSettled, CallContext.ProgressSink progress) { - Cancellation cancellation = new Cancellation(); - String key = requestKey(id); - synchronized (lock) { - incoming.put(key, new Call(cancellation, functionId)); - } - CallContext context = new CallContext(cancellation, this, functionId, operationId, progress); - executor.execute(() -> { - Object result = null; - Map error = null; - try { - Object value = invoke.invoke(context); - cancellation.throwIfCancelled(); - result = shape.apply(value); - } catch (Throwable failure) { - if (!cancellation.isCancelled()) { - log((functionId != null ? "Function " + functionId : "A request") + " failed:", failure); - } - error = toResponseError(failure, cancellation); - } - try { - if (onSettled != null) { - onSettled.run(); - } - } finally { - synchronized (lock) { - incoming.remove(key); - } - // Work belongs to its call: tools still running when the handler - // returned end now. Other calls have their own cancellation. - cancellation.cancel("The call ended."); - // Answered whatever the clean-up did, so Lithe never waits for - // its timeout. - respond(id, result, error); - } - }); - } - - private Backend declareBuild(String method, Handler handler) { - if (handler == null) { - throw new IllegalArgumentException("A build handler must not be null."); - } - synchronized (lock) { - if (phase != Phase.IDLE) { - throw new PluginSdkError("sdk.build.lateDeclaration", "Declare build handlers before listen()."); - } - buildHandlers.put(method, handler); - } - return this; - } - - private static void checkDeclared(String method, Object handler) { - if (method == null || Protocol.isReservedMethod(method)) { - throw new PluginSdkError("sdk.method.reserved", method + " is managed by the SDK and Lithe."); - } - if (handler == null) { - throw new IllegalArgumentException("A handler must not be null."); - } - } - - private void checkNewFunction(String id, Handler handler) { - if (!Protocol.isFunctionId(id)) { - throw new PluginSdkError("sdk.function.invalidID", "Function ID \"" + id - + "\" must be dot-separated lowerCamelCase segments of at most 128 bytes."); - } - if (handler == null) { - throw new IllegalArgumentException("A function handler must not be null."); - } - if (functions.containsKey(id)) { - throw new PluginSdkError("sdk.function.duplicate", "Function " + id + " is already declared."); - } - if (functions.size() >= Protocol.MAX_FUNCTIONS) { - throw new PluginSdkError("sdk.function.limit", - "A backend serves at most " + Protocol.MAX_FUNCTIONS + " functions."); - } - } - - /** True until the {@code initialize} answer fixes the initial function list. */ - private boolean beforeAnswer() { - return phase == Phase.IDLE || phase == Phase.LISTENING || phase == Phase.INITIALIZING; - } - - private void requireFunctions() { - if (phase != Phase.READY || !negotiated.functions()) { - throw new PluginSdkError("sdk.functions.unavailable", "The host has not negotiated plugin functions."); - } - } - - private static PluginSdkError unknownFunction(String id) { - return new PluginSdkError("sdk.function.unknown", "Function " + id + " is not registered."); - } - - private CompletableFuture queueRegistration(Supplier> change) { - synchronized (lock) { - CompletableFuture next = registrations.thenComposeAsync(ignored -> { - try { - return change.get(); - } catch (RuntimeException error) { - return failed(error); - } - }, executor); - registrations = next.handle((result, error) -> null); - return next; - } - } - - private void checkOutgoing(String method) { - if (method == null || Protocol.isReservedMethod(method)) { - throw new PluginSdkError("sdk.method.reserved", method - + " is reserved. A backend never asks the host to run a domain operation or a function."); - } - if (phase != Phase.READY) { - throw new PluginSdkError("sdk.session.notReady", "The session is not initialized."); - } - } - - private CompletableFuture request(String method, Object params) { - String id = "sdk-" + nextRequestId.getAndIncrement(); - CompletableFuture future = new CompletableFuture<>(); - synchronized (lock) { - if (phase == Phase.EXITED) { - future.completeExceptionally(new PluginSdkError("sdk.session.closed", "The session ended.")); - return future; - } - outgoing.put(id, new Pending(future, method)); - } - Map message = message(id, method); - if (params != null) { - message.put("params", params); - } - write(message); - return future; - } - - private void settleOutgoing(Map message) { - Object id = message.get("id"); - Pending pending; - synchronized (lock) { - pending = id instanceof String ? outgoing.remove(id) : null; - } - if (pending == null) { - return; - } - Object error = message.get("error"); - // Completed on a handler thread: the reader thread never runs plugin code. - if (error instanceof Map) { - Map failure = Json.object(error); - Object data = failure.get("data"); - String code = data instanceof Map ? Json.string(Json.object(data), "code") : null; - String text = Json.string(failure, "message"); - Object rpcCode = failure.get("code"); - PluginSdkError rejection = new PluginSdkError( - code == null ? "sdk.host.error" : code, - text == null ? pending.method + " failed." : text, - rpcCode instanceof Number ? ((Number) rpcCode).intValue() : null); - executor.execute(() -> pending.future.completeExceptionally(rejection)); - } else { - Object result = message.get("result"); - executor.execute(() -> pending.future.complete(result)); - } - } - - private void cancelAll(String reason) { - List calls; - synchronized (lock) { - calls = new ArrayList<>(incoming.values()); - } - for (Call call : calls) { - call.cancellation.cancel(reason); - } - } - - private void finish(int code) { - List pending; - synchronized (lock) { - if (phase == Phase.EXITED) { - return; - } - phase = Phase.EXITED; - exitCode = code; - pending = new ArrayList<>(outgoing.values()); - outgoing.clear(); - } - cancelAll("The session ended."); - Processes.killAll(); - for (Pending request : pending) { - request.future.completeExceptionally(new PluginSdkError("sdk.session.closed", "The session ended.")); - } - // Running handlers finish on their own threads; nothing new starts. - executor.shutdown(); - } - - private void respond(Object id, Object result, Map error) { - if (phase == Phase.EXITED) { - return; - } - Map message = message(id, null); - if (error != null) { - message.put("error", error); - } else { - message.put("result", result); - } - try { - writeEncoded(Framing.encode(message)); - } catch (Json.JsonException failure) { - // A result JSON cannot encode must not end the session. - log("The result could not be encoded:", failure); - Map fallback = message(id, null); - fallback.put("error", toResponseError(failure, null)); - write(fallback); - } - } - - private void write(Map message) { - writeEncoded(Framing.encode(message)); - } - - private void writeEncoded(byte[] frame) { - synchronized (writeLock) { - OutputStream stream = output; - if (stream == null || phase == Phase.EXITED) { - return; - } - try { - stream.write(frame); - stream.flush(); - } catch (IOException error) { - // The host closed the pipe; the input side ends the session. - log("Writing to the host failed: " + error.getMessage()); - } - } - } - - /** - * The JSON-RPC error object for a handler failure. Only {@link PluginError} - * carries a plugin code; any other exception becomes a generic internal - * error so that stack traces and machine paths never reach the host's UI. - */ - static Map toResponseError(Throwable failure, Cancellation cancellation) { - Throwable error = unwrap(failure); - if (cancellation != null && cancellation.isCancelled() && error instanceof CancelledException) { - return error(Protocol.REQUEST_CANCELLED, "The request was cancelled."); - } - if (error instanceof PluginError) { - Map response = error(Protocol.REQUEST_FAILED, Protocol.boundedText(error.getMessage())); - response.put("data", singleton("code", ((PluginError) error).code())); - return response; - } - // Unexpected exceptions often embed absolute paths or internals; the - // caller logs the details to stderr instead. - return error(Protocol.INTERNAL_ERROR, "The plugin backend failed unexpectedly."); - } - - /** - * Stamps the canonical format on a build result, so a handler can return the - * plain document. A result in another format is a backend bug: Core would - * refuse it as {@code plugin.build.invalidOutput}, so it fails here with a log. - */ - static Object buildResult(String method, Object result) { - String expected = BUILD_FORMATS.get(method); - if (!(result instanceof Map)) { - throw new PluginSdkError("sdk.build.invalidResult", method + " must return a " + expected + " object."); - } - Map document = new LinkedHashMap<>(Json.object(result)); - Object format = document.get("format"); - if (format != null && !expected.equals(format)) { - throw new PluginSdkError("sdk.build.invalidResult", - method + " returned format " + format + ", not " + expected + "."); - } - Object version = document.get("version"); - if (version != null && !(version instanceof Number && ((Number) version).doubleValue() == 1)) { - throw new PluginSdkError("sdk.build.invalidResult", method + " returned version " + version + ", not 1."); - } - document.put("format", expected); - document.put("version", 1); - return document; - } - - /** - * A function result as the JSON value Core accepts. A value JSON cannot - * encode, or one over Core's 1 MiB limit, fails here as a backend bug with a - * log line, instead of breaking the response frame or being refused by Core. - */ - static Object functionResult(Object result) { - String encoded; - try { - encoded = Json.stringify(result); - } catch (Json.JsonException error) { - throw new PluginSdkError("sdk.function.invalidResult", "The result is not JSON: " + error.getMessage()); - } - if (encoded.getBytes(StandardCharsets.UTF_8).length > Protocol.MAX_RESULT_BYTES) { - throw new PluginSdkError("sdk.function.invalidResult", - "The result exceeds " + Protocol.MAX_RESULT_BYTES + " bytes."); - } - return result; - } - - private static boolean offers(Object entry, int version) { - if (!(entry instanceof Map)) { - return false; - } - Object versions = ((Map) entry).get("versions"); - if (!(versions instanceof List)) { - return false; - } - for (Object offered : (List) versions) { - if (offered instanceof Number && ((Number) offered).doubleValue() == version) { - return true; - } - } - return false; - } - - /** {@code capabilities.experimental}, or an empty map. */ - private static Map experimental(Object capabilities) { - Object experimental = capabilities instanceof Map ? ((Map) capabilities).get("experimental") : null; - return experimental instanceof Map ? Json.object(experimental) : Collections.emptyMap(); - } - - /** The JSON-RPC ID as a map key; numbers and strings never collide. */ - private static String requestKey(Object id) { - if (id instanceof Number) { - Number number = (Number) id; - return "number:" + (number.doubleValue() == number.longValue() ? Long.toString(number.longValue()) : number); - } - return (id instanceof String ? "string:" : "other:") + id; - } - - private static Map message(Object id, String method) { - Map message = new LinkedHashMap<>(); - message.put("jsonrpc", "2.0"); - if (id != null || method == null) { - message.put("id", id); - } - if (method != null) { - message.put("method", method); - } - return message; - } - - private static Map error(int code, String text) { - Map error = new LinkedHashMap<>(); - error.put("code", code); - error.put("message", text); - return error; - } - - private static Map singleton(String key, Object value) { - Map map = new LinkedHashMap<>(); - map.put(key, value); - return map; - } - - private static Throwable unwrap(Throwable error) { - Throwable current = error; - while (current instanceof CompletionException && current.getCause() != null) { - current = current.getCause(); - } - return current; - } - - private static CompletableFuture failed(Throwable error) { - CompletableFuture future = new CompletableFuture<>(); - future.completeExceptionally(error); - return future; - } - - /** One host request being handled. */ - private static final class Call { - final Cancellation cancellation; - final String functionId; - - Call(Cancellation cancellation, String functionId) { - this.cancellation = cancellation; - this.functionId = functionId; - } - } - - /** One request this backend sent to the host. */ - private static final class Pending { - final CompletableFuture future; - final String method; - - Pending(CompletableFuture future, String method) { - this.future = future; - this.method = method; - } - } - - /** Daemon threads, so a handler still running never keeps the process alive after exit. */ - private static final class ThreadNamer implements java.util.concurrent.ThreadFactory { - private final AtomicInteger next = new AtomicInteger(1); - - @Override - public Thread newThread(Runnable runnable) { - Thread thread = new Thread(runnable, "lithe-sdk-call-" + next.getAndIncrement()); - thread.setDaemon(true); - return thread; - } - } -} diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/CallContext.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/CallContext.java deleted file mode 100644 index 5193590824..0000000000 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/CallContext.java +++ /dev/null @@ -1,88 +0,0 @@ -package io.github.litheidea.plugin.sdk; - -import java.io.IOException; -import java.util.List; - -/** - * What a handler gets besides its params: the call's cancellation, tools that - * end with the call, the backend's log, and progress for plugin functions. - */ -public final class CallContext { - /** Sends one progress report; absent outside plugin functions. */ - interface ProgressSink { - void report(String message, Double percentage); - } - - private final Cancellation cancellation; - private final Backend backend; - private final String functionId; - private final String operationId; - private final ProgressSink progress; - - CallContext(Cancellation cancellation, Backend backend, String functionId, String operationId, ProgressSink progress) { - this.cancellation = cancellation; - this.backend = backend; - this.functionId = functionId; - this.operationId = operationId; - this.progress = progress; - } - - /** - * Cancelled when the host cancels the call, the backend shuts down, or the - * call ends; work a handler leaves running after it returned is stopped. - */ - public Cancellation cancellation() { - return cancellation; - } - - public boolean isCancelled() { - return cancellation.isCancelled(); - } - - /** Throws {@link CancelledException} once the call was cancelled. */ - public void throwIfCancelled() { - cancellation.throwIfCancelled(); - } - - /** - * Runs a tool that belongs to this call: cancelling the call or ending it - * stops the tool, and the backend shutting down ends it. - */ - public Processes.Result run(List command, Processes.Options options) throws IOException { - Processes.Options settings = options == null ? new Processes.Options() : options.copy(); - Cancellation own = settings.cancellation(); - settings.cancellation(own == null ? cancellation : cancellation.combinedWith(own)); - return Processes.run(command, settings); - } - - /** Runs a tool that belongs to this call with default options. */ - public Processes.Result run(List command) throws IOException { - return run(command, null); - } - - /** Writes a diagnostic line to stderr, which the host keeps in its log. */ - public void log(String message) { - backend.log(message); - } - - /** - * Reports progress of a plugin function. {@code message} is cut to 1024 - * bytes and {@code percentage} rounded into 0-100; either may be null. - * Reports after the call ended, and from build requests, are dropped. - */ - public void progress(String message, Double percentage) { - if (progress != null) { - progress.report(message, percentage); - } - } - - /** The function this call runs, or null for a build or LSP request. */ - public String functionId() { - return functionId; - } - - /** The host's operation ID of a function call, or null. */ - public String operationId() { - return operationId; - } -} diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Cancellation.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Cancellation.java deleted file mode 100644 index 007562ac24..0000000000 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Cancellation.java +++ /dev/null @@ -1,104 +0,0 @@ -package io.github.litheidea.plugin.sdk; - -import java.util.ArrayList; -import java.util.List; - -/** - * Cancellation of one call, or of work a plugin manages itself. Listeners run - * once, on the thread that cancels; a listener added after cancellation runs - * immediately. A listener that throws is logged to stderr and does not keep - * the others, or the thread that cancelled, from running on. Cancelling never interrupts threads, because an interrupt - * closes NIO channels a handler may be writing state through; long work checks - * {@link #isCancelled()} or {@link #throwIfCancelled()} instead. - */ -public final class Cancellation { - private final Object lock = new Object(); - private final List listeners = new ArrayList<>(); - private String reason; - - /** Cancels with {@code reason}; later calls have no effect. */ - public void cancel(String reason) { - List pending; - synchronized (lock) { - if (this.reason != null) { - return; - } - this.reason = reason == null ? "The request was cancelled." : reason; - pending = new ArrayList<>(listeners); - listeners.clear(); - } - for (Runnable listener : pending) { - runIsolated(listener); - } - } - - public boolean isCancelled() { - synchronized (lock) { - return reason != null; - } - } - - /** Throws {@link CancelledException} once cancelled. */ - public void throwIfCancelled() { - String current; - synchronized (lock) { - current = reason; - } - if (current != null) { - throw new CancelledException(current); - } - } - - /** - * Runs {@code listener} on cancellation. The returned handle removes it, - * for work that finished first. - */ - public Registration onCancel(Runnable listener) { - synchronized (lock) { - if (reason == null) { - listeners.add(listener); - return () -> { - synchronized (lock) { - listeners.remove(listener); - } - }; - } - } - runIsolated(listener); - return () -> {}; - } - - /** - * A token cancelled when either this one or {@code other} is; for work that - * a plugin may also stop for its own reasons. - */ - public Cancellation combinedWith(Cancellation other) { - Cancellation combined = new Cancellation(); - Registration first = onCancel(() -> combined.cancel(reasonOrDefault())); - Registration second = other.onCancel(() -> combined.cancel(other.reasonOrDefault())); - combined.onCancel(() -> { - first.remove(); - second.remove(); - }); - return combined; - } - - private static void runIsolated(Runnable listener) { - try { - listener.run(); - } catch (RuntimeException error) { - System.err.println("A cancellation listener failed: " + error); - } - } - - private String reasonOrDefault() { - synchronized (lock) { - return reason == null ? "The request was cancelled." : reason; - } - } - - /** Removes a cancellation listener. */ - public interface Registration { - void remove(); - } -} diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/CancelledException.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/CancelledException.java deleted file mode 100644 index dd016767e2..0000000000 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/CancelledException.java +++ /dev/null @@ -1,14 +0,0 @@ -package io.github.litheidea.plugin.sdk; - -/** - * Thrown when the call a piece of work belongs to was cancelled or ended. - * A handler normally lets it propagate; the SDK then answers the host with - * {@code RequestCancelled}. - */ -public class CancelledException extends RuntimeException { - private static final long serialVersionUID = 1L; - - public CancelledException(String message) { - super(message); - } -} diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Framing.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Framing.java deleted file mode 100644 index 36f76f63d8..0000000000 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Framing.java +++ /dev/null @@ -1,138 +0,0 @@ -package io.github.litheidea.plugin.sdk; - -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -/** - * LSP base-protocol framing: {@code Content-Length} headers followed by a UTF-8 - * JSON body. Lithe talks to every plugin backend this way, so the SDK owns the - * framing and plugin code never writes to stdout itself. - */ -public final class Framing { - /** Largest header block accepted from the host. */ - static final int MAX_HEADER_BYTES = 8192; - - /** Largest body accepted from the host. Larger frames end the session. */ - public static final int MAX_FRAME_BYTES = 64 * 1024 * 1024; - - /** Stands for a body that is not JSON; compared by identity, so no host message can mimic it. */ - public static final Object MALFORMED = new Object() { - @Override - public String toString() { - return "MALFORMED"; - } - }; - - private static final byte[] HEADER_END = {'\r', '\n', '\r', '\n'}; - - private Framing() {} - - /** A framing error the session cannot recover from. */ - public static final class FramingException extends RuntimeException { - private static final long serialVersionUID = 1L; - - FramingException(String message) { - super(message); - } - } - - /** Encodes one JSON-RPC message as a complete frame. */ - public static byte[] encode(Object message) { - byte[] body = Json.stringify(message).getBytes(StandardCharsets.UTF_8); - byte[] header = ("Content-Length: " + body.length + "\r\n\r\n").getBytes(StandardCharsets.US_ASCII); - byte[] frame = Arrays.copyOf(header, header.length + body.length); - System.arraycopy(body, 0, frame, header.length, body.length); - return frame; - } - - /** Incrementally splits a byte stream into decoded JSON-RPC messages. */ - public static final class Decoder { - private byte[] buffer = new byte[8192]; - private int length; - - /** - * Appends {@code count} bytes of {@code chunk} and returns every complete - * message they finished. A body that is not JSON is returned as - * {@link #MALFORMED} so the caller can report it without losing the - * framing position. - */ - public List push(byte[] chunk, int offset, int count) { - ensureCapacity(length + count); - System.arraycopy(chunk, offset, buffer, length, count); - length += count; - List messages = new ArrayList<>(); - int start = 0; - for (;;) { - int headerEnd = indexOfHeaderEnd(start); - int available = length - start; - // The same bound whether the header arrived whole or in pieces. - if ((headerEnd >= 0 && headerEnd - start > MAX_HEADER_BYTES) - || (headerEnd < 0 && available > MAX_HEADER_BYTES)) { - throw new FramingException("The message header is too long."); - } - if (headerEnd < 0) { - break; - } - int bodyLength = contentLength(new String(buffer, start, headerEnd - start, StandardCharsets.US_ASCII)); - int bodyStart = headerEnd + HEADER_END.length; - if ((long) length < (long) bodyStart + bodyLength) { - break; - } - String body = new String(buffer, bodyStart, bodyLength, StandardCharsets.UTF_8); - start = bodyStart + bodyLength; - try { - messages.add(Json.parse(body)); - } catch (Json.JsonException error) { - messages.add(MALFORMED); - } - } - if (start > 0) { - System.arraycopy(buffer, start, buffer, 0, length - start); - length -= start; - } - return messages; - } - - private int indexOfHeaderEnd(int from) { - for (int index = from; index + HEADER_END.length <= length; index++) { - if (buffer[index] == '\r' && buffer[index + 1] == '\n' - && buffer[index + 2] == '\r' && buffer[index + 3] == '\n') { - return index; - } - } - return -1; - } - - private void ensureCapacity(int required) { - if (required > buffer.length) { - buffer = Arrays.copyOf(buffer, Math.max(required, buffer.length * 2)); - } - } - } - - private static int contentLength(String header) { - for (String line : header.split("\r\n")) { - int separator = line.indexOf(':'); - if (separator < 0) { - continue; - } - if (!line.substring(0, separator).trim().equalsIgnoreCase("content-length")) { - continue; - } - String value = line.substring(separator + 1).trim(); - if (!value.matches("[0-9]+")) { - break; - } - // More than ten digits exceeds the limit whatever they are. - long length = value.length() > 10 ? Long.MAX_VALUE : Long.parseLong(value); - if (length > MAX_FRAME_BYTES) { - throw new FramingException("A message of " + value + " bytes exceeds the " - + MAX_FRAME_BYTES + "-byte limit."); - } - return (int) length; - } - throw new FramingException("A message has no valid Content-Length header."); - } -} diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Json.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Json.java deleted file mode 100644 index 64ff3f59cc..0000000000 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Json.java +++ /dev/null @@ -1,426 +0,0 @@ -package io.github.litheidea.plugin.sdk; - -import java.math.BigDecimal; -import java.math.BigInteger; -import java.util.ArrayList; -import java.util.Collection; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -/** - * Minimal JSON reader and writer for protocol messages and stored state. - * - *

The SDK carries its own codec so that a plugin can shade it next to any - * JSON library without version conflicts. Values map to plain Java types: - * objects to {@code Map} (insertion-ordered), arrays to - * {@code List}, strings to {@code String}, booleans to - * {@code Boolean}, {@code null} to {@code null}, integers to {@code Long} (or - * {@code BigInteger} beyond its range), and other numbers to {@code Double}. - */ -public final class Json { - /** Deeper input is refused instead of overflowing the reader's stack. */ - private static final int MAX_DEPTH = 512; - - private Json() {} - - /** A value that is not JSON, or one JSON cannot represent. */ - public static final class JsonException extends RuntimeException { - private static final long serialVersionUID = 1L; - - public JsonException(String message) { - super(message); - } - } - - /** Parses one JSON document; trailing content other than whitespace is an error. */ - public static Object parse(String text) { - Reader reader = new Reader(text); - reader.skipWhitespace(); - Object value = reader.value(0); - reader.skipWhitespace(); - if (reader.position != text.length()) { - throw reader.error("Unexpected content after the value"); - } - return value; - } - - /** - * Encodes {@code value}. Maps need string keys; numbers must be finite. - * Arrays of objects and any {@link Collection} encode as JSON arrays. - */ - public static String stringify(Object value) { - StringBuilder output = new StringBuilder(); - write(output, value, 0); - return output.toString(); - } - - /** {@code value} as an object, or an empty map when it is {@code null}. */ - @SuppressWarnings("unchecked") - public static Map object(Object value) { - if (value == null) { - return new LinkedHashMap<>(); - } - if (value instanceof Map) { - return (Map) value; - } - throw new JsonException("Expected a JSON object."); - } - - /** The string at {@code key}, or {@code null} when it is missing or not a string. */ - public static String string(Map object, String key) { - Object value = object.get(key); - return value instanceof String ? (String) value : null; - } - - private static void write(StringBuilder output, Object value, int depth) { - if (depth > MAX_DEPTH) { - throw new JsonException("The value is nested too deeply, or contains itself."); - } - if (value == null) { - output.append("null"); - } else if (value instanceof String || value instanceof Character) { - quote(output, value.toString()); - } else if (value instanceof Boolean) { - output.append(value.toString()); - } else if (value instanceof Double || value instanceof Float) { - output.append(decimal(((Number) value).doubleValue())); - } else if (value instanceof Long || value instanceof Integer || value instanceof Short - || value instanceof Byte || value instanceof BigInteger) { - output.append(value.toString()); - } else if (value instanceof BigDecimal) { - output.append(((BigDecimal) value).toString()); - } else if (value instanceof Map) { - output.append('{'); - boolean first = true; - for (Map.Entry entry : ((Map) value).entrySet()) { - if (!(entry.getKey() instanceof String)) { - throw new JsonException("Object keys must be strings."); - } - if (!first) { - output.append(','); - } - first = false; - quote(output, (String) entry.getKey()); - output.append(':'); - write(output, entry.getValue(), depth + 1); - } - output.append('}'); - } else if (value instanceof Collection) { - writeArray(output, ((Collection) value).toArray(), depth); - } else if (value instanceof Object[]) { - writeArray(output, (Object[]) value, depth); - } else { - throw new JsonException("A " + value.getClass().getName() + " is not a JSON value."); - } - } - - private static void writeArray(StringBuilder output, Object[] values, int depth) { - output.append('['); - for (int index = 0; index < values.length; index++) { - if (index > 0) { - output.append(','); - } - write(output, values[index], depth + 1); - } - output.append(']'); - } - - private static String decimal(double value) { - if (Double.isNaN(value) || Double.isInfinite(value)) { - throw new JsonException("JSON has no representation for " + value + "."); - } - // Integral values print without a fraction, as JavaScript prints them. - if (value == Math.rint(value) && Math.abs(value) < 1e15) { - return Long.toString((long) value); - } - return Double.toString(value); - } - - private static void quote(StringBuilder output, String text) { - output.append('"'); - for (int index = 0; index < text.length(); index++) { - char character = text.charAt(index); - switch (character) { - case '"': - output.append("\\\""); - break; - case '\\': - output.append("\\\\"); - break; - case '\n': - output.append("\\n"); - break; - case '\r': - output.append("\\r"); - break; - case '\t': - output.append("\\t"); - break; - case '\b': - output.append("\\b"); - break; - case '\f': - output.append("\\f"); - break; - default: - if (character < 0x20 || isLoneSurrogate(text, index)) { - // A lone surrogate has no UTF-8 encoding; escaping it - // keeps the frame valid, as JSON.stringify does. - output.append(String.format("\\u%04x", (int) character)); - } else { - output.append(character); - } - } - } - output.append('"'); - } - - private static boolean isLoneSurrogate(String text, int index) { - char character = text.charAt(index); - if (Character.isHighSurrogate(character)) { - return index + 1 >= text.length() || !Character.isLowSurrogate(text.charAt(index + 1)); - } - if (Character.isLowSurrogate(character)) { - return index == 0 || !Character.isHighSurrogate(text.charAt(index - 1)); - } - return false; - } - - private static final class Reader { - private final String text; - private int position; - - Reader(String text) { - this.text = text; - } - - Object value(int depth) { - if (depth > MAX_DEPTH) { - throw error("The document is nested too deeply"); - } - if (position >= text.length()) { - throw error("Unexpected end of input"); - } - char character = text.charAt(position); - switch (character) { - case '{': - return object(depth); - case '[': - return array(depth); - case '"': - return string(); - case 't': - return literal("true", Boolean.TRUE); - case 'f': - return literal("false", Boolean.FALSE); - case 'n': - return literal("null", null); - default: - if (character == '-' || (character >= '0' && character <= '9')) { - return number(); - } - throw error("Unexpected character"); - } - } - - private Map object(int depth) { - Map result = new LinkedHashMap<>(); - position++; - skipWhitespace(); - if (peek() == '}') { - position++; - return result; - } - for (;;) { - skipWhitespace(); - if (peek() != '"') { - throw error("Expected a property name"); - } - String key = string(); - skipWhitespace(); - expect(':'); - skipWhitespace(); - result.put(key, value(depth + 1)); - skipWhitespace(); - char next = peek(); - position++; - if (next == '}') { - return result; - } - if (next != ',') { - throw error("Expected ',' or '}'"); - } - } - } - - private List array(int depth) { - List result = new ArrayList<>(); - position++; - skipWhitespace(); - if (peek() == ']') { - position++; - return result; - } - for (;;) { - skipWhitespace(); - result.add(value(depth + 1)); - skipWhitespace(); - char next = peek(); - position++; - if (next == ']') { - return result; - } - if (next != ',') { - throw error("Expected ',' or ']'"); - } - } - } - - private String string() { - position++; - StringBuilder result = new StringBuilder(); - for (;;) { - if (position >= text.length()) { - throw error("Unterminated string"); - } - char character = text.charAt(position++); - if (character == '"') { - return result.toString(); - } - if (character < 0x20) { - throw error("Control character in string"); - } - if (character != '\\') { - result.append(character); - continue; - } - if (position >= text.length()) { - throw error("Unterminated escape"); - } - char escape = text.charAt(position++); - switch (escape) { - case '"': - case '\\': - case '/': - result.append(escape); - break; - case 'b': - result.append('\b'); - break; - case 'f': - result.append('\f'); - break; - case 'n': - result.append('\n'); - break; - case 'r': - result.append('\r'); - break; - case 't': - result.append('\t'); - break; - case 'u': - if (position + 4 > text.length()) { - throw error("Incomplete unicode escape"); - } - int code = 0; - for (int index = 0; index < 4; index++) { - int digit = Character.digit(text.charAt(position++), 16); - if (digit < 0) { - throw error("Invalid unicode escape"); - } - code = code * 16 + digit; - } - result.append((char) code); - break; - default: - throw error("Invalid escape"); - } - } - } - - private Object number() { - int start = position; - if (peek() == '-') { - position++; - } - if (peek() == '0') { - position++; - } else if (isDigit(peek())) { - digits(); - } else { - throw error("Invalid number"); - } - boolean integral = true; - if (peek() == '.') { - integral = false; - position++; - if (!isDigit(peek())) { - throw error("Invalid number"); - } - digits(); - } - if (peek() == 'e' || peek() == 'E') { - integral = false; - position++; - if (peek() == '+' || peek() == '-') { - position++; - } - if (!isDigit(peek())) { - throw error("Invalid number"); - } - digits(); - } - String literal = text.substring(start, position); - if (integral) { - BigInteger value = new BigInteger(literal); - return value.bitLength() < 64 ? (Object) value.longValue() : value; - } - return Double.parseDouble(literal); - } - - private void digits() { - while (isDigit(peek())) { - position++; - } - } - - private Object literal(String word, Object value) { - if (!text.startsWith(word, position)) { - throw error("Unexpected literal"); - } - position += word.length(); - return value; - } - - private void expect(char character) { - if (peek() != character) { - throw error("Expected '" + character + "'"); - } - position++; - } - - private char peek() { - return position < text.length() ? text.charAt(position) : '\0'; - } - - void skipWhitespace() { - while (position < text.length()) { - char character = text.charAt(position); - if (character != ' ' && character != '\t' && character != '\n' && character != '\r') { - return; - } - position++; - } - } - - JsonException error(String message) { - return new JsonException(message + " at offset " + position + "."); - } - - private static boolean isDigit(char character) { - return character >= '0' && character <= '9'; - } - } -} diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/PluginError.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/PluginError.java deleted file mode 100644 index aa7b972de9..0000000000 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/PluginError.java +++ /dev/null @@ -1,38 +0,0 @@ -package io.github.litheidea.plugin.sdk; - -/** - * A failure a handler reports on purpose. Core ends the call with the stable - * {@code plugin.function.failed} code and forwards {@link #code()} as - * {@code pluginErrorCode}, so the plugin's own UI can tell its failures apart. - * Lithe never branches on it. - * - *

The message is shown to the user as written (Core keeps its first 1024 - * bytes), so it must not contain stack traces or machine paths. - */ -public class PluginError extends RuntimeException { - private static final long serialVersionUID = 1L; - - private final String code; - - /** - * @param code identifier of at most 64 bytes, such as {@code maven.snapshotLocked} - * @param message user-facing text - */ - public PluginError(String code, String message) { - this(code, message, null); - } - - /** As {@link #PluginError(String, String)}; {@code cause} is kept for logs only. */ - public PluginError(String code, String message, Throwable cause) { - super(message, cause); - if (!Protocol.isPluginErrorCode(code)) { - throw new IllegalArgumentException("Plugin error code \"" + code - + "\" must start with a letter, use letters, digits, \".\", \"_\" or \"-\", and be at most 64 bytes."); - } - this.code = code; - } - - public String code() { - return code; - } -} diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/PluginSdkError.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/PluginSdkError.java deleted file mode 100644 index 043ba52c6e..0000000000 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/PluginSdkError.java +++ /dev/null @@ -1,32 +0,0 @@ -package io.github.litheidea.plugin.sdk; - -/** - * An SDK usage error raised to the plugin author, or a failure the host - * reported for a request this backend sent. {@link #rpcCode()} carries the - * host's JSON-RPC error code in the latter case. - */ -public class PluginSdkError extends RuntimeException { - private static final long serialVersionUID = 1L; - - private final String code; - private final Integer rpcCode; - - public PluginSdkError(String code, String message) { - this(code, message, null); - } - - public PluginSdkError(String code, String message, Integer rpcCode) { - super(message); - this.code = code; - this.rpcCode = rpcCode; - } - - public String code() { - return code; - } - - /** The host's JSON-RPC error code, or {@code null} for a local usage error. */ - public Integer rpcCode() { - return rpcCode; - } -} diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Processes.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Processes.java deleted file mode 100644 index 76c36ca7ae..0000000000 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Processes.java +++ /dev/null @@ -1,623 +0,0 @@ -package io.github.litheidea.plugin.sdk; - -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.lang.reflect.Field; -import java.lang.reflect.Method; -import java.nio.ByteBuffer; -import java.nio.CharBuffer; -import java.nio.charset.CharsetDecoder; -import java.nio.charset.CodingErrorAction; -import java.nio.charset.StandardCharsets; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.TimeUnit; -import java.util.function.Consumer; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -/** - * Child processes a backend starts for one call. - * - *

Lithe's host offers no process capability in protocol version 1, so a - * backend runs its tools itself; this helper makes sure that the call ending, - * cancelling it, or the backend shutting down also ends the tools it started. - * - *

Process trees: on macOS and Linux every tool leads its own process group, - * as in the Node SDK, on every Java version. Java has no API for that, so the - * tool starts through the system {@code perl}, which calls {@code setpgrp} and - * then replaces itself with the tool, keeping its PID. A stop signals the whole - * group, and once the tool exits on its own the members it left behind, such - * as a background worker, are killed too; descendants that leave the group - * with {@code setsid} are out of reach. Windows, and a system without - * {@code perl}, fall back to {@code ProcessHandle} on Java 9 and newer, found - * by reflection so the SDK still loads on Java 8; it reaches only the - * descendants still attached to the tool. Lithe starts every backend inside - * its own process scope ({@code LITHE_PLUGIN_BACKEND_SCOPE}) and ends whatever - * is left of it when the session ends; that remains the final boundary. - */ -public final class Processes { - /** - * Variables Lithe sets on the backend so it can find and end every process - * the backend started, even one that left its parent. A tool always - * inherits them, also when the plugin replaces the environment. - */ - static final String[] HOST_MARKERS = {"LITHE_PROCESS_SCOPE", "LITHE_PLUGIN_BACKEND_SCOPE"}; - - /** Every tool started through {@link #run} that has not exited or been stopped. */ - private static final Set RUNNING = Collections.newSetFromMap(new ConcurrentHashMap<>()); - - private static final ScheduledExecutorService TIMERS = Executors.newSingleThreadScheduledExecutor(runnable -> { - Thread thread = new Thread(runnable, "lithe-sdk-process-timer"); - thread.setDaemon(true); - return thread; - }); - - private static final ProcessTree TREE = ProcessTree.load(); - - private static final ProcessGroups GROUPS = ProcessGroups.load(); - - private Processes() {} - - /** Options of one {@link #run}; every setter returns this object. */ - public static final class Options { - private Path directory; - private Map environment; - private Cancellation cancellation; - private int maxOutputBytes = 1 << 20; - private long killGraceMillis = 2000; - private Consumer onStdout; - private Consumer onStderr; - - /** Working directory; defaults to the backend's. */ - public Options directory(Path value) { - directory = value; - return this; - } - - /** Replaces the environment; the host's process markers are carried over regardless. */ - public Options environment(Map value) { - environment = value; - return this; - } - - /** Cancelling it stops the tool and makes {@link #run} throw {@link CancelledException}. */ - public Options cancellation(Cancellation value) { - cancellation = value; - return this; - } - - /** Captured bytes per stream; default 1 MiB. Listeners still see everything. */ - public Options maxOutputBytes(int value) { - maxOutputBytes = value; - return this; - } - - /** Delay between the polite and the forced stop; default 2000. */ - public Options killGraceMillis(long value) { - killGraceMillis = value; - return this; - } - - /** Receives stdout as it arrives, decoded as UTF-8 without splitting characters. */ - public Options onStdout(Consumer value) { - onStdout = value; - return this; - } - - /** Receives stderr as it arrives, decoded as UTF-8 without splitting characters. */ - public Options onStderr(Consumer value) { - onStderr = value; - return this; - } - - Cancellation cancellation() { - return cancellation; - } - - Options copy() { - Options copy = new Options(); - copy.directory = directory; - copy.environment = environment; - copy.cancellation = cancellation; - copy.maxOutputBytes = maxOutputBytes; - copy.killGraceMillis = killGraceMillis; - copy.onStdout = onStdout; - copy.onStderr = onStderr; - return copy; - } - } - - /** Exit status and captured output of a tool. */ - public static final class Result { - private final int exitCode; - private final String stdout; - private final String stderr; - private final boolean truncated; - - Result(int exitCode, String stdout, String stderr, boolean truncated) { - this.exitCode = exitCode; - this.stdout = stdout; - this.stderr = stderr; - this.truncated = truncated; - } - - /** The exit code; on POSIX a tool ended by signal N reports 128 + N. */ - public int exitCode() { - return exitCode; - } - - public String stdout() { - return stdout; - } - - public String stderr() { - return stderr; - } - - /** True when either stream exceeded {@code maxOutputBytes}. */ - public boolean truncated() { - return truncated; - } - } - - /** - * Runs {@code command} without a shell and returns its exit status and - * captured output. A non-zero exit returns normally; the caller decides - * what it means for its tool. Cancellation stops the tool and throws - * {@link CancelledException} once it ended. - */ - public static Result run(List command, Options options) throws IOException { - Options settings = options == null ? new Options() : options; - Cancellation cancellation = settings.cancellation; - if (cancellation != null) { - cancellation.throwIfCancelled(); - } - if (command.isEmpty()) { - throw new IllegalArgumentException("A command needs an executable."); - } - ProcessBuilder builder = new ProcessBuilder(new ArrayList<>(command)); - if (settings.directory != null) { - builder.directory(settings.directory.toFile()); - } - if (settings.environment != null) { - Map environment = builder.environment(); - environment.clear(); - environment.putAll(settings.environment); - for (String name : HOST_MARKERS) { - String value = System.getenv(name); - if (value == null) { - environment.remove(name); - } else { - environment.put(name, value); - } - } - } - boolean grouped = GROUPS.available(); - if (grouped) { - builder.command(GROUPS.launcher(command, builder.environment())); - } - Process process = builder.start(); - // Tools get no input; an open pipe would let one wait on it forever. - process.getOutputStream().close(); - Running entry = new Running(process, grouped ? GROUPS.leader(process) : 0); - RUNNING.add(entry); - Capture stdout = new Capture(process.getInputStream(), settings.maxOutputBytes, settings.onStdout, "stdout"); - Capture stderr = new Capture(process.getErrorStream(), settings.maxOutputBytes, settings.onStderr, "stderr"); - Cancellation.Registration registration = cancellation == null - ? () -> {} - : cancellation.onCancel(() -> entry.stop(settings.killGraceMillis)); - int exitCode; - try { - exitCode = waitFor(process, entry); - registration.remove(); - // The tool ended on its own; what it left running, such as a - // background worker, ends with it. A stopped tool stays registered - // until its forced stop, which reaches the same members. - entry.endLeftovers(); - // A descendant out of reach can keep the inherited pipes open after - // the tool exited; the output then ends at the grace period. - stdout.join(settings.killGraceMillis); - stderr.join(settings.killGraceMillis); - } finally { - registration.remove(); - if (!entry.stopping()) { - RUNNING.remove(entry); - } - } - if (cancellation != null) { - cancellation.throwIfCancelled(); - } - String errors = stderr.text(); - if (grouped && exitCode == ProcessGroups.EXEC_FAILED && errors.startsWith(ProcessGroups.EXEC_FAILURE)) { - // The launcher could not start the tool; report it as ProcessBuilder would. - throw new IOException("Cannot run program \"" + command.get(0) + "\": " - + errors.substring(ProcessGroups.EXEC_FAILURE.length()).trim()); - } - return new Result(exitCode, stdout.text(), errors, stdout.truncated() || stderr.truncated()); - } - - /** Runs {@code command} with default options. */ - public static Result run(List command) throws IOException { - return run(command, null); - } - - /** - * Forcibly ends every tool still running. The backend calls it on shutdown, - * on {@code exit}, and when the host closes stdin, so no tool outlives the - * session that started it. - */ - public static void killAll() { - for (Running entry : new ArrayList<>(RUNNING)) { - entry.forceStop(); - } - RUNNING.clear(); - } - - /** Number of tools still running or being stopped, for tests and diagnostics. */ - public static int runningCount() { - return RUNNING.size(); - } - - private static int waitFor(Process process, Running entry) { - boolean interrupted = false; - try { - for (;;) { - try { - return process.waitFor(); - } catch (InterruptedException error) { - // The tool still belongs to this call; end it before giving - // the interrupt back to the caller. - interrupted = true; - entry.forceStop(); - } - } - } finally { - if (interrupted) { - Thread.currentThread().interrupt(); - } - } - } - - /** One started tool and its stop state. */ - private static final class Running { - private final Process process; - /** The tool's process group, or 0 when it has none of its own. */ - private final long group; - private final Object lock = new Object(); - private boolean stopping; - private List tree = Collections.emptyList(); - private ScheduledFuture forceTimer; - - Running(Process process, long group) { - this.process = process; - this.group = group; - } - - /** Kills what the tool left after exiting on its own, unless a stop owns that. */ - void endLeftovers() { - synchronized (lock) { - if (stopping) { - return; - } - } - if (group > 0) { - // While any member lives the group ID cannot be reused, so this - // reaches only what this tool started; an empty group is ignored. - GROUPS.signal(group, true); - } else { - // Windows keeps the parent link of an exited tool, so its - // descendants are still found there. - TREE.destroy(TREE.descendants(process), true); - } - } - - boolean stopping() { - synchronized (lock) { - return stopping; - } - } - - /** Asks the tree to stop, then forces it after {@code graceMillis}. */ - void stop(long graceMillis) { - synchronized (lock) { - if (stopping) { - return; - } - stopping = true; - if (group > 0) { - GROUPS.signal(group, false); - } else { - // Descendants are listed before the tool is signalled: once - // it exits they are reparented and no longer reachable from it. - tree = TREE.descendants(process); - TREE.destroy(tree, false); - } - // Also covers a tool that has not formed its group yet. - process.destroy(); - forceTimer = TIMERS.schedule(this::forceStop, graceMillis, TimeUnit.MILLISECONDS); - } - } - - void forceStop() { - List known; - synchronized (lock) { - stopping = true; - if (forceTimer != null) { - forceTimer.cancel(false); - } - known = new ArrayList<>(tree); - if (group <= 0) { - known.addAll(TREE.descendants(process)); - } - } - if (group > 0) { - GROUPS.signal(group, true); - } - TREE.destroy(known, true); - process.destroyForcibly(); - RUNNING.remove(this); - } - } - - /** Reads one output stream on its own thread, keeping at most {@code limit} bytes. */ - private static final class Capture { - private final ByteArrayOutputStream kept = new ByteArrayOutputStream(); - private final InputStream input; - private final int limit; - private final Consumer listener; - private final CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder() - .onMalformedInput(CodingErrorAction.REPLACE) - .onUnmappableCharacter(CodingErrorAction.REPLACE); - private final Thread thread; - private volatile boolean truncated; - - Capture(InputStream input, int limit, Consumer listener, String name) { - this.input = input; - this.limit = limit; - this.listener = listener; - thread = new Thread(this::read, "lithe-sdk-" + name); - thread.setDaemon(true); - thread.start(); - } - - private void read() { - byte[] chunk = new byte[8192]; - // Holds a multi-byte character split across reads until it is whole. - ByteBuffer pending = ByteBuffer.allocate(chunk.length + 8); - try { - int count; - while ((count = input.read(chunk)) >= 0) { - synchronized (kept) { - int room = Math.max(0, limit - kept.size()); - kept.write(chunk, 0, Math.min(room, count)); - if (count > room) { - truncated = true; - } - } - if (listener != null) { - pending.put(chunk, 0, count); - pending.flip(); - deliver(pending, false); - pending.compact(); - } - } - if (listener != null) { - pending.flip(); - deliver(pending, true); - } - } catch (IOException error) { - // The stream was closed after the grace period; what was read is kept. - } - } - - private void deliver(ByteBuffer bytes, boolean end) { - CharBuffer characters = CharBuffer.allocate(bytes.remaining() + 1); - decoder.decode(bytes, characters, end); - if (end) { - decoder.flush(characters); - } - characters.flip(); - if (characters.hasRemaining()) { - try { - listener.accept(characters.toString()); - } catch (RuntimeException error) { - System.err.println("An output listener failed: " + error); - } - } - } - - void join(long millis) { - try { - thread.join(Math.max(1, millis)); - if (thread.isAlive()) { - input.close(); - thread.join(millis); - } - } catch (InterruptedException error) { - Thread.currentThread().interrupt(); - } catch (IOException error) { - // Closing an output pipe that already failed changes nothing. - } - } - - String text() { - synchronized (kept) { - return new String(kept.toByteArray(), StandardCharsets.UTF_8); - } - } - - boolean truncated() { - return truncated; - } - } - - /** - * A process group per tool on macOS and Linux. The launcher is - * {@code perl -e SCRIPT -- command...}: it calls - * {@code setpgrp(0, 0)} and replaces itself with the tool, so the PID Java - * reports is the tool's and also its group ID. {@code PERL5OPT} would make - * perl load modules before the script runs, so it is withheld from perl and - * handed back to the tool. Signals go through {@code /bin/kill}, which - * Java 8 needs for a negative PID. - */ - private static final class ProcessGroups { - /** Exit code and stderr prefix of a launcher that could not start the tool. */ - static final int EXEC_FAILED = 127; - static final String EXEC_FAILURE = "lithe-sdk-exec-failed: "; - - private static final String SCRIPT = "my $has = shift; my $opt = shift;" - + " $ENV{PERL5OPT} = $opt if $has;" - + " setpgrp(0, 0);" - + " exec { $ARGV[0] } @ARGV;" - + " print STDERR '" + EXEC_FAILURE + "', \"$!\\n\"; exit " + EXEC_FAILED + ";"; - - /** A signal that has not run after this long is abandoned; the host scope remains. */ - private static final long SIGNAL_TIMEOUT_MILLIS = 2000; - - private final String perl; - private final String kill; - private final Method pid; - - private ProcessGroups(String perl, String kill, Method pid) { - this.perl = perl; - this.kill = kill; - this.pid = pid; - } - - static ProcessGroups load() { - if (System.getProperty("os.name", "").startsWith("Windows")) { - return new ProcessGroups(null, null, null); - } - Method pid; - try { - pid = Process.class.getMethod("pid"); - } catch (NoSuchMethodException error) { - // Java 8 keeps the PID in a private field of UNIXProcess. - pid = null; - } - return new ProcessGroups(executable("/usr/bin/perl", "/bin/perl"), - executable("/bin/kill", "/usr/bin/kill"), pid); - } - - private static String executable(String... candidates) { - for (String candidate : candidates) { - if (new File(candidate).canExecute()) { - return candidate; - } - } - return null; - } - - boolean available() { - return perl != null && kill != null; - } - - List launcher(List command, Map environment) { - String option = environment.remove("PERL5OPT"); - List launcher = new ArrayList<>(Arrays.asList(perl, "-e", SCRIPT, "--", - option == null ? "0" : "1", option == null ? "" : option)); - launcher.addAll(command); - return launcher; - } - - /** The tool's PID, which is its group ID; 0 when it cannot be read. */ - long leader(Process process) { - try { - if (pid != null) { - return ((Number) pid.invoke(process)).longValue(); - } - Field field = process.getClass().getDeclaredField("pid"); - field.setAccessible(true); - return field.getInt(process); - } catch (ReflectiveOperationException | RuntimeException error) { - return 0; - } - } - - /** Signals every member of {@code group}; an empty group is ignored. */ - void signal(long group, boolean forcibly) { - ProcessBuilder builder = new ProcessBuilder(kill, "-s", forcibly ? "KILL" : "TERM", "--", "-" + group); - File discard = new File("/dev/null"); - builder.redirectOutput(discard).redirectError(discard); - try { - Process signal = builder.start(); - signal.getOutputStream().close(); - if (!signal.waitFor(SIGNAL_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)) { - signal.destroyForcibly(); - } - } catch (IOException error) { - // The host's process scope ends what this could not reach. - } catch (InterruptedException error) { - Thread.currentThread().interrupt(); - } - } - } - - /** - * {@code ProcessHandle} through reflection: present from Java 9, absent on - * Java 8, where only the direct child can be stopped. - */ - private static final class ProcessTree { - private final Method descendants; - private final Method destroy; - private final Method destroyForcibly; - - private ProcessTree(Method descendants, Method destroy, Method destroyForcibly) { - this.descendants = descendants; - this.destroy = destroy; - this.destroyForcibly = destroyForcibly; - } - - static ProcessTree load() { - try { - Class handle = Class.forName("java.lang.ProcessHandle"); - return new ProcessTree( - Process.class.getMethod("descendants"), - handle.getMethod("destroy"), - handle.getMethod("destroyForcibly")); - } catch (ReflectiveOperationException error) { - return new ProcessTree(null, null, null); - } - } - - List descendants(Process process) { - if (descendants == null) { - return Collections.emptyList(); - } - try { - @SuppressWarnings("unchecked") - Stream stream = (Stream) descendants.invoke(process); - return stream.collect(Collectors.toList()); - } catch (ReflectiveOperationException | RuntimeException error) { - return Collections.emptyList(); - } - } - - void destroy(List handles, boolean forcibly) { - Method method = forcibly ? destroyForcibly : destroy; - if (method == null) { - return; - } - for (Object handle : handles) { - try { - method.invoke(handle); - } catch (ReflectiveOperationException | RuntimeException error) { - // The process already exited, or the platform refused; the - // host's process scope ends what is left. - } - } - } - } -} diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java deleted file mode 100644 index 0c7ce3b6a1..0000000000 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java +++ /dev/null @@ -1,109 +0,0 @@ -package io.github.litheidea.plugin.sdk; - -import java.nio.charset.StandardCharsets; -import java.util.regex.Pattern; - -/** - * Wire constants and validation shared with Rust Core - * ({@code rust/lithe-core/src/lsp/interface/plugin_function.rs}) and the Node.js - * SDK. The SDK checks the same rules locally so a plugin author sees a mistake - * when registering a handler instead of as a refused session at runtime. - */ -public final class Protocol { - /** Protocol versions this SDK can answer. */ - public static final int FUNCTIONS_VERSION = 1; - public static final int BUILD_VERSION = 1; - - public static final String INITIALIZE = "initialize"; - public static final String INITIALIZED = "initialized"; - public static final String SHUTDOWN = "shutdown"; - public static final String EXIT = "exit"; - public static final String CANCEL_REQUEST = "$/cancelRequest"; - public static final String FUNCTION_INVOKE = "lithe/function/invoke"; - public static final String FUNCTION_REGISTER = "lithe/function/register"; - public static final String FUNCTION_UNREGISTER = "lithe/function/unregister"; - public static final String FUNCTION_PROGRESS = "lithe/function/progress"; - public static final String BUILD_MODEL = "lithe/build/model"; - public static final String BUILD_DEPENDENCIES = "lithe/build/dependencies"; - - /** JSON-RPC and LSP error codes the SDK answers with. */ - public static final int PARSE_ERROR = -32700; - public static final int INVALID_REQUEST = -32600; - public static final int METHOD_NOT_FOUND = -32601; - public static final int INVALID_PARAMS = -32602; - public static final int INTERNAL_ERROR = -32603; - public static final int SERVER_NOT_INITIALIZED = -32002; - public static final int REQUEST_FAILED = -32000; - public static final int REQUEST_CANCELLED = -32800; - - public static final int MAX_FUNCTIONS = 256; - public static final int MAX_FUNCTION_ID_BYTES = 128; - public static final int MAX_PLUGIN_ERROR_CODE_BYTES = 64; - public static final int MAX_MESSAGE_BYTES = 1024; - public static final int MAX_RESULT_BYTES = 1 << 20; - - private static final Pattern FUNCTION_SEGMENT = Pattern.compile("[a-z][A-Za-z0-9]*"); - private static final Pattern PLUGIN_ERROR_CODE = Pattern.compile("[A-Za-z][A-Za-z0-9._-]*"); - - private Protocol() {} - - /** Dot-separated lowerCamelCase segments, for example {@code snapshot.refresh}. */ - public static boolean isFunctionId(String value) { - if (value == null || value.isEmpty() || utf8Length(value) > MAX_FUNCTION_ID_BYTES) { - return false; - } - for (String segment : value.split("\\.", -1)) { - if (!FUNCTION_SEGMENT.matcher(segment).matches()) { - return false; - } - } - return true; - } - - /** The plugin error code alphabet Core forwards as {@code pluginErrorCode}. */ - public static boolean isPluginErrorCode(String value) { - return value != null - && utf8Length(value) <= MAX_PLUGIN_ERROR_CODE_BYTES - && PLUGIN_ERROR_CODE.matcher(value).matches(); - } - - /** Cuts {@code value} to {@link #MAX_MESSAGE_BYTES} UTF-8 bytes without splitting a character. */ - public static String boundedText(String value) { - return boundedText(value, MAX_MESSAGE_BYTES); - } - - /** Cuts {@code value} to {@code limit} UTF-8 bytes without splitting a character. */ - public static String boundedText(String value, int limit) { - String text = value == null ? "" : value; - int used = 0; - int index = 0; - while (index < text.length()) { - int codePoint = text.codePointAt(index); - int size = codePoint < 0x80 ? 1 : codePoint < 0x800 ? 2 : codePoint < 0x10000 ? 3 : 4; - if (used + size > limit) { - return text.substring(0, index); - } - used += size; - index += Character.charCount(codePoint); - } - return text; - } - - /** - * Methods reserved for the SDK. A plugin cannot install its own handler for - * these, or send them to the host: the lifecycle belongs to the host's - * session management, and {@code lithe/*} belongs to Lithe's typed contracts. - */ - public static boolean isReservedMethod(String method) { - return method.equals(INITIALIZE) - || method.equals(INITIALIZED) - || method.equals(SHUTDOWN) - || method.equals(EXIT) - || method.equals(CANCEL_REQUEST) - || method.startsWith("lithe/"); - } - - static int utf8Length(String value) { - return value.getBytes(StandardCharsets.UTF_8).length; - } -} diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/StateStore.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/StateStore.java deleted file mode 100644 index 24761e254a..0000000000 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/StateStore.java +++ /dev/null @@ -1,198 +0,0 @@ -package io.github.litheidea.plugin.sdk; - -import java.io.IOException; -import java.net.URISyntaxException; -import java.net.URL; -import java.nio.ByteBuffer; -import java.nio.channels.FileChannel; -import java.nio.charset.StandardCharsets; -import java.nio.file.AtomicMoveNotSupportedException; -import java.nio.file.FileAlreadyExistsException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.nio.file.StandardCopyOption; -import java.nio.file.StandardOpenOption; -import java.nio.file.attribute.PosixFilePermissions; -import java.security.CodeSource; -import java.security.SecureRandom; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.regex.Pattern; - -/** - * Durable plugin state, in a directory the host gives the backend. - * - *

The host passes writable directories through the {@code ${stateDir}} and - * {@code ${workspaceStateDir}} launch variables; the installed package and the - * host's installation are read-only, because signatures and delta updates are - * computed over their exact bytes. Values are JSON; each key is one file - * replaced atomically, so a crash leaves either the old or the new value, - * never half of one. The envelope matches the Node.js SDK's, so either SDK - * reads what the other wrote. - */ -public final class StateStore { - /** Version of the envelope each value is stored in. */ - private static final int STORE_FORMAT = 1; - private static final Pattern KEY = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,127}"); - private static final SecureRandom RANDOM = new SecureRandom(); - - private final Path root; - - private StateStore(Path root) { - this.root = root; - } - - /** - * Opens a store in {@code directory}, creating the directory. Refuses a - * directory inside the plugin package, found as the nearest directory with - * a {@code plugin.json} above the JAR this class was loaded from. - * - * @param directory absolute path, normally the value of {@code ${workspaceStateDir}} - */ - public static StateStore open(Path directory) throws IOException { - return open(directory, findPackageDirectory()); - } - - /** - * Opens a store in {@code directory}, refusing one inside - * {@code packageDirectory}; null skips that check. - */ - public static StateStore open(Path directory, Path packageDirectory) throws IOException { - if (directory == null || !directory.isAbsolute()) { - throw new PluginSdkError("sdk.state.invalidDirectory", - "The state directory must be an absolute path passed by the host."); - } - // Checked before creating anything, so a refused path leaves the package untouched. - Path resolved = canonical(directory); - if (packageDirectory != null && resolved.startsWith(canonical(packageDirectory))) { - throw new PluginSdkError("sdk.state.readOnlyPackage", - "The state directory is inside the plugin package, which is read-only. Use ${stateDir} or ${workspaceStateDir}."); - } - Files.createDirectories(resolved); - return new StateStore(resolved); - } - - /** The resolved directory, for a backend that keeps its own files next to the store. */ - public Path directory() { - return root; - } - - /** The stored value, or {@code fallback} when the key is missing or unreadable. */ - public Object read(String key, Object fallback) { - Path path = path(key); - if (!Files.isRegularFile(path)) { - return fallback; - } - try { - Object envelope = Json.parse(new String(Files.readAllBytes(path), StandardCharsets.UTF_8)); - if (!(envelope instanceof Map)) { - return fallback; - } - Map fields = (Map) envelope; - Object format = fields.get("litheStateStore"); - if (!(format instanceof Number) || ((Number) format).doubleValue() != STORE_FORMAT - || !fields.containsKey("value")) { - return fallback; - } - return fields.get("value"); - } catch (IOException | RuntimeException error) { - return fallback; - } - } - - /** Stores {@code value}, which must be JSON-encodable, replacing the previous one. */ - public void write(String key, Object value) throws IOException { - Path path = path(key); - Map envelope = new LinkedHashMap<>(); - envelope.put("litheStateStore", STORE_FORMAT); - envelope.put("value", value); - byte[] body; - try { - body = Json.stringify(envelope).getBytes(StandardCharsets.UTF_8); - } catch (Json.JsonException error) { - // Refused before touching the file, so the stored value stays. - throw new PluginSdkError("sdk.state.invalidValue", "The value for " + key + " is not JSON-encodable."); - } - Path temporary = root.resolve(key + ".json." + Long.toHexString(RANDOM.nextLong()) + ".tmp"); - try { - createPrivate(temporary); - try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.WRITE)) { - ByteBuffer buffer = ByteBuffer.wrap(body); - while (buffer.hasRemaining()) { - channel.write(buffer); - } - channel.force(true); - } - try { - Files.move(temporary, path, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); - } catch (AtomicMoveNotSupportedException error) { - Files.move(temporary, path, StandardCopyOption.REPLACE_EXISTING); - } - } catch (IOException | RuntimeException error) { - Files.deleteIfExists(temporary); - throw error; - } - } - - /** Removes the key; a missing key is not an error. */ - public void delete(String key) throws IOException { - Files.deleteIfExists(path(key)); - } - - private Path path(String key) { - if (key == null || !KEY.matcher(key).matches() || key.endsWith(".tmp")) { - throw new PluginSdkError("sdk.state.invalidKey", "State key \"" + key - + "\" must be 1-128 letters, digits, \".\", \"_\" or \"-\", start with a letter or digit, and not end with \".tmp\"."); - } - return root.resolve(key + ".json"); - } - - /** Creates an empty file only the user can read, where the platform supports it. */ - private static void createPrivate(Path file) throws IOException { - try { - Files.createFile(file, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------"))); - } catch (UnsupportedOperationException error) { - Files.createFile(file); - } catch (FileAlreadyExistsException error) { - throw new IOException("A temporary state file already exists: " + file.getFileName(), error); - } - } - - /** Resolves symbolic links of the longest existing prefix of {@code path}. */ - static Path canonical(Path path) throws IOException { - Path absolute = path.toAbsolutePath().normalize(); - Path existing = absolute; - while (existing != null && !Files.exists(existing)) { - existing = existing.getParent(); - } - if (existing == null) { - return absolute; - } - return existing.toRealPath().resolve(existing.relativize(absolute)); - } - - /** The nearest directory with a {@code plugin.json} above the JAR this class came from. */ - static Path findPackageDirectory() { - try { - CodeSource source = StateStore.class.getProtectionDomain().getCodeSource(); - URL location = source == null ? null : source.getLocation(); - if (location == null) { - return null; - } - Path current = Paths.get(location.toURI()).toAbsolutePath(); - if (Files.isRegularFile(current)) { - current = current.getParent(); - } - while (current != null) { - if (Files.isRegularFile(current.resolve("plugin.json"))) { - return current; - } - current = current.getParent(); - } - return null; - } catch (URISyntaxException | RuntimeException error) { - return null; - } - } -} diff --git a/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BackendTest.java b/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BackendTest.java deleted file mode 100644 index db6e9dff0b..0000000000 --- a/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BackendTest.java +++ /dev/null @@ -1,515 +0,0 @@ -package io.github.litheidea.plugin.sdk; - -import static io.github.litheidea.plugin.sdk.Fixtures.map; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.Timeout; - -/** Protocol behaviour of {@link Backend}; mirrors {@code sdk/node/test/backend.test.mjs}. */ -@Timeout(value = 30, unit = TimeUnit.SECONDS) -class BackendTest { - private static Backend functionBackend() { - return Backend.create("test-backend", "1.0.0") - .function("snapshot.refresh", (params, context) -> map("echoed", params)) - .function("install.prepare", (params, context) -> null); - } - - private static Map invoke(String functionId, String operationId, Object params) { - return map("functionID", functionId, "operationID", operationId, "params", params); - } - - @SuppressWarnings("unchecked") - private static Map object(Object value) { - return (Map) value; - } - - private static Object errorCode(Map response) { - Object error = response.get("error"); - return error == null ? null : object(error).get("code"); - } - - @Test - void initializeAnswersTheOfferedProtocolsWithSortedFunctions() { - Backend backend = functionBackend().onModel((params, context) -> map("projects", Collections.emptyList())); - TestHost host = new TestHost(backend); - Map result = object(host.initialize().get("result")); - assertEquals( - map("litheBuild", map("version", 1L), - "litheFunctions", map("version", 1L, "functions", - Arrays.asList(map("id", "install.prepare"), map("id", "snapshot.refresh")))), - object(result.get("capabilities")).get("experimental")); - assertEquals(map("name", "test-backend", "version", "1.0.0"), result.get("serverInfo")); - assertTrue(backend.negotiated().functions()); - assertTrue(backend.negotiated().build()); - assertEquals(1, host.close()); - } - - @Test - void aHostThatOffersNothingGetsNoLitheCapabilities() { - TestHost quiet = new TestHost(functionBackend()); - assertEquals(map(), object(object(quiet.initialize(map()).get("result")).get("capabilities")).get("experimental")); - quiet.close(); - - // No build handler: litheBuild is never claimed. Version 2 only: no functions. - TestHost other = new TestHost(functionBackend()); - Map answer = object(other.initialize(map( - "litheBuild", map("versions", Arrays.asList(1L)), - "litheFunctions", map("versions", Arrays.asList(2L)))).get("result")); - assertEquals(map(), object(answer.get("capabilities")).get("experimental")); - other.close(); - } - - @Test - void functionIdsFollowTheSharedContractIncludingTheFixtureCases() throws IOException { - for (String id : Arrays.asList("snapshot.refresh", "install.prepare", "a", "x1.y2Z")) { - assertTrue(Protocol.isFunctionId(id), id); - } - StringBuilder long129 = new StringBuilder(); - for (int index = 0; index < 129; index++) { - long129.append('a'); - } - for (String id : Arrays.asList("", "Not-Valid", "snapshot..refresh", "1abc", "a_b", "é", long129.toString(), "a.")) { - assertFalse(Protocol.isFunctionId(id), id); - } - // Every ID the fixture accepts is accepted locally, and the fixture's - // malformed ID refused, so the SDK never declares what Core rejects. - Map fixture = object(Json.parse(new String(Files.readAllBytes( - Paths.get("../../../shared/contracts/plugins/v1/fixtures/function-protocol.json")), StandardCharsets.UTF_8))); - for (Object entry : (List) fixture.get("negotiation")) { - Map testCase = object(entry); - List ids = new ArrayList<>(); - Object experimental = object(testCase.get("capabilities")).get("experimental"); - Object functions = experimental == null ? null : object(experimental).get("litheFunctions"); - Object declared = functions == null ? null : object(functions).get("functions"); - if (declared != null) { - for (Object function : (List) declared) { - ids.add((String) object(function).get("id")); - } - } - String name = (String) testCase.get("name"); - if (name.contains("malformed")) { - assertTrue(ids.stream().anyMatch(id -> !Protocol.isFunctionId(id)), name); - } else if (object(testCase.get("expected")).get("functions") != null) { - assertTrue(ids.stream().allMatch(Protocol::isFunctionId), name); - } - } - PluginSdkError invalid = assertThrows(PluginSdkError.class, - () -> Backend.create(null, null).function("Bad-ID", (params, context) -> null)); - assertEquals("sdk.function.invalidID", invalid.code()); - PluginSdkError duplicate = assertThrows(PluginSdkError.class, () -> Backend.create(null, null) - .function("a", (params, context) -> null) - .function("a", (params, context) -> null)); - assertEquals("sdk.function.duplicate", duplicate.code()); - } - - @Test - void theLifecycleAndTheLitheNamespaceStayReservedForTheSdk() { - Backend backend = Backend.create(null, null); - for (String method : Arrays.asList("initialize", "shutdown", "exit", "$/cancelRequest", "lithe/build/model", "lithe/anything")) { - assertThrows(PluginSdkError.class, () -> backend.onRequest(method, (params, context) -> null), method); - } - PluginSdkError reserved = assertThrows(PluginSdkError.class, () -> Backend.create(null, null, - map("experimental", map("litheFunctions", map("version", 1))))); - assertEquals("sdk.capabilities.reserved", reserved.code()); - TestHost host = new TestHost(backend); - host.initialize(); - // A backend never asks the host to dispatch a domain operation or a function. - for (String method : Arrays.asList("lithe/function/invoke", "lithe/build/model", "lithe/host/process.run")) { - PluginSdkError error = assertThrows(PluginSdkError.class, () -> backend.sendRequest(method, map()), method); - assertEquals("sdk.method.reserved", error.code()); - } - host.close(); - } - - @Test - void requestsBeforeInitializeAreRefusedWithServerNotInitialized() { - TestHost host = new TestHost(functionBackend()); - Map response = host.request("lithe/function/invoke", invoke("snapshot.refresh", "op", map())); - assertEquals(-32002L, errorCode(response)); - host.close(); - } - - @Test - void anInvocationReturnsItsResultAndNullStaysNull() { - TestHost host = new TestHost(functionBackend()); - host.initialize(); - Map response = host.request("lithe/function/invoke", - invoke("snapshot.refresh", "op-1", map("project", "app"))); - assertEquals(map("echoed", map("project", "app")), response.get("result")); - Map empty = host.request("lithe/function/invoke", invoke("install.prepare", "op-2", null)); - assertTrue(empty.containsKey("result")); - assertNull(empty.get("result")); - Map unknown = host.request("lithe/function/invoke", invoke("missing", "op-3", null)); - assertEquals(-32602L, errorCode(unknown)); - host.close(); - } - - @Test - void progressEchoesTheOperationIsBoundedAndStopsOnceTheCallEnded() { - CountDownLatch release = new CountDownLatch(1); - AtomicReference late = new AtomicReference<>(); - StringBuilder longMessage = new StringBuilder(); - for (int index = 0; index < 2000; index++) { - longMessage.append('x'); - } - Backend backend = Backend.create(null, null).function("long.task", (params, context) -> { - context.progress(longMessage.toString(), 140.4); - context.progress("half", 49.6); - late.set(context); - Fixtures.await(release, "the test to release the call"); - return "done"; - }); - TestHost host = new TestHost(backend); - host.initialize(); - CompletableFuture> response = host.requestAsync("lithe/function/invoke", - invoke("long.task", "wire-7", null), 1001L); - Map first = object(host.waitFor( - message -> "lithe/function/progress".equals(message.get("method")), "progress").get("params")); - assertEquals("wire-7", first.get("operationID")); - assertEquals(1024, ((String) first.get("message")).length()); - assertEquals(100L, first.get("percentage")); - Map second = object(host.waitFor(message -> message.get("params") instanceof Map - && "half".equals(object(message.get("params")).get("message")), "the second report").get("params")); - assertEquals(50L, second.get("percentage")); - release.countDown(); - assertEquals("done", Fixtures.await(response, "the response").get("result")); - late.get().progress("too late", null); - host.close(); - assertTrue(host.messages().stream().noneMatch(message -> message.get("params") instanceof Map - && "too late".equals(object(message.get("params")).get("message")))); - } - - @Test - void aPluginErrorCarriesItsCodeAndOtherFailuresHideTheirDetails() { - Backend backend = Backend.create(null, null) - .function("locked", (params, context) -> { - throw new PluginError("maven.snapshotLocked", "Snapshot is locked."); - }) - .function("crash", (params, context) -> { - throw new IOException("No such file: /Users/someone/secret"); - }); - TestHost host = new TestHost(backend); - host.initialize(); - Map locked = host.request("lithe/function/invoke", invoke("locked", "a", null)); - assertEquals(map("code", -32000L, "message", "Snapshot is locked.", "data", map("code", "maven.snapshotLocked")), - locked.get("error")); - Map crash = object(host.request("lithe/function/invoke", invoke("crash", "b", null)).get("error")); - assertEquals(-32603L, crash.get("code")); - assertFalse(((String) crash.get("message")).contains("/Users/")); - assertFalse(crash.containsKey("data")); - assertThrows(IllegalArgumentException.class, () -> new PluginError("bad code!", "x")); - host.close(); - } - - @Test - void aFailingCancellationListenerNeitherSkipsTheOthersNorTheReply() { - CountDownLatch laterListenerRan = new CountDownLatch(1); - CountDownLatch started = new CountDownLatch(1); - CountDownLatch cancelled = new CountDownLatch(1); - Backend backend = Backend.create(null, null) - .function("listener.settles", (params, context) -> { - context.cancellation().onCancel(() -> { - throw new IllegalStateException("clean-up failed"); - }); - context.cancellation().onCancel(laterListenerRan::countDown); - return "ok"; - }) - .function("listener.cancelled", (params, context) -> { - context.cancellation().onCancel(() -> { - throw new IllegalStateException("clean-up failed"); - }); - context.cancellation().onCancel(cancelled::countDown); - started.countDown(); - Fixtures.await(cancelled, "the cancellation"); - context.throwIfCancelled(); - return "not reached"; - }); - TestHost host = new TestHost(backend); - host.initialize(); - // The call ending runs the listeners before the reply. - assertEquals("ok", host.request("lithe/function/invoke", invoke("listener.settles", "s", null)).get("result")); - Fixtures.await(laterListenerRan, "the listener after the failing one"); - // $/cancelRequest runs them on the read loop, which must keep reading. - CompletableFuture> response = host.requestAsync("lithe/function/invoke", - invoke("listener.cancelled", "c", null), 42L); - Fixtures.await(started, "the handler to start"); - host.notify("$/cancelRequest", map("id", 42L)); - assertEquals(-32800L, errorCode(Fixtures.await(response, "the cancelled call"))); - assertEquals("ok", host.request("lithe/function/invoke", invoke("listener.settles", "t", null)).get("result")); - host.close(); - } - - @Test - void cancelRequestCancelsTheHandlerAndAnswersRequestCancelled() { - CountDownLatch started = new CountDownLatch(1); - CountDownLatch cancelled = new CountDownLatch(1); - Backend backend = Backend.create(null, null).function("wait.forever", (params, context) -> { - context.cancellation().onCancel(cancelled::countDown); - started.countDown(); - Fixtures.await(cancelled, "the cancellation"); - context.throwIfCancelled(); - return "not reached"; - }); - TestHost host = new TestHost(backend); - host.initialize(); - CompletableFuture> response = host.requestAsync("lithe/function/invoke", - invoke("wait.forever", "c", null), 41L); - Fixtures.await(started, "the handler to start"); - host.notify("$/cancelRequest", map("id", 41L)); - assertEquals(-32800L, errorCode(Fixtures.await(response, "the response"))); - host.close(); - } - - @Test - void registerAndUnregisterFunctionGoThroughTheHostAllOrNothing() throws Exception { - Backend backend = Backend.create(null, null); - TestHost host = new TestHost(backend); - host.initialize(); - - CompletableFuture registering = backend.registerFunction("late.one", (params, context) -> "late"); - Map register = host.waitFor( - message -> "lithe/function/register".equals(message.get("method")), "the registration"); - assertEquals(map("functions", Arrays.asList(map("id", "late.one"))), register.get("params")); - host.send(map("id", register.get("id"), "result", null)); - Fixtures.await(registering, "the registration to settle"); - assertEquals("late", host.request("lithe/function/invoke", invoke("late.one", "d", null)).get("result")); - - // A refused registration leaves no handler behind. - CompletableFuture refused = backend.registerFunction("other.one", (params, context) -> 0); - Map second = host.waitFor(message -> "lithe/function/register".equals(message.get("method")) - && object(message.get("params")).toString().contains("other.one"), "the second registration"); - host.send(map("id", second.get("id"), "error", - map("code", -32602L, "message", "No.", "data", map("code", "plugin.function.invalidRegistration")))); - ExecutionException failure = assertThrows(ExecutionException.class, - () -> refused.get(TestHost.WAIT_MILLIS, TimeUnit.MILLISECONDS)); - assertEquals("plugin.function.invalidRegistration", ((PluginSdkError) unwrap(failure)).code()); - assertEquals(Arrays.asList("late.one"), backend.functionIds()); - - CompletableFuture unregistering = backend.unregisterFunction("late.one"); - Map unregister = host.waitFor( - message -> "lithe/function/unregister".equals(message.get("method")), "the withdrawal"); - assertEquals(map("ids", Arrays.asList("late.one")), unregister.get("params")); - host.send(map("id", unregister.get("id"), "result", null)); - Fixtures.await(unregistering, "the withdrawal to settle"); - assertEquals(Collections.emptyList(), backend.functionIds()); - host.close(); - } - - @Test - void buildHandlersGetTheCanonicalFormatStampedAndAWrongFormatFails() { - Backend backend = Backend.create(null, null) - .onModel((params, context) -> map("projects", Arrays.asList(map("name", "app", "path", params.get("root"))))) - .onDependencies((params, context) -> map("format", "litheBuildModel", "projects", Collections.emptyList())); - TestHost host = new TestHost(backend); - host.initialize(); - Map model = host.request("lithe/build/model", map("buildSystemID", "sample", "root", ".")); - assertEquals(map("projects", Arrays.asList(map("name", "app", "path", ".")), "format", "litheBuildModel", "version", 1L), - model.get("result")); - Map dependencies = host.request("lithe/build/dependencies", map("buildSystemID", "sample", "root", ".")); - assertEquals(-32603L, errorCode(dependencies)); - host.close(); - } - - @Test - void aBuildResultCannotReplaceTheVersionTheSdkStamps() { - Backend backend = Backend.create(null, null) - .onModel((params, context) -> map("version", 2, "projects", Collections.emptyList())) - .onDependencies((params, context) -> map("version", 1, "dependencies", Collections.emptyList())); - TestHost host = new TestHost(backend); - host.initialize(); - assertEquals(-32603L, errorCode(host.request("lithe/build/model", map("buildSystemID", "sample", "root", ".")))); - assertEquals(map("version", 1L, "dependencies", Collections.emptyList(), "format", "litheDependencies"), - host.request("lithe/build/dependencies", map("buildSystemID", "sample", "root", ".")).get("result")); - host.close(); - } - - @Test - void aResultJsonCannotEncodeOrOneOverOneMebibyteFailsAndTheSessionContinues() { - Map cycle = map(); - cycle.put("self", cycle); - char[] large = new char[1 << 20]; - Arrays.fill(large, 'x'); - Backend backend = Backend.create(null, null) - .function("result.object", (params, context) -> new Object()) - .function("result.cycle", (params, context) -> cycle) - .function("result.nan", (params, context) -> Double.NaN) - .function("result.large", (params, context) -> new String(large)) - .function("result.fine", (params, context) -> map("ok", true)) - .onModel((params, context) -> map("projects", Arrays.asList(map("name", "app", "path", ".", "size", new Object())))); - TestHost host = new TestHost(backend); - host.initialize(); - int operation = 0; - for (String functionId : Arrays.asList("result.object", "result.cycle", "result.nan", "result.large")) { - Map response = host.request("lithe/function/invoke", invoke(functionId, "op-" + operation++, null)); - assertEquals(-32603L, errorCode(response), functionId); - } - assertEquals(-32603L, errorCode(host.request("lithe/build/model", map("buildSystemID", "sample", "root", ".")))); - assertEquals(map("ok", true), host.request("lithe/function/invoke", invoke("result.fine", "op-last", null)).get("result")); - assertEquals(1, host.close()); - } - - @Test - void buildRequestsAreRefusedWhenTheHostDidNotNegotiateLitheBuild() { - Backend backend = Backend.create(null, null).onModel((params, context) -> map("projects", Collections.emptyList())); - TestHost host = new TestHost(backend); - host.initialize(map("litheFunctions", map("versions", Arrays.asList(1L)))); - assertEquals(-32601L, errorCode(host.request("lithe/build/model", map("buildSystemID", "sample", "root", ".")))); - host.close(); - } - - @Test - void shutdownCancelsRunningCallsAndRunsHooksAndExitAfterShutdownIsZero() { - CountDownLatch started = new CountDownLatch(1); - List order = Collections.synchronizedList(new ArrayList<>()); - Backend backend = Backend.create(null, null) - .function("wait.forever", (params, context) -> { - CountDownLatch cancelled = new CountDownLatch(1); - context.cancellation().onCancel(cancelled::countDown); - started.countDown(); - Fixtures.await(cancelled, "the shutdown to cancel the call"); - context.throwIfCancelled(); - return null; - }) - .onShutdown(() -> order.add("hook")); - TestHost host = new TestHost(backend); - host.initialize(); - CompletableFuture> call = host.requestAsync("lithe/function/invoke", - invoke("wait.forever", "e", null), 2001L); - Fixtures.await(started, "the handler to start"); - Map shutdown = host.request("shutdown", null); - assertTrue(shutdown.containsKey("result")); - assertNull(shutdown.get("result")); - assertEquals(-32800L, errorCode(Fixtures.await(call, "the cancelled call"))); - assertEquals(Arrays.asList("hook"), order); - assertEquals(-32600L, errorCode(host.request("lithe/function/invoke", invoke("wait.forever", "f", null)))); - host.notify("exit", null); - assertEquals(0, host.exitCode()); - } - - @Test - void exitWithoutShutdownOrAClosedInputEndsWithCodeOne() { - TestHost host = new TestHost(Backend.create(null, null)); - host.initialize(); - host.notify("exit", null); - assertEquals(1, host.exitCode()); - assertEquals(1, new TestHost(Backend.create(null, null)).close()); - } - - @Test - void aMalformedBodyIsAnsweredWithAParseErrorAndTheSessionContinues() { - TestHost host = new TestHost(functionBackend()); - byte[] body = "{not json".getBytes(StandardCharsets.UTF_8); - host.sendBytes(("Content-Length: " + body.length + "\r\n\r\n{not json").getBytes(StandardCharsets.UTF_8)); - Map parseError = host.waitFor(message -> message.get("error") instanceof Map - && Long.valueOf(-32700L).equals(object(message.get("error")).get("code")), "the parse error"); - assertTrue(parseError.containsKey("id")); - assertNull(parseError.get("id")); - assertNotNull(host.initialize().get("result")); - host.close(); - } - - @Test - void aJsonValueThatIsNotAMessageObjectIsRefusedAndTheSessionContinues() { - TestHost host = new TestHost(functionBackend()); - host.sendBytes(Framing.encode(Arrays.asList(1, 2))); - host.waitFor(message -> message.get("error") instanceof Map - && Long.valueOf(-32600L).equals(object(message.get("error")).get("code")), "the refusal"); - assertNotNull(host.initialize().get("result")); - host.close(); - } - - @Test - void registeringFromAnInitializeHookJoinsTheInitialListWithoutARequest() { - Backend backend = Backend.create(null, null).function("a.one", (params, context) -> 1); - backend.onInitialize((params, negotiated) -> { - backend.registerFunction("b.two", (arguments, context) -> 2); - backend.unregisterFunction("a.one"); - }); - TestHost host = new TestHost(backend); - Map experimental = object(object(object(host.initialize().get("result")).get("capabilities")).get("experimental")); - assertEquals(Arrays.asList(map("id", "b.two")), object(experimental.get("litheFunctions")).get("functions")); - assertTrue(host.messages().stream().noneMatch(message -> message.get("method") instanceof String - && ((String) message.get("method")).startsWith("lithe/function/"))); - host.close(); - } - - @Test - void runtimeRegistrationNeedsTheNegotiatedProtocol() { - Backend backend = Backend.create(null, null); - TestHost host = new TestHost(backend); - host.initialize(map()); - ExecutionException failure = assertThrows(ExecutionException.class, () -> backend - .registerFunction("late.one", (params, context) -> 1).get(TestHost.WAIT_MILLIS, TimeUnit.MILLISECONDS)); - assertEquals("sdk.functions.unavailable", ((PluginSdkError) unwrap(failure)).code()); - host.close(); - } - - @Test - void aShutdownDuringASlowInitializeHookKeepsTheSessionClosed() { - CountDownLatch hookStarted = new CountDownLatch(1); - CountDownLatch release = new CountDownLatch(1); - Backend backend = Backend.create(null, null).onInitialize((params, negotiated) -> { - hookStarted.countDown(); - Fixtures.await(release, "the test to release initialize"); - }); - TestHost host = new TestHost(backend); - CompletableFuture> initialize = host.requestAsync("initialize", - map("capabilities", map()), 3001L); - Fixtures.await(hookStarted, "the initialize hook"); - assertNull(host.request("shutdown", null).get("error")); - release.countDown(); - assertEquals(-32600L, errorCode(Fixtures.await(initialize, "the initialize answer"))); - assertEquals(-32600L, errorCode(host.request("lithe/function/invoke", invoke("x", "y", null)))); - host.notify("exit", null); - assertEquals(0, host.exitCode()); - } - - @Test - void notificationsReachTheirHandlerOnlyOnceReady() { - CountDownLatch received = new CountDownLatch(1); - AtomicBoolean early = new AtomicBoolean(); - Backend backend = Backend.create(null, null).onNotification("workspace/didChangeConfiguration", params -> { - if (!(params instanceof Map)) { - early.set(true); - } - received.countDown(); - }); - TestHost host = new TestHost(backend); - host.notify("workspace/didChangeConfiguration", "before"); - host.initialize(); - host.notify("workspace/didChangeConfiguration", map("settings", map())); - Fixtures.await(received, "the notification"); - assertFalse(early.get()); - host.close(); - } - - private static Throwable unwrap(Throwable error) { - Throwable current = error; - while ((current instanceof ExecutionException || current instanceof CompletionException) && current.getCause() != null) { - current = current.getCause(); - } - return current; - } -} diff --git a/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/Fixtures.java b/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/Fixtures.java deleted file mode 100644 index c7f13e43db..0000000000 --- a/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/Fixtures.java +++ /dev/null @@ -1,133 +0,0 @@ -package io.github.litheidea.plugin.sdk; - -import java.io.IOException; -import java.io.UncheckedIOException; -import java.nio.file.FileVisitResult; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.SimpleFileVisitor; -import java.nio.file.attribute.BasicFileAttributes; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; -import java.util.function.BooleanSupplier; - -/** Small helpers shared by the SDK tests. */ -final class Fixtures { - private Fixtures() {} - - /** A map from alternating keys and values, in order. */ - static Map map(Object... entries) { - Map map = new LinkedHashMap<>(); - for (int index = 0; index < entries.length; index += 2) { - map.put((String) entries[index], entries[index + 1]); - } - return map; - } - - /** The future's value within {@link TestHost#WAIT_MILLIS}. */ - static T await(CompletableFuture future, String description) { - try { - return future.get(TestHost.WAIT_MILLIS, TimeUnit.MILLISECONDS); - } catch (TimeoutException error) { - throw new AssertionError("Timed out waiting for " + description + ".", error); - } catch (InterruptedException error) { - Thread.currentThread().interrupt(); - throw new AssertionError("Interrupted waiting for " + description + ".", error); - } catch (ExecutionException error) { - throw new AssertionError(description + " failed.", error.getCause()); - } - } - - /** Waits for {@code latch} within {@link TestHost#WAIT_MILLIS}. */ - static void await(CountDownLatch latch, String description) { - try { - if (!latch.await(TestHost.WAIT_MILLIS, TimeUnit.MILLISECONDS)) { - throw new AssertionError("Timed out waiting for " + description + "."); - } - } catch (InterruptedException error) { - Thread.currentThread().interrupt(); - throw new AssertionError("Interrupted waiting for " + description + ".", error); - } - } - - /** - * Bounded poll of an OS-level fact, such as a process having exited, that - * offers the test no notification channel. - */ - static void eventually(BooleanSupplier check, String description) { - long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(TestHost.WAIT_MILLIS); - while (!check.getAsBoolean()) { - if (System.nanoTime() > deadline) { - throw new AssertionError("Timed out waiting for " + description + "."); - } - try { - // test-stability: allow(real-sleep) reason: the polled fact is a separate OS process's state with no notification channel; the deadline above bounds the loop. - Thread.sleep(10); - } catch (InterruptedException error) { - Thread.currentThread().interrupt(); - throw new AssertionError("Interrupted waiting for " + description + ".", error); - } - } - } - - /** True while the POSIX process {@code pid} exists. */ - static boolean isAlive(long pid) { - try { - Process probe = new ProcessBuilder("kill", "-0", Long.toString(pid)).start(); - if (!probe.waitFor(TestHost.WAIT_MILLIS, TimeUnit.MILLISECONDS)) { - probe.destroyForcibly(); - throw new AssertionError("kill -0 did not answer."); - } - return probe.exitValue() == 0; - } catch (IOException error) { - throw new UncheckedIOException(error); - } catch (InterruptedException error) { - Thread.currentThread().interrupt(); - throw new AssertionError(error); - } - } - - /** The PID a tool wrote to {@code file}, once it is there. */ - static long awaitPid(Path file) { - eventually(() -> { - try { - return Files.isRegularFile(file) && !new String(Files.readAllBytes(file)).trim().isEmpty(); - } catch (IOException error) { - return false; - } - }, "a PID in " + file.getFileName()); - try { - return Long.parseLong(new String(Files.readAllBytes(file)).trim()); - } catch (IOException error) { - throw new UncheckedIOException(error); - } - } - - static void deleteTree(Path root) { - if (!Files.exists(root)) { - return; - } - try { - Files.walkFileTree(root, new SimpleFileVisitor() { - @Override - public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException { - Files.delete(file); - return FileVisitResult.CONTINUE; - } - - @Override - public FileVisitResult postVisitDirectory(Path directory, IOException error) throws IOException { - Files.delete(directory); - return FileVisitResult.CONTINUE; - } - }); - } catch (IOException error) { - throw new UncheckedIOException(error); - } - } -} diff --git a/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/SupportTest.java b/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/SupportTest.java deleted file mode 100644 index ce9771eb9c..0000000000 --- a/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/SupportTest.java +++ /dev/null @@ -1,370 +0,0 @@ -package io.github.litheidea.plugin.sdk; - -import static io.github.litheidea.plugin.sdk.Fixtures.map; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.junit.jupiter.api.Assumptions.assumeTrue; - -import java.io.IOException; -import java.math.BigInteger; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; -import java.util.stream.Stream; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.Timeout; -import org.junit.jupiter.api.condition.DisabledOnOs; -import org.junit.jupiter.api.condition.OS; - -/** Framing, JSON, tools, and state; mirrors {@code sdk/node/test/support.test.mjs}. */ -@Timeout(value = 30, unit = TimeUnit.SECONDS) -class SupportTest { - private Path directory; - - @BeforeEach - void createDirectory() throws IOException { - directory = Files.createTempDirectory("lithe-sdk-").toRealPath(); - } - - @AfterEach - void removeDirectory() { - Processes.killAll(); - Fixtures.deleteTree(directory); - } - - private static List shell(String script) { - return Arrays.asList("/bin/sh", "-c", script); - } - - /** The SDK gives each tool its own process group through the system perl. */ - private static boolean hasProcessGroups() { - return new java.io.File("/usr/bin/perl").canExecute() || new java.io.File("/bin/perl").canExecute(); - } - - private static boolean hasProcessHandle() { - try { - Class.forName("java.lang.ProcessHandle"); - return true; - } catch (ClassNotFoundException error) { - return false; - } - } - - @Test - void framesSplitAcrossChunksOrSeveralInOneChunkDecodeInOrder() { - Framing.Decoder decoder = new Framing.Decoder(); - byte[] first = Framing.encode(map("id", 1, "text", "é✓")); - byte[] second = Framing.encode(map("id", 2)); - byte[] bytes = Arrays.copyOf(first, first.length + second.length); - System.arraycopy(second, 0, bytes, first.length, second.length); - List messages = new ArrayList<>(); - for (int index = 0; index < bytes.length; index += 3) { - messages.addAll(decoder.push(bytes, index, Math.min(3, bytes.length - index))); - } - assertEquals(Arrays.asList(map("id", 1L, "text", "é✓"), map("id", 2L)), messages); - byte[] oversized = "Content-Length: 999999999999\r\n\r\n".getBytes(StandardCharsets.US_ASCII); - assertThrows(Framing.FramingException.class, () -> new Framing.Decoder().push(oversized, 0, oversized.length)); - byte[] missing = "X-Other: 1\r\n\r\n".getBytes(StandardCharsets.US_ASCII); - assertThrows(Framing.FramingException.class, () -> new Framing.Decoder().push(missing, 0, missing.length)); - } - - @Test - void aHeaderOverEightKibibytesIsRefusedAlsoWhenTheWholeFrameArrivesAtOnce() { - StringBuilder header = new StringBuilder("Content-Length: 2\r\nX-Padding: "); - while (header.length() <= 8200) { - header.append('x'); - } - byte[] frame = (header + "\r\n\r\n{}").getBytes(StandardCharsets.US_ASCII); - assertThrows(Framing.FramingException.class, () -> new Framing.Decoder().push(frame, 0, frame.length)); - } - - @Test - void jsonRoundTripsProtocolValuesAndRefusesWhatItCannotRepresent() { - String text = "{\"a\":[1,-2.5,true,false,null,\"\\u00e9\\n\\\"\"],\"big\":123456789012345678901234567890,\"e\":1e3}"; - Map value = Json.object(Json.parse(text)); - assertEquals(Arrays.asList(1L, -2.5, true, false, null, "é\n\""), value.get("a")); - assertEquals(new BigInteger("123456789012345678901234567890"), value.get("big")); - assertEquals(1000.0, value.get("e")); - // Integral doubles print without a fraction, as JavaScript prints them. - String encoded = Json.stringify(value); - assertEquals("{\"a\":[1,-2.5,true,false,null,\"é\\n\\\"\"],\"big\":123456789012345678901234567890,\"e\":1000}", encoded); - assertEquals(encoded, Json.stringify(Json.parse(encoded))); - assertEquals("\"\\ud800x\"", Json.stringify("\ud800x")); - assertEquals("[1,2.5]", Json.stringify(Arrays.asList(1.0, 2.5))); - for (String malformed : Arrays.asList("", "{", "[1,]", "{\"a\" 1}", "01", "1.", "\"\u0001\"", "tru", "{} x")) { - assertThrows(Json.JsonException.class, () -> Json.parse(malformed), malformed); - } - Map numericKeys = new HashMap<>(); - numericKeys.put(1, "x"); - assertThrows(Json.JsonException.class, () -> Json.stringify(numericKeys)); - assertThrows(Json.JsonException.class, () -> Json.stringify(Double.POSITIVE_INFINITY)); - StringBuilder deep = new StringBuilder(); - for (int index = 0; index < 600; index++) { - deep.append('['); - } - assertThrows(Json.JsonException.class, () -> Json.parse(deep.toString())); - } - - @Test - void boundedTextCutsOnCharacterBoundaries() { - assertEquals("ab", Protocol.boundedText("abc", 2)); - assertEquals("é", Protocol.boundedText("éé", 3)); - assertEquals("", Protocol.boundedText("😀", 3)); - assertEquals("", Protocol.boundedText(null)); - assertTrue(Protocol.isPluginErrorCode("maven.snapshotLocked")); - assertFalse(Protocol.isPluginErrorCode("bad code")); - } - - @Test - @DisabledOnOs(OS.WINDOWS) - void runCapturesOutputAndReturnsANonZeroExit() throws IOException { - Processes.Result result = Processes.run(shell("printf out; printf err >&2; exit 3")); - assertEquals(3, result.exitCode()); - assertEquals("out", result.stdout()); - assertEquals("err", result.stderr()); - assertFalse(result.truncated()); - Processes.Result limited = Processes.run(shell("printf xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"), - new Processes.Options().maxOutputBytes(10)); - assertEquals("xxxxxxxxxx", limited.stdout()); - assertTrue(limited.truncated()); - } - - @Test - @DisabledOnOs(OS.WINDOWS) - void streamedOutputKeepsACharacterSplitAcrossReadsWhole() throws IOException { - // 3-byte characters never align with the 8 KiB reads, so reads split them. - String expected = String.join("", Collections.nCopies(50_000, "✓")); - Path file = directory.resolve("checks.txt"); - Files.write(file, expected.getBytes(StandardCharsets.UTF_8)); - StringBuilder streamed = new StringBuilder(); - Processes.Result result = Processes.run(Arrays.asList("cat", file.toString()), - new Processes.Options().onStdout(streamed::append)); - assertEquals(expected, streamed.toString()); - assertEquals(expected, result.stdout()); - } - - @Test - @DisabledOnOs(OS.WINDOWS) - void aReplacedEnvironmentStillCarriesTheHostProcessMarkers() throws IOException { - Map environment = new HashMap<>(); - environment.put("PATH", System.getenv("PATH")); - environment.put("LITHE_PROCESS_SCOPE", "forged"); - environment.put("OWN", "kept"); - Processes.Result result = Processes.run(shell("printf '%s|%s' \"${LITHE_PROCESS_SCOPE-unset}\" \"$OWN\""), - new Processes.Options().environment(environment)); - String inherited = System.getenv("LITHE_PROCESS_SCOPE"); - assertEquals((inherited == null ? "unset" : inherited) + "|kept", result.stdout()); - } - - @Test - @DisabledOnOs(OS.WINDOWS) - void cancellingACallEndsTheToolTreeItStarted() { - assumeTrue(hasProcessGroups() || hasProcessHandle(), - "without perl, Java 8 ends only the direct child; the host scope ends the rest"); - Path grandchildPid = directory.resolve("grandchild.pid"); - // The child starts a grandchild that ignores SIGTERM, so only the forced - // stop can end it. - String grandchild = "trap '' TERM; echo $$ > '" + grandchildPid + "'; while :; do sleep 1; done"; - String child = "/bin/sh -c \"" + grandchild.replace("$", "\\$") + "\" & wait"; - CountDownLatch started = new CountDownLatch(1); - Backend backend = Backend.create(null, null).function("tool.run", (params, context) -> { - started.countDown(); - return context.run(shell(child), new Processes.Options().killGraceMillis(50)).exitCode(); - }); - TestHost host = new TestHost(backend); - host.initialize(); - CompletableFuture> response = host.requestAsync("lithe/function/invoke", - map("functionID", "tool.run", "operationID", "g"), 7L); - Fixtures.await(started, "the tool to start"); - long pid = Fixtures.awaitPid(grandchildPid); - host.notify("$/cancelRequest", map("id", 7L)); - Map answer = Fixtures.await(response, "the cancelled call"); - assertEquals(-32800L, ((Map) answer.get("error")).get("code")); - Fixtures.eventually(() -> !Fixtures.isAlive(pid), "the grandchild to be stopped"); - Fixtures.eventually(() -> Processes.runningCount() == 0, "the registry to drain"); - host.close(); - } - - @Test - @DisabledOnOs(OS.WINDOWS) - void aToolThatExitsTakesItsBackgroundWorkersButNotAnotherCallsTool() throws Exception { - assumeTrue(hasProcessGroups(), "without perl the tool has no process group of its own"); - Path workerPid = directory.resolve("worker.pid"); - Path otherPid = directory.resolve("other.pid"); - Cancellation other = new Cancellation(); - CompletableFuture otherCall = CompletableFuture.supplyAsync(() -> { - try { - return Processes.run(shell("echo $$ > '" + otherPid + "'; exec sleep 600"), - new Processes.Options().cancellation(other).killGraceMillis(50)); - } catch (IOException | CancelledException error) { - return error; - } - }); - long otherTool = Fixtures.awaitPid(otherPid); - // The tool succeeds at once and leaves a worker that holds no pipe. - Processes.Result result = Processes.run(shell( - "sleep 600 >/dev/null 2>&1 & echo $! > '" + workerPid + "'; exit 0")); - assertEquals(0, result.exitCode()); - long worker = Fixtures.awaitPid(workerPid); - Fixtures.eventually(() -> !Fixtures.isAlive(worker), "the left worker to be killed"); - assertTrue(Fixtures.isAlive(otherTool), "a concurrent call's tool keeps running"); - other.cancel("done"); - assertTrue(Fixtures.await(otherCall, "the other call") instanceof CancelledException); - Fixtures.eventually(() -> !Fixtures.isAlive(otherTool), "the other tool to be stopped"); - } - - @Test - @DisabledOnOs(OS.WINDOWS) - void aMissingExecutableFailsToStartAndPerlOptionsReachOnlyTheTool() throws IOException { - IOException missing = assertThrows(IOException.class, - () -> Processes.run(Arrays.asList(directory.resolve("missing").toString()))); - assertTrue(missing.getMessage().contains("missing"), missing.getMessage()); - Map environment = new HashMap<>(); - environment.put("PATH", System.getenv("PATH")); - // Perl would refuse to start with this; the tool only sees it. - environment.put("PERL5OPT", "-MLithe::Missing::Module"); - Processes.Result result = Processes.run(shell("printf %s \"$PERL5OPT\""), - new Processes.Options().environment(environment)); - assertEquals(0, result.exitCode(), result.stderr()); - assertEquals("-MLithe::Missing::Module", result.stdout()); - } - - @Test - @DisabledOnOs(OS.WINDOWS) - void closingTheSessionEndsToolsStillRunning() { - Path childPid = directory.resolve("child.pid"); - CountDownLatch started = new CountDownLatch(1); - Backend backend = Backend.create(null, null).function("tool.run", (params, context) -> { - started.countDown(); - // `exec` keeps the PID: the tool itself ignores SIGTERM. - return context.run(shell("trap '' TERM; echo $$ > '" + childPid + "'; exec sleep 600")).exitCode(); - }); - TestHost host = new TestHost(backend); - host.initialize(); - // No reply is awaited: the session ends while the call is still running. - host.send(map("id", 8L, "method", "lithe/function/invoke", - "params", map("functionID", "tool.run", "operationID", "h"))); - Fixtures.await(started, "the tool to start"); - long pid = Fixtures.awaitPid(childPid); - assertEquals(1, host.close()); - Fixtures.eventually(() -> !Fixtures.isAlive(pid), "the tool to be killed"); - } - - @Test - @DisabledOnOs(OS.WINDOWS) - void aCallThatSettlesStopsTheToolsItsHandlerLeftRunningAndOnlyThose() { - Path leftPid = directory.resolve("left.pid"); - Path otherPid = directory.resolve("other.pid"); - CountDownLatch otherStarted = new CountDownLatch(1); - CountDownLatch releaseOther = new CountDownLatch(1); - Backend backend = Backend.create(null, null) - .function("tool.leave", (params, context) -> { - Thread background = new Thread(() -> { - try { - context.run(shell("echo $$ > '" + leftPid + "'; exec sleep 600")); - } catch (IOException | CancelledException error) { - // Stopped with its call, as intended. - } - }); - background.setDaemon(true); - background.start(); - Fixtures.awaitPid(leftPid); - return "returned"; - }) - .function("tool.other", (params, context) -> { - Thread background = new Thread(() -> { - try { - context.run(shell("echo $$ > '" + otherPid + "'; exec sleep 600")); - } catch (IOException | CancelledException error) { - // Ends with its own call. - } - }); - background.setDaemon(true); - background.start(); - otherStarted.countDown(); - Fixtures.await(releaseOther, "the test to release the other call"); - return null; - }); - TestHost host = new TestHost(backend); - host.initialize(); - CompletableFuture> other = host.requestAsync("lithe/function/invoke", - map("functionID", "tool.other", "operationID", "o"), 11L); - Fixtures.await(otherStarted, "the other call"); - long otherTool = Fixtures.awaitPid(otherPid); - assertEquals("returned", host.request("lithe/function/invoke", - map("functionID", "tool.leave", "operationID", "l")).get("result")); - long leftTool = Fixtures.awaitPid(leftPid); - Fixtures.eventually(() -> !Fixtures.isAlive(leftTool), "the left tool to be stopped"); - assertTrue(Fixtures.isAlive(otherTool), "the other call's tool keeps running"); - releaseOther.countDown(); - Fixtures.await(other, "the other call"); - Fixtures.eventually(() -> !Fixtures.isAlive(otherTool), "the other tool to be stopped"); - host.close(); - } - - @Test - void theStateStoreWritesAtomicallyAndSurvivesReopening() throws IOException { - Path state = directory.resolve("workspace-state"); - StateStore store = StateStore.open(state, null); - assertEquals("none", store.read("snapshot", "none")); - store.write("snapshot", map("generation", 1)); - store.write("snapshot", map("generation", 2)); - assertEquals(map("generation", 2L), StateStore.open(state, null).read("snapshot", null)); - try (Stream files = Files.list(state)) { - assertEquals(Arrays.asList("snapshot.json"), - files.map(path -> path.getFileName().toString()).collect(Collectors.toList())); - } - // The same envelope as the Node.js SDK, so either reads the other's state. - assertEquals("{\"litheStateStore\":1,\"value\":{\"generation\":2}}", - new String(Files.readAllBytes(state.resolve("snapshot.json")), StandardCharsets.UTF_8)); - Files.write(state.resolve("broken.json"), "{".getBytes(StandardCharsets.UTF_8)); - assertNull(store.read("broken", null)); - store.delete("snapshot"); - store.delete("snapshot"); - assertNull(store.read("snapshot", null)); - for (String key : Arrays.asList("", "../escape", "a/b", "x.tmp", ".hidden")) { - PluginSdkError error = assertThrows(PluginSdkError.class, () -> store.write(key, 1), key); - assertEquals("sdk.state.invalidKey", error.code()); - } - } - - @Test - void theStateStoreRefusesAValueJsonCannotEncodeAndKeepsTheStoredOne() throws IOException { - StateStore store = StateStore.open(directory.resolve("state"), null); - store.write("kept", map("ok", true)); - PluginSdkError error = assertThrows(PluginSdkError.class, () -> store.write("kept", new Object())); - assertEquals("sdk.state.invalidValue", error.code()); - assertEquals(map("ok", true), store.read("kept", null)); - } - - @Test - void theStateStoreRefusesTheReadOnlyPluginPackage() throws IOException { - Path packageDirectory = directory.resolve("package"); - Files.createDirectories(packageDirectory); - Files.write(packageDirectory.resolve("plugin.json"), "{}".getBytes(StandardCharsets.UTF_8)); - PluginSdkError refused = assertThrows(PluginSdkError.class, - () -> StateStore.open(packageDirectory.resolve("state"), packageDirectory)); - assertEquals("sdk.state.readOnlyPackage", refused.code()); - // Refused before anything was created inside the package. - assertFalse(Files.exists(packageDirectory.resolve("state"))); - PluginSdkError relative = assertThrows(PluginSdkError.class, - () -> StateStore.open(java.nio.file.Paths.get("relative/state"), null)); - assertEquals("sdk.state.invalidDirectory", relative.code()); - StateStore.open(directory.resolve("state"), packageDirectory); - } -} diff --git a/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/TestHost.java b/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/TestHost.java deleted file mode 100644 index 8fc2c5cd31..0000000000 --- a/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/TestHost.java +++ /dev/null @@ -1,215 +0,0 @@ -package io.github.litheidea.plugin.sdk; - -import java.io.ByteArrayOutputStream; -import java.io.InputStream; -import java.io.OutputStream; -import java.io.PrintStream; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.TimeUnit; -import java.util.function.Predicate; - -/** - * A host for SDK tests: it writes framed messages to an in-memory backend and - * decodes the answers, with a bounded wait on each. - */ -final class TestHost { - /** Upper bound for any single wait; a broken backend fails fast instead of hanging. */ - static final long WAIT_MILLIS = 5000; - - private final QueueInput input = new QueueInput(); - private final List> messages = new ArrayList<>(); - private final ByteArrayOutputStream errors = new ByteArrayOutputStream(); - private final CompletableFuture done = new CompletableFuture<>(); - private int nextId = 1; - - TestHost(Backend backend) { - Framing.Decoder decoder = new Framing.Decoder(); - OutputStream output = new OutputStream() { - @Override - public void write(int value) { - write(new byte[] {(byte) value}, 0, 1); - } - - @Override - public void write(byte[] bytes, int offset, int length) { - for (Object message : decoder.push(bytes, offset, length)) { - synchronized (messages) { - messages.add(Json.object(message)); - messages.notifyAll(); - } - } - } - }; - PrintStream errorOutput = new PrintStream(errors, true); - Thread thread = new Thread(() -> { - try { - done.complete(backend.listen(input, output, errorOutput)); - } catch (Throwable error) { - done.completeExceptionally(error); - } - }, "test-host-backend"); - thread.setDaemon(true); - thread.start(); - } - - /** Sends one message; {@code jsonrpc} is added. */ - void send(Map message) { - Map framed = new LinkedHashMap<>(); - framed.put("jsonrpc", "2.0"); - framed.putAll(message); - input.push(Framing.encode(framed)); - } - - /** Writes raw bytes, for framing tests. */ - void sendBytes(byte[] bytes) { - input.push(bytes); - } - - /** Sends a request and waits for the backend's response. */ - Map request(String method, Object params) { - return requestAsync(method, params, nextId()).join(); - } - - Map request(String method, Object params, Object id) { - return requestAsync(method, params, id).join(); - } - - /** Sends a request now and resolves with the response once it arrives. */ - CompletableFuture> requestAsync(String method, Object params, Object id) { - send(Fixtures.map("id", id, "method", method, "params", params)); - return CompletableFuture.supplyAsync(() -> waitFor( - message -> id.equals(message.get("id")) && !message.containsKey("method"), - "the response to " + method)); - } - - void notify(String method, Object params) { - send(Fixtures.map("method", method, "params", params)); - } - - Map initialize() { - return initialize(Fixtures.map( - "litheBuild", Fixtures.map("versions", Arrays.asList(1)), - "litheFunctions", Fixtures.map("versions", Arrays.asList(1)))); - } - - Map initialize(Map experimental) { - Map response = request("initialize", Fixtures.map( - "processId", null, - "rootUri", "file:///workspace", - "capabilities", Fixtures.map("experimental", experimental))); - notify("initialized", Fixtures.map()); - return response; - } - - /** The first message, past or future, that {@code matches}. */ - Map waitFor(Predicate> matches, String description) { - long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(WAIT_MILLIS); - synchronized (messages) { - for (;;) { - for (Map message : messages) { - if (matches.test(message)) { - return message; - } - } - long remaining = TimeUnit.NANOSECONDS.toMillis(deadline - System.nanoTime()); - if (remaining <= 0) { - throw new AssertionError("Timed out waiting for " + description + "; messages: " + messages - + "; stderr: " + errors()); - } - try { - messages.wait(remaining); - } catch (InterruptedException error) { - Thread.currentThread().interrupt(); - throw new AssertionError("Interrupted waiting for " + description, error); - } - } - } - } - - List> messages() { - synchronized (messages) { - return new ArrayList<>(messages); - } - } - - String errors() { - return new String(errors.toByteArray(), StandardCharsets.UTF_8); - } - - /** The backend's exit code once the session ended. */ - int exitCode() { - return Fixtures.await(done, "the backend to exit"); - } - - /** Closes the input and returns the backend's exit code. */ - int close() { - input.close(); - return exitCode(); - } - - private Object nextId() { - synchronized (this) { - return (long) nextId++; - } - } - - /** An input stream fed by the test; closing it is the host closing stdin. */ - private static final class QueueInput extends InputStream { - private static final byte[] END = new byte[0]; - private final LinkedBlockingQueue chunks = new LinkedBlockingQueue<>(); - private byte[] current = new byte[0]; - private int position; - private boolean ended; - - void push(byte[] bytes) { - chunks.add(bytes.clone()); - } - - @Override - public void close() { - chunks.add(END); - } - - @Override - public int read() { - byte[] one = new byte[1]; - int count = read(one, 0, 1); - return count < 0 ? -1 : one[0] & 0xff; - } - - @Override - public int read(byte[] buffer, int offset, int length) { - if (ended) { - return -1; - } - while (position >= current.length) { - byte[] next; - try { - // The backend's reader blocks here as it blocks on stdin; the - // test bounds every wait on the backend's answers instead. - next = chunks.take(); - } catch (InterruptedException error) { - Thread.currentThread().interrupt(); - return -1; - } - if (next == END) { - ended = true; - return -1; - } - current = next; - position = 0; - } - int count = Math.min(length, current.length - position); - System.arraycopy(current, position, buffer, offset, count); - position += count; - return count; - } - } -} diff --git a/sdk/java/pom.xml b/sdk/java/pom.xml deleted file mode 100644 index d4f11ee084..0000000000 --- a/sdk/java/pom.xml +++ /dev/null @@ -1,77 +0,0 @@ - - - - 4.0.0 - - io.github.lithe-idea - lithe-plugin-sdk-java-parent - 0.1.0 - pom - - - plugin-sdk - examples/sample-build - - - - UTF-8 - 8 - 5.14.0 - - 2026-10-20T00:00:00Z - - - - - - org.junit.jupiter - junit-jupiter - ${junit.version} - test - - - - - - - - - org.apache.maven.plugins - maven-compiler-plugin - 3.14.0 - - - -Xlint:all,-options - - - - - org.apache.maven.plugins - maven-surefire-plugin - 3.5.4 - - - 300 - - - - org.apache.maven.plugins - maven-jar-plugin - 3.4.2 - - - org.apache.maven.plugins - maven-shade-plugin - 3.6.0 - - - - - diff --git a/sdk/node/README.md b/sdk/node/README.md deleted file mode 100644 index f11e666fcb..0000000000 --- a/sdk/node/README.md +++ /dev/null @@ -1,145 +0,0 @@ -# Lithe plugin backend SDK for Node.js - -`@lithe/plugin-sdk` is the official way to write a plugin backend in -JavaScript: the process a plugin starts through a `runtime` launch -(`{ "kind": "runtime", "tool": "node", … }`). It speaks the LSP base protocol -on stdio and implements Lithe's two backend protocols from -[plugin contract v1](../../shared/contracts/plugins/v1/README.md): - -- **`litheBuild` 1**: typed `lithe/build/model` and `lithe/build/dependencies` - requests, for a build system whose `model` or `dependencies` source is - `provider`. -- **`litheFunctions` 1**: plugin functions, which are plugin-private actions - that Lithe invokes for the same plugin, with progress, cancellation, and - registration at runtime. - -The SDK owns the wire format, negotiation, cancellation, process cleanup, and -error mapping. Your code supplies only handlers. Node 20.3 or newer is -required; declare it as the runtime's `minimumVersion`. - -## A minimal backend - -```js -import { createBackend, PluginError } from './vendor/lithe-plugin-sdk/src/index.mjs'; - -const backend = createBackend({ name: 'example-backend', version: '1.0.0' }); - -backend.build.model(async ({ root }) => ({ - buildSystemID: 'example', - root, - projects: [{ name: 'app', path: root }], -})); - -backend.function('snapshot.refresh', async (params, context) => { - context.progress({ message: 'Refreshing', percentage: 10 }); - const result = await context.run('example-tool', ['refresh'], { cwd: params.root }); - if (result.exitCode !== 0) throw new PluginError('example.refreshFailed', 'The refresh failed.'); - return { refreshedAt: Date.now() }; -}); - -backend.listen(); -``` - -[`examples/sample-build`](examples/sample-build) is a complete plugin with a -build provider, functions, persistent state, a function registered at runtime, -and cancellation. Rust Core's -`rust/lithe-core/tests/plugin_sdk_node.rs` drives it through a real Core -session. - -## Packaging - -The SDK is not published to a registry yet. Copy `src/` into your plugin -package (for example as `backend/vendor/lithe-plugin-sdk/src/`) and import it -by relative path. A plugin package is installed read-only, has no -`node_modules` install step, and must run offline, so everything the backend -imports has to be inside the package. The SDK has no dependencies. - -## API - -| API | Purpose | -| --- | --- | -| `createBackend({ name?, version?, capabilities? })` | Creates the backend. `capabilities` are standard LSP server capabilities; `experimental.litheBuild` and `experimental.litheFunctions` are reserved for the SDK. | -| `backend.build.model(handler)`, `backend.build.dependencies(handler)` | Implements the typed build requests. The SDK stamps `format` and `version`; a handler returning another format or a version other than 1 fails. Core validates the document. `litheBuild` is answered only when a handler exists. | -| `backend.function(id, handler)` | Declares a function offered in `initialize`. | -| `backend.registerFunction(id, handler)`, `backend.unregisterFunction(id)` | Adds or withdraws a function. Called before the `initialize` answer (for example from `onInitialize`), they change the initial list; later they send `lithe/function/register` / `unregister`. Withdrawing revokes running calls. | -| `backend.onInitialize(hook)`, `backend.onShutdown(hook)` | Lifecycle hooks. `onInitialize` receives the `initialize` params and what was negotiated. | -| `backend.onRequest(method, handler)`, `backend.onNotification(method, handler)` | Ordinary LSP methods, such as `textDocument/hover` for a language server. Lifecycle and `lithe/*` methods are reserved. | -| `backend.sendRequest`, `backend.sendNotification` | Ordinary LSP messages to the host; `lithe/*` methods are reserved. | -| `backend.log(...)` | Writes a line to stderr, which the host keeps in its log. | -| `backend.listen()` | Serves stdio until `exit` or the end of input. | -| `PluginError(code, message)` | A failure meant for your own UI. | -| `runProcess(command, args, options)` | Runs a process without a shell, as one cancellable process tree. | -| `openStateStore(directory)` | A small atomic JSON store in a host-provided directory. | - -Every handler receives a context: - -| Member | Meaning | -| --- | --- | -| `signal` | Aborted when Lithe cancels the call, its deadline passes, the function is withdrawn, or the session ends; also once the handler has settled, so work it left running stops. | -| `throwIfCancelled()` | Throws the cancellation if `signal` is aborted. | -| `run(command, args, options)` | `runProcess` bound to the call: cancelling or ending the call ends the whole process tree. | -| `log(...)` | Same as `backend.log`. | -| `functionID`, `operationID` | Functions only. `operationID` is opaque; never derive anything from it. | -| `progress({ message?, percentage? })` | Functions only. The message is cut to 1024 bytes and the percentage clamped to 0–100; reports after the call ends are dropped. | - -## Results and errors - -| Handler outcome | What Lithe sees | -| --- | --- | -| Returns a value (including `null`) | `result.value` | -| Throws `PluginError(code, message)` | `plugin.function.failed` with your `message` and `pluginErrorCode`. The code is for your plugin's own UI; Lithe never branches on it. | -| Throws anything else | `plugin.function.failed` with a generic message, so paths and stack traces never reach the UI. The real error goes to stderr. | -| Ends because of `signal` | `requestCancelled` (or Lithe's timeout/revocation outcome) | - -A result must serialize to at most 1 MiB of JSON. A value JSON cannot encode -(a `BigInt`, a cycle) or a larger one fails like any other unexpected error and -is logged to stderr; the session keeps running. A function ID is dot-separated -lowerCamelCase segments (`snapshot.refresh`), at most 128 bytes, and a session -holds at most 256 functions. A `PluginError` code matches -`[A-Za-z][A-Za-z0-9._-]*`, at most 64 bytes. - -## Rules that keep Lithe in charge - -The SDK leaves your plugin free to define its own functions, params, results, -UI, and state, but these rules keep Lithe's workflows, permissions, and -lifecycle unified. The SDK enforces the ones it can. - -1. **Domain capabilities stay typed.** Build models and dependency trees go - through `backend.build.*`, never through a function, so every host and IDE - workflow reads one contract. Functions are for actions only your plugin - understands. -2. **No calls back into Lithe's dispatch.** A backend never sends - `lithe/function/invoke` or `lithe/build/*`; Lithe would route it back to the - same process, which would then wait for itself. Lithe refuses these with - `plugin.function.recursiveCall`, and the SDK refuses to send any `lithe/*` - method. -3. **stdout belongs to the protocol.** When serving stdio, the SDK moves - `console.log`, `console.info`, and `console.debug` to stderr. Never write to - `process.stdout` yourself. -4. **The package is read-only.** Keep caches, snapshots, and logs under the - directories Lithe passes through launch templates (`${stateDir}`, - `${workspaceStateDir}`). `openStateStore` refuses any directory inside the - plugin package. Writing to the package breaks code signing and update - deltas. -5. **Work ends with its call.** Start processes with `context.run`, and check - `signal` in long loops. On cancellation the SDK stops the process tree - politely, then forcibly. When the handler returns or throws, tools of that - call still running (an unawaited `context.run`, the siblings of a failed - `Promise.all`) are stopped the same way. A tool that exits on its own takes - the workers it left in its process group with it. On `shutdown`, `exit`, or - the end of input the SDK ends every process it started. If the backend - itself is killed, no JavaScript runs; ending what it left is the host's - job, not the SDK's. Lithe finds those processes by the markers it puts in - the backend's environment, so `runProcess` carries them over even when - `env` replaces the environment. -6. **Errors stay presentable.** Throw `PluginError` for failures the user - should see; every other exception is reported generically. - -## Testing - -```bash -npm test --prefix sdk/node -``` - -The tests use a bounded in-memory host (`test/harness.mjs`), which plugin -authors can copy to test their own backends without starting Lithe. diff --git a/sdk/node/examples/sample-build/backend/main.mjs b/sdk/node/examples/sample-build/backend/main.mjs deleted file mode 100644 index 0d3a8267a8..0000000000 --- a/sdk/node/examples/sample-build/backend/main.mjs +++ /dev/null @@ -1,149 +0,0 @@ -// Sample Lithe plugin backend built on the official Node.js SDK. -// -// It is the build provider of the fictional "sample" build system, whose -// workspace file `lithe-sample.json` lists projects: -// -// { "projects": [{ "name": "app", "path": "app", -// "build": ["node", "build.mjs"], -// "dependencies": [{ "name": "left-pad", "version": "1.3.0" }] }] } -// -// It shows the three things a backend does: -// - answers Lithe's typed build requests (`lithe/build/model`, -// `lithe/build/dependencies`), whose results Lithe itself understands; -// - offers plugin functions for its own UI: `workspace.build` runs a -// multi-step build (prepare, run each project's tool, record a snapshot), -// and `snapshot.read` returns the last snapshot, also after a restart; -// - registers `snapshot.clear` at runtime once there is a snapshot to clear. -// -// A packaged plugin vendors the SDK inside its package, for example as -// `backend/vendor/lithe-plugin-sdk/`, and imports it from there. - -import { readFile } from 'node:fs/promises'; -import { isAbsolute, join, normalize, resolve, sep } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { createBackend, openStateStore, PluginError } from '../../../src/index.mjs'; - -const backend = createBackend({ name: 'sample-build', version: '0.1.0' }); -let workspaceRoot; -let store; - -backend.onInitialize(async ({ params }) => { - // Hosts may send a directory URI with a trailing slash (macOS does); - // `resolve` drops it so `inside` compares like paths. - workspaceRoot = resolve(fileURLToPath(params.rootUri)); - // `--state ${workspaceStateDir}` in plugin.json: the host's writable - // directory for this workspace. The package itself is read-only. - store = openStateStore(argument('--state')); - if (store.read('snapshot', null)) offerClear(); -}); - -backend.build.model(async ({ root }) => { - const config = await readConfig(root); - return { - projects: config.projects.map(({ name, path }) => ({ name, path })), - }; -}); - -backend.build.dependencies(async ({ root, project }) => { - const config = await readConfig(root); - const projects = project ? config.projects.filter(({ path }) => path === project.path) : config.projects; - return { - dependencies: projects.flatMap(({ dependencies = [] }) => - dependencies.map(({ name, version }) => ({ name, version, children: [] })), - ), - }; -}); - -backend.function('workspace.build', async ({ projects: selected }, context) => { - // Step 1: prepare. Read the workspace file and resolve the selection. - context.progress({ message: 'Preparing', percentage: 0 }); - const config = await readConfig('.'); - const projects = selectProjects(config, selected); - - // Step 2: run each project's tool. `context.run` ends the whole process - // tree when Lithe cancels the call, so a cancelled build leaves nothing behind. - const results = []; - for (const [index, project] of projects.entries()) { - context.progress({ message: `Building ${project.name}`, percentage: (100 * index) / projects.length }); - const [tool, ...args] = project.build; - const { exitCode } = await context.run(tool === 'node' ? process.execPath : tool, args, { - cwd: inside(workspaceRoot, project.path), - onStdout: (text) => context.log(`[${project.name}] ${text.trimEnd()}`), - }); - results.push({ name: project.name, exitCode }); - if (exitCode !== 0) { - record(results, 'failed'); - throw new PluginError('sample.buildFailed', `${project.name} failed with exit code ${exitCode}.`); - } - } - - // Step 3: record the outcome, so `snapshot.read` returns it after a restart. - const snapshot = record(results, 'succeeded'); - context.progress({ message: 'Done', percentage: 100 }); - return snapshot; -}); - -backend.function('snapshot.read', () => store.read('snapshot', null)); - -// Reads the latest snapshot when the build ends, not when it starts: two -// overlapping builds then get consecutive generations instead of the same one. -// The read and the write run without an `await` between them. -function record(projects, status) { - const previous = store.read('snapshot', null); - const snapshot = { generation: (previous?.generation ?? 0) + 1, status, projects }; - store.write('snapshot', snapshot); - offerClear(); - return snapshot; -} - -function offerClear() { - if (backend.functionIDs.includes('snapshot.clear')) return; - backend - .registerFunction('snapshot.clear', () => { - store.delete('snapshot'); - return null; - }) - .catch((error) => backend.log('snapshot.clear stays unavailable:', error.message)); -} - -/** Every project, or exactly the named ones; an unknown name is an error, not an empty build. */ -function selectProjects(config, selected) { - if (selected === undefined || selected === null) return config.projects; - if (!Array.isArray(selected) || selected.length === 0 || !selected.every((name) => typeof name === 'string')) { - throw new PluginError('sample.selection', '"projects" must be a non-empty list of project names.'); - } - const unknown = selected.filter((name) => !config.projects.some((project) => project.name === name)); - if (unknown.length > 0) { - throw new PluginError('sample.unknownProject', `Unknown project: ${unknown.join(', ')}.`); - } - return config.projects.filter(({ name }) => selected.includes(name)); -} - -async function readConfig(root) { - let config; - try { - config = JSON.parse(await readFile(join(inside(workspaceRoot, root), 'lithe-sample.json'), 'utf8')); - } catch { - throw new PluginError('sample.config', 'lithe-sample.json is missing or is not valid JSON.'); - } - if (!Array.isArray(config.projects)) { - throw new PluginError('sample.config', 'lithe-sample.json needs a "projects" list.'); - } - return config; -} - -/** `relative` resolved inside `base`; workspace files never name paths outside it. */ -function inside(base, relative) { - const path = normalize(join(base, relative)); - if (isAbsolute(relative) || (path !== base && !path.startsWith(base + sep))) { - throw new PluginError('sample.path', `${relative} is outside the workspace.`); - } - return path; -} - -function argument(name) { - const index = process.argv.indexOf(name); - return index >= 0 ? process.argv[index + 1] : undefined; -} - -await backend.listen(); diff --git a/sdk/node/examples/sample-build/plugin.json b/sdk/node/examples/sample-build/plugin.json deleted file mode 100644 index 57f2aac87a..0000000000 --- a/sdk/node/examples/sample-build/plugin.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "schemaVersion": 2, - "id": "org.example.sample-build", - "displayName": "Sample Build", - "version": "0.1.0", - "pluginAPIVersion": 3, - "requires": { "buildProviders": 1, "buildSystems": 1, "serverProcess": 1 }, - "vendor": { - "id": "org.example", - "displayName": "Example", - "signatureRequirement": "publisherPackage" - }, - "permissions": ["languageServer.launch", "process.build"], - "activationEvents": ["onBuildSystem:sample"], - "languages": [], - "buildSystems": [ - { - "id": "sample", - "displayName": "Sample", - "detection": { "markers": ["lithe-sample.json"], "nesting": "outermost" }, - "invocation": { - "executables": [{ "kind": "command", "command": "node" }] - }, - "provider": { - "launch": { - "kind": "runtime", - "tool": "node", - "minimumVersion": "20.3", - "entry": "backend/main.mjs" - }, - "arguments": ["--state", "${workspaceStateDir}"], - "executesProjectCode": true, - "mayUseNetwork": false - }, - "model": { "source": "provider" }, - "tasks": [ - { - "id": "build", - "displayName": "Build", - "group": "build", - "scopes": ["workspace"], - "arguments": ["--version"] - } - ], - "dependencies": { "source": "provider", "scopes": ["workspace", "project"] }, - "sync": { "watch": ["lithe-sample.json"] } - } - ] -} diff --git a/sdk/node/package.json b/sdk/node/package.json deleted file mode 100644 index cdf782f21a..0000000000 --- a/sdk/node/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@lithe/plugin-sdk", - "version": "0.1.0", - "description": "Official SDK for Lithe plugin backends: plugin functions and build providers over the LSP base protocol.", - "license": "Apache-2.0", - "private": true, - "type": "module", - "engines": { - "node": ">=20.3" - }, - "exports": { - ".": { - "types": "./src/index.d.ts", - "default": "./src/index.mjs" - } - }, - "files": [ - "src", - "README.md" - ], - "scripts": { - "test": "node --test test/*.test.mjs" - } -} diff --git a/sdk/node/src/backend.mjs b/sdk/node/src/backend.mjs deleted file mode 100644 index e9eb1ebc37..0000000000 --- a/sdk/node/src/backend.mjs +++ /dev/null @@ -1,647 +0,0 @@ -// The plugin backend session: one process the host starts from the plugin's -// manifest and talks to over stdio with the LSP base protocol. The SDK owns -// everything Lithe manages uniformly -- the lifecycle, protocol negotiation, -// cancellation, error codes, and the `lithe/*` namespace -- and leaves the -// plugin free to decide what its handlers do. -// -// Contract: shared/contracts/plugins/v1/README.md ("Build providers" and -// "Plugin functions"). - -import { format } from 'node:util'; -import { encodeFrame, FrameDecoder, FramingError, MALFORMED } from './framing.mjs'; -import { PluginSdkError, toResponseError } from './errors.mjs'; -import { killAllProcesses, runProcess } from './process.mjs'; -import { - BUILD_VERSION, - boundedText, - ERROR_CODES, - FUNCTIONS_VERSION, - isFunctionId, - isReservedMethod, - LIMITS, - METHODS, -} from './protocol.mjs'; - -/** Canonical result format of each typed build request. */ -const BUILD_FORMATS = Object.freeze({ - [METHODS.buildModel]: 'litheBuildModel', - [METHODS.buildDependencies]: 'litheDependencies', -}); - -/** - * Creates a backend. Declare handlers, then call `listen()`. - * - * @param {object} [options] - * @param {string} [options.name] Reported as `serverInfo.name` and used as the log prefix. - * @param {string} [options.version] Reported as `serverInfo.version`. - * @param {object} [options.capabilities] Standard LSP server capabilities, for a - * backend that is also a language server. `experimental.litheBuild` and - * `experimental.litheFunctions` are negotiated by the SDK and cannot be set here. - */ -export function createBackend(options = {}) { - return new Backend(options); -} - -class Backend { - #name; - #version; - #capabilities; - #functions = new Map(); - #buildHandlers = new Map(); - #requestHandlers = new Map(); - #notificationHandlers = new Map(); - #initializeHooks = []; - #shutdownHooks = []; - /** `idle` → `listening` → `initializing` → `ready` → `shuttingDown` → `exited`. */ - #phase = 'idle'; - #negotiated = Object.freeze({ functions: false, build: false }); - #initializeParams = null; - /** Host requests being handled, by JSON-RPC ID. */ - #incoming = new Map(); - /** Requests this backend sent to the host, by JSON-RPC ID. */ - #outgoing = new Map(); - #nextRequestId = 1; - /** Serializes register/unregister so the host sees them in call order. */ - #registrations = Promise.resolve(); - #output = null; - #errorOutput = process.stderr; - #finish = null; - - /** Typed build-provider handlers (`lithe/build/*`). */ - build; - - constructor({ name = 'lithe-plugin-backend', version, capabilities = {} } = {}) { - const experimental = capabilities.experimental ?? {}; - if ('litheBuild' in experimental || 'litheFunctions' in experimental) { - throw new PluginSdkError( - 'sdk.capabilities.reserved', - 'litheBuild and litheFunctions are negotiated by the SDK; declare handlers instead.', - ); - } - this.#name = String(name); - this.#version = version === undefined ? undefined : String(version); - this.#capabilities = capabilities; - this.build = Object.freeze({ - /** Answers `lithe/build/model` with a `litheBuildModel` document. */ - model: (handler) => this.#declareBuild(METHODS.buildModel, handler), - /** Answers `lithe/build/dependencies` with a `litheDependencies` document. */ - dependencies: (handler) => this.#declareBuild(METHODS.buildDependencies, handler), - }); - } - - /** Protocols the host agreed to in `initialize`; both false before it. */ - get negotiated() { - return this.#negotiated; - } - - /** The host's `initialize` params, or null before it arrives. */ - get initializeParams() { - return this.#initializeParams; - } - - /** IDs of the functions this backend currently serves, sorted. */ - get functionIDs() { - return [...this.#functions.keys()].sort(); - } - - /** - * Declares a plugin function offered from the start, in the `initialize` - * answer. To add one later, use `registerFunction`. - * - * @param {string} id Dot-separated lowerCamelCase segments, such as `snapshot.refresh`. - * @param {(params: object, context: object) => unknown} handler - */ - function(id, handler) { - if (!this.#beforeAnswer()) { - throw new PluginSdkError('sdk.function.lateDeclaration', 'After initialize, add functions with registerFunction().'); - } - this.#checkNewFunction(id, handler); - this.#functions.set(id, handler); - return this; - } - - /** Handles a standard LSP request, for a backend that is also a language server. */ - onRequest(method, handler) { - this.#declareHandler(this.#requestHandlers, method, handler); - return this; - } - - /** Handles a standard LSP notification. */ - onNotification(method, handler) { - this.#declareHandler(this.#notificationHandlers, method, handler); - return this; - } - - /** Runs before the `initialize` answer, with `{ params, negotiated }`. */ - onInitialize(hook) { - this.#initializeHooks.push(hook); - return this; - } - - /** Runs on `shutdown`, after running calls were cancelled. */ - onShutdown(hook) { - this.#shutdownHooks.push(hook); - return this; - } - - /** - * Adds a function. Before the `initialize` answer (including from an - * `onInitialize` hook) it joins the initial list; afterwards the host must - * accept it first. - */ - registerFunction(id, handler) { - if (this.#beforeAnswer()) { - return settle(() => { - this.#checkNewFunction(id, handler); - this.#functions.set(id, handler); - }); - } - return this.#queueRegistration(async () => { - this.#requireFunctions(); - this.#checkNewFunction(id, handler); - // Installed before the request: the host may invoke it as soon as its - // registry changed, before this backend reads the reply. - this.#functions.set(id, handler); - try { - await this.#request(METHODS.functionRegister, { functions: [{ id }] }); - } catch (error) { - this.#functions.delete(id); - throw error; - } - }); - } - - /** Withdraws a function. The host ends its running calls as revoked. */ - unregisterFunction(id) { - if (this.#beforeAnswer()) { - return settle(() => { - if (!this.#functions.delete(id)) { - throw new PluginSdkError('sdk.function.unknown', `Function ${id} is not registered.`); - } - }); - } - return this.#queueRegistration(async () => { - this.#requireFunctions(); - if (!this.#functions.has(id)) { - throw new PluginSdkError('sdk.function.unknown', `Function ${id} is not registered.`); - } - await this.#request(METHODS.functionUnregister, { ids: [id] }); - this.#functions.delete(id); - for (const call of this.#incoming.values()) { - if (call.functionID === id) call.controller.abort(); - } - }); - } - - /** Sends a standard LSP request to the host. `lithe/*` methods are reserved. */ - sendRequest(method, params) { - this.#checkOutgoing(method); - return this.#request(method, params); - } - - /** Sends a standard LSP notification to the host. `lithe/*` methods are reserved. */ - sendNotification(method, params) { - this.#checkOutgoing(method); - this.#write({ jsonrpc: '2.0', method, ...(params === undefined ? {} : { params }) }); - } - - /** Writes a diagnostic line to stderr, which the host keeps in its log. */ - log(...parts) { - this.#errorOutput.write(`[${this.#name}] ${format(...parts)}\n`); - } - - /** - * Serves the host until it sends `exit` or closes the input stream, and - * resolves with the process exit code. With the default `exit`, the process - * then exits with that code. - * - * @param {object} [io] - * @param {NodeJS.ReadableStream} [io.input] Defaults to stdin. - * @param {NodeJS.WritableStream} [io.output] Defaults to stdout. - * @param {NodeJS.WritableStream} [io.errorOutput] Defaults to stderr. - * @param {((code: number) => void) | null} [io.exit] Defaults to `process.exit`; null only resolves. - */ - listen({ input = process.stdin, output = process.stdout, errorOutput = process.stderr, exit = (code) => process.exit(code) } = {}) { - if (this.#phase !== 'idle') { - throw new PluginSdkError('sdk.listen.repeated', 'A backend listens once.'); - } - this.#phase = 'listening'; - this.#output = output; - this.#errorOutput = errorOutput; - if (output === process.stdout) redirectConsole(errorOutput); - - return new Promise((resolve) => { - this.#finish = (code) => { - if (this.#phase === 'exited') return; - this.#phase = 'exited'; - this.#abortAll(); - killAllProcesses(); - for (const pending of this.#outgoing.values()) { - pending.reject(new PluginSdkError('sdk.session.closed', 'The session ended.')); - } - this.#outgoing.clear(); - input.off?.('data', onData); - resolve(code); - exit?.(code); - }; - const decoder = new FrameDecoder(); - const onData = (chunk) => { - let messages; - try { - messages = decoder.push(chunk); - } catch (error) { - if (!(error instanceof FramingError)) throw error; - this.log('Ending the session:', error.message); - this.#finish(1); - return; - } - for (const message of messages) this.#dispatch(message); - }; - input.on('data', onData); - input.once('end', () => this.#finish(this.#phase === 'shuttingDown' ? 0 : 1)); - input.once('error', () => this.#finish(1)); - }); - } - - #dispatch(message) { - if (message === MALFORMED) { - this.#respond(null, undefined, { code: ERROR_CODES.parseError, message: 'The message is not valid JSON.' }); - return; - } - // Valid JSON that is not a message object, such as `null` or a batch. - if (typeof message !== 'object' || message === null || Array.isArray(message)) { - this.#respond(null, undefined, { code: ERROR_CODES.invalidRequest, message: 'The message is not a JSON-RPC object.' }); - return; - } - const hasID = message.id !== undefined && message.id !== null; - if (typeof message.method !== 'string') { - if (hasID) this.#settleOutgoing(message); - return; - } - if (hasID) { - this.#handleRequest(message); - } else { - this.#handleNotification(message); - } - } - - #handleRequest({ id, method, params }) { - if (method === METHODS.initialize) { - void this.#initialize(id, params ?? {}); - return; - } - if (method === METHODS.shutdown) { - void this.#shutdown(id); - return; - } - if (this.#phase === 'listening' || this.#phase === 'initializing') { - this.#respond(id, undefined, { code: ERROR_CODES.serverNotInitialized, message: 'The backend is not initialized.' }); - return; - } - if (this.#phase !== 'ready') { - this.#respond(id, undefined, { code: ERROR_CODES.invalidRequest, message: 'The backend is shutting down.' }); - return; - } - if (method === METHODS.functionInvoke) { - this.#invokeFunction(id, params ?? {}); - return; - } - const build = this.#buildHandlers.get(method); - if (build && this.#negotiated.build) { - this.#run(id, null, (context) => build(params ?? {}, context), (result) => buildResult(method, result)); - return; - } - const handler = this.#requestHandlers.get(method); - if (handler) { - this.#run(id, null, (context) => handler(params, context), (result) => result ?? null); - return; - } - this.#respond(id, undefined, { code: ERROR_CODES.methodNotFound, message: `Unsupported method: ${method}` }); - } - - #handleNotification({ method, params }) { - if (method === METHODS.exit) { - this.#finish(this.#phase === 'shuttingDown' ? 0 : 1); - return; - } - if (method === METHODS.cancelRequest) { - this.#incoming.get(requestKey(params?.id))?.controller.abort(); - return; - } - if (this.#phase !== 'ready' || method === METHODS.initialized) return; - const handler = this.#notificationHandlers.get(method); - if (!handler) return; - Promise.resolve() - .then(() => handler(params)) - .catch((error) => this.log(`Notification ${method} failed:`, error)); - } - - async #initialize(id, params) { - if (this.#phase !== 'listening') { - this.#respond(id, undefined, { code: ERROR_CODES.invalidRequest, message: 'initialize was already received.' }); - return; - } - this.#phase = 'initializing'; - this.#initializeParams = params; - const offered = params.capabilities?.experimental ?? {}; - const offers = (entry, version) => Array.isArray(entry?.versions) && entry.versions.includes(version); - this.#negotiated = Object.freeze({ - functions: offers(offered.litheFunctions, FUNCTIONS_VERSION), - build: this.#buildHandlers.size > 0 && offers(offered.litheBuild, BUILD_VERSION), - }); - try { - for (const hook of this.#initializeHooks) { - await hook({ params, negotiated: this.#negotiated }); - } - } catch (error) { - this.log('initialize failed:', error); - if (this.#phase === 'initializing') this.#phase = 'listening'; - this.#respond(id, undefined, toResponseError(error)); - return; - } - // A shutdown or exit that arrived while a hook ran ended the session; - // finishing initialize must not reopen it. - if (this.#phase !== 'initializing') { - this.#respond(id, undefined, { code: ERROR_CODES.invalidRequest, message: 'The session ended during initialize.' }); - return; - } - if (!this.#negotiated.functions && this.#functions.size > 0) { - this.log('The host did not offer litheFunctions version 1; functions stay unavailable.'); - } - const experimental = { ...(this.#capabilities.experimental ?? {}) }; - if (this.#negotiated.build) experimental.litheBuild = { version: BUILD_VERSION }; - if (this.#negotiated.functions) { - experimental.litheFunctions = { - version: FUNCTIONS_VERSION, - functions: this.functionIDs.map((functionID) => ({ id: functionID })), - }; - } - this.#phase = 'ready'; - this.#respond(id, { - capabilities: { ...this.#capabilities, experimental }, - serverInfo: { name: this.#name, ...(this.#version ? { version: this.#version } : {}) }, - }); - } - - async #shutdown(id) { - if (this.#phase !== 'ready' && this.#phase !== 'listening' && this.#phase !== 'initializing') { - this.#respond(id, undefined, { code: ERROR_CODES.invalidRequest, message: 'shutdown was already received.' }); - return; - } - this.#phase = 'shuttingDown'; - this.#abortAll(); - for (const hook of this.#shutdownHooks) { - try { - await hook(); - } catch (error) { - this.log('A shutdown hook failed:', error); - } - } - killAllProcesses(); - this.#respond(id, null); - } - - #invokeFunction(id, { functionID, operationID, params }) { - const handler = this.#negotiated.functions ? this.#functions.get(functionID) : undefined; - if (!handler) { - this.#respond(id, undefined, { code: ERROR_CODES.invalidParams, message: `Function ${functionID} is not registered.` }); - return; - } - let reporting = true; - const progress = ({ message, percentage } = {}) => { - if (!reporting) return; - const report = { operationID }; - if (message !== undefined) report.message = boundedText(message); - if (Number.isFinite(percentage)) report.percentage = Math.min(100, Math.max(0, Math.round(percentage))); - this.#write({ jsonrpc: '2.0', method: METHODS.functionProgress, params: report }); - }; - this.#run( - id, - functionID, - (context) => handler(params ?? {}, { ...context, functionID, operationID, progress }), - functionResult, - () => { - reporting = false; - }, - ); - } - - /** Runs one handler with its own cancellation and answers exactly once. */ - #run(id, functionID, invoke, shape, onSettled) { - const controller = new AbortController(); - const key = requestKey(id); - this.#incoming.set(key, { controller, functionID }); - const { signal } = controller; - const context = { - signal, - throwIfCancelled: () => signal.throwIfAborted(), - run: (command, args, options = {}) => { - const running = runProcess(command, args, { - ...options, - signal: options.signal ? AbortSignal.any([signal, options.signal]) : signal, - }); - // A tool the handler did not await is stopped when the call ends; its - // rejection then must not end the backend as an unhandled rejection. - running.catch(() => {}); - return running; - }, - log: (...parts) => this.log(...parts), - }; - Promise.resolve() - .then(() => invoke(context)) - .then( - (value) => { - signal.throwIfAborted(); - return { result: shape(value) }; - }, - (error) => { - if (!signal.aborted) this.log(`${functionID ? `Function ${functionID}` : 'A request'} failed:`, error); - return { error: toResponseError(error, signal) }; - }, - ) - .catch((error) => { - // Shaping the result failed, for example a build result in another format. - if (!signal.aborted) this.log('The result could not be sent:', error); - return { error: toResponseError(error, signal) }; - }) - .then(({ result, error }) => { - onSettled?.(); - this.#incoming.delete(key); - // Work belongs to its call: tools still running when the handler - // settled, such as the siblings of a failed `Promise.all`, end now. - // Other calls have their own controllers and keep running. - controller.abort(new PluginSdkError('sdk.call.ended', 'The call ended.')); - this.#respond(id, result, error); - }); - } - - #declareBuild(method, handler) { - if (this.#phase !== 'idle') { - throw new PluginSdkError('sdk.build.lateDeclaration', 'Declare build handlers before listen().'); - } - if (typeof handler !== 'function') throw new TypeError('A build handler must be a function.'); - this.#buildHandlers.set(method, handler); - return this; - } - - #declareHandler(handlers, method, handler) { - if (typeof method !== 'string' || isReservedMethod(method)) { - throw new PluginSdkError('sdk.method.reserved', `${method} is managed by the SDK and Lithe.`); - } - if (typeof handler !== 'function') throw new TypeError('A handler must be a function.'); - handlers.set(method, handler); - } - - #checkNewFunction(id, handler) { - if (!isFunctionId(id)) { - throw new PluginSdkError( - 'sdk.function.invalidID', - `Function ID ${JSON.stringify(id)} must be dot-separated lowerCamelCase segments of at most 128 bytes.`, - ); - } - if (typeof handler !== 'function') throw new TypeError('A function handler must be a function.'); - if (this.#functions.has(id)) { - throw new PluginSdkError('sdk.function.duplicate', `Function ${id} is already declared.`); - } - if (this.#functions.size >= LIMITS.maxFunctions) { - throw new PluginSdkError('sdk.function.limit', `A backend serves at most ${LIMITS.maxFunctions} functions.`); - } - } - - /** True until the `initialize` answer fixes the initial function list. */ - #beforeAnswer() { - return this.#phase === 'idle' || this.#phase === 'listening' || this.#phase === 'initializing'; - } - - #requireFunctions() { - if (this.#phase !== 'ready' || !this.#negotiated.functions) { - throw new PluginSdkError('sdk.functions.unavailable', 'The host has not negotiated plugin functions.'); - } - } - - #queueRegistration(change) { - const next = this.#registrations.then(change); - this.#registrations = next.catch(() => {}); - return next; - } - - #checkOutgoing(method) { - if (typeof method !== 'string' || isReservedMethod(method)) { - throw new PluginSdkError( - 'sdk.method.reserved', - `${method} is reserved. A backend never asks the host to run a domain operation or a function.`, - ); - } - if (this.#phase !== 'ready') { - throw new PluginSdkError('sdk.session.notReady', 'The session is not initialized.'); - } - } - - #request(method, params) { - const id = `sdk-${this.#nextRequestId++}`; - return new Promise((resolve, reject) => { - this.#outgoing.set(id, { resolve, reject, method }); - this.#write({ jsonrpc: '2.0', id, method, ...(params === undefined ? {} : { params }) }); - }); - } - - #settleOutgoing({ id, result, error }) { - const pending = this.#outgoing.get(id); - if (!pending) return; - this.#outgoing.delete(id); - if (error) { - const failure = new PluginSdkError(error.data?.code ?? 'sdk.host.error', error.message ?? `${pending.method} failed.`); - failure.rpcCode = error.code; - pending.reject(failure); - } else { - pending.resolve(result ?? null); - } - } - - #abortAll() { - for (const call of this.#incoming.values()) call.controller.abort(); - } - - #respond(id, result, error) { - if (this.#phase === 'exited') return; - try { - this.#write(error ? { jsonrpc: '2.0', id, error } : { jsonrpc: '2.0', id, result: result ?? null }); - } catch (failure) { - // A result JSON cannot encode, such as a BigInt or a cycle, must not - // become an unhandled rejection that ends the whole session. - this.log('The result could not be encoded:', failure); - this.#write({ jsonrpc: '2.0', id, error: toResponseError(failure) }); - } - } - - #write(message) { - if (!this.#output || this.#phase === 'exited') return; - this.#output.write(encodeFrame(message)); - } -} - -/** Runs `change` now and reports its outcome as a promise. */ -function settle(change) { - try { - change(); - return Promise.resolve(); - } catch (error) { - return Promise.reject(error); - } -} - -/** The JSON-RPC ID as a map key; numbers and strings never collide. */ -function requestKey(id) { - return `${typeof id}:${id}`; -} - -/** - * Stamps the canonical format on a build result, so a handler can return the - * plain document. A result in another format is a backend bug: Core would - * refuse it as `plugin.build.invalidOutput`, so it fails here with a clear log. - */ -function buildResult(method, result) { - const expected = BUILD_FORMATS[method]; - if (result === null || typeof result !== 'object' || Array.isArray(result)) { - throw new PluginSdkError('sdk.build.invalidResult', `${method} must return a ${expected} object.`); - } - if (result.format !== undefined && result.format !== expected) { - throw new PluginSdkError('sdk.build.invalidResult', `${method} returned format ${result.format}, not ${expected}.`); - } - if (result.version !== undefined && result.version !== 1) { - throw new PluginSdkError('sdk.build.invalidResult', `${method} returned version ${result.version}, not 1.`); - } - return { ...result, format: expected, version: 1 }; -} - -/** - * A function result as the JSON value Core accepts. A value JSON cannot - * encode, or one over Core's 1 MiB limit, fails here as a backend bug with a - * log line, instead of breaking the response frame or being refused by Core. - */ -function functionResult(result) { - let encoded; - try { - encoded = JSON.stringify(result ?? null); - } catch (error) { - throw new PluginSdkError('sdk.function.invalidResult', `The result is not JSON: ${error.message}`); - } - if (encoded === undefined) { - throw new PluginSdkError('sdk.function.invalidResult', 'The result is not a JSON value.'); - } - if (Buffer.byteLength(encoded) > LIMITS.maxResultBytes) { - throw new PluginSdkError('sdk.function.invalidResult', `The result exceeds ${LIMITS.maxResultBytes} bytes.`); - } - return result ?? null; -} - -/** - * Sends `console.log`, `info`, and `debug` to stderr. Stdout carries the - * protocol; a stray line there would corrupt the stream and end the session. - */ -function redirectConsole(errorOutput) { - const write = (...parts) => errorOutput.write(`${format(...parts)}\n`); - console.log = write; - console.info = write; - console.debug = write; -} diff --git a/sdk/node/src/errors.mjs b/sdk/node/src/errors.mjs deleted file mode 100644 index 7df9ace514..0000000000 --- a/sdk/node/src/errors.mjs +++ /dev/null @@ -1,60 +0,0 @@ -import { boundedText, ERROR_CODES, isPluginErrorCode } from './protocol.mjs'; - -/** - * A failure a handler reports on purpose. Core ends the call with the stable - * `plugin.function.failed` code and forwards `code` as `pluginErrorCode`, so - * the plugin's own UI can tell its failures apart. Lithe never branches on it. - */ -export class PluginError extends Error { - /** - * @param {string} code Identifier of at most 64 bytes, such as `maven.snapshotLocked`. - * @param {string} message User-facing text; Core keeps the first 1024 bytes. - * @param {object} [options] `{ cause }`, kept for logs only. - */ - constructor(code, message, options) { - super(message, options); - if (!isPluginErrorCode(code)) { - throw new TypeError( - `Plugin error code ${JSON.stringify(code)} must start with a letter, use letters, digits, ".", "_" or "-", and be at most 64 bytes.`, - ); - } - this.name = 'PluginError'; - this.code = code; - } -} - -/** An SDK usage error, raised synchronously to the plugin author. */ -export class PluginSdkError extends Error { - constructor(code, message) { - super(message); - this.name = 'PluginSdkError'; - this.code = code; - } -} - -/** True for the rejection an aborted `AbortSignal` produces. */ -export function isAbort(error, signal) { - return Boolean(signal?.aborted) && (error === signal.reason || error?.name === 'AbortError'); -} - -/** - * The JSON-RPC error object for a handler failure. Only `PluginError` carries - * a plugin code; any other exception becomes a generic internal error so that - * stack traces and machine paths never reach the host's UI. A `PluginError` - * message is shown to the user as written, so it must not contain them either. - */ -export function toResponseError(error, signal) { - if (isAbort(error, signal)) { - return { code: ERROR_CODES.requestCancelled, message: 'The request was cancelled.' }; - } - if (error instanceof PluginError) { - return { - code: ERROR_CODES.requestFailed, - message: boundedText(error.message), - data: { code: error.code }, - }; - } - // Unexpected exceptions often embed absolute paths (ENOENT) or internals; - // the caller logs the details to stderr instead. - return { code: ERROR_CODES.internalError, message: 'The plugin backend failed unexpectedly.' }; -} diff --git a/sdk/node/src/framing.mjs b/sdk/node/src/framing.mjs deleted file mode 100644 index ccfe7742e1..0000000000 --- a/sdk/node/src/framing.mjs +++ /dev/null @@ -1,74 +0,0 @@ -// LSP base-protocol framing: `Content-Length` headers followed by a UTF-8 -// JSON body. Lithe talks to every plugin backend this way, so the SDK owns -// the framing and plugin code never writes to stdout itself. - -const HEADER_END = Buffer.from('\r\n\r\n', 'ascii'); - -/** Largest header block accepted from the host. */ -const MAX_HEADER_BYTES = 8192; - -/** Largest body accepted from the host. Larger frames end the session. */ -export const MAX_FRAME_BYTES = 64 * 1024 * 1024; - -/** Stands for a body that is not JSON; compared by identity, so no host message can mimic it. */ -export const MALFORMED = Object.freeze({ malformed: true }); - -/** A framing error the session cannot recover from. */ -export class FramingError extends Error {} - -/** Incrementally splits a byte stream into decoded JSON-RPC messages. */ -export class FrameDecoder { - #buffer = Buffer.alloc(0); - - /** - * Appends `chunk` and returns every complete message it finished. A body - * that is not JSON is returned as `MALFORMED` so the caller can - * report it without losing the framing position. - */ - push(chunk) { - this.#buffer = this.#buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([this.#buffer, chunk]); - const messages = []; - for (;;) { - const headerEnd = this.#buffer.indexOf(HEADER_END); - // The same bound whether the header arrived whole or in pieces. - if (headerEnd > MAX_HEADER_BYTES || (headerEnd < 0 && this.#buffer.length > MAX_HEADER_BYTES)) { - throw new FramingError('The message header is too long.'); - } - if (headerEnd < 0) return messages; - const length = contentLength(this.#buffer.subarray(0, headerEnd).toString('ascii')); - const bodyStart = headerEnd + HEADER_END.length; - if (this.#buffer.length < bodyStart + length) { - return messages; - } - const body = this.#buffer.subarray(bodyStart, bodyStart + length).toString('utf8'); - this.#buffer = this.#buffer.subarray(bodyStart + length); - try { - messages.push(JSON.parse(body)); - } catch { - messages.push(MALFORMED); - } - } - } -} - -function contentLength(header) { - for (const line of header.split('\r\n')) { - const separator = line.indexOf(':'); - if (separator < 0) continue; - if (line.slice(0, separator).trim().toLowerCase() !== 'content-length') continue; - const value = line.slice(separator + 1).trim(); - if (!/^\d+$/.test(value)) break; - const length = Number(value); - if (length > MAX_FRAME_BYTES) { - throw new FramingError(`A message of ${length} bytes exceeds the ${MAX_FRAME_BYTES}-byte limit.`); - } - return length; - } - throw new FramingError('A message has no valid Content-Length header.'); -} - -/** Encodes one JSON-RPC message as a complete frame. */ -export function encodeFrame(message) { - const body = Buffer.from(JSON.stringify(message), 'utf8'); - return Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'ascii'), body]); -} diff --git a/sdk/node/src/index.d.ts b/sdk/node/src/index.d.ts deleted file mode 100644 index 8841d2461a..0000000000 --- a/sdk/node/src/index.d.ts +++ /dev/null @@ -1,146 +0,0 @@ -// Type declarations for the Lithe plugin backend SDK. Keep in sync with the -// `.mjs` sources; `sdk/node/test/support.test.mjs` checks the exported names. - -/** Any value JSON can carry. */ -export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; -export type JsonObject = { [key: string]: JsonValue }; - -export interface RunProcessOptions { - cwd?: string; - /** Replaces the environment when given; Lithe's process markers are carried over regardless. */ - env?: Record; - /** Aborting it ends the whole process tree and rejects with its reason. */ - signal?: AbortSignal; - /** Captured bytes per stream; default 1 MiB. */ - maxOutputBytes?: number; - onStdout?: (chunk: string) => void; - onStderr?: (chunk: string) => void; - /** Delay between the polite and forced stop; default 2000. */ - killGraceMilliseconds?: number; -} - -export interface ProcessResult { - exitCode: number | null; - signal: string | null; - stdout: string; - stderr: string; - truncated: boolean; -} - -/** Shared by every handler. */ -export interface RequestContext { - /** Aborted when the host cancels the call, the call times out, the session ends, or the call has settled. */ - signal: AbortSignal; - throwIfCancelled(): void; - /** `runProcess` bound to this call: its tree ends when the call is cancelled or settles. */ - run(command: string, args?: string[], options?: RunProcessOptions): Promise; - /** Writes a line to stderr, which the host keeps in its log. */ - log(...parts: unknown[]): void; -} - -export interface ProgressReport { - /** Cut to 1024 bytes. */ - message?: string; - /** Rounded and clamped to 0-100. */ - percentage?: number; -} - -export interface FunctionContext extends RequestContext { - functionID: string; - /** Opaque per-call ID chosen by Lithe. */ - operationID: string; - /** Reports progress; ignored once the call has ended. */ - progress(report: ProgressReport): void; -} - -export type FunctionHandler = (params: JsonObject, context: FunctionContext) => unknown | Promise; -export type BuildHandler = (params: BuildRequestParams, context: RequestContext) => JsonObject | Promise; - -/** Params of `lithe/build/model` and `lithe/build/dependencies`. */ -export interface BuildRequestParams { - buildSystemID: string; - root: string; - options: JsonObject; - optionArguments: string[]; - environment: Record; - /** Dependencies only: the selected project, or null for the workspace. */ - project?: { name: string; path: string } | null; - /** True when the user or a sync-file change asked to reload; skip any snapshot. */ - refresh?: boolean; - /** The build tool the host resolved; absent when it could not, so look it up yourself. */ - tool?: { executable: string }; - [key: string]: unknown; -} - -export interface Negotiated { - functions: boolean; - build: boolean; -} - -export interface BackendOptions { - name?: string; - version?: string; - /** Standard LSP server capabilities; `experimental.litheBuild` and `experimental.litheFunctions` are reserved. */ - capabilities?: JsonObject; -} - -export interface ListenOptions { - input?: NodeJS.ReadableStream; - output?: NodeJS.WritableStream; - errorOutput?: NodeJS.WritableStream; - /** Defaults to `process.exit`; null only resolves the returned promise. */ - exit?: ((code: number) => void) | null; -} - -export interface Backend { - readonly negotiated: Negotiated; - readonly initializeParams: JsonObject | null; - readonly functionIDs: string[]; - readonly build: { - model(handler: BuildHandler): Backend; - dependencies(handler: BuildHandler): Backend; - }; - function(id: string, handler: FunctionHandler): Backend; - registerFunction(id: string, handler: FunctionHandler): Promise; - unregisterFunction(id: string): Promise; - onRequest(method: string, handler: (params: unknown, context: RequestContext) => unknown): Backend; - onNotification(method: string, handler: (params: unknown) => unknown): Backend; - onInitialize(hook: (event: { params: JsonObject; negotiated: Negotiated }) => unknown): Backend; - onShutdown(hook: () => unknown): Backend; - sendRequest(method: string, params?: unknown): Promise; - sendNotification(method: string, params?: unknown): void; - log(...parts: unknown[]): void; - listen(options?: ListenOptions): Promise; -} - -export function createBackend(options?: BackendOptions): Backend; - -export class PluginError extends Error { - constructor(code: string, message: string, options?: { cause?: unknown }); - readonly code: string; -} - -export class PluginSdkError extends Error { - readonly code: string; -} - -export function runProcess(command: string, args?: string[], options?: RunProcessOptions): Promise; - -export interface StateStore { - readonly directory: string; - read(key: string, fallback?: T): T | undefined; - write(key: string, value: JsonValue): void; - delete(key: string): void; -} - -export function openStateStore(directory: string, options?: { packageDirectory?: string | null }): StateStore; - -export function isFunctionId(value: unknown): boolean; - -export const LIMITS: Readonly<{ - maxFunctions: number; - maxFunctionIdBytes: number; - maxPluginErrorCodeBytes: number; - maxMessageBytes: number; - maxResultBytes: number; -}>; diff --git a/sdk/node/src/index.mjs b/sdk/node/src/index.mjs deleted file mode 100644 index 6263ac8288..0000000000 --- a/sdk/node/src/index.mjs +++ /dev/null @@ -1,8 +0,0 @@ -// Official Node.js SDK for Lithe plugin backends. -// Guide: sdk/node/README.md. Wire contract: shared/contracts/plugins/v1/README.md. - -export { createBackend } from './backend.mjs'; -export { PluginError, PluginSdkError } from './errors.mjs'; -export { runProcess } from './process.mjs'; -export { openStateStore } from './state.mjs'; -export { isFunctionId, LIMITS } from './protocol.mjs'; diff --git a/sdk/node/src/process.mjs b/sdk/node/src/process.mjs deleted file mode 100644 index 03f59df6bc..0000000000 --- a/sdk/node/src/process.mjs +++ /dev/null @@ -1,188 +0,0 @@ -// Child processes a backend starts for one call. Lithe's host offers no -// process capability in protocol version 1, so a backend runs its tools -// itself; this helper makes sure that the call ending, cancelling it, or the -// backend shutting down also ends the whole process tree it started. When the -// backend itself is killed no JavaScript runs; the host then ends what is left -// through its own process lifecycle, which owns that boundary. - -import { spawn } from 'node:child_process'; -import { StringDecoder } from 'node:string_decoder'; - -const isWindows = process.platform === 'win32'; - -/** Every process tree started through `runProcess` that has not exited. */ -const running = new Set(); - -/** - * Variables Lithe sets on the backend so it can find and end every process - * the backend started, even one that left its process group. A tool always - * inherits them, also when the plugin replaces the environment. - */ -const HOST_MARKERS = ['LITHE_PROCESS_SCOPE', 'LITHE_PLUGIN_BACKEND_SCOPE']; - -function withHostMarkers(env) { - const result = { ...env }; - for (const name of HOST_MARKERS) { - if (process.env[name] === undefined) delete result[name]; - else result[name] = process.env[name]; - } - return result; -} - -/** - * Runs `command` without a shell and resolves with its exit status and - * captured output. Aborting `signal` ends the process tree and rejects with - * the signal's reason. A non-zero exit resolves; the caller decides what it - * means for its tool. - * - * @param {string} command Executable path or name. - * @param {string[]} [args] - * @param {object} [options] - * @param {string} [options.cwd] - * @param {Record} [options.env] Replaces the environment when given; the - * host's process markers are carried over regardless. - * @param {AbortSignal} [options.signal] - * @param {number} [options.maxOutputBytes] Captured bytes per stream; default 1 MiB. - * @param {(chunk: string) => void} [options.onStdout] - * @param {(chunk: string) => void} [options.onStderr] - * @param {number} [options.killGraceMilliseconds] Delay between the polite and forced stop; default 2000. - */ -export function runProcess(command, args = [], options = {}) { - const { cwd, env, signal, onStdout, onStderr } = options; - const maxOutputBytes = options.maxOutputBytes ?? 1 << 20; - const killGraceMilliseconds = options.killGraceMilliseconds ?? 2000; - if (signal?.aborted) return Promise.reject(signal.reason); - - return new Promise((resolve, reject) => { - const child = spawn(command, args, { - cwd, - env: env && withHostMarkers(env), - shell: false, - windowsHide: true, - // Its own process group on POSIX, so a stop reaches grandchildren too. - detached: !isWindows, - stdio: ['ignore', 'pipe', 'pipe'], - }); - const entry = { child, stopping: false, forceTimer: null }; - running.add(entry); - const stdout = capture(maxOutputBytes, onStdout); - const stderr = capture(maxOutputBytes, onStderr); - child.stdout.on('data', stdout.push); - child.stderr.on('data', stderr.push); - - const onAbort = () => stopTree(entry, killGraceMilliseconds); - signal?.addEventListener('abort', onAbort, { once: true }); - - let settled = false; - const finish = (outcome) => { - if (settled) return; - settled = true; - // A stopped tree stays registered until its forced stop: the leader - // can exit on SIGTERM while a grandchild in its group ignores it. - if (!entry.stopping) { - // The leader ended on its own; members it left behind in its group, - // such as a background worker, end with it. - signalTree(child, 'SIGKILL', { evenAfterExit: true }); - running.delete(entry); - } - signal?.removeEventListener('abort', onAbort); - stdout.end(); - stderr.end(); - outcome(); - }; - child.once('error', (error) => finish(() => reject(error))); - child.once('close', (exitCode, exitSignal) => - finish(() => { - if (signal?.aborted) { - reject(signal.reason); - return; - } - resolve({ - exitCode, - signal: exitSignal, - stdout: stdout.text(), - stderr: stderr.text(), - truncated: stdout.truncated() || stderr.truncated(), - }); - }), - ); - }); -} - -/** - * Forcibly ends every process tree still running. The backend calls it on - * shutdown, on `exit`, and when the host closes stdin, so no tool outlives - * the session that started it. - */ -export function killAllProcesses() { - for (const entry of running) { - clearTimeout(entry.forceTimer); - signalTree(entry.child, 'SIGKILL', { evenAfterExit: true }); - } - running.clear(); -} - -/** Number of process trees still running or being stopped, for tests and diagnostics. */ -export function runningProcessCount() { - return running.size; -} - -function stopTree(entry, graceMilliseconds) { - if (entry.stopping) return; - entry.stopping = true; - signalTree(entry.child, 'SIGTERM'); - entry.forceTimer = setTimeout(() => { - running.delete(entry); - signalTree(entry.child, 'SIGKILL', { evenAfterExit: true }); - }, graceMilliseconds); - entry.forceTimer.unref?.(); -} - -/** - * Signals the child's whole tree. `evenAfterExit` still signals the POSIX - * process group after its leader exited, which reaches members that survived - * the polite stop. While any member lives the group ID cannot be reused; - * once all exited the signal fails with ESRCH, which is ignored. - */ -function signalTree(child, signalName, { evenAfterExit = false } = {}) { - if (child.pid === undefined) return; - const exited = child.exitCode !== null || child.signalCode !== null; - if (exited && (isWindows || !evenAfterExit)) return; - try { - if (isWindows) { - // taskkill /T is the only stock way to end a Windows process tree. - spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' }); - } else { - process.kill(-child.pid, signalName); - } - } catch { - // The group already exited between the check and the signal. - } -} - -function capture(limit, listener) { - const chunks = []; - // Keeps a multi-byte character split across chunks whole in `listener`. - const decoder = new StringDecoder('utf8'); - let size = 0; - let truncated = false; - const deliver = (text) => { - if (text) listener?.(text); - }; - return { - push(chunk) { - deliver(decoder.write(chunk)); - if (size >= limit) { - truncated = true; - return; - } - const kept = chunk.subarray(0, limit - size); - truncated ||= kept.length < chunk.length; - chunks.push(kept); - size += kept.length; - }, - end: () => deliver(decoder.end()), - text: () => Buffer.concat(chunks).toString('utf8'), - truncated: () => truncated, - }; -} diff --git a/sdk/node/src/protocol.mjs b/sdk/node/src/protocol.mjs deleted file mode 100644 index cb13ce88b9..0000000000 --- a/sdk/node/src/protocol.mjs +++ /dev/null @@ -1,92 +0,0 @@ -// Wire constants and validation shared with Rust Core -// (rust/lithe-core/src/lsp/interface/plugin_function.rs). The SDK checks the -// same rules locally so a plugin author sees a mistake when registering a -// handler instead of as a refused session at runtime. - -/** Protocol versions this SDK can answer. */ -export const FUNCTIONS_VERSION = 1; -export const BUILD_VERSION = 1; - -export const METHODS = Object.freeze({ - initialize: 'initialize', - initialized: 'initialized', - shutdown: 'shutdown', - exit: 'exit', - cancelRequest: '$/cancelRequest', - functionInvoke: 'lithe/function/invoke', - functionRegister: 'lithe/function/register', - functionUnregister: 'lithe/function/unregister', - functionProgress: 'lithe/function/progress', - buildModel: 'lithe/build/model', - buildDependencies: 'lithe/build/dependencies', -}); - -/** JSON-RPC and LSP error codes the SDK answers with. */ -export const ERROR_CODES = Object.freeze({ - parseError: -32700, - invalidRequest: -32600, - methodNotFound: -32601, - invalidParams: -32602, - internalError: -32603, - serverNotInitialized: -32002, - requestFailed: -32000, - requestCancelled: -32800, -}); - -export const LIMITS = Object.freeze({ - maxFunctions: 256, - maxFunctionIdBytes: 128, - maxPluginErrorCodeBytes: 64, - maxMessageBytes: 1024, - maxResultBytes: 1 << 20, -}); - -/** Dot-separated lowerCamelCase segments, for example `snapshot.refresh`. */ -export function isFunctionId(value) { - return ( - typeof value === 'string' && - value.length > 0 && - Buffer.byteLength(value) <= LIMITS.maxFunctionIdBytes && - value.split('.').every((segment) => /^[a-z][A-Za-z0-9]*$/.test(segment)) - ); -} - -/** The plugin error code alphabet Core forwards as `pluginErrorCode`. */ -export function isPluginErrorCode(value) { - return ( - typeof value === 'string' && - Buffer.byteLength(value) <= LIMITS.maxPluginErrorCodeBytes && - /^[A-Za-z][A-Za-z0-9._-]*$/.test(value) - ); -} - -/** Cuts `value` to `limit` UTF-8 bytes without splitting a character. */ -export function boundedText(value, limit = LIMITS.maxMessageBytes) { - const text = String(value ?? ''); - if (Buffer.byteLength(text) <= limit) return text; - let used = 0; - let end = 0; - for (const character of text) { - const size = Buffer.byteLength(character); - if (used + size > limit) break; - used += size; - end += character.length; - } - return text.slice(0, end); -} - -/** - * Methods reserved for the SDK. A plugin cannot install its own handler for - * these, or send them to the host: the lifecycle belongs to the host's - * session management, and `lithe/*` belongs to Lithe's typed contracts. - */ -export function isReservedMethod(method) { - return ( - method === METHODS.initialize || - method === METHODS.initialized || - method === METHODS.shutdown || - method === METHODS.exit || - method === METHODS.cancelRequest || - method.startsWith('lithe/') - ); -} diff --git a/sdk/node/src/state.mjs b/sdk/node/src/state.mjs deleted file mode 100644 index f648bb3da6..0000000000 --- a/sdk/node/src/state.mjs +++ /dev/null @@ -1,148 +0,0 @@ -// Durable plugin state. The host gives every backend writable directories -// through the `${stateDir}` and `${workspaceStateDir}` launch variables; the -// installed package and the host's installation are read-only, because -// signatures and delta updates are computed over their exact bytes. - -import { - closeSync, - existsSync, - fsyncSync, - mkdirSync, - openSync, - readFileSync, - realpathSync, - renameSync, - rmSync, - writeFileSync, -} from 'node:fs'; -import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; -import { randomBytes } from 'node:crypto'; -import { PluginSdkError } from './errors.mjs'; - -/** Version of the envelope each value is stored in. */ -const STORE_FORMAT = 1; -const KEY = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; - -/** - * Opens a key-value store in `directory`, creating the directory. Values are - * JSON; each key is one file replaced atomically, so a crash leaves either the - * old or the new value, never half of one. - * - * @param {string} directory Absolute path, normally the value of `${workspaceStateDir}`. - * @param {object} [options] - * @param {string | null} [options.packageDirectory] The installed plugin package, - * which the store refuses to write into. Defaults to the nearest directory - * containing `plugin.json` above the running entry script. - */ -export function openStateStore(directory, options = {}) { - if (typeof directory !== 'string' || !isAbsolute(directory)) { - throw new PluginSdkError('sdk.state.invalidDirectory', 'The state directory must be an absolute path passed by the host.'); - } - const packageDirectory = options.packageDirectory === undefined ? findPackageDirectory() : options.packageDirectory; - // Checked before creating anything, so a refused path leaves the package untouched. - const root = canonical(directory); - if (packageDirectory && isInside(root, canonical(packageDirectory))) { - throw new PluginSdkError( - 'sdk.state.readOnlyPackage', - 'The state directory is inside the plugin package, which is read-only. Use ${stateDir} or ${workspaceStateDir}.', - ); - } - mkdirSync(root, { recursive: true }); - return new StateStore(root); -} - -class StateStore { - #root; - - constructor(root) { - this.#root = root; - } - - /** The resolved directory, for a backend that keeps its own files next to the store. */ - get directory() { - return this.#root; - } - - /** The stored value, or `fallback` when the key is missing or unreadable. */ - read(key, fallback = undefined) { - const path = this.#path(key); - if (!existsSync(path)) return fallback; - try { - const envelope = JSON.parse(readFileSync(path, 'utf8')); - if (envelope?.litheStateStore !== STORE_FORMAT || !('value' in envelope)) return fallback; - return envelope.value; - } catch { - return fallback; - } - } - - /** Stores `value`, which must be JSON-serializable, replacing the previous one. */ - write(key, value) { - const path = this.#path(key); - // `undefined`, a function, or a symbol would vanish from the envelope and - // silently replace the stored value with nothing; refuse it and keep the file. - if (JSON.stringify(value) === undefined) { - throw new PluginSdkError('sdk.state.invalidValue', `The value for ${key} is not JSON-serializable.`); - } - const body = JSON.stringify({ litheStateStore: STORE_FORMAT, value }); - const temporary = `${path}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`; - try { - writeFileSync(temporary, body, { encoding: 'utf8', mode: 0o600 }); - const handle = openSync(temporary, 'r'); - try { - fsyncSync(handle); - } finally { - closeSync(handle); - } - renameSync(temporary, path); - } catch (error) { - rmSync(temporary, { force: true }); - throw error; - } - } - - /** Removes the key; a missing key is not an error. */ - delete(key) { - rmSync(this.#path(key), { force: true }); - } - - #path(key) { - if (typeof key !== 'string' || !KEY.test(key) || key.endsWith('.tmp')) { - throw new PluginSdkError( - 'sdk.state.invalidKey', - `State key ${JSON.stringify(key)} must be 1-128 letters, digits, ".", "_" or "-", start with a letter or digit, and not end with ".tmp".`, - ); - } - return join(this.#root, `${key}.json`); - } -} - -function isInside(path, parent) { - const difference = relative(parent, path); - return difference === '' || (!difference.startsWith(`..${sep}`) && difference !== '..' && !isAbsolute(difference)); -} - -/** Resolves symbolic links of the longest existing prefix of `path`. */ -function canonical(path) { - const missing = []; - let current = resolve(path); - while (!existsSync(current)) { - const parent = dirname(current); - if (parent === current) break; - missing.unshift(basename(current)); - current = parent; - } - return join(realpathSync(current), ...missing); -} - -function findPackageDirectory() { - const entry = process.argv[1]; - if (!entry) return null; - let current = dirname(resolve(entry)); - for (;;) { - if (existsSync(join(current, 'plugin.json'))) return current; - const parent = dirname(current); - if (parent === current) return null; - current = parent; - } -} diff --git a/sdk/node/test/backend.test.mjs b/sdk/node/test/backend.test.mjs deleted file mode 100644 index 937a680c33..0000000000 --- a/sdk/node/test/backend.test.mjs +++ /dev/null @@ -1,322 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { test } from 'node:test'; -import { createBackend, isFunctionId, PluginError, PluginSdkError } from '../src/index.mjs'; -import { gate, TestHost, withTimeout } from './harness.mjs'; - -const fixture = JSON.parse( - readFileSync(new URL('../../../shared/contracts/plugins/v1/fixtures/function-protocol.json', import.meta.url), 'utf8'), -); - -function functionBackend() { - return createBackend({ name: 'test-backend', version: '1.0.0' }) - .function('snapshot.refresh', (params) => ({ echoed: params })) - .function('install.prepare', () => undefined); -} - -test('initialize answers the protocols the host offered, with sorted functions', async () => { - const backend = functionBackend(); - backend.build.model(() => ({ projects: [] })); - const host = new TestHost(backend); - const { result } = await host.initialize(); - assert.deepEqual(result.capabilities.experimental, { - litheBuild: { version: 1 }, - litheFunctions: { version: 1, functions: [{ id: 'install.prepare' }, { id: 'snapshot.refresh' }] }, - }); - assert.deepEqual(result.serverInfo, { name: 'test-backend', version: '1.0.0' }); - assert.deepEqual(backend.negotiated, { functions: true, build: true }); - assert.equal(await host.close(), 1); -}); - -test('a host that offers nothing gets no Lithe capabilities, and a backend without build handlers never claims litheBuild', async () => { - const quiet = new TestHost(functionBackend()); - assert.deepEqual((await quiet.initialize({})).result.capabilities.experimental, {}); - await quiet.close(); - - const other = new TestHost(functionBackend()); - const answer = (await other.initialize({ litheBuild: { versions: [1] }, litheFunctions: { versions: [2] } })).result; - assert.deepEqual(answer.capabilities.experimental, {}); - await other.close(); -}); - -test('function IDs follow the shared contract, including the fixture cases', () => { - for (const id of ['snapshot.refresh', 'install.prepare', 'a', 'x1.y2Z']) assert.ok(isFunctionId(id), id); - for (const id of ['', 'Not-Valid', 'snapshot..refresh', '1abc', 'a_b', 'é', 'a'.repeat(129)]) { - assert.ok(!isFunctionId(id), id); - } - // Every ID the fixture accepts must also be accepted locally, and the - // fixture's malformed ID refused, so the SDK never declares what Core rejects. - for (const testCase of fixture.negotiation) { - const ids = testCase.capabilities.experimental?.litheFunctions?.functions?.map(({ id }) => id) ?? []; - if (testCase.name.includes('malformed')) { - assert.ok(ids.some((id) => !isFunctionId(id)), testCase.name); - } else if (testCase.expected.functions) { - assert.ok(ids.every(isFunctionId), testCase.name); - } - } - assert.throws(() => createBackend().function('Bad-ID', () => {}), (error) => error.code === 'sdk.function.invalidID'); - assert.throws( - () => createBackend().function('a', () => {}).function('a', () => {}), - (error) => error.code === 'sdk.function.duplicate', - ); -}); - -test('the lifecycle and the lithe namespace stay reserved for the SDK', async () => { - const backend = createBackend(); - for (const method of ['initialize', 'shutdown', 'exit', '$/cancelRequest', 'lithe/build/model', 'lithe/anything']) { - assert.throws(() => backend.onRequest(method, () => {}), PluginSdkError, method); - } - assert.throws( - () => createBackend({ capabilities: { experimental: { litheFunctions: { version: 1 } } } }), - (error) => error.code === 'sdk.capabilities.reserved', - ); - const host = new TestHost(backend); - await host.initialize(); - // A backend never asks the host to dispatch a domain operation or a function. - for (const method of ['lithe/function/invoke', 'lithe/build/model', 'lithe/host/process.run']) { - assert.throws(() => backend.sendRequest(method, {}), (error) => error.code === 'sdk.method.reserved', method); - } - await host.close(); -}); - -test('requests before initialize are refused with ServerNotInitialized', async () => { - const host = new TestHost(functionBackend()); - const response = await host.request('lithe/function/invoke', { functionID: 'snapshot.refresh', operationID: 'op', params: {} }); - assert.equal(response.error.code, -32002); - await host.close(); -}); - -test('an invocation returns its result; undefined becomes null', async () => { - const host = new TestHost(functionBackend()); - await host.initialize(); - const response = await host.request('lithe/function/invoke', { - functionID: 'snapshot.refresh', - operationID: 'op-1', - params: { project: 'app' }, - }); - assert.deepEqual(response.result, { echoed: { project: 'app' } }); - const empty = await host.request('lithe/function/invoke', { functionID: 'install.prepare', operationID: 'op-2' }); - assert.equal(empty.result, null); - const unknown = await host.request('lithe/function/invoke', { functionID: 'missing', operationID: 'op-3' }); - assert.equal(unknown.error.code, -32602); - await host.close(); -}); - -test('progress echoes the operation ID, is bounded, and stops once the call ended', async () => { - const release = gate(); - let late; - const backend = createBackend().function('long.task', async (_params, context) => { - context.progress({ message: 'x'.repeat(2000), percentage: 140.4 }); - context.progress({ message: 'half', percentage: 49.6 }); - late = context.progress; - await release.opened; - return 'done'; - }); - const host = new TestHost(backend); - await host.initialize(); - const response = host.request('lithe/function/invoke', { functionID: 'long.task', operationID: 'wire-7' }); - const first = await host.waitFor((message) => message.method === 'lithe/function/progress'); - assert.equal(first.params.operationID, 'wire-7'); - assert.equal(first.params.message.length, 1024); - assert.equal(first.params.percentage, 100); - const second = await host.waitFor((message) => message.params?.message === 'half'); - assert.equal(second.params.percentage, 50); - release.open(); - assert.equal((await response).result, 'done'); - late({ message: 'too late' }); - await host.close(); - assert.ok(!host.messages.some((message) => message.params?.message === 'too late')); -}); - -test('a PluginError carries its code; other failures hide their details', async () => { - const backend = createBackend() - .function('locked', () => { - throw new PluginError('maven.snapshotLocked', 'Snapshot is locked.'); - }) - .function('crash', () => { - throw new Error('ENOENT: no such file, open /Users/someone/secret'); - }); - const host = new TestHost(backend); - await host.initialize(); - const locked = await host.request('lithe/function/invoke', { functionID: 'locked', operationID: 'a' }); - assert.deepEqual(locked.error, { code: -32000, message: 'Snapshot is locked.', data: { code: 'maven.snapshotLocked' } }); - const crash = await host.request('lithe/function/invoke', { functionID: 'crash', operationID: 'b' }); - assert.equal(crash.error.code, -32603); - assert.ok(!crash.error.message.includes('/Users/')); - assert.equal(crash.error.data, undefined); - assert.throws(() => new PluginError('bad code!', 'x'), TypeError); - await host.close(); -}); - -test('$/cancelRequest aborts the handler and answers RequestCancelled', async () => { - const started = gate(); - let observed; - const backend = createBackend().function('wait.forever', (_params, { signal }) => { - started.open(); - return new Promise((_, reject) => { - signal.addEventListener('abort', () => { - observed = true; - reject(signal.reason); - }); - }); - }); - const host = new TestHost(backend); - await host.initialize(); - const response = host.request('lithe/function/invoke', { functionID: 'wait.forever', operationID: 'c' }, 41); - await withTimeout(started.opened, 'the handler to start'); - host.notify('$/cancelRequest', { id: 41 }); - assert.equal((await response).error.code, -32800); - assert.ok(observed); - await host.close(); -}); - -test('registerFunction and unregisterFunction go through the host, all or nothing', async () => { - const backend = createBackend(); - const host = new TestHost(backend); - await host.initialize(); - - const registering = backend.registerFunction('late.one', () => 'late'); - const register = await host.waitFor((message) => message.method === 'lithe/function/register'); - assert.deepEqual(register.params, { functions: [{ id: 'late.one' }] }); - host.send({ id: register.id, result: null }); - await registering; - const call = await host.request('lithe/function/invoke', { functionID: 'late.one', operationID: 'd' }); - assert.equal(call.result, 'late'); - - // A refused registration leaves no handler behind. - const refused = backend.registerFunction('other.one', () => 0); - const second = await host.waitFor((message) => message.params?.functions?.[0]?.id === 'other.one'); - host.send({ id: second.id, error: { code: -32602, message: 'No.', data: { code: 'plugin.function.invalidRegistration' } } }); - await assert.rejects(refused, (error) => error.code === 'plugin.function.invalidRegistration'); - assert.deepEqual(backend.functionIDs, ['late.one']); - - const unregistering = backend.unregisterFunction('late.one'); - const unregister = await host.waitFor((message) => message.method === 'lithe/function/unregister'); - assert.deepEqual(unregister.params, { ids: ['late.one'] }); - host.send({ id: unregister.id, result: null }); - await unregistering; - assert.deepEqual(backend.functionIDs, []); - await host.close(); -}); - -test('build handlers get the canonical format stamped, and a wrong format fails', async () => { - const backend = createBackend(); - backend.build.model(({ root }) => ({ projects: [{ name: 'app', path: root }] })); - backend.build.dependencies(() => ({ format: 'litheBuildModel', projects: [] })); - const host = new TestHost(backend); - await host.initialize(); - const model = await host.request('lithe/build/model', { buildSystemID: 'sample', root: '.' }); - assert.deepEqual(model.result, { format: 'litheBuildModel', version: 1, projects: [{ name: 'app', path: '.' }] }); - const dependencies = await host.request('lithe/build/dependencies', { buildSystemID: 'sample', root: '.' }); - assert.equal(dependencies.error.code, -32603); - await host.close(); -}); - -test('a build result cannot replace the version the SDK stamps', async () => { - const backend = createBackend(); - backend.build.model(() => ({ version: 2, projects: [] })); - backend.build.dependencies(() => ({ version: 1, dependencies: [] })); - const host = new TestHost(backend); - await host.initialize(); - const model = await host.request('lithe/build/model', { buildSystemID: 'sample', root: '.' }); - assert.equal(model.error.code, -32603); - const dependencies = await host.request('lithe/build/dependencies', { buildSystemID: 'sample', root: '.' }); - assert.deepEqual(dependencies.result, { format: 'litheDependencies', version: 1, dependencies: [] }); - await host.close(); -}); - -test('a result JSON cannot encode, or one over 1 MiB, fails the call and the session continues', async () => { - const cycle = {}; - cycle.self = cycle; - const backend = createBackend() - .function('result.bigint', () => 1n) - .function('result.cycle', () => cycle) - .function('result.symbol', () => Symbol('value')) - .function('result.large', () => 'x'.repeat(1 << 20)) - .function('result.fine', () => ({ ok: true })); - backend.build.model(() => ({ projects: [{ name: 'app', path: '.', size: 1n }] })); - const host = new TestHost(backend); - await host.initialize(); - let operation = 0; - for (const functionID of ['result.bigint', 'result.cycle', 'result.symbol', 'result.large']) { - const response = await host.request('lithe/function/invoke', { functionID, operationID: `op-${operation++}` }); - assert.equal(response.error?.code, -32603, functionID); - } - const model = await host.request('lithe/build/model', { buildSystemID: 'sample', root: '.' }); - assert.equal(model.error?.code, -32603); - const fine = await host.request('lithe/function/invoke', { functionID: 'result.fine', operationID: 'op-last' }); - assert.deepEqual(fine.result, { ok: true }); - assert.equal(await host.close(), 1); -}); - -test('build requests are refused when the host did not negotiate litheBuild', async () => { - const backend = createBackend(); - backend.build.model(() => ({ projects: [] })); - const host = new TestHost(backend); - await host.initialize({ litheFunctions: { versions: [1] } }); - const model = await host.request('lithe/build/model', { buildSystemID: 'sample', root: '.' }); - assert.equal(model.error.code, -32601); - await host.close(); -}); - -test('shutdown cancels running calls and runs hooks; exit after shutdown is code 0', async () => { - const started = gate(); - const order = []; - const backend = createBackend() - .function('wait.forever', (_params, { signal }) => { - started.open(); - return new Promise((_, reject) => signal.addEventListener('abort', () => reject(signal.reason))); - }) - .onShutdown(() => order.push('hook')); - const host = new TestHost(backend); - await host.initialize(); - const call = host.request('lithe/function/invoke', { functionID: 'wait.forever', operationID: 'e' }); - await withTimeout(started.opened, 'the handler to start'); - const shutdown = await host.request('shutdown'); - assert.equal(shutdown.result, null); - assert.equal((await call).error.code, -32800); - assert.deepEqual(order, ['hook']); - const after = await host.request('lithe/function/invoke', { functionID: 'wait.forever', operationID: 'f' }); - assert.equal(after.error.code, -32600); - host.notify('exit'); - assert.equal(await withTimeout(host.done, 'exit'), 0); -}); - -test('exit without shutdown, or a closed input, ends with code 1', async () => { - const host = new TestHost(createBackend()); - await host.initialize(); - host.notify('exit'); - assert.equal(await withTimeout(host.done, 'exit'), 1); - assert.equal(await new TestHost(createBackend()).close(), 1); -}); - -test('a malformed body is answered with a parse error and the session continues', async () => { - const host = new TestHost(functionBackend()); - const body = Buffer.from('{not json'); - host.input.write(Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`), body])); - const parseError = await host.waitFor((message) => message.error?.code === -32700); - assert.equal(parseError.id, null); - assert.ok((await host.initialize()).result); - await host.close(); -}); - -test('registering from an initialize hook joins the initial list without a request', async () => { - const backend = createBackend().function('a.one', () => 1); - backend.onInitialize(() => { - void backend.registerFunction('b.two', () => 2); - void backend.unregisterFunction('a.one'); - }); - const host = new TestHost(backend); - const { result } = await host.initialize(); - assert.deepEqual(result.capabilities.experimental.litheFunctions.functions, [{ id: 'b.two' }]); - assert.ok(!host.messages.some((message) => message.method?.startsWith('lithe/function/'))); - await host.close(); -}); - -test('runtime registration needs the negotiated protocol', async () => { - const backend = createBackend(); - const host = new TestHost(backend); - await host.initialize({}); - await assert.rejects(backend.registerFunction('late.one', () => 1), (error) => error.code === 'sdk.functions.unavailable'); - await host.close(); -}); diff --git a/sdk/node/test/harness.mjs b/sdk/node/test/harness.mjs deleted file mode 100644 index 4988ed5d7a..0000000000 --- a/sdk/node/test/harness.mjs +++ /dev/null @@ -1,106 +0,0 @@ -// A host for SDK tests: it writes framed messages to a backend, in memory or -// in a child process, and decodes the answers with a bounded wait on each. - -import { ChildProcess } from 'node:child_process'; -import { PassThrough } from 'node:stream'; -import { encodeFrame, FrameDecoder } from '../src/framing.mjs'; - -/** Upper bound for any single wait; a broken backend fails fast instead of hanging. */ -export const WAIT_MILLISECONDS = 5000; - -export class TestHost { - messages = []; - #waiters = []; - #nextID = 1; - exitCode; - - /** Hosts `backend` in memory, or talks to a backend child process over its stdio as Lithe does. */ - constructor(backend) { - if (backend instanceof ChildProcess) { - this.input = backend.stdin; - this.output = backend.stdout; - this.errorOutput = backend.stderr; - this.done = new Promise((resolve) => backend.once('exit', (code) => resolve(code))); - } else { - this.input = new PassThrough(); - this.output = new PassThrough(); - this.errorOutput = new PassThrough(); - this.done = backend.listen({ input: this.input, output: this.output, errorOutput: this.errorOutput, exit: null }); - } - const decoder = new FrameDecoder(); - this.output.on('data', (chunk) => { - for (const message of decoder.push(chunk)) { - this.messages.push(message); - this.#waiters = this.#waiters.filter((waiter) => !waiter(message)); - } - }); - this.errorOutput.resume(); - } - - send(message) { - this.input.write(encodeFrame({ jsonrpc: '2.0', ...message })); - } - - /** Sends a request and resolves with the backend's response. */ - request(method, params, id = this.#nextID++) { - this.send({ id, method, params }); - return this.waitFor((message) => message.id === id && message.method === undefined, `response to ${method}`); - } - - notify(method, params) { - this.send({ method, params }); - } - - async initialize( - experimental = { litheBuild: { versions: [1] }, litheFunctions: { versions: [1] } }, - rootUri = 'file:///workspace', - ) { - const response = await this.request('initialize', { - processId: null, - rootUri, - capabilities: { experimental }, - }); - this.notify('initialized', {}); - return response; - } - - /** Resolves with the first message, past or future, that `matches`. */ - waitFor(matches, description = 'message') { - const existing = this.messages.find(matches); - if (existing) return Promise.resolve(existing); - return new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error(`Timed out waiting for ${description}.`)), WAIT_MILLISECONDS); - this.#waiters.push((message) => { - if (!matches(message)) return false; - clearTimeout(timer); - resolve(message); - return true; - }); - }); - } - - /** Ends the session and resolves with the backend's exit code. */ - async close() { - this.input.end(); - return withTimeout(this.done, 'the backend to exit'); - } -} - -export function withTimeout(promise, description) { - let timer; - return Promise.race([ - promise, - new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error(`Timed out waiting for ${description}.`)), WAIT_MILLISECONDS); - }), - ]).finally(() => clearTimeout(timer)); -} - -/** A promise with its resolver, for handlers a test releases explicitly. */ -export function gate() { - let open; - const opened = new Promise((resolve) => { - open = resolve; - }); - return { open, opened }; -} diff --git a/sdk/node/test/sample-build.test.mjs b/sdk/node/test/sample-build.test.mjs deleted file mode 100644 index cc66be2c29..0000000000 --- a/sdk/node/test/sample-build.test.mjs +++ /dev/null @@ -1,64 +0,0 @@ -import assert from 'node:assert/strict'; -import { spawn } from 'node:child_process'; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { test } from 'node:test'; -import { fileURLToPath, pathToFileURL } from 'node:url'; -import { TestHost } from './harness.mjs'; - -const sampleBackend = fileURLToPath(new URL('../examples/sample-build/backend/main.mjs', import.meta.url)); - -/** Starts the sample backend on a workspace with two quick projects, as Lithe would. */ -async function startSample(t) { - const root = mkdtempSync(join(tmpdir(), 'lithe-sample-build-')); - const workspace = join(root, 'workspace'); - const state = join(root, 'state'); - const quick = ['node', '-e', 'process.exit(0)']; - for (const name of ['app', 'lib']) mkdirSync(join(workspace, name), { recursive: true }); - mkdirSync(state); - writeFileSync( - join(workspace, 'lithe-sample.json'), - JSON.stringify({ projects: [{ name: 'app', path: 'app', build: quick }, { name: 'lib', path: 'lib', build: quick }] }), - ); - const child = spawn(process.execPath, [sampleBackend, '--state', state], { stdio: 'pipe' }); - t.after(() => { - child.kill(); - rmSync(root, { recursive: true, force: true }); - }); - const host = new TestHost(child); - await host.initialize(undefined, pathToFileURL(workspace).href); - return host; -} - -function build(host, operationID, params) { - return host.request('lithe/function/invoke', { functionID: 'workspace.build', operationID, params }); -} - -test('overlapping workspace builds record consecutive snapshot generations', async (t) => { - const host = await startSample(t); - // Both calls are in flight before either tool finishes. - const [first, second] = await Promise.all([build(host, 'op-1', {}), build(host, 'op-2', {})]); - assert.deepEqual([first.result.generation, second.result.generation].sort(), [1, 2]); - const latest = await host.request('lithe/function/invoke', { functionID: 'snapshot.read', operationID: 'op-3' }); - assert.equal(latest.result.generation, 2); - await host.close(); -}); - -test('a selection naming an unknown project fails instead of building nothing', async (t) => { - const host = await startSample(t); - for (const [operationID, projects, code] of [ - ['op-1', ['typo'], 'sample.unknownProject'], - ['op-2', ['app', 'typo'], 'sample.unknownProject'], - ['op-3', [], 'sample.selection'], - ['op-4', 'app', 'sample.selection'], - ]) { - const response = await build(host, operationID, { projects }); - assert.equal(response.error?.data?.code, code, JSON.stringify(projects)); - } - const snapshot = await host.request('lithe/function/invoke', { functionID: 'snapshot.read', operationID: 'op-5' }); - assert.equal(snapshot.result, null); - const selected = await build(host, 'op-6', { projects: ['lib'] }); - assert.deepEqual(selected.result.projects, [{ name: 'lib', exitCode: 0 }]); - await host.close(); -}); diff --git a/sdk/node/test/support.test.mjs b/sdk/node/test/support.test.mjs deleted file mode 100644 index efc52820f2..0000000000 --- a/sdk/node/test/support.test.mjs +++ /dev/null @@ -1,321 +0,0 @@ -import assert from 'node:assert/strict'; -import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { test } from 'node:test'; -import { encodeFrame, FrameDecoder, FramingError } from '../src/framing.mjs'; -import { createBackend, openStateStore, runProcess } from '../src/index.mjs'; -import { runningProcessCount } from '../src/process.mjs'; -import { gate, TestHost, WAIT_MILLISECONDS, withTimeout } from './harness.mjs'; - -function temporaryDirectory(t) { - const directory = mkdtempSync(join(tmpdir(), 'lithe-sdk-')); - t.after(() => rmSync(directory, { recursive: true, force: true })); - return directory; -} - -/** Bounded poll of an OS-level fact the test cannot be notified about. */ -async function eventually(check, description) { - const deadline = Date.now() + WAIT_MILLISECONDS; - while (!check()) { - if (Date.now() > deadline) throw new Error(`Timed out waiting for ${description}.`); - await new Promise((resolve) => setTimeout(resolve, 10)); - } -} - -function isAlive(pid) { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - -test('frames split across chunks, or several in one chunk, decode in order', () => { - const decoder = new FrameDecoder(); - const bytes = Buffer.concat([encodeFrame({ id: 1, text: 'é✓' }), encodeFrame({ id: 2 })]); - const messages = []; - for (let index = 0; index < bytes.length; index += 3) { - messages.push(...decoder.push(bytes.subarray(index, index + 3))); - } - assert.deepEqual(messages, [{ id: 1, text: 'é✓' }, { id: 2 }]); - assert.throws(() => new FrameDecoder().push(Buffer.from('Content-Length: 999999999999\r\n\r\n')), FramingError); - assert.throws(() => new FrameDecoder().push(Buffer.from('X-Other: 1\r\n\r\n')), FramingError); -}); - -test('runProcess captures output and resolves a non-zero exit', async () => { - const result = await runProcess(process.execPath, ['-e', 'process.stdout.write("out"); process.stderr.write("err"); process.exit(3)']); - assert.deepEqual( - { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr, truncated: result.truncated }, - { exitCode: 3, stdout: 'out', stderr: 'err', truncated: false }, - ); - const limited = await runProcess(process.execPath, ['-e', 'process.stdout.write("x".repeat(100))'], { maxOutputBytes: 10 }); - assert.equal(limited.stdout, 'xxxxxxxxxx'); - assert.ok(limited.truncated); -}); - -test('cancelling a call ends the process tree it started', { skip: process.platform === 'win32' && 'POSIX process groups' }, async (t) => { - const directory = temporaryDirectory(t); - const grandchildPid = join(directory, 'grandchild.pid'); - // The child starts a grandchild that ignores SIGTERM, so only the group's - // forced stop can end it. - const grandchild = `process.on("SIGTERM", () => {}); require("fs").writeFileSync(${JSON.stringify(grandchildPid)}, String(process.pid)); setInterval(() => {}, 1000);`; - const child = `require("child_process").spawn(process.execPath, ["-e", ${JSON.stringify(grandchild)}], { stdio: "ignore" }); setInterval(() => {}, 1000);`; - const started = gate(); - const backend = createBackend().function('tool.run', async (_params, context) => { - const running = context.run(process.execPath, ['-e', child], { killGraceMilliseconds: 50 }); - started.open(); - return running; - }); - const host = new TestHost(backend); - await host.initialize(); - const response = host.request('lithe/function/invoke', { functionID: 'tool.run', operationID: 'g' }, 7); - await withTimeout(started.opened, 'the tool to start'); - await eventually(() => existsSync(grandchildPid) && readFileSync(grandchildPid, 'utf8').length > 0, 'the grandchild'); - const pid = Number(readFileSync(grandchildPid, 'utf8')); - host.notify('$/cancelRequest', { id: 7 }); - assert.equal((await response).error.code, -32800); - await eventually(() => !isAlive(pid), 'the grandchild to be stopped'); - await eventually(() => runningProcessCount() === 0, 'the registry to drain'); - await host.close(); -}); - -test('closing the session ends tools still running', { skip: process.platform === 'win32' && 'POSIX process groups' }, async (t) => { - const directory = temporaryDirectory(t); - const childPid = join(directory, 'child.pid'); - const started = gate(); - const backend = createBackend().function('tool.run', (_params, context) => { - const running = context.run(process.execPath, [ - '-e', - `process.on("SIGTERM", () => {}); require("fs").writeFileSync(${JSON.stringify(childPid)}, String(process.pid)); setInterval(() => {}, 1000);`, - ]); - started.open(); - return running; - }); - const host = new TestHost(backend); - await host.initialize(); - // No reply is awaited: the session ends while the call is still running. - host.send({ id: 8, method: 'lithe/function/invoke', params: { functionID: 'tool.run', operationID: 'h' } }); - await withTimeout(started.opened, 'the tool to start'); - await eventually(() => existsSync(childPid) && readFileSync(childPid, 'utf8').length > 0, 'the child'); - const pid = Number(readFileSync(childPid, 'utf8')); - assert.equal(await host.close(), 1); - await eventually(() => !isAlive(pid), 'the child to be killed'); -}); - -test('the state store writes atomically and survives reopening', (t) => { - const directory = join(temporaryDirectory(t), 'workspace-state'); - const store = openStateStore(directory, { packageDirectory: null }); - assert.equal(store.read('snapshot', 'none'), 'none'); - store.write('snapshot', { generation: 1 }); - store.write('snapshot', { generation: 2 }); - assert.deepEqual(openStateStore(directory, { packageDirectory: null }).read('snapshot'), { generation: 2 }); - assert.deepEqual(readdirSync(directory), ['snapshot.json']); - writeFileSync(join(directory, 'broken.json'), '{'); - assert.equal(store.read('broken', null), null); - store.delete('snapshot'); - store.delete('snapshot'); - assert.equal(store.read('snapshot'), undefined); - for (const key of ['', '../escape', 'a/b', 'x.tmp', '.hidden']) { - assert.throws(() => store.write(key, 1), (error) => error.code === 'sdk.state.invalidKey', key); - } -}); - -test('the state store refuses the read-only plugin package', (t) => { - const root = temporaryDirectory(t); - const packageDirectory = join(root, 'package'); - mkdirSync(packageDirectory); - writeFileSync(join(packageDirectory, 'plugin.json'), '{}'); - assert.throws( - () => openStateStore(join(packageDirectory, 'state'), { packageDirectory }), - (error) => error.code === 'sdk.state.readOnlyPackage', - ); - // Refused before anything was created inside the package. - assert.ok(!existsSync(join(packageDirectory, 'state'))); - assert.throws(() => openStateStore('relative/state'), (error) => error.code === 'sdk.state.invalidDirectory'); - assert.ok(openStateStore(join(root, 'state'), { packageDirectory })); -}); - -test('the type declarations name exactly the runtime exports', async () => { - const runtime = Object.keys(await import('../src/index.mjs')).sort(); - const declarations = readFileSync(new URL('../src/index.d.ts', import.meta.url), 'utf8'); - const declared = [...declarations.matchAll(/^export (?:function|class|const) (\w+)/gm)].map((match) => match[1]); - assert.deepEqual([...new Set(declared)].sort(), runtime); -}); - -const posixOnly = { skip: process.platform === 'win32' && 'POSIX process groups' }; - -/** A Node one-liner that records its PID in `file` and runs until stopped. */ -function recordsPidAndWaits(file) { - return `require("fs").writeFileSync(${JSON.stringify(file)}, String(process.pid)); setInterval(() => {}, 1000);`; -} - -/** Every PID a test tool recorded, so cleanup still finds it after the temporary directory is gone. */ -const recordedPids = new Set(); - -function recordedPid(file) { - if (!existsSync(file) || readFileSync(file, 'utf8').length === 0) return undefined; - const pid = Number(readFileSync(file, 'utf8')); - recordedPids.add(pid); - return pid; -} - -/** Kills recorded tools still alive, so a failed assertion leaves nothing behind. */ -function killRecordedOnExit(t) { - t.after(() => { - for (const pid of recordedPids) { - if (isAlive(pid)) process.kill(pid, 'SIGKILL'); - } - }); -} - -test('a call that settles stops the tools its handler left running, and only those', posixOnly, async (t) => { - const directory = temporaryDirectory(t); - const failedSibling = join(directory, 'failed-sibling.pid'); - const floating = join(directory, 'floating.pid'); - const other = join(directory, 'other.pid'); - killRecordedOnExit(t); - const startTool = (context, file) => - context.run(process.execPath, ['-e', recordsPidAndWaits(file)], { killGraceMilliseconds: 50 }); - const backend = createBackend() - .function('parallel.fail', async (_params, context) => { - const slow = startTool(context, failedSibling); - await eventually(() => recordedPid(failedSibling) !== undefined, 'the sibling tool'); - await Promise.all([slow, context.run(join(directory, 'missing-tool'))]); - }) - .function('floating.succeed', async (_params, context) => { - startTool(context, floating); - await eventually(() => recordedPid(floating) !== undefined, 'the floating tool'); - return 'done'; - }) - .function('other.run', (_params, context) => startTool(context, other)); - const host = new TestHost(backend); - await host.initialize(); - const otherCall = host.request('lithe/function/invoke', { functionID: 'other.run', operationID: 'o' }, 40); - await eventually(() => recordedPid(other) !== undefined, 'the other call’s tool'); - - const failed = await host.request('lithe/function/invoke', { functionID: 'parallel.fail', operationID: 'f' }); - assert.equal(failed.error?.code, -32603); - await eventually(() => !isAlive(recordedPid(failedSibling)), 'the failed call’s sibling tool to stop'); - const succeeded = await host.request('lithe/function/invoke', { functionID: 'floating.succeed', operationID: 's' }); - assert.equal(succeeded.result, 'done'); - await eventually(() => !isAlive(recordedPid(floating)), 'the unawaited tool to stop'); - - // Another call's tool was not touched; it ends with its own call. - assert.ok(isAlive(recordedPid(other))); - host.notify('$/cancelRequest', { id: 40 }); - assert.equal((await otherCall).error.code, -32800); - await eventually(() => runningProcessCount() === 0, 'the registry to drain'); - assert.equal(await host.close(), 1); -}); - -test('a tool that exits normally also ends the workers it left in its group', posixOnly, async (t) => { - const directory = temporaryDirectory(t); - const worker = join(directory, 'worker.pid'); - killRecordedOnExit(t); - // The leader starts a background worker in its own group, waits until it - // runs, and exits 0 without stopping it. - const leader = ` - const { existsSync, readFileSync } = require("fs"); - const child = require("child_process").spawn(process.execPath, ["-e", ${JSON.stringify(recordsPidAndWaits(worker))}], { stdio: "ignore" }); - child.unref(); - const deadline = Date.now() + 5000; - const wait = () => existsSync(${JSON.stringify(worker)}) && readFileSync(${JSON.stringify(worker)}, "utf8") ? process.exit(0) - : Date.now() > deadline ? process.exit(2) : setTimeout(wait, 10); - wait();`; - const result = await runProcess(process.execPath, ['-e', leader]); - assert.equal(result.exitCode, 0); - assert.equal(runningProcessCount(), 0); - await eventually(() => !isAlive(recordedPid(worker)), 'the worker to stop'); -}); - - -test('streamed output keeps a character split across chunks whole', async (t) => { - const directory = temporaryDirectory(t); - const released = join(directory, 'released'); - // The child writes "a" and the first byte of "中", waits until the parent - // saw that chunk, then writes the other two bytes, so "中" spans two chunks. - const child = ` - const { existsSync } = require("fs"); - process.stdout.write(Buffer.from([0x61, 0xe4])); - const deadline = Date.now() + 5000; - const wait = () => existsSync(${JSON.stringify(released)}) ? process.stdout.write(Buffer.from([0xb8, 0xad])) - : Date.now() > deadline ? process.exit(2) : setTimeout(wait, 10); - wait();`; - const streamed = []; - const result = await runProcess(process.execPath, ['-e', child], { - onStdout: (text) => { - streamed.push(text); - writeFileSync(released, ''); - }, - }); - assert.equal(result.exitCode, 0); - assert.equal(result.stdout, 'a中'); - assert.deepEqual(streamed, ['a', '中']); -}); - -test('the state store refuses a value JSON would drop and keeps the stored one', (t) => { - const store = openStateStore(join(temporaryDirectory(t), 'state'), { packageDirectory: null }); - store.write('snapshot', { generation: 8 }); - for (const value of [undefined, () => 1, Symbol('value'), 1n]) { - assert.throws(() => store.write('snapshot', value), (error) => error.code === 'sdk.state.invalidValue' || error instanceof TypeError); - } - assert.deepEqual(store.read('snapshot'), { generation: 8 }); -}); - -test('a header over 8 KiB is refused also when the whole frame arrives at once', () => { - const header = `Content-Length: 2\r\nX-Padding: ${'x'.repeat(9000)}\r\n\r\n{}`; - assert.throws(() => new FrameDecoder().push(Buffer.from(header)), FramingError); -}); - -test('a JSON value that is not a message object is refused and the session continues', async () => { - const host = new TestHost(createBackend().function('ping', () => 'pong')); - await host.initialize(); - host.input.write(encodeFrame(null)); - const refused = await host.waitFor((message) => message.id === null && message.error, 'the refusal'); - assert.equal(refused.error.code, -32600); - const answered = await host.request('lithe/function/invoke', { functionID: 'ping', operationID: 'p' }); - assert.equal(answered.result, 'pong'); - await host.close(); -}); - -test('a shutdown during a slow initialize hook keeps the session closed', async () => { - const hook = gate(); - const backend = createBackend() - .function('ping', () => 'pong') - .onInitialize(() => hook.opened); - const host = new TestHost(backend); - const initializing = host.request('initialize', { - processId: null, - rootUri: 'file:///workspace', - capabilities: { experimental: { litheFunctions: { versions: [1] } } }, - }); - const early = await host.request('lithe/function/invoke', { functionID: 'ping', operationID: 'early' }); - assert.equal(early.error.code, -32002); - assert.equal((await host.request('shutdown')).result, null); - hook.open(); - assert.equal((await initializing).error?.code, -32600); - const late = await host.request('lithe/function/invoke', { functionID: 'ping', operationID: 'late' }); - assert.equal(late.error.code, -32600); - host.notify('exit'); - assert.equal(await withTimeout(host.done, 'the backend to exit'), 0); -}); - -test('a replaced environment still carries the host process markers', async (t) => { - const saved = { scope: process.env.LITHE_PROCESS_SCOPE, backend: process.env.LITHE_PLUGIN_BACKEND_SCOPE }; - t.after(() => { - for (const [name, value] of [['LITHE_PROCESS_SCOPE', saved.scope], ['LITHE_PLUGIN_BACKEND_SCOPE', saved.backend]]) { - if (value === undefined) delete process.env[name]; - else process.env[name] = value; - } - }); - process.env.LITHE_PROCESS_SCOPE = 'instance-1'; - process.env.LITHE_PLUGIN_BACKEND_SCOPE = 'backend-1'; - const print = 'process.stdout.write(JSON.stringify([process.env.LITHE_PROCESS_SCOPE, process.env.LITHE_PLUGIN_BACKEND_SCOPE, process.env.PLUGIN_VALUE]))'; - // A plugin can neither drop nor forge the markers. - const result = await runProcess(process.execPath, ['-e', print], { - env: { PLUGIN_VALUE: 'kept', LITHE_PLUGIN_BACKEND_SCOPE: 'forged' }, - }); - assert.deepEqual(JSON.parse(result.stdout), ['instance-1', 'backend-1', 'kept']); -}); diff --git a/shared/contracts/application-boundary.md b/shared/contracts/application-boundary.md index ca5be39943..d4b37cf69c 100644 --- a/shared/contracts/application-boundary.md +++ b/shared/contracts/application-boundary.md @@ -18,8 +18,45 @@ verification scripts are the executable source of boundary checks. - Every asynchronous operation exposes `idle`, `loading`, `ready`, and `failed` outcomes. - Failures contain a stable `code` and user-facing `message`; platform details belong in `details`. +## Shared built-in translations + +[Shared i18n](../i18n/README.md) owns migrated semantic keys, typed named parameters, +locale registration, platform scopes and documented platform wording overrides. +Build-time generation supplies native macOS string tables and the Windows catalog; +no runtime JSON loading, package download or write to installed resources occurs. +The locale registry preserves each platform's persisted identifiers and default +language. Explicit fallback chains resolve shared entries during generation; +all existing platform catalogs and presentation-owned bilingual copy use this source. Translation coverage is not a +capability claim. [Format fixtures](../fixtures/i18n/format-v1.json) bind generation +to native positional formatting, literal percent signs and unmodified argument data. + ## Feature Contracts +Global Agent instructions edit each upstream Agent's native user file, independently +of the current project and provider. Opening settings is read-only. Codex uses +`CODEX_HOME` (otherwise the user home `.codex`) and its first non-empty +`AGENTS.override.md` / `AGENTS.md`; Claude uses `CLAUDE_CONFIG_DIR` (otherwise +the user home `.claude`) and `CLAUDE.md`. The host resolves these locations; +UI supplies no arbitrary read/write destination. Absolute native paths in snapshots +are display and optimistic-write guards, not portable identifiers. A save checks +the snapshot's path and revision, replaces the file atomically, and reads the +effective source back. Failures retain the draft; reloading explicitly discards it +only after a successful read. Drafts stay separate by Agent. Updated instructions +load in a new native conversation; existing sessions are not rewritten or stopped. +Project instructions retain the upstream Agent's precedence rules. See +`fixtures/agent/agent-instructions-v1.json`. + +A user can also author one shared instruction text in platform-owned application +support storage and opt each supported Agent into a delimited block in its native +file. Synchronization preserves every byte outside that block. Turning a switch +off removes only that block. Enabled, unknown and out-of-date states come from +native snapshots, not a stored preference. Source saves and each target replacement +are atomic individually, not a cross-file transaction: per-Agent conflicts remain +visible and retryable while the saved shared draft and successful targets survive. +No startup/background synchronization or extra prompt injection occurs. A malformed +or duplicate marker is never repaired by overwriting it automatically. See +`fixtures/agent/shared-instructions-v1.json`. + Shared Monaco highlighting is owned by the current view/model pair. A queued callback for a closed or replaced model must finish without running against the new model or locking surviving documents. Primary, split and diff views share @@ -756,3 +793,19 @@ JDT-owned workspace build paths and the existing platform-owned JDT state. - 固定 codex-acp 的 `session_info_update._meta.codex.error.willRetry == true` 和非空 `turnId` 是 macOS 重试显示的证据;该通知可能没有 `title`。共享样例 `codexRetry` 由官方 ACP SDK 往返验证,Host 继续透传元数据,不新增请求/事件种类或网络重试策略。 - 状态隔离到会话,新进度清除重试显示,完成/失败/断连清理本轮状态,正在停止时不因迟到进度回退;不得显示未经脱敏的原始错误或从文本猜测重试次数。请求仍等待上游完成/取消确认,既有十分钟 prompt 上限和十秒取消上限不变。 - 这次交付 macOS 展示;Windows 对话 UI 的消费与真实验收仍待完成。展示状态只在内存中,不写入 bundle、安装目录或上游历史。 + +### Claude Desktop 机制卡片 + +- Host 识别 Claude Desktop 注入的 MCP 工具(`ccd_*`、`visualize`、`terminal`、`Claude_Browser`、`computer-use`、`scheduled-tasks` 与按 UUID 命名的 claude.ai 连接器),为每个调用给出可读的英文 `title` 和 `kind`;不认识的 MCP 服务原样透传。 +- 有交互含义的调用在 `tool_call` / `tool_call_update` 的 `_meta.lithe.claudeDesktop` 附加卡片,字段 `card` 决定类型:`chapter {title, summary?}`、`nextStep {actions: [{label, message}] ≤4}`、`task {title, summary?, prompt?, state: proposed|withdrawn|started|handedOff}`、`pullRequest {action: bound|unbound|monitored|autoMerge, url, number?, autoFix?, enabled?}`、`directory {action: changed|requested, path}`、`widget {title, code ≤256 KiB 字符}`。文本字段截断到 16 KiB 字符;超限的 `widget.code` 置空。 +- `nextStep` 是 Host 另发的已完成调用,id 为原 `wrap_up` 调用 id 加 `#next`;任务撤回或启动的结果到达后,Host 以原卡片调用 id 补发整张卡片。后到的卡片整体替换先前的卡片;不带卡片的更新不清除卡片。 +- 客户端只在调用状态为 `completed` 时显示卡片;未知 `card` 类型、缺字段或非 http(s) 的 PR 链接退回普通工具行。卡片动作只填写输入框,不代替用户发送。可视化内容必须在禁止网络加载、非持久存储、禁止导航的隔离网页中渲染。 + + +Agent skill management groups files by Global/Project and retains their Agent +provenance. Codex-reported capabilities and Claude's partial directory inventory +are distinct; neither file presence nor a management-list row proves that the +current conversation has used a skill. Explicit create/import operations write +only to native user/project directories and preserve existing skills. Explicit toggles write native settings: Codex by discovered path via its official App Server API, Claude by name via skillOverrides in user or project-local settings. Claude states are configured visibility, not effective managed/session policy. Never persist a separate Lithe enabled flag, move packages to simulate disablement, or claim that a change revokes already loaded session content. File +contents remain untrusted document data. Settings discovery and file management +must not create conversations, send prompts, or interrupt existing Agent work. diff --git a/shared/contracts/mcp-registry-page.json b/shared/contracts/mcp-registry-page.json new file mode 100644 index 0000000000..3535d54d4a --- /dev/null +++ b/shared/contracts/mcp-registry-page.json @@ -0,0 +1,36 @@ +{ + "servers": [ + { + "server": { + "name": "io.example/documentation", + "title": "Documentation", + "description": "Example remote documentation service", + "version": "1.2.0", + "websiteUrl": "https://example.invalid/docs", + "remotes": [{"type": "streamable-http", "url": "https://example.invalid/mcp"}] + }, + "_meta": {"io.modelcontextprotocol.registry/official": {"status": "active"}} + }, + { + "server": { + "name": "io.example/browser", + "description": "Example local package", + "version": "2.0.0", + "packages": [{"registryType": "npm", "identifier": "@example/browser", "version": "2.0.0", "transport": {"type": "stdio"}}] + } + }, + { + "server": { + "name": "io.example/database", + "version": "1.0.0", + "repository": {"url": "https://example.invalid/database"}, + "packages": [{"registryType": "npm", "identifier": "@example/database", "version": "1.0.0", "transport": {"type": "stdio"}, "environmentVariables": [{"name": "API_KEY", "isRequired": true, "isSecret": true}]}] + } + }, + { + "server": {"name": "io.example/removed"}, + "_meta": {"io.modelcontextprotocol.registry/official": {"status": "deleted"}} + } + ], + "metadata": {"nextCursor": "opaque/+ cursor?"} +} diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index ba81f07cdf..3cf6a4b511 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -11,21 +11,32 @@ This directory is the cross-platform source of truth: | File | Purpose | | --- | --- | | `plugin-manifest.schema.json` | JSON shape of `plugin.json` | -| `fixtures/*.plugin.json` | Valid example manifests: language plugins (PHP, Go, Java, a discovered Node runtime), UI (`hello-lithe`), and level 3 build systems (Cargo, Go, Gradle, Maven, and two build providers) | +| `fixtures/*.plugin.json` | Valid example manifests, named after the capability they exercise rather than a published plugin: `language-*` (packaged, system-command, and host-integration language servers; `node-runtime` for a discovered runtime), `hello-lithe` and `web-panel` (UI), `settings-v2`, and `build-*` (level 3 build systems and the two forms of a build provider) | | `fixtures/invalid-manifests.json` | Edits to a valid manifest and the rejection code every host must report | | `fixtures/command-plans.json` | Golden run/test command expansions and language-server launch plans | | `fixtures/runtime-versions.json` | Golden outcomes of the launch runtime version check | | `fixtures/build-plans.json` | Golden build-system plans and decoded outputs | | `fixtures/function-protocol.json` | Golden plugin-function negotiation, registration, invocation, outcome, progress, and refusal cases | +| `fixtures/project-generator.plugin.json`, `fixtures/project-generator-templates/` | Project generators: template files, a scaffolding command, and an archive download | +| `fixtures/project-plans.json` | Golden `project.*` form evaluations, generation plans, and name checks | | `plugin-index.schema.json` | JSON shape of a marketplace source index | | `fixtures/marketplace/*.json` | Marketplace index, availability, source URL, and install review golden cases | Validation and template expansion are implemented once in Rust Core (`rust/lithe-core/src/plugins/`, with build systems in `rust/lithe-core/src/build/`) and exposed through the -`plugins.declarative.*` commands in `../../rust-core-api.md`. Hosts must not +`plugins.declarative.*` commands in [`shared/contracts/rust-core-api.md`](https://github.com/1lck/Lithe-IDEA/blob/preview/shared/contracts/rust-core-api.md). Hosts must not reimplement them. +Plugin authors do not need this repository. The public +[`Lithe-IDEA/lithe-plugin-docs`](https://github.com/Lithe-IDEA/lithe-plugin-docs) +repository holds the developer guide and a byte-identical copy of this whole +directory under `contract/v1/`, pinned to a revision of this repository; each +schema's `$id` is its URL there. Contract changes land here first and must then +be synced to that repository, so links in this file that leave this directory +are absolute. `plugin.json` may not carry a `$schema` key, because unknown +fields are rejected; editors associate the schema by file name instead. + ## Package layout ```text @@ -183,15 +194,18 @@ contains a machine path, and the host decides where state lives. | --- | --- | | `${pluginDir}` | Root of the installed, read-only package. It must be used at most once per value and be followed by a `/`-rooted path inside the package; that path may vary only by `${arch}`. | | `${stateDir}` | Writable directory the host keeps for this plugin's server and removes when the plugin is uninstalled. Not available in `validationArguments`. | -| `${workspaceStateDir}` | Writable directory for the server's state of the current workspace. Not available in `validationArguments`. | +| `${workspaceStateDir}` | Writable directory for the server's state of the current workspace. Not available in `validationArguments`, nor in a [plugin backend](#plugin-backend), which belongs to no workspace. | | `${arch}` | Host CPU architecture: `arm64` or `x86_64`. | | `${setting:}` | Current value of a declared [plugin setting](#plugin-settings-api-2). Not available in `validationArguments`, and never inside a package path. | `plugins.declarative.languageServerLaunchPlan` expands a server's launch. The -host selects the process with exactly one of `languageID` (a language server) -and `buildSystemID` (a build provider), passes `{pluginDirectory, -stateDirectory, workspaceStateDirectory, architecture, settings?}`, and -receives: +host selects the process with exactly one of `languageID` (a language server), +`buildSystemID` (a build provider), `vcsProviderID` (a VCS provider), and +`backend: true` (the [plugin backend](#plugin-backend)), passes `{pluginDirectory, +stateDirectory, workspaceStateDirectory?, architecture, settings?}`, and +receives. `workspaceStateDirectory` may be omitted only when the selected +process does not use `${workspaceStateDir}`; otherwise the request is +`plugin.launch.invalidRequest`. | Field | Meaning | | --- | --- | @@ -204,9 +218,12 @@ receives: | `requiredPackagePaths` | Sorted package-relative paths the launch points at. The host verifies each is inside the package and not a symbolic link before starting the server. | | `settings` | Effective value of every declared setting that produced this plan; absent when the plugin declares none. | | `buildProvider` | Present only for a `buildSystemID` selection: `{buildSystemID, executesProjectCode, mayUseNetwork, languageServer?}`. `languageServer` names the language whose server is the provider; the rest of the plan is then that server's plan, and the host reuses its running session instead of starting a second process. | +| `vcsProvider` | Present only for a `vcsProviderID` selection: the [provider descriptor](https://github.com/1lck/Lithe-IDEA/blob/preview/shared/contracts/vcs-provider-v1.md#provider-descriptor) the host keeps for the session. | +| `backend` | Present only for a `backend` selection: `{hostServices}`, the sorted [host services](#host-services-protocol-version-2) the plugin's permissions grant. The host passes them to `lsp.startServer.hostServices` unchanged. | -A language without a server, an undeclared build system, and a build system -without a provider are `plugin.launch.unavailable`. Core never creates or +A language without a server, an undeclared build system, a build system +without a provider, an undeclared VCS provider, and a plugin without a +backend are `plugin.launch.unavailable`. Core never creates or reads any of these paths. The command also serves API 1 manifests, whose arguments are returned unchanged. @@ -359,7 +376,7 @@ files sorted by path, or an empty list when no project marker is present. | --- | --- | | `1` | Languages, `packaged` and `systemCommand` language servers, `node` and `go` runtime dependencies, run and test providers. | | `2` | `runtime` launches with a packaged or discovered runtime, host integrations, the `java` runtime, launch templates, `initializationOptions`, and plugin settings. | -| `3` | Capability versions (`requires`), and `buildSystems`: declarative build tools with detection, options, a project model, tasks, run entries, tests, diagnostics, dependencies, and sync. | +| `3` | Capability versions (`requires`), `buildSystems`: declarative build tools with detection, options, a project model, tasks, run entries, tests, diagnostics, dependencies, and sync, and [`vcsProviders`](#api-level-3-vcs-providers). | A level 2 feature in a level 1 manifest is refused with `plugin.manifest.invalid` (`plugin.runtime.unsupported` for the `java` tool), @@ -383,12 +400,15 @@ hosts. Level 3 versions each section on its own. A level 3 manifest lists in | --- | --- | --- | | `languages` | `languages[]` | `1` | | `languageServers` | `languageServers[]` apart from their launch fields | `1`, `2` (adds [`workspaceConfiguration`](#workspace-configuration-languageservers-2)) | -| `serverProcess` | The launch fields of language servers and of build providers with a process of their own: `launch`, `arguments`, `validationArguments`, `runtimeDependencies`, `initializationOptions` | `1` | +| `serverProcess` | The launch fields of language servers, of build providers with a process of their own, of VCS providers, and of the plugin backend: `launch`, `arguments`, `validationArguments`, `runtimeDependencies`, `initializationOptions` | `1` | | `executionProviders` | `executionProviders[]` | `1` | | `settings` | `settings[]` and `settingActions[]` | `1`, `2` (adds [placement, scope, effect, page buttons](#settings-v2-placement-scope-effect-and-page-buttons)) | | `ui` | `ui` | `1`, `2` (adds [web content](#web-content-ui-2)) | | `buildSystems` | Declarative build detection, models, and command plans | `1` | | `buildProviders` | `buildSystems[].provider`, including own-process and shared language-server providers | `1` | +| `vcsProviders` | `vcsProviders[]` | `1` | +| `projectGenerators` | [`projectGenerators[]`](#project-generators): entries of the New Project window | `1` | +| `backend` | [`backend`](#plugin-backend): the plugin's own long-lived process for its pages | `1` | Version 1 of every capability means what its section means at level 2 (runtime launches, launch templates, `initializationOptions`, and so on), so @@ -409,7 +429,9 @@ moving a level 2 manifest to level 3 only adds `requires`. `buildSystems[].provider` requires `buildProviders: 1`, whether it starts its own process or shares a language server. A template-only build plugin must omit `buildProviders`. Launch support alone does not implement Provider - negotiation, requests, and lifecycle. + negotiation, requests, and lifecycle. Every VCS provider starts its own + process, so `vcsProviders` always comes with `serverProcess`, and so does + `backend`. - The base fields (`schemaVersion`, `id`, `displayName`, `version`, `vendor`, `permissions`, `activationEvents`, and `requires` itself) change only with a new `pluginAPIVersion`. @@ -434,8 +456,11 @@ The macOS host runs levels 1 to 3 and sends its capability table, including `buildSystems: [1]`, with all three commands. It does not implement build providers and does not declare `buildProviders`. Provider plugins are therefore filtered from the market and refused by declarative validation and install -review; template-only build plugins remain available. Core supports -`buildProviders: 1` for validation and plans without a host table. Windows has +review; template-only build plugins remain available. It does not declare +`vcsProviders` either, so VCS provider plugins are filtered the same way until +the macOS host starts providers. It declares `backend: [1]`: it starts +plugin backends and serves their credentials from the Keychain. Core supports `backend: 1`, `buildProviders: 1`, and +`vcsProviders: 1` for validation and plans without a host table. Windows has no declarative plugin host and sends no `hostCapabilities`, so it offers no level 3 version. @@ -464,12 +489,16 @@ Rules for publishing across levels and capability versions: | Permission | Required when | | --- | --- | -| `languageServer.launch` | `languageServers` is not empty | +| `languageServer.launch` | `languageServers` or `vcsProviders` is not empty, or a build provider starts its own process | | `process.run` | any provider declares `run` | | `process.test` | any provider declares `test` | | `process.build` | `buildSystems` is not empty | +| `process.vcs` | `vcsProviders` is not empty | +| `project.generate` | `projectGenerators` is not empty | | `ui.contribute` | `ui` is declared | | `ui.web` | a tool window shows [web content](#web-content-ui-2) | +| `backend.launch` | `backend` is declared | +| `credentials.store` | the backend uses the [credentials host service](#host-services-protocol-version-2); refused without a `backend` | | Activation event | Meaning | | --- | --- | @@ -477,6 +506,7 @@ Rules for publishing across levels and capability versions: | `onRun:` | Activate the run provider when the user runs a file of the language. | | `onTest:` | Activate the test provider when the user lists or runs tests. | | `onBuildSystem:` | Activate the build system before its first plan, model, or decode. | +| `onVcsRoot:` | Start the VCS provider once a root of its VCS is detected in the workspace. | Each provider needs its event and each event needs a provider. Enabling a plugin only permits activation; nothing starts until its event occurs. @@ -613,9 +643,11 @@ the plugin, sorted by `kind`, language, and tool: | `runtimeLanguageServer` | `languageID`, `tool`, `packagedRuntime`, `entry`, `runtimeArguments`, `arguments`, `validationArguments` | Package entry started by a runtime; `packagedRuntime` is `null` for a discovered runtime | | `buildSystem` | `buildSystemID`, `invocation`, `options`, `tasks`, `run`, `test`, `model`, `dependencies`, `hostIntegration`?, `provider`? | Every command line the build system can plan; `provider` is the shared form, which sends build requests to a language server | | `packagedBuildProvider`, `systemBuildProvider`, `runtimeBuildProvider` | the matching language-server fields with `buildSystemID`, `executesProjectCode`, `mayUseNetwork` instead of `languageID` | A build provider with a process of its own | -| `runtime` | `tool`, `minimumVersion`? | Runtime the host discovers and passes to a language server or build provider | +| `packagedVcsProvider`, `systemVcsProvider`, `runtimeVcsProvider` | the matching language-server fields with `vcs`, `vcsTool`, `mayUseNetwork` instead of `languageID` | A VCS provider process; `vcsTool` is the declared VCS client or `null` | +| `runtime` | `tool`, `minimumVersion`? | Runtime the host discovers and passes to a language server, build provider, or VCS provider | | `run` | `languageID`, `executable`, `arguments` | Run command and its argument template | | `test` | `languageID`, `executable`, `workspaceArguments`, `fileArguments`, `testCaseArguments` | Test command and its argument templates | +| `projectGenerator` | `generatorID`, `command`, `arguments`, `environment`, `collect`, `mayUseNetwork`, `timeoutSeconds`, `when` | A `command` step of a [project generator](#project-generators); `files` and `archive` steps start no program | Each entry carries everything that decides what actually runs, so an update that keeps the same executable but changes its arguments, argument templates, @@ -716,8 +748,12 @@ commands: Core validates the declaration, detects instances, decodes the model, and expands every command line; the host only runs the processes Core planned and passes their output back. A community plugin therefore needs no host change to add a build tool, and no host code branches on a build-system -ID. The fixtures `cargo-build`, `go-build`, `gradle-build`, and `maven-build` -are complete examples, `gradle-provider` and `mix-provider` show the two +ID. One complete fixture covers each model source: `build-static-file` reads +a project file, `build-command-stdout` decodes a command's standard output, +`build-command-output-file` decodes a file the command writes, and +`build-host-integration` hands the model to a host integration. They use +Cargo, Go, Gradle, and Maven as realistic inputs; they are not those +plugins. `build-provider` and `build-provider-language-server` show the two forms of a build provider, and `fixtures/build-plans.json` is the golden list of plans and decoded outputs every host gets from them. @@ -952,20 +988,209 @@ digests the watched files. A selection the declaration does not allow is `plugin.build.invalidSelection`; an operation it does not declare is `plugin.build.unavailable`. +## API level 3: VCS providers + +`vcsProviders[]` declares version control systems the plugin implements, each +through its own LSP base-protocol process: the VCS ID, the static markers that +identify a root, metadata paths the host watches, the VCS client the provider +drives, and the shared [`serverProcess`](#capability-versions-level-3) launch +fields. The ID `git` is reserved for the built-in support +(`plugin.vcs.reserved`). Capabilities belong to the running process and are +negotiated in `initialize` under `experimental.litheVcs`, never declared in +the manifest. + +Detection never starts a process: `vcs.detectRoots` finds roots from the +markers, and the host starts a provider only after a root of its VCS is +found. The install review lists the provider as a `…VcsProvider` program with +its VCS client (`vcsTool`) and `mayUseNetwork`. Requests go through +`lsp.vcsRequest` by operation name and replies are normalized by +`vcs.decodeReply`. The fields, operations, reply shapes, and error codes are +defined in [the VCS provider contract](https://github.com/1lck/Lithe-IDEA/blob/preview/shared/contracts/vcs-provider-v1.md). + +## Plugin backend + +A plugin with its own pages may need code that a page cannot run: database +drivers, network clients, file formats. `backend` declares one long-lived +process that serves only the plugin itself, independent of any build system, +language, or workspace: + +```json +"pluginAPIVersion": 3, +"requires": { "backend": 1, "serverProcess": 1, "ui": 2 }, +"permissions": ["backend.launch", "credentials.store", "ui.contribute", "ui.web"], +"backend": { "launch": { "kind": "packaged", "path": "bin/backend" }, "arguments": ["--state", "${stateDir}"] } +``` + +- `backend` holds exactly the [`serverProcess`](#capability-versions-level-3) + launch fields, with the same templates and install review; the review lists + it as `packagedBackend`, `systemCommandBackend`, or `runtimeBackend`. It + needs `pluginAPIVersion` 3, `requires.backend: 1`, `requires.serverProcess: + 1`, and the `backend.launch` permission. `fixtures/backend.plugin.json` is + the example; the backend cases of `fixtures/invalid-manifests.json` are the + rejections. +- In `backend` 1 only the plugin's own [web pages](#web-content-ui-2) call it, + so a backend without a web tool window is `plugin.manifest.invalid`. +- It belongs to no workspace: `${workspaceStateDir}` is refused, and its + writable state is `${stateDir}`. The host starts it with `${stateDir}` as + root and working directory. +- The host keeps at most one backend process per plugin, starts it on the + first `functions.*` call from one of the plugin's pages, and ends it when + the plugin is disabled, updated, or uninstalled, and when the host quits. + Its consent is the install review: it needs no per-workspace prompt. +- The backend speaks the [plugin-function protocol](#plugin-functions) and, + from version 2, may ask the host for the + [host services](#host-services-protocol-version-2) its permissions grant. + It owns no domain contract: it never answers `lithe/build/*` or VCS + requests. +- A plugin publishes one archive per platform, so its packaged path does not + vary by `${arch}`; a macOS archive may carry a universal binary. + +## Project generators + +`projectGenerators` (requires `projectGenerators` 1 and the `project.generate` +permission) adds entries to the host's New Project window. The host owns the +window: the entry list and its search, the project name and location, the +"create Git repository" option, the empty project, and the Create button. A +generator declares only its own fields, the languages and build systems the +result needs, and the steps that produce it. No host code branches on a +generator ID. + +```json +"projectGenerators": [{ + "id": "spring-boot", + "displayName": "Spring Boot", + "section": "generator", + "icon": {"path": "icons/spring-boot.svg", "mode": "original"}, + "fields": [ + {"id": "groupId", "type": "text", "title": "Group", "default": "com.example", "pattern": "[a-z][a-z0-9.]*"}, + {"id": "packageName", "type": "text", "title": "Package name", + "default": "${field.groupId}.${project.name|package}", "pattern": "[a-z][a-z0-9.]*"}, + {"id": "jdk", "type": "runtime", "title": "JDK", "runtime": "java", "minimumVersion": "17"} + ], + "requirements": [{"languageID": "java"}, {"buildSystemID": "maven"}], + "steps": [{"kind": "archive", "url": "https://start.spring.io/starter.zip", + "query": [{"name": "packageName", "value": "${field.packageName}"}]}] +}] +``` + +| Field | Meaning | +| --- | --- | +| `id` | Lowercase letters, digits, and hyphens; unique in the plugin. | +| `section` | `project` lists the entry under "New Project" (a plain project of a language); `generator` lists it under "Generators" (a framework or scaffolding tool). | +| `order` | 0–1000, default 100. Entries are sorted by section, `order`, then name. | +| `fields` | The generator's inputs, drawn and evaluated in order. A field may read only fields declared before it. | +| `requirements` | `languageID` or `buildSystemID` (optionally `when`) the created project needs. The catalog lists the installed plugins that provide each, with their enabled state, so the host can offer to enable one or open the marketplace. A requirement never blocks creation. | +| `steps` | Run in order in a staging directory that becomes the project root. | +| `openFiles` | Project-relative files the host opens after creating the project, optionally with `when`. | + +### Fields + +| `type` | Value | Properties | +| --- | --- | --- | +| `text` | A string | `default` (a template over `${project.name}` and earlier fields, re-evaluated until the user edits the field), `pattern` (matched as a whole), `maxLength` (default 256, at most 1024), `placeholder`, `optional`, `maximumVersion` | +| `enum` | One option | `options` (1–16 `{value, title}`), `default` (the first option otherwise), `presentation` (`segmented` or `menu`), `maximumVersion` | +| `boolean` | `true` or `false` | `default` | +| `multiSelect` | A list of options, kept in declaration order | `options` (1–128, each with an optional `group` heading), `default` | +| `runtime` | A host-discovered installation | `runtime` (`java`), `minimumVersion`, `optional`. The host chooses the initial value. | + +`maximumVersion` is a template over earlier fields naming the highest version +the value may be, compared numerically by dotted components. A target Java +release that must not exceed the selected JDK declares +`"maximumVersion": "${field.jdk.major}"`; while the bound expands to an empty +string (no JDK chosen yet) nothing is checked. + +Every field takes `id` (lowerCamelCase), `title`, `description` (shown behind +an info tip), `advanced` (listed under the collapsed "Advanced Settings" +group), and `visibleWhen`. A hidden field is not validated, and templates and +conditions read it as absent. + +Text values never start with `-` and never contain control characters, so a +value cannot become an option or split a line. A project name is one +directory name: not empty, at most 128 characters, no `/`, `\`, `:`, or +control characters, not `.` or `..`, no leading `-`, and no surrounding +spaces. + +### Conditions + +`when` and `visibleWhen` take one condition or a list that must all hold: +`{"field": "", "equals" | "notEquals": }` or `{"field": "", +"oneOf": [, …]}`. Values are option values for `enum`, `true`/`false` +for `boolean`, and strings for `text` and `runtime` (`notEquals: ""` tests +that an optional field is filled). A condition on a hidden field is false. + +### Templates + +Strings in the manifest use `${…}`: `${project.name}`, `${field.}` (a +text or option value, `true`/`false`, a comma-separated list, or a runtime's +home directory), `${field..version}`, and `${field..major}` (`21` for +`21.0.4`, `8` for `1.8.0_412`). Modifiers follow `|` and apply in order: +`path` turns dots into `/`, `package` makes a valid package name +(`com.Example.my-app` → `com.example.myapp`), and `lower` lowercases. + +Template files use `{{ … }}` with the same variables, because POMs, Gradle +scripts, and properties files already contain `${…}` that must reach the +project unchanged. Only `{{` followed by `project.` or `field.` is a +variable; any other `{{` is copied as text. A file name may also contain +`${…}` variables, although a step's `target` is the portable place for them. +A path segment that starts with `dot-` becomes a dotfile (`dot-gitignore` → +`.gitignore`), because packages keep no hidden files. Hidden files in a +template directory are skipped; symbolic links are refused. + +### Steps + +| `kind` | Properties | The host… | +| --- | --- | --- | +| `files` | `source` (package-relative directory), `target` (project-relative directory template), `when` | writes the files of the plan, setting the executable bit where the template file has one. | +| `command` | `command` (a bare executable name), `arguments` (strings or `{value, when}`), `environment`, `collect`, `mayUseNetwork`, `timeoutSeconds` (default 600, at most 1800), `when` | resolves the executable like a build tool, runs it with the staging directory as the working directory, and, with `collect`, moves the contents of that subdirectory up into the project root. Arguments that expand to an empty string are left out. | +| `archive` | `url` (no query or fragment), `query` (`{name, value, when, omitEmpty}`), `when` | downloads the zip and calls `project.extractArchive` into the staging directory. | + +Archive addresses must expand to HTTPS URLs (HTTP only for `localhost`, +`127.0.0.1`, or `[::1]`) without credentials. The URL may come from a field, +such as a configurable Spring Initializr server; the host shows the server +before creating. Template directories are limited to 1024 files, 1 MiB per +file, and 8 MiB in total; archives to 20,000 entries and 512 MiB extracted. + +### Commands + +| Command | Payload | Result | +| --- | --- | --- | +| `project.generatorCatalog` | `{plugins: [{manifest, enabled}], hostCapabilities?}` | `catalog: {generators: [{pluginID, pluginName, pluginEnabled, generator, requirements: [{languageID or buildSystemID, providers: [{pluginID, pluginName, enabled}]}], usesNetwork}], issues: [{pluginID, error}]}` | +| `project.generatorForm` | `{manifest, generatorID, form}` | `form: {projectName: {problem}, fields: [{id, visible, value, problem?}], activeRequirements, valid}` | +| `project.generationPlan` | `{manifest, generatorID, packagePath, form}` | `plan: {projectName, steps, openFiles, runtimes: [{field, runtime, home, version}], usesNetwork}` | +| `project.checkName` | `{projectName}` | `name: {problem}` | +| `project.extractArchive` | `{archivePath, destination}` | `extracted: {files}` | + +`form` is `{projectName, values, edited, runtimes}`. `values` maps field IDs +to values; a text value counts only when its ID is in `edited`, so derived +defaults keep following the fields they read. `runtimes` maps runtime field +IDs to `{home, version}` of the user's selection. A field `problem` is one of +`required`, `tooLong`, `invalidFormat`, `unsupportedValue`, `runtimeTooOld`, +and `versionTooHigh`; hosts localize by code. Plan steps are `{kind: +"writeFiles", files: [{path, contents, executable}]}`, `{kind: "command", +command, arguments, environment, collect, mayUseNetwork, timeoutSeconds}`, and +`{kind: "archive", url, host, format: "zip"}`. + +After the steps, the host moves the staging directory to its final location +(refusing an existing one), may record a `java` runtime as the project's JDK, +creates the Git repository when asked, and opens the project. A failure or +cancellation removes the staging directory and leaves nothing behind +(`plugin.project.generationFailed`). + ## Plugin functions -A plugin backend (a language server or build provider process the plugin -starts) can offer **plugin functions**: plugin-private actions with +A plugin backend (a language server, build provider, or +[plugin backend](#plugin-backend) process the plugin starts) can offer +**plugin functions**: plugin-private actions with serializable params and results, which the host invokes on behalf of the same plugin, for example from that plugin's own window. Functions complement the -typed domain protocols; they never replace them. Version 1 of the protocol is +typed domain protocols; they never replace them. Versions 1 and 2 of the protocol are implemented in Rust Core (`rust/lithe-core/src/lsp/interface/plugin_function.rs`). macOS starts every build provider process as a session of the plugin that owns it and invokes its functions from that plugin's web pages (the `functions.*` bridge methods below). A provider served by a shared language server is started by the host for every plugin of its language, so it serves no plugin's functions. Windows has no plugin backend host. The -[official Node SDK](#official-backend-sdk) implements the backend side. +[official SDKs](#official-backend-sdk) implement the backend side. ### Capability ownership @@ -989,6 +1214,7 @@ Two rules keep these directions from forming a loop: domain registry. Each is added to a protocol version together with its params, permission, and the operation it must belong to. Version 1 offers none: every `lithe/host/*` request is refused with `plugin.host.unavailable`. + Version 2 adds the [credentials](#host-services-protocol-version-2) service. ### Declarative or backend implementation @@ -1010,7 +1236,7 @@ Two rules keep these directions from forming a loop: ### Negotiation For a session owned by a plugin (`lsp.startServer.pluginId`), the host offers -`capabilities.experimental.litheFunctions: { "versions": [1] }` in +`capabilities.experimental.litheFunctions: { "versions": [1, 2] }` in `initialize`, next to and independent of `litheBuild`. Host-owned sessions do not offer or accept this protocol, retain no plugin-function registry, and reject register/unregister with `-32601`. A plugin backend that offers @@ -1111,6 +1337,42 @@ A backend reports progress of a running call with the notification normalized report. A malformed report, or one for an operation that is not running on that session, is dropped. +### Host services (protocol version 2) + +A backend that negotiated version 2 may send these requests to the host. +Each service is granted by one manifest permission; Core receives the +granted services as `lsp.startServer.hostServices` (from the launch plan's +`backend.hostServices`) and checks every request before the host sees it. + +| Request | Params | Result | Permission | +| --- | --- | --- | --- | +| `lithe/host/credentials/get` | `{ key }` | `{ secret }`, `null` when nothing is stored | `credentials.store` | +| `lithe/host/credentials/set` | `{ key, secret }` | `null` | `credentials.store` | +| `lithe/host/credentials/delete` | `{ key }` | `null`, also when nothing was stored | `credentials.store` | + +- A `key` is 1–128 ASCII letters, digits, `.`, `_`, `:`, or `-`; a `secret` + is a string of at most 16 KiB. Unknown params are refused so a later + version can add them. +- Credentials are private to the plugin ID: the host stores them in the + platform's secret store (the Keychain on macOS) in a namespace of that ID, + never in `${stateDir}` or the package. They survive disabling and updating + the plugin and are deleted when it is uninstalled. +- Core refuses, without involving the host, in this order: a session that + negotiated version 1 or a method version 2 does not define (`-32601`, + `plugin.host.unavailable`), a service the plugin is not granted (`-32601`, + `plugin.host.permissionDenied`), and malformed params (`-32602`, + `plugin.host.invalidParams`). The code is in `error.data.code`. +- An accepted request becomes the runtime event `hostRequest` with `method` + and `result: { hostRequestID, service, params }`. The host answers it with + `lsp.hostResponse { sessionId, hostRequestID, result? | error: { message } }`; + Core sends `result` (or `null`) to the backend, or `-32603` with + `plugin.host.failed` and the host's message cut to 1024 bytes. An unknown + `hostRequestID`, including one already answered, is `invalid_request`. +- A session holds at most 16 unanswered host requests; a further one is + refused with `plugin.host.failed`. Unanswered requests end with the session. +- `hostServices` needs `pluginId` and names only services this Core defines; + otherwise `lsp.startServer` is `invalid_request`. + ### Compatibility - Packages of every API level are unchanged: functions add no manifest field. @@ -1119,39 +1381,58 @@ running on that session, is dropped. function calls, and its function requests are answered with `-32601`. - A new host capability or wire field the other side must not ignore needs a new protocol version, offered next to the old one. -- `fixtures/function-protocol.json` holds the golden cases. Rust Core tests run - them, and a scripted backend covers routing, result, failure, cancellation, +- A backend that answers `version: 1` keeps working unchanged: version 2 + only adds host services. +- `fixtures/function-protocol.json` holds the golden cases, including the + host requests (`hostRequests`). Rust Core tests run them, and a scripted backend covers routing, result, failure, cancellation, deadline, late reply, revocation, and compatibility with a build-only backend. ### Official backend SDK -[`sdk/node`](../../../../sdk/node/README.md) is the official SDK for backends +The official SDKs live in +[lithe-plugin-docs](https://github.com/Lithe-IDEA/lithe-plugin-docs), which +also releases them; they implement this contract and never define protocol. + +[`sdk/node`](https://github.com/Lithe-IDEA/lithe-plugin-docs/blob/main/sdk/node/README.md) is the official SDK for backends started through a `node` runtime launch. It implements this protocol and -`litheBuild` 1, so a plugin author writes only handlers. It enforces the -rules above on the backend side: `lithe/*` methods are never sent, stdout +`litheBuild` 1, so a plugin author writes only handlers. It is released +(`sdk-node-v`) as a `.tgz` with its SHA-256 for plugins to vendor +into their package. It enforces the rules above on the backend side: `lithe/*` methods are never sent, stdout carries only protocol frames, `PluginError` is the only way to set a `pluginErrorCode`, processes started for a call end with it, and its state -store refuses the plugin package. Its sample plugin -(`sdk/node/examples/sample-build`) is driven through a real Core session by -`rust/lithe-core/tests/plugin_sdk_node.rs`, which the Rust Core lane runs with -`LITHE_PLUGIN_SDK_NODE` set. +store refuses the plugin package. -[`sdk/java`](../../../../sdk/java/README.md) is the Java twin for backends +[`sdk/java`](https://github.com/Lithe-IDEA/lithe-plugin-docs/blob/main/sdk/java/README.md) is the Java twin for backends started through a `java` runtime launch. It needs Java 8 or newer, has no runtime dependencies, and is released as a JAR with its SHA-256 for plugins to shade into their backend. It enforces the same rules with the same error mapping, with one difference: on Java 8 it stops only a cancelled tool's -direct child, and the host's process scope ends the rest. Its sample plugin -(`sdk/java/examples/sample-build`) runs the same Core scenario through -`rust/lithe-core/tests/plugin_sdk_java.rs`. - -Neither SDK adds a wire field: a backend written without one is equally -valid. +direct child, and the host's process scope ends the rest. + +[`sdk/rust`](https://github.com/Lithe-IDEA/lithe-plugin-docs/blob/main/sdk/rust/README.md) is the SDK for +[plugin backends](#plugin-backend), which ship as packaged executables. It +implements plugin-function protocol versions 1 and 2, including the +`credentials` host service, but neither build nor VCS providers. It needs +Rust 1.87 or newer, depends only on `serde_json`, and is released +(`sdk-rust-v`) as a `.crate` archive with its SHA-256, or used as a +Git dependency pinned by the tag. It enforces the same rules: on Unix it +points stdout at stderr so only protocol frames reach the host, and a panic +ends only its call, reported like any unexpected failure. + +The Node.js and Java SDKs each have a sample build provider +(`sdk/*/examples/sample-build`) that answers one end-to-end scenario driven +through a real Core session; the Rust SDK's sample backend +(`sdk/rust/examples/sample-backend`) answers a plugin backend scenario in which +the test plays the host and serves the credential requests. That repository +runs them against the Lithe commit its contract copy pins, and weekly against +the latest `preview`, the branch Lithe changes merge into. + +No SDK adds a wire field: a backend written without one is equally valid. ## Outcomes and error codes -`plugins.declarative.*`, `plugins.marketplace.*`, and `build.*` commands return `{"status": "ok", …}` or +`plugins.declarative.*`, `plugins.marketplace.*`, `build.*`, `vcs.*`, and `project.*` commands return `{"status": "ok", …}` or `{"status": "rejected", "error": {"code", "message", "field"?}}`. A malformed command payload is a Core `invalid_request` failure instead. @@ -1170,11 +1451,13 @@ command payload is a Core `invalid_request` failure instead. | `plugin.test.unsupportedProject` | Core | No project marker in the workspace | | `plugin.execution.unavailable` | Core | The plugin has no run/test provider for the language | | `plugin.integration.unsupported` | Core | A host integration the host does not implement, or one requested by a plugin outside the official namespace | -| `plugin.launch.unavailable` | Core | The plugin has no language server for the language, or no provider process for the build system | +| `plugin.launch.unavailable` | Core | The plugin has no language server for the language, no provider process for the build system, or no such VCS provider | | `plugin.build.unavailable` | Core | The plugin declares no such build system, or the build system does not declare the requested operation | | `plugin.build.invalidSelection` | Core | A build request selects a scope, target, filter, or argument the declaration does not allow, or misses a required path | | `plugin.build.invalidOutput` | Core | Build-tool output or a report does not fit its decoder | | `plugin.launch.invalidRequest` | Core | A launch plan request has an unusable directory or an unknown architecture | +| `plugin.vcs.reserved` | Core | A VCS provider claims the ID of the built-in Git support | +| `plugin.vcs.invalidOutput` | Core | A VCS provider's reply does not fit the [VCS provider contract](https://github.com/1lck/Lithe-IDEA/blob/preview/shared/contracts/vcs-provider-v1.md) | | `plugin.index.invalid` | Core | Malformed index root or entry, or an invalid URL, checksum, platform, version, or date | | `plugin.index.schemaUnsupported` | Core | The index `schemaVersion` is not `1` | | `plugin.index.officialNamespace` | Core | A third-party source lists a `dev.lithe` ID | @@ -1195,8 +1478,17 @@ command payload is a Core `invalid_request` failure instead. | `plugin.function.invalidReference`, `plugin.function.invalidParams`, `plugin.function.unavailable`, `plugin.function.wrongPlugin`, `plugin.function.unknown` | Core | A [plugin-function](#plugin-functions) call is refused before it reaches the backend | | `plugin.function.failed`, `plugin.function.invalidResult`, `plugin.function.revoked` | Core | A plugin-function call ended without a usable result | | `plugin.function.invalidRegistration` | Core | A backend's register or unregister request was refused as a whole | +| `plugin.project.unavailable` | Core | The plugin declares no such project generator | +| `plugin.project.invalidInput` | Core | The project name or a field value is not accepted; `field` is `projectName` or `values.`, or a step whose expanded path leaves the project | +| `plugin.project.templateInvalid` | Core | A template directory is missing, too large, contains a symbolic link or non-UTF-8 file, or uses an unknown variable | +| `plugin.project.invalidURL` | Core | An archive address does not expand to an HTTPS URL (HTTP only for loopback hosts) without credentials, query, or fragment | +| `plugin.project.archiveInvalid` | Core | A downloaded archive is not a zip, has an entry outside the destination, a symbolic link, a file that already exists, or expands beyond the size limit | +| `plugin.project.generationFailed` | Host | A command step failed or timed out, a download failed, or the project could not be moved into place | | `plugin.function.recursiveCall` | Core | A backend asked the host to dispatch a domain operation or function | | `plugin.host.unavailable` | Core | A backend asked for a host capability the negotiated protocol does not offer | +| `plugin.host.permissionDenied` | Core | A backend asked for a [host service](#host-services-protocol-version-2) its plugin's permissions do not grant | +| `plugin.host.invalidParams` | Core | A host-service request has malformed or unknown params | +| `plugin.host.failed` | Core | The host could not complete a host-service request, or too many were unanswered | Host-facing plugin states are `notInstalled`, `installed` (enabled or disabled), `running`, `restartRequired`, `updateRequired`, and `failed`. @@ -1290,8 +1582,10 @@ Hosts load a page under these rules: - The root element carries `data-theme` (`light` or `dark`) and the host's `--lithe-*` CSS colour and font variables, updated with the theme. -Before the page's scripts run, the host defines `window.lithe` (bridge -version 1): `lithe.context` (`pluginID`, `windowID`, `platform`, `locale`, +Before the page's scripts run, the host defines `window.lithe`: +`lithe.version`, the bridge version (2 on current macOS hosts; version 2 +adds the file dialogs to version 1, so a page checks it before offering +them), `lithe.context` (`pluginID`, `windowID`, `platform`, `locale`, `theme`), `lithe.request(method, params)`, which returns a promise of the result or rejects with `{ code, message }`, and `lithe.on(event, listener)`, which returns an unsubscribe function. A page reaches only its own plugin's @@ -1310,17 +1604,26 @@ their output appears in the host's Build output window. | `build.setOptions` | `{ instance, values }` | `{ options }`, checked by `build.resolveOptions` and stored like values set in the host's option list | | `workspace.openFile` | `{ path, line?, column? }`, a path inside the workspace | `{}` | | `ui.getState`, `ui.setState` | none, `{ state }` | `{ state }`, `{}`; JSON of at most 64 KiB per window and workspace | -| `functions.list` | `{ instance }` | `{ functions }`: sorted IDs of the functions the instance's provider currently offers; starts the provider | -| `functions.invoke` | `{ instance, function, params?, call? }`; `params` an object, `call` a page-chosen ID of at most 128 characters that the page never used before | `{ value }`, the function's result | +| `functions.list` | `{ instance? }` | `{ functions }`: sorted IDs of the functions the instance's provider, or without `instance` the plugin's [backend](#plugin-backend), currently offers; starts that process | +| `functions.invoke` | `{ instance?, function, params?, call? }`, without `instance` a call to the plugin's backend; `params` an object, `call` a page-chosen ID of at most 128 characters that the page never used before | `{ value }`, the function's result | | `functions.cancel` | `{ call }` | `{}`; a call still awaiting consent never starts, a call waiting for its provider to start ends at once while the start goes on, and a call that already ended is ignored | +| `dialog.openFile` (bridge 2) | `{ title?, extensions? }` | `{ path }`: the absolute path of the file the user chose, or `null` when the dialog was cancelled | +| `dialog.saveFile` (bridge 2) | `{ title?, suggestedName?, extensions? }` | `{ path }` as for `dialog.openFile`; the host has asked before replacing an existing file, and nothing is written | | Event | Payload | | --- | --- | | `build.instancesChanged` | `{ instances }`, as `build.instances` returns them | | `build.runChanged` | `{ run, instance, state, exitCode? }`, `state` one of `running`, `succeeded`, `failed`, `cancelled` | -| `functions.progress` | `{ call, instance, function, message?, percentage? }`, only for a call made with `call` | +| `functions.progress` | `{ call, instance?, function, message?, percentage? }`, only for a call made with `call`; `instance` absent for a backend call | | `ui.themeChanged` | `{ theme }` | +The file dialogs are the host's: they name the plugin that asks, a page +has at most one open at a time, and the page learns only the path the user +chose. `extensions` are up to 16 file name extensions without the dot; +`suggestedName` is a file name, not a path. A page passes the path to its +backend, which reads or writes the file; the page itself still reaches no +file. + Bridge errors are `plugin.web.unknownMethod`, `plugin.web.invalidRequest`, and `plugin.web.forbidden`. Build requests also report the `build.*` codes. Function requests report Core's codes unchanged (`plugin.function.*`, @@ -1334,20 +1637,24 @@ cancelled or the provider stopped. A page has at most 16 function calls accepted at once, counting calls still awaiting consent, and closing it cancels them. Reusing a `call` ID is `plugin.web.invalidRequest`, also after that call ended. -`functions.list` and `functions.invoke` ask for the same consent as the -host's provider requests: once given for a plugin and workspace, it holds -until the project is closed. -macOS implements web content with WKWebView and advertises `ui: [1, 2]`. -Windows has no WebView2 host yet and still advertises `ui: [1]`, so Core refuses -`ui` 2 plugins there with `plugin.api.unsupported`. +`functions.list` and `functions.invoke` with an `instance` ask for the same +consent as the host's provider requests: once given for a plugin and +workspace, it holds until the project is closed. Calls to the plugin's +backend ask for none; the install review covered it. +macOS implements web content with WKWebView and advertises `ui: [1, 2, 3]` +(including the native controls described below). +Windows implements web content with WebView2 under a plugin-private +`lithe-plugin://` origin and also advertises `ui: [1, 2]`; its bridge v0 +answers `ui.getState`, `ui.setState`, and `workspace.openFile`, and returns +`plugin.web.unknownMethod` for `build.*` and `functions.*` until the Windows +build-system capability lands. Windows does not advertise `ui` 3. ### Future public extension direction (not implemented in API level 2) - Plugins may bring their own UI as [web content](#web-content-ui-2) inside - host-owned windows. A separately versioned JSON component tree - (Tree/List/Form/Input/Button, data sources and events) rendered by native - platform adapters remains the direction for plugins that want native - controls without writing a page. Built-in or trusted native modules may + host-owned windows. Native trees/lists and panel actions now use the separately versioned + [native schema](#host-rendered-native-content-ui-3-native-schema-1). + Forms and broader component composition remain follow-up work. Built-in or trusted native modules may register Native View/Renderer Factories under the same host window management. This does not restore arbitrary dynamic third-party Swift Bundle loading; ABI, signing and isolation need separate design. @@ -1376,8 +1683,8 @@ existing Debug UI. Each must verify disable/re-enable, resource ownership and restoration; view-bearing samples also verify layout persistence. These samples are future acceptance criteria, not claims about current runtime support. -The design source is the [proposed public Extension Contract and Provider API](../../../../.agents/notes/proposed/architecture/2026-10-13-public-extension-contract-and-provider-api.md). -Current runtime behavior is recorded in the [first-stage workbench UI Note](../../../../.agents/notes/implemented/architecture/2026-10-12-workbench-ui-contributions.md). +The design source is the [proposed public Extension Contract and Provider API](https://github.com/1lck/Lithe-IDEA/blob/preview/.agents/notes/proposed/architecture/2026-10-13-public-extension-contract-and-provider-api.md). +Current runtime behavior is recorded in the [first-stage workbench UI Note](https://github.com/1lck/Lithe-IDEA/blob/preview/.agents/notes/implemented/architecture/2026-10-12-workbench-ui-contributions.md). ### Current Windows availability Windows can validate API level 2 through Rust Core but its UI adapter has not @@ -1437,8 +1744,10 @@ optional; without it the same resource is used in both themes. Brand authors must make that resource legible in both themes or provide a dark variant. `host` cannot be combined with resource paths or original-color mode. -The host owns icon size: 20pt tool-window rail, 16pt actions/menus/palette, 13pt -window header. Aspect ratio is preserved inside that slot. Plugin-specified +The host owns icon size: 20pt tool-window rail and 16pt actions/menus/palette. +Panel titles are text-only: no title icon, outline or bottom divider. A declared +window icon remains available in the activity rail and management menus. +Aspect ratio is preserved inside each icon slot. Plugin-specified size, color, stroke, arbitrary style, remote URLs and SF Symbol names are rejected. Stroke geometry belongs to the asset; authors should follow the host icon weight, rather than expecting the host to rewrite paths or PNG pixels. @@ -1471,3 +1780,194 @@ macOS 动作的可见、可用、勾选和激活中状态由宿主计算,不 条件脚本。窗口尚在激活时,其打开命令在工具栏、菜单和命令面板禁用;窗口入口 仍可取消显示。多个窗口同时请求同一插件模块时只构造一次;禁用或关闭会取消 并清理在途激活,旧的菜单或命令引用不能重新调用已禁用的贡献。 + +#### Shared executable candidates (`executableResolution` 1) + +Declare executable sources in `invocation.executables`, in preference order. +Array order is the recommended form; no custom path setting is required for a +plugin that only needs automatic discovery. Existing `projectFile`, `environment`, +`command` and `runtime` sources retain their v1 behavior. The shared +`executableResolution: 1` capability adds `setting` and ordering metadata: + +```json +"executables": [ + { "id": "selected", "kind": "setting", "settingID": "toolPath" }, + { "id": "wrapper", "kind": "projectFile", "path": "mvnw" }, + { "id": "system", "kind": "command", "command": "mvn" } +] +``` + +A `setting` references a declared **global file** setting with `effect: "reload"` +or `"nextRun"` (`settings: 2`). Core receives raw `request.settings`. An absent +value uses the declared default; an explicit empty string emits a setting candidate +with `path: null`, which skips. A nonempty value must satisfy the setting rules, +be absolute, and have no surrounding whitespace or controls. Invalid stored +values reject the invocation instead of invoking the normal settings display +resolver's fallback-to-default behavior. Project overrides never select tools. +The host checks file permissions and versions: a configured failure is terminal, +including a failure to probe, and must not fall back to subsequent candidates or +a Provider's own lookup. Paths with spaces remain single paths. The selected +setting's directory precedes PATH. Runtime candidates construct their environment +before version probing and reuse it for execution; a rejected candidate's +environment does not leak into the next choice. Provider hints and task/test +execution use this same plan/selection mechanism. If resolution fails before a +configured candidate is reached (for example, an earlier Runtime is missing), +any nonempty setting in the tool or Runtime candidate sets still forbids the +Provider's independent lookup. All-empty settings retain automatic-discovery +fallback. This check applies after failure, so an earlier successful candidate +is unaffected by a later unused setting. + +Optional metadata applies within **one candidate set owned by one plugin**: + +- `id` is a unique stable identifier; anonymous candidates cannot be anchors. +- `priority` is a signed 32-bit integer; higher wins, absent means 0. Ties retain + original array order. +- `insertBefore` or `insertAfter` names an ID in this set. One candidate cannot + combine either with `priority`, or declare both insertion fields. Constraints + mean relative order, not immediate adjacency. +- Normalize the complete set into a directed graph before platform filtering. + Repeatedly select a zero-indegree node by descending priority, then original + index; emit it and remove its edges. Explicit edges take precedence over + priority. Duplicate/invalid IDs, unknown anchors, cycles/self-edges and + conflicting metadata are manifest errors. Filter platforms only after sorting, + so anchors remain valid even if they do not apply to the current platform. + +`plugins::executables` owns the reusable source types and deterministic order; +`ExecutableCandidateResolver` receives file-system, Setting (already validated by +Core), Environment, Runtime and command-discovery capabilities from the host. +Results distinguish `skip`, `resolved` and `terminalFailure`; plugins declare data +and cannot inject host native code. Each operation uses a value snapshot for its +settings/environment and ordered plan. Passive import does not resolve/probe +runtimes or execute project wrappers. A project candidate remains project code +and is only executed through the consumer's existing trust/user-action workflow. + +This PR connects Build Systems and their custom Runtime Dependencies. Own Build +Providers already adapt command/runtime launches through BuildToolResolver and +receive the selected build tool as `tool.executable`; their ServerProcess protocol +is unchanged. Language Servers, Execution Providers, and other ServerProcess +launch forms can later adapt into this same resolver without changing their +manifest structure all at once: preserve package-realpath/executable checks, +workspace trust, auto-start restrictions, runtime ownership and cancellation. +They do **not** advertise support for new candidate declarations until their +adapter is implemented; this capability currently authorizes the Build candidate +positions only. Windows does not advertise it yet. + +Compatibility: Cargo 0.1.1 already shipped as a preview with `buildSystems: 2` +and `invocation.executableSetting`. That field is deprecated and supported only +as a v2 input adapter: prepend an equivalent setting candidate to the normal +list; never emit `configuredExecutable` or use a separate resolution branch. +New plugins use the fifth candidate and `executableResolution: 1` (Build v1 +remains sufficient). Legacy manifests do not need the new capability; hosts +without it reject new-style plugins before decoding unknown fields. Published +0.1.0/0.1.1 archives remain immutable; migration is a new plugin version. + +See [`build-tool-path.plugin.json`](fixtures/build-tool-path.plugin.json). + +### Host-rendered native content (`ui` 3, native schema 1) + +macOS supports `requires.ui: 3` for a tool window whose sole body is `native`. +Text and Web windows retain their existing behavior. Windows does not advertise +ui 3 and rejects these packages before installing them. The independently +versioned [native schema](native-ui.schema.json) has no style, script, HTML or +host-action fields. The host renders every control with its shared theme. + +See [native-panel.plugin.json](fixtures/native-panel.plugin.json) and the runnable +[Node SDK Sample Explorer](https://github.com/Lithe-IDEA/lithe-plugin-docs/blob/main/sdk/node/examples/sample-build/plugin.json). +`native.kind` is `tree`, `list` or `form`; `nodes` contains literal `id`, `label` and +optional `children`. IDs are stable, case-sensitive ASCII letters/digits plus +`._-` (1–128 bytes). The component, initial nodes and actions have distinct IDs. +The entire node set is limited to 2000 nodes and depth 16 (root depth 0); labels +are nonblank and at most 1024 UTF-8 bytes. Lists reject nested children. + +A `form` declares `nodes: []`, an empty `contextMenu`, at most 32 `controls` +and 16 `buttons`; at least one control or button is required. +Tree/list panels cannot contain controls. Each control has a unique `id`, literal +`label` (1–256 UTF-8 bytes), `kind`, initial `value`, optional `enabled` (default +true), and optional `onCommit` referencing a declared `invoke` action that does +not require a selection. An optional literal `info` (1–1024 UTF-8 bytes) renders the shared information +icon beside the label; tooltip timing, pinning, accessibility and styling are host-owned. + +- `input` / `search`: string `value` (at most 1024 UTF-8 bytes), optional `placeholder` + (at most 256 UTF-8 bytes), no options. Uses the IDE settings text-field owner. +- `select`: string `value` equal to one of 1–128 unique `options: [{id,label}]`; + option IDs use the standard identifier rule, labels are 1–256 UTF-8 bytes. + No placeholder. Uses `LitheSettingsSelect`, including its popup and keyboard behavior. +- `segmented`: string value and 1–8 options, using the same option contract and + the shared segmented selector; no placeholder. +- `editor`: string value up to 8192 UTF-8 bytes, optional `rows` (2–12, default 4), + no placeholder/options/onCommit. Reuses the shared multiline editor; Enter inserts + a newline and form buttons submit its current value. +- `stepper`: finite numeric value, required `minimum`, `maximum` and positive + `step`; bounds and step are limited to ±1e9/1e9, and value must fit the range. + No placeholder/options. Uses the shared settings stepper and bound behavior. +- `checkbox`: boolean `value`, no options or placeholder. Uses `LitheSettingsCheckbox`. + +Forms may declare `layout: {"columns":12}` (1–12 columns). Each control may +provide `span` (1–columns); omitted span fills a row. Controls pack in declaration +order, wrapping before a span would exceed the row. Without layout, the form +retains its original single-column presentation and must omit span. Layout is +form-only. Gaps, alignment and breakpoints remain host-owned; plugins cannot +supply pixels, CSS or gap values. macOS uses a native `Layout`, 12pt gaps, +top-aligned variable-height rows, and switches to one column below 640pt of +content width. Reflow preserves the same controls, local values and focus. + +Buttons reference an existing non-selection action through `action`, have stable +`id`, literal `label`, optional `primary`, `enabled` and `info`. +Text actions reuse the host's shared natural-width button style and 6pt action +spacing; a narrow action group falls back to vertical stacking. Deprecated +`buttons[].width` (`content`/`fill`) and `buttons[].span` are accepted for older +experimental packages, but do not change button width or action-group layout. +Grid spans continue to apply to value controls. The declaration cannot override +button colors, fonts, chrome, padding or fixed/max widths. + +Input/search editing saves local values without executing plugin code; Return commits. +Selectors, segmented selectors, steppers and checkboxes commit after a changed value. Omit `onCommit` to keep +a field local. Commits pass **all** current fields as `params.values` (strings, +booleans and numbers keyed by stable control ID), together with `selectedID: null`. +The subsequent form source call also receives `{values}` and returns `{nodes: []}`. +Disabled fields still contribute their values; they cannot edit or commit. +A pending call disables all fields. Values use the existing 64 KiB per-plugin, +per-window, per-workspace state store; removed fields, invalid types and removed +options are discarded when restoring. Provider replies never overwrite local +fields. Form declarations and choices are static in this version. +See [native-form.plugin.json](fixtures/native-form.plugin.json) and +[host UI acceptance fixture](https://github.com/1lck/Lithe-IDEA/blob/preview/macos/Tests/Fixtures/native-ui-lab/README.md) for acceptance. + +An optional `source` names this plugin's `buildSystemID` and a function returning +`{ nodes: [...] }`. This phase uses the existing provider transport; it adds no +new backend, provider registry, or process protocol. There must be exactly one +detected instance of that build system in the workspace; zero or multiple +matches produce an error instead of silently targeting a different root. +Static panels omit `source` and declare empty actions and placements. + +`actions` declares at most 64 independent handlers with `id`, literal `title`, +`kind` (`refresh` or `invoke`) and `requiresSelection`. A refresh reloads the +source and cannot require selection or name a function. An invoke names a +`function` and receives `{ selectedID: string | null }` (forms also include `values`). Invocations return an +optional `{ message }`, then the host reloads the source. `toolbar` and +`contextMenu` contain distinct references to declared action IDs in display +order. Toolbar actions are icon-only and appear when the pointer enters the whole +panel, like the host hide control; keyboard and accessibility focus also reveal them. +An optional `icon: {"host":"search"}` reuses the host icon vocabulary (including +`reset`); native actions do not load custom package icons. Without an icon, refresh +uses the refresh icon and invoke uses the generic run icon. The literal title is +the tooltip and accessibility name. A context-menu action selects its clicked row before running; selecting +a row alone never starts a provider function. These are panel-local actions; +this phase does not change API-v2 commands into arbitrary function handlers. + +The host allows one operation per panel, disables actions while busy, and +shows loading, empty, failure and success feedback. A source reply is at most +1 MiB and follows the same node limits; invalid data preserves the last valid +rows. Selection and expansion use stable IDs and the existing per-plugin, +window and workspace state store (at most 64 KiB). Arrow keys move selection, +expand/collapse nodes and select a parent; hidden/reopened panels restore state. +Provider calls retain existing consent, scope checks, timeout and cancellation. +Hiding a native panel cancels its pending operation. Disable, package replacement +and workspace close synchronously invalidate it through the existing shared +plugin-page lifecycle; late replies cannot publish. All state is outside the +installed package and application bundle. + +Schema 1 loads the complete node set on refresh. Forms, lazy child data sources, +multi-instance selectors, global function commands, native renderer factories +and Windows rendering remain follow-up work under #1209; this subset does not +claim completion of that whole issue. diff --git a/shared/contracts/plugins/v1/fixtures/backend.plugin.json b/shared/contracts/plugins/v1/fixtures/backend.plugin.json new file mode 100644 index 0000000000..b5bd06b179 --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/backend.plugin.json @@ -0,0 +1,49 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.backend", + "displayName": "Backend", + "version": "1.0.0", + "pluginAPIVersion": 3, + "requires": { + "backend": 1, + "serverProcess": 1, + "ui": 2 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "backend.launch", + "credentials.store", + "ui.contribute", + "ui.web" + ], + "activationEvents": [ + "onView:dev.lithe.fixture.backend.window" + ], + "languages": [], + "ui": { + "toolWindows": [ + { + "id": "dev.lithe.fixture.backend.window", + "title": "Backend", + "placement": "bottom", + "web": { + "entry": "ui/index.html" + } + } + ] + }, + "backend": { + "launch": { + "kind": "packaged", + "path": "bin/backend" + }, + "arguments": [ + "--state", + "${stateDir}" + ] + } +} diff --git a/shared/contracts/plugins/v1/fixtures/gradle-build.plugin.json b/shared/contracts/plugins/v1/fixtures/build-command-output-file.plugin.json similarity index 98% rename from shared/contracts/plugins/v1/fixtures/gradle-build.plugin.json rename to shared/contracts/plugins/v1/fixtures/build-command-output-file.plugin.json index 508ed70ef2..7d5c974c0b 100644 --- a/shared/contracts/plugins/v1/fixtures/gradle-build.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/build-command-output-file.plugin.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, - "id": "dev.lithe.fixture.gradle-build", - "displayName": "Gradle Build Fixture", + "id": "dev.lithe.fixture.build-command-output-file", + "displayName": "Command Output File Build Fixture", "version": "0.1.0", "pluginAPIVersion": 3, "requires": { "buildSystems": 1 }, diff --git a/shared/contracts/plugins/v1/fixtures/go-build.plugin.json b/shared/contracts/plugins/v1/fixtures/build-command-stdout.plugin.json similarity index 97% rename from shared/contracts/plugins/v1/fixtures/go-build.plugin.json rename to shared/contracts/plugins/v1/fixtures/build-command-stdout.plugin.json index 84a6587ab5..8ed8855eca 100644 --- a/shared/contracts/plugins/v1/fixtures/go-build.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/build-command-stdout.plugin.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, - "id": "dev.lithe.fixture.go-build", - "displayName": "Go Build Fixture", + "id": "dev.lithe.fixture.build-command-stdout", + "displayName": "Command Stdout Build Fixture", "version": "0.1.0", "pluginAPIVersion": 3, "requires": { "buildSystems": 1 }, diff --git a/shared/contracts/plugins/v1/fixtures/maven-build.plugin.json b/shared/contracts/plugins/v1/fixtures/build-host-integration.plugin.json similarity index 98% rename from shared/contracts/plugins/v1/fixtures/maven-build.plugin.json rename to shared/contracts/plugins/v1/fixtures/build-host-integration.plugin.json index 683075e176..01c58909f7 100644 --- a/shared/contracts/plugins/v1/fixtures/maven-build.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/build-host-integration.plugin.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, - "id": "dev.lithe.fixture.maven-build", - "displayName": "Maven Build Fixture", + "id": "dev.lithe.fixture.build-host-integration", + "displayName": "Host Integration Build Fixture", "version": "0.1.0", "pluginAPIVersion": 3, "requires": { "buildSystems": 1 }, diff --git a/shared/contracts/plugins/v1/fixtures/build-plans.json b/shared/contracts/plugins/v1/fixtures/build-plans.json index 48f09e0122..4c7635778a 100644 --- a/shared/contracts/plugins/v1/fixtures/build-plans.json +++ b/shared/contracts/plugins/v1/fixtures/build-plans.json @@ -4,7 +4,7 @@ "cases": [ { "name": "cargo invocation is the cargo command", - "plugin": "cargo-build.plugin.json", + "plugin": "build-static-file.plugin.json", "buildSystemID": "cargo", "operation": "invocationPlan", "request": { @@ -35,7 +35,7 @@ }, { "name": "cargo version meets the minimum", - "plugin": "cargo-build.plugin.json", + "plugin": "build-static-file.plugin.json", "buildSystemID": "cargo", "operation": "checkToolVersion", "request": { @@ -51,7 +51,7 @@ }, { "name": "cargo version below the minimum", - "plugin": "cargo-build.plugin.json", + "plugin": "build-static-file.plugin.json", "buildSystemID": "cargo", "operation": "checkToolVersion", "request": { @@ -67,7 +67,7 @@ }, { "name": "cargo build puts options after the subcommand", - "plugin": "cargo-build.plugin.json", + "plugin": "build-static-file.plugin.json", "buildSystemID": "cargo", "operation": "taskPlan", "request": { @@ -107,7 +107,7 @@ }, { "name": "cargo example needs its target", - "plugin": "cargo-build.plugin.json", + "plugin": "build-static-file.plugin.json", "buildSystemID": "cargo", "operation": "taskPlan", "request": { @@ -143,7 +143,7 @@ }, { "name": "cargo example refuses a binary target", - "plugin": "cargo-build.plugin.json", + "plugin": "build-static-file.plugin.json", "buildSystemID": "cargo", "operation": "taskPlan", "request": { @@ -170,7 +170,7 @@ }, { "name": "cargo example refuses the project scope", - "plugin": "cargo-build.plugin.json", + "plugin": "build-static-file.plugin.json", "buildSystemID": "cargo", "operation": "taskPlan", "request": { @@ -191,7 +191,7 @@ }, { "name": "cargo test selects one exact case", - "plugin": "cargo-build.plugin.json", + "plugin": "build-static-file.plugin.json", "buildSystemID": "cargo", "operation": "testPlan", "request": { @@ -223,7 +223,7 @@ }, { "name": "cargo test selects an integration target", - "plugin": "cargo-build.plugin.json", + "plugin": "build-static-file.plugin.json", "buildSystemID": "cargo", "operation": "testPlan", "request": { @@ -263,7 +263,7 @@ }, { "name": "cargo test refuses a binary target", - "plugin": "cargo-build.plugin.json", + "plugin": "build-static-file.plugin.json", "buildSystemID": "cargo", "operation": "testPlan", "request": { @@ -289,7 +289,7 @@ }, { "name": "cargo test cannot select a suite", - "plugin": "cargo-build.plugin.json", + "plugin": "build-static-file.plugin.json", "buildSystemID": "cargo", "operation": "testPlan", "request": { @@ -310,7 +310,7 @@ }, { "name": "cargo options keep known values and drop the rest", - "plugin": "cargo-build.plugin.json", + "plugin": "build-static-file.plugin.json", "buildSystemID": "cargo", "operation": "resolveOptions", "request": { @@ -382,7 +382,7 @@ }, { "name": "cargo options accept model values before a model exists", - "plugin": "cargo-build.plugin.json", + "plugin": "build-static-file.plugin.json", "buildSystemID": "cargo", "operation": "resolveOptions", "request": { @@ -433,7 +433,7 @@ }, { "name": "cargo short diagnostics", - "plugin": "cargo-build.plugin.json", + "plugin": "build-static-file.plugin.json", "buildSystemID": "cargo", "operation": "decodeDiagnostics", "request": { @@ -463,7 +463,7 @@ }, { "name": "go runs through the host-managed runtime", - "plugin": "go-build.plugin.json", + "plugin": "build-command-stdout.plugin.json", "buildSystemID": "go", "operation": "invocationPlan", "request": { @@ -503,7 +503,7 @@ }, { "name": "go runtime version", - "plugin": "go-build.plugin.json", + "plugin": "build-command-stdout.plugin.json", "buildSystemID": "go", "operation": "checkToolVersion", "request": { @@ -520,7 +520,7 @@ }, { "name": "go runtime below the minimum", - "plugin": "go-build.plugin.json", + "plugin": "build-command-stdout.plugin.json", "buildSystemID": "go", "operation": "checkToolVersion", "request": { @@ -537,7 +537,7 @@ }, { "name": "go model is a json stream on stdout", - "plugin": "go-build.plugin.json", + "plugin": "build-command-stdout.plugin.json", "buildSystemID": "go", "operation": "modelPlan", "request": { @@ -566,7 +566,7 @@ }, { "name": "go list output becomes projects, targets, and run entries", - "plugin": "go-build.plugin.json", + "plugin": "build-command-stdout.plugin.json", "buildSystemID": "go", "operation": "decodeModel", "request": { @@ -680,7 +680,7 @@ }, { "name": "go dependencies of a module", - "plugin": "go-build.plugin.json", + "plugin": "build-command-stdout.plugin.json", "buildSystemID": "go", "operation": "dependencyPlan", "request": { @@ -714,7 +714,7 @@ }, { "name": "go module list skips the main module", - "plugin": "go-build.plugin.json", + "plugin": "build-command-stdout.plugin.json", "buildSystemID": "go", "operation": "decodeDependencies", "request": { @@ -748,7 +748,7 @@ }, { "name": "go test escapes the case name", - "plugin": "go-build.plugin.json", + "plugin": "build-command-stdout.plugin.json", "buildSystemID": "go", "operation": "testPlan", "request": { @@ -786,7 +786,7 @@ }, { "name": "go diagnostics relative to the module", - "plugin": "go-build.plugin.json", + "plugin": "build-command-stdout.plugin.json", "buildSystemID": "go", "operation": "decodeDiagnostics", "request": { @@ -815,7 +815,7 @@ }, { "name": "gradle prefers the unix wrapper", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "invocationPlan", "request": { @@ -874,7 +874,7 @@ }, { "name": "gradle prefers the batch wrapper on windows", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "invocationPlan", "request": { @@ -933,7 +933,7 @@ }, { "name": "gradle two-part version", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "checkToolVersion", "request": { @@ -949,7 +949,7 @@ }, { "name": "gradle model runs the init script into the output file", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "modelPlan", "request": { @@ -987,7 +987,7 @@ }, { "name": "gradle model needs an output file", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "modelPlan", "request": { @@ -1004,7 +1004,7 @@ }, { "name": "gradle build of one project", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "taskPlan", "request": { @@ -1041,7 +1041,7 @@ }, { "name": "gradle build of the whole build", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "taskPlan", "request": { @@ -1066,7 +1066,7 @@ }, { "name": "gradle model task runs by its path", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "taskPlan", "request": { @@ -1106,7 +1106,7 @@ }, { "name": "gradle arbitrary tasks need arguments", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "taskPlan", "request": { @@ -1138,7 +1138,7 @@ }, { "name": "gradle build takes no user arguments", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "taskPlan", "request": { @@ -1158,7 +1158,7 @@ }, { "name": "gradle test of one method", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "testPlan", "request": { @@ -1199,7 +1199,7 @@ }, { "name": "gradle test refuses a case without its suite", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "testPlan", "request": { @@ -1223,7 +1223,7 @@ }, { "name": "gradle workspace test collects every model project's reports", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "testPlan", "request": { @@ -1259,7 +1259,7 @@ }, { "name": "gradle dependencies of the root project", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "dependencyPlan", "request": { @@ -1293,7 +1293,7 @@ }, { "name": "gradle javac and kotlin diagnostics", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "decodeDiagnostics", "request": { @@ -1328,7 +1328,7 @@ }, { "name": "maven package of a nested module", - "plugin": "maven-build.plugin.json", + "plugin": "build-host-integration.plugin.json", "buildSystemID": "maven", "operation": "taskPlan", "request": { @@ -1373,7 +1373,7 @@ }, { "name": "maven clean of the reactor root", - "plugin": "maven-build.plugin.json", + "plugin": "build-host-integration.plugin.json", "buildSystemID": "maven", "operation": "taskPlan", "request": { @@ -1408,7 +1408,7 @@ }, { "name": "maven goal of a reactor below the workspace root", - "plugin": "maven-build.plugin.json", + "plugin": "build-host-integration.plugin.json", "buildSystemID": "maven", "operation": "taskPlan", "request": { @@ -1443,7 +1443,7 @@ }, { "name": "maven test of one method", - "plugin": "maven-build.plugin.json", + "plugin": "build-host-integration.plugin.json", "buildSystemID": "maven", "operation": "testPlan", "request": { @@ -1481,7 +1481,7 @@ }, { "name": "maven test refuses a case without its suite", - "plugin": "maven-build.plugin.json", + "plugin": "build-host-integration.plugin.json", "buildSystemID": "maven", "operation": "testPlan", "request": { @@ -1498,7 +1498,7 @@ }, { "name": "maven dependency tree of a nested module", - "plugin": "maven-build.plugin.json", + "plugin": "build-host-integration.plugin.json", "buildSystemID": "maven", "operation": "dependencyPlan", "request": { @@ -1552,7 +1552,7 @@ }, { "name": "maven dependency tree of the reactor root", - "plugin": "maven-build.plugin.json", + "plugin": "build-host-integration.plugin.json", "buildSystemID": "maven", "operation": "dependencyPlan", "request": { @@ -1592,7 +1592,7 @@ }, { "name": "maven compiler diagnostics", - "plugin": "maven-build.plugin.json", + "plugin": "build-host-integration.plugin.json", "buildSystemID": "maven", "operation": "decodeDiagnostics", "request": { @@ -1622,7 +1622,7 @@ }, { "name": "unknown build system", - "plugin": "maven-build.plugin.json", + "plugin": "build-host-integration.plugin.json", "buildSystemID": "gradle", "operation": "taskPlan", "request": { @@ -1639,7 +1639,7 @@ }, { "name": "gradle provider model is a request for the provider with the option values", - "plugin": "gradle-provider.plugin.json", + "plugin": "build-provider.plugin.json", "buildSystemID": "gradle", "operation": "modelPlan", "request": { @@ -1676,7 +1676,7 @@ }, { "name": "gradle provider model covers the whole instance", - "plugin": "gradle-provider.plugin.json", + "plugin": "build-provider.plugin.json", "buildSystemID": "gradle", "operation": "modelPlan", "request": { @@ -1696,7 +1696,7 @@ }, { "name": "gradle provider dependencies of a project name the project", - "plugin": "gradle-provider.plugin.json", + "plugin": "build-provider.plugin.json", "buildSystemID": "gradle", "operation": "dependencyPlan", "request": { @@ -1737,7 +1737,7 @@ }, { "name": "gradle provider model reload carries refresh and the resolved tool", - "plugin": "gradle-provider.plugin.json", + "plugin": "build-provider.plugin.json", "buildSystemID": "gradle", "operation": "modelPlan", "request": { @@ -1782,7 +1782,7 @@ }, { "name": "gradle provider model without a reload leaves refresh out", - "plugin": "gradle-provider.plugin.json", + "plugin": "build-provider.plugin.json", "buildSystemID": "gradle", "operation": "modelPlan", "request": { @@ -1820,7 +1820,7 @@ }, { "name": "gradle provider dependency reload carries refresh and the resolved tool", - "plugin": "gradle-provider.plugin.json", + "plugin": "build-provider.plugin.json", "buildSystemID": "gradle", "operation": "dependencyPlan", "request": { @@ -1869,7 +1869,7 @@ }, { "name": "gradle provider tool must be an absolute path", - "plugin": "gradle-provider.plugin.json", + "plugin": "build-provider.plugin.json", "buildSystemID": "gradle", "operation": "modelPlan", "request": { @@ -1891,7 +1891,7 @@ }, { "name": "gradle model command ignores the provider refresh and tool", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "modelPlan", "request": { @@ -1933,7 +1933,7 @@ }, { "name": "gradle provider model result becomes projects, tasks, and run entries", - "plugin": "gradle-provider.plugin.json", + "plugin": "build-provider.plugin.json", "buildSystemID": "gradle", "operation": "decodeModel", "request": { @@ -2079,7 +2079,7 @@ }, { "name": "gradle provider model needs the provider's result", - "plugin": "gradle-provider.plugin.json", + "plugin": "build-provider.plugin.json", "buildSystemID": "gradle", "operation": "decodeModel", "request": { @@ -2096,7 +2096,7 @@ }, { "name": "gradle provider dependency result is a litheDependencies tree", - "plugin": "gradle-provider.plugin.json", + "plugin": "build-provider.plugin.json", "buildSystemID": "gradle", "operation": "decodeDependencies", "request": { @@ -2149,7 +2149,7 @@ }, { "name": "gradle provider dependency result of another format is refused", - "plugin": "gradle-provider.plugin.json", + "plugin": "build-provider.plugin.json", "buildSystemID": "gradle", "operation": "decodeDependencies", "request": { @@ -2205,7 +2205,7 @@ }, { "name": "mix model goes to the shared language server", - "plugin": "mix-provider.plugin.json", + "plugin": "build-provider-language-server.plugin.json", "buildSystemID": "mix", "operation": "modelPlan", "request": { @@ -2235,7 +2235,7 @@ }, { "name": "mix provider lists dependencies of the workspace only", - "plugin": "mix-provider.plugin.json", + "plugin": "build-provider-language-server.plugin.json", "buildSystemID": "mix", "operation": "dependencyPlan", "request": { @@ -2255,7 +2255,7 @@ }, { "name": "mix dependencies of the workspace", - "plugin": "mix-provider.plugin.json", + "plugin": "build-provider-language-server.plugin.json", "buildSystemID": "mix", "operation": "dependencyPlan", "request": { @@ -2286,7 +2286,7 @@ }, { "name": "maven accepts Java 8 stderr with environment options", - "plugin": "maven-build.plugin.json", + "plugin": "build-host-integration.plugin.json", "buildSystemID": "maven", "operation": "checkToolVersion", "request": { @@ -2303,7 +2303,7 @@ }, { "name": "maven rejects Java 7 against minimum 8", - "plugin": "maven-build.plugin.json", + "plugin": "build-host-integration.plugin.json", "buildSystemID": "maven", "operation": "checkToolVersion", "request": { @@ -2320,7 +2320,7 @@ }, { "name": "gradle reports Java 8 as below minimum 17", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "checkToolVersion", "request": { diff --git a/shared/contracts/plugins/v1/fixtures/mix-provider.plugin.json b/shared/contracts/plugins/v1/fixtures/build-provider-language-server.plugin.json similarity index 93% rename from shared/contracts/plugins/v1/fixtures/mix-provider.plugin.json rename to shared/contracts/plugins/v1/fixtures/build-provider-language-server.plugin.json index 9f963638c0..39967483b8 100644 --- a/shared/contracts/plugins/v1/fixtures/mix-provider.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/build-provider-language-server.plugin.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, - "id": "dev.lithe.fixture.mix-provider", - "displayName": "Mix Provider Fixture", + "id": "dev.lithe.fixture.build-provider-language-server", + "displayName": "Build Provider With Language Server Fixture", "version": "0.1.0", "pluginAPIVersion": 3, "requires": { "buildProviders": 1, "buildSystems": 1, "languageServers": 1, "languages": 1, "serverProcess": 1 }, diff --git a/shared/contracts/plugins/v1/fixtures/gradle-provider.plugin.json b/shared/contracts/plugins/v1/fixtures/build-provider.plugin.json similarity index 97% rename from shared/contracts/plugins/v1/fixtures/gradle-provider.plugin.json rename to shared/contracts/plugins/v1/fixtures/build-provider.plugin.json index 68e352a324..512f7010f8 100644 --- a/shared/contracts/plugins/v1/fixtures/gradle-provider.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/build-provider.plugin.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, - "id": "dev.lithe.fixture.gradle-provider", - "displayName": "Gradle Provider Fixture", + "id": "dev.lithe.fixture.build-provider", + "displayName": "Build Provider Fixture", "version": "0.1.0", "pluginAPIVersion": 3, "requires": { "buildProviders": 1, "buildSystems": 1, "serverProcess": 1, "settings": 1 }, diff --git a/shared/contracts/plugins/v1/fixtures/cargo-build.plugin.json b/shared/contracts/plugins/v1/fixtures/build-static-file.plugin.json similarity index 97% rename from shared/contracts/plugins/v1/fixtures/cargo-build.plugin.json rename to shared/contracts/plugins/v1/fixtures/build-static-file.plugin.json index c8b1f7ab45..7844177174 100644 --- a/shared/contracts/plugins/v1/fixtures/cargo-build.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/build-static-file.plugin.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, - "id": "dev.lithe.fixture.cargo-build", - "displayName": "Cargo Build Fixture", + "id": "dev.lithe.fixture.build-static-file", + "displayName": "Static File Build Fixture", "version": "0.1.0", "pluginAPIVersion": 3, "requires": { "buildSystems": 1 }, diff --git a/shared/contracts/plugins/v1/fixtures/build-tool-path.plugin.json b/shared/contracts/plugins/v1/fixtures/build-tool-path.plugin.json new file mode 100644 index 0000000000..3115aac8a5 --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/build-tool-path.plugin.json @@ -0,0 +1,74 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.build-tool-path", + "displayName": "Build path fixture", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": { + "buildSystems": 1, + "settings": 2, + "executableResolution": 1 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "process.build" + ], + "activationEvents": [ + "onBuildSystem:fixture" + ], + "languages": [], + "settings": [ + { + "id": "toolPath", + "type": "file", + "title": "Build tool", + "default": "", + "scope": "global", + "effect": "reload", + "page": { + "buildSystemID": "fixture" + } + } + ], + "buildSystems": [ + { + "id": "fixture", + "displayName": "Fixture", + "detection": { + "markers": [ + "build.fixture" + ] + }, + "invocation": { + "executables": [ + { + "id": "selected", + "kind": "setting", + "settingID": "toolPath" + }, + { + "kind": "command", + "command": "fixture-build" + } + ] + }, + "tasks": [ + { + "id": "build", + "displayName": "Build", + "group": "build", + "scopes": [ + "workspace" + ], + "arguments": [ + "build" + ] + } + ] + } + ] +} diff --git a/shared/contracts/plugins/v1/fixtures/command-plans.json b/shared/contracts/plugins/v1/fixtures/command-plans.json index dd3c9afba9..5c1994c4b5 100644 --- a/shared/contracts/plugins/v1/fixtures/command-plans.json +++ b/shared/contracts/plugins/v1/fixtures/command-plans.json @@ -4,7 +4,7 @@ "cases": [ { "name": "php run passes user arguments after the file", - "plugin": "php-support.plugin.json", + "plugin": "language-packaged-server.plugin.json", "languageID": "php", "operation": "runPlan", "request": { "relativeFilePath": "public/index.php", "arguments": ["--verbose", "two words"] }, @@ -15,7 +15,7 @@ }, { "name": "php run protects a file name that looks like an option", - "plugin": "php-support.plugin.json", + "plugin": "language-packaged-server.plugin.json", "languageID": "php", "operation": "runPlan", "request": { "relativeFilePath": "-weird.php" }, @@ -26,7 +26,7 @@ }, { "name": "php run keeps surrounding spaces in a file name", - "plugin": "php-support.plugin.json", + "plugin": "language-packaged-server.plugin.json", "languageID": "php", "operation": "runPlan", "request": { "relativeFilePath": " spaced.php" }, @@ -37,7 +37,7 @@ }, { "name": "php run refuses a path outside the workspace", - "plugin": "php-support.plugin.json", + "plugin": "language-packaged-server.plugin.json", "languageID": "php", "operation": "runPlan", "request": { "relativeFilePath": "../outside.php" }, @@ -45,7 +45,7 @@ }, { "name": "php discovery lists the workspace then sorted test files", - "plugin": "php-support.plugin.json", + "plugin": "language-packaged-server.plugin.json", "languageID": "php", "operation": "discoverTests", "request": { "relativeProjectFilePaths": ["tests/StoreTest.php", "composer.json", "src/Store.php", "tests/Helpers.php", "tests/LegacyTEST.php"] }, @@ -60,7 +60,7 @@ }, { "name": "php discovery returns nothing outside a PHP project", - "plugin": "php-support.plugin.json", + "plugin": "language-packaged-server.plugin.json", "languageID": "php", "operation": "discoverTests", "request": { "relativeProjectFilePaths": ["README.md", "index.php", "tests/IndexTest.php"] }, @@ -68,7 +68,7 @@ }, { "name": "php workspace test", - "plugin": "php-support.plugin.json", + "plugin": "language-packaged-server.plugin.json", "languageID": "php", "operation": "testPlan", "request": { "scope": { "kind": "workspace" }, "relativeProjectFilePaths": ["phpunit.xml.dist"] }, @@ -79,7 +79,7 @@ }, { "name": "php file test", - "plugin": "php-support.plugin.json", + "plugin": "language-packaged-server.plugin.json", "languageID": "php", "operation": "testPlan", "request": { "scope": { "kind": "file", "relativeFilePath": "tests/StoreTest.php" }, "relativeProjectFilePaths": ["composer.json"] }, @@ -90,7 +90,7 @@ }, { "name": "php single test case with its file", - "plugin": "php-support.plugin.json", + "plugin": "language-packaged-server.plugin.json", "languageID": "php", "operation": "testPlan", "request": { "scope": { "kind": "testCase", "identifier": " testKeepsInsertionOrder ", "relativeFilePath": "tests/StoreTest.php" }, "relativeProjectFilePaths": ["composer.json"] }, @@ -101,7 +101,7 @@ }, { "name": "php single test case escapes regex characters and omits the missing file", - "plugin": "php-support.plugin.json", + "plugin": "language-packaged-server.plugin.json", "languageID": "php", "operation": "testPlan", "request": { "scope": { "kind": "testCase", "identifier": "testA.b/c" }, "relativeProjectFilePaths": ["composer.json"] }, @@ -112,7 +112,7 @@ }, { "name": "php blank test identifier", - "plugin": "php-support.plugin.json", + "plugin": "language-packaged-server.plugin.json", "languageID": "php", "operation": "testPlan", "request": { "scope": { "kind": "testCase", "identifier": " " }, "relativeProjectFilePaths": ["composer.json"] }, @@ -120,7 +120,7 @@ }, { "name": "php test outside a PHP project", - "plugin": "php-support.plugin.json", + "plugin": "language-packaged-server.plugin.json", "languageID": "php", "operation": "testPlan", "request": { "scope": { "kind": "workspace" }, "relativeProjectFilePaths": ["index.php"] }, @@ -128,7 +128,7 @@ }, { "name": "language without an execution provider", - "plugin": "php-support.plugin.json", + "plugin": "language-packaged-server.plugin.json", "languageID": "python", "operation": "runPlan", "request": { "relativeFilePath": "main.py" }, @@ -136,7 +136,7 @@ }, { "name": "go run uses the project toolchain", - "plugin": "go-support.plugin.json", + "plugin": "language-system-command.plugin.json", "languageID": "go", "operation": "runPlan", "request": { "relativeFilePath": "cmd/app/main.go", "arguments": ["-port", "8080"] }, @@ -147,7 +147,7 @@ }, { "name": "go discovery", - "plugin": "go-support.plugin.json", + "plugin": "language-system-command.plugin.json", "languageID": "go", "operation": "discoverTests", "request": { "relativeProjectFilePaths": ["go.mod", "main.go", "pkg/store/store_test.go", "main_test.go"] }, @@ -162,7 +162,7 @@ }, { "name": "go file test runs the file's package", - "plugin": "go-support.plugin.json", + "plugin": "language-system-command.plugin.json", "languageID": "go", "operation": "testPlan", "request": { "scope": { "kind": "file", "relativeFilePath": "pkg/store/store_test.go" }, "relativeProjectFilePaths": ["go.mod"] }, @@ -173,7 +173,7 @@ }, { "name": "go test case in a root file targets the root package", - "plugin": "go-support.plugin.json", + "plugin": "language-system-command.plugin.json", "languageID": "go", "operation": "testPlan", "request": { "scope": { "kind": "testCase", "identifier": "TestMain", "relativeFilePath": "main_test.go" }, "relativeProjectFilePaths": ["go.work"] }, @@ -184,7 +184,7 @@ }, { "name": "go test case without a file searches every package", - "plugin": "go-support.plugin.json", + "plugin": "language-system-command.plugin.json", "languageID": "go", "operation": "testPlan", "request": { "scope": { "kind": "testCase", "identifier": "TestStore/sub(case)" }, "relativeProjectFilePaths": ["go.mod"] }, @@ -195,7 +195,7 @@ }, { "name": "java launch pins the packaged runtime and lists the jdt integration resources", - "plugin": "java-support.plugin.json", + "plugin": "language-host-integration.plugin.json", "languageID": "java", "operation": "languageServerLaunchPlan", "request": { @@ -252,7 +252,7 @@ }, { "name": "java launch selects per-architecture package paths and drops a trailing separator", - "plugin": "java-support.plugin.json", + "plugin": "language-host-integration.plugin.json", "languageID": "java", "operation": "languageServerLaunchPlan", "request": { @@ -579,7 +579,7 @@ }, { "name": "php launch keeps API 1 arguments literal and names the packaged executable", - "plugin": "php-support.plugin.json", + "plugin": "language-packaged-server.plugin.json", "languageID": "php", "operation": "languageServerLaunchPlan", "request": { @@ -613,7 +613,7 @@ }, { "name": "go launch resolves a system command and needs nothing from the package", - "plugin": "go-support.plugin.json", + "plugin": "language-system-command.plugin.json", "languageID": "go", "operation": "languageServerLaunchPlan", "request": { @@ -647,7 +647,7 @@ }, { "name": "launch plan refuses an unknown architecture", - "plugin": "java-support.plugin.json", + "plugin": "language-host-integration.plugin.json", "languageID": "java", "operation": "languageServerLaunchPlan", "request": { @@ -663,7 +663,7 @@ }, { "name": "launch plan refuses an empty plugin directory", - "plugin": "java-support.plugin.json", + "plugin": "language-host-integration.plugin.json", "languageID": "java", "operation": "languageServerLaunchPlan", "request": { @@ -679,7 +679,7 @@ }, { "name": "launch plan refuses a language without a server", - "plugin": "java-support.plugin.json", + "plugin": "language-host-integration.plugin.json", "languageID": "kotlin", "operation": "languageServerLaunchPlan", "request": { @@ -695,7 +695,7 @@ }, { "name": "gradle provider launch is a java runtime launch selected by build system", - "plugin": "gradle-provider.plugin.json", + "plugin": "build-provider.plugin.json", "buildSystemID": "gradle", "operation": "languageServerLaunchPlan", "request": { @@ -757,7 +757,7 @@ }, { "name": "mix provider launch is the elixir language server plan and names that server", - "plugin": "mix-provider.plugin.json", + "plugin": "build-provider-language-server.plugin.json", "buildSystemID": "mix", "operation": "languageServerLaunchPlan", "request": { @@ -793,7 +793,7 @@ }, { "name": "elixir language server launch carries no build provider", - "plugin": "mix-provider.plugin.json", + "plugin": "build-provider-language-server.plugin.json", "languageID": "elixir", "operation": "languageServerLaunchPlan", "request": { @@ -823,7 +823,7 @@ }, { "name": "launch plan refuses a build system without a provider", - "plugin": "cargo-build.plugin.json", + "plugin": "build-static-file.plugin.json", "buildSystemID": "cargo", "operation": "languageServerLaunchPlan", "request": { @@ -839,7 +839,7 @@ }, { "name": "launch plan refuses an undeclared build system", - "plugin": "gradle-provider.plugin.json", + "plugin": "build-provider.plugin.json", "buildSystemID": "maven", "operation": "languageServerLaunchPlan", "request": { diff --git a/shared/contracts/plugins/v1/fixtures/function-protocol.json b/shared/contracts/plugins/v1/fixtures/function-protocol.json index 9cbf35e519..98d1326320 100644 --- a/shared/contracts/plugins/v1/fixtures/function-protocol.json +++ b/shared/contracts/plugins/v1/fixtures/function-protocol.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "protocol": "litheFunctions", - "offer": { "versions": [1] }, + "offer": { "versions": [1, 2] }, "negotiation": [ { "name": "no answer leaves the protocol off", @@ -36,9 +36,14 @@ }, "expected": { "functions": ["install.finish", "install.prepare"] } }, + { + "name": "version 2 negotiates the same registry and adds host services", + "capabilities": { "experimental": { "litheFunctions": { "version": 2, "functions": [{ "id": "query.run" }] } } }, + "expected": { "functions": ["query.run"], "version": 2 } + }, { "name": "a version the host never offered is not negotiated", - "capabilities": { "experimental": { "litheFunctions": { "version": 2 } } }, + "capabilities": { "experimental": { "litheFunctions": { "version": 3 } } }, "expected": { "functions": null } }, { @@ -266,5 +271,87 @@ "method": "workspace/applyEdit", "expected": { "code": null } } + ], + "hostRequests": [ + { + "name": "version 1 sessions get no host service even when granted", + "version": 1, + "hostServices": ["credentials"], + "method": "lithe/host/credentials/get", + "params": { "key": "connection.prod" }, + "expected": { "rpcCode": -32601, "code": "plugin.host.unavailable" } + }, + { + "name": "a granted credential read is forwarded to the host", + "version": 2, + "hostServices": ["credentials"], + "method": "lithe/host/credentials/get", + "params": { "key": "connection.prod" }, + "expected": { "forward": "credentials" } + }, + { + "name": "a credential write carries the secret", + "version": 2, + "hostServices": ["credentials"], + "method": "lithe/host/credentials/set", + "params": { "key": "connection.prod:password", "secret": "s3cret" }, + "expected": { "forward": "credentials" } + }, + { + "name": "a credential delete names only the key", + "version": 2, + "hostServices": ["credentials"], + "method": "lithe/host/credentials/delete", + "params": { "key": "connection.prod" }, + "expected": { "forward": "credentials" } + }, + { + "name": "a service the plugin's permissions do not grant is refused", + "version": 2, + "hostServices": [], + "method": "lithe/host/credentials/get", + "params": { "key": "connection.prod" }, + "expected": { "rpcCode": -32601, "code": "plugin.host.permissionDenied" } + }, + { + "name": "a host method version 2 does not define is unavailable", + "version": 2, + "hostServices": ["credentials"], + "method": "lithe/host/process.run", + "params": {}, + "expected": { "rpcCode": -32601, "code": "plugin.host.unavailable" } + }, + { + "name": "a key outside the alphabet is invalid", + "version": 2, + "hostServices": ["credentials"], + "method": "lithe/host/credentials/get", + "params": { "key": "../other-plugin" }, + "expected": { "rpcCode": -32602, "code": "plugin.host.invalidParams" } + }, + { + "name": "an empty key is invalid", + "version": 2, + "hostServices": ["credentials"], + "method": "lithe/host/credentials/delete", + "params": { "key": "" }, + "expected": { "rpcCode": -32602, "code": "plugin.host.invalidParams" } + }, + { + "name": "a write without a string secret is invalid", + "version": 2, + "hostServices": ["credentials"], + "method": "lithe/host/credentials/set", + "params": { "key": "connection.prod", "secret": 42 }, + "expected": { "rpcCode": -32602, "code": "plugin.host.invalidParams" } + }, + { + "name": "unknown params are invalid so a later version can add them", + "version": 2, + "hostServices": ["credentials"], + "method": "lithe/host/credentials/get", + "params": { "key": "connection.prod", "scope": "global" }, + "expected": { "rpcCode": -32602, "code": "plugin.host.invalidParams" } + } ] } diff --git a/shared/contracts/plugins/v1/fixtures/invalid-manifests.json b/shared/contracts/plugins/v1/fixtures/invalid-manifests.json index 967ce21f2f..79b27a9fc7 100644 --- a/shared/contracts/plugins/v1/fixtures/invalid-manifests.json +++ b/shared/contracts/plugins/v1/fixtures/invalid-manifests.json @@ -4,133 +4,133 @@ "cases": [ { "name": "legacy native manifest schema", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/schemaVersion", "value": 1 }], "expectedCode": "plugin.schema.unsupported" }, { "name": "future plugin API", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/pluginAPIVersion", "value": 99 }], "expectedCode": "plugin.api.unsupported" }, { "name": "unknown top-level field", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/entrypoint", "value": { "kind": "nativeBundle" } }], "expectedCode": "plugin.manifest.invalid" }, { "name": "uppercase plugin id", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/id", "value": "Dev.Lithe.Fixture" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "packaged language server escapes the package", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/launch/path", "value": "../bin/intelephense" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "system command is a path", - "base": "go-support.plugin.json", + "base": "language-system-command.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/launch/commands", "value": ["/usr/local/bin/gopls"] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "language server for an undeclared language", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/languageID", "value": "hack" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "unsupported runtime dependency", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/runtimeDependencies", "value": [{ "tool": "python" }] }], "expectedCode": "plugin.runtime.unsupported" }, { "name": "unknown permission", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/permissions/0", "value": "network.fetch" }], "expectedCode": "plugin.permission.unknown" }, { "name": "language server without its permission", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/permissions", "value": ["process.run", "process.test"] }], "expectedCode": "plugin.permission.undeclared" }, { "name": "missing activation event", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/activationEvents", "value": ["onLanguage:php", "onRun:php"] }], "expectedCode": "plugin.activation.invalid" }, { "name": "activation event without a provider", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "remove", "path": "/executionProviders/0/run" }], "expectedCode": "plugin.activation.invalid" }, { "name": "unknown template variable", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/executionProviders/0/run/arguments", "value": ["${workspaceRoot}/${file}"] }], "expectedCode": "plugin.template.invalid" }, { "name": "user arguments embedded in an element", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/executionProviders/0/run/arguments", "value": ["${file}", "--args=${userArguments}"] }], "expectedCode": "plugin.template.invalid" }, { "name": "test case uses the file without a guard", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/executionProviders/0/test/testCaseArguments", "value": ["vendor/bin/phpunit", "--filter", "${testName:regex}", "${file}"] }], "expectedCode": "plugin.template.invalid" }, { "name": "test case never names the test", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/executionProviders/0/test/testCaseArguments", "value": ["vendor/bin/phpunit"] }], "expectedCode": "plugin.template.invalid" }, { "name": "executable is an absolute path", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/executionProviders/0/run/executable", "value": { "command": "/usr/bin/php" } }], "expectedCode": "plugin.manifest.invalid" }, { "name": "runtime launch in an API 1 manifest", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/launch", "value": { "kind": "runtime", "tool": "node", "entry": "language-server/lib/intelephense.js" } }, { "op": "set", "path": "/languageServers/0/runtimeDependencies", "value": [] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "initialization options in an API 1 manifest", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/initializationOptions", "value": { "storagePath": "cache" } }], "expectedCode": "plugin.manifest.invalid" }, { "name": "host integration in an API 1 manifest", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/hostIntegration", "value": { "id": "jdt" } }], "expectedCode": "plugin.manifest.invalid" }, { "name": "runtime dependency states a minimum version", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/runtimeDependencies", "value": [{ "tool": "node", "minimumVersion": "18" }] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "java runtime in an API 1 manifest", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/runtimeDependencies", "value": [{ "tool": "java" }] }], "expectedCode": "plugin.runtime.unsupported" }, @@ -166,19 +166,19 @@ }, { "name": "packaged runtime escapes the package", - "base": "java-support.plugin.json", + "base": "language-host-integration.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/launch/packagedRuntime", "value": "../jdk/bin/java" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "packaged runtime varies by something other than the architecture", - "base": "java-support.plugin.json", + "base": "language-host-integration.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/launch/packagedRuntime", "value": "${stateDir}/jdk/bin/java" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "packaged runtime also states a minimum version", - "base": "java-support.plugin.json", + "base": "language-host-integration.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/launch/minimumVersion", "value": "21" }], "expectedCode": "plugin.manifest.invalid" }, @@ -226,61 +226,61 @@ }, { "name": "unknown host integration", - "base": "java-support.plugin.json", + "base": "language-host-integration.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/hostIntegration/id", "value": "roslyn" }], "expectedCode": "plugin.integration.unsupported" }, { "name": "host integration requested outside the official namespace", - "base": "java-support.plugin.json", + "base": "language-host-integration.plugin.json", "edits": [{ "op": "set", "path": "/id", "value": "io.example.java" }, { "op": "set", "path": "/vendor/signatureRequirement", "value": "none" }], "expectedCode": "plugin.integration.unsupported" }, { "name": "jdt integration without a java runtime launch", - "base": "java-support.plugin.json", + "base": "language-host-integration.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/launch", "value": { "kind": "packaged", "path": "language-server/bin/jdtls" } }], "expectedCode": "plugin.manifest.invalid" }, { "name": "jdt integration misses a required resource", - "base": "java-support.plugin.json", + "base": "language-host-integration.plugin.json", "edits": [{ "op": "remove", "path": "/languageServers/0/hostIntegration/resources/configuration" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "jdt integration declares a resource it does not use", - "base": "java-support.plugin.json", + "base": "language-host-integration.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/hostIntegration/resources/indexer", "value": "language-server/indexer.jar" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "integration resource leaves the package", - "base": "java-support.plugin.json", + "base": "language-host-integration.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/hostIntegration/resources/lombokAgent", "value": "../lombok.jar" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "integration resource list given as a single path", - "base": "java-support.plugin.json", + "base": "language-host-integration.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/hostIntegration/resources/extensionBundleDirectories", "value": "language-server/java-debug" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "integration capability the integration does not provide", - "base": "java-support.plugin.json", + "base": "language-host-integration.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/hostIntegration/capabilities", "value": ["formatting"] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "integration capability without the resource its host code reads", - "base": "java-support.plugin.json", + "base": "language-host-integration.plugin.json", "edits": [{ "op": "remove", "path": "/languageServers/0/hostIntegration/resources/testRunner" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "integration capability that the plugin's execution provider already provides", - "base": "java-support.plugin.json", + "base": "language-host-integration.plugin.json", "edits": [ { "op": "set", "path": "/permissions", "value": ["languageServer.launch", "process.run"] }, { "op": "set", "path": "/activationEvents", "value": ["onLanguage:java", "onRun:java"] }, @@ -295,7 +295,7 @@ ], "expectedCode": "plugin.manifest.invalid" }, - {"name": "settings on API level 1", "base": "php-support.plugin.json", "edits": [{"op": "set", "path": "/settings", "value": [{"id": "x", "type": "boolean", "title": "X", "default": true}]}], "expectedCode": "plugin.manifest.invalid"}, + {"name": "settings on API level 1", "base": "language-packaged-server.plugin.json", "edits": [{"op": "set", "path": "/settings", "value": [{"id": "x", "type": "boolean", "title": "X", "default": true}]}], "expectedCode": "plugin.manifest.invalid"}, {"name": "launch template names an undeclared setting", "base": "node-runtime.plugin.json", "edits": [{"op": "set", "path": "/languageServers/0/arguments/1", "value": "--log-level=${setting:verbosity}"}], "expectedCode": "plugin.template.invalid"}, {"name": "setting shapes a package path", "base": "node-runtime.plugin.json", "edits": [{"op": "set", "path": "/languageServers/0/launch/runtimeArguments/1", "value": "--require=${pluginDir}/language-server/${setting:logLevel}.js"}], "expectedCode": "plugin.template.invalid"}, {"name": "setting identifier with a separator", "base": "node-runtime.plugin.json", "edits": [{"op": "set", "path": "/settings/0/id", "value": "heap-mb"}], "expectedCode": "plugin.manifest.invalid"}, @@ -449,7 +449,7 @@ }, { "name": "build systems need plugin API 3", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [ { "op": "set", "path": "/pluginAPIVersion", "value": 2 }, { "op": "remove", "path": "/requires" } @@ -458,25 +458,25 @@ }, { "name": "build systems need the build permission", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/permissions", "value": [] }], "expectedCode": "plugin.permission.undeclared" }, { "name": "build systems need their activation event", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/activationEvents", "value": [] }], "expectedCode": "plugin.activation.invalid" }, { "name": "build activation names an undeclared build system", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/activationEvents", "value": ["onBuildSystem:cargo", "onBuildSystem:bazel"] }], "expectedCode": "plugin.activation.invalid" }, { "name": "build system ID is not an identifier", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [ { "op": "set", "path": "/buildSystems/0/id", "value": "Cargo" }, { "op": "set", "path": "/activationEvents", "value": ["onBuildSystem:Cargo"] } @@ -485,7 +485,7 @@ }, { "name": "host integration is reserved to official plugins", - "base": "maven-build.plugin.json", + "base": "build-host-integration.plugin.json", "edits": [ { "op": "set", "path": "/id", "value": "org.example.maven-build" }, { "op": "set", "path": "/vendor/id", "value": "org.example" } @@ -494,97 +494,97 @@ }, { "name": "host integration the host does not provide", - "base": "maven-build.plugin.json", + "base": "build-host-integration.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/hostIntegration", "value": "sbt" }], "expectedCode": "plugin.integration.unsupported" }, { "name": "host-integration model without a host integration", - "base": "maven-build.plugin.json", + "base": "build-host-integration.plugin.json", "edits": [{ "op": "remove", "path": "/buildSystems/0/hostIntegration" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "option arguments placed twice", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/tasks/0/arguments", "value": ["build", "${optionArguments}", "${optionArguments:release}"] }], "expectedCode": "plugin.template.invalid" }, { "name": "option arguments name an undeclared option", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/tasks/2/arguments", "value": ["clean", "${optionArguments:profile}"] }], "expectedCode": "plugin.template.invalid" }, { "name": "target variable without its guard", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/test/arguments", "value": ["test", "${target.name}"] }], "expectedCode": "plugin.template.invalid" }, { "name": "test case variable without its guard", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/test/arguments", "value": ["test", "--", "${test.case}"] }], "expectedCode": "plugin.template.invalid" }, { "name": "test variables outside a test command", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/tasks/0/arguments", "value": ["build", "${test.case}"] }], "expectedCode": "plugin.template.invalid" }, { "name": "user arguments in a model command", - "base": "go-build.plugin.json", + "base": "build-command-stdout.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/model/arguments", "value": ["list", "-json", "${userArguments}"] }], "expectedCode": "plugin.template.invalid" }, { "name": "output file without the file channel", - "base": "gradle-build.plugin.json", + "base": "build-command-output-file.plugin.json", "edits": [{ "op": "remove", "path": "/buildSystems/0/model/output" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "file channel without the output file", - "base": "gradle-build.plugin.json", + "base": "build-command-output-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/model/arguments", "value": ["-q", "litheModel"] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "plugin directory in a task", - "base": "gradle-build.plugin.json", + "base": "build-command-output-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/tasks/1/arguments", "value": ["--init-script", "${pluginDir}/x.gradle", "build"] }], "expectedCode": "plugin.template.invalid" }, { "name": "user arguments required but never placed", - "base": "maven-build.plugin.json", + "base": "build-host-integration.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/tasks/4/arguments", "value": ["validate"] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "run entries from a task that is not a model template", - "base": "gradle-build.plugin.json", + "base": "build-command-output-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/run/0/fromTasks/templates", "value": ["build"] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "host-managed runtime with its own executables", - "base": "go-build.plugin.json", + "base": "build-command-stdout.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/invocation/runtimeDependencies/0/executables", "value": [{ "kind": "command", "command": "go" }] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "runtime executable without its dependency", - "base": "go-build.plugin.json", + "base": "build-command-stdout.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/invocation/runtimeDependencies", "value": [] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "plugin-described runtime without executables", - "base": "go-build.plugin.json", + "base": "build-command-stdout.plugin.json", "edits": [ { "op": "set", "path": "/buildSystems/0/invocation/executables/0/runtime", "value": "zig" }, { "op": "set", "path": "/buildSystems/0/invocation/runtimeDependencies/0/id", "value": "zig" } @@ -593,229 +593,229 @@ }, { "name": "environment executable not declared as an input", - "base": "gradle-build.plugin.json", + "base": "build-command-output-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/invocation/environmentInputs", "value": [] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "project-file executable leaves the root", - "base": "gradle-build.plugin.json", + "base": "build-command-output-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/invocation/executables/0/path", "value": "../gradlew" }], "expectedCode": "plugin.path.invalid" }, { "name": "version pattern without a capture group", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/invocation/version/pattern", "value": "cargo \\d+" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "minimum version without a probe", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "remove", "path": "/buildSystems/0/invocation/version" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "static model file leaves the root", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/model/file", "value": "../Cargo.toml" }], "expectedCode": "plugin.path.invalid" }, { "name": "selector without a pointer", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/model/map/projects/0/name", "value": "name" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "literal field with a control character", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/model/map/projects/0/contentRoots/0/path", "value": { "value": "src\n" } }], "expectedCode": "plugin.manifest.invalid" }, { "name": "predicate with both equals and exists", - "base": "go-build.plugin.json", + "base": "build-command-stdout.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/dependencies/map/where/0/equals", "value": true }], "expectedCode": "plugin.manifest.invalid" }, { "name": "a static model in a format the map does not read", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/model/decoder", "value": "yaml" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "a model command printing a single JSON document reads it as jsonStream", - "base": "go-build.plugin.json", + "base": "build-command-stdout.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/model/decoder", "value": "json" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "a model map lists no tasks", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/model/map/projects/0/tasks", "value": [] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "a model map has no top-level targets", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/model/map/targets", "value": [] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "a project rule names no parent", - "base": "go-build.plugin.json", + "base": "build-command-stdout.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/model/map/projects/0/parent", "value": "/Dir" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "target tags are a literal list", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/model/map/projects/0/targets/0/tags", "value": { "from": "/kind", "map": { "bin": ["application"] } } }], "expectedCode": "plugin.manifest.invalid" }, { "name": "lastSegment is the only transform", - "base": "go-build.plugin.json", + "base": "build-command-stdout.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/model/map/projects/0/targets/0/properties/short/transform", "value": "parentDirectory" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "a dependency map is flat", - "base": "go-build.plugin.json", + "base": "build-command-stdout.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/dependencies/map/children", "value": "/Deps/*" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "layout lists only excluded directories", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/layout/generated", "value": ["target/generated"] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "diagnostic capture group beyond the pattern", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/diagnostics/patterns/0/message", "value": 9 }], "expectedCode": "plugin.manifest.invalid" }, { "name": "option environment name is not uppercase", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/options/0/environment", "value": { "cargo_release": "1" } }], "expectedCode": "plugin.manifest.invalid" }, { "name": "sync watch leaves the root", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/sync/watch", "value": ["../Cargo.toml"] }], "expectedCode": "plugin.path.invalid" }, { "name": "test target tags without the target scope", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/test/scopes", "value": ["project"] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "provider that no source uses", - "base": "gradle-build.plugin.json", + "base": "build-command-output-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/provider", "value": { "launch": { "kind": "systemCommand", "commands": ["gradle-provider"] }, "executesProjectCode": true, "mayUseNetwork": true } }], "expectedCode": "plugin.manifest.invalid" }, { "name": "provider source without a provider", - "base": "gradle-provider.plugin.json", + "base": "build-provider.plugin.json", "edits": [{ "op": "remove", "path": "/buildSystems/0/provider" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "shared provider names a language without a language server", - "base": "mix-provider.plugin.json", + "base": "build-provider-language-server.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/provider/languageServer", "value": "erlang" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "provider mixes the shared and own forms", - "base": "mix-provider.plugin.json", + "base": "build-provider-language-server.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/provider/launch", "value": { "kind": "systemCommand", "commands": ["mix-provider"] } }], "expectedCode": "plugin.manifest.invalid" }, { "name": "provider without its execution flags", - "base": "gradle-provider.plugin.json", + "base": "build-provider.plugin.json", "edits": [{ "op": "remove", "path": "/buildSystems/0/provider/executesProjectCode" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "own provider without the language server permission", - "base": "gradle-provider.plugin.json", + "base": "build-provider.plugin.json", "edits": [{ "op": "set", "path": "/permissions", "value": ["process.build"] }], "expectedCode": "plugin.permission.undeclared" }, { "name": "provider entry leaves the package", - "base": "gradle-provider.plugin.json", + "base": "build-provider.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/provider/launch/entry", "value": "../provider.jar" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "provider runtime the host cannot start", - "base": "gradle-provider.plugin.json", + "base": "build-provider.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/provider/launch/tool", "value": "go" }], "expectedCode": "plugin.runtime.unsupported" }, { "name": "provider argument names an undeclared setting", - "base": "gradle-provider.plugin.json", + "base": "build-provider.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/provider/arguments", "value": ["--heap=${setting:missing}"] }], "expectedCode": "plugin.template.invalid" }, { "name": "provider dependencies of a target", - "base": "gradle-provider.plugin.json", + "base": "build-provider.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/dependencies/scopes", "value": ["target"] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "plugin API 3 without requires", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "remove", "path": "/requires" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "requires below plugin API 3", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/requires", "value": { "languageServers": 1, "languages": 1, "serverProcess": 1 } }], "expectedCode": "plugin.manifest.invalid" }, { "name": "empty requires", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/requires", "value": {} }], "expectedCode": "plugin.manifest.invalid" }, { "name": "capability version is not a positive integer", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/requires/buildSystems", "value": 0 }], "expectedCode": "plugin.manifest.invalid" }, { "name": "malformed capability ID", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/requires/build-systems", "value": 1 }], "expectedCode": "plugin.manifest.invalid" }, { "name": "future capability", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/requires/debuggers", "value": 1 }], "expectedCode": "plugin.api.unsupported" }, { "name": "future capability is reported before its unknown section", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [ { "op": "set", "path": "/requires/debuggers", "value": 1 }, { "op": "set", "path": "/debuggers", "value": [{ "id": "lldb" }] } @@ -824,43 +824,43 @@ }, { "name": "future capability version", - "base": "cargo-build.plugin.json", - "edits": [{ "op": "set", "path": "/requires/buildSystems", "value": 2 }], + "base": "build-static-file.plugin.json", + "edits": [{ "op": "set", "path": "/requires/buildSystems", "value": 3 }], "expectedCode": "plugin.api.unsupported" }, { "name": "used capability missing from requires", - "base": "mix-provider.plugin.json", + "base": "build-provider-language-server.plugin.json", "edits": [{ "op": "remove", "path": "/requires/languageServers" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "requires lists an unused capability", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/requires/ui", "value": 1 }], "expectedCode": "plugin.manifest.invalid" }, { "name": "own provider without buildProviders capability", - "base": "gradle-provider.plugin.json", + "base": "build-provider.plugin.json", "edits": [{ "op": "remove", "path": "/requires/buildProviders" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "shared provider without buildProviders capability", - "base": "mix-provider.plugin.json", + "base": "build-provider-language-server.plugin.json", "edits": [{ "op": "remove", "path": "/requires/buildProviders" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "template build lists unused buildProviders capability", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/requires/buildProviders", "value": 1 }], "expectedCode": "plugin.manifest.invalid" }, { "name": "own provider process without serverProcess", - "base": "gradle-provider.plugin.json", + "base": "build-provider.plugin.json", "edits": [{ "op": "remove", "path": "/requires/serverProcess" }], "expectedCode": "plugin.manifest.invalid" }, @@ -920,45 +920,99 @@ }, { "name": "build system icon leaves the package", - "base": "gradle-build.plugin.json", + "base": "build-command-output-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/icon", "value": {"path": "../gradle.svg"} }], "expectedCode": "plugin.manifest.invalid" }, { "name": "toolbar option without an icon", - "base": "gradle-build.plugin.json", + "base": "build-command-output-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/options/0/presentation", "value": "toolbar" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "toolbar option that is not boolean", - "base": "maven-build.plugin.json", + "base": "build-host-integration.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/options/0/presentation", "value": "toolbar" }, { "op": "set", "path": "/buildSystems/0/options/0/icon", "value": {"host": "settings"} }], "expectedCode": "plugin.manifest.invalid" }, { "name": "tree option that is not multiSelect", - "base": "gradle-build.plugin.json", + "base": "build-command-output-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/options/0/presentation", "value": "tree" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "option icon outside the toolbar", - "base": "gradle-build.plugin.json", + "base": "build-command-output-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/options/0/icon", "value": {"host": "settings"} }], "expectedCode": "plugin.manifest.invalid" }, { "name": "task category deeper than four headings", - "base": "gradle-build.plugin.json", + "base": "build-command-output-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/tasks/0/category", "value": ["a", "b", "c", "d", "e"] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "task category with an empty heading", - "base": "gradle-build.plugin.json", + "base": "build-command-output-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/tasks/0/category", "value": ["Tasks", " "] }], "expectedCode": "plugin.manifest.invalid" + }, + { + "name": "backend needs its capability in requires", + "base": "backend.plugin.json", + "edits": [{ "op": "remove", "path": "/requires/backend" }], + "expectedCode": "plugin.manifest.invalid" + }, + { + "name": "backend capability version this Core does not know", + "base": "backend.plugin.json", + "edits": [{ "op": "set", "path": "/requires/backend", "value": 2 }], + "expectedCode": "plugin.api.unsupported" + }, + { + "name": "backend needs serverProcess", + "base": "backend.plugin.json", + "edits": [{ "op": "remove", "path": "/requires/serverProcess" }], + "expectedCode": "plugin.manifest.invalid" + }, + { + "name": "backend needs the backend.launch permission", + "base": "backend.plugin.json", + "edits": [{ "op": "set", "path": "/permissions", "value": ["credentials.store", "ui.contribute", "ui.web"] }], + "expectedCode": "plugin.permission.undeclared" + }, + { + "name": "backend serves only web pages", + "base": "backend.plugin.json", + "edits": [{ "op": "remove", "path": "/ui/toolWindows/0/web" }, { "op": "set", "path": "/ui/toolWindows/0/text", "value": "No page" }, { "op": "set", "path": "/requires/ui", "value": 1 }, { "op": "set", "path": "/permissions", "value": ["backend.launch", "credentials.store", "ui.contribute"] }], + "expectedCode": "plugin.manifest.invalid" + }, + { + "name": "backend is not bound to a workspace", + "base": "backend.plugin.json", + "edits": [{ "op": "set", "path": "/backend/arguments", "value": ["--cache", "${workspaceStateDir}"] }], + "expectedCode": "plugin.manifest.invalid" + }, + { + "name": "backend launch stays inside the package", + "base": "backend.plugin.json", + "edits": [{ "op": "set", "path": "/backend/launch/path", "value": "../backend" }], + "expectedCode": "plugin.manifest.invalid" + }, + { + "name": "backend rejects unknown fields", + "base": "backend.plugin.json", + "edits": [{ "op": "set", "path": "/backend/workspace", "value": true }], + "expectedCode": "plugin.manifest.invalid" + }, + { + "name": "credentials.store without a backend", + "base": "backend.plugin.json", + "edits": [{ "op": "remove", "path": "/backend" }, { "op": "set", "path": "/requires", "value": {"ui": 2} }, { "op": "set", "path": "/permissions", "value": ["credentials.store", "ui.contribute", "ui.web"] }], + "expectedCode": "plugin.manifest.invalid" } ] } diff --git a/shared/contracts/plugins/v1/fixtures/java-support.plugin.json b/shared/contracts/plugins/v1/fixtures/language-host-integration.plugin.json similarity index 93% rename from shared/contracts/plugins/v1/fixtures/java-support.plugin.json rename to shared/contracts/plugins/v1/fixtures/language-host-integration.plugin.json index bfae664daa..5a2ec18a5d 100644 --- a/shared/contracts/plugins/v1/fixtures/java-support.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/language-host-integration.plugin.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, - "id": "dev.lithe.fixture.java-support", - "displayName": "Java Support Fixture", + "id": "dev.lithe.fixture.language-host-integration", + "displayName": "Host Integration Language Server Fixture", "version": "1.0.0", "pluginAPIVersion": 2, "vendor": { diff --git a/shared/contracts/plugins/v1/fixtures/php-support.plugin.json b/shared/contracts/plugins/v1/fixtures/language-packaged-server.plugin.json similarity index 94% rename from shared/contracts/plugins/v1/fixtures/php-support.plugin.json rename to shared/contracts/plugins/v1/fixtures/language-packaged-server.plugin.json index d0abff123f..30aacbd2e3 100644 --- a/shared/contracts/plugins/v1/fixtures/php-support.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/language-packaged-server.plugin.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, - "id": "dev.lithe.fixture.php-support", - "displayName": "PHP Support Fixture", + "id": "dev.lithe.fixture.language-packaged-server", + "displayName": "Packaged Language Server Fixture", "version": "0.1.0", "pluginAPIVersion": 1, "vendor": { diff --git a/shared/contracts/plugins/v1/fixtures/go-support.plugin.json b/shared/contracts/plugins/v1/fixtures/language-system-command.plugin.json similarity index 95% rename from shared/contracts/plugins/v1/fixtures/go-support.plugin.json rename to shared/contracts/plugins/v1/fixtures/language-system-command.plugin.json index a37203487d..299f341b1e 100644 --- a/shared/contracts/plugins/v1/fixtures/go-support.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/language-system-command.plugin.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, - "id": "dev.lithe.fixture.go-support", - "displayName": "Go Support Fixture", + "id": "dev.lithe.fixture.language-system-command", + "displayName": "System Command Language Server Fixture", "version": "0.1.0", "pluginAPIVersion": 1, "vendor": { diff --git a/shared/contracts/plugins/v1/fixtures/marketplace/index-cases.json b/shared/contracts/plugins/v1/fixtures/marketplace/index-cases.json index bee4b09299..c4f5960bfa 100644 --- a/shared/contracts/plugins/v1/fixtures/marketplace/index-cases.json +++ b/shared/contracts/plugins/v1/fixtures/marketplace/index-cases.json @@ -8,7 +8,7 @@ "platform": "darwin-arm64", "expected": { "plugins": [ - { "id": "dev.lithe.fixture.php-support", "latestVersion": "1.1.0", "platforms": ["darwin-arm64", "darwin-x64"], "availability": { "status": "available", "version": "1.0.0", "sha256": "5555555555555555555555555555555555555555555555555555555555555555" } }, + { "id": "dev.lithe.fixture.language-packaged-server", "latestVersion": "1.1.0", "platforms": ["darwin-arm64", "darwin-x64"], "availability": { "status": "available", "version": "1.0.0", "sha256": "5555555555555555555555555555555555555555555555555555555555555555" } }, { "id": "io.example.elixir-tools", "latestVersion": "3.0.0", "platforms": ["darwin-arm64", "windows-x64"], "availability": { "status": "unsupported", "code": "plugin.index.apiUnsupported" } }, { "id": "io.example.future-tools", "latestVersion": "3.0.0", "platforms": ["darwin-arm64", "linux-x64"], "availability": { "status": "unsupported", "code": "plugin.index.apiUnsupported" } }, { "id": "io.example.zig-support", "latestVersion": "2.0.0", "platforms": ["darwin-arm64", "windows-x64"], "availability": { "status": "available", "version": "2.0.0", "downloadURL": "https://github.com/example/lithe-zig/releases/download/v2.0.0/zig-support-2.0.0-darwin-arm64.zip" } } @@ -29,7 +29,7 @@ { "id": "io.example.zig-support", "availability": { "status": "unsupported", "code": "plugin.index.platformUnsupported", "platform": "darwin-x64" } } ], "rejected": [ - { "id": "dev.lithe.fixture.php-support", "code": "plugin.index.officialNamespace" }, + { "id": "dev.lithe.fixture.language-packaged-server", "code": "plugin.index.officialNamespace" }, { "id": "io.example.zig-support", "code": "plugin.index.duplicate" } ] } @@ -45,7 +45,7 @@ { "id": "io.example.zig-support", "availability": { "status": "available", "version": "2.0.0", "sha256": "3333333333333333333333333333333333333333333333333333333333333333" } } ], "rejected": [ - { "id": "dev.lithe.fixture.php-support", "code": "plugin.index.officialNamespace" }, + { "id": "dev.lithe.fixture.language-packaged-server", "code": "plugin.index.officialNamespace" }, { "id": "io.example.zig-support", "code": "plugin.index.duplicate" } ] } @@ -62,7 +62,7 @@ { "id": "io.example.zig-support", "availability": { "status": "available", "version": "2.0.0" } } ], "rejected": [ - { "id": "dev.lithe.fixture.php-support", "code": "plugin.index.officialNamespace" }, + { "id": "dev.lithe.fixture.language-packaged-server", "code": "plugin.index.officialNamespace" }, { "id": "io.example.zig-support", "code": "plugin.index.duplicate" } ] } @@ -79,7 +79,7 @@ { "id": "io.example.zig-support", "availability": { "status": "available", "version": "2.0.0" } } ], "rejected": [ - { "id": "dev.lithe.fixture.php-support", "code": "plugin.index.officialNamespace" }, + { "id": "dev.lithe.fixture.language-packaged-server", "code": "plugin.index.officialNamespace" }, { "id": "io.example.zig-support", "code": "plugin.index.duplicate" } ] } @@ -96,7 +96,7 @@ { "id": "io.example.zig-support", "availability": { "status": "available", "version": "2.0.0", "sha256": "3333333333333333333333333333333333333333333333333333333333333333" } } ], "rejected": [ - { "id": "dev.lithe.fixture.php-support", "code": "plugin.index.officialNamespace" }, + { "id": "dev.lithe.fixture.language-packaged-server", "code": "plugin.index.officialNamespace" }, { "id": "io.example.zig-support", "code": "plugin.index.duplicate" } ] } diff --git a/shared/contracts/plugins/v1/fixtures/marketplace/index.json b/shared/contracts/plugins/v1/fixtures/marketplace/index.json index b4ca8a7a4e..640b5ec3ff 100644 --- a/shared/contracts/plugins/v1/fixtures/marketplace/index.json +++ b/shared/contracts/plugins/v1/fixtures/marketplace/index.json @@ -45,8 +45,8 @@ ] }, { - "id": "dev.lithe.fixture.php-support", - "name": "PHP Support Fixture", + "id": "dev.lithe.fixture.language-packaged-server", + "name": "Packaged Language Server Fixture", "author": "Lithe", "repository": "https://github.com/Lithe-IDEA/Lithe-IDEA", "description": "PHP language server, run and test support.", diff --git a/shared/contracts/plugins/v1/fixtures/marketplace/install-reviews.json b/shared/contracts/plugins/v1/fixtures/marketplace/install-reviews.json index 2d50fa7f70..0663067507 100644 --- a/shared/contracts/plugins/v1/fixtures/marketplace/install-reviews.json +++ b/shared/contracts/plugins/v1/fixtures/marketplace/install-reviews.json @@ -4,8 +4,8 @@ "cases": [ { "name": "first installation lists every program", - "base": "php-support.plugin.json", - "expectedID": "dev.lithe.fixture.php-support", + "base": "language-packaged-server.plugin.json", + "expectedID": "dev.lithe.fixture.language-packaged-server", "expectedVersion": "0.1.0", "expected": { "permissions": [ @@ -67,7 +67,7 @@ }, { "name": "unchanged update needs no new approval", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [ { "op": "set", @@ -75,14 +75,14 @@ "value": "0.2.0" } ], - "approvedFrom": "php-support.plugin.json", + "approvedFrom": "language-packaged-server.plugin.json", "expected": { "newProgramCount": 0 } }, { "name": "changed language server arguments need approval", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [ { "op": "set", @@ -93,7 +93,7 @@ ] } ], - "approvedFrom": "php-support.plugin.json", + "approvedFrom": "language-packaged-server.plugin.json", "expected": { "newPrograms": [ { @@ -112,7 +112,7 @@ }, { "name": "changed run arguments with the same executable need approval", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [ { "op": "set", @@ -123,7 +123,7 @@ ] } ], - "approvedFrom": "php-support.plugin.json", + "approvedFrom": "language-packaged-server.plugin.json", "expected": { "newPrograms": [ { @@ -143,7 +143,7 @@ }, { "name": "changed test arguments with the same executable need approval", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [ { "op": "set", @@ -153,14 +153,14 @@ ] } ], - "approvedFrom": "php-support.plugin.json", + "approvedFrom": "language-packaged-server.plugin.json", "expected": { "newProgramCount": 1 } }, { "name": "changed language server validation probe needs approval", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [ { "op": "set", @@ -170,21 +170,21 @@ ] } ], - "approvedFrom": "php-support.plugin.json", + "approvedFrom": "language-packaged-server.plugin.json", "expected": { "newProgramCount": 1 } }, { "name": "package does not match the index entry", - "base": "php-support.plugin.json", - "expectedID": "dev.lithe.fixture.php-support", + "base": "language-packaged-server.plugin.json", + "expectedID": "dev.lithe.fixture.language-packaged-server", "expectedVersion": "9.9.9", "expectedCode": "plugin.package.mismatch" }, { "name": "unsigned package in the official namespace", - "base": "go-support.plugin.json", + "base": "language-system-command.plugin.json", "edits": [ { "op": "set", @@ -196,7 +196,7 @@ }, { "name": "unsigned community package", - "base": "go-support.plugin.json", + "base": "language-system-command.plugin.json", "edits": [ { "op": "set", @@ -271,19 +271,19 @@ }, { "name": "template build review does not require buildProviders", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "hostCapabilities": { "buildSystems": [ 1 ] }, "expected": { - "id": "dev.lithe.fixture.cargo-build" + "id": "dev.lithe.fixture.build-static-file" } }, { "name": "gradle provider review requires buildProviders even with buildSystems", - "base": "gradle-provider.plugin.json", + "base": "build-provider.plugin.json", "hostCapabilities": { "buildSystems": [ 1 @@ -306,7 +306,7 @@ }, { "name": "gradle provider review rejects unsupported buildProviders version", - "base": "gradle-provider.plugin.json", + "base": "build-provider.plugin.json", "hostCapabilities": { "buildProviders": [ 2 @@ -332,14 +332,14 @@ }, { "name": "gradle provider review without a host table uses Core capabilities", - "base": "gradle-provider.plugin.json", + "base": "build-provider.plugin.json", "expected": { - "id": "dev.lithe.fixture.gradle-provider" + "id": "dev.lithe.fixture.build-provider" } }, { "name": "mix provider review requires buildProviders even with buildSystems", - "base": "mix-provider.plugin.json", + "base": "build-provider-language-server.plugin.json", "hostCapabilities": { "buildSystems": [ 1 @@ -362,7 +362,7 @@ }, { "name": "mix provider review rejects unsupported buildProviders version", - "base": "mix-provider.plugin.json", + "base": "build-provider-language-server.plugin.json", "hostCapabilities": { "buildProviders": [ 2 @@ -388,14 +388,14 @@ }, { "name": "mix provider review without a host table uses Core capabilities", - "base": "mix-provider.plugin.json", + "base": "build-provider-language-server.plugin.json", "expected": { - "id": "dev.lithe.fixture.mix-provider" + "id": "dev.lithe.fixture.build-provider-language-server" } }, { "name": "own provider review accepts implemented buildProviders", - "base": "gradle-provider.plugin.json", + "base": "build-provider.plugin.json", "hostCapabilities": { "buildProviders": [ 1 @@ -411,21 +411,21 @@ ] }, "expected": { - "id": "dev.lithe.fixture.gradle-provider" + "id": "dev.lithe.fixture.build-provider" } }, { "name": "host without build systems refuses a build plugin", - "base": "mix-provider.plugin.json", + "base": "build-provider-language-server.plugin.json", "hostCapabilities": { "languages": [1], "languageServers": [1], "serverProcess": [1] }, "expectedCode": "plugin.api.unsupported" }, { "name": "host with every required capability reviews a build plugin", - "base": "mix-provider.plugin.json", + "base": "build-provider-language-server.plugin.json", "hostCapabilities": { "buildProviders": [1], "buildSystems": [1], "languages": [1], "languageServers": [1], "serverProcess": [1] }, "expected": { - "id": "dev.lithe.fixture.mix-provider", + "id": "dev.lithe.fixture.build-provider-language-server", "permissions": ["languageServer.launch", "process.build"], "newProgramCount": 2 } diff --git a/shared/contracts/plugins/v1/fixtures/native-controls.plugin.json b/shared/contracts/plugins/v1/fixtures/native-controls.plugin.json new file mode 100644 index 0000000000..49b82e206f --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/native-controls.plugin.json @@ -0,0 +1,407 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.native-controls", + "displayName": "Native public controls fixture", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": { + "buildProviders": 1, + "buildSystems": 1, + "serverProcess": 1, + "ui": 3 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "languageServer.launch", + "process.build", + "ui.contribute" + ], + "activationEvents": [ + "onBuildSystem:sample", + "onView:dev.lithe.fixture.native-controls.form" + ], + "languages": [], + "buildSystems": [ + { + "id": "sample", + "displayName": "Sample", + "detection": { + "markers": [ + "lithe-sample.json" + ], + "nesting": "outermost" + }, + "invocation": { + "executables": [ + { + "kind": "command", + "command": "node" + } + ] + }, + "provider": { + "launch": { + "kind": "runtime", + "tool": "node", + "minimumVersion": "20.3", + "entry": "backend/main.mjs" + }, + "arguments": [ + "--state", + "${workspaceStateDir}" + ], + "executesProjectCode": true, + "mayUseNetwork": false + }, + "model": { + "source": "provider" + }, + "tasks": [ + { + "id": "build", + "displayName": "Build", + "group": "build", + "scopes": [ + "workspace" + ], + "arguments": [ + "--version" + ] + } + ], + "dependencies": { + "source": "provider", + "scopes": [ + "workspace", + "project" + ] + }, + "sync": { + "watch": [ + "lithe-sample.json" + ] + } + } + ], + "ui": { + "toolWindows": [ + { + "id": "dev.lithe.fixture.native-controls.form", + "title": "Public controls", + "placement": "bottom", + "native": { + "schemaVersion": 1, + "id": "form", + "kind": "form", + "nodes": [], + "controls": [ + { + "id": "name", + "kind": "input", + "label": "名称(输入后按回车)", + "value": "UI Lab", + "placeholder": "输入中文、English 或 😀🚀", + "onCommit": "changed", + "span": 6, + "info": "使用 IDE 公共控件。当前值会在提交时传给示例插件,界面样式由宿主管理。" + }, + { + "id": "query", + "kind": "input", + "label": "空输入框与占位提示", + "value": "", + "placeholder": "试试中文输入法、粘贴、撤销", + "onCommit": "changed", + "span": 6, + "info": "使用 IDE 公共控件。当前值会在提交时传给示例插件,界面样式由宿主管理。" + }, + { + "id": "environment", + "kind": "select", + "label": "环境(切换后自动提交)", + "value": "dev", + "options": [ + { + "id": "dev", + "label": "开发环境" + }, + { + "id": "test", + "label": "测试环境 / English" + }, + { + "id": "prod", + "label": "生产环境 😀" + } + ], + "onCommit": "changed", + "span": 6, + "info": "使用 IDE 公共控件。当前值会在提交时传给示例插件,界面样式由宿主管理。" + }, + { + "id": "notify", + "kind": "checkbox", + "label": "启用通知(切换后自动提交)", + "value": true, + "onCommit": "changed", + "span": 6, + "info": "使用 IDE 公共控件。当前值会在提交时传给示例插件,界面样式由宿主管理。" + }, + { + "id": "offline", + "kind": "checkbox", + "label": "离线模式(仅本地保存)", + "value": false, + "span": 6, + "info": "使用 IDE 公共控件。当前值会在提交时传给示例插件,界面样式由宿主管理。" + }, + { + "id": "locked-input", + "kind": "input", + "label": "禁用输入框", + "value": "不可编辑", + "enabled": false, + "span": 6, + "info": "使用 IDE 公共控件。此控件已禁用;悬停可查看说明。" + }, + { + "id": "locked-select", + "kind": "select", + "label": "禁用下拉选择", + "value": "locked", + "options": [ + { + "id": "locked", + "label": "固定选项" + } + ], + "enabled": false, + "span": 6, + "info": "使用 IDE 公共控件。此控件已禁用;悬停可查看说明。" + }, + { + "id": "locked-check", + "kind": "checkbox", + "label": "禁用复选框", + "value": true, + "enabled": false, + "span": 6, + "info": "使用 IDE 公共控件。此控件已禁用;悬停可查看说明。" + }, + { + "id": "search", + "kind": "search", + "label": "搜索框(回车提交)", + "value": "", + "placeholder": "搜索中文 / English / 😀", + "onCommit": "changed", + "info": "复用共享搜索输入、焦点样式、I形光标和清空按钮。清空仅修改本地值。", + "span": 6 + }, + { + "id": "notes", + "kind": "editor", + "label": "多行输入(使用下方按钮提交)", + "value": "第一行:中文\nSecond line 😀", + "rows": 4, + "info": "Enter 换行,支持组合输入、选择和撤销;最多8192 UTF-8字节。", + "span": 6 + }, + { + "id": "mode", + "kind": "segmented", + "label": "分段选择", + "value": "safe", + "options": [ + { + "id": "fast", + "label": "快速" + }, + { + "id": "safe", + "label": "安全" + }, + { + "id": "debug", + "label": "调试 😀" + } + ], + "onCommit": "changed", + "info": "复用设置页分段选择,传递稳定选项ID。", + "span": 6 + }, + { + "id": "concurrency", + "kind": "stepper", + "label": "数字步进器", + "value": 2, + "minimum": 1, + "maximum": 8, + "step": 1, + "onCommit": "changed", + "info": "最小1、最大8、步长1;到边界后对应按钮禁用。", + "span": 6 + }, + { + "id": "disabled-search", + "kind": "search", + "label": "禁用 · 搜索框(回车提交)", + "value": "", + "placeholder": "搜索中文 / English / 😀", + "onCommit": "changed", + "info": "复用共享搜索输入、焦点样式、I形光标和清空按钮。清空仅修改本地值。", + "enabled": false, + "span": 6 + }, + { + "id": "disabled-notes", + "kind": "editor", + "label": "禁用 · 多行输入(使用下方按钮提交)", + "value": "第一行:中文\nSecond line 😀", + "rows": 4, + "info": "Enter 换行,支持组合输入、选择和撤销;最多8192 UTF-8字节。", + "enabled": false, + "span": 6 + }, + { + "id": "disabled-mode", + "kind": "segmented", + "label": "禁用 · 分段选择", + "value": "safe", + "options": [ + { + "id": "fast", + "label": "快速" + }, + { + "id": "safe", + "label": "安全" + }, + { + "id": "debug", + "label": "调试 😀" + } + ], + "onCommit": "changed", + "info": "复用设置页分段选择,传递稳定选项ID。", + "enabled": false, + "span": 6 + }, + { + "id": "disabled-concurrency", + "kind": "stepper", + "label": "禁用 · 数字步进器", + "value": 2, + "minimum": 1, + "maximum": 8, + "step": 1, + "onCommit": "changed", + "info": "最小1、最大8、步长1;到边界后对应按钮禁用。", + "enabled": false, + "span": 6 + } + ], + "source": { + "buildSystemID": "sample", + "function": "form.nodes" + }, + "actions": [ + { + "id": "changed", + "title": "提交控件值", + "kind": "invoke", + "function": "form.changed", + "requiresSelection": false + }, + { + "id": "form-slow", + "title": "慢提交", + "kind": "invoke", + "function": "form.slow", + "requiresSelection": false + }, + { + "id": "form-fail", + "title": "模拟提交失败", + "kind": "invoke", + "function": "form.fail", + "requiresSelection": false + } + ], + "toolbar": [], + "contextMenu": [], + "layout": { + "columns": 12 + }, + "buttons": [ + { + "id": "secondary-content", + "label": "普通按钮(内容宽度)", + "action": "changed", + "width": "content", + "span": 6, + "info": "随文字宽度伸缩,保留公共最小宽度、外框和内边距。" + }, + { + "id": "secondary-fill", + "label": "普通按钮(填满栅格)", + "action": "changed", + "width": "fill", + "span": 6, + "info": "填满剩余空间,样式和高度仍由公共按钮控制。" + }, + { + "id": "primary-content", + "label": "主要按钮(内容宽度)", + "action": "changed", + "primary": true, + "width": "content", + "span": 6 + }, + { + "id": "primary-fill", + "label": "主要按钮(填满栅格)", + "action": "changed", + "primary": true, + "width": "fill", + "span": 6 + }, + { + "id": "disabled-button", + "label": "禁用普通按钮", + "action": "changed", + "enabled": false, + "span": 6 + }, + { + "id": "disabled-primary", + "label": "禁用主要按钮", + "action": "changed", + "primary": true, + "enabled": false, + "width": "fill", + "span": 6 + }, + { + "id": "slow-button", + "label": "慢提交(3秒,检查禁用/取消)", + "action": "form-slow", + "span": 6 + }, + { + "id": "failure-button", + "label": "模拟提交失败", + "action": "form-fail", + "width": "fill", + "span": 6 + } + ] + } + } + ] + } +} diff --git a/shared/contracts/plugins/v1/fixtures/native-form.plugin.json b/shared/contracts/plugins/v1/fixtures/native-form.plugin.json new file mode 100644 index 0000000000..71dd640319 --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/native-form.plugin.json @@ -0,0 +1,157 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.native-form", + "displayName": "Native form fixture", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": { + "buildProviders": 1, + "buildSystems": 1, + "serverProcess": 1, + "ui": 3 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "languageServer.launch", + "process.build", + "ui.contribute" + ], + "activationEvents": [ + "onBuildSystem:sample", + "onView:dev.lithe.fixture.native-form.form" + ], + "languages": [], + "buildSystems": [ + { + "id": "sample", + "displayName": "Sample", + "detection": { + "markers": [ + "lithe-sample.json" + ], + "nesting": "outermost" + }, + "invocation": { + "executables": [ + { + "kind": "command", + "command": "node" + } + ] + }, + "provider": { + "launch": { + "kind": "runtime", + "tool": "node", + "minimumVersion": "20.3", + "entry": "backend/main.mjs" + }, + "arguments": [ + "--state", + "${workspaceStateDir}" + ], + "executesProjectCode": true, + "mayUseNetwork": false + }, + "model": { + "source": "provider" + }, + "tasks": [ + { + "id": "build", + "displayName": "Build", + "group": "build", + "scopes": [ + "workspace" + ], + "arguments": [ + "--version" + ] + } + ], + "dependencies": { + "source": "provider", + "scopes": [ + "workspace", + "project" + ] + }, + "sync": { + "watch": [ + "lithe-sample.json" + ] + } + } + ], + "ui": { + "toolWindows": [ + { + "id": "dev.lithe.fixture.native-form.form", + "title": "Native form", + "placement": "bottom", + "native": { + "schemaVersion": 1, + "id": "form", + "kind": "form", + "nodes": [], + "controls": [ + { + "id": "name", + "kind": "input", + "label": "名称(输入后按回车)", + "value": "UI Lab", + "placeholder": "输入中文、English 或 😀🚀", + "span": 6 + }, + { + "id": "query", + "kind": "input", + "label": "空输入框与占位提示", + "value": "", + "placeholder": "试试中文输入法、粘贴、撤销", + "span": 6 + }, + { + "id": "environment", + "kind": "select", + "label": "环境(切换后自动提交)", + "value": "dev", + "options": [ + { + "id": "dev", + "label": "开发环境" + }, + { + "id": "test", + "label": "测试环境 / English" + }, + { + "id": "prod", + "label": "生产环境 😀" + } + ], + "span": 6 + }, + { + "id": "notify", + "kind": "checkbox", + "label": "启用通知(切换后自动提交)", + "value": true, + "span": 6 + } + ], + "actions": [], + "toolbar": [], + "contextMenu": [], + "layout": { + "columns": 12 + } + } + } + ] + } +} diff --git a/shared/contracts/plugins/v1/fixtures/native-panel.plugin.json b/shared/contracts/plugins/v1/fixtures/native-panel.plugin.json new file mode 100644 index 0000000000..41030102a1 --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/native-panel.plugin.json @@ -0,0 +1,137 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.native-panel", + "displayName": "Sample Build", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": { + "buildProviders": 1, + "buildSystems": 1, + "serverProcess": 1, + "ui": 3 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "languageServer.launch", + "process.build", + "ui.contribute" + ], + "activationEvents": [ + "onBuildSystem:sample", + "onView:dev.lithe.fixture.native-panel.explorer" + ], + "languages": [], + "buildSystems": [ + { + "id": "sample", + "displayName": "Sample", + "detection": { + "markers": [ + "lithe-sample.json" + ], + "nesting": "outermost" + }, + "invocation": { + "executables": [ + { + "kind": "command", + "command": "node" + } + ] + }, + "provider": { + "launch": { + "kind": "runtime", + "tool": "node", + "minimumVersion": "20.3", + "entry": "backend/main.mjs" + }, + "arguments": [ + "--state", + "${workspaceStateDir}" + ], + "executesProjectCode": true, + "mayUseNetwork": false + }, + "model": { + "source": "provider" + }, + "tasks": [ + { + "id": "build", + "displayName": "Build", + "group": "build", + "scopes": [ + "workspace" + ], + "arguments": [ + "--version" + ] + } + ], + "dependencies": { + "source": "provider", + "scopes": [ + "workspace", + "project" + ] + }, + "sync": { + "watch": [ + "lithe-sample.json" + ] + } + } + ], + "ui": { + "toolWindows": [ + { + "id": "dev.lithe.fixture.native-panel.explorer", + "title": "Sample Explorer", + "placement": "right", + "native": { + "schemaVersion": 1, + "id": "explorer", + "kind": "tree", + "nodes": [], + "source": { + "buildSystemID": "sample", + "function": "ui.nodes" + }, + "actions": [ + { + "id": "refresh", + "title": "Refresh", + "kind": "refresh", + "requiresSelection": false, + "icon": { + "host": "refresh" + } + }, + { + "id": "inspect", + "title": "Inspect", + "kind": "invoke", + "function": "ui.inspect", + "requiresSelection": true, + "icon": { + "host": "search" + } + } + ], + "toolbar": [ + "refresh", + "inspect" + ], + "contextMenu": [ + "inspect" + ] + } + } + ] + } +} diff --git a/shared/contracts/plugins/v1/fixtures/project-generator-templates/.gitattributes b/shared/contracts/plugins/v1/fixtures/project-generator-templates/.gitattributes new file mode 100644 index 0000000000..59f82bacf3 --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/project-generator-templates/.gitattributes @@ -0,0 +1,2 @@ +# Templates are emitted byte-for-byte, so checkouts must not convert line endings. +* -text diff --git a/shared/contracts/plugins/v1/fixtures/project-generator-templates/app/README.md b/shared/contracts/plugins/v1/fixtures/project-generator-templates/app/README.md new file mode 100644 index 0000000000..af59f58c2b --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/project-generator-templates/app/README.md @@ -0,0 +1,3 @@ +# {{ project.name }} + +Build files keep ${placeholders} and {{other}} text unchanged. diff --git a/shared/contracts/plugins/v1/fixtures/project-generator-templates/app/dot-gitignore b/shared/contracts/plugins/v1/fixtures/project-generator-templates/app/dot-gitignore new file mode 100644 index 0000000000..e69de29bb2 diff --git a/shared/contracts/plugins/v1/fixtures/project-generator-templates/main/Main.java b/shared/contracts/plugins/v1/fixtures/project-generator-templates/main/Main.java new file mode 100644 index 0000000000..a90094923e --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/project-generator-templates/main/Main.java @@ -0,0 +1,3 @@ +package {{ field.groupId }}; + +class Main {} diff --git a/shared/contracts/plugins/v1/fixtures/project-generator.plugin.json b/shared/contracts/plugins/v1/fixtures/project-generator.plugin.json new file mode 100644 index 0000000000..548eed58c7 --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/project-generator.plugin.json @@ -0,0 +1,83 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.project-generator", + "displayName": "Project Generator Fixture", + "version": "1.0.0", + "pluginAPIVersion": 3, + "requires": {"projectGenerators": 1}, + "vendor": {"id": "org.example", "displayName": "Example", "signatureRequirement": "publisherPackage"}, + "permissions": ["project.generate"], + "activationEvents": [], + "projectGenerators": [ + { + "id": "app", + "displayName": "App", + "section": "project", + "fields": [ + {"id": "buildSystem", "type": "enum", "title": "Build system", "presentation": "segmented", + "options": [{"value": "none", "title": "None"}, {"value": "maven", "title": "Maven"}]}, + {"id": "jdk", "type": "runtime", "title": "JDK", "runtime": "java", "minimumVersion": "17"}, + {"id": "sample", "type": "boolean", "title": "Add sample code", "default": true}, + {"id": "groupId", "type": "text", "title": "Group", "advanced": true, "default": "com.example", + "pattern": "[a-z][a-z0-9]*(\\.[a-z][a-z0-9]*)*", + "visibleWhen": {"field": "buildSystem", "equals": "maven"}} + ], + "requirements": [ + {"languageID": "java"}, + {"buildSystemID": "maven", "when": {"field": "buildSystem", "equals": "maven"}} + ], + "steps": [ + {"kind": "files", "source": "project-generator-templates/app"}, + {"kind": "files", "source": "project-generator-templates/main", + "target": "src/${field.groupId|package|path}", + "when": [{"field": "buildSystem", "equals": "maven"}, {"field": "sample", "equals": true}]} + ], + "openFiles": [ + {"path": "src/${field.groupId|path}/Main.java", + "when": [{"field": "buildSystem", "equals": "maven"}, {"field": "sample", "equals": true}]} + ] + }, + { + "id": "archetype", + "displayName": "Archetype", + "section": "generator", + "fields": [ + {"id": "jdk", "type": "runtime", "title": "JDK", "runtime": "java"}, + {"id": "artifactId", "type": "text", "title": "Artifact", "default": "${project.name|lower}", + "pattern": "[a-z0-9-]+"}, + {"id": "version", "type": "text", "title": "Archetype version", "optional": true, "pattern": "[0-9.]+"} + ], + "steps": [ + {"kind": "command", "command": "mvn", "mayUseNetwork": true, + "arguments": [ + "-B", "archetype:generate", + "-DartifactId=${field.artifactId}", + {"value": "-DarchetypeVersion=${field.version}", "when": {"field": "version", "notEquals": ""}} + ], + "environment": {"JAVA_HOME": "${field.jdk}"}, + "collect": "${field.artifactId}"} + ] + }, + { + "id": "initializr", + "displayName": "Initializr", + "section": "generator", + "order": 5, + "fields": [ + {"id": "serverUrl", "type": "text", "title": "Server URL", "default": "https://start.example.com", + "pattern": "https?://[a-z0-9.:-]+"}, + {"id": "dependencies", "type": "multiSelect", "title": "Dependencies", + "options": [{"value": "web", "title": "Web"}, {"value": "jpa", "title": "JPA"}]}, + {"id": "bootVersion", "type": "text", "title": "Boot", "optional": true, "pattern": "[0-9.]+"} + ], + "steps": [ + {"kind": "archive", "url": "${field.serverUrl}/starter.zip", + "query": [ + {"name": "name", "value": "${project.name}"}, + {"name": "bootVersion", "value": "${field.bootVersion}", "omitEmpty": true}, + {"name": "dependencies", "value": "${field.dependencies}", "omitEmpty": true} + ]} + ] + } + ] +} diff --git a/shared/contracts/plugins/v1/fixtures/project-plans.json b/shared/contracts/plugins/v1/fixtures/project-plans.json new file mode 100644 index 0000000000..b4a16feae7 --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/project-plans.json @@ -0,0 +1,360 @@ +{ + "schemaVersion": 1, + "description": "Golden answers of the project.* commands for project-generator.plugin.json. generationPlan cases read templates with the fixtures directory as the package path.", + "cases": [ + { + "name": "form defaults", + "operation": "generatorForm", + "generatorID": "app", + "form": { + "projectName": "demo" + }, + "expected": { + "status": "ok", + "form": { + "projectName": { + "problem": null + }, + "fields": [ + { + "id": "buildSystem", + "visible": true, + "value": "none" + }, + { + "id": "jdk", + "visible": true, + "value": "", + "problem": "required" + }, + { + "id": "sample", + "visible": true, + "value": true + }, + { + "id": "groupId", + "visible": false, + "value": "com.example" + } + ], + "activeRequirements": [ + 0 + ], + "valid": false + } + } + }, + { + "name": "form shows maven fields and requirements", + "operation": "generatorForm", + "generatorID": "app", + "form": { + "projectName": "demo", + "values": { + "buildSystem": "maven", + "groupId": "Bad Group" + }, + "edited": [ + "groupId" + ], + "runtimes": { + "jdk": { + "home": "/opt/jdk-21", + "version": "21.0.4" + } + } + }, + "expected": { + "status": "ok", + "form": { + "projectName": { + "problem": null + }, + "fields": [ + { + "id": "buildSystem", + "visible": true, + "value": "maven" + }, + { + "id": "jdk", + "visible": true, + "value": "/opt/jdk-21" + }, + { + "id": "sample", + "visible": true, + "value": true + }, + { + "id": "groupId", + "visible": true, + "value": "Bad Group", + "problem": "invalidFormat" + } + ], + "activeRequirements": [ + 0, + 1 + ], + "valid": false + } + } + }, + { + "name": "form derives a default from the project name", + "operation": "generatorForm", + "generatorID": "archetype", + "form": { + "projectName": "My-App", + "runtimes": { + "jdk": { + "home": "/opt/jdk-21", + "version": "21.0.4" + } + } + }, + "expected": { + "status": "ok", + "form": { + "projectName": { + "problem": null + }, + "fields": [ + { + "id": "jdk", + "visible": true, + "value": "/opt/jdk-21" + }, + { + "id": "artifactId", + "visible": true, + "value": "my-app" + }, + { + "id": "version", + "visible": true, + "value": "" + } + ], + "activeRequirements": [], + "valid": true + } + } + }, + { + "name": "plan writes templates", + "operation": "generationPlan", + "generatorID": "app", + "form": { + "projectName": "demo", + "values": { + "buildSystem": "maven" + }, + "runtimes": { + "jdk": { + "home": "/opt/jdk-21", + "version": "21.0.4" + } + } + }, + "expected": { + "status": "ok", + "plan": { + "projectName": "demo", + "steps": [ + { + "kind": "writeFiles", + "files": [ + { + "path": "README.md", + "contents": "# demo\n\nBuild files keep ${placeholders} and {{other}} text unchanged.\n", + "executable": false + }, + { + "path": ".gitignore", + "contents": "", + "executable": false + } + ] + }, + { + "kind": "writeFiles", + "files": [ + { + "path": "src/com/example/Main.java", + "contents": "package com.example;\n\nclass Main {}\n", + "executable": false + } + ] + } + ], + "openFiles": [ + "src/com/example/Main.java" + ], + "runtimes": [ + { + "field": "jdk", + "runtime": "java", + "home": "/opt/jdk-21", + "version": "21.0.4" + } + ], + "usesNetwork": false + } + } + }, + { + "name": "plan rejects an old runtime", + "operation": "generationPlan", + "generatorID": "app", + "form": { + "projectName": "demo", + "runtimes": { + "jdk": { + "home": "/opt/jdk-11", + "version": "11.0.2" + } + } + }, + "expected": { + "status": "rejected", + "error": { + "code": "plugin.project.invalidInput", + "message": "The value of jdk is not accepted (runtimeTooOld).", + "field": "values.jdk" + } + } + }, + { + "name": "plan runs a scaffolding command", + "operation": "generationPlan", + "generatorID": "archetype", + "form": { + "projectName": "My-App", + "values": { + "version": "1.5" + }, + "edited": [ + "version" + ], + "runtimes": { + "jdk": { + "home": "/opt/jdk-21", + "version": "21.0.4" + } + } + }, + "expected": { + "status": "ok", + "plan": { + "projectName": "My-App", + "steps": [ + { + "kind": "command", + "command": "mvn", + "arguments": [ + "-B", + "archetype:generate", + "-DartifactId=my-app", + "-DarchetypeVersion=1.5" + ], + "environment": { + "JAVA_HOME": "/opt/jdk-21" + }, + "collect": "my-app", + "mayUseNetwork": true, + "timeoutSeconds": 600 + } + ], + "openFiles": [], + "runtimes": [ + { + "field": "jdk", + "runtime": "java", + "home": "/opt/jdk-21", + "version": "21.0.4" + } + ], + "usesNetwork": true + } + } + }, + { + "name": "plan downloads an archive", + "operation": "generationPlan", + "generatorID": "initializr", + "form": { + "projectName": "my app", + "values": { + "dependencies": [ + "jpa", + "web" + ] + } + }, + "expected": { + "status": "ok", + "plan": { + "projectName": "my app", + "steps": [ + { + "kind": "archive", + "url": "https://start.example.com/starter.zip?name=my+app&dependencies=web%2Cjpa", + "host": "start.example.com", + "format": "zip" + } + ], + "openFiles": [], + "runtimes": [], + "usesNetwork": true + } + } + }, + { + "name": "plan refuses a non-HTTPS server", + "operation": "generationPlan", + "generatorID": "initializr", + "form": { + "projectName": "demo", + "values": { + "serverUrl": "http://start.example.com" + }, + "edited": [ + "serverUrl" + ] + }, + "expected": { + "status": "rejected", + "error": { + "code": "plugin.project.invalidURL", + "message": "http://start.example.com/starter.zip is not an HTTPS address of a project archive.", + "field": "projectGenerators.initializr.steps[0]" + } + } + }, + { + "name": "name accepted", + "operation": "checkName", + "projectName": "demo-app", + "expected": { + "status": "ok", + "name": { + "problem": null + } + } + }, + { + "name": "name with a slash", + "operation": "checkName", + "projectName": "a/b", + "expected": { + "status": "ok", + "name": { + "problem": "invalidFormat" + } + } + } + ] +} diff --git a/shared/contracts/plugins/v1/fixtures/vcs-provider.plugin.json b/shared/contracts/plugins/v1/fixtures/vcs-provider.plugin.json new file mode 100644 index 0000000000..b78094beac --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/vcs-provider.plugin.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.vcs-provider", + "displayName": "VCS Provider Fixture", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": { "serverProcess": 1, "vcsProviders": 1 }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "languageServer.launch", + "process.vcs" + ], + "activationEvents": [ + "onVcsRoot:svn" + ], + "languages": [], + "vcsProviders": [ + { + "id": "svn", + "displayName": "Subversion", + "detection": { + "markers": [{ "name": ".svn", "type": "directory" }] + }, + "metadataPaths": [".svn/wc.db"], + "tool": { + "commands": ["svn"], + "validationArguments": ["--version", "--quiet"] + }, + "launch": { + "kind": "runtime", + "tool": "node", + "minimumVersion": "20.3", + "entry": "provider/main.mjs" + }, + "arguments": ["--stdio"], + "mayUseNetwork": true + } + ] +} diff --git a/shared/contracts/plugins/v1/native-ui.schema.json b/shared/contracts/plugins/v1/native-ui.schema.json new file mode 100644 index 0000000000..5e8571490d --- /dev/null +++ b/shared/contracts/plugins/v1/native-ui.schema.json @@ -0,0 +1,781 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/Lithe-IDEA/lithe-plugin-docs/main/contract/v1/native-ui.schema.json", + "title": "Host-rendered native plugin UI schema 1", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "id", + "kind", + "nodes", + "actions", + "toolbar", + "contextMenu" + ], + "properties": { + "schemaVersion": { + "const": 1 + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._-]+$" + }, + "kind": { + "enum": [ + "tree", + "list", + "form" + ] + }, + "nodes": { + "type": "array", + "maxItems": 2000, + "items": { + "$ref": "#/$defs/node" + } + }, + "source": { + "type": "object", + "additionalProperties": false, + "required": [ + "buildSystemID", + "function" + ], + "properties": { + "buildSystemID": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._-]+$" + }, + "function": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._-]+$" + } + } + }, + "actions": { + "type": "array", + "maxItems": 64, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "title", + "kind", + "requiresSelection" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._-]+$" + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "kind": { + "enum": [ + "refresh", + "invoke" + ] + }, + "function": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._-]+$" + }, + "requiresSelection": { + "type": "boolean" + }, + "icon": { + "description": "Optional host icon. Toolbar actions are icon-only; title supplies tooltip and accessibility text.", + "allOf": [ + { + "$ref": "plugin-manifest.schema.json#/$defs/uiIcon" + }, + { + "required": [ + "host" + ] + } + ] + } + }, + "allOf": [ + { + "if": { + "properties": { + "kind": { + "const": "invoke" + } + } + }, + "then": { + "required": [ + "function" + ] + }, + "else": { + "not": { + "required": [ + "function" + ] + }, + "properties": { + "requiresSelection": { + "const": false + } + } + } + } + ] + } + }, + "toolbar": { + "type": "array", + "uniqueItems": true, + "maxItems": 64, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._-]+$" + } + }, + "contextMenu": { + "type": "array", + "uniqueItems": true, + "maxItems": 64, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._-]+$" + } + }, + "controls": { + "type": "array", + "maxItems": 32, + "items": { + "$ref": "#/$defs/control" + } + }, + "layout": { + "type": "object", + "additionalProperties": false, + "required": [ + "columns" + ], + "properties": { + "columns": { + "type": "integer", + "minimum": 1, + "maximum": 12 + } + } + }, + "buttons": { + "type": "array", + "maxItems": 16, + "items": { + "$ref": "#/$defs/formButton" + } + } + }, + "$defs": { + "node": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "label" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._-]+$" + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "children": { + "type": "array", + "maxItems": 2000, + "items": { + "$ref": "#/$defs/node" + } + } + } + }, + "control": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "kind", + "label", + "value" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._-]+$" + }, + "kind": { + "enum": [ + "input", + "search", + "editor", + "select", + "segmented", + "checkbox", + "stepper" + ] + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "value": { + "type": [ + "string", + "boolean", + "number" + ] + }, + "placeholder": { + "type": "string", + "maxLength": 256 + }, + "enabled": { + "type": "boolean" + }, + "onCommit": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._-]+$" + }, + "options": { + "type": "array", + "minItems": 1, + "maxItems": 128, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "label" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._-]+$" + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + } + } + }, + "span": { + "type": "integer", + "minimum": 1, + "maximum": 12 + }, + "info": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "rows": { + "type": "integer", + "minimum": 2, + "maximum": 12 + }, + "minimum": { + "type": "number", + "minimum": -1000000000.0, + "maximum": 1000000000.0 + }, + "maximum": { + "type": "number", + "minimum": -1000000000.0, + "maximum": 1000000000.0 + }, + "step": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 1000000000.0 + } + }, + "allOf": [ + { + "if": { + "properties": { + "kind": { + "enum": [ + "input", + "search" + ] + } + } + }, + "then": { + "properties": { + "value": { + "type": "string", + "maxLength": 1024 + } + }, + "not": { + "anyOf": [ + { + "required": [ + "options" + ] + }, + { + "required": [ + "rows" + ] + }, + { + "required": [ + "minimum" + ] + }, + { + "required": [ + "maximum" + ] + }, + { + "required": [ + "step" + ] + } + ] + } + } + }, + { + "if": { + "properties": { + "kind": { + "enum": [ + "editor" + ] + } + } + }, + "then": { + "properties": { + "value": { + "type": "string", + "maxLength": 8192 + } + }, + "not": { + "anyOf": [ + { + "required": [ + "options" + ] + }, + { + "required": [ + "placeholder" + ] + }, + { + "required": [ + "onCommit" + ] + }, + { + "required": [ + "minimum" + ] + }, + { + "required": [ + "maximum" + ] + }, + { + "required": [ + "step" + ] + } + ] + } + } + }, + { + "if": { + "properties": { + "kind": { + "enum": [ + "select" + ] + } + } + }, + "then": { + "properties": { + "value": { + "type": "string" + } + }, + "not": { + "anyOf": [ + { + "required": [ + "placeholder" + ] + }, + { + "required": [ + "rows" + ] + }, + { + "required": [ + "minimum" + ] + }, + { + "required": [ + "maximum" + ] + }, + { + "required": [ + "step" + ] + } + ] + }, + "required": [ + "options" + ] + } + }, + { + "if": { + "properties": { + "kind": { + "enum": [ + "segmented" + ] + } + } + }, + "then": { + "properties": { + "value": { + "type": "string" + }, + "options": { + "maxItems": 8 + } + }, + "not": { + "anyOf": [ + { + "required": [ + "placeholder" + ] + }, + { + "required": [ + "rows" + ] + }, + { + "required": [ + "minimum" + ] + }, + { + "required": [ + "maximum" + ] + }, + { + "required": [ + "step" + ] + } + ] + }, + "required": [ + "options" + ] + } + }, + { + "if": { + "properties": { + "kind": { + "enum": [ + "checkbox" + ] + } + } + }, + "then": { + "properties": { + "value": { + "type": "boolean" + } + }, + "not": { + "anyOf": [ + { + "required": [ + "options" + ] + }, + { + "required": [ + "placeholder" + ] + }, + { + "required": [ + "rows" + ] + }, + { + "required": [ + "minimum" + ] + }, + { + "required": [ + "maximum" + ] + }, + { + "required": [ + "step" + ] + } + ] + } + } + }, + { + "if": { + "properties": { + "kind": { + "enum": [ + "stepper" + ] + } + } + }, + "then": { + "properties": { + "value": { + "type": "number" + } + }, + "not": { + "anyOf": [ + { + "required": [ + "options" + ] + }, + { + "required": [ + "placeholder" + ] + }, + { + "required": [ + "rows" + ] + } + ] + }, + "required": [ + "minimum", + "maximum", + "step" + ] + } + } + ] + }, + "formButton": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "label", + "action" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._-]+$" + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "action": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._-]+$" + }, + "width": { + "enum": [ + "content", + "fill" + ], + "deprecated": true, + "description": "Legacy placement hint accepted for compatibility. The host action group owns natural button widths, wrapping and spacing." + }, + "primary": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "span": { + "type": "integer", + "minimum": 1, + "maximum": 12, + "deprecated": true, + "description": "Legacy placement hint accepted for compatibility. The host action group owns natural button widths, wrapping and spacing." + }, + "info": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + } + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "kind": { + "const": "form" + } + } + }, + "then": { + "properties": { + "nodes": { + "maxItems": 0 + }, + "controls": {}, + "contextMenu": { + "maxItems": 0 + } + }, + "anyOf": [ + { + "required": [ + "controls" + ], + "properties": { + "controls": { + "minItems": 1 + } + } + }, + { + "required": [ + "buttons" + ], + "properties": { + "buttons": { + "minItems": 1 + } + } + } + ] + }, + "else": { + "properties": { + "controls": { + "maxItems": 0 + }, + "buttons": { + "maxItems": 0 + } + }, + "not": { + "required": [ + "layout" + ] + } + } + }, + { + "if": { + "not": { + "required": [ + "layout" + ] + } + }, + "then": { + "properties": { + "controls": { + "items": { + "not": { + "required": [ + "span" + ] + } + } + }, + "buttons": { + "items": { + "not": { + "required": [ + "span" + ] + } + } + } + } + } + } + ] +} diff --git a/shared/contracts/plugins/v1/plugin-index.schema.json b/shared/contracts/plugins/v1/plugin-index.schema.json index 4d4f53af56..26ae06a5d4 100644 --- a/shared/contracts/plugins/v1/plugin-index.schema.json +++ b/shared/contracts/plugins/v1/plugin-index.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://lithe.dev/contracts/plugins/v1/plugin-index.schema.json", + "$id": "https://raw.githubusercontent.com/Lithe-IDEA/lithe-plugin-docs/main/contract/v1/plugin-index.schema.json", "title": "Lithe plugin marketplace index (plugin contract v1)", "description": "Shape of a marketplace source index. Semantic rules that JSON Schema cannot express (download URLs inside the entry's own repository, duplicate IDs and versions, the official namespace, platform and API filtering) are defined in README.md and enforced by plugins.marketplace.validateIndex.", "type": "object", diff --git a/shared/contracts/plugins/v1/plugin-manifest.schema.json b/shared/contracts/plugins/v1/plugin-manifest.schema.json index 1b45e5dadd..e210b478d8 100644 --- a/shared/contracts/plugins/v1/plugin-manifest.schema.json +++ b/shared/contracts/plugins/v1/plugin-manifest.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://lithe.dev/contracts/plugins/v1/plugin-manifest.schema.json", + "$id": "https://raw.githubusercontent.com/Lithe-IDEA/lithe-plugin-docs/main/contract/v1/plugin-manifest.schema.json", "title": "Lithe declarative plugin manifest (plugin contract v1)", "description": "Shape of plugin.json for a declarative plugin. Semantic rules that JSON Schema cannot express (cross references, permissions, activation events, template variables) are defined in README.md and enforced by plugins.declarative.validate.", "type": "object", @@ -21,14 +21,18 @@ "minProperties": 1, "additionalProperties": false, "properties": { + "backend": { "description": "Required exactly when backend is present: the plugin's own process serving its web pages.", "enum": [1] }, "buildProviders": { "description": "Required exactly when any buildSystems[].provider is present, including own-process and shared language-server providers. Declares host support for Provider orchestration, independent of declarative buildSystems and serverProcess launch support.", "enum": [1] }, - "buildSystems": { "description": "Declarative build detection, models, and command plans; does not imply buildProviders support.", "enum": [1] }, - "executionProviders": { "enum": [1] }, + "buildSystems": { "description": "Declarative build detection, models, and command plans; does not imply buildProviders support.", "enum": [1, 2] }, + "executableResolution": { "enum": [1], "description": "Shared executable candidate ordering and strict selection." }, + "executionProviders": { "enum": [1] }, "languageServers": { "enum": [1] }, "languages": { "enum": [1] }, - "serverProcess": { "description": "The launch fields of language servers and of build providers that start their own process.", "enum": [1] }, + "projectGenerators": { "description": "Entries of the New Project window: fields, requirements, and steps.", "enum": [1] }, + "serverProcess": { "description": "The launch fields of language servers, of build providers that start their own process, of VCS providers, and of the plugin backend.", "enum": [1] }, "settings": { "enum": [1] }, - "ui": { "description": "Version 2 adds web content in tool windows.", "enum": [1, 2] } + "ui": { "description": "Version 2 adds web content; version 3 adds native controls.", "enum": [1, 2, 3] }, + "vcsProviders": { "description": "Required exactly when vcsProviders is non-empty. The provider protocol itself is negotiated in initialize (shared/contracts/vcs-provider-v1.md).", "enum": [1] } } }, "vendor": { @@ -43,11 +47,11 @@ }, "permissions": { "type": "array", - "items": { "enum": ["languageServer.launch", "process.build", "process.run", "process.test", "ui.contribute", "ui.web"] } + "items": { "enum": ["backend.launch", "credentials.store", "languageServer.launch", "process.build", "process.run", "process.test", "process.vcs", "project.generate", "ui.contribute", "ui.web"] } }, "activationEvents": { "type": "array", - "items": { "type": "string", "pattern": "^(onLanguage|onRun|onTest|onView|onCommand|onBuildSystem):[a-z0-9.+_-]+$" } + "items": { "type": "string", "pattern": "^(onLanguage|onRun|onTest|onView|onCommand|onBuildSystem|onVcsRoot):[a-z0-9.+_-]+$" } }, "languages": { "type": "array", @@ -68,6 +72,22 @@ "maxItems": 16, "items": { "$ref": "#/$defs/buildSystem" } }, + "backend": { + "description": "pluginAPIVersion 3, requires.backend 1: the plugin's own LSP base-protocol process. The host starts it on the first plugin-function call from one of the plugin's web pages, keeps one process per plugin independent of any workspace, and ends it when the plugin is disabled, updated, or uninstalled. Requires backend.launch and a web tool window; credentials.store lets it use the host's private credential store.", + "$ref": "#/$defs/backend" + }, + "vcsProviders": { + "description": "pluginAPIVersion 3: version control systems the plugin provides, each through its own LSP base-protocol process. Requires languageServer.launch, process.vcs, and one onVcsRoot: activation event per provider.", + "type": "array", + "maxItems": 8, + "items": { "$ref": "#/$defs/vcsProvider" } + }, + "projectGenerators": { + "description": "pluginAPIVersion 3 with requires.projectGenerators: entries of the New Project window. Requires the project.generate permission.", + "type": "array", + "maxItems": 16, + "items": { "$ref": "#/$defs/projectGenerator" } + }, "ui": { "type": "object", "additionalProperties": false, @@ -89,7 +109,8 @@ ], "oneOf": [ { "required": ["text"] }, - { "required": ["web"] } + { "required": ["web"] }, + { "required": ["native"] } ], "properties": { "id": { @@ -115,6 +136,7 @@ "type": "string", "maxLength": 65536 }, + "native": { "$ref": "native-ui.schema.json" }, "web": { "description": "ui 2: the plugin's own page, served from the package into a sandboxed host web view. Needs the ui.web permission.", "type": "object", @@ -190,6 +212,173 @@ } }, "$defs": { + "generatorCondition": { + "type": "object", + "additionalProperties": false, + "required": ["field"], + "properties": { + "field": { "type": "string", "description": "A field declared earlier in the same generator." }, + "equals": { "type": ["string", "boolean"] }, + "notEquals": { "type": ["string", "boolean"] }, + "oneOf": { "type": "array", "minItems": 1, "items": { "type": ["string", "boolean"] } } + }, + "oneOf": [ + { "required": ["equals"] }, + { "required": ["notEquals"] }, + { "required": ["oneOf"] } + ] + }, + "generatorConditions": { + "oneOf": [ + { "$ref": "#/$defs/generatorCondition" }, + { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/generatorCondition" } } + ] + }, + "generatorField": { + "type": "object", + "additionalProperties": false, + "required": ["id", "type", "title"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z][A-Za-z0-9]*$" }, + "type": { "enum": ["text", "enum", "boolean", "multiSelect", "runtime"] }, + "title": { "type": "string", "minLength": 1, "maxLength": 120 }, + "description": { "type": "string", "minLength": 1, "maxLength": 480 }, + "advanced": { "type": "boolean" }, + "visibleWhen": { "$ref": "#/$defs/generatorConditions" }, + "default": { "description": "text: a template over ${project.name} and earlier fields; enum: an option; boolean; multiSelect: options. Not allowed for runtime." }, + "options": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["value", "title"], + "properties": { + "value": { "type": "string", "pattern": "^[A-Za-z0-9._-]{1,120}$" }, + "title": { "type": "string", "minLength": 1, "maxLength": 120 }, + "group": { "type": "string", "minLength": 1, "maxLength": 120 } + } + } + }, + "presentation": { "enum": ["segmented", "menu"] }, + "pattern": { "type": "string", "maxLength": 256 }, + "maxLength": { "type": "integer", "minimum": 1, "maximum": 1024 }, + "placeholder": { "type": "string", "minLength": 1, "maxLength": 120 }, + "optional": { "type": "boolean" }, + "runtime": { "enum": ["java"] }, + "minimumVersion": { "type": "string" }, + "maximumVersion": { "type": "string", "description": "text and enum: the highest version the value may name, a template over earlier fields such as ${field.jdk.major}." } + } + }, + "generatorConditionalValue": { + "oneOf": [ + { "type": "string" }, + { + "type": "object", + "additionalProperties": false, + "required": ["value", "when"], + "properties": { "value": { "type": "string" }, "when": { "$ref": "#/$defs/generatorConditions" } } + } + ] + }, + "generatorStep": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "source"], + "properties": { + "kind": { "const": "files" }, + "source": { "type": "string", "description": "Package-relative template directory." }, + "target": { "type": "string", "description": "Project-relative directory template." }, + "when": { "$ref": "#/$defs/generatorConditions" } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "command", "mayUseNetwork"], + "properties": { + "kind": { "const": "command" }, + "command": { "type": "string", "pattern": "^[^/\\\\\\s]+$" }, + "arguments": { "type": "array", "maxItems": 64, "items": { "$ref": "#/$defs/generatorConditionalValue" } }, + "environment": { "type": "object", "maxProperties": 16, "additionalProperties": { "type": "string" } }, + "collect": { "type": "string" }, + "mayUseNetwork": { "type": "boolean" }, + "timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 1800 }, + "when": { "$ref": "#/$defs/generatorConditions" } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "url"], + "properties": { + "kind": { "const": "archive" }, + "url": { "type": "string", "description": "https address without query or fragment; http only for loopback hosts." }, + "query": { + "type": "array", + "maxItems": 32, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "value"], + "properties": { + "name": { "type": "string", "pattern": "^[A-Za-z0-9._-]{1,120}$" }, + "value": { "type": "string" }, + "when": { "$ref": "#/$defs/generatorConditions" }, + "omitEmpty": { "type": "boolean" } + } + } + }, + "when": { "$ref": "#/$defs/generatorConditions" } + } + } + ] + }, + "projectGenerator": { + "type": "object", + "additionalProperties": false, + "required": ["id", "displayName", "section", "steps"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" }, + "displayName": { "type": "string", "minLength": 1, "maxLength": 120 }, + "description": { "type": "string", "minLength": 1, "maxLength": 480 }, + "section": { "enum": ["project", "generator"] }, + "icon": { "$ref": "#/$defs/uiIcon" }, + "order": { "type": "integer", "minimum": 0, "maximum": 1000 }, + "fields": { "type": "array", "maxItems": 32, "items": { "$ref": "#/$defs/generatorField" } }, + "requirements": { + "type": "array", + "maxItems": 8, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "languageID": { "type": "string" }, + "buildSystemID": { "type": "string" }, + "when": { "$ref": "#/$defs/generatorConditions" } + }, + "oneOf": [ { "required": ["languageID"] }, { "required": ["buildSystemID"] } ] + } + }, + "steps": { "type": "array", "minItems": 1, "maxItems": 16, "items": { "$ref": "#/$defs/generatorStep" } }, + "openFiles": { + "type": "array", + "maxItems": 8, + "items": { + "oneOf": [ + { "type": "string" }, + { + "type": "object", + "additionalProperties": false, + "required": ["path", "when"], + "properties": { "path": { "type": "string" }, "when": { "$ref": "#/$defs/generatorConditions" } } + } + ] + } + } + } + }, "uiIcon": { "type": "object", "additionalProperties": false, @@ -244,6 +433,7 @@ "terminal", "search", "refresh", + "reset", "run", "debug", "settings", @@ -361,7 +551,7 @@ } }, "serverProcess": { - "description": "The launch fields a language server and a pluginAPIVersion 3 build provider share; both are LSP base-protocol processes and pass the same validation.", + "description": "The launch fields a language server, a pluginAPIVersion 3 build provider, and a VCS provider share; all are LSP base-protocol processes and pass the same validation.", "type": "object", "required": ["launch"], "properties": { @@ -451,6 +641,72 @@ } ] }, + "backend": { + "description": "Launch fields of the plugin backend. ${workspaceStateDir} is refused because the backend is not bound to a workspace.", + "type": "object", + "$ref": "#/$defs/serverProcess", + "unevaluatedProperties": false + }, + "vcsProvider": { + "description": "pluginAPIVersion 3: a VCS provider process (shared/contracts/vcs-provider-v1.md). Detection uses the markers only and never starts the process; capabilities are negotiated, not declared.", + "type": "object", + "$ref": "#/$defs/serverProcess", + "unevaluatedProperties": false, + "required": ["id", "displayName", "detection", "mayUseNetwork"], + "properties": { + "id": { + "description": "Stable VCS ID; git is reserved for the built-in support.", + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,31}$", + "not": { "const": "git" } + }, + "displayName": { "type": "string", "minLength": 1, "maxLength": 64 }, + "detection": { + "type": "object", + "additionalProperties": false, + "required": ["markers"], + "properties": { + "markers": { + "description": "Entries directly inside a root that mark it.", + "type": "array", + "minItems": 1, + "maxItems": 8, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "type"], + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 255, "pattern": "^[^/\\\\]+$", "not": { "enum": [".", ".."] } }, + "type": { "enum": ["directory", "file"] } + } + } + } + } + }, + "metadataPaths": { + "description": "Root-relative paths the host watches; a change means the status may be stale.", + "type": "array", + "maxItems": 16, + "items": { "type": "string", "minLength": 1 } + }, + "tool": { + "description": "The VCS client the provider drives. The host resolves it like a systemCommand launch and passes its absolute path with every request.", + "type": "object", + "additionalProperties": false, + "required": ["commands"], + "properties": { + "commands": { + "type": "array", + "minItems": 1, + "maxItems": 8, + "items": { "type": "string", "pattern": "^[^/\\\\\\s]+$" } + }, + "validationArguments": { "type": "array", "items": { "type": "string" } } + } + }, + "mayUseNetwork": { "type": "boolean" } + } + }, "minimumVersion": { "description": "pluginAPIVersion 2: one to three dot-separated numbers, for example 21 or 18.17.", "type": "string", @@ -583,7 +839,7 @@ }, "maxItems": 64 }, - "buildExecutable": { + "executableCandidate": { "oneOf": [ { "type": "object", @@ -618,8 +874,52 @@ "windows" ] } + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "priority": { + "type": "integer", + "minimum": -2147483648, + "maximum": 2147483647 + }, + "insertBefore": { + "type": "string", + "minLength": 1 + }, + "insertAfter": { + "type": "string", + "minLength": 1 } - } + }, + "allOf": [ + { + "not": { + "required": [ + "priority", + "insertBefore" + ] + } + }, + { + "not": { + "required": [ + "priority", + "insertAfter" + ] + } + }, + { + "not": { + "required": [ + "insertBefore", + "insertAfter" + ] + } + } + ] }, { "type": "object", @@ -652,8 +952,52 @@ "windows" ] } + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "priority": { + "type": "integer", + "minimum": -2147483648, + "maximum": 2147483647 + }, + "insertBefore": { + "type": "string", + "minLength": 1 + }, + "insertAfter": { + "type": "string", + "minLength": 1 } - } + }, + "allOf": [ + { + "not": { + "required": [ + "priority", + "insertBefore" + ] + } + }, + { + "not": { + "required": [ + "priority", + "insertAfter" + ] + } + }, + { + "not": { + "required": [ + "insertBefore", + "insertAfter" + ] + } + } + ] }, { "type": "object", @@ -679,8 +1023,52 @@ "windows" ] } + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "priority": { + "type": "integer", + "minimum": -2147483648, + "maximum": 2147483647 + }, + "insertBefore": { + "type": "string", + "minLength": 1 + }, + "insertAfter": { + "type": "string", + "minLength": 1 + } + }, + "allOf": [ + { + "not": { + "required": [ + "priority", + "insertBefore" + ] + } + }, + { + "not": { + "required": [ + "priority", + "insertAfter" + ] + } + }, + { + "not": { + "required": [ + "insertBefore", + "insertAfter" + ] + } } - } + ] }, { "type": "object", @@ -707,11 +1095,127 @@ "windows" ] } + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "priority": { + "type": "integer", + "minimum": -2147483648, + "maximum": 2147483647 + }, + "insertBefore": { + "type": "string", + "minLength": 1 + }, + "insertAfter": { + "type": "string", + "minLength": 1 } - } + }, + "allOf": [ + { + "not": { + "required": [ + "priority", + "insertBefore" + ] + } + }, + { + "not": { + "required": [ + "priority", + "insertAfter" + ] + } + }, + { + "not": { + "required": [ + "insertBefore", + "insertAfter" + ] + } + } + ] + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "settingID" + ], + "properties": { + "kind": { + "const": "setting" + }, + "settingID": { + "type": "string", + "minLength": 1 + }, + "platforms": { + "type": "array", + "items": { + "enum": [ + "macos", + "linux", + "windows" + ] + } + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "priority": { + "type": "integer", + "minimum": -2147483648, + "maximum": 2147483647 + }, + "insertBefore": { + "type": "string", + "minLength": 1 + }, + "insertAfter": { + "type": "string", + "minLength": 1 + } + }, + "allOf": [ + { + "not": { + "required": [ + "priority", + "insertBefore" + ] + } + }, + { + "not": { + "required": [ + "priority", + "insertAfter" + ] + } + }, + { + "not": { + "required": [ + "insertBefore", + "insertAfter" + ] + } + } + ] } ] }, + "buildExecutable": {"$ref": "#/$defs/executableCandidate"}, "buildVersionProbe": { "type": "object", "additionalProperties": false, @@ -1095,6 +1599,13 @@ "executables" ], "properties": { + "executableSetting": { + "deprecated": true, + "type": "string", + "description": "buildSystems 2: global file setting with effect reload or nextRun; empty uses discovery, nonempty must be used without fallback.", + "pattern": "^[a-z][a-zA-Z0-9]*$", + "maxLength": 120 + }, "executables": { "type": "array", "items": { diff --git a/shared/contracts/remote-workspace-protocol-v1.md b/shared/contracts/remote-workspace-protocol-v1.md new file mode 100644 index 0000000000..898dfd2b53 --- /dev/null +++ b/shared/contracts/remote-workspace-protocol-v1.md @@ -0,0 +1,213 @@ +# Remote workspace protocol v1 + +The protocol between a Lithe client and `lithe-remote-server`, which runs at +the end of a transport chain (SSH host, container, or a chain of both). The +Rust types in `rust/lithe-remote-protocol/` are the reference +implementation; this document is the compatibility surface for other +clients. Design rationale lives in +`.agents/notes/proposed/architecture/2026-10-24-remote-development-workspace-host.md`. + +## Starting the server + +| Command | Session | +| --- | --- | +| `lithe-remote-server --stdio` | Ends with the connection, terminating everything it started. | +| `lithe-remote-server --connect [--state-dir ]` | Persistent: relays to the daemon of session ``, starting it if needed. | + +`` is 1–64 characters from `[A-Za-z0-9._-]`. The daemon keeps its +socket, lock and log under `` (default `$HOME/.lithe-server`) in +directories only the user can access, and rejects peers with another user +id. A session has one client at a time: a new connection replaces the +current one. A session without a client ends after its idle timeout. + +## Stream + +1. The server writes the preamble `\nLITHE-REMOTE-PROTOCOL/1\n` once per + connection. Clients discard up to 64 KiB of bytes before it (shell + banners) and report them as diagnostics when the preamble never arrives. +2. Every following unit is a frame: a 4-byte big-endian length and that many + bytes of UTF-8 JSON. Frames larger than 16 MiB are rejected. +3. Closing the client-to-server stream is a disconnect. + +The server never listens on a network port; all traffic, including tunnels, +uses this stream. + +## Messages + +```json +{"type": "request", "id": 1, "method": "fs.stat", "params": {"path": "/srv"}} +{"type": "response", "id": 1, "result": {"kind": "directory", "size": 4096, "modifiedMs": 1761300000000, "readonly": false, "mode": 493}} +{"type": "response", "id": 2, "error": {"code": "NOT_FOUND", "message": "…", "details": {"path": "/x", "osError": 2}}} +{"type": "event", "event": "process.exit", "params": {"processId": 1, "exitCode": 0, "signal": null}, "seq": 42} +``` + +- Request `id`s are chosen by the client and unique per connection. + Responses may arrive in any order. A client that stops waiting discards a + late response. +- The first request must be `server.hello`; anything earlier fails with + `INVALID_REQUEST`. Events reach a connection only after it calls + `server.resume`, once. +- Every event carries `seq`, increasing by one per event in the session. +- Field names are camelCase. Byte payloads (`data`) are standard Base64. +- Paths are absolute paths on the remote host. Relative paths fail with + `INVALID_PATH`. +- Requests that change a running resource in order — `process.writeStdin`, + `process.closeStdin`, `process.kill`, `terminal.write`, `terminal.resize`, + `terminal.close`, `tunnel.write`, `tunnel.close`, `watch.stop`, + `walk.cancel` — are applied in the order sent. Other requests may complete + in any order. + +## Resources and events + +Processes, terminals, watches, tunnels and walks are identified by ids the +**client** chooses, unique within the session across reconnections (start +from `session.nextResourceId` in the hello result). Reusing the id of a live +resource fails with `RESOURCE_ID_IN_USE`; an unknown id fails with +`UNKNOWN_RESOURCE`. + +Every event is named `.` and carries `Id`, so a client +can route `terminal.output` with `terminalId: 7` to terminal 7, and can +subscribe before creating the resource. + +## Methods + +| Method | Params | Result | +| --- | --- | --- | +| `server.hello` | `protocolVersion`, `clientName`, `idleTimeoutSeconds?` | `protocolVersion`, `serverVersion`, `host`, `session` | +| `server.resume` | `afterSeq?` | `truncated`; then every kept event after `afterSeq`, followed by live events | +| `server.ack` | `seq` | `null`; events up to `seq` may be forgotten | +| `server.shutdown` | — | `null`; the session ends | +| `server.ping` | — | `{}` | +| `server.state` | — | running `processes`, `terminals`, `watches` | +| `fs.stat` | `path` | metadata of the path itself (links not followed) | +| `fs.readDir` | `path` | `entries` sorted by name byte order, `skippedNonUtf8` | +| `fs.readFile` | `path`, `offset`, `length` (≤ 8 MiB) | `data`, `eof`, `metadata` | +| `fs.writeFile` | `path`, `data` (≤ 8 MiB), `expected?`, `overwrite`, `createParents` | metadata after the write | +| `fs.createDir` | `path`, `recursive` | `null` | +| `fs.remove` | `path`, `recursive` | `null`; links are removed, never followed | +| `fs.rename` | `from`, `to`, `overwrite` | `null` | +| `fs.copy` | `from`, `to`, `recursive`, `overwrite` | `skippedSpecialFiles`; links are copied as links | +| `fs.realPath` | `path` | `path` with links resolved | +| `fs.setPermissions` | `path`, `mode` (≤ `0o7777`) | `null` | +| `fs.volumeInfo` | `path` | `caseSensitive` (`null` when unknown) | +| `walk.start` | `walkId`, `root`, `excludeNames`, `maxEntries?` | `null`; entries follow as events | +| `walk.cancel` | `walkId` | `null` | +| `watch.start` | `watchId`, `root`, `excludeNames` | `watchedDirectories` (per-directory backends) | +| `watch.stop` | `watchId` | `null` | +| `process.spawn` | `processId`, `argv`, `cwd?`, `environment` (`server` / `login`), `env`, `pipeStdin` | `pid` | +| `process.writeStdin` | `processId`, `data` | `null`; `BUSY` when the process stops reading | +| `process.closeStdin` | `processId` | `null` | +| `process.kill` | `processId`, `force` | `null`; signals every process of its session, also after `process.exit` while members run; `UNKNOWN_RESOURCE` once the session is empty | +| `terminal.open` | `terminalId`, `argv?`, `cwd?`, `env`, `columns`, `rows` | `pid`; default program is the login shell | +| `terminal.write` | `terminalId`, `data` | `null`; `BUSY` when not read | +| `terminal.resize` | `terminalId`, `columns`, `rows` | `null` | +| `terminal.close` | `terminalId` | `null`; `SIGHUP` to its session, `SIGKILL` after 2 s | +| `environment.resolve` | `refresh` | `environment`, `source`, `diagnostics?` | +| `tunnel.open` | `tunnelId`, `target` (`{kind: tcp, host, port}` / `{kind: unix, path}`) | `null`; `CONNECT_FAILED` on failure | +| `tunnel.write` | `tunnelId`, `data` | `null`; `BUSY` when the peer stops reading | +| `tunnel.shutdownWrite` | `tunnelId` | `null`; queued data is sent, then the write side is shut down; replies keep arriving | +| `tunnel.close` | `tunnelId` | `null`; aborts both directions, queued data is discarded | +| `ports.listening` | — | sorted `ports` (`address`, `port`) | + +`fs.copy` refuses, with `INVALID_REQUEST`, to copy a file onto itself — +directly, through a symbolic link in either direction or onto a hard link — +and to copy a directory into itself after resolving links. Sockets, FIFOs and +devices are never copied: at the top level the request fails with +`UNSUPPORTED`, inside a tree they are skipped and counted. + +`fs.writeFile` replaces the file atomically through a temporary sibling and +writes through symbolic links. With `expected` (`size`, `modifiedMs`) it fails +with `CONFLICT` unless the file still matches; without it, an existing file is +only replaced when `overwrite` is set. A write with neither `expected` nor +`overwrite` only creates: when two such writes race, exactly one succeeds and +the other fails with `ALREADY_EXISTS`. When a file is replaced, the temporary +file never has broader permissions than the original. + +`process.exit` and `terminal.exit` report the leader. The server keeps owning +its session until no member is left, so members that outlive the leader can +still be killed and are cleaned up when the session ends; `server.state` +marks such processes with `leaderExited`. + +When a session ends, the server terminates everything it still owns +(`SIGTERM` or `SIGHUP`, then `SIGKILL` after 2 seconds to sessions that still +have members), lets running requests finish for up to 5 seconds, and exits. + +`environment.resolve` asks the user's login shell, interactive first, for its +environment and caches it for the session. `process.spawn` with +`environment: login` uses that environment as the base. + +## Events + +| Event | Params | +| --- | --- | +| `process.output` | `processId`, `stream` (`stdout` / `stderr`), `data` | +| `process.exit` | `processId`, `exitCode`, `signal` — always after that process's output | +| `terminal.output` | `terminalId`, `data` | +| `terminal.exit` | `terminalId`, `exitCode`, `signal` — always after that terminal's output | +| `watch.changed` | `watchId`, `changes` (`path`, `kind`: `created` / `modified` / `deleted`), `overflow` | +| `watch.error` | `watchId`, `code` (`LIMIT_REACHED` / `ROOT_REMOVED`), `message` | +| `walk.entries` | `walkId`, `entries` (`path` relative with `/`, `kind`) | +| `walk.done` | `walkId`, `total`, `truncated`, `cancelled`, `skippedNonUtf8`, `unreadableDirectories` | +| `tunnel.data` | `tunnelId`, `data` | +| `tunnel.closed` | `tunnelId`, `error?` — the last event of the tunnel | + +`watch.changed` batches are coalesced and sorted by path. With `overflow` +changes were lost and the client must rescan the watched tree. + +## Event delivery and persistent sessions + +`server.hello` returns `session` with `persistent`, `resumed`, +`nextResourceId` and `lastEventSeq`. The session keeps every event until the +client acknowledges it with `server.ack` (clients acknowledge periodically, +for example every 256 events), up to 8 MiB; beyond that the oldest kept +events are dropped. Written to a socket is not received: when a connection +dies, events may still sit in its outbox or in kernel buffers, so they stay +kept until acknowledged. + +`server.resume` with `afterSeq` — the last `seq` the client received on an +earlier connection — sends every kept event after it, then live events, so +each event arrives exactly once. A reconnecting client first calls +`server.state` and subscribes to the resources it reattaches to, then +resumes, so their missed events reach those subscriptions. `truncated` means +events after `afterSeq` were dropped; the client re-reads state and rescans +watches. + +While no client is attached, a persistent session keeps processes, terminals +and watches running. Tunnels and walks belong to the connection that opened +them and end with it. After `server.shutdown` a persistent session is no +longer reachable, even while it is still terminating its processes; the next +connection with the same key starts a fresh session. + +## Host capabilities + +`server.hello` returns `host` with `os` (`linux` / `macos`), `arch` +(`x86_64` / `aarch64`), `hostName`, `userName`, `homeDirectory`, +`defaultShell`, `pathSeparator`, `process.orphanProtection` +(`parentDeathSignal` / `serverShutdown`), `watch.backend` +(`inotify` / `fsevents`), `watch.directoryLimit` and the sorted `features` +list. Clients branch on these facts, never on `os` alone, and check +`features` before calling optional methods. + +## Error codes + +`PROTOCOL_MISMATCH`, `INVALID_REQUEST`, `UNKNOWN_METHOD`, `INVALID_PATH`, +`NOT_FOUND`, `PERMISSION_DENIED`, `ALREADY_EXISTS`, `NOT_A_DIRECTORY`, +`IS_A_DIRECTORY`, `DIRECTORY_NOT_EMPTY`, `CONFLICT`, `TOO_LARGE`, +`UNKNOWN_RESOURCE`, `RESOURCE_ID_IN_USE`, `SPAWN_FAILED`, `CONNECT_FAILED`, +`BUSY`, `UNSUPPORTED`, `IO`. Clients additionally produce `TIMEOUT` and +`DISCONNECTED` locally. Messages are for people; OS error numbers stay in +`details`. + +## Compatibility + +Incompatible changes increment the protocol version, which `server.hello` +rejects with `PROTOCOL_MISMATCH`. Additive fields and new methods keep the +version and are announced through `features`. The daemon's socket name +includes the server version, so a client upgrade starts a new daemon rather +than reaching an older one. + +## Server identity + +`lithe-remote-server --identity` prints one JSON line with `serverVersion`, +`protocolVersion` and `sha256` of the executable itself. Bootstrappers reuse an +installed server only when the hash matches the artifact they ship. diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 4caa3730c6..e8f068259d 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -434,7 +434,7 @@ They must not derive these counters from context occupancy or subscription quota `acp-events-v1.json` covers completion both with and without usage. The launch JSON's optional `observeSubagents` (default `false`) negotiates the -pinned adapters' AIR v1 `nativeSubagentSessions` capability. macOS enables it; +pinned adapters' AIR v1 `nativeSubagentSessions` capability. macOS and Windows enable it; consumers without child-session UI omit it. Native execution and history remain upstream-owned. `update` preserves the immediate parent `sessionId` and the adapter's `subagent_spawned` payload (`subagentSessionId`, `name`, `task`, @@ -603,6 +603,80 @@ stable error code and a user-facing message: ### MCP configuration +`mcp.probe` accepts a user-reviewed connection `{transport, command?, args?, env?, +url?, headers?}` with only those fields. Agent Host uses the pinned official +rmcp SDK for initialization, session handling and paginated `tools/list`; it +never sends `tools/call`. Results are `{tools:[{name,description}]}`, sorted by +name, deduplicated, bounded to 1000 tools/20 pages/2 MiB of metadata. stdio input +and each HTTP response body are bounded before decoding. A probe has a +30-second request budget plus bounded cleanup, cancellation is cooperative, +and stdio children and their process groups are reaped on completion or cancel. +HTTP accepts HTTPS and loopback HTTP, rejects credential-bearing URLs and +redirects, preserves TLS verification and explicit headers, and uses no cookie +store. It does not inherit another Agent's OAuth session or unmapped native +configuration options. Legacy `sse` remains a configurable transport but this +SDK probe returns `not_supported` rather than inventing a healthy status. +The host must require an explicit detection action; launching local code or +package runners also requires the product's execution confirmation. + +`mcp.toolPolicy` is a pure text operation with `{source,content,server,tools, +owned?,tool?,enabled?}`. `source` is `codex` or `claude`; omission of `tool` and +`enabled` reads current policy. It returns `{content,changed,owned,tools, +hasRestrictions}`. The returned tools contain `{name,enabled,editable}`. +Codex uses entry-local `disabled_tools`, preserving `enabled_tools`; Claude's +input is its user `settings.json`, editing only `permissions.deny` with exact +`mcp____` rules. Unknown settings/formatting +outside the targeted value survive. Existing unowned denies, broad wildcard +rules, allow-list exclusions and ambiguous normalized names are not relaxed. +Hosts retain the exact added tool names by native path/server, validate full +file snapshots before atomic writes, and never treat a read as ownership. +Policies describe the user configuration; project/managed settings and live +Agent sessions may differ. Claude server renames are refused while user MCP +restrictions exist, to avoid re-enabling tools under a new name. + +`mcp.commandLine` accepts `{action:"format",command,args}` to return `{line}`, +or `{action:"parse",line}` to return `{command,args}`. POSIX shell-style quoting +is only a lossless text projection: hosts pass argv directly, never evaluate a +shell, substitutions, pipes or redirects. Blank and embedded-newline arguments +are preserved; malformed quoting is rejected. These operations and probe +results use `shared/fixtures/mcp/tools-v1.json` as a synthetic contract fixture. + +`mcp.parseRegistry` accepts `{ content: string }` (at most 1 MiB UTF-8) and +normalizes an official MCP Registry v0.1 response into `{ entries, nextCursor }`. +Each entry has `id`, `title`, `summary`, `version`, nullable `publisher`, +`source: "registry"`, nullable HTTPS `website` and +nullable `draft: McpServer`. At most 100 input entries are accepted. Inactive +records are skipped, pagination cursors remain opaque, and links containing +credentials or unresolved variables are not offered. Only static HTTPS remote +endpoints without header/variable inputs, or versioned npm/PyPI stdio packages +without additional runtime/package/environment inputs, using the public default +registry and runner, produce drafts. npm versions must be complete releases; +mutable tags and major/minor ranges remain documentation-only. Other +records remain documentation-only. This operation never fetches, installs or +executes anything. Hosts own cancellable directory requests and require the +user to review and save a draft before modifying Agent configurations. +`shared/contracts/mcp-registry-page.json` is a portable synthetic response. + +`mcp.featuredRegistry` accepts `{}` and returns the same page shape with +`source: "featured"` from the Git-owned, compile-time catalog at +`rust/lithe-core/src/mcp/featured.json`. It performs no network, filesystem, +package resolution or execution. Catalog package versions are pinned; remote +requirements remain subject to the publisher's documentation. Hosts show this +page without contacting the Registry, extend matching entries on explicit +search, and retain available results when a directory request fails. +The release catalog includes Context7, Playwright, Chrome DevTools, Microsoft +Learn, GitHub, DeepWiki, Exa, Filesystem, Fetch and Memory. A featured entry may +add `setup: "bearer-token" | "allowed-directories"`: its draft is an incomplete +template, requiring an Authorization bearer value or at least one absolute +directory argument after the fixed Filesystem package argument. Hosts must +collect and validate these inputs before saving and must not select Agents by +default. Public Registry parsing never copies `setup`, even when input uses a +featured-looking ID. `shared/fixtures/mcp/featured-setup-v1.json` illustrates the +optional field; ordinary entries omit it. Brand marks are platform-owned, +read-only bundled assets, selected only for known featured IDs; public entries +keep the transport fallback. No response can select arbitrary resources or +cause remote image fetching. + `mcp.parseConfiguration` accepts `{ source: "codex" | "claude", scope: "global" | "project", content: string, projectPath?: string }` and returns `{ servers: McpServer[] }`. Claude project scope requires `projectPath` and reads @@ -628,12 +702,22 @@ lowercase SHA-256 hashes with separate purposes: `mcp.planWrite` accepts `{ source, scope, projectPath?, content?: string | null, operation: "set" | "remove", server?: McpServer, name?: string, -previousName?: string, expectedHash?: string, owned?: OwnedEntry[] }`. `set` +previousName?: string, expectedHash?: string, owned?: OwnedEntry[], +toolFilters?: {enabledTools?: string[], disabledTools?: string[]} }`. `set` requires `server`, `remove` requires `name`, and project scope requires `projectPath`. `previousName` identifies the original of a rename. Each owned entry is `{ name, definitionHash, sourceHash?: string }`; hosts must record the hashes returned from the actual write plan rather than hashes from the draft. +Successful Codex removals also return `toolFilters` when native `enabled_tools` +or `disabled_tools` exists. These lists contain tool names only; an empty +allow-list stays empty and malformed lists reject removal. Hosts retain them +by native path/server only after the complete write batch succeeds, separately +from live source ownership. A later `set` applies retained filters only when +inserting a new Codex entry; existing/renamed native policy always takes +precedence. Returned hashes cover the restored policy. Claude's user deny +rules remain in its separate settings file during server removal. + Automatic replacement/removal, including unchanged set plans used for repeated registration, requires an owned record whose name, `definitionHash` and `sourceHash` match the current entry. An existing raw entry omitted from discovery @@ -654,11 +738,11 @@ it is not owned. For rename, it refers to `previousName`, and cannot authorize an unrelated existing destination; destination ownership checks still apply. The response is `{ content, changed, action, conflict, definitionHash?, -sourceHash? }`. Actions are `inserted`, `updated`, `unchanged`, `removed`, `absent`, +sourceHash?, toolFilters? }`. Actions are `inserted`, `updated`, `unchanged`, `removed`, `absent`, or `conflict`; conflicts leave the input unchanged. Successful set plans return both hashes computed by parsing the actual output entry, including preserved unknown fields. Unchanged set plans also return the current entry's hashes; -removal/absence normally has no entry hashes. Hashes sent on the draft server +removal can return the old definition hash for filter retention; absence has no entry hashes. Hashes sent on the draft server are never trusted as write output or persisted into Agent configuration. ### Agent adapters @@ -683,6 +767,89 @@ and the 64 KiB limit still fail with redacted errors. Omitting this flag keeps the existing parser behavior; saving still requires all fields, a compatible protocol and explicit consent for HTTP. +`agent.instructions.read` takes `{agentId}` (`codex-acp` or `claude-acp`) and +returns `{agentId, path, content, exists, revision}`. It does not create files. +`agent.instructions.save` takes `{agentId, content, expectedPath, expectedRevision}`. +The host resolves the native user profile again and rejects a changed effective +path or byte revision before replacing the file. The result is a fresh effective +snapshot: clearing a Codex override may return the base `AGENTS.md` instead. +No prompt injection, account settings, repository files, or native history are +modified. Files must be regular UTF-8 files at most 1 MiB; terminal symlinks are +rejected. Read-only files cannot be overwritten. Existing file permissions are +preserved; Unix new files use `0600` and new directories use `0700`. +Save staging is a sibling temporary file removed on completion/failure. +Other Lithe saves are serialized by a non-waiting lock; external writers do not +share it, so revision checks are optimistic rather than an OS-wide transaction. +Errors use the existing envelope codes with stable `details` reasons including +`unsupported_agent`, `file_access`, `unsafe_file`, `file_changed`, `file_too_large`, +`invalid_utf8`, `profile_missing`, `profile_relative`, and `busy`. +The fixture is `shared/fixtures/agent/agent-instructions-v1.json`. + +`agent.instructions.shared` takes a platform-selected absolute `dataDirectory` +from the writable application-support adapter plus one tagged action: + +- `read`: returns `{file, agents}` without creating files. `file` is the shared + `Shared.md` snapshot; each Agent reports `{agentId, file, enabled, needsSync, + ownLineCount, issue}`. A native read or marker failure sets `enabled: null` and + an actionable issue rather than claiming disabled. +- `save`: takes `content`, `expectedRevision` for Shared.md and `files` guards + (`{agentId,path,revision}`) for targets. It saves the shared source, then updates + already-enabled native targets. A failed target retains its bytes and an issue; + other targets continue. `needsSync` compares the actual block with the saved source. +- `setEnabled`: takes `agentId`, `enabled`, the shared `expectedRevision` and one + `file` guard. It inserts/replaces or removes only the Lithe-delimited block in the + effective native file; it never disables the Agent or removes its own instructions. + +Marker tokens are `` and +``. The owned range includes its separator +newlines, so disabling restores the original bytes. Duplicate, incomplete or edited +markers are rejected; shared content may not contain the marker tokens. Native +source selection, size, permission, symlink and optimistic revision guards remain +those of `agent.instructions.save`. A nonblocking OS file lock in `.sync.lock` +serializes shared writes across app processes. Shared.md uses the same 1 MiB bound, +private Unix modes and atomic replacement; source/target commits are individually +atomic and partial success is explicit. Cancellation stops remaining targets and the +next page read reconciles actual state. None of these files is a build cache or +installed resource. The fixture is `fixtures/agent/shared-instructions-v1.json`. + +`agent.hooks` manages a platform-owned writable Hook library. Requests take +`dataDirectory` and a strict `action`: `read`, `save`, `remove`, or `sync`. +Mutations require `expectedRevision` and native `files` guards containing +`agentId`, `path`, and `revision`. Guard paths never select write destinations: +Agent Host resolves `CODEX_HOME/hooks.json` and `CLAUDE_CONFIG_DIR/settings.json` +using native profile defaults when environment overrides are absent. + +`save` also takes `hook`: a UUID `id`, unique ASCII `name` (1–80 letters, +digits, hyphens or underscores), `description` (at most 200 Unicode scalars), +`event` (`turn-end`, `after-tool`, `before-tool`, `prompt`, `session-start`), +`matcher` (`all`, `shell`, `edit`), nonempty UTF-8 `command` (at most 32768 bytes), +nullable `timeoutSeconds` (1–86400, null inherits the native default), selected +`agents` (`codex-acp`, `claude-acp`), `enabled`, and `pendingRemoval: false`. +Only tool events accept a non-`all` matcher. `remove` takes `id`; `sync` retries +the existing desired state. Neither configuration management nor reading the +library executes commands or grants native trust. + +The result contains source `revision`, flattened `hooks` with actual +`configuredAgents` and `needsSync`, plus `targets` containing `agentId`, nullable +`file` guard, nullable `issue`, and `disabled` (Claude's `disableAllHooks`). +Native configuration contents, which can include secrets, are never returned. +Desired state is committed before target writes; target failures remain visible +and removal failures retain a `pendingRemoval` row. A nonblocking OS file lock +coordinates library writers. Each bounded UTF-8 file replacement is guarded and +atomic; cross-file synchronization is explicitly partial, with write-ahead +ownership records permitting recovery after interruption. Native duplicate JSON +keys, malformed JSON, symlinks, nonregular files and changed snapshots are rejected. +Existing unknown JSON values survive writes, while formatting is normalized. + +Only exact recorded placements can be retired. Native groups are cleared to +`{"hooks":[]}` instead of compacted, preserving Codex's index-based trust keys. +Unchanged commands keep their original positions; edited commands do not inherit +trust. A matching user-owned entry is never claimed or duplicated. Codex still +requires native Hook enablement and approval in `/hooks`; `configuredAgents` +does not claim execution. Source, ownership journal, lock and temporary siblings +are user state outside installed resources and cannot be reused across worktrees. +The shared fixture is `shared/fixtures/agent/hooks-management-v1.json`. + `agent.status`, `agent.install`, `agent.uninstall`, and `agent.installCli` manage ACP adapters in `/agents/`, using the Node.js and npm the user installed. Lithe never installs Node.js or npm; the agents' own command-line tools are installed only through `agent.installCli` on an explicit user action. `agent.status` @@ -773,8 +940,8 @@ package manager owns the download and Lithe does not infer bytes from logs. | `community.discourse.search` | Search normalized topics and sanitized posts | | `editor.lineEdit` | Apply a deterministic line-level text transform and return the replacement plus selection to restore | | `editor.lineCommentToken` | Resolve the line comment token for a file extension or language id | -| `workspace.snapshot` | Enumerate visible workspace nodes and relative file paths; `nestedWorktrees` lists linked worktrees checked out inside their own repository, which are left out of the tree, files, and search index ; `includeTree: false` returns only `files` and a childless `root` | -| `workspace.listDirectories` | Return one visible level of children for each requested workspace-relative directory (`""` is the root); missing, hidden, symlinked, or nested-worktree paths are omitted, and each listing reports the nested worktrees it left out. Child directories have `children: []` only when a shallow probe proves them visibly empty; otherwise children remain omitted (unloaded), with no recursive tree allocation | +| `workspace.snapshot` | Enumerate visible workspace nodes and relative file paths; `nestedWorktrees` lists linked worktrees checked out inside their own repository, which are left out of the tree, files, and search index ; `includeTree: false` returns only `files` and a childless `root`; `files` (and the tree, when included) keep the lowercase traversal order that bounded consumers such as Windows Quick Open rely on, not the natural display order of `workspace.listDirectories` | +| `workspace.listDirectories` | Return one visible level of children for each requested workspace-relative directory (`""` is the root); missing, hidden, symlinked, or nested-worktree paths are omitted, and each listing reports the nested worktrees it left out. Child directories have `children: []` only when a shallow probe proves them visibly empty; otherwise children remain omitted (unloaded), with no recursive tree allocation. Children list directories first, then names in IDEA's natural file-name order (digit runs compare by value, case-insensitive, `-` next to `_`; fixture `shared/fixtures/workspace/file-name-order-v1.json`) | | `workspace.repositories` | Discover deterministic Git repository roots for an opened workspace | | `workspace.search` | Search visible file names and UTF-8 text files | | `workspace.searchEverywhere` | Search visible file names, Java types/methods, and UTF-8 text files | @@ -836,6 +1003,8 @@ package manager owns the download and Lithe does not infer bytes from logs. | `lsp.request` | Submit a typed semantic request and return an opaque operation ID | | `lsp.buildRequest` | Forward one planned `lithe/build/*` request to a session that negotiated the build-provider protocol | | `lsp.functionRequest` | Invoke one registered plugin function on a session its plugin started | +| `lsp.hostResponse` | Answer one host-service request a plugin backend sent | +| `lsp.vcsRequest` | Send one VCS operation, planned by Core, to a session that negotiated the VCS-provider protocol | | `java.navigationMarkers` | Resolve versioned Java gutter markers from bounded JDT LS semantic requests | | `java.resolveNavigation` | Resolve one Java gutter marker to normalized parent or implementation locations | | `lsp.cancelOperation` | Cancel one pending semantic operation | @@ -863,6 +1032,7 @@ package manager owns the download and Lithe does not infer bytes from logs. | `git.initialize` | Initialize a directory outside existing repositories without staging or committing | | `git.configureIdentity` | Save or clear one local/global `user.name` or `user.email` override | | `git.status` | Resolve the repository, current branch, and working-tree changes | +| `git.untrackedDirectory` | List one level of a summarized untracked directory: its files and subdirectory summaries | | `git.commitState` | Read exact HEAD, symbolic branch and index preconditions for a workspace commit | | `git.watchContext` | Resolve the repository and absolute Git metadata roots needed by native file watchers | | `git.worktrees` | Return deterministic registered-worktree metadata without scanning each checkout | @@ -876,6 +1046,12 @@ package manager owns the download and Lithe does not infer bytes from logs. | `git.executionInspect` | Inspect executable capabilities, configuration provenance and effective Fetch preferences | | `git.executionConfigure` | Explicitly save or clear one allowlisted value in a selected config scope | | `git.authRespond` | Answer or cancel a live authentication challenge once | +| `remote.probe` | Check that an SSH connection reaches a supported host | +| `remote.authRespond` | Answer or cancel a prompt relayed by `remote.probe` or `remote.connect` once | +| `remote.connect` | Open a connection, installing the shipped server when needed | +| `remote.readDir` / `remote.readFile` / `remote.writeFile` | List, read or save over an open connection | +| `remote.disconnect` | Close an open connection and its OpenSSH process tree | +| `remote.closeAll` | Close every open connection; the host calls it at application exit | | `git.historyRewritePreview` | Review undo, message edit, squash, or drop with complete messages, eligibility, and an immutable checkout expectation | | `git.rebasePreview` | Resolve the complete local linear range strictly after a selected unchanged base | | `git.rebaseStart` | Start a reviewed native interactive rebase with persisted messages and recovery identity | @@ -910,7 +1086,7 @@ package manager owns the download and Lithe does not infer bytes from logs. | `plugins.declarative.runPlan` | Expand a declarative plugin's run template for one workspace file | | `plugins.declarative.discoverTests` | List a declarative plugin's workspace and file test items | | `plugins.declarative.testPlan` | Expand a declarative plugin's test template for a workspace, file, or test case | -| `plugins.declarative.languageServerLaunchPlan` | Expand a declarative plugin's language-server or build-provider launch with host directories and architecture | +| `plugins.declarative.languageServerLaunchPlan` | Expand a declarative plugin's language-server, build-provider, or VCS-provider launch with host directories and architecture | | `plugins.declarative.resolveSettings` | Merge a plugin's stored global and project setting layers into effective values with their source | | `plugins.declarative.workspaceConfiguration` | Expand a language server's `workspaceConfiguration` with the merged setting values | | `plugins.declarative.checkRuntimeVersion` | Decide whether a launch runtime's version output meets the declared minimum version | @@ -928,6 +1104,14 @@ package manager owns the download and Lithe does not infer bytes from logs. | `build.decodeDependencies` | Decode dependency output or a provider result into one project's bounded tree | | `build.syncFingerprint` | Digest the files a build model depends on, to tell whether it is stale | | `build.languageImport` | Map a build system's options to the Java language server's Maven or Gradle project import | +| `vcs.detectRoots` | Find the VCS roots of a workspace from static markers, with registered providers, ambiguous directories, and applied mappings | +| `vcs.ownerOf` | Assign workspace paths to the detected root that owns each | +| `vcs.decodeReply` | Validate a VCS provider's reply and return the normalized result | +| `project.generatorCatalog` | List the project generators of the given plugins, with the plugins that provide each requirement | +| `project.generatorForm` | Evaluate a generator's form: derived defaults, visibility, problems, and active requirements | +| `project.generationPlan` | Expand a filled-in form into the files, commands, and archive downloads that create a project | +| `project.checkName` | Check that a name can be a new project's directory name | +| `project.extractArchive` | Extract a downloaded project archive into the staging directory, refusing unsafe entries | | `plugins.marketplace.validateIndex` | Validate a marketplace index and report each plugin's availability for one platform | | `plugins.marketplace.resolveSource` | Resolve a user-entered marketplace source to the index URL to fetch | | `plugins.marketplace.installReview` | List a package's permissions and programs, and those not yet approved | @@ -1400,24 +1584,50 @@ an upstream. If the push fails, the local branch is intentionally retained so the user can fix credentials or connectivity and retry without losing commits. `git.pushPreview` accepts `root`, an optional complete local `gitReference` or -legacy `reference`, an optional bounded `limit`, and `pushTags`. It returns `localBranch`, -`localHead`, `remote`, `remoteBranch`, nullable `remoteTrackingOid`, nullable -`upstream`, exact reviewed `tags`, `commits`, and `hasMore` using -`shared/fixtures/git/push-preview-v1.json`. The push destination follows +legacy `reference`, an optional bounded `limit`, `pushTags`, and an optional +`pushTarget`. It returns `localBranch`, +`localHead`, `remote`, `remoteBranch`, `targetKind`, `createsRemoteBranch`, +nullable `remoteTrackingOid`, nullable `upstream`, exact reviewed `tags`, +`commits`, and `hasMore` using `shared/fixtures/git/push-preview-v1.json`. +`targetKind` describes how `remoteBranch` relates to the local branch: +`tracking` when both names match, `publish` when the branch has no upstream on the +selected remote and will be published under its own name, and `divergent` when the +upstream branch has a different name. `createsRemoteBranch` reports whether +`remoteBranch` is still absent on the remote. The push destination follows `branch..pushRemote`, then `remote.pushDefault`, the configured upstream remote, `branch..remote`, and finally `origin` or the first configured -remote. A destination without a fetched tracking reference previews commits not -reachable from that remote. A reviewed `push` mutation sends these resolved fields -back as `expectedPush`; Core rejects a stale local tip, destination, or tracking OID -before starting Git. `force` binds `--force-with-lease` to the reviewed destination -OID when `expectedPush` is present, and Core also validates the reviewed tag -identities; `pushTags` accepts `none`, `all`, or `reachable` -and maps to no tag option, `--tags`, or `--follow-tags` respectively. Legacy push -callers may omit `expectedPush`. A reviewed push uses the preview's immutable -`localHead` OID as the refspec source, so a repository change after validation -cannot add unreviewed commits to the operation. Because Git cannot infer an -upstream from an OID source, Core explicitly configures the reviewed local -branch after a successful first push. +remote. An upstream only names the destination when it lives on the selected +remote, so a `pushRemote` or `remote.pushDefault` override falls back to the +same-named branch on that remote. A destination without a fetched tracking +reference previews commits not reachable from that remote. A reviewed `push` +mutation sends these resolved fields back as `expectedPush`; Core rejects a stale +local tip, destination, or tracking OID before starting Git. An optional +`expectedPush.targetKind` is compared only when the caller supplies it, so a +caller that predates target kinds keeps its previous behaviour instead of being +rejected as stale. + +A `divergent` destination is never pushed implicitly. `pushTarget` accepts +`publish` (send the branch to its same-named remote branch and repoint the upstream +there after a successful push) or `upstream` (keep the differently named upstream +branch, which is the only case allowed to update it, and leave the tracking +configuration untouched). `git.pushPreview` and `push` resolve the destination +through the same function with the same `pushTarget`, so the preview describes the +branch, tracking OID and commits that choice would update, and a reviewed push is +validated against exactly that destination. A client therefore previews with the +choice, shows the result, and pushes with the same `pushTarget` and the +`expectedPush` taken from that preview; a tracking configuration that moves in +between is rejected as stale. Without `pushTarget`, a `divergent` preview reports +the same-named destination and `push` rejects the request with `invalid_request`. +`pushTarget` is ignored for branches that are not `divergent`. +`force` binds +`--force-with-lease` to the reviewed destination OID when `expectedPush` is +present, and Core also validates the reviewed tag identities; `pushTags` accepts +`none`, `all`, or `reachable` and maps to no tag option, `--tags`, or +`--follow-tags` respectively. Legacy push callers may omit `expectedPush`. A +reviewed push uses the preview's immutable `localHead` OID as the refspec source, +so a repository change after validation cannot add unreviewed commits to the +operation. Because Git cannot infer an upstream from an OID source, Core +explicitly configures the reviewed local branch after a successful first push. New reference-based workflows send `gitReference` as `{ "fullName": string, "shortName": string, "kind": "local" | "remote" | "tag" }`. Core verifies @@ -1742,8 +1952,16 @@ Agent completion, or workspace tree visibility. The existing case-insensitive `*`/`?` matcher applies to a basename or root-relative path (`*` can span `/`). A trailing `/` explicitly marks a root-relative directory glob: match it before descending into a directory and against ancestors of directly -reported file updates. The UI adds the root form for leading `**/`; ordinary -file globs retain their file-only semantics. Empty user exclusions preserve the +reported file updates. A leading `**/` stands for zero or more folders in the +matcher itself, so `**/dist/` covers a root-level `dist` as well as nested ones +(no client has to add the root form). An entry ending in +`/*` or `/**` is the same folder rule as one ending in `/`, so `.lithe/*` hides the +folder and everything below it rather than leaving an empty folder; ordinary file globs +and plain names (such as `.lithe`) retain their file-only semantics. The macOS file +watcher is the one place that does not follow the hiding rules for the workspace-root `.lithe` +folder: hiding it removes it from the tree and listings, but its file events are still +delivered (except built-in noise such as `.lithe/run/local.json`) because editing its JSON +files reloads project services. Empty user exclusions preserve the normal visibility rules. Search settings changes invalidate only the search cache, not the workspace tree. Each root retains at most one content and one names-only rule variant; superseded allocations are released when active @@ -2052,7 +2270,9 @@ provider ID, selected executable/arguments/environment, root URI, working directory, initialization options, optional runtime executable, `jdtlsLaunchResources`, cache directory, `workspaceFingerprint`, and the optional `pluginId` of the plugin that owns the process, its optional -`workspaceConfiguration` (an object, plugin-owned sessions only), plus +`workspaceConfiguration` (an object, plugin-owned sessions only), its +optional `hostServices` (the launch plan's `backend.hostServices`; +plugin-owned sessions only, and only services Core defines), plus initialize, post-initialize readiness, request, Java project build (`javaBuildTimeoutMilliseconds`), and shutdown deadlines. Java callers may also provide the versioned `mavenContext` accepted by @@ -2315,6 +2535,23 @@ event carries the provider's reply unchanged as `result.value`, which the host passes to `build.decodeModel` or `build.decodeDependencies` as `result`; cancellation and late results follow `lsp.cancelOperation`. +`lsp.vcsRequest` accepts `{ sessionId, operationId?, vcs, operation, root, +tool?, arguments?, timeoutMilliseconds? }` and returns `{ operationId }`. +`operation` is a [VCS provider contract](vcs-provider-v1.md#lspvcsrequest) +operation name, never a method; Core checks `arguments`, adds the absolute +`root` and `tool` paths, and writes the operation's `lithe/vcs/*` request. +Only plugin-owned sessions offer `capabilities.experimental.litheVcs: +{ versions: [1] }`; a session whose process did not answer with a VCS ID and +every required capability, or answered another VCS than `vcs`, refuses the +request with `not_supported` and details `vcs.provider.unavailable`, and an +operation whose capability was not negotiated is `not_supported` with details +`vcs.capability.unsupported`. A successful negotiation emits a `vcsProvider` +runtime event whose `result` is `{ vcs, capabilities }`. The +`requestCompleted` event carries the provider's reply as `result.value`, which +the host passes to `vcs.decodeReply`; a failed request carries a `vcs.*` +`error.code` (the provider's own code only when the contract lists it). +Timeout, cancellation, and late results follow `lsp.buildRequest`. + A plugin-owned session started with `workspaceConfiguration` answers each `workspace/configuration` item from it by `section` (an exact key wins over a dotted path; no section returns the whole object, a missing one `null`) and @@ -2329,8 +2566,8 @@ sessions refuse it with `invalid_request`. functionID }, params?, timeoutMilliseconds? }` and returns `{ operationId }`. It sends `lithe/function/invoke` to a session started with `lsp.startServer.pluginId` equal to `function.pluginID` that negotiated -`capabilities.experimental.litheFunctions` version 1 and registered the -function. `params` must be a JSON object and defaults to `{}`. Refusals are +`capabilities.experimental.litheFunctions` version 1 or 2 and registered +the function. `params` must be a JSON object and defaults to `{}`. Refusals are `invalid_request`, `not_supported`, or `permission_denied` with a `plugin.function.*` code in `details`, in the order the [plugin contract](plugins/v1/README.md#plugin-functions) defines. The @@ -2358,6 +2595,18 @@ is `pluginFunctions` in the session's client state. Sessions without `lsp.startServer.pluginId` neither offer nor accept `litheFunctions`, retain no registry, and publish no function-discovery events. +A session that negotiated `litheFunctions` version 2 may send the +[host-service requests](plugins/v1/README.md#host-services-protocol-version-2) +its `hostServices` grant. Core checks method, grant, and params, then +publishes the runtime event `hostRequest` with `method` and `result: { +hostRequestID, service, params }`; refusals go back to the backend without an +event. `lsp.hostResponse` accepts `{ sessionId, hostRequestID, result? }` or +`{ sessionId, hostRequestID, error: { message } }`, returns `{}`, and sends +`result` (default `null`) or `-32603` with `data.code: "plugin.host.failed"` +to the backend. An unknown or already answered `hostRequestID` is +`invalid_request`. At most 16 host requests are unanswered per session; more +are refused with `plugin.host.failed`. + `lsp.request` accepts a semantic `operation` plus the operation-specific URI, position, range, diagnostics, item, action, or command fields, and returns `{ operationId }`. Supported operations include @@ -2452,7 +2701,7 @@ channel until events are queued or the timeout elapses, then drains the same typed events. Hosts should use `waitEvents` so idle sessions do not poll. Event types include `stateChanged`, `featuresChanged`, `diagnostics`, -`requestCompleted`, `requestProgress`, `functionsChanged`, +`requestCompleted`, `requestProgress`, `functionsChanged`, `hostRequest`, `serverInfoChanged`, and `log`. Every request completes at most once with either `result` or a structured runtime error containing provider/session, stage, optional method/document/request, stable code, and @@ -2471,7 +2720,10 @@ metadata never stores an absolute workspace or storage path. `history.record` accepts `workspaceRoot`, a relative `path`, a `reason`, and optional UTF-8 `content`; when content is omitted the core reads the workspace file. Records are versioned, de-duplicated against the latest snapshot, capped at 100 entries -per file, and pruned after 30 days. Invalid metadata and missing snapshot files +per file, and pruned after 30 days. `history.record` and `history.entries` apply +the same `hiddenDirectoryNames` / `hiddenFilePatterns` matcher as the workspace +tree, so a hidden path (including folder patterns such as `**/generated/` or +`.lithe/*`) is neither recorded nor listed. Invalid metadata and missing snapshot files are ignored. `history.entries` returns Unix-second timestamps and relative `contentPath` values. `history.content` rejects traversal, `history.relocate` updates metadata and storage paths, and `history.rename` and @@ -3051,6 +3303,14 @@ selections the declaration does not allow are `plugin.build.invalidSelection`, output that does not fit its decoder is `plugin.build.invalidOutput`, and a malformed payload is an `invalid_request` failure. +Executable candidates are normalized by the shared plugin executable-resolution +mechanism before platform filtering. A `setting` candidate emits +`{kind: "setting", settingID, path: absolutePath | null}` in the same `executables` +list; null skips, invalid stored values reject the plan, and native validation +failure is terminal. There is no separate `configuredExecutable` result. +See the plugin contract's **Shared executable candidates** section for ordering, +capability negotiation and the compatibility adapter for published v2 previews. + Host-managed runtime probes use `java -version` (including Java 8), `node --version`, and `go version`. Hosts execute the returned arguments and pass combined stdout and stderr as `build.checkToolVersion.request.output`: @@ -3090,6 +3350,116 @@ inside `workspaceRoot` (and the given `outputFile`); none of the commands writes anything or starts a process. `shared/contracts/plugins/v1/fixtures/build-plans.json` holds the golden plans and decoded outputs. +### VCS providers + +The `vcs.*` commands implement API level 3 `vcsProviders` of +[`plugins/v1`](plugins/v1/README.md) and the +[VCS provider contract](vcs-provider-v1.md). Results use the +`plugins.declarative.*` envelope; a malformed payload, a relative +`workspaceRoot`, or an unknown operation is an `invalid_request` failure. + +| Command | Request | Result | +| --- | --- | --- | +| `vcs.detectRoots` | `{ workspaceRoot, plugins: [{ manifest }], mappings?: [{ id, vcs }] }` | `detection`: `{ providers, roots: [{ id, vcs, path, mapped }], ambiguous: [{ id, vcs }], unusedMappings, truncated, rejected: [{ index, error }], conflicts: [{ pluginID, vcs, by }] }` | +| `vcs.ownerOf` | `{ roots: [id], ambiguous?: [id], paths: [path] }` | `owners`: `[{ path, root }]` in request order; `root` is `null` outside every root or inside an unmapped ambiguous directory | +| `vcs.decodeReply` | `{ operation, capabilities, request?, reply }` | `result`: the normalized reply, or `plugin.vcs.invalidOutput` | + +`vcs.detectRoots` reads directory entries below `workspaceRoot` (at most eight +levels and 20,000 directories) and the marker names in its ancestors; it never +reads file contents, writes anything, or starts a process. The other two +commands touch no file. `shared/fixtures/vcs/model-v1.json` holds the +detection cases, decoded snapshots, and reply validation cases. +### Project generators + +The `project.*` commands back the New Project window. Payloads, results, and +problem codes are specified in the plugin contract's +[Project generators](plugins/v1/README.md#project-generators) section, and +`shared/contracts/plugins/v1/fixtures/project-plans.json` holds the golden +answers. Results use the `plugins.declarative.*` envelope: `catalog`, `form`, +`plan`, `name`, and `extracted`. Commands that take a manifest revalidate it +and answer `plugin.project.unavailable` for an undeclared generator. + +`project.generationPlan` reads template files below `packagePath` (bounded, +no symbolic links). `project.extractArchive` is the only command that writes: +it extracts into an existing staging directory the host created and never +replaces a file. Neither starts a process or downloads anything; the host +runs command steps, downloads archives, and moves the finished project into +place. + +### Remote connections + +`remote.probe` checks a saved SSH connection before a remote workspace is +opened. The request is `{ connection: { host, port?, user?, authentication?, +identityFile?, connectTimeoutSeconds?, keepAliveSeconds? } }`; unknown fields, +including any password, are rejected. `authentication` is `auto` (default), +`agent`, `privateKey` (requires `identityFile`), or `password`. `host` is a +name, address, or SSH configuration alias without a user. + +Core runs the system `ssh` with the probe of +[`remote-workspace-protocol-v1`](remote-workspace-protocol-v1.md)'s bootstrap +and relays every OpenSSH question through the same AskPass transport as Git. +Each question is an `authentication` event with `requestId`, `prompt`, +`secret`, and `attempt` on the request's event channel; the host answers it +once with `remote.authRespond` (`{ requestId, answer: string | null }`, +returning `{ accepted }`, with the bounds of `git.authRespond`). Null cancels +the prompt. Core never stores or emits an answer. + +The result is `{ status, host, diagnostics }`: + +| `status` | Meaning | +| --- | --- | +| `connected` | The shell ran and a server build exists; `host` is set | +| `unsupportedPlatform` | The shell ran, but no server build exists; `host` is set | +| `authenticationFailed` | Every offered credential was rejected | +| `hostKeyRejected` | The host key was declined or has changed | +| `unreachable` | The name did not resolve or the host could not be reached | +| `failed` | Another failure; see `diagnostics` | + +`host` is `{ system, machine, homeDirectory, serverTarget }` from `uname -s`, +`uname -m` and `$HOME`; `serverTarget` is the server's Rust target or `null`. +`diagnostics` is the redacted end of OpenSSH's standard error (at most +4 KiB). A malformed connection is `invalid_request`, a missing `ssh` is +`runtime_missing`, and cancellation or the five-minute bound are `cancelled` +and `timed_out`. + +`host.details` carries display facts read by the same shell, each nullable: +`hostName`, `operatingSystem`, `kernel`, `cpuCount`, `memoryBytes`, +`diskTotalBytes` and `diskAvailableBytes` (the file system holding `$HOME`), +and `uptimeSeconds`. + +#### Remote connections that stay open + +`remote.connect` takes `{ connection, servers: [{ target, path }], +serverVersion, installRoot? }`. `servers` are the read-only server builds the +host ships, by Rust target; `installRoot` defaults to `$HOME/.lithe-server`. +One OpenSSH process probes the host, keeps an installed server whose +`--identity` reports the same SHA-256 or uploads the matching build through +the same stream, and starts it, so the user authenticates once. Prompts are +relayed as for `remote.probe`. The result is `{ status, session, host, +diagnostics }`; `session` is `{ id, uploaded, hostName, userName, +homeDirectory }` and is set only for `connected`. A host without a matching +build is `unsupportedPlatform` and nothing is uploaded. + +Core keeps an open connection until `remote.disconnect` (`{ sessionId }`, +returning `{ closed }`) or until the host process exits; ending it terminates +the OpenSSH process tree. `remote.closeAll` (no payload, returning +`{ closed }` with the number of connections) ends all of them; the host calls +it while quitting because exiting without it can leave the OpenSSH tree and +the remote server running. Requests over it take `sessionId`: + +| Command | Request | Result | +| --- | --- | --- | +| `remote.readDir` | `{ sessionId, path }` | the remote protocol's `fs.readDir` result | +| `remote.readFile` | `{ sessionId, path }` | `{ data (base64), size, modifiedMs }`; files over 16 MiB are `not_supported` | +| `remote.writeFile` | `{ sessionId, path, data, expected? }` | `{ status: "saved", size, modifiedMs }` or `{ status: "conflict" }` | + +`remote.writeFile` replaces the file atomically on the host. With +`expected` (`{ size, modifiedMs }` from the read) a changed file is a +`conflict`; without it the file must not exist yet. A missing path is +`workspace_not_found`, a denied one `permission_denied`. A broken connection +fails with `connection_closed` and is forgotten, so the host reconnects; an +unknown or closed `sessionId` is also `connection_closed`. + ### Git patch discovery and rebase amendment preconditions `git.patchExport` accepts optional `metadataOnly` (default false). In metadata @@ -3120,6 +3490,16 @@ than being inserted as literal source text. The initialize handshake advertises incomplete items. Core forwards `labelDetails` and omits null `data` so `completionItem/resolve` can still produce import `use`/`import` edits. +The shared JDT adapter sends `java.completion.guessMethodArguments: "off"` +through initialization, configuration notifications and section queries on both +platforms. JDT LS therefore leaves constructor and method argument lists empty +while preserving the snippet cursor inside the parentheses. Other completion +settings declared in the provider's initialization options are retained for the +session in initialize, didChangeConfiguration, and parent/leaf section pulls +(including favoriteStaticMembers and filteredTypes). This does not disable snippet +support or strip parameter text from arbitrary completion items; the shared +configuration fixture is `shared/fixtures/lsp/java-completion-v1.json`. + ### Java preparation snapshot Java `projectPreparation` runtime events carry a `result` object with `phase` @@ -3178,6 +3558,20 @@ repositories). Untracked paths owned by a nested root are excluded from the parent list. Each change returns `canToggleStaging`; platforms render that eligibility instead of reinterpreting submodule dirt. +`git.status` accepts optional `summarizeUntrackedDirectories`. When true, Core +reads Git's collapsed untracked view and reports a wholly untracked directory +holding more than 100 non-ignored files as one change whose `path` ends with `/` +and which carries `untrackedFileCount`. Smaller directories keep one change per +file. A directory containing a nested repository is never summarized; it is +split until each summary is free of nested repositories, so staging (`stage`), +discarding (`discard`/`discardAll`) or diffing (`git.diff` with `untracked`) the +summary path acts on exactly the counted files. Omission preserves the +file-level projection. `git.untrackedDirectory` accepts `{ root, directory, +repositoryRoots? }`, where `directory` is a summary path ending with `/`, and +returns `{ changes }`: the directory's immediate untracked files plus each +subdirectory as a summary, using the same change shape and nested-root +exclusion as `git.status`. + `git.workspaceCommitPrepare` owns the complete multi-repository policy. It accepts `repositories: [{ id, root }]`, `message`, `amend`, `push`, `includeParentReferences`, optional `previous` session for retry, and optional @@ -3191,6 +3585,22 @@ finds real gitlink relationships, includes clean parents when requested, orders children first, and adds push-only child work where needed. Cycles fail closed. A changed reviewed plan must be displayed and confirmed again before any step. +When `push` is set, Core also checks whether any repository's branch tracks a +differently named remote branch (a `divergent` push destination, see +`git.pushPreview`). Those repositories are returned as `pushTargetQuestions: [{ +repositoryId, localBranch, remote, upstream, upstreamBranch }]` (omitted when +empty) and the session must not be stepped: when a step reaches such a repository, +Core refuses it before writing that repository's commit instead of committing +something it cannot push. Clients answer by preparing again +with `pushTargets: { : { choice, remote, remoteBranch } }`, where +`choice` is `publish` (`remoteBranch` is `localBranch`) or `upstream` +(`remoteBranch` is `upstreamBranch`). The answer names the remote and branch that +were shown; if the tracking configuration moves before the push, the answer no +longer matches and Core asks again. Each repository is answered and checked +separately, answers are stored in `plan.pushTargets`, and a retry restores and +re-validates them from `previous` without the client forwarding them. Routine +pushes (`tracking` or `publish` destinations) never produce a question. + An optional `pathScope: { include, paths: { : [] } }` restricts local changelist commits. With `include: true`, only the listed literal paths are allowed; with `include: false`, the listed paths are excluded. Missing @@ -3260,16 +3670,29 @@ bridge exposes the same lifetime and result contract. Fixtures live in `processLifecycle.resolve` accepts `{ context, settings, records }` and returns `{ gracefulStopMilliseconds, decisions }` with exactly one decision per record, in request order. `context` is `quit`, `closeTerminalTab`, `agentDisconnect`, -`crashRecovery`, `startupSweep`, or `finalSweep`. Each record carries a host-chosen `id`, a +`crashRecovery`, `startupSweep`, `finalSweep`, or `closeWorkspace` (a project or +window closes while Lithe keeps running). Each record carries a host-chosen `id`, a `kind` (`run`, `terminal`, `agentTask`, `languageServer`, `debugAdapter`, `pluginTool`, `sidecar`, `daemon`, `container`, `oneShot`), the `ownership` proof (`launched`, `scopeToken`, `instanceLabel`, `unproven`), and optionally `declaredPolicy`, `recognizerId`, `recognizedPolicy`, `detached`, `idle`, and `retained`, and `terminationApproved` (default `false`). Omitted settings take the documented defaults: busy terminals and Agent tasks are confirmed one by one, closing a busy terminal tab asks, -daemons and containers are asked about, startup leftovers are asked about, and +daemons and containers are asked about, startup leftovers are asked about, Run +sessions are listed before they are stopped (`runServicesOnExit: ask`), and ordinary processes get five seconds to stop (clamped to 1–60). +A `run` record with `launched` ownership is a Run session the host tracks +itself. During `quit` and `closeWorkspace` its action is `confirmStop` with +`runServicesOnExit: ask` and `terminate` with `runServicesOnExit: terminate` +(reason `runServicesSetting`). Hosts show every `confirmStop` record in one +confirmation whose only choices are stopping all of them or cancelling the +close; a Run session cannot be kept, because its process tree and output pipes +belong to Lithe. Other `run` records, such as inventory items found only by +their scope token, keep the default `terminate`. During `closeWorkspace`, +records that would need any other question (`confirmEach`, `askOnExit`) +`defer` with reason `decidedOnQuit`. + `finalSweep` runs after confirmation and without dialogs. Owned Agent tasks need `terminationApproved: true` to terminate; otherwise their action is `defer` and the host must preserve a recoverable record for individual startup confirmation. @@ -3285,7 +3708,8 @@ only through ancestry proved in the same snapshot. A decision reports the pre-setting `policy` (`terminate`, `confirmEach`, `askOnExit`, `external`), the `action` the host performs (`terminate`, -`confirm`, `ask`, `retain`, `defer`, `ignore`), and a stable `reason` for logs. +`confirm`, `ask`, `retain`, `defer`, `ignore`, `confirmStop`), and a stable +`reason` for logs. Unproven ownership is always `ignore`; a record the user kept is `retain`. Agent tasks are never ended during `crashRecovery` and are always confirmed during `startupSweep`, whatever the settings say. Hosts own discovery, @@ -3338,7 +3762,8 @@ scope-token ownership. Fixtures live in `recognizerId`. `answers` maps IDs to `terminate` or `keep`. It returns `{ terminate, keep, settings }`: -- `terminate` records end and `retain` records are kept. +- `terminate` and `confirmStop` records end and `retain` records are kept. + Hosts settle a `confirmStop` record only after the user approved stopping. - `confirm` and `ask` records follow their answer, and an unanswered one is kept. - `defer` and `ignore` appear in neither list. @@ -3394,3 +3819,70 @@ Core resolves its current stash position under the existing writer lease and rej an identity that is no longer present. Legacy positional references remain accepted. See `shared/fixtures/git/saved-stash-v1.json`. Native Shelf persistence is platform-owned; raw patch bytes are kept separately for index and working-tree versions. + +### Native plugin UI data + +`plugins.nativeUI.validateData` accepts `{ ui, data }`, where `ui` is the +validated native schema-1 declaration and `data` is a provider's `{ nodes }` +reply. It returns `{ status: "ok", nodes }` or +`{ status: "rejected", error: { code: "plugin.ui.invalidData", message } }`. +Malformed envelopes remain Core `invalid_request` failures. The operation uses +the same identity, literal label, nesting and node budgets as manifest validation +and rejects data above 1 MiB. Form declarations validate bounded input/select/checkbox +controls (including search/editor/segmented/stepper), typed scalar defaults, choices, +info, button action references/width policies and onCommit references; form source replies +require empty nodes. Hosts inject this typed operation into their +presentation feature; views never call the C ABI. Windows can reuse validation +when it later adds a native renderer; Core support does not advertise ui 3 on +that platform. + +### Agent skill management + +`agent.skills` accepts explicit `action` values `list`, `directories`, `read`, +`create`, `import`, and `setEnabled`, with `agentId` (`codex-acp` or `claude-acp`) and optional +absolute `workspace`. These management operations never create an Agent session, +send a prompt or stop a running conversation. Only an explicit `setEnabled` action changes native visibility settings. +The shared fixture is `shared/fixtures/agent/skills-management-v1.json`. + +`list` returns `skills`, `source`, `issues`, and native `directories` (`user`, +plus `repo` when a workspace is present). Each row has `name`, `description`, +absolute `path`, native `scope` (`user`, `repo`, `system`, `admin`), optional +`enabled` and optional `pluginId`. Paths are transient platform file-operation +metadata, not portable persisted identifiers. Rows are sorted by lowercased name +and path. Native Codex discovery uses the installed CLI's official `skills/list` +with the selected cwd and `forceReload: true`, reusing the bounded App Server +probe and process-tree cleanup. Unknown enablement remains null. Missing or +malformed native results fail explicitly; refresh failure retains previous rows +only within the same workspace and Agent. + +Claude `source: directory` is an explicitly partial inventory of +`CLAUDE_CONFIG_DIR/skills` (default `~/.claude/skills`) and the selected workspace's +`.claude/skills`. It does not emulate inherited-directory, plugin, policy or +session discovery. Its `enabled` reports configured visibility: user rows read user settings; repo rows merge user, project shared and project-local settings. Absent overrides mean on, off means false, name-only/user-invocable-only mean true. Invalid or unreadable configuration leaves the affected state null. This is not effective managed policy or evidence of session availability. Codex `source: agent` is native discovery. Platform UI +uses Global/Project as the main categories and Agent provenance as a filter. + +`setEnabled` requires an absolute discovered `source` path and boolean `enabled`; Claude also requires matching `scope`. It returns a fresh listing using the same shape as `list`. Codex validates the path with `skills/list`, calls official `skills/config/write`, then forces native readback and verifies the desired state. Its setting is global by path even for a project skill. Claude merges only the named `skillOverrides` on/off value into user `settings.json` for global skills, or project `.claude/settings.local.json` for project skills; it preserves unrelated JSON values and permissions, refuses malformed, linked or over-1-MiB files, uses a nonblocking cross-process lock and same-directory atomic replacement with a pre-publish snapshot check. Claude settings are by name, so same-named skills share a setting; project/managed settings can override user settings. Plugin skills are not managed through this operation. Existing session contents cannot be withdrawn: clients advise reconnecting, and never automatically interrupt a running conversation. No skill file is renamed or edited by toggling. + +`directories` returns only the native destination map and needs no installed +CLI. `read` takes the selected absolute `source` path to a `SKILL.md`, reads at +most 128 KiB of regular UTF-8 text and returns `content`. It never interprets or +executes instructions, scripts or dynamic content in the document. + +`create` requires `scope` (`user` or `repo`), portable `name` (1–64 lowercase +letters/digits separated by single hyphens), `description`, and `instructions`. +`import` requires `scope` and an absolute, user-selected `source` (`SKILL.md` or +its package directory). Writes return the resulting `path`. Codex targets +`~/.agents/skills` or `/.agents/skills`; Claude targets its native +configuration directory or `/.claude/skills`. Project writes require +an opened workspace. Imports preserve package contents and file permissions, +reject special files and symbolic links, and enforce 2,000 entries, 20 nested +levels, 128 KiB per SKILL.md, 8 MiB per resource file and 32 MiB total. Hidden staging is removed on failure or +cancellation; publication atomically refuses an existing target, including an +empty directory. Existing skills and import sources are never overwritten. +These are explicit user-owned documents, not downloaded/build/cache resources; +no app bundle, installation resource or shared worktree cache is writable. + +The macOS settings surface offers search, refresh, file details, native open and +reveal, creation and import. It keeps native loading separate from management +and rejects stale results after project changes. Windows can use the same Core +operations; the settings UI and Windows runtime validation remain pending. diff --git a/shared/contracts/vcs-provider-v1.md b/shared/contracts/vcs-provider-v1.md new file mode 100644 index 0000000000..64b06224bc --- /dev/null +++ b/shared/contracts/vcs-provider-v1.md @@ -0,0 +1,362 @@ +# VCS provider contract v1 + +> **Status: Core implemented (P1); no host uses it yet.** Core accepts the +> `vcsProviders` manifest capability and the commands below. The macOS and +> Windows hosts do not start providers yet, so no build shows a plugin VCS. +> The decision record is +> [Unified VCS model and providers](../../.agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md); +> example data is in [`shared/fixtures/vcs/model-v1.json`](../fixtures/vcs/model-v1.json) +> and [`plugins/v1/fixtures/vcs-provider.plugin.json`](plugins/v1/fixtures/vcs-provider.plugin.json). + +This contract defines one VCS-neutral model for the features every version +control system has: roots, file status, base content, history, annotation, +check-in, rollback, and update. A **provider** implements it for one VCS as a +plugin process speaking the LSP base protocol, the same process kind as +[build providers](plugins/v1/README.md#build-providers). Subversion is the +first provider. + +Git is not part of this layer yet. Core recognizes `.git` markers so a nested +working copy is assigned to the right root, and the `git.*` commands keep +serving Git roots unchanged. Features that only exist in one VCS (Git log +graph, rebase, worktrees, stash; Subversion locks, properties, relocate) are +not modeled here. + +## Shared rules + +- UTF-8 JSON; every list has a deterministic order. +- Paths inside a root are root-relative with `/` separators and no empty, + `.`, or `..` segment. A root's identity is a workspace-relative `id` (`""` + for the workspace, `..` segments for an enclosing directory); its native + `path` is a platform binding, never a portable identity. +- Missing values are `null`. +- Hosts never parse or order revision IDs; only the provider compares them. +- Remote locations are display data; Core removes user info, query, and + fragment before they leave it. +- Secrets never appear in arguments, results, console events, or logs. + +## Manifest (plugin API level 3) + +```json +{ + "pluginAPIVersion": 3, + "requires": { "serverProcess": 1, "vcsProviders": 1 }, + "permissions": ["languageServer.launch", "process.vcs"], + "activationEvents": ["onVcsRoot:svn"], + "vcsProviders": [ + { + "id": "svn", + "displayName": "Subversion", + "detection": { "markers": [{ "name": ".svn", "type": "directory" }] }, + "metadataPaths": [".svn/wc.db"], + "tool": { "commands": ["svn"], "validationArguments": ["--version", "--quiet"] }, + "launch": { "kind": "runtime", "tool": "node", "minimumVersion": "20.3", "entry": "provider/main.mjs" }, + "arguments": ["--stdio"], + "mayUseNetwork": true + } + ] +} +``` + +| Field | Rule | +| --- | --- | +| `id` | `[a-z][a-z0-9-]{0,31}`; unique in the plugin. `git` is reserved (`plugin.vcs.reserved`). | +| `displayName` | One line of 1-64 characters. | +| `detection.markers` | 1-8 entries directly inside a root; `type` is `directory` or `file`. | +| `metadataPaths` | Up to 16 root-relative paths the host watches to refresh status. | +| `tool` | Optional. Bare command names the host resolves like a `systemCommand` launch, and an optional probe. | +| `launch`, `arguments`, `validationArguments`, `runtimeDependencies`, `initializationOptions` | The shared server-process fields. | +| `mayUseNetwork` | Shown in the install review. | + +A plugin declares at most 8 providers. A non-empty `vcsProviders` requires the +`languageServer.launch` and `process.vcs` permissions, the activation event +`onVcsRoot:` for every provider, and `requires.vcsProviders` and +`requires.serverProcess`. Capabilities are not declared in the manifest: they +belong to the running process and are negotiated. + +The install review lists one program per provider, of kind +`packagedVcsProvider`, `systemVcsProvider`, or `runtimeVcsProvider`, with +`vcs`, `vcsTool` (the declared `tool`), and `mayUseNetwork`, plus the +runtime it needs. Changing the tool or the launch is a new program the user +approves again. + +`plugins.declarative.languageServerLaunchPlan` with `vcsProviderID` plans +the process and adds `vcsProvider`, the descriptor below. + +## Provider descriptor + +```json +{ + "vcs": "svn", + "displayName": "Subversion", + "origin": { "kind": "plugin", "pluginID": "io.github.lithe-idea.svn-support" }, + "markers": [{ "name": ".svn", "type": "directory" }], + "metadataPaths": [".svn/wc.db"], + "tool": { "commands": ["svn"], "validationArguments": ["--version", "--quiet"] }, + "mayUseNetwork": true +} +``` + +The built-in Git entry is `{ vcs: "git", displayName: "Git", origin: { kind: +"builtIn" }, markers }` with the `.git` directory and file markers. + +## Core commands + +| Command | Purpose | +| --- | --- | +| `vcs.detectRoots` | Providers, roots, ambiguous directories, and applied mappings of a workspace | +| `vcs.ownerOf` | The root that owns each workspace path | +| `lsp.vcsRequest` | Sends one operation to a negotiated provider session | +| `vcs.decodeReply` | Validates a provider's reply and returns the normalized result | + +### `vcs.detectRoots` + +```json +{ + "workspaceRoot": "/abs/workspace", + "plugins": [{ "manifest": { } }], + "mappings": [{ "id": "mixed", "vcs": "svn" }] +} +``` + +Core validates every manifest again; a rejected one is listed as +`rejected: [{ index, error }]` and the rest still count. When two plugins claim +one VCS, an official plugin wins, then the lower plugin ID; the loser is listed +in `conflicts: [{ pluginID, vcs, by }]`. A relative `workspaceRoot` fails the +call. + +```json +{ + "status": "ok", + "detection": { + "providers": [ ], + "roots": [{ "id": "services/legacy", "vcs": "svn", "path": "/abs/workspace/services/legacy", "mapped": false }], + "ambiguous": [{ "id": "mixed", "vcs": ["git", "svn"] }], + "unusedMappings": [], + "truncated": false, + "rejected": [], + "conflicts": [] + } +} +``` + +Roots come from static markers; detection never starts a process. One +breadth-first walk reads the markers directly inside each directory down to +eight levels below the workspace, at most 20,000 directories. It never enters a +marker directory (`.git`, `.svn`), a symbolic link, or a dependency or build +directory such as `node_modules`, `target`, or `.worktrees`. When the workspace +itself has no marker, its ancestors are checked, because a Subversion working +copy has one `.svn` at its top; the enclosing root's `id` is `..`, `../..`, …. +`truncated` is `true` only when the directory budget ran out, so a root may be +missing. + +A directory with markers of several VCSs is ambiguous until a mapping names +one of them; Core never guesses. Mappings that name no ambiguous directory, or +a VCS whose markers are not there, are returned in `unusedMappings`. Roots and +ambiguous directories are ordered by depth (enclosing first), then `id`. + +### `vcs.ownerOf` + +```json +{ "roots": ["", "services/legacy"], "ambiguous": ["mixed"], "paths": ["README.md", "mixed/a.txt"] } +``` + +returns `{ status: "ok", owners: [{ path, root }] }` in request order. A path +belongs to the deepest root that contains it; inside an unmapped ambiguous +directory it belongs to no root (`null`), not to an enclosing one. At most +100,000 paths per call. + +### `lsp.vcsRequest` + +```json +{ + "sessionId": "…", + "operationId": "…", + "vcs": "svn", + "operation": "checkin", + "root": "/abs/workspace/services/legacy", + "tool": "/opt/homebrew/bin/svn", + "arguments": { "paths": ["src/Main.java"], "message": "Fix" }, + "timeoutMilliseconds": 600000 +} +``` + +The host names an operation, never a method, so a session cannot be used as a +generic JSON-RPC tunnel. Core refuses the request before writing it when the +session did not negotiate a provider for `vcs` +(`notSupported`, details `vcs.provider.unavailable`), when the provider did not +negotiate the operation's capability (`notSupported`, details +`vcs.capability.unsupported`), or when the arguments are malformed +(`invalidRequest`). `timeoutMilliseconds` is capped at one hour; the session's +request timeout applies when it is absent. The result arrives as a +`requestCompleted` event with `result: { value: }`; a failure carries +one of the error codes below. + +| Operation | Method | Capability | Arguments | +| --- | --- | --- | --- | +| `status` | `lithe/vcs/status` | `status` | `{ includeIgnored? }` | +| `baseContent` | `lithe/vcs/baseContent` | `baseContent` | `{ path }` | +| `history` | `lithe/vcs/history` | `history` | `{ cursor?, limit? }` | +| `fileHistory` | `lithe/vcs/fileHistory` | `fileHistory` | `{ path, cursor?, limit? }` | +| `annotate` | `lithe/vcs/annotate` | `annotate` | `{ path }` | +| `checkin` | `lithe/vcs/checkin` | `checkin` | `{ paths, message }` | +| `rollback` | `lithe/vcs/rollback` | `rollback` | `{ paths }` | +| `update` | `lithe/vcs/update` | `update` | `{ revision? }` | +| `fileOperation` | `lithe/vcs/fileOperation` | `fileOperations` | `{ kind: "add" \| "delete", paths }` or `{ kind: "move", from, to }` | +| `resolve` | `lithe/vcs/resolve` | `conflicts` | `{ paths }` | +| `cleanup` | `lithe/vcs/cleanup` | `cleanup` | `{}` | + +`paths` holds 1-10,000 root-relative paths; Core sorts and deduplicates them. +A history page has 1-500 entries (100 by default). A check-in message is +non-empty text of at most 1 MiB. Unknown argument fields fail. + +Core adds `root: { path }` and `tool: { executable } | null` to the params. +The provider receives, for example: + +```json +{ "paths": ["src/Main.java"], "message": "Fix", "root": { "path": "/abs/…/legacy" }, "tool": { "executable": "/opt/homebrew/bin/svn" } } +``` + +### `vcs.decodeReply` + +```json +{ "operation": "status", "capabilities": { "status": 1, "…": 1 }, "request": { }, "reply": { } } +``` + +`capabilities` is what the session reported in its `vcsProvider` event, +`request` the arguments the host sent, and `reply` the `value` of the +completed request. The result is `{ status: "ok", result }` or +`{ status: "rejected", error: { code: "plugin.vcs.invalidOutput", … } }`. + +## Negotiation + +The host offers `capabilities.experimental.litheVcs: { "versions": [1] }` in +`initialize`, to plugin-owned sessions only. The provider answers: + +```json +{ "litheVcs": { "version": 1, "vcs": "svn", "capabilities": { "status": 1, "baseContent": 1, "checkin": 1, "rollback": 1, "update": 1 } } } +``` + +A session is a provider only when the answer names an offered version, a valid +VCS ID, and every required capability. Unknown capabilities, and known ones at +another version, are ignored. Core then publishes a runtime event of kind +`vcsProvider` with `result: { vcs, capabilities }`; the host compares `vcs` +with the manifest entry it launched and stops a session that answered another +one. A session without a valid answer receives no `lithe/vcs/*` request. + +| Capability | Required | Meaning | +| --- | --- | --- | +| `status` | yes | File status of a root | +| `baseContent` | yes | Content of a file at its base revision (Diff, gutter) | +| `checkin` | yes | Record selected changes as a new revision | +| `rollback` | yes | Discard local changes of selected files | +| `history` | no | Paged revision history of a root | +| `fileHistory` | no | Paged revision history of one path | +| `annotate` | no | Per-line revision of a file | +| `update` | no | Bring remote revisions into the working copy | +| `push` | no | Check-in is local; publishing is a separate step | +| `staging` | no | Changes carry a staged/unstaged dimension | +| `fileOperations` | no | Add, move, and delete must go through the VCS to be tracked | +| `conflicts` | no | Report and resolve conflicted files | +| `cleanup` | no | Recover an interrupted working copy | + +UI behavior is chosen from capabilities only, never from `vcs`. A provider +without `push` commits directly to the server; the host labels the action +accordingly and does not offer "Commit and Push". Version 1 has no operation +for `push`: a provider that negotiates it tells the host the VCS has a +publishing step, which a later version will model. + +## Replies + +### Revision + +```json +{ "id": "1234", "display": "r1234", "kind": "number" } +``` + +`kind` is `hash`, `number`, or `opaque` (the default) and only affects +presentation. `display` defaults to `id`. `id` is passed back to the provider +unchanged. + +### `status` + +```json +{ + "head": { "revision": { "id": "1234", "display": "r1234", "kind": "number" }, "branch": null, "location": "https://svn.example.invalid/repo/trunk" }, + "changes": [ + { "path": "src/Main.java", "oldPath": null, "status": "modified", "flags": ["locked", "propertiesModified"] } + ], + "truncated": false +} +``` + +`status` is one of `modified`, `added`, `deleted`, `missing` (removed from disk +without telling the VCS), `renamed`, `copied`, `replaced`, `typeChanged`, +`unversioned`, `ignored`, `conflicted`, `switched`; any other value is invalid. +Only `renamed` and `copied` may name an `oldPath`. + +`flags` refine a status: `external`, `locked`, `lockedByOther`, +`propertiesModified`, `submodule`, `treeConflict`. Core drops unknown flags so +a newer provider can add optional data within version 1, and sorts the rest. + +A provider that negotiated `staging` gives every change +`staging: "staged" | "unstaged" | "partial"`; any other provider gives none. +Changelist membership is host state and never part of a reply. + +Core sorts `changes` by `path` and rejects duplicates. It caps a snapshot at +100,000 changes and sets `truncated: true`, as does a provider that stopped +early; a truncated snapshot must not be presented as complete. + +### Other operations + +| Operation | Reply | Rules | +| --- | --- | --- | +| `baseContent` | `{ content: string \| null, binary: bool }` | `null` content: the file has no base (added or unversioned); binary content is not sent. At most 32 MiB. | +| `history`, `fileHistory` | `{ entries: [{ revision, author, date, message, changes, changesTruncated }], nextCursor }` | Provider order (newest first) is kept; more entries than requested is invalid. Each entry lists at most 10,000 changes. | +| `annotate` | `{ revisions: [{ revision, author, date, message }], lines: [id \| null] }` | Each line names a listed revision ID or `null`; only used revisions are returned, sorted by ID. At most 500,000 lines. | +| `checkin` | `{ revision }` | The new revision, or `null` when the VCS does not report one. | +| `update` | `{ revision, conflicts: [path] }` | Conflicted paths, sorted and inside the root. | +| `rollback`, `fileOperation`, `resolve`, `cleanup` | `{}` | Unknown fields are ignored. | + +A path that is absolute, has a `..` segment, or otherwise leaves the root, a +missing required field, or a size limit exceeded makes the reply invalid. + +## Provider messages + +| Direction | Method | Version 1 behavior | +| --- | --- | --- | +| provider → host | `lithe/vcs/execution` | Notification of a command the provider ran: `{ command: [executable, ...arguments], exitCode: number \| null, durationMilliseconds, stderr }`, with `stderr` cut to 16 KiB and no stdout. Core passes it on as a session `log` event (`message` is the method, `detail` the params as JSON); routing it to the project console is host work (P3). Arguments are shown as sent, so a provider never puts a credential in them. | +| provider → host | `lithe/vcs/authenticate` | Not accepted yet: Core answers `-32601`. A provider runs its client non-interactively and fails with `vcs.authentication.failed`. | + +Both sides ignore unknown fields in params and results. `$/cancelRequest` and +the request deadline end a request with `requestCancelled` or +`requestTimeout`. + +## Error codes + +| Code | Raised by | Meaning | +| --- | --- | --- | +| `vcs.provider.unavailable` | Core (`details` of `notSupported`) | The session negotiated no provider, or one for another VCS | +| `vcs.capability.unsupported` | Core (`details` of `notSupported`) | The operation needs a capability the provider did not negotiate | +| `plugin.vcs.reserved` | Core (manifest) | A plugin declared the reserved `git` ID | +| `plugin.vcs.invalidOutput` | Core | A reply failed validation, exceeded a limit, or had no result | +| `vcs.provider.failed` | Core | The provider failed without one of the codes below | +| `vcs.authentication.failed` | provider | Credentials were missing or rejected | +| `vcs.conflict` | provider | The operation stopped on conflicted files | +| `vcs.outOfDate` | provider | The server rejected a check-in because the working copy is out of date | +| `vcs.tool.unavailable` | provider | The VCS client could not be run | +| `vcs.workingCopy.locked` | provider | The working copy is locked by an interrupted operation; `cleanup` may help | +| `vcs.workingCopy.unsupported` | provider | The working copy format is not supported | + +A provider reports its code in the JSON-RPC `error.data.code`. Core keeps only +the provider codes listed here; any other code, including Core's own, becomes +`vcs.provider.failed`. The provider's message (at most 4 KiB) is passed on and +must not contain credentials. + +## Subversion provider notes + +Subversion is provided by the community plugin +[lithe-plugin-svn](https://github.com/Lithe-IDEA/lithe-plugin-svn) +(`io.github.lithe-idea.svn-support`), released independently of Lithe. It calls +the user's `svn` client, 1.8 or newer, and parses its `--xml` output. Its README +documents how each operation maps to svn and how svn errors map to contract +codes; its tests check those replies against a pinned Lithe Core. diff --git a/shared/fixtures/agent/agent-instructions-v1.json b/shared/fixtures/agent/agent-instructions-v1.json new file mode 100644 index 0000000000..9a91a9049f --- /dev/null +++ b/shared/fixtures/agent/agent-instructions-v1.json @@ -0,0 +1,40 @@ +{ + "version": 1, + "readRequest": { + "command": "agent.instructions.read", + "payload": { + "agentId": "codex-acp" + } + }, + "file": { + "agentId": "codex-acp", + "path": "/fixture/user/.codex/AGENTS.md", + "content": "# Working agreements\nUse concise replies.\n", + "exists": true, + "revision": "5b94dd8e0a4a42bab37c0b1b7bb3d36e15d6cda57bb6cd32de40f2060e051228" + }, + "saveRequest": { + "command": "agent.instructions.save", + "payload": { + "agentId": "codex-acp", + "content": "# Updated\n", + "expectedPath": "/fixture/user/.codex/AGENTS.md", + "expectedRevision": "5b94dd8e0a4a42bab37c0b1b7bb3d36e15d6cda57bb6cd32de40f2060e051228" + } + }, + "windowsFile": { + "agentId": "claude-acp", + "path": "C:\\Users\\fixture\\.claude\\CLAUDE.md", + "content": "Claude only", + "exists": true, + "revision": "6200025904539440f7dde4fa1a3a0ca731c61c31bc8f0d188acf741caf0bdd40" + }, + "conflict": { + "ok": false, + "error": { + "code": "invalid_request", + "message": "Global instructions changed outside this editor. Your draft is kept; reload before saving.", + "details": "file_changed" + } + } +} diff --git a/shared/fixtures/agent/hooks-management-v1.json b/shared/fixtures/agent/hooks-management-v1.json new file mode 100644 index 0000000000..4c7afe0120 --- /dev/null +++ b/shared/fixtures/agent/hooks-management-v1.json @@ -0,0 +1,64 @@ +{ + "command": "agent.hooks", + "readRequest": { + "dataDirectory": "/fixture/library", + "action": "read" + }, + "hook": { + "id": "00000000-0000-4000-8000-000000000001", + "name": "notify", + "description": "一轮结束时提醒", + "event": "turn-end", + "matcher": "all", + "command": "printf done", + "timeoutSeconds": null, + "agents": [ + "codex-acp", + "claude-acp" + ], + "enabled": true, + "pendingRemoval": false + }, + "library": { + "revision": "fixture-library-revision", + "hooks": [ + { + "id": "00000000-0000-4000-8000-000000000001", + "name": "notify", + "description": "一轮结束时提醒", + "event": "turn-end", + "matcher": "all", + "command": "printf done", + "timeoutSeconds": null, + "agents": [ + "codex-acp", + "claude-acp" + ], + "enabled": true, + "pendingRemoval": false, + "configuredAgents": [ + "codex-acp" + ], + "needsSync": true + } + ], + "targets": [ + { + "agentId": "codex-acp", + "file": { + "agentId": "codex-acp", + "path": "/fixture/.codex/hooks.json", + "revision": "codex-revision" + }, + "issue": null, + "disabled": false + }, + { + "agentId": "claude-acp", + "file": null, + "issue": "Hook configuration must be a regular file, not a symbolic link or directory.", + "disabled": false + } + ] + } +} diff --git a/shared/fixtures/agent/shared-instructions-v1.json b/shared/fixtures/agent/shared-instructions-v1.json new file mode 100644 index 0000000000..607a41288e --- /dev/null +++ b/shared/fixtures/agent/shared-instructions-v1.json @@ -0,0 +1,43 @@ +{ + "version": 1, + "readRequest": { + "command": "agent.instructions.shared", + "payload": { + "dataDirectory": "/fixture/app-support/AgentInstructions", + "action": "read" + } + }, + "library": { + "file": { + "agentId": "shared", + "path": "/fixture/app-support/AgentInstructions/Shared.md", + "content": "Use concise Chinese replies.", + "exists": true, + "revision": "source-sha256" + }, + "agents": [ + { + "agentId": "codex-acp", + "file": { + "agentId": "codex-acp", + "path": "/fixture/user/.codex/AGENTS.md", + "content": "Own rules\n\nUse concise Chinese replies.\n\n", + "exists": true, + "revision": "native-sha256" + }, + "enabled": true, + "needsSync": false, + "ownLineCount": 1, + "issue": null + }, + { + "agentId": "claude-acp", + "file": null, + "enabled": null, + "needsSync": false, + "ownLineCount": 0, + "issue": "The instruction path must be a regular file, not a symbolic link or directory." + } + ] + } +} diff --git a/shared/fixtures/agent/skills-management-v1.json b/shared/fixtures/agent/skills-management-v1.json new file mode 100644 index 0000000000..9e39f281e1 --- /dev/null +++ b/shared/fixtures/agent/skills-management-v1.json @@ -0,0 +1,144 @@ +{ + "requests": { + "list": { + "command": "agent.skills", + "payload": { + "action": "list", + "agentId": "codex-acp", + "workspace": "/fixture/workspace" + } + }, + "directories": { + "command": "agent.skills", + "payload": { + "action": "directories", + "agentId": "claude-acp", + "workspace": "/fixture/workspace" + } + }, + "create": { + "command": "agent.skills", + "payload": { + "action": "create", + "agentId": "codex-acp", + "workspace": "/fixture/workspace", + "scope": "repo", + "name": "example-skill", + "description": "Use for a specific workflow", + "instructions": "Inspect the project and explain the result." + } + }, + "import": { + "command": "agent.skills", + "payload": { + "action": "import", + "agentId": "claude-acp", + "workspace": "/fixture/workspace", + "scope": "user", + "source": "/fixture/import/example-skill" + } + }, + "read": { + "command": "agent.skills", + "payload": { + "action": "read", + "agentId": "codex-acp", + "source": "/fixture/home/.agents/skills/shared-checks/SKILL.md" + } + }, + "setEnabled": { + "command": "agent.skills", + "payload": { + "action": "setEnabled", + "agentId": "codex-acp", + "workspace": "/fixture/workspace", + "source": "/fixture/workspace/.agents/skills/project-workflow/SKILL.md", + "enabled": false, + "scope": "repo" + } + } + }, + "nativeCodex": { + "data": [ + { + "cwd": "/fixture/workspace", + "skills": [ + { + "name": "plugin-helper", + "description": "Use the plugin helper", + "path": "/fixture/plugins/helper/SKILL.md", + "scope": "user", + "enabled": true, + "pluginId": "helper@example" + }, + { + "name": "project-workflow", + "description": "Use for this project", + "path": "/fixture/workspace/.agents/skills/project-workflow/SKILL.md", + "scope": "repo", + "enabled": false, + "pluginId": null + }, + { + "name": "shared-checks", + "description": "Use for common checks", + "path": "/fixture/home/.agents/skills/shared-checks/SKILL.md", + "scope": "user", + "enabled": true, + "pluginId": null + } + ], + "errors": [] + } + ] + }, + "normalizedCodex": { + "skills": [ + { + "name": "plugin-helper", + "description": "Use the plugin helper", + "path": "/fixture/plugins/helper/SKILL.md", + "scope": "user", + "enabled": true, + "pluginId": "helper@example" + }, + { + "name": "project-workflow", + "description": "Use for this project", + "path": "/fixture/workspace/.agents/skills/project-workflow/SKILL.md", + "scope": "repo", + "enabled": false, + "pluginId": null + }, + { + "name": "shared-checks", + "description": "Use for common checks", + "path": "/fixture/home/.agents/skills/shared-checks/SKILL.md", + "scope": "user", + "enabled": true, + "pluginId": null + } + ], + "source": "agent", + "issues": [], + "directories": {} + }, + "claudeInventory": { + "skills": [ + { + "name": "local-review", + "description": "Review this project's changes", + "path": "/fixture/workspace/.claude/skills/local-review/SKILL.md", + "scope": "repo", + "enabled": false, + "pluginId": null + } + ], + "source": "directory", + "issues": [], + "directories": { + "user": "/fixture/home/.claude/skills", + "repo": "/fixture/workspace/.claude/skills" + } + } +} diff --git a/shared/fixtures/git/push-preview-v1.json b/shared/fixtures/git/push-preview-v1.json index 9a11e7246c..ffb0413d26 100644 --- a/shared/fixtures/git/push-preview-v1.json +++ b/shared/fixtures/git/push-preview-v1.json @@ -3,6 +3,8 @@ "localHead": "2222222222222222222222222222222222222222", "remote": "origin", "remoteBranch": "feature/core", + "targetKind": "tracking", + "createsRemoteBranch": false, "remoteTrackingOid": "1111111111111111111111111111111111111111", "upstream": "origin/feature/core", "tags": [ diff --git a/shared/fixtures/i18n/format-v1.json b/shared/fixtures/i18n/format-v1.json new file mode 100644 index 0000000000..26a36794be --- /dev/null +++ b/shared/fixtures/i18n/format-v1.json @@ -0,0 +1,23 @@ +{ + "schemaVersion": 1, + "parameters": { "name": "string", "count": "integer" }, + "arguments": ["name", "count"], + "cases": [ + { + "name": "Reordered parameters and literal percent", + "template": "100% {count} 个:{name}", + "nativeTemplate": "100%% %2$lld 个:%1$@", + "stringValue": "中文/%@/{count}", + "integerValue": 9000000000, + "expected": "100% 9000000000 个:中文/%@/{count}" + }, + { + "name": "Repeated parameter and zero count", + "template": "{name}: {count} files ({name})", + "nativeTemplate": "%1$@: %2$lld files (%1$@)", + "stringValue": "feature/100%", + "integerValue": 0, + "expected": "feature/100%: 0 files (feature/100%)" + } + ] +} diff --git a/shared/fixtures/i18n/native-abi-v1.json b/shared/fixtures/i18n/native-abi-v1.json new file mode 100644 index 0000000000..75991068cd --- /dev/null +++ b/shared/fixtures/i18n/native-abi-v1.json @@ -0,0 +1,11 @@ +{ + "schemaVersion": 1, + "parameters": { "rows": "unsignedInteger", "elapsed": "integer" }, + "macos": { "formats": { "rows": "llu", "elapsed": "d" } }, + "arguments": ["rows", "elapsed"], + "template": "{rows} rows affected in {elapsed} ms", + "nativeTemplate": "%1$llu rows affected in %2$d ms", + "unsignedValue": "18446744073709551615", + "integerValue": 2147483647, + "expected": "18446744073709551615 rows affected in 2147483647 ms" +} diff --git a/shared/fixtures/lsp/java-completion-v1.json b/shared/fixtures/lsp/java-completion-v1.json new file mode 100644 index 0000000000..224648fec8 --- /dev/null +++ b/shared/fixtures/lsp/java-completion-v1.json @@ -0,0 +1,23 @@ +{ + "version": 1, + "pluginCompletion": { + "guessMethodArguments": "insertParameterNames", + "favoriteStaticMembers": ["java.util.Objects.*"], + "filteredTypes": ["example.internal.*"] + }, + "javaSettings": { + "completion": { + "guessMethodArguments": "off", + "favoriteStaticMembers": ["java.util.Objects.*"], + "filteredTypes": ["example.internal.*"] + } + }, + "workspaceConfigurationSections": [ + "java", + "java.completion", + "java.completion.guessMethodArguments", + "java.completion.favoriteStaticMembers", + "java.completion.filteredTypes", + "java.completion.unknown" + ] +} diff --git a/shared/fixtures/mcp/featured-setup-v1.json b/shared/fixtures/mcp/featured-setup-v1.json new file mode 100644 index 0000000000..a2b2df565a --- /dev/null +++ b/shared/fixtures/mcp/featured-setup-v1.json @@ -0,0 +1,48 @@ +{ + "entries": [ + { + "id": "lithe.featured/example-token", + "title": "Example token service", + "summary": "Synthetic setup contract", + "publisher": "Example", + "source": "featured", + "version": "", + "website": "https://example.invalid/docs", + "setup": "bearer-token", + "draft": { + "name": "example-token", + "transport": "http", + "command": null, + "args": [], + "env": [], + "url": "https://example.invalid/mcp", + "headers": [], + "definitionHash": "" + } + }, + { + "id": "lithe.featured/example-folders", + "title": "Example allowed folders", + "summary": "Synthetic setup contract", + "publisher": "Example", + "source": "featured", + "version": "1.2.3", + "website": null, + "setup": "allowed-directories", + "draft": { + "name": "example-folders", + "transport": "stdio", + "command": "npx", + "args": [ + "-y", + "@example/filesystem@1.2.3" + ], + "env": [], + "url": null, + "headers": [], + "definitionHash": "" + } + } + ], + "nextCursor": null +} diff --git a/shared/fixtures/mcp/tools-v1.json b/shared/fixtures/mcp/tools-v1.json new file mode 100644 index 0000000000..5384e9f4e1 --- /dev/null +++ b/shared/fixtures/mcp/tools-v1.json @@ -0,0 +1,52 @@ +{ + "probeRequest": { + "transport": "http", + "url": "https://example.invalid/mcp", + "command": null, + "args": [], + "env": [], + "headers": [] + }, + "probeResult": { + "tools": [ + { + "name": "read", + "description": "Synthetic read-only tool metadata" + } + ] + }, + "policyRequest": { + "source": "claude", + "content": "{}", + "server": "docs", + "tools": [ + "read" + ], + "owned": [], + "tool": "read", + "enabled": false + }, + "policyResult": { + "content": "{\n \"permissions\": {\n \"deny\": [\"mcp__docs__read\"]\n}\n}", + "changed": true, + "owned": [ + "read" + ], + "tools": [ + { + "name": "read", + "enabled": false, + "editable": true + } + ], + "hasRestrictions": true + }, + "commandLine": { + "command": "/fixture/my tools/server", + "args": [ + "", + "folder with spaces", + "a'b" + ] + } +} diff --git a/shared/fixtures/modules/built-in-v1.json b/shared/fixtures/modules/built-in-v1.json index d5ab71276a..aafa878de9 100644 --- a/shared/fixtures/modules/built-in-v1.json +++ b/shared/fixtures/modules/built-in-v1.json @@ -34,20 +34,6 @@ ], "required": false }, - { - "id": "dev.lithe.database", - "displayName": "Database", - "scope": "workspace", - "defaultState": "disabled", - "activationPolicy": "onDemand", - "sleepPolicy": { "kind": "whenIdle", "afterSeconds": 600 }, - "dependencies": ["dev.lithe.workspace"], - "capabilities": ["dev.lithe.capability.database-workspace"], - "contributions": [ - { "id": "database.workspace", "kind": "toolWindow" } - ], - "required": false - }, { "id": "dev.lithe.debug", "displayName": "Debug", diff --git a/shared/fixtures/process-lifecycle/exit-plan-v1.json b/shared/fixtures/process-lifecycle/exit-plan-v1.json index fe9f14f3a2..1a2bdd65de 100644 --- a/shared/fixtures/process-lifecycle/exit-plan-v1.json +++ b/shared/fixtures/process-lifecycle/exit-plan-v1.json @@ -243,6 +243,83 @@ {"id": "remembered-service", "kind": "daemon", "policy": "askOnExit", "action": "retain", "reason": "rememberedChoice"} ] } + }, + { + "name": "quit asks before stopping the run sessions the host tracks", + "request": { + "context": "quit", + "records": [ + {"id": "api", "kind": "run", "ownership": "launched"}, + {"id": "run:other", "kind": "run", "ownership": "scopeToken"}, + {"id": "jdtls", "kind": "languageServer", "ownership": "launched"} + ] + }, + "expected": { + "gracefulStopMilliseconds": 5000, + "decisions": [ + {"id": "api", "kind": "run", "policy": "terminate", "action": "confirmStop", "reason": "runServicesSetting"}, + {"id": "run:other", "kind": "run", "policy": "terminate", "action": "terminate", "reason": "defaultTerminate"}, + {"id": "jdtls", "kind": "languageServer", "policy": "terminate", "action": "terminate", "reason": "defaultTerminate"} + ] + } + }, + { + "name": "quit stops run sessions without asking when told to", + "request": { + "context": "quit", + "settings": {"runServicesOnExit": "terminate"}, + "records": [ + {"id": "api", "kind": "run", "ownership": "launched"} + ] + }, + "expected": { + "gracefulStopMilliseconds": 5000, + "decisions": [ + {"id": "api", "kind": "run", "policy": "terminate", "action": "terminate", "reason": "runServicesSetting"} + ] + } + }, + { + "name": "closing a workspace decides only its own run sessions", + "request": { + "context": "closeWorkspace", + "records": [ + {"id": "api", "kind": "run", "ownership": "launched"}, + {"id": "kept-api", "kind": "run", "ownership": "launched", "retained": true}, + {"id": "spring", "kind": "terminal", "ownership": "launched"}, + {"id": "migration", "kind": "agentTask", "ownership": "scopeToken"}, + {"id": "daemon", "kind": "daemon", "ownership": "scopeToken", "detached": true}, + {"id": "jdtls", "kind": "languageServer", "ownership": "launched"}, + {"id": "outside", "kind": "container", "ownership": "unproven"} + ] + }, + "expected": { + "gracefulStopMilliseconds": 5000, + "decisions": [ + {"id": "api", "kind": "run", "policy": "terminate", "action": "confirmStop", "reason": "runServicesSetting"}, + {"id": "kept-api", "kind": "run", "policy": "terminate", "action": "retain", "reason": "retainedByUser"}, + {"id": "spring", "kind": "terminal", "policy": "confirmEach", "action": "defer", "reason": "decidedOnQuit"}, + {"id": "migration", "kind": "agentTask", "policy": "confirmEach", "action": "defer", "reason": "decidedOnQuit"}, + {"id": "daemon", "kind": "daemon", "policy": "askOnExit", "action": "defer", "reason": "decidedOnQuit"}, + {"id": "jdtls", "kind": "languageServer", "policy": "terminate", "action": "terminate", "reason": "defaultTerminate"}, + {"id": "outside", "kind": "container", "policy": "external", "action": "ignore", "reason": "ownershipUnproven"} + ] + } + }, + { + "name": "run sessions end without a question after a crash", + "request": { + "context": "crashRecovery", + "records": [ + {"id": "api", "kind": "run", "ownership": "launched"} + ] + }, + "expected": { + "gracefulStopMilliseconds": 5000, + "decisions": [ + {"id": "api", "kind": "run", "policy": "terminate", "action": "terminate", "reason": "defaultTerminate"} + ] + } } ] } diff --git a/shared/fixtures/process-lifecycle/settle-v1.json b/shared/fixtures/process-lifecycle/settle-v1.json index 80a59e2ad1..ab2c8081ae 100644 --- a/shared/fixtures/process-lifecycle/settle-v1.json +++ b/shared/fixtures/process-lifecycle/settle-v1.json @@ -26,6 +26,7 @@ "confirmTerminalTabClose": true, "agentTasks": "confirmEach", "backgroundResources": "ask", + "runServicesOnExit": "ask", "rememberedChoices": {"gradle.daemon": "terminate", "old.daemon": "keep"}, "startupSweep": "ask", "gracefulStopSeconds": 5 @@ -49,6 +50,31 @@ "confirmTerminalTabClose": true, "agentTasks": "confirmEach", "backgroundResources": "ask", + "runServicesOnExit": "ask", + "rememberedChoices": {}, + "startupSweep": "ask", + "gracefulStopSeconds": 5 + } + } + }, + { + "name": "a confirmed stop ends every listed run session", + "request": { + "decisions": [ + {"id": "api", "action": "confirmStop"}, + {"id": "worker", "action": "confirmStop"}, + {"id": "jdtls", "action": "terminate"} + ] + }, + "expected": { + "terminate": ["api", "worker", "jdtls"], + "keep": [], + "settings": { + "terminalOnQuit": "confirmEach", + "confirmTerminalTabClose": true, + "agentTasks": "confirmEach", + "backgroundResources": "ask", + "runServicesOnExit": "ask", "rememberedChoices": {}, "startupSweep": "ask", "gracefulStopSeconds": 5 diff --git a/shared/fixtures/search/exclusions.json b/shared/fixtures/search/exclusions.json index a58ec756d4..67df8f7c37 100644 --- a/shared/fixtures/search/exclusions.json +++ b/shared/fixtures/search/exclusions.json @@ -3,7 +3,12 @@ "cache/", "**/.*/", ".*/", - "*.log" + "*.log", + "generated/*", + "docs/**", + "notes", + "**/tmp/", + "**/secret.txt" ], "cases": [ { @@ -60,6 +65,101 @@ "path": "src/Name.swift", "isDirectory": false, "hidden": false + }, + { + "path": "generated", + "isDirectory": true, + "hidden": true + }, + { + "path": "generated/Name.swift", + "isDirectory": false, + "hidden": true + }, + { + "path": "src/generated", + "isDirectory": true, + "hidden": false + }, + { + "path": "src/generated/Name.swift", + "isDirectory": false, + "hidden": false + }, + { + "path": "generated", + "isDirectory": false, + "hidden": false + }, + { + "path": "docs", + "isDirectory": true, + "hidden": true + }, + { + "path": "docs/api/index.html", + "isDirectory": false, + "hidden": true + }, + { + "path": "src/docs", + "isDirectory": true, + "hidden": false + }, + { + "path": "notes", + "isDirectory": false, + "hidden": true + }, + { + "path": "notes", + "isDirectory": true, + "hidden": false + }, + { + "path": "notes/todo.md", + "isDirectory": false, + "hidden": false + }, + { + "path": "tmp", + "isDirectory": true, + "hidden": true + }, + { + "path": "a/b/tmp", + "isDirectory": true, + "hidden": true + }, + { + "path": "a/b/tmp/x.txt", + "isDirectory": false, + "hidden": true + }, + { + "path": "a/tmp.txt", + "isDirectory": false, + "hidden": false + }, + { + "path": "secret.txt", + "isDirectory": false, + "hidden": true + }, + { + "path": "config/secret.txt", + "isDirectory": false, + "hidden": true + }, + { + "path": "config/secret.txt.bak", + "isDirectory": false, + "hidden": false + }, + { + "path": "secret.txt", + "isDirectory": true, + "hidden": false } ] } diff --git a/shared/fixtures/vcs/model-v1.json b/shared/fixtures/vcs/model-v1.json new file mode 100644 index 0000000000..afe2685038 --- /dev/null +++ b/shared/fixtures/vcs/model-v1.json @@ -0,0 +1,663 @@ +{ + "version": 1, + "status": "draft", + "contract": "shared/contracts/vcs-provider-v1.md", + "providers": [ + { + "vcs": "git", + "displayName": "Git", + "origin": { + "kind": "builtIn" + }, + "markers": [ + { + "name": ".git", + "type": "directory" + }, + { + "name": ".git", + "type": "file" + } + ] + }, + { + "vcs": "svn", + "displayName": "Subversion", + "origin": { + "kind": "plugin", + "pluginID": "dev.lithe.fixture.vcs-provider" + }, + "markers": [ + { + "name": ".svn", + "type": "directory" + } + ], + "capabilities": { + "annotate": 1, + "baseContent": 1, + "checkin": 1, + "cleanup": 1, + "conflicts": 1, + "fileHistory": 1, + "fileOperations": 1, + "history": 1, + "rollback": 1, + "status": 1, + "update": 1 + } + } + ], + "rootDetectionCases": [ + { + "name": "nested Subversion working copy inside a Git repository", + "markers": [ + { + "directory": "", + "name": ".git", + "type": "directory" + }, + { + "directory": "services/legacy", + "name": ".svn", + "type": "directory" + } + ], + "expectedRoots": [ + { + "id": "", + "vcs": "git" + }, + { + "id": "services/legacy", + "vcs": "svn" + } + ], + "expectedOwnership": [ + { + "path": "README.md", + "root": "" + }, + { + "path": "services/legacy/src/Main.java", + "root": "services/legacy" + }, + { + "path": "services/legacy-tools/a.sh", + "root": "" + } + ], + "expectedAmbiguous": [] + }, + { + "name": "an unmapped directory with markers of two providers owns nothing and blocks the outer root", + "markers": [ + { + "directory": "", + "name": ".git", + "type": "directory" + }, + { + "directory": "mixed", + "name": ".git", + "type": "directory" + }, + { + "directory": "mixed", + "name": ".svn", + "type": "directory" + } + ], + "expectedRoots": [ + { + "id": "", + "vcs": "git" + } + ], + "expectedOwnership": [ + { + "path": "mixed/a.txt", + "root": null + }, + { + "path": "mixed.txt", + "root": "" + } + ], + "expectedAmbiguous": [ + { + "id": "mixed", + "vcs": [ + "git", + "svn" + ] + } + ] + }, + { + "name": "a mapping chooses the provider of an ambiguous directory", + "markers": [ + { + "directory": "", + "name": ".git", + "type": "directory" + }, + { + "directory": "mixed", + "name": ".git", + "type": "directory" + }, + { + "directory": "mixed", + "name": ".svn", + "type": "directory" + } + ], + "mappings": [ + { + "id": "gone", + "vcs": "git" + }, + { + "id": "mixed", + "vcs": "hg" + }, + { + "id": "mixed", + "vcs": "svn" + } + ], + "expectedRoots": [ + { + "id": "", + "vcs": "git" + }, + { + "id": "mixed", + "vcs": "svn", + "mapped": true + } + ], + "expectedOwnership": [ + { + "path": "mixed/a.txt", + "root": "mixed" + } + ], + "expectedAmbiguous": [], + "expectedUnusedMappings": [ + { + "id": "gone", + "vcs": "git" + }, + { + "id": "mixed", + "vcs": "hg" + } + ] + }, + { + "name": "Git worktree file marker", + "markers": [ + { + "directory": "linked", + "name": ".git", + "type": "file" + } + ], + "expectedRoots": [ + { + "id": "linked", + "vcs": "git" + } + ], + "expectedOwnership": [ + { + "path": "linked/src/lib.rs", + "root": "linked" + }, + { + "path": "other/a.txt", + "root": null + } + ], + "expectedAmbiguous": [] + }, + { + "name": "a workspace opened inside a Subversion working copy", + "workspace": "trunk/module", + "markers": [ + { + "directory": "", + "name": ".svn", + "type": "directory" + } + ], + "expectedRoots": [ + { + "id": "../..", + "vcs": "svn" + } + ], + "expectedOwnership": [ + { + "path": "src/Main.java", + "root": "../.." + } + ], + "expectedAmbiguous": [] + }, + { + "name": "metadata and dependency directories are not searched", + "markers": [ + { + "directory": "", + "name": ".svn", + "type": "directory" + }, + { + "directory": ".svn/pristine", + "name": ".git", + "type": "directory" + }, + { + "directory": "node_modules/left-pad", + "name": ".git", + "type": "directory" + }, + { + "directory": "target/checkout", + "name": ".svn", + "type": "directory" + } + ], + "expectedRoots": [ + { + "id": "", + "vcs": "svn" + } + ], + "expectedOwnership": [ + { + "path": "node_modules/left-pad/index.js", + "root": "" + } + ], + "expectedAmbiguous": [] + }, + { + "name": "roots deeper than eight directories are not looked for", + "markers": [ + { + "directory": "a/b/c/d/e/f/g/h", + "name": ".svn", + "type": "directory" + }, + { + "directory": "z/b/c/d/e/f/g/h/i", + "name": ".git", + "type": "directory" + } + ], + "expectedRoots": [ + { + "id": "a/b/c/d/e/f/g/h", + "vcs": "svn" + } + ], + "expectedOwnership": [ + { + "path": "z/b/c/d/e/f/g/h/i/a.txt", + "root": null + } + ], + "expectedAmbiguous": [] + } + ], + "statusSnapshots": [ + { + "name": "provider with a staging dimension", + "capabilities": { + "baseContent": 1, + "checkin": 1, + "rollback": 1, + "status": 1, + "staging": 1 + }, + "snapshot": { + "head": { + "revision": { + "id": "4b825dc642cb6eb9a060e54bf8d69288fbee4904", + "display": "4b825dc6", + "kind": "hash" + }, + "branch": "preview", + "location": "https://vcs.example.invalid/owner/repo.git" + }, + "changes": [ + { + "path": "docs/new.md", + "oldPath": null, + "status": "added", + "flags": [], + "staging": "staged" + }, + { + "path": "src/lib.rs", + "oldPath": null, + "status": "modified", + "flags": [], + "staging": "partial" + }, + { + "path": "src/renamed.rs", + "oldPath": "src/old.rs", + "status": "renamed", + "flags": [], + "staging": "staged" + }, + { + "path": "vendor/module", + "oldPath": null, + "status": "modified", + "flags": [ + "submodule" + ], + "staging": "unstaged" + } + ], + "truncated": false + } + }, + { + "name": "svn root without staging, with Subversion-only flags", + "capabilities": { + "annotate": 1, + "baseContent": 1, + "checkin": 1, + "cleanup": 1, + "conflicts": 1, + "fileHistory": 1, + "fileOperations": 1, + "history": 1, + "rollback": 1, + "status": 1, + "update": 1 + }, + "snapshot": { + "head": { + "revision": { + "id": "1234", + "display": "r1234", + "kind": "number" + }, + "branch": null, + "location": "https://svn.example.invalid/repo/trunk" + }, + "changes": [ + { + "path": "build.xml", + "oldPath": null, + "status": "missing", + "flags": [] + }, + { + "path": "lib/vendor", + "oldPath": null, + "status": "unversioned", + "flags": [ + "external" + ] + }, + { + "path": "src/Main.java", + "oldPath": null, + "status": "modified", + "flags": [ + "locked", + "propertiesModified" + ] + }, + { + "path": "src/Moved.java", + "oldPath": "src/Old.java", + "status": "renamed", + "flags": [] + }, + { + "path": "src/Shared.java", + "oldPath": null, + "status": "conflicted", + "flags": [ + "treeConflict" + ] + } + ], + "truncated": false + } + } + ], + "pluginReplyValidationCases": [ + { + "name": "unknown status value is rejected", + "reply": { + "changes": [ + { + "path": "a.txt", + "oldPath": null, + "status": "obstructed", + "flags": [] + } + ] + }, + "expectedError": "plugin.vcs.invalidOutput" + }, + { + "name": "path escaping the root is rejected", + "reply": { + "changes": [ + { + "path": "../outside.txt", + "oldPath": null, + "status": "modified", + "flags": [] + } + ] + }, + "expectedError": "plugin.vcs.invalidOutput" + }, + { + "name": "unknown flag is dropped, change is kept", + "reply": { + "changes": [ + { + "path": "a.txt", + "oldPath": null, + "status": "modified", + "flags": [ + "futureFlag", + "locked" + ] + } + ] + }, + "expectedChanges": [ + { + "path": "a.txt", + "oldPath": null, + "status": "modified", + "flags": [ + "locked" + ] + } + ] + }, + { + "name": "staging field from a provider without staging is rejected", + "reply": { + "changes": [ + { + "path": "a.txt", + "oldPath": null, + "status": "modified", + "flags": [], + "staging": "staged" + } + ] + }, + "expectedError": "plugin.vcs.invalidOutput" + }, + { + "name": "unordered changes are sorted by path", + "reply": { + "changes": [ + { + "path": "b.txt", + "oldPath": null, + "status": "added", + "flags": [] + }, + { + "path": "a.txt", + "oldPath": null, + "status": "deleted", + "flags": [] + } + ] + }, + "expectedChanges": [ + { + "path": "a.txt", + "oldPath": null, + "status": "deleted", + "flags": [] + }, + { + "path": "b.txt", + "oldPath": null, + "status": "added", + "flags": [] + } + ] + }, + { + "name": "credentials, query, and fragment leave the head location", + "reply": { + "head": { + "revision": { + "id": "7" + }, + "location": "https://alice:secret@svn.example.invalid/repo/trunk?p=1#top" + }, + "changes": [] + }, + "expectedResult": { + "head": { + "revision": { + "id": "7", + "display": "7", + "kind": "opaque" + }, + "branch": null, + "location": "https://svn.example.invalid/repo/trunk" + }, + "changes": [], + "truncated": false + } + }, + { + "name": "an original path on a modification is rejected", + "reply": { + "changes": [ + { + "path": "a.txt", + "oldPath": "b.txt", + "status": "modified", + "flags": [] + } + ] + }, + "expectedError": "plugin.vcs.invalidOutput" + }, + { + "name": "a history page longer than the request is rejected", + "operation": "history", + "request": { + "limit": 1 + }, + "reply": { + "entries": [ + { + "revision": { + "id": "2" + }, + "author": "a", + "date": "2026-10-22T00:00:00Z", + "message": "m" + }, + { + "revision": { + "id": "1" + }, + "author": "a", + "date": "2026-10-21T00:00:00Z", + "message": "m" + } + ], + "nextCursor": null + }, + "expectedError": "plugin.vcs.invalidOutput" + }, + { + "name": "an annotated line must name a listed revision", + "operation": "annotate", + "request": { + "path": "a.txt" + }, + "reply": { + "revisions": [ + { + "revision": { + "id": "1" + }, + "author": "a", + "date": "2026-10-21T00:00:00Z", + "message": "m" + } + ], + "lines": [ + "1", + "2" + ] + }, + "expectedError": "plugin.vcs.invalidOutput" + }, + { + "name": "update conflicts are sorted and stay inside the root", + "operation": "update", + "request": {}, + "reply": { + "revision": { + "id": "9", + "display": "r9", + "kind": "number" + }, + "conflicts": [ + "b.txt", + "a.txt" + ] + }, + "expectedResult": { + "revision": { + "id": "9", + "display": "r9", + "kind": "number" + }, + "conflicts": [ + "a.txt", + "b.txt" + ] + } + } + ] +} diff --git a/shared/fixtures/workspace/file-name-order-v1.json b/shared/fixtures/workspace/file-name-order-v1.json new file mode 100644 index 0000000000..00ba51940a --- /dev/null +++ b/shared/fixtures/workspace/file-name-order-v1.json @@ -0,0 +1,47 @@ +{ + "version": 1, + "description": "Sibling names in ascending project-tree order, following IntelliJ IDEA's FileNameComparator (NaturalComparator, ignoreCase and likeFileNames). Every name must sort strictly before each name after it within its group; directories-first ranking is applied by the tree, not by this comparator.", + "orderedGroups": [ + { + "id": "release-notes", + "names": [ + "TEMPLATE.md", + "v0.1.4.md", + "v0.1.5.md", + "v0.1.8.md", + "v0.1.12.md", + "v0.1.13.md", + "v0.1.14.md", + "v0.2.0.md", + "v0.2.1.md", + "v0.2.2.md", + "v0.3.1.md", + "v0.3.3.md" + ] + }, + { + "id": "digit-runs-compare-by-value", + "names": ["file1.txt", "file2.txt", "file10.txt", "file20.txt", "file100.txt"] + }, + { + "id": "leading-zeros-sort-after-the-plain-number", + "names": ["a1", "a01", "a001", "a2"] + }, + { + "id": "case-is-ignored-before-it-breaks-ties", + "names": ["A.txt", "a.txt", "B.txt", "b.txt", "c.txt"] + }, + { + "id": "dash-ranks-next-to-underscore", + "names": ["foo", "foo.bar", "foo-bar", "foo_bar", "fooa"] + }, + { + "id": "spaces-inside-numbers", + "names": ["Test1 2.txt", "Test1 12.txt", "Test2.txt"] + }, + { + "id": "non-ascii-names-follow-code-units", + "names": ["notes", "笔记"] + } + ] +} diff --git a/shared/i18n/README.md b/shared/i18n/README.md new file mode 100644 index 0000000000..fcf1326718 --- /dev/null +++ b/shared/i18n/README.md @@ -0,0 +1,87 @@ +# 维护共享翻译 + +应用自有的 macOS 和 Windows 翻译都在这里维护,再生成两端使用的资源。 +macOS 的字符串表、Windows 的完整翻译字典,以及原先页面内的中英文文案都已迁入。 +两端继续使用原来的翻译接口,不在运行时读取这里的 JSON,也不下载语言包。 + +## 修改文案并生成资源 + +- `manifest.json` 声明语言、自称名称、平台默认语言、macOS 资源标识和回退关系。 + 共享 `zh-CN` 对应 macOS 已保存的 `zh-Hans`;不要改掉用户的持久化标识。 +- `messages.json` 声明语义 key、用途、适用平台和参数。相同语义共用 key; + 平台专属内容保留独立 key。历史 Windows key 中的空格、连字符等通过 `windows.key` + 显式兼容,原生英文旧 key 通过 `macos.key` 显式兼容。 +- `locales/.json` 保存对应语言的 `messages`。平台措辞确实不同时,使用 + `overrides.macos` 或 `overrides.windows`,同时填写 `value` 和 `reason`。 + +修改后运行: + +```sh +node scripts/generate-i18n.mjs +node scripts/generate-i18n.mjs --check +node scripts/generate-i18n.mjs --coverage +node --test --test-timeout=60000 scripts/test-i18n.mjs +``` + +提交共享源文件和生成结果。Windows `shared.generated.ts`、macOS +`AppLanguage.generated.swift` 和各语言的整个 `Localizable.strings` 都是生成物。 +不要手工维护这些文件,不要在 Windows 再添加一份翻译字典,也不要在页面重新判断中英文。 +开发、构建、打包与独立跨平台 CI 会检查结果是否最新;`--check` 只读,不写文件。 +测试文件总预算为 60 秒;单项测试仍由源码中的独立超时控制,最长 15 秒, +打包子进程最长 5 秒,避免把整份顺序测试的耗时误算成单项超时。 +生成文件由根目录 `.gitattributes` 固定使用 LF 换行,Windows 启用 Git 自动换行转换时 +也应保持相同字节;不要通过重新生成或跳过校验掩盖检出格式差异。 + +## 使用正确的语义和平台范围 + +先查清词条用途,不要按英文相同就合并。已确认的通用动作共用词条,尚未证明相同语义的 +既有内容继续使用显式平台范围,避免“Provider”等同名不同义的翻译串用。 +平台范围仅决定资源生成,不表示功能已实现;功能能力继续由 +[功能矩阵](../platform-feature-matrix) 记录。 + +macOS 生成器同时输出语义 key 和旧英文兼容别名,所以 `Text("Save")`、 +`String(localized: "Save")` 与新语义 key 读取同一张表。需要明确选择语言时,通过 +`AppLanguage.localizedString` 或 `localizedFormat` 读取已打包的原生资源。 +上游诊断、用户代码、字体覆盖样本、开发基准场景、搜索关键词和 AI 提交的目标输出语言属于各自的数据, +不要把这些内容当作界面翻译改写;已知的 Agent 选项说明在呈现处通过共享词条翻译。 + +## 声明参数和格式 + +普通参数写成 `{name}`,并在 `parameters` 中声明 `string`、`integer` 或 +`unsignedInteger`。参数集合由源定义控制,翻译遗漏或拼错参数会失败。 +某种语言不需要英文复数后缀时,用 `optionalParameters` 记录该参数及省略理由; +不能为了通过检查悄悄删掉参数。确实需要空译文的语言后缀必须声明 `allowEmptyTranslation`。 + +原生旧 key 还声明 `macos.arguments` 的调用顺序。默认字符串使用 `%@`、整数使用 +`%lld`、无符号整数使用 `%llu`;历史 `%d` 等调用通过 `macos.formats` 显式保留 ABI +类型,不能随意改成 64 位格式。翻译调整顺序或重复参数时生成位置格式,普通百分号 +转换成 `%%`。历史明确使用位置格式的译文用 override 的 `nativeFormat: positional` +保留原表格式。原生调用方仍须传入匹配的 `CVarArg` 类型。 + +`literalBraces` 只用于本来就显示代码花括号或双花括号的文本;声明后未知的花括号内容 +保持原文,已声明参数仍必须完整。Windows 只替换一次参数,用户值中的 `{name}` 不会被 +第二次解释。没有传入的参数保留占位符,未知 key 原样返回。当前不支持 ICU 复数语法 +或任意 printf 指令;若有复杂复数需求,应独立评估成熟工具。 + +## 增加一种内置语言 + +1. 在清单中添加唯一的 `id`、`title`、`macos`、`swiftCase`、`fallback` 和 `coverage`。 +2. 创建同名语言文件,例如 `locales/ja-JP.json`。 +3. 翻译完整时用 `coverage: complete`;允许暂缺时用 `fallback`,并声明回退语言。 + 所有回退链最终必须到达完整默认语言,不能成环。 +4. 运行生成及校验,检查语言列表、切换、参数、缺词回退和两端打包。 + +语言选项、Swift 枚举、Windows 类型和原生打包列表都由清单驱动。 +所有平台词条都在构建时展开回退,不存在另一个旧字典需要修改。 +默认回退语言为英文;macOS 默认英文、Windows 默认中文,保留现有产品行为。 +旧资源本来缺失的翻译使用英文回退;“资源覆盖完整”不代表所有句子都已人工翻译。 +删除语言时还要移除生成的 `.lproj` 目录,并考虑用户已有设置的回退。 + +## 保持安装资源只读 + +生成只发生在当前源码工作树。macOS 打包复制资源并仅将目标副本转换成二进制 plist, +随后签名;运行时只读,不产生语言缓存,不影响签名和 Sparkle 增量更新的源基线。 +源文件和生成物不通过 worktree 资源复用脚本复制,见 +[独立工作树的本地编译](../../docs/ci-builds.md#独立工作树的本地编译)。 +Windows 验证传输目录 `.artifacts/i18n-windows/` 是本次源码快照和验证工具的临时暂存区, +不能作为生成资源、下载缓存或跨工作树可复用文件;验证后清理传输包与工具。 diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json new file mode 100644 index 0000000000..667acfaf00 --- /dev/null +++ b/shared/i18n/locales/en-US.json @@ -0,0 +1,8896 @@ +{ + "messages": { + "agent.actions.close": "Close the Agent panel", + "agent.actions.closeSearch": "Close search", + "agent.actions.hideDetails": "Hide session details", + "agent.actions.history": "Conversation history", + "agent.actions.moveToToolWindow": "Move conversation to the tool window", + "agent.actions.newConversation": "New conversation", + "agent.actions.openInEditor": "Open conversation in editor", + "agent.actions.openSettings": "Open Agent Settings", + "agent.actions.reconnect": "Reconnect", + "agent.actions.search": "Search conversation", + "agent.actions.settings": "Agent Settings", + "agent.actions.showDetails": "Show session details", + "agent.actions.switchAgent": "Switch Agent", + "agent.actions.tabs": "Conversation tabs", + "agent.activity.edits": "Edits", + "agent.activity.executing": "Running tools", + "agent.activity.permission": "Waiting for permission", + "agent.activity.quiet": "No recent activity", + "agent.activity.reasoning": "Reasoning", + "agent.activity.recovering": "Reconnecting {attempt}", + "agent.activity.responding": "Responding", + "agent.activity.retrying": "Reconnecting {attempt}/{max}", + "agent.activity.running": "Running", + "agent.activity.tasks": "Tasks", + "agent.activity.waiting": "Waiting for response", + "agent.archive.badge": "Archived", + "agent.archive.confirmMessage": "Unarchive this conversation and open it?", + "agent.archive.empty": "No archived conversations.", + "agent.archive.incomplete": "Showing recent archived conversations only.", + "agent.archive.refresh": "Refresh archived conversations", + "agent.archive.restore": "Unarchive", + "agent.archive.title": "Archived by Agent", + "agent.archive.unsupported": "This Agent does not support archived conversations.", + "agent.composer.agentSettings": "Agent Settings…", + "agent.composer.attachActive": "Attach current file", + "agent.composer.attachFile": "Attach a project file", + "agent.composer.attachFiles": "Attach files", + "agent.composer.attachHint": "Drag files here or click to choose files", + "agent.composer.cancelling": "Stopping…", + "agent.composer.chooseAgent": "Choose an Agent", + "agent.composer.clipboardBusy": "The clipboard is busy. Try pasting again.", + "agent.composer.commands": "Agent commands", + "agent.composer.connectionClosed": "The Agent connection closed.", + "agent.composer.dropFiles": "Drop files to reference them", + "agent.composer.dropHere": "Drop files here", + "agent.composer.fileLimit": "Attach up to {count} files per message.", + "agent.composer.imageCleanupFailed": "Some abandoned images could not be removed from the application cache.", + "agent.composer.invalidImage": "Paste supported images up to 10 MiB each and 32 MiB total (PNG, JPEG, WebP, GIF or BMP).", + "agent.composer.message": "Message the Agent", + "agent.composer.messageHint": "Type your task, / for commands or @ for files", + "agent.composer.noAgent": "No Agent is set up yet", + "agent.composer.noCommands": "No matching commands", + "agent.composer.noFiles": "No matching files", + "agent.composer.placeholder": "Describe a task for the Agent…", + "agent.composer.preparing": "Preparing the session…", + "agent.composer.queued": "Queued: {text}", + "agent.composer.received": "Agent received", + "agent.composer.removeAttachment": "Remove file {name}", + "agent.composer.removeFile": "Remove {name}", + "agent.composer.searchFiles": "Search project files", + "agent.composer.send": "Send", + "agent.composer.sending": "Sending…", + "agent.composer.stop": "Stop", + "agent.composer.supplementCancelled": "Supplementary message cancelled.", + "agent.composer.supplementRejected": "The Agent did not accept the supplementary message.", + "agent.composer.tooManyFiles": "Attach at most 32 files at a time.", + "agent.composer.tooManyImages": "Paste at most 32 images at a time.", + "agent.configuration.staged": "Applies to the next turn", + "agent.context.attach": "Add to Agent conversation", + "agent.context.busy": "Finish the current Agent operation before switching Agents.", + "agent.context.current": "Current conversation", + "agent.context.dirty": "Files were attached from disk. Save unsaved editor changes before sending.", + "agent.context.label": "Context usage", + "agent.context.new": "New conversation", + "agent.context.placeholder": "Context: {percent}", + "agent.context.setup": "Configure Agent…", + "agent.context.stale": "The workspace, Agent or conversation changed. Open the file menu again.", + "agent.context.tokens": "{percent} · {used} / {capacity} context tokens", + "agent.context.unavailable": "A selected file is unavailable. Check that it still exists on disk.", + "agent.continueWaiting": "Continue waiting", + "agent.details.agent": "Agent", + "agent.details.context": "Context and model", + "agent.details.messages": "Messages", + "agent.details.noConfig": "The Agent has not reported model settings yet.", + "agent.details.session": "Session", + "agent.details.sessionId": "Session ID", + "agent.details.title": "Session details", + "agent.details.titleLabel": "Title", + "agent.details.turns": "Completed turns", + "agent.details.unavailable": "MCP servers and changed files appear when the Agent reports them.", + "agent.details.workspace": "Workspace", + "agent.editor.focusTab": "Focus editor tab", + "agent.editor.foreignAgentMessage": "This tab was opened with {{agent}}, but {{current}} is active. Switch back to {{agent}} to load it; the current connection must not take over the session.", + "agent.editor.foreignAgentTitle": "This conversation belongs to another Agent", + "agent.editor.handoffNotice": "This conversation is open in the editor. The tool window shows history.", + "agent.editor.switchBack": "Switch to {{agent}}", + "agent.edits.changed": "The file changed on disk. Review its current contents before reverting.", + "agent.edits.diff": "Reported diff", + "agent.edits.dirty": "Save or discard the unsaved editor changes before reverting.", + "agent.edits.failed": "The edit could not be reverted. Check the file location and permissions.", + "agent.edits.incomplete": "Complete original content is unavailable; revert is disabled.", + "agent.edits.keep": "Keep", + "agent.edits.recoveryRequired": "The file could not be restored automatically. Recover it from: {path}", + "agent.edits.revert": "Revert", + "agent.edits.title": "Review edits ({count})", + "agent.error.disabled": "Agent conversation is turned off. Turn it on in the panel settings to send messages.", + "agent.error.notConnected": "The Agent is not running. Connect to start a conversation.", + "agent.error.preparing": "The conversation is still being prepared. Try again in a moment.", + "agent.evidence.input": "Input", + "agent.evidence.result": "Result", + "agent.hero.choose": "Choose an Agent to start", + "agent.hero.sendTo": "Send a message to {name}", + "agent.history.addFavorite": "Add to favorites", + "agent.history.conversations": "{count} conversations", + "agent.history.copied": "Session ID copied", + "agent.history.copyId": "Copy session ID", + "agent.history.deselectAll": "Deselect all", + "agent.history.done": "Done", + "agent.history.empty": "No saved sessions for this project yet.", + "agent.history.export": "Export conversations", + "agent.history.exportFailed": "The conversations could not be exported.", + "agent.history.exportFileType": "Markdown", + "agent.history.exportTooLarge": "The transcript is larger than 32 MiB, so it was not exported.", + "agent.history.exportUnfinished": "Some conversations are not fully loaded yet, so nothing was exported. Open them once and try again.", + "agent.history.exporting": "Exporting conversations…", + "agent.history.filter": "History filter", + "agent.history.filter.all": "All conversations", + "agent.history.filter.favorites": "Favorites", + "agent.history.filter.removed": "Removed conversations", + "agent.history.loading": "Loading history…", + "agent.history.messages": "{count} messages", + "agent.history.noMatch": "No matching conversations", + "agent.history.noMatchHint": "Try another title or session ID.", + "agent.history.refresh": "Refresh history", + "agent.history.remove": "Remove from history", + "agent.history.removeConfirm": "Remove {count} conversation(s) from history? You can restore them from Removed conversations. The Agent's original records will be kept.", + "agent.history.removeFavorite": "Remove from favorites", + "agent.history.removedNotice": "Removed conversations are hidden only in Lithe. The Agent's original records are kept.", + "agent.history.rename": "Rename conversation", + "agent.history.renameMessage": "Set a Lithe-only title for this conversation.", + "agent.history.restore": "Restore conversations", + "agent.history.saveFailed": "Lithe could not save the conversation annotations.", + "agent.history.search": "Search conversation titles or IDs…", + "agent.history.select": "Select", + "agent.history.selectAll": "Select all", + "agent.history.selectedCount": "{count} selected", + "agent.history.thinking": "Thinking", + "agent.history.title": "History ({count})", + "agent.history.tool": "Tool", + "agent.history.you": "You", + "agent.notice.dismiss": "Dismiss notification", + "agent.permission.deny": "Deny", + "agent.permission.dismiss": "Not now", + "agent.permission.title": "Permission required", + "agent.plan.collapse": "Hide plan", + "agent.plan.completed": "Completed", + "agent.plan.expand": "Show plan", + "agent.plan.pending": "Pending", + "agent.plan.progress": "Plan {completed}/{total}", + "agent.plan.running": "Running", + "agent.quietNotice": "No recent progress has been received. The task is still active.", + "agent.quota.primary": "{window} window {percent}", + "agent.quota.resetsAt": "resets {time}", + "agent.quota.unknown": "{window} window usage is unknown", + "agent.quotaView.accountChanged": "The local Codex account changed. Reconnect to refresh quota.", + "agent.quotaView.fiveHours": "5 hours", + "agent.quotaView.resets": "Resets: {time}", + "agent.quotaView.sevenDays": "7 days", + "agent.quotaView.stale": "Quota is temporarily unavailable. The last value may be out of date.", + "agent.quotaView.title": "Codex subscription quota", + "agent.quotaView.unknown": "Quota —", + "agent.quotaView.updated": "Updated: {time}", + "agent.quotaView.used": "{window} · {percent} used", + "agent.quotaView.usedPercent": "Used {percent}", + "agent.quotaView.waiting": "Waiting for subscription quota…", + "agent.role.agent": "Agent", + "agent.role.user": "You", + "agent.selector.fast": "Fast", + "agent.selector.mode": "Approval mode", + "agent.selector.model": "Model", + "agent.selector.more": "More session settings", + "agent.selector.noModels": "No matching models", + "agent.selector.searchModels": "Search models", + "agent.selector.speed": "Speed", + "agent.selector.standard": "Standard", + "agent.selector.thinkingLevel": "Thinking level", + "agent.settings.addProvider": "Add", + "agent.settings.agent": "Agent", + "agent.settings.allowInsecureHint": "Allow a plain http endpoint", + "agent.settings.apiKey": "API key", + "agent.settings.apiKeyMode": "API key", + "agent.settings.apiKeyPlaceholder": "sk-…", + "agent.settings.apiKeyStored": "Stored — enter a new key to replace it", + "agent.settings.arguments": "Arguments", + "agent.settings.authJson": "Codex auth.json (optional; API key saved securely)", + "agent.settings.authentication": "Sign-in", + "agent.settings.back": "Back to the conversation", + "agent.settings.baseUrl": "Endpoint", + "agent.settings.busyAuthentication": "Finish the current turn before changing how the Agent signs in.", + "agent.settings.cliDetected": "{name} {version} is installed.", + "agent.settings.cliMissing": "{name} {version} or newer was not found.", + "agent.settings.cliUpdateWarning": "Updated with an installer warning: {warning}", + "agent.settings.cliUpdated": "Updated to {version}.", + "agent.settings.command": "Executable", + "agent.settings.configurationTooLarge": "CLI configuration exceeds 64 KiB.", + "agent.settings.conflict": "Agent settings changed in another window. Review the latest values and try again.", + "agent.settings.customAgent": "Custom ACP agent", + "agent.settings.editProvider": "Edit", + "agent.settings.enable": "Enable the Agent panel", + "agent.settings.enableHint": "Off by default; nothing starts until you turn it on.", + "agent.settings.enableSubscription": "Enable", + "agent.settings.formMode": "Edit fields", + "agent.settings.hideKey": "Hide API key", + "agent.settings.importConfiguration": "Import CLI configuration", + "agent.settings.importConfigurationAction": "Import", + "agent.settings.importConfigurationHint": "Reads the endpoint and model only; keys stay with your CLI.", + "agent.settings.importFailed": "The CLI configuration could not be read. Check its format and location.", + "agent.settings.importProtocolMismatch": "This configuration does not match the selected agent's protocol.", + "agent.settings.importingConfiguration": "Reading…", + "agent.settings.install": "Install", + "agent.settings.installCli": "Install", + "agent.settings.installed": "{name} {version} is installed.", + "agent.settings.installing": "Installing…", + "agent.settings.invalidProviderUrl": "Use a valid HTTPS API URL, or explicitly allow HTTP.", + "agent.settings.keyFailed": "The credential could not be updated. Try again.", + "agent.settings.linkLocal": "Use latest local CLI settings and API key on every connection", + "agent.settings.model": "Model", + "agent.settings.modelPlaceholder": "Adapter default", + "agent.settings.notInstalled": "{name} is not installed.", + "agent.settings.preflight": "Environment details", + "agent.settings.progress": "npm {stage} · {megabytes} MB", + "agent.settings.provider": "Provider", + "agent.settings.providerName": "Provider name", + "agent.settings.readLocalConfiguration": "Read local CLI settings", + "agent.settings.reconnect": "Reconnect", + "agent.settings.reconnectHint": "Provider changes apply on the next connection.", + "agent.settings.refresh": "Check again", + "agent.settings.reinstall": "Reinstall", + "agent.settings.removeProvider": "Remove", + "agent.settings.removeProviderConfirm": "Remove this provider and its saved key?", + "agent.settings.requiredProviderFields": "Enter a provider name, API URL, model and API key.", + "agent.settings.saveFailed": "Agent settings could not be saved. Try again.", + "agent.settings.saveKey": "Save", + "agent.settings.showKey": "Show API key", + "agent.settings.subscriptionHint": "Lithe reuses the Codex CLI's own ChatGPT sign-in; no key is copied.", + "agent.settings.subscriptionMode": "Codex subscription", + "agent.settings.subscriptionRowHint": "Use your local ChatGPT account. No API key or URL is needed.", + "agent.settings.textMode": "Edit configuration text", + "agent.settings.title": "Settings", + "agent.settings.uninstall": "Uninstall adapter", + "agent.settings.unlinkKey": "Unlink key", + "agent.settings.updateCli": "Update", + "agent.settings.updatingCli": "Updating…", + "agent.settings.useApiKey": "Use API key", + "agent.setup.disabled": "The Agent panel is turned off.", + "agent.setup.enable": "Turn on", + "agent.setup.invalidCliConfiguration": "The linked CLI configuration needs a valid endpoint, model and API key. Check local CLI settings, then reconnect.", + "agent.setup.missingApiKey": "Add the provider's API key in Agent settings.", + "agent.setup.missingCommand": "Set the ACP Agent executable before starting a conversation.", + "agent.setup.missingProvider": "The Agent needs a provider endpoint. Add one in Agent settings.", + "agent.setup.noAgent": "No Agent is selected.", + "agent.setup.noProject": "Open a project to use the Agent.", + "agent.setup.openSettings": "Agent settings", + "agent.setup.subscriptionNeedsCodex": "Only the Codex adapter can use a ChatGPT subscription.", + "agent.state.cancel": "Cancel", + "agent.state.failed": "The Agent could not start", + "agent.state.signIn": "Sign in to Codex", + "agent.state.signInAction": "Sign in with ChatGPT", + "agent.state.signInMessage": "Use your local ChatGPT account. No API key or URL is needed.", + "agent.state.signingIn": "Waiting for ChatGPT sign-in…", + "agent.state.signingInMessage": "Complete sign-in in your browser. Your credentials are managed by Codex.", + "agent.state.starting": "Starting the Agent…", + "agent.state.startingMessage": "The Agent process starts when this panel opens.", + "agent.stats.calibrated": "Estimated composition calibrated to provider input tokens", + "agent.stats.calls": "Model calls", + "agent.stats.estimated": "Estimated from request size", + "agent.stats.lowerBound": "Some calls have no reported usage; token counts are a lower bound.", + "agent.stats.mcp": "MCP tools", + "agent.stats.messages": "Conversation messages", + "agent.stats.modelTime": "Model time", + "agent.stats.noComposition": "Context composition is available after an observed model request.", + "agent.stats.nonStreaming": "Non-streaming calls", + "agent.stats.observe": "Model call statistics", + "agent.stats.observeHint": "Observe requests through the local Host proxy; reconnect to apply.", + "agent.stats.system": "System prompt", + "agent.stats.toolCalls": "Tool calls", + "agent.stats.toolCount": "{tools} tools · {mcp} MCP tools", + "agent.stats.toolTime": "Tool and permission time", + "agent.stats.tools": "Tool definitions", + "agent.stats.tps": "Output speed", + "agent.stats.ttft": "First token", + "agent.stats.unknown": "Not reported", + "agent.status.connecting": "Starting the Agent…", + "agent.status.failed": "The Agent could not start", + "agent.status.retry": "Retry", + "agent.stop": "Stop", + "agent.subagents.cancelled": "Cancelled", + "agent.subagents.completed": "Completed", + "agent.subagents.disconnected": "Disconnected", + "agent.subagents.failed": "Failed", + "agent.subagents.progress": "{completed}/{total} completed", + "agent.subagents.running": "Running", + "agent.subagents.title": "Subagents", + "agent.subscription.authenticating": "Waiting for the browser sign-in to finish…", + "agent.subscription.cancelSignIn": "Cancel sign-in", + "agent.subscription.required": "Sign in with your ChatGPT account to use the Codex subscription.", + "agent.subscription.signIn": "Sign in with ChatGPT", + "agent.subscription.signedIn": "Signed in", + "agent.tabs.close": "Close conversation", + "agent.tabs.label": "Open conversations", + "agent.tabs.untitled": "Untitled conversation", + "agent.title": "Agent", + "agent.tool.details": "Tool details", + "agent.tool.status.completed": "Done", + "agent.tool.status.failed": "Failed", + "agent.tool.status.inProgress": "Running", + "agent.tool.status.interrupted": "Interrupted", + "agent.tool.status.pending": "Pending", + "agent.toolGroup.allCompleted": "All completed", + "agent.toolGroup.completed": "{done}/{total} completed", + "agent.toolGroup.failed": "{count} failed", + "agent.toolGroup.hide": "Hide tool details", + "agent.toolGroup.interrupted": "{count} interrupted", + "agent.toolGroup.show": "Show tool details", + "agent.toolGroup.title": "Tool activity ({count})", + "agent.transcript.code": "code", + "agent.transcript.copyCode": "Copy code", + "agent.transcript.copyReply": "Copy reply", + "agent.transcript.empty": "No messages yet.", + "agent.transcript.hideReasoning": "Hide thinking", + "agent.transcript.loading": "Loading conversation…", + "agent.transcript.noMatch": "No matching messages", + "agent.transcript.pickSession": "Pick a conversation, or start a new one.", + "agent.transcript.reasoning": "Thinking process", + "agent.transcript.showReasoning": "Show thinking", + "agent.transcript.stopping": "Stopping…", + "agent.transcript.thinking": "Thinking…", + "agent.turn.cacheRead": "Cache read tokens: {count}", + "agent.turn.cacheReadLabel": "Cache read", + "agent.turn.cacheWrite": "Cache write tokens: {count}", + "agent.turn.cacheWriteLabel": "Cache write", + "agent.turn.elapsed": "Elapsed: {duration}", + "agent.turn.elapsedHint": "Elapsed since sending, including tools and permission waits.", + "agent.turn.elapsedLabel": "Elapsed", + "agent.turn.hours": "{hours}h {minutes}m {seconds}s", + "agent.turn.input": "Input: {count}", + "agent.turn.inputLabel": "Input", + "agent.turn.minutes": "{minutes}m {seconds}s", + "agent.turn.output": "Output: {count}", + "agent.turn.outputLabel": "Output", + "agent.turn.reasoning": "Reasoning tokens: {count}", + "agent.turn.reasoningLabel": "Reasoning", + "agent.turn.seconds": "{seconds}s", + "agent.turn.thisTurn": "This turn", + "agent.turn.total": "Total tokens: {count}", + "agent.turn.totalLabel": "Total tokens", + "agent.turn.usageHint": "Token counts reported by the Agent. Accounting scope depends on the Agent.", + "agent.usage.tokens": "{used} of {capacity} tokens", + "agent.usage.unknown": "Context occupancy is not known yet", + "ai.actionRunNumber": "Action run #{number}", + "ai.add": "Add", + "ai.addContext": "Add context", + "ai.addToQueue": "Add to queue", + "ai.added": "added", + "ai.agent": "Agent", + "ai.agentDefault": "Agent default", + "ai.agentHistory": "Agent History", + "ai.agentInstallFailed": "Failed to install {name}", + "ai.agentInstalled": "{name} installed", + "ai.agentSessionRestarted": "Agent session restarted", + "ai.agentSetup": "Agent setup", + "ai.agents": "Agents", + "ai.aiPreferences": "AI preferences", + "ai.allow": "Allow", + "ai.allowOnce": "Allow once", + "ai.always": "Always", + "ai.alwaysAllowRequestType": "Always allow this request type", + "ai.alwaysDenyRequestType": "Always deny this request type", + "ai.apiKeyRequired": "API key required", + "ai.apiKeySaved": "API key saved", + "ai.apiKeySavedWithPeriod": "API key saved.", + "ai.apiKeys": "API Keys", + "ai.apply": "Apply", + "ai.applyCodeToCurrentBuffer": "Apply this code to current buffer", + "ai.archiveSession": "Archive session", + "ai.ask": "Ask", + "ai.branch": "Branch", + "ai.browse": "Browse", + "ai.cancel": "Cancel", + "ai.clickToRenameSession": "Click to rename session", + "ai.closeMessageSearch": "Close message search", + "ai.closeSearch": "Close search", + "ai.completeLoginThenRestart": "Complete login in the agent CLI, then restart the session.", + "ai.configureCustomModel": "Configure custom model...", + "ai.copyCode": "Copy code", + "ai.copyPrompt": "Copy prompt", + "ai.copyResponse": "Copy response", + "ai.copyTableHtml": "Copy table as HTML", + "ai.copyTableMarkdown": "Copy table as Markdown", + "ai.couldNotOpenAgentTerminal": "Couldn't open the agent terminal", + "ai.couldNotRestartAgentSession": "Couldn't restart the agent session", + "ai.deleteNamedSkill": "Delete {title}", + "ai.deleteSkill": "Delete skill", + "ai.deny": "Deny", + "ai.denyOnce": "Deny once", + "ai.details": "Details", + "ai.editNamedSkill": "Edit {title}", + "ai.editPrompt": "Edit prompt", + "ai.editSkill": "Edit skill", + "ai.enterApiKey": "Enter API key...", + "ai.error": "Error", + "ai.executePlan": "Execute Plan", + "ai.executePlanStepPrompt": "Execute step {number} of the plan: {title}\n\n{description}", + "ai.failedFetchModels": "Failed to fetch models", + "ai.failedRemoveApiKey": "Failed to remove API key.", + "ai.failedValidateApiKey": "Failed to validate API key.", + "ai.fileList": "File list", + "ai.files": "Files", + "ai.finishAgentSetupThenRestart": "Finish the agent setup, then restart the session.", + "ai.generatedContentNumber": "AI generated content {number}", + "ai.generatedImageNumber": "Generated image {number}", + "ai.hideDetails": "Hide details", + "ai.install": "Install", + "ai.installingAgent": "Installing {name}", + "ai.invalidApiKey": "Invalid API key.", + "ai.loadingAvailableSkills": "Loading available skills...", + "ai.loadingModels": "Loading models...", + "ai.localOverride": "Local override", + "ai.markdown": "Markdown", + "ai.marketplace": "Marketplace", + "ai.marketplaceSkill": "Marketplace skill", + "ai.marketplaceSkillWithLocalOverride": "Marketplace skill with local override", + "ai.messageInput": "Message input", + "ai.microphoneAccessFailed": "Microphone access failed. Check System Settings -> Privacy & Security -> Microphone.", + "ai.microphoneAccessFailedLinux": "Microphone access failed. Check your PipeWire/PulseAudio input device and unmute the default microphone.", + "ai.mode": "Mode", + "ai.model": "Model", + "ai.mySkills": "My skills", + "ai.never": "Never", + "ai.new": "New", + "ai.newAgent": "New Agent", + "ai.newSession": "New Session", + "ai.newSkill": "New skill", + "ai.nextMatch": "Next match", + "ai.nextSearchMatch": "Next search match", + "ai.noAvailableSkillsMatch": "No available skills match \"{query}\"", + "ai.noMatchingContextFound": "No matching context found", + "ai.noMatchingSlashCommands": "No matching slash commands", + "ai.noModelsFound": "No models found.", + "ai.noOllamaModelsDetected": "No models detected. Please install a model in Ollama.", + "ai.noProvidersFound": "No providers found", + "ai.noPublishedSkillsYet": "No published skills yet", + "ai.noSkillsMatch": "No skills match \"{query}\"", + "ai.noSkillsYet": "No skills yet", + "ai.noSlashCommandsAvailable": "No slash commands available yet", + "ai.notSynced": "Not synced", + "ai.open": "Open", + "ai.openAgentTerminal": "Open Agent Terminal", + "ai.openDashboard": "Open dashboard", + "ai.openDiff": "Open diff", + "ai.openFile": "Open file", + "ai.openLower": "open", + "ai.openResource": "Open {name}", + "ai.openTabs": "Open tabs", + "ai.openTerminal": "Open terminal", + "ai.opening": "Opening...", + "ai.permission": "Permission", + "ai.pinSession": "Pin session", + "ai.plan": "Plan", + "ai.planSummary": "Plan ({count} {label})", + "ai.pleaseEnterApiKey": "Please enter an API key.", + "ai.previousMatch": "Previous match", + "ai.previousSearchMatch": "Previous search match", + "ai.project": "Project", + "ai.provider": "Provider", + "ai.publishedSkillsWillAppear": "Published skills will appear here once the Lithe skills registry is available.", + "ai.pullRequestNumber": "Pull request #{number}", + "ai.recent": "Recent", + "ai.remove": "Remove", + "ai.removeContextItemHint": "{name}. Press Delete to remove from context.", + "ai.renameSession": "Rename {title}", + "ai.restartAgentSession": "Restart Agent Session", + "ai.restarting": "Restarting...", + "ai.save": "Save", + "ai.saveKey": "Save key", + "ai.searchApiKeyProviders": "Search API key providers...", + "ai.searchAvailableSkills": "Search available skills...", + "ai.searchContext": "Search context...", + "ai.searchFiles": "Search files...", + "ai.searchMessages": "Search messages", + "ai.searchSkills": "Search skills...", + "ai.selectAiModel": "Select AI model", + "ai.selectAiProvider": "Select AI provider", + "ai.selectModel": "Select model", + "ai.selectProvider": "Select a provider", + "ai.selectedContext": "Selected context", + "ai.send": "Send", + "ai.sendMessage": "Send message", + "ai.settings": "Settings", + "ai.showSlashCommands": "Show slash commands", + "ai.skillContentPlaceholder": "Write the instructions or reusable context for this skill...", + "ai.skillTitlePlaceholder": "Code review checklist", + "ai.skills": "Skills", + "ai.slashCommandSuggestions": "Slash command suggestions", + "ai.startVoiceInput": "Start voice input", + "ai.step": "step", + "ai.steps": "steps", + "ai.stopGeneration": "Stop generation", + "ai.stopVoiceInput": "Stop voice input", + "ai.suggestions": "Suggestions", + "ai.syncPaused": "Sync paused", + "ai.synced": "Synced", + "ai.syncing": "Syncing", + "ai.terminal": "Terminal", + "ai.thinking": "Thinking...", + "ai.title": "Title", + "ai.toggleAgentHistory": "Toggle agent history", + "ai.toolCall": "Tool call", + "ai.toolCalls": "{count} tool calls", + "ai.toolChangedFile": "changed {file}", + "ai.toolChangedFiles": "changed {count} files", + "ai.toolDetailWithSummary": "{status} - {summary}", + "ai.toolStatusCompleted": "completed", + "ai.toolStatusFailed": "failed", + "ai.toolStatusPending": "pending", + "ai.toolStatusRunning": "running", + "ai.toolTerminalOutput": "terminal output", + "ai.toolTerminals": "{count} terminals", + "ai.typeModelName": "Type a model name and press Enter", + "ai.unknownError": "Unknown error", + "ai.unpinSession": "Unpin session", + "ai.useCustomValue": "Use {value}", + "ai.validating": "Validating", + "ai.voiceCouldNotStart": "Voice input could not be started.", + "ai.voiceDisabledMacDev": "Voice input is disabled in macOS dev mode. Test it in a packaged app build.", + "ai.voiceDisabledMacDevShort": "Voice input is disabled in macOS dev mode", + "ai.voiceNotSupported": "Voice input is not supported", + "ai.voiceNotSupportedWebview": "Voice input is not supported in this webview.", + "ai.voiceStoppedUnexpectedly": "Voice input stopped unexpectedly.", + "ai.voiceUnavailableLinuxWebview": "Voice input is unavailable in this Linux webview. Chromium speech recognition may be blocked even when the microphone works.", + "aiCommit.add": "Add provider", + "aiCommit.aiCOMMITBUSY": "Another AI operation is busy. Try again shortly.", + "aiCommit.aiCOMMITCONFIGMISSING": "The imported configuration no longer exists. Reload or choose another provider.", + "aiCommit.aiCOMMITCONFIGREADFAILED": "Could not read the local configuration. Check file permissions.", + "aiCommit.aiCOMMITCONFIGTOOLARGE": "The configuration file exceeds the 1 MiB limit.", + "aiCommit.aiCOMMITDIFFFAILED": "Some selected diffs could not be read. Refresh Git changes and retry.", + "aiCommit.aiCOMMITDRAFTCHANGED": "The draft changed while confirming. Your text has been kept.", + "aiCommit.aiCOMMITEMPTYDIFF": "No text changes are available for generation.", + "aiCommit.aiCOMMITEMPTYRESPONSE": "The provider returned no commit message.", + "aiCommit.aiCOMMITINSECUREENDPOINT": "HTTP is disabled. Enable HTTP only if you trust this provider.", + "aiCommit.aiCOMMITINVALIDCONFIG": "The local configuration is invalid or uses an unsupported format.", + "aiCommit.aiCOMMITINVALIDOPTIONS": "Check the commit generation settings.", + "aiCommit.aiCOMMITINVALIDPROVIDER": "Enter a valid API URL, model and protocol. URLs cannot contain credentials, query strings or fragments.", + "aiCommit.aiCOMMITINVALIDRESPONSE": "The response does not match the selected API protocol.", + "aiCommit.aiCOMMITKEYFAILED": "Windows Credential Manager could not read or save the key.", + "aiCommit.aiCOMMITMISSINGKEY": "No supported API key found. Save a key or update the source configuration.", + "aiCommit.aiCOMMITMULTIPLEREPOSITORIES": "Select files from one repository for AI generation.", + "aiCommit.aiCOMMITNETWORKERROR": "Could not reach the AI provider. Check the address and network.", + "aiCommit.aiCOMMITOUTPUTLIMIT": "The provider exhausted its output budget before completing the message. Try lower reasoning effort or a smaller diff budget.", + "aiCommit.aiCOMMITRESPONSETOOLARGE": "The response exceeds the 2 MiB limit.", + "aiCommit.aiCOMMITSENSITIVEFILE": "The selection includes an environment or credential file. Deselect it before generating.", + "aiCommit.aiCOMMITSTALE": "The selected changes changed. Generate again.", + "aiCommit.aiCOMMITTIMEOUT": "Generation timed out. Try lower reasoning effort or a smaller diff budget.", + "aiCommit.aiCOMMITTOOMANYFILES": "Select at most 1,000 files for AI generation.", + "aiCommit.allowHttp": "Allow unencrypted HTTP for this trusted provider", + "aiCommit.authentication": "Authentication", + "aiCommit.body": "Include a short body when useful", + "aiCommit.cancel": "Cancel generation", + "aiCommit.clearKey": "Remove key", + "aiCommit.concise": "Concise sentence", + "aiCommit.configure": "Configure an AI provider in AI & Commit settings first.", + "aiCommit.conventional": "Conventional Commits", + "aiCommit.custom": "Custom instructions", + "aiCommit.customFormat": "Custom", + "aiCommit.defaultEffort": "Provider default (omit parameter)", + "aiCommit.descriptive": "Descriptive", + "aiCommit.detect": "Reload configurations", + "aiCommit.detected": "Detected {source} configuration", + "aiCommit.diff": "Diff character limit", + "aiCommit.disclosure": "Generation sends the selected files’ changes to this provider. Review the message before committing.", + "aiCommit.draftChanged": "The draft changed while confirming. Your text has been kept.", + "aiCommit.effort": "Reasoning effort", + "aiCommit.enabled": "Enable AI commit messages", + "aiCommit.endpoint": "API URL", + "aiCommit.example": "Example", + "aiCommit.failed": "AI generation failed. Check the provider configuration and try again.", + "aiCommit.format": "Format", + "aiCommit.generating": "Generating…", + "aiCommit.httpError": "The AI provider returned HTTP {status}. Check your key, model and API protocol.", + "aiCommit.imperative": "Imperative subject", + "aiCommit.import": "Import from {source}", + "aiCommit.key": "API key or token", + "aiCommit.keyStored": "A credential is saved", + "aiCommit.language": "Output language", + "aiCommit.legacyGateway": "legacy gateway", + "aiCommit.managed": "Address, model and credentials are read from the local {source} configuration before generation.", + "aiCommit.model": "Model", + "aiCommit.name": "Provider name", + "aiCommit.noKey": "No API credential found (CLI login alone may not provide an API key)", + "aiCommit.notDetected": "No supported Codex or Claude configuration found. You can add a provider manually.", + "aiCommit.profile": "Configuration", + "aiCommit.profiles": "AI providers", + "aiCommit.protocol": "API protocol", + "aiCommit.releaseNote": "Release note", + "aiCommit.remove": "Remove", + "aiCommit.replace": "Replace the current commit message with the generated message?", + "aiCommit.requiresKey": "Provider requires an API key", + "aiCommit.rules": "Commit message generation", + "aiCommit.saveKey": "Save key", + "aiCommit.saved": "Saved", + "aiCommit.settings": "AI & Commit settings", + "aiCommit.stale": "The repository or selected changes changed. Generate again.", + "aiCommit.subject": "Subject maximum length", + "aiCommit.tokenLimitField": "Output token parameter", + "aiHistory.archived": "Archived", + "aiHistory.deleteSession": "Delete session", + "aiHistory.deleteSessionNamed": "Delete {title}", + "aiHistory.empty": "No agent history yet", + "aiHistory.noMatches": "No sessions match \"{query}\"", + "aiHistory.restoreSession": "Restore session", + "aiHistory.restoreSessionNamed": "Restore {title}", + "aiHistory.searchPlaceholder": "Search agent history...", + "aiSettings.acpSession": "ACP Session", + "aiSettings.acpSessionOptionDescription": "Session option exposed by the active ACP agent", + "aiSettings.agentHistory": "Agent History", + "aiSettings.agentHistoryCleared": "Agent history cleared", + "aiSettings.aiAutocomplete": "AI Autocomplete", + "aiSettings.aiAutocompleteDescription": "Enable AI autocomplete while typing", + "aiSettings.apiKey": "API Key", + "aiSettings.apiKeys": "API Keys", + "aiSettings.apiKeysDescription": "Manage provider API keys separately", + "aiSettings.authentication": "Authentication", + "aiSettings.autocomplete": "Autocomplete", + "aiSettings.autocompleteModel": "Autocomplete Model", + "aiSettings.autocompleteModelDescription": "Choose any OpenRouter model for autocomplete", + "aiSettings.autocompleteProvider": "Autocomplete Provider", + "aiSettings.autocompleteProviderDescription": "Use OpenRouter or an OpenAI-compatible endpoint", + "aiSettings.baseUrl": "Base URL", + "aiSettings.checking": "Checking", + "aiSettings.clearAgentHistory": "Clear Agent History", + "aiSettings.clearAgentHistoryDescription": "Permanently delete all agent history", + "aiSettings.clearAll": "Clear All", + "aiSettings.clearing": "Clearing...", + "aiSettings.cloud": "Cloud", + "aiSettings.comingSoon": "Coming Soon", + "aiSettings.configureProviderExtension": "Configure provider extension", + "aiSettings.connectionStatus": "Connection Status", + "aiSettings.custom": "Custom", + "aiSettings.customApiKey": "Custom API Key", + "aiSettings.customAutocompleteApiKeyRemoveFailed": "Failed to remove custom autocomplete API key", + "aiSettings.customAutocompleteApiKeyRemoved": "Custom autocomplete API key removed", + "aiSettings.customAutocompleteApiKeySaveFailed": "Failed to save custom autocomplete API key", + "aiSettings.customAutocompleteApiKeySaved": "Custom autocomplete API key saved", + "aiSettings.customBaseUrl": "Custom Base URL", + "aiSettings.customModel": "Custom Model", + "aiSettings.customModelDescription": "Model name sent to the custom endpoint", + "aiSettings.customProvider": "Custom Provider", + "aiSettings.customProviderApiKeyRemoveFailed": "Failed to remove custom provider API key", + "aiSettings.customProviderApiKeyRemoved": "Custom provider API key removed", + "aiSettings.customProviderApiKeySaveFailed": "Failed to save custom provider API key", + "aiSettings.customProviderApiKeySaved": "Custom provider API key saved", + "aiSettings.customProviderBaseUrlDescription": "OpenAI-compatible endpoint base URL for Lithe Agent", + "aiSettings.endpoint": "Endpoint", + "aiSettings.error": "Error", + "aiSettings.getKey": "Get key", + "aiSettings.loadingModels": "Loading models", + "aiSettings.loadingModelsEllipsis": "Loading models...", + "aiSettings.local": "Local", + "aiSettings.manageKeys": "Manage keys", + "aiSettings.mode": "Mode", + "aiSettings.model": "Model", + "aiSettings.modelDescription": "Choose the model used by Lithe Agent", + "aiSettings.modelList": "Model List", + "aiSettings.modelListEmpty": "Model list is empty. Refresh to try again.", + "aiSettings.modelListLoadFailed": "Could not load model list. Refresh to try again.", + "aiSettings.noModelsLoaded": "No models loaded", + "aiSettings.ollamaApiKeyDescription": "Used for authenticated Ollama endpoints and Ollama Cloud.", + "aiSettings.ollamaApiKeyRemoveFailed": "Failed to remove Ollama API key", + "aiSettings.ollamaApiKeyRemoved": "Ollama API key removed", + "aiSettings.ollamaApiKeySaveFailed": "Failed to save Ollama API key", + "aiSettings.ollamaApiKeySaved": "Ollama API key saved", + "aiSettings.ollamaCloudConnectionFailed": "Could not reach Ollama Cloud. Verify your API key and internet connection.", + "aiSettings.ollamaCloudKey": "Ollama Cloud Key", + "aiSettings.ollamaCloudKeyDescription": "Ollama Cloud requires an API key.", + "aiSettings.ollamaEndpointDescription": "Base URL for Ollama API (local, LAN, or cloud)", + "aiSettings.ollamaLocalConnectionFailed": "Could not connect. Check that Ollama is running at this address.", + "aiSettings.ollamaMode": "Ollama mode", + "aiSettings.ollamaModeDescription": "Run Ollama locally or use Ollama Cloud", + "aiSettings.openAiCompatibleBaseUrlDescription": "OpenAI-compatible endpoint base URL", + "aiSettings.optionalBearerDescription": "Optional bearer token for the custom endpoint", + "aiSettings.provider": "Provider", + "aiSettings.providerDescription": "Choose the provider used by Lithe Agent", + "aiSettings.refreshModelList": "Refresh model list", + "aiSettings.removeOllamaApiKey": "Remove Ollama API key", + "aiSettings.removeSavedApiKey": "Remove saved API key", + "aiSettings.requiresOAuth": "Requires OAuth authentication", + "aiSettings.resetOllamaUrl": "Reset Ollama URL to default", + "aiSettings.resetToDefault": "Reset to default", + "aiSettings.saved": "Saved", + "aiSettings.savedKeyDescription": "Stored securely. Leave blank to keep the existing key.", + "aiSettings.savedKeyPlaceholder": "•••••••• (saved)", + "aiShortcut.findFixBug": "Find and fix a bug", + "aiShortcut.findFixBugContent": "Investigate this bug, identify the root cause, implement the fix, and verify it:", + "aiShortcut.planImplementation": "Plan an implementation", + "aiShortcut.planImplementationContent": "Inspect the relevant code and propose a focused implementation plan for this task:", + "aiShortcut.reviewChanges": "Review current changes", + "aiShortcut.reviewChangesContent": "Review the current workspace changes for bugs, regressions, and missing tests:", + "aiShortcut.writeTests": "Write tests for a change", + "aiShortcut.writeTestsContent": "Inspect the relevant behavior and add focused tests for this change:", + "commandPalette.actions.aiQuickEdit.label": "AI: Quick Edit Selection", + "commandPalette.actions.cliInstall.label": "CLI: Install Terminal Command", + "commandPalette.actions.colorTheme.label": "Preferences: Color Theme", + "commandPalette.actions.databaseConnect.label": "Database: Show Databases", + "commandPalette.actions.developerOpenLitheLog.label": "Developer: Open Lithe Log", + "commandPalette.actions.fileLocalHistory.label": "File: Show Local History", + "commandPalette.actions.fileNew.label": "File: New File", + "commandPalette.actions.fileNewDocument.label": "File: New Document", + "commandPalette.actions.fileOpenProject.label": "File: Open Project", + "commandPalette.actions.fileReopenWithEncoding.label": "File: Reopen with Encoding", + "commandPalette.actions.fileSave.label": "File: Save", + "commandPalette.actions.fileSaveAll.label": "File: Save All", + "commandPalette.actions.fileSaveAs.label": "File: Save As", + "commandPalette.actions.fileSaveWithEncoding.label": "File: Save with Encoding", + "commandPalette.actions.gitAddRemote.label": "Git: Add Remote", + "commandPalette.actions.gitApplyStash.label": "Git: Apply Stash", + "commandPalette.actions.gitBranchManager.label": "Git: Open Branch Manager", + "commandPalette.actions.gitCheckoutBranch.label": "Git: Checkout Branch", + "commandPalette.actions.gitCommit.label": "Git: Commit Changes", + "commandPalette.actions.gitCompareTags.label": "Git: Compare Tags", + "commandPalette.actions.gitCreateBranch.label": "Git: Create Branch", + "commandPalette.actions.gitCreateTag.label": "Git: Create Tag", + "commandPalette.actions.gitDeleteBranch.label": "Git: Delete Branch", + "commandPalette.actions.gitDeleteTag.label": "Git: Delete Tag", + "commandPalette.actions.gitDiscardAll.label": "Git: Discard All Changes", + "commandPalette.actions.gitDropStash.label": "Git: Drop Stash", + "commandPalette.actions.gitFetch.label": "Git: Fetch", + "commandPalette.actions.gitInitializeRepository.label": "Git: Initialize Repository", + "commandPalette.actions.gitManageRemotes.label": "Git: Manage Remotes", + "commandPalette.actions.gitManageTags.label": "Git: Manage Tags", + "commandPalette.actions.gitOpenLog.label": "Git: Open Log", + "commandPalette.actions.gitPopStash.label": "Git: Pop Stash", + "commandPalette.actions.gitPull.label": "Git: Pull", + "commandPalette.actions.gitPush.label": "Git: Push", + "commandPalette.actions.gitRefresh.label": "Git: Refresh Status", + "commandPalette.actions.gitRemoveRemote.label": "Git: Remove Remote", + "commandPalette.actions.gitSelectRepository.label": "Git: Select Repository", + "commandPalette.actions.gitShowBranchDiff.label": "Git: Show Branch Diff", + "commandPalette.actions.gitShowChanges.label": "Git: Show Changes", + "commandPalette.actions.gitStageAll.label": "Git: Stage All Changes", + "commandPalette.actions.gitUnstageAll.label": "Git: Unstage All Changes", + "commandPalette.actions.gitViewStashes.label": "Git: View Stashes", + "commandPalette.actions.gitWorktreeManager.label": "Git: Manage Worktrees", + "commandPalette.actions.githubCheckAuth.label": "GitHub: Check Authentication", + "commandPalette.actions.githubNewIssue.label": "GitHub: New Issue", + "commandPalette.actions.githubNewPullRequest.label": "GitHub: New Pull Request", + "commandPalette.actions.githubRefresh.label": "GitHub: Refresh Current View", + "commandPalette.actions.githubRunWorkflow.label": "GitHub: Run Workflow", + "commandPalette.actions.githubShowActions.label": "GitHub: Show Actions", + "commandPalette.actions.githubShowIssues.label": "GitHub: Show Issues", + "commandPalette.actions.githubShowPullRequests.label": "GitHub: Show Pull Requests", + "commandPalette.actions.goToLine.label": "Go: Go to Line", + "commandPalette.actions.goToSymbolInEditor.label": "Go: Symbol in Editor", + "commandPalette.actions.iconTheme.label": "Preferences: Icon Theme", + "commandPalette.actions.lsp.restartAllServers.label": "Language Server: Restart All Servers", + "commandPalette.actions.lsp.stopAllServers.label": "Language Server: Stop All Servers", + "commandPalette.actions.lspStatus.label": "LSP: Show Status", + "commandPalette.actions.markdownPreview.label": "Markdown: Preview Markdown", + "commandPalette.actions.openOnboarding.label": "Help: Open Onboarding", + "commandPalette.actions.openSettings.label": "Preferences: Open Settings", + "commandPalette.actions.openSettingsJson.label": "Preferences: Open Settings JSON file", + "commandPalette.actions.openSettingsTabAdvanced.label": "Preferences: Open Advanced Settings", + "commandPalette.actions.openSettingsTabAppearance.label": "Preferences: Open Appearance Settings", + "commandPalette.actions.openSettingsTabEditor.label": "Preferences: Open Editor Settings", + "commandPalette.actions.openSettingsTabFileExplorer.label": "Preferences: Open Files Settings", + "commandPalette.actions.openSettingsTabGeneral.label": "Preferences: Open General Settings", + "commandPalette.actions.openSettingsTabGit.label": "Preferences: Open Git Settings", + "commandPalette.actions.openSettingsTabKeyboard.label": "Preferences: Open Keymap Settings", + "commandPalette.actions.openSettingsTabTerminal.label": "Preferences: Open Terminal Settings", + "commandPalette.actions.openUrl.label": "View: Open URL...", + "commandPalette.actions.openWebViewer.label": "View: Open Web Viewer", + "commandPalette.actions.paneCloseEditorGroup.label": "View: Close Editor Group", + "commandPalette.actions.paneCloseOtherEditorGroups.label": "View: Close Other Editor Groups", + "commandPalette.actions.paneMoveEditorNextGroup.label": "View: Move Editor Into Next Group", + "commandPalette.actions.paneMoveEditorPreviousGroup.label": "View: Move Editor Into Previous Group", + "commandPalette.actions.paneResetEditorGroupSizes.label": "View: Reset Editor Group Sizes", + "commandPalette.actions.paneSplitEditorDown.label": "View: Split Editor Down", + "commandPalette.actions.paneSplitEditorRight.label": "View: Split Editor Right", + "commandPalette.actions.paneToggleEditorGroupLock.label": "View: Toggle Editor Group Lock", + "commandPalette.actions.quickOpen.label": "Go: Quick Open", + "commandPalette.actions.reportBug.label": "Help: Report a Bug", + "commandPalette.actions.searchGlobal.label": "Search: Global Search", + "commandPalette.actions.showWhatsNew.label": "Help: What's New", + "commandPalette.actions.tabClose.label": "Tab: Close Tab", + "commandPalette.actions.tabNext.label": "Tab: Next Tab", + "commandPalette.actions.tabPrevious.label": "Tab: Previous Tab", + "commandPalette.actions.tabReopen.label": "Tab: Reopen Closed Tab", + "commandPalette.actions.terminalFind.label": "Terminal: Find", + "commandPalette.actions.terminalNew.label": "Terminal: New Terminal", + "commandPalette.actions.terminalResetZoom.label": "Terminal: Reset Zoom", + "commandPalette.actions.terminalSplitDown.label": "Terminal: Split Down", + "commandPalette.actions.terminalSplitRight.label": "Terminal: Split Right", + "commandPalette.actions.terminalZoomIn.label": "Terminal: Zoom In", + "commandPalette.actions.terminalZoomOut.label": "Terminal: Zoom Out", + "commandPalette.actions.toggleAiChatFeature.disableLabel": "Features: Disable AI Chat", + "commandPalette.actions.toggleAiChatFeature.enableLabel": "Features: Enable AI Chat", + "commandPalette.actions.toggleAiChatView.disableLabel": "View: Hide AI Chat", + "commandPalette.actions.toggleAiChatView.enableLabel": "View: Show AI Chat", + "commandPalette.actions.toggleAiCompletion.disableLabel": "AI: Disable AI Completion", + "commandPalette.actions.toggleAiCompletion.enableLabel": "AI: Enable AI Completion", + "commandPalette.actions.toggleAutoCompletion.disableLabel": "Language: Disable Auto Completion", + "commandPalette.actions.toggleAutoCompletion.enableLabel": "Language: Enable Auto Completion", + "commandPalette.actions.toggleAutoDetectLanguage.disableLabel": "Language: Disable Auto-detect Language", + "commandPalette.actions.toggleAutoDetectLanguage.enableLabel": "Language: Enable Auto-detect Language", + "commandPalette.actions.toggleAutoSave.disableLabel": "General: Disable Auto Save", + "commandPalette.actions.toggleAutoSave.enableLabel": "General: Enable Auto Save", + "commandPalette.actions.toggleBottomPane.disableLabel": "View: Hide Bottom Pane", + "commandPalette.actions.toggleBottomPane.enableLabel": "View: Show Bottom Pane", + "commandPalette.actions.toggleBreadcrumbs.disableLabel": "Features: Disable Breadcrumbs", + "commandPalette.actions.toggleBreadcrumbs.enableLabel": "Features: Enable Breadcrumbs", + "commandPalette.actions.toggleCodeLens.disableLabel": "Language: Disable Code Lens", + "commandPalette.actions.toggleCodeLens.enableLabel": "Language: Enable Code Lens", + "commandPalette.actions.toggleCommandsPersistence.disableLabel": "Features: Disable Persistent Commands", + "commandPalette.actions.toggleCommandsPersistence.enableLabel": "Features: Enable Persistent Commands", + "commandPalette.actions.toggleCompactMenuBar.disableLabel": "View: Use Full Menu Bar", + "commandPalette.actions.toggleCompactMenuBar.enableLabel": "View: Use Compact Menu Bar", + "commandPalette.actions.toggleDebuggerFeature.disableLabel": "Features: Disable Debugger", + "commandPalette.actions.toggleDebuggerFeature.enableLabel": "Features: Enable Debugger", + "commandPalette.actions.toggleDiagnostics.disableLabel": "Features: Disable Diagnostics", + "commandPalette.actions.toggleDiagnostics.enableLabel": "Features: Enable Diagnostics", + "commandPalette.actions.toggleDiagnosticsPanel.label": "View: Show Diagnostics", + "commandPalette.actions.toggleFindView.label": "View: Find", + "commandPalette.actions.toggleFormatOnSave.disableLabel": "Language: Disable Format on Save", + "commandPalette.actions.toggleFormatOnSave.enableLabel": "Language: Enable Format on Save", + "commandPalette.actions.toggleGitFeature.disableLabel": "Features: Disable Git", + "commandPalette.actions.toggleGitFeature.enableLabel": "Features: Enable Git", + "commandPalette.actions.toggleInlayHints.disableLabel": "Language: Disable Inlay Hints", + "commandPalette.actions.toggleInlayHints.enableLabel": "Language: Enable Inlay Hints", + "commandPalette.actions.toggleLineNumbers.disableLabel": "Editor: Hide Line Numbers", + "commandPalette.actions.toggleLineNumbers.enableLabel": "Editor: Show Line Numbers", + "commandPalette.actions.toggleNativeMenuBar.disableLabel": "View: Hide Native Menu Bar", + "commandPalette.actions.toggleNativeMenuBar.enableLabel": "View: Show Native Menu Bar", + "commandPalette.actions.toggleOutlineFeature.disableLabel": "Features: Disable Outline", + "commandPalette.actions.toggleOutlineFeature.enableLabel": "Features: Enable Outline", + "commandPalette.actions.toggleParameterHints.disableLabel": "Language: Disable Parameter Hints", + "commandPalette.actions.toggleParameterHints.enableLabel": "Language: Enable Parameter Hints", + "commandPalette.actions.toggleRelativeLineNumbers.disableLabel": "Editor: Disable Relative Line Numbers", + "commandPalette.actions.toggleRelativeLineNumbers.enableLabel": "Editor: Enable Relative Line Numbers", + "commandPalette.actions.toggleRemoteFeature.disableLabel": "Features: Disable Remote", + "commandPalette.actions.toggleRemoteFeature.enableLabel": "Features: Enable Remote", + "commandPalette.actions.toggleRun.disableLabel": "View: Hide Run", + "commandPalette.actions.toggleRun.enableLabel": "View: Show Run", + "commandPalette.actions.toggleSearchFeature.disableLabel": "Features: Disable Search", + "commandPalette.actions.toggleSearchFeature.enableLabel": "Features: Enable Search", + "commandPalette.actions.toggleSemanticTokens.disableLabel": "Language: Disable Semantic Tokens", + "commandPalette.actions.toggleSemanticTokens.enableLabel": "Language: Enable Semantic Tokens", + "commandPalette.actions.toggleShowMinimap.disableLabel": "Editor: Hide Minimap", + "commandPalette.actions.toggleShowMinimap.enableLabel": "Editor: Show Minimap", + "commandPalette.actions.toggleSidebar.disableLabel": "View: Hide Secondary Sidebar", + "commandPalette.actions.toggleSidebar.enableLabel": "View: Show Secondary Sidebar", + "commandPalette.actions.toggleTerminal.disableLabel": "View: Hide Terminal", + "commandPalette.actions.toggleTerminal.enableLabel": "View: Show Terminal", + "commandPalette.actions.toggleTerminalFeature.disableLabel": "Features: Disable Terminal", + "commandPalette.actions.toggleTerminalFeature.enableLabel": "Features: Enable Terminal", + "commandPalette.actions.toggleVimMode.label": "Vim Mode: Toggle", + "commandPalette.actions.toggleWordWrap.disableLabel": "Editor: Disable Word Wrap", + "commandPalette.actions.toggleWordWrap.enableLabel": "Editor: Enable Word Wrap", + "commandPalette.actions.viewResetZoom.label": "Editor: Reset Zoom", + "commandPalette.actions.viewShowFiles.label": "View: Show Files", + "commandPalette.actions.viewShowGit.label": "View: Show Git", + "commandPalette.actions.viewShowOutline.label": "View: Show Outline", + "commandPalette.actions.viewZoomIn.label": "Editor: Zoom In", + "commandPalette.actions.viewZoomOut.label": "Editor: Zoom Out", + "commandPalette.actions.vimInsertMode.label": "Vim: Enter Insert Mode", + "commandPalette.actions.vimNormalMode.label": "Vim: Enter Normal Mode", + "commandPalette.actions.vimVisualMode.label": "Vim: Enter Visual Mode", + "commandPalette.actions.windowEnterFullscreen.label": "Window: Enter Fullscreen", + "commandPalette.actions.windowExitFullscreen.label": "Window: Exit Fullscreen", + "commandPalette.actions.windowMaximize.label": "Window: Maximize", + "commandPalette.actions.windowMinimize.label": "Window: Minimize", + "commandPalette.actions.windowRestore.label": "Window: Restore", + "commandPalette.backToCommands": "Back to commands", + "commandPalette.categories.AI": "AI", + "commandPalette.categories.Advanced": "Advanced", + "commandPalette.categories.CLI": "CLI", + "commandPalette.categories.Developer": "Developer", + "commandPalette.categories.Editor": "Editor", + "commandPalette.categories.Features": "Features", + "commandPalette.categories.File": "File", + "commandPalette.categories.Generate": "Generate", + "commandPalette.categories.Git": "Git", + "commandPalette.categories.GitHub": "GitHub", + "commandPalette.categories.LSP": "LSP", + "commandPalette.categories.Language": "Language", + "commandPalette.categories.Markdown": "Markdown", + "commandPalette.categories.Navigation": "Navigation", + "commandPalette.categories.Settings": "Settings", + "commandPalette.categories.Terminal": "Terminal", + "commandPalette.categories.Theme": "Theme", + "commandPalette.categories.View": "View", + "commandPalette.categories.Vim": "Vim", + "commandPalette.categories.Window": "Window", + "commandPalette.categories.languageServer": "Language Server", + "commandPalette.clearPersistedActions": "Clear persisted actions", + "commandPalette.close": "Close command palette", + "commandPalette.current": "Current", + "commandPalette.failedToImportTheme": "Failed to import theme", + "commandPalette.importedTheme": "Imported {name}", + "commandPalette.importedThemeVariants": "Imported {count} theme variants", + "commandPalette.noCommands": "No commands found", + "commandPalette.noIconThemes": "No icon themes found", + "commandPalette.noThemes": "No themes found", + "commandPalette.openAppearanceSettings": "Open appearance settings", + "commandPalette.placeholder": "Type a command...", + "commandPalette.searchIconThemes": "Search icon themes...", + "commandPalette.searchThemes": "Search themes...", + "commandPalette.title": "Command palette", + "commandPalette.uploadTheme": "Upload theme", + "csv.auto": "Auto", + "csv.changeDelimiter": "Change delimiter", + "csv.comma": "Comma", + "csv.copyAsCsv": "Copy as CSV", + "csv.copyAsJson": "Copy as JSON", + "csv.delimiter": "Delimiter", + "csv.headerOff": "Header Off", + "csv.headerOn": "Header On", + "csv.pipe": "Pipe", + "csv.rowsColumns": "{rows} rows • {columns} columns", + "csv.semicolon": "Semicolon", + "csv.tab": "Tab", + "csv.toggleHeaderRow": "Toggle header row", + "customTheme.baseTheme": "Base theme", + "customTheme.createTitle": "Create Theme", + "customTheme.defaultName": "My Lithe Theme", + "customTheme.description": "Start from an installed theme, then edit the generated JSON before saving or installing it.", + "customTheme.filterAllFiles": "All files", + "customTheme.filterLitheTheme": "Lithe theme", + "customTheme.generateFailed": "Failed to generate the theme file.", + "customTheme.id": "ID", + "customTheme.install": "Install Theme", + "customTheme.installFailed": "Failed to install the theme.", + "customTheme.installed": "Installed {theme}", + "customTheme.installedVariants": "Installed {count} theme variants", + "customTheme.installing": "Installing...", + "customTheme.jsonSaved": "Theme JSON saved", + "customTheme.name": "Name", + "customTheme.saveJson": "Save JSON", + "customTheme.saveJsonFailed": "Failed to save theme JSON", + "customTheme.themeJson": "Theme JSON", + "database.connect": "Connect", + "database.host": "Host", + "database.optional": "Optional", + "database.password": "Password", + "database.port": "Port", + "database.username": "Username", + "debugger.adapterOutputAppearsHere": "Adapter output appears here.", + "debugger.addExpression": "Add expression", + "debugger.addWatch": "Add watch", + "debugger.applyCodeChanges": "Apply code changes", + "debugger.applyCodeChangesHelp": "Save, compile and apply changes to this Java service", + "debugger.applyingCodeChanges": "Applying…", + "debugger.breakpoints": "Breakpoints", + "debugger.breakpointsEmpty": "Click a gutter line or toggle the current line.", + "debugger.clearBreakpoints": "Clear breakpoints", + "debugger.clearConsole": "Clear console", + "debugger.clearDebugConsole": "Clear debug console", + "debugger.clearWatchExpressions": "Clear watch expressions", + "debugger.command": "Command", + "debugger.commandToRun": "Command to run", + "debugger.configuration": "Configuration", + "debugger.console": "Console", + "debugger.continue": "Continue", + "debugger.continueDebugging": "Continue debugging", + "debugger.debugConfiguration": "Debug configuration", + "debugger.disableBreakpoint": "Disable breakpoint", + "debugger.empty": "Empty", + "debugger.enableBreakpoint": "Enable breakpoint", + "debugger.evaluating": "Evaluating...", + "debugger.hideRunAndDebug": "Hide Run and Debug", + "debugger.into": "Into", + "debugger.lineNumber": "Line {line}", + "debugger.loadingVariable": "Loading variable", + "debugger.noCommandAvailable": "No command available", + "debugger.noLaunchJsonFound": "No launch.json found", + "debugger.notEvaluated": "Not evaluated", + "debugger.openProjectToLoadLaunchJson": "Open a project to load launch.json", + "debugger.out": "Out", + "debugger.over": "Over", + "debugger.pause": "Pause", + "debugger.pauseDebugging": "Pause debugging", + "debugger.paused": "Paused", + "debugger.refreshWatches": "Refresh watches", + "debugger.removeBreakpoint": "Remove breakpoint", + "debugger.removeWatch": "Remove watch", + "debugger.restartService": "Restart service", + "debugger.runAndDebug": "Run and Debug", + "debugger.showRunAndDebug": "Show Run and Debug", + "debugger.stack": "Stack", + "debugger.stackEmpty": "Start a session to see frames.", + "debugger.start": "Start", + "debugger.statusExited": "Exited", + "debugger.statusFailed": "Failed", + "debugger.statusIdle": "Idle", + "debugger.statusPaused": "Paused", + "debugger.statusRunning": "Running", + "debugger.statusStopped": "Stopped", + "debugger.stepInto": "Step into", + "debugger.stepOut": "Step out", + "debugger.stepOver": "Step over", + "debugger.stop": "Stop", + "debugger.threads": "Threads", + "debugger.threadsAndVariables": "Threads & Variables", + "debugger.threadsEmpty": "Pause the process to see threads.", + "debugger.toggleCurrentLineBreakpoint": "Toggle breakpoint on current line", + "debugger.toggleRunAndDebug": "Toggle Run and Debug", + "debugger.variables": "Variables", + "debugger.variablesEmpty": "Pause on a frame to inspect values.", + "debugger.watch": "Watch", + "debugger.watchEmpty": "Add expressions to inspect while paused.", + "diagnostics.all": "All Diagnostics", + "diagnostics.clearSourceFilter": "Clear Source Filter ({source})", + "diagnostics.clearSourceFilterSimple": "Clear Source Filter", + "diagnostics.closePane": "Close problems pane", + "diagnostics.copyDetails": "Copy Full Details", + "diagnostics.copyLocation": "Copy Location", + "diagnostics.copyMessage": "Copy Message", + "diagnostics.detailsCopied": "Diagnostic details copied", + "diagnostics.disableWrap": "Disable Message Wrap", + "diagnostics.editorWidth": "Editor Width", + "diagnostics.empty": "No problems detected", + "diagnostics.enableWrap": "Enable Message Wrap", + "diagnostics.errors": "Errors", + "diagnostics.exitFullScreen": "Exit Full Screen", + "diagnostics.filesAria": "Diagnostic files", + "diagnostics.filter": "Filter problems", + "diagnostics.filterBySource": "Filter by Source: {source}", + "diagnostics.fullScreen": "Full Screen", + "diagnostics.fullWidth": "Full Width", + "diagnostics.goToProblem": "Go to Problem", + "diagnostics.groupByFile": "Group by: File", + "diagnostics.groupByNone": "Group by: None", + "diagnostics.groupBySeverity": "Group by: Severity", + "diagnostics.hideFiles": "Hide files", + "diagnostics.info": "Info", + "diagnostics.loadingFixes": "Loading quick fixes...", + "diagnostics.locationCopied": "Diagnostic location copied", + "diagnostics.messageCopied": "Diagnostic message copied", + "diagnostics.noFixes": "No quick fixes available", + "diagnostics.noMatch": "No problems match the current filters", + "diagnostics.onlyCurrentFile": "Only Current File", + "diagnostics.problemCount": "{count} problems", + "diagnostics.problemCountOne": "{count} problem", + "diagnostics.resetAllFilters": "Reset All Filters", + "diagnostics.resetFilters": "Reset filters", + "diagnostics.search": "Search problems", + "diagnostics.severityCount": "{label} ({visible}/{total})", + "diagnostics.showFiles": "Show files", + "diagnostics.sortByFile": "Sort by: File", + "diagnostics.sortByPosition": "Sort by: Position", + "diagnostics.sortBySeverity": "Sort by: Severity", + "diagnostics.warnings": "Warnings", + "docker.actionFailed": "Action failed", + "docker.actionsFor": "Actions for {name}", + "docker.all": "All", + "docker.automated": "automated", + "docker.build": "Build", + "docker.buildAndRunLocalImages": "Build and run local images", + "docker.buildArgs": "Build Args", + "docker.buildDockerImage": "Build Docker Image", + "docker.buildPresetName": "Build preset name", + "docker.buildPresets": "Build presets", + "docker.cleanupCompleted": "Docker {target} cleanup completed.", + "docker.cliUnavailable": "Docker CLI unavailable", + "docker.cliUnavailableDescription": "Install Docker Desktop or add the Docker CLI to PATH, then try again.", + "docker.command": "Command", + "docker.compose": "Compose", + "docker.composePresetCompleted": "Docker Compose preset {name} completed.", + "docker.composePresetName": "Compose preset name", + "docker.composePresets": "Compose presets", + "docker.composeProject": "Compose project", + "docker.composeProjectActions": "Compose project actions", + "docker.composeUnavailable": "Compose unavailable", + "docker.composeUnavailableDescription": "Compose resources could not be loaded.", + "docker.confirmDockerCleanup": "Confirm Docker Cleanup", + "docker.containerActions": "Container actions", + "docker.containerDestinationPath": "Container destination path", + "docker.containerName": "Container Name", + "docker.containers": "Containers", + "docker.contextPath": "Context Path", + "docker.copiedToContainer": "Copied {source} to {target}.", + "docker.copiedToHost": "Copied {source} to {target}.", + "docker.copy": "Copy", + "docker.copyFileToHost": "Copy {name} to host", + "docker.copyFromContainer": "Copy From Container", + "docker.copyIn": "Copy In", + "docker.copyToContainer": "Copy To Container", + "docker.copyToHost": "Copy to host", + "docker.copyToHostPath": "Copy to host path", + "docker.debug": "Debug", + "docker.debugCommand": "Debug command", + "docker.debugInContainer": "Debug In Container", + "docker.debugPresetName": "Debug preset name", + "docker.debugPresetRequiresRunningContainer": "Docker debug presets require a running container.", + "docker.debugPresets": "Debug presets", + "docker.delete": "Delete", + "docker.deleteEnvFile": "Delete Env File", + "docker.details": "Details", + "docker.devContainers": "Dev Containers", + "docker.directory": "Directory", + "docker.dismiss": "Dismiss", + "docker.dockerHub": "Docker Hub", + "docker.envFile": "env file", + "docker.envFilePath": "Env file path", + "docker.envFiles": "Env files", + "docker.environment": "Environment", + "docker.files": "Files", + "docker.hostFileOrFolderPath": "Host file or folder path", + "docker.image": "Image", + "docker.imageActions": "Image actions", + "docker.imageBuildCompleted": "Docker image build completed.", + "docker.imageExamplePlaceholder": "Image, for example nginx:latest", + "docker.images": "Images", + "docker.launchConfigs": "Launch configs", + "docker.loadingCompose": "Loading Compose", + "docker.loadingFiles": "Loading files...", + "docker.loadingProjectConfig": "Loading project config", + "docker.loadingResources": "Loading resources", + "docker.logFilterAll": "All", + "docker.logFilterErr": "Err", + "docker.logFilterErrors": "Errors", + "docker.login": "Login", + "docker.logs": "Logs", + "docker.logsStopped": "Logs stopped", + "docker.networks": "Networks", + "docker.noComposeFiles": "No Compose files found.", + "docker.noComposeServicesFound": "No Compose services found", + "docker.noEnvFilesOrPresets": "No env files or presets found.", + "docker.noFilesFound": "No files found.", + "docker.noMatchingComposeServices": "No matching Compose services", + "docker.noMatchingContainers": "No matching containers", + "docker.noMatchingImages": "No matching images", + "docker.noMatchingLogLines": "No matching log lines.", + "docker.noMatchingNetworks": "No matching networks", + "docker.noMatchingVolumes": "No matching volumes", + "docker.notRunning": "Docker is not running", + "docker.notRunningDescription": "Start Docker Desktop, then refresh this view.", + "docker.official": "official", + "docker.offlineProjectFilesAvailable": "Docker is offline. Project files and presets are still available.", + "docker.open": "Open", + "docker.openEnvFile": "Open Env File", + "docker.openServiceUrl": "Open service URL", + "docker.openShell": "Open shell", + "docker.openWorkspaceInspectCompose": "Open a workspace to inspect Compose files.", + "docker.openWorkspaceManagePresets": "Open a workspace to manage Docker presets.", + "docker.opened": "Opened {name}.", + "docker.opening": "Opening...", + "docker.password": "Password", + "docker.pause": "Pause", + "docker.ports": "Ports", + "docker.project": "Project", + "docker.projectActionFailed": "Project action failed", + "docker.projectActions": "Project actions", + "docker.prune": "Prune", + "docker.pruneTarget": "Prune {target}", + "docker.pruneTargetWithVolumes": "{target} and volumes", + "docker.pull": "Pull", + "docker.pulledImage": "Pulled {image}.", + "docker.push": "Push", + "docker.pushedImage": "Pushed {image}.", + "docker.rebuild": "Rebuild", + "docker.refresh": "Refresh", + "docker.refreshFiles": "Refresh files", + "docker.registry": "Registry", + "docker.registryActionFailed": "Registry action failed", + "docker.registryImage": "Registry image", + "docker.registryLogin": "Registry login", + "docker.registryLoginCompleted": "Docker registry login completed.", + "docker.registryOfflineNotice": "Search and login remain available. Start Docker to pull, push, or tag images.", + "docker.registryOptional": "Registry (optional)", + "docker.remove": "Remove", + "docker.removed": "Removed {name}.", + "docker.resources": "Resources", + "docker.restart": "Restart", + "docker.retry": "Retry", + "docker.run": "Run", + "docker.runDockerImage": "Run Docker Image", + "docker.runPresetName": "Run preset name", + "docker.runPresets": "Run presets", + "docker.running": "Running", + "docker.runningEllipsis": "Running...", + "docker.saveBuildPreset": "Save Build Preset", + "docker.saveComposePreset": "Save Compose Preset", + "docker.saveDebug": "Save Debug", + "docker.saveDebugPreset": "Save Debug Preset", + "docker.savePreset": "Save Preset", + "docker.saveRunPreset": "Save Run Preset", + "docker.searchDocker": "Search Docker", + "docker.searchDockerHub": "Search Docker Hub", + "docker.searchDockerHubToFindImages": "Search Docker Hub to find images", + "docker.searchDockerResources": "Search Docker resources", + "docker.searchImages": "Search images", + "docker.searchLogs": "Search logs", + "docker.section.cleanup": "Cleanup", + "docker.section.compose": "Compose", + "docker.section.containers": "Containers", + "docker.section.images": "Images", + "docker.section.networks": "Networks", + "docker.section.project": "Project", + "docker.section.registry": "Registry", + "docker.section.volumes": "Volumes", + "docker.selectRunningContainerBeforeDebugPreset": "Select a running container before starting a Docker debug preset.", + "docker.serviceActions": "Service actions", + "docker.servicesCount": "{count} services", + "docker.starsCount": "{count} stars", + "docker.start": "Start", + "docker.startDockerBeforeBuilding": "Start Docker before building this image. You can still save these values as a preset.", + "docker.startDockerBeforeRunning": "Start Docker before running this image. You can still save these values as a preset.", + "docker.startWithEnvFiles": "Start with env files", + "docker.startedImage": "Started {image}.", + "docker.stop": "Stop", + "docker.stopProject": "Stop project", + "docker.stopped": "Stopped", + "docker.streamingLogs": "Streaming logs", + "docker.system": "System", + "docker.tag": "Tag", + "docker.taggedImage": "Tagged {source} as {target}.", + "docker.targetTag": "Target tag", + "docker.technicalDetails": "Technical details", + "docker.typeDeleteToRemove": "Type delete to remove {path}", + "docker.typePruneToClean": "Type prune to clean up Docker {target}", + "docker.unavailable": "Docker unavailable", + "docker.unavailableDescription": "Docker could not be reached on this machine.", + "docker.unpause": "Unpause", + "docker.up": "Up", + "docker.usePreset": "Use Preset", + "docker.username": "Username", + "docker.variable": "variable", + "docker.variables": "variables", + "docker.viewOptions": "View options", + "docker.volumes": "Volumes", + "docker.waitingForLogs": "Waiting for logs.", + "docker.workingDirectory": "Working directory", + "docker.workspace": "Workspace", + "editor.actions": "Editor actions", + "editor.addSelectionToNextMatch": "Add Selection to Next Match", + "editor.aiInlineEdit": "AI inline edit", + "editor.autoSaveFailed": "Could not auto-save \"{name}\". Your changes are still open.", + "editor.contentNotLoaded": "File \"{name}\" has not finished loading. Load or retry the tab before saving or editing.", + "editor.duplicateLine": "Duplicate Line", + "editor.enableLargeFileServices": "Enable anyway", + "editor.encodingDiscard": "Discard Changes", + "editor.encodingFailed": "Could not read the file with the selected encoding.", + "editor.encodingReopenDirty": "Save this file before reopening it with another encoding?", + "editor.encodingReopenTitle": "Reopen with Encoding", + "editor.externalConflict": "This file changed outside Lithe while you had unsaved edits.", + "editor.externalDeleted": "This file was deleted outside Lithe. Your editor content is preserved.", + "editor.externalReadFailed": "Could not check disk changes. Check file access, then refocus the window to retry.", + "editor.find": "Find", + "editor.findAllReferences": "Find All References", + "editor.findInFile": "Find in file", + "editor.formatDocument": "Format Document", + "editor.formatSelection": "Format Selection", + "editor.goToDefinition": "Go to Definition", + "editor.goToLine": "Go to Line", + "editor.goToLineColumn": "Go to line and column", + "editor.goToTypeDefinition": "Go to Type Definition", + "editor.indent": "Indent", + "editor.inlineGitBlame": "Inline Git Blame", + "editor.keepEditorVersion": "Keep Editor", + "editor.largeFileServicesDisabled": "Language intelligence, Git blame, Code Lens, and semantic highlighting are off for this large file.", + "editor.loadDiskVersion": "Load Disk Version", + "editor.markdownPreviewView": "Preview", + "editor.markdownSourceView": "Editor", + "editor.markdownSplitView": "Editor and Preview", + "editor.markdownViewModes": "Markdown view", + "editor.minimap": "Minimap", + "editor.moveLineDown": "Move Line Down", + "editor.moveLineUp": "Move Line Up", + "editor.noLanguages": "No languages found", + "editor.openHtmlInBrowser": "Open HTML in browser", + "editor.openHtmlInBrowserFailed": "Failed to open HTML in browser", + "editor.outdent": "Outdent", + "editor.plainText": "Plain Text", + "editor.preferences": "Editor preferences", + "editor.preview": "Preview", + "editor.quickFix": "Quick Fix...", + "editor.recreateFile": "Allow Recreating File", + "editor.renameSymbol": "Rename Symbol", + "editor.reopenWithEncoding": "Reopen with Encoding", + "editor.restoreLoadFailed": "Could not load this file.", + "editor.saveFailed": "Could not save \"{name}\". Check that the file is writable and try again.", + "editor.saveWithEncoding": "Save with Encoding", + "editor.selectAll": "Select All", + "editor.selectAllOccurrences": "Select All Occurrences", + "editor.selectLanguage": "Select language mode", + "editor.showHover": "Show Hover", + "editor.symbolPath": "Symbol path", + "editor.title": "Editor", + "editor.toggleBookmark": "Toggle Bookmark", + "editor.toggleCase": "Toggle Case", + "editor.toggleComment": "Toggle Comment", + "editor.triggerSuggest": "Trigger Suggest", + "editor.unknownFile": "Unknown", + "editorCommands.appliedAction": "Applied: {title}", + "editorCommands.chooseQuickFixPrompt": "Choose a quick fix:", + "editorCommands.documentAlreadyFormatted": "Document is already formatted.", + "editorCommands.documentFormatted": "Document formatted.", + "editorCommands.formattingFailed": "Formatting failed.", + "editorCommands.noDiagnosticAtCursor": "No diagnostic at the cursor.", + "editorCommands.noEditableFileForQuickFixes": "No editable file for quick fixes.", + "editorCommands.noEditableFileToFormat": "No editable file to format.", + "editorCommands.noFoldableEditor": "No foldable editor is active.", + "editorCommands.noFormatterConfigured": "No formatter configured for this file type.", + "editorCommands.noQuickFixes": "No quick fixes available.", + "editorCommands.preferredAction": "{title} (preferred)", + "editorCommands.selectTextToFormat": "Select text to format.", + "editorCommands.selectionAlreadyFormatted": "Selection is already formatted.", + "editorCommands.selectionFormatted": "Selection formatted.", + "editorCommands.selectionFormattingFailed": "Selection formatting failed.", + "editorCommands.unableToApplyAction": "Unable to apply action: {title}", + "extensions.activate": "Activate", + "extensions.activateAndUse": "Activate and use", + "extensions.activateFailed": "Failed to activate {name}: {message}", + "extensions.activated": "{name} activated", + "extensions.active": "Active", + "extensions.add": "Add", + "extensions.addedSuccessfully": "{name} added successfully", + "extensions.agentFallbackDescription": "ACP-compatible coding agent", + "extensions.agents": "Agents", + "extensions.ai": "AI", + "extensions.all": "All", + "extensions.alreadyInstalled": "{name} is already installed", + "extensions.availableCount": "{count} available", + "extensions.builtIn": "Built-in", + "extensions.byPublisher": "By {publisher}", + "extensions.cannotInstallAutomatically": "{name} cannot be installed automatically", + "extensions.category.agent": "Agent", + "extensions.category.ai": "AI", + "extensions.category.iconTheme": "Icon Theme", + "extensions.category.integration": "Integration", + "extensions.category.language": "Language", + "extensions.category.skill": "Skill", + "extensions.category.theme": "Theme", + "extensions.contributions": "Contributions", + "extensions.crashed": "{name} crashed", + "extensions.current": "Current", + "extensions.currentName": "Current: {name}", + "extensions.deactivate": "Deactivate", + "extensions.deactivateFailed": "Failed to deactivate {name}: {message}", + "extensions.deactivated": "{name} deactivated", + "extensions.disabled": "Disabled", + "extensions.extensionError": "Extension error", + "extensions.globalInstallStillDetected": "A global installation is still detected on your PATH.", + "extensions.iconThemeFallbackDescription": "{name} icon theme", + "extensions.iconThemes": "Icon Themes", + "extensions.importPackage": "Import plugin package", + "extensions.install": "Install", + "extensions.installFailed": "Failed to install extension: {message}", + "extensions.installFailedGeneric": "Installation failed", + "extensions.installFailedWithMessage": "Failed to install {name}: {message}", + "extensions.installPrompt": "{name} extension not installed. Install it to enable language support?", + "extensions.installSuccess": "{name} installed successfully!", + "extensions.installed": "Installed", + "extensions.installedCount": "{count} installed", + "extensions.installedSuccessfully": "{name} installed successfully", + "extensions.installing": "Installing {name}...", + "extensions.integrations": "Integrations", + "extensions.languages": "Languages", + "extensions.loadingAgents": "Loading agents", + "extensions.loadingExtension": "Loading extension", + "extensions.loadingSkills": "Loading skills", + "extensions.local": "Local", + "extensions.managedInstallRemoved": "{name} managed install removed", + "extensions.marketplace": "Marketplace", + "extensions.newSkill": "New Skill", + "extensions.noneFound": "No extensions found.", + "extensions.noneSelected": "No extension selected.", + "extensions.notFound": "Extension \"{id}\" not found", + "extensions.packageTooLarge": "Plugin package exceeds 256 KB", + "extensions.reinstall": "Reinstall", + "extensions.remove": "Remove", + "extensions.removedSuccessfully": "{name} removed successfully", + "extensions.resetFailed": "Failed to reset {name}: {message}", + "extensions.resetToMarketplaceSuccess": "{name} reset to marketplace version", + "extensions.resetToMarketplaceVersion": "Reset to Marketplace Version", + "extensions.retryLoading": "Retry loading {name}", + "extensions.reusableSkillDescription": "Reusable AI chat instructions", + "extensions.searchPlaceholder": "Search extensions", + "extensions.selected": "{name} selected", + "extensions.skills": "Skills", + "extensions.themeFallbackDescription": "{category} theme", + "extensions.themes": "Themes", + "extensions.title": "Extensions", + "extensions.unavailable": "Unavailable", + "extensions.unexpectedError": "An unexpected error occurred", + "extensions.uninstall": "Uninstall", + "extensions.uninstallFailed": "Failed to uninstall {name}: {message}", + "extensions.uninstalledSuccessfully": "{name} uninstalled successfully", + "extensions.unknownError": "Unknown error", + "extensions.update": "Update", + "extensions.updateActionFailed": "Failed to update {name}: {message}", + "extensions.updateFailed": "Failed to update {name}: {message}", + "extensions.updatedSuccessfully": "{name} updated successfully", + "extensions.updatedWithLocalOverride": "{name} updated, local override kept", + "extensions.updatesCount": "{count} updates", + "extensions.use": "Use", + "extensions.useFailed": "Failed to use {name}: {message}", + "extensions.useName": "Use {name}", + "extensions.you": "You", + "fileExplorer.ariaLabel": "File Explorer", + "fileExplorer.cannotCreateFile": "Cannot Create File", + "fileExplorer.cannotDetermineCreateLocation": "Cannot determine where to create the file.", + "fileExplorer.deleteFile": "Delete File", + "fileExplorer.deleteFileMessage": "Are you sure you want to delete the file \"{name}\"? This action cannot be undone.", + "fileExplorer.deleteFolder": "Delete Folder", + "fileExplorer.deleteFolderMessage": "Are you sure you want to delete the folder \"{name}\" and all its contents? This action cannot be undone.", + "fileExplorer.deleting": "Deleting...", + "fileExplorer.fileAlreadyExists": "File Already Exists", + "fileExplorer.fileAlreadyExistsMessage": "A file with this name already exists.", + "fileExplorer.fileIcons": "File Icons", + "fileExplorer.folderAlreadyExists": "Folder Already Exists", + "fileExplorer.folderAlreadyExistsMessage": "A folder with this name already exists.", + "fileExplorer.folderIsEmpty": "Folder is empty", + "fileExplorer.gitStatusDecorations": "Git Status Decorations", + "fileExplorer.gitignoredFiles": "Gitignored Files", + "fileExplorer.indentGuides": "Indent Guides", + "fileExplorer.indentation": "Indentation", + "fileExplorer.indentationCompact": "Compact", + "fileExplorer.indentationDefault": "Default", + "fileExplorer.indentationSpacious": "Spacious", + "fileExplorer.indentationWide": "Wide", + "fileExplorer.moveFailed": "Move Failed", + "fileExplorer.noFolderOpen": "No folder open", + "fileExplorer.noMatchingFiles": "No matching files", + "fileExplorer.ok": "OK", + "fileExplorer.openAllFilesMessage": "{count} files will be opened in tabs. Continue?", + "fileExplorer.opening": "Opening...", + "fileExplorer.preferences": "File explorer preferences", + "fileExplorer.searchFiles": "Search files", + "fileExplorer.searchingFiles": "Searching files", + "fileExplorer.visibility": "Visibility", + "fileNavigator.fileTree": "File tree", + "fileNavigator.files": "Files", + "fileNavigator.flatList": "Flat list", + "fileNavigator.noFilesMatch": "No files match", + "fileNavigator.resizeAria": "Resize file navigator", + "fileNavigator.searchResultFiles": "Search result files", + "fileNavigator.showingCount": "Showing {visible} of {total}", + "fileNavigator.viewAria": "File navigator view", + "fileSystem.addFolderLocalOnly": "Add Folder to Workspace is only available for local folders.", + "fileSystem.addFolderToWorkspaceFailed": "Failed to add folder to workspace: {path}", + "fileSystem.folderAddedToWorkspace": "Added \"{name}\" to workspace.", + "fileSystem.folderAlreadyInWorkspace": "Folder is already in this workspace.", + "fileSystem.folderRemovedFromWorkspace": "Removed \"{name}\" from workspace.", + "fileSystem.noSupportedDroppedPaths": "No supported dropped files or folders could be opened.", + "fileSystem.openFileFailed": "Failed to open file {file}: {error}", + "fileSystem.openFolderFailed": "Failed to open folder: {path}", + "fileSystem.openRemoteProjectFailed": "Failed to open remote project.", + "fileSystem.primaryFolderCannotBeRemoved": "Primary workspace folder cannot be removed.", + "fileSystem.recentProjectNotFolder": "Recent project is not a folder: {path}. Remove it from the recent projects list?", + "fileSystem.recentProjectUnavailable": "The path {path} does not exist. If it is on a removable or network drive, make sure the drive is connected, or remove it from the recent projects list.", + "fileSystem.recentProjectUnavailableBadge": "Unavailable", + "fileSystem.recentProjectUnavailableTitle": "Recent Project Unavailable", + "fileSystem.remoteSessionRestoreFailed": "Remote workspace opened, but saved tabs could not be restored.", + "fileSystem.removeFolderFromWorkspaceFailed": "Could not remove \"{name}\" because its file watcher could not be stopped.", + "fileSystem.removeFromList": "Remove From List", + "fileSystem.removedMissingProject": "Removed missing project \"{name}\".", + "fileSystem.restoreWorkspaceFolderFailed": "Could not restore workspace folder \"{name}\".", + "fileSystem.revealLocalOnly": "Reveal in folder is only available for local workspaces.", + "fileSystem.switchProjectFailed": "Failed to switch project.", + "fileSystem.workspaceSessionRestoreFailed": "Workspace opened, but saved tabs could not be restored.", + "fileSystem.wslSessionRestoreFailed": "WSL workspace opened, but saved tabs could not be restored.", + "files.addFolderToWorkspace": "Add Folder to Workspace", + "files.cancel": "Cancel", + "files.chooseDifferentEnvFileName": "Choose a different env file name", + "files.collapseAll": "Collapse All", + "files.copy": "Copy", + "files.copyContent": "Copy Content", + "files.copyPath": "Copy Path", + "files.copyRelativePath": "Copy Relative Path", + "files.createEnvDevelopment": "Create .env.development", + "files.createEnvExample": "Create .env.example", + "files.createEnvLocal": "Create .env.local", + "files.createFailed": "Failed to create {name}", + "files.createFileFailedMessage": "Failed to create file: {error}", + "files.created": "Created {name}", + "files.cut": "Cut", + "files.delete": "Delete", + "files.duplicate": "Duplicate", + "files.enterNewFileName": "Enter the name for the new file:", + "files.enterNewFolderName": "Enter the name for the new folder:", + "files.fileNamePlaceholder": "file name", + "files.findInFolder": "Find in Folder", + "files.folderNamePlaceholder": "folder name", + "files.generateImage": "Generate Image", + "files.invalidFileName": "Invalid file name", + "files.invalidFileNameCharacters": "Invalid file name: path traversal and special characters are not allowed", + "files.javaClassAlreadyExists": "{name} already exists", + "files.javaPasteRemoteUnsupported": "Pasting a Java class onto a remote folder is not supported yet", + "files.localHistory": "Local History", + "files.nameNewFile": "Name new file", + "files.nameNewFolder": "Name new folder", + "files.newFile": "New File", + "files.newFolder": "New Folder", + "files.noExtension": "No extension", + "files.nothingToPaste": "Nothing to paste", + "files.open": "Open", + "files.openAllFiles": "Open All Files", + "files.openFailed": "Failed to open {name}", + "files.openFolderFirst": "Please open a folder first", + "files.openInTerminal": "Open in Terminal", + "files.openPreview": "Open Preview", + "files.overwrite": "Overwrite", + "files.overwriteEnvMessage": "{name} already exists. Overwrite it?", + "files.overwriteEnvTitle": "Overwrite Env File", + "files.paste": "Paste", + "files.properties": "Properties", + "files.propertiesFile": "File", + "files.propertiesPath": "Path", + "files.propertiesSize": "Size", + "files.propertiesType": "Type", + "files.refresh": "Refresh", + "files.removeFolderFromWorkspace": "Remove Folder from Workspace", + "files.rename": "Rename", + "files.renameItem": "Rename {name}", + "files.reveal": "Show in Finder", + "files.sizeUnknown": "Unknown", + "files.symlinkTo": "Symlink to: {target}", + "files.title": "Files", + "files.unableToDetermineRootPath": "Unable to determine root folder path", + "files.untitled": "Untitled", + "files.untitledNumber": "Untitled-{number}", + "files.uploadFiles": "Upload Files", + "fontSelector.bundled": "{font} (bundled)", + "fontSelector.custom": "{font} (custom)", + "fontSelector.error": "Error loading fonts: {error}", + "fontSelector.loading": "Loading fonts", + "fontSelector.select": "Select font", + "footer.change": "{count} change", + "footer.changes": "{count} changes", + "footer.cursor": "Go to line and column", + "footer.encoding": "File encoding", + "footer.filePath": "File path", + "footer.gitBranch": "Git branch", + "footer.indent": "Indentation", + "footer.memory": "Memory", + "footer.memoryUsage": "Total {total} · Lithe {used}", + "footer.moveLeft": "Move Left", + "footer.moveRight": "Move Right", + "footer.noChanges": "No changes", + "footer.readOnly": "Read-only", + "footer.resetOrder": "Reset Footer Order", + "footer.spaces": "{count} spaces", + "footer.statusBar": "Status bar", + "footer.writable": "Writable", + "git.abort": "Abort", + "git.actionCompleted": "{action} completed", + "git.actionFailed": "{action} failed", + "git.actions": "Git Actions", + "git.add": "Add", + "git.addPathsToIgnoreConfirm": "Add {count} target(s) to {target}? A directory target ignores every current and future untracked item below it.\n\n{paths}", + "git.addRemote": "Add remote", + "git.addRepository": "Add Repository...", + "git.addSelectionToGitignore": "Add {count} selected items to .gitignore", + "git.addSelectionToLocalExclude": "Add {count} selected items to .git/info/exclude", + "git.addTag": "Add tag", + "git.addToGitignore": "Add to .gitignore", + "git.addToLocalExclude": "Add to .git/info/exclude", + "git.addToVcs": "Add to VCS", + "git.added": "added", + "git.adding": "Adding...", + "git.addingRepository": "Adding Repository...", + "git.aheadOfRemote": "{count} commits ahead of the remote branch", + "git.aiCommitEmptyMessage": "AI returned an empty commit message.", + "git.allChangesDiscarded": "All changes discarded", + "git.allFilesStaged": "All files staged successfully", + "git.allFilesUnstaged": "All files unstaged successfully", + "git.amend": "Amend", + "git.amendLastCommit": " last commit", + "git.applyStash": "Apply stash", + "git.behindRemote": "{count} commits behind the remote branch", + "git.binaryDiffDescription": "{name} cannot be displayed as a text diff.", + "git.binaryFileChanged": "Binary file changed", + "git.branch": "Branch", + "git.branchActions": "Branch actions for {branch}", + "git.branchCount": "{count} branch", + "git.branches": "Branches", + "git.branchesCount": "{count} branches", + "git.browse": "Browse", + "git.changeAdded": "added", + "git.changeDeleted": "deleted", + "git.changeModified": "modified", + "git.changedFilesCount": "{count} changed file{plural}", + "git.changelists.active": "Active changelist", + "git.changelists.activeSuffix": " (active)", + "git.changelists.default": "Default ChangeList", + "git.changelists.delete": "Delete changelist", + "git.changelists.deleteDescription": "Files return to Default and may be included by its bulk staging. File contents and the Git index are unchanged.", + "git.changelists.invalidName": "Use a unique, nonempty name of at most 100 characters.", + "git.changelists.move": "Move to changelist", + "git.changelists.name": "Changelist name", + "git.changelists.new": "New changelist", + "git.changelists.otherStaged": "Other changelists contain staged changes. Uncheck those files or switch the active list before committing.", + "git.changelists.rename": "Rename changelist", + "git.changelists.scope": "Commit: {name}. Other lists must be unstaged.", + "git.changelists.stageActive": "Stage all changes in the active changelist", + "git.changelists.unavailable": "Saved changelists could not be read. Staging and committing are disabled to protect excluded files.", + "git.changelists.unstageActive": "Unstage all changes in the active changelist", + "git.changesCommitted": "Changes committed successfully", + "git.changesFetched": "Fetched successfully.", + "git.changesNode": "Changes", + "git.changesPushed": "Changes pushed successfully.", + "git.checkout": "Checkout", + "git.checkoutTag": "Checkout Tag", + "git.checkoutTagConfirm": "Checkout {name} in detached HEAD?", + "git.cherryPickCommit": "Cherry-pick Commit", + "git.cherryPickCommitConfirm": "Cherry-pick commit {hash} \"{message}\" onto the current branch?", + "git.cherryPickInProgress": "Cherry-pick in progress", + "git.chooseCommitAction": "Choose commit action", + "git.chooseDiffSource": "Choose diff source", + "git.chooseRemoteAction": "Choose remote action", + "git.clearAdded": "Clear Added", + "git.close": "Close", + "git.closeDiffView": "Close diff view", + "git.collapseAll": "Collapse all", + "git.collapseAllFiles": "Collapse all files", + "git.commit": "Commit", + "git.commitAndPush": "Commit and Push", + "git.commitAndPushEllipsis": "Commit and Push...", + "git.commitChanges": "Commit Changes", + "git.commitChangesFailed": "Failed to commit changes", + "git.commitCount": "{count} commit", + "git.commitIndexReconcileFailed": "The commit succeeded, but the staging area could not be refreshed.", + "git.commitMessageFormat": "Commit message format", + "git.commitMessagePlaceholder": "Commit Message", + "git.commitMessageTitleAndBody": "Title + body", + "git.commitMessageTitleOnly": "Title only", + "git.commitSha": "Commit SHA", + "git.commitShaOrRef": "Commit SHA or ref", + "git.commitsCount": "{count} commits", + "git.committing": "Committing...", + "git.compareBranchPlaceholder": "Compare current branch with...", + "git.compareWithBranch": "compare with {branch}", + "git.compareWithHead": "Compare with HEAD", + "git.compareWithPreviousTag": "Compare with previous tag", + "git.conflictMarkersRemain": "Conflict markers remain in: {paths}", + "git.conflictedFile": "Stopped with {count} conflicted file. Resolve them in the changes list, then continue.", + "git.conflictedFiles": "Stopped with {count} conflicted files. Resolve them in the changes list, then continue.", + "git.conflictsResolved": "All conflicts resolved. Continue to finish, or abort to undo.", + "git.console.arguments.files": "… {count} more files", + "git.console.arguments.references": "… {count} more references", + "git.console.automaticQueries": "Automatic query ×{count}, identical results", + "git.console.cancel": "Cancel running operations", + "git.console.clear": "Clear", + "git.console.closeSearch": "Close search", + "git.console.completed": "Completed", + "git.console.copy": "Copy output", + "git.console.copyCommand": "Copy command", + "git.console.copyOutput": "Copy output", + "git.console.copyPath": "Copy repository path", + "git.console.details": "Command details", + "git.console.empty": "Git commands and output will appear here.", + "git.console.exit": "Exit code", + "git.console.find": "Find in Git console", + "git.console.fold.branches": "Other {count} branches", + "git.console.fold.commits": "Other {count} commits", + "git.console.fold.files": "Other {count} files", + "git.console.fold.lines": "Expand {count} lines", + "git.console.fold.repeat": "Repeated {count} times", + "git.console.fold.tags": "Other {count} tags", + "git.console.historyTruncated": "Earlier Git commands were truncated to limit memory use.", + "git.console.log": "Log", + "git.console.matches": "{count} matches", + "git.console.nextMatch": "Next match", + "git.console.notice.completedWithoutOutput": "Git completed successfully with no output.", + "git.console.notice.fetchUnchanged": "Fetch completed with no reference changes.", + "git.console.notice.waitingForOutput": "Running Git; waiting for output.", + "git.console.open": "Open Git Console", + "git.console.options": "Git command options", + "git.console.presentationUnavailable": "Compression is unavailable. Showing complete retained output.", + "git.console.previousMatch": "Previous match", + "git.console.queued": "Waiting to start", + "git.console.referenceChanges": "Other reference changes: {added} added, {updated} updated, {deleted} deleted", + "git.console.running": "Running", + "git.console.scrollToEnd": "Scroll to end", + "git.console.searchLimit": "first {count} locations", + "git.console.title": "Console", + "git.console.truncated": "Earlier output was omitted to limit memory use.", + "git.console.unconfirmed": "No completed invocation was reported", + "git.console.wrap": "Soft wrap", + "git.contextMenu.add": "Add", + "git.contextMenu.commitFile": "Stage and Open Commit…", + "git.contextMenu.diffFailed": "Failed to open the diff", + "git.contextMenu.fetch": "Fetch", + "git.contextMenu.newBranch": "New Branch…", + "git.contextMenu.noChanges": "No changes to show", + "git.contextMenu.openGitLog": "Open Git Log", + "git.contextMenu.pull": "Pull…", + "git.contextMenu.push": "Push…", + "git.contextMenu.showDiff": "Show Diff", + "git.contextMenu.stageFailed": "Failed to stage the file", + "git.contextMenu.submenu": "Git", + "git.continueCherryPick": "Continue Cherry-pick", + "git.continueMerge": "Continue Merge", + "git.continueRebase": "Continue Rebase", + "git.continueRevert": "Continue Revert", + "git.copiedLabel": "{label} copied", + "git.copyCommitSha": "Copy commit SHA", + "git.copyLabelFailed": "Failed to copy {label}", + "git.copyTagName": "Copy tag name", + "git.create": "Create", + "git.createNewBranch": "Create new branch \"{name}\"", + "git.createStash": "Create Stash", + "git.createTag": "Create tag", + "git.createWorktree": "Create worktree \"{path}\"", + "git.creating": "Creating...", + "git.current": "current", + "git.delete": "Delete", + "git.deleteBranch": "Delete Branch", + "git.deleteBranchConfirm": "Are you sure you want to delete branch \"{branch}\"?", + "git.deleteCommit": "Delete Commit", + "git.deleteCommitConfirm": "Delete commit {hash} \"{message}\" and replay every later local commit?", + "git.deleteFileConfirm": "Delete \"{name}\"?", + "git.deleteFilesConfirm": "Delete {count} files?", + "git.deleteFilesFailed": "Failed to delete {count} selected file(s).", + "git.deleteRemoteTag": "Delete Remote Tag", + "git.deleteRemoteTagAction": "Delete remote tag", + "git.deleteRemoteTagConfirm": "Delete {name} from {remote}?", + "git.deleteTag": "Delete tag", + "git.deleteTagFrom": "Delete tag from {remote}", + "git.deleteTagFromRemote": "Delete tag from remote", + "git.detachedHead": "Detached HEAD", + "git.detectingRepositories": "Detecting repositories...", + "git.diff.actions": "Diff actions", + "git.diff.addedVersion": "Added version", + "git.diff.afterVersion": "After version", + "git.diff.beforeVersion": "Before version", + "git.diff.changedFiles": "Changed files", + "git.diff.commitPreviewEmptyTitle": "Commit", + "git.diff.commitPreviewTitle": "Commit: {file}", + "git.diff.commitRangeTitle": "Commits {base}..{target}", + "git.diff.compareRefsFailed": "Failed to compare {base} and {target}:\n{error}", + "git.diff.currentVersion": "Current version", + "git.diff.deletedVersion": "Deleted version", + "git.diff.differences": "{count} differences", + "git.diff.emptyChanges": "No changes with diffs.", + "git.diff.emptyRevision": "Empty", + "git.diff.emptyStagedChanges": "No staged changes with diffs.", + "git.diff.emptyUnstagedChanges": "No unstaged changes with diffs.", + "git.diff.excludeBlock": "Exclude block from commit", + "git.diff.filePosition": "{current} / {total}", + "git.diff.getCommitDiffFailed": "Failed to get diff for commit {commit}:\n{error}", + "git.diff.getFileDiffFailed": "Failed to get diff for {file}:\n{error}", + "git.diff.getSelectedCommitDiffFailed": "Failed to load selected commit differences: {error}", + "git.diff.getStashDiffFailed": "Failed to get diff for {stash}:\n{error}", + "git.diff.getWorkingTreeDiffFailed": "Failed to get working tree diff:\n{error}", + "git.diff.hideChangedFiles": "Hide changed files", + "git.diff.hideWhitespace": "Hide whitespace", + "git.diff.highlightWords": "Highlight Words", + "git.diff.hunkStale": "The file changed after this diff was loaded, so the block was not rolled back. Try again after the diff refreshes.", + "git.diff.includeBlock": "Include block in commit", + "git.diff.includedCount": ", {included} included", + "git.diff.indexVersion": "Index version", + "git.diff.invalidExpression": "Invalid expression", + "git.diff.lineEndingChanges": "Line endings differ", + "git.diff.matchCount": "{current} of {total}", + "git.diff.nextDifference": "Next Difference", + "git.diff.nextFile": "Next File", + "git.diff.noChangedFileSelected": "No changed file selected", + "git.diff.noChangesBetween": "No changes between {base} and {target}.", + "git.diff.noChangesInCommit": "No changes in this commit.", + "git.diff.noChangesInCommitForFile": "No changes in this commit for file {file}.", + "git.diff.noChangesInSelectedCommits": "No changes in the selected commits.", + "git.diff.noChangesInStash": "No changes in this stash.", + "git.diff.noDifferences": "No differences", + "git.diff.noResults": "No results", + "git.diff.oneDifference": "1 difference", + "git.diff.openFileFailed": "Failed to open file {file}:\n{error}", + "git.diff.previousDifference": "Previous Difference", + "git.diff.previousFile": "Previous File", + "git.diff.previousRevision": "Previous revision", + "git.diff.refreshFailed": "Failed to refresh diff", + "git.diff.repositoryPreviewEmptyTitle": "Repository Diff", + "git.diff.repositoryPreviewTitle": "Repository Diff: {file}", + "git.diff.rollbackHunk": "Rollback Change Block", + "git.diff.rollbackHunkConfirm": "Roll back this change block in \"{path}\"? Its unstaged edits will be discarded. This cannot be undone.", + "git.diff.search": "Search Diff", + "git.diff.searchChanges": "Search changes", + "git.diff.selectedCommitsTitle": "{count} selected commits", + "git.diff.selectionChanged": "The file changed. Review the refreshed diff and try again.", + "git.diff.showChangedFiles": "Show changed files", + "git.diff.showWhitespace": "Show Whitespaces", + "git.diff.sideBySideViewer": "Side-by-side viewer", + "git.diff.split": "Side-by-side", + "git.diff.splitUnavailableForAddedDeleted": "Split view is unavailable for added or deleted files", + "git.diff.stage": "Stage", + "git.diff.stageFailed": "Could not update the selected block. Refresh and try again.", + "git.diff.stagedChanges": "Staged Changes", + "git.diff.statusAdded": "ADDED", + "git.diff.statusDeleted": "DELETED", + "git.diff.statusModified": "MODIFIED", + "git.diff.statusRenamed": "RENAMED", + "git.diff.synchronizedHorizontalScroll": "Synchronized diff horizontal scroll", + "git.diff.title": "Git Diff", + "git.diff.uncommitted": "Uncommitted Changes", + "git.diff.unified": "Unified", + "git.diff.unifiedViewer": "Unified viewer", + "git.diff.unstage": "Unstage", + "git.diff.unstagedChanges": "Unstaged Changes", + "git.diff.viewOnGitHub": "View on GitHub", + "git.diff.worktree": "WORKTREE", + "git.diffFileCount": "{count} file{plural}", + "git.discard": "Discard", + "git.discardAllChanges": "Discard All Changes", + "git.discardAllChangesConfirm": "Are you sure you want to discard all changes? This cannot be undone.", + "git.discardChanges": "Discard Changes", + "git.discardChangesConfirm": "Discard all unstaged changes? This cannot be undone.", + "git.discardChangesFailed": "Failed to discard changes", + "git.discardFileChanges": "Discard File Changes", + "git.discardFileConfirm": "Discard changes for \"{path}\"? This cannot be undone.", + "git.discardSelectedChanges": "Discard selected changes", + "git.dropStash": "Drop stash", + "git.editCommitMessage": "Edit Commit Message", + "git.editCommitMessagePrompt": "Enter a new message for commit {hash}.", + "git.enterCommitMessage": "Enter commit message:", + "git.excludeFileFromCommit": "Exclude {name} from commit", + "git.excludeFolderFromCommit": "Exclude folder {name} from commit", + "git.execution.advanced": "Advanced configuration and sources", + "git.execution.authAgain": "Git requested credentials again. Check the previous answer.", + "git.execution.authExpired": "Git authentication request expired. Retry the operation.", + "git.execution.authScope": "This response is sent only to the running Git operation.", + "git.execution.authentication": "Git authentication", + "git.execution.chooseValue": "Choose a value", + "git.execution.clear": "Clear override", + "git.execution.continue": "Continue", + "git.execution.credentials": "Git can request credentials in Lithe. Lithe does not save passwords; your Git helper controls credential storage.", + "git.execution.deleted": "Deleted references", + "git.execution.effectiveFetch": "Effective Fetch options", + "git.execution.executable": "Git executable", + "git.execution.helper": "Use credential helper", + "git.execution.key": "Configuration key", + "git.execution.overridden": "Overridden by a later value", + "git.execution.pathDefault": "Use Git from PATH", + "git.execution.phase.compressing": "Compressing objects", + "git.execution.phase.counting": "Counting objects", + "git.execution.phase.enumerating": "Enumerating objects", + "git.execution.phase.receiving": "Receiving objects", + "git.execution.phase.resolving": "Resolving deltas", + "git.execution.phase.updating": "Updating files", + "git.execution.phase.writing": "Writing objects", + "git.execution.precedence": "One-time choices override repository preferences, then application defaults. Git configuration changes only when you save or clear a field.", + "git.execution.preferences": "Fetch and commit preferences", + "git.execution.progress": "Git progress", + "git.execution.referencesTruncated": "Reference list truncated; total updated: {updated}, deleted: {deleted}", + "git.execution.referencesUnavailable": "Reference changes could not be inspected", + "git.execution.reload": "Reload Git configuration", + "git.execution.remoteFailed": "Fetch failed", + "git.execution.remoteSucceeded": "Fetch succeeded", + "git.execution.response": "Response", + "git.execution.retry": "Retry authentication", + "git.execution.save": "Save", + "git.execution.saved": "Git configuration saved", + "git.execution.sources": "Configuration sources", + "git.execution.temporary": "Temporary configuration", + "git.execution.title": "Git execution", + "git.execution.updated": "Updated references", + "git.execution.value": "Configuration value", + "git.expandAll": "Expand all", + "git.expandAllFiles": "Expand all files", + "git.failedToInitializeRepository": "Failed to initialize repository.", + "git.failedToSelectRepository": "Failed to select repository.", + "git.fetch": "Fetch", + "git.fetch.allRemotes": "Fetch all remotes", + "git.fetch.credentials": "Credentials use your existing Git helper and SSH configuration.", + "git.fetch.defaults": "Fetch defaults", + "git.fetch.oneTime": "These options apply once. Git configuration is unchanged. The console records the commands actually executed.", + "git.fetch.options": "Fetch options…", + "git.fetch.prune": "Prune stale remote-tracking references", + "git.fetch.remoteName": "Configured remote name", + "git.fetch.scope": "Used for ordinary Fetch in every project.", + "git.fetch.submodules": "Fetch submodules", + "git.fetch.submodules.inherit": "Use Git configuration", + "git.fetch.submodules.no": "Do not fetch submodules", + "git.fetch.submodules.onDemand": "Fetch on demand", + "git.fetch.submodules.yes": "Fetch all submodules", + "git.fetch.tags": "Fetch tags", + "git.fetch.tags.all": "Fetch all tags", + "git.fetch.tags.inherit": "Use Git configuration", + "git.fetch.tags.none": "Do not fetch tags", + "git.fetch.tags.prune": "Synchronize tags and delete local tags absent on remote", + "git.fetchFailed": "Failed to fetch changes.", + "git.fetchedChanges": "Fetched successfully.", + "git.fetching": "Fetching", + "git.fetchingChanges": "Fetching changes...", + "git.fileStaged": "{count} file staged", + "git.filesStaged": "{count} files staged", + "git.filterRepositories": "Filter repositories...", + "git.finishOperationBeforeCommit": "Finish or abort the current Git operation before committing.", + "git.flatList": "Flat List", + "git.generateCommitMessageFailed": "Failed to generate commit message.", + "git.generateCommitMessageWithAI": "Generate commit message with AI", + "git.gitignoreFile": ".gitignore", + "git.groupByDirectory": "Directory", + "git.gutter.added": "Added lines - click to review the change", + "git.gutter.close": "Close", + "git.gutter.deleted": "Deleted lines - click to review the change", + "git.gutter.modified": "Modified lines - click to review the change", + "git.gutter.next": "Next Change", + "git.gutter.openDiff": "Open Full Diff", + "git.gutter.previous": "Previous Change", + "git.gutter.stage": "Stage Change", + "git.gutter.stageUnavailable": "Save the file to stage this change", + "git.gutter.title": "Git Local Changes (Working Tree) - Change {index} of {total}", + "git.historyLoadFailed": "Unable to load Git history.", + "git.historyLoadRepositoryFailed": "Unable to load Git history for this repository.", + "git.historyMutationFailed": "Git history operation failed", + "git.historyReview.affected": "Affected history: {count} commits (oldest first)", + "git.historyReview.body": "Description", + "git.historyReview.completed": "History updated. A recovery reference preserves the previous HEAD.", + "git.historyReview.contiguousRequired": "Squash requires consecutive commits on the current branch", + "git.historyReview.copied": "Copied", + "git.historyReview.copyFailed": "Could not copy. Select and copy the reference above.", + "git.historyReview.copyRecovery": "Copy recovery reference", + "git.historyReview.createRecovery": "Create Recovery Branch…", + "git.historyReview.deleteCommit": "Remove this commit and its changes, then replay later commits. Replaying may stop on conflicts.", + "git.historyReview.description": "Review the exact commits and repository state before changing history.", + "git.historyReview.editCommitMessage": "Edit the complete commit message. This commit and its later descendants receive new commit IDs.", + "git.historyReview.failed": "The operation did not finish. Review the repository before trying again.", + "git.historyReview.headRequired": "Undo Commit is available only for HEAD", + "git.historyReview.loading": "Checking whether this operation is available…", + "git.historyReview.outcomeUnknown": "Git stopped before Lithe could confirm the final branch state. Inspect the branch and recovery reference before starting another operation.", + "git.historyReview.recovery": "Previous history is available at this reference. You can use it to create a recovery branch.", + "git.historyReview.recoveryCreated": "Recovery branch created: {name}", + "git.historyReview.recoveryFailed": "Could not create the recovery branch. Check the branch name and whether it already exists.", + "git.historyReview.recoveryName": "Enter a branch name for the previous history. Your current branch and files stay in place.", + "git.historyReview.refreshFailed": "The branch changed, but the working tree could not be refreshed. Check the changes before continuing.", + "git.historyReview.reload": "Reload review", + "git.historyReview.reviewAgain": "Reload the review before trying again. If Git paused on conflicts, use Continue, Skip, or Abort in Changes.", + "git.historyReview.running": "Updating history…", + "git.historyReview.squashCommits": "Combine {count} commits into 1. The message below includes every selected commit, from oldest to newest; later descendants receive new commit IDs.", + "git.historyReview.title": "Commit title", + "git.historyReview.titleCharacters": "Title: {count} characters", + "git.historyReview.totalCharacters": "Complete message: {count} characters", + "git.historyReview.undoCommit": "Move HEAD to its parent. The undone changes remain staged, and existing staged and unstaged changes are preserved.", + "git.includeFileInCommit": "Include {name} in commit", + "git.includeFolderInCommit": "Include folder {name} in commit", + "git.indexedFilesOfTotal": "{visible} of {total} changed files", + "git.indexing": "Indexing", + "git.indexingChanges": "Indexing changes", + "git.indexingWithCount": "{label} {processed}/{total}", + "git.initialize": "Initialize", + "git.initializeGitRepository": "Initialize Git repository", + "git.initializeRepository": "Initialize Repository", + "git.initializing": "Initializing...", + "git.jumpToSource": "Jump to Source", + "git.lineHistory.empty": "No commits touched these lines.", + "git.lineHistory.failed": "Line history query failed.", + "git.lineHistory.loading": "Loading line history…", + "git.lineHistory.menuLabel": "Show Line History", + "git.lineHistory.mergeBadge": "merge (first parent)", + "git.lineHistory.retry": "Retry", + "git.lineHistory.titleLine": "History of line {line}", + "git.lineHistory.titleRange": "History of lines {start}–{end}", + "git.lineHistory.truncated": "Only the most recent {count} entries are shown.", + "git.lineHistory.unmapped.new": "Line {line} is newly added and has no committed history.", + "git.lineHistory.unmapped.unreliable": "Line {line} has uncommitted edits and cannot be mapped to a committed version.", + "git.lineHistory.viewCommit": "View whole commit", + "git.loadingCommits": "Loading commits", + "git.loadingDiff": "Loading diff", + "git.loadingGitStatus": "Loading Git status", + "git.loadingRemotes": "Loading remotes...", + "git.loadingTags": "Loading tags...", + "git.loadingWorktrees": "Loading worktrees...", + "git.localAhead": "Local ahead", + "git.localExcludeFile": ".git/info/exclude", + "git.log.action.checkout": "Checkout", + "git.log.action.checkoutAndRebase": "Checkout and Rebase onto Current Branch", + "git.log.action.compareWithCurrent": "Compare with Current Branch", + "git.log.action.createBranch": "New Branch from Reference…", + "git.log.action.diffWithWorkingTree": "Show Diff with Working Tree", + "git.log.action.mergeIntoCurrent": "Merge Reference into Current Branch", + "git.log.action.pullMergeIntoCurrent": "Pull Remote Branch with Merge", + "git.log.action.pullRebaseIntoCurrent": "Pull Remote Branch with Rebase", + "git.log.action.rebaseCurrentOnto": "Rebase Current Branch onto Reference", + "git.log.actionSucceeded": "{action} completed for {reference}", + "git.log.all": "All", + "git.log.autoStashCleanupFailed": "Pull completed and local changes were restored, but the temporary stash could not be removed.", + "git.log.autoStashDeferredByConflicts": "Pull stopped with {count} conflicted file(s). Local changes remain in Stashes and can be restored after the Git operation is finished.", + "git.log.autoStashRestoreConflicts": "Pull completed, but restoring local changes caused {count} conflicted file(s). {stash} was kept for recovery.", + "git.log.branchCreateFailed": "Failed to create branch", + "git.log.branchCreated": "Created branch {name}", + "git.log.branchDeletedTitle": "Deleted Branch:", + "git.log.branchFilter": "Branch: {name}", + "git.log.branchNameAlreadyExists": "Branch name {name} already exists", + "git.log.branchNameClashesWithBranchPrefix": "Branch name {name} conflicts with existing local branch {branch}", + "git.log.branchNameClashesWithDirectory": "Branch name {name} conflicts with local branch directory with the same name", + "git.log.branchNameClashesWithRemote": "Branch name {name} conflicts with remote branch with the same name", + "git.log.branchNameEmpty": "Specify name for the new branch", + "git.log.branchNameHead": "HEAD is a reserved keyword", + "git.log.branchNameLabel": "Branch Name:", + "git.log.branchNamePrompt": "Enter the new branch name.", + "git.log.branchRestored": "Restored branch {name}", + "git.log.branchRestoredUpstreamFailed": "Restored branch {name}, but could not restore its upstream tracking ({upstream})", + "git.log.checkoutAndRebase": "Checkout and Rebase", + "git.log.checkoutAndRebaseConfirm": "Checkout '{branch}', then rebase it onto '{current}'? Rebase rewrites the checked-out branch history.", + "git.log.checkoutAndRebaseOnto": "Checkout and Rebase onto '{branch}'", + "git.log.checkoutAndUpdate": "Checkout and Update", + "git.log.checkoutFailed": "Failed to checkout '{branch}'.", + "git.log.chooseWorktreeDirectory": "Choose Worktree Directory", + "git.log.clearBranchFilter": "Clear branch filter", + "git.log.clearFilter": "Clear Git log filter", + "git.log.collapse": "Collapse {name}", + "git.log.collapseLongEdges": "Collapse long graph edges", + "git.log.commitDetails": "Commit details", + "git.log.commitFiles": "Commit files", + "git.log.commitHash": "Commit hash", + "git.log.commitMessage": "Commit message", + "git.log.compareWithCurrent": "Compare with '{branch}'", + "git.log.compareWithHead": "Compare with HEAD", + "git.log.confirmAction": "{action} for {reference}?", + "git.log.copied": "{label} copied", + "git.log.copyBranchName": "Copy Branch Name", + "git.log.copyCommitHash": "Copy Commit Hash", + "git.log.copyCommitMessage": "Copy Commit Message", + "git.log.copyFailed": "Unable to copy {label}", + "git.log.copyHash": "Copy Hash", + "git.log.copyShortHash": "Copy Short Hash", + "git.log.couldNotCheckout": "Could not checkout {reference}", + "git.log.createBranchFromTitle": "Create Branch from {reference}", + "git.log.createTagOnCommit": "Create New Tag On {commit}", + "git.log.dateJustNow": "Just now", + "git.log.dateMinuteAgoOne": "{count} minute ago", + "git.log.dateMinutesAgo": "{count} minutes ago", + "git.log.dateToday": "Today {time}", + "git.log.dateYesterday": "Yesterday {time}", + "git.log.deleteRemoteBranch": "Delete Remote Branch", + "git.log.deleteRemoteBranchConfirm": "Delete remote branch '{branch}'? This removes it from the remote repository.", + "git.log.expand": "Expand {name}", + "git.log.fileCountOne": "{count} file", + "git.log.filesCount": "{count} files", + "git.log.filter": "Filter Git log", + "git.log.filterAuthor": "Author", + "git.log.filterBranch": "Branch", + "git.log.filterField": "Git log filter field", + "git.log.filterPlaceholder": "{field} filter", + "git.log.filterText": "Text", + "git.log.goToChild": "Go to child commit {hash}", + "git.log.goToParent": "Go to parent commit {hash}", + "git.log.headCurrentBranch": "HEAD (Current Branch)", + "git.log.hide": "Hide Git Log", + "git.log.hideDecorations": "Hide branch and tag labels", + "git.log.loadMore": "Load more commits", + "git.log.loading": "Loading Git log…", + "git.log.loadingChangedFiles": "Loading changed files…", + "git.log.loadingCommits": "Loading commits…", + "git.log.loadingShort": "Loading…", + "git.log.local": "Local", + "git.log.logLabel": "Log: {name}", + "git.log.manageRemotes": "Manage Remotes...", + "git.log.mergeIntoCurrent": "Merge '{branch}' into '{current}'", + "git.log.newBranchFrom": "New Branch from '{branch}'...", + "git.log.newBranchPrompt": "Enter a local branch name to create from '{branch}'.", + "git.log.newTag": "New Tag…", + "git.log.newWorktreeBranchPrompt": "Enter the new local branch name for a worktree based on '{branch}'.", + "git.log.newWorktreeFrom": "New Worktree from '{branch}'...", + "git.log.noChangedFiles": "No changed files", + "git.log.noCommits": "No commits in this view", + "git.log.noMatch": "No commits match this filter", + "git.log.noRemoteBranches": "No Remote Branches", + "git.log.noRepository": "No repository open", + "git.log.noWorkingTreeDifferences": "There are no differences between '{branch}' and the working tree.", + "git.log.none": "None", + "git.log.openCommitDiff": "Open Commit Diff", + "git.log.openDiff": "Open Diff", + "git.log.openDiffHint": "Double-click or press Enter to open commit diff", + "git.log.openFileDiff": "Double-click to open file diff", + "git.log.openWorkspace": "Open a Git workspace to view its log.", + "git.log.operationBlocked": "Local changes block this operation: {paths}", + "git.log.operationConflicts": "Git stopped with {count} conflicted file(s).", + "git.log.operationStopped": "Git stopped before completing. Use the conflict controls to continue or abort.", + "git.log.pullMergeIntoCurrent": "Pull into '{branch}' Using Merge", + "git.log.pullRebaseIntoCurrent": "Pull into '{branch}' Using Rebase", + "git.log.pullRemoteIntoCurrentConfirm": "Fetch '{branch}', then integrate it into '{current}' using {strategy}?", + "git.log.rebaseCurrentOnto": "Rebase '{current}' onto '{branch}'", + "git.log.references": "References", + "git.log.referencesLoadFailed": "Failed to load references", + "git.log.refresh": "Refresh Git log", + "git.log.remote": "Remote", + "git.log.renameBranch": "Rename Branch...", + "git.log.renameBranchPrompt": "Enter the new branch name.", + "git.log.repository": "Repository", + "git.log.resizeAuthorColumn": "Resize author column", + "git.log.resizeDateColumn": "Resize date column", + "git.log.restoreBranch": "Restore", + "git.log.restoreUpstreamTracking": "Restoring upstream tracking", + "git.log.retry": "Retry", + "git.log.selectCommit": "Select a commit", + "git.log.settings": "Open Git Log Settings", + "git.log.showAll": "Show all references", + "git.log.showDecorations": "Show branch and tag labels", + "git.log.showDiffWithWorkingTree": "Show Diff with Working Tree", + "git.log.showLongEdges": "Show long graph edges", + "git.log.stopTrackingBranch": "Stop Tracking Branch", + "git.log.switchToRepositoryFirst": "Switch to this repository to manage its references", + "git.log.tagCreateFailed": "Failed to create tag '{name}'", + "git.log.tagNamePrompt": "Enter the name of new tag", + "git.log.tags": "Tags", + "git.log.toolbar.compareWithCurrent": "Compare with Current Branch", + "git.log.toolbar.deleteBranch": "Delete Branch", + "git.log.toolbar.fetch": "Fetch", + "git.log.toolbar.goToHead": "Go to Selected Branch HEAD", + "git.log.toolbar.hideWorktrees": "Hide Worktree Repositories", + "git.log.toolbar.mark": "Mark", + "git.log.toolbar.moreActions": "More Actions", + "git.log.toolbar.newBranch": "New Branch", + "git.log.toolbar.showAllBranches": "Show All Branches", + "git.log.toolbar.showMyBranches": "Show My Branches", + "git.log.toolbar.showWorktrees": "Show Worktree Repositories", + "git.log.toolbar.unmark": "Unmark", + "git.log.toolbar.updateSelected": "Update Selected", + "git.log.trackingBranch": "Tracking Branch", + "git.log.tryAgain": "Try again", + "git.log.unableToLoad": "Unable to load Git log.", + "git.log.unableToLoadFiles": "Unable to load changed files", + "git.log.unableToRefresh": "Unable to refresh Git log.", + "git.log.updateBranch": "Update", + "git.log.workingTreeComparisonFailed": "Failed to compare '{branch}' with the working tree:\n{error}", + "git.log.workingTreeComparisonTitle": "{branch} vs Working Tree", + "git.manageBranches": "Manage Branches", + "git.manageRemotes": "Manage Remotes", + "git.manageTags": "Manage Tags", + "git.merge": "Merge", + "git.mergeBranchTitle": "Merge Branch", + "git.mergeConfirm": "Merge branch \"{branch}\" into \"{current}\"? Conflicts may require resolution.", + "git.mergeFailed": "Merge failed.", + "git.mergeInProgress": "Merge in progress", + "git.mergeIntoCurrent": "Merge into Current Branch", + "git.mergeSuccess": "Merged {branch} successfully.", + "git.message": "Message", + "git.moreFiles": " (+{count} more)", + "git.name": "Name", + "git.newBranch": "New Branch", + "git.newImage": "New Image", + "git.newImageAlt": "{name} (new)", + "git.newWorktree": "New Worktree", + "git.noBranch": "No branch", + "git.noBranchesFound": "No branches found", + "git.noChangesInFile": "No changes in this file", + "git.noCommits": "No commits", + "git.noDiffData": "No diff data available", + "git.noFilesStaged": "No files staged", + "git.noImage": "No image", + "git.noMatchingBranches": "No matching branches", + "git.noMatchingCommits": "No matching commits", + "git.noMatchingRemotes": "No matching remotes", + "git.noMatchingRepositories": "No matching repositories", + "git.noMatchingStashes": "No matching stashes", + "git.noMatchingTags": "No matching tags", + "git.noMatchingWorktrees": "No matching worktrees", + "git.noOtherBranches": "No other branches", + "git.noRemote": "No remote", + "git.noRemotesConfigured": "No remotes configured", + "git.noRepositoriesFound": "No repositories found", + "git.noRepositoryOpen": "No repository open", + "git.noRepositorySelected": "No repository selected", + "git.noStashes": "No stashes", + "git.noTagsFound": "No tags found", + "git.noWorktreesFound": "No worktrees found", + "git.notAGitRepository": "Not a Git repository", + "git.oldImageAlt": "{name} (old)", + "git.openFile": "Open File", + "git.openFolderBeforeInit": "Open a folder before initializing a repository.", + "git.openFolderBeforeInitializing": "Open a folder before initializing Git", + "git.operationError": "Error: {error}", + "git.operationFailed": "Git operation failed", + "git.operationStep": "Step {step} of {total}.", + "git.optionalAnnotation": "Optional annotation", + "git.patch.applicable": "The patch can be applied to the selected destination.", + "git.patch.applied": "Patch applied. The changes list has been refreshed.", + "git.patch.apply": "Apply Patch…", + "git.patch.applyDescription": "Load or paste a patch, check it against this repository, then confirm where to apply it.", + "git.patch.applying": "Applying patch…", + "git.patch.base": "Base commit (before)", + "git.patch.check": "Check patch", + "git.patch.checking": "Checking patch…", + "git.patch.confirmApply": "Apply reviewed patch", + "git.patch.create": "Create Patch…", + "git.patch.destination": "Apply changes to", + "git.patch.displayTruncated": "Only the beginning is displayed. The complete original patch is retained for saving or applying; open another file to replace it.", + "git.patch.distinctCommits": "Choose two different commits to define the patch direction.", + "git.patch.editPatch": "Edit patch text", + "git.patch.exportDescription": "Choose files and review the exact patch before saving it.", + "git.patch.fileTooLarge": "The patch exceeds the 32 MiB limit.", + "git.patch.invalidUtf8": "The patch is not valid UTF-8. Convert it without losing patch contents, then open it again.", + "git.patch.loading": "Preparing patch…", + "git.patch.noChanges": "There are no changes to export for this source.", + "git.patch.notApplicable": "The patch cannot be applied to the selected destination.", + "git.patch.openFile": "Open patch file…", + "git.patch.paste": "Patch text (you can paste here)", + "git.patch.preview": "Update preview", + "git.patch.previewSelection": "Update the preview to review the current file selection.", + "git.patch.rawDiff": "Patch contents", + "git.patch.save": "Save patch…", + "git.patch.saved": "Patch saved to {path}", + "git.patch.selectAll": "Select all", + "git.patch.selectNone": "Select none", + "git.patch.selectedFiles": "{count} files selected", + "git.patch.singleRepository": "Create Patch requires files from one repository", + "git.patch.source": "Changes to export", + "git.patch.source.staged": "Staged changes only", + "git.patch.source.unstaged": "Unstaged changes only", + "git.patch.source.workingTree": "All uncommitted changes", + "git.patch.target.indexAndWorktree": "Index and working tree", + "git.patch.target.worktree": "Working tree only", + "git.patch.targetCommit": "Target commit (after)", + "git.patch.twoCommits": "Select exactly two commits to export a patch", + "git.popStash": "Pop stash", + "git.previewFirstChange": "Preview first change", + "git.pull": "Pull", + "git.pullChanges": "Pull Changes", + "git.pullCommit": "Pull {count} commit", + "git.pullCommits": "Pull {count} commits", + "git.pullCount": "Pull {count}", + "git.pullDialog.description": "Bring remote changes into the current branch. Review the source branch and strategy before pulling.", + "git.pullDialog.from": "Pull from", + "git.pullDialog.fromPlaceholder": "Select a remote branch", + "git.pullDialog.loadFailed": "Unable to load pull options.", + "git.pullDialog.loading": "Loading pull options...", + "git.pullDialog.localChanges": "Local changes can block the pull. Commit or stash them first.", + "git.pullDialog.noBranches": "No remote branches found. Fetch to update them.", + "git.pullDialog.pull": "Pull", + "git.pullDialog.strategy.ffOnly": "Fast-forward only", + "git.pullDialog.strategy.ffOnlyHint": "Apply changes only when the branch can fast-forward, never creating a merge commit.", + "git.pullDialog.strategy.mergeHint": "Integrate the remote branch with a merge commit when the histories have diverged.", + "git.pullDialog.strategy.rebaseHint": "Replay your local commits on top of the remote branch for a linear history.", + "git.pullDialog.strategyLabel": "Strategy", + "git.pullDialog.strategySummary": "Strategy: {strategy}", + "git.pullDialog.title": "Pull", + "git.pullDialog.upstreamBadge": "Upstream", + "git.pullResult.dirty": "Commit or stash local changes before pulling.", + "git.pullResult.fetchFailed": "Fetch failed: {error}", + "git.pullResult.gitRejectedPull": "Git rejected the pull.", + "git.pullResult.inspectBranchFailed": "Unable to inspect the branch.", + "git.pullResult.mergeConflict": "Merge stopped for conflict resolution.", + "git.pullResult.merged": "Merged upstream changes successfully.", + "git.pullResult.noUpstream": "The current branch has no upstream. Set an upstream before pulling.", + "git.pullResult.preflightFailed": "Pull safety check failed: {error}", + "git.pullResult.pullFailed": "Pull failed: {error}", + "git.pullResult.pulled": "Pulled changes successfully.", + "git.pullResult.rebaseConflict": "Rebase stopped for conflict resolution.", + "git.pullResult.rebased": "Rebased onto upstream successfully.", + "git.pullResult.remoteUpdateFailed": "Unable to update remote references.", + "git.pullResult.stateChanged": "The branch changed while choosing a pull strategy. Review it and try again.", + "git.pullResult.strategyRequired": "Branches have diverged. Open Source Control and choose Merge or Rebase.", + "git.pullResult.upToDate": "The current branch is already up to date.", + "git.pulledChanges": "Changes pulled successfully.", + "git.pulling": "Pulling", + "git.pullingChanges": "Pulling changes...", + "git.push": "Push", + "git.pushChanges": "Push Changes", + "git.pushCommit": "Push {count} commit", + "git.pushCommits": "Push {count} commits", + "git.pushCount": "Push {count}", + "git.pushDialog.changedFiles": "Changed Files", + "git.pushDialog.chooseTargetDescription": "These branches track a branch with a different name. Choose where each push goes; nothing is committed until you confirm.", + "git.pushDialog.chooseTargetTitle": "Choose the push destination", + "git.pushDialog.commitHistory": "Commit History", + "git.pushDialog.createsRemoteBranch": "New remote branch", + "git.pushDialog.description": "Review the destination, commits, files, and tags before pushing.", + "git.pushDialog.divergentWarning": "\"{branch}\" tracks \"{upstream}\", a different branch. Choose where this push should go.", + "git.pushDialog.filesFailed": "Unable to load changed files.", + "git.pushDialog.forcePush": "Force Push", + "git.pushDialog.forcePushHint": "Force Push uses force-with-lease and refuses unseen remote updates.", + "git.pushDialog.help": "Push safety information", + "git.pushDialog.loading": "Loading push details...", + "git.pushDialog.loadingFiles": "Loading changed files...", + "git.pushDialog.moreActions": "More push actions", + "git.pushDialog.moreCommits": "More commits are not shown in this bounded preview.", + "git.pushDialog.newUpstream": "New upstream", + "git.pushDialog.noChangedFiles": "This commit has no changed files.", + "git.pushDialog.noCommitSelected": "No commit selected", + "git.pushDialog.noCommits": "No commits to push", + "git.pushDialog.previewFailed": "Unable to load push details.", + "git.pushDialog.publishSameName": "Publish to {remote}/{branch}", + "git.pushDialog.pushTags": "Push tags:", + "git.pushDialog.pushToUpstream": "Push to {branch}", + "git.pushDialog.tagScope": "Tag scope", + "git.pushDialog.tagsAll": "All", + "git.pushDialog.tagsReachable": "Reachable", + "git.pushDialog.title": "Push Commits to {repository}", + "git.pushDialog.upstreamConfigurationFailed": "Push succeeded, but the branch upstream could not be configured.", + "git.pushFailed": "Failed to push changes.", + "git.pushTag": "Push tag", + "git.pushTagTo": "Push tag to {remote}", + "git.pushedChanges": "Changes pushed successfully.", + "git.pushing": "Pushing", + "git.pushingChanges": "Pushing changes...", + "git.rebase": "Rebase", + "git.rebaseBranchTitle": "Rebase Branch", + "git.rebaseConfirm": "Rebase \"{current}\" onto \"{branch}\"? Conflicts may require resolution.", + "git.rebaseFailed": "Rebase failed.", + "git.rebaseInProgress": "Rebase in progress", + "git.rebaseOntoThis": "Rebase Current Branch onto This", + "git.rebasePlan.abortConfirm": "Abort this rebase and restore the original branch history?", + "git.rebasePlan.action.drop": "Drop", + "git.rebasePlan.action.edit": "Edit", + "git.rebasePlan.action.fixup": "Fixup", + "git.rebasePlan.action.pick": "Pick", + "git.rebasePlan.action.reword": "Reword", + "git.rebasePlan.action.squash": "Squash", + "git.rebasePlan.actionLabel": "Commit action", + "git.rebasePlan.amendConfirm": "Amend the paused commit with the currently staged changes and this complete message, then continue?", + "git.rebasePlan.amendContinue": "Amend & Continue", + "git.rebasePlan.conflicts": "Close this dialog, resolve and stage these files in Changes, then reopen this session to continue.", + "git.rebasePlan.description": "Review and reorder the commits after the selected base, oldest to newest. The base itself is kept.", + "git.rebasePlan.down": "Move Down", + "git.rebasePlan.editHelp": "Edit and stage files in Changes. Continue keeps the current commit; Amend & Continue replaces it with staged changes and the message below.", + "git.rebasePlan.fromHere": "Interactively Rebase from Here…", + "git.rebasePlan.help.drop": "Omit this commit and its changes.", + "git.rebasePlan.help.edit": "Pause after applying this commit so you can edit and amend it.", + "git.rebasePlan.help.fixup": "Combine with the preceding commit and keep its message.", + "git.rebasePlan.help.pick": "Keep this commit.", + "git.rebasePlan.help.reword": "Replace the complete commit message.", + "git.rebasePlan.help.squash": "Combine with the preceding commit using the complete message above.", + "git.rebasePlan.invalidPlan": "Squash and fixup need a preceding retained commit. Reword and squash require a message.", + "git.rebasePlan.noSession": "No interactive rebase session. Select a base commit in Log to prepare a plan.", + "git.rebasePlan.output": "Git output", + "git.rebasePlan.range": "{count} commits on {branch}; base {base} is excluded.", + "git.rebasePlan.reloadAmend": "Reload current commit message", + "git.rebasePlan.resultCount": "Planned history: {before} → {after} commits.", + "git.rebasePlan.session": "Interactive Rebase Session…", + "git.rebasePlan.skipConfirm": "Skip the current commit and continue? Its changes will be omitted.", + "git.rebasePlan.staleAmend": "The paused commit changed outside this editor. Your draft is preserved; copy it if needed, then reload the current message before amending.", + "git.rebasePlan.start": "Start Rebase", + "git.rebasePlan.status.aborted": "Rebase aborted", + "git.rebasePlan.status.completed": "Rebase completed", + "git.rebasePlan.status.conflict": "Conflicts need attention", + "git.rebasePlan.status.edit": "Paused for editing", + "git.rebasePlan.status.failed": "Rebase failed", + "git.rebasePlan.status.interrupted": "Rebase interrupted; inspect the session before continuing", + "git.rebasePlan.status.paused": "Paused", + "git.rebasePlan.status.starting": "Starting", + "git.rebasePlan.title": "Interactive Rebase", + "git.rebasePlan.up": "Move Up", + "git.refresh": "Refresh", + "git.refreshAfterDeleteFailed": "Files were deleted, but Source Control could not refresh.", + "git.refreshAria": "Refresh git status", + "git.refreshStatus": "Refresh Status", + "git.refreshingStatus": "Refreshing Git status...", + "git.remoteAhead": "Remote ahead", + "git.remoteChangesAction": "{action} remote changes", + "git.remoteCount": "{count} remote{plural}", + "git.removeRemote": "Remove remote", + "git.removed": "Removed", + "git.removedImage": "Removed Image", + "git.repositories": "Repositories", + "git.repositoriesCount": "{count} repositories", + "git.repository": "Repository", + "git.repositoryCount": "{count} repository", + "git.repositoryInitialized": "Repository initialized.", + "git.resetHard": "Hard - discard changes", + "git.resetMixed": "Mixed - keep files", + "git.resetSoft": "Soft - keep staged", + "git.resetToCommit": "Reset to This Commit", + "git.resetToCommitPrompt": "Choose how to reset the current branch to {hash}. Hard reset discards working tree changes.", + "git.resizeCommitArea": "Resize commit area", + "git.resolveConflicts": "Resolve {count} conflicted file{plural}, stage them, then continue.", + "git.resolveConflictsFirst": "Resolve the conflicts first: {paths}", + "git.revertCommit": "Revert Commit", + "git.revertCommitConfirm": "Create a new commit that reverts {hash} \"{message}\"?", + "git.revertInProgress": "Revert in progress", + "git.rollback": "Rollback", + "git.rollbackPathsConfirm": "Roll back changes in {count} selected path(s)? This cannot be undone.", + "git.saveCommitMessage": "Save Message", + "git.saved.changed": "The repository changed. The shelf was retained; review the working tree before retrying.", + "git.saved.conflicts": "Resolve conflicts before shelving changes.", + "git.saved.drop": "Delete saved changes?", + "git.saved.empty": "No saved changes", + "git.saved.failed": "Saved changes operation failed", + "git.saved.noChanges": "There are no changes to shelve", + "git.saved.restore": "Restore", + "git.saved.shelve": "Shelve…", + "git.saved.shelves": "Lithe Shelves", + "git.saved.staged": "Staged", + "git.saved.stashes": "Git Stashes", + "git.saved.tab": "Shelf", + "git.saved.unsaved": "Save or close unsaved files before shelving or restoring changes.", + "git.saved.untracked": "Include untracked files", + "git.saved.worktree": "Working tree", + "git.searchBranches": "Search branches...", + "git.searchBranchesAria": "Search branches", + "git.searchCommits": "Search commits...", + "git.searchRemotes": "Search remotes...", + "git.searchStashes": "Search stashes...", + "git.searchTags": "Search tags...", + "git.searchWorktrees": "Search worktrees...", + "git.selectFilesAndSpecifyCommitMessage": "Select files to commit and specify commit message", + "git.selectFilesToCommit": "Select files to commit", + "git.selectRepository": "Select Repository", + "git.selectSingleRepositoryForCommit": "Select files from the active repository to commit.", + "git.selectSingleRepositoryForStash": "Select changes from one repository to stash.", + "git.selectedFilesCount": "{count} files selected", + "git.selectedFolderNotRepo": "Selected folder is not inside a Git repository.", + "git.selecting": "Selecting...", + "git.selectorSections": "Git selector sections", + "git.setup.clear": "Clear Override", + "git.setup.effective": "Effective value", + "git.setup.email": "Committer email", + "git.setup.errorDetails": "Show Git error details", + "git.setup.firstCommit": "Stage your files in Changes, enter a commit message, and create the first commit.", + "git.setup.global": "Global Git configuration", + "git.setup.globalDescription": "Global identity applies to other repositories unless they override it.", + "git.setup.identity": "Commit identity", + "git.setup.identityDescription": "Git records this name and email in new commits. These settings do not change existing commits.", + "git.setup.initialize": "Initialize Git Repository", + "git.setup.initializeDescription": "Files will stay unchanged. Nothing will be staged or committed automatically.", + "git.setup.initializeFirst": "Initialize this project from Git before saving repository-specific identity.", + "git.setup.initializeHint": "Initialize Git to track changes and create your first commit.", + "git.setup.initializeTitle": "Initialize Git in this project folder?", + "git.setup.loading": "Checking Git repository…", + "git.setup.local": "Current repository", + "git.setup.localDescription": "Repository identity overrides inherited global values. Clear an override to use inherited settings.", + "git.setup.missingIdentity": "Set your Git name and email in Settings before committing.", + "git.setup.name": "Committer name", + "git.setup.noCommits": "This branch has no commits yet", + "git.setup.notRepository": "This project is not a Git repository", + "git.setup.openChanges": "Open Changes", + "git.setup.openProject": "Open a project to configure Git identity.", + "git.setup.openSettings": "Open Git Settings", + "git.setup.ownershipDenied": "Git does not trust this repository's directory ownership. Shared folders can trigger this check. Check the directory permissions and Git safe.directory configuration, then retry.", + "git.setup.permissionDenied": "Git does not have permission to access this repository folder. Check the current account's folder permissions, then retry.", + "git.setup.readFailed": "Git cannot read this repository", + "git.setup.readFailedHint": "Git returned an error while reading the repository. Review the details, then retry.", + "git.setup.reload": "Reload Git Configuration", + "git.setup.save": "Save", + "git.setup.saved": "Git configuration saved", + "git.setup.scope": "Configuration scope", + "git.setup.separateSave": "Save each field separately. Switching scope reloads the saved values.", + "git.setup.unconfigured": "No effective value configured", + "git.showBranchDiff": "Show Branch Diff", + "git.showDiff": "Show Diff", + "git.signTag": "Sign tag", + "git.skipCommit": "Skip Commit", + "git.specifyCommitMessage": "Specify commit message", + "git.splitDiffView": "Split diff view", + "git.splitView": "Split view", + "git.squash": "Squash", + "git.squashCommits": "Squash Commits", + "git.squashCommitsPrompt": "Enter the message for the {count} squashed commits.", + "git.stageAllChanges": "Stage all changes", + "git.stageFile": "Stage File", + "git.stageFileNamed": "Stage {name}", + "git.stageFilesFailed": "Failed to stage files", + "git.stageFolder": "Stage folder {name}", + "git.staged": "Staged", + "git.stagedFilesCount": "{count} staged", + "git.stash": "Stash", + "git.stashAllUnstaged": "Stash All Unstaged", + "git.stashAllUnstagedChanges": "Stash all unstaged changes", + "git.stashAndSwitchFailed": "Failed to switch branch after stashing", + "git.stashAndSwitchSuccess": "Changes stashed and branch switched successfully", + "git.stashChanges": "Stash Changes", + "git.stashCount": "{count} stash", + "git.stashFailed": "Failed to stash changes", + "git.stashFile": "Stash File", + "git.stashFileDefault": "Stash {path}", + "git.stashMessage": "Stash message...", + "git.stashMessageDefaultAll": "Message (default: Stash all unstaged changes)", + "git.stashMessageDefaultFile": "Message (default: Stash {name})", + "git.stashMessageDefaultSelection": "Message (default: Stash selected changes)", + "git.stashSelected": "Stash Selected Changes", + "git.stashSelectedDefault": "Stash selected changes", + "git.stashesCount": "{count} stashes", + "git.stashing": "Stashing...", + "git.statusLoadFailed": "Unable to load Git data. Retry to refresh the repository.", + "git.stoppedBeforeCompletion": "{branch} stopped before completion. Continue, skip, or abort from the changes list.", + "git.switchingTo": "Switching to {branch}", + "git.tag.message": "Message", + "git.tag.remote": "Tag remote", + "git.tagCount": "{count} tag{plural}", + "git.tagName": "Tag name", + "git.target": "Target", + "git.uncommittedChanges": "Uncommitted Changes", + "git.uncommittedWouldOverwrite": "Uncommitted changes would be overwritten: {listed}{more}. Stash or commit them first.", + "git.undoCommit": "Undo Commit", + "git.unifiedDiffView": "Unified diff view", + "git.unifiedView": "Unified view", + "git.unstageAllChanges": "Unstage all changes", + "git.unstageFile": "Unstage File", + "git.unstageFileNamed": "Unstage {name}", + "git.unstageFilesFailed": "Failed to unstage files", + "git.unstageFolder": "Unstage folder {name}", + "git.unstaged": "Unstaged", + "git.untrackedDirectorySummary": "{path} — {count} untracked files. Expand to list its contents.", + "git.unversionedFilesNode": "Unversioned Files", + "git.url": "URL", + "git.verifyConflictMarkersFailed": "Unable to verify conflict markers. Retry before committing.", + "git.viewDiff": "View Diff", + "git.viewOptions": "View Options", + "git.viewStashes": "View Stashes", + "git.workspaceCommit.attention": "Repository needs attention", + "git.workspaceCommit.changed": "The plan changed. Review it again before continuing.", + "git.workspaceCommit.committed": "Committed", + "git.workspaceCommit.committedAndPushed": "Committed and pushed", + "git.workspaceCommit.committedPushPending": "Committed; push pending", + "git.workspaceCommit.dirtySubmodule": "Uncommitted changes in submodule; commit its files in that repository", + "git.workspaceCommit.dismiss": "Dismiss results", + "git.workspaceCommit.headAdvanced": "HEAD advanced; review before continuing", + "git.workspaceCommit.notIncluded": "Not included in the updated plan", + "git.workspaceCommit.order": "Repositories run in the order shown. Completed commits are kept if a later step fails.", + "git.workspaceCommit.outcomeUnknown": "Commit outcome unknown; inspect before retrying", + "git.workspaceCommit.parents": "Update parent repository references", + "git.workspaceCommit.pending": "Pending", + "git.workspaceCommit.pushFailed": "Push failed", + "git.workspaceCommit.pushOnly": "Push existing commit only", + "git.workspaceCommit.reference": "Update reference: {path} → {child}", + "git.workspaceCommit.retry": "Review and retry unfinished work", + "git.workspaceCommit.review": "Review workspace commit", + "git.workspaceCommit.stop": "Stop", + "git.workspaceCommit.waitingForSubmodule": "Waiting for submodule", + "git.worktreeCount": "{count} worktree", + "git.worktreeDialog.after.currentWindow": "Open in the current window", + "git.worktreeDialog.after.newWindow": "Open in a new window", + "git.worktreeDialog.after.stay": "Stay in this workspace", + "git.worktreeDialog.afterCreate": "After creating", + "git.worktreeDialog.bare": "Bare repository", + "git.worktreeDialog.branchInUse": "already in a worktree", + "git.worktreeDialog.branchName": "New branch name", + "git.worktreeDialog.description": "Create isolated working directories or open an existing worktree. Removal always checks for uncommitted changes.", + "git.worktreeDialog.destination": "Destination directory", + "git.worktreeDialog.locked": "Locked", + "git.worktreeDialog.manage": "Manage Worktrees…", + "git.worktreeDialog.missing": "Missing / prunable", + "git.worktreeDialog.mode": "Worktree mode", + "git.worktreeDialog.mode.detached": "Detached HEAD", + "git.worktreeDialog.mode.existingBranch": "Use an existing local branch", + "git.worktreeDialog.mode.newBranch": "Create a new branch", + "git.worktreeDialog.noCheckout": "Do not populate files yet (--no-checkout)", + "git.worktreeDialog.openCurrent": "Open in Current Window", + "git.worktreeDialog.openFailed": "Could not open the worktree.", + "git.worktreeDialog.openNew": "Open in New Window", + "git.worktreeDialog.primary": "Primary worktree", + "git.worktreeDialog.reference": "Starting reference", + "git.worktreeDialog.remove": "Remove Worktree", + "git.worktreeDialog.removeConfirm": "Remove the worktree at {path}? Only a clean, unlocked worktree can be removed. Its branch is kept.", + "git.worktreeDialog.revision": "Commit revision (optional)", + "git.worktreeDialog.revisionHint": "Use the selected reference if empty", + "git.worktrees": "Worktrees", + "git.worktreesCount": "{count} worktrees", + "git.zoomIn": "Zoom in", + "git.zoomOut": "Zoom out", + "github.actionRunActions": "Action run actions", + "github.actions": "Actions", + "github.activity": "Activity", + "github.addAssignee": "Add assignee", + "github.addComment": "Add comment", + "github.added": "Added", + "github.aiDraftUnusable": "AI did not return a usable draft.", + "github.all": "All", + "github.allIssues": "All Issues", + "github.allRuns": "All Runs", + "github.approve": "Approve", + "github.approvePullRequest": "Approve pull request", + "github.assigned": "Assigned", + "github.assignees": "Assignees", + "github.author": "Author", + "github.base": "Base", + "github.behindBase": "Behind base", + "github.blockCommands": "block commands", + "github.blocked": "Blocked", + "github.branch": "Branch", + "github.branchNameCopied": "Branch name copied", + "github.branchNameUnavailable": "Branch name is not available.", + "github.branches": "Branches", + "github.browseRepository": "Browse Repository", + "github.cancel": "Cancel", + "github.cancelled": "Cancelled", + "github.changedFiles": "Changed files", + "github.changes": "Changes", + "github.changesRequested": "Changes requested", + "github.changesRequestedLower": "changes requested", + "github.check": "Check", + "github.checkedOutPr": "Checked out PR #{number}", + "github.checkingAccount": "Checking GitHub account", + "github.checkoutBranch": "Checkout Branch", + "github.checks": "Checks", + "github.checksPassed": "{count} checks passed", + "github.checksPassedWithConflicts": "{count} checks passed · has conflicts", + "github.checksPending": "{count} pending", + "github.chooseBase": "Choose base", + "github.chooseBaseBranch": "Choose base branch", + "github.chooseBranchOrTag": "Choose branch or tag", + "github.chooseHead": "Choose head", + "github.chooseHeadBranch": "Choose head branch", + "github.chooseLabels": "Choose labels", + "github.chooseWorkflow": "Choose workflow", + "github.chooseWorkflowDescription": "Choose a workflow and the branch or tag it should run against.", + "github.chooseWorkflowRef": "Choose workflow ref", + "github.close": "Close", + "github.closeAsNotPlanned": "Close as not planned", + "github.closePr": "Close PR", + "github.closePullRequest": "Close pull request", + "github.closePullRequestConfirm": "Close this pull request without merging it?", + "github.closedAgo": "Closed {time}", + "github.closedBy": "Closed by {author}", + "github.closedIssues": "Closed Issues", + "github.closing": "Closing", + "github.collapseDiffForFile": "Collapse diff for {path}", + "github.comment": "Comment", + "github.commentActions": "Comment actions", + "github.commentAdded": "Comment added", + "github.commentDeleted": "Comment deleted", + "github.commentUpdated": "Comment updated", + "github.commenting": "Commenting", + "github.commentsCount": "{count} comments", + "github.commit": "Commit", + "github.commitDiffUnavailable": "Commit diff is not available.", + "github.commitShaCopied": "Commit SHA copied", + "github.connectGitHub": "Connect GitHub", + "github.conversationLocked": "This conversation is locked", + "github.copyBranchName": "Copy branch name", + "github.copyCommitSha": "Copy commit SHA", + "github.copyFailed": "Failed to copy: {error}", + "github.copyFullCommitSha": "Copy full commit SHA", + "github.copyJobLogs": "Copy job logs", + "github.copyLink": "Copy link", + "github.copyTitle": "Copy Title", + "github.couldNotLoadNotifications": "Could not load notifications", + "github.couldNotMatchNotificationActionsRun": "Could not match this notification to a GitHub Actions run.", + "github.create": "Create", + "github.createDraftPullRequest": "Create as draft pull request", + "github.created": "Created", + "github.creating": "Creating", + "github.delete": "Delete", + "github.deleteComment": "Delete comment", + "github.deleteCommentConfirm": "Delete this comment permanently?", + "github.deleted": "Deleted", + "github.describeRequestedChanges": "Describe the requested changes...", + "github.description": "Description", + "github.draft": "Draft", + "github.edit": "Edit", + "github.editCommentPlaceholder": "Edit comment...", + "github.editDescription": "Edit description", + "github.editTitle": "Edit title", + "github.edited": "edited", + "github.enableSidebarSections": "Enable GitHub sidebar sections in Settings -> Appearance.", + "github.event": "Event", + "github.expandDiffForFile": "Expand diff for {path}", + "github.failed": "Failed", + "github.failedCheckoutPr": "Failed to checkout PR #{number}", + "github.failedClosePullRequest": "Failed to close pull request", + "github.failedResolveActionsRun": "Failed to resolve the GitHub Actions run", + "github.failedUpdatePullRequest": "Failed to update pull request", + "github.filesChanged": "{count} files changed", + "github.filesCount": "Files {count}", + "github.filterLabel": "Filter: {label}", + "github.filterSection": "Filter GitHub {section}", + "github.filteredFilesCount": "{shown} of {total} files", + "github.fromPath": "from {path}", + "github.generate": "Generate", + "github.generating": "Generating", + "github.githubUsername": "GitHub username", + "github.hasConflicts": "Has conflicts", + "github.head": "Head", + "github.hideChangedFiles": "Hide changed files", + "github.hideLogSearch": "Hide log search", + "github.inProgress": "In Progress", + "github.inRepository": "in {repository}", + "github.invalidDiffFilePath": "Invalid file path in diff.", + "github.issueActions": "Issue actions", + "github.issueClosed": "Issue closed", + "github.issueCreated": "Issue created", + "github.issueDraftGenerated": "Issue draft generated", + "github.issueLinkCopied": "Issue link copied", + "github.issueLinkUnavailable": "Issue link is not available.", + "github.issueLocked": "Issue locked", + "github.issueMilestone": "Issue milestone", + "github.issueReopened": "Issue reopened", + "github.issueTitle": "Issue title", + "github.issueType": "Issue type", + "github.issueUnlocked": "Issue unlocked", + "github.issueUpdated": "Issue updated", + "github.issues": "Issues", + "github.itemByAuthor": "#{number} by {author}", + "github.jobsCount": "{count} jobs", + "github.jobsFailed": "{count} failed", + "github.jobsRunning": "{count} running", + "github.labels": "Labels", + "github.labelsCount": "{count} labels", + "github.leaveComment": "Leave a comment...", + "github.linked": "Linked", + "github.linkedIssues": "Linked issues", + "github.loading": "Loading...", + "github.loadingActionRun": "Loading action run", + "github.loadingActivity": "Loading activity", + "github.loadingDiff": "Loading diff", + "github.loadingFileDiff": "Loading file diff", + "github.loadingIssue": "Loading issue", + "github.loadingIssues": "Loading issues", + "github.loadingJobLogs": "Loading job logs", + "github.loadingLabels": "Loading labels...", + "github.loadingLogs": "Loading logs", + "github.loadingMoreActivityItems": "Loading {count} more activity items...", + "github.loadingMoreComments": "Loading {count} more comments", + "github.loadingMoreJobs": "Loading {count} more jobs", + "github.loadingNotifications": "Loading notifications", + "github.loadingPr": "Loading PR #{number}", + "github.loadingPullRequests": "Loading pull requests", + "github.loadingWorkflowRuns": "Loading workflow runs", + "github.lockAsOffTopic": "Lock as off-topic", + "github.lockAsResolved": "Lock as resolved", + "github.lockAsSpam": "Lock as spam", + "github.lockAsTooHeated": "Lock as too heated", + "github.locked": "Locked", + "github.lockedAs": "Locked as {reason}", + "github.logsAvailableAfterJobFinishes": "Logs are available after this job finishes.", + "github.markdownCommand.bulletList": "Bulleted list", + "github.markdownCommand.codeBlock": "Code block", + "github.markdownCommand.divider": "Divider", + "github.markdownCommand.heading1": "Heading 1", + "github.markdownCommand.heading2": "Heading 2", + "github.markdownCommand.heading3": "Heading 3", + "github.markdownCommand.numberedList": "Numbered list", + "github.markdownCommand.paragraph": "Text", + "github.markdownCommand.quote": "Quote", + "github.markdownCommand.table": "Table", + "github.markdownCommand.taskList": "Task list", + "github.markdownCommandGroup.basicBlocks": "Basic blocks", + "github.markdownCommandGroup.insert": "Insert", + "github.markdownCommandGroup.lists": "Lists", + "github.markdownEditor": "Markdown editor", + "github.markdownRenderNeedsFix": "This Markdown needs a small fix before rich text can render it: {error}", + "github.merge": "Merge", + "github.mergePullRequest": "Merge pull request", + "github.milestone": "Milestone", + "github.modified": "Modified", + "github.myPrs": "My PRs", + "github.newIssue": "New Issue", + "github.newPullRequest": "New Pull Request", + "github.noActivity": "No activity", + "github.noAssignees": "No assignees", + "github.noChecksReported": "No checks reported", + "github.noCommentsYet": "No comments yet", + "github.noDescriptionProvided": "No description provided", + "github.noDiffHunksForFile": "No diff hunks available for this file.", + "github.noFileChanges": "No file changes", + "github.noFilesMatchFilters": "No files match your filters", + "github.noIssues": "No issues", + "github.noLabels": "No labels", + "github.noLogLinesMatchSearch": "No log lines match this search.", + "github.noLogsAvailableForStep": "No logs available for this step.", + "github.noMatchingBlocks": "No matching blocks", + "github.noMatchingIssues": "No matching issues", + "github.noMatchingLabels": "No matching labels", + "github.noMatchingPullRequests": "No matching pull requests", + "github.noMatchingWorkflowRuns": "No matching workflow runs", + "github.noMilestone": "No milestone", + "github.noPullRequests": "No pull requests", + "github.noRepositorySelected": "No repository selected", + "github.noReviewers": "No reviewers", + "github.noStepsReported": "No steps reported.", + "github.noType": "No type", + "github.noUnreadNotifications": "No unread notifications.", + "github.noWorkflowRuns": "No workflow runs", + "github.none": "None", + "github.notAuthenticated": "GitHub not authenticated", + "github.notAuthenticatedDescription": "Connect GitHub, then retry this view.", + "github.notConnected": "GitHub not connected", + "github.notConnectedDescription": "Connect your account to use PRs, Issues, Actions, and Releases.", + "github.notifications": "Notifications", + "github.notificationsCount": "Notifications ({count})", + "github.open": "Open", + "github.openAttachment": "Open attachment", + "github.openChangedFilesForPr": "Open changed files for pull request #{number}", + "github.openCommitInBrowser": "Open commit in browser", + "github.openCommitOnGitHub": "Open commit on GitHub", + "github.openCommitSha": "Open commit {sha}", + "github.openIssues": "Open Issues", + "github.openOnGitHub": "Open on GitHub", + "github.openPr": "Open PR", + "github.openPrs": "Open PRs", + "github.openedAgo": "Opened {time}", + "github.optionalReviewNote": "Optional review note...", + "github.overview": "Overview", + "github.partialChecksPassed": "{passed}/{total} passed", + "github.patchErrors": "{count} patch errors", + "github.pendingCheck": "Pending check", + "github.pendingChecks": "Pending checks", + "github.peopleCount": "{count} people", + "github.prDraftGenerated": "PR draft generated", + "github.prLinkCopied": "PR link copied", + "github.prLinkUnavailable": "PR link is not available.", + "github.pullRequestActionFailed": "Pull request action failed", + "github.pullRequestActions": "Pull request actions", + "github.pullRequestApproved": "Pull request approved", + "github.pullRequestClosed": "Pull request closed", + "github.pullRequestCreated": "Pull request created", + "github.pullRequestMerged": "Pull request merged", + "github.pullRequestNumber": "Pull request #{number}", + "github.pullRequestTitle": "Pull request title", + "github.pullRequestUpdated": "Pull request updated", + "github.pullRequests": "Pull Requests", + "github.queued": "Queued", + "github.readyToMerge": "Ready to merge", + "github.ref": "Ref", + "github.refresh": "Refresh", + "github.refreshIssues": "Refresh Issues", + "github.refreshJobLogs": "Refresh job logs", + "github.refreshPullRequests": "Refresh Pull Requests", + "github.refreshWorkflowRuns": "Refresh Workflow Runs", + "github.refreshing": "Refreshing...", + "github.removeAssignee": "Remove @{assignee}", + "github.renamed": "Renamed", + "github.reopen": "Reopen", + "github.reopening": "Reopening", + "github.repositoryNotGitRepo": "Repository is not a Git repository", + "github.requestChanges": "Request changes", + "github.retry": "Retry", + "github.retryAuthCheck": "Retry GitHub authentication check", + "github.reviewRequests": "Review Requests", + "github.reviewRequired": "review required", + "github.reviewRequiredTitle": "Review required", + "github.reviewers": "Reviewers", + "github.run": "Run", + "github.runLinkCopied": "Run link copied", + "github.runLinkUnavailable": "Run link is not available.", + "github.runNumber": "Run #{number}", + "github.runWorkflow": "Run Workflow", + "github.running": "Running", + "github.save": "Save", + "github.saving": "Saving", + "github.savingTitle": "Saving title", + "github.searchFiles": "Search files...", + "github.searchGitHub": "Search GitHub", + "github.searchLogs": "Search logs", + "github.selectFile": "Select a file", + "github.selectGitRepositoryFolder": "Select another folder that contains a `.git` repository.", + "github.selectedFolderNotGitRepo": "Selected folder is not inside a Git repository.", + "github.selecting": "Selecting...", + "github.showChangedFiles": "Show changed files", + "github.skipped": "Skipped", + "github.state": "State", + "github.status": "Status", + "github.stepLogsCopied": "Step logs copied", + "github.stepLogsNotLoaded": "Step logs are not loaded.", + "github.success": "Success", + "github.successful": "Successful", + "github.temporarilyUnavailable": "GitHub is temporarily unavailable", + "github.textFormatting": "text formatting", + "github.title": "Title", + "github.tryAgain": "Try again", + "github.type": "Type", + "github.typeGitHubUsernames": "Type one or more GitHub usernames, then press Enter.", + "github.unassigned": "Unassigned", + "github.unknown": "Unknown", + "github.unlockConversation": "Unlock conversation", + "github.updated": "Updated", + "github.updatedAgo": "Updated {time}", + "github.updatingComment": "Updating comment", + "github.workflow": "Workflow", + "github.workflowQueued": "Workflow queued", + "github.working": "Working", + "github.writeComment": "Write a comment...", + "github.writeDescription": "Write a description...", + "github.writeReview": "Write a review...", + "htmlPreview.noActiveBuffer": "No active buffer", + "htmlPreview.title": "HTML Preview", + "ideImport.importFrom": "Import from...", + "ideImport.importSettings": "Import Settings", + "ideImport.importWhat": "Import what...", + "ideImport.importedSelectedSettings": "Imported selected settings from {name}", + "ideImport.noOptionMatches": "No import option matches \"{query}\".", + "ideImport.noRecentProjects": "No recent projects found in {name}", + "ideImport.noSourceMatches": "No import source matches \"{query}\".", + "ideImport.recentProjects": "Recent Projects", + "ideImport.scanFailed": "Could not scan installed editors: {error}", + "ideImport.scanningInstalledEditors": "Scanning installed editors...", + "image.balanced": "Balanced", + "image.calculating": "Calculating...", + "image.convert": "Convert", + "image.convertFailed": "Failed to convert format", + "image.convertFormat": "Convert format", + "image.convertMenu": "Convert", + "image.convertToFormat": "Convert to {format}", + "image.convertToFormatEllipsis": "Convert to {format}...", + "image.currentFile": "Current", + "image.edit": "Edit", + "image.editOperations": "Edit operations", + "image.estimatedSize": "Estimated Size:", + "image.flipFailed": "Failed to flip image", + "image.flipHorizontal": "Flip Horizontal", + "image.flipVertical": "Flip Vertical", + "image.heightPx": "Height (px)", + "image.highQuality": "High Quality", + "image.losslessTransparency": "Lossless compression, supports transparency", + "image.lossySmaller": "Lossy compression, smaller file sizes", + "image.maintainAspectRatio": "Maintain aspect ratio", + "image.modernBestCompression": "Modern format, best compression", + "image.nextGenCompression": "Next-gen format, excellent compression", + "image.originalDimensions": "Original: {width} × {height}px", + "image.qualitySetting": "Quality Setting", + "image.recommended": "Recommended", + "image.recommendedBadge": "RECOMMENDED", + "image.resize": "Resize", + "image.resizeEllipsis": "Resize...", + "image.resizeFailed": "Failed to resize image", + "image.resizeImage": "Resize Image", + "image.rotate180": "Rotate 180°", + "image.rotate180Deg": "Rotate 180deg", + "image.rotateCcw": "Rotate 90° CCW", + "image.rotateCcwDeg": "Rotate 90deg CCW", + "image.rotateCw": "Rotate 90° CW", + "image.rotateCwDeg": "Rotate 90deg CW", + "image.rotateFailed": "Failed to rotate image", + "image.saveChanges": "Save changes", + "image.smallSize": "Small Size", + "image.undo": "Undo", + "image.undoLastOperation": "Undo last operation", + "image.updateFailed": "Failed to update image", + "image.widthPx": "Width (px)", + "inlineEdit.addApiKey": "Add API key", + "inlineEdit.apiKeyRequired": "{provider} API key is required for inline edit.", + "inlineEdit.applied": "Inline edit applied.", + "inlineEdit.apply": "Apply inline edit", + "inlineEdit.applying": "Applying inline edit", + "inlineEdit.backToProviders": "Back to providers", + "inlineEdit.close": "Close inline edit", + "inlineEdit.description": "Describe the code change, then press Enter to apply or Escape to close.", + "inlineEdit.editCurrentLine": "Edit current line...", + "inlineEdit.editSelection": "Edit selection...", + "inlineEdit.emptyResult": "Inline edit returned an empty result.", + "inlineEdit.failedTryAgain": "Inline edit failed. Please try again.", + "inlineEdit.instruction": "Inline edit instruction", + "inlineEdit.loadingModels": "Loading models...", + "inlineEdit.model": "Inline edit model", + "inlineEdit.noModels": "No models found", + "inlineEdit.noProviders": "No providers found", + "inlineEdit.pickProvider": "Pick a provider...", + "inlineEdit.requiresOpenBuffer": "Inline edit requires an open buffer.", + "inlineEdit.searchModels": "Search models...", + "inlineEdit.selectModel": "Please select an inline edit model.", + "inlineEdit.targetUnavailable": "Could not determine an inline edit target.", + "inlineEdit.title": "Inline edit", + "javaEntry.abstractClass": "Abstract class", + "javaEntry.annotation": "Annotation", + "javaEntry.cancel": "Cancel", + "javaEntry.class": "Class", + "javaEntry.create": "Create", + "javaEntry.enum": "Enum", + "javaEntry.exception": "Exception", + "javaEntry.exists": "A file or directory already exists at this destination.", + "javaEntry.failed": "Could not create the Java entry: {message}", + "javaEntry.interface": "Interface", + "javaEntry.invalidName": "Enter a valid Java identifier (not a keyword or reserved Windows name).", + "javaEntry.invalidPackage": "Use dot-separated Java identifiers, for example com.example.app.", + "javaEntry.newMenu": "New", + "javaEntry.newPackage": "Java Package…", + "javaEntry.newType": "Java Class…", + "javaEntry.package": "Package name", + "javaEntry.packageHint": "Package is inferred from conventional source folders. Confirm it for custom source roots.", + "javaEntry.packageInfo": "package-info.java", + "javaEntry.record": "Record (Java 16+)", + "javaEntry.title": "New Java Class", + "javaEntry.typeKind": "Kind", + "javaEntry.typeName": "Name", + "javaEntry.withMain": "Generate main method", + "keybindings.cancelRecording": "Cancel recording", + "keybindings.categories.Debug": "Debug", + "keybindings.categories.Edit": "Edit", + "keybindings.categories.Selection": "Selection", + "keybindings.categories.Workbench": "Workbench", + "keybindings.commands.database.connect.title": "Show Databases", + "keybindings.commands.debug.start.title": "Start Debugging", + "keybindings.commands.debug.stop.title": "Stop Debugging", + "keybindings.commands.debug.toggleBreakpoint.title": "Toggle Breakpoint", + "keybindings.commands.editor.copy.title": "Copy", + "keybindings.commands.editor.copyLineDown.title": "Copy Line Down", + "keybindings.commands.editor.copyLineUp.title": "Copy Line Up", + "keybindings.commands.editor.cut.title": "Cut", + "keybindings.commands.editor.deleteLine.title": "Delete Line", + "keybindings.commands.editor.duplicateLine.title": "Duplicate Line", + "keybindings.commands.editor.expandSelection.title": "Expand Selection", + "keybindings.commands.editor.foldAll.title": "Fold All", + "keybindings.commands.editor.foldLevel.title": "Fold Level {level}", + "keybindings.commands.editor.formatDocument.title": "Format Document", + "keybindings.commands.editor.formatSelection.title": "Format Selection", + "keybindings.commands.editor.goToBracket.title": "Go to Bracket", + "keybindings.commands.editor.goToDefinition.title": "Go to Definition", + "keybindings.commands.editor.goToImplementation.title": "Go to Implementation", + "keybindings.commands.editor.goToLine.title": "Go to Line", + "keybindings.commands.editor.goToReferences.title": "Go to References", + "keybindings.commands.editor.goToTypeDefinition.title": "Go to Type Definition", + "keybindings.commands.editor.inlineEdit.title": "Agent Inline Edit", + "keybindings.commands.editor.insertCursorAbove.title": "Add Cursor Above", + "keybindings.commands.editor.insertCursorBelow.title": "Add Cursor Below", + "keybindings.commands.editor.insertCursorsAtLineEnds.title": "Add Cursors to Line Ends", + "keybindings.commands.editor.moveLineDown.title": "Move Line Down", + "keybindings.commands.editor.moveLineUp.title": "Move Line Up", + "keybindings.commands.editor.paste.title": "Paste", + "keybindings.commands.editor.quickFix.title": "Quick Fix", + "keybindings.commands.editor.redo.title": "Redo", + "keybindings.commands.editor.removeBrackets.title": "Remove Brackets", + "keybindings.commands.editor.removeSecondaryCursors.title": "Remove Secondary Cursors", + "keybindings.commands.editor.renameSymbol.title": "Rename Symbol", + "keybindings.commands.editor.selectAll.title": "Select All", + "keybindings.commands.editor.selectAllOccurrences.title": "Select All Occurrences of Find Match", + "keybindings.commands.editor.selectNextOccurrence.title": "Add Selection To Next Find Match", + "keybindings.commands.editor.selectPreviousOccurrence.title": "Add Selection To Previous Find Match", + "keybindings.commands.editor.selectToBracket.title": "Select to Bracket", + "keybindings.commands.editor.showHover.title": "Show Hover", + "keybindings.commands.editor.showOutline.title": "Go to Symbol in Editor", + "keybindings.commands.editor.shrinkSelection.title": "Shrink Selection", + "keybindings.commands.editor.toggleComment.title": "Toggle Comment", + "keybindings.commands.editor.toggleLineNumbers.title": "Toggle Line Numbers", + "keybindings.commands.editor.toggleRenderWhitespace.title": "Toggle Render Whitespace", + "keybindings.commands.editor.toggleWordWrap.title": "Toggle Word Wrap", + "keybindings.commands.editor.triggerParameterHints.title": "Trigger Parameter Hints", + "keybindings.commands.editor.triggerSuggest.title": "Trigger Suggest", + "keybindings.commands.editor.undo.title": "Undo", + "keybindings.commands.editor.unfoldAll.title": "Unfold All", + "keybindings.commands.file.close.title": "Close Tab", + "keybindings.commands.file.closeAll.title": "Close All Tabs", + "keybindings.commands.file.closeOthers.title": "Close Other Tabs", + "keybindings.commands.file.closeSaved.title": "Close Saved Tabs", + "keybindings.commands.file.closeTabsToLeft.title": "Close Tabs to the Left", + "keybindings.commands.file.closeTabsToRight.title": "Close Tabs to the Right", + "keybindings.commands.file.localHistory.title": "Show Local History", + "keybindings.commands.file.new.title": "New File", + "keybindings.commands.file.open.title": "Open Project", + "keybindings.commands.file.openSelected.title": "Open Selected File", + "keybindings.commands.file.quickOpen.title": "Quick Open", + "keybindings.commands.file.reopenClosed.title": "Reopen Closed Tab", + "keybindings.commands.file.revert.title": "Revert File", + "keybindings.commands.file.save.title": "Save File", + "keybindings.commands.file.saveAll.title": "Save All", + "keybindings.commands.file.saveAs.title": "Save File As", + "keybindings.commands.help.showWhatsNew.title": "What's New", + "keybindings.commands.lsp.restartAllServers.title": "Language Server: Restart All Servers", + "keybindings.commands.lsp.stopAllServers.title": "Language Server: Stop All Servers", + "keybindings.commands.navigation.goBack.title": "Go Back", + "keybindings.commands.navigation.goForward.title": "Go Forward", + "keybindings.commands.terminal.close.title": "Close Terminal", + "keybindings.commands.terminal.find.title": "Find in Terminal", + "keybindings.commands.terminal.new.title": "New Terminal", + "keybindings.commands.terminal.split.title": "Split Terminal Right", + "keybindings.commands.terminal.splitDown.title": "Split Terminal Down", + "keybindings.commands.window.maximize.title": "Maximize Window", + "keybindings.commands.window.minimize.alt.title": "Minimize (Alt)", + "keybindings.commands.window.minimize.mac.title": "Minimize (Mac)", + "keybindings.commands.window.minimize.title": "Minimize Window", + "keybindings.commands.window.quit.title": "Quit Application", + "keybindings.commands.window.toggleFullscreen.title": "Toggle Fullscreen", + "keybindings.commands.window.toggleFullscreenMac.title": "Toggle Fullscreen (Mac)", + "keybindings.commands.window.toggleMenuBar.title": "Toggle Menu Bar", + "keybindings.commands.workbench.agentLauncher.title": "New Agent", + "keybindings.commands.workbench.closeEditorGroup.title": "Close Editor Group", + "keybindings.commands.workbench.closeOtherEditorGroups.title": "Close Other Editor Groups", + "keybindings.commands.workbench.closeWindow.title": "Close Window", + "keybindings.commands.workbench.commandPalette.title": "Command Palette", + "keybindings.commands.workbench.moveEditorToNextGroup.title": "Move Editor Into Next Group", + "keybindings.commands.workbench.moveEditorToPreviousGroup.title": "Move Editor Into Previous Group", + "keybindings.commands.workbench.newTab.title": "New Tab", + "keybindings.commands.workbench.newWindow.title": "New Window", + "keybindings.commands.workbench.nextTab.title": "Next Tab", + "keybindings.commands.workbench.nextTabAlt.title": "Next Tab (Alt)", + "keybindings.commands.workbench.nextTabCtrlTab.title": "Next Tab (Ctrl+Tab)", + "keybindings.commands.workbench.openKeyboardShortcuts.title": "Open Keyboard Shortcuts", + "keybindings.commands.workbench.openSettings.title": "Open Settings", + "keybindings.commands.workbench.previousTab.title": "Previous Tab", + "keybindings.commands.workbench.previousTabAlt.title": "Previous Tab (Alt)", + "keybindings.commands.workbench.previousTabCtrlTab.title": "Previous Tab (Ctrl+Shift+Tab)", + "keybindings.commands.workbench.resetEditorGroupSizes.title": "Reset Editor Group Sizes", + "keybindings.commands.workbench.searchEverywhere.title": "Search Everywhere", + "keybindings.commands.workbench.showDebugger.title": "Show Run and Debug", + "keybindings.commands.workbench.showDocker.title": "Show Docker", + "keybindings.commands.workbench.showFileExplorer.title": "Show Files", + "keybindings.commands.workbench.showFind.title": "Find", + "keybindings.commands.workbench.showFindReplace.title": "Find and Replace", + "keybindings.commands.workbench.showGitHub.title": "Show GitHub", + "keybindings.commands.workbench.showGlobalSearch.title": "Global Search", + "keybindings.commands.workbench.showNotifications.title": "Show Notifications", + "keybindings.commands.workbench.showOutline.title": "Show Outline", + "keybindings.commands.workbench.showProjectSearch.title": "Project Search", + "keybindings.commands.workbench.showSourceControl.title": "Show Source Control", + "keybindings.commands.workbench.showThemeSelector.title": "Theme Selector", + "keybindings.commands.workbench.splitEditorDown.title": "Split Editor Down", + "keybindings.commands.workbench.splitEditorRight.title": "Split Editor Right", + "keybindings.commands.workbench.switchToTab.title": "Switch to Tab {index}", + "keybindings.commands.workbench.toggleAIChat.title": "Toggle Agent", + "keybindings.commands.workbench.toggleActivitySidebar.title": "Toggle Activity Sidebar", + "keybindings.commands.workbench.toggleDiagnostics.title": "Show Diagnostics", + "keybindings.commands.workbench.toggleEditorGroupLock.title": "Toggle Editor Group Lock", + "keybindings.commands.workbench.toggleGitLog.title": "Toggle Git Log", + "keybindings.commands.workbench.toggleMinimap.title": "Toggle Minimap", + "keybindings.commands.workbench.toggleRun.title": "Toggle Run", + "keybindings.commands.workbench.toggleSidebar.title": "Toggle Secondary Sidebar", + "keybindings.commands.workbench.toggleSpringEndpoints.description": "Show or hide indexed Spring MVC endpoints", + "keybindings.commands.workbench.toggleSpringEndpoints.title": "Toggle Spring Endpoints", + "keybindings.commands.workbench.toggleTerminal.title": "Toggle Terminal", + "keybindings.commands.workbench.toggleTerminalAlt.title": "Toggle Terminal (Alt)", + "keybindings.commands.workbench.zoomIn.title": "Zoom In", + "keybindings.commands.workbench.zoomOut.title": "Zoom Out", + "keybindings.commands.workbench.zoomReset.title": "Reset Zoom", + "keybindings.conflictsWith": "Conflicts with: {commands}", + "keybindings.default": "Default", + "keybindings.editForCommand": "Edit keybinding for {title}", + "keybindings.extension": "Extension", + "keybindings.notAssigned": "Not assigned", + "keybindings.preset": "Preset", + "keybindings.pressKeys": "Press keys...", + "keybindings.record": "Record keybinding", + "keybindings.remove": "Remove", + "keybindings.removeKeybinding": "Remove keybinding", + "keybindings.reset": "Reset", + "keybindings.resetToDefault": "Reset to default", + "keybindings.resetToDefaultAria": "Reset to default keybinding", + "keybindings.user": "User", + "layout.actions": "Actions", + "layout.pinned": "Pinned", + "layout.projectDots": "Project Dots", + "layout.resizeActivityRail": "Resize activity rail", + "layout.resizeAiChat": "Resize AI chat", + "layout.resizeBottomPane": "Resize bottom pane", + "layout.resizeSidebar": "Resize sidebar", + "layout.showInActivitySidebar": "Show in Activity Sidebar", + "layout.sidebarSections": "Sidebar sections", + "linuxFolderPicker.chooseReadableFolder": "Choose a readable folder.", + "linuxFolderPicker.folderPath": "Folder path", + "linuxFolderPicker.go": "Go", + "linuxFolderPicker.home": "Home", + "linuxFolderPicker.loadingFolders": "Loading folders", + "linuxFolderPicker.noFolders": "No folders", + "linuxFolderPicker.parentFolder": "Parent folder", + "linuxFolderPicker.unableToReadFolder": "Unable to read this folder.", + "localHistory.back": "Back", + "localHistory.compareFailed": "Failed to compare snapshot", + "localHistory.compareSnapshotsFailed": "Failed to compare snapshots", + "localHistory.compareWithCurrent": "Compare with current", + "localHistory.compareWithPrevious": "Compare with previous", + "localHistory.createEntry": "Create Local History Entry", + "localHistory.createEntryAria": "Create local history entry", + "localHistory.createFailed": "Failed to create snapshot", + "localHistory.deleteFailed": "Failed to delete snapshot", + "localHistory.deleteSnapshot": "Delete snapshot", + "localHistory.entryCreated": "Local history entry created", + "localHistory.entryName": "Entry name", + "localHistory.loadFailed": "Failed to load local history", + "localHistory.loadingTimeline": "Loading timeline...", + "localHistory.localWorkspaceOnly": "Project local history is only available for local workspaces.", + "localHistory.namePrompt": "Name this local history entry:", + "localHistory.noChanges": "No file changes to snapshot.", + "localHistory.noChangesToCompare": "No changes to compare.", + "localHistory.noFileSelected": "No local file selected", + "localHistory.noPreviousEntry": "No previous local history entry.", + "localHistory.noSnapshots": "No local history snapshots", + "localHistory.openFailed": "Failed to open snapshot", + "localHistory.openLocalFileFirst": "Open a local file first.", + "localHistory.openSnapshot": "Open snapshot", + "localHistory.optionalName": "Optional name", + "localHistory.renameFailed": "Failed to rename snapshot", + "localHistory.renameSnapshot": "Rename snapshot", + "localHistory.restoreFailed": "Failed to restore snapshot", + "localHistory.restoreSnapshot": "Restore snapshot", + "localHistory.searchTimeline": "Search timeline...", + "localHistory.selectLocalFileFirst": "Select a local file first.", + "localHistory.snapshot": "snapshot", + "localHistory.snapshotRestored": "Snapshot restored", + "localHistory.title": "Local History", + "localHistory.titleForFile": "Local History: {file}", + "lsp.active": "Language Servers Active", + "lsp.connecting": "Connecting to Language Server...", + "lsp.connectingShort": "Connecting", + "lsp.didNotStart": "Language server did not start.", + "lsp.failedNamed": "{name} language service failed to start.", + "lsp.issue": "Language server issue", + "lsp.issueHint": "Check notifications for the latest error. Reinstall the affected language tools from Extensions if the server binary is missing or failed to launch.", + "lsp.javaIndexClearFailed": "Failed to clear the Java index: {error}", + "lsp.javaIndexCleared": "Java index cleared. Reopen a Java file to rebuild.", + "lsp.languageServersRestarted": "Language servers restarted.", + "lsp.languageServersStopped": "Language servers stopped.", + "lsp.noActive": "No active language servers", + "lsp.noProject": "No Project", + "lsp.notReadyNamed": "{name} language server is not ready.", + "lsp.preparingNamed": "{name} language service is preparing the project...", + "lsp.readyNamed": "{name} language server is ready.", + "lsp.restart": "Restart", + "lsp.restartAll": "Restart all", + "lsp.restartAllFailed": "Failed to restart language servers", + "lsp.restartFailed": "Failed to restart language server", + "lsp.restarting": "Restarting...", + "lsp.retry": "Retry", + "lsp.start": "Start", + "lsp.startFailed": "Failed to start language server", + "lsp.starting": "Starting...", + "lsp.status": "Language server status", + "lsp.stopAll": "Stop all", + "lsp.stopAllFailed": "Failed to stop language servers", + "lsp.stopFailed": "Failed to stop language server", + "lsp.stopNamed": "Stop {name} language server", + "lsp.stopping": "Stopping...", + "lsp.timedOutNamed": "{name} language service preparation timed out.", + "lsp.unavailableNamed": "{name} language service is unavailable.", + "lsp.unsupportedFeatureNamed": "{name} language server does not support {feature}.", + "markdown.documentProperties": "Document properties", + "markdownDocument.placeholder": "Start writing, or type / for commands...", + "maven.add": "Add", + "maven.addProfile": "Add Maven profile", + "maven.automatic": "Automatic", + "maven.buildOutput": "Build Output", + "maven.cancelled": "Cancelled", + "maven.clearOutput": "Clear build output", + "maven.collapseAll": "Collapse all", + "maven.configurationChanged": "Maven configuration changed", + "maven.debug": "Debug", + "maven.debugAlreadyRunning": "Stop the active Debug session before starting another one.", + "maven.debugConfigurationUnavailable": "No JDWP-capable Debug configuration is available for this module.", + "maven.dependencies": "Dependencies", + "maven.dependencyCancelled": "Dependency resolution cancelled", + "maven.dependencyLoading": "Resolving dependencies...", + "maven.detectedMissing": "Not detected on this machine", + "maven.detectedValue": "Detected: {value}", + "maven.detecting": "Detecting…", + "maven.emptyOutput": "Run a Maven lifecycle phase to see output.", + "maven.executeGoal": "Execute Maven goal", + "maven.javaConfigurationFailed": "The Java language server did not take the Maven configuration: {error}", + "maven.javaHome": "Maven JDK Home", + "maven.javaSourceUnavailable": "No Java source file is available to start the language service for this module.", + "maven.lifecycle": "Lifecycle", + "maven.loadFailed": "Unable to load Maven project", + "maven.localRepository": "Local repository", + "maven.mavenExecutable": "Maven home or executable", + "maven.noDependencies": "No dependencies", + "maven.notDetected": "No Maven project detected", + "maven.omittedConflict": "conflict with", + "maven.omittedDuplicate": "duplicate omitted", + "maven.openPom": "Open pom.xml", + "maven.package": "Package", + "maven.processOutput": "Process output", + "maven.profileId": "Profile ID", + "maven.profiles": "Profiles", + "maven.project": "Project", + "maven.reloadFailed": "Unable to reload the Java language server.", + "maven.reloadJdt": "Reload JDT LS", + "maven.reloadProjects": "Reload Maven projects", + "maven.rerunTest": "Rerun test", + "maven.resolutionProblems": "Maven could not resolve this project ({count} problems)", + "maven.restoreProfiles": "Restore default profiles", + "maven.run": "Run", + "maven.runConfigurationUnavailable": "No unambiguous Run configuration is available for this module.", + "maven.runPhase": "Run this lifecycle phase", + "maven.runSelected": "Run selected lifecycle phase", + "maven.runStartFailed": "Unable to start the module Run configuration. See Run output for details.", + "maven.runTestClass": "Run test class", + "maven.runTestMethod": "Run test method", + "maven.scanning": "Scanning Maven project...", + "maven.scopeManagedFrom": "scope managed from {value}", + "maven.scopeNotUpdatedTo": "scope not updated to {value}", + "maven.scopeUpdatedFrom": "scope updated from {value}", + "maven.settings": "Maven Settings", + "maven.settingsDescription": "Leave a field empty to let Lithe detect it automatically.", + "maven.skipTests": "Skip tests", + "maven.sourceRoot.generatedMain": "generated main", + "maven.sourceRoot.generatedTest": "generated test", + "maven.sourceRoot.mainJava": "main Java", + "maven.sourceRoot.mainResources": "main resources", + "maven.sourceRoot.testJava": "test Java", + "maven.sourceRoot.testResources": "test resources", + "maven.sourceRoots": "Source Roots", + "maven.stop": "Stop Maven task", + "maven.test": "Test", + "maven.testFailures": "Test failures", + "maven.testLocationUnknown": "Source location unavailable", + "maven.testRunFailed": "Unable to run Maven test", + "maven.testsFailed": "Failed: {count}", + "maven.testsPassed": "Passed: {count}", + "maven.testsRun": "Total: {count}", + "maven.testsSkipped": "Skipped: {count}", + "maven.title": "Maven", + "maven.versionManagedFrom": "version managed from {value}", + "menu.changelog": "Changelog", + "menu.checkForUpdates": "Check for Updates", + "menu.closeAllTabs": "Close All Tabs", + "menu.closeFolder": "Close Folder", + "menu.closeOtherTabs": "Close Other Tabs", + "menu.closeSavedTabs": "Close Saved Tabs", + "menu.closeTab": "Close Tab", + "menu.closeTabsToLeft": "Close Tabs to the Left", + "menu.closeTabsToRight": "Close Tabs to the Right", + "menu.closeTerminal": "Close Terminal", + "menu.closeWindow": "Close Window", + "menu.commandPalette": "Command Palette", + "menu.copy": "Copy", + "menu.cut": "Cut", + "menu.deleteLine": "Delete Line", + "menu.diagnostics": "Diagnostics", + "menu.documentation": "Documentation", + "menu.duplicateLine": "Duplicate Line", + "menu.edit": "Edit", + "menu.file": "File", + "menu.fileExplorer": "File Explorer", + "menu.find": "Find", + "menu.findAndReplace": "Find and Replace", + "menu.formatDocument": "Format Document", + "menu.formatSelection": "Format Selection", + "menu.github": "GitHub", + "menu.globalSearch": "Global Search", + "menu.go": "Go", + "menu.goBack": "Go Back", + "menu.goForward": "Go Forward", + "menu.goToDefinition": "Go to Definition", + "menu.goToImplementation": "Go to Implementation", + "menu.goToLine": "Go to Line", + "menu.goToReferences": "Go to References", + "menu.goToTypeDefinition": "Go to Type Definition", + "menu.help": "Help", + "menu.keyboardShortcuts": "Keyboard Shortcuts", + "menu.maximize": "Maximize", + "menu.minimize": "Minimize", + "menu.moveLineDown": "Move Line Down", + "menu.moveLineUp": "Move Line Up", + "menu.newFile": "New File", + "menu.newTab": "New Tab", + "menu.newTerminal": "New Terminal", + "menu.newWindow": "New Window", + "menu.nextTab": "Next Tab", + "menu.openFolder": "Open Folder", + "menu.paste": "Paste", + "menu.preferences": "Preferences", + "menu.previousTab": "Previous Tab", + "menu.quickFix": "Quick Fix", + "menu.quickOpen": "Quick Open", + "menu.quit": "Quit", + "menu.redo": "Redo", + "menu.renameSymbol": "Rename Symbol", + "menu.reopenClosedTab": "Reopen Closed Tab", + "menu.reportBug": "Report a Bug", + "menu.requestFeature": "Request a Feature", + "menu.resetZoom": "Reset Zoom", + "menu.revertFile": "Revert File", + "menu.run": "Run", + "menu.runAndDebug": "Run and Debug", + "menu.save": "Save", + "menu.saveAll": "Save All", + "menu.saveAs": "Save As...", + "menu.saveAsTitle": "Save As", + "menu.saveFileFailed": "Failed to save file. Please try again.", + "menu.selectAll": "Select All", + "menu.showHover": "Show Hover", + "menu.showLocalHistory": "Show Local History", + "menu.sourceControl": "Source Control", + "menu.splitEditor": "Split Editor", + "menu.splitTerminalDown": "Split Terminal Down", + "menu.splitTerminalRight": "Split Terminal Right", + "menu.startDebugging": "Start Debugging", + "menu.stopDebugging": "Stop Debugging", + "menu.terminal": "Terminal", + "menu.theme": "Theme", + "menu.toggleActivitySidebar": "Toggle Activity Sidebar", + "menu.toggleBreakpoint": "Toggle Breakpoint", + "menu.toggleComment": "Toggle Comment", + "menu.toggleFullscreen": "Toggle Fullscreen", + "menu.toggleLineNumbers": "Toggle Line Numbers", + "menu.toggleMenuBar": "Toggle Menu Bar", + "menu.toggleMinimap": "Toggle Minimap", + "menu.toggleRenderWhitespace": "Toggle Render Whitespace", + "menu.toggleSecondarySidebar": "Toggle Secondary Sidebar", + "menu.toggleTerminal": "Toggle Terminal", + "menu.toggleWordWrap": "Toggle Word Wrap", + "menu.tools": "Tools", + "menu.triggerParameterHints": "Trigger Parameter Hints", + "menu.undo": "Undo", + "menu.view": "View", + "menu.webInspector": "Web Inspector", + "menu.whatsNew": "What's New", + "menu.window": "Window", + "menu.zoomIn": "Zoom In", + "menu.zoomOut": "Zoom Out", + "native.aChangeListWithThisNameAlreadyExists": "A ChangeList with this name already exists.", + "native.aCustomAgentCanFollowEitherYourCodexOrYourClaudeConfiguration": "A custom Agent can follow either your Codex or your Claude configuration.", + "native.aDetailedMessageWithAClearSubjectAndBody": "A detailed message with a clear subject and body", + "native.aMessageCreatesAnAnnotatedTag": "A message creates an annotated tag.", + "native.aMissingCheckoutCanMakeTheWorktreeListInaccurate": "A missing checkout can make the worktree list inaccurate.", + "native.aNewerVerifiedAdapterVersionIsAvailable": "A newer verified adapter version is available.", + "native.aRebaseCannotStartWithAnyUncommittedChangesIncludingTheseUnrelatedTo": "A rebase cannot start with any uncommitted changes, including these unrelated to '{arg1}':", + "native.aRunningLocalJVMAndItsJDKJstatToolAreRequired": "A running local JVM and its JDK jstat tool are required.", + "native.aSecureConnectionToGitHubCouldNotBeEstablishedCheckTLSInspectionProxyVPNOrSyst9ed7704d75": "A secure connection to GitHub could not be established. Check TLS inspection, proxy, VPN, or system certificate settings.", + "native.aStableReleaseInstallationIsAlreadyInProgress": "A stable release installation is already in progress.", + "native.aTaskStartedBeforeLitheLastClosedUnexpectedlyIsStillRunning": "A task {arg1} started before Lithe last closed unexpectedly is still running", + "native.aTerminalIsBusyWhenAProgramOtherThanTheShellStillRunsInItSuchAsASpringBootAppNa88ac3e46a": "A terminal is busy when a program other than the shell still runs in it, such as a Spring Boot app, npm run dev or a job you sent to the background. Idle terminals always close without asking. Ending a terminal ends every program started in it.", + "native.aWorktreeOperationIsAlreadyRunningOrTheRepositoryIsUnavailable": "A worktree operation is already running, or the repository is unavailable.", + "native.abort": "Abort", + "native.abort.variant2": "Abort…", + "native.abortRebase": "Abort Rebase", + "native.abortThisRebaseAndRestoreItsOriginalBranch": "Abort this rebase and restore its original branch?", + "native.accept": "Accept", + "native.acceptsAllPermissions": "Skip routine approval prompts; environment and managed policies still apply.", + "native.acpAdapter": "ACP adapter", + "native.actionFor": "Action for {arg1}", + "native.actions": "Actions", + "native.actionsFor": "Actions for {arg1}", + "native.actionsForFieldBreakpoint": "Actions for {arg1} field breakpoint", + "native.actionsForMethodBreakpoint": "Actions for {arg1} method breakpoint", + "native.activeMavenProfiles": "Active Maven Profiles", + "native.activeProfiles": "Active profiles", + "native.activity": "Activity", + "native.addAClassOrMethodName": "Add a class or method name", + "native.addAMemoryUsageIndicatorToTheStatusBar": "Add a memory usage indicator to the status bar", + "native.addAnAIProviderInAIProvidersFirst": "Add an AI provider in AI Providers first.", + "native.addDatabaseConnection": "Add database connection", + "native.addHeader": "Add header", + "native.addMemoryUsageMonitoringTrackCurrentAndAverageMemoryUsageInTheStatusBar": "Add memory usage monitoring\n\nTrack current and average memory usage in the status bar.", + "native.addMemoryUsageVisibilityToTheStatusBar": "Add memory usage visibility to the status bar", + "native.addMethodBreakpoint": "Add method breakpoint", + "native.addOneHereOrRegisterAServerWithCodexOrClaudeCodeAndRefresh": "Add one here, or register a server with Codex or Claude Code and refresh.", + "native.addOrRemoveABreakpointAtTheCaret": "Add or remove a breakpoint at the caret", + "native.addProvider": "Add Provider", + "native.addProvider.variant2": "Add provider", + "native.addServer": "Add server", + "native.addShortcut": "Add Shortcut", + "native.addTo": "Add to {arg1}", + "native.addToAnotherConversation": "Add to Another Conversation", + "native.addToCurrentConversation": "Add to Current Conversation · {arg1}", + "native.addToFavorites": "Add to favorites", + "native.addToolWindowsAndCommandsToTheWorkbench": "Add tool windows and commands to the workbench", + "native.addVariable": "Add variable", + "native.added": "Added", + "native.addedByPlugins": "Added by plugins", + "native.addedMemoryUsageVisibilityToTheStatusBar": "Added memory usage visibility to the status bar.", + "native.additionalSearchPaths": "Additional Search Paths", + "native.additions": "Additions", + "native.addsGoLanguageServerIntegrationFormattingRunningAndTestSupport": "Adds Go language-server integration, formatting, running, and test support.", + "native.addsJavaLanguageIntelligenceThroughTheEclipseJDTLanguageServerWithLombokDebuggb2eae07f8b": "Adds Java language intelligence through the Eclipse JDT language server, with Lombok, debugging, and test support. Ships its own Java runtime.", + "native.addsLanguageServerIntegrationAndFormattingSupport": "Adds language-server integration and formatting support.", + "native.addsLanguageServerIntegrationFormattingRunningAndTestSupport": "Adds language-server integration, formatting, running, and test support.", + "native.addsToThePullRequestConversationWithoutAnApprovalDecision": "Adds to the pull request conversation without an approval decision.", + "native.advancedConfigurationAndSources": "Advanced configuration and sources", + "native.affectedHistoryOldestToNewest": "Affected history · oldest to newest", + "native.after": "After", + "native.afterLitheClosesUnexpectedly": "After Lithe closes unexpectedly", + "native.agent": "Agent", + "native.agentChangeRollbackIsUnavailable": "Agent change rollback is unavailable.", + "native.agentCommands": "Agent commands", + "native.agentConfiguration": "Agent configuration", + "native.agentConversation": "Agent conversation", + "native.agentConversationIsTurnedOffTurnItOnInThePanelSettingsToSendMessages": "Agent conversation is turned off. Turn it on in Settings > AI > Agents to send messages.", + "native.agentExecutable": "Agent executable", + "native.agentList": "Agent list", + "native.agentProvider": "Agent provider", + "native.agentReceived": "Agent received", + "native.agentReportedChangesExcerptsMayNotIncludeTheEntireFile": "Agent-reported changes; excerpts may not include the entire file.", + "native.agentSettings": "Agent Settings", + "native.agentSettings.variant2": "Agent Settings…", + "native.agentTasks": "Agent tasks", + "native.agentTasksWillBeEndedWithoutAskingADeploymentOrMigrationThatIsStillRunningWillBeInterrupted": "Agent tasks will be ended without asking. A deployment or migration that is still running will be interrupted.", + "native.agents": "Agents", + "native.agentsMayRunDeploymentsMigrationsOrLongBuildsEndingOneHalfwayCanLeaveDataOrSer5ca5732b6c": "Agents may run deployments, migrations or long builds. Ending one halfway can leave data or services inconsistent. Keep it if you are not sure what it does.", + "native.agentsRunOnTheNodeJsInstalledOnThisMacInstallOrUpdateNodeJsYourselfThenCheckAgain": "Agents run on the Node.js installed on this Mac. Install or update Node.js yourself, then check again.", + "native.ai": "AI", + "native.aiChat": "AI Chat", + "native.aiCommit": "AI & Commit", + "native.aiProvider": "AI provider", + "native.aiProviders": "AI Providers", + "native.aiProviders.variant2": "AI providers", + "native.aiSettingsAndCredentialsAreReadDirectlyFromItsLocalConfigurationFiles": "AI settings and credentials are read directly from its local configuration files.", + "native.aiToolConnectionsMCP": "AI tool connections (MCP)", + "native.all": "All", + "native.allBranches": "All Branches", + "native.allCompleted": "All completed", + "native.allConfigurations": "All configurations", + "native.allConflictsResolvedContinueToFinishOrAbortToUndo": "All conflicts resolved. Continue to finish, or abort to undo.", + "native.allConversations": "All conversations", + "native.allMessages": "All messages", + "native.allModules": "All modules", + "native.allProjects": "All projects", + "native.allProjectsProjectCanOverride": "All projects · project can override", + "native.allReferences": "All References", + "native.allRepositories": "All Repositories", + "native.allServers": "All servers", + "native.allSessions": "All sessions", + "native.allSeverities": "All severities", + "native.allTasksCompleted": "All tasks completed", + "native.allTests": "All tests: {arg1}", + "native.allUncommittedChanges": "All uncommitted changes", + "native.allUsers": "All Users", + "native.allow": "Allow", + "native.allowFastForward": "Allow fast-forward", + "native.allowInsecureHTTP": "Allow insecure HTTP", + "native.allowProjectEnvironmentAndMavenConfigurationChanges": "Allow project environment and Maven configuration changes", + "native.allowRecreatingFile": "Allow Recreating File", + "native.allowRunBuildReloadAndStopExecutesProjectCode": "Allow run, build, reload and stop (executes project code)", + "native.allowTheAgentToContinue": "Allow the Agent to continue?", + "native.allowToRunBuildTools": "Allow “{arg1}” to run build tools?", + "native.alwaysAskBeforeMakingChanges": "Ask before making changes or running actions that require approval.", + "native.alwaysAskToEditExternalFilesAndUseTheInternet": "Always ask to edit external files and use the internet", + "native.alwaysEnd": "Always end", + "native.alwaysKeep": "Always keep", + "native.amend": "Amend", + "native.amendAndContinue": "Amend and Continue", + "native.amendAndContinue.variant2": "Amend and Continue…", + "native.amendAppliesToRepositoriesWithSelectedFiles": "Amend applies to repositories with selected files.", + "native.anAgentStartsWhenYouOpenThisPanelInAProjectTurningThisOffStopsEveryAgentProcess": "An Agent starts when you open this panel in a project. Turning this off stops every Agent process.", + "native.anUpdateIsAvailable": "An update is available.", + "native.annotateWithGitBlame": "Annotate with Git Blame", + "native.anotherGlobalEntryUsesThisNameWithADifferentDefinition": "Another global entry uses this name with a different definition.", + "native.anyTime": "Any Time", + "native.apiKey": "API key", + "native.apiKeyAvailableInClaudeConfiguration": "API key available in Claude configuration", + "native.apiKeyAvailableInCodexAuthJson": "API key available in Codex auth.json", + "native.apiKeyIsManagedByClaudeConfiguration": "API key is managed by Claude configuration", + "native.apiKeyIsManagedByCodexConfiguration": "API key is managed by Codex configuration", + "native.apiKeyOrToken": "API key or token", + "native.apiKeySavedLocally": "API key saved locally", + "native.apiKeysAreStoredInLitheSLocalApplicationDataAndAreNeverWrittenToLitheSettings": "API keys are stored in Lithe's local application data and are never written to Lithe settings.", + "native.apiProtocol": "API protocol", + "native.apiRequestURL": "API request URL", + "native.apiURL": "API URL", + "native.appearanceBehavior": "Appearance & Behavior", + "native.appearanceMode": "Appearance mode", + "native.application": "Application", + "native.applicationDefault": "Application default", + "native.applicationMemory": "Application Memory", + "native.applicationTypes": "Application types", + "native.applicationVersion": "Application version", + "native.applicationWideEditorAndTerminalSettingsRemainAvailableInTheOtherCategories": "Application-wide editor and terminal settings remain available in the other categories.", + "native.applications": "Applications", + "native.appliesImmediately": "Applies immediately", + "native.appliesTheNextTimeTheLanguageServerStarts": "Applies the next time the language server starts.", + "native.applyAIGeneratedContent": "Apply AI-generated content?", + "native.applyPatch": "Apply Patch", + "native.applyPatch.variant2": "Apply Patch…", + "native.applyTo": "Apply to", + "native.applyToTheCurrentBranch": "Apply {arg1} to the current branch.", + "native.approvalMode": "Approval mode", + "native.approve": "Approve", + "native.approveForMe": "Approve for me", + "native.approvePullRequest": "Approve pull request", + "native.archiveSHA256": "Archive SHA-256", + "native.archived": "Archived", + "native.archivedD": "Archived ({arg1})", + "native.arguments": "Arguments", + "native.argumentsFromThePage": "Arguments from the page:", + "native.argumentsOnePerLine": "Arguments (one per line)", + "native.argumentsPassedToTheProgram": "Arguments passed to the program", + "native.askBeforeClosingATabWithRunningPrograms": "Ask before closing a tab with running programs", + "native.askBeforeStopping": "Ask before stopping", + "native.askEveryTime": "Ask every time", + "native.askForApproval": "Ask for approval", + "native.askForEachTask": "Ask for each task", + "native.askForEachTerminal": "Ask for each terminal", + "native.askInOneList": "Ask in one list", + "native.askOnNextLaunch": "Ask on next launch", + "native.askTheAgentAboutThisWorkspace": "Ask the Agent about this workspace", + "native.askTheAgentEnterToSendShiftEnterForANewLine": "Ask the Agent, Enter to send, Shift-Enter for a new line", + "native.assignees": "Assignees", + "native.astroSupport": "Astro Support", + "native.attach": "Attach", + "native.attachFile": "Attach file", + "native.attachFiles": "Attach files", + "native.attachToARemoteJVMOrTomcat": "Attach to a remote JVM or Tomcat", + "native.attachUpTo32FilesPerMessage": "Attach up to 32 files per message.", + "native.attachmentsAreNotSupportedYet": "Attachments are not supported yet", + "native.authenticationMustBeAJSONObjectContainingAnOPENAIAPIKEYString": "Authentication must be a JSON object containing an OPENAI_API_KEY string.", + "native.author": "Author", + "native.authorizeInYourBrowser": "Authorize in your browser", + "native.authorizeThisProjectToExposeEnvironmentMavenRunConfigurationsAndOutputToConnec6e24e4763f": "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.", + "native.authorizeThisProjectToExposeEnvironmentMavenRunConfigurationsAndOutputToConnecb6f617e865": "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. Enabled plugins can also use this project grant.", + "native.authorizeThisProjectToExposeEnvironmentMavenRunConfigurationsAndOutputToConnece4d5e32bce": "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, and the project-level server registration is removed. Enabled plugins can also use this project grant.", + "native.autobuildBuiltAt": "AutoBuild — Built at {arg1}", + "native.autobuildIsRebuiltNightlyAndMayContainUnfinishedFeaturesSeriousBugsOrOtherProbd0994a4a11": "AutoBuild is rebuilt nightly and may contain unfinished features, serious bugs, or other problems. Stable releases are recommended for most users.", + "native.autobuildRolling": "AutoBuild Rolling", + "native.autobuildUpdate": "AutoBuild Update", + "native.automatic": "Automatic", + "native.automaticQueryLldIdenticalResults": "Automatic query ×{arg1}, identical results", + "native.automaticUsesAProjectMvnwFirstThenDetectedMavenThenMvnd": "Automatic uses a project mvnw first, then detected Maven, then mvnd.", + "native.automaticallyAcceptAllFileEdits": "Automatically approve file edits; other actions follow permission rules.", + "native.automaticallyGenerated": "Automatically generated", + "native.automaticallyIdentified": "Automatically identified", + "native.available": "Available", + "native.availableToAgents": "Available to Agents", + "native.averageSinceLaunch": "Average since launch", + "native.averageTimeToFirstTokenTTFT": "Average time to first token (TTFT)", + "native.averageTotal": "Average total", + "native.avg": "avg", + "native.backToAllReferences": "Back to all references", + "native.backToTheConversation": "Back to the conversation", + "native.backgroundCommandEndedExitCodeArg1Arg2": "Background command ended (exit code {arg1}): {arg2}", + "native.backgroundCommandFinished": "Background command finished: {arg1}", + "native.backgroundServices": "Background Services", + "native.backgroundServices.variant2": "Background services", + "native.backgroundServicesStartedInLitheAreStillRunning": "Background services started in Lithe are still running", + "native.balancedReasoning": "Balanced reasoning", + "native.base": "Base", + "native.base.variant2": "Base: {arg1}", + "native.baseBranch": "Base branch", + "native.baseTarget": "Base {arg1} → Target {arg2}", + "native.baseURLForAServiceCompatibleWithTheOpenAIResponsesAPI": "Base URL for a service compatible with the OpenAI Responses API.", + "native.baseURLForAnAnthropicMessagesAPIService": "Base URL for an Anthropic Messages API service.", + "native.basicInformation": "Basic Information", + "native.basicLatinLetters": "basic Latin letters", + "native.before": "Before", + "native.beforeDeletion": "Before deletion", + "native.beforeProjectReplacement": "Before project replacement", + "native.beforeRename": "Before rename", + "native.beforeRestore": "Before restore", + "native.branch": "Branch", + "native.branch.variant2": "Branch:", + "native.branch.variant3": "Branch: {arg1}", + "native.branchContext": "Branch context", + "native.branchName": "Branch name", + "native.branchPublishedToGitHub": "Branch published to GitHub", + "native.branchTracksWhichHasADifferentNameChooseWhichBranchToUpdate": "Branch '{arg1}' tracks '{arg2}', which has a different name. Choose which branch to update.", + "native.branches": "Branches", + "native.branchesHaveDiverged": "Branches have diverged", + "native.branchesMustBeDifferent": "Branches must be different", + "native.branchesUnavailable": "Branches unavailable", + "native.breakdownUnavailable": "Breakdown unavailable", + "native.breakpointActions": "Breakpoint actions", + "native.breakpoints": "Breakpoints", + "native.bugReport": "Bug report", + "native.build": "Build", + "native.build.variant2": "Build {arg1} → {arg2}", + "native.buildExecutionDeployment": "Build, Execution, Deployment", + "native.buildFilesChanged": "{arg1} build files changed", + "native.buildFilesChanged.variant2": "Build files changed.", + "native.buildFilesChangedReloadTheModelToPickUpTheChanges": "Build files changed. Reload the model to pick up the changes.", + "native.buildOutput": "Build Output", + "native.buildOutput.variant2": "Build output", + "native.buildOutputs": "Build Outputs", + "native.buildRunAndDebug": "Build, run and debug", + "native.buildSystem": "Build System", + "native.buildsProjects": "Builds {arg1} projects", + "native.built": "Built {arg1}", + "native.builtIn": "Built-in", + "native.builtInBackgrounds": "Built-in backgrounds", + "native.cCObjectiveCSupport": "C/C++/Objective-C Support", + "native.cSupport": "C# Support", + "native.cacheHit": "Cache hit", + "native.cacheHit.variant2": "Cache hit {arg1}", + "native.cacheRead": "Cache read", + "native.cacheReadTokens": "Cache read tokens: {arg1}", + "native.cacheWrite": "Cache write", + "native.cacheWriteTokens": "Cache write tokens: {arg1}", + "native.callStack": "Call Stack", + "native.cameraAndMicrophoneInUse": "Camera and microphone in use", + "native.cameraInUse": "Camera in use", + "native.cancelQuit": "Cancel Quit", + "native.cancelRunningGitOperations": "Cancel running Git operations", + "native.cancelled": "Cancelled", + "native.cannotBeUsed": "Cannot be used: {arg1}", + "native.cannotCopyADirectoryIntoItself": "Cannot copy a directory into itself", + "native.cannotCreateANewConversation": "Cannot create a new conversation: {arg1}", + "native.cannotUseUsingTheProjectJDKInstead": "Cannot use {arg1}; using the project JDK instead.", + "native.category": "Category", + "native.changeTheValuesStoredInTheSelectedScopeChooseInheritToRemoveAnOverride": "Change the values stored in the selected scope. Choose Inherit to remove an override.", + "native.changeWorkbenchBackground": "Change workbench background", + "native.changed": "Changed", + "native.changedFiles": "Changed Files", + "native.changedOutsideLitheRefreshAndTryAgain": "“{arg1}” changed outside Lithe. Refresh and try again.", + "native.changelist": "ChangeList", + "native.changelistEditingIsUnavailable": "ChangeList editing is unavailable.", + "native.changelistName": "ChangeList name", + "native.changelistStorageIsUnavailableRestoreTheSavedDataAndReopenTheWorkspace": "ChangeList storage is unavailable. Restore the saved data and reopen the workspace.", + "native.changes": "Changes", + "native.changesApplyToTheNextTurn": "Changes apply to the next turn.", + "native.changesAreWrittenTo": "Changes are written to", + "native.changesFromExternalToolsWillAppearAutomatically": "Changes from external tools will appear automatically.", + "native.changesFromTheCompareBranchWillBeProposedForTheBaseBranch": "Changes from the compare branch will be proposed for the base branch.", + "native.changesRequested": "Changes requested", + "native.changingChannelsRequiresAFullInstallationAndMayInstallAnOlderVersionOnItsFirste8ccfd1e52": "Changing channels requires a full installation and may install an older version. On its first launch, Lithe-Dev copies Stable settings and workspaces if it has no user data. The apps then save settings and caches separately. Some accounts may need sign-in again. Your project files stay in their current locations.", + "native.changingChannelsRequiresAFullInstallationAndMayInstallAnOlderVersionOpenTheSelda3c463026": "Changing channels requires a full installation and may install an older version. Open the selected release page to download its installer. Your channel selection is saved for future updates.", + "native.charactersUsedByTheInterfaceAndYourFiles": "characters used by the interface and your files", + "native.chars": "chars", + "native.chatCompletions": "Chat Completions", + "native.chatHistory": "Chat History", + "native.chatOptions": "Chat options", + "native.chatgptSignInWasCancelled": "ChatGPT sign-in was cancelled.", + "native.check": "Check: {arg1}", + "native.checkAgain": "Check Again", + "native.checkComplete": "Check complete.", + "native.checkCompleteSomeCLIUpdateResultsAreUnavailableSeePreflight": "Check complete. Some CLI update results are unavailable; see Preflight.", + "native.checkFailed": "Check failed.", + "native.checkForUpdates": "Check for Updates", + "native.checkForUpdates.variant2": "Check for Updates…", + "native.checkTheConfigurationFormatAPIURLAndModelEachConfigurationMustBeUnder64KiB": "Check the configuration format, API URL and model. Each configuration must be under 64 KiB.", + "native.checkYourInternetConnectionAndTryAgainLater": "Check your internet connection and try again later.", + "native.checkedAgainEveryTimeLitheLoadsThePlugin": "Checked again every time Lithe loads the plugin.", + "native.checking": "Checking…", + "native.checkingForUpdates": "Checking for updates…", + "native.checkingGitHubReleases": "Checking GitHub Releases…", + "native.checkingGitRepository": "Checking Git repository…", + "native.checkingNodeJsAndNpm": "Checking Node.js and npm…", + "native.checkingOutPullRequest": "Checking out pull request…", + "native.checkingSelectedCommitsAndRepositoryState": "Checking selected commits and repository state…", + "native.checkingTheBranchAndAffectedCommits": "Checking the branch and affected commits…", + "native.checkout": "Checkout", + "native.checkout.variant2": "checkout", + "native.checkoutAndRebase": "Checkout and Rebase", + "native.checkoutAndRebaseBranch": "Checkout and rebase branch?", + "native.checkoutAndRebaseOntoCurrentBranch": "Checkout and Rebase onto Current Branch", + "native.checkoutBranchAfterCreation": "Checkout branch after creation", + "native.checkoutPath": "Checkout path", + "native.checkoutPathMissing": "Checkout Path Missing", + "native.checkoutTagOrRevision": "Checkout Tag or Revision", + "native.checkoutTagOrRevision.variant2": "Checkout Tag or Revision…", + "native.checkoutThenReplayItOntoTheBranchThatIsCurrentNow": "Checkout {arg1}, then replay it onto the branch that is current now.", + "native.cherryPick": "Cherry-pick", + "native.cherryPick.variant2": "cherry-pick", + "native.cherryPickCommit": "Cherry-pick Commit…", + "native.cherryPickInProgress": "Cherry-pick in progress", + "native.cherryPickThisCommit": "Cherry-pick this commit?", + "native.chineseCharacters": "Chinese characters", + "native.choose": "Choose…", + "native.chooseABaseAndCompareBranchThenDescribeThePullRequest": "Choose a base and compare branch, then describe the pull request.", + "native.chooseABehaviorOnTheRightLitheShowsTheValueGitIsUsingAndTheValueAResetWouldRestore": "Choose a behavior on the right. Lithe shows the value Git is using and the value a reset would restore.", + "native.chooseAColorThemeAndWhetherLitheFollowsTheSystemAppearance": "Choose a color theme and whether Lithe follows the system appearance.", + "native.chooseACommitFormat": "Choose a commit format", + "native.chooseADatabaseConnectionAndThenATableFromTheSidebar": "Choose a database connection and then a table from the sidebar.", + "native.chooseADefaultWebBrowserInSystemSettingsThenRetry": "Choose a default web browser in System Settings, then retry.", + "native.chooseADirectoryInsideTheProject": "Choose a directory inside the project", + "native.chooseAGitExecutableAboveToContinue": "Choose a Git executable above to continue.", + "native.chooseAJDKDirectory": "Choose a JDK directory", + "native.chooseAJDKInSettingsProjectOrInstallAFullJDKAndSetJAVAHOME": "Choose a JDK in Settings → Project or install a full JDK and set JAVA_HOME.", + "native.chooseALocalOrCustomProviderInTheAgentPanelSettings": "Choose a local or custom provider in Settings > AI > Agents.", + "native.chooseALocalOrCustomProviderSoTheAgentCanSignIn": "Choose a local or custom provider so the Agent can sign in.", + "native.chooseAModuleEnableDetailedDiagnosticsReproduceTheProblemThenPreviewAndExportADiagnosticBundle": "Choose a module, enable detailed diagnostics, reproduce the problem, then preview and export a diagnostic bundle.", + "native.chooseAPullRequestToReviewItsContextFilesAndConversation": "Choose a pull request to review its context, files, and conversation.", + "native.chooseARunConfigurationForThisMavenModule": "Choose a Run configuration for this Maven module", + "native.chooseATableFromTheSidebarToBrowseAndEditData": "Choose a table from the sidebar to browse and edit data.", + "native.chooseATableToInspectItsColumnsIndexesAndForeignKeys": "Choose a table to inspect its columns, indexes, and foreign keys.", + "native.chooseAValue": "Choose a value", + "native.chooseAnAIProviderBelowSoTheAgentCanSignIn": "Choose an AI provider below so the Agent can sign in.", + "native.chooseAnAgent": "Choose an Agent", + "native.chooseAnAgentToStart": "Choose an Agent to start", + "native.chooseAnotherJDKInSettingsProjectOrClearTheInvalidPath": "Choose another JDK in Settings → Project or clear the invalid path.", + "native.chooseAtLeastOneAgent": "Choose at least one Agent.", + "native.chooseDatabase": "Choose database", + "native.chooseDirectory": "Choose Directory", + "native.chooseDirectory.variant2": "Choose directory", + "native.chooseExistingFilesToAddToAConversation": "Choose existing files to add to a conversation.", + "native.chooseHowLitheLocatesGitAndHandlesCredentials": "Choose how Lithe locates Git and handles credentials.", + "native.chooseHowThisGitSettingBehaves": "Choose how this Git setting behaves.", + "native.chooseImage": "Choose Image…", + "native.chooseImplementation": "Choose Implementation", + "native.chooseLogDirectory": "Choose Log Directory", + "native.chooseMavenExecutableOrHome": "Choose Maven executable or home", + "native.chooseMavenOrMvndHomeBinOrExecutable": "Choose Maven or mvnd home, bin, or executable", + "native.chooseParent": "Choose Parent…", + "native.choosePluginPackage": "Choose Plugin Package", + "native.chooseRuntimeDirectory": "Choose Runtime Directory", + "native.chooseServicesToRun": "Choose services to run", + "native.chooseStepTarget": "Choose Step Target", + "native.chooseTwoBranches": "Choose two branches", + "native.chooseWhatHappensToProgramsStartedInLitheWhenYouQuitOrCloseATerminalProgramsStb8837e766c": "Choose what happens to programs started in Lithe when you quit or close a terminal. Programs started outside Lithe are never shown or stopped.", + "native.chooseWhatPullDoesWhenYourBranchAndTheRemoteBranchHaveDiverged": "Choose what Pull does when your branch and the remote branch have diverged.", + "native.chooseWhetherFetchAlsoUpdatesSubmodules": "Choose whether Fetch also updates submodules.", + "native.chooseWhetherLitheFollowsTheSystemAppearanceOrAlwaysUsesALightOrDarkTheme": "Choose whether Lithe follows the system appearance or always uses a light or dark theme.", + "native.chooseWhetherOpeningAnotherProjectAsksFirstStaysInThisWindowOrCreatesANewWindow": "Choose whether opening another project asks first, stays in this window, or creates a new window.", + "native.chooseWhetherPullMergesRemoteCommitsOrRebasesYourLocalCommits": "Choose whether Pull merges remote commits or rebases your local commits.", + "native.chooseWhetherThisSettingBelongsToTheOpenRepositoryOrToGitEverywhere": "Choose whether this setting belongs to the open repository or to Git everywhere.", + "native.chooseWhichBranchPushTargetsWhenYouDoNotNameOne": "Choose which branch Push targets when you do not name one.", + "native.chooseWhichBranchToUpdateBeforePushing": "Choose which branch to update before pushing.", + "native.chooseWorkbenchBackground": "Choose Workbench Background", + "native.classes": "Classes", + "native.claudeAgent": "Claude Agent", + "native.claudeConfigurationDetected": "Claude configuration detected", + "native.claudeConfigurationImported": "Claude configuration imported", + "native.claudeHandlesPermissionDecisions": "Claude evaluates and approves actions automatically.", + "native.claudeMessagesAPI": "Claude Messages API", + "native.claudeSettingsAndCredentialsAreReadDirectlyFromItsLocalConfigurationFiles": "Claude settings and credentials are read directly from its local configuration files.", + "native.clearAll": "Clear All", + "native.clearBranchFilter": "Clear branch filter", + "native.clearBuildOutput": "Clear build output", + "native.clearCollectedLogs": "Clear collected logs?", + "native.clearConflictFilter": "Clear conflict filter", + "native.clearDateFilter": "Clear date filter", + "native.clearDebugOutput": "Clear debug output", + "native.clearGitConsole": "Clear Git console", + "native.clearLogSearch": "Clear log search", + "native.clearLogs": "Clear Logs", + "native.clearOverride": "Clear Override", + "native.clearPathFilter": "Clear path filter", + "native.clearRunOutput": "Clear run output", + "native.clearSearch": "Clear search", + "native.clearSelection": "Clear selection", + "native.clearTheCurrentProjectSJavaIndexAndRebuildItOnNextUse": "Clear the current project's Java index and rebuild it on next use", + "native.clearTheOverrideAndInherit": "Clear the override and inherit", + "native.clearUserFilter": "Clear user filter", + "native.cliUpdatedTo": "CLI updated to {arg1}", + "native.clickTheEditorGutterToAddABreakpoint": "Click the editor gutter to add a breakpoint", + "native.clickTheFoldedOptionsToExpandRightClickForCommandDetails": "Click the folded options to expand; right-click for command details.", + "native.clickTheRunIconInTheEditorGutter": "— Click the Run icon in the editor gutter", + "native.clineCursorKiloMistralAndMoreWithACPTechnology": "Cline, Cursor, Kilo, Mistral, and more with ACP technology", + "native.clojureSupport": "Clojure Support", + "native.clone": "Clone", + "native.cloneRepository": "Clone Repository", + "native.cloneRepository.variant2": "Clone Repository…", + "native.closeAllTabs": "Close All Tabs", + "native.closeComparison": "Close comparison", + "native.closeConversation": "Close conversation", + "native.closeDiff": "Close diff", + "native.closeEsc": "Close (Esc)", + "native.closeFind": "Close Find", + "native.closeGitConsole": "Close Git console", + "native.closeOtherTabs": "Close Other Tabs", + "native.closeProject": "Close Project", + "native.closePullRequest": "Close Pull Request", + "native.closePullRequest.variant2": "Close pull request", + "native.closePullRequest.variant3": "Close pull request?", + "native.closeRunConfiguration": "Close run configuration", + "native.closeRunningTerminal": "Close Running Terminal?", + "native.closeSearch": "Close search", + "native.closeSettings": "Close Settings", + "native.closeSplit": "Close split", + "native.closeTabsToTheLeft": "Close Tabs to the Left", + "native.closeTabsToTheRight": "Close Tabs to the Right", + "native.closeTerminal": "Close Terminal", + "native.closeTerminal.variant2": "Close terminal “{arg1}”?", + "native.closeUnmodifiedTabs": "Close Unmodified Tabs", + "native.closed": "Closed", + "native.closingATabEndsEveryProgramStartedInItIncludingJobsSentToTheBackgroundTurnThis6e11c0ada9": "Closing a tab ends every program started in it, including jobs sent to the background. Turn this off to close tabs immediately. Background services that left the terminal are not ended here; they are asked about when you quit.", + "native.closingATerminalTab": "Closing a terminal tab", + "native.closingPullRequest": "Closing pull request…", + "native.closingTheProjectStopsTheseServicesAndTheProcessesTheyStarted": "Closing the project stops these services and the processes they started.", + "native.closingTheTerminalEndsThesePrograms": "Closing the terminal ends these programs.", + "native.closingThisTerminalWillStopItsShellAndAnyRunningCommand": "Closing this terminal will stop its shell and any running command.", + "native.cmakeSupport": "CMake Support", + "native.code": "code", + "native.codex": "Codex", + "native.codexAgentsRequireTheResponsesAPIClaudeAgentsRequireAnAnthropicAPIKey": "Codex Agents require the Responses API. Claude Agents require an Anthropic API key.", + "native.codexArchivedHistoryCouldNotBeRead": "Codex archived history could not be read.", + "native.codexConfigurationDetected": "Codex configuration detected", + "native.codexConfigurationImported": "Codex configuration imported", + "native.codexCouldNotRestoreThisArchivedConversation": "Codex could not restore this archived conversation.", + "native.codexSettingsAndAPIKeyAreReadDirectlyFromCodexConfigTomlAndCodexAuthJson": "Codex settings and API key are read directly from ~/.codex/config.toml and ~/.codex/auth.json.", + "native.codexSettingsAndCredentialsAreReadDirectlyFromItsLocalConfigurationFiles": "Codex settings and credentials are read directly from its local configuration files.", + "native.codexSubscription": "Codex subscription", + "native.codexSubscriptionQuota": "Codex subscription quota", + "native.collaborateWithClaudeAndCodexToAskQuestionsRunTasksAndReviewCodeChanges": "Collaborate with Claude and Codex to ask questions, run tasks, and review code changes.", + "native.collaborationMode": "Collaboration mode", + "native.collapse": "Collapse", + "native.collapseAll": "Collapse all", + "native.collapseDirectory": "Collapse directory", + "native.collapseJSONBlock": "Collapse JSON block", + "native.collapseLongGraphEdges": "Collapse long graph edges", + "native.collapseNotification": "Collapse notification", + "native.collapseRepository": "Collapse repository", + "native.collapseSection": "Collapse section", + "native.collapsed": "Collapsed", + "native.collectedLogsCleared": "Collected logs cleared.", + "native.colorTheme": "Color theme", + "native.combineTheMessagesWhenTheRebaseRunsIncludingChangesMadeAtEarlierEditStops": "Combine the messages when the rebase runs, including changes made at earlier Edit stops", + "native.combinedCommitMessage": "Combined commit message", + "native.combinesThePullRequestIntoOneCommitOnTheBaseBranch": "Combines the pull request into one commit on the base branch.", + "native.command": "Command", + "native.commandDetails": "Command details", + "native.commandPaletteActions": "Command Palette Actions", + "native.commandsAnAIAgentStartedThatAreStillRunningForExampleADeploymentADatabaseMigradd876f8a4a": "Commands an AI Agent started that are still running, for example a deployment, a database migration or a dev server. Each one is asked about separately because ending one halfway can leave data or services inconsistent. What the Agent needs for itself, such as its MCP servers, always ends with the Agent.", + "native.comment": "Comment", + "native.commentPosted": "Comment posted", + "native.comments": "Comments", + "native.commit": "Commit", + "native.commit.variant2": "Commit…", + "native.commitAndPush": "Commit and Push", + "native.commitAndPush.variant2": "Commit and Push…", + "native.commitAndPush.variant3": "Commit and push", + "native.commitAndPushChanges": "Commit and push changes?", + "native.commitChangedFiles": "Commit changed files", + "native.commitChangedFilesInTheSubmoduleFirst": "Commit changed files in the submodule first", + "native.commitDIFF": "COMMIT DIFF", + "native.commitDetails": "Commit details", + "native.commitHistory": "Commit History", + "native.commitHistoryLld": "Commit History ({arg1})", + "native.commitIdentity": "Commit identity", + "native.commitMessage": "Commit Message", + "native.commitMessage.variant2": "Commit message", + "native.commitMessage.variant3": "Commit message: {arg1}", + "native.commitMessageGenerated": "Commit message generated", + "native.commitMessageGeneration": "Commit message generation", + "native.commitMessageProvider": "Commit message provider", + "native.commitMessageReplaced": "Commit message replaced", + "native.commitMessages": "commit messages", + "native.commitMessagesUse": "Commit messages use", + "native.commitTitle": "Commit title", + "native.commitVersion": "Commit version", + "native.commits": "Commits", + "native.committed": "Committed", + "native.committedAndPushed": "Committed and pushed", + "native.committedChangesButDetachedHEADCannotBePushed": "Committed changes, but detached HEAD cannot be pushed", + "native.committedPushFailed": "Committed; push failed", + "native.committedPushPending": "Committed; push pending", + "native.committerEmail": "Committer email", + "native.committerName": "Committer name", + "native.community": "Community", + "native.communityDisk": "Community / Disk", + "native.communityPluginInstalledFromDiskItsPublisherAndRepositoryHaveNotBeenVerified": "Community plugin installed from disk. Its publisher and repository have not been verified.", + "native.communityPluginMaintainedByAThirdPartyReportPluginProblemsInItsOwnRepository": "Community plugin maintained by a third party. Report plugin problems in its own repository.", + "native.communityPluginsAreMaintainedByThirdPartiesAndCanRunTheProgramsAboveOnYourMacO5dbe99fc4e": "Community plugins are maintained by third parties and can run the programs above on your Mac. Only install plugins you trust.", + "native.compare": "Compare", + "native.compareChanges": "Compare changes", + "native.compareCurrentBranchWithWorkingTree": "Compare current branch with working tree", + "native.compareWith": "Compare '{arg1}' with '{arg2}'", + "native.compareWithCurrentBranch": "Compare with Current Branch", + "native.comparingChanges": "Comparing changes", + "native.completeCommitMessage": "Complete commit message", + "native.completeSignInInYourBrowserYourCredentialsAreManagedByCodex": "Complete sign-in in your browser. Your credentials are managed by Codex.", + "native.completed": "Completed", + "native.compressionIsUnavailableShowingCompleteRetainedOutput": "Compression is unavailable. Showing complete retained output.", + "native.concise": "Concise", + "native.conciseSentence": "Concise sentence", + "native.configuration": "Configuration", + "native.configurationDetails": "Configuration details", + "native.configurationID": "Configuration ID", + "native.configurationKey": "Configuration key", + "native.configurationOverrides": "Configuration overrides", + "native.configurationScope": "Configuration scope", + "native.configurationSources": "Configuration sources", + "native.configurationValue": "Configuration value", + "native.configure": "Configure", + "native.configureAnAIProviderInSettingsFirst": "Configure an AI provider in Settings first.", + "native.configureDependencySearchPaths": "Configure dependency search paths", + "native.configureEditorAndProjectBehavior": "Configure editor and project behavior", + "native.configureInSettings": "Configure in Settings", + "native.configureProjectJDKAndMaven": "Configure project JDK and Maven…", + "native.configured": "Configured", + "native.configuredRemoteName": "Configured remote name", + "native.confirmDeletion": "Confirm deletion", + "native.conflict": "Conflict", + "native.conflict.variant2": "conflict -> {arg1}", + "native.conflicted": "Conflicted", + "native.conflicts": "Conflicts", + "native.conflictsWith": "Conflicts with {arg1}", + "native.connect": "Connect", + "native.connectAGitHubAccountBeforeContinuing": "Connect a GitHub account before continuing", + "native.connectToDatabasesBrowseSchemasEditDataRunSQLAndManageBackupsFromTheDatabaseWorkspace": "Connect to databases, browse schemas, edit data, run SQL, and manage backups from the Database workspace.", + "native.connectToRunningJVM": "Connect to running JVM", + "native.connected": "Connected", + "native.connectedAgentsCanChangeTheProjectJDKMavenAndRunConfigurationSettings": "Connected agents can change the project JDK, Maven and run configuration settings.", + "native.connectedAgentsCanRunBuildReloadAndStopTheProjectCommandsExecuteProjectCodeSoE6d273dab37": "Connected agents can run, build, reload and stop the project. Commands execute project code, so enable this only for projects you trust.", + "native.connectedAgentsCanUseTheConfigurationBelowWhileThisProjectIsOpen": "Connected agents can use the configuration below while this project is open.", + "native.connectedAs": "Connected as @{arg1}", + "native.connectionRecoveryIsTakingLongerTheAgentIsStillRetrying": "Connection recovery is taking longer. The Agent is still retrying.", + "native.connectionsLld": "Connections: {arg1}", + "native.console": "Console", + "native.content": "Content", + "native.contentSHA256": "Content SHA-256", + "native.context": "Context", + "native.context.variant2": "Context: {arg1}", + "native.contextTokens": "{arg1} · {arg2} / {arg3} context tokens", + "native.contextTokens.variant2": "Context tokens", + "native.contextUsage": "Context usage", + "native.contextUsageIsNotReportedByThisAgent": "Context usage is not reported by this Agent", + "native.contextUsed": "{arg1} · {arg2} / {arg3} context used", + "native.contextUsed.variant2": "Context used {arg1}", + "native.continueCherryPick": "Continue Cherry-pick", + "native.continueInstallation": "Continue Installation", + "native.continueMerge": "Continue Merge", + "native.continueRebase": "Continue Rebase", + "native.continueRevert": "Continue Revert", + "native.continueWaiting": "Continue waiting", + "native.controlsHowLitheSOrdinaryFetchSynchronizesTags": "Controls how Lithe's ordinary Fetch synchronizes tags.", + "native.controlsHowLitheSOrdinaryFetchSynchronizesTagsInheritLeavesTagSelectionToGitAndTheRemote": "Controls how Lithe's ordinary Fetch synchronizes tags. Inherit leaves tag selection to Git and the remote.", + "native.controlsHowLitheSOrdinaryFetchUpdatesSubmodules": "Controls how Lithe's ordinary Fetch updates submodules.", + "native.controlsHowLitheSOrdinaryFetchUpdatesSubmodulesInheritFollowsGitSFetchRecurseSubmodulesSetting": "Controls how Lithe's ordinary Fetch updates submodules. Inherit follows Git's fetch.recurseSubmodules setting.", + "native.controlsWhetherLitheSOrdinaryFetchRemovesStaleRemoteBranches": "Controls whether Lithe's ordinary Fetch removes stale remote branches.", + "native.conventionalCommits": "Conventional Commits", + "native.conversation": "Conversation", + "native.conversationHistory": "Conversation history", + "native.conversationHistoryIsUnavailable": "Conversation history is unavailable", + "native.conversationTabs": "Conversation tabs", + "native.conversationTitle": "Conversation title", + "native.conversationsForThisProjectAndAgentAppearHere": "Conversations for this project and Agent appear here.", + "native.copied": "Copied", + "native.copiedFiles": "Copied files", + "native.copyAgentConfiguration": "Copy agent configuration", + "native.copyBranchName": "Copy Branch Name", + "native.copyCode": "Copy code", + "native.copyCommand": "Copy command", + "native.copyCommitHash": "Copy Commit Hash", + "native.copyCompleteCommand": "Copy complete command", + "native.copyCompleteConsole": "Copy complete console", + "native.copyCompleteOutput": "Copy complete output", + "native.copyConversation": "Copy Conversation", + "native.copyFiles": "Copy", + "native.copyJSON": "Copy JSON", + "native.copyMessage": "Copy message", + "native.copyName": "Copy Name", + "native.copyOriginalCommand": "Copy Original Command", + "native.copyOutput": "Copy output", + "native.copyPath": "Copy Path", + "native.copyPathReference": "Copy Path / Reference", + "native.copyProjectPath": "Copy Project Path", + "native.copyRecoveryReference": "Copy Recovery Reference", + "native.copyRelativePath": "Copy Relative Path", + "native.copyReply": "Copy reply", + "native.copyRepositoryPath": "Copy repository path", + "native.copySessionID": "Copy session ID", + "native.copyShortHash": "Copy Short Hash", + "native.copyTable": "Copy table", + "native.copyWorktreePath": "Copy worktree path", + "native.couldNotCheckForUpdates": "Could not check for updates", + "native.couldNotCheckForUpdates.variant2": "Could not check for updates.", + "native.couldNotCompareTheLocalAdapterWithTheVerifiedVersion": "Could not compare the local adapter with the verified version.", + "native.couldNotConfirm": "Could not confirm", + "native.couldNotCopyYourStableSettings": "Could not copy your Stable settings", + "native.couldNotInspectSavedFile": "Could not inspect saved file", + "native.couldNotInspectTheSelectedHistoryRefreshThePreviewToRetry": "Could not inspect the selected history. Refresh the preview to retry.", + "native.couldNotInspectThisWorktree": "Could not inspect this worktree", + "native.couldNotInstallUpdate": "Could not install update", + "native.couldNotLoadChangedFiles": "Could not load changed files", + "native.couldNotLoadFilePreview": "Could not load file preview", + "native.couldNotLoadProject": "Could not load project", + "native.couldNotLoadSavedChanges": "Could not load saved changes", + "native.couldNotLoadServiceDependencies": "Could not load service dependencies", + "native.couldNotLoadTheChangedFiles": "Could not load the changed files.", + "native.couldNotMoveTheEditorTabItsContentCouldNotBeSynchronized": "Could not move the editor tab. Its content could not be synchronized.", + "native.couldNotOpenHTMLInBrowser": "Could not open HTML in browser", + "native.couldNotRead": "Could not read {arg1}: {arg2}", + "native.couldNotReadTheProjectFolderCheckThatItStillExistsAndThatLitheHasPermissionToAccessIt": "Could not read the project folder. Check that it still exists and that Lithe has permission to access it.", + "native.couldNotRegisterLitheMCPIn": "Could not register Lithe MCP in {arg1}: {arg2}", + "native.couldNotRemoveLitheMCPFrom": "Could not remove Lithe MCP from {arg1}: {arg2}", + "native.couldNotRestoreLocalHistory": "Could not restore local history", + "native.couldNotRestoreProjectHistory": "Could not restore project history", + "native.couldNotRestoreThisProjectHistoryEntry": "Could not restore this project history entry", + "native.couldNotSaveAccessToTheSelectedBackgroundImage": "Could not save access to the selected background image.", + "native.couldNotSaveServiceSelection": "Could not save service selection", + "native.couldNotVerifyTheCommitOutcomeReviewBeforeRetrying": "Could not verify the commit outcome. Review before retrying.", + "native.couldNotWriteThePreviousContentsWereKeptIn": "Could not write {arg1}. The previous contents were kept in {arg2}.", + "native.cpu": "CPU", + "native.create": "Create", + "native.createABranchAtTheHistorySavedBeforeThisRebaseYourCurrentCheckoutStaysInPlace": "Create a branch at the history saved before this rebase. Your current checkout stays in place.", + "native.createABranchAtTheOriginalHistorySoYouCanInspectItInGitLogYourCurrentCheckoutStaysInPlace": "Create a branch at the original history so you can inspect it in Git Log. Your current checkout stays in place.", + "native.createACheckoutToGetStarted": "Create a checkout to get started.", + "native.createAMergeCommit": "Create a merge commit", + "native.createANewCommitThatReverses": "Create a new commit that reverses {arg1}.", + "native.createAPlanBeforeMakingChanges": "Read and plan without modifying files.", + "native.createASharedProjectConfigurationOrALocalOverride": "Create a shared project configuration or a local override.", + "native.createAnIndependentCheckoutAndANewBranchFromTheSelectedReference": "Create an independent checkout and a new branch from the selected reference.", + "native.createAsDraft": "Create as draft", + "native.createBranch": "Create Branch", + "native.createDraft": "Create Draft", + "native.createFrom": "Create from '{arg1}'.", + "native.createMergeCommit": "Create merge commit", + "native.createOnCommitLeaveTheMessageEmptyForALightweightTag": "Create on commit {arg1}. Leave the message empty for a lightweight tag.", + "native.createPatch": "Create Patch", + "native.createPatch.variant2": "Create Patch…", + "native.createPatchBetweenCommits": "Export Patch Between Commits…", + "native.createPullRequest": "Create Pull Request", + "native.createPullRequest.variant2": "Create pull request", + "native.createRecoveryBranch": "Create Recovery Branch", + "native.createRecoveryBranch.variant2": "Create Recovery Branch…", + "native.createWithoutCheckingOutFiles": "Create without checking out files", + "native.createdOutsideLithe": "Created outside Lithe.", + "native.createdWorktreeFor": "Created worktree for {arg1}", + "native.createsANewBranchOn": "Creates a new branch on {arg1}", + "native.creatingArchive": "Creating archive…", + "native.creatingPullRequest": "Creating pull request…", + "native.credentialAvailableInClaudeConfiguration": "Credential available in Claude configuration", + "native.credentialAvailableInCodexConfiguration": "Credential available in Codex configuration", + "native.credentials": "Credentials", + "native.credentialsUseYourExistingGitHelperAndSSHConfiguration": "Credentials use your existing Git helper and SSH configuration.", + "native.cssSupport": "CSS Support", + "native.current": "Current", + "native.current.variant2": "Current: {arg1}", + "native.currentApplicationMemoryAverageSinceLaunch": "Current application memory: {arg1}\nAverage since launch: {arg2}", + "native.currentBranch": "Current branch", + "native.currentBranchWithSafetyCheck": "Current branch with safety check", + "native.currentCommit": "Current commit: {arg1}", + "native.currentFile": "Current File", + "native.currentFileRemainsAvailableAddASupportedJavaOrMavenEntryPointThenIdentifyTheProjectAgain": "Current File remains available. Add a supported Java or Maven entry point, then identify the project again.", + "native.currentGitHubRepository": "Current GitHub repository", + "native.currentProvider": "Current provider: {arg1}", + "native.currentRepository": "Current repository", + "native.currentRun": "Current run", + "native.currentSession": "Current session", + "native.currentVersion": "Current Version", + "native.currentVersion.variant2": "Current version", + "native.currentVersionVerifiedTarget": "Current version: {arg1} · Verified target: {arg2}", + "native.currentlyEffective": "Currently effective", + "native.custom": "Custom", + "native.customAPIProvider": "Custom API provider", + "native.customAgent": "Custom Agent", + "native.customInstructions": "Custom instructions", + "native.customMavenHome": "Custom Maven Home", + "native.customTemplate": "Custom template", + "native.customizeShortcutsForLitheActionsChangesApplyImmediately": "Customize shortcuts for Lithe actions. Changes apply immediately.", + "native.cyrillicLetters": "Cyrillic letters", + "native.dConversations": "{arg1} conversations", + "native.dMessages": "{arg1} messages", + "native.dSelected": "{arg1} selected", + "native.daemon": "Daemon", + "native.dangerZone": "Danger Zone", + "native.dark": "Dark", + "native.dartSupport": "Dart Support", + "native.database": "Database", + "native.date": "Date", + "native.debug": "Debug", + "native.debugResume": "Debug: Resume", + "native.debugStepInto": "Debug: Step Into", + "native.debugStepOut": "Debug: Step Out", + "native.debugStepOver": "Debug: Step Over", + "native.debugTarget": "Debug target", + "native.debugger": "Debugger", + "native.debuggingCode": "Debugging code", + "native.decreaseFontSize": "Decrease Font Size", + "native.deepReasoningForComplexTasks": "Deep reasoning for complex tasks", + "native.default": "Default", + "native.defaultChangeList": "Default ChangeList", + "native.defaultDirectory": "Default directory", + "native.defaultOptions": "Default options", + "native.defaultPushBehavior": "Default push behavior", + "native.defaultShell": "Default shell", + "native.defaultsForOrdinaryFetchInEveryProjectARepositorySOwnGitConfigurationAboveStillWinsWhereItIsSet": "Defaults for ordinary Fetch in every project. A repository's own Git configuration above still wins where it is set.", + "native.definitionNotFound": "Definition not found", + "native.definitions": "Definitions", + "native.deleteBranch": "Delete Branch", + "native.deleteBranch.variant2": "Delete branch?", + "native.deleteChangeList": "Delete ChangeList", + "native.deleteChangeList.variant2": "Delete ChangeList?", + "native.deleteChat": "Delete Chat", + "native.deleteCollectedApplicationLogs": "Delete collected application logs?", + "native.deleteOnlyLitheManagedLogsInTheActiveDirectoryAcrossAllModulesAndSessionsFilte2970fa47b9": "Delete only Lithe-managed logs in the active directory across all modules and sessions. Filters do not limit this action. New events will continue to be recorded.", + "native.deleteProvider": "Delete provider", + "native.deleteTag": "Delete Tag…", + "native.deleteTag.variant2": "Delete tag '{arg1}'?", + "native.deleteTagTargetIsNotACommit": "Delete Tag… (target is not a commit)", + "native.deleteTheLocalBranchGitWillRefuseIfItContainsUnmergedWork": "Delete the local branch {arg1}? Git will refuse if it contains unmerged work.", + "native.deleteThisProviderAndItsSavedAPIKeyAgentsUsingItWillBeUnlinkedItIsAlsoRemovedFc47686de94": "Delete this provider and its saved API key? Agents using it will be unlinked. It is also removed from AI Providers in Settings.", + "native.deleted": "Deleted", + "native.deletedBranch": "Deleted branch '{arg1}'", + "native.deletedReferences": "Deleted references: {arg1}", + "native.deletedTag": "Deleted tag '{arg1}'", + "native.deletions": "Deletions", + "native.deny": "Deny", + "native.dependencies": "Dependencies", + "native.dependencyPaths": "Dependency Paths", + "native.dependencyResolutionCancelled": "Dependency resolution cancelled", + "native.deprecatedAPI": "Deprecated API", + "native.description": "Description", + "native.descriptionFormat": "Description format", + "native.deselectAll": "Deselect All", + "native.deselectAll.variant2": "Deselect all", + "native.destinationFolder": "Destination folder", + "native.detachedHEAD": "Detached HEAD", + "native.detailed": "Detailed", + "native.detailedDiagnostics": "Detailed diagnostics", + "native.detailedDiagnosticsCoverTheSelectedModuleAndExpireAfter15MinutesRefreshToSeeTheCurrentStatus": "Detailed diagnostics cover the selected module and expire after 15 minutes. Refresh to see the current status.", + "native.detailedDiagnosticsFor15Minutes": "Detailed diagnostics for 15 minutes", + "native.detailedDiagnosticsOff": "Detailed diagnostics: Off", + "native.detailedDiagnosticsOn": "Detailed diagnostics: On ({arg1})", + "native.detailedSubjectBody": "Detailed subject + body", + "native.detectBuildSystemsAgain": "Detect build systems again", + "native.detectInstalledShells": "Detect Installed Shells", + "native.detectedConfigurations": "Detected configurations", + "native.detectedJDK": "Detected JDK", + "native.detectedMaven": "Detected Maven", + "native.detectedOnThisMac": "detected on this Mac", + "native.detectedSystemJDK": "Detected system JDK", + "native.detecting": "Detecting…", + "native.detectingBuildSystems": "Detecting build systems…", + "native.developerTools": "Developer Tools", + "native.diagnosticBundle": "Diagnostic bundle", + "native.diagnostics": "Diagnostics", + "native.diagnosticsBundle": "Diagnostics bundle", + "native.diagnosticsFromJDTLSWillAppearHere": "Diagnostics from JDT LS will appear here.", + "native.didNotReportAVersionOrNewerIsRequired": "{arg1} did not report a version; {arg2} or newer is required.", + "native.diff": "Diff", + "native.diffCharacterLimit": "Diff character limit", + "native.diffWith": "Diff: {arg1} with {arg2}", + "native.differentJDKVendorSelected": "Different JDK vendor selected", + "native.directory": "Directory", + "native.directory.variant2": "Directory: {arg1}", + "native.directoryOrFileName": "Directory or file name", + "native.disable": "Disable", + "native.disableAutomaticScrolling": "Disable automatic scrolling", + "native.disableBreakpoint": "Disable breakpoint", + "native.disableExceptionBreakpoint": "Disable {arg1} exception breakpoint", + "native.disableFieldBreakpoint": "Disable {arg1} field breakpoint", + "native.disableMCP": "Disable MCP", + "native.disableMethodBreakpoint": "Disable {arg1} method breakpoint", + "native.disableSoftWraps": "Disable soft wraps", + "native.disabled": "{arg1} disabled", + "native.disabled.variant2": "Disabled", + "native.discard": "Discard", + "native.discardBlock": "Discard Block", + "native.discardChanges": "Discard Changes", + "native.discardSelectedChange": "Discard selected change", + "native.discardThisChangeBlock": "Discard this change block", + "native.discardThisFileAndRetry": "Discard this file and retry", + "native.discardedEditorChanges": "Discarded editor changes", + "native.disconnect": "Disconnect", + "native.disconnected": "Disconnected", + "native.discovering": "Discovering…", + "native.discoveringMaven": "Discovering Maven…", + "native.dismiss": "Dismiss", + "native.dismissHistoryOperationResult": "Dismiss history operation result", + "native.dismissNotification": "Dismiss notification", + "native.dismissRebaseResult": "Dismiss rebase result", + "native.dismissResults": "Dismiss Results", + "native.display": "Display", + "native.doNotFetch": "Do not fetch", + "native.doNotFetchSubmodules": "Do not fetch submodules", + "native.doNotFetchTags": "Do not fetch tags", + "native.doNotIgnore": "Do not ignore", + "native.doNotPushByDefault": "Do not push by default", + "native.dockerCompose": "Docker Compose", + "native.dockerfileSupport": "Dockerfile Support", + "native.documentation": "Documentation", + "native.doesNotIncludeLitheRendersThoseCharactersWithAndTheSystemFont": "{arg1} does not include {arg2}; Lithe renders those characters with {arg3} and the system font.", + "native.doesNotProvideArchivedConversations": "{arg1} does not provide archived conversations.", + "native.donTAllow": "Don’t Allow", + "native.donTAskAgain": "Don't ask again", + "native.download": "Download", + "native.download.variant2": "Download {arg1}", + "native.downloadAndInstall": "Download and Install", + "native.downloadPHPSupportFromTheOfficialPluginRelease": "Download PHP Support from the official plugin release.", + "native.downloadPHPSupportFromTheOfficialSignedPluginPackage": "Download PHP Support from the official signed plugin package.", + "native.downloadStableRelease": "Download Stable Release", + "native.downloadedLldOfLldEnabled": "Downloaded ({arg1} of {arg2} enabled)", + "native.downloadedS": "Downloaded {arg1} · {arg2}/s", + "native.downloadingFullStableRelease": "Downloading full stable release…", + "native.downloadingPackages": "Downloading packages…", + "native.downloadingUpdate": "Downloading update {arg1}…", + "native.draft": "Draft", + "native.dragFilesHereOrClickToChooseFiles": "Drag files here or click to choose files", + "native.drop": "Drop", + "native.drop.variant2": "Drop {arg1}?", + "native.dropCommit": "Remove Commit from History", + "native.dropCommit.variant2": "Remove Commit from History…", + "native.dropFilesHere": "Drop files here", + "native.dropShelf": "Drop Shelf", + "native.dropStash": "Drop Stash", + "native.dropThisShelf": "Drop this shelf?", + "native.droppedLldWriteFailuresLld": "Dropped: {arg1}; write failures: {arg2}", + "native.duplicate": "Duplicate", + "native.duplicateOmitted": "duplicate omitted", + "native.durationLldMs": "Duration: {arg1} ms", + "native.eachAgentKeepsItsOwnConfigurationFileTurningAnAgentOffRemovesOnlyTheEntryLithe602e7165d1": "Each Agent keeps its own configuration file. Turning an Agent off removes only the entry Lithe wrote; an entry created outside Lithe is never removed without taking ownership first.", + "native.eachBuiltInPresetIncludesAPreviewOfTheGeneratedMessage": "Each built-in preset includes a preview of the generated message.", + "native.eachRepositoryHasItsOwnCommitCompletedStepsAreKeptIfAnotherRepositoryFails": "Each repository has its own commit. Completed steps are kept if another repository fails.", + "native.eachRowExplainsTheSettingTheValueGitIsActuallyUsingAndWhereThatValueComesFrom": "Each row explains the setting, the value Git is actually using, and where that value comes from.", + "native.eachSubmoduleIsPushedBeforeItsParent": "Each submodule is pushed before its parent.", + "native.earlierEvidenceWasOmittedByCollectionLimits": "Earlier evidence was omitted by collection limits.", + "native.earlierGitCommandsWereTruncatedToLimitMemoryUse": "Earlier Git commands were truncated to limit memory use.", + "native.earlierGitOutputWasOmittedToLimitMemoryUse": "Earlier Git output was omitted to limit memory use.", + "native.earlierLogContentOmitted": "Earlier log content omitted", + "native.edit": "Edit…", + "native.editBreakpoint": "Edit Breakpoint…", + "native.editCommitMessage": "Edit Commit Message", + "native.editCommitMessage.variant2": "Edit Commit Message…", + "native.editConfiguration": "Edit Configuration…", + "native.editConfigurationText": "Edit configuration text", + "native.editExceptionBreakpoint": "Edit {arg1} exception breakpoint", + "native.editExceptionBreakpoint.variant2": "Edit exception breakpoint", + "native.editFieldBreakpoint": "Edit {arg1} field breakpoint", + "native.editFilesAndStageTheChangesYouWantToIncludeAmendAndContinueUpdatesThisCommitCobd078ad14c": "Edit files and stage the changes you want to include. Amend and Continue updates this commit; Continue keeps the existing commit.", + "native.editMessage": "Edit message", + "native.editMethodBreakpoint": "Edit {arg1} method breakpoint", + "native.editProvider": "Edit provider", + "native.editPullRequest": "Edit pull request", + "native.editRunConfiguration": "Edit run configuration", + "native.editSearchExclusions": "Edit search exclusions", + "native.editService": "Edit Service", + "native.editServiceConfiguration": "Edit service configuration", + "native.editTitleAndDescription": "Edit title and description", + "native.editedLldFile": "Edited {arg1} file", + "native.editedLldFiles": "Edited {arg1} files", + "native.editor": "Editor", + "native.editorAndLanguageServices": "Editor and language services", + "native.edits": "Edits", + "native.effectiveFetchOptions": "Effective Fetch options", + "native.effectiveGit": "Effective Git: {arg1}", + "native.effectiveGit.variant2": "Effective Git: {arg1} · {arg2}", + "native.effectiveGitFromPATH": "Effective Git: from PATH", + "native.effectiveJDK": "Effective JDK", + "native.effectiveSource": "Effective source", + "native.effectiveValue": "Effective value", + "native.effectiveValue.variant2": "Effective value: {arg1}", + "native.elapsed": "Elapsed", + "native.elapsed.variant2": "Elapsed: {arg1}", + "native.elapsedLldS": "Elapsed {arg1} s", + "native.elapsedSinceSendingIncludingToolsAndPermissionWaits": "Elapsed since sending, including tools and permission waits.", + "native.elixirSupport": "Elixir Support", + "native.email": "Email", + "native.enableAgentConversation": "Enable Agent conversation", + "native.enableBreakpoint": "Enable breakpoint", + "native.enableBreakpoints": "Enable breakpoints", + "native.enableExceptionBreakpoint": "Enable {arg1} exception breakpoint", + "native.enableFieldBreakpoint": "Enable {arg1} field breakpoint", + "native.enableForThisProject": "Enable for this project", + "native.enableMCP": "Enable MCP", + "native.enableMethodBreakpoint": "Enable {arg1} method breakpoint", + "native.enabled": "{arg1} enabled", + "native.enabled.variant2": "Enabled", + "native.enabledPluginsCanAlsoUseThisProjectGrant": "Enabled plugins can also use this project grant.", + "native.endAll": "End All", + "native.endAll.variant2": "End all", + "native.endAllAndQuit": "End All and Quit", + "native.endChecked": "End Checked", + "native.endOnNextLaunch": "End on next launch", + "native.endThisTask": "End This Task", + "native.endThisTerminal": "End This Terminal", + "native.endWithoutAsking": "End without asking", + "native.english": "English", + "native.enterABranchName": "Enter a branch name", + "native.enterABranchName.variant2": "Enter a branch name.", + "native.enterABranchNameBeforePublishing": "Enter a branch name before publishing.", + "native.enterAChangeListName1100Characters": "Enter a ChangeList name (1–100 characters).", + "native.enterACommitMessage": "Enter a commit message", + "native.enterACompleteServerURL": "Enter a complete server URL.", + "native.enterAModelIDSupportedByYourProvider": "Enter a model ID supported by your provider.", + "native.enterANonemptyGitIdentityWithoutControlCharactersOrAngleBrackets": "Enter a nonempty Git identity without control characters or angle brackets", + "native.enterAPIKey": "Enter API key", + "native.enterAProviderName": "Enter a provider name.", + "native.enterAServerName": "Enter a server name.", + "native.enterATagName": "Enter a tag name", + "native.enterATagNameBranchNameCommitHashOrOtherGitRevision": "Enter a tag name, branch name, commit hash, or other Git revision.", + "native.enterAWholeNumberFromDToD": "Enter a whole number from {arg1} to {arg2}.", + "native.enterAddress": "Enter address", + "native.enterAnAPIKeyCLILoginCredentialsCannotBeUsedAsAnAPIKey": "Enter an API key. CLI login credentials cannot be used as an API key.", + "native.enterAnHTTPOrHTTPSAPIURLWithoutCredentialsQueryParametersOrFragments": "Enter an HTTP or HTTPS API URL without credentials, query parameters or fragments.", + "native.enterModelID": "Enter model ID", + "native.enterTheCommandThatStartsThisServer": "Enter the command that starts this server.", + "native.enterTheNextFunctionCall": "Enter the next function call", + "native.enterTheOneTimeCode": "Enter the one-time code", + "native.environment": "Environment", + "native.environmentVariableAndHeaderNamesMustBeUnique": "Environment variable and header names must be unique.", + "native.environmentVariables": "Environment variables", + "native.erlangSupport": "Erlang Support", + "native.error": "Error", + "native.errors": "Errors", + "native.errorsOnly": "Errors only", + "native.estimatedFromTheSizeOfTheLatestRequest": "Estimated from the size of the latest request.", + "native.estimatedFromTheSizeOfTheLatestRequestAndScaledToTheProviderSInputTokens": "Estimated from the size of the latest request and scaled to the provider's input tokens.", + "native.evaluateExpression": "Evaluate expression", + "native.evaluateExpressionWhilePaused": "Evaluate expression while paused", + "native.example": "Example", + "native.exception": "Exception", + "native.exceptionBreakpoints": "Exception Breakpoints", + "native.excludeFromDependencyTree": "Exclude from dependency tree", + "native.excluded": "Excluded", + "native.excludedPaths": "Excluded Paths", + "native.executable": "Executable", + "native.executeMavenGoal": "Execute Maven Goal", + "native.executeMavenGoal.variant2": "Execute Maven goal", + "native.executeTheNextSourceLine": "Execute the next source line", + "native.existingLocalBranch": "Existing local branch", + "native.exitCodeLld": "Exit code {arg1}", + "native.expand": "Expand", + "native.expandDirectory": "Expand directory", + "native.expandJSONBlock": "Expand JSON block", + "native.expandLldLines": "Expand {arg1} lines", + "native.expandNotification": "Expand notification", + "native.expandRepository": "Expand repository", + "native.expandSection": "Expand section", + "native.expandTemporaryGitConfigurationOrCommandDetails": "Expand temporary Git configuration or command details", + "native.expanded": "Expanded", + "native.explainTheIntentTestingAndAnythingReviewersShouldKnow": "Explain the intent, testing, and anything reviewers should know…", + "native.explainWhatMustChangeBeforeThisPullRequestCanBeApproved": "Explain what must change before this pull request can be approved.", + "native.export": "Export…", + "native.exportAsPDF": "Export as PDF…", + "native.exportConversations": "Export conversations", + "native.exportDiagnosticsBundle": "Export Diagnostics Bundle", + "native.exportDiagnosticsBundle.variant2": "Export Diagnostics Bundle…", + "native.exportLogs": "Export logs", + "native.exportingConversations": "Exporting conversations…", + "native.exportsTheIndexAgainstHEAD": "Exports the index against HEAD.", + "native.exportsTheWorkingTreeSNetChangesAgainstHEADIncludingSelectedUntrackedFiles": "Exports the working tree's net changes against HEAD, including selected untracked files.", + "native.exportsWorkingFilesAgainstTheIndexIncludingSelectedUntrackedFiles": "Exports working files against the index, including selected untracked files.", + "native.expressionHistory": "Expression history", + "native.externalChange": "External change", + "native.externalEditsConflictWithUnsavedChanges": "External edits conflict with unsaved changes", + "native.extraDeepReasoningForDemandingTasks": "Extra deep reasoning for demanding tasks", + "native.fSupport": "F# Support", + "native.failed": "{arg1} ({arg2} failed)", + "native.failed.variant2": "Failed", + "native.failedToStartCheckTheLanguageServerConfiguration": "Failed to start; check the language server configuration", + "native.failures": "Failures", + "native.fast": "Fast", + "native.fastForwardOnPull": "Fast-forward on pull", + "native.fastForwardOnly": "Fast-forward only", + "native.fastMode": "Fast mode", + "native.favorites": "Favorites", + "native.featEditorAddMemoryUsageIndicator": "feat(editor): add memory usage indicator", + "native.features": "Features", + "native.fetch": "Fetch", + "native.fetchAgain": "Fetch again", + "native.fetchAll": "Fetch all", + "native.fetchAllRemotes": "Fetch All Remotes", + "native.fetchAllRemotes.variant2": "Fetch all remotes", + "native.fetchAllSubmodules": "Fetch all submodules", + "native.fetchAllTags": "Fetch all tags", + "native.fetchAndCommitPreferences": "Fetch and commit preferences", + "native.fetchCommandFailed": "Fetch command failed", + "native.fetchCommandSucceeded": "Fetch command succeeded", + "native.fetchCompletedWithNoReferenceChanges": "Fetch completed with no reference changes.", + "native.fetchDefaults": "Fetch defaults", + "native.fetchFailedNoCompletedGitInvocationWasReported": "Fetch failed — no completed Git invocation was reported", + "native.fetchInProgressPlannedCommandWaitingForResult": "Fetch in progress — planned command; waiting for result", + "native.fetchLocalConfiguration": "Fetch local configuration", + "native.fetchOptions": "Fetch Options", + "native.fetchOptions.variant2": "Fetch Options…", + "native.fetchSubmodules": "Fetch submodules", + "native.fetchSubmodulesOnDemand": "Fetch submodules on demand", + "native.fetchTags": "Fetch tags", + "native.fetchTagsThatPointToFetchedCommits": "Fetch tags that point to fetched commits", + "native.fetchThisAgentSLocalConfigurationInThePanelSettings": "Fetch this Agent's local configuration in Settings > AI > Agents.", + "native.fetchWhenNeeded": "Fetch when needed", + "native.fetchYourLocalConfigurationSoTheAgentCanSignIn": "Fetch your local configuration so the Agent can sign in.", + "native.fetchedGitRemotes": "Fetched Git remotes", + "native.fetchedLldPage": "Fetched {arg1} page", + "native.fetchedLldPages": "Fetched {arg1} pages", + "native.fieldBreakpoints": "Field Breakpoints", + "native.fileContext": "File context", + "native.fileContextIsNotSupportedYet": "File context is not supported yet", + "native.fileLldOfLld": "File {arg1} of {arg2}", + "native.fileSaved": "File saved", + "native.fileTreeRowHeight": "File tree row height", + "native.files": "Files", + "native.files.variant2": "files", + "native.filesAddedSaveYourEditorChangesBeforeSendingSoTheAgentCanReadThem": "Files added. Save your editor changes before sending so the Agent can read them.", + "native.filesReturnToTheDefaultChangeListAndMayBeIncludedByStageAllTheirGitStagingStateIsUnchanged": "Files return to the default ChangeList and may be included by Stage All. Their Git staging state is unchanged.", + "native.filesWillStayUnchangedNothingWillBeStagedOrCommittedAutomatically": "Files will stay unchanged. Nothing will be staged or committed automatically.", + "native.filterByChangedPath": "Filter by changed path", + "native.filterByTitleAuthorOrLabel": "Filter by title, author, or label", + "native.filterLogMessages": "Filter log messages", + "native.filterProblemsBySeverity": "Filter problems by severity", + "native.filterResults": "Filter results", + "native.find": "Find", + "native.findFilesAndActions": "Find files and actions", + "native.findInFile": "Find in File", + "native.findInFile.variant2": "Find in File…", + "native.findInFile.variant3": "Find in file", + "native.findInFiles": "Find in Files", + "native.findInGitConsole": "Find in Git console", + "native.findInLog": "Find in log", + "native.findInPage": "Find in page", + "native.findNext": "Find Next", + "native.findPrevious": "Find Previous", + "native.findReferencesToTheSelectedJavaSymbol": "Find references to the selected Java symbol", + "native.findReferencesToTheSelectedSymbol": "Find references to the selected symbol", + "native.findUsages": "Find Usages", + "native.finished": "Finished", + "native.firstLldLocations": "First {arg1} locations", + "native.fixTheFailingChecksBelowBeforeInstalling": "Fix the failing checks below before installing.", + "native.fixup": "Fixup", + "native.folderName": "Folder name", + "native.followGitSetting": "Follow Git setting", + "native.followGitTagRules": "Follow Git tag rules", + "native.followTheInstructionsYouDefineBelow": "Follow the instructions you define below", + "native.followsEachRemoteSTagSetting": "Follows each remote's tag setting", + "native.font": "Font", + "native.forceCheckout": "Force Checkout", + "native.forceRemove": "Force Remove", + "native.forceRemoveWorktree": "Force remove worktree?", + "native.forget": "Forget", + "native.format": "Format", + "native.formatJSON": "Format JSON", + "native.forward": "Forward", + "native.found": "found", + "native.framesAreNotAvailable": "Frames are not available", + "native.freeSpace": "Free space", + "native.fromJAVAHOME": "from JAVA_HOME", + "native.fromUTCISO8601": "From (UTC ISO 8601)", + "native.frontendAndOtherNonJavaRootsKeepTheWorkspaceEditorAndTerminalSettingsJavaAndMa2434fd734d": "Frontend and other non-Java roots keep the workspace editor and terminal settings. Java and Maven SDKs only apply to the backends listed on the left.", + "native.fullAccess": "Full access", + "native.fullProgramDeclaration": "Full Program Declaration", + "native.gatheringDiagnostics": "Gathering diagnostics…", + "native.general": "General", + "native.generateACommitMessageFromStagedDiffs": "Generate a commit message from staged diffs", + "native.generateACommitMessageFromTheSelectedDiff": "Generate a commit message from the selected diff", + "native.generateATitleAndDescriptionFromTheSelectedBranchChanges": "Generate a title and description from the selected branch changes", + "native.generateLitheRunGeneratedJsonToEnableRunAndDebugExistingProjectAndLocalOverridesWillBePreserved": "Generate .lithe/run/generated.json to enable Run and Debug. Existing project and local overrides will be preserved.", + "native.generatePreview": "Generate Preview", + "native.generateWithAI": "Generate with AI", + "native.generated1RunnableProjectEntry": "Generated 1 runnable project entry.", + "native.generatedFromThePluginSDeclaration": "Generated from the plugin's declaration.", + "native.generatedLldRunnableProjectEntries": "Generated {arg1} runnable project entries.", + "native.generatedMain": "generated main", + "native.generatedTest": "generated test", + "native.generating": "Generating…", + "native.gitAuthentication": "Git authentication", + "native.gitBehaviorAndConfiguration": "Git behavior and configuration", + "native.gitBranchOperation": "Git branch operation", + "native.gitBranchOptions": "Git branch options", + "native.gitCanRequestCredentialsInLithePasswordsAreNotSavedByLitheTheSelectedGitHelper42412f0efe": "Git can request credentials in Lithe. Passwords are not saved by Lithe; the selected Git helper controls credential storage.", + "native.gitCommandFailed": "Git command failed", + "native.gitCommandIsRunning": "Git command is running", + "native.gitCommandOutputWillAppearHere": "Git command output will appear here.", + "native.gitCommandSucceeded": "Git command succeeded", + "native.gitCompletedSuccessfullyWithNoOutput": "Git completed successfully with no output.", + "native.gitConfiguration": "Git configuration", + "native.gitConfigurationSaved": "Git configuration saved", + "native.gitConfigurationSaved.variant2": "Git configuration saved: {arg1}", + "native.gitCouldNotConfirmTheFinalStateRefreshHistoryBeforePerformingAnotherAction": "Git could not confirm the final state. Refresh history before performing another action.", + "native.gitDefault": "Git default", + "native.gitExecutable": "Git executable", + "native.gitExecutable.variant2": "Git executable: {arg1}", + "native.gitExecution": "Git execution", + "native.gitExitedWithCodeD": "Git exited with code {arg1}", + "native.gitFetchRemoveStaleBranches": "Git Fetch: remove stale branches", + "native.gitFetchRemoveStaleTags": "Git Fetch: remove stale tags", + "native.gitIsPreparingTheSequencer": "Git is preparing the sequencer.", + "native.gitIsReady": "Git is ready", + "native.gitNotFound": "Git not found", + "native.gitOperation": "Git operation", + "native.gitOperationFinished": "Git operation finished", + "native.gitPhase": "Git phase: {arg1}", + "native.gitProgramAndCredentials": "Git Program and Credentials", + "native.gitProgress": "Git progress", + "native.gitRecordsThisNameAndEmailInNewCommitsTheseSettingsDoNotChangeExistingCommits": "Git records this name and email in new commits. These settings do not change existing commits.", + "native.gitRequestedCredentialsAgainCheckThePreviousAnswer": "Git requested credentials again. Check the previous answer.", + "native.gitSetupIsUnavailable": "Git setup is unavailable.", + "native.gitStash": "Git stash", + "native.gitStashes": "Git Stashes", + "native.gitToolWindowActions": "Git tool window actions", + "native.gitVersion": "Git version", + "native.gitWasNotFound": "Git was not found", + "native.githubAuthorizationFailed": "GitHub authorization failed:", + "native.githubAuthorizationWasCancelled": "GitHub authorization was cancelled.", + "native.githubCouldNotBeReachedCheckYourInternetProxyOrVPNConnectionAndTryAgain": "GitHub could not be reached. Check your internet, proxy, or VPN connection and try again.", + "native.githubDidNotReturnAnyFileChangesForThisPullRequest": "GitHub did not return any file changes for this pull request.", + "native.githubRejectedTheUpdateRequestBecauseASharedAPILimitWasReachedOpenTheReleasePa741bf3b6a2": "GitHub rejected the update request because a shared API limit was reached. Open the Release page or try again after the limit resets.", + "native.githubReturnedAnUnexpectedResponse": "GitHub returned an unexpected response", + "native.githubSignInAndPullRequestManagementAreTemporarilyUnavailable": "GitHub sign-in and pull request management are temporarily unavailable.", + "native.githubSignInIsUnavailableInThisBuild": "GitHub sign-in is unavailable in this build.", + "native.githubUser": "GitHub user {arg1}", + "native.globalGit": "Global Git", + "native.globalGitConfiguration": "Global Git configuration", + "native.globalIdentityAppliesToOtherRepositoriesUnlessTheyOverrideIt": "Global identity applies to other repositories unless they override it.", + "native.goSupport": "Go Support", + "native.goToChildCommit": "Go to child commit {arg1}", + "native.goToDefinition": "Go to Definition", + "native.goToImplementation": "Go to Implementation", + "native.goToLine": "Go to Line", + "native.goToLine.variant2": "Go to Line…", + "native.goToLineColumn": "Go to Line:Column", + "native.goToParentCommit": "Go to parent commit {arg1}", + "native.goToUsage": "Go to Usage", + "native.graphqlSupport": "GraphQL Support", + "native.greekLetters": "Greek letters", + "native.groovySupport": "Groovy Support", + "native.groups": "Groups", + "native.hardResetDiscardChanges": "Hard Reset (Discard Changes)", + "native.haskellSupport": "Haskell Support", + "native.head": "HEAD", + "native.headAdvancedReviewBeforeContinuing": "HEAD advanced; review before continuing.", + "native.headBranch": "Head branch", + "native.headCurrentBranch": "HEAD (Current Branch)", + "native.headWillMoveToItsParentTheIndexAndWorkingFilesStayAsTheyAreStagedDifferencesWi69609861ef": "HEAD will move to its parent. The index and working files stay as they are; staged differences will be compared with the parent commit.", + "native.header": "Header", + "native.headers": "Headers", + "native.heapMemory": "Heap Memory", + "native.hiddenPaths": "Hidden paths", + "native.hideAPIKey": "Hide API key", + "native.hideBuildOutputToolWindow": "Hide Build Output tool window", + "native.hideBuildToolWindow": "Hide Build tool window", + "native.hideCommitDecorations": "Hide commit decorations", + "native.hideConfigurationList": "Hide configuration list", + "native.hideDebugToolWindow": "Hide Debug tool window", + "native.hideEntry": "Hide Entry", + "native.hideGitBlame": "Hide Git Blame", + "native.hideGitToolWindow": "Hide Git tool window", + "native.hideMavenBuildToolWindow": "Hide Maven Build tool window", + "native.hideMavenToolWindow": "Hide Maven tool window", + "native.hidePerformance": "Hide Performance", + "native.hidePlan": "Hide plan", + "native.hideProblemsToolWindow": "Hide Problems tool window", + "native.hideRunToolWindow": "Hide Run tool window", + "native.hideSessionDetails": "Hide Session Details", + "native.hideTerminalToolWindow": "Hide Terminal tool window", + "native.hideThinking": "Hide thinking", + "native.hideToolDetails": "Hide tool details", + "native.hideWorktreeRepositories": "Hide worktree repositories", + "native.high": "High", + "native.highlightWords": "Highlight words", + "native.hint": "Hint", + "native.history": "History", + "native.historyChangedButGitReportedAnIncompleteStepRefreshTheRepositoryAndInspectTheRf4eaf05b2a": "History changed, but Git reported an incomplete step. Refresh the repository and inspect the recovery reference before retrying.", + "native.historyFilter": "History filter", + "native.hit": "Hit: {arg1}", + "native.homepage": "Homepage", + "native.host": "Host", + "native.howGitMatchesAndStoresCredentialsForRemoteOperations": "How Git matches and stores credentials for remote operations.", + "native.howMuchTheJavaLanguageServerWritesToItsOwnLogQuieterLevelsReduceDiskWrites": "How much the Java language server writes to its own log. Quieter levels reduce disk writes.", + "native.howPullAndUpdateProjectCombineYourLocalAndRemoteCommits": "How Pull and Update Project combine your local and remote commits.", + "native.howUncommittedChangesArePutAsideBeforeSwitchingBranchesOrPulling": "How uncommitted changes are put aside before switching branches or pulling.", + "native.htmlSupport": "HTML Support", + "native.httpIsBlockedUntilYouExplicitlyAllowItForThisProvider": "HTTP is blocked until you explicitly allow it for this provider.", + "native.httpIsDisabledForThisProviderEnableTheInsecureHTTPOptionOrUseHTTPS": "HTTP is disabled for this provider. Enable the insecure HTTP option or use HTTPS.", + "native.httpSendsTheAPICredentialWithoutEncryptionUseOnlyATrustedEndpoint": "HTTP sends the API credential without encryption. Use only a trusted endpoint.", + "native.identifyAgain": "Identify Again", + "native.identifyAndGenerate": "Identify and Generate", + "native.identifyingProject": "Identifying project…", + "native.if": "If: {arg1}", + "native.ifLitheCrashesOrIsForceQuitASmallHelperImmediatelyEndsItsTerminalsAndToolsAgenf47a67e797": "If Lithe crashes or is force quit, a small helper immediately ends its terminals and tools. Agent tasks and background services are kept until the next launch, so nothing important is ended without you. Agent tasks are always asked about, whatever you choose here.", + "native.ifResetUse": "If reset, use", + "native.ignoreWhitespace": "Ignore whitespace", + "native.ignoredEntriesLld": "Ignored entries: {arg1}", + "native.imperativeSubject": "Imperative subject", + "native.implementations": "Implementations", + "native.importFromClaude": "Import from Claude", + "native.importFromCodex": "Import from Codex", + "native.inEffect": "In effect: ", + "native.include": "Include", + "native.includeAShortBodyWhenUseful": "Include a short body when useful", + "native.includeInCommit": "Include in commit", + "native.includeNonProjectItems": "Include non-project items", + "native.includeTheURLPathWhenGitLooksUpHTTPCredentials": "Include the URL path when Git looks up HTTP credentials.", + "native.increaseFontSize": "Increase Font Size", + "native.indentation": "Indentation", + "native.indexAndWorkingTree": "Index and working tree", + "native.information": "Information", + "native.inherit": "Inherit", + "native.inheritProjectJDK": "Inherit project JDK", + "native.inheritProjectMaven": "Inherit project Maven", + "native.inheritProjectMavenJDK": "Inherit project Maven JDK", + "native.inheritedFromProject": "Inherited from project", + "native.inheritedValue": "Inherited value", + "native.initializeARepositoryBeforeSavingRepositorySpecificIdentity": "Initialize a repository before saving repository-specific identity", + "native.initializeGitInThisProjectFolder": "Initialize Git in this project folder?", + "native.initializeGitRepository": "Initialize Git Repository", + "native.initializeGitRepository.variant2": "Initialize Git Repository…", + "native.initializeGitRepository.variant3": "Initialize Git repository…", + "native.initializeGitToTrackChangesAndCreateYourFirstCommit": "Initialize Git to track changes and create your first commit.", + "native.initializeThisProjectFromGitBeforeSavingRepositorySpecificIdentity": "Initialize this project from Git before saving repository-specific identity.", + "native.initializingProjectIndex": "Initializing project index", + "native.input": "Input", + "native.input.variant2": "Input: {arg1}", + "native.insideThePlugin": "Inside the plugin", + "native.inspectGitStatusAndTheCommandOutputThenContinueWhenReady": "Inspect Git status and the command output, then continue when ready.", + "native.inspectTheCurrentGitStateBeforeRetryingTheRecoveryReferencePreservesTheOriginalHistory": "Inspect the current Git state before retrying. The recovery reference preserves the original history.", + "native.installACPAdaptersCheckTheRuntimeAndFollowYourLocalCLIConfiguration": "Install ACP adapters, check the runtime, and follow your local CLI configuration.", + "native.installACPAdaptersCheckTheRuntimeAndManageLocalOrCustomProviders": "Install ACP adapters, check the runtime, and manage local or custom providers.", + "native.installAndRestart": "Install and Restart", + "native.installAutoBuild": "Install AutoBuild", + "native.installCommunityPlugin": "Install community plugin “{arg1}”?", + "native.installFromRepository": "Install from repository", + "native.installNodeJsAndNpmBeforeInstallingTheAdapter": "Install Node.js and npm before installing the adapter.", + "native.installPHPSupportFromASignedPluginPackage": "Install PHP Support from a signed plugin package.", + "native.installPHPSupportPlugin": "Install PHP Support Plugin", + "native.installPluginFromDisk": "Install Plugin from Disk", + "native.installPluginFromDisk.variant2": "Install Plugin from Disk…", + "native.installPreview": "Install Preview", + "native.installTheSelectedChannel": "Install the selected channel", + "native.installation": "Installation", + "native.installation.variant2": "Installation: {arg1}", + "native.installed": "Installed", + "native.installed.variant2": "Installed {arg1}", + "native.installedFrom": "Installed from {arg1}", + "native.installedFromDisk": "Installed from Disk", + "native.installedFromUpdatesOnlyComeFromThatRepository": "Installed from {arg1}. Updates only come from that repository.", + "native.installedLldOfLldEnabled": "Installed ({arg1} of {arg2} enabled)", + "native.installedOnThisMac": "Installed on this Mac: {arg1}", + "native.installedUpdateToAvailable": "Installed {arg1} · update to {arg2} available", + "native.installingPackages": "Installing packages…", + "native.installingUpdate": "Installing update {arg1}…", + "native.installingWithNpm": "Installing with npm…", + "native.integrateIncomingChangesIntoCurrentBranchM": "Integrate incoming changes into current branch (M)", + "native.interactiveRebase": "Interactive Rebase", + "native.interactiveRebaseAborted": "Interactive Rebase Aborted", + "native.interactiveRebaseCompleted": "Interactive Rebase Completed", + "native.interactiveRebaseConflicts": "Interactive Rebase: Conflicts", + "native.interactiveRebaseEditCommit": "Interactive Rebase: Edit Commit", + "native.interactiveRebaseFailed": "Interactive Rebase Failed", + "native.interactiveRebaseInterrupted": "Interactive Rebase Interrupted", + "native.interactiveRebasePaused": "Interactive Rebase Paused", + "native.interactivelyRebaseFromHere": "Interactive Rebase from Here…", + "native.interface": "Interface", + "native.interrupt": "Interrupt", + "native.interrupted": "Interrupted", + "native.invalidGitTagName": "Invalid Git tag name.", + "native.invalidJSONCheckTheConfigurationFormat": "Invalid JSON. Check the configuration format.", + "native.invalidJVMHeapCounters": "Invalid JVM heap counters.", + "native.invalidJVMPerformanceOutput": "Invalid JVM performance output.", + "native.invalidTOMLUseStraightQuotesInsteadOfSmartQuotes": "Invalid TOML. Use straight quotes (\") instead of smart quotes (“ ”).", + "native.invert": "Invert", + "native.isNoLongerAnOfficialPluginItMovedToTheCommunityPluginInLitheStoreAndGetsNoFurt8a96fdb405": "{arg1} is no longer an official plugin. It moved to the community plugin {arg2} in Lithe Store and gets no further updates here. Uninstall it, restart Lithe, then install the community version from the Marketplace.", + "native.isNotAvailable": "{arg1} is not available.", + "native.isNotInstalledLitheInstallsItWithYourNpmIntoItsApplicationData": "{arg1} is not installed. Lithe installs it with your npm into its application data.", + "native.isVersionOrNewerIsRequired": "{arg1} is version {arg2}; {arg3} or newer is required.", + "native.itCanReadFilesRunCommandsAndEditCodeAfterAskingForPermission": "It can read files, run commands and edit code after asking for permission.", + "native.itsDetailsAndActionsWillAppearHere": "Its details and actions will appear here.", + "native.japaneseCharacters": "Japanese characters", + "native.javaApplication": "Java Application", + "native.javaEntriesAppearAfterTheJavaLanguageServiceListsRunnableClasses": "Java entries appear after the Java language service lists runnable classes.", + "native.javaEntriesCouldNotBeRefreshed": "Java entries could not be refreshed", + "native.javaNavigationIsAvailableForJavaFiles": "Java navigation is available for .java files", + "native.javaNavigationIsIdle": "Java navigation is idle", + "native.javaNavigationReady": "Java navigation ready", + "native.javaRebuildIndex": "Java: Rebuild Index", + "native.javaRuntimeIsNotReadyRetryMavenReload": "Java runtime is not ready. Retry Maven reload.", + "native.javaSDK": "Java SDK", + "native.javaServiceFailedToStart": "Java service failed to start", + "native.javaServiceFailedToStart.variant2": "Java service failed to start: {arg1}", + "native.javaServiceIsPreparing": "Java service is preparing", + "native.javaServiceIsReady": "Java service is ready", + "native.javaServicePreparationTimedOut": "Java service preparation timed out", + "native.jdkHome": "JDK Home", + "native.jdkHome.variant2": "JDK home", + "native.jdwpIsNotEncryptedPreferLocalhostOrAnSSHTunnel": "JDWP is not encrypted; prefer localhost or an SSH tunnel", + "native.jdwpPort": "JDWP port", + "native.jsonSupport": "JSON Support", + "native.juliaSupport": "Julia Support", + "native.jumpToALineAndColumnInTheActiveEditor": "Jump to a line and column in the active editor", + "native.jumpToLatest": "Jump to latest", + "native.justNow": "just now", + "native.jvmPerformanceMonitoringEnded": "JVM performance monitoring ended.", + "native.keepAll": "Keep All", + "native.keepAll.variant2": "Keep all", + "native.keepCurrent": "Keep Current", + "native.keepEditor": "Keep Editor", + "native.keepExistingContent": "Keep existing content", + "native.keepRunning": "Keep Running", + "native.keepRunning.variant2": "Keep running", + "native.keepStaleBranches": "Keep stale branches", + "native.keepStaleTags": "Keep stale tags", + "native.keptEditorVersion": "Kept editor version", + "native.keymap": "Keymap…", + "native.koreanCharacters": "Korean characters", + "native.kotlinSupport": "Kotlin Support", + "native.labels": "Labels", + "native.labelsAndAssignees": "Labels and assignees", + "native.languageServer": "Language Server", + "native.languageServerInsideThePlugin": "{arg1} language server (inside the plugin)", + "native.languageServerInstalledOnThisMac": "{arg1} language server (installed on this Mac)", + "native.languageServerLogLevel": "Language server log level", + "native.languageServers": "Language servers", + "native.languageService": "Language Service", + "native.languageServiceForThisProject": "Language service for this project", + "native.languageServices": "Language Services", + "native.languagesFrameworks": "Languages & Frameworks", + "native.last30Days": "Last 30 Days", + "native.last7Days": "Last 7 Days", + "native.later": "Later", + "native.latestTurnAsReportedByTheAgentItsAccountingScopeDependsOnTheAgentTurnOnModelCa4056cd34fb": "Latest turn as reported by the Agent; its accounting scope depends on the Agent. Turn on Model call statistics for session totals.", + "native.latestVersion": "Latest Version", + "native.launch": "Launch", + "native.launchA": "— Launch a", + "native.launching": "Launching", + "native.layout": "Layout", + "native.leaveAReview": "Leave a review", + "native.letConnectedAgentsInspectAndControlThisProject": "Let connected agents inspect and control this project.", + "native.license": "License", + "native.lifecycle": "Lifecycle", + "native.light": "Light", + "native.line": "Line", + "native.lineBreakpointActions": "Line breakpoint actions", + "native.lineBreakpoints": "Line Breakpoints", + "native.lineColumn": "[Line] [:column]:", + "native.linear": "Linear", + "native.linesKeptForScrollingBackInNewTerminalSessions": "Lines kept for scrolling back in new terminal sessions.", + "native.linkedWorktree": "Linked worktree", + "native.listedIn": "Listed in", + "native.lithe": "Lithe", + "native.litheAgent": "Lithe Agent", + "native.litheApplicationDefault": "Lithe application default", + "native.litheAsksAgainAfterThePluginIsDisabledUpdatedOrLitheRestarts": "Lithe asks again after the plugin is disabled, updated, or Lithe restarts.", + "native.litheCanTGivePagesAccessToTheCameraOrMicrophoneInThisVersion": "Lithe can’t give pages access to the camera or microphone in this version.", + "native.litheCannotReplaceThisAppInItsCurrentFolderMoveItToAWritableApplicationsFolder9358c4d3f8": "Lithe cannot replace this app in its current folder. Move it to a writable Applications folder or install the stable release manually.", + "native.litheChecksGitHubReleasesForPublishedUpdates": "Lithe checks GitHub Releases for published updates.", + "native.litheClosedUnexpectedlyLastTimeAndLeftProgramsRunning": "Lithe closed unexpectedly last time and left programs running", + "native.litheCouldNotFindARunnableGit": "Lithe could not find a runnable Git.", + "native.litheDefault": "Lithe default", + "native.litheDoesNotSavePasswordsCredentialsAreStoredByTheGitCredentialHelperYouChoose": "Lithe does not save passwords. Credentials are stored by the Git credential helper you choose.", + "native.litheFetchDefaults": "Lithe Fetch Defaults", + "native.litheFetchRemoveStaleBranches": "Lithe Fetch: remove stale branches", + "native.litheFetchSubmodules": "Lithe: fetch submodules", + "native.litheFetchTags": "Lithe: fetch tags", + "native.litheFirstAsksEachProgramToStopGivingItThisLongToSaveItsWorkAndExitProgramsStiffc3767044": "Lithe first asks each program to stop, giving it this long to save its work and exit. Programs still running afterwards are force-stopped.", + "native.litheHasNotYetCompletedARealConversationWithThisAdapterVersion": "Lithe has not yet completed a real conversation with this adapter version.", + "native.litheIDEAccess": "Lithe IDE access", + "native.litheInstallsTheACPAdapterPackageNotTheVendorCLITheTwoAreIndependentAndShareTh3f39562117": "Lithe installs the ACP adapter package, not the vendor CLI. The two are independent and share the CLI's configuration.", + "native.litheIsUpToDate": "Lithe is up to date", + "native.litheIsUpToDateAtVersion": "Lithe is up to date at version {arg1}", + "native.litheLooksForCodexAndClaudeConfigurationFilesOnThisMac": "Lithe looks for Codex and Claude configuration files on this Mac.", + "native.litheLooksForCodexConfigTomlAndCodexAuthJsonOnThisMac": "Lithe looks for ~/.codex/config.toml and ~/.codex/auth.json on this Mac.", + "native.litheMCPWasRegisteredButCodexConfigTomlCouldNotBeAddedToGitSLocalExcludeList": "Lithe MCP was registered, but .codex/config.toml could not be added to Git’s local exclude list: {arg1}", + "native.litheNeedsToIdentifyTheProjectAndGenerateLitheRunGeneratedJsonBeforeRunAndDebu71dbeebe55": "Lithe needs to identify the project and generate .lithe/run/generated.json before Run and Debug are available. Project and local overrides will not be changed.", + "native.litheOfficial": "Lithe Official", + "native.lithePruneStaleBranches": "Lithe: prune stale branches", + "native.lithePublisherSignatureVerified": "Lithe publisher signature verified", + "native.litheReadsTheServersRegisteredInYourCodexAndClaudeCodeConfigurationSavingWritefeb8d3e17b": "Lithe reads the servers registered in your Codex and Claude Code configuration. Saving writes only the changed entry, keeps backups in Application Support, and never overwrites an entry created outside Lithe. Agents read MCP configuration when a session starts, so restart a session to apply changes.", + "native.litheShelve": "Lithe Shelve", + "native.litheShelves": "Lithe Shelves", + "native.litheWillCloneTheRepositoryIntoDestinationDescription": "Lithe will clone the repository into (destinationDescription).", + "native.litheWillDownloadTheUpdateAndRestartAfterReplacingTheCurrentApp": "Lithe will download the update and restart after replacing the current app.", + "native.litheWillLookForServicesAgainAndRefreshLitheRunGeneratedJsonProjectAndLocalOvec4eb77affa": "Lithe will look for services again and refresh .lithe/run/generated.json. Project and local overrides will not be changed.", + "native.lld": "{arg1} {arg2}", + "native.lld.variant2": "{arg1} ({arg2})", + "native.lldAffectedCommitsLldCommitsCoreCreatesARecoveryReferenceBeforeStarting": "{arg1} affected commits → {arg2} commits. Core creates a recovery reference before starting.", + "native.lldBytes": "{arg1} bytes", + "native.lldChangedFiles": "{arg1} changed files", + "native.lldCharacters": "{arg1} characters", + "native.lldCharactersTotal": "{arg1} characters total", + "native.lldFailed": "{arg1} failed", + "native.lldFileS": "{arg1} file(s) · {arg2}", + "native.lldIgnored": "{arg1} ignored", + "native.lldInterrupted": "{arg1} interrupted", + "native.lldLines": "{arg1} lines", + "native.lldLldCompleted": "{arg1}/{arg2} completed", + "native.lldMatches": " · {arg1} matches", + "native.lldModelCalls": "{arg1} model calls", + "native.lldMoreFiles": "… {arg1} more files", + "native.lldMoreNotifications": "{arg1} more notifications", + "native.lldMoreReferences": "… {arg1} more references", + "native.lldNonStreaming": "{arg1} non-streaming", + "native.lldOfLld": "{arg1} of {arg2}", + "native.lldOfLldServicesSelected": "{arg1} of {arg2} services selected", + "native.lldOfLldStepsProcessed": "{arg1} · {arg2} of {arg3} steps processed", + "native.lldPassed": "{arg1} passed", + "native.lldProcesses": "{arg1} processes", + "native.lldRequestsReturnedNoUsageSoTheseTotalsAreALowerBound": "{arg1} requests returned no usage, so these totals are a lower bound.", + "native.lldRows": "{arg1} rows", + "native.lldRunning": "{arg1} running", + "native.lldS": "{arg1} s", + "native.lldSelected": "{arg1} selected", + "native.lldSkipped": "{arg1} skipped", + "native.lldStaged": "{arg1} staged", + "native.lldSteps": "{arg1} steps", + "native.lldTestFailed": "{arg1} test failed", + "native.lldTestIgnored": "{arg1} test ignored", + "native.lldTestPassed": "{arg1} test passed", + "native.lldTestTotal": "{arg1} test total", + "native.lldTestsFailed": "{arg1} tests failed", + "native.lldTestsIgnored": "{arg1} tests ignored", + "native.lldTestsPassed": "{arg1} tests passed", + "native.lldTestsTotal": "{arg1} tests total", + "native.lldToolCalls": "{arg1} tool calls", + "native.lldTurns": "{arg1} turns", + "native.lldWorktreeRecordsNeedAttention": "{arg1} worktree records need attention", + "native.lldWorktrees": "{arg1} worktrees", + "native.lldhLldmLlds": "{arg1}h {arg2}m {arg3}s", + "native.lldmLlds": "{arg1}m {arg2}s", + "native.llds": "{arg1}s", + "native.load": "Load", + "native.loadDiskVersion": "Load Disk Version", + "native.loadMore": "Load More", + "native.loadMoreCommits": "Load more commits", + "native.loadProjectModel": "Load Project Model", + "native.loadTheDependenciesAgain": "Load the dependencies again", + "native.loadTheDependenciesOf": "Load the dependencies of {arg1}", + "native.loadTheModel": "Load the {arg1} model", + "native.loadTheProjectModel": "Load the project model", + "native.loadedFileSystemVersion": "Loaded file-system version", + "native.loading": "Loading…", + "native.loadingBranchDetails": "Loading branch details…", + "native.loadingBranches": "Loading branches", + "native.loadingBranches.variant2": "Loading branches…", + "native.loadingBreakpoints": "Loading breakpoints…", + "native.loadingChangedFiles": "Loading changed files…", + "native.loadingCommitDiff": "Loading commit diff…", + "native.loadingCommits": "Loading commits…", + "native.loadingComparison": "Loading comparison…", + "native.loadingConversation": "Loading conversation…", + "native.loadingDependencies": "Loading dependencies...", + "native.loadingDependencies.variant2": "Loading dependencies…", + "native.loadingDependenciesOf": "Loading dependencies of {arg1}", + "native.loadingDiff": "Loading diff…", + "native.loadingHistory": "Loading history…", + "native.loadingModel": "Loading {arg1} model", + "native.loadingObjectFields": "Loading object fields…", + "native.loadingProjectEnvironment": "Loading project environment…", + "native.loadingPullRequests": "Loading pull requests", + "native.loadingRunServices": "Loading run services...", + "native.loadingSessionSettings": "Loading session settings…", + "native.loadingWorktreeDetails": "Loading worktree details", + "native.loadingWorktrees": "Loading worktrees", + "native.local": "Local", + "native.localChanges": "Local Changes", + "native.localChanges.variant2": "Local changes", + "native.localChangesWereRestoredWithConflicts": "Local changes were restored with conflicts", + "native.localChangesWouldBeOverwritten": "Local changes would be overwritten", + "native.localCommitS": "local commit(s)", + "native.localConfiguration": "Local {arg1} configuration", + "native.localConfiguration.variant2": "Local configuration", + "native.localConfigurationFollowsYourCLIFilesCustomProvidersAreSavedInLitheAndDoNotChad03a7c54cb": "Local configuration follows your CLI files. Custom providers are saved in Lithe and do not change those files. Switching restarts this project's Agent connection; open history again to continue a saved conversation.", + "native.localDevelopmentPackage": "Local development package", + "native.localHistory": "Local History", + "native.localHistory.variant2": "Local History…", + "native.localHistoryIsAvailableForTextFiles": "Local History is available for text files", + "native.localJDKHomeOptional": "Local JDK Home (optional)", + "native.localPackage": "Local Package", + "native.localVariables": "Local Variables", + "native.lockWorktree": "Lock Worktree", + "native.locked": "Locked", + "native.locked.variant2": "Locked {arg1}", + "native.log": "Log: {arg1}", + "native.logDirectory": "Log directory", + "native.logFilter": "Log filter", + "native.logLevel": "Log level", + "native.logLocations": "Log locations", + "native.logTimeline": "Log timeline", + "native.logs": "Logs", + "native.logsAndDiagnostics": "Logs and Diagnostics", + "native.lookingForGit": "Looking for Git…", + "native.low": "Low", + "native.lowEffortAndASmallOutputLimitAreRecommendedForFastCommitMessageGeneration": "Low effort and a small output limit are recommended for fast commit-message generation.", + "native.lowRecommended": "Low (recommended)", + "native.luaSupport": "Lua Support", + "native.macosCouldNotPrepareTheUpdateDiskImageDownloadTheReleaseManuallyAndTryAgain": "macOS could not prepare the update disk image. Download the release manually and try again.", + "native.macosCouldNotPrepareTheUpdateDiskImageOpenTheReleasePageAndInstallItManually": "macOS could not prepare the update disk image. Open the Release page and install it manually.", + "native.mainClass": "Main class", + "native.mainJava": "main Java", + "native.mainResources": "main resources", + "native.mainWorktree": "Main Worktree", + "native.maintenance": "Maintenance", + "native.makeSupport": "Make Support", + "native.manage": "Manage…", + "native.manageAIProviders": "Manage AI Providers…", + "native.manageAllProjectBreakpoints": "Manage all project breakpoints", + "native.managePluginRepositories": "Manage Plugin Repositories…", + "native.manageProjectBreakpointsWithoutStartingADebugSession": "Manage project breakpoints without starting a debug session", + "native.manageSelected": "Manage selected", + "native.manageTheServersCodexAndClaudeCodeCanStart": "Manage the servers Codex and Claude Code can start.", + "native.managedMemory": "Managed Memory", + "native.managedOutsideTheMarketplaceNoUpdateSource": "Managed outside the Marketplace; no update source.", + "native.markTargetAs": "Mark Target As", + "native.markdownIsSupportedOnGitHub": "Markdown is supported on GitHub", + "native.markdownSupport": "Markdown Support", + "native.markdownTemplate": "Markdown template", + "native.marketplace": "Marketplace", + "native.matchCase": "Match Case", + "native.matchHostAndURLPath": "Match host and URL path", + "native.matchHostOnly": "Match host only", + "native.matches": "Matches", + "native.matches.variant2": "matches", + "native.matchesThePluginRepository": "Matches the plugin repository", + "native.matchingBranches": "Matching branches", + "native.mavenBuild": "Maven Build", + "native.mavenConfigurationChanged": "Maven configuration changed", + "native.mavenCouldNotResolveThisProjectLldProblems": "Maven could not resolve this project ({arg1} problems)", + "native.mavenCouldNotResolveThisProjectLldProblems.variant2": "Maven could not resolve this project ({arg1} problems): {arg2}", + "native.mavenDependencyResolutionExitedWithCode": "Maven dependency resolution exited with code {arg1}.", + "native.mavenDependencyResolutionTimedOutAfter60Seconds": "Maven dependency resolution timed out after 60 seconds.", + "native.mavenExecutable": "Maven executable", + "native.mavenExecutableNotResolved": "Maven executable not resolved", + "native.mavenHome": "Maven Home", + "native.mavenHomeOrExecutable": "Maven home or executable", + "native.mavenJDK": "Maven JDK", + "native.mavenJDKHome": "Maven JDK Home", + "native.mavenModule": "Maven Module", + "native.mavenModule.variant2": "Maven module", + "native.mavenPOMChanged": "Maven POM changed", + "native.mavenProject": "Maven project", + "native.mavenReloadWasCancelledOrTimedOut": "Maven reload was cancelled or timed out.", + "native.mavenRunAndDebugUseThisJDKUnlessASubprojectOrRunConfigurationOverridesIt": "Maven, Run, and Debug use this JDK unless a subproject or run configuration overrides it.", + "native.mavenSettings": "Maven settings", + "native.mavenSource": "Maven source", + "native.mavenSpringBoot": "Maven / Spring Boot", + "native.mavenWrapper": "Maven Wrapper", + "native.mavenWrapperIsNotExecutable": "Maven Wrapper is not executable", + "native.max": "Max", + "native.maximumMemory": "Maximum memory", + "native.maximumReasoningDepth": "Maximum reasoning depth", + "native.mcp": "MCP", + "native.mcpConfiguration": "MCP Configuration", + "native.mcpConfigurationManagementIsUnavailable": "MCP configuration management is unavailable.", + "native.mcpIsEnabledForThisProject": "MCP is enabled for this project.", + "native.mcpIsOffForThisProject": "MCP is off for this project.", + "native.mcpServers": "MCP servers", + "native.mcpTools": "MCP tools", + "native.mcpToolsSent": "MCP tools sent", + "native.me": "Me", + "native.medium": "Medium", + "native.menuActions": "Menu Actions", + "native.merge": "Merge", + "native.merge.variant2": "merge", + "native.mergeBranch": "Merge branch?", + "native.mergeInProgress": "Merge in progress", + "native.mergeIntoCurrentBranch": "Merge into Current Branch", + "native.mergeIntoTheCurrentBranchConflictsMayRequireTerminalResolution": "Merge {arg1} into the current branch. Conflicts may require terminal resolution.", + "native.mergeJoinsBothHistoriesWithAMergeCommitRebaseReplaysYourCommitsOnTopOfTheUpstr4232ebcb80": "Merge joins both histories with a merge commit. Rebase replays your commits on top of the upstream, keeping history linear but rewriting your commit hashes.", + "native.mergePullRequest": "Merge pull request?", + "native.merged": "Merged", + "native.mergingPullRequest": "Merging pull request…", + "native.message": " ({arg1})", + "native.message.variant2": "{arg1}, {arg2}", + "native.message.variant3": "{arg1}: {arg2}", + "native.message.variant4": ", ", + "native.message.variant5": "; ", + "native.message.variant6": "→ {arg1}", + "native.messageOptional": "Message (optional)", + "native.messageTheAgent": "Message the Agent", + "native.messages": "Messages", + "native.metadataUpdated": "Metadata updated", + "native.methodBreakpoints": "Method Breakpoints", + "native.microphoneInUse": "Microphone in use", + "native.minimap": "Minimap", + "native.minimumLevel": "Minimum level", + "native.minimumVersion": "Minimum version", + "native.mixedResetKeepChangesUnstaged": "Mixed Reset (Keep Changes Unstaged)", + "native.model": "Model", + "native.modelCallStatistics": "Model call statistics", + "native.modelCallsObservedByTheLocalProxyDuringThisTurnTTFTIsTheAverageTimeToTheFirstGeneratedToken": "Model calls observed by the local proxy during this turn. TTFT is the average time to the first generated token.", + "native.modelCallsSteps": "Model calls (steps)", + "native.modelTime": "Model time", + "native.modified": "Modified", + "native.modifiedFilesLld": "Modified files: {arg1}", + "native.module": "Module", + "native.moduleLogsRotateAt10MiBAndExpireAfter30DaysTheCombinedBudgetIs100MiBPerformanc7c993f9014": "Module logs rotate at 10 MiB and expire after 30 days. The combined budget is 100 MiB; performance logs have a separate 20 MiB budget.", + "native.modulePath": "Module path", + "native.moduleRoot": "Module Root", + "native.moduleSessionMinimumLevel": "Module: {arg1} · Session: {arg2} · Minimum level: {arg3}", + "native.more": "… more", + "native.moreActions": "More actions", + "native.moreAgents": "More Agents…", + "native.moreInformation": "More information", + "native.moreSessionSettings": "More session settings", + "native.moveCommitEarlier": "Move commit earlier", + "native.moveCommitLater": "Move commit later", + "native.moveEarlier": "Move Earlier", + "native.moveLater": "Move Later", + "native.moveLldItemsToTrash": "Move {arg1} items to Trash?", + "native.moveTheCurrentBranchToAndDiscardAllWorkingTreeChanges": "Move the current branch to {arg1} and discard all working-tree changes.", + "native.moveTheCurrentBranchToAndKeepChangesStaged": "Move the current branch to {arg1} and keep changes staged.", + "native.moveTheCurrentBranchToAndKeepChangesUnstaged": "Move the current branch to {arg1} and keep changes unstaged.", + "native.moveToBottom": "Move to Bottom", + "native.moveToBottomLeft": "Move to Bottom Left", + "native.moveToBottomRight": "Move to Bottom Right", + "native.moveToChangeList": "Move to ChangeList", + "native.moveToFolder": "Move to Folder", + "native.moveToLeft": "Move to Left", + "native.moveToLeftBottom": "Move to Left Bottom", + "native.moveToLeftTop": "Move to Left Top", + "native.moveToRight": "Move to Right", + "native.moveToRightBottom": "Move to Right Bottom", + "native.moveToRightTop": "Move to Right Top", + "native.moveToRoot": "Move to root", + "native.moveToTheNextMatchInTheActiveEditor": "Move to the next match in the active editor", + "native.moveToThePreviousMatchInTheActiveEditor": "Move to the previous match in the active editor", + "native.moveToTrash": "Move to Trash", + "native.moved": "Moved", + "native.muteBreakpoints": "Mute breakpoints", + "native.muteCamera": "Mute Camera", + "native.muteLineBreakpoints": "Mute Line Breakpoints", + "native.muteMicrophone": "Mute Microphone", + "native.name": "Name", + "native.name.variant2": "Name:", + "native.nameAndEmailAreWrittenToTheGlobalGitConfigurationExistingCommitsDoNotChange": "Name and email are written to the global Git configuration. Existing commits do not change.", + "native.nameAndEmailAreWrittenToThisRepositorySGitConfigurationAndOverrideTheGlobalVal4aca07ef08": "Name and email are written to this repository's Git configuration and override the global values. Existing commits do not change.", + "native.nativeInstaller": "Native installer", + "native.navigate": "Navigate", + "native.navigateToACallSiteOfTheSelectedJavaSymbol": "Navigate to a call site of the selected Java symbol", + "native.navigateToACallSiteOfTheSelectedSymbol": "Navigate to a call site of the selected symbol", + "native.navigateToAnImplementationOfTheSelectedSymbol": "Navigate to an implementation of the selected symbol", + "native.navigateToTheDeclarationOfTheSelectedSymbol": "Navigate to the declaration of the selected symbol", + "native.navigateToTheNextEditorLocation": "Navigate to the next editor location", + "native.navigateToThePreviousEditorLocation": "Navigate to the previous editor location", + "native.navigation": "Navigation", + "native.needsNoExtraPermissions": "Needs no extra permissions.", + "native.needsRestart": "Needs restart", + "native.new": "New", + "native.newBranch": "New Branch", + "native.newBranch.variant2": "New Branch…", + "native.newBranch.variant3": "New branch", + "native.newBranchFrom": "New Branch from '{arg1}'…", + "native.newBranchName": "New branch name", + "native.newChangeList": "New ChangeList", + "native.newChat": "New Chat", + "native.newConfiguration": "New Configuration", + "native.newConnection": "New Connection", + "native.newConversation": "New Conversation", + "native.newConversation.variant2": "New conversation", + "native.newDirectory": "New Directory…", + "native.newFile": "New File…", + "native.newFolder": "New Folder", + "native.newProject": "New Project…", + "native.newRunConfiguration": "New Run Configuration", + "native.newTag": "New Tag", + "native.newTag.variant2": "New Tag…", + "native.newTerminalSession": "New terminal session", + "native.newWindow": "New Window", + "native.newWindow.variant2": "New window", + "native.newWorktree": "New Worktree", + "native.nextConsoleExpression": "Next console expression", + "native.nextDiffMatch": "Next diff match", + "native.nextDifference": "Next difference", + "native.nextFile": "Next File", + "native.nextMatch": "Next Match", + "native.nextMatch.variant2": "Next match", + "native.nextMatchReturn": "Next match (Return)", + "native.nextRun": "Next run", + "native.noAIProviderIsConfiguredYet": "No AI provider is configured yet.", + "native.noAPIKeyFoundInClaudeConfiguration": "No API key found in Claude configuration", + "native.noAPIKeyFoundInCodexAuthJson": "No API key found in Codex auth.json", + "native.noAgent": "no Agent", + "native.noAgentIsReadyYetOpenThePanelSettingsToInstallAnAgentAndChooseALocalOrCustomProvider": "No Agent is ready yet. Open Settings > AI > Agents to install an Agent and choose a local or custom provider.", + "native.noAgentIsReadyYetOpenThePanelSettingsToInstallAnAgentAndFetchItsLocalConfiguration": "No Agent is ready yet. Open Settings > AI > Agents to install an Agent and fetch its local configuration.", + "native.noAgentIsSelected": "No Agent is selected.", + "native.noAgentIsSetUpYet": "No Agent is set up yet", + "native.noAvailableLocalBranchBranchesAlreadyCheckedOutInAnotherWorktreeCannotBeReused": "No available local branch. Branches already checked out in another worktree cannot be reused.", + "native.noBackgroundImageSelected": "No background image selected", + "native.noBranchesFound": "No branches found", + "native.noBreakpoints": "No breakpoints", + "native.noCallableTargetAtThisLocation": "No callable target at this location", + "native.noChangedFiles": "No changed files", + "native.noChanges": "No changes", + "native.noCodexConfigurationWasFound": "No Codex configuration was found", + "native.noColumns": "No Columns", + "native.noCommand": "No command", + "native.noCommitSelected": "No commit selected", + "native.noCommits": "No commits", + "native.noCommitsMatchThisView": "No commits match this view", + "native.noCommitsWereFoundForThisBranch": "No commits were found for this branch.", + "native.noCompletedGitInvocationWasReported": "No completed Git invocation was reported", + "native.noConfigurationWasFoundOnThisMac": "No {arg1} configuration was found on this Mac.", + "native.noConfigurationsInThisScope": "No configurations in this scope", + "native.noConversationYet": "No conversation yet", + "native.noCredentialFoundInClaudeConfiguration": "No credential found in Claude configuration", + "native.noCredentialFoundInCodexConfiguration": "No credential found in Codex configuration", + "native.noCurrentBranch": "No current branch", + "native.noCustomProvidersYet": "No custom providers yet.", + "native.noDefaultShellWasDetectedChooseADetectedShellOrRunDetectionAgain": "No default shell was detected. Choose a detected shell or run detection again.", + "native.noDependencies": "No dependencies", + "native.noDependencies.variant2": "No dependencies.", + "native.noDescriptionProvided": "No description provided.", + "native.noDifferences": "No differences", + "native.noDifferencesWereFoundForThisSelection": "No differences were found for this selection.", + "native.noEarlierConversations": "No earlier conversations", + "native.noEffectiveValueConfigured": "No effective value configured", + "native.noFileEdits": "No file edits", + "native.noFilesMatchTheConflictFilter": "No files match the conflict filter", + "native.noGitHubOrigin": "No GitHub origin", + "native.noGitRepositoryIsOpen": "No Git repository is open", + "native.noHistoryRecordedYet": "No history recorded yet", + "native.noImplementationsFound": "No implementations found", + "native.noJDKFoundSetJAVAHOMEInstallAJDKOrChooseAJDKDirectory": "No JDK found. Set JAVA_HOME, install a JDK, or choose a JDK directory.", + "native.noJDKIsConfiguredForThisProject": "No JDK is configured for this project.", + "native.noJVMPerformanceSamplesReceivedFor10Seconds": "No JVM performance samples received for 10 seconds.", + "native.noJavaNavigationResults": "No Java navigation results", + "native.noJavaProblems": "No Java problems", + "native.noLocalChanges": "No local changes", + "native.noLocalVariablesInTheCurrentFrame": "No local variables in the current frame", + "native.noMCPServersRegistered": "No MCP servers registered", + "native.noMatches": "No matches", + "native.noMatchesIn": "No matches in", + "native.noMatchesInActions": "No matches in Actions", + "native.noMatchesInAll": "No matches in All", + "native.noMatchesInClasses": "No matches in Classes", + "native.noMatchesInFiles": "No matches in Files", + "native.noMatchesInSymbols": "No matches in Symbols", + "native.noMatchesInText": "No matches in Text", + "native.noMatchingBranches": "No matching branches", + "native.noMatchingCommands": "No matching commands", + "native.noMatchingCommitsInTheLoadedHistory": "No matching commits in the loaded history.", + "native.noMatchingConversations": "No matching conversations", + "native.noMatchingItems": "No matching items", + "native.noMatchingLogs": "No matching logs", + "native.noMatchingMessages": "No matching messages", + "native.noMatchingPlugins": "No matching plugins", + "native.noMatchingUsers": "No matching users", + "native.noMavenExecutableWasFoundChooseMavenHomeOrAnExecutableInMavenSettings": "No Maven executable was found. Choose Maven Home or an executable in Maven Settings.", + "native.noMavenFoundAddAMavenWrapperToTheProjectOrChooseAMavenInstallation": "No Maven found. Add a Maven Wrapper to the project or choose a Maven installation.", + "native.noMavenProjectDetected": "No Maven project detected", + "native.noMavenProjectIsAvailableForDebug": "No Maven project is available for Debug", + "native.noModelsAvailable": "No models available", + "native.noNewCommitsPushingCreatesTheRemoteBranch": "No new commits. Pushing creates the remote branch.", + "native.noNotifications": "No notifications", + "native.noPaths": "No paths", + "native.noPathsResolvedForThisService": "No paths resolved for this service", + "native.noPlugins": "No Plugins", + "native.noPluginsAvailable": "No plugins available", + "native.noProblemsIn": "No problems in {arg1}", + "native.noProblemsMatchTheCurrentFilter": "No problems match the current filter", + "native.noProfilingEventsWereRecorded": "No profiling events were recorded.", + "native.noProjectEntryPointDetected": "No project entry point detected", + "native.noProjectHistoryRecordedYet": "No project history recorded yet", + "native.noProjectIsOpen": "No project is open", + "native.noProjectIsOpenRowsBackedByGitConfigurationWriteToTheGlobalGitConfigurationCom6dc0364452": "No project is open. Rows backed by Git configuration write to the global Git configuration; commit identity needs a repository.", + "native.noProjectLevelMCPEntryWasWritten": "No project-level MCP entry was written.", + "native.noProjectLoaded": "No project loaded", + "native.noProjectWasDetectedInThisWorkspace": "No {arg1} project was detected in this workspace.", + "native.noProviderYet": "No {arg1} provider yet", + "native.noPublishedReleaseIsAvailableYet": "No published release is available yet.", + "native.noPullRequests": "No pull requests", + "native.noPullRequestsMatchTheSelectedState": "No pull requests match the selected state.", + "native.noRecentProgressHasBeenReceivedTheTaskIsStillActive": "No recent progress has been received. The task is still active.", + "native.noRecentProjects": "No recent projects", + "native.noReleaseIsAvailableYet": "No release is available yet", + "native.noRememberedChoicesCheckRememberMyChoiceInTheQuitDialogToSkipTheQuestionForAKi279db9aaf7": "No remembered choices. Check “Remember my choice” in the quit dialog to skip the question for a kind of service next time.", + "native.noRemote": "No remote", + "native.noRemoteBranches": "No Remote Branches", + "native.noRemoteURL": "No remote URL", + "native.noRunServicesConfigured": "No run services configured", + "native.noRunnableServicesWereDetectedInThisProject": "No runnable services were detected in this project.", + "native.noRunningDebugProcessAcceptsStandardInput": "No running debug process accepts standard input", + "native.noSavedChanges": "No saved changes", + "native.noShellWasFoundAtChooseADetectedShellOrRunDetectionAgain": "No shell was found at {arg1}. Choose a detected shell or run detection again.", + "native.noShelves": "No shelves", + "native.noSpringBootOrMavenModuleConfigurations": "No Spring Boot or Maven Module configurations", + "native.noSubagentOutputYet": "No subagent output yet", + "native.noSubagentsYet": "No subagents yet", + "native.noTasksYet": "No tasks yet", + "native.noTextualDiffAvailable": "No textual diff available", + "native.noTextualDifference": "No textual difference", + "native.noToolsRunning": "No tools running", + "native.noUpdateCheckHasBeenPerformedYet": "No update check has been performed yet.", + "native.noUpdatePackageIsAvailableForThisMacArchitectureOpenTheReleasePageToCheckAvailableDownloads": "No update package is available for this Mac architecture. Open the Release page to check available downloads.", + "native.noUpdatePackageIsAvailableForThisMacDownloadTheReleaseManually": "No update package is available for this Mac. Download the release manually.", + "native.noUpdatesFoundInTheLocalHomebrewIndex": "No updates found in the local Homebrew index.", + "native.noUpstreamConfigured": "No upstream configured", + "native.noUsagesFound": "No usages found", + "native.noWorktrees": "No worktrees", + "native.nodeJsLldOrLater": "Node.js {arg1} or later", + "native.nodeJsLldOrLaterIsRequiredFound": "Node.js {arg1} or later is required; found {arg2}.", + "native.nodeJsLldOrLaterWasNotFoundInstallItThenCheckAgain": "Node.js {arg1} or later was not found. Install it, then check again.", + "native.nodeJsSupport": "Node.js Support", + "native.nonStreaming": "Non-streaming", + "native.nonStreamingResponses": "Non-streaming responses", + "native.none": "None", + "native.noneFastest": "None (fastest)", + "native.normalFolder": "Normal Folder", + "native.notAssigned": "Not Assigned", + "native.notAvailableForThisLitheOrMac": "{arg1} (not available for this Lithe or Mac)", + "native.notAvailableYetDependenciesAreNotIndexed": "Not available yet: dependencies are not indexed", + "native.notFetchedYet": "Not fetched yet.", + "native.notFound": "Not found", + "native.notIncludedInTheUpdatedPlan": "Not included in the updated plan", + "native.notInstalled": "Not installed", + "native.notInstalled.variant2": "Not installed · {arg1}", + "native.notInstalledInstallItToStartUsingThisAgent": "Not installed. Install it to start using this Agent.", + "native.notRefreshedYet": "Not refreshed yet", + "native.notRun": "Not run", + "native.notRunning": "Not running", + "native.notSelected": "Not selected", + "native.notSet": "Not set", + "native.notSupported": "Not Supported", + "native.notSupportedOnYet": "Not supported on {arg1} yet.", + "native.notUsed": "Not used", + "native.notUsedYet": "Not used yet.", + "native.nothingToPush": "Nothing to push", + "native.notifications": "Notifications", + "native.nowFollowsYourLocalConfiguration": "{arg1} now follows your local {arg2} configuration.", + "native.npmWasNotFoundItIsNormallyInstalledWithNodeJs": "npm was not found. It is normally installed with Node.js.", + "native.ocamlSupport": "OCaml Support", + "native.ofIsHandledBy": "{arg1} of {arg2} is handled by {arg3}.", + "native.off": "Off", + "native.offForThisProject": "Off for this project", + "native.official": "Official", + "native.olderCommitsAreNotListed": "Older commits are not listed.", + "native.olderCommitsAreOutsideTheLoadedHistory": "Older commits are outside the loaded history", + "native.on": "On", + "native.oneArgumentPerLineArgumentsKeepSpacesWithoutQuoting": "One argument per line. Arguments keep spaces without quoting.", + "native.oneFile": "One file: {arg1}", + "native.oneNAMEValueEntryPerLine": "One NAME=value entry per line", + "native.onePathPerLineRelativePathsAreResolvedFromTheWorkspace": "One path per line. Relative paths are resolved from the workspace.", + "native.onePatternPerLineCaseInsensitivePatternsUseWildcardsASimplifiedFormOfRegularEx4f7ef7b38a": "One pattern per line, case-insensitive. Patterns use wildcards, a simplified form of regular expressions: `*` matches any characters and `?` matches one.\nEnd a pattern with `/` or `/*` to hide a folder and everything in it; start with `**/` to match at any depth. Other patterns hide matching files.\nExamples:\n`**/dist/` — every dist folder\n`.lithe/*` — the .lithe folder in the project root\n`*.log` — all log files\n`docs/*.md` — Markdown files in docs\nThe built-in patterns below always apply.", + "native.onePatternPerLineLogCacheOnlyAffectsThisSearchEverywhereWindow": "One pattern per line: `*.log`, `**/.*/`, `.*`, `cache/`. Only affects this Search Everywhere window.", + "native.oneSentenceFocusedOnTheMainChange": "One sentence focused on the main change", + "native.oneTIMECODE": "ONE-TIME CODE", + "native.oneTest": "One test: {arg1}", + "native.oneTimeChoicesOverrideRepositoryPreferencesWhichOverrideApplicationDefaultsGit1dff06f51d": "One-time choices override repository preferences, which override application defaults. Git configuration is changed only when you save or clear a field below.", + "native.onlyAPIKeysAreUsedTheAgentSOwnAccountSignInSuchAsAChatGPTLoginIsNeverUsed": "Only API keys are used. The Agent's own account sign-in, such as a ChatGPT login, is never used.", + "native.onlyAskForActionsDetectedAsPotentiallyUnsafe": "Only ask for actions detected as potentially unsafe", + "native.onlyLocalFilesCanBeAttachedToAnAgentMessage": "Only local files can be attached to an Agent message.", + "native.onlySourceFilesOfTheCurrentLanguageCanBeOpenedFromOutsideTheWorkspace": "Only source files of the current language can be opened from outside the workspace", + "native.onlyTheAPIURLModelAndAPIKeyAreSavedOtherCLIOptionsContinueToUseYourLocalConfig36d9d4cdfd": "Only the API URL, model and API key are saved. Other CLI options continue to use your local configuration. The key is stored separately from settings. Editing shows a template of these saved fields.", + "native.onlyTheCommitMessageChangesLldCommitsWillReceiveNewIdentitiesStagedFilesAreNotIncluded": "Only the commit message changes. {arg1} commits will receive new identities. Staged files are not included.", + "native.onlyTheCurrentBranchCanBeUpdated": "Only the current branch can be updated", + "native.onlyTheFilesBelowAreCollectedCredentialsTokensAndHomeDirectoryPathsAreRedacted1a2820206c": "Only the files below are collected. Credentials, tokens, and home-directory paths are redacted automatically; workspace source, editor buffers, and terminal history are never included.", + "native.onlyTheMostRecentArchivedConversationsAreShown": "Only the most recent archived conversations are shown.", + "native.onlyTheReportedAgentEditsWillBeReversedConflictingChangesAndUnsavedEditorChangesWillBePreserved": "Only the reported Agent edits will be reversed. Conflicting changes and unsaved editor changes will be preserved.", + "native.open": "Open {arg1}", + "native.openAGitProjectBeforeUsingPullRequests": "Open a Git project before using pull requests", + "native.openAICommitSettings": "Open AI & Commit settings", + "native.openAJavaFile": "Open a Java file", + "native.openAJavaFileBeforeStartingDebug": "Open a Java file before starting Debug", + "native.openALocalFolderToStartWorking": "Open a local folder to start working.", + "native.openALocalProjectFolder": "Open a local project folder", + "native.openAProjectBeforeChoosingProjectPaths": "Open a project before choosing project paths.", + "native.openAProjectContainingAPomXmlFile": "Open a project containing a pom.xml file.", + "native.openAProjectToConfigureGitIdentity": "Open a project to configure Git identity.", + "native.openAProjectToConfigureItsJDKAndMavenRuntime": "Open a project to configure its JDK and Maven runtime.", + "native.openAProjectToConfigureItsRunConfigurations": "Open a project to configure its run configurations.", + "native.openAProjectToConfigureMCPAccessForAgents": "Open a project to configure MCP access for agents.", + "native.openAProjectToLetConnectedAgentsInspectAndControlIt": "Open a project to let connected agents inspect and control it.", + "native.openAProjectToSeeItsLanguageService": "Open a project to see its language service.", + "native.openAProjectToUseGit": "Open a project to use Git", + "native.openAgain": "Open again", + "native.openAgentSettings": "Open Agent Settings", + "native.openAutoBuildDownloads": "Open AutoBuild Downloads", + "native.openChanges": "Open Changes", + "native.openConfiguration": "Open Configuration", + "native.openFile": "Open file", + "native.openFileInEditorToSeeProblems": "Open file in editor to see problems", + "native.openGitBranches": "Open Git branches", + "native.openGitConsole": "Open Git Console", + "native.openGitHub": "Open GitHub", + "native.openGitHubProfile": "Open GitHub profile", + "native.openGitSettings": "Open Git Settings", + "native.openHTMLInBrowser": "Open HTML in Browser", + "native.openHistoryForTheActiveFile": "Open history for the active file", + "native.openInCurrentWindow": "Open in Current Window", + "native.openInEditor": "Open in Editor", + "native.openInLithe": "Open in Lithe", + "native.openInNewWindow": "Open in New Window", + "native.openInRightSplit": "Open in Right Split", + "native.openLogDirectory": "Open Log Directory", + "native.openModulePomXml": "Open module pom.xml", + "native.openNotificationHistory": "Open notification history", + "native.openPatch": "Open Patch…", + "native.openPluginManagement": "Open Plugin Management", + "native.openPomXml": "Open pom.xml", + "native.openProject": "Open Project", + "native.openProject.variant2": "Open Project…", + "native.openProjectWideLocalHistory": "Open project-wide local history", + "native.openProjects": "Open Projects", + "native.openProjectsIn": "Open projects in", + "native.openReleasePage": "Open Release Page", + "native.openRemote": "Open Remote ↗", + "native.openRemote.variant2": "Open remote", + "native.openStableDownloads": "Open Stable Downloads", + "native.openToInspect": "Open to inspect", + "native.openingItMovesTheConversationOutOfSArchiveAndBackIntoItsHistoryBrowsingArchive50e980deac": "Opening it moves the conversation out of {arg1}'s archive and back into its history. Browsing archived conversations never changes them.", + "native.optionalApprovalSummary": "Optional approval summary", + "native.options": "{arg1} options", + "native.options.variant2": "Options", + "native.or": "or", + "native.orLater": "{arg1} {arg2} or later", + "native.orLaterIsRequiredFoundUpdateItThenCheckAgain": "{arg1} {arg2} or later is required; found {arg3}. Update it, then check again.", + "native.originalContentIsMissingOrIncomplete": "Original content is missing or incomplete.", + "native.originalHEADCreateABranchFromTheRecoveryReferenceToInspectTheOriginalHistory": "Original HEAD: {arg1}. Create a branch from the recovery reference to inspect the original history.", + "native.other": "Other", + "native.otherChangeListsHaveStagedFilesUnstageThemBeforeCommittingTheCurrentChangeList": "Other ChangeLists have staged files. Unstage them before committing the current ChangeList.", + "native.otherLldBranches": "Other {arg1} branches", + "native.otherLldCommits": "Other {arg1} commits", + "native.otherLldFiles": "Other {arg1} files", + "native.otherLldTags": "Other {arg1} tags", + "native.otherReferenceChangesLldAddedLldUpdatedLldDeleted": "Other reference changes: {arg1} added, {arg2} updated, {arg3} deleted", + "native.otherRunConfigurations": "Other run configurations", + "native.output": "Output", + "native.output.variant2": "Output: {arg1}", + "native.outputLanguage": "Output language", + "native.outputSpeedTPS": "Output speed (TPS)", + "native.overriddenByALaterValue": "Overridden by a later value", + "native.overriddenInThisProject": "Overridden in this project", + "native.override": "Override", + "native.overrideAGitSetting": "Override a Git setting", + "native.overview": "Overview", + "native.package": "Package", + "native.packageTheRedactedApplicationLogWithAnEnvironmentAndPerformanceSnapshotIntoAZi3d8894ab31": "Package the redacted application log with an environment and performance snapshot into a zip you can attach to a bug report. Credentials, tokens, and home-directory paths are removed automatically, and you can review the file list before anything is written.", + "native.pageZoom": "Page Zoom", + "native.parentVersion": "Parent version", + "native.partOfLitheItStartsNoProgramsOfItsOwn": "Part of Lithe. It starts no programs of its own.", + "native.partiallySelected": "Partially selected", + "native.passed": "Passed", + "native.passwordOrPassphrase": "Password or passphrase", + "native.pasteAnHttpsPluginIndexURLOrAGitHubRepositoryURLAGitHubRepositoryMustContainLi7616c31790": "Paste an https plugin index URL or a GitHub repository URL. A GitHub repository must contain lithe-plugin-index.json at its root.", + "native.pastePatch": "Paste Patch", + "native.pasteThisIntoYourMCPClientConfiguration": "Paste this into your MCP client configuration.", + "native.pastedFiles": "Pasted files", + "native.patchCanBeAppliedToTheSelectedDestination": "Patch can be applied to the selected destination.", + "native.patchCannotBeApplied": "Patch cannot be applied.", + "native.patchPreview": "Patch preview", + "native.patchesMustBeAtMost32MiB": "Patches must be at most 32 MiB.", + "native.path": "Path", + "native.pathMissing": "Path Missing", + "native.paths": "Paths", + "native.pause": "Pause", + "native.pauseTheProcessToInspectVariables": "Pause the process to inspect variables", + "native.paused": "Paused", + "native.peakThisRun": "Peak this run", + "native.peakTotal": "Peak total", + "native.pending": "Pending", + "native.pendingConfirmation": "Pending confirmation", + "native.pendingPluginChangesLld": "Pending plugin changes: {arg1}", + "native.pendingVerification": "Pending verification", + "native.performance": "Performance", + "native.performanceIsAvailableForLocalJavaProcessesLaunchedByLithe": "Performance is available for local Java processes launched by Lithe.", + "native.performanceResults": "Performance Results", + "native.perlSupport": "Perl Support", + "native.permissionRequired": "Permission required", + "native.permissions": "Permissions", + "native.permissionsPrograms": "Permissions & Programs", + "native.phpLanguageSupportWillBeRemovedAfterRestartingLithe": "PHP language support will be removed after restarting Lithe.", + "native.phpSupport": "PHP Support", + "native.pick": "Pick", + "native.pickPreservesTheCommitEditPausesSoYouCanAmendStagedChangesSquashCombinesMessag9cb6d5afdd": "Pick preserves the commit. Edit pauses so you can amend staged changes. Squash combines messages; Fixup keeps the preceding message. Drop removes the commit.", + "native.pinConfiguration": "Pin configuration", + "native.pinned": "Pinned", + "native.pinnedLocal": "Pinned: {arg1} · Local: {arg2}", + "native.placeTheCaretInAJavaFileToSetABreakpoint": "Place the caret in a Java file to set a breakpoint", + "native.placeTheCaretOnAJavaSymbolFirst": "Place the caret on a Java symbol first", + "native.plan": "Plan", + "native.planBeforeMakingChanges": "Plan before making changes", + "native.planLldLld": "Plan {arg1}/{arg2}", + "native.plannedCommand": "Planned command", + "native.plannedGitCommandWaitingToStart": "Planned Git command — waiting to start", + "native.platforms": "Platforms", + "native.pluginChangesTakeEffectAfterLitheRestarts": "Plugin changes take effect after Lithe restarts.", + "native.pluginDetails": "Plugin Details", + "native.pluginDisabled": "Plugin disabled", + "native.pluginID": "Plugin ID", + "native.pluginIDItCanKeepUsingTheDeviceUntilYouCloseOrHideItsWindow": "Plugin ID: {arg1}. It can keep using the device until you close or hide its window.", + "native.pluginMarketplaceIsNotAvailable": "Plugin marketplace is not available", + "native.pluginRepositories": "Plugin Repositories", + "native.pluginRepository": "Plugin repository", + "native.pluginSettings": "Plugin settings", + "native.pluginSource": "Plugin Source", + "native.plugins": "Plugins…", + "native.pop": "Pop", + "native.ports": "ports {arg1}", + "native.postingComment": "Posting comment…", + "native.powershellSupport": "PowerShell Support", + "native.preflight": "Preflight", + "native.preparingConversation": "Preparing conversation…", + "native.preparingDownload": "Preparing download…", + "native.preservesEveryCommitAndAddsAMergeCommitToTheBaseBranch": "Preserves every commit and adds a merge commit to the base branch.", + "native.pressShortcut": "Press shortcut…", + "native.previewAndExport": "Preview and Export…", + "native.previewAndExportTheSelectedLogsWithEnvironmentInformationCredentialsAndHomeDir65f650979b": "Preview and export the selected logs with environment information. Credentials and home-directory paths are redacted; source code, editor buffers and terminal history are excluded.", + "native.previewBackgroundImage": "Preview background image", + "native.previewFirstChange": "Preview first change", + "native.previewShowsTheFirst65536CharactersTheCompletePatchIsUsedWhenSavingOrApplying": "Preview shows the first 65,536 characters. The complete patch is used when saving or applying.", + "native.previewUpdate": "Preview Update", + "native.previousConfigurationBackedUpTo": "Previous configuration backed up to", + "native.previousConsoleExpression": "Previous console expression", + "native.previousDiffMatch": "Previous diff match", + "native.previousDifference": "Previous difference", + "native.previousFile": "Previous File", + "native.previousMatch": "Previous Match", + "native.previousMatch.variant2": "Previous match", + "native.previousMatchShiftReturn": "Previous match (Shift+Return)", + "native.previousSession": "Previous session", + "native.previousVersion": "Previous Version", + "native.primary": "Primary", + "native.primaryConfiguration": "Primary configuration", + "native.primaryWorktree": "Primary worktree", + "native.print": "Print…", + "native.prismaSupport": "Prisma Support", + "native.problems": "Problems", + "native.problemsCouldBeFilteredOut": "Problems could be filtered out", + "native.process": "Process", + "native.processOutput": "Process output", + "native.processOutputWillAppearHere": "Process output will appear here.", + "native.processTerminated": "Process Terminated", + "native.processTerminationPolicy": "Process Termination Policy", + "native.procfile": "Procfile", + "native.profile": "Profile", + "native.profiles": "Profiles", + "native.program": "Program", + "native.programArguments": "Program arguments", + "native.programsChangesAreHighlighted": "Programs (changes are highlighted)", + "native.programsItCanRun": "Programs it can run", + "native.programsLeftRunning": "Programs left running", + "native.programsStartedInLitheThatLeftTheirTerminalOrToolToKeepRunningOnTheirOwnLikeDa7b15ea8191": "Programs started in Lithe that left their terminal or tool to keep running on their own, like daemons or background dev servers. They are listed together when you quit, and you choose which ones to end. Only programs started in this Lithe are listed; containers and services started outside Lithe are never touched.", + "native.project": "Project: {arg1}", + "native.projectAccess": "Project access", + "native.projectChanges": "Project changes", + "native.projectConfiguration": "Project configuration", + "native.projectDefault": "Project default", + "native.projectDefaultsThisMac": "Project defaults (This Mac)", + "native.projectEnvironment": "Project environment", + "native.projectErrors": "Project Errors", + "native.projectFiles": "Project files", + "native.projectIdentificationComplete": "Project identification complete", + "native.projectIdentificationFailed": "Project identification failed", + "native.projectIsStillLoading": "Project is still loading", + "native.projectJDK": "Project JDK", + "native.projectJDKMaven": "Project · JDK & Maven", + "native.projectLocalHistory": "Project Local History", + "native.projectMavenWrapper": "project Maven Wrapper", + "native.projectOpened": "Project opened", + "native.projectPathsMustStayInsideTheCurrentProject": "Project paths must stay inside the current project.", + "native.projectRoot": "Project root", + "native.projectRunConfigurationIsInvalid": "Project run configuration is invalid", + "native.projectRunConfigurationNotFound": "Project run configuration not found", + "native.projectRuntimeSettingsAreSavedLocallyForThisProject": "Project runtime settings are saved locally for this project.", + "native.projectToolchainNeedsAttention": "Project toolchain needs attention", + "native.protection": "Protection", + "native.protocolBuffersSupport": "Protocol Buffers Support", + "native.providedBy": "Provided by", + "native.providedByLitheForThisLanguage": "Provided by Lithe for this language", + "native.provider": "Provider", + "native.providerConfigurationParsingIsUnavailable": "Provider configuration parsing is unavailable.", + "native.providerDetails": "Provider details", + "native.providerName": "Provider name", + "native.providerRequiresAnAPIKey": "Provider requires an API key", + "native.providers": "Providers", + "native.provides": "Provides", + "native.pruneStaleRecords": "Prune Stale Records", + "native.pruneStaleRemoteBranches": "Prune stale remote branches", + "native.pruneStaleRemoteTrackingReferences": "Prune stale remote-tracking references", + "native.pruneStaleTags": "Prune stale tags", + "native.pruneStaleWorktreeRecords": "Prune stale worktree records?", + "native.pruneTheStaleRecordInstead": "Prune the stale record instead.", + "native.prunedStaleWorktreeRecords": "Pruned stale worktree records", + "native.publishBranch": "Publish Branch", + "native.publishBranchFirst": "Publish branch first", + "native.publishThisWorktree": "Publish this worktree", + "native.publishTo": "Publish to {arg1}/{arg2}", + "native.publishing": "Publishing…", + "native.pull": "Pull", + "native.pull.variant2": "pull", + "native.pullAndPush": "Pull and push", + "native.pullAndPushSettingsAreWrittenToTheSelectedGitConfigurationFetchSettingsApplyTob5ea5b72f2": "Pull and Push settings are written to the selected Git configuration. Fetch settings apply to both manual and background fetches.", + "native.pullIntoTheCurrentBranchAndReplayLocalCommits": "Pull {arg1} into the current branch and replay local commits.", + "native.pullIntoTheCurrentBranchWithAMerge": "Pull {arg1} into the current branch with a merge.", + "native.pullRemoteBranchWithMerge": "Pull remote branch with merge?", + "native.pullRemoteBranchWithRebase": "Pull remote branch with rebase?", + "native.pullRequestCheckedOutAsALocalBranch": "Pull request checked out as a local branch", + "native.pullRequestClosed": "Pull request closed", + "native.pullRequestCreated": "Pull request created", + "native.pullRequestDescriptionGeneration": "Pull request description generation", + "native.pullRequestGenerationUsesTheSelectedProviderLanguageReasoningEffortAndDiffLimitAbove": "Pull request generation uses the selected provider, language, reasoning effort, and diff limit above.", + "native.pullRequestMerged": "Pull request merged", + "native.pullRequestReopened": "Pull request reopened", + "native.pullRequestUpdated": "Pull request updated", + "native.pullRequests": "Pull Requests", + "native.pullRequestsIntegrationIsUnderDevelopment": "Pull Requests integration is under development", + "native.pullRequestsUnavailable": "Pull requests unavailable", + "native.pullStrategy": "Pull strategy", + "native.pullWithMerge": "Pull with Merge", + "native.pullWithRebase": "Pull with Rebase", + "native.punctuationAndSymbols": "punctuation and symbols", + "native.push": "Push", + "native.push.variant2": "Push…", + "native.pushOnly": "Push only", + "native.pushTheLatestCommitsBeforeComparingOrCreatingAPullRequest": "Push the latest commits before comparing or creating a pull request.", + "native.pushThisBranchToGitHub": "Push this branch to GitHub", + "native.pushTo": "Push to {arg1}", + "native.pythonSupport": "Python Support", + "native.quickInformation": "Quick Information", + "native.quickResponsesWithBasicReasoning": "Quick responses with basic reasoning", + "native.quit": "Quit", + "native.quitWithSelection": "Quit with Selection", + "native.quittingLitheEndsThesePrograms": "Quitting Lithe ends these programs.", + "native.quittingLitheStopsTheseServicesAndTheProcessesTheyStarted": "Quitting Lithe stops these services and the processes they started.", + "native.quota": "Quota —", + "native.quotaIsTemporarilyUnavailableTheLastValueMayBeOutOfDate": "Quota is temporarily unavailable. The last value may be out of date.", + "native.rSupport": "R Support", + "native.ranLldCommand": "Ran {arg1} command", + "native.ranLldCommands": "Ran {arg1} commands", + "native.readLldFile": "Read {arg1} file", + "native.readLldFiles": "Read {arg1} files", + "native.readOnly": "Read-only", + "native.readOnlyFilePreview": "Read-only file preview", + "native.readingChangesAndRecentCommits": "Reading changes and recent commits.", + "native.readingGitConfiguration": "Reading Git configuration…", + "native.readingProject": "Reading project…", + "native.readingRegisteredCheckouts": "Reading registered checkouts.", + "native.readsTheAPIURLModelAndKeyFromClaudeSettingsJsonAndClaudeJsonEditThoseFilesToCh388f05ea21": "Reads the API URL, model and key from ~/.claude/settings.json and ~/.claude.json. Edit those files to change them, then fetch again.", + "native.readsTheAPIURLModelAndKeyFromCodexConfigTomlAndAuthJsonEditThoseFilesToChangeThemThenFetchAgain": "Reads the API URL, model and key from ~/.codex/config.toml and auth.json. Edit those files to change them, then fetch again.", + "native.ready": "Ready", + "native.readyToCreate": "Ready to create", + "native.reason": "Reason", + "native.reasoning": "Reasoning", + "native.reasoningEffort": "Reasoning effort", + "native.reasoningTokens": "Reasoning tokens: {arg1}", + "native.rebase": "Rebase", + "native.rebase.variant2": "rebase", + "native.rebaseAndKeepMerges": "Rebase and keep merges", + "native.rebaseAndMerge": "Rebase and merge", + "native.rebaseBranch": "Rebase branch?", + "native.rebaseCurrentBranchOnto": "Rebase Current Branch onto…", + "native.rebaseCurrentBranchOntoIncomingChangesR": "Rebase current branch onto incoming changes (R)", + "native.rebaseInProgress": "Rebase in progress", + "native.rebaseRequiresACleanWorkingTreeCommitOrStashLocalChangesBeforeChoosingRebase": "Rebase requires a clean working tree. Commit or stash local changes before choosing Rebase.", + "native.recent": "Recent", + "native.recentProjects": "Recent Projects", + "native.recommendedKeepWorktreesInAPersistentFolderNextToTheRepositoryYouCanChoosePrivf758d1db78": "Recommended: keep worktrees in a persistent folder next to the repository. You can choose /private/tmp manually for disposable checkouts.", + "native.reconnect": "Reconnect", + "native.reconnectThisAgentToRefreshItsModelList": "Reconnect this Agent to refresh its model list.", + "native.reconnecting": "Reconnecting…", + "native.reconnectingDD": "Reconnecting {arg1}/{arg2}…", + "native.recoveryReference": "Recovery reference: {arg1}", + "native.referenceChangesCouldNotBeInspected": "Reference changes could not be inspected", + "native.referenceListTruncatedTotalUpdatedLldDeletedLld": "Reference list truncated; total updated: {arg1}, deleted: {arg2}", + "native.refreshAll": "Refresh All", + "native.refreshChanges": "Refresh changes", + "native.refreshConnectionStructure": "Refresh connection structure", + "native.refreshDependencies": "Refresh dependencies", + "native.refreshDetectedRuntimes": "Refresh detected runtimes", + "native.refreshFiles": "Refresh Files", + "native.refreshGitLog": "Refresh Git log", + "native.refreshHistory": "Refresh history", + "native.refreshLocalHistory": "Refresh local history", + "native.refreshLog": "Refresh Log", + "native.refreshLogs": "Refresh Logs", + "native.refreshPluginRepositories": "Refresh Plugin Repositories", + "native.refreshPreview": "Refresh Preview", + "native.refreshProjectHistory": "Refresh project history", + "native.refreshPullRequests": "Refresh pull requests", + "native.refreshWorktrees": "Refresh worktrees", + "native.refreshed": "Refreshed {arg1}", + "native.refreshingJavaEntries": "Refreshing Java entries", + "native.refreshingProject": "Refreshing project", + "native.regenerate": "Regenerate", + "native.registeredForThisProjectIn": "Registered for this project in {arg1}.", + "native.registeredWith": "Registered with {arg1}", + "native.registersTheWorktreeAndPreparesItsHEADWhileLeavingItsFilesUncheckedOut": "Registers the worktree and prepares its HEAD while leaving its files unchecked out.", + "native.registration": "Registration", + "native.regularExpression": "Regular Expression", + "native.releaseDate": "Release Date", + "native.releaseNote": "Release note", + "native.reload": "Reload", + "native.reloadAIConfigurations": "Reload AI configurations", + "native.reloadCodexConfiguration": "Reload Codex configuration", + "native.reloadConfiguration": "Reload Configuration", + "native.reloadGitConfiguration": "Reload Git Configuration", + "native.reloadMavenProject": "Reload Maven project", + "native.reloadProjectModel": "Reload Project Model", + "native.reloadProjects": "Reload {arg1} projects", + "native.reloadRange": "Reload Range", + "native.reloadTheCorePreviewAndResetTheStepPlan": "Reload the Core preview and reset the step plan", + "native.reloadTheProjectModel": "Reload the project model", + "native.reloadingMaven": "Reloading Maven...", + "native.reloadsProject": "Reloads project", + "native.rememberMyChoiceForTheseKindsOfServices": "Remember my choice for these kinds of services", + "native.rememberedChoices": "Remembered choices", + "native.remote": "Remote", + "native.remote.variant2": "Remote: {arg1}", + "native.remoteFetchFailed": "Remote Fetch failed", + "native.remoteFetchSucceeded": "Remote Fetch succeeded", + "native.remoteJVMTomcat": "Remote JVM / Tomcat", + "native.remoteURL": "Remote URL", + "native.removeAll": "Remove All", + "native.removeAndItsCheckoutDirectoryTheBranchIsKeptIfGitRefusesBecauseFilesHaveChange7ffdbe003e": "Remove '{arg1}' and its checkout directory? The branch is kept. If Git refuses because files have changed, you can review a separate force-removal warning.", + "native.removeAndItsCheckoutDirectoryTheBranchIsKeptIfGitRefusesBecauseFilesHaveChanged2b6784d33": "Remove '{arg1}' and its checkout directory? The branch is kept. If Git refuses because files have changed, review the force-removal warning.", + "native.removeConnection": "Remove Connection", + "native.removeDConversationSFromHistoryYouCanRestoreThemFromRemovedConversationsTheAge92ba9dc5d9": "Remove {arg1} conversation(s) from history? You can restore them from Removed conversations. The Agent's original records will be kept.", + "native.removeFile": "Remove file {arg1}", + "native.removeFromFavorites": "Remove from favorites", + "native.removeFromHistory": "Remove from history", + "native.removeFromRecentProjects": "Remove from Recent Projects", + "native.removeLocalRemoteBranchesThatNoLongerExistOnTheServer": "Remove local remote branches that no longer exist on the server.", + "native.removeLocalTagsThatWereDeletedFromTheRemote": "Remove local tags that were deleted from the remote.", + "native.removeStaleBranches": "Remove stale branches", + "native.removeStaleRemoteBranches": "Remove stale remote branches", + "native.removeStaleTags": "Remove stale tags", + "native.removeTheSelectedCommitAndItsChangesFromThisBranchLaterCommitsWillBeReplayedAR2e43a75384": "Remove the selected commit and its changes from this branch. Later commits will be replayed. A recovery reference will retain the original history.", + "native.removeWorktree": "Remove Worktree…", + "native.removeWorktree.variant2": "Remove worktree?", + "native.removed": "Removed", + "native.removed.variant2": "Removed {arg1}", + "native.removedConversations": "Removed conversations", + "native.removedConversationsAreHiddenOnlyInLitheTheAgentSOriginalRecordsAreKept": "Removed conversations are hidden only in Lithe. The Agent's original records are kept.", + "native.removedRestartLitheToFinish": "Removed. Restart Lithe to finish.", + "native.removedRestoreItToUseItAgain": "Removed. Restore it to use it again.", + "native.rename": "Rename '{arg1}'.", + "native.rename.variant2": "Rename…", + "native.renameBranch": "Rename Branch", + "native.renameChangeList": "Rename ChangeList", + "native.renameChat": "Rename Chat", + "native.renameConversation": "Rename conversation", + "native.renamed": "Renamed", + "native.reopenPullRequest": "Reopen Pull Request", + "native.reopenPullRequest.variant2": "Reopen pull request", + "native.reopenPullRequest.variant3": "Reopen pull request?", + "native.reopeningPullRequest": "Reopening pull request…", + "native.repairOrPruneTheMissingCheckoutBeforeChangingItsLock": "Repair or prune the missing checkout before changing its lock.", + "native.repairWorktreeRecords": "Repair Worktree Records", + "native.repairedWorktreeRecords": "Repaired worktree records", + "native.repeatedLldTimes": "Repeated {arg1} times", + "native.replace": "Replace", + "native.replaceCurrentCommitMessage": "Replace current commit message?", + "native.replaceExistingContent": "Replace existing content", + "native.replaceInFile": "Replace in File", + "native.replaceInFile.variant2": "Replace in File…", + "native.replaceInFiles": "Replace in Files", + "native.replaceInProject": "Replace in Project", + "native.replaceInProject.variant2": "Replace in project", + "native.replaceTextAcrossTheWorkspace": "Replace text across the workspace", + "native.replaceTheMessageOfThePausedCommitAndIncludeTheCurrentlyStagedChangesThenContinueTheRebase": "Replace the message of the paused commit and include the currently staged changes, then continue the rebase.", + "native.replaceWith": "Replace with", + "native.replaceWithinTheActiveEditor": "Replace within the active editor", + "native.replacesAnInstalledPlugin": "Replaces an Installed Plugin", + "native.replay": "Replay", + "native.replayTheCurrentBranchOntoConflictsMayRequireTerminalResolution": "Replay the current branch onto {arg1}. Conflicts may require terminal resolution.", + "native.replaysEveryCommitOntoTheBaseBranchWithoutAMergeCommit": "Replays every commit onto the base branch without a merge commit.", + "native.reportedChange": "Reported change", + "native.repository": "Repository", + "native.repositoryChangedReviewAndRetry": "Repository changed; review and retry", + "native.repositoryIdentityOverridesInheritedGlobalValuesClearAnOverrideToUseInheritedSettings": "Repository identity overrides inherited global values. Clear an override to use inherited settings.", + "native.repositoryNeedsAttention": "Repository needs attention", + "native.repositoryURL": "Repository URL", + "native.requestChanges": "Request changes", + "native.requiredFieldsAreMarkedWith": "Required fields are marked with *", + "native.rerun": "Rerun", + "native.rerunLastTest": "Rerun last test", + "native.rescan": "Rescan", + "native.rescanServices": "Rescan services", + "native.rescanTheProjectForServices": "Rescan the project for services", + "native.reset": "Reset", + "native.resetCurrentBranch": "Reset current branch?", + "native.resetCurrentBranchToHere": "Reset Current Branch to Here…", + "native.resetFetchDefaults": "Reset Fetch defaults", + "native.resetFontSize": "Reset Font Size", + "native.resetHard": "Reset (Hard)", + "native.resetMixed": "Reset (Mixed)", + "native.resetPageZoom100": "Reset Page Zoom (100%)", + "native.resetSoft": "Reset (Soft)", + "native.resetThisSettingToItsInheritedValue": "Reset this setting to its inherited value", + "native.resetToDefault": "Reset to Default", + "native.resets": "Resets: {arg1}", + "native.residentMemory": "Resident Memory", + "native.residentMemoryOfLitheAndItsManagedProcessTrees": "Resident memory of Lithe and its managed process trees", + "native.residentMemoryOfTheLitheProcess": "Resident memory of the Lithe process", + "native.resolveLldConflictedFileSStageTheResultThenContinue": "Resolve {arg1} conflicted file(s), stage the result, then continue.", + "native.resolveLldConflictedFileSStageThemThenContinue": "Resolve {arg1} conflicted file(s), stage them, then continue.", + "native.resolvingDependencies": "Resolving dependencies...", + "native.resolvingPushDestination": "Resolving push destination…", + "native.resolvingServicePaths": "Resolving service paths...", + "native.resourcesRoot": "Resources Root", + "native.responding": "Responding…", + "native.response": "Response", + "native.responsesAPI": "Responses API", + "native.restart": "Restart", + "native.restartLanguageServers": "Restart Language Servers", + "native.restartLithe": "Restart Lithe", + "native.restartLitheToFinishUninstallingThenInstallTheCommunityPluginFromTheMarketplace": "Restart Lithe to finish uninstalling {arg1}, then install the community plugin {arg2} from the Marketplace.", + "native.restartLitheToFinishUninstallingThenInstallThisPlugin": "Restart Lithe to finish uninstalling {arg1}, then install this plugin.", + "native.restartLitheToLoadTheUpdatedPage": "Restart Lithe to load the updated page.", + "native.restartRequired": "Restart required", + "native.restartRun": "Restart run", + "native.restore": "Restore", + "native.restoreAllDefaults": "Restore All Defaults", + "native.restoreConversations": "Restore conversations", + "native.restoreDefault": "Restore Default", + "native.restoreDefaultTemplate": "Restore Default Template", + "native.restoreDefaultUILayout": "Restore Default UI Layout", + "native.restorePath": "Restore path", + "native.restoringGitHubConnection": "Restoring GitHub connection", + "native.result": "Result", + "native.results": "Results", + "native.results.variant2": "results", + "native.resume": "Resume", + "native.resumeThePausedDebugSession": "Resume the paused debug session", + "native.retentionAndCleanup": "Retention and cleanup", + "native.retiredOfficialGoPluginGoSupportIsNowTheCommunityPluginGoSupportInLitheStore": "Retired official Go plugin. Go support is now the community plugin Go Support in Lithe Store.", + "native.retiredOfficialPHPPluginPHPSupportIsNowTheCommunityPluginPHPSupportInLitheStore": "Retired official PHP plugin. PHP support is now the community plugin PHP Support in Lithe Store.", + "native.retryDebugging": "Retry debugging", + "native.retryIdentification": "Retry Identification", + "native.retryUpdateCheck": "Retry update check", + "native.retryUsesLitheAuthenticationForThisOperationOnly": "Retry uses Lithe authentication for this operation only.", + "native.returnFromTheCurrentFunction": "Return from the current function", + "native.returnToLithe": "Return to Lithe", + "native.returnToStable": "Return to Stable", + "native.returnToStable.variant2": "Return to Stable?", + "native.returnToTheWelcomeScreen": "Return to the Welcome screen", + "native.revealInFinder": "Reveal in Finder", + "native.revert": "Revert", + "native.revert.variant2": "revert", + "native.revertCommit": "Revert Commit (Keep History)…", + "native.revertInProgress": "Revert in progress", + "native.revertThisCommit": "Revert this commit?", + "native.review": "Review", + "native.reviewAction": "Review action", + "native.reviewAndRetry": "Review and Retry…", + "native.reviewAndRetryUnfinishedSteps": "Review and Retry Unfinished Steps…", + "native.reviewApproved": "Review approved", + "native.reviewChanges": "Review changes", + "native.reviewCommentSubmitted": "Review comment submitted", + "native.reviewCommitAndPush": "Review commit and push", + "native.reviewForceRemove": "Review Force Remove…", + "native.reviewRemainingSteps": "Review remaining steps", + "native.reviewRepositoryCommits": "Review repository commits", + "native.reviewTheFilesAndDiffBeforeApplyingTheRepositoryIsCheckedAgainWhenYouConfirm": "Review the files and diff before applying. The repository is checked again when you confirm.", + "native.reword": "Reword", + "native.rewriteCommitsAfterTheSelectedBaseThroughHEADTheBaseCommitStaysInPlaceStepsRunFromTopToBottom": "Rewrite commits after the selected base through HEAD. The base commit stays in place. Steps run from top to bottom.", + "native.rightClickAFieldWhilePausedToAddABreakpoint": "Right-click a field while paused to add a breakpoint", + "native.role": "Role", + "native.rollBack": "Roll Back", + "native.rollBack.variant2": "Roll Back…", + "native.rollBack.variant3": "Roll back", + "native.rollBackAgentChanges": "Roll back Agent changes", + "native.rollBackAgentChanges.variant2": "Roll back Agent changes?", + "native.rollBackAll": "Roll back all", + "native.rollBackTo": "Roll back {arg1} to {arg2}?", + "native.rollbackCouldNotRestoreTheChangedFileItRemainsAtRestoreItManuallyBeforeRetrying": "Rollback could not restore the changed file. It remains at {arg1}. Restore it manually before retrying.", + "native.rollbackCouldNotVerifyTheMovedFileCheckTheTrashBeforeRetrying": "Rollback could not verify the moved file. Check the Trash before retrying.", + "native.routesAPIKeyAndCodexSubscriptionRequestsThroughALocalProxyOnThisMacToShowModel6781e5aa03": "Routes API-key and Codex subscription requests through a local proxy on this Mac to show model calls, time to first token, output speed and context composition. Requests and credentials are forwarded unchanged and never stored. Applies after reconnecting.", + "native.rubySupport": "Ruby Support", + "native.runABuildTaskToSeeOutput": "Run a build task to see output.", + "native.runAConfigurationToSeeProcessOutput": "Run a configuration to see process output.", + "native.runAMavenLifecyclePhaseToSeeOutput": "Run a Maven lifecycle phase to see output.", + "native.runAllServices": "Run all services", + "native.runAllServices.variant2": "Run all services?", + "native.runAllTests": "Run All Tests", + "native.runAnotherACPAgentYouInstalledYourselfItMustSupportSigningInWithACustomAPIKeyGateway": "Run another ACP Agent you installed yourself. It must support signing in with a custom API key gateway.", + "native.runBrewUpdateManuallyThenCheckAgainForUpdates": "Run `brew update` manually, then check again for updates.", + "native.runCheckedConfigurationsWithoutRestartingRunningServices": "Run checked configurations without restarting running services", + "native.runConfiguration": "Run Configuration", + "native.runConfiguration.variant2": "Run configuration", + "native.runConfiguration.variant3": "run configuration", + "native.runConfigurations": "Run configurations", + "native.runConfigurationsAndMavenGoalsStartedInLitheTheyAlwaysStopWhenTheirProjectOrWi2085dbd360": "Run configurations and Maven goals started in Lithe. They always stop when their project or window closes or Lithe quits, together with the processes they started; this only decides whether Lithe lists them and asks first.", + "native.runConfigurationsAreUnavailableEnableTheExecutionModuleAndTryAgain": "Run configurations are unavailable. Enable the execution module and try again.", + "native.runConfigurationsInheritTheProjectJDKAndMavenUnlessOverriddenBelow": "Run configurations inherit the project JDK and Maven unless overridden below.", + "native.runConfigurationsMayBeOutOfDate": "Run configurations may be out of date", + "native.runSelected": "Run selected", + "native.runSelectedConfiguration": "Run selected configuration", + "native.runSelectedMavenLifecyclePhase": "Run selected Maven lifecycle phase", + "native.runSelectedServices": "Run selected services", + "native.runSelectedTask": "Run selected task", + "native.runServices": "Run services", + "native.runServicesAreStillRunning": "Run services are still running", + "native.runTests": "Run Tests", + "native.runTests.variant2": "Run tests", + "native.runTestsToViewProcessPerformance": "Run tests to view process performance.", + "native.runYourFiles": "Run your files", + "native.runYourTests": "Run your tests", + "native.running": "Running", + "native.runningFor": "running for {arg1}", + "native.runningGitWaitingForOutput": "Running Git; waiting for output.", + "native.runningServices": "Running services", + "native.runningTime": "Running Time", + "native.runningTools": "Running tools…", + "native.runsFiles": "Runs {arg1} files", + "native.runsOnInstalledOnThisMac": "Runs on {arg1} installed on this Mac", + "native.runsOnShippedWithThePlugin": "Runs on {arg1} shipped with the plugin", + "native.runsTests": "Runs {arg1} tests", + "native.runsTheInstalledOnThisMacThroughTheAdapter": "Runs the {arg1} installed on this Mac through the {arg2} adapter.", + "native.runtime": "Runtime", + "native.runtimeUsedByTheLanguageServerInstalledOnThisMac": "Runtime used by the language server (installed on this Mac)", + "native.rustSupport": "Rust Support", + "native.s": "{arg1}s", + "native.sampleInterval": "Sample interval", + "native.saveAfter": "Save after", + "native.saveChangedFilesAutomatically": "Save changed files automatically", + "native.saveChanges": "Save Changes", + "native.saveEachFieldSeparatelySwitchingScopeReloadsTheSavedValues": "Save each field separately. Switching scope reloads the saved values.", + "native.saveKey": "Save Key", + "native.saveLocalChangesWith": "Save local changes with", + "native.saveMessage": "Save Message", + "native.saveMessage.variant2": "Save message", + "native.saveMetadata": "Save Metadata", + "native.saveOrDiscardUnsavedFilesBeforeDeletingThisItem": "Save or discard unsaved files before deleting this item", + "native.savePatch": "Save Patch…", + "native.saveScope": "Save scope", + "native.saveTheActiveDocument": "Save the active document", + "native.saveTitle": "Save title", + "native.saved": "Saved", + "native.savedInLitheRunConfigurationsJsonForTheWholeTeamLocalJDKPathsAreNeverShared": "Saved in .lithe/run/configurations.json for the whole team. Local JDK paths are never shared.", + "native.savedInLitheRunLocalJsonAndExcludedFromGit": "Saved in .lithe/run/local.json and excluded from Git.", + "native.savedOnlyForThisProjectOnThisComputerInLitheRunLocalJsonAndExcludedFromGit": "Saved only for this project on this computer in .lithe/run/local.json and excluded from Git.", + "native.savedTo": "Saved to {arg1}", + "native.savingReplacesTheCurrentGitHubMetadata": "Saving replaces the current GitHub metadata.", + "native.savingWritesTo": "Saving writes to", + "native.says": "“{arg1}” says", + "native.scalaSupport": "Scala Support", + "native.scopeManagedFrom": "scope managed from {arg1}", + "native.scopeNotUpdatedTo": "scope not updated to {arg1}", + "native.scopeUpdatedFrom": "scope updated from {arg1}", + "native.scrollToNewGitOutput": "Scroll to new Git output", + "native.scrollback": "Scrollback", + "native.searchAcrossTheProject": "Search across the project", + "native.searchActionsOrShortcuts": "Search actions or shortcuts", + "native.searchBranches": "Search branches", + "native.searchCommits": "Search commits", + "native.searchConnections": "Search connections", + "native.searchConversation": "Search conversation", + "native.searchConversationTitlesOrIDs": "Search conversation titles or IDs…", + "native.searchDiff": "Search diff", + "native.searchEverywhere": "Search Everywhere", + "native.searchEverywhere.variant2": "Search Everywhere…", + "native.searchEverywhereDoubleShift": "Search Everywhere (Double Shift)", + "native.searchExclusions": "Search exclusions", + "native.searchFilesAndContents": "Search files and contents", + "native.searchFonts": "Search fonts", + "native.searchForBranchesAndActions": "Search for branches and actions", + "native.searchImplementations": "Search implementations", + "native.searchInProject": "Search in Project", + "native.searchOptions": "Search options", + "native.searchPaths": "Search Paths: {arg1}", + "native.searchPlugins": "Search plugins", + "native.searchProjects": "Search projects", + "native.searchTextAcrossTheWorkspace": "Search text across the workspace", + "native.searchUsers": "Search users", + "native.searchWithinTheActiveEditor": "Search within the active editor", + "native.searchWorktreeOrPath": "Search worktree or path", + "native.searchedLldTime": "Searched {arg1} time", + "native.searchedLldTimes": "Searched {arg1} times", + "native.select": "Select", + "native.select.variant2": "Select {arg1}", + "native.selectAChangedFileFirst": "Select a changed file first", + "native.selectACommit": "Select a commit", + "native.selectAFile": "Select a file", + "native.selectAFileToPreview": "Select a file to preview", + "native.selectAFileToReview": "Select a file to review", + "native.selectAHistoryEntry": "Select a history entry", + "native.selectAJavaFileBeforeRunningCurrentFile": "Select a Java file before running Current File.", + "native.selectAPHPSupportPluginPackage": "Select a PHP Support plugin package.", + "native.selectAPlugin": "Select a plugin", + "native.selectAProjectHistoryEntry": "Select a project history entry", + "native.selectAPullRequest": "Select a pull request", + "native.selectARunConfigurationToSeeItsOutput": "Select a run configuration to see its output.", + "native.selectAServiceOrTaskToConfigureItsArgumentsEnvironmentAndProjectEnvironmentOveafafee2e2f": "Select a service or task to configure its arguments, environment and project-environment overrides. Changes apply after Save.", + "native.selectASpringBootOrMavenModuleConfiguration": "Select a Spring Boot or Maven Module configuration", + "native.selectASpringBootOrMavenModuleConfigurationBeforeStartingDebug": "Select a Spring Boot or Maven Module configuration before starting Debug", + "native.selectAWorktree": "Select a worktree", + "native.selectAll": "Select all", + "native.selectBranch": "Select branch", + "native.selectConfigurationsForBatchActions": "Select configurations for batch actions", + "native.selectConversation": "Select conversation", + "native.selectDebugInTheEditorContextMenu": "— Select \"Debug...\" in the editor context menu", + "native.selectForCompare": "Select for Compare", + "native.selectRepositoryForCommitsAndBranchOperations": "Select repository for commits and branch operations", + "native.selectRunConfiguration": "Select run configuration", + "native.selectSavedChanges": "Select saved changes", + "native.selectShell": "Select shell", + "native.selectTestJVM": "Select test JVM", + "native.selectTheChangeListToCommit": "Select the ChangeList to commit", + "native.selected": "Selected", + "native.selectedChangeListOrStagedFilesChangedBeforeGenerationFinished": "Selected ChangeList or staged files changed before generation finished", + "native.selectedDirectory": "Selected directory", + "native.selectedFromDiskThePublisherAndRepositoryHaveNotBeenVerified": "Selected from disk. The publisher and repository have not been verified.", + "native.selectedRuntime": "Selected runtime", + "native.selectedVersion": "selected version", + "native.selfUpdateIsOnlyAvailableWhenLitheIsRunningFromAPackagedLitheApp": "Self-update is only available when Lithe is running from a packaged Lithe.app.", + "native.send": "Send", + "native.sendAMessageTo": "Send a message to {arg1}", + "native.sendInputToDebuggee": "Send input to debuggee", + "native.sendProgramInput": "Send program input", + "native.sending": "Sending…", + "native.sensitiveFilesAreNotSentToAnAIProvider": "Sensitive files are not sent to an AI provider.", + "native.separateArgumentsWithSpacesQuoteAnArgumentThatContainsSpaces": "Separate arguments with spaces; quote an argument that contains spaces.", + "native.server": "Server", + "native.serverLibrary": "Server library", + "native.services": "Services", + "native.session": "Session", + "native.sessionStatistics": "Session statistics", + "native.setTheCustomAgentSExecutableInThePanelSettings": "Set the custom Agent's executable in Settings > AI > Agents.", + "native.setTheDefaultsUsedByOrdinaryFetchInEveryProjectCredentialsUseYourExistingGitHea37790d0a5": "Set the defaults used by ordinary Fetch in every project. Credentials use your existing Git helper and SSH configuration.", + "native.setUpAgentFileAttachments": "Set Up Agent File Attachments…", + "native.setYourGitNameAndEmailInSettingsBeforeCommitting": "Set your Git name and email in Settings before committing.", + "native.setsAsTheUpstream": "Sets {arg1} as the upstream", + "native.setting": "Setting", + "native.settingValuesOnlyFillThePlacesTheProgramTemplatesReserveForThemSoChangingASetta1ef53141a": "Setting values only fill the places the program templates reserve for them, so changing a setting never changes the programs above.", + "native.settings": "Settings…", + "native.sha256": "SHA-256", + "native.shareProjectOn": "Share project on", + "native.sharedWithProject": "Shared with project", + "native.shelf": "Shelf", + "native.shell": "Shell", + "native.shellIsNotAvailableAtThisPath": "Shell is not available at this path", + "native.shellNotFound": "Shell not found", + "native.shellSupport": "Shell Support", + "native.shelveAndContinue": "Shelve and Continue", + "native.shelveChanges": "Shelve Changes", + "native.shelvingSavesTheseChangesInLitheRunsTheOperationThenRestoresThemIfConflictsSto789262a3ef": "Shelving saves these changes in Lithe, runs the operation, then restores them. If conflicts stop the operation, the shelf stays saved until you finish it.", + "native.shipsWithLithe": "Ships with Lithe", + "native.shortcutIsAlreadyAssignedToThisCommand": "Shortcut is already assigned to this command", + "native.shortcutNeedsCommandControlOrOption": "Shortcut needs Command, Control, or Option", + "native.show": "Show", + "native.showAPIKey": "Show API key", + "native.showAgentDiff": "Show Agent diff", + "native.showAllChanges": "Show all changes", + "native.showAllLldLines": "Show all {arg1} lines", + "native.showAllReferences": "Show all references", + "native.showAsJSON": "Show as JSON", + "native.showAsTree": "Show as tree", + "native.showChanges": "Show Changes", + "native.showChatsInTheAIToolWindow": "Show Chats in the AI Tool Window", + "native.showChatsInTheEditor": "Show Chats in the Editor", + "native.showCommitDecorations": "Show commit decorations", + "native.showCommitDetails": "Show commit details", + "native.showConfigurationList": "Show configuration list", + "native.showConflictFiles": "Show Conflict Files", + "native.showContextBreakdown": "Show context breakdown", + "native.showDependencies": "Show Dependencies", + "native.showDiff": "Show Diff", + "native.showDiffWithWorkingTree": "Show Diff with Working Tree", + "native.showEditorPreview": "Show Editor Preview", + "native.showEntry": "Show Entry", + "native.showFileHistory": "Show file history", + "native.showGitKeyNames": "Show Git key names", + "native.showIndexedSpringMVCRoutes": "Show indexed Spring MVC routes", + "native.showItsOwnPageInAToolWindow": "Show its own page in a tool window", + "native.showLess": "Show less", + "native.showLldMore": "Show {arg1} more", + "native.showLocalHistory": "Show Local History…", + "native.showLongGraphEdges": "Show long graph edges", + "native.showMinimap": "Show minimap", + "native.showOrHideGitHistory": "Show or hide Git history", + "native.showOrHideJavaDiagnostics": "Show or hide Java diagnostics", + "native.showOrHideLanguageDiagnostics": "Show or hide language diagnostics", + "native.showOrHideLanguageNeutralTestRunners": "Show or hide language-neutral test runners", + "native.showOrHideRunOutput": "Show or hide run output", + "native.showOrHideTheDebugToolWindow": "Show or hide the Debug tool window", + "native.showOrHideTheMavenToolWindow": "Show or hide the Maven tool window", + "native.showOrHideTheTerminalToolWindow": "Show or hide the Terminal tool window", + "native.showPlan": "Show plan", + "native.showPreview": "Show Preview", + "native.showProjectInFinder": "Show Project in Finder", + "native.showProjectLocalHistory": "Show Project Local History…", + "native.showResults": "Show Results", + "native.showSessionDetails": "Show Session Details", + "native.showSourceFileName": "Show source file name", + "native.showTechnicalGitDetails": "Show technical Git details", + "native.showTerminal": "Show Terminal", + "native.showTheActiveFileInFinder": "Show the active file in Finder", + "native.showThinking": "Show thinking", + "native.showToolDetails": "Show tool details", + "native.showToolbar": "Show Toolbar", + "native.showUsagesAndGitAuthor": "Show usages and Git author", + "native.showWorktreeRepositories": "Show worktree repositories", + "native.showingThePreviousJavaEntriesWhileTheJavaLanguageServicePreparesTheProject": "Showing the previous Java entries while the Java language service prepares the project.", + "native.shownAcrossTheEntireWorkbenchIncludingToolbarsProjectTreeEditorTabsTerminalsAndStatusBar": "Shown across the entire workbench, including toolbars, project tree, editor, tabs, terminals, and status bar.", + "native.sideBySide": "Side-by-side", + "native.signInToCodex": "Sign in to Codex", + "native.signInToGitHub": "Sign in to GitHub", + "native.signInToViewAndManagePullRequests": "Sign in to view and manage pull requests.", + "native.signInWithChatGPT": "Sign in with ChatGPT", + "native.signalsThatTheChangesAreReadyToMergeASummaryIsOptional": "Signals that the changes are ready to merge. A summary is optional.", + "native.signature": "Signature", + "native.signedInThroughTheACPGatewayWithTheAPIKeyFromYourLocalConfiguration": "Signed in through the ACP gateway with the API key from your local configuration.", + "native.signedInThroughTheACPGatewayWithThisProviderSAPIKey": "Signed in through the ACP gateway with this provider's API key.", + "native.singleFile": "Single file", + "native.singleRow": "Single row", + "native.skipBuild": "Skip Build", + "native.skipCommit": "Skip Commit", + "native.skipCommit.variant2": "Skip Commit…", + "native.skipTests": "Skip tests", + "native.skipTheCurrentCommitAndItsUnresolvedChanges": "Skip the current commit and its unresolved changes?", + "native.skipVersion": "Skip Version", + "native.skipped": "Skipped", + "native.smartCheckout": "Smart Checkout", + "native.smartCheckoutShelve": "Smart Checkout (Shelve)", + "native.smartCheckoutShelvesYourChangesInLitheSwitchesBranchThenRestoresThemForceCheck60e1561e81": "Smart Checkout shelves your changes in Lithe, switches branch, then restores them. Force Checkout switches and discards them.", + "native.smartCheckoutStashesYourChangesSwitchesBranchThenRestoresThemForceCheckoutSwit3688f9f903": "Smart Checkout stashes your changes, switches branch, then restores them. Force Checkout switches and discards them.", + "native.smartStepInto": "Smart step into", + "native.softResetKeepChangesStaged": "Soft Reset (Keep Changes Staged)", + "native.softWrapIsUnavailableForVeryLargeFiles": "Soft wrap is unavailable for very large files", + "native.softWraps": "Soft Wraps", + "native.softwareUpdate": "Software Update", + "native.soliditySupport": "Solidity Support", + "native.someDependenciesAreNotShown": "Some dependencies are not shown.", + "native.someMCPConfigurationFilesCouldNotBeRestoredRefreshAndCheckTheseFilesBeforeRetrying": "Some MCP configuration files could not be restored: {arg1}. Refresh and check these files before retrying.", + "native.sortByLastUpdated": "Sort by Last Updated", + "native.sortByName": "Sort by Name", + "native.sortPlugins": "Sort plugins", + "native.source": "Source", + "native.sourceCode": "Source Code", + "native.sourceRepository": "Source Repository", + "native.sourceRoots": "Source Roots", + "native.sourcesRoot": "Sources Root", + "native.speed": "Speed", + "native.springBoot": "Spring Boot", + "native.springEndpoints": "Spring Endpoints", + "native.sqlSupport": "SQL Support", + "native.squash": "Squash", + "native.squash1": "Squash → 1", + "native.squashAndMerge": "Squash and merge", + "native.squashCommits": "Squash Commits", + "native.squashCommits.variant2": "Squash Commits…", + "native.squashLldCommitsInto1LaterCommitsInTheAffectedHistoryWillBeReplayed": "Squash {arg1} commits into 1. Later commits in the affected history will be replayed.", + "native.stableRecommended": "Stable (Recommended)", + "native.stableReleaseIsReady": "Stable release {arg1} is ready.", + "native.stackFrameDisplayOptions": "Stack frame display options", + "native.stageAllAndCommitUseTheCurrentChangeList": "Stage All and Commit use the current ChangeList.", + "native.stageAllChanges": "Stage all changes", + "native.stageAllFiles": "Stage all files", + "native.stageAllFilesInCurrentChangeList": "Stage all files in current ChangeList", + "native.stageAllFilesInRepository": "Stage all files in repository", + "native.stageAtLeastOneChangeBeforeCommitting": "Stage at least one change before committing", + "native.stageAtLeastOneFileFirst": "Stage at least one file first", + "native.stageFile": "Stage File", + "native.stageFile.variant2": "Stage file", + "native.stageThisChangeBlock": "Stage this change block", + "native.stageYourFilesInChangesEnterACommitMessageAndCreateTheFirstCommit": "Stage your files in Changes, enter a commit message, and create the first commit.", + "native.staged": "STAGED", + "native.staged.variant2": "Staged", + "native.stagedChanges": "Staged changes", + "native.stagedFilesChangedBeforeGenerationFinished": "Staged files changed before generation finished", + "native.staleRecord": "Stale record", + "native.standard": "Standard", + "native.startALanguageServerWhenYouOpenFilesOfItsLanguage": "Start a language server when you open files of its language", + "native.startALocalJavaDebugSessionToViewPerformance": "Start a local Java debug session to view performance.", + "native.startDebugging": "Start Debugging", + "native.startDebugging.variant2": "Start debugging", + "native.startDebuggingTheCurrentJavaFile": "Start debugging the current Java file", + "native.startFrom": "Start from", + "native.startRebase": "Start Rebase", + "native.startRecording": "Start Recording", + "native.startService": "Start service", + "native.startTheDiscussionOrSubmitTheFirstReview": "Start the discussion or submit the first review.", + "native.startTheSessionBySendingAMessage": "Start the session by sending a message", + "native.startThisConfigurationToSeeItsOutputHere": "Start this configuration to see its output here.", + "native.startWithAnActionVerbWithoutATypePrefix": "Start with an action verb, without a type prefix", + "native.startedATaskThatIsStillRunning": "{arg1} started a task that is still running", + "native.starting": "Starting", + "native.startingCommitOptional": "Starting commit (optional)", + "native.startingInteractiveRebase": "Starting Interactive Rebase", + "native.startingJavaLanguageServer": "Starting Java language server...", + "native.startingJavaNavigation": "Starting Java navigation...", + "native.startingTheAgent": "Starting the Agent…", + "native.startsNoPrograms": "Starts no programs.", + "native.startsOnDemandWhenAMatchingFileIsOpened": "Starts on demand when a matching file is opened", + "native.stash": "Stash", + "native.stashAndContinue": "Stash and Continue", + "native.stashChanges": "Stash Changes", + "native.stashChangesHereToSwitchBranchesSafely": "Stash changes here to switch branches safely.", + "native.stashMessage": "Stash message", + "native.stashOrShelfChangesHereToSwitchBranchesSafely": "Stash or shelf changes here to switch branches safely.", + "native.stashRestore": "stash restore", + "native.stashRestoreNeedsAttention": "Stash restore needs attention", + "native.stashingSetsTheseChangesAsideRunsTheOperationThenRestoresThemIfConflictsStopTh7f3dbd0dad": "Stashing sets these changes aside, runs the operation, then restores them. If conflicts stop the operation, the changes stay stashed until you finish it.", + "native.state": "State", + "native.status": "Status", + "native.stepInto": "Step into", + "native.stepLldOfLld": "Step {arg1} of {arg2}", + "native.stepOut": "Step out", + "native.stepOver": "Step over", + "native.stopAllServices": "Stop all services", + "native.stopAndClose": "Stop and Close", + "native.stopAndQuit": "Stop and Quit", + "native.stopBuildTask": "Stop build task", + "native.stopCamera": "Stop Camera", + "native.stopCurrentRun": "Stop current run", + "native.stopDebug": "Stop Debug", + "native.stopDebugging": "Stop debugging", + "native.stopLoading": "Stop Loading", + "native.stopMavenTask": "Stop Maven task", + "native.stopMicrophone": "Stop Microphone", + "native.stopRecording": "Stop Recording", + "native.stopRun": "Stop Run", + "native.stopRun.variant2": "Stop run", + "native.stopSelected": "Stop selected", + "native.stopService": "Stop service", + "native.stopTheCurrentDebugSession": "Stop the current debug session", + "native.stopTheCurrentRun": "Stop the current run", + "native.stopTrackingBranch": "Stop Tracking Branch", + "native.stopWithoutAsking": "Stop without asking", + "native.stoppedMovingTheRemainingItemsToTrash": "Stopped moving the remaining items to Trash", + "native.stopping": "Stopping", + "native.stopping.variant2": "Stopping…", + "native.stoppingInstallation": "Stopping installation…", + "native.stoppingPrograms": "Stopping programs", + "native.storePatchesInLitheWithoutAddingObjectsToGit": "Store patches in Lithe without adding objects to Git.", + "native.storeTemporaryChangesInGitSStashList": "Store temporary changes in Git's stash list.", + "native.structuredTypeScopeSubjectFormat": "Structured type(scope): subject format", + "native.subagent": "Subagent", + "native.subagentOutput": "Subagent output", + "native.subagentTask": "Subagent task", + "native.subagents": "Subagents", + "native.subjectMaximumLength": "Subject maximum length", + "native.submittingReview": "Submitting review…", + "native.succeeded": "Succeeded", + "native.supplementaryMessageCancelled": "Supplementary message cancelled.", + "native.supportedPlaceholdersSummaryChangesTestingRisks": "Supported placeholders: {summary}, {changes}, {testing}, {risks}.", + "native.svelteSupport": "Svelte Support", + "native.swapDirection": "Swap Direction", + "native.swiftSupport": "Swift Support", + "native.switchAgent": "Switch Agent", + "native.switchProjectView": "Switch project view", + "native.switchToTheNodeJsNpmEnvironmentThatInstalledThisCLIUpdateItThereThenCheckAgain": "Switch to the Node.js/npm environment that installed this CLI, update it there, then check again.", + "native.symbols": "Symbols", + "native.syncAndRemoveMissingTags": "Sync and remove missing tags", + "native.syncWithRemote": "Sync with Remote", + "native.synchronizeTagsAndRemoveLocalTagsMissingFromTheRemote": "Synchronize tags and remove local tags missing from the remote", + "native.system": "System", + "native.systemDefault": "System default", + "native.systemDefault.variant2": "System default · {arg1} ({arg2})", + "native.systemDefaultShellNotDetected": "System default · Shell not detected", + "native.systemGit": "System Git", + "native.systemMaven": "system Maven", + "native.systemPrompt": "System prompt", + "native.tabWidth": "Tab width", + "native.tableProperties": "Table Properties", + "native.tag": "Tag", + "native.tagName": "Tag name", + "native.tagOrRevision": "Tag or revision", + "native.tags": "Tags", + "native.takeOwnership": "Take ownership", + "native.takesEffectAfterLitheRestarts": "Takes effect after Lithe restarts", + "native.tasks": "Tasks", + "native.tasks.variant2": "Tasks: {arg1}", + "native.tasksD": "Tasks: {arg1}", + "native.temporaryConfiguration": "Temporary configuration", + "native.temporaryConfiguration.variant2": "Temporary configuration: {arg1}", + "native.terminalIsStillRunning": "Terminal “{arg1}” is still running", + "native.terminalScrollback": "Terminal scrollback", + "native.terminalsWithRunningPrograms": "Terminals with running programs", + "native.terraformSupport": "Terraform Support", + "native.test": "Test", + "native.testJava": "test Java", + "native.testProcessTreeResidentMemory": "Test process tree · Resident memory", + "native.testResources": "test resources", + "native.testRunTimedOutAfterSeconds": "{arg1} test run timed out after {arg2} seconds.", + "native.tests": "Tests", + "native.testsFailed": "Tests failed", + "native.testsPassed": "Tests passed", + "native.text": "Text", + "native.textMeAuthorBranchPath": "Text, me, author:, branch:, path:", + "native.textOrHash": "Text or hash", + "native.theAIProviderReturnedAnEmptyCommitMessage": "The AI provider returned an empty commit message.", + "native.theAIProviderReturnedAnEmptyPullRequestDescription": "The AI provider returned an empty pull request description.", + "native.theAIProviderReturnedAnHTTPError": "The AI provider returned an HTTP error.", + "native.theAIProviderReturnedAnUnexpectedPullRequestDescription": "The AI provider returned an unexpected pull request description.", + "native.theAIProviderReturnedAnUnexpectedResponse": "The AI provider returned an unexpected response.", + "native.theAPIKeyIsStoredSeparatelyFromSettingsYourLocalCLIConfigurationIsNotChanged": "The API key is stored separately from settings. Your local CLI configuration is not changed.", + "native.theActiveRepositoryChangedInspectTheOriginalRepositoryBeforeRetrying": "The active repository changed. Inspect the original repository before retrying.", + "native.theAgentAlreadySavedTheseFilesMarkTheCurrentChangesAsReviewed": "The Agent already saved these files. Mark the current changes as reviewed.", + "native.theAgentCouldNotStart": "The Agent could not start", + "native.theAgentDeclinedToContinue": "The Agent declined to continue.", + "native.theAgentDidNotAcceptTheSupplementaryMessage": "The Agent did not accept the supplementary message.", + "native.theAgentDidNotConfirmTheSelectedSessionSetting": "The Agent did not confirm the selected session setting.", + "native.theAgentDidNotProvideEnoughOriginalContentToSafelyRollBackThisFile": "The Agent did not provide enough original content to safely roll back this file.", + "native.theAgentDiffIsTooLargeToDisplayCompletelyRollbackIsUnavailable": "The Agent diff is too large to display completely. Rollback is unavailable.", + "native.theAgentHasNotReportedContextUsageYet": "The Agent has not reported context usage yet.", + "native.theAgentIsNotRunningConnectToStartAConversation": "The Agent is not running. Connect to start a conversation.", + "native.theAgentIsStillRespondingInThisConversation": "The Agent is still responding in this conversation.", + "native.theAgentIsWaitingForYourPermission": "The Agent is waiting for your permission", + "native.theAgentModuleIsStillStartingTryAgainInAMoment": "The Agent module is still starting. Try again in a moment.", + "native.theAgentNoLongerSupportsTheSelectedSessionSetting": "The Agent no longer supports the selected session setting.", + "native.theAgentProcessStartsWhenThisPanelOpens": "The Agent process starts when this panel opens.", + "native.theAgentRequestFailed": "The Agent request failed.", + "native.theAgentStoppedAfterReachingItsRequestLimitForThisTurn": "The Agent stopped after reaching its request limit for this turn.", + "native.theBranchCouldNotBePublished": "The branch could not be published", + "native.theBranchNowContainsTheCompletedPlanItsOriginalHistoryRemainsAvailableThroughTee76d52d9b": "The branch now contains the completed plan. Its original history remains available through the recovery reference.", + "native.theBuildToolExitedWithCodeLld": "The build tool exited with code {arg1}.", + "native.theBuildToolOutputIsTooLargeOrNotUTF8": "The build tool output is too large or not UTF-8.", + "native.theBuildToolTimedOut": "The build tool timed out.", + "native.theCheckoutDirectoryNoLongerExistsRepairMovedWorktreeMetadataOrPruneTheStaleGitRecord": "The checkout directory no longer exists. Repair moved worktree metadata or prune the stale Git record.", + "native.theCheckoutPathDoesNotExist": "The checkout path does not exist", + "native.theConfiguredBuildToolIsNotExecutable": "The configured build tool is not executable: {arg1}", + "native.theConversationIsStillBeingPreparedTryAgainInAMoment": "The conversation is still being prepared. Try again in a moment.", + "native.theCredentialStoreVersionIsUnsupportedExistingCredentialsWerePreserved": "The credential store version is unsupported. Existing credentials were preserved.", + "native.theCurrentFileWillBeSavedToLocalHistoryBeforeItIsReplaced": "The current file will be saved to Local History before it is replaced.", + "native.theCurrentWorktreeCannotBeRemovedHere": "The current worktree cannot be removed here.", + "native.theDefaultChangeListCannotBeRenamed": "The default ChangeList cannot be renamed.", + "native.theDownloadedAppDoesNotMatchTheSelectedStableReleaseNothingWasInstalled": "The downloaded app does not match the selected stable release. Nothing was installed.", + "native.theDownloadedDiskImageDoesNotContainLitheApp": "The downloaded disk image does not contain Lithe.app.", + "native.theDownloadedUpdateFailedItsChecksumVerification": "The downloaded update failed its checksum verification.", + "native.theDownloadedUpdateFailedItsSHA256VerificationDoNotInstallItRetryOrUseTheReleasePage": "The downloaded update failed its SHA-256 verification. Do not install it; retry or use the Release page.", + "native.theEditStopChangedRefreshTheSessionBeforeAmending": "The edit stop changed. Refresh the session before amending.", + "native.theFileHasChangedSinceTheAgentEditOpenTheDiffAndReviewItManually": "The file has changed since the Agent edit. Open the diff and review it manually.", + "native.theGeneratedMessageWillReplaceTheTextCurrentlyInTheEditor": "The generated message will replace the text currently in the editor.", + "native.theGeneratedTitleOrDescriptionWouldReplaceTextYouAlreadyEntered": "The generated title or description would replace text you already entered.", + "native.theGitHubAuthorizationCodeExpiredStartAgain": "The GitHub authorization code expired. Start again.", + "native.theInstallerReportedAnErrorButTheNewCLIVersionWasVerified": "The installer reported an error, but the new CLI version was verified.", + "native.theInterfaceLanguageChangesImmediatelyEnglishIsTheDefault": "The interface language changes immediately. English is the default.", + "native.theItemCanBeRecoveredFromTheMacOSTrash": "The item can be recovered from the macOS Trash.", + "native.theJVMDoesNotExposeSupportedHeapCountersThroughJstat": "The JVM does not expose supported heap counters through jstat.", + "native.theJavaLanguageServerDidNotTakeTheMavenConfiguration": "The Java language server did not take the Maven configuration: {arg1}", + "native.theJavaLanguageServiceFailedToPrepareTheProject": "The Java language service failed to prepare the project.", + "native.theLatestStableReleaseWillBeDownloadedInFullAndReplaceThisAutoBuildAfterYouConbfd63f6a8e": "The latest stable release will be downloaded in full and replace this AutoBuild after you confirm installation. Projects and settings are kept, but AutoBuild-only settings may not work in the stable release.", + "native.theLatestStableReleaseWillBeDownloadedInFullAndReplaceThisPreviewAfterYouConfic01b41840e": "The latest stable release will be downloaded in full and replace this preview after you confirm installation. Projects and settings are kept, but preview-only settings may not work in the stable release.", + "native.theLocalAdapterIsAtOrAboveTheVerifiedVersion": "The local adapter is at or above the verified version.", + "native.theLocalCodexAccountChangedReconnectToRefreshQuota": "The local Codex account changed. Reconnect to refresh quota.", + "native.theMavenProjectCouldNotBeReloadedThePreviousModelIsStillAvailable": "The Maven project could not be reloaded. The previous model is still available.", + "native.theModelIsTooLargeSomeEntriesAreNotShown": "The model is too large; some entries are not shown.", + "native.theMonitoredJVMHasExitedOrChanged": "The monitored JVM has exited or changed.", + "native.thePatchChangesTheBaseCommitSTreeIntoTheTargetCommitSTree": "The patch changes the base commit's tree into the target commit's tree.", + "native.thePluginSPageAsksToRunThemIn": "The plugin’s page asks to run them in {arg1}.", + "native.thePluginWantsToUseYourCamera": "The plugin “{arg1}” wants to use your camera.", + "native.thePluginWantsToUseYourCameraAndMicrophone": "The plugin “{arg1}” wants to use your camera and microphone.", + "native.thePluginWantsToUseYourMicrophone": "The plugin “{arg1}” wants to use your microphone.", + "native.thePluginWillBeRemovedAfterRestartingLithe": "The plugin will be removed after restarting Lithe.", + "native.thePreviousAgentIsStillStoppingTryAgainShortly": "The previous Agent is still stopping. Try again shortly.", + "native.thePreviousVersionIsUsedAfterRestartingLitheYourSettingValuesAreKept": "The previous version is used after restarting Lithe. Your setting values are kept.", + "native.thePrimaryWorktreeCannotBeLocked": "The primary worktree cannot be locked.", + "native.thePrimaryWorktreeCannotBeRemoved": "The primary worktree cannot be removed.", + "native.theProjectModelIsNotLoaded": "The project model is not loaded.", + "native.thePublishedUpdateManifestIsInvalidAndCannotBeTrustedOpenTheReleasePageAndDowna6815c0bf9": "The published update manifest is invalid and cannot be trusted. Open the Release page and download the update manually.", + "native.thePullRequestWillReturnToTheOpenListAndCanReceiveNewReviews": "The pull request will return to the open list and can receive new reviews.", + "native.theRebaseWasAbortedInspectTheBranchAndWorkingFilesBeforeContinuingYourWork": "The rebase was aborted. Inspect the branch and working files before continuing your work.", + "native.theReportedFileChangesHaveBeenUpdatedReviewThemAgainBeforeRollingBack": "The reported file changes have been updated. Review them again before rolling back.", + "native.theRepositoryChangedOrAnotherGitOperationIsRunningPreviewThePatchAgain": "The repository changed or another Git operation is running. Preview the patch again.", + "native.theRepositoryChangedOrAnotherGitOperationIsRunningRefreshBeforeRetrying": "The repository changed or another Git operation is running. Refresh before retrying.", + "native.theRepositoryChangedOrAnotherGitOperationIsRunningRefreshThePreviewToRetry": "The repository changed or another Git operation is running. Refresh the preview to retry.", + "native.theRepositoryWillBeOpenedInDetachedHEADState": "The repository will be opened in detached HEAD state.", + "native.theResponseStoppedAtTheModelSTokenLimit": "The response stopped at the model's token limit.", + "native.theSelectedAIProviderHasAnInvalidAPIURLOrModel": "The selected AI provider has an invalid API URL or model.", + "native.theSelectedAIProviderHasNoAPIKey": "The selected AI provider has no API key.", + "native.theSelectedBackgroundImageIsUnavailableAndWasRemoved": "The selected background image is unavailable and was removed.", + "native.theSelectedBranchDiffIsSentToTheActiveAIProviderWhenYouGenerate": "The selected branch diff is sent to the active AI provider when you generate.", + "native.theSelectedBranchesHaveNoTextualChangesToSummarize": "The selected branches have no textual changes to summarize.", + "native.theSelectedFileChangedBeforeGenerationFinished": "The selected file changed before generation finished", + "native.theSelectedFileHasNoTextualDiffToSummarize": "The selected file has no textual diff to summarize.", + "native.theSelectedTableHasNoColumnsToDisplay": "The selected table has no columns to display.", + "native.theSelectedVersionsMatch": "The selected versions match", + "native.theStableInstallerStoppedYourCurrentAppHasBeenPreservedTryAgainOrDownloadTheStd70ce1979b": "The stable installer stopped. Your current app has been preserved. Try again or download the stable release manually.", + "native.theStableProfileCouldNotBeCopiedSafelyItsOriginalDataHasBeenPreserved": "The Stable profile could not be copied safely. Its original data has been preserved.", + "native.theStableReleaseIsMissingAValidPublisherSignatureNothingWasInstalledTryANewerRef80e417a6": "The stable release is missing a valid publisher signature. Nothing was installed. Try a newer release or install manually from a trusted source.", + "native.theStagedChangesHaveNoTextualDiffToSummarize": "The staged changes have no textual diff to summarize.", + "native.theStagedChangesWillBeCommittedAndTheCurrentBranchWillBePushedToItsConfiguredRemote": "The staged changes will be committed and the current branch will be pushed to its configured remote.", + "native.theUpdateCouldNotBeInstalledDownloadTheReleaseManuallyAndTryAgain": "The update could not be installed. Download the release manually and try again.", + "native.theUpdatePackageCouldNotBeDownloadedCheckYourInternetConnectionAndTryAgain": "The update package could not be downloaded. Check your internet connection and try again.", + "native.theUpdatePackageCouldNotBeDownloadedCheckYourInternetProxyOrVPNConnectionAndTryAgain": "The update package could not be downloaded. Check your internet, proxy, or VPN connection and try again.", + "native.theUpdatePackageHasNoChecksumAndCannotBeVerified": "The update package has no checksum and cannot be verified.", + "native.theUpdateRequestTimedOutCheckYourProxyOrVPNConnectionAndTryAgain": "The update request timed out. Check your proxy or VPN connection and try again.", + "native.theUpdateServerReturnedAnUnexpectedResponseOpenTheReleasePageAndDownloadTheUpdateManually": "The update server returned an unexpected response. Open the Release page and download the update manually.", + "native.theUpdateServerReturnedHTTPOpenTheReleasePageToDownloadTheUpdateManuallyOrTryAgainLater": "The update server returned HTTP {arg1}. Open the Release page to download the update manually, or try again later.", + "native.theValueGitUsesNow": "The value Git uses now", + "native.theValueGitUsesNowAndWhereItComesFrom": "The value Git uses now and where it comes from", + "native.theVerificationPageIsOpenAndTheCodeIsAlreadyOnYourClipboard": "The verification page is open and the code is already on your clipboard.", + "native.theWorkingTreeCouldNotBeRefreshedKeepYourLocalFilesAndInspectGitStatusBeforeContinuing": "The working tree could not be refreshed. Keep your local files and inspect Git status before continuing.", + "native.theWorktreeWillPointDirectlyToACommitCreateABranchThereBeforeKeepingNewCommits": "The worktree will point directly to a commit. Create a branch there before keeping new commits.", + "native.theme": "Theme", + "native.theme.variant2": "Theme…", + "native.thereIsNoPublishedGitHubReleaseToCheckYet": "There is no published GitHub Release to check yet.", + "native.theseAreTheOptionsLitheWillUseForTheNextOrdinaryFetch": "These are the options Lithe will use for the next ordinary Fetch.", + "native.theseChoicesApplyToThisFetchOnlyGitConfigurationAndCredentialsRemainUnchanged": "These choices apply to this Fetch only. Git configuration and credentials remain unchanged.", + "native.theseDefaultsApplyOnlyToThisProjectOnThisComputerRunConfigurationsInheritThemUcc45c36f49": "These defaults apply only to this project on this computer. Run configurations inherit them unless overridden. Use the Project save scope in a run configuration to share settings with your team.", + "native.theseOverridesAffectOnlyThisRunConfigurationClearAPathToInheritTheProjectEnvironment": "These overrides affect only this run configuration. Clear a path to inherit the project environment.", + "native.theseProcessesLeftTheirTerminalOrToolToKeepRunningInTheBackgroundLikeDaemonsAn39708c186a": "These processes left their terminal or tool to keep running in the background, like daemons and dev servers. Checked items end when Lithe quits; unchecked items keep running.", + "native.theseProgramsWereStartedInLitheBeforeItClosedUnexpectedlyCheckedItemsAreEndedN3cfc802d12": "These programs were started in Lithe before it closed unexpectedly. Checked items are ended now; unchecked items keep running and are not tracked anymore.", + "native.thinking": "Thinking", + "native.thinking.variant2": "Thinking…", + "native.thinkingLevel": "Thinking level", + "native.thinkingProcess": "Thinking process", + "native.thisActionCannotBeUndoneByLithe": "This action cannot be undone by Lithe.", + "native.thisAddressCannotBeOpenedHere": "This address cannot be opened here.", + "native.thisAgent": "This Agent", + "native.thisAgentCannotReopenEarlierConversationsStartANewConversation": "This Agent cannot reopen earlier conversations. Start a new conversation.", + "native.thisAgentHasNoGlobalMCPConfigurationFile": "This Agent has no global MCP configuration file.", + "native.thisAgentIsNoLongerConfiguredOpenAgentSettingsToConfigureIt": "This Agent is no longer configured. Open Agent settings to configure it.", + "native.thisAgentSProviderHasNoAPIKeyUpdateItsLocalConfigurationOrEditTheCustomProvidebf9a7aac24": "This Agent's provider has no API key. Update its local configuration or edit the custom provider in Settings > AI > Agents.", + "native.thisBranchHasNoCommitsYet": "This branch has no commits yet", + "native.thisBuildSystemAnswersThroughAProviderProcessWhichLitheForMacOSDoesNotRunYet": "This build system answers through a provider process, which Lithe for macOS does not run yet.", + "native.thisComputer": "This computer", + "native.thisComputerThisProject": "This computer · This project", + "native.thisConversationIsNoLongerAvailableChooseAnotherConversation": "This conversation is no longer available. Choose another conversation.", + "native.thisDiffHasTooManyLinesForComparisonShowingTheReportedBeforeAndAfterText": "This diff has too many lines for comparison. Showing the reported before and after text.", + "native.thisDocumentIsReadOnly": "This document is read-only", + "native.thisDoesNotDeleteTheBranchOrCommitsThePullRequestCanBeReopenedLater": "This does not delete the branch or commits. The pull request can be reopened later.", + "native.thisEntryBelongsToTheOpenProjectAndIsManagedByLitheIDEAccess": "This entry belongs to the open project and is managed by Lithe IDE access.", + "native.thisFileCannotBeDisplayedAsText": "This file cannot be displayed as text", + "native.thisFileChangedOutsideLitheWhileYouHadUnsavedEdits": "This file changed outside Lithe while you had unsaved edits.", + "native.thisFileHasUnsavedEditorChangesSaveOrDiscardThemBeforeRollingBackTheAgentEdit": "This file has unsaved editor changes. Save or discard them before rolling back the Agent edit.", + "native.thisFileIsOutsideTheCurrentProject": "This file is outside the current project.", + "native.thisFileWasDeletedOutsideLitheYourEditorContentIsPreserved": "This file was deleted outside Lithe. Your editor content is preserved.", + "native.thisFolderAlreadyBelongsToAGitRepository": "This folder already belongs to a Git repository", + "native.thisMac": "This Mac", + "native.thisMacOnly": "This Mac only", + "native.thisPage": "This page", + "native.thisPermanentlyDeletesUncommittedAndUntrackedFilesInTheBranchItselfIsKept": "This permanently deletes uncommitted and untracked files in '{arg1}'. The branch itself is kept.", + "native.thisPluginAddsNoOptionsToThisPage": "This plugin adds no options to this page.", + "native.thisPluginDeclaresNoFeatures": "This plugin declares no features.", + "native.thisPluginDeclaresNoSettings": "This plugin declares no settings.", + "native.thisPluginIsMaintainedAndSignedByLitheReportProblemsInTheSourceRepository": "This plugin is maintained and signed by Lithe. Report problems in the source repository.", + "native.thisPluginIsMaintainedByACommunityDeveloperNotByLitheLitheVerifiedThatTheDownl1989920b43": "This plugin is maintained by a community developer, not by Lithe. Lithe verified that the download matches the plugin repository, and will show what the plugin can run before installing it. Report problems with the plugin in its own repository.", + "native.thisPluginRequiresANewerVersionOfLithe": "This plugin requires a newer version of Lithe.", + "native.thisPreviewContainsChangesThatHaveNotBeenOfficiallyReleasedAndMayBeUnstable": "This preview contains changes that have not been officially released and may be unstable.", + "native.thisProject": "This project", + "native.thisProjectIsNotAGitRepository": "This project is not a Git repository", + "native.thisRebaseSessionIsNoLongerActiveInTheCurrentRepository": "This rebase session is no longer active in the current repository.", + "native.thisRecoveryResultIsNoLongerActiveInTheCurrentRepository": "This recovery result is no longer active in the current repository.", + "native.thisRemovesGitRegistrationsWhoseCheckoutDirectoriesNoLongerExistItDoesNotDeleteBranches": "This removes Git registrations whose checkout directories no longer exist. It does not delete branches.", + "native.thisRemovesTheSavedPatchFromLitheAndCannotBeUndone": "This removes the saved patch from Lithe and cannot be undone.", + "native.thisRemovesTheStashFromGitAndCannotBeUndone": "This removes the stash from Git and cannot be undone.", + "native.thisRemovesTheTagFromTheRepositoryAndAffectsCollaboratorsWhoReferenceItYouCanR8fb0a4ab8b": "This removes the tag from the repository and affects collaborators who reference it. You can restore it from the banner afterwards.", + "native.thisRepository": "This repository", + "native.thisResponseIsSentOnlyToTheRunningGitOperation": "This response is sent only to the running Git operation.", + "native.thisSendsTheCurrentBranchToItsConfiguredRemote": "This sends the current branch to its configured remote.", + "native.thisSubprojectCanUseADifferentJDKFromOtherBackendsInTheSameWorkspace": "This subproject can use a different JDK from other backends in the same workspace.", + "native.thisSubprojectDoesNotUseAJDK": "This subproject does not use a JDK.", + "native.thisTurn": "This turn", + "native.thisUpdatesOnGitHubAndCannotBeUndoneFromLithe": " This updates {arg1} on GitHub and cannot be undone from Lithe.", + "native.thisValueIsNotAllowed": "This value is not allowed.", + "native.thisVersionOfLitheCannotReadUpdateManifestSchemaOpenTheReleasePageAndUpdateManually": "This version of Lithe cannot read update manifest schema {arg1}. Open the Release page and update manually.", + "native.thisWillStartLldDetectedServices": "This will start {arg1} detected services.", + "native.thisWindow": "This Window", + "native.thisWindow.variant2": "This window", + "native.thisWorktreeHasADetachedHEADPublishItAsABranchBeforeCreatingAPullRequest": "This worktree has a detached HEAD. Publish it as a branch before creating a pull request.", + "native.thisWorktreeHasNoUncommittedChanges": "This worktree has no uncommitted changes.", + "native.threads": "Threads", + "native.timeToExit": "Time to exit", + "native.timedOut": "{arg1} timed out.", + "native.timedOut.variant2": "Timed Out", + "native.title": "Title", + "native.toDebugYourCodeDoOneOfTheFollowing": "To debug your code, do one of the following:", + "native.toTrackChangesToCode": "To track changes to code:", + "native.today": "Today", + "native.toggle": "Toggle {arg1}", + "native.toggleBreakpointAtCaret": "Toggle breakpoint at caret", + "native.toggleDebug": "Toggle Debug", + "native.toggleFavorite": "Toggle favorite", + "native.toggleGitLog": "Toggle Git Log", + "native.toggleLineBreakpoint": "Toggle Line Breakpoint", + "native.toggleMaven": "Toggle Maven", + "native.togglePreview": "Toggle preview", + "native.toggleProblems": "Toggle Problems", + "native.toggleRun": "Toggle Run", + "native.toggleTerminal": "Toggle Terminal", + "native.toggleTests": "Toggle Tests", + "native.tok": "{arg1} tok", + "native.tokS": "{arg1} tok/s", + "native.tokenCountsReportedByTheAgentAccountingScopeDependsOnTheAgent": "Token counts reported by the Agent. Accounting scope depends on the Agent.", + "native.tokenUsage": "Token usage", + "native.tomlSupport": "TOML Support", + "native.tool": "Tool", + "native.toolActivityLld": "Tool activity ({arg1})", + "native.toolAndPermissionTime": "Tool and permission time", + "native.toolCall": "Tool call", + "native.toolCalls": "Tool calls", + "native.toolDefinitions": "Tool definitions", + "native.toolWindow": "Tool Window", + "native.toolWindows": "Tool Windows", + "native.toolbarActions": "Toolbar Actions", + "native.tools": "Tools", + "native.toolsSent": "Tools sent", + "native.total": "Total", + "native.totalTokens": "Total tokens: {arg1}", + "native.trackingBranch": "Tracking Branch", + "native.transport": "Transport", + "native.tryABranchNameOrCheckoutPath": "Try a branch name or checkout path.", + "native.tryADifferentSearchTerm": "Try a different search term.", + "native.tryAgain": "Try Again", + "native.tryAnotherTitleAuthorNumberOrLabel": "Try another title, author, number, or label.", + "native.tryAnotherTitleOrSessionID": "Try another title or session ID.", + "native.ttft": "TTFT {arg1}", + "native.turnOnMCPToGenerateAConfigurationForAgents": "Turn on MCP to generate a configuration for agents.", + "native.turnOnModelCallStatisticsInAgentSettingsAndReconnectToSeeModelCallsTimeToFirstc4f5efa5f9": "Turn on Model call statistics in Agent settings and reconnect to see model calls, time to first token and output speed.", + "native.turnOnModelCallStatisticsInAgentSettingsAndReconnectToSeeTheMCPToolsSentWithEachRequest": "Turn on Model call statistics in Agent settings and reconnect to see the MCP tools sent with each request.", + "native.turnOnModelCallStatisticsInAgentSettingsAndReconnectToSeeTheSystemPromptToolDeffd4968e05": "Turn on Model call statistics in Agent settings and reconnect to see the system prompt, tool definitions and messages.", + "native.turnOnModelCallStatisticsReconnectAndSendAMessageToSeeTheBreakdown": "Turn on Model call statistics, reconnect and send a message to see the breakdown.", + "native.turnUsage": "Turn usage", + "native.turningThisOffStopsEveryAgentProcess": "Turning this off stops every Agent process.", + "native.turns": "Turns", + "native.type": "Type", + "native.typeToSeeCommands": "Type / to see commands", + "native.typeToSeeOptions": "Type / to see options", + "native.typeYourTaskForCommandsOrForFiles": "Type your task, / for commands or @ for files", + "native.typeYourTaskOrForCommands": "Type your task or / for commands", + "native.unableToCreateTheMavenDependencyPlan": "Unable to create the Maven dependency plan.", + "native.unableToParseMavenDependenciesForThisModule": "Unable to parse Maven dependencies for this module.", + "native.unableToResolveMavenDependencies": "Unable to resolve Maven dependencies.", + "native.unableToSignInPleaseTryAgain": "Unable to sign in. Please try again.", + "native.unableToStartMavenDependencyResolution": "Unable to start Maven dependency resolution: {arg1}", + "native.unarchiveAndOpen": "Unarchive and open", + "native.unarchiveThisConversation": "Unarchive this conversation?", + "native.unavailable": "Unavailable", + "native.uncachedInput": "Uncached input", + "native.unchanged": "Unchanged", + "native.unchecked": "Unchecked", + "native.uncommittedChangesBlockThisCherryPick": "Uncommitted changes block this cherry-pick", + "native.uncommittedChangesBlockThisMerge": "Uncommitted changes block this merge", + "native.uncommittedChangesBlockThisRebase": "Uncommitted changes block this rebase", + "native.uncommittedChangesBlockThisRevert": "Uncommitted changes block this revert", + "native.uncommittedChangesStayInThisWorktreeAndAreNotIncludedInThePullRequest": "Uncommitted changes stay in this worktree and are not included in the pull request.", + "native.uncommittedSubmoduleChanges": "Uncommitted submodule changes", + "native.undo": "Undo", + "native.undoCommit": "Undo Last Commit (Keep Changes)", + "native.undoCommit.variant2": "Undo Last Commit (Keep Changes)…", + "native.unfiledCONNECTIONS": "UNFILED CONNECTIONS", + "native.uninstall": "Uninstall {arg1}?", + "native.uninstallFromTheInstalledTabAndRestartLitheFirstItIsTheRetiredOfficialVersionO07265e4f97": "Uninstall {arg1} from the Installed tab and restart Lithe first. It is the retired official version of this plugin and supports the same language.", + "native.uninstallPHPSupport": "Uninstall PHP Support?", + "native.unknownError": "Unknown error", + "native.unknownSource": "Unknown source", + "native.unlink": "Unlink", + "native.unlockTheWorktreeBeforeRemovingIt": "Unlock the worktree before removing it.", + "native.unlockWorktree": "Unlock Worktree", + "native.unlocked": "Unlocked", + "native.unlocked.variant2": "Unlocked {arg1}", + "native.unmuteCamera": "Unmute Camera", + "native.unmuteLineBreakpoints": "Unmute Line Breakpoints", + "native.unmuteMicrophone": "Unmute Microphone", + "native.unnamedProvider": "Unnamed provider", + "native.unnamedService": "Unnamed service", + "native.unpinConfiguration": "Unpin configuration", + "native.unrestrictedAccessToTheInternetAndAnyFileOnYourComputer": "Unrestricted access to the internet and any file on your computer", + "native.unsignedDevelopmentPackageReleaseBuildsOfLitheWillNotLoadIt": "Unsigned development package. Release builds of Lithe will not load it.", + "native.unstage": "Unstage", + "native.unstageAllFiles": "Unstage all files", + "native.unstageAllFilesInRepository": "Unstage all files in repository", + "native.unstageFile": "Unstage file", + "native.unstageThisChangeBlock": "Unstage this change block", + "native.unstaged": "Unstaged", + "native.unstagedChanges": "Unstaged changes", + "native.unsupportedContent": "Unsupported content", + "native.untilUTCISO8601": "Until (UTC ISO 8601)", + "native.untitledConversation": "Untitled conversation", + "native.untracked": "Untracked", + "native.unusedCode": "Unused code", + "native.unversionedFiles": "Unversioned Files", + "native.upToDate": "Up to date.", + "native.update": "Update", + "native.update.variant2": "Update {arg1}", + "native.updateAfter": "Update {arg1}/{arg2} after {arg3}", + "native.updateAvailable": "Update Available", + "native.updateAvailable.variant2": "Update available", + "native.updateChannel": "Update channel", + "native.updateCommunityPlugin": "Update community plugin “{arg1}”?", + "native.updateCurrentBranch": "Update Current Branch", + "native.updateLitheToUseThisConfigurationVersion": "Update Lithe to use this configuration version.", + "native.updateLog": "Update log", + "native.updateNow": "Update Now", + "native.updateParentRepositoryReferences": "Update parent repository references", + "native.updateProject": "Update Project", + "native.updateProject.variant2": "Update Project…", + "native.updateRequired": "Update required", + "native.updateStatus": "Update status", + "native.updateThisCLIUsingItsOriginalInstallerThenCheckAgain": "Update this CLI using its original installer, then check again.", + "native.updateTo": "Update to", + "native.updateTo.variant2": "Update to {arg1}", + "native.updateTo.variant3": "Update to: {arg1}", + "native.updated": "Updated: {arg1}", + "native.updatedReferences": "Updated references: {arg1}", + "native.updatedWithLithe": "Updated with Lithe", + "native.updates": "Updates", + "native.updatingCLI": "Updating CLI…", + "native.updatingLabelsAndAssignees": "Updating labels and assignees…", + "native.updatingLldIncomingLldLocal": "Updating {arg1} ({arg2} incoming, {arg3} local)", + "native.updatingPullRequest": "Updating pull request…", + "native.upgradeLitheToUseThisRunConfigurationVersion": "Upgrade Lithe to use this run configuration version.", + "native.upperBoundOfTheJavaLanguageServerSHeapRaiseItForLargeMultiModuleProjects": "Upper bound of the Java language server's heap. Raise it for large multi-module projects.", + "native.upstreamBranch": "Upstream branch", + "native.upstreamCommitS": "upstream commit(s)", + "native.url": "URL", + "native.usages": "Usages", + "native.useAIWorktreeDirectoryPrivateTmp": "Use AI worktree directory (/private/tmp)", + "native.useAModelIDSupportedByYourProvider": "Use a model ID supported by your provider.", + "native.useAPersistentFolderForOngoingWorkTemporaryCheckoutsMayBeRemovedByTheSystem": "Use a persistent folder for ongoing work. Temporary checkouts may be removed by the system.", + "native.useAThirdPartyProvider": "Use a third-party provider", + "native.useAValidFileOrDirectoryName": "Use a valid file or directory name", + "native.useAnAgentInYourProject": "Use an Agent in your project", + "native.useAutoBuild": "Use AutoBuild", + "native.useAutoBuildRolling": "Use AutoBuild Rolling?", + "native.useConfiguration": "Use {arg1} configuration", + "native.useCredentialHelper": "Use credential helper", + "native.useDefaultMessages": "Use Default Messages", + "native.useDetectedJDK": "Use detected JDK", + "native.useFastProcessing": "Use fast processing", + "native.useForm": "Use form", + "native.useFullHTTPPathForCredentials": "Use full HTTP path for credentials", + "native.useGitConfiguration": "Use Git configuration", + "native.useGitFromPATH": "Use Git from PATH", + "native.useGitSetting": "Use Git setting", + "native.useLettersDigitsDotsDashesOrUnderscoresTheNameMustStartWithALetterOrDigit": "Use letters, digits, dots, dashes or underscores. The name must start with a letter or digit.", + "native.useLocalConfigurationOrAddYourOwnProvider": "Use local configuration or add your own provider.", + "native.useMvnwOrDetectedMaven": "Use mvnw or detected Maven", + "native.useProjectDefault": "Use project default", + "native.useProjectJDK": "Use Project JDK", + "native.useProjectJDK.variant2": "Use project JDK", + "native.useProjectJDKOrSystemDefault": "Use project JDK or system default", + "native.useProjectOrFileDirectory": "Use project or file directory", + "native.useServiceJDK": "Use service JDK", + "native.useSoftWraps": "Use soft wraps", + "native.useTemporaryDirectory": "Use temporary directory", + "native.useTheDefaultValue": "Use the default value", + "native.useTheFilterMenuToShowOtherSeverities": "Use the filter menu to show other severities.", + "native.useTheStandardResponseSpeed": "Use the standard response speed", + "native.useThisWhenARepositoryOrGitDefaultNeedsAOneOffAdjustment": "Use this when a repository or Git default needs a one-off adjustment.", + "native.useYourLocalChatGPTAccountNoAPIKeyOrURLIsNeeded": "Use your local ChatGPT account. No API key or URL is needed.", + "native.used": "{arg1} · {arg2} used", + "native.used.variant2": "Used {arg1}", + "native.usedBy": "Used by: {arg1}", + "native.usedForNewTerminalSessions": "Used for new terminal sessions.", + "native.usedForOrdinaryFetchInEveryProjectOneTimeChoicesCanOverrideTheseDefaults": "Used for ordinary Fetch in every project. One-time choices can override these defaults.", + "native.usedLldTool": "Used {arg1} tool", + "native.usedLldTools": "Used {arg1} tools", + "native.user": "User", + "native.userFacingSentenceForReleaseNotes": "User-facing sentence for release notes", + "native.valid": "Valid", + "native.validValuesAreSavedAsYouType": "Valid values are saved as you type.", + "native.validatingTheCredentialStoredInKeychain": "Validating the credential stored in Keychain…", + "native.value": "Value", + "native.value05Seconds": "0.5 seconds", + "native.value15Seconds": "1.5 seconds", + "native.value1File": "1 file", + "native.value1MoreNotification": "1 more notification", + "native.value2Spaces": "2 spaces", + "native.value3Seconds": "3 seconds", + "native.value4Spaces": "4 spaces", + "native.value5Hours": "5 hours", + "native.value7Days": "7 days", + "native.value8Spaces": "8 spaces", + "native.valuesAreMaskedHereButStoredAsPlainTextInTheAgentSConfigurationFile": "Values are masked here but stored as plain text in the Agent's configuration file.", + "native.verification": "Verification", + "native.verified": "Verified", + "native.verifyingStableRelease": "Verifying stable release…", + "native.version": "Version", + "native.versionControl": "Version Control", + "native.versionControlIntegration": "Version control integration", + "native.versionIsAvailable": "Version {arg1} is available.", + "native.versionIsBuiltInOrInstalledFromDisk": "Version {arg1} is built in or installed from disk.", + "native.versionIsInstalled": "Version {arg1} is installed.", + "native.versionIsInstalledRestartLitheToLoadIt": "Version {arg1} is installed. Restart Lithe to load it.", + "native.versionManagedFrom": "version managed from {arg1}", + "native.versionStatus": "Version status", + "native.versionsSource": "Versions & Source", + "native.view": "View", + "native.viewBreakpoints": "View Breakpoints", + "native.viewBreakpoints.variant2": "View breakpoints", + "native.viewBreakpointsF8": "View breakpoints (⌘⇧F8)", + "native.viewOptions": "View Options", + "native.viewSavedChanges": "View Saved Changes", + "native.vmArguments": "VM arguments", + "native.vmOptions": "VM options", + "native.vueSupport": "Vue Support", + "native.waitForTheAgentConfigurationToFinishUpdating": "Wait for the Agent configuration to finish updating.", + "native.waitForTheAgentFileRollbackToFinish": "Wait for the Agent file rollback to finish.", + "native.waitForTheAgentOperationToFinishBeforeChangingProviders": "Wait for the Agent operation to finish before changing providers.", + "native.waitForTheCurrentGitOperationToFinish": "Wait for the current Git operation to finish.", + "native.waitForTheProjectToFinishLoadingThenIdentifyItAgain": "Wait for the project to finish loading, then identify it again.", + "native.waitingForALocalTestJVM": "Waiting for a local test JVM…", + "native.waitingForAgentResponse": "Waiting for Agent response…", + "native.waitingForChatGPTSignIn": "Waiting for ChatGPT sign-in…", + "native.waitingForDebuggerOutput": "Waiting for debugger output…", + "native.waitingForDownloadDataCheckYourNetworkOrProxyIfThisContinues": "Waiting for download data. Check your network or proxy if this continues.", + "native.waitingForGitHub": "Waiting for GitHub…", + "native.waitingForJVMPerformanceData": "Waiting for JVM performance data…", + "native.waitingForPermission": "Waiting for permission…", + "native.waitingForProcessPerformanceData": "Waiting for process performance data…", + "native.waitingForStackData": "Waiting for stack data…", + "native.waitingForSubmodule": "Waiting for submodule", + "native.waitingForSubscriptionQuota": "Waiting for subscription quota…", + "native.waitingForTheJavaLanguageService": "Waiting for the Java language service", + "native.waitingForThreadData": "Waiting for thread data…", + "native.waitingToQuitToCompleteTheUpdate": "Waiting to quit to complete the update.", + "native.wantsToUseYourCamera": "“{arg1}” wants to use your camera.", + "native.wantsToUseYourCameraAndMicrophone": "“{arg1}” wants to use your camera and microphone.", + "native.wantsToUseYourMicrophone": "“{arg1}” wants to use your microphone.", + "native.warning": "Warning", + "native.warnings": "Warnings", + "native.wasCreatedOutsideLitheTakeOwnershipBeforeChangingIt": "“{arg1}” was created outside Lithe. Take ownership before changing it.", + "native.wasNotFoundInstallItForExampleThenCheckAgain": "{arg1} was not found. Install it (for example `{arg2}`), then check again.", + "native.welcomeToLithe": "Welcome to Lithe", + "native.welcomeToLitheAgent": "Welcome to Lithe Agent", + "native.whatDoesThisPullRequestChange": "What does this pull request change?", + "native.whatFetchRemovesOrDownloadsBeforeYouStartWorking": "What Fetch removes or downloads before you start working.", + "native.whatFetchWillDo": "What Fetch will do", + "native.whenQuittingLithe": "When quitting Lithe", + "native.wherePushGoesWhenYouDoNotChooseATargetExplicitly": "Where Push goes when you do not choose a target explicitly.", + "native.whereShouldThisApply": "Where should this apply?", + "native.whereTheRowsBelowThatAreBackedByGitConfigurationAreWritten": "Where the rows below that are backed by Git configuration are written.", + "native.whereWouldYouLikeToOpenTheProject": "Where would you like to open the project ‘{arg1}’?", + "native.whitespaceComparison": "Whitespace comparison", + "native.wholeWords": "Whole Words", + "native.window": "Window", + "native.wip": "WIP", + "native.workbenchActions": "Workbench Actions", + "native.workbenchBackground": "Workbench background", + "native.workbenchBackgroundOpacity": "Workbench background opacity", + "native.working": "Working…", + "native.workingDirectory": "Working directory", + "native.workingTREE": "WORKING TREE", + "native.workingTree": "{arg1} ↔ Working Tree", + "native.workingTree.variant2": "Working Tree", + "native.workingTree.variant3": "Working tree", + "native.workingTreeIsClean": "Working tree is clean", + "native.workingTreeMatchesThisReference": "Working tree matches this reference", + "native.workingWithNpm": "Working with npm…", + "native.workspace": "Workspace", + "native.worktree": "Worktree", + "native.worktreeAction": "Worktree action", + "native.worktreeActionUnavailable": "Worktree action unavailable", + "native.worktreeChanges": "Worktree changes", + "native.worktreeCommitHistory": "Worktree commit history", + "native.worktreeDestination": "Worktree destination", + "native.worktreeSettings": "Worktree Settings", + "native.worktrees": "Worktrees", + "native.worktreesAreUnavailable": "Worktrees are unavailable", + "native.wrapIntoRows": "Wrap into rows", + "native.writeAClearActionableComment": "Write a clear, actionable comment…", + "native.writeToGlobalConfig": "Write to global config", + "native.writeToThisRepository": "Write to this repository", + "native.xhigh": "XHigh", + "native.xmlSupport": "XML Support", + "native.yamlSupport": "YAML Support", + "native.yesterday": "Yesterday", + "native.you": "You", + "native.youCanMuteOrStopTheDeviceFromTheAddressBarWhileThePageIsUsingIt": "You can mute or stop the device from the address bar while the page is using it.", + "native.youHaveUncommittedChangesRebaseWillRefuseToStartUntilTheyAreCommittedOrStashed": "You have uncommitted changes. Rebase will refuse to start until they are committed or stashed.", + "native.yourBranchAndEachHaveCommitsTheOtherDoesNotSoTheChangesCannotBeFastForwarded": "Your branch and '{arg1}' each have commits the other does not, so the changes cannot be fast-forwarded.", + "native.yourChangesToTheseFilesConflictWith": "Your changes to these files conflict with '{arg1}':", + "native.yourChangesToTheseFilesWouldBeOverwrittenBy": "Your changes to these files would be overwritten by '{arg1}':", + "native.yourLocalChangesAreSafeInTheIsIncompleteResolveTheConflictsThenDropThisStashManually": "Your local changes are safe in {arg1}. The {arg2} is incomplete. Resolve the conflicts, then drop this stash manually.", + "native.yourLocalConfigurationHasNoAPIKeyForThisAgentAddOneThenFetchTheConfigurationAgain": "Your local configuration has no API key for this Agent. Add one, then fetch the configuration again.", + "native.yourLoginShellDidNotReportAPATHSoOnlyLitheSOwnPATHWasSearched": "Your login shell did not report a PATH, so only Lithe's own PATH was searched.", + "native.yourOriginalSettingsAndWorkspaceDataHaveBeenPreservedRestartLitheDevToTryAgain": "Your original settings and workspace data have been preserved. Restart Lithe-Dev to try again.", + "native.zigSupport": "Zig Support", + "native.zoomIn": "Zoom In", + "native.zoomOut": "Zoom Out", + "navigation.definition": "definition", + "navigation.enterValidLineNumber": "Enter a valid line number.", + "navigation.goToLine": "Go to Line", + "navigation.goToLinePrompt": "Go to line", + "navigation.implementation": "implementation", + "navigation.lineNumber": "Line number", + "navigation.noOtherReferencesFound": "No other references found.", + "navigation.noReferencesFound": "No references found.", + "navigation.noTargetFound": "No {target} found.", + "navigation.reference": "reference", + "navigation.typeDefinition": "type definition", + "newProject.backToStarters": "Back to project starters", + "newProject.chooseParentFolder": "Choose a parent folder", + "newProject.chooseProjectLocation": "Choose project location", + "newProject.cloningRepository": "Cloning Repository", + "newProject.cloningRepositoryStatus": "Cloning repository", + "newProject.creatingProject": "Creating Project", + "newProject.errorCreatedButCouldNotOpen": "The project was created but Lithe could not open it.", + "newProject.errorCrossPlatformName": "Choose a project name that is supported on every platform.", + "newProject.errorDestinationExists": "A file or folder already exists at {path}.", + "newProject.errorEnterName": "Enter a project name.", + "newProject.errorPathTraversalName": "Project names cannot be path traversal segments.", + "newProject.errorReservedCharacters": "Project names cannot contain path separators or reserved characters.", + "newProject.errorTrailingPeriodOrSpace": "Project names cannot end with a period or space.", + "newProject.location": "Location", + "newProject.packageManager": "Package Manager", + "newProject.packageManagerDescription": "Setup runs in the integrated terminal so you can follow its output.", + "newProject.preparingProject": "Preparing project", + "newProject.projectLocation": "Project location", + "newProject.projectName": "Project Name", + "newProject.repositoryUrl": "Repository URL", + "newProject.repositoryUrlDescription": "HTTPS and SSH repository URLs are supported.", + "newProject.starters": "Starters", + "notebook.convertToCode": "Convert to Code", + "notebook.convertToMarkdown": "Convert to Markdown", + "notebook.deleteCell": "Delete cell", + "notebook.editCell": "Edit cell", + "notebook.insertCellBelow": "Insert cell below", + "notebook.moveCell": "Move cell", + "notebook.pdfOutput": "PDF output", + "notebook.previewCell": "Preview cell", + "notebook.pythonCellOutput": "Python cell output: {output}", + "notebook.pythonCellRan": "Python cell ran.", + "notebook.pythonCellRunFailed": "Failed to run Python cell", + "notebook.pythonCellTimedOut": "Python cell timed out.", + "notebook.pythonDisplayOutputs": "Python cell produced {count} display outputs.", + "notebook.rChunkCapturedErrorOutput": "R chunk completed with captured error output.", + "notebook.rChunkOutput": "R chunk output: {output}", + "notebook.rChunkRan": "R chunk ran.", + "notebook.rChunkRunFailed": "Failed to run R chunk", + "notebook.rChunkSkippedEvalFalse": "R chunk skipped because eval=FALSE.", + "notebook.rChunkTimedOut": "R chunk timed out.", + "notebook.runCell": "Run cell", + "notebook.runningCell": "Running cell", + "notifications.clearAll": "Clear All", + "notifications.copied": "Copied", + "notifications.copy": "Copy", + "notifications.copyAll": "Copy All", + "notifications.copyDetails": "Copy Details", + "notifications.copyMessage": "Copy Message", + "notifications.copyNotification": "Copy Notification", + "notifications.delete": "Delete", + "notifications.deleteNotification": "Delete Notification", + "notifications.details": "Notification details", + "notifications.empty": "No notifications yet.", + "notifications.filter": "Filter Notifications", + "notifications.filterAll": "All", + "notifications.filterErrors": "Errors", + "notifications.filterInfo": "Info", + "notifications.filterSuccess": "Success", + "notifications.filterWarnings": "Warnings", + "notifications.noMatch": "No matching notifications.", + "notifications.occurrenceCount": "{message} ({count})", + "notifications.search": "Search notifications", + "notifications.title": "Notifications", + "onboarding.enableVimMode": "Enable Vim mode", + "onboarding.import": "Import", + "onboarding.importSettings": "Import settings from another editor", + "onboarding.importSettingsDescription": "Import matching setup from VS Code, Cursor, Windsurf, Zed, or JetBrains.", + "onboarding.keybindingPreset": "Keybinding preset", + "onboarding.keybindingPreset.atom.description": "Match common Atom shortcuts.", + "onboarding.keybindingPreset.atom.label": "Atom", + "onboarding.keybindingPreset.emacs.description": "Match common Emacs shortcuts.", + "onboarding.keybindingPreset.emacs.label": "Emacs", + "onboarding.keybindingPreset.jetbrains.description": "Match common JetBrains IDE shortcuts.", + "onboarding.keybindingPreset.jetbrains.label": "JetBrains", + "onboarding.keybindingPreset.none.description": "Use Lithe built-in shortcuts.", + "onboarding.keybindingPreset.none.label": "None", + "onboarding.keybindingPreset.sublime.description": "Match common Sublime Text shortcuts.", + "onboarding.keybindingPreset.sublime.label": "Sublime Text", + "onboarding.keybindingPreset.vscode.description": "Match common VS Code shortcuts.", + "onboarding.keybindingPreset.vscode.label": "VS Code", + "onboarding.keybindingPreset.xcode.description": "Match common Xcode shortcuts.", + "onboarding.keybindingPreset.xcode.label": "Xcode", + "onboarding.keybindingPreset.zed.description": "Match common Zed shortcuts.", + "onboarding.keybindingPreset.zed.label": "Zed", + "onboarding.loadingReleaseNotes": "Loading release notes", + "onboarding.openFoldersInNewWindow": "Open folders in a new window", + "onboarding.releaseNotesDescription": "The latest changes, improvements, and fixes.", + "onboarding.releasedDate": "Released {date}", + "onboarding.updatedFromVersion": "Updated from {previousVersion} to {currentVersion}.", + "onboarding.versionInstalled": "Lithe {version} is installed.", + "onboarding.welcomeDescription": "Lithe {version}. Choose a few defaults before you start.", + "onboarding.welcomeTitle": "Welcome to Lithe", + "onboarding.whatsNewTitle": "What's new in Lithe {version}", + "outline.collapse": "Collapse", + "outline.collapseAll": "Collapse All", + "outline.commandPlaceholderWithFile": "Outline: {file}", + "outline.copyLocation": "Copy Location", + "outline.copyName": "Copy Name", + "outline.expand": "Expand", + "outline.expandAll": "Expand All", + "outline.filter": "Filter outline", + "outline.filter.functions": "Functions", + "outline.filter.other": "Other", + "outline.filter.properties": "Properties", + "outline.filter.types": "Types", + "outline.filter.variables": "Variables", + "outline.goToSymbol": "Go to Symbol", + "outline.loading": "Loading outline", + "outline.loadingEllipsis": "Loading outline...", + "outline.noActiveFile": "No active file.", + "outline.noOutlineForActiveFile": "No outline for the active file.", + "outline.noSymbolsFound": "No symbols found.", + "outline.openFile": "Open a File", + "outline.search": "Search outline", + "outline.showAll": "Show All", + "outline.title": "Outline", + "panes.resizePanes": "Resize panes", + "panes.searchResults": "Search results", + "panes.webViewerDisabled": "Web Viewer is disabled", + "panes.webViewerDisabledDescription": "Enable it in Settings > Features to open URLs in embedded editor tabs.", + "plugins.title": "Plugins", + "preparation.building": "Building Java project", + "preparation.configuring": "Synchronizing Java project configuration", + "preparation.explanation": "The Java workspace must finish preparation before launch. Background indexing does not block running; compilation runs before launch.", + "preparation.failed": "Java project preparation failed — view details", + "preparation.importing": "Importing Java project and dependencies", + "preparation.logs": "View logs", + "preparation.ready": "Java project model ready", + "preparation.settings": "Language service settings and retry", + "preparation.starting": "Starting Java service", + "preparation.stopped": "Java project preparation stopped", + "project.clearScope": "Search entire workspace", + "project.deleteSelected": "Delete {count} selected items?", + "project.dependencies": "Dependencies", + "project.dependenciesHint": "No resolved dependency snapshot", + "project.localHistory": "Show Project Local History", + "project.mark.excluded": "Excluded", + "project.mark.module": "Module", + "project.mark.package": "Package", + "project.mark.plain": "Unmark", + "project.mark.resources": "Resources Root", + "project.mark.sources": "Sources Root", + "project.markAs": "Mark Directory As", + "project.markFailed": "Could not save directory marks", + "project.replaceInFolder": "Replace in Folder…", + "project.resolveDependencies": "Resolve Dependencies", + "project.revealActive": "Select Opened File", + "project.searchScope": "Search in: {path}", + "projectIcon.emptyDescription": "Looks for .ico, icon/logo/favicon .png and .svg files", + "projectIcon.emptyTitle": "No icon files found in this project", + "projectIcon.remove": "Remove icon", + "projectIcon.removeCustom": "Remove custom icon", + "projectIcon.scanning": "Scanning for icons", + "projectIcon.selectTitle": "Select project icon", + "projectNameMenu.addFolderToWorkspace": "Add Folder to Workspace", + "projectNameMenu.collapseAllFolders": "Collapse All Folders", + "projectNameMenu.openFolderInNewTab": "Open Folder in New Tab", + "projectOpen.attach": "Attach", + "projectOpen.cancel": "Cancel", + "projectOpen.doNotAskAgain": "Do not ask again", + "projectOpen.newWindow": "New Window", + "projectOpen.thisWindow": "This Window", + "projectOpen.title": "Open Project", + "projectOpen.where": "Where do you want to open project \"{project}\"?", + "projectPicker.connected": "Connected", + "projectPicker.connecting": "Connecting...", + "projectPicker.connectionFailed": "Connection failed", + "projectPicker.connectionFailedWithPeriod": "Connection failed.", + "projectPicker.connectionSuccessful": "Connection successful.", + "projectPicker.default": "Default", + "projectPicker.disconnected": "Disconnected", + "projectPicker.fillRequiredFields": "Please fill in all required fields", + "projectPicker.hostAndUsernameRequired": "Host and username are required to test.", + "projectPicker.installed": "Installed", + "projectPicker.linuxProjectPath": "Linux project path", + "projectPicker.missing": "Missing", + "projectPicker.noProjectsMatch": "No projects match \"{query}\".", + "projectPicker.open": "Open", + "projectPicker.openDistribution": "Open {name}", + "projectPicker.openWslFailed": "Failed to open WSL project.", + "projectPicker.remote": "Remote", + "projectPicker.remoteIntro": "Connect to remote servers via SSH or SFTP.", + "projectPicker.remoteProjectName": "Remote: {name}", + "projectPicker.removedMissingRecentProjects": "Removed {count} missing projects from recents.", + "projectPicker.removedRecentProject": "Removed \"{name}\" from recent projects.", + "projectPicker.saveConnection": "Save Connection", + "projectPicker.saveConnectionFailed": "Failed to save connection. Please try again.", + "projectPicker.testConnection": "Test Connection", + "projectPicker.testing": "Testing", + "quickOpen.fileCountOne": "{count} file", + "quickOpen.filesCount": "{count} files", + "quickOpen.indexing": "Indexing project files", + "quickOpen.loadingFiles": "Loading files", + "quickOpen.loadingSymbols": "Loading symbols...", + "quickOpen.noFiles": "No files available", + "quickOpen.noFilesInProject": "No files found in project", + "quickOpen.noMatch": "No matching files found", + "quickOpen.noSymbols": "No symbols found", + "quickOpen.openFolder": "Open a folder to start searching files", + "quickOpen.searchFiles": "Type to search files...", + "quickOpen.searchSymbols": "Type to filter symbols...", + "quickOpen.searchWorkspaceSymbols": "Type to search symbols across the project...", + "quickOpen.searching": "Searching...", + "quickOpen.symbolsCount": "{count} symbols", + "references.clearReferences": "Clear references", + "references.exitFullscreen": "Exit fullscreen", + "references.findReferencesHint": "Use Shift+F12 to find references", + "references.findingReferences": "Finding references", + "references.fullscreen": "Fullscreen", + "references.noReferencesFound": "No references found", + "references.title": "References", + "remote.cannotConnectToServer": "Cannot connect to server. Check the host address and port.", + "remote.connectedTo": "Connected to {name}", + "remote.connectionFailed": "Connection failed", + "remote.connectionName": "Connection Name", + "remote.connectionNamePlaceholder": "My Server", + "remote.connectionSavedSuccessfully": "Connection saved successfully.", + "remote.connectionTimedOut": "Connection timed out. The server may be unavailable.", + "remote.connectionType": "Connection Type", + "remote.enterPassword": "Enter Password", + "remote.enterPasswordForPrefix": "Enter the password for", + "remote.enterPasswordForSuffix": "({user}@{host}:{port})", + "remote.hidePassword": "Hide password", + "remote.hostKeyVerificationFailed": "Host key verification failed. The server's identity could not be verified.", + "remote.incorrectUsernameOrPassword": "Incorrect username or password. Please try again.", + "remote.leaveEmptyForKeyAuth": "Leave empty to use key authentication", + "remote.noRouteToHost": "Cannot reach the server. Check your network connection.", + "remote.passwordRequired": "Password is required", + "remote.permissionDenied": "Permission denied. Check your username and password.", + "remote.privateKeyPath": "Private Key Path", + "remote.savePasswordForFutureConnections": "Save password for future connections", + "remote.showPassword": "Show password", + "run.addCustomCommandHint": "Add a custom command, or open a file with runnable LSP CodeLens actions.", + "run.applications": "Applications", + "run.cancel": "Cancel", + "run.chooseServices": "Choose services to run", + "run.clearOutput": "Clear run output", + "run.command": "Command", + "run.configuration": "Configuration", + "run.configurationDetails": "Configuration details", + "run.configurationOverrideHint": "Leave empty to use the project default.", + "run.configurationOverridesSection": "Configuration overrides", + "run.configurations": "Run configurations", + "run.currentFile": "Current file", + "run.custom": "Custom", + "run.deleteAction": "Delete run action", + "run.deleteActionMessage": "Delete the run action “{name}”?", + "run.detectedInProject": "Detected in project", + "run.done": "Done", + "run.editService": "Edit Service", + "run.editorTitle": "Run Configuration", + "run.effectiveSource": "Effective source", + "run.emptyOutput": "Run a configuration to see process output.", + "run.environment": "Environment", + "run.failed": "Failed", + "run.filterActions": "Filter actions", + "run.freshnessCheckFailed": "Could not check whether run configurations are up to date", + "run.generatedEntries": "Generated {count} runnable project entries.", + "run.groups": "Groups", + "run.identifyAgain": "Identify Again", + "run.identifyAndGenerate": "Identify and Generate", + "run.identifying": "Identifying project…", + "run.infrastructure": "Docker services", + "run.invalidTitle": "Project run configuration is invalid", + "run.javaBuildAlwaysContinue": "Always Continue in This Workspace", + "run.javaBuildAlwaysContinueEnabled": "This workspace continues Java launches after build failures without asking.", + "run.javaBuildAskAgain": "Ask Again on Build Failure", + "run.javaBuildContinue": "Run Anyway", + "run.javaBuildElapsed": "Build response time: {milliseconds} ms. This is evidence only and does not decide whether you may run.", + "run.javaBuildFailedTitle": "The Java build for {name} did not finish successfully", + "run.javaBuildMarkersMayRemain": "The language-service builder failed in this session, so some reported errors may be left over from that failure.", + "run.javaBuildOpenLogs": "Open Logs", + "run.javaBuildRebuildIndex": "Rebuild Java Index", + "run.javaBuildWorkspaceScope": "The build could not confirm one target project, so errors from other workspace modules may have affected this result.", + "run.javaDiscoveryFailed": "Java entries could not be refreshed: {message}", + "run.javaDiscoveryLoading": "Waiting for the Java language service to list runnable classes...", + "run.javaDiscoveryStale": "Showing the previous Java entries while the Java language service prepares the project.", + "run.jdkHome": "JDK Home", + "run.jdkHomeHint": "Leave empty to use the detected JDK.", + "run.languageInfrastructure": "Infrastructure", + "run.languageOther": "Other", + "run.languageTools": "Build tools", + "run.languages": "Languages / ecosystems", + "run.mainClass": "Main class", + "run.mavenExecutable": "Maven home or executable", + "run.mavenExecutableHint": "Choose a Maven or mvnd home, bin directory, or executable. Leave empty to use the project wrapper, detected Maven, then mvnd.", + "run.mavenJdkHome": "Maven JDK Home", + "run.mavenJdkHomeHint": "Leave empty to use the same JDK as the application.", + "run.mavenTests": "Maven tests", + "run.mavenTestsHint": "Override the Maven tool window's Skip Tests setting for this run configuration.", + "run.mavenTestsProjectDefault": "Use project default", + "run.mavenTestsRun": "Run tests", + "run.mavenTestsSkip": "Skip tests", + "run.minimize": "Minimize", + "run.missingMessage": "Generate .lithe/run/generated.json to enable Run. Existing project and local overrides will be preserved.", + "run.missingTitle": "Project run configuration not found", + "run.modifyRunConfiguration": "Modify Run Configuration…", + "run.newCustomAction": "New custom action", + "run.noMatchingActions": "No matching actions", + "run.noRunnableActionsFound": "No runnable actions found", + "run.nodeExecutable": "Node.js executable", + "run.nodeExecutableHint": "Choose node.exe, or leave empty to use the detected Node.js runtime.", + "run.otherConfigurations": "Other run configurations", + "run.processOutput": "Process output", + "run.programArguments": "Program arguments", + "run.projectAction": "Run project action", + "run.projectDefaultsSection": "Project defaults", + "run.rescan": "Rescan services", + "run.rescanProjectActions": "Rescan project actions", + "run.resizeConfigurationList": "Resize run configuration list", + "run.resizeLanguages": "Resize language sidebar", + "run.resizeLanguagesHint": "Drag left to collapse or right to expand; arrow keys adjust width", + "run.run": "Run configuration", + "run.runAllServices": "Run all services", + "run.runCell": "Run cell", + "run.runChunk": "Run chunk", + "run.runMarkerFailed": "Unable to run {target}", + "run.runSelectedServices": "Run selected services", + "run.runTarget": "Run '{target}'", + "run.running": "Running", + "run.runtimeSection": "Runtime (this PC)", + "run.saveScope": "Save scope", + "run.saveScopeLocal": "This computer", + "run.saveScopeLocalHint": "Saved only for this project on this computer in .lithe/run/local.json and excluded from Git.", + "run.saveScopeProject": "Project", + "run.saveScopeProjectHint": "Saved in .lithe/run/configurations.json for the whole team. Local JDK paths are never shared.", + "run.scanning": "Scanning", + "run.scanningProjectActions": "Scanning project actions", + "run.scrollToEnd": "Always scroll output to the end", + "run.selectConfiguration": "Select a run configuration to see its output.", + "run.services": "Services", + "run.snapshotOutput": "Snapshot current output", + "run.snapshotTabName": "Output snapshot", + "run.source.generated": "Automatically generated", + "run.source.local": "This computer", + "run.source.project": "Project configuration", + "run.staleConfigurations": "Run configurations may be out of date", + "run.stdinPlaceholder": "Type program input…", + "run.stdinSend": "Send", + "run.stop": "Stop run", + "run.succeeded": "Succeeded", + "run.tasks": "Tasks", + "run.title": "Run", + "run.toolchainAuto": "Auto-detect (leave empty)", + "run.toolchainCurrent": "Current path", + "run.toolchainNeedsAttention": "Project toolchain needs attention", + "run.toolchainProjectDefault": "Use project default", + "run.tryAnotherActionSearch": "Try another name, command, or source.", + "run.type": "Type", + "run.updateService": "Update service", + "run.updateServiceHelp": "Compile changes for Spring Boot DevTools. The application context may restart.", + "run.updatingService": "Updating…", + "run.vmArguments": "JVM arguments", + "run.workingDirectory": "Working directory", + "run.workingDirectoryHint": "Leave empty to use the project root.", + "run.wrapOutputLines": "Use soft wraps", + "runActions.addAction": "Add action", + "runActions.build": "Build", + "runActions.check": "Check", + "runActions.deleteNamedAction": "Delete {name}", + "runActions.editNamedAction": "Edit {name}", + "runActions.editRunAction": "Edit run action", + "runActions.name": "Name", + "runActions.namePlaceholder": "Start development server", + "runActions.newRunAction": "New run action", + "runActions.projectScript": "Project script", + "runActions.run": "Run", + "runActions.saveChanges": "Save changes", + "runActions.savedForPrefix": "This action is saved for", + "runActions.savedForSuffix": "and runs in a new terminal.", + "runActions.test": "Test", + "runActions.workingDirectoryDescription": "Leave empty for the project root, or enter a relative path such as", + "runningServices.ask": "Ask before stopping", + "runningServices.message": "{count} Run services started from this window are still running. Closing the window stops them and their child processes.", + "runningServices.quitMessage": "{count} Run services started from this window are still running. Quitting Lithe stops them and their child processes.", + "runningServices.setting": "Running Run services", + "runningServices.settingDescription": "Applies when closing a window, quitting Lithe or installing an update. Services started from Lithe always stop with it, together with their child processes.", + "runningServices.settingsGroup": "When Quitting", + "runningServices.stopAndClose": "Stop and Close", + "runningServices.stopAndQuit": "Stop and Quit", + "runningServices.stopAndUpdate": "Stop and Update", + "runningServices.stopFailed": "Some services could not be stopped and are still running: {error}", + "runningServices.stopping": "Stopping…", + "runningServices.terminate": "Stop without asking", + "runningServices.title": "Services Still Running", + "runningServices.untitled": "Unnamed service", + "runningServices.updateMessage": "{count} Run services are still running. Installing the update restarts Lithe, which stops them and their child processes.", + "search.clear": "Clear search", + "search.close": "Close search", + "search.emptyDescription": "Type a query to see matching files and lines in a project-wide result buffer.", + "search.emptyTitle": "Search across your project", + "search.failed": "Search failed", + "search.filesToExclude": "Files to exclude", + "search.filesToInclude": "Files to include", + "search.hideDetails": "Hide details", + "search.hideReplace": "Hide replace", + "search.matchCase": "Match case", + "search.matchWholeWord": "Match whole word", + "search.nextMatch": "Next match", + "search.noResults": "No results found", + "search.noResultsFor": "No results found for \"{query}\"", + "search.noResultsForWithFilters": "No results found for \"{query}\" with the current file filters", + "search.openProjectDescription": "Global search needs an open project folder.", + "search.openProjectTitle": "Open a project to search", + "search.options": "Search options", + "search.previousMatch": "Previous match", + "search.replace": "Replace", + "search.replaceAllShort": "All", + "search.replaceMatchesFailed": "Failed to replace search matches", + "search.replaceWith": "Replace with...", + "search.replacedMatches": "Replaced {count} matches", + "search.retry": "Try again", + "search.search": "Search", + "search.showDetails": "Show details", + "search.showReplace": "Show replace", + "search.unsupported": "Global search is not available for this workspace type.", + "search.useRegex": "Use regular expression", + "settings.advanced.data": "Data", + "settings.advanced.experimental": "Experimental", + "settings.advanced.exportFailed": "Failed to export settings: {error}", + "settings.advanced.exportSettings": "Export Settings", + "settings.advanced.exportSettingsDescription": "Save all app settings to a JSON file.", + "settings.advanced.feature.breadcrumbs.description": "File path navigation breadcrumbs in editor", + "settings.advanced.feature.breadcrumbs.name": "Breadcrumbs", + "settings.advanced.feature.diagnostics.description": "Code diagnostics and error reporting", + "settings.advanced.feature.diagnostics.name": "Diagnostics & Problems", + "settings.advanced.feature.outline.description": "Document symbols and quick navigation for the active file", + "settings.advanced.feature.outline.name": "Outline", + "settings.advanced.feature.persistentCommands.description": "The last used commands appear at the top of the command palette", + "settings.advanced.feature.persistentCommands.name": "Persistent Commands", + "settings.advanced.feature.search.description": "Search across files and folders in workspace", + "settings.advanced.feature.search.name": "Global Search", + "settings.advanced.feature.terminal.description": "Built-in terminal for command line operations", + "settings.advanced.feature.terminal.name": "Integrated Terminal", + "settings.advanced.features": "Features", + "settings.advanced.featuresDescription": "Toggle application features on or off", + "settings.advanced.importFailed": "Failed to import settings: {error}", + "settings.advanced.importSettings": "Import Settings", + "settings.advanced.importSettingsDescription": "Restore app settings from an Lithe settings JSON file.", + "settings.advanced.invalidFile": "Invalid settings file format", + "settings.advanced.reset": "Reset", + "settings.advanced.resetSettings": "Reset Settings", + "settings.advanced.resetSettingsDescription": "Reset all settings to their default values", + "settings.advanced.settingsExported": "Settings exported", + "settings.advanced.settingsImported": "Settings imported", + "settings.advanced.settingsReset": "Settings reset to defaults", + "settings.appearance.activeAndHovered": "Active and Hovered", + "settings.appearance.activityBarWidth": "Activity Bar Width", + "settings.appearance.activityBarWidthAria": "Activity bar width: {size} pixels", + "settings.appearance.activityBarWidthDescription": "Set the width of the expanded activity bar", + "settings.appearance.always": "Always", + "settings.appearance.colorTheme": "Color Theme", + "settings.appearance.colorThemeDescription": "Choose your preferred color theme", + "settings.appearance.comfortable": "Comfortable", + "settings.appearance.compactMenuBar": "Compact Menu Bar", + "settings.appearance.compactMenuBarDescription": "Requires UI menu bar; compact hamburger or full UI menu", + "settings.appearance.create": "Create", + "settings.appearance.customThemeRemoved": "Custom theme removed", + "settings.appearance.customThemes": "Custom Themes", + "settings.appearance.customThemesDescription": "Import Lithe theme JSON or create one from an installed theme.", + "settings.appearance.differentiateProjects": "Differentiate Projects with Color", + "settings.appearance.differentiateProjectsDescription": "Tint the top of the window with a project color gradient (Lithe themes; hidden while window transparency is on)", + "settings.appearance.expandedActivityBar": "Expanded Activity Bar", + "settings.appearance.expandedActivityBarDescription": "Show labels beside icons in the activity bar", + "settings.appearance.focused": "Focused", + "settings.appearance.formatGuide": "Format guide", + "settings.appearance.hoveredOnly": "Hovered Only", + "settings.appearance.iconTheme": "Icon Theme", + "settings.appearance.iconThemeDescription": "Icons displayed in the file tree and tabs", + "settings.appearance.import": "Import", + "settings.appearance.importThemeFailed": "Failed to import theme", + "settings.appearance.importedTheme": "Imported {theme}", + "settings.appearance.importedThemeVariants": "Imported {count} theme variants", + "settings.appearance.interface": "Interface", + "settings.appearance.layout": "Layout", + "settings.appearance.nativeMenuBar": "Native Menu Bar", + "settings.appearance.nativeMenuBarDescription": "Use the native menu bar or a custom UI menu bar", + "settings.appearance.openAskEveryTime": "Ask Every Time", + "settings.appearance.openAttach": "Attach", + "settings.appearance.openNewWindow": "New Window", + "settings.appearance.openProjectsDestination": "Open Projects", + "settings.appearance.openProjectsDestinationDescription": "Choose whether opening another project asks first, replaces the current project, attaches it to this window, or opens a new window", + "settings.appearance.openThisWindow": "This Window", + "settings.appearance.preferredDarkTheme": "Preferred Dark Theme", + "settings.appearance.preferredDarkThemeDescription": "Used when Sync With OS is enabled and the system appearance is dark", + "settings.appearance.preferredLightTheme": "Preferred Light Theme", + "settings.appearance.preferredLightThemeDescription": "Used when Sync With OS is enabled and the system appearance is light", + "settings.appearance.reduceMotion": "Reduce Motion", + "settings.appearance.reduceMotionDescription": "Reduce non-essential interface animations while keeping state changes visible", + "settings.appearance.removeCustomThemeFailed": "Failed to remove custom theme", + "settings.appearance.removeTheme": "Remove {theme}", + "settings.appearance.showStatusBar": "Show Status Bar", + "settings.appearance.showStatusBarDescription": "Show app controls and status information along the bottom edge", + "settings.appearance.showTabIcons": "Show Tab Icons", + "settings.appearance.showTabIconsDescription": "Show file and view icons in editor tabs", + "settings.appearance.sidebarWidth": "Sidebar Width", + "settings.appearance.sidebarWidthAria": "Sidebar width: {size} pixels", + "settings.appearance.sidebarWidthDescription": "Set the default width used by left and right sidebars", + "settings.appearance.syncWithOs": "Sync With OS", + "settings.appearance.syncWithOsDescription": "Automatically switch between your preferred light and dark themes", + "settings.appearance.tabCloseButtons": "Tab Close Buttons", + "settings.appearance.tabCloseButtonsDescription": "Choose when unpinned tabs show their close button", + "settings.appearance.theme": "Theme", + "settings.appearance.typography": "Typography", + "settings.appearance.uiFontFamily": "UI Font Family", + "settings.appearance.uiFontFamilyDescription": "Font family for UI elements (file tree, markdown, etc.)", + "settings.appearance.uiFontSize": "UI Font Size", + "settings.appearance.uiFontSizeAria": "UI font size: {size} pixels", + "settings.appearance.uiFontSizeDescription": "Adjust UI text and icon scale in 0.5px steps", + "settings.appearance.windowChromeDensity": "Window Chrome Density", + "settings.appearance.windowChromeDensityDescription": "Choose a focused or roomier scale for title bars, tabs, sidebars, and the footer", + "settings.appearance.windowTransparency": "Window Transparency", + "settings.appearance.windowTransparencyDescription": "Use translucent app chrome and transparent native windows where supported", + "settings.common.allFiles": "All Files", + "settings.common.json": "JSON", + "settings.displayLanguage": "Display Language", + "settings.displayLanguageDescription": "Choose the language used throughout Lithe.", + "settings.editor.autoCompletion": "Auto Completion", + "settings.editor.autoCompletionDescription": "Show completion suggestions while typing", + "settings.editor.autoDetect": "Auto Detect", + "settings.editor.autoDetectLanguage": "Auto-detect Language", + "settings.editor.autoDetectLanguageDescription": "Automatically detect file language from extension", + "settings.editor.autoSave": "Auto Save", + "settings.editor.autoSaveDescription": "Automatically save files when editing", + "settings.editor.blink": "Blink", + "settings.editor.bracketPairColorization": "Bracket Pair Colorization", + "settings.editor.bracketPairColorizationDescription": "Use matching colors to distinguish nested bracket pairs", + "settings.editor.codeLens": "Code Lens", + "settings.editor.codeLensDescription": "Show inline code actions above symbols", + "settings.editor.cursorBlinking": "Cursor Blinking", + "settings.editor.cursorBlinkingDescription": "Animation used by the editor cursor outside Vim normal mode", + "settings.editor.cursorBlock": "Block", + "settings.editor.cursorBlockOutline": "Block Outline", + "settings.editor.cursorLine": "Line", + "settings.editor.cursorStyle": "Cursor Style", + "settings.editor.cursorStyleDescription": "Shape of the editor cursor outside Vim normal mode", + "settings.editor.cursorThinLine": "Thin Line", + "settings.editor.cursorThinUnderline": "Thin Underline", + "settings.editor.cursorUnderline": "Underline", + "settings.editor.defaultLanguage": "Default Language", + "settings.editor.defaultLanguageDescription": "Default syntax highlighting for new files", + "settings.editor.expand": "Expand", + "settings.editor.fontFamily": "Font", + "settings.editor.fontFamilyDescription": "Search all installed system fonts, including proportional fonts", + "settings.editor.fontLigatures": "Font Ligatures", + "settings.editor.fontLigaturesDescription": "Enable programming ligatures (such as => and !=); requires a supporting font", + "settings.editor.fontPreview": "Editor font preview", + "settings.editor.fontSize": "Font Size", + "settings.editor.fontSizeDescription": "Editor font size in pixels (6–72)", + "settings.editor.formatOnSave": "Format on Save", + "settings.editor.formatOnSaveDescription": "Automatically format code when saving", + "settings.editor.highlightOccurrences": "Highlight Occurrences", + "settings.editor.highlightOccurrencesDescription": "Highlight visible matches for the word under the cursor", + "settings.editor.indentGuides": "Indent Guides", + "settings.editor.indentGuidesDescription": "Show vertical guides for indentation levels", + "settings.editor.inlayHints": "Inlay Hints", + "settings.editor.inlayHintsDescription": "Show inline type and parameter hints from language servers", + "settings.editor.italicComments": "Italic Comments", + "settings.editor.italicCommentsDescription": "Render code comments in italics", + "settings.editor.lineHeight": "Line Height", + "settings.editor.lineHeightDescription": "Editor line height multiplier", + "settings.editor.lineNumbers": "Line Numbers", + "settings.editor.lineNumbersDescription": "Show line numbers in the editor", + "settings.editor.lintOnSave": "Lint on Save", + "settings.editor.lintOnSaveDescription": "Run linter when saving files", + "settings.editor.maxOpenTabs": "Max Open Tabs", + "settings.editor.maxOpenTabsDescription": "Maximum number of tabs before oldest closes", + "settings.editor.parameterHints": "Parameter Hints", + "settings.editor.parameterHintsDescription": "Show function parameter hints", + "settings.editor.phase": "Phase", + "settings.editor.relativeLineNumbers": "Relative Line Numbers", + "settings.editor.relativeLineNumbersDescription": "Show relative numbers when Vim mode is active", + "settings.editor.renderWhitespace": "Render Whitespace", + "settings.editor.renderWhitespaceDescription": "Show visible markers for spaces and tabs", + "settings.editor.scrollBeyondLastLine": "Scroll Beyond Last Line", + "settings.editor.scrollBeyondLastLineDescription": "Allow scrolling the final line above the bottom of the editor", + "settings.editor.section": "Editor", + "settings.editor.semanticTokens": "Semantic Tokens", + "settings.editor.semanticTokensDescription": "Use language server semantic highlighting", + "settings.editor.showMinimap": "Show minimap", + "settings.editor.showMinimapDescription": "Show a minimap overview on the right side of the editor", + "settings.editor.smooth": "Smooth", + "settings.editor.smoothScrolling": "Smooth Scrolling", + "settings.editor.smoothScrollingDescription": "Animate editor scrolling between positions", + "settings.editor.solid": "Solid", + "settings.editor.stickyScroll": "Sticky Scroll", + "settings.editor.stickyScrollDescription": "Keep containing scopes visible at the top while scrolling", + "settings.editor.symbolBreadcrumb": "Show Symbol in Breadcrumb", + "settings.editor.symbolBreadcrumbDescription": "Show the containing function/class for the cursor position in the breadcrumb bar", + "settings.editor.tabSize": "Tab Size", + "settings.editor.tabSizeDescription": "Number of spaces per tab", + "settings.editor.whitespaceAll": "All", + "settings.editor.whitespaceBoundary": "Boundary", + "settings.editor.whitespaceNone": "None", + "settings.editor.whitespaceTrailing": "Trailing", + "settings.editor.wordWrap": "Word Wrap", + "settings.editor.wordWrapDescription": "Wrap lines that exceed viewport width", + "settings.files.behavior": "Behavior", + "settings.files.compactFolders": "Compact Folders", + "settings.files.compactFoldersDescription": "Collapse single-child folder chains", + "settings.files.confirmBeforeDelete": "Confirm Before Delete", + "settings.files.confirmBeforeDeleteDescription": "Ask for confirmation before deleting a file or folder", + "settings.files.display": "Display", + "settings.files.filters": "Filters", + "settings.files.foldersFirst": "Folders First", + "settings.files.globPatterns": "Comma-separated glob patterns", + "settings.files.hiddenDirectories": "Hidden Directories", + "settings.files.hiddenFiles": "Hidden Files", + "settings.files.hideRootFolder": "Hide Root Folder", + "settings.files.hideRootFolderDescription": "Show project files directly at the top level", + "settings.files.indentSize": "Indent Size", + "settings.files.indentSizeDescription": "Pixels per nesting level", + "settings.files.name": "Name", + "settings.files.respectGitignore": "Respect .gitignore", + "settings.files.respectGitignoreDescription": "Hide files matched by root and nested .gitignore files", + "settings.files.showFileIcons": "Show File Icons", + "settings.files.showFileIconsDescription": "Show themed file and folder icons", + "settings.files.showGitStatus": "Show Git Status", + "settings.files.showGitStatusDescription": "Display Git color decorations beside changed files", + "settings.files.showHiddenFiles": "Show Hidden Files", + "settings.files.showHiddenFilesDescription": "Show dotfiles and hidden directories", + "settings.files.showIndentGuides": "Show Indent Guides", + "settings.files.showIndentGuidesDescription": "Show vertical guides for nested folders", + "settings.files.sortOrder": "Sort Order", + "settings.files.sortOrderDescription": "Choose whether folders stay above files or everything sorts by name", + "settings.general.bugReportTemplate": "Environment\n\n- App: Lithe {version}\n- OS: {platform} {osVersion}\n\nProblem\n\nDescribe the issue here. Steps to reproduce, expected vs actual.\n", + "settings.general.check": "Check", + "settings.general.checking": "Checking...", + "settings.general.cliInstallFailed": "Failed to install CLI: {error}. You may need administrator privileges.", + "settings.general.cliInstalled": "CLI command is installed at $HOME/.local/bin/lithe", + "settings.general.cliNotInstalled": "CLI command is not installed.", + "settings.general.cliUninstallFailed": "Failed to uninstall CLI: {error}", + "settings.general.copy": "Copy", + "settings.general.copyCommandFailed": "Failed to copy command: {error}", + "settings.general.copyInstallCommand": "Copy install command to clipboard", + "settings.general.downloading": "Downloading...", + "settings.general.githubReportDescription": "Open a bug report issue", + "settings.general.importSettings": "Import Settings", + "settings.general.importSettingsDescription": "Import matching setup from another editor.", + "settings.general.install": "Install", + "settings.general.installCommandCopied": "Install command copied to clipboard", + "settings.general.installUpdate": "Install {version}", + "settings.general.installing": "Installing...", + "settings.general.latestVersion": "Lithe is up to date. Language extensions are updated separately.", + "settings.general.noReportChannel": "No report channel matches \"{query}\".", + "settings.general.open": "Open", + "settings.general.prepareReportFailed": "Failed to prepare bug report", + "settings.general.reportBug": "Report a Bug", + "settings.general.reportBugDescription": "Choose where to report an issue with environment details.", + "settings.general.reportTemplateCopied": "Report template copied", + "settings.general.reportVia": "Report via...", + "settings.general.terminalCommand": "Terminal Command", + "settings.general.terminalCommandDescription": "Install the `lithe` command to open folders and files from your terminal.", + "settings.general.uninstall": "Uninstall", + "settings.general.uninstalling": "Uninstalling...", + "settings.general.upToDate": "Lithe {version} · App is up to date", + "settings.general.update": "update", + "settings.general.updateAvailable": "Lithe {version} · Version {availableVersion} available", + "settings.general.updateCheckFailed": "Lithe {version} · Failed to check for updates", + "settings.general.updateDownloadProgress": "Lithe update download progress", + "settings.general.updateInstalling": "Lithe {version} · Installing update...", + "settings.general.updateProgress": "Lithe {version} · Downloading {percentage}%", + "settings.general.version": "Version", + "settings.general.versionDescription": "Check for updates and install the latest app version.", + "settings.git.autoRefresh": "Auto Refresh Git Status", + "settings.git.autoRefreshDescription": "Refresh the Git view automatically after relevant file changes and Git events", + "settings.git.collapseEmpty": "Collapse Empty Sections", + "settings.git.collapseEmptyDescription": "Hide empty Git sections like Staged Changes when they have no items", + "settings.git.compactBadges": "Compact Git Status Badges", + "settings.git.compactBadgesDescription": "Use a denser layout for diff stats and staged labels in the Git panel", + "settings.git.confirmDiscard": "Confirm Before Discard", + "settings.git.confirmDiscardDescription": "Confirm file, repository, or Diff block rollback before discarding.", + "settings.git.defaultDiff": "Default Diff View", + "settings.git.defaultDiffDescription": "Choose the default layout for Git diffs", + "settings.git.editor": "Editor", + "settings.git.folderChanges": "Folder-Based Changes", + "settings.git.folderChangesDescription": "Show Git changes in a folder tree, similar to Files", + "settings.git.gitIntegration": "Git Integration", + "settings.git.gitIntegrationDescription": "Enable source control management with Git repositories", + "settings.git.gutter": "Enable Git Gutter", + "settings.git.gutterDescription": "Mark changed lines in the editor gutter; click a marker to review the change inline", + "settings.git.inlineBlame": "Enable Inline Blame", + "settings.git.inlineBlameDescription": "Show inline Git blame metadata for the current line in the editor", + "settings.git.integration": "Integration", + "settings.git.openDiff": "Open Diff On Click", + "settings.git.openDiffDescription": "Open the diff when clicking a changed file instead of opening the file directly", + "settings.git.split": "Split", + "settings.git.stagedFirst": "Show Staged First", + "settings.git.stagedFirstDescription": "Render staged changes above unstaged changes in the Git panel", + "settings.git.unified": "Unified", + "settings.git.untracked": "Show Untracked Files", + "settings.git.untrackedDescription": "Display untracked files in the Git status panel", + "settings.git.view": "Git View", + "settings.groups.ai": "AI", + "settings.groups.appearance": "Appearance & Behavior", + "settings.groups.build": "Build, Execution, Deployment", + "settings.groups.languages": "Languages & Frameworks", + "settings.groups.tools": "Tools", + "settings.groups.versionControl": "Version Control", + "settings.keyboard.actions": "Actions", + "settings.keyboard.all": "All", + "settings.keyboard.andPreset": " and preset", + "settings.keyboard.back": "Back", + "settings.keyboard.command": "Command", + "settings.keyboard.default": "Default", + "settings.keyboard.editKeybindings": "Edit Keybindings", + "settings.keyboard.editKeybindingsDescription": "Customize shortcuts individually.", + "settings.keyboard.export": "Export", + "settings.keyboard.exportFailed": "Failed to export keybindings: {error}", + "settings.keyboard.extension": "Extension", + "settings.keyboard.filter": "Keybinding filter", + "settings.keyboard.import": "Import", + "settings.keyboard.importFailed": "Failed to import keybindings: {error}", + "settings.keyboard.invalidFile": "Invalid keybindings file format", + "settings.keyboard.keybinding": "Keybinding", + "settings.keyboard.keybindingsExported": "Keybindings exported", + "settings.keyboard.keybindingsImported": "Imported {count} keybindings{preset}", + "settings.keyboard.keybindingsReset": "Keybindings reset to defaults", + "settings.keyboard.noKeybindings": "No keybindings found", + "settings.keyboard.none": "None", + "settings.keyboard.openEditor": "Open Editor", + "settings.keyboard.pluralSuffix": "s", + "settings.keyboard.preset": "Preset", + "settings.keyboard.presetAria": "Keybinding preset", + "settings.keyboard.presetChanges": "Preset Changes", + "settings.keyboard.presetDescription": "Apply a base shortcut style before your custom overrides.", + "settings.keyboard.presetIncomplete": "This preset is incomplete. {count} built-in command{suffix} still missing preset coverage.", + "settings.keyboard.presetLabel": "Keybinding Preset", + "settings.keyboard.presetPluralSuffix": "s are", + "settings.keyboard.presetSingularSuffix": " is", + "settings.keyboard.resetToDefaults": "Reset to Defaults", + "settings.keyboard.search": "Search actions or shortcuts", + "settings.keyboard.source": "Source", + "settings.keyboard.user": "User", + "settings.keyboard.userOverrides": "{count} user override{suffix} currently saved.", + "settings.keyboard.vimMode": "Vim Mode", + "settings.keyboard.vimModeComingSoon": "Vim mode is coming soon!\n\nThis will enable vim-style keybindings in the editor for power users.", + "settings.keyboard.vimModeDescription": "Enable vim keybindings and commands", + "settings.keyboard.when": "When", + "settings.languageChinese": "Simplified Chinese", + "settings.languageEnglish": "English", + "settings.logs.cancel": "Cancel", + "settings.logs.category.all": "All modules", + "settings.logs.category.application": "Application", + "settings.logs.category.git": "Git", + "settings.logs.category.language": "Editor and language services", + "settings.logs.category.performance": "Performance", + "settings.logs.category.plugins": "Plugins", + "settings.logs.category.run": "Build, run and debug", + "settings.logs.choose": "Choose…", + "settings.logs.chooseDirectory": "Choose a Parent Directory for Lithe Logs", + "settings.logs.cleanupFailed": "Unable to clean up logs: {error}", + "settings.logs.clearCurrent": "Clear current directory logs", + "settings.logs.clearCurrentDescription": "Delete only log files managed by Lithe in the active directory.", + "settings.logs.clearLogs": "Clear Logs", + "settings.logs.clearPrompt": "Delete only Lithe-managed logs in the active directory across all modules and sessions. Filters do not limit this action. New events will continue to be recorded.", + "settings.logs.clearTitle": "Clear collected logs?", + "settings.logs.cleared": "Deleted {count} log files and freed {size}.", + "settings.logs.copyFailed": "Unable to copy the log path: {error}", + "settings.logs.copyPath": "Copy Path", + "settings.logs.customPath": "Custom log location", + "settings.logs.customPathDescription": "Choose a parent directory; Lithe writes to its Lithe/logs subdirectory.", + "settings.logs.defaultPath": "Default log location", + "settings.logs.defaultPathDescription": "Lithe always maintains and cleans this application-owned directory.", + "settings.logs.defaultRestored": "The default log directory is active.", + "settings.logs.deleteLogs": "Delete Logs", + "settings.logs.diagnosticBundle": "Diagnostic bundle", + "settings.logs.diagnosticBundleDescription": "Preview and export the selected logs with environment information. Credentials and home-directory paths are redacted; source code, editor buffers and terminal history are excluded.", + "settings.logs.diagnosticExpiry": "Detailed diagnostics cover the selected module and expire after 15 minutes. Refresh to see the current status.", + "settings.logs.diagnosticFailed": "Unable to change diagnostic logging: {error}", + "settings.logs.diagnosticMode": "Detailed diagnostics", + "settings.logs.diagnosticModeDescription": "Record detailed events for the selected module for 15 minutes.", + "settings.logs.diagnosticOff": "Detailed diagnostics: Off", + "settings.logs.diagnosticOn": "Detailed diagnostics: On ({module})", + "settings.logs.diagnostics": "Diagnostics", + "settings.logs.directoryChangeFailed": "Unable to change the log directory: {error}", + "settings.logs.directoryChanged": "The log directory changed immediately.", + "settings.logs.effectivePath": "Current log location", + "settings.logs.effectivePathDescription": "The directory receiving logs in this session.", + "settings.logs.empty": "No matching logs", + "settings.logs.exportBundle": "Export Diagnostic Bundle…", + "settings.logs.exportBundleChooseDestination": "Choose Where to Save the Diagnostic Bundle", + "settings.logs.exportBundleConfirm": "Export", + "settings.logs.exportBundleFailed": "Unable to export the diagnostic bundle: {error}", + "settings.logs.exportBundleFilter": "Zip Archives", + "settings.logs.exportBundleIntro": "Lithe will include the following files, with credentials, tokens, and home-directory paths already redacted:", + "settings.logs.exportBundlePreviewFailed": "Unable to prepare the diagnostic bundle: {error}", + "settings.logs.exportBundleSuccess": "Diagnostic bundle saved to {path}.", + "settings.logs.exportBundleTitle": "Export Diagnostic Bundle?", + "settings.logs.fallback.loggingUnavailable": "Application file logging is unavailable. Lithe is continuing with console diagnostics only.", + "settings.logs.fallback.notWritable": "The configured directory is not writable. This session is writing to the default location.", + "settings.logs.fallback.unavailable": "The configured directory is unavailable. This session is writing to the default location.", + "settings.logs.fallbackNotification": "The log directory {path} is unavailable; this session is using the default location.", + "settings.logs.health": "Dropped: {dropped}; write failures: {failures}", + "settings.logs.incomplete": "Earlier evidence was omitted by collection limits.", + "settings.logs.keepLogs": "Keep Logs", + "settings.logs.level": "Minimum level", + "settings.logs.loadFailed": "Unable to load log settings: {error}", + "settings.logs.loading": "Loading…", + "settings.logs.locations": "Log locations", + "settings.logs.module": "Module", + "settings.logs.openCurrent": "Open Current Log", + "settings.logs.openDirectory": "Open Directory", + "settings.logs.openDirectoryFailed": "Unable to open the log directory: {error}", + "settings.logs.openFailed": "Unable to open the current log: {error}", + "settings.logs.pathCopied": "Log path copied.", + "settings.logs.previewExport": "Preview and Export…", + "settings.logs.previousDirectoryPrompt": "The previous directory {path} still contains about {size} of Lithe logs. Delete them?", + "settings.logs.previousDirectoryTitle": "Clean Up Previous Logs?", + "settings.logs.refreshFailed": "Logs were cleared, but the open log tab could not refresh: {error}", + "settings.logs.refreshTimeline": "Refresh logs", + "settings.logs.restoreDefault": "Restore Default", + "settings.logs.retention": "Retention and cleanup", + "settings.logs.retentionDescription": "Module logs rotate at 10 MiB and expire after 30 days. The combined budget is 100 MiB; performance logs have a separate 20 MiB budget.", + "settings.logs.search": "Filter log messages", + "settings.logs.selectionSummary": "Module: {module} · Session: {session} · Minimum level: {level}", + "settings.logs.session": "Session", + "settings.logs.session.all": "All sessions", + "settings.logs.session.current": "Current session", + "settings.logs.session.previous": "Previous session", + "settings.logs.since": "From (UTC ISO 8601)", + "settings.logs.timeline": "Log timeline", + "settings.logs.unavailableNotification": "Application file logging is unavailable; Lithe will continue without writing log files.", + "settings.logs.until": "Until (UTC ISO 8601)", + "settings.logs.usingDefault": "Using the default location", + "settings.logs.workflow": "Choose a module, enable detailed diagnostics, reproduce the problem, then preview and export a diagnostic bundle.", + "settings.mac.aiProvider": "AI provider", + "settings.mac.apiKey": "API key or token", + "settings.mac.apiKeyPlaceholder": "Stored securely by the app", + "settings.mac.apiKeySaveFailed": "Failed to save API key", + "settings.mac.apiKeySaved": "API key saved", + "settings.mac.apiKeySavedWithoutValidation": "API key saved, but validation failed", + "settings.mac.apiKeyStoredPlaceholder": "Saved — type a new key to replace it", + "settings.mac.apiUrl": "API URL", + "settings.mac.appearance": "Appearance", + "settings.mac.appearanceDescription": "Choose a color theme and whether Lithe follows the system appearance.", + "settings.mac.appearanceMode": "Appearance mode", + "settings.mac.apply": "Apply", + "settings.mac.askEveryTime": "Ask every time", + "settings.mac.attach": "Attach", + "settings.mac.autoCompletion": "Auto completion", + "settings.mac.autoCompletionDescription": "Show completion suggestions from the active language server.", + "settings.mac.autoSave": "Save changed files automatically", + "settings.mac.categories": "Settings categories", + "settings.mac.checkForUpdates": "Check for Updates", + "settings.mac.checking": "Checking for updates…", + "settings.mac.colorTheme": "Color theme", + "settings.mac.commitMessage": "Commit messages", + "settings.mac.currentVersion": "Current version: {version}", + "settings.mac.dark": "Dark", + "settings.mac.defaultShell": "Default shell", + "settings.mac.defaultShellDescription": "Used for new terminal sessions.", + "settings.mac.detectedServers": "Detected language servers", + "settings.mac.detectedServersDescription": "Language servers are detected from installed language extensions and start when a supported file opens.", + "settings.mac.directories": "Directories", + "settings.mac.display": "Display", + "settings.mac.done": "Done", + "settings.mac.editorTabs": "Editor tabs", + "settings.mac.enableAiCommit": "Generate commit messages with AI", + "settings.mac.filePatterns": "File patterns", + "settings.mac.files": "Files", + "settings.mac.followSystem": "System", + "settings.mac.fontSize": "Font size", + "settings.mac.git": "Git", + "settings.mac.gitPolicyDescription": "Choose how local changes are protected before a Git operation.", + "settings.mac.gitStash": "Git stash", + "settings.mac.hiddenPaths": "Hidden paths", + "settings.mac.hiddenPathsDescription": "One entry per line. Directory names hide matching folders; file entries support * and ?.", + "settings.mac.indentation": "Indentation", + "settings.mac.keymapJetBrains": "JetBrains", + "settings.mac.keymapLithe": "Lithe", + "settings.mac.keymapPreset": "Keymap", + "settings.mac.keymapVisualStudioCode": "Visual Studio Code", + "settings.mac.keymapXcode": "Xcode", + "settings.mac.language": "Language", + "settings.mac.languageDescription": "The interface language changes immediately. English is the default.", + "settings.mac.languageServices": "Language services", + "settings.mac.layout": "Layout", + "settings.mac.light": "Light", + "settings.mac.model": "Model", + "settings.mac.newWindow": "New window", + "settings.mac.openProjectsDescription": "Choose whether opening another project asks first, replaces the current project, attaches it to this window, or creates a new window.", + "settings.mac.openProjectsIn": "Open projects in", + "settings.mac.parameterHints": "Parameter hints", + "settings.mac.preset": "Preset", + "settings.mac.projects": "Projects", + "settings.mac.provider": "Provider", + "settings.mac.restoreDefaults": "Restore Defaults", + "settings.mac.restoreDefaultsConfirm": "⚠️ Reset all settings to their defaults?", + "settings.mac.saveLocalChangesWith": "Save local changes with", + "settings.mac.searchShortcuts": "Search shortcuts", + "settings.mac.semanticHighlighting": "Semantic highlighting", + "settings.mac.shelf": "Shelf", + "settings.mac.shell": "Shell", + "settings.mac.shortcuts": "Keyboard shortcuts", + "settings.mac.shortcutsDescription": "Select a keymap preset, then use the command palette to inspect and run available commands.", + "settings.mac.showCodeVision": "Show usages and Git author", + "settings.mac.singleRow": "Single row", + "settings.mac.softwareUpdate": "Software Update", + "settings.mac.spaces": "spaces", + "settings.mac.systemDefault": "System default", + "settings.mac.tabWidth": "Tab width", + "settings.mac.thisWindow": "This window", + "settings.mac.upToDate": "Lithe is up to date.", + "settings.mac.updateAvailable": "Version {version} is available.", + "settings.mac.updateFailed": "The update check failed. Try again later.", + "settings.mac.updateHint": "Lithe can check for new preview and stable releases.", + "settings.mac.wrapRows": "Wrap into rows", + "settings.mcp.allowConfigure": "Allow project environment and Maven configuration changes", + "settings.mcp.allowExecute": "Allow run, build, reload and stop (executes project code)", + "settings.mcp.copyConfiguration": "Copy agent configuration", + "settings.mcp.description": "Authorizes this project only. Connected agents can inspect environment, run configurations and output, which may contain sensitive project data. Access ends when the project or IDE closes.", + "settings.mcp.disable": "Disable MCP", + "settings.mcp.enable": "Enable MCP", + "settings.mcp.openProject": "Open a project to configure MCP access for agents.", + "settings.mcp.plugins": "Enabled plugins declaring IDE access can also use this project grant.", + "settings.mcp.title": "MCP Configuration", + "settings.navigation": "Settings navigation", + "settings.noMatching": "No settings found", + "settings.noMatchingSettings": "No settings found", + "settings.noResults": "No settings found", + "settings.project.detected": "Detected installations", + "settings.project.loading": "Working…", + "settings.project.mavenAutomatic": "Automatic: project Maven Wrapper, detected Maven, then mvnd. You can also enter a Maven or mvnd home, bin directory, or executable path.", + "settings.project.openProject": "Open a project to configure its JDK and Maven.", + "settings.project.openSettings": "Configure project JDK and Maven…", + "settings.project.overrides": "These overrides affect only this run configuration. Clear a path to inherit the project environment.", + "settings.project.refresh": "Reload and detect", + "settings.project.reloadFailed": "Could not reload project environment.", + "settings.project.saved": "Project environment saved.", + "settings.project.savedReloadFailed": "Project defaults were saved, but updating Maven or reloading failed: ", + "settings.project.scope": "These defaults apply only to this project on this computer. Run configurations inherit them unless overridden. Use the Project save scope in a run configuration to share settings with your team. Leave a path empty to use automatic selection.", + "settings.project.scopeTitle": "This computer · This project", + "settings.project.title": "Project · JDK & Maven", + "settings.project.useProjectJdk": "Use project JDK", + "settings.resizeCategories": "Resize settings categories", + "settings.resizePlugins": "Resize plugin list", + "settings.run.description": "Select a service or task to configure its arguments, environment and project-environment overrides. Changes apply after Save.", + "settings.run.title": "Run configurations", + "settings.search": "Search settings", + "settings.sections": "Settings sections", + "settings.tabs.advanced": "Advanced", + "settings.tabs.agents": "Agents", + "settings.tabs.ai": "AI Chat & Editing", + "settings.tabs.aiCommit": "AI & Commit", + "settings.tabs.appearance": "Appearance", + "settings.tabs.editor": "Editor", + "settings.tabs.files": "Files", + "settings.tabs.general": "General", + "settings.tabs.git": "Git", + "settings.tabs.keyboard": "Keymap", + "settings.tabs.logs": "Logs and Diagnostics", + "settings.tabs.lsp": "LSP", + "settings.tabs.maven": "Maven", + "settings.tabs.plugins": "Plugins", + "settings.tabs.terminal": "Terminal", + "settings.tabs.updates": "Updates", + "settings.terminal.addProfile": "Add Profile", + "settings.terminal.altClickMovesCursor": "Alt Click Moves Cursor", + "settings.terminal.altClickMovesCursorDescription": "Move the shell prompt cursor to the clicked position when supported", + "settings.terminal.bar": "Bar", + "settings.terminal.blinkingCursor": "Blinking Cursor", + "settings.terminal.blinkingCursorDescription": "Whether the cursor should blink", + "settings.terminal.block": "Block", + "settings.terminal.cursor": "Cursor", + "settings.terminal.cursorStyle": "Cursor Style", + "settings.terminal.cursorStyleDescription": "Shape of the cursor", + "settings.terminal.cursorWidth": "Cursor Width", + "settings.terminal.cursorWidthDescription": "Thickness of the bar or block cursor", + "settings.terminal.custom": "Custom", + "settings.terminal.customProfile": "Custom Profile {number}", + "settings.terminal.defaultProfile": "Default Profile", + "settings.terminal.defaultProfileDescription": "Used by the terminal toolbar button and Cmd+T when the terminal is focused.", + "settings.terminal.defaultShell": "Default Shell", + "settings.terminal.defaultShellDescription": "Fallback shell when a terminal profile does not override it.", + "settings.terminal.deleteProfile": "Delete {profile}", + "settings.terminal.fontFamily": "Font Family", + "settings.terminal.fontFamilyDescription": "Font family for the integrated terminal. Select a Nerd Font for best icon support.", + "settings.terminal.fontHelp": "Note: Selected font must be installed on your system to work correctly. If icons are missing, try installing a Nerd Font.", + "settings.terminal.fontSize": "Font Size", + "settings.terminal.fontSizeDescription": "Terminal font size in pixels", + "settings.terminal.hidden": "Hidden", + "settings.terminal.inactiveCursorStyle": "Inactive Cursor Style", + "settings.terminal.inactiveCursorStyleDescription": "Appearance of the terminal cursor when the terminal is not focused", + "settings.terminal.interaction": "Interaction", + "settings.terminal.launch": "Launch", + "settings.terminal.launchDescription": "Choose which shell and profile new terminal tabs should use by default.", + "settings.terminal.letterSpacing": "Letter Spacing", + "settings.terminal.letterSpacingDescription": "Additional spacing between characters", + "settings.terminal.lineHeight": "Line Height", + "settings.terminal.lineHeightDescription": "Line height multiplier", + "settings.terminal.nerdFont": "Nerd Font", + "settings.terminal.noCustomProfiles": "No custom terminal profiles yet.", + "settings.terminal.optionAsMeta": "Option as Meta", + "settings.terminal.optionAsMetaDescription": "Treat the Option key as Meta in terminal applications on macOS", + "settings.terminal.outline": "Outline", + "settings.terminal.profileName": "Name", + "settings.terminal.profileNamePlaceholder": "My Profile", + "settings.terminal.profilePickerDescription": "Visible in the terminal profile picker.", + "settings.terminal.profileShell": "Shell", + "settings.terminal.profiles": "Profiles", + "settings.terminal.profilesDescription": "Create reusable launch presets with a shell override, startup directory, and optional startup commands.", + "settings.terminal.profilesHelp": "Built-in profiles are generated from detected shells. Custom profiles appear in the terminal toolbar profile picker.", + "settings.terminal.rightClickSelectsWord": "Right Click Selects Word", + "settings.terminal.rightClickSelectsWordDescription": "Select the word under the pointer before opening the context menu", + "settings.terminal.scrollback": "Scrollback", + "settings.terminal.scrollbackDescription": "How many lines of terminal history to keep in memory", + "settings.terminal.selectFont": "Select font...", + "settings.terminal.startupCommands": "Startup Commands", + "settings.terminal.startupCommandsDescription": "Enter one command per line.", + "settings.terminal.startupCommandsPlaceholder": "One command per line", + "settings.terminal.startupDirectory": "Startup Directory", + "settings.terminal.startupDirectoryDescription": "Leave empty to use the current workspace directory.", + "settings.terminal.startupDirectoryPlaceholder": "Leave empty to use the current workspace directory", + "settings.terminal.systemDefault": "System Default", + "settings.terminal.typography": "Typography", + "settings.terminal.underline": "Underline", + "springEndpoints.empty": "No Spring MVC endpoints found", + "springEndpoints.error.indexFailed": "Spring endpoint indexing failed.", + "springEndpoints.error.indexTimeout": "Spring endpoint indexing timed out.", + "springEndpoints.error.permissionDenied": "Spring endpoint indexing does not have permission to read this workspace.", + "springEndpoints.error.rootUnavailable": "The Spring workspace root is unavailable.", + "springEndpoints.error.unsupportedRoot": "Spring endpoint indexing is unavailable for this workspace.", + "springEndpoints.filterPlaceholder": "Filter route, controller, or method", + "springEndpoints.loading": "Indexing Spring project...", + "springEndpoints.noMatch": "No endpoints match the current filter", + "springEndpoints.refreshing": "Updating Spring endpoints...", + "springEndpoints.routeCount": "{count} routes", + "springEndpoints.routeCountOne": "{count} route", + "springEndpoints.stale": "Showing the last successful Spring index. Refresh failed.", + "svg.editor": "Editor", + "svg.invalid": "Could not render SVG. Check the XML source.", + "svg.preview": "Preview", + "svg.resize": "Resize SVG editor and preview", + "svg.split": "Editor and Preview", + "svg.viewMode": "SVG view mode", + "tabs.activated": "Activated {name}{unsaved}", + "tabs.announcementUnsaved": ", unsaved changes", + "tabs.ariaPinnedSuffix": " (pinned)", + "tabs.ariaPreviewSuffix": " (preview)", + "tabs.ariaUnsavedSuffix": " (unsaved)", + "tabs.cannotClosePinned": "Cannot close pinned tab {name}", + "tabs.close": "Close", + "tabs.closeAll": "Close All", + "tabs.closeOthers": "Close Others", + "tabs.closeSaved": "Close Saved", + "tabs.closeSplit": "Close Split", + "tabs.closeSplitPane": "Close split pane", + "tabs.closeToRight": "Close to Right", + "tabs.closed": "Closed {name}", + "tabs.couldNotReopen": "Couldn't reopen {name}", + "tabs.exitFullScreen": "Exit Full Screen", + "tabs.fullScreenEditor": "Full Screen Editor", + "tabs.goBackShort": "Go Back", + "tabs.lockEditorGroup": "Lock Editor Group", + "tabs.newTab": "New tab", + "tabs.noRecentlyClosed": "No recently closed tabs", + "tabs.openFiles": "Open files", + "tabs.pin": "Pin Tab", + "tabs.reload": "Reload", + "tabs.rename": "Rename {name}", + "tabs.splitDown": "Split Down", + "tabs.splitRight": "Split Right", + "tabs.switchedTo": "Switched to {name}{unsaved}", + "tabs.toggleEditorFullScreen": "Toggle editor full screen", + "tabs.unlockEditorGroup": "Unlock Editor Group", + "tabs.unpin": "Unpin Tab", + "tabs.unsavedChanges": "Unsaved changes", + "terminal.allFiles": "All Files", + "terminal.chooseTerminalProfile": "Choose Terminal Profile", + "terminal.contextClear": "Clear Terminal", + "terminal.contextClose": "Close Terminal", + "terminal.contextCloseAll": "Close All Terminals", + "terminal.contextCloseOthers": "Close Other Terminals", + "terminal.contextCloseRight": "Close Terminals to Right", + "terminal.contextDuplicate": "Duplicate Terminal", + "terminal.contextExport": "Export Output", + "terminal.contextPin": "Pin Terminal", + "terminal.contextRename": "Rename Terminal", + "terminal.contextUnpin": "Unpin Terminal", + "terminal.copySelection": "Copy Selection", + "terminal.detectShells": "Detect Installed Shells", + "terminal.detectShellsFailed": "Shell detection failed. Please retry. Existing terminals are unaffected.", + "terminal.detectingShells": "Detecting shells…", + "terminal.errorFallback": "Failed to initialize terminal", + "terminal.errorTitle": "Terminal Error", + "terminal.exitFullScreen": "Exit Full Screen", + "terminal.findInTerminal": "Find in Terminal", + "terminal.fullScreen": "Full Screen", + "terminal.fullScreenTerminal": "Full Screen Terminal", + "terminal.minimizeTerminal": "Minimize Terminal", + "terminal.newTerminal": "New Terminal", + "terminal.nextTab": "Next Tab", + "terminal.noTerminals": "No terminals", + "terminal.paste": "Paste", + "terminal.pasteCopiedFilePaths": "Paste Copied File Paths", + "terminal.pasteIntoTerminal": "Paste into Terminal", + "terminal.pasteLinesConfirm": "Paste {count} lines into the terminal? This may run multiple commands.", + "terminal.previousTab": "Previous Tab", + "terminal.resizeSidebar": "Resize terminal sidebar", + "terminal.retry": "Retry", + "terminal.search": "Search", + "terminal.searchMatchCase": "Match case", + "terminal.searchMatchWholeWord": "Match whole word", + "terminal.searchPlaceholder": "Find in terminal...", + "terminal.searchUseRegex": "Use regular expression", + "terminal.shellUnavailable": "Not detected", + "terminal.tabAriaPinned": " (pinned)", + "terminal.tabClose": "Close {name}", + "terminal.tabNamePlaceholder": "Terminal name", + "terminal.tabRenameAria": "Rename {name}", + "terminal.tabUnpin": "Unpin terminal", + "terminal.terminalTabs": "Terminal tabs", + "terminal.terminals": "Terminals", + "terminal.textFiles": "Text Files", + "terminal.toolbarEditorWidth": "Editor Width", + "terminal.toolbarFullWidth": "Full Width", + "terminal.toolbarHorizontalTabs": "Horizontal Tabs", + "terminal.toolbarTabLayout": "Tab Layout", + "terminal.toolbarTabPosition": "Tab Position", + "terminal.toolbarTabsOnLeft": "Tabs on Left", + "terminal.toolbarTabsOnRight": "Tabs on Right", + "terminal.toolbarTerminalWidth": "Terminal Width", + "terminal.toolbarVerticalTabs": "Vertical Tabs", + "titleProject.addProject": "Add Project", + "titleProject.cloneRepository": "Clone Repository…", + "titleProject.closeAllProjects": "Close All Projects", + "titleProject.closeProject": "Close project {name}", + "titleProject.currentProject": "Current project {project}", + "titleProject.displayAliasPrompt": "Enter a display alias shown after the folder name to distinguish projects with the same name.", + "titleProject.newProject": "New Project…", + "titleProject.newWindow": "New Window", + "titleProject.noRecentProjects": "No recent projects", + "titleProject.open": "Open…", + "titleProject.openFolder": "Open Folder", + "titleProject.openFolderInNewWindow": "Open Folder in New Window", + "titleProject.openInNewWindow": "Open in New Window", + "titleProject.openProjects": "Open Projects", + "titleProject.recentProjects": "Recent Projects", + "titleProject.removeProject": "Remove Project", + "titleProject.selectIcon": "Select Icon", + "titleProject.setDisplayAlias": "Set Display Alias", + "titleProject.switchToProject": "Switch to {project}", + "titleProject.switchToProjectMenu": "Switch to Project", + "titleProject.trigger": "Project: {project}", + "toolchain.detecting": "Detecting…", + "toolchain.failed": "Could not resolve: {message}", + "toolchain.invalid": "Cannot be used: {message}", + "toolchain.javaNotFound": "No JDK found. Set JAVA_HOME, install a JDK, or choose a JDK directory.", + "toolchain.jdk": "JDK {version}", + "toolchain.maven": "Maven {version}", + "toolchain.mavenNotFound": "No Maven found. Add a Maven Wrapper to the project or choose a Maven installation.", + "toolchain.mode.automatic": "Automatic", + "toolchain.mode.configured": "Selected", + "toolchain.mode.inherit": "Inherited from project", + "toolchain.mode.projectJdk": "Use project JDK", + "toolchain.source.detected": "detected on this computer", + "toolchain.source.javaHome": "from JAVA_HOME", + "toolchain.source.mavenWrapper": "project Maven Wrapper", + "toolchain.source.path": "from PATH", + "toolchain.source.project": "from the project's .lithe/toolchains", + "ui.archive": "Archive", + "ui.backToProjects": "Back to projects", + "ui.breadcrumb": "breadcrumb", + "ui.browse": "Browse", + "ui.cancel": "Cancel", + "ui.carousel": "carousel", + "ui.choose": "Choose", + "ui.clear": "Clear", + "ui.close": "Close", + "ui.closeDialog": "Close dialog", + "ui.closeToast": "Close toast", + "ui.confirm": "Confirm", + "ui.decreaseValue": "Decrease value", + "ui.delete": "Delete", + "ui.done": "Done", + "ui.edit": "Edit", + "ui.import": "Import", + "ui.increaseValue": "Increase value", + "ui.input": "Input", + "ui.loading": "Loading", + "ui.more": "More", + "ui.morePages": "More pages", + "ui.next": "Next", + "ui.nextPage": "Go to next page", + "ui.nextSlide": "Next slide", + "ui.noActionsHere": "Nothing here", + "ui.noMatchingOptions": "No matching options", + "ui.notice": "Notice", + "ui.ok": "OK", + "ui.open": "Open", + "ui.pagination": "pagination", + "ui.pin": "Pin", + "ui.previous": "Previous", + "ui.previousPage": "Go to previous page", + "ui.previousSlide": "Previous slide", + "ui.refresh": "Refresh", + "ui.remove": "Remove", + "ui.rename": "Rename", + "ui.retry": "Retry", + "ui.save": "Save", + "ui.saving": "Saving...", + "ui.searchPlaceholder": "Search...", + "ui.selectPlaceholder": "Select...", + "ui.slide": "slide", + "ui.symlink": "Symlink", + "ui.unpin": "Unpin", + "ui.useCustomValue": "Use {value}", + "unsavedChanges.doNotSave": "Don't Save", + "unsavedChanges.messagePrefix": "Do you want to save the changes you made to", + "unsavedChanges.messageSuffix": "?", + "unsavedChanges.title": "Unsaved Changes", + "unsavedProjectTransition.action.closingThisProject": "closing this project", + "unsavedProjectTransition.action.restartingToUpdate": "restarting to update", + "unsavedProjectTransition.action.switchingProjects": "switching projects", + "unsavedProjectTransition.discardAll": "Discard All", + "unsavedProjectTransition.messageMany": "Save changes to {count} files before {action}?", + "unsavedProjectTransition.messageOne": "Save changes to \"{file}\" before {action}?", + "unsavedProjectTransition.saveAll": "Save All", + "unsavedProjectTransition.saveBeforeContinuing": "Save \"{file}\" before continuing.", + "unsavedProjectTransition.saveOrCloseBeforeContinuing": "Save or close {count} unsaved files before continuing.", + "update.acknowledgeDownload": "I understand — open release downloads", + "update.autobuildBuiltAt": "AutoBuild — Built at {timestamp}", + "update.autobuildWarning": "AutoBuild is a rolling development release and is likely to contain many bugs and unfinished features. Ordinary users should stay on Stable.", + "update.available": "Update available", + "update.availableVersion": "Update available: {version}", + "update.channel": "Update channel", + "update.channelSaveFailed": "Could not save the update channel. Check application storage and try again.", + "update.channelStable": "Stable (recommended)", + "update.confirmAutobuild": "Use the AutoBuild rolling channel?", + "update.currentVersion": "Current version", + "update.detailsTitle": "Lithe {version} update", + "update.downloadLater": "Download Later", + "update.downloading": "Downloading", + "update.failed": "Update failed", + "update.installing": "Installing", + "update.installingTooltip": "Installing update...", + "update.manualDownloadExplanation": "This channel requires a manual installer download. Open the selected release page, choose the Windows installer, save your work and close Lithe before running it. Lithe and Lithe-Dev install separately. On its first launch, Lithe-Dev copies Stable settings and workspaces if it has no user data; then both apps save settings and caches independently. Some accounts may need sign-in again. The displayed version may be lower or unchanged; it cannot determine whether this rolling build is newer. Lithe has not checked that you have the latest build.", + "update.manualDownloadStatus": "Use the selected channel’s release page to download and install. Build freshness has not been checked.", + "update.manualDownloadTitle": "Install from release downloads", + "update.openReleasePage": "Open release page", + "update.options": "Update options", + "update.releaseDate": "Release date", + "update.releaseNotes": "Release notes", + "update.remindTomorrow": "Remind Me Tomorrow", + "update.skipVersion": "Skip {version}", + "update.targetVersion": "Target version", + "update.updatingProgress": "Updating Lithe {percentage}%", + "update.useAutobuild": "I understand — use AutoBuild", + "update.version": "Version", + "update.viewDownloads": "View release downloads", + "update.viewReleaseNotes": "View Release Notes", + "v0DesignSystem.active": "active", + "v0DesignSystem.add": "add", + "v0DesignSystem.addRegistry": "Add registry", + "v0DesignSystem.addTitle": "Add v0 design system", + "v0DesignSystem.adding": "adding", + "v0DesignSystem.backToCommands": "Back to commands", + "v0DesignSystem.backToDesignSystems": "Back to v0 design systems", + "v0DesignSystem.fallbackName": "Design system", + "v0DesignSystem.loadPublicFailed": "Could not load public registries", + "v0DesignSystem.namePlaceholder": "Name", + "v0DesignSystem.noFound": "No design systems found", + "v0DesignSystem.noneDescription": "Use v0 defaults", + "v0DesignSystem.noneName": "No design system", + "v0DesignSystem.notesPlaceholder": "Notes", + "v0DesignSystem.publicCount": "{count} public", + "v0DesignSystem.refreshPublicRegistries": "Refresh public registries", + "v0DesignSystem.registryDirectoryStatus": "Registry directory returned {status}", + "v0DesignSystem.registryRequired": "Registry URL is required.", + "v0DesignSystem.removeSelected": "Remove selected", + "v0DesignSystem.saveAndUse": "Save and use", + "v0DesignSystem.saved": "saved", + "v0DesignSystem.searchPlaceholder": "Search v0 design systems...", + "viewer.clearBrowsingData": "Clear browsing data", + "viewer.closeImageViewer": "Close image viewer", + "viewer.copyUrl": "Copy URL", + "viewer.createWebviewFailed": "Couldn't create webview.", + "viewer.enterUrl": "Enter URL...", + "viewer.enterUrlPrompt": "Enter URL:", + "viewer.enterValidUrl": "Enter a valid URL", + "viewer.extension": "Extension", + "viewer.failedToLoadFile": "Failed to load file", + "viewer.failedToLoadPdfDocument": "Failed to load PDF document.", + "viewer.failedToReadFile": "Failed to read file: {error}", + "viewer.fileInformation": "File Information", + "viewer.hexPreview": "Hex Preview", + "viewer.loadingBinaryFile": "Loading binary file", + "viewer.loadingImage": "Loading image", + "viewer.loadingPage": "Loading page", + "viewer.loadingPdf": "Loading PDF", + "viewer.loadingPdfPage": "Loading page", + "viewer.localBrowserPage": "Local browser page", + "viewer.localOrPrivateConnection": "Local or private network connection", + "viewer.notSecureHttp": "Not secure (HTTP)", + "viewer.openExternalLinkConfirm": "Do you want to open this external link?\n\n{url}", + "viewer.openExternalLinkTitle": "External Link", + "viewer.openExternalViewer": "Open in external viewer", + "viewer.openInBrowser": "Open in browser", + "viewer.openPage": "Open a page", + "viewer.openPageDescription": "Enter a URL to load a website, local development server, or app-bound page.", + "viewer.openUrl": "Open URL", + "viewer.page": "Page", + "viewer.path": "Path", + "viewer.readingFile": "Reading file", + "viewer.resetZoom": "Reset zoom", + "viewer.section": "Section", + "viewer.sections": "Sections", + "viewer.secureConnectionHttps": "Secure connection (HTTPS)", + "viewer.size": "Size", + "viewer.stopLoading": "Stop loading", + "viewer.type": "Type", + "viewer.wasmVersion": "WASM Version", + "viewer.webAssemblyModule": "WebAssembly Module", + "viewer.zoom": "Zoom", + "viewer.zoomControls": "Zoom controls", + "viewer.zoomIn": "Zoom in", + "viewer.zoomOut": "Zoom out", + "welcome.addRemote": "Add Remote", + "welcome.backToProjects": "Back to projects", + "welcome.builtIn": "Built-in", + "welcome.checkUpdates": "Check for Updates", + "welcome.chooseHowToStart": "Choose how to start...", + "welcome.clone": "Clone", + "welcome.cloneRepository": "Clone Repository", + "welcome.cloneRepositoryDescription": "Clone an existing Git repository into a new local project.", + "welcome.emptyProject": "Empty Project", + "welcome.emptyProjectDescription": "Create a clean folder and start from scratch.", + "welcome.gitBadge": "Git", + "welcome.newProject": "New Project", + "welcome.newRemoteConnection": "New Remote Connection", + "welcome.nextjsDescription": "App Router, TypeScript, Tailwind CSS, ESLint, and a src directory.", + "welcome.noRecentProjects": "No recent projects", + "welcome.noStarters": "No project starters match \"{query}\".", + "welcome.open": "Open", + "welcome.openFolder": "Open Folder", + "welcome.openFolderHint": "Open a folder to get started.", + "welcome.openProject": "Open Project", + "welcome.openProjectOrRemote": "Open project or remote connection", + "welcome.projects": "Projects", + "welcome.recentProjects": "Recent Projects", + "welcome.removeRecent": "Remove {name} from recent projects", + "welcome.searchProjects": "Search projects", + "welcome.title": "Welcome to Lithe", + "welcome.viteReactDescription": "A lightweight React and TypeScript starter.", + "welcome.webApp": "Web app", + "window.close": "Close", + "window.maximize": "Maximize", + "window.menu": "Menu", + "window.minimize": "Minimize", + "window.restore": "Restore", + "windowOpen.cannotOpenPath": "Cannot open \"{path}\". Check that it exists and is accessible.", + "windowOpen.pathIsNotFolder": "Path is not a folder: {path}", + "windowOpen.runCommand": "Run Command", + "windowOpen.runTerminalCommandPrompt": "Open a terminal and run this command?", + "windowOpen.runTerminalCommandTitle": "Run Terminal Command", + "windowOpen.workingDirectory": "Working directory: {path}", + "workbench.activityViews": "Activity views", + "workbench.changes": "Changes", + "workbench.commit": "Commit", + "workbench.currentFile": "Current File", + "workbench.database": "Database", + "workbench.diagnostics": "Diagnostics", + "workbench.emptyEditorDescription": "Changes from external tools will appear automatically.", + "workbench.emptyEditorTitle": "Select a file to review", + "workbench.gitLog": "Git Log", + "workbench.maven": "Maven", + "workbench.moreProjectActions": "More project actions", + "workbench.openProject": "Open Project", + "workbench.project": "Project", + "workbench.renderErrorDescription": "Retry to restore this surface without restarting Lithe.", + "workbench.renderErrorTitle": "The editor failed to render", + "workbench.run": "Run", + "workbench.search": "Search", + "workbench.searchInFiles": "Search in files...", + "workbench.settings": "Settings", + "workbench.springEndpoints": "Spring Endpoints", + "workbench.terminal": "Terminal", + "native.inline.languageServerTools": "Language Server Tools", + "native.inline.installDetectAndSelectLspExecutables": "Install, detect, and select LSP executables", + "native.inline.languageServer": "Language server", + "native.inline.resolvedExecutable": "Resolved executable", + "native.inline.executableFoundNotVerified": "Executable found (not verified)", + "native.inline.executableVerified": "Executable verified", + "native.inline.executableNotFound": "Executable not found", + "native.inline.customPath": "Custom path", + "native.inline.useAutomaticDetection": "Use automatic detection", + "native.inline.chooseOrEnterAnAbsolutePath": "Choose or enter an absolute path", + "native.inline.chooseExecutable": "Choose executable", + "native.inline.save": "Save", + "native.inline.theNextLspSessionWillUseThisPath": "The next LSP session will use this path.", + "native.inline.installation": "Installation", + "native.inline.homebrewIsPreferredUseTheOfficialDownloadWhenHomebrewOrAFormulaIsUnavailabl": "Homebrew is preferred. Use the official download when Homebrew or a formula is unavailable.", + "native.inline.officialDownload": "Official download", + "native.inline.homebrewWasNotFoundUseTheOfficialDownloadOrChooseAnExecutable": "Homebrew was not found. Use the official download or choose an executable.", + "native.inline.installingWithHomebrew": "Installing with Homebrew...", + "native.inline.noBrewFormula": "No Brew formula", + "native.inline.noLaunchCommandIsConfigured": "No launch command is configured", + "native.inline.expectedArg1": "Expected: {arg1}", + "native.inline.installArg1": "Install {arg1}", + "native.inline.installedArg1": "Installed: {arg1}", + "native.inline.onlyLanguagesUsedByThisProjectAreShownChangesAreSavedAutomaticallyForThisPr": "Only languages used by this project are shown. Changes are saved automatically for this project. Turning a language off stops its server, releases its resources, and keeps it off when you reopen the project.", + "native.inline.enableArg1LanguageServer": "Enable {arg1} language server", + "native.inline.retryMavenConfiguration": "Retry Maven configuration", + "native.inline.noConfigurableLanguageServers": "No configurable language servers", + "native.inline.settingsAppearHereWhenSupportedSourceLanguagesAreDetected": "Settings appear here when supported source languages are detected.", + "native.inline.languageServerConfigurationIsDegradedCompatibilitySettingsAreInUse": "Language server configuration is degraded; compatibility settings are in use.", + "native.inline.restartLitheToFinishUninstallingTheOldArg1ThenInstallTheCommunityArg2FromLi": "Restart Lithe to finish uninstalling the old {arg1}, then install the community {arg2} from Lithe Store in the Marketplace.", + "native.inline.arg1MovedToLitheStoreAsACommunityPluginAndTheOldOfficialVersionGetsNoUpdate": "{arg1} moved to Lithe Store as a community plugin, and the old official version gets no updates. Uninstall it from Plugin Management, restart Lithe, then install the community version.", + "native.inline.thisArg1ProjectNeedsArg2InstallItFromLitheStoreInThePluginManagementMarketp": "This {arg1} project needs {arg2}. Install it from Lithe Store in the Plugin Management Marketplace.", + "native.inline.arg1IsInstalledRestartLitheBeforeControllingItsLanguageServerHere": "{arg1} is installed. Restart Lithe before controlling its language server here.", + "native.inline.arg1IsDisabledEnableItFromPluginManagementFirst": "{arg1} is disabled. Enable it from Plugin Management first.", + "native.inline.thisArg1ProjectNeedsArg2DownloadAndInstallItFromPluginManagement": "This {arg1} project needs {arg2}. Download and install it from Plugin Management.", + "native.inline.thisArg1ProjectNeedsArg2WhichWasRemovedRestoreItFromPluginManagementThenRes": "This {arg1} project needs {arg2}, which was removed. Restore it from Plugin Management, then restart Lithe.", + "native.inline.arg1WasQuarantinedAfterItsPreviousSessionReEnableItFromPluginManagement": "{arg1} was quarantined after its previous session. Re-enable it from Plugin Management.", + "native.inline.openPluginManagement": "Open Plugin Management", + "native.inline.languageServiceConnectedApplyingMavenConfiguration": "Language service connected; applying Maven configuration", + "native.inline.someModulesFailedArg1Arg2LanguageServiceRemainsAvailable": "Some modules failed ({arg1}/{arg2}); language service remains available", + "native.inline.starting": "Starting", + "native.inline.initializingProjectIndex": "Initializing project index", + "native.inline.running": "Running", + "native.inline.stopping": "Stopping", + "native.inline.off": "Off", + "native.inline.startsOnDemandWhenAMatchingFileIsOpened": "Starts on demand when a matching file is opened", + "native.inline.failedToStartCheckTheLspRuntimeJdk": "Failed to start; check the LSP runtime JDK", + "native.inline.failedToStartCheckTheLanguageServerConfiguration": "Failed to start; check the language server configuration", + "native.inline.javaProjectPreparationFailed": "Java project preparation failed", + "native.inline.viewProjectPreparationDetails": "View project preparation details", + "native.inline.theJavaWorkspaceMustFinishPreparationBeforeLaunchBackgroundIndexingDoesNotB": "The Java workspace must finish preparation before launch. Background indexing does not block running; compilation runs before launch.", + "native.inline.languageServiceSettingsAndRetry": "Language service settings and retry", + "native.inline.viewLogs": "View logs", + "native.inline.javaLaunchesAlwaysContinueAfterBuildFailuresInThisWorkspace": "Java launches always continue after build failures in this workspace.", + "native.inline.askAgain": "Ask Again", + "native.inline.startingJavaService": "Starting Java service", + "native.inline.importingJavaProjectAndDependencies": "Importing Java project and dependencies", + "native.inline.synchronizingJavaProjectConfiguration": "Synchronizing Java project configuration", + "native.inline.buildingJavaProject": "Building Java project", + "native.inline.javaProjectModelReady": "Java project model ready", + "native.inline.javaProjectPreparationStopped": "Java project preparation stopped", + "native.inline.javaBuildDidNotSucceed": "Java build did not succeed", + "native.inline.markerScope": "Marker scope", + "native.inline.workspaceMayIncludeUnrelatedModules": "Workspace (may include unrelated modules)", + "native.inline.requestedLaunchTargetAndDependencies": "Requested launch target and dependencies", + "native.inline.buildElapsed": "Build elapsed", + "native.inline.runAnywayContinuesThisLaunchWithTheResolvedOutputAndDoesNotBuildAgain": "Run Anyway continues this launch with the resolved output and does not build again.", + "native.inline.runAnyway": "Run Anyway", + "native.inline.alwaysContinueInThisWorkspace": "Always Continue in This Workspace", + "native.inline.rebuildJavaIndex": "Rebuild Java Index", + "native.inline.openLogs": "Open Logs", + "native.inline.cancel": "Cancel", + "native.inline.theJavaLanguageServiceBuilderFailedInThisSessionSoTheseErrorMarkersMayBeSta": "The Java language-service builder failed in this session, so these error markers may be stale. You can run the existing output or rebuild the index.", + "native.inline.theJavaLanguageServiceReportedCompilationErrorsWhichMayComeFromDependencies": "The Java language service reported compilation errors, which may come from dependencies or other workspace modules.", + "native.inline.chooseArg1LanguageServer": "Choose {arg1} language server", + "native.inline.choose": "Choose", + "native.inline.openAProjectThatUsesThisPluginFirst": "Open a project that uses this plugin first.", + "native.inline.arg1NeedsThisPluginSBuildSystemWhichThisProjectDoesNotUse": "“{arg1}” needs this plugin's build system, which this project does not use.", + "native.inline.arg1Finished": "“{arg1}” finished", + "native.inline.openASupportedSourceFile": "Open a supported source file", + "native.inline.startingTheArg1LspProcess": "Starting the {arg1} LSP process", + "native.inline.initializingArg1Lsp": "Initializing {arg1} LSP", + "native.inline.arg1LanguageServerReady": "{arg1} language server ready", + "native.inline.stoppingArg1Lsp": "Stopping {arg1} LSP", + "native.inline.arg1IsDeclaredByTheCatalogButNoLspSessionIsRunning": "{arg1} is declared by the catalog, but no LSP session is running", + "native.inline.arg1LspIsDisabledInThisWorkspace": "{arg1} LSP is disabled in this workspace", + "native.inline.arg1LspExitedUnexpectedly": "{arg1} LSP exited unexpectedly", + "native.inline.languageServerStarted": "Language server started", + "native.inline.noLspSessionIsRunning": "No LSP session is running", + "native.inline.languageServerDiagnosticsCleared": "Language server diagnostics cleared", + "native.inline.openAProjectBeforeRebuildingTheJavaIndex": "Open a project before rebuilding the Java index.", + "native.inline.javaIndexClearedReopenAJavaFileToRebuild": "Java index cleared. Reopen a Java file to rebuild.", + "native.inline.failedToClearTheJavaIndex": "Failed to clear the Java index", + "native.inline.executableRequired": "Choose a language-server executable.", + "native.inline.executableInvalid": "The selected language-server path is not executable: {path}", + "native.inline.executableValidationFailed": "The selected language server could not run: {path}\n{message}", + "native.inline.homebrewUnavailable": "Homebrew is not installed or is not available to Lithe.", + "native.inline.homebrewUnsupported": "No verified Homebrew formula is configured for {provider}.", + "aiCommit.exampleMessage": "Add an editor memory indicator", + "backend.unavailable": "This feature is not available yet.", + "agent.reportedLabels.approvalMode": "Approval mode", + "agent.reportedLabels.alwaysAskToEditExternalFilesAndUseTheInternet": "Always ask to edit external files and use the internet", + "agent.reportedLabels.onlyAskForActionsDetectedAsPotentiallyUnsafe": "Only ask for actions detected as potentially unsafe", + "agent.reportedLabels.unrestrictedAccessToTheInternetAndAnyFileOnYourComputer": "Unrestricted access to the internet and any file on your computer", + "agent.reportedLabels.collaborationMode": "Collaboration mode", + "agent.reportedLabels.planBeforeMakingChanges": "Plan before making changes", + "agent.reportedLabels.reasoningEffort": "Reasoning effort", + "agent.reportedLabels.fastMode": "Fast mode", + "agent.reportedLabels.model": "Model", + "aiCommit.languageEnglish": "English", + "aiCommit.languageChinese": "简体中文", + "native.aSecureConnectionToTheMcpRegistryCouldNotBeEstablishedCheckTheProxyOrRetryDirectly": "A secure connection to the MCP Registry could not be established. Check the proxy or retry directly.", + "native.aSkillHasAnUnknownScopeAndCannotBeClassified": "A skill has an unknown scope and cannot be classified.", + "native.aSkillWasOmittedBecauseItsNativeMetadataIsIncomplete": "A skill was omitted because its native metadata is incomplete.", + "native.aSkillWithThisDirectoryNameAlreadyExistsExistingSkillsAreNeverOverwritten": "A skill with this directory name already exists. Existing skills are never overwritten.", + "native.addAtLeastOneAbsoluteAllowedFolderPathAfterTheFilesystemPackageArgument": "Add at least one absolute allowed folder path after the Filesystem package argument.", + "native.addAtLeastOneAbsoluteFolderPathAfterThePackageArgumentOnePerLineFilesystemCanReadAndWriteOnlyInTheFoldersYouAllowRequiresNodeJs24OrLater": "Add at least one absolute folder path after the package argument, one per line. Filesystem can read and write only in the folders you allow. Requires Node.js 24 or later.", + "native.agentSkills": "Agent skills", + "native.agents2": "Agents", + "native.allSources": "All sources", + "native.anotherInstructionSaveIsRunningTryAgain": "Another instruction save is running. Try again.", + "native.arg1LinesOfItsOwn": "{arg1} lines of its own", + "native.arg1Tools": "{arg1} tools", + "native.askQuestionsAboutPublicGithubRepositoriesAndReadTheirGeneratedDocumentation": "Ask questions about public GitHub repositories and read their generated documentation.", + "native.cannotAccessTheGlobalInstructionFileCheckItsPermissionsAndTryAgain": "Cannot access the global instruction file. Check its permissions and try again.", + "native.cannotLocateTheAgentUserDirectory": "Cannot locate the Agent user directory.", + "native.checkCommandLineQuotesBeforeSaving": "Check command-line quotes before saving.", + "native.checkTheMcpCommandUrlAndHeaders": "Check the MCP command, URL and headers.", + "native.chooseWhetherToEnableTheSkill": "Choose whether to enable the skill.", + "native.claudeCodeHasMcpToolRestrictionsUpdateItsNativeRulesBeforeRenamingAServerSoDisabledToolsDoNotBecomeAvailableUnderANewName": "Claude Code has MCP tool restrictions. Update its native rules before renaming a server so disabled tools do not become available under a new name.", + "native.claudePluginSkillsMustBeManagedThroughClaudeSPluginSettings": "Claude plugin skills must be managed through Claude's plugin settings.", + "native.claudeSavesVisibilityByNameGlobalSkillsUseUserSettingsProjectSkillsUseProjectLocalSettingsProjectAndManagedSettingsMayOverrideGlobalSettingsSkillsWithTheSameNameShareThisSetting": "Claude saves visibility by name: global skills use user settings; project skills use project-local settings. Project and managed settings may override global settings. Skills with the same name share this setting.", + "native.claudeSkillSettingsCouldNotBeReadFixInvalidSettingsAndRefreshBeforeToggling": "Claude skill settings could not be read. Fix invalid settings and refresh before toggling.", + "native.codexIsUsingAgentsOverrideMdClearingItRestoresTheBaseAgentsMdInstructions": "Codex is using AGENTS.override.md. Clearing it restores the base AGENTS.md instructions.", + "native.codexReportedSkillDiscoveryErrorsCheckTheAffectedSkillMdFilesAndRefresh": "Codex reported skill discovery errors. Check the affected SKILL.md files and refresh.", + "native.codexReportsItsDiscoveredSkillsClaudeListsFilesInTheSelectedProjectAndUserDirectoriesInheritedAndPluginSkillsMayAlsoBeAvailableInItsSessions": "Codex reports its discovered skills. Claude lists files in the selected project and user directories; inherited and plugin skills may also be available in its sessions.", + "native.codexSavesThisSettingByPathForAllProjectsUsingThisSkill": "Codex saves this setting by path for all projects using this skill.", + "native.codexSkillDiscoveryFailedCheckTheCliAndRetry": "Codex skill discovery failed. Check the CLI and retry.", + "native.codexSkillSettingsCouldNotBeConfirmedRefreshToCheckTheCurrentState": "Codex skill settings could not be confirmed. Refresh to check the current state.", + "native.configure2": "Configure…", + "native.copyPath2": "Copy path", + "native.couldNotImportMcpJsonCheckTheMcpserversObjectAndTryAgain": "Could not import MCP JSON. Check the mcpServers object and try again.", + "native.couldNotReadTheCommandLine": "Could not read the command line.", + "native.couldNotStartTheMcpCommandCheckItsExecutableAndRuntime": "Could not start the MCP command. Check its executable and runtime.", + "native.createSkill": "Create skill", + "native.currentLibraryDocumentationAndCodeExamplesForCodingAgents": "Current library documentation and code examples for coding agents.", + "native.defaultInstructions": "Default instructions", + "native.detectAgain": "Detect again", + "native.detectTools": "Detect tools", + "native.detectionConnectsToThisServerAndListsToolMetadataWithoutCallingAnyToolLocalCommandsMayRunCodeOrDownloadPackagesResultsDescribeThisCheckNotTheAvailabilityOfAnExistingAgentSessionToolSwitchesApplyToTheSelectedAgentSUserConfigurationProjectAndManagedPoliciesCanStillRestrictAccessStartANewAgentSessionAfterChanges": "Detection connects to this server and lists tool metadata without calling any tool. Local commands may run code or download packages. Results describe this check, not the availability of an existing Agent session. Tool switches apply to the selected Agent's user configuration; project and managed policies can still restrict access. Start a new Agent session after changes.", + "native.detectionStartsTheConfiguredExecutableAndMayDownloadPackagesContinueOnlyIfYouTrustThisServerLitheWillListToolsThenStopTheProbeProcess": "Detection starts the configured executable and may download packages. Continue only if you trust this server. Lithe will list tools, then stop the probe process.", + "native.directConnection": "Direct connection", + "native.directRetryAppliesOnlyToPublicMcpRegistrySearchesOnThisPageSystemProxySettingsAndAgentServerConnectionsAreUnchangedTlsCertificateValidationRemainsEnabled": "Direct retry applies only to public MCP Registry searches on this page. System proxy settings and Agent server connections are unchanged. TLS certificate validation remains enabled.", + "native.directoryImportsPreserveScriptsTemplatesAndOtherSupportingFilesExistingSkillsAreNeverOverwritten": "Directory imports preserve scripts, templates and other supporting files. Existing skills are never overwritten.", + "native.disabledInAgent": "Disabled in Agent", + "native.discardDraftAndReload": "Discard draft and reload?", + "native.discoverMcpServers": "Discover MCP servers", + "native.editMcpServer": "Edit MCP server", + "native.empty": "Empty", + "native.enableSkill": "Enable skill", + "native.enabledInAgent": "Enabled in Agent", + "native.enterADescriptionAndInstructions": "Enter a description and instructions.", + "native.enterAnAuthorizationHeaderWithBearerAndYourGithubToken": "Enter an Authorization header with Bearer and your GitHub token.", + "native.enterTheExecutableAndArgumentsAsInATerminalQuotePathsWithSpacesLithePassesExactArgumentsWithoutRunningAShellPipesAndRedirectsAreNotEvaluated": "Enter the executable and arguments as in a terminal. Quote paths with spaces. Lithe passes exact arguments without running a shell; pipes and redirects are not evaluated.", + "native.featured": "Featured", + "native.featuredAndTheOfficialMcpRegistry": "Featured and the official MCP Registry", + "native.featuredServersShipWithLitheAndWorkOfflineSearchingAlsoChecksTheOfficialMcpRegistryListingIsNotASecurityReviewAddingOpensAConfigurationToReviewPackagesAreNotDownloadedOrRunFollowThePublisherSDocumentationForAuthenticationAndOtherRequirements": "Featured servers ship with Lithe and work offline. Searching also checks the official MCP Registry. Listing is not a security review. Adding opens a configuration to review; packages are not downloaded or run. Follow the publisher's documentation for authentication and other requirements.", + "native.fetchWebPagesAndConvertTheirContentToMarkdownRequiresUvAndPython310OrLater": "Fetch web pages and convert their content to Markdown. Requires uv and Python 3.10 or later.", + "native.fileInventoryOnlyClaudeDeterminesWhetherThisSkillIsAvailableInASession": "File inventory only. Claude determines whether this skill is available in a session.", + "native.githubRequiresAPersonalAccessTokenSetTheAuthorizationHeaderToBearerFollowedByYourTokenItIsStoredInTheAgentConfigurationFile": "GitHub requires a personal access token. Set the Authorization header to Bearer followed by your token. It is stored in the Agent configuration file.", + "native.globalInstructions": "Global instructions", + "native.globalInstructionsApplyToAllProjectsUsingThisAgentEachAgentKeepsItsOwnNativeFile": "Global instructions apply to all projects using this Agent. Each Agent keeps its own native file.", + "native.globalInstructionsAreAvailableForCodexAndClaudeCode": "Global instructions are available for Codex and Claude Code.", + "native.globalInstructionsChangedOutsideThisEditorYourDraftIsKeptReloadBeforeSaving": "Global instructions changed outside this editor. Your draft is kept; reload before saving.", + "native.globalInstructionsMustBeNoLargerThan1Mib": "Global instructions must be no larger than 1 MiB.", + "native.globalSkills": "Global skills", + "native.importARegularFileOrDirectoryInsteadOfASymbolicLink": "Import a regular file or directory instead of a symbolic link.", + "native.importJson": "Import JSON", + "native.importMcpConfiguration": "Import MCP configuration", + "native.importSkill": "Import skill", + "native.importSkillDirectory": "Import skill directory", + "native.importSkillMd": "Import SKILL.md", + "native.importSource": "Import source", + "native.inspectChromePagesConsoleMessagesNetworkRequestsAndPerformance": "Inspect Chrome pages, console messages, network requests and performance.", + "native.installCodexCliToDiscoverItsSkills": "Install Codex CLI to discover its skills.", + "native.instructionAgent": "Instruction Agent", + "native.instructionSynchronizationWasCancelled": "Instruction synchronization was cancelled.", + "native.instructions": "Instructions", + "native.instructionsSaved": "Instructions saved.", + "native.invalidMcpToolConfiguration": "Invalid MCP tool configuration", + "native.invalidSharedInstructionRequest": "Invalid shared instruction request.", + "native.keepAPersistentKnowledgeGraphBetweenConversationsRequiresNodeJs24OrLater": "Keep a persistent knowledge graph between conversations. Requires Node.js 24 or later.", + "native.legacySseProbingIsUnavailableUseThePublisherSStreamableHttpEndpoint": "Legacy SSE probing is unavailable. Use the publisher's Streamable HTTP endpoint.", + "native.loadMore2": "Load more", + "native.manageSkills": "Manage Skills…", + "native.managed": "Managed", + "native.mcpConnectionFailedCheckTheServerNetworkAndAuthentication": "MCP connection failed. Check the server, network and authentication.", + "native.mcpDetectionTimedOutAfter30Seconds": "MCP detection timed out after 30 seconds.", + "native.mcpDetectionWasCancelled": "MCP detection was cancelled.", + "native.mcpJson": "MCP JSON", + "native.mcpToolMetadataExceededTheInspectionLimit": "MCP tool metadata exceeded the inspection limit.", + "native.nativeInstructionFile": "Native instruction file", + "native.newMcpServer": "New MCP server", + "native.newSkill": "New skill", + "native.noMatchingMcpServers": "No matching MCP servers", + "native.noMatchingSkills": "No matching skills.", + "native.noSkillsInThisScope": "No skills in this scope.", + "native.notDetected": "Not detected", + "native.officialMcpRegistry": "Official MCP Registry", + "native.openAProjectToManageItsSkills": "Open a project to manage its skills.", + "native.openPagesClickTypeAndInspectABrowserThroughItsAccessibilityTree": "Open pages, click, type and inspect a browser through its accessibility tree.", + "native.openSkillMd": "Open SKILL.md", + "native.pasteAJsonObjectContainingMcpservers": "Paste a JSON object containing mcpServers.", + "native.pasteStandardJsonWithAnMcpserversObjectChooseAServerToReviewBeforeSavingOnlyTheEditorSSupportedCommandArgumentsEnvironmentUrlAndHeaderFieldsAreImportedAuthenticationAndClientSpecificOptionsMustBeConfiguredSeparatelyImportingDoesNotTakeOwnershipOfAnExistingEntry": "Paste standard JSON with an mcpServers object. Choose a server to review before saving. Only the editor's supported command, arguments, environment, URL and header fields are imported; authentication and client-specific options must be configured separately. Importing does not take ownership of an existing entry.", + "native.plugin": "Plugin", + "native.previewImport": "Preview import", + "native.projectSkills": "Project skills", + "native.publicDirectoryMetadataIsSuppliedByTheOfficialMcpRegistryListingIsNotAnEndorsementOrASecurityReviewAddingOpensAnEditableConfigurationLitheDoesNotDownloadOrRunServerPackagesServersWithComplexInputsMustBeConfiguredUsingThePublisherSDocumentation": "Public directory metadata is supplied by the official MCP Registry. Listing is not an endorsement or a security review. Adding opens an editable configuration; Lithe does not download or run server packages. Servers with complex inputs must be configured using the publisher's documentation.", + "native.readWriteAndSearchFilesOnlyInTheDirectoriesYouAllowRequiresNodeJs24OrLater": "Read, write and search files only in the directories you allow. Requires Node.js 24 or later.", + "native.refreshSkills": "Refresh skills", + "native.registryWebsite": "Registry website", + "native.reloadFromDisk": "Reload from disk", + "native.reloadThisAgentBeforeSynchronizingInstructions": "Reload this Agent before synchronizing instructions.", + "native.reloadToolSettings": "Reload tool settings", + "native.reloadingReplacesThisDraftWithTheCurrentFileContents": "Reloading replaces this draft with the current file contents.", + "native.removeFromLibrary": "Remove from library", + "native.retryDirectly": "Retry directly", + "native.reviewConfiguration": "Review configuration", + "native.runAndDetect": "Run and detect", + "native.runThisMcpCommand": "Run this MCP command?", + "native.saveInstructions": "Save instructions", + "native.saveSharedInstructions": "Save shared instructions", + "native.searchMcpServers": "Search MCP servers", + "native.searchMcpServersGithubBrowsersDocumentation": "Search MCP servers — GitHub, browsers, documentation…", + "native.searchOfficialMicrosoftAndAzureDocumentationAndCodeSamples": "Search official Microsoft and Azure documentation and code samples.", + "native.searchSkills": "Search skills", + "native.searchTheOfficialMcpRegistry": "Search the official MCP Registry", + "native.searchTheWebAndCodeAndRetrievePageContentForCodingAgents": "Search the web and code, and retrieve page content for coding agents.", + "native.selectSkillMdOrItsContainingDirectory": "Select SKILL.md or its containing directory.", + "native.serverTools": "Server tools", + "native.sharedInstructions": "Shared instructions", + "native.sharedInstructionsCannotContainLitheSynchronizationMarkers": "Shared instructions cannot contain Lithe synchronization markers.", + "native.sharedInstructionsChangedOutsideThisEditorReloadBeforeSaving": "Shared instructions changed outside this editor. Reload before saving.", + "native.sharedInstructionsSaved": "Shared instructions saved.", + "native.showingAvailableMatches": "Showing available matches.", + "native.skillActions": "Skill actions", + "native.skillFiles": "Skill files", + "native.skillImportsCannotContainSymbolicLinksOrSpecialFiles": "Skill imports cannot contain symbolic links or special files.", + "native.skillInstructions": "Skill instructions", + "native.skillMdExceedsThe128KibLimit": "SKILL.md exceeds the 128 KiB limit.", + "native.skillMdFrontmatterIsNotClosed": "SKILL.md frontmatter is not closed.", + "native.skillMdHasInvalidYamlFrontmatter": "SKILL.md has invalid YAML frontmatter.", + "native.skillMdMustUseUtf8Text": "SKILL.md must use UTF-8 text.", + "native.skillMdNeedsANameAndDescription": "SKILL.md needs a name and description.", + "native.skillMdNeedsInstructionsAfterItsFrontmatter": "SKILL.md needs instructions after its frontmatter.", + "native.skillMdNeedsYamlFrontmatter": "SKILL.md needs YAML frontmatter.", + "native.skillNameLowercaseWithHyphens": "Skill name (lowercase-with-hyphens)", + "native.skillProject": "Skill project", + "native.skillSavedRefreshOrReconnectTheAgentIfItDoesNotAppearInTheConversation": "Skill saved. Refresh or reconnect the Agent if it does not appear in the conversation.", + "native.skillSettingSaved": "Skill setting saved.", + "native.skillSettingsAreBusyRetryAfterTheOtherChangeFinishes": "Skill settings are busy. Retry after the other change finishes.", + "native.skillSettingsChangedElsewhereRefreshAndRetry": "Skill settings changed elsewhere. Refresh and retry.", + "native.skillSettingsCouldNotBeUpdatedCheckTheSettingsFileAndRefresh": "Skill settings could not be updated. Check the settings file and refresh.", + "native.skillSource": "Skill source", + "native.skillTargetAgent": "Skill target Agent", + "native.skills": "Skills", + "native.startANewConversationAfterSavingToLoadTheUpdatedInstructionsProjectInstructionsStillApply": "Start a new conversation after saving to load the updated instructions. Project instructions still apply.", + "native.stateUnknown": "State unknown", + "native.switchesSaveNativeAgentSettingsReconnectExistingConversationsToRefreshAvailableSkills": "Switches save native Agent settings. Reconnect existing conversations to refresh available skills.", + "native.syncAgain": "Sync again", + "native.syncNeeded": "Sync needed", + "native.thatSkillNameIsReservedByClaudeChooseADifferentName": "That skill name is reserved by Claude. Choose a different name.", + "native.theAgentDidNotApplyTheSkillSettingRefreshAndCheckProjectOrManagedConfiguration": "The Agent did not apply the skill setting. Refresh and check project or managed configuration.", + "native.theAgentUserDirectoryMustBeAnAbsolutePathCheckCodexHomeOrClaudeConfigDir": "The Agent user directory must be an absolute path. Check CODEX_HOME or CLAUDE_CONFIG_DIR.", + "native.theFeaturedMcpCatalogCouldNotBeLoaded": "The featured MCP catalog could not be loaded.", + "native.theFileWillBeCreatedWhenYouSave": "The file will be created when you save.", + "native.theGlobalInstructionFileMustUseUtf8Encoding": "The global instruction file must use UTF-8 encoding.", + "native.theInstructionDirectoryChangedReloadBeforeSaving": "The instruction directory changed. Reload before saving.", + "native.theInstructionPathMustBeARegularFileNotASymbolicLinkOrDirectory": "The instruction path must be a regular file, not a symbolic link or directory.", + "native.theLitheSharedInstructionMarkersWereChangedRepairTheNativeFileBeforeSyncing": "The Lithe shared instruction markers were changed. Repair the native file before syncing.", + "native.theMcpRegistryCouldNotBeLoadedTryAgainOrAddAServerManually": "The MCP Registry could not be loaded. Try again or add a server manually.", + "native.theMcpRegistryIsUnreachableCheckTheNetworkOrProxy": "The MCP Registry is unreachable. Check the network or proxy.", + "native.theMcpRegistryRequestTimedOutTryAgain": "The MCP Registry request timed out. Try again.", + "native.theSharedInstructionDirectoryMustBeAbsolute": "The shared instruction directory must be absolute.", + "native.theSkillCouldNotBePublishedCheckPermissionsAndExistingSkillNames": "The skill could not be published. Check permissions and existing skill names.", + "native.theSkillDestinationContainsASymbolicLinkOrNonDirectoryChooseARegularNativeSkillDirectory": "The skill destination contains a symbolic link or non-directory. Choose a regular native skill directory.", + "native.theSkillDocumentIsUnavailableRefreshAndRetry": "The skill document is unavailable. Refresh and retry.", + "native.theSkillFileCouldNotBeOpened": "The skill file could not be opened.", + "native.theSkillFileCouldNotBeRead": "The skill file could not be read.", + "native.theSkillIsNoLongerAvailableInThisScopeRefreshAndRetry": "The skill is no longer available in this scope. Refresh and retry.", + "native.theSwitchControlsTheLitheSharedBlockNotTheAgentOrItsOwnInstructionsSaveSharedChangesBeforeSwitchingOpenARowToEditTheCompleteNativeFile": "The switch controls the Lithe shared block, not the Agent or its own instructions. Save shared changes before switching. Open a row to edit the complete native file.", + "native.thisEditorShowsTheCompleteNativeFileIncludingAnyLitheSharedBlockEditingSharedTextHereCanLeaveItOutOfSyncUseTheSharedEditorToUpdateAllEnabledAgents": "This editor shows the complete native file, including any Lithe shared block. Editing shared text here can leave it out of sync; use the shared editor to update all enabled Agents.", + "native.thisServerReportedNoTools": "This server reported no tools.", + "native.thisToolIsRestrictedOutsideLitheEditTheNativeAgentPolicyToEnableIt": "This tool is restricted outside Lithe. Edit the native Agent policy to enable it.", + "native.unsavedChanges": "Unsaved changes", + "native.useSharedInstructions": "Use shared instructions", + "native.useSystemProxy": "Use system proxy", + "native.useUpTo64LowercaseLettersDigitsAndSingleHyphensForTheSkillName": "Use up to 64 lowercase letters, digits and single hyphens for the skill name.", + "native.viewSkill": "View skill", + "native.whenShouldThisSkillBeUsed": "When should this skill be used?", + "native.workWithGithubRepositoriesIssuesPullRequestsAndActionsAPersonalAccessTokenIsRequired": "Work with GitHub repositories, issues, pull requests and Actions. A personal access token is required.", + "native.writeSharedInstructionsOnceThenEnableThemForEachAgentLitheOnlyChangesItsMarkedBlockTheRestOfEachNativeFileStaysYoursStartANewConversationToLoadChangesProjectInstructionsStillApply": "Write shared instructions once, then enable them for each Agent. Lithe only changes its marked block; the rest of each native file stays yours. Start a new conversation to load changes. Project instructions still apply.", + "native.arg1DoesNotProvideArg2BecauseArg3AlreadyDoes": "{arg1} does not provide {arg2} because {arg3} already does.", + "native.arg1FilesHaveConflicts": "{arg1} files have conflicts.", + "native.arg1FilesSelected": "{arg1} files selected", + "native.arg1HoldsTheMetadataOfSeveralVersionControlSystemsChooseTheOneThatManagesIt": "{arg1} holds the metadata of several version control systems. Choose the one that manages it.", + "native.arg1ProviderInsideThePlugin": "{arg1} provider (inside the plugin)", + "native.arg1ProviderInstalledOnThisMac": "{arg1} provider (installed on this Mac)", + "native.arg1ProviderRunsWithArg2": "{arg1} provider (runs with {arg2})", + "native.authenticationFailedLitheCannotAskForCredentialsYetSignInOnceWithTheCommandLineClientSoItStoresThemThenTryAgain": "Authentication failed. Lithe cannot ask for credentials yet: sign in once with the command-line client so it stores them, then try again.", + "native.baseRevision": "Base revision", + "native.binaryFilesCannotBeCompared": "Binary files cannot be compared.", + "native.cleanUpWorkingCopy": "Clean Up Working Copy", + "native.cleaningUp": "Cleaning up…", + "native.clientArg1": "Client: {arg1}", + "native.committed2": "Committed.", + "native.committedRevisionArg1": "Committed revision {arg1}.", + "native.committing": "Committing…", + "native.detectAgain2": "Detect Again", + "native.detectingWorkingCopies": "Detecting working copies…", + "native.historyOfArg1": "History of {arg1}", + "native.ignored": "Ignored", + "native.litheCouldNotEvaluateTheVersionControlDataArg1": "Lithe could not evaluate the version control data: {arg1}", + "native.localEditsToTheseFilesAreLost": "Local edits to these files are lost.", + "native.lockedByAnotherUser": "Locked by another user", + "native.lockedInThisWorkingCopy": "Locked in this working copy", + "native.markAsResolved": "Mark as Resolved", + "native.markingAsResolved": "Marking as resolved…", + "native.mayConnectToTheRepositoryServer": "May connect to the repository server", + "native.missing": "Missing", + "native.moreActions2": "More Actions", + "native.morePathsChangedThanAreShown": "More paths changed than are shown.", + "native.movedFromArg1": "Moved from {arg1}", + "native.moving": "Moving…", + "native.noArg1WorkingCopyWasDetectedInThisWorkspace": "No {arg1} working copy was detected in this workspace.", + "native.noRevisions": "No revisions", + "native.openFile2": "Open File", + "native.propertiesModified": "Properties modified", + "native.readingChanges": "Reading changes…", + "native.replaced": "Replaced", + "native.restartLitheToUseArg1": "Restart Lithe to use {arg1}.", + "native.rollBackTheSelectedChanges": "Roll back the selected changes?", + "native.rollingBack": "Rolling back…", + "native.showAll": "Show All", + "native.showArg1Diff": "Show {arg1} Diff", + "native.showHistory": "Show History", + "native.switched": "Switched", + "native.theActionStoppedOnConflictedFilesResolveThemThenTryAgain": "The action stopped on conflicted files. Resolve them, then try again.", + "native.theFileIsTooLargeToCompare": "The file is too large to compare.", + "native.theVersionControlClientCouldNotBeRunCheckThatItIsInstalled": "The version control client could not be run. Check that it is installed.", + "native.theVersionControlProviderFailedToStart": "The version control provider failed to start.", + "native.theVersionControlProviderStoppedBeforeItWasReady": "The version control provider stopped before it was ready.", + "native.theWorkingCopyIsLockedByAnInterruptedActionRunCleanupThenTryAgain": "The working copy is locked by an interrupted action. Run Cleanup, then try again.", + "native.theWorkingCopyIsOutOfDateUpdateItThenCommitAgain": "The working copy is out of date. Update it, then commit again.", + "native.theWorkingCopyWasCleanedUp": "The working copy was cleaned up.", + "native.theWorkspace": "The workspace", + "native.theWorkspaceIsTooLargeToSearchCompletelySomeWorkingCopiesMayBeMissing": "The workspace is too large to search completely; some working copies may be missing.", + "native.thereAreTooManyChangesToListSomeAreNotShown": "There are too many changes to list; some are not shown.", + "native.thisVersionControlProviderDoesNotSupportThisAction": "This version control provider does not support this action.", + "native.thisWorkingCopyFormatIsNotSupportedUpgradeItWithTheCommandLineClient": "This working copy format is not supported. Upgrade it with the command-line client.", + "native.thisWorkingCopyIsNoLongerAvailableRefreshAndTryAgain": "This working copy is no longer available. Refresh and try again.", + "native.treeConflict": "Tree conflict", + "native.typeChanged": "Type changed", + "native.unversioned": "Unversioned", + "native.updated2": "Updated.", + "native.updatedToRevisionArg1": "Updated to revision {arg1}.", + "native.updating": "Updating…", + "native.versionControlProvider": "Version control provider", + "native.waitForTheRunningVersionControlActionToFinish": "Wait for the running version control action to finish.", + "native.workingCopy": "Working copy", + "hooks.add": "Add Hook", + "hooks.agents": "Agents", + "hooks.allAgents": "All", + "hooks.codexApproval": "Codex requires Hooks to be enabled and commands to be trusted in its native /hooks screen. Lithe does not grant trust. Editing a command requires approval again.", + "hooks.command": "Command", + "hooks.commandHint": "The Agent runs the command in the session folder and passes event JSON on standard input.", + "hooks.delete": "Delete", + "hooks.deleteHint": "This removes only the native entries written by Lithe. Other Hooks are kept.", + "hooks.deleteTitle": "Delete Hook?", + "hooks.description": "Description (optional, 200 characters)", + "hooks.descriptionHint": "Optional, up to 200 characters. Stored only in the Hook library.", + "hooks.descriptionLabel": "Description", + "hooks.descriptionPlaceholder": "For example, notify me when a turn ends", + "hooks.disabledAll": "Claude Code has disabled all Hooks in its settings.", + "hooks.edit": "Edit", + "hooks.editTitle": "Edit Hook", + "hooks.editor": "Hook", + "hooks.empty": "No Hooks yet.", + "hooks.enabled": "Enable Hook", + "hooks.event.prompt": "When submitting a prompt", + "hooks.intro": "Hooks run commands at Agent lifecycle events, using the session folder and JSON on standard input. Changes apply to new sessions.", + "hooks.matcher": "Tool filter", + "hooks.matcher.all": "All tools", + "hooks.matcher.edit": "File edits", + "hooks.matcher.shell": "Shell commands", + "hooks.name": "Name", + "hooks.nameHint": "Use 1–80 letters, digits, hyphens or underscores. Names must be unique.", + "hooks.namePlaceholder": "For example, notify", + "hooks.needsSync": "Sync needed", + "hooks.newTitle": "New Hook", + "hooks.pendingRemoval": "Removal pending", + "hooks.reload": "Reload from disk", + "hooks.retry": "Retry Hook synchronization", + "hooks.save": "Save Hook", + "hooks.search": "Search Hooks", + "hooks.timeout": "Timeout (seconds)", + "hooks.timeoutHint": "Leave empty to use the Agent default, or enter an integer from 1 to 86400.", + "hooks.timeoutPlaceholder": "Agent default", + "hooks.when": "When to run", + "keybindings.commands.editor.cursorRedo.description": "Restore the next cursor and selection without changing text", + "keybindings.commands.editor.cursorRedo.title": "Cursor Redo", + "keybindings.commands.editor.cursorUndo.description": "Restore the previous cursor and selection without changing text", + "keybindings.commands.editor.cursorUndo.title": "Cursor Undo", + "native.aBasicProjectWithAFreeStructure": "A basic project with a free structure.", + "native.aHookWithThisNameAlreadyExists": "A Hook with this name already exists.", + "native.aHostNameAnIPAddressOrAHostAliasFromSshConfigSettingsFromThatFileSuchAsProxyJumpAndIdentit": "A host name, an IP address, or a Host alias from ~/.ssh/config. Settings from that file, such as ProxyJump and IdentityFile, still apply.", + "native.aJavaProjectWithNoBuildToolMavenOrGradle": "A Java project with no build tool, Maven, or Gradle.", + "native.aManagedHookWasEditedOutsideLitheRestoreOrRemoveThatNativeEntryBeforeSynchronizing": "A managed Hook was edited outside Lithe. Restore or remove that native entry before synchronizing.", + "native.aProjectGeneratedByMavenFromAnArchetype": "A project generated by Maven from an archetype.", + "native.aRemovedHookCannotBeSaved": "A removed Hook cannot be saved.", + "native.aSpringBootProjectFromSpringInitializr": "A Spring Boot project from Spring Initializr.", + "native.acceptEdits": "Accept edits", + "native.addHook": "Add Hook", + "native.addSampleCode": "Add sample code", + "native.address": "Address", + "native.advancedSettings": "Advanced Settings", + "native.afterAToolRuns": "After a tool runs", + "native.agentDefault": "Agent default", + "native.agentHooks": "Agent hooks", + "native.allTools": "All tools", + "native.alreadyExistsChooseAnotherNameOrLocation": "{arg1} already exists. Choose another name or location.", + "native.anIdenticalNativeHookAlreadyExistsLitheKeptItUnchanged": "An identical native Hook already exists. Lithe kept it unchanged.", + "native.anotherHookUpdateIsRunningTryAgain": "Another Hook update is running. Try again.", + "native.answeredWithStatusLld": "{arg1} answered with status {arg2}. {arg3}", + "native.archetype": "Archetype", + "native.archetypeArtifactId": "Archetype ArtifactId", + "native.archetypeGroupId": "Archetype GroupId", + "native.archetypeVersion": "Archetype version", + "native.archiveConversation": "Archive conversation", + "native.artifact": "Artifact", + "native.asOfUpdatedOnlyWhileReplying": "As of {arg1}; updated only while replying.", + "native.authentication": "Authentication", + "native.auto": "Auto", + "native.automaticTriesYourSSHAgentAndKeysFirstThenAsksForAPasswordLitheAsksInADialogWheneverTheSer": "Automatic tries your SSH agent and keys first, then asks for a password. Lithe asks in a dialog whenever the server needs an answer, such as a password, a passphrase, or a verification code.", + "native.backgroundTaskEnded": "Background task ended", + "native.backgroundTaskFinished": "Background task finished", + "native.beforeAToolRuns": "Before a tool runs", + "native.browse": "Browse…", + "native.buildSystemX797f5735e5": "Build system", + "native.bypassPermissions": "Bypass permissions", + "native.cannotAccessHookConfigurationCheckTheFilePermissionsAndAgentProfile": "Cannot access Hook configuration. Check the file permissions and Agent profile.", + "native.cannotAccessTheHookLibraryCheckItsPermissions": "Cannot access the Hook library. Check its permissions.", + "native.cannotEncodeHookConfiguration": "Cannot encode Hook configuration.", + "native.cannotEncodeTheHookLibrary": "Cannot encode the Hook library.", + "native.checkTheHookNameCommandEventAgentsAndTimeout186400Seconds": "Check the Hook name, command, event, Agents and timeout (1–86400 seconds).", + "native.checked": "Checked {arg1}", + "native.chooseAPrivateKeyFile": "Choose a private key file.", + "native.chooseOneOfTheOfferedValues": "Choose one of the offered values.", + "native.chooseRemoteFolder": "Choose Remote Folder", + "native.chooseThisFolder": "Choose This Folder", + "native.claudeCodeHasDisabledAllHooksInItsSettings": "Claude Code has disabled all Hooks in its settings.", + "native.claudeCodeIsNotSignedInRunClaudeInATerminalSignInWithLoginThenRetry": "Claude Code is not signed in. Run `claude` in a terminal, sign in with /login, then retry.", + "native.claudeSubscription": "Claude subscription", + "native.claudeSubscriptionQuota": "Claude subscription quota", + "native.agentAccounts": "Subscription accounts", + "native.agentAccountsInfo": "Each account is a separate configuration folder of the official CLI. Sign-in runs the CLI's own login command in the background and opens your browser, so Lithe never reads or copies credentials. Switching restarts this project's connection for this Agent and is refused while a reply or permission request is pending. Lithe never switches accounts by itself when a limit is reached.", + "native.defaultAgentAccount": "Default account", + "native.agentAccountPreferred": "Preferred", + "native.agentAccountSetPreferred": "Set as preferred", + "native.agentAccountSignInAgain": "Sign in again", + "native.agentAccountRemove": "Remove account", + "native.agentAccountRemoveMessage": "Remove this account from Lithe? Its sign-in folder is left on disk untouched.", + "native.agentAccountAdd": "Add another account", + "native.agentAccountWaiting": "Waiting for browser sign-in…", + "native.agentAccountSnapshotNote": "As of {arg1}; not refreshed by Lithe.", + "native.agentAccountUsageInfo": "Usage is whatever the Agent last reported. Claude Code reports it while a reply runs; Codex is also refreshed about once a minute while the Agent panel is open. Lithe never queries the account itself, so a value can be out of date, and an account you are not using keeps its last values until you switch back. A window that has already reset is shown without a reset time or pace.", + "native.agentAccountResets": "Resets {arg1}", + "native.agentAccountPace": "Pace {arg1}", + "native.agentAccountDefaultName": "Account {arg1}", + "native.agentAccountSignInUnsupported": "This Agent has no account sign-in.", + "native.agentAccountSignInFailed": "Sign-in did not finish. Check that the Agent's CLI is installed, then try again.", + "native.agentAccountSignInTimedOut": "Sign-in timed out. Try again.", + "native.codexHookApproval": "Codex Hook approval", + "native.codexRequiresHooksToBeEnabledAndCommandsToBeTrustedInItsNativeHooksScreenLitheSavesConfigu": "Codex requires Hooks to be enabled and commands to be trusted in its native /hooks screen. Lithe saves configuration without granting trust. Editing a command requires approval again. A configured Hook is not proof it ran.", + "native.connectedButLitheCannotRunOnThisPlatformYetLinuxOnX8664OrARM64IsSupported": "Connected, but Lithe cannot run on this platform yet. Linux on x86_64 or ARM64 is supported.", + "native.connectedThisHostCanBeUsedForRemoteDevelopment": "Connected. This host can be used for remote development.", + "native.connecting": "Connecting…", + "native.connection": "Connection", + "native.connectionTimeout": "Connection timeout", + "native.conversationActions": "Conversation actions", + "native.conversationContinuedFromASummary": "Conversation continued from a summary", + "native.couldNotOpenTheRemoteFolder": "Could not open the remote folder: {arg1}", + "native.couldNotSaveTheConnections": "Could not save the connections: {arg1}", + "native.createGitRepository": "Create Git repository", + "native.createsProjects": "Creates {arg1} projects", + "native.creatingProject": "Creating project…", + "native.creatingThisProjectDownloadsFilesFromTheInternet": "Creating this project downloads files from the internet.", + "native.customArchetype": "Custom archetype…", + "native.dMinutesAgo": "{arg1} minutes ago", + "native.deleteChatX93291d9c41": "Delete chat", + "native.deleteHook": "Delete Hook?", + "native.didNotFinishWithinLldSeconds": "{arg1} did not finish within {arg2} seconds.", + "native.directories": "Directories", + "native.disk": "Disk", + "native.earlier": "Earlier", + "native.editHook": "Edit Hook", + "native.emptyProject": "Empty Project", + "native.enableHook": "Enable Hook", + "native.enlargeImage": "Enlarge image", + "native.enterAHost": "Enter a host.", + "native.enterAValue": "Enter a value.", + "native.enterAnAbsolutePathThatStartsWith": "Enter an absolute path that starts with /.", + "native.enterTheHostWithoutAUserNameOrSpacesPutTheUserInUserName": "Enter the host without a user name or spaces; put the user in User name.", + "native.exitedWithStatusD": "{arg1} exited with status {arg2}.\n{arg3}", + "native.fileEdits": "File edits", + "native.filePatterns": "File patterns", + "native.findInMarketplace": "Find in Marketplace", + "native.finishedWithoutCreating": "{arg1} finished without creating {arg2}.", + "native.fitToWindow": "Fit to Window", + "native.folder": "Folder", + "native.forExampleNotify": "For example, notify", + "native.forExampleNotifyMeWhenATurnEnds": "For example, notify me when a turn ends", + "native.forkConversation": "Fork conversation", + "native.freeOf": "{arg1} free of {arg2}", + "native.generators": "Generators", + "native.getMoreLanguagesAndFrameworksFromPlugins": "Get more languages and frameworks from plugins…", + "native.go": "Go", + "native.gradleDSL": "Gradle DSL", + "native.group": "Group", + "native.homeDirectory": "Home directory", + "native.hook": "Hook", + "native.hookCommand": "Hook command", + "native.hookConfigurationChangedOutsideThisEditorReloadBeforeSaving": "Hook configuration changed outside this editor. Reload before saving.", + "native.hookConfigurationMustBeARegularFileNotASymbolicLinkOrDirectory": "Hook configuration must be a regular file, not a symbolic link or directory.", + "native.hookConfigurationMustBeNoLargerThan1MiB": "Hook configuration must be no larger than 1 MiB.", + "native.hookConfigurationMustUseUTF8Encoding": "Hook configuration must use UTF-8 encoding.", + "native.hookDescription": "Hook description", + "native.hookName": "Hook name", + "native.hookSynchronizationWasCancelled": "Hook synchronization was cancelled.", + "native.hooks": "Hooks", + "native.hooksRunShellCommandsAtAgentLifecycleEventsTheAgentSuppliesEventJSONOnStandardInputAndRuns": "Hooks run shell commands at Agent lifecycle events. The Agent supplies event JSON on standard input and runs the command in the session folder. Changes apply to new sessions; existing sessions keep their own loaded configuration.", + "native.hostName": "Host name", + "native.httpsGithubComOwnerRepository": "https://github.com/owner/repository", + "native.invalidHookFileGuards": "Invalid Hook file guards.", + "native.isNeededForThisProjectButIsDisabled": "{arg1} is needed for this project but {arg2} is disabled.", + "native.itemLldOfLld": "Item {arg1} of {arg2}", + "native.keepAliveInterval": "Keep-alive interval", + "native.kernel": "Kernel", + "native.latest": "Latest", + "native.leaveEmptyToUseTheAgentDefaultOrEnterAnIntegerFrom1To86400": "Leave empty to use the Agent default, or enter an integer from 1 to 86400.", + "native.leaveEmptyToUseTheLatestVersionMavenFindsInItsCatalog": "Leave empty to use the latest version Maven finds in its catalog.", + "native.leaveEmptyToUseThePortFromSshConfigOr22": "Leave empty to use the port from ~/.ssh/config, or 22.", + "native.leaveEmptyToUseTheUserFromSshConfigOrYourLocalUserName": "Leave empty to use the user from ~/.ssh/config, or your local user name.", + "native.leaveEmptyToUseTheVersionTheServerRecommends": "Leave empty to use the version the server recommends.", + "native.litheCouldNotFindInstallItOrAddItToPATHThenTryAgain": "Lithe could not find {arg1}. Install it or add it to PATH, then try again.", + "native.litheWillCloneTheRepositoryInto": "Lithe will clone the repository into {arg1}.", + "native.lldCPUCores": "{arg1} CPU cores", + "native.location": "Location", + "native.lookUpAgain": "Look Up Again", + "native.lookUpLocation": "Look Up Location", + "native.lookingForInstalledJDKs": "Looking for installed JDKs…", + "native.lookingUpTheLocationSendsThisAddressToIpinfoIoLitheDoesThisOnlyWhenYouClickLookUp": "Looking up the location sends this address to ipinfo.io. Lithe does this only when you click Look Up.", + "native.manual": "Manual", + "native.mavenRunsWithThisJDKAndTheNewProjectUsesIt": "Maven runs with this JDK, and the new project uses it.", + "native.mayConnectToTheInternet": "May connect to the internet", + "native.memory": "{arg1} memory", + "native.messaging": "Messaging", + "native.method": "Method", + "native.mode": "Mode", + "native.move": "Move", + "native.newHook": "New Hook", + "native.newProjectX6f8d176462": "New Project", + "native.newSSHConnection": "New SSH Connection", + "native.nextImage": "Next image", + "native.noHooksYet": "No Hooks yet.", + "native.noHostYet": "No host yet", + "native.noInstalledPluginSupportsYet": "No installed plugin supports {arg1} yet.", + "native.noItems": "No items", + "native.noJDKWasFoundInstallAJDKThenReopenThisWindow": "No JDK was found. Install a JDK, then reopen this window.", + "native.noMatchingHooks": "No matching Hooks.", + "native.noSSHConnectionsYet": "No SSH connections yet.", + "native.notTested": "Not tested", + "native.oneEntryPerLineDirectoryNamesHideMatchingFoldersFileEntriesSupportAnd": "One entry per line. Directory names hide matching folders; file entries support * and ?.", + "native.operatingSystem": "Operating system", + "native.optionalDescriptionUpTo200CharactersOnlyTheHookLibraryStoresIt": "Optional description, up to 200 characters. Only the Hook library stores it.", + "native.packageName": "Package name", + "native.packaging": "Packaging", + "native.passphrase": "Passphrase", + "native.password": "Password", + "native.pin": "Pin", + "native.pinConversation": "Pin conversation", + "native.port": "Port", + "native.previous30Days": "Previous 30 days", + "native.previous7Days": "Previous 7 days", + "native.previousImage": "Previous image", + "native.privateKey": "Private Key", + "native.privateKeyX477bf990a2": "Private key", + "native.privateNetwork": "Private network", + "native.projectTemplate": "Project Template", + "native.projectWillBeCreatedIn": "Project will be created in: {arg1}", + "native.providedByADisabledPlugin": "Provided by a disabled plugin", + "native.publishCoreSelectsDefaultRemote": "Publish {arg1} (Core selects default remote)", + "native.pushXc68c0bf1cd": "Push '{arg1}'?", + "native.quotaX6c105cae1a": "Quota", + "native.rememberInKeychain": "Remember in Keychain", + "native.remoteDevelopment": "Remote Development", + "native.remoteFolder": "Remote folder", + "native.remoteProject": "Remote Project", + "native.removalPending": "Removal pending", + "native.resources": "Resources", + "native.retryHookSynchronization": "Retry Hook synchronization", + "native.sSH": "SSH", + "native.sSHAgent": "SSH Agent", + "native.sSHAuthentication": "SSH Authentication", + "native.saveHook": "Save Hook", + "native.savedInKeychain": "Saved in Keychain", + "native.savesConnectsAndOpensTheRemoteFolderInThisWindow": "Saves, connects, and opens the remote folder in this window.", + "native.searchHooks": "Search Hooks", + "native.searchJDKs": "Search JDKs", + "native.secondsBetweenKeepAliveMessagesWhichDetectADroppedNetworkLeaveEmptyFor15Seconds": "Seconds between keep-alive messages, which detect a dropped network. Leave empty for 15 seconds.", + "native.secondsToWaitForTheServerToAnswerLeaveEmptyFor15Seconds": "Seconds to wait for the server to answer. Leave empty for 15 seconds.", + "native.security": "Security", + "native.serverDefault": "Server default", + "native.serverURL": "Server URL", + "native.shellCommands": "Shell commands", + "native.showLldEarlierItems": "Show {arg1} earlier items", + "native.showMore": "Show more", + "native.skillLoaded": "Skill loaded: {arg1}", + "native.templateEngines": "Template Engines", + "native.testConnection": "Test Connection", + "native.testTheConnectionToSeeTheHostSSystemResourcesAndAddress": "Test the connection to see the host's system, resources, and address.", + "native.testing": "Testing", + "native.theAddressIsInvalid": "The address is invalid.", + "native.theAgentExecutesThisCommandUsingItsOwnShellAndPassesEventJSONOnStandardInputCommandsRunWit": "The Agent executes this command using its own shell and passes event JSON on standard input. Commands run with your user permissions; add only commands you trust.", + "native.theAgentProfileChangedRestoreThePreviousProfileBeforeSynchronizingItsHooks": "The Agent profile changed. Restore the previous profile before synchronizing its Hooks.", + "native.theConnectionFailed": "The connection failed.", + "native.theConnectionToTheRemoteHostWasLostReconnectToContinue": "The connection to the remote host was lost. Reconnect to continue.", + "native.theFieldValueDoesNotMatchItsDeclarationOrExceedsItsSizeLimit": "The field value does not match its declaration or exceeds its size limit.", + "native.theFieldValueIsInvalidOrExceeds1024UTF8Bytes": "The field value is invalid or exceeds 1024 UTF-8 bytes.", + "native.theFileSystemThatHoldsTheHomeDirectory": "The file system that holds the home directory.", + "native.theHookLibraryCanContainAtMost200Hooks": "The Hook library can contain at most 200 Hooks.", + "native.theHookLibraryContainsDuplicateHooks": "The Hook library contains duplicate Hooks.", + "native.theHookLibraryDirectoryMustBeAbsolute": "The Hook library directory must be absolute.", + "native.theHookLibraryExceededItsSizeLimit": "The Hook library exceeded its size limit.", + "native.theHookLibraryIsInvalidRestoreItBeforeMakingChanges": "The Hook library is invalid. Restore it before making changes.", + "native.theHookLibraryLockMustBeARegularFile": "The Hook library lock must be a regular file.", + "native.theHookLibraryResponseWasNotRecognised": "The Hook library response was not recognised.", + "native.theHostCouldNotBeReachedCheckTheHostNameThePortAndYourNetwork": "The host could not be reached. Check the host name, the port, and your network.", + "native.theHostKeyWasNotAcceptedIfTheServerWasReinstalledRemoveItsOldEntryFromSshKnownHosts": "The host key was not accepted. If the server was reinstalled, remove its old entry from ~/.ssh/known_hosts.", + "native.theJDKTheNewProjectUsesItMustBeAtLeastTheJavaVersionBelow": "The JDK the new project uses. It must be at least the Java version below.", + "native.theLitheServerWasInstalledOnThisHost": "The Lithe server was installed on this host.", + "native.theLocationServiceDidNotAnswer": "The location service did not answer.", + "native.theNameOfTheArtifactTheBuildProduces": "The name of the artifact the build produces.", + "native.theNativeHookConfigurationMustBeValidJSONExistingContentsWereKept": "The native Hook configuration must be valid JSON. Existing contents were kept.", + "native.theNativeHookEventMustContainAnArrayExistingContentsWereKept": "The native Hook event must contain an array. Existing contents were kept.", + "native.theNativeHooksFieldMustBeAJSONObjectExistingContentsWereKept": "The native hooks field must be a JSON object. Existing contents were kept.", + "native.thePortMustBeBetween1And65535": "The port must be between 1 and 65535.", + "native.theProjectArchiveFromIsTooLarge": "The project archive from {arg1} is too large.", + "native.theProjectFolderOnTheHostBrowseConnectsInstallsTheLitheServerOnTheHostIfNeededAndLetsYouPi": "The project folder on the host. Browse connects, installs the Lithe server on the host if needed, and lets you pick a folder.", + "native.theProjectFolderOnTheHostEmptyUsesTheHomeDirectoryBrowseConnectsInstallsTheLitheServerOnTh": "The project folder on the host; empty uses the home directory. Browse connects, installs the Lithe server on the host if needed, and lets you pick a folder.", + "native.theProjectWasCreatedButGitCouldNotBeSetUp": "The project was created, but Git could not be set up: {arg1}", + "native.theServerAskedAgainCheckThePreviousAnswer": "The server asked again. Check the previous answer.", + "native.theServerRejectedTheCredentialsCheckTheUserNameAndTheAuthenticationMethod": "The server rejected the credentials. Check the user name and the authentication method.", + "native.theSpringInitializrServerThatGeneratesTheProjectChangeItToUseAMirrorOrYourCompanySOwnServe": "The Spring Initializr server that generates the project. Change it to use a mirror or your company's own server.", + "native.theUserNameCannotContainOrSpaces": "The user name cannot contain @, : or spaces.", + "native.theValueIsNotInTheExpectedFormat": "The value is not in the expected format.", + "native.theValueIsTooLong": "The value is too long.", + "native.thisBuildOfLitheDoesNotIncludeTheRemoteServer": "This build of Lithe does not include the remote server.", + "native.thisHookNoLongerExistsReloadTheLibrary": "This Hook no longer exists. Reload the library.", + "native.thisPanelNeedsExactlyOneMatchingPluginInstanceInTheWorkspace": "This panel needs exactly one matching plugin instance in the workspace.", + "native.thisRemovesOnlyTheNativeEntriesWrittenByLitheOtherHooksAreKept": "This removes only the native entries written by Lithe. Other Hooks are kept.", + "native.thisRuntimeIsOlderThanTheGeneratorRequires": "This runtime is older than the generator requires.", + "native.thisSendsTheSelectedLocalBranchToItsConfiguredRemote": "This sends the selected local branch to its configured remote.", + "native.thisTemplateComesFromWhichIsDisabled": "This template comes from {arg1}, which is disabled.", + "native.thisVersionIsNewerThanTheSelectedRuntimeSupports": "This version is newer than the selected runtime supports.", + "native.timeoutSeconds": "Timeout (seconds)", + "native.toolFilter": "Tool filter", + "native.tracking": "Tracking {arg1}", + "native.unknownDate": "Unknown date", + "native.unpin": "Unpin", + "native.unpinConversation": "Unpin conversation", + "native.uptime": "Uptime", + "native.usageAppearsAfterTheNextReply": "Usage appears after the next reply.", + "native.use180LettersDigitsHyphensOrUnderscoresNamesMustBeUnique": "Use 1–80 letters, digits, hyphens or underscores. Names must be unique.", + "native.useTheClaudeAiAccountYouSignedInToWithClaudeCodeNoAPIKeyOrURLIsNeededLitheNeverReadsItsCre": "Use the claude.ai account you signed in to with Claude Code. No API key or URL is needed; Lithe never reads its credentials.", + "native.userName": "User name", + "native.usesTheClaudeAiAccountSignedInToClaudeCodeSignInWithLoginInClaudeCodeIfTheAgentAsks": "Uses the claude.ai account signed in to Claude Code. Sign in with /login in Claude Code if the Agent asks.", + "native.usesYourLocalChatGPTAccount": "Uses your local ChatGPT account.", + "native.usuallyTheReversedDomainNameOfYourOrganizationSuchAsComExample": "Usually the reversed domain name of your organization, such as com.example.", + "native.whenASessionStarts": "When a session starts", + "native.whenATurnEnds": "When a turn ends", + "native.whenSubmittingAPrompt": "When submitting a prompt", + "native.whenToRun": "When to run", + "native.withoutItLitheAsksEveryTimeItConnectsARememberedSecretIsAnsweredOnlyTheFirstTimeTheServerA": "Without it, Lithe asks every time it connects. A remembered secret is answered only the first time the server asks for it; if it is rejected, Lithe asks you.", + "native.withoutRememberInKeychainLitheAsksEveryTimeItConnectsARememberedSecretIsAnsweredOnlyTheFir": "Without Remember in Keychain, Lithe asks every time it connects. A remembered secret is answered only the first time the server asks for it; if it is rejected, Lithe asks you.", + "settings.tabs.hooks": "Hooks", + "terminal.dockBottom": "Dock Terminal at Bottom", + "terminal.dockLeft": "Dock Terminal on Left", + "terminal.dockRight": "Dock Terminal on Right", + "terminal.movePanel": "Drag to move terminal; click to switch bottom/right", + "terminal.resizePanel": "Resize Terminal Panel", + "windows.aHookWithThisNameAlreadyExists": "A Hook with this name already exists.", + "windows.aManagedHookWasEditedOutsideLitheRestoreOrRemoveThatNativeEntryBeforeSynchronizing": "A managed Hook was edited outside Lithe. Restore or remove that native entry before synchronizing.", + "windows.aRemovedHookCannotBeSaved": "A removed Hook cannot be saved.", + "windows.addHook": "Add Hook", + "windows.afterAToolRuns": "After a tool runs", + "windows.agentDefault": "Agent default", + "windows.agentHooks": "Agent hooks", + "windows.agents": "Agents", + "windows.allTools": "All tools", + "windows.anIdenticalNativeHookAlreadyExistsLitheKeptItUnchanged": "An identical native Hook already exists. Lithe kept it unchanged.", + "windows.anotherHookUpdateIsRunningTryAgain": "Another Hook update is running. Try again.", + "windows.beforeAToolRuns": "Before a tool runs", + "windows.cannotAccessHookConfigurationCheckTheFilePermissionsAndAgentProfile": "Cannot access Hook configuration. Check the file permissions and Agent profile.", + "windows.cannotAccessTheHookLibraryCheckItsPermissions": "Cannot access the Hook library. Check its permissions.", + "windows.cannotEncodeHookConfiguration": "Cannot encode Hook configuration.", + "windows.cannotEncodeTheHookLibrary": "Cannot encode the Hook library.", + "windows.checkTheHookNameCommandEventAgentsAndTimeout186400Seconds": "Check the Hook name, command, event, Agents and timeout (1–86400 seconds).", + "windows.claudeCodeHasDisabledAllHooksInItsSettings": "Claude Code has disabled all Hooks in its settings.", + "windows.codexHookApproval": "Codex Hook approval", + "windows.codexRequiresHooksToBeEnabledAndCommandsToBeTrustedInItsNativeHooksScreenLitheSavesConfigu": "Codex requires Hooks to be enabled and commands to be trusted in its native /hooks screen. Lithe saves configuration without granting trust. Editing a command requires approval again. A configured Hook is not proof it ran.", + "windows.commandPaletteActionsCursorRedoDescription": "Restore the next cursor and selection without changing text", + "windows.commandPaletteActionsCursorRedoLabel": "Edit: Cursor Redo", + "windows.commandPaletteActionsCursorUndoDescription": "Restore the previous cursor and selection without changing text", + "windows.commandPaletteActionsCursorUndoLabel": "Edit: Cursor Undo", + "windows.deleteHook": "Delete Hook?", + "windows.enableHook": "Enable Hook", + "windows.fileEdits": "File edits", + "windows.hook": "Hook", + "windows.hookCommand": "Hook command", + "windows.hookConfigurationChangedOutsideThisEditorReloadBeforeSaving": "Hook configuration changed outside this editor. Reload before saving.", + "windows.hookConfigurationMustBeARegularFileNotASymbolicLinkOrDirectory": "Hook configuration must be a regular file, not a symbolic link or directory.", + "windows.hookConfigurationMustBeNoLargerThan1MiB": "Hook configuration must be no larger than 1 MiB.", + "windows.hookConfigurationMustUseUTF8Encoding": "Hook configuration must use UTF-8 encoding.", + "windows.hookDescription": "Hook description", + "windows.hookSynchronizationWasCancelled": "Hook synchronization was cancelled.", + "windows.hooks": "Hooks", + "windows.hooksEventAfterTool": "After a tool runs", + "windows.hooksEventBeforeTool": "Before a tool runs", + "windows.hooksEventSessionStart": "When a session starts", + "windows.hooksEventTurnEnd": "When a turn ends", + "windows.hooksRunShellCommandsAtAgentLifecycleEventsTheAgentSuppliesEventJSONOnStandardInputAndRuns": "Hooks run shell commands at Agent lifecycle events. The Agent supplies event JSON on standard input and runs the command in the session folder. Changes apply to new sessions; existing sessions keep their own loaded configuration.", + "windows.invalidHookFileGuards": "Invalid Hook file guards.", + "windows.leaveEmptyToUseTheAgentDefaultOrEnterAnIntegerFrom1To86400": "Leave empty to use the Agent default, or enter an integer from 1 to 86400.", + "windows.noHooksYet": "No Hooks yet.", + "windows.noMatchingHooks": "No matching Hooks.", + "windows.optionalDescriptionUpTo200CharactersOnlyTheHookLibraryStoresIt": "Optional description, up to 200 characters. Only the Hook library stores it.", + "windows.removalPending": "Removal pending", + "windows.retryHookSynchronization": "Retry Hook synchronization", + "windows.saveHook": "Save Hook", + "windows.searchHooks": "Search Hooks", + "windows.shellCommands": "Shell commands", + "windows.syncNeeded": "Sync needed", + "windows.theAgentExecutesThisCommandUsingItsOwnShellAndPassesEventJSONOnStandardInputCommandsRunWit": "The Agent executes this command using its own shell and passes event JSON on standard input. Commands run with your user permissions; add only commands you trust.", + "windows.theAgentProfileChangedRestoreThePreviousProfileBeforeSynchronizingItsHooks": "The Agent profile changed. Restore the previous profile before synchronizing its Hooks.", + "windows.theHookLibraryCanContainAtMost200Hooks": "The Hook library can contain at most 200 Hooks.", + "windows.theHookLibraryContainsDuplicateHooks": "The Hook library contains duplicate Hooks.", + "windows.theHookLibraryDirectoryMustBeAbsolute": "The Hook library directory must be absolute.", + "windows.theHookLibraryExceededItsSizeLimit": "The Hook library exceeded its size limit.", + "windows.theHookLibraryIsInvalidRestoreItBeforeMakingChanges": "The Hook library is invalid. Restore it before making changes.", + "windows.theHookLibraryLockMustBeARegularFile": "The Hook library lock must be a regular file.", + "windows.theHookLibraryResponseWasNotRecognised": "The Hook library response was not recognised.", + "windows.theNativeHookConfigurationMustBeValidJSONExistingContentsWereKept": "The native Hook configuration must be valid JSON. Existing contents were kept.", + "windows.theNativeHookEventMustContainAnArrayExistingContentsWereKept": "The native Hook event must contain an array. Existing contents were kept.", + "windows.theNativeHooksFieldMustBeAJSONObjectExistingContentsWereKept": "The native hooks field must be a JSON object. Existing contents were kept.", + "windows.thisHookNoLongerExistsReloadTheLibrary": "This Hook no longer exists. Reload the library.", + "windows.thisRemovesOnlyTheNativeEntriesWrittenByLitheOtherHooksAreKept": "This removes only the native entries written by Lithe. Other Hooks are kept.", + "windows.timeoutSeconds": "Timeout (seconds)", + "windows.toolFilter": "Tool filter", + "windows.use180LettersDigitsHyphensOrUnderscoresNamesMustBeUnique": "Use 1–80 letters, digits, hyphens or underscores. Names must be unique.", + "windows.whenASessionStarts": "When a session starts", + "windows.whenATurnEnds": "When a turn ends", + "windows.whenSubmittingAPrompt": "When submitting a prompt", + "windows.whenToRun": "When to run", + "run.declarativePlugins": "Declarative plugins", + "settings.declarativePlugins.title": "Declarative plugins (contract v1)", + "settings.declarativePlugins.description": "Packages that declare processes; the host launches them after an install review. Themes and Worker extensions live in the Extensions browser.", + "settings.declarativePlugins.import": "Import package…", + "settings.declarativePlugins.reviewTitle": "Install {name} {version}?", + "settings.declarativePlugins.unverified": "Unverified publisher: this package has no signature. Its permissions and programs are shown below.", + "settings.declarativePlugins.confirmInstall": "Install", + "settings.declarativePlugins.programDeclaration": "Program declaration ({count})", + "settings.declarativePlugins.empty": "No declarative plugins installed.", + "settings.declarativePlugins.toggle": "Enable or disable {name}", + "settings.declarativePlugins.rollback": "Roll back", + "settings.declarativePlugins.uninstall": "Uninstall", + "settings.declarativePlugins.source.bundled": "bundled", + "settings.declarativePlugins.source.installed": "installed", + "settings.declarativePlugins.settings": "Settings", + "settings.declarativePlugins.webWindows": "Web tool windows", + "settings.declarativePlugins.openWindow": "Open", + "settings.declarativePlugins.closeWindow": "Close", + "settings.declarativePlugins.placement.left": "left", + "settings.declarativePlugins.placement.right": "right", + "settings.declarativePlugins.placement.bottom": "bottom", + "settings.declarativePlugins.showSettings": "Settings…", + "settings.declarativePlugins.hideSettings": "Hide settings", + "settings.declarativePlugins.settingsUnavailable": "This plugin's settings could not be resolved.", + "settings.declarativePlugins.settingSource.default": "default", + "settings.declarativePlugins.settingSource.global": "user value", + "settings.declarativePlugins.settingSource.project": "project override", + "settings.declarativePlugins.settingScope.global": "all workspaces", + "settings.declarativePlugins.settingScope.project": "this workspace", + "settings.declarativePlugins.settingEffect.restart": "takes effect after the language server restarts", + "settings.declarativePlugins.settingEffect.immediate": "applied to the running language server", + "settings.declarativePlugins.settingEffect.reload": "takes effect when the plugin model reloads", + "settings.declarativePlugins.settingEffect.nextRun": "takes effect on the next run", + "native.databaseIsNowAPluginInstallItFromTheMarketplace": "Database is now a plugin. Install it from the Marketplace, and your saved connections and passwords move into it.", + "native.openMarketplace": "Open Marketplace", + "native.suggestedReplies": "Suggested replies", + "native.changedTheWorkingDirectoryTo": "Changed the working directory to {arg1}", + "native.askedForAccessTo": "Asked for access to {arg1}", + "native.chapter": "Chapter: {arg1}", + "native.startInNewConversation": "Start in new conversation", + "native.suggested": "Suggested", + "native.withdrawn": "Withdrawn", + "native.startedInClaudeDesktop": "Started in Claude Desktop", + "native.handedOff": "Handed off", + "native.linkedThisConversationToAPullRequest": "Linked this conversation to a pull request", + "native.unlinkedThePullRequest": "Unlinked the pull request", + "native.watchingPullRequestChecksAndFixingFailures": "Watching pull request checks and fixing failures", + "native.watchingPullRequestChecks": "Watching pull request checks", + "native.turnedOffAutoMerge": "Turned off auto-merge", + "native.turnedOnAutoMerge": "Turned on auto-merge", + "native.visualization": "Visualization", + "native.showVisualization": "Show visualization", + "native.hideVisualization": "Hide visualization" + }, + "overrides": { + "windows": { + "files.reveal": { + "value": "Reveal in File Explorer", + "reason": "Windows uses File Explorer; macOS uses Finder." + } + } + } +} diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json new file mode 100644 index 0000000000..ebb7e819f0 --- /dev/null +++ b/shared/i18n/locales/zh-CN.json @@ -0,0 +1,8975 @@ +{ + "messages": { + "agent.actions.close": "关闭 Agent 面板", + "agent.actions.closeSearch": "关闭查找", + "agent.actions.hideDetails": "隐藏会话详情", + "agent.actions.history": "对话历史", + "agent.actions.moveToToolWindow": "将对话移回 AI 工具窗口", + "agent.actions.newConversation": "新对话", + "agent.actions.openInEditor": "在编辑区打开对话", + "agent.actions.openSettings": "打开 Agent 设置", + "agent.actions.reconnect": "重新连接", + "agent.actions.search": "搜索对话", + "agent.actions.settings": "Agent 设置", + "agent.actions.showDetails": "显示会话详情", + "agent.actions.switchAgent": "切换 Agent", + "agent.actions.tabs": "会话标签", + "agent.activity.edits": "编辑", + "agent.activity.executing": "正在执行工具", + "agent.activity.permission": "等待授权", + "agent.activity.quiet": "近期无活动", + "agent.activity.reasoning": "思考中", + "agent.activity.recovering": "正在重连 {attempt}", + "agent.activity.responding": "正在回复", + "agent.activity.retrying": "正在重连 {attempt}/{max}", + "agent.activity.running": "运行中", + "agent.activity.tasks": "任务", + "agent.activity.waiting": "等待响应", + "agent.archive.badge": "已归档", + "agent.archive.confirmMessage": "取消归档并打开此会话?", + "agent.archive.empty": "没有已归档的会话。", + "agent.archive.incomplete": "仅显示最近的归档会话。", + "agent.archive.refresh": "刷新归档会话", + "agent.archive.restore": "取消归档", + "agent.archive.title": "Agent 原生归档", + "agent.archive.unsupported": "此 Agent 不支持原生归档会话。", + "agent.composer.agentSettings": "Agent 设置…", + "agent.composer.attachActive": "引用当前文件", + "agent.composer.attachFile": "引用项目文件", + "agent.composer.attachFiles": "添加文件", + "agent.composer.attachHint": "拖入文件,或点击选择文件", + "agent.composer.cancelling": "正在停止…", + "agent.composer.chooseAgent": "选择 Agent", + "agent.composer.clipboardBusy": "剪贴板正忙,请再次粘贴。", + "agent.composer.commands": "Agent 命令", + "agent.composer.connectionClosed": "Agent 连接已关闭。", + "agent.composer.dropFiles": "松开即可引用文件", + "agent.composer.dropHere": "拖放文件到这里", + "agent.composer.fileLimit": "每条消息最多引用 {count} 个文件。", + "agent.composer.imageCleanupFailed": "部分已放弃的图片无法从应用缓存中移除。", + "agent.composer.invalidImage": "请粘贴 PNG、JPEG、WebP、GIF 或 BMP 图片,每张不超过 10 MiB,总计不超过 32 MiB。", + "agent.composer.message": "向 Agent 发送消息", + "agent.composer.messageHint": "输入任务,/ 打开命令,@ 引用文件", + "agent.composer.noAgent": "尚未设置 Agent", + "agent.composer.noCommands": "没有匹配的命令", + "agent.composer.noFiles": "没有匹配的文件", + "agent.composer.placeholder": "描述一个任务给 Agent…", + "agent.composer.preparing": "正在准备会话…", + "agent.composer.queued": "已排队:{text}", + "agent.composer.received": "Agent 已接收", + "agent.composer.removeAttachment": "移除文件 {name}", + "agent.composer.removeFile": "移除 {name}", + "agent.composer.searchFiles": "搜索项目文件", + "agent.composer.send": "发送", + "agent.composer.sending": "正在发送…", + "agent.composer.stop": "停止", + "agent.composer.supplementCancelled": "补充消息已取消。", + "agent.composer.supplementRejected": "Agent 未接受这条补充消息。", + "agent.composer.tooManyFiles": "一次最多添加 32 个文件。", + "agent.composer.tooManyImages": "每次最多粘贴 32 张图片。", + "agent.configuration.staged": "将在下一轮生效", + "agent.context.attach": "添加到 Agent 会话", + "agent.context.busy": "请等待当前 Agent 操作完成后再切换 Agent。", + "agent.context.current": "当前会话", + "agent.context.dirty": "已附加磁盘文件,请在发送前保存编辑器中未保存的修改。", + "agent.context.label": "上下文用量", + "agent.context.new": "新会话", + "agent.context.placeholder": "上下文: {percent}", + "agent.context.setup": "配置 Agent…", + "agent.context.stale": "工作区、Agent 或会话已改变,请重新打开文件菜单。", + "agent.context.tokens": "{percent} · {used} / {capacity} 上下文 token", + "agent.context.unavailable": "所选文件不可用,请确认文件仍存在于磁盘。", + "agent.continueWaiting": "继续等待", + "agent.details.agent": "Agent", + "agent.details.context": "上下文与模型", + "agent.details.messages": "消息", + "agent.details.noConfig": "Agent 尚未上报模型设置。", + "agent.details.session": "会话", + "agent.details.sessionId": "会话 ID", + "agent.details.title": "会话详情", + "agent.details.titleLabel": "标题", + "agent.details.turns": "已完成轮次", + "agent.details.unavailable": "MCP 服务器与变更文件会在 Agent 上报后显示。", + "agent.details.workspace": "工作区", + "agent.editor.focusTab": "定位到编辑区标签", + "agent.editor.foreignAgentMessage": "此标签由 {{agent}} 打开,当前启用的是 {{current}}。请切回 {{agent}} 加载会话,当前连接不能接管该会话。", + "agent.editor.foreignAgentTitle": "该会话属于其他 Agent", + "agent.editor.handoffNotice": "该对话已在编辑区打开,工具窗口显示历史列表。", + "agent.editor.switchBack": "切回 {{agent}}", + "agent.edits.changed": "磁盘文件已变化,请检查当前内容后再撤销。", + "agent.edits.diff": "上报的差异", + "agent.edits.dirty": "请先保存或放弃编辑器中未保存的改动。", + "agent.edits.failed": "无法撤销,请检查文件位置和权限。", + "agent.edits.incomplete": "缺少完整原文,无法撤销。", + "agent.edits.keep": "保留", + "agent.edits.recoveryRequired": "无法自动还原文件,请从此位置恢复:{path}", + "agent.edits.revert": "撤销", + "agent.edits.title": "审阅修改({count})", + "agent.error.disabled": "Agent 对话功能已关闭。请在面板设置中开启后再发送消息。", + "agent.error.notConnected": "Agent 未运行。请先连接再开始对话。", + "agent.error.preparing": "对话仍在准备中,请稍后再试。", + "agent.evidence.input": "输入", + "agent.evidence.result": "结果", + "agent.hero.choose": "选择一个 Agent 开始", + "agent.hero.sendTo": "向 {name} 发送消息", + "agent.history.addFavorite": "加入收藏", + "agent.history.conversations": "{count} 个会话", + "agent.history.copied": "已复制会话 ID", + "agent.history.copyId": "复制会话 ID", + "agent.history.deselectAll": "取消全选", + "agent.history.done": "完成", + "agent.history.empty": "该项目还没有已保存的会话。", + "agent.history.export": "导出会话", + "agent.history.exportFailed": "导出会话失败。", + "agent.history.exportFileType": "Markdown", + "agent.history.exportTooLarge": "转录内容超过 32 MiB,未导出。", + "agent.history.exportUnfinished": "部分会话还没有完整加载,因此没有导出。请先打开这些会话再试一次。", + "agent.history.exporting": "正在导出会话…", + "agent.history.filter": "历史筛选", + "agent.history.filter.all": "全部会话", + "agent.history.filter.favorites": "收藏", + "agent.history.filter.removed": "已移除的会话", + "agent.history.loading": "正在加载历史…", + "agent.history.messages": "{count} 条消息", + "agent.history.noMatch": "没有匹配的会话", + "agent.history.noMatchHint": "试试其他标题或会话 ID。", + "agent.history.refresh": "刷新历史", + "agent.history.remove": "从历史中移除", + "agent.history.removeConfirm": "从历史中移除 {count} 个会话?你可以在“已移除的会话”里恢复它们。Agent 的原始记录会保留。", + "agent.history.removeFavorite": "取消收藏", + "agent.history.removedNotice": "已移除的会话只在 Lithe 中隐藏,Agent 的原始记录会保留。", + "agent.history.rename": "重命名会话", + "agent.history.renameMessage": "为该会话设置一个只用于 Lithe 的标题。", + "agent.history.restore": "恢复会话", + "agent.history.saveFailed": "Lithe 无法保存会话标注。", + "agent.history.search": "搜索会话标题或 ID…", + "agent.history.select": "选择", + "agent.history.selectAll": "全选", + "agent.history.selectedCount": "已选 {count} 项", + "agent.history.thinking": "思考", + "agent.history.title": "历史({count})", + "agent.history.tool": "工具", + "agent.history.you": "你", + "agent.notice.dismiss": "关闭通知", + "agent.permission.deny": "拒绝", + "agent.permission.dismiss": "暂不处理", + "agent.permission.title": "需要许可", + "agent.plan.collapse": "收起计划", + "agent.plan.completed": "已完成", + "agent.plan.expand": "展开计划", + "agent.plan.pending": "待处理", + "agent.plan.progress": "计划 {completed}/{total}", + "agent.plan.running": "进行中", + "agent.quietNotice": "暂未收到新的进度,任务仍在进行。", + "agent.quota.primary": "{window} 窗口 {percent}", + "agent.quota.resetsAt": "{time} 重置", + "agent.quota.unknown": "{window} 窗口额度未知", + "agent.quotaView.accountChanged": "本机 Codex 账号已变更,请重新连接以刷新额度。", + "agent.quotaView.fiveHours": "5 小时", + "agent.quotaView.resets": "重置时间:{time}", + "agent.quotaView.sevenDays": "7 天", + "agent.quotaView.stale": "暂时无法更新额度,上次读数可能已过期。", + "agent.quotaView.title": "Codex 订阅额度", + "agent.quotaView.unknown": "额度 —", + "agent.quotaView.updated": "更新时间:{time}", + "agent.quotaView.used": "{window} · 已用 {percent}", + "agent.quotaView.usedPercent": "已用 {percent}", + "agent.quotaView.waiting": "正在等待订阅额度…", + "agent.role.agent": "Agent", + "agent.role.user": "你", + "agent.selector.fast": "快速", + "agent.selector.mode": "权限模式", + "agent.selector.model": "模型", + "agent.selector.more": "更多会话设置", + "agent.selector.noModels": "没有匹配的模型", + "agent.selector.searchModels": "搜索模型", + "agent.selector.speed": "速度", + "agent.selector.standard": "标准", + "agent.selector.thinkingLevel": "思考强度", + "agent.settings.addProvider": "添加", + "agent.settings.agent": "Agent", + "agent.settings.allowInsecureHint": "允许明文 http 地址", + "agent.settings.apiKey": "API Key", + "agent.settings.apiKeyMode": "API Key", + "agent.settings.apiKeyPlaceholder": "sk-…", + "agent.settings.apiKeyStored": "已保存,输入新 Key 可替换", + "agent.settings.arguments": "参数", + "agent.settings.authJson": "Codex auth.json(可选;密钥保存到安全存储)", + "agent.settings.authentication": "登录方式", + "agent.settings.back": "返回对话", + "agent.settings.baseUrl": "服务商地址", + "agent.settings.busyAuthentication": "当前轮次结束前不能切换 Agent 的登录方式。", + "agent.settings.cliDetected": "已安装 {name} {version}。", + "agent.settings.cliMissing": "未找到 {name} {version} 或更高版本。", + "agent.settings.cliUpdateWarning": "已升级,但安装器有警告:{warning}", + "agent.settings.cliUpdated": "已升级到 {version}。", + "agent.settings.command": "可执行文件", + "agent.settings.configurationTooLarge": "CLI 配置超过 64 KiB。", + "agent.settings.conflict": "其他窗口已更改 Agent 设置。请检查最新配置后重试。", + "agent.settings.customAgent": "自定义 ACP Agent", + "agent.settings.editProvider": "编辑", + "agent.settings.enable": "启用 Agent 面板", + "agent.settings.enableHint": "默认关闭;开启前不会启动任何进程。", + "agent.settings.enableSubscription": "启用", + "agent.settings.formMode": "编辑表单", + "agent.settings.hideKey": "隐藏 API Key", + "agent.settings.importConfiguration": "导入 CLI 配置", + "agent.settings.importConfigurationAction": "导入", + "agent.settings.importConfigurationHint": "只读取地址与模型,API Key 仍由本机 CLI 管理。", + "agent.settings.importFailed": "无法读取 CLI 配置,请检查格式和文件位置。", + "agent.settings.importProtocolMismatch": "该配置的协议与所选 Agent 不匹配。", + "agent.settings.importingConfiguration": "解析中…", + "agent.settings.install": "安装", + "agent.settings.installCli": "安装", + "agent.settings.installed": "已安装 {name} {version}。", + "agent.settings.installing": "安装中…", + "agent.settings.invalidProviderUrl": "请输入有效的 HTTPS API 地址,或明确允许 HTTP。", + "agent.settings.keyFailed": "无法更新密钥,请重试。", + "agent.settings.linkLocal": "每次连接使用本机 CLI 的最新地址、模型和密钥", + "agent.settings.model": "模型", + "agent.settings.modelPlaceholder": "适配器默认值", + "agent.settings.notInstalled": "{name} 未安装。", + "agent.settings.preflight": "环境详情", + "agent.settings.progress": "npm {stage} · {megabytes} MB", + "agent.settings.provider": "供应商", + "agent.settings.providerName": "供应商名称", + "agent.settings.readLocalConfiguration": "读取本机 CLI 配置", + "agent.settings.reconnect": "重新连接", + "agent.settings.reconnectHint": "服务商改动在下次连接时生效。", + "agent.settings.refresh": "重新检测", + "agent.settings.reinstall": "重新安装", + "agent.settings.removeProvider": "移除", + "agent.settings.removeProviderConfirm": "移除此供应商及其已保存的密钥?", + "agent.settings.requiredProviderFields": "请填写供应商名称、API 地址、模型与 API Key。", + "agent.settings.saveFailed": "无法保存 Agent 设置,请重试。", + "agent.settings.saveKey": "保存", + "agent.settings.showKey": "显示 API Key", + "agent.settings.subscriptionHint": "复用本机 Codex CLI 的 ChatGPT 登录,不复制任何密钥。", + "agent.settings.subscriptionMode": "Codex 订阅", + "agent.settings.subscriptionRowHint": "使用本机 ChatGPT 账号,不需要 API Key 或地址。", + "agent.settings.textMode": "编辑配置文本", + "agent.settings.title": "设置", + "agent.settings.uninstall": "卸载适配器", + "agent.settings.unlinkKey": "解除密钥关联", + "agent.settings.updateCli": "升级", + "agent.settings.updatingCli": "升级中…", + "agent.settings.useApiKey": "改用 API Key", + "agent.setup.disabled": "Agent 面板已关闭。", + "agent.setup.enable": "开启", + "agent.setup.invalidCliConfiguration": "关联的 CLI 配置需要有效的地址、模型和 API Key。请检查本机 CLI 配置后重连。", + "agent.setup.missingApiKey": "请在 Agent 设置中填写服务商 API Key。", + "agent.setup.missingCommand": "开始对话前请先设置 ACP Agent 可执行文件。", + "agent.setup.missingProvider": "Agent 需要一个服务商地址,请在 Agent 设置中填写。", + "agent.setup.noAgent": "尚未选择 Agent。", + "agent.setup.noProject": "打开一个项目后才能使用 Agent。", + "agent.setup.openSettings": "Agent 设置", + "agent.setup.subscriptionNeedsCodex": "只有 Codex 适配器可以使用 ChatGPT 订阅。", + "agent.state.cancel": "取消", + "agent.state.failed": "Agent 无法启动", + "agent.state.signIn": "登录 Codex", + "agent.state.signInAction": "登录 ChatGPT", + "agent.state.signInMessage": "使用本机已登录的 ChatGPT 账号,无需 API Key 或 URL。", + "agent.state.signingIn": "正在等待 ChatGPT 登录…", + "agent.state.signingInMessage": "请在浏览器中完成登录,凭据由 Codex 管理。", + "agent.state.starting": "正在启动 Agent…", + "agent.state.startingMessage": "打开此面板时才会启动 Agent 进程。", + "agent.stats.calibrated": "估算分项已按供应商输入 Token 校准", + "agent.stats.calls": "模型调用次数", + "agent.stats.estimated": "按请求大小估算", + "agent.stats.lowerBound": "部分调用未上报用量,Token 计数为下界。", + "agent.stats.mcp": "MCP 工具", + "agent.stats.messages": "对话消息", + "agent.stats.modelTime": "模型用时", + "agent.stats.noComposition": "观测到模型请求后显示上下文构成。", + "agent.stats.nonStreaming": "非流式调用", + "agent.stats.observe": "模型调用统计", + "agent.stats.observeHint": "通过本机 Host 代理观测请求,重连后生效。", + "agent.stats.system": "系统提示词", + "agent.stats.toolCalls": "工具调用", + "agent.stats.toolCount": "{tools} 个工具 · {mcp} 个 MCP 工具", + "agent.stats.toolTime": "工具与权限耗时", + "agent.stats.tools": "工具定义", + "agent.stats.tps": "输出速度", + "agent.stats.ttft": "首 Token 延迟", + "agent.stats.unknown": "未上报", + "agent.status.connecting": "正在启动 Agent…", + "agent.status.failed": "Agent 无法启动", + "agent.status.retry": "重试", + "agent.stop": "停止", + "agent.subagents.cancelled": "已取消", + "agent.subagents.completed": "已完成", + "agent.subagents.disconnected": "已断开", + "agent.subagents.failed": "失败", + "agent.subagents.progress": "已完成 {completed}/{total}", + "agent.subagents.running": "运行中", + "agent.subagents.title": "子代理", + "agent.subscription.authenticating": "等待浏览器登录完成…", + "agent.subscription.cancelSignIn": "取消登录", + "agent.subscription.required": "使用 Codex 订阅需要先登录 ChatGPT 账号。", + "agent.subscription.signIn": "登录 ChatGPT", + "agent.subscription.signedIn": "已登录", + "agent.tabs.close": "关闭对话", + "agent.tabs.label": "已打开的会话", + "agent.tabs.untitled": "未命名对话", + "agent.title": "Agent", + "agent.tool.details": "工具详情", + "agent.tool.status.completed": "已完成", + "agent.tool.status.failed": "失败", + "agent.tool.status.inProgress": "运行中", + "agent.tool.status.interrupted": "已中断", + "agent.tool.status.pending": "等待执行", + "agent.toolGroup.allCompleted": "全部完成", + "agent.toolGroup.completed": "已完成 {done}/{total}", + "agent.toolGroup.failed": "{count} 个失败", + "agent.toolGroup.hide": "收起工具详情", + "agent.toolGroup.interrupted": "{count} 个已中断", + "agent.toolGroup.show": "展开工具详情", + "agent.toolGroup.title": "工具操作({count})", + "agent.transcript.code": "代码", + "agent.transcript.copyCode": "复制代码", + "agent.transcript.copyReply": "复制回复", + "agent.transcript.empty": "还没有消息。", + "agent.transcript.hideReasoning": "收起思考", + "agent.transcript.loading": "正在加载对话…", + "agent.transcript.noMatch": "没有匹配的消息", + "agent.transcript.pickSession": "选择一个会话,或新建一个。", + "agent.transcript.reasoning": "思考过程", + "agent.transcript.showReasoning": "展开思考", + "agent.transcript.stopping": "正在停止…", + "agent.transcript.thinking": "思考中…", + "agent.turn.cacheRead": "缓存读取 token:{count}", + "agent.turn.cacheReadLabel": "缓存读取", + "agent.turn.cacheWrite": "缓存写入 token:{count}", + "agent.turn.cacheWriteLabel": "缓存写入", + "agent.turn.elapsed": "耗时:{duration}", + "agent.turn.elapsedHint": "从发送开始计时,包含工具执行和等待权限的时间。", + "agent.turn.elapsedLabel": "耗时", + "agent.turn.hours": "{hours}小时 {minutes}分 {seconds}秒", + "agent.turn.input": "输入:{count}", + "agent.turn.inputLabel": "输入", + "agent.turn.minutes": "{minutes}分 {seconds}秒", + "agent.turn.output": "输出:{count}", + "agent.turn.outputLabel": "输出", + "agent.turn.reasoning": "推理 token:{count}", + "agent.turn.reasoningLabel": "推理", + "agent.turn.seconds": "{seconds}秒", + "agent.turn.thisTurn": "本轮", + "agent.turn.total": "总 token:{count}", + "agent.turn.totalLabel": "总 token", + "agent.turn.usageHint": "Agent 上报的 token 数;统计口径由 Agent 决定。", + "agent.usage.tokens": "{used} / {capacity} tokens", + "agent.usage.unknown": "上下文占用未知", + "ai.actionRunNumber": "Actions 运行 #{number}", + "ai.add": "添加", + "ai.addContext": "添加上下文", + "ai.addToQueue": "加入队列", + "ai.added": "已添加", + "ai.agent": "Agent", + "ai.agentDefault": "Agent 默认值", + "ai.agentHistory": "Agent 历史", + "ai.agentInstallFailed": "安装 {name} 失败", + "ai.agentInstalled": "{name} 已安装", + "ai.agentSessionRestarted": "Agent 会话已重启", + "ai.agentSetup": "Agent 设置", + "ai.agents": "Agents", + "ai.aiPreferences": "AI 偏好设置", + "ai.allow": "允许", + "ai.allowOnce": "允许一次", + "ai.always": "始终", + "ai.alwaysAllowRequestType": "始终允许此类请求", + "ai.alwaysDenyRequestType": "始终拒绝此类请求", + "ai.apiKeyRequired": "需要 API key", + "ai.apiKeySaved": "API key 已保存", + "ai.apiKeySavedWithPeriod": "API key 已保存。", + "ai.apiKeys": "API Keys", + "ai.apply": "应用", + "ai.applyCodeToCurrentBuffer": "将此代码应用到当前缓冲区", + "ai.archiveSession": "归档会话", + "ai.ask": "提问", + "ai.branch": "分支", + "ai.browse": "浏览", + "ai.cancel": "取消", + "ai.clickToRenameSession": "点击重命名会话", + "ai.closeMessageSearch": "关闭消息搜索", + "ai.closeSearch": "关闭搜索", + "ai.completeLoginThenRestart": "在 Agent CLI 中完成登录后重启会话。", + "ai.configureCustomModel": "配置自定义模型...", + "ai.copyCode": "复制代码", + "ai.copyPrompt": "复制提示词", + "ai.copyResponse": "复制回复", + "ai.copyTableHtml": "复制表格为 HTML", + "ai.copyTableMarkdown": "复制表格为 Markdown", + "ai.couldNotOpenAgentTerminal": "无法打开 Agent 终端", + "ai.couldNotRestartAgentSession": "无法重启 Agent 会话", + "ai.deleteNamedSkill": "删除 {title}", + "ai.deleteSkill": "删除技能", + "ai.deny": "拒绝", + "ai.denyOnce": "拒绝一次", + "ai.details": "详情", + "ai.editNamedSkill": "编辑 {title}", + "ai.editPrompt": "编辑提示词", + "ai.editSkill": "编辑技能", + "ai.enterApiKey": "输入 API key...", + "ai.error": "错误", + "ai.executePlan": "执行计划", + "ai.executePlanStepPrompt": "执行计划的第 {number} 步:{title}\n\n{description}", + "ai.failedFetchModels": "获取模型失败", + "ai.failedRemoveApiKey": "移除 API key 失败。", + "ai.failedValidateApiKey": "验证 API key 失败。", + "ai.fileList": "文件列表", + "ai.files": "文件", + "ai.finishAgentSetupThenRestart": "完成 Agent 设置后重启会话。", + "ai.generatedContentNumber": "AI 生成内容 {number}", + "ai.generatedImageNumber": "生成的图片 {number}", + "ai.hideDetails": "隐藏详情", + "ai.install": "安装", + "ai.installingAgent": "正在安装 {name}", + "ai.invalidApiKey": "API key 无效。", + "ai.loadingAvailableSkills": "正在加载可用技能...", + "ai.loadingModels": "正在加载模型...", + "ai.localOverride": "本地覆盖", + "ai.markdown": "Markdown", + "ai.marketplace": "市场", + "ai.marketplaceSkill": "市场技能", + "ai.marketplaceSkillWithLocalOverride": "带本地覆盖的市场技能", + "ai.messageInput": "消息输入", + "ai.microphoneAccessFailed": "麦克风访问失败。请检查系统设置 -> 隐私与安全 -> 麦克风。", + "ai.microphoneAccessFailedLinux": "麦克风访问失败。请检查 PipeWire/PulseAudio 输入设备,并取消默认麦克风静音。", + "ai.mode": "模式", + "ai.model": "模型", + "ai.mySkills": "我的技能", + "ai.never": "永不", + "ai.new": "新建", + "ai.newAgent": "新建 Agent", + "ai.newSession": "新会话", + "ai.newSkill": "新建技能", + "ai.nextMatch": "下一个匹配项", + "ai.nextSearchMatch": "下一个搜索匹配项", + "ai.noAvailableSkillsMatch": "没有可用技能匹配“{query}”", + "ai.noMatchingContextFound": "未找到匹配的上下文", + "ai.noMatchingSlashCommands": "没有匹配的斜杠命令", + "ai.noModelsFound": "未找到模型。", + "ai.noOllamaModelsDetected": "未检测到模型。请在 Ollama 中安装模型。", + "ai.noProvidersFound": "未找到服务商", + "ai.noPublishedSkillsYet": "暂无已发布技能", + "ai.noSkillsMatch": "没有技能匹配“{query}”", + "ai.noSkillsYet": "暂无技能", + "ai.noSlashCommandsAvailable": "暂无可用斜杠命令", + "ai.notSynced": "未同步", + "ai.open": "打开", + "ai.openAgentTerminal": "打开 Agent 终端", + "ai.openDashboard": "打开控制台", + "ai.openDiff": "打开 diff", + "ai.openFile": "打开文件", + "ai.openLower": "打开", + "ai.openResource": "打开 {name}", + "ai.openTabs": "打开的标签页", + "ai.openTerminal": "打开终端", + "ai.opening": "正在打开...", + "ai.permission": "权限", + "ai.pinSession": "置顶会话", + "ai.plan": "计划", + "ai.planSummary": "计划({count} 个{label})", + "ai.pleaseEnterApiKey": "请输入 API key。", + "ai.previousMatch": "上一个匹配项", + "ai.previousSearchMatch": "上一个搜索匹配项", + "ai.project": "项目", + "ai.provider": "服务商", + "ai.publishedSkillsWillAppear": "Lithe 技能注册表可用后,已发布技能会显示在这里。", + "ai.pullRequestNumber": "拉取请求 #{number}", + "ai.recent": "最近", + "ai.remove": "移除", + "ai.removeContextItemHint": "{name}。按 Delete 可从上下文中移除。", + "ai.renameSession": "重命名 {title}", + "ai.restartAgentSession": "重启 Agent 会话", + "ai.restarting": "正在重启...", + "ai.save": "保存", + "ai.saveKey": "保存 key", + "ai.searchApiKeyProviders": "搜索 API key 提供商...", + "ai.searchAvailableSkills": "搜索可用技能...", + "ai.searchContext": "搜索上下文...", + "ai.searchFiles": "搜索文件...", + "ai.searchMessages": "搜索消息", + "ai.searchSkills": "搜索技能...", + "ai.selectAiModel": "选择 AI 模型", + "ai.selectAiProvider": "选择 AI 服务商", + "ai.selectModel": "选择模型", + "ai.selectProvider": "选择服务商", + "ai.selectedContext": "已选上下文", + "ai.send": "发送", + "ai.sendMessage": "发送消息", + "ai.settings": "设置", + "ai.showSlashCommands": "显示斜杠命令", + "ai.skillContentPlaceholder": "编写此技能的指令或可复用上下文...", + "ai.skillTitlePlaceholder": "代码审查清单", + "ai.skills": "技能", + "ai.slashCommandSuggestions": "斜杠命令建议", + "ai.startVoiceInput": "开始语音输入", + "ai.step": "步骤", + "ai.steps": "步骤", + "ai.stopGeneration": "停止生成", + "ai.stopVoiceInput": "停止语音输入", + "ai.suggestions": "建议", + "ai.syncPaused": "同步已暂停", + "ai.synced": "已同步", + "ai.syncing": "正在同步", + "ai.terminal": "终端", + "ai.thinking": "正在思考...", + "ai.title": "标题", + "ai.toggleAgentHistory": "切换 Agent 历史", + "ai.toolCall": "工具调用", + "ai.toolCalls": "{count} 个工具调用", + "ai.toolChangedFile": "已更改 {file}", + "ai.toolChangedFiles": "已更改 {count} 个文件", + "ai.toolDetailWithSummary": "{status} - {summary}", + "ai.toolStatusCompleted": "已完成", + "ai.toolStatusFailed": "失败", + "ai.toolStatusPending": "等待中", + "ai.toolStatusRunning": "运行中", + "ai.toolTerminalOutput": "终端输出", + "ai.toolTerminals": "{count} 个终端", + "ai.typeModelName": "输入模型名称并按 Enter", + "ai.unknownError": "未知错误", + "ai.unpinSession": "取消置顶会话", + "ai.useCustomValue": "使用 {value}", + "ai.validating": "正在验证", + "ai.voiceCouldNotStart": "无法启动语音输入。", + "ai.voiceDisabledMacDev": "macOS 开发模式下已禁用语音输入。请在打包应用中测试。", + "ai.voiceDisabledMacDevShort": "macOS 开发模式下已禁用语音输入", + "ai.voiceNotSupported": "不支持语音输入", + "ai.voiceNotSupportedWebview": "此 webview 不支持语音输入。", + "ai.voiceStoppedUnexpectedly": "语音输入意外停止。", + "ai.voiceUnavailableLinuxWebview": "此 Linux webview 不可用语音输入。即使麦克风可用,Chromium 语音识别也可能被阻止。", + "aiCommit.add": "添加服务商", + "aiCommit.aiCOMMITBUSY": "AI 请求正在处理中,请稍后重试。", + "aiCommit.aiCOMMITCONFIGMISSING": "导入的配置已不存在,请重新识别或选择其他服务商。", + "aiCommit.aiCOMMITCONFIGREADFAILED": "无法读取本机配置,请检查文件权限。", + "aiCommit.aiCOMMITCONFIGTOOLARGE": "配置文件超过 1 MiB 限制。", + "aiCommit.aiCOMMITDIFFFAILED": "部分勾选文件的差异读取失败,请刷新 Git 更改后重试。", + "aiCommit.aiCOMMITDRAFTCHANGED": "确认期间草稿发生变化,已保留你的输入。", + "aiCommit.aiCOMMITEMPTYDIFF": "没有可用于生成的文本差异。", + "aiCommit.aiCOMMITEMPTYRESPONSE": "服务商未返回提交信息。", + "aiCommit.aiCOMMITINSECUREENDPOINT": "HTTP 尚未启用,仅对可信服务商开启此选项。", + "aiCommit.aiCOMMITINVALIDCONFIG": "本机配置格式无效或暂不支持。", + "aiCommit.aiCOMMITINVALIDOPTIONS": "请检查提交信息生成设置。", + "aiCommit.aiCOMMITINVALIDPROVIDER": "请填写有效的 API 地址、模型和协议,地址不能包含账号密码、查询参数或片段。", + "aiCommit.aiCOMMITINVALIDRESPONSE": "返回内容与所选 API 协议不匹配。", + "aiCommit.aiCOMMITKEYFAILED": "Windows 凭据管理器无法读取或保存密钥。", + "aiCommit.aiCOMMITMISSINGKEY": "未找到支持的 API 密钥,请保存密钥或更新来源配置。", + "aiCommit.aiCOMMITMULTIPLEREPOSITORIES": "请只勾选同一个仓库的文件来生成提交信息。", + "aiCommit.aiCOMMITNETWORKERROR": "无法连接 AI 服务商,请检查地址和网络。", + "aiCommit.aiCOMMITOUTPUTLIMIT": "服务商在生成完整提交信息前用尽输出额度,请降低推理强度或减少 Diff 字符限制后重试。", + "aiCommit.aiCOMMITRESPONSETOOLARGE": "服务商响应超过 2 MiB 限制。", + "aiCommit.aiCOMMITSENSITIVEFILE": "勾选范围包含环境配置或凭据文件,请取消勾选后再生成。", + "aiCommit.aiCOMMITSTALE": "勾选的更改已变化,请重新生成。", + "aiCommit.aiCOMMITTIMEOUT": "生成超时,可降低推理强度或减少 Diff 字符限制后重试。", + "aiCommit.aiCOMMITTOOMANYFILES": "一次 AI 生成最多选择 1000 个文件。", + "aiCommit.allowHttp": "允许此可信服务商使用未加密的 HTTP", + "aiCommit.authentication": "认证方式", + "aiCommit.body": "有必要时包含简短正文", + "aiCommit.cancel": "取消生成", + "aiCommit.clearKey": "移除密钥", + "aiCommit.concise": "简洁摘要", + "aiCommit.configure": "请先在“AI 与提交”设置中配置服务商。", + "aiCommit.conventional": "Conventional Commits 规范", + "aiCommit.custom": "自定义指令", + "aiCommit.customFormat": "自定义", + "aiCommit.defaultEffort": "服务商默认(不发送参数)", + "aiCommit.descriptive": "详细描述", + "aiCommit.detect": "重新加载识别", + "aiCommit.detected": "已检测到 {source} 配置", + "aiCommit.diff": "Diff 字符限制", + "aiCommit.disclosure": "生成时会将勾选文件的差异发送给当前服务商。请检查生成内容后再提交。", + "aiCommit.draftChanged": "确认期间草稿发生变化,已保留你的输入。", + "aiCommit.effort": "推理强度", + "aiCommit.enabled": "启用 AI 提交信息", + "aiCommit.endpoint": "API 地址", + "aiCommit.example": "示例", + "aiCommit.failed": "AI 生成失败,请检查服务商配置后重试。", + "aiCommit.format": "格式", + "aiCommit.generating": "正在生成…", + "aiCommit.httpError": "AI 服务商返回 HTTP {status},请检查密钥、模型和 API 协议。", + "aiCommit.imperative": "祈使句标题", + "aiCommit.import": "从 {source} 导入", + "aiCommit.key": "API 密钥或 Token", + "aiCommit.keyStored": "已保存凭据", + "aiCommit.language": "输出语言", + "aiCommit.legacyGateway": "旧网关兼容", + "aiCommit.managed": "每次生成前从本机 {source} 配置读取地址、模型和凭据。", + "aiCommit.model": "模型", + "aiCommit.name": "服务商名称", + "aiCommit.noKey": "未找到 API 凭据(仅登录 CLI 不一定提供 API 密钥)", + "aiCommit.notDetected": "未找到支持的 Codex 或 Claude 配置,可以手动添加服务商。", + "aiCommit.profile": "配置", + "aiCommit.profiles": "AI 服务商", + "aiCommit.protocol": "API 协议", + "aiCommit.releaseNote": "发布说明", + "aiCommit.remove": "移除", + "aiCommit.replace": "使用生成结果替换当前提交信息吗?", + "aiCommit.requiresKey": "服务商需要 API 密钥", + "aiCommit.rules": "提交信息生成", + "aiCommit.saveKey": "保存密钥", + "aiCommit.saved": "已保存", + "aiCommit.settings": "AI 与提交设置", + "aiCommit.stale": "仓库或勾选的更改已变化,请重新生成。", + "aiCommit.subject": "标题最大长度", + "aiCommit.tokenLimitField": "输出 Token 参数", + "aiHistory.archived": "已归档", + "aiHistory.deleteSession": "删除会话", + "aiHistory.deleteSessionNamed": "删除 {title}", + "aiHistory.empty": "暂无 Agent 历史", + "aiHistory.noMatches": "没有匹配“{query}”的会话", + "aiHistory.restoreSession": "恢复会话", + "aiHistory.restoreSessionNamed": "恢复 {title}", + "aiHistory.searchPlaceholder": "搜索 Agent 历史...", + "aiSettings.acpSession": "ACP 会话", + "aiSettings.acpSessionOptionDescription": "当前 ACP Agent 暴露的会话选项", + "aiSettings.agentHistory": "Agent 历史", + "aiSettings.agentHistoryCleared": "Agent 历史已清空", + "aiSettings.aiAutocomplete": "AI 自动补全", + "aiSettings.aiAutocompleteDescription": "输入时启用 AI 自动补全", + "aiSettings.apiKey": "API 密钥", + "aiSettings.apiKeys": "API 密钥", + "aiSettings.apiKeysDescription": "单独管理各服务商的 API 密钥", + "aiSettings.authentication": "认证", + "aiSettings.autocomplete": "自动补全", + "aiSettings.autocompleteModel": "自动补全模型", + "aiSettings.autocompleteModelDescription": "选择任意 OpenRouter 模型用于自动补全", + "aiSettings.autocompleteProvider": "自动补全服务商", + "aiSettings.autocompleteProviderDescription": "使用 OpenRouter 或 OpenAI 兼容端点", + "aiSettings.baseUrl": "基础 URL", + "aiSettings.checking": "正在检查", + "aiSettings.clearAgentHistory": "清空 Agent 历史", + "aiSettings.clearAgentHistoryDescription": "永久删除所有 Agent 历史", + "aiSettings.clearAll": "全部清空", + "aiSettings.clearing": "正在清空...", + "aiSettings.cloud": "云端", + "aiSettings.comingSoon": "即将推出", + "aiSettings.configureProviderExtension": "配置服务商扩展", + "aiSettings.connectionStatus": "连接状态", + "aiSettings.custom": "自定义", + "aiSettings.customApiKey": "自定义 API 密钥", + "aiSettings.customAutocompleteApiKeyRemoveFailed": "移除自定义自动补全 API 密钥失败", + "aiSettings.customAutocompleteApiKeyRemoved": "自定义自动补全 API 密钥已移除", + "aiSettings.customAutocompleteApiKeySaveFailed": "保存自定义自动补全 API 密钥失败", + "aiSettings.customAutocompleteApiKeySaved": "自定义自动补全 API 密钥已保存", + "aiSettings.customBaseUrl": "自定义基础 URL", + "aiSettings.customModel": "自定义模型", + "aiSettings.customModelDescription": "发送到自定义端点的模型名称", + "aiSettings.customProvider": "自定义服务商", + "aiSettings.customProviderApiKeyRemoveFailed": "移除自定义服务商 API 密钥失败", + "aiSettings.customProviderApiKeyRemoved": "自定义服务商 API 密钥已移除", + "aiSettings.customProviderApiKeySaveFailed": "保存自定义服务商 API 密钥失败", + "aiSettings.customProviderApiKeySaved": "自定义服务商 API 密钥已保存", + "aiSettings.customProviderBaseUrlDescription": "供 Lithe Agent 使用的 OpenAI 兼容端点基础 URL", + "aiSettings.endpoint": "端点", + "aiSettings.error": "错误", + "aiSettings.getKey": "获取密钥", + "aiSettings.loadingModels": "正在加载模型", + "aiSettings.loadingModelsEllipsis": "正在加载模型...", + "aiSettings.local": "本地", + "aiSettings.manageKeys": "管理密钥", + "aiSettings.mode": "模式", + "aiSettings.model": "模型", + "aiSettings.modelDescription": "选择 Lithe Agent 使用的模型", + "aiSettings.modelList": "模型列表", + "aiSettings.modelListEmpty": "模型列表为空。请刷新后重试。", + "aiSettings.modelListLoadFailed": "无法加载模型列表。请刷新后重试。", + "aiSettings.noModelsLoaded": "未加载模型", + "aiSettings.ollamaApiKeyDescription": "用于需要认证的 Ollama 端点和 Ollama Cloud。", + "aiSettings.ollamaApiKeyRemoveFailed": "移除 Ollama API 密钥失败", + "aiSettings.ollamaApiKeyRemoved": "Ollama API 密钥已移除", + "aiSettings.ollamaApiKeySaveFailed": "保存 Ollama API 密钥失败", + "aiSettings.ollamaApiKeySaved": "Ollama API 密钥已保存", + "aiSettings.ollamaCloudConnectionFailed": "无法连接 Ollama Cloud。请检查 API 密钥和网络连接。", + "aiSettings.ollamaCloudKey": "Ollama Cloud 密钥", + "aiSettings.ollamaCloudKeyDescription": "Ollama Cloud 需要 API 密钥。", + "aiSettings.ollamaEndpointDescription": "Ollama API 的基础 URL(本地、局域网或云端)", + "aiSettings.ollamaLocalConnectionFailed": "无法连接。请确认 Ollama 正在此地址运行。", + "aiSettings.ollamaMode": "Ollama 模式", + "aiSettings.ollamaModeDescription": "在本地运行 Ollama 或使用 Ollama Cloud", + "aiSettings.openAiCompatibleBaseUrlDescription": "OpenAI 兼容端点基础 URL", + "aiSettings.optionalBearerDescription": "自定义端点的可选 Bearer 令牌", + "aiSettings.provider": "服务商", + "aiSettings.providerDescription": "选择 Lithe Agent 使用的服务商", + "aiSettings.refreshModelList": "刷新模型列表", + "aiSettings.removeOllamaApiKey": "移除 Ollama API 密钥", + "aiSettings.removeSavedApiKey": "移除已保存的 API 密钥", + "aiSettings.requiresOAuth": "需要 OAuth 认证", + "aiSettings.resetOllamaUrl": "将 Ollama URL 重置为默认值", + "aiSettings.resetToDefault": "重置为默认值", + "aiSettings.saved": "已保存", + "aiSettings.savedKeyDescription": "已安全存储。留空将保留现有密钥。", + "aiSettings.savedKeyPlaceholder": "•••••••• (已保存)", + "aiShortcut.findFixBug": "查找并修复 Bug", + "aiShortcut.findFixBugContent": "调查此 Bug,找出根因,实施修复并验证:", + "aiShortcut.planImplementation": "规划实现方案", + "aiShortcut.planImplementationContent": "查看相关代码,并为此任务提出聚焦的实现计划:", + "aiShortcut.reviewChanges": "审查当前更改", + "aiShortcut.reviewChangesContent": "审查当前工作区更改,查找 Bug、回归和缺失测试:", + "aiShortcut.writeTests": "为变更编写测试", + "aiShortcut.writeTestsContent": "查看相关行为,并为此变更添加聚焦测试:", + "commandPalette.actions.aiQuickEdit.label": "AI:快速编辑选区", + "commandPalette.actions.cliInstall.label": "CLI:安装终端命令", + "commandPalette.actions.colorTheme.label": "首选项:颜色主题", + "commandPalette.actions.databaseConnect.label": "数据库:显示数据库", + "commandPalette.actions.developerOpenLitheLog.label": "开发者:打开 Lithe 日志", + "commandPalette.actions.fileLocalHistory.label": "文件:显示本地历史", + "commandPalette.actions.fileNew.label": "文件:新建文件", + "commandPalette.actions.fileNewDocument.label": "文件:新建文档", + "commandPalette.actions.fileOpenProject.label": "文件:打开项目", + "commandPalette.actions.fileReopenWithEncoding.label": "文件:以编码重新打开", + "commandPalette.actions.fileSave.label": "文件:保存", + "commandPalette.actions.fileSaveAll.label": "文件:全部保存", + "commandPalette.actions.fileSaveAs.label": "文件:另存为", + "commandPalette.actions.fileSaveWithEncoding.label": "文件:以编码保存", + "commandPalette.actions.gitAddRemote.label": "Git:添加远程", + "commandPalette.actions.gitApplyStash.label": "Git:应用贮藏", + "commandPalette.actions.gitBranchManager.label": "Git:打开分支管理器", + "commandPalette.actions.gitCheckoutBranch.label": "Git:检出分支", + "commandPalette.actions.gitCommit.label": "Git:提交更改", + "commandPalette.actions.gitCompareTags.label": "Git:比较标签", + "commandPalette.actions.gitCreateBranch.label": "Git:创建分支", + "commandPalette.actions.gitCreateTag.label": "Git:创建标签", + "commandPalette.actions.gitDeleteBranch.label": "Git:删除分支", + "commandPalette.actions.gitDeleteTag.label": "Git:删除标签", + "commandPalette.actions.gitDiscardAll.label": "Git:丢弃所有更改", + "commandPalette.actions.gitDropStash.label": "Git:删除贮藏", + "commandPalette.actions.gitFetch.label": "Git:获取", + "commandPalette.actions.gitInitializeRepository.label": "Git:初始化仓库", + "commandPalette.actions.gitManageRemotes.label": "Git:管理远程", + "commandPalette.actions.gitManageTags.label": "Git:管理标签", + "commandPalette.actions.gitOpenLog.label": "Git:打开日志", + "commandPalette.actions.gitPopStash.label": "Git:弹出贮藏", + "commandPalette.actions.gitPull.label": "Git:拉取", + "commandPalette.actions.gitPush.label": "Git:推送", + "commandPalette.actions.gitRefresh.label": "Git:刷新状态", + "commandPalette.actions.gitRemoveRemote.label": "Git:移除远程", + "commandPalette.actions.gitSelectRepository.label": "Git:选择仓库", + "commandPalette.actions.gitShowBranchDiff.label": "Git:显示分支差异", + "commandPalette.actions.gitShowChanges.label": "Git:显示更改", + "commandPalette.actions.gitStageAll.label": "Git:暂存所有更改", + "commandPalette.actions.gitUnstageAll.label": "Git:取消暂存所有更改", + "commandPalette.actions.gitViewStashes.label": "Git:查看贮藏", + "commandPalette.actions.gitWorktreeManager.label": "Git:管理工作树", + "commandPalette.actions.githubCheckAuth.label": "GitHub:检查身份验证", + "commandPalette.actions.githubNewIssue.label": "GitHub:新建议题", + "commandPalette.actions.githubNewPullRequest.label": "GitHub:新建拉取请求", + "commandPalette.actions.githubRefresh.label": "GitHub:刷新当前视图", + "commandPalette.actions.githubRunWorkflow.label": "GitHub:运行工作流", + "commandPalette.actions.githubShowActions.label": "GitHub:显示 Actions", + "commandPalette.actions.githubShowIssues.label": "GitHub:显示议题", + "commandPalette.actions.githubShowPullRequests.label": "GitHub:显示拉取请求", + "commandPalette.actions.goToLine.label": "转到:行", + "commandPalette.actions.goToSymbolInEditor.label": "转到:编辑器中的符号", + "commandPalette.actions.iconTheme.label": "首选项:图标主题", + "commandPalette.actions.lsp.restartAllServers.label": "语言服务器:重启所有服务器", + "commandPalette.actions.lsp.stopAllServers.label": "语言服务器:停止所有服务器", + "commandPalette.actions.lspStatus.label": "LSP:显示状态", + "commandPalette.actions.markdownPreview.label": "Markdown:预览 Markdown", + "commandPalette.actions.openOnboarding.label": "帮助:打开引导", + "commandPalette.actions.openSettings.label": "首选项:打开设置", + "commandPalette.actions.openSettingsJson.label": "首选项:打开设置 JSON 文件", + "commandPalette.actions.openSettingsTabAdvanced.label": "首选项:打开高级设置", + "commandPalette.actions.openSettingsTabAppearance.label": "首选项:打开外观设置", + "commandPalette.actions.openSettingsTabEditor.label": "首选项:打开编辑器设置", + "commandPalette.actions.openSettingsTabFileExplorer.label": "首选项:打开文件设置", + "commandPalette.actions.openSettingsTabGeneral.label": "首选项:打开通用设置", + "commandPalette.actions.openSettingsTabGit.label": "首选项:打开 Git 设置", + "commandPalette.actions.openSettingsTabKeyboard.label": "首选项:打开键盘快捷键设置", + "commandPalette.actions.openSettingsTabTerminal.label": "首选项:打开终端设置", + "commandPalette.actions.openUrl.label": "视图:打开 URL...", + "commandPalette.actions.openWebViewer.label": "视图:打开 Web 查看器", + "commandPalette.actions.paneCloseEditorGroup.label": "视图:关闭编辑器组", + "commandPalette.actions.paneCloseOtherEditorGroups.label": "视图:关闭其他编辑器组", + "commandPalette.actions.paneMoveEditorNextGroup.label": "视图:移动编辑器到下一组", + "commandPalette.actions.paneMoveEditorPreviousGroup.label": "视图:移动编辑器到上一组", + "commandPalette.actions.paneResetEditorGroupSizes.label": "视图:重置编辑器组大小", + "commandPalette.actions.paneSplitEditorDown.label": "视图:向下拆分编辑器", + "commandPalette.actions.paneSplitEditorRight.label": "视图:向右拆分编辑器", + "commandPalette.actions.paneToggleEditorGroupLock.label": "视图:切换编辑器组锁定", + "commandPalette.actions.quickOpen.label": "转到:快速打开", + "commandPalette.actions.reportBug.label": "帮助:报告 Bug", + "commandPalette.actions.searchGlobal.label": "搜索:全局搜索", + "commandPalette.actions.showWhatsNew.label": "帮助:新增功能", + "commandPalette.actions.tabClose.label": "标签页:关闭标签页", + "commandPalette.actions.tabNext.label": "标签页:下一个标签页", + "commandPalette.actions.tabPrevious.label": "标签页:上一个标签页", + "commandPalette.actions.tabReopen.label": "标签页:重新打开已关闭标签页", + "commandPalette.actions.terminalFind.label": "终端:查找", + "commandPalette.actions.terminalNew.label": "终端:新建终端", + "commandPalette.actions.terminalResetZoom.label": "终端:重置缩放", + "commandPalette.actions.terminalSplitDown.label": "终端:向下拆分", + "commandPalette.actions.terminalSplitRight.label": "终端:向右拆分", + "commandPalette.actions.terminalZoomIn.label": "终端:放大", + "commandPalette.actions.terminalZoomOut.label": "终端:缩小", + "commandPalette.actions.toggleAiChatFeature.disableLabel": "功能:禁用 AI 聊天", + "commandPalette.actions.toggleAiChatFeature.enableLabel": "功能:启用 AI 聊天", + "commandPalette.actions.toggleAiChatView.disableLabel": "视图:隐藏 AI 聊天", + "commandPalette.actions.toggleAiChatView.enableLabel": "视图:显示 AI 聊天", + "commandPalette.actions.toggleAiCompletion.disableLabel": "AI:禁用 AI 补全", + "commandPalette.actions.toggleAiCompletion.enableLabel": "AI:启用 AI 补全", + "commandPalette.actions.toggleAutoCompletion.disableLabel": "语言:禁用自动补全", + "commandPalette.actions.toggleAutoCompletion.enableLabel": "语言:启用自动补全", + "commandPalette.actions.toggleAutoDetectLanguage.disableLabel": "语言:禁用自动检测语言", + "commandPalette.actions.toggleAutoDetectLanguage.enableLabel": "语言:启用自动检测语言", + "commandPalette.actions.toggleAutoSave.disableLabel": "通用:禁用自动保存", + "commandPalette.actions.toggleAutoSave.enableLabel": "通用:启用自动保存", + "commandPalette.actions.toggleBottomPane.disableLabel": "视图:隐藏底部面板", + "commandPalette.actions.toggleBottomPane.enableLabel": "视图:显示底部面板", + "commandPalette.actions.toggleBreadcrumbs.disableLabel": "功能:禁用面包屑", + "commandPalette.actions.toggleBreadcrumbs.enableLabel": "功能:启用面包屑", + "commandPalette.actions.toggleCodeLens.disableLabel": "语言:禁用 Code Lens", + "commandPalette.actions.toggleCodeLens.enableLabel": "语言:启用 Code Lens", + "commandPalette.actions.toggleCommandsPersistence.disableLabel": "功能:禁用持久命令", + "commandPalette.actions.toggleCommandsPersistence.enableLabel": "功能:启用持久命令", + "commandPalette.actions.toggleCompactMenuBar.disableLabel": "视图:使用完整菜单栏", + "commandPalette.actions.toggleCompactMenuBar.enableLabel": "视图:使用紧凑菜单栏", + "commandPalette.actions.toggleDebuggerFeature.disableLabel": "功能:禁用调试器", + "commandPalette.actions.toggleDebuggerFeature.enableLabel": "功能:启用调试器", + "commandPalette.actions.toggleDiagnostics.disableLabel": "功能:禁用诊断", + "commandPalette.actions.toggleDiagnostics.enableLabel": "功能:启用诊断", + "commandPalette.actions.toggleDiagnosticsPanel.label": "视图:显示诊断", + "commandPalette.actions.toggleFindView.label": "视图:查找", + "commandPalette.actions.toggleFormatOnSave.disableLabel": "语言:禁用保存时格式化", + "commandPalette.actions.toggleFormatOnSave.enableLabel": "语言:启用保存时格式化", + "commandPalette.actions.toggleGitFeature.disableLabel": "功能:禁用 Git", + "commandPalette.actions.toggleGitFeature.enableLabel": "功能:启用 Git", + "commandPalette.actions.toggleInlayHints.disableLabel": "语言:禁用内嵌提示", + "commandPalette.actions.toggleInlayHints.enableLabel": "语言:启用内嵌提示", + "commandPalette.actions.toggleLineNumbers.disableLabel": "编辑器:隐藏行号", + "commandPalette.actions.toggleLineNumbers.enableLabel": "编辑器:显示行号", + "commandPalette.actions.toggleNativeMenuBar.disableLabel": "视图:隐藏原生菜单栏", + "commandPalette.actions.toggleNativeMenuBar.enableLabel": "视图:显示原生菜单栏", + "commandPalette.actions.toggleOutlineFeature.disableLabel": "功能:禁用大纲", + "commandPalette.actions.toggleOutlineFeature.enableLabel": "功能:启用大纲", + "commandPalette.actions.toggleParameterHints.disableLabel": "语言:禁用参数提示", + "commandPalette.actions.toggleParameterHints.enableLabel": "语言:启用参数提示", + "commandPalette.actions.toggleRelativeLineNumbers.disableLabel": "编辑器:禁用相对行号", + "commandPalette.actions.toggleRelativeLineNumbers.enableLabel": "编辑器:启用相对行号", + "commandPalette.actions.toggleRemoteFeature.disableLabel": "功能:禁用远程开发", + "commandPalette.actions.toggleRemoteFeature.enableLabel": "功能:启用远程开发", + "commandPalette.actions.toggleRun.disableLabel": "视图:隐藏运行", + "commandPalette.actions.toggleRun.enableLabel": "视图:显示运行", + "commandPalette.actions.toggleSearchFeature.disableLabel": "功能:禁用搜索", + "commandPalette.actions.toggleSearchFeature.enableLabel": "功能:启用搜索", + "commandPalette.actions.toggleSemanticTokens.disableLabel": "语言:禁用语义标记", + "commandPalette.actions.toggleSemanticTokens.enableLabel": "语言:启用语义标记", + "commandPalette.actions.toggleShowMinimap.disableLabel": "编辑器:隐藏缩略图", + "commandPalette.actions.toggleShowMinimap.enableLabel": "编辑器:显示缩略图", + "commandPalette.actions.toggleSidebar.disableLabel": "视图:隐藏辅助侧栏", + "commandPalette.actions.toggleSidebar.enableLabel": "视图:显示辅助侧栏", + "commandPalette.actions.toggleTerminal.disableLabel": "视图:隐藏终端", + "commandPalette.actions.toggleTerminal.enableLabel": "视图:显示终端", + "commandPalette.actions.toggleTerminalFeature.disableLabel": "功能:禁用终端", + "commandPalette.actions.toggleTerminalFeature.enableLabel": "功能:启用终端", + "commandPalette.actions.toggleVimMode.label": "Vim 模式:切换", + "commandPalette.actions.toggleWordWrap.disableLabel": "编辑器:禁用自动换行", + "commandPalette.actions.toggleWordWrap.enableLabel": "编辑器:启用自动换行", + "commandPalette.actions.viewResetZoom.label": "编辑器:重置缩放", + "commandPalette.actions.viewShowFiles.label": "视图:显示文件", + "commandPalette.actions.viewShowGit.label": "视图:显示 Git", + "commandPalette.actions.viewShowOutline.label": "视图:显示大纲", + "commandPalette.actions.viewZoomIn.label": "编辑器:放大", + "commandPalette.actions.viewZoomOut.label": "编辑器:缩小", + "commandPalette.actions.vimInsertMode.label": "Vim:进入插入模式", + "commandPalette.actions.vimNormalMode.label": "Vim:进入普通模式", + "commandPalette.actions.vimVisualMode.label": "Vim:进入可视模式", + "commandPalette.actions.windowEnterFullscreen.label": "窗口:进入全屏", + "commandPalette.actions.windowExitFullscreen.label": "窗口:退出全屏", + "commandPalette.actions.windowMaximize.label": "窗口:最大化", + "commandPalette.actions.windowMinimize.label": "窗口:最小化", + "commandPalette.actions.windowRestore.label": "窗口:还原", + "commandPalette.backToCommands": "返回命令", + "commandPalette.categories.AI": "AI", + "commandPalette.categories.Advanced": "高级", + "commandPalette.categories.CLI": "CLI", + "commandPalette.categories.Developer": "开发者", + "commandPalette.categories.Editor": "编辑器", + "commandPalette.categories.Features": "功能", + "commandPalette.categories.File": "文件", + "commandPalette.categories.Generate": "生成", + "commandPalette.categories.Git": "Git", + "commandPalette.categories.GitHub": "GitHub", + "commandPalette.categories.LSP": "LSP", + "commandPalette.categories.Language": "语言", + "commandPalette.categories.Markdown": "Markdown", + "commandPalette.categories.Navigation": "导航", + "commandPalette.categories.Settings": "设置", + "commandPalette.categories.Terminal": "终端", + "commandPalette.categories.Theme": "主题", + "commandPalette.categories.View": "视图", + "commandPalette.categories.Vim": "Vim", + "commandPalette.categories.Window": "窗口", + "commandPalette.categories.languageServer": "语言服务器", + "commandPalette.clearPersistedActions": "清除持久命令", + "commandPalette.close": "关闭命令面板", + "commandPalette.current": "当前", + "commandPalette.failedToImportTheme": "无法导入主题", + "commandPalette.importedTheme": "已导入 {name}", + "commandPalette.importedThemeVariants": "已导入 {count} 个主题变体", + "commandPalette.noCommands": "未找到命令", + "commandPalette.noIconThemes": "未找到图标主题", + "commandPalette.noThemes": "未找到主题", + "commandPalette.openAppearanceSettings": "打开外观设置", + "commandPalette.placeholder": "输入命令...", + "commandPalette.searchIconThemes": "搜索图标主题...", + "commandPalette.searchThemes": "搜索主题...", + "commandPalette.title": "命令面板", + "commandPalette.uploadTheme": "上传主题", + "csv.auto": "自动", + "csv.changeDelimiter": "更改分隔符", + "csv.comma": "逗号", + "csv.copyAsCsv": "复制为 CSV", + "csv.copyAsJson": "复制为 JSON", + "csv.delimiter": "分隔符", + "csv.headerOff": "表头关闭", + "csv.headerOn": "表头开启", + "csv.pipe": "竖线", + "csv.rowsColumns": "{rows} 行 • {columns} 列", + "csv.semicolon": "分号", + "csv.tab": "制表符", + "csv.toggleHeaderRow": "切换表头行", + "customTheme.baseTheme": "基础主题", + "customTheme.createTitle": "创建主题", + "customTheme.defaultName": "我的 Lithe 主题", + "customTheme.description": "从已安装主题开始,然后在保存或安装前编辑生成的 JSON。", + "customTheme.filterAllFiles": "所有文件", + "customTheme.filterLitheTheme": "Lithe 主题", + "customTheme.generateFailed": "生成主题文件失败。", + "customTheme.id": "ID", + "customTheme.install": "安装主题", + "customTheme.installFailed": "安装主题失败。", + "customTheme.installed": "已安装 {theme}", + "customTheme.installedVariants": "已安装 {count} 个主题变体", + "customTheme.installing": "正在安装...", + "customTheme.jsonSaved": "主题 JSON 已保存", + "customTheme.name": "名称", + "customTheme.saveJson": "保存 JSON", + "customTheme.saveJsonFailed": "保存主题 JSON 失败", + "customTheme.themeJson": "主题 JSON", + "database.connect": "连接", + "database.host": "主机", + "database.optional": "可选", + "database.password": "密码", + "database.port": "端口", + "database.username": "用户名", + "debugger.adapterOutputAppearsHere": "适配器输出会显示在这里。", + "debugger.addExpression": "添加表达式", + "debugger.addWatch": "添加监视", + "debugger.applyCodeChanges": "应用代码更改", + "debugger.applyCodeChangesHelp": "保存、编译并将代码更改应用到当前 Java 服务", + "debugger.applyingCodeChanges": "正在应用…", + "debugger.breakpoints": "断点", + "debugger.breakpointsEmpty": "点击行号槽或切换当前行断点。", + "debugger.clearBreakpoints": "清空断点", + "debugger.clearConsole": "清空控制台", + "debugger.clearDebugConsole": "清空调试控制台", + "debugger.clearWatchExpressions": "清空监视表达式", + "debugger.command": "命令", + "debugger.commandToRun": "要运行的命令", + "debugger.configuration": "配置", + "debugger.console": "控制台", + "debugger.continue": "继续", + "debugger.continueDebugging": "继续调试", + "debugger.debugConfiguration": "调试配置", + "debugger.disableBreakpoint": "禁用断点", + "debugger.empty": "空", + "debugger.enableBreakpoint": "启用断点", + "debugger.evaluating": "正在求值...", + "debugger.hideRunAndDebug": "隐藏运行和调试", + "debugger.into": "进入", + "debugger.lineNumber": "第 {line} 行", + "debugger.loadingVariable": "正在加载变量", + "debugger.noCommandAvailable": "没有可用命令", + "debugger.noLaunchJsonFound": "未找到 launch.json", + "debugger.notEvaluated": "未求值", + "debugger.openProjectToLoadLaunchJson": "打开项目以加载 launch.json", + "debugger.out": "跳出", + "debugger.over": "跳过", + "debugger.pause": "暂停", + "debugger.pauseDebugging": "暂停调试", + "debugger.paused": "已暂停", + "debugger.refreshWatches": "刷新监视", + "debugger.removeBreakpoint": "移除断点", + "debugger.removeWatch": "移除监视", + "debugger.restartService": "重启服务", + "debugger.runAndDebug": "运行和调试", + "debugger.showRunAndDebug": "显示运行和调试", + "debugger.stack": "调用栈", + "debugger.stackEmpty": "启动会话后可查看栈帧。", + "debugger.start": "启动", + "debugger.statusExited": "已退出", + "debugger.statusFailed": "失败", + "debugger.statusIdle": "空闲", + "debugger.statusPaused": "已暂停", + "debugger.statusRunning": "运行中", + "debugger.statusStopped": "已停止", + "debugger.stepInto": "单步进入", + "debugger.stepOut": "单步跳出", + "debugger.stepOver": "单步跳过", + "debugger.stop": "停止", + "debugger.threads": "线程", + "debugger.threadsAndVariables": "线程和变量", + "debugger.threadsEmpty": "暂停进程后可查看线程。", + "debugger.toggleCurrentLineBreakpoint": "切换当前行断点", + "debugger.toggleRunAndDebug": "切换运行和调试", + "debugger.variables": "变量", + "debugger.variablesEmpty": "暂停在栈帧上以查看变量值。", + "debugger.watch": "监视", + "debugger.watchEmpty": "添加表达式,以便暂停时查看。", + "diagnostics.all": "全部诊断", + "diagnostics.clearSourceFilter": "清除来源筛选({source})", + "diagnostics.clearSourceFilterSimple": "清除来源筛选", + "diagnostics.closePane": "关闭问题面板", + "diagnostics.copyDetails": "复制完整详情", + "diagnostics.copyLocation": "复制位置", + "diagnostics.copyMessage": "复制消息", + "diagnostics.detailsCopied": "已复制诊断详情", + "diagnostics.disableWrap": "禁用消息换行", + "diagnostics.editorWidth": "编辑器宽度", + "diagnostics.empty": "未检测到问题", + "diagnostics.enableWrap": "启用消息换行", + "diagnostics.errors": "错误", + "diagnostics.exitFullScreen": "退出全屏", + "diagnostics.filesAria": "诊断文件", + "diagnostics.filter": "筛选问题", + "diagnostics.filterBySource": "按来源筛选:{source}", + "diagnostics.fullScreen": "全屏", + "diagnostics.fullWidth": "全宽", + "diagnostics.goToProblem": "转到问题", + "diagnostics.groupByFile": "分组:文件", + "diagnostics.groupByNone": "不分组", + "diagnostics.groupBySeverity": "分组:严重程度", + "diagnostics.hideFiles": "隐藏文件", + "diagnostics.info": "信息", + "diagnostics.loadingFixes": "正在加载快速修复...", + "diagnostics.locationCopied": "已复制诊断位置", + "diagnostics.messageCopied": "已复制诊断消息", + "diagnostics.noFixes": "没有可用的快速修复", + "diagnostics.noMatch": "没有符合当前筛选条件的问题", + "diagnostics.onlyCurrentFile": "仅当前文件", + "diagnostics.problemCount": "{count} 个问题", + "diagnostics.problemCountOne": "{count} 个问题", + "diagnostics.resetAllFilters": "重置全部筛选", + "diagnostics.resetFilters": "重置筛选", + "diagnostics.search": "搜索问题", + "diagnostics.severityCount": "{label} ({visible}/{total})", + "diagnostics.showFiles": "显示文件", + "diagnostics.sortByFile": "排序:文件", + "diagnostics.sortByPosition": "排序:位置", + "diagnostics.sortBySeverity": "排序:严重程度", + "diagnostics.warnings": "警告", + "docker.actionFailed": "操作失败", + "docker.actionsFor": "{name} 的操作", + "docker.all": "全部", + "docker.automated": "自动构建", + "docker.build": "构建", + "docker.buildAndRunLocalImages": "构建并运行本地镜像", + "docker.buildArgs": "构建参数", + "docker.buildDockerImage": "构建 Docker 镜像", + "docker.buildPresetName": "构建预设名称", + "docker.buildPresets": "构建预设", + "docker.cleanupCompleted": "Docker {target} 清理完成。", + "docker.cliUnavailable": "Docker CLI 不可用", + "docker.cliUnavailableDescription": "请安装 Docker Desktop,或将 Docker CLI 添加到 PATH 后重试。", + "docker.command": "命令", + "docker.compose": "Compose", + "docker.composePresetCompleted": "Docker Compose 预设 {name} 已完成。", + "docker.composePresetName": "Compose 预设名称", + "docker.composePresets": "Compose 预设", + "docker.composeProject": "Compose 项目", + "docker.composeProjectActions": "Compose 项目操作", + "docker.composeUnavailable": "Compose 不可用", + "docker.composeUnavailableDescription": "无法加载 Compose 资源。", + "docker.confirmDockerCleanup": "确认 Docker 清理", + "docker.containerActions": "容器操作", + "docker.containerDestinationPath": "容器目标路径", + "docker.containerName": "容器名称", + "docker.containers": "容器", + "docker.contextPath": "上下文路径", + "docker.copiedToContainer": "已将 {source} 复制到 {target}。", + "docker.copiedToHost": "已将 {source} 复制到 {target}。", + "docker.copy": "复制", + "docker.copyFileToHost": "将 {name} 复制到主机", + "docker.copyFromContainer": "从容器复制", + "docker.copyIn": "复制进容器", + "docker.copyToContainer": "复制到容器", + "docker.copyToHost": "复制到主机", + "docker.copyToHostPath": "复制到主机路径", + "docker.debug": "调试", + "docker.debugCommand": "调试命令", + "docker.debugInContainer": "在容器中调试", + "docker.debugPresetName": "调试预设名称", + "docker.debugPresetRequiresRunningContainer": "Docker 调试预设需要正在运行的容器。", + "docker.debugPresets": "调试预设", + "docker.delete": "删除", + "docker.deleteEnvFile": "删除 Env 文件", + "docker.details": "详情", + "docker.devContainers": "Dev Containers", + "docker.directory": "目录", + "docker.dismiss": "关闭", + "docker.dockerHub": "Docker Hub", + "docker.envFile": "env 文件", + "docker.envFilePath": "Env 文件路径", + "docker.envFiles": "Env 文件", + "docker.environment": "环境变量", + "docker.files": "文件", + "docker.hostFileOrFolderPath": "主机文件或文件夹路径", + "docker.image": "镜像", + "docker.imageActions": "镜像操作", + "docker.imageBuildCompleted": "Docker 镜像构建完成。", + "docker.imageExamplePlaceholder": "镜像,例如 nginx:latest", + "docker.images": "镜像", + "docker.launchConfigs": "启动配置", + "docker.loadingCompose": "正在加载 Compose", + "docker.loadingFiles": "正在加载文件...", + "docker.loadingProjectConfig": "正在加载项目配置", + "docker.loadingResources": "正在加载资源", + "docker.logFilterAll": "全部", + "docker.logFilterErr": "Err", + "docker.logFilterErrors": "错误", + "docker.login": "登录", + "docker.logs": "日志", + "docker.logsStopped": "日志已停止", + "docker.networks": "网络", + "docker.noComposeFiles": "未找到 Compose 文件。", + "docker.noComposeServicesFound": "未找到 Compose 服务", + "docker.noEnvFilesOrPresets": "未找到 env 文件或预设。", + "docker.noFilesFound": "未找到文件。", + "docker.noMatchingComposeServices": "没有匹配的 Compose 服务", + "docker.noMatchingContainers": "没有匹配的容器", + "docker.noMatchingImages": "没有匹配的镜像", + "docker.noMatchingLogLines": "没有匹配的日志行。", + "docker.noMatchingNetworks": "没有匹配的网络", + "docker.noMatchingVolumes": "没有匹配的卷", + "docker.notRunning": "Docker 未运行", + "docker.notRunningDescription": "请启动 Docker Desktop,然后刷新此视图。", + "docker.official": "官方", + "docker.offlineProjectFilesAvailable": "Docker 离线。仍可查看项目文件和预设。", + "docker.open": "打开", + "docker.openEnvFile": "打开 Env 文件", + "docker.openServiceUrl": "打开服务 URL", + "docker.openShell": "打开 shell", + "docker.openWorkspaceInspectCompose": "打开工作区以检查 Compose 文件。", + "docker.openWorkspaceManagePresets": "打开工作区以管理 Docker 预设。", + "docker.opened": "已打开 {name}。", + "docker.opening": "正在打开...", + "docker.password": "密码", + "docker.pause": "暂停", + "docker.ports": "端口", + "docker.project": "项目", + "docker.projectActionFailed": "项目操作失败", + "docker.projectActions": "项目操作", + "docker.prune": "清理", + "docker.pruneTarget": "清理 {target}", + "docker.pruneTargetWithVolumes": "{target} 和卷", + "docker.pull": "拉取", + "docker.pulledImage": "已拉取 {image}。", + "docker.push": "推送", + "docker.pushedImage": "已推送 {image}。", + "docker.rebuild": "重新构建", + "docker.refresh": "刷新", + "docker.refreshFiles": "刷新文件", + "docker.registry": "Registry", + "docker.registryActionFailed": "Registry 操作失败", + "docker.registryImage": "Registry 镜像", + "docker.registryLogin": "Registry 登录", + "docker.registryLoginCompleted": "Docker Registry 登录完成。", + "docker.registryOfflineNotice": "搜索和登录仍可使用。启动 Docker 后可拉取、推送或标记镜像。", + "docker.registryOptional": "Registry(可选)", + "docker.remove": "移除", + "docker.removed": "已移除 {name}。", + "docker.resources": "资源", + "docker.restart": "重启", + "docker.retry": "重试", + "docker.run": "运行", + "docker.runDockerImage": "运行 Docker 镜像", + "docker.runPresetName": "运行预设名称", + "docker.runPresets": "运行预设", + "docker.running": "运行中", + "docker.runningEllipsis": "运行中...", + "docker.saveBuildPreset": "保存构建预设", + "docker.saveComposePreset": "保存 Compose 预设", + "docker.saveDebug": "保存调试", + "docker.saveDebugPreset": "保存调试预设", + "docker.savePreset": "保存预设", + "docker.saveRunPreset": "保存运行预设", + "docker.searchDocker": "搜索 Docker", + "docker.searchDockerHub": "搜索 Docker Hub", + "docker.searchDockerHubToFindImages": "搜索 Docker Hub 以查找镜像", + "docker.searchDockerResources": "搜索 Docker 资源", + "docker.searchImages": "搜索镜像", + "docker.searchLogs": "搜索日志", + "docker.section.cleanup": "清理", + "docker.section.compose": "Compose", + "docker.section.containers": "容器", + "docker.section.images": "镜像", + "docker.section.networks": "网络", + "docker.section.project": "项目", + "docker.section.registry": "Registry", + "docker.section.volumes": "卷", + "docker.selectRunningContainerBeforeDebugPreset": "启动 Docker 调试预设前,请先选择一个正在运行的容器。", + "docker.serviceActions": "服务操作", + "docker.servicesCount": "{count} 个服务", + "docker.starsCount": "{count} stars", + "docker.start": "启动", + "docker.startDockerBeforeBuilding": "构建此镜像前请先启动 Docker。你仍可将这些值保存为预设。", + "docker.startDockerBeforeRunning": "运行此镜像前请先启动 Docker。你仍可将这些值保存为预设。", + "docker.startWithEnvFiles": "使用 env 文件启动", + "docker.startedImage": "已启动 {image}。", + "docker.stop": "停止", + "docker.stopProject": "停止项目", + "docker.stopped": "已停止", + "docker.streamingLogs": "正在流式显示日志", + "docker.system": "系统", + "docker.tag": "标记", + "docker.taggedImage": "已将 {source} 标记为 {target}。", + "docker.targetTag": "目标标签", + "docker.technicalDetails": "技术详情", + "docker.typeDeleteToRemove": "输入 delete 以移除 {path}", + "docker.typePruneToClean": "输入 prune 以清理 Docker {target}", + "docker.unavailable": "Docker 不可用", + "docker.unavailableDescription": "无法连接到此机器上的 Docker。", + "docker.unpause": "恢复", + "docker.up": "上级", + "docker.usePreset": "使用预设", + "docker.username": "用户名", + "docker.variable": "变量", + "docker.variables": "变量", + "docker.viewOptions": "视图选项", + "docker.volumes": "卷", + "docker.waitingForLogs": "正在等待日志。", + "docker.workingDirectory": "工作目录", + "docker.workspace": "工作区", + "editor.actions": "编辑器操作", + "editor.addSelectionToNextMatch": "将所选添加到下一处匹配", + "editor.aiInlineEdit": "AI 行内编辑", + "editor.autoSaveFailed": "无法自动保存“{name}”。更改仍保留在编辑器中。", + "editor.contentNotLoaded": "文件“{name}”尚未加载完成。请等待加载完成或重试后再保存或编辑。", + "editor.duplicateLine": "复制行", + "editor.enableLargeFileServices": "仍然启用", + "editor.encodingDiscard": "放弃更改", + "editor.encodingFailed": "无法使用所选编码读取文件。", + "editor.encodingReopenDirty": "以其他编码重新打开前,是否先保存此文件?", + "editor.encodingReopenTitle": "以编码重新打开", + "editor.externalConflict": "该文件已在 Lithe 外部更改,同时编辑器中还有未保存的修改。", + "editor.externalDeleted": "该文件已被外部删除,编辑器内容已保留。", + "editor.externalReadFailed": "无法检查磁盘变化。请检查文件权限,重新激活窗口后重试。", + "editor.find": "查找", + "editor.findAllReferences": "查找全部引用", + "editor.findInFile": "在文件中查找", + "editor.formatDocument": "格式化文档", + "editor.formatSelection": "格式化所选内容", + "editor.goToDefinition": "转到定义", + "editor.goToLine": "转到行", + "editor.goToLineColumn": "转到行和列", + "editor.goToTypeDefinition": "转到类型定义", + "editor.indent": "增加缩进", + "editor.inlineGitBlame": "行内 Git Blame", + "editor.keepEditorVersion": "保留编辑器内容", + "editor.largeFileServicesDisabled": "此大文件已关闭语言智能、Git Blame、代码镜头和语义高亮。", + "editor.loadDiskVersion": "加载磁盘版本", + "editor.markdownPreviewView": "预览", + "editor.markdownSourceView": "编辑", + "editor.markdownSplitView": "编辑与预览", + "editor.markdownViewModes": "Markdown 视图", + "editor.minimap": "缩略图", + "editor.moveLineDown": "下移行", + "editor.moveLineUp": "上移行", + "editor.noLanguages": "未找到语言", + "editor.openHtmlInBrowser": "在浏览器中打开 HTML", + "editor.openHtmlInBrowserFailed": "无法在浏览器中打开 HTML", + "editor.outdent": "减少缩进", + "editor.plainText": "纯文本", + "editor.preferences": "编辑器首选项", + "editor.preview": "预览", + "editor.quickFix": "快速修复...", + "editor.recreateFile": "允许重新创建文件", + "editor.renameSymbol": "重命名符号", + "editor.reopenWithEncoding": "以编码重新打开", + "editor.restoreLoadFailed": "无法加载此文件。", + "editor.saveFailed": "无法保存“{name}”。请检查文件是否可写,然后重试。", + "editor.saveWithEncoding": "以编码保存", + "editor.selectAll": "全选", + "editor.selectAllOccurrences": "选择全部出现位置", + "editor.selectLanguage": "选择语言模式", + "editor.showHover": "显示悬停信息", + "editor.symbolPath": "符号路径", + "editor.title": "编辑器", + "editor.toggleBookmark": "切换书签", + "editor.toggleCase": "切换大小写", + "editor.toggleComment": "切换注释", + "editor.triggerSuggest": "触发建议", + "editor.unknownFile": "未知", + "editorCommands.appliedAction": "已应用:{title}", + "editorCommands.chooseQuickFixPrompt": "选择快速修复:", + "editorCommands.documentAlreadyFormatted": "文档已是格式化状态。", + "editorCommands.documentFormatted": "文档已格式化。", + "editorCommands.formattingFailed": "格式化失败。", + "editorCommands.noDiagnosticAtCursor": "光标处没有诊断。", + "editorCommands.noEditableFileForQuickFixes": "没有可用于快速修复的可编辑文件。", + "editorCommands.noEditableFileToFormat": "没有可格式化的可编辑文件。", + "editorCommands.noFoldableEditor": "没有活动的可折叠编辑器。", + "editorCommands.noFormatterConfigured": "没有为此文件类型配置格式化程序。", + "editorCommands.noQuickFixes": "没有可用的快速修复。", + "editorCommands.preferredAction": "{title}(首选)", + "editorCommands.selectTextToFormat": "请选择要格式化的文本。", + "editorCommands.selectionAlreadyFormatted": "选区已是格式化状态。", + "editorCommands.selectionFormatted": "选区已格式化。", + "editorCommands.selectionFormattingFailed": "选区格式化失败。", + "editorCommands.unableToApplyAction": "无法应用操作:{title}", + "extensions.activate": "启用", + "extensions.activateAndUse": "启用并使用", + "extensions.activateFailed": "启用 {name} 失败:{message}", + "extensions.activated": "{name} 已启用", + "extensions.active": "使用中", + "extensions.add": "添加", + "extensions.addedSuccessfully": "{name} 添加成功", + "extensions.agentFallbackDescription": "兼容 ACP 的编码代理", + "extensions.agents": "代理", + "extensions.ai": "AI", + "extensions.all": "全部", + "extensions.alreadyInstalled": "{name} 已安装", + "extensions.availableCount": "{count} 个可用", + "extensions.builtIn": "内置", + "extensions.byPublisher": "作者:{publisher}", + "extensions.cannotInstallAutomatically": "{name} 无法自动安装", + "extensions.category.agent": "代理", + "extensions.category.ai": "AI", + "extensions.category.iconTheme": "图标主题", + "extensions.category.integration": "集成", + "extensions.category.language": "语言", + "extensions.category.skill": "技能", + "extensions.category.theme": "主题", + "extensions.contributions": "贡献项", + "extensions.crashed": "{name} 已崩溃", + "extensions.current": "当前", + "extensions.currentName": "当前:{name}", + "extensions.deactivate": "停用", + "extensions.deactivateFailed": "停用 {name} 失败:{message}", + "extensions.deactivated": "{name} 已停用", + "extensions.disabled": "已禁用", + "extensions.extensionError": "扩展错误", + "extensions.globalInstallStillDetected": "仍在 PATH 中检测到全局安装。", + "extensions.iconThemeFallbackDescription": "{name} 图标主题", + "extensions.iconThemes": "图标主题", + "extensions.importPackage": "导入插件包", + "extensions.install": "安装", + "extensions.installFailed": "安装扩展失败:{message}", + "extensions.installFailedGeneric": "安装失败", + "extensions.installFailedWithMessage": "安装 {name} 失败:{message}", + "extensions.installPrompt": "未安装 {name} 扩展。是否安装以启用语言支持?", + "extensions.installSuccess": "{name} 已安装成功!", + "extensions.installed": "已安装", + "extensions.installedCount": "{count} 个已安装", + "extensions.installedSuccessfully": "{name} 安装成功", + "extensions.installing": "正在安装 {name}...", + "extensions.integrations": "集成", + "extensions.languages": "语言", + "extensions.loadingAgents": "正在加载代理", + "extensions.loadingExtension": "正在加载扩展", + "extensions.loadingSkills": "正在加载技能", + "extensions.local": "本地", + "extensions.managedInstallRemoved": "{name} 的托管安装已移除", + "extensions.marketplace": "市场", + "extensions.newSkill": "新建技能", + "extensions.noneFound": "未找到扩展。", + "extensions.noneSelected": "未选择扩展。", + "extensions.notFound": "未找到扩展“{id}”", + "extensions.packageTooLarge": "插件包不能超过 256 KB", + "extensions.reinstall": "重新安装", + "extensions.remove": "移除", + "extensions.removedSuccessfully": "{name} 移除成功", + "extensions.resetFailed": "重置 {name} 失败:{message}", + "extensions.resetToMarketplaceSuccess": "{name} 已重置为市场版本", + "extensions.resetToMarketplaceVersion": "重置为市场版本", + "extensions.retryLoading": "重试加载 {name}", + "extensions.reusableSkillDescription": "可复用的 AI 聊天指令", + "extensions.searchPlaceholder": "搜索扩展", + "extensions.selected": "已选择 {name}", + "extensions.skills": "技能", + "extensions.themeFallbackDescription": "{category} 主题", + "extensions.themes": "主题", + "extensions.title": "扩展", + "extensions.unavailable": "不可用", + "extensions.unexpectedError": "发生了意外错误", + "extensions.uninstall": "卸载", + "extensions.uninstallFailed": "卸载 {name} 失败:{message}", + "extensions.uninstalledSuccessfully": "{name} 卸载成功", + "extensions.unknownError": "未知错误", + "extensions.update": "更新", + "extensions.updateActionFailed": "更新 {name} 失败:{message}", + "extensions.updateFailed": "更新 {name} 失败:{message}", + "extensions.updatedSuccessfully": "{name} 更新成功", + "extensions.updatedWithLocalOverride": "{name} 已更新,并保留本地覆盖", + "extensions.updatesCount": "{count} 个更新", + "extensions.use": "使用", + "extensions.useFailed": "使用 {name} 失败:{message}", + "extensions.useName": "使用 {name}", + "extensions.you": "你", + "fileExplorer.ariaLabel": "文件资源管理器", + "fileExplorer.cannotCreateFile": "无法创建文件", + "fileExplorer.cannotDetermineCreateLocation": "无法确定文件创建位置。", + "fileExplorer.deleteFile": "删除文件", + "fileExplorer.deleteFileMessage": "确定要删除文件“{name}”吗?此操作无法撤销。", + "fileExplorer.deleteFolder": "删除文件夹", + "fileExplorer.deleteFolderMessage": "确定要删除文件夹“{name}”及其所有内容吗?此操作无法撤销。", + "fileExplorer.deleting": "正在删除...", + "fileExplorer.fileAlreadyExists": "文件已存在", + "fileExplorer.fileAlreadyExistsMessage": "已存在同名文件。", + "fileExplorer.fileIcons": "文件图标", + "fileExplorer.folderAlreadyExists": "文件夹已存在", + "fileExplorer.folderAlreadyExistsMessage": "已存在同名文件夹。", + "fileExplorer.folderIsEmpty": "文件夹为空", + "fileExplorer.gitStatusDecorations": "Git 状态标记", + "fileExplorer.gitignoredFiles": "被 Git 忽略的文件", + "fileExplorer.indentGuides": "缩进参考线", + "fileExplorer.indentation": "缩进", + "fileExplorer.indentationCompact": "紧凑", + "fileExplorer.indentationDefault": "默认", + "fileExplorer.indentationSpacious": "宽松", + "fileExplorer.indentationWide": "宽", + "fileExplorer.moveFailed": "移动失败", + "fileExplorer.noFolderOpen": "未打开文件夹", + "fileExplorer.noMatchingFiles": "没有匹配的文件", + "fileExplorer.ok": "确定", + "fileExplorer.openAllFilesMessage": "将打开 {count} 个文件到标签页。是否继续?", + "fileExplorer.opening": "正在打开...", + "fileExplorer.preferences": "文件资源管理器偏好设置", + "fileExplorer.searchFiles": "搜索文件", + "fileExplorer.searchingFiles": "正在搜索文件", + "fileExplorer.visibility": "可见性", + "fileNavigator.fileTree": "文件树", + "fileNavigator.files": "文件", + "fileNavigator.flatList": "平铺列表", + "fileNavigator.noFilesMatch": "没有匹配的文件", + "fileNavigator.resizeAria": "调整文件导航器大小", + "fileNavigator.searchResultFiles": "搜索结果文件", + "fileNavigator.showingCount": "显示 {visible} / {total}", + "fileNavigator.viewAria": "文件导航器视图", + "fileSystem.addFolderLocalOnly": "添加文件夹到工作区仅适用于本地文件夹。", + "fileSystem.addFolderToWorkspaceFailed": "添加文件夹到工作区失败:{path}", + "fileSystem.folderAddedToWorkspace": "已将“{name}”添加到工作区。", + "fileSystem.folderAlreadyInWorkspace": "该文件夹已在此工作区中。", + "fileSystem.folderRemovedFromWorkspace": "已从工作区移除“{name}”。", + "fileSystem.noSupportedDroppedPaths": "无法打开拖放的文件或文件夹。", + "fileSystem.openFileFailed": "打开文件 {file} 失败:{error}", + "fileSystem.openFolderFailed": "打开文件夹失败:{path}", + "fileSystem.openRemoteProjectFailed": "打开远程项目失败。", + "fileSystem.primaryFolderCannotBeRemoved": "无法移除主工作区文件夹。", + "fileSystem.recentProjectNotFolder": "最近项目不是文件夹:{path}。是否从最近项目列表中移除?", + "fileSystem.recentProjectUnavailable": "路径 {path} 不存在。如果它在可移动或网络驱动器上,请确认驱动器已连接;也可以将它从最近项目列表中移除。", + "fileSystem.recentProjectUnavailableBadge": "不可用", + "fileSystem.recentProjectUnavailableTitle": "最近项目不可用", + "fileSystem.remoteSessionRestoreFailed": "远程工作区已打开,但无法恢复保存的标签页。", + "fileSystem.removeFolderFromWorkspaceFailed": "无法移除“{name}”,因为未能停止其文件监听。", + "fileSystem.removeFromList": "从列表中移除", + "fileSystem.removedMissingProject": "已移除缺失项目“{name}”。", + "fileSystem.restoreWorkspaceFolderFailed": "无法恢复工作区文件夹“{name}”。", + "fileSystem.revealLocalOnly": "在文件夹中显示仅适用于本地工作区。", + "fileSystem.switchProjectFailed": "切换项目失败。", + "fileSystem.workspaceSessionRestoreFailed": "工作区已打开,但无法恢复保存的标签页。", + "fileSystem.wslSessionRestoreFailed": "WSL 工作区已打开,但无法恢复保存的标签页。", + "files.addFolderToWorkspace": "将文件夹添加到工作区", + "files.cancel": "取消", + "files.chooseDifferentEnvFileName": "请选择其他 env 文件名", + "files.collapseAll": "全部折叠", + "files.copy": "复制", + "files.copyContent": "复制内容", + "files.copyPath": "复制路径", + "files.copyRelativePath": "复制相对路径", + "files.createEnvDevelopment": "创建 .env.development", + "files.createEnvExample": "创建 .env.example", + "files.createEnvLocal": "创建 .env.local", + "files.createFailed": "无法创建 {name}", + "files.createFileFailedMessage": "创建文件失败:{error}", + "files.created": "已创建 {name}", + "files.cut": "剪切", + "files.delete": "删除", + "files.duplicate": "创建副本", + "files.enterNewFileName": "输入新文件名称:", + "files.enterNewFolderName": "输入新文件夹名称:", + "files.fileNamePlaceholder": "文件名", + "files.findInFolder": "在文件夹中查找", + "files.folderNamePlaceholder": "文件夹名", + "files.generateImage": "生成图片", + "files.invalidFileName": "文件名无效", + "files.invalidFileNameCharacters": "文件名无效:不允许路径穿越和特殊字符", + "files.javaClassAlreadyExists": "{name} 已存在", + "files.javaPasteRemoteUnsupported": "暂不支持将 Java 类粘贴到远程文件夹", + "files.localHistory": "本地历史", + "files.nameNewFile": "命名新文件", + "files.nameNewFolder": "命名新文件夹", + "files.newFile": "新建文件", + "files.newFolder": "新建文件夹", + "files.noExtension": "无扩展名", + "files.nothingToPaste": "剪贴板中没有可粘贴的内容", + "files.open": "打开", + "files.openAllFiles": "打开全部文件", + "files.openFailed": "无法打开 {name}", + "files.openFolderFirst": "请先打开一个文件夹", + "files.openInTerminal": "在终端中打开", + "files.openPreview": "打开预览", + "files.overwrite": "覆盖", + "files.overwriteEnvMessage": "{name} 已存在。要覆盖吗?", + "files.overwriteEnvTitle": "覆盖 Env 文件", + "files.paste": "粘贴", + "files.properties": "属性", + "files.propertiesFile": "文件", + "files.propertiesPath": "路径", + "files.propertiesSize": "大小", + "files.propertiesType": "类型", + "files.refresh": "刷新", + "files.removeFolderFromWorkspace": "从工作区移除文件夹", + "files.rename": "重命名", + "files.renameItem": "重命名 {name}", + "files.reveal": "在 Finder 中显示", + "files.sizeUnknown": "未知", + "files.symlinkTo": "符号链接到:{target}", + "files.title": "文件", + "files.unableToDetermineRootPath": "无法确定根文件夹路径", + "files.untitled": "未命名", + "files.untitledNumber": "未命名-{number}", + "files.uploadFiles": "上传文件", + "fontSelector.bundled": "{font}(内置)", + "fontSelector.custom": "{font}(自定义)", + "fontSelector.error": "加载字体出错:{error}", + "fontSelector.loading": "正在加载字体", + "fontSelector.select": "选择字体", + "footer.change": "{count} 个更改", + "footer.changes": "{count} 个更改", + "footer.cursor": "跳转到行和列", + "footer.encoding": "文件编码", + "footer.filePath": "文件路径", + "footer.gitBranch": "Git 分支", + "footer.indent": "缩进", + "footer.memory": "内存", + "footer.memoryUsage": "总计 {total} · Lithe {used}", + "footer.moveLeft": "左移", + "footer.moveRight": "右移", + "footer.noChanges": "没有更改", + "footer.readOnly": "只读", + "footer.resetOrder": "重置页脚顺序", + "footer.spaces": "{count} 个空格", + "footer.statusBar": "状态栏", + "footer.writable": "可写", + "git.abort": "中止", + "git.actionCompleted": "{action} 已完成", + "git.actionFailed": "{action} 失败", + "git.actions": "Git 操作", + "git.add": "添加", + "git.addPathsToIgnoreConfirm": "确定将 {count} 个目标添加到 {target} 吗?目录目标会忽略其中当前及以后出现的全部未跟踪项目。\n\n{paths}", + "git.addRemote": "添加远程", + "git.addRepository": "添加仓库...", + "git.addSelectionToGitignore": "将选中的 {count} 个项目添加到 .gitignore", + "git.addSelectionToLocalExclude": "将选中的 {count} 个项目添加到 .git/info/exclude", + "git.addTag": "添加标签", + "git.addToGitignore": "添加到 .gitignore", + "git.addToLocalExclude": "添加到 .git/info/exclude", + "git.addToVcs": "添加到 VCS", + "git.added": "已添加", + "git.adding": "正在添加...", + "git.addingRepository": "正在添加仓库...", + "git.aheadOfRemote": "领先远端分支 {count} 个提交", + "git.aiCommitEmptyMessage": "AI 返回了空提交说明。", + "git.allChangesDiscarded": "所有更改已丢弃", + "git.allFilesStaged": "所有文件已暂存", + "git.allFilesUnstaged": "所有文件已取消暂存", + "git.amend": "修订", + "git.amendLastCommit": "上次提交", + "git.applyStash": "应用贮藏", + "git.behindRemote": "落后远端分支 {count} 个提交", + "git.binaryDiffDescription": "{name} 无法以文本差异显示。", + "git.binaryFileChanged": "二进制文件已更改", + "git.branch": "分支", + "git.branchActions": "{branch} 的分支操作", + "git.branchCount": "{count} 个分支", + "git.branches": "分支", + "git.branchesCount": "{count} 个分支", + "git.browse": "浏览", + "git.changeAdded": "添加", + "git.changeDeleted": "删除", + "git.changeModified": "修改", + "git.changedFilesCount": "{count} 个已更改文件", + "git.changelists.active": "活动变更列表", + "git.changelists.activeSuffix": "(活动)", + "git.changelists.default": "默认更改列表", + "git.changelists.delete": "删除变更列表", + "git.changelists.deleteDescription": "文件将回到默认列表,可能被其批量暂存操作选中。文件内容和 Git 暂存区保持不变。", + "git.changelists.invalidName": "请输入不重名、非空且不超过 100 个字符的名称。", + "git.changelists.move": "移动到变更列表", + "git.changelists.name": "变更列表名称", + "git.changelists.new": "新建变更列表", + "git.changelists.otherStaged": "其他变更列表中还有已暂存改动,请取消其勾选或切换活动列表后再提交。", + "git.changelists.rename": "重命名变更列表", + "git.changelists.scope": "提交范围:{name}。其他列表需取消暂存。", + "git.changelists.stageActive": "暂存活动变更列表中的全部改动", + "git.changelists.unavailable": "无法读取已保存的变更列表。为保护排除的文件,已停用暂存和提交。", + "git.changelists.unstageActive": "取消暂存活动变更列表中的全部改动", + "git.changesCommitted": "更改已提交", + "git.changesFetched": "已成功获取。", + "git.changesNode": "更改", + "git.changesPushed": "更改已成功推送。", + "git.checkout": "检出", + "git.checkoutTag": "检出标签", + "git.checkoutTagConfirm": "是否在分离的 HEAD 中检出 {name}?", + "git.cherryPickCommit": "Cherry-pick 提交", + "git.cherryPickCommitConfirm": "确定将提交 {hash}“{message}”拣选到当前分支吗?", + "git.cherryPickInProgress": "拣选进行中", + "git.chooseCommitAction": "选择提交操作", + "git.chooseDiffSource": "选择差异来源", + "git.chooseRemoteAction": "选择远程操作", + "git.clearAdded": "清除已添加", + "git.close": "关闭", + "git.closeDiffView": "关闭差异视图", + "git.collapseAll": "全部折叠", + "git.collapseAllFiles": "折叠所有文件", + "git.commit": "提交", + "git.commitAndPush": "提交并推送", + "git.commitAndPushEllipsis": "提交并推送...", + "git.commitChanges": "提交更改", + "git.commitChangesFailed": "提交更改失败", + "git.commitCount": "{count} 个提交", + "git.commitIndexReconcileFailed": "提交已成功,但暂存区未能刷新。", + "git.commitMessageFormat": "提交说明格式", + "git.commitMessagePlaceholder": "提交说明", + "git.commitMessageTitleAndBody": "标题 + 正文", + "git.commitMessageTitleOnly": "仅标题", + "git.commitSha": "提交 SHA", + "git.commitShaOrRef": "提交 SHA 或引用", + "git.commitsCount": "{count} 个提交", + "git.committing": "正在提交...", + "git.compareBranchPlaceholder": "将当前分支与...比较", + "git.compareWithBranch": "与 {branch} 比较", + "git.compareWithHead": "与 HEAD 比较", + "git.compareWithPreviousTag": "与上一个标签比较", + "git.conflictMarkersRemain": "仍存在冲突标记:{paths}", + "git.conflictedFile": "已停止,有 {count} 个冲突文件。请在更改列表中解决后继续。", + "git.conflictedFiles": "已停止,有 {count} 个冲突文件。请在更改列表中解决后继续。", + "git.conflictsResolved": "所有冲突已解决。继续完成,或中止以撤销。", + "git.console.arguments.files": "… 另 {count} 个文件", + "git.console.arguments.references": "… 另 {count} 个引用", + "git.console.automaticQueries": "自动查询 {count} 次,结果相同", + "git.console.cancel": "取消正在运行的操作", + "git.console.clear": "清空", + "git.console.closeSearch": "关闭查找", + "git.console.completed": "已完成", + "git.console.copy": "复制输出", + "git.console.copyCommand": "复制完整命令", + "git.console.copyOutput": "复制完整输出", + "git.console.copyPath": "复制仓库路径", + "git.console.details": "命令详情", + "git.console.empty": "Git 命令及其输出将显示在这里。", + "git.console.exit": "退出码", + "git.console.find": "在 Git 控制台中查找", + "git.console.fold.branches": "其余 {count} 个分支", + "git.console.fold.commits": "其余 {count} 条提交", + "git.console.fold.files": "其余 {count} 个文件", + "git.console.fold.lines": "展开中间 {count} 行", + "git.console.fold.repeat": "重复 {count} 次", + "git.console.fold.tags": "其余 {count} 个标签", + "git.console.historyTruncated": "为限制内存占用,较早的 Git 执行记录已截断,无法展开。", + "git.console.log": "日志", + "git.console.matches": "{count} 处匹配", + "git.console.nextMatch": "下一个匹配", + "git.console.notice.completedWithoutOutput": "执行成功,没有输出。", + "git.console.notice.fetchUnchanged": "获取完成,没有引用变化。", + "git.console.notice.waitingForOutput": "正在执行 Git,等待输出。", + "git.console.open": "打开 Git 控制台", + "git.console.options": "Git 命令选项", + "git.console.presentationUnavailable": "暂时无法压缩,正在显示完整保留的输出。", + "git.console.previousMatch": "上一个匹配", + "git.console.queued": "等待启动", + "git.console.referenceChanges": "其余引用变化:新增 {added}、更新 {updated}、删除 {deleted}", + "git.console.running": "正在运行", + "git.console.scrollToEnd": "滚动到底部", + "git.console.searchLimit": "前 {count} 个位置", + "git.console.title": "控制台", + "git.console.truncated": "为限制内存占用,已省略较早的输出。", + "git.console.unconfirmed": "未收到已完成的命令记录", + "git.console.wrap": "自动换行", + "git.contextMenu.add": "添加", + "git.contextMenu.commitFile": "暂存并打开提交…", + "git.contextMenu.diffFailed": "打开差异失败", + "git.contextMenu.fetch": "获取", + "git.contextMenu.newBranch": "新建分支…", + "git.contextMenu.noChanges": "没有可显示的变更", + "git.contextMenu.openGitLog": "打开提交记录", + "git.contextMenu.pull": "拉取…", + "git.contextMenu.push": "推送…", + "git.contextMenu.showDiff": "显示差异", + "git.contextMenu.stageFailed": "暂存文件失败", + "git.contextMenu.submenu": "Git", + "git.continueCherryPick": "继续拣选", + "git.continueMerge": "继续合并", + "git.continueRebase": "继续变基", + "git.continueRevert": "继续还原", + "git.copiedLabel": "已复制 {label}", + "git.copyCommitSha": "复制提交 SHA", + "git.copyLabelFailed": "复制 {label} 失败", + "git.copyTagName": "复制标签名称", + "git.create": "创建", + "git.createNewBranch": "创建新分支 “{name}”", + "git.createStash": "创建贮藏", + "git.createTag": "创建标签", + "git.createWorktree": "创建工作树 “{path}”", + "git.creating": "正在创建...", + "git.current": "当前", + "git.delete": "删除", + "git.deleteBranch": "删除分支", + "git.deleteBranchConfirm": "确定要删除分支 “{branch}” 吗?", + "git.deleteCommit": "删除提交", + "git.deleteCommitConfirm": "确定删除提交 {hash}“{message}”并重放之后的所有本地提交吗?", + "git.deleteFileConfirm": "删除“{name}”?", + "git.deleteFilesConfirm": "删除 {count} 个文件?", + "git.deleteFilesFailed": "有 {count} 个选中文件删除失败。", + "git.deleteRemoteTag": "删除远程标签", + "git.deleteRemoteTagAction": "删除远程标签", + "git.deleteRemoteTagConfirm": "删除 {remote} 上的 {name} 吗?", + "git.deleteTag": "删除标签", + "git.deleteTagFrom": "从 {remote} 删除标签", + "git.deleteTagFromRemote": "从远程删除标签", + "git.detachedHead": "分离的 HEAD", + "git.detectingRepositories": "正在检测仓库...", + "git.diff.actions": "差异操作", + "git.diff.addedVersion": "新增版本", + "git.diff.afterVersion": "新版本", + "git.diff.beforeVersion": "旧版本", + "git.diff.changedFiles": "已更改文件", + "git.diff.commitPreviewEmptyTitle": "提交", + "git.diff.commitPreviewTitle": "提交: {file}", + "git.diff.commitRangeTitle": "提交 {base}..{target}", + "git.diff.compareRefsFailed": "比较 {base} 和 {target} 失败:\n{error}", + "git.diff.currentVersion": "当前版本", + "git.diff.deletedVersion": "删除版本", + "git.diff.differences": "{count} 处差异", + "git.diff.emptyChanges": "没有包含差异的更改。", + "git.diff.emptyRevision": "空树", + "git.diff.emptyStagedChanges": "没有包含差异的已暂存更改。", + "git.diff.emptyUnstagedChanges": "没有包含差异的未暂存更改。", + "git.diff.excludeBlock": "从提交中排除此块", + "git.diff.filePosition": "{current} / {total}", + "git.diff.getCommitDiffFailed": "获取提交 {commit} 的差异失败:\n{error}", + "git.diff.getFileDiffFailed": "获取 {file} 的差异失败:\n{error}", + "git.diff.getSelectedCommitDiffFailed": "加载选中提交的差异失败:{error}", + "git.diff.getStashDiffFailed": "获取 {stash} 的差异失败:\n{error}", + "git.diff.getWorkingTreeDiffFailed": "获取工作区差异失败:\n{error}", + "git.diff.hideChangedFiles": "隐藏已更改文件", + "git.diff.hideWhitespace": "隐藏空白字符", + "git.diff.highlightWords": "词级高亮", + "git.diff.hunkStale": "此 Diff 加载后文件已发生变化,未回滚该修改块。请在 Diff 刷新后重试。", + "git.diff.includeBlock": "将此块包含在提交中", + "git.diff.includedCount": ",已包含 {included} 处", + "git.diff.indexVersion": "暂存区版本", + "git.diff.invalidExpression": "表达式无效", + "git.diff.lineEndingChanges": "换行符不同", + "git.diff.matchCount": "{current} / {total}", + "git.diff.nextDifference": "下一处差异", + "git.diff.nextFile": "下一个文件", + "git.diff.noChangedFileSelected": "未选择已更改文件", + "git.diff.noChangesBetween": "{base} 和 {target} 之间没有更改。", + "git.diff.noChangesInCommit": "此提交中没有更改。", + "git.diff.noChangesInCommitForFile": "此提交中没有 {file} 的更改。", + "git.diff.noChangesInSelectedCommits": "选中的提交中没有更改。", + "git.diff.noChangesInStash": "此贮藏中没有更改。", + "git.diff.noDifferences": "无差异", + "git.diff.noResults": "没有结果", + "git.diff.oneDifference": "1 处差异", + "git.diff.openFileFailed": "打开文件 {file} 失败:\n{error}", + "git.diff.previousDifference": "上一处差异", + "git.diff.previousFile": "上一个文件", + "git.diff.previousRevision": "上一版本", + "git.diff.refreshFailed": "刷新差异失败", + "git.diff.repositoryPreviewEmptyTitle": "仓库差异", + "git.diff.repositoryPreviewTitle": "仓库差异: {file}", + "git.diff.rollbackHunk": "回滚修改块", + "git.diff.rollbackHunkConfirm": "回滚 “{path}” 中的这个修改块吗?该块未暂存的修改将被丢弃,此操作无法撤销。", + "git.diff.search": "搜索差异", + "git.diff.searchChanges": "搜索更改", + "git.diff.selectedCommitsTitle": "{count} 个选中提交", + "git.diff.selectionChanged": "文件已变化,请检查刷新后的差异再操作。", + "git.diff.showChangedFiles": "显示已更改文件", + "git.diff.showWhitespace": "显示空白符", + "git.diff.sideBySideViewer": "并排视图", + "git.diff.split": "并排视图", + "git.diff.splitUnavailableForAddedDeleted": "新增或删除的文件无法使用并排视图", + "git.diff.stage": "暂存", + "git.diff.stageFailed": "无法更新所选块,请刷新后重试。", + "git.diff.stagedChanges": "已暂存的更改", + "git.diff.statusAdded": "新增", + "git.diff.statusDeleted": "删除", + "git.diff.statusModified": "修改", + "git.diff.statusRenamed": "重命名", + "git.diff.synchronizedHorizontalScroll": "同步差异水平滚动", + "git.diff.title": "Git 差异", + "git.diff.uncommitted": "未提交的更改", + "git.diff.unified": "统一视图", + "git.diff.unifiedViewer": "统一视图", + "git.diff.unstage": "取消暂存", + "git.diff.unstagedChanges": "未暂存的更改", + "git.diff.viewOnGitHub": "在 GitHub 上查看", + "git.diff.worktree": "工作区", + "git.diffFileCount": "{count} 个文件", + "git.discard": "丢弃", + "git.discardAllChanges": "丢弃全部更改", + "git.discardAllChangesConfirm": "确定要丢弃所有更改吗?此操作无法撤销。", + "git.discardChanges": "丢弃更改", + "git.discardChangesConfirm": "丢弃所有未暂存的更改吗?此操作无法撤销。", + "git.discardChangesFailed": "丢弃更改失败", + "git.discardFileChanges": "丢弃文件更改", + "git.discardFileConfirm": "丢弃 “{path}” 的更改吗?此操作无法撤销。", + "git.discardSelectedChanges": "丢弃选中的改动", + "git.dropStash": "删除贮藏", + "git.editCommitMessage": "编辑提交消息", + "git.editCommitMessagePrompt": "请输入提交 {hash} 的新消息。", + "git.enterCommitMessage": "输入提交说明:", + "git.excludeFileFromCommit": "从本次提交中排除 {name}", + "git.excludeFolderFromCommit": "从本次提交中排除文件夹 {name}", + "git.execution.advanced": "高级配置与来源", + "git.execution.authAgain": "Git 再次请求凭据,请检查上一次输入。", + "git.execution.authExpired": "Git 认证请求已过期,请重试操作。", + "git.execution.authScope": "此回复仅发送给正在运行的 Git 操作。", + "git.execution.authentication": "Git 认证", + "git.execution.chooseValue": "选择一个值", + "git.execution.clear": "清除覆盖", + "git.execution.continue": "继续", + "git.execution.credentials": "Git 可以在 Lithe 中请求凭据。Lithe 不保存密码;凭据存储由 Git 凭据助手控制。", + "git.execution.deleted": "已删除引用", + "git.execution.effectiveFetch": "实际生效的 Fetch 选项", + "git.execution.executable": "Git 可执行文件", + "git.execution.helper": "使用凭据助手", + "git.execution.key": "配置项", + "git.execution.overridden": "已被更高优先级的值覆盖", + "git.execution.pathDefault": "使用 PATH 中的 Git", + "git.execution.phase.compressing": "压缩对象", + "git.execution.phase.counting": "统计对象", + "git.execution.phase.enumerating": "枚举对象", + "git.execution.phase.receiving": "接收对象", + "git.execution.phase.resolving": "解析差异", + "git.execution.phase.updating": "更新文件", + "git.execution.phase.writing": "写入对象", + "git.execution.precedence": "单次选项优先于仓库偏好,再优先于应用默认值。只有保存或清除字段时才修改 Git 配置。", + "git.execution.preferences": "Fetch 与提交偏好", + "git.execution.progress": "Git 进度", + "git.execution.referencesTruncated": "引用列表已截断;更新总数:{updated},删除总数:{deleted}", + "git.execution.referencesUnavailable": "无法读取引用变更", + "git.execution.reload": "重新读取 Git 配置", + "git.execution.remoteFailed": "Fetch 失败", + "git.execution.remoteSucceeded": "Fetch 成功", + "git.execution.response": "回复", + "git.execution.retry": "重试认证", + "git.execution.save": "保存", + "git.execution.saved": "Git 配置已保存", + "git.execution.sources": "配置来源", + "git.execution.temporary": "临时配置", + "git.execution.title": "Git 执行", + "git.execution.updated": "已更新引用", + "git.execution.value": "配置值", + "git.expandAll": "全部展开", + "git.expandAllFiles": "展开所有文件", + "git.failedToInitializeRepository": "初始化仓库失败。", + "git.failedToSelectRepository": "选择仓库失败。", + "git.fetch": "获取", + "git.fetch.allRemotes": "获取所有远程", + "git.fetch.credentials": "凭据沿用现有 Git 凭据助手和 SSH 配置。", + "git.fetch.defaults": "Fetch 默认行为", + "git.fetch.oneTime": "这些选项仅本次生效,不会修改 Git 配置。控制台会记录实际执行的命令。", + "git.fetch.options": "Fetch 选项…", + "git.fetch.prune": "清理失效的远程跟踪引用", + "git.fetch.remoteName": "已配置的远程名称", + "git.fetch.scope": "用于所有项目中的普通 Fetch。", + "git.fetch.submodules": "获取子模块", + "git.fetch.submodules.inherit": "使用 Git 配置", + "git.fetch.submodules.no": "不获取子模块", + "git.fetch.submodules.onDemand": "按需获取", + "git.fetch.submodules.yes": "获取全部子模块", + "git.fetch.tags": "获取标签", + "git.fetch.tags.all": "获取全部标签", + "git.fetch.tags.inherit": "使用 Git 配置", + "git.fetch.tags.none": "不获取标签", + "git.fetch.tags.prune": "同步标签并删除远程已不存在的本地标签", + "git.fetchFailed": "获取更改失败。", + "git.fetchedChanges": "已成功获取。", + "git.fetching": "正在获取", + "git.fetchingChanges": "正在获取更改...", + "git.fileStaged": "已暂存 {count} 个文件", + "git.filesStaged": "已暂存 {count} 个文件", + "git.filterRepositories": "筛选仓库...", + "git.finishOperationBeforeCommit": "请先完成或中止当前 Git 操作,再提交文件。", + "git.flatList": "平铺显示", + "git.generateCommitMessageFailed": "生成提交说明失败。", + "git.generateCommitMessageWithAI": "使用 AI 生成提交说明", + "git.gitignoreFile": ".gitignore", + "git.groupByDirectory": "目录", + "git.gutter.added": "新增的行 - 点击查看此更改", + "git.gutter.close": "关闭", + "git.gutter.deleted": "删除的行 - 点击查看此更改", + "git.gutter.modified": "修改的行 - 点击查看此更改", + "git.gutter.next": "下一处更改", + "git.gutter.openDiff": "打开完整 Diff", + "git.gutter.previous": "上一处更改", + "git.gutter.stage": "暂存更改", + "git.gutter.stageUnavailable": "保存文件后才能暂存此更改", + "git.gutter.title": "Git 本地更改(工作树)- 第 {index} 个更改(共 {total} 个)", + "git.historyLoadFailed": "无法加载 Git 历史。", + "git.historyLoadRepositoryFailed": "无法加载此仓库的 Git 历史。", + "git.historyMutationFailed": "Git 历史操作失败", + "git.historyReview.affected": "受影响历史:{count} 个提交(从旧到新)", + "git.historyReview.body": "正文", + "git.historyReview.completed": "历史已更新。恢复引用保留了操作前的 HEAD。", + "git.historyReview.contiguousRequired": "压缩需要选中当前分支上的连续提交", + "git.historyReview.copied": "已复制", + "git.historyReview.copyFailed": "复制失败,请选中并复制上方引用。", + "git.historyReview.copyRecovery": "复制恢复引用", + "git.historyReview.createRecovery": "创建恢复分支…", + "git.historyReview.deleteCommit": "移除该提交及其变更,然后重放后续提交。重放可能因冲突而暂停。", + "git.historyReview.description": "修改历史前,核对受影响的提交及仓库状态。", + "git.historyReview.editCommitMessage": "编辑完整提交消息。该提交及其后续提交将获得新的提交 ID。", + "git.historyReview.failed": "操作未完成,请检查仓库后再试。", + "git.historyReview.headRequired": "仅 HEAD 提交可撤销", + "git.historyReview.loading": "正在检查操作是否可用…", + "git.historyReview.outcomeUnknown": "Git 停止时,Lithe 未能确认分支的最终状态。请先检查分支及恢复引用,再开始其他操作。", + "git.historyReview.recovery": "操作前的历史保留在此引用中,可基于它创建恢复分支。", + "git.historyReview.recoveryCreated": "已创建恢复分支:{name}", + "git.historyReview.recoveryFailed": "无法创建恢复分支,请检查分支名及是否已经存在。", + "git.historyReview.recoveryName": "输入用于保存操作前历史的分支名。当前分支和文件保持原状。", + "git.historyReview.refreshFailed": "分支已改变,但工作树刷新失败。请检查变更后再继续。", + "git.historyReview.reload": "重新核对", + "git.historyReview.reviewAgain": "再次尝试前请重新核对。如果 Git 因冲突暂停,可在变更面板中继续、跳过或中止。", + "git.historyReview.running": "正在更新历史…", + "git.historyReview.squashCommits": "将 {count} 个提交压缩为 1 个。下方消息已按从旧到新的顺序包含所有选中提交;后续提交也将获得新的提交 ID。", + "git.historyReview.title": "提交标题", + "git.historyReview.titleCharacters": "标题:{count} 个字符", + "git.historyReview.totalCharacters": "完整消息:{count} 个字符", + "git.historyReview.undoCommit": "将 HEAD 回退到父提交。被撤销的变更保留在暂存区,原有暂存和未暂存变更也会保留。", + "git.includeFileInCommit": "将 {name} 包含在本次提交中", + "git.includeFolderInCommit": "将文件夹 {name} 包含在本次提交中", + "git.indexedFilesOfTotal": "{visible} / {total} 个已更改文件", + "git.indexing": "索引", + "git.indexingChanges": "正在索引更改", + "git.indexingWithCount": "{label} {processed}/{total}", + "git.initialize": "初始化", + "git.initializeGitRepository": "初始化 Git 仓库", + "git.initializeRepository": "初始化仓库", + "git.initializing": "正在初始化...", + "git.jumpToSource": "跳转到源", + "git.lineHistory.empty": "没有提交修改过这些行。", + "git.lineHistory.failed": "行历史查询失败。", + "git.lineHistory.loading": "正在查询行历史…", + "git.lineHistory.menuLabel": "查看这行的修改历史", + "git.lineHistory.mergeBadge": "合并(相对第一父)", + "git.lineHistory.retry": "重试", + "git.lineHistory.titleLine": "第 {line} 行的修改历史", + "git.lineHistory.titleRange": "第 {start}–{end} 行的修改历史", + "git.lineHistory.truncated": "仅显示最近 {count} 条记录。", + "git.lineHistory.unmapped.new": "第 {line} 行是新增行,尚无提交历史。", + "git.lineHistory.unmapped.unreliable": "第 {line} 行有未提交修改,无法可靠对应到提交版本。", + "git.lineHistory.viewCommit": "查看整个提交", + "git.loadingCommits": "正在加载提交", + "git.loadingDiff": "正在加载差异", + "git.loadingGitStatus": "正在加载 Git 状态", + "git.loadingRemotes": "正在加载远程...", + "git.loadingTags": "正在加载标签...", + "git.loadingWorktrees": "正在加载工作树...", + "git.localAhead": "本地领先", + "git.localExcludeFile": ".git/info/exclude", + "git.log.action.checkout": "检出", + "git.log.action.checkoutAndRebase": "检出并变基到当前分支", + "git.log.action.compareWithCurrent": "与当前分支比较", + "git.log.action.createBranch": "从引用新建分支…", + "git.log.action.diffWithWorkingTree": "显示与工作树的差异", + "git.log.action.mergeIntoCurrent": "将此引用合并到当前分支", + "git.log.action.pullMergeIntoCurrent": "使用合并拉入远程分支", + "git.log.action.pullRebaseIntoCurrent": "使用变基拉入远程分支", + "git.log.action.rebaseCurrentOnto": "将当前分支变基到此引用", + "git.log.actionSucceeded": "已对 {reference} 完成“{action}”", + "git.log.all": "全部", + "git.log.autoStashCleanupFailed": "拉取及本地更改恢复已完成,但无法删除临时贮藏。", + "git.log.autoStashDeferredByConflicts": "拉取因 {count} 个文件冲突而停止。本地改动仍保留在贮藏中,请完成 Git 操作后再恢复。", + "git.log.autoStashRestoreConflicts": "拉取已完成,但恢复本地改动时产生了 {count} 个文件冲突;已保留 {stash} 以便恢复。", + "git.log.branchCreateFailed": "创建分支失败", + "git.log.branchCreated": "已创建分支 {name}", + "git.log.branchDeletedTitle": "已删除分支:", + "git.log.branchFilter": "分支:{name}", + "git.log.branchNameAlreadyExists": "分支名称 {name} 已存在", + "git.log.branchNameClashesWithBranchPrefix": "分支名称 {name} 与现有本地分支 {branch} 冲突", + "git.log.branchNameClashesWithDirectory": "分支名称 {name} 与同名的本地分支目录冲突", + "git.log.branchNameClashesWithRemote": "分支名称 {name} 与同名的远程分支冲突", + "git.log.branchNameEmpty": "请指定新分支的名称", + "git.log.branchNameHead": "HEAD 是保留关键字", + "git.log.branchNameLabel": "分支名称:", + "git.log.branchNamePrompt": "输入新分支名称。", + "git.log.branchRestored": "已恢复分支 {name}", + "git.log.branchRestoredUpstreamFailed": "分支 {name} 已恢复,但上游跟踪({upstream})恢复失败", + "git.log.checkoutAndRebase": "签出并变基", + "git.log.checkoutAndRebaseConfirm": "签出“{branch}”,然后将其变基到“{current}”吗?变基会重写签出分支的历史。", + "git.log.checkoutAndRebaseOnto": "签出并变基到“{branch}”", + "git.log.checkoutAndUpdate": "签出并更新", + "git.log.checkoutFailed": "签出“{branch}”失败。", + "git.log.chooseWorktreeDirectory": "选择工作树目录", + "git.log.clearBranchFilter": "清除分支筛选", + "git.log.clearFilter": "清除 Git 日志筛选", + "git.log.collapse": "折叠 {name}", + "git.log.collapseLongEdges": "收起图谱长边", + "git.log.commitDetails": "提交详情", + "git.log.commitFiles": "提交文件", + "git.log.commitHash": "提交哈希", + "git.log.commitMessage": "提交说明", + "git.log.compareWithCurrent": "与“{branch}”比较", + "git.log.compareWithHead": "与 HEAD 比较", + "git.log.confirmAction": "确定要对 {reference} 执行“{action}”吗?", + "git.log.copied": "已复制{label}", + "git.log.copyBranchName": "复制分支名称", + "git.log.copyCommitHash": "复制提交哈希", + "git.log.copyCommitMessage": "复制提交说明", + "git.log.copyFailed": "无法复制{label}", + "git.log.copyHash": "复制哈希", + "git.log.copyShortHash": "复制短哈希", + "git.log.couldNotCheckout": "无法签出 {reference}", + "git.log.createBranchFromTitle": "从 {reference} 创建分支", + "git.log.createTagOnCommit": "在 {commit} 上创建新标签", + "git.log.dateJustNow": "刚刚", + "git.log.dateMinuteAgoOne": "{count} 分钟前", + "git.log.dateMinutesAgo": "{count} 分钟前", + "git.log.dateToday": "今天 {time}", + "git.log.dateYesterday": "昨天 {time}", + "git.log.deleteRemoteBranch": "删除远程分支", + "git.log.deleteRemoteBranchConfirm": "确定删除远程分支“{branch}”吗?这会从远程仓库中删除它。", + "git.log.expand": "展开 {name}", + "git.log.fileCountOne": "{count} 个文件", + "git.log.filesCount": "{count} 个文件", + "git.log.filter": "筛选 Git 日志", + "git.log.filterAuthor": "作者", + "git.log.filterBranch": "分支", + "git.log.filterField": "Git 日志筛选字段", + "git.log.filterPlaceholder": "{field} 筛选", + "git.log.filterText": "文本", + "git.log.goToChild": "跳转到子提交 {hash}", + "git.log.goToParent": "跳转到父提交 {hash}", + "git.log.headCurrentBranch": "HEAD(当前分支)", + "git.log.hide": "隐藏提交记录", + "git.log.hideDecorations": "隐藏分支和标签", + "git.log.loadMore": "加载更多提交", + "git.log.loading": "正在加载 Git 日志…", + "git.log.loadingChangedFiles": "正在加载更改的文件…", + "git.log.loadingCommits": "正在加载提交…", + "git.log.loadingShort": "加载中…", + "git.log.local": "本地", + "git.log.logLabel": "日志:{name}", + "git.log.manageRemotes": "管理远程...", + "git.log.mergeIntoCurrent": "将“{branch}”合并到“{current}”中", + "git.log.newBranchFrom": "从“{branch}”新建分支...", + "git.log.newBranchPrompt": "输入要从“{branch}”创建的本地分支名称。", + "git.log.newTag": "新建标签…", + "git.log.newWorktreeBranchPrompt": "输入基于“{branch}”创建工作树时使用的新本地分支名称。", + "git.log.newWorktreeFrom": "从“{branch}”新建工作树...", + "git.log.noChangedFiles": "没有更改的文件", + "git.log.noCommits": "此视图中没有提交", + "git.log.noMatch": "没有符合筛选条件的提交", + "git.log.noRemoteBranches": "没有远程分支", + "git.log.noRepository": "未打开仓库", + "git.log.noWorkingTreeDifferences": "“{branch}”与工作树之间没有差异。", + "git.log.none": "无", + "git.log.openCommitDiff": "打开提交差异", + "git.log.openDiff": "打开差异", + "git.log.openDiffHint": "双击或按 Enter 打开提交差异", + "git.log.openFileDiff": "双击打开文件差异", + "git.log.openWorkspace": "打开一个 Git 工作区以查看提交记录。", + "git.log.operationBlocked": "本地更改阻止了此操作:{paths}", + "git.log.operationConflicts": "Git 已停止,有 {count} 个冲突文件。", + "git.log.operationStopped": "Git 在完成前停止,请使用冲突操作继续或中止。", + "git.log.pullMergeIntoCurrent": "使用“合并拉入”“{branch}”", + "git.log.pullRebaseIntoCurrent": "使用“变基拉入”“{branch}”", + "git.log.pullRemoteIntoCurrentConfirm": "获取“{branch}”,然后使用{strategy}将其集成到“{current}”吗?", + "git.log.rebaseCurrentOnto": "将“{current}”变基到“{branch}”", + "git.log.references": "引用", + "git.log.referencesLoadFailed": "加载引用失败", + "git.log.refresh": "刷新 Git 日志", + "git.log.remote": "远程", + "git.log.renameBranch": "重命名...", + "git.log.renameBranchPrompt": "输入新的分支名称。", + "git.log.repository": "仓库", + "git.log.resizeAuthorColumn": "调整作者列宽度", + "git.log.resizeDateColumn": "调整日期列宽度", + "git.log.restoreBranch": "恢复", + "git.log.restoreUpstreamTracking": "恢复上游跟踪", + "git.log.retry": "重试", + "git.log.selectCommit": "选择一个提交", + "git.log.settings": "打开 Git 日志设置", + "git.log.showAll": "显示全部引用", + "git.log.showDecorations": "显示分支和标签", + "git.log.showDiffWithWorkingTree": "显示与工作树的差异", + "git.log.showLongEdges": "显示图谱长边", + "git.log.stopTrackingBranch": "停止跟踪分支", + "git.log.switchToRepositoryFirst": "切换到该仓库后才能管理其引用", + "git.log.tagCreateFailed": "创建标签“{name}”失败", + "git.log.tagNamePrompt": "输入新标签名称", + "git.log.tags": "标签", + "git.log.toolbar.compareWithCurrent": "与当前分支比较", + "git.log.toolbar.deleteBranch": "删除分支", + "git.log.toolbar.fetch": "提取", + "git.log.toolbar.goToHead": "导航到所选分支 HEAD", + "git.log.toolbar.hideWorktrees": "隐藏工作树仓库", + "git.log.toolbar.mark": "标记", + "git.log.toolbar.moreActions": "更多操作", + "git.log.toolbar.newBranch": "新建分支", + "git.log.toolbar.showAllBranches": "显示全部分支", + "git.log.toolbar.showMyBranches": "显示我的分支", + "git.log.toolbar.showWorktrees": "显示工作树仓库", + "git.log.toolbar.unmark": "取消标记", + "git.log.toolbar.updateSelected": "更新所选", + "git.log.trackingBranch": "跟踪的分支", + "git.log.tryAgain": "重试", + "git.log.unableToLoad": "无法加载 Git 日志。", + "git.log.unableToLoadFiles": "无法加载更改的文件", + "git.log.unableToRefresh": "无法刷新 Git 日志。", + "git.log.updateBranch": "更新", + "git.log.workingTreeComparisonFailed": "无法比较“{branch}”与工作树:\n{error}", + "git.log.workingTreeComparisonTitle": "{branch} 与工作树", + "git.manageBranches": "管理分支", + "git.manageRemotes": "管理远程", + "git.manageTags": "管理标签", + "git.merge": "合并", + "git.mergeBranchTitle": "合并分支", + "git.mergeConfirm": "将分支 “{branch}” 合并到 “{current}”?冲突可能需要手动解决。", + "git.mergeFailed": "合并失败。", + "git.mergeInProgress": "合并进行中", + "git.mergeIntoCurrent": "合并到当前分支", + "git.mergeSuccess": "已成功合并 {branch}。", + "git.message": "说明", + "git.moreFiles": "(另有 {count} 个)", + "git.name": "名称", + "git.newBranch": "新建分支", + "git.newImage": "新图片", + "git.newImageAlt": "{name}(新)", + "git.newWorktree": "新建工作树", + "git.noBranch": "无分支", + "git.noBranchesFound": "未找到分支", + "git.noChangesInFile": "此文件没有更改", + "git.noCommits": "暂无提交", + "git.noDiffData": "没有可用的差异数据", + "git.noFilesStaged": "没有已暂存的文件", + "git.noImage": "没有图片", + "git.noMatchingBranches": "没有匹配的分支", + "git.noMatchingCommits": "没有匹配的提交", + "git.noMatchingRemotes": "没有匹配的远程", + "git.noMatchingRepositories": "没有匹配的仓库", + "git.noMatchingStashes": "没有匹配的贮藏", + "git.noMatchingTags": "没有匹配的标签", + "git.noMatchingWorktrees": "没有匹配的工作树", + "git.noOtherBranches": "没有其他分支", + "git.noRemote": "无远程", + "git.noRemotesConfigured": "未配置远程", + "git.noRepositoriesFound": "未找到仓库", + "git.noRepositoryOpen": "未打开仓库", + "git.noRepositorySelected": "未选择仓库", + "git.noStashes": "暂无贮藏", + "git.noTagsFound": "未找到标签", + "git.noWorktreesFound": "未找到工作树", + "git.notAGitRepository": "不是 Git 仓库", + "git.oldImageAlt": "{name}(旧)", + "git.openFile": "打开文件", + "git.openFolderBeforeInit": "初始化仓库前请先打开文件夹。", + "git.openFolderBeforeInitializing": "打开文件夹后再初始化 Git", + "git.operationError": "错误:{error}", + "git.operationFailed": "Git 操作失败", + "git.operationStep": "第 {step} / {total} 步。", + "git.optionalAnnotation": "可选注释", + "git.patch.applicable": "补丁可以应用到所选位置。", + "git.patch.applied": "补丁已应用,变更列表已刷新。", + "git.patch.apply": "应用补丁…", + "git.patch.applyDescription": "打开或粘贴补丁,在当前仓库检查通过后,确认要应用到的位置。", + "git.patch.applying": "正在应用补丁…", + "git.patch.base": "起始提交(修改前)", + "git.patch.check": "检查补丁", + "git.patch.checking": "正在检查补丁…", + "git.patch.confirmApply": "应用已核对的补丁", + "git.patch.create": "创建补丁…", + "git.patch.destination": "应用到", + "git.patch.displayTruncated": "这里只显示开头部分。保存或应用时使用完整原始补丁;如需替换,请打开另一个文件。", + "git.patch.distinctCommits": "请选择两个不同的提交,以确定补丁方向。", + "git.patch.editPatch": "编辑补丁文本", + "git.patch.exportDescription": "选择文件并核对完整补丁后保存。", + "git.patch.fileTooLarge": "补丁超过 32 MiB 大小限制。", + "git.patch.invalidUtf8": "补丁不是有效的 UTF-8。请无损转换编码后重新打开。", + "git.patch.loading": "正在准备补丁…", + "git.patch.noChanges": "此来源中没有可导出的变更。", + "git.patch.notApplicable": "补丁无法应用到所选位置。", + "git.patch.openFile": "打开补丁文件…", + "git.patch.paste": "补丁文本(可在此粘贴)", + "git.patch.preview": "更新预览", + "git.patch.previewSelection": "请更新预览以核对当前所选文件。", + "git.patch.rawDiff": "补丁内容", + "git.patch.save": "保存补丁…", + "git.patch.saved": "补丁已保存到 {path}", + "git.patch.selectAll": "全选", + "git.patch.selectNone": "全不选", + "git.patch.selectedFiles": "已选 {count} 个文件", + "git.patch.singleRepository": "创建补丁需要选择同一仓库中的文件", + "git.patch.source": "导出的变更", + "git.patch.source.staged": "仅暂存变更", + "git.patch.source.unstaged": "仅未暂存变更", + "git.patch.source.workingTree": "所有未提交变更", + "git.patch.target.indexAndWorktree": "暂存区和工作树", + "git.patch.target.worktree": "仅工作树", + "git.patch.targetCommit": "目标提交(修改后)", + "git.patch.twoCommits": "导出补丁需要恰好选中两个提交", + "git.popStash": "弹出贮藏", + "git.previewFirstChange": "预览首个改动", + "git.pull": "拉取", + "git.pullChanges": "拉取更改", + "git.pullCommit": "拉取 {count} 个提交", + "git.pullCommits": "拉取 {count} 个提交", + "git.pullCount": "拉取 {count}", + "git.pullDialog.description": "将远程改动拉取到当前分支。拉取前请确认来源分支与拉取策略。", + "git.pullDialog.from": "拉取来源", + "git.pullDialog.fromPlaceholder": "选择远程分支", + "git.pullDialog.loadFailed": "无法加载拉取选项。", + "git.pullDialog.loading": "正在加载拉取选项...", + "git.pullDialog.localChanges": "本地改动会阻止拉取,请先提交或贮藏。", + "git.pullDialog.noBranches": "未找到远程分支。请先执行 Fetch 更新。", + "git.pullDialog.pull": "拉取", + "git.pullDialog.strategy.ffOnly": "仅快进", + "git.pullDialog.strategy.ffOnlyHint": "仅在可快进时应用改动,从不创建合并提交。", + "git.pullDialog.strategy.mergeHint": "当分支历史分叉时,通过合并提交整合远程分支。", + "git.pullDialog.strategy.rebaseHint": "将本地提交重放到远程分支之上,保持线性历史。", + "git.pullDialog.strategyLabel": "策略", + "git.pullDialog.strategySummary": "策略:{strategy}", + "git.pullDialog.title": "拉取", + "git.pullDialog.upstreamBadge": "上游", + "git.pullResult.dirty": "拉取前请先提交或贮藏本地更改。", + "git.pullResult.fetchFailed": "获取失败:{error}", + "git.pullResult.gitRejectedPull": "Git 拒绝了此次拉取。", + "git.pullResult.inspectBranchFailed": "无法检查当前分支。", + "git.pullResult.mergeConflict": "合并因冲突而停止,请先解决冲突。", + "git.pullResult.merged": "已成功合并上游更改。", + "git.pullResult.noUpstream": "当前分支没有上游分支,请先设置上游分支。", + "git.pullResult.preflightFailed": "拉取安全检查失败:{error}", + "git.pullResult.pullFailed": "拉取失败:{error}", + "git.pullResult.pulled": "已成功拉取更改。", + "git.pullResult.rebaseConflict": "变基因冲突而停止,请先解决冲突。", + "git.pullResult.rebased": "已成功变基到上游分支。", + "git.pullResult.remoteUpdateFailed": "无法更新远程引用。", + "git.pullResult.stateChanged": "选择拉取策略期间分支状态已变化,请检查后重试。", + "git.pullResult.strategyRequired": "分支已经分叉,请打开源代码管理并选择合并或变基。", + "git.pullResult.upToDate": "当前分支已是最新状态。", + "git.pulledChanges": "更改已成功拉取。", + "git.pulling": "正在拉取", + "git.pullingChanges": "正在拉取更改...", + "git.push": "推送", + "git.pushChanges": "推送更改", + "git.pushCommit": "推送 {count} 个提交", + "git.pushCommits": "推送 {count} 个提交", + "git.pushCount": "推送 {count}", + "git.pushDialog.changedFiles": "变更列表", + "git.pushDialog.chooseTargetDescription": "这些分支跟踪的是名称不同的远端分支。请为每个推送选择目标,确认之前不会写入任何提交。", + "git.pushDialog.chooseTargetTitle": "选择推送目标", + "git.pushDialog.commitHistory": "提交历史", + "git.pushDialog.createsRemoteBranch": "新建远端分支", + "git.pushDialog.description": "推送前检查目标、提交、变更文件和标签。", + "git.pushDialog.divergentWarning": "「{branch}」跟踪的是另一个分支「{upstream}」。请选择这次推送的目标。", + "git.pushDialog.filesFailed": "无法加载变更文件。", + "git.pushDialog.forcePush": "强制推送", + "git.pushDialog.forcePushHint": "强制推送使用 force-with-lease,会拒绝覆盖尚未获取的远程更新。", + "git.pushDialog.help": "推送安全说明", + "git.pushDialog.loading": "正在加载推送详情...", + "git.pushDialog.loadingFiles": "正在加载变更文件...", + "git.pushDialog.moreActions": "更多推送操作", + "git.pushDialog.moreCommits": "有更多提交未显示在当前有界预览中。", + "git.pushDialog.newUpstream": "新建上游", + "git.pushDialog.noChangedFiles": "此提交没有变更文件。", + "git.pushDialog.noCommitSelected": "未选择提交", + "git.pushDialog.noCommits": "没有要推送的提交", + "git.pushDialog.previewFailed": "无法加载推送详情。", + "git.pushDialog.publishSameName": "发布到 {remote}/{branch}", + "git.pushDialog.pushTags": "推送标签:", + "git.pushDialog.pushToUpstream": "推送到 {branch}", + "git.pushDialog.tagScope": "标签范围", + "git.pushDialog.tagsAll": "所有", + "git.pushDialog.tagsReachable": "可达标签", + "git.pushDialog.title": "将提交推送到 {repository}", + "git.pushDialog.upstreamConfigurationFailed": "推送已成功,但无法配置分支上游。", + "git.pushFailed": "推送更改失败。", + "git.pushTag": "推送标签", + "git.pushTagTo": "推送到 {remote}", + "git.pushedChanges": "更改已成功推送。", + "git.pushing": "正在推送", + "git.pushingChanges": "正在推送更改...", + "git.rebase": "变基", + "git.rebaseBranchTitle": "变基分支", + "git.rebaseConfirm": "将 “{current}” 变基到 “{branch}”?冲突可能需要手动解决。", + "git.rebaseFailed": "变基失败。", + "git.rebaseInProgress": "变基进行中", + "git.rebaseOntoThis": "将当前分支变基到此分支", + "git.rebasePlan.abortConfirm": "终止变基并恢复原分支历史?", + "git.rebasePlan.action.drop": "丢弃 (drop)", + "git.rebasePlan.action.edit": "停下编辑 (edit)", + "git.rebasePlan.action.fixup": "并入 (fixup)", + "git.rebasePlan.action.pick": "保留 (pick)", + "git.rebasePlan.action.reword": "改消息 (reword)", + "git.rebasePlan.action.squash": "合并 (squash)", + "git.rebasePlan.actionLabel": "提交动作", + "git.rebasePlan.amendConfirm": "用当前暂存修改及此完整消息修订暂停处的提交,然后继续?", + "git.rebasePlan.amendContinue": "修订并继续", + "git.rebasePlan.conflicts": "关闭弹窗,在“更改”中解决并暂存以下文件,再重新打开此会话继续。", + "git.rebasePlan.description": "按从旧到新的顺序审阅并重排所选基底之后的提交,基底提交本身保留。", + "git.rebasePlan.down": "下移", + "git.rebasePlan.editHelp": "在“更改”中编辑并暂存文件。“继续”保留当前提交;“修订并继续”用暂存内容及以下完整消息修订当前提交。", + "git.rebasePlan.fromHere": "从这里交互式变基…", + "git.rebasePlan.help.drop": "省略此提交及其修改。", + "git.rebasePlan.help.edit": "应用此提交后暂停,允许编辑文件并修订提交。", + "git.rebasePlan.help.fixup": "并入前一个提交,保留前一个提交的消息。", + "git.rebasePlan.help.pick": "保留此提交。", + "git.rebasePlan.help.reword": "替换完整提交消息。", + "git.rebasePlan.help.squash": "与前一个提交合并,使用以上完整消息。", + "git.rebasePlan.invalidPlan": "Squash 和 fixup 前必须存在保留的提交。Reword 和 squash 必须填写提交消息。", + "git.rebasePlan.noSession": "没有交互式变基会话。请在日志中选择基底提交以制定计划。", + "git.rebasePlan.output": "Git 输出", + "git.rebasePlan.range": "分支 {branch} 的 {count} 个提交;不包含基底 {base}。", + "git.rebasePlan.reloadAmend": "重新载入当前提交消息", + "git.rebasePlan.resultCount": "计划结果:{before} → {after} 个提交。", + "git.rebasePlan.session": "交互式变基会话…", + "git.rebasePlan.skipConfirm": "跳过当前提交并继续?该提交的更改将被省略。", + "git.rebasePlan.staleAmend": "暂停的提交已在编辑器外发生变化。草稿已保留;如需保留请先复制,再重新载入当前消息后修订。", + "git.rebasePlan.start": "开始变基", + "git.rebasePlan.status.aborted": "变基已终止", + "git.rebasePlan.status.completed": "变基已完成", + "git.rebasePlan.status.conflict": "有冲突需要解决", + "git.rebasePlan.status.edit": "已暂停以便编辑", + "git.rebasePlan.status.failed": "变基失败", + "git.rebasePlan.status.interrupted": "变基已中断;继续前请检查会话", + "git.rebasePlan.status.paused": "已暂停", + "git.rebasePlan.status.starting": "正在开始", + "git.rebasePlan.title": "交互式变基", + "git.rebasePlan.up": "上移", + "git.refresh": "刷新", + "git.refreshAfterDeleteFailed": "文件已删除,但源代码管理刷新失败。", + "git.refreshAria": "刷新 Git 状态", + "git.refreshStatus": "刷新状态", + "git.refreshingStatus": "正在刷新 Git 状态...", + "git.remoteAhead": "远程领先", + "git.remoteChangesAction": "{action} 远程更改", + "git.remoteCount": "{count} 个远程", + "git.removeRemote": "移除远程", + "git.removed": "已删除", + "git.removedImage": "已删除图片", + "git.repositories": "仓库", + "git.repositoriesCount": "{count} 个仓库", + "git.repository": "仓库", + "git.repositoryCount": "{count} 个仓库", + "git.repositoryInitialized": "仓库已初始化。", + "git.resetHard": "硬重置 - 丢弃更改", + "git.resetMixed": "混合重置 - 保留文件", + "git.resetSoft": "软重置 - 保留暂存", + "git.resetToCommit": "重置到此处", + "git.resetToCommitPrompt": "请选择将当前分支重置到 {hash} 的方式。硬重置会丢弃工作区更改。", + "git.resizeCommitArea": "调整提交区高度", + "git.resolveConflicts": "解决 {count} 个冲突文件,暂存后继续。", + "git.resolveConflictsFirst": "请先解决冲突:{paths}", + "git.revertCommit": "还原提交", + "git.revertCommitConfirm": "确定创建一个新提交来还原 {hash}“{message}”吗?", + "git.revertInProgress": "还原进行中", + "git.rollback": "回滚", + "git.rollbackPathsConfirm": "确定回滚选中的 {count} 个路径吗?此操作无法撤销。", + "git.saveCommitMessage": "保存消息", + "git.saved.changed": "仓库已发生变化。搁置记录已保留,请检查工作区后再重试。", + "git.saved.conflicts": "搁置改动前,请先解决合并冲突。", + "git.saved.drop": "删除保存的改动?", + "git.saved.empty": "没有保存的改动", + "git.saved.failed": "保存的改动操作失败", + "git.saved.noChanges": "没有可搁置的改动", + "git.saved.restore": "恢复", + "git.saved.shelve": "搁置…", + "git.saved.shelves": "Lithe 本地搁置", + "git.saved.staged": "暂存区", + "git.saved.stashes": "Git 暂存记录", + "git.saved.tab": "搁置", + "git.saved.unsaved": "搁置或恢复改动前,请先保存或关闭未保存的文件。", + "git.saved.untracked": "包含未跟踪文件", + "git.saved.worktree": "工作区", + "git.searchBranches": "搜索分支...", + "git.searchBranchesAria": "搜索分支", + "git.searchCommits": "搜索提交...", + "git.searchRemotes": "搜索远程...", + "git.searchStashes": "搜索贮藏...", + "git.searchTags": "搜索标签...", + "git.searchWorktrees": "搜索工作树...", + "git.selectFilesAndSpecifyCommitMessage": "请选择要提交的文件并填写提交说明", + "git.selectFilesToCommit": "请选择要提交的文件", + "git.selectRepository": "选择仓库", + "git.selectSingleRepositoryForCommit": "请选择当前仓库中的文件进行提交。", + "git.selectSingleRepositoryForStash": "请选择同一个仓库中的更改进行贮藏。", + "git.selectedFilesCount": "已选择 {count} 个文件", + "git.selectedFolderNotRepo": "所选文件夹不在 Git 仓库内。", + "git.selecting": "正在选择...", + "git.selectorSections": "Git 选择分区", + "git.setup.clear": "清除覆盖", + "git.setup.effective": "当前生效值", + "git.setup.email": "提交者邮箱", + "git.setup.errorDetails": "查看 Git 错误详情", + "git.setup.firstCommit": "在“更改”面板暂存文件、填写提交消息,然后创建首次提交。", + "git.setup.global": "全局 Git 配置", + "git.setup.globalDescription": "全局身份会用于其他仓库,仓库自身的配置可以覆盖它。", + "git.setup.identity": "提交身份", + "git.setup.identityDescription": "Git 会将此姓名和邮箱记录在新提交中,不会修改已有提交。", + "git.setup.initialize": "初始化 Git 仓库", + "git.setup.initializeDescription": "文件内容保持不变,不会自动暂存或提交。", + "git.setup.initializeFirst": "请先在 Git 面板初始化项目,再保存当前仓库的身份配置。", + "git.setup.initializeHint": "初始化 Git 后即可跟踪更改并创建首次提交。", + "git.setup.initializeTitle": "在此项目目录中初始化 Git?", + "git.setup.loading": "正在检查 Git 仓库…", + "git.setup.local": "当前仓库", + "git.setup.localDescription": "仓库身份会覆盖继承的全局值。清除覆盖后将使用继承的配置。", + "git.setup.missingIdentity": "提交前,请先在设置页填写 Git 姓名和邮箱。", + "git.setup.name": "提交者姓名", + "git.setup.noCommits": "当前分支尚无提交", + "git.setup.notRepository": "此项目尚未初始化 Git 仓库", + "git.setup.openChanges": "打开更改面板", + "git.setup.openProject": "请打开项目后配置 Git 提交身份。", + "git.setup.openSettings": "打开 Git 设置", + "git.setup.ownershipDenied": "Git 未信任此仓库的目录所有权,共享文件夹可能触发此安全检查。请检查目录权限和 Git 的 safe.directory 配置后重试。", + "git.setup.permissionDenied": "Git 没有访问此仓库文件夹的权限。请检查当前账户的文件夹访问权限后重试。", + "git.setup.readFailed": "Git 无法读取此仓库", + "git.setup.readFailedHint": "Git 读取仓库时发生错误,请查看具体原因后重试。", + "git.setup.reload": "重新加载 Git 配置", + "git.setup.save": "保存", + "git.setup.saved": "Git 配置已保存", + "git.setup.scope": "配置范围", + "git.setup.separateSave": "每个字段单独保存。切换范围时会重新加载已保存的值。", + "git.setup.unconfigured": "尚未配置有效值", + "git.showBranchDiff": "显示分支差异", + "git.showDiff": "显示差异", + "git.signTag": "签名标签", + "git.skipCommit": "跳过提交", + "git.specifyCommitMessage": "请填写提交说明", + "git.splitDiffView": "并排差异视图", + "git.splitView": "并排视图", + "git.squash": "压缩", + "git.squashCommits": "压缩提交", + "git.squashCommitsPrompt": "请输入压缩这 {count} 个提交后使用的提交消息。", + "git.stageAllChanges": "暂存所有更改", + "git.stageFile": "暂存文件", + "git.stageFileNamed": "暂存 {name}", + "git.stageFilesFailed": "暂存文件失败", + "git.stageFolder": "暂存文件夹 {name}", + "git.staged": "已暂存", + "git.stagedFilesCount": "已暂存 {count}", + "git.stash": "贮藏", + "git.stashAllUnstaged": "贮藏全部未暂存", + "git.stashAllUnstagedChanges": "贮藏所有未暂存的更改", + "git.stashAndSwitchFailed": "贮藏后切换分支失败", + "git.stashAndSwitchSuccess": "已贮藏更改并成功切换分支", + "git.stashChanges": "贮藏更改", + "git.stashCount": "{count} 个贮藏", + "git.stashFailed": "贮藏更改失败", + "git.stashFile": "贮藏文件", + "git.stashFileDefault": "贮藏 {path}", + "git.stashMessage": "贮藏说明...", + "git.stashMessageDefaultAll": "说明(默认:贮藏全部未暂存更改)", + "git.stashMessageDefaultFile": "说明(默认:贮藏 {name})", + "git.stashMessageDefaultSelection": "说明(默认:搁置选中的更改)", + "git.stashSelected": "搁置选中的更改", + "git.stashSelectedDefault": "搁置选中的更改", + "git.stashesCount": "{count} 个贮藏", + "git.stashing": "正在贮藏...", + "git.statusLoadFailed": "无法加载 Git 数据,请重试刷新仓库。", + "git.stoppedBeforeCompletion": "{branch} 在完成前已停止。请在更改列表中继续、跳过或中止。", + "git.switchingTo": "正在切换到 {branch}", + "git.tag.message": "说明", + "git.tag.remote": "标签远程", + "git.tagCount": "{count} 个标签", + "git.tagName": "标签名称", + "git.target": "目标", + "git.uncommittedChanges": "未提交的更改", + "git.uncommittedWouldOverwrite": "未提交的更改会被覆盖:{listed}{more}。请先贮藏或提交。", + "git.undoCommit": "撤销提交", + "git.unifiedDiffView": "统一差异视图", + "git.unifiedView": "统一视图", + "git.unstageAllChanges": "取消暂存所有更改", + "git.unstageFile": "取消暂存文件", + "git.unstageFileNamed": "取消暂存 {name}", + "git.unstageFilesFailed": "取消暂存文件失败", + "git.unstageFolder": "取消暂存文件夹 {name}", + "git.unstaged": "未暂存", + "git.untrackedDirectorySummary": "{path} — {count} 个未跟踪文件,展开可查看内容。", + "git.unversionedFilesNode": "未版本控制的文件", + "git.url": "URL", + "git.verifyConflictMarkersFailed": "无法验证冲突标记。请重试后再提交。", + "git.viewDiff": "查看差异", + "git.viewOptions": "视图选项", + "git.viewStashes": "查看贮藏", + "git.workspaceCommit.attention": "仓库需要处理", + "git.workspaceCommit.changed": "提交计划已变化,请重新确认后继续。", + "git.workspaceCommit.committed": "已提交", + "git.workspaceCommit.committedAndPushed": "已提交并推送", + "git.workspaceCommit.committedPushPending": "已提交,等待推送", + "git.workspaceCommit.dirtySubmodule": "子模块有未提交的改动,请在对应仓库提交文件", + "git.workspaceCommit.dismiss": "清除结果", + "git.workspaceCommit.headAdvanced": "HEAD 已变化,请确认后继续", + "git.workspaceCommit.notIncluded": "未包含在更新后的计划中", + "git.workspaceCommit.order": "按下列顺序执行。后续步骤失败时,已完成的提交会保留。", + "git.workspaceCommit.outcomeUnknown": "提交结果未知,请检查后再重试", + "git.workspaceCommit.parents": "更新父仓库引用", + "git.workspaceCommit.pending": "等待执行", + "git.workspaceCommit.pushFailed": "推送失败", + "git.workspaceCommit.pushOnly": "仅推送已有提交", + "git.workspaceCommit.reference": "更新引用:{path} → {child}", + "git.workspaceCommit.retry": "确认并重试未完成的操作", + "git.workspaceCommit.review": "确认工作区提交", + "git.workspaceCommit.stop": "停止", + "git.workspaceCommit.waitingForSubmodule": "等待子模块", + "git.worktreeCount": "{count} 个工作树", + "git.worktreeDialog.after.currentWindow": "在当前窗口打开", + "git.worktreeDialog.after.newWindow": "在新窗口打开", + "git.worktreeDialog.after.stay": "留在当前工作区", + "git.worktreeDialog.afterCreate": "创建后", + "git.worktreeDialog.bare": "裸仓库", + "git.worktreeDialog.branchInUse": "已在工作树中使用", + "git.worktreeDialog.branchName": "新分支名称", + "git.worktreeDialog.description": "创建独立工作目录,或打开现有工作树。移除前始终检查未提交修改。", + "git.worktreeDialog.destination": "目标目录", + "git.worktreeDialog.locked": "已锁定", + "git.worktreeDialog.manage": "管理工作树…", + "git.worktreeDialog.missing": "已丢失 / 可清理", + "git.worktreeDialog.mode": "工作树模式", + "git.worktreeDialog.mode.detached": "分离 HEAD", + "git.worktreeDialog.mode.existingBranch": "使用已有本地分支", + "git.worktreeDialog.mode.newBranch": "创建新分支", + "git.worktreeDialog.noCheckout": "暂不检出文件(--no-checkout)", + "git.worktreeDialog.openCurrent": "在当前窗口打开", + "git.worktreeDialog.openFailed": "无法打开工作树。", + "git.worktreeDialog.openNew": "在新窗口打开", + "git.worktreeDialog.primary": "主工作树", + "git.worktreeDialog.reference": "起始引用", + "git.worktreeDialog.remove": "移除工作树", + "git.worktreeDialog.removeConfirm": "移除 {path} 的工作树?仅允许移除干净且未锁定的工作树,分支会保留。", + "git.worktreeDialog.revision": "提交版本(可选)", + "git.worktreeDialog.revisionHint": "留空时使用所选引用", + "git.worktrees": "工作树", + "git.worktreesCount": "{count} 个工作树", + "git.zoomIn": "放大", + "git.zoomOut": "缩小", + "github.actionRunActions": "Actions 运行操作", + "github.actions": "Actions", + "github.activity": "动态", + "github.addAssignee": "添加负责人", + "github.addComment": "添加评论", + "github.added": "已添加", + "github.aiDraftUnusable": "AI 未返回可用草稿。", + "github.all": "全部", + "github.allIssues": "全部 Issue", + "github.allRuns": "全部运行", + "github.approve": "批准", + "github.approvePullRequest": "批准拉取请求", + "github.assigned": "已分配", + "github.assignees": "负责人", + "github.author": "作者", + "github.base": "Base", + "github.behindBase": "落后于 base", + "github.blockCommands": "块命令", + "github.blocked": "已阻塞", + "github.branch": "分支", + "github.branchNameCopied": "已复制分支名称", + "github.branchNameUnavailable": "分支名称不可用。", + "github.branches": "分支", + "github.browseRepository": "浏览仓库", + "github.cancel": "取消", + "github.cancelled": "已取消", + "github.changedFiles": "变更文件", + "github.changes": "变更", + "github.changesRequested": "已请求更改", + "github.changesRequestedLower": "已请求更改", + "github.check": "检查", + "github.checkedOutPr": "已检出 PR #{number}", + "github.checkingAccount": "正在检查 GitHub 账户", + "github.checkoutBranch": "检出分支", + "github.checks": "检查", + "github.checksPassed": "{count} 个检查已通过", + "github.checksPassedWithConflicts": "{count} 个检查已通过 · 存在冲突", + "github.checksPending": "{count} 个等待中", + "github.chooseBase": "选择 base", + "github.chooseBaseBranch": "选择 base 分支", + "github.chooseBranchOrTag": "选择分支或标签", + "github.chooseHead": "选择 head", + "github.chooseHeadBranch": "选择 head 分支", + "github.chooseLabels": "选择标签", + "github.chooseWorkflow": "选择工作流", + "github.chooseWorkflowDescription": "选择工作流以及要运行的分支或标签。", + "github.chooseWorkflowRef": "选择工作流 Ref", + "github.close": "关闭", + "github.closeAsNotPlanned": "按未计划关闭", + "github.closePr": "关闭 PR", + "github.closePullRequest": "关闭拉取请求", + "github.closePullRequestConfirm": "要在不合并的情况下关闭此拉取请求吗?", + "github.closedAgo": "{time}关闭", + "github.closedBy": "由 {author} 关闭", + "github.closedIssues": "已关闭 Issue", + "github.closing": "正在关闭", + "github.collapseDiffForFile": "折叠 {path} 的 diff", + "github.comment": "评论", + "github.commentActions": "评论操作", + "github.commentAdded": "评论已添加", + "github.commentDeleted": "评论已删除", + "github.commentUpdated": "评论已更新", + "github.commenting": "正在评论", + "github.commentsCount": "{count} 条评论", + "github.commit": "提交", + "github.commitDiffUnavailable": "提交 diff 不可用。", + "github.commitShaCopied": "已复制提交 SHA", + "github.connectGitHub": "连接 GitHub", + "github.conversationLocked": "此对话已锁定", + "github.copyBranchName": "复制分支名称", + "github.copyCommitSha": "复制提交 SHA", + "github.copyFailed": "复制失败:{error}", + "github.copyFullCommitSha": "复制完整提交 SHA", + "github.copyJobLogs": "复制作业日志", + "github.copyLink": "复制链接", + "github.copyTitle": "复制标题", + "github.couldNotLoadNotifications": "无法加载通知", + "github.couldNotMatchNotificationActionsRun": "无法将此通知匹配到 GitHub Actions 运行。", + "github.create": "创建", + "github.createDraftPullRequest": "创建为草稿拉取请求", + "github.created": "已创建", + "github.creating": "正在创建", + "github.delete": "删除", + "github.deleteComment": "删除评论", + "github.deleteCommentConfirm": "确定要永久删除这条评论吗?", + "github.deleted": "已删除", + "github.describeRequestedChanges": "描述需要更改的内容...", + "github.description": "描述", + "github.draft": "草稿", + "github.edit": "编辑", + "github.editCommentPlaceholder": "编辑评论...", + "github.editDescription": "编辑描述", + "github.editTitle": "编辑标题", + "github.edited": "已编辑", + "github.enableSidebarSections": "请在“设置 -> 外观”中启用 GitHub 侧栏分区。", + "github.event": "事件", + "github.expandDiffForFile": "展开 {path} 的 diff", + "github.failed": "失败", + "github.failedCheckoutPr": "检出 PR #{number} 失败", + "github.failedClosePullRequest": "关闭拉取请求失败", + "github.failedResolveActionsRun": "解析 GitHub Actions 运行失败", + "github.failedUpdatePullRequest": "更新拉取请求失败", + "github.filesChanged": "{count} 个文件已变更", + "github.filesCount": "文件 {count}", + "github.filterLabel": "筛选:{label}", + "github.filterSection": "筛选 GitHub {section}", + "github.filteredFilesCount": "{shown} / {total} 个文件", + "github.fromPath": "来自 {path}", + "github.generate": "生成", + "github.generating": "正在生成", + "github.githubUsername": "GitHub 用户名", + "github.hasConflicts": "存在冲突", + "github.head": "Head", + "github.hideChangedFiles": "隐藏变更文件", + "github.hideLogSearch": "隐藏日志搜索", + "github.inProgress": "进行中", + "github.inRepository": "位于 {repository}", + "github.invalidDiffFilePath": "diff 中的文件路径无效。", + "github.issueActions": "Issue 操作", + "github.issueClosed": "Issue 已关闭", + "github.issueCreated": "Issue 已创建", + "github.issueDraftGenerated": "Issue 草稿已生成", + "github.issueLinkCopied": "已复制 Issue 链接", + "github.issueLinkUnavailable": "Issue 链接不可用。", + "github.issueLocked": "Issue 已锁定", + "github.issueMilestone": "Issue 里程碑", + "github.issueReopened": "Issue 已重新打开", + "github.issueTitle": "Issue 标题", + "github.issueType": "Issue 类型", + "github.issueUnlocked": "Issue 已解锁", + "github.issueUpdated": "Issue 已更新", + "github.issues": "Issue", + "github.itemByAuthor": "#{number} 由 {author} 创建", + "github.jobsCount": "{count} 个作业", + "github.jobsFailed": "{count} 个失败", + "github.jobsRunning": "{count} 个运行中", + "github.labels": "标签", + "github.labelsCount": "{count} 个标签", + "github.leaveComment": "留下评论...", + "github.linked": "已关联", + "github.linkedIssues": "关联 Issue", + "github.loading": "正在加载...", + "github.loadingActionRun": "正在加载 Actions 运行", + "github.loadingActivity": "正在加载动态", + "github.loadingDiff": "正在加载 diff", + "github.loadingFileDiff": "正在加载文件 diff", + "github.loadingIssue": "正在加载 Issue", + "github.loadingIssues": "正在加载 Issue", + "github.loadingJobLogs": "正在加载作业日志", + "github.loadingLabels": "正在加载标签...", + "github.loadingLogs": "正在加载日志", + "github.loadingMoreActivityItems": "正在加载另外 {count} 条动态...", + "github.loadingMoreComments": "正在加载另外 {count} 条评论", + "github.loadingMoreJobs": "正在加载另外 {count} 个作业", + "github.loadingNotifications": "正在加载通知", + "github.loadingPr": "正在加载 PR #{number}", + "github.loadingPullRequests": "正在加载拉取请求", + "github.loadingWorkflowRuns": "正在加载工作流运行", + "github.lockAsOffTopic": "按偏离主题锁定", + "github.lockAsResolved": "按已解决锁定", + "github.lockAsSpam": "按垃圾内容锁定", + "github.lockAsTooHeated": "按讨论过热锁定", + "github.locked": "已锁定", + "github.lockedAs": "已按 {reason} 锁定", + "github.logsAvailableAfterJobFinishes": "此作业完成后可查看日志。", + "github.markdownCommand.bulletList": "项目符号列表", + "github.markdownCommand.codeBlock": "代码块", + "github.markdownCommand.divider": "分隔线", + "github.markdownCommand.heading1": "一级标题", + "github.markdownCommand.heading2": "二级标题", + "github.markdownCommand.heading3": "三级标题", + "github.markdownCommand.numberedList": "编号列表", + "github.markdownCommand.paragraph": "文本", + "github.markdownCommand.quote": "引用", + "github.markdownCommand.table": "表格", + "github.markdownCommand.taskList": "任务列表", + "github.markdownCommandGroup.basicBlocks": "基础块", + "github.markdownCommandGroup.insert": "插入", + "github.markdownCommandGroup.lists": "列表", + "github.markdownEditor": "Markdown 编辑器", + "github.markdownRenderNeedsFix": "此 Markdown 需要稍作修正才能渲染富文本:{error}", + "github.merge": "合并", + "github.mergePullRequest": "合并拉取请求", + "github.milestone": "里程碑", + "github.modified": "已修改", + "github.myPrs": "我的 PR", + "github.newIssue": "新建 Issue", + "github.newPullRequest": "新建拉取请求", + "github.noActivity": "暂无动态", + "github.noAssignees": "无负责人", + "github.noChecksReported": "未报告检查", + "github.noCommentsYet": "暂无评论", + "github.noDescriptionProvided": "未提供描述", + "github.noDiffHunksForFile": "此文件没有可用的 diff hunk。", + "github.noFileChanges": "无文件变更", + "github.noFilesMatchFilters": "没有文件匹配当前筛选", + "github.noIssues": "没有 Issue", + "github.noLabels": "无标签", + "github.noLogLinesMatchSearch": "没有日志行匹配此次搜索。", + "github.noLogsAvailableForStep": "此步骤没有可用日志。", + "github.noMatchingBlocks": "没有匹配的块", + "github.noMatchingIssues": "没有匹配的 Issue", + "github.noMatchingLabels": "没有匹配的标签", + "github.noMatchingPullRequests": "没有匹配的拉取请求", + "github.noMatchingWorkflowRuns": "没有匹配的工作流运行", + "github.noMilestone": "无里程碑", + "github.noPullRequests": "没有拉取请求", + "github.noRepositorySelected": "未选择仓库", + "github.noReviewers": "无审查者", + "github.noStepsReported": "未报告步骤。", + "github.noType": "无类型", + "github.noUnreadNotifications": "没有未读通知。", + "github.noWorkflowRuns": "没有工作流运行", + "github.none": "无", + "github.notAuthenticated": "GitHub 未认证", + "github.notAuthenticatedDescription": "连接 GitHub 后重试此视图。", + "github.notConnected": "GitHub 未连接", + "github.notConnectedDescription": "连接账户后可使用 PR、Issue、Actions 和 Releases。", + "github.notifications": "通知", + "github.notificationsCount": "通知({count})", + "github.open": "打开", + "github.openAttachment": "打开附件", + "github.openChangedFilesForPr": "打开拉取请求 #{number} 的变更文件", + "github.openCommitInBrowser": "在浏览器中打开提交", + "github.openCommitOnGitHub": "在 GitHub 上打开提交", + "github.openCommitSha": "打开提交 {sha}", + "github.openIssues": "打开的 Issue", + "github.openOnGitHub": "在 GitHub 上打开", + "github.openPr": "打开 PR", + "github.openPrs": "打开的 PR", + "github.openedAgo": "{time}打开", + "github.optionalReviewNote": "可选审查备注...", + "github.overview": "概览", + "github.partialChecksPassed": "{passed}/{total} 个已通过", + "github.patchErrors": "{count} 个 patch 错误", + "github.pendingCheck": "等待中的检查", + "github.pendingChecks": "等待中的检查", + "github.peopleCount": "{count} 人", + "github.prDraftGenerated": "PR 草稿已生成", + "github.prLinkCopied": "已复制 PR 链接", + "github.prLinkUnavailable": "PR 链接不可用。", + "github.pullRequestActionFailed": "拉取请求操作失败", + "github.pullRequestActions": "拉取请求操作", + "github.pullRequestApproved": "拉取请求已批准", + "github.pullRequestClosed": "拉取请求已关闭", + "github.pullRequestCreated": "拉取请求已创建", + "github.pullRequestMerged": "拉取请求已合并", + "github.pullRequestNumber": "拉取请求 #{number}", + "github.pullRequestTitle": "拉取请求标题", + "github.pullRequestUpdated": "拉取请求已更新", + "github.pullRequests": "拉取请求", + "github.queued": "已排队", + "github.readyToMerge": "可合并", + "github.ref": "Ref", + "github.refresh": "刷新", + "github.refreshIssues": "刷新 Issue", + "github.refreshJobLogs": "刷新作业日志", + "github.refreshPullRequests": "刷新拉取请求", + "github.refreshWorkflowRuns": "刷新工作流运行", + "github.refreshing": "正在刷新...", + "github.removeAssignee": "移除 @{assignee}", + "github.renamed": "已重命名", + "github.reopen": "重新打开", + "github.reopening": "正在重新打开", + "github.repositoryNotGitRepo": "仓库不是 Git 仓库", + "github.requestChanges": "请求更改", + "github.retry": "重试", + "github.retryAuthCheck": "重试 GitHub 认证检查", + "github.reviewRequests": "审查请求", + "github.reviewRequired": "需要审查", + "github.reviewRequiredTitle": "需要审查", + "github.reviewers": "审查者", + "github.run": "运行", + "github.runLinkCopied": "已复制运行链接", + "github.runLinkUnavailable": "运行链接不可用。", + "github.runNumber": "运行 #{number}", + "github.runWorkflow": "运行工作流", + "github.running": "运行中", + "github.save": "保存", + "github.saving": "正在保存", + "github.savingTitle": "正在保存标题", + "github.searchFiles": "搜索文件...", + "github.searchGitHub": "搜索 GitHub", + "github.searchLogs": "搜索日志", + "github.selectFile": "选择文件", + "github.selectGitRepositoryFolder": "请选择另一个包含 `.git` 仓库的文件夹。", + "github.selectedFolderNotGitRepo": "所选文件夹不在 Git 仓库中。", + "github.selecting": "正在选择...", + "github.showChangedFiles": "显示变更文件", + "github.skipped": "已跳过", + "github.state": "状态", + "github.status": "状态", + "github.stepLogsCopied": "已复制步骤日志", + "github.stepLogsNotLoaded": "步骤日志尚未加载。", + "github.success": "成功", + "github.successful": "成功", + "github.temporarilyUnavailable": "GitHub 暂时不可用", + "github.textFormatting": "文本格式化", + "github.title": "标题", + "github.tryAgain": "重试", + "github.type": "类型", + "github.typeGitHubUsernames": "输入一个或多个 GitHub 用户名,然后按 Enter。", + "github.unassigned": "未分配", + "github.unknown": "未知", + "github.unlockConversation": "解锁对话", + "github.updated": "已更新", + "github.updatedAgo": "{time}更新", + "github.updatingComment": "正在更新评论", + "github.workflow": "工作流", + "github.workflowQueued": "工作流已排队", + "github.working": "处理中", + "github.writeComment": "写一条评论...", + "github.writeDescription": "写一段描述...", + "github.writeReview": "写一条审查...", + "htmlPreview.noActiveBuffer": "没有活动缓冲区", + "htmlPreview.title": "HTML 预览", + "ideImport.importFrom": "从哪里导入...", + "ideImport.importSettings": "导入设置", + "ideImport.importWhat": "导入哪些内容...", + "ideImport.importedSelectedSettings": "已从 {name} 导入所选设置", + "ideImport.noOptionMatches": "没有导入选项匹配“{query}”。", + "ideImport.noRecentProjects": "{name} 中未找到最近项目", + "ideImport.noSourceMatches": "没有导入来源匹配“{query}”。", + "ideImport.recentProjects": "最近项目", + "ideImport.scanFailed": "无法扫描已安装的编辑器:{error}", + "ideImport.scanningInstalledEditors": "正在扫描已安装的编辑器...", + "image.balanced": "均衡", + "image.calculating": "正在计算...", + "image.convert": "转换", + "image.convertFailed": "转换格式失败", + "image.convertFormat": "转换格式", + "image.convertMenu": "转换", + "image.convertToFormat": "转换为 {format}", + "image.convertToFormatEllipsis": "转换为 {format}...", + "image.currentFile": "当前", + "image.edit": "编辑", + "image.editOperations": "图片编辑操作", + "image.estimatedSize": "预计大小:", + "image.flipFailed": "翻转图片失败", + "image.flipHorizontal": "水平翻转", + "image.flipVertical": "垂直翻转", + "image.heightPx": "高度(px)", + "image.highQuality": "高质量", + "image.losslessTransparency": "无损压缩,支持透明通道", + "image.lossySmaller": "有损压缩,文件更小", + "image.maintainAspectRatio": "保持宽高比", + "image.modernBestCompression": "现代格式,压缩率更佳", + "image.nextGenCompression": "新一代格式,压缩表现出色", + "image.originalDimensions": "原始尺寸:{width} × {height}px", + "image.qualitySetting": "质量设置", + "image.recommended": "推荐", + "image.recommendedBadge": "推荐", + "image.resize": "调整大小", + "image.resizeEllipsis": "调整大小...", + "image.resizeFailed": "调整图片大小失败", + "image.resizeImage": "调整图片大小", + "image.rotate180": "旋转 180°", + "image.rotate180Deg": "旋转 180 度", + "image.rotateCcw": "逆时针旋转 90°", + "image.rotateCcwDeg": "逆时针旋转 90 度", + "image.rotateCw": "顺时针旋转 90°", + "image.rotateCwDeg": "顺时针旋转 90 度", + "image.rotateFailed": "旋转图片失败", + "image.saveChanges": "保存更改", + "image.smallSize": "小体积", + "image.undo": "撤销", + "image.undoLastOperation": "撤销上次操作", + "image.updateFailed": "更新图片失败", + "image.widthPx": "宽度(px)", + "inlineEdit.addApiKey": "添加 API 密钥", + "inlineEdit.apiKeyRequired": "行内编辑需要 {provider} API 密钥。", + "inlineEdit.applied": "行内编辑已应用。", + "inlineEdit.apply": "应用行内编辑", + "inlineEdit.applying": "正在应用行内编辑", + "inlineEdit.backToProviders": "返回提供商", + "inlineEdit.close": "关闭行内编辑", + "inlineEdit.description": "描述代码更改,然后按 Enter 应用或按 Escape 关闭。", + "inlineEdit.editCurrentLine": "编辑当前行...", + "inlineEdit.editSelection": "编辑选区...", + "inlineEdit.emptyResult": "行内编辑返回了空结果。", + "inlineEdit.failedTryAgain": "行内编辑失败,请重试。", + "inlineEdit.instruction": "行内编辑指令", + "inlineEdit.loadingModels": "正在加载模型...", + "inlineEdit.model": "行内编辑模型", + "inlineEdit.noModels": "未找到模型", + "inlineEdit.noProviders": "未找到提供商", + "inlineEdit.pickProvider": "选择提供商...", + "inlineEdit.requiresOpenBuffer": "行内编辑需要先打开缓冲区。", + "inlineEdit.searchModels": "搜索模型...", + "inlineEdit.selectModel": "请选择行内编辑模型。", + "inlineEdit.targetUnavailable": "无法确定行内编辑目标。", + "inlineEdit.title": "行内编辑", + "javaEntry.abstractClass": "抽象类", + "javaEntry.annotation": "注解", + "javaEntry.cancel": "取消", + "javaEntry.class": "类", + "javaEntry.create": "创建", + "javaEntry.enum": "枚举", + "javaEntry.exception": "异常类", + "javaEntry.exists": "目标位置已存在同名文件或文件夹。", + "javaEntry.failed": "创建 Java 文件失败:{message}", + "javaEntry.interface": "接口", + "javaEntry.invalidName": "请输入合法的 Java 标识符,不能使用关键字或 Windows 保留名称。", + "javaEntry.invalidPackage": "请使用点分隔的合法 Java 包名,例如 com.example.app。", + "javaEntry.newMenu": "新建", + "javaEntry.newPackage": "Java 包…", + "javaEntry.newType": "Java 类…", + "javaEntry.package": "包名", + "javaEntry.packageHint": "包名按常见源码目录推断;自定义源码根目录请确认包名。", + "javaEntry.packageInfo": "package-info.java", + "javaEntry.record": "记录(Java 16+)", + "javaEntry.title": "新建 Java 类", + "javaEntry.typeKind": "类型", + "javaEntry.typeName": "名称", + "javaEntry.withMain": "生成 main 方法", + "keybindings.cancelRecording": "取消录制", + "keybindings.categories.Debug": "调试", + "keybindings.categories.Edit": "编辑", + "keybindings.categories.Selection": "选择", + "keybindings.categories.Workbench": "工作台", + "keybindings.commands.database.connect.title": "显示数据库", + "keybindings.commands.debug.start.title": "开始调试", + "keybindings.commands.debug.stop.title": "停止调试", + "keybindings.commands.debug.toggleBreakpoint.title": "切换断点", + "keybindings.commands.editor.copy.title": "复制", + "keybindings.commands.editor.copyLineDown.title": "向下复制行", + "keybindings.commands.editor.copyLineUp.title": "向上复制行", + "keybindings.commands.editor.cut.title": "剪切", + "keybindings.commands.editor.deleteLine.title": "删除行", + "keybindings.commands.editor.duplicateLine.title": "复制行", + "keybindings.commands.editor.expandSelection.title": "扩大选择", + "keybindings.commands.editor.foldAll.title": "全部折叠", + "keybindings.commands.editor.foldLevel.title": "折叠级别 {level}", + "keybindings.commands.editor.formatDocument.title": "格式化文档", + "keybindings.commands.editor.formatSelection.title": "格式化所选内容", + "keybindings.commands.editor.goToBracket.title": "转到括号", + "keybindings.commands.editor.goToDefinition.title": "转到定义", + "keybindings.commands.editor.goToImplementation.title": "转到实现", + "keybindings.commands.editor.goToLine.title": "转到行", + "keybindings.commands.editor.goToReferences.title": "转到引用", + "keybindings.commands.editor.goToTypeDefinition.title": "转到类型定义", + "keybindings.commands.editor.inlineEdit.title": "Agent 行内编辑", + "keybindings.commands.editor.insertCursorAbove.title": "在上方添加光标", + "keybindings.commands.editor.insertCursorBelow.title": "在下方添加光标", + "keybindings.commands.editor.insertCursorsAtLineEnds.title": "在行尾添加多个光标", + "keybindings.commands.editor.moveLineDown.title": "下移行", + "keybindings.commands.editor.moveLineUp.title": "上移行", + "keybindings.commands.editor.paste.title": "粘贴", + "keybindings.commands.editor.quickFix.title": "快速修复", + "keybindings.commands.editor.redo.title": "重做", + "keybindings.commands.editor.removeBrackets.title": "移除括号", + "keybindings.commands.editor.removeSecondaryCursors.title": "移除辅助光标", + "keybindings.commands.editor.renameSymbol.title": "重命名符号", + "keybindings.commands.editor.selectAll.title": "全选", + "keybindings.commands.editor.selectAllOccurrences.title": "选择全部查找匹配", + "keybindings.commands.editor.selectNextOccurrence.title": "将所选添加到下一处查找匹配", + "keybindings.commands.editor.selectPreviousOccurrence.title": "将所选添加到上一处查找匹配", + "keybindings.commands.editor.selectToBracket.title": "选择到括号", + "keybindings.commands.editor.showHover.title": "显示悬停信息", + "keybindings.commands.editor.showOutline.title": "转到编辑器中的符号", + "keybindings.commands.editor.shrinkSelection.title": "缩小选择", + "keybindings.commands.editor.toggleComment.title": "切换注释", + "keybindings.commands.editor.toggleLineNumbers.title": "切换行号", + "keybindings.commands.editor.toggleRenderWhitespace.title": "切换空白字符渲染", + "keybindings.commands.editor.toggleWordWrap.title": "切换自动换行", + "keybindings.commands.editor.triggerParameterHints.title": "触发参数提示", + "keybindings.commands.editor.triggerSuggest.title": "触发建议", + "keybindings.commands.editor.undo.title": "撤销", + "keybindings.commands.editor.unfoldAll.title": "全部展开", + "keybindings.commands.file.close.title": "关闭标签页", + "keybindings.commands.file.closeAll.title": "关闭所有标签页", + "keybindings.commands.file.closeOthers.title": "关闭其他标签页", + "keybindings.commands.file.closeSaved.title": "关闭已保存标签页", + "keybindings.commands.file.closeTabsToLeft.title": "关闭左侧标签页", + "keybindings.commands.file.closeTabsToRight.title": "关闭右侧标签页", + "keybindings.commands.file.localHistory.title": "显示本地历史", + "keybindings.commands.file.new.title": "新建文件", + "keybindings.commands.file.open.title": "打开项目", + "keybindings.commands.file.openSelected.title": "打开所选文件", + "keybindings.commands.file.quickOpen.title": "快速打开", + "keybindings.commands.file.reopenClosed.title": "重新打开已关闭标签页", + "keybindings.commands.file.revert.title": "还原文件", + "keybindings.commands.file.save.title": "保存文件", + "keybindings.commands.file.saveAll.title": "全部保存", + "keybindings.commands.file.saveAs.title": "文件另存为", + "keybindings.commands.help.showWhatsNew.title": "新增功能", + "keybindings.commands.lsp.restartAllServers.title": "语言服务器:全部重启", + "keybindings.commands.lsp.stopAllServers.title": "语言服务器:全部停止", + "keybindings.commands.navigation.goBack.title": "后退", + "keybindings.commands.navigation.goForward.title": "前进", + "keybindings.commands.terminal.close.title": "关闭终端", + "keybindings.commands.terminal.find.title": "在终端中查找", + "keybindings.commands.terminal.new.title": "新建终端", + "keybindings.commands.terminal.split.title": "向右拆分终端", + "keybindings.commands.terminal.splitDown.title": "向下拆分终端", + "keybindings.commands.window.maximize.title": "最大化窗口", + "keybindings.commands.window.minimize.alt.title": "最小化(备用)", + "keybindings.commands.window.minimize.mac.title": "最小化(Mac)", + "keybindings.commands.window.minimize.title": "最小化窗口", + "keybindings.commands.window.quit.title": "退出应用", + "keybindings.commands.window.toggleFullscreen.title": "切换全屏", + "keybindings.commands.window.toggleFullscreenMac.title": "切换全屏(Mac)", + "keybindings.commands.window.toggleMenuBar.title": "切换菜单栏", + "keybindings.commands.workbench.agentLauncher.title": "新建 Agent", + "keybindings.commands.workbench.closeEditorGroup.title": "关闭编辑器组", + "keybindings.commands.workbench.closeOtherEditorGroups.title": "关闭其他编辑器组", + "keybindings.commands.workbench.closeWindow.title": "关闭窗口", + "keybindings.commands.workbench.commandPalette.title": "命令面板", + "keybindings.commands.workbench.moveEditorToNextGroup.title": "移动编辑器到下一组", + "keybindings.commands.workbench.moveEditorToPreviousGroup.title": "移动编辑器到上一组", + "keybindings.commands.workbench.newTab.title": "新建标签页", + "keybindings.commands.workbench.newWindow.title": "新建窗口", + "keybindings.commands.workbench.nextTab.title": "下一个标签页", + "keybindings.commands.workbench.nextTabAlt.title": "下一个标签页(备用)", + "keybindings.commands.workbench.nextTabCtrlTab.title": "下一个标签页(Ctrl+Tab)", + "keybindings.commands.workbench.openKeyboardShortcuts.title": "打开键盘快捷键", + "keybindings.commands.workbench.openSettings.title": "打开设置", + "keybindings.commands.workbench.previousTab.title": "上一个标签页", + "keybindings.commands.workbench.previousTabAlt.title": "上一个标签页(备用)", + "keybindings.commands.workbench.previousTabCtrlTab.title": "上一个标签页(Ctrl+Shift+Tab)", + "keybindings.commands.workbench.resetEditorGroupSizes.title": "重置编辑器组大小", + "keybindings.commands.workbench.searchEverywhere.title": "全局查找", + "keybindings.commands.workbench.showDebugger.title": "显示运行和调试", + "keybindings.commands.workbench.showDocker.title": "显示 Docker", + "keybindings.commands.workbench.showFileExplorer.title": "显示文件", + "keybindings.commands.workbench.showFind.title": "查找", + "keybindings.commands.workbench.showFindReplace.title": "查找和替换", + "keybindings.commands.workbench.showGitHub.title": "显示 GitHub", + "keybindings.commands.workbench.showGlobalSearch.title": "全局搜索", + "keybindings.commands.workbench.showNotifications.title": "显示通知", + "keybindings.commands.workbench.showOutline.title": "显示大纲", + "keybindings.commands.workbench.showProjectSearch.title": "项目搜索", + "keybindings.commands.workbench.showSourceControl.title": "显示源代码管理", + "keybindings.commands.workbench.showThemeSelector.title": "主题选择器", + "keybindings.commands.workbench.splitEditorDown.title": "向下拆分编辑器", + "keybindings.commands.workbench.splitEditorRight.title": "向右拆分编辑器", + "keybindings.commands.workbench.switchToTab.title": "切换到标签页 {index}", + "keybindings.commands.workbench.toggleAIChat.title": "切换 Agent", + "keybindings.commands.workbench.toggleActivitySidebar.title": "切换活动侧栏", + "keybindings.commands.workbench.toggleDiagnostics.title": "显示诊断", + "keybindings.commands.workbench.toggleEditorGroupLock.title": "切换编辑器组锁定", + "keybindings.commands.workbench.toggleGitLog.title": "切换 Git 日志", + "keybindings.commands.workbench.toggleMinimap.title": "切换缩略图", + "keybindings.commands.workbench.toggleRun.title": "切换运行", + "keybindings.commands.workbench.toggleSidebar.title": "切换辅助侧栏", + "keybindings.commands.workbench.toggleSpringEndpoints.description": "显示或隐藏已索引的 Spring MVC 接口", + "keybindings.commands.workbench.toggleSpringEndpoints.title": "切换 Spring 接口", + "keybindings.commands.workbench.toggleTerminal.title": "切换终端", + "keybindings.commands.workbench.toggleTerminalAlt.title": "切换终端(备用)", + "keybindings.commands.workbench.zoomIn.title": "放大", + "keybindings.commands.workbench.zoomOut.title": "缩小", + "keybindings.commands.workbench.zoomReset.title": "重置缩放", + "keybindings.conflictsWith": "冲突项:{commands}", + "keybindings.default": "默认", + "keybindings.editForCommand": "编辑 {title} 的快捷键", + "keybindings.extension": "扩展", + "keybindings.notAssigned": "未分配", + "keybindings.preset": "预设", + "keybindings.pressKeys": "按下快捷键...", + "keybindings.record": "录制快捷键", + "keybindings.remove": "移除", + "keybindings.removeKeybinding": "移除快捷键", + "keybindings.reset": "重置", + "keybindings.resetToDefault": "重置为默认", + "keybindings.resetToDefaultAria": "重置为默认快捷键", + "keybindings.user": "用户", + "layout.actions": "操作", + "layout.pinned": "已固定", + "layout.projectDots": "项目圆点", + "layout.resizeActivityRail": "调整活动栏大小", + "layout.resizeAiChat": "调整 AI 聊天大小", + "layout.resizeBottomPane": "调整底部面板大小", + "layout.resizeSidebar": "调整侧边栏大小", + "layout.showInActivitySidebar": "在活动侧栏中显示", + "layout.sidebarSections": "侧边栏分区", + "linuxFolderPicker.chooseReadableFolder": "请选择可读取的文件夹。", + "linuxFolderPicker.folderPath": "文件夹路径", + "linuxFolderPicker.go": "转到", + "linuxFolderPicker.home": "主目录", + "linuxFolderPicker.loadingFolders": "正在加载文件夹", + "linuxFolderPicker.noFolders": "没有文件夹", + "linuxFolderPicker.parentFolder": "上级文件夹", + "linuxFolderPicker.unableToReadFolder": "无法读取此文件夹。", + "localHistory.back": "返回", + "localHistory.compareFailed": "无法比较快照", + "localHistory.compareSnapshotsFailed": "无法比较快照", + "localHistory.compareWithCurrent": "与当前比较", + "localHistory.compareWithPrevious": "与上一条比较", + "localHistory.createEntry": "创建本地历史记录", + "localHistory.createEntryAria": "创建本地历史记录", + "localHistory.createFailed": "无法创建快照", + "localHistory.deleteFailed": "无法删除快照", + "localHistory.deleteSnapshot": "删除快照", + "localHistory.entryCreated": "已创建本地历史记录", + "localHistory.entryName": "记录名称", + "localHistory.loadFailed": "无法加载本地历史", + "localHistory.loadingTimeline": "正在加载时间线...", + "localHistory.localWorkspaceOnly": "项目本地历史仅适用于本地工作区。", + "localHistory.namePrompt": "命名此本地历史记录:", + "localHistory.noChanges": "没有可快照的文件更改。", + "localHistory.noChangesToCompare": "没有可比较的更改。", + "localHistory.noFileSelected": "未选择本地文件", + "localHistory.noPreviousEntry": "没有上一条本地历史记录。", + "localHistory.noSnapshots": "没有本地历史快照", + "localHistory.openFailed": "无法打开快照", + "localHistory.openLocalFileFirst": "请先打开本地文件。", + "localHistory.openSnapshot": "打开快照", + "localHistory.optionalName": "可选名称", + "localHistory.renameFailed": "无法重命名快照", + "localHistory.renameSnapshot": "重命名快照", + "localHistory.restoreFailed": "无法恢复快照", + "localHistory.restoreSnapshot": "恢复快照", + "localHistory.searchTimeline": "搜索时间线...", + "localHistory.selectLocalFileFirst": "请先选择本地文件。", + "localHistory.snapshot": "快照", + "localHistory.snapshotRestored": "已恢复快照", + "localHistory.title": "本地历史", + "localHistory.titleForFile": "本地历史:{file}", + "lsp.active": "语言服务器运行中", + "lsp.connecting": "正在连接语言服务器...", + "lsp.connectingShort": "连接中", + "lsp.didNotStart": "语言服务器未启动。", + "lsp.failedNamed": "{name} 语言服务启动失败。", + "lsp.issue": "语言服务器异常", + "lsp.issueHint": "请查看通知中的最新错误。如果服务器二进制文件缺失或启动失败,请从扩展中重新安装对应语言工具。", + "lsp.javaIndexClearFailed": "清除 Java 索引失败:{error}", + "lsp.javaIndexCleared": "Java 索引已清除,重新打开 Java 文件时将自动重建。", + "lsp.languageServersRestarted": "语言服务器已重启。", + "lsp.languageServersStopped": "语言服务器已停止。", + "lsp.noActive": "没有活动的语言服务器", + "lsp.noProject": "无项目", + "lsp.notReadyNamed": "{name} 语言服务器尚未就绪。", + "lsp.preparingNamed": "{name} 语言服务正在准备项目...", + "lsp.readyNamed": "{name} 语言服务器已就绪。", + "lsp.restart": "重启", + "lsp.restartAll": "全部重启", + "lsp.restartAllFailed": "无法重启语言服务器", + "lsp.restartFailed": "无法重启语言服务器", + "lsp.restarting": "正在重启...", + "lsp.retry": "重试", + "lsp.start": "启动", + "lsp.startFailed": "无法启动语言服务器", + "lsp.starting": "正在启动...", + "lsp.status": "语言服务器状态", + "lsp.stopAll": "全部停止", + "lsp.stopAllFailed": "无法停止语言服务器", + "lsp.stopFailed": "无法停止语言服务器", + "lsp.stopNamed": "停止 {name} 语言服务器", + "lsp.stopping": "正在停止...", + "lsp.timedOutNamed": "{name} 语言服务准备超时。", + "lsp.unavailableNamed": "{name} 语言服务不可用。", + "lsp.unsupportedFeatureNamed": "{name} 语言服务器不支持{feature}。", + "markdown.documentProperties": "文档属性", + "markdownDocument.placeholder": "开始编写,或输入 / 使用命令...", + "maven.add": "添加", + "maven.addProfile": "添加 Maven Profile", + "maven.automatic": "自动检测", + "maven.buildOutput": "构建输出", + "maven.cancelled": "已取消", + "maven.clearOutput": "清除构建输出", + "maven.collapseAll": "全部折叠", + "maven.configurationChanged": "Maven 配置已更改", + "maven.debug": "调试", + "maven.debugAlreadyRunning": "请先停止当前调试会话,再启动新的调试。", + "maven.debugConfigurationUnavailable": "当前模块没有支持 JDWP 的调试配置。", + "maven.dependencies": "依赖项", + "maven.dependencyCancelled": "依赖项解析已取消", + "maven.dependencyLoading": "正在解析依赖项...", + "maven.detectedMissing": "本机未检测到", + "maven.detectedValue": "检测到:{value}", + "maven.detecting": "正在检测…", + "maven.emptyOutput": "运行 Maven 生命周期阶段后将在这里显示输出。", + "maven.executeGoal": "执行 Maven Goal", + "maven.javaConfigurationFailed": "Java 语言服务器未能应用 Maven 配置:{error}", + "maven.javaHome": "Maven JDK 主目录", + "maven.javaSourceUnavailable": "当前模块没有可用于启动 Java 语言服务的源码文件。", + "maven.lifecycle": "生命周期", + "maven.loadFailed": "无法加载 Maven 项目", + "maven.localRepository": "本地仓库", + "maven.mavenExecutable": "Maven 主目录 / 可执行文件", + "maven.noDependencies": "没有依赖项", + "maven.notDetected": "未检测到 Maven 项目", + "maven.omittedConflict": "版本冲突,采用", + "maven.omittedDuplicate": "已省略重复依赖", + "maven.openPom": "打开 pom.xml", + "maven.package": "打包", + "maven.processOutput": "进程输出", + "maven.profileId": "Profile ID", + "maven.profiles": "Profiles", + "maven.project": "项目", + "maven.reloadFailed": "无法重新加载 Java 语言服务器。", + "maven.reloadJdt": "重新加载 JDT LS", + "maven.reloadProjects": "重新加载 Maven 项目", + "maven.rerunTest": "重新运行测试", + "maven.resolutionProblems": "Maven 无法解析此项目({count} 个问题)", + "maven.restoreProfiles": "恢复默认 Profiles", + "maven.run": "运行", + "maven.runConfigurationUnavailable": "当前模块没有可明确选择的运行配置。", + "maven.runPhase": "运行当前生命周期阶段", + "maven.runSelected": "运行选中的生命周期阶段", + "maven.runStartFailed": "无法启动模块运行配置,请查看运行输出了解详情。", + "maven.runTestClass": "运行测试类", + "maven.runTestMethod": "运行测试方法", + "maven.scanning": "正在扫描 Maven 项目...", + "maven.scopeManagedFrom": "依赖管理前 scope 为 {value}", + "maven.scopeNotUpdatedTo": "未提升为 {value} scope", + "maven.scopeUpdatedFrom": "scope 由 {value} 提升", + "maven.settings": "Maven 设置", + "maven.settingsDescription": "留空表示由 Lithe 自动检测。", + "maven.skipTests": "跳过测试", + "maven.sourceRoot.generatedMain": "生成的主源码", + "maven.sourceRoot.generatedTest": "生成的测试源码", + "maven.sourceRoot.mainJava": "主 Java 源码", + "maven.sourceRoot.mainResources": "主资源", + "maven.sourceRoot.testJava": "测试 Java 源码", + "maven.sourceRoot.testResources": "测试资源", + "maven.sourceRoots": "源码根目录", + "maven.stop": "停止 Maven 任务", + "maven.test": "测试", + "maven.testFailures": "测试失败", + "maven.testLocationUnknown": "无法定位源码", + "maven.testRunFailed": "无法运行 Maven 测试", + "maven.testsFailed": "失败:{count}", + "maven.testsPassed": "通过:{count}", + "maven.testsRun": "总数:{count}", + "maven.testsSkipped": "跳过:{count}", + "maven.title": "Maven", + "maven.versionManagedFrom": "依赖管理前版本为 {value}", + "menu.changelog": "更新日志", + "menu.checkForUpdates": "检查更新", + "menu.closeAllTabs": "关闭所有标签页", + "menu.closeFolder": "关闭文件夹", + "menu.closeOtherTabs": "关闭其他标签页", + "menu.closeSavedTabs": "关闭已保存标签页", + "menu.closeTab": "关闭标签页", + "menu.closeTabsToLeft": "关闭左侧标签页", + "menu.closeTabsToRight": "关闭右侧标签页", + "menu.closeTerminal": "关闭终端", + "menu.closeWindow": "关闭窗口", + "menu.commandPalette": "命令面板", + "menu.copy": "复制", + "menu.cut": "剪切", + "menu.deleteLine": "删除行", + "menu.diagnostics": "诊断", + "menu.documentation": "文档", + "menu.duplicateLine": "复制行", + "menu.edit": "编辑", + "menu.file": "文件", + "menu.fileExplorer": "文件资源管理器", + "menu.find": "查找", + "menu.findAndReplace": "查找并替换", + "menu.formatDocument": "格式化文档", + "menu.formatSelection": "格式化所选内容", + "menu.github": "GitHub", + "menu.globalSearch": "全局搜索", + "menu.go": "转到", + "menu.goBack": "后退", + "menu.goForward": "前进", + "menu.goToDefinition": "转到定义", + "menu.goToImplementation": "转到实现", + "menu.goToLine": "转到行", + "menu.goToReferences": "转到引用", + "menu.goToTypeDefinition": "转到类型定义", + "menu.help": "帮助", + "menu.keyboardShortcuts": "键盘快捷键", + "menu.maximize": "最大化", + "menu.minimize": "最小化", + "menu.moveLineDown": "下移行", + "menu.moveLineUp": "上移行", + "menu.newFile": "新建文件", + "menu.newTab": "新建标签页", + "menu.newTerminal": "新建终端", + "menu.newWindow": "新建窗口", + "menu.nextTab": "下一个标签页", + "menu.openFolder": "打开文件夹", + "menu.paste": "粘贴", + "menu.preferences": "首选项", + "menu.previousTab": "上一个标签页", + "menu.quickFix": "快速修复...", + "menu.quickOpen": "快速打开", + "menu.quit": "退出", + "menu.redo": "重做", + "menu.renameSymbol": "重命名符号", + "menu.reopenClosedTab": "重新打开已关闭标签页", + "menu.reportBug": "报告 Bug", + "menu.requestFeature": "请求新功能", + "menu.resetZoom": "重置缩放", + "menu.revertFile": "还原文件", + "menu.run": "运行", + "menu.runAndDebug": "运行和调试", + "menu.save": "保存", + "menu.saveAll": "全部保存", + "menu.saveAs": "另存为...", + "menu.saveAsTitle": "另存为", + "menu.saveFileFailed": "保存文件失败。请重试。", + "menu.selectAll": "全选", + "menu.showHover": "显示悬停信息", + "menu.showLocalHistory": "显示本地历史", + "menu.sourceControl": "源代码管理", + "menu.splitEditor": "拆分编辑器", + "menu.splitTerminalDown": "向下拆分终端", + "menu.splitTerminalRight": "向右拆分终端", + "menu.startDebugging": "开始调试", + "menu.stopDebugging": "停止调试", + "menu.terminal": "终端", + "menu.theme": "主题", + "menu.toggleActivitySidebar": "切换活动侧栏", + "menu.toggleBreakpoint": "切换断点", + "menu.toggleComment": "切换注释", + "menu.toggleFullscreen": "切换全屏", + "menu.toggleLineNumbers": "切换行号", + "menu.toggleMenuBar": "切换菜单栏", + "menu.toggleMinimap": "切换缩略图", + "menu.toggleRenderWhitespace": "切换空白字符显示", + "menu.toggleSecondarySidebar": "切换辅助侧栏", + "menu.toggleTerminal": "切换终端", + "menu.toggleWordWrap": "切换自动换行", + "menu.tools": "工具", + "menu.triggerParameterHints": "触发参数提示", + "menu.undo": "撤销", + "menu.view": "视图", + "menu.webInspector": "Web 检查器", + "menu.whatsNew": "新增功能", + "menu.window": "窗口", + "menu.zoomIn": "放大", + "menu.zoomOut": "缩小", + "native.aChangeListWithThisNameAlreadyExists": "已存在同名的更改列表。", + "native.aCustomAgentCanFollowEitherYourCodexOrYourClaudeConfiguration": "自定义 Agent 可以跟随你本机的 Codex 或 Claude 配置。", + "native.aDetailedMessageWithAClearSubjectAndBody": "带有清晰标题和正文的详细说明", + "native.aMessageCreatesAnAnnotatedTag": "填写说明将创建附注标签。", + "native.aMissingCheckoutCanMakeTheWorktreeListInaccurate": "路径不存在或无效的工作树会影响列表的准确性。", + "native.aNewerVerifiedAdapterVersionIsAvailable": "有更高的已核验适配器版本可用。", + "native.aRebaseCannotStartWithAnyUncommittedChangesIncludingTheseUnrelatedTo": "变基无法在存在任何未提交更改时开始,包括下列与“{arg1}”无关的更改:", + "native.aRunningLocalJVMAndItsJDKJstatToolAreRequired": "需要运行中的本地 JVM 及其 JDK jstat 工具。", + "native.aSecureConnectionToGitHubCouldNotBeEstablishedCheckTLSInspectionProxyVPNOrSyst9ed7704d75": "无法与 GitHub 建立安全连接。请检查 TLS 检查、代理、VPN 或系统证书设置。", + "native.aStableReleaseInstallationIsAlreadyInProgress": "正在进行正式版安装,请等待完成。", + "native.aTaskStartedBeforeLitheLastClosedUnexpectedlyIsStillRunning": "Lithe 上次意外关闭前,{arg1} 启动的一个任务仍在运行", + "native.aTerminalIsBusyWhenAProgramOtherThanTheShellStillRunsInItSuchAsASpringBootAppNa88ac3e46a": "终端里除了 Shell 本身还有程序在运行时(例如 Spring Boot 应用、npm run dev 或放到后台的任务),这个终端就是“运行中”。空闲终端总是直接关闭,不会询问。结束一个终端会结束其中启动的所有程序。", + "native.aWorktreeOperationIsAlreadyRunningOrTheRepositoryIsUnavailable": "已有工作树操作正在运行,或仓库不可用。", + "native.abort": "中止", + "native.abort.variant2": "中止…", + "native.abortRebase": "中止变基", + "native.abortThisRebaseAndRestoreItsOriginalBranch": "中止此次变基并恢复原分支?", + "native.accept": "接受", + "native.acceptsAllPermissions": "跳过常规权限审批,仍受环境和管理策略限制。", + "native.acpAdapter": "ACP 适配器", + "native.actionFor": "{arg1} 的操作", + "native.actions": "操作", + "native.actionsFor": "{arg1} 的操作", + "native.actionsForFieldBreakpoint": "字段断点 {arg1} 的操作", + "native.actionsForMethodBreakpoint": "方法断点 {arg1} 的操作", + "native.activeMavenProfiles": "启用的 Maven Profiles", + "native.activeProfiles": "启用的 Profiles", + "native.activity": "活动", + "native.addAClassOrMethodName": "添加类名或方法名", + "native.addAMemoryUsageIndicatorToTheStatusBar": "在状态栏添加内存占用指示器", + "native.addAnAIProviderInAIProvidersFirst": "请先在“AI 服务商”中添加服务商。", + "native.addDatabaseConnection": "添加数据库连接", + "native.addHeader": "添加请求头", + "native.addMemoryUsageMonitoringTrackCurrentAndAverageMemoryUsageInTheStatusBar": "添加内存占用监控\n\n在状态栏显示当前及平均内存占用。", + "native.addMemoryUsageVisibilityToTheStatusBar": "在状态栏显示内存占用", + "native.addMethodBreakpoint": "添加方法断点", + "native.addOneHereOrRegisterAServerWithCodexOrClaudeCodeAndRefresh": "可以在这里添加,也可以先在 Codex 或 Claude Code 中注册服务器后刷新。", + "native.addOrRemoveABreakpointAtTheCaret": "在光标所在行添加或移除断点", + "native.addProvider": "添加服务商", + "native.addProvider.variant2": "添加供应商", + "native.addServer": "添加服务器", + "native.addShortcut": "添加快捷键", + "native.addTo": "添加到 {arg1}", + "native.addToAnotherConversation": "添加到其他会话", + "native.addToCurrentConversation": "添加到当前会话 · {arg1}", + "native.addToFavorites": "加入收藏", + "native.addToolWindowsAndCommandsToTheWorkbench": "向工作台添加工具窗口和命令", + "native.addVariable": "添加变量", + "native.added": "新增", + "native.addedByPlugins": "由插件添加", + "native.addedMemoryUsageVisibilityToTheStatusBar": "已在状态栏中加入内存占用显示。", + "native.additionalSearchPaths": "额外搜索路径", + "native.additions": "新增", + "native.addsGoLanguageServerIntegrationFormattingRunningAndTestSupport": "提供 Go 语言服务器、格式化、运行和测试支持。", + "native.addsJavaLanguageIntelligenceThroughTheEclipseJDTLanguageServerWithLombokDebuggb2eae07f8b": "通过 Eclipse JDT 语言服务器提供 Java 代码智能,并支持 Lombok、调试和测试。自带 Java 运行时。", + "native.addsLanguageServerIntegrationAndFormattingSupport": "提供语言服务器和格式化支持。", + "native.addsLanguageServerIntegrationFormattingRunningAndTestSupport": "提供语言服务器、格式化、运行和测试支持。", + "native.addsToThePullRequestConversationWithoutAnApprovalDecision": "在拉取请求中发表评论,不作出批准决定。", + "native.advancedConfigurationAndSources": "高级配置与来源", + "native.affectedHistoryOldestToNewest": "受影响的提交历史 · 从旧到新", + "native.after": "修改后", + "native.afterLitheClosesUnexpectedly": "Lithe 意外关闭后", + "native.agent": "Agent", + "native.agentChangeRollbackIsUnavailable": "当前无法回撤 Agent 修改。", + "native.agentCommands": "Agent 命令", + "native.agentConfiguration": "Agent 配置", + "native.agentConversation": "Agent 对话", + "native.agentConversationIsTurnedOffTurnItOnInThePanelSettingsToSendMessages": "Agent 对话功能已关闭。请在设置窗口的 Agent 页面中开启后再发送消息。", + "native.agentExecutable": "Agent 可执行文件", + "native.agentList": "Agent 列表", + "native.agentProvider": "Agent 供应商", + "native.agentReceived": "Agent 已接收", + "native.agentReportedChangesExcerptsMayNotIncludeTheEntireFile": "此处展示 Agent 上报的修改,片段可能不包含完整文件。", + "native.agentSettings": "Agent 设置", + "native.agentSettings.variant2": "Agent 设置…", + "native.agentTasks": "Agent 任务", + "native.agentTasksWillBeEndedWithoutAskingADeploymentOrMigrationThatIsStillRunningWillBeInterrupted": "Agent 任务将不经确认直接结束。仍在进行的部署或迁移会被中断。", + "native.agents": "Agent 管理", + "native.agentsMayRunDeploymentsMigrationsOrLongBuildsEndingOneHalfwayCanLeaveDataOrSer5ca5732b6c": "Agent 可能在执行部署、数据库迁移或耗时构建。中途结束可能导致数据或服务处于不一致状态。如果不确定它在做什么,请选择保留。", + "native.agentsRunOnTheNodeJsInstalledOnThisMacInstallOrUpdateNodeJsYourselfThenCheckAgain": "Agent 运行在这台 Mac 上已安装的 Node.js 之上。请自行安装或升级 Node.js,然后重新检测。", + "native.ai": "AI", + "native.aiChat": "AI 对话", + "native.aiCommit": "AI 与提交", + "native.aiProvider": "AI 服务商", + "native.aiProviders": "AI 服务商", + "native.aiProviders.variant2": "AI 服务商", + "native.aiSettingsAndCredentialsAreReadDirectlyFromItsLocalConfigurationFiles": "AI 配置和凭据会直接从本地配置文件读取。", + "native.aiToolConnectionsMCP": "AI 工具连接(MCP)", + "native.all": "全部", + "native.allBranches": "全部分支", + "native.allCompleted": "全部完成", + "native.allConfigurations": "全部配置", + "native.allConflictsResolvedContinueToFinishOrAbortToUndo": "冲突已全部解决。继续以完成操作,或中止以撤销。", + "native.allConversations": "全部会话", + "native.allMessages": "全部消息", + "native.allModules": "全部模块", + "native.allProjects": "所有项目", + "native.allProjectsProjectCanOverride": "所有项目 · 项目可覆盖", + "native.allReferences": "所有引用", + "native.allRepositories": "全部仓库", + "native.allServers": "所有服务器", + "native.allSessions": "全部会话", + "native.allSeverities": "全部级别", + "native.allTasksCompleted": "所有任务已完成", + "native.allTests": "全部测试:{arg1}", + "native.allUncommittedChanges": "全部未提交更改", + "native.allUsers": "所有用户", + "native.allow": "允许", + "native.allowFastForward": "允许快进", + "native.allowInsecureHTTP": "允许不安全的 HTTP", + "native.allowProjectEnvironmentAndMavenConfigurationChanges": "允许修改项目环境和 Maven 配置", + "native.allowRecreatingFile": "允许重新创建文件", + "native.allowRunBuildReloadAndStopExecutesProjectCode": "允许运行、构建、重载和停止(会执行项目代码)", + "native.allowTheAgentToContinue": "允许 Agent 继续吗?", + "native.allowToRunBuildTools": "允许“{arg1}”运行构建工具吗?", + "native.alwaysAskBeforeMakingChanges": "修改文件或执行需要权限的操作前请求你的批准。", + "native.alwaysAskToEditExternalFilesAndUseTheInternet": "修改工作区外文件或访问网络时先请求批准。", + "native.alwaysEnd": "总是结束", + "native.alwaysKeep": "总是保留", + "native.amend": "修订提交", + "native.amendAndContinue": "修订并继续", + "native.amendAndContinue.variant2": "修订并继续…", + "native.amendAppliesToRepositoriesWithSelectedFiles": "修订提交仅作用于有已选文件的仓库。", + "native.anAgentStartsWhenYouOpenThisPanelInAProjectTurningThisOffStopsEveryAgentProcess": "在项目中打开此面板时才会启动 Agent。关闭后会停止所有 Agent 进程。", + "native.anUpdateIsAvailable": "有可用更新。", + "native.annotateWithGitBlame": "使用 Git Blame 显示注释", + "native.anotherGlobalEntryUsesThisNameWithADifferentDefinition": "另一个全局条目使用了相同名称但定义不同。", + "native.anyTime": "不限时间", + "native.apiKey": "API 密钥", + "native.apiKeyAvailableInClaudeConfiguration": "Claude 配置中存在 API 密钥", + "native.apiKeyAvailableInCodexAuthJson": "Codex auth.json 中存在 API 密钥", + "native.apiKeyIsManagedByClaudeConfiguration": "API 密钥由 Claude 配置管理", + "native.apiKeyIsManagedByCodexConfiguration": "API 密钥由 Codex 配置管理", + "native.apiKeyOrToken": "API 密钥或 Token", + "native.apiKeySavedLocally": "API 密钥已保存到本地", + "native.apiKeysAreStoredInLitheSLocalApplicationDataAndAreNeverWrittenToLitheSettings": "API 密钥会保存在 Lithe 的本地应用数据中,不会写入 Lithe 设置。", + "native.apiProtocol": "API 协议", + "native.apiRequestURL": "API 请求地址", + "native.apiURL": "API 地址", + "native.appearanceBehavior": "外观与行为", + "native.appearanceMode": "外观模式", + "native.application": "应用运行", + "native.applicationDefault": "应用默认值", + "native.applicationMemory": "应用内存", + "native.applicationTypes": "应用类型", + "native.applicationVersion": "应用版本", + "native.applicationWideEditorAndTerminalSettingsRemainAvailableInTheOtherCategories": "应用级编辑器和终端设置仍可在其他分类中使用。", + "native.applications": "应用", + "native.appliesImmediately": "立即生效", + "native.appliesTheNextTimeTheLanguageServerStarts": "下次启动语言服务器时生效。", + "native.applyAIGeneratedContent": "要应用 AI 生成的内容吗?", + "native.applyPatch": "应用补丁", + "native.applyPatch.variant2": "应用补丁…", + "native.applyTo": "应用目标", + "native.applyToTheCurrentBranch": "将提交 {arg1} 应用到当前分支。", + "native.approvalMode": "权限模式", + "native.approve": "批准", + "native.approveForMe": "替我审批", + "native.approvePullRequest": "批准拉取请求", + "native.archiveSHA256": "归档 SHA-256", + "native.archived": "已归档", + "native.archivedD": "已归档({arg1})", + "native.arguments": "参数", + "native.argumentsFromThePage": "页面提供的参数:", + "native.argumentsOnePerLine": "参数(每行一个)", + "native.argumentsPassedToTheProgram": "传递给程序的参数", + "native.askBeforeClosingATabWithRunningPrograms": "关闭仍有程序运行的标签页前先确认", + "native.askBeforeStopping": "停止前询问", + "native.askEveryTime": "每次询问", + "native.askForApproval": "请求批准", + "native.askForEachTask": "逐个确认", + "native.askForEachTerminal": "逐个确认", + "native.askInOneList": "统一列表确认", + "native.askOnNextLaunch": "下次启动时询问", + "native.askTheAgentAboutThisWorkspace": "向 Agent 询问这个工作区", + "native.askTheAgentEnterToSendShiftEnterForANewLine": "向 Agent 提问,Enter 发送,Shift-Enter 换行", + "native.assignees": "负责人", + "native.astroSupport": "Astro 支持", + "native.attach": "连接", + "native.attachFile": "附件", + "native.attachFiles": "添加文件", + "native.attachToARemoteJVMOrTomcat": "连接到远程 JVM 或 Tomcat", + "native.attachUpTo32FilesPerMessage": "每条消息最多添加 32 个文件。", + "native.attachmentsAreNotSupportedYet": "暂不支持附件", + "native.authenticationMustBeAJSONObjectContainingAnOPENAIAPIKEYString": "认证配置必须是包含 OPENAI_API_KEY 字符串的 JSON 对象。", + "native.author": "作者", + "native.authorizeInYourBrowser": "在浏览器中授权", + "native.authorizeThisProjectToExposeEnvironmentMavenRunConfigurationsAndOutputToConnec6e24e4763f": "仅授权此项目,连接的 Agent 可以读取环境、Maven、运行配置和输出。输出可能包含项目敏感信息。关闭项目或退出 Lithe 后连接失效。", + "native.authorizeThisProjectToExposeEnvironmentMavenRunConfigurationsAndOutputToConnecb6f617e865": "仅授权此项目,连接的 Agent 可以读取环境、Maven、运行配置和输出。输出可能包含项目敏感信息。关闭项目或退出 Lithe 后连接失效。已启用的插件也可使用此项目授权。", + "native.authorizeThisProjectToExposeEnvironmentMavenRunConfigurationsAndOutputToConnece4d5e32bce": "授权当前项目后,已连接的 Agent 可以访问环境、Maven、运行配置与输出。输出可能包含敏感项目数据。关闭项目或退出 Lithe 时访问结束,并移除项目级服务器注册。已启用的插件也可以使用该授权。", + "native.autobuildBuiltAt": "AutoBuild — 构建自 {arg1}", + "native.autobuildIsRebuiltNightlyAndMayContainUnfinishedFeaturesSeriousBugsOrOtherProbd0994a4a11": "AutoBuild 每晚滚动构建,可能包含未完成的功能、严重缺陷或其他异常,普通用户建议使用正式版。", + "native.autobuildRolling": "AutoBuild 滚动版", + "native.autobuildUpdate": "AutoBuild 更新", + "native.automatic": "自动", + "native.automaticQueryLldIdenticalResults": "自动查询 {arg1} 次,结果相同", + "native.automaticUsesAProjectMvnwFirstThenDetectedMavenThenMvnd": "自动模式会优先使用项目 mvnw,其次使用检测到的 Maven,最后使用 mvnd。", + "native.automaticallyAcceptAllFileEdits": "自动批准文件编辑,其他操作仍按权限规则处理。", + "native.automaticallyGenerated": "自动生成", + "native.automaticallyIdentified": "自动识别", + "native.available": "可用", + "native.availableToAgents": "可用的 Agent", + "native.averageSinceLaunch": "启动以来平均", + "native.averageTimeToFirstTokenTTFT": "首 token 平均(TTFT)", + "native.averageTotal": "平均总计", + "native.avg": "平均", + "native.backToAllReferences": "返回所有引用", + "native.backToTheConversation": "返回对话", + "native.backgroundCommandEndedExitCodeArg1Arg2": "后台命令已结束(退出码 {arg1}):{arg2}", + "native.backgroundCommandFinished": "后台命令已完成:{arg1}", + "native.backgroundServices": "后台服务", + "native.backgroundServices.variant2": "后台服务", + "native.backgroundServicesStartedInLitheAreStillRunning": "在 Lithe 中启动的后台服务仍在运行", + "native.balancedReasoning": "平衡思考深度与响应速度", + "native.base": "目标", + "native.base.variant2": "基点:{arg1}", + "native.baseBranch": "目标分支", + "native.baseTarget": "基准 {arg1} → 目标 {arg2}", + "native.baseURLForAServiceCompatibleWithTheOpenAIResponsesAPI": "填写兼容 OpenAI Responses API 的服务端基础地址。", + "native.baseURLForAnAnthropicMessagesAPIService": "填写兼容 Anthropic Messages API 的服务端基础地址。", + "native.basicInformation": "基本信息", + "native.basicLatinLetters": "基本拉丁字母", + "native.before": "修改前", + "native.beforeDeletion": "删除前", + "native.beforeProjectReplacement": "项目替换前", + "native.beforeRename": "重命名前", + "native.beforeRestore": "恢复前", + "native.branch": "分支", + "native.branch.variant2": "分支:", + "native.branch.variant3": "分支:{arg1}", + "native.branchContext": "分支上下文", + "native.branchName": "分支名称", + "native.branchPublishedToGitHub": "分支已发布到 GitHub", + "native.branchTracksWhichHasADifferentNameChooseWhichBranchToUpdate": "分支“{arg1}”跟踪的是名称不同的“{arg2}”。请选择要更新哪个分支。", + "native.branches": "分支", + "native.branchesHaveDiverged": "分支已分叉", + "native.branchesMustBeDifferent": "两个分支不能相同", + "native.branchesUnavailable": "无法加载分支", + "native.breakdownUnavailable": "分项暂不可用", + "native.breakpointActions": "断点操作", + "native.breakpoints": "断点", + "native.bugReport": "错误报告", + "native.build": "构建", + "native.build.variant2": "构建 {arg1} → {arg2}", + "native.buildExecutionDeployment": "构建、执行、部署", + "native.buildFilesChanged": "{arg1} 构建文件已更改", + "native.buildFilesChanged.variant2": "构建文件已更改。", + "native.buildFilesChangedReloadTheModelToPickUpTheChanges": "构建文件已更改,请重新加载模型以应用更改。", + "native.buildOutput": "构建输出", + "native.buildOutput.variant2": "构建输出", + "native.buildOutputs": "构建产物", + "native.buildRunAndDebug": "构建、运行与调试", + "native.buildSystem": "构建系统", + "native.buildsProjects": "构建 {arg1} 项目", + "native.built": "构建于 {arg1}", + "native.builtIn": "内置", + "native.builtInBackgrounds": "内置背景", + "native.cCObjectiveCSupport": "C/C++/Objective-C 支持", + "native.cSupport": "C# 支持", + "native.cacheHit": "缓存命中", + "native.cacheHit.variant2": "缓存命中 {arg1}", + "native.cacheRead": "缓存读取", + "native.cacheReadTokens": "缓存读取 token:{arg1}", + "native.cacheWrite": "缓存写入", + "native.cacheWriteTokens": "缓存写入 token:{arg1}", + "native.callStack": "调用栈", + "native.cameraAndMicrophoneInUse": "摄像头和麦克风使用中", + "native.cameraInUse": "摄像头使用中", + "native.cancelQuit": "取消退出", + "native.cancelRunningGitOperations": "取消正在运行的 Git 操作", + "native.cancelled": "已取消", + "native.cannotBeUsed": "无法使用:{arg1}", + "native.cannotCopyADirectoryIntoItself": "不能将目录复制到其自身或子目录中", + "native.cannotCreateANewConversation": "无法新建对话:{arg1}", + "native.cannotUseUsingTheProjectJDKInstead": "无法使用 {arg1},已改用项目 JDK。", + "native.category": "分类", + "native.changeTheValuesStoredInTheSelectedScopeChooseInheritToRemoveAnOverride": "修改所选范围中保存的值。选择“继承”即可移除覆盖。", + "native.changeWorkbenchBackground": "更换工作台背景", + "native.changed": "已更改", + "native.changedFiles": "已更改文件", + "native.changedOutsideLitheRefreshAndTryAgain": "“{arg1}”在 Lithe 之外被修改。请刷新后重试。", + "native.changelist": "更改列表", + "native.changelistEditingIsUnavailable": "暂时无法编辑更改列表。", + "native.changelistName": "更改列表名称", + "native.changelistStorageIsUnavailableRestoreTheSavedDataAndReopenTheWorkspace": "更改列表保存数据不可用。请恢复保存的数据后重新打开工作区。", + "native.changes": "更改", + "native.changesApplyToTheNextTurn": "修改将在下一轮对话生效。", + "native.changesAreWrittenTo": "更改写入", + "native.changesFromExternalToolsWillAppearAutomatically": "外部工具产生的更改会自动显示。", + "native.changesFromTheCompareBranchWillBeProposedForTheBaseBranch": "比较分支中的更改将提交到目标分支。", + "native.changesRequested": "已请求修改", + "native.changingChannelsRequiresAFullInstallationAndMayInstallAnOlderVersionOnItsFirste8ccfd1e52": "切换渠道需要全量安装,可能安装较旧的版本。Lithe-Dev 首次启动且没有自己的用户数据时,会自动复制正式版的设置和工作区,之后两版分别保存设置和缓存。部分账号可能需要重新登录。项目文件仍保留在原位置。", + "native.changingChannelsRequiresAFullInstallationAndMayInstallAnOlderVersionOpenTheSelda3c463026": "切换渠道需要全量安装,可能安装较旧的版本。请打开所选渠道的发布页下载安装包。渠道选择已保存,用于后续更新。", + "native.charactersUsedByTheInterfaceAndYourFiles": "界面和文件用到的字符", + "native.chars": "字符", + "native.chatCompletions": "Chat Completions", + "native.chatHistory": "对话历史", + "native.chatOptions": "对话选项", + "native.chatgptSignInWasCancelled": "已取消 ChatGPT 登录。", + "native.check": "检查:{arg1}", + "native.checkAgain": "重新检查", + "native.checkComplete": "检查完成。", + "native.checkCompleteSomeCLIUpdateResultsAreUnavailableSeePreflight": "检查完成。部分 CLI 的更新状态无法确认,请查看预检信息。", + "native.checkFailed": "检查失败。", + "native.checkForUpdates": "检查更新", + "native.checkForUpdates.variant2": "检查更新…", + "native.checkTheConfigurationFormatAPIURLAndModelEachConfigurationMustBeUnder64KiB": "请检查配置格式、API 地址和模型,每份配置不得超过 64 KiB。", + "native.checkYourInternetConnectionAndTryAgainLater": "请检查网络连接,稍后再试。", + "native.checkedAgainEveryTimeLitheLoadsThePlugin": "每次加载插件时都会重新校验。", + "native.checking": "检测中…", + "native.checkingForUpdates": "正在检查更新…", + "native.checkingGitHubReleases": "正在检查 GitHub Releases…", + "native.checkingGitRepository": "正在检查 Git 仓库…", + "native.checkingNodeJsAndNpm": "正在检测 Node.js 和 npm…", + "native.checkingOutPullRequest": "正在检出拉取请求…", + "native.checkingSelectedCommitsAndRepositoryState": "正在检查所选提交和仓库状态…", + "native.checkingTheBranchAndAffectedCommits": "正在检查分支及受影响的提交…", + "native.checkout": "检出", + "native.checkout.variant2": "检出", + "native.checkoutAndRebase": "检出并变基", + "native.checkoutAndRebaseBranch": "检出并变基分支?", + "native.checkoutAndRebaseOntoCurrentBranch": "检出并变基到当前分支", + "native.checkoutBranchAfterCreation": "创建后检出分支", + "native.checkoutPath": "检出路径", + "native.checkoutPathMissing": "检出路径不存在", + "native.checkoutTagOrRevision": "检出标签或修订版本", + "native.checkoutTagOrRevision.variant2": "检出标签或修订版本…", + "native.checkoutThenReplayItOntoTheBranchThatIsCurrentNow": "检出 {arg1},然后将其提交重放到当前分支之上。", + "native.cherryPick": "拣选", + "native.cherryPick.variant2": "拣选", + "native.cherryPickCommit": "拣选提交…", + "native.cherryPickInProgress": "正在拣选", + "native.cherryPickThisCommit": "拣选此提交?", + "native.chineseCharacters": "中文字符", + "native.choose": "选择…", + "native.chooseABaseAndCompareBranchThenDescribeThePullRequest": "选择目标分支和比较分支,然后填写拉取请求说明。", + "native.chooseABehaviorOnTheRightLitheShowsTheValueGitIsUsingAndTheValueAResetWouldRestore": "在右侧选择行为。Lithe 会显示 Git 当前使用的值,以及重置后会恢复的值。", + "native.chooseAColorThemeAndWhetherLitheFollowsTheSystemAppearance": "选择配色主题,并设置是否跟随系统外观。", + "native.chooseACommitFormat": "选择提交格式", + "native.chooseADatabaseConnectionAndThenATableFromTheSidebar": "请先选择数据库连接,再从侧边栏选择一个表。", + "native.chooseADefaultWebBrowserInSystemSettingsThenRetry": "请在系统设置中选择默认网页浏览器,然后重试。", + "native.chooseADirectoryInsideTheProject": "请选择项目内的目录", + "native.chooseAGitExecutableAboveToContinue": "请先在上方选择 Git 可执行文件。", + "native.chooseAJDKDirectory": "选择 JDK 目录", + "native.chooseAJDKInSettingsProjectOrInstallAFullJDKAndSetJAVAHOME": "请在“设置 → 项目”中选择 JDK,或安装完整 JDK 并设置 JAVA_HOME。", + "native.chooseALocalOrCustomProviderInTheAgentPanelSettings": "请在设置窗口的 Agent 页面中选择本机配置或自定义供应商。", + "native.chooseALocalOrCustomProviderSoTheAgentCanSignIn": "请选择本机配置或自定义供应商,Agent 才能登录。", + "native.chooseAModuleEnableDetailedDiagnosticsReproduceTheProblemThenPreviewAndExportADiagnosticBundle": "选择模块,开启详细诊断并复现问题,然后预览并导出诊断包。", + "native.chooseAPullRequestToReviewItsContextFilesAndConversation": "选择一个拉取请求以查看上下文、文件和讨论。", + "native.chooseARunConfigurationForThisMavenModule": "请为此 Maven 模块选择运行配置", + "native.chooseATableFromTheSidebarToBrowseAndEditData": "从侧边栏选择一个表以浏览和编辑数据。", + "native.chooseATableToInspectItsColumnsIndexesAndForeignKeys": "选择一个表以查看其列、索引和外键。", + "native.chooseAValue": "选择一个值", + "native.chooseAnAIProviderBelowSoTheAgentCanSignIn": "请在下方选择 AI 服务商,Agent 才能登录。", + "native.chooseAnAgent": "选择 Agent", + "native.chooseAnAgentToStart": "选择一个 Agent 开始", + "native.chooseAnotherJDKInSettingsProjectOrClearTheInvalidPath": "请在“设置 → 项目”中选择其他 JDK,或清除无效路径。", + "native.chooseAtLeastOneAgent": "请至少选择一个 Agent。", + "native.chooseDatabase": "选择数据库", + "native.chooseDirectory": "选择目录", + "native.chooseDirectory.variant2": "选择目录", + "native.chooseExistingFilesToAddToAConversation": "请选择仍存在的文件添加到会话。", + "native.chooseHowLitheLocatesGitAndHandlesCredentials": "选择 Lithe 如何查找 Git 并处理凭据。", + "native.chooseHowThisGitSettingBehaves": "选择这个 Git 设置的行为。", + "native.chooseImage": "选择图片…", + "native.chooseImplementation": "选择实现", + "native.chooseLogDirectory": "选择日志目录", + "native.chooseMavenExecutableOrHome": "选择 Maven 可执行文件或主目录", + "native.chooseMavenOrMvndHomeBinOrExecutable": "选择 Maven 或 mvnd 的主目录、bin 目录或可执行文件", + "native.chooseParent": "选择父目录…", + "native.choosePluginPackage": "选择插件包", + "native.chooseRuntimeDirectory": "选择运行时目录", + "native.chooseServicesToRun": "选择要运行的服务", + "native.chooseStepTarget": "选择步进目标", + "native.chooseTwoBranches": "请选择两个分支", + "native.chooseWhatHappensToProgramsStartedInLitheWhenYouQuitOrCloseATerminalProgramsStb8837e766c": "选择退出 Lithe 或关闭终端时,如何处理在 Lithe 中启动的程序。在 Lithe 之外启动的程序不会被列出,也不会被结束。", + "native.chooseWhatPullDoesWhenYourBranchAndTheRemoteBranchHaveDiverged": "选择本地分支和远程分支分叉时 Pull 的处理方式。", + "native.chooseWhetherFetchAlsoUpdatesSubmodules": "选择 Fetch 是否同时更新子模块。", + "native.chooseWhetherLitheFollowsTheSystemAppearanceOrAlwaysUsesALightOrDarkTheme": "选择跟随系统外观,或始终使用浅色或深色主题。", + "native.chooseWhetherOpeningAnotherProjectAsksFirstStaysInThisWindowOrCreatesANewWindow": "选择打开其他项目时是每次询问、保留在此窗口,还是创建新窗口。", + "native.chooseWhetherPullMergesRemoteCommitsOrRebasesYourLocalCommits": "选择 Pull 是合并远程提交,还是把本地提交变基到远程提交之后。", + "native.chooseWhetherThisSettingBelongsToTheOpenRepositoryOrToGitEverywhere": "选择将此设置应用于当前仓库,还是应用于 Git 的全局配置。", + "native.chooseWhichBranchPushTargetsWhenYouDoNotNameOne": "未指定目标分支时,选择 Push 要推送到哪个分支。", + "native.chooseWhichBranchToUpdateBeforePushing": "推送前请选择要更新的分支。", + "native.chooseWorkbenchBackground": "选择工作台背景", + "native.classes": "类", + "native.claudeAgent": "Claude Agent", + "native.claudeConfigurationDetected": "已检测到 Claude 配置", + "native.claudeConfigurationImported": "Claude 配置已导入", + "native.claudeHandlesPermissionDecisions": "由 Claude 自动评估并审批操作。", + "native.claudeMessagesAPI": "Claude Messages API", + "native.claudeSettingsAndCredentialsAreReadDirectlyFromItsLocalConfigurationFiles": "Claude 配置和凭据会直接从本地配置文件读取。", + "native.clearAll": "全部清除", + "native.clearBranchFilter": "清除分支筛选", + "native.clearBuildOutput": "清除构建输出", + "native.clearCollectedLogs": "清空已收集的日志?", + "native.clearConflictFilter": "清除冲突筛选", + "native.clearDateFilter": "清除日期筛选", + "native.clearDebugOutput": "清除调试输出", + "native.clearGitConsole": "清空 Git 控制台", + "native.clearLogSearch": "清除日志搜索", + "native.clearLogs": "清空日志", + "native.clearOverride": "清除覆盖", + "native.clearPathFilter": "清除路径筛选", + "native.clearRunOutput": "清除运行输出", + "native.clearSearch": "清除搜索", + "native.clearSelection": "清除勾选", + "native.clearTheCurrentProjectSJavaIndexAndRebuildItOnNextUse": "清除当前项目的 Java 索引,并在下次使用时重建", + "native.clearTheOverrideAndInherit": "清除覆盖并恢复继承", + "native.clearUserFilter": "清除用户筛选", + "native.cliUpdatedTo": "CLI 已更新至 {arg1}", + "native.clickTheEditorGutterToAddABreakpoint": "点击编辑器左侧行号区域添加断点", + "native.clickTheFoldedOptionsToExpandRightClickForCommandDetails": "点击折叠参数可展开;右键查看命令详情。", + "native.clickTheRunIconInTheEditorGutter": "— 点击编辑器行号栏中的运行图标", + "native.clineCursorKiloMistralAndMoreWithACPTechnology": "Cline、Cursor、Kilo、Mistral,以及更多采用 ACP 技术的 Agent", + "native.clojureSupport": "Clojure 支持", + "native.clone": "克隆", + "native.cloneRepository": "克隆仓库", + "native.cloneRepository.variant2": "克隆仓库…", + "native.closeAllTabs": "关闭所有标签页", + "native.closeComparison": "关闭比较", + "native.closeConversation": "关闭对话", + "native.closeDiff": "关闭 Diff", + "native.closeEsc": "关闭(Esc)", + "native.closeFind": "关闭查找", + "native.closeGitConsole": "关闭 Git 控制台", + "native.closeOtherTabs": "关闭其他标签页", + "native.closeProject": "关闭项目", + "native.closePullRequest": "关闭拉取请求", + "native.closePullRequest.variant2": "关闭拉取请求", + "native.closePullRequest.variant3": "关闭拉取请求?", + "native.closeRunConfiguration": "关闭运行配置", + "native.closeRunningTerminal": "关闭正在运行的终端?", + "native.closeSearch": "关闭搜索", + "native.closeSettings": "关闭设置", + "native.closeSplit": "关闭分栏", + "native.closeTabsToTheLeft": "关闭左侧标签页", + "native.closeTabsToTheRight": "关闭右侧标签页", + "native.closeTerminal": "关闭终端", + "native.closeTerminal.variant2": "关闭终端“{arg1}”?", + "native.closeUnmodifiedTabs": "关闭未修改的标签页", + "native.closed": "已关闭", + "native.closingATabEndsEveryProgramStartedInItIncludingJobsSentToTheBackgroundTurnThis6e11c0ada9": "关闭标签页会结束其中启动的所有程序,包括放到后台的任务。关闭此选项后标签页会立即关闭。已脱离终端的后台服务不会在这里结束,而是在退出 Lithe 时再询问。", + "native.closingATerminalTab": "关闭终端标签页", + "native.closingPullRequest": "正在关闭拉取请求…", + "native.closingTheProjectStopsTheseServicesAndTheProcessesTheyStarted": "关闭项目会停止这些服务及其启动的进程。", + "native.closingTheTerminalEndsThesePrograms": "关闭终端会结束这些程序。", + "native.closingThisTerminalWillStopItsShellAndAnyRunningCommand": "关闭此终端将停止其 Shell 和所有正在运行的命令。", + "native.cmakeSupport": "CMake 支持", + "native.code": "代码", + "native.codex": "Codex", + "native.codexAgentsRequireTheResponsesAPIClaudeAgentsRequireAnAnthropicAPIKey": "Codex Agent 需要 Responses API,Claude Agent 需要 Anthropic API 密钥。", + "native.codexArchivedHistoryCouldNotBeRead": "无法读取 Codex 的归档历史。", + "native.codexConfigurationDetected": "已检测到 Codex 配置", + "native.codexConfigurationImported": "Codex 配置已导入", + "native.codexCouldNotRestoreThisArchivedConversation": "Codex 无法取消归档此会话。", + "native.codexSettingsAndAPIKeyAreReadDirectlyFromCodexConfigTomlAndCodexAuthJson": "Codex 配置和 API 密钥会直接从 ~/.codex/config.toml 与 ~/.codex/auth.json 读取。", + "native.codexSettingsAndCredentialsAreReadDirectlyFromItsLocalConfigurationFiles": "Codex 配置和凭据会直接从本地配置文件读取。", + "native.codexSubscription": "Codex 官方订阅(本机账号)", + "native.codexSubscriptionQuota": "Codex 订阅额度", + "native.collaborateWithClaudeAndCodexToAskQuestionsRunTasksAndReviewCodeChanges": "与 Claude、Codex 协作,提问、执行任务并审查代码修改。", + "native.collaborationMode": "协作模式", + "native.collapse": "折叠", + "native.collapseAll": "全部折叠", + "native.collapseDirectory": "折叠目录", + "native.collapseJSONBlock": "收起 JSON 段落", + "native.collapseLongGraphEdges": "收束长连线", + "native.collapseNotification": "收起通知", + "native.collapseRepository": "折叠仓库", + "native.collapseSection": "折叠分组", + "native.collapsed": "已折叠", + "native.collectedLogsCleared": "已清空收集的日志。", + "native.colorTheme": "配色主题", + "native.combineTheMessagesWhenTheRebaseRunsIncludingChangesMadeAtEarlierEditStops": "变基执行时合并消息,包含此前暂停修订时更新的消息", + "native.combinedCommitMessage": "合并后的提交消息", + "native.combinesThePullRequestIntoOneCommitOnTheBaseBranch": "将拉取请求压缩为目标分支上的一个提交。", + "native.command": "命令", + "native.commandDetails": "命令详情", + "native.commandPaletteActions": "命令面板入口", + "native.commandsAnAIAgentStartedThatAreStillRunningForExampleADeploymentADatabaseMigradd876f8a4a": "AI Agent 启动后仍在运行的命令,例如部署、数据库迁移或开发服务器。由于中途结束可能导致数据或服务不一致,每个任务都会单独确认。Agent 自身需要的进程(例如它的 MCP 服务)总是随 Agent 一起结束。", + "native.comment": "评论", + "native.commentPosted": "评论已发表", + "native.comments": "评论", + "native.commit": "提交", + "native.commit.variant2": "提交…", + "native.commitAndPush": "提交并推送", + "native.commitAndPush.variant2": "提交并推送…", + "native.commitAndPush.variant3": "提交并推送", + "native.commitAndPushChanges": "提交并推送更改吗?", + "native.commitChangedFiles": "提交更改的文件", + "native.commitChangedFilesInTheSubmoduleFirst": "请先在子模块中提交修改的文件", + "native.commitDIFF": "提交 Diff", + "native.commitDetails": "提交详情", + "native.commitHistory": "提交历史", + "native.commitHistoryLld": "提交历史({arg1} 条)", + "native.commitIdentity": "提交身份", + "native.commitMessage": "提交信息", + "native.commitMessage.variant2": "提交消息", + "native.commitMessage.variant3": "提交说明:{arg1}", + "native.commitMessageGenerated": "提交信息已生成", + "native.commitMessageGeneration": "提交信息生成", + "native.commitMessageProvider": "提交信息服务商", + "native.commitMessageReplaced": "提交信息已替换", + "native.commitMessages": "提交信息", + "native.commitMessagesUse": "提交信息使用", + "native.commitTitle": "提交标题", + "native.commitVersion": "提交版本", + "native.commits": "提交", + "native.committed": "已提交", + "native.committedAndPushed": "已提交并推送", + "native.committedChangesButDetachedHEADCannotBePushed": "更改已提交,但 detached HEAD 无法推送", + "native.committedPushFailed": "已提交,推送失败", + "native.committedPushPending": "已提交,等待推送", + "native.committerEmail": "提交者邮箱", + "native.committerName": "提交者姓名", + "native.community": "社区", + "native.communityDisk": "社区 / 磁盘", + "native.communityPluginInstalledFromDiskItsPublisherAndRepositoryHaveNotBeenVerified": "从磁盘安装的社区插件,尚未验证发布者与源码仓库。", + "native.communityPluginMaintainedByAThirdPartyReportPluginProblemsInItsOwnRepository": "社区插件由第三方维护,插件问题请到它自己的仓库反馈。", + "native.communityPluginsAreMaintainedByThirdPartiesAndCanRunTheProgramsAboveOnYourMacO5dbe99fc4e": "社区插件由第三方维护,可以在你的 Mac 上运行上面列出的程序。请只安装你信任的插件。", + "native.compare": "比较", + "native.compareChanges": "比较更改", + "native.compareCurrentBranchWithWorkingTree": "比较当前分支与工作区", + "native.compareWith": "比较“{arg1}”与“{arg2}”", + "native.compareWithCurrentBranch": "与当前分支比较", + "native.comparingChanges": "比较更改", + "native.completeCommitMessage": "完整提交消息", + "native.completeSignInInYourBrowserYourCredentialsAreManagedByCodex": "请在浏览器中完成登录,凭据由 Codex 管理。", + "native.completed": "已完成", + "native.compressionIsUnavailableShowingCompleteRetainedOutput": "暂时无法压缩,正在显示完整保留的输出。", + "native.concise": "简洁", + "native.conciseSentence": "简洁一句话", + "native.configuration": "配置", + "native.configurationDetails": "配置详情", + "native.configurationID": "配置 ID", + "native.configurationKey": "配置项", + "native.configurationOverrides": "当前配置覆盖", + "native.configurationScope": "配置范围", + "native.configurationSources": "配置来源", + "native.configurationValue": "配置值", + "native.configure": "配置", + "native.configureAnAIProviderInSettingsFirst": "请先在设置中配置 AI 服务商。", + "native.configureDependencySearchPaths": "配置依赖搜索路径", + "native.configureEditorAndProjectBehavior": "配置编辑器和项目行为", + "native.configureInSettings": "在设置中配置", + "native.configureProjectJDKAndMaven": "配置项目 JDK 和 Maven…", + "native.configured": "已配置", + "native.configuredRemoteName": "已配置的远程仓库名称", + "native.confirmDeletion": "确认删除", + "native.conflict": "冲突", + "native.conflict.variant2": "版本冲突 → {arg1}", + "native.conflicted": "冲突", + "native.conflicts": "存在冲突", + "native.conflictsWith": "与 {arg1} 冲突", + "native.connect": "连接", + "native.connectAGitHubAccountBeforeContinuing": "请先连接 GitHub 账号", + "native.connectToDatabasesBrowseSchemasEditDataRunSQLAndManageBackupsFromTheDatabaseWorkspace": "连接数据库、浏览结构、编辑数据、运行 SQL,并在数据库工作区中管理备份。", + "native.connectToRunningJVM": "连接到正在运行的 JVM", + "native.connected": "已连接", + "native.connectedAgentsCanChangeTheProjectJDKMavenAndRunConfigurationSettings": "连接的 Agent 可以修改项目 JDK、Maven 和运行配置。", + "native.connectedAgentsCanRunBuildReloadAndStopTheProjectCommandsExecuteProjectCodeSoE6d273dab37": "连接的 Agent 可以运行、构建、重载和停止项目。命令会执行项目代码,请仅对信任的项目开启。", + "native.connectedAgentsCanUseTheConfigurationBelowWhileThisProjectIsOpen": "项目打开期间,连接的 Agent 可以使用下方配置。", + "native.connectedAs": "已连接为 @{arg1}", + "native.connectionRecoveryIsTakingLongerTheAgentIsStillRetrying": "连接恢复等待较久,Agent 仍在重试。", + "native.connectionsLld": "连接:{arg1}", + "native.console": "控制台", + "native.content": "内容", + "native.contentSHA256": "内容 SHA-256", + "native.context": "上下文", + "native.context.variant2": "上下文: {arg1}", + "native.contextTokens": "{arg1} · {arg2} / {arg3} 上下文 token", + "native.contextTokens.variant2": "上下文 Token", + "native.contextUsage": "上下文用量", + "native.contextUsageIsNotReportedByThisAgent": "此 Agent 尚未提供上下文用量", + "native.contextUsed": "{arg1} · {arg2} / {arg3} 上下文已使用", + "native.contextUsed.variant2": "上下文已用 {arg1}", + "native.continueCherryPick": "继续拣选", + "native.continueInstallation": "继续安装", + "native.continueMerge": "继续合并", + "native.continueRebase": "继续变基", + "native.continueRevert": "继续还原", + "native.continueWaiting": "继续等待", + "native.controlsHowLitheSOrdinaryFetchSynchronizesTags": "控制 Lithe 普通 Fetch 如何同步标签。", + "native.controlsHowLitheSOrdinaryFetchSynchronizesTagsInheritLeavesTagSelectionToGitAndTheRemote": "控制 Lithe 普通 Fetch 如何同步标签。“继承”表示把标签选择交给 Git 和远程仓库。", + "native.controlsHowLitheSOrdinaryFetchUpdatesSubmodules": "控制 Lithe 普通 Fetch 如何更新子模块。", + "native.controlsHowLitheSOrdinaryFetchUpdatesSubmodulesInheritFollowsGitSFetchRecurseSubmodulesSetting": "控制 Lithe 普通 Fetch 如何更新子模块。“继承”表示遵循 Git 的 fetch.recurseSubmodules 设置。", + "native.controlsWhetherLitheSOrdinaryFetchRemovesStaleRemoteBranches": "控制 Lithe 普通 Fetch 是否清理失效的远程分支。", + "native.conventionalCommits": "Conventional Commits 规范", + "native.conversation": "讨论", + "native.conversationHistory": "对话历史", + "native.conversationHistoryIsUnavailable": "当前无法读取对话历史", + "native.conversationTabs": "会话标签", + "native.conversationTitle": "会话标题", + "native.conversationsForThisProjectAndAgentAppearHere": "此项目和 Agent 的会话会显示在这里。", + "native.copied": "复制", + "native.copiedFiles": "已拷贝文件", + "native.copyAgentConfiguration": "复制 Agent 配置", + "native.copyBranchName": "复制分支名称", + "native.copyCode": "复制代码", + "native.copyCommand": "复制命令", + "native.copyCommitHash": "复制提交哈希", + "native.copyCompleteCommand": "复制完整命令", + "native.copyCompleteConsole": "复制完整控制台", + "native.copyCompleteOutput": "复制完整输出", + "native.copyConversation": "复制对话", + "native.copyFiles": "拷贝", + "native.copyJSON": "复制 JSON", + "native.copyMessage": "复制消息", + "native.copyName": "复制名称", + "native.copyOriginalCommand": "复制原始命令", + "native.copyOutput": "复制输出", + "native.copyPath": "复制路径", + "native.copyPathReference": "复制路径 / 引用", + "native.copyProjectPath": "复制项目路径", + "native.copyRecoveryReference": "复制恢复引用", + "native.copyRelativePath": "复制相对路径", + "native.copyReply": "复制回复", + "native.copyRepositoryPath": "复制仓库路径", + "native.copySessionID": "复制会话 ID", + "native.copyShortHash": "复制短哈希", + "native.copyTable": "复制表格", + "native.copyWorktreePath": "复制工作树路径", + "native.couldNotCheckForUpdates": "无法检查更新", + "native.couldNotCheckForUpdates.variant2": "无法检查更新。", + "native.couldNotCompareTheLocalAdapterWithTheVerifiedVersion": "无法比较本地适配器与已核验版本。", + "native.couldNotConfirm": "无法确认", + "native.couldNotCopyYourStableSettings": "无法复制正式版设置", + "native.couldNotInspectSavedFile": "无法读取已保存的文件", + "native.couldNotInspectTheSelectedHistoryRefreshThePreviewToRetry": "无法检查所选历史。请刷新预览后重试。", + "native.couldNotInspectThisWorktree": "无法检查此工作树", + "native.couldNotInstallUpdate": "无法安装更新", + "native.couldNotLoadChangedFiles": "无法加载更改的文件", + "native.couldNotLoadFilePreview": "无法加载文件预览", + "native.couldNotLoadProject": "无法加载项目", + "native.couldNotLoadSavedChanges": "无法加载已保存的更改", + "native.couldNotLoadServiceDependencies": "无法加载服务依赖", + "native.couldNotLoadTheChangedFiles": "无法加载更改的文件。", + "native.couldNotMoveTheEditorTabItsContentCouldNotBeSynchronized": "无法移动编辑标签,未能同步其内容。", + "native.couldNotOpenHTMLInBrowser": "无法在浏览器中打开 HTML", + "native.couldNotRead": "无法读取 {arg1}:{arg2}", + "native.couldNotReadTheProjectFolderCheckThatItStillExistsAndThatLitheHasPermissionToAccessIt": "无法读取项目文件夹,请确认文件夹仍然存在并允许 Lithe 访问。", + "native.couldNotRegisterLitheMCPIn": "无法在 {arg1} 中注册 Lithe MCP:{arg2}", + "native.couldNotRemoveLitheMCPFrom": "无法从 {arg1} 中移除 Lithe MCP:{arg2}", + "native.couldNotRestoreLocalHistory": "无法恢复本地历史", + "native.couldNotRestoreProjectHistory": "无法恢复项目历史", + "native.couldNotRestoreThisProjectHistoryEntry": "无法恢复此项目历史记录", + "native.couldNotSaveAccessToTheSelectedBackgroundImage": "无法保存所选背景图片的访问权限。", + "native.couldNotSaveServiceSelection": "无法保存服务勾选状态", + "native.couldNotVerifyTheCommitOutcomeReviewBeforeRetrying": "无法确认提交结果,请检查后再重试。", + "native.couldNotWriteThePreviousContentsWereKeptIn": "无法写入 {arg1}。之前的内容已保留在 {arg2}。", + "native.cpu": "CPU", + "native.create": "创建", + "native.createABranchAtTheHistorySavedBeforeThisRebaseYourCurrentCheckoutStaysInPlace": "从变基前保存的历史创建分支,不会切换当前检出。", + "native.createABranchAtTheOriginalHistorySoYouCanInspectItInGitLogYourCurrentCheckoutStaysInPlace": "从改写前的历史创建分支,便于在提交日志中查看;不会切换当前检出。", + "native.createACheckoutToGetStarted": "新建一个检出目录以开始使用。", + "native.createAMergeCommit": "创建合并提交", + "native.createANewCommitThatReverses": "创建一个新提交,撤销提交 {arg1} 的更改。", + "native.createAPlanBeforeMakingChanges": "阅读和分析后制定计划,不修改文件。", + "native.createASharedProjectConfigurationOrALocalOverride": "创建团队共享的项目配置,或仅本机生效的覆盖配置。", + "native.createAnIndependentCheckoutAndANewBranchFromTheSelectedReference": "从所选引用创建独立检出目录和新分支。", + "native.createAsDraft": "创建为草稿", + "native.createBranch": "创建分支", + "native.createDraft": "创建草稿", + "native.createFrom": "从“{arg1}”创建。", + "native.createMergeCommit": "创建合并提交", + "native.createOnCommitLeaveTheMessageEmptyForALightweightTag": "在提交 {arg1} 上创建标签。留空说明可创建轻量标签。", + "native.createPatch": "创建补丁", + "native.createPatch.variant2": "创建补丁…", + "native.createPatchBetweenCommits": "导出两次提交间的补丁…", + "native.createPullRequest": "创建拉取请求", + "native.createPullRequest.variant2": "创建拉取请求", + "native.createRecoveryBranch": "创建恢复分支", + "native.createRecoveryBranch.variant2": "创建恢复分支…", + "native.createWithoutCheckingOutFiles": "创建但不检出文件", + "native.createdOutsideLithe": "该条目在 Lithe 之外创建。", + "native.createdWorktreeFor": "已为 {arg1} 创建工作树", + "native.createsANewBranchOn": "将在 {arg1} 上创建新分支", + "native.creatingArchive": "正在创建压缩包…", + "native.creatingPullRequest": "正在创建拉取请求…", + "native.credentialAvailableInClaudeConfiguration": "Claude 配置中存在凭据", + "native.credentialAvailableInCodexConfiguration": "Codex 配置中存在凭据", + "native.credentials": "凭据", + "native.credentialsUseYourExistingGitHelperAndSSHConfiguration": "凭据沿用现有 Git 凭据助手和 SSH 配置。", + "native.cssSupport": "CSS 支持", + "native.current": "当前占用", + "native.current.variant2": "当前:{arg1}", + "native.currentApplicationMemoryAverageSinceLaunch": "当前应用内存:{arg1}\n启动以来平均:{arg2}", + "native.currentBranch": "当前分支", + "native.currentBranchWithSafetyCheck": "当前分支(带安全检查)", + "native.currentCommit": "当前提交:{arg1}", + "native.currentFile": "当前文件", + "native.currentFileRemainsAvailableAddASupportedJavaOrMavenEntryPointThenIdentifyTheProjectAgain": "仍可使用“当前文件”运行。请添加受支持的 Java 或 Maven 启动入口,然后重新识别项目。", + "native.currentGitHubRepository": "当前 GitHub 仓库", + "native.currentProvider": "当前供应商:{arg1}", + "native.currentRepository": "当前仓库", + "native.currentRun": "当前运行", + "native.currentSession": "当前会话", + "native.currentVersion": "当前版本", + "native.currentVersion.variant2": "当前版本", + "native.currentVersionVerifiedTarget": "当前版本:{arg1} · 已核验目标:{arg2}", + "native.currentlyEffective": "当前生效", + "native.custom": "自定义", + "native.customAPIProvider": "自定义 API 服务", + "native.customAgent": "自定义 Agent", + "native.customInstructions": "自定义指令", + "native.customMavenHome": "自定义 Maven 主目录", + "native.customTemplate": "自定义模板", + "native.customizeShortcutsForLitheActionsChangesApplyImmediately": "自定义 Lithe 操作的快捷键,修改后立即生效。", + "native.cyrillicLetters": "西里尔字母", + "native.dConversations": "共 {arg1} 个会话", + "native.dMessages": "{arg1} 条消息", + "native.dSelected": "已选 {arg1} 项", + "native.daemon": "守护进程", + "native.dangerZone": "危险操作", + "native.dark": "深色", + "native.dartSupport": "Dart 支持", + "native.database": "数据库连接", + "native.date": "日期", + "native.debug": "调试", + "native.debugResume": "调试:继续", + "native.debugStepInto": "调试:步入", + "native.debugStepOut": "调试:步出", + "native.debugStepOver": "调试:步过", + "native.debugTarget": "调试目标", + "native.debugger": "调试器", + "native.debuggingCode": "调试代码", + "native.decreaseFontSize": "减小字号", + "native.deepReasoningForComplexTasks": "深度推理,适合复杂任务", + "native.default": "默认", + "native.defaultChangeList": "默认更改列表", + "native.defaultDirectory": "默认目录", + "native.defaultOptions": "默认参数", + "native.defaultPushBehavior": "默认 Push 行为", + "native.defaultShell": "默认 Shell", + "native.defaultsForOrdinaryFetchInEveryProjectARepositorySOwnGitConfigurationAboveStillWinsWhereItIsSet": "所有项目普通获取的默认值。仓库自身的 Git 配置一旦设置,仍以其为准。", + "native.definitionNotFound": "没有找到定义", + "native.definitions": "定义", + "native.deleteBranch": "删除分支", + "native.deleteBranch.variant2": "删除分支?", + "native.deleteChangeList": "删除更改列表", + "native.deleteChangeList.variant2": "删除更改列表?", + "native.deleteChat": "删除对话", + "native.deleteCollectedApplicationLogs": "删除已收集的应用日志?", + "native.deleteOnlyLitheManagedLogsInTheActiveDirectoryAcrossAllModulesAndSessionsFilte2970fa47b9": "仅删除生效目录中由 Lithe 管理的所有模块和会话日志。筛选条件不限制清理范围,后续事件仍会继续记录。", + "native.deleteProvider": "删除供应商", + "native.deleteTag": "删除标签…", + "native.deleteTag.variant2": "删除标签“{arg1}”?", + "native.deleteTagTargetIsNotACommit": "删除标签…(目标不是提交)", + "native.deleteTheLocalBranchGitWillRefuseIfItContainsUnmergedWork": "删除本地分支 {arg1}?如果包含未合并的更改,Git 将拒绝删除。", + "native.deleteThisProviderAndItsSavedAPIKeyAgentsUsingItWillBeUnlinkedItIsAlsoRemovedFc47686de94": "确定删除此供应商和已保存的 API 密钥吗?使用它的 Agent 将取消关联,设置中的 AI 服务商也会移除此项。", + "native.deleted": "删除", + "native.deletedBranch": "已删除分支“{arg1}”", + "native.deletedReferences": "已删除引用:{arg1}", + "native.deletedTag": "已删除标签“{arg1}”", + "native.deletions": "删除", + "native.deny": "拒绝", + "native.dependencies": "依赖", + "native.dependencyPaths": "依赖路径", + "native.dependencyResolutionCancelled": "依赖解析已取消", + "native.deprecatedAPI": "已弃用的 API", + "native.description": "描述", + "native.descriptionFormat": "描述格式", + "native.deselectAll": "全不选", + "native.deselectAll.variant2": "取消全选", + "native.destinationFolder": "目标文件夹", + "native.detachedHEAD": "分离 HEAD", + "native.detailed": "详细", + "native.detailedDiagnostics": "详细诊断", + "native.detailedDiagnosticsCoverTheSelectedModuleAndExpireAfter15MinutesRefreshToSeeTheCurrentStatus": "详细诊断仅覆盖所选模块,15 分钟后自动关闭。刷新可查看当前状态。", + "native.detailedDiagnosticsFor15Minutes": "详细诊断(15 分钟后关闭)", + "native.detailedDiagnosticsOff": "详细诊断:已关闭", + "native.detailedDiagnosticsOn": "详细诊断:已开启({arg1})", + "native.detailedSubjectBody": "详细说明(标题 + 正文)", + "native.detectBuildSystemsAgain": "重新检测构建系统", + "native.detectInstalledShells": "检测已安装的 Shell", + "native.detectedConfigurations": "检测到的配置", + "native.detectedJDK": "检测到的 JDK", + "native.detectedMaven": "检测到的 Maven", + "native.detectedOnThisMac": "本机检测到", + "native.detectedSystemJDK": "检测到的系统 JDK", + "native.detecting": "正在检测…", + "native.detectingBuildSystems": "正在检测构建系统…", + "native.developerTools": "开发者工具", + "native.diagnosticBundle": "诊断包", + "native.diagnostics": "诊断", + "native.diagnosticsBundle": "诊断包", + "native.diagnosticsFromJDTLSWillAppearHere": "JDT LS 的诊断信息会显示在这里。", + "native.didNotReportAVersionOrNewerIsRequired": "{arg1} 没有报告版本,需要 {arg2} 或更高版本。", + "native.diff": "Diff", + "native.diffCharacterLimit": "Diff 字符限制", + "native.diffWith": "差异:{arg1} 与 {arg2}", + "native.differentJDKVendorSelected": "选择了不同的 JDK 发行版", + "native.directory": "目录", + "native.directory.variant2": "目录:{arg1}", + "native.directoryOrFileName": "目录或文件名", + "native.disable": "禁用", + "native.disableAutomaticScrolling": "禁用自动滚动", + "native.disableBreakpoint": "禁用断点", + "native.disableExceptionBreakpoint": "禁用异常断点 {arg1}", + "native.disableFieldBreakpoint": "禁用字段断点 {arg1}", + "native.disableMCP": "关闭 MCP", + "native.disableMethodBreakpoint": "禁用方法断点 {arg1}", + "native.disableSoftWraps": "禁用自动换行", + "native.disabled": "{arg1} 已停用", + "native.disabled.variant2": "已禁用", + "native.discard": "丢弃", + "native.discardBlock": "丢弃代码块", + "native.discardChanges": "丢弃更改", + "native.discardSelectedChange": "丢弃选中的更改", + "native.discardThisChangeBlock": "丢弃此更改代码块", + "native.discardThisFileAndRetry": "丢弃此文件的更改并重试", + "native.discardedEditorChanges": "丢弃编辑器更改", + "native.disconnect": "断开连接", + "native.disconnected": "已断开", + "native.discovering": "正在检测…", + "native.discoveringMaven": "正在检测 Maven…", + "native.dismiss": "关闭提示", + "native.dismissHistoryOperationResult": "关闭历史操作结果", + "native.dismissNotification": "关闭通知", + "native.dismissRebaseResult": "关闭变基结果", + "native.dismissResults": "清除结果", + "native.display": "显示", + "native.doNotFetch": "不获取", + "native.doNotFetchSubmodules": "不获取子模块", + "native.doNotFetchTags": "不获取标签", + "native.doNotIgnore": "不忽略空白", + "native.doNotPushByDefault": "默认不 Push", + "native.dockerCompose": "Docker Compose", + "native.dockerfileSupport": "Dockerfile 支持", + "native.documentation": "文档", + "native.doesNotIncludeLitheRendersThoseCharactersWithAndTheSystemFont": "{arg1} 不包含{arg2};Lithe 会用 {arg3} 和系统字体显示这些字符。", + "native.doesNotProvideArchivedConversations": "{arg1} 不支持归档会话。", + "native.donTAllow": "不允许", + "native.donTAskAgain": "不再询问", + "native.download": "下载", + "native.download.variant2": "下载 {arg1}", + "native.downloadAndInstall": "下载并安装", + "native.downloadPHPSupportFromTheOfficialPluginRelease": "从官方插件版本下载 PHP 支持。", + "native.downloadPHPSupportFromTheOfficialSignedPluginPackage": "从官方签名的插件包下载 PHP 支持。", + "native.downloadStableRelease": "下载正式版", + "native.downloadedLldOfLldEnabled": "已下载({arg1} 个,{arg2} 个已启用)", + "native.downloadedS": "已下载 {arg1} · {arg2}/秒", + "native.downloadingFullStableRelease": "正在下载正式版完整安装包…", + "native.downloadingPackages": "正在下载软件包…", + "native.downloadingUpdate": "正在下载更新 {arg1}…", + "native.draft": "草稿", + "native.dragFilesHereOrClickToChooseFiles": "拖入文件,或点击选择文件", + "native.drop": "丢弃", + "native.drop.variant2": "删除 {arg1}?", + "native.dropCommit": "从历史中移除提交", + "native.dropCommit.variant2": "从历史中移除提交…", + "native.dropFilesHere": "拖放文件到这里", + "native.dropShelf": "丢弃 Shelf", + "native.dropStash": "丢弃 Stash", + "native.dropThisShelf": "丢弃此 Shelf 吗?", + "native.droppedLldWriteFailuresLld": "已丢弃:{arg1};写入失败:{arg2}", + "native.duplicate": "复制", + "native.duplicateOmitted": "重复依赖已省略", + "native.durationLldMs": "耗时:{arg1} 毫秒", + "native.eachAgentKeepsItsOwnConfigurationFileTurningAnAgentOffRemovesOnlyTheEntryLithe602e7165d1": "每个 Agent 使用各自的配置文件。关闭某个 Agent 只会移除 Lithe 写入的条目;在 Lithe 之外创建的条目必须先接管所有权才会被移除。", + "native.eachBuiltInPresetIncludesAPreviewOfTheGeneratedMessage": "三个内置预设都会显示生成信息示例。", + "native.eachRepositoryHasItsOwnCommitCompletedStepsAreKeptIfAnotherRepositoryFails": "每个仓库会分别提交。某个仓库失败时,已完成的步骤仍会保留。", + "native.eachRowExplainsTheSettingTheValueGitIsActuallyUsingAndWhereThatValueComesFrom": "每一行都会说明设置用途、Git 实际采用的值,以及这个值来自哪里。", + "native.eachSubmoduleIsPushedBeforeItsParent": "先推送子模块,再推送父仓库。", + "native.earlierEvidenceWasOmittedByCollectionLimits": "部分较早的日志因收集上限被省略。", + "native.earlierGitCommandsWereTruncatedToLimitMemoryUse": "为限制内存占用,较早的 Git 执行记录已截断,无法展开。", + "native.earlierGitOutputWasOmittedToLimitMemoryUse": "为限制内存占用,已省略较早的 Git 输出。", + "native.earlierLogContentOmitted": "较早的日志已省略", + "native.edit": "编辑…", + "native.editBreakpoint": "编辑断点…", + "native.editCommitMessage": "编辑提交消息", + "native.editCommitMessage.variant2": "编辑提交消息…", + "native.editConfiguration": "编辑运行配置…", + "native.editConfigurationText": "编辑配置文本", + "native.editExceptionBreakpoint": "编辑异常断点 {arg1}", + "native.editExceptionBreakpoint.variant2": "编辑异常断点", + "native.editFieldBreakpoint": "编辑字段断点 {arg1}", + "native.editFilesAndStageTheChangesYouWantToIncludeAmendAndContinueUpdatesThisCommitCobd078ad14c": "修改文件并暂存需要包含的更改。“修订并继续”会更新当前提交;“继续”会保留现有提交。", + "native.editMessage": "编辑消息", + "native.editMethodBreakpoint": "编辑方法断点 {arg1}", + "native.editProvider": "编辑供应商", + "native.editPullRequest": "编辑拉取请求", + "native.editRunConfiguration": "编辑运行配置", + "native.editSearchExclusions": "编辑搜索排除规则", + "native.editService": "编辑服务", + "native.editServiceConfiguration": "编辑服务配置", + "native.editTitleAndDescription": "编辑标题和描述", + "native.editedLldFile": "编辑了 {arg1} 个文件", + "native.editedLldFiles": "编辑了 {arg1} 个文件", + "native.editor": "编辑器", + "native.editorAndLanguageServices": "编辑器与语言服务", + "native.edits": "编辑", + "native.effectiveFetchOptions": "实际生效的 Fetch 选项", + "native.effectiveGit": "实际使用的 Git:{arg1}", + "native.effectiveGit.variant2": "实际使用的 Git:{arg1} · {arg2}", + "native.effectiveGitFromPATH": "实际使用的 Git:来自 PATH", + "native.effectiveJDK": "实际使用的 JDK", + "native.effectiveSource": "生效来源", + "native.effectiveValue": "当前生效值", + "native.effectiveValue.variant2": "当前生效值:{arg1}", + "native.elapsed": "耗时", + "native.elapsed.variant2": "耗时:{arg1}", + "native.elapsedLldS": "已用时 {arg1} 秒", + "native.elapsedSinceSendingIncludingToolsAndPermissionWaits": "从发送开始计时,包含工具执行和等待权限的时间。", + "native.elixirSupport": "Elixir 支持", + "native.email": "邮箱", + "native.enableAgentConversation": "启用 Agent 对话", + "native.enableBreakpoint": "启用断点", + "native.enableBreakpoints": "启用断点", + "native.enableExceptionBreakpoint": "启用异常断点 {arg1}", + "native.enableFieldBreakpoint": "启用字段断点 {arg1}", + "native.enableForThisProject": "在本项目启用", + "native.enableMCP": "开启 MCP", + "native.enableMethodBreakpoint": "启用方法断点 {arg1}", + "native.enabled": "{arg1} 已启用", + "native.enabled.variant2": "已启用", + "native.enabledPluginsCanAlsoUseThisProjectGrant": "已启用的插件也可使用此项目授权。", + "native.endAll": "全部结束", + "native.endAll.variant2": "全部结束", + "native.endAllAndQuit": "全部结束并退出", + "native.endChecked": "结束勾选项", + "native.endOnNextLaunch": "下次启动时结束", + "native.endThisTask": "结束此任务", + "native.endThisTerminal": "结束此终端", + "native.endWithoutAsking": "直接结束", + "native.english": "英文", + "native.enterABranchName": "请输入分支名称", + "native.enterABranchName.variant2": "请输入分支名称。", + "native.enterABranchNameBeforePublishing": "请输入分支名称后再发布。", + "native.enterAChangeListName1100Characters": "请输入更改列表名称(1–100 个字符)。", + "native.enterACommitMessage": "请输入提交信息", + "native.enterACompleteServerURL": "请输入完整的服务器 URL。", + "native.enterAModelIDSupportedByYourProvider": "请填写该服务商支持的模型 ID。", + "native.enterANonemptyGitIdentityWithoutControlCharactersOrAngleBrackets": "请输入非空姓名或邮箱,不要包含控制字符或尖括号。", + "native.enterAPIKey": "填写 API Key", + "native.enterAProviderName": "请输入供应商名称。", + "native.enterAServerName": "请输入服务器名称。", + "native.enterATagName": "请输入标签名称", + "native.enterATagNameBranchNameCommitHashOrOtherGitRevision": "输入标签名、分支名、提交 Hash 或其他 Git 修订版本。", + "native.enterAWholeNumberFromDToD": "请输入 {arg1} 到 {arg2} 之间的整数。", + "native.enterAddress": "输入网址或本地路径", + "native.enterAnAPIKeyCLILoginCredentialsCannotBeUsedAsAnAPIKey": "请输入 API 密钥,CLI 账号登录凭据不能作为 API 密钥使用。", + "native.enterAnHTTPOrHTTPSAPIURLWithoutCredentialsQueryParametersOrFragments": "请输入 HTTP 或 HTTPS API 地址,不能包含账号、查询参数或片段。", + "native.enterModelID": "填写模型 ID", + "native.enterTheCommandThatStartsThisServer": "请输入启动该服务器的命令。", + "native.enterTheNextFunctionCall": "进入下一个函数调用", + "native.enterTheOneTimeCode": "输入一次性代码", + "native.environment": "环境变量", + "native.environmentVariableAndHeaderNamesMustBeUnique": "环境变量名和 Header 名不能重复。", + "native.environmentVariables": "环境变量", + "native.erlangSupport": "Erlang 支持", + "native.error": "错误", + "native.errors": "错误输出", + "native.errorsOnly": "仅错误", + "native.estimatedFromTheSizeOfTheLatestRequest": "按最近一次主请求各部分的大小估算。", + "native.estimatedFromTheSizeOfTheLatestRequestAndScaledToTheProviderSInputTokens": "按最近一次主请求各部分的大小估算,并按服务商返回的输入 token 校准。", + "native.evaluateExpression": "计算表达式", + "native.evaluateExpressionWhilePaused": "暂停时计算表达式", + "native.example": "示例", + "native.exception": "异常", + "native.exceptionBreakpoints": "异常断点", + "native.excludeFromDependencyTree": "从依赖树中排除", + "native.excluded": "已排除", + "native.excludedPaths": "已排除路径", + "native.executable": "可执行文件", + "native.executeMavenGoal": "执行 Maven 目标", + "native.executeMavenGoal.variant2": "执行 Maven 目标", + "native.executeTheNextSourceLine": "执行下一行源代码", + "native.existingLocalBranch": "已有本地分支", + "native.exitCodeLld": "退出码 {arg1}", + "native.expand": "展开", + "native.expandDirectory": "展开目录", + "native.expandJSONBlock": "展开 JSON 段落", + "native.expandLldLines": "展开中间 {arg1} 行", + "native.expandNotification": "展开通知", + "native.expandRepository": "展开仓库", + "native.expandSection": "展开分组", + "native.expandTemporaryGitConfigurationOrCommandDetails": "展开临时 Git 参数或命令详情", + "native.expanded": "已展开", + "native.explainTheIntentTestingAndAnythingReviewersShouldKnow": "说明更改目的、测试情况,以及审查者需要了解的内容…", + "native.explainWhatMustChangeBeforeThisPullRequestCanBeApproved": "说明在批准此拉取请求之前必须修改的内容。", + "native.export": "导出…", + "native.exportAsPDF": "导出为 PDF…", + "native.exportConversations": "导出会话", + "native.exportDiagnosticsBundle": "导出诊断包", + "native.exportDiagnosticsBundle.variant2": "导出诊断包…", + "native.exportLogs": "导出日志", + "native.exportingConversations": "正在导出会话…", + "native.exportsTheIndexAgainstHEAD": "导出暂存区相对 HEAD 的更改。", + "native.exportsTheWorkingTreeSNetChangesAgainstHEADIncludingSelectedUntrackedFiles": "导出工作树相对 HEAD 的最终更改,包含所选的未跟踪文件。", + "native.exportsWorkingFilesAgainstTheIndexIncludingSelectedUntrackedFiles": "导出工作文件相对暂存区的更改,包含所选的未跟踪文件。", + "native.expressionHistory": "表达式历史", + "native.externalChange": "外部更改", + "native.externalEditsConflictWithUnsavedChanges": "外部编辑与未保存的更改发生冲突", + "native.extraDeepReasoningForDemandingTasks": "额外深度推理,适合高难度任务", + "native.fSupport": "F# 支持", + "native.failed": "{arg1}(失败 {arg2})", + "native.failed.variant2": "失败", + "native.failedToStartCheckTheLanguageServerConfiguration": "启动失败,请检查语言服务器配置", + "native.failures": "失败详情", + "native.fast": "快速", + "native.fastForwardOnPull": "Pull 时的快进方式", + "native.fastForwardOnly": "仅允许快进", + "native.fastMode": "快速模式", + "native.favorites": "收藏", + "native.featEditorAddMemoryUsageIndicator": "feat(editor): 添加内存占用指示器", + "native.features": "功能", + "native.fetch": "获取", + "native.fetchAgain": "重新获取", + "native.fetchAll": "全部获取", + "native.fetchAllRemotes": "获取所有远程仓库", + "native.fetchAllRemotes.variant2": "获取所有远程仓库", + "native.fetchAllSubmodules": "获取所有子模块", + "native.fetchAllTags": "获取所有标签", + "native.fetchAndCommitPreferences": "Fetch 与提交偏好", + "native.fetchCommandFailed": "获取命令执行失败", + "native.fetchCommandSucceeded": "获取命令执行成功", + "native.fetchCompletedWithNoReferenceChanges": "获取完成,没有引用变化。", + "native.fetchDefaults": "Fetch 默认行为", + "native.fetchFailedNoCompletedGitInvocationWasReported": "获取失败 — 未收到已完成的 Git 命令记录", + "native.fetchInProgressPlannedCommandWaitingForResult": "正在获取 — 以下为计划命令,等待执行结果", + "native.fetchLocalConfiguration": "一键获取本机配置", + "native.fetchOptions": "获取选项", + "native.fetchOptions.variant2": "获取选项…", + "native.fetchSubmodules": "获取子模块", + "native.fetchSubmodulesOnDemand": "按需获取子模块", + "native.fetchTags": "获取标签", + "native.fetchTagsThatPointToFetchedCommits": "获取指向已获取提交的标签", + "native.fetchThisAgentSLocalConfigurationInThePanelSettings": "请在设置窗口的 Agent 页面中获取该 Agent 的本机配置。", + "native.fetchWhenNeeded": "按需获取", + "native.fetchYourLocalConfigurationSoTheAgentCanSignIn": "请获取本机配置,Agent 才能登录。", + "native.fetchedGitRemotes": "已获取 Git 远程仓库", + "native.fetchedLldPage": "获取了 {arg1} 个网页", + "native.fetchedLldPages": "获取了 {arg1} 个网页", + "native.fieldBreakpoints": "字段断点", + "native.fileContext": "文件上下文", + "native.fileContextIsNotSupportedYet": "暂不支持文件上下文", + "native.fileLldOfLld": "第 {arg1} 个文件,共 {arg2} 个", + "native.fileSaved": "文件已保存", + "native.fileTreeRowHeight": "文件树行高", + "native.files": "文件", + "native.files.variant2": "个文件", + "native.filesAddedSaveYourEditorChangesBeforeSendingSoTheAgentCanReadThem": "文件已添加。请在发送前保存编辑内容,以便 Agent 读取最新文件。", + "native.filesReturnToTheDefaultChangeListAndMayBeIncludedByStageAllTheirGitStagingStateIsUnchanged": "文件将回到默认更改列表,可能被全部暂存操作选中。现有 Git 暂存状态保持不变。", + "native.filesWillStayUnchangedNothingWillBeStagedOrCommittedAutomatically": "文件内容保持不变,不会自动暂存或提交。", + "native.filterByChangedPath": "按更改路径筛选", + "native.filterByTitleAuthorOrLabel": "按标题、作者或标签筛选", + "native.filterLogMessages": "筛选日志内容", + "native.filterProblemsBySeverity": "按严重级别筛选问题", + "native.filterResults": "筛选结果", + "native.find": "查找", + "native.findFilesAndActions": "查找文件和操作", + "native.findInFile": "在文件中查找", + "native.findInFile.variant2": "在文件中查找…", + "native.findInFile.variant3": "在文件中查找", + "native.findInFiles": "在项目中查找", + "native.findInGitConsole": "在 Git 控制台中查找", + "native.findInLog": "在日志中查找", + "native.findInPage": "在页面中查找", + "native.findNext": "查找下一个", + "native.findPrevious": "查找上一个", + "native.findReferencesToTheSelectedJavaSymbol": "查找所选 Java 符号的引用", + "native.findReferencesToTheSelectedSymbol": "查找所选符号的引用", + "native.findUsages": "查找调用位置", + "native.finished": "已完成", + "native.firstLldLocations": "前 {arg1} 个位置", + "native.fixTheFailingChecksBelowBeforeInstalling": "请先解决下方未通过的检测项,再进行安装。", + "native.fixup": "合并并丢弃消息", + "native.folderName": "文件夹名称", + "native.followGitSetting": "遵循 Git 配置", + "native.followGitTagRules": "遵循 Git 标签规则", + "native.followTheInstructionsYouDefineBelow": "遵循你在下方定义的指令", + "native.followsEachRemoteSTagSetting": "按各远程的标签设置", + "native.font": "字体", + "native.forceCheckout": "强制检出", + "native.forceRemove": "强制删除", + "native.forceRemoveWorktree": "强制删除工作树?", + "native.forget": "忘记", + "native.format": "格式", + "native.formatJSON": "格式化 JSON", + "native.forward": "前进", + "native.found": "个结果", + "native.framesAreNotAvailable": "当前无可用栈帧", + "native.freeSpace": "剩余空间", + "native.fromJAVAHOME": "来自 JAVA_HOME", + "native.fromUTCISO8601": "起始时间(UTC ISO 8601)", + "native.frontendAndOtherNonJavaRootsKeepTheWorkspaceEditorAndTerminalSettingsJavaAndMa2434fd734d": "前端和其他非 Java 根目录沿用工作区的编辑器和终端设置。Java 与 Maven SDK 只作用于左侧列出的后端项目。", + "native.fullAccess": "完全访问", + "native.fullProgramDeclaration": "完整程序声明", + "native.gatheringDiagnostics": "正在收集诊断信息…", + "native.general": "通用", + "native.generateACommitMessageFromStagedDiffs": "根据已暂存的 Diff 生成提交信息", + "native.generateACommitMessageFromTheSelectedDiff": "根据选中的 Diff 生成提交信息", + "native.generateATitleAndDescriptionFromTheSelectedBranchChanges": "根据所选分支的更改生成标题和描述", + "native.generateLitheRunGeneratedJsonToEnableRunAndDebugExistingProjectAndLocalOverridesWillBePreserved": "生成 .lithe/run/generated.json 后即可使用运行和调试。现有项目配置和本机覆盖配置会被保留。", + "native.generatePreview": "生成预览", + "native.generateWithAI": "AI 生成", + "native.generated1RunnableProjectEntry": "已生成 1 个可运行的项目入口。", + "native.generatedFromThePluginSDeclaration": "根据插件声明的能力自动生成。", + "native.generatedLldRunnableProjectEntries": "已生成 {arg1} 个可运行的项目入口。", + "native.generatedMain": "生成的主代码", + "native.generatedTest": "生成的测试代码", + "native.generating": "正在生成…", + "native.gitAuthentication": "Git 认证", + "native.gitBehaviorAndConfiguration": "Git 行为与配置", + "native.gitBranchOperation": "Git 分支操作", + "native.gitBranchOptions": "Git 分支选项", + "native.gitCanRequestCredentialsInLithePasswordsAreNotSavedByLitheTheSelectedGitHelper42412f0efe": "Git 可以在 Lithe 中请求凭据。Lithe 不保存密码;是否保存凭据由所选 Git 凭据助手控制。", + "native.gitCommandFailed": "Git 命令执行失败", + "native.gitCommandIsRunning": "Git 命令正在运行", + "native.gitCommandOutputWillAppearHere": "Git 命令输出将显示在这里。", + "native.gitCommandSucceeded": "Git 命令执行成功", + "native.gitCompletedSuccessfullyWithNoOutput": "执行成功,没有输出。", + "native.gitConfiguration": "Git 配置", + "native.gitConfigurationSaved": "Git 配置已保存", + "native.gitConfigurationSaved.variant2": "Git 配置已保存:{arg1}", + "native.gitCouldNotConfirmTheFinalStateRefreshHistoryBeforePerformingAnotherAction": "Git 无法确认最终状态。执行其他操作前请刷新提交历史。", + "native.gitDefault": "Git 默认值", + "native.gitExecutable": "Git 可执行文件", + "native.gitExecutable.variant2": "Git 可执行文件:{arg1}", + "native.gitExecution": "Git 执行", + "native.gitExitedWithCodeD": "Git 已退出,退出码为 {arg1}", + "native.gitFetchRemoveStaleBranches": "Git Fetch:清理失效的远程分支", + "native.gitFetchRemoveStaleTags": "Git Fetch:清理失效的远程标签", + "native.gitIsPreparingTheSequencer": "Git 正在准备变基步骤。", + "native.gitIsReady": "Git 已就绪", + "native.gitNotFound": "未找到 Git", + "native.gitOperation": "Git 操作", + "native.gitOperationFinished": "Git 操作已完成", + "native.gitPhase": "Git 阶段:{arg1}", + "native.gitProgramAndCredentials": "Git 程序与凭据", + "native.gitProgress": "Git 进度", + "native.gitRecordsThisNameAndEmailInNewCommitsTheseSettingsDoNotChangeExistingCommits": "Git 会将此姓名和邮箱记录在新提交中,不会修改已有提交。", + "native.gitRequestedCredentialsAgainCheckThePreviousAnswer": "Git 再次请求凭据,请检查上一次输入。", + "native.gitSetupIsUnavailable": "Git 设置暂不可用。", + "native.gitStash": "Git Stash", + "native.gitStashes": "Git 暂存记录", + "native.gitToolWindowActions": "Git 工具窗口操作", + "native.gitVersion": "Git 版本", + "native.gitWasNotFound": "未找到 Git", + "native.githubAuthorizationFailed": "GitHub 授权失败:", + "native.githubAuthorizationWasCancelled": "GitHub 授权已取消。", + "native.githubCouldNotBeReachedCheckYourInternetProxyOrVPNConnectionAndTryAgain": "无法连接 GitHub。请检查网络、代理或 VPN 连接后重试。", + "native.githubDidNotReturnAnyFileChangesForThisPullRequest": "GitHub 未返回此拉取请求的文件变更。", + "native.githubRejectedTheUpdateRequestBecauseASharedAPILimitWasReachedOpenTheReleasePa741bf3b6a2": "GitHub 因共享 API 配额已耗尽而拒绝了更新请求。请打开 Release 页面,或等待配额重置后重试。", + "native.githubReturnedAnUnexpectedResponse": "GitHub 返回了意外响应", + "native.githubSignInAndPullRequestManagementAreTemporarilyUnavailable": "GitHub 登录和拉取请求管理暂时不可用。", + "native.githubSignInIsUnavailableInThisBuild": "此版本暂时无法登录 GitHub。", + "native.githubUser": "GitHub 用户 {arg1}", + "native.globalGit": "全局 Git", + "native.globalGitConfiguration": "全局 Git 配置", + "native.globalIdentityAppliesToOtherRepositoriesUnlessTheyOverrideIt": "全局身份会用于其他仓库,仓库自身的配置可以覆盖它。", + "native.goSupport": "Go 语言支持", + "native.goToChildCommit": "跳转到子提交 {arg1}", + "native.goToDefinition": "跳转到定义", + "native.goToImplementation": "跳转到实现", + "native.goToLine": "跳转到行", + "native.goToLine.variant2": "跳转到行…", + "native.goToLineColumn": "跳转到行:列", + "native.goToParentCommit": "跳转到父提交 {arg1}", + "native.goToUsage": "跳转到调用位置", + "native.graphqlSupport": "GraphQL 支持", + "native.greekLetters": "希腊字母", + "native.groovySupport": "Groovy 支持", + "native.groups": "组合", + "native.hardResetDiscardChanges": "硬重置(丢弃更改)", + "native.haskellSupport": "Haskell 支持", + "native.head": "HEAD", + "native.headAdvancedReviewBeforeContinuing": "提交记录已推进,请检查后再继续。", + "native.headBranch": "来源分支", + "native.headCurrentBranch": "HEAD(当前分支)", + "native.headWillMoveToItsParentTheIndexAndWorkingFilesStayAsTheyAreStagedDifferencesWi69609861ef": "将撤销最近一次提交,把 HEAD 移到父提交。暂存区和工作文件保持不变,暂存差异将改为与父提交比较。", + "native.header": "请求头", + "native.headers": "请求头", + "native.heapMemory": "堆内存", + "native.hiddenPaths": "隐藏路径", + "native.hideAPIKey": "隐藏密钥", + "native.hideBuildOutputToolWindow": "隐藏构建输出工具窗口", + "native.hideBuildToolWindow": "隐藏构建工具窗口", + "native.hideCommitDecorations": "隐藏提交装饰", + "native.hideConfigurationList": "收起运行配置列表", + "native.hideDebugToolWindow": "隐藏调试工具窗口", + "native.hideEntry": "隐藏入口", + "native.hideGitBlame": "隐藏 Git Blame 注释", + "native.hideGitToolWindow": "隐藏 Git 工具窗口", + "native.hideMavenBuildToolWindow": "隐藏 Maven 构建工具窗口", + "native.hideMavenToolWindow": "隐藏 Maven 工具窗口", + "native.hidePerformance": "隐藏性能面板", + "native.hidePlan": "收起计划", + "native.hideProblemsToolWindow": "隐藏问题工具窗口", + "native.hideRunToolWindow": "隐藏运行工具窗口", + "native.hideSessionDetails": "隐藏会话详情", + "native.hideTerminalToolWindow": "隐藏终端工具窗口", + "native.hideThinking": "收起思考", + "native.hideToolDetails": "收起工具详情", + "native.hideWorktreeRepositories": "隐藏工作树仓库", + "native.high": "高", + "native.highlightWords": "高亮单词", + "native.hint": "提示", + "native.history": "历史", + "native.historyChangedButGitReportedAnIncompleteStepRefreshTheRepositoryAndInspectTheRf4eaf05b2a": "提交历史已改变,但仍有步骤未完成。重试前请刷新仓库并检查恢复引用。", + "native.historyFilter": "历史筛选", + "native.hit": "命中次数:{arg1}", + "native.homepage": "主页", + "native.host": "主机", + "native.howGitMatchesAndStoresCredentialsForRemoteOperations": "Git 如何为远程操作匹配和保存凭据。", + "native.howMuchTheJavaLanguageServerWritesToItsOwnLogQuieterLevelsReduceDiskWrites": "Java 语言服务器写入自身日志的详细程度;较安静的级别可减少磁盘写入。", + "native.howPullAndUpdateProjectCombineYourLocalAndRemoteCommits": "Pull 和“更新项目”如何合并本地与远程提交。", + "native.howUncommittedChangesArePutAsideBeforeSwitchingBranchesOrPulling": "切换分支或拉取前,如何暂存未提交的更改。", + "native.htmlSupport": "HTML 支持", + "native.httpIsBlockedUntilYouExplicitlyAllowItForThisProvider": "在你为此服务商明确允许之前,HTTP 请求会被阻止。", + "native.httpIsDisabledForThisProviderEnableTheInsecureHTTPOptionOrUseHTTPS": "此服务商未允许 HTTP,请开启不安全 HTTP 选项或改用 HTTPS。", + "native.httpSendsTheAPICredentialWithoutEncryptionUseOnlyATrustedEndpoint": "HTTP 会以明文传输 API 凭据,请仅对可信地址启用。", + "native.identifyAgain": "重新识别", + "native.identifyAndGenerate": "识别并生成", + "native.identifyingProject": "正在识别项目…", + "native.if": "条件:{arg1}", + "native.ifLitheCrashesOrIsForceQuitASmallHelperImmediatelyEndsItsTerminalsAndToolsAgenf47a67e797": "如果 Lithe 崩溃或被强制退出,一个小型辅助进程会立即结束它的终端和工具进程。Agent 任务和后台服务会保留到下次启动,避免在你不知情时结束重要的程序。无论这里如何选择,Agent 任务总是会询问。", + "native.ifResetUse": "重置后使用", + "native.ignoreWhitespace": "忽略空白", + "native.ignoredEntriesLld": "已忽略条目:{arg1}", + "native.imperativeSubject": "动作式标题", + "native.implementations": "实现", + "native.importFromClaude": "从 Claude 导入", + "native.importFromCodex": "从 Codex 导入", + "native.inEffect": "生效:", + "native.include": "包含范围", + "native.includeAShortBodyWhenUseful": "有必要时包含简短正文", + "native.includeInCommit": "包含在提交中", + "native.includeNonProjectItems": "包含非项目文件", + "native.includeTheURLPathWhenGitLooksUpHTTPCredentials": "Git 查找 HTTP 凭据时是否把 URL 路径也纳入匹配。", + "native.increaseFontSize": "增大字号", + "native.indentation": "缩进", + "native.indexAndWorkingTree": "暂存区和工作树", + "native.information": "信息", + "native.inherit": "继承", + "native.inheritProjectJDK": "继承项目 JDK", + "native.inheritProjectMaven": "继承项目 Maven", + "native.inheritProjectMavenJDK": "继承项目 Maven JDK", + "native.inheritedFromProject": "继承项目", + "native.inheritedValue": "继承值", + "native.initializeARepositoryBeforeSavingRepositorySpecificIdentity": "请先初始化仓库,再保存仓库身份配置。", + "native.initializeGitInThisProjectFolder": "在此项目目录中初始化 Git?", + "native.initializeGitRepository": "初始化 Git 仓库", + "native.initializeGitRepository.variant2": "初始化 Git 仓库…", + "native.initializeGitRepository.variant3": "初始化 Git 仓库…", + "native.initializeGitToTrackChangesAndCreateYourFirstCommit": "初始化 Git 后即可跟踪更改并创建首次提交。", + "native.initializeThisProjectFromGitBeforeSavingRepositorySpecificIdentity": "请先在 Git 面板初始化项目,再保存当前仓库的身份配置。", + "native.initializingProjectIndex": "正在初始化项目索引", + "native.input": "输入", + "native.input.variant2": "输入:{arg1}", + "native.insideThePlugin": "插件内置", + "native.inspectGitStatusAndTheCommandOutputThenContinueWhenReady": "请检查 Git 状态和命令输出,准备好后再继续。", + "native.inspectTheCurrentGitStateBeforeRetryingTheRecoveryReferencePreservesTheOriginalHistory": "重试前请检查当前 Git 状态。恢复引用保留了原始历史。", + "native.installACPAdaptersCheckTheRuntimeAndFollowYourLocalCLIConfiguration": "安装 ACP 适配器、检测运行环境,并跟随你本机 CLI 的配置。", + "native.installACPAdaptersCheckTheRuntimeAndManageLocalOrCustomProviders": "安装 ACP 适配器、检查运行环境,管理本机配置或自定义供应商。", + "native.installAndRestart": "安装并重启", + "native.installAutoBuild": "安装 AutoBuild", + "native.installCommunityPlugin": "安装社区插件“{arg1}”?", + "native.installFromRepository": "从仓库安装", + "native.installNodeJsAndNpmBeforeInstallingTheAdapter": "请先安装 Node.js 和 npm,再安装适配器。", + "native.installPHPSupportFromASignedPluginPackage": "请从已签名的插件包安装 PHP Support。", + "native.installPHPSupportPlugin": "安装 PHP Support 插件", + "native.installPluginFromDisk": "从磁盘安装插件", + "native.installPluginFromDisk.variant2": "从磁盘安装插件…", + "native.installPreview": "安装预览版", + "native.installTheSelectedChannel": "安装所选渠道版本", + "native.installation": "安装来源", + "native.installation.variant2": "安装来源:{arg1}", + "native.installed": "已安装", + "native.installed.variant2": "已安装 {arg1}", + "native.installedFrom": "安装自 {arg1}", + "native.installedFromDisk": "从磁盘安装", + "native.installedFromUpdatesOnlyComeFromThatRepository": "从 {arg1} 安装,更新只会来自该仓库。", + "native.installedLldOfLldEnabled": "已安装({arg1} / {arg2} 个已启用)", + "native.installedOnThisMac": "本机已安装:{arg1}", + "native.installedUpdateToAvailable": "已安装 {arg1} · 可更新到 {arg2}", + "native.installingPackages": "正在安装软件包…", + "native.installingUpdate": "正在安装更新 {arg1}…", + "native.installingWithNpm": "正在用 npm 安装…", + "native.integrateIncomingChangesIntoCurrentBranchM": "将传入的更改合并到当前分支(M)", + "native.interactiveRebase": "交互式变基", + "native.interactiveRebaseAborted": "交互式变基已中止", + "native.interactiveRebaseCompleted": "交互式变基已完成", + "native.interactiveRebaseConflicts": "交互式变基:存在冲突", + "native.interactiveRebaseEditCommit": "交互式变基:暂停修订提交", + "native.interactiveRebaseFailed": "交互式变基失败", + "native.interactiveRebaseInterrupted": "交互式变基被中断", + "native.interactiveRebasePaused": "交互式变基已暂停", + "native.interactivelyRebaseFromHere": "从此处交互式变基…", + "native.interface": "界面", + "native.interrupt": "中断", + "native.interrupted": "已中断", + "native.invalidGitTagName": "Git 标签名称无效。", + "native.invalidJSONCheckTheConfigurationFormat": "JSON 无效,请检查配置格式。", + "native.invalidJVMHeapCounters": "JVM 堆计数器无效。", + "native.invalidJVMPerformanceOutput": "JVM 性能输出无效。", + "native.invalidTOMLUseStraightQuotesInsteadOfSmartQuotes": "TOML 格式无效,请使用直引号(\"),不要使用智能引号(“ ”)。", + "native.invert": "反选", + "native.isNoLongerAnOfficialPluginItMovedToTheCommunityPluginInLitheStoreAndGetsNoFurt8a96fdb405": "{arg1} 已不再是官方插件,已迁移为 Lithe Store 中的社区插件 {arg2},这里不会再收到更新。请先卸载它并重启 Lithe,再从插件市场安装社区版本。", + "native.isNotAvailable": "{arg1} 不可用。", + "native.isNotInstalledLitheInstallsItWithYourNpmIntoItsApplicationData": "{arg1} 尚未安装。Lithe 会用你的 npm 把它安装到应用数据目录。", + "native.isVersionOrNewerIsRequired": "{arg1} 的版本是 {arg2},需要 {arg3} 或更高版本。", + "native.itCanReadFilesRunCommandsAndEditCodeAfterAskingForPermission": "它可以在征得你的许可后读取文件、运行命令和修改代码。", + "native.itsDetailsAndActionsWillAppearHere": "工作树详情和可用操作会显示在这里。", + "native.japaneseCharacters": "日文字符", + "native.javaApplication": "Java 应用", + "native.javaEntriesAppearAfterTheJavaLanguageServiceListsRunnableClasses": "Java 语言服务列出可运行的类之后,Java 入口才会出现。", + "native.javaEntriesCouldNotBeRefreshed": "无法刷新 Java 入口", + "native.javaNavigationIsAvailableForJavaFiles": "Java 导航仅适用于 .java 文件", + "native.javaNavigationIsIdle": "Java 导航空闲", + "native.javaNavigationReady": "Java 导航已就绪", + "native.javaRebuildIndex": "Java:重建索引", + "native.javaRuntimeIsNotReadyRetryMavenReload": "Java 运行时尚未就绪,请重试 Maven 重新加载。", + "native.javaSDK": "Java SDK", + "native.javaServiceFailedToStart": "Java 服务启动失败", + "native.javaServiceFailedToStart.variant2": "Java 服务启动失败:{arg1}", + "native.javaServiceIsPreparing": "Java 服务正在准备", + "native.javaServiceIsReady": "Java 服务已就绪", + "native.javaServicePreparationTimedOut": "Java 服务准备超时", + "native.jdkHome": "JDK 主目录", + "native.jdkHome.variant2": "JDK 主目录", + "native.jdwpIsNotEncryptedPreferLocalhostOrAnSSHTunnel": "JDWP 未加密,建议使用 localhost 或 SSH 隧道", + "native.jdwpPort": "JDWP 端口", + "native.jsonSupport": "JSON 支持", + "native.juliaSupport": "Julia 支持", + "native.jumpToALineAndColumnInTheActiveEditor": "在当前编辑器中跳转到指定的行和列", + "native.jumpToLatest": "跳转到最新位置", + "native.justNow": "刚刚", + "native.jvmPerformanceMonitoringEnded": "JVM 性能监控已结束。", + "native.keepAll": "全部保留", + "native.keepAll.variant2": "保留全部", + "native.keepCurrent": "保留当前内容", + "native.keepEditor": "保留编辑器版本", + "native.keepExistingContent": "保留现有内容", + "native.keepRunning": "保留此任务", + "native.keepRunning.variant2": "继续运行", + "native.keepStaleBranches": "保留失效分支", + "native.keepStaleTags": "保留失效标签", + "native.keptEditorVersion": "已保留编辑器版本", + "native.keymap": "快捷键…", + "native.koreanCharacters": "韩文字符", + "native.kotlinSupport": "Kotlin 支持", + "native.labels": "标签", + "native.labelsAndAssignees": "标签和负责人", + "native.languageServer": "语言服务器", + "native.languageServerInsideThePlugin": "{arg1} 语言服务器(插件内置)", + "native.languageServerInstalledOnThisMac": "{arg1} 语言服务器(本机已安装)", + "native.languageServerLogLevel": "语言服务器日志级别", + "native.languageServers": "语言服务器", + "native.languageService": "语言服务", + "native.languageServiceForThisProject": "为此项目启用语言服务", + "native.languageServices": "语言服务", + "native.languagesFrameworks": "语言与框架", + "native.last30Days": "最近 30 天", + "native.last7Days": "最近 7 天", + "native.later": "稍后", + "native.latestTurnAsReportedByTheAgentItsAccountingScopeDependsOnTheAgentTurnOnModelCa4056cd34fb": "最近一轮由 Agent 上报的计数,统计口径由 Agent 决定;开启“模型调用统计”后显示整个会话的合计。", + "native.latestVersion": "最新版本", + "native.launch": "启动方式", + "native.launchA": "— 启动", + "native.launching": "启动中", + "native.layout": "布局", + "native.leaveAReview": "提交审查", + "native.letConnectedAgentsInspectAndControlThisProject": "允许连接的 Agent 查看和控制此项目。", + "native.license": "许可证", + "native.lifecycle": "生命周期", + "native.light": "浅色", + "native.line": "行", + "native.lineBreakpointActions": "行断点操作", + "native.lineBreakpoints": "行断点", + "native.lineColumn": "[行] [:列]:", + "native.linear": "Linear", + "native.linesKeptForScrollingBackInNewTerminalSessions": "新建终端会话保留的可回滚行数。", + "native.linkedWorktree": "关联工作树", + "native.listedIn": "所在插件仓库", + "native.lithe": "Lithe", + "native.litheAgent": "Lithe Agent", + "native.litheApplicationDefault": "Lithe 应用默认值", + "native.litheAsksAgainAfterThePluginIsDisabledUpdatedOrLitheRestarts": "插件被停用、更新或 Lithe 重启后会再次询问。", + "native.litheCanTGivePagesAccessToTheCameraOrMicrophoneInThisVersion": "此版本的 Lithe 无法让页面使用摄像头或麦克风。", + "native.litheCannotReplaceThisAppInItsCurrentFolderMoveItToAWritableApplicationsFolder9358c4d3f8": "Lithe 无法替换当前目录中的应用。请将应用移到有写入权限的应用程序目录,或手动安装正式版。", + "native.litheChecksGitHubReleasesForPublishedUpdates": "Lithe 会检查 GitHub Releases 中发布的更新。", + "native.litheClosedUnexpectedlyLastTimeAndLeftProgramsRunning": "Lithe 上次意外关闭,有程序仍在运行", + "native.litheCouldNotFindARunnableGit": "Lithe 未找到可运行的 Git。", + "native.litheDefault": "Lithe 默认", + "native.litheDoesNotSavePasswordsCredentialsAreStoredByTheGitCredentialHelperYouChoose": "Lithe 不保存密码,凭据由你选择的 Git 凭据助手保存。", + "native.litheFetchDefaults": "Lithe 获取默认值", + "native.litheFetchRemoveStaleBranches": "Lithe Fetch:清理失效的远程分支", + "native.litheFetchSubmodules": "Lithe:获取子模块", + "native.litheFetchTags": "Lithe:获取标签", + "native.litheFirstAsksEachProgramToStopGivingItThisLongToSaveItsWorkAndExitProgramsStiffc3767044": "Lithe 会先请求每个程序停止,并给它这么长的时间保存工作并退出。超时后仍在运行的程序会被强制结束。", + "native.litheHasNotYetCompletedARealConversationWithThisAdapterVersion": "Lithe 尚未用这个适配器版本完成过真实对话。", + "native.litheIDEAccess": "Lithe IDE 访问", + "native.litheInstallsTheACPAdapterPackageNotTheVendorCLITheTwoAreIndependentAndShareTh3f39562117": "Lithe 安装的是 ACP 适配器包,而不是厂商的 CLI。两者相互独立,并共用 CLI 的配置。", + "native.litheIsUpToDate": "Lithe 已是最新版本", + "native.litheIsUpToDateAtVersion": "Lithe 当前已是最新版本 {arg1}", + "native.litheLooksForCodexAndClaudeConfigurationFilesOnThisMac": "Lithe 会在此 Mac 上查找 Codex 和 Claude 的本地配置文件。", + "native.litheLooksForCodexConfigTomlAndCodexAuthJsonOnThisMac": "Lithe 会在此 Mac 上查找 ~/.codex/config.toml 和 ~/.codex/auth.json。", + "native.litheMCPWasRegisteredButCodexConfigTomlCouldNotBeAddedToGitSLocalExcludeList": "Lithe MCP 已注册,但无法把 .codex/config.toml 加入 Git 的本地排除列表:{arg1}", + "native.litheNeedsToIdentifyTheProjectAndGenerateLitheRunGeneratedJsonBeforeRunAndDebu71dbeebe55": "使用运行和调试功能前,Lithe 需要识别项目并生成 .lithe/run/generated.json。项目配置和本机覆盖配置不会被修改。", + "native.litheOfficial": "Lithe 官方", + "native.lithePruneStaleBranches": "Lithe:清理失效的远程分支", + "native.lithePublisherSignatureVerified": "已验证 Lithe 发布者签名", + "native.litheReadsTheServersRegisteredInYourCodexAndClaudeCodeConfigurationSavingWritefeb8d3e17b": "Lithe 会读取你已在 Codex 与 Claude Code 配置中注册的服务器。保存时只改写发生变化的条目,备份存放在 Application Support 中,且不会覆盖在 Lithe 之外创建的条目。Agent 在会话启动时读取 MCP 配置,重启会话后生效。", + "native.litheShelve": "Lithe Shelf", + "native.litheShelves": "Lithe 搁置记录", + "native.litheWillCloneTheRepositoryIntoDestinationDescription": "Lithe 会将仓库克隆到(目标文件夹)。", + "native.litheWillDownloadTheUpdateAndRestartAfterReplacingTheCurrentApp": "Lithe 将下载更新,替换当前应用后重新启动。", + "native.litheWillLookForServicesAgainAndRefreshLitheRunGeneratedJsonProjectAndLocalOvec4eb77affa": "Lithe 将重新查找服务并刷新 .lithe/run/generated.json。项目与本机的覆盖配置不会被修改。", + "native.lld": "{arg1} {arg2}", + "native.lld.variant2": "{arg1}({arg2})", + "native.lldAffectedCommitsLldCommitsCoreCreatesARecoveryReferenceBeforeStarting": "{arg1} 笔受影响提交 → {arg2} 笔提交。开始前会创建恢复引用。", + "native.lldBytes": "{arg1} 字节", + "native.lldChangedFiles": "{arg1} 个文件有更改", + "native.lldCharacters": "{arg1} 个字符", + "native.lldCharactersTotal": "共 {arg1} 个字符", + "native.lldFailed": "{arg1} 个失败", + "native.lldFileS": "{arg1} 个文件 · {arg2}", + "native.lldIgnored": "{arg1} 个已忽略", + "native.lldInterrupted": "{arg1} 个已中断", + "native.lldLines": "{arg1} 行", + "native.lldLldCompleted": "已完成 {arg1}/{arg2}", + "native.lldMatches": " · {arg1} 处匹配", + "native.lldModelCalls": "{arg1} 次模型调用", + "native.lldMoreFiles": "… 另 {arg1} 个文件", + "native.lldMoreNotifications": "还有 {arg1} 条通知", + "native.lldMoreReferences": "… 另 {arg1} 个引用", + "native.lldNonStreaming": "{arg1} 次非流式", + "native.lldOfLld": "第 {arg1} 个,共 {arg2} 个", + "native.lldOfLldServicesSelected": "已选择 {arg2} 个服务中的 {arg1} 个", + "native.lldOfLldStepsProcessed": "{arg1} · 已处理 {arg2} / {arg3} 步", + "native.lldPassed": "{arg1} 个通过", + "native.lldProcesses": "{arg1} 个进程", + "native.lldRequestsReturnedNoUsageSoTheseTotalsAreALowerBound": "有 {arg1} 次请求未返回用量,以上为最低值。", + "native.lldRows": "{arg1} 行", + "native.lldRunning": "{arg1} 个运行中", + "native.lldS": "{arg1} 秒", + "native.lldSelected": "已勾选 {arg1} 项", + "native.lldSkipped": "{arg1} 个跳过", + "native.lldStaged": "已暂存 {arg1} 个", + "native.lldSteps": "{arg1} 步", + "native.lldTestFailed": "{arg1} 个测试失败", + "native.lldTestIgnored": "{arg1} 个测试已忽略", + "native.lldTestPassed": "{arg1} 个测试通过", + "native.lldTestTotal": "共 {arg1} 个测试", + "native.lldTestsFailed": "{arg1} 个测试失败", + "native.lldTestsIgnored": "{arg1} 个测试已忽略", + "native.lldTestsPassed": "{arg1} 个测试通过", + "native.lldTestsTotal": "共 {arg1} 个测试", + "native.lldToolCalls": "{arg1} 次工具调用", + "native.lldTurns": "{arg1} 轮", + "native.lldWorktreeRecordsNeedAttention": "{arg1} 个工作树需要修复", + "native.lldWorktrees": "{arg1} 个工作树", + "native.lldhLldmLlds": "{arg1}小时 {arg2}分 {arg3}秒", + "native.lldmLlds": "{arg1}分 {arg2}秒", + "native.llds": "{arg1}秒", + "native.load": "加载", + "native.loadDiskVersion": "加载磁盘版本", + "native.loadMore": "加载更多", + "native.loadMoreCommits": "加载更多提交", + "native.loadProjectModel": "加载项目模型", + "native.loadTheDependenciesAgain": "重新加载依赖", + "native.loadTheDependenciesOf": "加载 {arg1} 的依赖", + "native.loadTheModel": "加载 {arg1} 的模型", + "native.loadTheProjectModel": "加载项目模型", + "native.loadedFileSystemVersion": "已加载文件系统版本", + "native.loading": "正在加载…", + "native.loadingBranchDetails": "正在加载分支信息…", + "native.loadingBranches": "正在加载分支", + "native.loadingBranches.variant2": "正在加载分支…", + "native.loadingBreakpoints": "正在加载断点…", + "native.loadingChangedFiles": "正在加载更改的文件…", + "native.loadingCommitDiff": "正在加载提交 Diff…", + "native.loadingCommits": "正在加载提交…", + "native.loadingComparison": "正在加载比较结果…", + "native.loadingConversation": "正在加载对话…", + "native.loadingDependencies": "正在加载依赖…", + "native.loadingDependencies.variant2": "正在加载依赖…", + "native.loadingDependenciesOf": "正在加载 {arg1} 的依赖", + "native.loadingDiff": "正在加载 Diff…", + "native.loadingHistory": "正在加载历史…", + "native.loadingModel": "正在加载 {arg1} 模型", + "native.loadingObjectFields": "正在加载对象字段…", + "native.loadingProjectEnvironment": "正在读取项目环境…", + "native.loadingPullRequests": "正在加载拉取请求", + "native.loadingRunServices": "正在加载运行服务…", + "native.loadingSessionSettings": "正在加载会话设置…", + "native.loadingWorktreeDetails": "正在加载工作树详情", + "native.loadingWorktrees": "正在加载工作树", + "native.local": "本地", + "native.localChanges": "本地更改", + "native.localChanges.variant2": "本地更改", + "native.localChangesWereRestoredWithConflicts": "本地更改已恢复,但存在冲突", + "native.localChangesWouldBeOverwritten": "本地更改将被覆盖", + "native.localCommitS": "个本地提交", + "native.localConfiguration": "使用本机 {arg1} 配置", + "native.localConfiguration.variant2": "本机配置", + "native.localConfigurationFollowsYourCLIFilesCustomProvidersAreSavedInLitheAndDoNotChad03a7c54cb": "本机配置跟随 CLI 文件。自定义供应商保存在 Lithe 中,不修改本机文件。切换会重启当前项目的 Agent 连接,可重新打开历史会话继续对话。", + "native.localDevelopmentPackage": "本地开发包", + "native.localHistory": "本地历史", + "native.localHistory.variant2": "本地历史…", + "native.localHistoryIsAvailableForTextFiles": "本地历史仅适用于文本文件", + "native.localJDKHomeOptional": "本地 JDK 主目录(可选)", + "native.localPackage": "本地插件包", + "native.localVariables": "局部变量", + "native.lockWorktree": "锁定工作树", + "native.locked": "已锁定", + "native.locked.variant2": "已锁定 {arg1}", + "native.log": "日志:{arg1}", + "native.logDirectory": "日志目录", + "native.logFilter": "日志筛选", + "native.logLevel": "日志级别", + "native.logLocations": "日志位置", + "native.logTimeline": "日志时间线", + "native.logs": "日志", + "native.logsAndDiagnostics": "日志与诊断", + "native.lookingForGit": "正在查找 Git…", + "native.low": "低", + "native.lowEffortAndASmallOutputLimitAreRecommendedForFastCommitMessageGeneration": "为了快速生成提交信息,建议使用低推理强度和较小的输出限制。", + "native.lowRecommended": "低(推荐)", + "native.luaSupport": "Lua 支持", + "native.macosCouldNotPrepareTheUpdateDiskImageDownloadTheReleaseManuallyAndTryAgain": "macOS 无法准备更新磁盘映像,请手动下载版本后重试。", + "native.macosCouldNotPrepareTheUpdateDiskImageOpenTheReleasePageAndInstallItManually": "macOS 无法准备更新磁盘映像。请打开 Release 页面手动安装。", + "native.mainClass": "主类", + "native.mainJava": "主代码", + "native.mainResources": "主资源", + "native.mainWorktree": "主工作区(主)", + "native.maintenance": "维护", + "native.makeSupport": "Make 支持", + "native.manage": "管理…", + "native.manageAIProviders": "管理 AI 服务商…", + "native.manageAllProjectBreakpoints": "管理项目中的所有断点", + "native.managePluginRepositories": "管理插件仓库…", + "native.manageProjectBreakpointsWithoutStartingADebugSession": "无需启动调试会话即可管理项目断点", + "native.manageSelected": "管理所选会话", + "native.manageTheServersCodexAndClaudeCodeCanStart": "管理 Codex 与 Claude Code 可以启动的服务器。", + "native.managedMemory": "托管内存", + "native.managedOutsideTheMarketplaceNoUpdateSource": "在插件市场之外管理,没有更新来源。", + "native.markTargetAs": "将目标标记为", + "native.markdownIsSupportedOnGitHub": "支持 GitHub Markdown", + "native.markdownSupport": "Markdown 支持", + "native.markdownTemplate": "Markdown 模板", + "native.marketplace": "插件市场", + "native.matchCase": "区分大小写", + "native.matchHostAndURLPath": "匹配主机和 URL 路径", + "native.matchHostOnly": "仅匹配主机", + "native.matches": "匹配项", + "native.matches.variant2": "个匹配项", + "native.matchesThePluginRepository": "与插件仓库一致", + "native.matchingBranches": "匹配的分支", + "native.mavenBuild": "Maven 构建", + "native.mavenConfigurationChanged": "Maven 配置已变化", + "native.mavenCouldNotResolveThisProjectLldProblems": "Maven 无法解析此项目({arg1} 个问题)", + "native.mavenCouldNotResolveThisProjectLldProblems.variant2": "Maven 无法解析此项目({arg1} 个问题):{arg2}", + "native.mavenDependencyResolutionExitedWithCode": "Maven 依赖解析已退出,退出码为 {arg1}。", + "native.mavenDependencyResolutionTimedOutAfter60Seconds": "Maven 依赖解析在 60 秒后超时。", + "native.mavenExecutable": "Maven 可执行文件", + "native.mavenExecutableNotResolved": "未解析出 Maven 可执行文件", + "native.mavenHome": "Maven 主目录", + "native.mavenHomeOrExecutable": "Maven 主目录或可执行文件", + "native.mavenJDK": "Maven JDK", + "native.mavenJDKHome": "Maven JDK 主目录", + "native.mavenModule": "Maven 模块", + "native.mavenModule.variant2": "Maven 模块", + "native.mavenPOMChanged": "Maven POM 已变化", + "native.mavenProject": "Maven 项目", + "native.mavenReloadWasCancelledOrTimedOut": "Maven 重新加载已取消或超时。", + "native.mavenRunAndDebugUseThisJDKUnlessASubprojectOrRunConfigurationOverridesIt": "除非子项目或运行配置另行指定,Maven、运行和调试都会使用此 JDK。", + "native.mavenSettings": "Maven 设置", + "native.mavenSource": "Maven 来源", + "native.mavenSpringBoot": "Maven / Spring Boot", + "native.mavenWrapper": "Maven Wrapper", + "native.mavenWrapperIsNotExecutable": "Maven Wrapper 不可执行", + "native.max": "最大", + "native.maximumMemory": "最大内存", + "native.maximumReasoningDepth": "使用最大推理深度", + "native.mcp": "MCP", + "native.mcpConfiguration": "MCP 配置", + "native.mcpConfigurationManagementIsUnavailable": "MCP 配置管理不可用。", + "native.mcpIsEnabledForThisProject": "此项目的 MCP 已开启。", + "native.mcpIsOffForThisProject": "此项目的 MCP 已关闭。", + "native.mcpServers": "MCP 服务器", + "native.mcpTools": "MCP 工具", + "native.mcpToolsSent": "发送的 MCP 工具", + "native.me": "我", + "native.medium": "中", + "native.menuActions": "菜单命令", + "native.merge": "合并", + "native.merge.variant2": "合并", + "native.mergeBranch": "合并分支?", + "native.mergeInProgress": "正在合并", + "native.mergeIntoCurrentBranch": "合并到当前分支", + "native.mergeIntoTheCurrentBranchConflictsMayRequireTerminalResolution": "将 {arg1} 合并到当前分支。如有冲突,可能需要在终端中解决。", + "native.mergeJoinsBothHistoriesWithAMergeCommitRebaseReplaysYourCommitsOnTopOfTheUpstr4232ebcb80": "合并会用一个合并提交连接两条历史。变基会把你的提交重放到上游之上,保持历史线性,但会重写你的提交哈希。", + "native.mergePullRequest": "合并拉取请求?", + "native.merged": "已合并", + "native.mergingPullRequest": "正在合并拉取请求…", + "native.message": "({arg1})", + "native.message.variant2": "{arg1},{arg2}", + "native.message.variant3": "{arg1}:{arg2}", + "native.message.variant4": ",", + "native.message.variant5": ";", + "native.message.variant6": "→ {arg1}", + "native.messageOptional": "说明(可选)", + "native.messageTheAgent": "向 Agent 发送消息", + "native.messages": "对话消息", + "native.metadataUpdated": "元数据已更新", + "native.methodBreakpoints": "方法断点", + "native.microphoneInUse": "麦克风使用中", + "native.minimap": "代码缩略图", + "native.minimumLevel": "最低级别", + "native.minimumVersion": "最低版本", + "native.mixedResetKeepChangesUnstaged": "混合重置(保留未暂存更改)", + "native.model": "模型", + "native.modelCallStatistics": "模型调用统计", + "native.modelCallsObservedByTheLocalProxyDuringThisTurnTTFTIsTheAverageTimeToTheFirstGeneratedToken": "本轮由本机代理观测到的模型调用;首字为首个生成 token 的平均等待时间。", + "native.modelCallsSteps": "模型调用(步)", + "native.modelTime": "模型用时", + "native.modified": "修改", + "native.modifiedFilesLld": "修改了 {arg1} 个文件", + "native.module": "模块", + "native.moduleLogsRotateAt10MiBAndExpireAfter30DaysTheCombinedBudgetIs100MiBPerformanc7c993f9014": "模块日志达到 10 MiB 后轮转,保留 30 天。总预算为 100 MiB,性能日志另有限额 20 MiB。", + "native.modulePath": "模块路径", + "native.moduleRoot": "模块根目录", + "native.moduleSessionMinimumLevel": "模块:{arg1} · 会话:{arg2} · 最低级别:{arg3}", + "native.more": "… 更多", + "native.moreActions": "更多操作", + "native.moreAgents": "更多 Agent…", + "native.moreInformation": "查看说明", + "native.moreSessionSettings": "更多会话设置", + "native.moveCommitEarlier": "将提交上移", + "native.moveCommitLater": "将提交下移", + "native.moveEarlier": "向前移动", + "native.moveLater": "向后移动", + "native.moveLldItemsToTrash": "将 {arg1} 个项目移到废纸篓?", + "native.moveTheCurrentBranchToAndDiscardAllWorkingTreeChanges": "将当前分支移动到 {arg1},并丢弃所有工作区更改。", + "native.moveTheCurrentBranchToAndKeepChangesStaged": "将当前分支移动到 {arg1},并将更改保留为已暂存状态。", + "native.moveTheCurrentBranchToAndKeepChangesUnstaged": "将当前分支移动到 {arg1},并将更改保留为未暂存状态。", + "native.moveToBottom": "移至底部", + "native.moveToBottomLeft": "移到底部左侧", + "native.moveToBottomRight": "移到底部右侧", + "native.moveToChangeList": "移到更改列表", + "native.moveToFolder": "移动到文件夹", + "native.moveToLeft": "移到左侧", + "native.moveToLeftBottom": "移到左侧下方", + "native.moveToLeftTop": "移到左侧上方", + "native.moveToRight": "移至右侧", + "native.moveToRightBottom": "移到右侧下方", + "native.moveToRightTop": "移到右侧上方", + "native.moveToRoot": "移动到根目录", + "native.moveToTheNextMatchInTheActiveEditor": "跳转到当前编辑器中的下一个匹配项", + "native.moveToThePreviousMatchInTheActiveEditor": "跳转到当前编辑器中的上一个匹配项", + "native.moveToTrash": "移到废纸篓", + "native.moved": "移动", + "native.muteBreakpoints": "暂停断点", + "native.muteCamera": "暂停摄像头", + "native.muteLineBreakpoints": "暂停行断点", + "native.muteMicrophone": "静音麦克风", + "native.name": "名称", + "native.name.variant2": "名称:", + "native.nameAndEmailAreWrittenToTheGlobalGitConfigurationExistingCommitsDoNotChange": "姓名和邮箱写入全局 Git 配置,已有提交不会改变。", + "native.nameAndEmailAreWrittenToThisRepositorySGitConfigurationAndOverrideTheGlobalVal4aca07ef08": "姓名和邮箱写入当前仓库的 Git 配置并覆盖全局值,已有提交不会改变。", + "native.nativeInstaller": "原生安装器", + "native.navigate": "导航", + "native.navigateToACallSiteOfTheSelectedJavaSymbol": "导航到所选 Java 符号的调用位置", + "native.navigateToACallSiteOfTheSelectedSymbol": "跳转到所选符号的调用位置", + "native.navigateToAnImplementationOfTheSelectedSymbol": "跳转到所选符号的实现", + "native.navigateToTheDeclarationOfTheSelectedSymbol": "跳转到所选符号的声明", + "native.navigateToTheNextEditorLocation": "跳转到下一个编辑器位置", + "native.navigateToThePreviousEditorLocation": "跳转到上一个编辑器位置", + "native.navigation": "导航", + "native.needsNoExtraPermissions": "不需要额外权限。", + "native.needsRestart": "需重启", + "native.new": "新增", + "native.newBranch": "新建分支", + "native.newBranch.variant2": "新建分支…", + "native.newBranch.variant3": "新分支", + "native.newBranchFrom": "从“{arg1}”新建分支…", + "native.newBranchName": "新分支名称", + "native.newChangeList": "新建更改列表", + "native.newChat": "新对话", + "native.newConfiguration": "新建配置", + "native.newConnection": "新建连接", + "native.newConversation": "新建会话", + "native.newConversation.variant2": "新对话", + "native.newDirectory": "新建目录…", + "native.newFile": "新建文件…", + "native.newFolder": "新建文件夹", + "native.newProject": "新建项目…", + "native.newRunConfiguration": "新建运行配置", + "native.newTag": "新建标签", + "native.newTag.variant2": "新建标签…", + "native.newTerminalSession": "新建终端会话", + "native.newWindow": "新窗口", + "native.newWindow.variant2": "新窗口", + "native.newWorktree": "新建工作树", + "native.nextConsoleExpression": "下一条表达式", + "native.nextDiffMatch": "下一个 Diff 匹配项", + "native.nextDifference": "下一个差异", + "native.nextFile": "下一个文件", + "native.nextMatch": "下一个匹配项", + "native.nextMatch.variant2": "下一个匹配", + "native.nextMatchReturn": "下一个匹配项(Return)", + "native.nextRun": "下次运行", + "native.noAIProviderIsConfiguredYet": "尚未配置 AI 服务商。", + "native.noAPIKeyFoundInClaudeConfiguration": "Claude 配置中未找到 API 密钥", + "native.noAPIKeyFoundInCodexAuthJson": "Codex auth.json 中未找到 API 密钥", + "native.noAgent": "无 Agent", + "native.noAgentIsReadyYetOpenThePanelSettingsToInstallAnAgentAndChooseALocalOrCustomProvider": "尚无可用的 Agent,请在设置窗口的 Agent 页面中安装 Agent,并选择本机配置或自定义供应商。", + "native.noAgentIsReadyYetOpenThePanelSettingsToInstallAnAgentAndFetchItsLocalConfiguration": "还没有可用的 Agent。请打开设置窗口的 Agent 页面,安装 Agent 并获取它的本机配置。", + "native.noAgentIsSelected": "未选择 Agent。", + "native.noAgentIsSetUpYet": "尚未设置 Agent", + "native.noAvailableLocalBranchBranchesAlreadyCheckedOutInAnotherWorktreeCannotBeReused": "没有可用的本地分支。已在其他工作树检出的分支不能重复使用。", + "native.noBackgroundImageSelected": "未选择背景图片", + "native.noBranchesFound": "没有匹配的分支", + "native.noBreakpoints": "没有断点", + "native.noCallableTargetAtThisLocation": "当前位置没有可调用目标", + "native.noChangedFiles": "没有更改的文件", + "native.noChanges": "没有更改", + "native.noCodexConfigurationWasFound": "未找到 Codex 配置", + "native.noColumns": "没有列", + "native.noCommand": "无命令", + "native.noCommitSelected": "未选择提交", + "native.noCommits": "没有提交记录", + "native.noCommitsMatchThisView": "没有提交匹配当前视图", + "native.noCommitsWereFoundForThisBranch": "此分支没有找到提交记录。", + "native.noCompletedGitInvocationWasReported": "未收到已完成的 Git 命令记录", + "native.noConfigurationWasFoundOnThisMac": "这台 Mac 上没有找到 {arg1} 配置。", + "native.noConfigurationsInThisScope": "当前范围内没有配置", + "native.noConversationYet": "尚无讨论", + "native.noCredentialFoundInClaudeConfiguration": "Claude 配置中未找到凭据", + "native.noCredentialFoundInCodexConfiguration": "Codex 配置中未找到凭据", + "native.noCurrentBranch": "没有当前分支", + "native.noCustomProvidersYet": "尚未添加自定义供应商。", + "native.noDefaultShellWasDetectedChooseADetectedShellOrRunDetectionAgain": "未检测到系统默认 Shell。请选择已检测到的 Shell,或重新执行检测。", + "native.noDependencies": "无依赖", + "native.noDependencies.variant2": "没有依赖。", + "native.noDescriptionProvided": "未提供描述。", + "native.noDifferences": "没有差异", + "native.noDifferencesWereFoundForThisSelection": "所选内容没有差异。", + "native.noEarlierConversations": "没有更早的对话", + "native.noEffectiveValueConfigured": "尚未配置有效值", + "native.noFileEdits": "暂无文件修改", + "native.noFilesMatchTheConflictFilter": "没有符合冲突筛选条件的文件", + "native.noGitHubOrigin": "未找到 GitHub 远程仓库", + "native.noGitRepositoryIsOpen": "未打开 Git 仓库", + "native.noHistoryRecordedYet": "还没有历史记录", + "native.noImplementationsFound": "没有找到实现", + "native.noJDKFoundSetJAVAHOMEInstallAJDKOrChooseAJDKDirectory": "未找到 JDK。请设置 JAVA_HOME、安装 JDK,或选择 JDK 目录。", + "native.noJDKIsConfiguredForThisProject": "此项目没有配置 JDK。", + "native.noJVMPerformanceSamplesReceivedFor10Seconds": "10 秒内未收到 JVM 性能采样。", + "native.noJavaNavigationResults": "没有 Java 导航结果", + "native.noJavaProblems": "没有 Java 问题", + "native.noLocalChanges": "没有本地更改", + "native.noLocalVariablesInTheCurrentFrame": "当前栈帧没有局部变量", + "native.noMCPServersRegistered": "尚未注册 MCP 服务器", + "native.noMatches": "没有匹配结果", + "native.noMatchesIn": "没有匹配项:", + "native.noMatchesInActions": "操作中没有匹配项", + "native.noMatchesInAll": "全部范围内没有匹配项", + "native.noMatchesInClasses": "类中没有匹配项", + "native.noMatchesInFiles": "文件中没有匹配项", + "native.noMatchesInSymbols": "符号中没有匹配项", + "native.noMatchesInText": "文本中没有匹配项", + "native.noMatchingBranches": "没有匹配的分支", + "native.noMatchingCommands": "没有匹配的命令", + "native.noMatchingCommitsInTheLoadedHistory": "已加载的历史中没有匹配的提交。", + "native.noMatchingConversations": "没有匹配的会话", + "native.noMatchingItems": "没有匹配项", + "native.noMatchingLogs": "没有匹配的日志", + "native.noMatchingMessages": "没有匹配的消息", + "native.noMatchingPlugins": "没有匹配的插件", + "native.noMatchingUsers": "没有匹配的用户", + "native.noMavenExecutableWasFoundChooseMavenHomeOrAnExecutableInMavenSettings": "未找到 Maven 可执行文件,请在 Maven 设置中选择 Maven 主目录或可执行文件。", + "native.noMavenFoundAddAMavenWrapperToTheProjectOrChooseAMavenInstallation": "未找到 Maven。请为项目添加 Maven Wrapper,或选择 Maven 安装目录。", + "native.noMavenProjectDetected": "未检测到 Maven 项目", + "native.noMavenProjectIsAvailableForDebug": "没有可用于调试的 Maven 项目", + "native.noModelsAvailable": "暂无可用模型", + "native.noNewCommitsPushingCreatesTheRemoteBranch": "没有新的提交。推送会创建远程分支。", + "native.noNotifications": "暂无通知", + "native.noPaths": "没有路径", + "native.noPathsResolvedForThisService": "此服务没有已解析的路径", + "native.noPlugins": "暂无插件", + "native.noPluginsAvailable": "暂无可用插件", + "native.noProblemsIn": "{arg1} 中没有问题", + "native.noProblemsMatchTheCurrentFilter": "没有问题匹配当前筛选条件", + "native.noProfilingEventsWereRecorded": "没有录制到性能事件。", + "native.noProjectEntryPointDetected": "未识别到项目启动入口", + "native.noProjectHistoryRecordedYet": "还没有项目历史记录", + "native.noProjectIsOpen": "未打开项目", + "native.noProjectIsOpenRowsBackedByGitConfigurationWriteToTheGlobalGitConfigurationCom6dc0364452": "当前没有打开项目。由 Git 配置支持的选项会写入全局 Git 配置;提交身份需要打开仓库。", + "native.noProjectLevelMCPEntryWasWritten": "未写入项目级 MCP 条目。", + "native.noProjectLoaded": "未加载项目", + "native.noProjectWasDetectedInThisWorkspace": "此工作区未检测到 {arg1} 工程。", + "native.noProviderYet": "还没有 {arg1} 服务商", + "native.noPublishedReleaseIsAvailableYet": "暂时没有已发布的版本。", + "native.noPullRequests": "暂无拉取请求", + "native.noPullRequestsMatchTheSelectedState": "当前状态下没有拉取请求。", + "native.noRecentProgressHasBeenReceivedTheTaskIsStillActive": "暂未收到新的进度,任务仍在进行。", + "native.noRecentProjects": "没有最近项目", + "native.noReleaseIsAvailableYet": "暂时没有可用版本", + "native.noRememberedChoicesCheckRememberMyChoiceInTheQuitDialogToSkipTheQuestionForAKi279db9aaf7": "暂无已记住的选择。在退出对话框中勾选“记住对这类服务的选择”后,下次遇到同类服务就不再询问。", + "native.noRemote": "无远程仓库", + "native.noRemoteBranches": "没有远程分支", + "native.noRemoteURL": "没有远程地址", + "native.noRunServicesConfigured": "未配置运行服务", + "native.noRunnableServicesWereDetectedInThisProject": "未在此项目中检测到可运行的服务。", + "native.noRunningDebugProcessAcceptsStandardInput": "没有正在运行且接受标准输入的调试进程", + "native.noSavedChanges": "没有已保存的更改", + "native.noShellWasFoundAtChooseADetectedShellOrRunDetectionAgain": "在 {arg1} 未找到 Shell。请选择已检测到的 Shell,或重新执行检测。", + "native.noShelves": "没有 Shelf", + "native.noSpringBootOrMavenModuleConfigurations": "没有 Spring Boot 或 Maven Module 配置", + "native.noSubagentOutputYet": "尚未收到子代理输出", + "native.noSubagentsYet": "暂无子代理", + "native.noTasksYet": "暂无任务", + "native.noTextualDiffAvailable": "没有可用的文本 Diff", + "native.noTextualDifference": "没有文本差异", + "native.noToolsRunning": "暂无工具运行", + "native.noUpdateCheckHasBeenPerformedYet": "尚未执行更新检查。", + "native.noUpdatePackageIsAvailableForThisMacArchitectureOpenTheReleasePageToCheckAvailableDownloads": "没有适用于此 Mac 架构的更新包。请打开 Release 页面查看可用下载。", + "native.noUpdatePackageIsAvailableForThisMacDownloadTheReleaseManually": "没有适用于此 Mac 的更新包,请手动下载版本。", + "native.noUpdatesFoundInTheLocalHomebrewIndex": "当前 Homebrew 索引未发现更新。", + "native.noUpstreamConfigured": "未设置上游分支", + "native.noUsagesFound": "没有找到调用位置", + "native.noWorktrees": "没有工作树", + "native.nodeJsLldOrLater": "Node.js {arg1} 或更高版本", + "native.nodeJsLldOrLaterIsRequiredFound": "需要 Node.js {arg1} 或更高版本,当前为 {arg2}。", + "native.nodeJsLldOrLaterWasNotFoundInstallItThenCheckAgain": "未找到 Node.js {arg1} 或更高版本。请安装后重新检测。", + "native.nodeJsSupport": "Node.js 支持", + "native.nonStreaming": "非流式", + "native.nonStreamingResponses": "非流式响应", + "native.none": "无", + "native.noneFastest": "无(最快)", + "native.normalFolder": "普通文件夹", + "native.notAssigned": "未分配", + "native.notAvailableForThisLitheOrMac": "{arg1}(当前 Lithe 或这台 Mac 无法使用)", + "native.notAvailableYetDependenciesAreNotIndexed": "尚不可用:依赖尚未建立索引", + "native.notFetchedYet": "尚未获取。", + "native.notFound": "未找到", + "native.notIncludedInTheUpdatedPlan": "未包含在更新后的计划中", + "native.notInstalled": "未安装", + "native.notInstalled.variant2": "未安装 · {arg1}", + "native.notInstalledInstallItToStartUsingThisAgent": "尚未安装。安装后即可使用该 Agent。", + "native.notRefreshedYet": "尚未刷新", + "native.notRun": "尚未运行", + "native.notRunning": "未运行", + "native.notSelected": "未勾选", + "native.notSet": "未设置", + "native.notSupported": "不支持", + "native.notSupportedOnYet": "暂不支持 {arg1} 平台", + "native.notUsed": "不使用", + "native.notUsedYet": "尚未被使用。", + "native.nothingToPush": "没有可推送的内容", + "native.notifications": "通知", + "native.nowFollowsYourLocalConfiguration": "{arg1} 现在跟随你本机的 {arg2} 配置。", + "native.npmWasNotFoundItIsNormallyInstalledWithNodeJs": "未找到 npm。它通常随 Node.js 一起安装。", + "native.ocamlSupport": "OCaml 支持", + "native.ofIsHandledBy": "{arg2} 的 {arg1} 由 {arg3} 处理。", + "native.off": "关闭", + "native.offForThisProject": "已在此项目关闭", + "native.official": "官方", + "native.olderCommitsAreNotListed": "更早的提交未列出。", + "native.olderCommitsAreOutsideTheLoadedHistory": "更早的提交不在当前加载的历史范围内", + "native.on": "开启", + "native.oneArgumentPerLineArgumentsKeepSpacesWithoutQuoting": "每行一个参数。参数中的空格无需额外引号。", + "native.oneFile": "单个文件:{arg1}", + "native.oneNAMEValueEntryPerLine": "每行填写一个 NAME=value", + "native.onePathPerLineRelativePathsAreResolvedFromTheWorkspace": "每行一个路径;相对路径以当前工作区为基准。", + "native.onePatternPerLineCaseInsensitivePatternsUseWildcardsASimplifiedFormOfRegularEx4f7ef7b38a": "每行一条规则,不区分大小写。规则使用通配符,是正则表达式的简化写法:`*` 匹配任意字符,`?` 匹配单个字符。\n以 `/` 或 `/*` 结尾表示隐藏整个文件夹及其内容,以 `**/` 开头表示匹配任意层级;其他写法隐藏匹配的文件。\n示例:\n`**/dist/` — 任意层级的 dist 文件夹\n`.lithe/*` — 项目根目录下的 .lithe 文件夹\n`*.log` — 所有 .log 文件\n`docs/*.md` — docs 下的 Markdown 文件\n下方的内置规则始终生效。", + "native.onePatternPerLineLogCacheOnlyAffectsThisSearchEverywhereWindow": "每行一个表达式:`*.log`、`**/.*/`、`.*`、`cache/`。仅影响当前双 Shift 搜索窗口。", + "native.oneSentenceFocusedOnTheMainChange": "聚焦主要更改的一句话", + "native.oneTIMECODE": "一次性代码", + "native.oneTest": "单个测试:{arg1}", + "native.oneTimeChoicesOverrideRepositoryPreferencesWhichOverrideApplicationDefaultsGit1dff06f51d": "单次选项优先于仓库偏好,仓库偏好优先于应用默认值。只有保存或清除下方字段时才会修改 Git 配置。", + "native.onlyAPIKeysAreUsedTheAgentSOwnAccountSignInSuchAsAChatGPTLoginIsNeverUsed": "只使用 API 密钥登录,不会使用 Agent 自带的账号登录(例如 ChatGPT 登录)。", + "native.onlyAskForActionsDetectedAsPotentiallyUnsafe": "自动审批,仅在检测到潜在风险时请求你的确认。", + "native.onlyLocalFilesCanBeAttachedToAnAgentMessage": "Agent 消息仅支持添加本地文件。", + "native.onlySourceFilesOfTheCurrentLanguageCanBeOpenedFromOutsideTheWorkspace": "工作区外只能打开当前语言的源码文件", + "native.onlyTheAPIURLModelAndAPIKeyAreSavedOtherCLIOptionsContinueToUseYourLocalConfig36d9d4cdfd": "仅保存 API 地址、模型和 API 密钥,其他 CLI 选项继续沿用本机配置。密钥与设置分开存储。编辑时显示这些已保存字段的配置模板。", + "native.onlyTheCommitMessageChangesLldCommitsWillReceiveNewIdentitiesStagedFilesAreNotIncluded": "仅修改提交消息,{arg1} 笔提交的哈希会改变。不会包含当前暂存的文件。", + "native.onlyTheCurrentBranchCanBeUpdated": "只能更新当前分支", + "native.onlyTheFilesBelowAreCollectedCredentialsTokensAndHomeDirectoryPathsAreRedacted1a2820206c": "仅收集下列文件。凭据、令牌和用户目录路径会被自动脱敏;工作区源代码、编辑器缓冲区内容和终端历史绝不会被包含。", + "native.onlyTheMostRecentArchivedConversationsAreShown": "仅显示最近的归档会话。", + "native.onlyTheReportedAgentEditsWillBeReversedConflictingChangesAndUnsavedEditorChangesWillBePreserved": "仅回撤 Agent 上报的修改;有冲突或未保存编辑的文件会保留。", + "native.open": "打开 {arg1}", + "native.openAGitProjectBeforeUsingPullRequests": "请先打开 Git 项目,再使用拉取请求功能", + "native.openAICommitSettings": "打开“AI 与提交”设置", + "native.openAJavaFile": "打开 Java 文件", + "native.openAJavaFileBeforeStartingDebug": "开始调试前请先打开 Java 文件", + "native.openALocalFolderToStartWorking": "打开本地文件夹开始工作。", + "native.openALocalProjectFolder": "打开本地项目文件夹", + "native.openAProjectBeforeChoosingProjectPaths": "请先打开项目,再选择项目路径。", + "native.openAProjectContainingAPomXmlFile": "打开一个包含 pom.xml 文件的项目。", + "native.openAProjectToConfigureGitIdentity": "请打开项目后配置 Git 提交身份。", + "native.openAProjectToConfigureItsJDKAndMavenRuntime": "打开项目后即可配置 JDK 和 Maven 运行时。", + "native.openAProjectToConfigureItsRunConfigurations": "打开项目后即可编辑运行配置。", + "native.openAProjectToConfigureMCPAccessForAgents": "打开项目后,可在这里配置 Agent 的 MCP 访问权限。", + "native.openAProjectToLetConnectedAgentsInspectAndControlIt": "打开项目后,已连接的 Agent 才能检查和控制它。", + "native.openAProjectToSeeItsLanguageService": "打开项目后可查看其语言服务。", + "native.openAProjectToUseGit": "请打开项目以使用 Git", + "native.openAgain": "重新打开", + "native.openAgentSettings": "打开 Agent 设置", + "native.openAutoBuildDownloads": "打开 AutoBuild 下载页", + "native.openChanges": "打开更改面板", + "native.openConfiguration": "打开配置", + "native.openFile": "打开文件", + "native.openFileInEditorToSeeProblems": "在编辑器中打开文件以查看问题", + "native.openGitBranches": "打开 Git 分支", + "native.openGitConsole": "打开 Git 控制台", + "native.openGitHub": "打开 GitHub", + "native.openGitHubProfile": "打开 GitHub 个人主页", + "native.openGitSettings": "打开 Git 设置", + "native.openHTMLInBrowser": "在系统浏览器中打开", + "native.openHistoryForTheActiveFile": "打开当前文件的历史记录", + "native.openInCurrentWindow": "在当前窗口中打开", + "native.openInEditor": "在编辑器中打开", + "native.openInLithe": "在 Lithe 中打开", + "native.openInNewWindow": "在新窗口中打开", + "native.openInRightSplit": "在右侧分栏打开", + "native.openLogDirectory": "打开日志目录", + "native.openModulePomXml": "打开所属模块的 pom.xml", + "native.openNotificationHistory": "打开通知历史", + "native.openPatch": "打开补丁…", + "native.openPluginManagement": "打开插件管理", + "native.openPomXml": "打开 pom.xml", + "native.openProject": "打开项目", + "native.openProject.variant2": "打开项目…", + "native.openProjectWideLocalHistory": "打开项目级本地历史", + "native.openProjects": "打开的项目", + "native.openProjectsIn": "项目打开方式", + "native.openReleasePage": "打开 Release 页面", + "native.openRemote": "打开远程仓库 ↗", + "native.openRemote.variant2": "打开远程仓库", + "native.openStableDownloads": "打开正式版下载页", + "native.openToInspect": "打开后检查", + "native.openingItMovesTheConversationOutOfSArchiveAndBackIntoItsHistoryBrowsingArchive50e980deac": "打开后,该会话会从 {arg1} 的归档中移回历史记录。仅浏览归档会话不会改动它们。", + "native.optionalApprovalSummary": "可选的批准说明", + "native.options": "{arg1} 选项", + "native.options.variant2": "参数", + "native.or": "或", + "native.orLater": "{arg1} {arg2} 或更高版本", + "native.orLaterIsRequiredFoundUpdateItThenCheckAgain": "需要 {arg1} {arg2} 或更高版本,当前为 {arg3}。请升级后重新检测。", + "native.originalContentIsMissingOrIncomplete": "原始内容缺失或不完整,无法安全回撤。", + "native.originalHEADCreateABranchFromTheRecoveryReferenceToInspectTheOriginalHistory": "原 HEAD:{arg1}。可从恢复引用创建分支,查看改写前的历史。", + "native.other": "其他", + "native.otherChangeListsHaveStagedFilesUnstageThemBeforeCommittingTheCurrentChangeList": "其他更改列表有已暂存文件。请先取消这些文件的暂存,再提交当前更改列表。", + "native.otherLldBranches": "其余 {arg1} 个分支", + "native.otherLldCommits": "其余 {arg1} 条提交", + "native.otherLldFiles": "其余 {arg1} 个文件", + "native.otherLldTags": "其余 {arg1} 个标签", + "native.otherReferenceChangesLldAddedLldUpdatedLldDeleted": "其余引用变化:新增 {arg1}、更新 {arg2}、删除 {arg3}", + "native.otherRunConfigurations": "其他运行配置", + "native.output": "输出", + "native.output.variant2": "输出:{arg1}", + "native.outputLanguage": "输出语言", + "native.outputSpeedTPS": "输出速度(TPS)", + "native.overriddenByALaterValue": "已被更高优先级的值覆盖", + "native.overriddenInThisProject": "已被此项目覆盖", + "native.override": "已覆盖", + "native.overrideAGitSetting": "覆盖 Git 设置", + "native.overview": "概览", + "native.package": "包", + "native.packageTheRedactedApplicationLogWithAnEnvironmentAndPerformanceSnapshotIntoAZi3d8894ab31": "将脱敏后的应用日志与环境和性能快照打包成一个 zip 压缩包,可附在错误报告中。凭据、令牌和用户目录路径会被自动移除,你也可以在写入任何内容之前先查看文件清单。", + "native.pageZoom": "页面缩放", + "native.parentVersion": "父版本", + "native.partOfLitheItStartsNoProgramsOfItsOwn": "属于 Lithe 自身功能,不会启动额外程序。", + "native.partiallySelected": "部分勾选", + "native.passed": "通过", + "native.passwordOrPassphrase": "密码或密钥口令", + "native.pasteAnHttpsPluginIndexURLOrAGitHubRepositoryURLAGitHubRepositoryMustContainLi7616c31790": "粘贴 https 插件索引地址或 GitHub 仓库地址。GitHub 仓库的根目录需要包含 lithe-plugin-index.json。", + "native.pastePatch": "粘贴补丁", + "native.pasteThisIntoYourMCPClientConfiguration": "将此内容粘贴到 MCP 客户端配置中。", + "native.pastedFiles": "已粘贴文件", + "native.patchCanBeAppliedToTheSelectedDestination": "补丁可应用到所选目标。", + "native.patchCannotBeApplied": "补丁无法应用。", + "native.patchPreview": "补丁预览", + "native.patchesMustBeAtMost32MiB": "补丁大小不能超过 32 MiB。", + "native.path": "路径", + "native.pathMissing": "路径不存在", + "native.paths": "路径", + "native.pause": "暂停", + "native.pauseTheProcessToInspectVariables": "暂停程序以查看变量", + "native.paused": "已暂停", + "native.peakThisRun": "本次运行峰值", + "native.peakTotal": "峰值总计", + "native.pending": "等待执行", + "native.pendingConfirmation": "等待确认", + "native.pendingPluginChangesLld": "待确认的插件修改:{arg1}", + "native.pendingVerification": "等待验证", + "native.performance": "性能", + "native.performanceIsAvailableForLocalJavaProcessesLaunchedByLithe": "性能监控适用于由 Lithe 启动的本地 Java 进程。", + "native.performanceResults": "性能分析结果", + "native.perlSupport": "Perl 支持", + "native.permissionRequired": "需要许可", + "native.permissions": "权限", + "native.permissionsPrograms": "权限与程序", + "native.phpLanguageSupportWillBeRemovedAfterRestartingLithe": "重启 Lithe 后将移除 PHP 语言支持。", + "native.phpSupport": "PHP 支持", + "native.pick": "保留提交", + "native.pickPreservesTheCommitEditPausesSoYouCanAmendStagedChangesSquashCombinesMessag9cb6d5afdd": "保留提交:保持原提交。编辑:暂停以便修订提交。合并并保留消息:合并两笔提交的消息。合并并丢弃消息:仅保留前一笔消息。移除提交:从历史中删除该笔提交。", + "native.pinConfiguration": "置顶运行配置", + "native.pinned": "置顶", + "native.pinnedLocal": "固定版本:{arg1} · 本地:{arg2}", + "native.placeTheCaretInAJavaFileToSetABreakpoint": "请将光标置于 Java 文件中以设置断点", + "native.placeTheCaretOnAJavaSymbolFirst": "请先将光标置于 Java 符号上", + "native.plan": "计划", + "native.planBeforeMakingChanges": "先制定计划,再进行修改。", + "native.planLldLld": "计划 {arg1}/{arg2}", + "native.plannedCommand": "计划执行的命令", + "native.plannedGitCommandWaitingToStart": "Git 命令计划 — 等待启动", + "native.platforms": "支持平台", + "native.pluginChangesTakeEffectAfterLitheRestarts": "插件变更将在重启 Lithe 后生效。", + "native.pluginDetails": "插件详情", + "native.pluginDisabled": "插件已禁用", + "native.pluginID": "插件 ID", + "native.pluginIDItCanKeepUsingTheDeviceUntilYouCloseOrHideItsWindow": "插件 ID:{arg1}。在你关闭或隐藏它的窗口之前,它可以一直使用该设备。", + "native.pluginMarketplaceIsNotAvailable": "插件市场暂不可用", + "native.pluginRepositories": "插件仓库", + "native.pluginRepository": "插件仓库", + "native.pluginSettings": "插件设置", + "native.pluginSource": "来源", + "native.plugins": "插件…", + "native.pop": "弹出", + "native.ports": "端口 {arg1}", + "native.postingComment": "正在发表评论…", + "native.powershellSupport": "PowerShell 支持", + "native.preflight": "预检", + "native.preparingConversation": "正在准备对话…", + "native.preparingDownload": "正在准备下载…", + "native.preservesEveryCommitAndAddsAMergeCommitToTheBaseBranch": "保留全部提交,并在目标分支中添加一个合并提交。", + "native.pressShortcut": "请按下快捷键…", + "native.previewAndExport": "预览并导出…", + "native.previewAndExportTheSelectedLogsWithEnvironmentInformationCredentialsAndHomeDir65f650979b": "预览并导出筛选后的日志和环境信息。凭据和用户目录路径均会脱敏,不包含源码、编辑缓冲区或终端历史。", + "native.previewBackgroundImage": "预览背景图片", + "native.previewFirstChange": "预览第一项更改", + "native.previewShowsTheFirst65536CharactersTheCompletePatchIsUsedWhenSavingOrApplying": "预览仅显示前 65,536 个字符;保存或应用时使用完整补丁。", + "native.previewUpdate": "预览版更新", + "native.previousConfigurationBackedUpTo": "原配置已备份到", + "native.previousConsoleExpression": "上一条表达式", + "native.previousDiffMatch": "上一个 Diff 匹配项", + "native.previousDifference": "上一个差异", + "native.previousFile": "上一个文件", + "native.previousMatch": "上一个匹配项", + "native.previousMatch.variant2": "上一个匹配", + "native.previousMatchShiftReturn": "上一个匹配项(Shift+Return)", + "native.previousSession": "上一次会话", + "native.previousVersion": "上一版本", + "native.primary": "主工作区", + "native.primaryConfiguration": "主配置", + "native.primaryWorktree": "主工作区", + "native.print": "打印…", + "native.prismaSupport": "Prisma 支持", + "native.problems": "问题", + "native.problemsCouldBeFilteredOut": "问题可能已被筛选隐藏", + "native.process": "进程", + "native.processOutput": "进程输出", + "native.processOutputWillAppearHere": "进程输出将在这里显示。", + "native.processTerminated": "进程已终止", + "native.processTerminationPolicy": "进程终止策略", + "native.procfile": "Procfile", + "native.profile": "配置", + "native.profiles": "Profiles", + "native.program": "程序", + "native.programArguments": "程序参数", + "native.programsChangesAreHighlighted": "可以运行的程序(变化已高亮)", + "native.programsItCanRun": "可以运行的程序", + "native.programsLeftRunning": "遗留的程序", + "native.programsStartedInLitheThatLeftTheirTerminalOrToolToKeepRunningOnTheirOwnLikeDa7b15ea8191": "在 Lithe 中启动、但已脱离终端或工具独立运行的程序,例如守护进程或后台开发服务器。退出时会把它们列在一起,由你选择结束哪些。只会列出在本次 Lithe 中启动的程序;在 Lithe 之外启动的容器和服务一律不处理。", + "native.project": "项目:{arg1}", + "native.projectAccess": "项目权限", + "native.projectChanges": "项目更改", + "native.projectConfiguration": "项目配置", + "native.projectDefault": "项目默认值", + "native.projectDefaultsThisMac": "项目默认运行环境(本机)", + "native.projectEnvironment": "项目环境", + "native.projectErrors": "项目错误", + "native.projectFiles": "项目文件", + "native.projectIdentificationComplete": "项目识别完成", + "native.projectIdentificationFailed": "项目识别失败", + "native.projectIsStillLoading": "项目仍在加载中", + "native.projectJDK": "项目 JDK", + "native.projectJDKMaven": "项目 · JDK 与 Maven", + "native.projectLocalHistory": "项目本地历史", + "native.projectMavenWrapper": "项目 Maven Wrapper", + "native.projectOpened": "项目已打开", + "native.projectPathsMustStayInsideTheCurrentProject": "项目路径必须位于当前项目内。", + "native.projectRoot": "项目根目录", + "native.projectRunConfigurationIsInvalid": "项目运行配置无效", + "native.projectRunConfigurationNotFound": "未找到项目运行配置", + "native.projectRuntimeSettingsAreSavedLocallyForThisProject": "项目运行时设置会保存在本机的项目配置中。", + "native.projectToolchainNeedsAttention": "项目工具链需要处理", + "native.protection": "保护状态", + "native.protocolBuffersSupport": "Protocol Buffers 支持", + "native.providedBy": "由以下插件提供", + "native.providedByLitheForThisLanguage": "由 Lithe 为该语言提供", + "native.provider": "识别器", + "native.providerConfigurationParsingIsUnavailable": "供应商配置解析暂不可用。", + "native.providerDetails": "服务商详情", + "native.providerName": "服务商名称", + "native.providerRequiresAnAPIKey": "服务商需要 API 密钥", + "native.providers": "供应商管理", + "native.provides": "提供", + "native.pruneStaleRecords": "清理陈旧记录", + "native.pruneStaleRemoteBranches": "清理失效的远程分支", + "native.pruneStaleRemoteTrackingReferences": "清理失效的远程跟踪引用", + "native.pruneStaleTags": "清理失效的标签", + "native.pruneStaleWorktreeRecords": "清理陈旧的工作树记录?", + "native.pruneTheStaleRecordInstead": "请改用清理陈旧记录。", + "native.prunedStaleWorktreeRecords": "已清理陈旧的工作树记录", + "native.publishBranch": "发布分支", + "native.publishBranchFirst": "请先发布分支", + "native.publishThisWorktree": "发布当前工作树", + "native.publishTo": "发布到 {arg1}/{arg2}", + "native.publishing": "正在发布…", + "native.pull": "拉取", + "native.pull.variant2": "拉取", + "native.pullAndPush": "Pull 与 Push", + "native.pullAndPushSettingsAreWrittenToTheSelectedGitConfigurationFetchSettingsApplyTob5ea5b72f2": "拉取和推送设置写入所选的 Git 配置;获取设置同时作用于手动获取和后台获取。", + "native.pullIntoTheCurrentBranchAndReplayLocalCommits": "将 {arg1} 拉取到当前分支,并重放本地提交。", + "native.pullIntoTheCurrentBranchWithAMerge": "以合并方式将 {arg1} 拉取到当前分支。", + "native.pullRemoteBranchWithMerge": "以合并方式拉取远程分支?", + "native.pullRemoteBranchWithRebase": "以变基方式拉取远程分支?", + "native.pullRequestCheckedOutAsALocalBranch": "拉取请求已检出为本地分支", + "native.pullRequestClosed": "拉取请求已关闭", + "native.pullRequestCreated": "拉取请求已创建", + "native.pullRequestDescriptionGeneration": "拉取请求描述生成", + "native.pullRequestGenerationUsesTheSelectedProviderLanguageReasoningEffortAndDiffLimitAbove": "拉取请求生成会使用上方选择的服务商、语言、推理强度和差异字符限制。", + "native.pullRequestMerged": "拉取请求已合并", + "native.pullRequestReopened": "拉取请求已重新打开", + "native.pullRequestUpdated": "拉取请求已更新", + "native.pullRequests": "拉取请求", + "native.pullRequestsIntegrationIsUnderDevelopment": "拉取请求集成正在开发中", + "native.pullRequestsUnavailable": "无法加载拉取请求", + "native.pullStrategy": "Pull 策略", + "native.pullWithMerge": "拉取并合并", + "native.pullWithRebase": "拉取并变基", + "native.punctuationAndSymbols": "标点与符号", + "native.push": "推送", + "native.push.variant2": "推送…", + "native.pushOnly": "仅推送", + "native.pushTheLatestCommitsBeforeComparingOrCreatingAPullRequest": "比较更改或创建拉取请求前,请先推送最新提交。", + "native.pushThisBranchToGitHub": "将当前分支推送到 GitHub", + "native.pushTo": "推送到 {arg1}", + "native.pythonSupport": "Python 支持", + "native.quickInformation": "快速信息", + "native.quickResponsesWithBasicReasoning": "快速响应,基础推理", + "native.quit": "退出", + "native.quitWithSelection": "按选择退出", + "native.quittingLitheEndsThesePrograms": "退出 Lithe 会结束这些程序。", + "native.quittingLitheStopsTheseServicesAndTheProcessesTheyStarted": "退出 Lithe 会停止这些服务及其启动的进程。", + "native.quota": "额度 —", + "native.quotaIsTemporarilyUnavailableTheLastValueMayBeOutOfDate": "暂时无法更新额度,上次读数可能已过期。", + "native.rSupport": "R 支持", + "native.ranLldCommand": "运行了 {arg1} 个命令", + "native.ranLldCommands": "运行了 {arg1} 个命令", + "native.readLldFile": "读取了 {arg1} 个文件", + "native.readLldFiles": "读取了 {arg1} 个文件", + "native.readOnly": "只读", + "native.readOnlyFilePreview": "只读文件预览", + "native.readingChangesAndRecentCommits": "正在读取文件更改和最近提交。", + "native.readingGitConfiguration": "正在读取 Git 配置…", + "native.readingProject": "正在读取项目…", + "native.readingRegisteredCheckouts": "正在读取 Git 中注册的检出目录。", + "native.readsTheAPIURLModelAndKeyFromClaudeSettingsJsonAndClaudeJsonEditThoseFilesToCh388f05ea21": "从 ~/.claude/settings.json 和 ~/.claude.json 读取 API 地址、模型和密钥。需要修改时请编辑这些文件,然后重新获取。", + "native.readsTheAPIURLModelAndKeyFromCodexConfigTomlAndAuthJsonEditThoseFilesToChangeThemThenFetchAgain": "从 ~/.codex/config.toml 和 auth.json 读取 API 地址、模型和密钥。需要修改时请编辑这些文件,然后重新获取。", + "native.ready": "就绪", + "native.readyToCreate": "可以创建拉取请求", + "native.reason": "原因", + "native.reasoning": "推理", + "native.reasoningEffort": "推理强度", + "native.reasoningTokens": "推理 token:{arg1}", + "native.rebase": "变基", + "native.rebase.variant2": "变基", + "native.rebaseAndKeepMerges": "变基并保留合并", + "native.rebaseAndMerge": "变基并合并", + "native.rebaseBranch": "变基分支?", + "native.rebaseCurrentBranchOnto": "将当前分支变基到…", + "native.rebaseCurrentBranchOntoIncomingChangesR": "将当前分支变基到传入的更改之上(R)", + "native.rebaseInProgress": "正在变基", + "native.rebaseRequiresACleanWorkingTreeCommitOrStashLocalChangesBeforeChoosingRebase": "变基需要干净的工作区。选择变基前,请先提交或暂存本地更改。", + "native.recent": "最近", + "native.recentProjects": "最近项目", + "native.recommendedKeepWorktreesInAPersistentFolderNextToTheRepositoryYouCanChoosePrivf758d1db78": "建议将工作树放在仓库旁的持久目录中。临时检出时可以手动选择 /private/tmp。", + "native.reconnect": "重新连接", + "native.reconnectThisAgentToRefreshItsModelList": "重新连接此 Agent 即可刷新模型列表。", + "native.reconnecting": "正在重连…", + "native.reconnectingDD": "正在重连 {arg1}/{arg2}…", + "native.recoveryReference": "恢复引用:{arg1}", + "native.referenceChangesCouldNotBeInspected": "无法读取引用变更", + "native.referenceListTruncatedTotalUpdatedLldDeletedLld": "引用列表已截断;更新总数:{arg1},删除总数:{arg2}", + "native.refreshAll": "全部刷新", + "native.refreshChanges": "刷新更改", + "native.refreshConnectionStructure": "刷新连接结构", + "native.refreshDependencies": "刷新依赖", + "native.refreshDetectedRuntimes": "刷新运行时检测结果", + "native.refreshFiles": "刷新文件", + "native.refreshGitLog": "刷新 Git 日志", + "native.refreshHistory": "刷新历史", + "native.refreshLocalHistory": "刷新本地历史", + "native.refreshLog": "刷新日志", + "native.refreshLogs": "刷新日志", + "native.refreshPluginRepositories": "刷新插件仓库", + "native.refreshPreview": "刷新预览", + "native.refreshProjectHistory": "刷新项目历史", + "native.refreshPullRequests": "刷新拉取请求", + "native.refreshWorktrees": "刷新工作树", + "native.refreshed": "已于 {arg1} 刷新", + "native.refreshingJavaEntries": "正在刷新 Java 入口", + "native.refreshingProject": "正在刷新项目", + "native.regenerate": "重新生成", + "native.registeredForThisProjectIn": "已为该项目的 {arg1} 注册。", + "native.registeredWith": "已注册到 {arg1}", + "native.registersTheWorktreeAndPreparesItsHEADWhileLeavingItsFilesUncheckedOut": "注册工作树并设置 HEAD,但不检出文件。", + "native.registration": "注册状态", + "native.regularExpression": "正则表达式", + "native.releaseDate": "发布日期", + "native.releaseNote": "发布说明", + "native.reload": "重新加载", + "native.reloadAIConfigurations": "重新加载识别", + "native.reloadCodexConfiguration": "重新加载识别", + "native.reloadConfiguration": "重新加载配置", + "native.reloadGitConfiguration": "重新加载 Git 配置", + "native.reloadMavenProject": "重新加载 Maven 项目", + "native.reloadProjectModel": "重新加载项目模型", + "native.reloadProjects": "重新加载 {arg1} 项目", + "native.reloadRange": "重新加载范围", + "native.reloadTheCorePreviewAndResetTheStepPlan": "重新加载预览并重置操作计划", + "native.reloadTheProjectModel": "重新加载项目模型", + "native.reloadingMaven": "正在重新加载 Maven…", + "native.reloadsProject": "重新加载项目", + "native.rememberMyChoiceForTheseKindsOfServices": "记住对这类服务的选择", + "native.rememberedChoices": "已记住的选择", + "native.remote": "远程", + "native.remote.variant2": "远程:{arg1}", + "native.remoteFetchFailed": "远程 Fetch 失败", + "native.remoteFetchSucceeded": "远程 Fetch 成功", + "native.remoteJVMTomcat": "远程 JVM / Tomcat", + "native.remoteURL": "远程地址", + "native.removeAll": "全部移除", + "native.removeAndItsCheckoutDirectoryTheBranchIsKeptIfGitRefusesBecauseFilesHaveChange7ffdbe003e": "删除“{arg1}”及其检出目录?对应分支会保留。如果 Git 因文件已更改而拒绝删除,你可以继续查看单独的强制删除警告。", + "native.removeAndItsCheckoutDirectoryTheBranchIsKeptIfGitRefusesBecauseFilesHaveChanged2b6784d33": "删除“{arg1}”及其检出目录?分支将保留。如果 Git 因文件已更改而拒绝删除,请查看强制删除警告。", + "native.removeConnection": "移除连接", + "native.removeDConversationSFromHistoryYouCanRestoreThemFromRemovedConversationsTheAge92ba9dc5d9": "确定要从历史记录中移除 {arg1} 个会话吗?可在「已移除的会话」中恢复,Agent 的原始记录会保留。", + "native.removeFile": "移除文件 {arg1}", + "native.removeFromFavorites": "取消收藏", + "native.removeFromHistory": "从历史列表移除", + "native.removeFromRecentProjects": "从最近项目中移除", + "native.removeLocalRemoteBranchesThatNoLongerExistOnTheServer": "删除服务器上已经不存在的本地远程分支。", + "native.removeLocalTagsThatWereDeletedFromTheRemote": "删除远程已经删除的本地标签。", + "native.removeStaleBranches": "清理失效分支", + "native.removeStaleRemoteBranches": "清理失效的远程分支", + "native.removeStaleTags": "清理失效标签", + "native.removeTheSelectedCommitAndItsChangesFromThisBranchLaterCommitsWillBeReplayedAR2e43a75384": "从当前分支历史中移除所选提交及其更改,并重放后续提交。恢复引用会保留原始历史。", + "native.removeWorktree": "删除工作树…", + "native.removeWorktree.variant2": "删除工作树?", + "native.removed": "已删除", + "native.removed.variant2": "已删除 {arg1}", + "native.removedConversations": "已移除的会话", + "native.removedConversationsAreHiddenOnlyInLitheTheAgentSOriginalRecordsAreKept": "移除仅隐藏 Lithe 中的会话,Agent 的原始记录仍保留。", + "native.removedRestartLitheToFinish": "已移除,重启 Lithe 后完成。", + "native.removedRestoreItToUseItAgain": "已移除,恢复后可再次使用。", + "native.rename": "重命名“{arg1}”。", + "native.rename.variant2": "重命名…", + "native.renameBranch": "重命名分支", + "native.renameChangeList": "重命名更改列表", + "native.renameChat": "重命名对话", + "native.renameConversation": "重命名会话", + "native.renamed": "已重命名", + "native.reopenPullRequest": "重新打开拉取请求", + "native.reopenPullRequest.variant2": "重新打开拉取请求", + "native.reopenPullRequest.variant3": "重新打开拉取请求?", + "native.reopeningPullRequest": "正在重新打开拉取请求…", + "native.repairOrPruneTheMissingCheckoutBeforeChangingItsLock": "请先修复或清理路径缺失的工作树,再更改锁定状态。", + "native.repairWorktreeRecords": "修复工作树记录", + "native.repairedWorktreeRecords": "已修复工作树记录", + "native.repeatedLldTimes": "重复 {arg1} 次", + "native.replace": "替换", + "native.replaceCurrentCommitMessage": "替换当前提交信息?", + "native.replaceExistingContent": "替换现有内容", + "native.replaceInFile": "在文件中替换", + "native.replaceInFile.variant2": "在文件中替换…", + "native.replaceInFiles": "在项目中替换", + "native.replaceInProject": "在项目中替换", + "native.replaceInProject.variant2": "在项目中替换", + "native.replaceTextAcrossTheWorkspace": "替换工作区中的文本", + "native.replaceTheMessageOfThePausedCommitAndIncludeTheCurrentlyStagedChangesThenContinueTheRebase": "更新暂停处提交的消息,并包含当前暂存的更改,然后继续变基。", + "native.replaceWith": "替换为", + "native.replaceWithinTheActiveEditor": "在当前编辑器中替换", + "native.replacesAnInstalledPlugin": "需先卸载旧版", + "native.replay": "重放", + "native.replayTheCurrentBranchOntoConflictsMayRequireTerminalResolution": "将当前分支的提交重放到 {arg1} 之上。如有冲突,可能需要在终端中解决。", + "native.replaysEveryCommitOntoTheBaseBranchWithoutAMergeCommit": "将所有提交重放到目标分支,不创建合并提交。", + "native.reportedChange": "上报的修改", + "native.repository": "仓库", + "native.repositoryChangedReviewAndRetry": "仓库状态已改变,请检查并重试", + "native.repositoryIdentityOverridesInheritedGlobalValuesClearAnOverrideToUseInheritedSettings": "仓库身份会覆盖继承的全局值。清除覆盖后将使用继承的配置。", + "native.repositoryNeedsAttention": "仓库需要处理", + "native.repositoryURL": "仓库 URL", + "native.requestChanges": "请求修改", + "native.requiredFieldsAreMarkedWith": "带 * 的字段为必填项", + "native.rerun": "重新运行", + "native.rerunLastTest": "重新运行上次测试", + "native.rescan": "重新识别", + "native.rescanServices": "重新识别服务", + "native.rescanTheProjectForServices": "重新识别项目中的服务", + "native.reset": "重置", + "native.resetCurrentBranch": "重置当前分支?", + "native.resetCurrentBranchToHere": "将当前分支重置到这里…", + "native.resetFetchDefaults": "重置 Fetch 默认行为", + "native.resetFontSize": "重置字号", + "native.resetHard": "硬重置", + "native.resetMixed": "混合重置", + "native.resetPageZoom100": "恢复页面缩放(100%)", + "native.resetSoft": "软重置", + "native.resetThisSettingToItsInheritedValue": "将此设置重置为继承值", + "native.resetToDefault": "恢复默认值", + "native.resets": "重置时间:{arg1}", + "native.residentMemory": "驻留内存", + "native.residentMemoryOfLitheAndItsManagedProcessTrees": "Lithe 及其托管进程树的常驻内存", + "native.residentMemoryOfTheLitheProcess": "Lithe 进程的常驻内存", + "native.resolveLldConflictedFileSStageTheResultThenContinue": "请解决 {arg1} 个文件的冲突,暂存结果后再继续。", + "native.resolveLldConflictedFileSStageThemThenContinue": "请解决 {arg1} 个冲突文件并暂存,然后继续。", + "native.resolvingDependencies": "正在解析依赖…", + "native.resolvingPushDestination": "正在确定推送目标…", + "native.resolvingServicePaths": "正在解析服务路径…", + "native.resourcesRoot": "资源根目录", + "native.responding": "回复中…", + "native.response": "回复", + "native.responsesAPI": "Responses API", + "native.restart": "重新启动", + "native.restartLanguageServers": "重启语言服务器", + "native.restartLithe": "重启 Lithe", + "native.restartLitheToFinishUninstallingThenInstallTheCommunityPluginFromTheMarketplace": "请重启 Lithe 以完成 {arg1} 的卸载,然后从插件市场安装社区插件 {arg2}。", + "native.restartLitheToFinishUninstallingThenInstallThisPlugin": "请重启 Lithe 以完成 {arg1} 的卸载,然后再安装此插件。", + "native.restartLitheToLoadTheUpdatedPage": "重启 Lithe 以加载更新后的页面。", + "native.restartRequired": "需要重启 Lithe", + "native.restartRun": "重新运行", + "native.restore": "恢复", + "native.restoreAllDefaults": "全部恢复默认", + "native.restoreConversations": "恢复会话", + "native.restoreDefault": "恢复默认", + "native.restoreDefaultTemplate": "恢复默认模板", + "native.restoreDefaultUILayout": "恢复默认 UI 布局", + "native.restorePath": "恢复路径", + "native.restoringGitHubConnection": "正在恢复 GitHub 连接", + "native.result": "结果", + "native.results": "测试结果", + "native.results.variant2": "个结果", + "native.resume": "恢复执行", + "native.resumeThePausedDebugSession": "继续已暂停的调试会话", + "native.retentionAndCleanup": "保留与清理", + "native.retiredOfficialGoPluginGoSupportIsNowTheCommunityPluginGoSupportInLitheStore": "已退役的官方 Go 插件。Go 支持现在由 Lithe Store 中的社区插件 Go Support 提供。", + "native.retiredOfficialPHPPluginPHPSupportIsNowTheCommunityPluginPHPSupportInLitheStore": "已退役的官方 PHP 插件。PHP 支持现在由 Lithe Store 中的社区插件 PHP Support 提供。", + "native.retryDebugging": "重试调试", + "native.retryIdentification": "重新识别", + "native.retryUpdateCheck": "重试更新检查", + "native.retryUsesLitheAuthenticationForThisOperationOnly": "此次重试仅为当前操作使用 Lithe 认证。", + "native.returnFromTheCurrentFunction": "从当前函数返回", + "native.returnToLithe": "返回 Lithe", + "native.returnToStable": "回到正式版", + "native.returnToStable.variant2": "回到正式版?", + "native.returnToTheWelcomeScreen": "返回 Welcome 页面", + "native.revealInFinder": "在 Finder 中显示", + "native.revert": "反向提交", + "native.revert.variant2": "反向提交", + "native.revertCommit": "反向提交(保留历史)…", + "native.revertInProgress": "正在还原", + "native.revertThisCommit": "反向提交以撤销此提交?", + "native.review": "查看", + "native.reviewAction": "审查操作", + "native.reviewAndRetry": "查看并重试…", + "native.reviewAndRetryUnfinishedSteps": "查看并重试未完成的步骤…", + "native.reviewApproved": "审查已批准", + "native.reviewChanges": "查看文件修改", + "native.reviewCommentSubmitted": "审查评论已提交", + "native.reviewCommitAndPush": "检查提交并推送", + "native.reviewForceRemove": "查看强制删除警告…", + "native.reviewRemainingSteps": "确认剩余步骤", + "native.reviewRepositoryCommits": "确认各仓库提交", + "native.reviewTheFilesAndDiffBeforeApplyingTheRepositoryIsCheckedAgainWhenYouConfirm": "请先检查文件和差异。确认应用时会再次检查仓库状态。", + "native.reword": "编辑消息", + "native.rewriteCommitsAfterTheSelectedBaseThroughHEADTheBaseCommitStaysInPlaceStepsRunFromTopToBottom": "改写所选基点之后直到 HEAD 的提交,不包含基点本身。步骤从上到下依次执行。", + "native.rightClickAFieldWhilePausedToAddABreakpoint": "暂停时右键单击字段以添加断点", + "native.role": "类型", + "native.rollBack": "回滚", + "native.rollBack.variant2": "回滚…", + "native.rollBack.variant3": "回撤", + "native.rollBackAgentChanges": "回撤 Agent 修改", + "native.rollBackAgentChanges.variant2": "回撤 Agent 的修改?", + "native.rollBackAll": "回撤全部", + "native.rollBackTo": "将 {arg1} 回滚到 {arg2}?", + "native.rollbackCouldNotRestoreTheChangedFileItRemainsAtRestoreItManuallyBeforeRetrying": "回撤时无法恢复发生冲突的文件,文件仍保留在 {arg1}。请先手动恢复再重试。", + "native.rollbackCouldNotVerifyTheMovedFileCheckTheTrashBeforeRetrying": "回撤时无法核对已移动的文件,请先检查废纸篓再重试。", + "native.routesAPIKeyAndCodexSubscriptionRequestsThroughALocalProxyOnThisMacToShowModel6781e5aa03": "通过本机本地代理转发 API Key 和 Codex 订阅请求,用于显示模型调用次数、首 token 延迟、输出速度和上下文构成。请求与凭据原样转发,不会保存。重新连接后生效。", + "native.rubySupport": "Ruby 支持", + "native.runABuildTaskToSeeOutput": "运行构建任务后会显示输出。", + "native.runAConfigurationToSeeProcessOutput": "运行配置后将在这里显示进程输出。", + "native.runAMavenLifecyclePhaseToSeeOutput": "运行 Maven 生命周期阶段后会显示输出。", + "native.runAllServices": "运行所有服务", + "native.runAllServices.variant2": "是否运行所有服务?", + "native.runAllTests": "运行全部测试", + "native.runAnotherACPAgentYouInstalledYourselfItMustSupportSigningInWithACustomAPIKeyGateway": "运行你自行安装的其他 ACP Agent。它必须支持通过自定义 API 密钥网关登录。", + "native.runBrewUpdateManuallyThenCheckAgainForUpdates": "请手动运行 `brew update`,然后重新检查更新。", + "native.runCheckedConfigurationsWithoutRestartingRunningServices": "启动勾选的配置,已运行的服务保持运行", + "native.runConfiguration": "运行配置", + "native.runConfiguration.variant2": "运行配置", + "native.runConfiguration.variant3": "运行配置", + "native.runConfigurations": "运行配置", + "native.runConfigurationsAndMavenGoalsStartedInLitheTheyAlwaysStopWhenTheirProjectOrWi2085dbd360": "在 Lithe 中启动的运行配置和 Maven 目标。项目或窗口关闭、或退出 Lithe 时,它们总会连同自己启动的进程一起停止;这里只决定 Lithe 是否先列出并询问。", + "native.runConfigurationsAreUnavailableEnableTheExecutionModuleAndTryAgain": "运行配置暂不可用,请启用执行模块后重试。", + "native.runConfigurationsInheritTheProjectJDKAndMavenUnlessOverriddenBelow": "运行配置默认继承项目 JDK 和 Maven,可在下方为当前配置单独覆盖。", + "native.runConfigurationsMayBeOutOfDate": "运行配置可能已过期", + "native.runSelected": "启动所选配置", + "native.runSelectedConfiguration": "运行选中的配置", + "native.runSelectedMavenLifecyclePhase": "运行选中的 Maven 生命周期阶段", + "native.runSelectedServices": "运行选中的服务", + "native.runSelectedTask": "运行所选任务", + "native.runServices": "运行服务", + "native.runServicesAreStillRunning": "仍有运行服务在运行", + "native.runTests": "运行测试", + "native.runTests.variant2": "运行测试", + "native.runTestsToViewProcessPerformance": "运行测试以查看进程性能。", + "native.runYourFiles": "运行你的文件", + "native.runYourTests": "运行你的测试", + "native.running": "运行中", + "native.runningFor": "已运行 {arg1}", + "native.runningGitWaitingForOutput": "正在执行 Git,等待输出。", + "native.runningServices": "运行中的服务", + "native.runningTime": "已运行", + "native.runningTools": "正在执行工具…", + "native.runsFiles": "运行 {arg1} 文件", + "native.runsOnInstalledOnThisMac": "使用本机已安装的 {arg1} 运行", + "native.runsOnShippedWithThePlugin": "使用插件自带的 {arg1} 运行", + "native.runsTests": "运行 {arg1} 测试", + "native.runsTheInstalledOnThisMacThroughTheAdapter": "通过 {arg2} 适配器驱动这台 Mac 上已安装的 {arg1}。", + "native.runtime": "运行时长", + "native.runtimeUsedByTheLanguageServerInstalledOnThisMac": "语言服务器使用的运行时(本机已安装)", + "native.rustSupport": "Rust 支持", + "native.s": "{arg1}秒", + "native.sampleInterval": "采样间隔", + "native.saveAfter": "保存延迟", + "native.saveChangedFilesAutomatically": "自动保存已修改的文件", + "native.saveChanges": "保存更改", + "native.saveEachFieldSeparatelySwitchingScopeReloadsTheSavedValues": "每个字段单独保存。切换范围时会重新加载已保存的值。", + "native.saveKey": "保存密钥", + "native.saveLocalChangesWith": "本地更改保存方式", + "native.saveMessage": "保存消息", + "native.saveMessage.variant2": "保存信息", + "native.saveMetadata": "保存元数据", + "native.saveOrDiscardUnsavedFilesBeforeDeletingThisItem": "删除此项目之前,请先保存或丢弃未保存的文件", + "native.savePatch": "保存补丁…", + "native.saveScope": "保存范围", + "native.saveTheActiveDocument": "保存当前文档", + "native.saveTitle": "保存标题", + "native.saved": "已保存", + "native.savedInLitheRunConfigurationsJsonForTheWholeTeamLocalJDKPathsAreNeverShared": "保存在 .lithe/run/configurations.json,供整个团队使用。本机 JDK 路径不会共享。", + "native.savedInLitheRunLocalJsonAndExcludedFromGit": "保存在 .lithe/run/local.json,并已从 Git 中排除。", + "native.savedOnlyForThisProjectOnThisComputerInLitheRunLocalJsonAndExcludedFromGit": "仅为这台电脑上的此项目保存在 .lithe/run/local.json,并已从 Git 中排除。", + "native.savedTo": "已保存到 {arg1}", + "native.savingReplacesTheCurrentGitHubMetadata": "保存后将替换当前 GitHub 元数据。", + "native.savingWritesTo": "保存将写入", + "native.says": "“{arg1}”提示", + "native.scalaSupport": "Scala 支持", + "native.scopeManagedFrom": "依赖管理前 scope 为 {arg1}", + "native.scopeNotUpdatedTo": "未提升为 {arg1} scope", + "native.scopeUpdatedFrom": "scope 由 {arg1} 提升", + "native.scrollToNewGitOutput": "滚动到最新 Git 输出", + "native.scrollback": "回滚", + "native.searchAcrossTheProject": "搜索整个项目", + "native.searchActionsOrShortcuts": "搜索操作或快捷键", + "native.searchBranches": "搜索分支", + "native.searchCommits": "搜索提交", + "native.searchConnections": "搜索连接", + "native.searchConversation": "搜索对话", + "native.searchConversationTitlesOrIDs": "搜索会话标题或 ID…", + "native.searchDiff": "搜索 Diff", + "native.searchEverywhere": "全局搜索", + "native.searchEverywhere.variant2": "全局搜索…", + "native.searchEverywhereDoubleShift": "全局搜索(双击 Shift)", + "native.searchExclusions": "搜索排除规则", + "native.searchFilesAndContents": "搜索文件和内容", + "native.searchFonts": "搜索字体", + "native.searchForBranchesAndActions": "搜索分支和操作", + "native.searchImplementations": "搜索实现", + "native.searchInProject": "在项目中搜索", + "native.searchOptions": "搜索选项", + "native.searchPaths": "搜索路径:{arg1}", + "native.searchPlugins": "搜索插件", + "native.searchProjects": "搜索项目", + "native.searchTextAcrossTheWorkspace": "搜索整个工作区的文本", + "native.searchUsers": "搜索用户", + "native.searchWithinTheActiveEditor": "在当前编辑器中搜索", + "native.searchWorktreeOrPath": "搜索工作树或路径", + "native.searchedLldTime": "搜索了 {arg1} 次", + "native.searchedLldTimes": "搜索了 {arg1} 次", + "native.select": "多选", + "native.select.variant2": "勾选 {arg1}", + "native.selectAChangedFileFirst": "请先选择一个更改文件", + "native.selectACommit": "选择提交", + "native.selectAFile": "选择文件", + "native.selectAFileToPreview": "选择文件以预览", + "native.selectAFileToReview": "选择文件以查看", + "native.selectAHistoryEntry": "选择一条历史记录", + "native.selectAJavaFileBeforeRunningCurrentFile": "使用“当前文件”运行前,请先选择一个 Java 文件。", + "native.selectAPHPSupportPluginPackage": "请选择 PHP Support 插件包。", + "native.selectAPlugin": "选择一个插件", + "native.selectAProjectHistoryEntry": "选择一条项目历史记录", + "native.selectAPullRequest": "选择一个拉取请求", + "native.selectARunConfigurationToSeeItsOutput": "选择一个运行配置以查看详情和输出。", + "native.selectAServiceOrTaskToConfigureItsArgumentsEnvironmentAndProjectEnvironmentOveafafee2e2f": "选择服务或任务,配置启动参数、环境变量和项目环境覆盖项;点击保存后生效。", + "native.selectASpringBootOrMavenModuleConfiguration": "选择 Spring Boot 或 Maven Module 配置", + "native.selectASpringBootOrMavenModuleConfigurationBeforeStartingDebug": "开始调试前请选择 Spring Boot 或 Maven Module 配置", + "native.selectAWorktree": "选择工作树", + "native.selectAll": "全选", + "native.selectBranch": "选择分支", + "native.selectConfigurationsForBatchActions": "勾选配置以批量操作", + "native.selectConversation": "选择会话", + "native.selectDebugInTheEditorContextMenu": "— 在编辑器右键菜单中选择“调试...”", + "native.selectForCompare": "选择以进行比较", + "native.selectRepositoryForCommitsAndBranchOperations": "选择用于提交和分支操作的仓库", + "native.selectRunConfiguration": "选择运行配置", + "native.selectSavedChanges": "选择已保存的更改", + "native.selectShell": "选择 Shell", + "native.selectTestJVM": "选择测试 JVM", + "native.selectTheChangeListToCommit": "选择要提交的更改列表", + "native.selected": "已勾选", + "native.selectedChangeListOrStagedFilesChangedBeforeGenerationFinished": "生成完成前,当前更改列表或暂存文件已变化,已丢弃旧的提交说明", + "native.selectedDirectory": "当前选择的目录", + "native.selectedFromDiskThePublisherAndRepositoryHaveNotBeenVerified": "从本地磁盘选择,尚未验证发布者与源码仓库。", + "native.selectedRuntime": "已选择", + "native.selectedVersion": "已选版本", + "native.selfUpdateIsOnlyAvailableWhenLitheIsRunningFromAPackagedLitheApp": "只有从打包后的 Lithe.app 运行时才支持自动覆盖更新。", + "native.send": "发送", + "native.sendAMessageTo": "向 {arg1} 发送消息", + "native.sendInputToDebuggee": "向调试进程发送输入", + "native.sendProgramInput": "发送程序输入", + "native.sending": "发送中…", + "native.sensitiveFilesAreNotSentToAnAIProvider": "敏感文件不会发送给 AI 服务商。", + "native.separateArgumentsWithSpacesQuoteAnArgumentThatContainsSpaces": "参数之间用空格分隔;包含空格的参数请加引号。", + "native.server": "服务器", + "native.serverLibrary": "服务器资源库", + "native.services": "服务", + "native.session": "会话", + "native.sessionStatistics": "会话统计", + "native.setTheCustomAgentSExecutableInThePanelSettings": "请在设置窗口的 Agent 页面中填写自定义 Agent 的可执行文件。", + "native.setTheDefaultsUsedByOrdinaryFetchInEveryProjectCredentialsUseYourExistingGitHea37790d0a5": "设置所有项目中普通 Fetch 使用的默认行为。凭据使用现有的 Git 凭据助手和 SSH 配置。", + "native.setUpAgentFileAttachments": "设置 Agent 文件附件…", + "native.setYourGitNameAndEmailInSettingsBeforeCommitting": "提交前,请先在设置页填写 Git 姓名和邮箱。", + "native.setsAsTheUpstream": "将 {arg1} 设为上游分支", + "native.setting": "设置项", + "native.settingValuesOnlyFillThePlacesTheProgramTemplatesReserveForThemSoChangingASetta1ef53141a": "设置值只会填入程序模板预留的位置,不能改变上面列出的程序,所以修改设置不需要重新审查。", + "native.settings": "设置…", + "native.sha256": "SHA-256", + "native.shareProjectOn": "将项目分享至", + "native.sharedWithProject": "项目共享", + "native.shelf": "搁置", + "native.shell": "Shell", + "native.shellIsNotAvailableAtThisPath": "此路径没有可用的 Shell", + "native.shellNotFound": "未找到 Shell", + "native.shellSupport": "Shell 支持", + "native.shelveAndContinue": "保存为 Shelf 并继续", + "native.shelveChanges": "搁置更改", + "native.shelvingSavesTheseChangesInLitheRunsTheOperationThenRestoresThemIfConflictsSto789262a3ef": "保存为 Shelf 会把这些更改保存在 Lithe 中,执行操作后再恢复。如果操作因冲突中断,Shelf 会一直保留,直到你完成该操作。", + "native.shipsWithLithe": "随 Lithe 发行", + "native.shortcutIsAlreadyAssignedToThisCommand": "该命令已使用此快捷键", + "native.shortcutNeedsCommandControlOrOption": "快捷键需要包含 Command、Control 或 Option", + "native.show": "显示", + "native.showAPIKey": "显示密钥", + "native.showAgentDiff": "查看 Agent 修改差异", + "native.showAllChanges": "显示所有更改", + "native.showAllLldLines": "显示全部 {arg1} 行", + "native.showAllReferences": "显示所有引用", + "native.showAsJSON": "显示为 JSON", + "native.showAsTree": "显示为树形", + "native.showChanges": "显示更改", + "native.showChatsInTheAIToolWindow": "在 AI 工具窗口中显示对话", + "native.showChatsInTheEditor": "在编辑区中显示对话", + "native.showCommitDecorations": "显示提交装饰", + "native.showCommitDetails": "显示提交详情", + "native.showConfigurationList": "展开运行配置列表", + "native.showConflictFiles": "显示冲突文件", + "native.showContextBreakdown": "查看上下文明细", + "native.showDependencies": "显示依赖", + "native.showDiff": "显示 Diff", + "native.showDiffWithWorkingTree": "显示与工作区的 Diff", + "native.showEditorPreview": "显示编辑器预览", + "native.showEntry": "显示入口", + "native.showFileHistory": "显示文件历史", + "native.showGitKeyNames": "显示 Git 配置键名", + "native.showIndexedSpringMVCRoutes": "显示已索引的 Spring MVC 路由", + "native.showItsOwnPageInAToolWindow": "在工具窗口中显示插件自带的页面", + "native.showLess": "收起", + "native.showLldMore": "显示其余 {arg1} 项", + "native.showLocalHistory": "查看本地历史…", + "native.showLongGraphEdges": "显示完整长连线", + "native.showMinimap": "显示代码缩略图", + "native.showOrHideGitHistory": "显示或隐藏 Git 历史", + "native.showOrHideJavaDiagnostics": "显示或隐藏 Java 诊断", + "native.showOrHideLanguageDiagnostics": "显示或隐藏语言诊断", + "native.showOrHideLanguageNeutralTestRunners": "显示或隐藏通用测试运行器", + "native.showOrHideRunOutput": "显示或隐藏运行输出", + "native.showOrHideTheDebugToolWindow": "显示或隐藏调试工具窗口", + "native.showOrHideTheMavenToolWindow": "显示或隐藏 Maven 工具窗口", + "native.showOrHideTheTerminalToolWindow": "显示或隐藏终端工具窗口", + "native.showPlan": "展开计划", + "native.showPreview": "显示预览", + "native.showProjectInFinder": "在 Finder 中显示项目", + "native.showProjectLocalHistory": "查看项目本地历史…", + "native.showResults": "显示结果", + "native.showSessionDetails": "显示会话详情", + "native.showSourceFileName": "显示源码文件名", + "native.showTechnicalGitDetails": "显示 Git 技术细节", + "native.showTerminal": "跳到此终端", + "native.showTheActiveFileInFinder": "在 Finder 中显示当前文件", + "native.showThinking": "展开思考", + "native.showToolDetails": "展开工具详情", + "native.showToolbar": "显示工具栏", + "native.showUsagesAndGitAuthor": "显示引用数量和 Git 作者", + "native.showWorktreeRepositories": "显示工作树仓库", + "native.showingThePreviousJavaEntriesWhileTheJavaLanguageServicePreparesTheProject": "Java 语言服务正在准备项目,暂时显示上次的 Java 入口。", + "native.shownAcrossTheEntireWorkbenchIncludingToolbarsProjectTreeEditorTabsTerminalsAndStatusBar": "显示在整个工作台内,包括工具栏、项目文件树、编辑区、标签栏、终端和状态栏。", + "native.sideBySide": "并排", + "native.signInToCodex": "登录 Codex", + "native.signInToGitHub": "登录 GitHub", + "native.signInToViewAndManagePullRequests": "登录后即可查看和管理拉取请求。", + "native.signInWithChatGPT": "登录 ChatGPT", + "native.signalsThatTheChangesAreReadyToMergeASummaryIsOptional": "表示这些更改已可合并,可选择填写说明。", + "native.signature": "签名", + "native.signedInThroughTheACPGatewayWithTheAPIKeyFromYourLocalConfiguration": "使用本机配置中的 API 密钥通过 ACP 网关登录。", + "native.signedInThroughTheACPGatewayWithThisProviderSAPIKey": "使用该服务商的 API 密钥通过 ACP 网关登录。", + "native.singleFile": "单文件", + "native.singleRow": "单行", + "native.skipBuild": "跳过此构建", + "native.skipCommit": "跳过该提交", + "native.skipCommit.variant2": "跳过当前提交…", + "native.skipTests": "跳过测试", + "native.skipTheCurrentCommitAndItsUnresolvedChanges": "跳过当前提交及其尚未解决的更改?", + "native.skipVersion": "跳过此版本", + "native.skipped": "跳过", + "native.smartCheckout": "智能检出", + "native.smartCheckoutShelve": "智能检出(Shelf)", + "native.smartCheckoutShelvesYourChangesInLitheSwitchesBranchThenRestoresThemForceCheck60e1561e81": "智能检出会先把更改保存为 Lithe Shelf,切换分支后再恢复。强制检出会直接切换并丢弃这些更改。", + "native.smartCheckoutStashesYourChangesSwitchesBranchThenRestoresThemForceCheckoutSwit3688f9f903": "智能检出会先贮藏你的更改,切换分支后再恢复。强制检出会直接切换并丢弃这些更改。", + "native.smartStepInto": "智能步入", + "native.softResetKeepChangesStaged": "软重置(保留暂存更改)", + "native.softWrapIsUnavailableForVeryLargeFiles": "文件过大,自动换行不可用", + "native.softWraps": "自动换行", + "native.softwareUpdate": "软件更新", + "native.soliditySupport": "Solidity 支持", + "native.someDependenciesAreNotShown": "部分依赖未显示。", + "native.someMCPConfigurationFilesCouldNotBeRestoredRefreshAndCheckTheseFilesBeforeRetrying": "部分 MCP 配置文件未能恢复:{arg1}。请刷新并检查这些文件后重试。", + "native.sortByLastUpdated": "按最近更新排序", + "native.sortByName": "按名称排序", + "native.sortPlugins": "插件排序", + "native.source": "配置来源", + "native.sourceCode": "源代码", + "native.sourceRepository": "源码仓库", + "native.sourceRoots": "源码目录", + "native.sourcesRoot": "源代码根目录", + "native.speed": "速度", + "native.springBoot": "Spring Boot", + "native.springEndpoints": "Spring 接口", + "native.sqlSupport": "SQL 支持", + "native.squash": "合并并保留消息", + "native.squash1": "合并为 1 笔", + "native.squashAndMerge": "压缩并合并", + "native.squashCommits": "合并所选提交", + "native.squashCommits.variant2": "合并所选提交…", + "native.squashLldCommitsInto1LaterCommitsInTheAffectedHistoryWillBeReplayed": "将 {arg1} 笔提交合并为 1 笔,并重放其后的受影响提交。", + "native.stableRecommended": "正式版(推荐)", + "native.stableReleaseIsReady": "正式版 {arg1} 已准备就绪。", + "native.stackFrameDisplayOptions": "调用栈显示选项", + "native.stageAllAndCommitUseTheCurrentChangeList": "全部暂存和提交仅使用当前更改列表。", + "native.stageAllChanges": "暂存所有更改", + "native.stageAllFiles": "暂存所有文件", + "native.stageAllFilesInCurrentChangeList": "暂存当前更改列表中的所有文件", + "native.stageAllFilesInRepository": "暂存仓库的所有文件", + "native.stageAtLeastOneChangeBeforeCommitting": "提交前请至少暂存一项更改", + "native.stageAtLeastOneFileFirst": "请先至少暂存一个文件", + "native.stageFile": "暂存文件", + "native.stageFile.variant2": "暂存文件", + "native.stageThisChangeBlock": "暂存此更改代码块", + "native.stageYourFilesInChangesEnterACommitMessageAndCreateTheFirstCommit": "在“更改”面板暂存文件、填写提交消息,然后创建首次提交。", + "native.staged": "已暂存", + "native.staged.variant2": "已暂存", + "native.stagedChanges": "已暂存更改", + "native.stagedFilesChangedBeforeGenerationFinished": "生成完成前,已暂存的文件发生了变化", + "native.staleRecord": "陈旧记录", + "native.standard": "标准", + "native.startALanguageServerWhenYouOpenFilesOfItsLanguage": "打开对应语言的文件时启动语言服务器", + "native.startALocalJavaDebugSessionToViewPerformance": "启动本地 Java 调试会话以查看性能。", + "native.startDebugging": "开始调试", + "native.startDebugging.variant2": "开始调试", + "native.startDebuggingTheCurrentJavaFile": "开始调试当前 Java 文件", + "native.startFrom": "起始引用", + "native.startRebase": "开始变基", + "native.startRecording": "开始录制", + "native.startService": "启动服务", + "native.startTheDiscussionOrSubmitTheFirstReview": "发起讨论或提交第一次审查。", + "native.startTheSessionBySendingAMessage": "发送消息以开始会话", + "native.startThisConfigurationToSeeItsOutputHere": "启动此配置后,输出将显示在这里。", + "native.startWithAnActionVerbWithoutATypePrefix": "以动作动词开头,不添加类型前缀", + "native.startedATaskThatIsStillRunning": "{arg1} 启动的任务仍在运行", + "native.starting": "正在启动", + "native.startingCommitOptional": "起始提交(可选)", + "native.startingInteractiveRebase": "正在开始交互式变基", + "native.startingJavaLanguageServer": "正在启动 Java 语言服务器…", + "native.startingJavaNavigation": "正在启动 Java 导航…", + "native.startingTheAgent": "正在启动 Agent…", + "native.startsNoPrograms": "不启动任何程序。", + "native.startsOnDemandWhenAMatchingFileIsOpened": "按需启动,打开对应文件时运行", + "native.stash": "暂存", + "native.stashAndContinue": "贮藏并继续", + "native.stashChanges": "储藏更改", + "native.stashChangesHereToSwitchBranchesSafely": "将更改暂存到这里,以便安全切换分支。", + "native.stashMessage": "Stash 信息", + "native.stashOrShelfChangesHereToSwitchBranchesSafely": "将更改 Stash 或保存为 Shelf,以便安全切换分支。", + "native.stashRestore": "恢复暂存更改", + "native.stashRestoreNeedsAttention": "恢复暂存的更改需要处理", + "native.stashingSetsTheseChangesAsideRunsTheOperationThenRestoresThemIfConflictsStopTh7f3dbd0dad": "贮藏会先把这些更改放到一边,执行操作后再恢复。如果操作因冲突中断,这些更改会一直保留在贮藏中,直到你完成该操作。", + "native.state": "状态", + "native.status": "状态", + "native.stepInto": "单步进入", + "native.stepLldOfLld": "第 {arg1} 步,共 {arg2} 步", + "native.stepOut": "单步跳出", + "native.stepOver": "单步跳过", + "native.stopAllServices": "停止所有服务", + "native.stopAndClose": "停止并关闭", + "native.stopAndQuit": "停止并退出", + "native.stopBuildTask": "停止构建任务", + "native.stopCamera": "停止摄像头", + "native.stopCurrentRun": "停止当前运行", + "native.stopDebug": "停止调试", + "native.stopDebugging": "停止调试", + "native.stopLoading": "停止加载", + "native.stopMavenTask": "停止 Maven 任务", + "native.stopMicrophone": "停止麦克风", + "native.stopRecording": "停止录制", + "native.stopRun": "停止运行", + "native.stopRun.variant2": "停止运行", + "native.stopSelected": "停止所选配置", + "native.stopService": "停止服务", + "native.stopTheCurrentDebugSession": "停止当前调试会话", + "native.stopTheCurrentRun": "停止当前运行", + "native.stopTrackingBranch": "停止跟踪分支", + "native.stopWithoutAsking": "直接停止", + "native.stoppedMovingTheRemainingItemsToTrash": "已停止将剩余项目移到废纸篓", + "native.stopping": "正在停止", + "native.stopping.variant2": "正在停止…", + "native.stoppingInstallation": "正在停止安装…", + "native.stoppingPrograms": "结束程序", + "native.storePatchesInLitheWithoutAddingObjectsToGit": "将补丁保存到 Lithe,不向 Git 添加对象。", + "native.storeTemporaryChangesInGitSStashList": "将临时更改保存到 Git 的 Stash 列表中。", + "native.structuredTypeScopeSubjectFormat": "结构化 type(scope): subject 格式", + "native.subagent": "子代理", + "native.subagentOutput": "子代理输出", + "native.subagentTask": "子代理任务", + "native.subagents": "子代理", + "native.subjectMaximumLength": "标题最大长度", + "native.submittingReview": "正在提交审查…", + "native.succeeded": "成功", + "native.supplementaryMessageCancelled": "补充消息已取消。", + "native.supportedPlaceholdersSummaryChangesTestingRisks": "支持的占位符:{summary}、{changes}、{testing}、{risks}。", + "native.svelteSupport": "Svelte 支持", + "native.swapDirection": "交换方向", + "native.swiftSupport": "Swift 支持", + "native.switchAgent": "切换 Agent", + "native.switchProjectView": "切换项目视图", + "native.switchToTheNodeJsNpmEnvironmentThatInstalledThisCLIUpdateItThereThenCheckAgain": "请切换到安装此 CLI 的 Node.js/npm 环境,在该环境中更新后重新检测。", + "native.symbols": "符号", + "native.syncAndRemoveMissingTags": "同步并删除已不存在的标签", + "native.syncWithRemote": "同步远程", + "native.synchronizeTagsAndRemoveLocalTagsMissingFromTheRemote": "同步标签并删除远程已不存在的本地标签", + "native.system": "跟随系统", + "native.systemDefault": "系统默认", + "native.systemDefault.variant2": "系统默认 · {arg1}({arg2})", + "native.systemDefaultShellNotDetected": "系统默认 · 未检测到 Shell", + "native.systemGit": "系统 Git", + "native.systemMaven": "系统 Maven", + "native.systemPrompt": "系统提示词", + "native.tabWidth": "Tab 宽度", + "native.tableProperties": "表属性", + "native.tag": "标签", + "native.tagName": "标签名称", + "native.tagOrRevision": "标签或修订版本", + "native.tags": "标签", + "native.takeOwnership": "接管所有权", + "native.takesEffectAfterLitheRestarts": "重启 Lithe 后生效", + "native.tasks": "任务", + "native.tasks.variant2": "任务:{arg1}", + "native.tasksD": "任务:{arg1}", + "native.temporaryConfiguration": "临时配置", + "native.temporaryConfiguration.variant2": "临时配置:{arg1}", + "native.terminalIsStillRunning": "终端“{arg1}”仍在运行程序", + "native.terminalScrollback": "终端回滚行数", + "native.terminalsWithRunningPrograms": "运行中的终端", + "native.terraformSupport": "Terraform 支持", + "native.test": "测试", + "native.testJava": "测试代码", + "native.testProcessTreeResidentMemory": "测试进程树 · 驻留内存", + "native.testResources": "测试资源", + "native.testRunTimedOutAfterSeconds": "{arg1} 测试运行在 {arg2} 秒后超时。", + "native.tests": "测试", + "native.testsFailed": "测试失败", + "native.testsPassed": "测试通过", + "native.text": "文本", + "native.textMeAuthorBranchPath": "搜索文本,或使用 me、author:、branch:、path:", + "native.textOrHash": "文本或 Hash", + "native.theAIProviderReturnedAnEmptyCommitMessage": "AI 服务商返回了空的提交信息。", + "native.theAIProviderReturnedAnEmptyPullRequestDescription": "AI 服务返回了空的拉取请求描述。", + "native.theAIProviderReturnedAnHTTPError": "AI 服务商返回了 HTTP 错误。", + "native.theAIProviderReturnedAnUnexpectedPullRequestDescription": "AI 服务返回了无法识别的拉取请求描述。", + "native.theAIProviderReturnedAnUnexpectedResponse": "AI 服务商返回了意外响应。", + "native.theAPIKeyIsStoredSeparatelyFromSettingsYourLocalCLIConfigurationIsNotChanged": "密钥与设置分开存储,不会修改本机 CLI 配置。", + "native.theActiveRepositoryChangedInspectTheOriginalRepositoryBeforeRetrying": "当前仓库已更改。重试前请检查原仓库。", + "native.theAgentAlreadySavedTheseFilesMarkTheCurrentChangesAsReviewed": "Agent 已将修改写入文件;此操作将当前修改标为已确认。", + "native.theAgentCouldNotStart": "Agent 无法启动", + "native.theAgentDeclinedToContinue": "Agent 拒绝继续。", + "native.theAgentDidNotAcceptTheSupplementaryMessage": "Agent 未接受补充消息。", + "native.theAgentDidNotConfirmTheSelectedSessionSetting": "Agent 未确认所选会话设置。", + "native.theAgentDidNotProvideEnoughOriginalContentToSafelyRollBackThisFile": "Agent 未提供足够的原始内容,无法安全回撤此文件。", + "native.theAgentDiffIsTooLargeToDisplayCompletelyRollbackIsUnavailable": "Agent 差异过大,无法完整展示或安全回撤。", + "native.theAgentHasNotReportedContextUsageYet": "Agent 尚未上报上下文用量。", + "native.theAgentIsNotRunningConnectToStartAConversation": "Agent 未运行。请先连接再开始对话。", + "native.theAgentIsStillRespondingInThisConversation": "Agent 仍在回复当前对话。", + "native.theAgentIsWaitingForYourPermission": "Agent 正在等待你的许可", + "native.theAgentModuleIsStillStartingTryAgainInAMoment": "Agent 模块仍在启动,请稍后再试。", + "native.theAgentNoLongerSupportsTheSelectedSessionSetting": "Agent 已不再支持所选会话设置。", + "native.theAgentProcessStartsWhenThisPanelOpens": "打开此面板时才会启动 Agent 进程。", + "native.theAgentRequestFailed": "Agent 请求失败。", + "native.theAgentStoppedAfterReachingItsRequestLimitForThisTurn": "Agent 已达到本轮请求次数上限,已停止。", + "native.theBranchCouldNotBePublished": "无法发布该分支", + "native.theBranchNowContainsTheCompletedPlanItsOriginalHistoryRemainsAvailableThroughTee76d52d9b": "操作计划已执行完成。仍可通过恢复引用查看分支的原始历史。", + "native.theBuildToolExitedWithCodeLld": "构建工具已退出,退出码 {arg1}。", + "native.theBuildToolOutputIsTooLargeOrNotUTF8": "构建工具的输出过大或不是 UTF-8 编码。", + "native.theBuildToolTimedOut": "构建工具超时。", + "native.theCheckoutDirectoryNoLongerExistsRepairMovedWorktreeMetadataOrPruneTheStaleGitRecord": "检出目录已不存在。你可以修复移动后的工作树元数据,或清理失效的 Git 记录。", + "native.theCheckoutPathDoesNotExist": "检出路径不存在", + "native.theConfiguredBuildToolIsNotExecutable": "配置的构建工具无法执行:{arg1}", + "native.theConversationIsStillBeingPreparedTryAgainInAMoment": "对话仍在准备中,请稍后再试。", + "native.theCredentialStoreVersionIsUnsupportedExistingCredentialsWerePreserved": "凭据存储版本暂不支持,已保留原有凭据。", + "native.theCurrentFileWillBeSavedToLocalHistoryBeforeItIsReplaced": "替换当前文件前,会将其保存到本地历史。", + "native.theCurrentWorktreeCannotBeRemovedHere": "不能在这里删除当前工作树。", + "native.theDefaultChangeListCannotBeRenamed": "默认更改列表不能重命名。", + "native.theDownloadedAppDoesNotMatchTheSelectedStableReleaseNothingWasInstalled": "下载的应用与所选正式版不符,未执行安装。", + "native.theDownloadedDiskImageDoesNotContainLitheApp": "下载的磁盘映像中没有 Lithe.app。", + "native.theDownloadedUpdateFailedItsChecksumVerification": "下载的更新未通过校验。", + "native.theDownloadedUpdateFailedItsSHA256VerificationDoNotInstallItRetryOrUseTheReleasePage": "下载的更新未通过 SHA-256 校验,请勿安装。请重试或通过 Release 页面下载。", + "native.theEditStopChangedRefreshTheSessionBeforeAmending": "编辑暂停位置已更改。请刷新会话后再修改提交。", + "native.theFileHasChangedSinceTheAgentEditOpenTheDiffAndReviewItManually": "文件内容与 Agent 修改存在冲突,请打开差异手动确认。", + "native.theGeneratedMessageWillReplaceTheTextCurrentlyInTheEditor": "生成的提交信息将替换编辑器中的当前文本。", + "native.theGeneratedTitleOrDescriptionWouldReplaceTextYouAlreadyEntered": "生成的标题或描述可能会替换你已经输入的内容。", + "native.theGitHubAuthorizationCodeExpiredStartAgain": "GitHub 授权码已过期,请重新开始。", + "native.theInstallerReportedAnErrorButTheNewCLIVersionWasVerified": "安装器报告了错误,但已确认新 CLI 版本可用。", + "native.theInterfaceLanguageChangesImmediatelyEnglishIsTheDefault": "界面语言会立即生效。默认语言为英文。", + "native.theItemCanBeRecoveredFromTheMacOSTrash": "可以从 macOS 废纸篓中恢复此项目。", + "native.theJVMDoesNotExposeSupportedHeapCountersThroughJstat": "当前 JVM 未通过 jstat 提供受支持的堆计数器。", + "native.theJavaLanguageServerDidNotTakeTheMavenConfiguration": "Java 语言服务器未能应用 Maven 配置:{arg1}", + "native.theJavaLanguageServiceFailedToPrepareTheProject": "Java 语言服务未能完成项目准备。", + "native.theLatestStableReleaseWillBeDownloadedInFullAndReplaceThisAutoBuildAfterYouConbfd63f6a8e": "将全量下载最新正式版,在你确认安装后替换当前 AutoBuild。项目和设置会保留,但 AutoBuild 专有的设置可能无法在正式版中使用。", + "native.theLatestStableReleaseWillBeDownloadedInFullAndReplaceThisPreviewAfterYouConfic01b41840e": "将全量下载最新正式版,在你确认安装后替换当前预览版。项目和设置会保留,但预览版专有的设置可能无法在正式版中使用。", + "native.theLocalAdapterIsAtOrAboveTheVerifiedVersion": "本地适配器版本等于或高于已核验版本。", + "native.theLocalCodexAccountChangedReconnectToRefreshQuota": "本机 Codex 账号已变更,请重新连接以刷新额度。", + "native.theMavenProjectCouldNotBeReloadedThePreviousModelIsStillAvailable": "无法重新加载 Maven 项目,仍可使用之前的模型。", + "native.theModelIsTooLargeSomeEntriesAreNotShown": "模型过大,部分条目未显示。", + "native.theMonitoredJVMHasExitedOrChanged": "监控的 JVM 已退出或发生变化。", + "native.thePatchChangesTheBaseCommitSTreeIntoTheTargetCommitSTree": "补丁将基准提交的文件内容变为目标提交的文件内容。", + "native.thePluginSPageAsksToRunThemIn": "插件页面请求在 {arg1} 中运行它们。", + "native.thePluginWantsToUseYourCamera": "插件“{arg1}”想使用你的摄像头。", + "native.thePluginWantsToUseYourCameraAndMicrophone": "插件“{arg1}”想使用你的摄像头和麦克风。", + "native.thePluginWantsToUseYourMicrophone": "插件“{arg1}”想使用你的麦克风。", + "native.thePluginWillBeRemovedAfterRestartingLithe": "插件将在重启 Lithe 后移除。", + "native.thePreviousAgentIsStillStoppingTryAgainShortly": "上一个 Agent 仍在停止,请稍后重试。", + "native.thePreviousVersionIsUsedAfterRestartingLitheYourSettingValuesAreKept": "重启 Lithe 后使用上一版本,你修改过的设置值会保留。", + "native.thePrimaryWorktreeCannotBeLocked": "主工作区不能锁定。", + "native.thePrimaryWorktreeCannotBeRemoved": "主工作区不能删除。", + "native.theProjectModelIsNotLoaded": "项目模型尚未加载。", + "native.thePublishedUpdateManifestIsInvalidAndCannotBeTrustedOpenTheReleasePageAndDowna6815c0bf9": "已发布的更新清单无效,无法信任。请打开 Release 页面手动下载更新。", + "native.thePullRequestWillReturnToTheOpenListAndCanReceiveNewReviews": "该拉取请求将回到打开列表,并可继续接收审查。", + "native.theRebaseWasAbortedInspectTheBranchAndWorkingFilesBeforeContinuingYourWork": "变基已中止。继续工作前请检查分支和工作文件。", + "native.theReportedFileChangesHaveBeenUpdatedReviewThemAgainBeforeRollingBack": "Agent 上报的文件修改已更新,请重新确认后再回撤。", + "native.theRepositoryChangedOrAnotherGitOperationIsRunningPreviewThePatchAgain": "仓库已更改或有其他 Git 操作正在运行。请重新预览补丁。", + "native.theRepositoryChangedOrAnotherGitOperationIsRunningRefreshBeforeRetrying": "仓库已更改或有其他 Git 操作正在运行。请刷新后重试。", + "native.theRepositoryChangedOrAnotherGitOperationIsRunningRefreshThePreviewToRetry": "仓库已更改或有其他 Git 操作正在运行。请刷新预览后重试。", + "native.theRepositoryWillBeOpenedInDetachedHEADState": "仓库将以 detached HEAD 状态打开。", + "native.theResponseStoppedAtTheModelSTokenLimit": "回复已达到模型的 token 上限,已停止。", + "native.theSelectedAIProviderHasAnInvalidAPIURLOrModel": "所选 AI 服务商的 API 地址或模型无效。", + "native.theSelectedAIProviderHasNoAPIKey": "所选 AI 服务商没有 API 密钥。", + "native.theSelectedBackgroundImageIsUnavailableAndWasRemoved": "所选背景图片不可用,已移除。", + "native.theSelectedBranchDiffIsSentToTheActiveAIProviderWhenYouGenerate": "生成时,所选分支的差异内容会发送给当前 AI 服务商。", + "native.theSelectedBranchesHaveNoTextualChangesToSummarize": "所选分支之间没有可供总结的文本更改。", + "native.theSelectedFileChangedBeforeGenerationFinished": "生成完成前,选中的文件已发生变化", + "native.theSelectedFileHasNoTextualDiffToSummarize": "所选文件没有可用于总结的文本 Diff。", + "native.theSelectedTableHasNoColumnsToDisplay": "所选表没有可显示的列。", + "native.theSelectedVersionsMatch": "所选版本相同", + "native.theStableInstallerStoppedYourCurrentAppHasBeenPreservedTryAgainOrDownloadTheStd70ce1979b": "正式版安装程序已停止,当前应用已保留。请重试或手动下载安装正式版。", + "native.theStableProfileCouldNotBeCopiedSafelyItsOriginalDataHasBeenPreserved": "无法安全复制正式版配置,原有数据已保留。", + "native.theStableReleaseIsMissingAValidPublisherSignatureNothingWasInstalledTryANewerRef80e417a6": "正式版缺少有效的发布者签名,未执行安装。请等待新版本,或从可信来源手动安装。", + "native.theStagedChangesHaveNoTextualDiffToSummarize": "已暂存的更改没有可用于总结的文本 Diff。", + "native.theStagedChangesWillBeCommittedAndTheCurrentBranchWillBePushedToItsConfiguredRemote": "暂存的更改将被提交,并推送当前分支到已配置的远程仓库。", + "native.theUpdateCouldNotBeInstalledDownloadTheReleaseManuallyAndTryAgain": "无法安装更新,请手动下载版本后重试。", + "native.theUpdatePackageCouldNotBeDownloadedCheckYourInternetConnectionAndTryAgain": "无法下载更新包,请检查网络连接后重试。", + "native.theUpdatePackageCouldNotBeDownloadedCheckYourInternetProxyOrVPNConnectionAndTryAgain": "无法下载更新包。请检查网络、代理或 VPN 连接后重试。", + "native.theUpdatePackageHasNoChecksumAndCannotBeVerified": "更新包没有校验信息,无法验证。", + "native.theUpdateRequestTimedOutCheckYourProxyOrVPNConnectionAndTryAgain": "更新请求超时。请检查代理或 VPN 连接后重试。", + "native.theUpdateServerReturnedAnUnexpectedResponseOpenTheReleasePageAndDownloadTheUpdateManually": "更新服务器返回了异常响应。请打开 Release 页面手动下载更新。", + "native.theUpdateServerReturnedHTTPOpenTheReleasePageToDownloadTheUpdateManuallyOrTryAgainLater": "更新服务器返回 HTTP {arg1}。请打开 Release 页面手动下载,或稍后重试。", + "native.theValueGitUsesNow": "Git 当前使用的值", + "native.theValueGitUsesNowAndWhereItComesFrom": "Git 当前使用的值及其来源", + "native.theVerificationPageIsOpenAndTheCodeIsAlreadyOnYourClipboard": "验证页面已打开,一次性代码也已复制到剪贴板。", + "native.theWorkingTreeCouldNotBeRefreshedKeepYourLocalFilesAndInspectGitStatusBeforeContinuing": "工作树刷新失败。请保留本地文件,检查 Git 状态后再继续。", + "native.theWorktreeWillPointDirectlyToACommitCreateABranchThereBeforeKeepingNewCommits": "工作树将直接指向一个提交。若要保留后续新提交,请先在该工作树中创建分支。", + "native.theme": "主题", + "native.theme.variant2": "主题…", + "native.thereIsNoPublishedGitHubReleaseToCheckYet": "目前还没有可检查的 GitHub Release。", + "native.theseAreTheOptionsLitheWillUseForTheNextOrdinaryFetch": "这是 Lithe 下次普通 Fetch 使用的选项。", + "native.theseChoicesApplyToThisFetchOnlyGitConfigurationAndCredentialsRemainUnchanged": "这些选项仅用于本次获取。Git 配置和凭据保持不变。", + "native.theseDefaultsApplyOnlyToThisProjectOnThisComputerRunConfigurationsInheritThemUcc45c36f49": "这些默认设置仅作用于这台电脑上的此项目。运行配置默认继承,单独设置时可覆盖。需要与团队共享的设置,请在运行配置中选择“项目”保存范围。", + "native.theseOverridesAffectOnlyThisRunConfigurationClearAPathToInheritTheProjectEnvironment": "以下覆盖值仅影响当前运行配置。清空路径可恢复继承项目环境。", + "native.theseProcessesLeftTheirTerminalOrToolToKeepRunningInTheBackgroundLikeDaemonsAn39708c186a": "这些进程已脱离所在的终端或工具,在后台独立运行,例如守护进程和开发服务器。勾选的项目会在 Lithe 退出时结束;未勾选的项目继续运行。", + "native.theseProgramsWereStartedInLitheBeforeItClosedUnexpectedlyCheckedItemsAreEndedN3cfc802d12": "这些程序是 Lithe 意外关闭前在 Lithe 中启动的。勾选的项目会立即结束;未勾选的项目继续运行,Lithe 之后不再管理它们。", + "native.thinking": "思考", + "native.thinking.variant2": "思考中…", + "native.thinkingLevel": "思考强度", + "native.thinkingProcess": "思考过程", + "native.thisActionCannotBeUndoneByLithe": "此操作无法在 Lithe 中撤销。", + "native.thisAddressCannotBeOpenedHere": "无法在此处打开该地址。", + "native.thisAgent": "该 Agent", + "native.thisAgentCannotReopenEarlierConversationsStartANewConversation": "该 Agent 无法重新打开更早的对话,请新建对话。", + "native.thisAgentHasNoGlobalMCPConfigurationFile": "该 Agent 没有全局 MCP 配置文件。", + "native.thisAgentIsNoLongerConfiguredOpenAgentSettingsToConfigureIt": "此 Agent 已取消配置,请打开 Agent 设置重新配置。", + "native.thisAgentSProviderHasNoAPIKeyUpdateItsLocalConfigurationOrEditTheCustomProvidebf9a7aac24": "此 Agent 的供应商未配置 API 密钥,请更新本机配置或在设置窗口的 Agent 页面中编辑自定义供应商。", + "native.thisBranchHasNoCommitsYet": "当前分支尚无提交", + "native.thisBuildSystemAnswersThroughAProviderProcessWhichLitheForMacOSDoesNotRunYet": "此构建系统通过 provider 进程响应,macOS 版 Lithe 暂不支持运行 provider 进程。", + "native.thisComputer": "本机", + "native.thisComputerThisProject": "本机 · 此项目", + "native.thisConversationIsNoLongerAvailableChooseAnotherConversation": "此会话已不可用,请选择其他会话。", + "native.thisDiffHasTooManyLinesForComparisonShowingTheReportedBeforeAndAfterText": "差异行数过多,显示 Agent 上报的修改前后文本。", + "native.thisDocumentIsReadOnly": "此文档为只读", + "native.thisDoesNotDeleteTheBranchOrCommitsThePullRequestCanBeReopenedLater": "这不会删除分支或提交,稍后仍可重新打开该拉取请求。", + "native.thisEntryBelongsToTheOpenProjectAndIsManagedByLitheIDEAccess": "该条目属于当前打开的项目,由“Lithe IDE 访问”管理。", + "native.thisFileCannotBeDisplayedAsText": "此文件无法以文本形式显示", + "native.thisFileChangedOutsideLitheWhileYouHadUnsavedEdits": "你有未保存的编辑时,该文件在 Lithe 外部发生了变化。", + "native.thisFileHasUnsavedEditorChangesSaveOrDiscardThemBeforeRollingBackTheAgentEdit": "此文件有未保存的编辑,请先保存或放弃后再回撤 Agent 修改。", + "native.thisFileIsOutsideTheCurrentProject": "此文件不在当前项目中。", + "native.thisFileWasDeletedOutsideLitheYourEditorContentIsPreserved": "该文件已被外部删除,编辑器内容已保留。", + "native.thisFolderAlreadyBelongsToAGitRepository": "此目录已属于一个 Git 仓库。", + "native.thisMac": "本机", + "native.thisMacOnly": "仅本机", + "native.thisPage": "此页面", + "native.thisPermanentlyDeletesUncommittedAndUntrackedFilesInTheBranchItselfIsKept": "这会永久删除“{arg1}”中的未提交文件和未跟踪文件,但会保留对应分支。", + "native.thisPluginAddsNoOptionsToThisPage": "该插件未在此页添加选项。", + "native.thisPluginDeclaresNoFeatures": "该插件没有声明功能。", + "native.thisPluginDeclaresNoSettings": "该插件没有声明设置。", + "native.thisPluginIsMaintainedAndSignedByLitheReportProblemsInTheSourceRepository": "该插件由 Lithe 维护并签名,问题请在源码仓库反馈。", + "native.thisPluginIsMaintainedByACommunityDeveloperNotByLitheLitheVerifiedThatTheDownl1989920b43": "插件由社区开发者维护,并非 Lithe 官方出品。Lithe 已校验下载内容与插件仓库一致,并会在安装前列出插件能运行的程序。插件问题请到插件自己的仓库反馈。", + "native.thisPluginRequiresANewerVersionOfLithe": "该插件需要更新版本的 Lithe。", + "native.thisPreviewContainsChangesThatHaveNotBeenOfficiallyReleasedAndMayBeUnstable": "此预览版包含尚未正式发布的改动,可能不稳定。", + "native.thisProject": "此项目", + "native.thisProjectIsNotAGitRepository": "当前项目不是 Git 仓库", + "native.thisRebaseSessionIsNoLongerActiveInTheCurrentRepository": "此变基会话在当前仓库中已失效。", + "native.thisRecoveryResultIsNoLongerActiveInTheCurrentRepository": "此恢复结果在当前仓库中已失效。", + "native.thisRemovesGitRegistrationsWhoseCheckoutDirectoriesNoLongerExistItDoesNotDeleteBranches": "这会移除检出目录已不存在的 Git 注册记录,但不会删除分支。", + "native.thisRemovesTheSavedPatchFromLitheAndCannotBeUndone": "这会从 Lithe 中删除已保存的补丁,且无法撤销。", + "native.thisRemovesTheStashFromGitAndCannotBeUndone": "这会从 Git 中删除 Stash,且无法撤销。", + "native.thisRemovesTheTagFromTheRepositoryAndAffectsCollaboratorsWhoReferenceItYouCanR8fb0a4ab8b": "这会从仓库中删除标签,并影响引用该标签的协作者。删除后可以通过提示栏恢复。", + "native.thisRepository": "当前仓库", + "native.thisResponseIsSentOnlyToTheRunningGitOperation": "此回复仅发送给正在运行的 Git 操作。", + "native.thisSendsTheCurrentBranchToItsConfiguredRemote": "这会将当前分支推送到已配置的远程仓库。", + "native.thisSubprojectCanUseADifferentJDKFromOtherBackendsInTheSameWorkspace": "此子项目可以使用与同一工作区中其他后端不同的 JDK。", + "native.thisSubprojectDoesNotUseAJDK": "此子项目不使用 JDK。", + "native.thisTurn": "本轮", + "native.thisUpdatesOnGitHubAndCannotBeUndoneFromLithe": " 这会更新 GitHub 上的 {arg1},且无法在 Lithe 中撤销。", + "native.thisValueIsNotAllowed": "该值不被允许。", + "native.thisVersionOfLitheCannotReadUpdateManifestSchemaOpenTheReleasePageAndUpdateManually": "此版本的 Lithe 无法读取版本为 {arg1} 的更新清单格式。请打开 Release 页面手动更新。", + "native.thisWillStartLldDetectedServices": "这将启动检测到的 {arg1} 个服务。", + "native.thisWindow": "此窗口", + "native.thisWindow.variant2": "此窗口", + "native.thisWorktreeHasADetachedHEADPublishItAsABranchBeforeCreatingAPullRequest": "当前工作树处于分离 HEAD 状态。请先将它发布为分支,再创建拉取请求。", + "native.thisWorktreeHasNoUncommittedChanges": "此工作树没有未提交的更改。", + "native.threads": "线程", + "native.timeToExit": "等待退出时间", + "native.timedOut": "{arg1} 超时。", + "native.timedOut.variant2": "已超时", + "native.title": "标题", + "native.toDebugYourCodeDoOneOfTheFollowing": "要调试代码,请执行以下操作之一:", + "native.toTrackChangesToCode": "要跟踪代码更改:", + "native.today": "今天", + "native.toggle": "切换 {arg1}", + "native.toggleBreakpointAtCaret": "在光标处切换断点", + "native.toggleDebug": "切换调试窗口", + "native.toggleFavorite": "切换收藏", + "native.toggleGitLog": "切换 Git 日志", + "native.toggleLineBreakpoint": "切换行断点", + "native.toggleMaven": "切换 Maven", + "native.togglePreview": "切换预览", + "native.toggleProblems": "切换问题窗口", + "native.toggleRun": "切换运行窗口", + "native.toggleTerminal": "切换终端", + "native.toggleTests": "切换测试窗口", + "native.tok": "{arg1} tok", + "native.tokS": "{arg1} tok/s", + "native.tokenCountsReportedByTheAgentAccountingScopeDependsOnTheAgent": "Agent 上报的 token 数;统计口径由 Agent 决定。", + "native.tokenUsage": "Token 用量", + "native.tomlSupport": "TOML 支持", + "native.tool": "工具", + "native.toolActivityLld": "工具操作({arg1})", + "native.toolAndPermissionTime": "工具与授权用时", + "native.toolCall": "工具调用", + "native.toolCalls": "工具调用", + "native.toolDefinitions": "工具定义", + "native.toolWindow": "工具窗口", + "native.toolWindows": "工具窗口", + "native.toolbarActions": "工具栏操作", + "native.tools": "工具", + "native.toolsSent": "发送的工具", + "native.total": "总计", + "native.totalTokens": "总 token:{arg1}", + "native.trackingBranch": "跟踪的分支", + "native.transport": "传输方式", + "native.tryABranchNameOrCheckoutPath": "请尝试输入分支名称或检出路径。", + "native.tryADifferentSearchTerm": "请尝试其他搜索词。", + "native.tryAgain": "重试", + "native.tryAnotherTitleAuthorNumberOrLabel": "请尝试其他标题、作者、编号或标签。", + "native.tryAnotherTitleOrSessionID": "试试其他标题或会话 ID。", + "native.ttft": "首字 {arg1}", + "native.turnOnMCPToGenerateAConfigurationForAgents": "开启 MCP 后会生成供 Agent 使用的配置。", + "native.turnOnModelCallStatisticsInAgentSettingsAndReconnectToSeeModelCallsTimeToFirstc4f5efa5f9": "在 Agent 设置中开启“模型调用统计”并重新连接后,可查看模型调用次数、首 token 延迟和输出速度。", + "native.turnOnModelCallStatisticsInAgentSettingsAndReconnectToSeeTheMCPToolsSentWithEachRequest": "在 Agent 设置中开启模型调用统计并重新连接后,可查看每次请求携带的 MCP 工具。", + "native.turnOnModelCallStatisticsInAgentSettingsAndReconnectToSeeTheSystemPromptToolDeffd4968e05": "在 Agent 设置中开启“模型调用统计”并重新连接后,可查看系统提示词、工具定义和对话消息的占比。", + "native.turnOnModelCallStatisticsReconnectAndSendAMessageToSeeTheBreakdown": "开启“模型调用统计”,重新连接并发送消息后可查看分项。", + "native.turnUsage": "本轮用量", + "native.turningThisOffStopsEveryAgentProcess": "关闭后会停止所有 Agent 进程。", + "native.turns": "轮次", + "native.type": "类型", + "native.typeToSeeCommands": "输入 / 查看命令", + "native.typeToSeeOptions": "输入 / 查看选项", + "native.typeYourTaskForCommandsOrForFiles": "输入任务,用 / 选择命令,用 @ 添加文件", + "native.typeYourTaskOrForCommands": "输入任务,或用 / 选择命令", + "native.unableToCreateTheMavenDependencyPlan": "无法创建 Maven 依赖解析计划。", + "native.unableToParseMavenDependenciesForThisModule": "无法解析此模块的 Maven 依赖。", + "native.unableToResolveMavenDependencies": "无法解析 Maven 依赖。", + "native.unableToSignInPleaseTryAgain": "登录失败,请重试。", + "native.unableToStartMavenDependencyResolution": "无法启动 Maven 依赖解析:{arg1}", + "native.unarchiveAndOpen": "取消归档并打开", + "native.unarchiveThisConversation": "取消归档此会话?", + "native.unavailable": "暂不可用", + "native.uncachedInput": "未缓存输入", + "native.unchanged": "未更改", + "native.unchecked": "未检测", + "native.uncommittedChangesBlockThisCherryPick": "未提交的更改阻止了本次拣选", + "native.uncommittedChangesBlockThisMerge": "未提交的更改阻止了本次合并", + "native.uncommittedChangesBlockThisRebase": "未提交的更改阻止了本次变基", + "native.uncommittedChangesBlockThisRevert": "未提交的更改阻止了本次回滚", + "native.uncommittedChangesStayInThisWorktreeAndAreNotIncludedInThePullRequest": "未提交的更改会保留在当前工作树中,不会包含在拉取请求里。", + "native.uncommittedSubmoduleChanges": "子模块有未提交的修改", + "native.undo": "撤销", + "native.undoCommit": "撤销最近一次提交(保留更改)", + "native.undoCommit.variant2": "撤销最近一次提交(保留更改)…", + "native.unfiledCONNECTIONS": "未归档连接", + "native.uninstall": "卸载 {arg1}?", + "native.uninstallFromTheInstalledTabAndRestartLitheFirstItIsTheRetiredOfficialVersionO07265e4f97": "请先在“已安装”中卸载 {arg1} 并重启 Lithe。它是此插件已退役的官方版本,支持同一种语言。", + "native.uninstallPHPSupport": "卸载 PHP 支持?", + "native.unknownError": "未知错误", + "native.unknownSource": "未知来源", + "native.unlink": "取消关联", + "native.unlockTheWorktreeBeforeRemovingIt": "请先解锁工作树再删除。", + "native.unlockWorktree": "解锁工作树", + "native.unlocked": "未锁定", + "native.unlocked.variant2": "已解锁 {arg1}", + "native.unmuteCamera": "恢复摄像头", + "native.unmuteLineBreakpoints": "恢复行断点", + "native.unmuteMicrophone": "取消静音麦克风", + "native.unnamedProvider": "未命名服务商", + "native.unnamedService": "未命名服务", + "native.unpinConfiguration": "取消置顶运行配置", + "native.unrestrictedAccessToTheInternetAndAnyFileOnYourComputer": "允许访问网络和电脑上的所有文件。", + "native.unsignedDevelopmentPackageReleaseBuildsOfLitheWillNotLoadIt": "未签名的开发包,正式版 Lithe 不会加载。", + "native.unstage": "取消暂存", + "native.unstageAllFiles": "取消暂存所有文件", + "native.unstageAllFilesInRepository": "取消暂存仓库的所有文件", + "native.unstageFile": "取消暂存文件", + "native.unstageThisChangeBlock": "取消暂存此更改代码块", + "native.unstaged": "未暂存", + "native.unstagedChanges": "未暂存更改", + "native.unsupportedContent": "不支持的内容", + "native.untilUTCISO8601": "结束时间(UTC ISO 8601)", + "native.untitledConversation": "未命名对话", + "native.untracked": "未跟踪", + "native.unusedCode": "未使用的代码", + "native.unversionedFiles": "未版本控制的文件", + "native.upToDate": "已是最新版本。", + "native.update": "更新", + "native.update.variant2": "更新 {arg1}", + "native.updateAfter": "在 {arg1}/{arg2} 更新引用,等待 {arg3} 完成", + "native.updateAvailable": "有可用更新", + "native.updateAvailable.variant2": "有可用更新", + "native.updateChannel": "更新渠道", + "native.updateCommunityPlugin": "更新社区插件“{arg1}”?", + "native.updateCurrentBranch": "更新当前分支", + "native.updateLitheToUseThisConfigurationVersion": "请更新 Lithe 以使用此版本的配置。", + "native.updateLog": "更新日志", + "native.updateNow": "立即更新", + "native.updateParentRepositoryReferences": "更新父仓库引用", + "native.updateProject": "更新项目", + "native.updateProject.variant2": "更新项目…", + "native.updateRequired": "需要更新", + "native.updateStatus": "更新状态", + "native.updateThisCLIUsingItsOriginalInstallerThenCheckAgain": "请使用原安装器更新此 CLI,然后重新检测。", + "native.updateTo": "可更新至", + "native.updateTo.variant2": "更新到 {arg1}", + "native.updateTo.variant3": "可更新至:{arg1}", + "native.updated": "更新时间:{arg1}", + "native.updatedReferences": "已更新引用:{arg1}", + "native.updatedWithLithe": "随 Lithe 一起更新", + "native.updates": "更新", + "native.updatingCLI": "正在更新 CLI…", + "native.updatingLabelsAndAssignees": "正在更新标签和负责人…", + "native.updatingLldIncomingLldLocal": "正在更新 {arg1}({arg2} 个传入提交,{arg3} 个本地提交)", + "native.updatingPullRequest": "正在更新拉取请求…", + "native.upgradeLitheToUseThisRunConfigurationVersion": "请升级 Lithe 后再使用此版本的运行配置。", + "native.upperBoundOfTheJavaLanguageServerSHeapRaiseItForLargeMultiModuleProjects": "Java 语言服务器的堆内存上限。大型多模块项目可适当调高。", + "native.upstreamBranch": "上游分支", + "native.upstreamCommitS": "个上游提交", + "native.url": "URL", + "native.usages": "调用位置", + "native.useAIWorktreeDirectoryPrivateTmp": "使用 AI 工作树目录(/private/tmp)", + "native.useAModelIDSupportedByYourProvider": "填写该服务商支持的模型 ID。", + "native.useAPersistentFolderForOngoingWorkTemporaryCheckoutsMayBeRemovedByTheSystem": "长期工作请使用持久目录,临时检出目录可能被系统清理。", + "native.useAThirdPartyProvider": "使用第三方服务", + "native.useAValidFileOrDirectoryName": "请输入有效的文件或目录名称", + "native.useAnAgentInYourProject": "在项目中使用 Agent", + "native.useAutoBuild": "使用 AutoBuild", + "native.useAutoBuildRolling": "使用 AutoBuild 滚动版?", + "native.useConfiguration": "使用 {arg1} 配置", + "native.useCredentialHelper": "使用凭据助手", + "native.useDefaultMessages": "使用默认消息", + "native.useDetectedJDK": "使用检测到的 JDK", + "native.useFastProcessing": "使用快速处理模式", + "native.useForm": "使用表单", + "native.useFullHTTPPathForCredentials": "凭据使用完整 HTTP 路径", + "native.useGitConfiguration": "使用 Git 配置", + "native.useGitFromPATH": "使用 PATH 中的 Git", + "native.useGitSetting": "使用 Git 配置", + "native.useLettersDigitsDotsDashesOrUnderscoresTheNameMustStartWithALetterOrDigit": "只能使用字母、数字、点、短横线或下划线,且必须以字母或数字开头。", + "native.useLocalConfigurationOrAddYourOwnProvider": "使用本机配置或添加自定义供应商。", + "native.useMvnwOrDetectedMaven": "使用 mvnw 或检测到的 Maven", + "native.useProjectDefault": "使用项目默认值", + "native.useProjectJDK": "使用项目 JDK", + "native.useProjectJDK.variant2": "使用项目 JDK", + "native.useProjectJDKOrSystemDefault": "使用项目 JDK 或系统默认值", + "native.useProjectOrFileDirectory": "使用项目或文件目录", + "native.useServiceJDK": "使用服务 JDK", + "native.useSoftWraps": "启用自动换行", + "native.useTemporaryDirectory": "使用临时目录", + "native.useTheDefaultValue": "使用默认值", + "native.useTheFilterMenuToShowOtherSeverities": "使用筛选菜单查看其他级别。", + "native.useTheStandardResponseSpeed": "使用标准响应速度", + "native.useThisWhenARepositoryOrGitDefaultNeedsAOneOffAdjustment": "仅在需要临时调整仓库或 Git 默认值时使用。", + "native.useYourLocalChatGPTAccountNoAPIKeyOrURLIsNeeded": "使用本机已登录的 ChatGPT 账号,无需 API Key 或 URL。", + "native.used": "{arg1} · 已用 {arg2}", + "native.used.variant2": "已使用 {arg1}", + "native.usedBy": "使用者:{arg1}", + "native.usedForNewTerminalSessions": "用于新建的终端会话。", + "native.usedForOrdinaryFetchInEveryProjectOneTimeChoicesCanOverrideTheseDefaults": "用于所有项目中的普通 Fetch;单次操作可以覆盖这些默认值。", + "native.usedLldTool": "调用了 {arg1} 个工具", + "native.usedLldTools": "调用了 {arg1} 个工具", + "native.user": "用户", + "native.userFacingSentenceForReleaseNotes": "适合发布说明的面向用户句子", + "native.valid": "有效", + "native.validValuesAreSavedAsYouType": "值合法时立即保存。", + "native.validatingTheCredentialStoredInKeychain": "正在验证钥匙串中保存的凭据…", + "native.value": "值", + "native.value05Seconds": "0.5 秒", + "native.value15Seconds": "1.5 秒", + "native.value1File": "1 个文件", + "native.value1MoreNotification": "还有 1 条通知", + "native.value2Spaces": "2 个空格", + "native.value3Seconds": "3 秒", + "native.value4Spaces": "4 个空格", + "native.value5Hours": "5 小时", + "native.value7Days": "7 天", + "native.value8Spaces": "8 个空格", + "native.valuesAreMaskedHereButStoredAsPlainTextInTheAgentSConfigurationFile": "此处仅做遮蔽显示,值会以明文保存在 Agent 配置文件中。", + "native.verification": "验证", + "native.verified": "已验证", + "native.verifyingStableRelease": "正在校验正式版…", + "native.version": "版本", + "native.versionControl": "版本控制", + "native.versionControlIntegration": "版本控制集成", + "native.versionIsAvailable": "版本 {arg1} 可用。", + "native.versionIsBuiltInOrInstalledFromDisk": "版本 {arg1} 为内置或从磁盘安装。", + "native.versionIsInstalled": "已安装版本 {arg1}。", + "native.versionIsInstalledRestartLitheToLoadIt": "已安装版本 {arg1},重启 Lithe 后生效。", + "native.versionManagedFrom": "依赖管理前版本为 {arg1}", + "native.versionStatus": "版本状态", + "native.versionsSource": "版本与来源", + "native.view": "查看", + "native.viewBreakpoints": "查看断点", + "native.viewBreakpoints.variant2": "查看断点", + "native.viewBreakpointsF8": "查看断点(⌘⇧F8)", + "native.viewOptions": "视图选项", + "native.viewSavedChanges": "查看已保存的更改", + "native.vmArguments": "VM 参数", + "native.vmOptions": "VM 选项", + "native.vueSupport": "Vue 支持", + "native.waitForTheAgentConfigurationToFinishUpdating": "请等待 Agent 配置更新完成。", + "native.waitForTheAgentFileRollbackToFinish": "请等待 Agent 文件回撤完成。", + "native.waitForTheAgentOperationToFinishBeforeChangingProviders": "请等待 Agent 当前操作完成后再切换供应商。", + "native.waitForTheCurrentGitOperationToFinish": "请等待当前 Git 操作完成。", + "native.waitForTheProjectToFinishLoadingThenIdentifyItAgain": "请等待项目加载完成,然后重新识别。", + "native.waitingForALocalTestJVM": "正在等待本地测试 JVM…", + "native.waitingForAgentResponse": "等待 Agent 响应…", + "native.waitingForChatGPTSignIn": "正在等待 ChatGPT 登录…", + "native.waitingForDebuggerOutput": "等待调试器输出…", + "native.waitingForDownloadDataCheckYourNetworkOrProxyIfThisContinues": "正在等待下载数据。如果持续无进展,请检查网络或代理。", + "native.waitingForGitHub": "正在等待 GitHub 授权…", + "native.waitingForJVMPerformanceData": "正在等待 JVM 性能数据…", + "native.waitingForPermission": "等待授权…", + "native.waitingForProcessPerformanceData": "正在等待进程性能数据…", + "native.waitingForStackData": "等待调用栈数据…", + "native.waitingForSubmodule": "等待子模块完成", + "native.waitingForSubscriptionQuota": "正在等待订阅额度…", + "native.waitingForTheJavaLanguageService": "正在等待 Java 语言服务", + "native.waitingForThreadData": "等待线程数据…", + "native.waitingToQuitToCompleteTheUpdate": "等待退出应用以完成更新。", + "native.wantsToUseYourCamera": "“{arg1}”想使用你的摄像头。", + "native.wantsToUseYourCameraAndMicrophone": "“{arg1}”想使用你的摄像头和麦克风。", + "native.wantsToUseYourMicrophone": "“{arg1}”想使用你的麦克风。", + "native.warning": "警告", + "native.warnings": "警告", + "native.wasCreatedOutsideLitheTakeOwnershipBeforeChangingIt": "“{arg1}”是在 Lithe 之外创建的。请先接管所有权再修改。", + "native.wasNotFoundInstallItForExampleThenCheckAgain": "未找到 {arg1}。请先安装(例如 `{arg2}`),然后重新检测。", + "native.welcomeToLithe": "欢迎使用 Lithe", + "native.welcomeToLitheAgent": "欢迎使用 Lithe Agent", + "native.whatDoesThisPullRequestChange": "这个拉取请求更改了什么?", + "native.whatFetchRemovesOrDownloadsBeforeYouStartWorking": "开始工作前,Fetch 会清理或下载什么。", + "native.whatFetchWillDo": "Fetch 会做什么", + "native.whenQuittingLithe": "退出 Lithe 时", + "native.wherePushGoesWhenYouDoNotChooseATargetExplicitly": "未明确选择目标时,Push 会推送到哪里。", + "native.whereShouldThisApply": "应用范围", + "native.whereTheRowsBelowThatAreBackedByGitConfigurationAreWritten": "下方由 Git 配置支持的选项写入的位置。", + "native.whereWouldYouLikeToOpenTheProject": "你想在哪里打开项目“{arg1}”?", + "native.whitespaceComparison": "空白字符比较", + "native.wholeWords": "全字匹配", + "native.window": "窗口", + "native.wip": "进行中", + "native.workbenchActions": "工作台操作", + "native.workbenchBackground": "工作台背景", + "native.workbenchBackgroundOpacity": "工作台不透明度", + "native.working": "处理中…", + "native.workingDirectory": "工作目录", + "native.workingTREE": "工作区", + "native.workingTree": "{arg1} ↔ 工作区", + "native.workingTree.variant2": "工作区", + "native.workingTree.variant3": "工作树", + "native.workingTreeIsClean": "工作区干净", + "native.workingTreeMatchesThisReference": "工作区与此引用一致", + "native.workingWithNpm": "npm 正在处理…", + "native.workspace": "工作区", + "native.worktree": "工作树", + "native.worktreeAction": "工作树操作", + "native.worktreeActionUnavailable": "无法执行工作树操作", + "native.worktreeChanges": "工作树更改", + "native.worktreeCommitHistory": "工作树提交历史", + "native.worktreeDestination": "工作树目标路径", + "native.worktreeSettings": "工作树设置", + "native.worktrees": "工作树", + "native.worktreesAreUnavailable": "无法加载工作树", + "native.wrapIntoRows": "多行展开", + "native.writeAClearActionableComment": "写下清晰、可执行的评论…", + "native.writeToGlobalConfig": "写入全局配置", + "native.writeToThisRepository": "写入当前仓库", + "native.xhigh": "超高", + "native.xmlSupport": "XML 支持", + "native.yamlSupport": "YAML 支持", + "native.yesterday": "昨天", + "native.you": "你", + "native.youCanMuteOrStopTheDeviceFromTheAddressBarWhileThePageIsUsingIt": "页面使用设备期间,你可以在地址栏中将其暂停或停止。", + "native.youHaveUncommittedChangesRebaseWillRefuseToStartUntilTheyAreCommittedOrStashed": "你有未提交的更改。变基会拒绝执行,除非先提交或贮藏这些更改。", + "native.yourBranchAndEachHaveCommitsTheOtherDoesNotSoTheChangesCannotBeFastForwarded": "你的分支与“{arg1}”各自拥有对方没有的提交,因此无法快进合并。", + "native.yourChangesToTheseFilesConflictWith": "你对这些文件的更改与“{arg1}”冲突:", + "native.yourChangesToTheseFilesWouldBeOverwrittenBy": "你对下列文件的更改将被“{arg1}”覆盖:", + "native.yourLocalChangesAreSafeInTheIsIncompleteResolveTheConflictsThenDropThisStashManually": "本地更改已安全保存在 {arg1} 中。{arg2}尚未完成。请解决冲突,然后手动删除这条暂存记录。", + "native.yourLocalConfigurationHasNoAPIKeyForThisAgentAddOneThenFetchTheConfigurationAgain": "本机配置中没有该 Agent 的 API 密钥。请先添加,然后重新获取配置。", + "native.yourLoginShellDidNotReportAPATHSoOnlyLitheSOwnPATHWasSearched": "登录 shell 没有返回 PATH,因此只搜索了 Lithe 自身的 PATH。", + "native.yourOriginalSettingsAndWorkspaceDataHaveBeenPreservedRestartLitheDevToTryAgain": "原有设置和工作区数据已保留。请重新启动 Lithe-Dev 重试。", + "native.zigSupport": "Zig 支持", + "native.zoomIn": "放大", + "native.zoomOut": "缩小", + "navigation.definition": "定义", + "navigation.enterValidLineNumber": "请输入有效的行号。", + "navigation.goToLine": "转到行", + "navigation.goToLinePrompt": "转到行", + "navigation.implementation": "实现", + "navigation.lineNumber": "行号", + "navigation.noOtherReferencesFound": "未找到其他引用。", + "navigation.noReferencesFound": "未找到引用。", + "navigation.noTargetFound": "未找到{target}。", + "navigation.reference": "引用", + "navigation.typeDefinition": "类型定义", + "newProject.backToStarters": "返回项目模板", + "newProject.chooseParentFolder": "选择父文件夹", + "newProject.chooseProjectLocation": "选择项目位置", + "newProject.cloningRepository": "正在克隆仓库", + "newProject.cloningRepositoryStatus": "正在克隆仓库", + "newProject.creatingProject": "正在创建项目", + "newProject.errorCreatedButCouldNotOpen": "项目已创建,但 Lithe 无法打开它。", + "newProject.errorCrossPlatformName": "请选择所有平台都支持的项目名称。", + "newProject.errorDestinationExists": "{path} 已存在文件或文件夹。", + "newProject.errorEnterName": "请输入项目名称。", + "newProject.errorPathTraversalName": "项目名称不能是路径穿越片段。", + "newProject.errorReservedCharacters": "项目名称不能包含路径分隔符或保留字符。", + "newProject.errorTrailingPeriodOrSpace": "项目名称不能以句点或空格结尾。", + "newProject.location": "位置", + "newProject.packageManager": "包管理器", + "newProject.packageManagerDescription": "安装会在集成终端中运行,方便你查看输出。", + "newProject.preparingProject": "正在准备项目", + "newProject.projectLocation": "项目位置", + "newProject.projectName": "项目名称", + "newProject.repositoryUrl": "仓库 URL", + "newProject.repositoryUrlDescription": "支持 HTTPS 和 SSH 仓库 URL。", + "newProject.starters": "模板", + "notebook.convertToCode": "转换为代码", + "notebook.convertToMarkdown": "转换为 Markdown", + "notebook.deleteCell": "删除单元格", + "notebook.editCell": "编辑单元格", + "notebook.insertCellBelow": "在下方插入单元格", + "notebook.moveCell": "移动单元格", + "notebook.pdfOutput": "PDF 输出", + "notebook.previewCell": "预览单元格", + "notebook.pythonCellOutput": "Python 单元格输出:{output}", + "notebook.pythonCellRan": "Python 单元格已运行。", + "notebook.pythonCellRunFailed": "运行 Python 单元格失败", + "notebook.pythonCellTimedOut": "Python 单元格超时。", + "notebook.pythonDisplayOutputs": "Python 单元格产生了 {count} 个显示输出。", + "notebook.rChunkCapturedErrorOutput": "R 代码块已完成,并捕获到错误输出。", + "notebook.rChunkOutput": "R 代码块输出:{output}", + "notebook.rChunkRan": "R 代码块已运行。", + "notebook.rChunkRunFailed": "运行 R 代码块失败", + "notebook.rChunkSkippedEvalFalse": "R 代码块已跳过,因为 eval=FALSE。", + "notebook.rChunkTimedOut": "R 代码块超时。", + "notebook.runCell": "运行单元格", + "notebook.runningCell": "单元格运行中", + "notifications.clearAll": "全部清除", + "notifications.copied": "已复制", + "notifications.copy": "复制", + "notifications.copyAll": "全部复制", + "notifications.copyDetails": "复制详情", + "notifications.copyMessage": "复制消息", + "notifications.copyNotification": "复制通知", + "notifications.delete": "删除", + "notifications.deleteNotification": "删除通知", + "notifications.details": "通知详情", + "notifications.empty": "暂无通知。", + "notifications.filter": "筛选通知", + "notifications.filterAll": "全部", + "notifications.filterErrors": "错误", + "notifications.filterInfo": "信息", + "notifications.filterSuccess": "成功", + "notifications.filterWarnings": "警告", + "notifications.noMatch": "没有匹配的通知。", + "notifications.occurrenceCount": "{message}({count})", + "notifications.search": "搜索通知", + "notifications.title": "通知", + "onboarding.enableVimMode": "启用 Vim 模式", + "onboarding.import": "导入", + "onboarding.importSettings": "从其他编辑器导入设置", + "onboarding.importSettingsDescription": "从 VS Code、Cursor、Windsurf、Zed 或 JetBrains 导入匹配的设置。", + "onboarding.keybindingPreset": "快捷键预设", + "onboarding.keybindingPreset.atom.description": "匹配常用 Atom 快捷键。", + "onboarding.keybindingPreset.atom.label": "Atom", + "onboarding.keybindingPreset.emacs.description": "匹配常用 Emacs 快捷键。", + "onboarding.keybindingPreset.emacs.label": "Emacs", + "onboarding.keybindingPreset.jetbrains.description": "匹配常用 JetBrains IDE 快捷键。", + "onboarding.keybindingPreset.jetbrains.label": "JetBrains", + "onboarding.keybindingPreset.none.description": "使用 Lithe 内置快捷键。", + "onboarding.keybindingPreset.none.label": "无", + "onboarding.keybindingPreset.sublime.description": "匹配常用 Sublime Text 快捷键。", + "onboarding.keybindingPreset.sublime.label": "Sublime Text", + "onboarding.keybindingPreset.vscode.description": "匹配常用 VS Code 快捷键。", + "onboarding.keybindingPreset.vscode.label": "VS Code", + "onboarding.keybindingPreset.xcode.description": "匹配常用 Xcode 快捷键。", + "onboarding.keybindingPreset.xcode.label": "Xcode", + "onboarding.keybindingPreset.zed.description": "匹配常用 Zed 快捷键。", + "onboarding.keybindingPreset.zed.label": "Zed", + "onboarding.loadingReleaseNotes": "正在加载发布说明", + "onboarding.openFoldersInNewWindow": "在新窗口中打开文件夹", + "onboarding.releaseNotesDescription": "最新变更、改进和修复。", + "onboarding.releasedDate": "发布于 {date}", + "onboarding.updatedFromVersion": "已从 {previousVersion} 更新到 {currentVersion}。", + "onboarding.versionInstalled": "已安装 Lithe {version}。", + "onboarding.welcomeDescription": "Lithe {version}。开始前先选择几个默认设置。", + "onboarding.welcomeTitle": "欢迎使用 Lithe", + "onboarding.whatsNewTitle": "Lithe {version} 新增功能", + "outline.collapse": "折叠", + "outline.collapseAll": "全部折叠", + "outline.commandPlaceholderWithFile": "大纲:{file}", + "outline.copyLocation": "复制位置", + "outline.copyName": "复制名称", + "outline.expand": "展开", + "outline.expandAll": "全部展开", + "outline.filter": "筛选大纲", + "outline.filter.functions": "函数", + "outline.filter.other": "其他", + "outline.filter.properties": "属性", + "outline.filter.types": "类型", + "outline.filter.variables": "变量", + "outline.goToSymbol": "转到符号", + "outline.loading": "正在加载大纲", + "outline.loadingEllipsis": "正在加载大纲...", + "outline.noActiveFile": "没有活动文件。", + "outline.noOutlineForActiveFile": "当前文件没有大纲。", + "outline.noSymbolsFound": "未找到符号。", + "outline.openFile": "打开文件", + "outline.search": "搜索大纲", + "outline.showAll": "显示全部", + "outline.title": "大纲", + "panes.resizePanes": "调整窗格大小", + "panes.searchResults": "搜索结果", + "panes.webViewerDisabled": "Web 查看器已禁用", + "panes.webViewerDisabledDescription": "在“设置 > 功能”中启用后,可以在嵌入式编辑器标签页中打开 URL。", + "plugins.title": "插件", + "preparation.building": "正在构建 Java 项目", + "preparation.configuring": "正在同步 Java 项目配置", + "preparation.explanation": "Java 工作区准备完成后才能启动。普通后台索引不阻塞运行,编译在运行前执行。", + "preparation.failed": "Java 项目准备失败,请查看详情", + "preparation.importing": "正在导入 Java 项目与依赖", + "preparation.logs": "查看日志", + "preparation.ready": "Java 项目模型已就绪", + "preparation.settings": "语言服务设置与重试", + "preparation.starting": "正在启动 Java 服务", + "preparation.stopped": "Java 项目准备已停止", + "project.clearScope": "搜索整个工作区", + "project.deleteSelected": "删除选中的 {count} 个项目?", + "project.dependencies": "依赖", + "project.dependenciesHint": "尚无已解析的依赖快照", + "project.localHistory": "显示项目本地历史", + "project.mark.excluded": "排除目录", + "project.mark.module": "模块", + "project.mark.package": "包", + "project.mark.plain": "取消标记", + "project.mark.resources": "资源根目录", + "project.mark.sources": "源码根目录", + "project.markAs": "将目录标记为", + "project.markFailed": "无法保存目录标记", + "project.replaceInFolder": "在文件夹中替换…", + "project.resolveDependencies": "解析依赖", + "project.revealActive": "定位当前文件", + "project.searchScope": "搜索范围:{path}", + "projectIcon.emptyDescription": "会查找 .ico、icon/logo/favicon .png 和 .svg 文件", + "projectIcon.emptyTitle": "此项目中未找到图标文件", + "projectIcon.remove": "移除图标", + "projectIcon.removeCustom": "移除自定义图标", + "projectIcon.scanning": "正在扫描图标", + "projectIcon.selectTitle": "选择项目图标", + "projectNameMenu.addFolderToWorkspace": "添加文件夹到工作区", + "projectNameMenu.collapseAllFolders": "折叠所有文件夹", + "projectNameMenu.openFolderInNewTab": "在新标签页中打开文件夹", + "projectOpen.attach": "附加", + "projectOpen.cancel": "取消", + "projectOpen.doNotAskAgain": "不再询问", + "projectOpen.newWindow": "新窗口", + "projectOpen.thisWindow": "此窗口", + "projectOpen.title": "打开项目", + "projectOpen.where": "你想在哪里打开项目“{project}”?", + "projectPicker.connected": "已连接", + "projectPicker.connecting": "正在连接...", + "projectPicker.connectionFailed": "连接失败", + "projectPicker.connectionFailedWithPeriod": "连接失败。", + "projectPicker.connectionSuccessful": "连接成功。", + "projectPicker.default": "默认", + "projectPicker.disconnected": "已断开", + "projectPicker.fillRequiredFields": "请填写所有必填字段", + "projectPicker.hostAndUsernameRequired": "需要主机和用户名才能测试。", + "projectPicker.installed": "已安装", + "projectPicker.linuxProjectPath": "Linux 项目路径", + "projectPicker.missing": "缺失", + "projectPicker.noProjectsMatch": "没有匹配“{query}”的项目。", + "projectPicker.open": "打开", + "projectPicker.openDistribution": "打开 {name}", + "projectPicker.openWslFailed": "打开 WSL 项目失败。", + "projectPicker.remote": "远程", + "projectPicker.remoteIntro": "通过 SSH 或 SFTP 连接到远程服务器。", + "projectPicker.remoteProjectName": "远程:{name}", + "projectPicker.removedMissingRecentProjects": "已从最近项目中移除 {count} 个缺失项目。", + "projectPicker.removedRecentProject": "已从最近项目中移除“{name}”。", + "projectPicker.saveConnection": "保存连接", + "projectPicker.saveConnectionFailed": "保存连接失败。请重试。", + "projectPicker.testConnection": "测试连接", + "projectPicker.testing": "正在测试", + "quickOpen.fileCountOne": "{count} 个文件", + "quickOpen.filesCount": "{count} 个文件", + "quickOpen.indexing": "正在索引项目文件", + "quickOpen.loadingFiles": "正在加载文件", + "quickOpen.loadingSymbols": "正在加载符号...", + "quickOpen.noFiles": "没有可用文件", + "quickOpen.noFilesInProject": "项目中未找到文件", + "quickOpen.noMatch": "未找到匹配的文件", + "quickOpen.noSymbols": "未找到符号", + "quickOpen.openFolder": "打开文件夹后再搜索文件", + "quickOpen.searchFiles": "输入以搜索文件...", + "quickOpen.searchSymbols": "输入以筛选符号...", + "quickOpen.searchWorkspaceSymbols": "在项目中搜索符号...", + "quickOpen.searching": "正在搜索...", + "quickOpen.symbolsCount": "{count} 个符号", + "references.clearReferences": "清除引用", + "references.exitFullscreen": "退出全屏", + "references.findReferencesHint": "使用 Shift+F12 查找引用", + "references.findingReferences": "正在查找引用", + "references.fullscreen": "全屏", + "references.noReferencesFound": "未找到引用", + "references.title": "引用", + "remote.cannotConnectToServer": "无法连接到服务器。请检查主机地址和端口。", + "remote.connectedTo": "已连接到 {name}", + "remote.connectionFailed": "连接失败", + "remote.connectionName": "连接名称", + "remote.connectionNamePlaceholder": "我的服务器", + "remote.connectionSavedSuccessfully": "连接已保存成功。", + "remote.connectionTimedOut": "连接超时。服务器可能不可用。", + "remote.connectionType": "连接类型", + "remote.enterPassword": "输入密码", + "remote.enterPasswordForPrefix": "输入以下连接的密码:", + "remote.enterPasswordForSuffix": "({user}@{host}:{port})", + "remote.hidePassword": "隐藏密码", + "remote.hostKeyVerificationFailed": "主机密钥验证失败。无法验证服务器身份。", + "remote.incorrectUsernameOrPassword": "用户名或密码不正确,请重试。", + "remote.leaveEmptyForKeyAuth": "留空则使用密钥认证", + "remote.noRouteToHost": "无法到达服务器。请检查网络连接。", + "remote.passwordRequired": "密码为必填项", + "remote.permissionDenied": "权限被拒绝。请检查用户名和密码。", + "remote.privateKeyPath": "私钥路径", + "remote.savePasswordForFutureConnections": "为以后连接保存密码", + "remote.showPassword": "显示密码", + "run.addCustomCommandHint": "添加自定义命令,或打开带有可运行 LSP CodeLens 操作的文件。", + "run.applications": "应用", + "run.cancel": "取消", + "run.chooseServices": "选择要运行的服务", + "run.clearOutput": "清除运行输出", + "run.command": "命令", + "run.configuration": "配置", + "run.configurationDetails": "配置详情", + "run.configurationOverrideHint": "留空则使用项目默认值。", + "run.configurationOverridesSection": "当前配置覆盖", + "run.configurations": "运行配置", + "run.currentFile": "当前文件", + "run.custom": "自定义", + "run.deleteAction": "删除运行操作", + "run.deleteActionMessage": "要删除运行操作“{name}”吗?", + "run.detectedInProject": "项目中检测到", + "run.done": "完成", + "run.editService": "编辑服务", + "run.editorTitle": "运行配置", + "run.effectiveSource": "生效来源", + "run.emptyOutput": "运行配置后将在这里显示进程输出。", + "run.environment": "环境变量", + "run.failed": "失败", + "run.filterActions": "筛选操作", + "run.freshnessCheckFailed": "未能确认运行配置是否为最新", + "run.generatedEntries": "已生成 {count} 个可运行项目入口。", + "run.groups": "组", + "run.identifyAgain": "重新识别", + "run.identifyAndGenerate": "识别并生成", + "run.identifying": "正在识别项目…", + "run.infrastructure": "Docker 服务", + "run.invalidTitle": "项目运行配置无效", + "run.javaBuildAlwaysContinue": "对此工作区始终继续", + "run.javaBuildAlwaysContinueEnabled": "此工作区遇到 Java 构建失败时会直接继续启动,不再询问。", + "run.javaBuildAskAgain": "构建失败时重新询问", + "run.javaBuildContinue": "仍然运行", + "run.javaBuildElapsed": "构建响应耗时:{milliseconds} 毫秒。该数据只用于说明情况,不决定是否允许运行。", + "run.javaBuildFailedTitle": "{name} 的 Java 构建未成功完成", + "run.javaBuildMarkersMayRemain": "语言服务的构建器在本次会话中发生过失败,因此当前报告的部分错误可能是那次失败留下的。", + "run.javaBuildOpenLogs": "打开日志", + "run.javaBuildRebuildIndex": "重建 Java 索引", + "run.javaBuildWorkspaceScope": "本次构建未能确认唯一目标项目,因此其他工作区模块的错误可能影响了结果。", + "run.javaDiscoveryFailed": "无法刷新 Java 入口:{message}", + "run.javaDiscoveryLoading": "正在等待 Java 语言服务列出可运行的类...", + "run.javaDiscoveryStale": "Java 语言服务正在准备项目,暂时显示上次的 Java 入口。", + "run.jdkHome": "JDK 主目录", + "run.jdkHomeHint": "留空则使用自动检测到的 JDK。", + "run.languageInfrastructure": "基础设施", + "run.languageOther": "其他", + "run.languageTools": "构建工具", + "run.languages": "语言 / 生态", + "run.mainClass": "主类", + "run.mavenExecutable": "Maven 主目录 / 可执行文件", + "run.mavenExecutableHint": "可选择 Maven 或 mvnd 的主目录、bin 目录或可执行文件;留空则依次使用项目 Wrapper、检测到的 Maven、mvnd。", + "run.mavenJdkHome": "Maven JDK 主目录", + "run.mavenJdkHomeHint": "留空则与应用使用同一个 JDK。", + "run.mavenTests": "Maven 测试", + "run.mavenTestsHint": "为当前运行配置覆盖 Maven 工具窗口中的“跳过测试”设置。", + "run.mavenTestsProjectDefault": "使用项目默认值", + "run.mavenTestsRun": "运行测试", + "run.mavenTestsSkip": "跳过测试", + "run.minimize": "最小化", + "run.missingMessage": "生成 .lithe/run/generated.json 后即可运行项目。已有的项目和本机覆盖不会被改动。", + "run.missingTitle": "未找到项目运行配置", + "run.modifyRunConfiguration": "修改运行配置…", + "run.newCustomAction": "新建自定义操作", + "run.noMatchingActions": "没有匹配的操作", + "run.noRunnableActionsFound": "未找到可运行操作", + "run.nodeExecutable": "Node.js 可执行文件", + "run.nodeExecutableHint": "可选择 node.exe;留空则使用自动检测到的 Node.js 运行时。", + "run.otherConfigurations": "其他运行配置", + "run.processOutput": "进程输出", + "run.programArguments": "程序参数", + "run.projectAction": "运行项目操作", + "run.projectDefaultsSection": "项目默认运行环境", + "run.rescan": "重新扫描服务", + "run.rescanProjectActions": "重新扫描项目操作", + "run.resizeConfigurationList": "调整运行配置列表宽度", + "run.resizeLanguages": "调整语言侧栏宽度", + "run.resizeLanguagesHint": "向左拖动折叠,向右拖动展开;方向键调整宽度", + "run.run": "运行配置", + "run.runAllServices": "运行所有服务", + "run.runCell": "运行单元", + "run.runChunk": "运行代码块", + "run.runMarkerFailed": "无法运行 {target}", + "run.runSelectedServices": "运行选中的服务", + "run.runTarget": "运行 '{target}'", + "run.running": "运行中", + "run.runtimeSection": "运行环境(本机)", + "run.saveScope": "保存范围", + "run.saveScopeLocal": "本机", + "run.saveScopeLocalHint": "仅为这台电脑上的此项目保存在 .lithe/run/local.json,并已从 Git 中排除。", + "run.saveScopeProject": "项目", + "run.saveScopeProjectHint": "保存在 .lithe/run/configurations.json,供整个团队使用。本机 JDK 路径不会被共享。", + "run.scanning": "正在扫描", + "run.scanningProjectActions": "正在扫描项目操作", + "run.scrollToEnd": "输出始终滚动到最后一行", + "run.selectConfiguration": "选择一个运行配置以查看输出。", + "run.services": "服务", + "run.snapshotOutput": "快照当前输出", + "run.snapshotTabName": "输出快照", + "run.source.generated": "自动生成", + "run.source.local": "此电脑", + "run.source.project": "项目配置", + "run.staleConfigurations": "运行配置可能已过期", + "run.stdinPlaceholder": "输入程序所需内容…", + "run.stdinSend": "发送", + "run.stop": "停止运行", + "run.succeeded": "成功", + "run.tasks": "任务", + "run.title": "运行", + "run.toolchainAuto": "自动检测(留空)", + "run.toolchainCurrent": "当前路径", + "run.toolchainNeedsAttention": "项目工具链需要处理", + "run.toolchainProjectDefault": "使用项目默认值", + "run.tryAnotherActionSearch": "请尝试其他名称、命令或来源。", + "run.type": "类型", + "run.updateService": "更新服务", + "run.updateServiceHelp": "编译更改,由 Spring Boot DevTools 处理更新。应用上下文可能重启。", + "run.updatingService": "正在更新…", + "run.vmArguments": "JVM 参数", + "run.workingDirectory": "工作目录", + "run.workingDirectoryHint": "留空则使用项目根目录。", + "run.wrapOutputLines": "自动换行", + "runActions.addAction": "添加操作", + "runActions.build": "构建", + "runActions.check": "检查", + "runActions.deleteNamedAction": "删除 {name}", + "runActions.editNamedAction": "编辑 {name}", + "runActions.editRunAction": "编辑运行操作", + "runActions.name": "名称", + "runActions.namePlaceholder": "启动开发服务器", + "runActions.newRunAction": "新建运行操作", + "runActions.projectScript": "项目脚本", + "runActions.run": "运行", + "runActions.saveChanges": "保存更改", + "runActions.savedForPrefix": "此操作将保存到", + "runActions.savedForSuffix": "并在新终端中运行。", + "runActions.test": "测试", + "runActions.workingDirectoryDescription": "留空则使用项目根目录,或输入相对路径,例如", + "runningServices.ask": "停止前询问", + "runningServices.message": "此窗口启动的 {count} 个运行服务仍在运行。关闭窗口会停止这些服务及其子进程。", + "runningServices.quitMessage": "此窗口启动的 {count} 个运行服务仍在运行。退出 Lithe 会停止这些服务及其子进程。", + "runningServices.setting": "运行中的运行服务", + "runningServices.settingDescription": "关闭窗口、退出 Lithe 或安装更新时生效。从 Lithe 启动的服务总会随之停止,包括它们的子进程。", + "runningServices.settingsGroup": "退出时", + "runningServices.stopAndClose": "停止并关闭", + "runningServices.stopAndQuit": "停止并退出", + "runningServices.stopAndUpdate": "停止并更新", + "runningServices.stopFailed": "部分服务未能停止,仍在运行:{error}", + "runningServices.stopping": "正在停止…", + "runningServices.terminate": "直接停止", + "runningServices.title": "仍有服务在运行", + "runningServices.untitled": "未命名服务", + "runningServices.updateMessage": "{count} 个运行服务仍在运行。安装更新会重启 Lithe,并停止这些服务及其子进程。", + "search.clear": "清除搜索", + "search.close": "关闭搜索", + "search.emptyDescription": "输入关键词,即可在整个项目中查找匹配的文件和代码行。", + "search.emptyTitle": "在项目中搜索", + "search.failed": "搜索失败", + "search.filesToExclude": "要排除的文件", + "search.filesToInclude": "要包含的文件", + "search.hideDetails": "隐藏详细信息", + "search.hideReplace": "隐藏替换", + "search.matchCase": "区分大小写", + "search.matchWholeWord": "全字匹配", + "search.nextMatch": "下一个匹配项", + "search.noResults": "未找到结果", + "search.noResultsFor": "未找到 “{query}” 的结果", + "search.noResultsForWithFilters": "未找到 “{query}” 的结果(已应用当前文件筛选)", + "search.openProjectDescription": "全局搜索需要先打开一个项目文件夹。", + "search.openProjectTitle": "打开项目后再搜索", + "search.options": "搜索选项", + "search.previousMatch": "上一个匹配项", + "search.replace": "替换", + "search.replaceAllShort": "全部", + "search.replaceMatchesFailed": "替换搜索匹配失败", + "search.replaceWith": "替换为...", + "search.replacedMatches": "已替换 {count} 处匹配", + "search.retry": "重试", + "search.search": "搜索", + "search.showDetails": "显示详细信息", + "search.showReplace": "显示替换", + "search.unsupported": "当前工作区类型不支持全局搜索。", + "search.useRegex": "使用正则表达式", + "settings.advanced.data": "数据", + "settings.advanced.experimental": "实验性", + "settings.advanced.exportFailed": "导出设置失败:{error}", + "settings.advanced.exportSettings": "导出设置", + "settings.advanced.exportSettingsDescription": "将全部应用设置保存到 JSON 文件。", + "settings.advanced.feature.breadcrumbs.description": "编辑器中的文件路径导航面包屑", + "settings.advanced.feature.breadcrumbs.name": "面包屑", + "settings.advanced.feature.diagnostics.description": "代码诊断和错误报告", + "settings.advanced.feature.diagnostics.name": "诊断和问题", + "settings.advanced.feature.outline.description": "活动文件的文档符号和快速导航", + "settings.advanced.feature.outline.name": "大纲", + "settings.advanced.feature.persistentCommands.description": "最近使用的命令显示在命令面板顶部", + "settings.advanced.feature.persistentCommands.name": "常用命令", + "settings.advanced.feature.search.description": "在工作区中的文件和文件夹内搜索", + "settings.advanced.feature.search.name": "全局搜索", + "settings.advanced.feature.terminal.description": "用于命令行操作的内置终端", + "settings.advanced.feature.terminal.name": "集成终端", + "settings.advanced.features": "功能", + "settings.advanced.featuresDescription": "打开或关闭应用功能", + "settings.advanced.importFailed": "导入设置失败:{error}", + "settings.advanced.importSettings": "导入设置", + "settings.advanced.importSettingsDescription": "从 Lithe 设置 JSON 文件恢复应用设置。", + "settings.advanced.invalidFile": "设置文件格式无效", + "settings.advanced.reset": "重置", + "settings.advanced.resetSettings": "重置设置", + "settings.advanced.resetSettingsDescription": "将所有设置重置为默认值", + "settings.advanced.settingsExported": "设置已导出", + "settings.advanced.settingsImported": "设置已导入", + "settings.advanced.settingsReset": "设置已重置为默认值", + "settings.appearance.activeAndHovered": "活动和悬停时", + "settings.appearance.activityBarWidth": "活动栏宽度", + "settings.appearance.activityBarWidthAria": "活动栏宽度:{size} 像素", + "settings.appearance.activityBarWidthDescription": "设置展开活动栏的宽度", + "settings.appearance.always": "始终", + "settings.appearance.colorTheme": "颜色主题", + "settings.appearance.colorThemeDescription": "选择偏好的颜色主题", + "settings.appearance.comfortable": "舒适", + "settings.appearance.compactMenuBar": "紧凑菜单栏", + "settings.appearance.compactMenuBarDescription": "需要界面菜单栏;使用紧凑汉堡菜单或完整界面菜单", + "settings.appearance.create": "创建", + "settings.appearance.customThemeRemoved": "自定义主题已移除", + "settings.appearance.customThemes": "自定义主题", + "settings.appearance.customThemesDescription": "导入 Lithe 主题 JSON,或从已安装主题创建主题。", + "settings.appearance.differentiateProjects": "用颜色区分项目", + "settings.appearance.differentiateProjectsDescription": "在窗口顶部显示项目颜色渐变(Lithe 主题;开启窗口透明度时不显示)", + "settings.appearance.expandedActivityBar": "展开活动栏", + "settings.appearance.expandedActivityBarDescription": "在活动栏图标旁显示标签", + "settings.appearance.focused": "紧凑", + "settings.appearance.formatGuide": "格式指南", + "settings.appearance.hoveredOnly": "仅悬停时", + "settings.appearance.iconTheme": "图标主题", + "settings.appearance.iconThemeDescription": "在文件树和标签页中显示的图标", + "settings.appearance.import": "导入", + "settings.appearance.importThemeFailed": "导入主题失败", + "settings.appearance.importedTheme": "已导入 {theme}", + "settings.appearance.importedThemeVariants": "已导入 {count} 个主题变体", + "settings.appearance.interface": "界面", + "settings.appearance.layout": "布局", + "settings.appearance.nativeMenuBar": "原生菜单栏", + "settings.appearance.nativeMenuBarDescription": "使用原生菜单栏或自定义界面菜单栏", + "settings.appearance.openAskEveryTime": "每次询问", + "settings.appearance.openAttach": "附加", + "settings.appearance.openNewWindow": "新窗口", + "settings.appearance.openProjectsDestination": "打开项目方式", + "settings.appearance.openProjectsDestinationDescription": "选择打开另一个项目时是每次询问、替换当前项目、附加到当前窗口还是打开新窗口", + "settings.appearance.openThisWindow": "此窗口", + "settings.appearance.preferredDarkTheme": "首选深色主题", + "settings.appearance.preferredDarkThemeDescription": "启用与操作系统同步且系统外观为深色时使用", + "settings.appearance.preferredLightTheme": "首选浅色主题", + "settings.appearance.preferredLightThemeDescription": "启用与操作系统同步且系统外观为浅色时使用", + "settings.appearance.reduceMotion": "减少动态效果", + "settings.appearance.reduceMotionDescription": "减少非必要的界面动画,同时保持状态变化可见", + "settings.appearance.removeCustomThemeFailed": "移除自定义主题失败", + "settings.appearance.removeTheme": "移除 {theme}", + "settings.appearance.showStatusBar": "显示状态栏", + "settings.appearance.showStatusBarDescription": "在底部边缘显示应用控件和状态信息", + "settings.appearance.showTabIcons": "显示标签页图标", + "settings.appearance.showTabIconsDescription": "在编辑器标签页中显示文件和视图图标", + "settings.appearance.sidebarWidth": "侧边栏宽度", + "settings.appearance.sidebarWidthAria": "侧边栏宽度:{size} 像素", + "settings.appearance.sidebarWidthDescription": "设置左右侧边栏使用的默认宽度", + "settings.appearance.syncWithOs": "与操作系统同步", + "settings.appearance.syncWithOsDescription": "在偏好的浅色和深色主题之间自动切换", + "settings.appearance.tabCloseButtons": "标签页关闭按钮", + "settings.appearance.tabCloseButtonsDescription": "选择未固定标签页显示关闭按钮的时机", + "settings.appearance.theme": "主题", + "settings.appearance.typography": "字体排印", + "settings.appearance.uiFontFamily": "界面字体", + "settings.appearance.uiFontFamilyDescription": "界面元素使用的字体(文件树、Markdown 等)", + "settings.appearance.uiFontSize": "界面字体大小", + "settings.appearance.uiFontSizeAria": "界面字体大小:{size} 像素", + "settings.appearance.uiFontSizeDescription": "以 0.5 像素为单位调整界面文本和图标缩放", + "settings.appearance.windowChromeDensity": "窗口框架密度", + "settings.appearance.windowChromeDensityDescription": "为标题栏、标签页、侧边栏和页脚选择更紧凑或更宽松的尺寸", + "settings.appearance.windowTransparency": "窗口透明度", + "settings.appearance.windowTransparencyDescription": "在支持时使用半透明应用框架和透明原生窗口", + "settings.common.allFiles": "所有文件", + "settings.common.json": "JSON", + "settings.displayLanguage": "显示语言", + "settings.displayLanguageDescription": "选择 Lithe 界面使用的语言。", + "settings.editor.autoCompletion": "自动补全", + "settings.editor.autoCompletionDescription": "输入时显示补全建议", + "settings.editor.autoDetect": "自动检测", + "settings.editor.autoDetectLanguage": "自动检测语言", + "settings.editor.autoDetectLanguageDescription": "根据扩展名自动检测文件语言", + "settings.editor.autoSave": "自动保存", + "settings.editor.autoSaveDescription": "编辑时自动保存文件", + "settings.editor.blink": "闪烁", + "settings.editor.bracketPairColorization": "括号对着色", + "settings.editor.bracketPairColorizationDescription": "使用匹配颜色区分嵌套的括号对", + "settings.editor.codeLens": "代码镜头", + "settings.editor.codeLensDescription": "在符号上方显示内嵌代码操作", + "settings.editor.cursorBlinking": "光标闪烁", + "settings.editor.cursorBlinkingDescription": "Vim 普通模式外编辑器光标使用的动画", + "settings.editor.cursorBlock": "块", + "settings.editor.cursorBlockOutline": "块轮廓", + "settings.editor.cursorLine": "竖线", + "settings.editor.cursorStyle": "光标样式", + "settings.editor.cursorStyleDescription": "Vim 普通模式外编辑器光标的形状", + "settings.editor.cursorThinLine": "细竖线", + "settings.editor.cursorThinUnderline": "细下划线", + "settings.editor.cursorUnderline": "下划线", + "settings.editor.defaultLanguage": "默认语言", + "settings.editor.defaultLanguageDescription": "新文件默认使用的语法高亮", + "settings.editor.expand": "展开", + "settings.editor.fontFamily": "字体", + "settings.editor.fontFamilyDescription": "搜索并选择系统所有已安装字体,包含非等宽字体", + "settings.editor.fontLigatures": "字体连字", + "settings.editor.fontLigaturesDescription": "启用编程连字(例如 =>、!=);需要所选字体本身支持", + "settings.editor.fontPreview": "编辑器字体预览", + "settings.editor.fontSize": "字体大小", + "settings.editor.fontSizeDescription": "编辑器字体大小(6–72 像素)", + "settings.editor.formatOnSave": "保存时格式化", + "settings.editor.formatOnSaveDescription": "保存时自动格式化代码", + "settings.editor.highlightOccurrences": "突出显示匹配项", + "settings.editor.highlightOccurrencesDescription": "突出显示光标下单词的可见匹配项", + "settings.editor.indentGuides": "缩进参考线", + "settings.editor.indentGuidesDescription": "显示缩进层级的垂直参考线", + "settings.editor.inlayHints": "内嵌提示", + "settings.editor.inlayHintsDescription": "显示语言服务器提供的内嵌类型和参数提示", + "settings.editor.italicComments": "斜体注释", + "settings.editor.italicCommentsDescription": "以斜体显示代码注释", + "settings.editor.lineHeight": "行高", + "settings.editor.lineHeightDescription": "编辑器行高倍数", + "settings.editor.lineNumbers": "行号", + "settings.editor.lineNumbersDescription": "在编辑器中显示行号", + "settings.editor.lintOnSave": "保存时检查", + "settings.editor.lintOnSaveDescription": "保存文件时运行检查器", + "settings.editor.maxOpenTabs": "最大打开标签数", + "settings.editor.maxOpenTabsDescription": "达到此数量后关闭最早的标签页", + "settings.editor.parameterHints": "参数提示", + "settings.editor.parameterHintsDescription": "显示函数参数提示", + "settings.editor.phase": "渐变", + "settings.editor.relativeLineNumbers": "相对行号", + "settings.editor.relativeLineNumbersDescription": "Vim 模式激活时显示相对行号", + "settings.editor.renderWhitespace": "显示空白字符", + "settings.editor.renderWhitespaceDescription": "为空格和制表符显示可见标记", + "settings.editor.scrollBeyondLastLine": "滚动至末行之外", + "settings.editor.scrollBeyondLastLineDescription": "允许将最后一行滚动到编辑器底部上方", + "settings.editor.section": "编辑器", + "settings.editor.semanticTokens": "语义令牌", + "settings.editor.semanticTokensDescription": "使用语言服务器的语义高亮", + "settings.editor.showMinimap": "显示代码缩略图", + "settings.editor.showMinimapDescription": "在编辑器右侧显示缩略图概览", + "settings.editor.smooth": "平滑", + "settings.editor.smoothScrolling": "平滑滚动", + "settings.editor.smoothScrollingDescription": "在编辑器位置之间滚动时显示动画", + "settings.editor.solid": "常亮", + "settings.editor.stickyScroll": "粘滞滚动", + "settings.editor.stickyScrollDescription": "滚动时在顶部保持显示包含范围", + "settings.editor.symbolBreadcrumb": "在面包屑中显示符号", + "settings.editor.symbolBreadcrumbDescription": "在面包屑栏中显示光标位置所在的函数或类", + "settings.editor.tabSize": "制表符大小", + "settings.editor.tabSizeDescription": "每个制表符占用的空格数", + "settings.editor.whitespaceAll": "全部", + "settings.editor.whitespaceBoundary": "边界", + "settings.editor.whitespaceNone": "无", + "settings.editor.whitespaceTrailing": "行尾", + "settings.editor.wordWrap": "自动换行", + "settings.editor.wordWrapDescription": "换行显示超出视口宽度的行", + "settings.files.behavior": "行为", + "settings.files.compactFolders": "紧凑文件夹", + "settings.files.compactFoldersDescription": "折叠只有一个子项的连续文件夹", + "settings.files.confirmBeforeDelete": "删除前确认", + "settings.files.confirmBeforeDeleteDescription": "删除文件或文件夹前请求确认", + "settings.files.display": "显示", + "settings.files.filters": "筛选", + "settings.files.foldersFirst": "文件夹优先", + "settings.files.globPatterns": "以逗号分隔的 glob 模式", + "settings.files.hiddenDirectories": "隐藏目录", + "settings.files.hiddenFiles": "隐藏文件", + "settings.files.hideRootFolder": "隐藏根文件夹", + "settings.files.hideRootFolderDescription": "在顶层直接显示项目文件", + "settings.files.indentSize": "缩进大小", + "settings.files.indentSizeDescription": "每个嵌套层级的像素数", + "settings.files.name": "名称", + "settings.files.respectGitignore": "遵循 .gitignore", + "settings.files.respectGitignoreDescription": "隐藏由根目录和嵌套 .gitignore 文件匹配的文件", + "settings.files.showFileIcons": "显示文件图标", + "settings.files.showFileIconsDescription": "显示主题化的文件和文件夹图标", + "settings.files.showGitStatus": "显示 Git 状态", + "settings.files.showGitStatusDescription": "在已更改文件旁显示 Git 颜色标记", + "settings.files.showHiddenFiles": "显示隐藏文件", + "settings.files.showHiddenFilesDescription": "显示点文件和隐藏目录", + "settings.files.showIndentGuides": "显示缩进参考线", + "settings.files.showIndentGuidesDescription": "显示嵌套文件夹的垂直参考线", + "settings.files.sortOrder": "排序顺序", + "settings.files.sortOrderDescription": "选择文件夹始终排在文件之前,还是全部按名称排序", + "settings.general.bugReportTemplate": "环境\n\n- 应用:Lithe {version}\n- 操作系统:{platform} {osVersion}\n\n问题\n\n请在此描述问题。请提供复现步骤、预期结果和实际结果。\n", + "settings.general.check": "检查", + "settings.general.checking": "正在检查...", + "settings.general.cliInstallFailed": "安装 CLI 失败:{error}。您可能需要管理员权限。", + "settings.general.cliInstalled": "CLI 命令已安装到 $HOME/.local/bin/lithe", + "settings.general.cliNotInstalled": "CLI 命令未安装。", + "settings.general.cliUninstallFailed": "卸载 CLI 失败:{error}", + "settings.general.copy": "复制", + "settings.general.copyCommandFailed": "复制命令失败:{error}", + "settings.general.copyInstallCommand": "复制安装命令到剪贴板", + "settings.general.downloading": "正在下载...", + "settings.general.githubReportDescription": "打开问题报告 Issue", + "settings.general.importSettings": "导入设置", + "settings.general.importSettingsDescription": "从其他编辑器导入相匹配的配置。", + "settings.general.install": "安装", + "settings.general.installCommandCopied": "安装命令已复制到剪贴板", + "settings.general.installUpdate": "安装 {version}", + "settings.general.installing": "正在安装...", + "settings.general.latestVersion": "Lithe 主程序已是最新版本,语言扩展需单独更新。", + "settings.general.noReportChannel": "没有匹配“{query}”的报告渠道。", + "settings.general.open": "打开", + "settings.general.prepareReportFailed": "准备问题报告失败", + "settings.general.reportBug": "报告问题", + "settings.general.reportBugDescription": "选择提交包含环境信息的问题的位置。", + "settings.general.reportTemplateCopied": "报告模板已复制", + "settings.general.reportVia": "通过以下方式报告...", + "settings.general.terminalCommand": "终端命令", + "settings.general.terminalCommandDescription": "安装 `lithe` 命令,以便从终端打开文件夹和文件。", + "settings.general.uninstall": "卸载", + "settings.general.uninstalling": "正在卸载...", + "settings.general.upToDate": "Lithe {version} · 应用已是最新版本", + "settings.general.update": "更新", + "settings.general.updateAvailable": "Lithe {version} · 版本 {availableVersion} 可用", + "settings.general.updateCheckFailed": "Lithe {version} · 检查更新失败", + "settings.general.updateDownloadProgress": "Lithe 更新下载进度", + "settings.general.updateInstalling": "Lithe {version} · 正在安装更新...", + "settings.general.updateProgress": "Lithe {version} · 正在下载 {percentage}%", + "settings.general.version": "版本", + "settings.general.versionDescription": "检查更新并安装最新应用版本。", + "settings.git.autoRefresh": "自动刷新 Git 状态", + "settings.git.autoRefreshDescription": "相关文件或 Git 事件发生变化后自动刷新 Git 视图", + "settings.git.collapseEmpty": "折叠空分区", + "settings.git.collapseEmptyDescription": "没有项目时隐藏已暂存更改等空 Git 分区", + "settings.git.compactBadges": "紧凑 Git 状态标记", + "settings.git.compactBadgesDescription": "在 Git 面板中使用更紧凑的差异统计和暂存标签布局", + "settings.git.confirmDiscard": "丢弃前确认", + "settings.git.confirmDiscardDescription": "丢弃文件、仓库或 Diff 修改块前确认。", + "settings.git.defaultDiff": "默认差异视图", + "settings.git.defaultDiffDescription": "选择 Git 差异的默认布局", + "settings.git.editor": "编辑器", + "settings.git.folderChanges": "基于文件夹的更改", + "settings.git.folderChangesDescription": "以类似文件视图的文件夹树形式显示 Git 更改", + "settings.git.gitIntegration": "Git 集成", + "settings.git.gitIntegrationDescription": "启用 Git 仓库的源代码管理功能", + "settings.git.gutter": "启用 Git 变更标记", + "settings.git.gutterDescription": "在编辑器行号栏标记有改动的行,点击标记可在行内查看这一处差异", + "settings.git.inlineBlame": "启用行内 Blame", + "settings.git.inlineBlameDescription": "在编辑器中显示当前行的 Git Blame 元数据", + "settings.git.integration": "集成", + "settings.git.openDiff": "单击打开差异", + "settings.git.openDiffDescription": "单击已更改文件时打开差异,而不是直接打开文件", + "settings.git.split": "拆分视图", + "settings.git.stagedFirst": "优先显示已暂存项", + "settings.git.stagedFirstDescription": "在 Git 面板中将已暂存更改显示在未暂存更改之前", + "settings.git.unified": "统一视图", + "settings.git.untracked": "显示未跟踪文件", + "settings.git.untrackedDescription": "在 Git 状态面板中显示未跟踪文件", + "settings.git.view": "Git 视图", + "settings.groups.ai": "AI", + "settings.groups.appearance": "外观与行为", + "settings.groups.build": "构建、执行、部署", + "settings.groups.languages": "语言与框架", + "settings.groups.tools": "工具", + "settings.groups.versionControl": "版本控制", + "settings.keyboard.actions": "操作", + "settings.keyboard.all": "全部", + "settings.keyboard.andPreset": "及预设", + "settings.keyboard.back": "后退", + "settings.keyboard.command": "命令", + "settings.keyboard.default": "默认", + "settings.keyboard.editKeybindings": "编辑快捷键", + "settings.keyboard.editKeybindingsDescription": "逐项自定义快捷键。", + "settings.keyboard.export": "导出", + "settings.keyboard.exportFailed": "导出快捷键失败:{error}", + "settings.keyboard.extension": "扩展", + "settings.keyboard.filter": "快捷键筛选", + "settings.keyboard.import": "导入", + "settings.keyboard.importFailed": "导入快捷键失败:{error}", + "settings.keyboard.invalidFile": "快捷键文件格式无效", + "settings.keyboard.keybinding": "快捷键", + "settings.keyboard.keybindingsExported": "快捷键已导出", + "settings.keyboard.keybindingsImported": "已导入 {count} 个快捷键{preset}", + "settings.keyboard.keybindingsReset": "快捷键已恢复默认值", + "settings.keyboard.noKeybindings": "未找到快捷键", + "settings.keyboard.none": "无", + "settings.keyboard.openEditor": "打开编辑器", + "settings.keyboard.pluralSuffix": "", + "settings.keyboard.preset": "预设", + "settings.keyboard.presetAria": "快捷键预设", + "settings.keyboard.presetChanges": "预设更改", + "settings.keyboard.presetDescription": "在自定义覆盖前应用基础快捷键样式。", + "settings.keyboard.presetIncomplete": "此预设不完整。仍有 {count} 个内置命令{suffix}缺少预设覆盖。", + "settings.keyboard.presetLabel": "快捷键预设", + "settings.keyboard.presetPluralSuffix": "", + "settings.keyboard.presetSingularSuffix": "", + "settings.keyboard.resetToDefaults": "恢复默认值", + "settings.keyboard.search": "搜索操作或快捷键", + "settings.keyboard.source": "配置来源", + "settings.keyboard.user": "用户", + "settings.keyboard.userOverrides": "当前保存了 {count} 个用户覆盖{suffix}。", + "settings.keyboard.vimMode": "Vim 模式", + "settings.keyboard.vimModeComingSoon": "Vim 模式即将推出!\n\n它会为高级用户在编辑器中启用 Vim 风格快捷键。", + "settings.keyboard.vimModeDescription": "启用 Vim 快捷键和命令", + "settings.keyboard.when": "条件", + "settings.languageChinese": "简体中文", + "settings.languageEnglish": "英文", + "settings.logs.cancel": "取消", + "settings.logs.category.all": "全部模块", + "settings.logs.category.application": "应用运行", + "settings.logs.category.git": "Git", + "settings.logs.category.language": "编辑器与语言服务", + "settings.logs.category.performance": "性能", + "settings.logs.category.plugins": "插件", + "settings.logs.category.run": "构建、运行与调试", + "settings.logs.choose": "选择…", + "settings.logs.chooseDirectory": "选择 Lithe 日志的父目录", + "settings.logs.cleanupFailed": "无法清理日志:{error}", + "settings.logs.clearCurrent": "清除当前目录日志", + "settings.logs.clearCurrentDescription": "只删除当前目录中由 Lithe 管理的日志文件。", + "settings.logs.clearLogs": "清空日志", + "settings.logs.clearPrompt": "仅删除生效目录中由 Lithe 管理的所有模块和会话日志。筛选条件不限制清理范围,后续事件仍会继续记录。", + "settings.logs.clearTitle": "清空已收集的日志?", + "settings.logs.cleared": "已删除 {count} 个日志文件,释放 {size}。", + "settings.logs.copyFailed": "无法复制日志路径:{error}", + "settings.logs.copyPath": "复制路径", + "settings.logs.customPath": "自定义日志位置", + "settings.logs.customPathDescription": "选择父目录后,Lithe 会写入其中的 Lithe/logs 子目录。", + "settings.logs.defaultPath": "默认日志位置", + "settings.logs.defaultPathDescription": "Lithe 始终维护并清理这个应用自有目录。", + "settings.logs.defaultRestored": "默认日志目录已生效。", + "settings.logs.deleteLogs": "删除日志", + "settings.logs.diagnosticBundle": "诊断包", + "settings.logs.diagnosticBundleDescription": "预览并导出筛选后的日志和环境信息。凭据和用户目录路径均会脱敏,不包含源码、编辑缓冲区或终端历史。", + "settings.logs.diagnosticExpiry": "详细诊断仅覆盖所选模块,15 分钟后自动关闭。刷新可查看当前状态。", + "settings.logs.diagnosticFailed": "无法切换诊断日志:{error}", + "settings.logs.diagnosticMode": "详细诊断", + "settings.logs.diagnosticModeDescription": "所选模块记录详细事件,15 分钟后自动关闭。", + "settings.logs.diagnosticOff": "详细诊断:已关闭", + "settings.logs.diagnosticOn": "详细诊断:已开启({module})", + "settings.logs.diagnostics": "诊断", + "settings.logs.directoryChangeFailed": "无法切换日志目录:{error}", + "settings.logs.directoryChanged": "日志目录已立即切换。", + "settings.logs.effectivePath": "当前日志位置", + "settings.logs.effectivePathDescription": "本次会话实际写入日志的目录。", + "settings.logs.empty": "没有匹配的日志", + "settings.logs.exportBundle": "导出诊断包…", + "settings.logs.exportBundleChooseDestination": "选择诊断包的保存位置", + "settings.logs.exportBundleConfirm": "导出", + "settings.logs.exportBundleFailed": "无法导出诊断包:{error}", + "settings.logs.exportBundleFilter": "Zip 压缩包", + "settings.logs.exportBundleIntro": "Lithe 将包含以下文件,其中的凭据、令牌和用户目录路径均已脱敏:", + "settings.logs.exportBundlePreviewFailed": "无法准备诊断包:{error}", + "settings.logs.exportBundleSuccess": "诊断包已保存到 {path}。", + "settings.logs.exportBundleTitle": "导出诊断包?", + "settings.logs.fallback.loggingUnavailable": "应用文件日志当前不可用,Lithe 将继续运行并仅保留控制台诊断。", + "settings.logs.fallback.notWritable": "配置的目录不可写,本次会话正在写入默认位置。", + "settings.logs.fallback.unavailable": "配置的目录当前不可用,本次会话正在写入默认位置。", + "settings.logs.fallbackNotification": "日志目录 {path} 当前不可用,本次运行将写入默认位置。", + "settings.logs.health": "已丢弃:{dropped};写入失败:{failures}", + "settings.logs.incomplete": "部分较早的日志因收集上限被省略。", + "settings.logs.keepLogs": "保留日志", + "settings.logs.level": "最低级别", + "settings.logs.loadFailed": "无法加载日志设置:{error}", + "settings.logs.loading": "正在加载…", + "settings.logs.locations": "日志位置", + "settings.logs.module": "模块", + "settings.logs.openCurrent": "打开当前日志", + "settings.logs.openDirectory": "打开目录", + "settings.logs.openDirectoryFailed": "无法打开日志目录:{error}", + "settings.logs.openFailed": "无法打开当前日志:{error}", + "settings.logs.pathCopied": "日志路径已复制。", + "settings.logs.previewExport": "预览并导出…", + "settings.logs.previousDirectoryPrompt": "旧目录 {path} 中仍有约 {size} 的 Lithe 日志,是否删除?", + "settings.logs.previousDirectoryTitle": "清理旧日志?", + "settings.logs.refreshFailed": "日志已清除,但无法刷新已打开的日志标签:{error}", + "settings.logs.refreshTimeline": "刷新日志", + "settings.logs.restoreDefault": "恢复默认", + "settings.logs.retention": "保留与清理", + "settings.logs.retentionDescription": "模块日志达到 10 MiB 后轮转,保留 30 天。总预算为 100 MiB,性能日志另有限额 20 MiB。", + "settings.logs.search": "筛选日志内容", + "settings.logs.selectionSummary": "模块:{module} · 会话:{session} · 最低级别:{level}", + "settings.logs.session": "会话", + "settings.logs.session.all": "全部会话", + "settings.logs.session.current": "当前会话", + "settings.logs.session.previous": "上一次会话", + "settings.logs.since": "起始时间(UTC ISO 8601)", + "settings.logs.timeline": "日志时间线", + "settings.logs.unavailableNotification": "应用文件日志当前不可用,Lithe 将继续运行但不会写入日志文件。", + "settings.logs.until": "结束时间(UTC ISO 8601)", + "settings.logs.usingDefault": "正在使用默认位置", + "settings.logs.workflow": "选择模块,开启详细诊断并复现问题,然后预览并导出诊断包。", + "settings.mac.aiProvider": "AI 服务商", + "settings.mac.apiKey": "API 密钥或 Token", + "settings.mac.apiKeyPlaceholder": "由应用安全存储", + "settings.mac.apiKeySaveFailed": "API 密钥保存失败", + "settings.mac.apiKeySaved": "API 密钥已保存", + "settings.mac.apiKeySavedWithoutValidation": "API 密钥已保存,但校验未通过", + "settings.mac.apiKeyStoredPlaceholder": "已保存,输入新密钥可覆盖", + "settings.mac.apiUrl": "API 地址", + "settings.mac.appearance": "外观", + "settings.mac.appearanceDescription": "选择配色主题,并设置是否跟随系统外观。", + "settings.mac.appearanceMode": "外观模式", + "settings.mac.apply": "应用", + "settings.mac.askEveryTime": "每次询问", + "settings.mac.attach": "附加", + "settings.mac.autoCompletion": "自动补全", + "settings.mac.autoCompletionDescription": "显示活动语言服务器提供的补全建议。", + "settings.mac.autoSave": "自动保存已修改的文件", + "settings.mac.categories": "设置分类", + "settings.mac.checkForUpdates": "检查更新", + "settings.mac.checking": "正在检查更新…", + "settings.mac.colorTheme": "配色主题", + "settings.mac.commitMessage": "提交信息", + "settings.mac.currentVersion": "当前版本:{version}", + "settings.mac.dark": "深色", + "settings.mac.defaultShell": "默认 Shell", + "settings.mac.defaultShellDescription": "用于新建的终端会话。", + "settings.mac.detectedServers": "已检测语言服务器", + "settings.mac.detectedServersDescription": "语言服务器由已安装的语言扩展检测,并在打开受支持文件时启动。", + "settings.mac.directories": "目录", + "settings.mac.display": "显示", + "settings.mac.done": "完成", + "settings.mac.editorTabs": "编辑器标签栏", + "settings.mac.enableAiCommit": "使用 AI 生成提交信息", + "settings.mac.filePatterns": "文件模式", + "settings.mac.files": "文件", + "settings.mac.followSystem": "跟随系统", + "settings.mac.fontSize": "字体大小", + "settings.mac.git": "Git", + "settings.mac.gitPolicyDescription": "选择执行 Git 操作前保护本地更改的方式。", + "settings.mac.gitStash": "Git Stash", + "settings.mac.hiddenPaths": "隐藏路径", + "settings.mac.hiddenPathsDescription": "每行输入一项。目录名用于隐藏匹配的文件夹;文件条目支持 * 和 ?。", + "settings.mac.indentation": "缩进", + "settings.mac.keymapJetBrains": "JetBrains", + "settings.mac.keymapLithe": "Lithe", + "settings.mac.keymapPreset": "快捷键", + "settings.mac.keymapVisualStudioCode": "Visual Studio Code", + "settings.mac.keymapXcode": "Xcode", + "settings.mac.language": "语言", + "settings.mac.languageDescription": "界面语言会立即生效。默认语言为英文。", + "settings.mac.languageServices": "语言服务", + "settings.mac.layout": "布局", + "settings.mac.light": "浅色", + "settings.mac.model": "模型", + "settings.mac.newWindow": "新窗口", + "settings.mac.openProjectsDescription": "选择打开其他项目时是每次询问、替换当前项目、附加到此窗口,还是创建新窗口。", + "settings.mac.openProjectsIn": "项目打开方式", + "settings.mac.parameterHints": "参数提示", + "settings.mac.preset": "预设", + "settings.mac.projects": "项目", + "settings.mac.provider": "服务商", + "settings.mac.restoreDefaults": "恢复默认设置", + "settings.mac.restoreDefaultsConfirm": "⚠️确认恢复所有配置吗?", + "settings.mac.saveLocalChangesWith": "本地更改保存方式", + "settings.mac.searchShortcuts": "搜索快捷键", + "settings.mac.semanticHighlighting": "语义高亮", + "settings.mac.shelf": "搁置", + "settings.mac.shell": "Shell", + "settings.mac.shortcuts": "键盘快捷键", + "settings.mac.shortcutsDescription": "选择快捷键预设,然后使用命令面板查看和运行可用命令。", + "settings.mac.showCodeVision": "显示引用数量和 Git 作者", + "settings.mac.singleRow": "单行", + "settings.mac.softwareUpdate": "软件更新", + "settings.mac.spaces": "个空格", + "settings.mac.systemDefault": "系统默认", + "settings.mac.tabWidth": "Tab 宽度", + "settings.mac.thisWindow": "此窗口", + "settings.mac.upToDate": "Lithe 当前已是最新版本。", + "settings.mac.updateAvailable": "版本 {version} 可用。", + "settings.mac.updateFailed": "检查更新失败,请稍后重试。", + "settings.mac.updateHint": "Lithe 可以检查新的预览版和稳定版。", + "settings.mac.wrapRows": "多行展开", + "settings.mcp.allowConfigure": "允许修改项目环境和 Maven 配置", + "settings.mcp.allowExecute": "允许运行、构建、重载和停止(会执行项目代码)", + "settings.mcp.copyConfiguration": "复制 Agent 配置", + "settings.mcp.description": "仅授权此项目。连接的 Agent 可以检查环境、运行配置和输出;输出可能包含项目敏感信息。关闭项目或退出 IDE 后连接失效。", + "settings.mcp.disable": "关闭 MCP", + "settings.mcp.enable": "开启 MCP", + "settings.mcp.openProject": "打开项目后,可在这里配置 Agent 的 MCP 访问权限。", + "settings.mcp.plugins": "声明 IDE 权限的已启用插件也可使用此项目授权。", + "settings.mcp.title": "MCP 配置", + "settings.navigation": "设置导航", + "settings.noMatching": "未找到设置", + "settings.noMatchingSettings": "未找到设置", + "settings.noResults": "未找到设置", + "settings.project.detected": "检测到的安装", + "settings.project.loading": "处理中…", + "settings.project.mavenAutomatic": "自动:依次使用项目 Maven Wrapper、检测到的 Maven、mvnd。也可以填写 Maven 或 mvnd 的主目录、bin 目录或可执行文件路径。", + "settings.project.openProject": "打开项目后,可在这里配置项目 JDK 和 Maven。", + "settings.project.openSettings": "配置项目 JDK 和 Maven…", + "settings.project.overrides": "以下覆盖值仅影响当前运行配置。清空路径可恢复继承项目环境。", + "settings.project.refresh": "重新加载并检测", + "settings.project.reloadFailed": "无法重新加载项目环境。", + "settings.project.saved": "项目环境已保存。", + "settings.project.savedReloadFailed": "项目默认值已保存,但更新 Maven 或重新加载失败:", + "settings.project.scope": "这些默认设置仅作用于这台电脑上的此项目。运行配置默认继承,单独设置时可覆盖。需要与团队共享的设置,请在运行配置中选择“项目”保存范围。路径留空时使用自动选择。", + "settings.project.scopeTitle": "本机 · 此项目", + "settings.project.title": "项目 · JDK 与 Maven", + "settings.project.useProjectJdk": "使用项目 JDK", + "settings.resizeCategories": "调整设置分类栏宽度", + "settings.resizePlugins": "调整插件列表宽度", + "settings.run.description": "选择服务或任务,配置启动参数、环境变量和项目环境覆盖项;点击保存后生效。", + "settings.run.title": "运行配置", + "settings.search": "搜索设置", + "settings.sections": "设置分区", + "settings.tabs.advanced": "高级", + "settings.tabs.agents": "Agent", + "settings.tabs.ai": "AI 聊天与编辑", + "settings.tabs.aiCommit": "AI 与提交", + "settings.tabs.appearance": "外观", + "settings.tabs.editor": "编辑器", + "settings.tabs.files": "文件", + "settings.tabs.general": "通用", + "settings.tabs.git": "Git", + "settings.tabs.keyboard": "快捷键", + "settings.tabs.logs": "日志与诊断", + "settings.tabs.lsp": "LSP", + "settings.tabs.maven": "Maven", + "settings.tabs.plugins": "插件", + "settings.tabs.terminal": "终端", + "settings.tabs.updates": "更新", + "settings.terminal.addProfile": "添加配置文件", + "settings.terminal.altClickMovesCursor": "Alt 单击移动光标", + "settings.terminal.altClickMovesCursorDescription": "在支持时将 Shell 提示符光标移动到单击位置", + "settings.terminal.bar": "竖线", + "settings.terminal.blinkingCursor": "光标闪烁", + "settings.terminal.blinkingCursorDescription": "是否让光标闪烁", + "settings.terminal.block": "块", + "settings.terminal.cursor": "光标", + "settings.terminal.cursorStyle": "光标样式", + "settings.terminal.cursorStyleDescription": "光标的形状", + "settings.terminal.cursorWidth": "光标宽度", + "settings.terminal.cursorWidthDescription": "竖线或块光标的粗细", + "settings.terminal.custom": "自定义", + "settings.terminal.customProfile": "自定义配置文件 {number}", + "settings.terminal.defaultProfile": "默认配置文件", + "settings.terminal.defaultProfileDescription": "终端获得焦点时由终端工具栏按钮和 Cmd+T 使用。", + "settings.terminal.defaultShell": "默认 Shell", + "settings.terminal.defaultShellDescription": "终端配置文件未覆盖时使用的备用 Shell。", + "settings.terminal.deleteProfile": "删除 {profile}", + "settings.terminal.fontFamily": "字体", + "settings.terminal.fontFamilyDescription": "集成终端使用的字体。选择 Nerd Font 以获得最佳图标支持。", + "settings.terminal.fontHelp": "注意:所选字体必须已安装在系统中才能正常工作。如果缺少图标,请尝试安装 Nerd Font。", + "settings.terminal.fontSize": "字体大小", + "settings.terminal.fontSizeDescription": "终端字体大小(像素)", + "settings.terminal.hidden": "隐藏", + "settings.terminal.inactiveCursorStyle": "非活动光标样式", + "settings.terminal.inactiveCursorStyleDescription": "终端未获得焦点时光标的外观", + "settings.terminal.interaction": "交互", + "settings.terminal.launch": "启动方式", + "settings.terminal.launchDescription": "选择新终端标签页默认使用的 Shell 和配置文件。", + "settings.terminal.letterSpacing": "字母间距", + "settings.terminal.letterSpacingDescription": "字符之间的额外间距", + "settings.terminal.lineHeight": "行高", + "settings.terminal.lineHeightDescription": "行高倍数", + "settings.terminal.nerdFont": "Nerd Font", + "settings.terminal.noCustomProfiles": "尚无自定义终端配置文件。", + "settings.terminal.optionAsMeta": "Option 作为 Meta", + "settings.terminal.optionAsMetaDescription": "在 macOS 终端应用中将 Option 键视为 Meta 键", + "settings.terminal.outline": "轮廓", + "settings.terminal.profileName": "名称", + "settings.terminal.profileNamePlaceholder": "我的配置文件", + "settings.terminal.profilePickerDescription": "显示在终端配置文件选择器中。", + "settings.terminal.profileShell": "Shell", + "settings.terminal.profiles": "配置文件", + "settings.terminal.profilesDescription": "创建可复用的启动预设,其中包含 Shell 覆盖、启动目录和可选启动命令。", + "settings.terminal.profilesHelp": "内置配置文件根据检测到的 Shell 生成。自定义配置文件显示在终端工具栏的配置文件选择器中。", + "settings.terminal.rightClickSelectsWord": "右键选择单词", + "settings.terminal.rightClickSelectsWordDescription": "打开上下文菜单前选择指针下的单词", + "settings.terminal.scrollback": "回滚缓冲区", + "settings.terminal.scrollbackDescription": "在内存中保留的终端历史行数", + "settings.terminal.selectFont": "选择字体...", + "settings.terminal.startupCommands": "启动命令", + "settings.terminal.startupCommandsDescription": "每行输入一个命令。", + "settings.terminal.startupCommandsPlaceholder": "每行一个命令", + "settings.terminal.startupDirectory": "启动目录", + "settings.terminal.startupDirectoryDescription": "留空以使用当前工作区目录。", + "settings.terminal.startupDirectoryPlaceholder": "留空以使用当前工作区目录", + "settings.terminal.systemDefault": "系统默认", + "settings.terminal.typography": "字体排印", + "settings.terminal.underline": "下划线", + "springEndpoints.empty": "未找到 Spring MVC 接口", + "springEndpoints.error.indexFailed": "Spring 接口索引失败。", + "springEndpoints.error.indexTimeout": "Spring 接口索引超时。", + "springEndpoints.error.permissionDenied": "没有读取当前工作区的权限,无法索引 Spring 接口。", + "springEndpoints.error.rootUnavailable": "Spring 工作区根目录不可用。", + "springEndpoints.error.unsupportedRoot": "当前工作区不支持 Spring 接口索引。", + "springEndpoints.filterPlaceholder": "筛选路由、控制器或方法", + "springEndpoints.loading": "正在索引 Spring 项目...", + "springEndpoints.noMatch": "没有匹配当前筛选条件的接口", + "springEndpoints.refreshing": "正在更新 Spring 接口...", + "springEndpoints.routeCount": "{count} 个路由", + "springEndpoints.routeCountOne": "{count} 个路由", + "springEndpoints.stale": "正在显示上次成功索引的结果,刷新失败。", + "svg.editor": "编辑", + "svg.invalid": "无法渲染 SVG,请检查 XML 源码。", + "svg.preview": "预览", + "svg.resize": "调整 SVG 编辑与预览宽度", + "svg.split": "编辑与预览", + "svg.viewMode": "SVG 视图模式", + "tabs.activated": "已激活 {name}{unsaved}", + "tabs.announcementUnsaved": ",未保存", + "tabs.ariaPinnedSuffix": "(已固定)", + "tabs.ariaPreviewSuffix": "(预览)", + "tabs.ariaUnsavedSuffix": "(未保存)", + "tabs.cannotClosePinned": "无法关闭已固定标签页 {name}", + "tabs.close": "关闭", + "tabs.closeAll": "全部关闭", + "tabs.closeOthers": "关闭其他", + "tabs.closeSaved": "关闭已保存", + "tabs.closeSplit": "关闭拆分", + "tabs.closeSplitPane": "关闭拆分窗格", + "tabs.closeToRight": "关闭右侧", + "tabs.closed": "已关闭 {name}", + "tabs.couldNotReopen": "无法重新打开 {name}", + "tabs.exitFullScreen": "退出全屏", + "tabs.fullScreenEditor": "编辑器全屏", + "tabs.goBackShort": "后退", + "tabs.lockEditorGroup": "锁定编辑器组", + "tabs.newTab": "新建标签页", + "tabs.noRecentlyClosed": "没有最近关闭的标签页", + "tabs.openFiles": "打开的文件", + "tabs.pin": "固定标签页", + "tabs.reload": "重新加载", + "tabs.rename": "重命名 {name}", + "tabs.splitDown": "向下拆分", + "tabs.splitRight": "向右拆分", + "tabs.switchedTo": "已切换到 {name}{unsaved}", + "tabs.toggleEditorFullScreen": "切换编辑器全屏", + "tabs.unlockEditorGroup": "解锁编辑器组", + "tabs.unpin": "取消固定标签页", + "tabs.unsavedChanges": "未保存的更改", + "terminal.allFiles": "所有文件", + "terminal.chooseTerminalProfile": "选择终端配置文件", + "terminal.contextClear": "清除终端", + "terminal.contextClose": "关闭终端", + "terminal.contextCloseAll": "关闭所有终端", + "terminal.contextCloseOthers": "关闭其他终端", + "terminal.contextCloseRight": "关闭右侧终端", + "terminal.contextDuplicate": "复制终端", + "terminal.contextExport": "导出输出", + "terminal.contextPin": "固定终端", + "terminal.contextRename": "重命名终端", + "terminal.contextUnpin": "取消固定终端", + "terminal.copySelection": "复制所选内容", + "terminal.detectShells": "重新检测已安装的 Shell", + "terminal.detectShellsFailed": "Shell 检测失败,请重试。现有终端不受影响。", + "terminal.detectingShells": "正在检测 Shell…", + "terminal.errorFallback": "无法初始化终端", + "terminal.errorTitle": "终端错误", + "terminal.exitFullScreen": "退出全屏", + "terminal.findInTerminal": "在终端中查找", + "terminal.fullScreen": "全屏", + "terminal.fullScreenTerminal": "全屏终端", + "terminal.minimizeTerminal": "最小化终端", + "terminal.newTerminal": "新建终端", + "terminal.nextTab": "下一个标签页", + "terminal.noTerminals": "没有终端", + "terminal.paste": "粘贴", + "terminal.pasteCopiedFilePaths": "粘贴已复制的文件路径", + "terminal.pasteIntoTerminal": "粘贴到终端", + "terminal.pasteLinesConfirm": "要将 {count} 行粘贴到终端吗?这可能会执行多条命令。", + "terminal.previousTab": "上一个标签页", + "terminal.resizeSidebar": "调整终端侧栏大小", + "terminal.retry": "重试", + "terminal.search": "搜索", + "terminal.searchMatchCase": "区分大小写", + "terminal.searchMatchWholeWord": "全字匹配", + "terminal.searchPlaceholder": "在终端中查找...", + "terminal.searchUseRegex": "使用正则表达式", + "terminal.shellUnavailable": "暂未检测到", + "terminal.tabAriaPinned": "(已固定)", + "terminal.tabClose": "关闭 {name}", + "terminal.tabNamePlaceholder": "终端名称", + "terminal.tabRenameAria": "重命名 {name}", + "terminal.tabUnpin": "取消固定终端", + "terminal.terminalTabs": "终端标签页", + "terminal.terminals": "终端", + "terminal.textFiles": "文本文件", + "terminal.toolbarEditorWidth": "编辑器宽度", + "terminal.toolbarFullWidth": "全宽", + "terminal.toolbarHorizontalTabs": "水平标签页", + "terminal.toolbarTabLayout": "标签页布局", + "terminal.toolbarTabPosition": "标签页位置", + "terminal.toolbarTabsOnLeft": "标签页在左侧", + "terminal.toolbarTabsOnRight": "标签页在右侧", + "terminal.toolbarTerminalWidth": "终端宽度", + "terminal.toolbarVerticalTabs": "垂直标签页", + "titleProject.addProject": "添加项目", + "titleProject.cloneRepository": "克隆仓库…", + "titleProject.closeAllProjects": "关闭所有项目", + "titleProject.closeProject": "关闭项目 {name}", + "titleProject.currentProject": "当前项目 {project}", + "titleProject.displayAliasPrompt": "输入显示别名,将显示在文件夹名之后,用于区分同名项目。", + "titleProject.newProject": "新建项目…", + "titleProject.newWindow": "新建窗口", + "titleProject.noRecentProjects": "没有最近项目", + "titleProject.open": "打开…", + "titleProject.openFolder": "打开文件夹", + "titleProject.openFolderInNewWindow": "在新窗口中打开文件夹", + "titleProject.openInNewWindow": "在新窗口中打开", + "titleProject.openProjects": "打开的项目", + "titleProject.recentProjects": "最近项目", + "titleProject.removeProject": "移除项目", + "titleProject.selectIcon": "选择图标", + "titleProject.setDisplayAlias": "设置显示别名", + "titleProject.switchToProject": "切换到 {project}", + "titleProject.switchToProjectMenu": "切换到项目", + "titleProject.trigger": "项目:{project}", + "toolchain.detecting": "正在检测…", + "toolchain.failed": "无法解析:{message}", + "toolchain.invalid": "无法使用:{message}", + "toolchain.javaNotFound": "未找到 JDK。请设置 JAVA_HOME、安装 JDK,或选择 JDK 目录。", + "toolchain.jdk": "JDK {version}", + "toolchain.maven": "Maven {version}", + "toolchain.mavenNotFound": "未找到 Maven。请为项目添加 Maven Wrapper,或选择 Maven 安装目录。", + "toolchain.mode.automatic": "自动", + "toolchain.mode.configured": "已选择", + "toolchain.mode.inherit": "继承项目", + "toolchain.mode.projectJdk": "使用项目 JDK", + "toolchain.source.detected": "本机检测到", + "toolchain.source.javaHome": "来自 JAVA_HOME", + "toolchain.source.mavenWrapper": "项目 Maven Wrapper", + "toolchain.source.path": "来自 PATH", + "toolchain.source.project": "来自项目 .lithe/toolchains", + "ui.archive": "归档", + "ui.backToProjects": "返回项目", + "ui.breadcrumb": "面包屑", + "ui.browse": "浏览", + "ui.cancel": "取消", + "ui.carousel": "轮播", + "ui.choose": "选择", + "ui.clear": "清空", + "ui.close": "关闭", + "ui.closeDialog": "关闭对话框", + "ui.closeToast": "关闭通知", + "ui.confirm": "确认", + "ui.decreaseValue": "减少数值", + "ui.delete": "删除", + "ui.done": "完成", + "ui.edit": "编辑", + "ui.import": "导入", + "ui.increaseValue": "增加数值", + "ui.input": "输入", + "ui.loading": "正在加载", + "ui.more": "更多", + "ui.morePages": "更多页面", + "ui.next": "下一个", + "ui.nextPage": "转到下一页", + "ui.nextSlide": "下一张幻灯片", + "ui.noActionsHere": "此处无任何内容", + "ui.noMatchingOptions": "没有匹配的选项", + "ui.notice": "提示", + "ui.ok": "确定", + "ui.open": "打开", + "ui.pagination": "分页", + "ui.pin": "置顶", + "ui.previous": "上一个", + "ui.previousPage": "转到上一页", + "ui.previousSlide": "上一张幻灯片", + "ui.refresh": "刷新", + "ui.remove": "移除", + "ui.rename": "重命名", + "ui.retry": "重试", + "ui.save": "保存", + "ui.saving": "正在保存...", + "ui.searchPlaceholder": "搜索...", + "ui.selectPlaceholder": "选择...", + "ui.slide": "幻灯片", + "ui.symlink": "符号链接", + "ui.unpin": "取消置顶", + "ui.useCustomValue": "使用 {value}", + "unsavedChanges.doNotSave": "不保存", + "unsavedChanges.messagePrefix": "是否要保存对", + "unsavedChanges.messageSuffix": " 所做的更改?", + "unsavedChanges.title": "未保存的更改", + "unsavedProjectTransition.action.closingThisProject": "关闭此项目", + "unsavedProjectTransition.action.restartingToUpdate": "重启以更新", + "unsavedProjectTransition.action.switchingProjects": "切换项目", + "unsavedProjectTransition.discardAll": "全部放弃", + "unsavedProjectTransition.messageMany": "是否在{action}前保存 {count} 个文件的更改?", + "unsavedProjectTransition.messageOne": "是否在{action}前保存对“{file}”的更改?", + "unsavedProjectTransition.saveAll": "全部保存", + "unsavedProjectTransition.saveBeforeContinuing": "继续前请保存“{file}”。", + "unsavedProjectTransition.saveOrCloseBeforeContinuing": "继续前请保存或关闭 {count} 个未保存文件。", + "update.acknowledgeDownload": "我已了解,打开发布下载页面", + "update.autobuildBuiltAt": "AutoBuild — 构建自 {timestamp}", + "update.autobuildWarning": "AutoBuild 是滚动开发版本,很可能存在大量错误和未完成功能。普通用户请继续使用稳定版。", + "update.available": "有可用更新", + "update.availableVersion": "有可用更新:{version}", + "update.channel": "更新通道", + "update.channelSaveFailed": "无法保存更新通道,请检查应用存储后重试。", + "update.channelStable": "稳定版(推荐)", + "update.confirmAutobuild": "切换到 AutoBuild 滚动通道?", + "update.currentVersion": "当前版本", + "update.detailsTitle": "Lithe {version} 更新", + "update.downloadLater": "稍后下载", + "update.downloading": "正在下载", + "update.failed": "更新失败", + "update.installing": "正在安装", + "update.installingTooltip": "正在安装更新...", + "update.manualDownloadExplanation": "此通道需要手动下载安装程序。请打开所选发布页面,选择 Windows 安装程序,保存工作并关闭 Lithe 后再安装。Lithe 与 Lithe-Dev 分别安装。Lithe-Dev 首次启动且没有自己的用户数据时,会自动复制正式版的设置和工作区,之后两版分别保存设置和缓存。部分账号可能需要重新登录。显示版本号可能更低或不变,无法据此判断滚动构建是否更新。Lithe 尚未检查当前构建是否为最新。", + "update.manualDownloadStatus": "请从所选通道的发布页面下载并安装,尚未检查构建是否为最新。", + "update.manualDownloadTitle": "从发布页面下载安装", + "update.openReleasePage": "打开发布页面", + "update.options": "更新选项", + "update.releaseDate": "发布日期", + "update.releaseNotes": "发行说明", + "update.remindTomorrow": "明天提醒我", + "update.skipVersion": "跳过 {version}", + "update.targetVersion": "目标版本", + "update.updatingProgress": "正在更新 Lithe {percentage}%", + "update.useAutobuild": "我已了解,使用 AutoBuild", + "update.version": "版本", + "update.viewDownloads": "查看发布下载", + "update.viewReleaseNotes": "查看发行说明", + "v0DesignSystem.active": "使用中", + "v0DesignSystem.add": "添加", + "v0DesignSystem.addRegistry": "添加注册表", + "v0DesignSystem.addTitle": "添加 v0 设计系统", + "v0DesignSystem.adding": "正在添加", + "v0DesignSystem.backToCommands": "返回命令", + "v0DesignSystem.backToDesignSystems": "返回 v0 设计系统", + "v0DesignSystem.fallbackName": "设计系统", + "v0DesignSystem.loadPublicFailed": "无法加载公共注册表", + "v0DesignSystem.namePlaceholder": "名称", + "v0DesignSystem.noFound": "未找到设计系统", + "v0DesignSystem.noneDescription": "使用 v0 默认设置", + "v0DesignSystem.noneName": "无设计系统", + "v0DesignSystem.notesPlaceholder": "备注", + "v0DesignSystem.publicCount": "{count} 个公共项", + "v0DesignSystem.refreshPublicRegistries": "刷新公共注册表", + "v0DesignSystem.registryDirectoryStatus": "注册表目录返回状态 {status}", + "v0DesignSystem.registryRequired": "必须填写注册表 URL。", + "v0DesignSystem.removeSelected": "移除所选", + "v0DesignSystem.saveAndUse": "保存并使用", + "v0DesignSystem.saved": "已保存", + "v0DesignSystem.searchPlaceholder": "搜索 v0 设计系统...", + "viewer.clearBrowsingData": "清除浏览数据", + "viewer.closeImageViewer": "关闭图片查看器", + "viewer.copyUrl": "复制 URL", + "viewer.createWebviewFailed": "无法创建 webview。", + "viewer.enterUrl": "输入 URL...", + "viewer.enterUrlPrompt": "输入 URL:", + "viewer.enterValidUrl": "输入有效的 URL", + "viewer.extension": "扩展名", + "viewer.failedToLoadFile": "加载文件失败", + "viewer.failedToLoadPdfDocument": "无法加载 PDF 文档。", + "viewer.failedToReadFile": "读取文件失败:{error}", + "viewer.fileInformation": "文件信息", + "viewer.hexPreview": "十六进制预览", + "viewer.loadingBinaryFile": "正在加载二进制文件", + "viewer.loadingImage": "正在加载图片", + "viewer.loadingPage": "正在加载页面", + "viewer.loadingPdf": "正在加载 PDF", + "viewer.loadingPdfPage": "正在加载页面", + "viewer.localBrowserPage": "本地浏览器页面", + "viewer.localOrPrivateConnection": "本地或专用网络连接", + "viewer.notSecureHttp": "非安全连接(HTTP)", + "viewer.openExternalLinkConfirm": "要打开这个外部链接吗?\n\n{url}", + "viewer.openExternalLinkTitle": "外部链接", + "viewer.openExternalViewer": "在外部查看器中打开", + "viewer.openInBrowser": "在浏览器中打开", + "viewer.openPage": "打开页面", + "viewer.openPageDescription": "输入 URL 以加载网站、本地开发服务器或应用内页面。", + "viewer.openUrl": "打开 URL", + "viewer.page": "页面", + "viewer.path": "路径", + "viewer.readingFile": "正在读取文件", + "viewer.resetZoom": "重置缩放", + "viewer.section": "段", + "viewer.sections": "段", + "viewer.secureConnectionHttps": "安全连接(HTTPS)", + "viewer.size": "大小", + "viewer.stopLoading": "停止加载", + "viewer.type": "类型", + "viewer.wasmVersion": "WASM 版本", + "viewer.webAssemblyModule": "WebAssembly 模块", + "viewer.zoom": "缩放", + "viewer.zoomControls": "缩放控制", + "viewer.zoomIn": "放大", + "viewer.zoomOut": "缩小", + "welcome.addRemote": "添加远程", + "welcome.backToProjects": "返回项目列表", + "welcome.builtIn": "内置", + "welcome.checkUpdates": "检查更新", + "welcome.chooseHowToStart": "选择如何开始...", + "welcome.clone": "克隆", + "welcome.cloneRepository": "克隆仓库", + "welcome.cloneRepositoryDescription": "将已有 Git 仓库克隆到本地新项目。", + "welcome.emptyProject": "空项目", + "welcome.emptyProjectDescription": "创建一个空白文件夹,从零开始。", + "welcome.gitBadge": "Git", + "welcome.newProject": "新建项目", + "welcome.newRemoteConnection": "新建远程连接", + "welcome.nextjsDescription": "包含 App Router、TypeScript、Tailwind CSS、ESLint 和 src 目录。", + "welcome.noRecentProjects": "暂无最近项目", + "welcome.noStarters": "没有匹配 “{query}” 的项目模板。", + "welcome.open": "打开", + "welcome.openFolder": "打开文件夹", + "welcome.openFolderHint": "打开文件夹以开始使用。", + "welcome.openProject": "打开项目", + "welcome.openProjectOrRemote": "打开项目或远程连接", + "welcome.projects": "项目", + "welcome.recentProjects": "最近项目", + "welcome.removeRecent": "从最近项目中移除 {name}", + "welcome.searchProjects": "搜索项目", + "welcome.title": "欢迎使用 Lithe", + "welcome.viteReactDescription": "轻量的 React 和 TypeScript 起步模板。", + "welcome.webApp": "Web 应用", + "window.close": "关闭", + "window.maximize": "最大化", + "window.menu": "菜单", + "window.minimize": "最小化", + "window.restore": "还原", + "windowOpen.cannotOpenPath": "无法打开“{path}”。请检查它是否存在且可访问。", + "windowOpen.pathIsNotFolder": "路径不是文件夹:{path}", + "windowOpen.runCommand": "运行命令", + "windowOpen.runTerminalCommandPrompt": "要打开终端并运行此命令吗?", + "windowOpen.runTerminalCommandTitle": "运行终端命令", + "windowOpen.workingDirectory": "工作目录:{path}", + "workbench.activityViews": "活动视图", + "workbench.changes": "更改", + "workbench.commit": "提交", + "workbench.currentFile": "当前文件", + "workbench.database": "数据库", + "workbench.diagnostics": "诊断", + "workbench.emptyEditorDescription": "外部工具产生的更改会自动显示。", + "workbench.emptyEditorTitle": "选择文件以查看", + "workbench.gitLog": "提交记录", + "workbench.maven": "Maven", + "workbench.moreProjectActions": "更多项目操作", + "workbench.openProject": "打开项目", + "workbench.project": "项目", + "workbench.renderErrorDescription": "可以重试恢复此界面,无需重启 Lithe。", + "workbench.renderErrorTitle": "编辑器界面渲染失败", + "workbench.run": "运行", + "workbench.search": "搜索", + "workbench.searchInFiles": "在文件中搜索...", + "workbench.settings": "设置", + "workbench.springEndpoints": "Spring 接口", + "workbench.terminal": "终端", + "native.inline.languageServerTools": "语言服务器工具", + "native.inline.installDetectAndSelectLspExecutables": "安装、探测并指定 LSP 可执行文件", + "native.inline.languageServer": "语言服务器", + "native.inline.resolvedExecutable": "当前解析结果", + "native.inline.executableFoundNotVerified": "已找到可执行文件(未验证)", + "native.inline.executableVerified": "可执行文件已验证", + "native.inline.executableNotFound": "未找到可执行文件", + "native.inline.customPath": "自定义路径", + "native.inline.useAutomaticDetection": "恢复自动探测", + "native.inline.chooseOrEnterAnAbsolutePath": "选择或输入绝对路径", + "native.inline.chooseExecutable": "选择可执行文件", + "native.inline.save": "保存", + "native.inline.theNextLspSessionWillUseThisPath": "保存后,下一次启动该 LSP 时使用此路径。", + "native.inline.installation": "安装", + "native.inline.homebrewIsPreferredUseTheOfficialDownloadWhenHomebrewOrAFormulaIsUnavailabl": "优先使用 Homebrew;没有可用 formula 或 Homebrew 时,从官方页面下载安装。", + "native.inline.officialDownload": "官方下载", + "native.inline.homebrewWasNotFoundUseTheOfficialDownloadOrChooseAnExecutable": "未找到 Homebrew,请使用官方下载或手动指定路径。", + "native.inline.installingWithHomebrew": "Homebrew 正在安装…", + "native.inline.noBrewFormula": "无 Brew formula", + "native.inline.noLaunchCommandIsConfigured": "Provider 未配置启动命令", + "native.inline.expectedArg1": "等待探测:{arg1}", + "native.inline.installArg1": "Brew 安装 {arg1}", + "native.inline.installedArg1": "安装完成:{arg1}", + "native.inline.onlyLanguagesUsedByThisProjectAreShownChangesAreSavedAutomaticallyForThisPr": "仅显示当前项目使用的语言。开关状态会自动为此项目保存,关闭后会停止语言服务器并释放资源,下次打开仍保持关闭。", + "native.inline.enableArg1LanguageServer": "启用 {arg1} 语言服务器", + "native.inline.retryMavenConfiguration": "重试 Maven 配置", + "native.inline.noConfigurableLanguageServers": "当前项目没有可配置的语言服务器", + "native.inline.settingsAppearHereWhenSupportedSourceLanguagesAreDetected": "识别到支持的源码语言后,会在这里显示对应设置。", + "native.inline.languageServerConfigurationIsDegradedCompatibilitySettingsAreInUse": "语言服务器配置加载异常,当前正在使用兼容配置。", + "native.inline.restartLitheToFinishUninstallingTheOldArg1ThenInstallTheCommunityArg2FromLi": "重启 Lithe 以完成旧版 {arg1} 的卸载,然后在插件市场的 Lithe Store 中安装社区版 {arg1}。", + "native.inline.arg1MovedToLitheStoreAsACommunityPluginAndTheOldOfficialVersionGetsNoUpdate": "{arg1} 已迁移为 Lithe Store 社区插件,旧官方版不再更新。请在插件管理中卸载旧版并重启 Lithe,再安装社区版。", + "native.inline.thisArg1ProjectNeedsArg2InstallItFromLitheStoreInThePluginManagementMarketp": "检测到 {arg1} 项目,请在插件管理的插件市场中,从 Lithe Store 安装 {arg2}。", + "native.inline.arg1IsInstalledRestartLitheBeforeControllingItsLanguageServerHere": "{arg1} 已安装,重启 Lithe 后才能在这里控制语言服务器。", + "native.inline.arg1IsDisabledEnableItFromPluginManagementFirst": "{arg1} 插件已禁用,请先在插件管理中启用。", + "native.inline.thisArg1ProjectNeedsArg2DownloadAndInstallItFromPluginManagement": "检测到 {arg1} 项目,请先在插件管理中下载并安装 {arg2}。", + "native.inline.thisArg1ProjectNeedsArg2WhichWasRemovedRestoreItFromPluginManagementThenRes": "检测到 {arg1} 项目,但 {arg2} 已被移除。请在插件管理中恢复它,然后重启 Lithe。", + "native.inline.arg1WasQuarantinedAfterItsPreviousSessionReEnableItFromPluginManagement": "{arg1} 因上次启动异常被隔离,请先在插件管理中重新启用。", + "native.inline.openPluginManagement": "打开插件管理", + "native.inline.languageServiceConnectedApplyingMavenConfiguration": "语言服务已连接,Maven 配置应用中", + "native.inline.someModulesFailedArg1Arg2LanguageServiceRemainsAvailable": "部分模块失败({arg1}/{arg2}),语言服务仍可用", + "native.inline.starting": "正在启动", + "native.inline.initializingProjectIndex": "正在初始化项目索引", + "native.inline.running": "运行中", + "native.inline.stopping": "正在停止", + "native.inline.off": "已关闭", + "native.inline.startsOnDemandWhenAMatchingFileIsOpened": "按需启动,打开对应文件时运行", + "native.inline.failedToStartCheckTheLspRuntimeJdk": "启动失败,请检查 LSP 运行 JDK", + "native.inline.failedToStartCheckTheLanguageServerConfiguration": "启动失败,请检查语言服务器配置", + "native.inline.javaProjectPreparationFailed": "Java 项目准备失败", + "native.inline.viewProjectPreparationDetails": "查看项目准备详情", + "native.inline.theJavaWorkspaceMustFinishPreparationBeforeLaunchBackgroundIndexingDoesNotB": "Java 工作区准备完成后才能启动。普通后台索引不阻塞运行,编译在运行前执行。", + "native.inline.languageServiceSettingsAndRetry": "语言服务设置与重试", + "native.inline.viewLogs": "查看日志", + "native.inline.javaLaunchesAlwaysContinueAfterBuildFailuresInThisWorkspace": "此工作区的 Java 构建失败将始终继续启动。", + "native.inline.askAgain": "再次询问", + "native.inline.startingJavaService": "正在启动 Java 服务", + "native.inline.importingJavaProjectAndDependencies": "正在导入 Java 项目与依赖", + "native.inline.synchronizingJavaProjectConfiguration": "正在同步 Java 项目配置", + "native.inline.buildingJavaProject": "正在构建 Java 项目", + "native.inline.javaProjectModelReady": "Java 项目模型已就绪", + "native.inline.javaProjectPreparationStopped": "Java 项目准备已停止", + "native.inline.javaBuildDidNotSucceed": "Java 构建未成功", + "native.inline.markerScope": "错误范围", + "native.inline.workspaceMayIncludeUnrelatedModules": "整个工作区(可能包含无关模块)", + "native.inline.requestedLaunchTargetAndDependencies": "请求的启动目标及其依赖", + "native.inline.buildElapsed": "构建耗时", + "native.inline.runAnywayContinuesThisLaunchWithTheResolvedOutputAndDoesNotBuildAgain": "“仍然运行”会使用已经解析出的产物继续本次启动,不会再次构建。", + "native.inline.runAnyway": "仍然运行", + "native.inline.alwaysContinueInThisWorkspace": "对此工作区始终继续", + "native.inline.rebuildJavaIndex": "重建 Java 索引", + "native.inline.openLogs": "查看日志", + "native.inline.cancel": "取消", + "native.inline.theJavaLanguageServiceBuilderFailedInThisSessionSoTheseErrorMarkersMayBeSta": "Java 语言服务的构建器在本次会话中发生过崩溃,当前错误标记可能是残留。你仍可运行已有产物,或重建索引恢复可信状态。", + "native.inline.theJavaLanguageServiceReportedCompilationErrorsWhichMayComeFromDependencies": "Java 语言服务报告了编译错误,但错误也可能来自依赖工程或工作区中的其他模块。", + "native.inline.chooseArg1LanguageServer": "选择 {arg1} 语言服务器", + "native.inline.choose": "选择", + "native.inline.openAProjectThatUsesThisPluginFirst": "请先打开使用该插件的项目", + "native.inline.arg1NeedsThisPluginSBuildSystemWhichThisProjectDoesNotUse": "“{arg1}”需要该插件的构建系统,当前项目中没有找到", + "native.inline.arg1Finished": "“{arg1}”已完成", + "native.inline.openASupportedSourceFile": "打开一个受支持的源码文件", + "native.inline.startingTheArg1LspProcess": "正在启动 {arg1} LSP 进程", + "native.inline.initializingArg1Lsp": "正在初始化 {arg1} LSP", + "native.inline.arg1LanguageServerReady": "{arg1} 语言服务器已就绪", + "native.inline.stoppingArg1Lsp": "正在停止 {arg1} LSP", + "native.inline.arg1IsDeclaredByTheCatalogButNoLspSessionIsRunning": "{arg1} 已由 catalog 声明,但当前没有运行中的 LSP 会话", + "native.inline.arg1LspIsDisabledInThisWorkspace": "{arg1} LSP 已在当前工作区禁用", + "native.inline.arg1LspExitedUnexpectedly": "{arg1} LSP 异常退出", + "native.inline.languageServerStarted": "语言服务器已启动", + "native.inline.noLspSessionIsRunning": "当前没有运行中的 LSP 会话", + "native.inline.languageServerDiagnosticsCleared": "语言服务器诊断已清空", + "native.inline.openAProjectBeforeRebuildingTheJavaIndex": "请先打开一个项目", + "native.inline.javaIndexClearedReopenAJavaFileToRebuild": "Java 索引已清除,重新打开 Java 文件时将自动重建", + "native.inline.failedToClearTheJavaIndex": "清除 Java 索引失败", + "native.inline.executableRequired": "请选择语言服务器可执行文件。", + "native.inline.executableInvalid": "该路径不是可执行文件:{path}", + "native.inline.executableValidationFailed": "语言服务器无法运行:{path}\n{message}", + "native.inline.homebrewUnavailable": "Lithe 无法找到 Homebrew。", + "native.inline.homebrewUnsupported": "{provider} 没有已验证的 Homebrew formula。", + "aiCommit.exampleMessage": "添加编辑器内存占用指示器", + "backend.unavailable": "待开发", + "agent.reportedLabels.approvalMode": "权限模式", + "agent.reportedLabels.alwaysAskToEditExternalFilesAndUseTheInternet": "修改工作区外文件或访问网络时先请求批准。", + "agent.reportedLabels.onlyAskForActionsDetectedAsPotentiallyUnsafe": "自动审批,仅在检测到潜在风险时请求你的确认。", + "agent.reportedLabels.unrestrictedAccessToTheInternetAndAnyFileOnYourComputer": "允许访问网络和电脑上的所有文件。", + "agent.reportedLabels.collaborationMode": "协作模式", + "agent.reportedLabels.planBeforeMakingChanges": "先制定计划,再进行修改。", + "agent.reportedLabels.reasoningEffort": "推理强度", + "agent.reportedLabels.fastMode": "快速模式", + "agent.reportedLabels.model": "模型", + "aiCommit.languageEnglish": "English", + "aiCommit.languageChinese": "简体中文", + "native.aSecureConnectionToTheMcpRegistryCouldNotBeEstablishedCheckTheProxyOrRetryDirectly": "无法与 MCP Registry 建立安全连接。请检查代理,或使用直连重试。", + "native.aSkillHasAnUnknownScopeAndCannotBeClassified": "有技能的作用范围未知,暂时无法分类。", + "native.aSkillWasOmittedBecauseItsNativeMetadataIsIncomplete": "有技能的原生信息不完整,未在列表中显示。", + "native.aSkillWithThisDirectoryNameAlreadyExistsExistingSkillsAreNeverOverwritten": "同名技能目录已存在,不会覆盖已有技能。", + "native.addAtLeastOneAbsoluteAllowedFolderPathAfterTheFilesystemPackageArgument": "请在 Filesystem 包参数后至少添加一个允许访问的文件夹绝对路径。", + "native.addAtLeastOneAbsoluteFolderPathAfterThePackageArgumentOnePerLineFilesystemCanReadAndWriteOnlyInTheFoldersYouAllowRequiresNodeJs24OrLater": "在包参数后至少添加一个文件夹的绝对路径,每行一个。Filesystem 只可读写你允许的文件夹。需要 Node.js 24 或更高版本。", + "native.agentSkills": "Agent 技能", + "native.agents2": "Agent", + "native.allSources": "全部来源", + "native.anotherInstructionSaveIsRunningTryAgain": "另一项指令保存正在进行,请重试。", + "native.arg1LinesOfItsOwn": "{arg1} 行自有内容", + "native.arg1Tools": "{arg1} 个工具", + "native.askQuestionsAboutPublicGithubRepositoriesAndReadTheirGeneratedDocumentation": "向公开 GitHub 仓库提问,阅读自动生成的项目文档。", + "native.cannotAccessTheGlobalInstructionFileCheckItsPermissionsAndTryAgain": "无法访问全局指令文件,请检查文件权限后重试。", + "native.cannotLocateTheAgentUserDirectory": "无法定位 Agent 的用户目录。", + "native.checkCommandLineQuotesBeforeSaving": "请检查命令行中的引号后再保存。", + "native.checkTheMcpCommandUrlAndHeaders": "请检查 MCP 命令、URL 和请求头。", + "native.chooseWhetherToEnableTheSkill": "请选择启用或停用技能。", + "native.claudeCodeHasMcpToolRestrictionsUpdateItsNativeRulesBeforeRenamingAServerSoDisabledToolsDoNotBecomeAvailableUnderANewName": "Claude Code 存在 MCP 工具限制。重命名服务器前请先更新其原生规则,以免已禁用的工具在新名称下重新可用。", + "native.claudePluginSkillsMustBeManagedThroughClaudeSPluginSettings": "Claude 插件技能需通过 Claude 的插件设置管理。", + "native.claudeSavesVisibilityByNameGlobalSkillsUseUserSettingsProjectSkillsUseProjectLocalSettingsProjectAndManagedSettingsMayOverrideGlobalSettingsSkillsWithTheSameNameShareThisSetting": "Claude 按名称保存可见性:全局技能写入用户配置,项目技能写入项目本地配置。项目及受管配置可能覆盖全局设置;同名技能共用此设置。", + "native.claudeSkillSettingsCouldNotBeReadFixInvalidSettingsAndRefreshBeforeToggling": "无法读取 Claude 技能配置,请修复无效配置后刷新再操作开关。", + "native.codexIsUsingAgentsOverrideMdClearingItRestoresTheBaseAgentsMdInstructions": "Codex 正在使用 AGENTS.override.md。清空后将恢复基础 AGENTS.md 中的指令。", + "native.codexReportedSkillDiscoveryErrorsCheckTheAffectedSkillMdFilesAndRefresh": "Codex 报告了技能发现错误,请检查 SKILL.md 文件并刷新。", + "native.codexReportsItsDiscoveredSkillsClaudeListsFilesInTheSelectedProjectAndUserDirectoriesInheritedAndPluginSkillsMayAlsoBeAvailableInItsSessions": "Codex 显示官方发现的技能;Claude 显示所选项目和用户目录中的技能文件。Claude 会话还可能包含继承目录和插件提供的技能。", + "native.codexSavesThisSettingByPathForAllProjectsUsingThisSkill": "Codex 按路径保存此设置,影响使用该技能路径的所有项目。", + "native.codexSkillDiscoveryFailedCheckTheCliAndRetry": "Codex 技能发现失败,请检查 CLI 后重试。", + "native.codexSkillSettingsCouldNotBeConfirmedRefreshToCheckTheCurrentState": "无法确认 Codex 技能设置,请刷新查看当前状态。", + "native.configure2": "配置…", + "native.copyPath2": "复制路径", + "native.couldNotImportMcpJsonCheckTheMcpserversObjectAndTryAgain": "无法导入 MCP JSON。请检查 mcpServers 对象后重试。", + "native.couldNotReadTheCommandLine": "无法读取命令行。", + "native.couldNotStartTheMcpCommandCheckItsExecutableAndRuntime": "无法启动 MCP 命令,请检查程序及所需运行时。", + "native.createSkill": "创建技能", + "native.currentLibraryDocumentationAndCodeExamplesForCodingAgents": "为编程 Agent 提供最新的库文档和代码示例。", + "native.defaultInstructions": "默认指令", + "native.detectAgain": "重新检测", + "native.detectTools": "检测工具", + "native.detectionConnectsToThisServerAndListsToolMetadataWithoutCallingAnyToolLocalCommandsMayRunCodeOrDownloadPackagesResultsDescribeThisCheckNotTheAvailabilityOfAnExistingAgentSessionToolSwitchesApplyToTheSelectedAgentSUserConfigurationProjectAndManagedPoliciesCanStillRestrictAccessStartANewAgentSessionAfterChanges": "检测会连接服务器并读取工具信息,不会调用工具。本地命令可能执行代码或下载软件包。结果仅代表此次检测,不代表已有 Agent 会话的状态。工具开关作用于所选 Agent 的用户配置,项目或受管规则仍可能限制访问。修改后请新建 Agent 会话。", + "native.detectionStartsTheConfiguredExecutableAndMayDownloadPackagesContinueOnlyIfYouTrustThisServerLitheWillListToolsThenStopTheProbeProcess": "检测会运行配置的程序,并可能下载软件包。请仅对信任的服务器继续。Lithe 会读取工具列表,然后停止检测进程。", + "native.directConnection": "直连", + "native.directRetryAppliesOnlyToPublicMcpRegistrySearchesOnThisPageSystemProxySettingsAndAgentServerConnectionsAreUnchangedTlsCertificateValidationRemainsEnabled": "直连仅用于此页面的公开 MCP Registry 查询,不修改系统代理或 Agent 的服务器连接设置,并继续校验 TLS 证书。", + "native.directoryImportsPreserveScriptsTemplatesAndOtherSupportingFilesExistingSkillsAreNeverOverwritten": "导入目录会保留脚本、模板等配套文件,不会覆盖已有技能。", + "native.disabledInAgent": "Agent 已禁用", + "native.discardDraftAndReload": "放弃草稿并重新加载?", + "native.discoverMcpServers": "发现 MCP 服务器", + "native.editMcpServer": "编辑 MCP 服务器", + "native.empty": "空", + "native.enableSkill": "启用技能", + "native.enabledInAgent": "Agent 已启用", + "native.enterADescriptionAndInstructions": "请填写技能说明和正文。", + "native.enterAnAuthorizationHeaderWithBearerAndYourGithubToken": "请填写 Authorization 请求头,内容为 Bearer 加空格和 GitHub 令牌。", + "native.enterTheExecutableAndArgumentsAsInATerminalQuotePathsWithSpacesLithePassesExactArgumentsWithoutRunningAShellPipesAndRedirectsAreNotEvaluated": "按终端形式输入程序和参数,含空格的路径请加引号。Lithe 直接传递参数,不运行 Shell,也不解释管道或重定向。", + "native.featured": "精选", + "native.featuredAndTheOfficialMcpRegistry": "精选,以及官方 MCP Registry", + "native.featuredServersShipWithLitheAndWorkOfflineSearchingAlsoChecksTheOfficialMcpRegistryListingIsNotASecurityReviewAddingOpensAConfigurationToReviewPackagesAreNotDownloadedOrRunFollowThePublisherSDocumentationForAuthenticationAndOtherRequirements": "精选服务器随 Lithe 提供,无需联网即可浏览。搜索时也会查询官方 MCP Registry。收录不代表安全审核。添加时先检查配置,不会下载或运行服务器包;认证等要求请参考发布者文档。", + "native.fetchWebPagesAndConvertTheirContentToMarkdownRequiresUvAndPython310OrLater": "抓取网页并转换为 Markdown。需要 uv 和 Python 3.10 或更高版本。", + "native.fileInventoryOnlyClaudeDeterminesWhetherThisSkillIsAvailableInASession": "此处仅列出技能文件。技能是否在会话中可用,由 Claude 决定。", + "native.githubRequiresAPersonalAccessTokenSetTheAuthorizationHeaderToBearerFollowedByYourTokenItIsStoredInTheAgentConfigurationFile": "GitHub 需要个人访问令牌。请将 Authorization 请求头填写为 Bearer 加空格和令牌。令牌会保存在 Agent 配置文件中。", + "native.globalInstructions": "全局指令", + "native.globalInstructionsApplyToAllProjectsUsingThisAgentEachAgentKeepsItsOwnNativeFile": "全局指令适用于使用此 Agent 的所有项目。每个 Agent 使用各自的原生文件。", + "native.globalInstructionsAreAvailableForCodexAndClaudeCode": "Codex 和 Claude Code 支持全局指令。", + "native.globalInstructionsChangedOutsideThisEditorYourDraftIsKeptReloadBeforeSaving": "全局指令已被其他程序修改。草稿已保留,请重新加载后再保存。", + "native.globalInstructionsMustBeNoLargerThan1Mib": "全局指令不能超过 1 MiB。", + "native.globalSkills": "全局技能", + "native.importARegularFileOrDirectoryInsteadOfASymbolicLink": "请导入普通文件或目录,而不是符号链接。", + "native.importJson": "导入 JSON", + "native.importMcpConfiguration": "导入 MCP 配置", + "native.importSkill": "导入技能", + "native.importSkillDirectory": "导入技能目录", + "native.importSkillMd": "导入 SKILL.md", + "native.importSource": "导入来源", + "native.inspectChromePagesConsoleMessagesNetworkRequestsAndPerformance": "检查 Chrome 页面、控制台消息、网络请求和性能。", + "native.installCodexCliToDiscoverItsSkills": "请先安装 Codex CLI,再查看它发现的技能。", + "native.instructionAgent": "指令所属 Agent", + "native.instructionSynchronizationWasCancelled": "指令同步已取消。", + "native.instructions": "指令", + "native.instructionsSaved": "指令已保存。", + "native.invalidMcpToolConfiguration": "MCP 工具配置无效", + "native.invalidSharedInstructionRequest": "共享指令请求无效。", + "native.keepAPersistentKnowledgeGraphBetweenConversationsRequiresNodeJs24OrLater": "跨对话保留知识图谱。需要 Node.js 24 或更高版本。", + "native.legacySseProbingIsUnavailableUseThePublisherSStreamableHttpEndpoint": "暂不支持检测旧式 SSE 连接,请使用发布者提供的 Streamable HTTP 地址。", + "native.loadMore2": "加载更多", + "native.manageSkills": "管理技能…", + "native.managed": "托管", + "native.mcpConnectionFailedCheckTheServerNetworkAndAuthentication": "MCP 连接失败,请检查服务器、网络及认证。", + "native.mcpDetectionTimedOutAfter30Seconds": "MCP 检测已超时(30 秒)。", + "native.mcpDetectionWasCancelled": "MCP 检测已取消。", + "native.mcpJson": "MCP JSON", + "native.mcpToolMetadataExceededTheInspectionLimit": "MCP 工具信息超过检测上限。", + "native.nativeInstructionFile": "原生指令文件", + "native.newMcpServer": "新建 MCP 服务器", + "native.newSkill": "新建技能", + "native.noMatchingMcpServers": "没有匹配的 MCP 服务器", + "native.noMatchingSkills": "没有匹配的技能。", + "native.noSkillsInThisScope": "当前分类暂无技能。", + "native.notDetected": "未检测", + "native.officialMcpRegistry": "官方 MCP Registry", + "native.openAProjectToManageItsSkills": "请先打开项目,再管理项目技能。", + "native.openPagesClickTypeAndInspectABrowserThroughItsAccessibilityTree": "打开网页、点击、输入,并通过无障碍树读取浏览器页面。", + "native.openSkillMd": "打开 SKILL.md", + "native.pasteAJsonObjectContainingMcpservers": "请粘贴包含 mcpServers 的 JSON 对象。", + "native.pasteStandardJsonWithAnMcpserversObjectChooseAServerToReviewBeforeSavingOnlyTheEditorSSupportedCommandArgumentsEnvironmentUrlAndHeaderFieldsAreImportedAuthenticationAndClientSpecificOptionsMustBeConfiguredSeparatelyImportingDoesNotTakeOwnershipOfAnExistingEntry": "粘贴包含 mcpServers 的标准 JSON,选择服务器并检查后再保存。只导入编辑器支持的命令、参数、环境变量、URL 和请求头;认证及客户端专有选项需另行配置。导入不会接管已有条目。", + "native.plugin": "插件", + "native.previewImport": "预览导入", + "native.projectSkills": "项目技能", + "native.publicDirectoryMetadataIsSuppliedByTheOfficialMcpRegistryListingIsNotAnEndorsementOrASecurityReviewAddingOpensAnEditableConfigurationLitheDoesNotDownloadOrRunServerPackagesServersWithComplexInputsMustBeConfiguredUsingThePublisherSDocumentation": "目录信息来自官方 MCP Registry,收录不代表推荐或安全审查。添加时先打开配置编辑器,Lithe 不会下载或运行服务器包。需要复杂参数的服务器请参考发布者文档配置。", + "native.readWriteAndSearchFilesOnlyInTheDirectoriesYouAllowRequiresNodeJs24OrLater": "读写、搜索你允许的目录中的文件。需要 Node.js 24 或更高版本。", + "native.refreshSkills": "刷新技能", + "native.registryWebsite": "Registry 网站", + "native.reloadFromDisk": "从磁盘重新加载", + "native.reloadThisAgentBeforeSynchronizingInstructions": "请重新加载此 Agent 的指令后再同步。", + "native.reloadToolSettings": "重新加载工具设置", + "native.reloadingReplacesThisDraftWithTheCurrentFileContents": "重新加载将使用磁盘文件的当前内容替换此草稿。", + "native.removeFromLibrary": "从资源库移除", + "native.retryDirectly": "直连重试", + "native.reviewConfiguration": "检查配置", + "native.runAndDetect": "运行并检测", + "native.runThisMcpCommand": "运行此 MCP 命令?", + "native.saveInstructions": "保存指令", + "native.saveSharedInstructions": "保存共享指令", + "native.searchMcpServers": "搜索 MCP 服务器", + "native.searchMcpServersGithubBrowsersDocumentation": "搜索 MCP 服务器 — GitHub、浏览器、文档…", + "native.searchOfficialMicrosoftAndAzureDocumentationAndCodeSamples": "搜索微软与 Azure 的官方文档和代码示例。", + "native.searchSkills": "搜索技能", + "native.searchTheOfficialMcpRegistry": "搜索官方 MCP Registry", + "native.searchTheWebAndCodeAndRetrievePageContentForCodingAgents": "为编程 Agent 搜索网页和代码,获取页面内容。", + "native.selectSkillMdOrItsContainingDirectory": "请选择 SKILL.md 文件或包含该文件的目录。", + "native.serverTools": "服务器工具", + "native.sharedInstructions": "共享指令", + "native.sharedInstructionsCannotContainLitheSynchronizationMarkers": "共享指令正文不能包含 Lithe 同步标记。", + "native.sharedInstructionsChangedOutsideThisEditorReloadBeforeSaving": "共享指令已被其他程序修改,请重新加载后再保存。", + "native.sharedInstructionsSaved": "共享指令已保存。", + "native.showingAvailableMatches": "仍显示可用的匹配项。", + "native.skillActions": "技能操作", + "native.skillFiles": "技能文件", + "native.skillImportsCannotContainSymbolicLinksOrSpecialFiles": "导入的技能不能包含符号链接或特殊文件。", + "native.skillInstructions": "技能正文", + "native.skillMdExceedsThe128KibLimit": "SKILL.md 超过了 128 KiB 的大小限制。", + "native.skillMdFrontmatterIsNotClosed": "SKILL.md 的文件头缺少结束标记。", + "native.skillMdHasInvalidYamlFrontmatter": "SKILL.md 的 YAML 文件头格式不正确。", + "native.skillMdMustUseUtf8Text": "SKILL.md 必须使用 UTF-8 编码。", + "native.skillMdNeedsANameAndDescription": "SKILL.md 需要技能名称和说明。", + "native.skillMdNeedsInstructionsAfterItsFrontmatter": "请在 SKILL.md 的文件头后添加技能正文。", + "native.skillMdNeedsYamlFrontmatter": "SKILL.md 需要 YAML 格式的文件头。", + "native.skillNameLowercaseWithHyphens": "技能名称(小写字母与连字符)", + "native.skillProject": "当前项目", + "native.skillSavedRefreshOrReconnectTheAgentIfItDoesNotAppearInTheConversation": "技能已保存。如果对话中未显示,请刷新或重新连接 Agent。", + "native.skillSettingSaved": "技能设置已保存。", + "native.skillSettingsAreBusyRetryAfterTheOtherChangeFinishes": "技能配置正在被修改,请等待其他修改完成后重试。", + "native.skillSettingsChangedElsewhereRefreshAndRetry": "技能配置已在其他位置更改,请刷新后重试。", + "native.skillSettingsCouldNotBeUpdatedCheckTheSettingsFileAndRefresh": "无法更新技能设置,请检查配置文件后刷新。", + "native.skillSource": "技能来源", + "native.skillTargetAgent": "适用 Agent", + "native.skills": "技能", + "native.startANewConversationAfterSavingToLoadTheUpdatedInstructionsProjectInstructionsStillApply": "保存后请新建对话以加载更新后的指令。项目指令仍然有效。", + "native.stateUnknown": "状态未知", + "native.switchesSaveNativeAgentSettingsReconnectExistingConversationsToRefreshAvailableSkills": "开关保存到 Agent 原生配置。已有对话需重新连接以刷新可用技能。", + "native.syncAgain": "重新同步", + "native.syncNeeded": "待同步", + "native.thatSkillNameIsReservedByClaudeChooseADifferentName": "该技能名称为 Claude 保留,请使用其他名称。", + "native.theAgentDidNotApplyTheSkillSettingRefreshAndCheckProjectOrManagedConfiguration": "Agent 未应用此技能设置,请刷新并检查项目或受管配置。", + "native.theAgentUserDirectoryMustBeAnAbsolutePathCheckCodexHomeOrClaudeConfigDir": "Agent 用户目录必须为绝对路径,请检查 CODEX_HOME 或 CLAUDE_CONFIG_DIR。", + "native.theFeaturedMcpCatalogCouldNotBeLoaded": "无法加载内置 MCP 精选目录。", + "native.theFileWillBeCreatedWhenYouSave": "保存时将创建此文件。", + "native.theGlobalInstructionFileMustUseUtf8Encoding": "全局指令文件必须使用 UTF-8 编码。", + "native.theInstructionDirectoryChangedReloadBeforeSaving": "指令目录已变更,请重新加载后再保存。", + "native.theInstructionPathMustBeARegularFileNotASymbolicLinkOrDirectory": "指令路径必须为普通文件,不能为符号链接或目录。", + "native.theLitheSharedInstructionMarkersWereChangedRepairTheNativeFileBeforeSyncing": "Lithe 共享指令标记已被修改,请先修复原生文件再同步。", + "native.theMcpRegistryCouldNotBeLoadedTryAgainOrAddAServerManually": "无法加载 MCP Registry。请重试,或手动添加服务器。", + "native.theMcpRegistryIsUnreachableCheckTheNetworkOrProxy": "无法连接 MCP Registry,请检查网络或代理。", + "native.theMcpRegistryRequestTimedOutTryAgain": "MCP Registry 请求超时,请重试。", + "native.theSharedInstructionDirectoryMustBeAbsolute": "共享指令目录必须为绝对路径。", + "native.theSkillCouldNotBePublishedCheckPermissionsAndExistingSkillNames": "无法保存技能,请检查目录权限以及是否存在同名技能。", + "native.theSkillDestinationContainsASymbolicLinkOrNonDirectoryChooseARegularNativeSkillDirectory": "技能目标位置包含符号链接或非目录项目,请使用普通技能目录。", + "native.theSkillDocumentIsUnavailableRefreshAndRetry": "技能文件当前不可用,请刷新后重试。", + "native.theSkillFileCouldNotBeOpened": "无法打开技能文件。", + "native.theSkillFileCouldNotBeRead": "无法读取技能文件。", + "native.theSkillIsNoLongerAvailableInThisScopeRefreshAndRetry": "此技能已不在当前范围内,请刷新后重试。", + "native.theSwitchControlsTheLitheSharedBlockNotTheAgentOrItsOwnInstructionsSaveSharedChangesBeforeSwitchingOpenARowToEditTheCompleteNativeFile": "开关控制是否使用 Lithe 共享区块,不会停用 Agent 或它的自有指令。请先保存共享修改再操作开关。点击行可编辑完整原生文件。", + "native.thisEditorShowsTheCompleteNativeFileIncludingAnyLitheSharedBlockEditingSharedTextHereCanLeaveItOutOfSyncUseTheSharedEditorToUpdateAllEnabledAgents": "此处显示包含 Lithe 共享区块在内的完整原生文件。在此修改共享部分可能导致不同步;请在共享编辑器中更新所有已开启的 Agent。", + "native.thisServerReportedNoTools": "服务器未提供工具。", + "native.thisToolIsRestrictedOutsideLitheEditTheNativeAgentPolicyToEnableIt": "该工具已被 Lithe 之外的规则限制,请编辑 Agent 原生规则后再启用。", + "native.unsavedChanges": "有未保存的修改", + "native.useSharedInstructions": "使用共享指令", + "native.useSystemProxy": "使用系统代理", + "native.useUpTo64LowercaseLettersDigitsAndSingleHyphensForTheSkillName": "技能名称最多 64 个字符,请使用小写字母、数字和单个连字符。", + "native.viewSkill": "查看", + "native.whenShouldThisSkillBeUsed": "什么情况下应使用这个技能?", + "native.workWithGithubRepositoriesIssuesPullRequestsAndActionsAPersonalAccessTokenIsRequired": "访问 GitHub 仓库、Issue、PR 和 Actions。需要个人访问令牌。", + "native.writeSharedInstructionsOnceThenEnableThemForEachAgentLitheOnlyChangesItsMarkedBlockTheRestOfEachNativeFileStaysYoursStartANewConversationToLoadChangesProjectInstructionsStillApply": "共享指令只需写一次,再为各 Agent 开启。Lithe 只修改标记区块,保留原生文件的其他内容。新建对话后加载更新,项目指令仍然生效。", + "native.arg1DoesNotProvideArg2BecauseArg3AlreadyDoes": "{arg3} 已提供 {arg2},因此不再使用 {arg1} 提供的版本。", + "native.arg1FilesHaveConflicts": "{arg1} 个文件存在冲突。", + "native.arg1FilesSelected": "已选择 {arg1} 个文件", + "native.arg1HoldsTheMetadataOfSeveralVersionControlSystemsChooseTheOneThatManagesIt": "{arg1} 包含多个版本控制系统的元数据。请选择管理它的版本控制系统。", + "native.arg1ProviderInsideThePlugin": "{arg1} Provider(插件内置)", + "native.arg1ProviderInstalledOnThisMac": "{arg1} Provider(使用本机已安装的程序)", + "native.arg1ProviderRunsWithArg2": "{arg1} Provider(使用 {arg2} 运行)", + "native.authenticationFailedLitheCannotAskForCredentialsYetSignInOnceWithTheCommandLineClientSoItStoresThemThenTryAgain": "认证失败。Lithe 暂时无法询问凭据:请先用命令行客户端登录一次并保存凭据,然后重试。", + "native.baseRevision": "基线版本", + "native.binaryFilesCannotBeCompared": "无法比较二进制文件。", + "native.cleanUpWorkingCopy": "清理工作副本", + "native.cleaningUp": "正在清理…", + "native.clientArg1": "客户端:{arg1}", + "native.committed2": "已提交。", + "native.committedRevisionArg1": "已提交修订版本 {arg1}。", + "native.committing": "正在提交…", + "native.detectAgain2": "重新识别", + "native.detectingWorkingCopies": "正在识别工作副本…", + "native.historyOfArg1": "{arg1} 的历史", + "native.ignored": "已忽略", + "native.litheCouldNotEvaluateTheVersionControlDataArg1": "Lithe 无法解析版本控制数据:{arg1}", + "native.localEditsToTheseFilesAreLost": "这些文件的本地修改将会丢失。", + "native.lockedByAnotherUser": "已被其他用户锁定", + "native.lockedInThisWorkingCopy": "已在此工作副本中锁定", + "native.markAsResolved": "标记为已解决", + "native.markingAsResolved": "正在标记为已解决…", + "native.mayConnectToTheRepositoryServer": "可能连接仓库服务器", + "native.missing": "缺失", + "native.moreActions2": "更多操作", + "native.morePathsChangedThanAreShown": "更改的路径多于此处显示的数量。", + "native.movedFromArg1": "从 {arg1} 移动", + "native.moving": "正在移动…", + "native.noArg1WorkingCopyWasDetectedInThisWorkspace": "此工作区中没有识别到 {arg1} 工作副本。", + "native.noRevisions": "没有修订版本", + "native.openFile2": "打开文件", + "native.propertiesModified": "属性已修改", + "native.readingChanges": "正在读取更改…", + "native.replaced": "已替换", + "native.restartLitheToUseArg1": "重启 Lithe 后即可使用 {arg1}。", + "native.rollBackTheSelectedChanges": "回滚选中的更改?", + "native.rollingBack": "正在回滚…", + "native.showAll": "显示全部", + "native.showArg1Diff": "显示 {arg1} 差异", + "native.showHistory": "显示历史", + "native.switched": "已切换", + "native.theActionStoppedOnConflictedFilesResolveThemThenTryAgain": "操作因冲突文件而停止。请先解决冲突,然后重试。", + "native.theFileIsTooLargeToCompare": "文件过大,无法比较。", + "native.theVersionControlClientCouldNotBeRunCheckThatItIsInstalled": "无法运行版本控制客户端。请检查它是否已安装。", + "native.theVersionControlProviderFailedToStart": "版本控制 Provider 启动失败。", + "native.theVersionControlProviderStoppedBeforeItWasReady": "版本控制 Provider 在就绪前已停止。", + "native.theWorkingCopyIsLockedByAnInterruptedActionRunCleanupThenTryAgain": "工作副本被中断的操作锁定。请先执行清理,然后重试。", + "native.theWorkingCopyIsOutOfDateUpdateItThenCommitAgain": "工作副本已过期。请先更新,再重新提交。", + "native.theWorkingCopyWasCleanedUp": "工作副本已清理。", + "native.theWorkspace": "工作区", + "native.theWorkspaceIsTooLargeToSearchCompletelySomeWorkingCopiesMayBeMissing": "工作区过大,无法完整搜索;可能有工作副本未被识别。", + "native.thereAreTooManyChangesToListSomeAreNotShown": "更改过多,部分未显示。", + "native.thisVersionControlProviderDoesNotSupportThisAction": "此版本控制 Provider 不支持该操作。", + "native.thisWorkingCopyFormatIsNotSupportedUpgradeItWithTheCommandLineClient": "不支持此工作副本格式。请用命令行客户端升级。", + "native.thisWorkingCopyIsNoLongerAvailableRefreshAndTryAgain": "此工作副本已不可用。请刷新后重试。", + "native.treeConflict": "树冲突", + "native.typeChanged": "类型已更改", + "native.unversioned": "未版本控制", + "native.updated2": "已更新。", + "native.updatedToRevisionArg1": "已更新到修订版本 {arg1}。", + "native.updating": "正在更新…", + "native.versionControlProvider": "版本控制 Provider", + "native.waitForTheRunningVersionControlActionToFinish": "请等待正在执行的版本控制操作完成。", + "native.workingCopy": "工作副本", + "hooks.add": "添加 Hook", + "hooks.agents": "Agent", + "hooks.allAgents": "全部", + "hooks.codexApproval": "Codex 需要启用 Hooks 并在原生 /hooks 页面中信任命令。Lithe 不授予信任,编辑命令后需要重新批准。", + "hooks.command": "命令", + "hooks.commandHint": "由 Agent 在会话目录中运行,并通过标准输入传入事件 JSON。", + "hooks.delete": "删除", + "hooks.deleteHint": "仅移除 Lithe 写入的原生条目,保留其他 Hook。", + "hooks.deleteTitle": "删除 Hook?", + "hooks.description": "描述(可选,最多 200 字符)", + "hooks.descriptionHint": "可选,最多 200 字符,仅保存在 Hook 资源库中。", + "hooks.descriptionLabel": "描述", + "hooks.descriptionPlaceholder": "例如:一轮结束时提醒我", + "hooks.disabledAll": "Claude Code 已关闭所有 Hook。", + "hooks.edit": "编辑", + "hooks.editTitle": "编辑 Hook", + "hooks.editor": "Hook", + "hooks.empty": "还没有 Hook。", + "hooks.enabled": "启用 Hook", + "hooks.event.prompt": "提交提示词时", + "hooks.intro": "Hook 在 Agent 生命周期事件中执行命令,使用会话目录并通过标准输入接收 JSON。更改对新会话生效。", + "hooks.matcher": "工具筛选", + "hooks.matcher.all": "所有工具", + "hooks.matcher.edit": "文件编辑", + "hooks.matcher.shell": "Shell 命令", + "hooks.name": "名称", + "hooks.nameHint": "使用 1–80 个字母、数字、连字符或下划线,名称不能重复。", + "hooks.namePlaceholder": "例如 notify", + "hooks.needsSync": "待同步", + "hooks.newTitle": "新 Hook", + "hooks.pendingRemoval": "等待移除", + "hooks.reload": "从磁盘重新加载", + "hooks.retry": "重试 Hook 同步", + "hooks.save": "保存 Hook", + "hooks.search": "搜索 Hook", + "hooks.timeout": "超时(秒)", + "hooks.timeoutHint": "留空以沿用 Agent 默认值,或输入 1–86400 的整数。", + "hooks.timeoutPlaceholder": "沿用 Agent 默认值", + "hooks.when": "何时运行", + "keybindings.commands.editor.cursorRedo.description": "恢复下一个光标位置和选区,保留代码修改", + "keybindings.commands.editor.cursorRedo.title": "光标重做", + "keybindings.commands.editor.cursorUndo.description": "回到上一个光标位置和选区,保留代码修改", + "keybindings.commands.editor.cursorUndo.title": "光标撤销", + "native.aBasicProjectWithAFreeStructure": "具有自由结构的基本项目。", + "native.aHookWithThisNameAlreadyExists": "已存在同名 Hook。", + "native.aHostNameAnIPAddressOrAHostAliasFromSshConfigSettingsFromThatFileSuchAsProxyJumpAndIdentit": "主机名、IP 地址,或 ~/.ssh/config 中的 Host 别名。该文件里的 ProxyJump、IdentityFile 等设置仍然生效。", + "native.aJavaProjectWithNoBuildToolMavenOrGradle": "不使用构建工具,或使用 Maven、Gradle 的 Java 项目。", + "native.aManagedHookWasEditedOutsideLitheRestoreOrRemoveThatNativeEntryBeforeSynchronizing": "Lithe 管理的 Hook 已在外部修改,请先恢复或移除该原生条目再同步。", + "native.aProjectGeneratedByMavenFromAnArchetype": "由 Maven 根据原型(Archetype)生成的项目。", + "native.aRemovedHookCannotBeSaved": "无法保存已移除的 Hook。", + "native.aSpringBootProjectFromSpringInitializr": "由 Spring Initializr 生成的 Spring Boot 项目。", + "native.acceptEdits": "接受编辑", + "native.addHook": "添加 Hook", + "native.addSampleCode": "添加示例代码", + "native.address": "地址", + "native.advancedSettings": "高级设置", + "native.afterAToolRuns": "工具运行后", + "native.agentDefault": "沿用 Agent 默认值", + "native.agentHooks": "Agent 钩子", + "native.allTools": "所有工具", + "native.alreadyExistsChooseAnotherNameOrLocation": "{arg1} 已存在,请更换名称或位置。", + "native.anIdenticalNativeHookAlreadyExistsLitheKeptItUnchanged": "已存在相同的原生 Hook,Lithe 保留了该条目。", + "native.anotherHookUpdateIsRunningTryAgain": "另一个 Hook 更新正在进行,请稍后重试。", + "native.answeredWithStatusLld": "{arg1} 返回状态 {arg2}。{arg3}", + "native.archetype": "原型", + "native.archetypeArtifactId": "原型 ArtifactId", + "native.archetypeGroupId": "原型 GroupId", + "native.archetypeVersion": "原型版本", + "native.archiveConversation": "归档会话", + "native.artifact": "工件", + "native.asOfUpdatedOnlyWhileReplying": "截至 {arg1},仅在回复时更新。", + "native.authentication": "认证方式", + "native.auto": "自动", + "native.automaticTriesYourSSHAgentAndKeysFirstThenAsksForAPasswordLitheAsksInADialogWheneverTheSer": "“自动”会先尝试 SSH Agent 和密钥,再询问密码。服务器需要回答时(例如密码、私钥口令或验证码),Lithe 会弹窗询问。", + "native.backgroundTaskEnded": "后台任务已结束", + "native.backgroundTaskFinished": "后台任务已完成", + "native.beforeAToolRuns": "工具运行前", + "native.browse": "浏览…", + "native.buildSystemX797f5735e5": "构建系统", + "native.bypassPermissions": "跳过权限确认", + "native.cannotAccessHookConfigurationCheckTheFilePermissionsAndAgentProfile": "无法访问 Hook 配置,请检查文件权限和 Agent 配置目录。", + "native.cannotAccessTheHookLibraryCheckItsPermissions": "无法访问 Hook 资源库,请检查权限。", + "native.cannotEncodeHookConfiguration": "无法编码 Hook 配置。", + "native.cannotEncodeTheHookLibrary": "无法编码 Hook 资源库。", + "native.checkTheHookNameCommandEventAgentsAndTimeout186400Seconds": "请检查 Hook 名称、命令、事件、Agent 和超时(1–86400 秒)。", + "native.checked": "{arg1}检查", + "native.chooseAPrivateKeyFile": "请选择私钥文件。", + "native.chooseOneOfTheOfferedValues": "请从提供的选项中选择。", + "native.chooseRemoteFolder": "选择远程文件夹", + "native.chooseThisFolder": "选择此文件夹", + "native.claudeCodeHasDisabledAllHooksInItsSettings": "Claude Code 已在设置中关闭所有 Hook。", + "native.claudeCodeIsNotSignedInRunClaudeInATerminalSignInWithLoginThenRetry": "Claude Code 尚未登录。请在终端运行 `claude`,用 /login 登录后再重试。", + "native.claudeSubscription": "Claude 官方订阅(本机账号)", + "native.claudeSubscriptionQuota": "Claude 订阅额度", + "native.agentAccounts": "订阅账号", + "native.agentAccountsInfo": "每个账号对应官方 CLI 的一个独立配置目录。登录会在后台运行 CLI 自己的登录命令并打开浏览器,Lithe 不会读取或复制任何凭据。切换账号会重启当前项目中该 Agent 的连接,正在回复或等待授权时会被拒绝。额度用尽时 Lithe 不会自动换账号。", + "native.defaultAgentAccount": "默认账号", + "native.agentAccountPreferred": "首选", + "native.agentAccountSetPreferred": "设为首选", + "native.agentAccountSignInAgain": "重新登录", + "native.agentAccountRemove": "移除账号", + "native.agentAccountRemoveMessage": "从 Lithe 中移除此账号?它的登录目录会原样保留在磁盘上。", + "native.agentAccountAdd": "添加另一个账号", + "native.agentAccountWaiting": "正在等待浏览器登录…", + "native.agentAccountSnapshotNote": "截至 {arg1},本次未主动刷新", + "native.agentAccountUsageInfo": "用量是 Agent 最近一次报告的值。Claude Code 只在回复过程中报告;Codex 在 Agent 面板打开时还会约每分钟刷新一次。Lithe 不会自己查询账号,所以数值可能已经过时;没在使用的账号会保留最后的值,直到你切换回去。已经重置的窗口不再显示重置时间和配速。", + "native.agentAccountResets": "重置于 {arg1}", + "native.agentAccountPace": "配速 {arg1}", + "native.agentAccountDefaultName": "账号 {arg1}", + "native.agentAccountSignInUnsupported": "此 Agent 不支持账号登录。", + "native.agentAccountSignInFailed": "登录没有完成。请确认已安装该 Agent 的 CLI,然后重试。", + "native.agentAccountSignInTimedOut": "登录超时,请重试。", + "native.codexHookApproval": "Codex Hook 授权", + "native.codexRequiresHooksToBeEnabledAndCommandsToBeTrustedInItsNativeHooksScreenLitheSavesConfigu": "Codex 需要启用 Hooks,并在原生 /hooks 页面中信任命令。Lithe 只保存配置,不授予信任。编辑命令后需要重新批准。配置已写入不代表已执行。", + "native.connectedButLitheCannotRunOnThisPlatformYetLinuxOnX8664OrARM64IsSupported": "已连接,但 Lithe 暂不支持这台主机的平台。目前支持 x86_64 或 ARM64 上的 Linux。", + "native.connectedThisHostCanBeUsedForRemoteDevelopment": "连接成功,这台主机可以用于远程开发。", + "native.connecting": "正在连接…", + "native.connection": "连接", + "native.connectionTimeout": "连接超时", + "native.conversationActions": "会话操作", + "native.conversationContinuedFromASummary": "对话已从摘要继续", + "native.couldNotOpenTheRemoteFolder": "无法打开远程文件夹:{arg1}", + "native.couldNotSaveTheConnections": "无法保存连接:{arg1}", + "native.createGitRepository": "创建 Git 仓库", + "native.createsProjects": "创建 {arg1} 项目", + "native.creatingProject": "正在创建项目…", + "native.creatingThisProjectDownloadsFilesFromTheInternet": "创建此项目需要从网络下载文件。", + "native.customArchetype": "自定义原型…", + "native.dMinutesAgo": "{arg1}分钟前", + "native.deleteChatX93291d9c41": "删除会话", + "native.deleteHook": "删除 Hook?", + "native.didNotFinishWithinLldSeconds": "{arg1} 未在 {arg2} 秒内完成。", + "native.directories": "目录", + "native.disk": "磁盘", + "native.earlier": "更早", + "native.editHook": "编辑 Hook", + "native.emptyProject": "空项目", + "native.enableHook": "启用 Hook", + "native.enlargeImage": "放大查看图片", + "native.enterAHost": "请填写主机。", + "native.enterAValue": "请输入内容。", + "native.enterAnAbsolutePathThatStartsWith": "请输入以 / 开头的绝对路径。", + "native.enterTheHostWithoutAUserNameOrSpacesPutTheUserInUserName": "主机里不要包含用户名或空格,用户名请填在“用户名”里。", + "native.exitedWithStatusD": "{arg1} 以状态 {arg2} 退出。\n{arg3}", + "native.fileEdits": "文件编辑", + "native.filePatterns": "文件模式", + "native.findInMarketplace": "在插件市场中查找", + "native.finishedWithoutCreating": "{arg1} 已结束,但没有生成 {arg2}。", + "native.fitToWindow": "适应窗口", + "native.folder": "分组", + "native.forExampleNotify": "例如 notify", + "native.forExampleNotifyMeWhenATurnEnds": "例如:一轮结束时提醒我", + "native.forkConversation": "分叉会话", + "native.freeOf": "可用 {arg1},共 {arg2}", + "native.generators": "生成器", + "native.getMoreLanguagesAndFrameworksFromPlugins": "通过插件获取更多语言和框架…", + "native.go": "前往", + "native.gradleDSL": "Gradle DSL", + "native.group": "组", + "native.homeDirectory": "主目录", + "native.hook": "Hook", + "native.hookCommand": "Hook 命令", + "native.hookConfigurationChangedOutsideThisEditorReloadBeforeSaving": "Hook 配置已在外部改变,请重新加载后再保存。", + "native.hookConfigurationMustBeARegularFileNotASymbolicLinkOrDirectory": "Hook 配置必须是普通文件,不能是符号链接或目录。", + "native.hookConfigurationMustBeNoLargerThan1MiB": "Hook 配置不能超过 1 MiB。", + "native.hookConfigurationMustUseUTF8Encoding": "Hook 配置必须使用 UTF-8 编码。", + "native.hookDescription": "Hook 描述", + "native.hookName": "Hook 名称", + "native.hookSynchronizationWasCancelled": "Hook 同步已取消。", + "native.hooks": "Hooks", + "native.hooksRunShellCommandsAtAgentLifecycleEventsTheAgentSuppliesEventJSONOnStandardInputAndRuns": "Hook 会在 Agent 的生命周期事件中运行 Shell 命令。Agent 通过标准输入传入事件 JSON,并在会话目录中执行。更改对新会话生效,现有会话继续使用已加载的配置。", + "native.hostName": "主机名", + "native.httpsGithubComOwnerRepository": "https://github.com/owner/repository", + "native.invalidHookFileGuards": "Hook 文件校验信息无效。", + "native.isNeededForThisProjectButIsDisabled": "此项目需要 {arg1} 支持,但 {arg2} 已停用。", + "native.itemLldOfLld": "第 {arg1} 项,共 {arg2} 项", + "native.keepAliveInterval": "保活间隔", + "native.kernel": "内核", + "native.latest": "最新", + "native.leaveEmptyToUseTheAgentDefaultOrEnterAnIntegerFrom1To86400": "留空以沿用 Agent 默认值,或输入 1–86400 之间的整数。", + "native.leaveEmptyToUseTheLatestVersionMavenFindsInItsCatalog": "留空则使用 Maven 目录中的最新版本。", + "native.leaveEmptyToUseThePortFromSshConfigOr22": "留空时使用 ~/.ssh/config 中的端口,没有则为 22。", + "native.leaveEmptyToUseTheUserFromSshConfigOrYourLocalUserName": "留空时使用 ~/.ssh/config 中的用户,没有则为本机用户名。", + "native.leaveEmptyToUseTheVersionTheServerRecommends": "留空则使用服务器推荐的版本。", + "native.litheCouldNotFindInstallItOrAddItToPATHThenTryAgain": "Lithe 找不到 {arg1}。请安装它或将它加入 PATH 后重试。", + "native.litheWillCloneTheRepositoryInto": "Lithe 会将仓库克隆到 {arg1}。", + "native.lldCPUCores": "{arg1} 核 CPU", + "native.location": "位置", + "native.lookUpAgain": "重新查询", + "native.lookUpLocation": "查询地区", + "native.lookingForInstalledJDKs": "正在查找已安装的 JDK…", + "native.lookingUpTheLocationSendsThisAddressToIpinfoIoLitheDoesThisOnlyWhenYouClickLookUp": "查询地区会把这个地址发送给 ipinfo.io。只有你点击“查询地区”时 Lithe 才会这样做。", + "native.manual": "手动", + "native.mavenRunsWithThisJDKAndTheNewProjectUsesIt": "Maven 使用此 JDK 运行,新项目也使用它。", + "native.mayConnectToTheInternet": "可能连接网络", + "native.memory": "{arg1} 内存", + "native.messaging": "消息", + "native.method": "认证方式", + "native.mode": "模式", + "native.move": "移动", + "native.newHook": "新 Hook", + "native.newProjectX6f8d176462": "新建项目", + "native.newSSHConnection": "新建 SSH 连接", + "native.nextImage": "下一张图片", + "native.noHooksYet": "还没有 Hook。", + "native.noHostYet": "尚未填写主机", + "native.noInstalledPluginSupportsYet": "尚未安装支持 {arg1} 的插件。", + "native.noItems": "暂无项目", + "native.noJDKWasFoundInstallAJDKThenReopenThisWindow": "未找到 JDK。请安装 JDK 后重新打开此窗口。", + "native.noMatchingHooks": "没有匹配的 Hook。", + "native.noSSHConnectionsYet": "还没有 SSH 连接。", + "native.notTested": "未测试", + "native.oneEntryPerLineDirectoryNamesHideMatchingFoldersFileEntriesSupportAnd": "每行输入一项。目录名用于隐藏匹配的文件夹;文件条目支持 * 和 ?。", + "native.operatingSystem": "系统", + "native.optionalDescriptionUpTo200CharactersOnlyTheHookLibraryStoresIt": "可选描述,最多 200 个字符,仅保存在 Hook 资源库中。", + "native.packageName": "软件包名称", + "native.packaging": "打包", + "native.passphrase": "私钥口令", + "native.password": "密码", + "native.pin": "置顶", + "native.pinConversation": "置顶会话", + "native.port": "端口", + "native.previous30Days": "过去 30 天", + "native.previous7Days": "过去 7 天", + "native.previousImage": "上一张图片", + "native.privateKey": "私钥", + "native.privateKeyX477bf990a2": "私钥", + "native.privateNetwork": "内网地址", + "native.projectTemplate": "项目模板", + "native.projectWillBeCreatedIn": "项目将在以下位置创建:{arg1}", + "native.providedByADisabledPlugin": "由已停用的插件提供", + "native.publishCoreSelectsDefaultRemote": "发布 {arg1}(自动选择默认远程仓库)", + "native.pushXc68c0bf1cd": "推送“{arg1}”?", + "native.quotaX6c105cae1a": "额度", + "native.rememberInKeychain": "记住到钥匙串", + "native.remoteDevelopment": "远程开发", + "native.remoteFolder": "远程文件夹", + "native.remoteProject": "远程项目", + "native.removalPending": "等待移除", + "native.resources": "资源", + "native.retryHookSynchronization": "重试 Hook 同步", + "native.sSH": "SSH", + "native.sSHAgent": "SSH Agent", + "native.sSHAuthentication": "SSH 认证", + "native.saveHook": "保存 Hook", + "native.savedInKeychain": "已保存在钥匙串中", + "native.savesConnectsAndOpensTheRemoteFolderInThisWindow": "保存、连接,并在当前窗口打开远程文件夹。", + "native.searchHooks": "搜索 Hook", + "native.searchJDKs": "搜索 JDK", + "native.secondsBetweenKeepAliveMessagesWhichDetectADroppedNetworkLeaveEmptyFor15Seconds": "发送保活消息的间隔秒数,用于发现网络中断。留空为 15 秒。", + "native.secondsToWaitForTheServerToAnswerLeaveEmptyFor15Seconds": "等待服务器响应的秒数。留空为 15 秒。", + "native.security": "安全", + "native.serverDefault": "服务器默认", + "native.serverURL": "服务器 URL", + "native.shellCommands": "Shell 命令", + "native.showLldEarlierItems": "显示更早的 {arg1} 项", + "native.showMore": "显示更多", + "native.skillLoaded": "已加载技能:{arg1}", + "native.templateEngines": "模板引擎", + "native.testConnection": "测试连接", + "native.testTheConnectionToSeeTheHostSSystemResourcesAndAddress": "测试连接后,这里会显示主机的系统、资源和地址。", + "native.testing": "测试", + "native.theAddressIsInvalid": "地址无效。", + "native.theAgentExecutesThisCommandUsingItsOwnShellAndPassesEventJSONOnStandardInputCommandsRunWit": "Agent 使用自身 Shell 执行命令,并通过标准输入传入事件 JSON。命令以你的用户权限运行,请只添加你信任的命令。", + "native.theAgentProfileChangedRestoreThePreviousProfileBeforeSynchronizingItsHooks": "Agent 配置目录已改变,请恢复之前的目录后再同步其 Hook。", + "native.theConnectionFailed": "连接失败。", + "native.theConnectionToTheRemoteHostWasLostReconnectToContinue": "与远程主机的连接已断开,请重新连接后继续。", + "native.theFieldValueDoesNotMatchItsDeclarationOrExceedsItsSizeLimit": "字段值不符合声明或超过大小限制。", + "native.theFieldValueIsInvalidOrExceeds1024UTF8Bytes": "字段值无效或超过 1024 个 UTF-8 字节。", + "native.theFileSystemThatHoldsTheHomeDirectory": "主目录所在的文件系统。", + "native.theHookLibraryCanContainAtMost200Hooks": "Hook 资源库最多可包含 200 个 Hook。", + "native.theHookLibraryContainsDuplicateHooks": "Hook 资源库中存在重复条目。", + "native.theHookLibraryDirectoryMustBeAbsolute": "Hook 资源库目录必须是绝对路径。", + "native.theHookLibraryExceededItsSizeLimit": "Hook 资源库超过大小限制。", + "native.theHookLibraryIsInvalidRestoreItBeforeMakingChanges": "Hook 资源库格式无效,请恢复文件后再修改。", + "native.theHookLibraryLockMustBeARegularFile": "Hook 资源库锁必须是普通文件。", + "native.theHookLibraryResponseWasNotRecognised": "无法识别 Hook 资源库响应。", + "native.theHostCouldNotBeReachedCheckTheHostNameThePortAndYourNetwork": "无法连接到主机,请检查主机名、端口和网络。", + "native.theHostKeyWasNotAcceptedIfTheServerWasReinstalledRemoveItsOldEntryFromSshKnownHosts": "主机密钥未被接受。如果服务器重装过,请从 ~/.ssh/known_hosts 中删除它的旧记录。", + "native.theJDKTheNewProjectUsesItMustBeAtLeastTheJavaVersionBelow": "新项目使用的 JDK,版本不能低于下方的 Java 版本。", + "native.theLitheServerWasInstalledOnThisHost": "已在这台主机上安装 Lithe 远端服务。", + "native.theLocationServiceDidNotAnswer": "地区查询服务没有响应。", + "native.theNameOfTheArtifactTheBuildProduces": "构建产物的名称。", + "native.theNativeHookConfigurationMustBeValidJSONExistingContentsWereKept": "原生 Hook 配置必须是有效 JSON,已有内容已保留。", + "native.theNativeHookEventMustContainAnArrayExistingContentsWereKept": "原生 Hook 事件必须是数组,已有内容已保留。", + "native.theNativeHooksFieldMustBeAJSONObjectExistingContentsWereKept": "原生 hooks 字段必须是 JSON 对象,已有内容已保留。", + "native.thePortMustBeBetween1And65535": "端口必须在 1 到 65535 之间。", + "native.theProjectArchiveFromIsTooLarge": "来自 {arg1} 的项目压缩包过大。", + "native.theProjectFolderOnTheHostBrowseConnectsInstallsTheLitheServerOnTheHostIfNeededAndLetsYouPi": "主机上的项目文件夹。“浏览”会连接主机,必要时在主机上安装 Lithe 远端服务,然后让你选择文件夹。", + "native.theProjectFolderOnTheHostEmptyUsesTheHomeDirectoryBrowseConnectsInstallsTheLitheServerOnTh": "主机上的项目文件夹,留空时使用主目录。“浏览”会连接主机,必要时在主机上安装 Lithe 远端服务,然后让你选择文件夹。", + "native.theProjectWasCreatedButGitCouldNotBeSetUp": "项目已创建,但无法初始化 Git 仓库:{arg1}", + "native.theServerAskedAgainCheckThePreviousAnswer": "服务器再次提问,请检查上一次的回答。", + "native.theServerRejectedTheCredentialsCheckTheUserNameAndTheAuthenticationMethod": "服务器拒绝了凭据,请检查用户名和认证方式。", + "native.theSpringInitializrServerThatGeneratesTheProjectChangeItToUseAMirrorOrYourCompanySOwnServe": "生成项目的 Spring Initializr 服务器。可改为镜像或公司内部服务器。", + "native.theUserNameCannotContainOrSpaces": "用户名不能包含 @、: 或空格。", + "native.theValueIsNotInTheExpectedFormat": "格式不正确。", + "native.theValueIsTooLong": "内容过长。", + "native.thisBuildOfLitheDoesNotIncludeTheRemoteServer": "当前构建的 Lithe 没有附带远端服务。", + "native.thisHookNoLongerExistsReloadTheLibrary": "该 Hook 已不存在,请重新加载资源库。", + "native.thisPanelNeedsExactlyOneMatchingPluginInstanceInTheWorkspace": "此面板要求工作区中恰好有一个匹配的插件实例。", + "native.thisRemovesOnlyTheNativeEntriesWrittenByLitheOtherHooksAreKept": "仅移除 Lithe 写入的原生配置条目,保留其他 Hook。", + "native.thisRuntimeIsOlderThanTheGeneratorRequires": "此运行时版本低于模板要求。", + "native.thisSendsTheSelectedLocalBranchToItsConfiguredRemote": "这会将选中的本地分支推送到已配置的远程仓库。", + "native.thisTemplateComesFromWhichIsDisabled": "此模板由已停用的插件 {arg1} 提供。", + "native.thisVersionIsNewerThanTheSelectedRuntimeSupports": "该版本高于所选运行时支持的版本。", + "native.timeoutSeconds": "超时(秒)", + "native.toolFilter": "工具筛选", + "native.tracking": "跟踪 {arg1}", + "native.unknownDate": "时间未知", + "native.unpin": "取消置顶", + "native.unpinConversation": "取消置顶", + "native.uptime": "运行时长", + "native.usageAppearsAfterTheNextReply": "下一次回复后显示用量。", + "native.use180LettersDigitsHyphensOrUnderscoresNamesMustBeUnique": "使用 1–80 个字母、数字、连字符或下划线,名称不能重复。", + "native.useTheClaudeAiAccountYouSignedInToWithClaudeCodeNoAPIKeyOrURLIsNeededLitheNeverReadsItsCre": "使用你在 Claude Code 中登录的 claude.ai 账号,无需 API Key 或地址;Lithe 不会读取它的登录凭据。", + "native.userName": "用户名", + "native.usesTheClaudeAiAccountSignedInToClaudeCodeSignInWithLoginInClaudeCodeIfTheAgentAsks": "使用 Claude Code 中登录的 claude.ai 账号;如提示未登录,请在 Claude Code 中用 /login 登录。", + "native.usesYourLocalChatGPTAccount": "使用本机 ChatGPT 账号。", + "native.usuallyTheReversedDomainNameOfYourOrganizationSuchAsComExample": "通常是组织域名的反写,例如 com.example。", + "native.whenASessionStarts": "会话开始时", + "native.whenATurnEnds": "一轮结束时", + "native.whenSubmittingAPrompt": "提交提示词时", + "native.whenToRun": "何时运行", + "native.withoutItLitheAsksEveryTimeItConnectsARememberedSecretIsAnsweredOnlyTheFirstTimeTheServerA": "不勾选时每次连接都会询问。记住的密码只在服务器第一次询问时自动填入;如果被拒绝,Lithe 会改为询问你。", + "native.withoutRememberInKeychainLitheAsksEveryTimeItConnectsARememberedSecretIsAnsweredOnlyTheFir": "不勾选“记住到钥匙串”时每次连接都会询问。记住的密码只在服务器第一次询问时自动填入;如果被拒绝,Lithe 会改为询问你。", + "settings.tabs.hooks": "Hooks", + "terminal.dockBottom": "终端停靠到底部", + "terminal.dockLeft": "终端停靠到左侧", + "terminal.dockRight": "终端停靠到右侧", + "terminal.movePanel": "拖动以移动终端;点击切换底部/右侧", + "terminal.resizePanel": "调整终端面板宽度", + "windows.aHookWithThisNameAlreadyExists": "已存在同名 Hook。", + "windows.aManagedHookWasEditedOutsideLitheRestoreOrRemoveThatNativeEntryBeforeSynchronizing": "Lithe 管理的 Hook 已在外部修改,请先恢复或移除该原生条目再同步。", + "windows.aRemovedHookCannotBeSaved": "无法保存已移除的 Hook。", + "windows.addHook": "添加 Hook", + "windows.afterAToolRuns": "工具运行后", + "windows.agentDefault": "沿用 Agent 默认值", + "windows.agentHooks": "Agent 钩子", + "windows.agents": "Agent 管理", + "windows.allTools": "所有工具", + "windows.anIdenticalNativeHookAlreadyExistsLitheKeptItUnchanged": "已存在相同的原生 Hook,Lithe 保留了该条目。", + "windows.anotherHookUpdateIsRunningTryAgain": "另一个 Hook 更新正在进行,请稍后重试。", + "windows.beforeAToolRuns": "工具运行前", + "windows.cannotAccessHookConfigurationCheckTheFilePermissionsAndAgentProfile": "无法访问 Hook 配置,请检查文件权限和 Agent 配置目录。", + "windows.cannotAccessTheHookLibraryCheckItsPermissions": "无法访问 Hook 资源库,请检查权限。", + "windows.cannotEncodeHookConfiguration": "无法编码 Hook 配置。", + "windows.cannotEncodeTheHookLibrary": "无法编码 Hook 资源库。", + "windows.checkTheHookNameCommandEventAgentsAndTimeout186400Seconds": "请检查 Hook 名称、命令、事件、Agent 和超时(1–86400 秒)。", + "windows.claudeCodeHasDisabledAllHooksInItsSettings": "Claude Code 已在设置中关闭所有 Hook。", + "windows.codexHookApproval": "Codex Hook 授权", + "windows.codexRequiresHooksToBeEnabledAndCommandsToBeTrustedInItsNativeHooksScreenLitheSavesConfigu": "Codex 需要启用 Hooks,并在原生 /hooks 页面中信任命令。Lithe 只保存配置,不授予信任。编辑命令后需要重新批准。配置已写入不代表已执行。", + "windows.commandPaletteActionsCursorRedoDescription": "恢复下一个光标位置和选区,保留代码修改", + "windows.commandPaletteActionsCursorRedoLabel": "编辑:光标重做", + "windows.commandPaletteActionsCursorUndoDescription": "回到上一个光标位置和选区,保留代码修改", + "windows.commandPaletteActionsCursorUndoLabel": "编辑:光标撤销", + "windows.deleteHook": "删除 Hook?", + "windows.enableHook": "启用 Hook", + "windows.fileEdits": "文件编辑", + "windows.hook": "Hook", + "windows.hookCommand": "Hook 命令", + "windows.hookConfigurationChangedOutsideThisEditorReloadBeforeSaving": "Hook 配置已在外部改变,请重新加载后再保存。", + "windows.hookConfigurationMustBeARegularFileNotASymbolicLinkOrDirectory": "Hook 配置必须是普通文件,不能是符号链接或目录。", + "windows.hookConfigurationMustBeNoLargerThan1MiB": "Hook 配置不能超过 1 MiB。", + "windows.hookConfigurationMustUseUTF8Encoding": "Hook 配置必须使用 UTF-8 编码。", + "windows.hookDescription": "Hook 描述", + "windows.hookSynchronizationWasCancelled": "Hook 同步已取消。", + "windows.hooks": "Hooks", + "windows.hooksEventAfterTool": "工具运行后", + "windows.hooksEventBeforeTool": "工具运行前", + "windows.hooksEventSessionStart": "会话开始时", + "windows.hooksEventTurnEnd": "一轮结束时", + "windows.hooksRunShellCommandsAtAgentLifecycleEventsTheAgentSuppliesEventJSONOnStandardInputAndRuns": "Hook 会在 Agent 的生命周期事件中运行 Shell 命令。Agent 通过标准输入传入事件 JSON,并在会话目录中执行。更改对新会话生效,现有会话继续使用已加载的配置。", + "windows.invalidHookFileGuards": "Hook 文件校验信息无效。", + "windows.leaveEmptyToUseTheAgentDefaultOrEnterAnIntegerFrom1To86400": "留空以沿用 Agent 默认值,或输入 1–86400 之间的整数。", + "windows.noHooksYet": "还没有 Hook。", + "windows.noMatchingHooks": "没有匹配的 Hook。", + "windows.optionalDescriptionUpTo200CharactersOnlyTheHookLibraryStoresIt": "可选描述,最多 200 个字符,仅保存在 Hook 资源库中。", + "windows.removalPending": "等待移除", + "windows.retryHookSynchronization": "重试 Hook 同步", + "windows.saveHook": "保存 Hook", + "windows.searchHooks": "搜索 Hook", + "windows.shellCommands": "Shell 命令", + "windows.syncNeeded": "待同步", + "windows.theAgentExecutesThisCommandUsingItsOwnShellAndPassesEventJSONOnStandardInputCommandsRunWit": "Agent 使用自身 Shell 执行命令,并通过标准输入传入事件 JSON。命令以你的用户权限运行,请只添加你信任的命令。", + "windows.theAgentProfileChangedRestoreThePreviousProfileBeforeSynchronizingItsHooks": "Agent 配置目录已改变,请恢复之前的目录后再同步其 Hook。", + "windows.theHookLibraryCanContainAtMost200Hooks": "Hook 资源库最多可包含 200 个 Hook。", + "windows.theHookLibraryContainsDuplicateHooks": "Hook 资源库中存在重复条目。", + "windows.theHookLibraryDirectoryMustBeAbsolute": "Hook 资源库目录必须是绝对路径。", + "windows.theHookLibraryExceededItsSizeLimit": "Hook 资源库超过大小限制。", + "windows.theHookLibraryIsInvalidRestoreItBeforeMakingChanges": "Hook 资源库格式无效,请恢复文件后再修改。", + "windows.theHookLibraryLockMustBeARegularFile": "Hook 资源库锁必须是普通文件。", + "windows.theHookLibraryResponseWasNotRecognised": "无法识别 Hook 资源库响应。", + "windows.theNativeHookConfigurationMustBeValidJSONExistingContentsWereKept": "原生 Hook 配置必须是有效 JSON,已有内容已保留。", + "windows.theNativeHookEventMustContainAnArrayExistingContentsWereKept": "原生 Hook 事件必须是数组,已有内容已保留。", + "windows.theNativeHooksFieldMustBeAJSONObjectExistingContentsWereKept": "原生 hooks 字段必须是 JSON 对象,已有内容已保留。", + "windows.thisHookNoLongerExistsReloadTheLibrary": "该 Hook 已不存在,请重新加载资源库。", + "windows.thisRemovesOnlyTheNativeEntriesWrittenByLitheOtherHooksAreKept": "仅移除 Lithe 写入的原生配置条目,保留其他 Hook。", + "windows.timeoutSeconds": "超时(秒)", + "windows.toolFilter": "工具筛选", + "windows.use180LettersDigitsHyphensOrUnderscoresNamesMustBeUnique": "使用 1–80 个字母、数字、连字符或下划线,名称不能重复。", + "windows.whenASessionStarts": "会话开始时", + "windows.whenATurnEnds": "一轮结束时", + "windows.whenSubmittingAPrompt": "提交提示词时", + "windows.whenToRun": "何时运行", + "run.declarativePlugins": "声明式插件", + "settings.declarativePlugins.title": "声明式插件(契约 v1)", + "settings.declarativePlugins.description": "以声明方式提供进程的插件包,宿主在安装审查后代为拉起。主题与 Worker 扩展请在扩展浏览器中管理。", + "settings.declarativePlugins.import": "导入包…", + "settings.declarativePlugins.reviewTitle": "安装 {name} {version}?", + "settings.declarativePlugins.unverified": "未认证发布者:该包没有签名。其权限与程序声明如下。", + "settings.declarativePlugins.confirmInstall": "安装", + "settings.declarativePlugins.programDeclaration": "程序声明({count} 项)", + "settings.declarativePlugins.empty": "尚未安装声明式插件。", + "settings.declarativePlugins.toggle": "启用或停用 {name}", + "settings.declarativePlugins.rollback": "回滚", + "settings.declarativePlugins.uninstall": "卸载", + "settings.declarativePlugins.source.bundled": "内置", + "settings.declarativePlugins.source.installed": "已安装", + "settings.declarativePlugins.settings": "设置", + "settings.declarativePlugins.webWindows": "Web 工具窗口", + "settings.declarativePlugins.openWindow": "打开", + "settings.declarativePlugins.closeWindow": "关闭", + "settings.declarativePlugins.placement.left": "左侧", + "settings.declarativePlugins.placement.right": "右侧", + "settings.declarativePlugins.placement.bottom": "底部", + "settings.declarativePlugins.showSettings": "设置…", + "settings.declarativePlugins.hideSettings": "收起设置", + "settings.declarativePlugins.settingsUnavailable": "无法解析该插件的设置。", + "settings.declarativePlugins.settingSource.default": "默认", + "settings.declarativePlugins.settingSource.global": "用户值", + "settings.declarativePlugins.settingSource.project": "项目覆盖", + "settings.declarativePlugins.settingScope.global": "所有工作区", + "settings.declarativePlugins.settingScope.project": "当前工作区", + "settings.declarativePlugins.settingEffect.restart": "语言服务器重启后生效", + "settings.declarativePlugins.settingEffect.immediate": "立即应用到运行中的语言服务器", + "settings.declarativePlugins.settingEffect.reload": "插件模型重新加载后生效", + "settings.declarativePlugins.settingEffect.nextRun": "下次运行生效", + "native.databaseIsNowAPluginInstallItFromTheMarketplace": "数据库已改为插件。从插件市场安装后,已保存的连接和密码会自动迁入。", + "native.openMarketplace": "打开插件市场", + "native.suggestedReplies": "建议的回复", + "native.changedTheWorkingDirectoryTo": "已将工作目录切换到 {arg1}", + "native.askedForAccessTo": "请求访问 {arg1}", + "native.chapter": "章节:{arg1}", + "native.startInNewConversation": "在新对话中开始", + "native.suggested": "建议", + "native.withdrawn": "已撤回", + "native.startedInClaudeDesktop": "已在 Claude Desktop 中开始", + "native.handedOff": "已移交", + "native.linkedThisConversationToAPullRequest": "已将此对话关联到拉取请求", + "native.unlinkedThePullRequest": "已取消关联拉取请求", + "native.watchingPullRequestChecksAndFixingFailures": "正在关注拉取请求检查并修复失败项", + "native.watchingPullRequestChecks": "正在关注拉取请求检查", + "native.turnedOffAutoMerge": "已关闭自动合并", + "native.turnedOnAutoMerge": "已开启自动合并", + "native.visualization": "可视化", + "native.showVisualization": "显示可视化", + "native.hideVisualization": "隐藏可视化" + }, + "overrides": { + "macos": { + "native.arg1ProviderRunsWithArg2": { + "value": "{arg1} Provider(使用 {arg2} 运行)", + "reason": "保留原有位置格式,避免 String(format:) 参数错位。", + "nativeFormat": "positional" + }, + "ui.ok": { + "value": "好", + "reason": "Preserve the existing macOS label during incremental migration." + }, + "ui.confirm": { + "value": "确定", + "reason": "Preserve the existing macOS label during incremental migration." + }, + "ui.clear": { + "value": "清除", + "reason": "Preserve the existing macOS label during incremental migration." + }, + "native.didNotReportAVersionOrNewerIsRequired": { + "value": "{arg1} 没有报告版本,需要 {arg2} 或更高版本。", + "reason": "Preserve the existing native positional argument format.", + "nativeFormat": "positional" + }, + "native.isVersionOrNewerIsRequired": { + "value": "{arg1} 的版本是 {arg2},需要 {arg3} 或更高版本。", + "reason": "Preserve the existing native positional argument format.", + "nativeFormat": "positional" + }, + "native.ofIsHandledBy": { + "value": "{arg2} 的 {arg1} 由 {arg3} 处理。", + "reason": "Preserve the existing native positional argument format.", + "nativeFormat": "positional" + }, + "native.lldOfLldServicesSelected": { + "value": "已选择 {arg2} 个服务中的 {arg1} 个", + "reason": "Preserve the existing native positional argument format.", + "nativeFormat": "positional" + }, + "native.rollBackTo": { + "value": "将 {arg1} 回滚到 {arg2}?", + "reason": "Preserve the existing native positional argument format.", + "nativeFormat": "positional" + }, + "native.runsTheInstalledOnThisMacThroughTheAdapter": { + "value": "通过 {arg2} 适配器驱动这台 Mac 上已安装的 {arg1}。", + "reason": "Preserve the existing native positional argument format.", + "nativeFormat": "positional" + }, + "native.answeredWithStatusLld": { + "value": "{arg1} 返回状态 {arg2}。{arg3}", + "reason": "Preserve the existing native positional format and calling ABI.", + "nativeFormat": "positional" + }, + "native.didNotFinishWithinLldSeconds": { + "value": "{arg1} 未在 {arg2} 秒内完成。", + "reason": "Preserve the existing native positional format and calling ABI.", + "nativeFormat": "positional" + }, + "native.exitedWithStatusD": { + "value": "{arg1} 以状态 {arg2} 退出。\n{arg3}", + "reason": "Preserve the existing native positional format and calling ABI.", + "nativeFormat": "positional" + }, + "native.finishedWithoutCreating": { + "value": "{arg1} 已结束,但没有生成 {arg2}。", + "reason": "Preserve the existing native positional format and calling ABI.", + "nativeFormat": "positional" + }, + "native.freeOf": { + "value": "可用 {arg1},共 {arg2}", + "reason": "Preserve the existing native positional format and calling ABI.", + "nativeFormat": "positional" + }, + "native.isNeededForThisProjectButIsDisabled": { + "value": "此项目需要 {arg1} 支持,但 {arg2} 已停用。", + "reason": "Preserve the existing native positional format and calling ABI.", + "nativeFormat": "positional" + } + }, + "windows": { + "files.reveal": { + "value": "在资源管理器中显示", + "reason": "Windows uses File Explorer; macOS uses Finder." + } + } + } +} diff --git a/shared/i18n/manifest.json b/shared/i18n/manifest.json new file mode 100644 index 0000000000..a87294ff42 --- /dev/null +++ b/shared/i18n/manifest.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": 1, + "defaultLocale": "en-US", + "platformDefaults": { + "macos": "en-US", + "windows": "zh-CN" + }, + "locales": [ + { + "id": "en-US", + "title": "English", + "macos": "en", + "swiftCase": "english", + "fallback": null, + "coverage": "complete" + }, + { + "id": "zh-CN", + "title": "简体中文", + "macos": "zh-Hans", + "swiftCase": "simplifiedChinese", + "fallback": "en-US", + "coverage": "complete" + } + ] +} diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json new file mode 100644 index 0000000000..2891764da8 --- /dev/null +++ b/shared/i18n/messages.json @@ -0,0 +1,82983 @@ +{ + "schemaVersion": 1, + "messages": [ + { + "id": "agent.actions.close", + "description": "Windows UI: agent.actions.close", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.actions.closeSearch", + "description": "Windows UI: agent.actions.closeSearch", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.actions.hideDetails", + "description": "Windows UI: agent.actions.hideDetails", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.actions.history", + "description": "Windows UI: agent.actions.history", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.actions.moveToToolWindow", + "description": "Windows UI: agent.actions.moveToToolWindow", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.actions.newConversation", + "description": "Windows UI: agent.actions.newConversation", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.actions.openInEditor", + "description": "Windows UI: agent.actions.openInEditor", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.actions.openSettings", + "description": "Windows UI: agent.actions.openSettings", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.actions.reconnect", + "description": "Windows UI: agent.actions.reconnect", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.actions.search", + "description": "Windows UI: agent.actions.search", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.actions.settings", + "description": "Windows UI: agent.actions.settings", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.actions.showDetails", + "description": "Windows UI: agent.actions.showDetails", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.actions.switchAgent", + "description": "Windows UI: agent.actions.switchAgent", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.actions.tabs", + "description": "Windows UI: agent.actions.tabs", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.activity.edits", + "description": "Windows UI: agent.activity.edits", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.activity.executing", + "description": "Windows UI: agent.activity.executing", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.activity.permission", + "description": "Windows UI: agent.activity.permission", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.activity.quiet", + "description": "Windows UI: agent.activity.quiet", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.activity.reasoning", + "description": "Windows UI: agent.activity.reasoning", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.activity.recovering", + "description": "Windows UI: agent.activity.recovering", + "platforms": [ + "windows" + ], + "parameters": { + "attempt": "string" + } + }, + { + "id": "agent.activity.responding", + "description": "Windows UI: agent.activity.responding", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.activity.retrying", + "description": "Windows UI: agent.activity.retrying", + "platforms": [ + "windows" + ], + "parameters": { + "attempt": "string", + "max": "string" + } + }, + { + "id": "agent.activity.running", + "description": "Windows UI: agent.activity.running", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.activity.tasks", + "description": "Windows UI: agent.activity.tasks", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.activity.waiting", + "description": "Windows UI: agent.activity.waiting", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.archive.badge", + "description": "Windows UI: agent.archive.badge", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.archive.confirmMessage", + "description": "Windows UI: agent.archive.confirmMessage", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.archive.empty", + "description": "Windows UI: agent.archive.empty", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.archive.incomplete", + "description": "Windows UI: agent.archive.incomplete", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.archive.refresh", + "description": "Windows UI: agent.archive.refresh", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.archive.restore", + "description": "Windows UI: agent.archive.restore", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.archive.title", + "description": "Windows UI: agent.archive.title", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.archive.unsupported", + "description": "Windows UI: agent.archive.unsupported", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.composer.agentSettings", + "description": "Windows UI: agent.composer.agentSettings", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.composer.attachActive", + "description": "Windows UI: agent.composer.attachActive", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.composer.attachFile", + "description": "Windows UI: agent.composer.attachFile", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.composer.attachFiles", + "description": "Windows UI: agent.composer.attachFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.composer.attachHint", + "description": "Windows UI: agent.composer.attachHint", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.composer.cancelling", + "description": "Windows UI: agent.composer.cancelling", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.composer.chooseAgent", + "description": "Windows UI: agent.composer.chooseAgent", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.composer.clipboardBusy", + "description": "Windows UI: agent.composer.clipboardBusy", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.composer.commands", + "description": "Windows UI: agent.composer.commands", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.composer.connectionClosed", + "description": "Windows UI: agent.composer.connectionClosed", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.composer.dropFiles", + "description": "Windows UI: agent.composer.dropFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.composer.dropHere", + "description": "Windows UI: agent.composer.dropHere", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.composer.fileLimit", + "description": "Windows UI: agent.composer.fileLimit", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "agent.composer.imageCleanupFailed", + "description": "Windows UI: agent.composer.imageCleanupFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.composer.invalidImage", + "description": "Windows UI: agent.composer.invalidImage", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.composer.message", + "description": "Windows UI: agent.composer.message", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.composer.messageHint", + "description": "Windows UI: agent.composer.messageHint", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.composer.noAgent", + "description": "Windows UI: agent.composer.noAgent", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.composer.noCommands", + "description": "Windows UI: agent.composer.noCommands", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.composer.noFiles", + "description": "Windows UI: agent.composer.noFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.composer.placeholder", + "description": "Windows UI: agent.composer.placeholder", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.composer.preparing", + "description": "Windows UI: agent.composer.preparing", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.composer.queued", + "description": "Windows UI: agent.composer.queued", + "platforms": [ + "windows" + ], + "parameters": { + "text": "string" + } + }, + { + "id": "agent.composer.received", + "description": "Windows UI: agent.composer.received", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.composer.removeAttachment", + "description": "Windows UI: agent.composer.removeAttachment", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "agent.composer.removeFile", + "description": "Windows UI: agent.composer.removeFile", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "agent.composer.searchFiles", + "description": "Windows UI: agent.composer.searchFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.composer.send", + "description": "Windows UI: agent.composer.send", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.composer.sending", + "description": "Windows UI: agent.composer.sending", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.composer.stop", + "description": "Windows UI: agent.composer.stop", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.composer.supplementCancelled", + "description": "Windows UI: agent.composer.supplementCancelled", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.composer.supplementRejected", + "description": "Windows UI: agent.composer.supplementRejected", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.composer.tooManyFiles", + "description": "Windows UI: agent.composer.tooManyFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.composer.tooManyImages", + "description": "Windows UI: agent.composer.tooManyImages", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.configuration.staged", + "description": "Windows UI: agent.configuration.staged", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.context.attach", + "description": "Windows UI: agent.context.attach", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.context.busy", + "description": "Windows UI: agent.context.busy", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.context.current", + "description": "Windows UI: agent.context.current", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.context.dirty", + "description": "Windows UI: agent.context.dirty", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.context.label", + "description": "Windows UI: agent.context.label", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.context.new", + "description": "Windows UI: agent.context.new", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.context.placeholder", + "description": "Windows UI: agent.context.placeholder", + "platforms": [ + "windows" + ], + "parameters": { + "percent": "string" + } + }, + { + "id": "agent.context.setup", + "description": "Windows UI: agent.context.setup", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.context.stale", + "description": "Windows UI: agent.context.stale", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.context.tokens", + "description": "Windows UI: agent.context.tokens", + "platforms": [ + "windows" + ], + "parameters": { + "capacity": "string", + "percent": "string", + "used": "string" + } + }, + { + "id": "agent.context.unavailable", + "description": "Windows UI: agent.context.unavailable", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.continueWaiting", + "description": "Windows UI: agent.continueWaiting", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.details.agent", + "description": "Windows UI: agent.details.agent", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.details.context", + "description": "Windows UI: agent.details.context", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.details.messages", + "description": "Windows UI: agent.details.messages", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.details.noConfig", + "description": "Windows UI: agent.details.noConfig", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.details.session", + "description": "Windows UI: agent.details.session", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.details.sessionId", + "description": "Windows UI: agent.details.sessionId", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.details.title", + "description": "Windows UI: agent.details.title", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.details.titleLabel", + "description": "Windows UI: agent.details.titleLabel", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.details.turns", + "description": "Windows UI: agent.details.turns", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.details.unavailable", + "description": "Windows UI: agent.details.unavailable", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.details.workspace", + "description": "Windows UI: agent.details.workspace", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.editor.focusTab", + "description": "Windows UI: agent.editor.focusTab", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.editor.foreignAgentMessage", + "description": "Windows UI: agent.editor.foreignAgentMessage", + "platforms": [ + "windows" + ], + "parameters": { + "agent": "string", + "current": "string" + }, + "literalBraces": true + }, + { + "id": "agent.editor.foreignAgentTitle", + "description": "Windows UI: agent.editor.foreignAgentTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.editor.handoffNotice", + "description": "Windows UI: agent.editor.handoffNotice", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.editor.switchBack", + "description": "Windows UI: agent.editor.switchBack", + "platforms": [ + "windows" + ], + "parameters": { + "agent": "string" + }, + "literalBraces": true + }, + { + "id": "agent.edits.changed", + "description": "Windows UI: agent.edits.changed", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.edits.diff", + "description": "Windows UI: agent.edits.diff", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.edits.dirty", + "description": "Windows UI: agent.edits.dirty", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.edits.failed", + "description": "Windows UI: agent.edits.failed", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.edits.incomplete", + "description": "Windows UI: agent.edits.incomplete", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.edits.keep", + "description": "Windows UI: agent.edits.keep", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.edits.recoveryRequired", + "description": "Windows UI: agent.edits.recoveryRequired", + "platforms": [ + "windows" + ], + "parameters": { + "path": "string" + } + }, + { + "id": "agent.edits.revert", + "description": "Windows UI: agent.edits.revert", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.edits.title", + "description": "Windows UI: agent.edits.title", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "agent.error.disabled", + "description": "Windows UI: agent.error.disabled", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.error.notConnected", + "description": "Windows UI: agent.error.notConnected", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.error.preparing", + "description": "Windows UI: agent.error.preparing", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.evidence.input", + "description": "Windows UI: agent.evidence.input", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.evidence.result", + "description": "Windows UI: agent.evidence.result", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.hero.choose", + "description": "Windows UI: agent.hero.choose", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.hero.sendTo", + "description": "Windows UI: agent.hero.sendTo", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "agent.history.addFavorite", + "description": "Windows UI: agent.history.addFavorite", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.history.conversations", + "description": "Windows UI: agent.history.conversations", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "agent.history.copied", + "description": "Windows UI: agent.history.copied", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.history.copyId", + "description": "Windows UI: agent.history.copyId", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.history.deselectAll", + "description": "Windows UI: agent.history.deselectAll", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.history.done", + "description": "Windows UI: agent.history.done", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.history.empty", + "description": "Windows UI: agent.history.empty", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.history.export", + "description": "Windows UI: agent.history.export", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.history.exportFailed", + "description": "Windows UI: agent.history.exportFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.history.exportFileType", + "description": "Windows UI: agent.history.exportFileType", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.history.exportTooLarge", + "description": "Windows UI: agent.history.exportTooLarge", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.history.exportUnfinished", + "description": "Windows UI: agent.history.exportUnfinished", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.history.exporting", + "description": "Windows UI: agent.history.exporting", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.history.filter", + "description": "Windows UI: agent.history.filter", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.history.filter.all", + "description": "Windows UI: agent.history.filter.all", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.history.filter.favorites", + "description": "Windows UI: agent.history.filter.favorites", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.history.filter.removed", + "description": "Windows UI: agent.history.filter.removed", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.history.loading", + "description": "Windows UI: agent.history.loading", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.history.messages", + "description": "Windows UI: agent.history.messages", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "agent.history.noMatch", + "description": "Windows UI: agent.history.noMatch", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.history.noMatchHint", + "description": "Windows UI: agent.history.noMatchHint", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.history.refresh", + "description": "Windows UI: agent.history.refresh", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.history.remove", + "description": "Windows UI: agent.history.remove", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.history.removeConfirm", + "description": "Windows UI: agent.history.removeConfirm", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "agent.history.removeFavorite", + "description": "Windows UI: agent.history.removeFavorite", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.history.removedNotice", + "description": "Windows UI: agent.history.removedNotice", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.history.rename", + "description": "Windows UI: agent.history.rename", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.history.renameMessage", + "description": "Windows UI: agent.history.renameMessage", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.history.restore", + "description": "Windows UI: agent.history.restore", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.history.saveFailed", + "description": "Windows UI: agent.history.saveFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.history.search", + "description": "Windows UI: agent.history.search", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.history.select", + "description": "Windows UI: agent.history.select", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.history.selectAll", + "description": "Windows UI: agent.history.selectAll", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.history.selectedCount", + "description": "Windows UI: agent.history.selectedCount", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "agent.history.thinking", + "description": "Windows UI: agent.history.thinking", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.history.title", + "description": "Windows UI: agent.history.title", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "agent.history.tool", + "description": "Windows UI: agent.history.tool", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.history.you", + "description": "Windows UI: agent.history.you", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.notice.dismiss", + "description": "Windows UI: agent.notice.dismiss", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.permission.deny", + "description": "Windows UI: agent.permission.deny", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.permission.dismiss", + "description": "Windows UI: agent.permission.dismiss", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.permission.title", + "description": "Windows UI: agent.permission.title", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.plan.collapse", + "description": "Windows UI: agent.plan.collapse", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.plan.completed", + "description": "Windows UI: agent.plan.completed", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.plan.expand", + "description": "Windows UI: agent.plan.expand", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.plan.pending", + "description": "Windows UI: agent.plan.pending", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.plan.progress", + "description": "Windows UI: agent.plan.progress", + "platforms": [ + "windows" + ], + "parameters": { + "completed": "string", + "total": "string" + } + }, + { + "id": "agent.plan.running", + "description": "Windows UI: agent.plan.running", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.quietNotice", + "description": "Windows UI: agent.quietNotice", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.quota.primary", + "description": "Windows UI: agent.quota.primary", + "platforms": [ + "windows" + ], + "parameters": { + "percent": "string", + "window": "string" + } + }, + { + "id": "agent.quota.resetsAt", + "description": "Windows UI: agent.quota.resetsAt", + "platforms": [ + "windows" + ], + "parameters": { + "time": "string" + } + }, + { + "id": "agent.quota.unknown", + "description": "Windows UI: agent.quota.unknown", + "platforms": [ + "windows" + ], + "parameters": { + "window": "string" + } + }, + { + "id": "agent.quotaView.accountChanged", + "description": "Windows UI: agent.quotaView.accountChanged", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.quotaView.fiveHours", + "description": "Windows UI: agent.quotaView.fiveHours", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.quotaView.resets", + "description": "Windows UI: agent.quotaView.resets", + "platforms": [ + "windows" + ], + "parameters": { + "time": "string" + } + }, + { + "id": "agent.quotaView.sevenDays", + "description": "Windows UI: agent.quotaView.sevenDays", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.quotaView.stale", + "description": "Windows UI: agent.quotaView.stale", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.quotaView.title", + "description": "Windows UI: agent.quotaView.title", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.quotaView.unknown", + "description": "Windows UI: agent.quotaView.unknown", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.quotaView.updated", + "description": "Windows UI: agent.quotaView.updated", + "platforms": [ + "windows" + ], + "parameters": { + "time": "string" + } + }, + { + "id": "agent.quotaView.used", + "description": "Windows UI: agent.quotaView.used", + "platforms": [ + "windows" + ], + "parameters": { + "percent": "string", + "window": "string" + } + }, + { + "id": "agent.quotaView.usedPercent", + "description": "Windows UI: agent.quotaView.usedPercent", + "platforms": [ + "windows" + ], + "parameters": { + "percent": "string" + } + }, + { + "id": "agent.quotaView.waiting", + "description": "Windows UI: agent.quotaView.waiting", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.reportedLabels.approvalMode", + "description": "Selected-language presentation: Approval mode", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.reportedLabels.alwaysAskToEditExternalFilesAndUseTheInternet", + "description": "Selected-language presentation: Always ask to edit external files and use the internet", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.reportedLabels.onlyAskForActionsDetectedAsPotentiallyUnsafe", + "description": "Selected-language presentation: Only ask for actions detected as potentially unsafe", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.reportedLabels.unrestrictedAccessToTheInternetAndAnyFileOnYourComputer", + "description": "Selected-language presentation: Unrestricted access to the internet and any file on your computer", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.reportedLabels.collaborationMode", + "description": "Selected-language presentation: Collaboration mode", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.reportedLabels.planBeforeMakingChanges", + "description": "Selected-language presentation: Plan before making changes", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.reportedLabels.reasoningEffort", + "description": "Selected-language presentation: Reasoning effort", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.reportedLabels.fastMode", + "description": "Selected-language presentation: Fast mode", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.reportedLabels.model", + "description": "Selected-language presentation: Model", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.role.agent", + "description": "Windows UI: agent.role.agent", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.role.user", + "description": "Windows UI: agent.role.user", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.selector.fast", + "description": "Windows UI: agent.selector.fast", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.selector.mode", + "description": "Windows UI: agent.selector.mode", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.selector.model", + "description": "Windows UI: agent.selector.model", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.selector.more", + "description": "Windows UI: agent.selector.more", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.selector.noModels", + "description": "Windows UI: agent.selector.noModels", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.selector.searchModels", + "description": "Windows UI: agent.selector.searchModels", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.selector.speed", + "description": "Windows UI: agent.selector.speed", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.selector.standard", + "description": "Windows UI: agent.selector.standard", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.selector.thinkingLevel", + "description": "Windows UI: agent.selector.thinkingLevel", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.addProvider", + "description": "Windows UI: agent.settings.addProvider", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.agent", + "description": "Windows UI: agent.settings.agent", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.allowInsecureHint", + "description": "Windows UI: agent.settings.allowInsecureHint", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.apiKey", + "description": "Windows UI: agent.settings.apiKey", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.apiKeyMode", + "description": "Windows UI: agent.settings.apiKeyMode", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.apiKeyPlaceholder", + "description": "Windows UI: agent.settings.apiKeyPlaceholder", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.apiKeyStored", + "description": "Windows UI: agent.settings.apiKeyStored", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.arguments", + "description": "Windows UI: agent.settings.arguments", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.authJson", + "description": "Windows UI: agent.settings.authJson", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.authentication", + "description": "Windows UI: agent.settings.authentication", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.back", + "description": "Windows UI: agent.settings.back", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.baseUrl", + "description": "Windows UI: agent.settings.baseUrl", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.busyAuthentication", + "description": "Windows UI: agent.settings.busyAuthentication", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.cliDetected", + "description": "Windows UI: agent.settings.cliDetected", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string", + "version": "string" + } + }, + { + "id": "agent.settings.cliMissing", + "description": "Windows UI: agent.settings.cliMissing", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string", + "version": "string" + } + }, + { + "id": "agent.settings.cliUpdateWarning", + "description": "Windows UI: agent.settings.cliUpdateWarning", + "platforms": [ + "windows" + ], + "parameters": { + "warning": "string" + } + }, + { + "id": "agent.settings.cliUpdated", + "description": "Windows UI: agent.settings.cliUpdated", + "platforms": [ + "windows" + ], + "parameters": { + "version": "string" + } + }, + { + "id": "agent.settings.command", + "description": "Windows UI: agent.settings.command", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.configurationTooLarge", + "description": "Windows UI: agent.settings.configurationTooLarge", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.conflict", + "description": "Windows UI: agent.settings.conflict", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.customAgent", + "description": "Windows UI: agent.settings.customAgent", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.editProvider", + "description": "Windows UI: agent.settings.editProvider", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.enable", + "description": "Windows UI: agent.settings.enable", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.enableHint", + "description": "Windows UI: agent.settings.enableHint", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.enableSubscription", + "description": "Windows UI: agent.settings.enableSubscription", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Enable", + "arguments": [] + } + }, + { + "id": "agent.settings.formMode", + "description": "Windows UI: agent.settings.formMode", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.hideKey", + "description": "Windows UI: agent.settings.hideKey", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.importConfiguration", + "description": "Windows UI: agent.settings.importConfiguration", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.importConfigurationAction", + "description": "Windows UI: agent.settings.importConfigurationAction", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.importConfigurationHint", + "description": "Windows UI: agent.settings.importConfigurationHint", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.importFailed", + "description": "Windows UI: agent.settings.importFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.importProtocolMismatch", + "description": "Windows UI: agent.settings.importProtocolMismatch", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.importingConfiguration", + "description": "Windows UI: agent.settings.importingConfiguration", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.install", + "description": "Windows UI: agent.settings.install", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.installCli", + "description": "Windows UI: agent.settings.installCli", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.installed", + "description": "Windows UI: agent.settings.installed", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string", + "version": "string" + } + }, + { + "id": "agent.settings.installing", + "description": "Windows UI: agent.settings.installing", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.invalidProviderUrl", + "description": "Windows UI: agent.settings.invalidProviderUrl", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.keyFailed", + "description": "Windows UI: agent.settings.keyFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.linkLocal", + "description": "Windows UI: agent.settings.linkLocal", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.model", + "description": "Windows UI: agent.settings.model", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.modelPlaceholder", + "description": "Windows UI: agent.settings.modelPlaceholder", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.notInstalled", + "description": "Windows UI: agent.settings.notInstalled", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "agent.settings.preflight", + "description": "Windows UI: agent.settings.preflight", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.progress", + "description": "Windows UI: agent.settings.progress", + "platforms": [ + "windows" + ], + "parameters": { + "megabytes": "string", + "stage": "string" + } + }, + { + "id": "agent.settings.provider", + "description": "Windows UI: agent.settings.provider", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.providerName", + "description": "Windows UI: agent.settings.providerName", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.readLocalConfiguration", + "description": "Windows UI: agent.settings.readLocalConfiguration", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.reconnect", + "description": "Windows UI: agent.settings.reconnect", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.reconnectHint", + "description": "Windows UI: agent.settings.reconnectHint", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.refresh", + "description": "Windows UI: agent.settings.refresh", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.reinstall", + "description": "Windows UI: agent.settings.reinstall", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.removeProvider", + "description": "Windows UI: agent.settings.removeProvider", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.removeProviderConfirm", + "description": "Windows UI: agent.settings.removeProviderConfirm", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.requiredProviderFields", + "description": "Windows UI: agent.settings.requiredProviderFields", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.saveFailed", + "description": "Windows UI: agent.settings.saveFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.saveKey", + "description": "Windows UI: agent.settings.saveKey", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.showKey", + "description": "Windows UI: agent.settings.showKey", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.subscriptionHint", + "description": "Windows UI: agent.settings.subscriptionHint", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.subscriptionMode", + "description": "Windows UI: agent.settings.subscriptionMode", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.subscriptionRowHint", + "description": "Windows UI: agent.settings.subscriptionRowHint", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.textMode", + "description": "Windows UI: agent.settings.textMode", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.title", + "description": "Windows UI: agent.settings.title", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.uninstall", + "description": "Windows UI: agent.settings.uninstall", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.unlinkKey", + "description": "Windows UI: agent.settings.unlinkKey", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.updateCli", + "description": "Windows UI: agent.settings.updateCli", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.updatingCli", + "description": "Windows UI: agent.settings.updatingCli", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.settings.useApiKey", + "description": "Windows UI: agent.settings.useApiKey", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.setup.disabled", + "description": "Windows UI: agent.setup.disabled", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.setup.enable", + "description": "Windows UI: agent.setup.enable", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.setup.invalidCliConfiguration", + "description": "Windows UI: agent.setup.invalidCliConfiguration", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.setup.missingApiKey", + "description": "Windows UI: agent.setup.missingApiKey", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.setup.missingCommand", + "description": "Windows UI: agent.setup.missingCommand", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.setup.missingProvider", + "description": "Windows UI: agent.setup.missingProvider", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.setup.noAgent", + "description": "Windows UI: agent.setup.noAgent", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.setup.noProject", + "description": "Windows UI: agent.setup.noProject", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.setup.openSettings", + "description": "Windows UI: agent.setup.openSettings", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.setup.subscriptionNeedsCodex", + "description": "Windows UI: agent.setup.subscriptionNeedsCodex", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.state.cancel", + "description": "Windows UI: agent.state.cancel", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.state.failed", + "description": "Windows UI: agent.state.failed", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.state.signIn", + "description": "Windows UI: agent.state.signIn", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.state.signInAction", + "description": "Windows UI: agent.state.signInAction", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.state.signInMessage", + "description": "Windows UI: agent.state.signInMessage", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.state.signingIn", + "description": "Windows UI: agent.state.signingIn", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.state.signingInMessage", + "description": "Windows UI: agent.state.signingInMessage", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.state.starting", + "description": "Windows UI: agent.state.starting", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.state.startingMessage", + "description": "Windows UI: agent.state.startingMessage", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.stats.calibrated", + "description": "Windows UI: agent.stats.calibrated", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.stats.calls", + "description": "Windows UI: agent.stats.calls", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.stats.estimated", + "description": "Windows UI: agent.stats.estimated", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.stats.lowerBound", + "description": "Windows UI: agent.stats.lowerBound", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.stats.mcp", + "description": "Windows UI: agent.stats.mcp", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.stats.messages", + "description": "Windows UI: agent.stats.messages", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.stats.modelTime", + "description": "Windows UI: agent.stats.modelTime", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.stats.noComposition", + "description": "Windows UI: agent.stats.noComposition", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.stats.nonStreaming", + "description": "Windows UI: agent.stats.nonStreaming", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.stats.observe", + "description": "Windows UI: agent.stats.observe", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.stats.observeHint", + "description": "Windows UI: agent.stats.observeHint", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.stats.system", + "description": "Windows UI: agent.stats.system", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.stats.toolCalls", + "description": "Windows UI: agent.stats.toolCalls", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.stats.toolCount", + "description": "Windows UI: agent.stats.toolCount", + "platforms": [ + "windows" + ], + "parameters": { + "mcp": "string", + "tools": "string" + } + }, + { + "id": "agent.stats.toolTime", + "description": "Windows UI: agent.stats.toolTime", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.stats.tools", + "description": "Windows UI: agent.stats.tools", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.stats.tps", + "description": "Windows UI: agent.stats.tps", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.stats.ttft", + "description": "Windows UI: agent.stats.ttft", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.stats.unknown", + "description": "Windows UI: agent.stats.unknown", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.status.connecting", + "description": "Windows UI: agent.status.connecting", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.status.failed", + "description": "Windows UI: agent.status.failed", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.status.retry", + "description": "Windows UI: agent.status.retry", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.stop", + "description": "Windows UI: agent.stop", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Stop", + "arguments": [] + } + }, + { + "id": "agent.subagents.cancelled", + "description": "Windows UI: agent.subagents.cancelled", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.subagents.completed", + "description": "Windows UI: agent.subagents.completed", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.subagents.disconnected", + "description": "Windows UI: agent.subagents.disconnected", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.subagents.failed", + "description": "Windows UI: agent.subagents.failed", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.subagents.progress", + "description": "Windows UI: agent.subagents.progress", + "platforms": [ + "windows" + ], + "parameters": { + "completed": "string", + "total": "string" + } + }, + { + "id": "agent.subagents.running", + "description": "Windows UI: agent.subagents.running", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.subagents.title", + "description": "Windows UI: agent.subagents.title", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.subscription.authenticating", + "description": "Windows UI: agent.subscription.authenticating", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.subscription.cancelSignIn", + "description": "Windows UI: agent.subscription.cancelSignIn", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.subscription.required", + "description": "Windows UI: agent.subscription.required", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.subscription.signIn", + "description": "Windows UI: agent.subscription.signIn", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.subscription.signedIn", + "description": "Windows UI: agent.subscription.signedIn", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.tabs.close", + "description": "Windows UI: agent.tabs.close", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.tabs.label", + "description": "Windows UI: agent.tabs.label", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.tabs.untitled", + "description": "Windows UI: agent.tabs.untitled", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.title", + "description": "Windows UI: agent.title", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.tool.details", + "description": "Windows UI: agent.tool.details", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.tool.status.completed", + "description": "Windows UI: agent.tool.status.completed", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.tool.status.failed", + "description": "Windows UI: agent.tool.status.failed", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.tool.status.inProgress", + "description": "Windows UI: agent.tool.status.in_progress", + "platforms": [ + "windows" + ], + "windows": { + "key": "agent.tool.status.in_progress" + } + }, + { + "id": "agent.tool.status.interrupted", + "description": "Windows UI: agent.tool.status.interrupted", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.tool.status.pending", + "description": "Windows UI: agent.tool.status.pending", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.toolGroup.allCompleted", + "description": "Windows UI: agent.toolGroup.allCompleted", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.toolGroup.completed", + "description": "Windows UI: agent.toolGroup.completed", + "platforms": [ + "windows" + ], + "parameters": { + "done": "string", + "total": "string" + } + }, + { + "id": "agent.toolGroup.failed", + "description": "Windows UI: agent.toolGroup.failed", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "agent.toolGroup.hide", + "description": "Windows UI: agent.toolGroup.hide", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.toolGroup.interrupted", + "description": "Windows UI: agent.toolGroup.interrupted", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "agent.toolGroup.show", + "description": "Windows UI: agent.toolGroup.show", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.toolGroup.title", + "description": "Windows UI: agent.toolGroup.title", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "agent.transcript.code", + "description": "Windows UI: agent.transcript.code", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.transcript.copyCode", + "description": "Windows UI: agent.transcript.copyCode", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.transcript.copyReply", + "description": "Windows UI: agent.transcript.copyReply", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.transcript.empty", + "description": "Windows UI: agent.transcript.empty", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.transcript.hideReasoning", + "description": "Windows UI: agent.transcript.hideReasoning", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.transcript.loading", + "description": "Windows UI: agent.transcript.loading", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.transcript.noMatch", + "description": "Windows UI: agent.transcript.noMatch", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.transcript.pickSession", + "description": "Windows UI: agent.transcript.pickSession", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.transcript.reasoning", + "description": "Windows UI: agent.transcript.reasoning", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.transcript.showReasoning", + "description": "Windows UI: agent.transcript.showReasoning", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.transcript.stopping", + "description": "Windows UI: agent.transcript.stopping", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.transcript.thinking", + "description": "Windows UI: agent.transcript.thinking", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.turn.cacheRead", + "description": "Windows UI: agent.turn.cacheRead", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "agent.turn.cacheReadLabel", + "description": "Windows UI: agent.turn.cacheReadLabel", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.turn.cacheWrite", + "description": "Windows UI: agent.turn.cacheWrite", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "agent.turn.cacheWriteLabel", + "description": "Windows UI: agent.turn.cacheWriteLabel", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.turn.elapsed", + "description": "Windows UI: agent.turn.elapsed", + "platforms": [ + "windows" + ], + "parameters": { + "duration": "string" + } + }, + { + "id": "agent.turn.elapsedHint", + "description": "Windows UI: agent.turn.elapsedHint", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.turn.elapsedLabel", + "description": "Windows UI: agent.turn.elapsedLabel", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.turn.hours", + "description": "Windows UI: agent.turn.hours", + "platforms": [ + "windows" + ], + "parameters": { + "hours": "string", + "minutes": "string", + "seconds": "string" + } + }, + { + "id": "agent.turn.input", + "description": "Windows UI: agent.turn.input", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "agent.turn.inputLabel", + "description": "Windows UI: agent.turn.inputLabel", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.turn.minutes", + "description": "Windows UI: agent.turn.minutes", + "platforms": [ + "windows" + ], + "parameters": { + "minutes": "string", + "seconds": "string" + } + }, + { + "id": "agent.turn.output", + "description": "Windows UI: agent.turn.output", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "agent.turn.outputLabel", + "description": "Windows UI: agent.turn.outputLabel", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.turn.reasoning", + "description": "Windows UI: agent.turn.reasoning", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "agent.turn.reasoningLabel", + "description": "Windows UI: agent.turn.reasoningLabel", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.turn.seconds", + "description": "Windows UI: agent.turn.seconds", + "platforms": [ + "windows" + ], + "parameters": { + "seconds": "string" + } + }, + { + "id": "agent.turn.thisTurn", + "description": "Windows UI: agent.turn.thisTurn", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.turn.total", + "description": "Windows UI: agent.turn.total", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "agent.turn.totalLabel", + "description": "Windows UI: agent.turn.totalLabel", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.turn.usageHint", + "description": "Windows UI: agent.turn.usageHint", + "platforms": [ + "windows" + ] + }, + { + "id": "agent.usage.tokens", + "description": "Windows UI: agent.usage.tokens", + "platforms": [ + "windows" + ], + "parameters": { + "capacity": "string", + "used": "string" + } + }, + { + "id": "agent.usage.unknown", + "description": "Windows UI: agent.usage.unknown", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.actionRunNumber", + "description": "Windows UI: ai.actionRunNumber", + "platforms": [ + "windows" + ], + "parameters": { + "number": "string" + } + }, + { + "id": "ai.add", + "description": "Windows UI: ai.add", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Add", + "arguments": [] + } + }, + { + "id": "ai.addContext", + "description": "Windows UI: ai.addContext", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.addToQueue", + "description": "Windows UI: ai.addToQueue", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.added", + "description": "Windows UI: ai.added", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.agent", + "description": "Windows UI: ai.agent", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.agentDefault", + "description": "Windows UI: ai.agentDefault", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.agentHistory", + "description": "Windows UI: ai.agentHistory", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.agentInstallFailed", + "description": "Windows UI: ai.agentInstallFailed", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "ai.agentInstalled", + "description": "Windows UI: ai.agentInstalled", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "ai.agentSessionRestarted", + "description": "Windows UI: ai.agentSessionRestarted", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.agentSetup", + "description": "Windows UI: ai.agentSetup", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.agents", + "description": "Windows UI: ai.agents", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.aiPreferences", + "description": "Windows UI: ai.aiPreferences", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.allow", + "description": "Windows UI: ai.allow", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.allowOnce", + "description": "Windows UI: ai.allowOnce", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.always", + "description": "Windows UI: ai.always", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.alwaysAllowRequestType", + "description": "Windows UI: ai.alwaysAllowRequestType", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.alwaysDenyRequestType", + "description": "Windows UI: ai.alwaysDenyRequestType", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.apiKeyRequired", + "description": "Windows UI: ai.apiKeyRequired", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.apiKeySaved", + "description": "Windows UI: ai.apiKeySaved", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.apiKeySavedWithPeriod", + "description": "Windows UI: ai.apiKeySavedWithPeriod", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.apiKeys", + "description": "Windows UI: ai.apiKeys", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.apply", + "description": "Windows UI: ai.apply", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Apply", + "arguments": [] + } + }, + { + "id": "ai.applyCodeToCurrentBuffer", + "description": "Windows UI: ai.applyCodeToCurrentBuffer", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.archiveSession", + "description": "Windows UI: ai.archiveSession", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.ask", + "description": "Windows UI: ai.ask", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.branch", + "description": "Windows UI: ai.branch", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.browse", + "description": "Windows UI: ai.browse", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.cancel", + "description": "Windows UI: ai.cancel", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.clickToRenameSession", + "description": "Windows UI: ai.clickToRenameSession", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.closeMessageSearch", + "description": "Windows UI: ai.closeMessageSearch", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.closeSearch", + "description": "Windows UI: ai.closeSearch", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.completeLoginThenRestart", + "description": "Windows UI: ai.completeLoginThenRestart", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.configureCustomModel", + "description": "Windows UI: ai.configureCustomModel", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.copyCode", + "description": "Windows UI: ai.copyCode", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.copyPrompt", + "description": "Windows UI: ai.copyPrompt", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.copyResponse", + "description": "Windows UI: ai.copyResponse", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.copyTableHtml", + "description": "Windows UI: ai.copyTableHtml", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.copyTableMarkdown", + "description": "Windows UI: ai.copyTableMarkdown", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.couldNotOpenAgentTerminal", + "description": "Windows UI: ai.couldNotOpenAgentTerminal", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.couldNotRestartAgentSession", + "description": "Windows UI: ai.couldNotRestartAgentSession", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.deleteNamedSkill", + "description": "Windows UI: ai.deleteNamedSkill", + "platforms": [ + "windows" + ], + "parameters": { + "title": "string" + } + }, + { + "id": "ai.deleteSkill", + "description": "Windows UI: ai.deleteSkill", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.deny", + "description": "Windows UI: ai.deny", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.denyOnce", + "description": "Windows UI: ai.denyOnce", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.details", + "description": "Windows UI: ai.details", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.editNamedSkill", + "description": "Windows UI: ai.editNamedSkill", + "platforms": [ + "windows" + ], + "parameters": { + "title": "string" + } + }, + { + "id": "ai.editPrompt", + "description": "Windows UI: ai.editPrompt", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.editSkill", + "description": "Windows UI: ai.editSkill", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.enterApiKey", + "description": "Windows UI: ai.enterApiKey", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.error", + "description": "Windows UI: ai.error", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.executePlan", + "description": "Windows UI: ai.executePlan", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.executePlanStepPrompt", + "description": "Windows UI: ai.executePlanStepPrompt", + "platforms": [ + "windows" + ], + "parameters": { + "description": "string", + "number": "string", + "title": "string" + } + }, + { + "id": "ai.failedFetchModels", + "description": "Windows UI: ai.failedFetchModels", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.failedRemoveApiKey", + "description": "Windows UI: ai.failedRemoveApiKey", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.failedValidateApiKey", + "description": "Windows UI: ai.failedValidateApiKey", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.fileList", + "description": "Windows UI: ai.fileList", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.files", + "description": "Windows UI: ai.files", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.finishAgentSetupThenRestart", + "description": "Windows UI: ai.finishAgentSetupThenRestart", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.generatedContentNumber", + "description": "Windows UI: ai.generatedContentNumber", + "platforms": [ + "windows" + ], + "parameters": { + "number": "string" + } + }, + { + "id": "ai.generatedImageNumber", + "description": "Windows UI: ai.generatedImageNumber", + "platforms": [ + "windows" + ], + "parameters": { + "number": "string" + } + }, + { + "id": "ai.hideDetails", + "description": "Windows UI: ai.hideDetails", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.install", + "description": "Windows UI: ai.install", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Install", + "arguments": [] + } + }, + { + "id": "ai.installingAgent", + "description": "Windows UI: ai.installingAgent", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "ai.invalidApiKey", + "description": "Windows UI: ai.invalidApiKey", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.loadingAvailableSkills", + "description": "Windows UI: ai.loadingAvailableSkills", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.loadingModels", + "description": "Windows UI: ai.loadingModels", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.localOverride", + "description": "Windows UI: ai.localOverride", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.markdown", + "description": "Windows UI: ai.markdown", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.marketplace", + "description": "Windows UI: ai.marketplace", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.marketplaceSkill", + "description": "Windows UI: ai.marketplaceSkill", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.marketplaceSkillWithLocalOverride", + "description": "Windows UI: ai.marketplaceSkillWithLocalOverride", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.messageInput", + "description": "Windows UI: ai.messageInput", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.microphoneAccessFailed", + "description": "Windows UI: ai.microphoneAccessFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.microphoneAccessFailedLinux", + "description": "Windows UI: ai.microphoneAccessFailedLinux", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.mode", + "description": "Windows UI: ai.mode", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.model", + "description": "Windows UI: ai.model", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.mySkills", + "description": "Windows UI: ai.mySkills", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.never", + "description": "Windows UI: ai.never", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.new", + "description": "Windows UI: ai.new", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.newAgent", + "description": "Windows UI: ai.newAgent", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.newSession", + "description": "Windows UI: ai.newSession", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.newSkill", + "description": "Windows UI: ai.newSkill", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.nextMatch", + "description": "Windows UI: ai.nextMatch", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.nextSearchMatch", + "description": "Windows UI: ai.nextSearchMatch", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.noAvailableSkillsMatch", + "description": "Windows UI: ai.noAvailableSkillsMatch", + "platforms": [ + "windows" + ], + "parameters": { + "query": "string" + } + }, + { + "id": "ai.noMatchingContextFound", + "description": "Windows UI: ai.noMatchingContextFound", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.noMatchingSlashCommands", + "description": "Windows UI: ai.noMatchingSlashCommands", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.noModelsFound", + "description": "Windows UI: ai.noModelsFound", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.noOllamaModelsDetected", + "description": "Windows UI: ai.noOllamaModelsDetected", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.noProvidersFound", + "description": "Windows UI: ai.noProvidersFound", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.noPublishedSkillsYet", + "description": "Windows UI: ai.noPublishedSkillsYet", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.noSkillsMatch", + "description": "Windows UI: ai.noSkillsMatch", + "platforms": [ + "windows" + ], + "parameters": { + "query": "string" + } + }, + { + "id": "ai.noSkillsYet", + "description": "Windows UI: ai.noSkillsYet", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.noSlashCommandsAvailable", + "description": "Windows UI: ai.noSlashCommandsAvailable", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.notSynced", + "description": "Windows UI: ai.notSynced", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.open", + "description": "Windows UI: ai.open", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.openAgentTerminal", + "description": "Windows UI: ai.openAgentTerminal", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.openDashboard", + "description": "Windows UI: ai.openDashboard", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.openDiff", + "description": "Windows UI: ai.openDiff", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.openFile", + "description": "Windows UI: ai.openFile", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.openLower", + "description": "Windows UI: ai.openLower", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.openResource", + "description": "Windows UI: ai.openResource", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "ai.openTabs", + "description": "Windows UI: ai.openTabs", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.openTerminal", + "description": "Windows UI: ai.openTerminal", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.opening", + "description": "Windows UI: ai.opening", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.permission", + "description": "Windows UI: ai.permission", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.pinSession", + "description": "Windows UI: ai.pinSession", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.plan", + "description": "Windows UI: ai.plan", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.planSummary", + "description": "Windows UI: ai.planSummary", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string", + "label": "string" + } + }, + { + "id": "ai.pleaseEnterApiKey", + "description": "Windows UI: ai.pleaseEnterApiKey", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.previousMatch", + "description": "Windows UI: ai.previousMatch", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.previousSearchMatch", + "description": "Windows UI: ai.previousSearchMatch", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.project", + "description": "Windows UI: ai.project", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Project", + "arguments": [] + } + }, + { + "id": "ai.provider", + "description": "Windows UI: ai.provider", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.publishedSkillsWillAppear", + "description": "Windows UI: ai.publishedSkillsWillAppear", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.pullRequestNumber", + "description": "Windows UI: ai.pullRequestNumber", + "platforms": [ + "windows" + ], + "parameters": { + "number": "string" + } + }, + { + "id": "ai.recent", + "description": "Windows UI: ai.recent", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.remove", + "description": "Windows UI: ai.remove", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.removeContextItemHint", + "description": "Windows UI: ai.removeContextItemHint", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "ai.renameSession", + "description": "Windows UI: ai.renameSession", + "platforms": [ + "windows" + ], + "parameters": { + "title": "string" + } + }, + { + "id": "ai.restartAgentSession", + "description": "Windows UI: ai.restartAgentSession", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.restarting", + "description": "Windows UI: ai.restarting", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.save", + "description": "Windows UI: ai.save", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.saveKey", + "description": "Windows UI: ai.saveKey", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.searchApiKeyProviders", + "description": "Windows UI: ai.searchApiKeyProviders", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.searchAvailableSkills", + "description": "Windows UI: ai.searchAvailableSkills", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.searchContext", + "description": "Windows UI: ai.searchContext", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.searchFiles", + "description": "Windows UI: ai.searchFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.searchMessages", + "description": "Windows UI: ai.searchMessages", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.searchSkills", + "description": "Windows UI: ai.searchSkills", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.selectAiModel", + "description": "Windows UI: ai.selectAiModel", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.selectAiProvider", + "description": "Windows UI: ai.selectAiProvider", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.selectModel", + "description": "Windows UI: ai.selectModel", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.selectProvider", + "description": "Windows UI: ai.selectProvider", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.selectedContext", + "description": "Windows UI: ai.selectedContext", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.send", + "description": "Windows UI: ai.send", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.sendMessage", + "description": "Windows UI: ai.sendMessage", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.settings", + "description": "Windows UI: ai.settings", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Settings", + "arguments": [] + } + }, + { + "id": "ai.showSlashCommands", + "description": "Windows UI: ai.showSlashCommands", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.skillContentPlaceholder", + "description": "Windows UI: ai.skillContentPlaceholder", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.skillTitlePlaceholder", + "description": "Windows UI: ai.skillTitlePlaceholder", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.skills", + "description": "Windows UI: ai.skills", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.slashCommandSuggestions", + "description": "Windows UI: ai.slashCommandSuggestions", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.startVoiceInput", + "description": "Windows UI: ai.startVoiceInput", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.step", + "description": "Windows UI: ai.step", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.steps", + "description": "Windows UI: ai.steps", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.stopGeneration", + "description": "Windows UI: ai.stopGeneration", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.stopVoiceInput", + "description": "Windows UI: ai.stopVoiceInput", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.suggestions", + "description": "Windows UI: ai.suggestions", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.syncPaused", + "description": "Windows UI: ai.syncPaused", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.synced", + "description": "Windows UI: ai.synced", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.syncing", + "description": "Windows UI: ai.syncing", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.terminal", + "description": "Windows UI: ai.terminal", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Terminal", + "arguments": [] + } + }, + { + "id": "ai.thinking", + "description": "Windows UI: ai.thinking", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.title", + "description": "Windows UI: ai.title", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.toggleAgentHistory", + "description": "Windows UI: ai.toggleAgentHistory", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.toolCall", + "description": "Windows UI: ai.toolCall", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.toolCalls", + "description": "Windows UI: ai.toolCalls", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "ai.toolChangedFile", + "description": "Windows UI: ai.toolChangedFile", + "platforms": [ + "windows" + ], + "parameters": { + "file": "string" + } + }, + { + "id": "ai.toolChangedFiles", + "description": "Windows UI: ai.toolChangedFiles", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "ai.toolDetailWithSummary", + "description": "Windows UI: ai.toolDetailWithSummary", + "platforms": [ + "windows" + ], + "parameters": { + "status": "string", + "summary": "string" + } + }, + { + "id": "ai.toolStatusCompleted", + "description": "Windows UI: ai.toolStatusCompleted", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.toolStatusFailed", + "description": "Windows UI: ai.toolStatusFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.toolStatusPending", + "description": "Windows UI: ai.toolStatusPending", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.toolStatusRunning", + "description": "Windows UI: ai.toolStatusRunning", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.toolTerminalOutput", + "description": "Windows UI: ai.toolTerminalOutput", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.toolTerminals", + "description": "Windows UI: ai.toolTerminals", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "ai.typeModelName", + "description": "Windows UI: ai.typeModelName", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.unknownError", + "description": "Windows UI: ai.unknownError", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.unpinSession", + "description": "Windows UI: ai.unpinSession", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.useCustomValue", + "description": "Windows UI: ai.useCustomValue", + "platforms": [ + "windows" + ], + "parameters": { + "value": "string" + } + }, + { + "id": "ai.validating", + "description": "Windows UI: ai.validating", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.voiceCouldNotStart", + "description": "Windows UI: ai.voiceCouldNotStart", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.voiceDisabledMacDev", + "description": "Windows UI: ai.voiceDisabledMacDev", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.voiceDisabledMacDevShort", + "description": "Windows UI: ai.voiceDisabledMacDevShort", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.voiceNotSupported", + "description": "Windows UI: ai.voiceNotSupported", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.voiceNotSupportedWebview", + "description": "Windows UI: ai.voiceNotSupportedWebview", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.voiceStoppedUnexpectedly", + "description": "Windows UI: ai.voiceStoppedUnexpectedly", + "platforms": [ + "windows" + ] + }, + { + "id": "ai.voiceUnavailableLinuxWebview", + "description": "Windows UI: ai.voiceUnavailableLinuxWebview", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.add", + "description": "Windows UI: aiCommit.add", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.aiCOMMITBUSY", + "description": "Windows UI: aiCommit.AI_COMMIT_BUSY", + "platforms": [ + "windows" + ], + "windows": { + "key": "aiCommit.AI_COMMIT_BUSY" + } + }, + { + "id": "aiCommit.aiCOMMITCONFIGMISSING", + "description": "Windows UI: aiCommit.AI_COMMIT_CONFIG_MISSING", + "platforms": [ + "windows" + ], + "windows": { + "key": "aiCommit.AI_COMMIT_CONFIG_MISSING" + } + }, + { + "id": "aiCommit.aiCOMMITCONFIGREADFAILED", + "description": "Windows UI: aiCommit.AI_COMMIT_CONFIG_READ_FAILED", + "platforms": [ + "windows" + ], + "windows": { + "key": "aiCommit.AI_COMMIT_CONFIG_READ_FAILED" + } + }, + { + "id": "aiCommit.aiCOMMITCONFIGTOOLARGE", + "description": "Windows UI: aiCommit.AI_COMMIT_CONFIG_TOO_LARGE", + "platforms": [ + "windows" + ], + "windows": { + "key": "aiCommit.AI_COMMIT_CONFIG_TOO_LARGE" + } + }, + { + "id": "aiCommit.aiCOMMITDIFFFAILED", + "description": "Windows UI: aiCommit.AI_COMMIT_DIFF_FAILED", + "platforms": [ + "windows" + ], + "windows": { + "key": "aiCommit.AI_COMMIT_DIFF_FAILED" + } + }, + { + "id": "aiCommit.aiCOMMITDRAFTCHANGED", + "description": "Windows UI: aiCommit.AI_COMMIT_DRAFT_CHANGED", + "platforms": [ + "windows" + ], + "windows": { + "key": "aiCommit.AI_COMMIT_DRAFT_CHANGED" + } + }, + { + "id": "aiCommit.aiCOMMITEMPTYDIFF", + "description": "Windows UI: aiCommit.AI_COMMIT_EMPTY_DIFF", + "platforms": [ + "windows" + ], + "windows": { + "key": "aiCommit.AI_COMMIT_EMPTY_DIFF" + } + }, + { + "id": "aiCommit.aiCOMMITEMPTYRESPONSE", + "description": "Windows UI: aiCommit.AI_COMMIT_EMPTY_RESPONSE", + "platforms": [ + "windows" + ], + "windows": { + "key": "aiCommit.AI_COMMIT_EMPTY_RESPONSE" + } + }, + { + "id": "aiCommit.aiCOMMITINSECUREENDPOINT", + "description": "Windows UI: aiCommit.AI_COMMIT_INSECURE_ENDPOINT", + "platforms": [ + "windows" + ], + "windows": { + "key": "aiCommit.AI_COMMIT_INSECURE_ENDPOINT" + } + }, + { + "id": "aiCommit.aiCOMMITINVALIDCONFIG", + "description": "Windows UI: aiCommit.AI_COMMIT_INVALID_CONFIG", + "platforms": [ + "windows" + ], + "windows": { + "key": "aiCommit.AI_COMMIT_INVALID_CONFIG" + } + }, + { + "id": "aiCommit.aiCOMMITINVALIDOPTIONS", + "description": "Windows UI: aiCommit.AI_COMMIT_INVALID_OPTIONS", + "platforms": [ + "windows" + ], + "windows": { + "key": "aiCommit.AI_COMMIT_INVALID_OPTIONS" + } + }, + { + "id": "aiCommit.aiCOMMITINVALIDPROVIDER", + "description": "Windows UI: aiCommit.AI_COMMIT_INVALID_PROVIDER", + "platforms": [ + "windows" + ], + "windows": { + "key": "aiCommit.AI_COMMIT_INVALID_PROVIDER" + } + }, + { + "id": "aiCommit.aiCOMMITINVALIDRESPONSE", + "description": "Windows UI: aiCommit.AI_COMMIT_INVALID_RESPONSE", + "platforms": [ + "windows" + ], + "windows": { + "key": "aiCommit.AI_COMMIT_INVALID_RESPONSE" + } + }, + { + "id": "aiCommit.aiCOMMITKEYFAILED", + "description": "Windows UI: aiCommit.AI_COMMIT_KEY_FAILED", + "platforms": [ + "windows" + ], + "windows": { + "key": "aiCommit.AI_COMMIT_KEY_FAILED" + } + }, + { + "id": "aiCommit.aiCOMMITMISSINGKEY", + "description": "Windows UI: aiCommit.AI_COMMIT_MISSING_KEY", + "platforms": [ + "windows" + ], + "windows": { + "key": "aiCommit.AI_COMMIT_MISSING_KEY" + } + }, + { + "id": "aiCommit.aiCOMMITMULTIPLEREPOSITORIES", + "description": "Windows UI: aiCommit.AI_COMMIT_MULTIPLE_REPOSITORIES", + "platforms": [ + "windows" + ], + "windows": { + "key": "aiCommit.AI_COMMIT_MULTIPLE_REPOSITORIES" + } + }, + { + "id": "aiCommit.aiCOMMITNETWORKERROR", + "description": "Windows UI: aiCommit.AI_COMMIT_NETWORK_ERROR", + "platforms": [ + "windows" + ], + "windows": { + "key": "aiCommit.AI_COMMIT_NETWORK_ERROR" + } + }, + { + "id": "aiCommit.aiCOMMITOUTPUTLIMIT", + "description": "Windows UI: aiCommit.AI_COMMIT_OUTPUT_LIMIT", + "platforms": [ + "windows" + ], + "windows": { + "key": "aiCommit.AI_COMMIT_OUTPUT_LIMIT" + } + }, + { + "id": "aiCommit.aiCOMMITRESPONSETOOLARGE", + "description": "Windows UI: aiCommit.AI_COMMIT_RESPONSE_TOO_LARGE", + "platforms": [ + "windows" + ], + "windows": { + "key": "aiCommit.AI_COMMIT_RESPONSE_TOO_LARGE" + } + }, + { + "id": "aiCommit.aiCOMMITSENSITIVEFILE", + "description": "Windows UI: aiCommit.AI_COMMIT_SENSITIVE_FILE", + "platforms": [ + "windows" + ], + "windows": { + "key": "aiCommit.AI_COMMIT_SENSITIVE_FILE" + } + }, + { + "id": "aiCommit.aiCOMMITSTALE", + "description": "Windows UI: aiCommit.AI_COMMIT_STALE", + "platforms": [ + "windows" + ], + "windows": { + "key": "aiCommit.AI_COMMIT_STALE" + } + }, + { + "id": "aiCommit.aiCOMMITTIMEOUT", + "description": "Windows UI: aiCommit.AI_COMMIT_TIMEOUT", + "platforms": [ + "windows" + ], + "windows": { + "key": "aiCommit.AI_COMMIT_TIMEOUT" + } + }, + { + "id": "aiCommit.aiCOMMITTOOMANYFILES", + "description": "Windows UI: aiCommit.AI_COMMIT_TOO_MANY_FILES", + "platforms": [ + "windows" + ], + "windows": { + "key": "aiCommit.AI_COMMIT_TOO_MANY_FILES" + } + }, + { + "id": "aiCommit.allowHttp", + "description": "Windows UI: aiCommit.allowHttp", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.authentication", + "description": "Windows UI: aiCommit.authentication", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.body", + "description": "Windows UI: aiCommit.body", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.cancel", + "description": "Windows UI: aiCommit.cancel", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.clearKey", + "description": "Windows UI: aiCommit.clearKey", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.concise", + "description": "Windows UI: aiCommit.concise", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.configure", + "description": "Windows UI: aiCommit.configure", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.conventional", + "description": "Windows UI: aiCommit.conventional", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.custom", + "description": "Windows UI: aiCommit.custom", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.customFormat", + "description": "Windows UI: aiCommit.customFormat", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.defaultEffort", + "description": "Windows UI: aiCommit.defaultEffort", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.descriptive", + "description": "Windows UI: aiCommit.descriptive", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.detect", + "description": "Windows UI: aiCommit.detect", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.detected", + "description": "Windows UI: aiCommit.detected", + "platforms": [ + "windows" + ], + "parameters": { + "source": "string" + } + }, + { + "id": "aiCommit.diff", + "description": "Windows UI: aiCommit.diff", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.disclosure", + "description": "Windows UI: aiCommit.disclosure", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.draftChanged", + "description": "Windows UI: aiCommit.draftChanged", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.effort", + "description": "Windows UI: aiCommit.effort", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.enabled", + "description": "Windows UI: aiCommit.enabled", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.endpoint", + "description": "Windows UI: aiCommit.endpoint", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.example", + "description": "Windows UI: aiCommit.example", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.exampleMessage", + "description": "Selected-language presentation: Add an editor memory indicator", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.failed", + "description": "Windows UI: aiCommit.failed", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.format", + "description": "Windows UI: aiCommit.format", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.generating", + "description": "Windows UI: aiCommit.generating", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.httpError", + "description": "Windows UI: aiCommit.httpError", + "platforms": [ + "windows" + ], + "parameters": { + "status": "string" + } + }, + { + "id": "aiCommit.imperative", + "description": "Windows UI: aiCommit.imperative", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.import", + "description": "Windows UI: aiCommit.import", + "platforms": [ + "windows" + ], + "parameters": { + "source": "string" + } + }, + { + "id": "aiCommit.key", + "description": "Windows UI: aiCommit.key", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.keyStored", + "description": "Windows UI: aiCommit.keyStored", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.language", + "description": "Windows UI: aiCommit.language", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.legacyGateway", + "description": "Windows UI: aiCommit.legacyGateway", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.managed", + "description": "Windows UI: aiCommit.managed", + "platforms": [ + "windows" + ], + "parameters": { + "source": "string" + } + }, + { + "id": "aiCommit.model", + "description": "Windows UI: aiCommit.model", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.name", + "description": "Windows UI: aiCommit.name", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.noKey", + "description": "Windows UI: aiCommit.noKey", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.notDetected", + "description": "Windows UI: aiCommit.notDetected", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.profile", + "description": "Windows UI: aiCommit.profile", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.profiles", + "description": "Windows UI: aiCommit.profiles", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.protocol", + "description": "Windows UI: aiCommit.protocol", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.releaseNote", + "description": "Windows UI: aiCommit.releaseNote", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.remove", + "description": "Windows UI: aiCommit.remove", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.replace", + "description": "Windows UI: aiCommit.replace", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.requiresKey", + "description": "Windows UI: aiCommit.requiresKey", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.rules", + "description": "Windows UI: aiCommit.rules", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.saveKey", + "description": "Windows UI: aiCommit.saveKey", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.saved", + "description": "Windows UI: aiCommit.saved", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.settings", + "description": "Windows UI: aiCommit.settings", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.stale", + "description": "Windows UI: aiCommit.stale", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.subject", + "description": "Windows UI: aiCommit.subject", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.tokenLimitField", + "description": "Windows UI: aiCommit.tokenLimitField", + "platforms": [ + "windows" + ] + }, + { + "id": "aiHistory.archived", + "description": "Windows UI: aiHistory.archived", + "platforms": [ + "windows" + ] + }, + { + "id": "aiHistory.deleteSession", + "description": "Windows UI: aiHistory.deleteSession", + "platforms": [ + "windows" + ] + }, + { + "id": "aiHistory.deleteSessionNamed", + "description": "Windows UI: aiHistory.deleteSessionNamed", + "platforms": [ + "windows" + ], + "parameters": { + "title": "string" + } + }, + { + "id": "aiHistory.empty", + "description": "Windows UI: aiHistory.empty", + "platforms": [ + "windows" + ] + }, + { + "id": "aiHistory.noMatches", + "description": "Windows UI: aiHistory.noMatches", + "platforms": [ + "windows" + ], + "parameters": { + "query": "string" + } + }, + { + "id": "aiHistory.restoreSession", + "description": "Windows UI: aiHistory.restoreSession", + "platforms": [ + "windows" + ] + }, + { + "id": "aiHistory.restoreSessionNamed", + "description": "Windows UI: aiHistory.restoreSessionNamed", + "platforms": [ + "windows" + ], + "parameters": { + "title": "string" + } + }, + { + "id": "aiHistory.searchPlaceholder", + "description": "Windows UI: aiHistory.searchPlaceholder", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.acpSession", + "description": "Windows UI: aiSettings.acpSession", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.acpSessionOptionDescription", + "description": "Windows UI: aiSettings.acpSessionOptionDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.agentHistory", + "description": "Windows UI: aiSettings.agentHistory", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.agentHistoryCleared", + "description": "Windows UI: aiSettings.agentHistoryCleared", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.aiAutocomplete", + "description": "Windows UI: aiSettings.aiAutocomplete", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.aiAutocompleteDescription", + "description": "Windows UI: aiSettings.aiAutocompleteDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.apiKey", + "description": "Windows UI: aiSettings.apiKey", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.apiKeys", + "description": "Windows UI: aiSettings.apiKeys", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.apiKeysDescription", + "description": "Windows UI: aiSettings.apiKeysDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.authentication", + "description": "Windows UI: aiSettings.authentication", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.autocomplete", + "description": "Windows UI: aiSettings.autocomplete", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.autocompleteModel", + "description": "Windows UI: aiSettings.autocompleteModel", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.autocompleteModelDescription", + "description": "Windows UI: aiSettings.autocompleteModelDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.autocompleteProvider", + "description": "Windows UI: aiSettings.autocompleteProvider", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.autocompleteProviderDescription", + "description": "Windows UI: aiSettings.autocompleteProviderDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.baseUrl", + "description": "Windows UI: aiSettings.baseUrl", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.checking", + "description": "Windows UI: aiSettings.checking", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.clearAgentHistory", + "description": "Windows UI: aiSettings.clearAgentHistory", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.clearAgentHistoryDescription", + "description": "Windows UI: aiSettings.clearAgentHistoryDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.clearAll", + "description": "Windows UI: aiSettings.clearAll", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.clearing", + "description": "Windows UI: aiSettings.clearing", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.cloud", + "description": "Windows UI: aiSettings.cloud", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.comingSoon", + "description": "Windows UI: aiSettings.comingSoon", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.configureProviderExtension", + "description": "Windows UI: aiSettings.configureProviderExtension", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.connectionStatus", + "description": "Windows UI: aiSettings.connectionStatus", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.custom", + "description": "Windows UI: aiSettings.custom", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.customApiKey", + "description": "Windows UI: aiSettings.customApiKey", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.customAutocompleteApiKeyRemoveFailed", + "description": "Windows UI: aiSettings.customAutocompleteApiKeyRemoveFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.customAutocompleteApiKeyRemoved", + "description": "Windows UI: aiSettings.customAutocompleteApiKeyRemoved", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.customAutocompleteApiKeySaveFailed", + "description": "Windows UI: aiSettings.customAutocompleteApiKeySaveFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.customAutocompleteApiKeySaved", + "description": "Windows UI: aiSettings.customAutocompleteApiKeySaved", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.customBaseUrl", + "description": "Windows UI: aiSettings.customBaseUrl", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.customModel", + "description": "Windows UI: aiSettings.customModel", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.customModelDescription", + "description": "Windows UI: aiSettings.customModelDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.customProvider", + "description": "Windows UI: aiSettings.customProvider", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.customProviderApiKeyRemoveFailed", + "description": "Windows UI: aiSettings.customProviderApiKeyRemoveFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.customProviderApiKeyRemoved", + "description": "Windows UI: aiSettings.customProviderApiKeyRemoved", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.customProviderApiKeySaveFailed", + "description": "Windows UI: aiSettings.customProviderApiKeySaveFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.customProviderApiKeySaved", + "description": "Windows UI: aiSettings.customProviderApiKeySaved", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.customProviderBaseUrlDescription", + "description": "Windows UI: aiSettings.customProviderBaseUrlDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.endpoint", + "description": "Windows UI: aiSettings.endpoint", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.error", + "description": "Windows UI: aiSettings.error", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.getKey", + "description": "Windows UI: aiSettings.getKey", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.loadingModels", + "description": "Windows UI: aiSettings.loadingModels", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.loadingModelsEllipsis", + "description": "Windows UI: aiSettings.loadingModelsEllipsis", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.local", + "description": "Windows UI: aiSettings.local", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.manageKeys", + "description": "Windows UI: aiSettings.manageKeys", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.mode", + "description": "Windows UI: aiSettings.mode", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.model", + "description": "Windows UI: aiSettings.model", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.modelDescription", + "description": "Windows UI: aiSettings.modelDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.modelList", + "description": "Windows UI: aiSettings.modelList", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.modelListEmpty", + "description": "Windows UI: aiSettings.modelListEmpty", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.modelListLoadFailed", + "description": "Windows UI: aiSettings.modelListLoadFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.noModelsLoaded", + "description": "Windows UI: aiSettings.noModelsLoaded", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.ollamaApiKeyDescription", + "description": "Windows UI: aiSettings.ollamaApiKeyDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.ollamaApiKeyRemoveFailed", + "description": "Windows UI: aiSettings.ollamaApiKeyRemoveFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.ollamaApiKeyRemoved", + "description": "Windows UI: aiSettings.ollamaApiKeyRemoved", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.ollamaApiKeySaveFailed", + "description": "Windows UI: aiSettings.ollamaApiKeySaveFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.ollamaApiKeySaved", + "description": "Windows UI: aiSettings.ollamaApiKeySaved", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.ollamaCloudConnectionFailed", + "description": "Windows UI: aiSettings.ollamaCloudConnectionFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.ollamaCloudKey", + "description": "Windows UI: aiSettings.ollamaCloudKey", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.ollamaCloudKeyDescription", + "description": "Windows UI: aiSettings.ollamaCloudKeyDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.ollamaEndpointDescription", + "description": "Windows UI: aiSettings.ollamaEndpointDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.ollamaLocalConnectionFailed", + "description": "Windows UI: aiSettings.ollamaLocalConnectionFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.ollamaMode", + "description": "Windows UI: aiSettings.ollamaMode", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.ollamaModeDescription", + "description": "Windows UI: aiSettings.ollamaModeDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.openAiCompatibleBaseUrlDescription", + "description": "Windows UI: aiSettings.openAiCompatibleBaseUrlDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.optionalBearerDescription", + "description": "Windows UI: aiSettings.optionalBearerDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.provider", + "description": "Windows UI: aiSettings.provider", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.providerDescription", + "description": "Windows UI: aiSettings.providerDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.refreshModelList", + "description": "Windows UI: aiSettings.refreshModelList", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.removeOllamaApiKey", + "description": "Windows UI: aiSettings.removeOllamaApiKey", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.removeSavedApiKey", + "description": "Windows UI: aiSettings.removeSavedApiKey", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.requiresOAuth", + "description": "Windows UI: aiSettings.requiresOAuth", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.resetOllamaUrl", + "description": "Windows UI: aiSettings.resetOllamaUrl", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.resetToDefault", + "description": "Windows UI: aiSettings.resetToDefault", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.saved", + "description": "Windows UI: aiSettings.saved", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.savedKeyDescription", + "description": "Windows UI: aiSettings.savedKeyDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "aiSettings.savedKeyPlaceholder", + "description": "Windows UI: aiSettings.savedKeyPlaceholder", + "platforms": [ + "windows" + ] + }, + { + "id": "aiShortcut.findFixBug", + "description": "Windows UI: aiShortcut.findFixBug", + "platforms": [ + "windows" + ] + }, + { + "id": "aiShortcut.findFixBugContent", + "description": "Windows UI: aiShortcut.findFixBugContent", + "platforms": [ + "windows" + ] + }, + { + "id": "aiShortcut.planImplementation", + "description": "Windows UI: aiShortcut.planImplementation", + "platforms": [ + "windows" + ] + }, + { + "id": "aiShortcut.planImplementationContent", + "description": "Windows UI: aiShortcut.planImplementationContent", + "platforms": [ + "windows" + ] + }, + { + "id": "aiShortcut.reviewChanges", + "description": "Windows UI: aiShortcut.reviewChanges", + "platforms": [ + "windows" + ] + }, + { + "id": "aiShortcut.reviewChangesContent", + "description": "Windows UI: aiShortcut.reviewChangesContent", + "platforms": [ + "windows" + ] + }, + { + "id": "aiShortcut.writeTests", + "description": "Windows UI: aiShortcut.writeTests", + "platforms": [ + "windows" + ] + }, + { + "id": "aiShortcut.writeTestsContent", + "description": "Windows UI: aiShortcut.writeTestsContent", + "platforms": [ + "windows" + ] + }, + { + "id": "backend.unavailable", + "description": "Selected-language presentation: This feature is not available yet.", + "platforms": [ + "windows" + ], + "windows": { + "key": "This feature is not available yet." + } + }, + { + "id": "commandPalette.actions.aiQuickEdit.label", + "description": "Windows UI: commandPalette.actions.ai-quick-edit.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.ai-quick-edit.label" + } + }, + { + "id": "commandPalette.actions.cliInstall.label", + "description": "Windows UI: commandPalette.actions.cli-install.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.cli-install.label" + } + }, + { + "id": "commandPalette.actions.colorTheme.label", + "description": "Windows UI: commandPalette.actions.color-theme.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.color-theme.label" + } + }, + { + "id": "commandPalette.actions.databaseConnect.label", + "description": "Windows UI: commandPalette.actions.database-connect.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.database-connect.label" + } + }, + { + "id": "commandPalette.actions.developerOpenLitheLog.label", + "description": "Windows UI: commandPalette.actions.developer-open-lithe-log.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.developer-open-lithe-log.label" + } + }, + { + "id": "commandPalette.actions.fileLocalHistory.label", + "description": "Windows UI: commandPalette.actions.file-local-history.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.file-local-history.label" + } + }, + { + "id": "commandPalette.actions.fileNew.label", + "description": "Windows UI: commandPalette.actions.file-new.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.file-new.label" + } + }, + { + "id": "commandPalette.actions.fileNewDocument.label", + "description": "Windows UI: commandPalette.actions.file-new-document.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.file-new-document.label" + } + }, + { + "id": "commandPalette.actions.fileOpenProject.label", + "description": "Windows UI: commandPalette.actions.file-open-project.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.file-open-project.label" + } + }, + { + "id": "commandPalette.actions.fileReopenWithEncoding.label", + "description": "Windows UI: commandPalette.actions.file-reopen-with-encoding.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.file-reopen-with-encoding.label" + } + }, + { + "id": "commandPalette.actions.fileSave.label", + "description": "Windows UI: commandPalette.actions.file-save.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.file-save.label" + } + }, + { + "id": "commandPalette.actions.fileSaveAll.label", + "description": "Windows UI: commandPalette.actions.file-save-all.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.file-save-all.label" + } + }, + { + "id": "commandPalette.actions.fileSaveAs.label", + "description": "Windows UI: commandPalette.actions.file-save-as.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.file-save-as.label" + } + }, + { + "id": "commandPalette.actions.fileSaveWithEncoding.label", + "description": "Windows UI: commandPalette.actions.file-save-with-encoding.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.file-save-with-encoding.label" + } + }, + { + "id": "commandPalette.actions.gitAddRemote.label", + "description": "Windows UI: commandPalette.actions.git-add-remote.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.git-add-remote.label" + } + }, + { + "id": "commandPalette.actions.gitApplyStash.label", + "description": "Windows UI: commandPalette.actions.git-apply-stash.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.git-apply-stash.label" + } + }, + { + "id": "commandPalette.actions.gitBranchManager.label", + "description": "Windows UI: commandPalette.actions.git-branch-manager.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.git-branch-manager.label" + } + }, + { + "id": "commandPalette.actions.gitCheckoutBranch.label", + "description": "Windows UI: commandPalette.actions.git-checkout-branch.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.git-checkout-branch.label" + } + }, + { + "id": "commandPalette.actions.gitCommit.label", + "description": "Windows UI: commandPalette.actions.git-commit.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.git-commit.label" + } + }, + { + "id": "commandPalette.actions.gitCompareTags.label", + "description": "Windows UI: commandPalette.actions.git-compare-tags.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.git-compare-tags.label" + } + }, + { + "id": "commandPalette.actions.gitCreateBranch.label", + "description": "Windows UI: commandPalette.actions.git-create-branch.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.git-create-branch.label" + } + }, + { + "id": "commandPalette.actions.gitCreateTag.label", + "description": "Windows UI: commandPalette.actions.git-create-tag.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.git-create-tag.label" + } + }, + { + "id": "commandPalette.actions.gitDeleteBranch.label", + "description": "Windows UI: commandPalette.actions.git-delete-branch.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.git-delete-branch.label" + } + }, + { + "id": "commandPalette.actions.gitDeleteTag.label", + "description": "Windows UI: commandPalette.actions.git-delete-tag.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.git-delete-tag.label" + } + }, + { + "id": "commandPalette.actions.gitDiscardAll.label", + "description": "Windows UI: commandPalette.actions.git-discard-all.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.git-discard-all.label" + } + }, + { + "id": "commandPalette.actions.gitDropStash.label", + "description": "Windows UI: commandPalette.actions.git-drop-stash.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.git-drop-stash.label" + } + }, + { + "id": "commandPalette.actions.gitFetch.label", + "description": "Windows UI: commandPalette.actions.git-fetch.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.git-fetch.label" + } + }, + { + "id": "commandPalette.actions.gitInitializeRepository.label", + "description": "Windows UI: commandPalette.actions.git-initialize-repository.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.git-initialize-repository.label" + } + }, + { + "id": "commandPalette.actions.gitManageRemotes.label", + "description": "Windows UI: commandPalette.actions.git-manage-remotes.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.git-manage-remotes.label" + } + }, + { + "id": "commandPalette.actions.gitManageTags.label", + "description": "Windows UI: commandPalette.actions.git-manage-tags.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.git-manage-tags.label" + } + }, + { + "id": "commandPalette.actions.gitOpenLog.label", + "description": "Windows UI: commandPalette.actions.git-open-log.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.git-open-log.label" + } + }, + { + "id": "commandPalette.actions.gitPopStash.label", + "description": "Windows UI: commandPalette.actions.git-pop-stash.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.git-pop-stash.label" + } + }, + { + "id": "commandPalette.actions.gitPull.label", + "description": "Windows UI: commandPalette.actions.git-pull.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.git-pull.label" + } + }, + { + "id": "commandPalette.actions.gitPush.label", + "description": "Windows UI: commandPalette.actions.git-push.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.git-push.label" + } + }, + { + "id": "commandPalette.actions.gitRefresh.label", + "description": "Windows UI: commandPalette.actions.git-refresh.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.git-refresh.label" + } + }, + { + "id": "commandPalette.actions.gitRemoveRemote.label", + "description": "Windows UI: commandPalette.actions.git-remove-remote.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.git-remove-remote.label" + } + }, + { + "id": "commandPalette.actions.gitSelectRepository.label", + "description": "Windows UI: commandPalette.actions.git-select-repository.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.git-select-repository.label" + } + }, + { + "id": "commandPalette.actions.gitShowBranchDiff.label", + "description": "Windows UI: commandPalette.actions.git-show-branch-diff.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.git-show-branch-diff.label" + } + }, + { + "id": "commandPalette.actions.gitShowChanges.label", + "description": "Windows UI: commandPalette.actions.git-show-changes.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.git-show-changes.label" + } + }, + { + "id": "commandPalette.actions.gitStageAll.label", + "description": "Windows UI: commandPalette.actions.git-stage-all.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.git-stage-all.label" + } + }, + { + "id": "commandPalette.actions.gitUnstageAll.label", + "description": "Windows UI: commandPalette.actions.git-unstage-all.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.git-unstage-all.label" + } + }, + { + "id": "commandPalette.actions.gitViewStashes.label", + "description": "Windows UI: commandPalette.actions.git-view-stashes.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.git-view-stashes.label" + } + }, + { + "id": "commandPalette.actions.gitWorktreeManager.label", + "description": "Windows UI: commandPalette.actions.git-worktree-manager.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.git-worktree-manager.label" + } + }, + { + "id": "commandPalette.actions.githubCheckAuth.label", + "description": "Windows UI: commandPalette.actions.github-check-auth.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.github-check-auth.label" + } + }, + { + "id": "commandPalette.actions.githubNewIssue.label", + "description": "Windows UI: commandPalette.actions.github-new-issue.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.github-new-issue.label" + } + }, + { + "id": "commandPalette.actions.githubNewPullRequest.label", + "description": "Windows UI: commandPalette.actions.github-new-pull-request.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.github-new-pull-request.label" + } + }, + { + "id": "commandPalette.actions.githubRefresh.label", + "description": "Windows UI: commandPalette.actions.github-refresh.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.github-refresh.label" + } + }, + { + "id": "commandPalette.actions.githubRunWorkflow.label", + "description": "Windows UI: commandPalette.actions.github-run-workflow.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.github-run-workflow.label" + } + }, + { + "id": "commandPalette.actions.githubShowActions.label", + "description": "Windows UI: commandPalette.actions.github-show-actions.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.github-show-actions.label" + } + }, + { + "id": "commandPalette.actions.githubShowIssues.label", + "description": "Windows UI: commandPalette.actions.github-show-issues.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.github-show-issues.label" + } + }, + { + "id": "commandPalette.actions.githubShowPullRequests.label", + "description": "Windows UI: commandPalette.actions.github-show-pull-requests.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.github-show-pull-requests.label" + } + }, + { + "id": "commandPalette.actions.goToLine.label", + "description": "Windows UI: commandPalette.actions.go-to-line.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.go-to-line.label" + } + }, + { + "id": "commandPalette.actions.goToSymbolInEditor.label", + "description": "Windows UI: commandPalette.actions.go-to-symbol-in-editor.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.go-to-symbol-in-editor.label" + } + }, + { + "id": "commandPalette.actions.iconTheme.label", + "description": "Windows UI: commandPalette.actions.icon-theme.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.icon-theme.label" + } + }, + { + "id": "commandPalette.actions.lsp.restartAllServers.label", + "description": "Windows UI: commandPalette.actions.lsp.restartAllServers.label", + "platforms": [ + "windows" + ] + }, + { + "id": "commandPalette.actions.lsp.stopAllServers.label", + "description": "Windows UI: commandPalette.actions.lsp.stopAllServers.label", + "platforms": [ + "windows" + ] + }, + { + "id": "commandPalette.actions.lspStatus.label", + "description": "Windows UI: commandPalette.actions.lsp-status.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.lsp-status.label" + } + }, + { + "id": "commandPalette.actions.markdownPreview.label", + "description": "Windows UI: commandPalette.actions.markdown-preview.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.markdown-preview.label" + } + }, + { + "id": "commandPalette.actions.openOnboarding.label", + "description": "Windows UI: commandPalette.actions.open-onboarding.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.open-onboarding.label" + } + }, + { + "id": "commandPalette.actions.openSettings.label", + "description": "Windows UI: commandPalette.actions.open-settings.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.open-settings.label" + } + }, + { + "id": "commandPalette.actions.openSettingsJson.label", + "description": "Windows UI: commandPalette.actions.open-settings-json.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.open-settings-json.label" + } + }, + { + "id": "commandPalette.actions.openSettingsTabAdvanced.label", + "description": "Windows UI: commandPalette.actions.open-settings-tab-advanced.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.open-settings-tab-advanced.label" + } + }, + { + "id": "commandPalette.actions.openSettingsTabAppearance.label", + "description": "Windows UI: commandPalette.actions.open-settings-tab-appearance.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.open-settings-tab-appearance.label" + } + }, + { + "id": "commandPalette.actions.openSettingsTabEditor.label", + "description": "Windows UI: commandPalette.actions.open-settings-tab-editor.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.open-settings-tab-editor.label" + } + }, + { + "id": "commandPalette.actions.openSettingsTabFileExplorer.label", + "description": "Windows UI: commandPalette.actions.open-settings-tab-file-explorer.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.open-settings-tab-file-explorer.label" + } + }, + { + "id": "commandPalette.actions.openSettingsTabGeneral.label", + "description": "Windows UI: commandPalette.actions.open-settings-tab-general.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.open-settings-tab-general.label" + } + }, + { + "id": "commandPalette.actions.openSettingsTabGit.label", + "description": "Windows UI: commandPalette.actions.open-settings-tab-git.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.open-settings-tab-git.label" + } + }, + { + "id": "commandPalette.actions.openSettingsTabKeyboard.label", + "description": "Windows UI: commandPalette.actions.open-settings-tab-keyboard.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.open-settings-tab-keyboard.label" + } + }, + { + "id": "commandPalette.actions.openSettingsTabTerminal.label", + "description": "Windows UI: commandPalette.actions.open-settings-tab-terminal.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.open-settings-tab-terminal.label" + } + }, + { + "id": "commandPalette.actions.openUrl.label", + "description": "Windows UI: commandPalette.actions.open-url.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.open-url.label" + } + }, + { + "id": "commandPalette.actions.openWebViewer.label", + "description": "Windows UI: commandPalette.actions.open-web-viewer.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.open-web-viewer.label" + } + }, + { + "id": "commandPalette.actions.paneCloseEditorGroup.label", + "description": "Windows UI: commandPalette.actions.pane-close-editor-group.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.pane-close-editor-group.label" + } + }, + { + "id": "commandPalette.actions.paneCloseOtherEditorGroups.label", + "description": "Windows UI: commandPalette.actions.pane-close-other-editor-groups.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.pane-close-other-editor-groups.label" + } + }, + { + "id": "commandPalette.actions.paneMoveEditorNextGroup.label", + "description": "Windows UI: commandPalette.actions.pane-move-editor-next-group.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.pane-move-editor-next-group.label" + } + }, + { + "id": "commandPalette.actions.paneMoveEditorPreviousGroup.label", + "description": "Windows UI: commandPalette.actions.pane-move-editor-previous-group.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.pane-move-editor-previous-group.label" + } + }, + { + "id": "commandPalette.actions.paneResetEditorGroupSizes.label", + "description": "Windows UI: commandPalette.actions.pane-reset-editor-group-sizes.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.pane-reset-editor-group-sizes.label" + } + }, + { + "id": "commandPalette.actions.paneSplitEditorDown.label", + "description": "Windows UI: commandPalette.actions.pane-split-editor-down.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.pane-split-editor-down.label" + } + }, + { + "id": "commandPalette.actions.paneSplitEditorRight.label", + "description": "Windows UI: commandPalette.actions.pane-split-editor-right.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.pane-split-editor-right.label" + } + }, + { + "id": "commandPalette.actions.paneToggleEditorGroupLock.label", + "description": "Windows UI: commandPalette.actions.pane-toggle-editor-group-lock.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.pane-toggle-editor-group-lock.label" + } + }, + { + "id": "commandPalette.actions.quickOpen.label", + "description": "Windows UI: commandPalette.actions.quick-open.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.quick-open.label" + } + }, + { + "id": "commandPalette.actions.reportBug.label", + "description": "Windows UI: commandPalette.actions.report-bug.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.report-bug.label" + } + }, + { + "id": "commandPalette.actions.searchGlobal.label", + "description": "Windows UI: commandPalette.actions.search-global.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.search-global.label" + } + }, + { + "id": "commandPalette.actions.showWhatsNew.label", + "description": "Windows UI: commandPalette.actions.show-whats-new.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.show-whats-new.label" + } + }, + { + "id": "commandPalette.actions.tabClose.label", + "description": "Windows UI: commandPalette.actions.tab-close.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.tab-close.label" + } + }, + { + "id": "commandPalette.actions.tabNext.label", + "description": "Windows UI: commandPalette.actions.tab-next.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.tab-next.label" + } + }, + { + "id": "commandPalette.actions.tabPrevious.label", + "description": "Windows UI: commandPalette.actions.tab-previous.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.tab-previous.label" + } + }, + { + "id": "commandPalette.actions.tabReopen.label", + "description": "Windows UI: commandPalette.actions.tab-reopen.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.tab-reopen.label" + } + }, + { + "id": "commandPalette.actions.terminalFind.label", + "description": "Windows UI: commandPalette.actions.terminal-find.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.terminal-find.label" + } + }, + { + "id": "commandPalette.actions.terminalNew.label", + "description": "Windows UI: commandPalette.actions.terminal-new.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.terminal-new.label" + } + }, + { + "id": "commandPalette.actions.terminalResetZoom.label", + "description": "Windows UI: commandPalette.actions.terminal-reset-zoom.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.terminal-reset-zoom.label" + } + }, + { + "id": "commandPalette.actions.terminalSplitDown.label", + "description": "Windows UI: commandPalette.actions.terminal-split-down.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.terminal-split-down.label" + } + }, + { + "id": "commandPalette.actions.terminalSplitRight.label", + "description": "Windows UI: commandPalette.actions.terminal-split-right.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.terminal-split-right.label" + } + }, + { + "id": "commandPalette.actions.terminalZoomIn.label", + "description": "Windows UI: commandPalette.actions.terminal-zoom-in.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.terminal-zoom-in.label" + } + }, + { + "id": "commandPalette.actions.terminalZoomOut.label", + "description": "Windows UI: commandPalette.actions.terminal-zoom-out.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.terminal-zoom-out.label" + } + }, + { + "id": "commandPalette.actions.toggleAiChatFeature.disableLabel", + "description": "Windows UI: commandPalette.actions.toggle-ai-chat-feature.disableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-ai-chat-feature.disableLabel" + } + }, + { + "id": "commandPalette.actions.toggleAiChatFeature.enableLabel", + "description": "Windows UI: commandPalette.actions.toggle-ai-chat-feature.enableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-ai-chat-feature.enableLabel" + } + }, + { + "id": "commandPalette.actions.toggleAiChatView.disableLabel", + "description": "Windows UI: commandPalette.actions.toggle-ai-chat-view.disableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-ai-chat-view.disableLabel" + } + }, + { + "id": "commandPalette.actions.toggleAiChatView.enableLabel", + "description": "Windows UI: commandPalette.actions.toggle-ai-chat-view.enableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-ai-chat-view.enableLabel" + } + }, + { + "id": "commandPalette.actions.toggleAiCompletion.disableLabel", + "description": "Windows UI: commandPalette.actions.toggle-ai-completion.disableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-ai-completion.disableLabel" + } + }, + { + "id": "commandPalette.actions.toggleAiCompletion.enableLabel", + "description": "Windows UI: commandPalette.actions.toggle-ai-completion.enableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-ai-completion.enableLabel" + } + }, + { + "id": "commandPalette.actions.toggleAutoCompletion.disableLabel", + "description": "Windows UI: commandPalette.actions.toggle-auto-completion.disableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-auto-completion.disableLabel" + } + }, + { + "id": "commandPalette.actions.toggleAutoCompletion.enableLabel", + "description": "Windows UI: commandPalette.actions.toggle-auto-completion.enableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-auto-completion.enableLabel" + } + }, + { + "id": "commandPalette.actions.toggleAutoDetectLanguage.disableLabel", + "description": "Windows UI: commandPalette.actions.toggle-auto-detect-language.disableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-auto-detect-language.disableLabel" + } + }, + { + "id": "commandPalette.actions.toggleAutoDetectLanguage.enableLabel", + "description": "Windows UI: commandPalette.actions.toggle-auto-detect-language.enableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-auto-detect-language.enableLabel" + } + }, + { + "id": "commandPalette.actions.toggleAutoSave.disableLabel", + "description": "Windows UI: commandPalette.actions.toggle-auto-save.disableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-auto-save.disableLabel" + } + }, + { + "id": "commandPalette.actions.toggleAutoSave.enableLabel", + "description": "Windows UI: commandPalette.actions.toggle-auto-save.enableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-auto-save.enableLabel" + } + }, + { + "id": "commandPalette.actions.toggleBottomPane.disableLabel", + "description": "Windows UI: commandPalette.actions.toggle-bottom-pane.disableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-bottom-pane.disableLabel" + } + }, + { + "id": "commandPalette.actions.toggleBottomPane.enableLabel", + "description": "Windows UI: commandPalette.actions.toggle-bottom-pane.enableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-bottom-pane.enableLabel" + } + }, + { + "id": "commandPalette.actions.toggleBreadcrumbs.disableLabel", + "description": "Windows UI: commandPalette.actions.toggle-breadcrumbs.disableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-breadcrumbs.disableLabel" + } + }, + { + "id": "commandPalette.actions.toggleBreadcrumbs.enableLabel", + "description": "Windows UI: commandPalette.actions.toggle-breadcrumbs.enableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-breadcrumbs.enableLabel" + } + }, + { + "id": "commandPalette.actions.toggleCodeLens.disableLabel", + "description": "Windows UI: commandPalette.actions.toggle-code-lens.disableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-code-lens.disableLabel" + } + }, + { + "id": "commandPalette.actions.toggleCodeLens.enableLabel", + "description": "Windows UI: commandPalette.actions.toggle-code-lens.enableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-code-lens.enableLabel" + } + }, + { + "id": "commandPalette.actions.toggleCommandsPersistence.disableLabel", + "description": "Windows UI: commandPalette.actions.toggle-commands-persistence.disableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-commands-persistence.disableLabel" + } + }, + { + "id": "commandPalette.actions.toggleCommandsPersistence.enableLabel", + "description": "Windows UI: commandPalette.actions.toggle-commands-persistence.enableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-commands-persistence.enableLabel" + } + }, + { + "id": "commandPalette.actions.toggleCompactMenuBar.disableLabel", + "description": "Windows UI: commandPalette.actions.toggle-compact-menu-bar.disableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-compact-menu-bar.disableLabel" + } + }, + { + "id": "commandPalette.actions.toggleCompactMenuBar.enableLabel", + "description": "Windows UI: commandPalette.actions.toggle-compact-menu-bar.enableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-compact-menu-bar.enableLabel" + } + }, + { + "id": "commandPalette.actions.toggleDebuggerFeature.disableLabel", + "description": "Windows UI: commandPalette.actions.toggle-debugger-feature.disableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-debugger-feature.disableLabel" + } + }, + { + "id": "commandPalette.actions.toggleDebuggerFeature.enableLabel", + "description": "Windows UI: commandPalette.actions.toggle-debugger-feature.enableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-debugger-feature.enableLabel" + } + }, + { + "id": "commandPalette.actions.toggleDiagnostics.disableLabel", + "description": "Windows UI: commandPalette.actions.toggle-diagnostics.disableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-diagnostics.disableLabel" + } + }, + { + "id": "commandPalette.actions.toggleDiagnostics.enableLabel", + "description": "Windows UI: commandPalette.actions.toggle-diagnostics.enableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-diagnostics.enableLabel" + } + }, + { + "id": "commandPalette.actions.toggleDiagnosticsPanel.label", + "description": "Windows UI: commandPalette.actions.toggle-diagnostics-panel.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-diagnostics-panel.label" + } + }, + { + "id": "commandPalette.actions.toggleFindView.label", + "description": "Windows UI: commandPalette.actions.toggle-find-view.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-find-view.label" + } + }, + { + "id": "commandPalette.actions.toggleFormatOnSave.disableLabel", + "description": "Windows UI: commandPalette.actions.toggle-format-on-save.disableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-format-on-save.disableLabel" + } + }, + { + "id": "commandPalette.actions.toggleFormatOnSave.enableLabel", + "description": "Windows UI: commandPalette.actions.toggle-format-on-save.enableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-format-on-save.enableLabel" + } + }, + { + "id": "commandPalette.actions.toggleGitFeature.disableLabel", + "description": "Windows UI: commandPalette.actions.toggle-git-feature.disableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-git-feature.disableLabel" + } + }, + { + "id": "commandPalette.actions.toggleGitFeature.enableLabel", + "description": "Windows UI: commandPalette.actions.toggle-git-feature.enableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-git-feature.enableLabel" + } + }, + { + "id": "commandPalette.actions.toggleInlayHints.disableLabel", + "description": "Windows UI: commandPalette.actions.toggle-inlay-hints.disableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-inlay-hints.disableLabel" + } + }, + { + "id": "commandPalette.actions.toggleInlayHints.enableLabel", + "description": "Windows UI: commandPalette.actions.toggle-inlay-hints.enableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-inlay-hints.enableLabel" + } + }, + { + "id": "commandPalette.actions.toggleLineNumbers.disableLabel", + "description": "Windows UI: commandPalette.actions.toggle-line-numbers.disableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-line-numbers.disableLabel" + } + }, + { + "id": "commandPalette.actions.toggleLineNumbers.enableLabel", + "description": "Windows UI: commandPalette.actions.toggle-line-numbers.enableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-line-numbers.enableLabel" + } + }, + { + "id": "commandPalette.actions.toggleNativeMenuBar.disableLabel", + "description": "Windows UI: commandPalette.actions.toggle-native-menu-bar.disableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-native-menu-bar.disableLabel" + } + }, + { + "id": "commandPalette.actions.toggleNativeMenuBar.enableLabel", + "description": "Windows UI: commandPalette.actions.toggle-native-menu-bar.enableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-native-menu-bar.enableLabel" + } + }, + { + "id": "commandPalette.actions.toggleOutlineFeature.disableLabel", + "description": "Windows UI: commandPalette.actions.toggle-outline-feature.disableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-outline-feature.disableLabel" + } + }, + { + "id": "commandPalette.actions.toggleOutlineFeature.enableLabel", + "description": "Windows UI: commandPalette.actions.toggle-outline-feature.enableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-outline-feature.enableLabel" + } + }, + { + "id": "commandPalette.actions.toggleParameterHints.disableLabel", + "description": "Windows UI: commandPalette.actions.toggle-parameter-hints.disableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-parameter-hints.disableLabel" + } + }, + { + "id": "commandPalette.actions.toggleParameterHints.enableLabel", + "description": "Windows UI: commandPalette.actions.toggle-parameter-hints.enableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-parameter-hints.enableLabel" + } + }, + { + "id": "commandPalette.actions.toggleRelativeLineNumbers.disableLabel", + "description": "Windows UI: commandPalette.actions.toggle-relative-line-numbers.disableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-relative-line-numbers.disableLabel" + } + }, + { + "id": "commandPalette.actions.toggleRelativeLineNumbers.enableLabel", + "description": "Windows UI: commandPalette.actions.toggle-relative-line-numbers.enableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-relative-line-numbers.enableLabel" + } + }, + { + "id": "commandPalette.actions.toggleRemoteFeature.disableLabel", + "description": "Windows UI: commandPalette.actions.toggle-remote-feature.disableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-remote-feature.disableLabel" + } + }, + { + "id": "commandPalette.actions.toggleRemoteFeature.enableLabel", + "description": "Windows UI: commandPalette.actions.toggle-remote-feature.enableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-remote-feature.enableLabel" + } + }, + { + "id": "commandPalette.actions.toggleRun.disableLabel", + "description": "Windows UI: commandPalette.actions.toggle-run.disableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-run.disableLabel" + } + }, + { + "id": "commandPalette.actions.toggleRun.enableLabel", + "description": "Windows UI: commandPalette.actions.toggle-run.enableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-run.enableLabel" + } + }, + { + "id": "commandPalette.actions.toggleSearchFeature.disableLabel", + "description": "Windows UI: commandPalette.actions.toggle-search-feature.disableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-search-feature.disableLabel" + } + }, + { + "id": "commandPalette.actions.toggleSearchFeature.enableLabel", + "description": "Windows UI: commandPalette.actions.toggle-search-feature.enableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-search-feature.enableLabel" + } + }, + { + "id": "commandPalette.actions.toggleSemanticTokens.disableLabel", + "description": "Windows UI: commandPalette.actions.toggle-semantic-tokens.disableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-semantic-tokens.disableLabel" + } + }, + { + "id": "commandPalette.actions.toggleSemanticTokens.enableLabel", + "description": "Windows UI: commandPalette.actions.toggle-semantic-tokens.enableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-semantic-tokens.enableLabel" + } + }, + { + "id": "commandPalette.actions.toggleShowMinimap.disableLabel", + "description": "Windows UI: commandPalette.actions.toggle-show-minimap.disableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-show-minimap.disableLabel" + } + }, + { + "id": "commandPalette.actions.toggleShowMinimap.enableLabel", + "description": "Windows UI: commandPalette.actions.toggle-show-minimap.enableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-show-minimap.enableLabel" + } + }, + { + "id": "commandPalette.actions.toggleSidebar.disableLabel", + "description": "Windows UI: commandPalette.actions.toggle-sidebar.disableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-sidebar.disableLabel" + } + }, + { + "id": "commandPalette.actions.toggleSidebar.enableLabel", + "description": "Windows UI: commandPalette.actions.toggle-sidebar.enableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-sidebar.enableLabel" + } + }, + { + "id": "commandPalette.actions.toggleTerminal.disableLabel", + "description": "Windows UI: commandPalette.actions.toggle-terminal.disableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-terminal.disableLabel" + } + }, + { + "id": "commandPalette.actions.toggleTerminal.enableLabel", + "description": "Windows UI: commandPalette.actions.toggle-terminal.enableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-terminal.enableLabel" + } + }, + { + "id": "commandPalette.actions.toggleTerminalFeature.disableLabel", + "description": "Windows UI: commandPalette.actions.toggle-terminal-feature.disableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-terminal-feature.disableLabel" + } + }, + { + "id": "commandPalette.actions.toggleTerminalFeature.enableLabel", + "description": "Windows UI: commandPalette.actions.toggle-terminal-feature.enableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-terminal-feature.enableLabel" + } + }, + { + "id": "commandPalette.actions.toggleVimMode.label", + "description": "Windows UI: commandPalette.actions.toggle-vim-mode.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-vim-mode.label" + } + }, + { + "id": "commandPalette.actions.toggleWordWrap.disableLabel", + "description": "Windows UI: commandPalette.actions.toggle-word-wrap.disableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-word-wrap.disableLabel" + } + }, + { + "id": "commandPalette.actions.toggleWordWrap.enableLabel", + "description": "Windows UI: commandPalette.actions.toggle-word-wrap.enableLabel", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.toggle-word-wrap.enableLabel" + } + }, + { + "id": "commandPalette.actions.viewResetZoom.label", + "description": "Windows UI: commandPalette.actions.view-reset-zoom.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.view-reset-zoom.label" + } + }, + { + "id": "commandPalette.actions.viewShowFiles.label", + "description": "Windows UI: commandPalette.actions.view-show-files.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.view-show-files.label" + } + }, + { + "id": "commandPalette.actions.viewShowGit.label", + "description": "Windows UI: commandPalette.actions.view-show-git.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.view-show-git.label" + } + }, + { + "id": "commandPalette.actions.viewShowOutline.label", + "description": "Windows UI: commandPalette.actions.view-show-outline.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.view-show-outline.label" + } + }, + { + "id": "commandPalette.actions.viewZoomIn.label", + "description": "Windows UI: commandPalette.actions.view-zoom-in.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.view-zoom-in.label" + } + }, + { + "id": "commandPalette.actions.viewZoomOut.label", + "description": "Windows UI: commandPalette.actions.view-zoom-out.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.view-zoom-out.label" + } + }, + { + "id": "commandPalette.actions.vimInsertMode.label", + "description": "Windows UI: commandPalette.actions.vim-insert-mode.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.vim-insert-mode.label" + } + }, + { + "id": "commandPalette.actions.vimNormalMode.label", + "description": "Windows UI: commandPalette.actions.vim-normal-mode.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.vim-normal-mode.label" + } + }, + { + "id": "commandPalette.actions.vimVisualMode.label", + "description": "Windows UI: commandPalette.actions.vim-visual-mode.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.vim-visual-mode.label" + } + }, + { + "id": "commandPalette.actions.windowEnterFullscreen.label", + "description": "Windows UI: commandPalette.actions.window-enter-fullscreen.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.window-enter-fullscreen.label" + } + }, + { + "id": "commandPalette.actions.windowExitFullscreen.label", + "description": "Windows UI: commandPalette.actions.window-exit-fullscreen.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.window-exit-fullscreen.label" + } + }, + { + "id": "commandPalette.actions.windowMaximize.label", + "description": "Windows UI: commandPalette.actions.window-maximize.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.window-maximize.label" + } + }, + { + "id": "commandPalette.actions.windowMinimize.label", + "description": "Windows UI: commandPalette.actions.window-minimize.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.window-minimize.label" + } + }, + { + "id": "commandPalette.actions.windowRestore.label", + "description": "Windows UI: commandPalette.actions.window-restore.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.window-restore.label" + } + }, + { + "id": "commandPalette.backToCommands", + "description": "Windows UI: commandPalette.backToCommands", + "platforms": [ + "windows" + ] + }, + { + "id": "commandPalette.categories.AI", + "description": "Windows UI: commandPalette.categories.AI", + "platforms": [ + "windows" + ] + }, + { + "id": "commandPalette.categories.Advanced", + "description": "Windows UI: commandPalette.categories.Advanced", + "platforms": [ + "windows" + ] + }, + { + "id": "commandPalette.categories.CLI", + "description": "Windows UI: commandPalette.categories.CLI", + "platforms": [ + "windows" + ] + }, + { + "id": "commandPalette.categories.Developer", + "description": "Windows UI: commandPalette.categories.Developer", + "platforms": [ + "windows" + ] + }, + { + "id": "commandPalette.categories.Editor", + "description": "Windows UI: commandPalette.categories.Editor", + "platforms": [ + "windows" + ] + }, + { + "id": "commandPalette.categories.Features", + "description": "Windows UI: commandPalette.categories.Features", + "platforms": [ + "windows" + ] + }, + { + "id": "commandPalette.categories.File", + "description": "Windows UI: commandPalette.categories.File", + "platforms": [ + "windows" + ] + }, + { + "id": "commandPalette.categories.Generate", + "description": "Windows UI: commandPalette.categories.Generate", + "platforms": [ + "windows" + ] + }, + { + "id": "commandPalette.categories.Git", + "description": "Windows UI: commandPalette.categories.Git", + "platforms": [ + "windows" + ] + }, + { + "id": "commandPalette.categories.GitHub", + "description": "Windows UI: commandPalette.categories.GitHub", + "platforms": [ + "windows" + ] + }, + { + "id": "commandPalette.categories.LSP", + "description": "Windows UI: commandPalette.categories.LSP", + "platforms": [ + "windows" + ] + }, + { + "id": "commandPalette.categories.Language", + "description": "Windows UI: commandPalette.categories.Language", + "platforms": [ + "windows" + ] + }, + { + "id": "commandPalette.categories.Markdown", + "description": "Windows UI: commandPalette.categories.Markdown", + "platforms": [ + "windows" + ] + }, + { + "id": "commandPalette.categories.Navigation", + "description": "Windows UI: commandPalette.categories.Navigation", + "platforms": [ + "windows" + ] + }, + { + "id": "commandPalette.categories.Settings", + "description": "Windows UI: commandPalette.categories.Settings", + "platforms": [ + "windows" + ] + }, + { + "id": "commandPalette.categories.Terminal", + "description": "Windows UI: commandPalette.categories.Terminal", + "platforms": [ + "windows" + ] + }, + { + "id": "commandPalette.categories.Theme", + "description": "Windows UI: commandPalette.categories.Theme", + "platforms": [ + "windows" + ] + }, + { + "id": "commandPalette.categories.View", + "description": "Windows UI: commandPalette.categories.View", + "platforms": [ + "windows" + ] + }, + { + "id": "commandPalette.categories.Vim", + "description": "Windows UI: commandPalette.categories.Vim", + "platforms": [ + "windows" + ] + }, + { + "id": "commandPalette.categories.Window", + "description": "Windows UI: commandPalette.categories.Window", + "platforms": [ + "windows" + ] + }, + { + "id": "commandPalette.categories.languageServer", + "description": "Windows UI: commandPalette.categories.Language Server", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.categories.Language Server" + } + }, + { + "id": "commandPalette.clearPersistedActions", + "description": "Windows UI: commandPalette.clearPersistedActions", + "platforms": [ + "windows" + ] + }, + { + "id": "commandPalette.close", + "description": "Windows UI: commandPalette.close", + "platforms": [ + "windows" + ] + }, + { + "id": "commandPalette.current", + "description": "Windows UI: commandPalette.current", + "platforms": [ + "windows" + ] + }, + { + "id": "commandPalette.failedToImportTheme", + "description": "Windows UI: commandPalette.failedToImportTheme", + "platforms": [ + "windows" + ] + }, + { + "id": "commandPalette.importedTheme", + "description": "Windows UI: commandPalette.importedTheme", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "commandPalette.importedThemeVariants", + "description": "Windows UI: commandPalette.importedThemeVariants", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "commandPalette.noCommands", + "description": "Windows UI: commandPalette.noCommands", + "platforms": [ + "windows" + ] + }, + { + "id": "commandPalette.noIconThemes", + "description": "Windows UI: commandPalette.noIconThemes", + "platforms": [ + "windows" + ] + }, + { + "id": "commandPalette.noThemes", + "description": "Windows UI: commandPalette.noThemes", + "platforms": [ + "windows" + ] + }, + { + "id": "commandPalette.openAppearanceSettings", + "description": "Windows UI: commandPalette.openAppearanceSettings", + "platforms": [ + "windows" + ] + }, + { + "id": "commandPalette.placeholder", + "description": "Windows UI: commandPalette.placeholder", + "platforms": [ + "windows" + ] + }, + { + "id": "commandPalette.searchIconThemes", + "description": "Windows UI: commandPalette.searchIconThemes", + "platforms": [ + "windows" + ] + }, + { + "id": "commandPalette.searchThemes", + "description": "Windows UI: commandPalette.searchThemes", + "platforms": [ + "windows" + ] + }, + { + "id": "commandPalette.title", + "description": "Windows UI: commandPalette.title", + "platforms": [ + "windows" + ] + }, + { + "id": "commandPalette.uploadTheme", + "description": "Windows UI: commandPalette.uploadTheme", + "platforms": [ + "windows" + ] + }, + { + "id": "csv.auto", + "description": "Windows UI: csv.auto", + "platforms": [ + "windows" + ] + }, + { + "id": "csv.changeDelimiter", + "description": "Windows UI: csv.changeDelimiter", + "platforms": [ + "windows" + ] + }, + { + "id": "csv.comma", + "description": "Windows UI: csv.comma", + "platforms": [ + "windows" + ] + }, + { + "id": "csv.copyAsCsv", + "description": "Windows UI: csv.copyAsCsv", + "platforms": [ + "windows" + ] + }, + { + "id": "csv.copyAsJson", + "description": "Windows UI: csv.copyAsJson", + "platforms": [ + "windows" + ] + }, + { + "id": "csv.delimiter", + "description": "Windows UI: csv.delimiter", + "platforms": [ + "windows" + ] + }, + { + "id": "csv.headerOff", + "description": "Windows UI: csv.headerOff", + "platforms": [ + "windows" + ] + }, + { + "id": "csv.headerOn", + "description": "Windows UI: csv.headerOn", + "platforms": [ + "windows" + ] + }, + { + "id": "csv.pipe", + "description": "Windows UI: csv.pipe", + "platforms": [ + "windows" + ] + }, + { + "id": "csv.rowsColumns", + "description": "Windows UI: csv.rowsColumns", + "platforms": [ + "windows" + ], + "parameters": { + "columns": "string", + "rows": "string" + } + }, + { + "id": "csv.semicolon", + "description": "Windows UI: csv.semicolon", + "platforms": [ + "windows" + ] + }, + { + "id": "csv.tab", + "description": "Windows UI: csv.tab", + "platforms": [ + "windows" + ] + }, + { + "id": "csv.toggleHeaderRow", + "description": "Windows UI: csv.toggleHeaderRow", + "platforms": [ + "windows" + ] + }, + { + "id": "customTheme.baseTheme", + "description": "Windows UI: customTheme.baseTheme", + "platforms": [ + "windows" + ] + }, + { + "id": "customTheme.createTitle", + "description": "Windows UI: customTheme.createTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "customTheme.defaultName", + "description": "Windows UI: customTheme.defaultName", + "platforms": [ + "windows" + ] + }, + { + "id": "customTheme.description", + "description": "Windows UI: customTheme.description", + "platforms": [ + "windows" + ] + }, + { + "id": "customTheme.filterAllFiles", + "description": "Windows UI: customTheme.filterAllFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "customTheme.filterLitheTheme", + "description": "Windows UI: customTheme.filterLitheTheme", + "platforms": [ + "windows" + ] + }, + { + "id": "customTheme.generateFailed", + "description": "Windows UI: customTheme.generateFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "customTheme.id", + "description": "Windows UI: customTheme.id", + "platforms": [ + "windows" + ] + }, + { + "id": "customTheme.install", + "description": "Windows UI: customTheme.install", + "platforms": [ + "windows" + ] + }, + { + "id": "customTheme.installFailed", + "description": "Windows UI: customTheme.installFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "customTheme.installed", + "description": "Windows UI: customTheme.installed", + "platforms": [ + "windows" + ], + "parameters": { + "theme": "string" + } + }, + { + "id": "customTheme.installedVariants", + "description": "Windows UI: customTheme.installedVariants", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "customTheme.installing", + "description": "Windows UI: customTheme.installing", + "platforms": [ + "windows" + ] + }, + { + "id": "customTheme.jsonSaved", + "description": "Windows UI: customTheme.jsonSaved", + "platforms": [ + "windows" + ] + }, + { + "id": "customTheme.name", + "description": "Windows UI: customTheme.name", + "platforms": [ + "windows" + ] + }, + { + "id": "customTheme.saveJson", + "description": "Windows UI: customTheme.saveJson", + "platforms": [ + "windows" + ] + }, + { + "id": "customTheme.saveJsonFailed", + "description": "Windows UI: customTheme.saveJsonFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "customTheme.themeJson", + "description": "Windows UI: customTheme.themeJson", + "platforms": [ + "windows" + ] + }, + { + "id": "database.connect", + "description": "Windows UI: database.connect", + "platforms": [ + "windows" + ] + }, + { + "id": "database.host", + "description": "Windows UI: database.host", + "platforms": [ + "windows" + ] + }, + { + "id": "database.optional", + "description": "Windows UI: database.optional", + "platforms": [ + "windows" + ] + }, + { + "id": "database.password", + "description": "Windows UI: database.password", + "platforms": [ + "windows" + ] + }, + { + "id": "database.port", + "description": "Windows UI: database.port", + "platforms": [ + "windows" + ] + }, + { + "id": "database.username", + "description": "Windows UI: database.username", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.adapterOutputAppearsHere", + "description": "Windows UI: debugger.adapterOutputAppearsHere", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.addExpression", + "description": "Windows UI: debugger.addExpression", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.addWatch", + "description": "Windows UI: debugger.addWatch", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.applyCodeChanges", + "description": "Windows UI: debugger.applyCodeChanges", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.applyCodeChangesHelp", + "description": "Windows UI: debugger.applyCodeChangesHelp", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.applyingCodeChanges", + "description": "Windows UI: debugger.applyingCodeChanges", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.breakpoints", + "description": "Windows UI: debugger.breakpoints", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.breakpointsEmpty", + "description": "Windows UI: debugger.breakpointsEmpty", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.clearBreakpoints", + "description": "Windows UI: debugger.clearBreakpoints", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.clearConsole", + "description": "Windows UI: debugger.clearConsole", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.clearDebugConsole", + "description": "Windows UI: debugger.clearDebugConsole", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.clearWatchExpressions", + "description": "Windows UI: debugger.clearWatchExpressions", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.command", + "description": "Windows UI: debugger.command", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.commandToRun", + "description": "Windows UI: debugger.commandToRun", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.configuration", + "description": "Windows UI: debugger.configuration", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.console", + "description": "Windows UI: debugger.console", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.continue", + "description": "Windows UI: debugger.continue", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Continue", + "arguments": [] + } + }, + { + "id": "debugger.continueDebugging", + "description": "Windows UI: debugger.continueDebugging", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.debugConfiguration", + "description": "Windows UI: debugger.debugConfiguration", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.disableBreakpoint", + "description": "Windows UI: debugger.disableBreakpoint", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.empty", + "description": "Windows UI: debugger.empty", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.enableBreakpoint", + "description": "Windows UI: debugger.enableBreakpoint", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.evaluating", + "description": "Windows UI: debugger.evaluating", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.hideRunAndDebug", + "description": "Windows UI: debugger.hideRunAndDebug", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.into", + "description": "Windows UI: debugger.into", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.lineNumber", + "description": "Windows UI: debugger.lineNumber", + "platforms": [ + "windows" + ], + "parameters": { + "line": "string" + } + }, + { + "id": "debugger.loadingVariable", + "description": "Windows UI: debugger.loadingVariable", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.noCommandAvailable", + "description": "Windows UI: debugger.noCommandAvailable", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.noLaunchJsonFound", + "description": "Windows UI: debugger.noLaunchJsonFound", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.notEvaluated", + "description": "Windows UI: debugger.notEvaluated", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.openProjectToLoadLaunchJson", + "description": "Windows UI: debugger.openProjectToLoadLaunchJson", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.out", + "description": "Windows UI: debugger.out", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.over", + "description": "Windows UI: debugger.over", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.pause", + "description": "Windows UI: debugger.pause", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.pauseDebugging", + "description": "Windows UI: debugger.pauseDebugging", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.paused", + "description": "Windows UI: debugger.paused", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.refreshWatches", + "description": "Windows UI: debugger.refreshWatches", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.removeBreakpoint", + "description": "Windows UI: debugger.removeBreakpoint", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.removeWatch", + "description": "Windows UI: debugger.removeWatch", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.restartService", + "description": "Windows UI: debugger.restartService", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.runAndDebug", + "description": "Windows UI: debugger.runAndDebug", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.showRunAndDebug", + "description": "Windows UI: debugger.showRunAndDebug", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.stack", + "description": "Windows UI: debugger.stack", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.stackEmpty", + "description": "Windows UI: debugger.stackEmpty", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.start", + "description": "Windows UI: debugger.start", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.statusExited", + "description": "Windows UI: debugger.statusExited", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.statusFailed", + "description": "Windows UI: debugger.statusFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.statusIdle", + "description": "Windows UI: debugger.statusIdle", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.statusPaused", + "description": "Windows UI: debugger.statusPaused", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.statusRunning", + "description": "Windows UI: debugger.statusRunning", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.statusStopped", + "description": "Windows UI: debugger.statusStopped", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.stepInto", + "description": "Windows UI: debugger.stepInto", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.stepOut", + "description": "Windows UI: debugger.stepOut", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.stepOver", + "description": "Windows UI: debugger.stepOver", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.stop", + "description": "Windows UI: debugger.stop", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.threads", + "description": "Windows UI: debugger.threads", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.threadsAndVariables", + "description": "Windows UI: debugger.threadsAndVariables", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.threadsEmpty", + "description": "Windows UI: debugger.threadsEmpty", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.toggleCurrentLineBreakpoint", + "description": "Windows UI: debugger.toggleCurrentLineBreakpoint", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.toggleRunAndDebug", + "description": "Windows UI: debugger.toggleRunAndDebug", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.variables", + "description": "Windows UI: debugger.variables", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.variablesEmpty", + "description": "Windows UI: debugger.variablesEmpty", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.watch", + "description": "Windows UI: debugger.watch", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.watchEmpty", + "description": "Windows UI: debugger.watchEmpty", + "platforms": [ + "windows" + ] + }, + { + "id": "diagnostics.all", + "description": "Windows UI: diagnostics.all", + "platforms": [ + "windows" + ] + }, + { + "id": "diagnostics.clearSourceFilter", + "description": "Windows UI: diagnostics.clearSourceFilter", + "platforms": [ + "windows" + ], + "parameters": { + "source": "string" + } + }, + { + "id": "diagnostics.clearSourceFilterSimple", + "description": "Windows UI: diagnostics.clearSourceFilterSimple", + "platforms": [ + "windows" + ] + }, + { + "id": "diagnostics.closePane", + "description": "Windows UI: diagnostics.closePane", + "platforms": [ + "windows" + ] + }, + { + "id": "diagnostics.copyDetails", + "description": "Windows UI: diagnostics.copyDetails", + "platforms": [ + "windows" + ] + }, + { + "id": "diagnostics.copyLocation", + "description": "Windows UI: diagnostics.copyLocation", + "platforms": [ + "windows" + ] + }, + { + "id": "diagnostics.copyMessage", + "description": "Windows UI: diagnostics.copyMessage", + "platforms": [ + "windows" + ] + }, + { + "id": "diagnostics.detailsCopied", + "description": "Windows UI: diagnostics.detailsCopied", + "platforms": [ + "windows" + ] + }, + { + "id": "diagnostics.disableWrap", + "description": "Windows UI: diagnostics.disableWrap", + "platforms": [ + "windows" + ] + }, + { + "id": "diagnostics.editorWidth", + "description": "Windows UI: diagnostics.editorWidth", + "platforms": [ + "windows" + ] + }, + { + "id": "diagnostics.empty", + "description": "Windows UI: diagnostics.empty", + "platforms": [ + "windows" + ] + }, + { + "id": "diagnostics.enableWrap", + "description": "Windows UI: diagnostics.enableWrap", + "platforms": [ + "windows" + ] + }, + { + "id": "diagnostics.errors", + "description": "Windows UI: diagnostics.errors", + "platforms": [ + "windows" + ] + }, + { + "id": "diagnostics.exitFullScreen", + "description": "Windows UI: diagnostics.exitFullScreen", + "platforms": [ + "windows" + ] + }, + { + "id": "diagnostics.filesAria", + "description": "Windows UI: diagnostics.filesAria", + "platforms": [ + "windows" + ] + }, + { + "id": "diagnostics.filter", + "description": "Windows UI: diagnostics.filter", + "platforms": [ + "windows" + ] + }, + { + "id": "diagnostics.filterBySource", + "description": "Windows UI: diagnostics.filterBySource", + "platforms": [ + "windows" + ], + "parameters": { + "source": "string" + } + }, + { + "id": "diagnostics.fullScreen", + "description": "Windows UI: diagnostics.fullScreen", + "platforms": [ + "windows" + ] + }, + { + "id": "diagnostics.fullWidth", + "description": "Windows UI: diagnostics.fullWidth", + "platforms": [ + "windows" + ] + }, + { + "id": "diagnostics.goToProblem", + "description": "Windows UI: diagnostics.goToProblem", + "platforms": [ + "windows" + ] + }, + { + "id": "diagnostics.groupByFile", + "description": "Windows UI: diagnostics.groupByFile", + "platforms": [ + "windows" + ] + }, + { + "id": "diagnostics.groupByNone", + "description": "Windows UI: diagnostics.groupByNone", + "platforms": [ + "windows" + ] + }, + { + "id": "diagnostics.groupBySeverity", + "description": "Windows UI: diagnostics.groupBySeverity", + "platforms": [ + "windows" + ] + }, + { + "id": "diagnostics.hideFiles", + "description": "Windows UI: diagnostics.hideFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "diagnostics.info", + "description": "Windows UI: diagnostics.info", + "platforms": [ + "windows" + ] + }, + { + "id": "diagnostics.loadingFixes", + "description": "Windows UI: diagnostics.loadingFixes", + "platforms": [ + "windows" + ] + }, + { + "id": "diagnostics.locationCopied", + "description": "Windows UI: diagnostics.locationCopied", + "platforms": [ + "windows" + ] + }, + { + "id": "diagnostics.messageCopied", + "description": "Windows UI: diagnostics.messageCopied", + "platforms": [ + "windows" + ] + }, + { + "id": "diagnostics.noFixes", + "description": "Windows UI: diagnostics.noFixes", + "platforms": [ + "windows" + ] + }, + { + "id": "diagnostics.noMatch", + "description": "Windows UI: diagnostics.noMatch", + "platforms": [ + "windows" + ] + }, + { + "id": "diagnostics.onlyCurrentFile", + "description": "Windows UI: diagnostics.onlyCurrentFile", + "platforms": [ + "windows" + ] + }, + { + "id": "diagnostics.problemCount", + "description": "Windows UI: diagnostics.problemCount", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "diagnostics.problemCountOne", + "description": "Windows UI: diagnostics.problemCountOne", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "diagnostics.resetAllFilters", + "description": "Windows UI: diagnostics.resetAllFilters", + "platforms": [ + "windows" + ] + }, + { + "id": "diagnostics.resetFilters", + "description": "Windows UI: diagnostics.resetFilters", + "platforms": [ + "windows" + ] + }, + { + "id": "diagnostics.search", + "description": "Windows UI: diagnostics.search", + "platforms": [ + "windows" + ] + }, + { + "id": "diagnostics.severityCount", + "description": "Windows UI: diagnostics.severityCount", + "platforms": [ + "windows" + ], + "parameters": { + "label": "string", + "total": "string", + "visible": "string" + } + }, + { + "id": "diagnostics.showFiles", + "description": "Windows UI: diagnostics.showFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "diagnostics.sortByFile", + "description": "Windows UI: diagnostics.sortByFile", + "platforms": [ + "windows" + ] + }, + { + "id": "diagnostics.sortByPosition", + "description": "Windows UI: diagnostics.sortByPosition", + "platforms": [ + "windows" + ] + }, + { + "id": "diagnostics.sortBySeverity", + "description": "Windows UI: diagnostics.sortBySeverity", + "platforms": [ + "windows" + ] + }, + { + "id": "diagnostics.warnings", + "description": "Windows UI: diagnostics.warnings", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.actionFailed", + "description": "Windows UI: docker.actionFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.actionsFor", + "description": "Windows UI: docker.actionsFor", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "docker.all", + "description": "Windows UI: docker.all", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.automated", + "description": "Windows UI: docker.automated", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.build", + "description": "Windows UI: docker.build", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.buildAndRunLocalImages", + "description": "Windows UI: docker.buildAndRunLocalImages", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.buildArgs", + "description": "Windows UI: docker.buildArgs", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.buildDockerImage", + "description": "Windows UI: docker.buildDockerImage", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.buildPresetName", + "description": "Windows UI: docker.buildPresetName", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.buildPresets", + "description": "Windows UI: docker.buildPresets", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.cleanupCompleted", + "description": "Windows UI: docker.cleanupCompleted", + "platforms": [ + "windows" + ], + "parameters": { + "target": "string" + } + }, + { + "id": "docker.cliUnavailable", + "description": "Windows UI: docker.cliUnavailable", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.cliUnavailableDescription", + "description": "Windows UI: docker.cliUnavailableDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.command", + "description": "Windows UI: docker.command", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.compose", + "description": "Windows UI: docker.compose", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.composePresetCompleted", + "description": "Windows UI: docker.composePresetCompleted", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "docker.composePresetName", + "description": "Windows UI: docker.composePresetName", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.composePresets", + "description": "Windows UI: docker.composePresets", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.composeProject", + "description": "Windows UI: docker.composeProject", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.composeProjectActions", + "description": "Windows UI: docker.composeProjectActions", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.composeUnavailable", + "description": "Windows UI: docker.composeUnavailable", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.composeUnavailableDescription", + "description": "Windows UI: docker.composeUnavailableDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.confirmDockerCleanup", + "description": "Windows UI: docker.confirmDockerCleanup", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.containerActions", + "description": "Windows UI: docker.containerActions", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.containerDestinationPath", + "description": "Windows UI: docker.containerDestinationPath", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.containerName", + "description": "Windows UI: docker.containerName", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.containers", + "description": "Windows UI: docker.containers", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.contextPath", + "description": "Windows UI: docker.contextPath", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.copiedToContainer", + "description": "Windows UI: docker.copiedToContainer", + "platforms": [ + "windows" + ], + "parameters": { + "source": "string", + "target": "string" + } + }, + { + "id": "docker.copiedToHost", + "description": "Windows UI: docker.copiedToHost", + "platforms": [ + "windows" + ], + "parameters": { + "source": "string", + "target": "string" + } + }, + { + "id": "docker.copy", + "description": "Windows UI: docker.copy", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.copyFileToHost", + "description": "Windows UI: docker.copyFileToHost", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "docker.copyFromContainer", + "description": "Windows UI: docker.copyFromContainer", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.copyIn", + "description": "Windows UI: docker.copyIn", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.copyToContainer", + "description": "Windows UI: docker.copyToContainer", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.copyToHost", + "description": "Windows UI: docker.copyToHost", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.copyToHostPath", + "description": "Windows UI: docker.copyToHostPath", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.debug", + "description": "Windows UI: docker.debug", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.debugCommand", + "description": "Windows UI: docker.debugCommand", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.debugInContainer", + "description": "Windows UI: docker.debugInContainer", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.debugPresetName", + "description": "Windows UI: docker.debugPresetName", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.debugPresetRequiresRunningContainer", + "description": "Windows UI: docker.debugPresetRequiresRunningContainer", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.debugPresets", + "description": "Windows UI: docker.debugPresets", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.delete", + "description": "Windows UI: docker.delete", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.deleteEnvFile", + "description": "Windows UI: docker.deleteEnvFile", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.details", + "description": "Windows UI: docker.details", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.devContainers", + "description": "Windows UI: docker.devContainers", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.directory", + "description": "Windows UI: docker.directory", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.dismiss", + "description": "Windows UI: docker.dismiss", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.dockerHub", + "description": "Windows UI: docker.dockerHub", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.envFile", + "description": "Windows UI: docker.envFile", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.envFilePath", + "description": "Windows UI: docker.envFilePath", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.envFiles", + "description": "Windows UI: docker.envFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.environment", + "description": "Windows UI: docker.environment", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.files", + "description": "Windows UI: docker.files", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.hostFileOrFolderPath", + "description": "Windows UI: docker.hostFileOrFolderPath", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.image", + "description": "Windows UI: docker.image", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.imageActions", + "description": "Windows UI: docker.imageActions", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.imageBuildCompleted", + "description": "Windows UI: docker.imageBuildCompleted", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.imageExamplePlaceholder", + "description": "Windows UI: docker.imageExamplePlaceholder", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.images", + "description": "Windows UI: docker.images", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.launchConfigs", + "description": "Windows UI: docker.launchConfigs", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.loadingCompose", + "description": "Windows UI: docker.loadingCompose", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.loadingFiles", + "description": "Windows UI: docker.loadingFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.loadingProjectConfig", + "description": "Windows UI: docker.loadingProjectConfig", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.loadingResources", + "description": "Windows UI: docker.loadingResources", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.logFilterAll", + "description": "Windows UI: docker.logFilterAll", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.logFilterErr", + "description": "Windows UI: docker.logFilterErr", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.logFilterErrors", + "description": "Windows UI: docker.logFilterErrors", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.login", + "description": "Windows UI: docker.login", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.logs", + "description": "Windows UI: docker.logs", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.logsStopped", + "description": "Windows UI: docker.logsStopped", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.networks", + "description": "Windows UI: docker.networks", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.noComposeFiles", + "description": "Windows UI: docker.noComposeFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.noComposeServicesFound", + "description": "Windows UI: docker.noComposeServicesFound", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.noEnvFilesOrPresets", + "description": "Windows UI: docker.noEnvFilesOrPresets", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.noFilesFound", + "description": "Windows UI: docker.noFilesFound", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.noMatchingComposeServices", + "description": "Windows UI: docker.noMatchingComposeServices", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.noMatchingContainers", + "description": "Windows UI: docker.noMatchingContainers", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.noMatchingImages", + "description": "Windows UI: docker.noMatchingImages", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.noMatchingLogLines", + "description": "Windows UI: docker.noMatchingLogLines", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.noMatchingNetworks", + "description": "Windows UI: docker.noMatchingNetworks", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.noMatchingVolumes", + "description": "Windows UI: docker.noMatchingVolumes", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.notRunning", + "description": "Windows UI: docker.notRunning", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.notRunningDescription", + "description": "Windows UI: docker.notRunningDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.official", + "description": "Windows UI: docker.official", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.offlineProjectFilesAvailable", + "description": "Windows UI: docker.offlineProjectFilesAvailable", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.open", + "description": "Windows UI: docker.open", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.openEnvFile", + "description": "Windows UI: docker.openEnvFile", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.openServiceUrl", + "description": "Windows UI: docker.openServiceUrl", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.openShell", + "description": "Windows UI: docker.openShell", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.openWorkspaceInspectCompose", + "description": "Windows UI: docker.openWorkspaceInspectCompose", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.openWorkspaceManagePresets", + "description": "Windows UI: docker.openWorkspaceManagePresets", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.opened", + "description": "Windows UI: docker.opened", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "docker.opening", + "description": "Windows UI: docker.opening", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.password", + "description": "Windows UI: docker.password", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.pause", + "description": "Windows UI: docker.pause", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.ports", + "description": "Windows UI: docker.ports", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.project", + "description": "Windows UI: docker.project", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.projectActionFailed", + "description": "Windows UI: docker.projectActionFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.projectActions", + "description": "Windows UI: docker.projectActions", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.prune", + "description": "Windows UI: docker.prune", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.pruneTarget", + "description": "Windows UI: docker.pruneTarget", + "platforms": [ + "windows" + ], + "parameters": { + "target": "string" + } + }, + { + "id": "docker.pruneTargetWithVolumes", + "description": "Windows UI: docker.pruneTargetWithVolumes", + "platforms": [ + "windows" + ], + "parameters": { + "target": "string" + } + }, + { + "id": "docker.pull", + "description": "Windows UI: docker.pull", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.pulledImage", + "description": "Windows UI: docker.pulledImage", + "platforms": [ + "windows" + ], + "parameters": { + "image": "string" + } + }, + { + "id": "docker.push", + "description": "Windows UI: docker.push", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.pushedImage", + "description": "Windows UI: docker.pushedImage", + "platforms": [ + "windows" + ], + "parameters": { + "image": "string" + } + }, + { + "id": "docker.rebuild", + "description": "Windows UI: docker.rebuild", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.refresh", + "description": "Windows UI: docker.refresh", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.refreshFiles", + "description": "Windows UI: docker.refreshFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.registry", + "description": "Windows UI: docker.registry", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.registryActionFailed", + "description": "Windows UI: docker.registryActionFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.registryImage", + "description": "Windows UI: docker.registryImage", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.registryLogin", + "description": "Windows UI: docker.registryLogin", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.registryLoginCompleted", + "description": "Windows UI: docker.registryLoginCompleted", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.registryOfflineNotice", + "description": "Windows UI: docker.registryOfflineNotice", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.registryOptional", + "description": "Windows UI: docker.registryOptional", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.remove", + "description": "Windows UI: docker.remove", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.removed", + "description": "Windows UI: docker.removed", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "docker.resources", + "description": "Windows UI: docker.resources", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.restart", + "description": "Windows UI: docker.restart", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.retry", + "description": "Windows UI: docker.retry", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.run", + "description": "Windows UI: docker.run", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.runDockerImage", + "description": "Windows UI: docker.runDockerImage", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.runPresetName", + "description": "Windows UI: docker.runPresetName", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.runPresets", + "description": "Windows UI: docker.runPresets", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.running", + "description": "Windows UI: docker.running", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.runningEllipsis", + "description": "Windows UI: docker.runningEllipsis", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.saveBuildPreset", + "description": "Windows UI: docker.saveBuildPreset", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.saveComposePreset", + "description": "Windows UI: docker.saveComposePreset", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.saveDebug", + "description": "Windows UI: docker.saveDebug", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.saveDebugPreset", + "description": "Windows UI: docker.saveDebugPreset", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.savePreset", + "description": "Windows UI: docker.savePreset", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.saveRunPreset", + "description": "Windows UI: docker.saveRunPreset", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.searchDocker", + "description": "Windows UI: docker.searchDocker", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.searchDockerHub", + "description": "Windows UI: docker.searchDockerHub", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.searchDockerHubToFindImages", + "description": "Windows UI: docker.searchDockerHubToFindImages", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.searchDockerResources", + "description": "Windows UI: docker.searchDockerResources", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.searchImages", + "description": "Windows UI: docker.searchImages", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.searchLogs", + "description": "Windows UI: docker.searchLogs", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.section.cleanup", + "description": "Windows UI: docker.section.cleanup", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.section.compose", + "description": "Windows UI: docker.section.compose", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.section.containers", + "description": "Windows UI: docker.section.containers", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.section.images", + "description": "Windows UI: docker.section.images", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.section.networks", + "description": "Windows UI: docker.section.networks", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.section.project", + "description": "Windows UI: docker.section.project", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.section.registry", + "description": "Windows UI: docker.section.registry", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.section.volumes", + "description": "Windows UI: docker.section.volumes", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.selectRunningContainerBeforeDebugPreset", + "description": "Windows UI: docker.selectRunningContainerBeforeDebugPreset", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.serviceActions", + "description": "Windows UI: docker.serviceActions", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.servicesCount", + "description": "Windows UI: docker.servicesCount", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "docker.starsCount", + "description": "Windows UI: docker.starsCount", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "docker.start", + "description": "Windows UI: docker.start", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.startDockerBeforeBuilding", + "description": "Windows UI: docker.startDockerBeforeBuilding", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.startDockerBeforeRunning", + "description": "Windows UI: docker.startDockerBeforeRunning", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.startWithEnvFiles", + "description": "Windows UI: docker.startWithEnvFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.startedImage", + "description": "Windows UI: docker.startedImage", + "platforms": [ + "windows" + ], + "parameters": { + "image": "string" + } + }, + { + "id": "docker.stop", + "description": "Windows UI: docker.stop", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.stopProject", + "description": "Windows UI: docker.stopProject", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.stopped", + "description": "Windows UI: docker.stopped", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.streamingLogs", + "description": "Windows UI: docker.streamingLogs", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.system", + "description": "Windows UI: docker.system", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.tag", + "description": "Windows UI: docker.tag", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.taggedImage", + "description": "Windows UI: docker.taggedImage", + "platforms": [ + "windows" + ], + "parameters": { + "source": "string", + "target": "string" + } + }, + { + "id": "docker.targetTag", + "description": "Windows UI: docker.targetTag", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.technicalDetails", + "description": "Windows UI: docker.technicalDetails", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.typeDeleteToRemove", + "description": "Windows UI: docker.typeDeleteToRemove", + "platforms": [ + "windows" + ], + "parameters": { + "path": "string" + } + }, + { + "id": "docker.typePruneToClean", + "description": "Windows UI: docker.typePruneToClean", + "platforms": [ + "windows" + ], + "parameters": { + "target": "string" + } + }, + { + "id": "docker.unavailable", + "description": "Windows UI: docker.unavailable", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.unavailableDescription", + "description": "Windows UI: docker.unavailableDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.unpause", + "description": "Windows UI: docker.unpause", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.up", + "description": "Windows UI: docker.up", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.usePreset", + "description": "Windows UI: docker.usePreset", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.username", + "description": "Windows UI: docker.username", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.variable", + "description": "Windows UI: docker.variable", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.variables", + "description": "Windows UI: docker.variables", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.viewOptions", + "description": "Windows UI: docker.viewOptions", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.volumes", + "description": "Windows UI: docker.volumes", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.waitingForLogs", + "description": "Windows UI: docker.waitingForLogs", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.workingDirectory", + "description": "Windows UI: docker.workingDirectory", + "platforms": [ + "windows" + ] + }, + { + "id": "docker.workspace", + "description": "Windows UI: docker.workspace", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.actions", + "description": "Windows UI: editor.actions", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.addSelectionToNextMatch", + "description": "Windows UI: editor.addSelectionToNextMatch", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.aiInlineEdit", + "description": "Windows UI: editor.aiInlineEdit", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.autoSaveFailed", + "description": "Windows UI: editor.autoSaveFailed", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "editor.contentNotLoaded", + "description": "Windows UI: editor.contentNotLoaded", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "editor.duplicateLine", + "description": "Windows UI: editor.duplicateLine", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.enableLargeFileServices", + "description": "Windows UI: editor.enableLargeFileServices", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.encodingDiscard", + "description": "Windows UI: editor.encodingDiscard", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.encodingFailed", + "description": "Windows UI: editor.encodingFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.encodingReopenDirty", + "description": "Windows UI: editor.encodingReopenDirty", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.encodingReopenTitle", + "description": "Windows UI: editor.encodingReopenTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.externalConflict", + "description": "Windows UI: editor.externalConflict", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.externalDeleted", + "description": "Windows UI: editor.externalDeleted", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.externalReadFailed", + "description": "Windows UI: editor.externalReadFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.find", + "description": "Windows UI: editor.find", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.findAllReferences", + "description": "Windows UI: editor.findAllReferences", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.findInFile", + "description": "Windows UI: editor.findInFile", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.formatDocument", + "description": "Windows UI: editor.formatDocument", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.formatSelection", + "description": "Windows UI: editor.formatSelection", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.goToDefinition", + "description": "Windows UI: editor.goToDefinition", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.goToLine", + "description": "Windows UI: editor.goToLine", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.goToLineColumn", + "description": "Windows UI: editor.goToLineColumn", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.goToTypeDefinition", + "description": "Windows UI: editor.goToTypeDefinition", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.indent", + "description": "Windows UI: editor.indent", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.inlineGitBlame", + "description": "Windows UI: editor.inlineGitBlame", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.keepEditorVersion", + "description": "Windows UI: editor.keepEditorVersion", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.largeFileServicesDisabled", + "description": "Windows UI: editor.largeFileServicesDisabled", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.loadDiskVersion", + "description": "Windows UI: editor.loadDiskVersion", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.markdownPreviewView", + "description": "Windows UI: editor.markdownPreviewView", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.markdownSourceView", + "description": "Windows UI: editor.markdownSourceView", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.markdownSplitView", + "description": "Windows UI: editor.markdownSplitView", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.markdownViewModes", + "description": "Windows UI: editor.markdownViewModes", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.minimap", + "description": "Windows UI: editor.minimap", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.moveLineDown", + "description": "Windows UI: editor.moveLineDown", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.moveLineUp", + "description": "Windows UI: editor.moveLineUp", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.noLanguages", + "description": "Windows UI: editor.noLanguages", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.openHtmlInBrowser", + "description": "Windows UI: editor.openHtmlInBrowser", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.openHtmlInBrowserFailed", + "description": "Windows UI: editor.openHtmlInBrowserFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.outdent", + "description": "Windows UI: editor.outdent", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.plainText", + "description": "Windows UI: editor.plainText", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.preferences", + "description": "Windows UI: editor.preferences", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.preview", + "description": "Windows UI: editor.preview", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.quickFix", + "description": "Windows UI: editor.quickFix", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.recreateFile", + "description": "Windows UI: editor.recreateFile", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.renameSymbol", + "description": "Windows UI: editor.renameSymbol", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.reopenWithEncoding", + "description": "Windows UI: editor.reopenWithEncoding", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.restoreLoadFailed", + "description": "Windows UI: editor.restoreLoadFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.saveFailed", + "description": "Windows UI: editor.saveFailed", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "editor.saveWithEncoding", + "description": "Windows UI: editor.saveWithEncoding", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.selectAll", + "description": "Windows UI: editor.selectAll", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.selectAllOccurrences", + "description": "Windows UI: editor.selectAllOccurrences", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.selectLanguage", + "description": "Windows UI: editor.selectLanguage", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.showHover", + "description": "Windows UI: editor.showHover", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.symbolPath", + "description": "Windows UI: editor.symbolPath", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.title", + "description": "Windows UI: editor.title", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.toggleBookmark", + "description": "Windows UI: editor.toggleBookmark", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.toggleCase", + "description": "Windows UI: editor.toggleCase", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.toggleComment", + "description": "Windows UI: editor.toggleComment", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.triggerSuggest", + "description": "Windows UI: editor.triggerSuggest", + "platforms": [ + "windows" + ] + }, + { + "id": "editor.unknownFile", + "description": "Windows UI: editor.unknownFile", + "platforms": [ + "windows" + ] + }, + { + "id": "editorCommands.appliedAction", + "description": "Windows UI: editorCommands.appliedAction", + "platforms": [ + "windows" + ], + "parameters": { + "title": "string" + } + }, + { + "id": "editorCommands.chooseQuickFixPrompt", + "description": "Windows UI: editorCommands.chooseQuickFixPrompt", + "platforms": [ + "windows" + ] + }, + { + "id": "editorCommands.documentAlreadyFormatted", + "description": "Windows UI: editorCommands.documentAlreadyFormatted", + "platforms": [ + "windows" + ] + }, + { + "id": "editorCommands.documentFormatted", + "description": "Windows UI: editorCommands.documentFormatted", + "platforms": [ + "windows" + ] + }, + { + "id": "editorCommands.formattingFailed", + "description": "Windows UI: editorCommands.formattingFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "editorCommands.noDiagnosticAtCursor", + "description": "Windows UI: editorCommands.noDiagnosticAtCursor", + "platforms": [ + "windows" + ] + }, + { + "id": "editorCommands.noEditableFileForQuickFixes", + "description": "Windows UI: editorCommands.noEditableFileForQuickFixes", + "platforms": [ + "windows" + ] + }, + { + "id": "editorCommands.noEditableFileToFormat", + "description": "Windows UI: editorCommands.noEditableFileToFormat", + "platforms": [ + "windows" + ] + }, + { + "id": "editorCommands.noFoldableEditor", + "description": "Windows UI: editorCommands.noFoldableEditor", + "platforms": [ + "windows" + ] + }, + { + "id": "editorCommands.noFormatterConfigured", + "description": "Windows UI: editorCommands.noFormatterConfigured", + "platforms": [ + "windows" + ] + }, + { + "id": "editorCommands.noQuickFixes", + "description": "Windows UI: editorCommands.noQuickFixes", + "platforms": [ + "windows" + ] + }, + { + "id": "editorCommands.preferredAction", + "description": "Windows UI: editorCommands.preferredAction", + "platforms": [ + "windows" + ], + "parameters": { + "title": "string" + } + }, + { + "id": "editorCommands.selectTextToFormat", + "description": "Windows UI: editorCommands.selectTextToFormat", + "platforms": [ + "windows" + ] + }, + { + "id": "editorCommands.selectionAlreadyFormatted", + "description": "Windows UI: editorCommands.selectionAlreadyFormatted", + "platforms": [ + "windows" + ] + }, + { + "id": "editorCommands.selectionFormatted", + "description": "Windows UI: editorCommands.selectionFormatted", + "platforms": [ + "windows" + ] + }, + { + "id": "editorCommands.selectionFormattingFailed", + "description": "Windows UI: editorCommands.selectionFormattingFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "editorCommands.unableToApplyAction", + "description": "Windows UI: editorCommands.unableToApplyAction", + "platforms": [ + "windows" + ], + "parameters": { + "title": "string" + } + }, + { + "id": "extensions.activate", + "description": "Windows UI: extensions.activate", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.activateAndUse", + "description": "Windows UI: extensions.activateAndUse", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.activateFailed", + "description": "Windows UI: extensions.activateFailed", + "platforms": [ + "windows" + ], + "parameters": { + "message": "string", + "name": "string" + } + }, + { + "id": "extensions.activated", + "description": "Windows UI: extensions.activated", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "extensions.active", + "description": "Windows UI: extensions.active", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.add", + "description": "Windows UI: extensions.add", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.addedSuccessfully", + "description": "Windows UI: extensions.addedSuccessfully", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "extensions.agentFallbackDescription", + "description": "Windows UI: extensions.agentFallbackDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.agents", + "description": "Windows UI: extensions.agents", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.ai", + "description": "Windows UI: extensions.ai", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.all", + "description": "Windows UI: extensions.all", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.alreadyInstalled", + "description": "Windows UI: extensions.alreadyInstalled", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "extensions.availableCount", + "description": "Windows UI: extensions.availableCount", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "extensions.builtIn", + "description": "Windows UI: extensions.builtIn", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.byPublisher", + "description": "Windows UI: extensions.byPublisher", + "platforms": [ + "windows" + ], + "parameters": { + "publisher": "string" + } + }, + { + "id": "extensions.cannotInstallAutomatically", + "description": "Windows UI: extensions.cannotInstallAutomatically", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "extensions.category.agent", + "description": "Windows UI: extensions.category.agent", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.category.ai", + "description": "Windows UI: extensions.category.ai", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.category.iconTheme", + "description": "Windows UI: extensions.category.icon-theme", + "platforms": [ + "windows" + ], + "windows": { + "key": "extensions.category.icon-theme" + } + }, + { + "id": "extensions.category.integration", + "description": "Windows UI: extensions.category.integration", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.category.language", + "description": "Windows UI: extensions.category.language", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.category.skill", + "description": "Windows UI: extensions.category.skill", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.category.theme", + "description": "Windows UI: extensions.category.theme", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.contributions", + "description": "Windows UI: extensions.contributions", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.crashed", + "description": "Windows UI: extensions.crashed", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "extensions.current", + "description": "Windows UI: extensions.current", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.currentName", + "description": "Windows UI: extensions.currentName", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "extensions.deactivate", + "description": "Windows UI: extensions.deactivate", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.deactivateFailed", + "description": "Windows UI: extensions.deactivateFailed", + "platforms": [ + "windows" + ], + "parameters": { + "message": "string", + "name": "string" + } + }, + { + "id": "extensions.deactivated", + "description": "Windows UI: extensions.deactivated", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "extensions.disabled", + "description": "Windows UI: extensions.disabled", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.extensionError", + "description": "Windows UI: extensions.extensionError", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.globalInstallStillDetected", + "description": "Windows UI: extensions.globalInstallStillDetected", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.iconThemeFallbackDescription", + "description": "Windows UI: extensions.iconThemeFallbackDescription", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "extensions.iconThemes", + "description": "Windows UI: extensions.iconThemes", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.importPackage", + "description": "Windows UI: extensions.importPackage", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.install", + "description": "Windows UI: extensions.install", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.installFailed", + "description": "Windows UI: extensions.installFailed", + "platforms": [ + "windows" + ], + "parameters": { + "message": "string" + } + }, + { + "id": "extensions.installFailedGeneric", + "description": "Windows UI: extensions.installFailedGeneric", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.installFailedWithMessage", + "description": "Windows UI: extensions.installFailedWithMessage", + "platforms": [ + "windows" + ], + "parameters": { + "message": "string", + "name": "string" + } + }, + { + "id": "extensions.installPrompt", + "description": "Windows UI: extensions.installPrompt", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "extensions.installSuccess", + "description": "Windows UI: extensions.installSuccess", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "extensions.installed", + "description": "Windows UI: extensions.installed", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.installedCount", + "description": "Windows UI: extensions.installedCount", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "extensions.installedSuccessfully", + "description": "Windows UI: extensions.installedSuccessfully", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "extensions.installing", + "description": "Windows UI: extensions.installing", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "extensions.integrations", + "description": "Windows UI: extensions.integrations", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.languages", + "description": "Windows UI: extensions.languages", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.loadingAgents", + "description": "Windows UI: extensions.loadingAgents", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.loadingExtension", + "description": "Windows UI: extensions.loadingExtension", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.loadingSkills", + "description": "Windows UI: extensions.loadingSkills", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.local", + "description": "Windows UI: extensions.local", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.managedInstallRemoved", + "description": "Windows UI: extensions.managedInstallRemoved", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "extensions.marketplace", + "description": "Windows UI: extensions.marketplace", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.newSkill", + "description": "Windows UI: extensions.newSkill", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.noneFound", + "description": "Windows UI: extensions.noneFound", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.noneSelected", + "description": "Windows UI: extensions.noneSelected", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.notFound", + "description": "Windows UI: extensions.notFound", + "platforms": [ + "windows" + ], + "parameters": { + "id": "string" + } + }, + { + "id": "extensions.packageTooLarge", + "description": "Windows UI: extensions.packageTooLarge", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.reinstall", + "description": "Windows UI: extensions.reinstall", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Reinstall", + "arguments": [] + } + }, + { + "id": "extensions.remove", + "description": "Windows UI: extensions.remove", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.removedSuccessfully", + "description": "Windows UI: extensions.removedSuccessfully", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "extensions.resetFailed", + "description": "Windows UI: extensions.resetFailed", + "platforms": [ + "windows" + ], + "parameters": { + "message": "string", + "name": "string" + } + }, + { + "id": "extensions.resetToMarketplaceSuccess", + "description": "Windows UI: extensions.resetToMarketplaceSuccess", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "extensions.resetToMarketplaceVersion", + "description": "Windows UI: extensions.resetToMarketplaceVersion", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.retryLoading", + "description": "Windows UI: extensions.retryLoading", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "extensions.reusableSkillDescription", + "description": "Windows UI: extensions.reusableSkillDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.searchPlaceholder", + "description": "Windows UI: extensions.searchPlaceholder", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.selected", + "description": "Windows UI: extensions.selected", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "extensions.skills", + "description": "Windows UI: extensions.skills", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.themeFallbackDescription", + "description": "Windows UI: extensions.themeFallbackDescription", + "platforms": [ + "windows" + ], + "parameters": { + "category": "string" + } + }, + { + "id": "extensions.themes", + "description": "Windows UI: extensions.themes", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.title", + "description": "Windows UI: extensions.title", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.unavailable", + "description": "Windows UI: extensions.unavailable", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.unexpectedError", + "description": "Windows UI: extensions.unexpectedError", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.uninstall", + "description": "Windows UI: extensions.uninstall", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Uninstall", + "arguments": [] + } + }, + { + "id": "extensions.uninstallFailed", + "description": "Windows UI: extensions.uninstallFailed", + "platforms": [ + "windows" + ], + "parameters": { + "message": "string", + "name": "string" + } + }, + { + "id": "extensions.uninstalledSuccessfully", + "description": "Windows UI: extensions.uninstalledSuccessfully", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "extensions.unknownError", + "description": "Windows UI: extensions.unknownError", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.update", + "description": "Windows UI: extensions.update", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.updateActionFailed", + "description": "Windows UI: extensions.updateActionFailed", + "platforms": [ + "windows" + ], + "parameters": { + "message": "string", + "name": "string" + } + }, + { + "id": "extensions.updateFailed", + "description": "Windows UI: extensions.updateFailed", + "platforms": [ + "windows" + ], + "parameters": { + "message": "string", + "name": "string" + } + }, + { + "id": "extensions.updatedSuccessfully", + "description": "Windows UI: extensions.updatedSuccessfully", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "extensions.updatedWithLocalOverride", + "description": "Windows UI: extensions.updatedWithLocalOverride", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "extensions.updatesCount", + "description": "Windows UI: extensions.updatesCount", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "extensions.use", + "description": "Windows UI: extensions.use", + "platforms": [ + "windows" + ] + }, + { + "id": "extensions.useFailed", + "description": "Windows UI: extensions.useFailed", + "platforms": [ + "windows" + ], + "parameters": { + "message": "string", + "name": "string" + } + }, + { + "id": "extensions.useName", + "description": "Windows UI: extensions.useName", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "extensions.you", + "description": "Windows UI: extensions.you", + "platforms": [ + "windows" + ] + }, + { + "id": "fileExplorer.ariaLabel", + "description": "Windows UI: fileExplorer.ariaLabel", + "platforms": [ + "windows" + ] + }, + { + "id": "fileExplorer.cannotCreateFile", + "description": "Windows UI: fileExplorer.cannotCreateFile", + "platforms": [ + "windows" + ] + }, + { + "id": "fileExplorer.cannotDetermineCreateLocation", + "description": "Windows UI: fileExplorer.cannotDetermineCreateLocation", + "platforms": [ + "windows" + ] + }, + { + "id": "fileExplorer.deleteFile", + "description": "Windows UI: fileExplorer.deleteFile", + "platforms": [ + "windows" + ] + }, + { + "id": "fileExplorer.deleteFileMessage", + "description": "Windows UI: fileExplorer.deleteFileMessage", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "fileExplorer.deleteFolder", + "description": "Windows UI: fileExplorer.deleteFolder", + "platforms": [ + "windows" + ] + }, + { + "id": "fileExplorer.deleteFolderMessage", + "description": "Windows UI: fileExplorer.deleteFolderMessage", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "fileExplorer.deleting", + "description": "Windows UI: fileExplorer.deleting", + "platforms": [ + "windows" + ] + }, + { + "id": "fileExplorer.fileAlreadyExists", + "description": "Windows UI: fileExplorer.fileAlreadyExists", + "platforms": [ + "windows" + ] + }, + { + "id": "fileExplorer.fileAlreadyExistsMessage", + "description": "Windows UI: fileExplorer.fileAlreadyExistsMessage", + "platforms": [ + "windows" + ] + }, + { + "id": "fileExplorer.fileIcons", + "description": "Windows UI: fileExplorer.fileIcons", + "platforms": [ + "windows" + ] + }, + { + "id": "fileExplorer.folderAlreadyExists", + "description": "Windows UI: fileExplorer.folderAlreadyExists", + "platforms": [ + "windows" + ] + }, + { + "id": "fileExplorer.folderAlreadyExistsMessage", + "description": "Windows UI: fileExplorer.folderAlreadyExistsMessage", + "platforms": [ + "windows" + ] + }, + { + "id": "fileExplorer.folderIsEmpty", + "description": "Windows UI: fileExplorer.folderIsEmpty", + "platforms": [ + "windows" + ] + }, + { + "id": "fileExplorer.gitStatusDecorations", + "description": "Windows UI: fileExplorer.gitStatusDecorations", + "platforms": [ + "windows" + ] + }, + { + "id": "fileExplorer.gitignoredFiles", + "description": "Windows UI: fileExplorer.gitignoredFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "fileExplorer.indentGuides", + "description": "Windows UI: fileExplorer.indentGuides", + "platforms": [ + "windows" + ] + }, + { + "id": "fileExplorer.indentation", + "description": "Windows UI: fileExplorer.indentation", + "platforms": [ + "windows" + ] + }, + { + "id": "fileExplorer.indentationCompact", + "description": "Windows UI: fileExplorer.indentationCompact", + "platforms": [ + "windows" + ] + }, + { + "id": "fileExplorer.indentationDefault", + "description": "Windows UI: fileExplorer.indentationDefault", + "platforms": [ + "windows" + ] + }, + { + "id": "fileExplorer.indentationSpacious", + "description": "Windows UI: fileExplorer.indentationSpacious", + "platforms": [ + "windows" + ] + }, + { + "id": "fileExplorer.indentationWide", + "description": "Windows UI: fileExplorer.indentationWide", + "platforms": [ + "windows" + ] + }, + { + "id": "fileExplorer.moveFailed", + "description": "Windows UI: fileExplorer.moveFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "fileExplorer.noFolderOpen", + "description": "Windows UI: fileExplorer.noFolderOpen", + "platforms": [ + "windows" + ] + }, + { + "id": "fileExplorer.noMatchingFiles", + "description": "Windows UI: fileExplorer.noMatchingFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "fileExplorer.ok", + "description": "Windows UI: fileExplorer.ok", + "platforms": [ + "windows" + ] + }, + { + "id": "fileExplorer.openAllFilesMessage", + "description": "Windows UI: fileExplorer.openAllFilesMessage", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "fileExplorer.opening", + "description": "Windows UI: fileExplorer.opening", + "platforms": [ + "windows" + ] + }, + { + "id": "fileExplorer.preferences", + "description": "Windows UI: fileExplorer.preferences", + "platforms": [ + "windows" + ] + }, + { + "id": "fileExplorer.searchFiles", + "description": "Windows UI: fileExplorer.searchFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "fileExplorer.searchingFiles", + "description": "Windows UI: fileExplorer.searchingFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "fileExplorer.visibility", + "description": "Windows UI: fileExplorer.visibility", + "platforms": [ + "windows" + ] + }, + { + "id": "fileNavigator.fileTree", + "description": "Windows UI: fileNavigator.fileTree", + "platforms": [ + "windows" + ] + }, + { + "id": "fileNavigator.files", + "description": "Windows UI: fileNavigator.files", + "platforms": [ + "windows" + ] + }, + { + "id": "fileNavigator.flatList", + "description": "Windows UI: fileNavigator.flatList", + "platforms": [ + "windows" + ] + }, + { + "id": "fileNavigator.noFilesMatch", + "description": "Windows UI: fileNavigator.noFilesMatch", + "platforms": [ + "windows" + ] + }, + { + "id": "fileNavigator.resizeAria", + "description": "Windows UI: fileNavigator.resizeAria", + "platforms": [ + "windows" + ] + }, + { + "id": "fileNavigator.searchResultFiles", + "description": "Windows UI: fileNavigator.searchResultFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "fileNavigator.showingCount", + "description": "Windows UI: fileNavigator.showingCount", + "platforms": [ + "windows" + ], + "parameters": { + "total": "string", + "visible": "string" + } + }, + { + "id": "fileNavigator.viewAria", + "description": "Windows UI: fileNavigator.viewAria", + "platforms": [ + "windows" + ] + }, + { + "id": "fileSystem.addFolderLocalOnly", + "description": "Windows UI: fileSystem.addFolderLocalOnly", + "platforms": [ + "windows" + ] + }, + { + "id": "fileSystem.addFolderToWorkspaceFailed", + "description": "Windows UI: fileSystem.addFolderToWorkspaceFailed", + "platforms": [ + "windows" + ], + "parameters": { + "path": "string" + } + }, + { + "id": "fileSystem.folderAddedToWorkspace", + "description": "Windows UI: fileSystem.folderAddedToWorkspace", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "fileSystem.folderAlreadyInWorkspace", + "description": "Windows UI: fileSystem.folderAlreadyInWorkspace", + "platforms": [ + "windows" + ] + }, + { + "id": "fileSystem.folderRemovedFromWorkspace", + "description": "Windows UI: fileSystem.folderRemovedFromWorkspace", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "fileSystem.noSupportedDroppedPaths", + "description": "Windows UI: fileSystem.noSupportedDroppedPaths", + "platforms": [ + "windows" + ] + }, + { + "id": "fileSystem.openFileFailed", + "description": "Windows UI: fileSystem.openFileFailed", + "platforms": [ + "windows" + ], + "parameters": { + "error": "string", + "file": "string" + } + }, + { + "id": "fileSystem.openFolderFailed", + "description": "Windows UI: fileSystem.openFolderFailed", + "platforms": [ + "windows" + ], + "parameters": { + "path": "string" + } + }, + { + "id": "fileSystem.openRemoteProjectFailed", + "description": "Windows UI: fileSystem.openRemoteProjectFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "fileSystem.primaryFolderCannotBeRemoved", + "description": "Windows UI: fileSystem.primaryFolderCannotBeRemoved", + "platforms": [ + "windows" + ] + }, + { + "id": "fileSystem.recentProjectNotFolder", + "description": "Windows UI: fileSystem.recentProjectNotFolder", + "platforms": [ + "windows" + ], + "parameters": { + "path": "string" + } + }, + { + "id": "fileSystem.recentProjectUnavailable", + "description": "Windows UI: fileSystem.recentProjectUnavailable", + "platforms": [ + "windows" + ], + "parameters": { + "path": "string" + } + }, + { + "id": "fileSystem.recentProjectUnavailableBadge", + "description": "Windows UI: fileSystem.recentProjectUnavailableBadge", + "platforms": [ + "windows" + ] + }, + { + "id": "fileSystem.recentProjectUnavailableTitle", + "description": "Windows UI: fileSystem.recentProjectUnavailableTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "fileSystem.remoteSessionRestoreFailed", + "description": "Windows UI: fileSystem.remoteSessionRestoreFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "fileSystem.removeFolderFromWorkspaceFailed", + "description": "Windows UI: fileSystem.removeFolderFromWorkspaceFailed", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "fileSystem.removeFromList", + "description": "Windows UI: fileSystem.removeFromList", + "platforms": [ + "windows" + ] + }, + { + "id": "fileSystem.removedMissingProject", + "description": "Windows UI: fileSystem.removedMissingProject", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "fileSystem.restoreWorkspaceFolderFailed", + "description": "Windows UI: fileSystem.restoreWorkspaceFolderFailed", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "fileSystem.revealLocalOnly", + "description": "Windows UI: fileSystem.revealLocalOnly", + "platforms": [ + "windows" + ] + }, + { + "id": "fileSystem.switchProjectFailed", + "description": "Windows UI: fileSystem.switchProjectFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "fileSystem.workspaceSessionRestoreFailed", + "description": "Windows UI: fileSystem.workspaceSessionRestoreFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "fileSystem.wslSessionRestoreFailed", + "description": "Windows UI: fileSystem.wslSessionRestoreFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "files.addFolderToWorkspace", + "description": "Windows UI: files.addFolderToWorkspace", + "platforms": [ + "windows" + ] + }, + { + "id": "files.cancel", + "description": "Windows UI: files.cancel", + "platforms": [ + "windows" + ] + }, + { + "id": "files.chooseDifferentEnvFileName", + "description": "Windows UI: files.chooseDifferentEnvFileName", + "platforms": [ + "windows" + ] + }, + { + "id": "files.collapseAll", + "description": "Windows UI: files.collapseAll", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Collapse All", + "arguments": [] + } + }, + { + "id": "files.copy", + "description": "Windows UI: files.copy", + "platforms": [ + "windows" + ] + }, + { + "id": "files.copyContent", + "description": "Windows UI: files.copyContent", + "platforms": [ + "windows" + ] + }, + { + "id": "files.copyPath", + "description": "Windows UI: files.copyPath", + "platforms": [ + "windows" + ] + }, + { + "id": "files.copyRelativePath", + "description": "Windows UI: files.copyRelativePath", + "platforms": [ + "windows" + ] + }, + { + "id": "files.createEnvDevelopment", + "description": "Windows UI: files.createEnvDevelopment", + "platforms": [ + "windows" + ] + }, + { + "id": "files.createEnvExample", + "description": "Windows UI: files.createEnvExample", + "platforms": [ + "windows" + ] + }, + { + "id": "files.createEnvLocal", + "description": "Windows UI: files.createEnvLocal", + "platforms": [ + "windows" + ] + }, + { + "id": "files.createFailed", + "description": "Windows UI: files.createFailed", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "files.createFileFailedMessage", + "description": "Windows UI: files.createFileFailedMessage", + "platforms": [ + "windows" + ], + "parameters": { + "error": "string" + } + }, + { + "id": "files.created", + "description": "Windows UI: files.created", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "files.cut", + "description": "Windows UI: files.cut", + "platforms": [ + "windows" + ] + }, + { + "id": "files.delete", + "description": "Windows UI: files.delete", + "platforms": [ + "windows" + ] + }, + { + "id": "files.duplicate", + "description": "Windows UI: files.duplicate", + "platforms": [ + "windows" + ] + }, + { + "id": "files.enterNewFileName", + "description": "Windows UI: files.enterNewFileName", + "platforms": [ + "windows" + ] + }, + { + "id": "files.enterNewFolderName", + "description": "Windows UI: files.enterNewFolderName", + "platforms": [ + "windows" + ] + }, + { + "id": "files.fileNamePlaceholder", + "description": "Windows UI: files.fileNamePlaceholder", + "platforms": [ + "windows" + ] + }, + { + "id": "files.findInFolder", + "description": "Windows UI: files.findInFolder", + "platforms": [ + "windows" + ] + }, + { + "id": "files.folderNamePlaceholder", + "description": "Windows UI: files.folderNamePlaceholder", + "platforms": [ + "windows" + ] + }, + { + "id": "files.generateImage", + "description": "Windows UI: files.generateImage", + "platforms": [ + "windows" + ] + }, + { + "id": "files.invalidFileName", + "description": "Windows UI: files.invalidFileName", + "platforms": [ + "windows" + ] + }, + { + "id": "files.invalidFileNameCharacters", + "description": "Windows UI: files.invalidFileNameCharacters", + "platforms": [ + "windows" + ] + }, + { + "id": "files.javaClassAlreadyExists", + "description": "Windows UI: files.javaClassAlreadyExists", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "files.javaPasteRemoteUnsupported", + "description": "Windows UI: files.javaPasteRemoteUnsupported", + "platforms": [ + "windows" + ] + }, + { + "id": "files.localHistory", + "description": "Windows UI: files.localHistory", + "platforms": [ + "windows" + ] + }, + { + "id": "files.nameNewFile", + "description": "Windows UI: files.nameNewFile", + "platforms": [ + "windows" + ] + }, + { + "id": "files.nameNewFolder", + "description": "Windows UI: files.nameNewFolder", + "platforms": [ + "windows" + ] + }, + { + "id": "files.newFile", + "description": "Windows UI: files.newFile", + "platforms": [ + "windows" + ] + }, + { + "id": "files.newFolder", + "description": "Windows UI: files.newFolder", + "platforms": [ + "windows" + ] + }, + { + "id": "files.noExtension", + "description": "Windows UI: files.noExtension", + "platforms": [ + "windows" + ] + }, + { + "id": "files.nothingToPaste", + "description": "Windows UI: files.nothingToPaste", + "platforms": [ + "windows" + ] + }, + { + "id": "files.open", + "description": "Windows UI: files.open", + "platforms": [ + "windows" + ] + }, + { + "id": "files.openAllFiles", + "description": "Windows UI: files.openAllFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "files.openFailed", + "description": "Windows UI: files.openFailed", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "files.openFolderFirst", + "description": "Windows UI: files.openFolderFirst", + "platforms": [ + "windows" + ] + }, + { + "id": "files.openInTerminal", + "description": "Windows UI: files.openInTerminal", + "platforms": [ + "windows" + ] + }, + { + "id": "files.openPreview", + "description": "Windows UI: files.openPreview", + "platforms": [ + "windows" + ] + }, + { + "id": "files.overwrite", + "description": "Windows UI: files.overwrite", + "platforms": [ + "windows" + ] + }, + { + "id": "files.overwriteEnvMessage", + "description": "Windows UI: files.overwriteEnvMessage", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "files.overwriteEnvTitle", + "description": "Windows UI: files.overwriteEnvTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "files.paste", + "description": "Windows UI: files.paste", + "platforms": [ + "windows" + ] + }, + { + "id": "files.properties", + "description": "Windows UI: files.properties", + "platforms": [ + "windows" + ] + }, + { + "id": "files.propertiesFile", + "description": "Windows UI: files.propertiesFile", + "platforms": [ + "windows" + ] + }, + { + "id": "files.propertiesPath", + "description": "Windows UI: files.propertiesPath", + "platforms": [ + "windows" + ] + }, + { + "id": "files.propertiesSize", + "description": "Windows UI: files.propertiesSize", + "platforms": [ + "windows" + ] + }, + { + "id": "files.propertiesType", + "description": "Windows UI: files.propertiesType", + "platforms": [ + "windows" + ] + }, + { + "id": "files.refresh", + "description": "Windows UI: files.refresh", + "platforms": [ + "windows" + ] + }, + { + "id": "files.removeFolderFromWorkspace", + "description": "Windows UI: files.removeFolderFromWorkspace", + "platforms": [ + "windows" + ] + }, + { + "id": "files.rename", + "description": "Windows UI: files.rename", + "platforms": [ + "windows" + ] + }, + { + "id": "files.renameItem", + "description": "Windows UI: files.renameItem", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "files.reveal", + "description": "Reveal a file using the platform file manager.", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Show in Finder", + "arguments": [] + } + }, + { + "id": "files.sizeUnknown", + "description": "Windows UI: files.sizeUnknown", + "platforms": [ + "windows" + ] + }, + { + "id": "files.symlinkTo", + "description": "Windows UI: files.symlinkTo", + "platforms": [ + "windows" + ], + "parameters": { + "target": "string" + } + }, + { + "id": "files.title", + "description": "Windows UI: files.title", + "platforms": [ + "windows" + ] + }, + { + "id": "files.unableToDetermineRootPath", + "description": "Windows UI: files.unableToDetermineRootPath", + "platforms": [ + "windows" + ] + }, + { + "id": "files.untitled", + "description": "Windows UI: files.untitled", + "platforms": [ + "windows" + ] + }, + { + "id": "files.untitledNumber", + "description": "Windows UI: files.untitledNumber", + "platforms": [ + "windows" + ], + "parameters": { + "number": "string" + } + }, + { + "id": "files.uploadFiles", + "description": "Windows UI: files.uploadFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "fontSelector.bundled", + "description": "Windows UI: fontSelector.bundled", + "platforms": [ + "windows" + ], + "parameters": { + "font": "string" + } + }, + { + "id": "fontSelector.custom", + "description": "Windows UI: fontSelector.custom", + "platforms": [ + "windows" + ], + "parameters": { + "font": "string" + } + }, + { + "id": "fontSelector.error", + "description": "Windows UI: fontSelector.error", + "platforms": [ + "windows" + ], + "parameters": { + "error": "string" + } + }, + { + "id": "fontSelector.loading", + "description": "Windows UI: fontSelector.loading", + "platforms": [ + "windows" + ] + }, + { + "id": "fontSelector.select", + "description": "Windows UI: fontSelector.select", + "platforms": [ + "windows" + ] + }, + { + "id": "footer.change", + "description": "Windows UI: footer.change", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "footer.changes", + "description": "Windows UI: footer.changes", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "footer.cursor", + "description": "Windows UI: footer.cursor", + "platforms": [ + "windows" + ] + }, + { + "id": "footer.encoding", + "description": "Windows UI: footer.encoding", + "platforms": [ + "windows" + ] + }, + { + "id": "footer.filePath", + "description": "Windows UI: footer.filePath", + "platforms": [ + "windows" + ] + }, + { + "id": "footer.gitBranch", + "description": "Windows UI: footer.gitBranch", + "platforms": [ + "windows" + ] + }, + { + "id": "footer.indent", + "description": "Windows UI: footer.indent", + "platforms": [ + "windows" + ] + }, + { + "id": "footer.memory", + "description": "Windows UI: footer.memory", + "platforms": [ + "windows" + ] + }, + { + "id": "footer.memoryUsage", + "description": "Windows UI: footer.memoryUsage", + "platforms": [ + "windows" + ], + "parameters": { + "total": "string", + "used": "string" + } + }, + { + "id": "footer.moveLeft", + "description": "Windows UI: footer.moveLeft", + "platforms": [ + "windows" + ] + }, + { + "id": "footer.moveRight", + "description": "Windows UI: footer.moveRight", + "platforms": [ + "windows" + ] + }, + { + "id": "footer.noChanges", + "description": "Windows UI: footer.noChanges", + "platforms": [ + "windows" + ] + }, + { + "id": "footer.readOnly", + "description": "Windows UI: footer.readOnly", + "platforms": [ + "windows" + ] + }, + { + "id": "footer.resetOrder", + "description": "Windows UI: footer.resetOrder", + "platforms": [ + "windows" + ] + }, + { + "id": "footer.spaces", + "description": "Windows UI: footer.spaces", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "footer.statusBar", + "description": "Windows UI: footer.statusBar", + "platforms": [ + "windows" + ] + }, + { + "id": "footer.writable", + "description": "Windows UI: footer.writable", + "platforms": [ + "windows" + ] + }, + { + "id": "git.abort", + "description": "Windows UI: git.abort", + "platforms": [ + "windows" + ] + }, + { + "id": "git.actionCompleted", + "description": "Windows UI: git.actionCompleted", + "platforms": [ + "windows" + ], + "parameters": { + "action": "string" + } + }, + { + "id": "git.actionFailed", + "description": "Windows UI: git.actionFailed", + "platforms": [ + "windows" + ], + "parameters": { + "action": "string" + } + }, + { + "id": "git.actions", + "description": "Windows UI: git.actions", + "platforms": [ + "windows" + ] + }, + { + "id": "git.add", + "description": "Windows UI: git.add", + "platforms": [ + "windows" + ] + }, + { + "id": "git.addPathsToIgnoreConfirm", + "description": "Windows UI: git.addPathsToIgnoreConfirm", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string", + "paths": "string", + "target": "string" + } + }, + { + "id": "git.addRemote", + "description": "Windows UI: git.addRemote", + "platforms": [ + "windows" + ] + }, + { + "id": "git.addRepository", + "description": "Windows UI: git.addRepository", + "platforms": [ + "windows" + ] + }, + { + "id": "git.addSelectionToGitignore", + "description": "Windows UI: git.addSelectionToGitignore", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.addSelectionToLocalExclude", + "description": "Windows UI: git.addSelectionToLocalExclude", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.addTag", + "description": "Windows UI: git.addTag", + "platforms": [ + "windows" + ] + }, + { + "id": "git.addToGitignore", + "description": "Windows UI: git.addToGitignore", + "platforms": [ + "windows" + ] + }, + { + "id": "git.addToLocalExclude", + "description": "Windows UI: git.addToLocalExclude", + "platforms": [ + "windows" + ] + }, + { + "id": "git.addToVcs", + "description": "Windows UI: git.addToVcs", + "platforms": [ + "windows" + ] + }, + { + "id": "git.added", + "description": "Windows UI: git.added", + "platforms": [ + "windows" + ] + }, + { + "id": "git.adding", + "description": "Windows UI: git.adding", + "platforms": [ + "windows" + ] + }, + { + "id": "git.addingRepository", + "description": "Windows UI: git.addingRepository", + "platforms": [ + "windows" + ] + }, + { + "id": "git.aheadOfRemote", + "description": "Windows UI: git.aheadOfRemote", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.aiCommitEmptyMessage", + "description": "Windows UI: git.aiCommitEmptyMessage", + "platforms": [ + "windows" + ] + }, + { + "id": "git.allChangesDiscarded", + "description": "Windows UI: git.allChangesDiscarded", + "platforms": [ + "windows" + ] + }, + { + "id": "git.allFilesStaged", + "description": "Windows UI: git.allFilesStaged", + "platforms": [ + "windows" + ] + }, + { + "id": "git.allFilesUnstaged", + "description": "Windows UI: git.allFilesUnstaged", + "platforms": [ + "windows" + ] + }, + { + "id": "git.amend", + "description": "Windows UI: git.amend", + "platforms": [ + "windows" + ] + }, + { + "id": "git.amendLastCommit", + "description": "Windows UI: git.amendLastCommit", + "platforms": [ + "windows" + ] + }, + { + "id": "git.applyStash", + "description": "Windows UI: git.applyStash", + "platforms": [ + "windows" + ] + }, + { + "id": "git.behindRemote", + "description": "Windows UI: git.behindRemote", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.binaryDiffDescription", + "description": "Windows UI: git.binaryDiffDescription", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "git.binaryFileChanged", + "description": "Windows UI: git.binaryFileChanged", + "platforms": [ + "windows" + ] + }, + { + "id": "git.branch", + "description": "Windows UI: git.branch", + "platforms": [ + "windows" + ] + }, + { + "id": "git.branchActions", + "description": "Windows UI: git.branchActions", + "platforms": [ + "windows" + ], + "parameters": { + "branch": "string" + } + }, + { + "id": "git.branchCount", + "description": "Windows UI: git.branchCount", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.branches", + "description": "Windows UI: git.branches", + "platforms": [ + "windows" + ] + }, + { + "id": "git.branchesCount", + "description": "Windows UI: git.branchesCount", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.browse", + "description": "Windows UI: git.browse", + "platforms": [ + "windows" + ] + }, + { + "id": "git.changeAdded", + "description": "Windows UI: git.changeAdded", + "platforms": [ + "windows" + ] + }, + { + "id": "git.changeDeleted", + "description": "Windows UI: git.changeDeleted", + "platforms": [ + "windows" + ] + }, + { + "id": "git.changeModified", + "description": "Windows UI: git.changeModified", + "platforms": [ + "windows" + ] + }, + { + "id": "git.changedFilesCount", + "description": "Windows UI: git.changedFilesCount", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string", + "plural": "string" + }, + "optionalParameters": { + "plural": "The existing locale does not use this language-specific suffix." + } + }, + { + "id": "git.changelists.active", + "description": "Windows UI: git.changelists.active", + "platforms": [ + "windows" + ] + }, + { + "id": "git.changelists.activeSuffix", + "description": "Windows UI: git.changelists.activeSuffix", + "platforms": [ + "windows" + ] + }, + { + "id": "git.changelists.default", + "description": "Windows UI: git.changelists.default", + "platforms": [ + "windows" + ] + }, + { + "id": "git.changelists.delete", + "description": "Windows UI: git.changelists.delete", + "platforms": [ + "windows" + ] + }, + { + "id": "git.changelists.deleteDescription", + "description": "Windows UI: git.changelists.deleteDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "git.changelists.invalidName", + "description": "Windows UI: git.changelists.invalidName", + "platforms": [ + "windows" + ] + }, + { + "id": "git.changelists.move", + "description": "Windows UI: git.changelists.move", + "platforms": [ + "windows" + ] + }, + { + "id": "git.changelists.name", + "description": "Windows UI: git.changelists.name", + "platforms": [ + "windows" + ] + }, + { + "id": "git.changelists.new", + "description": "Windows UI: git.changelists.new", + "platforms": [ + "windows" + ] + }, + { + "id": "git.changelists.otherStaged", + "description": "Windows UI: git.changelists.otherStaged", + "platforms": [ + "windows" + ] + }, + { + "id": "git.changelists.rename", + "description": "Windows UI: git.changelists.rename", + "platforms": [ + "windows" + ] + }, + { + "id": "git.changelists.scope", + "description": "Windows UI: git.changelists.scope", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "git.changelists.stageActive", + "description": "Windows UI: git.changelists.stageActive", + "platforms": [ + "windows" + ] + }, + { + "id": "git.changelists.unavailable", + "description": "Windows UI: git.changelists.unavailable", + "platforms": [ + "windows" + ] + }, + { + "id": "git.changelists.unstageActive", + "description": "Windows UI: git.changelists.unstageActive", + "platforms": [ + "windows" + ] + }, + { + "id": "git.changesCommitted", + "description": "Windows UI: git.changesCommitted", + "platforms": [ + "windows" + ] + }, + { + "id": "git.changesFetched", + "description": "Windows UI: git.changesFetched", + "platforms": [ + "windows" + ] + }, + { + "id": "git.changesNode", + "description": "Windows UI: git.changesNode", + "platforms": [ + "windows" + ] + }, + { + "id": "git.changesPushed", + "description": "Windows UI: git.changesPushed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.checkout", + "description": "Windows UI: git.checkout", + "platforms": [ + "windows" + ] + }, + { + "id": "git.checkoutTag", + "description": "Windows UI: git.checkoutTag", + "platforms": [ + "windows" + ] + }, + { + "id": "git.checkoutTagConfirm", + "description": "Windows UI: git.checkoutTagConfirm", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "git.cherryPickCommit", + "description": "Windows UI: git.cherryPickCommit", + "platforms": [ + "windows" + ] + }, + { + "id": "git.cherryPickCommitConfirm", + "description": "Windows UI: git.cherryPickCommitConfirm", + "platforms": [ + "windows" + ], + "parameters": { + "hash": "string", + "message": "string" + } + }, + { + "id": "git.cherryPickInProgress", + "description": "Windows UI: git.cherryPickInProgress", + "platforms": [ + "windows" + ] + }, + { + "id": "git.chooseCommitAction", + "description": "Windows UI: git.chooseCommitAction", + "platforms": [ + "windows" + ] + }, + { + "id": "git.chooseDiffSource", + "description": "Windows UI: git.chooseDiffSource", + "platforms": [ + "windows" + ] + }, + { + "id": "git.chooseRemoteAction", + "description": "Windows UI: git.chooseRemoteAction", + "platforms": [ + "windows" + ] + }, + { + "id": "git.clearAdded", + "description": "Windows UI: git.clearAdded", + "platforms": [ + "windows" + ] + }, + { + "id": "git.close", + "description": "Windows UI: git.close", + "platforms": [ + "windows" + ] + }, + { + "id": "git.closeDiffView", + "description": "Windows UI: git.closeDiffView", + "platforms": [ + "windows" + ] + }, + { + "id": "git.collapseAll", + "description": "Windows UI: git.collapseAll", + "platforms": [ + "windows" + ] + }, + { + "id": "git.collapseAllFiles", + "description": "Windows UI: git.collapseAllFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "git.commit", + "description": "Windows UI: git.commit", + "platforms": [ + "windows" + ] + }, + { + "id": "git.commitAndPush", + "description": "Windows UI: git.commitAndPush", + "platforms": [ + "windows" + ] + }, + { + "id": "git.commitAndPushEllipsis", + "description": "Windows UI: git.commitAndPushEllipsis", + "platforms": [ + "windows" + ] + }, + { + "id": "git.commitChanges", + "description": "Windows UI: git.commitChanges", + "platforms": [ + "windows" + ] + }, + { + "id": "git.commitChangesFailed", + "description": "Windows UI: git.commitChangesFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.commitCount", + "description": "Windows UI: git.commitCount", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.commitIndexReconcileFailed", + "description": "Windows UI: git.commitIndexReconcileFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.commitMessageFormat", + "description": "Windows UI: git.commitMessageFormat", + "platforms": [ + "windows" + ] + }, + { + "id": "git.commitMessagePlaceholder", + "description": "Windows UI: git.commitMessagePlaceholder", + "platforms": [ + "windows" + ] + }, + { + "id": "git.commitMessageTitleAndBody", + "description": "Windows UI: git.commitMessageTitleAndBody", + "platforms": [ + "windows" + ] + }, + { + "id": "git.commitMessageTitleOnly", + "description": "Windows UI: git.commitMessageTitleOnly", + "platforms": [ + "windows" + ] + }, + { + "id": "git.commitSha", + "description": "Windows UI: git.commitSha", + "platforms": [ + "windows" + ] + }, + { + "id": "git.commitShaOrRef", + "description": "Windows UI: git.commitShaOrRef", + "platforms": [ + "windows" + ] + }, + { + "id": "git.commitsCount", + "description": "Windows UI: git.commitsCount", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.committing", + "description": "Windows UI: git.committing", + "platforms": [ + "windows" + ] + }, + { + "id": "git.compareBranchPlaceholder", + "description": "Windows UI: git.compareBranchPlaceholder", + "platforms": [ + "windows" + ] + }, + { + "id": "git.compareWithBranch", + "description": "Windows UI: git.compareWithBranch", + "platforms": [ + "windows" + ], + "parameters": { + "branch": "string" + } + }, + { + "id": "git.compareWithHead", + "description": "Windows UI: git.compareWithHead", + "platforms": [ + "windows" + ] + }, + { + "id": "git.compareWithPreviousTag", + "description": "Windows UI: git.compareWithPreviousTag", + "platforms": [ + "windows" + ] + }, + { + "id": "git.conflictMarkersRemain", + "description": "Windows UI: git.conflictMarkersRemain", + "platforms": [ + "windows" + ], + "parameters": { + "paths": "string" + } + }, + { + "id": "git.conflictedFile", + "description": "Windows UI: git.conflictedFile", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.conflictedFiles", + "description": "Windows UI: git.conflictedFiles", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.conflictsResolved", + "description": "Windows UI: git.conflictsResolved", + "platforms": [ + "windows" + ] + }, + { + "id": "git.console.arguments.files", + "description": "Windows UI: git.console.arguments.files", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.console.arguments.references", + "description": "Windows UI: git.console.arguments.references", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.console.automaticQueries", + "description": "Windows UI: git.console.automaticQueries", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.console.cancel", + "description": "Windows UI: git.console.cancel", + "platforms": [ + "windows" + ] + }, + { + "id": "git.console.clear", + "description": "Windows UI: git.console.clear", + "platforms": [ + "windows" + ] + }, + { + "id": "git.console.closeSearch", + "description": "Windows UI: git.console.closeSearch", + "platforms": [ + "windows" + ] + }, + { + "id": "git.console.completed", + "description": "Windows UI: git.console.completed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.console.copy", + "description": "Windows UI: git.console.copy", + "platforms": [ + "windows" + ] + }, + { + "id": "git.console.copyCommand", + "description": "Windows UI: git.console.copyCommand", + "platforms": [ + "windows" + ] + }, + { + "id": "git.console.copyOutput", + "description": "Windows UI: git.console.copyOutput", + "platforms": [ + "windows" + ] + }, + { + "id": "git.console.copyPath", + "description": "Windows UI: git.console.copyPath", + "platforms": [ + "windows" + ] + }, + { + "id": "git.console.details", + "description": "Windows UI: git.console.details", + "platforms": [ + "windows" + ] + }, + { + "id": "git.console.empty", + "description": "Windows UI: git.console.empty", + "platforms": [ + "windows" + ] + }, + { + "id": "git.console.exit", + "description": "Windows UI: git.console.exit", + "platforms": [ + "windows" + ] + }, + { + "id": "git.console.find", + "description": "Windows UI: git.console.find", + "platforms": [ + "windows" + ] + }, + { + "id": "git.console.fold.branches", + "description": "Windows UI: git.console.fold.branches", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.console.fold.commits", + "description": "Windows UI: git.console.fold.commits", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.console.fold.files", + "description": "Windows UI: git.console.fold.files", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.console.fold.lines", + "description": "Windows UI: git.console.fold.lines", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.console.fold.repeat", + "description": "Windows UI: git.console.fold.repeat", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.console.fold.tags", + "description": "Windows UI: git.console.fold.tags", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.console.historyTruncated", + "description": "Windows UI: git.console.historyTruncated", + "platforms": [ + "windows" + ] + }, + { + "id": "git.console.log", + "description": "Windows UI: git.console.log", + "platforms": [ + "windows" + ] + }, + { + "id": "git.console.matches", + "description": "Windows UI: git.console.matches", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.console.nextMatch", + "description": "Windows UI: git.console.nextMatch", + "platforms": [ + "windows" + ] + }, + { + "id": "git.console.notice.completedWithoutOutput", + "description": "Windows UI: git.console.notice.completedWithoutOutput", + "platforms": [ + "windows" + ] + }, + { + "id": "git.console.notice.fetchUnchanged", + "description": "Windows UI: git.console.notice.fetchUnchanged", + "platforms": [ + "windows" + ] + }, + { + "id": "git.console.notice.waitingForOutput", + "description": "Windows UI: git.console.notice.waitingForOutput", + "platforms": [ + "windows" + ] + }, + { + "id": "git.console.open", + "description": "Windows UI: git.console.open", + "platforms": [ + "windows" + ] + }, + { + "id": "git.console.options", + "description": "Windows UI: git.console.options", + "platforms": [ + "windows" + ] + }, + { + "id": "git.console.presentationUnavailable", + "description": "Windows UI: git.console.presentationUnavailable", + "platforms": [ + "windows" + ] + }, + { + "id": "git.console.previousMatch", + "description": "Windows UI: git.console.previousMatch", + "platforms": [ + "windows" + ] + }, + { + "id": "git.console.queued", + "description": "Windows UI: git.console.queued", + "platforms": [ + "windows" + ] + }, + { + "id": "git.console.referenceChanges", + "description": "Windows UI: git.console.referenceChanges", + "platforms": [ + "windows" + ], + "parameters": { + "added": "string", + "deleted": "string", + "updated": "string" + } + }, + { + "id": "git.console.running", + "description": "Windows UI: git.console.running", + "platforms": [ + "windows" + ] + }, + { + "id": "git.console.scrollToEnd", + "description": "Windows UI: git.console.scrollToEnd", + "platforms": [ + "windows" + ] + }, + { + "id": "git.console.searchLimit", + "description": "Windows UI: git.console.searchLimit", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.console.title", + "description": "Windows UI: git.console.title", + "platforms": [ + "windows" + ] + }, + { + "id": "git.console.truncated", + "description": "Windows UI: git.console.truncated", + "platforms": [ + "windows" + ] + }, + { + "id": "git.console.unconfirmed", + "description": "Windows UI: git.console.unconfirmed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.console.wrap", + "description": "Windows UI: git.console.wrap", + "platforms": [ + "windows" + ] + }, + { + "id": "git.contextMenu.add", + "description": "Windows UI: git.contextMenu.add", + "platforms": [ + "windows" + ] + }, + { + "id": "git.contextMenu.commitFile", + "description": "Windows UI: git.contextMenu.commitFile", + "platforms": [ + "windows" + ] + }, + { + "id": "git.contextMenu.diffFailed", + "description": "Windows UI: git.contextMenu.diffFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.contextMenu.fetch", + "description": "Windows UI: git.contextMenu.fetch", + "platforms": [ + "windows" + ] + }, + { + "id": "git.contextMenu.newBranch", + "description": "Windows UI: git.contextMenu.newBranch", + "platforms": [ + "windows" + ] + }, + { + "id": "git.contextMenu.noChanges", + "description": "Windows UI: git.contextMenu.noChanges", + "platforms": [ + "windows" + ] + }, + { + "id": "git.contextMenu.openGitLog", + "description": "Windows UI: git.contextMenu.openGitLog", + "platforms": [ + "windows" + ] + }, + { + "id": "git.contextMenu.pull", + "description": "Windows UI: git.contextMenu.pull", + "platforms": [ + "windows" + ] + }, + { + "id": "git.contextMenu.push", + "description": "Windows UI: git.contextMenu.push", + "platforms": [ + "windows" + ] + }, + { + "id": "git.contextMenu.showDiff", + "description": "Windows UI: git.contextMenu.showDiff", + "platforms": [ + "windows" + ] + }, + { + "id": "git.contextMenu.stageFailed", + "description": "Windows UI: git.contextMenu.stageFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.contextMenu.submenu", + "description": "Windows UI: git.contextMenu.submenu", + "platforms": [ + "windows" + ] + }, + { + "id": "git.continueCherryPick", + "description": "Windows UI: git.continueCherryPick", + "platforms": [ + "windows" + ] + }, + { + "id": "git.continueMerge", + "description": "Windows UI: git.continueMerge", + "platforms": [ + "windows" + ] + }, + { + "id": "git.continueRebase", + "description": "Windows UI: git.continueRebase", + "platforms": [ + "windows" + ] + }, + { + "id": "git.continueRevert", + "description": "Windows UI: git.continueRevert", + "platforms": [ + "windows" + ] + }, + { + "id": "git.copiedLabel", + "description": "Windows UI: git.copiedLabel", + "platforms": [ + "windows" + ], + "parameters": { + "label": "string" + } + }, + { + "id": "git.copyCommitSha", + "description": "Windows UI: git.copyCommitSha", + "platforms": [ + "windows" + ] + }, + { + "id": "git.copyLabelFailed", + "description": "Windows UI: git.copyLabelFailed", + "platforms": [ + "windows" + ], + "parameters": { + "label": "string" + } + }, + { + "id": "git.copyTagName", + "description": "Windows UI: git.copyTagName", + "platforms": [ + "windows" + ] + }, + { + "id": "git.create", + "description": "Windows UI: git.create", + "platforms": [ + "windows" + ] + }, + { + "id": "git.createNewBranch", + "description": "Windows UI: git.createNewBranch", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "git.createStash", + "description": "Windows UI: git.createStash", + "platforms": [ + "windows" + ] + }, + { + "id": "git.createTag", + "description": "Windows UI: git.createTag", + "platforms": [ + "windows" + ] + }, + { + "id": "git.createWorktree", + "description": "Windows UI: git.createWorktree", + "platforms": [ + "windows" + ], + "parameters": { + "path": "string" + } + }, + { + "id": "git.creating", + "description": "Windows UI: git.creating", + "platforms": [ + "windows" + ] + }, + { + "id": "git.current", + "description": "Windows UI: git.current", + "platforms": [ + "windows" + ] + }, + { + "id": "git.delete", + "description": "Windows UI: git.delete", + "platforms": [ + "windows" + ] + }, + { + "id": "git.deleteBranch", + "description": "Windows UI: git.deleteBranch", + "platforms": [ + "windows" + ] + }, + { + "id": "git.deleteBranchConfirm", + "description": "Windows UI: git.deleteBranchConfirm", + "platforms": [ + "windows" + ], + "parameters": { + "branch": "string" + } + }, + { + "id": "git.deleteCommit", + "description": "Windows UI: git.deleteCommit", + "platforms": [ + "windows" + ] + }, + { + "id": "git.deleteCommitConfirm", + "description": "Windows UI: git.deleteCommitConfirm", + "platforms": [ + "windows" + ], + "parameters": { + "hash": "string", + "message": "string" + } + }, + { + "id": "git.deleteFileConfirm", + "description": "Windows UI: git.deleteFileConfirm", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "git.deleteFilesConfirm", + "description": "Windows UI: git.deleteFilesConfirm", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.deleteFilesFailed", + "description": "Windows UI: git.deleteFilesFailed", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.deleteRemoteTag", + "description": "Windows UI: git.deleteRemoteTag", + "platforms": [ + "windows" + ] + }, + { + "id": "git.deleteRemoteTagAction", + "description": "Windows UI: git.deleteRemoteTagAction", + "platforms": [ + "windows" + ] + }, + { + "id": "git.deleteRemoteTagConfirm", + "description": "Windows UI: git.deleteRemoteTagConfirm", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string", + "remote": "string" + } + }, + { + "id": "git.deleteTag", + "description": "Windows UI: git.deleteTag", + "platforms": [ + "windows" + ] + }, + { + "id": "git.deleteTagFrom", + "description": "Windows UI: git.deleteTagFrom", + "platforms": [ + "windows" + ], + "parameters": { + "remote": "string" + } + }, + { + "id": "git.deleteTagFromRemote", + "description": "Windows UI: git.deleteTagFromRemote", + "platforms": [ + "windows" + ] + }, + { + "id": "git.detachedHead", + "description": "Windows UI: git.detachedHead", + "platforms": [ + "windows" + ] + }, + { + "id": "git.detectingRepositories", + "description": "Windows UI: git.detectingRepositories", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.actions", + "description": "Windows UI: git.diff.actions", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.addedVersion", + "description": "Windows UI: git.diff.addedVersion", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.afterVersion", + "description": "Windows UI: git.diff.afterVersion", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.beforeVersion", + "description": "Windows UI: git.diff.beforeVersion", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.changedFiles", + "description": "Windows UI: git.diff.changedFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.commitPreviewEmptyTitle", + "description": "Windows UI: git.diff.commitPreviewEmptyTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.commitPreviewTitle", + "description": "Windows UI: git.diff.commitPreviewTitle", + "platforms": [ + "windows" + ], + "parameters": { + "file": "string" + } + }, + { + "id": "git.diff.commitRangeTitle", + "description": "Windows UI: git.diff.commitRangeTitle", + "platforms": [ + "windows" + ], + "parameters": { + "base": "string", + "target": "string" + } + }, + { + "id": "git.diff.compareRefsFailed", + "description": "Windows UI: git.diff.compareRefsFailed", + "platforms": [ + "windows" + ], + "parameters": { + "base": "string", + "error": "string", + "target": "string" + } + }, + { + "id": "git.diff.currentVersion", + "description": "Windows UI: git.diff.currentVersion", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.deletedVersion", + "description": "Windows UI: git.diff.deletedVersion", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.differences", + "description": "Windows UI: git.diff.differences", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.diff.emptyChanges", + "description": "Windows UI: git.diff.emptyChanges", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.emptyRevision", + "description": "Windows UI: git.diff.emptyRevision", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.emptyStagedChanges", + "description": "Windows UI: git.diff.emptyStagedChanges", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.emptyUnstagedChanges", + "description": "Windows UI: git.diff.emptyUnstagedChanges", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.excludeBlock", + "description": "Windows UI: git.diff.excludeBlock", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.filePosition", + "description": "Windows UI: git.diff.filePosition", + "platforms": [ + "windows" + ], + "parameters": { + "current": "string", + "total": "string" + } + }, + { + "id": "git.diff.getCommitDiffFailed", + "description": "Windows UI: git.diff.getCommitDiffFailed", + "platforms": [ + "windows" + ], + "parameters": { + "commit": "string", + "error": "string" + } + }, + { + "id": "git.diff.getFileDiffFailed", + "description": "Windows UI: git.diff.getFileDiffFailed", + "platforms": [ + "windows" + ], + "parameters": { + "error": "string", + "file": "string" + } + }, + { + "id": "git.diff.getSelectedCommitDiffFailed", + "description": "Windows UI: git.diff.getSelectedCommitDiffFailed", + "platforms": [ + "windows" + ], + "parameters": { + "error": "string" + } + }, + { + "id": "git.diff.getStashDiffFailed", + "description": "Windows UI: git.diff.getStashDiffFailed", + "platforms": [ + "windows" + ], + "parameters": { + "error": "string", + "stash": "string" + } + }, + { + "id": "git.diff.getWorkingTreeDiffFailed", + "description": "Windows UI: git.diff.getWorkingTreeDiffFailed", + "platforms": [ + "windows" + ], + "parameters": { + "error": "string" + } + }, + { + "id": "git.diff.hideChangedFiles", + "description": "Windows UI: git.diff.hideChangedFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.hideWhitespace", + "description": "Windows UI: git.diff.hideWhitespace", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.highlightWords", + "description": "Windows UI: git.diff.highlightWords", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.hunkStale", + "description": "Windows UI: git.diff.hunkStale", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.includeBlock", + "description": "Windows UI: git.diff.includeBlock", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.includedCount", + "description": "Windows UI: git.diff.includedCount", + "platforms": [ + "windows" + ], + "parameters": { + "included": "string" + } + }, + { + "id": "git.diff.indexVersion", + "description": "Windows UI: git.diff.indexVersion", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.invalidExpression", + "description": "Windows UI: git.diff.invalidExpression", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.lineEndingChanges", + "description": "Windows UI: git.diff.lineEndingChanges", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.matchCount", + "description": "Windows UI: git.diff.matchCount", + "platforms": [ + "windows" + ], + "parameters": { + "current": "string", + "total": "string" + } + }, + { + "id": "git.diff.nextDifference", + "description": "Windows UI: git.diff.nextDifference", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.nextFile", + "description": "Windows UI: git.diff.nextFile", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.noChangedFileSelected", + "description": "Windows UI: git.diff.noChangedFileSelected", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.noChangesBetween", + "description": "Windows UI: git.diff.noChangesBetween", + "platforms": [ + "windows" + ], + "parameters": { + "base": "string", + "target": "string" + } + }, + { + "id": "git.diff.noChangesInCommit", + "description": "Windows UI: git.diff.noChangesInCommit", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.noChangesInCommitForFile", + "description": "Windows UI: git.diff.noChangesInCommitForFile", + "platforms": [ + "windows" + ], + "parameters": { + "file": "string" + } + }, + { + "id": "git.diff.noChangesInSelectedCommits", + "description": "Windows UI: git.diff.noChangesInSelectedCommits", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.noChangesInStash", + "description": "Windows UI: git.diff.noChangesInStash", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.noDifferences", + "description": "Windows UI: git.diff.noDifferences", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.noResults", + "description": "Windows UI: git.diff.noResults", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.oneDifference", + "description": "Windows UI: git.diff.oneDifference", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.openFileFailed", + "description": "Windows UI: git.diff.openFileFailed", + "platforms": [ + "windows" + ], + "parameters": { + "error": "string", + "file": "string" + } + }, + { + "id": "git.diff.previousDifference", + "description": "Windows UI: git.diff.previousDifference", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.previousFile", + "description": "Windows UI: git.diff.previousFile", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.previousRevision", + "description": "Windows UI: git.diff.previousRevision", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.refreshFailed", + "description": "Windows UI: git.diff.refreshFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.repositoryPreviewEmptyTitle", + "description": "Windows UI: git.diff.repositoryPreviewEmptyTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.repositoryPreviewTitle", + "description": "Windows UI: git.diff.repositoryPreviewTitle", + "platforms": [ + "windows" + ], + "parameters": { + "file": "string" + } + }, + { + "id": "git.diff.rollbackHunk", + "description": "Windows UI: git.diff.rollbackHunk", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.rollbackHunkConfirm", + "description": "Windows UI: git.diff.rollbackHunkConfirm", + "platforms": [ + "windows" + ], + "parameters": { + "path": "string" + } + }, + { + "id": "git.diff.search", + "description": "Windows UI: git.diff.search", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.searchChanges", + "description": "Windows UI: git.diff.searchChanges", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.selectedCommitsTitle", + "description": "Windows UI: git.diff.selectedCommitsTitle", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.diff.selectionChanged", + "description": "Windows UI: git.diff.selectionChanged", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.showChangedFiles", + "description": "Windows UI: git.diff.showChangedFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.showWhitespace", + "description": "Windows UI: git.diff.showWhitespace", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.sideBySideViewer", + "description": "Windows UI: git.diff.sideBySideViewer", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.split", + "description": "Windows UI: git.diff.split", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.splitUnavailableForAddedDeleted", + "description": "Windows UI: git.diff.splitUnavailableForAddedDeleted", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.stage", + "description": "Windows UI: git.diff.stage", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.stageFailed", + "description": "Windows UI: git.diff.stageFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.stagedChanges", + "description": "Windows UI: git.diff.stagedChanges", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.statusAdded", + "description": "Windows UI: git.diff.statusAdded", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.statusDeleted", + "description": "Windows UI: git.diff.statusDeleted", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.statusModified", + "description": "Windows UI: git.diff.statusModified", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.statusRenamed", + "description": "Windows UI: git.diff.statusRenamed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.synchronizedHorizontalScroll", + "description": "Windows UI: git.diff.synchronizedHorizontalScroll", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.title", + "description": "Windows UI: git.diff.title", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.uncommitted", + "description": "Windows UI: git.diff.uncommitted", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.unified", + "description": "Windows UI: git.diff.unified", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.unifiedViewer", + "description": "Windows UI: git.diff.unifiedViewer", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.unstage", + "description": "Windows UI: git.diff.unstage", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.unstagedChanges", + "description": "Windows UI: git.diff.unstagedChanges", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.viewOnGitHub", + "description": "Windows UI: git.diff.viewOnGitHub", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diff.worktree", + "description": "Windows UI: git.diff.worktree", + "platforms": [ + "windows" + ] + }, + { + "id": "git.diffFileCount", + "description": "Windows UI: git.diffFileCount", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string", + "plural": "string" + }, + "optionalParameters": { + "plural": "The existing locale does not use this language-specific suffix." + } + }, + { + "id": "git.discard", + "description": "Windows UI: git.discard", + "platforms": [ + "windows" + ] + }, + { + "id": "git.discardAllChanges", + "description": "Windows UI: git.discardAllChanges", + "platforms": [ + "windows" + ] + }, + { + "id": "git.discardAllChangesConfirm", + "description": "Windows UI: git.discardAllChangesConfirm", + "platforms": [ + "windows" + ] + }, + { + "id": "git.discardChanges", + "description": "Windows UI: git.discardChanges", + "platforms": [ + "windows" + ] + }, + { + "id": "git.discardChangesConfirm", + "description": "Windows UI: git.discardChangesConfirm", + "platforms": [ + "windows" + ] + }, + { + "id": "git.discardChangesFailed", + "description": "Windows UI: git.discardChangesFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.discardFileChanges", + "description": "Windows UI: git.discardFileChanges", + "platforms": [ + "windows" + ] + }, + { + "id": "git.discardFileConfirm", + "description": "Windows UI: git.discardFileConfirm", + "platforms": [ + "windows" + ], + "parameters": { + "path": "string" + } + }, + { + "id": "git.discardSelectedChanges", + "description": "Windows UI: git.discardSelectedChanges", + "platforms": [ + "windows" + ] + }, + { + "id": "git.dropStash", + "description": "Windows UI: git.dropStash", + "platforms": [ + "windows" + ] + }, + { + "id": "git.editCommitMessage", + "description": "Windows UI: git.editCommitMessage", + "platforms": [ + "windows" + ] + }, + { + "id": "git.editCommitMessagePrompt", + "description": "Windows UI: git.editCommitMessagePrompt", + "platforms": [ + "windows" + ], + "parameters": { + "hash": "string" + } + }, + { + "id": "git.enterCommitMessage", + "description": "Windows UI: git.enterCommitMessage", + "platforms": [ + "windows" + ] + }, + { + "id": "git.excludeFileFromCommit", + "description": "Windows UI: git.excludeFileFromCommit", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "git.excludeFolderFromCommit", + "description": "Windows UI: git.excludeFolderFromCommit", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "git.execution.advanced", + "description": "Windows UI: git.execution.advanced", + "platforms": [ + "windows" + ] + }, + { + "id": "git.execution.authAgain", + "description": "Windows UI: git.execution.authAgain", + "platforms": [ + "windows" + ] + }, + { + "id": "git.execution.authExpired", + "description": "Windows UI: git.execution.authExpired", + "platforms": [ + "windows" + ] + }, + { + "id": "git.execution.authScope", + "description": "Windows UI: git.execution.authScope", + "platforms": [ + "windows" + ] + }, + { + "id": "git.execution.authentication", + "description": "Windows UI: git.execution.authentication", + "platforms": [ + "windows" + ] + }, + { + "id": "git.execution.chooseValue", + "description": "Windows UI: git.execution.chooseValue", + "platforms": [ + "windows" + ] + }, + { + "id": "git.execution.clear", + "description": "Windows UI: git.execution.clear", + "platforms": [ + "windows" + ] + }, + { + "id": "git.execution.continue", + "description": "Windows UI: git.execution.continue", + "platforms": [ + "windows" + ] + }, + { + "id": "git.execution.credentials", + "description": "Windows UI: git.execution.credentials", + "platforms": [ + "windows" + ] + }, + { + "id": "git.execution.deleted", + "description": "Windows UI: git.execution.deleted", + "platforms": [ + "windows" + ] + }, + { + "id": "git.execution.effectiveFetch", + "description": "Windows UI: git.execution.effectiveFetch", + "platforms": [ + "windows" + ] + }, + { + "id": "git.execution.executable", + "description": "Windows UI: git.execution.executable", + "platforms": [ + "windows" + ] + }, + { + "id": "git.execution.helper", + "description": "Windows UI: git.execution.helper", + "platforms": [ + "windows" + ] + }, + { + "id": "git.execution.key", + "description": "Windows UI: git.execution.key", + "platforms": [ + "windows" + ] + }, + { + "id": "git.execution.overridden", + "description": "Windows UI: git.execution.overridden", + "platforms": [ + "windows" + ] + }, + { + "id": "git.execution.pathDefault", + "description": "Windows UI: git.execution.pathDefault", + "platforms": [ + "windows" + ] + }, + { + "id": "git.execution.phase.compressing", + "description": "Windows UI: git.execution.phase.compressing", + "platforms": [ + "windows" + ] + }, + { + "id": "git.execution.phase.counting", + "description": "Windows UI: git.execution.phase.counting", + "platforms": [ + "windows" + ] + }, + { + "id": "git.execution.phase.enumerating", + "description": "Windows UI: git.execution.phase.enumerating", + "platforms": [ + "windows" + ] + }, + { + "id": "git.execution.phase.receiving", + "description": "Windows UI: git.execution.phase.receiving", + "platforms": [ + "windows" + ] + }, + { + "id": "git.execution.phase.resolving", + "description": "Windows UI: git.execution.phase.resolving", + "platforms": [ + "windows" + ] + }, + { + "id": "git.execution.phase.updating", + "description": "Windows UI: git.execution.phase.updating", + "platforms": [ + "windows" + ] + }, + { + "id": "git.execution.phase.writing", + "description": "Windows UI: git.execution.phase.writing", + "platforms": [ + "windows" + ] + }, + { + "id": "git.execution.precedence", + "description": "Windows UI: git.execution.precedence", + "platforms": [ + "windows" + ] + }, + { + "id": "git.execution.preferences", + "description": "Windows UI: git.execution.preferences", + "platforms": [ + "windows" + ] + }, + { + "id": "git.execution.progress", + "description": "Windows UI: git.execution.progress", + "platforms": [ + "windows" + ] + }, + { + "id": "git.execution.referencesTruncated", + "description": "Windows UI: git.execution.referencesTruncated", + "platforms": [ + "windows" + ], + "parameters": { + "deleted": "string", + "updated": "string" + } + }, + { + "id": "git.execution.referencesUnavailable", + "description": "Windows UI: git.execution.referencesUnavailable", + "platforms": [ + "windows" + ] + }, + { + "id": "git.execution.reload", + "description": "Windows UI: git.execution.reload", + "platforms": [ + "windows" + ] + }, + { + "id": "git.execution.remoteFailed", + "description": "Windows UI: git.execution.remoteFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.execution.remoteSucceeded", + "description": "Windows UI: git.execution.remoteSucceeded", + "platforms": [ + "windows" + ] + }, + { + "id": "git.execution.response", + "description": "Windows UI: git.execution.response", + "platforms": [ + "windows" + ] + }, + { + "id": "git.execution.retry", + "description": "Windows UI: git.execution.retry", + "platforms": [ + "windows" + ] + }, + { + "id": "git.execution.save", + "description": "Windows UI: git.execution.save", + "platforms": [ + "windows" + ] + }, + { + "id": "git.execution.saved", + "description": "Windows UI: git.execution.saved", + "platforms": [ + "windows" + ] + }, + { + "id": "git.execution.sources", + "description": "Windows UI: git.execution.sources", + "platforms": [ + "windows" + ] + }, + { + "id": "git.execution.temporary", + "description": "Windows UI: git.execution.temporary", + "platforms": [ + "windows" + ] + }, + { + "id": "git.execution.title", + "description": "Windows UI: git.execution.title", + "platforms": [ + "windows" + ] + }, + { + "id": "git.execution.updated", + "description": "Windows UI: git.execution.updated", + "platforms": [ + "windows" + ] + }, + { + "id": "git.execution.value", + "description": "Windows UI: git.execution.value", + "platforms": [ + "windows" + ] + }, + { + "id": "git.expandAll", + "description": "Windows UI: git.expandAll", + "platforms": [ + "windows" + ] + }, + { + "id": "git.expandAllFiles", + "description": "Windows UI: git.expandAllFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "git.failedToInitializeRepository", + "description": "Windows UI: git.failedToInitializeRepository", + "platforms": [ + "windows" + ] + }, + { + "id": "git.failedToSelectRepository", + "description": "Windows UI: git.failedToSelectRepository", + "platforms": [ + "windows" + ] + }, + { + "id": "git.fetch", + "description": "Windows UI: git.fetch", + "platforms": [ + "windows" + ] + }, + { + "id": "git.fetch.allRemotes", + "description": "Windows UI: git.fetch.allRemotes", + "platforms": [ + "windows" + ] + }, + { + "id": "git.fetch.credentials", + "description": "Windows UI: git.fetch.credentials", + "platforms": [ + "windows" + ] + }, + { + "id": "git.fetch.defaults", + "description": "Windows UI: git.fetch.defaults", + "platforms": [ + "windows" + ] + }, + { + "id": "git.fetch.oneTime", + "description": "Windows UI: git.fetch.oneTime", + "platforms": [ + "windows" + ] + }, + { + "id": "git.fetch.options", + "description": "Windows UI: git.fetch.options", + "platforms": [ + "windows" + ] + }, + { + "id": "git.fetch.prune", + "description": "Windows UI: git.fetch.prune", + "platforms": [ + "windows" + ] + }, + { + "id": "git.fetch.remoteName", + "description": "Windows UI: git.fetch.remoteName", + "platforms": [ + "windows" + ] + }, + { + "id": "git.fetch.scope", + "description": "Windows UI: git.fetch.scope", + "platforms": [ + "windows" + ] + }, + { + "id": "git.fetch.submodules", + "description": "Windows UI: git.fetch.submodules", + "platforms": [ + "windows" + ] + }, + { + "id": "git.fetch.submodules.inherit", + "description": "Windows UI: git.fetch.submodules.inherit", + "platforms": [ + "windows" + ] + }, + { + "id": "git.fetch.submodules.no", + "description": "Windows UI: git.fetch.submodules.no", + "platforms": [ + "windows" + ] + }, + { + "id": "git.fetch.submodules.onDemand", + "description": "Windows UI: git.fetch.submodules.onDemand", + "platforms": [ + "windows" + ] + }, + { + "id": "git.fetch.submodules.yes", + "description": "Windows UI: git.fetch.submodules.yes", + "platforms": [ + "windows" + ] + }, + { + "id": "git.fetch.tags", + "description": "Windows UI: git.fetch.tags", + "platforms": [ + "windows" + ] + }, + { + "id": "git.fetch.tags.all", + "description": "Windows UI: git.fetch.tags.all", + "platforms": [ + "windows" + ] + }, + { + "id": "git.fetch.tags.inherit", + "description": "Windows UI: git.fetch.tags.inherit", + "platforms": [ + "windows" + ] + }, + { + "id": "git.fetch.tags.none", + "description": "Windows UI: git.fetch.tags.none", + "platforms": [ + "windows" + ] + }, + { + "id": "git.fetch.tags.prune", + "description": "Windows UI: git.fetch.tags.prune", + "platforms": [ + "windows" + ] + }, + { + "id": "git.fetchFailed", + "description": "Windows UI: git.fetchFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.fetchedChanges", + "description": "Windows UI: git.fetchedChanges", + "platforms": [ + "windows" + ] + }, + { + "id": "git.fetching", + "description": "Windows UI: git.fetching", + "platforms": [ + "windows" + ] + }, + { + "id": "git.fetchingChanges", + "description": "Windows UI: git.fetchingChanges", + "platforms": [ + "windows" + ] + }, + { + "id": "git.fileStaged", + "description": "Windows UI: git.fileStaged", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.filesStaged", + "description": "Windows UI: git.filesStaged", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.filterRepositories", + "description": "Windows UI: git.filterRepositories", + "platforms": [ + "windows" + ] + }, + { + "id": "git.finishOperationBeforeCommit", + "description": "Windows UI: git.finishOperationBeforeCommit", + "platforms": [ + "windows" + ] + }, + { + "id": "git.flatList", + "description": "Windows UI: git.flatList", + "platforms": [ + "windows" + ] + }, + { + "id": "git.generateCommitMessageFailed", + "description": "Windows UI: git.generateCommitMessageFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.generateCommitMessageWithAI", + "description": "Windows UI: git.generateCommitMessageWithAI", + "platforms": [ + "windows" + ] + }, + { + "id": "git.gitignoreFile", + "description": "Windows UI: git.gitignoreFile", + "platforms": [ + "windows" + ] + }, + { + "id": "git.groupByDirectory", + "description": "Windows UI: git.groupByDirectory", + "platforms": [ + "windows" + ] + }, + { + "id": "git.gutter.added", + "description": "Windows UI: git.gutter.added", + "platforms": [ + "windows" + ] + }, + { + "id": "git.gutter.close", + "description": "Windows UI: git.gutter.close", + "platforms": [ + "windows" + ] + }, + { + "id": "git.gutter.deleted", + "description": "Windows UI: git.gutter.deleted", + "platforms": [ + "windows" + ] + }, + { + "id": "git.gutter.modified", + "description": "Windows UI: git.gutter.modified", + "platforms": [ + "windows" + ] + }, + { + "id": "git.gutter.next", + "description": "Windows UI: git.gutter.next", + "platforms": [ + "windows" + ] + }, + { + "id": "git.gutter.openDiff", + "description": "Windows UI: git.gutter.openDiff", + "platforms": [ + "windows" + ] + }, + { + "id": "git.gutter.previous", + "description": "Windows UI: git.gutter.previous", + "platforms": [ + "windows" + ] + }, + { + "id": "git.gutter.stage", + "description": "Windows UI: git.gutter.stage", + "platforms": [ + "windows" + ] + }, + { + "id": "git.gutter.stageUnavailable", + "description": "Windows UI: git.gutter.stageUnavailable", + "platforms": [ + "windows" + ] + }, + { + "id": "git.gutter.title", + "description": "Windows UI: git.gutter.title", + "platforms": [ + "windows" + ], + "parameters": { + "index": "string", + "total": "string" + } + }, + { + "id": "git.historyLoadFailed", + "description": "Windows UI: git.historyLoadFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.historyLoadRepositoryFailed", + "description": "Windows UI: git.historyLoadRepositoryFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.historyMutationFailed", + "description": "Windows UI: git.historyMutationFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.historyReview.affected", + "description": "Windows UI: git.historyReview.affected", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.historyReview.body", + "description": "Windows UI: git.historyReview.body", + "platforms": [ + "windows" + ] + }, + { + "id": "git.historyReview.completed", + "description": "Windows UI: git.historyReview.completed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.historyReview.contiguousRequired", + "description": "Windows UI: git.historyReview.contiguousRequired", + "platforms": [ + "windows" + ] + }, + { + "id": "git.historyReview.copied", + "description": "Windows UI: git.historyReview.copied", + "platforms": [ + "windows" + ] + }, + { + "id": "git.historyReview.copyFailed", + "description": "Windows UI: git.historyReview.copyFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.historyReview.copyRecovery", + "description": "Windows UI: git.historyReview.copyRecovery", + "platforms": [ + "windows" + ] + }, + { + "id": "git.historyReview.createRecovery", + "description": "Windows UI: git.historyReview.createRecovery", + "platforms": [ + "windows" + ] + }, + { + "id": "git.historyReview.deleteCommit", + "description": "Windows UI: git.historyReview.deleteCommit", + "platforms": [ + "windows" + ] + }, + { + "id": "git.historyReview.description", + "description": "Windows UI: git.historyReview.description", + "platforms": [ + "windows" + ] + }, + { + "id": "git.historyReview.editCommitMessage", + "description": "Windows UI: git.historyReview.editCommitMessage", + "platforms": [ + "windows" + ] + }, + { + "id": "git.historyReview.failed", + "description": "Windows UI: git.historyReview.failed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.historyReview.headRequired", + "description": "Windows UI: git.historyReview.headRequired", + "platforms": [ + "windows" + ] + }, + { + "id": "git.historyReview.loading", + "description": "Windows UI: git.historyReview.loading", + "platforms": [ + "windows" + ] + }, + { + "id": "git.historyReview.outcomeUnknown", + "description": "Windows UI: git.historyReview.outcomeUnknown", + "platforms": [ + "windows" + ] + }, + { + "id": "git.historyReview.recovery", + "description": "Windows UI: git.historyReview.recovery", + "platforms": [ + "windows" + ] + }, + { + "id": "git.historyReview.recoveryCreated", + "description": "Windows UI: git.historyReview.recoveryCreated", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "git.historyReview.recoveryFailed", + "description": "Windows UI: git.historyReview.recoveryFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.historyReview.recoveryName", + "description": "Windows UI: git.historyReview.recoveryName", + "platforms": [ + "windows" + ] + }, + { + "id": "git.historyReview.refreshFailed", + "description": "Windows UI: git.historyReview.refreshFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.historyReview.reload", + "description": "Windows UI: git.historyReview.reload", + "platforms": [ + "windows" + ] + }, + { + "id": "git.historyReview.reviewAgain", + "description": "Windows UI: git.historyReview.reviewAgain", + "platforms": [ + "windows" + ] + }, + { + "id": "git.historyReview.running", + "description": "Windows UI: git.historyReview.running", + "platforms": [ + "windows" + ] + }, + { + "id": "git.historyReview.squashCommits", + "description": "Windows UI: git.historyReview.squashCommits", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.historyReview.title", + "description": "Windows UI: git.historyReview.title", + "platforms": [ + "windows" + ] + }, + { + "id": "git.historyReview.titleCharacters", + "description": "Windows UI: git.historyReview.titleCharacters", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.historyReview.totalCharacters", + "description": "Windows UI: git.historyReview.totalCharacters", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.historyReview.undoCommit", + "description": "Windows UI: git.historyReview.undoCommit", + "platforms": [ + "windows" + ] + }, + { + "id": "git.includeFileInCommit", + "description": "Windows UI: git.includeFileInCommit", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "git.includeFolderInCommit", + "description": "Windows UI: git.includeFolderInCommit", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "git.indexedFilesOfTotal", + "description": "Windows UI: git.indexedFilesOfTotal", + "platforms": [ + "windows" + ], + "parameters": { + "total": "string", + "visible": "string" + } + }, + { + "id": "git.indexing", + "description": "Windows UI: git.indexing", + "platforms": [ + "windows" + ] + }, + { + "id": "git.indexingChanges", + "description": "Windows UI: git.indexingChanges", + "platforms": [ + "windows" + ] + }, + { + "id": "git.indexingWithCount", + "description": "Windows UI: git.indexingWithCount", + "platforms": [ + "windows" + ], + "parameters": { + "label": "string", + "processed": "string", + "total": "string" + } + }, + { + "id": "git.initialize", + "description": "Windows UI: git.initialize", + "platforms": [ + "windows" + ] + }, + { + "id": "git.initializeGitRepository", + "description": "Windows UI: git.initializeGitRepository", + "platforms": [ + "windows" + ] + }, + { + "id": "git.initializeRepository", + "description": "Windows UI: git.initializeRepository", + "platforms": [ + "windows" + ] + }, + { + "id": "git.initializing", + "description": "Windows UI: git.initializing", + "platforms": [ + "windows" + ] + }, + { + "id": "git.jumpToSource", + "description": "Windows UI: git.jumpToSource", + "platforms": [ + "windows" + ] + }, + { + "id": "git.lineHistory.empty", + "description": "Windows UI: git.lineHistory.empty", + "platforms": [ + "windows" + ] + }, + { + "id": "git.lineHistory.failed", + "description": "Windows UI: git.lineHistory.failed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.lineHistory.loading", + "description": "Windows UI: git.lineHistory.loading", + "platforms": [ + "windows" + ] + }, + { + "id": "git.lineHistory.menuLabel", + "description": "Windows UI: git.lineHistory.menuLabel", + "platforms": [ + "windows" + ] + }, + { + "id": "git.lineHistory.mergeBadge", + "description": "Windows UI: git.lineHistory.mergeBadge", + "platforms": [ + "windows" + ] + }, + { + "id": "git.lineHistory.retry", + "description": "Windows UI: git.lineHistory.retry", + "platforms": [ + "windows" + ] + }, + { + "id": "git.lineHistory.titleLine", + "description": "Windows UI: git.lineHistory.titleLine", + "platforms": [ + "windows" + ], + "parameters": { + "line": "string" + } + }, + { + "id": "git.lineHistory.titleRange", + "description": "Windows UI: git.lineHistory.titleRange", + "platforms": [ + "windows" + ], + "parameters": { + "end": "string", + "start": "string" + } + }, + { + "id": "git.lineHistory.truncated", + "description": "Windows UI: git.lineHistory.truncated", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.lineHistory.unmapped.new", + "description": "Windows UI: git.lineHistory.unmapped.new", + "platforms": [ + "windows" + ], + "parameters": { + "line": "string" + } + }, + { + "id": "git.lineHistory.unmapped.unreliable", + "description": "Windows UI: git.lineHistory.unmapped.unreliable", + "platforms": [ + "windows" + ], + "parameters": { + "line": "string" + } + }, + { + "id": "git.lineHistory.viewCommit", + "description": "Windows UI: git.lineHistory.viewCommit", + "platforms": [ + "windows" + ] + }, + { + "id": "git.loadingCommits", + "description": "Windows UI: git.loadingCommits", + "platforms": [ + "windows" + ] + }, + { + "id": "git.loadingDiff", + "description": "Windows UI: git.loadingDiff", + "platforms": [ + "windows" + ] + }, + { + "id": "git.loadingGitStatus", + "description": "Windows UI: git.loadingGitStatus", + "platforms": [ + "windows" + ] + }, + { + "id": "git.loadingRemotes", + "description": "Windows UI: git.loadingRemotes", + "platforms": [ + "windows" + ] + }, + { + "id": "git.loadingTags", + "description": "Windows UI: git.loadingTags", + "platforms": [ + "windows" + ] + }, + { + "id": "git.loadingWorktrees", + "description": "Windows UI: git.loadingWorktrees", + "platforms": [ + "windows" + ] + }, + { + "id": "git.localAhead", + "description": "Windows UI: git.localAhead", + "platforms": [ + "windows" + ] + }, + { + "id": "git.localExcludeFile", + "description": "Windows UI: git.localExcludeFile", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.action.checkout", + "description": "Windows UI: git.log.action.checkout", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.action.checkoutAndRebase", + "description": "Windows UI: git.log.action.checkoutAndRebase", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.action.compareWithCurrent", + "description": "Windows UI: git.log.action.compareWithCurrent", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.action.createBranch", + "description": "Windows UI: git.log.action.createBranch", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.action.diffWithWorkingTree", + "description": "Windows UI: git.log.action.diffWithWorkingTree", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.action.mergeIntoCurrent", + "description": "Windows UI: git.log.action.mergeIntoCurrent", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.action.pullMergeIntoCurrent", + "description": "Windows UI: git.log.action.pullMergeIntoCurrent", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.action.pullRebaseIntoCurrent", + "description": "Windows UI: git.log.action.pullRebaseIntoCurrent", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.action.rebaseCurrentOnto", + "description": "Windows UI: git.log.action.rebaseCurrentOnto", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.actionSucceeded", + "description": "Windows UI: git.log.actionSucceeded", + "platforms": [ + "windows" + ], + "parameters": { + "action": "string", + "reference": "string" + } + }, + { + "id": "git.log.all", + "description": "Windows UI: git.log.all", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.autoStashCleanupFailed", + "description": "Windows UI: git.log.autoStashCleanupFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.autoStashDeferredByConflicts", + "description": "Windows UI: git.log.autoStashDeferredByConflicts", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.log.autoStashRestoreConflicts", + "description": "Windows UI: git.log.autoStashRestoreConflicts", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string", + "stash": "string" + } + }, + { + "id": "git.log.branchCreateFailed", + "description": "Windows UI: git.log.branchCreateFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.branchCreated", + "description": "Windows UI: git.log.branchCreated", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "git.log.branchDeletedTitle", + "description": "Windows UI: git.log.branchDeletedTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.branchFilter", + "description": "Windows UI: git.log.branchFilter", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "git.log.branchNameAlreadyExists", + "description": "Windows UI: git.log.branchNameAlreadyExists", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "git.log.branchNameClashesWithBranchPrefix", + "description": "Windows UI: git.log.branchNameClashesWithBranchPrefix", + "platforms": [ + "windows" + ], + "parameters": { + "branch": "string", + "name": "string" + } + }, + { + "id": "git.log.branchNameClashesWithDirectory", + "description": "Windows UI: git.log.branchNameClashesWithDirectory", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "git.log.branchNameClashesWithRemote", + "description": "Windows UI: git.log.branchNameClashesWithRemote", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "git.log.branchNameEmpty", + "description": "Windows UI: git.log.branchNameEmpty", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.branchNameHead", + "description": "Windows UI: git.log.branchNameHead", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.branchNameLabel", + "description": "Windows UI: git.log.branchNameLabel", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.branchNamePrompt", + "description": "Windows UI: git.log.branchNamePrompt", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.branchRestored", + "description": "Windows UI: git.log.branchRestored", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "git.log.branchRestoredUpstreamFailed", + "description": "Windows UI: git.log.branchRestoredUpstreamFailed", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string", + "upstream": "string" + } + }, + { + "id": "git.log.checkoutAndRebase", + "description": "Windows UI: git.log.checkoutAndRebase", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.checkoutAndRebaseConfirm", + "description": "Windows UI: git.log.checkoutAndRebaseConfirm", + "platforms": [ + "windows" + ], + "parameters": { + "branch": "string", + "current": "string" + } + }, + { + "id": "git.log.checkoutAndRebaseOnto", + "description": "Windows UI: git.log.checkoutAndRebaseOnto", + "platforms": [ + "windows" + ], + "parameters": { + "branch": "string" + } + }, + { + "id": "git.log.checkoutAndUpdate", + "description": "Windows UI: git.log.checkoutAndUpdate", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.checkoutFailed", + "description": "Windows UI: git.log.checkoutFailed", + "platforms": [ + "windows" + ], + "parameters": { + "branch": "string" + } + }, + { + "id": "git.log.chooseWorktreeDirectory", + "description": "Windows UI: git.log.chooseWorktreeDirectory", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.clearBranchFilter", + "description": "Windows UI: git.log.clearBranchFilter", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.clearFilter", + "description": "Windows UI: git.log.clearFilter", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.collapse", + "description": "Windows UI: git.log.collapse", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "git.log.collapseLongEdges", + "description": "Windows UI: git.log.collapseLongEdges", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.commitDetails", + "description": "Windows UI: git.log.commitDetails", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.commitFiles", + "description": "Windows UI: git.log.commitFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.commitHash", + "description": "Windows UI: git.log.commitHash", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.commitMessage", + "description": "Windows UI: git.log.commitMessage", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.compareWithCurrent", + "description": "Windows UI: git.log.compareWithCurrent", + "platforms": [ + "windows" + ], + "parameters": { + "branch": "string" + } + }, + { + "id": "git.log.compareWithHead", + "description": "Windows UI: git.log.compareWithHead", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.confirmAction", + "description": "Windows UI: git.log.confirmAction", + "platforms": [ + "windows" + ], + "parameters": { + "action": "string", + "reference": "string" + } + }, + { + "id": "git.log.copied", + "description": "Windows UI: git.log.copied", + "platforms": [ + "windows" + ], + "parameters": { + "label": "string" + } + }, + { + "id": "git.log.copyBranchName", + "description": "Windows UI: git.log.copyBranchName", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.copyCommitHash", + "description": "Windows UI: git.log.copyCommitHash", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.copyCommitMessage", + "description": "Windows UI: git.log.copyCommitMessage", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.copyFailed", + "description": "Windows UI: git.log.copyFailed", + "platforms": [ + "windows" + ], + "parameters": { + "label": "string" + } + }, + { + "id": "git.log.copyHash", + "description": "Windows UI: git.log.copyHash", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.copyShortHash", + "description": "Windows UI: git.log.copyShortHash", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.couldNotCheckout", + "description": "Windows UI: git.log.couldNotCheckout", + "platforms": [ + "windows" + ], + "parameters": { + "reference": "string" + } + }, + { + "id": "git.log.createBranchFromTitle", + "description": "Windows UI: git.log.createBranchFromTitle", + "platforms": [ + "windows" + ], + "parameters": { + "reference": "string" + } + }, + { + "id": "git.log.createTagOnCommit", + "description": "Windows UI: git.log.createTagOnCommit", + "platforms": [ + "windows" + ], + "parameters": { + "commit": "string" + } + }, + { + "id": "git.log.dateJustNow", + "description": "Windows UI: git.log.dateJustNow", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.dateMinuteAgoOne", + "description": "Windows UI: git.log.dateMinuteAgoOne", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.log.dateMinutesAgo", + "description": "Windows UI: git.log.dateMinutesAgo", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.log.dateToday", + "description": "Windows UI: git.log.dateToday", + "platforms": [ + "windows" + ], + "parameters": { + "time": "string" + } + }, + { + "id": "git.log.dateYesterday", + "description": "Windows UI: git.log.dateYesterday", + "platforms": [ + "windows" + ], + "parameters": { + "time": "string" + } + }, + { + "id": "git.log.deleteRemoteBranch", + "description": "Windows UI: git.log.deleteRemoteBranch", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.deleteRemoteBranchConfirm", + "description": "Windows UI: git.log.deleteRemoteBranchConfirm", + "platforms": [ + "windows" + ], + "parameters": { + "branch": "string" + } + }, + { + "id": "git.log.expand", + "description": "Windows UI: git.log.expand", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "git.log.fileCountOne", + "description": "Windows UI: git.log.fileCountOne", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.log.filesCount", + "description": "Windows UI: git.log.filesCount", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.log.filter", + "description": "Windows UI: git.log.filter", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.filterAuthor", + "description": "Windows UI: git.log.filterAuthor", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.filterBranch", + "description": "Windows UI: git.log.filterBranch", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.filterField", + "description": "Windows UI: git.log.filterField", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.filterPlaceholder", + "description": "Windows UI: git.log.filterPlaceholder", + "platforms": [ + "windows" + ], + "parameters": { + "field": "string" + } + }, + { + "id": "git.log.filterText", + "description": "Windows UI: git.log.filterText", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.goToChild", + "description": "Windows UI: git.log.goToChild", + "platforms": [ + "windows" + ], + "parameters": { + "hash": "string" + } + }, + { + "id": "git.log.goToParent", + "description": "Windows UI: git.log.goToParent", + "platforms": [ + "windows" + ], + "parameters": { + "hash": "string" + } + }, + { + "id": "git.log.headCurrentBranch", + "description": "Windows UI: git.log.headCurrentBranch", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.hide", + "description": "Windows UI: git.log.hide", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.hideDecorations", + "description": "Windows UI: git.log.hideDecorations", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.loadMore", + "description": "Windows UI: git.log.loadMore", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.loading", + "description": "Windows UI: git.log.loading", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.loadingChangedFiles", + "description": "Windows UI: git.log.loadingChangedFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.loadingCommits", + "description": "Windows UI: git.log.loadingCommits", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.loadingShort", + "description": "Windows UI: git.log.loadingShort", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.local", + "description": "Windows UI: git.log.local", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.logLabel", + "description": "Windows UI: git.log.logLabel", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "git.log.manageRemotes", + "description": "Windows UI: git.log.manageRemotes", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.mergeIntoCurrent", + "description": "Windows UI: git.log.mergeIntoCurrent", + "platforms": [ + "windows" + ], + "parameters": { + "branch": "string", + "current": "string" + } + }, + { + "id": "git.log.newBranchFrom", + "description": "Windows UI: git.log.newBranchFrom", + "platforms": [ + "windows" + ], + "parameters": { + "branch": "string" + } + }, + { + "id": "git.log.newBranchPrompt", + "description": "Windows UI: git.log.newBranchPrompt", + "platforms": [ + "windows" + ], + "parameters": { + "branch": "string" + } + }, + { + "id": "git.log.newTag", + "description": "Windows UI: git.log.newTag", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.newWorktreeBranchPrompt", + "description": "Windows UI: git.log.newWorktreeBranchPrompt", + "platforms": [ + "windows" + ], + "parameters": { + "branch": "string" + } + }, + { + "id": "git.log.newWorktreeFrom", + "description": "Windows UI: git.log.newWorktreeFrom", + "platforms": [ + "windows" + ], + "parameters": { + "branch": "string" + } + }, + { + "id": "git.log.noChangedFiles", + "description": "Windows UI: git.log.noChangedFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.noCommits", + "description": "Windows UI: git.log.noCommits", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.noMatch", + "description": "Windows UI: git.log.noMatch", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.noRemoteBranches", + "description": "Windows UI: git.log.noRemoteBranches", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.noRepository", + "description": "Windows UI: git.log.noRepository", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.noWorkingTreeDifferences", + "description": "Windows UI: git.log.noWorkingTreeDifferences", + "platforms": [ + "windows" + ], + "parameters": { + "branch": "string" + } + }, + { + "id": "git.log.none", + "description": "Windows UI: git.log.none", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.openCommitDiff", + "description": "Windows UI: git.log.openCommitDiff", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.openDiff", + "description": "Windows UI: git.log.openDiff", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.openDiffHint", + "description": "Windows UI: git.log.openDiffHint", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.openFileDiff", + "description": "Windows UI: git.log.openFileDiff", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.openWorkspace", + "description": "Windows UI: git.log.openWorkspace", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.operationBlocked", + "description": "Windows UI: git.log.operationBlocked", + "platforms": [ + "windows" + ], + "parameters": { + "paths": "string" + } + }, + { + "id": "git.log.operationConflicts", + "description": "Windows UI: git.log.operationConflicts", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.log.operationStopped", + "description": "Windows UI: git.log.operationStopped", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.pullMergeIntoCurrent", + "description": "Windows UI: git.log.pullMergeIntoCurrent", + "platforms": [ + "windows" + ], + "parameters": { + "branch": "string" + } + }, + { + "id": "git.log.pullRebaseIntoCurrent", + "description": "Windows UI: git.log.pullRebaseIntoCurrent", + "platforms": [ + "windows" + ], + "parameters": { + "branch": "string" + } + }, + { + "id": "git.log.pullRemoteIntoCurrentConfirm", + "description": "Windows UI: git.log.pullRemoteIntoCurrentConfirm", + "platforms": [ + "windows" + ], + "parameters": { + "branch": "string", + "current": "string", + "strategy": "string" + } + }, + { + "id": "git.log.rebaseCurrentOnto", + "description": "Windows UI: git.log.rebaseCurrentOnto", + "platforms": [ + "windows" + ], + "parameters": { + "branch": "string", + "current": "string" + } + }, + { + "id": "git.log.references", + "description": "Windows UI: git.log.references", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.referencesLoadFailed", + "description": "Windows UI: git.log.referencesLoadFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.refresh", + "description": "Windows UI: git.log.refresh", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.remote", + "description": "Windows UI: git.log.remote", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.renameBranch", + "description": "Windows UI: git.log.renameBranch", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.renameBranchPrompt", + "description": "Windows UI: git.log.renameBranchPrompt", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.repository", + "description": "Windows UI: git.log.repository", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.resizeAuthorColumn", + "description": "Windows UI: git.log.resizeAuthorColumn", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.resizeDateColumn", + "description": "Windows UI: git.log.resizeDateColumn", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.restoreBranch", + "description": "Windows UI: git.log.restoreBranch", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.restoreUpstreamTracking", + "description": "Windows UI: git.log.restoreUpstreamTracking", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.retry", + "description": "Windows UI: git.log.retry", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.selectCommit", + "description": "Windows UI: git.log.selectCommit", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.settings", + "description": "Windows UI: git.log.settings", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.showAll", + "description": "Windows UI: git.log.showAll", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.showDecorations", + "description": "Windows UI: git.log.showDecorations", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.showDiffWithWorkingTree", + "description": "Windows UI: git.log.showDiffWithWorkingTree", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.showLongEdges", + "description": "Windows UI: git.log.showLongEdges", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.stopTrackingBranch", + "description": "Windows UI: git.log.stopTrackingBranch", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.switchToRepositoryFirst", + "description": "Windows UI: git.log.switchToRepositoryFirst", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.tagCreateFailed", + "description": "Windows UI: git.log.tagCreateFailed", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "git.log.tagNamePrompt", + "description": "Windows UI: git.log.tagNamePrompt", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.tags", + "description": "Windows UI: git.log.tags", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.toolbar.compareWithCurrent", + "description": "Windows UI: git.log.toolbar.compareWithCurrent", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.toolbar.deleteBranch", + "description": "Windows UI: git.log.toolbar.deleteBranch", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.toolbar.fetch", + "description": "Windows UI: git.log.toolbar.fetch", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.toolbar.goToHead", + "description": "Windows UI: git.log.toolbar.goToHead", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.toolbar.hideWorktrees", + "description": "Windows UI: git.log.toolbar.hideWorktrees", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.toolbar.mark", + "description": "Windows UI: git.log.toolbar.mark", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.toolbar.moreActions", + "description": "Windows UI: git.log.toolbar.moreActions", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.toolbar.newBranch", + "description": "Windows UI: git.log.toolbar.newBranch", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.toolbar.showAllBranches", + "description": "Windows UI: git.log.toolbar.showAllBranches", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.toolbar.showMyBranches", + "description": "Windows UI: git.log.toolbar.showMyBranches", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.toolbar.showWorktrees", + "description": "Windows UI: git.log.toolbar.showWorktrees", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.toolbar.unmark", + "description": "Windows UI: git.log.toolbar.unmark", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.toolbar.updateSelected", + "description": "Windows UI: git.log.toolbar.updateSelected", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.trackingBranch", + "description": "Windows UI: git.log.trackingBranch", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.tryAgain", + "description": "Windows UI: git.log.tryAgain", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.unableToLoad", + "description": "Windows UI: git.log.unableToLoad", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.unableToLoadFiles", + "description": "Windows UI: git.log.unableToLoadFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.unableToRefresh", + "description": "Windows UI: git.log.unableToRefresh", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.updateBranch", + "description": "Windows UI: git.log.updateBranch", + "platforms": [ + "windows" + ] + }, + { + "id": "git.log.workingTreeComparisonFailed", + "description": "Windows UI: git.log.workingTreeComparisonFailed", + "platforms": [ + "windows" + ], + "parameters": { + "branch": "string", + "error": "string" + } + }, + { + "id": "git.log.workingTreeComparisonTitle", + "description": "Windows UI: git.log.workingTreeComparisonTitle", + "platforms": [ + "windows" + ], + "parameters": { + "branch": "string" + } + }, + { + "id": "git.manageBranches", + "description": "Windows UI: git.manageBranches", + "platforms": [ + "windows" + ] + }, + { + "id": "git.manageRemotes", + "description": "Windows UI: git.manageRemotes", + "platforms": [ + "windows" + ] + }, + { + "id": "git.manageTags", + "description": "Windows UI: git.manageTags", + "platforms": [ + "windows" + ] + }, + { + "id": "git.merge", + "description": "Windows UI: git.merge", + "platforms": [ + "windows" + ] + }, + { + "id": "git.mergeBranchTitle", + "description": "Windows UI: git.mergeBranchTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "git.mergeConfirm", + "description": "Windows UI: git.mergeConfirm", + "platforms": [ + "windows" + ], + "parameters": { + "branch": "string", + "current": "string" + } + }, + { + "id": "git.mergeFailed", + "description": "Windows UI: git.mergeFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.mergeInProgress", + "description": "Windows UI: git.mergeInProgress", + "platforms": [ + "windows" + ] + }, + { + "id": "git.mergeIntoCurrent", + "description": "Windows UI: git.mergeIntoCurrent", + "platforms": [ + "windows" + ] + }, + { + "id": "git.mergeSuccess", + "description": "Windows UI: git.mergeSuccess", + "platforms": [ + "windows" + ], + "parameters": { + "branch": "string" + } + }, + { + "id": "git.message", + "description": "Windows UI: git.message", + "platforms": [ + "windows" + ] + }, + { + "id": "git.moreFiles", + "description": "Windows UI: git.moreFiles", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.name", + "description": "Windows UI: git.name", + "platforms": [ + "windows" + ] + }, + { + "id": "git.newBranch", + "description": "Windows UI: git.newBranch", + "platforms": [ + "windows" + ] + }, + { + "id": "git.newImage", + "description": "Windows UI: git.newImage", + "platforms": [ + "windows" + ] + }, + { + "id": "git.newImageAlt", + "description": "Windows UI: git.newImageAlt", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "git.newWorktree", + "description": "Windows UI: git.newWorktree", + "platforms": [ + "windows" + ] + }, + { + "id": "git.noBranch", + "description": "Windows UI: git.noBranch", + "platforms": [ + "windows" + ] + }, + { + "id": "git.noBranchesFound", + "description": "Windows UI: git.noBranchesFound", + "platforms": [ + "windows" + ] + }, + { + "id": "git.noChangesInFile", + "description": "Windows UI: git.noChangesInFile", + "platforms": [ + "windows" + ] + }, + { + "id": "git.noCommits", + "description": "Windows UI: git.noCommits", + "platforms": [ + "windows" + ] + }, + { + "id": "git.noDiffData", + "description": "Windows UI: git.noDiffData", + "platforms": [ + "windows" + ] + }, + { + "id": "git.noFilesStaged", + "description": "Windows UI: git.noFilesStaged", + "platforms": [ + "windows" + ] + }, + { + "id": "git.noImage", + "description": "Windows UI: git.noImage", + "platforms": [ + "windows" + ] + }, + { + "id": "git.noMatchingBranches", + "description": "Windows UI: git.noMatchingBranches", + "platforms": [ + "windows" + ] + }, + { + "id": "git.noMatchingCommits", + "description": "Windows UI: git.noMatchingCommits", + "platforms": [ + "windows" + ] + }, + { + "id": "git.noMatchingRemotes", + "description": "Windows UI: git.noMatchingRemotes", + "platforms": [ + "windows" + ] + }, + { + "id": "git.noMatchingRepositories", + "description": "Windows UI: git.noMatchingRepositories", + "platforms": [ + "windows" + ] + }, + { + "id": "git.noMatchingStashes", + "description": "Windows UI: git.noMatchingStashes", + "platforms": [ + "windows" + ] + }, + { + "id": "git.noMatchingTags", + "description": "Windows UI: git.noMatchingTags", + "platforms": [ + "windows" + ] + }, + { + "id": "git.noMatchingWorktrees", + "description": "Windows UI: git.noMatchingWorktrees", + "platforms": [ + "windows" + ] + }, + { + "id": "git.noOtherBranches", + "description": "Windows UI: git.noOtherBranches", + "platforms": [ + "windows" + ] + }, + { + "id": "git.noRemote", + "description": "Windows UI: git.noRemote", + "platforms": [ + "windows" + ] + }, + { + "id": "git.noRemotesConfigured", + "description": "Windows UI: git.noRemotesConfigured", + "platforms": [ + "windows" + ] + }, + { + "id": "git.noRepositoriesFound", + "description": "Windows UI: git.noRepositoriesFound", + "platforms": [ + "windows" + ] + }, + { + "id": "git.noRepositoryOpen", + "description": "Windows UI: git.noRepositoryOpen", + "platforms": [ + "windows" + ] + }, + { + "id": "git.noRepositorySelected", + "description": "Windows UI: git.noRepositorySelected", + "platforms": [ + "windows" + ] + }, + { + "id": "git.noStashes", + "description": "Windows UI: git.noStashes", + "platforms": [ + "windows" + ] + }, + { + "id": "git.noTagsFound", + "description": "Windows UI: git.noTagsFound", + "platforms": [ + "windows" + ] + }, + { + "id": "git.noWorktreesFound", + "description": "Windows UI: git.noWorktreesFound", + "platforms": [ + "windows" + ] + }, + { + "id": "git.notAGitRepository", + "description": "Windows UI: git.notAGitRepository", + "platforms": [ + "windows" + ] + }, + { + "id": "git.oldImageAlt", + "description": "Windows UI: git.oldImageAlt", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "git.openFile", + "description": "Windows UI: git.openFile", + "platforms": [ + "windows" + ] + }, + { + "id": "git.openFolderBeforeInit", + "description": "Windows UI: git.openFolderBeforeInit", + "platforms": [ + "windows" + ] + }, + { + "id": "git.openFolderBeforeInitializing", + "description": "Windows UI: git.openFolderBeforeInitializing", + "platforms": [ + "windows" + ] + }, + { + "id": "git.operationError", + "description": "Windows UI: git.operationError", + "platforms": [ + "windows" + ], + "parameters": { + "error": "string" + } + }, + { + "id": "git.operationFailed", + "description": "Windows UI: git.operationFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.operationStep", + "description": "Windows UI: git.operationStep", + "platforms": [ + "windows" + ], + "parameters": { + "step": "string", + "total": "string" + } + }, + { + "id": "git.optionalAnnotation", + "description": "Windows UI: git.optionalAnnotation", + "platforms": [ + "windows" + ] + }, + { + "id": "git.patch.applicable", + "description": "Windows UI: git.patch.applicable", + "platforms": [ + "windows" + ] + }, + { + "id": "git.patch.applied", + "description": "Windows UI: git.patch.applied", + "platforms": [ + "windows" + ] + }, + { + "id": "git.patch.apply", + "description": "Windows UI: git.patch.apply", + "platforms": [ + "windows" + ] + }, + { + "id": "git.patch.applyDescription", + "description": "Windows UI: git.patch.applyDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "git.patch.applying", + "description": "Windows UI: git.patch.applying", + "platforms": [ + "windows" + ] + }, + { + "id": "git.patch.base", + "description": "Windows UI: git.patch.base", + "platforms": [ + "windows" + ] + }, + { + "id": "git.patch.check", + "description": "Windows UI: git.patch.check", + "platforms": [ + "windows" + ] + }, + { + "id": "git.patch.checking", + "description": "Windows UI: git.patch.checking", + "platforms": [ + "windows" + ] + }, + { + "id": "git.patch.confirmApply", + "description": "Windows UI: git.patch.confirmApply", + "platforms": [ + "windows" + ] + }, + { + "id": "git.patch.create", + "description": "Windows UI: git.patch.create", + "platforms": [ + "windows" + ] + }, + { + "id": "git.patch.destination", + "description": "Windows UI: git.patch.destination", + "platforms": [ + "windows" + ] + }, + { + "id": "git.patch.displayTruncated", + "description": "Windows UI: git.patch.displayTruncated", + "platforms": [ + "windows" + ] + }, + { + "id": "git.patch.distinctCommits", + "description": "Windows UI: git.patch.distinctCommits", + "platforms": [ + "windows" + ] + }, + { + "id": "git.patch.editPatch", + "description": "Windows UI: git.patch.editPatch", + "platforms": [ + "windows" + ] + }, + { + "id": "git.patch.exportDescription", + "description": "Windows UI: git.patch.exportDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "git.patch.fileTooLarge", + "description": "Windows UI: git.patch.fileTooLarge", + "platforms": [ + "windows" + ] + }, + { + "id": "git.patch.invalidUtf8", + "description": "Windows UI: git.patch.invalidUtf8", + "platforms": [ + "windows" + ] + }, + { + "id": "git.patch.loading", + "description": "Windows UI: git.patch.loading", + "platforms": [ + "windows" + ] + }, + { + "id": "git.patch.noChanges", + "description": "Windows UI: git.patch.noChanges", + "platforms": [ + "windows" + ] + }, + { + "id": "git.patch.notApplicable", + "description": "Windows UI: git.patch.notApplicable", + "platforms": [ + "windows" + ] + }, + { + "id": "git.patch.openFile", + "description": "Windows UI: git.patch.openFile", + "platforms": [ + "windows" + ] + }, + { + "id": "git.patch.paste", + "description": "Windows UI: git.patch.paste", + "platforms": [ + "windows" + ] + }, + { + "id": "git.patch.preview", + "description": "Windows UI: git.patch.preview", + "platforms": [ + "windows" + ] + }, + { + "id": "git.patch.previewSelection", + "description": "Windows UI: git.patch.previewSelection", + "platforms": [ + "windows" + ] + }, + { + "id": "git.patch.rawDiff", + "description": "Windows UI: git.patch.rawDiff", + "platforms": [ + "windows" + ] + }, + { + "id": "git.patch.save", + "description": "Windows UI: git.patch.save", + "platforms": [ + "windows" + ] + }, + { + "id": "git.patch.saved", + "description": "Windows UI: git.patch.saved", + "platforms": [ + "windows" + ], + "parameters": { + "path": "string" + } + }, + { + "id": "git.patch.selectAll", + "description": "Windows UI: git.patch.selectAll", + "platforms": [ + "windows" + ] + }, + { + "id": "git.patch.selectNone", + "description": "Windows UI: git.patch.selectNone", + "platforms": [ + "windows" + ] + }, + { + "id": "git.patch.selectedFiles", + "description": "Windows UI: git.patch.selectedFiles", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.patch.singleRepository", + "description": "Windows UI: git.patch.singleRepository", + "platforms": [ + "windows" + ] + }, + { + "id": "git.patch.source", + "description": "Windows UI: git.patch.source", + "platforms": [ + "windows" + ] + }, + { + "id": "git.patch.source.staged", + "description": "Windows UI: git.patch.source.staged", + "platforms": [ + "windows" + ] + }, + { + "id": "git.patch.source.unstaged", + "description": "Windows UI: git.patch.source.unstaged", + "platforms": [ + "windows" + ] + }, + { + "id": "git.patch.source.workingTree", + "description": "Windows UI: git.patch.source.workingTree", + "platforms": [ + "windows" + ] + }, + { + "id": "git.patch.target.indexAndWorktree", + "description": "Windows UI: git.patch.target.indexAndWorktree", + "platforms": [ + "windows" + ] + }, + { + "id": "git.patch.target.worktree", + "description": "Windows UI: git.patch.target.worktree", + "platforms": [ + "windows" + ] + }, + { + "id": "git.patch.targetCommit", + "description": "Windows UI: git.patch.targetCommit", + "platforms": [ + "windows" + ] + }, + { + "id": "git.patch.twoCommits", + "description": "Windows UI: git.patch.twoCommits", + "platforms": [ + "windows" + ] + }, + { + "id": "git.popStash", + "description": "Windows UI: git.popStash", + "platforms": [ + "windows" + ] + }, + { + "id": "git.previewFirstChange", + "description": "Windows UI: git.previewFirstChange", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pull", + "description": "Windows UI: git.pull", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pullChanges", + "description": "Windows UI: git.pullChanges", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pullCommit", + "description": "Windows UI: git.pullCommit", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.pullCommits", + "description": "Windows UI: git.pullCommits", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.pullCount", + "description": "Windows UI: git.pullCount", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.pullDialog.description", + "description": "Windows UI: git.pullDialog.description", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pullDialog.from", + "description": "Windows UI: git.pullDialog.from", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pullDialog.fromPlaceholder", + "description": "Windows UI: git.pullDialog.fromPlaceholder", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pullDialog.loadFailed", + "description": "Windows UI: git.pullDialog.loadFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pullDialog.loading", + "description": "Windows UI: git.pullDialog.loading", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pullDialog.localChanges", + "description": "Windows UI: git.pullDialog.localChanges", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pullDialog.noBranches", + "description": "Windows UI: git.pullDialog.noBranches", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pullDialog.pull", + "description": "Windows UI: git.pullDialog.pull", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pullDialog.strategy.ffOnly", + "description": "Windows UI: git.pullDialog.strategy.ffOnly", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pullDialog.strategy.ffOnlyHint", + "description": "Windows UI: git.pullDialog.strategy.ffOnlyHint", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pullDialog.strategy.mergeHint", + "description": "Windows UI: git.pullDialog.strategy.mergeHint", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pullDialog.strategy.rebaseHint", + "description": "Windows UI: git.pullDialog.strategy.rebaseHint", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pullDialog.strategyLabel", + "description": "Windows UI: git.pullDialog.strategyLabel", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pullDialog.strategySummary", + "description": "Windows UI: git.pullDialog.strategySummary", + "platforms": [ + "windows" + ], + "parameters": { + "strategy": "string" + } + }, + { + "id": "git.pullDialog.title", + "description": "Windows UI: git.pullDialog.title", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pullDialog.upstreamBadge", + "description": "Windows UI: git.pullDialog.upstreamBadge", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pullResult.dirty", + "description": "Windows UI: git.pullResult.dirty", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pullResult.fetchFailed", + "description": "Windows UI: git.pullResult.fetchFailed", + "platforms": [ + "windows" + ], + "parameters": { + "error": "string" + } + }, + { + "id": "git.pullResult.gitRejectedPull", + "description": "Windows UI: git.pullResult.gitRejectedPull", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pullResult.inspectBranchFailed", + "description": "Windows UI: git.pullResult.inspectBranchFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pullResult.mergeConflict", + "description": "Windows UI: git.pullResult.mergeConflict", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pullResult.merged", + "description": "Windows UI: git.pullResult.merged", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pullResult.noUpstream", + "description": "Windows UI: git.pullResult.noUpstream", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pullResult.preflightFailed", + "description": "Windows UI: git.pullResult.preflightFailed", + "platforms": [ + "windows" + ], + "parameters": { + "error": "string" + } + }, + { + "id": "git.pullResult.pullFailed", + "description": "Windows UI: git.pullResult.pullFailed", + "platforms": [ + "windows" + ], + "parameters": { + "error": "string" + } + }, + { + "id": "git.pullResult.pulled", + "description": "Windows UI: git.pullResult.pulled", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pullResult.rebaseConflict", + "description": "Windows UI: git.pullResult.rebaseConflict", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pullResult.rebased", + "description": "Windows UI: git.pullResult.rebased", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pullResult.remoteUpdateFailed", + "description": "Windows UI: git.pullResult.remoteUpdateFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pullResult.stateChanged", + "description": "Windows UI: git.pullResult.stateChanged", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pullResult.strategyRequired", + "description": "Windows UI: git.pullResult.strategyRequired", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pullResult.upToDate", + "description": "Windows UI: git.pullResult.upToDate", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pulledChanges", + "description": "Windows UI: git.pulledChanges", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pulling", + "description": "Windows UI: git.pulling", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pullingChanges", + "description": "Windows UI: git.pullingChanges", + "platforms": [ + "windows" + ] + }, + { + "id": "git.push", + "description": "Windows UI: git.push", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pushChanges", + "description": "Windows UI: git.pushChanges", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pushCommit", + "description": "Windows UI: git.pushCommit", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.pushCommits", + "description": "Windows UI: git.pushCommits", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.pushCount", + "description": "Windows UI: git.pushCount", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.pushDialog.changedFiles", + "description": "Windows UI: git.pushDialog.changedFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pushDialog.chooseTargetDescription", + "description": "Windows UI: git.pushDialog.chooseTargetDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pushDialog.chooseTargetTitle", + "description": "Windows UI: git.pushDialog.chooseTargetTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pushDialog.commitHistory", + "description": "Windows UI: git.pushDialog.commitHistory", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pushDialog.createsRemoteBranch", + "description": "Windows UI: git.pushDialog.createsRemoteBranch", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pushDialog.description", + "description": "Windows UI: git.pushDialog.description", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pushDialog.divergentWarning", + "description": "Windows UI: git.pushDialog.divergentWarning", + "platforms": [ + "windows" + ], + "parameters": { + "branch": "string", + "upstream": "string" + } + }, + { + "id": "git.pushDialog.filesFailed", + "description": "Windows UI: git.pushDialog.filesFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pushDialog.forcePush", + "description": "Windows UI: git.pushDialog.forcePush", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pushDialog.forcePushHint", + "description": "Windows UI: git.pushDialog.forcePushHint", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pushDialog.help", + "description": "Windows UI: git.pushDialog.help", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pushDialog.loading", + "description": "Windows UI: git.pushDialog.loading", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pushDialog.loadingFiles", + "description": "Windows UI: git.pushDialog.loadingFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pushDialog.moreActions", + "description": "Windows UI: git.pushDialog.moreActions", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pushDialog.moreCommits", + "description": "Windows UI: git.pushDialog.moreCommits", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pushDialog.newUpstream", + "description": "Windows UI: git.pushDialog.newUpstream", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pushDialog.noChangedFiles", + "description": "Windows UI: git.pushDialog.noChangedFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pushDialog.noCommitSelected", + "description": "Windows UI: git.pushDialog.noCommitSelected", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pushDialog.noCommits", + "description": "Windows UI: git.pushDialog.noCommits", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pushDialog.previewFailed", + "description": "Windows UI: git.pushDialog.previewFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pushDialog.publishSameName", + "description": "Windows UI: git.pushDialog.publishSameName", + "platforms": [ + "windows" + ], + "parameters": { + "branch": "string", + "remote": "string" + } + }, + { + "id": "git.pushDialog.pushTags", + "description": "Windows UI: git.pushDialog.pushTags", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pushDialog.pushToUpstream", + "description": "Windows UI: git.pushDialog.pushToUpstream", + "platforms": [ + "windows" + ], + "parameters": { + "branch": "string" + } + }, + { + "id": "git.pushDialog.tagScope", + "description": "Windows UI: git.pushDialog.tagScope", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pushDialog.tagsAll", + "description": "Windows UI: git.pushDialog.tagsAll", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pushDialog.tagsReachable", + "description": "Windows UI: git.pushDialog.tagsReachable", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pushDialog.title", + "description": "Windows UI: git.pushDialog.title", + "platforms": [ + "windows" + ], + "parameters": { + "repository": "string" + } + }, + { + "id": "git.pushDialog.upstreamConfigurationFailed", + "description": "Windows UI: git.pushDialog.upstreamConfigurationFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pushFailed", + "description": "Windows UI: git.pushFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pushTag", + "description": "Windows UI: git.pushTag", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pushTagTo", + "description": "Windows UI: git.pushTagTo", + "platforms": [ + "windows" + ], + "parameters": { + "remote": "string" + } + }, + { + "id": "git.pushedChanges", + "description": "Windows UI: git.pushedChanges", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pushing", + "description": "Windows UI: git.pushing", + "platforms": [ + "windows" + ] + }, + { + "id": "git.pushingChanges", + "description": "Windows UI: git.pushingChanges", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rebase", + "description": "Windows UI: git.rebase", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rebaseBranchTitle", + "description": "Windows UI: git.rebaseBranchTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rebaseConfirm", + "description": "Windows UI: git.rebaseConfirm", + "platforms": [ + "windows" + ], + "parameters": { + "branch": "string", + "current": "string" + } + }, + { + "id": "git.rebaseFailed", + "description": "Windows UI: git.rebaseFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rebaseInProgress", + "description": "Windows UI: git.rebaseInProgress", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rebaseOntoThis", + "description": "Windows UI: git.rebaseOntoThis", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rebasePlan.abortConfirm", + "description": "Windows UI: git.rebasePlan.abortConfirm", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rebasePlan.action.drop", + "description": "Windows UI: git.rebasePlan.action.drop", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rebasePlan.action.edit", + "description": "Windows UI: git.rebasePlan.action.edit", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rebasePlan.action.fixup", + "description": "Windows UI: git.rebasePlan.action.fixup", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rebasePlan.action.pick", + "description": "Windows UI: git.rebasePlan.action.pick", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rebasePlan.action.reword", + "description": "Windows UI: git.rebasePlan.action.reword", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rebasePlan.action.squash", + "description": "Windows UI: git.rebasePlan.action.squash", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rebasePlan.actionLabel", + "description": "Windows UI: git.rebasePlan.actionLabel", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rebasePlan.amendConfirm", + "description": "Windows UI: git.rebasePlan.amendConfirm", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rebasePlan.amendContinue", + "description": "Windows UI: git.rebasePlan.amendContinue", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rebasePlan.conflicts", + "description": "Windows UI: git.rebasePlan.conflicts", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rebasePlan.description", + "description": "Windows UI: git.rebasePlan.description", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rebasePlan.down", + "description": "Windows UI: git.rebasePlan.down", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rebasePlan.editHelp", + "description": "Windows UI: git.rebasePlan.editHelp", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rebasePlan.fromHere", + "description": "Windows UI: git.rebasePlan.fromHere", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rebasePlan.help.drop", + "description": "Windows UI: git.rebasePlan.help.drop", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rebasePlan.help.edit", + "description": "Windows UI: git.rebasePlan.help.edit", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rebasePlan.help.fixup", + "description": "Windows UI: git.rebasePlan.help.fixup", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rebasePlan.help.pick", + "description": "Windows UI: git.rebasePlan.help.pick", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rebasePlan.help.reword", + "description": "Windows UI: git.rebasePlan.help.reword", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rebasePlan.help.squash", + "description": "Windows UI: git.rebasePlan.help.squash", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rebasePlan.invalidPlan", + "description": "Windows UI: git.rebasePlan.invalidPlan", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rebasePlan.noSession", + "description": "Windows UI: git.rebasePlan.noSession", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rebasePlan.output", + "description": "Windows UI: git.rebasePlan.output", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rebasePlan.range", + "description": "Windows UI: git.rebasePlan.range", + "platforms": [ + "windows" + ], + "parameters": { + "base": "string", + "branch": "string", + "count": "string" + } + }, + { + "id": "git.rebasePlan.reloadAmend", + "description": "Windows UI: git.rebasePlan.reloadAmend", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rebasePlan.resultCount", + "description": "Windows UI: git.rebasePlan.resultCount", + "platforms": [ + "windows" + ], + "parameters": { + "after": "string", + "before": "string" + } + }, + { + "id": "git.rebasePlan.session", + "description": "Windows UI: git.rebasePlan.session", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rebasePlan.skipConfirm", + "description": "Windows UI: git.rebasePlan.skipConfirm", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rebasePlan.staleAmend", + "description": "Windows UI: git.rebasePlan.staleAmend", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rebasePlan.start", + "description": "Windows UI: git.rebasePlan.start", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rebasePlan.status.aborted", + "description": "Windows UI: git.rebasePlan.status.aborted", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rebasePlan.status.completed", + "description": "Windows UI: git.rebasePlan.status.completed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rebasePlan.status.conflict", + "description": "Windows UI: git.rebasePlan.status.conflict", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rebasePlan.status.edit", + "description": "Windows UI: git.rebasePlan.status.edit", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rebasePlan.status.failed", + "description": "Windows UI: git.rebasePlan.status.failed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rebasePlan.status.interrupted", + "description": "Windows UI: git.rebasePlan.status.interrupted", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rebasePlan.status.paused", + "description": "Windows UI: git.rebasePlan.status.paused", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rebasePlan.status.starting", + "description": "Windows UI: git.rebasePlan.status.starting", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rebasePlan.title", + "description": "Windows UI: git.rebasePlan.title", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rebasePlan.up", + "description": "Windows UI: git.rebasePlan.up", + "platforms": [ + "windows" + ] + }, + { + "id": "git.refresh", + "description": "Windows UI: git.refresh", + "platforms": [ + "windows" + ] + }, + { + "id": "git.refreshAfterDeleteFailed", + "description": "Windows UI: git.refreshAfterDeleteFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.refreshAria", + "description": "Windows UI: git.refreshAria", + "platforms": [ + "windows" + ] + }, + { + "id": "git.refreshStatus", + "description": "Windows UI: git.refreshStatus", + "platforms": [ + "windows" + ] + }, + { + "id": "git.refreshingStatus", + "description": "Windows UI: git.refreshingStatus", + "platforms": [ + "windows" + ] + }, + { + "id": "git.remoteAhead", + "description": "Windows UI: git.remoteAhead", + "platforms": [ + "windows" + ] + }, + { + "id": "git.remoteChangesAction", + "description": "Windows UI: git.remoteChangesAction", + "platforms": [ + "windows" + ], + "parameters": { + "action": "string" + } + }, + { + "id": "git.remoteCount", + "description": "Windows UI: git.remoteCount", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string", + "plural": "string" + }, + "optionalParameters": { + "plural": "The existing locale does not use this language-specific suffix." + } + }, + { + "id": "git.removeRemote", + "description": "Windows UI: git.removeRemote", + "platforms": [ + "windows" + ] + }, + { + "id": "git.removed", + "description": "Windows UI: git.removed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.removedImage", + "description": "Windows UI: git.removedImage", + "platforms": [ + "windows" + ] + }, + { + "id": "git.repositories", + "description": "Windows UI: git.repositories", + "platforms": [ + "windows" + ] + }, + { + "id": "git.repositoriesCount", + "description": "Windows UI: git.repositoriesCount", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.repository", + "description": "Windows UI: git.repository", + "platforms": [ + "windows" + ] + }, + { + "id": "git.repositoryCount", + "description": "Windows UI: git.repositoryCount", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.repositoryInitialized", + "description": "Windows UI: git.repositoryInitialized", + "platforms": [ + "windows" + ] + }, + { + "id": "git.resetHard", + "description": "Windows UI: git.resetHard", + "platforms": [ + "windows" + ] + }, + { + "id": "git.resetMixed", + "description": "Windows UI: git.resetMixed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.resetSoft", + "description": "Windows UI: git.resetSoft", + "platforms": [ + "windows" + ] + }, + { + "id": "git.resetToCommit", + "description": "Windows UI: git.resetToCommit", + "platforms": [ + "windows" + ] + }, + { + "id": "git.resetToCommitPrompt", + "description": "Windows UI: git.resetToCommitPrompt", + "platforms": [ + "windows" + ], + "parameters": { + "hash": "string" + } + }, + { + "id": "git.resizeCommitArea", + "description": "Windows UI: git.resizeCommitArea", + "platforms": [ + "windows" + ] + }, + { + "id": "git.resolveConflicts", + "description": "Windows UI: git.resolveConflicts", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string", + "plural": "string" + }, + "optionalParameters": { + "plural": "The existing locale does not use this language-specific suffix." + } + }, + { + "id": "git.resolveConflictsFirst", + "description": "Windows UI: git.resolveConflictsFirst", + "platforms": [ + "windows" + ], + "parameters": { + "paths": "string" + } + }, + { + "id": "git.revertCommit", + "description": "Windows UI: git.revertCommit", + "platforms": [ + "windows" + ] + }, + { + "id": "git.revertCommitConfirm", + "description": "Windows UI: git.revertCommitConfirm", + "platforms": [ + "windows" + ], + "parameters": { + "hash": "string", + "message": "string" + } + }, + { + "id": "git.revertInProgress", + "description": "Windows UI: git.revertInProgress", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rollback", + "description": "Windows UI: git.rollback", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rollbackPathsConfirm", + "description": "Windows UI: git.rollbackPathsConfirm", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.saveCommitMessage", + "description": "Windows UI: git.saveCommitMessage", + "platforms": [ + "windows" + ] + }, + { + "id": "git.saved.changed", + "description": "Windows UI: git.saved.changed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.saved.conflicts", + "description": "Windows UI: git.saved.conflicts", + "platforms": [ + "windows" + ] + }, + { + "id": "git.saved.drop", + "description": "Windows UI: git.saved.drop", + "platforms": [ + "windows" + ] + }, + { + "id": "git.saved.empty", + "description": "Windows UI: git.saved.empty", + "platforms": [ + "windows" + ] + }, + { + "id": "git.saved.failed", + "description": "Windows UI: git.saved.failed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.saved.noChanges", + "description": "Windows UI: git.saved.noChanges", + "platforms": [ + "windows" + ] + }, + { + "id": "git.saved.restore", + "description": "Windows UI: git.saved.restore", + "platforms": [ + "windows" + ] + }, + { + "id": "git.saved.shelve", + "description": "Windows UI: git.saved.shelve", + "platforms": [ + "windows" + ] + }, + { + "id": "git.saved.shelves", + "description": "Windows UI: git.saved.shelves", + "platforms": [ + "windows" + ] + }, + { + "id": "git.saved.staged", + "description": "Windows UI: git.saved.staged", + "platforms": [ + "windows" + ] + }, + { + "id": "git.saved.stashes", + "description": "Windows UI: git.saved.stashes", + "platforms": [ + "windows" + ] + }, + { + "id": "git.saved.tab", + "description": "Windows UI: git.saved.tab", + "platforms": [ + "windows" + ] + }, + { + "id": "git.saved.unsaved", + "description": "Windows UI: git.saved.unsaved", + "platforms": [ + "windows" + ] + }, + { + "id": "git.saved.untracked", + "description": "Windows UI: git.saved.untracked", + "platforms": [ + "windows" + ] + }, + { + "id": "git.saved.worktree", + "description": "Windows UI: git.saved.worktree", + "platforms": [ + "windows" + ] + }, + { + "id": "git.searchBranches", + "description": "Windows UI: git.searchBranches", + "platforms": [ + "windows" + ] + }, + { + "id": "git.searchBranchesAria", + "description": "Windows UI: git.searchBranchesAria", + "platforms": [ + "windows" + ] + }, + { + "id": "git.searchCommits", + "description": "Windows UI: git.searchCommits", + "platforms": [ + "windows" + ] + }, + { + "id": "git.searchRemotes", + "description": "Windows UI: git.searchRemotes", + "platforms": [ + "windows" + ] + }, + { + "id": "git.searchStashes", + "description": "Windows UI: git.searchStashes", + "platforms": [ + "windows" + ] + }, + { + "id": "git.searchTags", + "description": "Windows UI: git.searchTags", + "platforms": [ + "windows" + ] + }, + { + "id": "git.searchWorktrees", + "description": "Windows UI: git.searchWorktrees", + "platforms": [ + "windows" + ] + }, + { + "id": "git.selectFilesAndSpecifyCommitMessage", + "description": "Windows UI: git.selectFilesAndSpecifyCommitMessage", + "platforms": [ + "windows" + ] + }, + { + "id": "git.selectFilesToCommit", + "description": "Windows UI: git.selectFilesToCommit", + "platforms": [ + "windows" + ] + }, + { + "id": "git.selectRepository", + "description": "Windows UI: git.selectRepository", + "platforms": [ + "windows" + ] + }, + { + "id": "git.selectSingleRepositoryForCommit", + "description": "Windows UI: git.selectSingleRepositoryForCommit", + "platforms": [ + "windows" + ] + }, + { + "id": "git.selectSingleRepositoryForStash", + "description": "Windows UI: git.selectSingleRepositoryForStash", + "platforms": [ + "windows" + ] + }, + { + "id": "git.selectedFilesCount", + "description": "Windows UI: git.selectedFilesCount", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.selectedFolderNotRepo", + "description": "Windows UI: git.selectedFolderNotRepo", + "platforms": [ + "windows" + ] + }, + { + "id": "git.selecting", + "description": "Windows UI: git.selecting", + "platforms": [ + "windows" + ] + }, + { + "id": "git.selectorSections", + "description": "Windows UI: git.selectorSections", + "platforms": [ + "windows" + ] + }, + { + "id": "git.setup.clear", + "description": "Windows UI: git.setup.clear", + "platforms": [ + "windows" + ] + }, + { + "id": "git.setup.effective", + "description": "Windows UI: git.setup.effective", + "platforms": [ + "windows" + ] + }, + { + "id": "git.setup.email", + "description": "Windows UI: git.setup.email", + "platforms": [ + "windows" + ] + }, + { + "id": "git.setup.errorDetails", + "description": "Windows UI: git.setup.errorDetails", + "platforms": [ + "windows" + ] + }, + { + "id": "git.setup.firstCommit", + "description": "Windows UI: git.setup.firstCommit", + "platforms": [ + "windows" + ] + }, + { + "id": "git.setup.global", + "description": "Windows UI: git.setup.global", + "platforms": [ + "windows" + ] + }, + { + "id": "git.setup.globalDescription", + "description": "Windows UI: git.setup.globalDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "git.setup.identity", + "description": "Windows UI: git.setup.identity", + "platforms": [ + "windows" + ] + }, + { + "id": "git.setup.identityDescription", + "description": "Windows UI: git.setup.identityDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "git.setup.initialize", + "description": "Windows UI: git.setup.initialize", + "platforms": [ + "windows" + ] + }, + { + "id": "git.setup.initializeDescription", + "description": "Windows UI: git.setup.initializeDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "git.setup.initializeFirst", + "description": "Windows UI: git.setup.initializeFirst", + "platforms": [ + "windows" + ] + }, + { + "id": "git.setup.initializeHint", + "description": "Windows UI: git.setup.initializeHint", + "platforms": [ + "windows" + ] + }, + { + "id": "git.setup.initializeTitle", + "description": "Windows UI: git.setup.initializeTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "git.setup.loading", + "description": "Windows UI: git.setup.loading", + "platforms": [ + "windows" + ] + }, + { + "id": "git.setup.local", + "description": "Windows UI: git.setup.local", + "platforms": [ + "windows" + ] + }, + { + "id": "git.setup.localDescription", + "description": "Windows UI: git.setup.localDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "git.setup.missingIdentity", + "description": "Windows UI: git.setup.missingIdentity", + "platforms": [ + "windows" + ] + }, + { + "id": "git.setup.name", + "description": "Windows UI: git.setup.name", + "platforms": [ + "windows" + ] + }, + { + "id": "git.setup.noCommits", + "description": "Windows UI: git.setup.noCommits", + "platforms": [ + "windows" + ] + }, + { + "id": "git.setup.notRepository", + "description": "Windows UI: git.setup.notRepository", + "platforms": [ + "windows" + ] + }, + { + "id": "git.setup.openChanges", + "description": "Windows UI: git.setup.openChanges", + "platforms": [ + "windows" + ] + }, + { + "id": "git.setup.openProject", + "description": "Windows UI: git.setup.openProject", + "platforms": [ + "windows" + ] + }, + { + "id": "git.setup.openSettings", + "description": "Windows UI: git.setup.openSettings", + "platforms": [ + "windows" + ] + }, + { + "id": "git.setup.ownershipDenied", + "description": "Windows UI: git.setup.ownershipDenied", + "platforms": [ + "windows" + ] + }, + { + "id": "git.setup.permissionDenied", + "description": "Windows UI: git.setup.permissionDenied", + "platforms": [ + "windows" + ] + }, + { + "id": "git.setup.readFailed", + "description": "Windows UI: git.setup.readFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.setup.readFailedHint", + "description": "Windows UI: git.setup.readFailedHint", + "platforms": [ + "windows" + ] + }, + { + "id": "git.setup.reload", + "description": "Windows UI: git.setup.reload", + "platforms": [ + "windows" + ] + }, + { + "id": "git.setup.save", + "description": "Windows UI: git.setup.save", + "platforms": [ + "windows" + ] + }, + { + "id": "git.setup.saved", + "description": "Windows UI: git.setup.saved", + "platforms": [ + "windows" + ] + }, + { + "id": "git.setup.scope", + "description": "Windows UI: git.setup.scope", + "platforms": [ + "windows" + ] + }, + { + "id": "git.setup.separateSave", + "description": "Windows UI: git.setup.separateSave", + "platforms": [ + "windows" + ] + }, + { + "id": "git.setup.unconfigured", + "description": "Windows UI: git.setup.unconfigured", + "platforms": [ + "windows" + ] + }, + { + "id": "git.showBranchDiff", + "description": "Windows UI: git.showBranchDiff", + "platforms": [ + "windows" + ] + }, + { + "id": "git.showDiff", + "description": "Windows UI: git.showDiff", + "platforms": [ + "windows" + ] + }, + { + "id": "git.signTag", + "description": "Windows UI: git.signTag", + "platforms": [ + "windows" + ] + }, + { + "id": "git.skipCommit", + "description": "Windows UI: git.skipCommit", + "platforms": [ + "windows" + ] + }, + { + "id": "git.specifyCommitMessage", + "description": "Windows UI: git.specifyCommitMessage", + "platforms": [ + "windows" + ] + }, + { + "id": "git.splitDiffView", + "description": "Windows UI: git.splitDiffView", + "platforms": [ + "windows" + ] + }, + { + "id": "git.splitView", + "description": "Windows UI: git.splitView", + "platforms": [ + "windows" + ] + }, + { + "id": "git.squash", + "description": "Windows UI: git.squash", + "platforms": [ + "windows" + ] + }, + { + "id": "git.squashCommits", + "description": "Windows UI: git.squashCommits", + "platforms": [ + "windows" + ] + }, + { + "id": "git.squashCommitsPrompt", + "description": "Windows UI: git.squashCommitsPrompt", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.stageAllChanges", + "description": "Windows UI: git.stageAllChanges", + "platforms": [ + "windows" + ] + }, + { + "id": "git.stageFile", + "description": "Windows UI: git.stageFile", + "platforms": [ + "windows" + ] + }, + { + "id": "git.stageFileNamed", + "description": "Windows UI: git.stageFileNamed", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "git.stageFilesFailed", + "description": "Windows UI: git.stageFilesFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.stageFolder", + "description": "Windows UI: git.stageFolder", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "git.staged", + "description": "Windows UI: git.staged", + "platforms": [ + "windows" + ] + }, + { + "id": "git.stagedFilesCount", + "description": "Windows UI: git.stagedFilesCount", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.stash", + "description": "Windows UI: git.stash", + "platforms": [ + "windows" + ] + }, + { + "id": "git.stashAllUnstaged", + "description": "Windows UI: git.stashAllUnstaged", + "platforms": [ + "windows" + ] + }, + { + "id": "git.stashAllUnstagedChanges", + "description": "Windows UI: git.stashAllUnstagedChanges", + "platforms": [ + "windows" + ] + }, + { + "id": "git.stashAndSwitchFailed", + "description": "Windows UI: git.stashAndSwitchFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.stashAndSwitchSuccess", + "description": "Windows UI: git.stashAndSwitchSuccess", + "platforms": [ + "windows" + ] + }, + { + "id": "git.stashChanges", + "description": "Windows UI: git.stashChanges", + "platforms": [ + "windows" + ] + }, + { + "id": "git.stashCount", + "description": "Windows UI: git.stashCount", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.stashFailed", + "description": "Windows UI: git.stashFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.stashFile", + "description": "Windows UI: git.stashFile", + "platforms": [ + "windows" + ] + }, + { + "id": "git.stashFileDefault", + "description": "Windows UI: git.stashFileDefault", + "platforms": [ + "windows" + ], + "parameters": { + "path": "string" + } + }, + { + "id": "git.stashMessage", + "description": "Windows UI: git.stashMessage", + "platforms": [ + "windows" + ] + }, + { + "id": "git.stashMessageDefaultAll", + "description": "Windows UI: git.stashMessageDefaultAll", + "platforms": [ + "windows" + ] + }, + { + "id": "git.stashMessageDefaultFile", + "description": "Windows UI: git.stashMessageDefaultFile", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "git.stashMessageDefaultSelection", + "description": "Windows UI: git.stashMessageDefaultSelection", + "platforms": [ + "windows" + ] + }, + { + "id": "git.stashSelected", + "description": "Windows UI: git.stashSelected", + "platforms": [ + "windows" + ] + }, + { + "id": "git.stashSelectedDefault", + "description": "Windows UI: git.stashSelectedDefault", + "platforms": [ + "windows" + ] + }, + { + "id": "git.stashesCount", + "description": "Windows UI: git.stashesCount", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.stashing", + "description": "Windows UI: git.stashing", + "platforms": [ + "windows" + ] + }, + { + "id": "git.statusLoadFailed", + "description": "Windows UI: git.statusLoadFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.stoppedBeforeCompletion", + "description": "Windows UI: git.stoppedBeforeCompletion", + "platforms": [ + "windows" + ], + "parameters": { + "branch": "string" + } + }, + { + "id": "git.switchingTo", + "description": "Windows UI: git.switchingTo", + "platforms": [ + "windows" + ], + "parameters": { + "branch": "string" + } + }, + { + "id": "git.tag.message", + "description": "Windows UI: git.tag.message", + "platforms": [ + "windows" + ] + }, + { + "id": "git.tag.remote", + "description": "Windows UI: git.tag.remote", + "platforms": [ + "windows" + ] + }, + { + "id": "git.tagCount", + "description": "Windows UI: git.tagCount", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string", + "plural": "string" + }, + "optionalParameters": { + "plural": "The existing locale does not use this language-specific suffix." + } + }, + { + "id": "git.tagName", + "description": "Windows UI: git.tagName", + "platforms": [ + "windows" + ] + }, + { + "id": "git.target", + "description": "Windows UI: git.target", + "platforms": [ + "windows" + ] + }, + { + "id": "git.uncommittedChanges", + "description": "Windows UI: git.uncommittedChanges", + "platforms": [ + "windows" + ] + }, + { + "id": "git.uncommittedWouldOverwrite", + "description": "Windows UI: git.uncommittedWouldOverwrite", + "platforms": [ + "windows" + ], + "parameters": { + "listed": "string", + "more": "string" + } + }, + { + "id": "git.undoCommit", + "description": "Windows UI: git.undoCommit", + "platforms": [ + "windows" + ] + }, + { + "id": "git.unifiedDiffView", + "description": "Windows UI: git.unifiedDiffView", + "platforms": [ + "windows" + ] + }, + { + "id": "git.unifiedView", + "description": "Windows UI: git.unifiedView", + "platforms": [ + "windows" + ] + }, + { + "id": "git.unstageAllChanges", + "description": "Windows UI: git.unstageAllChanges", + "platforms": [ + "windows" + ] + }, + { + "id": "git.unstageFile", + "description": "Windows UI: git.unstageFile", + "platforms": [ + "windows" + ] + }, + { + "id": "git.unstageFileNamed", + "description": "Windows UI: git.unstageFileNamed", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "git.unstageFilesFailed", + "description": "Windows UI: git.unstageFilesFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.unstageFolder", + "description": "Windows UI: git.unstageFolder", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "git.unstaged", + "description": "Windows UI: git.unstaged", + "platforms": [ + "windows" + ] + }, + { + "id": "git.untrackedDirectorySummary", + "description": "Windows UI: git.untrackedDirectorySummary", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string", + "path": "string" + } + }, + { + "id": "git.unversionedFilesNode", + "description": "Windows UI: git.unversionedFilesNode", + "platforms": [ + "windows" + ] + }, + { + "id": "git.url", + "description": "Windows UI: git.url", + "platforms": [ + "windows" + ] + }, + { + "id": "git.verifyConflictMarkersFailed", + "description": "Windows UI: git.verifyConflictMarkersFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.viewDiff", + "description": "Windows UI: git.viewDiff", + "platforms": [ + "windows" + ] + }, + { + "id": "git.viewOptions", + "description": "Windows UI: git.viewOptions", + "platforms": [ + "windows" + ] + }, + { + "id": "git.viewStashes", + "description": "Windows UI: git.viewStashes", + "platforms": [ + "windows" + ] + }, + { + "id": "git.workspaceCommit.attention", + "description": "Windows UI: git.workspaceCommit.attention", + "platforms": [ + "windows" + ] + }, + { + "id": "git.workspaceCommit.changed", + "description": "Windows UI: git.workspaceCommit.changed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.workspaceCommit.committed", + "description": "Windows UI: git.workspaceCommit.committed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.workspaceCommit.committedAndPushed", + "description": "Windows UI: git.workspaceCommit.committedAndPushed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.workspaceCommit.committedPushPending", + "description": "Windows UI: git.workspaceCommit.committedPushPending", + "platforms": [ + "windows" + ] + }, + { + "id": "git.workspaceCommit.dirtySubmodule", + "description": "Windows UI: git.workspaceCommit.dirtySubmodule", + "platforms": [ + "windows" + ] + }, + { + "id": "git.workspaceCommit.dismiss", + "description": "Windows UI: git.workspaceCommit.dismiss", + "platforms": [ + "windows" + ] + }, + { + "id": "git.workspaceCommit.headAdvanced", + "description": "Windows UI: git.workspaceCommit.headAdvanced", + "platforms": [ + "windows" + ] + }, + { + "id": "git.workspaceCommit.notIncluded", + "description": "Windows UI: git.workspaceCommit.notIncluded", + "platforms": [ + "windows" + ] + }, + { + "id": "git.workspaceCommit.order", + "description": "Windows UI: git.workspaceCommit.order", + "platforms": [ + "windows" + ] + }, + { + "id": "git.workspaceCommit.outcomeUnknown", + "description": "Windows UI: git.workspaceCommit.outcomeUnknown", + "platforms": [ + "windows" + ] + }, + { + "id": "git.workspaceCommit.parents", + "description": "Windows UI: git.workspaceCommit.parents", + "platforms": [ + "windows" + ] + }, + { + "id": "git.workspaceCommit.pending", + "description": "Windows UI: git.workspaceCommit.pending", + "platforms": [ + "windows" + ] + }, + { + "id": "git.workspaceCommit.pushFailed", + "description": "Windows UI: git.workspaceCommit.pushFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.workspaceCommit.pushOnly", + "description": "Windows UI: git.workspaceCommit.pushOnly", + "platforms": [ + "windows" + ] + }, + { + "id": "git.workspaceCommit.reference", + "description": "Windows UI: git.workspaceCommit.reference", + "platforms": [ + "windows" + ], + "parameters": { + "child": "string", + "path": "string" + } + }, + { + "id": "git.workspaceCommit.retry", + "description": "Windows UI: git.workspaceCommit.retry", + "platforms": [ + "windows" + ] + }, + { + "id": "git.workspaceCommit.review", + "description": "Windows UI: git.workspaceCommit.review", + "platforms": [ + "windows" + ] + }, + { + "id": "git.workspaceCommit.stop", + "description": "Windows UI: git.workspaceCommit.stop", + "platforms": [ + "windows" + ] + }, + { + "id": "git.workspaceCommit.waitingForSubmodule", + "description": "Windows UI: git.workspaceCommit.waitingForSubmodule", + "platforms": [ + "windows" + ] + }, + { + "id": "git.worktreeCount", + "description": "Windows UI: git.worktreeCount", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.worktreeDialog.after.currentWindow", + "description": "Windows UI: git.worktreeDialog.after.current-window", + "platforms": [ + "windows" + ], + "windows": { + "key": "git.worktreeDialog.after.current-window" + } + }, + { + "id": "git.worktreeDialog.after.newWindow", + "description": "Windows UI: git.worktreeDialog.after.new-window", + "platforms": [ + "windows" + ], + "windows": { + "key": "git.worktreeDialog.after.new-window" + } + }, + { + "id": "git.worktreeDialog.after.stay", + "description": "Windows UI: git.worktreeDialog.after.stay", + "platforms": [ + "windows" + ] + }, + { + "id": "git.worktreeDialog.afterCreate", + "description": "Windows UI: git.worktreeDialog.afterCreate", + "platforms": [ + "windows" + ] + }, + { + "id": "git.worktreeDialog.bare", + "description": "Windows UI: git.worktreeDialog.bare", + "platforms": [ + "windows" + ] + }, + { + "id": "git.worktreeDialog.branchInUse", + "description": "Windows UI: git.worktreeDialog.branchInUse", + "platforms": [ + "windows" + ] + }, + { + "id": "git.worktreeDialog.branchName", + "description": "Windows UI: git.worktreeDialog.branchName", + "platforms": [ + "windows" + ] + }, + { + "id": "git.worktreeDialog.description", + "description": "Windows UI: git.worktreeDialog.description", + "platforms": [ + "windows" + ] + }, + { + "id": "git.worktreeDialog.destination", + "description": "Windows UI: git.worktreeDialog.destination", + "platforms": [ + "windows" + ] + }, + { + "id": "git.worktreeDialog.locked", + "description": "Windows UI: git.worktreeDialog.locked", + "platforms": [ + "windows" + ] + }, + { + "id": "git.worktreeDialog.manage", + "description": "Windows UI: git.worktreeDialog.manage", + "platforms": [ + "windows" + ] + }, + { + "id": "git.worktreeDialog.missing", + "description": "Windows UI: git.worktreeDialog.missing", + "platforms": [ + "windows" + ] + }, + { + "id": "git.worktreeDialog.mode", + "description": "Windows UI: git.worktreeDialog.mode", + "platforms": [ + "windows" + ] + }, + { + "id": "git.worktreeDialog.mode.detached", + "description": "Windows UI: git.worktreeDialog.mode.detached", + "platforms": [ + "windows" + ] + }, + { + "id": "git.worktreeDialog.mode.existingBranch", + "description": "Windows UI: git.worktreeDialog.mode.existingBranch", + "platforms": [ + "windows" + ] + }, + { + "id": "git.worktreeDialog.mode.newBranch", + "description": "Windows UI: git.worktreeDialog.mode.newBranch", + "platforms": [ + "windows" + ] + }, + { + "id": "git.worktreeDialog.noCheckout", + "description": "Windows UI: git.worktreeDialog.noCheckout", + "platforms": [ + "windows" + ] + }, + { + "id": "git.worktreeDialog.openCurrent", + "description": "Windows UI: git.worktreeDialog.openCurrent", + "platforms": [ + "windows" + ] + }, + { + "id": "git.worktreeDialog.openFailed", + "description": "Windows UI: git.worktreeDialog.openFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "git.worktreeDialog.openNew", + "description": "Windows UI: git.worktreeDialog.openNew", + "platforms": [ + "windows" + ] + }, + { + "id": "git.worktreeDialog.primary", + "description": "Windows UI: git.worktreeDialog.primary", + "platforms": [ + "windows" + ] + }, + { + "id": "git.worktreeDialog.reference", + "description": "Windows UI: git.worktreeDialog.reference", + "platforms": [ + "windows" + ] + }, + { + "id": "git.worktreeDialog.remove", + "description": "Windows UI: git.worktreeDialog.remove", + "platforms": [ + "windows" + ] + }, + { + "id": "git.worktreeDialog.removeConfirm", + "description": "Windows UI: git.worktreeDialog.removeConfirm", + "platforms": [ + "windows" + ], + "parameters": { + "path": "string" + } + }, + { + "id": "git.worktreeDialog.revision", + "description": "Windows UI: git.worktreeDialog.revision", + "platforms": [ + "windows" + ] + }, + { + "id": "git.worktreeDialog.revisionHint", + "description": "Windows UI: git.worktreeDialog.revisionHint", + "platforms": [ + "windows" + ] + }, + { + "id": "git.worktrees", + "description": "Windows UI: git.worktrees", + "platforms": [ + "windows" + ] + }, + { + "id": "git.worktreesCount", + "description": "Windows UI: git.worktreesCount", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "git.zoomIn", + "description": "Windows UI: git.zoomIn", + "platforms": [ + "windows" + ] + }, + { + "id": "git.zoomOut", + "description": "Windows UI: git.zoomOut", + "platforms": [ + "windows" + ] + }, + { + "id": "github.actionRunActions", + "description": "Windows UI: github.actionRunActions", + "platforms": [ + "windows" + ] + }, + { + "id": "github.actions", + "description": "Windows UI: github.actions", + "platforms": [ + "windows" + ] + }, + { + "id": "github.activity", + "description": "Windows UI: github.activity", + "platforms": [ + "windows" + ] + }, + { + "id": "github.addAssignee", + "description": "Windows UI: github.addAssignee", + "platforms": [ + "windows" + ] + }, + { + "id": "github.addComment", + "description": "Windows UI: github.addComment", + "platforms": [ + "windows" + ] + }, + { + "id": "github.added", + "description": "Windows UI: github.added", + "platforms": [ + "windows" + ] + }, + { + "id": "github.aiDraftUnusable", + "description": "Windows UI: github.aiDraftUnusable", + "platforms": [ + "windows" + ] + }, + { + "id": "github.all", + "description": "Windows UI: github.all", + "platforms": [ + "windows" + ] + }, + { + "id": "github.allIssues", + "description": "Windows UI: github.allIssues", + "platforms": [ + "windows" + ] + }, + { + "id": "github.allRuns", + "description": "Windows UI: github.allRuns", + "platforms": [ + "windows" + ] + }, + { + "id": "github.approve", + "description": "Windows UI: github.approve", + "platforms": [ + "windows" + ] + }, + { + "id": "github.approvePullRequest", + "description": "Windows UI: github.approvePullRequest", + "platforms": [ + "windows" + ] + }, + { + "id": "github.assigned", + "description": "Windows UI: github.assigned", + "platforms": [ + "windows" + ] + }, + { + "id": "github.assignees", + "description": "Windows UI: github.assignees", + "platforms": [ + "windows" + ] + }, + { + "id": "github.author", + "description": "Windows UI: github.author", + "platforms": [ + "windows" + ] + }, + { + "id": "github.base", + "description": "Windows UI: github.base", + "platforms": [ + "windows" + ] + }, + { + "id": "github.behindBase", + "description": "Windows UI: github.behindBase", + "platforms": [ + "windows" + ] + }, + { + "id": "github.blockCommands", + "description": "Windows UI: github.blockCommands", + "platforms": [ + "windows" + ] + }, + { + "id": "github.blocked", + "description": "Windows UI: github.blocked", + "platforms": [ + "windows" + ] + }, + { + "id": "github.branch", + "description": "Windows UI: github.branch", + "platforms": [ + "windows" + ] + }, + { + "id": "github.branchNameCopied", + "description": "Windows UI: github.branchNameCopied", + "platforms": [ + "windows" + ] + }, + { + "id": "github.branchNameUnavailable", + "description": "Windows UI: github.branchNameUnavailable", + "platforms": [ + "windows" + ] + }, + { + "id": "github.branches", + "description": "Windows UI: github.branches", + "platforms": [ + "windows" + ] + }, + { + "id": "github.browseRepository", + "description": "Windows UI: github.browseRepository", + "platforms": [ + "windows" + ] + }, + { + "id": "github.cancel", + "description": "Windows UI: github.cancel", + "platforms": [ + "windows" + ] + }, + { + "id": "github.cancelled", + "description": "Windows UI: github.cancelled", + "platforms": [ + "windows" + ] + }, + { + "id": "github.changedFiles", + "description": "Windows UI: github.changedFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "github.changes", + "description": "Windows UI: github.changes", + "platforms": [ + "windows" + ] + }, + { + "id": "github.changesRequested", + "description": "Windows UI: github.changesRequested", + "platforms": [ + "windows" + ] + }, + { + "id": "github.changesRequestedLower", + "description": "Windows UI: github.changesRequestedLower", + "platforms": [ + "windows" + ] + }, + { + "id": "github.check", + "description": "Windows UI: github.check", + "platforms": [ + "windows" + ] + }, + { + "id": "github.checkedOutPr", + "description": "Windows UI: github.checkedOutPr", + "platforms": [ + "windows" + ], + "parameters": { + "number": "string" + } + }, + { + "id": "github.checkingAccount", + "description": "Windows UI: github.checkingAccount", + "platforms": [ + "windows" + ] + }, + { + "id": "github.checkoutBranch", + "description": "Windows UI: github.checkoutBranch", + "platforms": [ + "windows" + ] + }, + { + "id": "github.checks", + "description": "Windows UI: github.checks", + "platforms": [ + "windows" + ] + }, + { + "id": "github.checksPassed", + "description": "Windows UI: github.checksPassed", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "github.checksPassedWithConflicts", + "description": "Windows UI: github.checksPassedWithConflicts", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "github.checksPending", + "description": "Windows UI: github.checksPending", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "github.chooseBase", + "description": "Windows UI: github.chooseBase", + "platforms": [ + "windows" + ] + }, + { + "id": "github.chooseBaseBranch", + "description": "Windows UI: github.chooseBaseBranch", + "platforms": [ + "windows" + ] + }, + { + "id": "github.chooseBranchOrTag", + "description": "Windows UI: github.chooseBranchOrTag", + "platforms": [ + "windows" + ] + }, + { + "id": "github.chooseHead", + "description": "Windows UI: github.chooseHead", + "platforms": [ + "windows" + ] + }, + { + "id": "github.chooseHeadBranch", + "description": "Windows UI: github.chooseHeadBranch", + "platforms": [ + "windows" + ] + }, + { + "id": "github.chooseLabels", + "description": "Windows UI: github.chooseLabels", + "platforms": [ + "windows" + ] + }, + { + "id": "github.chooseWorkflow", + "description": "Windows UI: github.chooseWorkflow", + "platforms": [ + "windows" + ] + }, + { + "id": "github.chooseWorkflowDescription", + "description": "Windows UI: github.chooseWorkflowDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "github.chooseWorkflowRef", + "description": "Windows UI: github.chooseWorkflowRef", + "platforms": [ + "windows" + ] + }, + { + "id": "github.close", + "description": "Windows UI: github.close", + "platforms": [ + "windows" + ] + }, + { + "id": "github.closeAsNotPlanned", + "description": "Windows UI: github.closeAsNotPlanned", + "platforms": [ + "windows" + ] + }, + { + "id": "github.closePr", + "description": "Windows UI: github.closePr", + "platforms": [ + "windows" + ] + }, + { + "id": "github.closePullRequest", + "description": "Windows UI: github.closePullRequest", + "platforms": [ + "windows" + ] + }, + { + "id": "github.closePullRequestConfirm", + "description": "Windows UI: github.closePullRequestConfirm", + "platforms": [ + "windows" + ] + }, + { + "id": "github.closedAgo", + "description": "Windows UI: github.closedAgo", + "platforms": [ + "windows" + ], + "parameters": { + "time": "string" + } + }, + { + "id": "github.closedBy", + "description": "Windows UI: github.closedBy", + "platforms": [ + "windows" + ], + "parameters": { + "author": "string" + } + }, + { + "id": "github.closedIssues", + "description": "Windows UI: github.closedIssues", + "platforms": [ + "windows" + ] + }, + { + "id": "github.closing", + "description": "Windows UI: github.closing", + "platforms": [ + "windows" + ] + }, + { + "id": "github.collapseDiffForFile", + "description": "Windows UI: github.collapseDiffForFile", + "platforms": [ + "windows" + ], + "parameters": { + "path": "string" + } + }, + { + "id": "github.comment", + "description": "Windows UI: github.comment", + "platforms": [ + "windows" + ] + }, + { + "id": "github.commentActions", + "description": "Windows UI: github.commentActions", + "platforms": [ + "windows" + ] + }, + { + "id": "github.commentAdded", + "description": "Windows UI: github.commentAdded", + "platforms": [ + "windows" + ] + }, + { + "id": "github.commentDeleted", + "description": "Windows UI: github.commentDeleted", + "platforms": [ + "windows" + ] + }, + { + "id": "github.commentUpdated", + "description": "Windows UI: github.commentUpdated", + "platforms": [ + "windows" + ] + }, + { + "id": "github.commenting", + "description": "Windows UI: github.commenting", + "platforms": [ + "windows" + ] + }, + { + "id": "github.commentsCount", + "description": "Windows UI: github.commentsCount", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "github.commit", + "description": "Windows UI: github.commit", + "platforms": [ + "windows" + ] + }, + { + "id": "github.commitDiffUnavailable", + "description": "Windows UI: github.commitDiffUnavailable", + "platforms": [ + "windows" + ] + }, + { + "id": "github.commitShaCopied", + "description": "Windows UI: github.commitShaCopied", + "platforms": [ + "windows" + ] + }, + { + "id": "github.connectGitHub", + "description": "Windows UI: github.connectGitHub", + "platforms": [ + "windows" + ] + }, + { + "id": "github.conversationLocked", + "description": "Windows UI: github.conversationLocked", + "platforms": [ + "windows" + ] + }, + { + "id": "github.copyBranchName", + "description": "Windows UI: github.copyBranchName", + "platforms": [ + "windows" + ] + }, + { + "id": "github.copyCommitSha", + "description": "Windows UI: github.copyCommitSha", + "platforms": [ + "windows" + ] + }, + { + "id": "github.copyFailed", + "description": "Windows UI: github.copyFailed", + "platforms": [ + "windows" + ], + "parameters": { + "error": "string" + } + }, + { + "id": "github.copyFullCommitSha", + "description": "Windows UI: github.copyFullCommitSha", + "platforms": [ + "windows" + ] + }, + { + "id": "github.copyJobLogs", + "description": "Windows UI: github.copyJobLogs", + "platforms": [ + "windows" + ] + }, + { + "id": "github.copyLink", + "description": "Windows UI: github.copyLink", + "platforms": [ + "windows" + ] + }, + { + "id": "github.copyTitle", + "description": "Windows UI: github.copyTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "github.couldNotLoadNotifications", + "description": "Windows UI: github.couldNotLoadNotifications", + "platforms": [ + "windows" + ] + }, + { + "id": "github.couldNotMatchNotificationActionsRun", + "description": "Windows UI: github.couldNotMatchNotificationActionsRun", + "platforms": [ + "windows" + ] + }, + { + "id": "github.create", + "description": "Windows UI: github.create", + "platforms": [ + "windows" + ] + }, + { + "id": "github.createDraftPullRequest", + "description": "Windows UI: github.createDraftPullRequest", + "platforms": [ + "windows" + ] + }, + { + "id": "github.created", + "description": "Windows UI: github.created", + "platforms": [ + "windows" + ] + }, + { + "id": "github.creating", + "description": "Windows UI: github.creating", + "platforms": [ + "windows" + ] + }, + { + "id": "github.delete", + "description": "Windows UI: github.delete", + "platforms": [ + "windows" + ] + }, + { + "id": "github.deleteComment", + "description": "Windows UI: github.deleteComment", + "platforms": [ + "windows" + ] + }, + { + "id": "github.deleteCommentConfirm", + "description": "Windows UI: github.deleteCommentConfirm", + "platforms": [ + "windows" + ] + }, + { + "id": "github.deleted", + "description": "Windows UI: github.deleted", + "platforms": [ + "windows" + ] + }, + { + "id": "github.describeRequestedChanges", + "description": "Windows UI: github.describeRequestedChanges", + "platforms": [ + "windows" + ] + }, + { + "id": "github.description", + "description": "Windows UI: github.description", + "platforms": [ + "windows" + ] + }, + { + "id": "github.draft", + "description": "Windows UI: github.draft", + "platforms": [ + "windows" + ] + }, + { + "id": "github.edit", + "description": "Windows UI: github.edit", + "platforms": [ + "windows" + ] + }, + { + "id": "github.editCommentPlaceholder", + "description": "Windows UI: github.editCommentPlaceholder", + "platforms": [ + "windows" + ] + }, + { + "id": "github.editDescription", + "description": "Windows UI: github.editDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "github.editTitle", + "description": "Windows UI: github.editTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "github.edited", + "description": "Windows UI: github.edited", + "platforms": [ + "windows" + ] + }, + { + "id": "github.enableSidebarSections", + "description": "Windows UI: github.enableSidebarSections", + "platforms": [ + "windows" + ] + }, + { + "id": "github.event", + "description": "Windows UI: github.event", + "platforms": [ + "windows" + ] + }, + { + "id": "github.expandDiffForFile", + "description": "Windows UI: github.expandDiffForFile", + "platforms": [ + "windows" + ], + "parameters": { + "path": "string" + } + }, + { + "id": "github.failed", + "description": "Windows UI: github.failed", + "platforms": [ + "windows" + ] + }, + { + "id": "github.failedCheckoutPr", + "description": "Windows UI: github.failedCheckoutPr", + "platforms": [ + "windows" + ], + "parameters": { + "number": "string" + } + }, + { + "id": "github.failedClosePullRequest", + "description": "Windows UI: github.failedClosePullRequest", + "platforms": [ + "windows" + ] + }, + { + "id": "github.failedResolveActionsRun", + "description": "Windows UI: github.failedResolveActionsRun", + "platforms": [ + "windows" + ] + }, + { + "id": "github.failedUpdatePullRequest", + "description": "Windows UI: github.failedUpdatePullRequest", + "platforms": [ + "windows" + ] + }, + { + "id": "github.filesChanged", + "description": "Windows UI: github.filesChanged", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "github.filesCount", + "description": "Windows UI: github.filesCount", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "github.filterLabel", + "description": "Windows UI: github.filterLabel", + "platforms": [ + "windows" + ], + "parameters": { + "label": "string" + } + }, + { + "id": "github.filterSection", + "description": "Windows UI: github.filterSection", + "platforms": [ + "windows" + ], + "parameters": { + "section": "string" + } + }, + { + "id": "github.filteredFilesCount", + "description": "Windows UI: github.filteredFilesCount", + "platforms": [ + "windows" + ], + "parameters": { + "shown": "string", + "total": "string" + } + }, + { + "id": "github.fromPath", + "description": "Windows UI: github.fromPath", + "platforms": [ + "windows" + ], + "parameters": { + "path": "string" + } + }, + { + "id": "github.generate", + "description": "Windows UI: github.generate", + "platforms": [ + "windows" + ] + }, + { + "id": "github.generating", + "description": "Windows UI: github.generating", + "platforms": [ + "windows" + ] + }, + { + "id": "github.githubUsername", + "description": "Windows UI: github.githubUsername", + "platforms": [ + "windows" + ] + }, + { + "id": "github.hasConflicts", + "description": "Windows UI: github.hasConflicts", + "platforms": [ + "windows" + ] + }, + { + "id": "github.head", + "description": "Windows UI: github.head", + "platforms": [ + "windows" + ] + }, + { + "id": "github.hideChangedFiles", + "description": "Windows UI: github.hideChangedFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "github.hideLogSearch", + "description": "Windows UI: github.hideLogSearch", + "platforms": [ + "windows" + ] + }, + { + "id": "github.inProgress", + "description": "Windows UI: github.inProgress", + "platforms": [ + "windows" + ] + }, + { + "id": "github.inRepository", + "description": "Windows UI: github.inRepository", + "platforms": [ + "windows" + ], + "parameters": { + "repository": "string" + } + }, + { + "id": "github.invalidDiffFilePath", + "description": "Windows UI: github.invalidDiffFilePath", + "platforms": [ + "windows" + ] + }, + { + "id": "github.issueActions", + "description": "Windows UI: github.issueActions", + "platforms": [ + "windows" + ] + }, + { + "id": "github.issueClosed", + "description": "Windows UI: github.issueClosed", + "platforms": [ + "windows" + ] + }, + { + "id": "github.issueCreated", + "description": "Windows UI: github.issueCreated", + "platforms": [ + "windows" + ] + }, + { + "id": "github.issueDraftGenerated", + "description": "Windows UI: github.issueDraftGenerated", + "platforms": [ + "windows" + ] + }, + { + "id": "github.issueLinkCopied", + "description": "Windows UI: github.issueLinkCopied", + "platforms": [ + "windows" + ] + }, + { + "id": "github.issueLinkUnavailable", + "description": "Windows UI: github.issueLinkUnavailable", + "platforms": [ + "windows" + ] + }, + { + "id": "github.issueLocked", + "description": "Windows UI: github.issueLocked", + "platforms": [ + "windows" + ] + }, + { + "id": "github.issueMilestone", + "description": "Windows UI: github.issueMilestone", + "platforms": [ + "windows" + ] + }, + { + "id": "github.issueReopened", + "description": "Windows UI: github.issueReopened", + "platforms": [ + "windows" + ] + }, + { + "id": "github.issueTitle", + "description": "Windows UI: github.issueTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "github.issueType", + "description": "Windows UI: github.issueType", + "platforms": [ + "windows" + ] + }, + { + "id": "github.issueUnlocked", + "description": "Windows UI: github.issueUnlocked", + "platforms": [ + "windows" + ] + }, + { + "id": "github.issueUpdated", + "description": "Windows UI: github.issueUpdated", + "platforms": [ + "windows" + ] + }, + { + "id": "github.issues", + "description": "Windows UI: github.issues", + "platforms": [ + "windows" + ] + }, + { + "id": "github.itemByAuthor", + "description": "Windows UI: github.itemByAuthor", + "platforms": [ + "windows" + ], + "parameters": { + "author": "string", + "number": "string" + } + }, + { + "id": "github.jobsCount", + "description": "Windows UI: github.jobsCount", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "github.jobsFailed", + "description": "Windows UI: github.jobsFailed", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "github.jobsRunning", + "description": "Windows UI: github.jobsRunning", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "github.labels", + "description": "Windows UI: github.labels", + "platforms": [ + "windows" + ] + }, + { + "id": "github.labelsCount", + "description": "Windows UI: github.labelsCount", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "github.leaveComment", + "description": "Windows UI: github.leaveComment", + "platforms": [ + "windows" + ] + }, + { + "id": "github.linked", + "description": "Windows UI: github.linked", + "platforms": [ + "windows" + ] + }, + { + "id": "github.linkedIssues", + "description": "Windows UI: github.linkedIssues", + "platforms": [ + "windows" + ] + }, + { + "id": "github.loading", + "description": "Windows UI: github.loading", + "platforms": [ + "windows" + ] + }, + { + "id": "github.loadingActionRun", + "description": "Windows UI: github.loadingActionRun", + "platforms": [ + "windows" + ] + }, + { + "id": "github.loadingActivity", + "description": "Windows UI: github.loadingActivity", + "platforms": [ + "windows" + ] + }, + { + "id": "github.loadingDiff", + "description": "Windows UI: github.loadingDiff", + "platforms": [ + "windows" + ] + }, + { + "id": "github.loadingFileDiff", + "description": "Windows UI: github.loadingFileDiff", + "platforms": [ + "windows" + ] + }, + { + "id": "github.loadingIssue", + "description": "Windows UI: github.loadingIssue", + "platforms": [ + "windows" + ] + }, + { + "id": "github.loadingIssues", + "description": "Windows UI: github.loadingIssues", + "platforms": [ + "windows" + ] + }, + { + "id": "github.loadingJobLogs", + "description": "Windows UI: github.loadingJobLogs", + "platforms": [ + "windows" + ] + }, + { + "id": "github.loadingLabels", + "description": "Windows UI: github.loadingLabels", + "platforms": [ + "windows" + ] + }, + { + "id": "github.loadingLogs", + "description": "Windows UI: github.loadingLogs", + "platforms": [ + "windows" + ] + }, + { + "id": "github.loadingMoreActivityItems", + "description": "Windows UI: github.loadingMoreActivityItems", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "github.loadingMoreComments", + "description": "Windows UI: github.loadingMoreComments", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "github.loadingMoreJobs", + "description": "Windows UI: github.loadingMoreJobs", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "github.loadingNotifications", + "description": "Windows UI: github.loadingNotifications", + "platforms": [ + "windows" + ] + }, + { + "id": "github.loadingPr", + "description": "Windows UI: github.loadingPr", + "platforms": [ + "windows" + ], + "parameters": { + "number": "string" + } + }, + { + "id": "github.loadingPullRequests", + "description": "Windows UI: github.loadingPullRequests", + "platforms": [ + "windows" + ] + }, + { + "id": "github.loadingWorkflowRuns", + "description": "Windows UI: github.loadingWorkflowRuns", + "platforms": [ + "windows" + ] + }, + { + "id": "github.lockAsOffTopic", + "description": "Windows UI: github.lockAsOffTopic", + "platforms": [ + "windows" + ] + }, + { + "id": "github.lockAsResolved", + "description": "Windows UI: github.lockAsResolved", + "platforms": [ + "windows" + ] + }, + { + "id": "github.lockAsSpam", + "description": "Windows UI: github.lockAsSpam", + "platforms": [ + "windows" + ] + }, + { + "id": "github.lockAsTooHeated", + "description": "Windows UI: github.lockAsTooHeated", + "platforms": [ + "windows" + ] + }, + { + "id": "github.locked", + "description": "Windows UI: github.locked", + "platforms": [ + "windows" + ] + }, + { + "id": "github.lockedAs", + "description": "Windows UI: github.lockedAs", + "platforms": [ + "windows" + ], + "parameters": { + "reason": "string" + } + }, + { + "id": "github.logsAvailableAfterJobFinishes", + "description": "Windows UI: github.logsAvailableAfterJobFinishes", + "platforms": [ + "windows" + ] + }, + { + "id": "github.markdownCommand.bulletList", + "description": "Windows UI: github.markdownCommand.bullet-list", + "platforms": [ + "windows" + ], + "windows": { + "key": "github.markdownCommand.bullet-list" + } + }, + { + "id": "github.markdownCommand.codeBlock", + "description": "Windows UI: github.markdownCommand.code-block", + "platforms": [ + "windows" + ], + "windows": { + "key": "github.markdownCommand.code-block" + } + }, + { + "id": "github.markdownCommand.divider", + "description": "Windows UI: github.markdownCommand.divider", + "platforms": [ + "windows" + ] + }, + { + "id": "github.markdownCommand.heading1", + "description": "Windows UI: github.markdownCommand.heading-1", + "platforms": [ + "windows" + ], + "windows": { + "key": "github.markdownCommand.heading-1" + } + }, + { + "id": "github.markdownCommand.heading2", + "description": "Windows UI: github.markdownCommand.heading-2", + "platforms": [ + "windows" + ], + "windows": { + "key": "github.markdownCommand.heading-2" + } + }, + { + "id": "github.markdownCommand.heading3", + "description": "Windows UI: github.markdownCommand.heading-3", + "platforms": [ + "windows" + ], + "windows": { + "key": "github.markdownCommand.heading-3" + } + }, + { + "id": "github.markdownCommand.numberedList", + "description": "Windows UI: github.markdownCommand.numbered-list", + "platforms": [ + "windows" + ], + "windows": { + "key": "github.markdownCommand.numbered-list" + } + }, + { + "id": "github.markdownCommand.paragraph", + "description": "Windows UI: github.markdownCommand.paragraph", + "platforms": [ + "windows" + ] + }, + { + "id": "github.markdownCommand.quote", + "description": "Windows UI: github.markdownCommand.quote", + "platforms": [ + "windows" + ] + }, + { + "id": "github.markdownCommand.table", + "description": "Windows UI: github.markdownCommand.table", + "platforms": [ + "windows" + ] + }, + { + "id": "github.markdownCommand.taskList", + "description": "Windows UI: github.markdownCommand.task-list", + "platforms": [ + "windows" + ], + "windows": { + "key": "github.markdownCommand.task-list" + } + }, + { + "id": "github.markdownCommandGroup.basicBlocks", + "description": "Windows UI: github.markdownCommandGroup.basic blocks", + "platforms": [ + "windows" + ], + "windows": { + "key": "github.markdownCommandGroup.basic blocks" + } + }, + { + "id": "github.markdownCommandGroup.insert", + "description": "Windows UI: github.markdownCommandGroup.insert", + "platforms": [ + "windows" + ] + }, + { + "id": "github.markdownCommandGroup.lists", + "description": "Windows UI: github.markdownCommandGroup.lists", + "platforms": [ + "windows" + ] + }, + { + "id": "github.markdownEditor", + "description": "Windows UI: github.markdownEditor", + "platforms": [ + "windows" + ] + }, + { + "id": "github.markdownRenderNeedsFix", + "description": "Windows UI: github.markdownRenderNeedsFix", + "platforms": [ + "windows" + ], + "parameters": { + "error": "string" + } + }, + { + "id": "github.merge", + "description": "Windows UI: github.merge", + "platforms": [ + "windows" + ] + }, + { + "id": "github.mergePullRequest", + "description": "Windows UI: github.mergePullRequest", + "platforms": [ + "windows" + ] + }, + { + "id": "github.milestone", + "description": "Windows UI: github.milestone", + "platforms": [ + "windows" + ] + }, + { + "id": "github.modified", + "description": "Windows UI: github.modified", + "platforms": [ + "windows" + ] + }, + { + "id": "github.myPrs", + "description": "Windows UI: github.myPrs", + "platforms": [ + "windows" + ] + }, + { + "id": "github.newIssue", + "description": "Windows UI: github.newIssue", + "platforms": [ + "windows" + ] + }, + { + "id": "github.newPullRequest", + "description": "Windows UI: github.newPullRequest", + "platforms": [ + "windows" + ] + }, + { + "id": "github.noActivity", + "description": "Windows UI: github.noActivity", + "platforms": [ + "windows" + ] + }, + { + "id": "github.noAssignees", + "description": "Windows UI: github.noAssignees", + "platforms": [ + "windows" + ] + }, + { + "id": "github.noChecksReported", + "description": "Windows UI: github.noChecksReported", + "platforms": [ + "windows" + ] + }, + { + "id": "github.noCommentsYet", + "description": "Windows UI: github.noCommentsYet", + "platforms": [ + "windows" + ] + }, + { + "id": "github.noDescriptionProvided", + "description": "Windows UI: github.noDescriptionProvided", + "platforms": [ + "windows" + ] + }, + { + "id": "github.noDiffHunksForFile", + "description": "Windows UI: github.noDiffHunksForFile", + "platforms": [ + "windows" + ] + }, + { + "id": "github.noFileChanges", + "description": "Windows UI: github.noFileChanges", + "platforms": [ + "windows" + ] + }, + { + "id": "github.noFilesMatchFilters", + "description": "Windows UI: github.noFilesMatchFilters", + "platforms": [ + "windows" + ] + }, + { + "id": "github.noIssues", + "description": "Windows UI: github.noIssues", + "platforms": [ + "windows" + ] + }, + { + "id": "github.noLabels", + "description": "Windows UI: github.noLabels", + "platforms": [ + "windows" + ] + }, + { + "id": "github.noLogLinesMatchSearch", + "description": "Windows UI: github.noLogLinesMatchSearch", + "platforms": [ + "windows" + ] + }, + { + "id": "github.noLogsAvailableForStep", + "description": "Windows UI: github.noLogsAvailableForStep", + "platforms": [ + "windows" + ] + }, + { + "id": "github.noMatchingBlocks", + "description": "Windows UI: github.noMatchingBlocks", + "platforms": [ + "windows" + ] + }, + { + "id": "github.noMatchingIssues", + "description": "Windows UI: github.noMatchingIssues", + "platforms": [ + "windows" + ] + }, + { + "id": "github.noMatchingLabels", + "description": "Windows UI: github.noMatchingLabels", + "platforms": [ + "windows" + ] + }, + { + "id": "github.noMatchingPullRequests", + "description": "Windows UI: github.noMatchingPullRequests", + "platforms": [ + "windows" + ] + }, + { + "id": "github.noMatchingWorkflowRuns", + "description": "Windows UI: github.noMatchingWorkflowRuns", + "platforms": [ + "windows" + ] + }, + { + "id": "github.noMilestone", + "description": "Windows UI: github.noMilestone", + "platforms": [ + "windows" + ] + }, + { + "id": "github.noPullRequests", + "description": "Windows UI: github.noPullRequests", + "platforms": [ + "windows" + ] + }, + { + "id": "github.noRepositorySelected", + "description": "Windows UI: github.noRepositorySelected", + "platforms": [ + "windows" + ] + }, + { + "id": "github.noReviewers", + "description": "Windows UI: github.noReviewers", + "platforms": [ + "windows" + ] + }, + { + "id": "github.noStepsReported", + "description": "Windows UI: github.noStepsReported", + "platforms": [ + "windows" + ] + }, + { + "id": "github.noType", + "description": "Windows UI: github.noType", + "platforms": [ + "windows" + ] + }, + { + "id": "github.noUnreadNotifications", + "description": "Windows UI: github.noUnreadNotifications", + "platforms": [ + "windows" + ] + }, + { + "id": "github.noWorkflowRuns", + "description": "Windows UI: github.noWorkflowRuns", + "platforms": [ + "windows" + ] + }, + { + "id": "github.none", + "description": "Windows UI: github.none", + "platforms": [ + "windows" + ] + }, + { + "id": "github.notAuthenticated", + "description": "Windows UI: github.notAuthenticated", + "platforms": [ + "windows" + ] + }, + { + "id": "github.notAuthenticatedDescription", + "description": "Windows UI: github.notAuthenticatedDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "github.notConnected", + "description": "Windows UI: github.notConnected", + "platforms": [ + "windows" + ] + }, + { + "id": "github.notConnectedDescription", + "description": "Windows UI: github.notConnectedDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "github.notifications", + "description": "Windows UI: github.notifications", + "platforms": [ + "windows" + ] + }, + { + "id": "github.notificationsCount", + "description": "Windows UI: github.notificationsCount", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "github.open", + "description": "Windows UI: github.open", + "platforms": [ + "windows" + ] + }, + { + "id": "github.openAttachment", + "description": "Windows UI: github.openAttachment", + "platforms": [ + "windows" + ] + }, + { + "id": "github.openChangedFilesForPr", + "description": "Windows UI: github.openChangedFilesForPr", + "platforms": [ + "windows" + ], + "parameters": { + "number": "string" + } + }, + { + "id": "github.openCommitInBrowser", + "description": "Windows UI: github.openCommitInBrowser", + "platforms": [ + "windows" + ] + }, + { + "id": "github.openCommitOnGitHub", + "description": "Windows UI: github.openCommitOnGitHub", + "platforms": [ + "windows" + ] + }, + { + "id": "github.openCommitSha", + "description": "Windows UI: github.openCommitSha", + "platforms": [ + "windows" + ], + "parameters": { + "sha": "string" + } + }, + { + "id": "github.openIssues", + "description": "Windows UI: github.openIssues", + "platforms": [ + "windows" + ] + }, + { + "id": "github.openOnGitHub", + "description": "Windows UI: github.openOnGitHub", + "platforms": [ + "windows" + ] + }, + { + "id": "github.openPr", + "description": "Windows UI: github.openPr", + "platforms": [ + "windows" + ] + }, + { + "id": "github.openPrs", + "description": "Windows UI: github.openPrs", + "platforms": [ + "windows" + ] + }, + { + "id": "github.openedAgo", + "description": "Windows UI: github.openedAgo", + "platforms": [ + "windows" + ], + "parameters": { + "time": "string" + } + }, + { + "id": "github.optionalReviewNote", + "description": "Windows UI: github.optionalReviewNote", + "platforms": [ + "windows" + ] + }, + { + "id": "github.overview", + "description": "Windows UI: github.overview", + "platforms": [ + "windows" + ] + }, + { + "id": "github.partialChecksPassed", + "description": "Windows UI: github.partialChecksPassed", + "platforms": [ + "windows" + ], + "parameters": { + "passed": "string", + "total": "string" + } + }, + { + "id": "github.patchErrors", + "description": "Windows UI: github.patchErrors", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "github.pendingCheck", + "description": "Windows UI: github.pendingCheck", + "platforms": [ + "windows" + ] + }, + { + "id": "github.pendingChecks", + "description": "Windows UI: github.pendingChecks", + "platforms": [ + "windows" + ] + }, + { + "id": "github.peopleCount", + "description": "Windows UI: github.peopleCount", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "github.prDraftGenerated", + "description": "Windows UI: github.prDraftGenerated", + "platforms": [ + "windows" + ] + }, + { + "id": "github.prLinkCopied", + "description": "Windows UI: github.prLinkCopied", + "platforms": [ + "windows" + ] + }, + { + "id": "github.prLinkUnavailable", + "description": "Windows UI: github.prLinkUnavailable", + "platforms": [ + "windows" + ] + }, + { + "id": "github.pullRequestActionFailed", + "description": "Windows UI: github.pullRequestActionFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "github.pullRequestActions", + "description": "Windows UI: github.pullRequestActions", + "platforms": [ + "windows" + ] + }, + { + "id": "github.pullRequestApproved", + "description": "Windows UI: github.pullRequestApproved", + "platforms": [ + "windows" + ] + }, + { + "id": "github.pullRequestClosed", + "description": "Windows UI: github.pullRequestClosed", + "platforms": [ + "windows" + ] + }, + { + "id": "github.pullRequestCreated", + "description": "Windows UI: github.pullRequestCreated", + "platforms": [ + "windows" + ] + }, + { + "id": "github.pullRequestMerged", + "description": "Windows UI: github.pullRequestMerged", + "platforms": [ + "windows" + ] + }, + { + "id": "github.pullRequestNumber", + "description": "Windows UI: github.pullRequestNumber", + "platforms": [ + "windows" + ], + "parameters": { + "number": "string" + } + }, + { + "id": "github.pullRequestTitle", + "description": "Windows UI: github.pullRequestTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "github.pullRequestUpdated", + "description": "Windows UI: github.pullRequestUpdated", + "platforms": [ + "windows" + ] + }, + { + "id": "github.pullRequests", + "description": "Windows UI: github.pullRequests", + "platforms": [ + "windows" + ] + }, + { + "id": "github.queued", + "description": "Windows UI: github.queued", + "platforms": [ + "windows" + ] + }, + { + "id": "github.readyToMerge", + "description": "Windows UI: github.readyToMerge", + "platforms": [ + "windows" + ] + }, + { + "id": "github.ref", + "description": "Windows UI: github.ref", + "platforms": [ + "windows" + ] + }, + { + "id": "github.refresh", + "description": "Windows UI: github.refresh", + "platforms": [ + "windows" + ] + }, + { + "id": "github.refreshIssues", + "description": "Windows UI: github.refreshIssues", + "platforms": [ + "windows" + ] + }, + { + "id": "github.refreshJobLogs", + "description": "Windows UI: github.refreshJobLogs", + "platforms": [ + "windows" + ] + }, + { + "id": "github.refreshPullRequests", + "description": "Windows UI: github.refreshPullRequests", + "platforms": [ + "windows" + ] + }, + { + "id": "github.refreshWorkflowRuns", + "description": "Windows UI: github.refreshWorkflowRuns", + "platforms": [ + "windows" + ] + }, + { + "id": "github.refreshing", + "description": "Windows UI: github.refreshing", + "platforms": [ + "windows" + ] + }, + { + "id": "github.removeAssignee", + "description": "Windows UI: github.removeAssignee", + "platforms": [ + "windows" + ], + "parameters": { + "assignee": "string" + } + }, + { + "id": "github.renamed", + "description": "Windows UI: github.renamed", + "platforms": [ + "windows" + ] + }, + { + "id": "github.reopen", + "description": "Windows UI: github.reopen", + "platforms": [ + "windows" + ] + }, + { + "id": "github.reopening", + "description": "Windows UI: github.reopening", + "platforms": [ + "windows" + ] + }, + { + "id": "github.repositoryNotGitRepo", + "description": "Windows UI: github.repositoryNotGitRepo", + "platforms": [ + "windows" + ] + }, + { + "id": "github.requestChanges", + "description": "Windows UI: github.requestChanges", + "platforms": [ + "windows" + ] + }, + { + "id": "github.retry", + "description": "Windows UI: github.retry", + "platforms": [ + "windows" + ] + }, + { + "id": "github.retryAuthCheck", + "description": "Windows UI: github.retryAuthCheck", + "platforms": [ + "windows" + ] + }, + { + "id": "github.reviewRequests", + "description": "Windows UI: github.reviewRequests", + "platforms": [ + "windows" + ] + }, + { + "id": "github.reviewRequired", + "description": "Windows UI: github.reviewRequired", + "platforms": [ + "windows" + ] + }, + { + "id": "github.reviewRequiredTitle", + "description": "Windows UI: github.reviewRequiredTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "github.reviewers", + "description": "Windows UI: github.reviewers", + "platforms": [ + "windows" + ] + }, + { + "id": "github.run", + "description": "Windows UI: github.run", + "platforms": [ + "windows" + ] + }, + { + "id": "github.runLinkCopied", + "description": "Windows UI: github.runLinkCopied", + "platforms": [ + "windows" + ] + }, + { + "id": "github.runLinkUnavailable", + "description": "Windows UI: github.runLinkUnavailable", + "platforms": [ + "windows" + ] + }, + { + "id": "github.runNumber", + "description": "Windows UI: github.runNumber", + "platforms": [ + "windows" + ], + "parameters": { + "number": "string" + } + }, + { + "id": "github.runWorkflow", + "description": "Windows UI: github.runWorkflow", + "platforms": [ + "windows" + ] + }, + { + "id": "github.running", + "description": "Windows UI: github.running", + "platforms": [ + "windows" + ] + }, + { + "id": "github.save", + "description": "Windows UI: github.save", + "platforms": [ + "windows" + ] + }, + { + "id": "github.saving", + "description": "Windows UI: github.saving", + "platforms": [ + "windows" + ] + }, + { + "id": "github.savingTitle", + "description": "Windows UI: github.savingTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "github.searchFiles", + "description": "Windows UI: github.searchFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "github.searchGitHub", + "description": "Windows UI: github.searchGitHub", + "platforms": [ + "windows" + ] + }, + { + "id": "github.searchLogs", + "description": "Windows UI: github.searchLogs", + "platforms": [ + "windows" + ] + }, + { + "id": "github.selectFile", + "description": "Windows UI: github.selectFile", + "platforms": [ + "windows" + ] + }, + { + "id": "github.selectGitRepositoryFolder", + "description": "Windows UI: github.selectGitRepositoryFolder", + "platforms": [ + "windows" + ] + }, + { + "id": "github.selectedFolderNotGitRepo", + "description": "Windows UI: github.selectedFolderNotGitRepo", + "platforms": [ + "windows" + ] + }, + { + "id": "github.selecting", + "description": "Windows UI: github.selecting", + "platforms": [ + "windows" + ] + }, + { + "id": "github.showChangedFiles", + "description": "Windows UI: github.showChangedFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "github.skipped", + "description": "Windows UI: github.skipped", + "platforms": [ + "windows" + ] + }, + { + "id": "github.state", + "description": "Windows UI: github.state", + "platforms": [ + "windows" + ] + }, + { + "id": "github.status", + "description": "Windows UI: github.status", + "platforms": [ + "windows" + ] + }, + { + "id": "github.stepLogsCopied", + "description": "Windows UI: github.stepLogsCopied", + "platforms": [ + "windows" + ] + }, + { + "id": "github.stepLogsNotLoaded", + "description": "Windows UI: github.stepLogsNotLoaded", + "platforms": [ + "windows" + ] + }, + { + "id": "github.success", + "description": "Windows UI: github.success", + "platforms": [ + "windows" + ] + }, + { + "id": "github.successful", + "description": "Windows UI: github.successful", + "platforms": [ + "windows" + ] + }, + { + "id": "github.temporarilyUnavailable", + "description": "Windows UI: github.temporarilyUnavailable", + "platforms": [ + "windows" + ] + }, + { + "id": "github.textFormatting", + "description": "Windows UI: github.textFormatting", + "platforms": [ + "windows" + ] + }, + { + "id": "github.title", + "description": "Windows UI: github.title", + "platforms": [ + "windows" + ] + }, + { + "id": "github.tryAgain", + "description": "Windows UI: github.tryAgain", + "platforms": [ + "windows" + ] + }, + { + "id": "github.type", + "description": "Windows UI: github.type", + "platforms": [ + "windows" + ] + }, + { + "id": "github.typeGitHubUsernames", + "description": "Windows UI: github.typeGitHubUsernames", + "platforms": [ + "windows" + ] + }, + { + "id": "github.unassigned", + "description": "Windows UI: github.unassigned", + "platforms": [ + "windows" + ] + }, + { + "id": "github.unknown", + "description": "Windows UI: github.unknown", + "platforms": [ + "windows" + ] + }, + { + "id": "github.unlockConversation", + "description": "Windows UI: github.unlockConversation", + "platforms": [ + "windows" + ] + }, + { + "id": "github.updated", + "description": "Windows UI: github.updated", + "platforms": [ + "windows" + ] + }, + { + "id": "github.updatedAgo", + "description": "Windows UI: github.updatedAgo", + "platforms": [ + "windows" + ], + "parameters": { + "time": "string" + } + }, + { + "id": "github.updatingComment", + "description": "Windows UI: github.updatingComment", + "platforms": [ + "windows" + ] + }, + { + "id": "github.workflow", + "description": "Windows UI: github.workflow", + "platforms": [ + "windows" + ] + }, + { + "id": "github.workflowQueued", + "description": "Windows UI: github.workflowQueued", + "platforms": [ + "windows" + ] + }, + { + "id": "github.working", + "description": "Windows UI: github.working", + "platforms": [ + "windows" + ] + }, + { + "id": "github.writeComment", + "description": "Windows UI: github.writeComment", + "platforms": [ + "windows" + ] + }, + { + "id": "github.writeDescription", + "description": "Windows UI: github.writeDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "github.writeReview", + "description": "Windows UI: github.writeReview", + "platforms": [ + "windows" + ] + }, + { + "id": "htmlPreview.noActiveBuffer", + "description": "Windows UI: htmlPreview.noActiveBuffer", + "platforms": [ + "windows" + ] + }, + { + "id": "htmlPreview.title", + "description": "Windows UI: htmlPreview.title", + "platforms": [ + "windows" + ] + }, + { + "id": "ideImport.importFrom", + "description": "Windows UI: ideImport.importFrom", + "platforms": [ + "windows" + ] + }, + { + "id": "ideImport.importSettings", + "description": "Windows UI: ideImport.importSettings", + "platforms": [ + "windows" + ] + }, + { + "id": "ideImport.importWhat", + "description": "Windows UI: ideImport.importWhat", + "platforms": [ + "windows" + ] + }, + { + "id": "ideImport.importedSelectedSettings", + "description": "Windows UI: ideImport.importedSelectedSettings", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "ideImport.noOptionMatches", + "description": "Windows UI: ideImport.noOptionMatches", + "platforms": [ + "windows" + ], + "parameters": { + "query": "string" + } + }, + { + "id": "ideImport.noRecentProjects", + "description": "Windows UI: ideImport.noRecentProjects", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "ideImport.noSourceMatches", + "description": "Windows UI: ideImport.noSourceMatches", + "platforms": [ + "windows" + ], + "parameters": { + "query": "string" + } + }, + { + "id": "ideImport.recentProjects", + "description": "Windows UI: ideImport.recentProjects", + "platforms": [ + "windows" + ] + }, + { + "id": "ideImport.scanFailed", + "description": "Windows UI: ideImport.scanFailed", + "platforms": [ + "windows" + ], + "parameters": { + "error": "string" + } + }, + { + "id": "ideImport.scanningInstalledEditors", + "description": "Windows UI: ideImport.scanningInstalledEditors", + "platforms": [ + "windows" + ] + }, + { + "id": "image.balanced", + "description": "Windows UI: image.balanced", + "platforms": [ + "windows" + ] + }, + { + "id": "image.calculating", + "description": "Windows UI: image.calculating", + "platforms": [ + "windows" + ] + }, + { + "id": "image.convert", + "description": "Windows UI: image.convert", + "platforms": [ + "windows" + ] + }, + { + "id": "image.convertFailed", + "description": "Windows UI: image.convertFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "image.convertFormat", + "description": "Windows UI: image.convertFormat", + "platforms": [ + "windows" + ] + }, + { + "id": "image.convertMenu", + "description": "Windows UI: image.convertMenu", + "platforms": [ + "windows" + ] + }, + { + "id": "image.convertToFormat", + "description": "Windows UI: image.convertToFormat", + "platforms": [ + "windows" + ], + "parameters": { + "format": "string" + } + }, + { + "id": "image.convertToFormatEllipsis", + "description": "Windows UI: image.convertToFormatEllipsis", + "platforms": [ + "windows" + ], + "parameters": { + "format": "string" + } + }, + { + "id": "image.currentFile", + "description": "Windows UI: image.currentFile", + "platforms": [ + "windows" + ] + }, + { + "id": "image.edit", + "description": "Windows UI: image.edit", + "platforms": [ + "windows" + ] + }, + { + "id": "image.editOperations", + "description": "Windows UI: image.editOperations", + "platforms": [ + "windows" + ] + }, + { + "id": "image.estimatedSize", + "description": "Windows UI: image.estimatedSize", + "platforms": [ + "windows" + ] + }, + { + "id": "image.flipFailed", + "description": "Windows UI: image.flipFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "image.flipHorizontal", + "description": "Windows UI: image.flipHorizontal", + "platforms": [ + "windows" + ] + }, + { + "id": "image.flipVertical", + "description": "Windows UI: image.flipVertical", + "platforms": [ + "windows" + ] + }, + { + "id": "image.heightPx", + "description": "Windows UI: image.heightPx", + "platforms": [ + "windows" + ] + }, + { + "id": "image.highQuality", + "description": "Windows UI: image.highQuality", + "platforms": [ + "windows" + ] + }, + { + "id": "image.losslessTransparency", + "description": "Windows UI: image.losslessTransparency", + "platforms": [ + "windows" + ] + }, + { + "id": "image.lossySmaller", + "description": "Windows UI: image.lossySmaller", + "platforms": [ + "windows" + ] + }, + { + "id": "image.maintainAspectRatio", + "description": "Windows UI: image.maintainAspectRatio", + "platforms": [ + "windows" + ] + }, + { + "id": "image.modernBestCompression", + "description": "Windows UI: image.modernBestCompression", + "platforms": [ + "windows" + ] + }, + { + "id": "image.nextGenCompression", + "description": "Windows UI: image.nextGenCompression", + "platforms": [ + "windows" + ] + }, + { + "id": "image.originalDimensions", + "description": "Windows UI: image.originalDimensions", + "platforms": [ + "windows" + ], + "parameters": { + "height": "string", + "width": "string" + } + }, + { + "id": "image.qualitySetting", + "description": "Windows UI: image.qualitySetting", + "platforms": [ + "windows" + ] + }, + { + "id": "image.recommended", + "description": "Windows UI: image.recommended", + "platforms": [ + "windows" + ] + }, + { + "id": "image.recommendedBadge", + "description": "Windows UI: image.recommendedBadge", + "platforms": [ + "windows" + ] + }, + { + "id": "image.resize", + "description": "Windows UI: image.resize", + "platforms": [ + "windows" + ] + }, + { + "id": "image.resizeEllipsis", + "description": "Windows UI: image.resizeEllipsis", + "platforms": [ + "windows" + ] + }, + { + "id": "image.resizeFailed", + "description": "Windows UI: image.resizeFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "image.resizeImage", + "description": "Windows UI: image.resizeImage", + "platforms": [ + "windows" + ] + }, + { + "id": "image.rotate180", + "description": "Windows UI: image.rotate180", + "platforms": [ + "windows" + ] + }, + { + "id": "image.rotate180Deg", + "description": "Windows UI: image.rotate180Deg", + "platforms": [ + "windows" + ] + }, + { + "id": "image.rotateCcw", + "description": "Windows UI: image.rotateCcw", + "platforms": [ + "windows" + ] + }, + { + "id": "image.rotateCcwDeg", + "description": "Windows UI: image.rotateCcwDeg", + "platforms": [ + "windows" + ] + }, + { + "id": "image.rotateCw", + "description": "Windows UI: image.rotateCw", + "platforms": [ + "windows" + ] + }, + { + "id": "image.rotateCwDeg", + "description": "Windows UI: image.rotateCwDeg", + "platforms": [ + "windows" + ] + }, + { + "id": "image.rotateFailed", + "description": "Windows UI: image.rotateFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "image.saveChanges", + "description": "Windows UI: image.saveChanges", + "platforms": [ + "windows" + ] + }, + { + "id": "image.smallSize", + "description": "Windows UI: image.smallSize", + "platforms": [ + "windows" + ] + }, + { + "id": "image.undo", + "description": "Windows UI: image.undo", + "platforms": [ + "windows" + ] + }, + { + "id": "image.undoLastOperation", + "description": "Windows UI: image.undoLastOperation", + "platforms": [ + "windows" + ] + }, + { + "id": "image.updateFailed", + "description": "Windows UI: image.updateFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "image.widthPx", + "description": "Windows UI: image.widthPx", + "platforms": [ + "windows" + ] + }, + { + "id": "inlineEdit.addApiKey", + "description": "Windows UI: inlineEdit.addApiKey", + "platforms": [ + "windows" + ] + }, + { + "id": "inlineEdit.apiKeyRequired", + "description": "Windows UI: inlineEdit.apiKeyRequired", + "platforms": [ + "windows" + ], + "parameters": { + "provider": "string" + } + }, + { + "id": "inlineEdit.applied", + "description": "Windows UI: inlineEdit.applied", + "platforms": [ + "windows" + ] + }, + { + "id": "inlineEdit.apply", + "description": "Windows UI: inlineEdit.apply", + "platforms": [ + "windows" + ] + }, + { + "id": "inlineEdit.applying", + "description": "Windows UI: inlineEdit.applying", + "platforms": [ + "windows" + ] + }, + { + "id": "inlineEdit.backToProviders", + "description": "Windows UI: inlineEdit.backToProviders", + "platforms": [ + "windows" + ] + }, + { + "id": "inlineEdit.close", + "description": "Windows UI: inlineEdit.close", + "platforms": [ + "windows" + ] + }, + { + "id": "inlineEdit.description", + "description": "Windows UI: inlineEdit.description", + "platforms": [ + "windows" + ] + }, + { + "id": "inlineEdit.editCurrentLine", + "description": "Windows UI: inlineEdit.editCurrentLine", + "platforms": [ + "windows" + ] + }, + { + "id": "inlineEdit.editSelection", + "description": "Windows UI: inlineEdit.editSelection", + "platforms": [ + "windows" + ] + }, + { + "id": "inlineEdit.emptyResult", + "description": "Windows UI: inlineEdit.emptyResult", + "platforms": [ + "windows" + ] + }, + { + "id": "inlineEdit.failedTryAgain", + "description": "Windows UI: inlineEdit.failedTryAgain", + "platforms": [ + "windows" + ] + }, + { + "id": "inlineEdit.instruction", + "description": "Windows UI: inlineEdit.instruction", + "platforms": [ + "windows" + ] + }, + { + "id": "inlineEdit.loadingModels", + "description": "Windows UI: inlineEdit.loadingModels", + "platforms": [ + "windows" + ] + }, + { + "id": "inlineEdit.model", + "description": "Windows UI: inlineEdit.model", + "platforms": [ + "windows" + ] + }, + { + "id": "inlineEdit.noModels", + "description": "Windows UI: inlineEdit.noModels", + "platforms": [ + "windows" + ] + }, + { + "id": "inlineEdit.noProviders", + "description": "Windows UI: inlineEdit.noProviders", + "platforms": [ + "windows" + ] + }, + { + "id": "inlineEdit.pickProvider", + "description": "Windows UI: inlineEdit.pickProvider", + "platforms": [ + "windows" + ] + }, + { + "id": "inlineEdit.requiresOpenBuffer", + "description": "Windows UI: inlineEdit.requiresOpenBuffer", + "platforms": [ + "windows" + ] + }, + { + "id": "inlineEdit.searchModels", + "description": "Windows UI: inlineEdit.searchModels", + "platforms": [ + "windows" + ] + }, + { + "id": "inlineEdit.selectModel", + "description": "Windows UI: inlineEdit.selectModel", + "platforms": [ + "windows" + ] + }, + { + "id": "inlineEdit.targetUnavailable", + "description": "Windows UI: inlineEdit.targetUnavailable", + "platforms": [ + "windows" + ] + }, + { + "id": "inlineEdit.title", + "description": "Windows UI: inlineEdit.title", + "platforms": [ + "windows" + ] + }, + { + "id": "javaEntry.abstractClass", + "description": "Windows UI: javaEntry.abstractClass", + "platforms": [ + "windows" + ] + }, + { + "id": "javaEntry.annotation", + "description": "Windows UI: javaEntry.annotation", + "platforms": [ + "windows" + ] + }, + { + "id": "javaEntry.cancel", + "description": "Windows UI: javaEntry.cancel", + "platforms": [ + "windows" + ] + }, + { + "id": "javaEntry.class", + "description": "Windows UI: javaEntry.class", + "platforms": [ + "windows" + ] + }, + { + "id": "javaEntry.create", + "description": "Windows UI: javaEntry.create", + "platforms": [ + "windows" + ] + }, + { + "id": "javaEntry.enum", + "description": "Windows UI: javaEntry.enum", + "platforms": [ + "windows" + ] + }, + { + "id": "javaEntry.exception", + "description": "Windows UI: javaEntry.exception", + "platforms": [ + "windows" + ] + }, + { + "id": "javaEntry.exists", + "description": "Windows UI: javaEntry.exists", + "platforms": [ + "windows" + ] + }, + { + "id": "javaEntry.failed", + "description": "Windows UI: javaEntry.failed", + "platforms": [ + "windows" + ], + "parameters": { + "message": "string" + } + }, + { + "id": "javaEntry.interface", + "description": "Windows UI: javaEntry.interface", + "platforms": [ + "windows" + ] + }, + { + "id": "javaEntry.invalidName", + "description": "Windows UI: javaEntry.invalidName", + "platforms": [ + "windows" + ] + }, + { + "id": "javaEntry.invalidPackage", + "description": "Windows UI: javaEntry.invalidPackage", + "platforms": [ + "windows" + ] + }, + { + "id": "javaEntry.newMenu", + "description": "Windows UI: javaEntry.newMenu", + "platforms": [ + "windows" + ] + }, + { + "id": "javaEntry.newPackage", + "description": "Windows UI: javaEntry.newPackage", + "platforms": [ + "windows" + ] + }, + { + "id": "javaEntry.newType", + "description": "Windows UI: javaEntry.newType", + "platforms": [ + "windows" + ] + }, + { + "id": "javaEntry.package", + "description": "Windows UI: javaEntry.package", + "platforms": [ + "windows" + ] + }, + { + "id": "javaEntry.packageHint", + "description": "Windows UI: javaEntry.packageHint", + "platforms": [ + "windows" + ] + }, + { + "id": "javaEntry.packageInfo", + "description": "Windows UI: javaEntry.packageInfo", + "platforms": [ + "windows" + ] + }, + { + "id": "javaEntry.record", + "description": "Windows UI: javaEntry.record", + "platforms": [ + "windows" + ] + }, + { + "id": "javaEntry.title", + "description": "Windows UI: javaEntry.title", + "platforms": [ + "windows" + ] + }, + { + "id": "javaEntry.typeKind", + "description": "Windows UI: javaEntry.typeKind", + "platforms": [ + "windows" + ] + }, + { + "id": "javaEntry.typeName", + "description": "Windows UI: javaEntry.typeName", + "platforms": [ + "windows" + ] + }, + { + "id": "javaEntry.withMain", + "description": "Windows UI: javaEntry.withMain", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.cancelRecording", + "description": "Windows UI: keybindings.cancelRecording", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.categories.Debug", + "description": "Windows UI: keybindings.categories.Debug", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.categories.Edit", + "description": "Windows UI: keybindings.categories.Edit", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.categories.Selection", + "description": "Windows UI: keybindings.categories.Selection", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.categories.Workbench", + "description": "Windows UI: keybindings.categories.Workbench", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.database.connect.title", + "description": "Windows UI: keybindings.commands.database.connect.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.debug.start.title", + "description": "Windows UI: keybindings.commands.debug.start.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.debug.stop.title", + "description": "Windows UI: keybindings.commands.debug.stop.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.debug.toggleBreakpoint.title", + "description": "Windows UI: keybindings.commands.debug.toggleBreakpoint.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.copy.title", + "description": "Windows UI: keybindings.commands.editor.copy.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.copyLineDown.title", + "description": "Windows UI: keybindings.commands.editor.copyLineDown.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.copyLineUp.title", + "description": "Windows UI: keybindings.commands.editor.copyLineUp.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.cut.title", + "description": "Windows UI: keybindings.commands.editor.cut.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.deleteLine.title", + "description": "Windows UI: keybindings.commands.editor.deleteLine.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.duplicateLine.title", + "description": "Windows UI: keybindings.commands.editor.duplicateLine.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.expandSelection.title", + "description": "Windows UI: keybindings.commands.editor.expandSelection.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.foldAll.title", + "description": "Windows UI: keybindings.commands.editor.foldAll.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.foldLevel.title", + "description": "Windows UI: keybindings.commands.editor.foldLevel.title", + "platforms": [ + "windows" + ], + "parameters": { + "level": "string" + } + }, + { + "id": "keybindings.commands.editor.formatDocument.title", + "description": "Windows UI: keybindings.commands.editor.formatDocument.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.formatSelection.title", + "description": "Windows UI: keybindings.commands.editor.formatSelection.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.goToBracket.title", + "description": "Windows UI: keybindings.commands.editor.goToBracket.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.goToDefinition.title", + "description": "Windows UI: keybindings.commands.editor.goToDefinition.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.goToImplementation.title", + "description": "Windows UI: keybindings.commands.editor.goToImplementation.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.goToLine.title", + "description": "Windows UI: keybindings.commands.editor.goToLine.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.goToReferences.title", + "description": "Windows UI: keybindings.commands.editor.goToReferences.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.goToTypeDefinition.title", + "description": "Windows UI: keybindings.commands.editor.goToTypeDefinition.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.inlineEdit.title", + "description": "Windows UI: keybindings.commands.editor.inlineEdit.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.insertCursorAbove.title", + "description": "Windows UI: keybindings.commands.editor.insertCursorAbove.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.insertCursorBelow.title", + "description": "Windows UI: keybindings.commands.editor.insertCursorBelow.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.insertCursorsAtLineEnds.title", + "description": "Windows UI: keybindings.commands.editor.insertCursorsAtLineEnds.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.moveLineDown.title", + "description": "Windows UI: keybindings.commands.editor.moveLineDown.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.moveLineUp.title", + "description": "Windows UI: keybindings.commands.editor.moveLineUp.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.paste.title", + "description": "Windows UI: keybindings.commands.editor.paste.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.quickFix.title", + "description": "Windows UI: keybindings.commands.editor.quickFix.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.redo.title", + "description": "Windows UI: keybindings.commands.editor.redo.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.removeBrackets.title", + "description": "Windows UI: keybindings.commands.editor.removeBrackets.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.removeSecondaryCursors.title", + "description": "Windows UI: keybindings.commands.editor.removeSecondaryCursors.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.renameSymbol.title", + "description": "Windows UI: keybindings.commands.editor.renameSymbol.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.selectAll.title", + "description": "Windows UI: keybindings.commands.editor.selectAll.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.selectAllOccurrences.title", + "description": "Windows UI: keybindings.commands.editor.selectAllOccurrences.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.selectNextOccurrence.title", + "description": "Windows UI: keybindings.commands.editor.selectNextOccurrence.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.selectPreviousOccurrence.title", + "description": "Windows UI: keybindings.commands.editor.selectPreviousOccurrence.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.selectToBracket.title", + "description": "Windows UI: keybindings.commands.editor.selectToBracket.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.showHover.title", + "description": "Windows UI: keybindings.commands.editor.showHover.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.showOutline.title", + "description": "Windows UI: keybindings.commands.editor.showOutline.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.shrinkSelection.title", + "description": "Windows UI: keybindings.commands.editor.shrinkSelection.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.toggleComment.title", + "description": "Windows UI: keybindings.commands.editor.toggleComment.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.toggleLineNumbers.title", + "description": "Windows UI: keybindings.commands.editor.toggleLineNumbers.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.toggleRenderWhitespace.title", + "description": "Windows UI: keybindings.commands.editor.toggleRenderWhitespace.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.toggleWordWrap.title", + "description": "Windows UI: keybindings.commands.editor.toggleWordWrap.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.triggerParameterHints.title", + "description": "Windows UI: keybindings.commands.editor.triggerParameterHints.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.triggerSuggest.title", + "description": "Windows UI: keybindings.commands.editor.triggerSuggest.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.undo.title", + "description": "Windows UI: keybindings.commands.editor.undo.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.unfoldAll.title", + "description": "Windows UI: keybindings.commands.editor.unfoldAll.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.file.close.title", + "description": "Windows UI: keybindings.commands.file.close.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.file.closeAll.title", + "description": "Windows UI: keybindings.commands.file.closeAll.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.file.closeOthers.title", + "description": "Windows UI: keybindings.commands.file.closeOthers.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.file.closeSaved.title", + "description": "Windows UI: keybindings.commands.file.closeSaved.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.file.closeTabsToLeft.title", + "description": "Windows UI: keybindings.commands.file.closeTabsToLeft.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.file.closeTabsToRight.title", + "description": "Windows UI: keybindings.commands.file.closeTabsToRight.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.file.localHistory.title", + "description": "Windows UI: keybindings.commands.file.localHistory.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.file.new.title", + "description": "Windows UI: keybindings.commands.file.new.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.file.open.title", + "description": "Windows UI: keybindings.commands.file.open.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.file.openSelected.title", + "description": "Windows command: open the focused file or search result", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.file.quickOpen.title", + "description": "Windows UI: keybindings.commands.file.quickOpen.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.file.reopenClosed.title", + "description": "Windows UI: keybindings.commands.file.reopenClosed.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.file.revert.title", + "description": "Windows UI: keybindings.commands.file.revert.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.file.save.title", + "description": "Windows UI: keybindings.commands.file.save.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.file.saveAll.title", + "description": "Windows UI: keybindings.commands.file.saveAll.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.file.saveAs.title", + "description": "Windows UI: keybindings.commands.file.saveAs.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.help.showWhatsNew.title", + "description": "Windows UI: keybindings.commands.help.showWhatsNew.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.lsp.restartAllServers.title", + "description": "Windows UI: keybindings.commands.lsp.restartAllServers.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.lsp.stopAllServers.title", + "description": "Windows UI: keybindings.commands.lsp.stopAllServers.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.navigation.goBack.title", + "description": "Windows UI: keybindings.commands.navigation.goBack.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.navigation.goForward.title", + "description": "Windows UI: keybindings.commands.navigation.goForward.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.terminal.close.title", + "description": "Windows UI: keybindings.commands.terminal.close.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.terminal.find.title", + "description": "Windows UI: keybindings.commands.terminal.find.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.terminal.new.title", + "description": "Windows UI: keybindings.commands.terminal.new.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.terminal.split.title", + "description": "Windows UI: keybindings.commands.terminal.split.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.terminal.splitDown.title", + "description": "Windows UI: keybindings.commands.terminal.splitDown.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.window.maximize.title", + "description": "Windows UI: keybindings.commands.window.maximize.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.window.minimize.alt.title", + "description": "Windows UI: keybindings.commands.window.minimize.alt.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.window.minimize.mac.title", + "description": "Windows UI: keybindings.commands.window.minimize.mac.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.window.minimize.title", + "description": "Windows UI: keybindings.commands.window.minimize.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.window.quit.title", + "description": "Windows UI: keybindings.commands.window.quit.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.window.toggleFullscreen.title", + "description": "Windows UI: keybindings.commands.window.toggleFullscreen.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.window.toggleFullscreenMac.title", + "description": "Windows UI: keybindings.commands.window.toggleFullscreenMac.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.window.toggleMenuBar.title", + "description": "Windows UI: keybindings.commands.window.toggleMenuBar.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.agentLauncher.title", + "description": "Windows UI: keybindings.commands.workbench.agentLauncher.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.closeEditorGroup.title", + "description": "Windows UI: keybindings.commands.workbench.closeEditorGroup.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.closeOtherEditorGroups.title", + "description": "Windows UI: keybindings.commands.workbench.closeOtherEditorGroups.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.closeWindow.title", + "description": "Windows UI: keybindings.commands.workbench.closeWindow.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.commandPalette.title", + "description": "Windows UI: keybindings.commands.workbench.commandPalette.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.moveEditorToNextGroup.title", + "description": "Windows UI: keybindings.commands.workbench.moveEditorToNextGroup.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.moveEditorToPreviousGroup.title", + "description": "Windows UI: keybindings.commands.workbench.moveEditorToPreviousGroup.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.newTab.title", + "description": "Windows UI: keybindings.commands.workbench.newTab.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.newWindow.title", + "description": "Windows UI: keybindings.commands.workbench.newWindow.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.nextTab.title", + "description": "Windows UI: keybindings.commands.workbench.nextTab.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.nextTabAlt.title", + "description": "Windows UI: keybindings.commands.workbench.nextTabAlt.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.nextTabCtrlTab.title", + "description": "Windows UI: keybindings.commands.workbench.nextTabCtrlTab.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.openKeyboardShortcuts.title", + "description": "Windows UI: keybindings.commands.workbench.openKeyboardShortcuts.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.openSettings.title", + "description": "Windows UI: keybindings.commands.workbench.openSettings.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.previousTab.title", + "description": "Windows UI: keybindings.commands.workbench.previousTab.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.previousTabAlt.title", + "description": "Windows UI: keybindings.commands.workbench.previousTabAlt.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.previousTabCtrlTab.title", + "description": "Windows UI: keybindings.commands.workbench.previousTabCtrlTab.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.resetEditorGroupSizes.title", + "description": "Windows UI: keybindings.commands.workbench.resetEditorGroupSizes.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.searchEverywhere.title", + "description": "Windows UI: keybindings.commands.workbench.searchEverywhere.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.showDebugger.title", + "description": "Windows UI: keybindings.commands.workbench.showDebugger.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.showDocker.title", + "description": "Windows UI: keybindings.commands.workbench.showDocker.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.showFileExplorer.title", + "description": "Windows UI: keybindings.commands.workbench.showFileExplorer.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.showFind.title", + "description": "Windows UI: keybindings.commands.workbench.showFind.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.showFindReplace.title", + "description": "Windows UI: keybindings.commands.workbench.showFindReplace.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.showGitHub.title", + "description": "Windows UI: keybindings.commands.workbench.showGitHub.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.showGlobalSearch.title", + "description": "Windows UI: keybindings.commands.workbench.showGlobalSearch.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.showNotifications.title", + "description": "Windows UI: keybindings.commands.workbench.showNotifications.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.showOutline.title", + "description": "Windows UI: keybindings.commands.workbench.showOutline.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.showProjectSearch.title", + "description": "Windows UI: keybindings.commands.workbench.showProjectSearch.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.showSourceControl.title", + "description": "Windows UI: keybindings.commands.workbench.showSourceControl.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.showThemeSelector.title", + "description": "Windows UI: keybindings.commands.workbench.showThemeSelector.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.splitEditorDown.title", + "description": "Windows UI: keybindings.commands.workbench.splitEditorDown.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.splitEditorRight.title", + "description": "Windows UI: keybindings.commands.workbench.splitEditorRight.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.switchToTab.title", + "description": "Windows UI: keybindings.commands.workbench.switchToTab.title", + "platforms": [ + "windows" + ], + "parameters": { + "index": "string" + } + }, + { + "id": "keybindings.commands.workbench.toggleAIChat.title", + "description": "Windows UI: keybindings.commands.workbench.toggleAIChat.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.toggleActivitySidebar.title", + "description": "Windows UI: keybindings.commands.workbench.toggleActivitySidebar.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.toggleDiagnostics.title", + "description": "Windows UI: keybindings.commands.workbench.toggleDiagnostics.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.toggleEditorGroupLock.title", + "description": "Windows UI: keybindings.commands.workbench.toggleEditorGroupLock.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.toggleGitLog.title", + "description": "Windows UI: keybindings.commands.workbench.toggleGitLog.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.toggleMinimap.title", + "description": "Windows UI: keybindings.commands.workbench.toggleMinimap.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.toggleRun.title", + "description": "Windows UI: keybindings.commands.workbench.toggleRun.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.toggleSidebar.title", + "description": "Windows UI: keybindings.commands.workbench.toggleSidebar.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.toggleSpringEndpoints.description", + "description": "Windows UI: keybindings.commands.workbench.toggleSpringEndpoints.description", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.toggleSpringEndpoints.title", + "description": "Windows UI: keybindings.commands.workbench.toggleSpringEndpoints.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.toggleTerminal.title", + "description": "Windows UI: keybindings.commands.workbench.toggleTerminal.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.toggleTerminalAlt.title", + "description": "Windows UI: keybindings.commands.workbench.toggleTerminalAlt.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.zoomIn.title", + "description": "Windows UI: keybindings.commands.workbench.zoomIn.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.zoomOut.title", + "description": "Windows UI: keybindings.commands.workbench.zoomOut.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.workbench.zoomReset.title", + "description": "Windows UI: keybindings.commands.workbench.zoomReset.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.conflictsWith", + "description": "Windows UI: keybindings.conflictsWith", + "platforms": [ + "windows" + ], + "parameters": { + "commands": "string" + } + }, + { + "id": "keybindings.default", + "description": "Windows UI: keybindings.default", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.editForCommand", + "description": "Windows UI: keybindings.editForCommand", + "platforms": [ + "windows" + ], + "parameters": { + "title": "string" + } + }, + { + "id": "keybindings.extension", + "description": "Windows UI: keybindings.extension", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.notAssigned", + "description": "Windows UI: keybindings.notAssigned", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.preset", + "description": "Windows UI: keybindings.preset", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.pressKeys", + "description": "Windows UI: keybindings.pressKeys", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.record", + "description": "Windows UI: keybindings.record", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.remove", + "description": "Windows UI: keybindings.remove", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.removeKeybinding", + "description": "Windows UI: keybindings.removeKeybinding", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.reset", + "description": "Windows UI: keybindings.reset", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.resetToDefault", + "description": "Windows UI: keybindings.resetToDefault", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.resetToDefaultAria", + "description": "Windows UI: keybindings.resetToDefaultAria", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.user", + "description": "Windows UI: keybindings.user", + "platforms": [ + "windows" + ] + }, + { + "id": "layout.actions", + "description": "Windows UI: layout.actions", + "platforms": [ + "windows" + ] + }, + { + "id": "layout.pinned", + "description": "Windows UI: layout.pinned", + "platforms": [ + "windows" + ] + }, + { + "id": "layout.projectDots", + "description": "Windows UI: layout.projectDots", + "platforms": [ + "windows" + ] + }, + { + "id": "layout.resizeActivityRail", + "description": "Windows UI: layout.resizeActivityRail", + "platforms": [ + "windows" + ] + }, + { + "id": "layout.resizeAiChat", + "description": "Windows UI: layout.resizeAiChat", + "platforms": [ + "windows" + ] + }, + { + "id": "layout.resizeBottomPane", + "description": "Windows UI: layout.resizeBottomPane", + "platforms": [ + "windows" + ] + }, + { + "id": "layout.resizeSidebar", + "description": "Windows UI: layout.resizeSidebar", + "platforms": [ + "windows" + ] + }, + { + "id": "layout.showInActivitySidebar", + "description": "Windows UI: layout.showInActivitySidebar", + "platforms": [ + "windows" + ] + }, + { + "id": "layout.sidebarSections", + "description": "Windows UI: layout.sidebarSections", + "platforms": [ + "windows" + ] + }, + { + "id": "linuxFolderPicker.chooseReadableFolder", + "description": "Windows UI: linuxFolderPicker.chooseReadableFolder", + "platforms": [ + "windows" + ] + }, + { + "id": "linuxFolderPicker.folderPath", + "description": "Windows UI: linuxFolderPicker.folderPath", + "platforms": [ + "windows" + ] + }, + { + "id": "linuxFolderPicker.go", + "description": "Windows UI: linuxFolderPicker.go", + "platforms": [ + "windows" + ] + }, + { + "id": "linuxFolderPicker.home", + "description": "Windows UI: linuxFolderPicker.home", + "platforms": [ + "windows" + ] + }, + { + "id": "linuxFolderPicker.loadingFolders", + "description": "Windows UI: linuxFolderPicker.loadingFolders", + "platforms": [ + "windows" + ] + }, + { + "id": "linuxFolderPicker.noFolders", + "description": "Windows UI: linuxFolderPicker.noFolders", + "platforms": [ + "windows" + ] + }, + { + "id": "linuxFolderPicker.parentFolder", + "description": "Windows UI: linuxFolderPicker.parentFolder", + "platforms": [ + "windows" + ] + }, + { + "id": "linuxFolderPicker.unableToReadFolder", + "description": "Windows UI: linuxFolderPicker.unableToReadFolder", + "platforms": [ + "windows" + ] + }, + { + "id": "localHistory.back", + "description": "Windows UI: localHistory.back", + "platforms": [ + "windows" + ] + }, + { + "id": "localHistory.compareFailed", + "description": "Windows UI: localHistory.compareFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "localHistory.compareSnapshotsFailed", + "description": "Windows UI: localHistory.compareSnapshotsFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "localHistory.compareWithCurrent", + "description": "Windows UI: localHistory.compareWithCurrent", + "platforms": [ + "windows" + ] + }, + { + "id": "localHistory.compareWithPrevious", + "description": "Windows UI: localHistory.compareWithPrevious", + "platforms": [ + "windows" + ] + }, + { + "id": "localHistory.createEntry", + "description": "Windows UI: localHistory.createEntry", + "platforms": [ + "windows" + ] + }, + { + "id": "localHistory.createEntryAria", + "description": "Windows UI: localHistory.createEntryAria", + "platforms": [ + "windows" + ] + }, + { + "id": "localHistory.createFailed", + "description": "Windows UI: localHistory.createFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "localHistory.deleteFailed", + "description": "Windows UI: localHistory.deleteFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "localHistory.deleteSnapshot", + "description": "Windows UI: localHistory.deleteSnapshot", + "platforms": [ + "windows" + ] + }, + { + "id": "localHistory.entryCreated", + "description": "Windows UI: localHistory.entryCreated", + "platforms": [ + "windows" + ] + }, + { + "id": "localHistory.entryName", + "description": "Windows UI: localHistory.entryName", + "platforms": [ + "windows" + ] + }, + { + "id": "localHistory.loadFailed", + "description": "Windows UI: localHistory.loadFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "localHistory.loadingTimeline", + "description": "Windows UI: localHistory.loadingTimeline", + "platforms": [ + "windows" + ] + }, + { + "id": "localHistory.localWorkspaceOnly", + "description": "Windows UI: localHistory.localWorkspaceOnly", + "platforms": [ + "windows" + ] + }, + { + "id": "localHistory.namePrompt", + "description": "Windows UI: localHistory.namePrompt", + "platforms": [ + "windows" + ] + }, + { + "id": "localHistory.noChanges", + "description": "Windows UI: localHistory.noChanges", + "platforms": [ + "windows" + ] + }, + { + "id": "localHistory.noChangesToCompare", + "description": "Windows UI: localHistory.noChangesToCompare", + "platforms": [ + "windows" + ] + }, + { + "id": "localHistory.noFileSelected", + "description": "Windows UI: localHistory.noFileSelected", + "platforms": [ + "windows" + ] + }, + { + "id": "localHistory.noPreviousEntry", + "description": "Windows UI: localHistory.noPreviousEntry", + "platforms": [ + "windows" + ] + }, + { + "id": "localHistory.noSnapshots", + "description": "Windows UI: localHistory.noSnapshots", + "platforms": [ + "windows" + ] + }, + { + "id": "localHistory.openFailed", + "description": "Windows UI: localHistory.openFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "localHistory.openLocalFileFirst", + "description": "Windows UI: localHistory.openLocalFileFirst", + "platforms": [ + "windows" + ] + }, + { + "id": "localHistory.openSnapshot", + "description": "Windows UI: localHistory.openSnapshot", + "platforms": [ + "windows" + ] + }, + { + "id": "localHistory.optionalName", + "description": "Windows UI: localHistory.optionalName", + "platforms": [ + "windows" + ] + }, + { + "id": "localHistory.renameFailed", + "description": "Windows UI: localHistory.renameFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "localHistory.renameSnapshot", + "description": "Windows UI: localHistory.renameSnapshot", + "platforms": [ + "windows" + ] + }, + { + "id": "localHistory.restoreFailed", + "description": "Windows UI: localHistory.restoreFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "localHistory.restoreSnapshot", + "description": "Windows UI: localHistory.restoreSnapshot", + "platforms": [ + "windows" + ] + }, + { + "id": "localHistory.searchTimeline", + "description": "Windows UI: localHistory.searchTimeline", + "platforms": [ + "windows" + ] + }, + { + "id": "localHistory.selectLocalFileFirst", + "description": "Windows UI: localHistory.selectLocalFileFirst", + "platforms": [ + "windows" + ] + }, + { + "id": "localHistory.snapshot", + "description": "Windows UI: localHistory.snapshot", + "platforms": [ + "windows" + ] + }, + { + "id": "localHistory.snapshotRestored", + "description": "Windows UI: localHistory.snapshotRestored", + "platforms": [ + "windows" + ] + }, + { + "id": "localHistory.title", + "description": "Windows UI: localHistory.title", + "platforms": [ + "windows" + ] + }, + { + "id": "localHistory.titleForFile", + "description": "Windows UI: localHistory.titleForFile", + "platforms": [ + "windows" + ], + "parameters": { + "file": "string" + } + }, + { + "id": "lsp.active", + "description": "Windows UI: lsp.active", + "platforms": [ + "windows" + ] + }, + { + "id": "lsp.connecting", + "description": "Windows UI: lsp.connecting", + "platforms": [ + "windows" + ] + }, + { + "id": "lsp.connectingShort", + "description": "Windows UI: lsp.connectingShort", + "platforms": [ + "windows" + ] + }, + { + "id": "lsp.didNotStart", + "description": "Windows UI: lsp.didNotStart", + "platforms": [ + "windows" + ] + }, + { + "id": "lsp.failedNamed", + "description": "Windows UI: lsp.failedNamed", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "lsp.issue", + "description": "Windows UI: lsp.issue", + "platforms": [ + "windows" + ] + }, + { + "id": "lsp.issueHint", + "description": "Windows UI: lsp.issueHint", + "platforms": [ + "windows" + ] + }, + { + "id": "lsp.javaIndexClearFailed", + "description": "Windows UI: lsp.javaIndexClearFailed", + "platforms": [ + "windows" + ], + "parameters": { + "error": "string" + } + }, + { + "id": "lsp.javaIndexCleared", + "description": "Windows UI: lsp.javaIndexCleared", + "platforms": [ + "windows" + ] + }, + { + "id": "lsp.languageServersRestarted", + "description": "Windows UI: lsp.languageServersRestarted", + "platforms": [ + "windows" + ] + }, + { + "id": "lsp.languageServersStopped", + "description": "Windows UI: lsp.languageServersStopped", + "platforms": [ + "windows" + ] + }, + { + "id": "lsp.noActive", + "description": "Windows UI: lsp.noActive", + "platforms": [ + "windows" + ] + }, + { + "id": "lsp.noProject", + "description": "Windows UI: lsp.noProject", + "platforms": [ + "windows" + ] + }, + { + "id": "lsp.notReadyNamed", + "description": "Windows UI: lsp.notReadyNamed", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "lsp.preparingNamed", + "description": "Windows UI: lsp.preparingNamed", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "lsp.readyNamed", + "description": "Windows UI: lsp.readyNamed", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "lsp.restart", + "description": "Windows UI: lsp.restart", + "platforms": [ + "windows" + ] + }, + { + "id": "lsp.restartAll", + "description": "Windows UI: lsp.restartAll", + "platforms": [ + "windows" + ] + }, + { + "id": "lsp.restartAllFailed", + "description": "Windows UI: lsp.restartAllFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "lsp.restartFailed", + "description": "Windows UI: lsp.restartFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "lsp.restarting", + "description": "Windows UI: lsp.restarting", + "platforms": [ + "windows" + ] + }, + { + "id": "lsp.retry", + "description": "Windows UI: lsp.retry", + "platforms": [ + "windows" + ] + }, + { + "id": "lsp.start", + "description": "Windows UI: lsp.start", + "platforms": [ + "windows" + ] + }, + { + "id": "lsp.startFailed", + "description": "Windows UI: lsp.startFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "lsp.starting", + "description": "Windows UI: lsp.starting", + "platforms": [ + "windows" + ] + }, + { + "id": "lsp.status", + "description": "Windows UI: lsp.status", + "platforms": [ + "windows" + ] + }, + { + "id": "lsp.stopAll", + "description": "Windows UI: lsp.stopAll", + "platforms": [ + "windows" + ] + }, + { + "id": "lsp.stopAllFailed", + "description": "Windows UI: lsp.stopAllFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "lsp.stopFailed", + "description": "Windows UI: lsp.stopFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "lsp.stopNamed", + "description": "Windows UI: lsp.stopNamed", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "lsp.stopping", + "description": "Windows UI: lsp.stopping", + "platforms": [ + "windows" + ] + }, + { + "id": "lsp.timedOutNamed", + "description": "Windows UI: lsp.timedOutNamed", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "lsp.unavailableNamed", + "description": "Windows UI: lsp.unavailableNamed", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "lsp.unsupportedFeatureNamed", + "description": "Windows UI: lsp.unsupportedFeatureNamed", + "platforms": [ + "windows" + ], + "parameters": { + "feature": "string", + "name": "string" + } + }, + { + "id": "markdown.documentProperties", + "description": "Windows UI: markdown.documentProperties", + "platforms": [ + "windows" + ] + }, + { + "id": "markdownDocument.placeholder", + "description": "Windows UI: markdownDocument.placeholder", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.add", + "description": "Windows UI: maven.add", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.addProfile", + "description": "Windows UI: maven.addProfile", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.automatic", + "description": "Windows UI: maven.automatic", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.buildOutput", + "description": "Windows UI: maven.buildOutput", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.cancelled", + "description": "Windows UI: maven.cancelled", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.clearOutput", + "description": "Windows UI: maven.clearOutput", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.collapseAll", + "description": "Windows UI: maven.collapseAll", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.configurationChanged", + "description": "Windows UI: maven.configurationChanged", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.debug", + "description": "Windows UI: maven.debug", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.debugAlreadyRunning", + "description": "Windows UI: maven.debugAlreadyRunning", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.debugConfigurationUnavailable", + "description": "Windows UI: maven.debugConfigurationUnavailable", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.dependencies", + "description": "Windows UI: maven.dependencies", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.dependencyCancelled", + "description": "Windows UI: maven.dependencyCancelled", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.dependencyLoading", + "description": "Windows UI: maven.dependencyLoading", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.detectedMissing", + "description": "Windows UI: maven.detectedMissing", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.detectedValue", + "description": "Windows UI: maven.detectedValue", + "platforms": [ + "windows" + ], + "parameters": { + "value": "string" + } + }, + { + "id": "maven.detecting", + "description": "Windows UI: maven.detecting", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.emptyOutput", + "description": "Windows UI: maven.emptyOutput", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.executeGoal", + "description": "Windows UI: maven.executeGoal", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.javaConfigurationFailed", + "description": "Windows UI: maven.javaConfigurationFailed", + "platforms": [ + "windows" + ], + "parameters": { + "error": "string" + } + }, + { + "id": "maven.javaHome", + "description": "Windows UI: maven.javaHome", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.javaSourceUnavailable", + "description": "Windows UI: maven.javaSourceUnavailable", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.lifecycle", + "description": "Windows UI: maven.lifecycle", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.loadFailed", + "description": "Windows UI: maven.loadFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.localRepository", + "description": "Windows UI: maven.localRepository", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.mavenExecutable", + "description": "Windows UI: maven.mavenExecutable", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.noDependencies", + "description": "Windows UI: maven.noDependencies", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.notDetected", + "description": "Windows UI: maven.notDetected", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.omittedConflict", + "description": "Windows UI: maven.omittedConflict", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.omittedDuplicate", + "description": "Windows UI: maven.omittedDuplicate", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.openPom", + "description": "Windows UI: maven.openPom", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.package", + "description": "Windows UI: maven.package", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.processOutput", + "description": "Windows UI: maven.processOutput", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.profileId", + "description": "Windows UI: maven.profileId", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.profiles", + "description": "Windows UI: maven.profiles", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.project", + "description": "Windows UI: maven.project", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.reloadFailed", + "description": "Windows UI: maven.reloadFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.reloadJdt", + "description": "Windows UI: maven.reloadJdt", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.reloadProjects", + "description": "Windows UI: maven.reloadProjects", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.rerunTest", + "description": "Windows UI: maven.rerunTest", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.resolutionProblems", + "description": "Windows UI: maven.resolutionProblems", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "maven.restoreProfiles", + "description": "Windows UI: maven.restoreProfiles", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.run", + "description": "Windows UI: maven.run", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.runConfigurationUnavailable", + "description": "Windows UI: maven.runConfigurationUnavailable", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.runPhase", + "description": "Windows UI: maven.runPhase", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.runSelected", + "description": "Windows UI: maven.runSelected", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.runStartFailed", + "description": "Windows UI: maven.runStartFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.runTestClass", + "description": "Windows UI: maven.runTestClass", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.runTestMethod", + "description": "Windows UI: maven.runTestMethod", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.scanning", + "description": "Windows UI: maven.scanning", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.scopeManagedFrom", + "description": "Windows UI: maven.scopeManagedFrom", + "platforms": [ + "windows" + ], + "parameters": { + "value": "string" + } + }, + { + "id": "maven.scopeNotUpdatedTo", + "description": "Windows UI: maven.scopeNotUpdatedTo", + "platforms": [ + "windows" + ], + "parameters": { + "value": "string" + } + }, + { + "id": "maven.scopeUpdatedFrom", + "description": "Windows UI: maven.scopeUpdatedFrom", + "platforms": [ + "windows" + ], + "parameters": { + "value": "string" + } + }, + { + "id": "maven.settings", + "description": "Windows UI: maven.settings", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.settingsDescription", + "description": "Windows UI: maven.settingsDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.skipTests", + "description": "Windows UI: maven.skipTests", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.sourceRoot.generatedMain", + "description": "Windows UI: maven.sourceRoot.generatedMain", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.sourceRoot.generatedTest", + "description": "Windows UI: maven.sourceRoot.generatedTest", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.sourceRoot.mainJava", + "description": "Windows UI: maven.sourceRoot.mainJava", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.sourceRoot.mainResources", + "description": "Windows UI: maven.sourceRoot.mainResources", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.sourceRoot.testJava", + "description": "Windows UI: maven.sourceRoot.testJava", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.sourceRoot.testResources", + "description": "Windows UI: maven.sourceRoot.testResources", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.sourceRoots", + "description": "Windows UI: maven.sourceRoots", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.stop", + "description": "Windows UI: maven.stop", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.test", + "description": "Windows UI: maven.test", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.testFailures", + "description": "Windows UI: maven.testFailures", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.testLocationUnknown", + "description": "Windows UI: maven.testLocationUnknown", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.testRunFailed", + "description": "Windows UI: maven.testRunFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "maven.testsFailed", + "description": "Windows UI: maven.testsFailed", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "maven.testsPassed", + "description": "Windows UI: maven.testsPassed", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "maven.testsRun", + "description": "Windows UI: maven.testsRun", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "maven.testsSkipped", + "description": "Windows UI: maven.testsSkipped", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "maven.title", + "description": "Windows UI: maven.title", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Maven", + "arguments": [] + } + }, + { + "id": "maven.versionManagedFrom", + "description": "Windows UI: maven.versionManagedFrom", + "platforms": [ + "windows" + ], + "parameters": { + "value": "string" + } + }, + { + "id": "menu.changelog", + "description": "Windows UI: menu.changelog", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.checkForUpdates", + "description": "Windows UI: menu.checkForUpdates", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.closeAllTabs", + "description": "Windows UI: menu.closeAllTabs", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.closeFolder", + "description": "Windows UI: menu.closeFolder", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.closeOtherTabs", + "description": "Windows UI: menu.closeOtherTabs", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.closeSavedTabs", + "description": "Windows UI: menu.closeSavedTabs", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.closeTab", + "description": "Windows UI: menu.closeTab", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.closeTabsToLeft", + "description": "Windows UI: menu.closeTabsToLeft", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.closeTabsToRight", + "description": "Windows UI: menu.closeTabsToRight", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.closeTerminal", + "description": "Windows UI: menu.closeTerminal", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.closeWindow", + "description": "Windows UI: menu.closeWindow", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.commandPalette", + "description": "Windows UI: menu.commandPalette", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.copy", + "description": "Windows UI: menu.copy", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Copy", + "arguments": [] + } + }, + { + "id": "menu.cut", + "description": "Windows UI: menu.cut", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.deleteLine", + "description": "Windows UI: menu.deleteLine", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.diagnostics", + "description": "Windows UI: menu.diagnostics", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.documentation", + "description": "Windows UI: menu.documentation", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.duplicateLine", + "description": "Windows UI: menu.duplicateLine", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.edit", + "description": "Windows UI: menu.edit", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.file", + "description": "Windows UI: menu.file", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.fileExplorer", + "description": "Windows UI: menu.fileExplorer", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.find", + "description": "Windows UI: menu.find", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.findAndReplace", + "description": "Windows UI: menu.findAndReplace", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.formatDocument", + "description": "Windows UI: menu.formatDocument", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.formatSelection", + "description": "Windows UI: menu.formatSelection", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.github", + "description": "Windows UI: menu.github", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.globalSearch", + "description": "Windows UI: menu.globalSearch", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.go", + "description": "Windows UI: menu.go", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.goBack", + "description": "Windows UI: menu.goBack", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.goForward", + "description": "Windows UI: menu.goForward", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.goToDefinition", + "description": "Windows UI: menu.goToDefinition", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.goToImplementation", + "description": "Windows UI: menu.goToImplementation", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.goToLine", + "description": "Windows UI: menu.goToLine", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.goToReferences", + "description": "Windows UI: menu.goToReferences", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.goToTypeDefinition", + "description": "Windows UI: menu.goToTypeDefinition", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.help", + "description": "Windows UI: menu.help", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.keyboardShortcuts", + "description": "Windows UI: menu.keyboardShortcuts", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.maximize", + "description": "Windows UI: menu.maximize", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.minimize", + "description": "Windows UI: menu.minimize", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.moveLineDown", + "description": "Windows UI: menu.moveLineDown", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.moveLineUp", + "description": "Windows UI: menu.moveLineUp", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.newFile", + "description": "Windows UI: menu.newFile", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.newTab", + "description": "Windows UI: menu.newTab", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.newTerminal", + "description": "Windows UI: menu.newTerminal", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.newWindow", + "description": "Windows UI: menu.newWindow", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.nextTab", + "description": "Windows UI: menu.nextTab", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.openFolder", + "description": "Windows UI: menu.openFolder", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.paste", + "description": "Windows UI: menu.paste", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Paste", + "arguments": [] + } + }, + { + "id": "menu.preferences", + "description": "Windows UI: menu.preferences", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.previousTab", + "description": "Windows UI: menu.previousTab", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.quickFix", + "description": "Windows UI: menu.quickFix", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.quickOpen", + "description": "Windows UI: menu.quickOpen", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.quit", + "description": "Windows UI: menu.quit", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.redo", + "description": "Windows UI: menu.redo", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.renameSymbol", + "description": "Windows UI: menu.renameSymbol", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.reopenClosedTab", + "description": "Windows UI: menu.reopenClosedTab", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.reportBug", + "description": "Windows UI: menu.reportBug", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.requestFeature", + "description": "Windows UI: menu.requestFeature", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.resetZoom", + "description": "Windows UI: menu.resetZoom", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.revertFile", + "description": "Windows UI: menu.revertFile", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.run", + "description": "Windows UI: menu.run", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Run", + "arguments": [] + } + }, + { + "id": "menu.runAndDebug", + "description": "Windows UI: menu.runAndDebug", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.save", + "description": "Windows UI: menu.save", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.saveAll", + "description": "Windows UI: menu.saveAll", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.saveAs", + "description": "Windows UI: menu.saveAs", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.saveAsTitle", + "description": "Windows UI: menu.saveAsTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.saveFileFailed", + "description": "Windows UI: menu.saveFileFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.selectAll", + "description": "Windows UI: menu.selectAll", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Select All", + "arguments": [] + } + }, + { + "id": "menu.showHover", + "description": "Windows UI: menu.showHover", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.showLocalHistory", + "description": "Windows UI: menu.showLocalHistory", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.sourceControl", + "description": "Windows UI: menu.sourceControl", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.splitEditor", + "description": "Windows UI: menu.splitEditor", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.splitTerminalDown", + "description": "Windows UI: menu.splitTerminalDown", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.splitTerminalRight", + "description": "Windows UI: menu.splitTerminalRight", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.startDebugging", + "description": "Windows UI: menu.startDebugging", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.stopDebugging", + "description": "Windows UI: menu.stopDebugging", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.terminal", + "description": "Windows UI: menu.terminal", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.theme", + "description": "Windows UI: menu.theme", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.toggleActivitySidebar", + "description": "Windows UI: menu.toggleActivitySidebar", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.toggleBreakpoint", + "description": "Windows UI: menu.toggleBreakpoint", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.toggleComment", + "description": "Windows UI: menu.toggleComment", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.toggleFullscreen", + "description": "Windows UI: menu.toggleFullscreen", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.toggleLineNumbers", + "description": "Windows UI: menu.toggleLineNumbers", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.toggleMenuBar", + "description": "Windows UI: menu.toggleMenuBar", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.toggleMinimap", + "description": "Windows UI: menu.toggleMinimap", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.toggleRenderWhitespace", + "description": "Windows UI: menu.toggleRenderWhitespace", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.toggleSecondarySidebar", + "description": "Windows UI: menu.toggleSecondarySidebar", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.toggleTerminal", + "description": "Windows UI: menu.toggleTerminal", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.toggleWordWrap", + "description": "Windows UI: menu.toggleWordWrap", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.tools", + "description": "Windows UI: menu.tools", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.triggerParameterHints", + "description": "Windows UI: menu.triggerParameterHints", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.undo", + "description": "Windows UI: menu.undo", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.view", + "description": "Windows UI: menu.view", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.webInspector", + "description": "Windows UI: menu.webInspector", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.whatsNew", + "description": "Windows UI: menu.whatsNew", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.window", + "description": "Windows UI: menu.window", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.zoomIn", + "description": "Windows UI: menu.zoomIn", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.zoomOut", + "description": "Windows UI: menu.zoomOut", + "platforms": [ + "windows" + ] + }, + { + "id": "native.aChangeListWithThisNameAlreadyExists", + "description": "Native UI: A ChangeList with this name already exists.", + "platforms": [ + "macos" + ], + "macos": { + "key": "A ChangeList with this name already exists.", + "arguments": [] + } + }, + { + "id": "native.aCustomAgentCanFollowEitherYourCodexOrYourClaudeConfiguration", + "description": "Native UI: A custom Agent can follow either your Codex or your Claude configuration.", + "platforms": [ + "macos" + ], + "macos": { + "key": "A custom Agent can follow either your Codex or your Claude configuration.", + "arguments": [] + } + }, + { + "id": "native.aDetailedMessageWithAClearSubjectAndBody", + "description": "Native UI: A detailed message with a clear subject and body", + "platforms": [ + "macos" + ], + "macos": { + "key": "A detailed message with a clear subject and body", + "arguments": [] + } + }, + { + "id": "native.aMessageCreatesAnAnnotatedTag", + "description": "Native UI: A message creates an annotated tag.", + "platforms": [ + "macos" + ], + "macos": { + "key": "A message creates an annotated tag.", + "arguments": [] + } + }, + { + "id": "native.aMissingCheckoutCanMakeTheWorktreeListInaccurate", + "description": "Native UI: A missing checkout can make the worktree list inaccurate.", + "platforms": [ + "macos" + ], + "macos": { + "key": "A missing checkout can make the worktree list inaccurate.", + "arguments": [] + } + }, + { + "id": "native.aNewerVerifiedAdapterVersionIsAvailable", + "description": "Native UI: A newer verified adapter version is available.", + "platforms": [ + "macos" + ], + "macos": { + "key": "A newer verified adapter version is available.", + "arguments": [] + } + }, + { + "id": "native.aRebaseCannotStartWithAnyUncommittedChangesIncludingTheseUnrelatedTo", + "description": "Native UI: A rebase cannot start with any uncommitted changes, including these unrelated to '%@':", + "platforms": [ + "macos" + ], + "macos": { + "key": "A rebase cannot start with any uncommitted changes, including these unrelated to '%@':", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.aRunningLocalJVMAndItsJDKJstatToolAreRequired", + "description": "Native UI: A running local JVM and its JDK jstat tool are required.", + "platforms": [ + "macos" + ], + "macos": { + "key": "A running local JVM and its JDK jstat tool are required.", + "arguments": [] + } + }, + { + "id": "native.aSecureConnectionToGitHubCouldNotBeEstablishedCheckTLSInspectionProxyVPNOrSyst9ed7704d75", + "description": "Native UI: A secure connection to GitHub could not be established. Check TLS inspection, proxy, VPN, or system certificate settings.", + "platforms": [ + "macos" + ], + "macos": { + "key": "A secure connection to GitHub could not be established. Check TLS inspection, proxy, VPN, or system certificate settings.", + "arguments": [] + } + }, + { + "id": "native.aStableReleaseInstallationIsAlreadyInProgress", + "description": "Native UI: A stable release installation is already in progress.", + "platforms": [ + "macos" + ], + "macos": { + "key": "A stable release installation is already in progress.", + "arguments": [] + } + }, + { + "id": "native.aTaskStartedBeforeLitheLastClosedUnexpectedlyIsStillRunning", + "description": "Native UI: A task %@ started before Lithe last closed unexpectedly is still running", + "platforms": [ + "macos" + ], + "macos": { + "key": "A task %@ started before Lithe last closed unexpectedly is still running", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.aTerminalIsBusyWhenAProgramOtherThanTheShellStillRunsInItSuchAsASpringBootAppNa88ac3e46a", + "description": "Native UI: A terminal is busy when a program other than the shell still runs in it, such as a Spring Boot app, npm run dev or a job you sent to the background. Idle terminals always close without asking. Ending a terminal ends every program started in it.", + "platforms": [ + "macos" + ], + "macos": { + "key": "A terminal is busy when a program other than the shell still runs in it, such as a Spring Boot app, npm run dev or a job you sent to the background. Idle terminals always close without asking. Ending a terminal ends every program started in it.", + "arguments": [] + } + }, + { + "id": "native.aWorktreeOperationIsAlreadyRunningOrTheRepositoryIsUnavailable", + "description": "Native UI: A worktree operation is already running, or the repository is unavailable.", + "platforms": [ + "macos" + ], + "macos": { + "key": "A worktree operation is already running, or the repository is unavailable.", + "arguments": [] + } + }, + { + "id": "native.abort", + "description": "Native UI: Abort", + "platforms": [ + "macos" + ], + "macos": { + "key": "Abort", + "arguments": [] + } + }, + { + "id": "native.abort.variant2", + "description": "Native UI: Abort…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Abort…", + "arguments": [] + } + }, + { + "id": "native.abortRebase", + "description": "Native UI: Abort Rebase", + "platforms": [ + "macos" + ], + "macos": { + "key": "Abort Rebase", + "arguments": [] + } + }, + { + "id": "native.abortThisRebaseAndRestoreItsOriginalBranch", + "description": "Native UI: Abort this rebase and restore its original branch?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Abort this rebase and restore its original branch?", + "arguments": [] + } + }, + { + "id": "native.accept", + "description": "Native UI: Accept", + "platforms": [ + "macos" + ], + "macos": { + "key": "Accept", + "arguments": [] + } + }, + { + "id": "native.acceptsAllPermissions", + "description": "Native UI: Accepts all permissions", + "platforms": [ + "macos" + ], + "macos": { + "key": "Accepts all permissions", + "arguments": [] + } + }, + { + "id": "native.acpAdapter", + "description": "Native UI: ACP adapter", + "platforms": [ + "macos" + ], + "macos": { + "key": "ACP adapter", + "arguments": [] + } + }, + { + "id": "native.actionFor", + "description": "Native UI: Action for %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Action for %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.actions", + "description": "Native UI: Actions", + "platforms": [ + "macos" + ], + "macos": { + "key": "Actions", + "arguments": [] + } + }, + { + "id": "native.actionsFor", + "description": "Native UI: Actions for %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Actions for %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.actionsForFieldBreakpoint", + "description": "Native UI: Actions for %@ field breakpoint", + "platforms": [ + "macos" + ], + "macos": { + "key": "Actions for %@ field breakpoint", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.actionsForMethodBreakpoint", + "description": "Native UI: Actions for %@ method breakpoint", + "platforms": [ + "macos" + ], + "macos": { + "key": "Actions for %@ method breakpoint", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.activeMavenProfiles", + "description": "Native UI: Active Maven Profiles", + "platforms": [ + "macos" + ], + "macos": { + "key": "Active Maven Profiles", + "arguments": [] + } + }, + { + "id": "native.activeProfiles", + "description": "Native UI: Active profiles", + "platforms": [ + "macos" + ], + "macos": { + "key": "Active profiles", + "arguments": [] + } + }, + { + "id": "native.activity", + "description": "Native UI: Activity", + "platforms": [ + "macos" + ], + "macos": { + "key": "Activity", + "arguments": [] + } + }, + { + "id": "native.addAClassOrMethodName", + "description": "Native UI: Add a class or method name", + "platforms": [ + "macos" + ], + "macos": { + "key": "Add a class or method name", + "arguments": [] + } + }, + { + "id": "native.addAMemoryUsageIndicatorToTheStatusBar", + "description": "Native UI: Add a memory usage indicator to the status bar", + "platforms": [ + "macos" + ], + "macos": { + "key": "Add a memory usage indicator to the status bar", + "arguments": [] + } + }, + { + "id": "native.addAnAIProviderInAIProvidersFirst", + "description": "Native UI: Add an AI provider in AI Providers first.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Add an AI provider in AI Providers first.", + "arguments": [] + } + }, + { + "id": "native.addDatabaseConnection", + "description": "Native UI: Add database connection", + "platforms": [ + "macos" + ], + "macos": { + "key": "Add database connection", + "arguments": [] + } + }, + { + "id": "native.addHeader", + "description": "Native UI: Add header", + "platforms": [ + "macos" + ], + "macos": { + "key": "Add header", + "arguments": [] + } + }, + { + "id": "native.addMemoryUsageMonitoringTrackCurrentAndAverageMemoryUsageInTheStatusBar", + "description": "Native UI: Add memory usage monitoring\n\nTrack current and average memory usage in the status bar.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Add memory usage monitoring\n\nTrack current and average memory usage in the status bar.", + "arguments": [] + } + }, + { + "id": "native.addMemoryUsageVisibilityToTheStatusBar", + "description": "Native UI: Add memory usage visibility to the status bar", + "platforms": [ + "macos" + ], + "macos": { + "key": "Add memory usage visibility to the status bar", + "arguments": [] + } + }, + { + "id": "native.addMethodBreakpoint", + "description": "Native UI: Add method breakpoint", + "platforms": [ + "macos" + ], + "macos": { + "key": "Add method breakpoint", + "arguments": [] + } + }, + { + "id": "native.addOneHereOrRegisterAServerWithCodexOrClaudeCodeAndRefresh", + "description": "Native UI: Add one here, or register a server with Codex or Claude Code and refresh.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Add one here, or register a server with Codex or Claude Code and refresh.", + "arguments": [] + } + }, + { + "id": "native.addOrRemoveABreakpointAtTheCaret", + "description": "Native UI: Add or remove a breakpoint at the caret", + "platforms": [ + "macos" + ], + "macos": { + "key": "Add or remove a breakpoint at the caret", + "arguments": [] + } + }, + { + "id": "native.addProvider", + "description": "Native UI: Add Provider", + "platforms": [ + "macos" + ], + "macos": { + "key": "Add Provider", + "arguments": [] + } + }, + { + "id": "native.addProvider.variant2", + "description": "Native UI: Add provider", + "platforms": [ + "macos" + ], + "macos": { + "key": "Add provider", + "arguments": [] + } + }, + { + "id": "native.addServer", + "description": "Native UI: Add server", + "platforms": [ + "macos" + ], + "macos": { + "key": "Add server", + "arguments": [] + } + }, + { + "id": "native.addShortcut", + "description": "Native UI: Add Shortcut", + "platforms": [ + "macos" + ], + "macos": { + "key": "Add Shortcut", + "arguments": [] + } + }, + { + "id": "native.addTo", + "description": "Native UI: Add to %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Add to %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.addToAnotherConversation", + "description": "Native UI: Add to Another Conversation", + "platforms": [ + "macos" + ], + "macos": { + "key": "Add to Another Conversation", + "arguments": [] + } + }, + { + "id": "native.addToCurrentConversation", + "description": "Native UI: Add to Current Conversation · %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Add to Current Conversation · %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.addToFavorites", + "description": "Native UI: Add to favorites", + "platforms": [ + "macos" + ], + "macos": { + "key": "Add to favorites", + "arguments": [] + } + }, + { + "id": "native.addToolWindowsAndCommandsToTheWorkbench", + "description": "Native UI: Add tool windows and commands to the workbench", + "platforms": [ + "macos" + ], + "macos": { + "key": "Add tool windows and commands to the workbench", + "arguments": [] + } + }, + { + "id": "native.addVariable", + "description": "Native UI: Add variable", + "platforms": [ + "macos" + ], + "macos": { + "key": "Add variable", + "arguments": [] + } + }, + { + "id": "native.added", + "description": "Native UI: Added", + "platforms": [ + "macos" + ], + "macos": { + "key": "Added", + "arguments": [] + } + }, + { + "id": "native.addedByPlugins", + "description": "Native UI: Added by plugins", + "platforms": [ + "macos" + ], + "macos": { + "key": "Added by plugins", + "arguments": [] + } + }, + { + "id": "native.addedMemoryUsageVisibilityToTheStatusBar", + "description": "Native UI: Added memory usage visibility to the status bar.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Added memory usage visibility to the status bar.", + "arguments": [] + } + }, + { + "id": "native.additionalSearchPaths", + "description": "Native UI: Additional Search Paths", + "platforms": [ + "macos" + ], + "macos": { + "key": "Additional Search Paths", + "arguments": [] + } + }, + { + "id": "native.additions", + "description": "Native UI: Additions", + "platforms": [ + "macos" + ], + "macos": { + "key": "Additions", + "arguments": [] + } + }, + { + "id": "native.addsGoLanguageServerIntegrationFormattingRunningAndTestSupport", + "description": "Native UI: Adds Go language-server integration, formatting, running, and test support.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Adds Go language-server integration, formatting, running, and test support.", + "arguments": [] + } + }, + { + "id": "native.addsJavaLanguageIntelligenceThroughTheEclipseJDTLanguageServerWithLombokDebuggb2eae07f8b", + "description": "Native UI: Adds Java language intelligence through the Eclipse JDT language server, with Lombok, debugging, and test support. Ships its own Java runtime.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Adds Java language intelligence through the Eclipse JDT language server, with Lombok, debugging, and test support. Ships its own Java runtime.", + "arguments": [] + } + }, + { + "id": "native.addsLanguageServerIntegrationAndFormattingSupport", + "description": "Native UI: Adds language-server integration and formatting support.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Adds language-server integration and formatting support.", + "arguments": [] + } + }, + { + "id": "native.addsLanguageServerIntegrationFormattingRunningAndTestSupport", + "description": "Native UI: Adds language-server integration, formatting, running, and test support.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Adds language-server integration, formatting, running, and test support.", + "arguments": [] + } + }, + { + "id": "native.addsToThePullRequestConversationWithoutAnApprovalDecision", + "description": "Native UI: Adds to the pull request conversation without an approval decision.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Adds to the pull request conversation without an approval decision.", + "arguments": [] + } + }, + { + "id": "native.advancedConfigurationAndSources", + "description": "Native UI: Advanced configuration and sources", + "platforms": [ + "macos" + ], + "macos": { + "key": "Advanced configuration and sources", + "arguments": [] + } + }, + { + "id": "native.affectedHistoryOldestToNewest", + "description": "Native UI: Affected history · oldest to newest", + "platforms": [ + "macos" + ], + "macos": { + "key": "Affected history · oldest to newest", + "arguments": [] + } + }, + { + "id": "native.after", + "description": "Native UI: After", + "platforms": [ + "macos" + ], + "macos": { + "key": "After", + "arguments": [] + } + }, + { + "id": "native.afterLitheClosesUnexpectedly", + "description": "Native UI: After Lithe closes unexpectedly", + "platforms": [ + "macos" + ], + "macos": { + "key": "After Lithe closes unexpectedly", + "arguments": [] + } + }, + { + "id": "native.agent", + "description": "Native UI: Agent", + "platforms": [ + "macos" + ], + "macos": { + "key": "Agent", + "arguments": [] + } + }, + { + "id": "native.agentChangeRollbackIsUnavailable", + "description": "Native UI: Agent change rollback is unavailable.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Agent change rollback is unavailable.", + "arguments": [] + } + }, + { + "id": "native.agentCommands", + "description": "Native UI: Agent commands", + "platforms": [ + "macos" + ], + "macos": { + "key": "Agent commands", + "arguments": [] + } + }, + { + "id": "native.agentConfiguration", + "description": "Native UI: Agent configuration", + "platforms": [ + "macos" + ], + "macos": { + "key": "Agent configuration", + "arguments": [] + } + }, + { + "id": "native.agentConversation", + "description": "Native UI: Agent conversation", + "platforms": [ + "macos" + ], + "macos": { + "key": "Agent conversation", + "arguments": [] + } + }, + { + "id": "native.agentConversationIsTurnedOffTurnItOnInThePanelSettingsToSendMessages", + "description": "Native UI: Agent conversation is turned off. Turn it on in the panel settings to send messages.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Agent conversation is turned off. Turn it on in the panel settings to send messages.", + "arguments": [] + } + }, + { + "id": "native.agentExecutable", + "description": "Native UI: Agent executable", + "platforms": [ + "macos" + ], + "macos": { + "key": "Agent executable", + "arguments": [] + } + }, + { + "id": "native.agentList", + "description": "Native UI: Agent list", + "platforms": [ + "macos" + ], + "macos": { + "key": "Agent list", + "arguments": [] + } + }, + { + "id": "native.agentProvider", + "description": "Native UI: Agent provider", + "platforms": [ + "macos" + ], + "macos": { + "key": "Agent provider", + "arguments": [] + } + }, + { + "id": "native.agentReceived", + "description": "Native UI: Agent received", + "platforms": [ + "macos" + ], + "macos": { + "key": "Agent received", + "arguments": [] + } + }, + { + "id": "native.agentReportedChangesExcerptsMayNotIncludeTheEntireFile", + "description": "Native UI: Agent-reported changes; excerpts may not include the entire file.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Agent-reported changes; excerpts may not include the entire file.", + "arguments": [] + } + }, + { + "id": "native.agentSettings", + "description": "Native UI: Agent Settings", + "platforms": [ + "macos" + ], + "macos": { + "key": "Agent Settings", + "arguments": [] + } + }, + { + "id": "native.agentSettings.variant2", + "description": "Native UI: Agent Settings…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Agent Settings…", + "arguments": [] + } + }, + { + "id": "native.agentTasks", + "description": "Native UI: Agent tasks", + "platforms": [ + "macos" + ], + "macos": { + "key": "Agent tasks", + "arguments": [] + } + }, + { + "id": "native.agentTasksWillBeEndedWithoutAskingADeploymentOrMigrationThatIsStillRunningWillBeInterrupted", + "description": "Native UI: Agent tasks will be ended without asking. A deployment or migration that is still running will be interrupted.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Agent tasks will be ended without asking. A deployment or migration that is still running will be interrupted.", + "arguments": [] + } + }, + { + "id": "native.agents", + "description": "Native UI: Agents", + "platforms": [ + "macos" + ], + "macos": { + "key": "Agents", + "arguments": [] + } + }, + { + "id": "native.agentsMayRunDeploymentsMigrationsOrLongBuildsEndingOneHalfwayCanLeaveDataOrSer5ca5732b6c", + "description": "Native UI: Agents may run deployments, migrations or long builds. Ending one halfway can leave data or services inconsistent. Keep it if you are not sure what it does.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Agents may run deployments, migrations or long builds. Ending one halfway can leave data or services inconsistent. Keep it if you are not sure what it does.", + "arguments": [] + } + }, + { + "id": "native.agentsRunOnTheNodeJsInstalledOnThisMacInstallOrUpdateNodeJsYourselfThenCheckAgain", + "description": "Native UI: Agents run on the Node.js installed on this Mac. Install or update Node.js yourself, then check again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Agents run on the Node.js installed on this Mac. Install or update Node.js yourself, then check again.", + "arguments": [] + } + }, + { + "id": "native.ai", + "description": "Native UI: AI", + "platforms": [ + "macos" + ], + "macos": { + "key": "AI", + "arguments": [] + } + }, + { + "id": "native.aiChat", + "description": "Native UI: AI Chat", + "platforms": [ + "macos" + ], + "macos": { + "key": "AI Chat", + "arguments": [] + } + }, + { + "id": "native.aiCommit", + "description": "Native UI: AI & Commit", + "platforms": [ + "macos" + ], + "macos": { + "key": "AI & Commit", + "arguments": [] + } + }, + { + "id": "native.aiProvider", + "description": "Native UI: AI provider", + "platforms": [ + "macos" + ], + "macos": { + "key": "AI provider", + "arguments": [] + } + }, + { + "id": "native.aiProviders", + "description": "Native UI: AI Providers", + "platforms": [ + "macos" + ], + "macos": { + "key": "AI Providers", + "arguments": [] + } + }, + { + "id": "native.aiProviders.variant2", + "description": "Native UI: AI providers", + "platforms": [ + "macos" + ], + "macos": { + "key": "AI providers", + "arguments": [] + } + }, + { + "id": "native.aiSettingsAndCredentialsAreReadDirectlyFromItsLocalConfigurationFiles", + "description": "Native UI: AI settings and credentials are read directly from its local configuration files.", + "platforms": [ + "macos" + ], + "macos": { + "key": "AI settings and credentials are read directly from its local configuration files.", + "arguments": [] + } + }, + { + "id": "native.aiToolConnectionsMCP", + "description": "Native UI: AI tool connections (MCP)", + "platforms": [ + "macos" + ], + "macos": { + "key": "AI tool connections (MCP)", + "arguments": [] + } + }, + { + "id": "native.all", + "description": "Native UI: All", + "platforms": [ + "macos" + ], + "macos": { + "key": "All", + "arguments": [] + } + }, + { + "id": "native.allBranches", + "description": "Native UI: All Branches", + "platforms": [ + "macos" + ], + "macos": { + "key": "All Branches", + "arguments": [] + } + }, + { + "id": "native.allCompleted", + "description": "Native UI: All completed", + "platforms": [ + "macos" + ], + "macos": { + "key": "All completed", + "arguments": [] + } + }, + { + "id": "native.allConfigurations", + "description": "Native UI: All configurations", + "platforms": [ + "macos" + ], + "macos": { + "key": "All configurations", + "arguments": [] + } + }, + { + "id": "native.allConflictsResolvedContinueToFinishOrAbortToUndo", + "description": "Native UI: All conflicts resolved. Continue to finish, or abort to undo.", + "platforms": [ + "macos" + ], + "macos": { + "key": "All conflicts resolved. Continue to finish, or abort to undo.", + "arguments": [] + } + }, + { + "id": "native.allConversations", + "description": "Native UI: All conversations", + "platforms": [ + "macos" + ], + "macos": { + "key": "All conversations", + "arguments": [] + } + }, + { + "id": "native.allMessages", + "description": "Native UI: All messages", + "platforms": [ + "macos" + ], + "macos": { + "key": "All messages", + "arguments": [] + } + }, + { + "id": "native.allModules", + "description": "Native UI: All modules", + "platforms": [ + "macos" + ], + "macos": { + "key": "All modules", + "arguments": [] + } + }, + { + "id": "native.allProjects", + "description": "Native UI: All projects", + "platforms": [ + "macos" + ], + "macos": { + "key": "All projects", + "arguments": [] + } + }, + { + "id": "native.allProjectsProjectCanOverride", + "description": "Native UI: All projects · project can override", + "platforms": [ + "macos" + ], + "macos": { + "key": "All projects · project can override", + "arguments": [] + } + }, + { + "id": "native.allReferences", + "description": "Native UI: All References", + "platforms": [ + "macos" + ], + "macos": { + "key": "All References", + "arguments": [] + } + }, + { + "id": "native.allRepositories", + "description": "Native UI: All Repositories", + "platforms": [ + "macos" + ], + "macos": { + "key": "All Repositories", + "arguments": [] + } + }, + { + "id": "native.allServers", + "description": "Native UI: All servers", + "platforms": [ + "macos" + ], + "macos": { + "key": "All servers", + "arguments": [] + } + }, + { + "id": "native.allSessions", + "description": "Native UI: All sessions", + "platforms": [ + "macos" + ], + "macos": { + "key": "All sessions", + "arguments": [] + } + }, + { + "id": "native.allSeverities", + "description": "Native UI: All severities", + "platforms": [ + "macos" + ], + "macos": { + "key": "All severities", + "arguments": [] + } + }, + { + "id": "native.allTasksCompleted", + "description": "Native UI: All tasks completed", + "platforms": [ + "macos" + ], + "macos": { + "key": "All tasks completed", + "arguments": [] + } + }, + { + "id": "native.allTests", + "description": "Native UI: All tests: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "All tests: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.allUncommittedChanges", + "description": "Native UI: All uncommitted changes", + "platforms": [ + "macos" + ], + "macos": { + "key": "All uncommitted changes", + "arguments": [] + } + }, + { + "id": "native.allUsers", + "description": "Native UI: All Users", + "platforms": [ + "macos" + ], + "macos": { + "key": "All Users", + "arguments": [] + } + }, + { + "id": "native.allow", + "description": "Native UI: Allow", + "platforms": [ + "macos" + ], + "macos": { + "key": "Allow", + "arguments": [] + } + }, + { + "id": "native.allowFastForward", + "description": "Native UI: Allow fast-forward", + "platforms": [ + "macos" + ], + "macos": { + "key": "Allow fast-forward", + "arguments": [] + } + }, + { + "id": "native.allowInsecureHTTP", + "description": "Native UI: Allow insecure HTTP", + "platforms": [ + "macos" + ], + "macos": { + "key": "Allow insecure HTTP", + "arguments": [] + } + }, + { + "id": "native.allowProjectEnvironmentAndMavenConfigurationChanges", + "description": "Native UI: Allow project environment and Maven configuration changes", + "platforms": [ + "macos" + ], + "macos": { + "key": "Allow project environment and Maven configuration changes", + "arguments": [] + } + }, + { + "id": "native.allowRecreatingFile", + "description": "Native UI: Allow Recreating File", + "platforms": [ + "macos" + ], + "macos": { + "key": "Allow Recreating File", + "arguments": [] + } + }, + { + "id": "native.allowRunBuildReloadAndStopExecutesProjectCode", + "description": "Native UI: Allow run, build, reload and stop (executes project code)", + "platforms": [ + "macos" + ], + "macos": { + "key": "Allow run, build, reload and stop (executes project code)", + "arguments": [] + } + }, + { + "id": "native.allowTheAgentToContinue", + "description": "Native UI: Allow the Agent to continue?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Allow the Agent to continue?", + "arguments": [] + } + }, + { + "id": "native.allowToRunBuildTools", + "description": "Native UI: Allow “%@” to run build tools?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Allow “%@” to run build tools?", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.alwaysAskBeforeMakingChanges", + "description": "Native UI: Always ask before making changes", + "platforms": [ + "macos" + ], + "macos": { + "key": "Always ask before making changes", + "arguments": [] + } + }, + { + "id": "native.alwaysAskToEditExternalFilesAndUseTheInternet", + "description": "Native UI: Always ask to edit external files and use the internet", + "platforms": [ + "macos" + ], + "macos": { + "key": "Always ask to edit external files and use the internet", + "arguments": [] + } + }, + { + "id": "native.alwaysEnd", + "description": "Native UI: Always end", + "platforms": [ + "macos" + ], + "macos": { + "key": "Always end", + "arguments": [] + } + }, + { + "id": "native.alwaysKeep", + "description": "Native UI: Always keep", + "platforms": [ + "macos" + ], + "macos": { + "key": "Always keep", + "arguments": [] + } + }, + { + "id": "native.amend", + "description": "Native UI: Amend", + "platforms": [ + "macos" + ], + "macos": { + "key": "Amend", + "arguments": [] + } + }, + { + "id": "native.amendAndContinue", + "description": "Native UI: Amend and Continue", + "platforms": [ + "macos" + ], + "macos": { + "key": "Amend and Continue", + "arguments": [] + } + }, + { + "id": "native.amendAndContinue.variant2", + "description": "Native UI: Amend and Continue…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Amend and Continue…", + "arguments": [] + } + }, + { + "id": "native.amendAppliesToRepositoriesWithSelectedFiles", + "description": "Native UI: Amend applies to repositories with selected files.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Amend applies to repositories with selected files.", + "arguments": [] + } + }, + { + "id": "native.anAgentStartsWhenYouOpenThisPanelInAProjectTurningThisOffStopsEveryAgentProcess", + "description": "Native UI: An Agent starts when you open this panel in a project. Turning this off stops every Agent process.", + "platforms": [ + "macos" + ], + "macos": { + "key": "An Agent starts when you open this panel in a project. Turning this off stops every Agent process.", + "arguments": [] + } + }, + { + "id": "native.anUpdateIsAvailable", + "description": "Native UI: An update is available.", + "platforms": [ + "macos" + ], + "macos": { + "key": "An update is available.", + "arguments": [] + } + }, + { + "id": "native.annotateWithGitBlame", + "description": "Native UI: Annotate with Git Blame", + "platforms": [ + "macos" + ], + "macos": { + "key": "Annotate with Git Blame", + "arguments": [] + } + }, + { + "id": "native.anotherGlobalEntryUsesThisNameWithADifferentDefinition", + "description": "Native UI: Another global entry uses this name with a different definition.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Another global entry uses this name with a different definition.", + "arguments": [] + } + }, + { + "id": "native.anyTime", + "description": "Native UI: Any Time", + "platforms": [ + "macos" + ], + "macos": { + "key": "Any Time", + "arguments": [] + } + }, + { + "id": "native.apiKey", + "description": "Native UI: API key", + "platforms": [ + "macos" + ], + "macos": { + "key": "API key", + "arguments": [] + } + }, + { + "id": "native.apiKeyAvailableInClaudeConfiguration", + "description": "Native UI: API key available in Claude configuration", + "platforms": [ + "macos" + ], + "macos": { + "key": "API key available in Claude configuration", + "arguments": [] + } + }, + { + "id": "native.apiKeyAvailableInCodexAuthJson", + "description": "Native UI: API key available in Codex auth.json", + "platforms": [ + "macos" + ], + "macos": { + "key": "API key available in Codex auth.json", + "arguments": [] + } + }, + { + "id": "native.apiKeyIsManagedByClaudeConfiguration", + "description": "Native UI: API key is managed by Claude configuration", + "platforms": [ + "macos" + ], + "macos": { + "key": "API key is managed by Claude configuration", + "arguments": [] + } + }, + { + "id": "native.apiKeyIsManagedByCodexConfiguration", + "description": "Native UI: API key is managed by Codex configuration", + "platforms": [ + "macos" + ], + "macos": { + "key": "API key is managed by Codex configuration", + "arguments": [] + } + }, + { + "id": "native.apiKeyOrToken", + "description": "Native UI: API key or token", + "platforms": [ + "macos" + ], + "macos": { + "key": "API key or token", + "arguments": [] + } + }, + { + "id": "native.apiKeySavedLocally", + "description": "Native UI: API key saved locally", + "platforms": [ + "macos" + ], + "macos": { + "key": "API key saved locally", + "arguments": [] + } + }, + { + "id": "native.apiKeysAreStoredInLitheSLocalApplicationDataAndAreNeverWrittenToLitheSettings", + "description": "Native UI: API keys are stored in Lithe's local application data and are never written to Lithe settings.", + "platforms": [ + "macos" + ], + "macos": { + "key": "API keys are stored in Lithe's local application data and are never written to Lithe settings.", + "arguments": [] + } + }, + { + "id": "native.apiProtocol", + "description": "Native UI: API protocol", + "platforms": [ + "macos" + ], + "macos": { + "key": "API protocol", + "arguments": [] + } + }, + { + "id": "native.apiRequestURL", + "description": "Native UI: API request URL", + "platforms": [ + "macos" + ], + "macos": { + "key": "API request URL", + "arguments": [] + } + }, + { + "id": "native.apiURL", + "description": "Native UI: API URL", + "platforms": [ + "macos" + ], + "macos": { + "key": "API URL", + "arguments": [] + } + }, + { + "id": "native.appearanceBehavior", + "description": "Native UI: Appearance & Behavior", + "platforms": [ + "macos" + ], + "macos": { + "key": "Appearance & Behavior", + "arguments": [] + } + }, + { + "id": "native.appearanceMode", + "description": "Native UI: Appearance mode", + "platforms": [ + "macos" + ], + "macos": { + "key": "Appearance mode", + "arguments": [] + } + }, + { + "id": "native.application", + "description": "Native UI: Application", + "platforms": [ + "macos" + ], + "macos": { + "key": "Application", + "arguments": [] + } + }, + { + "id": "native.applicationDefault", + "description": "Native UI: Application default", + "platforms": [ + "macos" + ], + "macos": { + "key": "Application default", + "arguments": [] + } + }, + { + "id": "native.applicationMemory", + "description": "Native UI: Application Memory", + "platforms": [ + "macos" + ], + "macos": { + "key": "Application Memory", + "arguments": [] + } + }, + { + "id": "native.applicationTypes", + "description": "Native UI: Application types", + "platforms": [ + "macos" + ], + "macos": { + "key": "Application types", + "arguments": [] + } + }, + { + "id": "native.applicationVersion", + "description": "Native UI: Application version", + "platforms": [ + "macos" + ], + "macos": { + "key": "Application version", + "arguments": [] + } + }, + { + "id": "native.applicationWideEditorAndTerminalSettingsRemainAvailableInTheOtherCategories", + "description": "Native UI: Application-wide editor and terminal settings remain available in the other categories.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Application-wide editor and terminal settings remain available in the other categories.", + "arguments": [] + } + }, + { + "id": "native.applications", + "description": "Native UI: Applications", + "platforms": [ + "macos" + ], + "macos": { + "key": "Applications", + "arguments": [] + } + }, + { + "id": "native.appliesImmediately", + "description": "Native UI: Applies immediately", + "platforms": [ + "macos" + ], + "macos": { + "key": "Applies immediately", + "arguments": [] + } + }, + { + "id": "native.appliesTheNextTimeTheLanguageServerStarts", + "description": "Native UI: Applies the next time the language server starts.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Applies the next time the language server starts.", + "arguments": [] + } + }, + { + "id": "native.applyAIGeneratedContent", + "description": "Native UI: Apply AI-generated content?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Apply AI-generated content?", + "arguments": [] + } + }, + { + "id": "native.applyPatch", + "description": "Native UI: Apply Patch", + "platforms": [ + "macos" + ], + "macos": { + "key": "Apply Patch", + "arguments": [] + } + }, + { + "id": "native.applyPatch.variant2", + "description": "Native UI: Apply Patch…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Apply Patch…", + "arguments": [] + } + }, + { + "id": "native.applyTo", + "description": "Native UI: Apply to", + "platforms": [ + "macos" + ], + "macos": { + "key": "Apply to", + "arguments": [] + } + }, + { + "id": "native.applyToTheCurrentBranch", + "description": "Native UI: Apply %@ to the current branch.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Apply %@ to the current branch.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.approvalMode", + "description": "Native UI: Approval mode", + "platforms": [ + "macos" + ], + "macos": { + "key": "Approval mode", + "arguments": [] + } + }, + { + "id": "native.approve", + "description": "Native UI: Approve", + "platforms": [ + "macos" + ], + "macos": { + "key": "Approve", + "arguments": [] + } + }, + { + "id": "native.approveForMe", + "description": "Native UI: Approve for me", + "platforms": [ + "macos" + ], + "macos": { + "key": "Approve for me", + "arguments": [] + } + }, + { + "id": "native.approvePullRequest", + "description": "Native UI: Approve pull request", + "platforms": [ + "macos" + ], + "macos": { + "key": "Approve pull request", + "arguments": [] + } + }, + { + "id": "native.archiveSHA256", + "description": "Native UI: Archive SHA-256", + "platforms": [ + "macos" + ], + "macos": { + "key": "Archive SHA-256", + "arguments": [] + } + }, + { + "id": "native.archived", + "description": "Native UI: Archived", + "platforms": [ + "macos" + ], + "macos": { + "key": "Archived", + "arguments": [] + } + }, + { + "id": "native.archivedD", + "description": "Native UI: Archived (%d)", + "platforms": [ + "macos" + ], + "macos": { + "key": "Archived (%d)", + "arguments": [ + "arg1" + ], + "formats": { + "arg1": "d" + } + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.arguments", + "description": "Native UI: Arguments", + "platforms": [ + "macos" + ], + "macos": { + "key": "Arguments", + "arguments": [] + } + }, + { + "id": "native.argumentsFromThePage", + "description": "Native UI: Arguments from the page:", + "platforms": [ + "macos" + ], + "macos": { + "key": "Arguments from the page:", + "arguments": [] + } + }, + { + "id": "native.argumentsOnePerLine", + "description": "Native UI: Arguments (one per line)", + "platforms": [ + "macos" + ], + "macos": { + "key": "Arguments (one per line)", + "arguments": [] + } + }, + { + "id": "native.argumentsPassedToTheProgram", + "description": "Native UI: Arguments passed to the program", + "platforms": [ + "macos" + ], + "macos": { + "key": "Arguments passed to the program", + "arguments": [] + } + }, + { + "id": "native.askBeforeClosingATabWithRunningPrograms", + "description": "Native UI: Ask before closing a tab with running programs", + "platforms": [ + "macos" + ], + "macos": { + "key": "Ask before closing a tab with running programs", + "arguments": [] + } + }, + { + "id": "native.askBeforeStopping", + "description": "Native UI: Ask before stopping", + "platforms": [ + "macos" + ], + "macos": { + "key": "Ask before stopping", + "arguments": [] + } + }, + { + "id": "native.askEveryTime", + "description": "Native UI: Ask every time", + "platforms": [ + "macos" + ], + "macos": { + "key": "Ask every time", + "arguments": [] + } + }, + { + "id": "native.askForApproval", + "description": "Native UI: Ask for approval", + "platforms": [ + "macos" + ], + "macos": { + "key": "Ask for approval", + "arguments": [] + } + }, + { + "id": "native.askForEachTask", + "description": "Native UI: Ask for each task", + "platforms": [ + "macos" + ], + "macos": { + "key": "Ask for each task", + "arguments": [] + } + }, + { + "id": "native.askForEachTerminal", + "description": "Native UI: Ask for each terminal", + "platforms": [ + "macos" + ], + "macos": { + "key": "Ask for each terminal", + "arguments": [] + } + }, + { + "id": "native.askInOneList", + "description": "Native UI: Ask in one list", + "platforms": [ + "macos" + ], + "macos": { + "key": "Ask in one list", + "arguments": [] + } + }, + { + "id": "native.askOnNextLaunch", + "description": "Native UI: Ask on next launch", + "platforms": [ + "macos" + ], + "macos": { + "key": "Ask on next launch", + "arguments": [] + } + }, + { + "id": "native.askTheAgentAboutThisWorkspace", + "description": "Native UI: Ask the Agent about this workspace", + "platforms": [ + "macos" + ], + "macos": { + "key": "Ask the Agent about this workspace", + "arguments": [] + } + }, + { + "id": "native.askTheAgentEnterToSendShiftEnterForANewLine", + "description": "Native UI: Ask the Agent, Enter to send, Shift-Enter for a new line", + "platforms": [ + "macos" + ], + "macos": { + "key": "Ask the Agent, Enter to send, Shift-Enter for a new line", + "arguments": [] + } + }, + { + "id": "native.assignees", + "description": "Native UI: Assignees", + "platforms": [ + "macos" + ], + "macos": { + "key": "Assignees", + "arguments": [] + } + }, + { + "id": "native.astroSupport", + "description": "Native UI: Astro Support", + "platforms": [ + "macos" + ], + "macos": { + "key": "Astro Support", + "arguments": [] + } + }, + { + "id": "native.attach", + "description": "Native UI: Attach", + "platforms": [ + "macos" + ], + "macos": { + "key": "Attach", + "arguments": [] + } + }, + { + "id": "native.attachFile", + "description": "Native UI: Attach file", + "platforms": [ + "macos" + ], + "macos": { + "key": "Attach file", + "arguments": [] + } + }, + { + "id": "native.attachFiles", + "description": "Native UI: Attach files", + "platforms": [ + "macos" + ], + "macos": { + "key": "Attach files", + "arguments": [] + } + }, + { + "id": "native.attachToARemoteJVMOrTomcat", + "description": "Native UI: Attach to a remote JVM or Tomcat", + "platforms": [ + "macos" + ], + "macos": { + "key": "Attach to a remote JVM or Tomcat", + "arguments": [] + } + }, + { + "id": "native.attachUpTo32FilesPerMessage", + "description": "Native UI: Attach up to 32 files per message.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Attach up to 32 files per message.", + "arguments": [] + } + }, + { + "id": "native.attachmentsAreNotSupportedYet", + "description": "Native UI: Attachments are not supported yet", + "platforms": [ + "macos" + ], + "macos": { + "key": "Attachments are not supported yet", + "arguments": [] + } + }, + { + "id": "native.authenticationMustBeAJSONObjectContainingAnOPENAIAPIKEYString", + "description": "Native UI: Authentication must be a JSON object containing an OPENAI_API_KEY string.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Authentication must be a JSON object containing an OPENAI_API_KEY string.", + "arguments": [] + } + }, + { + "id": "native.author", + "description": "Native UI: Author", + "platforms": [ + "macos" + ], + "macos": { + "key": "Author", + "arguments": [] + } + }, + { + "id": "native.authorizeInYourBrowser", + "description": "Native UI: Authorize in your browser", + "platforms": [ + "macos" + ], + "macos": { + "key": "Authorize in your browser", + "arguments": [] + } + }, + { + "id": "native.authorizeThisProjectToExposeEnvironmentMavenRunConfigurationsAndOutputToConnec6e24e4763f", + "description": "Native UI: 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.", + "platforms": [ + "macos" + ], + "macos": { + "key": "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.", + "arguments": [] + } + }, + { + "id": "native.authorizeThisProjectToExposeEnvironmentMavenRunConfigurationsAndOutputToConnecb6f617e865", + "description": "Native UI: 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. Enabled plugins can also use this project grant.", + "platforms": [ + "macos" + ], + "macos": { + "key": "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. Enabled plugins can also use this project grant.", + "arguments": [] + } + }, + { + "id": "native.authorizeThisProjectToExposeEnvironmentMavenRunConfigurationsAndOutputToConnece4d5e32bce", + "description": "Native UI: 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, and the project-level server registration is removed. Enabled plugins can also use this project grant.", + "platforms": [ + "macos" + ], + "macos": { + "key": "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, and the project-level server registration is removed. Enabled plugins can also use this project grant.", + "arguments": [] + } + }, + { + "id": "native.autobuildBuiltAt", + "description": "Native UI: AutoBuild — Built at %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "AutoBuild — Built at %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.autobuildIsRebuiltNightlyAndMayContainUnfinishedFeaturesSeriousBugsOrOtherProbd0994a4a11", + "description": "Native UI: AutoBuild is rebuilt nightly and may contain unfinished features, serious bugs, or other problems. Stable releases are recommended for most users.", + "platforms": [ + "macos" + ], + "macos": { + "key": "AutoBuild is rebuilt nightly and may contain unfinished features, serious bugs, or other problems. Stable releases are recommended for most users.", + "arguments": [] + } + }, + { + "id": "native.autobuildRolling", + "description": "Native UI: AutoBuild Rolling", + "platforms": [ + "macos" + ], + "macos": { + "key": "AutoBuild Rolling", + "arguments": [] + } + }, + { + "id": "native.autobuildUpdate", + "description": "Native UI: AutoBuild Update", + "platforms": [ + "macos" + ], + "macos": { + "key": "AutoBuild Update", + "arguments": [] + } + }, + { + "id": "native.automatic", + "description": "Native UI: Automatic", + "platforms": [ + "macos" + ], + "macos": { + "key": "Automatic", + "arguments": [] + } + }, + { + "id": "native.automaticQueryLldIdenticalResults", + "description": "Native UI: Automatic query ×%lld, identical results", + "platforms": [ + "macos" + ], + "macos": { + "key": "Automatic query ×%lld, identical results", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.automaticUsesAProjectMvnwFirstThenDetectedMavenThenMvnd", + "description": "Native UI: Automatic uses a project mvnw first, then detected Maven, then mvnd.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Automatic uses a project mvnw first, then detected Maven, then mvnd.", + "arguments": [] + } + }, + { + "id": "native.automaticallyAcceptAllFileEdits", + "description": "Native UI: Automatically accept all file edits", + "platforms": [ + "macos" + ], + "macos": { + "key": "Automatically accept all file edits", + "arguments": [] + } + }, + { + "id": "native.automaticallyGenerated", + "description": "Native UI: Automatically generated", + "platforms": [ + "macos" + ], + "macos": { + "key": "Automatically generated", + "arguments": [] + } + }, + { + "id": "native.automaticallyIdentified", + "description": "Native UI: Automatically identified", + "platforms": [ + "macos" + ], + "macos": { + "key": "Automatically identified", + "arguments": [] + } + }, + { + "id": "native.available", + "description": "Native UI: Available", + "platforms": [ + "macos" + ], + "macos": { + "key": "Available", + "arguments": [] + } + }, + { + "id": "native.availableToAgents", + "description": "Native UI: Available to Agents", + "platforms": [ + "macos" + ], + "macos": { + "key": "Available to Agents", + "arguments": [] + } + }, + { + "id": "native.averageSinceLaunch", + "description": "Native UI: Average since launch", + "platforms": [ + "macos" + ], + "macos": { + "key": "Average since launch", + "arguments": [] + } + }, + { + "id": "native.averageTimeToFirstTokenTTFT", + "description": "Native UI: Average time to first token (TTFT)", + "platforms": [ + "macos" + ], + "macos": { + "key": "Average time to first token (TTFT)", + "arguments": [] + } + }, + { + "id": "native.averageTotal", + "description": "Native UI: Average total", + "platforms": [ + "macos" + ], + "macos": { + "key": "Average total", + "arguments": [] + } + }, + { + "id": "native.avg", + "description": "Native UI: avg", + "platforms": [ + "macos" + ], + "macos": { + "key": "avg", + "arguments": [] + } + }, + { + "id": "native.backToAllReferences", + "description": "Native UI: Back to all references", + "platforms": [ + "macos" + ], + "macos": { + "key": "Back to all references", + "arguments": [] + } + }, + { + "id": "native.backToTheConversation", + "description": "Native UI: Back to the conversation", + "platforms": [ + "macos" + ], + "macos": { + "key": "Back to the conversation", + "arguments": [] + } + }, + { + "id": "native.backgroundCommandEndedExitCodeArg1Arg2", + "description": "Native UI: Background command ended (exit code %@): %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Background command ended (exit code %@): %@", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string" + } + }, + { + "id": "native.backgroundCommandFinished", + "description": "Native UI: Background command finished: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Background command finished: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.backgroundServices", + "description": "Native UI: Background Services", + "platforms": [ + "macos" + ], + "macos": { + "key": "Background Services", + "arguments": [] + } + }, + { + "id": "native.backgroundServices.variant2", + "description": "Native UI: Background services", + "platforms": [ + "macos" + ], + "macos": { + "key": "Background services", + "arguments": [] + } + }, + { + "id": "native.backgroundServicesStartedInLitheAreStillRunning", + "description": "Native UI: Background services started in Lithe are still running", + "platforms": [ + "macos" + ], + "macos": { + "key": "Background services started in Lithe are still running", + "arguments": [] + } + }, + { + "id": "native.balancedReasoning", + "description": "Native UI: Balanced reasoning", + "platforms": [ + "macos" + ], + "macos": { + "key": "Balanced reasoning", + "arguments": [] + } + }, + { + "id": "native.base", + "description": "Native UI: Base", + "platforms": [ + "macos" + ], + "macos": { + "key": "Base", + "arguments": [] + } + }, + { + "id": "native.base.variant2", + "description": "Native UI: Base: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Base: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.baseBranch", + "description": "Native UI: Base branch", + "platforms": [ + "macos" + ], + "macos": { + "key": "Base branch", + "arguments": [] + } + }, + { + "id": "native.baseTarget", + "description": "Native UI: Base %@ → Target %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Base %@ → Target %@", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string" + } + }, + { + "id": "native.baseURLForAServiceCompatibleWithTheOpenAIResponsesAPI", + "description": "Native UI: Base URL for a service compatible with the OpenAI Responses API.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Base URL for a service compatible with the OpenAI Responses API.", + "arguments": [] + } + }, + { + "id": "native.baseURLForAnAnthropicMessagesAPIService", + "description": "Native UI: Base URL for an Anthropic Messages API service.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Base URL for an Anthropic Messages API service.", + "arguments": [] + } + }, + { + "id": "native.basicInformation", + "description": "Native UI: Basic Information", + "platforms": [ + "macos" + ], + "macos": { + "key": "Basic Information", + "arguments": [] + } + }, + { + "id": "native.basicLatinLetters", + "description": "Native UI: basic Latin letters", + "platforms": [ + "macos" + ], + "macos": { + "key": "basic Latin letters", + "arguments": [] + } + }, + { + "id": "native.before", + "description": "Native UI: Before", + "platforms": [ + "macos" + ], + "macos": { + "key": "Before", + "arguments": [] + } + }, + { + "id": "native.beforeDeletion", + "description": "Native UI: Before deletion", + "platforms": [ + "macos" + ], + "macos": { + "key": "Before deletion", + "arguments": [] + } + }, + { + "id": "native.beforeProjectReplacement", + "description": "Native UI: Before project replacement", + "platforms": [ + "macos" + ], + "macos": { + "key": "Before project replacement", + "arguments": [] + } + }, + { + "id": "native.beforeRename", + "description": "Native UI: Before rename", + "platforms": [ + "macos" + ], + "macos": { + "key": "Before rename", + "arguments": [] + } + }, + { + "id": "native.beforeRestore", + "description": "Native UI: Before restore", + "platforms": [ + "macos" + ], + "macos": { + "key": "Before restore", + "arguments": [] + } + }, + { + "id": "native.branch", + "description": "Native UI: Branch", + "platforms": [ + "macos" + ], + "macos": { + "key": "Branch", + "arguments": [] + } + }, + { + "id": "native.branch.variant2", + "description": "Native UI: Branch:", + "platforms": [ + "macos" + ], + "macos": { + "key": "Branch:", + "arguments": [] + } + }, + { + "id": "native.branch.variant3", + "description": "Native UI: Branch: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Branch: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.branchContext", + "description": "Native UI: Branch context", + "platforms": [ + "macos" + ], + "macos": { + "key": "Branch context", + "arguments": [] + } + }, + { + "id": "native.branchName", + "description": "Native UI: Branch name", + "platforms": [ + "macos" + ], + "macos": { + "key": "Branch name", + "arguments": [] + } + }, + { + "id": "native.branchPublishedToGitHub", + "description": "Native UI: Branch published to GitHub", + "platforms": [ + "macos" + ], + "macos": { + "key": "Branch published to GitHub", + "arguments": [] + } + }, + { + "id": "native.branchTracksWhichHasADifferentNameChooseWhichBranchToUpdate", + "description": "Native UI: Branch '%@' tracks '%@', which has a different name. Choose which branch to update.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Branch '%@' tracks '%@', which has a different name. Choose which branch to update.", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string" + } + }, + { + "id": "native.branches", + "description": "Native UI: Branches", + "platforms": [ + "macos" + ], + "macos": { + "key": "Branches", + "arguments": [] + } + }, + { + "id": "native.branchesHaveDiverged", + "description": "Native UI: Branches have diverged", + "platforms": [ + "macos" + ], + "macos": { + "key": "Branches have diverged", + "arguments": [] + } + }, + { + "id": "native.branchesMustBeDifferent", + "description": "Native UI: Branches must be different", + "platforms": [ + "macos" + ], + "macos": { + "key": "Branches must be different", + "arguments": [] + } + }, + { + "id": "native.branchesUnavailable", + "description": "Native UI: Branches unavailable", + "platforms": [ + "macos" + ], + "macos": { + "key": "Branches unavailable", + "arguments": [] + } + }, + { + "id": "native.breakdownUnavailable", + "description": "Native UI: Breakdown unavailable", + "platforms": [ + "macos" + ], + "macos": { + "key": "Breakdown unavailable", + "arguments": [] + } + }, + { + "id": "native.breakpointActions", + "description": "Native UI: Breakpoint actions", + "platforms": [ + "macos" + ], + "macos": { + "key": "Breakpoint actions", + "arguments": [] + } + }, + { + "id": "native.breakpoints", + "description": "Native UI: Breakpoints", + "platforms": [ + "macos" + ], + "macos": { + "key": "Breakpoints", + "arguments": [] + } + }, + { + "id": "native.bugReport", + "description": "Native UI: Bug report", + "platforms": [ + "macos" + ], + "macos": { + "key": "Bug report", + "arguments": [] + } + }, + { + "id": "native.build", + "description": "Native UI: Build", + "platforms": [ + "macos" + ], + "macos": { + "key": "Build", + "arguments": [] + } + }, + { + "id": "native.build.variant2", + "description": "Native UI: Build %@ → %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Build %@ → %@", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string" + } + }, + { + "id": "native.buildExecutionDeployment", + "description": "Native UI: Build, Execution, Deployment", + "platforms": [ + "macos" + ], + "macos": { + "key": "Build, Execution, Deployment", + "arguments": [] + } + }, + { + "id": "native.buildFilesChanged", + "description": "Native UI: %@ build files changed", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ build files changed", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.buildFilesChanged.variant2", + "description": "Native UI: Build files changed.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Build files changed.", + "arguments": [] + } + }, + { + "id": "native.buildFilesChangedReloadTheModelToPickUpTheChanges", + "description": "Native UI: Build files changed. Reload the model to pick up the changes.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Build files changed. Reload the model to pick up the changes.", + "arguments": [] + } + }, + { + "id": "native.buildOutput", + "description": "Native UI: Build Output", + "platforms": [ + "macos" + ], + "macos": { + "key": "Build Output", + "arguments": [] + } + }, + { + "id": "native.buildOutput.variant2", + "description": "Native UI: Build output", + "platforms": [ + "macos" + ], + "macos": { + "key": "Build output", + "arguments": [] + } + }, + { + "id": "native.buildOutputs", + "description": "Native UI: Build Outputs", + "platforms": [ + "macos" + ], + "macos": { + "key": "Build Outputs", + "arguments": [] + } + }, + { + "id": "native.buildRunAndDebug", + "description": "Native UI: Build, run and debug", + "platforms": [ + "macos" + ], + "macos": { + "key": "Build, run and debug", + "arguments": [] + } + }, + { + "id": "native.buildSystem", + "description": "Native UI: Build System", + "platforms": [ + "macos" + ], + "macos": { + "key": "Build System", + "arguments": [] + } + }, + { + "id": "native.buildsProjects", + "description": "Native UI: Builds %@ projects", + "platforms": [ + "macos" + ], + "macos": { + "key": "Builds %@ projects", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.built", + "description": "Native UI: Built %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Built %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.builtIn", + "description": "Native UI: Built-in", + "platforms": [ + "macos" + ], + "macos": { + "key": "Built-in", + "arguments": [] + } + }, + { + "id": "native.builtInBackgrounds", + "description": "Native UI: Built-in backgrounds", + "platforms": [ + "macos" + ], + "macos": { + "key": "Built-in backgrounds", + "arguments": [] + } + }, + { + "id": "native.cCObjectiveCSupport", + "description": "Native UI: C/C++/Objective-C Support", + "platforms": [ + "macos" + ], + "macos": { + "key": "C/C++/Objective-C Support", + "arguments": [] + } + }, + { + "id": "native.cSupport", + "description": "Native UI: C# Support", + "platforms": [ + "macos" + ], + "macos": { + "key": "C# Support", + "arguments": [] + } + }, + { + "id": "native.cacheHit", + "description": "Native UI: Cache hit", + "platforms": [ + "macos" + ], + "macos": { + "key": "Cache hit", + "arguments": [] + } + }, + { + "id": "native.cacheHit.variant2", + "description": "Native UI: Cache hit %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Cache hit %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.cacheRead", + "description": "Native UI: Cache read", + "platforms": [ + "macos" + ], + "macos": { + "key": "Cache read", + "arguments": [] + } + }, + { + "id": "native.cacheReadTokens", + "description": "Native UI: Cache read tokens: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Cache read tokens: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.cacheWrite", + "description": "Native UI: Cache write", + "platforms": [ + "macos" + ], + "macos": { + "key": "Cache write", + "arguments": [] + } + }, + { + "id": "native.cacheWriteTokens", + "description": "Native UI: Cache write tokens: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Cache write tokens: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.callStack", + "description": "Native UI: Call Stack", + "platforms": [ + "macos" + ], + "macos": { + "key": "Call Stack", + "arguments": [] + } + }, + { + "id": "native.cameraAndMicrophoneInUse", + "description": "Native UI: Camera and microphone in use", + "platforms": [ + "macos" + ], + "macos": { + "key": "Camera and microphone in use", + "arguments": [] + } + }, + { + "id": "native.cameraInUse", + "description": "Native UI: Camera in use", + "platforms": [ + "macos" + ], + "macos": { + "key": "Camera in use", + "arguments": [] + } + }, + { + "id": "native.cancelQuit", + "description": "Native UI: Cancel Quit", + "platforms": [ + "macos" + ], + "macos": { + "key": "Cancel Quit", + "arguments": [] + } + }, + { + "id": "native.cancelRunningGitOperations", + "description": "Native UI: Cancel running Git operations", + "platforms": [ + "macos" + ], + "macos": { + "key": "Cancel running Git operations", + "arguments": [] + } + }, + { + "id": "native.cancelled", + "description": "Native UI: Cancelled", + "platforms": [ + "macos" + ], + "macos": { + "key": "Cancelled", + "arguments": [] + } + }, + { + "id": "native.cannotBeUsed", + "description": "Native UI: Cannot be used: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Cannot be used: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.cannotCopyADirectoryIntoItself", + "description": "Native UI: Cannot copy a directory into itself", + "platforms": [ + "macos" + ], + "macos": { + "key": "Cannot copy a directory into itself", + "arguments": [] + } + }, + { + "id": "native.cannotCreateANewConversation", + "description": "Native UI: Cannot create a new conversation: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Cannot create a new conversation: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.cannotUseUsingTheProjectJDKInstead", + "description": "Native UI: Cannot use %@; using the project JDK instead.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Cannot use %@; using the project JDK instead.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.category", + "description": "Native UI: Category", + "platforms": [ + "macos" + ], + "macos": { + "key": "Category", + "arguments": [] + } + }, + { + "id": "native.changeTheValuesStoredInTheSelectedScopeChooseInheritToRemoveAnOverride", + "description": "Native UI: Change the values stored in the selected scope. Choose Inherit to remove an override.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Change the values stored in the selected scope. Choose Inherit to remove an override.", + "arguments": [] + } + }, + { + "id": "native.changeWorkbenchBackground", + "description": "Native UI: Change workbench background", + "platforms": [ + "macos" + ], + "macos": { + "key": "Change workbench background", + "arguments": [] + } + }, + { + "id": "native.changed", + "description": "Native UI: Changed", + "platforms": [ + "macos" + ], + "macos": { + "key": "Changed", + "arguments": [] + } + }, + { + "id": "native.changedFiles", + "description": "Native UI: Changed Files", + "platforms": [ + "macos" + ], + "macos": { + "key": "Changed Files", + "arguments": [] + } + }, + { + "id": "native.changedOutsideLitheRefreshAndTryAgain", + "description": "Native UI: “%@” changed outside Lithe. Refresh and try again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "“%@” changed outside Lithe. Refresh and try again.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.changelist", + "description": "Native UI: ChangeList", + "platforms": [ + "macos" + ], + "macos": { + "key": "ChangeList", + "arguments": [] + } + }, + { + "id": "native.changelistEditingIsUnavailable", + "description": "Native UI: ChangeList editing is unavailable.", + "platforms": [ + "macos" + ], + "macos": { + "key": "ChangeList editing is unavailable.", + "arguments": [] + } + }, + { + "id": "native.changelistName", + "description": "Native UI: ChangeList name", + "platforms": [ + "macos" + ], + "macos": { + "key": "ChangeList name", + "arguments": [] + } + }, + { + "id": "native.changelistStorageIsUnavailableRestoreTheSavedDataAndReopenTheWorkspace", + "description": "Native UI: ChangeList storage is unavailable. Restore the saved data and reopen the workspace.", + "platforms": [ + "macos" + ], + "macos": { + "key": "ChangeList storage is unavailable. Restore the saved data and reopen the workspace.", + "arguments": [] + } + }, + { + "id": "native.changes", + "description": "Native UI: Changes", + "platforms": [ + "macos" + ], + "macos": { + "key": "Changes", + "arguments": [] + } + }, + { + "id": "native.changesApplyToTheNextTurn", + "description": "Native UI: Changes apply to the next turn.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Changes apply to the next turn.", + "arguments": [] + } + }, + { + "id": "native.changesAreWrittenTo", + "description": "Native UI: Changes are written to", + "platforms": [ + "macos" + ], + "macos": { + "key": "Changes are written to", + "arguments": [] + } + }, + { + "id": "native.changesFromExternalToolsWillAppearAutomatically", + "description": "Native UI: Changes from external tools will appear automatically.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Changes from external tools will appear automatically.", + "arguments": [] + } + }, + { + "id": "native.changesFromTheCompareBranchWillBeProposedForTheBaseBranch", + "description": "Native UI: Changes from the compare branch will be proposed for the base branch.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Changes from the compare branch will be proposed for the base branch.", + "arguments": [] + } + }, + { + "id": "native.changesRequested", + "description": "Native UI: Changes requested", + "platforms": [ + "macos" + ], + "macos": { + "key": "Changes requested", + "arguments": [] + } + }, + { + "id": "native.changingChannelsRequiresAFullInstallationAndMayInstallAnOlderVersionOnItsFirste8ccfd1e52", + "description": "Native UI: Changing channels requires a full installation and may install an older version. On its first launch, Lithe-Dev copies Stable settings and workspaces if it has no user data. The apps then save settings and caches separately. Some accounts may need sign-in again. Your project files stay in their current locations.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Changing channels requires a full installation and may install an older version. On its first launch, Lithe-Dev copies Stable settings and workspaces if it has no user data. The apps then save settings and caches separately. Some accounts may need sign-in again. Your project files stay in their current locations.", + "arguments": [] + } + }, + { + "id": "native.changingChannelsRequiresAFullInstallationAndMayInstallAnOlderVersionOpenTheSelda3c463026", + "description": "Native UI: Changing channels requires a full installation and may install an older version. Open the selected release page to download its installer. Your channel selection is saved for future updates.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Changing channels requires a full installation and may install an older version. Open the selected release page to download its installer. Your channel selection is saved for future updates.", + "arguments": [] + } + }, + { + "id": "native.charactersUsedByTheInterfaceAndYourFiles", + "description": "Native UI: characters used by the interface and your files", + "platforms": [ + "macos" + ], + "macos": { + "key": "characters used by the interface and your files", + "arguments": [] + } + }, + { + "id": "native.chars", + "description": "Native UI: chars", + "platforms": [ + "macos" + ], + "macos": { + "key": "chars", + "arguments": [] + } + }, + { + "id": "native.chatCompletions", + "description": "Native UI: Chat Completions", + "platforms": [ + "macos" + ], + "macos": { + "key": "Chat Completions", + "arguments": [] + } + }, + { + "id": "native.chatHistory", + "description": "Native UI: Chat History", + "platforms": [ + "macos" + ], + "macos": { + "key": "Chat History", + "arguments": [] + } + }, + { + "id": "native.chatOptions", + "description": "Native UI: Chat options", + "platforms": [ + "macos" + ], + "macos": { + "key": "Chat options", + "arguments": [] + } + }, + { + "id": "native.chatgptSignInWasCancelled", + "description": "Native UI: ChatGPT sign-in was cancelled.", + "platforms": [ + "macos" + ], + "macos": { + "key": "ChatGPT sign-in was cancelled.", + "arguments": [] + } + }, + { + "id": "native.check", + "description": "Native UI: Check: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Check: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.checkAgain", + "description": "Native UI: Check Again", + "platforms": [ + "macos" + ], + "macos": { + "key": "Check Again", + "arguments": [] + } + }, + { + "id": "native.checkComplete", + "description": "Native UI: Check complete.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Check complete.", + "arguments": [] + } + }, + { + "id": "native.checkCompleteSomeCLIUpdateResultsAreUnavailableSeePreflight", + "description": "Native UI: Check complete. Some CLI update results are unavailable; see Preflight.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Check complete. Some CLI update results are unavailable; see Preflight.", + "arguments": [] + } + }, + { + "id": "native.checkFailed", + "description": "Native UI: Check failed.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Check failed.", + "arguments": [] + } + }, + { + "id": "native.checkForUpdates", + "description": "Native UI: Check for Updates", + "platforms": [ + "macos" + ], + "macos": { + "key": "Check for Updates", + "arguments": [] + } + }, + { + "id": "native.checkForUpdates.variant2", + "description": "Native UI: Check for Updates…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Check for Updates…", + "arguments": [] + } + }, + { + "id": "native.checkTheConfigurationFormatAPIURLAndModelEachConfigurationMustBeUnder64KiB", + "description": "Native UI: Check the configuration format, API URL and model. Each configuration must be under 64 KiB.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Check the configuration format, API URL and model. Each configuration must be under 64 KiB.", + "arguments": [] + } + }, + { + "id": "native.checkYourInternetConnectionAndTryAgainLater", + "description": "Native UI: Check your internet connection and try again later.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Check your internet connection and try again later.", + "arguments": [] + } + }, + { + "id": "native.checkedAgainEveryTimeLitheLoadsThePlugin", + "description": "Native UI: Checked again every time Lithe loads the plugin.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Checked again every time Lithe loads the plugin.", + "arguments": [] + } + }, + { + "id": "native.checking", + "description": "Native UI: Checking…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Checking…", + "arguments": [] + } + }, + { + "id": "native.checkingForUpdates", + "description": "Native UI: Checking for updates…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Checking for updates…", + "arguments": [] + } + }, + { + "id": "native.checkingGitHubReleases", + "description": "Native UI: Checking GitHub Releases…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Checking GitHub Releases…", + "arguments": [] + } + }, + { + "id": "native.checkingGitRepository", + "description": "Native UI: Checking Git repository…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Checking Git repository…", + "arguments": [] + } + }, + { + "id": "native.checkingNodeJsAndNpm", + "description": "Native UI: Checking Node.js and npm…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Checking Node.js and npm…", + "arguments": [] + } + }, + { + "id": "native.checkingOutPullRequest", + "description": "Native UI: Checking out pull request…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Checking out pull request…", + "arguments": [] + } + }, + { + "id": "native.checkingSelectedCommitsAndRepositoryState", + "description": "Native UI: Checking selected commits and repository state…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Checking selected commits and repository state…", + "arguments": [] + } + }, + { + "id": "native.checkingTheBranchAndAffectedCommits", + "description": "Native UI: Checking the branch and affected commits…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Checking the branch and affected commits…", + "arguments": [] + } + }, + { + "id": "native.checkout", + "description": "Native UI: Checkout", + "platforms": [ + "macos" + ], + "macos": { + "key": "Checkout", + "arguments": [] + } + }, + { + "id": "native.checkout.variant2", + "description": "Native UI: checkout", + "platforms": [ + "macos" + ], + "macos": { + "key": "checkout", + "arguments": [] + } + }, + { + "id": "native.checkoutAndRebase", + "description": "Native UI: Checkout and Rebase", + "platforms": [ + "macos" + ], + "macos": { + "key": "Checkout and Rebase", + "arguments": [] + } + }, + { + "id": "native.checkoutAndRebaseBranch", + "description": "Native UI: Checkout and rebase branch?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Checkout and rebase branch?", + "arguments": [] + } + }, + { + "id": "native.checkoutAndRebaseOntoCurrentBranch", + "description": "Native UI: Checkout and Rebase onto Current Branch", + "platforms": [ + "macos" + ], + "macos": { + "key": "Checkout and Rebase onto Current Branch", + "arguments": [] + } + }, + { + "id": "native.checkoutBranchAfterCreation", + "description": "Native UI: Checkout branch after creation", + "platforms": [ + "macos" + ], + "macos": { + "key": "Checkout branch after creation", + "arguments": [] + } + }, + { + "id": "native.checkoutPath", + "description": "Native UI: Checkout path", + "platforms": [ + "macos" + ], + "macos": { + "key": "Checkout path", + "arguments": [] + } + }, + { + "id": "native.checkoutPathMissing", + "description": "Native UI: Checkout Path Missing", + "platforms": [ + "macos" + ], + "macos": { + "key": "Checkout Path Missing", + "arguments": [] + } + }, + { + "id": "native.checkoutTagOrRevision", + "description": "Native UI: Checkout Tag or Revision", + "platforms": [ + "macos" + ], + "macos": { + "key": "Checkout Tag or Revision", + "arguments": [] + } + }, + { + "id": "native.checkoutTagOrRevision.variant2", + "description": "Native UI: Checkout Tag or Revision…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Checkout Tag or Revision…", + "arguments": [] + } + }, + { + "id": "native.checkoutThenReplayItOntoTheBranchThatIsCurrentNow", + "description": "Native UI: Checkout %@, then replay it onto the branch that is current now.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Checkout %@, then replay it onto the branch that is current now.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.cherryPick", + "description": "Native UI: Cherry-pick", + "platforms": [ + "macos" + ], + "macos": { + "key": "Cherry-pick", + "arguments": [] + } + }, + { + "id": "native.cherryPick.variant2", + "description": "Native UI: cherry-pick", + "platforms": [ + "macos" + ], + "macos": { + "key": "cherry-pick", + "arguments": [] + } + }, + { + "id": "native.cherryPickCommit", + "description": "Native UI: Cherry-pick Commit…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Cherry-pick Commit…", + "arguments": [] + } + }, + { + "id": "native.cherryPickInProgress", + "description": "Native UI: Cherry-pick in progress", + "platforms": [ + "macos" + ], + "macos": { + "key": "Cherry-pick in progress", + "arguments": [] + } + }, + { + "id": "native.cherryPickThisCommit", + "description": "Native UI: Cherry-pick this commit?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Cherry-pick this commit?", + "arguments": [] + } + }, + { + "id": "native.chineseCharacters", + "description": "Native UI: Chinese characters", + "platforms": [ + "macos" + ], + "macos": { + "key": "Chinese characters", + "arguments": [] + } + }, + { + "id": "native.choose", + "description": "Native UI: Choose…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose…", + "arguments": [] + } + }, + { + "id": "native.chooseABaseAndCompareBranchThenDescribeThePullRequest", + "description": "Native UI: Choose a base and compare branch, then describe the pull request.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose a base and compare branch, then describe the pull request.", + "arguments": [] + } + }, + { + "id": "native.chooseABehaviorOnTheRightLitheShowsTheValueGitIsUsingAndTheValueAResetWouldRestore", + "description": "Native UI: Choose a behavior on the right. Lithe shows the value Git is using and the value a reset would restore.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose a behavior on the right. Lithe shows the value Git is using and the value a reset would restore.", + "arguments": [] + } + }, + { + "id": "native.chooseAColorThemeAndWhetherLitheFollowsTheSystemAppearance", + "description": "Native UI: Choose a color theme and whether Lithe follows the system appearance.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose a color theme and whether Lithe follows the system appearance.", + "arguments": [] + } + }, + { + "id": "native.chooseACommitFormat", + "description": "Native UI: Choose a commit format", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose a commit format", + "arguments": [] + } + }, + { + "id": "native.chooseADatabaseConnectionAndThenATableFromTheSidebar", + "description": "Native UI: Choose a database connection and then a table from the sidebar.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose a database connection and then a table from the sidebar.", + "arguments": [] + } + }, + { + "id": "native.chooseADefaultWebBrowserInSystemSettingsThenRetry", + "description": "Native UI: Choose a default web browser in System Settings, then retry.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose a default web browser in System Settings, then retry.", + "arguments": [] + } + }, + { + "id": "native.chooseADirectoryInsideTheProject", + "description": "Native UI: Choose a directory inside the project", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose a directory inside the project", + "arguments": [] + } + }, + { + "id": "native.chooseAGitExecutableAboveToContinue", + "description": "Native UI: Choose a Git executable above to continue.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose a Git executable above to continue.", + "arguments": [] + } + }, + { + "id": "native.chooseAJDKDirectory", + "description": "Native UI: Choose a JDK directory", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose a JDK directory", + "arguments": [] + } + }, + { + "id": "native.chooseAJDKInSettingsProjectOrInstallAFullJDKAndSetJAVAHOME", + "description": "Native UI: Choose a JDK in Settings → Project or install a full JDK and set JAVA_HOME.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose a JDK in Settings → Project or install a full JDK and set JAVA_HOME.", + "arguments": [] + } + }, + { + "id": "native.chooseALocalOrCustomProviderInTheAgentPanelSettings", + "description": "Native UI: Choose a local or custom provider in the Agent panel settings.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose a local or custom provider in the Agent panel settings.", + "arguments": [] + } + }, + { + "id": "native.chooseALocalOrCustomProviderSoTheAgentCanSignIn", + "description": "Native UI: Choose a local or custom provider so the Agent can sign in.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose a local or custom provider so the Agent can sign in.", + "arguments": [] + } + }, + { + "id": "native.chooseAModuleEnableDetailedDiagnosticsReproduceTheProblemThenPreviewAndExportADiagnosticBundle", + "description": "Native UI: Choose a module, enable detailed diagnostics, reproduce the problem, then preview and export a diagnostic bundle.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose a module, enable detailed diagnostics, reproduce the problem, then preview and export a diagnostic bundle.", + "arguments": [] + } + }, + { + "id": "native.chooseAPullRequestToReviewItsContextFilesAndConversation", + "description": "Native UI: Choose a pull request to review its context, files, and conversation.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose a pull request to review its context, files, and conversation.", + "arguments": [] + } + }, + { + "id": "native.chooseARunConfigurationForThisMavenModule", + "description": "Native UI: Choose a Run configuration for this Maven module", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose a Run configuration for this Maven module", + "arguments": [] + } + }, + { + "id": "native.chooseATableFromTheSidebarToBrowseAndEditData", + "description": "Native UI: Choose a table from the sidebar to browse and edit data.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose a table from the sidebar to browse and edit data.", + "arguments": [] + } + }, + { + "id": "native.chooseATableToInspectItsColumnsIndexesAndForeignKeys", + "description": "Native UI: Choose a table to inspect its columns, indexes, and foreign keys.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose a table to inspect its columns, indexes, and foreign keys.", + "arguments": [] + } + }, + { + "id": "native.chooseAValue", + "description": "Native UI: Choose a value", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose a value", + "arguments": [] + } + }, + { + "id": "native.chooseAnAIProviderBelowSoTheAgentCanSignIn", + "description": "Native UI: Choose an AI provider below so the Agent can sign in.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose an AI provider below so the Agent can sign in.", + "arguments": [] + } + }, + { + "id": "native.chooseAnAgent", + "description": "Native UI: Choose an Agent", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose an Agent", + "arguments": [] + } + }, + { + "id": "native.chooseAnAgentToStart", + "description": "Native UI: Choose an Agent to start", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose an Agent to start", + "arguments": [] + } + }, + { + "id": "native.chooseAnotherJDKInSettingsProjectOrClearTheInvalidPath", + "description": "Native UI: Choose another JDK in Settings → Project or clear the invalid path.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose another JDK in Settings → Project or clear the invalid path.", + "arguments": [] + } + }, + { + "id": "native.chooseAtLeastOneAgent", + "description": "Native UI: Choose at least one Agent.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose at least one Agent.", + "arguments": [] + } + }, + { + "id": "native.chooseDatabase", + "description": "Native UI: Choose database", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose database", + "arguments": [] + } + }, + { + "id": "native.chooseDirectory", + "description": "Native UI: Choose Directory", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose Directory", + "arguments": [] + } + }, + { + "id": "native.chooseDirectory.variant2", + "description": "Native UI: Choose directory", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose directory", + "arguments": [] + } + }, + { + "id": "native.chooseExistingFilesToAddToAConversation", + "description": "Native UI: Choose existing files to add to a conversation.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose existing files to add to a conversation.", + "arguments": [] + } + }, + { + "id": "native.chooseHowLitheLocatesGitAndHandlesCredentials", + "description": "Native UI: Choose how Lithe locates Git and handles credentials.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose how Lithe locates Git and handles credentials.", + "arguments": [] + } + }, + { + "id": "native.chooseHowThisGitSettingBehaves", + "description": "Native UI: Choose how this Git setting behaves.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose how this Git setting behaves.", + "arguments": [] + } + }, + { + "id": "native.chooseImage", + "description": "Native UI: Choose Image…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose Image…", + "arguments": [] + } + }, + { + "id": "native.chooseImplementation", + "description": "Native UI: Choose Implementation", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose Implementation", + "arguments": [] + } + }, + { + "id": "native.chooseLogDirectory", + "description": "Native UI: Choose Log Directory", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose Log Directory", + "arguments": [] + } + }, + { + "id": "native.chooseMavenExecutableOrHome", + "description": "Native UI: Choose Maven executable or home", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose Maven executable or home", + "arguments": [] + } + }, + { + "id": "native.chooseMavenOrMvndHomeBinOrExecutable", + "description": "Native UI: Choose Maven or mvnd home, bin, or executable", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose Maven or mvnd home, bin, or executable", + "arguments": [] + } + }, + { + "id": "native.chooseParent", + "description": "Native UI: Choose Parent…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose Parent…", + "arguments": [] + } + }, + { + "id": "native.choosePluginPackage", + "description": "Native UI: Choose Plugin Package", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose Plugin Package", + "arguments": [] + } + }, + { + "id": "native.chooseRuntimeDirectory", + "description": "Native UI: Choose Runtime Directory", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose Runtime Directory", + "arguments": [] + } + }, + { + "id": "native.chooseServicesToRun", + "description": "Native UI: Choose services to run", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose services to run", + "arguments": [] + } + }, + { + "id": "native.chooseStepTarget", + "description": "Native UI: Choose Step Target", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose Step Target", + "arguments": [] + } + }, + { + "id": "native.chooseTwoBranches", + "description": "Native UI: Choose two branches", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose two branches", + "arguments": [] + } + }, + { + "id": "native.chooseWhatHappensToProgramsStartedInLitheWhenYouQuitOrCloseATerminalProgramsStb8837e766c", + "description": "Native UI: Choose what happens to programs started in Lithe when you quit or close a terminal. Programs started outside Lithe are never shown or stopped.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose what happens to programs started in Lithe when you quit or close a terminal. Programs started outside Lithe are never shown or stopped.", + "arguments": [] + } + }, + { + "id": "native.chooseWhatPullDoesWhenYourBranchAndTheRemoteBranchHaveDiverged", + "description": "Native UI: Choose what Pull does when your branch and the remote branch have diverged.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose what Pull does when your branch and the remote branch have diverged.", + "arguments": [] + } + }, + { + "id": "native.chooseWhetherFetchAlsoUpdatesSubmodules", + "description": "Native UI: Choose whether Fetch also updates submodules.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose whether Fetch also updates submodules.", + "arguments": [] + } + }, + { + "id": "native.chooseWhetherLitheFollowsTheSystemAppearanceOrAlwaysUsesALightOrDarkTheme", + "description": "Native UI: Choose whether Lithe follows the system appearance or always uses a light or dark theme.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose whether Lithe follows the system appearance or always uses a light or dark theme.", + "arguments": [] + } + }, + { + "id": "native.chooseWhetherOpeningAnotherProjectAsksFirstStaysInThisWindowOrCreatesANewWindow", + "description": "Native UI: Choose whether opening another project asks first, stays in this window, or creates a new window.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose whether opening another project asks first, stays in this window, or creates a new window.", + "arguments": [] + } + }, + { + "id": "native.chooseWhetherPullMergesRemoteCommitsOrRebasesYourLocalCommits", + "description": "Native UI: Choose whether Pull merges remote commits or rebases your local commits.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose whether Pull merges remote commits or rebases your local commits.", + "arguments": [] + } + }, + { + "id": "native.chooseWhetherThisSettingBelongsToTheOpenRepositoryOrToGitEverywhere", + "description": "Native UI: Choose whether this setting belongs to the open repository or to Git everywhere.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose whether this setting belongs to the open repository or to Git everywhere.", + "arguments": [] + } + }, + { + "id": "native.chooseWhichBranchPushTargetsWhenYouDoNotNameOne", + "description": "Native UI: Choose which branch Push targets when you do not name one.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose which branch Push targets when you do not name one.", + "arguments": [] + } + }, + { + "id": "native.chooseWhichBranchToUpdateBeforePushing", + "description": "Native UI: Choose which branch to update before pushing.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose which branch to update before pushing.", + "arguments": [] + } + }, + { + "id": "native.chooseWorkbenchBackground", + "description": "Native UI: Choose Workbench Background", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose Workbench Background", + "arguments": [] + } + }, + { + "id": "native.classes", + "description": "Native UI: Classes", + "platforms": [ + "macos" + ], + "macos": { + "key": "Classes", + "arguments": [] + } + }, + { + "id": "native.claudeAgent", + "description": "Native UI: Claude Agent", + "platforms": [ + "macos" + ], + "macos": { + "key": "Claude Agent", + "arguments": [] + } + }, + { + "id": "native.claudeConfigurationDetected", + "description": "Native UI: Claude configuration detected", + "platforms": [ + "macos" + ], + "macos": { + "key": "Claude configuration detected", + "arguments": [] + } + }, + { + "id": "native.claudeConfigurationImported", + "description": "Native UI: Claude configuration imported", + "platforms": [ + "macos" + ], + "macos": { + "key": "Claude configuration imported", + "arguments": [] + } + }, + { + "id": "native.claudeHandlesPermissionDecisions", + "description": "Native UI: Claude handles permission decisions", + "platforms": [ + "macos" + ], + "macos": { + "key": "Claude handles permission decisions", + "arguments": [] + } + }, + { + "id": "native.claudeMessagesAPI", + "description": "Native UI: Claude Messages API", + "platforms": [ + "macos" + ], + "macos": { + "key": "Claude Messages API", + "arguments": [] + } + }, + { + "id": "native.claudeSettingsAndCredentialsAreReadDirectlyFromItsLocalConfigurationFiles", + "description": "Native UI: Claude settings and credentials are read directly from its local configuration files.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Claude settings and credentials are read directly from its local configuration files.", + "arguments": [] + } + }, + { + "id": "native.clearAll", + "description": "Native UI: Clear All", + "platforms": [ + "macos" + ], + "macos": { + "key": "Clear All", + "arguments": [] + } + }, + { + "id": "native.clearBranchFilter", + "description": "Native UI: Clear branch filter", + "platforms": [ + "macos" + ], + "macos": { + "key": "Clear branch filter", + "arguments": [] + } + }, + { + "id": "native.clearBuildOutput", + "description": "Native UI: Clear build output", + "platforms": [ + "macos" + ], + "macos": { + "key": "Clear build output", + "arguments": [] + } + }, + { + "id": "native.clearCollectedLogs", + "description": "Native UI: Clear collected logs?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Clear collected logs?", + "arguments": [] + } + }, + { + "id": "native.clearConflictFilter", + "description": "Native UI: Clear conflict filter", + "platforms": [ + "macos" + ], + "macos": { + "key": "Clear conflict filter", + "arguments": [] + } + }, + { + "id": "native.clearDateFilter", + "description": "Native UI: Clear date filter", + "platforms": [ + "macos" + ], + "macos": { + "key": "Clear date filter", + "arguments": [] + } + }, + { + "id": "native.clearDebugOutput", + "description": "Native UI: Clear debug output", + "platforms": [ + "macos" + ], + "macos": { + "key": "Clear debug output", + "arguments": [] + } + }, + { + "id": "native.clearGitConsole", + "description": "Native UI: Clear Git console", + "platforms": [ + "macos" + ], + "macos": { + "key": "Clear Git console", + "arguments": [] + } + }, + { + "id": "native.clearLogSearch", + "description": "Native UI: Clear log search", + "platforms": [ + "macos" + ], + "macos": { + "key": "Clear log search", + "arguments": [] + } + }, + { + "id": "native.clearLogs", + "description": "Native UI: Clear Logs", + "platforms": [ + "macos" + ], + "macos": { + "key": "Clear Logs", + "arguments": [] + } + }, + { + "id": "native.clearOverride", + "description": "Native UI: Clear Override", + "platforms": [ + "macos" + ], + "macos": { + "key": "Clear Override", + "arguments": [] + } + }, + { + "id": "native.clearPathFilter", + "description": "Native UI: Clear path filter", + "platforms": [ + "macos" + ], + "macos": { + "key": "Clear path filter", + "arguments": [] + } + }, + { + "id": "native.clearRunOutput", + "description": "Native UI: Clear run output", + "platforms": [ + "macos" + ], + "macos": { + "key": "Clear run output", + "arguments": [] + } + }, + { + "id": "native.clearSearch", + "description": "Native UI: Clear search", + "platforms": [ + "macos" + ], + "macos": { + "key": "Clear search", + "arguments": [] + } + }, + { + "id": "native.clearSelection", + "description": "Native UI: Clear selection", + "platforms": [ + "macos" + ], + "macos": { + "key": "Clear selection", + "arguments": [] + } + }, + { + "id": "native.clearTheCurrentProjectSJavaIndexAndRebuildItOnNextUse", + "description": "Native UI: Clear the current project's Java index and rebuild it on next use", + "platforms": [ + "macos" + ], + "macos": { + "key": "Clear the current project's Java index and rebuild it on next use", + "arguments": [] + } + }, + { + "id": "native.clearTheOverrideAndInherit", + "description": "Native UI: Clear the override and inherit", + "platforms": [ + "macos" + ], + "macos": { + "key": "Clear the override and inherit", + "arguments": [] + } + }, + { + "id": "native.clearUserFilter", + "description": "Native UI: Clear user filter", + "platforms": [ + "macos" + ], + "macos": { + "key": "Clear user filter", + "arguments": [] + } + }, + { + "id": "native.cliUpdatedTo", + "description": "Native UI: CLI updated to %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "CLI updated to %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.clickTheEditorGutterToAddABreakpoint", + "description": "Native UI: Click the editor gutter to add a breakpoint", + "platforms": [ + "macos" + ], + "macos": { + "key": "Click the editor gutter to add a breakpoint", + "arguments": [] + } + }, + { + "id": "native.clickTheFoldedOptionsToExpandRightClickForCommandDetails", + "description": "Native UI: Click the folded options to expand; right-click for command details.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Click the folded options to expand; right-click for command details.", + "arguments": [] + } + }, + { + "id": "native.clickTheRunIconInTheEditorGutter", + "description": "Native UI: — Click the Run icon in the editor gutter", + "platforms": [ + "macos" + ], + "macos": { + "key": "— Click the Run icon in the editor gutter", + "arguments": [] + } + }, + { + "id": "native.clineCursorKiloMistralAndMoreWithACPTechnology", + "description": "Native UI: Cline, Cursor, Kilo, Mistral, and more with ACP technology", + "platforms": [ + "macos" + ], + "macos": { + "key": "Cline, Cursor, Kilo, Mistral, and more with ACP technology", + "arguments": [] + } + }, + { + "id": "native.clojureSupport", + "description": "Native UI: Clojure Support", + "platforms": [ + "macos" + ], + "macos": { + "key": "Clojure Support", + "arguments": [] + } + }, + { + "id": "native.clone", + "description": "Native UI: Clone", + "platforms": [ + "macos" + ], + "macos": { + "key": "Clone", + "arguments": [] + } + }, + { + "id": "native.cloneRepository", + "description": "Native UI: Clone Repository", + "platforms": [ + "macos" + ], + "macos": { + "key": "Clone Repository", + "arguments": [] + } + }, + { + "id": "native.cloneRepository.variant2", + "description": "Native UI: Clone Repository…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Clone Repository…", + "arguments": [] + } + }, + { + "id": "native.closeAllTabs", + "description": "Native UI: Close All Tabs", + "platforms": [ + "macos" + ], + "macos": { + "key": "Close All Tabs", + "arguments": [] + } + }, + { + "id": "native.closeComparison", + "description": "Native UI: Close comparison", + "platforms": [ + "macos" + ], + "macos": { + "key": "Close comparison", + "arguments": [] + } + }, + { + "id": "native.closeConversation", + "description": "Native UI: Close conversation", + "platforms": [ + "macos" + ], + "macos": { + "key": "Close conversation", + "arguments": [] + } + }, + { + "id": "native.closeDiff", + "description": "Native UI: Close diff", + "platforms": [ + "macos" + ], + "macos": { + "key": "Close diff", + "arguments": [] + } + }, + { + "id": "native.closeEsc", + "description": "Native UI: Close (Esc)", + "platforms": [ + "macos" + ], + "macos": { + "key": "Close (Esc)", + "arguments": [] + } + }, + { + "id": "native.closeFind", + "description": "Native UI: Close Find", + "platforms": [ + "macos" + ], + "macos": { + "key": "Close Find", + "arguments": [] + } + }, + { + "id": "native.closeGitConsole", + "description": "Native UI: Close Git console", + "platforms": [ + "macos" + ], + "macos": { + "key": "Close Git console", + "arguments": [] + } + }, + { + "id": "native.closeOtherTabs", + "description": "Native UI: Close Other Tabs", + "platforms": [ + "macos" + ], + "macos": { + "key": "Close Other Tabs", + "arguments": [] + } + }, + { + "id": "native.closeProject", + "description": "Native UI: Close Project", + "platforms": [ + "macos" + ], + "macos": { + "key": "Close Project", + "arguments": [] + } + }, + { + "id": "native.closePullRequest", + "description": "Native UI: Close Pull Request", + "platforms": [ + "macos" + ], + "macos": { + "key": "Close Pull Request", + "arguments": [] + } + }, + { + "id": "native.closePullRequest.variant2", + "description": "Native UI: Close pull request", + "platforms": [ + "macos" + ], + "macos": { + "key": "Close pull request", + "arguments": [] + } + }, + { + "id": "native.closePullRequest.variant3", + "description": "Native UI: Close pull request?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Close pull request?", + "arguments": [] + } + }, + { + "id": "native.closeRunConfiguration", + "description": "Native UI: Close run configuration", + "platforms": [ + "macos" + ], + "macos": { + "key": "Close run configuration", + "arguments": [] + } + }, + { + "id": "native.closeRunningTerminal", + "description": "Native UI: Close Running Terminal?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Close Running Terminal?", + "arguments": [] + } + }, + { + "id": "native.closeSearch", + "description": "Native UI: Close search", + "platforms": [ + "macos" + ], + "macos": { + "key": "Close search", + "arguments": [] + } + }, + { + "id": "native.closeSettings", + "description": "Native UI: Close Settings", + "platforms": [ + "macos" + ], + "macos": { + "key": "Close Settings", + "arguments": [] + } + }, + { + "id": "native.closeSplit", + "description": "Native UI: Close split", + "platforms": [ + "macos" + ], + "macos": { + "key": "Close split", + "arguments": [] + } + }, + { + "id": "native.closeTabsToTheLeft", + "description": "Native UI: Close Tabs to the Left", + "platforms": [ + "macos" + ], + "macos": { + "key": "Close Tabs to the Left", + "arguments": [] + } + }, + { + "id": "native.closeTabsToTheRight", + "description": "Native UI: Close Tabs to the Right", + "platforms": [ + "macos" + ], + "macos": { + "key": "Close Tabs to the Right", + "arguments": [] + } + }, + { + "id": "native.closeTerminal", + "description": "Native UI: Close Terminal", + "platforms": [ + "macos" + ], + "macos": { + "key": "Close Terminal", + "arguments": [] + } + }, + { + "id": "native.closeTerminal.variant2", + "description": "Native UI: Close terminal “%@”?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Close terminal “%@”?", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.closeUnmodifiedTabs", + "description": "Native UI: Close Unmodified Tabs", + "platforms": [ + "macos" + ], + "macos": { + "key": "Close Unmodified Tabs", + "arguments": [] + } + }, + { + "id": "native.closed", + "description": "Native UI: Closed", + "platforms": [ + "macos" + ], + "macos": { + "key": "Closed", + "arguments": [] + } + }, + { + "id": "native.closingATabEndsEveryProgramStartedInItIncludingJobsSentToTheBackgroundTurnThis6e11c0ada9", + "description": "Native UI: Closing a tab ends every program started in it, including jobs sent to the background. Turn this off to close tabs immediately. Background services that left the terminal are not ended here; they are asked about when you quit.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Closing a tab ends every program started in it, including jobs sent to the background. Turn this off to close tabs immediately. Background services that left the terminal are not ended here; they are asked about when you quit.", + "arguments": [] + } + }, + { + "id": "native.closingATerminalTab", + "description": "Native UI: Closing a terminal tab", + "platforms": [ + "macos" + ], + "macos": { + "key": "Closing a terminal tab", + "arguments": [] + } + }, + { + "id": "native.closingPullRequest", + "description": "Native UI: Closing pull request…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Closing pull request…", + "arguments": [] + } + }, + { + "id": "native.closingTheProjectStopsTheseServicesAndTheProcessesTheyStarted", + "description": "Native UI: Closing the project stops these services and the processes they started.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Closing the project stops these services and the processes they started.", + "arguments": [] + } + }, + { + "id": "native.closingTheTerminalEndsThesePrograms", + "description": "Native UI: Closing the terminal ends these programs.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Closing the terminal ends these programs.", + "arguments": [] + } + }, + { + "id": "native.closingThisTerminalWillStopItsShellAndAnyRunningCommand", + "description": "Native UI: Closing this terminal will stop its shell and any running command.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Closing this terminal will stop its shell and any running command.", + "arguments": [] + } + }, + { + "id": "native.cmakeSupport", + "description": "Native UI: CMake Support", + "platforms": [ + "macos" + ], + "macos": { + "key": "CMake Support", + "arguments": [] + } + }, + { + "id": "native.code", + "description": "Native UI: code", + "platforms": [ + "macos" + ], + "macos": { + "key": "code", + "arguments": [] + } + }, + { + "id": "native.codex", + "description": "Native UI: Codex", + "platforms": [ + "macos" + ], + "macos": { + "key": "Codex", + "arguments": [] + } + }, + { + "id": "native.codexAgentsRequireTheResponsesAPIClaudeAgentsRequireAnAnthropicAPIKey", + "description": "Native UI: Codex Agents require the Responses API. Claude Agents require an Anthropic API key.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Codex Agents require the Responses API. Claude Agents require an Anthropic API key.", + "arguments": [] + } + }, + { + "id": "native.codexArchivedHistoryCouldNotBeRead", + "description": "Native UI: Codex archived history could not be read.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Codex archived history could not be read.", + "arguments": [] + } + }, + { + "id": "native.codexConfigurationDetected", + "description": "Native UI: Codex configuration detected", + "platforms": [ + "macos" + ], + "macos": { + "key": "Codex configuration detected", + "arguments": [] + } + }, + { + "id": "native.codexConfigurationImported", + "description": "Native UI: Codex configuration imported", + "platforms": [ + "macos" + ], + "macos": { + "key": "Codex configuration imported", + "arguments": [] + } + }, + { + "id": "native.codexCouldNotRestoreThisArchivedConversation", + "description": "Native UI: Codex could not restore this archived conversation.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Codex could not restore this archived conversation.", + "arguments": [] + } + }, + { + "id": "native.codexSettingsAndAPIKeyAreReadDirectlyFromCodexConfigTomlAndCodexAuthJson", + "description": "Native UI: Codex settings and API key are read directly from ~/.codex/config.toml and ~/.codex/auth.json.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Codex settings and API key are read directly from ~/.codex/config.toml and ~/.codex/auth.json.", + "arguments": [] + } + }, + { + "id": "native.codexSettingsAndCredentialsAreReadDirectlyFromItsLocalConfigurationFiles", + "description": "Native UI: Codex settings and credentials are read directly from its local configuration files.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Codex settings and credentials are read directly from its local configuration files.", + "arguments": [] + } + }, + { + "id": "native.codexSubscription", + "description": "Native UI: Codex subscription", + "platforms": [ + "macos" + ], + "macos": { + "key": "Codex subscription", + "arguments": [] + } + }, + { + "id": "native.codexSubscriptionQuota", + "description": "Native UI: Codex subscription quota", + "platforms": [ + "macos" + ], + "macos": { + "key": "Codex subscription quota", + "arguments": [] + } + }, + { + "id": "native.collaborateWithClaudeAndCodexToAskQuestionsRunTasksAndReviewCodeChanges", + "description": "Native UI: Collaborate with Claude and Codex to ask questions, run tasks, and review code changes.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Collaborate with Claude and Codex to ask questions, run tasks, and review code changes.", + "arguments": [] + } + }, + { + "id": "native.collaborationMode", + "description": "Native UI: Collaboration mode", + "platforms": [ + "macos" + ], + "macos": { + "key": "Collaboration mode", + "arguments": [] + } + }, + { + "id": "native.collapse", + "description": "Native UI: Collapse", + "platforms": [ + "macos" + ], + "macos": { + "key": "Collapse", + "arguments": [] + } + }, + { + "id": "native.collapseAll", + "description": "Native UI: Collapse all", + "platforms": [ + "macos" + ], + "macos": { + "key": "Collapse all", + "arguments": [] + } + }, + { + "id": "native.collapseDirectory", + "description": "Native UI: Collapse directory", + "platforms": [ + "macos" + ], + "macos": { + "key": "Collapse directory", + "arguments": [] + } + }, + { + "id": "native.collapseJSONBlock", + "description": "Native UI: Collapse JSON block", + "platforms": [ + "macos" + ], + "macos": { + "key": "Collapse JSON block", + "arguments": [] + } + }, + { + "id": "native.collapseLongGraphEdges", + "description": "Native UI: Collapse long graph edges", + "platforms": [ + "macos" + ], + "macos": { + "key": "Collapse long graph edges", + "arguments": [] + } + }, + { + "id": "native.collapseNotification", + "description": "Native UI: Collapse notification", + "platforms": [ + "macos" + ], + "macos": { + "key": "Collapse notification", + "arguments": [] + } + }, + { + "id": "native.collapseRepository", + "description": "Native UI: Collapse repository", + "platforms": [ + "macos" + ], + "macos": { + "key": "Collapse repository", + "arguments": [] + } + }, + { + "id": "native.collapseSection", + "description": "Native UI: Collapse section", + "platforms": [ + "macos" + ], + "macos": { + "key": "Collapse section", + "arguments": [] + } + }, + { + "id": "native.collapsed", + "description": "Native UI: Collapsed", + "platforms": [ + "macos" + ], + "macos": { + "key": "Collapsed", + "arguments": [] + } + }, + { + "id": "native.collectedLogsCleared", + "description": "Native UI: Collected logs cleared.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Collected logs cleared.", + "arguments": [] + } + }, + { + "id": "native.colorTheme", + "description": "Native UI: Color theme", + "platforms": [ + "macos" + ], + "macos": { + "key": "Color theme", + "arguments": [] + } + }, + { + "id": "native.combineTheMessagesWhenTheRebaseRunsIncludingChangesMadeAtEarlierEditStops", + "description": "Native UI: Combine the messages when the rebase runs, including changes made at earlier Edit stops", + "platforms": [ + "macos" + ], + "macos": { + "key": "Combine the messages when the rebase runs, including changes made at earlier Edit stops", + "arguments": [] + } + }, + { + "id": "native.combinedCommitMessage", + "description": "Native UI: Combined commit message", + "platforms": [ + "macos" + ], + "macos": { + "key": "Combined commit message", + "arguments": [] + } + }, + { + "id": "native.combinesThePullRequestIntoOneCommitOnTheBaseBranch", + "description": "Native UI: Combines the pull request into one commit on the base branch.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Combines the pull request into one commit on the base branch.", + "arguments": [] + } + }, + { + "id": "native.command", + "description": "Native UI: Command", + "platforms": [ + "macos" + ], + "macos": { + "key": "Command", + "arguments": [] + } + }, + { + "id": "native.commandDetails", + "description": "Native UI: Command details", + "platforms": [ + "macos" + ], + "macos": { + "key": "Command details", + "arguments": [] + } + }, + { + "id": "native.commandPaletteActions", + "description": "Native UI: Command Palette Actions", + "platforms": [ + "macos" + ], + "macos": { + "key": "Command Palette Actions", + "arguments": [] + } + }, + { + "id": "native.commandsAnAIAgentStartedThatAreStillRunningForExampleADeploymentADatabaseMigradd876f8a4a", + "description": "Native UI: Commands an AI Agent started that are still running, for example a deployment, a database migration or a dev server. Each one is asked about separately because ending one halfway can leave data or services inconsistent. What the Agent needs for itself, such as its MCP servers, always ends with the Agent.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Commands an AI Agent started that are still running, for example a deployment, a database migration or a dev server. Each one is asked about separately because ending one halfway can leave data or services inconsistent. What the Agent needs for itself, such as its MCP servers, always ends with the Agent.", + "arguments": [] + } + }, + { + "id": "native.comment", + "description": "Native UI: Comment", + "platforms": [ + "macos" + ], + "macos": { + "key": "Comment", + "arguments": [] + } + }, + { + "id": "native.commentPosted", + "description": "Native UI: Comment posted", + "platforms": [ + "macos" + ], + "macos": { + "key": "Comment posted", + "arguments": [] + } + }, + { + "id": "native.comments", + "description": "Native UI: Comments", + "platforms": [ + "macos" + ], + "macos": { + "key": "Comments", + "arguments": [] + } + }, + { + "id": "native.commit", + "description": "Native UI: Commit", + "platforms": [ + "macos" + ], + "macos": { + "key": "Commit", + "arguments": [] + } + }, + { + "id": "native.commit.variant2", + "description": "Native UI: Commit…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Commit…", + "arguments": [] + } + }, + { + "id": "native.commitAndPush", + "description": "Native UI: Commit and Push", + "platforms": [ + "macos" + ], + "macos": { + "key": "Commit and Push", + "arguments": [] + } + }, + { + "id": "native.commitAndPush.variant2", + "description": "Native UI: Commit and Push…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Commit and Push…", + "arguments": [] + } + }, + { + "id": "native.commitAndPush.variant3", + "description": "Native UI: Commit and push", + "platforms": [ + "macos" + ], + "macos": { + "key": "Commit and push", + "arguments": [] + } + }, + { + "id": "native.commitAndPushChanges", + "description": "Native UI: Commit and push changes?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Commit and push changes?", + "arguments": [] + } + }, + { + "id": "native.commitChangedFiles", + "description": "Native UI: Commit changed files", + "platforms": [ + "macos" + ], + "macos": { + "key": "Commit changed files", + "arguments": [] + } + }, + { + "id": "native.commitChangedFilesInTheSubmoduleFirst", + "description": "Native UI: Commit changed files in the submodule first", + "platforms": [ + "macos" + ], + "macos": { + "key": "Commit changed files in the submodule first", + "arguments": [] + } + }, + { + "id": "native.commitDIFF", + "description": "Native UI: COMMIT DIFF", + "platforms": [ + "macos" + ], + "macos": { + "key": "COMMIT DIFF", + "arguments": [] + } + }, + { + "id": "native.commitDetails", + "description": "Native UI: Commit details", + "platforms": [ + "macos" + ], + "macos": { + "key": "Commit details", + "arguments": [] + } + }, + { + "id": "native.commitHistory", + "description": "Native UI: Commit History", + "platforms": [ + "macos" + ], + "macos": { + "key": "Commit History", + "arguments": [] + } + }, + { + "id": "native.commitHistoryLld", + "description": "Native UI: Commit History (%lld)", + "platforms": [ + "macos" + ], + "macos": { + "key": "Commit History (%lld)", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.commitIdentity", + "description": "Native UI: Commit identity", + "platforms": [ + "macos" + ], + "macos": { + "key": "Commit identity", + "arguments": [] + } + }, + { + "id": "native.commitMessage", + "description": "Native UI: Commit Message", + "platforms": [ + "macos" + ], + "macos": { + "key": "Commit Message", + "arguments": [] + } + }, + { + "id": "native.commitMessage.variant2", + "description": "Native UI: Commit message", + "platforms": [ + "macos" + ], + "macos": { + "key": "Commit message", + "arguments": [] + } + }, + { + "id": "native.commitMessage.variant3", + "description": "Native UI: Commit message: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Commit message: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.commitMessageGenerated", + "description": "Native UI: Commit message generated", + "platforms": [ + "macos" + ], + "macos": { + "key": "Commit message generated", + "arguments": [] + } + }, + { + "id": "native.commitMessageGeneration", + "description": "Native UI: Commit message generation", + "platforms": [ + "macos" + ], + "macos": { + "key": "Commit message generation", + "arguments": [] + } + }, + { + "id": "native.commitMessageProvider", + "description": "Native UI: Commit message provider", + "platforms": [ + "macos" + ], + "macos": { + "key": "Commit message provider", + "arguments": [] + } + }, + { + "id": "native.commitMessageReplaced", + "description": "Native UI: Commit message replaced", + "platforms": [ + "macos" + ], + "macos": { + "key": "Commit message replaced", + "arguments": [] + } + }, + { + "id": "native.commitMessages", + "description": "Native UI: commit messages", + "platforms": [ + "macos" + ], + "macos": { + "key": "commit messages", + "arguments": [] + } + }, + { + "id": "native.commitMessagesUse", + "description": "Native UI: Commit messages use", + "platforms": [ + "macos" + ], + "macos": { + "key": "Commit messages use", + "arguments": [] + } + }, + { + "id": "native.commitTitle", + "description": "Native UI: Commit title", + "platforms": [ + "macos" + ], + "macos": { + "key": "Commit title", + "arguments": [] + } + }, + { + "id": "native.commitVersion", + "description": "Native UI: Commit version", + "platforms": [ + "macos" + ], + "macos": { + "key": "Commit version", + "arguments": [] + } + }, + { + "id": "native.commits", + "description": "Native UI: Commits", + "platforms": [ + "macos" + ], + "macos": { + "key": "Commits", + "arguments": [] + } + }, + { + "id": "native.committed", + "description": "Native UI: Committed", + "platforms": [ + "macos" + ], + "macos": { + "key": "Committed", + "arguments": [] + } + }, + { + "id": "native.committedAndPushed", + "description": "Native UI: Committed and pushed", + "platforms": [ + "macos" + ], + "macos": { + "key": "Committed and pushed", + "arguments": [] + } + }, + { + "id": "native.committedChangesButDetachedHEADCannotBePushed", + "description": "Native UI: Committed changes, but detached HEAD cannot be pushed", + "platforms": [ + "macos" + ], + "macos": { + "key": "Committed changes, but detached HEAD cannot be pushed", + "arguments": [] + } + }, + { + "id": "native.committedPushFailed", + "description": "Native UI: Committed; push failed", + "platforms": [ + "macos" + ], + "macos": { + "key": "Committed; push failed", + "arguments": [] + } + }, + { + "id": "native.committedPushPending", + "description": "Native UI: Committed; push pending", + "platforms": [ + "macos" + ], + "macos": { + "key": "Committed; push pending", + "arguments": [] + } + }, + { + "id": "native.committerEmail", + "description": "Native UI: Committer email", + "platforms": [ + "macos" + ], + "macos": { + "key": "Committer email", + "arguments": [] + } + }, + { + "id": "native.committerName", + "description": "Native UI: Committer name", + "platforms": [ + "macos" + ], + "macos": { + "key": "Committer name", + "arguments": [] + } + }, + { + "id": "native.community", + "description": "Native UI: Community", + "platforms": [ + "macos" + ], + "macos": { + "key": "Community", + "arguments": [] + } + }, + { + "id": "native.communityDisk", + "description": "Native UI: Community / Disk", + "platforms": [ + "macos" + ], + "macos": { + "key": "Community / Disk", + "arguments": [] + } + }, + { + "id": "native.communityPluginInstalledFromDiskItsPublisherAndRepositoryHaveNotBeenVerified", + "description": "Native UI: Community plugin installed from disk. Its publisher and repository have not been verified.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Community plugin installed from disk. Its publisher and repository have not been verified.", + "arguments": [] + } + }, + { + "id": "native.communityPluginMaintainedByAThirdPartyReportPluginProblemsInItsOwnRepository", + "description": "Native UI: Community plugin maintained by a third party. Report plugin problems in its own repository.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Community plugin maintained by a third party. Report plugin problems in its own repository.", + "arguments": [] + } + }, + { + "id": "native.communityPluginsAreMaintainedByThirdPartiesAndCanRunTheProgramsAboveOnYourMacO5dbe99fc4e", + "description": "Native UI: Community plugins are maintained by third parties and can run the programs above on your Mac. Only install plugins you trust.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Community plugins are maintained by third parties and can run the programs above on your Mac. Only install plugins you trust.", + "arguments": [] + } + }, + { + "id": "native.compare", + "description": "Native UI: Compare", + "platforms": [ + "macos" + ], + "macos": { + "key": "Compare", + "arguments": [] + } + }, + { + "id": "native.compareChanges", + "description": "Native UI: Compare changes", + "platforms": [ + "macos" + ], + "macos": { + "key": "Compare changes", + "arguments": [] + } + }, + { + "id": "native.compareCurrentBranchWithWorkingTree", + "description": "Native UI: Compare current branch with working tree", + "platforms": [ + "macos" + ], + "macos": { + "key": "Compare current branch with working tree", + "arguments": [] + } + }, + { + "id": "native.compareWith", + "description": "Native UI: Compare '%@' with '%@'", + "platforms": [ + "macos" + ], + "macos": { + "key": "Compare '%@' with '%@'", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string" + } + }, + { + "id": "native.compareWithCurrentBranch", + "description": "Native UI: Compare with Current Branch", + "platforms": [ + "macos" + ], + "macos": { + "key": "Compare with Current Branch", + "arguments": [] + } + }, + { + "id": "native.comparingChanges", + "description": "Native UI: Comparing changes", + "platforms": [ + "macos" + ], + "macos": { + "key": "Comparing changes", + "arguments": [] + } + }, + { + "id": "native.completeCommitMessage", + "description": "Native UI: Complete commit message", + "platforms": [ + "macos" + ], + "macos": { + "key": "Complete commit message", + "arguments": [] + } + }, + { + "id": "native.completeSignInInYourBrowserYourCredentialsAreManagedByCodex", + "description": "Native UI: Complete sign-in in your browser. Your credentials are managed by Codex.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Complete sign-in in your browser. Your credentials are managed by Codex.", + "arguments": [] + } + }, + { + "id": "native.completed", + "description": "Native UI: Completed", + "platforms": [ + "macos" + ], + "macos": { + "key": "Completed", + "arguments": [] + } + }, + { + "id": "native.compressionIsUnavailableShowingCompleteRetainedOutput", + "description": "Native UI: Compression is unavailable. Showing complete retained output.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Compression is unavailable. Showing complete retained output.", + "arguments": [] + } + }, + { + "id": "native.concise", + "description": "Native UI: Concise", + "platforms": [ + "macos" + ], + "macos": { + "key": "Concise", + "arguments": [] + } + }, + { + "id": "native.conciseSentence", + "description": "Native UI: Concise sentence", + "platforms": [ + "macos" + ], + "macos": { + "key": "Concise sentence", + "arguments": [] + } + }, + { + "id": "native.configuration", + "description": "Native UI: Configuration", + "platforms": [ + "macos" + ], + "macos": { + "key": "Configuration", + "arguments": [] + } + }, + { + "id": "native.configurationDetails", + "description": "Native UI: Configuration details", + "platforms": [ + "macos" + ], + "macos": { + "key": "Configuration details", + "arguments": [] + } + }, + { + "id": "native.configurationID", + "description": "Native UI: Configuration ID", + "platforms": [ + "macos" + ], + "macos": { + "key": "Configuration ID", + "arguments": [] + } + }, + { + "id": "native.configurationKey", + "description": "Native UI: Configuration key", + "platforms": [ + "macos" + ], + "macos": { + "key": "Configuration key", + "arguments": [] + } + }, + { + "id": "native.configurationOverrides", + "description": "Native UI: Configuration overrides", + "platforms": [ + "macos" + ], + "macos": { + "key": "Configuration overrides", + "arguments": [] + } + }, + { + "id": "native.configurationScope", + "description": "Native UI: Configuration scope", + "platforms": [ + "macos" + ], + "macos": { + "key": "Configuration scope", + "arguments": [] + } + }, + { + "id": "native.configurationSources", + "description": "Native UI: Configuration sources", + "platforms": [ + "macos" + ], + "macos": { + "key": "Configuration sources", + "arguments": [] + } + }, + { + "id": "native.configurationValue", + "description": "Native UI: Configuration value", + "platforms": [ + "macos" + ], + "macos": { + "key": "Configuration value", + "arguments": [] + } + }, + { + "id": "native.configure", + "description": "Native UI: Configure", + "platforms": [ + "macos" + ], + "macos": { + "key": "Configure", + "arguments": [] + } + }, + { + "id": "native.configureAnAIProviderInSettingsFirst", + "description": "Native UI: Configure an AI provider in Settings first.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Configure an AI provider in Settings first.", + "arguments": [] + } + }, + { + "id": "native.configureDependencySearchPaths", + "description": "Native UI: Configure dependency search paths", + "platforms": [ + "macos" + ], + "macos": { + "key": "Configure dependency search paths", + "arguments": [] + } + }, + { + "id": "native.configureEditorAndProjectBehavior", + "description": "Native UI: Configure editor and project behavior", + "platforms": [ + "macos" + ], + "macos": { + "key": "Configure editor and project behavior", + "arguments": [] + } + }, + { + "id": "native.configureInSettings", + "description": "Native UI: Configure in Settings", + "platforms": [ + "macos" + ], + "macos": { + "key": "Configure in Settings", + "arguments": [] + } + }, + { + "id": "native.configureProjectJDKAndMaven", + "description": "Native UI: Configure project JDK and Maven…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Configure project JDK and Maven…", + "arguments": [] + } + }, + { + "id": "native.configured", + "description": "Native UI: Configured", + "platforms": [ + "macos" + ], + "macos": { + "key": "Configured", + "arguments": [] + } + }, + { + "id": "native.configuredRemoteName", + "description": "Native UI: Configured remote name", + "platforms": [ + "macos" + ], + "macos": { + "key": "Configured remote name", + "arguments": [] + } + }, + { + "id": "native.confirmDeletion", + "description": "Native UI: Confirm deletion", + "platforms": [ + "macos" + ], + "macos": { + "key": "Confirm deletion", + "arguments": [] + } + }, + { + "id": "native.conflict", + "description": "Native UI: Conflict", + "platforms": [ + "macos" + ], + "macos": { + "key": "Conflict", + "arguments": [] + } + }, + { + "id": "native.conflict.variant2", + "description": "Native UI: conflict -> %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "conflict -> %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.conflicted", + "description": "Native UI: Conflicted", + "platforms": [ + "macos" + ], + "macos": { + "key": "Conflicted", + "arguments": [] + } + }, + { + "id": "native.conflicts", + "description": "Native UI: Conflicts", + "platforms": [ + "macos" + ], + "macos": { + "key": "Conflicts", + "arguments": [] + } + }, + { + "id": "native.conflictsWith", + "description": "Native UI: Conflicts with %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Conflicts with %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.connect", + "description": "Native UI: Connect", + "platforms": [ + "macos" + ], + "macos": { + "key": "Connect", + "arguments": [] + } + }, + { + "id": "native.connectAGitHubAccountBeforeContinuing", + "description": "Native UI: Connect a GitHub account before continuing", + "platforms": [ + "macos" + ], + "macos": { + "key": "Connect a GitHub account before continuing", + "arguments": [] + } + }, + { + "id": "native.connectToDatabasesBrowseSchemasEditDataRunSQLAndManageBackupsFromTheDatabaseWorkspace", + "description": "Native UI: Connect to databases, browse schemas, edit data, run SQL, and manage backups from the Database workspace.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Connect to databases, browse schemas, edit data, run SQL, and manage backups from the Database workspace.", + "arguments": [] + } + }, + { + "id": "native.connectToRunningJVM", + "description": "Native UI: Connect to running JVM", + "platforms": [ + "macos" + ], + "macos": { + "key": "Connect to running JVM", + "arguments": [] + } + }, + { + "id": "native.connected", + "description": "Native UI: Connected", + "platforms": [ + "macos" + ], + "macos": { + "key": "Connected", + "arguments": [] + } + }, + { + "id": "native.connectedAgentsCanChangeTheProjectJDKMavenAndRunConfigurationSettings", + "description": "Native UI: Connected agents can change the project JDK, Maven and run configuration settings.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Connected agents can change the project JDK, Maven and run configuration settings.", + "arguments": [] + } + }, + { + "id": "native.connectedAgentsCanRunBuildReloadAndStopTheProjectCommandsExecuteProjectCodeSoE6d273dab37", + "description": "Native UI: Connected agents can run, build, reload and stop the project. Commands execute project code, so enable this only for projects you trust.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Connected agents can run, build, reload and stop the project. Commands execute project code, so enable this only for projects you trust.", + "arguments": [] + } + }, + { + "id": "native.connectedAgentsCanUseTheConfigurationBelowWhileThisProjectIsOpen", + "description": "Native UI: Connected agents can use the configuration below while this project is open.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Connected agents can use the configuration below while this project is open.", + "arguments": [] + } + }, + { + "id": "native.connectedAs", + "description": "Native UI: Connected as @%@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Connected as @%@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.connectionRecoveryIsTakingLongerTheAgentIsStillRetrying", + "description": "Native UI: Connection recovery is taking longer. The Agent is still retrying.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Connection recovery is taking longer. The Agent is still retrying.", + "arguments": [] + } + }, + { + "id": "native.connectionsLld", + "description": "Native UI: Connections: %lld", + "platforms": [ + "macos" + ], + "macos": { + "key": "Connections: %lld", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.console", + "description": "Native UI: Console", + "platforms": [ + "macos" + ], + "macos": { + "key": "Console", + "arguments": [] + } + }, + { + "id": "native.content", + "description": "Native UI: Content", + "platforms": [ + "macos" + ], + "macos": { + "key": "Content", + "arguments": [] + } + }, + { + "id": "native.contentSHA256", + "description": "Native UI: Content SHA-256", + "platforms": [ + "macos" + ], + "macos": { + "key": "Content SHA-256", + "arguments": [] + } + }, + { + "id": "native.context", + "description": "Native UI: Context", + "platforms": [ + "macos" + ], + "macos": { + "key": "Context", + "arguments": [] + } + }, + { + "id": "native.context.variant2", + "description": "Native UI: Context: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Context: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.contextTokens", + "description": "Native UI: %@ · %@ / %@ context tokens", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ · %@ / %@ context tokens", + "arguments": [ + "arg1", + "arg2", + "arg3" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string", + "arg3": "string" + } + }, + { + "id": "native.contextTokens.variant2", + "description": "Native UI: Context tokens", + "platforms": [ + "macos" + ], + "macos": { + "key": "Context tokens", + "arguments": [] + } + }, + { + "id": "native.contextUsage", + "description": "Native UI: Context usage", + "platforms": [ + "macos" + ], + "macos": { + "key": "Context usage", + "arguments": [] + } + }, + { + "id": "native.contextUsageIsNotReportedByThisAgent", + "description": "Native UI: Context usage is not reported by this Agent", + "platforms": [ + "macos" + ], + "macos": { + "key": "Context usage is not reported by this Agent", + "arguments": [] + } + }, + { + "id": "native.contextUsed", + "description": "Native UI: %@ · %@ / %@ context used", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ · %@ / %@ context used", + "arguments": [ + "arg1", + "arg2", + "arg3" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string", + "arg3": "string" + } + }, + { + "id": "native.contextUsed.variant2", + "description": "Native UI: Context used %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Context used %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.continueCherryPick", + "description": "Native UI: Continue Cherry-pick", + "platforms": [ + "macos" + ], + "macos": { + "key": "Continue Cherry-pick", + "arguments": [] + } + }, + { + "id": "native.continueInstallation", + "description": "Native UI: Continue Installation", + "platforms": [ + "macos" + ], + "macos": { + "key": "Continue Installation", + "arguments": [] + } + }, + { + "id": "native.continueMerge", + "description": "Native UI: Continue Merge", + "platforms": [ + "macos" + ], + "macos": { + "key": "Continue Merge", + "arguments": [] + } + }, + { + "id": "native.continueRebase", + "description": "Native UI: Continue Rebase", + "platforms": [ + "macos" + ], + "macos": { + "key": "Continue Rebase", + "arguments": [] + } + }, + { + "id": "native.continueRevert", + "description": "Native UI: Continue Revert", + "platforms": [ + "macos" + ], + "macos": { + "key": "Continue Revert", + "arguments": [] + } + }, + { + "id": "native.continueWaiting", + "description": "Native UI: Continue waiting", + "platforms": [ + "macos" + ], + "macos": { + "key": "Continue waiting", + "arguments": [] + } + }, + { + "id": "native.controlsHowLitheSOrdinaryFetchSynchronizesTags", + "description": "Native UI: Controls how Lithe's ordinary Fetch synchronizes tags.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Controls how Lithe's ordinary Fetch synchronizes tags.", + "arguments": [] + } + }, + { + "id": "native.controlsHowLitheSOrdinaryFetchSynchronizesTagsInheritLeavesTagSelectionToGitAndTheRemote", + "description": "Native UI: Controls how Lithe's ordinary Fetch synchronizes tags. Inherit leaves tag selection to Git and the remote.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Controls how Lithe's ordinary Fetch synchronizes tags. Inherit leaves tag selection to Git and the remote.", + "arguments": [] + } + }, + { + "id": "native.controlsHowLitheSOrdinaryFetchUpdatesSubmodules", + "description": "Native UI: Controls how Lithe's ordinary Fetch updates submodules.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Controls how Lithe's ordinary Fetch updates submodules.", + "arguments": [] + } + }, + { + "id": "native.controlsHowLitheSOrdinaryFetchUpdatesSubmodulesInheritFollowsGitSFetchRecurseSubmodulesSetting", + "description": "Native UI: Controls how Lithe's ordinary Fetch updates submodules. Inherit follows Git's fetch.recurseSubmodules setting.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Controls how Lithe's ordinary Fetch updates submodules. Inherit follows Git's fetch.recurseSubmodules setting.", + "arguments": [] + } + }, + { + "id": "native.controlsWhetherLitheSOrdinaryFetchRemovesStaleRemoteBranches", + "description": "Native UI: Controls whether Lithe's ordinary Fetch removes stale remote branches.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Controls whether Lithe's ordinary Fetch removes stale remote branches.", + "arguments": [] + } + }, + { + "id": "native.conventionalCommits", + "description": "Native UI: Conventional Commits", + "platforms": [ + "macos" + ], + "macos": { + "key": "Conventional Commits", + "arguments": [] + } + }, + { + "id": "native.conversation", + "description": "Native UI: Conversation", + "platforms": [ + "macos" + ], + "macos": { + "key": "Conversation", + "arguments": [] + } + }, + { + "id": "native.conversationHistory", + "description": "Native UI: Conversation history", + "platforms": [ + "macos" + ], + "macos": { + "key": "Conversation history", + "arguments": [] + } + }, + { + "id": "native.conversationHistoryIsUnavailable", + "description": "Native UI: Conversation history is unavailable", + "platforms": [ + "macos" + ], + "macos": { + "key": "Conversation history is unavailable", + "arguments": [] + } + }, + { + "id": "native.conversationTabs", + "description": "Native UI: Conversation tabs", + "platforms": [ + "macos" + ], + "macos": { + "key": "Conversation tabs", + "arguments": [] + } + }, + { + "id": "native.conversationTitle", + "description": "Native UI: Conversation title", + "platforms": [ + "macos" + ], + "macos": { + "key": "Conversation title", + "arguments": [] + } + }, + { + "id": "native.conversationsForThisProjectAndAgentAppearHere", + "description": "Native UI: Conversations for this project and Agent appear here.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Conversations for this project and Agent appear here.", + "arguments": [] + } + }, + { + "id": "native.copied", + "description": "Native UI: Copied", + "platforms": [ + "macos" + ], + "macos": { + "key": "Copied", + "arguments": [] + } + }, + { + "id": "native.copiedFiles", + "description": "Native UI: Copied files", + "platforms": [ + "macos" + ], + "macos": { + "key": "Copied files", + "arguments": [] + } + }, + { + "id": "native.copyAgentConfiguration", + "description": "Native UI: Copy agent configuration", + "platforms": [ + "macos" + ], + "macos": { + "key": "Copy agent configuration", + "arguments": [] + } + }, + { + "id": "native.copyBranchName", + "description": "Native UI: Copy Branch Name", + "platforms": [ + "macos" + ], + "macos": { + "key": "Copy Branch Name", + "arguments": [] + } + }, + { + "id": "native.copyCode", + "description": "Native UI: Copy code", + "platforms": [ + "macos" + ], + "macos": { + "key": "Copy code", + "arguments": [] + } + }, + { + "id": "native.copyCommand", + "description": "Native UI: Copy command", + "platforms": [ + "macos" + ], + "macos": { + "key": "Copy command", + "arguments": [] + } + }, + { + "id": "native.copyCommitHash", + "description": "Native UI: Copy Commit Hash", + "platforms": [ + "macos" + ], + "macos": { + "key": "Copy Commit Hash", + "arguments": [] + } + }, + { + "id": "native.copyCompleteCommand", + "description": "Native UI: Copy complete command", + "platforms": [ + "macos" + ], + "macos": { + "key": "Copy complete command", + "arguments": [] + } + }, + { + "id": "native.copyCompleteConsole", + "description": "Native UI: Copy complete console", + "platforms": [ + "macos" + ], + "macos": { + "key": "Copy complete console", + "arguments": [] + } + }, + { + "id": "native.copyCompleteOutput", + "description": "Native UI: Copy complete output", + "platforms": [ + "macos" + ], + "macos": { + "key": "Copy complete output", + "arguments": [] + } + }, + { + "id": "native.copyConversation", + "description": "Native UI: Copy Conversation", + "platforms": [ + "macos" + ], + "macos": { + "key": "Copy Conversation", + "arguments": [] + } + }, + { + "id": "native.copyFiles", + "description": "Native UI: Copy Files", + "platforms": [ + "macos" + ], + "macos": { + "key": "Copy Files", + "arguments": [] + } + }, + { + "id": "native.copyJSON", + "description": "Native UI: Copy JSON", + "platforms": [ + "macos" + ], + "macos": { + "key": "Copy JSON", + "arguments": [] + } + }, + { + "id": "native.copyMessage", + "description": "Native UI: Copy message", + "platforms": [ + "macos" + ], + "macos": { + "key": "Copy message", + "arguments": [] + } + }, + { + "id": "native.copyName", + "description": "Native UI: Copy Name", + "platforms": [ + "macos" + ], + "macos": { + "key": "Copy Name", + "arguments": [] + } + }, + { + "id": "native.copyOriginalCommand", + "description": "Native UI: Copy Original Command", + "platforms": [ + "macos" + ], + "macos": { + "key": "Copy Original Command", + "arguments": [] + } + }, + { + "id": "native.copyOutput", + "description": "Native UI: Copy output", + "platforms": [ + "macos" + ], + "macos": { + "key": "Copy output", + "arguments": [] + } + }, + { + "id": "native.copyPath", + "description": "Native UI: Copy Path", + "platforms": [ + "macos" + ], + "macos": { + "key": "Copy Path", + "arguments": [] + } + }, + { + "id": "native.copyPathReference", + "description": "Native UI: Copy Path / Reference", + "platforms": [ + "macos" + ], + "macos": { + "key": "Copy Path / Reference", + "arguments": [] + } + }, + { + "id": "native.copyProjectPath", + "description": "Native UI: Copy Project Path", + "platforms": [ + "macos" + ], + "macos": { + "key": "Copy Project Path", + "arguments": [] + } + }, + { + "id": "native.copyRecoveryReference", + "description": "Native UI: Copy Recovery Reference", + "platforms": [ + "macos" + ], + "macos": { + "key": "Copy Recovery Reference", + "arguments": [] + } + }, + { + "id": "native.copyRelativePath", + "description": "Native UI: Copy Relative Path", + "platforms": [ + "macos" + ], + "macos": { + "key": "Copy Relative Path", + "arguments": [] + } + }, + { + "id": "native.copyReply", + "description": "Native UI: Copy reply", + "platforms": [ + "macos" + ], + "macos": { + "key": "Copy reply", + "arguments": [] + } + }, + { + "id": "native.copyRepositoryPath", + "description": "Native UI: Copy repository path", + "platforms": [ + "macos" + ], + "macos": { + "key": "Copy repository path", + "arguments": [] + } + }, + { + "id": "native.copySessionID", + "description": "Native UI: Copy session ID", + "platforms": [ + "macos" + ], + "macos": { + "key": "Copy session ID", + "arguments": [] + } + }, + { + "id": "native.copyShortHash", + "description": "Native UI: Copy Short Hash", + "platforms": [ + "macos" + ], + "macos": { + "key": "Copy Short Hash", + "arguments": [] + } + }, + { + "id": "native.copyTable", + "description": "Native UI: Copy table", + "platforms": [ + "macos" + ], + "macos": { + "key": "Copy table", + "arguments": [] + } + }, + { + "id": "native.copyWorktreePath", + "description": "Native UI: Copy worktree path", + "platforms": [ + "macos" + ], + "macos": { + "key": "Copy worktree path", + "arguments": [] + } + }, + { + "id": "native.couldNotCheckForUpdates", + "description": "Native UI: Could not check for updates", + "platforms": [ + "macos" + ], + "macos": { + "key": "Could not check for updates", + "arguments": [] + } + }, + { + "id": "native.couldNotCheckForUpdates.variant2", + "description": "Native UI: Could not check for updates.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Could not check for updates.", + "arguments": [] + } + }, + { + "id": "native.couldNotCompareTheLocalAdapterWithTheVerifiedVersion", + "description": "Native UI: Could not compare the local adapter with the verified version.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Could not compare the local adapter with the verified version.", + "arguments": [] + } + }, + { + "id": "native.couldNotConfirm", + "description": "Native UI: Could not confirm", + "platforms": [ + "macos" + ], + "macos": { + "key": "Could not confirm", + "arguments": [] + } + }, + { + "id": "native.couldNotCopyYourStableSettings", + "description": "Native UI: Could not copy your Stable settings", + "platforms": [ + "macos" + ], + "macos": { + "key": "Could not copy your Stable settings", + "arguments": [] + } + }, + { + "id": "native.couldNotInspectSavedFile", + "description": "Native UI: Could not inspect saved file", + "platforms": [ + "macos" + ], + "macos": { + "key": "Could not inspect saved file", + "arguments": [] + } + }, + { + "id": "native.couldNotInspectTheSelectedHistoryRefreshThePreviewToRetry", + "description": "Native UI: Could not inspect the selected history. Refresh the preview to retry.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Could not inspect the selected history. Refresh the preview to retry.", + "arguments": [] + } + }, + { + "id": "native.couldNotInspectThisWorktree", + "description": "Native UI: Could not inspect this worktree", + "platforms": [ + "macos" + ], + "macos": { + "key": "Could not inspect this worktree", + "arguments": [] + } + }, + { + "id": "native.couldNotInstallUpdate", + "description": "Native UI: Could not install update", + "platforms": [ + "macos" + ], + "macos": { + "key": "Could not install update", + "arguments": [] + } + }, + { + "id": "native.couldNotLoadChangedFiles", + "description": "Native UI: Could not load changed files", + "platforms": [ + "macos" + ], + "macos": { + "key": "Could not load changed files", + "arguments": [] + } + }, + { + "id": "native.couldNotLoadFilePreview", + "description": "Native UI: Could not load file preview", + "platforms": [ + "macos" + ], + "macos": { + "key": "Could not load file preview", + "arguments": [] + } + }, + { + "id": "native.couldNotLoadProject", + "description": "Native UI: Could not load project", + "platforms": [ + "macos" + ], + "macos": { + "key": "Could not load project", + "arguments": [] + } + }, + { + "id": "native.couldNotLoadSavedChanges", + "description": "Native UI: Could not load saved changes", + "platforms": [ + "macos" + ], + "macos": { + "key": "Could not load saved changes", + "arguments": [] + } + }, + { + "id": "native.couldNotLoadServiceDependencies", + "description": "Native UI: Could not load service dependencies", + "platforms": [ + "macos" + ], + "macos": { + "key": "Could not load service dependencies", + "arguments": [] + } + }, + { + "id": "native.couldNotLoadTheChangedFiles", + "description": "Native UI: Could not load the changed files.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Could not load the changed files.", + "arguments": [] + } + }, + { + "id": "native.couldNotMoveTheEditorTabItsContentCouldNotBeSynchronized", + "description": "Native UI: Could not move the editor tab. Its content could not be synchronized.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Could not move the editor tab. Its content could not be synchronized.", + "arguments": [] + } + }, + { + "id": "native.couldNotOpenHTMLInBrowser", + "description": "Native UI: Could not open HTML in browser", + "platforms": [ + "macos" + ], + "macos": { + "key": "Could not open HTML in browser", + "arguments": [] + } + }, + { + "id": "native.couldNotRead", + "description": "Native UI: Could not read %@: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Could not read %@: %@", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string" + } + }, + { + "id": "native.couldNotReadTheProjectFolderCheckThatItStillExistsAndThatLitheHasPermissionToAccessIt", + "description": "Native UI: Could not read the project folder. Check that it still exists and that Lithe has permission to access it.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Could not read the project folder. Check that it still exists and that Lithe has permission to access it.", + "arguments": [] + } + }, + { + "id": "native.couldNotRegisterLitheMCPIn", + "description": "Native UI: Could not register Lithe MCP in %@: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Could not register Lithe MCP in %@: %@", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string" + } + }, + { + "id": "native.couldNotRemoveLitheMCPFrom", + "description": "Native UI: Could not remove Lithe MCP from %@: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Could not remove Lithe MCP from %@: %@", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string" + } + }, + { + "id": "native.couldNotRestoreLocalHistory", + "description": "Native UI: Could not restore local history", + "platforms": [ + "macos" + ], + "macos": { + "key": "Could not restore local history", + "arguments": [] + } + }, + { + "id": "native.couldNotRestoreProjectHistory", + "description": "Native UI: Could not restore project history", + "platforms": [ + "macos" + ], + "macos": { + "key": "Could not restore project history", + "arguments": [] + } + }, + { + "id": "native.couldNotRestoreThisProjectHistoryEntry", + "description": "Native UI: Could not restore this project history entry", + "platforms": [ + "macos" + ], + "macos": { + "key": "Could not restore this project history entry", + "arguments": [] + } + }, + { + "id": "native.couldNotSaveAccessToTheSelectedBackgroundImage", + "description": "Native UI: Could not save access to the selected background image.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Could not save access to the selected background image.", + "arguments": [] + } + }, + { + "id": "native.couldNotSaveServiceSelection", + "description": "Native UI: Could not save service selection", + "platforms": [ + "macos" + ], + "macos": { + "key": "Could not save service selection", + "arguments": [] + } + }, + { + "id": "native.couldNotVerifyTheCommitOutcomeReviewBeforeRetrying", + "description": "Native UI: Could not verify the commit outcome. Review before retrying.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Could not verify the commit outcome. Review before retrying.", + "arguments": [] + } + }, + { + "id": "native.couldNotWriteThePreviousContentsWereKeptIn", + "description": "Native UI: Could not write %@. The previous contents were kept in %@.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Could not write %@. The previous contents were kept in %@.", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string" + } + }, + { + "id": "native.cpu", + "description": "Native UI: CPU", + "platforms": [ + "macos" + ], + "macos": { + "key": "CPU", + "arguments": [] + } + }, + { + "id": "native.create", + "description": "Native UI: Create", + "platforms": [ + "macos" + ], + "macos": { + "key": "Create", + "arguments": [] + } + }, + { + "id": "native.createABranchAtTheHistorySavedBeforeThisRebaseYourCurrentCheckoutStaysInPlace", + "description": "Native UI: Create a branch at the history saved before this rebase. Your current checkout stays in place.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Create a branch at the history saved before this rebase. Your current checkout stays in place.", + "arguments": [] + } + }, + { + "id": "native.createABranchAtTheOriginalHistorySoYouCanInspectItInGitLogYourCurrentCheckoutStaysInPlace", + "description": "Native UI: Create a branch at the original history so you can inspect it in Git Log. Your current checkout stays in place.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Create a branch at the original history so you can inspect it in Git Log. Your current checkout stays in place.", + "arguments": [] + } + }, + { + "id": "native.createACheckoutToGetStarted", + "description": "Native UI: Create a checkout to get started.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Create a checkout to get started.", + "arguments": [] + } + }, + { + "id": "native.createAMergeCommit", + "description": "Native UI: Create a merge commit", + "platforms": [ + "macos" + ], + "macos": { + "key": "Create a merge commit", + "arguments": [] + } + }, + { + "id": "native.createANewCommitThatReverses", + "description": "Native UI: Create a new commit that reverses %@.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Create a new commit that reverses %@.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.createAPlanBeforeMakingChanges", + "description": "Native UI: Create a plan before making changes", + "platforms": [ + "macos" + ], + "macos": { + "key": "Create a plan before making changes", + "arguments": [] + } + }, + { + "id": "native.createASharedProjectConfigurationOrALocalOverride", + "description": "Native UI: Create a shared project configuration or a local override.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Create a shared project configuration or a local override.", + "arguments": [] + } + }, + { + "id": "native.createAnIndependentCheckoutAndANewBranchFromTheSelectedReference", + "description": "Native UI: Create an independent checkout and a new branch from the selected reference.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Create an independent checkout and a new branch from the selected reference.", + "arguments": [] + } + }, + { + "id": "native.createAsDraft", + "description": "Native UI: Create as draft", + "platforms": [ + "macos" + ], + "macos": { + "key": "Create as draft", + "arguments": [] + } + }, + { + "id": "native.createBranch", + "description": "Native UI: Create Branch", + "platforms": [ + "macos" + ], + "macos": { + "key": "Create Branch", + "arguments": [] + } + }, + { + "id": "native.createDraft", + "description": "Native UI: Create Draft", + "platforms": [ + "macos" + ], + "macos": { + "key": "Create Draft", + "arguments": [] + } + }, + { + "id": "native.createFrom", + "description": "Native UI: Create from '%@'.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Create from '%@'.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.createMergeCommit", + "description": "Native UI: Create merge commit", + "platforms": [ + "macos" + ], + "macos": { + "key": "Create merge commit", + "arguments": [] + } + }, + { + "id": "native.createOnCommitLeaveTheMessageEmptyForALightweightTag", + "description": "Native UI: Create on commit %@. Leave the message empty for a lightweight tag.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Create on commit %@. Leave the message empty for a lightweight tag.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.createPatch", + "description": "Native UI: Create Patch", + "platforms": [ + "macos" + ], + "macos": { + "key": "Create Patch", + "arguments": [] + } + }, + { + "id": "native.createPatch.variant2", + "description": "Native UI: Create Patch…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Create Patch…", + "arguments": [] + } + }, + { + "id": "native.createPatchBetweenCommits", + "description": "Native UI: Create Patch Between Commits…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Create Patch Between Commits…", + "arguments": [] + } + }, + { + "id": "native.createPullRequest", + "description": "Native UI: Create Pull Request", + "platforms": [ + "macos" + ], + "macos": { + "key": "Create Pull Request", + "arguments": [] + } + }, + { + "id": "native.createPullRequest.variant2", + "description": "Native UI: Create pull request", + "platforms": [ + "macos" + ], + "macos": { + "key": "Create pull request", + "arguments": [] + } + }, + { + "id": "native.createRecoveryBranch", + "description": "Native UI: Create Recovery Branch", + "platforms": [ + "macos" + ], + "macos": { + "key": "Create Recovery Branch", + "arguments": [] + } + }, + { + "id": "native.createRecoveryBranch.variant2", + "description": "Native UI: Create Recovery Branch…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Create Recovery Branch…", + "arguments": [] + } + }, + { + "id": "native.createWithoutCheckingOutFiles", + "description": "Native UI: Create without checking out files", + "platforms": [ + "macos" + ], + "macos": { + "key": "Create without checking out files", + "arguments": [] + } + }, + { + "id": "native.createdOutsideLithe", + "description": "Native UI: Created outside Lithe.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Created outside Lithe.", + "arguments": [] + } + }, + { + "id": "native.createdWorktreeFor", + "description": "Native UI: Created worktree for %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Created worktree for %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.createsANewBranchOn", + "description": "Native UI: Creates a new branch on %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Creates a new branch on %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.creatingArchive", + "description": "Native UI: Creating archive…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Creating archive…", + "arguments": [] + } + }, + { + "id": "native.creatingPullRequest", + "description": "Native UI: Creating pull request…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Creating pull request…", + "arguments": [] + } + }, + { + "id": "native.credentialAvailableInClaudeConfiguration", + "description": "Native UI: Credential available in Claude configuration", + "platforms": [ + "macos" + ], + "macos": { + "key": "Credential available in Claude configuration", + "arguments": [] + } + }, + { + "id": "native.credentialAvailableInCodexConfiguration", + "description": "Native UI: Credential available in Codex configuration", + "platforms": [ + "macos" + ], + "macos": { + "key": "Credential available in Codex configuration", + "arguments": [] + } + }, + { + "id": "native.credentials", + "description": "Native UI: Credentials", + "platforms": [ + "macos" + ], + "macos": { + "key": "Credentials", + "arguments": [] + } + }, + { + "id": "native.credentialsUseYourExistingGitHelperAndSSHConfiguration", + "description": "Native UI: Credentials use your existing Git helper and SSH configuration.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Credentials use your existing Git helper and SSH configuration.", + "arguments": [] + } + }, + { + "id": "native.cssSupport", + "description": "Native UI: CSS Support", + "platforms": [ + "macos" + ], + "macos": { + "key": "CSS Support", + "arguments": [] + } + }, + { + "id": "native.current", + "description": "Native UI: Current", + "platforms": [ + "macos" + ], + "macos": { + "key": "Current", + "arguments": [] + } + }, + { + "id": "native.current.variant2", + "description": "Native UI: Current: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Current: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.currentApplicationMemoryAverageSinceLaunch", + "description": "Native UI: Current application memory: %@\nAverage since launch: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Current application memory: %@\nAverage since launch: %@", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string" + } + }, + { + "id": "native.currentBranch", + "description": "Native UI: Current branch", + "platforms": [ + "macos" + ], + "macos": { + "key": "Current branch", + "arguments": [] + } + }, + { + "id": "native.currentBranchWithSafetyCheck", + "description": "Native UI: Current branch with safety check", + "platforms": [ + "macos" + ], + "macos": { + "key": "Current branch with safety check", + "arguments": [] + } + }, + { + "id": "native.currentCommit", + "description": "Native UI: Current commit: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Current commit: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.currentFile", + "description": "Native UI: Current File", + "platforms": [ + "macos" + ], + "macos": { + "key": "Current File", + "arguments": [] + } + }, + { + "id": "native.currentFileRemainsAvailableAddASupportedJavaOrMavenEntryPointThenIdentifyTheProjectAgain", + "description": "Native UI: Current File remains available. Add a supported Java or Maven entry point, then identify the project again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Current File remains available. Add a supported Java or Maven entry point, then identify the project again.", + "arguments": [] + } + }, + { + "id": "native.currentGitHubRepository", + "description": "Native UI: Current GitHub repository", + "platforms": [ + "macos" + ], + "macos": { + "key": "Current GitHub repository", + "arguments": [] + } + }, + { + "id": "native.currentProvider", + "description": "Native UI: Current provider: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Current provider: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.currentRepository", + "description": "Native UI: Current repository", + "platforms": [ + "macos" + ], + "macos": { + "key": "Current repository", + "arguments": [] + } + }, + { + "id": "native.currentRun", + "description": "Native UI: Current run", + "platforms": [ + "macos" + ], + "macos": { + "key": "Current run", + "arguments": [] + } + }, + { + "id": "native.currentSession", + "description": "Native UI: Current session", + "platforms": [ + "macos" + ], + "macos": { + "key": "Current session", + "arguments": [] + } + }, + { + "id": "native.currentVersion", + "description": "Native UI: Current Version", + "platforms": [ + "macos" + ], + "macos": { + "key": "Current Version", + "arguments": [] + } + }, + { + "id": "native.currentVersion.variant2", + "description": "Native UI: Current version", + "platforms": [ + "macos" + ], + "macos": { + "key": "Current version", + "arguments": [] + } + }, + { + "id": "native.currentVersionVerifiedTarget", + "description": "Native UI: Current version: %@ · Verified target: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Current version: %@ · Verified target: %@", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string" + } + }, + { + "id": "native.currentlyEffective", + "description": "Native UI: Currently effective", + "platforms": [ + "macos" + ], + "macos": { + "key": "Currently effective", + "arguments": [] + } + }, + { + "id": "native.custom", + "description": "Native UI: Custom", + "platforms": [ + "macos" + ], + "macos": { + "key": "Custom", + "arguments": [] + } + }, + { + "id": "native.customAPIProvider", + "description": "Native UI: Custom API provider", + "platforms": [ + "macos" + ], + "macos": { + "key": "Custom API provider", + "arguments": [] + } + }, + { + "id": "native.customAgent", + "description": "Native UI: Custom Agent", + "platforms": [ + "macos" + ], + "macos": { + "key": "Custom Agent", + "arguments": [] + } + }, + { + "id": "native.customInstructions", + "description": "Native UI: Custom instructions", + "platforms": [ + "macos" + ], + "macos": { + "key": "Custom instructions", + "arguments": [] + } + }, + { + "id": "native.customMavenHome", + "description": "Native UI: Custom Maven Home", + "platforms": [ + "macos" + ], + "macos": { + "key": "Custom Maven Home", + "arguments": [] + } + }, + { + "id": "native.customTemplate", + "description": "Native UI: Custom template", + "platforms": [ + "macos" + ], + "macos": { + "key": "Custom template", + "arguments": [] + } + }, + { + "id": "native.customizeShortcutsForLitheActionsChangesApplyImmediately", + "description": "Native UI: Customize shortcuts for Lithe actions. Changes apply immediately.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Customize shortcuts for Lithe actions. Changes apply immediately.", + "arguments": [] + } + }, + { + "id": "native.cyrillicLetters", + "description": "Native UI: Cyrillic letters", + "platforms": [ + "macos" + ], + "macos": { + "key": "Cyrillic letters", + "arguments": [] + } + }, + { + "id": "native.dConversations", + "description": "Native UI: %d conversations", + "platforms": [ + "macos" + ], + "macos": { + "key": "%d conversations", + "arguments": [ + "arg1" + ], + "formats": { + "arg1": "d" + } + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.dMessages", + "description": "Native UI: %d messages", + "platforms": [ + "macos" + ], + "macos": { + "key": "%d messages", + "arguments": [ + "arg1" + ], + "formats": { + "arg1": "d" + } + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.dSelected", + "description": "Native UI: %d selected", + "platforms": [ + "macos" + ], + "macos": { + "key": "%d selected", + "arguments": [ + "arg1" + ], + "formats": { + "arg1": "d" + } + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.daemon", + "description": "Native UI: Daemon", + "platforms": [ + "macos" + ], + "macos": { + "key": "Daemon", + "arguments": [] + } + }, + { + "id": "native.dangerZone", + "description": "Native UI: Danger Zone", + "platforms": [ + "macos" + ], + "macos": { + "key": "Danger Zone", + "arguments": [] + } + }, + { + "id": "native.dark", + "description": "Native UI: Dark", + "platforms": [ + "macos" + ], + "macos": { + "key": "Dark", + "arguments": [] + } + }, + { + "id": "native.dartSupport", + "description": "Native UI: Dart Support", + "platforms": [ + "macos" + ], + "macos": { + "key": "Dart Support", + "arguments": [] + } + }, + { + "id": "native.database", + "description": "Native UI: Database", + "platforms": [ + "macos" + ], + "macos": { + "key": "Database", + "arguments": [] + } + }, + { + "id": "native.date", + "description": "Native UI: Date", + "platforms": [ + "macos" + ], + "macos": { + "key": "Date", + "arguments": [] + } + }, + { + "id": "native.debug", + "description": "Native UI: Debug", + "platforms": [ + "macos" + ], + "macos": { + "key": "Debug", + "arguments": [] + } + }, + { + "id": "native.debugResume", + "description": "Native UI: Debug: Resume", + "platforms": [ + "macos" + ], + "macos": { + "key": "Debug: Resume", + "arguments": [] + } + }, + { + "id": "native.debugStepInto", + "description": "Native UI: Debug: Step Into", + "platforms": [ + "macos" + ], + "macos": { + "key": "Debug: Step Into", + "arguments": [] + } + }, + { + "id": "native.debugStepOut", + "description": "Native UI: Debug: Step Out", + "platforms": [ + "macos" + ], + "macos": { + "key": "Debug: Step Out", + "arguments": [] + } + }, + { + "id": "native.debugStepOver", + "description": "Native UI: Debug: Step Over", + "platforms": [ + "macos" + ], + "macos": { + "key": "Debug: Step Over", + "arguments": [] + } + }, + { + "id": "native.debugTarget", + "description": "Native UI: Debug target", + "platforms": [ + "macos" + ], + "macos": { + "key": "Debug target", + "arguments": [] + } + }, + { + "id": "native.debugger", + "description": "Native UI: Debugger", + "platforms": [ + "macos" + ], + "macos": { + "key": "Debugger", + "arguments": [] + } + }, + { + "id": "native.debuggingCode", + "description": "Native UI: Debugging code", + "platforms": [ + "macos" + ], + "macos": { + "key": "Debugging code", + "arguments": [] + } + }, + { + "id": "native.decreaseFontSize", + "description": "Native UI: Decrease Font Size", + "platforms": [ + "macos" + ], + "macos": { + "key": "Decrease Font Size", + "arguments": [] + } + }, + { + "id": "native.deepReasoningForComplexTasks", + "description": "Native UI: Deep reasoning for complex tasks", + "platforms": [ + "macos" + ], + "macos": { + "key": "Deep reasoning for complex tasks", + "arguments": [] + } + }, + { + "id": "native.default", + "description": "Native UI: Default", + "platforms": [ + "macos" + ], + "macos": { + "key": "Default", + "arguments": [] + } + }, + { + "id": "native.defaultChangeList", + "description": "Native UI: Default ChangeList", + "platforms": [ + "macos" + ], + "macos": { + "key": "Default ChangeList", + "arguments": [] + } + }, + { + "id": "native.defaultDirectory", + "description": "Native UI: Default directory", + "platforms": [ + "macos" + ], + "macos": { + "key": "Default directory", + "arguments": [] + } + }, + { + "id": "native.defaultOptions", + "description": "Native UI: Default options", + "platforms": [ + "macos" + ], + "macos": { + "key": "Default options", + "arguments": [] + } + }, + { + "id": "native.defaultPushBehavior", + "description": "Native UI: Default push behavior", + "platforms": [ + "macos" + ], + "macos": { + "key": "Default push behavior", + "arguments": [] + } + }, + { + "id": "native.defaultShell", + "description": "Native UI: Default shell", + "platforms": [ + "macos" + ], + "macos": { + "key": "Default shell", + "arguments": [] + } + }, + { + "id": "native.defaultsForOrdinaryFetchInEveryProjectARepositorySOwnGitConfigurationAboveStillWinsWhereItIsSet", + "description": "Native UI: Defaults for ordinary Fetch in every project. A repository's own Git configuration above still wins where it is set.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Defaults for ordinary Fetch in every project. A repository's own Git configuration above still wins where it is set.", + "arguments": [] + } + }, + { + "id": "native.definitionNotFound", + "description": "Native UI: Definition not found", + "platforms": [ + "macos" + ], + "macos": { + "key": "Definition not found", + "arguments": [] + } + }, + { + "id": "native.definitions", + "description": "Native UI: Definitions", + "platforms": [ + "macos" + ], + "macos": { + "key": "Definitions", + "arguments": [] + } + }, + { + "id": "native.deleteBranch", + "description": "Native UI: Delete Branch", + "platforms": [ + "macos" + ], + "macos": { + "key": "Delete Branch", + "arguments": [] + } + }, + { + "id": "native.deleteBranch.variant2", + "description": "Native UI: Delete branch?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Delete branch?", + "arguments": [] + } + }, + { + "id": "native.deleteChangeList", + "description": "Native UI: Delete ChangeList", + "platforms": [ + "macos" + ], + "macos": { + "key": "Delete ChangeList", + "arguments": [] + } + }, + { + "id": "native.deleteChangeList.variant2", + "description": "Native UI: Delete ChangeList?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Delete ChangeList?", + "arguments": [] + } + }, + { + "id": "native.deleteChat", + "description": "Native UI: Delete Chat", + "platforms": [ + "macos" + ], + "macos": { + "key": "Delete Chat", + "arguments": [] + } + }, + { + "id": "native.deleteCollectedApplicationLogs", + "description": "Native UI: Delete collected application logs?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Delete collected application logs?", + "arguments": [] + } + }, + { + "id": "native.deleteOnlyLitheManagedLogsInTheActiveDirectoryAcrossAllModulesAndSessionsFilte2970fa47b9", + "description": "Native UI: Delete only Lithe-managed logs in the active directory across all modules and sessions. Filters do not limit this action. New events will continue to be recorded.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Delete only Lithe-managed logs in the active directory across all modules and sessions. Filters do not limit this action. New events will continue to be recorded.", + "arguments": [] + } + }, + { + "id": "native.deleteProvider", + "description": "Native UI: Delete provider", + "platforms": [ + "macos" + ], + "macos": { + "key": "Delete provider", + "arguments": [] + } + }, + { + "id": "native.deleteTag", + "description": "Native UI: Delete Tag…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Delete Tag…", + "arguments": [] + } + }, + { + "id": "native.deleteTag.variant2", + "description": "Native UI: Delete tag '%@'?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Delete tag '%@'?", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.deleteTagTargetIsNotACommit", + "description": "Native UI: Delete Tag… (target is not a commit)", + "platforms": [ + "macos" + ], + "macos": { + "key": "Delete Tag… (target is not a commit)", + "arguments": [] + } + }, + { + "id": "native.deleteTheLocalBranchGitWillRefuseIfItContainsUnmergedWork", + "description": "Native UI: Delete the local branch %@? Git will refuse if it contains unmerged work.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Delete the local branch %@? Git will refuse if it contains unmerged work.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.deleteThisProviderAndItsSavedAPIKeyAgentsUsingItWillBeUnlinkedItIsAlsoRemovedFc47686de94", + "description": "Native UI: Delete this provider and its saved API key? Agents using it will be unlinked. It is also removed from AI Providers in Settings.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Delete this provider and its saved API key? Agents using it will be unlinked. It is also removed from AI Providers in Settings.", + "arguments": [] + } + }, + { + "id": "native.deleted", + "description": "Native UI: Deleted", + "platforms": [ + "macos" + ], + "macos": { + "key": "Deleted", + "arguments": [] + } + }, + { + "id": "native.deletedBranch", + "description": "Native UI: Deleted branch '%@'", + "platforms": [ + "macos" + ], + "macos": { + "key": "Deleted branch '%@'", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.deletedReferences", + "description": "Native UI: Deleted references: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Deleted references: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.deletedTag", + "description": "Native UI: Deleted tag '%@'", + "platforms": [ + "macos" + ], + "macos": { + "key": "Deleted tag '%@'", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.deletions", + "description": "Native UI: Deletions", + "platforms": [ + "macos" + ], + "macos": { + "key": "Deletions", + "arguments": [] + } + }, + { + "id": "native.deny", + "description": "Native UI: Deny", + "platforms": [ + "macos" + ], + "macos": { + "key": "Deny", + "arguments": [] + } + }, + { + "id": "native.dependencies", + "description": "Native UI: Dependencies", + "platforms": [ + "macos" + ], + "macos": { + "key": "Dependencies", + "arguments": [] + } + }, + { + "id": "native.dependencyPaths", + "description": "Native UI: Dependency Paths", + "platforms": [ + "macos" + ], + "macos": { + "key": "Dependency Paths", + "arguments": [] + } + }, + { + "id": "native.dependencyResolutionCancelled", + "description": "Native UI: Dependency resolution cancelled", + "platforms": [ + "macos" + ], + "macos": { + "key": "Dependency resolution cancelled", + "arguments": [] + } + }, + { + "id": "native.deprecatedAPI", + "description": "Native UI: Deprecated API", + "platforms": [ + "macos" + ], + "macos": { + "key": "Deprecated API", + "arguments": [] + } + }, + { + "id": "native.description", + "description": "Native UI: Description", + "platforms": [ + "macos" + ], + "macos": { + "key": "Description", + "arguments": [] + } + }, + { + "id": "native.descriptionFormat", + "description": "Native UI: Description format", + "platforms": [ + "macos" + ], + "macos": { + "key": "Description format", + "arguments": [] + } + }, + { + "id": "native.deselectAll", + "description": "Native UI: Deselect All", + "platforms": [ + "macos" + ], + "macos": { + "key": "Deselect All", + "arguments": [] + } + }, + { + "id": "native.deselectAll.variant2", + "description": "Native UI: Deselect all", + "platforms": [ + "macos" + ], + "macos": { + "key": "Deselect all", + "arguments": [] + } + }, + { + "id": "native.destinationFolder", + "description": "Native UI: Destination folder", + "platforms": [ + "macos" + ], + "macos": { + "key": "Destination folder", + "arguments": [] + } + }, + { + "id": "native.detachedHEAD", + "description": "Native UI: Detached HEAD", + "platforms": [ + "macos" + ], + "macos": { + "key": "Detached HEAD", + "arguments": [] + } + }, + { + "id": "native.detailed", + "description": "Native UI: Detailed", + "platforms": [ + "macos" + ], + "macos": { + "key": "Detailed", + "arguments": [] + } + }, + { + "id": "native.detailedDiagnostics", + "description": "Native UI: Detailed diagnostics", + "platforms": [ + "macos" + ], + "macos": { + "key": "Detailed diagnostics", + "arguments": [] + } + }, + { + "id": "native.detailedDiagnosticsCoverTheSelectedModuleAndExpireAfter15MinutesRefreshToSeeTheCurrentStatus", + "description": "Native UI: Detailed diagnostics cover the selected module and expire after 15 minutes. Refresh to see the current status.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Detailed diagnostics cover the selected module and expire after 15 minutes. Refresh to see the current status.", + "arguments": [] + } + }, + { + "id": "native.detailedDiagnosticsFor15Minutes", + "description": "Native UI: Detailed diagnostics for 15 minutes", + "platforms": [ + "macos" + ], + "macos": { + "key": "Detailed diagnostics for 15 minutes", + "arguments": [] + } + }, + { + "id": "native.detailedDiagnosticsOff", + "description": "Native UI: Detailed diagnostics: Off", + "platforms": [ + "macos" + ], + "macos": { + "key": "Detailed diagnostics: Off", + "arguments": [] + } + }, + { + "id": "native.detailedDiagnosticsOn", + "description": "Native UI: Detailed diagnostics: On (%@)", + "platforms": [ + "macos" + ], + "macos": { + "key": "Detailed diagnostics: On (%@)", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.detailedSubjectBody", + "description": "Native UI: Detailed subject + body", + "platforms": [ + "macos" + ], + "macos": { + "key": "Detailed subject + body", + "arguments": [] + } + }, + { + "id": "native.detectBuildSystemsAgain", + "description": "Native UI: Detect build systems again", + "platforms": [ + "macos" + ], + "macos": { + "key": "Detect build systems again", + "arguments": [] + } + }, + { + "id": "native.detectInstalledShells", + "description": "Native UI: Detect Installed Shells", + "platforms": [ + "macos" + ], + "macos": { + "key": "Detect Installed Shells", + "arguments": [] + } + }, + { + "id": "native.detectedConfigurations", + "description": "Native UI: Detected configurations", + "platforms": [ + "macos" + ], + "macos": { + "key": "Detected configurations", + "arguments": [] + } + }, + { + "id": "native.detectedJDK", + "description": "Native UI: Detected JDK", + "platforms": [ + "macos" + ], + "macos": { + "key": "Detected JDK", + "arguments": [] + } + }, + { + "id": "native.detectedMaven", + "description": "Native UI: Detected Maven", + "platforms": [ + "macos" + ], + "macos": { + "key": "Detected Maven", + "arguments": [] + } + }, + { + "id": "native.detectedOnThisMac", + "description": "Native UI: detected on this Mac", + "platforms": [ + "macos" + ], + "macos": { + "key": "detected on this Mac", + "arguments": [] + } + }, + { + "id": "native.detectedSystemJDK", + "description": "Native UI: Detected system JDK", + "platforms": [ + "macos" + ], + "macos": { + "key": "Detected system JDK", + "arguments": [] + } + }, + { + "id": "native.detecting", + "description": "Native UI: Detecting…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Detecting…", + "arguments": [] + } + }, + { + "id": "native.detectingBuildSystems", + "description": "Native UI: Detecting build systems…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Detecting build systems…", + "arguments": [] + } + }, + { + "id": "native.developerTools", + "description": "Native UI: Developer Tools", + "platforms": [ + "macos" + ], + "macos": { + "key": "Developer Tools", + "arguments": [] + } + }, + { + "id": "native.diagnosticBundle", + "description": "Native UI: Diagnostic bundle", + "platforms": [ + "macos" + ], + "macos": { + "key": "Diagnostic bundle", + "arguments": [] + } + }, + { + "id": "native.diagnostics", + "description": "Native UI: Diagnostics", + "platforms": [ + "macos" + ], + "macos": { + "key": "Diagnostics", + "arguments": [] + } + }, + { + "id": "native.diagnosticsBundle", + "description": "Native UI: Diagnostics bundle", + "platforms": [ + "macos" + ], + "macos": { + "key": "Diagnostics bundle", + "arguments": [] + } + }, + { + "id": "native.diagnosticsFromJDTLSWillAppearHere", + "description": "Native UI: Diagnostics from JDT LS will appear here.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Diagnostics from JDT LS will appear here.", + "arguments": [] + } + }, + { + "id": "native.didNotReportAVersionOrNewerIsRequired", + "description": "Native UI: %@ did not report a version; %@ or newer is required.", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ did not report a version; %@ or newer is required.", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string" + } + }, + { + "id": "native.diff", + "description": "Native UI: Diff", + "platforms": [ + "macos" + ], + "macos": { + "key": "Diff", + "arguments": [] + } + }, + { + "id": "native.diffCharacterLimit", + "description": "Native UI: Diff character limit", + "platforms": [ + "macos" + ], + "macos": { + "key": "Diff character limit", + "arguments": [] + } + }, + { + "id": "native.diffWith", + "description": "Native UI: Diff: %@ with %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Diff: %@ with %@", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string" + } + }, + { + "id": "native.differentJDKVendorSelected", + "description": "Native UI: Different JDK vendor selected", + "platforms": [ + "macos" + ], + "macos": { + "key": "Different JDK vendor selected", + "arguments": [] + } + }, + { + "id": "native.directory", + "description": "Native UI: Directory", + "platforms": [ + "macos" + ], + "macos": { + "key": "Directory", + "arguments": [] + } + }, + { + "id": "native.directory.variant2", + "description": "Native UI: Directory: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Directory: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.directoryOrFileName", + "description": "Native UI: Directory or file name", + "platforms": [ + "macos" + ], + "macos": { + "key": "Directory or file name", + "arguments": [] + } + }, + { + "id": "native.disable", + "description": "Native UI: Disable", + "platforms": [ + "macos" + ], + "macos": { + "key": "Disable", + "arguments": [] + } + }, + { + "id": "native.disableAutomaticScrolling", + "description": "Native UI: Disable automatic scrolling", + "platforms": [ + "macos" + ], + "macos": { + "key": "Disable automatic scrolling", + "arguments": [] + } + }, + { + "id": "native.disableBreakpoint", + "description": "Native UI: Disable breakpoint", + "platforms": [ + "macos" + ], + "macos": { + "key": "Disable breakpoint", + "arguments": [] + } + }, + { + "id": "native.disableExceptionBreakpoint", + "description": "Native UI: Disable %@ exception breakpoint", + "platforms": [ + "macos" + ], + "macos": { + "key": "Disable %@ exception breakpoint", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.disableFieldBreakpoint", + "description": "Native UI: Disable %@ field breakpoint", + "platforms": [ + "macos" + ], + "macos": { + "key": "Disable %@ field breakpoint", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.disableMCP", + "description": "Native UI: Disable MCP", + "platforms": [ + "macos" + ], + "macos": { + "key": "Disable MCP", + "arguments": [] + } + }, + { + "id": "native.disableMethodBreakpoint", + "description": "Native UI: Disable %@ method breakpoint", + "platforms": [ + "macos" + ], + "macos": { + "key": "Disable %@ method breakpoint", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.disableSoftWraps", + "description": "Native UI: Disable soft wraps", + "platforms": [ + "macos" + ], + "macos": { + "key": "Disable soft wraps", + "arguments": [] + } + }, + { + "id": "native.disabled", + "description": "Native UI: %@ disabled", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ disabled", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.disabled.variant2", + "description": "Native UI: Disabled", + "platforms": [ + "macos" + ], + "macos": { + "key": "Disabled", + "arguments": [] + } + }, + { + "id": "native.discard", + "description": "Native UI: Discard", + "platforms": [ + "macos" + ], + "macos": { + "key": "Discard", + "arguments": [] + } + }, + { + "id": "native.discardBlock", + "description": "Native UI: Discard Block", + "platforms": [ + "macos" + ], + "macos": { + "key": "Discard Block", + "arguments": [] + } + }, + { + "id": "native.discardChanges", + "description": "Native UI: Discard Changes", + "platforms": [ + "macos" + ], + "macos": { + "key": "Discard Changes", + "arguments": [] + } + }, + { + "id": "native.discardSelectedChange", + "description": "Native UI: Discard selected change", + "platforms": [ + "macos" + ], + "macos": { + "key": "Discard selected change", + "arguments": [] + } + }, + { + "id": "native.discardThisChangeBlock", + "description": "Native UI: Discard this change block", + "platforms": [ + "macos" + ], + "macos": { + "key": "Discard this change block", + "arguments": [] + } + }, + { + "id": "native.discardThisFileAndRetry", + "description": "Native UI: Discard this file and retry", + "platforms": [ + "macos" + ], + "macos": { + "key": "Discard this file and retry", + "arguments": [] + } + }, + { + "id": "native.discardedEditorChanges", + "description": "Native UI: Discarded editor changes", + "platforms": [ + "macos" + ], + "macos": { + "key": "Discarded editor changes", + "arguments": [] + } + }, + { + "id": "native.disconnect", + "description": "Native UI: Disconnect", + "platforms": [ + "macos" + ], + "macos": { + "key": "Disconnect", + "arguments": [] + } + }, + { + "id": "native.disconnected", + "description": "Native UI: Disconnected", + "platforms": [ + "macos" + ], + "macos": { + "key": "Disconnected", + "arguments": [] + } + }, + { + "id": "native.discovering", + "description": "Native UI: Discovering…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Discovering…", + "arguments": [] + } + }, + { + "id": "native.discoveringMaven", + "description": "Native UI: Discovering Maven…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Discovering Maven…", + "arguments": [] + } + }, + { + "id": "native.dismiss", + "description": "Native UI: Dismiss", + "platforms": [ + "macos" + ], + "macos": { + "key": "Dismiss", + "arguments": [] + } + }, + { + "id": "native.dismissHistoryOperationResult", + "description": "Native UI: Dismiss history operation result", + "platforms": [ + "macos" + ], + "macos": { + "key": "Dismiss history operation result", + "arguments": [] + } + }, + { + "id": "native.dismissNotification", + "description": "Native UI: Dismiss notification", + "platforms": [ + "macos" + ], + "macos": { + "key": "Dismiss notification", + "arguments": [] + } + }, + { + "id": "native.dismissRebaseResult", + "description": "Native UI: Dismiss rebase result", + "platforms": [ + "macos" + ], + "macos": { + "key": "Dismiss rebase result", + "arguments": [] + } + }, + { + "id": "native.dismissResults", + "description": "Native UI: Dismiss Results", + "platforms": [ + "macos" + ], + "macos": { + "key": "Dismiss Results", + "arguments": [] + } + }, + { + "id": "native.display", + "description": "Native UI: Display", + "platforms": [ + "macos" + ], + "macos": { + "key": "Display", + "arguments": [] + } + }, + { + "id": "native.doNotFetch", + "description": "Native UI: Do not fetch", + "platforms": [ + "macos" + ], + "macos": { + "key": "Do not fetch", + "arguments": [] + } + }, + { + "id": "native.doNotFetchSubmodules", + "description": "Native UI: Do not fetch submodules", + "platforms": [ + "macos" + ], + "macos": { + "key": "Do not fetch submodules", + "arguments": [] + } + }, + { + "id": "native.doNotFetchTags", + "description": "Native UI: Do not fetch tags", + "platforms": [ + "macos" + ], + "macos": { + "key": "Do not fetch tags", + "arguments": [] + } + }, + { + "id": "native.doNotIgnore", + "description": "Native UI: Do not ignore", + "platforms": [ + "macos" + ], + "macos": { + "key": "Do not ignore", + "arguments": [] + } + }, + { + "id": "native.doNotPushByDefault", + "description": "Native UI: Do not push by default", + "platforms": [ + "macos" + ], + "macos": { + "key": "Do not push by default", + "arguments": [] + } + }, + { + "id": "native.dockerCompose", + "description": "Native UI: Docker Compose", + "platforms": [ + "macos" + ], + "macos": { + "key": "Docker Compose", + "arguments": [] + } + }, + { + "id": "native.dockerfileSupport", + "description": "Native UI: Dockerfile Support", + "platforms": [ + "macos" + ], + "macos": { + "key": "Dockerfile Support", + "arguments": [] + } + }, + { + "id": "native.documentation", + "description": "Native UI: Documentation", + "platforms": [ + "macos" + ], + "macos": { + "key": "Documentation", + "arguments": [] + } + }, + { + "id": "native.doesNotIncludeLitheRendersThoseCharactersWithAndTheSystemFont", + "description": "Native UI: %@ does not include %@; Lithe renders those characters with %@ and the system font.", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ does not include %@; Lithe renders those characters with %@ and the system font.", + "arguments": [ + "arg1", + "arg2", + "arg3" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string", + "arg3": "string" + } + }, + { + "id": "native.doesNotProvideArchivedConversations", + "description": "Native UI: %@ does not provide archived conversations.", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ does not provide archived conversations.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.donTAllow", + "description": "Native UI: Don’t Allow", + "platforms": [ + "macos" + ], + "macos": { + "key": "Don’t Allow", + "arguments": [] + } + }, + { + "id": "native.donTAskAgain", + "description": "Native UI: Don't ask again", + "platforms": [ + "macos" + ], + "macos": { + "key": "Don't ask again", + "arguments": [] + } + }, + { + "id": "native.download", + "description": "Native UI: Download", + "platforms": [ + "macos" + ], + "macos": { + "key": "Download", + "arguments": [] + } + }, + { + "id": "native.download.variant2", + "description": "Native UI: Download %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Download %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.downloadAndInstall", + "description": "Native UI: Download and Install", + "platforms": [ + "macos" + ], + "macos": { + "key": "Download and Install", + "arguments": [] + } + }, + { + "id": "native.downloadPHPSupportFromTheOfficialPluginRelease", + "description": "Native UI: Download PHP Support from the official plugin release.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Download PHP Support from the official plugin release.", + "arguments": [] + } + }, + { + "id": "native.downloadPHPSupportFromTheOfficialSignedPluginPackage", + "description": "Native UI: Download PHP Support from the official signed plugin package.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Download PHP Support from the official signed plugin package.", + "arguments": [] + } + }, + { + "id": "native.downloadStableRelease", + "description": "Native UI: Download Stable Release", + "platforms": [ + "macos" + ], + "macos": { + "key": "Download Stable Release", + "arguments": [] + } + }, + { + "id": "native.downloadedLldOfLldEnabled", + "description": "Native UI: Downloaded (%lld of %lld enabled)", + "platforms": [ + "macos" + ], + "macos": { + "key": "Downloaded (%lld of %lld enabled)", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "integer", + "arg2": "integer" + } + }, + { + "id": "native.downloadedS", + "description": "Native UI: Downloaded %@ · %@/s", + "platforms": [ + "macos" + ], + "macos": { + "key": "Downloaded %@ · %@/s", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string" + } + }, + { + "id": "native.downloadingFullStableRelease", + "description": "Native UI: Downloading full stable release…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Downloading full stable release…", + "arguments": [] + } + }, + { + "id": "native.downloadingPackages", + "description": "Native UI: Downloading packages…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Downloading packages…", + "arguments": [] + } + }, + { + "id": "native.downloadingUpdate", + "description": "Native UI: Downloading update %@…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Downloading update %@…", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.draft", + "description": "Native UI: Draft", + "platforms": [ + "macos" + ], + "macos": { + "key": "Draft", + "arguments": [] + } + }, + { + "id": "native.dragFilesHereOrClickToChooseFiles", + "description": "Native UI: Drag files here or click to choose files", + "platforms": [ + "macos" + ], + "macos": { + "key": "Drag files here or click to choose files", + "arguments": [] + } + }, + { + "id": "native.drop", + "description": "Native UI: Drop", + "platforms": [ + "macos" + ], + "macos": { + "key": "Drop", + "arguments": [] + } + }, + { + "id": "native.drop.variant2", + "description": "Native UI: Drop %@?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Drop %@?", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.dropCommit", + "description": "Native UI: Drop Commit", + "platforms": [ + "macos" + ], + "macos": { + "key": "Drop Commit", + "arguments": [] + } + }, + { + "id": "native.dropCommit.variant2", + "description": "Native UI: Drop Commit…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Drop Commit…", + "arguments": [] + } + }, + { + "id": "native.dropFilesHere", + "description": "Native UI: Drop files here", + "platforms": [ + "macos" + ], + "macos": { + "key": "Drop files here", + "arguments": [] + } + }, + { + "id": "native.dropShelf", + "description": "Native UI: Drop Shelf", + "platforms": [ + "macos" + ], + "macos": { + "key": "Drop Shelf", + "arguments": [] + } + }, + { + "id": "native.dropStash", + "description": "Native UI: Drop Stash", + "platforms": [ + "macos" + ], + "macos": { + "key": "Drop Stash", + "arguments": [] + } + }, + { + "id": "native.dropThisShelf", + "description": "Native UI: Drop this shelf?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Drop this shelf?", + "arguments": [] + } + }, + { + "id": "native.droppedLldWriteFailuresLld", + "description": "Native UI: Dropped: %lld; write failures: %lld", + "platforms": [ + "macos" + ], + "macos": { + "key": "Dropped: %lld; write failures: %lld", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "integer", + "arg2": "integer" + } + }, + { + "id": "native.duplicate", + "description": "Native UI: Duplicate", + "platforms": [ + "macos" + ], + "macos": { + "key": "Duplicate", + "arguments": [] + } + }, + { + "id": "native.duplicateOmitted", + "description": "Native UI: duplicate omitted", + "platforms": [ + "macos" + ], + "macos": { + "key": "duplicate omitted", + "arguments": [] + } + }, + { + "id": "native.durationLldMs", + "description": "Native UI: Duration: %lld ms", + "platforms": [ + "macos" + ], + "macos": { + "key": "Duration: %lld ms", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.eachAgentKeepsItsOwnConfigurationFileTurningAnAgentOffRemovesOnlyTheEntryLithe602e7165d1", + "description": "Native UI: Each Agent keeps its own configuration file. Turning an Agent off removes only the entry Lithe wrote; an entry created outside Lithe is never removed without taking ownership first.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Each Agent keeps its own configuration file. Turning an Agent off removes only the entry Lithe wrote; an entry created outside Lithe is never removed without taking ownership first.", + "arguments": [] + } + }, + { + "id": "native.eachBuiltInPresetIncludesAPreviewOfTheGeneratedMessage", + "description": "Native UI: Each built-in preset includes a preview of the generated message.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Each built-in preset includes a preview of the generated message.", + "arguments": [] + } + }, + { + "id": "native.eachRepositoryHasItsOwnCommitCompletedStepsAreKeptIfAnotherRepositoryFails", + "description": "Native UI: Each repository has its own commit. Completed steps are kept if another repository fails.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Each repository has its own commit. Completed steps are kept if another repository fails.", + "arguments": [] + } + }, + { + "id": "native.eachRowExplainsTheSettingTheValueGitIsActuallyUsingAndWhereThatValueComesFrom", + "description": "Native UI: Each row explains the setting, the value Git is actually using, and where that value comes from.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Each row explains the setting, the value Git is actually using, and where that value comes from.", + "arguments": [] + } + }, + { + "id": "native.eachSubmoduleIsPushedBeforeItsParent", + "description": "Native UI: Each submodule is pushed before its parent.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Each submodule is pushed before its parent.", + "arguments": [] + } + }, + { + "id": "native.earlierEvidenceWasOmittedByCollectionLimits", + "description": "Native UI: Earlier evidence was omitted by collection limits.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Earlier evidence was omitted by collection limits.", + "arguments": [] + } + }, + { + "id": "native.earlierGitCommandsWereTruncatedToLimitMemoryUse", + "description": "Native UI: Earlier Git commands were truncated to limit memory use.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Earlier Git commands were truncated to limit memory use.", + "arguments": [] + } + }, + { + "id": "native.earlierGitOutputWasOmittedToLimitMemoryUse", + "description": "Native UI: Earlier Git output was omitted to limit memory use.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Earlier Git output was omitted to limit memory use.", + "arguments": [] + } + }, + { + "id": "native.earlierLogContentOmitted", + "description": "Native UI: Earlier log content omitted", + "platforms": [ + "macos" + ], + "macos": { + "key": "Earlier log content omitted", + "arguments": [] + } + }, + { + "id": "native.edit", + "description": "Native UI: Edit…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Edit…", + "arguments": [] + } + }, + { + "id": "native.editBreakpoint", + "description": "Native UI: Edit Breakpoint…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Edit Breakpoint…", + "arguments": [] + } + }, + { + "id": "native.editCommitMessage", + "description": "Native UI: Edit Commit Message", + "platforms": [ + "macos" + ], + "macos": { + "key": "Edit Commit Message", + "arguments": [] + } + }, + { + "id": "native.editCommitMessage.variant2", + "description": "Native UI: Edit Commit Message…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Edit Commit Message…", + "arguments": [] + } + }, + { + "id": "native.editConfiguration", + "description": "Native UI: Edit Configuration…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Edit Configuration…", + "arguments": [] + } + }, + { + "id": "native.editConfigurationText", + "description": "Native UI: Edit configuration text", + "platforms": [ + "macos" + ], + "macos": { + "key": "Edit configuration text", + "arguments": [] + } + }, + { + "id": "native.editExceptionBreakpoint", + "description": "Native UI: Edit %@ exception breakpoint", + "platforms": [ + "macos" + ], + "macos": { + "key": "Edit %@ exception breakpoint", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.editExceptionBreakpoint.variant2", + "description": "Native UI: Edit exception breakpoint", + "platforms": [ + "macos" + ], + "macos": { + "key": "Edit exception breakpoint", + "arguments": [] + } + }, + { + "id": "native.editFieldBreakpoint", + "description": "Native UI: Edit %@ field breakpoint", + "platforms": [ + "macos" + ], + "macos": { + "key": "Edit %@ field breakpoint", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.editFilesAndStageTheChangesYouWantToIncludeAmendAndContinueUpdatesThisCommitCobd078ad14c", + "description": "Native UI: Edit files and stage the changes you want to include. Amend and Continue updates this commit; Continue keeps the existing commit.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Edit files and stage the changes you want to include. Amend and Continue updates this commit; Continue keeps the existing commit.", + "arguments": [] + } + }, + { + "id": "native.editMessage", + "description": "Native UI: Edit message", + "platforms": [ + "macos" + ], + "macos": { + "key": "Edit message", + "arguments": [] + } + }, + { + "id": "native.editMethodBreakpoint", + "description": "Native UI: Edit %@ method breakpoint", + "platforms": [ + "macos" + ], + "macos": { + "key": "Edit %@ method breakpoint", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.editProvider", + "description": "Native UI: Edit provider", + "platforms": [ + "macos" + ], + "macos": { + "key": "Edit provider", + "arguments": [] + } + }, + { + "id": "native.editPullRequest", + "description": "Native UI: Edit pull request", + "platforms": [ + "macos" + ], + "macos": { + "key": "Edit pull request", + "arguments": [] + } + }, + { + "id": "native.editRunConfiguration", + "description": "Native UI: Edit run configuration", + "platforms": [ + "macos" + ], + "macos": { + "key": "Edit run configuration", + "arguments": [] + } + }, + { + "id": "native.editSearchExclusions", + "description": "Native UI: Edit search exclusions", + "platforms": [ + "macos" + ], + "macos": { + "key": "Edit search exclusions", + "arguments": [] + } + }, + { + "id": "native.editService", + "description": "Native UI: Edit Service", + "platforms": [ + "macos" + ], + "macos": { + "key": "Edit Service", + "arguments": [] + } + }, + { + "id": "native.editServiceConfiguration", + "description": "Native UI: Edit service configuration", + "platforms": [ + "macos" + ], + "macos": { + "key": "Edit service configuration", + "arguments": [] + } + }, + { + "id": "native.editTitleAndDescription", + "description": "Native UI: Edit title and description", + "platforms": [ + "macos" + ], + "macos": { + "key": "Edit title and description", + "arguments": [] + } + }, + { + "id": "native.editedLldFile", + "description": "Native UI: Edited %lld file", + "platforms": [ + "macos" + ], + "macos": { + "key": "Edited %lld file", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.editedLldFiles", + "description": "Native UI: Edited %lld files", + "platforms": [ + "macos" + ], + "macos": { + "key": "Edited %lld files", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.editor", + "description": "Native UI: Editor", + "platforms": [ + "macos" + ], + "macos": { + "key": "Editor", + "arguments": [] + } + }, + { + "id": "native.editorAndLanguageServices", + "description": "Native UI: Editor and language services", + "platforms": [ + "macos" + ], + "macos": { + "key": "Editor and language services", + "arguments": [] + } + }, + { + "id": "native.edits", + "description": "Native UI: Edits", + "platforms": [ + "macos" + ], + "macos": { + "key": "Edits", + "arguments": [] + } + }, + { + "id": "native.effectiveFetchOptions", + "description": "Native UI: Effective Fetch options", + "platforms": [ + "macos" + ], + "macos": { + "key": "Effective Fetch options", + "arguments": [] + } + }, + { + "id": "native.effectiveGit", + "description": "Native UI: Effective Git: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Effective Git: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.effectiveGit.variant2", + "description": "Native UI: Effective Git: %@ · %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Effective Git: %@ · %@", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string" + } + }, + { + "id": "native.effectiveGitFromPATH", + "description": "Native UI: Effective Git: from PATH", + "platforms": [ + "macos" + ], + "macos": { + "key": "Effective Git: from PATH", + "arguments": [] + } + }, + { + "id": "native.effectiveJDK", + "description": "Native UI: Effective JDK", + "platforms": [ + "macos" + ], + "macos": { + "key": "Effective JDK", + "arguments": [] + } + }, + { + "id": "native.effectiveSource", + "description": "Native UI: Effective source", + "platforms": [ + "macos" + ], + "macos": { + "key": "Effective source", + "arguments": [] + } + }, + { + "id": "native.effectiveValue", + "description": "Native UI: Effective value", + "platforms": [ + "macos" + ], + "macos": { + "key": "Effective value", + "arguments": [] + } + }, + { + "id": "native.effectiveValue.variant2", + "description": "Native UI: Effective value: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Effective value: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.elapsed", + "description": "Native UI: Elapsed", + "platforms": [ + "macos" + ], + "macos": { + "key": "Elapsed", + "arguments": [] + } + }, + { + "id": "native.elapsed.variant2", + "description": "Native UI: Elapsed: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Elapsed: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.elapsedLldS", + "description": "Native UI: Elapsed %lld s", + "platforms": [ + "macos" + ], + "macos": { + "key": "Elapsed %lld s", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.elapsedSinceSendingIncludingToolsAndPermissionWaits", + "description": "Native UI: Elapsed since sending, including tools and permission waits.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Elapsed since sending, including tools and permission waits.", + "arguments": [] + } + }, + { + "id": "native.elixirSupport", + "description": "Native UI: Elixir Support", + "platforms": [ + "macos" + ], + "macos": { + "key": "Elixir Support", + "arguments": [] + } + }, + { + "id": "native.email", + "description": "Native UI: Email", + "platforms": [ + "macos" + ], + "macos": { + "key": "Email", + "arguments": [] + } + }, + { + "id": "native.enableAgentConversation", + "description": "Native UI: Enable Agent conversation", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enable Agent conversation", + "arguments": [] + } + }, + { + "id": "native.enableBreakpoint", + "description": "Native UI: Enable breakpoint", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enable breakpoint", + "arguments": [] + } + }, + { + "id": "native.enableBreakpoints", + "description": "Native UI: Enable breakpoints", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enable breakpoints", + "arguments": [] + } + }, + { + "id": "native.enableExceptionBreakpoint", + "description": "Native UI: Enable %@ exception breakpoint", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enable %@ exception breakpoint", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.enableFieldBreakpoint", + "description": "Native UI: Enable %@ field breakpoint", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enable %@ field breakpoint", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.enableForThisProject", + "description": "Native UI: Enable for this project", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enable for this project", + "arguments": [] + } + }, + { + "id": "native.enableMCP", + "description": "Native UI: Enable MCP", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enable MCP", + "arguments": [] + } + }, + { + "id": "native.enableMethodBreakpoint", + "description": "Native UI: Enable %@ method breakpoint", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enable %@ method breakpoint", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.enabled", + "description": "Native UI: %@ enabled", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ enabled", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.enabled.variant2", + "description": "Native UI: Enabled", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enabled", + "arguments": [] + } + }, + { + "id": "native.enabledPluginsCanAlsoUseThisProjectGrant", + "description": "Native UI: Enabled plugins can also use this project grant.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enabled plugins can also use this project grant.", + "arguments": [] + } + }, + { + "id": "native.endAll", + "description": "Native UI: End All", + "platforms": [ + "macos" + ], + "macos": { + "key": "End All", + "arguments": [] + } + }, + { + "id": "native.endAll.variant2", + "description": "Native UI: End all", + "platforms": [ + "macos" + ], + "macos": { + "key": "End all", + "arguments": [] + } + }, + { + "id": "native.endAllAndQuit", + "description": "Native UI: End All and Quit", + "platforms": [ + "macos" + ], + "macos": { + "key": "End All and Quit", + "arguments": [] + } + }, + { + "id": "native.endChecked", + "description": "Native UI: End Checked", + "platforms": [ + "macos" + ], + "macos": { + "key": "End Checked", + "arguments": [] + } + }, + { + "id": "native.endOnNextLaunch", + "description": "Native UI: End on next launch", + "platforms": [ + "macos" + ], + "macos": { + "key": "End on next launch", + "arguments": [] + } + }, + { + "id": "native.endThisTask", + "description": "Native UI: End This Task", + "platforms": [ + "macos" + ], + "macos": { + "key": "End This Task", + "arguments": [] + } + }, + { + "id": "native.endThisTerminal", + "description": "Native UI: End This Terminal", + "platforms": [ + "macos" + ], + "macos": { + "key": "End This Terminal", + "arguments": [] + } + }, + { + "id": "native.endWithoutAsking", + "description": "Native UI: End without asking", + "platforms": [ + "macos" + ], + "macos": { + "key": "End without asking", + "arguments": [] + } + }, + { + "id": "native.english", + "description": "Native UI: English", + "platforms": [ + "macos" + ], + "macos": { + "key": "English", + "arguments": [] + } + }, + { + "id": "native.enterABranchName", + "description": "Native UI: Enter a branch name", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enter a branch name", + "arguments": [] + } + }, + { + "id": "native.enterABranchName.variant2", + "description": "Native UI: Enter a branch name.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enter a branch name.", + "arguments": [] + } + }, + { + "id": "native.enterABranchNameBeforePublishing", + "description": "Native UI: Enter a branch name before publishing.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enter a branch name before publishing.", + "arguments": [] + } + }, + { + "id": "native.enterAChangeListName1100Characters", + "description": "Native UI: Enter a ChangeList name (1–100 characters).", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enter a ChangeList name (1–100 characters).", + "arguments": [] + } + }, + { + "id": "native.enterACommitMessage", + "description": "Native UI: Enter a commit message", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enter a commit message", + "arguments": [] + } + }, + { + "id": "native.enterACompleteServerURL", + "description": "Native UI: Enter a complete server URL.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enter a complete server URL.", + "arguments": [] + } + }, + { + "id": "native.enterAModelIDSupportedByYourProvider", + "description": "Native UI: Enter a model ID supported by your provider.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enter a model ID supported by your provider.", + "arguments": [] + } + }, + { + "id": "native.enterANonemptyGitIdentityWithoutControlCharactersOrAngleBrackets", + "description": "Native UI: Enter a nonempty Git identity without control characters or angle brackets", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enter a nonempty Git identity without control characters or angle brackets", + "arguments": [] + } + }, + { + "id": "native.enterAPIKey", + "description": "Native UI: Enter API key", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enter API key", + "arguments": [] + } + }, + { + "id": "native.enterAProviderName", + "description": "Native UI: Enter a provider name.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enter a provider name.", + "arguments": [] + } + }, + { + "id": "native.enterAServerName", + "description": "Native UI: Enter a server name.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enter a server name.", + "arguments": [] + } + }, + { + "id": "native.enterATagName", + "description": "Native UI: Enter a tag name", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enter a tag name", + "arguments": [] + } + }, + { + "id": "native.enterATagNameBranchNameCommitHashOrOtherGitRevision", + "description": "Native UI: Enter a tag name, branch name, commit hash, or other Git revision.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enter a tag name, branch name, commit hash, or other Git revision.", + "arguments": [] + } + }, + { + "id": "native.enterAWholeNumberFromDToD", + "description": "Native UI: Enter a whole number from %d to %d.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enter a whole number from %d to %d.", + "arguments": [ + "arg1", + "arg2" + ], + "formats": { + "arg1": "d", + "arg2": "d" + } + }, + "parameters": { + "arg1": "integer", + "arg2": "integer" + } + }, + { + "id": "native.enterAddress", + "description": "Native UI: Enter address", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enter address", + "arguments": [] + } + }, + { + "id": "native.enterAnAPIKeyCLILoginCredentialsCannotBeUsedAsAnAPIKey", + "description": "Native UI: Enter an API key. CLI login credentials cannot be used as an API key.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enter an API key. CLI login credentials cannot be used as an API key.", + "arguments": [] + } + }, + { + "id": "native.enterAnHTTPOrHTTPSAPIURLWithoutCredentialsQueryParametersOrFragments", + "description": "Native UI: Enter an HTTP or HTTPS API URL without credentials, query parameters or fragments.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enter an HTTP or HTTPS API URL without credentials, query parameters or fragments.", + "arguments": [] + } + }, + { + "id": "native.enterModelID", + "description": "Native UI: Enter model ID", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enter model ID", + "arguments": [] + } + }, + { + "id": "native.enterTheCommandThatStartsThisServer", + "description": "Native UI: Enter the command that starts this server.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enter the command that starts this server.", + "arguments": [] + } + }, + { + "id": "native.enterTheNextFunctionCall", + "description": "Native UI: Enter the next function call", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enter the next function call", + "arguments": [] + } + }, + { + "id": "native.enterTheOneTimeCode", + "description": "Native UI: Enter the one-time code", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enter the one-time code", + "arguments": [] + } + }, + { + "id": "native.environment", + "description": "Native UI: Environment", + "platforms": [ + "macos" + ], + "macos": { + "key": "Environment", + "arguments": [] + } + }, + { + "id": "native.environmentVariableAndHeaderNamesMustBeUnique", + "description": "Native UI: Environment variable and header names must be unique.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Environment variable and header names must be unique.", + "arguments": [] + } + }, + { + "id": "native.environmentVariables", + "description": "Native UI: Environment variables", + "platforms": [ + "macos" + ], + "macos": { + "key": "Environment variables", + "arguments": [] + } + }, + { + "id": "native.erlangSupport", + "description": "Native UI: Erlang Support", + "platforms": [ + "macos" + ], + "macos": { + "key": "Erlang Support", + "arguments": [] + } + }, + { + "id": "native.error", + "description": "Native UI: Error", + "platforms": [ + "macos" + ], + "macos": { + "key": "Error", + "arguments": [] + } + }, + { + "id": "native.errors", + "description": "Native UI: Errors", + "platforms": [ + "macos" + ], + "macos": { + "key": "Errors", + "arguments": [] + } + }, + { + "id": "native.errorsOnly", + "description": "Native UI: Errors only", + "platforms": [ + "macos" + ], + "macos": { + "key": "Errors only", + "arguments": [] + } + }, + { + "id": "native.estimatedFromTheSizeOfTheLatestRequest", + "description": "Native UI: Estimated from the size of the latest request.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Estimated from the size of the latest request.", + "arguments": [] + } + }, + { + "id": "native.estimatedFromTheSizeOfTheLatestRequestAndScaledToTheProviderSInputTokens", + "description": "Native UI: Estimated from the size of the latest request and scaled to the provider's input tokens.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Estimated from the size of the latest request and scaled to the provider's input tokens.", + "arguments": [] + } + }, + { + "id": "native.evaluateExpression", + "description": "Native UI: Evaluate expression", + "platforms": [ + "macos" + ], + "macos": { + "key": "Evaluate expression", + "arguments": [] + } + }, + { + "id": "native.evaluateExpressionWhilePaused", + "description": "Native UI: Evaluate expression while paused", + "platforms": [ + "macos" + ], + "macos": { + "key": "Evaluate expression while paused", + "arguments": [] + } + }, + { + "id": "native.example", + "description": "Native UI: Example", + "platforms": [ + "macos" + ], + "macos": { + "key": "Example", + "arguments": [] + } + }, + { + "id": "native.exception", + "description": "Native UI: Exception", + "platforms": [ + "macos" + ], + "macos": { + "key": "Exception", + "arguments": [] + } + }, + { + "id": "native.exceptionBreakpoints", + "description": "Native UI: Exception Breakpoints", + "platforms": [ + "macos" + ], + "macos": { + "key": "Exception Breakpoints", + "arguments": [] + } + }, + { + "id": "native.excludeFromDependencyTree", + "description": "Native UI: Exclude from dependency tree", + "platforms": [ + "macos" + ], + "macos": { + "key": "Exclude from dependency tree", + "arguments": [] + } + }, + { + "id": "native.excluded", + "description": "Native UI: Excluded", + "platforms": [ + "macos" + ], + "macos": { + "key": "Excluded", + "arguments": [] + } + }, + { + "id": "native.excludedPaths", + "description": "Native UI: Excluded Paths", + "platforms": [ + "macos" + ], + "macos": { + "key": "Excluded Paths", + "arguments": [] + } + }, + { + "id": "native.executable", + "description": "Native UI: Executable", + "platforms": [ + "macos" + ], + "macos": { + "key": "Executable", + "arguments": [] + } + }, + { + "id": "native.executeMavenGoal", + "description": "Native UI: Execute Maven Goal", + "platforms": [ + "macos" + ], + "macos": { + "key": "Execute Maven Goal", + "arguments": [] + } + }, + { + "id": "native.executeMavenGoal.variant2", + "description": "Native UI: Execute Maven goal", + "platforms": [ + "macos" + ], + "macos": { + "key": "Execute Maven goal", + "arguments": [] + } + }, + { + "id": "native.executeTheNextSourceLine", + "description": "Native UI: Execute the next source line", + "platforms": [ + "macos" + ], + "macos": { + "key": "Execute the next source line", + "arguments": [] + } + }, + { + "id": "native.existingLocalBranch", + "description": "Native UI: Existing local branch", + "platforms": [ + "macos" + ], + "macos": { + "key": "Existing local branch", + "arguments": [] + } + }, + { + "id": "native.exitCodeLld", + "description": "Native UI: Exit code %lld", + "platforms": [ + "macos" + ], + "macos": { + "key": "Exit code %lld", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.expand", + "description": "Native UI: Expand", + "platforms": [ + "macos" + ], + "macos": { + "key": "Expand", + "arguments": [] + } + }, + { + "id": "native.expandDirectory", + "description": "Native UI: Expand directory", + "platforms": [ + "macos" + ], + "macos": { + "key": "Expand directory", + "arguments": [] + } + }, + { + "id": "native.expandJSONBlock", + "description": "Native UI: Expand JSON block", + "platforms": [ + "macos" + ], + "macos": { + "key": "Expand JSON block", + "arguments": [] + } + }, + { + "id": "native.expandLldLines", + "description": "Native UI: Expand %lld lines", + "platforms": [ + "macos" + ], + "macos": { + "key": "Expand %lld lines", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.expandNotification", + "description": "Native UI: Expand notification", + "platforms": [ + "macos" + ], + "macos": { + "key": "Expand notification", + "arguments": [] + } + }, + { + "id": "native.expandRepository", + "description": "Native UI: Expand repository", + "platforms": [ + "macos" + ], + "macos": { + "key": "Expand repository", + "arguments": [] + } + }, + { + "id": "native.expandSection", + "description": "Native UI: Expand section", + "platforms": [ + "macos" + ], + "macos": { + "key": "Expand section", + "arguments": [] + } + }, + { + "id": "native.expandTemporaryGitConfigurationOrCommandDetails", + "description": "Native UI: Expand temporary Git configuration or command details", + "platforms": [ + "macos" + ], + "macos": { + "key": "Expand temporary Git configuration or command details", + "arguments": [] + } + }, + { + "id": "native.expanded", + "description": "Native UI: Expanded", + "platforms": [ + "macos" + ], + "macos": { + "key": "Expanded", + "arguments": [] + } + }, + { + "id": "native.explainTheIntentTestingAndAnythingReviewersShouldKnow", + "description": "Native UI: Explain the intent, testing, and anything reviewers should know…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Explain the intent, testing, and anything reviewers should know…", + "arguments": [] + } + }, + { + "id": "native.explainWhatMustChangeBeforeThisPullRequestCanBeApproved", + "description": "Native UI: Explain what must change before this pull request can be approved.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Explain what must change before this pull request can be approved.", + "arguments": [] + } + }, + { + "id": "native.export", + "description": "Native UI: Export…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Export…", + "arguments": [] + } + }, + { + "id": "native.exportAsPDF", + "description": "Native UI: Export as PDF…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Export as PDF…", + "arguments": [] + } + }, + { + "id": "native.exportConversations", + "description": "Native UI: Export conversations", + "platforms": [ + "macos" + ], + "macos": { + "key": "Export conversations", + "arguments": [] + } + }, + { + "id": "native.exportDiagnosticsBundle", + "description": "Native UI: Export Diagnostics Bundle", + "platforms": [ + "macos" + ], + "macos": { + "key": "Export Diagnostics Bundle", + "arguments": [] + } + }, + { + "id": "native.exportDiagnosticsBundle.variant2", + "description": "Native UI: Export Diagnostics Bundle…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Export Diagnostics Bundle…", + "arguments": [] + } + }, + { + "id": "native.exportLogs", + "description": "Native UI: Export logs", + "platforms": [ + "macos" + ], + "macos": { + "key": "Export logs", + "arguments": [] + } + }, + { + "id": "native.exportingConversations", + "description": "Native UI: Exporting conversations…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Exporting conversations…", + "arguments": [] + } + }, + { + "id": "native.exportsTheIndexAgainstHEAD", + "description": "Native UI: Exports the index against HEAD.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Exports the index against HEAD.", + "arguments": [] + } + }, + { + "id": "native.exportsTheWorkingTreeSNetChangesAgainstHEADIncludingSelectedUntrackedFiles", + "description": "Native UI: Exports the working tree's net changes against HEAD, including selected untracked files.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Exports the working tree's net changes against HEAD, including selected untracked files.", + "arguments": [] + } + }, + { + "id": "native.exportsWorkingFilesAgainstTheIndexIncludingSelectedUntrackedFiles", + "description": "Native UI: Exports working files against the index, including selected untracked files.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Exports working files against the index, including selected untracked files.", + "arguments": [] + } + }, + { + "id": "native.expressionHistory", + "description": "Native UI: Expression history", + "platforms": [ + "macos" + ], + "macos": { + "key": "Expression history", + "arguments": [] + } + }, + { + "id": "native.externalChange", + "description": "Native UI: External change", + "platforms": [ + "macos" + ], + "macos": { + "key": "External change", + "arguments": [] + } + }, + { + "id": "native.externalEditsConflictWithUnsavedChanges", + "description": "Native UI: External edits conflict with unsaved changes", + "platforms": [ + "macos" + ], + "macos": { + "key": "External edits conflict with unsaved changes", + "arguments": [] + } + }, + { + "id": "native.extraDeepReasoningForDemandingTasks", + "description": "Native UI: Extra deep reasoning for demanding tasks", + "platforms": [ + "macos" + ], + "macos": { + "key": "Extra deep reasoning for demanding tasks", + "arguments": [] + } + }, + { + "id": "native.fSupport", + "description": "Native UI: F# Support", + "platforms": [ + "macos" + ], + "macos": { + "key": "F# Support", + "arguments": [] + } + }, + { + "id": "native.failed", + "description": "Native UI: %@ (%@ failed)", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ (%@ failed)", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string" + } + }, + { + "id": "native.failed.variant2", + "description": "Native UI: Failed", + "platforms": [ + "macos" + ], + "macos": { + "key": "Failed", + "arguments": [] + } + }, + { + "id": "native.failedToStartCheckTheLanguageServerConfiguration", + "description": "Native UI: Failed to start; check the language server configuration", + "platforms": [ + "macos" + ], + "macos": { + "key": "Failed to start; check the language server configuration", + "arguments": [] + } + }, + { + "id": "native.failures", + "description": "Native UI: Failures", + "platforms": [ + "macos" + ], + "macos": { + "key": "Failures", + "arguments": [] + } + }, + { + "id": "native.fast", + "description": "Native UI: Fast", + "platforms": [ + "macos" + ], + "macos": { + "key": "Fast", + "arguments": [] + } + }, + { + "id": "native.fastForwardOnPull", + "description": "Native UI: Fast-forward on pull", + "platforms": [ + "macos" + ], + "macos": { + "key": "Fast-forward on pull", + "arguments": [] + } + }, + { + "id": "native.fastForwardOnly", + "description": "Native UI: Fast-forward only", + "platforms": [ + "macos" + ], + "macos": { + "key": "Fast-forward only", + "arguments": [] + } + }, + { + "id": "native.fastMode", + "description": "Native UI: Fast mode", + "platforms": [ + "macos" + ], + "macos": { + "key": "Fast mode", + "arguments": [] + } + }, + { + "id": "native.favorites", + "description": "Native UI: Favorites", + "platforms": [ + "macos" + ], + "macos": { + "key": "Favorites", + "arguments": [] + } + }, + { + "id": "native.featEditorAddMemoryUsageIndicator", + "description": "Native UI: feat(editor): add memory usage indicator", + "platforms": [ + "macos" + ], + "macos": { + "key": "feat(editor): add memory usage indicator", + "arguments": [] + } + }, + { + "id": "native.features", + "description": "Native UI: Features", + "platforms": [ + "macos" + ], + "macos": { + "key": "Features", + "arguments": [] + } + }, + { + "id": "native.fetch", + "description": "Native UI: Fetch", + "platforms": [ + "macos" + ], + "macos": { + "key": "Fetch", + "arguments": [] + } + }, + { + "id": "native.fetchAgain", + "description": "Native UI: Fetch again", + "platforms": [ + "macos" + ], + "macos": { + "key": "Fetch again", + "arguments": [] + } + }, + { + "id": "native.fetchAll", + "description": "Native UI: Fetch all", + "platforms": [ + "macos" + ], + "macos": { + "key": "Fetch all", + "arguments": [] + } + }, + { + "id": "native.fetchAllRemotes", + "description": "Native UI: Fetch All Remotes", + "platforms": [ + "macos" + ], + "macos": { + "key": "Fetch All Remotes", + "arguments": [] + } + }, + { + "id": "native.fetchAllRemotes.variant2", + "description": "Native UI: Fetch all remotes", + "platforms": [ + "macos" + ], + "macos": { + "key": "Fetch all remotes", + "arguments": [] + } + }, + { + "id": "native.fetchAllSubmodules", + "description": "Native UI: Fetch all submodules", + "platforms": [ + "macos" + ], + "macos": { + "key": "Fetch all submodules", + "arguments": [] + } + }, + { + "id": "native.fetchAllTags", + "description": "Native UI: Fetch all tags", + "platforms": [ + "macos" + ], + "macos": { + "key": "Fetch all tags", + "arguments": [] + } + }, + { + "id": "native.fetchAndCommitPreferences", + "description": "Native UI: Fetch and commit preferences", + "platforms": [ + "macos" + ], + "macos": { + "key": "Fetch and commit preferences", + "arguments": [] + } + }, + { + "id": "native.fetchCommandFailed", + "description": "Native UI: Fetch command failed", + "platforms": [ + "macos" + ], + "macos": { + "key": "Fetch command failed", + "arguments": [] + } + }, + { + "id": "native.fetchCommandSucceeded", + "description": "Native UI: Fetch command succeeded", + "platforms": [ + "macos" + ], + "macos": { + "key": "Fetch command succeeded", + "arguments": [] + } + }, + { + "id": "native.fetchCompletedWithNoReferenceChanges", + "description": "Native UI: Fetch completed with no reference changes.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Fetch completed with no reference changes.", + "arguments": [] + } + }, + { + "id": "native.fetchDefaults", + "description": "Native UI: Fetch defaults", + "platforms": [ + "macos" + ], + "macos": { + "key": "Fetch defaults", + "arguments": [] + } + }, + { + "id": "native.fetchFailedNoCompletedGitInvocationWasReported", + "description": "Native UI: Fetch failed — no completed Git invocation was reported", + "platforms": [ + "macos" + ], + "macos": { + "key": "Fetch failed — no completed Git invocation was reported", + "arguments": [] + } + }, + { + "id": "native.fetchInProgressPlannedCommandWaitingForResult", + "description": "Native UI: Fetch in progress — planned command; waiting for result", + "platforms": [ + "macos" + ], + "macos": { + "key": "Fetch in progress — planned command; waiting for result", + "arguments": [] + } + }, + { + "id": "native.fetchLocalConfiguration", + "description": "Native UI: Fetch local configuration", + "platforms": [ + "macos" + ], + "macos": { + "key": "Fetch local configuration", + "arguments": [] + } + }, + { + "id": "native.fetchOptions", + "description": "Native UI: Fetch Options", + "platforms": [ + "macos" + ], + "macos": { + "key": "Fetch Options", + "arguments": [] + } + }, + { + "id": "native.fetchOptions.variant2", + "description": "Native UI: Fetch Options…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Fetch Options…", + "arguments": [] + } + }, + { + "id": "native.fetchSubmodules", + "description": "Native UI: Fetch submodules", + "platforms": [ + "macos" + ], + "macos": { + "key": "Fetch submodules", + "arguments": [] + } + }, + { + "id": "native.fetchSubmodulesOnDemand", + "description": "Native UI: Fetch submodules on demand", + "platforms": [ + "macos" + ], + "macos": { + "key": "Fetch submodules on demand", + "arguments": [] + } + }, + { + "id": "native.fetchTags", + "description": "Native UI: Fetch tags", + "platforms": [ + "macos" + ], + "macos": { + "key": "Fetch tags", + "arguments": [] + } + }, + { + "id": "native.fetchTagsThatPointToFetchedCommits", + "description": "Native UI: Fetch tags that point to fetched commits", + "platforms": [ + "macos" + ], + "macos": { + "key": "Fetch tags that point to fetched commits", + "arguments": [] + } + }, + { + "id": "native.fetchThisAgentSLocalConfigurationInThePanelSettings", + "description": "Native UI: Fetch this Agent's local configuration in the panel settings.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Fetch this Agent's local configuration in the panel settings.", + "arguments": [] + } + }, + { + "id": "native.fetchWhenNeeded", + "description": "Native UI: Fetch when needed", + "platforms": [ + "macos" + ], + "macos": { + "key": "Fetch when needed", + "arguments": [] + } + }, + { + "id": "native.fetchYourLocalConfigurationSoTheAgentCanSignIn", + "description": "Native UI: Fetch your local configuration so the Agent can sign in.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Fetch your local configuration so the Agent can sign in.", + "arguments": [] + } + }, + { + "id": "native.fetchedGitRemotes", + "description": "Native UI: Fetched Git remotes", + "platforms": [ + "macos" + ], + "macos": { + "key": "Fetched Git remotes", + "arguments": [] + } + }, + { + "id": "native.fetchedLldPage", + "description": "Native UI: Fetched %lld page", + "platforms": [ + "macos" + ], + "macos": { + "key": "Fetched %lld page", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.fetchedLldPages", + "description": "Native UI: Fetched %lld pages", + "platforms": [ + "macos" + ], + "macos": { + "key": "Fetched %lld pages", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.fieldBreakpoints", + "description": "Native UI: Field Breakpoints", + "platforms": [ + "macos" + ], + "macos": { + "key": "Field Breakpoints", + "arguments": [] + } + }, + { + "id": "native.fileContext", + "description": "Native UI: File context", + "platforms": [ + "macos" + ], + "macos": { + "key": "File context", + "arguments": [] + } + }, + { + "id": "native.fileContextIsNotSupportedYet", + "description": "Native UI: File context is not supported yet", + "platforms": [ + "macos" + ], + "macos": { + "key": "File context is not supported yet", + "arguments": [] + } + }, + { + "id": "native.fileLldOfLld", + "description": "Native UI: File %lld of %lld", + "platforms": [ + "macos" + ], + "macos": { + "key": "File %lld of %lld", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "integer", + "arg2": "integer" + } + }, + { + "id": "native.fileSaved", + "description": "Native UI: File saved", + "platforms": [ + "macos" + ], + "macos": { + "key": "File saved", + "arguments": [] + } + }, + { + "id": "native.fileTreeRowHeight", + "description": "Native UI: File tree row height", + "platforms": [ + "macos" + ], + "macos": { + "key": "File tree row height", + "arguments": [] + } + }, + { + "id": "native.files", + "description": "Native UI: Files", + "platforms": [ + "macos" + ], + "macos": { + "key": "Files", + "arguments": [] + } + }, + { + "id": "native.files.variant2", + "description": "Native UI: files", + "platforms": [ + "macos" + ], + "macos": { + "key": "files", + "arguments": [] + } + }, + { + "id": "native.filesAddedSaveYourEditorChangesBeforeSendingSoTheAgentCanReadThem", + "description": "Native UI: Files added. Save your editor changes before sending so the Agent can read them.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Files added. Save your editor changes before sending so the Agent can read them.", + "arguments": [] + } + }, + { + "id": "native.filesReturnToTheDefaultChangeListAndMayBeIncludedByStageAllTheirGitStagingStateIsUnchanged", + "description": "Native UI: Files return to the default ChangeList and may be included by Stage All. Their Git staging state is unchanged.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Files return to the default ChangeList and may be included by Stage All. Their Git staging state is unchanged.", + "arguments": [] + } + }, + { + "id": "native.filesWillStayUnchangedNothingWillBeStagedOrCommittedAutomatically", + "description": "Native UI: Files will stay unchanged. Nothing will be staged or committed automatically.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Files will stay unchanged. Nothing will be staged or committed automatically.", + "arguments": [] + } + }, + { + "id": "native.filterByChangedPath", + "description": "Native UI: Filter by changed path", + "platforms": [ + "macos" + ], + "macos": { + "key": "Filter by changed path", + "arguments": [] + } + }, + { + "id": "native.filterByTitleAuthorOrLabel", + "description": "Native UI: Filter by title, author, or label", + "platforms": [ + "macos" + ], + "macos": { + "key": "Filter by title, author, or label", + "arguments": [] + } + }, + { + "id": "native.filterLogMessages", + "description": "Native UI: Filter log messages", + "platforms": [ + "macos" + ], + "macos": { + "key": "Filter log messages", + "arguments": [] + } + }, + { + "id": "native.filterProblemsBySeverity", + "description": "Native UI: Filter problems by severity", + "platforms": [ + "macos" + ], + "macos": { + "key": "Filter problems by severity", + "arguments": [] + } + }, + { + "id": "native.filterResults", + "description": "Native UI: Filter results", + "platforms": [ + "macos" + ], + "macos": { + "key": "Filter results", + "arguments": [] + } + }, + { + "id": "native.find", + "description": "Native UI: Find", + "platforms": [ + "macos" + ], + "macos": { + "key": "Find", + "arguments": [] + } + }, + { + "id": "native.findFilesAndActions", + "description": "Native UI: Find files and actions", + "platforms": [ + "macos" + ], + "macos": { + "key": "Find files and actions", + "arguments": [] + } + }, + { + "id": "native.findInFile", + "description": "Native UI: Find in File", + "platforms": [ + "macos" + ], + "macos": { + "key": "Find in File", + "arguments": [] + } + }, + { + "id": "native.findInFile.variant2", + "description": "Native UI: Find in File…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Find in File…", + "arguments": [] + } + }, + { + "id": "native.findInFile.variant3", + "description": "Native UI: Find in file", + "platforms": [ + "macos" + ], + "macos": { + "key": "Find in file", + "arguments": [] + } + }, + { + "id": "native.findInFiles", + "description": "Native UI: Find in Files", + "platforms": [ + "macos" + ], + "macos": { + "key": "Find in Files", + "arguments": [] + } + }, + { + "id": "native.findInGitConsole", + "description": "Native UI: Find in Git console", + "platforms": [ + "macos" + ], + "macos": { + "key": "Find in Git console", + "arguments": [] + } + }, + { + "id": "native.findInLog", + "description": "Native UI: Find in log", + "platforms": [ + "macos" + ], + "macos": { + "key": "Find in log", + "arguments": [] + } + }, + { + "id": "native.findInPage", + "description": "Native UI: Find in page", + "platforms": [ + "macos" + ], + "macos": { + "key": "Find in page", + "arguments": [] + } + }, + { + "id": "native.findNext", + "description": "Native UI: Find Next", + "platforms": [ + "macos" + ], + "macos": { + "key": "Find Next", + "arguments": [] + } + }, + { + "id": "native.findPrevious", + "description": "Native UI: Find Previous", + "platforms": [ + "macos" + ], + "macos": { + "key": "Find Previous", + "arguments": [] + } + }, + { + "id": "native.findReferencesToTheSelectedJavaSymbol", + "description": "Native UI: Find references to the selected Java symbol", + "platforms": [ + "macos" + ], + "macos": { + "key": "Find references to the selected Java symbol", + "arguments": [] + } + }, + { + "id": "native.findReferencesToTheSelectedSymbol", + "description": "Native UI: Find references to the selected symbol", + "platforms": [ + "macos" + ], + "macos": { + "key": "Find references to the selected symbol", + "arguments": [] + } + }, + { + "id": "native.findUsages", + "description": "Native UI: Find Usages", + "platforms": [ + "macos" + ], + "macos": { + "key": "Find Usages", + "arguments": [] + } + }, + { + "id": "native.finished", + "description": "Native UI: Finished", + "platforms": [ + "macos" + ], + "macos": { + "key": "Finished", + "arguments": [] + } + }, + { + "id": "native.firstLldLocations", + "description": "Native UI: First %lld locations", + "platforms": [ + "macos" + ], + "macos": { + "key": "First %lld locations", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.fixTheFailingChecksBelowBeforeInstalling", + "description": "Native UI: Fix the failing checks below before installing.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Fix the failing checks below before installing.", + "arguments": [] + } + }, + { + "id": "native.fixup", + "description": "Native UI: Fixup", + "platforms": [ + "macos" + ], + "macos": { + "key": "Fixup", + "arguments": [] + } + }, + { + "id": "native.folderName", + "description": "Native UI: Folder name", + "platforms": [ + "macos" + ], + "macos": { + "key": "Folder name", + "arguments": [] + } + }, + { + "id": "native.followGitSetting", + "description": "Native UI: Follow Git setting", + "platforms": [ + "macos" + ], + "macos": { + "key": "Follow Git setting", + "arguments": [] + } + }, + { + "id": "native.followGitTagRules", + "description": "Native UI: Follow Git tag rules", + "platforms": [ + "macos" + ], + "macos": { + "key": "Follow Git tag rules", + "arguments": [] + } + }, + { + "id": "native.followTheInstructionsYouDefineBelow", + "description": "Native UI: Follow the instructions you define below", + "platforms": [ + "macos" + ], + "macos": { + "key": "Follow the instructions you define below", + "arguments": [] + } + }, + { + "id": "native.followsEachRemoteSTagSetting", + "description": "Native UI: Follows each remote's tag setting", + "platforms": [ + "macos" + ], + "macos": { + "key": "Follows each remote's tag setting", + "arguments": [] + } + }, + { + "id": "native.font", + "description": "Native UI: Font", + "platforms": [ + "macos" + ], + "macos": { + "key": "Font", + "arguments": [] + } + }, + { + "id": "native.forceCheckout", + "description": "Native UI: Force Checkout", + "platforms": [ + "macos" + ], + "macos": { + "key": "Force Checkout", + "arguments": [] + } + }, + { + "id": "native.forceRemove", + "description": "Native UI: Force Remove", + "platforms": [ + "macos" + ], + "macos": { + "key": "Force Remove", + "arguments": [] + } + }, + { + "id": "native.forceRemoveWorktree", + "description": "Native UI: Force remove worktree?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Force remove worktree?", + "arguments": [] + } + }, + { + "id": "native.forget", + "description": "Native UI: Forget", + "platforms": [ + "macos" + ], + "macos": { + "key": "Forget", + "arguments": [] + } + }, + { + "id": "native.format", + "description": "Native UI: Format", + "platforms": [ + "macos" + ], + "macos": { + "key": "Format", + "arguments": [] + } + }, + { + "id": "native.formatJSON", + "description": "Native UI: Format JSON", + "platforms": [ + "macos" + ], + "macos": { + "key": "Format JSON", + "arguments": [] + } + }, + { + "id": "native.forward", + "description": "Native UI: Forward", + "platforms": [ + "macos" + ], + "macos": { + "key": "Forward", + "arguments": [] + } + }, + { + "id": "native.found", + "description": "Native UI: found", + "platforms": [ + "macos" + ], + "macos": { + "key": "found", + "arguments": [] + } + }, + { + "id": "native.framesAreNotAvailable", + "description": "Native UI: Frames are not available", + "platforms": [ + "macos" + ], + "macos": { + "key": "Frames are not available", + "arguments": [] + } + }, + { + "id": "native.freeSpace", + "description": "Native UI: Free space", + "platforms": [ + "macos" + ], + "macos": { + "key": "Free space", + "arguments": [] + } + }, + { + "id": "native.fromJAVAHOME", + "description": "Native UI: from JAVA_HOME", + "platforms": [ + "macos" + ], + "macos": { + "key": "from JAVA_HOME", + "arguments": [] + } + }, + { + "id": "native.fromUTCISO8601", + "description": "Native UI: From (UTC ISO 8601)", + "platforms": [ + "macos" + ], + "macos": { + "key": "From (UTC ISO 8601)", + "arguments": [] + } + }, + { + "id": "native.frontendAndOtherNonJavaRootsKeepTheWorkspaceEditorAndTerminalSettingsJavaAndMa2434fd734d", + "description": "Native UI: Frontend and other non-Java roots keep the workspace editor and terminal settings. Java and Maven SDKs only apply to the backends listed on the left.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Frontend and other non-Java roots keep the workspace editor and terminal settings. Java and Maven SDKs only apply to the backends listed on the left.", + "arguments": [] + } + }, + { + "id": "native.fullAccess", + "description": "Native UI: Full access", + "platforms": [ + "macos" + ], + "macos": { + "key": "Full access", + "arguments": [] + } + }, + { + "id": "native.fullProgramDeclaration", + "description": "Native UI: Full Program Declaration", + "platforms": [ + "macos" + ], + "macos": { + "key": "Full Program Declaration", + "arguments": [] + } + }, + { + "id": "native.gatheringDiagnostics", + "description": "Native UI: Gathering diagnostics…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Gathering diagnostics…", + "arguments": [] + } + }, + { + "id": "native.general", + "description": "Native UI: General", + "platforms": [ + "macos" + ], + "macos": { + "key": "General", + "arguments": [] + } + }, + { + "id": "native.generateACommitMessageFromStagedDiffs", + "description": "Native UI: Generate a commit message from staged diffs", + "platforms": [ + "macos" + ], + "macos": { + "key": "Generate a commit message from staged diffs", + "arguments": [] + } + }, + { + "id": "native.generateACommitMessageFromTheSelectedDiff", + "description": "Native UI: Generate a commit message from the selected diff", + "platforms": [ + "macos" + ], + "macos": { + "key": "Generate a commit message from the selected diff", + "arguments": [] + } + }, + { + "id": "native.generateATitleAndDescriptionFromTheSelectedBranchChanges", + "description": "Native UI: Generate a title and description from the selected branch changes", + "platforms": [ + "macos" + ], + "macos": { + "key": "Generate a title and description from the selected branch changes", + "arguments": [] + } + }, + { + "id": "native.generateLitheRunGeneratedJsonToEnableRunAndDebugExistingProjectAndLocalOverridesWillBePreserved", + "description": "Native UI: Generate .lithe/run/generated.json to enable Run and Debug. Existing project and local overrides will be preserved.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Generate .lithe/run/generated.json to enable Run and Debug. Existing project and local overrides will be preserved.", + "arguments": [] + } + }, + { + "id": "native.generatePreview", + "description": "Native UI: Generate Preview", + "platforms": [ + "macos" + ], + "macos": { + "key": "Generate Preview", + "arguments": [] + } + }, + { + "id": "native.generateWithAI", + "description": "Native UI: Generate with AI", + "platforms": [ + "macos" + ], + "macos": { + "key": "Generate with AI", + "arguments": [] + } + }, + { + "id": "native.generated1RunnableProjectEntry", + "description": "Native UI: Generated 1 runnable project entry.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Generated 1 runnable project entry.", + "arguments": [] + } + }, + { + "id": "native.generatedFromThePluginSDeclaration", + "description": "Native UI: Generated from the plugin's declaration.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Generated from the plugin's declaration.", + "arguments": [] + } + }, + { + "id": "native.generatedLldRunnableProjectEntries", + "description": "Native UI: Generated %lld runnable project entries.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Generated %lld runnable project entries.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.generatedMain", + "description": "Native UI: generated main", + "platforms": [ + "macos" + ], + "macos": { + "key": "generated main", + "arguments": [] + } + }, + { + "id": "native.generatedTest", + "description": "Native UI: generated test", + "platforms": [ + "macos" + ], + "macos": { + "key": "generated test", + "arguments": [] + } + }, + { + "id": "native.generating", + "description": "Native UI: Generating…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Generating…", + "arguments": [] + } + }, + { + "id": "native.gitAuthentication", + "description": "Native UI: Git authentication", + "platforms": [ + "macos" + ], + "macos": { + "key": "Git authentication", + "arguments": [] + } + }, + { + "id": "native.gitBehaviorAndConfiguration", + "description": "Native UI: Git behavior and configuration", + "platforms": [ + "macos" + ], + "macos": { + "key": "Git behavior and configuration", + "arguments": [] + } + }, + { + "id": "native.gitBranchOperation", + "description": "Native UI: Git branch operation", + "platforms": [ + "macos" + ], + "macos": { + "key": "Git branch operation", + "arguments": [] + } + }, + { + "id": "native.gitBranchOptions", + "description": "Native UI: Git branch options", + "platforms": [ + "macos" + ], + "macos": { + "key": "Git branch options", + "arguments": [] + } + }, + { + "id": "native.gitCanRequestCredentialsInLithePasswordsAreNotSavedByLitheTheSelectedGitHelper42412f0efe", + "description": "Native UI: Git can request credentials in Lithe. Passwords are not saved by Lithe; the selected Git helper controls credential storage.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Git can request credentials in Lithe. Passwords are not saved by Lithe; the selected Git helper controls credential storage.", + "arguments": [] + } + }, + { + "id": "native.gitCommandFailed", + "description": "Native UI: Git command failed", + "platforms": [ + "macos" + ], + "macos": { + "key": "Git command failed", + "arguments": [] + } + }, + { + "id": "native.gitCommandIsRunning", + "description": "Native UI: Git command is running", + "platforms": [ + "macos" + ], + "macos": { + "key": "Git command is running", + "arguments": [] + } + }, + { + "id": "native.gitCommandOutputWillAppearHere", + "description": "Native UI: Git command output will appear here.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Git command output will appear here.", + "arguments": [] + } + }, + { + "id": "native.gitCommandSucceeded", + "description": "Native UI: Git command succeeded", + "platforms": [ + "macos" + ], + "macos": { + "key": "Git command succeeded", + "arguments": [] + } + }, + { + "id": "native.gitCompletedSuccessfullyWithNoOutput", + "description": "Native UI: Git completed successfully with no output.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Git completed successfully with no output.", + "arguments": [] + } + }, + { + "id": "native.gitConfiguration", + "description": "Native UI: Git configuration", + "platforms": [ + "macos" + ], + "macos": { + "key": "Git configuration", + "arguments": [] + } + }, + { + "id": "native.gitConfigurationSaved", + "description": "Native UI: Git configuration saved", + "platforms": [ + "macos" + ], + "macos": { + "key": "Git configuration saved", + "arguments": [] + } + }, + { + "id": "native.gitConfigurationSaved.variant2", + "description": "Native UI: Git configuration saved: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Git configuration saved: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.gitCouldNotConfirmTheFinalStateRefreshHistoryBeforePerformingAnotherAction", + "description": "Native UI: Git could not confirm the final state. Refresh history before performing another action.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Git could not confirm the final state. Refresh history before performing another action.", + "arguments": [] + } + }, + { + "id": "native.gitDefault", + "description": "Native UI: Git default", + "platforms": [ + "macos" + ], + "macos": { + "key": "Git default", + "arguments": [] + } + }, + { + "id": "native.gitExecutable", + "description": "Native UI: Git executable", + "platforms": [ + "macos" + ], + "macos": { + "key": "Git executable", + "arguments": [] + } + }, + { + "id": "native.gitExecutable.variant2", + "description": "Native UI: Git executable: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Git executable: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.gitExecution", + "description": "Native UI: Git execution", + "platforms": [ + "macos" + ], + "macos": { + "key": "Git execution", + "arguments": [] + } + }, + { + "id": "native.gitExitedWithCodeD", + "description": "Native UI: Git exited with code %d", + "platforms": [ + "macos" + ], + "macos": { + "key": "Git exited with code %d", + "arguments": [ + "arg1" + ], + "formats": { + "arg1": "d" + } + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.gitFetchRemoveStaleBranches", + "description": "Native UI: Git Fetch: remove stale branches", + "platforms": [ + "macos" + ], + "macos": { + "key": "Git Fetch: remove stale branches", + "arguments": [] + } + }, + { + "id": "native.gitFetchRemoveStaleTags", + "description": "Native UI: Git Fetch: remove stale tags", + "platforms": [ + "macos" + ], + "macos": { + "key": "Git Fetch: remove stale tags", + "arguments": [] + } + }, + { + "id": "native.gitIsPreparingTheSequencer", + "description": "Native UI: Git is preparing the sequencer.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Git is preparing the sequencer.", + "arguments": [] + } + }, + { + "id": "native.gitIsReady", + "description": "Native UI: Git is ready", + "platforms": [ + "macos" + ], + "macos": { + "key": "Git is ready", + "arguments": [] + } + }, + { + "id": "native.gitNotFound", + "description": "Native UI: Git not found", + "platforms": [ + "macos" + ], + "macos": { + "key": "Git not found", + "arguments": [] + } + }, + { + "id": "native.gitOperation", + "description": "Native UI: Git operation", + "platforms": [ + "macos" + ], + "macos": { + "key": "Git operation", + "arguments": [] + } + }, + { + "id": "native.gitOperationFinished", + "description": "Native UI: Git operation finished", + "platforms": [ + "macos" + ], + "macos": { + "key": "Git operation finished", + "arguments": [] + } + }, + { + "id": "native.gitPhase", + "description": "Native UI: Git phase: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Git phase: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.gitProgramAndCredentials", + "description": "Native UI: Git Program and Credentials", + "platforms": [ + "macos" + ], + "macos": { + "key": "Git Program and Credentials", + "arguments": [] + } + }, + { + "id": "native.gitProgress", + "description": "Native UI: Git progress", + "platforms": [ + "macos" + ], + "macos": { + "key": "Git progress", + "arguments": [] + } + }, + { + "id": "native.gitRecordsThisNameAndEmailInNewCommitsTheseSettingsDoNotChangeExistingCommits", + "description": "Native UI: Git records this name and email in new commits. These settings do not change existing commits.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Git records this name and email in new commits. These settings do not change existing commits.", + "arguments": [] + } + }, + { + "id": "native.gitRequestedCredentialsAgainCheckThePreviousAnswer", + "description": "Native UI: Git requested credentials again. Check the previous answer.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Git requested credentials again. Check the previous answer.", + "arguments": [] + } + }, + { + "id": "native.gitSetupIsUnavailable", + "description": "Native UI: Git setup is unavailable.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Git setup is unavailable.", + "arguments": [] + } + }, + { + "id": "native.gitStash", + "description": "Native UI: Git stash", + "platforms": [ + "macos" + ], + "macos": { + "key": "Git stash", + "arguments": [] + } + }, + { + "id": "native.gitStashes", + "description": "Native UI: Git Stashes", + "platforms": [ + "macos" + ], + "macos": { + "key": "Git Stashes", + "arguments": [] + } + }, + { + "id": "native.gitToolWindowActions", + "description": "Native UI: Git tool window actions", + "platforms": [ + "macos" + ], + "macos": { + "key": "Git tool window actions", + "arguments": [] + } + }, + { + "id": "native.gitVersion", + "description": "Native UI: Git version", + "platforms": [ + "macos" + ], + "macos": { + "key": "Git version", + "arguments": [] + } + }, + { + "id": "native.gitWasNotFound", + "description": "Native UI: Git was not found", + "platforms": [ + "macos" + ], + "macos": { + "key": "Git was not found", + "arguments": [] + } + }, + { + "id": "native.githubAuthorizationFailed", + "description": "Native UI: GitHub authorization failed:", + "platforms": [ + "macos" + ], + "macos": { + "key": "GitHub authorization failed:", + "arguments": [] + } + }, + { + "id": "native.githubAuthorizationWasCancelled", + "description": "Native UI: GitHub authorization was cancelled.", + "platforms": [ + "macos" + ], + "macos": { + "key": "GitHub authorization was cancelled.", + "arguments": [] + } + }, + { + "id": "native.githubCouldNotBeReachedCheckYourInternetProxyOrVPNConnectionAndTryAgain", + "description": "Native UI: GitHub could not be reached. Check your internet, proxy, or VPN connection and try again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "GitHub could not be reached. Check your internet, proxy, or VPN connection and try again.", + "arguments": [] + } + }, + { + "id": "native.githubDidNotReturnAnyFileChangesForThisPullRequest", + "description": "Native UI: GitHub did not return any file changes for this pull request.", + "platforms": [ + "macos" + ], + "macos": { + "key": "GitHub did not return any file changes for this pull request.", + "arguments": [] + } + }, + { + "id": "native.githubRejectedTheUpdateRequestBecauseASharedAPILimitWasReachedOpenTheReleasePa741bf3b6a2", + "description": "Native UI: GitHub rejected the update request because a shared API limit was reached. Open the Release page or try again after the limit resets.", + "platforms": [ + "macos" + ], + "macos": { + "key": "GitHub rejected the update request because a shared API limit was reached. Open the Release page or try again after the limit resets.", + "arguments": [] + } + }, + { + "id": "native.githubReturnedAnUnexpectedResponse", + "description": "Native UI: GitHub returned an unexpected response", + "platforms": [ + "macos" + ], + "macos": { + "key": "GitHub returned an unexpected response", + "arguments": [] + } + }, + { + "id": "native.githubSignInAndPullRequestManagementAreTemporarilyUnavailable", + "description": "Native UI: GitHub sign-in and pull request management are temporarily unavailable.", + "platforms": [ + "macos" + ], + "macos": { + "key": "GitHub sign-in and pull request management are temporarily unavailable.", + "arguments": [] + } + }, + { + "id": "native.githubSignInIsUnavailableInThisBuild", + "description": "Native UI: GitHub sign-in is unavailable in this build.", + "platforms": [ + "macos" + ], + "macos": { + "key": "GitHub sign-in is unavailable in this build.", + "arguments": [] + } + }, + { + "id": "native.githubUser", + "description": "Native UI: GitHub user %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "GitHub user %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.globalGit", + "description": "Native UI: Global Git", + "platforms": [ + "macos" + ], + "macos": { + "key": "Global Git", + "arguments": [] + } + }, + { + "id": "native.globalGitConfiguration", + "description": "Native UI: Global Git configuration", + "platforms": [ + "macos" + ], + "macos": { + "key": "Global Git configuration", + "arguments": [] + } + }, + { + "id": "native.globalIdentityAppliesToOtherRepositoriesUnlessTheyOverrideIt", + "description": "Native UI: Global identity applies to other repositories unless they override it.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Global identity applies to other repositories unless they override it.", + "arguments": [] + } + }, + { + "id": "native.goSupport", + "description": "Native UI: Go Support", + "platforms": [ + "macos" + ], + "macos": { + "key": "Go Support", + "arguments": [] + } + }, + { + "id": "native.goToChildCommit", + "description": "Native UI: Go to child commit %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Go to child commit %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.goToDefinition", + "description": "Native UI: Go to Definition", + "platforms": [ + "macos" + ], + "macos": { + "key": "Go to Definition", + "arguments": [] + } + }, + { + "id": "native.goToImplementation", + "description": "Native UI: Go to Implementation", + "platforms": [ + "macos" + ], + "macos": { + "key": "Go to Implementation", + "arguments": [] + } + }, + { + "id": "native.goToLine", + "description": "Native UI: Go to Line", + "platforms": [ + "macos" + ], + "macos": { + "key": "Go to Line", + "arguments": [] + } + }, + { + "id": "native.goToLine.variant2", + "description": "Native UI: Go to Line…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Go to Line…", + "arguments": [] + } + }, + { + "id": "native.goToLineColumn", + "description": "Native UI: Go to Line:Column", + "platforms": [ + "macos" + ], + "macos": { + "key": "Go to Line:Column", + "arguments": [] + } + }, + { + "id": "native.goToParentCommit", + "description": "Native UI: Go to parent commit %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Go to parent commit %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.goToUsage", + "description": "Native UI: Go to Usage", + "platforms": [ + "macos" + ], + "macos": { + "key": "Go to Usage", + "arguments": [] + } + }, + { + "id": "native.graphqlSupport", + "description": "Native UI: GraphQL Support", + "platforms": [ + "macos" + ], + "macos": { + "key": "GraphQL Support", + "arguments": [] + } + }, + { + "id": "native.greekLetters", + "description": "Native UI: Greek letters", + "platforms": [ + "macos" + ], + "macos": { + "key": "Greek letters", + "arguments": [] + } + }, + { + "id": "native.groovySupport", + "description": "Native UI: Groovy Support", + "platforms": [ + "macos" + ], + "macos": { + "key": "Groovy Support", + "arguments": [] + } + }, + { + "id": "native.groups", + "description": "Native UI: Groups", + "platforms": [ + "macos" + ], + "macos": { + "key": "Groups", + "arguments": [] + } + }, + { + "id": "native.hardResetDiscardChanges", + "description": "Native UI: Hard Reset (Discard Changes)", + "platforms": [ + "macos" + ], + "macos": { + "key": "Hard Reset (Discard Changes)", + "arguments": [] + } + }, + { + "id": "native.haskellSupport", + "description": "Native UI: Haskell Support", + "platforms": [ + "macos" + ], + "macos": { + "key": "Haskell Support", + "arguments": [] + } + }, + { + "id": "native.head", + "description": "Native UI: HEAD", + "platforms": [ + "macos" + ], + "macos": { + "key": "HEAD", + "arguments": [] + } + }, + { + "id": "native.headAdvancedReviewBeforeContinuing", + "description": "Native UI: HEAD advanced; review before continuing.", + "platforms": [ + "macos" + ], + "macos": { + "key": "HEAD advanced; review before continuing.", + "arguments": [] + } + }, + { + "id": "native.headBranch", + "description": "Native UI: Head branch", + "platforms": [ + "macos" + ], + "macos": { + "key": "Head branch", + "arguments": [] + } + }, + { + "id": "native.headCurrentBranch", + "description": "Native UI: HEAD (Current Branch)", + "platforms": [ + "macos" + ], + "macos": { + "key": "HEAD (Current Branch)", + "arguments": [] + } + }, + { + "id": "native.headWillMoveToItsParentTheIndexAndWorkingFilesStayAsTheyAreStagedDifferencesWi69609861ef", + "description": "Native UI: HEAD will move to its parent. The index and working files stay as they are; staged differences will be compared with the parent commit.", + "platforms": [ + "macos" + ], + "macos": { + "key": "HEAD will move to its parent. The index and working files stay as they are; staged differences will be compared with the parent commit.", + "arguments": [] + } + }, + { + "id": "native.header", + "description": "Native UI: Header", + "platforms": [ + "macos" + ], + "macos": { + "key": "Header", + "arguments": [] + } + }, + { + "id": "native.headers", + "description": "Native UI: Headers", + "platforms": [ + "macos" + ], + "macos": { + "key": "Headers", + "arguments": [] + } + }, + { + "id": "native.heapMemory", + "description": "Native UI: Heap Memory", + "platforms": [ + "macos" + ], + "macos": { + "key": "Heap Memory", + "arguments": [] + } + }, + { + "id": "native.hiddenPaths", + "description": "Native UI: Hidden paths", + "platforms": [ + "macos" + ], + "macos": { + "key": "Hidden paths", + "arguments": [] + } + }, + { + "id": "native.hideAPIKey", + "description": "Native UI: Hide API key", + "platforms": [ + "macos" + ], + "macos": { + "key": "Hide API key", + "arguments": [] + } + }, + { + "id": "native.hideBuildOutputToolWindow", + "description": "Native UI: Hide Build Output tool window", + "platforms": [ + "macos" + ], + "macos": { + "key": "Hide Build Output tool window", + "arguments": [] + } + }, + { + "id": "native.hideBuildToolWindow", + "description": "Native UI: Hide Build tool window", + "platforms": [ + "macos" + ], + "macos": { + "key": "Hide Build tool window", + "arguments": [] + } + }, + { + "id": "native.hideCommitDecorations", + "description": "Native UI: Hide commit decorations", + "platforms": [ + "macos" + ], + "macos": { + "key": "Hide commit decorations", + "arguments": [] + } + }, + { + "id": "native.hideConfigurationList", + "description": "Native UI: Hide configuration list", + "platforms": [ + "macos" + ], + "macos": { + "key": "Hide configuration list", + "arguments": [] + } + }, + { + "id": "native.hideDebugToolWindow", + "description": "Native UI: Hide Debug tool window", + "platforms": [ + "macos" + ], + "macos": { + "key": "Hide Debug tool window", + "arguments": [] + } + }, + { + "id": "native.hideEntry", + "description": "Native UI: Hide Entry", + "platforms": [ + "macos" + ], + "macos": { + "key": "Hide Entry", + "arguments": [] + } + }, + { + "id": "native.hideGitBlame", + "description": "Native UI: Hide Git Blame", + "platforms": [ + "macos" + ], + "macos": { + "key": "Hide Git Blame", + "arguments": [] + } + }, + { + "id": "native.hideGitToolWindow", + "description": "Native UI: Hide Git tool window", + "platforms": [ + "macos" + ], + "macos": { + "key": "Hide Git tool window", + "arguments": [] + } + }, + { + "id": "native.hideMavenBuildToolWindow", + "description": "Native UI: Hide Maven Build tool window", + "platforms": [ + "macos" + ], + "macos": { + "key": "Hide Maven Build tool window", + "arguments": [] + } + }, + { + "id": "native.hideMavenToolWindow", + "description": "Native UI: Hide Maven tool window", + "platforms": [ + "macos" + ], + "macos": { + "key": "Hide Maven tool window", + "arguments": [] + } + }, + { + "id": "native.hidePerformance", + "description": "Native UI: Hide Performance", + "platforms": [ + "macos" + ], + "macos": { + "key": "Hide Performance", + "arguments": [] + } + }, + { + "id": "native.hidePlan", + "description": "Native UI: Hide plan", + "platforms": [ + "macos" + ], + "macos": { + "key": "Hide plan", + "arguments": [] + } + }, + { + "id": "native.hideProblemsToolWindow", + "description": "Native UI: Hide Problems tool window", + "platforms": [ + "macos" + ], + "macos": { + "key": "Hide Problems tool window", + "arguments": [] + } + }, + { + "id": "native.hideRunToolWindow", + "description": "Native UI: Hide Run tool window", + "platforms": [ + "macos" + ], + "macos": { + "key": "Hide Run tool window", + "arguments": [] + } + }, + { + "id": "native.hideSessionDetails", + "description": "Native UI: Hide Session Details", + "platforms": [ + "macos" + ], + "macos": { + "key": "Hide Session Details", + "arguments": [] + } + }, + { + "id": "native.hideTerminalToolWindow", + "description": "Native UI: Hide Terminal tool window", + "platforms": [ + "macos" + ], + "macos": { + "key": "Hide Terminal tool window", + "arguments": [] + } + }, + { + "id": "native.hideThinking", + "description": "Native UI: Hide thinking", + "platforms": [ + "macos" + ], + "macos": { + "key": "Hide thinking", + "arguments": [] + } + }, + { + "id": "native.hideToolDetails", + "description": "Native UI: Hide tool details", + "platforms": [ + "macos" + ], + "macos": { + "key": "Hide tool details", + "arguments": [] + } + }, + { + "id": "native.hideWorktreeRepositories", + "description": "Native UI: Hide worktree repositories", + "platforms": [ + "macos" + ], + "macos": { + "key": "Hide worktree repositories", + "arguments": [] + } + }, + { + "id": "native.high", + "description": "Native UI: High", + "platforms": [ + "macos" + ], + "macos": { + "key": "High", + "arguments": [] + } + }, + { + "id": "native.highlightWords", + "description": "Native UI: Highlight words", + "platforms": [ + "macos" + ], + "macos": { + "key": "Highlight words", + "arguments": [] + } + }, + { + "id": "native.hint", + "description": "Native UI: Hint", + "platforms": [ + "macos" + ], + "macos": { + "key": "Hint", + "arguments": [] + } + }, + { + "id": "native.history", + "description": "Native UI: History", + "platforms": [ + "macos" + ], + "macos": { + "key": "History", + "arguments": [] + } + }, + { + "id": "native.historyChangedButGitReportedAnIncompleteStepRefreshTheRepositoryAndInspectTheRf4eaf05b2a", + "description": "Native UI: History changed, but Git reported an incomplete step. Refresh the repository and inspect the recovery reference before retrying.", + "platforms": [ + "macos" + ], + "macos": { + "key": "History changed, but Git reported an incomplete step. Refresh the repository and inspect the recovery reference before retrying.", + "arguments": [] + } + }, + { + "id": "native.historyFilter", + "description": "Native UI: History filter", + "platforms": [ + "macos" + ], + "macos": { + "key": "History filter", + "arguments": [] + } + }, + { + "id": "native.hit", + "description": "Native UI: Hit: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Hit: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.homepage", + "description": "Native UI: Homepage", + "platforms": [ + "macos" + ], + "macos": { + "key": "Homepage", + "arguments": [] + } + }, + { + "id": "native.host", + "description": "Native UI: Host", + "platforms": [ + "macos" + ], + "macos": { + "key": "Host", + "arguments": [] + } + }, + { + "id": "native.howGitMatchesAndStoresCredentialsForRemoteOperations", + "description": "Native UI: How Git matches and stores credentials for remote operations.", + "platforms": [ + "macos" + ], + "macos": { + "key": "How Git matches and stores credentials for remote operations.", + "arguments": [] + } + }, + { + "id": "native.howMuchTheJavaLanguageServerWritesToItsOwnLogQuieterLevelsReduceDiskWrites", + "description": "Native UI: How much the Java language server writes to its own log. Quieter levels reduce disk writes.", + "platforms": [ + "macos" + ], + "macos": { + "key": "How much the Java language server writes to its own log. Quieter levels reduce disk writes.", + "arguments": [] + } + }, + { + "id": "native.howPullAndUpdateProjectCombineYourLocalAndRemoteCommits", + "description": "Native UI: How Pull and Update Project combine your local and remote commits.", + "platforms": [ + "macos" + ], + "macos": { + "key": "How Pull and Update Project combine your local and remote commits.", + "arguments": [] + } + }, + { + "id": "native.howUncommittedChangesArePutAsideBeforeSwitchingBranchesOrPulling", + "description": "Native UI: How uncommitted changes are put aside before switching branches or pulling.", + "platforms": [ + "macos" + ], + "macos": { + "key": "How uncommitted changes are put aside before switching branches or pulling.", + "arguments": [] + } + }, + { + "id": "native.htmlSupport", + "description": "Native UI: HTML Support", + "platforms": [ + "macos" + ], + "macos": { + "key": "HTML Support", + "arguments": [] + } + }, + { + "id": "native.httpIsBlockedUntilYouExplicitlyAllowItForThisProvider", + "description": "Native UI: HTTP is blocked until you explicitly allow it for this provider.", + "platforms": [ + "macos" + ], + "macos": { + "key": "HTTP is blocked until you explicitly allow it for this provider.", + "arguments": [] + } + }, + { + "id": "native.httpIsDisabledForThisProviderEnableTheInsecureHTTPOptionOrUseHTTPS", + "description": "Native UI: HTTP is disabled for this provider. Enable the insecure HTTP option or use HTTPS.", + "platforms": [ + "macos" + ], + "macos": { + "key": "HTTP is disabled for this provider. Enable the insecure HTTP option or use HTTPS.", + "arguments": [] + } + }, + { + "id": "native.httpSendsTheAPICredentialWithoutEncryptionUseOnlyATrustedEndpoint", + "description": "Native UI: HTTP sends the API credential without encryption. Use only a trusted endpoint.", + "platforms": [ + "macos" + ], + "macos": { + "key": "HTTP sends the API credential without encryption. Use only a trusted endpoint.", + "arguments": [] + } + }, + { + "id": "native.identifyAgain", + "description": "Native UI: Identify Again", + "platforms": [ + "macos" + ], + "macos": { + "key": "Identify Again", + "arguments": [] + } + }, + { + "id": "native.identifyAndGenerate", + "description": "Native UI: Identify and Generate", + "platforms": [ + "macos" + ], + "macos": { + "key": "Identify and Generate", + "arguments": [] + } + }, + { + "id": "native.identifyingProject", + "description": "Native UI: Identifying project…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Identifying project…", + "arguments": [] + } + }, + { + "id": "native.if", + "description": "Native UI: If: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "If: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.ifLitheCrashesOrIsForceQuitASmallHelperImmediatelyEndsItsTerminalsAndToolsAgenf47a67e797", + "description": "Native UI: If Lithe crashes or is force quit, a small helper immediately ends its terminals and tools. Agent tasks and background services are kept until the next launch, so nothing important is ended without you. Agent tasks are always asked about, whatever you choose here.", + "platforms": [ + "macos" + ], + "macos": { + "key": "If Lithe crashes or is force quit, a small helper immediately ends its terminals and tools. Agent tasks and background services are kept until the next launch, so nothing important is ended without you. Agent tasks are always asked about, whatever you choose here.", + "arguments": [] + } + }, + { + "id": "native.ifResetUse", + "description": "Native UI: If reset, use", + "platforms": [ + "macos" + ], + "macos": { + "key": "If reset, use", + "arguments": [] + } + }, + { + "id": "native.ignoreWhitespace", + "description": "Native UI: Ignore whitespace", + "platforms": [ + "macos" + ], + "macos": { + "key": "Ignore whitespace", + "arguments": [] + } + }, + { + "id": "native.ignoredEntriesLld", + "description": "Native UI: Ignored entries: %lld", + "platforms": [ + "macos" + ], + "macos": { + "key": "Ignored entries: %lld", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.imperativeSubject", + "description": "Native UI: Imperative subject", + "platforms": [ + "macos" + ], + "macos": { + "key": "Imperative subject", + "arguments": [] + } + }, + { + "id": "native.implementations", + "description": "Native UI: Implementations", + "platforms": [ + "macos" + ], + "macos": { + "key": "Implementations", + "arguments": [] + } + }, + { + "id": "native.importFromClaude", + "description": "Native UI: Import from Claude", + "platforms": [ + "macos" + ], + "macos": { + "key": "Import from Claude", + "arguments": [] + } + }, + { + "id": "native.importFromCodex", + "description": "Native UI: Import from Codex", + "platforms": [ + "macos" + ], + "macos": { + "key": "Import from Codex", + "arguments": [] + } + }, + { + "id": "native.inEffect", + "description": "Native UI: In effect: ", + "platforms": [ + "macos" + ], + "macos": { + "key": "In effect: ", + "arguments": [] + } + }, + { + "id": "native.include", + "description": "Native UI: Include", + "platforms": [ + "macos" + ], + "macos": { + "key": "Include", + "arguments": [] + } + }, + { + "id": "native.includeAShortBodyWhenUseful", + "description": "Native UI: Include a short body when useful", + "platforms": [ + "macos" + ], + "macos": { + "key": "Include a short body when useful", + "arguments": [] + } + }, + { + "id": "native.includeInCommit", + "description": "Native UI: Include in commit", + "platforms": [ + "macos" + ], + "macos": { + "key": "Include in commit", + "arguments": [] + } + }, + { + "id": "native.includeNonProjectItems", + "description": "Native UI: Include non-project items", + "platforms": [ + "macos" + ], + "macos": { + "key": "Include non-project items", + "arguments": [] + } + }, + { + "id": "native.includeTheURLPathWhenGitLooksUpHTTPCredentials", + "description": "Native UI: Include the URL path when Git looks up HTTP credentials.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Include the URL path when Git looks up HTTP credentials.", + "arguments": [] + } + }, + { + "id": "native.increaseFontSize", + "description": "Native UI: Increase Font Size", + "platforms": [ + "macos" + ], + "macos": { + "key": "Increase Font Size", + "arguments": [] + } + }, + { + "id": "native.indentation", + "description": "Native UI: Indentation", + "platforms": [ + "macos" + ], + "macos": { + "key": "Indentation", + "arguments": [] + } + }, + { + "id": "native.indexAndWorkingTree", + "description": "Native UI: Index and working tree", + "platforms": [ + "macos" + ], + "macos": { + "key": "Index and working tree", + "arguments": [] + } + }, + { + "id": "native.information", + "description": "Native UI: Information", + "platforms": [ + "macos" + ], + "macos": { + "key": "Information", + "arguments": [] + } + }, + { + "id": "native.inherit", + "description": "Native UI: Inherit", + "platforms": [ + "macos" + ], + "macos": { + "key": "Inherit", + "arguments": [] + } + }, + { + "id": "native.inheritProjectJDK", + "description": "Native UI: Inherit project JDK", + "platforms": [ + "macos" + ], + "macos": { + "key": "Inherit project JDK", + "arguments": [] + } + }, + { + "id": "native.inheritProjectMaven", + "description": "Native UI: Inherit project Maven", + "platforms": [ + "macos" + ], + "macos": { + "key": "Inherit project Maven", + "arguments": [] + } + }, + { + "id": "native.inheritProjectMavenJDK", + "description": "Native UI: Inherit project Maven JDK", + "platforms": [ + "macos" + ], + "macos": { + "key": "Inherit project Maven JDK", + "arguments": [] + } + }, + { + "id": "native.inheritedFromProject", + "description": "Native UI: Inherited from project", + "platforms": [ + "macos" + ], + "macos": { + "key": "Inherited from project", + "arguments": [] + } + }, + { + "id": "native.inheritedValue", + "description": "Native UI: Inherited value", + "platforms": [ + "macos" + ], + "macos": { + "key": "Inherited value", + "arguments": [] + } + }, + { + "id": "native.initializeARepositoryBeforeSavingRepositorySpecificIdentity", + "description": "Native UI: Initialize a repository before saving repository-specific identity", + "platforms": [ + "macos" + ], + "macos": { + "key": "Initialize a repository before saving repository-specific identity", + "arguments": [] + } + }, + { + "id": "native.initializeGitInThisProjectFolder", + "description": "Native UI: Initialize Git in this project folder?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Initialize Git in this project folder?", + "arguments": [] + } + }, + { + "id": "native.initializeGitRepository", + "description": "Native UI: Initialize Git Repository", + "platforms": [ + "macos" + ], + "macos": { + "key": "Initialize Git Repository", + "arguments": [] + } + }, + { + "id": "native.initializeGitRepository.variant2", + "description": "Native UI: Initialize Git Repository…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Initialize Git Repository…", + "arguments": [] + } + }, + { + "id": "native.initializeGitRepository.variant3", + "description": "Native UI: Initialize Git repository…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Initialize Git repository…", + "arguments": [] + } + }, + { + "id": "native.initializeGitToTrackChangesAndCreateYourFirstCommit", + "description": "Native UI: Initialize Git to track changes and create your first commit.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Initialize Git to track changes and create your first commit.", + "arguments": [] + } + }, + { + "id": "native.initializeThisProjectFromGitBeforeSavingRepositorySpecificIdentity", + "description": "Native UI: Initialize this project from Git before saving repository-specific identity.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Initialize this project from Git before saving repository-specific identity.", + "arguments": [] + } + }, + { + "id": "native.initializingProjectIndex", + "description": "Native UI: Initializing project index", + "platforms": [ + "macos" + ], + "macos": { + "key": "Initializing project index", + "arguments": [] + } + }, + { + "id": "native.inline.alwaysContinueInThisWorkspace", + "description": "Presentation copy: macos/Sources/Lithe/Views/Run/JavaLaunchDecisionDialog.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.arg1Finished", + "description": "Presentation copy: macos/Sources/Lithe/Models/AppModel/AppModel+PluginSettings.swift", + "platforms": [ + "macos" + ], + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.inline.arg1IsDeclaredByTheCatalogButNoLspSessionIsRunning", + "description": "Presentation copy: macos/Sources/Lithe/Models/AppModel/AppModel.swift", + "platforms": [ + "macos" + ], + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.inline.arg1IsDisabledEnableItFromPluginManagementFirst", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift", + "platforms": [ + "macos" + ], + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.inline.arg1IsInstalledRestartLitheBeforeControllingItsLanguageServerHere", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift", + "platforms": [ + "macos" + ], + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.inline.arg1LanguageServerReady", + "description": "Presentation copy: macos/Sources/Lithe/Models/AppModel/AppModel.swift", + "platforms": [ + "macos" + ], + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.inline.arg1LspExitedUnexpectedly", + "description": "Presentation copy: macos/Sources/Lithe/Models/AppModel/AppModel.swift", + "platforms": [ + "macos" + ], + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.inline.arg1LspIsDisabledInThisWorkspace", + "description": "Presentation copy: macos/Sources/Lithe/Models/AppModel/AppModel.swift", + "platforms": [ + "macos" + ], + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.inline.arg1MovedToLitheStoreAsACommunityPluginAndTheOldOfficialVersionGetsNoUpdate", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift", + "platforms": [ + "macos" + ], + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.inline.arg1NeedsThisPluginSBuildSystemWhichThisProjectDoesNotUse", + "description": "Presentation copy: macos/Sources/Lithe/Models/AppModel/AppModel+PluginSettings.swift", + "platforms": [ + "macos" + ], + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.inline.arg1WasQuarantinedAfterItsPreviousSessionReEnableItFromPluginManagement", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift", + "platforms": [ + "macos" + ], + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.inline.askAgain", + "description": "Presentation copy: macos/Sources/Lithe/Views/Workbench/ProjectPreparationStatusView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.buildElapsed", + "description": "Presentation copy: macos/Sources/Lithe/Views/Run/JavaLaunchDecisionDialog.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.buildingJavaProject", + "description": "Presentation copy: macos/Sources/Lithe/Views/Workbench/ProjectPreparationStatusView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.cancel", + "description": "Presentation copy: macos/Sources/Lithe/Views/Run/JavaLaunchDecisionDialog.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.choose", + "description": "Presentation copy: macos/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.chooseArg1LanguageServer", + "description": "Presentation copy: macos/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift", + "platforms": [ + "macos" + ], + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.inline.chooseExecutable", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LanguageServerSetupView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.chooseOrEnterAnAbsolutePath", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LanguageServerSetupView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.customPath", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LanguageServerSetupView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.enableArg1LanguageServer", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift", + "platforms": [ + "macos" + ], + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.inline.executableFoundNotVerified", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LanguageServerSetupView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.executableInvalid", + "description": "Selected-language presentation: The selected language-server path is not executable: {path}", + "platforms": [ + "macos" + ], + "parameters": { + "path": "string" + } + }, + { + "id": "native.inline.executableNotFound", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LanguageServerSetupView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.executableRequired", + "description": "Selected-language presentation: Choose a language-server executable.", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.executableValidationFailed", + "description": "Selected-language presentation: The selected language server could not run: {path}\n{message}", + "platforms": [ + "macos" + ], + "parameters": { + "path": "string", + "message": "string" + } + }, + { + "id": "native.inline.executableVerified", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LanguageServerSetupView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.expectedArg1", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LanguageServerSetupView.swift", + "platforms": [ + "macos" + ], + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.inline.failedToClearTheJavaIndex", + "description": "Presentation copy: macos/Sources/Lithe/Models/AppModel/AppModel+JavaIndex.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.failedToStartCheckTheLanguageServerConfiguration", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.failedToStartCheckTheLspRuntimeJdk", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.homebrewIsPreferredUseTheOfficialDownloadWhenHomebrewOrAFormulaIsUnavailabl", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LanguageServerSetupView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.homebrewUnavailable", + "description": "Selected-language presentation: Homebrew is not installed or is not available to Lithe.", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.homebrewUnsupported", + "description": "Selected-language presentation: No verified Homebrew formula is configured for {provider}.", + "platforms": [ + "macos" + ], + "parameters": { + "provider": "string" + } + }, + { + "id": "native.inline.homebrewWasNotFoundUseTheOfficialDownloadOrChooseAnExecutable", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LanguageServerSetupView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.importingJavaProjectAndDependencies", + "description": "Presentation copy: macos/Sources/Lithe/Views/Workbench/ProjectPreparationStatusView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.initializingArg1Lsp", + "description": "Presentation copy: macos/Sources/Lithe/Models/AppModel/AppModel.swift", + "platforms": [ + "macos" + ], + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.inline.initializingProjectIndex", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.installArg1", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LanguageServerSetupView.swift", + "platforms": [ + "macos" + ], + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.inline.installDetectAndSelectLspExecutables", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LanguageServerSetupView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.installation", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LanguageServerSetupView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.installedArg1", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LanguageServerSetupView.swift", + "platforms": [ + "macos" + ], + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.inline.installingWithHomebrew", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LanguageServerSetupView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.javaBuildDidNotSucceed", + "description": "Presentation copy: macos/Sources/Lithe/Views/Run/JavaLaunchDecisionDialog.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.javaIndexClearedReopenAJavaFileToRebuild", + "description": "Presentation copy: macos/Sources/Lithe/Models/AppModel/AppModel+JavaIndex.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.javaLaunchesAlwaysContinueAfterBuildFailuresInThisWorkspace", + "description": "Presentation copy: macos/Sources/Lithe/Views/Workbench/ProjectPreparationStatusView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.javaProjectModelReady", + "description": "Presentation copy: macos/Sources/Lithe/Views/Workbench/ProjectPreparationStatusView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.javaProjectPreparationFailed", + "description": "Presentation copy: macos/Sources/Lithe/Views/Workbench/ProjectPreparationStatusView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.javaProjectPreparationStopped", + "description": "Presentation copy: macos/Sources/Lithe/Views/Workbench/ProjectPreparationStatusView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.languageServer", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LanguageServerSetupView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.languageServerConfigurationIsDegradedCompatibilitySettingsAreInUse", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.languageServerDiagnosticsCleared", + "description": "Presentation copy: macos/Sources/Lithe/Models/AppModel/AppModel.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.languageServerStarted", + "description": "Presentation copy: macos/Sources/Lithe/Models/AppModel/AppModel.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.languageServerTools", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LanguageServerSetupView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.languageServiceConnectedApplyingMavenConfiguration", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.languageServiceSettingsAndRetry", + "description": "Presentation copy: macos/Sources/Lithe/Views/Workbench/ProjectPreparationStatusView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.markerScope", + "description": "Presentation copy: macos/Sources/Lithe/Views/Run/JavaLaunchDecisionDialog.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.noBrewFormula", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LanguageServerSetupView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.noConfigurableLanguageServers", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.noLaunchCommandIsConfigured", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LanguageServerSetupView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.noLspSessionIsRunning", + "description": "Presentation copy: macos/Sources/Lithe/Models/AppModel/AppModel.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.off", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.officialDownload", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LanguageServerSetupView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.onlyLanguagesUsedByThisProjectAreShownChangesAreSavedAutomaticallyForThisPr", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.openAProjectBeforeRebuildingTheJavaIndex", + "description": "Presentation copy: macos/Sources/Lithe/Models/AppModel/AppModel+JavaIndex.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.openAProjectThatUsesThisPluginFirst", + "description": "Presentation copy: macos/Sources/Lithe/Models/AppModel/AppModel+PluginSettings.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.openASupportedSourceFile", + "description": "Presentation copy: macos/Sources/Lithe/Models/AppModel/AppModel.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.openLogs", + "description": "Presentation copy: macos/Sources/Lithe/Views/Run/JavaLaunchDecisionDialog.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.openPluginManagement", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.rebuildJavaIndex", + "description": "Presentation copy: macos/Sources/Lithe/Views/Run/JavaLaunchDecisionDialog.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.requestedLaunchTargetAndDependencies", + "description": "Presentation copy: macos/Sources/Lithe/Views/Run/JavaLaunchDecisionDialog.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.resolvedExecutable", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LanguageServerSetupView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.restartLitheToFinishUninstallingTheOldArg1ThenInstallTheCommunityArg2FromLi", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift", + "platforms": [ + "macos" + ], + "parameters": { + "arg1": "string", + "arg2": "string" + }, + "optionalParameters": { + "arg2": "Repeated interpolation of the same value is expressed once in this locale." + } + }, + { + "id": "native.inline.retryMavenConfiguration", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.runAnyway", + "description": "Presentation copy: macos/Sources/Lithe/Views/Run/JavaLaunchDecisionDialog.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.runAnywayContinuesThisLaunchWithTheResolvedOutputAndDoesNotBuildAgain", + "description": "Presentation copy: macos/Sources/Lithe/Views/Run/JavaLaunchDecisionDialog.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.running", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.save", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LanguageServerSetupView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.settingsAppearHereWhenSupportedSourceLanguagesAreDetected", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.someModulesFailedArg1Arg2LanguageServiceRemainsAvailable", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift", + "platforms": [ + "macos" + ], + "parameters": { + "arg1": "string", + "arg2": "string" + } + }, + { + "id": "native.inline.starting", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.startingJavaService", + "description": "Presentation copy: macos/Sources/Lithe/Views/Workbench/ProjectPreparationStatusView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.startingTheArg1LspProcess", + "description": "Presentation copy: macos/Sources/Lithe/Models/AppModel/AppModel.swift", + "platforms": [ + "macos" + ], + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.inline.startsOnDemandWhenAMatchingFileIsOpened", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.stopping", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.stoppingArg1Lsp", + "description": "Presentation copy: macos/Sources/Lithe/Models/AppModel/AppModel.swift", + "platforms": [ + "macos" + ], + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.inline.synchronizingJavaProjectConfiguration", + "description": "Presentation copy: macos/Sources/Lithe/Views/Workbench/ProjectPreparationStatusView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.theJavaLanguageServiceBuilderFailedInThisSessionSoTheseErrorMarkersMayBeSta", + "description": "Presentation copy: macos/Sources/Lithe/Views/Run/JavaLaunchDecisionDialog.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.theJavaLanguageServiceReportedCompilationErrorsWhichMayComeFromDependencies", + "description": "Presentation copy: macos/Sources/Lithe/Views/Run/JavaLaunchDecisionDialog.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.theJavaWorkspaceMustFinishPreparationBeforeLaunchBackgroundIndexingDoesNotB", + "description": "Presentation copy: macos/Sources/Lithe/Views/Workbench/ProjectPreparationStatusView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.theNextLspSessionWillUseThisPath", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LanguageServerSetupView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.thisArg1ProjectNeedsArg2DownloadAndInstallItFromPluginManagement", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift", + "platforms": [ + "macos" + ], + "parameters": { + "arg1": "string", + "arg2": "string" + } + }, + { + "id": "native.inline.thisArg1ProjectNeedsArg2InstallItFromLitheStoreInThePluginManagementMarketp", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift", + "platforms": [ + "macos" + ], + "parameters": { + "arg1": "string", + "arg2": "string" + } + }, + { + "id": "native.inline.thisArg1ProjectNeedsArg2WhichWasRemovedRestoreItFromPluginManagementThenRes", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift", + "platforms": [ + "macos" + ], + "parameters": { + "arg1": "string", + "arg2": "string" + } + }, + { + "id": "native.inline.useAutomaticDetection", + "description": "Presentation copy: macos/Sources/Lithe/Views/Language/LanguageServerSetupView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.viewLogs", + "description": "Presentation copy: macos/Sources/Lithe/Views/Workbench/ProjectPreparationStatusView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.viewProjectPreparationDetails", + "description": "Presentation copy: macos/Sources/Lithe/Views/Workbench/ProjectPreparationStatusView.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.inline.workspaceMayIncludeUnrelatedModules", + "description": "Presentation copy: macos/Sources/Lithe/Views/Run/JavaLaunchDecisionDialog.swift", + "platforms": [ + "macos" + ] + }, + { + "id": "native.input", + "description": "Native UI: Input", + "platforms": [ + "macos" + ], + "macos": { + "key": "Input", + "arguments": [] + } + }, + { + "id": "native.input.variant2", + "description": "Native UI: Input: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Input: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.insideThePlugin", + "description": "Native UI: Inside the plugin", + "platforms": [ + "macos" + ], + "macos": { + "key": "Inside the plugin", + "arguments": [] + } + }, + { + "id": "native.inspectGitStatusAndTheCommandOutputThenContinueWhenReady", + "description": "Native UI: Inspect Git status and the command output, then continue when ready.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Inspect Git status and the command output, then continue when ready.", + "arguments": [] + } + }, + { + "id": "native.inspectTheCurrentGitStateBeforeRetryingTheRecoveryReferencePreservesTheOriginalHistory", + "description": "Native UI: Inspect the current Git state before retrying. The recovery reference preserves the original history.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Inspect the current Git state before retrying. The recovery reference preserves the original history.", + "arguments": [] + } + }, + { + "id": "native.installACPAdaptersCheckTheRuntimeAndFollowYourLocalCLIConfiguration", + "description": "Native UI: Install ACP adapters, check the runtime, and follow your local CLI configuration.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Install ACP adapters, check the runtime, and follow your local CLI configuration.", + "arguments": [] + } + }, + { + "id": "native.installACPAdaptersCheckTheRuntimeAndManageLocalOrCustomProviders", + "description": "Native UI: Install ACP adapters, check the runtime, and manage local or custom providers.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Install ACP adapters, check the runtime, and manage local or custom providers.", + "arguments": [] + } + }, + { + "id": "native.installAndRestart", + "description": "Native UI: Install and Restart", + "platforms": [ + "macos" + ], + "macos": { + "key": "Install and Restart", + "arguments": [] + } + }, + { + "id": "native.installAutoBuild", + "description": "Native UI: Install AutoBuild", + "platforms": [ + "macos" + ], + "macos": { + "key": "Install AutoBuild", + "arguments": [] + } + }, + { + "id": "native.installCommunityPlugin", + "description": "Native UI: Install community plugin “%@”?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Install community plugin “%@”?", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.installFromRepository", + "description": "Native UI: Install from repository", + "platforms": [ + "macos" + ], + "macos": { + "key": "Install from repository", + "arguments": [] + } + }, + { + "id": "native.installNodeJsAndNpmBeforeInstallingTheAdapter", + "description": "Native UI: Install Node.js and npm before installing the adapter.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Install Node.js and npm before installing the adapter.", + "arguments": [] + } + }, + { + "id": "native.installPHPSupportFromASignedPluginPackage", + "description": "Native UI: Install PHP Support from a signed plugin package.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Install PHP Support from a signed plugin package.", + "arguments": [] + } + }, + { + "id": "native.installPHPSupportPlugin", + "description": "Native UI: Install PHP Support Plugin", + "platforms": [ + "macos" + ], + "macos": { + "key": "Install PHP Support Plugin", + "arguments": [] + } + }, + { + "id": "native.installPluginFromDisk", + "description": "Native UI: Install Plugin from Disk", + "platforms": [ + "macos" + ], + "macos": { + "key": "Install Plugin from Disk", + "arguments": [] + } + }, + { + "id": "native.installPluginFromDisk.variant2", + "description": "Native UI: Install Plugin from Disk…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Install Plugin from Disk…", + "arguments": [] + } + }, + { + "id": "native.installPreview", + "description": "Native UI: Install Preview", + "platforms": [ + "macos" + ], + "macos": { + "key": "Install Preview", + "arguments": [] + } + }, + { + "id": "native.installTheSelectedChannel", + "description": "Native UI: Install the selected channel", + "platforms": [ + "macos" + ], + "macos": { + "key": "Install the selected channel", + "arguments": [] + } + }, + { + "id": "native.installation", + "description": "Native UI: Installation", + "platforms": [ + "macos" + ], + "macos": { + "key": "Installation", + "arguments": [] + } + }, + { + "id": "native.installation.variant2", + "description": "Native UI: Installation: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Installation: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.installed", + "description": "Native UI: Installed", + "platforms": [ + "macos" + ], + "macos": { + "key": "Installed", + "arguments": [] + } + }, + { + "id": "native.installed.variant2", + "description": "Native UI: Installed %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Installed %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.installedFrom", + "description": "Native UI: Installed from %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Installed from %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.installedFromDisk", + "description": "Native UI: Installed from Disk", + "platforms": [ + "macos" + ], + "macos": { + "key": "Installed from Disk", + "arguments": [] + } + }, + { + "id": "native.installedFromUpdatesOnlyComeFromThatRepository", + "description": "Native UI: Installed from %@. Updates only come from that repository.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Installed from %@. Updates only come from that repository.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.installedLldOfLldEnabled", + "description": "Native UI: Installed (%lld of %lld enabled)", + "platforms": [ + "macos" + ], + "macos": { + "key": "Installed (%lld of %lld enabled)", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "integer", + "arg2": "integer" + } + }, + { + "id": "native.installedOnThisMac", + "description": "Native UI: Installed on this Mac: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Installed on this Mac: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.installedUpdateToAvailable", + "description": "Native UI: Installed %@ · update to %@ available", + "platforms": [ + "macos" + ], + "macos": { + "key": "Installed %@ · update to %@ available", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string" + } + }, + { + "id": "native.installingPackages", + "description": "Native UI: Installing packages…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Installing packages…", + "arguments": [] + } + }, + { + "id": "native.installingUpdate", + "description": "Native UI: Installing update %@…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Installing update %@…", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.installingWithNpm", + "description": "Native UI: Installing with npm…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Installing with npm…", + "arguments": [] + } + }, + { + "id": "native.integrateIncomingChangesIntoCurrentBranchM", + "description": "Native UI: Integrate incoming changes into current branch (M)", + "platforms": [ + "macos" + ], + "macos": { + "key": "Integrate incoming changes into current branch (M)", + "arguments": [] + } + }, + { + "id": "native.interactiveRebase", + "description": "Native UI: Interactive Rebase", + "platforms": [ + "macos" + ], + "macos": { + "key": "Interactive Rebase", + "arguments": [] + } + }, + { + "id": "native.interactiveRebaseAborted", + "description": "Native UI: Interactive Rebase Aborted", + "platforms": [ + "macos" + ], + "macos": { + "key": "Interactive Rebase Aborted", + "arguments": [] + } + }, + { + "id": "native.interactiveRebaseCompleted", + "description": "Native UI: Interactive Rebase Completed", + "platforms": [ + "macos" + ], + "macos": { + "key": "Interactive Rebase Completed", + "arguments": [] + } + }, + { + "id": "native.interactiveRebaseConflicts", + "description": "Native UI: Interactive Rebase: Conflicts", + "platforms": [ + "macos" + ], + "macos": { + "key": "Interactive Rebase: Conflicts", + "arguments": [] + } + }, + { + "id": "native.interactiveRebaseEditCommit", + "description": "Native UI: Interactive Rebase: Edit Commit", + "platforms": [ + "macos" + ], + "macos": { + "key": "Interactive Rebase: Edit Commit", + "arguments": [] + } + }, + { + "id": "native.interactiveRebaseFailed", + "description": "Native UI: Interactive Rebase Failed", + "platforms": [ + "macos" + ], + "macos": { + "key": "Interactive Rebase Failed", + "arguments": [] + } + }, + { + "id": "native.interactiveRebaseInterrupted", + "description": "Native UI: Interactive Rebase Interrupted", + "platforms": [ + "macos" + ], + "macos": { + "key": "Interactive Rebase Interrupted", + "arguments": [] + } + }, + { + "id": "native.interactiveRebasePaused", + "description": "Native UI: Interactive Rebase Paused", + "platforms": [ + "macos" + ], + "macos": { + "key": "Interactive Rebase Paused", + "arguments": [] + } + }, + { + "id": "native.interactivelyRebaseFromHere", + "description": "Native UI: Interactively Rebase from Here…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Interactively Rebase from Here…", + "arguments": [] + } + }, + { + "id": "native.interface", + "description": "Native UI: Interface", + "platforms": [ + "macos" + ], + "macos": { + "key": "Interface", + "arguments": [] + } + }, + { + "id": "native.interrupt", + "description": "Native UI: Interrupt", + "platforms": [ + "macos" + ], + "macos": { + "key": "Interrupt", + "arguments": [] + } + }, + { + "id": "native.interrupted", + "description": "Native UI: Interrupted", + "platforms": [ + "macos" + ], + "macos": { + "key": "Interrupted", + "arguments": [] + } + }, + { + "id": "native.invalidGitTagName", + "description": "Native UI: Invalid Git tag name.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Invalid Git tag name.", + "arguments": [] + } + }, + { + "id": "native.invalidJSONCheckTheConfigurationFormat", + "description": "Native UI: Invalid JSON. Check the configuration format.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Invalid JSON. Check the configuration format.", + "arguments": [] + } + }, + { + "id": "native.invalidJVMHeapCounters", + "description": "Native UI: Invalid JVM heap counters.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Invalid JVM heap counters.", + "arguments": [] + } + }, + { + "id": "native.invalidJVMPerformanceOutput", + "description": "Native UI: Invalid JVM performance output.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Invalid JVM performance output.", + "arguments": [] + } + }, + { + "id": "native.invalidTOMLUseStraightQuotesInsteadOfSmartQuotes", + "description": "Native UI: Invalid TOML. Use straight quotes (\") instead of smart quotes (“ ”).", + "platforms": [ + "macos" + ], + "macos": { + "key": "Invalid TOML. Use straight quotes (\") instead of smart quotes (“ ”).", + "arguments": [] + } + }, + { + "id": "native.invert", + "description": "Native UI: Invert", + "platforms": [ + "macos" + ], + "macos": { + "key": "Invert", + "arguments": [] + } + }, + { + "id": "native.isNoLongerAnOfficialPluginItMovedToTheCommunityPluginInLitheStoreAndGetsNoFurt8a96fdb405", + "description": "Native UI: %@ is no longer an official plugin. It moved to the community plugin %@ in Lithe Store and gets no further updates here. Uninstall it, restart Lithe, then install the community version from the Marketplace.", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ is no longer an official plugin. It moved to the community plugin %@ in Lithe Store and gets no further updates here. Uninstall it, restart Lithe, then install the community version from the Marketplace.", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string" + } + }, + { + "id": "native.isNotAvailable", + "description": "Native UI: %@ is not available.", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ is not available.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.isNotInstalledLitheInstallsItWithYourNpmIntoItsApplicationData", + "description": "Native UI: %@ is not installed. Lithe installs it with your npm into its application data.", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ is not installed. Lithe installs it with your npm into its application data.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.isVersionOrNewerIsRequired", + "description": "Native UI: %@ is version %@; %@ or newer is required.", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ is version %@; %@ or newer is required.", + "arguments": [ + "arg1", + "arg2", + "arg3" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string", + "arg3": "string" + } + }, + { + "id": "native.itCanReadFilesRunCommandsAndEditCodeAfterAskingForPermission", + "description": "Native UI: It can read files, run commands and edit code after asking for permission.", + "platforms": [ + "macos" + ], + "macos": { + "key": "It can read files, run commands and edit code after asking for permission.", + "arguments": [] + } + }, + { + "id": "native.itsDetailsAndActionsWillAppearHere", + "description": "Native UI: Its details and actions will appear here.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Its details and actions will appear here.", + "arguments": [] + } + }, + { + "id": "native.japaneseCharacters", + "description": "Native UI: Japanese characters", + "platforms": [ + "macos" + ], + "macos": { + "key": "Japanese characters", + "arguments": [] + } + }, + { + "id": "native.javaApplication", + "description": "Native UI: Java Application", + "platforms": [ + "macos" + ], + "macos": { + "key": "Java Application", + "arguments": [] + } + }, + { + "id": "native.javaEntriesAppearAfterTheJavaLanguageServiceListsRunnableClasses", + "description": "Native UI: Java entries appear after the Java language service lists runnable classes.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Java entries appear after the Java language service lists runnable classes.", + "arguments": [] + } + }, + { + "id": "native.javaEntriesCouldNotBeRefreshed", + "description": "Native UI: Java entries could not be refreshed", + "platforms": [ + "macos" + ], + "macos": { + "key": "Java entries could not be refreshed", + "arguments": [] + } + }, + { + "id": "native.javaNavigationIsAvailableForJavaFiles", + "description": "Native UI: Java navigation is available for .java files", + "platforms": [ + "macos" + ], + "macos": { + "key": "Java navigation is available for .java files", + "arguments": [] + } + }, + { + "id": "native.javaNavigationIsIdle", + "description": "Native UI: Java navigation is idle", + "platforms": [ + "macos" + ], + "macos": { + "key": "Java navigation is idle", + "arguments": [] + } + }, + { + "id": "native.javaNavigationReady", + "description": "Native UI: Java navigation ready", + "platforms": [ + "macos" + ], + "macos": { + "key": "Java navigation ready", + "arguments": [] + } + }, + { + "id": "native.javaRebuildIndex", + "description": "Native UI: Java: Rebuild Index", + "platforms": [ + "macos" + ], + "macos": { + "key": "Java: Rebuild Index", + "arguments": [] + } + }, + { + "id": "native.javaRuntimeIsNotReadyRetryMavenReload", + "description": "Native UI: Java runtime is not ready. Retry Maven reload.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Java runtime is not ready. Retry Maven reload.", + "arguments": [] + } + }, + { + "id": "native.javaSDK", + "description": "Native UI: Java SDK", + "platforms": [ + "macos" + ], + "macos": { + "key": "Java SDK", + "arguments": [] + } + }, + { + "id": "native.javaServiceFailedToStart", + "description": "Native UI: Java service failed to start", + "platforms": [ + "macos" + ], + "macos": { + "key": "Java service failed to start", + "arguments": [] + } + }, + { + "id": "native.javaServiceFailedToStart.variant2", + "description": "Native UI: Java service failed to start: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Java service failed to start: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.javaServiceIsPreparing", + "description": "Native UI: Java service is preparing", + "platforms": [ + "macos" + ], + "macos": { + "key": "Java service is preparing", + "arguments": [] + } + }, + { + "id": "native.javaServiceIsReady", + "description": "Native UI: Java service is ready", + "platforms": [ + "macos" + ], + "macos": { + "key": "Java service is ready", + "arguments": [] + } + }, + { + "id": "native.javaServicePreparationTimedOut", + "description": "Native UI: Java service preparation timed out", + "platforms": [ + "macos" + ], + "macos": { + "key": "Java service preparation timed out", + "arguments": [] + } + }, + { + "id": "native.jdkHome", + "description": "Native UI: JDK Home", + "platforms": [ + "macos" + ], + "macos": { + "key": "JDK Home", + "arguments": [] + } + }, + { + "id": "native.jdkHome.variant2", + "description": "Native UI: JDK home", + "platforms": [ + "macos" + ], + "macos": { + "key": "JDK home", + "arguments": [] + } + }, + { + "id": "native.jdwpIsNotEncryptedPreferLocalhostOrAnSSHTunnel", + "description": "Native UI: JDWP is not encrypted; prefer localhost or an SSH tunnel", + "platforms": [ + "macos" + ], + "macos": { + "key": "JDWP is not encrypted; prefer localhost or an SSH tunnel", + "arguments": [] + } + }, + { + "id": "native.jdwpPort", + "description": "Native UI: JDWP port", + "platforms": [ + "macos" + ], + "macos": { + "key": "JDWP port", + "arguments": [] + } + }, + { + "id": "native.jsonSupport", + "description": "Native UI: JSON Support", + "platforms": [ + "macos" + ], + "macos": { + "key": "JSON Support", + "arguments": [] + } + }, + { + "id": "native.juliaSupport", + "description": "Native UI: Julia Support", + "platforms": [ + "macos" + ], + "macos": { + "key": "Julia Support", + "arguments": [] + } + }, + { + "id": "native.jumpToALineAndColumnInTheActiveEditor", + "description": "Native UI: Jump to a line and column in the active editor", + "platforms": [ + "macos" + ], + "macos": { + "key": "Jump to a line and column in the active editor", + "arguments": [] + } + }, + { + "id": "native.jumpToLatest", + "description": "Native UI: Jump to latest", + "platforms": [ + "macos" + ], + "macos": { + "key": "Jump to latest", + "arguments": [] + } + }, + { + "id": "native.justNow", + "description": "Native UI: just now", + "platforms": [ + "macos" + ], + "macos": { + "key": "just now", + "arguments": [] + } + }, + { + "id": "native.jvmPerformanceMonitoringEnded", + "description": "Native UI: JVM performance monitoring ended.", + "platforms": [ + "macos" + ], + "macos": { + "key": "JVM performance monitoring ended.", + "arguments": [] + } + }, + { + "id": "native.keepAll", + "description": "Native UI: Keep All", + "platforms": [ + "macos" + ], + "macos": { + "key": "Keep All", + "arguments": [] + } + }, + { + "id": "native.keepAll.variant2", + "description": "Native UI: Keep all", + "platforms": [ + "macos" + ], + "macos": { + "key": "Keep all", + "arguments": [] + } + }, + { + "id": "native.keepCurrent", + "description": "Native UI: Keep Current", + "platforms": [ + "macos" + ], + "macos": { + "key": "Keep Current", + "arguments": [] + } + }, + { + "id": "native.keepEditor", + "description": "Native UI: Keep Editor", + "platforms": [ + "macos" + ], + "macos": { + "key": "Keep Editor", + "arguments": [] + } + }, + { + "id": "native.keepExistingContent", + "description": "Native UI: Keep existing content", + "platforms": [ + "macos" + ], + "macos": { + "key": "Keep existing content", + "arguments": [] + } + }, + { + "id": "native.keepRunning", + "description": "Native UI: Keep Running", + "platforms": [ + "macos" + ], + "macos": { + "key": "Keep Running", + "arguments": [] + } + }, + { + "id": "native.keepRunning.variant2", + "description": "Native UI: Keep running", + "platforms": [ + "macos" + ], + "macos": { + "key": "Keep running", + "arguments": [] + } + }, + { + "id": "native.keepStaleBranches", + "description": "Native UI: Keep stale branches", + "platforms": [ + "macos" + ], + "macos": { + "key": "Keep stale branches", + "arguments": [] + } + }, + { + "id": "native.keepStaleTags", + "description": "Native UI: Keep stale tags", + "platforms": [ + "macos" + ], + "macos": { + "key": "Keep stale tags", + "arguments": [] + } + }, + { + "id": "native.keptEditorVersion", + "description": "Native UI: Kept editor version", + "platforms": [ + "macos" + ], + "macos": { + "key": "Kept editor version", + "arguments": [] + } + }, + { + "id": "native.keymap", + "description": "Native UI: Keymap…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Keymap…", + "arguments": [] + } + }, + { + "id": "native.koreanCharacters", + "description": "Native UI: Korean characters", + "platforms": [ + "macos" + ], + "macos": { + "key": "Korean characters", + "arguments": [] + } + }, + { + "id": "native.kotlinSupport", + "description": "Native UI: Kotlin Support", + "platforms": [ + "macos" + ], + "macos": { + "key": "Kotlin Support", + "arguments": [] + } + }, + { + "id": "native.labels", + "description": "Native UI: Labels", + "platforms": [ + "macos" + ], + "macos": { + "key": "Labels", + "arguments": [] + } + }, + { + "id": "native.labelsAndAssignees", + "description": "Native UI: Labels and assignees", + "platforms": [ + "macos" + ], + "macos": { + "key": "Labels and assignees", + "arguments": [] + } + }, + { + "id": "native.languageServer", + "description": "Native UI: Language Server", + "platforms": [ + "macos" + ], + "macos": { + "key": "Language Server", + "arguments": [] + } + }, + { + "id": "native.languageServerInsideThePlugin", + "description": "Native UI: %@ language server (inside the plugin)", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ language server (inside the plugin)", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.languageServerInstalledOnThisMac", + "description": "Native UI: %@ language server (installed on this Mac)", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ language server (installed on this Mac)", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.languageServerLogLevel", + "description": "Native UI: Language server log level", + "platforms": [ + "macos" + ], + "macos": { + "key": "Language server log level", + "arguments": [] + } + }, + { + "id": "native.languageServers", + "description": "Native UI: Language servers", + "platforms": [ + "macos" + ], + "macos": { + "key": "Language servers", + "arguments": [] + } + }, + { + "id": "native.languageService", + "description": "Native UI: Language Service", + "platforms": [ + "macos" + ], + "macos": { + "key": "Language Service", + "arguments": [] + } + }, + { + "id": "native.languageServiceForThisProject", + "description": "Native UI: Language service for this project", + "platforms": [ + "macos" + ], + "macos": { + "key": "Language service for this project", + "arguments": [] + } + }, + { + "id": "native.languageServices", + "description": "Native UI: Language Services", + "platforms": [ + "macos" + ], + "macos": { + "key": "Language Services", + "arguments": [] + } + }, + { + "id": "native.languagesFrameworks", + "description": "Native UI: Languages & Frameworks", + "platforms": [ + "macos" + ], + "macos": { + "key": "Languages & Frameworks", + "arguments": [] + } + }, + { + "id": "native.last30Days", + "description": "Native UI: Last 30 Days", + "platforms": [ + "macos" + ], + "macos": { + "key": "Last 30 Days", + "arguments": [] + } + }, + { + "id": "native.last7Days", + "description": "Native UI: Last 7 Days", + "platforms": [ + "macos" + ], + "macos": { + "key": "Last 7 Days", + "arguments": [] + } + }, + { + "id": "native.later", + "description": "Native UI: Later", + "platforms": [ + "macos" + ], + "macos": { + "key": "Later", + "arguments": [] + } + }, + { + "id": "native.latestTurnAsReportedByTheAgentItsAccountingScopeDependsOnTheAgentTurnOnModelCa4056cd34fb", + "description": "Native UI: Latest turn as reported by the Agent; its accounting scope depends on the Agent. Turn on Model call statistics for session totals.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Latest turn as reported by the Agent; its accounting scope depends on the Agent. Turn on Model call statistics for session totals.", + "arguments": [] + } + }, + { + "id": "native.latestVersion", + "description": "Native UI: Latest Version", + "platforms": [ + "macos" + ], + "macos": { + "key": "Latest Version", + "arguments": [] + } + }, + { + "id": "native.launch", + "description": "Native UI: Launch", + "platforms": [ + "macos" + ], + "macos": { + "key": "Launch", + "arguments": [] + } + }, + { + "id": "native.launchA", + "description": "Native UI: — Launch a", + "platforms": [ + "macos" + ], + "macos": { + "key": "— Launch a", + "arguments": [] + } + }, + { + "id": "native.launching", + "description": "Native UI: Launching", + "platforms": [ + "macos" + ], + "macos": { + "key": "Launching", + "arguments": [] + } + }, + { + "id": "native.layout", + "description": "Native UI: Layout", + "platforms": [ + "macos" + ], + "macos": { + "key": "Layout", + "arguments": [] + } + }, + { + "id": "native.leaveAReview", + "description": "Native UI: Leave a review", + "platforms": [ + "macos" + ], + "macos": { + "key": "Leave a review", + "arguments": [] + } + }, + { + "id": "native.letConnectedAgentsInspectAndControlThisProject", + "description": "Native UI: Let connected agents inspect and control this project.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Let connected agents inspect and control this project.", + "arguments": [] + } + }, + { + "id": "native.license", + "description": "Native UI: License", + "platforms": [ + "macos" + ], + "macos": { + "key": "License", + "arguments": [] + } + }, + { + "id": "native.lifecycle", + "description": "Native UI: Lifecycle", + "platforms": [ + "macos" + ], + "macos": { + "key": "Lifecycle", + "arguments": [] + } + }, + { + "id": "native.light", + "description": "Native UI: Light", + "platforms": [ + "macos" + ], + "macos": { + "key": "Light", + "arguments": [] + } + }, + { + "id": "native.line", + "description": "Native UI: Line", + "platforms": [ + "macos" + ], + "macos": { + "key": "Line", + "arguments": [] + } + }, + { + "id": "native.lineBreakpointActions", + "description": "Native UI: Line breakpoint actions", + "platforms": [ + "macos" + ], + "macos": { + "key": "Line breakpoint actions", + "arguments": [] + } + }, + { + "id": "native.lineBreakpoints", + "description": "Native UI: Line Breakpoints", + "platforms": [ + "macos" + ], + "macos": { + "key": "Line Breakpoints", + "arguments": [] + } + }, + { + "id": "native.lineColumn", + "description": "Native UI: [Line] [:column]:", + "platforms": [ + "macos" + ], + "macos": { + "key": "[Line] [:column]:", + "arguments": [] + } + }, + { + "id": "native.linear", + "description": "Native UI: Linear", + "platforms": [ + "macos" + ], + "macos": { + "key": "Linear", + "arguments": [] + } + }, + { + "id": "native.linesKeptForScrollingBackInNewTerminalSessions", + "description": "Native UI: Lines kept for scrolling back in new terminal sessions.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Lines kept for scrolling back in new terminal sessions.", + "arguments": [] + } + }, + { + "id": "native.linkedWorktree", + "description": "Native UI: Linked worktree", + "platforms": [ + "macos" + ], + "macos": { + "key": "Linked worktree", + "arguments": [] + } + }, + { + "id": "native.listedIn", + "description": "Native UI: Listed in", + "platforms": [ + "macos" + ], + "macos": { + "key": "Listed in", + "arguments": [] + } + }, + { + "id": "native.lithe", + "description": "Native UI: Lithe", + "platforms": [ + "macos" + ], + "macos": { + "key": "Lithe", + "arguments": [] + } + }, + { + "id": "native.litheAgent", + "description": "Native UI: Lithe Agent", + "platforms": [ + "macos" + ], + "macos": { + "key": "Lithe Agent", + "arguments": [] + } + }, + { + "id": "native.litheApplicationDefault", + "description": "Native UI: Lithe application default", + "platforms": [ + "macos" + ], + "macos": { + "key": "Lithe application default", + "arguments": [] + } + }, + { + "id": "native.litheAsksAgainAfterThePluginIsDisabledUpdatedOrLitheRestarts", + "description": "Native UI: Lithe asks again after the plugin is disabled, updated, or Lithe restarts.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Lithe asks again after the plugin is disabled, updated, or Lithe restarts.", + "arguments": [] + } + }, + { + "id": "native.litheCanTGivePagesAccessToTheCameraOrMicrophoneInThisVersion", + "description": "Native UI: Lithe can’t give pages access to the camera or microphone in this version.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Lithe can’t give pages access to the camera or microphone in this version.", + "arguments": [] + } + }, + { + "id": "native.litheCannotReplaceThisAppInItsCurrentFolderMoveItToAWritableApplicationsFolder9358c4d3f8", + "description": "Native UI: Lithe cannot replace this app in its current folder. Move it to a writable Applications folder or install the stable release manually.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Lithe cannot replace this app in its current folder. Move it to a writable Applications folder or install the stable release manually.", + "arguments": [] + } + }, + { + "id": "native.litheChecksGitHubReleasesForPublishedUpdates", + "description": "Native UI: Lithe checks GitHub Releases for published updates.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Lithe checks GitHub Releases for published updates.", + "arguments": [] + } + }, + { + "id": "native.litheClosedUnexpectedlyLastTimeAndLeftProgramsRunning", + "description": "Native UI: Lithe closed unexpectedly last time and left programs running", + "platforms": [ + "macos" + ], + "macos": { + "key": "Lithe closed unexpectedly last time and left programs running", + "arguments": [] + } + }, + { + "id": "native.litheCouldNotFindARunnableGit", + "description": "Native UI: Lithe could not find a runnable Git.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Lithe could not find a runnable Git.", + "arguments": [] + } + }, + { + "id": "native.litheDefault", + "description": "Native UI: Lithe default", + "platforms": [ + "macos" + ], + "macos": { + "key": "Lithe default", + "arguments": [] + } + }, + { + "id": "native.litheDoesNotSavePasswordsCredentialsAreStoredByTheGitCredentialHelperYouChoose", + "description": "Native UI: Lithe does not save passwords. Credentials are stored by the Git credential helper you choose.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Lithe does not save passwords. Credentials are stored by the Git credential helper you choose.", + "arguments": [] + } + }, + { + "id": "native.litheFetchDefaults", + "description": "Native UI: Lithe Fetch Defaults", + "platforms": [ + "macos" + ], + "macos": { + "key": "Lithe Fetch Defaults", + "arguments": [] + } + }, + { + "id": "native.litheFetchRemoveStaleBranches", + "description": "Native UI: Lithe Fetch: remove stale branches", + "platforms": [ + "macos" + ], + "macos": { + "key": "Lithe Fetch: remove stale branches", + "arguments": [] + } + }, + { + "id": "native.litheFetchSubmodules", + "description": "Native UI: Lithe: fetch submodules", + "platforms": [ + "macos" + ], + "macos": { + "key": "Lithe: fetch submodules", + "arguments": [] + } + }, + { + "id": "native.litheFetchTags", + "description": "Native UI: Lithe: fetch tags", + "platforms": [ + "macos" + ], + "macos": { + "key": "Lithe: fetch tags", + "arguments": [] + } + }, + { + "id": "native.litheFirstAsksEachProgramToStopGivingItThisLongToSaveItsWorkAndExitProgramsStiffc3767044", + "description": "Native UI: Lithe first asks each program to stop, giving it this long to save its work and exit. Programs still running afterwards are force-stopped.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Lithe first asks each program to stop, giving it this long to save its work and exit. Programs still running afterwards are force-stopped.", + "arguments": [] + } + }, + { + "id": "native.litheHasNotYetCompletedARealConversationWithThisAdapterVersion", + "description": "Native UI: Lithe has not yet completed a real conversation with this adapter version.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Lithe has not yet completed a real conversation with this adapter version.", + "arguments": [] + } + }, + { + "id": "native.litheIDEAccess", + "description": "Native UI: Lithe IDE access", + "platforms": [ + "macos" + ], + "macos": { + "key": "Lithe IDE access", + "arguments": [] + } + }, + { + "id": "native.litheInstallsTheACPAdapterPackageNotTheVendorCLITheTwoAreIndependentAndShareTh3f39562117", + "description": "Native UI: Lithe installs the ACP adapter package, not the vendor CLI. The two are independent and share the CLI's configuration.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Lithe installs the ACP adapter package, not the vendor CLI. The two are independent and share the CLI's configuration.", + "arguments": [] + } + }, + { + "id": "native.litheIsUpToDate", + "description": "Native UI: Lithe is up to date", + "platforms": [ + "macos" + ], + "macos": { + "key": "Lithe is up to date", + "arguments": [] + } + }, + { + "id": "native.litheIsUpToDateAtVersion", + "description": "Native UI: Lithe is up to date at version %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Lithe is up to date at version %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.litheLooksForCodexAndClaudeConfigurationFilesOnThisMac", + "description": "Native UI: Lithe looks for Codex and Claude configuration files on this Mac.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Lithe looks for Codex and Claude configuration files on this Mac.", + "arguments": [] + } + }, + { + "id": "native.litheLooksForCodexConfigTomlAndCodexAuthJsonOnThisMac", + "description": "Native UI: Lithe looks for ~/.codex/config.toml and ~/.codex/auth.json on this Mac.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Lithe looks for ~/.codex/config.toml and ~/.codex/auth.json on this Mac.", + "arguments": [] + } + }, + { + "id": "native.litheMCPWasRegisteredButCodexConfigTomlCouldNotBeAddedToGitSLocalExcludeList", + "description": "Native UI: Lithe MCP was registered, but .codex/config.toml could not be added to Git’s local exclude list: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Lithe MCP was registered, but .codex/config.toml could not be added to Git’s local exclude list: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.litheNeedsToIdentifyTheProjectAndGenerateLitheRunGeneratedJsonBeforeRunAndDebu71dbeebe55", + "description": "Native UI: Lithe needs to identify the project and generate .lithe/run/generated.json before Run and Debug are available. Project and local overrides will not be changed.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Lithe needs to identify the project and generate .lithe/run/generated.json before Run and Debug are available. Project and local overrides will not be changed.", + "arguments": [] + } + }, + { + "id": "native.litheOfficial", + "description": "Native UI: Lithe Official", + "platforms": [ + "macos" + ], + "macos": { + "key": "Lithe Official", + "arguments": [] + } + }, + { + "id": "native.lithePruneStaleBranches", + "description": "Native UI: Lithe: prune stale branches", + "platforms": [ + "macos" + ], + "macos": { + "key": "Lithe: prune stale branches", + "arguments": [] + } + }, + { + "id": "native.lithePublisherSignatureVerified", + "description": "Native UI: Lithe publisher signature verified", + "platforms": [ + "macos" + ], + "macos": { + "key": "Lithe publisher signature verified", + "arguments": [] + } + }, + { + "id": "native.litheReadsTheServersRegisteredInYourCodexAndClaudeCodeConfigurationSavingWritefeb8d3e17b", + "description": "Native UI: Lithe reads the servers registered in your Codex and Claude Code configuration. Saving writes only the changed entry, keeps backups in Application Support, and never overwrites an entry created outside Lithe. Agents read MCP configuration when a session starts, so restart a session to apply changes.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Lithe reads the servers registered in your Codex and Claude Code configuration. Saving writes only the changed entry, keeps backups in Application Support, and never overwrites an entry created outside Lithe. Agents read MCP configuration when a session starts, so restart a session to apply changes.", + "arguments": [] + } + }, + { + "id": "native.litheShelve", + "description": "Native UI: Lithe Shelve", + "platforms": [ + "macos" + ], + "macos": { + "key": "Lithe Shelve", + "arguments": [] + } + }, + { + "id": "native.litheShelves", + "description": "Native UI: Lithe Shelves", + "platforms": [ + "macos" + ], + "macos": { + "key": "Lithe Shelves", + "arguments": [] + } + }, + { + "id": "native.litheWillCloneTheRepositoryIntoDestinationDescription", + "description": "Native UI: Lithe will clone the repository into (destinationDescription).", + "platforms": [ + "macos" + ], + "macos": { + "key": "Lithe will clone the repository into (destinationDescription).", + "arguments": [] + } + }, + { + "id": "native.litheWillDownloadTheUpdateAndRestartAfterReplacingTheCurrentApp", + "description": "Native UI: Lithe will download the update and restart after replacing the current app.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Lithe will download the update and restart after replacing the current app.", + "arguments": [] + } + }, + { + "id": "native.litheWillLookForServicesAgainAndRefreshLitheRunGeneratedJsonProjectAndLocalOvec4eb77affa", + "description": "Native UI: Lithe will look for services again and refresh .lithe/run/generated.json. Project and local overrides will not be changed.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Lithe will look for services again and refresh .lithe/run/generated.json. Project and local overrides will not be changed.", + "arguments": [] + } + }, + { + "id": "native.lld", + "description": "Native UI: %@ %lld", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ %lld", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "integer" + } + }, + { + "id": "native.lld.variant2", + "description": "Native UI: %@ (%lld)", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ (%lld)", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "integer" + } + }, + { + "id": "native.lldAffectedCommitsLldCommitsCoreCreatesARecoveryReferenceBeforeStarting", + "description": "Native UI: %lld affected commits → %lld commits. Core creates a recovery reference before starting.", + "platforms": [ + "macos" + ], + "macos": { + "key": "%lld affected commits → %lld commits. Core creates a recovery reference before starting.", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "integer", + "arg2": "integer" + } + }, + { + "id": "native.lldBytes", + "description": "Native UI: %lld bytes", + "platforms": [ + "macos" + ], + "macos": { + "key": "%lld bytes", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.lldChangedFiles", + "description": "Native UI: %lld changed files", + "platforms": [ + "macos" + ], + "macos": { + "key": "%lld changed files", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.lldCharacters", + "description": "Native UI: %lld characters", + "platforms": [ + "macos" + ], + "macos": { + "key": "%lld characters", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.lldCharactersTotal", + "description": "Native UI: %lld characters total", + "platforms": [ + "macos" + ], + "macos": { + "key": "%lld characters total", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.lldFailed", + "description": "Native UI: %lld failed", + "platforms": [ + "macos" + ], + "macos": { + "key": "%lld failed", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.lldFileS", + "description": "Native UI: %lld file(s) · %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "%lld file(s) · %@", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "integer", + "arg2": "string" + } + }, + { + "id": "native.lldIgnored", + "description": "Native UI: %lld ignored", + "platforms": [ + "macos" + ], + "macos": { + "key": "%lld ignored", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.lldInterrupted", + "description": "Native UI: %lld interrupted", + "platforms": [ + "macos" + ], + "macos": { + "key": "%lld interrupted", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.lldLines", + "description": "Native UI: %lld lines", + "platforms": [ + "macos" + ], + "macos": { + "key": "%lld lines", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.lldLldCompleted", + "description": "Native UI: %lld/%lld completed", + "platforms": [ + "macos" + ], + "macos": { + "key": "%lld/%lld completed", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "integer", + "arg2": "integer" + } + }, + { + "id": "native.lldMatches", + "description": "Native UI: · %lld matches", + "platforms": [ + "macos" + ], + "macos": { + "key": " · %lld matches", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.lldModelCalls", + "description": "Native UI: %lld model calls", + "platforms": [ + "macos" + ], + "macos": { + "key": "%lld model calls", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.lldMoreFiles", + "description": "Native UI: … %lld more files", + "platforms": [ + "macos" + ], + "macos": { + "key": "… %lld more files", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.lldMoreNotifications", + "description": "Native UI: %lld more notifications", + "platforms": [ + "macos" + ], + "macos": { + "key": "%lld more notifications", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.lldMoreReferences", + "description": "Native UI: … %lld more references", + "platforms": [ + "macos" + ], + "macos": { + "key": "… %lld more references", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.lldNonStreaming", + "description": "Native UI: %lld non-streaming", + "platforms": [ + "macos" + ], + "macos": { + "key": "%lld non-streaming", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.lldOfLld", + "description": "Native UI: %lld of %lld", + "platforms": [ + "macos" + ], + "macos": { + "key": "%lld of %lld", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "integer", + "arg2": "integer" + } + }, + { + "id": "native.lldOfLldServicesSelected", + "description": "Native UI: %lld of %lld services selected", + "platforms": [ + "macos" + ], + "macos": { + "key": "%lld of %lld services selected", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "integer", + "arg2": "integer" + } + }, + { + "id": "native.lldOfLldStepsProcessed", + "description": "Native UI: %@ · %lld of %lld steps processed", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ · %lld of %lld steps processed", + "arguments": [ + "arg1", + "arg2", + "arg3" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "integer", + "arg3": "integer" + } + }, + { + "id": "native.lldPassed", + "description": "Native UI: %lld passed", + "platforms": [ + "macos" + ], + "macos": { + "key": "%lld passed", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.lldProcesses", + "description": "Native UI: %lld processes", + "platforms": [ + "macos" + ], + "macos": { + "key": "%lld processes", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.lldRequestsReturnedNoUsageSoTheseTotalsAreALowerBound", + "description": "Native UI: %lld requests returned no usage, so these totals are a lower bound.", + "platforms": [ + "macos" + ], + "macos": { + "key": "%lld requests returned no usage, so these totals are a lower bound.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.lldRows", + "description": "Native UI: %lld rows", + "platforms": [ + "macos" + ], + "macos": { + "key": "%lld rows", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.lldRunning", + "description": "Native UI: %lld running", + "platforms": [ + "macos" + ], + "macos": { + "key": "%lld running", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.lldS", + "description": "Native UI: %lld s", + "platforms": [ + "macos" + ], + "macos": { + "key": "%lld s", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.lldSelected", + "description": "Native UI: %lld selected", + "platforms": [ + "macos" + ], + "macos": { + "key": "%lld selected", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.lldSkipped", + "description": "Native UI: %lld skipped", + "platforms": [ + "macos" + ], + "macos": { + "key": "%lld skipped", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.lldStaged", + "description": "Native UI: %lld staged", + "platforms": [ + "macos" + ], + "macos": { + "key": "%lld staged", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.lldSteps", + "description": "Native UI: %lld steps", + "platforms": [ + "macos" + ], + "macos": { + "key": "%lld steps", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.lldTestFailed", + "description": "Native UI: %lld test failed", + "platforms": [ + "macos" + ], + "macos": { + "key": "%lld test failed", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.lldTestIgnored", + "description": "Native UI: %lld test ignored", + "platforms": [ + "macos" + ], + "macos": { + "key": "%lld test ignored", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.lldTestPassed", + "description": "Native UI: %lld test passed", + "platforms": [ + "macos" + ], + "macos": { + "key": "%lld test passed", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.lldTestTotal", + "description": "Native UI: %lld test total", + "platforms": [ + "macos" + ], + "macos": { + "key": "%lld test total", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.lldTestsFailed", + "description": "Native UI: %lld tests failed", + "platforms": [ + "macos" + ], + "macos": { + "key": "%lld tests failed", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.lldTestsIgnored", + "description": "Native UI: %lld tests ignored", + "platforms": [ + "macos" + ], + "macos": { + "key": "%lld tests ignored", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.lldTestsPassed", + "description": "Native UI: %lld tests passed", + "platforms": [ + "macos" + ], + "macos": { + "key": "%lld tests passed", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.lldTestsTotal", + "description": "Native UI: %lld tests total", + "platforms": [ + "macos" + ], + "macos": { + "key": "%lld tests total", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.lldToolCalls", + "description": "Native UI: %lld tool calls", + "platforms": [ + "macos" + ], + "macos": { + "key": "%lld tool calls", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.lldTurns", + "description": "Native UI: %lld turns", + "platforms": [ + "macos" + ], + "macos": { + "key": "%lld turns", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.lldWorktreeRecordsNeedAttention", + "description": "Native UI: %lld worktree records need attention", + "platforms": [ + "macos" + ], + "macos": { + "key": "%lld worktree records need attention", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.lldWorktrees", + "description": "Native UI: %lld worktrees", + "platforms": [ + "macos" + ], + "macos": { + "key": "%lld worktrees", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.lldhLldmLlds", + "description": "Native UI: %lldh %lldm %llds", + "platforms": [ + "macos" + ], + "macos": { + "key": "%lldh %lldm %llds", + "arguments": [ + "arg1", + "arg2", + "arg3" + ] + }, + "parameters": { + "arg1": "integer", + "arg2": "integer", + "arg3": "integer" + } + }, + { + "id": "native.lldmLlds", + "description": "Native UI: %lldm %llds", + "platforms": [ + "macos" + ], + "macos": { + "key": "%lldm %llds", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "integer", + "arg2": "integer" + } + }, + { + "id": "native.llds", + "description": "Native UI: %llds", + "platforms": [ + "macos" + ], + "macos": { + "key": "%llds", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.load", + "description": "Native UI: Load", + "platforms": [ + "macos" + ], + "macos": { + "key": "Load", + "arguments": [] + } + }, + { + "id": "native.loadDiskVersion", + "description": "Native UI: Load Disk Version", + "platforms": [ + "macos" + ], + "macos": { + "key": "Load Disk Version", + "arguments": [] + } + }, + { + "id": "native.loadMore", + "description": "Native UI: Load More", + "platforms": [ + "macos" + ], + "macos": { + "key": "Load More", + "arguments": [] + } + }, + { + "id": "native.loadMoreCommits", + "description": "Native UI: Load more commits", + "platforms": [ + "macos" + ], + "macos": { + "key": "Load more commits", + "arguments": [] + } + }, + { + "id": "native.loadProjectModel", + "description": "Native UI: Load Project Model", + "platforms": [ + "macos" + ], + "macos": { + "key": "Load Project Model", + "arguments": [] + } + }, + { + "id": "native.loadTheDependenciesAgain", + "description": "Native UI: Load the dependencies again", + "platforms": [ + "macos" + ], + "macos": { + "key": "Load the dependencies again", + "arguments": [] + } + }, + { + "id": "native.loadTheDependenciesOf", + "description": "Native UI: Load the dependencies of %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Load the dependencies of %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.loadTheModel", + "description": "Native UI: Load the %@ model", + "platforms": [ + "macos" + ], + "macos": { + "key": "Load the %@ model", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.loadTheProjectModel", + "description": "Native UI: Load the project model", + "platforms": [ + "macos" + ], + "macos": { + "key": "Load the project model", + "arguments": [] + } + }, + { + "id": "native.loadedFileSystemVersion", + "description": "Native UI: Loaded file-system version", + "platforms": [ + "macos" + ], + "macos": { + "key": "Loaded file-system version", + "arguments": [] + } + }, + { + "id": "native.loading", + "description": "Native UI: Loading…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Loading…", + "arguments": [] + } + }, + { + "id": "native.loadingBranchDetails", + "description": "Native UI: Loading branch details…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Loading branch details…", + "arguments": [] + } + }, + { + "id": "native.loadingBranches", + "description": "Native UI: Loading branches", + "platforms": [ + "macos" + ], + "macos": { + "key": "Loading branches", + "arguments": [] + } + }, + { + "id": "native.loadingBranches.variant2", + "description": "Native UI: Loading branches…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Loading branches…", + "arguments": [] + } + }, + { + "id": "native.loadingBreakpoints", + "description": "Native UI: Loading breakpoints…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Loading breakpoints…", + "arguments": [] + } + }, + { + "id": "native.loadingChangedFiles", + "description": "Native UI: Loading changed files…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Loading changed files…", + "arguments": [] + } + }, + { + "id": "native.loadingCommitDiff", + "description": "Native UI: Loading commit diff…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Loading commit diff…", + "arguments": [] + } + }, + { + "id": "native.loadingCommits", + "description": "Native UI: Loading commits…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Loading commits…", + "arguments": [] + } + }, + { + "id": "native.loadingComparison", + "description": "Native UI: Loading comparison…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Loading comparison…", + "arguments": [] + } + }, + { + "id": "native.loadingConversation", + "description": "Native UI: Loading conversation…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Loading conversation…", + "arguments": [] + } + }, + { + "id": "native.loadingDependencies", + "description": "Native UI: Loading dependencies...", + "platforms": [ + "macos" + ], + "macos": { + "key": "Loading dependencies...", + "arguments": [] + } + }, + { + "id": "native.loadingDependencies.variant2", + "description": "Native UI: Loading dependencies…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Loading dependencies…", + "arguments": [] + } + }, + { + "id": "native.loadingDependenciesOf", + "description": "Native UI: Loading dependencies of %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Loading dependencies of %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.loadingDiff", + "description": "Native UI: Loading diff…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Loading diff…", + "arguments": [] + } + }, + { + "id": "native.loadingHistory", + "description": "Native UI: Loading history…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Loading history…", + "arguments": [] + } + }, + { + "id": "native.loadingModel", + "description": "Native UI: Loading %@ model", + "platforms": [ + "macos" + ], + "macos": { + "key": "Loading %@ model", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.loadingObjectFields", + "description": "Native UI: Loading object fields…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Loading object fields…", + "arguments": [] + } + }, + { + "id": "native.loadingProjectEnvironment", + "description": "Native UI: Loading project environment…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Loading project environment…", + "arguments": [] + } + }, + { + "id": "native.loadingPullRequests", + "description": "Native UI: Loading pull requests", + "platforms": [ + "macos" + ], + "macos": { + "key": "Loading pull requests", + "arguments": [] + } + }, + { + "id": "native.loadingRunServices", + "description": "Native UI: Loading run services...", + "platforms": [ + "macos" + ], + "macos": { + "key": "Loading run services...", + "arguments": [] + } + }, + { + "id": "native.loadingSessionSettings", + "description": "Native UI: Loading session settings…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Loading session settings…", + "arguments": [] + } + }, + { + "id": "native.loadingWorktreeDetails", + "description": "Native UI: Loading worktree details", + "platforms": [ + "macos" + ], + "macos": { + "key": "Loading worktree details", + "arguments": [] + } + }, + { + "id": "native.loadingWorktrees", + "description": "Native UI: Loading worktrees", + "platforms": [ + "macos" + ], + "macos": { + "key": "Loading worktrees", + "arguments": [] + } + }, + { + "id": "native.local", + "description": "Native UI: Local", + "platforms": [ + "macos" + ], + "macos": { + "key": "Local", + "arguments": [] + } + }, + { + "id": "native.localChanges", + "description": "Native UI: Local Changes", + "platforms": [ + "macos" + ], + "macos": { + "key": "Local Changes", + "arguments": [] + } + }, + { + "id": "native.localChanges.variant2", + "description": "Native UI: Local changes", + "platforms": [ + "macos" + ], + "macos": { + "key": "Local changes", + "arguments": [] + } + }, + { + "id": "native.localChangesWereRestoredWithConflicts", + "description": "Native UI: Local changes were restored with conflicts", + "platforms": [ + "macos" + ], + "macos": { + "key": "Local changes were restored with conflicts", + "arguments": [] + } + }, + { + "id": "native.localChangesWouldBeOverwritten", + "description": "Native UI: Local changes would be overwritten", + "platforms": [ + "macos" + ], + "macos": { + "key": "Local changes would be overwritten", + "arguments": [] + } + }, + { + "id": "native.localCommitS", + "description": "Native UI: local commit(s)", + "platforms": [ + "macos" + ], + "macos": { + "key": "local commit(s)", + "arguments": [] + } + }, + { + "id": "native.localConfiguration", + "description": "Native UI: Local %@ configuration", + "platforms": [ + "macos" + ], + "macos": { + "key": "Local %@ configuration", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.localConfiguration.variant2", + "description": "Native UI: Local configuration", + "platforms": [ + "macos" + ], + "macos": { + "key": "Local configuration", + "arguments": [] + } + }, + { + "id": "native.localConfigurationFollowsYourCLIFilesCustomProvidersAreSavedInLitheAndDoNotChad03a7c54cb", + "description": "Native UI: Local configuration follows your CLI files. Custom providers are saved in Lithe and do not change those files. Switching restarts this project's Agent connection; open history again to continue a saved conversation.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Local configuration follows your CLI files. Custom providers are saved in Lithe and do not change those files. Switching restarts this project's Agent connection; open history again to continue a saved conversation.", + "arguments": [] + } + }, + { + "id": "native.localDevelopmentPackage", + "description": "Native UI: Local development package", + "platforms": [ + "macos" + ], + "macos": { + "key": "Local development package", + "arguments": [] + } + }, + { + "id": "native.localHistory", + "description": "Native UI: Local History", + "platforms": [ + "macos" + ], + "macos": { + "key": "Local History", + "arguments": [] + } + }, + { + "id": "native.localHistory.variant2", + "description": "Native UI: Local History…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Local History…", + "arguments": [] + } + }, + { + "id": "native.localHistoryIsAvailableForTextFiles", + "description": "Native UI: Local History is available for text files", + "platforms": [ + "macos" + ], + "macos": { + "key": "Local History is available for text files", + "arguments": [] + } + }, + { + "id": "native.localJDKHomeOptional", + "description": "Native UI: Local JDK Home (optional)", + "platforms": [ + "macos" + ], + "macos": { + "key": "Local JDK Home (optional)", + "arguments": [] + } + }, + { + "id": "native.localPackage", + "description": "Native UI: Local Package", + "platforms": [ + "macos" + ], + "macos": { + "key": "Local Package", + "arguments": [] + } + }, + { + "id": "native.localVariables", + "description": "Native UI: Local Variables", + "platforms": [ + "macos" + ], + "macos": { + "key": "Local Variables", + "arguments": [] + } + }, + { + "id": "native.lockWorktree", + "description": "Native UI: Lock Worktree", + "platforms": [ + "macos" + ], + "macos": { + "key": "Lock Worktree", + "arguments": [] + } + }, + { + "id": "native.locked", + "description": "Native UI: Locked", + "platforms": [ + "macos" + ], + "macos": { + "key": "Locked", + "arguments": [] + } + }, + { + "id": "native.locked.variant2", + "description": "Native UI: Locked %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Locked %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.log", + "description": "Native UI: Log: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Log: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.logDirectory", + "description": "Native UI: Log directory", + "platforms": [ + "macos" + ], + "macos": { + "key": "Log directory", + "arguments": [] + } + }, + { + "id": "native.logFilter", + "description": "Native UI: Log filter", + "platforms": [ + "macos" + ], + "macos": { + "key": "Log filter", + "arguments": [] + } + }, + { + "id": "native.logLevel", + "description": "Native UI: Log level", + "platforms": [ + "macos" + ], + "macos": { + "key": "Log level", + "arguments": [] + } + }, + { + "id": "native.logLocations", + "description": "Native UI: Log locations", + "platforms": [ + "macos" + ], + "macos": { + "key": "Log locations", + "arguments": [] + } + }, + { + "id": "native.logTimeline", + "description": "Native UI: Log timeline", + "platforms": [ + "macos" + ], + "macos": { + "key": "Log timeline", + "arguments": [] + } + }, + { + "id": "native.logs", + "description": "Native UI: Logs", + "platforms": [ + "macos" + ], + "macos": { + "key": "Logs", + "arguments": [] + } + }, + { + "id": "native.logsAndDiagnostics", + "description": "Native UI: Logs and Diagnostics", + "platforms": [ + "macos" + ], + "macos": { + "key": "Logs and Diagnostics", + "arguments": [] + } + }, + { + "id": "native.lookingForGit", + "description": "Native UI: Looking for Git…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Looking for Git…", + "arguments": [] + } + }, + { + "id": "native.low", + "description": "Native UI: Low", + "platforms": [ + "macos" + ], + "macos": { + "key": "Low", + "arguments": [] + } + }, + { + "id": "native.lowEffortAndASmallOutputLimitAreRecommendedForFastCommitMessageGeneration", + "description": "Native UI: Low effort and a small output limit are recommended for fast commit-message generation.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Low effort and a small output limit are recommended for fast commit-message generation.", + "arguments": [] + } + }, + { + "id": "native.lowRecommended", + "description": "Native UI: Low (recommended)", + "platforms": [ + "macos" + ], + "macos": { + "key": "Low (recommended)", + "arguments": [] + } + }, + { + "id": "native.luaSupport", + "description": "Native UI: Lua Support", + "platforms": [ + "macos" + ], + "macos": { + "key": "Lua Support", + "arguments": [] + } + }, + { + "id": "native.macosCouldNotPrepareTheUpdateDiskImageDownloadTheReleaseManuallyAndTryAgain", + "description": "Native UI: macOS could not prepare the update disk image. Download the release manually and try again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "macOS could not prepare the update disk image. Download the release manually and try again.", + "arguments": [] + } + }, + { + "id": "native.macosCouldNotPrepareTheUpdateDiskImageOpenTheReleasePageAndInstallItManually", + "description": "Native UI: macOS could not prepare the update disk image. Open the Release page and install it manually.", + "platforms": [ + "macos" + ], + "macos": { + "key": "macOS could not prepare the update disk image. Open the Release page and install it manually.", + "arguments": [] + } + }, + { + "id": "native.mainClass", + "description": "Native UI: Main class", + "platforms": [ + "macos" + ], + "macos": { + "key": "Main class", + "arguments": [] + } + }, + { + "id": "native.mainJava", + "description": "Native UI: main Java", + "platforms": [ + "macos" + ], + "macos": { + "key": "main Java", + "arguments": [] + } + }, + { + "id": "native.mainResources", + "description": "Native UI: main resources", + "platforms": [ + "macos" + ], + "macos": { + "key": "main resources", + "arguments": [] + } + }, + { + "id": "native.mainWorktree", + "description": "Native UI: Main Worktree", + "platforms": [ + "macos" + ], + "macos": { + "key": "Main Worktree", + "arguments": [] + } + }, + { + "id": "native.maintenance", + "description": "Native UI: Maintenance", + "platforms": [ + "macos" + ], + "macos": { + "key": "Maintenance", + "arguments": [] + } + }, + { + "id": "native.makeSupport", + "description": "Native UI: Make Support", + "platforms": [ + "macos" + ], + "macos": { + "key": "Make Support", + "arguments": [] + } + }, + { + "id": "native.manage", + "description": "Native UI: Manage…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Manage…", + "arguments": [] + } + }, + { + "id": "native.manageAIProviders", + "description": "Native UI: Manage AI Providers…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Manage AI Providers…", + "arguments": [] + } + }, + { + "id": "native.manageAllProjectBreakpoints", + "description": "Native UI: Manage all project breakpoints", + "platforms": [ + "macos" + ], + "macos": { + "key": "Manage all project breakpoints", + "arguments": [] + } + }, + { + "id": "native.managePluginRepositories", + "description": "Native UI: Manage Plugin Repositories…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Manage Plugin Repositories…", + "arguments": [] + } + }, + { + "id": "native.manageProjectBreakpointsWithoutStartingADebugSession", + "description": "Native UI: Manage project breakpoints without starting a debug session", + "platforms": [ + "macos" + ], + "macos": { + "key": "Manage project breakpoints without starting a debug session", + "arguments": [] + } + }, + { + "id": "native.manageSelected", + "description": "Native UI: Manage selected", + "platforms": [ + "macos" + ], + "macos": { + "key": "Manage selected", + "arguments": [] + } + }, + { + "id": "native.manageTheServersCodexAndClaudeCodeCanStart", + "description": "Native UI: Manage the servers Codex and Claude Code can start.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Manage the servers Codex and Claude Code can start.", + "arguments": [] + } + }, + { + "id": "native.managedMemory", + "description": "Native UI: Managed Memory", + "platforms": [ + "macos" + ], + "macos": { + "key": "Managed Memory", + "arguments": [] + } + }, + { + "id": "native.managedOutsideTheMarketplaceNoUpdateSource", + "description": "Native UI: Managed outside the Marketplace; no update source.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Managed outside the Marketplace; no update source.", + "arguments": [] + } + }, + { + "id": "native.markTargetAs", + "description": "Native UI: Mark Target As", + "platforms": [ + "macos" + ], + "macos": { + "key": "Mark Target As", + "arguments": [] + } + }, + { + "id": "native.markdownIsSupportedOnGitHub", + "description": "Native UI: Markdown is supported on GitHub", + "platforms": [ + "macos" + ], + "macos": { + "key": "Markdown is supported on GitHub", + "arguments": [] + } + }, + { + "id": "native.markdownSupport", + "description": "Native UI: Markdown Support", + "platforms": [ + "macos" + ], + "macos": { + "key": "Markdown Support", + "arguments": [] + } + }, + { + "id": "native.markdownTemplate", + "description": "Native UI: Markdown template", + "platforms": [ + "macos" + ], + "macos": { + "key": "Markdown template", + "arguments": [] + } + }, + { + "id": "native.marketplace", + "description": "Native UI: Marketplace", + "platforms": [ + "macos" + ], + "macos": { + "key": "Marketplace", + "arguments": [] + } + }, + { + "id": "native.matchCase", + "description": "Native UI: Match Case", + "platforms": [ + "macos" + ], + "macos": { + "key": "Match Case", + "arguments": [] + } + }, + { + "id": "native.matchHostAndURLPath", + "description": "Native UI: Match host and URL path", + "platforms": [ + "macos" + ], + "macos": { + "key": "Match host and URL path", + "arguments": [] + } + }, + { + "id": "native.matchHostOnly", + "description": "Native UI: Match host only", + "platforms": [ + "macos" + ], + "macos": { + "key": "Match host only", + "arguments": [] + } + }, + { + "id": "native.matches", + "description": "Native UI: Matches", + "platforms": [ + "macos" + ], + "macos": { + "key": "Matches", + "arguments": [] + } + }, + { + "id": "native.matches.variant2", + "description": "Native UI: matches", + "platforms": [ + "macos" + ], + "macos": { + "key": "matches", + "arguments": [] + } + }, + { + "id": "native.matchesThePluginRepository", + "description": "Native UI: Matches the plugin repository", + "platforms": [ + "macos" + ], + "macos": { + "key": "Matches the plugin repository", + "arguments": [] + } + }, + { + "id": "native.matchingBranches", + "description": "Native UI: Matching branches", + "platforms": [ + "macos" + ], + "macos": { + "key": "Matching branches", + "arguments": [] + } + }, + { + "id": "native.mavenBuild", + "description": "Native UI: Maven Build", + "platforms": [ + "macos" + ], + "macos": { + "key": "Maven Build", + "arguments": [] + } + }, + { + "id": "native.mavenConfigurationChanged", + "description": "Native UI: Maven configuration changed", + "platforms": [ + "macos" + ], + "macos": { + "key": "Maven configuration changed", + "arguments": [] + } + }, + { + "id": "native.mavenCouldNotResolveThisProjectLldProblems", + "description": "Native UI: Maven could not resolve this project (%lld problems)", + "platforms": [ + "macos" + ], + "macos": { + "key": "Maven could not resolve this project (%lld problems)", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.mavenCouldNotResolveThisProjectLldProblems.variant2", + "description": "Native UI: Maven could not resolve this project (%lld problems): %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Maven could not resolve this project (%lld problems): %@", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "integer", + "arg2": "string" + } + }, + { + "id": "native.mavenDependencyResolutionExitedWithCode", + "description": "Native UI: Maven dependency resolution exited with code %@.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Maven dependency resolution exited with code %@.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.mavenDependencyResolutionTimedOutAfter60Seconds", + "description": "Native UI: Maven dependency resolution timed out after 60 seconds.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Maven dependency resolution timed out after 60 seconds.", + "arguments": [] + } + }, + { + "id": "native.mavenExecutable", + "description": "Native UI: Maven executable", + "platforms": [ + "macos" + ], + "macos": { + "key": "Maven executable", + "arguments": [] + } + }, + { + "id": "native.mavenExecutableNotResolved", + "description": "Native UI: Maven executable not resolved", + "platforms": [ + "macos" + ], + "macos": { + "key": "Maven executable not resolved", + "arguments": [] + } + }, + { + "id": "native.mavenHome", + "description": "Native UI: Maven Home", + "platforms": [ + "macos" + ], + "macos": { + "key": "Maven Home", + "arguments": [] + } + }, + { + "id": "native.mavenHomeOrExecutable", + "description": "Native UI: Maven home or executable", + "platforms": [ + "macos" + ], + "macos": { + "key": "Maven home or executable", + "arguments": [] + } + }, + { + "id": "native.mavenJDK", + "description": "Native UI: Maven JDK", + "platforms": [ + "macos" + ], + "macos": { + "key": "Maven JDK", + "arguments": [] + } + }, + { + "id": "native.mavenJDKHome", + "description": "Native UI: Maven JDK Home", + "platforms": [ + "macos" + ], + "macos": { + "key": "Maven JDK Home", + "arguments": [] + } + }, + { + "id": "native.mavenModule", + "description": "Native UI: Maven Module", + "platforms": [ + "macos" + ], + "macos": { + "key": "Maven Module", + "arguments": [] + } + }, + { + "id": "native.mavenModule.variant2", + "description": "Native UI: Maven module", + "platforms": [ + "macos" + ], + "macos": { + "key": "Maven module", + "arguments": [] + } + }, + { + "id": "native.mavenPOMChanged", + "description": "Native UI: Maven POM changed", + "platforms": [ + "macos" + ], + "macos": { + "key": "Maven POM changed", + "arguments": [] + } + }, + { + "id": "native.mavenProject", + "description": "Native UI: Maven project", + "platforms": [ + "macos" + ], + "macos": { + "key": "Maven project", + "arguments": [] + } + }, + { + "id": "native.mavenReloadWasCancelledOrTimedOut", + "description": "Native UI: Maven reload was cancelled or timed out.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Maven reload was cancelled or timed out.", + "arguments": [] + } + }, + { + "id": "native.mavenRunAndDebugUseThisJDKUnlessASubprojectOrRunConfigurationOverridesIt", + "description": "Native UI: Maven, Run, and Debug use this JDK unless a subproject or run configuration overrides it.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Maven, Run, and Debug use this JDK unless a subproject or run configuration overrides it.", + "arguments": [] + } + }, + { + "id": "native.mavenSettings", + "description": "Native UI: Maven settings", + "platforms": [ + "macos" + ], + "macos": { + "key": "Maven settings", + "arguments": [] + } + }, + { + "id": "native.mavenSource", + "description": "Native UI: Maven source", + "platforms": [ + "macos" + ], + "macos": { + "key": "Maven source", + "arguments": [] + } + }, + { + "id": "native.mavenSpringBoot", + "description": "Native UI: Maven / Spring Boot", + "platforms": [ + "macos" + ], + "macos": { + "key": "Maven / Spring Boot", + "arguments": [] + } + }, + { + "id": "native.mavenWrapper", + "description": "Native UI: Maven Wrapper", + "platforms": [ + "macos" + ], + "macos": { + "key": "Maven Wrapper", + "arguments": [] + } + }, + { + "id": "native.mavenWrapperIsNotExecutable", + "description": "Native UI: Maven Wrapper is not executable", + "platforms": [ + "macos" + ], + "macos": { + "key": "Maven Wrapper is not executable", + "arguments": [] + } + }, + { + "id": "native.max", + "description": "Native UI: Max", + "platforms": [ + "macos" + ], + "macos": { + "key": "Max", + "arguments": [] + } + }, + { + "id": "native.maximumMemory", + "description": "Native UI: Maximum memory", + "platforms": [ + "macos" + ], + "macos": { + "key": "Maximum memory", + "arguments": [] + } + }, + { + "id": "native.maximumReasoningDepth", + "description": "Native UI: Maximum reasoning depth", + "platforms": [ + "macos" + ], + "macos": { + "key": "Maximum reasoning depth", + "arguments": [] + } + }, + { + "id": "native.mcp", + "description": "Native UI: MCP", + "platforms": [ + "macos" + ], + "macos": { + "key": "MCP", + "arguments": [] + } + }, + { + "id": "native.mcpConfiguration", + "description": "Native UI: MCP Configuration", + "platforms": [ + "macos" + ], + "macos": { + "key": "MCP Configuration", + "arguments": [] + } + }, + { + "id": "native.mcpConfigurationManagementIsUnavailable", + "description": "Native UI: MCP configuration management is unavailable.", + "platforms": [ + "macos" + ], + "macos": { + "key": "MCP configuration management is unavailable.", + "arguments": [] + } + }, + { + "id": "native.mcpIsEnabledForThisProject", + "description": "Native UI: MCP is enabled for this project.", + "platforms": [ + "macos" + ], + "macos": { + "key": "MCP is enabled for this project.", + "arguments": [] + } + }, + { + "id": "native.mcpIsOffForThisProject", + "description": "Native UI: MCP is off for this project.", + "platforms": [ + "macos" + ], + "macos": { + "key": "MCP is off for this project.", + "arguments": [] + } + }, + { + "id": "native.mcpServers", + "description": "Native UI: MCP servers", + "platforms": [ + "macos" + ], + "macos": { + "key": "MCP servers", + "arguments": [] + } + }, + { + "id": "native.mcpTools", + "description": "Native UI: MCP tools", + "platforms": [ + "macos" + ], + "macos": { + "key": "MCP tools", + "arguments": [] + } + }, + { + "id": "native.mcpToolsSent", + "description": "Native UI: MCP tools sent", + "platforms": [ + "macos" + ], + "macos": { + "key": "MCP tools sent", + "arguments": [] + } + }, + { + "id": "native.me", + "description": "Native UI: Me", + "platforms": [ + "macos" + ], + "macos": { + "key": "Me", + "arguments": [] + } + }, + { + "id": "native.medium", + "description": "Native UI: Medium", + "platforms": [ + "macos" + ], + "macos": { + "key": "Medium", + "arguments": [] + } + }, + { + "id": "native.menuActions", + "description": "Native UI: Menu Actions", + "platforms": [ + "macos" + ], + "macos": { + "key": "Menu Actions", + "arguments": [] + } + }, + { + "id": "native.merge", + "description": "Native UI: Merge", + "platforms": [ + "macos" + ], + "macos": { + "key": "Merge", + "arguments": [] + } + }, + { + "id": "native.merge.variant2", + "description": "Native UI: merge", + "platforms": [ + "macos" + ], + "macos": { + "key": "merge", + "arguments": [] + } + }, + { + "id": "native.mergeBranch", + "description": "Native UI: Merge branch?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Merge branch?", + "arguments": [] + } + }, + { + "id": "native.mergeInProgress", + "description": "Native UI: Merge in progress", + "platforms": [ + "macos" + ], + "macos": { + "key": "Merge in progress", + "arguments": [] + } + }, + { + "id": "native.mergeIntoCurrentBranch", + "description": "Native UI: Merge into Current Branch", + "platforms": [ + "macos" + ], + "macos": { + "key": "Merge into Current Branch", + "arguments": [] + } + }, + { + "id": "native.mergeIntoTheCurrentBranchConflictsMayRequireTerminalResolution", + "description": "Native UI: Merge %@ into the current branch. Conflicts may require terminal resolution.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Merge %@ into the current branch. Conflicts may require terminal resolution.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.mergeJoinsBothHistoriesWithAMergeCommitRebaseReplaysYourCommitsOnTopOfTheUpstr4232ebcb80", + "description": "Native UI: Merge joins both histories with a merge commit. Rebase replays your commits on top of the upstream, keeping history linear but rewriting your commit hashes.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Merge joins both histories with a merge commit. Rebase replays your commits on top of the upstream, keeping history linear but rewriting your commit hashes.", + "arguments": [] + } + }, + { + "id": "native.mergePullRequest", + "description": "Native UI: Merge pull request?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Merge pull request?", + "arguments": [] + } + }, + { + "id": "native.merged", + "description": "Native UI: Merged", + "platforms": [ + "macos" + ], + "macos": { + "key": "Merged", + "arguments": [] + } + }, + { + "id": "native.mergingPullRequest", + "description": "Native UI: Merging pull request…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Merging pull request…", + "arguments": [] + } + }, + { + "id": "native.message", + "description": "Native UI: (%@)", + "platforms": [ + "macos" + ], + "macos": { + "key": " (%@)", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.message.variant2", + "description": "Native UI: %@, %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@, %@", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string" + } + }, + { + "id": "native.message.variant3", + "description": "Native UI: %@: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@: %@", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string" + } + }, + { + "id": "native.message.variant4", + "description": "Native UI: , ", + "platforms": [ + "macos" + ], + "macos": { + "key": ", ", + "arguments": [] + } + }, + { + "id": "native.message.variant5", + "description": "Native UI: ; ", + "platforms": [ + "macos" + ], + "macos": { + "key": "; ", + "arguments": [] + } + }, + { + "id": "native.message.variant6", + "description": "Native UI: → %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "→ %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.messageOptional", + "description": "Native UI: Message (optional)", + "platforms": [ + "macos" + ], + "macos": { + "key": "Message (optional)", + "arguments": [] + } + }, + { + "id": "native.messageTheAgent", + "description": "Native UI: Message the Agent", + "platforms": [ + "macos" + ], + "macos": { + "key": "Message the Agent", + "arguments": [] + } + }, + { + "id": "native.messages", + "description": "Native UI: Messages", + "platforms": [ + "macos" + ], + "macos": { + "key": "Messages", + "arguments": [] + } + }, + { + "id": "native.metadataUpdated", + "description": "Native UI: Metadata updated", + "platforms": [ + "macos" + ], + "macos": { + "key": "Metadata updated", + "arguments": [] + } + }, + { + "id": "native.methodBreakpoints", + "description": "Native UI: Method Breakpoints", + "platforms": [ + "macos" + ], + "macos": { + "key": "Method Breakpoints", + "arguments": [] + } + }, + { + "id": "native.microphoneInUse", + "description": "Native UI: Microphone in use", + "platforms": [ + "macos" + ], + "macos": { + "key": "Microphone in use", + "arguments": [] + } + }, + { + "id": "native.minimap", + "description": "Native UI: Minimap", + "platforms": [ + "macos" + ], + "macos": { + "key": "Minimap", + "arguments": [] + } + }, + { + "id": "native.minimumLevel", + "description": "Native UI: Minimum level", + "platforms": [ + "macos" + ], + "macos": { + "key": "Minimum level", + "arguments": [] + } + }, + { + "id": "native.minimumVersion", + "description": "Native UI: Minimum version", + "platforms": [ + "macos" + ], + "macos": { + "key": "Minimum version", + "arguments": [] + } + }, + { + "id": "native.mixedResetKeepChangesUnstaged", + "description": "Native UI: Mixed Reset (Keep Changes Unstaged)", + "platforms": [ + "macos" + ], + "macos": { + "key": "Mixed Reset (Keep Changes Unstaged)", + "arguments": [] + } + }, + { + "id": "native.model", + "description": "Native UI: Model", + "platforms": [ + "macos" + ], + "macos": { + "key": "Model", + "arguments": [] + } + }, + { + "id": "native.modelCallStatistics", + "description": "Native UI: Model call statistics", + "platforms": [ + "macos" + ], + "macos": { + "key": "Model call statistics", + "arguments": [] + } + }, + { + "id": "native.modelCallsObservedByTheLocalProxyDuringThisTurnTTFTIsTheAverageTimeToTheFirstGeneratedToken", + "description": "Native UI: Model calls observed by the local proxy during this turn. TTFT is the average time to the first generated token.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Model calls observed by the local proxy during this turn. TTFT is the average time to the first generated token.", + "arguments": [] + } + }, + { + "id": "native.modelCallsSteps", + "description": "Native UI: Model calls (steps)", + "platforms": [ + "macos" + ], + "macos": { + "key": "Model calls (steps)", + "arguments": [] + } + }, + { + "id": "native.modelTime", + "description": "Native UI: Model time", + "platforms": [ + "macos" + ], + "macos": { + "key": "Model time", + "arguments": [] + } + }, + { + "id": "native.modified", + "description": "Native UI: Modified", + "platforms": [ + "macos" + ], + "macos": { + "key": "Modified", + "arguments": [] + } + }, + { + "id": "native.modifiedFilesLld", + "description": "Native UI: Modified files: %lld", + "platforms": [ + "macos" + ], + "macos": { + "key": "Modified files: %lld", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.module", + "description": "Native UI: Module", + "platforms": [ + "macos" + ], + "macos": { + "key": "Module", + "arguments": [] + } + }, + { + "id": "native.moduleLogsRotateAt10MiBAndExpireAfter30DaysTheCombinedBudgetIs100MiBPerformanc7c993f9014", + "description": "Native UI: Module logs rotate at 10 MiB and expire after 30 days. The combined budget is 100 MiB; performance logs have a separate 20 MiB budget.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Module logs rotate at 10 MiB and expire after 30 days. The combined budget is 100 MiB; performance logs have a separate 20 MiB budget.", + "arguments": [] + } + }, + { + "id": "native.modulePath", + "description": "Native UI: Module path", + "platforms": [ + "macos" + ], + "macos": { + "key": "Module path", + "arguments": [] + } + }, + { + "id": "native.moduleRoot", + "description": "Native UI: Module Root", + "platforms": [ + "macos" + ], + "macos": { + "key": "Module Root", + "arguments": [] + } + }, + { + "id": "native.moduleSessionMinimumLevel", + "description": "Native UI: Module: %@ · Session: %@ · Minimum level: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Module: %@ · Session: %@ · Minimum level: %@", + "arguments": [ + "arg1", + "arg2", + "arg3" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string", + "arg3": "string" + } + }, + { + "id": "native.more", + "description": "Native UI: … more", + "platforms": [ + "macos" + ], + "macos": { + "key": "… more", + "arguments": [] + } + }, + { + "id": "native.moreActions", + "description": "Native UI: More actions", + "platforms": [ + "macos" + ], + "macos": { + "key": "More actions", + "arguments": [] + } + }, + { + "id": "native.moreAgents", + "description": "Native UI: More Agents…", + "platforms": [ + "macos" + ], + "macos": { + "key": "More Agents…", + "arguments": [] + } + }, + { + "id": "native.moreInformation", + "description": "Native UI: More information", + "platforms": [ + "macos" + ], + "macos": { + "key": "More information", + "arguments": [] + } + }, + { + "id": "native.moreSessionSettings", + "description": "Native UI: More session settings", + "platforms": [ + "macos" + ], + "macos": { + "key": "More session settings", + "arguments": [] + } + }, + { + "id": "native.moveCommitEarlier", + "description": "Native UI: Move commit earlier", + "platforms": [ + "macos" + ], + "macos": { + "key": "Move commit earlier", + "arguments": [] + } + }, + { + "id": "native.moveCommitLater", + "description": "Native UI: Move commit later", + "platforms": [ + "macos" + ], + "macos": { + "key": "Move commit later", + "arguments": [] + } + }, + { + "id": "native.moveEarlier", + "description": "Native UI: Move Earlier", + "platforms": [ + "macos" + ], + "macos": { + "key": "Move Earlier", + "arguments": [] + } + }, + { + "id": "native.moveLater", + "description": "Native UI: Move Later", + "platforms": [ + "macos" + ], + "macos": { + "key": "Move Later", + "arguments": [] + } + }, + { + "id": "native.moveLldItemsToTrash", + "description": "Native UI: Move %lld items to Trash?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Move %lld items to Trash?", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.moveTheCurrentBranchToAndDiscardAllWorkingTreeChanges", + "description": "Native UI: Move the current branch to %@ and discard all working-tree changes.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Move the current branch to %@ and discard all working-tree changes.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.moveTheCurrentBranchToAndKeepChangesStaged", + "description": "Native UI: Move the current branch to %@ and keep changes staged.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Move the current branch to %@ and keep changes staged.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.moveTheCurrentBranchToAndKeepChangesUnstaged", + "description": "Native UI: Move the current branch to %@ and keep changes unstaged.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Move the current branch to %@ and keep changes unstaged.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.moveToBottom", + "description": "Native UI: Move to Bottom", + "platforms": [ + "macos" + ], + "macos": { + "key": "Move to Bottom", + "arguments": [] + } + }, + { + "id": "native.moveToBottomLeft", + "description": "Native UI: Move to Bottom Left", + "platforms": [ + "macos" + ], + "macos": { + "key": "Move to Bottom Left", + "arguments": [] + } + }, + { + "id": "native.moveToBottomRight", + "description": "Native UI: Move to Bottom Right", + "platforms": [ + "macos" + ], + "macos": { + "key": "Move to Bottom Right", + "arguments": [] + } + }, + { + "id": "native.moveToChangeList", + "description": "Native UI: Move to ChangeList", + "platforms": [ + "macos" + ], + "macos": { + "key": "Move to ChangeList", + "arguments": [] + } + }, + { + "id": "native.moveToFolder", + "description": "Native UI: Move to Folder", + "platforms": [ + "macos" + ], + "macos": { + "key": "Move to Folder", + "arguments": [] + } + }, + { + "id": "native.moveToLeft", + "description": "Native UI: Move to Left", + "platforms": [ + "macos" + ], + "macos": { + "key": "Move to Left", + "arguments": [] + } + }, + { + "id": "native.moveToLeftBottom", + "description": "Native UI: Move to Left Bottom", + "platforms": [ + "macos" + ], + "macos": { + "key": "Move to Left Bottom", + "arguments": [] + } + }, + { + "id": "native.moveToLeftTop", + "description": "Native UI: Move to Left Top", + "platforms": [ + "macos" + ], + "macos": { + "key": "Move to Left Top", + "arguments": [] + } + }, + { + "id": "native.moveToRight", + "description": "Native UI: Move to Right", + "platforms": [ + "macos" + ], + "macos": { + "key": "Move to Right", + "arguments": [] + } + }, + { + "id": "native.moveToRightBottom", + "description": "Native UI: Move to Right Bottom", + "platforms": [ + "macos" + ], + "macos": { + "key": "Move to Right Bottom", + "arguments": [] + } + }, + { + "id": "native.moveToRightTop", + "description": "Native UI: Move to Right Top", + "platforms": [ + "macos" + ], + "macos": { + "key": "Move to Right Top", + "arguments": [] + } + }, + { + "id": "native.moveToRoot", + "description": "Native UI: Move to root", + "platforms": [ + "macos" + ], + "macos": { + "key": "Move to root", + "arguments": [] + } + }, + { + "id": "native.moveToTheNextMatchInTheActiveEditor", + "description": "Native UI: Move to the next match in the active editor", + "platforms": [ + "macos" + ], + "macos": { + "key": "Move to the next match in the active editor", + "arguments": [] + } + }, + { + "id": "native.moveToThePreviousMatchInTheActiveEditor", + "description": "Native UI: Move to the previous match in the active editor", + "platforms": [ + "macos" + ], + "macos": { + "key": "Move to the previous match in the active editor", + "arguments": [] + } + }, + { + "id": "native.moveToTrash", + "description": "Native UI: Move to Trash", + "platforms": [ + "macos" + ], + "macos": { + "key": "Move to Trash", + "arguments": [] + } + }, + { + "id": "native.moved", + "description": "Native UI: Moved", + "platforms": [ + "macos" + ], + "macos": { + "key": "Moved", + "arguments": [] + } + }, + { + "id": "native.muteBreakpoints", + "description": "Native UI: Mute breakpoints", + "platforms": [ + "macos" + ], + "macos": { + "key": "Mute breakpoints", + "arguments": [] + } + }, + { + "id": "native.muteCamera", + "description": "Native UI: Mute Camera", + "platforms": [ + "macos" + ], + "macos": { + "key": "Mute Camera", + "arguments": [] + } + }, + { + "id": "native.muteLineBreakpoints", + "description": "Native UI: Mute Line Breakpoints", + "platforms": [ + "macos" + ], + "macos": { + "key": "Mute Line Breakpoints", + "arguments": [] + } + }, + { + "id": "native.muteMicrophone", + "description": "Native UI: Mute Microphone", + "platforms": [ + "macos" + ], + "macos": { + "key": "Mute Microphone", + "arguments": [] + } + }, + { + "id": "native.name", + "description": "Native UI: Name", + "platforms": [ + "macos" + ], + "macos": { + "key": "Name", + "arguments": [] + } + }, + { + "id": "native.name.variant2", + "description": "Native UI: Name:", + "platforms": [ + "macos" + ], + "macos": { + "key": "Name:", + "arguments": [] + } + }, + { + "id": "native.nameAndEmailAreWrittenToTheGlobalGitConfigurationExistingCommitsDoNotChange", + "description": "Native UI: Name and email are written to the global Git configuration. Existing commits do not change.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Name and email are written to the global Git configuration. Existing commits do not change.", + "arguments": [] + } + }, + { + "id": "native.nameAndEmailAreWrittenToThisRepositorySGitConfigurationAndOverrideTheGlobalVal4aca07ef08", + "description": "Native UI: Name and email are written to this repository's Git configuration and override the global values. Existing commits do not change.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Name and email are written to this repository's Git configuration and override the global values. Existing commits do not change.", + "arguments": [] + } + }, + { + "id": "native.nativeInstaller", + "description": "Native UI: Native installer", + "platforms": [ + "macos" + ], + "macos": { + "key": "Native installer", + "arguments": [] + } + }, + { + "id": "native.navigate", + "description": "Native UI: Navigate", + "platforms": [ + "macos" + ], + "macos": { + "key": "Navigate", + "arguments": [] + } + }, + { + "id": "native.navigateToACallSiteOfTheSelectedJavaSymbol", + "description": "Native UI: Navigate to a call site of the selected Java symbol", + "platforms": [ + "macos" + ], + "macos": { + "key": "Navigate to a call site of the selected Java symbol", + "arguments": [] + } + }, + { + "id": "native.navigateToACallSiteOfTheSelectedSymbol", + "description": "Native UI: Navigate to a call site of the selected symbol", + "platforms": [ + "macos" + ], + "macos": { + "key": "Navigate to a call site of the selected symbol", + "arguments": [] + } + }, + { + "id": "native.navigateToAnImplementationOfTheSelectedSymbol", + "description": "Native UI: Navigate to an implementation of the selected symbol", + "platforms": [ + "macos" + ], + "macos": { + "key": "Navigate to an implementation of the selected symbol", + "arguments": [] + } + }, + { + "id": "native.navigateToTheDeclarationOfTheSelectedSymbol", + "description": "Native UI: Navigate to the declaration of the selected symbol", + "platforms": [ + "macos" + ], + "macos": { + "key": "Navigate to the declaration of the selected symbol", + "arguments": [] + } + }, + { + "id": "native.navigateToTheNextEditorLocation", + "description": "Native UI: Navigate to the next editor location", + "platforms": [ + "macos" + ], + "macos": { + "key": "Navigate to the next editor location", + "arguments": [] + } + }, + { + "id": "native.navigateToThePreviousEditorLocation", + "description": "Native UI: Navigate to the previous editor location", + "platforms": [ + "macos" + ], + "macos": { + "key": "Navigate to the previous editor location", + "arguments": [] + } + }, + { + "id": "native.navigation", + "description": "Native UI: Navigation", + "platforms": [ + "macos" + ], + "macos": { + "key": "Navigation", + "arguments": [] + } + }, + { + "id": "native.needsNoExtraPermissions", + "description": "Native UI: Needs no extra permissions.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Needs no extra permissions.", + "arguments": [] + } + }, + { + "id": "native.needsRestart", + "description": "Native UI: Needs restart", + "platforms": [ + "macos" + ], + "macos": { + "key": "Needs restart", + "arguments": [] + } + }, + { + "id": "native.new", + "description": "Native UI: New", + "platforms": [ + "macos" + ], + "macos": { + "key": "New", + "arguments": [] + } + }, + { + "id": "native.newBranch", + "description": "Native UI: New Branch", + "platforms": [ + "macos" + ], + "macos": { + "key": "New Branch", + "arguments": [] + } + }, + { + "id": "native.newBranch.variant2", + "description": "Native UI: New Branch…", + "platforms": [ + "macos" + ], + "macos": { + "key": "New Branch…", + "arguments": [] + } + }, + { + "id": "native.newBranch.variant3", + "description": "Native UI: New branch", + "platforms": [ + "macos" + ], + "macos": { + "key": "New branch", + "arguments": [] + } + }, + { + "id": "native.newBranchFrom", + "description": "Native UI: New Branch from '%@'…", + "platforms": [ + "macos" + ], + "macos": { + "key": "New Branch from '%@'…", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.newBranchName", + "description": "Native UI: New branch name", + "platforms": [ + "macos" + ], + "macos": { + "key": "New branch name", + "arguments": [] + } + }, + { + "id": "native.newChangeList", + "description": "Native UI: New ChangeList", + "platforms": [ + "macos" + ], + "macos": { + "key": "New ChangeList", + "arguments": [] + } + }, + { + "id": "native.newChat", + "description": "Native UI: New Chat", + "platforms": [ + "macos" + ], + "macos": { + "key": "New Chat", + "arguments": [] + } + }, + { + "id": "native.newConfiguration", + "description": "Native UI: New Configuration", + "platforms": [ + "macos" + ], + "macos": { + "key": "New Configuration", + "arguments": [] + } + }, + { + "id": "native.newConnection", + "description": "Native UI: New Connection", + "platforms": [ + "macos" + ], + "macos": { + "key": "New Connection", + "arguments": [] + } + }, + { + "id": "native.newConversation", + "description": "Native UI: New Conversation", + "platforms": [ + "macos" + ], + "macos": { + "key": "New Conversation", + "arguments": [] + } + }, + { + "id": "native.newConversation.variant2", + "description": "Native UI: New conversation", + "platforms": [ + "macos" + ], + "macos": { + "key": "New conversation", + "arguments": [] + } + }, + { + "id": "native.newDirectory", + "description": "Native UI: New Directory…", + "platforms": [ + "macos" + ], + "macos": { + "key": "New Directory…", + "arguments": [] + } + }, + { + "id": "native.newFile", + "description": "Native UI: New File…", + "platforms": [ + "macos" + ], + "macos": { + "key": "New File…", + "arguments": [] + } + }, + { + "id": "native.newFolder", + "description": "Native UI: New Folder", + "platforms": [ + "macos" + ], + "macos": { + "key": "New Folder", + "arguments": [] + } + }, + { + "id": "native.newProject", + "description": "Native UI: New Project…", + "platforms": [ + "macos" + ], + "macos": { + "key": "New Project…", + "arguments": [] + } + }, + { + "id": "native.newRunConfiguration", + "description": "Native UI: New Run Configuration", + "platforms": [ + "macos" + ], + "macos": { + "key": "New Run Configuration", + "arguments": [] + } + }, + { + "id": "native.newTag", + "description": "Native UI: New Tag", + "platforms": [ + "macos" + ], + "macos": { + "key": "New Tag", + "arguments": [] + } + }, + { + "id": "native.newTag.variant2", + "description": "Native UI: New Tag…", + "platforms": [ + "macos" + ], + "macos": { + "key": "New Tag…", + "arguments": [] + } + }, + { + "id": "native.newTerminalSession", + "description": "Native UI: New terminal session", + "platforms": [ + "macos" + ], + "macos": { + "key": "New terminal session", + "arguments": [] + } + }, + { + "id": "native.newWindow", + "description": "Native UI: New Window", + "platforms": [ + "macos" + ], + "macos": { + "key": "New Window", + "arguments": [] + } + }, + { + "id": "native.newWindow.variant2", + "description": "Native UI: New window", + "platforms": [ + "macos" + ], + "macos": { + "key": "New window", + "arguments": [] + } + }, + { + "id": "native.newWorktree", + "description": "Native UI: New Worktree", + "platforms": [ + "macos" + ], + "macos": { + "key": "New Worktree", + "arguments": [] + } + }, + { + "id": "native.nextConsoleExpression", + "description": "Native UI: Next console expression", + "platforms": [ + "macos" + ], + "macos": { + "key": "Next console expression", + "arguments": [] + } + }, + { + "id": "native.nextDiffMatch", + "description": "Native UI: Next diff match", + "platforms": [ + "macos" + ], + "macos": { + "key": "Next diff match", + "arguments": [] + } + }, + { + "id": "native.nextDifference", + "description": "Native UI: Next difference", + "platforms": [ + "macos" + ], + "macos": { + "key": "Next difference", + "arguments": [] + } + }, + { + "id": "native.nextFile", + "description": "Native UI: Next File", + "platforms": [ + "macos" + ], + "macos": { + "key": "Next File", + "arguments": [] + } + }, + { + "id": "native.nextMatch", + "description": "Native UI: Next Match", + "platforms": [ + "macos" + ], + "macos": { + "key": "Next Match", + "arguments": [] + } + }, + { + "id": "native.nextMatch.variant2", + "description": "Native UI: Next match", + "platforms": [ + "macos" + ], + "macos": { + "key": "Next match", + "arguments": [] + } + }, + { + "id": "native.nextMatchReturn", + "description": "Native UI: Next match (Return)", + "platforms": [ + "macos" + ], + "macos": { + "key": "Next match (Return)", + "arguments": [] + } + }, + { + "id": "native.nextRun", + "description": "Native UI: Next run", + "platforms": [ + "macos" + ], + "macos": { + "key": "Next run", + "arguments": [] + } + }, + { + "id": "native.noAIProviderIsConfiguredYet", + "description": "Native UI: No AI provider is configured yet.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No AI provider is configured yet.", + "arguments": [] + } + }, + { + "id": "native.noAPIKeyFoundInClaudeConfiguration", + "description": "Native UI: No API key found in Claude configuration", + "platforms": [ + "macos" + ], + "macos": { + "key": "No API key found in Claude configuration", + "arguments": [] + } + }, + { + "id": "native.noAPIKeyFoundInCodexAuthJson", + "description": "Native UI: No API key found in Codex auth.json", + "platforms": [ + "macos" + ], + "macos": { + "key": "No API key found in Codex auth.json", + "arguments": [] + } + }, + { + "id": "native.noAgent", + "description": "Native UI: no Agent", + "platforms": [ + "macos" + ], + "macos": { + "key": "no Agent", + "arguments": [] + } + }, + { + "id": "native.noAgentIsReadyYetOpenThePanelSettingsToInstallAnAgentAndChooseALocalOrCustomProvider", + "description": "Native UI: No Agent is ready yet. Open the panel settings to install an Agent and choose a local or custom provider.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No Agent is ready yet. Open the panel settings to install an Agent and choose a local or custom provider.", + "arguments": [] + } + }, + { + "id": "native.noAgentIsReadyYetOpenThePanelSettingsToInstallAnAgentAndFetchItsLocalConfiguration", + "description": "Native UI: No Agent is ready yet. Open the panel settings to install an Agent and fetch its local configuration.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No Agent is ready yet. Open the panel settings to install an Agent and fetch its local configuration.", + "arguments": [] + } + }, + { + "id": "native.noAgentIsSelected", + "description": "Native UI: No Agent is selected.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No Agent is selected.", + "arguments": [] + } + }, + { + "id": "native.noAgentIsSetUpYet", + "description": "Native UI: No Agent is set up yet", + "platforms": [ + "macos" + ], + "macos": { + "key": "No Agent is set up yet", + "arguments": [] + } + }, + { + "id": "native.noAvailableLocalBranchBranchesAlreadyCheckedOutInAnotherWorktreeCannotBeReused", + "description": "Native UI: No available local branch. Branches already checked out in another worktree cannot be reused.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No available local branch. Branches already checked out in another worktree cannot be reused.", + "arguments": [] + } + }, + { + "id": "native.noBackgroundImageSelected", + "description": "Native UI: No background image selected", + "platforms": [ + "macos" + ], + "macos": { + "key": "No background image selected", + "arguments": [] + } + }, + { + "id": "native.noBranchesFound", + "description": "Native UI: No branches found", + "platforms": [ + "macos" + ], + "macos": { + "key": "No branches found", + "arguments": [] + } + }, + { + "id": "native.noBreakpoints", + "description": "Native UI: No breakpoints", + "platforms": [ + "macos" + ], + "macos": { + "key": "No breakpoints", + "arguments": [] + } + }, + { + "id": "native.noCallableTargetAtThisLocation", + "description": "Native UI: No callable target at this location", + "platforms": [ + "macos" + ], + "macos": { + "key": "No callable target at this location", + "arguments": [] + } + }, + { + "id": "native.noChangedFiles", + "description": "Native UI: No changed files", + "platforms": [ + "macos" + ], + "macos": { + "key": "No changed files", + "arguments": [] + } + }, + { + "id": "native.noChanges", + "description": "Native UI: No changes", + "platforms": [ + "macos" + ], + "macos": { + "key": "No changes", + "arguments": [] + } + }, + { + "id": "native.noCodexConfigurationWasFound", + "description": "Native UI: No Codex configuration was found", + "platforms": [ + "macos" + ], + "macos": { + "key": "No Codex configuration was found", + "arguments": [] + } + }, + { + "id": "native.noColumns", + "description": "Native UI: No Columns", + "platforms": [ + "macos" + ], + "macos": { + "key": "No Columns", + "arguments": [] + } + }, + { + "id": "native.noCommand", + "description": "Native UI: No command", + "platforms": [ + "macos" + ], + "macos": { + "key": "No command", + "arguments": [] + } + }, + { + "id": "native.noCommitSelected", + "description": "Native UI: No commit selected", + "platforms": [ + "macos" + ], + "macos": { + "key": "No commit selected", + "arguments": [] + } + }, + { + "id": "native.noCommits", + "description": "Native UI: No commits", + "platforms": [ + "macos" + ], + "macos": { + "key": "No commits", + "arguments": [] + } + }, + { + "id": "native.noCommitsMatchThisView", + "description": "Native UI: No commits match this view", + "platforms": [ + "macos" + ], + "macos": { + "key": "No commits match this view", + "arguments": [] + } + }, + { + "id": "native.noCommitsWereFoundForThisBranch", + "description": "Native UI: No commits were found for this branch.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No commits were found for this branch.", + "arguments": [] + } + }, + { + "id": "native.noCompletedGitInvocationWasReported", + "description": "Native UI: No completed Git invocation was reported", + "platforms": [ + "macos" + ], + "macos": { + "key": "No completed Git invocation was reported", + "arguments": [] + } + }, + { + "id": "native.noConfigurationWasFoundOnThisMac", + "description": "Native UI: No %@ configuration was found on this Mac.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No %@ configuration was found on this Mac.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.noConfigurationsInThisScope", + "description": "Native UI: No configurations in this scope", + "platforms": [ + "macos" + ], + "macos": { + "key": "No configurations in this scope", + "arguments": [] + } + }, + { + "id": "native.noConversationYet", + "description": "Native UI: No conversation yet", + "platforms": [ + "macos" + ], + "macos": { + "key": "No conversation yet", + "arguments": [] + } + }, + { + "id": "native.noCredentialFoundInClaudeConfiguration", + "description": "Native UI: No credential found in Claude configuration", + "platforms": [ + "macos" + ], + "macos": { + "key": "No credential found in Claude configuration", + "arguments": [] + } + }, + { + "id": "native.noCredentialFoundInCodexConfiguration", + "description": "Native UI: No credential found in Codex configuration", + "platforms": [ + "macos" + ], + "macos": { + "key": "No credential found in Codex configuration", + "arguments": [] + } + }, + { + "id": "native.noCurrentBranch", + "description": "Native UI: No current branch", + "platforms": [ + "macos" + ], + "macos": { + "key": "No current branch", + "arguments": [] + } + }, + { + "id": "native.noCustomProvidersYet", + "description": "Native UI: No custom providers yet.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No custom providers yet.", + "arguments": [] + } + }, + { + "id": "native.noDefaultShellWasDetectedChooseADetectedShellOrRunDetectionAgain", + "description": "Native UI: No default shell was detected. Choose a detected shell or run detection again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No default shell was detected. Choose a detected shell or run detection again.", + "arguments": [] + } + }, + { + "id": "native.noDependencies", + "description": "Native UI: No dependencies", + "platforms": [ + "macos" + ], + "macos": { + "key": "No dependencies", + "arguments": [] + } + }, + { + "id": "native.noDependencies.variant2", + "description": "Native UI: No dependencies.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No dependencies.", + "arguments": [] + } + }, + { + "id": "native.noDescriptionProvided", + "description": "Native UI: No description provided.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No description provided.", + "arguments": [] + } + }, + { + "id": "native.noDifferences", + "description": "Native UI: No differences", + "platforms": [ + "macos" + ], + "macos": { + "key": "No differences", + "arguments": [] + } + }, + { + "id": "native.noDifferencesWereFoundForThisSelection", + "description": "Native UI: No differences were found for this selection.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No differences were found for this selection.", + "arguments": [] + } + }, + { + "id": "native.noEarlierConversations", + "description": "Native UI: No earlier conversations", + "platforms": [ + "macos" + ], + "macos": { + "key": "No earlier conversations", + "arguments": [] + } + }, + { + "id": "native.noEffectiveValueConfigured", + "description": "Native UI: No effective value configured", + "platforms": [ + "macos" + ], + "macos": { + "key": "No effective value configured", + "arguments": [] + } + }, + { + "id": "native.noFileEdits", + "description": "Native UI: No file edits", + "platforms": [ + "macos" + ], + "macos": { + "key": "No file edits", + "arguments": [] + } + }, + { + "id": "native.noFilesMatchTheConflictFilter", + "description": "Native UI: No files match the conflict filter", + "platforms": [ + "macos" + ], + "macos": { + "key": "No files match the conflict filter", + "arguments": [] + } + }, + { + "id": "native.noGitHubOrigin", + "description": "Native UI: No GitHub origin", + "platforms": [ + "macos" + ], + "macos": { + "key": "No GitHub origin", + "arguments": [] + } + }, + { + "id": "native.noGitRepositoryIsOpen", + "description": "Native UI: No Git repository is open", + "platforms": [ + "macos" + ], + "macos": { + "key": "No Git repository is open", + "arguments": [] + } + }, + { + "id": "native.noHistoryRecordedYet", + "description": "Native UI: No history recorded yet", + "platforms": [ + "macos" + ], + "macos": { + "key": "No history recorded yet", + "arguments": [] + } + }, + { + "id": "native.noImplementationsFound", + "description": "Native UI: No implementations found", + "platforms": [ + "macos" + ], + "macos": { + "key": "No implementations found", + "arguments": [] + } + }, + { + "id": "native.noJDKFoundSetJAVAHOMEInstallAJDKOrChooseAJDKDirectory", + "description": "Native UI: No JDK found. Set JAVA_HOME, install a JDK, or choose a JDK directory.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No JDK found. Set JAVA_HOME, install a JDK, or choose a JDK directory.", + "arguments": [] + } + }, + { + "id": "native.noJDKIsConfiguredForThisProject", + "description": "Native UI: No JDK is configured for this project.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No JDK is configured for this project.", + "arguments": [] + } + }, + { + "id": "native.noJVMPerformanceSamplesReceivedFor10Seconds", + "description": "Native UI: No JVM performance samples received for 10 seconds.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No JVM performance samples received for 10 seconds.", + "arguments": [] + } + }, + { + "id": "native.noJavaNavigationResults", + "description": "Native UI: No Java navigation results", + "platforms": [ + "macos" + ], + "macos": { + "key": "No Java navigation results", + "arguments": [] + } + }, + { + "id": "native.noJavaProblems", + "description": "Native UI: No Java problems", + "platforms": [ + "macos" + ], + "macos": { + "key": "No Java problems", + "arguments": [] + } + }, + { + "id": "native.noLocalChanges", + "description": "Native UI: No local changes", + "platforms": [ + "macos" + ], + "macos": { + "key": "No local changes", + "arguments": [] + } + }, + { + "id": "native.noLocalVariablesInTheCurrentFrame", + "description": "Native UI: No local variables in the current frame", + "platforms": [ + "macos" + ], + "macos": { + "key": "No local variables in the current frame", + "arguments": [] + } + }, + { + "id": "native.noMCPServersRegistered", + "description": "Native UI: No MCP servers registered", + "platforms": [ + "macos" + ], + "macos": { + "key": "No MCP servers registered", + "arguments": [] + } + }, + { + "id": "native.noMatches", + "description": "Native UI: No matches", + "platforms": [ + "macos" + ], + "macos": { + "key": "No matches", + "arguments": [] + } + }, + { + "id": "native.noMatchesIn", + "description": "Native UI: No matches in", + "platforms": [ + "macos" + ], + "macos": { + "key": "No matches in", + "arguments": [] + } + }, + { + "id": "native.noMatchesInActions", + "description": "Native UI: No matches in Actions", + "platforms": [ + "macos" + ], + "macos": { + "key": "No matches in Actions", + "arguments": [] + } + }, + { + "id": "native.noMatchesInAll", + "description": "Native UI: No matches in All", + "platforms": [ + "macos" + ], + "macos": { + "key": "No matches in All", + "arguments": [] + } + }, + { + "id": "native.noMatchesInClasses", + "description": "Native UI: No matches in Classes", + "platforms": [ + "macos" + ], + "macos": { + "key": "No matches in Classes", + "arguments": [] + } + }, + { + "id": "native.noMatchesInFiles", + "description": "Native UI: No matches in Files", + "platforms": [ + "macos" + ], + "macos": { + "key": "No matches in Files", + "arguments": [] + } + }, + { + "id": "native.noMatchesInSymbols", + "description": "Native UI: No matches in Symbols", + "platforms": [ + "macos" + ], + "macos": { + "key": "No matches in Symbols", + "arguments": [] + } + }, + { + "id": "native.noMatchesInText", + "description": "Native UI: No matches in Text", + "platforms": [ + "macos" + ], + "macos": { + "key": "No matches in Text", + "arguments": [] + } + }, + { + "id": "native.noMatchingBranches", + "description": "Native UI: No matching branches", + "platforms": [ + "macos" + ], + "macos": { + "key": "No matching branches", + "arguments": [] + } + }, + { + "id": "native.noMatchingCommands", + "description": "Native UI: No matching commands", + "platforms": [ + "macos" + ], + "macos": { + "key": "No matching commands", + "arguments": [] + } + }, + { + "id": "native.noMatchingCommitsInTheLoadedHistory", + "description": "Native UI: No matching commits in the loaded history.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No matching commits in the loaded history.", + "arguments": [] + } + }, + { + "id": "native.noMatchingConversations", + "description": "Native UI: No matching conversations", + "platforms": [ + "macos" + ], + "macos": { + "key": "No matching conversations", + "arguments": [] + } + }, + { + "id": "native.noMatchingItems", + "description": "Native UI: No matching items", + "platforms": [ + "macos" + ], + "macos": { + "key": "No matching items", + "arguments": [] + } + }, + { + "id": "native.noMatchingLogs", + "description": "Native UI: No matching logs", + "platforms": [ + "macos" + ], + "macos": { + "key": "No matching logs", + "arguments": [] + } + }, + { + "id": "native.noMatchingMessages", + "description": "Native UI: No matching messages", + "platforms": [ + "macos" + ], + "macos": { + "key": "No matching messages", + "arguments": [] + } + }, + { + "id": "native.noMatchingPlugins", + "description": "Native UI: No matching plugins", + "platforms": [ + "macos" + ], + "macos": { + "key": "No matching plugins", + "arguments": [] + } + }, + { + "id": "native.noMatchingUsers", + "description": "Native UI: No matching users", + "platforms": [ + "macos" + ], + "macos": { + "key": "No matching users", + "arguments": [] + } + }, + { + "id": "native.noMavenExecutableWasFoundChooseMavenHomeOrAnExecutableInMavenSettings", + "description": "Native UI: No Maven executable was found. Choose Maven Home or an executable in Maven Settings.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No Maven executable was found. Choose Maven Home or an executable in Maven Settings.", + "arguments": [] + } + }, + { + "id": "native.noMavenFoundAddAMavenWrapperToTheProjectOrChooseAMavenInstallation", + "description": "Native UI: No Maven found. Add a Maven Wrapper to the project or choose a Maven installation.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No Maven found. Add a Maven Wrapper to the project or choose a Maven installation.", + "arguments": [] + } + }, + { + "id": "native.noMavenProjectDetected", + "description": "Native UI: No Maven project detected", + "platforms": [ + "macos" + ], + "macos": { + "key": "No Maven project detected", + "arguments": [] + } + }, + { + "id": "native.noMavenProjectIsAvailableForDebug", + "description": "Native UI: No Maven project is available for Debug", + "platforms": [ + "macos" + ], + "macos": { + "key": "No Maven project is available for Debug", + "arguments": [] + } + }, + { + "id": "native.noModelsAvailable", + "description": "Native UI: No models available", + "platforms": [ + "macos" + ], + "macos": { + "key": "No models available", + "arguments": [] + } + }, + { + "id": "native.noNewCommitsPushingCreatesTheRemoteBranch", + "description": "Native UI: No new commits. Pushing creates the remote branch.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No new commits. Pushing creates the remote branch.", + "arguments": [] + } + }, + { + "id": "native.noNotifications", + "description": "Native UI: No notifications", + "platforms": [ + "macos" + ], + "macos": { + "key": "No notifications", + "arguments": [] + } + }, + { + "id": "native.noPaths", + "description": "Native UI: No paths", + "platforms": [ + "macos" + ], + "macos": { + "key": "No paths", + "arguments": [] + } + }, + { + "id": "native.noPathsResolvedForThisService", + "description": "Native UI: No paths resolved for this service", + "platforms": [ + "macos" + ], + "macos": { + "key": "No paths resolved for this service", + "arguments": [] + } + }, + { + "id": "native.noPlugins", + "description": "Native UI: No Plugins", + "platforms": [ + "macos" + ], + "macos": { + "key": "No Plugins", + "arguments": [] + } + }, + { + "id": "native.noPluginsAvailable", + "description": "Native UI: No plugins available", + "platforms": [ + "macos" + ], + "macos": { + "key": "No plugins available", + "arguments": [] + } + }, + { + "id": "native.noProblemsIn", + "description": "Native UI: No problems in %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "No problems in %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.noProblemsMatchTheCurrentFilter", + "description": "Native UI: No problems match the current filter", + "platforms": [ + "macos" + ], + "macos": { + "key": "No problems match the current filter", + "arguments": [] + } + }, + { + "id": "native.noProfilingEventsWereRecorded", + "description": "Native UI: No profiling events were recorded.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No profiling events were recorded.", + "arguments": [] + } + }, + { + "id": "native.noProjectEntryPointDetected", + "description": "Native UI: No project entry point detected", + "platforms": [ + "macos" + ], + "macos": { + "key": "No project entry point detected", + "arguments": [] + } + }, + { + "id": "native.noProjectHistoryRecordedYet", + "description": "Native UI: No project history recorded yet", + "platforms": [ + "macos" + ], + "macos": { + "key": "No project history recorded yet", + "arguments": [] + } + }, + { + "id": "native.noProjectIsOpen", + "description": "Native UI: No project is open", + "platforms": [ + "macos" + ], + "macos": { + "key": "No project is open", + "arguments": [] + } + }, + { + "id": "native.noProjectIsOpenRowsBackedByGitConfigurationWriteToTheGlobalGitConfigurationCom6dc0364452", + "description": "Native UI: No project is open. Rows backed by Git configuration write to the global Git configuration; commit identity needs a repository.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No project is open. Rows backed by Git configuration write to the global Git configuration; commit identity needs a repository.", + "arguments": [] + } + }, + { + "id": "native.noProjectLevelMCPEntryWasWritten", + "description": "Native UI: No project-level MCP entry was written.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No project-level MCP entry was written.", + "arguments": [] + } + }, + { + "id": "native.noProjectLoaded", + "description": "Native UI: No project loaded", + "platforms": [ + "macos" + ], + "macos": { + "key": "No project loaded", + "arguments": [] + } + }, + { + "id": "native.noProjectWasDetectedInThisWorkspace", + "description": "Native UI: No %@ project was detected in this workspace.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No %@ project was detected in this workspace.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.noProviderYet", + "description": "Native UI: No %@ provider yet", + "platforms": [ + "macos" + ], + "macos": { + "key": "No %@ provider yet", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.noPublishedReleaseIsAvailableYet", + "description": "Native UI: No published release is available yet.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No published release is available yet.", + "arguments": [] + } + }, + { + "id": "native.noPullRequests", + "description": "Native UI: No pull requests", + "platforms": [ + "macos" + ], + "macos": { + "key": "No pull requests", + "arguments": [] + } + }, + { + "id": "native.noPullRequestsMatchTheSelectedState", + "description": "Native UI: No pull requests match the selected state.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No pull requests match the selected state.", + "arguments": [] + } + }, + { + "id": "native.noRecentProgressHasBeenReceivedTheTaskIsStillActive", + "description": "Native UI: No recent progress has been received. The task is still active.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No recent progress has been received. The task is still active.", + "arguments": [] + } + }, + { + "id": "native.noRecentProjects", + "description": "Native UI: No recent projects", + "platforms": [ + "macos" + ], + "macos": { + "key": "No recent projects", + "arguments": [] + } + }, + { + "id": "native.noReleaseIsAvailableYet", + "description": "Native UI: No release is available yet", + "platforms": [ + "macos" + ], + "macos": { + "key": "No release is available yet", + "arguments": [] + } + }, + { + "id": "native.noRememberedChoicesCheckRememberMyChoiceInTheQuitDialogToSkipTheQuestionForAKi279db9aaf7", + "description": "Native UI: No remembered choices. Check “Remember my choice” in the quit dialog to skip the question for a kind of service next time.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No remembered choices. Check “Remember my choice” in the quit dialog to skip the question for a kind of service next time.", + "arguments": [] + } + }, + { + "id": "native.noRemote", + "description": "Native UI: No remote", + "platforms": [ + "macos" + ], + "macos": { + "key": "No remote", + "arguments": [] + } + }, + { + "id": "native.noRemoteBranches", + "description": "Native UI: No Remote Branches", + "platforms": [ + "macos" + ], + "macos": { + "key": "No Remote Branches", + "arguments": [] + } + }, + { + "id": "native.noRemoteURL", + "description": "Native UI: No remote URL", + "platforms": [ + "macos" + ], + "macos": { + "key": "No remote URL", + "arguments": [] + } + }, + { + "id": "native.noRunServicesConfigured", + "description": "Native UI: No run services configured", + "platforms": [ + "macos" + ], + "macos": { + "key": "No run services configured", + "arguments": [] + } + }, + { + "id": "native.noRunnableServicesWereDetectedInThisProject", + "description": "Native UI: No runnable services were detected in this project.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No runnable services were detected in this project.", + "arguments": [] + } + }, + { + "id": "native.noRunningDebugProcessAcceptsStandardInput", + "description": "Native UI: No running debug process accepts standard input", + "platforms": [ + "macos" + ], + "macos": { + "key": "No running debug process accepts standard input", + "arguments": [] + } + }, + { + "id": "native.noSavedChanges", + "description": "Native UI: No saved changes", + "platforms": [ + "macos" + ], + "macos": { + "key": "No saved changes", + "arguments": [] + } + }, + { + "id": "native.noShellWasFoundAtChooseADetectedShellOrRunDetectionAgain", + "description": "Native UI: No shell was found at %@. Choose a detected shell or run detection again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No shell was found at %@. Choose a detected shell or run detection again.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.noShelves", + "description": "Native UI: No shelves", + "platforms": [ + "macos" + ], + "macos": { + "key": "No shelves", + "arguments": [] + } + }, + { + "id": "native.noSpringBootOrMavenModuleConfigurations", + "description": "Native UI: No Spring Boot or Maven Module configurations", + "platforms": [ + "macos" + ], + "macos": { + "key": "No Spring Boot or Maven Module configurations", + "arguments": [] + } + }, + { + "id": "native.noSubagentOutputYet", + "description": "Native UI: No subagent output yet", + "platforms": [ + "macos" + ], + "macos": { + "key": "No subagent output yet", + "arguments": [] + } + }, + { + "id": "native.noSubagentsYet", + "description": "Native UI: No subagents yet", + "platforms": [ + "macos" + ], + "macos": { + "key": "No subagents yet", + "arguments": [] + } + }, + { + "id": "native.noTasksYet", + "description": "Native UI: No tasks yet", + "platforms": [ + "macos" + ], + "macos": { + "key": "No tasks yet", + "arguments": [] + } + }, + { + "id": "native.noTextualDiffAvailable", + "description": "Native UI: No textual diff available", + "platforms": [ + "macos" + ], + "macos": { + "key": "No textual diff available", + "arguments": [] + } + }, + { + "id": "native.noTextualDifference", + "description": "Native UI: No textual difference", + "platforms": [ + "macos" + ], + "macos": { + "key": "No textual difference", + "arguments": [] + } + }, + { + "id": "native.noToolsRunning", + "description": "Native UI: No tools running", + "platforms": [ + "macos" + ], + "macos": { + "key": "No tools running", + "arguments": [] + } + }, + { + "id": "native.noUpdateCheckHasBeenPerformedYet", + "description": "Native UI: No update check has been performed yet.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No update check has been performed yet.", + "arguments": [] + } + }, + { + "id": "native.noUpdatePackageIsAvailableForThisMacArchitectureOpenTheReleasePageToCheckAvailableDownloads", + "description": "Native UI: No update package is available for this Mac architecture. Open the Release page to check available downloads.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No update package is available for this Mac architecture. Open the Release page to check available downloads.", + "arguments": [] + } + }, + { + "id": "native.noUpdatePackageIsAvailableForThisMacDownloadTheReleaseManually", + "description": "Native UI: No update package is available for this Mac. Download the release manually.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No update package is available for this Mac. Download the release manually.", + "arguments": [] + } + }, + { + "id": "native.noUpdatesFoundInTheLocalHomebrewIndex", + "description": "Native UI: No updates found in the local Homebrew index.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No updates found in the local Homebrew index.", + "arguments": [] + } + }, + { + "id": "native.noUpstreamConfigured", + "description": "Native UI: No upstream configured", + "platforms": [ + "macos" + ], + "macos": { + "key": "No upstream configured", + "arguments": [] + } + }, + { + "id": "native.noUsagesFound", + "description": "Native UI: No usages found", + "platforms": [ + "macos" + ], + "macos": { + "key": "No usages found", + "arguments": [] + } + }, + { + "id": "native.noWorktrees", + "description": "Native UI: No worktrees", + "platforms": [ + "macos" + ], + "macos": { + "key": "No worktrees", + "arguments": [] + } + }, + { + "id": "native.nodeJsLldOrLater", + "description": "Native UI: Node.js %lld or later", + "platforms": [ + "macos" + ], + "macos": { + "key": "Node.js %lld or later", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.nodeJsLldOrLaterIsRequiredFound", + "description": "Native UI: Node.js %lld or later is required; found %@.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Node.js %lld or later is required; found %@.", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "integer", + "arg2": "string" + } + }, + { + "id": "native.nodeJsLldOrLaterWasNotFoundInstallItThenCheckAgain", + "description": "Native UI: Node.js %lld or later was not found. Install it, then check again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Node.js %lld or later was not found. Install it, then check again.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.nodeJsSupport", + "description": "Native UI: Node.js Support", + "platforms": [ + "macos" + ], + "macos": { + "key": "Node.js Support", + "arguments": [] + } + }, + { + "id": "native.nonStreaming", + "description": "Native UI: Non-streaming", + "platforms": [ + "macos" + ], + "macos": { + "key": "Non-streaming", + "arguments": [] + } + }, + { + "id": "native.nonStreamingResponses", + "description": "Native UI: Non-streaming responses", + "platforms": [ + "macos" + ], + "macos": { + "key": "Non-streaming responses", + "arguments": [] + } + }, + { + "id": "native.none", + "description": "Native UI: None", + "platforms": [ + "macos" + ], + "macos": { + "key": "None", + "arguments": [] + } + }, + { + "id": "native.noneFastest", + "description": "Native UI: None (fastest)", + "platforms": [ + "macos" + ], + "macos": { + "key": "None (fastest)", + "arguments": [] + } + }, + { + "id": "native.normalFolder", + "description": "Native UI: Normal Folder", + "platforms": [ + "macos" + ], + "macos": { + "key": "Normal Folder", + "arguments": [] + } + }, + { + "id": "native.notAssigned", + "description": "Native UI: Not Assigned", + "platforms": [ + "macos" + ], + "macos": { + "key": "Not Assigned", + "arguments": [] + } + }, + { + "id": "native.notAvailableForThisLitheOrMac", + "description": "Native UI: %@ (not available for this Lithe or Mac)", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ (not available for this Lithe or Mac)", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.notAvailableYetDependenciesAreNotIndexed", + "description": "Native UI: Not available yet: dependencies are not indexed", + "platforms": [ + "macos" + ], + "macos": { + "key": "Not available yet: dependencies are not indexed", + "arguments": [] + } + }, + { + "id": "native.notFetchedYet", + "description": "Native UI: Not fetched yet.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Not fetched yet.", + "arguments": [] + } + }, + { + "id": "native.notFound", + "description": "Native UI: Not found", + "platforms": [ + "macos" + ], + "macos": { + "key": "Not found", + "arguments": [] + } + }, + { + "id": "native.notIncludedInTheUpdatedPlan", + "description": "Native UI: Not included in the updated plan", + "platforms": [ + "macos" + ], + "macos": { + "key": "Not included in the updated plan", + "arguments": [] + } + }, + { + "id": "native.notInstalled", + "description": "Native UI: Not installed", + "platforms": [ + "macos" + ], + "macos": { + "key": "Not installed", + "arguments": [] + } + }, + { + "id": "native.notInstalled.variant2", + "description": "Native UI: Not installed · %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Not installed · %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.notInstalledInstallItToStartUsingThisAgent", + "description": "Native UI: Not installed. Install it to start using this Agent.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Not installed. Install it to start using this Agent.", + "arguments": [] + } + }, + { + "id": "native.notRefreshedYet", + "description": "Native UI: Not refreshed yet", + "platforms": [ + "macos" + ], + "macos": { + "key": "Not refreshed yet", + "arguments": [] + } + }, + { + "id": "native.notRun", + "description": "Native UI: Not run", + "platforms": [ + "macos" + ], + "macos": { + "key": "Not run", + "arguments": [] + } + }, + { + "id": "native.notRunning", + "description": "Native UI: Not running", + "platforms": [ + "macos" + ], + "macos": { + "key": "Not running", + "arguments": [] + } + }, + { + "id": "native.notSelected", + "description": "Native UI: Not selected", + "platforms": [ + "macos" + ], + "macos": { + "key": "Not selected", + "arguments": [] + } + }, + { + "id": "native.notSet", + "description": "Native UI: Not set", + "platforms": [ + "macos" + ], + "macos": { + "key": "Not set", + "arguments": [] + } + }, + { + "id": "native.notSupported", + "description": "Native UI: Not Supported", + "platforms": [ + "macos" + ], + "macos": { + "key": "Not Supported", + "arguments": [] + } + }, + { + "id": "native.notSupportedOnYet", + "description": "Native UI: Not supported on %@ yet.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Not supported on %@ yet.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.notUsed", + "description": "Native UI: Not used", + "platforms": [ + "macos" + ], + "macos": { + "key": "Not used", + "arguments": [] + } + }, + { + "id": "native.notUsedYet", + "description": "Native UI: Not used yet.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Not used yet.", + "arguments": [] + } + }, + { + "id": "native.nothingToPush", + "description": "Native UI: Nothing to push", + "platforms": [ + "macos" + ], + "macos": { + "key": "Nothing to push", + "arguments": [] + } + }, + { + "id": "native.notifications", + "description": "Native UI: Notifications", + "platforms": [ + "macos" + ], + "macos": { + "key": "Notifications", + "arguments": [] + } + }, + { + "id": "native.nowFollowsYourLocalConfiguration", + "description": "Native UI: %@ now follows your local %@ configuration.", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ now follows your local %@ configuration.", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string" + } + }, + { + "id": "native.npmWasNotFoundItIsNormallyInstalledWithNodeJs", + "description": "Native UI: npm was not found. It is normally installed with Node.js.", + "platforms": [ + "macos" + ], + "macos": { + "key": "npm was not found. It is normally installed with Node.js.", + "arguments": [] + } + }, + { + "id": "native.ocamlSupport", + "description": "Native UI: OCaml Support", + "platforms": [ + "macos" + ], + "macos": { + "key": "OCaml Support", + "arguments": [] + } + }, + { + "id": "native.ofIsHandledBy", + "description": "Native UI: %@ of %@ is handled by %@.", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ of %@ is handled by %@.", + "arguments": [ + "arg1", + "arg2", + "arg3" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string", + "arg3": "string" + } + }, + { + "id": "native.off", + "description": "Native UI: Off", + "platforms": [ + "macos" + ], + "macos": { + "key": "Off", + "arguments": [] + } + }, + { + "id": "native.offForThisProject", + "description": "Native UI: Off for this project", + "platforms": [ + "macos" + ], + "macos": { + "key": "Off for this project", + "arguments": [] + } + }, + { + "id": "native.official", + "description": "Native UI: Official", + "platforms": [ + "macos" + ], + "macos": { + "key": "Official", + "arguments": [] + } + }, + { + "id": "native.olderCommitsAreNotListed", + "description": "Native UI: Older commits are not listed.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Older commits are not listed.", + "arguments": [] + } + }, + { + "id": "native.olderCommitsAreOutsideTheLoadedHistory", + "description": "Native UI: Older commits are outside the loaded history", + "platforms": [ + "macos" + ], + "macos": { + "key": "Older commits are outside the loaded history", + "arguments": [] + } + }, + { + "id": "native.on", + "description": "Native UI: On", + "platforms": [ + "macos" + ], + "macos": { + "key": "On", + "arguments": [] + } + }, + { + "id": "native.oneArgumentPerLineArgumentsKeepSpacesWithoutQuoting", + "description": "Native UI: One argument per line. Arguments keep spaces without quoting.", + "platforms": [ + "macos" + ], + "macos": { + "key": "One argument per line. Arguments keep spaces without quoting.", + "arguments": [] + } + }, + { + "id": "native.oneFile", + "description": "Native UI: One file: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "One file: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.oneNAMEValueEntryPerLine", + "description": "Native UI: One NAME=value entry per line", + "platforms": [ + "macos" + ], + "macos": { + "key": "One NAME=value entry per line", + "arguments": [] + } + }, + { + "id": "native.onePathPerLineRelativePathsAreResolvedFromTheWorkspace", + "description": "Native UI: One path per line. Relative paths are resolved from the workspace.", + "platforms": [ + "macos" + ], + "macos": { + "key": "One path per line. Relative paths are resolved from the workspace.", + "arguments": [] + } + }, + { + "id": "native.onePatternPerLineCaseInsensitivePatternsUseWildcardsASimplifiedFormOfRegularEx4f7ef7b38a", + "description": "Native UI: One pattern per line, case-insensitive. Patterns use wildcards, a simplified form of regular expressions: `*` matches any characters and `?` matches one.\nEnd a pattern with `/` or `/*` to hide a folder and everything in it; start with `**/` to match at any depth. Other patterns hide matching files.\nExamples:\n`**/dist/` — every dist folder\n`.lithe/*` — the .lithe folder in the project root\n`*.log` — all log files\n`docs/*.md` — Markdown files in docs\nThe built-in patterns below always apply.", + "platforms": [ + "macos" + ], + "macos": { + "key": "One pattern per line, case-insensitive. Patterns use wildcards, a simplified form of regular expressions: `*` matches any characters and `?` matches one.\nEnd a pattern with `/` or `/*` to hide a folder and everything in it; start with `**/` to match at any depth. Other patterns hide matching files.\nExamples:\n`**/dist/` — every dist folder\n`.lithe/*` — the .lithe folder in the project root\n`*.log` — all log files\n`docs/*.md` — Markdown files in docs\nThe built-in patterns below always apply.", + "arguments": [] + } + }, + { + "id": "native.onePatternPerLineLogCacheOnlyAffectsThisSearchEverywhereWindow", + "description": "Native UI: One pattern per line: `*.log`, `**/.*/`, `.*`, `cache/`. Only affects this Search Everywhere window.", + "platforms": [ + "macos" + ], + "macos": { + "key": "One pattern per line: `*.log`, `**/.*/`, `.*`, `cache/`. Only affects this Search Everywhere window.", + "arguments": [] + } + }, + { + "id": "native.oneSentenceFocusedOnTheMainChange", + "description": "Native UI: One sentence focused on the main change", + "platforms": [ + "macos" + ], + "macos": { + "key": "One sentence focused on the main change", + "arguments": [] + } + }, + { + "id": "native.oneTIMECODE", + "description": "Native UI: ONE-TIME CODE", + "platforms": [ + "macos" + ], + "macos": { + "key": "ONE-TIME CODE", + "arguments": [] + } + }, + { + "id": "native.oneTest", + "description": "Native UI: One test: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "One test: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.oneTimeChoicesOverrideRepositoryPreferencesWhichOverrideApplicationDefaultsGit1dff06f51d", + "description": "Native UI: One-time choices override repository preferences, which override application defaults. Git configuration is changed only when you save or clear a field below.", + "platforms": [ + "macos" + ], + "macos": { + "key": "One-time choices override repository preferences, which override application defaults. Git configuration is changed only when you save or clear a field below.", + "arguments": [] + } + }, + { + "id": "native.onlyAPIKeysAreUsedTheAgentSOwnAccountSignInSuchAsAChatGPTLoginIsNeverUsed", + "description": "Native UI: Only API keys are used. The Agent's own account sign-in, such as a ChatGPT login, is never used.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Only API keys are used. The Agent's own account sign-in, such as a ChatGPT login, is never used.", + "arguments": [] + } + }, + { + "id": "native.onlyAskForActionsDetectedAsPotentiallyUnsafe", + "description": "Native UI: Only ask for actions detected as potentially unsafe", + "platforms": [ + "macos" + ], + "macos": { + "key": "Only ask for actions detected as potentially unsafe", + "arguments": [] + } + }, + { + "id": "native.onlyLocalFilesCanBeAttachedToAnAgentMessage", + "description": "Native UI: Only local files can be attached to an Agent message.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Only local files can be attached to an Agent message.", + "arguments": [] + } + }, + { + "id": "native.onlySourceFilesOfTheCurrentLanguageCanBeOpenedFromOutsideTheWorkspace", + "description": "Native UI: Only source files of the current language can be opened from outside the workspace", + "platforms": [ + "macos" + ], + "macos": { + "key": "Only source files of the current language can be opened from outside the workspace", + "arguments": [] + } + }, + { + "id": "native.onlyTheAPIURLModelAndAPIKeyAreSavedOtherCLIOptionsContinueToUseYourLocalConfig36d9d4cdfd", + "description": "Native UI: Only the API URL, model and API key are saved. Other CLI options continue to use your local configuration. The key is stored separately from settings. Editing shows a template of these saved fields.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Only the API URL, model and API key are saved. Other CLI options continue to use your local configuration. The key is stored separately from settings. Editing shows a template of these saved fields.", + "arguments": [] + } + }, + { + "id": "native.onlyTheCommitMessageChangesLldCommitsWillReceiveNewIdentitiesStagedFilesAreNotIncluded", + "description": "Native UI: Only the commit message changes. %lld commits will receive new identities. Staged files are not included.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Only the commit message changes. %lld commits will receive new identities. Staged files are not included.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.onlyTheCurrentBranchCanBeUpdated", + "description": "Native UI: Only the current branch can be updated", + "platforms": [ + "macos" + ], + "macos": { + "key": "Only the current branch can be updated", + "arguments": [] + } + }, + { + "id": "native.onlyTheFilesBelowAreCollectedCredentialsTokensAndHomeDirectoryPathsAreRedacted1a2820206c", + "description": "Native UI: Only the files below are collected. Credentials, tokens, and home-directory paths are redacted automatically; workspace source, editor buffers, and terminal history are never included.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Only the files below are collected. Credentials, tokens, and home-directory paths are redacted automatically; workspace source, editor buffers, and terminal history are never included.", + "arguments": [] + } + }, + { + "id": "native.onlyTheMostRecentArchivedConversationsAreShown", + "description": "Native UI: Only the most recent archived conversations are shown.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Only the most recent archived conversations are shown.", + "arguments": [] + } + }, + { + "id": "native.onlyTheReportedAgentEditsWillBeReversedConflictingChangesAndUnsavedEditorChangesWillBePreserved", + "description": "Native UI: Only the reported Agent edits will be reversed. Conflicting changes and unsaved editor changes will be preserved.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Only the reported Agent edits will be reversed. Conflicting changes and unsaved editor changes will be preserved.", + "arguments": [] + } + }, + { + "id": "native.open", + "description": "Native UI: Open %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.openAGitProjectBeforeUsingPullRequests", + "description": "Native UI: Open a Git project before using pull requests", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open a Git project before using pull requests", + "arguments": [] + } + }, + { + "id": "native.openAICommitSettings", + "description": "Native UI: Open AI & Commit settings", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open AI & Commit settings", + "arguments": [] + } + }, + { + "id": "native.openAJavaFile", + "description": "Native UI: Open a Java file", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open a Java file", + "arguments": [] + } + }, + { + "id": "native.openAJavaFileBeforeStartingDebug", + "description": "Native UI: Open a Java file before starting Debug", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open a Java file before starting Debug", + "arguments": [] + } + }, + { + "id": "native.openALocalFolderToStartWorking", + "description": "Native UI: Open a local folder to start working.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open a local folder to start working.", + "arguments": [] + } + }, + { + "id": "native.openALocalProjectFolder", + "description": "Native UI: Open a local project folder", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open a local project folder", + "arguments": [] + } + }, + { + "id": "native.openAProjectBeforeChoosingProjectPaths", + "description": "Native UI: Open a project before choosing project paths.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open a project before choosing project paths.", + "arguments": [] + } + }, + { + "id": "native.openAProjectContainingAPomXmlFile", + "description": "Native UI: Open a project containing a pom.xml file.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open a project containing a pom.xml file.", + "arguments": [] + } + }, + { + "id": "native.openAProjectToConfigureGitIdentity", + "description": "Native UI: Open a project to configure Git identity.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open a project to configure Git identity.", + "arguments": [] + } + }, + { + "id": "native.openAProjectToConfigureItsJDKAndMavenRuntime", + "description": "Native UI: Open a project to configure its JDK and Maven runtime.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open a project to configure its JDK and Maven runtime.", + "arguments": [] + } + }, + { + "id": "native.openAProjectToConfigureItsRunConfigurations", + "description": "Native UI: Open a project to configure its run configurations.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open a project to configure its run configurations.", + "arguments": [] + } + }, + { + "id": "native.openAProjectToConfigureMCPAccessForAgents", + "description": "Native UI: Open a project to configure MCP access for agents.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open a project to configure MCP access for agents.", + "arguments": [] + } + }, + { + "id": "native.openAProjectToLetConnectedAgentsInspectAndControlIt", + "description": "Native UI: Open a project to let connected agents inspect and control it.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open a project to let connected agents inspect and control it.", + "arguments": [] + } + }, + { + "id": "native.openAProjectToSeeItsLanguageService", + "description": "Native UI: Open a project to see its language service.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open a project to see its language service.", + "arguments": [] + } + }, + { + "id": "native.openAProjectToUseGit", + "description": "Native UI: Open a project to use Git", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open a project to use Git", + "arguments": [] + } + }, + { + "id": "native.openAgain", + "description": "Native UI: Open again", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open again", + "arguments": [] + } + }, + { + "id": "native.openAgentSettings", + "description": "Native UI: Open Agent Settings", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open Agent Settings", + "arguments": [] + } + }, + { + "id": "native.openAutoBuildDownloads", + "description": "Native UI: Open AutoBuild Downloads", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open AutoBuild Downloads", + "arguments": [] + } + }, + { + "id": "native.openChanges", + "description": "Native UI: Open Changes", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open Changes", + "arguments": [] + } + }, + { + "id": "native.openConfiguration", + "description": "Native UI: Open Configuration", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open Configuration", + "arguments": [] + } + }, + { + "id": "native.openFile", + "description": "Native UI: Open file", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open file", + "arguments": [] + } + }, + { + "id": "native.openFileInEditorToSeeProblems", + "description": "Native UI: Open file in editor to see problems", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open file in editor to see problems", + "arguments": [] + } + }, + { + "id": "native.openGitBranches", + "description": "Native UI: Open Git branches", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open Git branches", + "arguments": [] + } + }, + { + "id": "native.openGitConsole", + "description": "Native UI: Open Git Console", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open Git Console", + "arguments": [] + } + }, + { + "id": "native.openGitHub", + "description": "Native UI: Open GitHub", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open GitHub", + "arguments": [] + } + }, + { + "id": "native.openGitHubProfile", + "description": "Native UI: Open GitHub profile", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open GitHub profile", + "arguments": [] + } + }, + { + "id": "native.openGitSettings", + "description": "Native UI: Open Git Settings", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open Git Settings", + "arguments": [] + } + }, + { + "id": "native.openHTMLInBrowser", + "description": "Native UI: Open HTML in Browser", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open HTML in Browser", + "arguments": [] + } + }, + { + "id": "native.openHistoryForTheActiveFile", + "description": "Native UI: Open history for the active file", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open history for the active file", + "arguments": [] + } + }, + { + "id": "native.openInCurrentWindow", + "description": "Native UI: Open in Current Window", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open in Current Window", + "arguments": [] + } + }, + { + "id": "native.openInEditor", + "description": "Native UI: Open in Editor", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open in Editor", + "arguments": [] + } + }, + { + "id": "native.openInLithe", + "description": "Native UI: Open in Lithe", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open in Lithe", + "arguments": [] + } + }, + { + "id": "native.openInNewWindow", + "description": "Native UI: Open in New Window", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open in New Window", + "arguments": [] + } + }, + { + "id": "native.openInRightSplit", + "description": "Native UI: Open in Right Split", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open in Right Split", + "arguments": [] + } + }, + { + "id": "native.openLogDirectory", + "description": "Native UI: Open Log Directory", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open Log Directory", + "arguments": [] + } + }, + { + "id": "native.openModulePomXml", + "description": "Native UI: Open module pom.xml", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open module pom.xml", + "arguments": [] + } + }, + { + "id": "native.openNotificationHistory", + "description": "Native UI: Open notification history", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open notification history", + "arguments": [] + } + }, + { + "id": "native.openPatch", + "description": "Native UI: Open Patch…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open Patch…", + "arguments": [] + } + }, + { + "id": "native.openPluginManagement", + "description": "Native UI: Open Plugin Management", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open Plugin Management", + "arguments": [] + } + }, + { + "id": "native.openPomXml", + "description": "Native UI: Open pom.xml", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open pom.xml", + "arguments": [] + } + }, + { + "id": "native.openProject", + "description": "Native UI: Open Project", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open Project", + "arguments": [] + } + }, + { + "id": "native.openProject.variant2", + "description": "Native UI: Open Project…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open Project…", + "arguments": [] + } + }, + { + "id": "native.openProjectWideLocalHistory", + "description": "Native UI: Open project-wide local history", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open project-wide local history", + "arguments": [] + } + }, + { + "id": "native.openProjects", + "description": "Native UI: Open Projects", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open Projects", + "arguments": [] + } + }, + { + "id": "native.openProjectsIn", + "description": "Native UI: Open projects in", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open projects in", + "arguments": [] + } + }, + { + "id": "native.openReleasePage", + "description": "Native UI: Open Release Page", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open Release Page", + "arguments": [] + } + }, + { + "id": "native.openRemote", + "description": "Native UI: Open Remote ↗", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open Remote ↗", + "arguments": [] + } + }, + { + "id": "native.openRemote.variant2", + "description": "Native UI: Open remote", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open remote", + "arguments": [] + } + }, + { + "id": "native.openStableDownloads", + "description": "Native UI: Open Stable Downloads", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open Stable Downloads", + "arguments": [] + } + }, + { + "id": "native.openToInspect", + "description": "Native UI: Open to inspect", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open to inspect", + "arguments": [] + } + }, + { + "id": "native.openingItMovesTheConversationOutOfSArchiveAndBackIntoItsHistoryBrowsingArchive50e980deac", + "description": "Native UI: Opening it moves the conversation out of %@'s archive and back into its history. Browsing archived conversations never changes them.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Opening it moves the conversation out of %@'s archive and back into its history. Browsing archived conversations never changes them.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.optionalApprovalSummary", + "description": "Native UI: Optional approval summary", + "platforms": [ + "macos" + ], + "macos": { + "key": "Optional approval summary", + "arguments": [] + } + }, + { + "id": "native.options", + "description": "Native UI: %@ options", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ options", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.options.variant2", + "description": "Native UI: Options", + "platforms": [ + "macos" + ], + "macos": { + "key": "Options", + "arguments": [] + } + }, + { + "id": "native.or", + "description": "Native UI: or", + "platforms": [ + "macos" + ], + "macos": { + "key": "or", + "arguments": [] + } + }, + { + "id": "native.orLater", + "description": "Native UI: %@ %@ or later", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ %@ or later", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string" + } + }, + { + "id": "native.orLaterIsRequiredFoundUpdateItThenCheckAgain", + "description": "Native UI: %@ %@ or later is required; found %@. Update it, then check again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ %@ or later is required; found %@. Update it, then check again.", + "arguments": [ + "arg1", + "arg2", + "arg3" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string", + "arg3": "string" + } + }, + { + "id": "native.originalContentIsMissingOrIncomplete", + "description": "Native UI: Original content is missing or incomplete.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Original content is missing or incomplete.", + "arguments": [] + } + }, + { + "id": "native.originalHEADCreateABranchFromTheRecoveryReferenceToInspectTheOriginalHistory", + "description": "Native UI: Original HEAD: %@. Create a branch from the recovery reference to inspect the original history.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Original HEAD: %@. Create a branch from the recovery reference to inspect the original history.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.other", + "description": "Native UI: Other", + "platforms": [ + "macos" + ], + "macos": { + "key": "Other", + "arguments": [] + } + }, + { + "id": "native.otherChangeListsHaveStagedFilesUnstageThemBeforeCommittingTheCurrentChangeList", + "description": "Native UI: Other ChangeLists have staged files. Unstage them before committing the current ChangeList.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Other ChangeLists have staged files. Unstage them before committing the current ChangeList.", + "arguments": [] + } + }, + { + "id": "native.otherLldBranches", + "description": "Native UI: Other %lld branches", + "platforms": [ + "macos" + ], + "macos": { + "key": "Other %lld branches", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.otherLldCommits", + "description": "Native UI: Other %lld commits", + "platforms": [ + "macos" + ], + "macos": { + "key": "Other %lld commits", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.otherLldFiles", + "description": "Native UI: Other %lld files", + "platforms": [ + "macos" + ], + "macos": { + "key": "Other %lld files", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.otherLldTags", + "description": "Native UI: Other %lld tags", + "platforms": [ + "macos" + ], + "macos": { + "key": "Other %lld tags", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.otherReferenceChangesLldAddedLldUpdatedLldDeleted", + "description": "Native UI: Other reference changes: %lld added, %lld updated, %lld deleted", + "platforms": [ + "macos" + ], + "macos": { + "key": "Other reference changes: %lld added, %lld updated, %lld deleted", + "arguments": [ + "arg1", + "arg2", + "arg3" + ] + }, + "parameters": { + "arg1": "integer", + "arg2": "integer", + "arg3": "integer" + } + }, + { + "id": "native.otherRunConfigurations", + "description": "Native UI: Other run configurations", + "platforms": [ + "macos" + ], + "macos": { + "key": "Other run configurations", + "arguments": [] + } + }, + { + "id": "native.output", + "description": "Native UI: Output", + "platforms": [ + "macos" + ], + "macos": { + "key": "Output", + "arguments": [] + } + }, + { + "id": "native.output.variant2", + "description": "Native UI: Output: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Output: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.outputLanguage", + "description": "Native UI: Output language", + "platforms": [ + "macos" + ], + "macos": { + "key": "Output language", + "arguments": [] + } + }, + { + "id": "native.outputSpeedTPS", + "description": "Native UI: Output speed (TPS)", + "platforms": [ + "macos" + ], + "macos": { + "key": "Output speed (TPS)", + "arguments": [] + } + }, + { + "id": "native.overriddenByALaterValue", + "description": "Native UI: Overridden by a later value", + "platforms": [ + "macos" + ], + "macos": { + "key": "Overridden by a later value", + "arguments": [] + } + }, + { + "id": "native.overriddenInThisProject", + "description": "Native UI: Overridden in this project", + "platforms": [ + "macos" + ], + "macos": { + "key": "Overridden in this project", + "arguments": [] + } + }, + { + "id": "native.override", + "description": "Native UI: Override", + "platforms": [ + "macos" + ], + "macos": { + "key": "Override", + "arguments": [] + } + }, + { + "id": "native.overrideAGitSetting", + "description": "Native UI: Override a Git setting", + "platforms": [ + "macos" + ], + "macos": { + "key": "Override a Git setting", + "arguments": [] + } + }, + { + "id": "native.overview", + "description": "Native UI: Overview", + "platforms": [ + "macos" + ], + "macos": { + "key": "Overview", + "arguments": [] + } + }, + { + "id": "native.package", + "description": "Native UI: Package", + "platforms": [ + "macos" + ], + "macos": { + "key": "Package", + "arguments": [] + } + }, + { + "id": "native.packageTheRedactedApplicationLogWithAnEnvironmentAndPerformanceSnapshotIntoAZi3d8894ab31", + "description": "Native UI: Package the redacted application log with an environment and performance snapshot into a zip you can attach to a bug report. Credentials, tokens, and home-directory paths are removed automatically, and you can review the file list before anything is written.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Package the redacted application log with an environment and performance snapshot into a zip you can attach to a bug report. Credentials, tokens, and home-directory paths are removed automatically, and you can review the file list before anything is written.", + "arguments": [] + } + }, + { + "id": "native.pageZoom", + "description": "Native UI: Page Zoom", + "platforms": [ + "macos" + ], + "macos": { + "key": "Page Zoom", + "arguments": [] + } + }, + { + "id": "native.parentVersion", + "description": "Native UI: Parent version", + "platforms": [ + "macos" + ], + "macos": { + "key": "Parent version", + "arguments": [] + } + }, + { + "id": "native.partOfLitheItStartsNoProgramsOfItsOwn", + "description": "Native UI: Part of Lithe. It starts no programs of its own.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Part of Lithe. It starts no programs of its own.", + "arguments": [] + } + }, + { + "id": "native.partiallySelected", + "description": "Native UI: Partially selected", + "platforms": [ + "macos" + ], + "macos": { + "key": "Partially selected", + "arguments": [] + } + }, + { + "id": "native.passed", + "description": "Native UI: Passed", + "platforms": [ + "macos" + ], + "macos": { + "key": "Passed", + "arguments": [] + } + }, + { + "id": "native.passwordOrPassphrase", + "description": "Native UI: Password or passphrase", + "platforms": [ + "macos" + ], + "macos": { + "key": "Password or passphrase", + "arguments": [] + } + }, + { + "id": "native.pasteAnHttpsPluginIndexURLOrAGitHubRepositoryURLAGitHubRepositoryMustContainLi7616c31790", + "description": "Native UI: Paste an https plugin index URL or a GitHub repository URL. A GitHub repository must contain lithe-plugin-index.json at its root.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Paste an https plugin index URL or a GitHub repository URL. A GitHub repository must contain lithe-plugin-index.json at its root.", + "arguments": [] + } + }, + { + "id": "native.pastePatch", + "description": "Native UI: Paste Patch", + "platforms": [ + "macos" + ], + "macos": { + "key": "Paste Patch", + "arguments": [] + } + }, + { + "id": "native.pasteThisIntoYourMCPClientConfiguration", + "description": "Native UI: Paste this into your MCP client configuration.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Paste this into your MCP client configuration.", + "arguments": [] + } + }, + { + "id": "native.pastedFiles", + "description": "Native UI: Pasted files", + "platforms": [ + "macos" + ], + "macos": { + "key": "Pasted files", + "arguments": [] + } + }, + { + "id": "native.patchCanBeAppliedToTheSelectedDestination", + "description": "Native UI: Patch can be applied to the selected destination.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Patch can be applied to the selected destination.", + "arguments": [] + } + }, + { + "id": "native.patchCannotBeApplied", + "description": "Native UI: Patch cannot be applied.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Patch cannot be applied.", + "arguments": [] + } + }, + { + "id": "native.patchPreview", + "description": "Native UI: Patch preview", + "platforms": [ + "macos" + ], + "macos": { + "key": "Patch preview", + "arguments": [] + } + }, + { + "id": "native.patchesMustBeAtMost32MiB", + "description": "Native UI: Patches must be at most 32 MiB.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Patches must be at most 32 MiB.", + "arguments": [] + } + }, + { + "id": "native.path", + "description": "Native UI: Path", + "platforms": [ + "macos" + ], + "macos": { + "key": "Path", + "arguments": [] + } + }, + { + "id": "native.pathMissing", + "description": "Native UI: Path Missing", + "platforms": [ + "macos" + ], + "macos": { + "key": "Path Missing", + "arguments": [] + } + }, + { + "id": "native.paths", + "description": "Native UI: Paths", + "platforms": [ + "macos" + ], + "macos": { + "key": "Paths", + "arguments": [] + } + }, + { + "id": "native.pause", + "description": "Native UI: Pause", + "platforms": [ + "macos" + ], + "macos": { + "key": "Pause", + "arguments": [] + } + }, + { + "id": "native.pauseTheProcessToInspectVariables", + "description": "Native UI: Pause the process to inspect variables", + "platforms": [ + "macos" + ], + "macos": { + "key": "Pause the process to inspect variables", + "arguments": [] + } + }, + { + "id": "native.paused", + "description": "Native UI: Paused", + "platforms": [ + "macos" + ], + "macos": { + "key": "Paused", + "arguments": [] + } + }, + { + "id": "native.peakThisRun", + "description": "Native UI: Peak this run", + "platforms": [ + "macos" + ], + "macos": { + "key": "Peak this run", + "arguments": [] + } + }, + { + "id": "native.peakTotal", + "description": "Native UI: Peak total", + "platforms": [ + "macos" + ], + "macos": { + "key": "Peak total", + "arguments": [] + } + }, + { + "id": "native.pending", + "description": "Native UI: Pending", + "platforms": [ + "macos" + ], + "macos": { + "key": "Pending", + "arguments": [] + } + }, + { + "id": "native.pendingConfirmation", + "description": "Native UI: Pending confirmation", + "platforms": [ + "macos" + ], + "macos": { + "key": "Pending confirmation", + "arguments": [] + } + }, + { + "id": "native.pendingPluginChangesLld", + "description": "Native UI: Pending plugin changes: %lld", + "platforms": [ + "macos" + ], + "macos": { + "key": "Pending plugin changes: %lld", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.pendingVerification", + "description": "Native UI: Pending verification", + "platforms": [ + "macos" + ], + "macos": { + "key": "Pending verification", + "arguments": [] + } + }, + { + "id": "native.performance", + "description": "Native UI: Performance", + "platforms": [ + "macos" + ], + "macos": { + "key": "Performance", + "arguments": [] + } + }, + { + "id": "native.performanceIsAvailableForLocalJavaProcessesLaunchedByLithe", + "description": "Native UI: Performance is available for local Java processes launched by Lithe.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Performance is available for local Java processes launched by Lithe.", + "arguments": [] + } + }, + { + "id": "native.performanceResults", + "description": "Native UI: Performance Results", + "platforms": [ + "macos" + ], + "macos": { + "key": "Performance Results", + "arguments": [] + } + }, + { + "id": "native.perlSupport", + "description": "Native UI: Perl Support", + "platforms": [ + "macos" + ], + "macos": { + "key": "Perl Support", + "arguments": [] + } + }, + { + "id": "native.permissionRequired", + "description": "Native UI: Permission required", + "platforms": [ + "macos" + ], + "macos": { + "key": "Permission required", + "arguments": [] + } + }, + { + "id": "native.permissions", + "description": "Native UI: Permissions", + "platforms": [ + "macos" + ], + "macos": { + "key": "Permissions", + "arguments": [] + } + }, + { + "id": "native.permissionsPrograms", + "description": "Native UI: Permissions & Programs", + "platforms": [ + "macos" + ], + "macos": { + "key": "Permissions & Programs", + "arguments": [] + } + }, + { + "id": "native.phpLanguageSupportWillBeRemovedAfterRestartingLithe", + "description": "Native UI: PHP language support will be removed after restarting Lithe.", + "platforms": [ + "macos" + ], + "macos": { + "key": "PHP language support will be removed after restarting Lithe.", + "arguments": [] + } + }, + { + "id": "native.phpSupport", + "description": "Native UI: PHP Support", + "platforms": [ + "macos" + ], + "macos": { + "key": "PHP Support", + "arguments": [] + } + }, + { + "id": "native.pick", + "description": "Native UI: Pick", + "platforms": [ + "macos" + ], + "macos": { + "key": "Pick", + "arguments": [] + } + }, + { + "id": "native.pickPreservesTheCommitEditPausesSoYouCanAmendStagedChangesSquashCombinesMessag9cb6d5afdd", + "description": "Native UI: Pick preserves the commit. Edit pauses so you can amend staged changes. Squash combines messages; Fixup keeps the preceding message. Drop removes the commit.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Pick preserves the commit. Edit pauses so you can amend staged changes. Squash combines messages; Fixup keeps the preceding message. Drop removes the commit.", + "arguments": [] + } + }, + { + "id": "native.pinConfiguration", + "description": "Native UI: Pin configuration", + "platforms": [ + "macos" + ], + "macos": { + "key": "Pin configuration", + "arguments": [] + } + }, + { + "id": "native.pinned", + "description": "Native UI: Pinned", + "platforms": [ + "macos" + ], + "macos": { + "key": "Pinned", + "arguments": [] + } + }, + { + "id": "native.pinnedLocal", + "description": "Native UI: Pinned: %@ · Local: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Pinned: %@ · Local: %@", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string" + } + }, + { + "id": "native.placeTheCaretInAJavaFileToSetABreakpoint", + "description": "Native UI: Place the caret in a Java file to set a breakpoint", + "platforms": [ + "macos" + ], + "macos": { + "key": "Place the caret in a Java file to set a breakpoint", + "arguments": [] + } + }, + { + "id": "native.placeTheCaretOnAJavaSymbolFirst", + "description": "Native UI: Place the caret on a Java symbol first", + "platforms": [ + "macos" + ], + "macos": { + "key": "Place the caret on a Java symbol first", + "arguments": [] + } + }, + { + "id": "native.plan", + "description": "Native UI: Plan", + "platforms": [ + "macos" + ], + "macos": { + "key": "Plan", + "arguments": [] + } + }, + { + "id": "native.planBeforeMakingChanges", + "description": "Native UI: Plan before making changes", + "platforms": [ + "macos" + ], + "macos": { + "key": "Plan before making changes", + "arguments": [] + } + }, + { + "id": "native.planLldLld", + "description": "Native UI: Plan %lld/%lld", + "platforms": [ + "macos" + ], + "macos": { + "key": "Plan %lld/%lld", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "integer", + "arg2": "integer" + } + }, + { + "id": "native.plannedCommand", + "description": "Native UI: Planned command", + "platforms": [ + "macos" + ], + "macos": { + "key": "Planned command", + "arguments": [] + } + }, + { + "id": "native.plannedGitCommandWaitingToStart", + "description": "Native UI: Planned Git command — waiting to start", + "platforms": [ + "macos" + ], + "macos": { + "key": "Planned Git command — waiting to start", + "arguments": [] + } + }, + { + "id": "native.platforms", + "description": "Native UI: Platforms", + "platforms": [ + "macos" + ], + "macos": { + "key": "Platforms", + "arguments": [] + } + }, + { + "id": "native.pluginChangesTakeEffectAfterLitheRestarts", + "description": "Native UI: Plugin changes take effect after Lithe restarts.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Plugin changes take effect after Lithe restarts.", + "arguments": [] + } + }, + { + "id": "native.pluginDetails", + "description": "Native UI: Plugin Details", + "platforms": [ + "macos" + ], + "macos": { + "key": "Plugin Details", + "arguments": [] + } + }, + { + "id": "native.pluginDisabled", + "description": "Native UI: Plugin disabled", + "platforms": [ + "macos" + ], + "macos": { + "key": "Plugin disabled", + "arguments": [] + } + }, + { + "id": "native.pluginID", + "description": "Native UI: Plugin ID", + "platforms": [ + "macos" + ], + "macos": { + "key": "Plugin ID", + "arguments": [] + } + }, + { + "id": "native.pluginIDItCanKeepUsingTheDeviceUntilYouCloseOrHideItsWindow", + "description": "Native UI: Plugin ID: %@. It can keep using the device until you close or hide its window.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Plugin ID: %@. It can keep using the device until you close or hide its window.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.pluginMarketplaceIsNotAvailable", + "description": "Native UI: Plugin marketplace is not available", + "platforms": [ + "macos" + ], + "macos": { + "key": "Plugin marketplace is not available", + "arguments": [] + } + }, + { + "id": "native.pluginRepositories", + "description": "Native UI: Plugin Repositories", + "platforms": [ + "macos" + ], + "macos": { + "key": "Plugin Repositories", + "arguments": [] + } + }, + { + "id": "native.pluginRepository", + "description": "Native UI: Plugin repository", + "platforms": [ + "macos" + ], + "macos": { + "key": "Plugin repository", + "arguments": [] + } + }, + { + "id": "native.pluginSettings", + "description": "Native UI: Plugin settings", + "platforms": [ + "macos" + ], + "macos": { + "key": "Plugin settings", + "arguments": [] + } + }, + { + "id": "native.pluginSource", + "description": "Native UI: Plugin Source", + "platforms": [ + "macos" + ], + "macos": { + "key": "Plugin Source", + "arguments": [] + } + }, + { + "id": "native.plugins", + "description": "Native UI: Plugins…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Plugins…", + "arguments": [] + } + }, + { + "id": "native.pop", + "description": "Native UI: Pop", + "platforms": [ + "macos" + ], + "macos": { + "key": "Pop", + "arguments": [] + } + }, + { + "id": "native.ports", + "description": "Native UI: ports %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "ports %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.postingComment", + "description": "Native UI: Posting comment…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Posting comment…", + "arguments": [] + } + }, + { + "id": "native.powershellSupport", + "description": "Native UI: PowerShell Support", + "platforms": [ + "macos" + ], + "macos": { + "key": "PowerShell Support", + "arguments": [] + } + }, + { + "id": "native.preflight", + "description": "Native UI: Preflight", + "platforms": [ + "macos" + ], + "macos": { + "key": "Preflight", + "arguments": [] + } + }, + { + "id": "native.preparingConversation", + "description": "Native UI: Preparing conversation…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Preparing conversation…", + "arguments": [] + } + }, + { + "id": "native.preparingDownload", + "description": "Native UI: Preparing download…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Preparing download…", + "arguments": [] + } + }, + { + "id": "native.preservesEveryCommitAndAddsAMergeCommitToTheBaseBranch", + "description": "Native UI: Preserves every commit and adds a merge commit to the base branch.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Preserves every commit and adds a merge commit to the base branch.", + "arguments": [] + } + }, + { + "id": "native.pressShortcut", + "description": "Native UI: Press shortcut…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Press shortcut…", + "arguments": [] + } + }, + { + "id": "native.previewAndExport", + "description": "Native UI: Preview and Export…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Preview and Export…", + "arguments": [] + } + }, + { + "id": "native.previewAndExportTheSelectedLogsWithEnvironmentInformationCredentialsAndHomeDir65f650979b", + "description": "Native UI: Preview and export the selected logs with environment information. Credentials and home-directory paths are redacted; source code, editor buffers and terminal history are excluded.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Preview and export the selected logs with environment information. Credentials and home-directory paths are redacted; source code, editor buffers and terminal history are excluded.", + "arguments": [] + } + }, + { + "id": "native.previewBackgroundImage", + "description": "Native UI: Preview background image", + "platforms": [ + "macos" + ], + "macos": { + "key": "Preview background image", + "arguments": [] + } + }, + { + "id": "native.previewFirstChange", + "description": "Native UI: Preview first change", + "platforms": [ + "macos" + ], + "macos": { + "key": "Preview first change", + "arguments": [] + } + }, + { + "id": "native.previewShowsTheFirst65536CharactersTheCompletePatchIsUsedWhenSavingOrApplying", + "description": "Native UI: Preview shows the first 65,536 characters. The complete patch is used when saving or applying.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Preview shows the first 65,536 characters. The complete patch is used when saving or applying.", + "arguments": [] + } + }, + { + "id": "native.previewUpdate", + "description": "Native UI: Preview Update", + "platforms": [ + "macos" + ], + "macos": { + "key": "Preview Update", + "arguments": [] + } + }, + { + "id": "native.previousConfigurationBackedUpTo", + "description": "Native UI: Previous configuration backed up to", + "platforms": [ + "macos" + ], + "macos": { + "key": "Previous configuration backed up to", + "arguments": [] + } + }, + { + "id": "native.previousConsoleExpression", + "description": "Native UI: Previous console expression", + "platforms": [ + "macos" + ], + "macos": { + "key": "Previous console expression", + "arguments": [] + } + }, + { + "id": "native.previousDiffMatch", + "description": "Native UI: Previous diff match", + "platforms": [ + "macos" + ], + "macos": { + "key": "Previous diff match", + "arguments": [] + } + }, + { + "id": "native.previousDifference", + "description": "Native UI: Previous difference", + "platforms": [ + "macos" + ], + "macos": { + "key": "Previous difference", + "arguments": [] + } + }, + { + "id": "native.previousFile", + "description": "Native UI: Previous File", + "platforms": [ + "macos" + ], + "macos": { + "key": "Previous File", + "arguments": [] + } + }, + { + "id": "native.previousMatch", + "description": "Native UI: Previous Match", + "platforms": [ + "macos" + ], + "macos": { + "key": "Previous Match", + "arguments": [] + } + }, + { + "id": "native.previousMatch.variant2", + "description": "Native UI: Previous match", + "platforms": [ + "macos" + ], + "macos": { + "key": "Previous match", + "arguments": [] + } + }, + { + "id": "native.previousMatchShiftReturn", + "description": "Native UI: Previous match (Shift+Return)", + "platforms": [ + "macos" + ], + "macos": { + "key": "Previous match (Shift+Return)", + "arguments": [] + } + }, + { + "id": "native.previousSession", + "description": "Native UI: Previous session", + "platforms": [ + "macos" + ], + "macos": { + "key": "Previous session", + "arguments": [] + } + }, + { + "id": "native.previousVersion", + "description": "Native UI: Previous Version", + "platforms": [ + "macos" + ], + "macos": { + "key": "Previous Version", + "arguments": [] + } + }, + { + "id": "native.primary", + "description": "Native UI: Primary", + "platforms": [ + "macos" + ], + "macos": { + "key": "Primary", + "arguments": [] + } + }, + { + "id": "native.primaryConfiguration", + "description": "Native UI: Primary configuration", + "platforms": [ + "macos" + ], + "macos": { + "key": "Primary configuration", + "arguments": [] + } + }, + { + "id": "native.primaryWorktree", + "description": "Native UI: Primary worktree", + "platforms": [ + "macos" + ], + "macos": { + "key": "Primary worktree", + "arguments": [] + } + }, + { + "id": "native.print", + "description": "Native UI: Print…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Print…", + "arguments": [] + } + }, + { + "id": "native.prismaSupport", + "description": "Native UI: Prisma Support", + "platforms": [ + "macos" + ], + "macos": { + "key": "Prisma Support", + "arguments": [] + } + }, + { + "id": "native.problems", + "description": "Native UI: Problems", + "platforms": [ + "macos" + ], + "macos": { + "key": "Problems", + "arguments": [] + } + }, + { + "id": "native.problemsCouldBeFilteredOut", + "description": "Native UI: Problems could be filtered out", + "platforms": [ + "macos" + ], + "macos": { + "key": "Problems could be filtered out", + "arguments": [] + } + }, + { + "id": "native.process", + "description": "Native UI: Process", + "platforms": [ + "macos" + ], + "macos": { + "key": "Process", + "arguments": [] + } + }, + { + "id": "native.processOutput", + "description": "Native UI: Process output", + "platforms": [ + "macos" + ], + "macos": { + "key": "Process output", + "arguments": [] + } + }, + { + "id": "native.processOutputWillAppearHere", + "description": "Native UI: Process output will appear here.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Process output will appear here.", + "arguments": [] + } + }, + { + "id": "native.processTerminated", + "description": "Native UI: Process Terminated", + "platforms": [ + "macos" + ], + "macos": { + "key": "Process Terminated", + "arguments": [] + } + }, + { + "id": "native.processTerminationPolicy", + "description": "Native UI: Process Termination Policy", + "platforms": [ + "macos" + ], + "macos": { + "key": "Process Termination Policy", + "arguments": [] + } + }, + { + "id": "native.procfile", + "description": "Native UI: Procfile", + "platforms": [ + "macos" + ], + "macos": { + "key": "Procfile", + "arguments": [] + } + }, + { + "id": "native.profile", + "description": "Native UI: Profile", + "platforms": [ + "macos" + ], + "macos": { + "key": "Profile", + "arguments": [] + } + }, + { + "id": "native.profiles", + "description": "Native UI: Profiles", + "platforms": [ + "macos" + ], + "macos": { + "key": "Profiles", + "arguments": [] + } + }, + { + "id": "native.program", + "description": "Native UI: Program", + "platforms": [ + "macos" + ], + "macos": { + "key": "Program", + "arguments": [] + } + }, + { + "id": "native.programArguments", + "description": "Native UI: Program arguments", + "platforms": [ + "macos" + ], + "macos": { + "key": "Program arguments", + "arguments": [] + } + }, + { + "id": "native.programsChangesAreHighlighted", + "description": "Native UI: Programs (changes are highlighted)", + "platforms": [ + "macos" + ], + "macos": { + "key": "Programs (changes are highlighted)", + "arguments": [] + } + }, + { + "id": "native.programsItCanRun", + "description": "Native UI: Programs it can run", + "platforms": [ + "macos" + ], + "macos": { + "key": "Programs it can run", + "arguments": [] + } + }, + { + "id": "native.programsLeftRunning", + "description": "Native UI: Programs left running", + "platforms": [ + "macos" + ], + "macos": { + "key": "Programs left running", + "arguments": [] + } + }, + { + "id": "native.programsStartedInLitheThatLeftTheirTerminalOrToolToKeepRunningOnTheirOwnLikeDa7b15ea8191", + "description": "Native UI: Programs started in Lithe that left their terminal or tool to keep running on their own, like daemons or background dev servers. They are listed together when you quit, and you choose which ones to end. Only programs started in this Lithe are listed; containers and services started outside Lithe are never touched.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Programs started in Lithe that left their terminal or tool to keep running on their own, like daemons or background dev servers. They are listed together when you quit, and you choose which ones to end. Only programs started in this Lithe are listed; containers and services started outside Lithe are never touched.", + "arguments": [] + } + }, + { + "id": "native.project", + "description": "Native UI: Project: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Project: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.projectAccess", + "description": "Native UI: Project access", + "platforms": [ + "macos" + ], + "macos": { + "key": "Project access", + "arguments": [] + } + }, + { + "id": "native.projectChanges", + "description": "Native UI: Project changes", + "platforms": [ + "macos" + ], + "macos": { + "key": "Project changes", + "arguments": [] + } + }, + { + "id": "native.projectConfiguration", + "description": "Native UI: Project configuration", + "platforms": [ + "macos" + ], + "macos": { + "key": "Project configuration", + "arguments": [] + } + }, + { + "id": "native.projectDefault", + "description": "Native UI: Project default", + "platforms": [ + "macos" + ], + "macos": { + "key": "Project default", + "arguments": [] + } + }, + { + "id": "native.projectDefaultsThisMac", + "description": "Native UI: Project defaults (This Mac)", + "platforms": [ + "macos" + ], + "macos": { + "key": "Project defaults (This Mac)", + "arguments": [] + } + }, + { + "id": "native.projectEnvironment", + "description": "Native UI: Project environment", + "platforms": [ + "macos" + ], + "macos": { + "key": "Project environment", + "arguments": [] + } + }, + { + "id": "native.projectErrors", + "description": "Native UI: Project Errors", + "platforms": [ + "macos" + ], + "macos": { + "key": "Project Errors", + "arguments": [] + } + }, + { + "id": "native.projectFiles", + "description": "Native UI: Project files", + "platforms": [ + "macos" + ], + "macos": { + "key": "Project files", + "arguments": [] + } + }, + { + "id": "native.projectIdentificationComplete", + "description": "Native UI: Project identification complete", + "platforms": [ + "macos" + ], + "macos": { + "key": "Project identification complete", + "arguments": [] + } + }, + { + "id": "native.projectIdentificationFailed", + "description": "Native UI: Project identification failed", + "platforms": [ + "macos" + ], + "macos": { + "key": "Project identification failed", + "arguments": [] + } + }, + { + "id": "native.projectIsStillLoading", + "description": "Native UI: Project is still loading", + "platforms": [ + "macos" + ], + "macos": { + "key": "Project is still loading", + "arguments": [] + } + }, + { + "id": "native.projectJDK", + "description": "Native UI: Project JDK", + "platforms": [ + "macos" + ], + "macos": { + "key": "Project JDK", + "arguments": [] + } + }, + { + "id": "native.projectJDKMaven", + "description": "Native UI: Project · JDK & Maven", + "platforms": [ + "macos" + ], + "macos": { + "key": "Project · JDK & Maven", + "arguments": [] + } + }, + { + "id": "native.projectLocalHistory", + "description": "Native UI: Project Local History", + "platforms": [ + "macos" + ], + "macos": { + "key": "Project Local History", + "arguments": [] + } + }, + { + "id": "native.projectMavenWrapper", + "description": "Native UI: project Maven Wrapper", + "platforms": [ + "macos" + ], + "macos": { + "key": "project Maven Wrapper", + "arguments": [] + } + }, + { + "id": "native.projectOpened", + "description": "Native UI: Project opened", + "platforms": [ + "macos" + ], + "macos": { + "key": "Project opened", + "arguments": [] + } + }, + { + "id": "native.projectPathsMustStayInsideTheCurrentProject", + "description": "Native UI: Project paths must stay inside the current project.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Project paths must stay inside the current project.", + "arguments": [] + } + }, + { + "id": "native.projectRoot", + "description": "Native UI: Project root", + "platforms": [ + "macos" + ], + "macos": { + "key": "Project root", + "arguments": [] + } + }, + { + "id": "native.projectRunConfigurationIsInvalid", + "description": "Native UI: Project run configuration is invalid", + "platforms": [ + "macos" + ], + "macos": { + "key": "Project run configuration is invalid", + "arguments": [] + } + }, + { + "id": "native.projectRunConfigurationNotFound", + "description": "Native UI: Project run configuration not found", + "platforms": [ + "macos" + ], + "macos": { + "key": "Project run configuration not found", + "arguments": [] + } + }, + { + "id": "native.projectRuntimeSettingsAreSavedLocallyForThisProject", + "description": "Native UI: Project runtime settings are saved locally for this project.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Project runtime settings are saved locally for this project.", + "arguments": [] + } + }, + { + "id": "native.projectToolchainNeedsAttention", + "description": "Native UI: Project toolchain needs attention", + "platforms": [ + "macos" + ], + "macos": { + "key": "Project toolchain needs attention", + "arguments": [] + } + }, + { + "id": "native.protection", + "description": "Native UI: Protection", + "platforms": [ + "macos" + ], + "macos": { + "key": "Protection", + "arguments": [] + } + }, + { + "id": "native.protocolBuffersSupport", + "description": "Native UI: Protocol Buffers Support", + "platforms": [ + "macos" + ], + "macos": { + "key": "Protocol Buffers Support", + "arguments": [] + } + }, + { + "id": "native.providedBy", + "description": "Native UI: Provided by", + "platforms": [ + "macos" + ], + "macos": { + "key": "Provided by", + "arguments": [] + } + }, + { + "id": "native.providedByLitheForThisLanguage", + "description": "Native UI: Provided by Lithe for this language", + "platforms": [ + "macos" + ], + "macos": { + "key": "Provided by Lithe for this language", + "arguments": [] + } + }, + { + "id": "native.provider", + "description": "Native UI: Provider", + "platforms": [ + "macos" + ], + "macos": { + "key": "Provider", + "arguments": [] + } + }, + { + "id": "native.providerConfigurationParsingIsUnavailable", + "description": "Native UI: Provider configuration parsing is unavailable.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Provider configuration parsing is unavailable.", + "arguments": [] + } + }, + { + "id": "native.providerDetails", + "description": "Native UI: Provider details", + "platforms": [ + "macos" + ], + "macos": { + "key": "Provider details", + "arguments": [] + } + }, + { + "id": "native.providerName", + "description": "Native UI: Provider name", + "platforms": [ + "macos" + ], + "macos": { + "key": "Provider name", + "arguments": [] + } + }, + { + "id": "native.providerRequiresAnAPIKey", + "description": "Native UI: Provider requires an API key", + "platforms": [ + "macos" + ], + "macos": { + "key": "Provider requires an API key", + "arguments": [] + } + }, + { + "id": "native.providers", + "description": "Native UI: Providers", + "platforms": [ + "macos" + ], + "macos": { + "key": "Providers", + "arguments": [] + } + }, + { + "id": "native.provides", + "description": "Native UI: Provides", + "platforms": [ + "macos" + ], + "macos": { + "key": "Provides", + "arguments": [] + } + }, + { + "id": "native.pruneStaleRecords", + "description": "Native UI: Prune Stale Records", + "platforms": [ + "macos" + ], + "macos": { + "key": "Prune Stale Records", + "arguments": [] + } + }, + { + "id": "native.pruneStaleRemoteBranches", + "description": "Native UI: Prune stale remote branches", + "platforms": [ + "macos" + ], + "macos": { + "key": "Prune stale remote branches", + "arguments": [] + } + }, + { + "id": "native.pruneStaleRemoteTrackingReferences", + "description": "Native UI: Prune stale remote-tracking references", + "platforms": [ + "macos" + ], + "macos": { + "key": "Prune stale remote-tracking references", + "arguments": [] + } + }, + { + "id": "native.pruneStaleTags", + "description": "Native UI: Prune stale tags", + "platforms": [ + "macos" + ], + "macos": { + "key": "Prune stale tags", + "arguments": [] + } + }, + { + "id": "native.pruneStaleWorktreeRecords", + "description": "Native UI: Prune stale worktree records?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Prune stale worktree records?", + "arguments": [] + } + }, + { + "id": "native.pruneTheStaleRecordInstead", + "description": "Native UI: Prune the stale record instead.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Prune the stale record instead.", + "arguments": [] + } + }, + { + "id": "native.prunedStaleWorktreeRecords", + "description": "Native UI: Pruned stale worktree records", + "platforms": [ + "macos" + ], + "macos": { + "key": "Pruned stale worktree records", + "arguments": [] + } + }, + { + "id": "native.publishBranch", + "description": "Native UI: Publish Branch", + "platforms": [ + "macos" + ], + "macos": { + "key": "Publish Branch", + "arguments": [] + } + }, + { + "id": "native.publishBranchFirst", + "description": "Native UI: Publish branch first", + "platforms": [ + "macos" + ], + "macos": { + "key": "Publish branch first", + "arguments": [] + } + }, + { + "id": "native.publishThisWorktree", + "description": "Native UI: Publish this worktree", + "platforms": [ + "macos" + ], + "macos": { + "key": "Publish this worktree", + "arguments": [] + } + }, + { + "id": "native.publishTo", + "description": "Native UI: Publish to %@/%@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Publish to %@/%@", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string" + } + }, + { + "id": "native.publishing", + "description": "Native UI: Publishing…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Publishing…", + "arguments": [] + } + }, + { + "id": "native.pull", + "description": "Native UI: Pull", + "platforms": [ + "macos" + ], + "macos": { + "key": "Pull", + "arguments": [] + } + }, + { + "id": "native.pull.variant2", + "description": "Native UI: pull", + "platforms": [ + "macos" + ], + "macos": { + "key": "pull", + "arguments": [] + } + }, + { + "id": "native.pullAndPush", + "description": "Native UI: Pull and push", + "platforms": [ + "macos" + ], + "macos": { + "key": "Pull and push", + "arguments": [] + } + }, + { + "id": "native.pullAndPushSettingsAreWrittenToTheSelectedGitConfigurationFetchSettingsApplyTob5ea5b72f2", + "description": "Native UI: Pull and Push settings are written to the selected Git configuration. Fetch settings apply to both manual and background fetches.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Pull and Push settings are written to the selected Git configuration. Fetch settings apply to both manual and background fetches.", + "arguments": [] + } + }, + { + "id": "native.pullIntoTheCurrentBranchAndReplayLocalCommits", + "description": "Native UI: Pull %@ into the current branch and replay local commits.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Pull %@ into the current branch and replay local commits.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.pullIntoTheCurrentBranchWithAMerge", + "description": "Native UI: Pull %@ into the current branch with a merge.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Pull %@ into the current branch with a merge.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.pullRemoteBranchWithMerge", + "description": "Native UI: Pull remote branch with merge?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Pull remote branch with merge?", + "arguments": [] + } + }, + { + "id": "native.pullRemoteBranchWithRebase", + "description": "Native UI: Pull remote branch with rebase?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Pull remote branch with rebase?", + "arguments": [] + } + }, + { + "id": "native.pullRequestCheckedOutAsALocalBranch", + "description": "Native UI: Pull request checked out as a local branch", + "platforms": [ + "macos" + ], + "macos": { + "key": "Pull request checked out as a local branch", + "arguments": [] + } + }, + { + "id": "native.pullRequestClosed", + "description": "Native UI: Pull request closed", + "platforms": [ + "macos" + ], + "macos": { + "key": "Pull request closed", + "arguments": [] + } + }, + { + "id": "native.pullRequestCreated", + "description": "Native UI: Pull request created", + "platforms": [ + "macos" + ], + "macos": { + "key": "Pull request created", + "arguments": [] + } + }, + { + "id": "native.pullRequestDescriptionGeneration", + "description": "Native UI: Pull request description generation", + "platforms": [ + "macos" + ], + "macos": { + "key": "Pull request description generation", + "arguments": [] + } + }, + { + "id": "native.pullRequestGenerationUsesTheSelectedProviderLanguageReasoningEffortAndDiffLimitAbove", + "description": "Native UI: Pull request generation uses the selected provider, language, reasoning effort, and diff limit above.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Pull request generation uses the selected provider, language, reasoning effort, and diff limit above.", + "arguments": [] + } + }, + { + "id": "native.pullRequestMerged", + "description": "Native UI: Pull request merged", + "platforms": [ + "macos" + ], + "macos": { + "key": "Pull request merged", + "arguments": [] + } + }, + { + "id": "native.pullRequestReopened", + "description": "Native UI: Pull request reopened", + "platforms": [ + "macos" + ], + "macos": { + "key": "Pull request reopened", + "arguments": [] + } + }, + { + "id": "native.pullRequestUpdated", + "description": "Native UI: Pull request updated", + "platforms": [ + "macos" + ], + "macos": { + "key": "Pull request updated", + "arguments": [] + } + }, + { + "id": "native.pullRequests", + "description": "Native UI: Pull Requests", + "platforms": [ + "macos" + ], + "macos": { + "key": "Pull Requests", + "arguments": [] + } + }, + { + "id": "native.pullRequestsIntegrationIsUnderDevelopment", + "description": "Native UI: Pull Requests integration is under development", + "platforms": [ + "macos" + ], + "macos": { + "key": "Pull Requests integration is under development", + "arguments": [] + } + }, + { + "id": "native.pullRequestsUnavailable", + "description": "Native UI: Pull requests unavailable", + "platforms": [ + "macos" + ], + "macos": { + "key": "Pull requests unavailable", + "arguments": [] + } + }, + { + "id": "native.pullStrategy", + "description": "Native UI: Pull strategy", + "platforms": [ + "macos" + ], + "macos": { + "key": "Pull strategy", + "arguments": [] + } + }, + { + "id": "native.pullWithMerge", + "description": "Native UI: Pull with Merge", + "platforms": [ + "macos" + ], + "macos": { + "key": "Pull with Merge", + "arguments": [] + } + }, + { + "id": "native.pullWithRebase", + "description": "Native UI: Pull with Rebase", + "platforms": [ + "macos" + ], + "macos": { + "key": "Pull with Rebase", + "arguments": [] + } + }, + { + "id": "native.punctuationAndSymbols", + "description": "Native UI: punctuation and symbols", + "platforms": [ + "macos" + ], + "macos": { + "key": "punctuation and symbols", + "arguments": [] + } + }, + { + "id": "native.push", + "description": "Native UI: Push", + "platforms": [ + "macos" + ], + "macos": { + "key": "Push", + "arguments": [] + } + }, + { + "id": "native.push.variant2", + "description": "Native UI: Push…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Push…", + "arguments": [] + } + }, + { + "id": "native.pushOnly", + "description": "Native UI: Push only", + "platforms": [ + "macos" + ], + "macos": { + "key": "Push only", + "arguments": [] + } + }, + { + "id": "native.pushTheLatestCommitsBeforeComparingOrCreatingAPullRequest", + "description": "Native UI: Push the latest commits before comparing or creating a pull request.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Push the latest commits before comparing or creating a pull request.", + "arguments": [] + } + }, + { + "id": "native.pushThisBranchToGitHub", + "description": "Native UI: Push this branch to GitHub", + "platforms": [ + "macos" + ], + "macos": { + "key": "Push this branch to GitHub", + "arguments": [] + } + }, + { + "id": "native.pushTo", + "description": "Native UI: Push to %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Push to %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.pythonSupport", + "description": "Native UI: Python Support", + "platforms": [ + "macos" + ], + "macos": { + "key": "Python Support", + "arguments": [] + } + }, + { + "id": "native.quickInformation", + "description": "Native UI: Quick Information", + "platforms": [ + "macos" + ], + "macos": { + "key": "Quick Information", + "arguments": [] + } + }, + { + "id": "native.quickResponsesWithBasicReasoning", + "description": "Native UI: Quick responses with basic reasoning", + "platforms": [ + "macos" + ], + "macos": { + "key": "Quick responses with basic reasoning", + "arguments": [] + } + }, + { + "id": "native.quit", + "description": "Native UI: Quit", + "platforms": [ + "macos" + ], + "macos": { + "key": "Quit", + "arguments": [] + } + }, + { + "id": "native.quitWithSelection", + "description": "Native UI: Quit with Selection", + "platforms": [ + "macos" + ], + "macos": { + "key": "Quit with Selection", + "arguments": [] + } + }, + { + "id": "native.quittingLitheEndsThesePrograms", + "description": "Native UI: Quitting Lithe ends these programs.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Quitting Lithe ends these programs.", + "arguments": [] + } + }, + { + "id": "native.quittingLitheStopsTheseServicesAndTheProcessesTheyStarted", + "description": "Native UI: Quitting Lithe stops these services and the processes they started.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Quitting Lithe stops these services and the processes they started.", + "arguments": [] + } + }, + { + "id": "native.quota", + "description": "Native UI: Quota —", + "platforms": [ + "macos" + ], + "macos": { + "key": "Quota —", + "arguments": [] + } + }, + { + "id": "native.quotaIsTemporarilyUnavailableTheLastValueMayBeOutOfDate", + "description": "Native UI: Quota is temporarily unavailable. The last value may be out of date.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Quota is temporarily unavailable. The last value may be out of date.", + "arguments": [] + } + }, + { + "id": "native.rSupport", + "description": "Native UI: R Support", + "platforms": [ + "macos" + ], + "macos": { + "key": "R Support", + "arguments": [] + } + }, + { + "id": "native.ranLldCommand", + "description": "Native UI: Ran %lld command", + "platforms": [ + "macos" + ], + "macos": { + "key": "Ran %lld command", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.ranLldCommands", + "description": "Native UI: Ran %lld commands", + "platforms": [ + "macos" + ], + "macos": { + "key": "Ran %lld commands", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.readLldFile", + "description": "Native UI: Read %lld file", + "platforms": [ + "macos" + ], + "macos": { + "key": "Read %lld file", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.readLldFiles", + "description": "Native UI: Read %lld files", + "platforms": [ + "macos" + ], + "macos": { + "key": "Read %lld files", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.readOnly", + "description": "Native UI: Read-only", + "platforms": [ + "macos" + ], + "macos": { + "key": "Read-only", + "arguments": [] + } + }, + { + "id": "native.readOnlyFilePreview", + "description": "Native UI: Read-only file preview", + "platforms": [ + "macos" + ], + "macos": { + "key": "Read-only file preview", + "arguments": [] + } + }, + { + "id": "native.readingChangesAndRecentCommits", + "description": "Native UI: Reading changes and recent commits.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reading changes and recent commits.", + "arguments": [] + } + }, + { + "id": "native.readingGitConfiguration", + "description": "Native UI: Reading Git configuration…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reading Git configuration…", + "arguments": [] + } + }, + { + "id": "native.readingProject", + "description": "Native UI: Reading project…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reading project…", + "arguments": [] + } + }, + { + "id": "native.readingRegisteredCheckouts", + "description": "Native UI: Reading registered checkouts.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reading registered checkouts.", + "arguments": [] + } + }, + { + "id": "native.readsTheAPIURLModelAndKeyFromClaudeSettingsJsonAndClaudeJsonEditThoseFilesToCh388f05ea21", + "description": "Native UI: Reads the API URL, model and key from ~/.claude/settings.json and ~/.claude.json. Edit those files to change them, then fetch again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reads the API URL, model and key from ~/.claude/settings.json and ~/.claude.json. Edit those files to change them, then fetch again.", + "arguments": [] + } + }, + { + "id": "native.readsTheAPIURLModelAndKeyFromCodexConfigTomlAndAuthJsonEditThoseFilesToChangeThemThenFetchAgain", + "description": "Native UI: Reads the API URL, model and key from ~/.codex/config.toml and auth.json. Edit those files to change them, then fetch again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reads the API URL, model and key from ~/.codex/config.toml and auth.json. Edit those files to change them, then fetch again.", + "arguments": [] + } + }, + { + "id": "native.ready", + "description": "Native UI: Ready", + "platforms": [ + "macos" + ], + "macos": { + "key": "Ready", + "arguments": [] + } + }, + { + "id": "native.readyToCreate", + "description": "Native UI: Ready to create", + "platforms": [ + "macos" + ], + "macos": { + "key": "Ready to create", + "arguments": [] + } + }, + { + "id": "native.reason", + "description": "Native UI: Reason", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reason", + "arguments": [] + } + }, + { + "id": "native.reasoning", + "description": "Native UI: Reasoning", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reasoning", + "arguments": [] + } + }, + { + "id": "native.reasoningEffort", + "description": "Native UI: Reasoning effort", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reasoning effort", + "arguments": [] + } + }, + { + "id": "native.reasoningTokens", + "description": "Native UI: Reasoning tokens: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reasoning tokens: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.rebase", + "description": "Native UI: Rebase", + "platforms": [ + "macos" + ], + "macos": { + "key": "Rebase", + "arguments": [] + } + }, + { + "id": "native.rebase.variant2", + "description": "Native UI: rebase", + "platforms": [ + "macos" + ], + "macos": { + "key": "rebase", + "arguments": [] + } + }, + { + "id": "native.rebaseAndKeepMerges", + "description": "Native UI: Rebase and keep merges", + "platforms": [ + "macos" + ], + "macos": { + "key": "Rebase and keep merges", + "arguments": [] + } + }, + { + "id": "native.rebaseAndMerge", + "description": "Native UI: Rebase and merge", + "platforms": [ + "macos" + ], + "macos": { + "key": "Rebase and merge", + "arguments": [] + } + }, + { + "id": "native.rebaseBranch", + "description": "Native UI: Rebase branch?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Rebase branch?", + "arguments": [] + } + }, + { + "id": "native.rebaseCurrentBranchOnto", + "description": "Native UI: Rebase Current Branch onto…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Rebase Current Branch onto…", + "arguments": [] + } + }, + { + "id": "native.rebaseCurrentBranchOntoIncomingChangesR", + "description": "Native UI: Rebase current branch onto incoming changes (R)", + "platforms": [ + "macos" + ], + "macos": { + "key": "Rebase current branch onto incoming changes (R)", + "arguments": [] + } + }, + { + "id": "native.rebaseInProgress", + "description": "Native UI: Rebase in progress", + "platforms": [ + "macos" + ], + "macos": { + "key": "Rebase in progress", + "arguments": [] + } + }, + { + "id": "native.rebaseRequiresACleanWorkingTreeCommitOrStashLocalChangesBeforeChoosingRebase", + "description": "Native UI: Rebase requires a clean working tree. Commit or stash local changes before choosing Rebase.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Rebase requires a clean working tree. Commit or stash local changes before choosing Rebase.", + "arguments": [] + } + }, + { + "id": "native.recent", + "description": "Native UI: Recent", + "platforms": [ + "macos" + ], + "macos": { + "key": "Recent", + "arguments": [] + } + }, + { + "id": "native.recentProjects", + "description": "Native UI: Recent Projects", + "platforms": [ + "macos" + ], + "macos": { + "key": "Recent Projects", + "arguments": [] + } + }, + { + "id": "native.recommendedKeepWorktreesInAPersistentFolderNextToTheRepositoryYouCanChoosePrivf758d1db78", + "description": "Native UI: Recommended: keep worktrees in a persistent folder next to the repository. You can choose /private/tmp manually for disposable checkouts.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Recommended: keep worktrees in a persistent folder next to the repository. You can choose /private/tmp manually for disposable checkouts.", + "arguments": [] + } + }, + { + "id": "native.reconnect", + "description": "Native UI: Reconnect", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reconnect", + "arguments": [] + } + }, + { + "id": "native.reconnectThisAgentToRefreshItsModelList", + "description": "Native UI: Reconnect this Agent to refresh its model list.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reconnect this Agent to refresh its model list.", + "arguments": [] + } + }, + { + "id": "native.reconnecting", + "description": "Native UI: Reconnecting…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reconnecting…", + "arguments": [] + } + }, + { + "id": "native.reconnectingDD", + "description": "Native UI: Reconnecting %d/%d…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reconnecting %d/%d…", + "arguments": [ + "arg1", + "arg2" + ], + "formats": { + "arg1": "d", + "arg2": "d" + } + }, + "parameters": { + "arg1": "integer", + "arg2": "integer" + } + }, + { + "id": "native.recoveryReference", + "description": "Native UI: Recovery reference: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Recovery reference: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.referenceChangesCouldNotBeInspected", + "description": "Native UI: Reference changes could not be inspected", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reference changes could not be inspected", + "arguments": [] + } + }, + { + "id": "native.referenceListTruncatedTotalUpdatedLldDeletedLld", + "description": "Native UI: Reference list truncated; total updated: %lld, deleted: %lld", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reference list truncated; total updated: %lld, deleted: %lld", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "integer", + "arg2": "integer" + } + }, + { + "id": "native.refreshAll", + "description": "Native UI: Refresh All", + "platforms": [ + "macos" + ], + "macos": { + "key": "Refresh All", + "arguments": [] + } + }, + { + "id": "native.refreshChanges", + "description": "Native UI: Refresh changes", + "platforms": [ + "macos" + ], + "macos": { + "key": "Refresh changes", + "arguments": [] + } + }, + { + "id": "native.refreshConnectionStructure", + "description": "Native UI: Refresh connection structure", + "platforms": [ + "macos" + ], + "macos": { + "key": "Refresh connection structure", + "arguments": [] + } + }, + { + "id": "native.refreshDependencies", + "description": "Native UI: Refresh dependencies", + "platforms": [ + "macos" + ], + "macos": { + "key": "Refresh dependencies", + "arguments": [] + } + }, + { + "id": "native.refreshDetectedRuntimes", + "description": "Native UI: Refresh detected runtimes", + "platforms": [ + "macos" + ], + "macos": { + "key": "Refresh detected runtimes", + "arguments": [] + } + }, + { + "id": "native.refreshFiles", + "description": "Native UI: Refresh Files", + "platforms": [ + "macos" + ], + "macos": { + "key": "Refresh Files", + "arguments": [] + } + }, + { + "id": "native.refreshGitLog", + "description": "Native UI: Refresh Git log", + "platforms": [ + "macos" + ], + "macos": { + "key": "Refresh Git log", + "arguments": [] + } + }, + { + "id": "native.refreshHistory", + "description": "Native UI: Refresh history", + "platforms": [ + "macos" + ], + "macos": { + "key": "Refresh history", + "arguments": [] + } + }, + { + "id": "native.refreshLocalHistory", + "description": "Native UI: Refresh local history", + "platforms": [ + "macos" + ], + "macos": { + "key": "Refresh local history", + "arguments": [] + } + }, + { + "id": "native.refreshLog", + "description": "Native UI: Refresh Log", + "platforms": [ + "macos" + ], + "macos": { + "key": "Refresh Log", + "arguments": [] + } + }, + { + "id": "native.refreshLogs", + "description": "Native UI: Refresh Logs", + "platforms": [ + "macos" + ], + "macos": { + "key": "Refresh Logs", + "arguments": [] + } + }, + { + "id": "native.refreshPluginRepositories", + "description": "Native UI: Refresh Plugin Repositories", + "platforms": [ + "macos" + ], + "macos": { + "key": "Refresh Plugin Repositories", + "arguments": [] + } + }, + { + "id": "native.refreshPreview", + "description": "Native UI: Refresh Preview", + "platforms": [ + "macos" + ], + "macos": { + "key": "Refresh Preview", + "arguments": [] + } + }, + { + "id": "native.refreshProjectHistory", + "description": "Native UI: Refresh project history", + "platforms": [ + "macos" + ], + "macos": { + "key": "Refresh project history", + "arguments": [] + } + }, + { + "id": "native.refreshPullRequests", + "description": "Native UI: Refresh pull requests", + "platforms": [ + "macos" + ], + "macos": { + "key": "Refresh pull requests", + "arguments": [] + } + }, + { + "id": "native.refreshWorktrees", + "description": "Native UI: Refresh worktrees", + "platforms": [ + "macos" + ], + "macos": { + "key": "Refresh worktrees", + "arguments": [] + } + }, + { + "id": "native.refreshed", + "description": "Native UI: Refreshed %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Refreshed %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.refreshingJavaEntries", + "description": "Native UI: Refreshing Java entries", + "platforms": [ + "macos" + ], + "macos": { + "key": "Refreshing Java entries", + "arguments": [] + } + }, + { + "id": "native.refreshingProject", + "description": "Native UI: Refreshing project", + "platforms": [ + "macos" + ], + "macos": { + "key": "Refreshing project", + "arguments": [] + } + }, + { + "id": "native.regenerate", + "description": "Native UI: Regenerate", + "platforms": [ + "macos" + ], + "macos": { + "key": "Regenerate", + "arguments": [] + } + }, + { + "id": "native.registeredForThisProjectIn", + "description": "Native UI: Registered for this project in %@.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Registered for this project in %@.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.registeredWith", + "description": "Native UI: Registered with %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Registered with %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.registersTheWorktreeAndPreparesItsHEADWhileLeavingItsFilesUncheckedOut", + "description": "Native UI: Registers the worktree and prepares its HEAD while leaving its files unchecked out.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Registers the worktree and prepares its HEAD while leaving its files unchecked out.", + "arguments": [] + } + }, + { + "id": "native.registration", + "description": "Native UI: Registration", + "platforms": [ + "macos" + ], + "macos": { + "key": "Registration", + "arguments": [] + } + }, + { + "id": "native.regularExpression", + "description": "Native UI: Regular Expression", + "platforms": [ + "macos" + ], + "macos": { + "key": "Regular Expression", + "arguments": [] + } + }, + { + "id": "native.releaseDate", + "description": "Native UI: Release Date", + "platforms": [ + "macos" + ], + "macos": { + "key": "Release Date", + "arguments": [] + } + }, + { + "id": "native.releaseNote", + "description": "Native UI: Release note", + "platforms": [ + "macos" + ], + "macos": { + "key": "Release note", + "arguments": [] + } + }, + { + "id": "native.reload", + "description": "Native UI: Reload", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reload", + "arguments": [] + } + }, + { + "id": "native.reloadAIConfigurations", + "description": "Native UI: Reload AI configurations", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reload AI configurations", + "arguments": [] + } + }, + { + "id": "native.reloadCodexConfiguration", + "description": "Native UI: Reload Codex configuration", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reload Codex configuration", + "arguments": [] + } + }, + { + "id": "native.reloadConfiguration", + "description": "Native UI: Reload Configuration", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reload Configuration", + "arguments": [] + } + }, + { + "id": "native.reloadGitConfiguration", + "description": "Native UI: Reload Git Configuration", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reload Git Configuration", + "arguments": [] + } + }, + { + "id": "native.reloadMavenProject", + "description": "Native UI: Reload Maven project", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reload Maven project", + "arguments": [] + } + }, + { + "id": "native.reloadProjectModel", + "description": "Native UI: Reload Project Model", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reload Project Model", + "arguments": [] + } + }, + { + "id": "native.reloadProjects", + "description": "Native UI: Reload %@ projects", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reload %@ projects", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.reloadRange", + "description": "Native UI: Reload Range", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reload Range", + "arguments": [] + } + }, + { + "id": "native.reloadTheCorePreviewAndResetTheStepPlan", + "description": "Native UI: Reload the Core preview and reset the step plan", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reload the Core preview and reset the step plan", + "arguments": [] + } + }, + { + "id": "native.reloadTheProjectModel", + "description": "Native UI: Reload the project model", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reload the project model", + "arguments": [] + } + }, + { + "id": "native.reloadingMaven", + "description": "Native UI: Reloading Maven...", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reloading Maven...", + "arguments": [] + } + }, + { + "id": "native.reloadsProject", + "description": "Native UI: Reloads project", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reloads project", + "arguments": [] + } + }, + { + "id": "native.rememberMyChoiceForTheseKindsOfServices", + "description": "Native UI: Remember my choice for these kinds of services", + "platforms": [ + "macos" + ], + "macos": { + "key": "Remember my choice for these kinds of services", + "arguments": [] + } + }, + { + "id": "native.rememberedChoices", + "description": "Native UI: Remembered choices", + "platforms": [ + "macos" + ], + "macos": { + "key": "Remembered choices", + "arguments": [] + } + }, + { + "id": "native.remote", + "description": "Native UI: Remote", + "platforms": [ + "macos" + ], + "macos": { + "key": "Remote", + "arguments": [] + } + }, + { + "id": "native.remote.variant2", + "description": "Native UI: Remote: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Remote: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.remoteFetchFailed", + "description": "Native UI: Remote Fetch failed", + "platforms": [ + "macos" + ], + "macos": { + "key": "Remote Fetch failed", + "arguments": [] + } + }, + { + "id": "native.remoteFetchSucceeded", + "description": "Native UI: Remote Fetch succeeded", + "platforms": [ + "macos" + ], + "macos": { + "key": "Remote Fetch succeeded", + "arguments": [] + } + }, + { + "id": "native.remoteJVMTomcat", + "description": "Native UI: Remote JVM / Tomcat", + "platforms": [ + "macos" + ], + "macos": { + "key": "Remote JVM / Tomcat", + "arguments": [] + } + }, + { + "id": "native.remoteURL", + "description": "Native UI: Remote URL", + "platforms": [ + "macos" + ], + "macos": { + "key": "Remote URL", + "arguments": [] + } + }, + { + "id": "native.removeAll", + "description": "Native UI: Remove All", + "platforms": [ + "macos" + ], + "macos": { + "key": "Remove All", + "arguments": [] + } + }, + { + "id": "native.removeAndItsCheckoutDirectoryTheBranchIsKeptIfGitRefusesBecauseFilesHaveChange7ffdbe003e", + "description": "Native UI: Remove '%@' and its checkout directory? The branch is kept. If Git refuses because files have changed, you can review a separate force-removal warning.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Remove '%@' and its checkout directory? The branch is kept. If Git refuses because files have changed, you can review a separate force-removal warning.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.removeAndItsCheckoutDirectoryTheBranchIsKeptIfGitRefusesBecauseFilesHaveChanged2b6784d33", + "description": "Native UI: Remove '%@' and its checkout directory? The branch is kept. If Git refuses because files have changed, review the force-removal warning.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Remove '%@' and its checkout directory? The branch is kept. If Git refuses because files have changed, review the force-removal warning.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.removeConnection", + "description": "Native UI: Remove Connection", + "platforms": [ + "macos" + ], + "macos": { + "key": "Remove Connection", + "arguments": [] + } + }, + { + "id": "native.removeDConversationSFromHistoryYouCanRestoreThemFromRemovedConversationsTheAge92ba9dc5d9", + "description": "Native UI: Remove %d conversation(s) from history? You can restore them from Removed conversations. The Agent's original records will be kept.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Remove %d conversation(s) from history? You can restore them from Removed conversations. The Agent's original records will be kept.", + "arguments": [ + "arg1" + ], + "formats": { + "arg1": "d" + } + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.removeFile", + "description": "Native UI: Remove file %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Remove file %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.removeFromFavorites", + "description": "Native UI: Remove from favorites", + "platforms": [ + "macos" + ], + "macos": { + "key": "Remove from favorites", + "arguments": [] + } + }, + { + "id": "native.removeFromHistory", + "description": "Native UI: Remove from history", + "platforms": [ + "macos" + ], + "macos": { + "key": "Remove from history", + "arguments": [] + } + }, + { + "id": "native.removeFromRecentProjects", + "description": "Native UI: Remove from Recent Projects", + "platforms": [ + "macos" + ], + "macos": { + "key": "Remove from Recent Projects", + "arguments": [] + } + }, + { + "id": "native.removeLocalRemoteBranchesThatNoLongerExistOnTheServer", + "description": "Native UI: Remove local remote branches that no longer exist on the server.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Remove local remote branches that no longer exist on the server.", + "arguments": [] + } + }, + { + "id": "native.removeLocalTagsThatWereDeletedFromTheRemote", + "description": "Native UI: Remove local tags that were deleted from the remote.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Remove local tags that were deleted from the remote.", + "arguments": [] + } + }, + { + "id": "native.removeStaleBranches", + "description": "Native UI: Remove stale branches", + "platforms": [ + "macos" + ], + "macos": { + "key": "Remove stale branches", + "arguments": [] + } + }, + { + "id": "native.removeStaleRemoteBranches", + "description": "Native UI: Remove stale remote branches", + "platforms": [ + "macos" + ], + "macos": { + "key": "Remove stale remote branches", + "arguments": [] + } + }, + { + "id": "native.removeStaleTags", + "description": "Native UI: Remove stale tags", + "platforms": [ + "macos" + ], + "macos": { + "key": "Remove stale tags", + "arguments": [] + } + }, + { + "id": "native.removeTheSelectedCommitAndItsChangesFromThisBranchLaterCommitsWillBeReplayedAR2e43a75384", + "description": "Native UI: Remove the selected commit and its changes from this branch. Later commits will be replayed. A recovery reference will retain the original history.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Remove the selected commit and its changes from this branch. Later commits will be replayed. A recovery reference will retain the original history.", + "arguments": [] + } + }, + { + "id": "native.removeWorktree", + "description": "Native UI: Remove Worktree…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Remove Worktree…", + "arguments": [] + } + }, + { + "id": "native.removeWorktree.variant2", + "description": "Native UI: Remove worktree?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Remove worktree?", + "arguments": [] + } + }, + { + "id": "native.removed", + "description": "Native UI: Removed", + "platforms": [ + "macos" + ], + "macos": { + "key": "Removed", + "arguments": [] + } + }, + { + "id": "native.removed.variant2", + "description": "Native UI: Removed %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Removed %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.removedConversations", + "description": "Native UI: Removed conversations", + "platforms": [ + "macos" + ], + "macos": { + "key": "Removed conversations", + "arguments": [] + } + }, + { + "id": "native.removedConversationsAreHiddenOnlyInLitheTheAgentSOriginalRecordsAreKept", + "description": "Native UI: Removed conversations are hidden only in Lithe. The Agent's original records are kept.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Removed conversations are hidden only in Lithe. The Agent's original records are kept.", + "arguments": [] + } + }, + { + "id": "native.removedRestartLitheToFinish", + "description": "Native UI: Removed. Restart Lithe to finish.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Removed. Restart Lithe to finish.", + "arguments": [] + } + }, + { + "id": "native.removedRestoreItToUseItAgain", + "description": "Native UI: Removed. Restore it to use it again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Removed. Restore it to use it again.", + "arguments": [] + } + }, + { + "id": "native.rename", + "description": "Native UI: Rename '%@'.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Rename '%@'.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.rename.variant2", + "description": "Native UI: Rename…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Rename…", + "arguments": [] + } + }, + { + "id": "native.renameBranch", + "description": "Native UI: Rename Branch", + "platforms": [ + "macos" + ], + "macos": { + "key": "Rename Branch", + "arguments": [] + } + }, + { + "id": "native.renameChangeList", + "description": "Native UI: Rename ChangeList", + "platforms": [ + "macos" + ], + "macos": { + "key": "Rename ChangeList", + "arguments": [] + } + }, + { + "id": "native.renameChat", + "description": "Native UI: Rename Chat", + "platforms": [ + "macos" + ], + "macos": { + "key": "Rename Chat", + "arguments": [] + } + }, + { + "id": "native.renameConversation", + "description": "Native UI: Rename conversation", + "platforms": [ + "macos" + ], + "macos": { + "key": "Rename conversation", + "arguments": [] + } + }, + { + "id": "native.renamed", + "description": "Native UI: Renamed", + "platforms": [ + "macos" + ], + "macos": { + "key": "Renamed", + "arguments": [] + } + }, + { + "id": "native.reopenPullRequest", + "description": "Native UI: Reopen Pull Request", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reopen Pull Request", + "arguments": [] + } + }, + { + "id": "native.reopenPullRequest.variant2", + "description": "Native UI: Reopen pull request", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reopen pull request", + "arguments": [] + } + }, + { + "id": "native.reopenPullRequest.variant3", + "description": "Native UI: Reopen pull request?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reopen pull request?", + "arguments": [] + } + }, + { + "id": "native.reopeningPullRequest", + "description": "Native UI: Reopening pull request…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reopening pull request…", + "arguments": [] + } + }, + { + "id": "native.repairOrPruneTheMissingCheckoutBeforeChangingItsLock", + "description": "Native UI: Repair or prune the missing checkout before changing its lock.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Repair or prune the missing checkout before changing its lock.", + "arguments": [] + } + }, + { + "id": "native.repairWorktreeRecords", + "description": "Native UI: Repair Worktree Records", + "platforms": [ + "macos" + ], + "macos": { + "key": "Repair Worktree Records", + "arguments": [] + } + }, + { + "id": "native.repairedWorktreeRecords", + "description": "Native UI: Repaired worktree records", + "platforms": [ + "macos" + ], + "macos": { + "key": "Repaired worktree records", + "arguments": [] + } + }, + { + "id": "native.repeatedLldTimes", + "description": "Native UI: Repeated %lld times", + "platforms": [ + "macos" + ], + "macos": { + "key": "Repeated %lld times", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.replace", + "description": "Native UI: Replace", + "platforms": [ + "macos" + ], + "macos": { + "key": "Replace", + "arguments": [] + } + }, + { + "id": "native.replaceCurrentCommitMessage", + "description": "Native UI: Replace current commit message?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Replace current commit message?", + "arguments": [] + } + }, + { + "id": "native.replaceExistingContent", + "description": "Native UI: Replace existing content", + "platforms": [ + "macos" + ], + "macos": { + "key": "Replace existing content", + "arguments": [] + } + }, + { + "id": "native.replaceInFile", + "description": "Native UI: Replace in File", + "platforms": [ + "macos" + ], + "macos": { + "key": "Replace in File", + "arguments": [] + } + }, + { + "id": "native.replaceInFile.variant2", + "description": "Native UI: Replace in File…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Replace in File…", + "arguments": [] + } + }, + { + "id": "native.replaceInFiles", + "description": "Native UI: Replace in Files", + "platforms": [ + "macos" + ], + "macos": { + "key": "Replace in Files", + "arguments": [] + } + }, + { + "id": "native.replaceInProject", + "description": "Native UI: Replace in Project", + "platforms": [ + "macos" + ], + "macos": { + "key": "Replace in Project", + "arguments": [] + } + }, + { + "id": "native.replaceInProject.variant2", + "description": "Native UI: Replace in project", + "platforms": [ + "macos" + ], + "macos": { + "key": "Replace in project", + "arguments": [] + } + }, + { + "id": "native.replaceTextAcrossTheWorkspace", + "description": "Native UI: Replace text across the workspace", + "platforms": [ + "macos" + ], + "macos": { + "key": "Replace text across the workspace", + "arguments": [] + } + }, + { + "id": "native.replaceTheMessageOfThePausedCommitAndIncludeTheCurrentlyStagedChangesThenContinueTheRebase", + "description": "Native UI: Replace the message of the paused commit and include the currently staged changes, then continue the rebase.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Replace the message of the paused commit and include the currently staged changes, then continue the rebase.", + "arguments": [] + } + }, + { + "id": "native.replaceWith", + "description": "Native UI: Replace with", + "platforms": [ + "macos" + ], + "macos": { + "key": "Replace with", + "arguments": [] + } + }, + { + "id": "native.replaceWithinTheActiveEditor", + "description": "Native UI: Replace within the active editor", + "platforms": [ + "macos" + ], + "macos": { + "key": "Replace within the active editor", + "arguments": [] + } + }, + { + "id": "native.replacesAnInstalledPlugin", + "description": "Native UI: Replaces an Installed Plugin", + "platforms": [ + "macos" + ], + "macos": { + "key": "Replaces an Installed Plugin", + "arguments": [] + } + }, + { + "id": "native.replay", + "description": "Native UI: Replay", + "platforms": [ + "macos" + ], + "macos": { + "key": "Replay", + "arguments": [] + } + }, + { + "id": "native.replayTheCurrentBranchOntoConflictsMayRequireTerminalResolution", + "description": "Native UI: Replay the current branch onto %@. Conflicts may require terminal resolution.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Replay the current branch onto %@. Conflicts may require terminal resolution.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.replaysEveryCommitOntoTheBaseBranchWithoutAMergeCommit", + "description": "Native UI: Replays every commit onto the base branch without a merge commit.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Replays every commit onto the base branch without a merge commit.", + "arguments": [] + } + }, + { + "id": "native.reportedChange", + "description": "Native UI: Reported change", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reported change", + "arguments": [] + } + }, + { + "id": "native.repository", + "description": "Native UI: Repository", + "platforms": [ + "macos" + ], + "macos": { + "key": "Repository", + "arguments": [] + } + }, + { + "id": "native.repositoryChangedReviewAndRetry", + "description": "Native UI: Repository changed; review and retry", + "platforms": [ + "macos" + ], + "macos": { + "key": "Repository changed; review and retry", + "arguments": [] + } + }, + { + "id": "native.repositoryIdentityOverridesInheritedGlobalValuesClearAnOverrideToUseInheritedSettings", + "description": "Native UI: Repository identity overrides inherited global values. Clear an override to use inherited settings.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Repository identity overrides inherited global values. Clear an override to use inherited settings.", + "arguments": [] + } + }, + { + "id": "native.repositoryNeedsAttention", + "description": "Native UI: Repository needs attention", + "platforms": [ + "macos" + ], + "macos": { + "key": "Repository needs attention", + "arguments": [] + } + }, + { + "id": "native.repositoryURL", + "description": "Native UI: Repository URL", + "platforms": [ + "macos" + ], + "macos": { + "key": "Repository URL", + "arguments": [] + } + }, + { + "id": "native.requestChanges", + "description": "Native UI: Request changes", + "platforms": [ + "macos" + ], + "macos": { + "key": "Request changes", + "arguments": [] + } + }, + { + "id": "native.requiredFieldsAreMarkedWith", + "description": "Native UI: Required fields are marked with *", + "platforms": [ + "macos" + ], + "macos": { + "key": "Required fields are marked with *", + "arguments": [] + } + }, + { + "id": "native.rerun", + "description": "Native UI: Rerun", + "platforms": [ + "macos" + ], + "macos": { + "key": "Rerun", + "arguments": [] + } + }, + { + "id": "native.rerunLastTest", + "description": "Native UI: Rerun last test", + "platforms": [ + "macos" + ], + "macos": { + "key": "Rerun last test", + "arguments": [] + } + }, + { + "id": "native.rescan", + "description": "Native UI: Rescan", + "platforms": [ + "macos" + ], + "macos": { + "key": "Rescan", + "arguments": [] + } + }, + { + "id": "native.rescanServices", + "description": "Native UI: Rescan services", + "platforms": [ + "macos" + ], + "macos": { + "key": "Rescan services", + "arguments": [] + } + }, + { + "id": "native.rescanTheProjectForServices", + "description": "Native UI: Rescan the project for services", + "platforms": [ + "macos" + ], + "macos": { + "key": "Rescan the project for services", + "arguments": [] + } + }, + { + "id": "native.reset", + "description": "Native UI: Reset", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reset", + "arguments": [] + } + }, + { + "id": "native.resetCurrentBranch", + "description": "Native UI: Reset current branch?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reset current branch?", + "arguments": [] + } + }, + { + "id": "native.resetCurrentBranchToHere", + "description": "Native UI: Reset Current Branch to Here…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reset Current Branch to Here…", + "arguments": [] + } + }, + { + "id": "native.resetFetchDefaults", + "description": "Native UI: Reset Fetch defaults", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reset Fetch defaults", + "arguments": [] + } + }, + { + "id": "native.resetFontSize", + "description": "Native UI: Reset Font Size", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reset Font Size", + "arguments": [] + } + }, + { + "id": "native.resetHard", + "description": "Native UI: Reset (Hard)", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reset (Hard)", + "arguments": [] + } + }, + { + "id": "native.resetMixed", + "description": "Native UI: Reset (Mixed)", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reset (Mixed)", + "arguments": [] + } + }, + { + "id": "native.resetPageZoom100", + "description": "Native UI: Reset Page Zoom (100%)", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reset Page Zoom (100%)", + "arguments": [] + } + }, + { + "id": "native.resetSoft", + "description": "Native UI: Reset (Soft)", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reset (Soft)", + "arguments": [] + } + }, + { + "id": "native.resetThisSettingToItsInheritedValue", + "description": "Native UI: Reset this setting to its inherited value", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reset this setting to its inherited value", + "arguments": [] + } + }, + { + "id": "native.resetToDefault", + "description": "Native UI: Reset to Default", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reset to Default", + "arguments": [] + } + }, + { + "id": "native.resets", + "description": "Native UI: Resets: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Resets: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.residentMemory", + "description": "Native UI: Resident Memory", + "platforms": [ + "macos" + ], + "macos": { + "key": "Resident Memory", + "arguments": [] + } + }, + { + "id": "native.residentMemoryOfLitheAndItsManagedProcessTrees", + "description": "Native UI: Resident memory of Lithe and its managed process trees", + "platforms": [ + "macos" + ], + "macos": { + "key": "Resident memory of Lithe and its managed process trees", + "arguments": [] + } + }, + { + "id": "native.residentMemoryOfTheLitheProcess", + "description": "Native UI: Resident memory of the Lithe process", + "platforms": [ + "macos" + ], + "macos": { + "key": "Resident memory of the Lithe process", + "arguments": [] + } + }, + { + "id": "native.resolveLldConflictedFileSStageTheResultThenContinue", + "description": "Native UI: Resolve %lld conflicted file(s), stage the result, then continue.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Resolve %lld conflicted file(s), stage the result, then continue.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.resolveLldConflictedFileSStageThemThenContinue", + "description": "Native UI: Resolve %lld conflicted file(s), stage them, then continue.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Resolve %lld conflicted file(s), stage them, then continue.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.resolvingDependencies", + "description": "Native UI: Resolving dependencies...", + "platforms": [ + "macos" + ], + "macos": { + "key": "Resolving dependencies...", + "arguments": [] + } + }, + { + "id": "native.resolvingPushDestination", + "description": "Native UI: Resolving push destination…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Resolving push destination…", + "arguments": [] + } + }, + { + "id": "native.resolvingServicePaths", + "description": "Native UI: Resolving service paths...", + "platforms": [ + "macos" + ], + "macos": { + "key": "Resolving service paths...", + "arguments": [] + } + }, + { + "id": "native.resourcesRoot", + "description": "Native UI: Resources Root", + "platforms": [ + "macos" + ], + "macos": { + "key": "Resources Root", + "arguments": [] + } + }, + { + "id": "native.responding", + "description": "Native UI: Responding…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Responding…", + "arguments": [] + } + }, + { + "id": "native.response", + "description": "Native UI: Response", + "platforms": [ + "macos" + ], + "macos": { + "key": "Response", + "arguments": [] + } + }, + { + "id": "native.responsesAPI", + "description": "Native UI: Responses API", + "platforms": [ + "macos" + ], + "macos": { + "key": "Responses API", + "arguments": [] + } + }, + { + "id": "native.restart", + "description": "Native UI: Restart", + "platforms": [ + "macos" + ], + "macos": { + "key": "Restart", + "arguments": [] + } + }, + { + "id": "native.restartLanguageServers", + "description": "Native UI: Restart Language Servers", + "platforms": [ + "macos" + ], + "macos": { + "key": "Restart Language Servers", + "arguments": [] + } + }, + { + "id": "native.restartLithe", + "description": "Native UI: Restart Lithe", + "platforms": [ + "macos" + ], + "macos": { + "key": "Restart Lithe", + "arguments": [] + } + }, + { + "id": "native.restartLitheToFinishUninstallingThenInstallTheCommunityPluginFromTheMarketplace", + "description": "Native UI: Restart Lithe to finish uninstalling %@, then install the community plugin %@ from the Marketplace.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Restart Lithe to finish uninstalling %@, then install the community plugin %@ from the Marketplace.", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string" + } + }, + { + "id": "native.restartLitheToFinishUninstallingThenInstallThisPlugin", + "description": "Native UI: Restart Lithe to finish uninstalling %@, then install this plugin.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Restart Lithe to finish uninstalling %@, then install this plugin.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.restartLitheToLoadTheUpdatedPage", + "description": "Native UI: Restart Lithe to load the updated page.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Restart Lithe to load the updated page.", + "arguments": [] + } + }, + { + "id": "native.restartRequired", + "description": "Native UI: Restart required", + "platforms": [ + "macos" + ], + "macos": { + "key": "Restart required", + "arguments": [] + } + }, + { + "id": "native.restartRun", + "description": "Native UI: Restart run", + "platforms": [ + "macos" + ], + "macos": { + "key": "Restart run", + "arguments": [] + } + }, + { + "id": "native.restore", + "description": "Native UI: Restore", + "platforms": [ + "macos" + ], + "macos": { + "key": "Restore", + "arguments": [] + } + }, + { + "id": "native.restoreAllDefaults", + "description": "Native UI: Restore All Defaults", + "platforms": [ + "macos" + ], + "macos": { + "key": "Restore All Defaults", + "arguments": [] + } + }, + { + "id": "native.restoreConversations", + "description": "Native UI: Restore conversations", + "platforms": [ + "macos" + ], + "macos": { + "key": "Restore conversations", + "arguments": [] + } + }, + { + "id": "native.restoreDefault", + "description": "Native UI: Restore Default", + "platforms": [ + "macos" + ], + "macos": { + "key": "Restore Default", + "arguments": [] + } + }, + { + "id": "native.restoreDefaultTemplate", + "description": "Native UI: Restore Default Template", + "platforms": [ + "macos" + ], + "macos": { + "key": "Restore Default Template", + "arguments": [] + } + }, + { + "id": "native.restoreDefaultUILayout", + "description": "Native UI: Restore Default UI Layout", + "platforms": [ + "macos" + ], + "macos": { + "key": "Restore Default UI Layout", + "arguments": [] + } + }, + { + "id": "native.restorePath", + "description": "Native UI: Restore path", + "platforms": [ + "macos" + ], + "macos": { + "key": "Restore path", + "arguments": [] + } + }, + { + "id": "native.restoringGitHubConnection", + "description": "Native UI: Restoring GitHub connection", + "platforms": [ + "macos" + ], + "macos": { + "key": "Restoring GitHub connection", + "arguments": [] + } + }, + { + "id": "native.result", + "description": "Native UI: Result", + "platforms": [ + "macos" + ], + "macos": { + "key": "Result", + "arguments": [] + } + }, + { + "id": "native.results", + "description": "Native UI: Results", + "platforms": [ + "macos" + ], + "macos": { + "key": "Results", + "arguments": [] + } + }, + { + "id": "native.results.variant2", + "description": "Native UI: results", + "platforms": [ + "macos" + ], + "macos": { + "key": "results", + "arguments": [] + } + }, + { + "id": "native.resume", + "description": "Native UI: Resume", + "platforms": [ + "macos" + ], + "macos": { + "key": "Resume", + "arguments": [] + } + }, + { + "id": "native.resumeThePausedDebugSession", + "description": "Native UI: Resume the paused debug session", + "platforms": [ + "macos" + ], + "macos": { + "key": "Resume the paused debug session", + "arguments": [] + } + }, + { + "id": "native.retentionAndCleanup", + "description": "Native UI: Retention and cleanup", + "platforms": [ + "macos" + ], + "macos": { + "key": "Retention and cleanup", + "arguments": [] + } + }, + { + "id": "native.retiredOfficialGoPluginGoSupportIsNowTheCommunityPluginGoSupportInLitheStore", + "description": "Native UI: Retired official Go plugin. Go support is now the community plugin Go Support in Lithe Store.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Retired official Go plugin. Go support is now the community plugin Go Support in Lithe Store.", + "arguments": [] + } + }, + { + "id": "native.retiredOfficialPHPPluginPHPSupportIsNowTheCommunityPluginPHPSupportInLitheStore", + "description": "Native UI: Retired official PHP plugin. PHP support is now the community plugin PHP Support in Lithe Store.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Retired official PHP plugin. PHP support is now the community plugin PHP Support in Lithe Store.", + "arguments": [] + } + }, + { + "id": "native.retryDebugging", + "description": "Native UI: Retry debugging", + "platforms": [ + "macos" + ], + "macos": { + "key": "Retry debugging", + "arguments": [] + } + }, + { + "id": "native.retryIdentification", + "description": "Native UI: Retry Identification", + "platforms": [ + "macos" + ], + "macos": { + "key": "Retry Identification", + "arguments": [] + } + }, + { + "id": "native.retryUpdateCheck", + "description": "Native UI: Retry update check", + "platforms": [ + "macos" + ], + "macos": { + "key": "Retry update check", + "arguments": [] + } + }, + { + "id": "native.retryUsesLitheAuthenticationForThisOperationOnly", + "description": "Native UI: Retry uses Lithe authentication for this operation only.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Retry uses Lithe authentication for this operation only.", + "arguments": [] + } + }, + { + "id": "native.returnFromTheCurrentFunction", + "description": "Native UI: Return from the current function", + "platforms": [ + "macos" + ], + "macos": { + "key": "Return from the current function", + "arguments": [] + } + }, + { + "id": "native.returnToLithe", + "description": "Native UI: Return to Lithe", + "platforms": [ + "macos" + ], + "macos": { + "key": "Return to Lithe", + "arguments": [] + } + }, + { + "id": "native.returnToStable", + "description": "Native UI: Return to Stable", + "platforms": [ + "macos" + ], + "macos": { + "key": "Return to Stable", + "arguments": [] + } + }, + { + "id": "native.returnToStable.variant2", + "description": "Native UI: Return to Stable?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Return to Stable?", + "arguments": [] + } + }, + { + "id": "native.returnToTheWelcomeScreen", + "description": "Native UI: Return to the Welcome screen", + "platforms": [ + "macos" + ], + "macos": { + "key": "Return to the Welcome screen", + "arguments": [] + } + }, + { + "id": "native.revealInFinder", + "description": "Native UI: Reveal in Finder", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reveal in Finder", + "arguments": [] + } + }, + { + "id": "native.revert", + "description": "Native UI: Revert", + "platforms": [ + "macos" + ], + "macos": { + "key": "Revert", + "arguments": [] + } + }, + { + "id": "native.revert.variant2", + "description": "Native UI: revert", + "platforms": [ + "macos" + ], + "macos": { + "key": "revert", + "arguments": [] + } + }, + { + "id": "native.revertCommit", + "description": "Native UI: Revert Commit…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Revert Commit…", + "arguments": [] + } + }, + { + "id": "native.revertInProgress", + "description": "Native UI: Revert in progress", + "platforms": [ + "macos" + ], + "macos": { + "key": "Revert in progress", + "arguments": [] + } + }, + { + "id": "native.revertThisCommit", + "description": "Native UI: Revert this commit?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Revert this commit?", + "arguments": [] + } + }, + { + "id": "native.review", + "description": "Native UI: Review", + "platforms": [ + "macos" + ], + "macos": { + "key": "Review", + "arguments": [] + } + }, + { + "id": "native.reviewAction", + "description": "Native UI: Review action", + "platforms": [ + "macos" + ], + "macos": { + "key": "Review action", + "arguments": [] + } + }, + { + "id": "native.reviewAndRetry", + "description": "Native UI: Review and Retry…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Review and Retry…", + "arguments": [] + } + }, + { + "id": "native.reviewAndRetryUnfinishedSteps", + "description": "Native UI: Review and Retry Unfinished Steps…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Review and Retry Unfinished Steps…", + "arguments": [] + } + }, + { + "id": "native.reviewApproved", + "description": "Native UI: Review approved", + "platforms": [ + "macos" + ], + "macos": { + "key": "Review approved", + "arguments": [] + } + }, + { + "id": "native.reviewChanges", + "description": "Native UI: Review changes", + "platforms": [ + "macos" + ], + "macos": { + "key": "Review changes", + "arguments": [] + } + }, + { + "id": "native.reviewCommentSubmitted", + "description": "Native UI: Review comment submitted", + "platforms": [ + "macos" + ], + "macos": { + "key": "Review comment submitted", + "arguments": [] + } + }, + { + "id": "native.reviewCommitAndPush", + "description": "Native UI: Review commit and push", + "platforms": [ + "macos" + ], + "macos": { + "key": "Review commit and push", + "arguments": [] + } + }, + { + "id": "native.reviewForceRemove", + "description": "Native UI: Review Force Remove…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Review Force Remove…", + "arguments": [] + } + }, + { + "id": "native.reviewRemainingSteps", + "description": "Native UI: Review remaining steps", + "platforms": [ + "macos" + ], + "macos": { + "key": "Review remaining steps", + "arguments": [] + } + }, + { + "id": "native.reviewRepositoryCommits", + "description": "Native UI: Review repository commits", + "platforms": [ + "macos" + ], + "macos": { + "key": "Review repository commits", + "arguments": [] + } + }, + { + "id": "native.reviewTheFilesAndDiffBeforeApplyingTheRepositoryIsCheckedAgainWhenYouConfirm", + "description": "Native UI: Review the files and diff before applying. The repository is checked again when you confirm.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Review the files and diff before applying. The repository is checked again when you confirm.", + "arguments": [] + } + }, + { + "id": "native.reword", + "description": "Native UI: Reword", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reword", + "arguments": [] + } + }, + { + "id": "native.rewriteCommitsAfterTheSelectedBaseThroughHEADTheBaseCommitStaysInPlaceStepsRunFromTopToBottom", + "description": "Native UI: Rewrite commits after the selected base through HEAD. The base commit stays in place. Steps run from top to bottom.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Rewrite commits after the selected base through HEAD. The base commit stays in place. Steps run from top to bottom.", + "arguments": [] + } + }, + { + "id": "native.rightClickAFieldWhilePausedToAddABreakpoint", + "description": "Native UI: Right-click a field while paused to add a breakpoint", + "platforms": [ + "macos" + ], + "macos": { + "key": "Right-click a field while paused to add a breakpoint", + "arguments": [] + } + }, + { + "id": "native.role", + "description": "Native UI: Role", + "platforms": [ + "macos" + ], + "macos": { + "key": "Role", + "arguments": [] + } + }, + { + "id": "native.rollBack", + "description": "Native UI: Roll Back", + "platforms": [ + "macos" + ], + "macos": { + "key": "Roll Back", + "arguments": [] + } + }, + { + "id": "native.rollBack.variant2", + "description": "Native UI: Roll Back…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Roll Back…", + "arguments": [] + } + }, + { + "id": "native.rollBack.variant3", + "description": "Native UI: Roll back", + "platforms": [ + "macos" + ], + "macos": { + "key": "Roll back", + "arguments": [] + } + }, + { + "id": "native.rollBackAgentChanges", + "description": "Native UI: Roll back Agent changes", + "platforms": [ + "macos" + ], + "macos": { + "key": "Roll back Agent changes", + "arguments": [] + } + }, + { + "id": "native.rollBackAgentChanges.variant2", + "description": "Native UI: Roll back Agent changes?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Roll back Agent changes?", + "arguments": [] + } + }, + { + "id": "native.rollBackAll", + "description": "Native UI: Roll back all", + "platforms": [ + "macos" + ], + "macos": { + "key": "Roll back all", + "arguments": [] + } + }, + { + "id": "native.rollBackTo", + "description": "Native UI: Roll back %@ to %@?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Roll back %@ to %@?", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string" + } + }, + { + "id": "native.rollbackCouldNotRestoreTheChangedFileItRemainsAtRestoreItManuallyBeforeRetrying", + "description": "Native UI: Rollback could not restore the changed file. It remains at %@. Restore it manually before retrying.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Rollback could not restore the changed file. It remains at %@. Restore it manually before retrying.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.rollbackCouldNotVerifyTheMovedFileCheckTheTrashBeforeRetrying", + "description": "Native UI: Rollback could not verify the moved file. Check the Trash before retrying.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Rollback could not verify the moved file. Check the Trash before retrying.", + "arguments": [] + } + }, + { + "id": "native.routesAPIKeyAndCodexSubscriptionRequestsThroughALocalProxyOnThisMacToShowModel6781e5aa03", + "description": "Native UI: Routes API-key and Codex subscription requests through a local proxy on this Mac to show model calls, time to first token, output speed and context composition. Requests and credentials are forwarded unchanged and never stored. Applies after reconnecting.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Routes API-key and Codex subscription requests through a local proxy on this Mac to show model calls, time to first token, output speed and context composition. Requests and credentials are forwarded unchanged and never stored. Applies after reconnecting.", + "arguments": [] + } + }, + { + "id": "native.rubySupport", + "description": "Native UI: Ruby Support", + "platforms": [ + "macos" + ], + "macos": { + "key": "Ruby Support", + "arguments": [] + } + }, + { + "id": "native.runABuildTaskToSeeOutput", + "description": "Native UI: Run a build task to see output.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Run a build task to see output.", + "arguments": [] + } + }, + { + "id": "native.runAConfigurationToSeeProcessOutput", + "description": "Native UI: Run a configuration to see process output.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Run a configuration to see process output.", + "arguments": [] + } + }, + { + "id": "native.runAMavenLifecyclePhaseToSeeOutput", + "description": "Native UI: Run a Maven lifecycle phase to see output.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Run a Maven lifecycle phase to see output.", + "arguments": [] + } + }, + { + "id": "native.runAllServices", + "description": "Native UI: Run all services", + "platforms": [ + "macos" + ], + "macos": { + "key": "Run all services", + "arguments": [] + } + }, + { + "id": "native.runAllServices.variant2", + "description": "Native UI: Run all services?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Run all services?", + "arguments": [] + } + }, + { + "id": "native.runAllTests", + "description": "Native UI: Run All Tests", + "platforms": [ + "macos" + ], + "macos": { + "key": "Run All Tests", + "arguments": [] + } + }, + { + "id": "native.runAnotherACPAgentYouInstalledYourselfItMustSupportSigningInWithACustomAPIKeyGateway", + "description": "Native UI: Run another ACP Agent you installed yourself. It must support signing in with a custom API key gateway.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Run another ACP Agent you installed yourself. It must support signing in with a custom API key gateway.", + "arguments": [] + } + }, + { + "id": "native.runBrewUpdateManuallyThenCheckAgainForUpdates", + "description": "Native UI: Run `brew update` manually, then check again for updates.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Run `brew update` manually, then check again for updates.", + "arguments": [] + } + }, + { + "id": "native.runCheckedConfigurationsWithoutRestartingRunningServices", + "description": "Native UI: Run checked configurations without restarting running services", + "platforms": [ + "macos" + ], + "macos": { + "key": "Run checked configurations without restarting running services", + "arguments": [] + } + }, + { + "id": "native.runConfiguration", + "description": "Native UI: Run Configuration", + "platforms": [ + "macos" + ], + "macos": { + "key": "Run Configuration", + "arguments": [] + } + }, + { + "id": "native.runConfiguration.variant2", + "description": "Native UI: Run configuration", + "platforms": [ + "macos" + ], + "macos": { + "key": "Run configuration", + "arguments": [] + } + }, + { + "id": "native.runConfiguration.variant3", + "description": "Native UI: run configuration", + "platforms": [ + "macos" + ], + "macos": { + "key": "run configuration", + "arguments": [] + } + }, + { + "id": "native.runConfigurations", + "description": "Native UI: Run configurations", + "platforms": [ + "macos" + ], + "macos": { + "key": "Run configurations", + "arguments": [] + } + }, + { + "id": "native.runConfigurationsAndMavenGoalsStartedInLitheTheyAlwaysStopWhenTheirProjectOrWi2085dbd360", + "description": "Native UI: Run configurations and Maven goals started in Lithe. They always stop when their project or window closes or Lithe quits, together with the processes they started; this only decides whether Lithe lists them and asks first.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Run configurations and Maven goals started in Lithe. They always stop when their project or window closes or Lithe quits, together with the processes they started; this only decides whether Lithe lists them and asks first.", + "arguments": [] + } + }, + { + "id": "native.runConfigurationsAreUnavailableEnableTheExecutionModuleAndTryAgain", + "description": "Native UI: Run configurations are unavailable. Enable the execution module and try again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Run configurations are unavailable. Enable the execution module and try again.", + "arguments": [] + } + }, + { + "id": "native.runConfigurationsInheritTheProjectJDKAndMavenUnlessOverriddenBelow", + "description": "Native UI: Run configurations inherit the project JDK and Maven unless overridden below.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Run configurations inherit the project JDK and Maven unless overridden below.", + "arguments": [] + } + }, + { + "id": "native.runConfigurationsMayBeOutOfDate", + "description": "Native UI: Run configurations may be out of date", + "platforms": [ + "macos" + ], + "macos": { + "key": "Run configurations may be out of date", + "arguments": [] + } + }, + { + "id": "native.runSelected", + "description": "Native UI: Run selected", + "platforms": [ + "macos" + ], + "macos": { + "key": "Run selected", + "arguments": [] + } + }, + { + "id": "native.runSelectedConfiguration", + "description": "Native UI: Run selected configuration", + "platforms": [ + "macos" + ], + "macos": { + "key": "Run selected configuration", + "arguments": [] + } + }, + { + "id": "native.runSelectedMavenLifecyclePhase", + "description": "Native UI: Run selected Maven lifecycle phase", + "platforms": [ + "macos" + ], + "macos": { + "key": "Run selected Maven lifecycle phase", + "arguments": [] + } + }, + { + "id": "native.runSelectedServices", + "description": "Native UI: Run selected services", + "platforms": [ + "macos" + ], + "macos": { + "key": "Run selected services", + "arguments": [] + } + }, + { + "id": "native.runSelectedTask", + "description": "Native UI: Run selected task", + "platforms": [ + "macos" + ], + "macos": { + "key": "Run selected task", + "arguments": [] + } + }, + { + "id": "native.runServices", + "description": "Native UI: Run services", + "platforms": [ + "macos" + ], + "macos": { + "key": "Run services", + "arguments": [] + } + }, + { + "id": "native.runServicesAreStillRunning", + "description": "Native UI: Run services are still running", + "platforms": [ + "macos" + ], + "macos": { + "key": "Run services are still running", + "arguments": [] + } + }, + { + "id": "native.runTests", + "description": "Native UI: Run Tests", + "platforms": [ + "macos" + ], + "macos": { + "key": "Run Tests", + "arguments": [] + } + }, + { + "id": "native.runTests.variant2", + "description": "Native UI: Run tests", + "platforms": [ + "macos" + ], + "macos": { + "key": "Run tests", + "arguments": [] + } + }, + { + "id": "native.runTestsToViewProcessPerformance", + "description": "Native UI: Run tests to view process performance.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Run tests to view process performance.", + "arguments": [] + } + }, + { + "id": "native.runYourFiles", + "description": "Native UI: Run your files", + "platforms": [ + "macos" + ], + "macos": { + "key": "Run your files", + "arguments": [] + } + }, + { + "id": "native.runYourTests", + "description": "Native UI: Run your tests", + "platforms": [ + "macos" + ], + "macos": { + "key": "Run your tests", + "arguments": [] + } + }, + { + "id": "native.running", + "description": "Native UI: Running", + "platforms": [ + "macos" + ], + "macos": { + "key": "Running", + "arguments": [] + } + }, + { + "id": "native.runningFor", + "description": "Native UI: running for %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "running for %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.runningGitWaitingForOutput", + "description": "Native UI: Running Git; waiting for output.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Running Git; waiting for output.", + "arguments": [] + } + }, + { + "id": "native.runningServices", + "description": "Native UI: Running services", + "platforms": [ + "macos" + ], + "macos": { + "key": "Running services", + "arguments": [] + } + }, + { + "id": "native.runningTime", + "description": "Native UI: Running Time", + "platforms": [ + "macos" + ], + "macos": { + "key": "Running Time", + "arguments": [] + } + }, + { + "id": "native.runningTools", + "description": "Native UI: Running tools…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Running tools…", + "arguments": [] + } + }, + { + "id": "native.runsFiles", + "description": "Native UI: Runs %@ files", + "platforms": [ + "macos" + ], + "macos": { + "key": "Runs %@ files", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.runsOnInstalledOnThisMac", + "description": "Native UI: Runs on %@ installed on this Mac", + "platforms": [ + "macos" + ], + "macos": { + "key": "Runs on %@ installed on this Mac", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.runsOnShippedWithThePlugin", + "description": "Native UI: Runs on %@ shipped with the plugin", + "platforms": [ + "macos" + ], + "macos": { + "key": "Runs on %@ shipped with the plugin", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.runsTests", + "description": "Native UI: Runs %@ tests", + "platforms": [ + "macos" + ], + "macos": { + "key": "Runs %@ tests", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.runsTheInstalledOnThisMacThroughTheAdapter", + "description": "Native UI: Runs the %@ installed on this Mac through the %@ adapter.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Runs the %@ installed on this Mac through the %@ adapter.", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string" + } + }, + { + "id": "native.runtime", + "description": "Native UI: Runtime", + "platforms": [ + "macos" + ], + "macos": { + "key": "Runtime", + "arguments": [] + } + }, + { + "id": "native.runtimeUsedByTheLanguageServerInstalledOnThisMac", + "description": "Native UI: Runtime used by the language server (installed on this Mac)", + "platforms": [ + "macos" + ], + "macos": { + "key": "Runtime used by the language server (installed on this Mac)", + "arguments": [] + } + }, + { + "id": "native.rustSupport", + "description": "Native UI: Rust Support", + "platforms": [ + "macos" + ], + "macos": { + "key": "Rust Support", + "arguments": [] + } + }, + { + "id": "native.s", + "description": "Native UI: %@s", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@s", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.sampleInterval", + "description": "Native UI: Sample interval", + "platforms": [ + "macos" + ], + "macos": { + "key": "Sample interval", + "arguments": [] + } + }, + { + "id": "native.saveAfter", + "description": "Native UI: Save after", + "platforms": [ + "macos" + ], + "macos": { + "key": "Save after", + "arguments": [] + } + }, + { + "id": "native.saveChangedFilesAutomatically", + "description": "Native UI: Save changed files automatically", + "platforms": [ + "macos" + ], + "macos": { + "key": "Save changed files automatically", + "arguments": [] + } + }, + { + "id": "native.saveChanges", + "description": "Native UI: Save Changes", + "platforms": [ + "macos" + ], + "macos": { + "key": "Save Changes", + "arguments": [] + } + }, + { + "id": "native.saveEachFieldSeparatelySwitchingScopeReloadsTheSavedValues", + "description": "Native UI: Save each field separately. Switching scope reloads the saved values.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Save each field separately. Switching scope reloads the saved values.", + "arguments": [] + } + }, + { + "id": "native.saveKey", + "description": "Native UI: Save Key", + "platforms": [ + "macos" + ], + "macos": { + "key": "Save Key", + "arguments": [] + } + }, + { + "id": "native.saveLocalChangesWith", + "description": "Native UI: Save local changes with", + "platforms": [ + "macos" + ], + "macos": { + "key": "Save local changes with", + "arguments": [] + } + }, + { + "id": "native.saveMessage", + "description": "Native UI: Save Message", + "platforms": [ + "macos" + ], + "macos": { + "key": "Save Message", + "arguments": [] + } + }, + { + "id": "native.saveMessage.variant2", + "description": "Native UI: Save message", + "platforms": [ + "macos" + ], + "macos": { + "key": "Save message", + "arguments": [] + } + }, + { + "id": "native.saveMetadata", + "description": "Native UI: Save Metadata", + "platforms": [ + "macos" + ], + "macos": { + "key": "Save Metadata", + "arguments": [] + } + }, + { + "id": "native.saveOrDiscardUnsavedFilesBeforeDeletingThisItem", + "description": "Native UI: Save or discard unsaved files before deleting this item", + "platforms": [ + "macos" + ], + "macos": { + "key": "Save or discard unsaved files before deleting this item", + "arguments": [] + } + }, + { + "id": "native.savePatch", + "description": "Native UI: Save Patch…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Save Patch…", + "arguments": [] + } + }, + { + "id": "native.saveScope", + "description": "Native UI: Save scope", + "platforms": [ + "macos" + ], + "macos": { + "key": "Save scope", + "arguments": [] + } + }, + { + "id": "native.saveTheActiveDocument", + "description": "Native UI: Save the active document", + "platforms": [ + "macos" + ], + "macos": { + "key": "Save the active document", + "arguments": [] + } + }, + { + "id": "native.saveTitle", + "description": "Native UI: Save title", + "platforms": [ + "macos" + ], + "macos": { + "key": "Save title", + "arguments": [] + } + }, + { + "id": "native.saved", + "description": "Native UI: Saved", + "platforms": [ + "macos" + ], + "macos": { + "key": "Saved", + "arguments": [] + } + }, + { + "id": "native.savedInLitheRunConfigurationsJsonForTheWholeTeamLocalJDKPathsAreNeverShared", + "description": "Native UI: Saved in .lithe/run/configurations.json for the whole team. Local JDK paths are never shared.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Saved in .lithe/run/configurations.json for the whole team. Local JDK paths are never shared.", + "arguments": [] + } + }, + { + "id": "native.savedInLitheRunLocalJsonAndExcludedFromGit", + "description": "Native UI: Saved in .lithe/run/local.json and excluded from Git.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Saved in .lithe/run/local.json and excluded from Git.", + "arguments": [] + } + }, + { + "id": "native.savedOnlyForThisProjectOnThisComputerInLitheRunLocalJsonAndExcludedFromGit", + "description": "Native UI: Saved only for this project on this computer in .lithe/run/local.json and excluded from Git.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Saved only for this project on this computer in .lithe/run/local.json and excluded from Git.", + "arguments": [] + } + }, + { + "id": "native.savedTo", + "description": "Native UI: Saved to %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Saved to %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.savingReplacesTheCurrentGitHubMetadata", + "description": "Native UI: Saving replaces the current GitHub metadata.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Saving replaces the current GitHub metadata.", + "arguments": [] + } + }, + { + "id": "native.savingWritesTo", + "description": "Native UI: Saving writes to", + "platforms": [ + "macos" + ], + "macos": { + "key": "Saving writes to", + "arguments": [] + } + }, + { + "id": "native.says", + "description": "Native UI: “%@” says", + "platforms": [ + "macos" + ], + "macos": { + "key": "“%@” says", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.scalaSupport", + "description": "Native UI: Scala Support", + "platforms": [ + "macos" + ], + "macos": { + "key": "Scala Support", + "arguments": [] + } + }, + { + "id": "native.scopeManagedFrom", + "description": "Native UI: scope managed from %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "scope managed from %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.scopeNotUpdatedTo", + "description": "Native UI: scope not updated to %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "scope not updated to %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.scopeUpdatedFrom", + "description": "Native UI: scope updated from %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "scope updated from %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.scrollToNewGitOutput", + "description": "Native UI: Scroll to new Git output", + "platforms": [ + "macos" + ], + "macos": { + "key": "Scroll to new Git output", + "arguments": [] + } + }, + { + "id": "native.scrollback", + "description": "Native UI: Scrollback", + "platforms": [ + "macos" + ], + "macos": { + "key": "Scrollback", + "arguments": [] + } + }, + { + "id": "native.searchAcrossTheProject", + "description": "Native UI: Search across the project", + "platforms": [ + "macos" + ], + "macos": { + "key": "Search across the project", + "arguments": [] + } + }, + { + "id": "native.searchActionsOrShortcuts", + "description": "Native UI: Search actions or shortcuts", + "platforms": [ + "macos" + ], + "macos": { + "key": "Search actions or shortcuts", + "arguments": [] + } + }, + { + "id": "native.searchBranches", + "description": "Native UI: Search branches", + "platforms": [ + "macos" + ], + "macos": { + "key": "Search branches", + "arguments": [] + } + }, + { + "id": "native.searchCommits", + "description": "Native UI: Search commits", + "platforms": [ + "macos" + ], + "macos": { + "key": "Search commits", + "arguments": [] + } + }, + { + "id": "native.searchConnections", + "description": "Native UI: Search connections", + "platforms": [ + "macos" + ], + "macos": { + "key": "Search connections", + "arguments": [] + } + }, + { + "id": "native.searchConversation", + "description": "Native UI: Search conversation", + "platforms": [ + "macos" + ], + "macos": { + "key": "Search conversation", + "arguments": [] + } + }, + { + "id": "native.searchConversationTitlesOrIDs", + "description": "Native UI: Search conversation titles or IDs…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Search conversation titles or IDs…", + "arguments": [] + } + }, + { + "id": "native.searchDiff", + "description": "Native UI: Search diff", + "platforms": [ + "macos" + ], + "macos": { + "key": "Search diff", + "arguments": [] + } + }, + { + "id": "native.searchEverywhere", + "description": "Native UI: Search Everywhere", + "platforms": [ + "macos" + ], + "macos": { + "key": "Search Everywhere", + "arguments": [] + } + }, + { + "id": "native.searchEverywhere.variant2", + "description": "Native UI: Search Everywhere…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Search Everywhere…", + "arguments": [] + } + }, + { + "id": "native.searchEverywhereDoubleShift", + "description": "Native UI: Search Everywhere (Double Shift)", + "platforms": [ + "macos" + ], + "macos": { + "key": "Search Everywhere (Double Shift)", + "arguments": [] + } + }, + { + "id": "native.searchExclusions", + "description": "Native UI: Search exclusions", + "platforms": [ + "macos" + ], + "macos": { + "key": "Search exclusions", + "arguments": [] + } + }, + { + "id": "native.searchFilesAndContents", + "description": "Native UI: Search files and contents", + "platforms": [ + "macos" + ], + "macos": { + "key": "Search files and contents", + "arguments": [] + } + }, + { + "id": "native.searchFonts", + "description": "Native UI: Search fonts", + "platforms": [ + "macos" + ], + "macos": { + "key": "Search fonts", + "arguments": [] + } + }, + { + "id": "native.searchForBranchesAndActions", + "description": "Native UI: Search for branches and actions", + "platforms": [ + "macos" + ], + "macos": { + "key": "Search for branches and actions", + "arguments": [] + } + }, + { + "id": "native.searchImplementations", + "description": "Native UI: Search implementations", + "platforms": [ + "macos" + ], + "macos": { + "key": "Search implementations", + "arguments": [] + } + }, + { + "id": "native.searchInProject", + "description": "Native UI: Search in Project", + "platforms": [ + "macos" + ], + "macos": { + "key": "Search in Project", + "arguments": [] + } + }, + { + "id": "native.searchOptions", + "description": "Native UI: Search options", + "platforms": [ + "macos" + ], + "macos": { + "key": "Search options", + "arguments": [] + } + }, + { + "id": "native.searchPaths", + "description": "Native UI: Search Paths: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Search Paths: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.searchPlugins", + "description": "Native UI: Search plugins", + "platforms": [ + "macos" + ], + "macos": { + "key": "Search plugins", + "arguments": [] + } + }, + { + "id": "native.searchProjects", + "description": "Native UI: Search projects", + "platforms": [ + "macos" + ], + "macos": { + "key": "Search projects", + "arguments": [] + } + }, + { + "id": "native.searchTextAcrossTheWorkspace", + "description": "Native UI: Search text across the workspace", + "platforms": [ + "macos" + ], + "macos": { + "key": "Search text across the workspace", + "arguments": [] + } + }, + { + "id": "native.searchUsers", + "description": "Native UI: Search users", + "platforms": [ + "macos" + ], + "macos": { + "key": "Search users", + "arguments": [] + } + }, + { + "id": "native.searchWithinTheActiveEditor", + "description": "Native UI: Search within the active editor", + "platforms": [ + "macos" + ], + "macos": { + "key": "Search within the active editor", + "arguments": [] + } + }, + { + "id": "native.searchWorktreeOrPath", + "description": "Native UI: Search worktree or path", + "platforms": [ + "macos" + ], + "macos": { + "key": "Search worktree or path", + "arguments": [] + } + }, + { + "id": "native.searchedLldTime", + "description": "Native UI: Searched %lld time", + "platforms": [ + "macos" + ], + "macos": { + "key": "Searched %lld time", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.searchedLldTimes", + "description": "Native UI: Searched %lld times", + "platforms": [ + "macos" + ], + "macos": { + "key": "Searched %lld times", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.select", + "description": "Native UI: Select", + "platforms": [ + "macos" + ], + "macos": { + "key": "Select", + "arguments": [] + } + }, + { + "id": "native.select.variant2", + "description": "Native UI: Select %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Select %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.selectAChangedFileFirst", + "description": "Native UI: Select a changed file first", + "platforms": [ + "macos" + ], + "macos": { + "key": "Select a changed file first", + "arguments": [] + } + }, + { + "id": "native.selectACommit", + "description": "Native UI: Select a commit", + "platforms": [ + "macos" + ], + "macos": { + "key": "Select a commit", + "arguments": [] + } + }, + { + "id": "native.selectAFile", + "description": "Native UI: Select a file", + "platforms": [ + "macos" + ], + "macos": { + "key": "Select a file", + "arguments": [] + } + }, + { + "id": "native.selectAFileToPreview", + "description": "Native UI: Select a file to preview", + "platforms": [ + "macos" + ], + "macos": { + "key": "Select a file to preview", + "arguments": [] + } + }, + { + "id": "native.selectAFileToReview", + "description": "Native UI: Select a file to review", + "platforms": [ + "macos" + ], + "macos": { + "key": "Select a file to review", + "arguments": [] + } + }, + { + "id": "native.selectAHistoryEntry", + "description": "Native UI: Select a history entry", + "platforms": [ + "macos" + ], + "macos": { + "key": "Select a history entry", + "arguments": [] + } + }, + { + "id": "native.selectAJavaFileBeforeRunningCurrentFile", + "description": "Native UI: Select a Java file before running Current File.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Select a Java file before running Current File.", + "arguments": [] + } + }, + { + "id": "native.selectAPHPSupportPluginPackage", + "description": "Native UI: Select a PHP Support plugin package.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Select a PHP Support plugin package.", + "arguments": [] + } + }, + { + "id": "native.selectAPlugin", + "description": "Native UI: Select a plugin", + "platforms": [ + "macos" + ], + "macos": { + "key": "Select a plugin", + "arguments": [] + } + }, + { + "id": "native.selectAProjectHistoryEntry", + "description": "Native UI: Select a project history entry", + "platforms": [ + "macos" + ], + "macos": { + "key": "Select a project history entry", + "arguments": [] + } + }, + { + "id": "native.selectAPullRequest", + "description": "Native UI: Select a pull request", + "platforms": [ + "macos" + ], + "macos": { + "key": "Select a pull request", + "arguments": [] + } + }, + { + "id": "native.selectARunConfigurationToSeeItsOutput", + "description": "Native UI: Select a run configuration to see its output.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Select a run configuration to see its output.", + "arguments": [] + } + }, + { + "id": "native.selectAServiceOrTaskToConfigureItsArgumentsEnvironmentAndProjectEnvironmentOveafafee2e2f", + "description": "Native UI: Select a service or task to configure its arguments, environment and project-environment overrides. Changes apply after Save.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Select a service or task to configure its arguments, environment and project-environment overrides. Changes apply after Save.", + "arguments": [] + } + }, + { + "id": "native.selectASpringBootOrMavenModuleConfiguration", + "description": "Native UI: Select a Spring Boot or Maven Module configuration", + "platforms": [ + "macos" + ], + "macos": { + "key": "Select a Spring Boot or Maven Module configuration", + "arguments": [] + } + }, + { + "id": "native.selectASpringBootOrMavenModuleConfigurationBeforeStartingDebug", + "description": "Native UI: Select a Spring Boot or Maven Module configuration before starting Debug", + "platforms": [ + "macos" + ], + "macos": { + "key": "Select a Spring Boot or Maven Module configuration before starting Debug", + "arguments": [] + } + }, + { + "id": "native.selectAWorktree", + "description": "Native UI: Select a worktree", + "platforms": [ + "macos" + ], + "macos": { + "key": "Select a worktree", + "arguments": [] + } + }, + { + "id": "native.selectAll", + "description": "Native UI: Select all", + "platforms": [ + "macos" + ], + "macos": { + "key": "Select all", + "arguments": [] + } + }, + { + "id": "native.selectBranch", + "description": "Native UI: Select branch", + "platforms": [ + "macos" + ], + "macos": { + "key": "Select branch", + "arguments": [] + } + }, + { + "id": "native.selectConfigurationsForBatchActions", + "description": "Native UI: Select configurations for batch actions", + "platforms": [ + "macos" + ], + "macos": { + "key": "Select configurations for batch actions", + "arguments": [] + } + }, + { + "id": "native.selectConversation", + "description": "Native UI: Select conversation", + "platforms": [ + "macos" + ], + "macos": { + "key": "Select conversation", + "arguments": [] + } + }, + { + "id": "native.selectDebugInTheEditorContextMenu", + "description": "Native UI: — Select \"Debug...\" in the editor context menu", + "platforms": [ + "macos" + ], + "macos": { + "key": "— Select \"Debug...\" in the editor context menu", + "arguments": [] + } + }, + { + "id": "native.selectForCompare", + "description": "Native UI: Select for Compare", + "platforms": [ + "macos" + ], + "macos": { + "key": "Select for Compare", + "arguments": [] + } + }, + { + "id": "native.selectRepositoryForCommitsAndBranchOperations", + "description": "Native UI: Select repository for commits and branch operations", + "platforms": [ + "macos" + ], + "macos": { + "key": "Select repository for commits and branch operations", + "arguments": [] + } + }, + { + "id": "native.selectRunConfiguration", + "description": "Native UI: Select run configuration", + "platforms": [ + "macos" + ], + "macos": { + "key": "Select run configuration", + "arguments": [] + } + }, + { + "id": "native.selectSavedChanges", + "description": "Native UI: Select saved changes", + "platforms": [ + "macos" + ], + "macos": { + "key": "Select saved changes", + "arguments": [] + } + }, + { + "id": "native.selectShell", + "description": "Native UI: Select shell", + "platforms": [ + "macos" + ], + "macos": { + "key": "Select shell", + "arguments": [] + } + }, + { + "id": "native.selectTestJVM", + "description": "Native UI: Select test JVM", + "platforms": [ + "macos" + ], + "macos": { + "key": "Select test JVM", + "arguments": [] + } + }, + { + "id": "native.selectTheChangeListToCommit", + "description": "Native UI: Select the ChangeList to commit", + "platforms": [ + "macos" + ], + "macos": { + "key": "Select the ChangeList to commit", + "arguments": [] + } + }, + { + "id": "native.selected", + "description": "Native UI: Selected", + "platforms": [ + "macos" + ], + "macos": { + "key": "Selected", + "arguments": [] + } + }, + { + "id": "native.selectedChangeListOrStagedFilesChangedBeforeGenerationFinished", + "description": "Native UI: Selected ChangeList or staged files changed before generation finished", + "platforms": [ + "macos" + ], + "macos": { + "key": "Selected ChangeList or staged files changed before generation finished", + "arguments": [] + } + }, + { + "id": "native.selectedDirectory", + "description": "Native UI: Selected directory", + "platforms": [ + "macos" + ], + "macos": { + "key": "Selected directory", + "arguments": [] + } + }, + { + "id": "native.selectedFromDiskThePublisherAndRepositoryHaveNotBeenVerified", + "description": "Native UI: Selected from disk. The publisher and repository have not been verified.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Selected from disk. The publisher and repository have not been verified.", + "arguments": [] + } + }, + { + "id": "native.selectedRuntime", + "description": "Native UI: Selected runtime", + "platforms": [ + "macos" + ], + "macos": { + "key": "Selected runtime", + "arguments": [] + } + }, + { + "id": "native.selectedVersion", + "description": "Native UI: selected version", + "platforms": [ + "macos" + ], + "macos": { + "key": "selected version", + "arguments": [] + } + }, + { + "id": "native.selfUpdateIsOnlyAvailableWhenLitheIsRunningFromAPackagedLitheApp", + "description": "Native UI: Self-update is only available when Lithe is running from a packaged Lithe.app.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Self-update is only available when Lithe is running from a packaged Lithe.app.", + "arguments": [] + } + }, + { + "id": "native.send", + "description": "Native UI: Send", + "platforms": [ + "macos" + ], + "macos": { + "key": "Send", + "arguments": [] + } + }, + { + "id": "native.sendAMessageTo", + "description": "Native UI: Send a message to %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Send a message to %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.sendInputToDebuggee", + "description": "Native UI: Send input to debuggee", + "platforms": [ + "macos" + ], + "macos": { + "key": "Send input to debuggee", + "arguments": [] + } + }, + { + "id": "native.sendProgramInput", + "description": "Native UI: Send program input", + "platforms": [ + "macos" + ], + "macos": { + "key": "Send program input", + "arguments": [] + } + }, + { + "id": "native.sending", + "description": "Native UI: Sending…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Sending…", + "arguments": [] + } + }, + { + "id": "native.sensitiveFilesAreNotSentToAnAIProvider", + "description": "Native UI: Sensitive files are not sent to an AI provider.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Sensitive files are not sent to an AI provider.", + "arguments": [] + } + }, + { + "id": "native.separateArgumentsWithSpacesQuoteAnArgumentThatContainsSpaces", + "description": "Native UI: Separate arguments with spaces; quote an argument that contains spaces.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Separate arguments with spaces; quote an argument that contains spaces.", + "arguments": [] + } + }, + { + "id": "native.server", + "description": "Native UI: Server", + "platforms": [ + "macos" + ], + "macos": { + "key": "Server", + "arguments": [] + } + }, + { + "id": "native.serverLibrary", + "description": "Native UI: Server library", + "platforms": [ + "macos" + ], + "macos": { + "key": "Server library", + "arguments": [] + } + }, + { + "id": "native.services", + "description": "Native UI: Services", + "platforms": [ + "macos" + ], + "macos": { + "key": "Services", + "arguments": [] + } + }, + { + "id": "native.session", + "description": "Native UI: Session", + "platforms": [ + "macos" + ], + "macos": { + "key": "Session", + "arguments": [] + } + }, + { + "id": "native.sessionStatistics", + "description": "Native UI: Session statistics", + "platforms": [ + "macos" + ], + "macos": { + "key": "Session statistics", + "arguments": [] + } + }, + { + "id": "native.setTheCustomAgentSExecutableInThePanelSettings", + "description": "Native UI: Set the custom Agent's executable in the panel settings.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Set the custom Agent's executable in the panel settings.", + "arguments": [] + } + }, + { + "id": "native.setTheDefaultsUsedByOrdinaryFetchInEveryProjectCredentialsUseYourExistingGitHea37790d0a5", + "description": "Native UI: Set the defaults used by ordinary Fetch in every project. Credentials use your existing Git helper and SSH configuration.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Set the defaults used by ordinary Fetch in every project. Credentials use your existing Git helper and SSH configuration.", + "arguments": [] + } + }, + { + "id": "native.setUpAgentFileAttachments", + "description": "Native UI: Set Up Agent File Attachments…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Set Up Agent File Attachments…", + "arguments": [] + } + }, + { + "id": "native.setYourGitNameAndEmailInSettingsBeforeCommitting", + "description": "Native UI: Set your Git name and email in Settings before committing.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Set your Git name and email in Settings before committing.", + "arguments": [] + } + }, + { + "id": "native.setsAsTheUpstream", + "description": "Native UI: Sets %@ as the upstream", + "platforms": [ + "macos" + ], + "macos": { + "key": "Sets %@ as the upstream", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.setting", + "description": "Native UI: Setting", + "platforms": [ + "macos" + ], + "macos": { + "key": "Setting", + "arguments": [] + } + }, + { + "id": "native.settingValuesOnlyFillThePlacesTheProgramTemplatesReserveForThemSoChangingASetta1ef53141a", + "description": "Native UI: Setting values only fill the places the program templates reserve for them, so changing a setting never changes the programs above.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Setting values only fill the places the program templates reserve for them, so changing a setting never changes the programs above.", + "arguments": [] + } + }, + { + "id": "native.settings", + "description": "Native UI: Settings…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Settings…", + "arguments": [] + } + }, + { + "id": "native.sha256", + "description": "Native UI: SHA-256", + "platforms": [ + "macos" + ], + "macos": { + "key": "SHA-256", + "arguments": [] + } + }, + { + "id": "native.shareProjectOn", + "description": "Native UI: Share project on", + "platforms": [ + "macos" + ], + "macos": { + "key": "Share project on", + "arguments": [] + } + }, + { + "id": "native.sharedWithProject", + "description": "Native UI: Shared with project", + "platforms": [ + "macos" + ], + "macos": { + "key": "Shared with project", + "arguments": [] + } + }, + { + "id": "native.shelf", + "description": "Native UI: Shelf", + "platforms": [ + "macos" + ], + "macos": { + "key": "Shelf", + "arguments": [] + } + }, + { + "id": "native.shell", + "description": "Native UI: Shell", + "platforms": [ + "macos" + ], + "macos": { + "key": "Shell", + "arguments": [] + } + }, + { + "id": "native.shellIsNotAvailableAtThisPath", + "description": "Native UI: Shell is not available at this path", + "platforms": [ + "macos" + ], + "macos": { + "key": "Shell is not available at this path", + "arguments": [] + } + }, + { + "id": "native.shellNotFound", + "description": "Native UI: Shell not found", + "platforms": [ + "macos" + ], + "macos": { + "key": "Shell not found", + "arguments": [] + } + }, + { + "id": "native.shellSupport", + "description": "Native UI: Shell Support", + "platforms": [ + "macos" + ], + "macos": { + "key": "Shell Support", + "arguments": [] + } + }, + { + "id": "native.shelveAndContinue", + "description": "Native UI: Shelve and Continue", + "platforms": [ + "macos" + ], + "macos": { + "key": "Shelve and Continue", + "arguments": [] + } + }, + { + "id": "native.shelveChanges", + "description": "Native UI: Shelve Changes", + "platforms": [ + "macos" + ], + "macos": { + "key": "Shelve Changes", + "arguments": [] + } + }, + { + "id": "native.shelvingSavesTheseChangesInLitheRunsTheOperationThenRestoresThemIfConflictsSto789262a3ef", + "description": "Native UI: Shelving saves these changes in Lithe, runs the operation, then restores them. If conflicts stop the operation, the shelf stays saved until you finish it.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Shelving saves these changes in Lithe, runs the operation, then restores them. If conflicts stop the operation, the shelf stays saved until you finish it.", + "arguments": [] + } + }, + { + "id": "native.shipsWithLithe", + "description": "Native UI: Ships with Lithe", + "platforms": [ + "macos" + ], + "macos": { + "key": "Ships with Lithe", + "arguments": [] + } + }, + { + "id": "native.shortcutIsAlreadyAssignedToThisCommand", + "description": "Native UI: Shortcut is already assigned to this command", + "platforms": [ + "macos" + ], + "macos": { + "key": "Shortcut is already assigned to this command", + "arguments": [] + } + }, + { + "id": "native.shortcutNeedsCommandControlOrOption", + "description": "Native UI: Shortcut needs Command, Control, or Option", + "platforms": [ + "macos" + ], + "macos": { + "key": "Shortcut needs Command, Control, or Option", + "arguments": [] + } + }, + { + "id": "native.show", + "description": "Native UI: Show", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show", + "arguments": [] + } + }, + { + "id": "native.showAPIKey", + "description": "Native UI: Show API key", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show API key", + "arguments": [] + } + }, + { + "id": "native.showAgentDiff", + "description": "Native UI: Show Agent diff", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show Agent diff", + "arguments": [] + } + }, + { + "id": "native.showAllChanges", + "description": "Native UI: Show all changes", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show all changes", + "arguments": [] + } + }, + { + "id": "native.showAllLldLines", + "description": "Native UI: Show all %lld lines", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show all %lld lines", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.showAllReferences", + "description": "Native UI: Show all references", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show all references", + "arguments": [] + } + }, + { + "id": "native.showAsJSON", + "description": "Native UI: Show as JSON", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show as JSON", + "arguments": [] + } + }, + { + "id": "native.showAsTree", + "description": "Native UI: Show as tree", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show as tree", + "arguments": [] + } + }, + { + "id": "native.showChanges", + "description": "Native UI: Show Changes", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show Changes", + "arguments": [] + } + }, + { + "id": "native.showChatsInTheAIToolWindow", + "description": "Native UI: Show Chats in the AI Tool Window", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show Chats in the AI Tool Window", + "arguments": [] + } + }, + { + "id": "native.showChatsInTheEditor", + "description": "Native UI: Show Chats in the Editor", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show Chats in the Editor", + "arguments": [] + } + }, + { + "id": "native.showCommitDecorations", + "description": "Native UI: Show commit decorations", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show commit decorations", + "arguments": [] + } + }, + { + "id": "native.showCommitDetails", + "description": "Native UI: Show commit details", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show commit details", + "arguments": [] + } + }, + { + "id": "native.showConfigurationList", + "description": "Native UI: Show configuration list", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show configuration list", + "arguments": [] + } + }, + { + "id": "native.showConflictFiles", + "description": "Native UI: Show Conflict Files", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show Conflict Files", + "arguments": [] + } + }, + { + "id": "native.showContextBreakdown", + "description": "Native UI: Show context breakdown", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show context breakdown", + "arguments": [] + } + }, + { + "id": "native.showDependencies", + "description": "Native UI: Show Dependencies", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show Dependencies", + "arguments": [] + } + }, + { + "id": "native.showDiff", + "description": "Native UI: Show Diff", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show Diff", + "arguments": [] + } + }, + { + "id": "native.showDiffWithWorkingTree", + "description": "Native UI: Show Diff with Working Tree", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show Diff with Working Tree", + "arguments": [] + } + }, + { + "id": "native.showEditorPreview", + "description": "Native UI: Show Editor Preview", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show Editor Preview", + "arguments": [] + } + }, + { + "id": "native.showEntry", + "description": "Native UI: Show Entry", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show Entry", + "arguments": [] + } + }, + { + "id": "native.showFileHistory", + "description": "Native UI: Show file history", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show file history", + "arguments": [] + } + }, + { + "id": "native.showGitKeyNames", + "description": "Native UI: Show Git key names", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show Git key names", + "arguments": [] + } + }, + { + "id": "native.showIndexedSpringMVCRoutes", + "description": "Native UI: Show indexed Spring MVC routes", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show indexed Spring MVC routes", + "arguments": [] + } + }, + { + "id": "native.showItsOwnPageInAToolWindow", + "description": "Native UI: Show its own page in a tool window", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show its own page in a tool window", + "arguments": [] + } + }, + { + "id": "native.showLess", + "description": "Native UI: Show less", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show less", + "arguments": [] + } + }, + { + "id": "native.showLldMore", + "description": "Native UI: Show %lld more", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show %lld more", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.showLocalHistory", + "description": "Native UI: Show Local History…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show Local History…", + "arguments": [] + } + }, + { + "id": "native.showLongGraphEdges", + "description": "Native UI: Show long graph edges", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show long graph edges", + "arguments": [] + } + }, + { + "id": "native.showMinimap", + "description": "Native UI: Show minimap", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show minimap", + "arguments": [] + } + }, + { + "id": "native.showOrHideGitHistory", + "description": "Native UI: Show or hide Git history", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show or hide Git history", + "arguments": [] + } + }, + { + "id": "native.showOrHideJavaDiagnostics", + "description": "Native UI: Show or hide Java diagnostics", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show or hide Java diagnostics", + "arguments": [] + } + }, + { + "id": "native.showOrHideLanguageDiagnostics", + "description": "Native UI: Show or hide language diagnostics", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show or hide language diagnostics", + "arguments": [] + } + }, + { + "id": "native.showOrHideLanguageNeutralTestRunners", + "description": "Native UI: Show or hide language-neutral test runners", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show or hide language-neutral test runners", + "arguments": [] + } + }, + { + "id": "native.showOrHideRunOutput", + "description": "Native UI: Show or hide run output", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show or hide run output", + "arguments": [] + } + }, + { + "id": "native.showOrHideTheDebugToolWindow", + "description": "Native UI: Show or hide the Debug tool window", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show or hide the Debug tool window", + "arguments": [] + } + }, + { + "id": "native.showOrHideTheMavenToolWindow", + "description": "Native UI: Show or hide the Maven tool window", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show or hide the Maven tool window", + "arguments": [] + } + }, + { + "id": "native.showOrHideTheTerminalToolWindow", + "description": "Native UI: Show or hide the Terminal tool window", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show or hide the Terminal tool window", + "arguments": [] + } + }, + { + "id": "native.showPlan", + "description": "Native UI: Show plan", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show plan", + "arguments": [] + } + }, + { + "id": "native.showPreview", + "description": "Native UI: Show Preview", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show Preview", + "arguments": [] + } + }, + { + "id": "native.showProjectInFinder", + "description": "Native UI: Show Project in Finder", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show Project in Finder", + "arguments": [] + } + }, + { + "id": "native.showProjectLocalHistory", + "description": "Native UI: Show Project Local History…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show Project Local History…", + "arguments": [] + } + }, + { + "id": "native.showResults", + "description": "Native UI: Show Results", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show Results", + "arguments": [] + } + }, + { + "id": "native.showSessionDetails", + "description": "Native UI: Show Session Details", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show Session Details", + "arguments": [] + } + }, + { + "id": "native.showSourceFileName", + "description": "Native UI: Show source file name", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show source file name", + "arguments": [] + } + }, + { + "id": "native.showTechnicalGitDetails", + "description": "Native UI: Show technical Git details", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show technical Git details", + "arguments": [] + } + }, + { + "id": "native.showTerminal", + "description": "Native UI: Show Terminal", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show Terminal", + "arguments": [] + } + }, + { + "id": "native.showTheActiveFileInFinder", + "description": "Native UI: Show the active file in Finder", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show the active file in Finder", + "arguments": [] + } + }, + { + "id": "native.showThinking", + "description": "Native UI: Show thinking", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show thinking", + "arguments": [] + } + }, + { + "id": "native.showToolDetails", + "description": "Native UI: Show tool details", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show tool details", + "arguments": [] + } + }, + { + "id": "native.showToolbar", + "description": "Native UI: Show Toolbar", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show Toolbar", + "arguments": [] + } + }, + { + "id": "native.showUsagesAndGitAuthor", + "description": "Native UI: Show usages and Git author", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show usages and Git author", + "arguments": [] + } + }, + { + "id": "native.showWorktreeRepositories", + "description": "Native UI: Show worktree repositories", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show worktree repositories", + "arguments": [] + } + }, + { + "id": "native.showingThePreviousJavaEntriesWhileTheJavaLanguageServicePreparesTheProject", + "description": "Native UI: Showing the previous Java entries while the Java language service prepares the project.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Showing the previous Java entries while the Java language service prepares the project.", + "arguments": [] + } + }, + { + "id": "native.shownAcrossTheEntireWorkbenchIncludingToolbarsProjectTreeEditorTabsTerminalsAndStatusBar", + "description": "Native UI: Shown across the entire workbench, including toolbars, project tree, editor, tabs, terminals, and status bar.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Shown across the entire workbench, including toolbars, project tree, editor, tabs, terminals, and status bar.", + "arguments": [] + } + }, + { + "id": "native.sideBySide", + "description": "Native UI: Side-by-side", + "platforms": [ + "macos" + ], + "macos": { + "key": "Side-by-side", + "arguments": [] + } + }, + { + "id": "native.signInToCodex", + "description": "Native UI: Sign in to Codex", + "platforms": [ + "macos" + ], + "macos": { + "key": "Sign in to Codex", + "arguments": [] + } + }, + { + "id": "native.signInToGitHub", + "description": "Native UI: Sign in to GitHub", + "platforms": [ + "macos" + ], + "macos": { + "key": "Sign in to GitHub", + "arguments": [] + } + }, + { + "id": "native.signInToViewAndManagePullRequests", + "description": "Native UI: Sign in to view and manage pull requests.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Sign in to view and manage pull requests.", + "arguments": [] + } + }, + { + "id": "native.signInWithChatGPT", + "description": "Native UI: Sign in with ChatGPT", + "platforms": [ + "macos" + ], + "macos": { + "key": "Sign in with ChatGPT", + "arguments": [] + } + }, + { + "id": "native.signalsThatTheChangesAreReadyToMergeASummaryIsOptional", + "description": "Native UI: Signals that the changes are ready to merge. A summary is optional.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Signals that the changes are ready to merge. A summary is optional.", + "arguments": [] + } + }, + { + "id": "native.signature", + "description": "Native UI: Signature", + "platforms": [ + "macos" + ], + "macos": { + "key": "Signature", + "arguments": [] + } + }, + { + "id": "native.signedInThroughTheACPGatewayWithTheAPIKeyFromYourLocalConfiguration", + "description": "Native UI: Signed in through the ACP gateway with the API key from your local configuration.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Signed in through the ACP gateway with the API key from your local configuration.", + "arguments": [] + } + }, + { + "id": "native.signedInThroughTheACPGatewayWithThisProviderSAPIKey", + "description": "Native UI: Signed in through the ACP gateway with this provider's API key.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Signed in through the ACP gateway with this provider's API key.", + "arguments": [] + } + }, + { + "id": "native.singleFile", + "description": "Native UI: Single file", + "platforms": [ + "macos" + ], + "macos": { + "key": "Single file", + "arguments": [] + } + }, + { + "id": "native.singleRow", + "description": "Native UI: Single row", + "platforms": [ + "macos" + ], + "macos": { + "key": "Single row", + "arguments": [] + } + }, + { + "id": "native.skipBuild", + "description": "Native UI: Skip Build", + "platforms": [ + "macos" + ], + "macos": { + "key": "Skip Build", + "arguments": [] + } + }, + { + "id": "native.skipCommit", + "description": "Native UI: Skip Commit", + "platforms": [ + "macos" + ], + "macos": { + "key": "Skip Commit", + "arguments": [] + } + }, + { + "id": "native.skipCommit.variant2", + "description": "Native UI: Skip Commit…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Skip Commit…", + "arguments": [] + } + }, + { + "id": "native.skipTests", + "description": "Native UI: Skip tests", + "platforms": [ + "macos" + ], + "macos": { + "key": "Skip tests", + "arguments": [] + } + }, + { + "id": "native.skipTheCurrentCommitAndItsUnresolvedChanges", + "description": "Native UI: Skip the current commit and its unresolved changes?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Skip the current commit and its unresolved changes?", + "arguments": [] + } + }, + { + "id": "native.skipVersion", + "description": "Native UI: Skip Version", + "platforms": [ + "macos" + ], + "macos": { + "key": "Skip Version", + "arguments": [] + } + }, + { + "id": "native.skipped", + "description": "Native UI: Skipped", + "platforms": [ + "macos" + ], + "macos": { + "key": "Skipped", + "arguments": [] + } + }, + { + "id": "native.smartCheckout", + "description": "Native UI: Smart Checkout", + "platforms": [ + "macos" + ], + "macos": { + "key": "Smart Checkout", + "arguments": [] + } + }, + { + "id": "native.smartCheckoutShelve", + "description": "Native UI: Smart Checkout (Shelve)", + "platforms": [ + "macos" + ], + "macos": { + "key": "Smart Checkout (Shelve)", + "arguments": [] + } + }, + { + "id": "native.smartCheckoutShelvesYourChangesInLitheSwitchesBranchThenRestoresThemForceCheck60e1561e81", + "description": "Native UI: Smart Checkout shelves your changes in Lithe, switches branch, then restores them. Force Checkout switches and discards them.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Smart Checkout shelves your changes in Lithe, switches branch, then restores them. Force Checkout switches and discards them.", + "arguments": [] + } + }, + { + "id": "native.smartCheckoutStashesYourChangesSwitchesBranchThenRestoresThemForceCheckoutSwit3688f9f903", + "description": "Native UI: Smart Checkout stashes your changes, switches branch, then restores them. Force Checkout switches and discards them.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Smart Checkout stashes your changes, switches branch, then restores them. Force Checkout switches and discards them.", + "arguments": [] + } + }, + { + "id": "native.smartStepInto", + "description": "Native UI: Smart step into", + "platforms": [ + "macos" + ], + "macos": { + "key": "Smart step into", + "arguments": [] + } + }, + { + "id": "native.softResetKeepChangesStaged", + "description": "Native UI: Soft Reset (Keep Changes Staged)", + "platforms": [ + "macos" + ], + "macos": { + "key": "Soft Reset (Keep Changes Staged)", + "arguments": [] + } + }, + { + "id": "native.softWrapIsUnavailableForVeryLargeFiles", + "description": "Native UI: Soft wrap is unavailable for very large files", + "platforms": [ + "macos" + ], + "macos": { + "key": "Soft wrap is unavailable for very large files", + "arguments": [] + } + }, + { + "id": "native.softWraps", + "description": "Native UI: Soft Wraps", + "platforms": [ + "macos" + ], + "macos": { + "key": "Soft Wraps", + "arguments": [] + } + }, + { + "id": "native.softwareUpdate", + "description": "Native UI: Software Update", + "platforms": [ + "macos" + ], + "macos": { + "key": "Software Update", + "arguments": [] + } + }, + { + "id": "native.soliditySupport", + "description": "Native UI: Solidity Support", + "platforms": [ + "macos" + ], + "macos": { + "key": "Solidity Support", + "arguments": [] + } + }, + { + "id": "native.someDependenciesAreNotShown", + "description": "Native UI: Some dependencies are not shown.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Some dependencies are not shown.", + "arguments": [] + } + }, + { + "id": "native.someMCPConfigurationFilesCouldNotBeRestoredRefreshAndCheckTheseFilesBeforeRetrying", + "description": "Native UI: Some MCP configuration files could not be restored: %@. Refresh and check these files before retrying.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Some MCP configuration files could not be restored: %@. Refresh and check these files before retrying.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.sortByLastUpdated", + "description": "Native UI: Sort by Last Updated", + "platforms": [ + "macos" + ], + "macos": { + "key": "Sort by Last Updated", + "arguments": [] + } + }, + { + "id": "native.sortByName", + "description": "Native UI: Sort by Name", + "platforms": [ + "macos" + ], + "macos": { + "key": "Sort by Name", + "arguments": [] + } + }, + { + "id": "native.sortPlugins", + "description": "Native UI: Sort plugins", + "platforms": [ + "macos" + ], + "macos": { + "key": "Sort plugins", + "arguments": [] + } + }, + { + "id": "native.source", + "description": "Native UI: Source", + "platforms": [ + "macos" + ], + "macos": { + "key": "Source", + "arguments": [] + } + }, + { + "id": "native.sourceCode", + "description": "Native UI: Source Code", + "platforms": [ + "macos" + ], + "macos": { + "key": "Source Code", + "arguments": [] + } + }, + { + "id": "native.sourceRepository", + "description": "Native UI: Source Repository", + "platforms": [ + "macos" + ], + "macos": { + "key": "Source Repository", + "arguments": [] + } + }, + { + "id": "native.sourceRoots", + "description": "Native UI: Source Roots", + "platforms": [ + "macos" + ], + "macos": { + "key": "Source Roots", + "arguments": [] + } + }, + { + "id": "native.sourcesRoot", + "description": "Native UI: Sources Root", + "platforms": [ + "macos" + ], + "macos": { + "key": "Sources Root", + "arguments": [] + } + }, + { + "id": "native.speed", + "description": "Native UI: Speed", + "platforms": [ + "macos" + ], + "macos": { + "key": "Speed", + "arguments": [] + } + }, + { + "id": "native.springBoot", + "description": "Native UI: Spring Boot", + "platforms": [ + "macos" + ], + "macos": { + "key": "Spring Boot", + "arguments": [] + } + }, + { + "id": "native.springEndpoints", + "description": "Native UI: Spring Endpoints", + "platforms": [ + "macos" + ], + "macos": { + "key": "Spring Endpoints", + "arguments": [] + } + }, + { + "id": "native.sqlSupport", + "description": "Native UI: SQL Support", + "platforms": [ + "macos" + ], + "macos": { + "key": "SQL Support", + "arguments": [] + } + }, + { + "id": "native.squash", + "description": "Native UI: Squash", + "platforms": [ + "macos" + ], + "macos": { + "key": "Squash", + "arguments": [] + } + }, + { + "id": "native.squash1", + "description": "Native UI: Squash → 1", + "platforms": [ + "macos" + ], + "macos": { + "key": "Squash → 1", + "arguments": [] + } + }, + { + "id": "native.squashAndMerge", + "description": "Native UI: Squash and merge", + "platforms": [ + "macos" + ], + "macos": { + "key": "Squash and merge", + "arguments": [] + } + }, + { + "id": "native.squashCommits", + "description": "Native UI: Squash Commits", + "platforms": [ + "macos" + ], + "macos": { + "key": "Squash Commits", + "arguments": [] + } + }, + { + "id": "native.squashCommits.variant2", + "description": "Native UI: Squash Commits…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Squash Commits…", + "arguments": [] + } + }, + { + "id": "native.squashLldCommitsInto1LaterCommitsInTheAffectedHistoryWillBeReplayed", + "description": "Native UI: Squash %lld commits into 1. Later commits in the affected history will be replayed.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Squash %lld commits into 1. Later commits in the affected history will be replayed.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.stableRecommended", + "description": "Native UI: Stable (Recommended)", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stable (Recommended)", + "arguments": [] + } + }, + { + "id": "native.stableReleaseIsReady", + "description": "Native UI: Stable release %@ is ready.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stable release %@ is ready.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.stackFrameDisplayOptions", + "description": "Native UI: Stack frame display options", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stack frame display options", + "arguments": [] + } + }, + { + "id": "native.stageAllAndCommitUseTheCurrentChangeList", + "description": "Native UI: Stage All and Commit use the current ChangeList.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stage All and Commit use the current ChangeList.", + "arguments": [] + } + }, + { + "id": "native.stageAllChanges", + "description": "Native UI: Stage all changes", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stage all changes", + "arguments": [] + } + }, + { + "id": "native.stageAllFiles", + "description": "Native UI: Stage all files", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stage all files", + "arguments": [] + } + }, + { + "id": "native.stageAllFilesInCurrentChangeList", + "description": "Native UI: Stage all files in current ChangeList", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stage all files in current ChangeList", + "arguments": [] + } + }, + { + "id": "native.stageAllFilesInRepository", + "description": "Native UI: Stage all files in repository", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stage all files in repository", + "arguments": [] + } + }, + { + "id": "native.stageAtLeastOneChangeBeforeCommitting", + "description": "Native UI: Stage at least one change before committing", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stage at least one change before committing", + "arguments": [] + } + }, + { + "id": "native.stageAtLeastOneFileFirst", + "description": "Native UI: Stage at least one file first", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stage at least one file first", + "arguments": [] + } + }, + { + "id": "native.stageFile", + "description": "Native UI: Stage File", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stage File", + "arguments": [] + } + }, + { + "id": "native.stageFile.variant2", + "description": "Native UI: Stage file", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stage file", + "arguments": [] + } + }, + { + "id": "native.stageThisChangeBlock", + "description": "Native UI: Stage this change block", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stage this change block", + "arguments": [] + } + }, + { + "id": "native.stageYourFilesInChangesEnterACommitMessageAndCreateTheFirstCommit", + "description": "Native UI: Stage your files in Changes, enter a commit message, and create the first commit.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stage your files in Changes, enter a commit message, and create the first commit.", + "arguments": [] + } + }, + { + "id": "native.staged", + "description": "Native UI: STAGED", + "platforms": [ + "macos" + ], + "macos": { + "key": "STAGED", + "arguments": [] + } + }, + { + "id": "native.staged.variant2", + "description": "Native UI: Staged", + "platforms": [ + "macos" + ], + "macos": { + "key": "Staged", + "arguments": [] + } + }, + { + "id": "native.stagedChanges", + "description": "Native UI: Staged changes", + "platforms": [ + "macos" + ], + "macos": { + "key": "Staged changes", + "arguments": [] + } + }, + { + "id": "native.stagedFilesChangedBeforeGenerationFinished", + "description": "Native UI: Staged files changed before generation finished", + "platforms": [ + "macos" + ], + "macos": { + "key": "Staged files changed before generation finished", + "arguments": [] + } + }, + { + "id": "native.staleRecord", + "description": "Native UI: Stale record", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stale record", + "arguments": [] + } + }, + { + "id": "native.standard", + "description": "Native UI: Standard", + "platforms": [ + "macos" + ], + "macos": { + "key": "Standard", + "arguments": [] + } + }, + { + "id": "native.startALanguageServerWhenYouOpenFilesOfItsLanguage", + "description": "Native UI: Start a language server when you open files of its language", + "platforms": [ + "macos" + ], + "macos": { + "key": "Start a language server when you open files of its language", + "arguments": [] + } + }, + { + "id": "native.startALocalJavaDebugSessionToViewPerformance", + "description": "Native UI: Start a local Java debug session to view performance.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Start a local Java debug session to view performance.", + "arguments": [] + } + }, + { + "id": "native.startDebugging", + "description": "Native UI: Start Debugging", + "platforms": [ + "macos" + ], + "macos": { + "key": "Start Debugging", + "arguments": [] + } + }, + { + "id": "native.startDebugging.variant2", + "description": "Native UI: Start debugging", + "platforms": [ + "macos" + ], + "macos": { + "key": "Start debugging", + "arguments": [] + } + }, + { + "id": "native.startDebuggingTheCurrentJavaFile", + "description": "Native UI: Start debugging the current Java file", + "platforms": [ + "macos" + ], + "macos": { + "key": "Start debugging the current Java file", + "arguments": [] + } + }, + { + "id": "native.startFrom", + "description": "Native UI: Start from", + "platforms": [ + "macos" + ], + "macos": { + "key": "Start from", + "arguments": [] + } + }, + { + "id": "native.startRebase", + "description": "Native UI: Start Rebase", + "platforms": [ + "macos" + ], + "macos": { + "key": "Start Rebase", + "arguments": [] + } + }, + { + "id": "native.startRecording", + "description": "Native UI: Start Recording", + "platforms": [ + "macos" + ], + "macos": { + "key": "Start Recording", + "arguments": [] + } + }, + { + "id": "native.startService", + "description": "Native UI: Start service", + "platforms": [ + "macos" + ], + "macos": { + "key": "Start service", + "arguments": [] + } + }, + { + "id": "native.startTheDiscussionOrSubmitTheFirstReview", + "description": "Native UI: Start the discussion or submit the first review.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Start the discussion or submit the first review.", + "arguments": [] + } + }, + { + "id": "native.startTheSessionBySendingAMessage", + "description": "Native UI: Start the session by sending a message", + "platforms": [ + "macos" + ], + "macos": { + "key": "Start the session by sending a message", + "arguments": [] + } + }, + { + "id": "native.startThisConfigurationToSeeItsOutputHere", + "description": "Native UI: Start this configuration to see its output here.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Start this configuration to see its output here.", + "arguments": [] + } + }, + { + "id": "native.startWithAnActionVerbWithoutATypePrefix", + "description": "Native UI: Start with an action verb, without a type prefix", + "platforms": [ + "macos" + ], + "macos": { + "key": "Start with an action verb, without a type prefix", + "arguments": [] + } + }, + { + "id": "native.startedATaskThatIsStillRunning", + "description": "Native UI: %@ started a task that is still running", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ started a task that is still running", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.starting", + "description": "Native UI: Starting", + "platforms": [ + "macos" + ], + "macos": { + "key": "Starting", + "arguments": [] + } + }, + { + "id": "native.startingCommitOptional", + "description": "Native UI: Starting commit (optional)", + "platforms": [ + "macos" + ], + "macos": { + "key": "Starting commit (optional)", + "arguments": [] + } + }, + { + "id": "native.startingInteractiveRebase", + "description": "Native UI: Starting Interactive Rebase", + "platforms": [ + "macos" + ], + "macos": { + "key": "Starting Interactive Rebase", + "arguments": [] + } + }, + { + "id": "native.startingJavaLanguageServer", + "description": "Native UI: Starting Java language server...", + "platforms": [ + "macos" + ], + "macos": { + "key": "Starting Java language server...", + "arguments": [] + } + }, + { + "id": "native.startingJavaNavigation", + "description": "Native UI: Starting Java navigation...", + "platforms": [ + "macos" + ], + "macos": { + "key": "Starting Java navigation...", + "arguments": [] + } + }, + { + "id": "native.startingTheAgent", + "description": "Native UI: Starting the Agent…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Starting the Agent…", + "arguments": [] + } + }, + { + "id": "native.startsNoPrograms", + "description": "Native UI: Starts no programs.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Starts no programs.", + "arguments": [] + } + }, + { + "id": "native.startsOnDemandWhenAMatchingFileIsOpened", + "description": "Native UI: Starts on demand when a matching file is opened", + "platforms": [ + "macos" + ], + "macos": { + "key": "Starts on demand when a matching file is opened", + "arguments": [] + } + }, + { + "id": "native.stash", + "description": "Native UI: Stash", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stash", + "arguments": [] + } + }, + { + "id": "native.stashAndContinue", + "description": "Native UI: Stash and Continue", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stash and Continue", + "arguments": [] + } + }, + { + "id": "native.stashChanges", + "description": "Native UI: Stash Changes", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stash Changes", + "arguments": [] + } + }, + { + "id": "native.stashChangesHereToSwitchBranchesSafely", + "description": "Native UI: Stash changes here to switch branches safely.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stash changes here to switch branches safely.", + "arguments": [] + } + }, + { + "id": "native.stashMessage", + "description": "Native UI: Stash message", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stash message", + "arguments": [] + } + }, + { + "id": "native.stashOrShelfChangesHereToSwitchBranchesSafely", + "description": "Native UI: Stash or shelf changes here to switch branches safely.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stash or shelf changes here to switch branches safely.", + "arguments": [] + } + }, + { + "id": "native.stashRestore", + "description": "Native UI: stash restore", + "platforms": [ + "macos" + ], + "macos": { + "key": "stash restore", + "arguments": [] + } + }, + { + "id": "native.stashRestoreNeedsAttention", + "description": "Native UI: Stash restore needs attention", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stash restore needs attention", + "arguments": [] + } + }, + { + "id": "native.stashingSetsTheseChangesAsideRunsTheOperationThenRestoresThemIfConflictsStopTh7f3dbd0dad", + "description": "Native UI: Stashing sets these changes aside, runs the operation, then restores them. If conflicts stop the operation, the changes stay stashed until you finish it.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stashing sets these changes aside, runs the operation, then restores them. If conflicts stop the operation, the changes stay stashed until you finish it.", + "arguments": [] + } + }, + { + "id": "native.state", + "description": "Native UI: State", + "platforms": [ + "macos" + ], + "macos": { + "key": "State", + "arguments": [] + } + }, + { + "id": "native.status", + "description": "Native UI: Status", + "platforms": [ + "macos" + ], + "macos": { + "key": "Status", + "arguments": [] + } + }, + { + "id": "native.stepInto", + "description": "Native UI: Step into", + "platforms": [ + "macos" + ], + "macos": { + "key": "Step into", + "arguments": [] + } + }, + { + "id": "native.stepLldOfLld", + "description": "Native UI: Step %lld of %lld", + "platforms": [ + "macos" + ], + "macos": { + "key": "Step %lld of %lld", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "integer", + "arg2": "integer" + } + }, + { + "id": "native.stepOut", + "description": "Native UI: Step out", + "platforms": [ + "macos" + ], + "macos": { + "key": "Step out", + "arguments": [] + } + }, + { + "id": "native.stepOver", + "description": "Native UI: Step over", + "platforms": [ + "macos" + ], + "macos": { + "key": "Step over", + "arguments": [] + } + }, + { + "id": "native.stopAllServices", + "description": "Native UI: Stop all services", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stop all services", + "arguments": [] + } + }, + { + "id": "native.stopAndClose", + "description": "Native UI: Stop and Close", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stop and Close", + "arguments": [] + } + }, + { + "id": "native.stopAndQuit", + "description": "Native UI: Stop and Quit", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stop and Quit", + "arguments": [] + } + }, + { + "id": "native.stopBuildTask", + "description": "Native UI: Stop build task", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stop build task", + "arguments": [] + } + }, + { + "id": "native.stopCamera", + "description": "Native UI: Stop Camera", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stop Camera", + "arguments": [] + } + }, + { + "id": "native.stopCurrentRun", + "description": "Native UI: Stop current run", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stop current run", + "arguments": [] + } + }, + { + "id": "native.stopDebug", + "description": "Native UI: Stop Debug", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stop Debug", + "arguments": [] + } + }, + { + "id": "native.stopDebugging", + "description": "Native UI: Stop debugging", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stop debugging", + "arguments": [] + } + }, + { + "id": "native.stopLoading", + "description": "Native UI: Stop Loading", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stop Loading", + "arguments": [] + } + }, + { + "id": "native.stopMavenTask", + "description": "Native UI: Stop Maven task", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stop Maven task", + "arguments": [] + } + }, + { + "id": "native.stopMicrophone", + "description": "Native UI: Stop Microphone", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stop Microphone", + "arguments": [] + } + }, + { + "id": "native.stopRecording", + "description": "Native UI: Stop Recording", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stop Recording", + "arguments": [] + } + }, + { + "id": "native.stopRun", + "description": "Native UI: Stop Run", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stop Run", + "arguments": [] + } + }, + { + "id": "native.stopRun.variant2", + "description": "Native UI: Stop run", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stop run", + "arguments": [] + } + }, + { + "id": "native.stopSelected", + "description": "Native UI: Stop selected", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stop selected", + "arguments": [] + } + }, + { + "id": "native.stopService", + "description": "Native UI: Stop service", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stop service", + "arguments": [] + } + }, + { + "id": "native.stopTheCurrentDebugSession", + "description": "Native UI: Stop the current debug session", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stop the current debug session", + "arguments": [] + } + }, + { + "id": "native.stopTheCurrentRun", + "description": "Native UI: Stop the current run", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stop the current run", + "arguments": [] + } + }, + { + "id": "native.stopTrackingBranch", + "description": "Native UI: Stop Tracking Branch", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stop Tracking Branch", + "arguments": [] + } + }, + { + "id": "native.stopWithoutAsking", + "description": "Native UI: Stop without asking", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stop without asking", + "arguments": [] + } + }, + { + "id": "native.stoppedMovingTheRemainingItemsToTrash", + "description": "Native UI: Stopped moving the remaining items to Trash", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stopped moving the remaining items to Trash", + "arguments": [] + } + }, + { + "id": "native.stopping", + "description": "Native UI: Stopping", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stopping", + "arguments": [] + } + }, + { + "id": "native.stopping.variant2", + "description": "Native UI: Stopping…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stopping…", + "arguments": [] + } + }, + { + "id": "native.stoppingInstallation", + "description": "Native UI: Stopping installation…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stopping installation…", + "arguments": [] + } + }, + { + "id": "native.stoppingPrograms", + "description": "Native UI: Stopping programs", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stopping programs", + "arguments": [] + } + }, + { + "id": "native.storePatchesInLitheWithoutAddingObjectsToGit", + "description": "Native UI: Store patches in Lithe without adding objects to Git.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Store patches in Lithe without adding objects to Git.", + "arguments": [] + } + }, + { + "id": "native.storeTemporaryChangesInGitSStashList", + "description": "Native UI: Store temporary changes in Git's stash list.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Store temporary changes in Git's stash list.", + "arguments": [] + } + }, + { + "id": "native.structuredTypeScopeSubjectFormat", + "description": "Native UI: Structured type(scope): subject format", + "platforms": [ + "macos" + ], + "macos": { + "key": "Structured type(scope): subject format", + "arguments": [] + } + }, + { + "id": "native.subagent", + "description": "Native UI: Subagent", + "platforms": [ + "macos" + ], + "macos": { + "key": "Subagent", + "arguments": [] + } + }, + { + "id": "native.subagentOutput", + "description": "Native UI: Subagent output", + "platforms": [ + "macos" + ], + "macos": { + "key": "Subagent output", + "arguments": [] + } + }, + { + "id": "native.subagentTask", + "description": "Native UI: Subagent task", + "platforms": [ + "macos" + ], + "macos": { + "key": "Subagent task", + "arguments": [] + } + }, + { + "id": "native.subagents", + "description": "Native UI: Subagents", + "platforms": [ + "macos" + ], + "macos": { + "key": "Subagents", + "arguments": [] + } + }, + { + "id": "native.subjectMaximumLength", + "description": "Native UI: Subject maximum length", + "platforms": [ + "macos" + ], + "macos": { + "key": "Subject maximum length", + "arguments": [] + } + }, + { + "id": "native.submittingReview", + "description": "Native UI: Submitting review…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Submitting review…", + "arguments": [] + } + }, + { + "id": "native.succeeded", + "description": "Native UI: Succeeded", + "platforms": [ + "macos" + ], + "macos": { + "key": "Succeeded", + "arguments": [] + } + }, + { + "id": "native.supplementaryMessageCancelled", + "description": "Native UI: Supplementary message cancelled.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Supplementary message cancelled.", + "arguments": [] + } + }, + { + "id": "native.supportedPlaceholdersSummaryChangesTestingRisks", + "description": "Native UI: Supported placeholders: {summary}, {changes}, {testing}, {risks}.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Supported placeholders: {summary}, {changes}, {testing}, {risks}.", + "arguments": [] + }, + "literalBraces": true + }, + { + "id": "native.svelteSupport", + "description": "Native UI: Svelte Support", + "platforms": [ + "macos" + ], + "macos": { + "key": "Svelte Support", + "arguments": [] + } + }, + { + "id": "native.swapDirection", + "description": "Native UI: Swap Direction", + "platforms": [ + "macos" + ], + "macos": { + "key": "Swap Direction", + "arguments": [] + } + }, + { + "id": "native.swiftSupport", + "description": "Native UI: Swift Support", + "platforms": [ + "macos" + ], + "macos": { + "key": "Swift Support", + "arguments": [] + } + }, + { + "id": "native.switchAgent", + "description": "Native UI: Switch Agent", + "platforms": [ + "macos" + ], + "macos": { + "key": "Switch Agent", + "arguments": [] + } + }, + { + "id": "native.switchProjectView", + "description": "Native UI: Switch project view", + "platforms": [ + "macos" + ], + "macos": { + "key": "Switch project view", + "arguments": [] + } + }, + { + "id": "native.switchToTheNodeJsNpmEnvironmentThatInstalledThisCLIUpdateItThereThenCheckAgain", + "description": "Native UI: Switch to the Node.js/npm environment that installed this CLI, update it there, then check again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Switch to the Node.js/npm environment that installed this CLI, update it there, then check again.", + "arguments": [] + } + }, + { + "id": "native.symbols", + "description": "Native UI: Symbols", + "platforms": [ + "macos" + ], + "macos": { + "key": "Symbols", + "arguments": [] + } + }, + { + "id": "native.syncAndRemoveMissingTags", + "description": "Native UI: Sync and remove missing tags", + "platforms": [ + "macos" + ], + "macos": { + "key": "Sync and remove missing tags", + "arguments": [] + } + }, + { + "id": "native.syncWithRemote", + "description": "Native UI: Sync with Remote", + "platforms": [ + "macos" + ], + "macos": { + "key": "Sync with Remote", + "arguments": [] + } + }, + { + "id": "native.synchronizeTagsAndRemoveLocalTagsMissingFromTheRemote", + "description": "Native UI: Synchronize tags and remove local tags missing from the remote", + "platforms": [ + "macos" + ], + "macos": { + "key": "Synchronize tags and remove local tags missing from the remote", + "arguments": [] + } + }, + { + "id": "native.system", + "description": "Native UI: System", + "platforms": [ + "macos" + ], + "macos": { + "key": "System", + "arguments": [] + } + }, + { + "id": "native.systemDefault", + "description": "Native UI: System default", + "platforms": [ + "macos" + ], + "macos": { + "key": "System default", + "arguments": [] + } + }, + { + "id": "native.systemDefault.variant2", + "description": "Native UI: System default · %@ (%@)", + "platforms": [ + "macos" + ], + "macos": { + "key": "System default · %@ (%@)", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string" + } + }, + { + "id": "native.systemDefaultShellNotDetected", + "description": "Native UI: System default · Shell not detected", + "platforms": [ + "macos" + ], + "macos": { + "key": "System default · Shell not detected", + "arguments": [] + } + }, + { + "id": "native.systemGit", + "description": "Native UI: System Git", + "platforms": [ + "macos" + ], + "macos": { + "key": "System Git", + "arguments": [] + } + }, + { + "id": "native.systemMaven", + "description": "Native UI: system Maven", + "platforms": [ + "macos" + ], + "macos": { + "key": "system Maven", + "arguments": [] + } + }, + { + "id": "native.systemPrompt", + "description": "Native UI: System prompt", + "platforms": [ + "macos" + ], + "macos": { + "key": "System prompt", + "arguments": [] + } + }, + { + "id": "native.tabWidth", + "description": "Native UI: Tab width", + "platforms": [ + "macos" + ], + "macos": { + "key": "Tab width", + "arguments": [] + } + }, + { + "id": "native.tableProperties", + "description": "Native UI: Table Properties", + "platforms": [ + "macos" + ], + "macos": { + "key": "Table Properties", + "arguments": [] + } + }, + { + "id": "native.tag", + "description": "Native UI: Tag", + "platforms": [ + "macos" + ], + "macos": { + "key": "Tag", + "arguments": [] + } + }, + { + "id": "native.tagName", + "description": "Native UI: Tag name", + "platforms": [ + "macos" + ], + "macos": { + "key": "Tag name", + "arguments": [] + } + }, + { + "id": "native.tagOrRevision", + "description": "Native UI: Tag or revision", + "platforms": [ + "macos" + ], + "macos": { + "key": "Tag or revision", + "arguments": [] + } + }, + { + "id": "native.tags", + "description": "Native UI: Tags", + "platforms": [ + "macos" + ], + "macos": { + "key": "Tags", + "arguments": [] + } + }, + { + "id": "native.takeOwnership", + "description": "Native UI: Take ownership", + "platforms": [ + "macos" + ], + "macos": { + "key": "Take ownership", + "arguments": [] + } + }, + { + "id": "native.takesEffectAfterLitheRestarts", + "description": "Native UI: Takes effect after Lithe restarts", + "platforms": [ + "macos" + ], + "macos": { + "key": "Takes effect after Lithe restarts", + "arguments": [] + } + }, + { + "id": "native.tasks", + "description": "Native UI: Tasks", + "platforms": [ + "macos" + ], + "macos": { + "key": "Tasks", + "arguments": [] + } + }, + { + "id": "native.tasks.variant2", + "description": "Native UI: Tasks: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Tasks: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.tasksD", + "description": "Native UI: Tasks: %d", + "platforms": [ + "macos" + ], + "macos": { + "key": "Tasks: %d", + "arguments": [ + "arg1" + ], + "formats": { + "arg1": "d" + } + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.temporaryConfiguration", + "description": "Native UI: Temporary configuration", + "platforms": [ + "macos" + ], + "macos": { + "key": "Temporary configuration", + "arguments": [] + } + }, + { + "id": "native.temporaryConfiguration.variant2", + "description": "Native UI: Temporary configuration: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Temporary configuration: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.terminalIsStillRunning", + "description": "Native UI: Terminal “%@” is still running", + "platforms": [ + "macos" + ], + "macos": { + "key": "Terminal “%@” is still running", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.terminalScrollback", + "description": "Native UI: Terminal scrollback", + "platforms": [ + "macos" + ], + "macos": { + "key": "Terminal scrollback", + "arguments": [] + } + }, + { + "id": "native.terminalsWithRunningPrograms", + "description": "Native UI: Terminals with running programs", + "platforms": [ + "macos" + ], + "macos": { + "key": "Terminals with running programs", + "arguments": [] + } + }, + { + "id": "native.terraformSupport", + "description": "Native UI: Terraform Support", + "platforms": [ + "macos" + ], + "macos": { + "key": "Terraform Support", + "arguments": [] + } + }, + { + "id": "native.test", + "description": "Native UI: Test", + "platforms": [ + "macos" + ], + "macos": { + "key": "Test", + "arguments": [] + } + }, + { + "id": "native.testJava", + "description": "Native UI: test Java", + "platforms": [ + "macos" + ], + "macos": { + "key": "test Java", + "arguments": [] + } + }, + { + "id": "native.testProcessTreeResidentMemory", + "description": "Native UI: Test process tree · Resident memory", + "platforms": [ + "macos" + ], + "macos": { + "key": "Test process tree · Resident memory", + "arguments": [] + } + }, + { + "id": "native.testResources", + "description": "Native UI: test resources", + "platforms": [ + "macos" + ], + "macos": { + "key": "test resources", + "arguments": [] + } + }, + { + "id": "native.testRunTimedOutAfterSeconds", + "description": "Native UI: %@ test run timed out after %@ seconds.", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ test run timed out after %@ seconds.", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string" + } + }, + { + "id": "native.tests", + "description": "Native UI: Tests", + "platforms": [ + "macos" + ], + "macos": { + "key": "Tests", + "arguments": [] + } + }, + { + "id": "native.testsFailed", + "description": "Native UI: Tests failed", + "platforms": [ + "macos" + ], + "macos": { + "key": "Tests failed", + "arguments": [] + } + }, + { + "id": "native.testsPassed", + "description": "Native UI: Tests passed", + "platforms": [ + "macos" + ], + "macos": { + "key": "Tests passed", + "arguments": [] + } + }, + { + "id": "native.text", + "description": "Native UI: Text", + "platforms": [ + "macos" + ], + "macos": { + "key": "Text", + "arguments": [] + } + }, + { + "id": "native.textMeAuthorBranchPath", + "description": "Native UI: Text, me, author:, branch:, path:", + "platforms": [ + "macos" + ], + "macos": { + "key": "Text, me, author:, branch:, path:", + "arguments": [] + } + }, + { + "id": "native.textOrHash", + "description": "Native UI: Text or hash", + "platforms": [ + "macos" + ], + "macos": { + "key": "Text or hash", + "arguments": [] + } + }, + { + "id": "native.theAIProviderReturnedAnEmptyCommitMessage", + "description": "Native UI: The AI provider returned an empty commit message.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The AI provider returned an empty commit message.", + "arguments": [] + } + }, + { + "id": "native.theAIProviderReturnedAnEmptyPullRequestDescription", + "description": "Native UI: The AI provider returned an empty pull request description.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The AI provider returned an empty pull request description.", + "arguments": [] + } + }, + { + "id": "native.theAIProviderReturnedAnHTTPError", + "description": "Native UI: The AI provider returned an HTTP error.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The AI provider returned an HTTP error.", + "arguments": [] + } + }, + { + "id": "native.theAIProviderReturnedAnUnexpectedPullRequestDescription", + "description": "Native UI: The AI provider returned an unexpected pull request description.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The AI provider returned an unexpected pull request description.", + "arguments": [] + } + }, + { + "id": "native.theAIProviderReturnedAnUnexpectedResponse", + "description": "Native UI: The AI provider returned an unexpected response.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The AI provider returned an unexpected response.", + "arguments": [] + } + }, + { + "id": "native.theAPIKeyIsStoredSeparatelyFromSettingsYourLocalCLIConfigurationIsNotChanged", + "description": "Native UI: The API key is stored separately from settings. Your local CLI configuration is not changed.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The API key is stored separately from settings. Your local CLI configuration is not changed.", + "arguments": [] + } + }, + { + "id": "native.theActiveRepositoryChangedInspectTheOriginalRepositoryBeforeRetrying", + "description": "Native UI: The active repository changed. Inspect the original repository before retrying.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The active repository changed. Inspect the original repository before retrying.", + "arguments": [] + } + }, + { + "id": "native.theAgentAlreadySavedTheseFilesMarkTheCurrentChangesAsReviewed", + "description": "Native UI: The Agent already saved these files. Mark the current changes as reviewed.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The Agent already saved these files. Mark the current changes as reviewed.", + "arguments": [] + } + }, + { + "id": "native.theAgentCouldNotStart", + "description": "Native UI: The Agent could not start", + "platforms": [ + "macos" + ], + "macos": { + "key": "The Agent could not start", + "arguments": [] + } + }, + { + "id": "native.theAgentDeclinedToContinue", + "description": "Native UI: The Agent declined to continue.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The Agent declined to continue.", + "arguments": [] + } + }, + { + "id": "native.theAgentDidNotAcceptTheSupplementaryMessage", + "description": "Native UI: The Agent did not accept the supplementary message.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The Agent did not accept the supplementary message.", + "arguments": [] + } + }, + { + "id": "native.theAgentDidNotConfirmTheSelectedSessionSetting", + "description": "Native UI: The Agent did not confirm the selected session setting.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The Agent did not confirm the selected session setting.", + "arguments": [] + } + }, + { + "id": "native.theAgentDidNotProvideEnoughOriginalContentToSafelyRollBackThisFile", + "description": "Native UI: The Agent did not provide enough original content to safely roll back this file.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The Agent did not provide enough original content to safely roll back this file.", + "arguments": [] + } + }, + { + "id": "native.theAgentDiffIsTooLargeToDisplayCompletelyRollbackIsUnavailable", + "description": "Native UI: The Agent diff is too large to display completely. Rollback is unavailable.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The Agent diff is too large to display completely. Rollback is unavailable.", + "arguments": [] + } + }, + { + "id": "native.theAgentHasNotReportedContextUsageYet", + "description": "Native UI: The Agent has not reported context usage yet.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The Agent has not reported context usage yet.", + "arguments": [] + } + }, + { + "id": "native.theAgentIsNotRunningConnectToStartAConversation", + "description": "Native UI: The Agent is not running. Connect to start a conversation.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The Agent is not running. Connect to start a conversation.", + "arguments": [] + } + }, + { + "id": "native.theAgentIsStillRespondingInThisConversation", + "description": "Native UI: The Agent is still responding in this conversation.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The Agent is still responding in this conversation.", + "arguments": [] + } + }, + { + "id": "native.theAgentIsWaitingForYourPermission", + "description": "Native UI: The Agent is waiting for your permission", + "platforms": [ + "macos" + ], + "macos": { + "key": "The Agent is waiting for your permission", + "arguments": [] + } + }, + { + "id": "native.theAgentModuleIsStillStartingTryAgainInAMoment", + "description": "Native UI: The Agent module is still starting. Try again in a moment.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The Agent module is still starting. Try again in a moment.", + "arguments": [] + } + }, + { + "id": "native.theAgentNoLongerSupportsTheSelectedSessionSetting", + "description": "Native UI: The Agent no longer supports the selected session setting.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The Agent no longer supports the selected session setting.", + "arguments": [] + } + }, + { + "id": "native.theAgentProcessStartsWhenThisPanelOpens", + "description": "Native UI: The Agent process starts when this panel opens.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The Agent process starts when this panel opens.", + "arguments": [] + } + }, + { + "id": "native.theAgentRequestFailed", + "description": "Native UI: The Agent request failed.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The Agent request failed.", + "arguments": [] + } + }, + { + "id": "native.theAgentStoppedAfterReachingItsRequestLimitForThisTurn", + "description": "Native UI: The Agent stopped after reaching its request limit for this turn.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The Agent stopped after reaching its request limit for this turn.", + "arguments": [] + } + }, + { + "id": "native.theBranchCouldNotBePublished", + "description": "Native UI: The branch could not be published", + "platforms": [ + "macos" + ], + "macos": { + "key": "The branch could not be published", + "arguments": [] + } + }, + { + "id": "native.theBranchNowContainsTheCompletedPlanItsOriginalHistoryRemainsAvailableThroughTee76d52d9b", + "description": "Native UI: The branch now contains the completed plan. Its original history remains available through the recovery reference.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The branch now contains the completed plan. Its original history remains available through the recovery reference.", + "arguments": [] + } + }, + { + "id": "native.theBuildToolExitedWithCodeLld", + "description": "Native UI: The build tool exited with code %lld.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The build tool exited with code %lld.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.theBuildToolOutputIsTooLargeOrNotUTF8", + "description": "Native UI: The build tool output is too large or not UTF-8.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The build tool output is too large or not UTF-8.", + "arguments": [] + } + }, + { + "id": "native.theBuildToolTimedOut", + "description": "Native UI: The build tool timed out.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The build tool timed out.", + "arguments": [] + } + }, + { + "id": "native.theCheckoutDirectoryNoLongerExistsRepairMovedWorktreeMetadataOrPruneTheStaleGitRecord", + "description": "Native UI: The checkout directory no longer exists. Repair moved worktree metadata or prune the stale Git record.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The checkout directory no longer exists. Repair moved worktree metadata or prune the stale Git record.", + "arguments": [] + } + }, + { + "id": "native.theCheckoutPathDoesNotExist", + "description": "Native UI: The checkout path does not exist", + "platforms": [ + "macos" + ], + "macos": { + "key": "The checkout path does not exist", + "arguments": [] + } + }, + { + "id": "native.theConfiguredBuildToolIsNotExecutable", + "description": "Native UI: The configured build tool is not executable: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "The configured build tool is not executable: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.theConversationIsStillBeingPreparedTryAgainInAMoment", + "description": "Native UI: The conversation is still being prepared. Try again in a moment.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The conversation is still being prepared. Try again in a moment.", + "arguments": [] + } + }, + { + "id": "native.theCredentialStoreVersionIsUnsupportedExistingCredentialsWerePreserved", + "description": "Native UI: The credential store version is unsupported. Existing credentials were preserved.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The credential store version is unsupported. Existing credentials were preserved.", + "arguments": [] + } + }, + { + "id": "native.theCurrentFileWillBeSavedToLocalHistoryBeforeItIsReplaced", + "description": "Native UI: The current file will be saved to Local History before it is replaced.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The current file will be saved to Local History before it is replaced.", + "arguments": [] + } + }, + { + "id": "native.theCurrentWorktreeCannotBeRemovedHere", + "description": "Native UI: The current worktree cannot be removed here.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The current worktree cannot be removed here.", + "arguments": [] + } + }, + { + "id": "native.theDefaultChangeListCannotBeRenamed", + "description": "Native UI: The default ChangeList cannot be renamed.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The default ChangeList cannot be renamed.", + "arguments": [] + } + }, + { + "id": "native.theDownloadedAppDoesNotMatchTheSelectedStableReleaseNothingWasInstalled", + "description": "Native UI: The downloaded app does not match the selected stable release. Nothing was installed.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The downloaded app does not match the selected stable release. Nothing was installed.", + "arguments": [] + } + }, + { + "id": "native.theDownloadedDiskImageDoesNotContainLitheApp", + "description": "Native UI: The downloaded disk image does not contain Lithe.app.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The downloaded disk image does not contain Lithe.app.", + "arguments": [] + } + }, + { + "id": "native.theDownloadedUpdateFailedItsChecksumVerification", + "description": "Native UI: The downloaded update failed its checksum verification.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The downloaded update failed its checksum verification.", + "arguments": [] + } + }, + { + "id": "native.theDownloadedUpdateFailedItsSHA256VerificationDoNotInstallItRetryOrUseTheReleasePage", + "description": "Native UI: The downloaded update failed its SHA-256 verification. Do not install it; retry or use the Release page.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The downloaded update failed its SHA-256 verification. Do not install it; retry or use the Release page.", + "arguments": [] + } + }, + { + "id": "native.theEditStopChangedRefreshTheSessionBeforeAmending", + "description": "Native UI: The edit stop changed. Refresh the session before amending.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The edit stop changed. Refresh the session before amending.", + "arguments": [] + } + }, + { + "id": "native.theFileHasChangedSinceTheAgentEditOpenTheDiffAndReviewItManually", + "description": "Native UI: The file has changed since the Agent edit. Open the diff and review it manually.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The file has changed since the Agent edit. Open the diff and review it manually.", + "arguments": [] + } + }, + { + "id": "native.theGeneratedMessageWillReplaceTheTextCurrentlyInTheEditor", + "description": "Native UI: The generated message will replace the text currently in the editor.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The generated message will replace the text currently in the editor.", + "arguments": [] + } + }, + { + "id": "native.theGeneratedTitleOrDescriptionWouldReplaceTextYouAlreadyEntered", + "description": "Native UI: The generated title or description would replace text you already entered.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The generated title or description would replace text you already entered.", + "arguments": [] + } + }, + { + "id": "native.theGitHubAuthorizationCodeExpiredStartAgain", + "description": "Native UI: The GitHub authorization code expired. Start again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The GitHub authorization code expired. Start again.", + "arguments": [] + } + }, + { + "id": "native.theInstallerReportedAnErrorButTheNewCLIVersionWasVerified", + "description": "Native UI: The installer reported an error, but the new CLI version was verified.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The installer reported an error, but the new CLI version was verified.", + "arguments": [] + } + }, + { + "id": "native.theInterfaceLanguageChangesImmediatelyEnglishIsTheDefault", + "description": "Native UI: The interface language changes immediately. English is the default.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The interface language changes immediately. English is the default.", + "arguments": [] + } + }, + { + "id": "native.theItemCanBeRecoveredFromTheMacOSTrash", + "description": "Native UI: The item can be recovered from the macOS Trash.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The item can be recovered from the macOS Trash.", + "arguments": [] + } + }, + { + "id": "native.theJVMDoesNotExposeSupportedHeapCountersThroughJstat", + "description": "Native UI: The JVM does not expose supported heap counters through jstat.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The JVM does not expose supported heap counters through jstat.", + "arguments": [] + } + }, + { + "id": "native.theJavaLanguageServerDidNotTakeTheMavenConfiguration", + "description": "Native UI: The Java language server did not take the Maven configuration: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "The Java language server did not take the Maven configuration: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.theJavaLanguageServiceFailedToPrepareTheProject", + "description": "Native UI: The Java language service failed to prepare the project.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The Java language service failed to prepare the project.", + "arguments": [] + } + }, + { + "id": "native.theLatestStableReleaseWillBeDownloadedInFullAndReplaceThisAutoBuildAfterYouConbfd63f6a8e", + "description": "Native UI: The latest stable release will be downloaded in full and replace this AutoBuild after you confirm installation. Projects and settings are kept, but AutoBuild-only settings may not work in the stable release.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The latest stable release will be downloaded in full and replace this AutoBuild after you confirm installation. Projects and settings are kept, but AutoBuild-only settings may not work in the stable release.", + "arguments": [] + } + }, + { + "id": "native.theLatestStableReleaseWillBeDownloadedInFullAndReplaceThisPreviewAfterYouConfic01b41840e", + "description": "Native UI: The latest stable release will be downloaded in full and replace this preview after you confirm installation. Projects and settings are kept, but preview-only settings may not work in the stable release.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The latest stable release will be downloaded in full and replace this preview after you confirm installation. Projects and settings are kept, but preview-only settings may not work in the stable release.", + "arguments": [] + } + }, + { + "id": "native.theLocalAdapterIsAtOrAboveTheVerifiedVersion", + "description": "Native UI: The local adapter is at or above the verified version.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The local adapter is at or above the verified version.", + "arguments": [] + } + }, + { + "id": "native.theLocalCodexAccountChangedReconnectToRefreshQuota", + "description": "Native UI: The local Codex account changed. Reconnect to refresh quota.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The local Codex account changed. Reconnect to refresh quota.", + "arguments": [] + } + }, + { + "id": "native.theMavenProjectCouldNotBeReloadedThePreviousModelIsStillAvailable", + "description": "Native UI: The Maven project could not be reloaded. The previous model is still available.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The Maven project could not be reloaded. The previous model is still available.", + "arguments": [] + } + }, + { + "id": "native.theModelIsTooLargeSomeEntriesAreNotShown", + "description": "Native UI: The model is too large; some entries are not shown.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The model is too large; some entries are not shown.", + "arguments": [] + } + }, + { + "id": "native.theMonitoredJVMHasExitedOrChanged", + "description": "Native UI: The monitored JVM has exited or changed.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The monitored JVM has exited or changed.", + "arguments": [] + } + }, + { + "id": "native.thePatchChangesTheBaseCommitSTreeIntoTheTargetCommitSTree", + "description": "Native UI: The patch changes the base commit's tree into the target commit's tree.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The patch changes the base commit's tree into the target commit's tree.", + "arguments": [] + } + }, + { + "id": "native.thePluginSPageAsksToRunThemIn", + "description": "Native UI: The plugin’s page asks to run them in %@.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The plugin’s page asks to run them in %@.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.thePluginWantsToUseYourCamera", + "description": "Native UI: The plugin “%@” wants to use your camera.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The plugin “%@” wants to use your camera.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.thePluginWantsToUseYourCameraAndMicrophone", + "description": "Native UI: The plugin “%@” wants to use your camera and microphone.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The plugin “%@” wants to use your camera and microphone.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.thePluginWantsToUseYourMicrophone", + "description": "Native UI: The plugin “%@” wants to use your microphone.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The plugin “%@” wants to use your microphone.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.thePluginWillBeRemovedAfterRestartingLithe", + "description": "Native UI: The plugin will be removed after restarting Lithe.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The plugin will be removed after restarting Lithe.", + "arguments": [] + } + }, + { + "id": "native.thePreviousAgentIsStillStoppingTryAgainShortly", + "description": "Native UI: The previous Agent is still stopping. Try again shortly.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The previous Agent is still stopping. Try again shortly.", + "arguments": [] + } + }, + { + "id": "native.thePreviousVersionIsUsedAfterRestartingLitheYourSettingValuesAreKept", + "description": "Native UI: The previous version is used after restarting Lithe. Your setting values are kept.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The previous version is used after restarting Lithe. Your setting values are kept.", + "arguments": [] + } + }, + { + "id": "native.thePrimaryWorktreeCannotBeLocked", + "description": "Native UI: The primary worktree cannot be locked.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The primary worktree cannot be locked.", + "arguments": [] + } + }, + { + "id": "native.thePrimaryWorktreeCannotBeRemoved", + "description": "Native UI: The primary worktree cannot be removed.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The primary worktree cannot be removed.", + "arguments": [] + } + }, + { + "id": "native.theProjectModelIsNotLoaded", + "description": "Native UI: The project model is not loaded.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The project model is not loaded.", + "arguments": [] + } + }, + { + "id": "native.thePublishedUpdateManifestIsInvalidAndCannotBeTrustedOpenTheReleasePageAndDowna6815c0bf9", + "description": "Native UI: The published update manifest is invalid and cannot be trusted. Open the Release page and download the update manually.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The published update manifest is invalid and cannot be trusted. Open the Release page and download the update manually.", + "arguments": [] + } + }, + { + "id": "native.thePullRequestWillReturnToTheOpenListAndCanReceiveNewReviews", + "description": "Native UI: The pull request will return to the open list and can receive new reviews.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The pull request will return to the open list and can receive new reviews.", + "arguments": [] + } + }, + { + "id": "native.theRebaseWasAbortedInspectTheBranchAndWorkingFilesBeforeContinuingYourWork", + "description": "Native UI: The rebase was aborted. Inspect the branch and working files before continuing your work.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The rebase was aborted. Inspect the branch and working files before continuing your work.", + "arguments": [] + } + }, + { + "id": "native.theReportedFileChangesHaveBeenUpdatedReviewThemAgainBeforeRollingBack", + "description": "Native UI: The reported file changes have been updated. Review them again before rolling back.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The reported file changes have been updated. Review them again before rolling back.", + "arguments": [] + } + }, + { + "id": "native.theRepositoryChangedOrAnotherGitOperationIsRunningPreviewThePatchAgain", + "description": "Native UI: The repository changed or another Git operation is running. Preview the patch again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The repository changed or another Git operation is running. Preview the patch again.", + "arguments": [] + } + }, + { + "id": "native.theRepositoryChangedOrAnotherGitOperationIsRunningRefreshBeforeRetrying", + "description": "Native UI: The repository changed or another Git operation is running. Refresh before retrying.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The repository changed or another Git operation is running. Refresh before retrying.", + "arguments": [] + } + }, + { + "id": "native.theRepositoryChangedOrAnotherGitOperationIsRunningRefreshThePreviewToRetry", + "description": "Native UI: The repository changed or another Git operation is running. Refresh the preview to retry.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The repository changed or another Git operation is running. Refresh the preview to retry.", + "arguments": [] + } + }, + { + "id": "native.theRepositoryWillBeOpenedInDetachedHEADState", + "description": "Native UI: The repository will be opened in detached HEAD state.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The repository will be opened in detached HEAD state.", + "arguments": [] + } + }, + { + "id": "native.theResponseStoppedAtTheModelSTokenLimit", + "description": "Native UI: The response stopped at the model's token limit.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The response stopped at the model's token limit.", + "arguments": [] + } + }, + { + "id": "native.theSelectedAIProviderHasAnInvalidAPIURLOrModel", + "description": "Native UI: The selected AI provider has an invalid API URL or model.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The selected AI provider has an invalid API URL or model.", + "arguments": [] + } + }, + { + "id": "native.theSelectedAIProviderHasNoAPIKey", + "description": "Native UI: The selected AI provider has no API key.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The selected AI provider has no API key.", + "arguments": [] + } + }, + { + "id": "native.theSelectedBackgroundImageIsUnavailableAndWasRemoved", + "description": "Native UI: The selected background image is unavailable and was removed.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The selected background image is unavailable and was removed.", + "arguments": [] + } + }, + { + "id": "native.theSelectedBranchDiffIsSentToTheActiveAIProviderWhenYouGenerate", + "description": "Native UI: The selected branch diff is sent to the active AI provider when you generate.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The selected branch diff is sent to the active AI provider when you generate.", + "arguments": [] + } + }, + { + "id": "native.theSelectedBranchesHaveNoTextualChangesToSummarize", + "description": "Native UI: The selected branches have no textual changes to summarize.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The selected branches have no textual changes to summarize.", + "arguments": [] + } + }, + { + "id": "native.theSelectedFileChangedBeforeGenerationFinished", + "description": "Native UI: The selected file changed before generation finished", + "platforms": [ + "macos" + ], + "macos": { + "key": "The selected file changed before generation finished", + "arguments": [] + } + }, + { + "id": "native.theSelectedFileHasNoTextualDiffToSummarize", + "description": "Native UI: The selected file has no textual diff to summarize.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The selected file has no textual diff to summarize.", + "arguments": [] + } + }, + { + "id": "native.theSelectedTableHasNoColumnsToDisplay", + "description": "Native UI: The selected table has no columns to display.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The selected table has no columns to display.", + "arguments": [] + } + }, + { + "id": "native.theSelectedVersionsMatch", + "description": "Native UI: The selected versions match", + "platforms": [ + "macos" + ], + "macos": { + "key": "The selected versions match", + "arguments": [] + } + }, + { + "id": "native.theStableInstallerStoppedYourCurrentAppHasBeenPreservedTryAgainOrDownloadTheStd70ce1979b", + "description": "Native UI: The stable installer stopped. Your current app has been preserved. Try again or download the stable release manually.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The stable installer stopped. Your current app has been preserved. Try again or download the stable release manually.", + "arguments": [] + } + }, + { + "id": "native.theStableProfileCouldNotBeCopiedSafelyItsOriginalDataHasBeenPreserved", + "description": "Native UI: The Stable profile could not be copied safely. Its original data has been preserved.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The Stable profile could not be copied safely. Its original data has been preserved.", + "arguments": [] + } + }, + { + "id": "native.theStableReleaseIsMissingAValidPublisherSignatureNothingWasInstalledTryANewerRef80e417a6", + "description": "Native UI: The stable release is missing a valid publisher signature. Nothing was installed. Try a newer release or install manually from a trusted source.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The stable release is missing a valid publisher signature. Nothing was installed. Try a newer release or install manually from a trusted source.", + "arguments": [] + } + }, + { + "id": "native.theStagedChangesHaveNoTextualDiffToSummarize", + "description": "Native UI: The staged changes have no textual diff to summarize.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The staged changes have no textual diff to summarize.", + "arguments": [] + } + }, + { + "id": "native.theStagedChangesWillBeCommittedAndTheCurrentBranchWillBePushedToItsConfiguredRemote", + "description": "Native UI: The staged changes will be committed and the current branch will be pushed to its configured remote.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The staged changes will be committed and the current branch will be pushed to its configured remote.", + "arguments": [] + } + }, + { + "id": "native.theUpdateCouldNotBeInstalledDownloadTheReleaseManuallyAndTryAgain", + "description": "Native UI: The update could not be installed. Download the release manually and try again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The update could not be installed. Download the release manually and try again.", + "arguments": [] + } + }, + { + "id": "native.theUpdatePackageCouldNotBeDownloadedCheckYourInternetConnectionAndTryAgain", + "description": "Native UI: The update package could not be downloaded. Check your internet connection and try again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The update package could not be downloaded. Check your internet connection and try again.", + "arguments": [] + } + }, + { + "id": "native.theUpdatePackageCouldNotBeDownloadedCheckYourInternetProxyOrVPNConnectionAndTryAgain", + "description": "Native UI: The update package could not be downloaded. Check your internet, proxy, or VPN connection and try again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The update package could not be downloaded. Check your internet, proxy, or VPN connection and try again.", + "arguments": [] + } + }, + { + "id": "native.theUpdatePackageHasNoChecksumAndCannotBeVerified", + "description": "Native UI: The update package has no checksum and cannot be verified.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The update package has no checksum and cannot be verified.", + "arguments": [] + } + }, + { + "id": "native.theUpdateRequestTimedOutCheckYourProxyOrVPNConnectionAndTryAgain", + "description": "Native UI: The update request timed out. Check your proxy or VPN connection and try again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The update request timed out. Check your proxy or VPN connection and try again.", + "arguments": [] + } + }, + { + "id": "native.theUpdateServerReturnedAnUnexpectedResponseOpenTheReleasePageAndDownloadTheUpdateManually", + "description": "Native UI: The update server returned an unexpected response. Open the Release page and download the update manually.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The update server returned an unexpected response. Open the Release page and download the update manually.", + "arguments": [] + } + }, + { + "id": "native.theUpdateServerReturnedHTTPOpenTheReleasePageToDownloadTheUpdateManuallyOrTryAgainLater", + "description": "Native UI: The update server returned HTTP %@. Open the Release page to download the update manually, or try again later.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The update server returned HTTP %@. Open the Release page to download the update manually, or try again later.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.theValueGitUsesNow", + "description": "Native UI: The value Git uses now", + "platforms": [ + "macos" + ], + "macos": { + "key": "The value Git uses now", + "arguments": [] + } + }, + { + "id": "native.theValueGitUsesNowAndWhereItComesFrom", + "description": "Native UI: The value Git uses now and where it comes from", + "platforms": [ + "macos" + ], + "macos": { + "key": "The value Git uses now and where it comes from", + "arguments": [] + } + }, + { + "id": "native.theVerificationPageIsOpenAndTheCodeIsAlreadyOnYourClipboard", + "description": "Native UI: The verification page is open and the code is already on your clipboard.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The verification page is open and the code is already on your clipboard.", + "arguments": [] + } + }, + { + "id": "native.theWorkingTreeCouldNotBeRefreshedKeepYourLocalFilesAndInspectGitStatusBeforeContinuing", + "description": "Native UI: The working tree could not be refreshed. Keep your local files and inspect Git status before continuing.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The working tree could not be refreshed. Keep your local files and inspect Git status before continuing.", + "arguments": [] + } + }, + { + "id": "native.theWorktreeWillPointDirectlyToACommitCreateABranchThereBeforeKeepingNewCommits", + "description": "Native UI: The worktree will point directly to a commit. Create a branch there before keeping new commits.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The worktree will point directly to a commit. Create a branch there before keeping new commits.", + "arguments": [] + } + }, + { + "id": "native.theme", + "description": "Native UI: Theme", + "platforms": [ + "macos" + ], + "macos": { + "key": "Theme", + "arguments": [] + } + }, + { + "id": "native.theme.variant2", + "description": "Native UI: Theme…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Theme…", + "arguments": [] + } + }, + { + "id": "native.thereIsNoPublishedGitHubReleaseToCheckYet", + "description": "Native UI: There is no published GitHub Release to check yet.", + "platforms": [ + "macos" + ], + "macos": { + "key": "There is no published GitHub Release to check yet.", + "arguments": [] + } + }, + { + "id": "native.theseAreTheOptionsLitheWillUseForTheNextOrdinaryFetch", + "description": "Native UI: These are the options Lithe will use for the next ordinary Fetch.", + "platforms": [ + "macos" + ], + "macos": { + "key": "These are the options Lithe will use for the next ordinary Fetch.", + "arguments": [] + } + }, + { + "id": "native.theseChoicesApplyToThisFetchOnlyGitConfigurationAndCredentialsRemainUnchanged", + "description": "Native UI: These choices apply to this Fetch only. Git configuration and credentials remain unchanged.", + "platforms": [ + "macos" + ], + "macos": { + "key": "These choices apply to this Fetch only. Git configuration and credentials remain unchanged.", + "arguments": [] + } + }, + { + "id": "native.theseDefaultsApplyOnlyToThisProjectOnThisComputerRunConfigurationsInheritThemUcc45c36f49", + "description": "Native UI: These defaults apply only to this project on this computer. Run configurations inherit them unless overridden. Use the Project save scope in a run configuration to share settings with your team.", + "platforms": [ + "macos" + ], + "macos": { + "key": "These defaults apply only to this project on this computer. Run configurations inherit them unless overridden. Use the Project save scope in a run configuration to share settings with your team.", + "arguments": [] + } + }, + { + "id": "native.theseOverridesAffectOnlyThisRunConfigurationClearAPathToInheritTheProjectEnvironment", + "description": "Native UI: These overrides affect only this run configuration. Clear a path to inherit the project environment.", + "platforms": [ + "macos" + ], + "macos": { + "key": "These overrides affect only this run configuration. Clear a path to inherit the project environment.", + "arguments": [] + } + }, + { + "id": "native.theseProcessesLeftTheirTerminalOrToolToKeepRunningInTheBackgroundLikeDaemonsAn39708c186a", + "description": "Native UI: These processes left their terminal or tool to keep running in the background, like daemons and dev servers. Checked items end when Lithe quits; unchecked items keep running.", + "platforms": [ + "macos" + ], + "macos": { + "key": "These processes left their terminal or tool to keep running in the background, like daemons and dev servers. Checked items end when Lithe quits; unchecked items keep running.", + "arguments": [] + } + }, + { + "id": "native.theseProgramsWereStartedInLitheBeforeItClosedUnexpectedlyCheckedItemsAreEndedN3cfc802d12", + "description": "Native UI: These programs were started in Lithe before it closed unexpectedly. Checked items are ended now; unchecked items keep running and are not tracked anymore.", + "platforms": [ + "macos" + ], + "macos": { + "key": "These programs were started in Lithe before it closed unexpectedly. Checked items are ended now; unchecked items keep running and are not tracked anymore.", + "arguments": [] + } + }, + { + "id": "native.thinking", + "description": "Native UI: Thinking", + "platforms": [ + "macos" + ], + "macos": { + "key": "Thinking", + "arguments": [] + } + }, + { + "id": "native.thinking.variant2", + "description": "Native UI: Thinking…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Thinking…", + "arguments": [] + } + }, + { + "id": "native.thinkingLevel", + "description": "Native UI: Thinking level", + "platforms": [ + "macos" + ], + "macos": { + "key": "Thinking level", + "arguments": [] + } + }, + { + "id": "native.thinkingProcess", + "description": "Native UI: Thinking process", + "platforms": [ + "macos" + ], + "macos": { + "key": "Thinking process", + "arguments": [] + } + }, + { + "id": "native.thisActionCannotBeUndoneByLithe", + "description": "Native UI: This action cannot be undone by Lithe.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This action cannot be undone by Lithe.", + "arguments": [] + } + }, + { + "id": "native.thisAddressCannotBeOpenedHere", + "description": "Native UI: This address cannot be opened here.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This address cannot be opened here.", + "arguments": [] + } + }, + { + "id": "native.thisAgent", + "description": "Native UI: This Agent", + "platforms": [ + "macos" + ], + "macos": { + "key": "This Agent", + "arguments": [] + } + }, + { + "id": "native.thisAgentCannotReopenEarlierConversationsStartANewConversation", + "description": "Native UI: This Agent cannot reopen earlier conversations. Start a new conversation.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This Agent cannot reopen earlier conversations. Start a new conversation.", + "arguments": [] + } + }, + { + "id": "native.thisAgentHasNoGlobalMCPConfigurationFile", + "description": "Native UI: This Agent has no global MCP configuration file.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This Agent has no global MCP configuration file.", + "arguments": [] + } + }, + { + "id": "native.thisAgentIsNoLongerConfiguredOpenAgentSettingsToConfigureIt", + "description": "Native UI: This Agent is no longer configured. Open Agent settings to configure it.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This Agent is no longer configured. Open Agent settings to configure it.", + "arguments": [] + } + }, + { + "id": "native.thisAgentSProviderHasNoAPIKeyUpdateItsLocalConfigurationOrEditTheCustomProvidebf9a7aac24", + "description": "Native UI: This Agent's provider has no API key. Update its local configuration or edit the custom provider in the panel settings.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This Agent's provider has no API key. Update its local configuration or edit the custom provider in the panel settings.", + "arguments": [] + } + }, + { + "id": "native.thisBranchHasNoCommitsYet", + "description": "Native UI: This branch has no commits yet", + "platforms": [ + "macos" + ], + "macos": { + "key": "This branch has no commits yet", + "arguments": [] + } + }, + { + "id": "native.thisBuildSystemAnswersThroughAProviderProcessWhichLitheForMacOSDoesNotRunYet", + "description": "Native UI: This build system answers through a provider process, which Lithe for macOS does not run yet.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This build system answers through a provider process, which Lithe for macOS does not run yet.", + "arguments": [] + } + }, + { + "id": "native.thisComputer", + "description": "Native UI: This computer", + "platforms": [ + "macos" + ], + "macos": { + "key": "This computer", + "arguments": [] + } + }, + { + "id": "native.thisComputerThisProject", + "description": "Native UI: This computer · This project", + "platforms": [ + "macos" + ], + "macos": { + "key": "This computer · This project", + "arguments": [] + } + }, + { + "id": "native.thisConversationIsNoLongerAvailableChooseAnotherConversation", + "description": "Native UI: This conversation is no longer available. Choose another conversation.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This conversation is no longer available. Choose another conversation.", + "arguments": [] + } + }, + { + "id": "native.thisDiffHasTooManyLinesForComparisonShowingTheReportedBeforeAndAfterText", + "description": "Native UI: This diff has too many lines for comparison. Showing the reported before and after text.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This diff has too many lines for comparison. Showing the reported before and after text.", + "arguments": [] + } + }, + { + "id": "native.thisDocumentIsReadOnly", + "description": "Native UI: This document is read-only", + "platforms": [ + "macos" + ], + "macos": { + "key": "This document is read-only", + "arguments": [] + } + }, + { + "id": "native.thisDoesNotDeleteTheBranchOrCommitsThePullRequestCanBeReopenedLater", + "description": "Native UI: This does not delete the branch or commits. The pull request can be reopened later.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This does not delete the branch or commits. The pull request can be reopened later.", + "arguments": [] + } + }, + { + "id": "native.thisEntryBelongsToTheOpenProjectAndIsManagedByLitheIDEAccess", + "description": "Native UI: This entry belongs to the open project and is managed by Lithe IDE access.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This entry belongs to the open project and is managed by Lithe IDE access.", + "arguments": [] + } + }, + { + "id": "native.thisFileCannotBeDisplayedAsText", + "description": "Native UI: This file cannot be displayed as text", + "platforms": [ + "macos" + ], + "macos": { + "key": "This file cannot be displayed as text", + "arguments": [] + } + }, + { + "id": "native.thisFileChangedOutsideLitheWhileYouHadUnsavedEdits", + "description": "Native UI: This file changed outside Lithe while you had unsaved edits.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This file changed outside Lithe while you had unsaved edits.", + "arguments": [] + } + }, + { + "id": "native.thisFileHasUnsavedEditorChangesSaveOrDiscardThemBeforeRollingBackTheAgentEdit", + "description": "Native UI: This file has unsaved editor changes. Save or discard them before rolling back the Agent edit.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This file has unsaved editor changes. Save or discard them before rolling back the Agent edit.", + "arguments": [] + } + }, + { + "id": "native.thisFileIsOutsideTheCurrentProject", + "description": "Native UI: This file is outside the current project.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This file is outside the current project.", + "arguments": [] + } + }, + { + "id": "native.thisFileWasDeletedOutsideLitheYourEditorContentIsPreserved", + "description": "Native UI: This file was deleted outside Lithe. Your editor content is preserved.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This file was deleted outside Lithe. Your editor content is preserved.", + "arguments": [] + } + }, + { + "id": "native.thisFolderAlreadyBelongsToAGitRepository", + "description": "Native UI: This folder already belongs to a Git repository", + "platforms": [ + "macos" + ], + "macos": { + "key": "This folder already belongs to a Git repository", + "arguments": [] + } + }, + { + "id": "native.thisMac", + "description": "Native UI: This Mac", + "platforms": [ + "macos" + ], + "macos": { + "key": "This Mac", + "arguments": [] + } + }, + { + "id": "native.thisMacOnly", + "description": "Native UI: This Mac only", + "platforms": [ + "macos" + ], + "macos": { + "key": "This Mac only", + "arguments": [] + } + }, + { + "id": "native.thisPage", + "description": "Native UI: This page", + "platforms": [ + "macos" + ], + "macos": { + "key": "This page", + "arguments": [] + } + }, + { + "id": "native.thisPermanentlyDeletesUncommittedAndUntrackedFilesInTheBranchItselfIsKept", + "description": "Native UI: This permanently deletes uncommitted and untracked files in '%@'. The branch itself is kept.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This permanently deletes uncommitted and untracked files in '%@'. The branch itself is kept.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.thisPluginAddsNoOptionsToThisPage", + "description": "Native UI: This plugin adds no options to this page.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This plugin adds no options to this page.", + "arguments": [] + } + }, + { + "id": "native.thisPluginDeclaresNoFeatures", + "description": "Native UI: This plugin declares no features.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This plugin declares no features.", + "arguments": [] + } + }, + { + "id": "native.thisPluginDeclaresNoSettings", + "description": "Native UI: This plugin declares no settings.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This plugin declares no settings.", + "arguments": [] + } + }, + { + "id": "native.thisPluginIsMaintainedAndSignedByLitheReportProblemsInTheSourceRepository", + "description": "Native UI: This plugin is maintained and signed by Lithe. Report problems in the source repository.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This plugin is maintained and signed by Lithe. Report problems in the source repository.", + "arguments": [] + } + }, + { + "id": "native.thisPluginIsMaintainedByACommunityDeveloperNotByLitheLitheVerifiedThatTheDownl1989920b43", + "description": "Native UI: This plugin is maintained by a community developer, not by Lithe. Lithe verified that the download matches the plugin repository, and will show what the plugin can run before installing it. Report problems with the plugin in its own repository.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This plugin is maintained by a community developer, not by Lithe. Lithe verified that the download matches the plugin repository, and will show what the plugin can run before installing it. Report problems with the plugin in its own repository.", + "arguments": [] + } + }, + { + "id": "native.thisPluginRequiresANewerVersionOfLithe", + "description": "Native UI: This plugin requires a newer version of Lithe.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This plugin requires a newer version of Lithe.", + "arguments": [] + } + }, + { + "id": "native.thisPreviewContainsChangesThatHaveNotBeenOfficiallyReleasedAndMayBeUnstable", + "description": "Native UI: This preview contains changes that have not been officially released and may be unstable.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This preview contains changes that have not been officially released and may be unstable.", + "arguments": [] + } + }, + { + "id": "native.thisProject", + "description": "Native UI: This project", + "platforms": [ + "macos" + ], + "macos": { + "key": "This project", + "arguments": [] + } + }, + { + "id": "native.thisProjectIsNotAGitRepository", + "description": "Native UI: This project is not a Git repository", + "platforms": [ + "macos" + ], + "macos": { + "key": "This project is not a Git repository", + "arguments": [] + } + }, + { + "id": "native.thisRebaseSessionIsNoLongerActiveInTheCurrentRepository", + "description": "Native UI: This rebase session is no longer active in the current repository.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This rebase session is no longer active in the current repository.", + "arguments": [] + } + }, + { + "id": "native.thisRecoveryResultIsNoLongerActiveInTheCurrentRepository", + "description": "Native UI: This recovery result is no longer active in the current repository.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This recovery result is no longer active in the current repository.", + "arguments": [] + } + }, + { + "id": "native.thisRemovesGitRegistrationsWhoseCheckoutDirectoriesNoLongerExistItDoesNotDeleteBranches", + "description": "Native UI: This removes Git registrations whose checkout directories no longer exist. It does not delete branches.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This removes Git registrations whose checkout directories no longer exist. It does not delete branches.", + "arguments": [] + } + }, + { + "id": "native.thisRemovesTheSavedPatchFromLitheAndCannotBeUndone", + "description": "Native UI: This removes the saved patch from Lithe and cannot be undone.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This removes the saved patch from Lithe and cannot be undone.", + "arguments": [] + } + }, + { + "id": "native.thisRemovesTheStashFromGitAndCannotBeUndone", + "description": "Native UI: This removes the stash from Git and cannot be undone.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This removes the stash from Git and cannot be undone.", + "arguments": [] + } + }, + { + "id": "native.thisRemovesTheTagFromTheRepositoryAndAffectsCollaboratorsWhoReferenceItYouCanR8fb0a4ab8b", + "description": "Native UI: This removes the tag from the repository and affects collaborators who reference it. You can restore it from the banner afterwards.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This removes the tag from the repository and affects collaborators who reference it. You can restore it from the banner afterwards.", + "arguments": [] + } + }, + { + "id": "native.thisRepository", + "description": "Native UI: This repository", + "platforms": [ + "macos" + ], + "macos": { + "key": "This repository", + "arguments": [] + } + }, + { + "id": "native.thisResponseIsSentOnlyToTheRunningGitOperation", + "description": "Native UI: This response is sent only to the running Git operation.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This response is sent only to the running Git operation.", + "arguments": [] + } + }, + { + "id": "native.thisSendsTheCurrentBranchToItsConfiguredRemote", + "description": "Native UI: This sends the current branch to its configured remote.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This sends the current branch to its configured remote.", + "arguments": [] + } + }, + { + "id": "native.thisSubprojectCanUseADifferentJDKFromOtherBackendsInTheSameWorkspace", + "description": "Native UI: This subproject can use a different JDK from other backends in the same workspace.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This subproject can use a different JDK from other backends in the same workspace.", + "arguments": [] + } + }, + { + "id": "native.thisSubprojectDoesNotUseAJDK", + "description": "Native UI: This subproject does not use a JDK.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This subproject does not use a JDK.", + "arguments": [] + } + }, + { + "id": "native.thisTurn", + "description": "Native UI: This turn", + "platforms": [ + "macos" + ], + "macos": { + "key": "This turn", + "arguments": [] + } + }, + { + "id": "native.thisUpdatesOnGitHubAndCannotBeUndoneFromLithe", + "description": "Native UI: This updates %@ on GitHub and cannot be undone from Lithe.", + "platforms": [ + "macos" + ], + "macos": { + "key": " This updates %@ on GitHub and cannot be undone from Lithe.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.thisValueIsNotAllowed", + "description": "Native UI: This value is not allowed.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This value is not allowed.", + "arguments": [] + } + }, + { + "id": "native.thisVersionOfLitheCannotReadUpdateManifestSchemaOpenTheReleasePageAndUpdateManually", + "description": "Native UI: This version of Lithe cannot read update manifest schema %@. Open the Release page and update manually.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This version of Lithe cannot read update manifest schema %@. Open the Release page and update manually.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.thisWillStartLldDetectedServices", + "description": "Native UI: This will start %lld detected services.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This will start %lld detected services.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.thisWindow", + "description": "Native UI: This Window", + "platforms": [ + "macos" + ], + "macos": { + "key": "This Window", + "arguments": [] + } + }, + { + "id": "native.thisWindow.variant2", + "description": "Native UI: This window", + "platforms": [ + "macos" + ], + "macos": { + "key": "This window", + "arguments": [] + } + }, + { + "id": "native.thisWorktreeHasADetachedHEADPublishItAsABranchBeforeCreatingAPullRequest", + "description": "Native UI: This worktree has a detached HEAD. Publish it as a branch before creating a pull request.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This worktree has a detached HEAD. Publish it as a branch before creating a pull request.", + "arguments": [] + } + }, + { + "id": "native.thisWorktreeHasNoUncommittedChanges", + "description": "Native UI: This worktree has no uncommitted changes.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This worktree has no uncommitted changes.", + "arguments": [] + } + }, + { + "id": "native.threads", + "description": "Native UI: Threads", + "platforms": [ + "macos" + ], + "macos": { + "key": "Threads", + "arguments": [] + } + }, + { + "id": "native.timeToExit", + "description": "Native UI: Time to exit", + "platforms": [ + "macos" + ], + "macos": { + "key": "Time to exit", + "arguments": [] + } + }, + { + "id": "native.timedOut", + "description": "Native UI: %@ timed out.", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ timed out.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.timedOut.variant2", + "description": "Native UI: Timed Out", + "platforms": [ + "macos" + ], + "macos": { + "key": "Timed Out", + "arguments": [] + } + }, + { + "id": "native.title", + "description": "Native UI: Title", + "platforms": [ + "macos" + ], + "macos": { + "key": "Title", + "arguments": [] + } + }, + { + "id": "native.toDebugYourCodeDoOneOfTheFollowing", + "description": "Native UI: To debug your code, do one of the following:", + "platforms": [ + "macos" + ], + "macos": { + "key": "To debug your code, do one of the following:", + "arguments": [] + } + }, + { + "id": "native.toTrackChangesToCode", + "description": "Native UI: To track changes to code:", + "platforms": [ + "macos" + ], + "macos": { + "key": "To track changes to code:", + "arguments": [] + } + }, + { + "id": "native.today", + "description": "Native UI: Today", + "platforms": [ + "macos" + ], + "macos": { + "key": "Today", + "arguments": [] + } + }, + { + "id": "native.toggle", + "description": "Native UI: Toggle %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Toggle %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.toggleBreakpointAtCaret", + "description": "Native UI: Toggle breakpoint at caret", + "platforms": [ + "macos" + ], + "macos": { + "key": "Toggle breakpoint at caret", + "arguments": [] + } + }, + { + "id": "native.toggleDebug", + "description": "Native UI: Toggle Debug", + "platforms": [ + "macos" + ], + "macos": { + "key": "Toggle Debug", + "arguments": [] + } + }, + { + "id": "native.toggleFavorite", + "description": "Native UI: Toggle favorite", + "platforms": [ + "macos" + ], + "macos": { + "key": "Toggle favorite", + "arguments": [] + } + }, + { + "id": "native.toggleGitLog", + "description": "Native UI: Toggle Git Log", + "platforms": [ + "macos" + ], + "macos": { + "key": "Toggle Git Log", + "arguments": [] + } + }, + { + "id": "native.toggleLineBreakpoint", + "description": "Native UI: Toggle Line Breakpoint", + "platforms": [ + "macos" + ], + "macos": { + "key": "Toggle Line Breakpoint", + "arguments": [] + } + }, + { + "id": "native.toggleMaven", + "description": "Native UI: Toggle Maven", + "platforms": [ + "macos" + ], + "macos": { + "key": "Toggle Maven", + "arguments": [] + } + }, + { + "id": "native.togglePreview", + "description": "Native UI: Toggle preview", + "platforms": [ + "macos" + ], + "macos": { + "key": "Toggle preview", + "arguments": [] + } + }, + { + "id": "native.toggleProblems", + "description": "Native UI: Toggle Problems", + "platforms": [ + "macos" + ], + "macos": { + "key": "Toggle Problems", + "arguments": [] + } + }, + { + "id": "native.toggleRun", + "description": "Native UI: Toggle Run", + "platforms": [ + "macos" + ], + "macos": { + "key": "Toggle Run", + "arguments": [] + } + }, + { + "id": "native.toggleTerminal", + "description": "Native UI: Toggle Terminal", + "platforms": [ + "macos" + ], + "macos": { + "key": "Toggle Terminal", + "arguments": [] + } + }, + { + "id": "native.toggleTests", + "description": "Native UI: Toggle Tests", + "platforms": [ + "macos" + ], + "macos": { + "key": "Toggle Tests", + "arguments": [] + } + }, + { + "id": "native.tok", + "description": "Native UI: %@ tok", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ tok", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.tokS", + "description": "Native UI: %@ tok/s", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ tok/s", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.tokenCountsReportedByTheAgentAccountingScopeDependsOnTheAgent", + "description": "Native UI: Token counts reported by the Agent. Accounting scope depends on the Agent.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Token counts reported by the Agent. Accounting scope depends on the Agent.", + "arguments": [] + } + }, + { + "id": "native.tokenUsage", + "description": "Native UI: Token usage", + "platforms": [ + "macos" + ], + "macos": { + "key": "Token usage", + "arguments": [] + } + }, + { + "id": "native.tomlSupport", + "description": "Native UI: TOML Support", + "platforms": [ + "macos" + ], + "macos": { + "key": "TOML Support", + "arguments": [] + } + }, + { + "id": "native.tool", + "description": "Native UI: Tool", + "platforms": [ + "macos" + ], + "macos": { + "key": "Tool", + "arguments": [] + } + }, + { + "id": "native.toolActivityLld", + "description": "Native UI: Tool activity (%lld)", + "platforms": [ + "macos" + ], + "macos": { + "key": "Tool activity (%lld)", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.toolAndPermissionTime", + "description": "Native UI: Tool and permission time", + "platforms": [ + "macos" + ], + "macos": { + "key": "Tool and permission time", + "arguments": [] + } + }, + { + "id": "native.toolCall", + "description": "Native UI: Tool call", + "platforms": [ + "macos" + ], + "macos": { + "key": "Tool call", + "arguments": [] + } + }, + { + "id": "native.toolCalls", + "description": "Native UI: Tool calls", + "platforms": [ + "macos" + ], + "macos": { + "key": "Tool calls", + "arguments": [] + } + }, + { + "id": "native.toolDefinitions", + "description": "Native UI: Tool definitions", + "platforms": [ + "macos" + ], + "macos": { + "key": "Tool definitions", + "arguments": [] + } + }, + { + "id": "native.toolWindow", + "description": "Native UI: Tool Window", + "platforms": [ + "macos" + ], + "macos": { + "key": "Tool Window", + "arguments": [] + } + }, + { + "id": "native.toolWindows", + "description": "Native UI: Tool Windows", + "platforms": [ + "macos" + ], + "macos": { + "key": "Tool Windows", + "arguments": [] + } + }, + { + "id": "native.toolbarActions", + "description": "Native UI: Toolbar Actions", + "platforms": [ + "macos" + ], + "macos": { + "key": "Toolbar Actions", + "arguments": [] + } + }, + { + "id": "native.tools", + "description": "Native UI: Tools", + "platforms": [ + "macos" + ], + "macos": { + "key": "Tools", + "arguments": [] + } + }, + { + "id": "native.toolsSent", + "description": "Native UI: Tools sent", + "platforms": [ + "macos" + ], + "macos": { + "key": "Tools sent", + "arguments": [] + } + }, + { + "id": "native.total", + "description": "Native UI: Total", + "platforms": [ + "macos" + ], + "macos": { + "key": "Total", + "arguments": [] + } + }, + { + "id": "native.totalTokens", + "description": "Native UI: Total tokens: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Total tokens: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.trackingBranch", + "description": "Native UI: Tracking Branch", + "platforms": [ + "macos" + ], + "macos": { + "key": "Tracking Branch", + "arguments": [] + } + }, + { + "id": "native.transport", + "description": "Native UI: Transport", + "platforms": [ + "macos" + ], + "macos": { + "key": "Transport", + "arguments": [] + } + }, + { + "id": "native.tryABranchNameOrCheckoutPath", + "description": "Native UI: Try a branch name or checkout path.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Try a branch name or checkout path.", + "arguments": [] + } + }, + { + "id": "native.tryADifferentSearchTerm", + "description": "Native UI: Try a different search term.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Try a different search term.", + "arguments": [] + } + }, + { + "id": "native.tryAgain", + "description": "Native UI: Try Again", + "platforms": [ + "macos" + ], + "macos": { + "key": "Try Again", + "arguments": [] + } + }, + { + "id": "native.tryAnotherTitleAuthorNumberOrLabel", + "description": "Native UI: Try another title, author, number, or label.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Try another title, author, number, or label.", + "arguments": [] + } + }, + { + "id": "native.tryAnotherTitleOrSessionID", + "description": "Native UI: Try another title or session ID.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Try another title or session ID.", + "arguments": [] + } + }, + { + "id": "native.ttft", + "description": "Native UI: TTFT %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "TTFT %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.turnOnMCPToGenerateAConfigurationForAgents", + "description": "Native UI: Turn on MCP to generate a configuration for agents.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Turn on MCP to generate a configuration for agents.", + "arguments": [] + } + }, + { + "id": "native.turnOnModelCallStatisticsInAgentSettingsAndReconnectToSeeModelCallsTimeToFirstc4f5efa5f9", + "description": "Native UI: Turn on Model call statistics in Agent settings and reconnect to see model calls, time to first token and output speed.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Turn on Model call statistics in Agent settings and reconnect to see model calls, time to first token and output speed.", + "arguments": [] + } + }, + { + "id": "native.turnOnModelCallStatisticsInAgentSettingsAndReconnectToSeeTheMCPToolsSentWithEachRequest", + "description": "Native UI: Turn on Model call statistics in Agent settings and reconnect to see the MCP tools sent with each request.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Turn on Model call statistics in Agent settings and reconnect to see the MCP tools sent with each request.", + "arguments": [] + } + }, + { + "id": "native.turnOnModelCallStatisticsInAgentSettingsAndReconnectToSeeTheSystemPromptToolDeffd4968e05", + "description": "Native UI: Turn on Model call statistics in Agent settings and reconnect to see the system prompt, tool definitions and messages.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Turn on Model call statistics in Agent settings and reconnect to see the system prompt, tool definitions and messages.", + "arguments": [] + } + }, + { + "id": "native.turnOnModelCallStatisticsReconnectAndSendAMessageToSeeTheBreakdown", + "description": "Native UI: Turn on Model call statistics, reconnect and send a message to see the breakdown.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Turn on Model call statistics, reconnect and send a message to see the breakdown.", + "arguments": [] + } + }, + { + "id": "native.turnUsage", + "description": "Native UI: Turn usage", + "platforms": [ + "macos" + ], + "macos": { + "key": "Turn usage", + "arguments": [] + } + }, + { + "id": "native.turningThisOffStopsEveryAgentProcess", + "description": "Native UI: Turning this off stops every Agent process.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Turning this off stops every Agent process.", + "arguments": [] + } + }, + { + "id": "native.turns", + "description": "Native UI: Turns", + "platforms": [ + "macos" + ], + "macos": { + "key": "Turns", + "arguments": [] + } + }, + { + "id": "native.type", + "description": "Native UI: Type", + "platforms": [ + "macos" + ], + "macos": { + "key": "Type", + "arguments": [] + } + }, + { + "id": "native.typeToSeeCommands", + "description": "Native UI: Type / to see commands", + "platforms": [ + "macos" + ], + "macos": { + "key": "Type / to see commands", + "arguments": [] + } + }, + { + "id": "native.typeToSeeOptions", + "description": "Native UI: Type / to see options", + "platforms": [ + "macos" + ], + "macos": { + "key": "Type / to see options", + "arguments": [] + } + }, + { + "id": "native.typeYourTaskForCommandsOrForFiles", + "description": "Native UI: Type your task, / for commands or @ for files", + "platforms": [ + "macos" + ], + "macos": { + "key": "Type your task, / for commands or @ for files", + "arguments": [] + } + }, + { + "id": "native.typeYourTaskOrForCommands", + "description": "Native UI: Type your task or / for commands", + "platforms": [ + "macos" + ], + "macos": { + "key": "Type your task or / for commands", + "arguments": [] + } + }, + { + "id": "native.unableToCreateTheMavenDependencyPlan", + "description": "Native UI: Unable to create the Maven dependency plan.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Unable to create the Maven dependency plan.", + "arguments": [] + } + }, + { + "id": "native.unableToParseMavenDependenciesForThisModule", + "description": "Native UI: Unable to parse Maven dependencies for this module.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Unable to parse Maven dependencies for this module.", + "arguments": [] + } + }, + { + "id": "native.unableToResolveMavenDependencies", + "description": "Native UI: Unable to resolve Maven dependencies.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Unable to resolve Maven dependencies.", + "arguments": [] + } + }, + { + "id": "native.unableToSignInPleaseTryAgain", + "description": "Native UI: Unable to sign in. Please try again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Unable to sign in. Please try again.", + "arguments": [] + } + }, + { + "id": "native.unableToStartMavenDependencyResolution", + "description": "Native UI: Unable to start Maven dependency resolution: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Unable to start Maven dependency resolution: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.unarchiveAndOpen", + "description": "Native UI: Unarchive and open", + "platforms": [ + "macos" + ], + "macos": { + "key": "Unarchive and open", + "arguments": [] + } + }, + { + "id": "native.unarchiveThisConversation", + "description": "Native UI: Unarchive this conversation?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Unarchive this conversation?", + "arguments": [] + } + }, + { + "id": "native.unavailable", + "description": "Native UI: Unavailable", + "platforms": [ + "macos" + ], + "macos": { + "key": "Unavailable", + "arguments": [] + } + }, + { + "id": "native.uncachedInput", + "description": "Native UI: Uncached input", + "platforms": [ + "macos" + ], + "macos": { + "key": "Uncached input", + "arguments": [] + } + }, + { + "id": "native.unchanged", + "description": "Native UI: Unchanged", + "platforms": [ + "macos" + ], + "macos": { + "key": "Unchanged", + "arguments": [] + } + }, + { + "id": "native.unchecked", + "description": "Native UI: Unchecked", + "platforms": [ + "macos" + ], + "macos": { + "key": "Unchecked", + "arguments": [] + } + }, + { + "id": "native.uncommittedChangesBlockThisCherryPick", + "description": "Native UI: Uncommitted changes block this cherry-pick", + "platforms": [ + "macos" + ], + "macos": { + "key": "Uncommitted changes block this cherry-pick", + "arguments": [] + } + }, + { + "id": "native.uncommittedChangesBlockThisMerge", + "description": "Native UI: Uncommitted changes block this merge", + "platforms": [ + "macos" + ], + "macos": { + "key": "Uncommitted changes block this merge", + "arguments": [] + } + }, + { + "id": "native.uncommittedChangesBlockThisRebase", + "description": "Native UI: Uncommitted changes block this rebase", + "platforms": [ + "macos" + ], + "macos": { + "key": "Uncommitted changes block this rebase", + "arguments": [] + } + }, + { + "id": "native.uncommittedChangesBlockThisRevert", + "description": "Native UI: Uncommitted changes block this revert", + "platforms": [ + "macos" + ], + "macos": { + "key": "Uncommitted changes block this revert", + "arguments": [] + } + }, + { + "id": "native.uncommittedChangesStayInThisWorktreeAndAreNotIncludedInThePullRequest", + "description": "Native UI: Uncommitted changes stay in this worktree and are not included in the pull request.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Uncommitted changes stay in this worktree and are not included in the pull request.", + "arguments": [] + } + }, + { + "id": "native.uncommittedSubmoduleChanges", + "description": "Native UI: Uncommitted submodule changes", + "platforms": [ + "macos" + ], + "macos": { + "key": "Uncommitted submodule changes", + "arguments": [] + } + }, + { + "id": "native.undo", + "description": "Native UI: Undo", + "platforms": [ + "macos" + ], + "macos": { + "key": "Undo", + "arguments": [] + } + }, + { + "id": "native.undoCommit", + "description": "Native UI: Undo Commit", + "platforms": [ + "macos" + ], + "macos": { + "key": "Undo Commit", + "arguments": [] + } + }, + { + "id": "native.undoCommit.variant2", + "description": "Native UI: Undo Commit…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Undo Commit…", + "arguments": [] + } + }, + { + "id": "native.unfiledCONNECTIONS", + "description": "Native UI: UNFILED CONNECTIONS", + "platforms": [ + "macos" + ], + "macos": { + "key": "UNFILED CONNECTIONS", + "arguments": [] + } + }, + { + "id": "native.uninstall", + "description": "Native UI: Uninstall %@?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Uninstall %@?", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.uninstallFromTheInstalledTabAndRestartLitheFirstItIsTheRetiredOfficialVersionO07265e4f97", + "description": "Native UI: Uninstall %@ from the Installed tab and restart Lithe first. It is the retired official version of this plugin and supports the same language.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Uninstall %@ from the Installed tab and restart Lithe first. It is the retired official version of this plugin and supports the same language.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.uninstallPHPSupport", + "description": "Native UI: Uninstall PHP Support?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Uninstall PHP Support?", + "arguments": [] + } + }, + { + "id": "native.unknownError", + "description": "Native UI: Unknown error", + "platforms": [ + "macos" + ], + "macos": { + "key": "Unknown error", + "arguments": [] + } + }, + { + "id": "native.unknownSource", + "description": "Native UI: Unknown source", + "platforms": [ + "macos" + ], + "macos": { + "key": "Unknown source", + "arguments": [] + } + }, + { + "id": "native.unlink", + "description": "Native UI: Unlink", + "platforms": [ + "macos" + ], + "macos": { + "key": "Unlink", + "arguments": [] + } + }, + { + "id": "native.unlockTheWorktreeBeforeRemovingIt", + "description": "Native UI: Unlock the worktree before removing it.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Unlock the worktree before removing it.", + "arguments": [] + } + }, + { + "id": "native.unlockWorktree", + "description": "Native UI: Unlock Worktree", + "platforms": [ + "macos" + ], + "macos": { + "key": "Unlock Worktree", + "arguments": [] + } + }, + { + "id": "native.unlocked", + "description": "Native UI: Unlocked", + "platforms": [ + "macos" + ], + "macos": { + "key": "Unlocked", + "arguments": [] + } + }, + { + "id": "native.unlocked.variant2", + "description": "Native UI: Unlocked %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Unlocked %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.unmuteCamera", + "description": "Native UI: Unmute Camera", + "platforms": [ + "macos" + ], + "macos": { + "key": "Unmute Camera", + "arguments": [] + } + }, + { + "id": "native.unmuteLineBreakpoints", + "description": "Native UI: Unmute Line Breakpoints", + "platforms": [ + "macos" + ], + "macos": { + "key": "Unmute Line Breakpoints", + "arguments": [] + } + }, + { + "id": "native.unmuteMicrophone", + "description": "Native UI: Unmute Microphone", + "platforms": [ + "macos" + ], + "macos": { + "key": "Unmute Microphone", + "arguments": [] + } + }, + { + "id": "native.unnamedProvider", + "description": "Native UI: Unnamed provider", + "platforms": [ + "macos" + ], + "macos": { + "key": "Unnamed provider", + "arguments": [] + } + }, + { + "id": "native.unnamedService", + "description": "Native UI: Unnamed service", + "platforms": [ + "macos" + ], + "macos": { + "key": "Unnamed service", + "arguments": [] + } + }, + { + "id": "native.unpinConfiguration", + "description": "Native UI: Unpin configuration", + "platforms": [ + "macos" + ], + "macos": { + "key": "Unpin configuration", + "arguments": [] + } + }, + { + "id": "native.unrestrictedAccessToTheInternetAndAnyFileOnYourComputer", + "description": "Native UI: Unrestricted access to the internet and any file on your computer", + "platforms": [ + "macos" + ], + "macos": { + "key": "Unrestricted access to the internet and any file on your computer", + "arguments": [] + } + }, + { + "id": "native.unsignedDevelopmentPackageReleaseBuildsOfLitheWillNotLoadIt", + "description": "Native UI: Unsigned development package. Release builds of Lithe will not load it.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Unsigned development package. Release builds of Lithe will not load it.", + "arguments": [] + } + }, + { + "id": "native.unstage", + "description": "Native UI: Unstage", + "platforms": [ + "macos" + ], + "macos": { + "key": "Unstage", + "arguments": [] + } + }, + { + "id": "native.unstageAllFiles", + "description": "Native UI: Unstage all files", + "platforms": [ + "macos" + ], + "macos": { + "key": "Unstage all files", + "arguments": [] + } + }, + { + "id": "native.unstageAllFilesInRepository", + "description": "Native UI: Unstage all files in repository", + "platforms": [ + "macos" + ], + "macos": { + "key": "Unstage all files in repository", + "arguments": [] + } + }, + { + "id": "native.unstageFile", + "description": "Native UI: Unstage file", + "platforms": [ + "macos" + ], + "macos": { + "key": "Unstage file", + "arguments": [] + } + }, + { + "id": "native.unstageThisChangeBlock", + "description": "Native UI: Unstage this change block", + "platforms": [ + "macos" + ], + "macos": { + "key": "Unstage this change block", + "arguments": [] + } + }, + { + "id": "native.unstaged", + "description": "Native UI: Unstaged", + "platforms": [ + "macos" + ], + "macos": { + "key": "Unstaged", + "arguments": [] + } + }, + { + "id": "native.unstagedChanges", + "description": "Native UI: Unstaged changes", + "platforms": [ + "macos" + ], + "macos": { + "key": "Unstaged changes", + "arguments": [] + } + }, + { + "id": "native.unsupportedContent", + "description": "Native UI: Unsupported content", + "platforms": [ + "macos" + ], + "macos": { + "key": "Unsupported content", + "arguments": [] + } + }, + { + "id": "native.untilUTCISO8601", + "description": "Native UI: Until (UTC ISO 8601)", + "platforms": [ + "macos" + ], + "macos": { + "key": "Until (UTC ISO 8601)", + "arguments": [] + } + }, + { + "id": "native.untitledConversation", + "description": "Native UI: Untitled conversation", + "platforms": [ + "macos" + ], + "macos": { + "key": "Untitled conversation", + "arguments": [] + } + }, + { + "id": "native.untracked", + "description": "Native UI: Untracked", + "platforms": [ + "macos" + ], + "macos": { + "key": "Untracked", + "arguments": [] + } + }, + { + "id": "native.unusedCode", + "description": "Native UI: Unused code", + "platforms": [ + "macos" + ], + "macos": { + "key": "Unused code", + "arguments": [] + } + }, + { + "id": "native.unversionedFiles", + "description": "Native UI: Unversioned Files", + "platforms": [ + "macos" + ], + "macos": { + "key": "Unversioned Files", + "arguments": [] + } + }, + { + "id": "native.upToDate", + "description": "Native UI: Up to date.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Up to date.", + "arguments": [] + } + }, + { + "id": "native.update", + "description": "Native UI: Update", + "platforms": [ + "macos" + ], + "macos": { + "key": "Update", + "arguments": [] + } + }, + { + "id": "native.update.variant2", + "description": "Native UI: Update %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Update %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.updateAfter", + "description": "Native UI: Update %@/%@ after %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Update %@/%@ after %@", + "arguments": [ + "arg1", + "arg2", + "arg3" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string", + "arg3": "string" + } + }, + { + "id": "native.updateAvailable", + "description": "Native UI: Update Available", + "platforms": [ + "macos" + ], + "macos": { + "key": "Update Available", + "arguments": [] + } + }, + { + "id": "native.updateAvailable.variant2", + "description": "Native UI: Update available", + "platforms": [ + "macos" + ], + "macos": { + "key": "Update available", + "arguments": [] + } + }, + { + "id": "native.updateChannel", + "description": "Native UI: Update channel", + "platforms": [ + "macos" + ], + "macos": { + "key": "Update channel", + "arguments": [] + } + }, + { + "id": "native.updateCommunityPlugin", + "description": "Native UI: Update community plugin “%@”?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Update community plugin “%@”?", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.updateCurrentBranch", + "description": "Native UI: Update Current Branch", + "platforms": [ + "macos" + ], + "macos": { + "key": "Update Current Branch", + "arguments": [] + } + }, + { + "id": "native.updateLitheToUseThisConfigurationVersion", + "description": "Native UI: Update Lithe to use this configuration version.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Update Lithe to use this configuration version.", + "arguments": [] + } + }, + { + "id": "native.updateLog", + "description": "Native UI: Update log", + "platforms": [ + "macos" + ], + "macos": { + "key": "Update log", + "arguments": [] + } + }, + { + "id": "native.updateNow", + "description": "Native UI: Update Now", + "platforms": [ + "macos" + ], + "macos": { + "key": "Update Now", + "arguments": [] + } + }, + { + "id": "native.updateParentRepositoryReferences", + "description": "Native UI: Update parent repository references", + "platforms": [ + "macos" + ], + "macos": { + "key": "Update parent repository references", + "arguments": [] + } + }, + { + "id": "native.updateProject", + "description": "Native UI: Update Project", + "platforms": [ + "macos" + ], + "macos": { + "key": "Update Project", + "arguments": [] + } + }, + { + "id": "native.updateProject.variant2", + "description": "Native UI: Update Project…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Update Project…", + "arguments": [] + } + }, + { + "id": "native.updateRequired", + "description": "Native UI: Update required", + "platforms": [ + "macos" + ], + "macos": { + "key": "Update required", + "arguments": [] + } + }, + { + "id": "native.updateStatus", + "description": "Native UI: Update status", + "platforms": [ + "macos" + ], + "macos": { + "key": "Update status", + "arguments": [] + } + }, + { + "id": "native.updateThisCLIUsingItsOriginalInstallerThenCheckAgain", + "description": "Native UI: Update this CLI using its original installer, then check again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Update this CLI using its original installer, then check again.", + "arguments": [] + } + }, + { + "id": "native.updateTo", + "description": "Native UI: Update to", + "platforms": [ + "macos" + ], + "macos": { + "key": "Update to", + "arguments": [] + } + }, + { + "id": "native.updateTo.variant2", + "description": "Native UI: Update to %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Update to %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.updateTo.variant3", + "description": "Native UI: Update to: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Update to: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.updated", + "description": "Native UI: Updated: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Updated: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.updatedReferences", + "description": "Native UI: Updated references: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Updated references: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.updatedWithLithe", + "description": "Native UI: Updated with Lithe", + "platforms": [ + "macos" + ], + "macos": { + "key": "Updated with Lithe", + "arguments": [] + } + }, + { + "id": "native.updates", + "description": "Native UI: Updates", + "platforms": [ + "macos" + ], + "macos": { + "key": "Updates", + "arguments": [] + } + }, + { + "id": "native.updatingCLI", + "description": "Native UI: Updating CLI…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Updating CLI…", + "arguments": [] + } + }, + { + "id": "native.updatingLabelsAndAssignees", + "description": "Native UI: Updating labels and assignees…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Updating labels and assignees…", + "arguments": [] + } + }, + { + "id": "native.updatingLldIncomingLldLocal", + "description": "Native UI: Updating %@ (%lld incoming, %lld local)", + "platforms": [ + "macos" + ], + "macos": { + "key": "Updating %@ (%lld incoming, %lld local)", + "arguments": [ + "arg1", + "arg2", + "arg3" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "integer", + "arg3": "integer" + } + }, + { + "id": "native.updatingPullRequest", + "description": "Native UI: Updating pull request…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Updating pull request…", + "arguments": [] + } + }, + { + "id": "native.upgradeLitheToUseThisRunConfigurationVersion", + "description": "Native UI: Upgrade Lithe to use this run configuration version.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Upgrade Lithe to use this run configuration version.", + "arguments": [] + } + }, + { + "id": "native.upperBoundOfTheJavaLanguageServerSHeapRaiseItForLargeMultiModuleProjects", + "description": "Native UI: Upper bound of the Java language server's heap. Raise it for large multi-module projects.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Upper bound of the Java language server's heap. Raise it for large multi-module projects.", + "arguments": [] + } + }, + { + "id": "native.upstreamBranch", + "description": "Native UI: Upstream branch", + "platforms": [ + "macos" + ], + "macos": { + "key": "Upstream branch", + "arguments": [] + } + }, + { + "id": "native.upstreamCommitS", + "description": "Native UI: upstream commit(s)", + "platforms": [ + "macos" + ], + "macos": { + "key": "upstream commit(s)", + "arguments": [] + } + }, + { + "id": "native.url", + "description": "Native UI: URL", + "platforms": [ + "macos" + ], + "macos": { + "key": "URL", + "arguments": [] + } + }, + { + "id": "native.usages", + "description": "Native UI: Usages", + "platforms": [ + "macos" + ], + "macos": { + "key": "Usages", + "arguments": [] + } + }, + { + "id": "native.useAIWorktreeDirectoryPrivateTmp", + "description": "Native UI: Use AI worktree directory (/private/tmp)", + "platforms": [ + "macos" + ], + "macos": { + "key": "Use AI worktree directory (/private/tmp)", + "arguments": [] + } + }, + { + "id": "native.useAModelIDSupportedByYourProvider", + "description": "Native UI: Use a model ID supported by your provider.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Use a model ID supported by your provider.", + "arguments": [] + } + }, + { + "id": "native.useAPersistentFolderForOngoingWorkTemporaryCheckoutsMayBeRemovedByTheSystem", + "description": "Native UI: Use a persistent folder for ongoing work. Temporary checkouts may be removed by the system.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Use a persistent folder for ongoing work. Temporary checkouts may be removed by the system.", + "arguments": [] + } + }, + { + "id": "native.useAThirdPartyProvider", + "description": "Native UI: Use a third-party provider", + "platforms": [ + "macos" + ], + "macos": { + "key": "Use a third-party provider", + "arguments": [] + } + }, + { + "id": "native.useAValidFileOrDirectoryName", + "description": "Native UI: Use a valid file or directory name", + "platforms": [ + "macos" + ], + "macos": { + "key": "Use a valid file or directory name", + "arguments": [] + } + }, + { + "id": "native.useAnAgentInYourProject", + "description": "Native UI: Use an Agent in your project", + "platforms": [ + "macos" + ], + "macos": { + "key": "Use an Agent in your project", + "arguments": [] + } + }, + { + "id": "native.useAutoBuild", + "description": "Native UI: Use AutoBuild", + "platforms": [ + "macos" + ], + "macos": { + "key": "Use AutoBuild", + "arguments": [] + } + }, + { + "id": "native.useAutoBuildRolling", + "description": "Native UI: Use AutoBuild Rolling?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Use AutoBuild Rolling?", + "arguments": [] + } + }, + { + "id": "native.useConfiguration", + "description": "Native UI: Use %@ configuration", + "platforms": [ + "macos" + ], + "macos": { + "key": "Use %@ configuration", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.useCredentialHelper", + "description": "Native UI: Use credential helper", + "platforms": [ + "macos" + ], + "macos": { + "key": "Use credential helper", + "arguments": [] + } + }, + { + "id": "native.useDefaultMessages", + "description": "Native UI: Use Default Messages", + "platforms": [ + "macos" + ], + "macos": { + "key": "Use Default Messages", + "arguments": [] + } + }, + { + "id": "native.useDetectedJDK", + "description": "Native UI: Use detected JDK", + "platforms": [ + "macos" + ], + "macos": { + "key": "Use detected JDK", + "arguments": [] + } + }, + { + "id": "native.useFastProcessing", + "description": "Native UI: Use fast processing", + "platforms": [ + "macos" + ], + "macos": { + "key": "Use fast processing", + "arguments": [] + } + }, + { + "id": "native.useForm", + "description": "Native UI: Use form", + "platforms": [ + "macos" + ], + "macos": { + "key": "Use form", + "arguments": [] + } + }, + { + "id": "native.useFullHTTPPathForCredentials", + "description": "Native UI: Use full HTTP path for credentials", + "platforms": [ + "macos" + ], + "macos": { + "key": "Use full HTTP path for credentials", + "arguments": [] + } + }, + { + "id": "native.useGitConfiguration", + "description": "Native UI: Use Git configuration", + "platforms": [ + "macos" + ], + "macos": { + "key": "Use Git configuration", + "arguments": [] + } + }, + { + "id": "native.useGitFromPATH", + "description": "Native UI: Use Git from PATH", + "platforms": [ + "macos" + ], + "macos": { + "key": "Use Git from PATH", + "arguments": [] + } + }, + { + "id": "native.useGitSetting", + "description": "Native UI: Use Git setting", + "platforms": [ + "macos" + ], + "macos": { + "key": "Use Git setting", + "arguments": [] + } + }, + { + "id": "native.useLettersDigitsDotsDashesOrUnderscoresTheNameMustStartWithALetterOrDigit", + "description": "Native UI: Use letters, digits, dots, dashes or underscores. The name must start with a letter or digit.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Use letters, digits, dots, dashes or underscores. The name must start with a letter or digit.", + "arguments": [] + } + }, + { + "id": "native.useLocalConfigurationOrAddYourOwnProvider", + "description": "Native UI: Use local configuration or add your own provider.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Use local configuration or add your own provider.", + "arguments": [] + } + }, + { + "id": "native.useMvnwOrDetectedMaven", + "description": "Native UI: Use mvnw or detected Maven", + "platforms": [ + "macos" + ], + "macos": { + "key": "Use mvnw or detected Maven", + "arguments": [] + } + }, + { + "id": "native.useProjectDefault", + "description": "Native UI: Use project default", + "platforms": [ + "macos" + ], + "macos": { + "key": "Use project default", + "arguments": [] + } + }, + { + "id": "native.useProjectJDK", + "description": "Native UI: Use Project JDK", + "platforms": [ + "macos" + ], + "macos": { + "key": "Use Project JDK", + "arguments": [] + } + }, + { + "id": "native.useProjectJDK.variant2", + "description": "Native UI: Use project JDK", + "platforms": [ + "macos" + ], + "macos": { + "key": "Use project JDK", + "arguments": [] + } + }, + { + "id": "native.useProjectJDKOrSystemDefault", + "description": "Native UI: Use project JDK or system default", + "platforms": [ + "macos" + ], + "macos": { + "key": "Use project JDK or system default", + "arguments": [] + } + }, + { + "id": "native.useProjectOrFileDirectory", + "description": "Native UI: Use project or file directory", + "platforms": [ + "macos" + ], + "macos": { + "key": "Use project or file directory", + "arguments": [] + } + }, + { + "id": "native.useServiceJDK", + "description": "Native UI: Use service JDK", + "platforms": [ + "macos" + ], + "macos": { + "key": "Use service JDK", + "arguments": [] + } + }, + { + "id": "native.useSoftWraps", + "description": "Native UI: Use soft wraps", + "platforms": [ + "macos" + ], + "macos": { + "key": "Use soft wraps", + "arguments": [] + } + }, + { + "id": "native.useTemporaryDirectory", + "description": "Native UI: Use temporary directory", + "platforms": [ + "macos" + ], + "macos": { + "key": "Use temporary directory", + "arguments": [] + } + }, + { + "id": "native.useTheDefaultValue", + "description": "Native UI: Use the default value", + "platforms": [ + "macos" + ], + "macos": { + "key": "Use the default value", + "arguments": [] + } + }, + { + "id": "native.useTheFilterMenuToShowOtherSeverities", + "description": "Native UI: Use the filter menu to show other severities.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Use the filter menu to show other severities.", + "arguments": [] + } + }, + { + "id": "native.useTheStandardResponseSpeed", + "description": "Native UI: Use the standard response speed", + "platforms": [ + "macos" + ], + "macos": { + "key": "Use the standard response speed", + "arguments": [] + } + }, + { + "id": "native.useThisWhenARepositoryOrGitDefaultNeedsAOneOffAdjustment", + "description": "Native UI: Use this when a repository or Git default needs a one-off adjustment.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Use this when a repository or Git default needs a one-off adjustment.", + "arguments": [] + } + }, + { + "id": "native.useYourLocalChatGPTAccountNoAPIKeyOrURLIsNeeded", + "description": "Native UI: Use your local ChatGPT account. No API key or URL is needed.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Use your local ChatGPT account. No API key or URL is needed.", + "arguments": [] + } + }, + { + "id": "native.used", + "description": "Native UI: %@ · %@ used", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ · %@ used", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string" + } + }, + { + "id": "native.used.variant2", + "description": "Native UI: Used %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Used %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.usedBy", + "description": "Native UI: Used by: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Used by: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.usedForNewTerminalSessions", + "description": "Native UI: Used for new terminal sessions.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Used for new terminal sessions.", + "arguments": [] + } + }, + { + "id": "native.usedForOrdinaryFetchInEveryProjectOneTimeChoicesCanOverrideTheseDefaults", + "description": "Native UI: Used for ordinary Fetch in every project. One-time choices can override these defaults.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Used for ordinary Fetch in every project. One-time choices can override these defaults.", + "arguments": [] + } + }, + { + "id": "native.usedLldTool", + "description": "Native UI: Used %lld tool", + "platforms": [ + "macos" + ], + "macos": { + "key": "Used %lld tool", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.usedLldTools", + "description": "Native UI: Used %lld tools", + "platforms": [ + "macos" + ], + "macos": { + "key": "Used %lld tools", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.user", + "description": "Native UI: User", + "platforms": [ + "macos" + ], + "macos": { + "key": "User", + "arguments": [] + } + }, + { + "id": "native.userFacingSentenceForReleaseNotes", + "description": "Native UI: User-facing sentence for release notes", + "platforms": [ + "macos" + ], + "macos": { + "key": "User-facing sentence for release notes", + "arguments": [] + } + }, + { + "id": "native.valid", + "description": "Native UI: Valid", + "platforms": [ + "macos" + ], + "macos": { + "key": "Valid", + "arguments": [] + } + }, + { + "id": "native.validValuesAreSavedAsYouType", + "description": "Native UI: Valid values are saved as you type.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Valid values are saved as you type.", + "arguments": [] + } + }, + { + "id": "native.validatingTheCredentialStoredInKeychain", + "description": "Native UI: Validating the credential stored in Keychain…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Validating the credential stored in Keychain…", + "arguments": [] + } + }, + { + "id": "native.value", + "description": "Native UI: Value", + "platforms": [ + "macos" + ], + "macos": { + "key": "Value", + "arguments": [] + } + }, + { + "id": "native.value05Seconds", + "description": "Native UI: 0.5 seconds", + "platforms": [ + "macos" + ], + "macos": { + "key": "0.5 seconds", + "arguments": [] + } + }, + { + "id": "native.value15Seconds", + "description": "Native UI: 1.5 seconds", + "platforms": [ + "macos" + ], + "macos": { + "key": "1.5 seconds", + "arguments": [] + } + }, + { + "id": "native.value1File", + "description": "Native UI: 1 file", + "platforms": [ + "macos" + ], + "macos": { + "key": "1 file", + "arguments": [] + } + }, + { + "id": "native.value1MoreNotification", + "description": "Native UI: 1 more notification", + "platforms": [ + "macos" + ], + "macos": { + "key": "1 more notification", + "arguments": [] + } + }, + { + "id": "native.value2Spaces", + "description": "Native UI: 2 spaces", + "platforms": [ + "macos" + ], + "macos": { + "key": "2 spaces", + "arguments": [] + } + }, + { + "id": "native.value3Seconds", + "description": "Native UI: 3 seconds", + "platforms": [ + "macos" + ], + "macos": { + "key": "3 seconds", + "arguments": [] + } + }, + { + "id": "native.value4Spaces", + "description": "Native UI: 4 spaces", + "platforms": [ + "macos" + ], + "macos": { + "key": "4 spaces", + "arguments": [] + } + }, + { + "id": "native.value5Hours", + "description": "Native UI: 5 hours", + "platforms": [ + "macos" + ], + "macos": { + "key": "5 hours", + "arguments": [] + } + }, + { + "id": "native.value7Days", + "description": "Native UI: 7 days", + "platforms": [ + "macos" + ], + "macos": { + "key": "7 days", + "arguments": [] + } + }, + { + "id": "native.value8Spaces", + "description": "Native UI: 8 spaces", + "platforms": [ + "macos" + ], + "macos": { + "key": "8 spaces", + "arguments": [] + } + }, + { + "id": "native.valuesAreMaskedHereButStoredAsPlainTextInTheAgentSConfigurationFile", + "description": "Native UI: Values are masked here but stored as plain text in the Agent's configuration file.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Values are masked here but stored as plain text in the Agent's configuration file.", + "arguments": [] + } + }, + { + "id": "native.verification", + "description": "Native UI: Verification", + "platforms": [ + "macos" + ], + "macos": { + "key": "Verification", + "arguments": [] + } + }, + { + "id": "native.verified", + "description": "Native UI: Verified", + "platforms": [ + "macos" + ], + "macos": { + "key": "Verified", + "arguments": [] + } + }, + { + "id": "native.verifyingStableRelease", + "description": "Native UI: Verifying stable release…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Verifying stable release…", + "arguments": [] + } + }, + { + "id": "native.version", + "description": "Native UI: Version", + "platforms": [ + "macos" + ], + "macos": { + "key": "Version", + "arguments": [] + } + }, + { + "id": "native.versionControl", + "description": "Native UI: Version Control", + "platforms": [ + "macos" + ], + "macos": { + "key": "Version Control", + "arguments": [] + } + }, + { + "id": "native.versionControlIntegration", + "description": "Native UI: Version control integration", + "platforms": [ + "macos" + ], + "macos": { + "key": "Version control integration", + "arguments": [] + } + }, + { + "id": "native.versionIsAvailable", + "description": "Native UI: Version %@ is available.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Version %@ is available.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.versionIsBuiltInOrInstalledFromDisk", + "description": "Native UI: Version %@ is built in or installed from disk.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Version %@ is built in or installed from disk.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.versionIsInstalled", + "description": "Native UI: Version %@ is installed.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Version %@ is installed.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.versionIsInstalledRestartLitheToLoadIt", + "description": "Native UI: Version %@ is installed. Restart Lithe to load it.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Version %@ is installed. Restart Lithe to load it.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.versionManagedFrom", + "description": "Native UI: version managed from %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "version managed from %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.versionStatus", + "description": "Native UI: Version status", + "platforms": [ + "macos" + ], + "macos": { + "key": "Version status", + "arguments": [] + } + }, + { + "id": "native.versionsSource", + "description": "Native UI: Versions & Source", + "platforms": [ + "macos" + ], + "macos": { + "key": "Versions & Source", + "arguments": [] + } + }, + { + "id": "native.view", + "description": "Native UI: View", + "platforms": [ + "macos" + ], + "macos": { + "key": "View", + "arguments": [] + } + }, + { + "id": "native.viewBreakpoints", + "description": "Native UI: View Breakpoints", + "platforms": [ + "macos" + ], + "macos": { + "key": "View Breakpoints", + "arguments": [] + } + }, + { + "id": "native.viewBreakpoints.variant2", + "description": "Native UI: View breakpoints", + "platforms": [ + "macos" + ], + "macos": { + "key": "View breakpoints", + "arguments": [] + } + }, + { + "id": "native.viewBreakpointsF8", + "description": "Native UI: View breakpoints (⌘⇧F8)", + "platforms": [ + "macos" + ], + "macos": { + "key": "View breakpoints (⌘⇧F8)", + "arguments": [] + } + }, + { + "id": "native.viewOptions", + "description": "Native UI: View Options", + "platforms": [ + "macos" + ], + "macos": { + "key": "View Options", + "arguments": [] + } + }, + { + "id": "native.viewSavedChanges", + "description": "Native UI: View Saved Changes", + "platforms": [ + "macos" + ], + "macos": { + "key": "View Saved Changes", + "arguments": [] + } + }, + { + "id": "native.vmArguments", + "description": "Native UI: VM arguments", + "platforms": [ + "macos" + ], + "macos": { + "key": "VM arguments", + "arguments": [] + } + }, + { + "id": "native.vmOptions", + "description": "Native UI: VM options", + "platforms": [ + "macos" + ], + "macos": { + "key": "VM options", + "arguments": [] + } + }, + { + "id": "native.vueSupport", + "description": "Native UI: Vue Support", + "platforms": [ + "macos" + ], + "macos": { + "key": "Vue Support", + "arguments": [] + } + }, + { + "id": "native.waitForTheAgentConfigurationToFinishUpdating", + "description": "Native UI: Wait for the Agent configuration to finish updating.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Wait for the Agent configuration to finish updating.", + "arguments": [] + } + }, + { + "id": "native.waitForTheAgentFileRollbackToFinish", + "description": "Native UI: Wait for the Agent file rollback to finish.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Wait for the Agent file rollback to finish.", + "arguments": [] + } + }, + { + "id": "native.waitForTheAgentOperationToFinishBeforeChangingProviders", + "description": "Native UI: Wait for the Agent operation to finish before changing providers.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Wait for the Agent operation to finish before changing providers.", + "arguments": [] + } + }, + { + "id": "native.waitForTheCurrentGitOperationToFinish", + "description": "Native UI: Wait for the current Git operation to finish.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Wait for the current Git operation to finish.", + "arguments": [] + } + }, + { + "id": "native.waitForTheProjectToFinishLoadingThenIdentifyItAgain", + "description": "Native UI: Wait for the project to finish loading, then identify it again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Wait for the project to finish loading, then identify it again.", + "arguments": [] + } + }, + { + "id": "native.waitingForALocalTestJVM", + "description": "Native UI: Waiting for a local test JVM…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Waiting for a local test JVM…", + "arguments": [] + } + }, + { + "id": "native.waitingForAgentResponse", + "description": "Native UI: Waiting for Agent response…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Waiting for Agent response…", + "arguments": [] + } + }, + { + "id": "native.waitingForChatGPTSignIn", + "description": "Native UI: Waiting for ChatGPT sign-in…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Waiting for ChatGPT sign-in…", + "arguments": [] + } + }, + { + "id": "native.waitingForDebuggerOutput", + "description": "Native UI: Waiting for debugger output…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Waiting for debugger output…", + "arguments": [] + } + }, + { + "id": "native.waitingForDownloadDataCheckYourNetworkOrProxyIfThisContinues", + "description": "Native UI: Waiting for download data. Check your network or proxy if this continues.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Waiting for download data. Check your network or proxy if this continues.", + "arguments": [] + } + }, + { + "id": "native.waitingForGitHub", + "description": "Native UI: Waiting for GitHub…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Waiting for GitHub…", + "arguments": [] + } + }, + { + "id": "native.waitingForJVMPerformanceData", + "description": "Native UI: Waiting for JVM performance data…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Waiting for JVM performance data…", + "arguments": [] + } + }, + { + "id": "native.waitingForPermission", + "description": "Native UI: Waiting for permission…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Waiting for permission…", + "arguments": [] + } + }, + { + "id": "native.waitingForProcessPerformanceData", + "description": "Native UI: Waiting for process performance data…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Waiting for process performance data…", + "arguments": [] + } + }, + { + "id": "native.waitingForStackData", + "description": "Native UI: Waiting for stack data…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Waiting for stack data…", + "arguments": [] + } + }, + { + "id": "native.waitingForSubmodule", + "description": "Native UI: Waiting for submodule", + "platforms": [ + "macos" + ], + "macos": { + "key": "Waiting for submodule", + "arguments": [] + } + }, + { + "id": "native.waitingForSubscriptionQuota", + "description": "Native UI: Waiting for subscription quota…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Waiting for subscription quota…", + "arguments": [] + } + }, + { + "id": "native.waitingForTheJavaLanguageService", + "description": "Native UI: Waiting for the Java language service", + "platforms": [ + "macos" + ], + "macos": { + "key": "Waiting for the Java language service", + "arguments": [] + } + }, + { + "id": "native.waitingForThreadData", + "description": "Native UI: Waiting for thread data…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Waiting for thread data…", + "arguments": [] + } + }, + { + "id": "native.waitingToQuitToCompleteTheUpdate", + "description": "Native UI: Waiting to quit to complete the update.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Waiting to quit to complete the update.", + "arguments": [] + } + }, + { + "id": "native.wantsToUseYourCamera", + "description": "Native UI: “%@” wants to use your camera.", + "platforms": [ + "macos" + ], + "macos": { + "key": "“%@” wants to use your camera.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.wantsToUseYourCameraAndMicrophone", + "description": "Native UI: “%@” wants to use your camera and microphone.", + "platforms": [ + "macos" + ], + "macos": { + "key": "“%@” wants to use your camera and microphone.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.wantsToUseYourMicrophone", + "description": "Native UI: “%@” wants to use your microphone.", + "platforms": [ + "macos" + ], + "macos": { + "key": "“%@” wants to use your microphone.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.warning", + "description": "Native UI: Warning", + "platforms": [ + "macos" + ], + "macos": { + "key": "Warning", + "arguments": [] + } + }, + { + "id": "native.warnings", + "description": "Native UI: Warnings", + "platforms": [ + "macos" + ], + "macos": { + "key": "Warnings", + "arguments": [] + } + }, + { + "id": "native.wasCreatedOutsideLitheTakeOwnershipBeforeChangingIt", + "description": "Native UI: “%@” was created outside Lithe. Take ownership before changing it.", + "platforms": [ + "macos" + ], + "macos": { + "key": "“%@” was created outside Lithe. Take ownership before changing it.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.wasNotFoundInstallItForExampleThenCheckAgain", + "description": "Native UI: %@ was not found. Install it (for example `%@`), then check again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ was not found. Install it (for example `%@`), then check again.", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string" + } + }, + { + "id": "native.welcomeToLithe", + "description": "Native UI: Welcome to Lithe", + "platforms": [ + "macos" + ], + "macos": { + "key": "Welcome to Lithe", + "arguments": [] + } + }, + { + "id": "native.welcomeToLitheAgent", + "description": "Native UI: Welcome to Lithe Agent", + "platforms": [ + "macos" + ], + "macos": { + "key": "Welcome to Lithe Agent", + "arguments": [] + } + }, + { + "id": "native.whatDoesThisPullRequestChange", + "description": "Native UI: What does this pull request change?", + "platforms": [ + "macos" + ], + "macos": { + "key": "What does this pull request change?", + "arguments": [] + } + }, + { + "id": "native.whatFetchRemovesOrDownloadsBeforeYouStartWorking", + "description": "Native UI: What Fetch removes or downloads before you start working.", + "platforms": [ + "macos" + ], + "macos": { + "key": "What Fetch removes or downloads before you start working.", + "arguments": [] + } + }, + { + "id": "native.whatFetchWillDo", + "description": "Native UI: What Fetch will do", + "platforms": [ + "macos" + ], + "macos": { + "key": "What Fetch will do", + "arguments": [] + } + }, + { + "id": "native.whenQuittingLithe", + "description": "Native UI: When quitting Lithe", + "platforms": [ + "macos" + ], + "macos": { + "key": "When quitting Lithe", + "arguments": [] + } + }, + { + "id": "native.wherePushGoesWhenYouDoNotChooseATargetExplicitly", + "description": "Native UI: Where Push goes when you do not choose a target explicitly.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Where Push goes when you do not choose a target explicitly.", + "arguments": [] + } + }, + { + "id": "native.whereShouldThisApply", + "description": "Native UI: Where should this apply?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Where should this apply?", + "arguments": [] + } + }, + { + "id": "native.whereTheRowsBelowThatAreBackedByGitConfigurationAreWritten", + "description": "Native UI: Where the rows below that are backed by Git configuration are written.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Where the rows below that are backed by Git configuration are written.", + "arguments": [] + } + }, + { + "id": "native.whereWouldYouLikeToOpenTheProject", + "description": "Native UI: Where would you like to open the project ‘%@’?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Where would you like to open the project ‘%@’?", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.whitespaceComparison", + "description": "Native UI: Whitespace comparison", + "platforms": [ + "macos" + ], + "macos": { + "key": "Whitespace comparison", + "arguments": [] + } + }, + { + "id": "native.wholeWords", + "description": "Native UI: Whole Words", + "platforms": [ + "macos" + ], + "macos": { + "key": "Whole Words", + "arguments": [] + } + }, + { + "id": "native.window", + "description": "Native UI: Window", + "platforms": [ + "macos" + ], + "macos": { + "key": "Window", + "arguments": [] + } + }, + { + "id": "native.wip", + "description": "Native UI: WIP", + "platforms": [ + "macos" + ], + "macos": { + "key": "WIP", + "arguments": [] + } + }, + { + "id": "native.workbenchActions", + "description": "Native UI: Workbench Actions", + "platforms": [ + "macos" + ], + "macos": { + "key": "Workbench Actions", + "arguments": [] + } + }, + { + "id": "native.workbenchBackground", + "description": "Native UI: Workbench background", + "platforms": [ + "macos" + ], + "macos": { + "key": "Workbench background", + "arguments": [] + } + }, + { + "id": "native.workbenchBackgroundOpacity", + "description": "Native UI: Workbench background opacity", + "platforms": [ + "macos" + ], + "macos": { + "key": "Workbench background opacity", + "arguments": [] + } + }, + { + "id": "native.working", + "description": "Native UI: Working…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Working…", + "arguments": [] + } + }, + { + "id": "native.workingDirectory", + "description": "Native UI: Working directory", + "platforms": [ + "macos" + ], + "macos": { + "key": "Working directory", + "arguments": [] + } + }, + { + "id": "native.workingTREE", + "description": "Native UI: WORKING TREE", + "platforms": [ + "macos" + ], + "macos": { + "key": "WORKING TREE", + "arguments": [] + } + }, + { + "id": "native.workingTree", + "description": "Native UI: %@ ↔ Working Tree", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ ↔ Working Tree", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.workingTree.variant2", + "description": "Native UI: Working Tree", + "platforms": [ + "macos" + ], + "macos": { + "key": "Working Tree", + "arguments": [] + } + }, + { + "id": "native.workingTree.variant3", + "description": "Native UI: Working tree", + "platforms": [ + "macos" + ], + "macos": { + "key": "Working tree", + "arguments": [] + } + }, + { + "id": "native.workingTreeIsClean", + "description": "Native UI: Working tree is clean", + "platforms": [ + "macos" + ], + "macos": { + "key": "Working tree is clean", + "arguments": [] + } + }, + { + "id": "native.workingTreeMatchesThisReference", + "description": "Native UI: Working tree matches this reference", + "platforms": [ + "macos" + ], + "macos": { + "key": "Working tree matches this reference", + "arguments": [] + } + }, + { + "id": "native.workingWithNpm", + "description": "Native UI: Working with npm…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Working with npm…", + "arguments": [] + } + }, + { + "id": "native.workspace", + "description": "Native UI: Workspace", + "platforms": [ + "macos" + ], + "macos": { + "key": "Workspace", + "arguments": [] + } + }, + { + "id": "native.worktree", + "description": "Native UI: Worktree", + "platforms": [ + "macos" + ], + "macos": { + "key": "Worktree", + "arguments": [] + } + }, + { + "id": "native.worktreeAction", + "description": "Native UI: Worktree action", + "platforms": [ + "macos" + ], + "macos": { + "key": "Worktree action", + "arguments": [] + } + }, + { + "id": "native.worktreeActionUnavailable", + "description": "Native UI: Worktree action unavailable", + "platforms": [ + "macos" + ], + "macos": { + "key": "Worktree action unavailable", + "arguments": [] + } + }, + { + "id": "native.worktreeChanges", + "description": "Native UI: Worktree changes", + "platforms": [ + "macos" + ], + "macos": { + "key": "Worktree changes", + "arguments": [] + } + }, + { + "id": "native.worktreeCommitHistory", + "description": "Native UI: Worktree commit history", + "platforms": [ + "macos" + ], + "macos": { + "key": "Worktree commit history", + "arguments": [] + } + }, + { + "id": "native.worktreeDestination", + "description": "Native UI: Worktree destination", + "platforms": [ + "macos" + ], + "macos": { + "key": "Worktree destination", + "arguments": [] + } + }, + { + "id": "native.worktreeSettings", + "description": "Native UI: Worktree Settings", + "platforms": [ + "macos" + ], + "macos": { + "key": "Worktree Settings", + "arguments": [] + } + }, + { + "id": "native.worktrees", + "description": "Native UI: Worktrees", + "platforms": [ + "macos" + ], + "macos": { + "key": "Worktrees", + "arguments": [] + } + }, + { + "id": "native.worktreesAreUnavailable", + "description": "Native UI: Worktrees are unavailable", + "platforms": [ + "macos" + ], + "macos": { + "key": "Worktrees are unavailable", + "arguments": [] + } + }, + { + "id": "native.wrapIntoRows", + "description": "Native UI: Wrap into rows", + "platforms": [ + "macos" + ], + "macos": { + "key": "Wrap into rows", + "arguments": [] + } + }, + { + "id": "native.writeAClearActionableComment", + "description": "Native UI: Write a clear, actionable comment…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Write a clear, actionable comment…", + "arguments": [] + } + }, + { + "id": "native.writeToGlobalConfig", + "description": "Native UI: Write to global config", + "platforms": [ + "macos" + ], + "macos": { + "key": "Write to global config", + "arguments": [] + } + }, + { + "id": "native.writeToThisRepository", + "description": "Native UI: Write to this repository", + "platforms": [ + "macos" + ], + "macos": { + "key": "Write to this repository", + "arguments": [] + } + }, + { + "id": "native.xhigh", + "description": "Native UI: XHigh", + "platforms": [ + "macos" + ], + "macos": { + "key": "XHigh", + "arguments": [] + } + }, + { + "id": "native.xmlSupport", + "description": "Native UI: XML Support", + "platforms": [ + "macos" + ], + "macos": { + "key": "XML Support", + "arguments": [] + } + }, + { + "id": "native.yamlSupport", + "description": "Native UI: YAML Support", + "platforms": [ + "macos" + ], + "macos": { + "key": "YAML Support", + "arguments": [] + } + }, + { + "id": "native.yesterday", + "description": "Native UI: Yesterday", + "platforms": [ + "macos" + ], + "macos": { + "key": "Yesterday", + "arguments": [] + } + }, + { + "id": "native.you", + "description": "Native UI: You", + "platforms": [ + "macos" + ], + "macos": { + "key": "You", + "arguments": [] + } + }, + { + "id": "native.youCanMuteOrStopTheDeviceFromTheAddressBarWhileThePageIsUsingIt", + "description": "Native UI: You can mute or stop the device from the address bar while the page is using it.", + "platforms": [ + "macos" + ], + "macos": { + "key": "You can mute or stop the device from the address bar while the page is using it.", + "arguments": [] + } + }, + { + "id": "native.youHaveUncommittedChangesRebaseWillRefuseToStartUntilTheyAreCommittedOrStashed", + "description": "Native UI: You have uncommitted changes. Rebase will refuse to start until they are committed or stashed.", + "platforms": [ + "macos" + ], + "macos": { + "key": "You have uncommitted changes. Rebase will refuse to start until they are committed or stashed.", + "arguments": [] + } + }, + { + "id": "native.yourBranchAndEachHaveCommitsTheOtherDoesNotSoTheChangesCannotBeFastForwarded", + "description": "Native UI: Your branch and '%@' each have commits the other does not, so the changes cannot be fast-forwarded.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Your branch and '%@' each have commits the other does not, so the changes cannot be fast-forwarded.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.yourChangesToTheseFilesConflictWith", + "description": "Native UI: Your changes to these files conflict with '%@':", + "platforms": [ + "macos" + ], + "macos": { + "key": "Your changes to these files conflict with '%@':", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.yourChangesToTheseFilesWouldBeOverwrittenBy", + "description": "Native UI: Your changes to these files would be overwritten by '%@':", + "platforms": [ + "macos" + ], + "macos": { + "key": "Your changes to these files would be overwritten by '%@':", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.yourLocalChangesAreSafeInTheIsIncompleteResolveTheConflictsThenDropThisStashManually", + "description": "Native UI: Your local changes are safe in %@. The %@ is incomplete. Resolve the conflicts, then drop this stash manually.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Your local changes are safe in %@. The %@ is incomplete. Resolve the conflicts, then drop this stash manually.", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string" + } + }, + { + "id": "native.yourLocalConfigurationHasNoAPIKeyForThisAgentAddOneThenFetchTheConfigurationAgain", + "description": "Native UI: Your local configuration has no API key for this Agent. Add one, then fetch the configuration again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Your local configuration has no API key for this Agent. Add one, then fetch the configuration again.", + "arguments": [] + } + }, + { + "id": "native.yourLoginShellDidNotReportAPATHSoOnlyLitheSOwnPATHWasSearched", + "description": "Native UI: Your login shell did not report a PATH, so only Lithe's own PATH was searched.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Your login shell did not report a PATH, so only Lithe's own PATH was searched.", + "arguments": [] + } + }, + { + "id": "native.yourOriginalSettingsAndWorkspaceDataHaveBeenPreservedRestartLitheDevToTryAgain", + "description": "Native UI: Your original settings and workspace data have been preserved. Restart Lithe-Dev to try again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Your original settings and workspace data have been preserved. Restart Lithe-Dev to try again.", + "arguments": [] + } + }, + { + "id": "native.zigSupport", + "description": "Native UI: Zig Support", + "platforms": [ + "macos" + ], + "macos": { + "key": "Zig Support", + "arguments": [] + } + }, + { + "id": "native.zoomIn", + "description": "Native UI: Zoom In", + "platforms": [ + "macos" + ], + "macos": { + "key": "Zoom In", + "arguments": [] + } + }, + { + "id": "native.zoomOut", + "description": "Native UI: Zoom Out", + "platforms": [ + "macos" + ], + "macos": { + "key": "Zoom Out", + "arguments": [] + } + }, + { + "id": "navigation.definition", + "description": "Windows UI: navigation.definition", + "platforms": [ + "windows" + ] + }, + { + "id": "navigation.enterValidLineNumber", + "description": "Windows UI: navigation.enterValidLineNumber", + "platforms": [ + "windows" + ] + }, + { + "id": "navigation.goToLine", + "description": "Windows UI: navigation.goToLine", + "platforms": [ + "windows" + ] + }, + { + "id": "navigation.goToLinePrompt", + "description": "Windows UI: navigation.goToLinePrompt", + "platforms": [ + "windows" + ] + }, + { + "id": "navigation.implementation", + "description": "Windows UI: navigation.implementation", + "platforms": [ + "windows" + ] + }, + { + "id": "navigation.lineNumber", + "description": "Windows UI: navigation.lineNumber", + "platforms": [ + "windows" + ] + }, + { + "id": "navigation.noOtherReferencesFound", + "description": "Windows UI: navigation.noOtherReferencesFound", + "platforms": [ + "windows" + ] + }, + { + "id": "navigation.noReferencesFound", + "description": "Windows UI: navigation.noReferencesFound", + "platforms": [ + "windows" + ] + }, + { + "id": "navigation.noTargetFound", + "description": "Windows UI: navigation.noTargetFound", + "platforms": [ + "windows" + ], + "parameters": { + "target": "string" + } + }, + { + "id": "navigation.reference", + "description": "Windows UI: navigation.reference", + "platforms": [ + "windows" + ] + }, + { + "id": "navigation.typeDefinition", + "description": "Windows UI: navigation.typeDefinition", + "platforms": [ + "windows" + ] + }, + { + "id": "newProject.backToStarters", + "description": "Windows UI: newProject.backToStarters", + "platforms": [ + "windows" + ] + }, + { + "id": "newProject.chooseParentFolder", + "description": "Windows UI: newProject.chooseParentFolder", + "platforms": [ + "windows" + ] + }, + { + "id": "newProject.chooseProjectLocation", + "description": "Windows UI: newProject.chooseProjectLocation", + "platforms": [ + "windows" + ] + }, + { + "id": "newProject.cloningRepository", + "description": "Windows UI: newProject.cloningRepository", + "platforms": [ + "windows" + ] + }, + { + "id": "newProject.cloningRepositoryStatus", + "description": "Windows UI: newProject.cloningRepositoryStatus", + "platforms": [ + "windows" + ] + }, + { + "id": "newProject.creatingProject", + "description": "Windows UI: newProject.creatingProject", + "platforms": [ + "windows" + ] + }, + { + "id": "newProject.errorCreatedButCouldNotOpen", + "description": "Windows UI: newProject.errorCreatedButCouldNotOpen", + "platforms": [ + "windows" + ] + }, + { + "id": "newProject.errorCrossPlatformName", + "description": "Windows UI: newProject.errorCrossPlatformName", + "platforms": [ + "windows" + ] + }, + { + "id": "newProject.errorDestinationExists", + "description": "Windows UI: newProject.errorDestinationExists", + "platforms": [ + "windows" + ], + "parameters": { + "path": "string" + } + }, + { + "id": "newProject.errorEnterName", + "description": "Windows UI: newProject.errorEnterName", + "platforms": [ + "windows" + ] + }, + { + "id": "newProject.errorPathTraversalName", + "description": "Windows UI: newProject.errorPathTraversalName", + "platforms": [ + "windows" + ] + }, + { + "id": "newProject.errorReservedCharacters", + "description": "Windows UI: newProject.errorReservedCharacters", + "platforms": [ + "windows" + ] + }, + { + "id": "newProject.errorTrailingPeriodOrSpace", + "description": "Windows UI: newProject.errorTrailingPeriodOrSpace", + "platforms": [ + "windows" + ] + }, + { + "id": "newProject.location", + "description": "Windows UI: newProject.location", + "platforms": [ + "windows" + ] + }, + { + "id": "newProject.packageManager", + "description": "Windows UI: newProject.packageManager", + "platforms": [ + "windows" + ] + }, + { + "id": "newProject.packageManagerDescription", + "description": "Windows UI: newProject.packageManagerDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "newProject.preparingProject", + "description": "Windows UI: newProject.preparingProject", + "platforms": [ + "windows" + ] + }, + { + "id": "newProject.projectLocation", + "description": "Windows UI: newProject.projectLocation", + "platforms": [ + "windows" + ] + }, + { + "id": "newProject.projectName", + "description": "Windows UI: newProject.projectName", + "platforms": [ + "windows" + ] + }, + { + "id": "newProject.repositoryUrl", + "description": "Windows UI: newProject.repositoryUrl", + "platforms": [ + "windows" + ] + }, + { + "id": "newProject.repositoryUrlDescription", + "description": "Windows UI: newProject.repositoryUrlDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "newProject.starters", + "description": "Windows UI: newProject.starters", + "platforms": [ + "windows" + ] + }, + { + "id": "notebook.convertToCode", + "description": "Windows UI: notebook.convertToCode", + "platforms": [ + "windows" + ] + }, + { + "id": "notebook.convertToMarkdown", + "description": "Windows UI: notebook.convertToMarkdown", + "platforms": [ + "windows" + ] + }, + { + "id": "notebook.deleteCell", + "description": "Windows UI: notebook.deleteCell", + "platforms": [ + "windows" + ] + }, + { + "id": "notebook.editCell", + "description": "Windows UI: notebook.editCell", + "platforms": [ + "windows" + ] + }, + { + "id": "notebook.insertCellBelow", + "description": "Windows UI: notebook.insertCellBelow", + "platforms": [ + "windows" + ] + }, + { + "id": "notebook.moveCell", + "description": "Windows UI: notebook.moveCell", + "platforms": [ + "windows" + ] + }, + { + "id": "notebook.pdfOutput", + "description": "Windows UI: notebook.pdfOutput", + "platforms": [ + "windows" + ] + }, + { + "id": "notebook.previewCell", + "description": "Windows UI: notebook.previewCell", + "platforms": [ + "windows" + ] + }, + { + "id": "notebook.pythonCellOutput", + "description": "Windows UI: notebook.pythonCellOutput", + "platforms": [ + "windows" + ], + "parameters": { + "output": "string" + } + }, + { + "id": "notebook.pythonCellRan", + "description": "Windows UI: notebook.pythonCellRan", + "platforms": [ + "windows" + ] + }, + { + "id": "notebook.pythonCellRunFailed", + "description": "Windows UI: notebook.pythonCellRunFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "notebook.pythonCellTimedOut", + "description": "Windows UI: notebook.pythonCellTimedOut", + "platforms": [ + "windows" + ] + }, + { + "id": "notebook.pythonDisplayOutputs", + "description": "Windows UI: notebook.pythonDisplayOutputs", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "notebook.rChunkCapturedErrorOutput", + "description": "Windows UI: notebook.rChunkCapturedErrorOutput", + "platforms": [ + "windows" + ] + }, + { + "id": "notebook.rChunkOutput", + "description": "Windows UI: notebook.rChunkOutput", + "platforms": [ + "windows" + ], + "parameters": { + "output": "string" + } + }, + { + "id": "notebook.rChunkRan", + "description": "Windows UI: notebook.rChunkRan", + "platforms": [ + "windows" + ] + }, + { + "id": "notebook.rChunkRunFailed", + "description": "Windows UI: notebook.rChunkRunFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "notebook.rChunkSkippedEvalFalse", + "description": "Windows UI: notebook.rChunkSkippedEvalFalse", + "platforms": [ + "windows" + ] + }, + { + "id": "notebook.rChunkTimedOut", + "description": "Windows UI: notebook.rChunkTimedOut", + "platforms": [ + "windows" + ] + }, + { + "id": "notebook.runCell", + "description": "Windows UI: notebook.runCell", + "platforms": [ + "windows" + ] + }, + { + "id": "notebook.runningCell", + "description": "Windows UI: notebook.runningCell", + "platforms": [ + "windows" + ] + }, + { + "id": "notifications.clearAll", + "description": "Windows UI: notifications.clearAll", + "platforms": [ + "windows" + ] + }, + { + "id": "notifications.copied", + "description": "Windows UI: notifications.copied", + "platforms": [ + "windows" + ] + }, + { + "id": "notifications.copy", + "description": "Windows UI: notifications.copy", + "platforms": [ + "windows" + ] + }, + { + "id": "notifications.copyAll", + "description": "Windows UI: notifications.copyAll", + "platforms": [ + "windows" + ] + }, + { + "id": "notifications.copyDetails", + "description": "Windows UI: notifications.copyDetails", + "platforms": [ + "windows" + ] + }, + { + "id": "notifications.copyMessage", + "description": "Windows UI: notifications.copyMessage", + "platforms": [ + "windows" + ] + }, + { + "id": "notifications.copyNotification", + "description": "Windows UI: notifications.copyNotification", + "platforms": [ + "windows" + ] + }, + { + "id": "notifications.delete", + "description": "Windows UI: notifications.delete", + "platforms": [ + "windows" + ] + }, + { + "id": "notifications.deleteNotification", + "description": "Windows UI: notifications.deleteNotification", + "platforms": [ + "windows" + ] + }, + { + "id": "notifications.details", + "description": "Windows UI: notifications.details", + "platforms": [ + "windows" + ] + }, + { + "id": "notifications.empty", + "description": "Windows UI: notifications.empty", + "platforms": [ + "windows" + ] + }, + { + "id": "notifications.filter", + "description": "Windows UI: notifications.filter", + "platforms": [ + "windows" + ] + }, + { + "id": "notifications.filterAll", + "description": "Windows UI: notifications.filterAll", + "platforms": [ + "windows" + ] + }, + { + "id": "notifications.filterErrors", + "description": "Windows UI: notifications.filterErrors", + "platforms": [ + "windows" + ] + }, + { + "id": "notifications.filterInfo", + "description": "Windows UI: notifications.filterInfo", + "platforms": [ + "windows" + ] + }, + { + "id": "notifications.filterSuccess", + "description": "Windows UI: notifications.filterSuccess", + "platforms": [ + "windows" + ] + }, + { + "id": "notifications.filterWarnings", + "description": "Windows UI: notifications.filterWarnings", + "platforms": [ + "windows" + ] + }, + { + "id": "notifications.noMatch", + "description": "Windows UI: notifications.noMatch", + "platforms": [ + "windows" + ] + }, + { + "id": "notifications.occurrenceCount", + "description": "Windows UI: notifications.occurrenceCount", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string", + "message": "string" + } + }, + { + "id": "notifications.search", + "description": "Windows UI: notifications.search", + "platforms": [ + "windows" + ] + }, + { + "id": "notifications.title", + "description": "Windows UI: notifications.title", + "platforms": [ + "windows" + ] + }, + { + "id": "onboarding.enableVimMode", + "description": "Windows UI: onboarding.enableVimMode", + "platforms": [ + "windows" + ] + }, + { + "id": "onboarding.import", + "description": "Windows UI: onboarding.import", + "platforms": [ + "windows" + ] + }, + { + "id": "onboarding.importSettings", + "description": "Windows UI: onboarding.importSettings", + "platforms": [ + "windows" + ] + }, + { + "id": "onboarding.importSettingsDescription", + "description": "Windows UI: onboarding.importSettingsDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "onboarding.keybindingPreset", + "description": "Windows UI: onboarding.keybindingPreset", + "platforms": [ + "windows" + ] + }, + { + "id": "onboarding.keybindingPreset.atom.description", + "description": "Windows UI: onboarding.keybindingPreset.atom.description", + "platforms": [ + "windows" + ] + }, + { + "id": "onboarding.keybindingPreset.atom.label", + "description": "Windows UI: onboarding.keybindingPreset.atom.label", + "platforms": [ + "windows" + ] + }, + { + "id": "onboarding.keybindingPreset.emacs.description", + "description": "Windows UI: onboarding.keybindingPreset.emacs.description", + "platforms": [ + "windows" + ] + }, + { + "id": "onboarding.keybindingPreset.emacs.label", + "description": "Windows UI: onboarding.keybindingPreset.emacs.label", + "platforms": [ + "windows" + ] + }, + { + "id": "onboarding.keybindingPreset.jetbrains.description", + "description": "Windows UI: onboarding.keybindingPreset.jetbrains.description", + "platforms": [ + "windows" + ] + }, + { + "id": "onboarding.keybindingPreset.jetbrains.label", + "description": "Windows UI: onboarding.keybindingPreset.jetbrains.label", + "platforms": [ + "windows" + ] + }, + { + "id": "onboarding.keybindingPreset.none.description", + "description": "Windows UI: onboarding.keybindingPreset.none.description", + "platforms": [ + "windows" + ] + }, + { + "id": "onboarding.keybindingPreset.none.label", + "description": "Windows UI: onboarding.keybindingPreset.none.label", + "platforms": [ + "windows" + ] + }, + { + "id": "onboarding.keybindingPreset.sublime.description", + "description": "Windows UI: onboarding.keybindingPreset.sublime.description", + "platforms": [ + "windows" + ] + }, + { + "id": "onboarding.keybindingPreset.sublime.label", + "description": "Windows UI: onboarding.keybindingPreset.sublime.label", + "platforms": [ + "windows" + ] + }, + { + "id": "onboarding.keybindingPreset.vscode.description", + "description": "Windows UI: onboarding.keybindingPreset.vscode.description", + "platforms": [ + "windows" + ] + }, + { + "id": "onboarding.keybindingPreset.vscode.label", + "description": "Windows UI: onboarding.keybindingPreset.vscode.label", + "platforms": [ + "windows" + ] + }, + { + "id": "onboarding.keybindingPreset.xcode.description", + "description": "Windows UI: onboarding.keybindingPreset.xcode.description", + "platforms": [ + "windows" + ] + }, + { + "id": "onboarding.keybindingPreset.xcode.label", + "description": "Windows UI: onboarding.keybindingPreset.xcode.label", + "platforms": [ + "windows" + ] + }, + { + "id": "onboarding.keybindingPreset.zed.description", + "description": "Windows UI: onboarding.keybindingPreset.zed.description", + "platforms": [ + "windows" + ] + }, + { + "id": "onboarding.keybindingPreset.zed.label", + "description": "Windows UI: onboarding.keybindingPreset.zed.label", + "platforms": [ + "windows" + ] + }, + { + "id": "onboarding.loadingReleaseNotes", + "description": "Windows UI: onboarding.loadingReleaseNotes", + "platforms": [ + "windows" + ] + }, + { + "id": "onboarding.openFoldersInNewWindow", + "description": "Windows UI: onboarding.openFoldersInNewWindow", + "platforms": [ + "windows" + ] + }, + { + "id": "onboarding.releaseNotesDescription", + "description": "Windows UI: onboarding.releaseNotesDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "onboarding.releasedDate", + "description": "Windows UI: onboarding.releasedDate", + "platforms": [ + "windows" + ], + "parameters": { + "date": "string" + } + }, + { + "id": "onboarding.updatedFromVersion", + "description": "Windows UI: onboarding.updatedFromVersion", + "platforms": [ + "windows" + ], + "parameters": { + "currentVersion": "string", + "previousVersion": "string" + } + }, + { + "id": "onboarding.versionInstalled", + "description": "Windows UI: onboarding.versionInstalled", + "platforms": [ + "windows" + ], + "parameters": { + "version": "string" + } + }, + { + "id": "onboarding.welcomeDescription", + "description": "Windows UI: onboarding.welcomeDescription", + "platforms": [ + "windows" + ], + "parameters": { + "version": "string" + } + }, + { + "id": "onboarding.welcomeTitle", + "description": "Windows UI: onboarding.welcomeTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "onboarding.whatsNewTitle", + "description": "Windows UI: onboarding.whatsNewTitle", + "platforms": [ + "windows" + ], + "parameters": { + "version": "string" + } + }, + { + "id": "outline.collapse", + "description": "Windows UI: outline.collapse", + "platforms": [ + "windows" + ] + }, + { + "id": "outline.collapseAll", + "description": "Windows UI: outline.collapseAll", + "platforms": [ + "windows" + ] + }, + { + "id": "outline.commandPlaceholderWithFile", + "description": "Windows UI: outline.commandPlaceholderWithFile", + "platforms": [ + "windows" + ], + "parameters": { + "file": "string" + } + }, + { + "id": "outline.copyLocation", + "description": "Windows UI: outline.copyLocation", + "platforms": [ + "windows" + ] + }, + { + "id": "outline.copyName", + "description": "Windows UI: outline.copyName", + "platforms": [ + "windows" + ] + }, + { + "id": "outline.expand", + "description": "Windows UI: outline.expand", + "platforms": [ + "windows" + ] + }, + { + "id": "outline.expandAll", + "description": "Windows UI: outline.expandAll", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Expand All", + "arguments": [] + } + }, + { + "id": "outline.filter", + "description": "Windows UI: outline.filter", + "platforms": [ + "windows" + ] + }, + { + "id": "outline.filter.functions", + "description": "Windows UI: outline.filter.functions", + "platforms": [ + "windows" + ] + }, + { + "id": "outline.filter.other", + "description": "Windows UI: outline.filter.other", + "platforms": [ + "windows" + ] + }, + { + "id": "outline.filter.properties", + "description": "Windows UI: outline.filter.properties", + "platforms": [ + "windows" + ] + }, + { + "id": "outline.filter.types", + "description": "Windows UI: outline.filter.types", + "platforms": [ + "windows" + ] + }, + { + "id": "outline.filter.variables", + "description": "Windows UI: outline.filter.variables", + "platforms": [ + "windows" + ] + }, + { + "id": "outline.goToSymbol", + "description": "Windows UI: outline.goToSymbol", + "platforms": [ + "windows" + ] + }, + { + "id": "outline.loading", + "description": "Windows UI: outline.loading", + "platforms": [ + "windows" + ] + }, + { + "id": "outline.loadingEllipsis", + "description": "Windows UI: outline.loadingEllipsis", + "platforms": [ + "windows" + ] + }, + { + "id": "outline.noActiveFile", + "description": "Windows UI: outline.noActiveFile", + "platforms": [ + "windows" + ] + }, + { + "id": "outline.noOutlineForActiveFile", + "description": "Windows UI: outline.noOutlineForActiveFile", + "platforms": [ + "windows" + ] + }, + { + "id": "outline.noSymbolsFound", + "description": "Windows UI: outline.noSymbolsFound", + "platforms": [ + "windows" + ] + }, + { + "id": "outline.openFile", + "description": "Windows UI: outline.openFile", + "platforms": [ + "windows" + ] + }, + { + "id": "outline.search", + "description": "Windows UI: outline.search", + "platforms": [ + "windows" + ] + }, + { + "id": "outline.showAll", + "description": "Windows UI: outline.showAll", + "platforms": [ + "windows" + ] + }, + { + "id": "outline.title", + "description": "Windows UI: outline.title", + "platforms": [ + "windows" + ] + }, + { + "id": "panes.resizePanes", + "description": "Windows UI: panes.resizePanes", + "platforms": [ + "windows" + ] + }, + { + "id": "panes.searchResults", + "description": "Windows UI: panes.searchResults", + "platforms": [ + "windows" + ] + }, + { + "id": "panes.webViewerDisabled", + "description": "Windows UI: panes.webViewerDisabled", + "platforms": [ + "windows" + ] + }, + { + "id": "panes.webViewerDisabledDescription", + "description": "Windows UI: panes.webViewerDisabledDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "plugins.title", + "description": "Windows UI: plugins.title", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Plugins", + "arguments": [] + } + }, + { + "id": "preparation.building", + "description": "Windows UI: preparation.building", + "platforms": [ + "windows" + ] + }, + { + "id": "preparation.configuring", + "description": "Windows UI: preparation.configuring", + "platforms": [ + "windows" + ] + }, + { + "id": "preparation.explanation", + "description": "Windows UI: preparation.explanation", + "platforms": [ + "windows" + ] + }, + { + "id": "preparation.failed", + "description": "Windows UI: preparation.failed", + "platforms": [ + "windows" + ] + }, + { + "id": "preparation.importing", + "description": "Windows UI: preparation.importing", + "platforms": [ + "windows" + ] + }, + { + "id": "preparation.logs", + "description": "Windows UI: preparation.logs", + "platforms": [ + "windows" + ] + }, + { + "id": "preparation.ready", + "description": "Windows UI: preparation.ready", + "platforms": [ + "windows" + ] + }, + { + "id": "preparation.settings", + "description": "Windows UI: preparation.settings", + "platforms": [ + "windows" + ] + }, + { + "id": "preparation.starting", + "description": "Windows UI: preparation.starting", + "platforms": [ + "windows" + ] + }, + { + "id": "preparation.stopped", + "description": "Windows UI: preparation.stopped", + "platforms": [ + "windows" + ] + }, + { + "id": "project.clearScope", + "description": "Windows UI: project.clearScope", + "platforms": [ + "windows" + ] + }, + { + "id": "project.deleteSelected", + "description": "Windows UI: project.deleteSelected", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "project.dependencies", + "description": "Windows UI: project.dependencies", + "platforms": [ + "windows" + ] + }, + { + "id": "project.dependenciesHint", + "description": "Windows UI: project.dependenciesHint", + "platforms": [ + "windows" + ] + }, + { + "id": "project.localHistory", + "description": "Windows UI: project.localHistory", + "platforms": [ + "windows" + ] + }, + { + "id": "project.mark.excluded", + "description": "Windows UI: project.mark.excluded", + "platforms": [ + "windows" + ] + }, + { + "id": "project.mark.module", + "description": "Windows UI: project.mark.module", + "platforms": [ + "windows" + ] + }, + { + "id": "project.mark.package", + "description": "Windows UI: project.mark.package", + "platforms": [ + "windows" + ] + }, + { + "id": "project.mark.plain", + "description": "Windows UI: project.mark.plain", + "platforms": [ + "windows" + ] + }, + { + "id": "project.mark.resources", + "description": "Windows UI: project.mark.resources", + "platforms": [ + "windows" + ] + }, + { + "id": "project.mark.sources", + "description": "Windows UI: project.mark.sources", + "platforms": [ + "windows" + ] + }, + { + "id": "project.markAs", + "description": "Windows UI: project.markAs", + "platforms": [ + "windows" + ] + }, + { + "id": "project.markFailed", + "description": "Windows UI: project.markFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "project.replaceInFolder", + "description": "Windows UI: project.replaceInFolder", + "platforms": [ + "windows" + ] + }, + { + "id": "project.resolveDependencies", + "description": "Windows UI: project.resolveDependencies", + "platforms": [ + "windows" + ] + }, + { + "id": "project.revealActive", + "description": "Windows UI: project.revealActive", + "platforms": [ + "windows" + ] + }, + { + "id": "project.searchScope", + "description": "Windows UI: project.searchScope", + "platforms": [ + "windows" + ], + "parameters": { + "path": "string" + } + }, + { + "id": "projectIcon.emptyDescription", + "description": "Windows UI: projectIcon.emptyDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "projectIcon.emptyTitle", + "description": "Windows UI: projectIcon.emptyTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "projectIcon.remove", + "description": "Windows UI: projectIcon.remove", + "platforms": [ + "windows" + ] + }, + { + "id": "projectIcon.removeCustom", + "description": "Windows UI: projectIcon.removeCustom", + "platforms": [ + "windows" + ] + }, + { + "id": "projectIcon.scanning", + "description": "Windows UI: projectIcon.scanning", + "platforms": [ + "windows" + ] + }, + { + "id": "projectIcon.selectTitle", + "description": "Windows UI: projectIcon.selectTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "projectNameMenu.addFolderToWorkspace", + "description": "Windows UI: projectNameMenu.addFolderToWorkspace", + "platforms": [ + "windows" + ] + }, + { + "id": "projectNameMenu.collapseAllFolders", + "description": "Windows UI: projectNameMenu.collapseAllFolders", + "platforms": [ + "windows" + ] + }, + { + "id": "projectNameMenu.openFolderInNewTab", + "description": "Windows UI: projectNameMenu.openFolderInNewTab", + "platforms": [ + "windows" + ] + }, + { + "id": "projectOpen.attach", + "description": "Windows UI: projectOpen.attach", + "platforms": [ + "windows" + ] + }, + { + "id": "projectOpen.cancel", + "description": "Windows UI: projectOpen.cancel", + "platforms": [ + "windows" + ] + }, + { + "id": "projectOpen.doNotAskAgain", + "description": "Windows UI: projectOpen.doNotAskAgain", + "platforms": [ + "windows" + ] + }, + { + "id": "projectOpen.newWindow", + "description": "Windows UI: projectOpen.newWindow", + "platforms": [ + "windows" + ] + }, + { + "id": "projectOpen.thisWindow", + "description": "Windows UI: projectOpen.thisWindow", + "platforms": [ + "windows" + ] + }, + { + "id": "projectOpen.title", + "description": "Windows UI: projectOpen.title", + "platforms": [ + "windows" + ] + }, + { + "id": "projectOpen.where", + "description": "Windows UI: projectOpen.where", + "platforms": [ + "windows" + ], + "parameters": { + "project": "string" + } + }, + { + "id": "projectPicker.connected", + "description": "Windows UI: projectPicker.connected", + "platforms": [ + "windows" + ] + }, + { + "id": "projectPicker.connecting", + "description": "Windows UI: projectPicker.connecting", + "platforms": [ + "windows" + ] + }, + { + "id": "projectPicker.connectionFailed", + "description": "Windows UI: projectPicker.connectionFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "projectPicker.connectionFailedWithPeriod", + "description": "Windows UI: projectPicker.connectionFailedWithPeriod", + "platforms": [ + "windows" + ] + }, + { + "id": "projectPicker.connectionSuccessful", + "description": "Windows UI: projectPicker.connectionSuccessful", + "platforms": [ + "windows" + ] + }, + { + "id": "projectPicker.default", + "description": "Windows UI: projectPicker.default", + "platforms": [ + "windows" + ] + }, + { + "id": "projectPicker.disconnected", + "description": "Windows UI: projectPicker.disconnected", + "platforms": [ + "windows" + ] + }, + { + "id": "projectPicker.fillRequiredFields", + "description": "Windows UI: projectPicker.fillRequiredFields", + "platforms": [ + "windows" + ] + }, + { + "id": "projectPicker.hostAndUsernameRequired", + "description": "Windows UI: projectPicker.hostAndUsernameRequired", + "platforms": [ + "windows" + ] + }, + { + "id": "projectPicker.installed", + "description": "Windows UI: projectPicker.installed", + "platforms": [ + "windows" + ] + }, + { + "id": "projectPicker.linuxProjectPath", + "description": "Windows UI: projectPicker.linuxProjectPath", + "platforms": [ + "windows" + ] + }, + { + "id": "projectPicker.missing", + "description": "Windows UI: projectPicker.missing", + "platforms": [ + "windows" + ] + }, + { + "id": "projectPicker.noProjectsMatch", + "description": "Windows UI: projectPicker.noProjectsMatch", + "platforms": [ + "windows" + ], + "parameters": { + "query": "string" + } + }, + { + "id": "projectPicker.open", + "description": "Windows UI: projectPicker.open", + "platforms": [ + "windows" + ] + }, + { + "id": "projectPicker.openDistribution", + "description": "Windows UI: projectPicker.openDistribution", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "projectPicker.openWslFailed", + "description": "Windows UI: projectPicker.openWslFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "projectPicker.remote", + "description": "Windows UI: projectPicker.remote", + "platforms": [ + "windows" + ] + }, + { + "id": "projectPicker.remoteIntro", + "description": "Windows UI: projectPicker.remoteIntro", + "platforms": [ + "windows" + ] + }, + { + "id": "projectPicker.remoteProjectName", + "description": "Windows UI: projectPicker.remoteProjectName", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "projectPicker.removedMissingRecentProjects", + "description": "Windows UI: projectPicker.removedMissingRecentProjects", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "projectPicker.removedRecentProject", + "description": "Windows UI: projectPicker.removedRecentProject", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "projectPicker.saveConnection", + "description": "Windows UI: projectPicker.saveConnection", + "platforms": [ + "windows" + ] + }, + { + "id": "projectPicker.saveConnectionFailed", + "description": "Windows UI: projectPicker.saveConnectionFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "projectPicker.testConnection", + "description": "Windows UI: projectPicker.testConnection", + "platforms": [ + "windows" + ] + }, + { + "id": "projectPicker.testing", + "description": "Windows UI: projectPicker.testing", + "platforms": [ + "windows" + ] + }, + { + "id": "quickOpen.fileCountOne", + "description": "Windows UI: quickOpen.fileCountOne", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "quickOpen.filesCount", + "description": "File count in file lists.", + "platforms": [ + "macos", + "windows" + ], + "parameters": { + "count": "integer" + }, + "macos": { + "key": "%lld files", + "arguments": [ + "count" + ] + } + }, + { + "id": "quickOpen.indexing", + "description": "Windows UI: quickOpen.indexing", + "platforms": [ + "windows" + ] + }, + { + "id": "quickOpen.loadingFiles", + "description": "Windows UI: quickOpen.loadingFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "quickOpen.loadingSymbols", + "description": "Windows UI: quickOpen.loadingSymbols", + "platforms": [ + "windows" + ] + }, + { + "id": "quickOpen.noFiles", + "description": "Windows UI: quickOpen.noFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "quickOpen.noFilesInProject", + "description": "Windows UI: quickOpen.noFilesInProject", + "platforms": [ + "windows" + ] + }, + { + "id": "quickOpen.noMatch", + "description": "Windows UI: quickOpen.noMatch", + "platforms": [ + "windows" + ] + }, + { + "id": "quickOpen.noSymbols", + "description": "Windows UI: quickOpen.noSymbols", + "platforms": [ + "windows" + ] + }, + { + "id": "quickOpen.openFolder", + "description": "Windows UI: quickOpen.openFolder", + "platforms": [ + "windows" + ] + }, + { + "id": "quickOpen.searchFiles", + "description": "Windows UI: quickOpen.searchFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "quickOpen.searchSymbols", + "description": "Windows UI: quickOpen.searchSymbols", + "platforms": [ + "windows" + ] + }, + { + "id": "quickOpen.searchWorkspaceSymbols", + "description": "Windows UI: quickOpen.searchWorkspaceSymbols", + "platforms": [ + "windows" + ] + }, + { + "id": "quickOpen.searching", + "description": "Windows UI: quickOpen.searching", + "platforms": [ + "windows" + ] + }, + { + "id": "quickOpen.symbolsCount", + "description": "Windows UI: quickOpen.symbolsCount", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "references.clearReferences", + "description": "Windows UI: references.clearReferences", + "platforms": [ + "windows" + ] + }, + { + "id": "references.exitFullscreen", + "description": "Windows UI: references.exitFullscreen", + "platforms": [ + "windows" + ] + }, + { + "id": "references.findReferencesHint", + "description": "Windows UI: references.findReferencesHint", + "platforms": [ + "windows" + ] + }, + { + "id": "references.findingReferences", + "description": "Windows UI: references.findingReferences", + "platforms": [ + "windows" + ] + }, + { + "id": "references.fullscreen", + "description": "Windows UI: references.fullscreen", + "platforms": [ + "windows" + ] + }, + { + "id": "references.noReferencesFound", + "description": "Windows UI: references.noReferencesFound", + "platforms": [ + "windows" + ] + }, + { + "id": "references.title", + "description": "Windows UI: references.title", + "platforms": [ + "windows" + ] + }, + { + "id": "remote.cannotConnectToServer", + "description": "Windows UI: remote.cannotConnectToServer", + "platforms": [ + "windows" + ] + }, + { + "id": "remote.connectedTo", + "description": "Windows UI: remote.connectedTo", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "remote.connectionFailed", + "description": "Windows UI: remote.connectionFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "remote.connectionName", + "description": "Windows UI: remote.connectionName", + "platforms": [ + "windows" + ] + }, + { + "id": "remote.connectionNamePlaceholder", + "description": "Windows UI: remote.connectionNamePlaceholder", + "platforms": [ + "windows" + ] + }, + { + "id": "remote.connectionSavedSuccessfully", + "description": "Windows UI: remote.connectionSavedSuccessfully", + "platforms": [ + "windows" + ] + }, + { + "id": "remote.connectionTimedOut", + "description": "Windows UI: remote.connectionTimedOut", + "platforms": [ + "windows" + ] + }, + { + "id": "remote.connectionType", + "description": "Windows UI: remote.connectionType", + "platforms": [ + "windows" + ] + }, + { + "id": "remote.enterPassword", + "description": "Windows UI: remote.enterPassword", + "platforms": [ + "windows" + ] + }, + { + "id": "remote.enterPasswordForPrefix", + "description": "Windows UI: remote.enterPasswordForPrefix", + "platforms": [ + "windows" + ] + }, + { + "id": "remote.enterPasswordForSuffix", + "description": "Windows UI: remote.enterPasswordForSuffix", + "platforms": [ + "windows" + ], + "parameters": { + "host": "string", + "port": "string", + "user": "string" + } + }, + { + "id": "remote.hidePassword", + "description": "Windows UI: remote.hidePassword", + "platforms": [ + "windows" + ] + }, + { + "id": "remote.hostKeyVerificationFailed", + "description": "Windows UI: remote.hostKeyVerificationFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "remote.incorrectUsernameOrPassword", + "description": "Windows UI: remote.incorrectUsernameOrPassword", + "platforms": [ + "windows" + ] + }, + { + "id": "remote.leaveEmptyForKeyAuth", + "description": "Windows UI: remote.leaveEmptyForKeyAuth", + "platforms": [ + "windows" + ] + }, + { + "id": "remote.noRouteToHost", + "description": "Windows UI: remote.noRouteToHost", + "platforms": [ + "windows" + ] + }, + { + "id": "remote.passwordRequired", + "description": "Windows UI: remote.passwordRequired", + "platforms": [ + "windows" + ] + }, + { + "id": "remote.permissionDenied", + "description": "Windows UI: remote.permissionDenied", + "platforms": [ + "windows" + ] + }, + { + "id": "remote.privateKeyPath", + "description": "Windows UI: remote.privateKeyPath", + "platforms": [ + "windows" + ] + }, + { + "id": "remote.savePasswordForFutureConnections", + "description": "Windows UI: remote.savePasswordForFutureConnections", + "platforms": [ + "windows" + ] + }, + { + "id": "remote.showPassword", + "description": "Windows UI: remote.showPassword", + "platforms": [ + "windows" + ] + }, + { + "id": "run.addCustomCommandHint", + "description": "Windows UI: run.addCustomCommandHint", + "platforms": [ + "windows" + ] + }, + { + "id": "run.applications", + "description": "Windows UI: run.applications", + "platforms": [ + "windows" + ] + }, + { + "id": "run.cancel", + "description": "Windows UI: run.cancel", + "platforms": [ + "windows" + ] + }, + { + "id": "run.chooseServices", + "description": "Windows UI: run.chooseServices", + "platforms": [ + "windows" + ] + }, + { + "id": "run.clearOutput", + "description": "Windows UI: run.clearOutput", + "platforms": [ + "windows" + ] + }, + { + "id": "run.command", + "description": "Windows UI: run.command", + "platforms": [ + "windows" + ] + }, + { + "id": "run.configuration", + "description": "Windows UI: run.configuration", + "platforms": [ + "windows" + ] + }, + { + "id": "run.configurationDetails", + "description": "Windows UI: run.configurationDetails", + "platforms": [ + "windows" + ] + }, + { + "id": "run.configurationOverrideHint", + "description": "Windows UI: run.configurationOverrideHint", + "platforms": [ + "windows" + ] + }, + { + "id": "run.configurationOverridesSection", + "description": "Windows UI: run.configurationOverridesSection", + "platforms": [ + "windows" + ] + }, + { + "id": "run.configurations", + "description": "Windows UI: run.configurations", + "platforms": [ + "windows" + ] + }, + { + "id": "run.currentFile", + "description": "Windows UI: run.currentFile", + "platforms": [ + "windows" + ] + }, + { + "id": "run.custom", + "description": "Windows UI: run.custom", + "platforms": [ + "windows" + ] + }, + { + "id": "run.deleteAction", + "description": "Windows UI: run.deleteAction", + "platforms": [ + "windows" + ] + }, + { + "id": "run.deleteActionMessage", + "description": "Windows UI: run.deleteActionMessage", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "run.detectedInProject", + "description": "Windows UI: run.detectedInProject", + "platforms": [ + "windows" + ] + }, + { + "id": "run.done", + "description": "Windows UI: run.done", + "platforms": [ + "windows" + ] + }, + { + "id": "run.editService", + "description": "Windows UI: run.editService", + "platforms": [ + "windows" + ] + }, + { + "id": "run.editorTitle", + "description": "Windows UI: run.editorTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "run.effectiveSource", + "description": "Windows UI: run.effectiveSource", + "platforms": [ + "windows" + ] + }, + { + "id": "run.emptyOutput", + "description": "Windows UI: run.emptyOutput", + "platforms": [ + "windows" + ] + }, + { + "id": "run.environment", + "description": "Windows UI: run.environment", + "platforms": [ + "windows" + ] + }, + { + "id": "run.failed", + "description": "Windows UI: run.failed", + "platforms": [ + "windows" + ] + }, + { + "id": "run.filterActions", + "description": "Windows UI: run.filterActions", + "platforms": [ + "windows" + ] + }, + { + "id": "run.freshnessCheckFailed", + "description": "Windows UI: run.freshnessCheckFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "run.generatedEntries", + "description": "Windows UI: run.generatedEntries", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "run.groups", + "description": "Windows UI: run.groups", + "platforms": [ + "windows" + ] + }, + { + "id": "run.identifyAgain", + "description": "Windows UI: run.identifyAgain", + "platforms": [ + "windows" + ] + }, + { + "id": "run.identifyAndGenerate", + "description": "Windows UI: run.identifyAndGenerate", + "platforms": [ + "windows" + ] + }, + { + "id": "run.identifying", + "description": "Windows UI: run.identifying", + "platforms": [ + "windows" + ] + }, + { + "id": "run.infrastructure", + "description": "Windows UI: run.infrastructure", + "platforms": [ + "windows" + ] + }, + { + "id": "run.invalidTitle", + "description": "Windows UI: run.invalidTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "run.javaBuildAlwaysContinue", + "description": "Windows UI: run.javaBuildAlwaysContinue", + "platforms": [ + "windows" + ] + }, + { + "id": "run.javaBuildAlwaysContinueEnabled", + "description": "Windows UI: run.javaBuildAlwaysContinueEnabled", + "platforms": [ + "windows" + ] + }, + { + "id": "run.javaBuildAskAgain", + "description": "Windows UI: run.javaBuildAskAgain", + "platforms": [ + "windows" + ] + }, + { + "id": "run.javaBuildContinue", + "description": "Windows UI: run.javaBuildContinue", + "platforms": [ + "windows" + ] + }, + { + "id": "run.javaBuildElapsed", + "description": "Windows UI: run.javaBuildElapsed", + "platforms": [ + "windows" + ], + "parameters": { + "milliseconds": "string" + } + }, + { + "id": "run.javaBuildFailedTitle", + "description": "Windows UI: run.javaBuildFailedTitle", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "run.javaBuildMarkersMayRemain", + "description": "Windows UI: run.javaBuildMarkersMayRemain", + "platforms": [ + "windows" + ] + }, + { + "id": "run.javaBuildOpenLogs", + "description": "Windows UI: run.javaBuildOpenLogs", + "platforms": [ + "windows" + ] + }, + { + "id": "run.javaBuildRebuildIndex", + "description": "Windows UI: run.javaBuildRebuildIndex", + "platforms": [ + "windows" + ] + }, + { + "id": "run.javaBuildWorkspaceScope", + "description": "Windows UI: run.javaBuildWorkspaceScope", + "platforms": [ + "windows" + ] + }, + { + "id": "run.javaDiscoveryFailed", + "description": "Windows UI: run.javaDiscoveryFailed", + "platforms": [ + "windows" + ], + "parameters": { + "message": "string" + } + }, + { + "id": "run.javaDiscoveryLoading", + "description": "Windows UI: run.javaDiscoveryLoading", + "platforms": [ + "windows" + ] + }, + { + "id": "run.javaDiscoveryStale", + "description": "Windows UI: run.javaDiscoveryStale", + "platforms": [ + "windows" + ] + }, + { + "id": "run.jdkHome", + "description": "Windows UI: run.jdkHome", + "platforms": [ + "windows" + ] + }, + { + "id": "run.jdkHomeHint", + "description": "Windows UI: run.jdkHomeHint", + "platforms": [ + "windows" + ] + }, + { + "id": "run.languageInfrastructure", + "description": "Windows UI: run.languageInfrastructure", + "platforms": [ + "windows" + ] + }, + { + "id": "run.languageOther", + "description": "Windows UI: run.languageOther", + "platforms": [ + "windows" + ] + }, + { + "id": "run.languageTools", + "description": "Windows UI: run.languageTools", + "platforms": [ + "windows" + ] + }, + { + "id": "run.languages", + "description": "Windows UI: run.languages", + "platforms": [ + "windows" + ] + }, + { + "id": "run.mainClass", + "description": "Windows UI: run.mainClass", + "platforms": [ + "windows" + ] + }, + { + "id": "run.mavenExecutable", + "description": "Windows UI: run.mavenExecutable", + "platforms": [ + "windows" + ] + }, + { + "id": "run.mavenExecutableHint", + "description": "Windows UI: run.mavenExecutableHint", + "platforms": [ + "windows" + ] + }, + { + "id": "run.mavenJdkHome", + "description": "Windows UI: run.mavenJdkHome", + "platforms": [ + "windows" + ] + }, + { + "id": "run.mavenJdkHomeHint", + "description": "Windows UI: run.mavenJdkHomeHint", + "platforms": [ + "windows" + ] + }, + { + "id": "run.mavenTests", + "description": "Windows UI: run.mavenTests", + "platforms": [ + "windows" + ] + }, + { + "id": "run.mavenTestsHint", + "description": "Windows UI: run.mavenTestsHint", + "platforms": [ + "windows" + ] + }, + { + "id": "run.mavenTestsProjectDefault", + "description": "Windows UI: run.mavenTestsProjectDefault", + "platforms": [ + "windows" + ] + }, + { + "id": "run.mavenTestsRun", + "description": "Windows UI: run.mavenTestsRun", + "platforms": [ + "windows" + ] + }, + { + "id": "run.mavenTestsSkip", + "description": "Windows UI: run.mavenTestsSkip", + "platforms": [ + "windows" + ] + }, + { + "id": "run.minimize", + "description": "Windows UI: run.minimize", + "platforms": [ + "windows" + ] + }, + { + "id": "run.missingMessage", + "description": "Windows UI: run.missingMessage", + "platforms": [ + "windows" + ] + }, + { + "id": "run.missingTitle", + "description": "Windows UI: run.missingTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "run.modifyRunConfiguration", + "description": "Windows UI: run.modifyRunConfiguration", + "platforms": [ + "windows" + ] + }, + { + "id": "run.newCustomAction", + "description": "Windows UI: run.newCustomAction", + "platforms": [ + "windows" + ] + }, + { + "id": "run.noMatchingActions", + "description": "Windows UI: run.noMatchingActions", + "platforms": [ + "windows" + ] + }, + { + "id": "run.noRunnableActionsFound", + "description": "Windows UI: run.noRunnableActionsFound", + "platforms": [ + "windows" + ] + }, + { + "id": "run.nodeExecutable", + "description": "Windows UI: run.nodeExecutable", + "platforms": [ + "windows" + ] + }, + { + "id": "run.nodeExecutableHint", + "description": "Windows UI: run.nodeExecutableHint", + "platforms": [ + "windows" + ] + }, + { + "id": "run.otherConfigurations", + "description": "Windows UI: run.otherConfigurations", + "platforms": [ + "windows" + ] + }, + { + "id": "run.processOutput", + "description": "Windows UI: run.processOutput", + "platforms": [ + "windows" + ] + }, + { + "id": "run.programArguments", + "description": "Windows UI: run.programArguments", + "platforms": [ + "windows" + ] + }, + { + "id": "run.projectAction", + "description": "Windows UI: run.projectAction", + "platforms": [ + "windows" + ] + }, + { + "id": "run.projectDefaultsSection", + "description": "Windows UI: run.projectDefaultsSection", + "platforms": [ + "windows" + ] + }, + { + "id": "run.rescan", + "description": "Windows UI: run.rescan", + "platforms": [ + "windows" + ] + }, + { + "id": "run.rescanProjectActions", + "description": "Windows UI: run.rescanProjectActions", + "platforms": [ + "windows" + ] + }, + { + "id": "run.resizeConfigurationList", + "description": "Windows UI: run.resizeConfigurationList", + "platforms": [ + "windows" + ] + }, + { + "id": "run.resizeLanguages", + "description": "Windows UI: run.resizeLanguages", + "platforms": [ + "windows" + ] + }, + { + "id": "run.resizeLanguagesHint", + "description": "Windows UI: run.resizeLanguagesHint", + "platforms": [ + "windows" + ] + }, + { + "id": "run.run", + "description": "Windows UI: run.run", + "platforms": [ + "windows" + ] + }, + { + "id": "run.runAllServices", + "description": "Windows UI: run.runAllServices", + "platforms": [ + "windows" + ] + }, + { + "id": "run.runCell", + "description": "Windows UI: run.runCell", + "platforms": [ + "windows" + ] + }, + { + "id": "run.runChunk", + "description": "Windows UI: run.runChunk", + "platforms": [ + "windows" + ] + }, + { + "id": "run.runMarkerFailed", + "description": "Windows UI: run.runMarkerFailed", + "platforms": [ + "windows" + ], + "parameters": { + "target": "string" + } + }, + { + "id": "run.runSelectedServices", + "description": "Windows UI: run.runSelectedServices", + "platforms": [ + "windows" + ] + }, + { + "id": "run.runTarget", + "description": "Windows UI: run.runTarget", + "platforms": [ + "windows" + ], + "parameters": { + "target": "string" + } + }, + { + "id": "run.running", + "description": "Windows UI: run.running", + "platforms": [ + "windows" + ] + }, + { + "id": "run.runtimeSection", + "description": "Windows UI: run.runtimeSection", + "platforms": [ + "windows" + ] + }, + { + "id": "run.saveScope", + "description": "Windows UI: run.saveScope", + "platforms": [ + "windows" + ] + }, + { + "id": "run.saveScopeLocal", + "description": "Windows UI: run.saveScopeLocal", + "platforms": [ + "windows" + ] + }, + { + "id": "run.saveScopeLocalHint", + "description": "Windows UI: run.saveScopeLocalHint", + "platforms": [ + "windows" + ] + }, + { + "id": "run.saveScopeProject", + "description": "Windows UI: run.saveScopeProject", + "platforms": [ + "windows" + ] + }, + { + "id": "run.saveScopeProjectHint", + "description": "Windows UI: run.saveScopeProjectHint", + "platforms": [ + "windows" + ] + }, + { + "id": "run.scanning", + "description": "Windows UI: run.scanning", + "platforms": [ + "windows" + ] + }, + { + "id": "run.scanningProjectActions", + "description": "Windows UI: run.scanningProjectActions", + "platforms": [ + "windows" + ] + }, + { + "id": "run.scrollToEnd", + "description": "Windows UI: run.scrollToEnd", + "platforms": [ + "windows" + ] + }, + { + "id": "run.selectConfiguration", + "description": "Windows UI: run.selectConfiguration", + "platforms": [ + "windows" + ] + }, + { + "id": "run.services", + "description": "Windows UI: run.services", + "platforms": [ + "windows" + ] + }, + { + "id": "run.snapshotOutput", + "description": "Windows UI: run.snapshotOutput", + "platforms": [ + "windows" + ] + }, + { + "id": "run.snapshotTabName", + "description": "Windows UI: run.snapshotTabName", + "platforms": [ + "windows" + ] + }, + { + "id": "run.source.generated", + "description": "Windows UI: run.source.generated", + "platforms": [ + "windows" + ] + }, + { + "id": "run.source.local", + "description": "Windows UI: run.source.local", + "platforms": [ + "windows" + ] + }, + { + "id": "run.source.project", + "description": "Windows UI: run.source.project", + "platforms": [ + "windows" + ] + }, + { + "id": "run.staleConfigurations", + "description": "Windows UI: run.staleConfigurations", + "platforms": [ + "windows" + ] + }, + { + "id": "run.stdinPlaceholder", + "description": "Windows UI: run.stdinPlaceholder", + "platforms": [ + "windows" + ] + }, + { + "id": "run.stdinSend", + "description": "Windows UI: run.stdinSend", + "platforms": [ + "windows" + ] + }, + { + "id": "run.stop", + "description": "Windows UI: run.stop", + "platforms": [ + "windows" + ] + }, + { + "id": "run.succeeded", + "description": "Windows UI: run.succeeded", + "platforms": [ + "windows" + ] + }, + { + "id": "run.tasks", + "description": "Windows UI: run.tasks", + "platforms": [ + "windows" + ] + }, + { + "id": "run.title", + "description": "Windows UI: run.title", + "platforms": [ + "windows" + ] + }, + { + "id": "run.toolchainAuto", + "description": "Windows UI: run.toolchainAuto", + "platforms": [ + "windows" + ] + }, + { + "id": "run.toolchainCurrent", + "description": "Windows UI: run.toolchainCurrent", + "platforms": [ + "windows" + ] + }, + { + "id": "run.toolchainNeedsAttention", + "description": "Windows UI: run.toolchainNeedsAttention", + "platforms": [ + "windows" + ] + }, + { + "id": "run.toolchainProjectDefault", + "description": "Windows UI: run.toolchainProjectDefault", + "platforms": [ + "windows" + ] + }, + { + "id": "run.tryAnotherActionSearch", + "description": "Windows UI: run.tryAnotherActionSearch", + "platforms": [ + "windows" + ] + }, + { + "id": "run.type", + "description": "Windows UI: run.type", + "platforms": [ + "windows" + ] + }, + { + "id": "run.updateService", + "description": "Windows UI: run.updateService", + "platforms": [ + "windows" + ] + }, + { + "id": "run.updateServiceHelp", + "description": "Windows UI: run.updateServiceHelp", + "platforms": [ + "windows" + ] + }, + { + "id": "run.updatingService", + "description": "Windows UI: run.updatingService", + "platforms": [ + "windows" + ] + }, + { + "id": "run.vmArguments", + "description": "Windows UI: run.vmArguments", + "platforms": [ + "windows" + ] + }, + { + "id": "run.workingDirectory", + "description": "Windows UI: run.workingDirectory", + "platforms": [ + "windows" + ] + }, + { + "id": "run.workingDirectoryHint", + "description": "Windows UI: run.workingDirectoryHint", + "platforms": [ + "windows" + ] + }, + { + "id": "run.wrapOutputLines", + "description": "Windows UI: run.wrapOutputLines", + "platforms": [ + "windows" + ] + }, + { + "id": "runActions.addAction", + "description": "Windows UI: runActions.addAction", + "platforms": [ + "windows" + ] + }, + { + "id": "runActions.build", + "description": "Windows UI: runActions.build", + "platforms": [ + "windows" + ] + }, + { + "id": "runActions.check", + "description": "Windows UI: runActions.check", + "platforms": [ + "windows" + ] + }, + { + "id": "runActions.deleteNamedAction", + "description": "Windows UI: runActions.deleteNamedAction", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "runActions.editNamedAction", + "description": "Windows UI: runActions.editNamedAction", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "runActions.editRunAction", + "description": "Windows UI: runActions.editRunAction", + "platforms": [ + "windows" + ] + }, + { + "id": "runActions.name", + "description": "Windows UI: runActions.name", + "platforms": [ + "windows" + ] + }, + { + "id": "runActions.namePlaceholder", + "description": "Windows UI: runActions.namePlaceholder", + "platforms": [ + "windows" + ] + }, + { + "id": "runActions.newRunAction", + "description": "Windows UI: runActions.newRunAction", + "platforms": [ + "windows" + ] + }, + { + "id": "runActions.projectScript", + "description": "Windows UI: runActions.projectScript", + "platforms": [ + "windows" + ] + }, + { + "id": "runActions.run", + "description": "Windows UI: runActions.run", + "platforms": [ + "windows" + ] + }, + { + "id": "runActions.saveChanges", + "description": "Windows UI: runActions.saveChanges", + "platforms": [ + "windows" + ] + }, + { + "id": "runActions.savedForPrefix", + "description": "Windows UI: runActions.savedForPrefix", + "platforms": [ + "windows" + ] + }, + { + "id": "runActions.savedForSuffix", + "description": "Windows UI: runActions.savedForSuffix", + "platforms": [ + "windows" + ] + }, + { + "id": "runActions.test", + "description": "Windows UI: runActions.test", + "platforms": [ + "windows" + ] + }, + { + "id": "runActions.workingDirectoryDescription", + "description": "Windows UI: runActions.workingDirectoryDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "runningServices.ask", + "description": "Windows UI: runningServices.ask", + "platforms": [ + "windows" + ] + }, + { + "id": "runningServices.message", + "description": "Windows UI: runningServices.message", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "runningServices.quitMessage", + "description": "Windows UI: runningServices.quitMessage", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "runningServices.setting", + "description": "Windows UI: runningServices.setting", + "platforms": [ + "windows" + ] + }, + { + "id": "runningServices.settingDescription", + "description": "Windows UI: runningServices.settingDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "runningServices.settingsGroup", + "description": "Windows UI: runningServices.settingsGroup", + "platforms": [ + "windows" + ] + }, + { + "id": "runningServices.stopAndClose", + "description": "Windows UI: runningServices.stopAndClose", + "platforms": [ + "windows" + ] + }, + { + "id": "runningServices.stopAndQuit", + "description": "Windows UI: runningServices.stopAndQuit", + "platforms": [ + "windows" + ] + }, + { + "id": "runningServices.stopAndUpdate", + "description": "Windows UI: runningServices.stopAndUpdate", + "platforms": [ + "windows" + ] + }, + { + "id": "runningServices.stopFailed", + "description": "Windows UI: runningServices.stopFailed", + "platforms": [ + "windows" + ], + "parameters": { + "error": "string" + } + }, + { + "id": "runningServices.stopping", + "description": "Windows UI: runningServices.stopping", + "platforms": [ + "windows" + ] + }, + { + "id": "runningServices.terminate", + "description": "Windows UI: runningServices.terminate", + "platforms": [ + "windows" + ] + }, + { + "id": "runningServices.title", + "description": "Windows UI: runningServices.title", + "platforms": [ + "windows" + ] + }, + { + "id": "runningServices.untitled", + "description": "Windows UI: runningServices.untitled", + "platforms": [ + "windows" + ] + }, + { + "id": "runningServices.updateMessage", + "description": "Windows UI: runningServices.updateMessage", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "search.clear", + "description": "Windows UI: search.clear", + "platforms": [ + "windows" + ] + }, + { + "id": "search.close", + "description": "Windows UI: search.close", + "platforms": [ + "windows" + ] + }, + { + "id": "search.emptyDescription", + "description": "Windows UI: search.emptyDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "search.emptyTitle", + "description": "Windows UI: search.emptyTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "search.failed", + "description": "Windows UI: search.failed", + "platforms": [ + "windows" + ] + }, + { + "id": "search.filesToExclude", + "description": "Windows UI: search.filesToExclude", + "platforms": [ + "windows" + ] + }, + { + "id": "search.filesToInclude", + "description": "Windows UI: search.filesToInclude", + "platforms": [ + "windows" + ] + }, + { + "id": "search.hideDetails", + "description": "Windows UI: search.hideDetails", + "platforms": [ + "windows" + ] + }, + { + "id": "search.hideReplace", + "description": "Windows UI: search.hideReplace", + "platforms": [ + "windows" + ] + }, + { + "id": "search.matchCase", + "description": "Windows UI: search.matchCase", + "platforms": [ + "windows" + ] + }, + { + "id": "search.matchWholeWord", + "description": "Windows UI: search.matchWholeWord", + "platforms": [ + "windows" + ] + }, + { + "id": "search.nextMatch", + "description": "Windows UI: search.nextMatch", + "platforms": [ + "windows" + ] + }, + { + "id": "search.noResults", + "description": "Windows UI: search.noResults", + "platforms": [ + "windows" + ] + }, + { + "id": "search.noResultsFor", + "description": "Windows UI: search.noResultsFor", + "platforms": [ + "windows" + ], + "parameters": { + "query": "string" + } + }, + { + "id": "search.noResultsForWithFilters", + "description": "Windows UI: search.noResultsForWithFilters", + "platforms": [ + "windows" + ], + "parameters": { + "query": "string" + } + }, + { + "id": "search.openProjectDescription", + "description": "Windows UI: search.openProjectDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "search.openProjectTitle", + "description": "Windows UI: search.openProjectTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "search.options", + "description": "Windows UI: search.options", + "platforms": [ + "windows" + ] + }, + { + "id": "search.previousMatch", + "description": "Windows UI: search.previousMatch", + "platforms": [ + "windows" + ] + }, + { + "id": "search.replace", + "description": "Windows UI: search.replace", + "platforms": [ + "windows" + ] + }, + { + "id": "search.replaceAllShort", + "description": "Windows UI: search.replaceAllShort", + "platforms": [ + "windows" + ] + }, + { + "id": "search.replaceMatchesFailed", + "description": "Windows UI: search.replaceMatchesFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "search.replaceWith", + "description": "Windows UI: search.replaceWith", + "platforms": [ + "windows" + ] + }, + { + "id": "search.replacedMatches", + "description": "Windows UI: search.replacedMatches", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "search.retry", + "description": "Windows UI: search.retry", + "platforms": [ + "windows" + ] + }, + { + "id": "search.search", + "description": "Windows UI: search.search", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Search", + "arguments": [] + } + }, + { + "id": "search.showDetails", + "description": "Windows UI: search.showDetails", + "platforms": [ + "windows" + ] + }, + { + "id": "search.showReplace", + "description": "Windows UI: search.showReplace", + "platforms": [ + "windows" + ] + }, + { + "id": "search.unsupported", + "description": "Windows UI: search.unsupported", + "platforms": [ + "windows" + ] + }, + { + "id": "search.useRegex", + "description": "Windows UI: search.useRegex", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.advanced.data", + "description": "Windows UI: settings.advanced.data", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.advanced.experimental", + "description": "Windows UI: settings.advanced.experimental", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.advanced.exportFailed", + "description": "Windows UI: settings.advanced.exportFailed", + "platforms": [ + "windows" + ], + "parameters": { + "error": "string" + } + }, + { + "id": "settings.advanced.exportSettings", + "description": "Windows UI: settings.advanced.exportSettings", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.advanced.exportSettingsDescription", + "description": "Windows UI: settings.advanced.exportSettingsDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.advanced.feature.breadcrumbs.description", + "description": "Windows UI: settings.advanced.feature.breadcrumbs.description", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.advanced.feature.breadcrumbs.name", + "description": "Windows UI: settings.advanced.feature.breadcrumbs.name", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.advanced.feature.diagnostics.description", + "description": "Windows UI: settings.advanced.feature.diagnostics.description", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.advanced.feature.diagnostics.name", + "description": "Windows UI: settings.advanced.feature.diagnostics.name", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.advanced.feature.outline.description", + "description": "Windows UI: settings.advanced.feature.outline.description", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.advanced.feature.outline.name", + "description": "Windows UI: settings.advanced.feature.outline.name", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.advanced.feature.persistentCommands.description", + "description": "Windows UI: settings.advanced.feature.persistentCommands.description", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.advanced.feature.persistentCommands.name", + "description": "Windows UI: settings.advanced.feature.persistentCommands.name", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.advanced.feature.search.description", + "description": "Windows UI: settings.advanced.feature.search.description", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.advanced.feature.search.name", + "description": "Windows UI: settings.advanced.feature.search.name", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.advanced.feature.terminal.description", + "description": "Windows UI: settings.advanced.feature.terminal.description", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.advanced.feature.terminal.name", + "description": "Windows UI: settings.advanced.feature.terminal.name", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.advanced.features", + "description": "Windows UI: settings.advanced.features", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.advanced.featuresDescription", + "description": "Windows UI: settings.advanced.featuresDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.advanced.importFailed", + "description": "Windows UI: settings.advanced.importFailed", + "platforms": [ + "windows" + ], + "parameters": { + "error": "string" + } + }, + { + "id": "settings.advanced.importSettings", + "description": "Windows UI: settings.advanced.importSettings", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.advanced.importSettingsDescription", + "description": "Windows UI: settings.advanced.importSettingsDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.advanced.invalidFile", + "description": "Windows UI: settings.advanced.invalidFile", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.advanced.reset", + "description": "Windows UI: settings.advanced.reset", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.advanced.resetSettings", + "description": "Windows UI: settings.advanced.resetSettings", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.advanced.resetSettingsDescription", + "description": "Windows UI: settings.advanced.resetSettingsDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.advanced.settingsExported", + "description": "Windows UI: settings.advanced.settingsExported", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.advanced.settingsImported", + "description": "Windows UI: settings.advanced.settingsImported", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.advanced.settingsReset", + "description": "Windows UI: settings.advanced.settingsReset", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.activeAndHovered", + "description": "Windows UI: settings.appearance.activeAndHovered", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.activityBarWidth", + "description": "Windows UI: settings.appearance.activityBarWidth", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.activityBarWidthAria", + "description": "Windows UI: settings.appearance.activityBarWidthAria", + "platforms": [ + "windows" + ], + "parameters": { + "size": "string" + } + }, + { + "id": "settings.appearance.activityBarWidthDescription", + "description": "Windows UI: settings.appearance.activityBarWidthDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.always", + "description": "Windows UI: settings.appearance.always", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.colorTheme", + "description": "Windows UI: settings.appearance.colorTheme", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.colorThemeDescription", + "description": "Windows UI: settings.appearance.colorThemeDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.comfortable", + "description": "Windows UI: settings.appearance.comfortable", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.compactMenuBar", + "description": "Windows UI: settings.appearance.compactMenuBar", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.compactMenuBarDescription", + "description": "Windows UI: settings.appearance.compactMenuBarDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.create", + "description": "Windows UI: settings.appearance.create", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.customThemeRemoved", + "description": "Windows UI: settings.appearance.customThemeRemoved", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.customThemes", + "description": "Windows UI: settings.appearance.customThemes", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.customThemesDescription", + "description": "Windows UI: settings.appearance.customThemesDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.differentiateProjects", + "description": "Windows UI: settings.appearance.differentiateProjects", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.differentiateProjectsDescription", + "description": "Windows UI: settings.appearance.differentiateProjectsDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.expandedActivityBar", + "description": "Windows UI: settings.appearance.expandedActivityBar", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.expandedActivityBarDescription", + "description": "Windows UI: settings.appearance.expandedActivityBarDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.focused", + "description": "Windows UI: settings.appearance.focused", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.formatGuide", + "description": "Windows UI: settings.appearance.formatGuide", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.hoveredOnly", + "description": "Windows UI: settings.appearance.hoveredOnly", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.iconTheme", + "description": "Windows UI: settings.appearance.iconTheme", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.iconThemeDescription", + "description": "Windows UI: settings.appearance.iconThemeDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.import", + "description": "Windows UI: settings.appearance.import", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.importThemeFailed", + "description": "Windows UI: settings.appearance.importThemeFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.importedTheme", + "description": "Windows UI: settings.appearance.importedTheme", + "platforms": [ + "windows" + ], + "parameters": { + "theme": "string" + } + }, + { + "id": "settings.appearance.importedThemeVariants", + "description": "Windows UI: settings.appearance.importedThemeVariants", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "settings.appearance.interface", + "description": "Windows UI: settings.appearance.interface", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.layout", + "description": "Windows UI: settings.appearance.layout", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.nativeMenuBar", + "description": "Windows UI: settings.appearance.nativeMenuBar", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.nativeMenuBarDescription", + "description": "Windows UI: settings.appearance.nativeMenuBarDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.openAskEveryTime", + "description": "Windows UI: settings.appearance.openAskEveryTime", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.openAttach", + "description": "Windows UI: settings.appearance.openAttach", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.openNewWindow", + "description": "Windows UI: settings.appearance.openNewWindow", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.openProjectsDestination", + "description": "Windows UI: settings.appearance.openProjectsDestination", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.openProjectsDestinationDescription", + "description": "Windows UI: settings.appearance.openProjectsDestinationDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.openThisWindow", + "description": "Windows UI: settings.appearance.openThisWindow", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.preferredDarkTheme", + "description": "Windows UI: settings.appearance.preferredDarkTheme", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.preferredDarkThemeDescription", + "description": "Windows UI: settings.appearance.preferredDarkThemeDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.preferredLightTheme", + "description": "Windows UI: settings.appearance.preferredLightTheme", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.preferredLightThemeDescription", + "description": "Windows UI: settings.appearance.preferredLightThemeDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.reduceMotion", + "description": "Windows UI: settings.appearance.reduceMotion", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.reduceMotionDescription", + "description": "Windows UI: settings.appearance.reduceMotionDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.removeCustomThemeFailed", + "description": "Windows UI: settings.appearance.removeCustomThemeFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.removeTheme", + "description": "Windows UI: settings.appearance.removeTheme", + "platforms": [ + "windows" + ], + "parameters": { + "theme": "string" + } + }, + { + "id": "settings.appearance.showStatusBar", + "description": "Windows UI: settings.appearance.showStatusBar", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.showStatusBarDescription", + "description": "Windows UI: settings.appearance.showStatusBarDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.showTabIcons", + "description": "Windows UI: settings.appearance.showTabIcons", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.showTabIconsDescription", + "description": "Windows UI: settings.appearance.showTabIconsDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.sidebarWidth", + "description": "Windows UI: settings.appearance.sidebarWidth", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.sidebarWidthAria", + "description": "Windows UI: settings.appearance.sidebarWidthAria", + "platforms": [ + "windows" + ], + "parameters": { + "size": "string" + } + }, + { + "id": "settings.appearance.sidebarWidthDescription", + "description": "Windows UI: settings.appearance.sidebarWidthDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.syncWithOs", + "description": "Windows UI: settings.appearance.syncWithOs", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.syncWithOsDescription", + "description": "Windows UI: settings.appearance.syncWithOsDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.tabCloseButtons", + "description": "Windows UI: settings.appearance.tabCloseButtons", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.tabCloseButtonsDescription", + "description": "Windows UI: settings.appearance.tabCloseButtonsDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.theme", + "description": "Windows UI: settings.appearance.theme", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.typography", + "description": "Windows UI: settings.appearance.typography", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.uiFontFamily", + "description": "Windows UI: settings.appearance.uiFontFamily", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.uiFontFamilyDescription", + "description": "Windows UI: settings.appearance.uiFontFamilyDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.uiFontSize", + "description": "Windows UI: settings.appearance.uiFontSize", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.uiFontSizeAria", + "description": "Windows UI: settings.appearance.uiFontSizeAria", + "platforms": [ + "windows" + ], + "parameters": { + "size": "string" + } + }, + { + "id": "settings.appearance.uiFontSizeDescription", + "description": "Windows UI: settings.appearance.uiFontSizeDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.windowChromeDensity", + "description": "Windows UI: settings.appearance.windowChromeDensity", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.windowChromeDensityDescription", + "description": "Windows UI: settings.appearance.windowChromeDensityDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.windowTransparency", + "description": "Windows UI: settings.appearance.windowTransparency", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.appearance.windowTransparencyDescription", + "description": "Windows UI: settings.appearance.windowTransparencyDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.common.allFiles", + "description": "Windows UI: settings.common.allFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.common.json", + "description": "Windows UI: settings.common.json", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.displayLanguage", + "description": "Windows UI: settings.displayLanguage", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.displayLanguageDescription", + "description": "Windows UI: settings.displayLanguageDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.autoCompletion", + "description": "Windows UI: settings.editor.autoCompletion", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.autoCompletionDescription", + "description": "Windows UI: settings.editor.autoCompletionDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.autoDetect", + "description": "Windows UI: settings.editor.autoDetect", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.autoDetectLanguage", + "description": "Windows UI: settings.editor.autoDetectLanguage", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.autoDetectLanguageDescription", + "description": "Windows UI: settings.editor.autoDetectLanguageDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.autoSave", + "description": "Windows UI: settings.editor.autoSave", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.autoSaveDescription", + "description": "Windows UI: settings.editor.autoSaveDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.blink", + "description": "Windows UI: settings.editor.blink", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.bracketPairColorization", + "description": "Windows UI: settings.editor.bracketPairColorization", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.bracketPairColorizationDescription", + "description": "Windows UI: settings.editor.bracketPairColorizationDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.codeLens", + "description": "Windows UI: settings.editor.codeLens", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.codeLensDescription", + "description": "Windows UI: settings.editor.codeLensDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.cursorBlinking", + "description": "Windows UI: settings.editor.cursorBlinking", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.cursorBlinkingDescription", + "description": "Windows UI: settings.editor.cursorBlinkingDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.cursorBlock", + "description": "Windows UI: settings.editor.cursorBlock", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.cursorBlockOutline", + "description": "Windows UI: settings.editor.cursorBlockOutline", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.cursorLine", + "description": "Windows UI: settings.editor.cursorLine", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.cursorStyle", + "description": "Windows UI: settings.editor.cursorStyle", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.cursorStyleDescription", + "description": "Windows UI: settings.editor.cursorStyleDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.cursorThinLine", + "description": "Windows UI: settings.editor.cursorThinLine", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.cursorThinUnderline", + "description": "Windows UI: settings.editor.cursorThinUnderline", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.cursorUnderline", + "description": "Windows UI: settings.editor.cursorUnderline", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.defaultLanguage", + "description": "Windows UI: settings.editor.defaultLanguage", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.defaultLanguageDescription", + "description": "Windows UI: settings.editor.defaultLanguageDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.expand", + "description": "Windows UI: settings.editor.expand", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.fontFamily", + "description": "Windows UI: settings.editor.fontFamily", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.fontFamilyDescription", + "description": "Windows UI: settings.editor.fontFamilyDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.fontLigatures", + "description": "Windows UI: settings.editor.fontLigatures", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.fontLigaturesDescription", + "description": "Windows UI: settings.editor.fontLigaturesDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.fontPreview", + "description": "Windows UI: settings.editor.fontPreview", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.fontSize", + "description": "Windows UI: settings.editor.fontSize", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.fontSizeDescription", + "description": "Windows UI: settings.editor.fontSizeDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.formatOnSave", + "description": "Windows UI: settings.editor.formatOnSave", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.formatOnSaveDescription", + "description": "Windows UI: settings.editor.formatOnSaveDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.highlightOccurrences", + "description": "Windows UI: settings.editor.highlightOccurrences", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.highlightOccurrencesDescription", + "description": "Windows UI: settings.editor.highlightOccurrencesDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.indentGuides", + "description": "Windows UI: settings.editor.indentGuides", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.indentGuidesDescription", + "description": "Windows UI: settings.editor.indentGuidesDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.inlayHints", + "description": "Windows UI: settings.editor.inlayHints", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.inlayHintsDescription", + "description": "Windows UI: settings.editor.inlayHintsDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.italicComments", + "description": "Windows UI: settings.editor.italicComments", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.italicCommentsDescription", + "description": "Windows UI: settings.editor.italicCommentsDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.lineHeight", + "description": "Windows UI: settings.editor.lineHeight", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.lineHeightDescription", + "description": "Windows UI: settings.editor.lineHeightDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.lineNumbers", + "description": "Windows UI: settings.editor.lineNumbers", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.lineNumbersDescription", + "description": "Windows UI: settings.editor.lineNumbersDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.lintOnSave", + "description": "Windows UI: settings.editor.lintOnSave", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.lintOnSaveDescription", + "description": "Windows UI: settings.editor.lintOnSaveDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.maxOpenTabs", + "description": "Windows UI: settings.editor.maxOpenTabs", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.maxOpenTabsDescription", + "description": "Windows UI: settings.editor.maxOpenTabsDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.parameterHints", + "description": "Windows UI: settings.editor.parameterHints", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.parameterHintsDescription", + "description": "Windows UI: settings.editor.parameterHintsDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.phase", + "description": "Windows UI: settings.editor.phase", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.relativeLineNumbers", + "description": "Windows UI: settings.editor.relativeLineNumbers", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.relativeLineNumbersDescription", + "description": "Windows UI: settings.editor.relativeLineNumbersDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.renderWhitespace", + "description": "Windows UI: settings.editor.renderWhitespace", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.renderWhitespaceDescription", + "description": "Windows UI: settings.editor.renderWhitespaceDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.scrollBeyondLastLine", + "description": "Windows UI: settings.editor.scrollBeyondLastLine", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.scrollBeyondLastLineDescription", + "description": "Windows UI: settings.editor.scrollBeyondLastLineDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.section", + "description": "Windows UI: settings.editor.section", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.semanticTokens", + "description": "Windows UI: settings.editor.semanticTokens", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.semanticTokensDescription", + "description": "Windows UI: settings.editor.semanticTokensDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.showMinimap", + "description": "Windows UI: settings.editor.showMinimap", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.showMinimapDescription", + "description": "Windows UI: settings.editor.showMinimapDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.smooth", + "description": "Windows UI: settings.editor.smooth", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.smoothScrolling", + "description": "Windows UI: settings.editor.smoothScrolling", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.smoothScrollingDescription", + "description": "Windows UI: settings.editor.smoothScrollingDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.solid", + "description": "Windows UI: settings.editor.solid", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.stickyScroll", + "description": "Windows UI: settings.editor.stickyScroll", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.stickyScrollDescription", + "description": "Windows UI: settings.editor.stickyScrollDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.symbolBreadcrumb", + "description": "Windows UI: settings.editor.symbolBreadcrumb", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.symbolBreadcrumbDescription", + "description": "Windows UI: settings.editor.symbolBreadcrumbDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.tabSize", + "description": "Windows UI: settings.editor.tabSize", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.tabSizeDescription", + "description": "Windows UI: settings.editor.tabSizeDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.whitespaceAll", + "description": "Windows UI: settings.editor.whitespaceAll", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.whitespaceBoundary", + "description": "Windows UI: settings.editor.whitespaceBoundary", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.whitespaceNone", + "description": "Windows UI: settings.editor.whitespaceNone", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.whitespaceTrailing", + "description": "Windows UI: settings.editor.whitespaceTrailing", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.wordWrap", + "description": "Windows UI: settings.editor.wordWrap", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.wordWrapDescription", + "description": "Windows UI: settings.editor.wordWrapDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.files.behavior", + "description": "Windows UI: settings.files.behavior", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.files.compactFolders", + "description": "Windows UI: settings.files.compactFolders", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.files.compactFoldersDescription", + "description": "Windows UI: settings.files.compactFoldersDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.files.confirmBeforeDelete", + "description": "Windows UI: settings.files.confirmBeforeDelete", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.files.confirmBeforeDeleteDescription", + "description": "Windows UI: settings.files.confirmBeforeDeleteDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.files.display", + "description": "Windows UI: settings.files.display", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.files.filters", + "description": "Windows UI: settings.files.filters", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.files.foldersFirst", + "description": "Windows UI: settings.files.foldersFirst", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.files.globPatterns", + "description": "Windows UI: settings.files.globPatterns", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.files.hiddenDirectories", + "description": "Windows UI: settings.files.hiddenDirectories", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.files.hiddenFiles", + "description": "Windows UI: settings.files.hiddenFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.files.hideRootFolder", + "description": "Windows UI: settings.files.hideRootFolder", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.files.hideRootFolderDescription", + "description": "Windows UI: settings.files.hideRootFolderDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.files.indentSize", + "description": "Windows UI: settings.files.indentSize", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.files.indentSizeDescription", + "description": "Windows UI: settings.files.indentSizeDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.files.name", + "description": "Windows UI: settings.files.name", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.files.respectGitignore", + "description": "Windows UI: settings.files.respectGitignore", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.files.respectGitignoreDescription", + "description": "Windows UI: settings.files.respectGitignoreDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.files.showFileIcons", + "description": "Windows UI: settings.files.showFileIcons", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.files.showFileIconsDescription", + "description": "Windows UI: settings.files.showFileIconsDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.files.showGitStatus", + "description": "Windows UI: settings.files.showGitStatus", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.files.showGitStatusDescription", + "description": "Windows UI: settings.files.showGitStatusDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.files.showHiddenFiles", + "description": "Windows UI: settings.files.showHiddenFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.files.showHiddenFilesDescription", + "description": "Windows UI: settings.files.showHiddenFilesDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.files.showIndentGuides", + "description": "Windows UI: settings.files.showIndentGuides", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.files.showIndentGuidesDescription", + "description": "Windows UI: settings.files.showIndentGuidesDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.files.sortOrder", + "description": "Windows UI: settings.files.sortOrder", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.files.sortOrderDescription", + "description": "Windows UI: settings.files.sortOrderDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.general.bugReportTemplate", + "description": "Windows UI: settings.general.bugReportTemplate", + "platforms": [ + "windows" + ], + "parameters": { + "osVersion": "string", + "platform": "string", + "version": "string" + } + }, + { + "id": "settings.general.check", + "description": "Windows UI: settings.general.check", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.general.checking", + "description": "Windows UI: settings.general.checking", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.general.cliInstallFailed", + "description": "Windows UI: settings.general.cliInstallFailed", + "platforms": [ + "windows" + ], + "parameters": { + "error": "string" + } + }, + { + "id": "settings.general.cliInstalled", + "description": "Windows UI: settings.general.cliInstalled", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.general.cliNotInstalled", + "description": "Windows UI: settings.general.cliNotInstalled", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.general.cliUninstallFailed", + "description": "Windows UI: settings.general.cliUninstallFailed", + "platforms": [ + "windows" + ], + "parameters": { + "error": "string" + } + }, + { + "id": "settings.general.copy", + "description": "Windows UI: settings.general.copy", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.general.copyCommandFailed", + "description": "Windows UI: settings.general.copyCommandFailed", + "platforms": [ + "windows" + ], + "parameters": { + "error": "string" + } + }, + { + "id": "settings.general.copyInstallCommand", + "description": "Windows UI: settings.general.copyInstallCommand", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.general.downloading", + "description": "Windows UI: settings.general.downloading", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.general.githubReportDescription", + "description": "Windows UI: settings.general.githubReportDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.general.importSettings", + "description": "Windows UI: settings.general.importSettings", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.general.importSettingsDescription", + "description": "Windows UI: settings.general.importSettingsDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.general.install", + "description": "Windows UI: settings.general.install", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.general.installCommandCopied", + "description": "Windows UI: settings.general.installCommandCopied", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.general.installUpdate", + "description": "Windows UI: settings.general.installUpdate", + "platforms": [ + "windows" + ], + "parameters": { + "version": "string" + } + }, + { + "id": "settings.general.installing", + "description": "Windows UI: settings.general.installing", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.general.latestVersion", + "description": "Windows UI: settings.general.latestVersion", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.general.noReportChannel", + "description": "Windows UI: settings.general.noReportChannel", + "platforms": [ + "windows" + ], + "parameters": { + "query": "string" + } + }, + { + "id": "settings.general.open", + "description": "Windows UI: settings.general.open", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.general.prepareReportFailed", + "description": "Windows UI: settings.general.prepareReportFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.general.reportBug", + "description": "Windows UI: settings.general.reportBug", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.general.reportBugDescription", + "description": "Windows UI: settings.general.reportBugDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.general.reportTemplateCopied", + "description": "Windows UI: settings.general.reportTemplateCopied", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.general.reportVia", + "description": "Windows UI: settings.general.reportVia", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.general.terminalCommand", + "description": "Windows UI: settings.general.terminalCommand", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.general.terminalCommandDescription", + "description": "Windows UI: settings.general.terminalCommandDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.general.uninstall", + "description": "Windows UI: settings.general.uninstall", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.general.uninstalling", + "description": "Windows UI: settings.general.uninstalling", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.general.upToDate", + "description": "Windows UI: settings.general.upToDate", + "platforms": [ + "windows" + ], + "parameters": { + "version": "string" + } + }, + { + "id": "settings.general.update", + "description": "Windows UI: settings.general.update", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.general.updateAvailable", + "description": "Windows UI: settings.general.updateAvailable", + "platforms": [ + "windows" + ], + "parameters": { + "availableVersion": "string", + "version": "string" + } + }, + { + "id": "settings.general.updateCheckFailed", + "description": "Windows UI: settings.general.updateCheckFailed", + "platforms": [ + "windows" + ], + "parameters": { + "version": "string" + } + }, + { + "id": "settings.general.updateDownloadProgress", + "description": "Windows UI: settings.general.updateDownloadProgress", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.general.updateInstalling", + "description": "Windows UI: settings.general.updateInstalling", + "platforms": [ + "windows" + ], + "parameters": { + "version": "string" + } + }, + { + "id": "settings.general.updateProgress", + "description": "Windows UI: settings.general.updateProgress", + "platforms": [ + "windows" + ], + "parameters": { + "percentage": "string", + "version": "string" + } + }, + { + "id": "settings.general.version", + "description": "Windows UI: settings.general.version", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.general.versionDescription", + "description": "Windows UI: settings.general.versionDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.git.autoRefresh", + "description": "Windows UI: settings.git.autoRefresh", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.git.autoRefreshDescription", + "description": "Windows UI: settings.git.autoRefreshDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.git.collapseEmpty", + "description": "Windows UI: settings.git.collapseEmpty", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.git.collapseEmptyDescription", + "description": "Windows UI: settings.git.collapseEmptyDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.git.compactBadges", + "description": "Windows UI: settings.git.compactBadges", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.git.compactBadgesDescription", + "description": "Windows UI: settings.git.compactBadgesDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.git.confirmDiscard", + "description": "Windows UI: settings.git.confirmDiscard", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.git.confirmDiscardDescription", + "description": "Windows UI: settings.git.confirmDiscardDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.git.defaultDiff", + "description": "Windows UI: settings.git.defaultDiff", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.git.defaultDiffDescription", + "description": "Windows UI: settings.git.defaultDiffDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.git.editor", + "description": "Windows UI: settings.git.editor", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.git.folderChanges", + "description": "Windows UI: settings.git.folderChanges", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.git.folderChangesDescription", + "description": "Windows UI: settings.git.folderChangesDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.git.gitIntegration", + "description": "Windows UI: settings.git.gitIntegration", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.git.gitIntegrationDescription", + "description": "Windows UI: settings.git.gitIntegrationDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.git.gutter", + "description": "Windows UI: settings.git.gutter", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.git.gutterDescription", + "description": "Windows UI: settings.git.gutterDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.git.inlineBlame", + "description": "Windows UI: settings.git.inlineBlame", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.git.inlineBlameDescription", + "description": "Windows UI: settings.git.inlineBlameDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.git.integration", + "description": "Windows UI: settings.git.integration", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.git.openDiff", + "description": "Windows UI: settings.git.openDiff", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.git.openDiffDescription", + "description": "Windows UI: settings.git.openDiffDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.git.split", + "description": "Windows UI: settings.git.split", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.git.stagedFirst", + "description": "Windows UI: settings.git.stagedFirst", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.git.stagedFirstDescription", + "description": "Windows UI: settings.git.stagedFirstDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.git.unified", + "description": "Windows UI: settings.git.unified", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.git.untracked", + "description": "Windows UI: settings.git.untracked", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.git.untrackedDescription", + "description": "Windows UI: settings.git.untrackedDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.git.view", + "description": "Windows UI: settings.git.view", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.groups.ai", + "description": "Windows UI: settings.groups.ai", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.groups.appearance", + "description": "Windows UI: settings.groups.appearance", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.groups.build", + "description": "Windows UI: settings.groups.build", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.groups.languages", + "description": "Windows UI: settings.groups.languages", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.groups.tools", + "description": "Windows UI: settings.groups.tools", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.groups.versionControl", + "description": "Windows UI: settings.groups.versionControl", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.keyboard.actions", + "description": "Windows UI: settings.keyboard.actions", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.keyboard.all", + "description": "Windows UI: settings.keyboard.all", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.keyboard.andPreset", + "description": "Windows UI: settings.keyboard.andPreset", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.keyboard.back", + "description": "Windows UI: settings.keyboard.back", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Back", + "arguments": [] + } + }, + { + "id": "settings.keyboard.command", + "description": "Windows UI: settings.keyboard.command", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.keyboard.default", + "description": "Windows UI: settings.keyboard.default", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.keyboard.editKeybindings", + "description": "Windows UI: settings.keyboard.editKeybindings", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.keyboard.editKeybindingsDescription", + "description": "Windows UI: settings.keyboard.editKeybindingsDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.keyboard.export", + "description": "Windows UI: settings.keyboard.export", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.keyboard.exportFailed", + "description": "Windows UI: settings.keyboard.exportFailed", + "platforms": [ + "windows" + ], + "parameters": { + "error": "string" + } + }, + { + "id": "settings.keyboard.extension", + "description": "Windows UI: settings.keyboard.extension", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.keyboard.filter", + "description": "Windows UI: settings.keyboard.filter", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.keyboard.import", + "description": "Windows UI: settings.keyboard.import", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.keyboard.importFailed", + "description": "Windows UI: settings.keyboard.importFailed", + "platforms": [ + "windows" + ], + "parameters": { + "error": "string" + } + }, + { + "id": "settings.keyboard.invalidFile", + "description": "Windows UI: settings.keyboard.invalidFile", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.keyboard.keybinding", + "description": "Windows UI: settings.keyboard.keybinding", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.keyboard.keybindingsExported", + "description": "Windows UI: settings.keyboard.keybindingsExported", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.keyboard.keybindingsImported", + "description": "Windows UI: settings.keyboard.keybindingsImported", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string", + "preset": "string" + } + }, + { + "id": "settings.keyboard.keybindingsReset", + "description": "Windows UI: settings.keyboard.keybindingsReset", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.keyboard.noKeybindings", + "description": "Windows UI: settings.keyboard.noKeybindings", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.keyboard.none", + "description": "Windows UI: settings.keyboard.none", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.keyboard.openEditor", + "description": "Windows UI: settings.keyboard.openEditor", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.keyboard.pluralSuffix", + "description": "Windows UI: settings.keyboard.pluralSuffix", + "platforms": [ + "windows" + ], + "allowEmptyTranslation": true + }, + { + "id": "settings.keyboard.preset", + "description": "Windows UI: settings.keyboard.preset", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.keyboard.presetAria", + "description": "Windows UI: settings.keyboard.presetAria", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.keyboard.presetChanges", + "description": "Windows UI: settings.keyboard.presetChanges", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.keyboard.presetDescription", + "description": "Windows UI: settings.keyboard.presetDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.keyboard.presetIncomplete", + "description": "Windows UI: settings.keyboard.presetIncomplete", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string", + "suffix": "string" + } + }, + { + "id": "settings.keyboard.presetLabel", + "description": "Windows UI: settings.keyboard.presetLabel", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.keyboard.presetPluralSuffix", + "description": "Windows UI: settings.keyboard.presetPluralSuffix", + "platforms": [ + "windows" + ], + "allowEmptyTranslation": true + }, + { + "id": "settings.keyboard.presetSingularSuffix", + "description": "Windows UI: settings.keyboard.presetSingularSuffix", + "platforms": [ + "windows" + ], + "allowEmptyTranslation": true + }, + { + "id": "settings.keyboard.resetToDefaults", + "description": "Windows UI: settings.keyboard.resetToDefaults", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.keyboard.search", + "description": "Windows UI: settings.keyboard.search", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.keyboard.source", + "description": "Windows UI: settings.keyboard.source", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.keyboard.user", + "description": "Windows UI: settings.keyboard.user", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.keyboard.userOverrides", + "description": "Windows UI: settings.keyboard.userOverrides", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string", + "suffix": "string" + } + }, + { + "id": "settings.keyboard.vimMode", + "description": "Windows UI: settings.keyboard.vimMode", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.keyboard.vimModeComingSoon", + "description": "Windows UI: settings.keyboard.vimModeComingSoon", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.keyboard.vimModeDescription", + "description": "Windows UI: settings.keyboard.vimModeDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.keyboard.when", + "description": "Windows UI: settings.keyboard.when", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.languageChinese", + "description": "Windows UI: settings.languageChinese", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.languageEnglish", + "description": "Windows UI: settings.languageEnglish", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.cancel", + "description": "Windows UI: settings.logs.cancel", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.category.all", + "description": "Windows UI: settings.logs.category.all", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.category.application", + "description": "Windows UI: settings.logs.category.application", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.category.git", + "description": "Windows UI: settings.logs.category.git", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.category.language", + "description": "Windows UI: settings.logs.category.language", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.category.performance", + "description": "Windows UI: settings.logs.category.performance", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.category.plugins", + "description": "Windows UI: settings.logs.category.plugins", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.category.run", + "description": "Windows UI: settings.logs.category.run", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.choose", + "description": "Windows UI: settings.logs.choose", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.chooseDirectory", + "description": "Windows UI: settings.logs.chooseDirectory", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.cleanupFailed", + "description": "Windows UI: settings.logs.cleanupFailed", + "platforms": [ + "windows" + ], + "parameters": { + "error": "string" + } + }, + { + "id": "settings.logs.clearCurrent", + "description": "Windows UI: settings.logs.clearCurrent", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.clearCurrentDescription", + "description": "Windows UI: settings.logs.clearCurrentDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.clearLogs", + "description": "Windows UI: settings.logs.clearLogs", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.clearPrompt", + "description": "Windows UI: settings.logs.clearPrompt", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.clearTitle", + "description": "Windows UI: settings.logs.clearTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.cleared", + "description": "Windows UI: settings.logs.cleared", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string", + "size": "string" + } + }, + { + "id": "settings.logs.copyFailed", + "description": "Windows UI: settings.logs.copyFailed", + "platforms": [ + "windows" + ], + "parameters": { + "error": "string" + } + }, + { + "id": "settings.logs.copyPath", + "description": "Windows UI: settings.logs.copyPath", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.customPath", + "description": "Windows UI: settings.logs.customPath", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.customPathDescription", + "description": "Windows UI: settings.logs.customPathDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.defaultPath", + "description": "Windows UI: settings.logs.defaultPath", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.defaultPathDescription", + "description": "Windows UI: settings.logs.defaultPathDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.defaultRestored", + "description": "Windows UI: settings.logs.defaultRestored", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.deleteLogs", + "description": "Windows UI: settings.logs.deleteLogs", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.diagnosticBundle", + "description": "Windows UI: settings.logs.diagnosticBundle", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.diagnosticBundleDescription", + "description": "Windows UI: settings.logs.diagnosticBundleDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.diagnosticExpiry", + "description": "Windows UI: settings.logs.diagnosticExpiry", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.diagnosticFailed", + "description": "Windows UI: settings.logs.diagnosticFailed", + "platforms": [ + "windows" + ], + "parameters": { + "error": "string" + } + }, + { + "id": "settings.logs.diagnosticMode", + "description": "Windows UI: settings.logs.diagnosticMode", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.diagnosticModeDescription", + "description": "Windows UI: settings.logs.diagnosticModeDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.diagnosticOff", + "description": "Windows UI: settings.logs.diagnosticOff", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.diagnosticOn", + "description": "Windows UI: settings.logs.diagnosticOn", + "platforms": [ + "windows" + ], + "parameters": { + "module": "string" + } + }, + { + "id": "settings.logs.diagnostics", + "description": "Windows UI: settings.logs.diagnostics", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.directoryChangeFailed", + "description": "Windows UI: settings.logs.directoryChangeFailed", + "platforms": [ + "windows" + ], + "parameters": { + "error": "string" + } + }, + { + "id": "settings.logs.directoryChanged", + "description": "Windows UI: settings.logs.directoryChanged", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.effectivePath", + "description": "Windows UI: settings.logs.effectivePath", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.effectivePathDescription", + "description": "Windows UI: settings.logs.effectivePathDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.empty", + "description": "Windows UI: settings.logs.empty", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.exportBundle", + "description": "Windows UI: settings.logs.exportBundle", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.exportBundleChooseDestination", + "description": "Windows UI: settings.logs.exportBundleChooseDestination", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.exportBundleConfirm", + "description": "Windows UI: settings.logs.exportBundleConfirm", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.exportBundleFailed", + "description": "Windows UI: settings.logs.exportBundleFailed", + "platforms": [ + "windows" + ], + "parameters": { + "error": "string" + } + }, + { + "id": "settings.logs.exportBundleFilter", + "description": "Windows UI: settings.logs.exportBundleFilter", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.exportBundleIntro", + "description": "Windows UI: settings.logs.exportBundleIntro", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.exportBundlePreviewFailed", + "description": "Windows UI: settings.logs.exportBundlePreviewFailed", + "platforms": [ + "windows" + ], + "parameters": { + "error": "string" + } + }, + { + "id": "settings.logs.exportBundleSuccess", + "description": "Windows UI: settings.logs.exportBundleSuccess", + "platforms": [ + "windows" + ], + "parameters": { + "path": "string" + } + }, + { + "id": "settings.logs.exportBundleTitle", + "description": "Windows UI: settings.logs.exportBundleTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.fallback.loggingUnavailable", + "description": "Windows UI: settings.logs.fallback.logging_unavailable", + "platforms": [ + "windows" + ], + "windows": { + "key": "settings.logs.fallback.logging_unavailable" + } + }, + { + "id": "settings.logs.fallback.notWritable", + "description": "Windows UI: settings.logs.fallback.not_writable", + "platforms": [ + "windows" + ], + "windows": { + "key": "settings.logs.fallback.not_writable" + } + }, + { + "id": "settings.logs.fallback.unavailable", + "description": "Windows UI: settings.logs.fallback.unavailable", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.fallbackNotification", + "description": "Windows UI: settings.logs.fallbackNotification", + "platforms": [ + "windows" + ], + "parameters": { + "path": "string" + } + }, + { + "id": "settings.logs.health", + "description": "Windows UI: settings.logs.health", + "platforms": [ + "windows" + ], + "parameters": { + "dropped": "string", + "failures": "string" + } + }, + { + "id": "settings.logs.incomplete", + "description": "Windows UI: settings.logs.incomplete", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.keepLogs", + "description": "Windows UI: settings.logs.keepLogs", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.level", + "description": "Windows UI: settings.logs.level", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.loadFailed", + "description": "Windows UI: settings.logs.loadFailed", + "platforms": [ + "windows" + ], + "parameters": { + "error": "string" + } + }, + { + "id": "settings.logs.loading", + "description": "Windows UI: settings.logs.loading", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.locations", + "description": "Windows UI: settings.logs.locations", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.module", + "description": "Windows UI: settings.logs.module", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.openCurrent", + "description": "Windows UI: settings.logs.openCurrent", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.openDirectory", + "description": "Windows UI: settings.logs.openDirectory", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.openDirectoryFailed", + "description": "Windows UI: settings.logs.openDirectoryFailed", + "platforms": [ + "windows" + ], + "parameters": { + "error": "string" + } + }, + { + "id": "settings.logs.openFailed", + "description": "Windows UI: settings.logs.openFailed", + "platforms": [ + "windows" + ], + "parameters": { + "error": "string" + } + }, + { + "id": "settings.logs.pathCopied", + "description": "Windows UI: settings.logs.pathCopied", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.previewExport", + "description": "Windows UI: settings.logs.previewExport", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.previousDirectoryPrompt", + "description": "Windows UI: settings.logs.previousDirectoryPrompt", + "platforms": [ + "windows" + ], + "parameters": { + "path": "string", + "size": "string" + } + }, + { + "id": "settings.logs.previousDirectoryTitle", + "description": "Windows UI: settings.logs.previousDirectoryTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.refreshFailed", + "description": "Windows UI: settings.logs.refreshFailed", + "platforms": [ + "windows" + ], + "parameters": { + "error": "string" + } + }, + { + "id": "settings.logs.refreshTimeline", + "description": "Windows UI: settings.logs.refreshTimeline", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.restoreDefault", + "description": "Windows UI: settings.logs.restoreDefault", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.retention", + "description": "Windows UI: settings.logs.retention", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.retentionDescription", + "description": "Windows UI: settings.logs.retentionDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.search", + "description": "Windows UI: settings.logs.search", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.selectionSummary", + "description": "Windows UI: settings.logs.selectionSummary", + "platforms": [ + "windows" + ], + "parameters": { + "level": "string", + "module": "string", + "session": "string" + } + }, + { + "id": "settings.logs.session", + "description": "Windows UI: settings.logs.session", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.session.all", + "description": "Windows UI: settings.logs.session.all", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.session.current", + "description": "Windows UI: settings.logs.session.current", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.session.previous", + "description": "Windows UI: settings.logs.session.previous", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.since", + "description": "Windows UI: settings.logs.since", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.timeline", + "description": "Windows UI: settings.logs.timeline", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.unavailableNotification", + "description": "Windows UI: settings.logs.unavailableNotification", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.until", + "description": "Windows UI: settings.logs.until", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.usingDefault", + "description": "Windows UI: settings.logs.usingDefault", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.logs.workflow", + "description": "Windows UI: settings.logs.workflow", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.aiProvider", + "description": "Windows UI: settings.mac.aiProvider", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.apiKey", + "description": "Windows UI: settings.mac.apiKey", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.apiKeyPlaceholder", + "description": "Windows UI: settings.mac.apiKeyPlaceholder", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.apiKeySaveFailed", + "description": "Windows UI: settings.mac.apiKeySaveFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.apiKeySaved", + "description": "Windows UI: settings.mac.apiKeySaved", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.apiKeySavedWithoutValidation", + "description": "Windows UI: settings.mac.apiKeySavedWithoutValidation", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.apiKeyStoredPlaceholder", + "description": "Windows UI: settings.mac.apiKeyStoredPlaceholder", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.apiUrl", + "description": "Windows UI: settings.mac.apiUrl", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.appearance", + "description": "Settings label: Appearance.", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Appearance", + "arguments": [] + } + }, + { + "id": "settings.mac.appearanceDescription", + "description": "Windows UI: settings.mac.appearanceDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.appearanceMode", + "description": "Windows UI: settings.mac.appearanceMode", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.apply", + "description": "Windows UI: settings.mac.apply", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.askEveryTime", + "description": "Windows UI: settings.mac.askEveryTime", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.attach", + "description": "Windows UI: settings.mac.attach", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.autoCompletion", + "description": "Windows UI: settings.mac.autoCompletion", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.autoCompletionDescription", + "description": "Windows UI: settings.mac.autoCompletionDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.autoSave", + "description": "Windows UI: settings.mac.autoSave", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.categories", + "description": "Windows UI: settings.mac.categories", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.checkForUpdates", + "description": "Windows UI: settings.mac.checkForUpdates", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.checking", + "description": "Windows UI: settings.mac.checking", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.colorTheme", + "description": "Windows UI: settings.mac.colorTheme", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.commitMessage", + "description": "Windows UI: settings.mac.commitMessage", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.currentVersion", + "description": "Windows UI: settings.mac.currentVersion", + "platforms": [ + "windows" + ], + "parameters": { + "version": "string" + } + }, + { + "id": "settings.mac.dark", + "description": "Windows UI: settings.mac.dark", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.defaultShell", + "description": "Windows UI: settings.mac.defaultShell", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.defaultShellDescription", + "description": "Windows UI: settings.mac.defaultShellDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.detectedServers", + "description": "Windows UI: settings.mac.detectedServers", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.detectedServersDescription", + "description": "Windows UI: settings.mac.detectedServersDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.directories", + "description": "Windows UI: settings.mac.directories", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.display", + "description": "Windows UI: settings.mac.display", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.done", + "description": "Windows UI: settings.mac.done", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.editorTabs", + "description": "Settings label: Editor tabs.", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Editor tabs", + "arguments": [] + } + }, + { + "id": "settings.mac.enableAiCommit", + "description": "Windows UI: settings.mac.enableAiCommit", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.filePatterns", + "description": "Windows UI: settings.mac.filePatterns", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.files", + "description": "Windows UI: settings.mac.files", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.followSystem", + "description": "Windows UI: settings.mac.followSystem", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.fontSize", + "description": "Settings label: Font size.", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Font size", + "arguments": [] + } + }, + { + "id": "settings.mac.git", + "description": "Windows UI: settings.mac.git", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Git", + "arguments": [] + } + }, + { + "id": "settings.mac.gitPolicyDescription", + "description": "Windows UI: settings.mac.gitPolicyDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.gitStash", + "description": "Windows UI: settings.mac.gitStash", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.hiddenPaths", + "description": "Windows UI: settings.mac.hiddenPaths", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.hiddenPathsDescription", + "description": "Windows UI: settings.mac.hiddenPathsDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.indentation", + "description": "Windows UI: settings.mac.indentation", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.keymapJetBrains", + "description": "Windows UI: settings.mac.keymapJetBrains", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.keymapLithe", + "description": "Windows UI: settings.mac.keymapLithe", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.keymapPreset", + "description": "Settings label: Keymap.", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Keymap", + "arguments": [] + } + }, + { + "id": "settings.mac.keymapVisualStudioCode", + "description": "Windows UI: settings.mac.keymapVisualStudioCode", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.keymapXcode", + "description": "Windows UI: settings.mac.keymapXcode", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.language", + "description": "Settings label: Language.", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Language", + "arguments": [] + } + }, + { + "id": "settings.mac.languageDescription", + "description": "Windows UI: settings.mac.languageDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.languageServices", + "description": "Windows UI: settings.mac.languageServices", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.layout", + "description": "Windows UI: settings.mac.layout", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.light", + "description": "Windows UI: settings.mac.light", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.model", + "description": "Windows UI: settings.mac.model", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.newWindow", + "description": "Windows UI: settings.mac.newWindow", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.openProjectsDescription", + "description": "Windows UI: settings.mac.openProjectsDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.openProjectsIn", + "description": "Windows UI: settings.mac.openProjectsIn", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.parameterHints", + "description": "Windows UI: settings.mac.parameterHints", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.preset", + "description": "Windows UI: settings.mac.preset", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.projects", + "description": "Settings label: Projects.", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Projects", + "arguments": [] + } + }, + { + "id": "settings.mac.provider", + "description": "Windows UI: settings.mac.provider", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.restoreDefaults", + "description": "Windows UI: settings.mac.restoreDefaults", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Restore Defaults", + "arguments": [] + } + }, + { + "id": "settings.mac.restoreDefaultsConfirm", + "description": "Windows UI: settings.mac.restoreDefaultsConfirm", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.saveLocalChangesWith", + "description": "Windows UI: settings.mac.saveLocalChangesWith", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.searchShortcuts", + "description": "Windows UI: settings.mac.searchShortcuts", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.semanticHighlighting", + "description": "Windows UI: settings.mac.semanticHighlighting", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.shelf", + "description": "Windows UI: settings.mac.shelf", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.shell", + "description": "Windows UI: settings.mac.shell", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.shortcuts", + "description": "Windows UI: settings.mac.shortcuts", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.shortcutsDescription", + "description": "Windows UI: settings.mac.shortcutsDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.showCodeVision", + "description": "Windows UI: settings.mac.showCodeVision", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.singleRow", + "description": "Windows UI: settings.mac.singleRow", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.softwareUpdate", + "description": "Windows UI: settings.mac.softwareUpdate", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.spaces", + "description": "Windows UI: settings.mac.spaces", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.systemDefault", + "description": "Windows UI: settings.mac.systemDefault", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.tabWidth", + "description": "Windows UI: settings.mac.tabWidth", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.thisWindow", + "description": "Windows UI: settings.mac.thisWindow", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.upToDate", + "description": "Windows UI: settings.mac.upToDate", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.updateAvailable", + "description": "Windows UI: settings.mac.updateAvailable", + "platforms": [ + "windows" + ], + "parameters": { + "version": "string" + } + }, + { + "id": "settings.mac.updateFailed", + "description": "Windows UI: settings.mac.updateFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.updateHint", + "description": "Windows UI: settings.mac.updateHint", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mac.wrapRows", + "description": "Windows UI: settings.mac.wrapRows", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mcp.allowConfigure", + "description": "Windows UI: settings.mcp.allowConfigure", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mcp.allowExecute", + "description": "Windows UI: settings.mcp.allowExecute", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mcp.copyConfiguration", + "description": "Windows UI: settings.mcp.copyConfiguration", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mcp.description", + "description": "Windows UI: settings.mcp.description", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mcp.disable", + "description": "Windows UI: settings.mcp.disable", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mcp.enable", + "description": "Windows UI: settings.mcp.enable", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mcp.openProject", + "description": "Windows UI: settings.mcp.openProject", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mcp.plugins", + "description": "Windows UI: settings.mcp.plugins", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mcp.title", + "description": "Windows UI: settings.mcp.title", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.navigation", + "description": "Windows UI: settings.navigation", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.noMatching", + "description": "Windows UI: settings.noMatching", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.noMatchingSettings", + "description": "Windows UI: settings.noMatchingSettings", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.noResults", + "description": "Settings label: No settings found.", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "No settings found", + "arguments": [] + } + }, + { + "id": "settings.project.detected", + "description": "Windows UI: settings.project.detected", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.project.loading", + "description": "Windows UI: settings.project.loading", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.project.mavenAutomatic", + "description": "Windows UI: settings.project.mavenAutomatic", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.project.openProject", + "description": "Windows UI: settings.project.openProject", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.project.openSettings", + "description": "Windows UI: settings.project.openSettings", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.project.overrides", + "description": "Windows UI: settings.project.overrides", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.project.refresh", + "description": "Windows UI: settings.project.refresh", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.project.reloadFailed", + "description": "Windows UI: settings.project.reloadFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.project.saved", + "description": "Windows UI: settings.project.saved", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.project.savedReloadFailed", + "description": "Windows UI: settings.project.savedReloadFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.project.scope", + "description": "Windows UI: settings.project.scope", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.project.scopeTitle", + "description": "Windows UI: settings.project.scopeTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.project.title", + "description": "Windows UI: settings.project.title", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.project.useProjectJdk", + "description": "Windows UI: settings.project.useProjectJdk", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.resizeCategories", + "description": "Windows UI: settings.resizeCategories", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.resizePlugins", + "description": "Windows UI: settings.resizePlugins", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.run.description", + "description": "Windows UI: settings.run.description", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.run.title", + "description": "Windows UI: settings.run.title", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.search", + "description": "Settings label: Search settings.", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Search settings", + "arguments": [] + } + }, + { + "id": "settings.sections", + "description": "Windows UI: settings.sections", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.tabs.advanced", + "description": "Windows UI: settings.tabs.advanced", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.tabs.agents", + "description": "Windows UI: settings.tabs.agents", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.tabs.ai", + "description": "Windows UI: settings.tabs.ai", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.tabs.aiCommit", + "description": "Windows UI: settings.tabs.aiCommit", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.tabs.appearance", + "description": "Windows UI: settings.tabs.appearance", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.tabs.editor", + "description": "Windows UI: settings.tabs.editor", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.tabs.files", + "description": "Windows UI: settings.tabs.files", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.tabs.general", + "description": "Windows UI: settings.tabs.general", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.tabs.git", + "description": "Windows UI: settings.tabs.git", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.tabs.keyboard", + "description": "Windows UI: settings.tabs.keyboard", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.tabs.logs", + "description": "Windows UI: settings.tabs.logs", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.tabs.lsp", + "description": "Windows UI: settings.tabs.lsp", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.tabs.maven", + "description": "Windows UI: settings.tabs.maven", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.tabs.plugins", + "description": "Windows UI: settings.tabs.plugins", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.tabs.terminal", + "description": "Windows UI: settings.tabs.terminal", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.tabs.updates", + "description": "Windows UI: settings.tabs.updates", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.addProfile", + "description": "Windows UI: settings.terminal.addProfile", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.altClickMovesCursor", + "description": "Windows UI: settings.terminal.altClickMovesCursor", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.altClickMovesCursorDescription", + "description": "Windows UI: settings.terminal.altClickMovesCursorDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.bar", + "description": "Windows UI: settings.terminal.bar", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.blinkingCursor", + "description": "Windows UI: settings.terminal.blinkingCursor", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.blinkingCursorDescription", + "description": "Windows UI: settings.terminal.blinkingCursorDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.block", + "description": "Windows UI: settings.terminal.block", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.cursor", + "description": "Windows UI: settings.terminal.cursor", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.cursorStyle", + "description": "Windows UI: settings.terminal.cursorStyle", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.cursorStyleDescription", + "description": "Windows UI: settings.terminal.cursorStyleDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.cursorWidth", + "description": "Windows UI: settings.terminal.cursorWidth", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.cursorWidthDescription", + "description": "Windows UI: settings.terminal.cursorWidthDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.custom", + "description": "Windows UI: settings.terminal.custom", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.customProfile", + "description": "Windows UI: settings.terminal.customProfile", + "platforms": [ + "windows" + ], + "parameters": { + "number": "string" + } + }, + { + "id": "settings.terminal.defaultProfile", + "description": "Windows UI: settings.terminal.defaultProfile", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.defaultProfileDescription", + "description": "Windows UI: settings.terminal.defaultProfileDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.defaultShell", + "description": "Windows UI: settings.terminal.defaultShell", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.defaultShellDescription", + "description": "Windows UI: settings.terminal.defaultShellDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.deleteProfile", + "description": "Windows UI: settings.terminal.deleteProfile", + "platforms": [ + "windows" + ], + "parameters": { + "profile": "string" + } + }, + { + "id": "settings.terminal.fontFamily", + "description": "Windows UI: settings.terminal.fontFamily", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.fontFamilyDescription", + "description": "Windows UI: settings.terminal.fontFamilyDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.fontHelp", + "description": "Windows UI: settings.terminal.fontHelp", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.fontSize", + "description": "Windows UI: settings.terminal.fontSize", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.fontSizeDescription", + "description": "Windows UI: settings.terminal.fontSizeDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.hidden", + "description": "Windows UI: settings.terminal.hidden", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.inactiveCursorStyle", + "description": "Windows UI: settings.terminal.inactiveCursorStyle", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.inactiveCursorStyleDescription", + "description": "Windows UI: settings.terminal.inactiveCursorStyleDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.interaction", + "description": "Windows UI: settings.terminal.interaction", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.launch", + "description": "Windows UI: settings.terminal.launch", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.launchDescription", + "description": "Windows UI: settings.terminal.launchDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.letterSpacing", + "description": "Windows UI: settings.terminal.letterSpacing", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.letterSpacingDescription", + "description": "Windows UI: settings.terminal.letterSpacingDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.lineHeight", + "description": "Windows UI: settings.terminal.lineHeight", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.lineHeightDescription", + "description": "Windows UI: settings.terminal.lineHeightDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.nerdFont", + "description": "Windows UI: settings.terminal.nerdFont", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.noCustomProfiles", + "description": "Windows UI: settings.terminal.noCustomProfiles", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.optionAsMeta", + "description": "Windows UI: settings.terminal.optionAsMeta", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.optionAsMetaDescription", + "description": "Windows UI: settings.terminal.optionAsMetaDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.outline", + "description": "Windows UI: settings.terminal.outline", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.profileName", + "description": "Windows UI: settings.terminal.profileName", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.profileNamePlaceholder", + "description": "Windows UI: settings.terminal.profileNamePlaceholder", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.profilePickerDescription", + "description": "Windows UI: settings.terminal.profilePickerDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.profileShell", + "description": "Windows UI: settings.terminal.profileShell", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.profiles", + "description": "Windows UI: settings.terminal.profiles", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.profilesDescription", + "description": "Windows UI: settings.terminal.profilesDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.profilesHelp", + "description": "Windows UI: settings.terminal.profilesHelp", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.rightClickSelectsWord", + "description": "Windows UI: settings.terminal.rightClickSelectsWord", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.rightClickSelectsWordDescription", + "description": "Windows UI: settings.terminal.rightClickSelectsWordDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.scrollback", + "description": "Windows UI: settings.terminal.scrollback", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.scrollbackDescription", + "description": "Windows UI: settings.terminal.scrollbackDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.selectFont", + "description": "Windows UI: settings.terminal.selectFont", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.startupCommands", + "description": "Windows UI: settings.terminal.startupCommands", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.startupCommandsDescription", + "description": "Windows UI: settings.terminal.startupCommandsDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.startupCommandsPlaceholder", + "description": "Windows UI: settings.terminal.startupCommandsPlaceholder", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.startupDirectory", + "description": "Windows UI: settings.terminal.startupDirectory", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.startupDirectoryDescription", + "description": "Windows UI: settings.terminal.startupDirectoryDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.startupDirectoryPlaceholder", + "description": "Windows UI: settings.terminal.startupDirectoryPlaceholder", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.systemDefault", + "description": "Windows UI: settings.terminal.systemDefault", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.typography", + "description": "Windows UI: settings.terminal.typography", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.terminal.underline", + "description": "Windows UI: settings.terminal.underline", + "platforms": [ + "windows" + ] + }, + { + "id": "springEndpoints.empty", + "description": "Windows UI: springEndpoints.empty", + "platforms": [ + "windows" + ] + }, + { + "id": "springEndpoints.error.indexFailed", + "description": "Windows UI: springEndpoints.error.indexFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "springEndpoints.error.indexTimeout", + "description": "Windows UI: springEndpoints.error.indexTimeout", + "platforms": [ + "windows" + ] + }, + { + "id": "springEndpoints.error.permissionDenied", + "description": "Windows UI: springEndpoints.error.permissionDenied", + "platforms": [ + "windows" + ] + }, + { + "id": "springEndpoints.error.rootUnavailable", + "description": "Windows UI: springEndpoints.error.rootUnavailable", + "platforms": [ + "windows" + ] + }, + { + "id": "springEndpoints.error.unsupportedRoot", + "description": "Windows UI: springEndpoints.error.unsupportedRoot", + "platforms": [ + "windows" + ] + }, + { + "id": "springEndpoints.filterPlaceholder", + "description": "Windows UI: springEndpoints.filterPlaceholder", + "platforms": [ + "windows" + ] + }, + { + "id": "springEndpoints.loading", + "description": "Windows UI: springEndpoints.loading", + "platforms": [ + "windows" + ] + }, + { + "id": "springEndpoints.noMatch", + "description": "Windows UI: springEndpoints.noMatch", + "platforms": [ + "windows" + ] + }, + { + "id": "springEndpoints.refreshing", + "description": "Windows UI: springEndpoints.refreshing", + "platforms": [ + "windows" + ] + }, + { + "id": "springEndpoints.routeCount", + "description": "Windows UI: springEndpoints.routeCount", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "springEndpoints.routeCountOne", + "description": "Windows UI: springEndpoints.routeCountOne", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "springEndpoints.stale", + "description": "Windows UI: springEndpoints.stale", + "platforms": [ + "windows" + ] + }, + { + "id": "svg.editor", + "description": "Windows UI: svg.editor", + "platforms": [ + "windows" + ] + }, + { + "id": "svg.invalid", + "description": "Windows UI: svg.invalid", + "platforms": [ + "windows" + ] + }, + { + "id": "svg.preview", + "description": "Windows UI: svg.preview", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Preview", + "arguments": [] + } + }, + { + "id": "svg.resize", + "description": "Windows UI: svg.resize", + "platforms": [ + "windows" + ] + }, + { + "id": "svg.split", + "description": "Windows UI: svg.split", + "platforms": [ + "windows" + ] + }, + { + "id": "svg.viewMode", + "description": "Windows UI: svg.viewMode", + "platforms": [ + "windows" + ] + }, + { + "id": "tabs.activated", + "description": "Windows UI: tabs.activated", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string", + "unsaved": "string" + } + }, + { + "id": "tabs.announcementUnsaved", + "description": "Windows UI: tabs.announcementUnsaved", + "platforms": [ + "windows" + ] + }, + { + "id": "tabs.ariaPinnedSuffix", + "description": "Windows UI: tabs.ariaPinnedSuffix", + "platforms": [ + "windows" + ] + }, + { + "id": "tabs.ariaPreviewSuffix", + "description": "Windows UI: tabs.ariaPreviewSuffix", + "platforms": [ + "windows" + ] + }, + { + "id": "tabs.ariaUnsavedSuffix", + "description": "Windows UI: tabs.ariaUnsavedSuffix", + "platforms": [ + "windows" + ] + }, + { + "id": "tabs.cannotClosePinned", + "description": "Windows UI: tabs.cannotClosePinned", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "tabs.close", + "description": "Windows UI: tabs.close", + "platforms": [ + "windows" + ] + }, + { + "id": "tabs.closeAll", + "description": "Windows UI: tabs.closeAll", + "platforms": [ + "windows" + ] + }, + { + "id": "tabs.closeOthers", + "description": "Windows UI: tabs.closeOthers", + "platforms": [ + "windows" + ] + }, + { + "id": "tabs.closeSaved", + "description": "Windows UI: tabs.closeSaved", + "platforms": [ + "windows" + ] + }, + { + "id": "tabs.closeSplit", + "description": "Windows UI: tabs.closeSplit", + "platforms": [ + "windows" + ] + }, + { + "id": "tabs.closeSplitPane", + "description": "Windows UI: tabs.closeSplitPane", + "platforms": [ + "windows" + ] + }, + { + "id": "tabs.closeToRight", + "description": "Windows UI: tabs.closeToRight", + "platforms": [ + "windows" + ] + }, + { + "id": "tabs.closed", + "description": "Windows UI: tabs.closed", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "tabs.couldNotReopen", + "description": "Windows UI: tabs.couldNotReopen", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "tabs.exitFullScreen", + "description": "Windows UI: tabs.exitFullScreen", + "platforms": [ + "windows" + ] + }, + { + "id": "tabs.fullScreenEditor", + "description": "Windows UI: tabs.fullScreenEditor", + "platforms": [ + "windows" + ] + }, + { + "id": "tabs.goBackShort", + "description": "Windows UI: tabs.goBackShort", + "platforms": [ + "windows" + ] + }, + { + "id": "tabs.lockEditorGroup", + "description": "Windows UI: tabs.lockEditorGroup", + "platforms": [ + "windows" + ] + }, + { + "id": "tabs.newTab", + "description": "Windows UI: tabs.newTab", + "platforms": [ + "windows" + ] + }, + { + "id": "tabs.noRecentlyClosed", + "description": "Windows UI: tabs.noRecentlyClosed", + "platforms": [ + "windows" + ] + }, + { + "id": "tabs.openFiles", + "description": "Windows UI: tabs.openFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "tabs.pin", + "description": "Windows UI: tabs.pin", + "platforms": [ + "windows" + ] + }, + { + "id": "tabs.reload", + "description": "Windows UI: tabs.reload", + "platforms": [ + "windows" + ] + }, + { + "id": "tabs.rename", + "description": "Windows UI: tabs.rename", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "tabs.splitDown", + "description": "Windows UI: tabs.splitDown", + "platforms": [ + "windows" + ] + }, + { + "id": "tabs.splitRight", + "description": "Windows UI: tabs.splitRight", + "platforms": [ + "windows" + ] + }, + { + "id": "tabs.switchedTo", + "description": "Windows UI: tabs.switchedTo", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string", + "unsaved": "string" + } + }, + { + "id": "tabs.toggleEditorFullScreen", + "description": "Windows UI: tabs.toggleEditorFullScreen", + "platforms": [ + "windows" + ] + }, + { + "id": "tabs.unlockEditorGroup", + "description": "Windows UI: tabs.unlockEditorGroup", + "platforms": [ + "windows" + ] + }, + { + "id": "tabs.unpin", + "description": "Windows UI: tabs.unpin", + "platforms": [ + "windows" + ] + }, + { + "id": "tabs.unsavedChanges", + "description": "Windows UI: tabs.unsavedChanges", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.allFiles", + "description": "Windows UI: terminal.allFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.chooseTerminalProfile", + "description": "Windows UI: terminal.chooseTerminalProfile", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.contextClear", + "description": "Windows UI: terminal.contextClear", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.contextClose", + "description": "Windows UI: terminal.contextClose", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.contextCloseAll", + "description": "Windows UI: terminal.contextCloseAll", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.contextCloseOthers", + "description": "Windows UI: terminal.contextCloseOthers", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.contextCloseRight", + "description": "Windows UI: terminal.contextCloseRight", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.contextDuplicate", + "description": "Windows UI: terminal.contextDuplicate", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.contextExport", + "description": "Windows UI: terminal.contextExport", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.contextPin", + "description": "Windows UI: terminal.contextPin", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.contextRename", + "description": "Windows UI: terminal.contextRename", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.contextUnpin", + "description": "Windows UI: terminal.contextUnpin", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.copySelection", + "description": "Windows UI: terminal.copySelection", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.detectShells", + "description": "Windows UI: terminal.detectShells", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.detectShellsFailed", + "description": "Windows UI: terminal.detectShellsFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.detectingShells", + "description": "Windows UI: terminal.detectingShells", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.errorFallback", + "description": "Windows UI: terminal.errorFallback", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.errorTitle", + "description": "Windows UI: terminal.errorTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.exitFullScreen", + "description": "Windows UI: terminal.exitFullScreen", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.findInTerminal", + "description": "Windows UI: terminal.findInTerminal", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.fullScreen", + "description": "Windows UI: terminal.fullScreen", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.fullScreenTerminal", + "description": "Windows UI: terminal.fullScreenTerminal", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.minimizeTerminal", + "description": "Windows UI: terminal.minimizeTerminal", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.newTerminal", + "description": "Windows UI: terminal.newTerminal", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.nextTab", + "description": "Windows UI: terminal.nextTab", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.noTerminals", + "description": "Windows UI: terminal.noTerminals", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.paste", + "description": "Windows UI: terminal.paste", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.pasteCopiedFilePaths", + "description": "Windows UI: terminal.pasteCopiedFilePaths", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.pasteIntoTerminal", + "description": "Windows UI: terminal.pasteIntoTerminal", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.pasteLinesConfirm", + "description": "Windows UI: terminal.pasteLinesConfirm", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "terminal.previousTab", + "description": "Windows UI: terminal.previousTab", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.resizeSidebar", + "description": "Windows UI: terminal.resizeSidebar", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.retry", + "description": "Windows UI: terminal.retry", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.search", + "description": "Windows UI: terminal.search", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.searchMatchCase", + "description": "Windows UI: terminal.searchMatchCase", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.searchMatchWholeWord", + "description": "Windows UI: terminal.searchMatchWholeWord", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.searchPlaceholder", + "description": "Windows UI: terminal.searchPlaceholder", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.searchUseRegex", + "description": "Windows UI: terminal.searchUseRegex", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.shellUnavailable", + "description": "Windows UI: terminal.shellUnavailable", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.tabAriaPinned", + "description": "Windows UI: terminal.tabAriaPinned", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.tabClose", + "description": "Windows UI: terminal.tabClose", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "terminal.tabNamePlaceholder", + "description": "Windows UI: terminal.tabNamePlaceholder", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.tabRenameAria", + "description": "Windows UI: terminal.tabRenameAria", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "terminal.tabUnpin", + "description": "Windows UI: terminal.tabUnpin", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.terminalTabs", + "description": "Windows UI: terminal.terminalTabs", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.terminals", + "description": "Windows UI: terminal.terminals", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.textFiles", + "description": "Windows UI: terminal.textFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.toolbarEditorWidth", + "description": "Windows UI: terminal.toolbarEditorWidth", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.toolbarFullWidth", + "description": "Windows UI: terminal.toolbarFullWidth", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.toolbarHorizontalTabs", + "description": "Windows UI: terminal.toolbarHorizontalTabs", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.toolbarTabLayout", + "description": "Windows UI: terminal.toolbarTabLayout", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.toolbarTabPosition", + "description": "Windows UI: terminal.toolbarTabPosition", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.toolbarTabsOnLeft", + "description": "Windows UI: terminal.toolbarTabsOnLeft", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.toolbarTabsOnRight", + "description": "Windows UI: terminal.toolbarTabsOnRight", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.toolbarTerminalWidth", + "description": "Windows UI: terminal.toolbarTerminalWidth", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.toolbarVerticalTabs", + "description": "Windows UI: terminal.toolbarVerticalTabs", + "platforms": [ + "windows" + ] + }, + { + "id": "titleProject.addProject", + "description": "Windows UI: titleProject.addProject", + "platforms": [ + "windows" + ] + }, + { + "id": "titleProject.cloneRepository", + "description": "Windows UI: titleProject.cloneRepository", + "platforms": [ + "windows" + ] + }, + { + "id": "titleProject.closeAllProjects", + "description": "Windows UI: titleProject.closeAllProjects", + "platforms": [ + "windows" + ] + }, + { + "id": "titleProject.closeProject", + "description": "Windows UI: titleProject.closeProject", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "titleProject.currentProject", + "description": "Windows UI: titleProject.currentProject", + "platforms": [ + "windows" + ], + "parameters": { + "project": "string" + } + }, + { + "id": "titleProject.displayAliasPrompt", + "description": "Windows UI: titleProject.displayAliasPrompt", + "platforms": [ + "windows" + ] + }, + { + "id": "titleProject.newProject", + "description": "Windows UI: titleProject.newProject", + "platforms": [ + "windows" + ] + }, + { + "id": "titleProject.newWindow", + "description": "Windows UI: titleProject.newWindow", + "platforms": [ + "windows" + ] + }, + { + "id": "titleProject.noRecentProjects", + "description": "Windows UI: titleProject.noRecentProjects", + "platforms": [ + "windows" + ] + }, + { + "id": "titleProject.open", + "description": "Windows UI: titleProject.open", + "platforms": [ + "windows" + ] + }, + { + "id": "titleProject.openFolder", + "description": "Windows UI: titleProject.openFolder", + "platforms": [ + "windows" + ] + }, + { + "id": "titleProject.openFolderInNewWindow", + "description": "Windows UI: titleProject.openFolderInNewWindow", + "platforms": [ + "windows" + ] + }, + { + "id": "titleProject.openInNewWindow", + "description": "Windows UI: titleProject.openInNewWindow", + "platforms": [ + "windows" + ] + }, + { + "id": "titleProject.openProjects", + "description": "Windows UI: titleProject.openProjects", + "platforms": [ + "windows" + ] + }, + { + "id": "titleProject.recentProjects", + "description": "Windows UI: titleProject.recentProjects", + "platforms": [ + "windows" + ] + }, + { + "id": "titleProject.removeProject", + "description": "Windows UI: titleProject.removeProject", + "platforms": [ + "windows" + ] + }, + { + "id": "titleProject.selectIcon", + "description": "Windows UI: titleProject.selectIcon", + "platforms": [ + "windows" + ] + }, + { + "id": "titleProject.setDisplayAlias", + "description": "Windows UI: titleProject.setDisplayAlias", + "platforms": [ + "windows" + ] + }, + { + "id": "titleProject.switchToProject", + "description": "Windows UI: titleProject.switchToProject", + "platforms": [ + "windows" + ], + "parameters": { + "project": "string" + } + }, + { + "id": "titleProject.switchToProjectMenu", + "description": "Windows UI: titleProject.switchToProjectMenu", + "platforms": [ + "windows" + ] + }, + { + "id": "titleProject.trigger", + "description": "Windows UI: titleProject.trigger", + "platforms": [ + "windows" + ], + "parameters": { + "project": "string" + } + }, + { + "id": "toolchain.detecting", + "description": "Windows UI: toolchain.detecting", + "platforms": [ + "windows" + ] + }, + { + "id": "toolchain.failed", + "description": "Windows UI: toolchain.failed", + "platforms": [ + "windows" + ], + "parameters": { + "message": "string" + } + }, + { + "id": "toolchain.invalid", + "description": "Windows UI: toolchain.invalid", + "platforms": [ + "windows" + ], + "parameters": { + "message": "string" + } + }, + { + "id": "toolchain.javaNotFound", + "description": "Windows UI: toolchain.javaNotFound", + "platforms": [ + "windows" + ] + }, + { + "id": "toolchain.jdk", + "description": "Windows UI: toolchain.jdk", + "platforms": [ + "windows" + ], + "parameters": { + "version": "string" + } + }, + { + "id": "toolchain.maven", + "description": "Windows UI: toolchain.maven", + "platforms": [ + "windows" + ], + "parameters": { + "version": "string" + } + }, + { + "id": "toolchain.mavenNotFound", + "description": "Windows UI: toolchain.mavenNotFound", + "platforms": [ + "windows" + ] + }, + { + "id": "toolchain.mode.automatic", + "description": "Windows UI: toolchain.mode.automatic", + "platforms": [ + "windows" + ] + }, + { + "id": "toolchain.mode.configured", + "description": "Windows UI: toolchain.mode.configured", + "platforms": [ + "windows" + ] + }, + { + "id": "toolchain.mode.inherit", + "description": "Windows UI: toolchain.mode.inherit", + "platforms": [ + "windows" + ] + }, + { + "id": "toolchain.mode.projectJdk", + "description": "Windows UI: toolchain.mode.projectJdk", + "platforms": [ + "windows" + ] + }, + { + "id": "toolchain.source.detected", + "description": "Windows UI: toolchain.source.detected", + "platforms": [ + "windows" + ] + }, + { + "id": "toolchain.source.javaHome", + "description": "Windows UI: toolchain.source.javaHome", + "platforms": [ + "windows" + ] + }, + { + "id": "toolchain.source.mavenWrapper", + "description": "Windows UI: toolchain.source.mavenWrapper", + "platforms": [ + "windows" + ] + }, + { + "id": "toolchain.source.path", + "description": "Windows UI: toolchain.source.path", + "platforms": [ + "windows" + ] + }, + { + "id": "toolchain.source.project", + "description": "Windows UI: toolchain.source.project", + "platforms": [ + "windows" + ] + }, + { + "id": "ui.archive", + "description": "Windows UI: ui.archive", + "platforms": [ + "windows" + ] + }, + { + "id": "ui.backToProjects", + "description": "Windows UI: ui.backToProjects", + "platforms": [ + "windows" + ] + }, + { + "id": "ui.breadcrumb", + "description": "Windows UI: ui.breadcrumb", + "platforms": [ + "windows" + ] + }, + { + "id": "ui.browse", + "description": "Common action: Browse.", + "platforms": [ + "macos", + "windows" + ] + }, + { + "id": "ui.cancel", + "description": "Common action: Cancel.", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Cancel", + "arguments": [] + } + }, + { + "id": "ui.carousel", + "description": "Windows UI: ui.carousel", + "platforms": [ + "windows" + ] + }, + { + "id": "ui.choose", + "description": "Common action: Choose.", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Choose", + "arguments": [] + } + }, + { + "id": "ui.clear", + "description": "Common action: Clear.", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Clear", + "arguments": [] + } + }, + { + "id": "ui.close", + "description": "Common action: Close.", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Close", + "arguments": [] + } + }, + { + "id": "ui.closeDialog", + "description": "Windows UI: ui.closeDialog", + "platforms": [ + "windows" + ] + }, + { + "id": "ui.closeToast", + "description": "Windows UI: ui.closeToast", + "platforms": [ + "windows" + ] + }, + { + "id": "ui.confirm", + "description": "Common action: Confirm.", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Confirm", + "arguments": [] + } + }, + { + "id": "ui.decreaseValue", + "description": "Windows UI: ui.decreaseValue", + "platforms": [ + "windows" + ] + }, + { + "id": "ui.delete", + "description": "Common action: Delete.", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Delete", + "arguments": [] + } + }, + { + "id": "ui.done", + "description": "Common action: Done.", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Done", + "arguments": [] + } + }, + { + "id": "ui.edit", + "description": "Common action: Edit.", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Edit", + "arguments": [] + } + }, + { + "id": "ui.import", + "description": "Windows UI: ui.import", + "platforms": [ + "windows" + ] + }, + { + "id": "ui.increaseValue", + "description": "Windows UI: ui.increaseValue", + "platforms": [ + "windows" + ] + }, + { + "id": "ui.input", + "description": "Windows UI: ui.input", + "platforms": [ + "windows" + ] + }, + { + "id": "ui.loading", + "description": "Windows UI: ui.loading", + "platforms": [ + "windows" + ] + }, + { + "id": "ui.more", + "description": "Windows UI: ui.more", + "platforms": [ + "windows" + ] + }, + { + "id": "ui.morePages", + "description": "Windows UI: ui.morePages", + "platforms": [ + "windows" + ] + }, + { + "id": "ui.next", + "description": "Common action: Next.", + "platforms": [ + "macos", + "windows" + ] + }, + { + "id": "ui.nextPage", + "description": "Windows UI: ui.nextPage", + "platforms": [ + "windows" + ] + }, + { + "id": "ui.nextSlide", + "description": "Windows UI: ui.nextSlide", + "platforms": [ + "windows" + ] + }, + { + "id": "ui.noActionsHere", + "description": "Windows UI: ui.noActionsHere", + "platforms": [ + "windows" + ] + }, + { + "id": "ui.noMatchingOptions", + "description": "Windows UI: ui.noMatchingOptions", + "platforms": [ + "windows" + ] + }, + { + "id": "ui.notice", + "description": "Windows UI: ui.notice", + "platforms": [ + "windows" + ] + }, + { + "id": "ui.ok", + "description": "Common action: OK.", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "OK", + "arguments": [] + } + }, + { + "id": "ui.open", + "description": "Common action: Open.", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Open", + "arguments": [] + } + }, + { + "id": "ui.pagination", + "description": "Windows UI: ui.pagination", + "platforms": [ + "windows" + ] + }, + { + "id": "ui.pin", + "description": "Windows UI: ui.pin", + "platforms": [ + "windows" + ] + }, + { + "id": "ui.previous", + "description": "Common action: Previous.", + "platforms": [ + "macos", + "windows" + ] + }, + { + "id": "ui.previousPage", + "description": "Windows UI: ui.previousPage", + "platforms": [ + "windows" + ] + }, + { + "id": "ui.previousSlide", + "description": "Windows UI: ui.previousSlide", + "platforms": [ + "windows" + ] + }, + { + "id": "ui.refresh", + "description": "Common action: Refresh.", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Refresh", + "arguments": [] + } + }, + { + "id": "ui.remove", + "description": "Common action: Remove.", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Remove", + "arguments": [] + } + }, + { + "id": "ui.rename", + "description": "Common action: Rename.", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Rename", + "arguments": [] + } + }, + { + "id": "ui.retry", + "description": "Common action: Retry.", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Retry", + "arguments": [] + } + }, + { + "id": "ui.save", + "description": "Common action: Save.", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "Save", + "arguments": [] + } + }, + { + "id": "ui.saving", + "description": "Windows UI: ui.saving", + "platforms": [ + "windows" + ] + }, + { + "id": "ui.searchPlaceholder", + "description": "Windows UI: ui.searchPlaceholder", + "platforms": [ + "windows" + ] + }, + { + "id": "ui.selectPlaceholder", + "description": "Windows UI: ui.selectPlaceholder", + "platforms": [ + "windows" + ] + }, + { + "id": "ui.slide", + "description": "Windows UI: ui.slide", + "platforms": [ + "windows" + ] + }, + { + "id": "ui.symlink", + "description": "Windows UI: ui.symlink", + "platforms": [ + "windows" + ] + }, + { + "id": "ui.unpin", + "description": "Windows UI: ui.unpin", + "platforms": [ + "windows" + ] + }, + { + "id": "ui.useCustomValue", + "description": "Windows UI: ui.useCustomValue", + "platforms": [ + "windows" + ], + "parameters": { + "value": "string" + } + }, + { + "id": "unsavedChanges.doNotSave", + "description": "Windows UI: unsavedChanges.doNotSave", + "platforms": [ + "windows" + ] + }, + { + "id": "unsavedChanges.messagePrefix", + "description": "Windows UI: unsavedChanges.messagePrefix", + "platforms": [ + "windows" + ] + }, + { + "id": "unsavedChanges.messageSuffix", + "description": "Windows UI: unsavedChanges.messageSuffix", + "platforms": [ + "windows" + ] + }, + { + "id": "unsavedChanges.title", + "description": "Windows UI: unsavedChanges.title", + "platforms": [ + "windows" + ] + }, + { + "id": "unsavedProjectTransition.action.closingThisProject", + "description": "Windows UI: unsavedProjectTransition.action.closing this project", + "platforms": [ + "windows" + ], + "windows": { + "key": "unsavedProjectTransition.action.closing this project" + } + }, + { + "id": "unsavedProjectTransition.action.restartingToUpdate", + "description": "Windows UI: unsavedProjectTransition.action.restarting to update", + "platforms": [ + "windows" + ], + "windows": { + "key": "unsavedProjectTransition.action.restarting to update" + } + }, + { + "id": "unsavedProjectTransition.action.switchingProjects", + "description": "Windows UI: unsavedProjectTransition.action.switching projects", + "platforms": [ + "windows" + ], + "windows": { + "key": "unsavedProjectTransition.action.switching projects" + } + }, + { + "id": "unsavedProjectTransition.discardAll", + "description": "Windows UI: unsavedProjectTransition.discardAll", + "platforms": [ + "windows" + ] + }, + { + "id": "unsavedProjectTransition.messageMany", + "description": "Windows UI: unsavedProjectTransition.messageMany", + "platforms": [ + "windows" + ], + "parameters": { + "action": "string", + "count": "string" + } + }, + { + "id": "unsavedProjectTransition.messageOne", + "description": "Windows UI: unsavedProjectTransition.messageOne", + "platforms": [ + "windows" + ], + "parameters": { + "action": "string", + "file": "string" + } + }, + { + "id": "unsavedProjectTransition.saveAll", + "description": "Windows UI: unsavedProjectTransition.saveAll", + "platforms": [ + "windows" + ] + }, + { + "id": "unsavedProjectTransition.saveBeforeContinuing", + "description": "Windows UI: unsavedProjectTransition.saveBeforeContinuing", + "platforms": [ + "windows" + ], + "parameters": { + "file": "string" + } + }, + { + "id": "unsavedProjectTransition.saveOrCloseBeforeContinuing", + "description": "Windows UI: unsavedProjectTransition.saveOrCloseBeforeContinuing", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "update.acknowledgeDownload", + "description": "Windows UI: update.acknowledgeDownload", + "platforms": [ + "windows" + ] + }, + { + "id": "update.autobuildBuiltAt", + "description": "Windows UI: update.autobuildBuiltAt", + "platforms": [ + "windows" + ], + "parameters": { + "timestamp": "string" + } + }, + { + "id": "update.autobuildWarning", + "description": "Windows UI: update.autobuildWarning", + "platforms": [ + "windows" + ] + }, + { + "id": "update.available", + "description": "Windows UI: update.available", + "platforms": [ + "windows" + ] + }, + { + "id": "update.availableVersion", + "description": "Windows UI: update.availableVersion", + "platforms": [ + "windows" + ], + "parameters": { + "version": "string" + } + }, + { + "id": "update.channel", + "description": "Windows UI: update.channel", + "platforms": [ + "windows" + ] + }, + { + "id": "update.channelSaveFailed", + "description": "Windows UI: update.channelSaveFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "update.channelStable", + "description": "Windows UI: update.channelStable", + "platforms": [ + "windows" + ] + }, + { + "id": "update.confirmAutobuild", + "description": "Windows UI: update.confirmAutobuild", + "platforms": [ + "windows" + ] + }, + { + "id": "update.currentVersion", + "description": "Windows UI: update.currentVersion", + "platforms": [ + "windows" + ] + }, + { + "id": "update.detailsTitle", + "description": "Windows UI: update.detailsTitle", + "platforms": [ + "windows" + ], + "parameters": { + "version": "string" + } + }, + { + "id": "update.downloadLater", + "description": "Windows UI: update.downloadLater", + "platforms": [ + "windows" + ] + }, + { + "id": "update.downloading", + "description": "Windows UI: update.downloading", + "platforms": [ + "windows" + ] + }, + { + "id": "update.failed", + "description": "Windows UI: update.failed", + "platforms": [ + "windows" + ] + }, + { + "id": "update.installing", + "description": "Windows UI: update.installing", + "platforms": [ + "windows" + ] + }, + { + "id": "update.installingTooltip", + "description": "Windows UI: update.installingTooltip", + "platforms": [ + "windows" + ] + }, + { + "id": "update.manualDownloadExplanation", + "description": "Windows UI: update.manualDownloadExplanation", + "platforms": [ + "windows" + ] + }, + { + "id": "update.manualDownloadStatus", + "description": "Windows UI: update.manualDownloadStatus", + "platforms": [ + "windows" + ] + }, + { + "id": "update.manualDownloadTitle", + "description": "Windows UI: update.manualDownloadTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "update.openReleasePage", + "description": "Windows UI: update.openReleasePage", + "platforms": [ + "windows" + ] + }, + { + "id": "update.options", + "description": "Windows UI: update.options", + "platforms": [ + "windows" + ] + }, + { + "id": "update.releaseDate", + "description": "Windows UI: update.releaseDate", + "platforms": [ + "windows" + ] + }, + { + "id": "update.releaseNotes", + "description": "Windows UI: update.releaseNotes", + "platforms": [ + "windows" + ] + }, + { + "id": "update.remindTomorrow", + "description": "Windows UI: update.remindTomorrow", + "platforms": [ + "windows" + ] + }, + { + "id": "update.skipVersion", + "description": "Windows UI: update.skipVersion", + "platforms": [ + "windows" + ], + "parameters": { + "version": "string" + } + }, + { + "id": "update.targetVersion", + "description": "Windows UI: update.targetVersion", + "platforms": [ + "windows" + ] + }, + { + "id": "update.updatingProgress", + "description": "Windows UI: update.updatingProgress", + "platforms": [ + "windows" + ], + "parameters": { + "percentage": "string" + } + }, + { + "id": "update.useAutobuild", + "description": "Windows UI: update.useAutobuild", + "platforms": [ + "windows" + ] + }, + { + "id": "update.version", + "description": "Windows UI: update.version", + "platforms": [ + "windows" + ] + }, + { + "id": "update.viewDownloads", + "description": "Windows UI: update.viewDownloads", + "platforms": [ + "windows" + ] + }, + { + "id": "update.viewReleaseNotes", + "description": "Windows UI: update.viewReleaseNotes", + "platforms": [ + "windows" + ] + }, + { + "id": "v0DesignSystem.active", + "description": "Windows UI: v0DesignSystem.active", + "platforms": [ + "windows" + ] + }, + { + "id": "v0DesignSystem.add", + "description": "Windows UI: v0DesignSystem.add", + "platforms": [ + "windows" + ] + }, + { + "id": "v0DesignSystem.addRegistry", + "description": "Windows UI: v0DesignSystem.addRegistry", + "platforms": [ + "windows" + ] + }, + { + "id": "v0DesignSystem.addTitle", + "description": "Windows UI: v0DesignSystem.addTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "v0DesignSystem.adding", + "description": "Windows UI: v0DesignSystem.adding", + "platforms": [ + "windows" + ] + }, + { + "id": "v0DesignSystem.backToCommands", + "description": "Windows UI: v0DesignSystem.backToCommands", + "platforms": [ + "windows" + ] + }, + { + "id": "v0DesignSystem.backToDesignSystems", + "description": "Windows UI: v0DesignSystem.backToDesignSystems", + "platforms": [ + "windows" + ] + }, + { + "id": "v0DesignSystem.fallbackName", + "description": "Windows UI: v0DesignSystem.fallbackName", + "platforms": [ + "windows" + ] + }, + { + "id": "v0DesignSystem.loadPublicFailed", + "description": "Windows UI: v0DesignSystem.loadPublicFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "v0DesignSystem.namePlaceholder", + "description": "Windows UI: v0DesignSystem.namePlaceholder", + "platforms": [ + "windows" + ] + }, + { + "id": "v0DesignSystem.noFound", + "description": "Windows UI: v0DesignSystem.noFound", + "platforms": [ + "windows" + ] + }, + { + "id": "v0DesignSystem.noneDescription", + "description": "Windows UI: v0DesignSystem.noneDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "v0DesignSystem.noneName", + "description": "Windows UI: v0DesignSystem.noneName", + "platforms": [ + "windows" + ] + }, + { + "id": "v0DesignSystem.notesPlaceholder", + "description": "Windows UI: v0DesignSystem.notesPlaceholder", + "platforms": [ + "windows" + ] + }, + { + "id": "v0DesignSystem.publicCount", + "description": "Windows UI: v0DesignSystem.publicCount", + "platforms": [ + "windows" + ], + "parameters": { + "count": "string" + } + }, + { + "id": "v0DesignSystem.refreshPublicRegistries", + "description": "Windows UI: v0DesignSystem.refreshPublicRegistries", + "platforms": [ + "windows" + ] + }, + { + "id": "v0DesignSystem.registryDirectoryStatus", + "description": "Windows UI: v0DesignSystem.registryDirectoryStatus", + "platforms": [ + "windows" + ], + "parameters": { + "status": "string" + } + }, + { + "id": "v0DesignSystem.registryRequired", + "description": "Windows UI: v0DesignSystem.registryRequired", + "platforms": [ + "windows" + ] + }, + { + "id": "v0DesignSystem.removeSelected", + "description": "Windows UI: v0DesignSystem.removeSelected", + "platforms": [ + "windows" + ] + }, + { + "id": "v0DesignSystem.saveAndUse", + "description": "Windows UI: v0DesignSystem.saveAndUse", + "platforms": [ + "windows" + ] + }, + { + "id": "v0DesignSystem.saved", + "description": "Windows UI: v0DesignSystem.saved", + "platforms": [ + "windows" + ] + }, + { + "id": "v0DesignSystem.searchPlaceholder", + "description": "Windows UI: v0DesignSystem.searchPlaceholder", + "platforms": [ + "windows" + ] + }, + { + "id": "viewer.clearBrowsingData", + "description": "Windows UI: viewer.clearBrowsingData", + "platforms": [ + "windows" + ] + }, + { + "id": "viewer.closeImageViewer", + "description": "Windows UI: viewer.closeImageViewer", + "platforms": [ + "windows" + ] + }, + { + "id": "viewer.copyUrl", + "description": "Windows UI: viewer.copyUrl", + "platforms": [ + "windows" + ] + }, + { + "id": "viewer.createWebviewFailed", + "description": "Windows UI: viewer.createWebviewFailed", + "platforms": [ + "windows" + ] + }, + { + "id": "viewer.enterUrl", + "description": "Windows UI: viewer.enterUrl", + "platforms": [ + "windows" + ] + }, + { + "id": "viewer.enterUrlPrompt", + "description": "Windows UI: viewer.enterUrlPrompt", + "platforms": [ + "windows" + ] + }, + { + "id": "viewer.enterValidUrl", + "description": "Windows UI: viewer.enterValidUrl", + "platforms": [ + "windows" + ] + }, + { + "id": "viewer.extension", + "description": "Windows UI: viewer.extension", + "platforms": [ + "windows" + ] + }, + { + "id": "viewer.failedToLoadFile", + "description": "Windows UI: viewer.failedToLoadFile", + "platforms": [ + "windows" + ] + }, + { + "id": "viewer.failedToLoadPdfDocument", + "description": "Windows UI: viewer.failedToLoadPdfDocument", + "platforms": [ + "windows" + ] + }, + { + "id": "viewer.failedToReadFile", + "description": "Windows UI: viewer.failedToReadFile", + "platforms": [ + "windows" + ], + "parameters": { + "error": "string" + } + }, + { + "id": "viewer.fileInformation", + "description": "Windows UI: viewer.fileInformation", + "platforms": [ + "windows" + ] + }, + { + "id": "viewer.hexPreview", + "description": "Windows UI: viewer.hexPreview", + "platforms": [ + "windows" + ] + }, + { + "id": "viewer.loadingBinaryFile", + "description": "Windows UI: viewer.loadingBinaryFile", + "platforms": [ + "windows" + ] + }, + { + "id": "viewer.loadingImage", + "description": "Windows UI: viewer.loadingImage", + "platforms": [ + "windows" + ] + }, + { + "id": "viewer.loadingPage", + "description": "Windows UI: viewer.loadingPage", + "platforms": [ + "windows" + ] + }, + { + "id": "viewer.loadingPdf", + "description": "Windows UI: viewer.loadingPdf", + "platforms": [ + "windows" + ] + }, + { + "id": "viewer.loadingPdfPage", + "description": "Windows UI: viewer.loadingPdfPage", + "platforms": [ + "windows" + ] + }, + { + "id": "viewer.localBrowserPage", + "description": "Windows UI: viewer.localBrowserPage", + "platforms": [ + "windows" + ] + }, + { + "id": "viewer.localOrPrivateConnection", + "description": "Windows UI: viewer.localOrPrivateConnection", + "platforms": [ + "windows" + ] + }, + { + "id": "viewer.notSecureHttp", + "description": "Windows UI: viewer.notSecureHttp", + "platforms": [ + "windows" + ] + }, + { + "id": "viewer.openExternalLinkConfirm", + "description": "Windows UI: viewer.openExternalLinkConfirm", + "platforms": [ + "windows" + ], + "parameters": { + "url": "string" + } + }, + { + "id": "viewer.openExternalLinkTitle", + "description": "Windows UI: viewer.openExternalLinkTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "viewer.openExternalViewer", + "description": "Windows UI: viewer.openExternalViewer", + "platforms": [ + "windows" + ] + }, + { + "id": "viewer.openInBrowser", + "description": "Windows UI: viewer.openInBrowser", + "platforms": [ + "windows" + ] + }, + { + "id": "viewer.openPage", + "description": "Windows UI: viewer.openPage", + "platforms": [ + "windows" + ] + }, + { + "id": "viewer.openPageDescription", + "description": "Windows UI: viewer.openPageDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "viewer.openUrl", + "description": "Windows UI: viewer.openUrl", + "platforms": [ + "windows" + ] + }, + { + "id": "viewer.page", + "description": "Windows UI: viewer.page", + "platforms": [ + "windows" + ] + }, + { + "id": "viewer.path", + "description": "Windows UI: viewer.path", + "platforms": [ + "windows" + ] + }, + { + "id": "viewer.readingFile", + "description": "Windows UI: viewer.readingFile", + "platforms": [ + "windows" + ] + }, + { + "id": "viewer.resetZoom", + "description": "Windows UI: viewer.resetZoom", + "platforms": [ + "windows" + ] + }, + { + "id": "viewer.section", + "description": "Windows UI: viewer.section", + "platforms": [ + "windows" + ] + }, + { + "id": "viewer.sections", + "description": "Windows UI: viewer.sections", + "platforms": [ + "windows" + ] + }, + { + "id": "viewer.secureConnectionHttps", + "description": "Windows UI: viewer.secureConnectionHttps", + "platforms": [ + "windows" + ] + }, + { + "id": "viewer.size", + "description": "Windows UI: viewer.size", + "platforms": [ + "windows" + ] + }, + { + "id": "viewer.stopLoading", + "description": "Windows UI: viewer.stopLoading", + "platforms": [ + "windows" + ] + }, + { + "id": "viewer.type", + "description": "Windows UI: viewer.type", + "platforms": [ + "windows" + ] + }, + { + "id": "viewer.wasmVersion", + "description": "Windows UI: viewer.wasmVersion", + "platforms": [ + "windows" + ] + }, + { + "id": "viewer.webAssemblyModule", + "description": "Windows UI: viewer.webAssemblyModule", + "platforms": [ + "windows" + ] + }, + { + "id": "viewer.zoom", + "description": "Windows UI: viewer.zoom", + "platforms": [ + "windows" + ] + }, + { + "id": "viewer.zoomControls", + "description": "Windows UI: viewer.zoomControls", + "platforms": [ + "windows" + ] + }, + { + "id": "viewer.zoomIn", + "description": "Windows UI: viewer.zoomIn", + "platforms": [ + "windows" + ] + }, + { + "id": "viewer.zoomOut", + "description": "Windows UI: viewer.zoomOut", + "platforms": [ + "windows" + ] + }, + { + "id": "welcome.addRemote", + "description": "Windows UI: welcome.addRemote", + "platforms": [ + "windows" + ] + }, + { + "id": "welcome.backToProjects", + "description": "Windows UI: welcome.backToProjects", + "platforms": [ + "windows" + ] + }, + { + "id": "welcome.builtIn", + "description": "Windows UI: welcome.builtIn", + "platforms": [ + "windows" + ] + }, + { + "id": "welcome.checkUpdates", + "description": "Windows UI: welcome.checkUpdates", + "platforms": [ + "windows" + ] + }, + { + "id": "welcome.chooseHowToStart", + "description": "Windows UI: welcome.chooseHowToStart", + "platforms": [ + "windows" + ] + }, + { + "id": "welcome.clone", + "description": "Windows UI: welcome.clone", + "platforms": [ + "windows" + ] + }, + { + "id": "welcome.cloneRepository", + "description": "Windows UI: welcome.cloneRepository", + "platforms": [ + "windows" + ] + }, + { + "id": "welcome.cloneRepositoryDescription", + "description": "Windows UI: welcome.cloneRepositoryDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "welcome.emptyProject", + "description": "Windows UI: welcome.emptyProject", + "platforms": [ + "windows" + ] + }, + { + "id": "welcome.emptyProjectDescription", + "description": "Windows UI: welcome.emptyProjectDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "welcome.gitBadge", + "description": "Windows UI: welcome.gitBadge", + "platforms": [ + "windows" + ] + }, + { + "id": "welcome.newProject", + "description": "Windows UI: welcome.newProject", + "platforms": [ + "windows" + ] + }, + { + "id": "welcome.newRemoteConnection", + "description": "Windows UI: welcome.newRemoteConnection", + "platforms": [ + "windows" + ] + }, + { + "id": "welcome.nextjsDescription", + "description": "Windows UI: welcome.nextjsDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "welcome.noRecentProjects", + "description": "Windows UI: welcome.noRecentProjects", + "platforms": [ + "windows" + ] + }, + { + "id": "welcome.noStarters", + "description": "Windows UI: welcome.noStarters", + "platforms": [ + "windows" + ], + "parameters": { + "query": "string" + } + }, + { + "id": "welcome.open", + "description": "Windows UI: welcome.open", + "platforms": [ + "windows" + ] + }, + { + "id": "welcome.openFolder", + "description": "Windows UI: welcome.openFolder", + "platforms": [ + "windows" + ] + }, + { + "id": "welcome.openFolderHint", + "description": "Windows UI: welcome.openFolderHint", + "platforms": [ + "windows" + ] + }, + { + "id": "welcome.openProject", + "description": "Windows UI: welcome.openProject", + "platforms": [ + "windows" + ] + }, + { + "id": "welcome.openProjectOrRemote", + "description": "Windows UI: welcome.openProjectOrRemote", + "platforms": [ + "windows" + ] + }, + { + "id": "welcome.projects", + "description": "Windows UI: welcome.projects", + "platforms": [ + "windows" + ] + }, + { + "id": "welcome.recentProjects", + "description": "Windows UI: welcome.recentProjects", + "platforms": [ + "windows" + ] + }, + { + "id": "welcome.removeRecent", + "description": "Windows UI: welcome.removeRecent", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "welcome.searchProjects", + "description": "Windows UI: welcome.searchProjects", + "platforms": [ + "windows" + ] + }, + { + "id": "welcome.title", + "description": "Windows UI: welcome.title", + "platforms": [ + "windows" + ] + }, + { + "id": "welcome.viteReactDescription", + "description": "Windows UI: welcome.viteReactDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "welcome.webApp", + "description": "Windows UI: welcome.webApp", + "platforms": [ + "windows" + ] + }, + { + "id": "window.close", + "description": "Windows UI: window.close", + "platforms": [ + "windows" + ] + }, + { + "id": "window.maximize", + "description": "Windows UI: window.maximize", + "platforms": [ + "windows" + ] + }, + { + "id": "window.menu", + "description": "Windows UI: window.menu", + "platforms": [ + "windows" + ] + }, + { + "id": "window.minimize", + "description": "Windows UI: window.minimize", + "platforms": [ + "windows" + ] + }, + { + "id": "window.restore", + "description": "Windows UI: window.restore", + "platforms": [ + "windows" + ] + }, + { + "id": "windowOpen.cannotOpenPath", + "description": "Windows UI: windowOpen.cannotOpenPath", + "platforms": [ + "windows" + ], + "parameters": { + "path": "string" + } + }, + { + "id": "windowOpen.pathIsNotFolder", + "description": "Windows UI: windowOpen.pathIsNotFolder", + "platforms": [ + "windows" + ], + "parameters": { + "path": "string" + } + }, + { + "id": "windowOpen.runCommand", + "description": "Windows UI: windowOpen.runCommand", + "platforms": [ + "windows" + ] + }, + { + "id": "windowOpen.runTerminalCommandPrompt", + "description": "Windows UI: windowOpen.runTerminalCommandPrompt", + "platforms": [ + "windows" + ] + }, + { + "id": "windowOpen.runTerminalCommandTitle", + "description": "Windows UI: windowOpen.runTerminalCommandTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "windowOpen.workingDirectory", + "description": "Windows UI: windowOpen.workingDirectory", + "platforms": [ + "windows" + ], + "parameters": { + "path": "string" + } + }, + { + "id": "workbench.activityViews", + "description": "Windows UI: workbench.activityViews", + "platforms": [ + "windows" + ] + }, + { + "id": "workbench.changes", + "description": "Windows UI: workbench.changes", + "platforms": [ + "windows" + ] + }, + { + "id": "workbench.commit", + "description": "Windows UI: workbench.commit", + "platforms": [ + "windows" + ] + }, + { + "id": "workbench.currentFile", + "description": "Windows UI: workbench.currentFile", + "platforms": [ + "windows" + ] + }, + { + "id": "workbench.database", + "description": "Windows UI: workbench.database", + "platforms": [ + "windows" + ] + }, + { + "id": "workbench.diagnostics", + "description": "Windows UI: workbench.diagnostics", + "platforms": [ + "windows" + ] + }, + { + "id": "workbench.emptyEditorDescription", + "description": "Windows UI: workbench.emptyEditorDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "workbench.emptyEditorTitle", + "description": "Windows UI: workbench.emptyEditorTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "workbench.gitLog", + "description": "Windows UI: workbench.gitLog", + "platforms": [ + "windows" + ] + }, + { + "id": "workbench.maven", + "description": "Windows UI: workbench.maven", + "platforms": [ + "windows" + ] + }, + { + "id": "workbench.moreProjectActions", + "description": "Windows UI: workbench.moreProjectActions", + "platforms": [ + "windows" + ] + }, + { + "id": "workbench.openProject", + "description": "Windows UI: workbench.openProject", + "platforms": [ + "windows" + ] + }, + { + "id": "workbench.project", + "description": "Windows UI: workbench.project", + "platforms": [ + "windows" + ] + }, + { + "id": "workbench.renderErrorDescription", + "description": "Windows UI: workbench.renderErrorDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "workbench.renderErrorTitle", + "description": "Windows UI: workbench.renderErrorTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "workbench.run", + "description": "Windows UI: workbench.run", + "platforms": [ + "windows" + ] + }, + { + "id": "workbench.search", + "description": "Windows UI: workbench.search", + "platforms": [ + "windows" + ] + }, + { + "id": "workbench.searchInFiles", + "description": "Windows UI: workbench.searchInFiles", + "platforms": [ + "windows" + ] + }, + { + "id": "workbench.settings", + "description": "Windows UI: workbench.settings", + "platforms": [ + "windows" + ] + }, + { + "id": "workbench.springEndpoints", + "description": "Windows UI: workbench.springEndpoints", + "platforms": [ + "windows" + ] + }, + { + "id": "workbench.terminal", + "description": "Windows UI: workbench.terminal", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.languageEnglish", + "description": "Self-name of an AI commit output language.", + "platforms": [ + "windows" + ] + }, + { + "id": "aiCommit.languageChinese", + "description": "Self-name of an AI commit output language.", + "platforms": [ + "windows" + ] + }, + { + "id": "native.aSecureConnectionToTheMcpRegistryCouldNotBeEstablishedCheckTheProxyOrRetryDirectly", + "description": "Native UI: A secure connection to the MCP Registry could not be established. Check the proxy or retry directly.", + "platforms": [ + "macos" + ], + "macos": { + "key": "A secure connection to the MCP Registry could not be established. Check the proxy or retry directly.", + "arguments": [] + } + }, + { + "id": "native.aSkillHasAnUnknownScopeAndCannotBeClassified", + "description": "Native UI: A skill has an unknown scope and cannot be classified.", + "platforms": [ + "macos" + ], + "macos": { + "key": "A skill has an unknown scope and cannot be classified.", + "arguments": [] + } + }, + { + "id": "native.aSkillWasOmittedBecauseItsNativeMetadataIsIncomplete", + "description": "Native UI: A skill was omitted because its native metadata is incomplete.", + "platforms": [ + "macos" + ], + "macos": { + "key": "A skill was omitted because its native metadata is incomplete.", + "arguments": [] + } + }, + { + "id": "native.aSkillWithThisDirectoryNameAlreadyExistsExistingSkillsAreNeverOverwritten", + "description": "Native UI: A skill with this directory name already exists. Existing skills are never overwritten.", + "platforms": [ + "macos" + ], + "macos": { + "key": "A skill with this directory name already exists. Existing skills are never overwritten.", + "arguments": [] + } + }, + { + "id": "native.addAtLeastOneAbsoluteAllowedFolderPathAfterTheFilesystemPackageArgument", + "description": "Native UI: Add at least one absolute allowed folder path after the Filesystem package argument.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Add at least one absolute allowed folder path after the Filesystem package argument.", + "arguments": [] + } + }, + { + "id": "native.addAtLeastOneAbsoluteFolderPathAfterThePackageArgumentOnePerLineFilesystemCanReadAndWriteOnlyInTheFoldersYouAllowRequiresNodeJs24OrLater", + "description": "Native UI: Add at least one absolute folder path after the package argument, one per line. Filesystem can read and write only in the folders you allow. Requires Node.js 24 or later.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Add at least one absolute folder path after the package argument, one per line. Filesystem can read and write only in the folders you allow. Requires Node.js 24 or later.", + "arguments": [] + } + }, + { + "id": "native.agentSkills", + "description": "Native UI: Agent skills", + "platforms": [ + "macos" + ], + "macos": { + "key": "Agent skills", + "arguments": [] + } + }, + { + "id": "native.agents2", + "description": "Native UI: Instruction Agents", + "platforms": [ + "macos" + ], + "macos": { + "key": "Instruction Agents", + "arguments": [] + } + }, + { + "id": "native.allSources", + "description": "Native UI: All sources", + "platforms": [ + "macos" + ], + "macos": { + "key": "All sources", + "arguments": [] + } + }, + { + "id": "native.anotherInstructionSaveIsRunningTryAgain", + "description": "Native UI: Another instruction save is running. Try again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Another instruction save is running. Try again.", + "arguments": [] + } + }, + { + "id": "native.arg1LinesOfItsOwn", + "description": "Native UI: %d lines of its own", + "platforms": [ + "macos" + ], + "macos": { + "key": "%d lines of its own", + "arguments": [ + "arg1" + ], + "formats": { + "arg1": "d" + } + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.arg1Tools", + "description": "Native UI: %d tools", + "platforms": [ + "macos" + ], + "macos": { + "key": "%d tools", + "arguments": [ + "arg1" + ], + "formats": { + "arg1": "d" + } + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.askQuestionsAboutPublicGithubRepositoriesAndReadTheirGeneratedDocumentation", + "description": "Native UI: Ask questions about public GitHub repositories and read their generated documentation.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Ask questions about public GitHub repositories and read their generated documentation.", + "arguments": [] + } + }, + { + "id": "native.cannotAccessTheGlobalInstructionFileCheckItsPermissionsAndTryAgain", + "description": "Native UI: Cannot access the global instruction file. Check its permissions and try again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Cannot access the global instruction file. Check its permissions and try again.", + "arguments": [] + } + }, + { + "id": "native.cannotLocateTheAgentUserDirectory", + "description": "Native UI: Cannot locate the Agent user directory.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Cannot locate the Agent user directory.", + "arguments": [] + } + }, + { + "id": "native.checkCommandLineQuotesBeforeSaving", + "description": "Native UI: Check command-line quotes before saving.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Check command-line quotes before saving.", + "arguments": [] + } + }, + { + "id": "native.checkTheMcpCommandUrlAndHeaders", + "description": "Native UI: Check the MCP command, URL and headers.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Check the MCP command, URL and headers.", + "arguments": [] + } + }, + { + "id": "native.chooseWhetherToEnableTheSkill", + "description": "Native UI: Choose whether to enable the skill.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose whether to enable the skill.", + "arguments": [] + } + }, + { + "id": "native.claudeCodeHasMcpToolRestrictionsUpdateItsNativeRulesBeforeRenamingAServerSoDisabledToolsDoNotBecomeAvailableUnderANewName", + "description": "Native UI: Claude Code has MCP tool restrictions. Update its native rules before renaming a server so disabled tools do not become available under a new name.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Claude Code has MCP tool restrictions. Update its native rules before renaming a server so disabled tools do not become available under a new name.", + "arguments": [] + } + }, + { + "id": "native.claudePluginSkillsMustBeManagedThroughClaudeSPluginSettings", + "description": "Native UI: Claude plugin skills must be managed through Claude's plugin settings.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Claude plugin skills must be managed through Claude's plugin settings.", + "arguments": [] + } + }, + { + "id": "native.claudeSavesVisibilityByNameGlobalSkillsUseUserSettingsProjectSkillsUseProjectLocalSettingsProjectAndManagedSettingsMayOverrideGlobalSettingsSkillsWithTheSameNameShareThisSetting", + "description": "Native UI: Claude saves visibility by name: global skills use user settings; project skills use project-local settings. Project and managed settings may override global settings. Skills with the same name share this setting.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Claude saves visibility by name: global skills use user settings; project skills use project-local settings. Project and managed settings may override global settings. Skills with the same name share this setting.", + "arguments": [] + } + }, + { + "id": "native.claudeSkillSettingsCouldNotBeReadFixInvalidSettingsAndRefreshBeforeToggling", + "description": "Native UI: Claude skill settings could not be read. Fix invalid settings and refresh before toggling.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Claude skill settings could not be read. Fix invalid settings and refresh before toggling.", + "arguments": [] + } + }, + { + "id": "native.codexIsUsingAgentsOverrideMdClearingItRestoresTheBaseAgentsMdInstructions", + "description": "Native UI: Codex is using AGENTS.override.md. Clearing it restores the base AGENTS.md instructions.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Codex is using AGENTS.override.md. Clearing it restores the base AGENTS.md instructions.", + "arguments": [] + } + }, + { + "id": "native.codexReportedSkillDiscoveryErrorsCheckTheAffectedSkillMdFilesAndRefresh", + "description": "Native UI: Codex reported skill discovery errors. Check the affected SKILL.md files and refresh.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Codex reported skill discovery errors. Check the affected SKILL.md files and refresh.", + "arguments": [] + } + }, + { + "id": "native.codexReportsItsDiscoveredSkillsClaudeListsFilesInTheSelectedProjectAndUserDirectoriesInheritedAndPluginSkillsMayAlsoBeAvailableInItsSessions", + "description": "Native UI: Codex reports its discovered skills. Claude lists files in the selected project and user directories; inherited and plugin skills may also be available in its sessions.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Codex reports its discovered skills. Claude lists files in the selected project and user directories; inherited and plugin skills may also be available in its sessions.", + "arguments": [] + } + }, + { + "id": "native.codexSavesThisSettingByPathForAllProjectsUsingThisSkill", + "description": "Native UI: Codex saves this setting by path for all projects using this skill.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Codex saves this setting by path for all projects using this skill.", + "arguments": [] + } + }, + { + "id": "native.codexSkillDiscoveryFailedCheckTheCliAndRetry", + "description": "Native UI: Codex skill discovery failed. Check the CLI and retry.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Codex skill discovery failed. Check the CLI and retry.", + "arguments": [] + } + }, + { + "id": "native.codexSkillSettingsCouldNotBeConfirmedRefreshToCheckTheCurrentState", + "description": "Native UI: Codex skill settings could not be confirmed. Refresh to check the current state.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Codex skill settings could not be confirmed. Refresh to check the current state.", + "arguments": [] + } + }, + { + "id": "native.configure2", + "description": "Native UI: Configure…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Configure…", + "arguments": [] + } + }, + { + "id": "native.copyPath2", + "description": "Native UI: Copy path", + "platforms": [ + "macos" + ], + "macos": { + "key": "Copy path", + "arguments": [] + } + }, + { + "id": "native.couldNotImportMcpJsonCheckTheMcpserversObjectAndTryAgain", + "description": "Native UI: Could not import MCP JSON. Check the mcpServers object and try again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Could not import MCP JSON. Check the mcpServers object and try again.", + "arguments": [] + } + }, + { + "id": "native.couldNotReadTheCommandLine", + "description": "Native UI: Could not read the command line.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Could not read the command line.", + "arguments": [] + } + }, + { + "id": "native.couldNotStartTheMcpCommandCheckItsExecutableAndRuntime", + "description": "Native UI: Could not start the MCP command. Check its executable and runtime.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Could not start the MCP command. Check its executable and runtime.", + "arguments": [] + } + }, + { + "id": "native.createSkill", + "description": "Native UI: Create skill", + "platforms": [ + "macos" + ], + "macos": { + "key": "Create skill", + "arguments": [] + } + }, + { + "id": "native.currentLibraryDocumentationAndCodeExamplesForCodingAgents", + "description": "Native UI: Current library documentation and code examples for coding agents.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Current library documentation and code examples for coding agents.", + "arguments": [] + } + }, + { + "id": "native.defaultInstructions", + "description": "Native UI: Default instructions", + "platforms": [ + "macos" + ], + "macos": { + "key": "Default instructions", + "arguments": [] + } + }, + { + "id": "native.detectAgain", + "description": "Native UI: Detect again", + "platforms": [ + "macos" + ], + "macos": { + "key": "Detect again", + "arguments": [] + } + }, + { + "id": "native.detectTools", + "description": "Native UI: Detect tools", + "platforms": [ + "macos" + ], + "macos": { + "key": "Detect tools", + "arguments": [] + } + }, + { + "id": "native.detectionConnectsToThisServerAndListsToolMetadataWithoutCallingAnyToolLocalCommandsMayRunCodeOrDownloadPackagesResultsDescribeThisCheckNotTheAvailabilityOfAnExistingAgentSessionToolSwitchesApplyToTheSelectedAgentSUserConfigurationProjectAndManagedPoliciesCanStillRestrictAccessStartANewAgentSessionAfterChanges", + "description": "Native UI: Detection connects to this server and lists tool metadata without calling any tool. Local commands may run code or download packages. Results describe this check, not the availability of an existing Agent session. Tool switches apply to the selected Agent's user configuration; project and managed policies can still restrict access. Start a new Agent session after changes.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Detection connects to this server and lists tool metadata without calling any tool. Local commands may run code or download packages. Results describe this check, not the availability of an existing Agent session. Tool switches apply to the selected Agent's user configuration; project and managed policies can still restrict access. Start a new Agent session after changes.", + "arguments": [] + } + }, + { + "id": "native.detectionStartsTheConfiguredExecutableAndMayDownloadPackagesContinueOnlyIfYouTrustThisServerLitheWillListToolsThenStopTheProbeProcess", + "description": "Native UI: Detection starts the configured executable and may download packages. Continue only if you trust this server. Lithe will list tools, then stop the probe process.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Detection starts the configured executable and may download packages. Continue only if you trust this server. Lithe will list tools, then stop the probe process.", + "arguments": [] + } + }, + { + "id": "native.directConnection", + "description": "Native UI: Direct connection", + "platforms": [ + "macos" + ], + "macos": { + "key": "Direct connection", + "arguments": [] + } + }, + { + "id": "native.directRetryAppliesOnlyToPublicMcpRegistrySearchesOnThisPageSystemProxySettingsAndAgentServerConnectionsAreUnchangedTlsCertificateValidationRemainsEnabled", + "description": "Native UI: Direct retry applies only to public MCP Registry searches on this page. System proxy settings and Agent server connections are unchanged. TLS certificate validation remains enabled.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Direct retry applies only to public MCP Registry searches on this page. System proxy settings and Agent server connections are unchanged. TLS certificate validation remains enabled.", + "arguments": [] + } + }, + { + "id": "native.directoryImportsPreserveScriptsTemplatesAndOtherSupportingFilesExistingSkillsAreNeverOverwritten", + "description": "Native UI: Directory imports preserve scripts, templates and other supporting files. Existing skills are never overwritten.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Directory imports preserve scripts, templates and other supporting files. Existing skills are never overwritten.", + "arguments": [] + } + }, + { + "id": "native.disabledInAgent", + "description": "Native UI: Disabled in Agent", + "platforms": [ + "macos" + ], + "macos": { + "key": "Disabled in Agent", + "arguments": [] + } + }, + { + "id": "native.discardDraftAndReload", + "description": "Native UI: Discard draft and reload?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Discard draft and reload?", + "arguments": [] + } + }, + { + "id": "native.discoverMcpServers", + "description": "Native UI: Discover MCP servers", + "platforms": [ + "macos" + ], + "macos": { + "key": "Discover MCP servers", + "arguments": [] + } + }, + { + "id": "native.editMcpServer", + "description": "Native UI: Edit MCP server", + "platforms": [ + "macos" + ], + "macos": { + "key": "Edit MCP server", + "arguments": [] + } + }, + { + "id": "native.empty", + "description": "Native UI: Empty", + "platforms": [ + "macos" + ], + "macos": { + "key": "Empty", + "arguments": [] + } + }, + { + "id": "native.enableSkill", + "description": "Native UI: Enable skill", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enable skill", + "arguments": [] + } + }, + { + "id": "native.enabledInAgent", + "description": "Native UI: Enabled in Agent", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enabled in Agent", + "arguments": [] + } + }, + { + "id": "native.enterADescriptionAndInstructions", + "description": "Native UI: Enter a description and instructions.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enter a description and instructions.", + "arguments": [] + } + }, + { + "id": "native.enterAnAuthorizationHeaderWithBearerAndYourGithubToken", + "description": "Native UI: Enter an Authorization header with Bearer and your GitHub token.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enter an Authorization header with Bearer and your GitHub token.", + "arguments": [] + } + }, + { + "id": "native.enterTheExecutableAndArgumentsAsInATerminalQuotePathsWithSpacesLithePassesExactArgumentsWithoutRunningAShellPipesAndRedirectsAreNotEvaluated", + "description": "Native UI: Enter the executable and arguments as in a terminal. Quote paths with spaces. Lithe passes exact arguments without running a shell; pipes and redirects are not evaluated.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enter the executable and arguments as in a terminal. Quote paths with spaces. Lithe passes exact arguments without running a shell; pipes and redirects are not evaluated.", + "arguments": [] + } + }, + { + "id": "native.featured", + "description": "Native UI: Featured", + "platforms": [ + "macos" + ], + "macos": { + "key": "Featured", + "arguments": [] + } + }, + { + "id": "native.featuredAndTheOfficialMcpRegistry", + "description": "Native UI: Featured and the official MCP Registry", + "platforms": [ + "macos" + ], + "macos": { + "key": "Featured and the official MCP Registry", + "arguments": [] + } + }, + { + "id": "native.featuredServersShipWithLitheAndWorkOfflineSearchingAlsoChecksTheOfficialMcpRegistryListingIsNotASecurityReviewAddingOpensAConfigurationToReviewPackagesAreNotDownloadedOrRunFollowThePublisherSDocumentationForAuthenticationAndOtherRequirements", + "description": "Native UI: Featured servers ship with Lithe and work offline. Searching also checks the official MCP Registry. Listing is not a security review. Adding opens a configuration to review; packages are not downloaded or run. Follow the publisher's documentation for authentication and other requirements.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Featured servers ship with Lithe and work offline. Searching also checks the official MCP Registry. Listing is not a security review. Adding opens a configuration to review; packages are not downloaded or run. Follow the publisher's documentation for authentication and other requirements.", + "arguments": [] + } + }, + { + "id": "native.fetchWebPagesAndConvertTheirContentToMarkdownRequiresUvAndPython310OrLater", + "description": "Native UI: Fetch web pages and convert their content to Markdown. Requires uv and Python 3.10 or later.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Fetch web pages and convert their content to Markdown. Requires uv and Python 3.10 or later.", + "arguments": [] + } + }, + { + "id": "native.fileInventoryOnlyClaudeDeterminesWhetherThisSkillIsAvailableInASession", + "description": "Native UI: File inventory only. Claude determines whether this skill is available in a session.", + "platforms": [ + "macos" + ], + "macos": { + "key": "File inventory only. Claude determines whether this skill is available in a session.", + "arguments": [] + } + }, + { + "id": "native.githubRequiresAPersonalAccessTokenSetTheAuthorizationHeaderToBearerFollowedByYourTokenItIsStoredInTheAgentConfigurationFile", + "description": "Native UI: GitHub requires a personal access token. Set the Authorization header to Bearer followed by your token. It is stored in the Agent configuration file.", + "platforms": [ + "macos" + ], + "macos": { + "key": "GitHub requires a personal access token. Set the Authorization header to Bearer followed by your token. It is stored in the Agent configuration file.", + "arguments": [] + } + }, + { + "id": "native.globalInstructions", + "description": "Native UI: Global instructions", + "platforms": [ + "macos" + ], + "macos": { + "key": "Global instructions", + "arguments": [] + } + }, + { + "id": "native.globalInstructionsApplyToAllProjectsUsingThisAgentEachAgentKeepsItsOwnNativeFile", + "description": "Native UI: Global instructions apply to all projects using this Agent. Each Agent keeps its own native file.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Global instructions apply to all projects using this Agent. Each Agent keeps its own native file.", + "arguments": [] + } + }, + { + "id": "native.globalInstructionsAreAvailableForCodexAndClaudeCode", + "description": "Native UI: Global instructions are available for Codex and Claude Code.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Global instructions are available for Codex and Claude Code.", + "arguments": [] + } + }, + { + "id": "native.globalInstructionsChangedOutsideThisEditorYourDraftIsKeptReloadBeforeSaving", + "description": "Native UI: Global instructions changed outside this editor. Your draft is kept; reload before saving.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Global instructions changed outside this editor. Your draft is kept; reload before saving.", + "arguments": [] + } + }, + { + "id": "native.globalInstructionsMustBeNoLargerThan1Mib", + "description": "Native UI: Global instructions must be no larger than 1 MiB.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Global instructions must be no larger than 1 MiB.", + "arguments": [] + } + }, + { + "id": "native.globalSkills", + "description": "Native UI: Global skills", + "platforms": [ + "macos" + ], + "macos": { + "key": "Global skills", + "arguments": [] + } + }, + { + "id": "native.importARegularFileOrDirectoryInsteadOfASymbolicLink", + "description": "Native UI: Import a regular file or directory instead of a symbolic link.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Import a regular file or directory instead of a symbolic link.", + "arguments": [] + } + }, + { + "id": "native.importJson", + "description": "Native UI: Import JSON", + "platforms": [ + "macos" + ], + "macos": { + "key": "Import JSON", + "arguments": [] + } + }, + { + "id": "native.importMcpConfiguration", + "description": "Native UI: Import MCP configuration", + "platforms": [ + "macos" + ], + "macos": { + "key": "Import MCP configuration", + "arguments": [] + } + }, + { + "id": "native.importSkill", + "description": "Native UI: Import skill", + "platforms": [ + "macos" + ], + "macos": { + "key": "Import skill", + "arguments": [] + } + }, + { + "id": "native.importSkillDirectory", + "description": "Native UI: Import skill directory", + "platforms": [ + "macos" + ], + "macos": { + "key": "Import skill directory", + "arguments": [] + } + }, + { + "id": "native.importSkillMd", + "description": "Native UI: Import SKILL.md", + "platforms": [ + "macos" + ], + "macos": { + "key": "Import SKILL.md", + "arguments": [] + } + }, + { + "id": "native.importSource", + "description": "Native UI: Import source", + "platforms": [ + "macos" + ], + "macos": { + "key": "Import source", + "arguments": [] + } + }, + { + "id": "native.inspectChromePagesConsoleMessagesNetworkRequestsAndPerformance", + "description": "Native UI: Inspect Chrome pages, console messages, network requests and performance.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Inspect Chrome pages, console messages, network requests and performance.", + "arguments": [] + } + }, + { + "id": "native.installCodexCliToDiscoverItsSkills", + "description": "Native UI: Install Codex CLI to discover its skills.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Install Codex CLI to discover its skills.", + "arguments": [] + } + }, + { + "id": "native.instructionAgent", + "description": "Native UI: Instruction Agent", + "platforms": [ + "macos" + ], + "macos": { + "key": "Instruction Agent", + "arguments": [] + } + }, + { + "id": "native.instructionSynchronizationWasCancelled", + "description": "Native UI: Instruction synchronization was cancelled.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Instruction synchronization was cancelled.", + "arguments": [] + } + }, + { + "id": "native.instructions", + "description": "Native UI: Instructions", + "platforms": [ + "macos" + ], + "macos": { + "key": "Instructions", + "arguments": [] + } + }, + { + "id": "native.instructionsSaved", + "description": "Native UI: Instructions saved.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Instructions saved.", + "arguments": [] + } + }, + { + "id": "native.invalidMcpToolConfiguration", + "description": "Native UI: Invalid MCP tool configuration", + "platforms": [ + "macos" + ], + "macos": { + "key": "Invalid MCP tool configuration", + "arguments": [] + } + }, + { + "id": "native.invalidSharedInstructionRequest", + "description": "Native UI: Invalid shared instruction request.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Invalid shared instruction request.", + "arguments": [] + } + }, + { + "id": "native.keepAPersistentKnowledgeGraphBetweenConversationsRequiresNodeJs24OrLater", + "description": "Native UI: Keep a persistent knowledge graph between conversations. Requires Node.js 24 or later.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Keep a persistent knowledge graph between conversations. Requires Node.js 24 or later.", + "arguments": [] + } + }, + { + "id": "native.legacySseProbingIsUnavailableUseThePublisherSStreamableHttpEndpoint", + "description": "Native UI: Legacy SSE probing is unavailable. Use the publisher's Streamable HTTP endpoint.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Legacy SSE probing is unavailable. Use the publisher's Streamable HTTP endpoint.", + "arguments": [] + } + }, + { + "id": "native.loadMore2", + "description": "Native UI: Load more", + "platforms": [ + "macos" + ], + "macos": { + "key": "Load more", + "arguments": [] + } + }, + { + "id": "native.manageSkills", + "description": "Native UI: Manage Skills…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Manage Skills…", + "arguments": [] + } + }, + { + "id": "native.managed", + "description": "Native UI: Managed", + "platforms": [ + "macos" + ], + "macos": { + "key": "Managed", + "arguments": [] + } + }, + { + "id": "native.mcpConnectionFailedCheckTheServerNetworkAndAuthentication", + "description": "Native UI: MCP connection failed. Check the server, network and authentication.", + "platforms": [ + "macos" + ], + "macos": { + "key": "MCP connection failed. Check the server, network and authentication.", + "arguments": [] + } + }, + { + "id": "native.mcpDetectionTimedOutAfter30Seconds", + "description": "Native UI: MCP detection timed out after 30 seconds.", + "platforms": [ + "macos" + ], + "macos": { + "key": "MCP detection timed out after 30 seconds.", + "arguments": [] + } + }, + { + "id": "native.mcpDetectionWasCancelled", + "description": "Native UI: MCP detection was cancelled.", + "platforms": [ + "macos" + ], + "macos": { + "key": "MCP detection was cancelled.", + "arguments": [] + } + }, + { + "id": "native.mcpJson", + "description": "Native UI: MCP JSON", + "platforms": [ + "macos" + ], + "macos": { + "key": "MCP JSON", + "arguments": [] + } + }, + { + "id": "native.mcpToolMetadataExceededTheInspectionLimit", + "description": "Native UI: MCP tool metadata exceeded the inspection limit.", + "platforms": [ + "macos" + ], + "macos": { + "key": "MCP tool metadata exceeded the inspection limit.", + "arguments": [] + } + }, + { + "id": "native.nativeInstructionFile", + "description": "Native UI: Native instruction file", + "platforms": [ + "macos" + ], + "macos": { + "key": "Native instruction file", + "arguments": [] + } + }, + { + "id": "native.newMcpServer", + "description": "Native UI: New MCP server", + "platforms": [ + "macos" + ], + "macos": { + "key": "New MCP server", + "arguments": [] + } + }, + { + "id": "native.newSkill", + "description": "Native UI: New skill", + "platforms": [ + "macos" + ], + "macos": { + "key": "New skill", + "arguments": [] + } + }, + { + "id": "native.noMatchingMcpServers", + "description": "Native UI: No matching MCP servers", + "platforms": [ + "macos" + ], + "macos": { + "key": "No matching MCP servers", + "arguments": [] + } + }, + { + "id": "native.noMatchingSkills", + "description": "Native UI: No matching skills.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No matching skills.", + "arguments": [] + } + }, + { + "id": "native.noSkillsInThisScope", + "description": "Native UI: No skills in this scope.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No skills in this scope.", + "arguments": [] + } + }, + { + "id": "native.notDetected", + "description": "Native UI: Not detected", + "platforms": [ + "macos" + ], + "macos": { + "key": "Not detected", + "arguments": [] + } + }, + { + "id": "native.officialMcpRegistry", + "description": "Native UI: Official MCP Registry", + "platforms": [ + "macos" + ], + "macos": { + "key": "Official MCP Registry", + "arguments": [] + } + }, + { + "id": "native.openAProjectToManageItsSkills", + "description": "Native UI: Open a project to manage its skills.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open a project to manage its skills.", + "arguments": [] + } + }, + { + "id": "native.openPagesClickTypeAndInspectABrowserThroughItsAccessibilityTree", + "description": "Native UI: Open pages, click, type and inspect a browser through its accessibility tree.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open pages, click, type and inspect a browser through its accessibility tree.", + "arguments": [] + } + }, + { + "id": "native.openSkillMd", + "description": "Native UI: Open SKILL.md", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open SKILL.md", + "arguments": [] + } + }, + { + "id": "native.pasteAJsonObjectContainingMcpservers", + "description": "Native UI: Paste a JSON object containing mcpServers.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Paste a JSON object containing mcpServers.", + "arguments": [] + } + }, + { + "id": "native.pasteStandardJsonWithAnMcpserversObjectChooseAServerToReviewBeforeSavingOnlyTheEditorSSupportedCommandArgumentsEnvironmentUrlAndHeaderFieldsAreImportedAuthenticationAndClientSpecificOptionsMustBeConfiguredSeparatelyImportingDoesNotTakeOwnershipOfAnExistingEntry", + "description": "Native UI: Paste standard JSON with an mcpServers object. Choose a server to review before saving. Only the editor's supported command, arguments, environment, URL and header fields are imported; authentication and client-specific options must be configured separately. Importing does not take ownership of an existing entry.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Paste standard JSON with an mcpServers object. Choose a server to review before saving. Only the editor's supported command, arguments, environment, URL and header fields are imported; authentication and client-specific options must be configured separately. Importing does not take ownership of an existing entry.", + "arguments": [] + } + }, + { + "id": "native.plugin", + "description": "Native UI: Plugin", + "platforms": [ + "macos" + ], + "macos": { + "key": "Plugin", + "arguments": [] + } + }, + { + "id": "native.previewImport", + "description": "Native UI: Preview import", + "platforms": [ + "macos" + ], + "macos": { + "key": "Preview import", + "arguments": [] + } + }, + { + "id": "native.projectSkills", + "description": "Native UI: Project skills", + "platforms": [ + "macos" + ], + "macos": { + "key": "Project skills", + "arguments": [] + } + }, + { + "id": "native.publicDirectoryMetadataIsSuppliedByTheOfficialMcpRegistryListingIsNotAnEndorsementOrASecurityReviewAddingOpensAnEditableConfigurationLitheDoesNotDownloadOrRunServerPackagesServersWithComplexInputsMustBeConfiguredUsingThePublisherSDocumentation", + "description": "Native UI: Public directory metadata is supplied by the official MCP Registry. Listing is not an endorsement or a security review. Adding opens an editable configuration; Lithe does not download or run server packages. Servers with complex inputs must be configured using the publisher's documentation.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Public directory metadata is supplied by the official MCP Registry. Listing is not an endorsement or a security review. Adding opens an editable configuration; Lithe does not download or run server packages. Servers with complex inputs must be configured using the publisher's documentation.", + "arguments": [] + } + }, + { + "id": "native.readWriteAndSearchFilesOnlyInTheDirectoriesYouAllowRequiresNodeJs24OrLater", + "description": "Native UI: Read, write and search files only in the directories you allow. Requires Node.js 24 or later.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Read, write and search files only in the directories you allow. Requires Node.js 24 or later.", + "arguments": [] + } + }, + { + "id": "native.refreshSkills", + "description": "Native UI: Refresh skills", + "platforms": [ + "macos" + ], + "macos": { + "key": "Refresh skills", + "arguments": [] + } + }, + { + "id": "native.registryWebsite", + "description": "Native UI: Registry website", + "platforms": [ + "macos" + ], + "macos": { + "key": "Registry website", + "arguments": [] + } + }, + { + "id": "native.reloadFromDisk", + "description": "Native UI: Reload from disk", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reload from disk", + "arguments": [] + } + }, + { + "id": "native.reloadThisAgentBeforeSynchronizingInstructions", + "description": "Native UI: Reload this Agent before synchronizing instructions.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reload this Agent before synchronizing instructions.", + "arguments": [] + } + }, + { + "id": "native.reloadToolSettings", + "description": "Native UI: Reload tool settings", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reload tool settings", + "arguments": [] + } + }, + { + "id": "native.reloadingReplacesThisDraftWithTheCurrentFileContents", + "description": "Native UI: Reloading replaces this draft with the current file contents.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reloading replaces this draft with the current file contents.", + "arguments": [] + } + }, + { + "id": "native.removeFromLibrary", + "description": "Native UI: Remove from library", + "platforms": [ + "macos" + ], + "macos": { + "key": "Remove from library", + "arguments": [] + } + }, + { + "id": "native.retryDirectly", + "description": "Native UI: Retry directly", + "platforms": [ + "macos" + ], + "macos": { + "key": "Retry directly", + "arguments": [] + } + }, + { + "id": "native.reviewConfiguration", + "description": "Native UI: Review configuration", + "platforms": [ + "macos" + ], + "macos": { + "key": "Review configuration", + "arguments": [] + } + }, + { + "id": "native.runAndDetect", + "description": "Native UI: Run and detect", + "platforms": [ + "macos" + ], + "macos": { + "key": "Run and detect", + "arguments": [] + } + }, + { + "id": "native.runThisMcpCommand", + "description": "Native UI: Run this MCP command?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Run this MCP command?", + "arguments": [] + } + }, + { + "id": "native.saveInstructions", + "description": "Native UI: Save instructions", + "platforms": [ + "macos" + ], + "macos": { + "key": "Save instructions", + "arguments": [] + } + }, + { + "id": "native.saveSharedInstructions", + "description": "Native UI: Save shared instructions", + "platforms": [ + "macos" + ], + "macos": { + "key": "Save shared instructions", + "arguments": [] + } + }, + { + "id": "native.searchMcpServers", + "description": "Native UI: Search MCP servers", + "platforms": [ + "macos" + ], + "macos": { + "key": "Search MCP servers", + "arguments": [] + } + }, + { + "id": "native.searchMcpServersGithubBrowsersDocumentation", + "description": "Native UI: Search MCP servers — GitHub, browsers, documentation…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Search MCP servers — GitHub, browsers, documentation…", + "arguments": [] + } + }, + { + "id": "native.searchOfficialMicrosoftAndAzureDocumentationAndCodeSamples", + "description": "Native UI: Search official Microsoft and Azure documentation and code samples.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Search official Microsoft and Azure documentation and code samples.", + "arguments": [] + } + }, + { + "id": "native.searchSkills", + "description": "Native UI: Search skills", + "platforms": [ + "macos" + ], + "macos": { + "key": "Search skills", + "arguments": [] + } + }, + { + "id": "native.searchTheOfficialMcpRegistry", + "description": "Native UI: Search the official MCP Registry", + "platforms": [ + "macos" + ], + "macos": { + "key": "Search the official MCP Registry", + "arguments": [] + } + }, + { + "id": "native.searchTheWebAndCodeAndRetrievePageContentForCodingAgents", + "description": "Native UI: Search the web and code, and retrieve page content for coding agents.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Search the web and code, and retrieve page content for coding agents.", + "arguments": [] + } + }, + { + "id": "native.selectSkillMdOrItsContainingDirectory", + "description": "Native UI: Select SKILL.md or its containing directory.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Select SKILL.md or its containing directory.", + "arguments": [] + } + }, + { + "id": "native.serverTools", + "description": "Native UI: Server tools", + "platforms": [ + "macos" + ], + "macos": { + "key": "Server tools", + "arguments": [] + } + }, + { + "id": "native.sharedInstructions", + "description": "Native UI: Shared instructions", + "platforms": [ + "macos" + ], + "macos": { + "key": "Shared instructions", + "arguments": [] + } + }, + { + "id": "native.sharedInstructionsCannotContainLitheSynchronizationMarkers", + "description": "Native UI: Shared instructions cannot contain Lithe synchronization markers.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Shared instructions cannot contain Lithe synchronization markers.", + "arguments": [] + } + }, + { + "id": "native.sharedInstructionsChangedOutsideThisEditorReloadBeforeSaving", + "description": "Native UI: Shared instructions changed outside this editor. Reload before saving.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Shared instructions changed outside this editor. Reload before saving.", + "arguments": [] + } + }, + { + "id": "native.sharedInstructionsSaved", + "description": "Native UI: Shared instructions saved.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Shared instructions saved.", + "arguments": [] + } + }, + { + "id": "native.showingAvailableMatches", + "description": "Native UI: Showing available matches.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Showing available matches.", + "arguments": [] + } + }, + { + "id": "native.skillActions", + "description": "Native UI: Skill actions", + "platforms": [ + "macos" + ], + "macos": { + "key": "Skill actions", + "arguments": [] + } + }, + { + "id": "native.skillFiles", + "description": "Native UI: Skill files", + "platforms": [ + "macos" + ], + "macos": { + "key": "Skill files", + "arguments": [] + } + }, + { + "id": "native.skillImportsCannotContainSymbolicLinksOrSpecialFiles", + "description": "Native UI: Skill imports cannot contain symbolic links or special files.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Skill imports cannot contain symbolic links or special files.", + "arguments": [] + } + }, + { + "id": "native.skillInstructions", + "description": "Native UI: Skill instructions", + "platforms": [ + "macos" + ], + "macos": { + "key": "Skill instructions", + "arguments": [] + } + }, + { + "id": "native.skillMdExceedsThe128KibLimit", + "description": "Native UI: SKILL.md exceeds the 128 KiB limit.", + "platforms": [ + "macos" + ], + "macos": { + "key": "SKILL.md exceeds the 128 KiB limit.", + "arguments": [] + } + }, + { + "id": "native.skillMdFrontmatterIsNotClosed", + "description": "Native UI: SKILL.md frontmatter is not closed.", + "platforms": [ + "macos" + ], + "macos": { + "key": "SKILL.md frontmatter is not closed.", + "arguments": [] + } + }, + { + "id": "native.skillMdHasInvalidYamlFrontmatter", + "description": "Native UI: SKILL.md has invalid YAML frontmatter.", + "platforms": [ + "macos" + ], + "macos": { + "key": "SKILL.md has invalid YAML frontmatter.", + "arguments": [] + } + }, + { + "id": "native.skillMdMustUseUtf8Text", + "description": "Native UI: SKILL.md must use UTF-8 text.", + "platforms": [ + "macos" + ], + "macos": { + "key": "SKILL.md must use UTF-8 text.", + "arguments": [] + } + }, + { + "id": "native.skillMdNeedsANameAndDescription", + "description": "Native UI: SKILL.md needs a name and description.", + "platforms": [ + "macos" + ], + "macos": { + "key": "SKILL.md needs a name and description.", + "arguments": [] + } + }, + { + "id": "native.skillMdNeedsInstructionsAfterItsFrontmatter", + "description": "Native UI: SKILL.md needs instructions after its frontmatter.", + "platforms": [ + "macos" + ], + "macos": { + "key": "SKILL.md needs instructions after its frontmatter.", + "arguments": [] + } + }, + { + "id": "native.skillMdNeedsYamlFrontmatter", + "description": "Native UI: SKILL.md needs YAML frontmatter.", + "platforms": [ + "macos" + ], + "macos": { + "key": "SKILL.md needs YAML frontmatter.", + "arguments": [] + } + }, + { + "id": "native.skillNameLowercaseWithHyphens", + "description": "Native UI: Skill name (lowercase-with-hyphens)", + "platforms": [ + "macos" + ], + "macos": { + "key": "Skill name (lowercase-with-hyphens)", + "arguments": [] + } + }, + { + "id": "native.skillProject", + "description": "Native UI: Skill project", + "platforms": [ + "macos" + ], + "macos": { + "key": "Skill project", + "arguments": [] + } + }, + { + "id": "native.skillSavedRefreshOrReconnectTheAgentIfItDoesNotAppearInTheConversation", + "description": "Native UI: Skill saved. Refresh or reconnect the Agent if it does not appear in the conversation.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Skill saved. Refresh or reconnect the Agent if it does not appear in the conversation.", + "arguments": [] + } + }, + { + "id": "native.skillSettingSaved", + "description": "Native UI: Skill setting saved.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Skill setting saved.", + "arguments": [] + } + }, + { + "id": "native.skillSettingsAreBusyRetryAfterTheOtherChangeFinishes", + "description": "Native UI: Skill settings are busy. Retry after the other change finishes.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Skill settings are busy. Retry after the other change finishes.", + "arguments": [] + } + }, + { + "id": "native.skillSettingsChangedElsewhereRefreshAndRetry", + "description": "Native UI: Skill settings changed elsewhere. Refresh and retry.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Skill settings changed elsewhere. Refresh and retry.", + "arguments": [] + } + }, + { + "id": "native.skillSettingsCouldNotBeUpdatedCheckTheSettingsFileAndRefresh", + "description": "Native UI: Skill settings could not be updated. Check the settings file and refresh.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Skill settings could not be updated. Check the settings file and refresh.", + "arguments": [] + } + }, + { + "id": "native.skillSource", + "description": "Native UI: Skill source", + "platforms": [ + "macos" + ], + "macos": { + "key": "Skill source", + "arguments": [] + } + }, + { + "id": "native.skillTargetAgent", + "description": "Native UI: Skill target Agent", + "platforms": [ + "macos" + ], + "macos": { + "key": "Skill target Agent", + "arguments": [] + } + }, + { + "id": "native.skills", + "description": "Native UI: Skills", + "platforms": [ + "macos" + ], + "macos": { + "key": "Skills", + "arguments": [] + } + }, + { + "id": "native.startANewConversationAfterSavingToLoadTheUpdatedInstructionsProjectInstructionsStillApply", + "description": "Native UI: Start a new conversation after saving to load the updated instructions. Project instructions still apply.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Start a new conversation after saving to load the updated instructions. Project instructions still apply.", + "arguments": [] + } + }, + { + "id": "native.stateUnknown", + "description": "Native UI: State unknown", + "platforms": [ + "macos" + ], + "macos": { + "key": "State unknown", + "arguments": [] + } + }, + { + "id": "native.switchesSaveNativeAgentSettingsReconnectExistingConversationsToRefreshAvailableSkills", + "description": "Native UI: Switches save native Agent settings. Reconnect existing conversations to refresh available skills.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Switches save native Agent settings. Reconnect existing conversations to refresh available skills.", + "arguments": [] + } + }, + { + "id": "native.syncAgain", + "description": "Native UI: Sync again", + "platforms": [ + "macos" + ], + "macos": { + "key": "Sync again", + "arguments": [] + } + }, + { + "id": "native.syncNeeded", + "description": "Native UI: Sync needed", + "platforms": [ + "macos" + ], + "macos": { + "key": "Sync needed", + "arguments": [] + } + }, + { + "id": "native.thatSkillNameIsReservedByClaudeChooseADifferentName", + "description": "Native UI: That skill name is reserved by Claude. Choose a different name.", + "platforms": [ + "macos" + ], + "macos": { + "key": "That skill name is reserved by Claude. Choose a different name.", + "arguments": [] + } + }, + { + "id": "native.theAgentDidNotApplyTheSkillSettingRefreshAndCheckProjectOrManagedConfiguration", + "description": "Native UI: The Agent did not apply the skill setting. Refresh and check project or managed configuration.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The Agent did not apply the skill setting. Refresh and check project or managed configuration.", + "arguments": [] + } + }, + { + "id": "native.theAgentUserDirectoryMustBeAnAbsolutePathCheckCodexHomeOrClaudeConfigDir", + "description": "Native UI: The Agent user directory must be an absolute path. Check CODEX_HOME or CLAUDE_CONFIG_DIR.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The Agent user directory must be an absolute path. Check CODEX_HOME or CLAUDE_CONFIG_DIR.", + "arguments": [] + } + }, + { + "id": "native.theFeaturedMcpCatalogCouldNotBeLoaded", + "description": "Native UI: The featured MCP catalog could not be loaded.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The featured MCP catalog could not be loaded.", + "arguments": [] + } + }, + { + "id": "native.theFileWillBeCreatedWhenYouSave", + "description": "Native UI: The file will be created when you save.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The file will be created when you save.", + "arguments": [] + } + }, + { + "id": "native.theGlobalInstructionFileMustUseUtf8Encoding", + "description": "Native UI: The global instruction file must use UTF-8 encoding.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The global instruction file must use UTF-8 encoding.", + "arguments": [] + } + }, + { + "id": "native.theInstructionDirectoryChangedReloadBeforeSaving", + "description": "Native UI: The instruction directory changed. Reload before saving.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The instruction directory changed. Reload before saving.", + "arguments": [] + } + }, + { + "id": "native.theInstructionPathMustBeARegularFileNotASymbolicLinkOrDirectory", + "description": "Native UI: The instruction path must be a regular file, not a symbolic link or directory.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The instruction path must be a regular file, not a symbolic link or directory.", + "arguments": [] + } + }, + { + "id": "native.theLitheSharedInstructionMarkersWereChangedRepairTheNativeFileBeforeSyncing", + "description": "Native UI: The Lithe shared instruction markers were changed. Repair the native file before syncing.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The Lithe shared instruction markers were changed. Repair the native file before syncing.", + "arguments": [] + } + }, + { + "id": "native.theMcpRegistryCouldNotBeLoadedTryAgainOrAddAServerManually", + "description": "Native UI: The MCP Registry could not be loaded. Try again or add a server manually.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The MCP Registry could not be loaded. Try again or add a server manually.", + "arguments": [] + } + }, + { + "id": "native.theMcpRegistryIsUnreachableCheckTheNetworkOrProxy", + "description": "Native UI: The MCP Registry is unreachable. Check the network or proxy.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The MCP Registry is unreachable. Check the network or proxy.", + "arguments": [] + } + }, + { + "id": "native.theMcpRegistryRequestTimedOutTryAgain", + "description": "Native UI: The MCP Registry request timed out. Try again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The MCP Registry request timed out. Try again.", + "arguments": [] + } + }, + { + "id": "native.theSharedInstructionDirectoryMustBeAbsolute", + "description": "Native UI: The shared instruction directory must be absolute.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The shared instruction directory must be absolute.", + "arguments": [] + } + }, + { + "id": "native.theSkillCouldNotBePublishedCheckPermissionsAndExistingSkillNames", + "description": "Native UI: The skill could not be published. Check permissions and existing skill names.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The skill could not be published. Check permissions and existing skill names.", + "arguments": [] + } + }, + { + "id": "native.theSkillDestinationContainsASymbolicLinkOrNonDirectoryChooseARegularNativeSkillDirectory", + "description": "Native UI: The skill destination contains a symbolic link or non-directory. Choose a regular native skill directory.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The skill destination contains a symbolic link or non-directory. Choose a regular native skill directory.", + "arguments": [] + } + }, + { + "id": "native.theSkillDocumentIsUnavailableRefreshAndRetry", + "description": "Native UI: The skill document is unavailable. Refresh and retry.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The skill document is unavailable. Refresh and retry.", + "arguments": [] + } + }, + { + "id": "native.theSkillFileCouldNotBeOpened", + "description": "Native UI: The skill file could not be opened.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The skill file could not be opened.", + "arguments": [] + } + }, + { + "id": "native.theSkillFileCouldNotBeRead", + "description": "Native UI: The skill file could not be read.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The skill file could not be read.", + "arguments": [] + } + }, + { + "id": "native.theSkillIsNoLongerAvailableInThisScopeRefreshAndRetry", + "description": "Native UI: The skill is no longer available in this scope. Refresh and retry.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The skill is no longer available in this scope. Refresh and retry.", + "arguments": [] + } + }, + { + "id": "native.theSwitchControlsTheLitheSharedBlockNotTheAgentOrItsOwnInstructionsSaveSharedChangesBeforeSwitchingOpenARowToEditTheCompleteNativeFile", + "description": "Native UI: The switch controls the Lithe shared block, not the Agent or its own instructions. Save shared changes before switching. Open a row to edit the complete native file.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The switch controls the Lithe shared block, not the Agent or its own instructions. Save shared changes before switching. Open a row to edit the complete native file.", + "arguments": [] + } + }, + { + "id": "native.thisEditorShowsTheCompleteNativeFileIncludingAnyLitheSharedBlockEditingSharedTextHereCanLeaveItOutOfSyncUseTheSharedEditorToUpdateAllEnabledAgents", + "description": "Native UI: This editor shows the complete native file, including any Lithe shared block. Editing shared text here can leave it out of sync; use the shared editor to update all enabled Agents.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This editor shows the complete native file, including any Lithe shared block. Editing shared text here can leave it out of sync; use the shared editor to update all enabled Agents.", + "arguments": [] + } + }, + { + "id": "native.thisServerReportedNoTools", + "description": "Native UI: This server reported no tools.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This server reported no tools.", + "arguments": [] + } + }, + { + "id": "native.thisToolIsRestrictedOutsideLitheEditTheNativeAgentPolicyToEnableIt", + "description": "Native UI: This tool is restricted outside Lithe. Edit the native Agent policy to enable it.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This tool is restricted outside Lithe. Edit the native Agent policy to enable it.", + "arguments": [] + } + }, + { + "id": "native.unsavedChanges", + "description": "Native UI: Unsaved changes", + "platforms": [ + "macos" + ], + "macos": { + "key": "Unsaved changes", + "arguments": [] + } + }, + { + "id": "native.useSharedInstructions", + "description": "Native UI: Use shared instructions", + "platforms": [ + "macos" + ], + "macos": { + "key": "Use shared instructions", + "arguments": [] + } + }, + { + "id": "native.useSystemProxy", + "description": "Native UI: Use system proxy", + "platforms": [ + "macos" + ], + "macos": { + "key": "Use system proxy", + "arguments": [] + } + }, + { + "id": "native.useUpTo64LowercaseLettersDigitsAndSingleHyphensForTheSkillName", + "description": "Native UI: Use up to 64 lowercase letters, digits and single hyphens for the skill name.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Use up to 64 lowercase letters, digits and single hyphens for the skill name.", + "arguments": [] + } + }, + { + "id": "native.viewSkill", + "description": "Native UI: View skill", + "platforms": [ + "macos" + ], + "macos": { + "key": "View skill", + "arguments": [] + } + }, + { + "id": "native.whenShouldThisSkillBeUsed", + "description": "Native UI: When should this skill be used?", + "platforms": [ + "macos" + ], + "macos": { + "key": "When should this skill be used?", + "arguments": [] + } + }, + { + "id": "native.workWithGithubRepositoriesIssuesPullRequestsAndActionsAPersonalAccessTokenIsRequired", + "description": "Native UI: Work with GitHub repositories, issues, pull requests and Actions. A personal access token is required.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Work with GitHub repositories, issues, pull requests and Actions. A personal access token is required.", + "arguments": [] + } + }, + { + "id": "native.writeSharedInstructionsOnceThenEnableThemForEachAgentLitheOnlyChangesItsMarkedBlockTheRestOfEachNativeFileStaysYoursStartANewConversationToLoadChangesProjectInstructionsStillApply", + "description": "Native UI: Write shared instructions once, then enable them for each Agent. Lithe only changes its marked block; the rest of each native file stays yours. Start a new conversation to load changes. Project instructions still apply.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Write shared instructions once, then enable them for each Agent. Lithe only changes its marked block; the rest of each native file stays yours. Start a new conversation to load changes. Project instructions still apply.", + "arguments": [] + } + }, + { + "id": "native.arg1DoesNotProvideArg2BecauseArg3AlreadyDoes", + "description": "Native UI: %1$@ does not provide %2$@ because %3$@ already does.", + "platforms": [ + "macos" + ], + "macos": { + "key": "%1$@ does not provide %2$@ because %3$@ already does.", + "arguments": [ + "arg1", + "arg2", + "arg3" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string", + "arg3": "string" + } + }, + { + "id": "native.arg1FilesHaveConflicts", + "description": "Native UI: %lld files have conflicts.", + "platforms": [ + "macos" + ], + "macos": { + "key": "%lld files have conflicts.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.arg1FilesSelected", + "description": "Native UI: %lld files selected", + "platforms": [ + "macos" + ], + "macos": { + "key": "%lld files selected", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.arg1HoldsTheMetadataOfSeveralVersionControlSystemsChooseTheOneThatManagesIt", + "description": "Native UI: %@ holds the metadata of several version control systems. Choose the one that manages it.", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ holds the metadata of several version control systems. Choose the one that manages it.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.arg1ProviderInsideThePlugin", + "description": "Native UI: %@ provider (inside the plugin)", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ provider (inside the plugin)", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.arg1ProviderInstalledOnThisMac", + "description": "Native UI: %@ provider (installed on this Mac)", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ provider (installed on this Mac)", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.arg1ProviderRunsWithArg2", + "description": "Native UI: %1$@ provider (runs with %2$@)", + "platforms": [ + "macos" + ], + "macos": { + "key": "%1$@ provider (runs with %2$@)", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "string", + "arg2": "string" + } + }, + { + "id": "native.authenticationFailedLitheCannotAskForCredentialsYetSignInOnceWithTheCommandLineClientSoItStoresThemThenTryAgain", + "description": "Native UI: Authentication failed. Lithe cannot ask for credentials yet: sign in once with the command-line client so it stores them, then try again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Authentication failed. Lithe cannot ask for credentials yet: sign in once with the command-line client so it stores them, then try again.", + "arguments": [] + } + }, + { + "id": "native.baseRevision", + "description": "Native UI: Base revision", + "platforms": [ + "macos" + ], + "macos": { + "key": "Base revision", + "arguments": [] + } + }, + { + "id": "native.binaryFilesCannotBeCompared", + "description": "Native UI: Binary files cannot be compared.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Binary files cannot be compared.", + "arguments": [] + } + }, + { + "id": "native.cleanUpWorkingCopy", + "description": "Native UI: Clean Up Working Copy", + "platforms": [ + "macos" + ], + "macos": { + "key": "Clean Up Working Copy", + "arguments": [] + } + }, + { + "id": "native.cleaningUp", + "description": "Native UI: Cleaning up…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Cleaning up…", + "arguments": [] + } + }, + { + "id": "native.clientArg1", + "description": "Native UI: Client: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Client: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.committed2", + "description": "Native UI: Committed.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Committed.", + "arguments": [] + } + }, + { + "id": "native.committedRevisionArg1", + "description": "Native UI: Committed revision %@.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Committed revision %@.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.committing", + "description": "Native UI: Committing…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Committing…", + "arguments": [] + } + }, + { + "id": "native.detectAgain2", + "description": "Native UI: Detect Again", + "platforms": [ + "macos" + ], + "macos": { + "key": "Detect Again", + "arguments": [] + } + }, + { + "id": "native.detectingWorkingCopies", + "description": "Native UI: Detecting working copies…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Detecting working copies…", + "arguments": [] + } + }, + { + "id": "native.historyOfArg1", + "description": "Native UI: History of %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "History of %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.ignored", + "description": "Native UI: Ignored", + "platforms": [ + "macos" + ], + "macos": { + "key": "Ignored", + "arguments": [] + } + }, + { + "id": "native.litheCouldNotEvaluateTheVersionControlDataArg1", + "description": "Native UI: Lithe could not evaluate the version control data: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Lithe could not evaluate the version control data: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.localEditsToTheseFilesAreLost", + "description": "Native UI: Local edits to these files are lost.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Local edits to these files are lost.", + "arguments": [] + } + }, + { + "id": "native.lockedByAnotherUser", + "description": "Native UI: Locked by another user", + "platforms": [ + "macos" + ], + "macos": { + "key": "Locked by another user", + "arguments": [] + } + }, + { + "id": "native.lockedInThisWorkingCopy", + "description": "Native UI: Locked in this working copy", + "platforms": [ + "macos" + ], + "macos": { + "key": "Locked in this working copy", + "arguments": [] + } + }, + { + "id": "native.markAsResolved", + "description": "Native UI: Mark as Resolved", + "platforms": [ + "macos" + ], + "macos": { + "key": "Mark as Resolved", + "arguments": [] + } + }, + { + "id": "native.markingAsResolved", + "description": "Native UI: Marking as resolved…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Marking as resolved…", + "arguments": [] + } + }, + { + "id": "native.mayConnectToTheRepositoryServer", + "description": "Native UI: May connect to the repository server", + "platforms": [ + "macos" + ], + "macos": { + "key": "May connect to the repository server", + "arguments": [] + } + }, + { + "id": "native.missing", + "description": "Native UI: Missing", + "platforms": [ + "macos" + ], + "macos": { + "key": "Missing", + "arguments": [] + } + }, + { + "id": "native.moreActions2", + "description": "Native UI: More Actions", + "platforms": [ + "macos" + ], + "macos": { + "key": "More Actions", + "arguments": [] + } + }, + { + "id": "native.morePathsChangedThanAreShown", + "description": "Native UI: More paths changed than are shown.", + "platforms": [ + "macos" + ], + "macos": { + "key": "More paths changed than are shown.", + "arguments": [] + } + }, + { + "id": "native.movedFromArg1", + "description": "Native UI: Moved from %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Moved from %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.moving", + "description": "Native UI: Moving…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Moving…", + "arguments": [] + } + }, + { + "id": "native.noArg1WorkingCopyWasDetectedInThisWorkspace", + "description": "Native UI: No %@ working copy was detected in this workspace.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No %@ working copy was detected in this workspace.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.noRevisions", + "description": "Native UI: No revisions", + "platforms": [ + "macos" + ], + "macos": { + "key": "No revisions", + "arguments": [] + } + }, + { + "id": "native.openFile2", + "description": "Native UI: Open File", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open File", + "arguments": [] + } + }, + { + "id": "native.propertiesModified", + "description": "Native UI: Properties modified", + "platforms": [ + "macos" + ], + "macos": { + "key": "Properties modified", + "arguments": [] + } + }, + { + "id": "native.readingChanges", + "description": "Native UI: Reading changes…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reading changes…", + "arguments": [] + } + }, + { + "id": "native.replaced", + "description": "Native UI: Replaced", + "platforms": [ + "macos" + ], + "macos": { + "key": "Replaced", + "arguments": [] + } + }, + { + "id": "native.restartLitheToUseArg1", + "description": "Native UI: Restart Lithe to use %@.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Restart Lithe to use %@.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.rollBackTheSelectedChanges", + "description": "Native UI: Roll back the selected changes?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Roll back the selected changes?", + "arguments": [] + } + }, + { + "id": "native.rollingBack", + "description": "Native UI: Rolling back…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Rolling back…", + "arguments": [] + } + }, + { + "id": "native.showAll", + "description": "Native UI: Show All", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show All", + "arguments": [] + } + }, + { + "id": "native.showArg1Diff", + "description": "Native UI: Show %@ Diff", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show %@ Diff", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.showHistory", + "description": "Native UI: Show History", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show History", + "arguments": [] + } + }, + { + "id": "native.switched", + "description": "Native UI: Switched", + "platforms": [ + "macos" + ], + "macos": { + "key": "Switched", + "arguments": [] + } + }, + { + "id": "native.theActionStoppedOnConflictedFilesResolveThemThenTryAgain", + "description": "Native UI: The action stopped on conflicted files. Resolve them, then try again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The action stopped on conflicted files. Resolve them, then try again.", + "arguments": [] + } + }, + { + "id": "native.theFileIsTooLargeToCompare", + "description": "Native UI: The file is too large to compare.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The file is too large to compare.", + "arguments": [] + } + }, + { + "id": "native.theVersionControlClientCouldNotBeRunCheckThatItIsInstalled", + "description": "Native UI: The version control client could not be run. Check that it is installed.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The version control client could not be run. Check that it is installed.", + "arguments": [] + } + }, + { + "id": "native.theVersionControlProviderFailedToStart", + "description": "Native UI: The version control provider failed to start.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The version control provider failed to start.", + "arguments": [] + } + }, + { + "id": "native.theVersionControlProviderStoppedBeforeItWasReady", + "description": "Native UI: The version control provider stopped before it was ready.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The version control provider stopped before it was ready.", + "arguments": [] + } + }, + { + "id": "native.theWorkingCopyIsLockedByAnInterruptedActionRunCleanupThenTryAgain", + "description": "Native UI: The working copy is locked by an interrupted action. Run Cleanup, then try again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The working copy is locked by an interrupted action. Run Cleanup, then try again.", + "arguments": [] + } + }, + { + "id": "native.theWorkingCopyIsOutOfDateUpdateItThenCommitAgain", + "description": "Native UI: The working copy is out of date. Update it, then commit again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The working copy is out of date. Update it, then commit again.", + "arguments": [] + } + }, + { + "id": "native.theWorkingCopyWasCleanedUp", + "description": "Native UI: The working copy was cleaned up.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The working copy was cleaned up.", + "arguments": [] + } + }, + { + "id": "native.theWorkspace", + "description": "Native UI: The workspace", + "platforms": [ + "macos" + ], + "macos": { + "key": "The workspace", + "arguments": [] + } + }, + { + "id": "native.theWorkspaceIsTooLargeToSearchCompletelySomeWorkingCopiesMayBeMissing", + "description": "Native UI: The workspace is too large to search completely; some working copies may be missing.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The workspace is too large to search completely; some working copies may be missing.", + "arguments": [] + } + }, + { + "id": "native.thereAreTooManyChangesToListSomeAreNotShown", + "description": "Native UI: There are too many changes to list; some are not shown.", + "platforms": [ + "macos" + ], + "macos": { + "key": "There are too many changes to list; some are not shown.", + "arguments": [] + } + }, + { + "id": "native.thisVersionControlProviderDoesNotSupportThisAction", + "description": "Native UI: This version control provider does not support this action.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This version control provider does not support this action.", + "arguments": [] + } + }, + { + "id": "native.thisWorkingCopyFormatIsNotSupportedUpgradeItWithTheCommandLineClient", + "description": "Native UI: This working copy format is not supported. Upgrade it with the command-line client.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This working copy format is not supported. Upgrade it with the command-line client.", + "arguments": [] + } + }, + { + "id": "native.thisWorkingCopyIsNoLongerAvailableRefreshAndTryAgain", + "description": "Native UI: This working copy is no longer available. Refresh and try again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This working copy is no longer available. Refresh and try again.", + "arguments": [] + } + }, + { + "id": "native.treeConflict", + "description": "Native UI: Tree conflict", + "platforms": [ + "macos" + ], + "macos": { + "key": "Tree conflict", + "arguments": [] + } + }, + { + "id": "native.typeChanged", + "description": "Native UI: Type changed", + "platforms": [ + "macos" + ], + "macos": { + "key": "Type changed", + "arguments": [] + } + }, + { + "id": "native.unversioned", + "description": "Native UI: Unversioned", + "platforms": [ + "macos" + ], + "macos": { + "key": "Unversioned", + "arguments": [] + } + }, + { + "id": "native.updated2", + "description": "Native UI: Updated.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Updated.", + "arguments": [] + } + }, + { + "id": "native.updatedToRevisionArg1", + "description": "Native UI: Updated to revision %@.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Updated to revision %@.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.updating", + "description": "Native UI: Updating…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Updating…", + "arguments": [] + } + }, + { + "id": "native.versionControlProvider", + "description": "Native UI: Version control provider", + "platforms": [ + "macos" + ], + "macos": { + "key": "Version control provider", + "arguments": [] + } + }, + { + "id": "native.waitForTheRunningVersionControlActionToFinish", + "description": "Native UI: Wait for the running version control action to finish.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Wait for the running version control action to finish.", + "arguments": [] + } + }, + { + "id": "native.workingCopy", + "description": "Native UI: Working copy", + "platforms": [ + "macos" + ], + "macos": { + "key": "Working copy", + "arguments": [] + } + }, + { + "id": "hooks.add", + "description": "Windows UI: hooks.add", + "platforms": [ + "windows" + ] + }, + { + "id": "hooks.agents", + "description": "Windows UI: hooks.agents", + "platforms": [ + "windows" + ] + }, + { + "id": "hooks.allAgents", + "description": "Windows UI: hooks.allAgents", + "platforms": [ + "windows" + ] + }, + { + "id": "hooks.codexApproval", + "description": "Windows UI: hooks.codexApproval", + "platforms": [ + "windows" + ] + }, + { + "id": "hooks.command", + "description": "Windows UI: hooks.command", + "platforms": [ + "windows" + ] + }, + { + "id": "hooks.commandHint", + "description": "Windows UI: hooks.commandHint", + "platforms": [ + "windows" + ] + }, + { + "id": "hooks.delete", + "description": "Windows UI: hooks.delete", + "platforms": [ + "windows" + ] + }, + { + "id": "hooks.deleteHint", + "description": "Windows UI: hooks.deleteHint", + "platforms": [ + "windows" + ] + }, + { + "id": "hooks.deleteTitle", + "description": "Windows UI: hooks.deleteTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "hooks.description", + "description": "Windows UI: hooks.description", + "platforms": [ + "windows" + ] + }, + { + "id": "hooks.descriptionHint", + "description": "Windows UI: hooks.descriptionHint", + "platforms": [ + "windows" + ] + }, + { + "id": "hooks.descriptionLabel", + "description": "Windows UI: hooks.descriptionLabel", + "platforms": [ + "windows" + ] + }, + { + "id": "hooks.descriptionPlaceholder", + "description": "Windows UI: hooks.descriptionPlaceholder", + "platforms": [ + "windows" + ] + }, + { + "id": "hooks.disabledAll", + "description": "Windows UI: hooks.disabledAll", + "platforms": [ + "windows" + ] + }, + { + "id": "hooks.edit", + "description": "Windows UI: hooks.edit", + "platforms": [ + "windows" + ] + }, + { + "id": "hooks.editTitle", + "description": "Windows UI: hooks.editTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "hooks.editor", + "description": "Windows UI: hooks.editor", + "platforms": [ + "windows" + ] + }, + { + "id": "hooks.empty", + "description": "Windows UI: hooks.empty", + "platforms": [ + "windows" + ] + }, + { + "id": "hooks.enabled", + "description": "Windows UI: hooks.enabled", + "platforms": [ + "windows" + ] + }, + { + "id": "hooks.event.prompt", + "description": "Windows UI: hooks.event.prompt", + "platforms": [ + "windows" + ] + }, + { + "id": "hooks.intro", + "description": "Windows UI: hooks.intro", + "platforms": [ + "windows" + ] + }, + { + "id": "hooks.matcher", + "description": "Windows UI: hooks.matcher", + "platforms": [ + "windows" + ] + }, + { + "id": "hooks.matcher.all", + "description": "Windows UI: hooks.matcher.all", + "platforms": [ + "windows" + ] + }, + { + "id": "hooks.matcher.edit", + "description": "Windows UI: hooks.matcher.edit", + "platforms": [ + "windows" + ] + }, + { + "id": "hooks.matcher.shell", + "description": "Windows UI: hooks.matcher.shell", + "platforms": [ + "windows" + ] + }, + { + "id": "hooks.name", + "description": "Windows UI: hooks.name", + "platforms": [ + "windows" + ] + }, + { + "id": "hooks.nameHint", + "description": "Windows UI: hooks.nameHint", + "platforms": [ + "windows" + ] + }, + { + "id": "hooks.namePlaceholder", + "description": "Windows UI: hooks.namePlaceholder", + "platforms": [ + "windows" + ] + }, + { + "id": "hooks.needsSync", + "description": "Windows UI: hooks.needsSync", + "platforms": [ + "windows" + ] + }, + { + "id": "hooks.newTitle", + "description": "Windows UI: hooks.newTitle", + "platforms": [ + "windows" + ] + }, + { + "id": "hooks.pendingRemoval", + "description": "Windows UI: hooks.pendingRemoval", + "platforms": [ + "windows" + ] + }, + { + "id": "hooks.reload", + "description": "Windows UI: hooks.reload", + "platforms": [ + "windows" + ] + }, + { + "id": "hooks.retry", + "description": "Windows UI: hooks.retry", + "platforms": [ + "windows" + ] + }, + { + "id": "hooks.save", + "description": "Windows UI: hooks.save", + "platforms": [ + "windows" + ] + }, + { + "id": "hooks.search", + "description": "Windows UI: hooks.search", + "platforms": [ + "windows" + ] + }, + { + "id": "hooks.timeout", + "description": "Windows UI: hooks.timeout", + "platforms": [ + "windows" + ] + }, + { + "id": "hooks.timeoutHint", + "description": "Windows UI: hooks.timeoutHint", + "platforms": [ + "windows" + ] + }, + { + "id": "hooks.timeoutPlaceholder", + "description": "Windows UI: hooks.timeoutPlaceholder", + "platforms": [ + "windows" + ] + }, + { + "id": "hooks.when", + "description": "Windows UI: hooks.when", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.cursorRedo.description", + "description": "Windows UI: keybindings.commands.editor.cursorRedo.description", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.cursorRedo.title", + "description": "Windows UI: keybindings.commands.editor.cursorRedo.title", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.cursorUndo.description", + "description": "Windows UI: keybindings.commands.editor.cursorUndo.description", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.commands.editor.cursorUndo.title", + "description": "Windows UI: keybindings.commands.editor.cursorUndo.title", + "platforms": [ + "windows" + ] + }, + { + "id": "native.aBasicProjectWithAFreeStructure", + "description": "Native UI: A basic project with a free structure.", + "platforms": [ + "macos" + ], + "macos": { + "key": "A basic project with a free structure.", + "arguments": [] + } + }, + { + "id": "native.aHookWithThisNameAlreadyExists", + "description": "Native UI: A Hook with this name already exists.", + "platforms": [ + "macos" + ], + "macos": { + "key": "A Hook with this name already exists.", + "arguments": [] + } + }, + { + "id": "native.aHostNameAnIPAddressOrAHostAliasFromSshConfigSettingsFromThatFileSuchAsProxyJumpAndIdentit", + "description": "Native UI: A host name, an IP address, or a Host alias from ~/.ssh/config. Settings from that file, such as ProxyJump and IdentityFile, still apply.", + "platforms": [ + "macos" + ], + "macos": { + "key": "A host name, an IP address, or a Host alias from ~/.ssh/config. Settings from that file, such as ProxyJump and IdentityFile, still apply.", + "arguments": [] + } + }, + { + "id": "native.aJavaProjectWithNoBuildToolMavenOrGradle", + "description": "Native UI: A Java project with no build tool, Maven, or Gradle.", + "platforms": [ + "macos" + ], + "macos": { + "key": "A Java project with no build tool, Maven, or Gradle.", + "arguments": [] + } + }, + { + "id": "native.aManagedHookWasEditedOutsideLitheRestoreOrRemoveThatNativeEntryBeforeSynchronizing", + "description": "Native UI: A managed Hook was edited outside Lithe. Restore or remove that native entry before synchronizing.", + "platforms": [ + "macos" + ], + "macos": { + "key": "A managed Hook was edited outside Lithe. Restore or remove that native entry before synchronizing.", + "arguments": [] + } + }, + { + "id": "native.aProjectGeneratedByMavenFromAnArchetype", + "description": "Native UI: A project generated by Maven from an archetype.", + "platforms": [ + "macos" + ], + "macos": { + "key": "A project generated by Maven from an archetype.", + "arguments": [] + } + }, + { + "id": "native.aRemovedHookCannotBeSaved", + "description": "Native UI: A removed Hook cannot be saved.", + "platforms": [ + "macos" + ], + "macos": { + "key": "A removed Hook cannot be saved.", + "arguments": [] + } + }, + { + "id": "native.aSpringBootProjectFromSpringInitializr", + "description": "Native UI: A Spring Boot project from Spring Initializr.", + "platforms": [ + "macos" + ], + "macos": { + "key": "A Spring Boot project from Spring Initializr.", + "arguments": [] + } + }, + { + "id": "native.acceptEdits", + "description": "Native UI: Accept edits", + "platforms": [ + "macos" + ], + "macos": { + "key": "Accept edits", + "arguments": [] + } + }, + { + "id": "native.addHook", + "description": "Native UI: Add Hook", + "platforms": [ + "macos" + ], + "macos": { + "key": "Add Hook", + "arguments": [] + } + }, + { + "id": "native.addSampleCode", + "description": "Native UI: Add sample code", + "platforms": [ + "macos" + ], + "macos": { + "key": "Add sample code", + "arguments": [] + } + }, + { + "id": "native.address", + "description": "Native UI: Address", + "platforms": [ + "macos" + ], + "macos": { + "key": "Address", + "arguments": [] + } + }, + { + "id": "native.advancedSettings", + "description": "Native UI: Advanced Settings", + "platforms": [ + "macos" + ], + "macos": { + "key": "Advanced Settings", + "arguments": [] + } + }, + { + "id": "native.afterAToolRuns", + "description": "Native UI: After a tool runs", + "platforms": [ + "macos" + ], + "macos": { + "key": "After a tool runs", + "arguments": [] + } + }, + { + "id": "native.agentDefault", + "description": "Native UI: Agent default", + "platforms": [ + "macos" + ], + "macos": { + "key": "Agent default", + "arguments": [] + } + }, + { + "id": "native.agentHooks", + "description": "Native UI: Agent hooks", + "platforms": [ + "macos" + ], + "macos": { + "key": "Agent hooks", + "arguments": [] + } + }, + { + "id": "native.allTools", + "description": "Native UI: All tools", + "platforms": [ + "macos" + ], + "macos": { + "key": "All tools", + "arguments": [] + } + }, + { + "id": "native.alreadyExistsChooseAnotherNameOrLocation", + "description": "Native UI: %@ already exists. Choose another name or location.", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ already exists. Choose another name or location.", + "arguments": [ + "arg1" + ], + "formats": { + "arg1": "@" + } + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.anIdenticalNativeHookAlreadyExistsLitheKeptItUnchanged", + "description": "Native UI: An identical native Hook already exists. Lithe kept it unchanged.", + "platforms": [ + "macos" + ], + "macos": { + "key": "An identical native Hook already exists. Lithe kept it unchanged.", + "arguments": [] + } + }, + { + "id": "native.anotherHookUpdateIsRunningTryAgain", + "description": "Native UI: Another Hook update is running. Try again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Another Hook update is running. Try again.", + "arguments": [] + } + }, + { + "id": "native.answeredWithStatusLld", + "description": "Native UI: %@ answered with status %lld. %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ answered with status %lld. %@", + "arguments": [ + "arg1", + "arg2", + "arg3" + ], + "formats": { + "arg1": "@", + "arg2": "lld", + "arg3": "@" + } + }, + "parameters": { + "arg1": "string", + "arg2": "integer", + "arg3": "string" + } + }, + { + "id": "native.archetype", + "description": "Native UI: Archetype", + "platforms": [ + "macos" + ], + "macos": { + "key": "Archetype", + "arguments": [] + } + }, + { + "id": "native.archetypeArtifactId", + "description": "Native UI: Archetype ArtifactId", + "platforms": [ + "macos" + ], + "macos": { + "key": "Archetype ArtifactId", + "arguments": [] + } + }, + { + "id": "native.archetypeGroupId", + "description": "Native UI: Archetype GroupId", + "platforms": [ + "macos" + ], + "macos": { + "key": "Archetype GroupId", + "arguments": [] + } + }, + { + "id": "native.archetypeVersion", + "description": "Native UI: Archetype version", + "platforms": [ + "macos" + ], + "macos": { + "key": "Archetype version", + "arguments": [] + } + }, + { + "id": "native.archiveConversation", + "description": "Native UI: Archive conversation", + "platforms": [ + "macos" + ], + "macos": { + "key": "Archive conversation", + "arguments": [] + } + }, + { + "id": "native.artifact", + "description": "Native UI: Artifact", + "platforms": [ + "macos" + ], + "macos": { + "key": "Artifact", + "arguments": [] + } + }, + { + "id": "native.asOfUpdatedOnlyWhileReplying", + "description": "Native UI: As of %@; updated only while replying.", + "platforms": [ + "macos" + ], + "macos": { + "key": "As of %@; updated only while replying.", + "arguments": [ + "arg1" + ], + "formats": { + "arg1": "@" + } + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.authentication", + "description": "Native UI: Authentication", + "platforms": [ + "macos" + ], + "macos": { + "key": "Authentication", + "arguments": [] + } + }, + { + "id": "native.auto", + "description": "Native UI: Auto", + "platforms": [ + "macos" + ], + "macos": { + "key": "Auto", + "arguments": [] + } + }, + { + "id": "native.automaticTriesYourSSHAgentAndKeysFirstThenAsksForAPasswordLitheAsksInADialogWheneverTheSer", + "description": "Native UI: Automatic tries your SSH agent and keys first, then asks for a password. Lithe asks in a dialog whenever the server needs an answer, such as a password, a passphrase, or a verification code.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Automatic tries your SSH agent and keys first, then asks for a password. Lithe asks in a dialog whenever the server needs an answer, such as a password, a passphrase, or a verification code.", + "arguments": [] + } + }, + { + "id": "native.backgroundTaskEnded", + "description": "Native UI: Background task ended", + "platforms": [ + "macos" + ], + "macos": { + "key": "Background task ended", + "arguments": [] + } + }, + { + "id": "native.backgroundTaskFinished", + "description": "Native UI: Background task finished", + "platforms": [ + "macos" + ], + "macos": { + "key": "Background task finished", + "arguments": [] + } + }, + { + "id": "native.beforeAToolRuns", + "description": "Native UI: Before a tool runs", + "platforms": [ + "macos" + ], + "macos": { + "key": "Before a tool runs", + "arguments": [] + } + }, + { + "id": "native.browse", + "description": "Native UI: Browse…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Browse…", + "arguments": [] + } + }, + { + "id": "native.buildSystemX797f5735e5", + "description": "Native UI: Build system", + "platforms": [ + "macos" + ], + "macos": { + "key": "Build system", + "arguments": [] + } + }, + { + "id": "native.bypassPermissions", + "description": "Native UI: Bypass permissions", + "platforms": [ + "macos" + ], + "macos": { + "key": "Bypass permissions", + "arguments": [] + } + }, + { + "id": "native.cannotAccessHookConfigurationCheckTheFilePermissionsAndAgentProfile", + "description": "Native UI: Cannot access Hook configuration. Check the file permissions and Agent profile.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Cannot access Hook configuration. Check the file permissions and Agent profile.", + "arguments": [] + } + }, + { + "id": "native.cannotAccessTheHookLibraryCheckItsPermissions", + "description": "Native UI: Cannot access the Hook library. Check its permissions.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Cannot access the Hook library. Check its permissions.", + "arguments": [] + } + }, + { + "id": "native.cannotEncodeHookConfiguration", + "description": "Native UI: Cannot encode Hook configuration.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Cannot encode Hook configuration.", + "arguments": [] + } + }, + { + "id": "native.cannotEncodeTheHookLibrary", + "description": "Native UI: Cannot encode the Hook library.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Cannot encode the Hook library.", + "arguments": [] + } + }, + { + "id": "native.checkTheHookNameCommandEventAgentsAndTimeout186400Seconds", + "description": "Native UI: Check the Hook name, command, event, Agents and timeout (1–86400 seconds).", + "platforms": [ + "macos" + ], + "macos": { + "key": "Check the Hook name, command, event, Agents and timeout (1–86400 seconds).", + "arguments": [] + } + }, + { + "id": "native.checked", + "description": "Native UI: Checked %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Checked %@", + "arguments": [ + "arg1" + ], + "formats": { + "arg1": "@" + } + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.chooseAPrivateKeyFile", + "description": "Native UI: Choose a private key file.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose a private key file.", + "arguments": [] + } + }, + { + "id": "native.chooseOneOfTheOfferedValues", + "description": "Native UI: Choose one of the offered values.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose one of the offered values.", + "arguments": [] + } + }, + { + "id": "native.chooseRemoteFolder", + "description": "Native UI: Choose Remote Folder", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose Remote Folder", + "arguments": [] + } + }, + { + "id": "native.chooseThisFolder", + "description": "Native UI: Choose This Folder", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose This Folder", + "arguments": [] + } + }, + { + "id": "native.claudeCodeHasDisabledAllHooksInItsSettings", + "description": "Native UI: Claude Code has disabled all Hooks in its settings.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Claude Code has disabled all Hooks in its settings.", + "arguments": [] + } + }, + { + "id": "native.claudeCodeIsNotSignedInRunClaudeInATerminalSignInWithLoginThenRetry", + "description": "Native UI: Claude Code is not signed in. Run `claude` in a terminal, sign in with /login, then retry.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Claude Code is not signed in. Run `claude` in a terminal, sign in with /login, then retry.", + "arguments": [] + } + }, + { + "id": "native.claudeSubscription", + "description": "Native UI: Claude subscription", + "platforms": [ + "macos" + ], + "macos": { + "key": "Claude subscription", + "arguments": [] + } + }, + { + "id": "native.claudeSubscriptionQuota", + "description": "Native UI: Claude subscription quota", + "platforms": [ + "macos" + ], + "macos": { + "key": "Claude subscription quota", + "arguments": [] + } + }, + { + "id": "native.codexHookApproval", + "description": "Native UI: Codex Hook approval", + "platforms": [ + "macos" + ], + "macos": { + "key": "Codex Hook approval", + "arguments": [] + } + }, + { + "id": "native.codexRequiresHooksToBeEnabledAndCommandsToBeTrustedInItsNativeHooksScreenLitheSavesConfigu", + "description": "Native UI: Codex requires Hooks to be enabled and commands to be trusted in its native /hooks screen. Lithe saves configuration without granting trust. Editing a command requires approval again. A configured Hook is not proof it ran.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Codex requires Hooks to be enabled and commands to be trusted in its native /hooks screen. Lithe saves configuration without granting trust. Editing a command requires approval again. A configured Hook is not proof it ran.", + "arguments": [] + } + }, + { + "id": "native.connectedButLitheCannotRunOnThisPlatformYetLinuxOnX8664OrARM64IsSupported", + "description": "Native UI: Connected, but Lithe cannot run on this platform yet. Linux on x86_64 or ARM64 is supported.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Connected, but Lithe cannot run on this platform yet. Linux on x86_64 or ARM64 is supported.", + "arguments": [] + } + }, + { + "id": "native.connectedThisHostCanBeUsedForRemoteDevelopment", + "description": "Native UI: Connected. This host can be used for remote development.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Connected. This host can be used for remote development.", + "arguments": [] + } + }, + { + "id": "native.connecting", + "description": "Native UI: Connecting…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Connecting…", + "arguments": [] + } + }, + { + "id": "native.connection", + "description": "Native UI: Connection", + "platforms": [ + "macos" + ], + "macos": { + "key": "Connection", + "arguments": [] + } + }, + { + "id": "native.connectionTimeout", + "description": "Native UI: Connection timeout", + "platforms": [ + "macos" + ], + "macos": { + "key": "Connection timeout", + "arguments": [] + } + }, + { + "id": "native.conversationActions", + "description": "Native UI: Conversation actions", + "platforms": [ + "macos" + ], + "macos": { + "key": "Conversation actions", + "arguments": [] + } + }, + { + "id": "native.conversationContinuedFromASummary", + "description": "Native UI: Conversation continued from a summary", + "platforms": [ + "macos" + ], + "macos": { + "key": "Conversation continued from a summary", + "arguments": [] + } + }, + { + "id": "native.couldNotOpenTheRemoteFolder", + "description": "Native UI: Could not open the remote folder: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Could not open the remote folder: %@", + "arguments": [ + "arg1" + ], + "formats": { + "arg1": "@" + } + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.couldNotSaveTheConnections", + "description": "Native UI: Could not save the connections: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Could not save the connections: %@", + "arguments": [ + "arg1" + ], + "formats": { + "arg1": "@" + } + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.createGitRepository", + "description": "Native UI: Create Git repository", + "platforms": [ + "macos" + ], + "macos": { + "key": "Create Git repository", + "arguments": [] + } + }, + { + "id": "native.createsProjects", + "description": "Native UI: Creates %@ projects", + "platforms": [ + "macos" + ], + "macos": { + "key": "Creates %@ projects", + "arguments": [ + "arg1" + ], + "formats": { + "arg1": "@" + } + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.creatingProject", + "description": "Native UI: Creating project…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Creating project…", + "arguments": [] + } + }, + { + "id": "native.creatingThisProjectDownloadsFilesFromTheInternet", + "description": "Native UI: Creating this project downloads files from the internet.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Creating this project downloads files from the internet.", + "arguments": [] + } + }, + { + "id": "native.customArchetype", + "description": "Native UI: Custom archetype…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Custom archetype…", + "arguments": [] + } + }, + { + "id": "native.dMinutesAgo", + "description": "Native UI: %d minutes ago", + "platforms": [ + "macos" + ], + "macos": { + "key": "%d minutes ago", + "arguments": [ + "arg1" + ], + "formats": { + "arg1": "d" + } + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.deleteChatX93291d9c41", + "description": "Native UI: Delete chat", + "platforms": [ + "macos" + ], + "macos": { + "key": "Delete chat", + "arguments": [] + } + }, + { + "id": "native.deleteHook", + "description": "Native UI: Delete Hook?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Delete Hook?", + "arguments": [] + } + }, + { + "id": "native.didNotFinishWithinLldSeconds", + "description": "Native UI: %@ did not finish within %lld seconds.", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ did not finish within %lld seconds.", + "arguments": [ + "arg1", + "arg2" + ], + "formats": { + "arg1": "@", + "arg2": "lld" + } + }, + "parameters": { + "arg1": "string", + "arg2": "integer" + } + }, + { + "id": "native.directories", + "description": "Native UI: Directories", + "platforms": [ + "macos" + ], + "macos": { + "key": "Directories", + "arguments": [] + } + }, + { + "id": "native.disk", + "description": "Native UI: Disk", + "platforms": [ + "macos" + ], + "macos": { + "key": "Disk", + "arguments": [] + } + }, + { + "id": "native.earlier", + "description": "Native UI: Earlier", + "platforms": [ + "macos" + ], + "macos": { + "key": "Earlier", + "arguments": [] + } + }, + { + "id": "native.editHook", + "description": "Native UI: Edit Hook", + "platforms": [ + "macos" + ], + "macos": { + "key": "Edit Hook", + "arguments": [] + } + }, + { + "id": "native.emptyProject", + "description": "Native UI: Empty Project", + "platforms": [ + "macos" + ], + "macos": { + "key": "Empty Project", + "arguments": [] + } + }, + { + "id": "native.enableHook", + "description": "Native UI: Enable Hook", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enable Hook", + "arguments": [] + } + }, + { + "id": "native.enlargeImage", + "description": "Native UI: Enlarge image", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enlarge image", + "arguments": [] + } + }, + { + "id": "native.enterAHost", + "description": "Native UI: Enter a host.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enter a host.", + "arguments": [] + } + }, + { + "id": "native.enterAValue", + "description": "Native UI: Enter a value.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enter a value.", + "arguments": [] + } + }, + { + "id": "native.enterAnAbsolutePathThatStartsWith", + "description": "Native UI: Enter an absolute path that starts with /.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enter an absolute path that starts with /.", + "arguments": [] + } + }, + { + "id": "native.enterTheHostWithoutAUserNameOrSpacesPutTheUserInUserName", + "description": "Native UI: Enter the host without a user name or spaces; put the user in User name.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enter the host without a user name or spaces; put the user in User name.", + "arguments": [] + } + }, + { + "id": "native.exitedWithStatusD", + "description": "Native UI: %@ exited with status %d.\n%@", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ exited with status %d.\n%@", + "arguments": [ + "arg1", + "arg2", + "arg3" + ], + "formats": { + "arg1": "@", + "arg2": "d", + "arg3": "@" + } + }, + "parameters": { + "arg1": "string", + "arg2": "integer", + "arg3": "string" + } + }, + { + "id": "native.fileEdits", + "description": "Native UI: File edits", + "platforms": [ + "macos" + ], + "macos": { + "key": "File edits", + "arguments": [] + } + }, + { + "id": "native.filePatterns", + "description": "Native UI: File patterns", + "platforms": [ + "macos" + ], + "macos": { + "key": "File patterns", + "arguments": [] + } + }, + { + "id": "native.findInMarketplace", + "description": "Native UI: Find in Marketplace", + "platforms": [ + "macos" + ], + "macos": { + "key": "Find in Marketplace", + "arguments": [] + } + }, + { + "id": "native.finishedWithoutCreating", + "description": "Native UI: %@ finished without creating %@.", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ finished without creating %@.", + "arguments": [ + "arg1", + "arg2" + ], + "formats": { + "arg1": "@", + "arg2": "@" + } + }, + "parameters": { + "arg1": "string", + "arg2": "string" + } + }, + { + "id": "native.fitToWindow", + "description": "Native UI: Fit to Window", + "platforms": [ + "macos" + ], + "macos": { + "key": "Fit to Window", + "arguments": [] + } + }, + { + "id": "native.folder", + "description": "Native UI: Folder", + "platforms": [ + "macos" + ], + "macos": { + "key": "Folder", + "arguments": [] + } + }, + { + "id": "native.forExampleNotify", + "description": "Native UI: For example, notify", + "platforms": [ + "macos" + ], + "macos": { + "key": "For example, notify", + "arguments": [] + } + }, + { + "id": "native.forExampleNotifyMeWhenATurnEnds", + "description": "Native UI: For example, notify me when a turn ends", + "platforms": [ + "macos" + ], + "macos": { + "key": "For example, notify me when a turn ends", + "arguments": [] + } + }, + { + "id": "native.forkConversation", + "description": "Native UI: Fork conversation", + "platforms": [ + "macos" + ], + "macos": { + "key": "Fork conversation", + "arguments": [] + } + }, + { + "id": "native.freeOf", + "description": "Native UI: %@ free of %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ free of %@", + "arguments": [ + "arg1", + "arg2" + ], + "formats": { + "arg1": "@", + "arg2": "@" + } + }, + "parameters": { + "arg1": "string", + "arg2": "string" + } + }, + { + "id": "native.generators", + "description": "Native UI: Generators", + "platforms": [ + "macos" + ], + "macos": { + "key": "Generators", + "arguments": [] + } + }, + { + "id": "native.getMoreLanguagesAndFrameworksFromPlugins", + "description": "Native UI: Get more languages and frameworks from plugins…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Get more languages and frameworks from plugins…", + "arguments": [] + } + }, + { + "id": "native.go", + "description": "Native UI: Go", + "platforms": [ + "macos" + ], + "macos": { + "key": "Go", + "arguments": [] + } + }, + { + "id": "native.gradleDSL", + "description": "Native UI: Gradle DSL", + "platforms": [ + "macos" + ], + "macos": { + "key": "Gradle DSL", + "arguments": [] + } + }, + { + "id": "native.group", + "description": "Native UI: Group", + "platforms": [ + "macos" + ], + "macos": { + "key": "Group", + "arguments": [] + } + }, + { + "id": "native.homeDirectory", + "description": "Native UI: Home directory", + "platforms": [ + "macos" + ], + "macos": { + "key": "Home directory", + "arguments": [] + } + }, + { + "id": "native.hook", + "description": "Native UI: Hook", + "platforms": [ + "macos" + ], + "macos": { + "key": "Hook", + "arguments": [] + } + }, + { + "id": "native.hookCommand", + "description": "Native UI: Hook command", + "platforms": [ + "macos" + ], + "macos": { + "key": "Hook command", + "arguments": [] + } + }, + { + "id": "native.hookConfigurationChangedOutsideThisEditorReloadBeforeSaving", + "description": "Native UI: Hook configuration changed outside this editor. Reload before saving.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Hook configuration changed outside this editor. Reload before saving.", + "arguments": [] + } + }, + { + "id": "native.hookConfigurationMustBeARegularFileNotASymbolicLinkOrDirectory", + "description": "Native UI: Hook configuration must be a regular file, not a symbolic link or directory.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Hook configuration must be a regular file, not a symbolic link or directory.", + "arguments": [] + } + }, + { + "id": "native.hookConfigurationMustBeNoLargerThan1MiB", + "description": "Native UI: Hook configuration must be no larger than 1 MiB.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Hook configuration must be no larger than 1 MiB.", + "arguments": [] + } + }, + { + "id": "native.hookConfigurationMustUseUTF8Encoding", + "description": "Native UI: Hook configuration must use UTF-8 encoding.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Hook configuration must use UTF-8 encoding.", + "arguments": [] + } + }, + { + "id": "native.hookDescription", + "description": "Native UI: Hook description", + "platforms": [ + "macos" + ], + "macos": { + "key": "Hook description", + "arguments": [] + } + }, + { + "id": "native.hookName", + "description": "Native UI: Hook name", + "platforms": [ + "macos" + ], + "macos": { + "key": "Hook name", + "arguments": [] + } + }, + { + "id": "native.hookSynchronizationWasCancelled", + "description": "Native UI: Hook synchronization was cancelled.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Hook synchronization was cancelled.", + "arguments": [] + } + }, + { + "id": "native.hooks", + "description": "Native UI: Hooks", + "platforms": [ + "macos" + ], + "macos": { + "key": "Hooks", + "arguments": [] + } + }, + { + "id": "native.hooksRunShellCommandsAtAgentLifecycleEventsTheAgentSuppliesEventJSONOnStandardInputAndRuns", + "description": "Native UI: Hooks run shell commands at Agent lifecycle events. The Agent supplies event JSON on standard input and runs the command in the session folder. Changes apply to new sessions; existing sessions keep their own loaded configuration.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Hooks run shell commands at Agent lifecycle events. The Agent supplies event JSON on standard input and runs the command in the session folder. Changes apply to new sessions; existing sessions keep their own loaded configuration.", + "arguments": [] + } + }, + { + "id": "native.hostName", + "description": "Native UI: Host name", + "platforms": [ + "macos" + ], + "macos": { + "key": "Host name", + "arguments": [] + } + }, + { + "id": "native.httpsGithubComOwnerRepository", + "description": "Native UI: https://github.com/owner/repository", + "platforms": [ + "macos" + ], + "macos": { + "key": "https://github.com/owner/repository", + "arguments": [] + } + }, + { + "id": "native.invalidHookFileGuards", + "description": "Native UI: Invalid Hook file guards.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Invalid Hook file guards.", + "arguments": [] + } + }, + { + "id": "native.isNeededForThisProjectButIsDisabled", + "description": "Native UI: %@ is needed for this project but %@ is disabled.", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ is needed for this project but %@ is disabled.", + "arguments": [ + "arg1", + "arg2" + ], + "formats": { + "arg1": "@", + "arg2": "@" + } + }, + "parameters": { + "arg1": "string", + "arg2": "string" + } + }, + { + "id": "native.itemLldOfLld", + "description": "Native UI: Item %lld of %lld", + "platforms": [ + "macos" + ], + "macos": { + "key": "Item %lld of %lld", + "arguments": [ + "arg1", + "arg2" + ] + }, + "parameters": { + "arg1": "integer", + "arg2": "integer" + } + }, + { + "id": "native.keepAliveInterval", + "description": "Native UI: Keep-alive interval", + "platforms": [ + "macos" + ], + "macos": { + "key": "Keep-alive interval", + "arguments": [] + } + }, + { + "id": "native.kernel", + "description": "Native UI: Kernel", + "platforms": [ + "macos" + ], + "macos": { + "key": "Kernel", + "arguments": [] + } + }, + { + "id": "native.latest", + "description": "Native UI: Latest", + "platforms": [ + "macos" + ], + "macos": { + "key": "Latest", + "arguments": [] + } + }, + { + "id": "native.leaveEmptyToUseTheAgentDefaultOrEnterAnIntegerFrom1To86400", + "description": "Native UI: Leave empty to use the Agent default, or enter an integer from 1 to 86400.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Leave empty to use the Agent default, or enter an integer from 1 to 86400.", + "arguments": [] + } + }, + { + "id": "native.leaveEmptyToUseTheLatestVersionMavenFindsInItsCatalog", + "description": "Native UI: Leave empty to use the latest version Maven finds in its catalog.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Leave empty to use the latest version Maven finds in its catalog.", + "arguments": [] + } + }, + { + "id": "native.leaveEmptyToUseThePortFromSshConfigOr22", + "description": "Native UI: Leave empty to use the port from ~/.ssh/config, or 22.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Leave empty to use the port from ~/.ssh/config, or 22.", + "arguments": [] + } + }, + { + "id": "native.leaveEmptyToUseTheUserFromSshConfigOrYourLocalUserName", + "description": "Native UI: Leave empty to use the user from ~/.ssh/config, or your local user name.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Leave empty to use the user from ~/.ssh/config, or your local user name.", + "arguments": [] + } + }, + { + "id": "native.leaveEmptyToUseTheVersionTheServerRecommends", + "description": "Native UI: Leave empty to use the version the server recommends.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Leave empty to use the version the server recommends.", + "arguments": [] + } + }, + { + "id": "native.litheCouldNotFindInstallItOrAddItToPATHThenTryAgain", + "description": "Native UI: Lithe could not find %@. Install it or add it to PATH, then try again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Lithe could not find %@. Install it or add it to PATH, then try again.", + "arguments": [ + "arg1" + ], + "formats": { + "arg1": "@" + } + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.litheWillCloneTheRepositoryInto", + "description": "Native UI: Lithe will clone the repository into %@.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Lithe will clone the repository into %@.", + "arguments": [ + "arg1" + ], + "formats": { + "arg1": "@" + } + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.lldCPUCores", + "description": "Native UI: %lld CPU cores", + "platforms": [ + "macos" + ], + "macos": { + "key": "%lld CPU cores", + "arguments": [ + "arg1" + ], + "formats": { + "arg1": "lld" + } + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.location", + "description": "Native UI: Location", + "platforms": [ + "macos" + ], + "macos": { + "key": "Location", + "arguments": [] + } + }, + { + "id": "native.lookUpAgain", + "description": "Native UI: Look Up Again", + "platforms": [ + "macos" + ], + "macos": { + "key": "Look Up Again", + "arguments": [] + } + }, + { + "id": "native.lookUpLocation", + "description": "Native UI: Look Up Location", + "platforms": [ + "macos" + ], + "macos": { + "key": "Look Up Location", + "arguments": [] + } + }, + { + "id": "native.lookingForInstalledJDKs", + "description": "Native UI: Looking for installed JDKs…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Looking for installed JDKs…", + "arguments": [] + } + }, + { + "id": "native.lookingUpTheLocationSendsThisAddressToIpinfoIoLitheDoesThisOnlyWhenYouClickLookUp", + "description": "Native UI: Looking up the location sends this address to ipinfo.io. Lithe does this only when you click Look Up.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Looking up the location sends this address to ipinfo.io. Lithe does this only when you click Look Up.", + "arguments": [] + } + }, + { + "id": "native.manual", + "description": "Native UI: Manual", + "platforms": [ + "macos" + ], + "macos": { + "key": "Manual", + "arguments": [] + } + }, + { + "id": "native.mavenRunsWithThisJDKAndTheNewProjectUsesIt", + "description": "Native UI: Maven runs with this JDK, and the new project uses it.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Maven runs with this JDK, and the new project uses it.", + "arguments": [] + } + }, + { + "id": "native.mayConnectToTheInternet", + "description": "Native UI: May connect to the internet", + "platforms": [ + "macos" + ], + "macos": { + "key": "May connect to the internet", + "arguments": [] + } + }, + { + "id": "native.memory", + "description": "Native UI: %@ memory", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ memory", + "arguments": [ + "arg1" + ], + "formats": { + "arg1": "@" + } + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.messaging", + "description": "Native UI: Messaging", + "platforms": [ + "macos" + ], + "macos": { + "key": "Messaging", + "arguments": [] + } + }, + { + "id": "native.method", + "description": "Native UI: Method", + "platforms": [ + "macos" + ], + "macos": { + "key": "Method", + "arguments": [] + } + }, + { + "id": "native.mode", + "description": "Native UI: Mode", + "platforms": [ + "macos" + ], + "macos": { + "key": "Mode", + "arguments": [] + } + }, + { + "id": "native.move", + "description": "Native UI: Move", + "platforms": [ + "macos" + ], + "macos": { + "key": "Move", + "arguments": [] + } + }, + { + "id": "native.newHook", + "description": "Native UI: New Hook", + "platforms": [ + "macos" + ], + "macos": { + "key": "New Hook", + "arguments": [] + } + }, + { + "id": "native.newProjectX6f8d176462", + "description": "Native UI: New Project", + "platforms": [ + "macos" + ], + "macos": { + "key": "New Project", + "arguments": [] + } + }, + { + "id": "native.newSSHConnection", + "description": "Native UI: New SSH Connection", + "platforms": [ + "macos" + ], + "macos": { + "key": "New SSH Connection", + "arguments": [] + } + }, + { + "id": "native.nextImage", + "description": "Native UI: Next image", + "platforms": [ + "macos" + ], + "macos": { + "key": "Next image", + "arguments": [] + } + }, + { + "id": "native.noHooksYet", + "description": "Native UI: No Hooks yet.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No Hooks yet.", + "arguments": [] + } + }, + { + "id": "native.noHostYet", + "description": "Native UI: No host yet", + "platforms": [ + "macos" + ], + "macos": { + "key": "No host yet", + "arguments": [] + } + }, + { + "id": "native.noInstalledPluginSupportsYet", + "description": "Native UI: No installed plugin supports %@ yet.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No installed plugin supports %@ yet.", + "arguments": [ + "arg1" + ], + "formats": { + "arg1": "@" + } + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.noItems", + "description": "Native UI: No items", + "platforms": [ + "macos" + ], + "macos": { + "key": "No items", + "arguments": [] + } + }, + { + "id": "native.noJDKWasFoundInstallAJDKThenReopenThisWindow", + "description": "Native UI: No JDK was found. Install a JDK, then reopen this window.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No JDK was found. Install a JDK, then reopen this window.", + "arguments": [] + } + }, + { + "id": "native.noMatchingHooks", + "description": "Native UI: No matching Hooks.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No matching Hooks.", + "arguments": [] + } + }, + { + "id": "native.noSSHConnectionsYet", + "description": "Native UI: No SSH connections yet.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No SSH connections yet.", + "arguments": [] + } + }, + { + "id": "native.notTested", + "description": "Native UI: Not tested", + "platforms": [ + "macos" + ], + "macos": { + "key": "Not tested", + "arguments": [] + } + }, + { + "id": "native.oneEntryPerLineDirectoryNamesHideMatchingFoldersFileEntriesSupportAnd", + "description": "Native UI: One entry per line. Directory names hide matching folders; file entries support * and ?.", + "platforms": [ + "macos" + ], + "macos": { + "key": "One entry per line. Directory names hide matching folders; file entries support * and ?.", + "arguments": [] + } + }, + { + "id": "native.operatingSystem", + "description": "Native UI: Operating system", + "platforms": [ + "macos" + ], + "macos": { + "key": "Operating system", + "arguments": [] + } + }, + { + "id": "native.optionalDescriptionUpTo200CharactersOnlyTheHookLibraryStoresIt", + "description": "Native UI: Optional description, up to 200 characters. Only the Hook library stores it.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Optional description, up to 200 characters. Only the Hook library stores it.", + "arguments": [] + } + }, + { + "id": "native.packageName", + "description": "Native UI: Package name", + "platforms": [ + "macos" + ], + "macos": { + "key": "Package name", + "arguments": [] + } + }, + { + "id": "native.packaging", + "description": "Native UI: Packaging", + "platforms": [ + "macos" + ], + "macos": { + "key": "Packaging", + "arguments": [] + } + }, + { + "id": "native.passphrase", + "description": "Native UI: Passphrase", + "platforms": [ + "macos" + ], + "macos": { + "key": "Passphrase", + "arguments": [] + } + }, + { + "id": "native.password", + "description": "Native UI: Password", + "platforms": [ + "macos" + ], + "macos": { + "key": "Password", + "arguments": [] + } + }, + { + "id": "native.pin", + "description": "Native UI: Pin", + "platforms": [ + "macos" + ], + "macos": { + "key": "Pin", + "arguments": [] + } + }, + { + "id": "native.pinConversation", + "description": "Native UI: Pin conversation", + "platforms": [ + "macos" + ], + "macos": { + "key": "Pin conversation", + "arguments": [] + } + }, + { + "id": "native.port", + "description": "Native UI: Port", + "platforms": [ + "macos" + ], + "macos": { + "key": "Port", + "arguments": [] + } + }, + { + "id": "native.previous30Days", + "description": "Native UI: Previous 30 days", + "platforms": [ + "macos" + ], + "macos": { + "key": "Previous 30 days", + "arguments": [] + } + }, + { + "id": "native.previous7Days", + "description": "Native UI: Previous 7 days", + "platforms": [ + "macos" + ], + "macos": { + "key": "Previous 7 days", + "arguments": [] + } + }, + { + "id": "native.previousImage", + "description": "Native UI: Previous image", + "platforms": [ + "macos" + ], + "macos": { + "key": "Previous image", + "arguments": [] + } + }, + { + "id": "native.privateKey", + "description": "Native UI: Private Key", + "platforms": [ + "macos" + ], + "macos": { + "key": "Private Key", + "arguments": [] + } + }, + { + "id": "native.privateKeyX477bf990a2", + "description": "Native UI: Private key", + "platforms": [ + "macos" + ], + "macos": { + "key": "Private key", + "arguments": [] + } + }, + { + "id": "native.privateNetwork", + "description": "Native UI: Private network", + "platforms": [ + "macos" + ], + "macos": { + "key": "Private network", + "arguments": [] + } + }, + { + "id": "native.projectTemplate", + "description": "Native UI: Project Template", + "platforms": [ + "macos" + ], + "macos": { + "key": "Project Template", + "arguments": [] + } + }, + { + "id": "native.projectWillBeCreatedIn", + "description": "Native UI: Project will be created in: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Project will be created in: %@", + "arguments": [ + "arg1" + ], + "formats": { + "arg1": "@" + } + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.providedByADisabledPlugin", + "description": "Native UI: Provided by a disabled plugin", + "platforms": [ + "macos" + ], + "macos": { + "key": "Provided by a disabled plugin", + "arguments": [] + } + }, + { + "id": "native.publishCoreSelectsDefaultRemote", + "description": "Native UI: Publish %@ (Core selects default remote)", + "platforms": [ + "macos" + ], + "macos": { + "key": "Publish %@ (Core selects default remote)", + "arguments": [ + "arg1" + ], + "formats": { + "arg1": "@" + } + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.pushXc68c0bf1cd", + "description": "Native UI: Push '%@'?", + "platforms": [ + "macos" + ], + "macos": { + "key": "Push '%@'?", + "arguments": [ + "arg1" + ], + "formats": { + "arg1": "@" + } + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.quotaX6c105cae1a", + "description": "Native UI: Quota", + "platforms": [ + "macos" + ], + "macos": { + "key": "Quota", + "arguments": [] + } + }, + { + "id": "native.rememberInKeychain", + "description": "Native UI: Remember in Keychain", + "platforms": [ + "macos" + ], + "macos": { + "key": "Remember in Keychain", + "arguments": [] + } + }, + { + "id": "native.remoteDevelopment", + "description": "Native UI: Remote Development", + "platforms": [ + "macos" + ], + "macos": { + "key": "Remote Development", + "arguments": [] + } + }, + { + "id": "native.remoteFolder", + "description": "Native UI: Remote folder", + "platforms": [ + "macos" + ], + "macos": { + "key": "Remote folder", + "arguments": [] + } + }, + { + "id": "native.remoteProject", + "description": "Native UI: Remote Project", + "platforms": [ + "macos" + ], + "macos": { + "key": "Remote Project", + "arguments": [] + } + }, + { + "id": "native.removalPending", + "description": "Native UI: Removal pending", + "platforms": [ + "macos" + ], + "macos": { + "key": "Removal pending", + "arguments": [] + } + }, + { + "id": "native.resources", + "description": "Native UI: Resources", + "platforms": [ + "macos" + ], + "macos": { + "key": "Resources", + "arguments": [] + } + }, + { + "id": "native.retryHookSynchronization", + "description": "Native UI: Retry Hook synchronization", + "platforms": [ + "macos" + ], + "macos": { + "key": "Retry Hook synchronization", + "arguments": [] + } + }, + { + "id": "native.sSH", + "description": "Native UI: SSH", + "platforms": [ + "macos" + ], + "macos": { + "key": "SSH", + "arguments": [] + } + }, + { + "id": "native.sSHAgent", + "description": "Native UI: SSH Agent", + "platforms": [ + "macos" + ], + "macos": { + "key": "SSH Agent", + "arguments": [] + } + }, + { + "id": "native.sSHAuthentication", + "description": "Native UI: SSH Authentication", + "platforms": [ + "macos" + ], + "macos": { + "key": "SSH Authentication", + "arguments": [] + } + }, + { + "id": "native.saveHook", + "description": "Native UI: Save Hook", + "platforms": [ + "macos" + ], + "macos": { + "key": "Save Hook", + "arguments": [] + } + }, + { + "id": "native.savedInKeychain", + "description": "Native UI: Saved in Keychain", + "platforms": [ + "macos" + ], + "macos": { + "key": "Saved in Keychain", + "arguments": [] + } + }, + { + "id": "native.savesConnectsAndOpensTheRemoteFolderInThisWindow", + "description": "Native UI: Saves, connects, and opens the remote folder in this window.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Saves, connects, and opens the remote folder in this window.", + "arguments": [] + } + }, + { + "id": "native.searchHooks", + "description": "Native UI: Search Hooks", + "platforms": [ + "macos" + ], + "macos": { + "key": "Search Hooks", + "arguments": [] + } + }, + { + "id": "native.searchJDKs", + "description": "Native UI: Search JDKs", + "platforms": [ + "macos" + ], + "macos": { + "key": "Search JDKs", + "arguments": [] + } + }, + { + "id": "native.secondsBetweenKeepAliveMessagesWhichDetectADroppedNetworkLeaveEmptyFor15Seconds", + "description": "Native UI: Seconds between keep-alive messages, which detect a dropped network. Leave empty for 15 seconds.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Seconds between keep-alive messages, which detect a dropped network. Leave empty for 15 seconds.", + "arguments": [] + } + }, + { + "id": "native.secondsToWaitForTheServerToAnswerLeaveEmptyFor15Seconds", + "description": "Native UI: Seconds to wait for the server to answer. Leave empty for 15 seconds.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Seconds to wait for the server to answer. Leave empty for 15 seconds.", + "arguments": [] + } + }, + { + "id": "native.security", + "description": "Native UI: Security", + "platforms": [ + "macos" + ], + "macos": { + "key": "Security", + "arguments": [] + } + }, + { + "id": "native.serverDefault", + "description": "Native UI: Server default", + "platforms": [ + "macos" + ], + "macos": { + "key": "Server default", + "arguments": [] + } + }, + { + "id": "native.serverURL", + "description": "Native UI: Server URL", + "platforms": [ + "macos" + ], + "macos": { + "key": "Server URL", + "arguments": [] + } + }, + { + "id": "native.shellCommands", + "description": "Native UI: Shell commands", + "platforms": [ + "macos" + ], + "macos": { + "key": "Shell commands", + "arguments": [] + } + }, + { + "id": "native.showLldEarlierItems", + "description": "Native UI: Show %lld earlier items", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show %lld earlier items", + "arguments": [ + "arg1" + ], + "formats": { + "arg1": "lld" + } + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "native.showMore", + "description": "Native UI: Show more", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show more", + "arguments": [] + } + }, + { + "id": "native.skillLoaded", + "description": "Native UI: Skill loaded: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Skill loaded: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.templateEngines", + "description": "Native UI: Template Engines", + "platforms": [ + "macos" + ], + "macos": { + "key": "Template Engines", + "arguments": [] + } + }, + { + "id": "native.testConnection", + "description": "Native UI: Test Connection", + "platforms": [ + "macos" + ], + "macos": { + "key": "Test Connection", + "arguments": [] + } + }, + { + "id": "native.testTheConnectionToSeeTheHostSSystemResourcesAndAddress", + "description": "Native UI: Test the connection to see the host's system, resources, and address.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Test the connection to see the host's system, resources, and address.", + "arguments": [] + } + }, + { + "id": "native.testing", + "description": "Native UI: Testing", + "platforms": [ + "macos" + ], + "macos": { + "key": "Testing", + "arguments": [] + } + }, + { + "id": "native.theAddressIsInvalid", + "description": "Native UI: The address is invalid.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The address is invalid.", + "arguments": [] + } + }, + { + "id": "native.theAgentExecutesThisCommandUsingItsOwnShellAndPassesEventJSONOnStandardInputCommandsRunWit", + "description": "Native UI: The Agent executes this command using its own shell and passes event JSON on standard input. Commands run with your user permissions; add only commands you trust.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The Agent executes this command using its own shell and passes event JSON on standard input. Commands run with your user permissions; add only commands you trust.", + "arguments": [] + } + }, + { + "id": "native.theAgentProfileChangedRestoreThePreviousProfileBeforeSynchronizingItsHooks", + "description": "Native UI: The Agent profile changed. Restore the previous profile before synchronizing its Hooks.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The Agent profile changed. Restore the previous profile before synchronizing its Hooks.", + "arguments": [] + } + }, + { + "id": "native.theConnectionFailed", + "description": "Native UI: The connection failed.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The connection failed.", + "arguments": [] + } + }, + { + "id": "native.theConnectionToTheRemoteHostWasLostReconnectToContinue", + "description": "Native UI: The connection to the remote host was lost. Reconnect to continue.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The connection to the remote host was lost. Reconnect to continue.", + "arguments": [] + } + }, + { + "id": "native.theFieldValueDoesNotMatchItsDeclarationOrExceedsItsSizeLimit", + "description": "Native UI: The field value does not match its declaration or exceeds its size limit.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The field value does not match its declaration or exceeds its size limit.", + "arguments": [] + } + }, + { + "id": "native.theFieldValueIsInvalidOrExceeds1024UTF8Bytes", + "description": "Native UI: The field value is invalid or exceeds 1024 UTF-8 bytes.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The field value is invalid or exceeds 1024 UTF-8 bytes.", + "arguments": [] + } + }, + { + "id": "native.theFileSystemThatHoldsTheHomeDirectory", + "description": "Native UI: The file system that holds the home directory.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The file system that holds the home directory.", + "arguments": [] + } + }, + { + "id": "native.theHookLibraryCanContainAtMost200Hooks", + "description": "Native UI: The Hook library can contain at most 200 Hooks.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The Hook library can contain at most 200 Hooks.", + "arguments": [] + } + }, + { + "id": "native.theHookLibraryContainsDuplicateHooks", + "description": "Native UI: The Hook library contains duplicate Hooks.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The Hook library contains duplicate Hooks.", + "arguments": [] + } + }, + { + "id": "native.theHookLibraryDirectoryMustBeAbsolute", + "description": "Native UI: The Hook library directory must be absolute.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The Hook library directory must be absolute.", + "arguments": [] + } + }, + { + "id": "native.theHookLibraryExceededItsSizeLimit", + "description": "Native UI: The Hook library exceeded its size limit.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The Hook library exceeded its size limit.", + "arguments": [] + } + }, + { + "id": "native.theHookLibraryIsInvalidRestoreItBeforeMakingChanges", + "description": "Native UI: The Hook library is invalid. Restore it before making changes.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The Hook library is invalid. Restore it before making changes.", + "arguments": [] + } + }, + { + "id": "native.theHookLibraryLockMustBeARegularFile", + "description": "Native UI: The Hook library lock must be a regular file.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The Hook library lock must be a regular file.", + "arguments": [] + } + }, + { + "id": "native.theHookLibraryResponseWasNotRecognised", + "description": "Native UI: The Hook library response was not recognised.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The Hook library response was not recognised.", + "arguments": [] + } + }, + { + "id": "native.theHostCouldNotBeReachedCheckTheHostNameThePortAndYourNetwork", + "description": "Native UI: The host could not be reached. Check the host name, the port, and your network.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The host could not be reached. Check the host name, the port, and your network.", + "arguments": [] + } + }, + { + "id": "native.theHostKeyWasNotAcceptedIfTheServerWasReinstalledRemoveItsOldEntryFromSshKnownHosts", + "description": "Native UI: The host key was not accepted. If the server was reinstalled, remove its old entry from ~/.ssh/known_hosts.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The host key was not accepted. If the server was reinstalled, remove its old entry from ~/.ssh/known_hosts.", + "arguments": [] + } + }, + { + "id": "native.theJDKTheNewProjectUsesItMustBeAtLeastTheJavaVersionBelow", + "description": "Native UI: The JDK the new project uses. It must be at least the Java version below.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The JDK the new project uses. It must be at least the Java version below.", + "arguments": [] + } + }, + { + "id": "native.theLitheServerWasInstalledOnThisHost", + "description": "Native UI: The Lithe server was installed on this host.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The Lithe server was installed on this host.", + "arguments": [] + } + }, + { + "id": "native.theLocationServiceDidNotAnswer", + "description": "Native UI: The location service did not answer.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The location service did not answer.", + "arguments": [] + } + }, + { + "id": "native.theNameOfTheArtifactTheBuildProduces", + "description": "Native UI: The name of the artifact the build produces.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The name of the artifact the build produces.", + "arguments": [] + } + }, + { + "id": "native.theNativeHookConfigurationMustBeValidJSONExistingContentsWereKept", + "description": "Native UI: The native Hook configuration must be valid JSON. Existing contents were kept.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The native Hook configuration must be valid JSON. Existing contents were kept.", + "arguments": [] + } + }, + { + "id": "native.theNativeHookEventMustContainAnArrayExistingContentsWereKept", + "description": "Native UI: The native Hook event must contain an array. Existing contents were kept.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The native Hook event must contain an array. Existing contents were kept.", + "arguments": [] + } + }, + { + "id": "native.theNativeHooksFieldMustBeAJSONObjectExistingContentsWereKept", + "description": "Native UI: The native hooks field must be a JSON object. Existing contents were kept.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The native hooks field must be a JSON object. Existing contents were kept.", + "arguments": [] + } + }, + { + "id": "native.thePortMustBeBetween1And65535", + "description": "Native UI: The port must be between 1 and 65535.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The port must be between 1 and 65535.", + "arguments": [] + } + }, + { + "id": "native.theProjectArchiveFromIsTooLarge", + "description": "Native UI: The project archive from %@ is too large.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The project archive from %@ is too large.", + "arguments": [ + "arg1" + ], + "formats": { + "arg1": "@" + } + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.theProjectFolderOnTheHostBrowseConnectsInstallsTheLitheServerOnTheHostIfNeededAndLetsYouPi", + "description": "Native UI: The project folder on the host. Browse connects, installs the Lithe server on the host if needed, and lets you pick a folder.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The project folder on the host. Browse connects, installs the Lithe server on the host if needed, and lets you pick a folder.", + "arguments": [] + } + }, + { + "id": "native.theProjectFolderOnTheHostEmptyUsesTheHomeDirectoryBrowseConnectsInstallsTheLitheServerOnTh", + "description": "Native UI: The project folder on the host; empty uses the home directory. Browse connects, installs the Lithe server on the host if needed, and lets you pick a folder.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The project folder on the host; empty uses the home directory. Browse connects, installs the Lithe server on the host if needed, and lets you pick a folder.", + "arguments": [] + } + }, + { + "id": "native.theProjectWasCreatedButGitCouldNotBeSetUp", + "description": "Native UI: The project was created, but Git could not be set up: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "The project was created, but Git could not be set up: %@", + "arguments": [ + "arg1" + ], + "formats": { + "arg1": "@" + } + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.theServerAskedAgainCheckThePreviousAnswer", + "description": "Native UI: The server asked again. Check the previous answer.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The server asked again. Check the previous answer.", + "arguments": [] + } + }, + { + "id": "native.theServerRejectedTheCredentialsCheckTheUserNameAndTheAuthenticationMethod", + "description": "Native UI: The server rejected the credentials. Check the user name and the authentication method.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The server rejected the credentials. Check the user name and the authentication method.", + "arguments": [] + } + }, + { + "id": "native.theSpringInitializrServerThatGeneratesTheProjectChangeItToUseAMirrorOrYourCompanySOwnServe", + "description": "Native UI: The Spring Initializr server that generates the project. Change it to use a mirror or your company's own server.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The Spring Initializr server that generates the project. Change it to use a mirror or your company's own server.", + "arguments": [] + } + }, + { + "id": "native.theUserNameCannotContainOrSpaces", + "description": "Native UI: The user name cannot contain @, : or spaces.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The user name cannot contain @, : or spaces.", + "arguments": [] + } + }, + { + "id": "native.theValueIsNotInTheExpectedFormat", + "description": "Native UI: The value is not in the expected format.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The value is not in the expected format.", + "arguments": [] + } + }, + { + "id": "native.theValueIsTooLong", + "description": "Native UI: The value is too long.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The value is too long.", + "arguments": [] + } + }, + { + "id": "native.thisBuildOfLitheDoesNotIncludeTheRemoteServer", + "description": "Native UI: This build of Lithe does not include the remote server.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This build of Lithe does not include the remote server.", + "arguments": [] + } + }, + { + "id": "native.thisHookNoLongerExistsReloadTheLibrary", + "description": "Native UI: This Hook no longer exists. Reload the library.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This Hook no longer exists. Reload the library.", + "arguments": [] + } + }, + { + "id": "native.thisPanelNeedsExactlyOneMatchingPluginInstanceInTheWorkspace", + "description": "Native UI: This panel needs exactly one matching plugin instance in the workspace.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This panel needs exactly one matching plugin instance in the workspace.", + "arguments": [] + } + }, + { + "id": "native.thisRemovesOnlyTheNativeEntriesWrittenByLitheOtherHooksAreKept", + "description": "Native UI: This removes only the native entries written by Lithe. Other Hooks are kept.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This removes only the native entries written by Lithe. Other Hooks are kept.", + "arguments": [] + } + }, + { + "id": "native.thisRuntimeIsOlderThanTheGeneratorRequires", + "description": "Native UI: This runtime is older than the generator requires.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This runtime is older than the generator requires.", + "arguments": [] + } + }, + { + "id": "native.thisSendsTheSelectedLocalBranchToItsConfiguredRemote", + "description": "Native UI: This sends the selected local branch to its configured remote.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This sends the selected local branch to its configured remote.", + "arguments": [] + } + }, + { + "id": "native.thisTemplateComesFromWhichIsDisabled", + "description": "Native UI: This template comes from %@, which is disabled.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This template comes from %@, which is disabled.", + "arguments": [ + "arg1" + ], + "formats": { + "arg1": "@" + } + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.thisVersionIsNewerThanTheSelectedRuntimeSupports", + "description": "Native UI: This version is newer than the selected runtime supports.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This version is newer than the selected runtime supports.", + "arguments": [] + } + }, + { + "id": "native.timeoutSeconds", + "description": "Native UI: Timeout (seconds)", + "platforms": [ + "macos" + ], + "macos": { + "key": "Timeout (seconds)", + "arguments": [] + } + }, + { + "id": "native.toolFilter", + "description": "Native UI: Tool filter", + "platforms": [ + "macos" + ], + "macos": { + "key": "Tool filter", + "arguments": [] + } + }, + { + "id": "native.tracking", + "description": "Native UI: Tracking %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Tracking %@", + "arguments": [ + "arg1" + ], + "formats": { + "arg1": "@" + } + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.unknownDate", + "description": "Native UI: Unknown date", + "platforms": [ + "macos" + ], + "macos": { + "key": "Unknown date", + "arguments": [] + } + }, + { + "id": "native.unpin", + "description": "Native UI: Unpin", + "platforms": [ + "macos" + ], + "macos": { + "key": "Unpin", + "arguments": [] + } + }, + { + "id": "native.unpinConversation", + "description": "Native UI: Unpin conversation", + "platforms": [ + "macos" + ], + "macos": { + "key": "Unpin conversation", + "arguments": [] + } + }, + { + "id": "native.uptime", + "description": "Native UI: Uptime", + "platforms": [ + "macos" + ], + "macos": { + "key": "Uptime", + "arguments": [] + } + }, + { + "id": "native.usageAppearsAfterTheNextReply", + "description": "Native UI: Usage appears after the next reply.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Usage appears after the next reply.", + "arguments": [] + } + }, + { + "id": "native.use180LettersDigitsHyphensOrUnderscoresNamesMustBeUnique", + "description": "Native UI: Use 1–80 letters, digits, hyphens or underscores. Names must be unique.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Use 1–80 letters, digits, hyphens or underscores. Names must be unique.", + "arguments": [] + } + }, + { + "id": "native.useTheClaudeAiAccountYouSignedInToWithClaudeCodeNoAPIKeyOrURLIsNeededLitheNeverReadsItsCre", + "description": "Native UI: Use the claude.ai account you signed in to with Claude Code. No API key or URL is needed; Lithe never reads its credentials.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Use the claude.ai account you signed in to with Claude Code. No API key or URL is needed; Lithe never reads its credentials.", + "arguments": [] + } + }, + { + "id": "native.userName", + "description": "Native UI: User name", + "platforms": [ + "macos" + ], + "macos": { + "key": "User name", + "arguments": [] + } + }, + { + "id": "native.usesTheClaudeAiAccountSignedInToClaudeCodeSignInWithLoginInClaudeCodeIfTheAgentAsks", + "description": "Native UI: Uses the claude.ai account signed in to Claude Code. Sign in with /login in Claude Code if the Agent asks.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Uses the claude.ai account signed in to Claude Code. Sign in with /login in Claude Code if the Agent asks.", + "arguments": [] + } + }, + { + "id": "native.usesYourLocalChatGPTAccount", + "description": "Native UI: Uses your local ChatGPT account.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Uses your local ChatGPT account.", + "arguments": [] + } + }, + { + "id": "native.usuallyTheReversedDomainNameOfYourOrganizationSuchAsComExample", + "description": "Native UI: Usually the reversed domain name of your organization, such as com.example.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Usually the reversed domain name of your organization, such as com.example.", + "arguments": [] + } + }, + { + "id": "native.whenASessionStarts", + "description": "Native UI: When a session starts", + "platforms": [ + "macos" + ], + "macos": { + "key": "When a session starts", + "arguments": [] + } + }, + { + "id": "native.whenATurnEnds", + "description": "Native UI: When a turn ends", + "platforms": [ + "macos" + ], + "macos": { + "key": "When a turn ends", + "arguments": [] + } + }, + { + "id": "native.whenSubmittingAPrompt", + "description": "Native UI: When submitting a prompt", + "platforms": [ + "macos" + ], + "macos": { + "key": "When submitting a prompt", + "arguments": [] + } + }, + { + "id": "native.whenToRun", + "description": "Native UI: When to run", + "platforms": [ + "macos" + ], + "macos": { + "key": "When to run", + "arguments": [] + } + }, + { + "id": "native.withoutItLitheAsksEveryTimeItConnectsARememberedSecretIsAnsweredOnlyTheFirstTimeTheServerA", + "description": "Native UI: Without it, Lithe asks every time it connects. A remembered secret is answered only the first time the server asks for it; if it is rejected, Lithe asks you.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Without it, Lithe asks every time it connects. A remembered secret is answered only the first time the server asks for it; if it is rejected, Lithe asks you.", + "arguments": [] + } + }, + { + "id": "native.withoutRememberInKeychainLitheAsksEveryTimeItConnectsARememberedSecretIsAnsweredOnlyTheFir", + "description": "Native UI: Without Remember in Keychain, Lithe asks every time it connects. A remembered secret is answered only the first time the server asks for it; if it is rejected, Lithe asks you.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Without Remember in Keychain, Lithe asks every time it connects. A remembered secret is answered only the first time the server asks for it; if it is rejected, Lithe asks you.", + "arguments": [] + } + }, + { + "id": "settings.tabs.hooks", + "description": "Windows UI: settings.tabs.hooks", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.dockBottom", + "description": "Windows UI: terminal.dockBottom", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.dockLeft", + "description": "Windows UI: terminal.dockLeft", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.dockRight", + "description": "Windows UI: terminal.dockRight", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.movePanel", + "description": "Windows UI: terminal.movePanel", + "platforms": [ + "windows" + ] + }, + { + "id": "terminal.resizePanel", + "description": "Windows UI: terminal.resizePanel", + "platforms": [ + "windows" + ] + }, + { + "id": "windows.aHookWithThisNameAlreadyExists", + "description": "Windows UI: A Hook with this name already exists.", + "platforms": [ + "windows" + ], + "windows": { + "key": "A Hook with this name already exists." + } + }, + { + "id": "windows.aManagedHookWasEditedOutsideLitheRestoreOrRemoveThatNativeEntryBeforeSynchronizing", + "description": "Windows UI: A managed Hook was edited outside Lithe. Restore or remove that native entry before synchronizing.", + "platforms": [ + "windows" + ], + "windows": { + "key": "A managed Hook was edited outside Lithe. Restore or remove that native entry before synchronizing." + } + }, + { + "id": "windows.aRemovedHookCannotBeSaved", + "description": "Windows UI: A removed Hook cannot be saved.", + "platforms": [ + "windows" + ], + "windows": { + "key": "A removed Hook cannot be saved." + } + }, + { + "id": "windows.addHook", + "description": "Windows UI: Add Hook", + "platforms": [ + "windows" + ], + "windows": { + "key": "Add Hook" + } + }, + { + "id": "windows.afterAToolRuns", + "description": "Windows UI: After a tool runs", + "platforms": [ + "windows" + ], + "windows": { + "key": "After a tool runs" + } + }, + { + "id": "windows.agentDefault", + "description": "Windows UI: Agent default", + "platforms": [ + "windows" + ], + "windows": { + "key": "Agent default" + } + }, + { + "id": "windows.agentHooks", + "description": "Windows UI: Agent hooks", + "platforms": [ + "windows" + ], + "windows": { + "key": "Agent hooks" + } + }, + { + "id": "windows.agents", + "description": "Windows UI: Agents", + "platforms": [ + "windows" + ], + "windows": { + "key": "Agents" + } + }, + { + "id": "windows.allTools", + "description": "Windows UI: All tools", + "platforms": [ + "windows" + ], + "windows": { + "key": "All tools" + } + }, + { + "id": "windows.anIdenticalNativeHookAlreadyExistsLitheKeptItUnchanged", + "description": "Windows UI: An identical native Hook already exists. Lithe kept it unchanged.", + "platforms": [ + "windows" + ], + "windows": { + "key": "An identical native Hook already exists. Lithe kept it unchanged." + } + }, + { + "id": "windows.anotherHookUpdateIsRunningTryAgain", + "description": "Windows UI: Another Hook update is running. Try again.", + "platforms": [ + "windows" + ], + "windows": { + "key": "Another Hook update is running. Try again." + } + }, + { + "id": "windows.beforeAToolRuns", + "description": "Windows UI: Before a tool runs", + "platforms": [ + "windows" + ], + "windows": { + "key": "Before a tool runs" + } + }, + { + "id": "windows.cannotAccessHookConfigurationCheckTheFilePermissionsAndAgentProfile", + "description": "Windows UI: Cannot access Hook configuration. Check the file permissions and Agent profile.", + "platforms": [ + "windows" + ], + "windows": { + "key": "Cannot access Hook configuration. Check the file permissions and Agent profile." + } + }, + { + "id": "windows.cannotAccessTheHookLibraryCheckItsPermissions", + "description": "Windows UI: Cannot access the Hook library. Check its permissions.", + "platforms": [ + "windows" + ], + "windows": { + "key": "Cannot access the Hook library. Check its permissions." + } + }, + { + "id": "windows.cannotEncodeHookConfiguration", + "description": "Windows UI: Cannot encode Hook configuration.", + "platforms": [ + "windows" + ], + "windows": { + "key": "Cannot encode Hook configuration." + } + }, + { + "id": "windows.cannotEncodeTheHookLibrary", + "description": "Windows UI: Cannot encode the Hook library.", + "platforms": [ + "windows" + ], + "windows": { + "key": "Cannot encode the Hook library." + } + }, + { + "id": "windows.checkTheHookNameCommandEventAgentsAndTimeout186400Seconds", + "description": "Windows UI: Check the Hook name, command, event, Agents and timeout (1–86400 seconds).", + "platforms": [ + "windows" + ], + "windows": { + "key": "Check the Hook name, command, event, Agents and timeout (1–86400 seconds)." + } + }, + { + "id": "windows.claudeCodeHasDisabledAllHooksInItsSettings", + "description": "Windows UI: Claude Code has disabled all Hooks in its settings.", + "platforms": [ + "windows" + ], + "windows": { + "key": "Claude Code has disabled all Hooks in its settings." + } + }, + { + "id": "windows.codexHookApproval", + "description": "Windows UI: Codex Hook approval", + "platforms": [ + "windows" + ], + "windows": { + "key": "Codex Hook approval" + } + }, + { + "id": "windows.codexRequiresHooksToBeEnabledAndCommandsToBeTrustedInItsNativeHooksScreenLitheSavesConfigu", + "description": "Windows UI: Codex requires Hooks to be enabled and commands to be trusted in its native /hooks screen. Lithe saves configuration without granting trust. Editing a command requires approval again. A configured Hook is not proof it ran.", + "platforms": [ + "windows" + ], + "windows": { + "key": "Codex requires Hooks to be enabled and commands to be trusted in its native /hooks screen. Lithe saves configuration without granting trust. Editing a command requires approval again. A configured Hook is not proof it ran." + } + }, + { + "id": "windows.commandPaletteActionsCursorRedoDescription", + "description": "Windows UI: commandPalette.actions.cursor-redo.description", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.cursor-redo.description" + } + }, + { + "id": "windows.commandPaletteActionsCursorRedoLabel", + "description": "Windows UI: commandPalette.actions.cursor-redo.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.cursor-redo.label" + } + }, + { + "id": "windows.commandPaletteActionsCursorUndoDescription", + "description": "Windows UI: commandPalette.actions.cursor-undo.description", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.cursor-undo.description" + } + }, + { + "id": "windows.commandPaletteActionsCursorUndoLabel", + "description": "Windows UI: commandPalette.actions.cursor-undo.label", + "platforms": [ + "windows" + ], + "windows": { + "key": "commandPalette.actions.cursor-undo.label" + } + }, + { + "id": "windows.deleteHook", + "description": "Windows UI: Delete Hook?", + "platforms": [ + "windows" + ], + "windows": { + "key": "Delete Hook?" + } + }, + { + "id": "windows.enableHook", + "description": "Windows UI: Enable Hook", + "platforms": [ + "windows" + ], + "windows": { + "key": "Enable Hook" + } + }, + { + "id": "windows.fileEdits", + "description": "Windows UI: File edits", + "platforms": [ + "windows" + ], + "windows": { + "key": "File edits" + } + }, + { + "id": "windows.hook", + "description": "Windows UI: Hook", + "platforms": [ + "windows" + ], + "windows": { + "key": "Hook" + } + }, + { + "id": "windows.hookCommand", + "description": "Windows UI: Hook command", + "platforms": [ + "windows" + ], + "windows": { + "key": "Hook command" + } + }, + { + "id": "windows.hookConfigurationChangedOutsideThisEditorReloadBeforeSaving", + "description": "Windows UI: Hook configuration changed outside this editor. Reload before saving.", + "platforms": [ + "windows" + ], + "windows": { + "key": "Hook configuration changed outside this editor. Reload before saving." + } + }, + { + "id": "windows.hookConfigurationMustBeARegularFileNotASymbolicLinkOrDirectory", + "description": "Windows UI: Hook configuration must be a regular file, not a symbolic link or directory.", + "platforms": [ + "windows" + ], + "windows": { + "key": "Hook configuration must be a regular file, not a symbolic link or directory." + } + }, + { + "id": "windows.hookConfigurationMustBeNoLargerThan1MiB", + "description": "Windows UI: Hook configuration must be no larger than 1 MiB.", + "platforms": [ + "windows" + ], + "windows": { + "key": "Hook configuration must be no larger than 1 MiB." + } + }, + { + "id": "windows.hookConfigurationMustUseUTF8Encoding", + "description": "Windows UI: Hook configuration must use UTF-8 encoding.", + "platforms": [ + "windows" + ], + "windows": { + "key": "Hook configuration must use UTF-8 encoding." + } + }, + { + "id": "windows.hookDescription", + "description": "Windows UI: Hook description", + "platforms": [ + "windows" + ], + "windows": { + "key": "Hook description" + } + }, + { + "id": "windows.hookSynchronizationWasCancelled", + "description": "Windows UI: Hook synchronization was cancelled.", + "platforms": [ + "windows" + ], + "windows": { + "key": "Hook synchronization was cancelled." + } + }, + { + "id": "windows.hooks", + "description": "Windows UI: Hooks", + "platforms": [ + "windows" + ], + "windows": { + "key": "Hooks" + } + }, + { + "id": "windows.hooksEventAfterTool", + "description": "Windows UI: hooks.event.after-tool", + "platforms": [ + "windows" + ], + "windows": { + "key": "hooks.event.after-tool" + } + }, + { + "id": "windows.hooksEventBeforeTool", + "description": "Windows UI: hooks.event.before-tool", + "platforms": [ + "windows" + ], + "windows": { + "key": "hooks.event.before-tool" + } + }, + { + "id": "windows.hooksEventSessionStart", + "description": "Windows UI: hooks.event.session-start", + "platforms": [ + "windows" + ], + "windows": { + "key": "hooks.event.session-start" + } + }, + { + "id": "windows.hooksEventTurnEnd", + "description": "Windows UI: hooks.event.turn-end", + "platforms": [ + "windows" + ], + "windows": { + "key": "hooks.event.turn-end" + } + }, + { + "id": "windows.hooksRunShellCommandsAtAgentLifecycleEventsTheAgentSuppliesEventJSONOnStandardInputAndRuns", + "description": "Windows UI: Hooks run shell commands at Agent lifecycle events. The Agent supplies event JSON on standard input and runs the command in the session folder. Changes apply to new sessions; existing sessions keep their own loaded configuration.", + "platforms": [ + "windows" + ], + "windows": { + "key": "Hooks run shell commands at Agent lifecycle events. The Agent supplies event JSON on standard input and runs the command in the session folder. Changes apply to new sessions; existing sessions keep their own loaded configuration." + } + }, + { + "id": "windows.invalidHookFileGuards", + "description": "Windows UI: Invalid Hook file guards.", + "platforms": [ + "windows" + ], + "windows": { + "key": "Invalid Hook file guards." + } + }, + { + "id": "windows.leaveEmptyToUseTheAgentDefaultOrEnterAnIntegerFrom1To86400", + "description": "Windows UI: Leave empty to use the Agent default, or enter an integer from 1 to 86400.", + "platforms": [ + "windows" + ], + "windows": { + "key": "Leave empty to use the Agent default, or enter an integer from 1 to 86400." + } + }, + { + "id": "windows.noHooksYet", + "description": "Windows UI: No Hooks yet.", + "platforms": [ + "windows" + ], + "windows": { + "key": "No Hooks yet." + } + }, + { + "id": "windows.noMatchingHooks", + "description": "Windows UI: No matching Hooks.", + "platforms": [ + "windows" + ], + "windows": { + "key": "No matching Hooks." + } + }, + { + "id": "windows.optionalDescriptionUpTo200CharactersOnlyTheHookLibraryStoresIt", + "description": "Windows UI: Optional description, up to 200 characters. Only the Hook library stores it.", + "platforms": [ + "windows" + ], + "windows": { + "key": "Optional description, up to 200 characters. Only the Hook library stores it." + } + }, + { + "id": "windows.removalPending", + "description": "Windows UI: Removal pending", + "platforms": [ + "windows" + ], + "windows": { + "key": "Removal pending" + } + }, + { + "id": "windows.retryHookSynchronization", + "description": "Windows UI: Retry Hook synchronization", + "platforms": [ + "windows" + ], + "windows": { + "key": "Retry Hook synchronization" + } + }, + { + "id": "windows.saveHook", + "description": "Windows UI: Save Hook", + "platforms": [ + "windows" + ], + "windows": { + "key": "Save Hook" + } + }, + { + "id": "windows.searchHooks", + "description": "Windows UI: Search Hooks", + "platforms": [ + "windows" + ], + "windows": { + "key": "Search Hooks" + } + }, + { + "id": "windows.shellCommands", + "description": "Windows UI: Shell commands", + "platforms": [ + "windows" + ], + "windows": { + "key": "Shell commands" + } + }, + { + "id": "windows.syncNeeded", + "description": "Windows UI: Sync needed", + "platforms": [ + "windows" + ], + "windows": { + "key": "Sync needed" + } + }, + { + "id": "windows.theAgentExecutesThisCommandUsingItsOwnShellAndPassesEventJSONOnStandardInputCommandsRunWit", + "description": "Windows UI: The Agent executes this command using its own shell and passes event JSON on standard input. Commands run with your user permissions; add only commands you trust.", + "platforms": [ + "windows" + ], + "windows": { + "key": "The Agent executes this command using its own shell and passes event JSON on standard input. Commands run with your user permissions; add only commands you trust." + } + }, + { + "id": "windows.theAgentProfileChangedRestoreThePreviousProfileBeforeSynchronizingItsHooks", + "description": "Windows UI: The Agent profile changed. Restore the previous profile before synchronizing its Hooks.", + "platforms": [ + "windows" + ], + "windows": { + "key": "The Agent profile changed. Restore the previous profile before synchronizing its Hooks." + } + }, + { + "id": "windows.theHookLibraryCanContainAtMost200Hooks", + "description": "Windows UI: The Hook library can contain at most 200 Hooks.", + "platforms": [ + "windows" + ], + "windows": { + "key": "The Hook library can contain at most 200 Hooks." + } + }, + { + "id": "windows.theHookLibraryContainsDuplicateHooks", + "description": "Windows UI: The Hook library contains duplicate Hooks.", + "platforms": [ + "windows" + ], + "windows": { + "key": "The Hook library contains duplicate Hooks." + } + }, + { + "id": "windows.theHookLibraryDirectoryMustBeAbsolute", + "description": "Windows UI: The Hook library directory must be absolute.", + "platforms": [ + "windows" + ], + "windows": { + "key": "The Hook library directory must be absolute." + } + }, + { + "id": "windows.theHookLibraryExceededItsSizeLimit", + "description": "Windows UI: The Hook library exceeded its size limit.", + "platforms": [ + "windows" + ], + "windows": { + "key": "The Hook library exceeded its size limit." + } + }, + { + "id": "windows.theHookLibraryIsInvalidRestoreItBeforeMakingChanges", + "description": "Windows UI: The Hook library is invalid. Restore it before making changes.", + "platforms": [ + "windows" + ], + "windows": { + "key": "The Hook library is invalid. Restore it before making changes." + } + }, + { + "id": "windows.theHookLibraryLockMustBeARegularFile", + "description": "Windows UI: The Hook library lock must be a regular file.", + "platforms": [ + "windows" + ], + "windows": { + "key": "The Hook library lock must be a regular file." + } + }, + { + "id": "windows.theHookLibraryResponseWasNotRecognised", + "description": "Windows UI: The Hook library response was not recognised.", + "platforms": [ + "windows" + ], + "windows": { + "key": "The Hook library response was not recognised." + } + }, + { + "id": "windows.theNativeHookConfigurationMustBeValidJSONExistingContentsWereKept", + "description": "Windows UI: The native Hook configuration must be valid JSON. Existing contents were kept.", + "platforms": [ + "windows" + ], + "windows": { + "key": "The native Hook configuration must be valid JSON. Existing contents were kept." + } + }, + { + "id": "windows.theNativeHookEventMustContainAnArrayExistingContentsWereKept", + "description": "Windows UI: The native Hook event must contain an array. Existing contents were kept.", + "platforms": [ + "windows" + ], + "windows": { + "key": "The native Hook event must contain an array. Existing contents were kept." + } + }, + { + "id": "windows.theNativeHooksFieldMustBeAJSONObjectExistingContentsWereKept", + "description": "Windows UI: The native hooks field must be a JSON object. Existing contents were kept.", + "platforms": [ + "windows" + ], + "windows": { + "key": "The native hooks field must be a JSON object. Existing contents were kept." + } + }, + { + "id": "windows.thisHookNoLongerExistsReloadTheLibrary", + "description": "Windows UI: This Hook no longer exists. Reload the library.", + "platforms": [ + "windows" + ], + "windows": { + "key": "This Hook no longer exists. Reload the library." + } + }, + { + "id": "windows.thisRemovesOnlyTheNativeEntriesWrittenByLitheOtherHooksAreKept", + "description": "Windows UI: This removes only the native entries written by Lithe. Other Hooks are kept.", + "platforms": [ + "windows" + ], + "windows": { + "key": "This removes only the native entries written by Lithe. Other Hooks are kept." + } + }, + { + "id": "windows.timeoutSeconds", + "description": "Windows UI: Timeout (seconds)", + "platforms": [ + "windows" + ], + "windows": { + "key": "Timeout (seconds)" + } + }, + { + "id": "windows.toolFilter", + "description": "Windows UI: Tool filter", + "platforms": [ + "windows" + ], + "windows": { + "key": "Tool filter" + } + }, + { + "id": "windows.use180LettersDigitsHyphensOrUnderscoresNamesMustBeUnique", + "description": "Windows UI: Use 1–80 letters, digits, hyphens or underscores. Names must be unique.", + "platforms": [ + "windows" + ], + "windows": { + "key": "Use 1–80 letters, digits, hyphens or underscores. Names must be unique." + } + }, + { + "id": "windows.whenASessionStarts", + "description": "Windows UI: When a session starts", + "platforms": [ + "windows" + ], + "windows": { + "key": "When a session starts" + } + }, + { + "id": "windows.whenATurnEnds", + "description": "Windows UI: When a turn ends", + "platforms": [ + "windows" + ], + "windows": { + "key": "When a turn ends" + } + }, + { + "id": "windows.whenSubmittingAPrompt", + "description": "Windows UI: When submitting a prompt", + "platforms": [ + "windows" + ], + "windows": { + "key": "When submitting a prompt" + } + }, + { + "id": "windows.whenToRun", + "description": "Windows UI: When to run", + "platforms": [ + "windows" + ], + "windows": { + "key": "When to run" + } + }, + { + "id": "run.declarativePlugins", + "description": "Windows declarative plugin UI: run.declarativePlugins", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.title", + "description": "Windows declarative plugin UI: settings.declarativePlugins.title", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.description", + "description": "Windows declarative plugin UI: settings.declarativePlugins.description", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.import", + "description": "Windows declarative plugin UI: settings.declarativePlugins.import", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.reviewTitle", + "description": "Windows declarative plugin UI: settings.declarativePlugins.reviewTitle", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string", + "version": "string" + } + }, + { + "id": "settings.declarativePlugins.unverified", + "description": "Windows declarative plugin UI: settings.declarativePlugins.unverified", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.confirmInstall", + "description": "Windows declarative plugin UI: settings.declarativePlugins.confirmInstall", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.programDeclaration", + "description": "Windows declarative plugin UI: settings.declarativePlugins.programDeclaration", + "platforms": [ + "windows" + ], + "parameters": { + "count": "integer" + } + }, + { + "id": "settings.declarativePlugins.empty", + "description": "Windows declarative plugin UI: settings.declarativePlugins.empty", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.toggle", + "description": "Windows declarative plugin UI: settings.declarativePlugins.toggle", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "settings.declarativePlugins.rollback", + "description": "Windows declarative plugin UI: settings.declarativePlugins.rollback", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.uninstall", + "description": "Windows declarative plugin UI: settings.declarativePlugins.uninstall", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.source.bundled", + "description": "Windows declarative plugin UI: settings.declarativePlugins.source.bundled", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.source.installed", + "description": "Windows declarative plugin UI: settings.declarativePlugins.source.installed", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.settings", + "description": "Windows declarative plugin UI: settings.declarativePlugins.settings", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.webWindows", + "description": "Windows declarative plugin UI: settings.declarativePlugins.webWindows", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.openWindow", + "description": "Windows declarative plugin UI: settings.declarativePlugins.openWindow", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.closeWindow", + "description": "Windows declarative plugin UI: settings.declarativePlugins.closeWindow", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.placement.left", + "description": "Windows declarative plugin UI: settings.declarativePlugins.placement.left", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.placement.right", + "description": "Windows declarative plugin UI: settings.declarativePlugins.placement.right", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.placement.bottom", + "description": "Windows declarative plugin UI: settings.declarativePlugins.placement.bottom", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.showSettings", + "description": "Windows declarative plugin UI: settings.declarativePlugins.showSettings", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.hideSettings", + "description": "Windows declarative plugin UI: settings.declarativePlugins.hideSettings", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.settingsUnavailable", + "description": "Windows declarative plugin UI: settings.declarativePlugins.settingsUnavailable", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.settingSource.default", + "description": "Windows declarative plugin UI: settings.declarativePlugins.settingSource.default", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.settingSource.global", + "description": "Windows declarative plugin UI: settings.declarativePlugins.settingSource.global", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.settingSource.project", + "description": "Windows declarative plugin UI: settings.declarativePlugins.settingSource.project", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.settingScope.global", + "description": "Windows declarative plugin UI: settings.declarativePlugins.settingScope.global", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.settingScope.project", + "description": "Windows declarative plugin UI: settings.declarativePlugins.settingScope.project", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.settingEffect.restart", + "description": "Windows declarative plugin UI: settings.declarativePlugins.settingEffect.restart", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.settingEffect.immediate", + "description": "Windows declarative plugin UI: settings.declarativePlugins.settingEffect.immediate", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.settingEffect.reload", + "description": "Windows declarative plugin UI: settings.declarativePlugins.settingEffect.reload", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.declarativePlugins.settingEffect.nextRun", + "description": "Windows declarative plugin UI: settings.declarativePlugins.settingEffect.nextRun", + "platforms": [ + "windows" + ] + }, + { + "id": "native.agentAccounts", + "description": "Native UI: Subscription accounts", + "platforms": [ + "macos" + ], + "macos": { + "key": "Subscription accounts", + "arguments": [] + } + }, + { + "id": "native.agentAccountsInfo", + "description": "Native UI: Each account is a separate configuration folder of the official CLI. Sign-in runs the CLI's own login command in the background and opens your browser, so Lithe never reads or copies credentials. Switching restarts this project's connection for this Agent and is refused while a reply or permission request is pending. Lithe never switches accounts by itself when a limit is reached.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Each account is a separate configuration folder of the official CLI. Sign-in runs the CLI's own login command in the background and opens your browser, so Lithe never reads or copies credentials. Switching restarts this project's connection for this Agent and is refused while a reply or permission request is pending. Lithe never switches accounts by itself when a limit is reached.", + "arguments": [] + } + }, + { + "id": "native.defaultAgentAccount", + "description": "Native UI: Default account", + "platforms": [ + "macos" + ], + "macos": { + "key": "Default account", + "arguments": [] + } + }, + { + "id": "native.agentAccountPreferred", + "description": "Native UI: Preferred", + "platforms": [ + "macos" + ], + "macos": { + "key": "Preferred", + "arguments": [] + } + }, + { + "id": "native.agentAccountSetPreferred", + "description": "Native UI: Set as preferred", + "platforms": [ + "macos" + ], + "macos": { + "key": "Set as preferred", + "arguments": [] + } + }, + { + "id": "native.agentAccountSignInAgain", + "description": "Native UI: Sign in again", + "platforms": [ + "macos" + ], + "macos": { + "key": "Sign in again", + "arguments": [] + } + }, + { + "id": "native.agentAccountRemove", + "description": "Native UI: Remove account", + "platforms": [ + "macos" + ], + "macos": { + "key": "Remove account", + "arguments": [] + } + }, + { + "id": "native.agentAccountRemoveMessage", + "description": "Native UI: Remove this account from Lithe? Its sign-in folder is left on disk untouched.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Remove this account from Lithe? Its sign-in folder is left on disk untouched.", + "arguments": [] + } + }, + { + "id": "native.agentAccountAdd", + "description": "Native UI: Add another account", + "platforms": [ + "macos" + ], + "macos": { + "key": "Add another account", + "arguments": [] + } + }, + { + "id": "native.agentAccountWaiting", + "description": "Native UI: Waiting for browser sign-in…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Waiting for browser sign-in…", + "arguments": [] + } + }, + { + "id": "native.agentAccountSnapshotNote", + "description": "Native UI: As of %@; not refreshed by Lithe.", + "platforms": [ + "macos" + ], + "macos": { + "key": "As of %@; not refreshed by Lithe.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.agentAccountResets", + "description": "Native UI: Resets %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Resets %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.agentAccountPace", + "description": "Native UI: Pace %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Pace %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.agentAccountDefaultName", + "description": "Native UI: Account %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Account %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.agentAccountSignInUnsupported", + "description": "Native UI: This Agent has no account sign-in.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This Agent has no account sign-in.", + "arguments": [] + } + }, + { + "id": "native.agentAccountSignInFailed", + "description": "Native UI: Sign-in did not finish. Check that the Agent's CLI is installed, then try again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Sign-in did not finish. Check that the Agent's CLI is installed, then try again.", + "arguments": [] + } + }, + { + "id": "native.agentAccountSignInTimedOut", + "description": "Native UI: Sign-in timed out. Try again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Sign-in timed out. Try again.", + "arguments": [] + } + }, + { + "id": "native.agentAccountUsageInfo", + "description": "Native UI: Usage is whatever the Agent last reported. Claude Code reports it while a reply runs; Codex is also refreshed about once a minute while the Agent panel is open. Lithe never queries the account itself, so a value can be out of date, and an account you are not using keeps its last values until you switch back. A window that has already reset is shown without a reset time or pace.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Usage is whatever the Agent last reported. Claude Code reports it while a reply runs; Codex is also refreshed about once a minute while the Agent panel is open. Lithe never queries the account itself, so a value can be out of date, and an account you are not using keeps its last values until you switch back. A window that has already reset is shown without a reset time or pace.", + "arguments": [] + } + }, + { + "id": "native.databaseIsNowAPluginInstallItFromTheMarketplace", + "description": "Native UI: Database is now a plugin. Install it from the Marketplace, and your saved connections and passwords move into it.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Database is now a plugin. Install it from the Marketplace, and your saved connections and passwords move into it.", + "arguments": [] + } + }, + { + "id": "native.openMarketplace", + "description": "Native UI: Open Marketplace", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open Marketplace", + "arguments": [] + } + }, + { + "id": "native.suggestedReplies", + "description": "Native UI: Suggested replies", + "platforms": [ + "macos" + ], + "macos": { + "key": "Suggested replies", + "arguments": [] + } + }, + { + "id": "native.changedTheWorkingDirectoryTo", + "description": "Native UI: Changed the working directory to %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Changed the working directory to %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.askedForAccessTo", + "description": "Native UI: Asked for access to %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Asked for access to %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.chapter", + "description": "Native UI: Chapter: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Chapter: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.startInNewConversation", + "description": "Native UI: Start in new conversation", + "platforms": [ + "macos" + ], + "macos": { + "key": "Start in new conversation", + "arguments": [] + } + }, + { + "id": "native.suggested", + "description": "Native UI: Suggested", + "platforms": [ + "macos" + ], + "macos": { + "key": "Suggested", + "arguments": [] + } + }, + { + "id": "native.withdrawn", + "description": "Native UI: Withdrawn", + "platforms": [ + "macos" + ], + "macos": { + "key": "Withdrawn", + "arguments": [] + } + }, + { + "id": "native.startedInClaudeDesktop", + "description": "Native UI: Started in Claude Desktop", + "platforms": [ + "macos" + ], + "macos": { + "key": "Started in Claude Desktop", + "arguments": [] + } + }, + { + "id": "native.handedOff", + "description": "Native UI: Handed off", + "platforms": [ + "macos" + ], + "macos": { + "key": "Handed off", + "arguments": [] + } + }, + { + "id": "native.linkedThisConversationToAPullRequest", + "description": "Native UI: Linked this conversation to a pull request", + "platforms": [ + "macos" + ], + "macos": { + "key": "Linked this conversation to a pull request", + "arguments": [] + } + }, + { + "id": "native.unlinkedThePullRequest", + "description": "Native UI: Unlinked the pull request", + "platforms": [ + "macos" + ], + "macos": { + "key": "Unlinked the pull request", + "arguments": [] + } + }, + { + "id": "native.watchingPullRequestChecksAndFixingFailures", + "description": "Native UI: Watching pull request checks and fixing failures", + "platforms": [ + "macos" + ], + "macos": { + "key": "Watching pull request checks and fixing failures", + "arguments": [] + } + }, + { + "id": "native.watchingPullRequestChecks", + "description": "Native UI: Watching pull request checks", + "platforms": [ + "macos" + ], + "macos": { + "key": "Watching pull request checks", + "arguments": [] + } + }, + { + "id": "native.turnedOffAutoMerge", + "description": "Native UI: Turned off auto-merge", + "platforms": [ + "macos" + ], + "macos": { + "key": "Turned off auto-merge", + "arguments": [] + } + }, + { + "id": "native.turnedOnAutoMerge", + "description": "Native UI: Turned on auto-merge", + "platforms": [ + "macos" + ], + "macos": { + "key": "Turned on auto-merge", + "arguments": [] + } + }, + { + "id": "native.visualization", + "description": "Native UI: Visualization", + "platforms": [ + "macos" + ], + "macos": { + "key": "Visualization", + "arguments": [] + } + }, + { + "id": "native.showVisualization", + "description": "Native UI: Show visualization", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show visualization", + "arguments": [] + } + }, + { + "id": "native.hideVisualization", + "description": "Native UI: Hide visualization", + "platforms": [ + "macos" + ], + "macos": { + "key": "Hide visualization", + "arguments": [] + } + } + ] +} diff --git a/shared/platform-feature-matrix/features/agent-acp-conversation.json b/shared/platform-feature-matrix/features/agent-acp-conversation.json index 568ea80c57..e6c9c3f348 100644 --- a/shared/platform-feature-matrix/features/agent-acp-conversation.json +++ b/shared/platform-feature-matrix/features/agent-acp-conversation.json @@ -72,12 +72,14 @@ "windows/tauri/src/features/agent/stores/agent-connection-service.ts", ".agents/notes/implemented/architecture/2026-10-17-windows-agent-connection-ownership.md", "windows/tauri/src/features/agent/services/agent-transcript-navigation.ts", - "windows/tauri/src/features/agent/services/agent-transcript-navigation.test.ts" + "windows/tauri/src/features/agent/services/agent-transcript-navigation.test.ts", + "windows/tauri/src/features/agent/components/agent-usage-panel.tsx", + "windows/tauri/src/features/agent/stores/agent-parity.test.ts" ], - "implementationStatus": "partial", + "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Agent", - "verification": "macOS 原生轮次导航:宽编辑页下滚动条贴消息面板右边缘,消息与输入区按各自可用宽度的 80% 居中,窄面板可收缩;输入框默认两行书写区,附件“+”位于底栏最左侧、Agent图标之前,顶部附件/额度灰栏与占位完全移除;额度保留在底栏靠右,输入、换行和窗口变窄后按实际内容增高,书写区到达 240pt 或会话 60% 的可用高度约束后在框内滚动,能到达草稿末尾,删除文字、移除附件及发送清空后收缩,命令建议浮在上方且不遮挡输入;指针进入右侧轨道显示短标记,视口内轮次显示长标记,权限卡片或通知出现后完全被遮住的轮次改为短标记、关闭后无需滚动即恢复;正文停在中部时在长轨道和浮动命令列表上滚轮只交给对应覆盖层,到列表边界也不由正文拦截器抢走;预览上方用户输入、下方模型正文且无推理或工具日志;离开收起标记和预览;点击首轮能定位真实用户消息,持续输出、工具、权限、窗口缩放不抢回位置,回到底部或发送新消息恢复跟随;搜索与会话切换无旧预览或旧跳转。深浅主题和长列表验证滚轮、可访问标签及调整面板时预览收起。Windows 原生同等轮次轨道尚未接入,不能据 macOS 回归标记已验证。macOS Markdown 表格:深浅主题和窄宽面板下,长中文、加粗文字及无空格长标识符的表头与正文应换行并按完整内容增高,不以省略号隐藏尾部;短列按内容占宽,宽表保留共享横向滚动条并能滚至最末列。macOS + Codex + API Key:验证发消息前的上游配置选项和确认;无自定义服务商或 API Key 时确认仍显示本机顶层默认模型、保留密钥缺失错误;修改本机默认模型后重连,连续新建空会话确认不再使用旧导入模型;配置不在上游目录中的模型时,确认新会话优先使用目录内的上游推荐值;推荐值缺失、版本过旧或不可选时,按官方目录顺序选择第一个可用模型,确认后保留完整官方列表且不显示临时补入的无效模型;有效配置模型保持不变,拒绝与超时不显示假成功且 Codex 模型菜单显示 OpenAI 标志;验证工具输入/输出/文件/变更、权限拒绝、停止确认及超时重连、历史加载失败保留记录;使用确定性时钟推进静默、持续工具更新超过原十分钟期限,确认只出现静默提醒、轮次保持忙碌且没有自动取消或重发;继续等待只关闭提醒,新进度清除提醒并重新计时;多个授权等待超过五分钟仍可答复,全部完成后重新计时,后台会话不污染当前状态;等待授权期间其他更新、会话与 EOF 不被阻塞;明确停止仍保持忙碌直到确认,不确认时连接与进程树有界停止。Windows 旧 ACP 前端移除二十秒首条消息失败,静默提醒、继续等待、权限抑制与定时器清理由手动调度器覆盖;共享 Host 接入与原生端到端验收仍待完成。临时项目跑通读文件→修改→执行测试→追问,复用文档观察和脏缓冲区保存保护。检查无搜索框的完整模型目录(名称、上游顺序、分组、空目录)、品牌图标/选中状态、无模型说明、权限说明、思考/速度子菜单、确认/失败与会话切换关闭弹出面板;检查项目切换、退出清理,以及 CC GUI 风格空态、搜索、标签、自适应增长输入区和配置菜单在窄宽面板及深浅主题下的布局。Windows 端到端仍待验证;Claude API Key 鉴权与完整工具流程由独立能力行记录。 安装包环境下打开 Agent 面板,确认 Codex/Claude 图标从 Contents/Resources 加载且不依赖构建目录;资源包或图标缺失时显示默认图标而不闪退,读取前后包内文件清单和内容不变。 检查紧凑配置菜单:模型和设置子选项最小行高 26pt、权限模式最小行高 44pt,上下内边距 4pt;面板高度随行高收紧;权限面板按实际内容高度调整并保留 330pt 上限,短列表的双行说明不需要滚动即可完整显示。深浅主题下通过实际 SwiftUI/AppKit 渲染与原生鼠标事件验证双行说明、滚动到模型/权限长列表末项并选择,原始 ID 和选中回调不变;完整目录及选中标记正常。 点击任务、运行中、编辑确认展示当前会话的计划、活跃工具及去重文件;检查打开文件、分段差异、保留全部和逐个/全部回撤。保留后继续修改并回撤只能撤销新增修改;核对后台会话、未保存缓冲区、文件冲突、缺失或截断证据、编码、符号链接越界及批量部分失败不会覆盖用户内容。确认回撤后的编辑器刷新和连接关闭时任务清理,统计缺失不能伪造行数。 同一标签保留 A→B 后断连重连并回放,再执行 B→C 和回撤必须恢复 B;已回撤版本重连不再出现,失败回放保留基线、变更证据重新出现,关闭重开释放确认。创建路径被工作区内末端链接替换时保全链接及目标;最后快照后原生保存必须使回撤冲突;移动期间外部替换后核对实际移出的字节并恢复,原路径被重建时保全两个版本且提示恢复位置,Trash 失败不确认成功。 确认新请求无输出时显示等待响应而不显示推理;Codex 无标题的明确重试元数据切换到重试,新输出或工具事件恢复状态,授权和停止优先,迟到和后台会话事件不污染当前轮次;历史工具不计入当前执行状态。静默推进时钟不能伪造重试,正常轮次无绝对时限,取消确认保持。 响应期间点击审批、模型和思考选项,确认底栏预选值立即更新且当前轮不发送配置请求;连续选择取最后一次,改回已确认值撤销。结束或取消确认后先确认模型、再按最新选项逐项确认,全部完成前不能发送下一条;验证换模型后选项消失、拒绝或发送失败有明确错误并恢复已确认值。切换会话保持各自预选,后台完成只更新对应会话,断连清除预选且迟到确认不恢复。深浅主题下通过原生鼠标打开菜单并选择。Windows 同等预选能力尚未接入。 检查速度与思考强度子菜单:主模型面板宽度和位置不变,子面板顶端贴齐触发行、只包住选项且没有顶部整块空白;右侧空间不足时向左展开。深浅主题下验证上游名称原文、简短说明、已确认值勾选、Esc 只关闭子菜单、选择关闭两级菜单并保留原始 ID;长说明按实际宽度换行,长列表仍能选择末项,会话切换与锚点拆除释放全部子窗口。 同一配置 ID 的 choices、currentValue 或说明变化时,已打开的子菜单关闭,模型面板保留;重新打开使用最新选项、说明和勾选。原生窗口回归覆盖无搜索框时直接选择完整目录模型,以及键盘滚动后 mouseMoved 恢复指针选择;其他选项更新不关闭当前子菜单。 检查输入框上方任务/子代理和文件修改摘要按内容出现,空态移除状态区和预留高度;点击每项摘要直接打开原详情,长列表末项、文件打开/保留、差异与回撤确认保持。标题栏仅一行,独立历史按钮进入原管理页,验证搜索、收藏、改名、可恢复移除、导出及返回不丢草稿和连接。发送旁只保留一个会话详情入口,有用量显示圆环,未上报显示信息图标,点击在同一可滚动面板查看上下文、会话及Token统计,顶部不重复显示信息按钮,Agent/审批/模型在框内底部;深浅主题和320/417pt窄面板验证所有入口、关闭弹层及会话切换。 分别仅导入 Codex、先导入 Codex 再导入 Claude、反向导入与重复导入,确认来源、模型和关联保持独立;重载旧共享标识及已刷新标识的串用设置,确认正确绑定与提交供应商选择保留,错误关联恢复未配置,订阅和手动供应商不变。 点击输入区底栏 Agent 图标,确认 Claude/Codex 行显示原品牌图标,菜单底边位于触发按钮上方且不覆盖状态栏,切换及已选勾选保持。 Claude 暂时故障时显示正在重连 2/5 至 5/5,继续累计耗时且保持忙碌;恢复输出、错误、取消和断连清除次数。Codex 明确重试通知也显示正在重连;旧轮次通知和无效次数不能把新轮次或已结束会话变为重连。流式文本合并发布不能因状态清理增加每段刷新。 Codex API Key:本地 Responses 服务验证带 Retry-After:180 的 400/401/402/403/404 快速失败,503/断连最多五次原生调用,普通429无工作时有界补重发且与503共用五次预算,额度及已有工具/输出不整轮重发;明确重连次数和持续计时,尚无工作时20秒上限后保留10秒取消确认;已有回复、推理、计划、工具或授权后取消 Host 恢复上限及重复计数终止,原生有限重试仍有效。虚拟时钟推进恢复超过20秒并注入超过五次通知,确认同一轮完成且没有第二条 prompt;首次恢复警告后五分钟显示等待较久、继续等待/停止,后续警告不推迟提醒,实际进度清除。终止错误保留已完成工具与修改,用户明确继续复用原会话;未确认的停止仍有界清理。新建/历史恢复/分叉均使用预算,保持配置、工具、历史和订阅登录刷新。临时helper在进程树结束及启动失败后删除,安装包、适配器、用户CLI和配置不变;自定义ACP保留原生策略。Windows原生helper、对话UI和真实供应商验收待完成。 macOS:在 turnRetrying 后只发送有效 plan(含空 entries)即可退出重连并清空次数,保留计划、忙碌轮次和累计计时;无法解析的 plan 不能清理重连。已有回复/推理不退回等待,权限和停止继续优先;确认完成前仍拒绝第二条消息。确定性回归注入时钟与事件,Windows 同等界面行为仍待验证。 深浅主题检查标题栏无重复Agent名称,空态与底栏品牌资源尺寸保持,按钮仍有共享22pt命中区。 macOS 长会话:在窄面板积累多轮历史,读文件、修改、执行测试期间反复出现和关闭权限卡片,完成回复与统计后连续调整宽度,界面可响应且底部内容可见;主动上滚阅读历史后新文本、工具和权限更新不得拉走位置,回到底部或发送新消息后继续跟随,搜索期间暂停,会话切换或关闭不执行迟到滚动。保留 macOS 13 最低部署目标,旧系统实际运行仍待复验。 原生视口仅发送frame变化通知时也继续底部跟随;翻转坐标夹具先完成首次定位,再增加文档高度并改变视口frame,禁用bounds通知以验证独立通知路径。用户上滚浏览时仍不强制拉到底部,不观察文档估算高度驱动循环。 Claude:新会话仍使用供应商默认模型;会话选择不同模型并完成真实回复后关闭适配器,重连恢复必须保留历史模型并以相同模型发出实际请求。模型重新确认前不得发布加载完成;拒绝、错误确认和超时保留加载失败,禁止排队消息越过确认。分组列表、目录外当前模型和相同默认值均通过标准 ACP 确认;无选择器旧适配器和其他 Agent 保持原有加载行为。Windows 连接桥已接通共享 host,产品集成与真实窗口验收仍待完成。 macOS 活动状态区按需显示任务进度与当前步骤、右侧子代理入口和单独文件修改摘要,复用原详情与回撤。只有全部可见文件的上游统计完整且操作完成时显示增删合计,缺失或溢出时只显示文件数;已保留版本不再计入。空态高度与间距归零,审阅进行中或失败仍保留可操作入口。原生回归覆盖深浅主题、窄宽度、当前步骤、清空、旧弹层清理、长列表与文件动作。", - "notes": "2026-10-16 Windows:Agent 面板接入共享连接桥——`agent-connection.model.ts` 是按窗口与项目隔离的连接单例,会话标签、消息流、工具与权限提示、输入框、取消与重连共用同一份模型;传输经 `agent-transport.ts` 复用 `agent_open` / `agent_send` / `agent_close` 事件,不新增第二份协议实现。 真实适配器端到端、窄宽面板与中英文验收仍待执行。 2026-10-17 Windows:连接所有权按 review 收紧——并发启动只开一个 ACP 进程,`stop()` 等待进行中的启动并覆盖停止窗口;断连清空附着、响应、取消、配置等待与权限队列,只保留消息、轮次统计和完整历史标记;连接身份为「工作区 + Agent + 认证方式 + 自定义命令/参数」,任一项变化先停旧进程再重连。 草稿与附件提升到面板,设置/历史往返不再丢失。 2026-10-18 Windows:新增右侧轮次轨道,纯函数按用户消息切分轮次并生成 prompt/reply 预览,IntersectionObserver 只驱动轨道自身的可见标记;悬停预览与点击跳转对齐 macOS,真机验收待执行。" + "verification": "macOS 原生轮次导航:宽编辑页下滚动条贴消息面板右边缘,消息与输入区按各自可用宽度的 80% 居中,窄面板可收缩;输入框默认两行书写区,附件“+”位于底栏最左侧、Agent图标之前,顶部附件/额度灰栏与占位完全移除;额度保留在底栏靠右,输入、换行和窗口变窄后按实际内容增高,书写区到达 240pt 或会话 60% 的可用高度约束后在框内滚动,能到达草稿末尾,删除文字、移除附件及发送清空后收缩,命令建议浮在上方且不遮挡输入;指针进入右侧轨道显示短标记,视口内轮次显示长标记,权限卡片或通知出现后完全被遮住的轮次改为短标记、关闭后无需滚动即恢复;正文停在中部时在长轨道和浮动命令列表上滚轮只交给对应覆盖层,到列表边界也不由正文拦截器抢走;预览上方用户输入、下方模型正文且无推理或工具日志;离开收起标记和预览;点击首轮能定位真实用户消息,持续输出、工具、权限、窗口缩放不抢回位置,回到底部或发送新消息恢复跟随;搜索与会话切换无旧预览或旧跳转。深浅主题和长列表验证滚轮、可访问标签及调整面板时预览收起。Windows 已有右侧轮次轨道、悬停预览和点击跳转;目标平台验收仍待完成。macOS Markdown 表格:深浅主题和窄宽面板下,长中文、加粗文字及无空格长标识符的表头与正文应换行并按完整内容增高,不以省略号隐藏尾部;短列按内容占宽,宽表保留共享横向滚动条并能滚至最末列。macOS + Codex + API Key:验证发消息前的上游配置选项和确认;无自定义服务商或 API Key 时确认仍显示本机顶层默认模型、保留密钥缺失错误;修改本机默认模型后重连,连续新建空会话确认不再使用旧导入模型;配置不在上游目录中的模型时,确认新会话优先使用目录内的上游推荐值;推荐值缺失、版本过旧或不可选时,按官方目录顺序选择第一个可用模型,确认后保留完整官方列表且不显示临时补入的无效模型;有效配置模型保持不变,拒绝与超时不显示假成功且 Codex 模型菜单显示 OpenAI 标志;验证工具输入/输出/文件/变更、权限拒绝、停止确认及超时重连、历史加载失败保留记录;使用确定性时钟推进静默、持续工具更新超过原十分钟期限,确认只出现静默提醒、轮次保持忙碌且没有自动取消或重发;继续等待只关闭提醒,新进度清除提醒并重新计时;多个授权等待超过五分钟仍可答复,全部完成后重新计时,后台会话不污染当前状态;等待授权期间其他更新、会话与 EOF 不被阻塞;明确停止仍保持忙碌直到确认,不确认时连接与进程树有界停止。Windows Agent 面板已接入共享 Host,连接、会话、工具/权限、取消、计划/思考、上下文用量与基础轮次统计已存在;Windows 已映射共享 Host 的重试/静默活动状态;真实供应商端到端验收仍待完成。临时项目跑通读文件→修改→执行测试→追问,复用文档观察和脏缓冲区保存保护。检查无搜索框的完整模型目录(名称、上游顺序、分组、空目录)、品牌图标/选中状态、无模型说明、权限说明、思考/速度子菜单、确认/失败与会话切换关闭弹出面板;检查项目切换、退出清理,以及 CC GUI 风格空态、搜索、标签、自适应增长输入区和配置菜单在窄宽面板及深浅主题下的布局。Windows 端到端仍待验证;Claude API Key 鉴权与完整工具流程由独立能力行记录。 安装包环境下打开 Agent 面板,确认 Codex/Claude 图标从 Contents/Resources 加载且不依赖构建目录;资源包或图标缺失时显示默认图标而不闪退,读取前后包内文件清单和内容不变。 检查紧凑配置菜单:模型和设置子选项最小行高 26pt、权限模式最小行高 44pt,上下内边距 4pt;面板高度随行高收紧;权限面板按实际内容高度调整并保留 330pt 上限,短列表的双行说明不需要滚动即可完整显示。深浅主题下通过实际 SwiftUI/AppKit 渲染与原生鼠标事件验证双行说明、滚动到模型/权限长列表末项并选择,原始 ID 和选中回调不变;完整目录及选中标记正常。 点击任务、运行中、编辑确认展示当前会话的计划、活跃工具及去重文件;检查打开文件、分段差异、保留全部和逐个/全部回撤。保留后继续修改并回撤只能撤销新增修改;核对后台会话、未保存缓冲区、文件冲突、缺失或截断证据、编码、符号链接越界及批量部分失败不会覆盖用户内容。确认回撤后的编辑器刷新和连接关闭时任务清理,统计缺失不能伪造行数。 同一标签保留 A→B 后断连重连并回放,再执行 B→C 和回撤必须恢复 B;已回撤版本重连不再出现,失败回放保留基线、变更证据重新出现,关闭重开释放确认。创建路径被工作区内末端链接替换时保全链接及目标;最后快照后原生保存必须使回撤冲突;移动期间外部替换后核对实际移出的字节并恢复,原路径被重建时保全两个版本且提示恢复位置,Trash 失败不确认成功。 确认新请求无输出时显示等待响应而不显示推理;Codex 无标题的明确重试元数据切换到重试,新输出或工具事件恢复状态,授权和停止优先,迟到和后台会话事件不污染当前轮次;历史工具不计入当前执行状态。静默推进时钟不能伪造重试,正常轮次无绝对时限,取消确认保持。 响应期间点击审批、模型和思考选项,确认底栏预选值立即更新且当前轮不发送配置请求;连续选择取最后一次,改回已确认值撤销。结束或取消确认后先确认模型、再按最新选项逐项确认,全部完成前不能发送下一条;验证换模型后选项消失、拒绝或发送失败有明确错误并恢复已确认值。切换会话保持各自预选,后台完成只更新对应会话,断连清除预选且迟到确认不恢复。深浅主题下通过原生鼠标打开菜单并选择。Windows 已接入下一轮配置预选及按序确认。 检查速度与思考强度子菜单:主模型面板宽度和位置不变,子面板顶端贴齐触发行、只包住选项且没有顶部整块空白;右侧空间不足时向左展开。深浅主题下验证上游名称原文、简短说明、已确认值勾选、Esc 只关闭子菜单、选择关闭两级菜单并保留原始 ID;长说明按实际宽度换行,长列表仍能选择末项,会话切换与锚点拆除释放全部子窗口。 同一配置 ID 的 choices、currentValue 或说明变化时,已打开的子菜单关闭,模型面板保留;重新打开使用最新选项、说明和勾选。原生窗口回归覆盖无搜索框时直接选择完整目录模型,以及键盘滚动后 mouseMoved 恢复指针选择;其他选项更新不关闭当前子菜单。 检查输入框上方任务/子代理和文件修改摘要按内容出现,空态移除状态区和预留高度;点击每项摘要直接打开原详情,长列表末项、文件打开/保留、差异与回撤确认保持。标题栏仅一行,独立历史按钮进入原管理页,验证搜索、收藏、改名、可恢复移除、导出及返回不丢草稿和连接。发送旁只保留一个会话详情入口,有用量显示圆环,未上报显示信息图标,点击在同一可滚动面板查看上下文、会话及Token统计,顶部不重复显示信息按钮,Agent/审批/模型在框内底部;深浅主题和320/417pt窄面板验证所有入口、关闭弹层及会话切换。 分别仅导入 Codex、先导入 Codex 再导入 Claude、反向导入与重复导入,确认来源、模型和关联保持独立;重载旧共享标识及已刷新标识的串用设置,确认正确绑定与提交供应商选择保留,错误关联恢复未配置,订阅和手动供应商不变。 点击输入区底栏 Agent 图标,确认 Claude/Codex 行显示原品牌图标,菜单底边位于触发按钮上方且不覆盖状态栏,切换及已选勾选保持。 Claude 暂时故障时显示正在重连 2/5 至 5/5,继续累计耗时且保持忙碌;恢复输出、错误、取消和断连清除次数。Codex 明确重试通知也显示正在重连;旧轮次通知和无效次数不能把新轮次或已结束会话变为重连。流式文本合并发布不能因状态清理增加每段刷新。 Codex API Key:本地 Responses 服务验证带 Retry-After:180 的 400/401/402/403/404 快速失败,503/断连最多五次原生调用,普通429无工作时有界补重发且与503共用五次预算,额度及已有工具/输出不整轮重发;明确重连次数和持续计时,尚无工作时20秒上限后保留10秒取消确认;已有回复、推理、计划、工具或授权后取消 Host 恢复上限及重复计数终止,原生有限重试仍有效。虚拟时钟推进恢复超过20秒并注入超过五次通知,确认同一轮完成且没有第二条 prompt;首次恢复警告后五分钟显示等待较久、继续等待/停止,后续警告不推迟提醒,实际进度清除。终止错误保留已完成工具与修改,用户明确继续复用原会话;未确认的停止仍有界清理。新建/历史恢复/分叉均使用预算,保持配置、工具、历史和订阅登录刷新。临时helper在进程树结束及启动失败后删除,安装包、适配器、用户CLI和配置不变;自定义ACP保留原生策略。Windows 重试策略由共享 Host 提供,Windows 已呈现重试状态;真实供应商验收待完成。 macOS:在 turnRetrying 后只发送有效 plan(含空 entries)即可退出重连并清空次数,保留计划、忙碌轮次和累计计时;无法解析的 plan 不能清理重连。已有回复/推理不退回等待,权限和停止继续优先;确认完成前仍拒绝第二条消息。确定性回归注入时钟与事件,Windows 同等界面行为仍待验证。 深浅主题检查标题栏无重复Agent名称,空态与底栏品牌资源尺寸保持,按钮仍有共享22pt命中区。 macOS 长会话:在窄面板积累多轮历史,读文件、修改、执行测试期间反复出现和关闭权限卡片,完成回复与统计后连续调整宽度,界面可响应且底部内容可见;主动上滚阅读历史后新文本、工具和权限更新不得拉走位置,回到底部或发送新消息后继续跟随,搜索期间暂停,会话切换或关闭不执行迟到滚动。保留 macOS 13 最低部署目标,旧系统实际运行仍待复验。 原生视口仅发送frame变化通知时也继续底部跟随;翻转坐标夹具先完成首次定位,再增加文档高度并改变视口frame,禁用bounds通知以验证独立通知路径。用户上滚浏览时仍不强制拉到底部,不观察文档估算高度驱动循环。 Claude:新会话仍使用供应商默认模型;会话选择不同模型并完成真实回复后关闭适配器,重连恢复必须保留历史模型并以相同模型发出实际请求。模型重新确认前不得发布加载完成;拒绝、错误确认和超时保留加载失败,禁止排队消息越过确认。分组列表、目录外当前模型和相同默认值均通过标准 ACP 确认;无选择器旧适配器和其他 Agent 保持原有加载行为。Windows Agent 面板与连接桥已接入共享 Host;真实供应商和目标平台端到端验收仍待完成。 macOS 活动状态区按需显示任务进度与当前步骤、右侧子代理入口和单独文件修改摘要,复用原详情与回撤。只有全部可见文件的上游统计完整且操作完成时显示增删合计,缺失或溢出时只显示文件数;已保留版本不再计入。空态高度与间距归零,审阅进行中或失败仍保留可操作入口。原生回归覆盖深浅主题、窄宽度、当前步骤、清空、旧弹层清理、长列表与文件动作。 Windows:在真实 Tauri 窗口执行 Windows 已接入历史归档、原生子代理、受保护的文件修改审阅、下一轮配置预选、重试与静默状态,以及模型调用与会话统计。运行时验收按相关独立能力记录;真实 Codex/Claude 服务调用仍待验证。 单元测试不替代运行时验收,未执行的真实服务及平台动作保持 pending。", + "notes": "2026-10-22:Windows 已接入历史归档、原生子代理、受保护的文件修改审阅、下一轮配置预选、重试与静默状态,以及模型调用与会话统计。运行时验收按相关独立能力记录;真实 Codex/Claude 服务调用仍待验证。" } diff --git a/shared/platform-feature-matrix/features/agent-archived-conversation-history.json b/shared/platform-feature-matrix/features/agent-archived-conversation-history.json index 34daddfb3b..f51a69ea00 100644 --- a/shared/platform-feature-matrix/features/agent-archived-conversation-history.json +++ b/shared/platform-feature-matrix/features/agent-archived-conversation-history.json @@ -17,11 +17,17 @@ }, "windows": { "evidence": [ - "rust/lithe-agent-host/src/codex_history.rs" + "rust/lithe-agent-host/src/codex_history.rs", + "windows/tauri/src/features/agent/components/agent-history-list.tsx", + "windows/tauri/src/features/agent/stores/agent-connection.model.ts", + "windows/tauri/src/features/agent/services/agent-transport.ts", + "windows/tauri/src/features/agent/components/agent-archived-history.tsx", + "windows/tauri/src/features/agent/stores/agent-parity.test.ts" ], - "implementationStatus": "missing", - "verificationStatus": "not-applicable" + "implementationStatus": "implemented", + "verificationStatus": "pending" }, "owner": "Agent", - "verification": "macOS:Codex(订阅与 API Key 两种模式)打开历史页,确认“已归档(N)”默认折叠、只含当前工作区(路径大小写/尾斜杠按 codex-acp 规则)的归档会话且带“已归档”标记,与 Lithe 本地“已移除”互不影响;搜索标题或 ID 能命中归档会话并自动展开,清空搜索后恢复折叠;超过 Host 分页上限时,即使当前工作区结果为空、分组折叠或搜索无命中也显示“仅显示最近的归档会话”,不误报没有历史/没有匹配;让归档和活跃响应分别先返回,已加载与未打开的活跃转归档会话都在本轮刷新后仅出现在归档分组;在“已移除”中恢复归档会话只清除本地隐藏标记,保留标题与收藏,不取消归档或加载;浏览、刷新、搜索不改变 ~/.codex 下的归档状态。点击归档会话先弹出确认,取消不发送任何请求,确认后会话移回活跃历史并打开,失败时保留在归档分组并显示错误。恢复成功先于此前活跃与归档列表响应时,两个旧响应均失效,重新查询双列表;旧响应无论何种先后顺序、紧接成功事件或异步等待返回后到达,都不能把已恢复会话重新放回归档、结束新刷新或覆盖完整性状态。切换工作区、切换 Agent 或断开连接后归档列表清空,不混入旧结果。Claude 连接显示“Claude 不支持归档会话”且不请求归档列表。Windows 共享 Host 已提供协议,页面待实现。 Codex 历史时间与排序使用上游 recencyAt:只查看/恢复旧会话、刷新、重命名或收藏后时间和顺序保持;新一轮对话活动出现后更新。活跃与归档一致,缺失活动时间不回退到元数据更新时间或当前时间;读取失败保留已有列表。保留历史页和弹框外观。" + "verification": "macOS:Codex(订阅与 API Key 两种模式)打开历史页,确认“已归档(N)”默认折叠、只含当前工作区(路径大小写/尾斜杠按 codex-acp 规则)的归档会话且带“已归档”标记,与 Lithe 本地“已移除”互不影响;搜索标题或 ID 能命中归档会话并自动展开,清空搜索后恢复折叠;超过 Host 分页上限时,即使当前工作区结果为空、分组折叠或搜索无命中也显示“仅显示最近的归档会话”,不误报没有历史/没有匹配;让归档和活跃响应分别先返回,已加载与未打开的活跃转归档会话都在本轮刷新后仅出现在归档分组;在“已移除”中恢复归档会话只清除本地隐藏标记,保留标题与收藏,不取消归档或加载;浏览、刷新、搜索不改变 ~/.codex 下的归档状态。点击归档会话先弹出确认,取消不发送任何请求,确认后会话移回活跃历史并打开,失败时保留在归档分组并显示错误。恢复成功先于此前活跃与归档列表响应时,两个旧响应均失效,重新查询双列表;旧响应无论何种先后顺序、紧接成功事件或异步等待返回后到达,都不能把已恢复会话重新放回归档、结束新刷新或覆盖完整性状态。切换工作区、切换 Agent 或断开连接后归档列表清空,不混入旧结果。Claude 连接显示“Claude 不支持归档会话”且不请求归档列表。共享 Host 与 Windows Tauri/React 已接入归档列表、分页/完整性状态、与活跃历史独立的新鲜度缓存和确认后恢复。独立状态机测试覆盖恢复与并发旧响应;本次真实 Tauri 验收使用受控 ACP fixture,真实 Codex 账户执行仍待完成。 Codex 历史时间与排序使用上游 recencyAt:只查看/恢复旧会话、刷新、重命名或收藏后时间和顺序保持;新一轮对话活动出现后更新。活跃与归档一致,缺失活动时间不回退到元数据更新时间或当前时间;读取失败保留已有列表。保留历史页和弹框外观。", + "notes": "2026-10-22:Windows 已接入 Codex 归档分组、历史页显示、恢复命令及并发响应保护。真实 Codex 账户端到端验收仍待完成。" } diff --git a/shared/platform-feature-matrix/features/agent-chat-editor-placement.json b/shared/platform-feature-matrix/features/agent-chat-editor-placement.json index 72a89a7372..8b3b708d2f 100644 --- a/shared/platform-feature-matrix/features/agent-chat-editor-placement.json +++ b/shared/platform-feature-matrix/features/agent-chat-editor-placement.json @@ -2,7 +2,7 @@ "id": "agent-chat-editor-placement", "area": "AI", "group": "Agent 对话", - "capability": "在编辑区标签页与 AI 工具窗口之间移动对话(按钮、右键菜单或把 AI 工具窗口图标拖到编辑区),编辑区使用居中对话布局与会话详情栏,工具窗口展示历史列表,共享会话、标题与未发送草稿,共用原单行标题、独立历史管理与紧凑活动摘要,统计和上下文明细按需展开", + "capability": "在编辑区标签页与 AI 工具窗口之间移动对话(按钮、右键菜单或把 AI 工具窗口图标拖到编辑区),编辑区使用居中对话布局与会话详情栏,工具窗口展示历史列表,共享会话、标题与未发送草稿,共用原单行标题、独立历史管理与紧凑活动摘要,统计和上下文明细按需展开;macOS 编辑区对话采用 Claude Desktop 式外观:中性灰用户气泡、圆角卡片输入框、卡片下方的审批模式/模型/额度/上下文环状态行,详情栏为可折叠的上下文、更改、会话统计分区", "macos": { "implementationStatus": "implemented", "verificationStatus": "pending", @@ -19,7 +19,11 @@ "macos/Sources/Lithe/Views/Workbench/WorkbenchToolWindowDock.swift", "macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift", "macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift", - "macos/Tests/LitheTests/ToolWindowDockingTests.swift" + "macos/Tests/LitheTests/ToolWindowDockingTests.swift", + "macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift", + "macos/Tests/LitheTests/AgentTranscriptReplay.swift", + "macos/Tests/LitheTests/AgentTranscriptScrollTests.swift", + ".agents/notes/implemented/feature/2026-10-23-macos-agent-editor-large-history-and-claude-layout.md" ] }, "windows": { @@ -40,5 +44,5 @@ ] }, "owner": "Agent", - "verification": "深浅主题下使用标题栏位置按钮、工具窗口图标拖放和右键菜单在编辑区与工具窗口之间移动同一会话;重复打开仅聚焦,不创建会话或重连,文字与附件草稿保持。历史按钮进入原搜索/收藏/改名/可恢复移除/导出管理页,选择原连接会话后返回聊天。关闭或反向位置按钮返回工具窗口,共享标签排序、分组和焦点语义不变。两种位置共用单行标题、输入框上方按内容显示紧凑活动摘要、框内默认两行书写区,没有顶部条或占位;附件“+”在底栏Agent图标左侧,同栏保留审批/模型选择和靠右的订阅额度、发送旁唯一会话详情入口;收到用量显示圆环,未上报显示信息图标,入口位置不变。编辑区聊天列最大716pt、输入框最大700pt,窄窗口自然收缩,宽编辑页可通过设置菜单显示右侧上下文构成与会话统计详情;窄窗口仍可通过发送旁同一入口在可滚动弹窗查看相同数据,顶部不重复显示信息按钮。验证320/417/900/1400pt和深浅主题、各项摘要详情及文件回撤、上下文折叠、统计持久化、复制消息/对话、隐藏工具栏与会话切换的弹层清理。会话详情弹窗与右侧详情栏均不展示更改区、Git分支、变更文件数、Agent编辑行数或未编辑文件提示;文件活动栏及回撤仍可用;MCP服务器清单没有后端数据时不编造;本次原生宿主验证不能替代真实Agent完整产品验收。两种位置共用按内容出现的紧凑活动摘要:任务/子代理一行、文件修改摘要一行;空态移除状态区和间距,输入框与正文位置及最小高度正确,计划清空、保留全部和切换会话关闭旧弹层。窄面板长标题不覆盖子代理入口,文件摘要继续打开原有差异、保留与回撤。 Windows 已实现编辑区标签与工具窗口之间的放置切换:编辑区使用最大 716pt 居中列、最大 700pt 输入框和可开关的会话详情栏;工具窗口在编辑区占用会话时转为历史列表,选择历史会话会聚焦其已有标签或新开标签。同一会话重复打开只聚焦现有标签,关闭标签或反向位置按钮返回工具窗口,未发送草稿与附件按会话共享。本次原生宿主验证不能替代真实Agent完整产品验收,Windows 真机验收待执行。 Windows:A 工作区编辑区会话保留挂载,切换 B 并连接 Agent 后再返回 A,侧栏仍从 A 的 buffers 判断会话已放置,不重复出现输入框;切换 Agent 后仅当前 Agent 的标签接管侧栏。" + "verification": "深浅主题下使用标题栏位置按钮、工具窗口图标拖放和右键菜单在编辑区与工具窗口之间移动同一会话;重复打开仅聚焦,不创建会话或重连,文字与附件草稿保持。历史按钮进入原搜索/收藏/改名/可恢复移除/导出管理页,选择原连接会话后返回聊天。关闭或反向位置按钮返回工具窗口,共享标签排序、分组和焦点语义不变。两种位置共用单行标题、输入框上方按内容显示紧凑活动摘要、框内默认两行书写区,没有顶部条或占位;附件“+”在底栏Agent图标左侧,同栏保留审批/模型选择和靠右的订阅额度、发送旁唯一会话详情入口;收到用量显示圆环,未上报显示信息图标,入口位置不变。编辑区聊天列最大716pt、输入框最大700pt,窄窗口自然收缩,宽编辑页可通过设置菜单显示右侧上下文构成与会话统计详情;窄窗口仍可通过发送旁同一入口在可滚动弹窗查看相同数据,顶部不重复显示信息按钮。验证320/417/900/1400pt和深浅主题、各项摘要详情及文件回撤、上下文折叠、统计持久化、复制消息/对话、隐藏工具栏与会话切换的弹层清理。工具窗口的会话详情弹窗不展示更改区、Git分支、变更文件数、Agent编辑行数或未编辑文件提示;文件活动栏及回撤仍可用;MCP服务器清单没有后端数据时不编造;本次原生宿主验证不能替代真实Agent完整产品验收。两种位置共用按内容出现的紧凑活动摘要:任务/子代理一行、文件修改摘要一行;空态移除状态区和间距,输入框与正文位置及最小高度正确,计划清空、保留全部和切换会话关闭旧弹层。窄面板长标题不覆盖子代理入口,文件摘要继续打开原有差异、保留与回撤。 Windows 已实现编辑区标签与工具窗口之间的放置切换:编辑区使用最大 716pt 居中列、最大 700pt 输入框和可开关的会话详情栏;工具窗口在编辑区占用会话时转为历史列表,选择历史会话会聚焦其已有标签或新开标签。同一会话重复打开只聚焦现有标签,关闭标签或反向位置按钮返回工具窗口,未发送草稿与附件按会话共享。本次原生宿主验证不能替代真实Agent完整产品验收,Windows 真机验收待执行。 Windows:A 工作区编辑区会话保留挂载,切换 B 并连接 Agent 后再返回 A,侧栏仍从 A 的 buffers 判断会话已放置,不重复出现输入框;切换 Agent 后仅当前 Agent 的标签接管侧栏。 macOS 工具窗口正文在深浅主题及 280/350/486/620pt 缩放时,左侧仅保留 12pt 基础边距,额外导航留白只在右侧(合计 40pt);检查文字随可用宽度换行,右侧导航不覆盖正文,原生滚动条仍位于面板边缘,浏览历史和最新回复跟随继续正常,编辑页 80% 居中列保持原有行为。 macOS 编辑区(仅居中模式,工具窗口 CC GUI 样式不变):深浅主题下用户消息为灰色圆角气泡;输入卡片内依次是 Agent、权限模式(中文名称)、模型、添加、发送,卡片上方不再显示活动摘要。右侧详情栏为圆角卡片并可单独折叠且重启后保持:上下文卡显示已用百分比、容量、彩色进度条和图例(无分解数据显示“—”);更改卡显示总增删行数和文件列表,超过 5 个折叠为“显示更多”,可全部保留/全部回滚,悬停单个文件可看差异或回滚(回滚前确认);活动卡只显示任务与子代理,不重复文件修改。标题栏详情开关与菜单“显示会话详情”同步。" } diff --git a/shared/platform-feature-matrix/features/agent-claude-api-key-authentication.json b/shared/platform-feature-matrix/features/agent-claude-api-key-authentication.json index 6fd78cd710..61d2c5488f 100644 --- a/shared/platform-feature-matrix/features/agent-claude-api-key-authentication.json +++ b/shared/platform-feature-matrix/features/agent-claude-api-key-authentication.json @@ -17,13 +17,16 @@ ] }, "windows": { - "implementationStatus": "partial", + "implementationStatus": "implemented", "verificationStatus": "pending", "evidence": [ "rust/lithe-agent-host/src/session_routing.rs", - "shared/contracts/rust-core-api.md" + "shared/contracts/rust-core-api.md", + "windows/tauri/src/features/agent/stores/agent-connection.model.ts", + "windows/tauri/src/features/agent/services/agent-transport.ts" ] }, "owner": "Agent", - "verification": "macOS:验证所选 Anthropic Messages 供应商的正常流式回复、读写文件、执行测试、追问、停止和历史恢复。新建与恢复用同一公开 SDK env/settings.env 禁用原生长退避、watchdog、非流式回退,清除冲突路由并只发送有效 x-api-key。隔离配置目录以已安装 CLI/ACP 和本地 HTTP 服务覆盖 400/401/402/403 的快速终止、404 原生模型探测、408/429/500/502/503/504/529 及响应前断连五次短尝试,即使 Retry-After:180 和 x-should-retry:true 也不能等待三分钟;验证实际 HTTP 次数、结构化失败不能当成功、失败后恢复正常流、部分输出后不重放、项目重试配置冲突。Host 虚拟时钟验证二十秒预算不刷新、取消确认前仍忙碌、退避中停止、已有进度禁止重放和其他会话隔离。检查关闭后子进程退出和包内文件哈希不变。生产 macOS UI 和 Windows 真实验收仍待完成,不能把本地假服务当真实供应商或 Windows 验证。" + "verification": "macOS:验证所选 Anthropic Messages 供应商的正常流式回复、读写文件、执行测试、追问、停止和历史恢复。新建与恢复用同一公开 SDK env/settings.env 禁用原生长退避、watchdog、非流式回退,清除冲突路由并只发送有效 x-api-key。隔离配置目录以已安装 CLI/ACP 和本地 HTTP 服务覆盖 400/401/402/403 的快速终止、404 原生模型探测、408/429/500/502/503/504/529 及响应前断连五次短尝试,即使 Retry-After:180 和 x-should-retry:true 也不能等待三分钟;验证实际 HTTP 次数、结构化失败不能当成功、失败后恢复正常流、部分输出后不重放、项目重试配置冲突。Host 虚拟时钟验证二十秒预算不刷新、取消确认前仍忙碌、退避中停止、已有进度禁止重放和其他会话隔离。检查关闭后子进程退出和包内文件哈希不变。生产 macOS UI 和 Windows 真实验收仍待完成,不能把本地假服务当真实供应商或 Windows 验证。 Windows:在真实 Tauri 窗口执行 Claude 的 API Key 路由由共享 Host 执行,Windows 已补齐 turnRetrying、turnActivity 和模式降级通知的呈现。真实 Claude 的认证、新建、恢复、重试与继续回复仍待服务端验收。 单元测试不替代运行时验收,未执行的真实服务及平台动作保持 pending。", + "notes": "2026-10-22:Claude 的 API Key 路由由共享 Host 执行,Windows 已补齐 turnRetrying、turnActivity 和模式降级通知的呈现。真实 Claude 的认证、新建、恢复、重试与继续回复仍待服务端验收。" } diff --git a/shared/platform-feature-matrix/features/agent-claude-desktop-mechanisms.json b/shared/platform-feature-matrix/features/agent-claude-desktop-mechanisms.json new file mode 100644 index 0000000000..bc6629ed49 --- /dev/null +++ b/shared/platform-feature-matrix/features/agent-claude-desktop-mechanisms.json @@ -0,0 +1,31 @@ +{ + "id": "agent-claude-desktop-mechanisms", + "area": "AI", + "group": "Agent 对话", + "capability": "在 Lithe 中打开 Claude Desktop 开始的 Claude Code 会话时,Desktop 专属机制按 Desktop 的方式呈现:每轮总结(wrap_up)显示为 Agent 回复,建议回复显示为按钮(仅最新一轮、未在回复时出现,点击填入输入框);章节显示为分隔线;建议任务、启动会话与移交显示为任务卡片并随撤回/启动更新状态,可在新对话中开始;PR 绑定、CI 监控、自动合并显示为带链接的说明行;切换或申请目录显示为说明行;show_widget 可视化在禁网、非持久、禁止导航的隔离网页中展开显示; 变为 PR 链接;Desktop、浏览器、电脑操作、终端、定时任务与 claude.ai 连接器的其余工具显示可读标题;CI 监控事件与跨会话通知显示为折叠上下文。被拒绝或失败的 Desktop 调用保留为失败的工具行;CLI 会话行为不变", + "macos": { + "evidence": [ + "rust/lithe-agent-host/src/claude_desktop.rs", + "rust/lithe-agent-host/src/lib.rs", + "macos/Sources/LitheAgentConversationModule/Application/AgentDesktopCard.swift", + "macos/Sources/LitheAgentConversationModule/Application/AgentSessionConfiguration.swift", + "macos/Sources/LitheAgentConversationModule/Application/AgentImage.swift", + "macos/Sources/Lithe/Views/Agent/AgentDesktopCardView.swift", + "macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift", + "macos/Tests/LitheTests/AgentDesktopCardTests.swift" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "windows": { + "evidence": [ + "rust/lithe-agent-host/src/claude_desktop.rs", + "windows/tauri/src/features/agent/components/agent-transcript.tsx" + ], + "implementationStatus": "partial", + "verificationStatus": "pending" + }, + "owner": "Agent", + "verification": "macOS:在 Lithe 中恢复一个 Claude Desktop 开始、含章节、建议回复、建议任务(含撤回与启动)、PR 监控、目录切换与 show_widget 的会话,确认各卡片按顺序出现、被拒绝的调用留在折叠过程中显示失败;建议回复只在最后一轮且未在回复时出现,点击后文字进入输入框而不发送;任务卡片“在新对话中开始”新建对话并填入提示;PR 链接在浏览器打开;可视化展开后正常显示内联 SVG/HTML、不发起网络请求、点击链接交给浏览器;深浅主题与 300pt 窄面板布局不溢出。CLI 会话中同样的工具不存在,转录不受影响。运行 AgentDesktopCardTests 与 cargo test -p lithe-agent-host claude_desktop。Windows:共用宿主,Desktop 工具显示可读标题与类型,卡片尚未绘制,退回普通工具行。 可视化页面里点链接不会打开浏览器,页面脚本自己 click 链接也不会;页面刚展开时调用 sendPrompt 不会改动输入框草稿,用户在页面内点击后才会填入。任务撤回或启动失败时,原调用和失败原因在折叠过程里可见,卡片状态不变。压缩前历史里超过 4000 字符的可视化和任务提示完整显示。", + "notes": "2026-10-25:宿主在 `_meta.lithe.claudeDesktop` 附加卡片描述并为所有 Desktop 工具提供可读标题;macOS 原生绘制卡片。Windows 只获得可读工具行,卡片待对齐。用本机 96 个含 Desktop 工具的真实会话离线回放,产出章节 19、建议回复 154、建议任务 30(6 次启动、4 次撤回状态更新)、PR 3、目录 7、可视化 7、计划 854。" +} diff --git a/shared/platform-feature-matrix/features/agent-claude-subscription.json b/shared/platform-feature-matrix/features/agent-claude-subscription.json new file mode 100644 index 0000000000..19b2c04b0c --- /dev/null +++ b/shared/platform-feature-matrix/features/agent-claude-subscription.json @@ -0,0 +1,31 @@ +{ + "id": "agent-claude-subscription", + "area": "AI", + "group": "Agent 对话", + "capability": "Claude 供应商可选官方订阅:直接使用本机 Claude Code 已登录的 claude.ai 账号,与 API Key 模式随时切换,Lithe 不读取任何凭据", + "macos": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "macos/Sources/LitheCoreContracts/Agent/AgentConversationPorts.swift", + "macos/Sources/Lithe/Application/Features/AgentLaunchConfigurationResolver.swift", + "macos/Sources/Lithe/Models/AppModel/AppModel+AgentProviders.swift", + "macos/Sources/Lithe/Views/Agent/AgentProviderConfigurationView.swift", + "macos/Sources/Lithe/Application/Features/AgentSetupDiagnostics.swift", + "macos/Tests/LitheTests/AgentLaunchConfigurationResolverTests.swift", + "macos/Tests/LitheTests/AgentProviderConfigurationTests.swift", + "rust/lithe-agent-host/src/claude_subscription.rs", + ".agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md" + ] + }, + "windows": { + "implementationStatus": "partial", + "verificationStatus": "pending", + "evidence": [ + "rust/lithe-agent-host/src/claude_subscription.rs", + "rust/lithe-agent-host/src/lib.rs" + ] + }, + "owner": "Agent", + "verification": "macOS:本机 Claude Code 已用 /login 登录 claude.ai 账号时,在设置 > AI > Agent 管理 > Claude Agent 的供应商管理中启用「Claude 官方订阅(本机账号)」,预检的 Agent 供应商显示 PASS 并说明使用 claude.ai 账号;打开 Agent 面板新建与恢复会话、发送消息,确认请求不携带 API Key、地址或模型覆盖(Lithe 环境中的 ANTHROPIC_API_KEY、ANTHROPIC_AUTH_TOKEN、ANTHROPIC_BASE_URL、ANTHROPIC_MODEL、CLAUDE_CODE_OAUTH_TOKEN 不传给适配器),模型列表来自 Claude Code。终端执行 claude /logout 后重连,面板显示「Claude Code 尚未登录……用 /login 登录后再重试」,Lithe 不打开浏览器;重新登录后重试恢复。空闲时在订阅与 API Key 供应商之间切换,API Key 模式仍按所选供应商路由并清除 OAuth;Codex 订阅不能用于 Claude,Claude 订阅不能用于 Codex;取消关联后回到未配置。订阅模式不启用模型调用统计代理。Windows:共享 Host 已支持 claudeSubscription 启动与登录检查,设置界面尚未提供该选项,待接入并真机验收。" +} diff --git a/shared/platform-feature-matrix/features/agent-codex-subscription.json b/shared/platform-feature-matrix/features/agent-codex-subscription.json index 5a2236ee2b..a7059e0515 100644 --- a/shared/platform-feature-matrix/features/agent-codex-subscription.json +++ b/shared/platform-feature-matrix/features/agent-codex-subscription.json @@ -31,6 +31,6 @@ ] }, "owner": "Agent", - "verification": "macOS:已有 Codex ChatGPT 登录直接对话;未登录只显示登录按钮,点击才打开浏览器,取消与超时清理进程;API Key 与订阅空闲切换后验证实际计费来源,不携带旧 key/URL/模型、不改提交服务商;忙碌时拒绝切换,账号变化后断开;老配置仍走 API Key,Claude 不显示订阅选项。真实账号与 macOS UI 待验证;Windows 连接桥已接通共享 host,UI 待接入;2026-10-16 删除了从未接入后端的「Codex 集成」设置页与 Codex app-server 客户端死代码,AI 设置不再显示 Codex CLI 状态或连接按钮。 Windows:Codex 设置显示 macOS 同款订阅行,未启用时只提供 Enable,启用后提供 “Use API key” 返回 API Key 模式;切换按钮在连接中、创建会话、回复中、加载历史、等待权限或配置确认时禁用,避免中断当前工作。订阅启动不携带 provider 的 key、地址或模型;未登录时 Agent 面板只显示“Sign in with ChatGPT”,点击后才启动登录,登录中可取消并清理连接。旧配置仍按 API Key 读取;Claude 不显示订阅入口。真实 ChatGPT 账号验收待执行。", + "verification": "macOS:已有 Codex ChatGPT 登录直接对话;未登录只显示登录按钮,点击才打开浏览器,取消与超时清理进程;API Key 与订阅空闲切换后验证实际计费来源,不携带旧 key/URL/模型、不改提交服务商;忙碌时拒绝切换,账号变化后断开;老配置仍走 API Key,Codex 订阅不能用于 Claude(Claude 订阅见 agent-claude-subscription)。真实账号与 macOS UI 待验证;Windows 连接桥已接通共享 host,UI 待接入;2026-10-16 删除了从未接入后端的「Codex 集成」设置页与 Codex app-server 客户端死代码,AI 设置不再显示 Codex CLI 状态或连接按钮。 Windows:Codex 设置显示 macOS 同款订阅行,未启用时只提供 Enable,启用后提供 “Use API key” 返回 API Key 模式;切换按钮在连接中、创建会话、回复中、加载历史、等待权限或配置确认时禁用,避免中断当前工作。订阅启动不携带 provider 的 key、地址或模型;未登录时 Agent 面板只显示“Sign in with ChatGPT”,点击后才启动登录,登录中可取消并清理连接。旧配置仍按 API Key 读取;Claude 不显示订阅入口。真实 ChatGPT 账号验收待执行。", "notes": "2026-10-18 Windows:设置页订阅入口改成 macOS 的启用/切换行;`reconnect` 从设置 store 读取刚保存的值,避免切换登录方式后仍用旧配置重连;默认模型随供应商初始化(Codex `gpt-6.1-sol`,Claude `claude-fable-5-1`)。" } diff --git a/shared/platform-feature-matrix/features/agent-connection-model-switching.json b/shared/platform-feature-matrix/features/agent-connection-model-switching.json index 24a87ebf48..79ba2f2635 100644 --- a/shared/platform-feature-matrix/features/agent-connection-model-switching.json +++ b/shared/platform-feature-matrix/features/agent-connection-model-switching.json @@ -28,10 +28,12 @@ "windows/tauri/src/features/agent/stores/agent-connection.model.test.ts", "windows/tauri/src/features/agent/components/agent-panel.tsx", ".agents/notes/implemented/architecture/2026-10-17-windows-agent-connection-ownership.md", - "windows/tauri/tests/agent-connection-service.test.ts" + "windows/tauri/tests/agent-connection-service.test.ts", + "windows/tauri/src/features/agent/components/agent-session-selectors.tsx", + "windows/tauri/src/features/agent/stores/agent-parity.test.ts" ] }, "owner": "Agent", - "verification": "macOS:不触发 AppModel 通知或替换宿主根视图,切换 Claude/Codex,确认品牌、当前连接和模型列表立即同步;切到未准备的连接时保持会话设置加载提示,不闪现本地保存的默认模型;进程 ready 后继续等待会话确认,过期响应不解除等待;加载历史等待其配置,创建/加载/连接失败和不提供配置能力时退出加载,回车与发送按钮都禁止准备期间发送;已准备的连接来回切换不重复加载;分别在 Claude/Codex 关闭最后一个已准备标签,确认连接保持 ready、立即且只发一次 newSession,过期确认不恢复旧会话,新确认后底栏退出等待且消息发送到新会话;仍有标签时切到已有会话,断连时不创建。选择模型后等待上游确认,重新展开检查勾选,来回切换确认模型和会话分别保留;打开模型菜单后无搜索框且立即展示全部上游名称、顺序与分组,空目录有明确提示且仍能调整 Effort,当前 Agent 移除后正确回退,实际模型切换后发送最小消息验证回复。模型入口位于输入框下方 Agent 右侧,模式入口保留框内;深浅主题及280/540pt面板验证两个菜单可选择且模型与Agent同行。模型弹框至少 300pt 宽,长内容继续测量至共享宽度上限,顶部直接显示模型标题,不留搜索框占位;验证左侧勾选与hover、思考强度单行圆点滑块:中英文界面统一显示 Effort,Medium 与最长档位名称不省略;2/4/6 档及未知上游名称按实际目录显示、点击首尾档、调整强度后同一弹窗保持,等待期间禁用,拒绝后仍用已确认值,确认后能继续调整;Esc、外部点击及选择模型仍关闭;拖动仅在松开时提交一次、方向键和辅助功能可调整、禁用态不提交、确认后重新打开保持当前值、空模型目录仍可改强度、长模型列表最后一项可选择,其他配置子菜单仍按触发行定位并可关闭。 Windows 已实现连接身份切换、会话快照保留与配置确认前等待,真机往返切换验收待执行。模型入口位于输入框下方 Agent 右侧,模式入口保留框内;深浅主题及280/540pt面板验证两个菜单可选择且模型与Agent同行。模型弹框按内容测量宽度而非固定330pt,搜索框使用共享28pt控件;验证左侧勾选与hover、思考强度同级选择、名称搜索及空结果仍可改强度、长模型列表最后一项可选择,其他配置子菜单仍按触发行定位并可关闭。 Windows:用可控传输挂起首次启动,在完成前切换 Agent 或认证方式,确认先关闭旧进程再启动新配置;停止后立即重开也不得越过清理。 编辑区与侧栏同时请求相同身份时只启动一次,两个请求均在所有权登记后完成。", - "notes": "2026-10-01 macOS:原生 SwiftUI 回归、真实 Claude Sonnet→Haiku 切换及回复通过;修复版真实窗口验证 Codex 初始化加载提示→确认后的 Astra/审批控件、Astra→Luna→Astra 切换与重新展开勾选、名称搜索/空结果、Claude/Codex 往返保留配置,并收到 Codex 最小消息回复。 测试项目关闭后无 Agent 子进程,990 个 bundle 文件哈希不变。 Windows 对话 UI 与验收仍待完成。 同日 review 回归补齐:Claude/Codex 关闭最后一个标签后立即准备替代会话,忽略旧确认并在新确认后正常发送;两组参数化场景通过,测试窗口与模拟连接均关闭。 2026-10-17:思考强度改为单行胶囊轨道与圆点滑块。 95 项相关原生/配置/下拉回归通过;真实项目恢复已有 Codex 会话后验证 Xhigh→High 的点击确认,再通过辅助功能步进恢复 Xhigh,重新展开菜单确认当前值与六个上游档位一致。 深浅主题和 2/4/6 档由原生渲染回归覆盖;拖动仅在松开时提交,方向键从已确认值移动。 未发送测试提示词。 同日按用户要求将强度行标题统一改为 Effort;标题、括号与宽度测量使用相同原文,避免中文翻译导致测量与渲染不一致。 标签调整后 52 项相关测试通过,原生深浅主题截图中的 Effort (Medium) 完整显示;重启开发版并恢复原有会话后,将强度恢复为重启前的 Medium,重新展开确认 Effort 标签、选中值与可操作状态。 随后按用户要求将整个模型弹窗的最小宽度提高到 300pt,搜索框、模型列表和 Effort 行一并加宽,长内容仍按共享上限自适应。 加宽后 15 项原生布局回归通过,覆盖搜索空结果、屏幕左右边缘子菜单、长列表和强度控件;深浅主题截图均按 300pt 最小宽度渲染。 后续按用户要求,Effort 选择不再关闭模型弹窗;配置等待仅禁用,保留搜索与弹窗实例,普通模型/配置项仍按原规则关闭。 57 项原生/交互/配置回归通过,新增测试验证等待、拒绝、确认及连续选择均保持同一弹窗和搜索,Esc 与模型选择仍可关闭。 真实开发版恢复原有 Codex 会话后,搜索 6.1 并连续点击 High→Medium,确认期间与确认后弹窗都未关闭且搜索保留;Esc 正常关闭,最终恢复用户的 Medium,未发送测试提示词。 2026-10-18:按用户要求移除模型菜单顶部搜索框、过滤状态及自动聚焦逻辑,直接展示完整目录,保留原有最小宽度、滚动、当前模型勾选和 Effort 连续调整;原生回归同步改为验证无搜索输入、完整目录和空目录。 99 项模型呈现、原生交互、下一轮配置和共享菜单回归通过;深浅主题截图覆盖完整目录与空目录,真实开发版重新展开确认搜索框已移除、八个官方模型全部可见,恢复原会话并确认 5.6 Terra / Xhigh。 后台任务按用户选择保留,未发送测试提示词。 2026-10-17 Windows:所选连接的 Agent 与认证方式已即时生效(旧进程停止、快照重置后重连),界面身份与实际路由一致;准备期等待会话设置、模型确认提示和来回切换保留各自会话仍未实现。 2026-10-17 Windows 更新:所选连接按「工作区 + Agent + 认证方式 + 命令/参数」即时生效;切换前用 `captureSnapshot()` 保留该 Agent 的可见会话,切回 `restoreSnapshot()` 后由 `loadSession` 重新附着;会话设置确认前(`pendingConfigToken`)禁止发送并显示准备中。 真机验收待执行。" + "verification": "macOS:不触发 AppModel 通知或替换宿主根视图,切换 Claude/Codex,确认品牌、当前连接和模型列表立即同步;切到未准备的连接时保持会话设置加载提示,不闪现本地保存的默认模型;进程 ready 后继续等待会话确认,过期响应不解除等待;加载历史等待其配置,创建/加载/连接失败和不提供配置能力时退出加载,回车与发送按钮都禁止准备期间发送;已准备的连接来回切换不重复加载;分别在 Claude/Codex 关闭最后一个已准备标签,确认连接保持 ready、立即且只发一次 newSession,过期确认不恢复旧会话,新确认后底栏退出等待且消息发送到新会话;仍有标签时切到已有会话,断连时不创建。选择模型后等待上游确认,重新展开检查勾选,来回切换确认模型和会话分别保留;打开模型菜单后无搜索框且立即展示全部上游名称、顺序与分组,空目录有明确提示且仍能调整 Effort,当前 Agent 移除后正确回退,实际模型切换后发送最小消息验证回复。模型入口位于输入框下方 Agent 右侧,模式入口保留框内;深浅主题及280/540pt面板验证两个菜单可选择且模型与Agent同行。模型弹框至少 300pt 宽,长内容继续测量至共享宽度上限,顶部直接显示模型标题,不留搜索框占位;验证左侧勾选与hover、思考强度单行圆点滑块:中英文界面统一显示 Effort,Medium 与最长档位名称不省略;2/4/6 档及未知上游名称按实际目录显示、点击首尾档、调整强度后同一弹窗保持,等待期间禁用,拒绝后仍用已确认值,确认后能继续调整;Esc、外部点击及选择模型仍关闭;拖动仅在松开时提交一次、方向键和辅助功能可调整、禁用态不提交、确认后重新打开保持当前值、空模型目录仍可改强度、长模型列表最后一项可选择,其他配置子菜单仍按触发行定位并可关闭。 Windows 已实现连接身份切换、会话快照保留与配置确认前等待,真机往返切换验收待执行。模型入口位于输入框下方 Agent 右侧,模式入口保留框内;深浅主题及280/540pt面板验证两个菜单可选择且模型与Agent同行。模型弹框按内容测量宽度而非固定330pt,搜索框使用共享28pt控件;验证左侧勾选与hover、思考强度同级选择、名称搜索及空结果仍可改强度、长模型列表最后一项可选择,其他配置子菜单仍按触发行定位并可关闭。 Windows:用可控传输挂起首次启动,在完成前切换 Agent 或认证方式,确认先关闭旧进程再启动新配置;停止后立即重开也不得越过清理。 编辑区与侧栏同时请求相同身份时只启动一次,两个请求均在所有权登记后完成。 Windows:在真实 Tauri 窗口执行 Windows Agent 配置独立保留,切回恢复会话;模型目录完整显示,Effort 使用可访问的档位滑块。响应中预选模型、权限和 Effort,只在下一轮开始前按顺序等待 ACK,失败保留未确认状态且不发送。模型选项来自上游,不推测目录。 单元测试不替代运行时验收,未执行的真实服务及平台动作保持 pending。", + "notes": "2026-10-22:Windows Agent 配置独立保留,切回恢复会话;模型目录完整显示,Effort 使用可访问的档位滑块。响应中预选模型、权限和 Effort,只在下一轮开始前按顺序等待 ACK,失败保留未确认状态且不发送。模型选项来自上游,不推测目录。" } diff --git a/shared/platform-feature-matrix/features/agent-context-usage.json b/shared/platform-feature-matrix/features/agent-context-usage.json index ba86511048..932da0a8a6 100644 --- a/shared/platform-feature-matrix/features/agent-context-usage.json +++ b/shared/platform-feature-matrix/features/agent-context-usage.json @@ -39,12 +39,15 @@ "windows/tauri/src-tauri/src/agent.rs", "windows/tauri/src/features/agent/components/agent-usage-panel.tsx", "windows/tauri/src/features/agent/components/agent-composer.tsx", - "windows/tauri/src/features/agent/types/agent.types.ts" + "windows/tauri/src/features/agent/types/agent.types.ts", + "windows/tauri/src/features/agent/services/agent-session-selectors.ts", + "windows/tauri/src/features/agent/stores/agent-connection.model.ts", + "windows/tauri/src/features/agent/components/agent-observed-statistics.tsx" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Agent", - "verification": "macOS:窄宽面板和深浅主题检查圆环紧邻发送,悬停显示一位小数百分比及大写K已用/总容量,点击同一可滚动面板展示上下文、会话统计及Token明细,顶部没有重复信息按钮;关闭按钮、Esc、外部点击、隐藏工具栏及切换会话关闭;无报告时发送旁信息图标仍能打开统计;无上报提示未知,有效零报告显示0%,不从累计账单或消息推算。实际对话上报用量后重连/重启恢复相同模型,显示用量,不显示“上次上报”后缀或标题(包括悬停、上下文明细、会话详情与无障碍值);不同模型、未知模型、回放明确无效报告不恢复旧值,新上报清除历史标记。确认模型切换清除内存和持久化容量,权限和思考选项保留;压缩后数值可下降,会话不串用量。验证旧版统计记录兼容、缓存数量上限和运行中更新不逐条落盘,旧历史无缓存等待Agent新报告。共享菜单、悬停、无障碍与关闭行为保持;本地原生宿主回归通过不等于真实供应商验收,verification保持pending。Codex:无本地缓存的旧会话恢复后显示原生last.totalTokens/modelContextWindow,界面不显示来源后缀,内部保留历史标记,不使用累计total。标准ACP已上报优先;失败、取消、替换加载和新轮次不接收旧票据报告,通道不可用仍能加载历史;检查订阅与API Key、原生重试不变、安装包/适配器/CLI哈希不变、进程和临时helper清理。Windows已共享Host实现及cmd分支模拟,仍需目标平台实际验收。检查参考式上下文面板在264pt详情栏、300pt弹层及深浅主题/中英文下不截断:大号使用率保留一位小数,真实容量不加估算符号,四类估算分项标~,窗口未知时不归一化为100%;无分项显示未知及单条说明。MCP与普通工具数量保留,悬停可看估算Token;关闭按钮、Esc、外部点击与切换会话关闭弹层。会话详情弹窗及右侧详情栏均只展示上下文与会话/Token统计,不再展示更改区、分支、文件数、编辑行数或未编辑文件提示。有分项数据后面板底部不常驻估算说明和分隔线;分项比例仍有~,悬停显示估算Token及按请求大小估算/按供应商输入校准的口径。无分项的未知或启用指引继续保留。开启统计后,Codex订阅重连并发送消息,验证四类分项、调用次数、TTFT/TPS和缓存命中;关闭统计重连保持原生直连。WebSocket压缩、分片、控制帧和认证头原样透传,预热不计数,连续两轮基于完整上下文估算,未知前序ID不编造分项;旧历史缺分项等待新请求,不显示“订阅不统计”提示。原生Codex与本地假服务验收不等于真实供应商或Windows运行验收。原生account/read仅对普通后台且NO_CONSTRAINT启用采集;地区受限、未知或失败保留原生直连,账户变更关闭已观察连接。 Windows连接桥已转发usage_update,界面及恢复待实现。Codex:无本地缓存的旧会话恢复后显示原生last.totalTokens/modelContextWindow,界面不显示来源后缀,内部保留历史标记,不使用累计total。标准ACP已上报优先;失败、取消、替换加载和新轮次不接收旧票据报告,通道不可用仍能加载历史;检查订阅与API Key、原生重试不变、安装包/适配器/CLI哈希不变、进程和临时helper清理。Windows已共享Host实现及cmd分支模拟,仍需目标平台实际验收。检查参考式上下文面板在264pt详情栏、300pt弹层及深浅主题/中英文下不截断:大号使用率保留一位小数,真实容量不加估算符号,四类估算分项标~,窗口未知时不归一化为100%;无分项显示未知及单条说明。MCP与普通工具数量保留,悬停可看估算Token;关闭按钮、Esc、外部点击与切换会话关闭弹层。更改和会话统计仍可访问。有分项数据后面板底部不常驻估算说明和分隔线;分项比例仍有~,悬停显示估算Token及按请求大小估算/按供应商输入校准的口径。无分项的未知或启用指引继续保留。开启统计后,Codex订阅重连并发送消息,验证四类分项、调用次数、TTFT/TPS和缓存命中;关闭统计重连保持原生直连。WebSocket压缩、分片、控制帧和认证头原样透传,预热不计数,连续两轮基于完整上下文估算,未知前序ID不编造分项;旧历史缺分项等待新请求,不显示“订阅不统计”提示。原生Codex与本地假服务验收不等于真实供应商或Windows运行验收。原生account/read仅对普通后台且NO_CONSTRAINT启用采集;地区受限、未知或失败保留原生直连,账户变更关闭已观察连接。Windows 面板按 macOS 在输入框顶部上下文栏显示 12px 圆环与百分比,90% 起警告色、100% 起错误色,未知时显示 0% 并提示“上下文: 0.0%”,有用量时提示“x% · 12.3k / 200k 上下文 token”;压缩与会话隔离的实机验证待完成。", - "notes": "2026-10-16 Windows:输入框上方的上下文圆环与浮层由 `agent-usage-panel.tsx` 读取共享上下文用量字段,未上报时显示未知而不是 0。 真实窗口的悬停、越界与重连后刷新仍待验收。" + "verification": "macOS:窄宽面板和深浅主题检查圆环紧邻发送,悬停显示一位小数百分比及大写K已用/总容量,点击同一可滚动面板展示上下文、会话统计及Token明细,顶部没有重复信息按钮;关闭按钮、Esc、外部点击、隐藏工具栏及切换会话关闭;无报告时发送旁信息图标仍能打开统计;无上报提示未知,有效零报告显示0%,不从累计账单或消息推算。实际对话上报用量后重连/重启恢复相同模型,显示用量,不显示“上次上报”后缀或标题(包括悬停、上下文明细、会话详情与无障碍值);不同模型、未知模型、回放明确无效报告不恢复旧值,新上报清除历史标记。确认模型切换清除内存和持久化容量,权限和思考选项保留;压缩后数值可下降,会话不串用量。验证旧版统计记录兼容、缓存数量上限和运行中更新不逐条落盘,旧历史无缓存等待Agent新报告。共享菜单、悬停、无障碍与关闭行为保持;本地原生宿主回归通过不等于真实供应商验收,verification保持pending。Codex:无本地缓存的旧会话恢复后显示原生last.totalTokens/modelContextWindow,界面不显示来源后缀,内部保留历史标记,不使用累计total。标准ACP已上报优先;失败、取消、替换加载和新轮次不接收旧票据报告,通道不可用仍能加载历史;检查订阅与API Key、原生重试不变、安装包/适配器/CLI哈希不变、进程和临时helper清理。Windows已共享Host实现及cmd分支模拟,仍需目标平台实际验收。检查参考式上下文面板在264pt详情栏、300pt弹层及深浅主题/中英文下不截断:大号使用率保留一位小数,真实容量不加估算符号,四类估算分项标~,窗口未知时不归一化为100%;无分项显示未知及单条说明。MCP与普通工具数量保留,悬停可看估算Token;关闭按钮、Esc、外部点击与切换会话关闭弹层。会话详情弹窗及右侧详情栏均只展示上下文与会话/Token统计,不再展示更改区、分支、文件数、编辑行数或未编辑文件提示。有分项数据后面板底部不常驻估算说明和分隔线;分项比例仍有~,悬停显示估算Token及按请求大小估算/按供应商输入校准的口径。无分项的未知或启用指引继续保留。开启统计后,Codex订阅重连并发送消息,验证四类分项、调用次数、TTFT/TPS和缓存命中;关闭统计重连保持原生直连。WebSocket压缩、分片、控制帧和认证头原样透传,预热不计数,连续两轮基于完整上下文估算,未知前序ID不编造分项;旧历史缺分项等待新请求,不显示“订阅不统计”提示。原生Codex与本地假服务验收不等于真实供应商或Windows运行验收。原生account/read仅对普通后台且NO_CONSTRAINT启用采集;地区受限、未知或失败保留原生直连,账户变更关闭已观察连接。 Windows 已接入紧凑上下文圆环、悬停提示、分项详情与会话统计,消费历史 usage_update;真实 Codex 历史上下文恢复仍待验收。Codex:无本地缓存的旧会话恢复后显示原生last.totalTokens/modelContextWindow,界面不显示来源后缀,内部保留历史标记,不使用累计total。标准ACP已上报优先;失败、取消、替换加载和新轮次不接收旧票据报告,通道不可用仍能加载历史;检查订阅与API Key、原生重试不变、安装包/适配器/CLI哈希不变、进程和临时helper清理。Windows已共享Host实现及cmd分支模拟,仍需目标平台实际验收。检查参考式上下文面板在264pt详情栏、300pt弹层及深浅主题/中英文下不截断:大号使用率保留一位小数,真实容量不加估算符号,四类估算分项标~,窗口未知时不归一化为100%;无分项显示未知及单条说明。MCP与普通工具数量保留,悬停可看估算Token;关闭按钮、Esc、外部点击与切换会话关闭弹层。更改和会话统计仍可访问。有分项数据后面板底部不常驻估算说明和分隔线;分项比例仍有~,悬停显示估算Token及按请求大小估算/按供应商输入校准的口径。无分项的未知或启用指引继续保留。开启统计后,Codex订阅重连并发送消息,验证四类分项、调用次数、TTFT/TPS和缓存命中;关闭统计重连保持原生直连。WebSocket压缩、分片、控制帧和认证头原样透传,预热不计数,连续两轮基于完整上下文估算,未知前序ID不编造分项;旧历史缺分项等待新请求,不显示“订阅不统计”提示。原生Codex与本地假服务验收不等于真实供应商或Windows运行验收。原生account/read仅对普通后台且NO_CONSTRAINT启用采集;地区受限、未知或失败保留原生直连,账户变更关闭已观察连接。Windows 面板按 macOS 在输入框顶部上下文栏显示 12px 圆环与百分比,90% 起警告色、100% 起错误色,未知时显示 0% 并提示“上下文: 0.0%”,有用量时提示“x% · 12.3k / 200k 上下文 token”;压缩与会话隔离的实机验证待完成。 Windows:在真实 Tauri 窗口执行 Windows 上下文圆环可展开模型调用与会话统计,以及系统提示词、工具、MCP、消息的估算构成;按供应商输入 Token 校准并明确标识估算。历史重放消费上游 usage_update,不推测缺失值。 单元测试不替代运行时验收,未执行的真实服务及平台动作保持 pending。", + "notes": "2026-10-22:Windows 上下文圆环可展开模型调用与会话统计,以及系统提示词、工具、MCP、消息的估算构成;按供应商输入 Token 校准并明确标识估算。历史重放消费上游 usage_update,不推测缺失值。" } diff --git a/shared/platform-feature-matrix/features/agent-conversation-history.json b/shared/platform-feature-matrix/features/agent-conversation-history.json index 46ecea45c1..064fa08b91 100644 --- a/shared/platform-feature-matrix/features/agent-conversation-history.json +++ b/shared/platform-feature-matrix/features/agent-conversation-history.json @@ -2,7 +2,7 @@ "id": "agent-conversation-history", "area": "AI", "group": "Agent 对话", - "capability": "CC GUI 风格历史页:标题与会话 ID 搜索、对话活动时间、复制 ID、刷新与恢复、收藏与本地重命名、单条与批量移除确认及恢复、筛选、多选批量管理及 Markdown 导出;分页首批结果提前显示、近期列表复用与刷新请求合并", + "capability": "CC GUI 风格历史页:标题与会话 ID 搜索、对话活动时间、复制 ID、刷新与恢复、收藏与本地重命名、单条与批量移除确认及恢复、筛选、多选批量管理及 Markdown 导出;分页首批结果提前显示、近期列表复用与刷新请求合并;macOS 历史页按收藏、今天、昨天、过去 7/30 天、更早分组,行内用 Agent 磁贴与“…”菜单;大历史对话回放期间不逐条发布,对话视图首屏只构建最近 48 项并可展开更早内容", "macos": { "evidence": [ "macos/Sources/Lithe/Views/Agent/AgentHistoryView.swift", @@ -16,7 +16,9 @@ "shared/fixtures/agent/acp-events-v1.json", "rust/lithe-agent-host/src/codex_history.rs", "rust/lithe-agent-host/src/codex_history/tests.rs", - "shared/fixtures/agent/history-activity-v1.json" + "shared/fixtures/agent/history-activity-v1.json", + "macos/Tests/LitheTests/AgentLargeHistoryLoadTests.swift", + ".agents/notes/implemented/feature/2026-10-23-macos-agent-editor-large-history-and-claude-layout.md" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -31,12 +33,15 @@ "windows/tauri/src/features/agent/services/agent-history-save.ts", "windows/tauri/src/features/agent/services/agent-history-store.ts", "windows/tauri/src/features/agent/services/agent-history-view.ts", - "windows/tauri/src/features/agent/services/agent-history.test.ts" + "windows/tauri/src/features/agent/services/agent-history.test.ts", + "windows/tauri/src/features/agent/components/agent-panel.tsx", + "windows/tauri/src/features/agent/stores/agent-connection.model.ts", + "windows/tauri/src/features/agent/stores/agent-parity.test.ts" ], - "implementationStatus": "partial", + "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Agent", - "verification": "macOS:在深浅主题与窄宽面板查看历史页,检查共享搜索框与按钮的焦点、悬停、禁用状态,验证标题/ID 搜索、空结果、返回保留草稿、刷新失败不丢列表、打开历史恢复会话;验证收藏/重命名重启后保留且不同项目/Agent 隔离;筛选和全选只操作可见项,单条与批量移除先确认,取消不修改记录,确认只移除提示时选中的会话,移除后可恢复且不改上游文件;单个与批量 Markdown 导出等待历史回放完成、保持原选中标签,取消/失败不写部分记录;部分回放后失败再导出必须重新加载并等待完成,断连后的未完整回放不可导出,完整快照断连后仍可导出;关闭项目释放导出等待。消息数量只显示已加载的用户与 Agent 消息,未知总数不伪造。点击 New Chat 创建真实新会话,等待创建时不能重复请求,确认后恢复可用;断连或未配置时显示具体原因;输入框加号仍只添加文件。Chat History 点击后直接展开共享会话列表,当前会话置顶;新建但未提交的实际会话可见、创建确认后仍显示 New Chat,且不伪造更新时间;本地重命名与上游标题优先保留、隐藏历史不重现。鼠标和方向键/Enter 选择使用已有 loadSession;Escape 和外部点击关闭,关闭后键盘事件不继续作用于旧弹框。搜索和历史管理入口保持可用。进入会话后顶部历史入口显示当前标题;首条用户消息提供临时标题,本地重命名和上游标题优先;切换及重命名同步刷新顶部与列表,长标题省略且历史点击功能保留。标题栏三个点菜单打开共享弹框;检查 Rename Chat 持久化并更新标题,Delete Chat 取消不改动、确认后从历史隐藏且可恢复,忙碌时禁用,Settings打开现有设置。Show Toolbar 勾选隐藏/恢复输入框操作栏与Agent/模型/统计栏,已开弹框随隐藏关闭;文本可编辑且输入框高度保留,Tasks/Running/Edits不受影响。重开或切Agent保留可见状态,不停止任务。 Windows 面板已实现筛选(全部/收藏/已移除)、标题与会话 ID 搜索、复制会话 ID、收藏、本地重命名、单条与批量移除确认及恢复,标注按项目与 Agent 隔离并存在平台 store(`agent-history.json`),每次保存前先重读再合并以免多窗口互相覆盖;Markdown 导出已实现:先按需重放未打开的会话(导出用的加载不打开标签页),全部加载成功后才写文件,取消保存对话框不写文件,超过 32 MiB 直接报错;独立历史页仍未实现,消息数量只在会话已加载时显示。点击 New Chat 创建真实新会话,等待创建时不能重复请求,确认后恢复可用;断连或未配置时显示具体原因;输入框加号仍只添加文件。Chat History 点击后直接展开共享会话列表,当前会话置顶;新建但未提交的实际会话可见、创建确认后仍显示 New Chat,且不伪造更新时间;本地重命名与上游标题优先保留、隐藏历史不重现。鼠标和方向键/Enter 选择使用已有 loadSession;Escape 和外部点击关闭,关闭后键盘事件不继续作用于旧弹框。搜索和历史管理入口保持可用。进入会话后顶部历史入口显示当前标题;首条用户消息提供临时标题,本地重命名和上游标题优先;切换及重命名同步刷新顶部与列表,长标题省略且历史点击功能保留。标题栏三个点菜单打开共享弹框;检查 Rename Chat 持久化并更新标题,Delete Chat 取消不改动、确认后从历史隐藏且可恢复,忙碌时禁用,Settings打开现有设置。Show Toolbar 勾选隐藏/恢复输入框操作栏与Agent/模型/统计栏,已开弹框随隐藏关闭;文本可编辑且输入框高度保留,Tasks/Running/Edits不受影响。重开或切Agent保留可见状态,不停止任务。 历史加载:控制第二页尚未返回时确认第一页会话已显示且刷新状态保持;空项目页继续翻页,失败保留旧列表和已返回新项,重试及取消归档后丢弃旧请求分页。30 秒内从快捷历史切到管理页复用成功结果(包括空列表),请求未完成时不重复发送;超过期限重新查询,手动刷新立即查询。活跃与归档各自记录成功时间,归档失败只重试归档,断连/重连清除新鲜度;确认归档与活跃响应先后不同仍保持正确分组。Windows 共享 Host 仍发送最终完整列表,Windows 界面继续通过最终快照刷新;本次分页展示与新鲜度复用仅在 macOS 实现。 Codex 历史时间与排序使用上游 recencyAt:只查看/恢复旧会话、刷新、重命名或收藏后时间和顺序保持;新一轮对话活动出现后更新。活跃与归档一致,缺失活动时间不回退到元数据更新时间或当前时间;读取失败保留已有列表。保留历史页和弹框外观。 Codex 原生 App Server 活跃列表同样先发布匹配的非末页,继续保留 recency_at 排序、useStateDbOnly 只读请求、去重及 workspace/provider/source 隔离;全局外项目页和重复页不产生假空列表或重复行。 macOS 轮次结束:先确认 30 秒内命中活跃与归档快照,再完成后台会话的一轮回复,立即重新打开历史,验证只重查活跃列表并采用上游新活动时间与排序;加载/查看旧会话仍命中缓存。分别控制历史响应在轮次结束前、后到达,确认跨过该轮次的在途查询不能延长缓存期限,同时保持请求合并;新查询成功后可以再次复用。", - "notes": "2026-10-16 Windows:面板历史页已接共享会话列表与恢复(`agent-history-list.tsx`)。 历史标注(收藏、本地标题、已移除)只属于 Lithe,写在平台 store `agent-history.json`,键为 `Agent ID + 工作区路径`,不写发行包也不改写 Agent 自己的会话文件;每次保存前先重读再合并,避免多窗口互相覆盖。 筛选、搜索、排序与批量操作只作用于当前可见行(`agent-history-view.ts` 为纯函数),重命名拒绝空标题。 Markdown 导出走 `agent-history-export.ts` 的纯渲染与 `agent-history-save.ts` 的平台保存对话框:先按需重放未打开的会话(不打开标签页),全部成功后才写文件,超过 32 MiB 报错且失败不产出半份记录。 独立历史页与真机验收仍待后续 PR。" + "verification": "macOS:在深浅主题与窄宽面板查看历史页,检查共享搜索框与按钮的焦点、悬停、禁用状态,验证标题/ID 搜索、空结果、返回保留草稿、刷新失败不丢列表、打开历史恢复会话;验证收藏/重命名重启后保留且不同项目/Agent 隔离;筛选和全选只操作可见项,单条与批量移除先确认,取消不修改记录,确认只移除提示时选中的会话,移除后可恢复且不改上游文件;单个与批量 Markdown 导出等待历史回放完成、保持原选中标签,取消/失败不写部分记录;部分回放后失败再导出必须重新加载并等待完成,断连后的未完整回放不可导出,完整快照断连后仍可导出;关闭项目释放导出等待。消息数量只显示已加载的用户与 Agent 消息,未知总数不伪造。点击 New Chat 创建真实新会话,等待创建时不能重复请求,确认后恢复可用;断连或未配置时显示具体原因;输入框加号仍只添加文件。Chat History 点击后直接展开共享会话列表,当前会话置顶;新建但未提交的实际会话可见、创建确认后仍显示 New Chat,且不伪造更新时间;本地重命名与上游标题优先保留、隐藏历史不重现。鼠标和方向键/Enter 选择使用已有 loadSession;Escape 和外部点击关闭,关闭后键盘事件不继续作用于旧弹框。搜索和历史管理入口保持可用。进入会话后顶部历史入口显示当前标题;首条用户消息提供临时标题,本地重命名和上游标题优先;切换及重命名同步刷新顶部与列表,长标题省略且历史点击功能保留。标题栏三个点菜单打开共享弹框;检查 Rename Chat 持久化并更新标题,Delete Chat 取消不改动、确认后从历史隐藏且可恢复,忙碌时禁用,Settings打开现有设置。Show Toolbar 勾选隐藏/恢复输入框操作栏与Agent/模型/统计栏,已开弹框随隐藏关闭;文本可编辑且输入框高度保留,Tasks/Running/Edits不受影响。重开或切Agent保留可见状态,不停止任务。 Windows 面板已实现筛选(全部/收藏/已移除)、标题与会话 ID 搜索、复制会话 ID、收藏、本地重命名、单条与批量移除确认及恢复,标注按项目与 Agent 隔离并存在平台 store(`agent-history.json`),每次保存前先重读再合并以免多窗口互相覆盖;Markdown 导出已实现:先按需重放未打开的会话(导出用的加载不打开标签页),全部加载成功后才写文件,取消保存对话框不写文件,超过 32 MiB 直接报错;独立历史页已接入,消息数量只在会话已加载时显示。点击 New Chat 创建真实新会话,等待创建时不能重复请求,确认后恢复可用;断连或未配置时显示具体原因;输入框加号仍只添加文件。Chat History 点击后直接展开共享会话列表,当前会话置顶;新建但未提交的实际会话可见、创建确认后仍显示 New Chat,且不伪造更新时间;本地重命名与上游标题优先保留、隐藏历史不重现。鼠标和方向键/Enter 选择使用已有 loadSession;Escape 和外部点击关闭,关闭后键盘事件不继续作用于旧弹框。搜索和历史管理入口保持可用。进入会话后顶部历史入口显示当前标题;首条用户消息提供临时标题,本地重命名和上游标题优先;切换及重命名同步刷新顶部与列表,长标题省略且历史点击功能保留。标题栏三个点菜单打开共享弹框;检查 Rename Chat 持久化并更新标题,Delete Chat 取消不改动、确认后从历史隐藏且可恢复,忙碌时禁用,Settings打开现有设置。Show Toolbar 勾选隐藏/恢复输入框操作栏与Agent/模型/统计栏,已开弹框随隐藏关闭;文本可编辑且输入框高度保留,Tasks/Running/Edits不受影响。重开或切Agent保留可见状态,不停止任务。 历史加载:控制第二页尚未返回时确认第一页会话已显示且刷新状态保持;空项目页继续翻页,失败保留旧列表和已返回新项,重试及取消归档后丢弃旧请求分页。30 秒内从快捷历史切到管理页复用成功结果(包括空列表),请求未完成时不重复发送;超过期限重新查询,手动刷新立即查询。活跃与归档各自记录成功时间,归档失败只重试归档,断连/重连清除新鲜度;确认归档与活跃响应先后不同仍保持正确分组。Windows 同样消费共享 Host 的 sessionsPage 事件,非末页提前合并展示;活跃与归档分别维护 30 秒新鲜度,合并在途请求,并在轮次完成、重连与归档恢复时失效。跨过轮次的旧响应不能延长缓存有效期。 Codex 历史时间与排序使用上游 recencyAt:只查看/恢复旧会话、刷新、重命名或收藏后时间和顺序保持;新一轮对话活动出现后更新。活跃与归档一致,缺失活动时间不回退到元数据更新时间或当前时间;读取失败保留已有列表。保留历史页和弹框外观。 Codex 原生 App Server 活跃列表同样先发布匹配的非末页,继续保留 recency_at 排序、useStateDbOnly 只读请求、去重及 workspace/provider/source 隔离;全局外项目页和重复页不产生假空列表或重复行。 双平台轮次结束:先确认 30 秒内命中活跃与归档快照,再完成后台会话的一轮回复,立即重新打开历史,验证只重查活跃列表并采用上游新活动时间与排序;加载/查看旧会话仍命中缓存。分别控制历史响应在轮次结束前、后到达,确认跨过该轮次的在途查询不能延长缓存期限,同时保持请求合并;新查询成功后可以再次复用。 macOS 大历史加载:对 1 200 轮(万级回放事件)的对话执行 loadSession,回放期间不逐条发布,结束后消息顺序、工具合并正确,对话首屏只显示最近 48 项,点“显示更早的 N 项”或导航栏跳到更早回合会扩大窗口并保持位置;加载失败恢复旧副本,断连保留部分回放但不标记完整。历史页分组与“…”菜单(收藏、重命名、导出、复制会话 ID、移出历史/恢复)可用,搜索、筛选、多选、归档分区保持原行为。", + "notes": "2026-10-22:Windows 已提供独立历史页与快捷历史列表、分页首批展示、30 秒活跃/归档独立缓存、请求合并和轮次完成失效。搜索、收藏、重命名、移除/恢复、批量管理和 Markdown 导出沿用现有平台存储与回放流程。Agent 标注按项目和 Agent 隔离,不修改上游会话文件。单元测试覆盖分页、归档恢复及旧响应竞争;完整平台运行验收保持 pending,不能用测试替身证明真实服务。" } diff --git a/shared/platform-feature-matrix/features/agent-file-context-actions.json b/shared/platform-feature-matrix/features/agent-file-context-actions.json index 9f37379700..8b17cf3d40 100644 --- a/shared/platform-feature-matrix/features/agent-file-context-actions.json +++ b/shared/platform-feature-matrix/features/agent-file-context-actions.json @@ -17,10 +17,18 @@ ] }, "windows": { - "implementationStatus": "missing", + "implementationStatus": "implemented", "verificationStatus": "pending", - "evidence": ["shared/contracts/application-boundary.md"] + "evidence": [ + "shared/contracts/application-boundary.md", + "windows/tauri/src/features/agent/components/agent-composer.tsx", + "windows/tauri/src/features/agent/stores/agent-connection.model.ts", + "windows/tauri/src/features/agent/hooks/use-agent-file-context-menu.ts", + "windows/tauri/tests/agent-file-context-menu.test.ts", + "windows/tauri/src/features/agent/stores/agent-connection-service.ts" + ] }, "owner": "Agent", - "verification": "macOS:深浅主题下右键非活动文件标签,确认添加的是被右键的文件;项目树单文件和多选文件使用同一入口,目录不递归展开。当前会话入口明确 Agent,其他会话按已配置 Codex/Claude 分组,显示标题、短 ID 与当前勾选,支持新建会话。分别切换两个 Agent 与同 Agent 的两个会话,确认草稿文字及附件互不串用、重复添加去重、超过32个或非法批次原子拒绝、未配置时引导设置。首次启动、会话创建和历史加载失败后附件保留;已有回复不中断,只有手动发送才提交;添加后显示目标会话并聚焦输入框,编辑区/工具窗口位置保持。菜单打开后换项目、删除文件、取消 Agent 配置或移除会话,不得错误转发。脏文件只提醒保存,不自动保存或复制。Swift 原生宿主与受控 transport 回归不代替真实 Codex/Claude 完整工作流,Windows待实现。" + "verification": "macOS:深浅主题下右键非活动文件标签,确认添加的是被右键的文件;项目树单文件和多选文件使用同一入口,目录不递归展开。当前会话入口明确 Agent,其他会话按已配置 Codex/Claude 分组,显示标题、短 ID 与当前勾选,支持新建会话。分别切换两个 Agent 与同 Agent 的两个会话,确认草稿文字及附件互不串用、重复添加去重、超过32个或非法批次原子拒绝、未配置时引导设置。首次启动、会话创建和历史加载失败后附件保留;已有回复不中断,只有手动发送才提交;添加后显示目标会话并聚焦输入框,编辑区/工具窗口位置保持。菜单打开后换项目、删除文件、取消 Agent 配置或移除会话,不得错误转发。脏文件只提醒保存,不自动保存或复制。Swift 原生宿主与受控 transport 回归不代替真实 Codex/Claude 完整工作流,Windows 已接入相同文件上下文入口;真实供应商完整工作流待验收。 Windows:在真实 Tauri 窗口执行 Windows 项目树与编辑器标签菜单可将保存的文件引用添加到当前或指定 Agent/会话草稿,保留文本、去重并检查工作区与会话是否过期;不会自动发送或启动后台 Agent。草稿按 Agent/会话隔离。 单元测试不替代运行时验收,未执行的真实服务及平台动作保持 pending。 Windows 回归使用真实设置 store 和 CAS 队列:从 Codex 文件菜单添加到 Claude,保存完成并规范化后仍能加入目标草稿;保存失败、保存或连接期间切换项目与配置时不得附加到错误目标。 Windows 本机 CLI 联动回归同时使用真实连接服务与设置 CAS 队列:Claude/Codex 启动读取到新地址或模型并保存后,文件仍加入正确草稿;启动期间外部修订或刷新保存失败必须拒绝附加。", + "notes": "2026-10-22:Windows 项目树与编辑器标签菜单可将保存的文件引用添加到当前或指定 Agent/会话草稿,保留文本、去重并检查工作区与会话是否过期;不会自动发送或启动后台 Agent。草稿按 Agent/会话隔离。 Windows 回归使用真实设置 store 和 CAS 队列:从 Codex 文件菜单添加到 Claude,保存完成并规范化后仍能加入目标草稿;保存失败、保存或连接期间切换项目与配置时不得附加到错误目标。 Windows 本机 CLI 联动回归同时使用真实连接服务与设置 CAS 队列:Claude/Codex 启动读取到新地址或模型并保存后,文件仍加入正确草稿;启动期间外部修订或刷新保存失败必须拒绝附加。" } diff --git a/shared/platform-feature-matrix/features/agent-file-references.json b/shared/platform-feature-matrix/features/agent-file-references.json index 183cd5208e..379b8eb873 100644 --- a/shared/platform-feature-matrix/features/agent-file-references.json +++ b/shared/platform-feature-matrix/features/agent-file-references.json @@ -33,12 +33,15 @@ "windows/tauri/src/features/agent/services/agent-composer-mentions.test.ts", "windows/tauri/src/features/file-system/utils/file-system-dropped-paths.ts", "windows/tauri/src/features/file-system/utils/file-system-drop-controller.ts", - "windows/tauri/src/features/file-system/utils/file-system-drop-controller.test.ts" + "windows/tauri/src/features/file-system/utils/file-system-drop-controller.test.ts", + "windows/tauri/src-tauri/src/agent_clipboard.rs", + "windows/tauri/src/platform/agent-pasted-images.ts", + "windows/tauri/tests/agent-pasted-images.test.ts" ], - "implementationStatus": "partial", + "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Agent", - "verification": "macOS:从 Finder、项目树拖入临时文件(多文件、中文/空格名)到输入区与上下文栏,检查高亮和可移除标签、去重、数量限制、会话切换和发送失败保留草稿;用隔离项目验证文字+文件与纯文件发送、历史加载后引用正确送到原会话、Agent 读取与权限流程。在 Finder 复制文件后于输入框 ⌘V 应附加文件,截图后 ⌘V 应生成带缩略图的图片附件(保存在 Caches/AgentPastedImages,一周后清理,不写 app bundle),复制普通文字时 ⌘V 照常粘贴,Caps Lock 开启时附件 ⌘V 同样生效;切到另一窗口或同窗口其他输入框后,附件粘贴不得进入原 Agent 草稿;在深色和浅色主题下单独输入 @ 或全角 @ 应立即列出已加载工作区的文件候选(最多 20 项,不重新扫描);扫描结束前输入 @,文件清单就绪后应自动出现候选,无需重输;继续输入关键字应筛选文件,旧查询不得覆盖新候选;方向键切换、回车/Tab 选中后移除 @ 查询并附加该文件,Esc 只关闭候选且保留草稿。图片按文件引用处理,无多模态上传。Windows 原生拖放和 Claude 端到端待验证。 macOS 裸 @ 与 @Name 共享项目文件可见性,均不显示 .build/node_modules 生成文件,不受 Search exclusions(例如 *.log)影响;全局搜索仍可单独包含生成目录名称。 Unicode 回归:@i 能找到 src/İ.txt,小写展开和希腊字母上下文转换不丢失候选;与共享 unicode-contiguous fixture 核对。", - "notes": "2026-10-16 Windows:输入框通过项目文件搜索或“引用当前文件”生成 `file://` 引用(`agent-file-uri.ts`),引用随消息一起发送并按共享规则去重与限数;系统级与项目树拖放在后续 PR 接入。" + "verification": "macOS:从 Finder、项目树拖入临时文件(多文件、中文/空格名)到输入区与上下文栏,检查高亮和可移除标签、去重、数量限制、会话切换和发送失败保留草稿;用隔离项目验证文字+文件与纯文件发送、历史加载后引用正确送到原会话、Agent 读取与权限流程。在 Finder 复制文件后于输入框 ⌘V 应附加文件,截图后 ⌘V 应生成带缩略图的图片附件(保存在 Caches/AgentPastedImages,一周后清理,不写 app bundle),复制普通文字时 ⌘V 照常粘贴,Caps Lock 开启时附件 ⌘V 同样生效;切到另一窗口或同窗口其他输入框后,附件粘贴不得进入原 Agent 草稿;在深色和浅色主题下单独输入 @ 或全角 @ 应立即列出已加载工作区的文件候选(最多 20 项,不重新扫描);扫描结束前输入 @,文件清单就绪后应自动出现候选,无需重输;继续输入关键字应筛选文件,旧查询不得覆盖新候选;方向键切换、回车/Tab 选中后移除 @ 查询并附加该文件,Esc 只关闭候选且保留草稿。图片按文件引用处理,无多模态上传。Windows 原生拖放和 Claude 端到端待验证。 macOS 裸 @ 与 @Name 共享项目文件可见性,均不显示 .build/node_modules 生成文件,不受 Search exclusions(例如 *.log)影响;全局搜索仍可单独包含生成目录名称。 Unicode 回归:@i 能找到 src/İ.txt,小写展开和希腊字母上下文转换不丢失候选;与共享 unicode-contiguous fixture 核对。 Windows:在真实 Tauri 窗口执行 Windows 支持拖放、选择器、当前文件、@ 引用、原生 CF_HDROP 文件粘贴与图片粘贴缩略图。图片按数量、类型和大小限制写入平台缓存,过期或失败粘贴清理自身文件;已接收图片为历史保留。目录不递归展开。 单元测试不替代运行时验收,未执行的真实服务及平台动作保持 pending。", + "notes": "2026-10-22:Windows 支持拖放、选择器、当前文件、@ 引用、原生 CF_HDROP 文件粘贴与图片粘贴缩略图。图片按数量、类型和大小限制写入平台缓存,过期或失败粘贴清理自身文件;已接收图片为历史保留。目录不递归展开。" } diff --git a/shared/platform-feature-matrix/features/agent-global-instructions.json b/shared/platform-feature-matrix/features/agent-global-instructions.json new file mode 100644 index 0000000000..61bbe2f0e0 --- /dev/null +++ b/shared/platform-feature-matrix/features/agent-global-instructions.json @@ -0,0 +1,40 @@ +{ + "id": "agent-global-instructions", + "area": "AI", + "group": "Agent 对话", + "capability": "全局指令卡片:一份共享正文按 Agent 显式启停同步到原生用户文件的 Lithe 区块,保留自有内容、实际路径、独立草稿及逐项错误;支持展开编辑、外部修改校验、原子保存和显式重新加载;取消在 Core 登记边界同步交付,已取消保存不因后台任务启动竞态写入", + "macos": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "macos/Sources/Lithe/Views/Agent/AgentInstructionsSettingsView.swift", + "macos/Sources/Lithe/Application/Features/AgentInstructionsFeatureModel.swift", + "macos/Sources/Lithe/Core/Rust/RustAgentInstructionsService.swift", + "macos/Tests/LitheTests/AgentInstructionsFeatureModelTests.swift", + "macos/Tests/LitheTests/AgentInstructionsAppearanceTests.swift", + "rust/lithe-agent-host/src/instructions.rs", + "macos/Sources/Lithe/Views/App/SettingsView.swift", + "macos/Tests/LitheTests/SettingsSidebarLayoutTests.swift", + "macos/Tests/LitheTests/SharedInstructionsFeatureTests.swift", + "rust/lithe-agent-host/src/instructions/shared.rs", + "rust/lithe-agent-host/src/instructions/shared/tests.rs", + "macos/Sources/Lithe/Core/Rust/RustCoreCancellation.swift", + "macos/Tests/LitheTests/AgentInstructionsCancellationTests.swift" + ] + }, + "windows": { + "implementationStatus": "partial", + "verificationStatus": "pending", + "evidence": [ + "rust/lithe-agent-host/src/instructions.rs", + "rust/lithe-core/src/agent/instructions.rs", + "windows/tauri/src-tauri/src/platform.rs", + "shared/fixtures/agent/agent-instructions-v1.json", + "rust/lithe-agent-host/src/instructions/shared.rs", + "shared/fixtures/agent/shared-instructions-v1.json" + ] + }, + "owner": "Agent", + "verification": "在同一份 macOS 预览的 AI 分组中同时检查「指令」与「技能」入口,来回切换确认两个功能都可加载。macOS:打开设置 AI > 指令,分别查看 Codex、Claude 的实际用户路径,编辑保存后新建对话验证原生加载;确认 CODEX_HOME/CLAUDE_CONFIG_DIR、自定义目录、Codex 非空 override 优先和清空 override 后返回基础文件;切换 Agent 和设置页保留未保存草稿,文件被外部修改时拒绝覆盖并保留草稿,显式重新加载需确认且读取失败不丢草稿;回归中文/英文、深浅主题和窄宽窗口的原生编辑器、选择器、搜索及保存状态。运行 AgentInstructionsFeatureModelTests、AgentInstructionsAppearanceTests、AgentInstructionsContractTests、SettingsSidebarLayoutTests、SettingsSearchVocabularyTests,以及 Host/Core 计时测试;Windows 共享文件命令可经通用 platform_invoke 分派,设置页与 Windows 原生运行验收待完成。 新增共享卡片、Agent 图标/路径/自有行数与展开编辑;隔离用户目录真实保存、启用两 Agent、更新、关闭其中一个,逐字节确认私有内容不变,重新打开从原生区块回读开关;外部修改、损坏标记、只读目标与取消后不覆盖,单项失败继续其他项并保留错误。SharedInstructionsFeatureTests 覆盖连续开关排队、取消、失败和草稿;深浅色中英文宽窄布局需实机检查。 注册前取消回归:确定性控制调用前取消、请求编码时取消,真实 Core 返回 cancelled,已有共享源不变、缺失源和锁不创建,后续有效操作仍成功。CI test-stability-macos 产物包含 instructions-ui 的中英文、深浅主题、宽窄原生截图。", + "notes": "共享源保存在平台 storage adapter 的 Application Support/Lithe/AgentInstructions/Shared.md,不额外拼接提示词。开关只控制 Lithe 区块,新会话由上游加载;按文件原子替换,跨文件部分失败可见。源、锁、暂存文件及原生指令均为用户状态,不可工作树复用,不写发行资源。" +} diff --git a/shared/platform-feature-matrix/features/agent-hooks.json b/shared/platform-feature-matrix/features/agent-hooks.json new file mode 100644 index 0000000000..57871a053a --- /dev/null +++ b/shared/platform-feature-matrix/features/agent-hooks.json @@ -0,0 +1,36 @@ +{ + "id": "agent-hooks", + "area": "AI", + "group": "Agent 对话", + "capability": "Hook 资源库:创建、编辑、搜索、启停、删除并显式同步到 Codex / Claude Code 原生配置,支持五种共同生命周期事件、工具筛选、超时、部分失败重试和中断恢复;不代替上游执行或授予信任", + "macos": { + "implementationStatus": "implemented", + "verificationStatus": "verified", + "evidence": [ + "macos/Sources/Lithe/Views/Agent/AgentHooksSettingsView.swift", + "macos/Sources/Lithe/Application/Features/AgentHooksFeatureModel.swift", + "macos/Sources/Lithe/Core/Rust/RustAgentHooksService.swift", + "macos/Tests/LitheTests/AgentHooksFeatureModelTests.swift", + "macos/Tests/LitheTests/AgentHooksAppearanceTests.swift", + "rust/lithe-agent-host/src/hooks.rs", + "shared/fixtures/agent/hooks-management-v1.json", + ".agents/notes/implemented/feature/2026-10-24-agent-hooks.md", + "macos/Sources/Lithe/Views/Agent/AgentSelectionChip.swift", + "macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift" + ] + }, + "windows": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "windows/tauri/src/features/settings/components/tabs/agent-hooks-settings.tsx", + "windows/tauri/src/features/agent/services/agent-hooks.ts", + "windows/tauri/src/features/agent/services/agent-hooks.test.ts", + "rust/lithe-agent-host/src/hooks.rs", + "windows/tauri/src/features/settings/components/tabs/agent-hooks-settings.test.tsx" + ] + }, + "owner": "Agent", + "verification": "在 macOS 与真实 Windows 中打开 AI > Hooks,创建、编辑、搜索、启停和删除命令;确认五种事件和 Shell/文件编辑筛选映射正确,超时空值沿用原生默认。使用隔离用户目录运行真实 Codex/Claude Agent,批准原生信任后触发会话开始、提示词、工具前后和回合结束,并验证 JSON stdin 与工作目录。保留已有设置及用户 Hook;删除后其他组的位置不变,Codex trust 配置不变;外部修改、只读、非法 JSON、符号链接、原生单项失败、中断和取消显示可重试状态。运行 Host Hook、Core 契约、Swift 功能/外观/设置导航测试与 Windows frontend 验证,检查中英文深浅色窄宽页面及编辑面板。比较操作前后发行 bundle 文件清单与哈希不变。 按参考页检查编辑器两列标签对齐、多行描述与命令、创建/编辑标题和固定底部动作;验证 Agent 品牌选择按钮、全部恢复和全不选时禁用保存。 搜索不匹配时应显示中英文「没有匹配的 Hook」,清空后恢复列表;区分无搜索词的空资源库,搜索不发起配置写入。", + "notes": "源与所有权日志由平台 storage adapter 提供可写目录;1 MiB 有界读写,非阻塞 OS 锁,逐文件原子替换,跨文件部分失败明确可见。原生 JSON 保留未知字段值但规范化格式;非法 JSON/重复键拒绝覆盖。Codex 仍须在原生 /hooks 中启用与信任命令,配置已写入不表示已执行。macOS 已通过 51 项相关 Swift 计时测试、14 项 Hook 文件测试和真实界面 CRUD/搜索/启停/冲突恢复验收;10 个原生 CLI 与 4 个 ACP 真实模型会话覆盖 Codex 0.160.0、Claude Code 2.1.310、Codex ACP 1.13.1、Claude ACP 0.81.2,验证五种事件、工具筛选、JSON stdin、会话目录、命令修改、停用/删除/重新启用和原生超时。1382 个 bundle 文件哈希不变,测试配置与进程已清理。完整 Host lane 为 231 通过、39 个 opt-in 跳过。Windows 实机运行尚未执行,verificationStatus 保持 pending。 Windows frontend 的 9 项计时测试与类型检查通过,包含重新加载不携带写入快照的请求边界回归;这不替代 Windows 实机验收。 编辑器按参考设计采用两列表单、独立标题/底栏和品牌选择按钮;macOS 复用 MCP 原有选择按钮,新增布局与输入回归。 布局调整后 78 项 macOS Swift 计时测试和 9 项 frontend 测试通过,类型检查通过;已检查中英文深浅色编辑器截图,并在真实预览验证焦点、多行描述、全部/单选和工具事件切换。 Review 修复 Windows 无匹配搜索留白,空状态改为依据筛选结果;新增中英文界面回归后 11 项 frontend 计时测试和类型检查通过。覆盖名称/命令大小写、中文描述、清空恢复、空资源库和搜索不写入;目标 Windows 运行证据仍待补充。" +} diff --git a/shared/platform-feature-matrix/features/agent-management.json b/shared/platform-feature-matrix/features/agent-management.json index 042273bbb8..1ea12cff8e 100644 --- a/shared/platform-feature-matrix/features/agent-management.json +++ b/shared/platform-feature-matrix/features/agent-management.json @@ -29,12 +29,13 @@ "windows/tauri/src/features/agent/services/agent-management.ts", "windows/tauri/src/features/agent/services/agent-management.test.ts", "windows/tauri/src/features/agent/components/agent-settings-section.tsx", - "windows/tauri/src/features/agent/hooks/use-agent-management.ts" + "windows/tauri/src/features/agent/hooks/use-agent-management.ts", + "windows/tauri/src/features/settings/components/tabs/agent-settings-panel.tsx" ], - "implementationStatus": "partial", + "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Agent", - "verification": "在 macOS 打开 Agent 面板右上角的设置:确认弹出主设置窗口并选中 AI > Agents,沿用共享侧栏、搜索、面包屑与表单样式,显示 Node.js 与 npm 版本,Node 缺失或版本过低时只提示;一键安装、取消安装、更新与卸载 Codex 适配器;Codex、Claude Code 与后续目录 Agent 的 npm 发布版本确认无更新时,预检行保持 PASS、显示已是最新版本且没有 Update;Homebrew 无更新时只显示当前本地索引未发现更新,并提示手动 brew update 后重查,确认不会自动刷新索引;英文与简体中文切换后,折叠预检标题及说明随界面语言变化;确认有新版本时才显示 Update;原生安装查询未知时显示无法检查更新与原安装器指引,不运行更新器做探测;新增 npm/Homebrew Agent 复用目录包名与已验证的更新计划,无品牌分支;回归覆盖 Claude 两种 Homebrew 渠道、虚构后续 Agent、npm 字符串与单元素数组 JSON、成功命令 stderr 带 warning,以及 CLI 低于最低版本但 updateAvailable 为 false 时仍保留升级指引;查询失败时显示无法确认且没有 Update;CLI 过旧时确认通过原安装器升级,npm 环境不匹配或未知来源时只显示手动指引;升级后确认实际 PATH 中的版本达到要求,成功提示重连以刷新模型目录且现有会话不会被自动中断;模拟安装器先下载失败再重试成功但返回非零,确认版本确实提升时显示成功与折叠警告日志,版本未变/仍旧/丢失、取消和超时继续按原语义处理;确认下载数据量、速度、耗时实时更新且未知总量不显示百分比,取消/失败/完成后进度清除;一键获取本机 Codex 配置后返回 Agent 面板对话,且提交信息所用服务商不变;Windows 待接入设置界面。设置页 Agent 选择使用共享下拉框,预检和 ACP adapter 标注没有背景卡片;从设置选择 Codex/Claude/自定义 Agent 后,聊天标题与底栏同步,再从聊天底栏切回,设置下拉与详情同步;未配置或功能关闭时也保持选择一致且不自动打开设置。设置供应商按钮使用共享尺寸,错误提示使用共享通知,WARN/FAIL 检查项能收起再展开;AI Providers 无供应商时仍可新增。运行AgentManagementFeatureModelTests、AgentSetupDiagnosticsTests及lithe-agent-host计时测试:重复检查加载、失败原因、保留旧数据后重试;所有目录适配器相同/较高/较低/预发布/元数据/非法版本;Codex、Claude和future-agent在不同渠道与未知状态下的按钮一致性和CLI探测错误。真实macOS设置页与Windows界面验收仍待进行。更新版本展示回归覆盖npm、Homebrew cask/formula、缺少current_version及其他包同列时只选取目标包的版本;中英文预检标题与详情必须显示相同当前/目标版本。", - "notes": "2026-10-16 Windows:共享 `agent.status` / `agent.install` / `agent.uninstall` 已通过 `platform_invoke` 接通,前端服务层按共享 fixture 解析适配器状态与 `agentInstallProgress` 进度。 安装类命令不再套用 30 秒信封超时(npm 下载可能持续数分钟,共享 host 自己以 15 分钟为界),安装进度只投递给发起安装的那个窗口。 设置界面、预检清单与真机端到端验收仍待完成。 所有目录适配器由Rust按SemVer返回updateAvailable,本地版本等于或高于核验固定版本时不提示更新,未知版本不提供更新;所有CLI仅确认有新版且安装来源可更新时显示按钮,最低兼容版本警告不再代替更新证据。 每次检查显示加载、完成和失败原因,CLI部分失败保留updateCheckError,检查中及失败隐藏旧更新入口。 检查结果保留安装来源返回的availableVersion;CLI折叠标题与详情同时显示当前版本及可更新目标,最低要求独立列出;Homebrew条目没有目标版本时明确无法确认,适配器显示当前版本和核验目标。 面板设置区块已接入状态刷新、预检问题与适配器安装(含下载进度);卸载按钮、预检清单的完整展示与真机端到端验收仍待完成。 设置页复用同一条安装进度通道升级 CLI:只有一个安装来源可安全更新或完全没检测到 CLI 时才给按钮,升级后以 host 复核过的版本收尾,安装器 warnings 单独展示而不当作失败。" + "verification": "在 macOS 打开 Agent 面板右上角的设置:确认弹出主设置窗口并选中 AI > Agents,沿用共享侧栏、搜索、面包屑与表单样式,显示 Node.js 与 npm 版本,Node 缺失或版本过低时只提示;一键安装、取消安装、更新与卸载 Codex 适配器;Codex、Claude Code 与后续目录 Agent 的 npm 发布版本确认无更新时,预检行保持 PASS、显示已是最新版本且没有 Update;Homebrew 无更新时只显示当前本地索引未发现更新,并提示手动 brew update 后重查,确认不会自动刷新索引;英文与简体中文切换后,折叠预检标题及说明随界面语言变化;确认有新版本时才显示 Update;原生安装查询未知时显示无法检查更新与原安装器指引,不运行更新器做探测;新增 npm/Homebrew Agent 复用目录包名与已验证的更新计划,无品牌分支;回归覆盖 Claude 两种 Homebrew 渠道、虚构后续 Agent、npm 字符串与单元素数组 JSON、成功命令 stderr 带 warning,以及 CLI 低于最低版本但 updateAvailable 为 false 时仍保留升级指引;查询失败时显示无法确认且没有 Update;CLI 过旧时确认通过原安装器升级,npm 环境不匹配或未知来源时只显示手动指引;升级后确认实际 PATH 中的版本达到要求,成功提示重连以刷新模型目录且现有会话不会被自动中断;模拟安装器先下载失败再重试成功但返回非零,确认版本确实提升时显示成功与折叠警告日志,版本未变/仍旧/丢失、取消和超时继续按原语义处理;确认下载数据量、速度、耗时实时更新且未知总量不显示百分比,取消/失败/完成后进度清除;一键获取本机 Codex 配置后返回 Agent 面板对话,且提交信息所用服务商不变;Windows 已接入独立 Agent 设置页;官方 CLI 安装、卸载和登录仍需独立验收。设置页 Agent 选择使用共享下拉框,预检和 ACP adapter 标注没有背景卡片;从设置选择 Codex/Claude/自定义 Agent 后,聊天标题与底栏同步,再从聊天底栏切回,设置下拉与详情同步;未配置或功能关闭时也保持选择一致且不自动打开设置。设置供应商按钮使用共享尺寸,错误提示使用共享通知,WARN/FAIL 检查项能收起再展开;AI Providers 无供应商时仍可新增。运行AgentManagementFeatureModelTests、AgentSetupDiagnosticsTests及lithe-agent-host计时测试:重复检查加载、失败原因、保留旧数据后重试;所有目录适配器相同/较高/较低/预发布/元数据/非法版本;Codex、Claude和future-agent在不同渠道与未知状态下的按钮一致性和CLI探测错误。真实macOS设置页与Windows界面验收仍待进行。更新版本展示回归覆盖npm、Homebrew cask/formula、缺少current_version及其他包同列时只选取目标包的版本;中英文预检标题与详情必须显示相同当前/目标版本。 Windows:在真实 Tauri 窗口执行 Windows Agent 设置页复用管理模型,提供适配器安装、确认卸载、CLI 来源与版本、预检详情、更新检查及下载进度。真实下载、卸载及官方 CLI 登录需单独验收。 单元测试不替代运行时验收,未执行的真实服务及平台动作保持 pending。", + "notes": "2026-10-22:Windows Agent 设置页复用管理模型,提供适配器安装、确认卸载、CLI 来源与版本、预检详情、更新检查及下载进度。真实下载、卸载及官方 CLI 登录需单独验收。" } diff --git a/shared/platform-feature-matrix/features/agent-mode-sync.json b/shared/platform-feature-matrix/features/agent-mode-sync.json index 7153b78bbe..db3ef51283 100644 --- a/shared/platform-feature-matrix/features/agent-mode-sync.json +++ b/shared/platform-feature-matrix/features/agent-mode-sync.json @@ -25,12 +25,14 @@ "windows/tauri/src/features/agent/stores/agent-connection.model.ts", "windows/tauri/src/features/agent/services/agent-session-selectors.ts", "windows/tauri/src/features/agent/types/agent.types.test.ts", - "shared/fixtures/agent/acp-events-v1.json" + "shared/fixtures/agent/acp-events-v1.json", + "rust/lithe-agent-host/src/lib.rs", + "windows/tauri/src/features/agent/stores/agent-parity.test.ts" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Agent", - "verification": "macOS:使用真实 Agent 进入并退出计划模式,确认 current_mode_update 只同步有效模式且不回发请求。Claude 默认 Auto 且模型不支持时,先切 Plan 再首次发送,迟到降级提示不出现;实际降级时显示可关闭通知,成功切模式或模型后清除旧通知,配置失败保留。新降级通知早于配置结果时等待确认,不能被旧 Plan 或新模型确认误删;暂存警告→新错误→配置成功/失败都保留新错误,相反顺序保留最新有效警告,无效或其他会话通知不改变顺序。无本地 token 的原生 /model 按 mode→notice→config 顺序保留本次新通知,转换只保护一份完整配置,后续换模型仍清理旧警告;覆盖后台会话隔离和新轮次清理。确认通知不进入回复、导出或轮次统计,各会话独立;深浅主题和窄宽面板使用共享 Banner。执行 AgentConversationFeatureModelTests、AgentConversationPresentationTests、AgentTranscriptScrollTests、Host notice 握手与转发测试,通知与权限同时出现及关闭时保持长历史滚动。Windows 已处理 `current_mode_update`:只更新 Agent 已列出的 mode 选项,未知 id 不写入、不回发请求,选择器继续显示上游模式名。真实供应商与降级通知验收仍 pending。", - "notes": "2026-10-18 Windows:`current_mode_update` 通过 `applyCurrentMode` 只同步 category 为 mode 的已有选项;Agent 未列出的 id 被忽略,不伪造选项。单测覆盖有效切换与未知 id。真机计划模式往返待验收。" + "verification": "macOS:使用真实 Agent 进入并退出计划模式,确认 current_mode_update 只同步有效模式且不回发请求。Claude 默认 Auto 且模型不支持时,先切 Plan 再首次发送,迟到降级提示不出现;实际降级时显示可关闭通知,成功切模式或模型后清除旧通知,配置失败保留。新降级通知早于配置结果时等待确认,不能被旧 Plan 或新模型确认误删;暂存警告→新错误→配置成功/失败都保留新错误,相反顺序保留最新有效警告,无效或其他会话通知不改变顺序。无本地 token 的原生 /model 按 mode→notice→config 顺序保留本次新通知,转换只保护一份完整配置,后续换模型仍清理旧警告;覆盖后台会话隔离和新轮次清理。确认通知不进入回复、导出或轮次统计,各会话独立;深浅主题和窄宽面板使用共享 Banner。执行 AgentConversationFeatureModelTests、AgentConversationPresentationTests、AgentTranscriptScrollTests、Host notice 握手与转发测试,通知与权限同时出现及关闭时保持长历史滚动。Windows 已处理 `current_mode_update`:只更新 Agent 已列出的 mode 选项,未知 id 不写入、不回发请求,选择器继续显示上游模式名。真实供应商与降级通知验收仍 pending。 Windows:在真实 Tauri 窗口执行 Windows 消费上游模式变更与结构化降级通知,关联当前会话和配置;确认新配置后清理过期提示。重试次数与静默活动保留真实忙碌状态,不擅自结束轮次。 单元测试不替代运行时验收,未执行的真实服务及平台动作保持 pending。", + "notes": "2026-10-22:Windows 消费上游模式变更与结构化降级通知,关联当前会话和配置;确认新配置后清理过期提示。重试次数与静默活动保留真实忙碌状态,不擅自结束轮次。" } diff --git a/shared/platform-feature-matrix/features/agent-model-call-statistics.json b/shared/platform-feature-matrix/features/agent-model-call-statistics.json index b4ae2ddd60..d0f5d295f1 100644 --- a/shared/platform-feature-matrix/features/agent-model-call-statistics.json +++ b/shared/platform-feature-matrix/features/agent-model-call-statistics.json @@ -35,11 +35,14 @@ "rust/lithe-agent-host/src/model_proxy/websocket/tests.rs", "rust/lithe-agent-host/src/subscription.rs", "rust/lithe-agent-host/src/codex-config-relay.cjs", - "scripts/test-codex-config-relay.mjs" + "scripts/test-codex-config-relay.mjs", + "windows/tauri/src/features/agent/components/agent-observed-statistics.tsx", + "windows/tauri/src/features/agent/types/agent-observations.test.ts" ], - "implementationStatus": "partial", + "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Agent", - "verification": "macOS:在 Agent 设置开启“模型调用统计”,用 API Key 模式的 Claude 与 Codex 各完成一轮带工具调用的对话,确认 Agent 正常回复;点击会话详情显示会话统计(模型用时、工具与授权用时、TTFT、TPS)、Token 明细和上下文构成;发送旁圆环悬停仅显示百分比与已用/容量,点击展开上下文明细,数值与服务商控制台大致一致;每轮页脚显示模型调用次数、首字和速度,非流式响应有标记。关闭开关并重新连接后,步数、TPS、TTFT 与上下文构成隐藏,只显示轮次、Agent 上报的最近一轮 Token 与上下文占用;Codex订阅与API Key模式共用可选统计。并发两个会话时不串数据。窄宽面板、深浅主题检查布局。Windows 共享 Host 支持 observeModelCalls 与 modelCall 事件,界面尚未接入。 统计与上下文入口统一位于发送旁,顶部不再显示重复信息按钮;有报告显示用量圆环,未上报显示信息图标,均打开同一可滚动面板,窄面板可查看全部会话、Token与缓存命中、上下文四类分项及工具/MCP数量;主界面不常驻统计长文本。发送旁同一入口同时展示上下文构成、会话统计与Token明细,标题关闭按钮、Esc、外部点击关闭同一弹窗,历史快照仅显示用量数字,未上报不显示为0%;隐藏工具栏或切换会话关闭原弹层,编辑页与工具窗口数据保持一致。 上下文与MCP共用多色容量条和图例,缺少分项不显示伪造零值或技能分类,详情仅展示上下文构成、会话与Token统计,移除更改区、Git分支、变更文件数、Agent编辑行数和未编辑文件提示;弹窗与右侧详情栏一致,文件活动栏及回撤仍可用。 有分项数据后面板底部不常驻估算说明和分隔线;分项比例仍有~,悬停显示估算Token及按请求大小估算/按供应商输入校准的口径。无分项的未知或启用指引继续保留。 开启统计后,Codex订阅重连并发送消息,验证四类分项、调用次数、TTFT/TPS和缓存命中;关闭统计重连保持原生直连。WebSocket压缩、分片、控制帧和认证头原样透传,预热不计数,连续两轮基于完整上下文估算,未知前序ID不编造分项;旧历史缺分项等待新请求,不显示“订阅不统计”提示。原生Codex与本地假服务验收不等于真实供应商或Windows运行验收。 原生account/read仅对普通后台且NO_CONSTRAINT启用采集;地区受限、未知或失败保留原生直连,账户变更关闭已观察连接。 会话详情展开并滚动至会话统计与Token用量,确认左侧边框竖向且无横穿面板中间的多余线;正常标题分隔、滚动和关闭行为保留,深浅主题均检查。 会话统计不显示“模型调用由本机代理观测”说明,Token卡片不显示“本会话所有模型请求由服务商返回的计数”说明;数值、标题分隔和卡片间距正常,无说明残留空白;缺失统计指引、缺失用量下界及Agent原生计数口径按原状态保留。 确定性回放account/read请求→account/updated或account/login/completed通知→旧成功响应→新建/恢复/分叉,确认同邮箱工作区切换也保持原生路由,通知后的新查询成功才允许观察;已观察连接仍关闭。darwin/win32协议模拟均覆盖,不等于Windows原生验收。" + "verification": "macOS:在 Agent 设置开启“模型调用统计”,用 API Key 模式的 Claude 与 Codex 各完成一轮带工具调用的对话,确认 Agent 正常回复;点击会话详情显示会话统计(模型用时、工具与授权用时、TTFT、TPS)、Token 明细和上下文构成;发送旁圆环悬停仅显示百分比与已用/容量,点击展开上下文明细,数值与服务商控制台大致一致;每轮页脚显示模型调用次数、首字和速度,非流式响应有标记。关闭开关并重新连接后,步数、TPS、TTFT 与上下文构成隐藏,只显示轮次、Agent 上报的最近一轮 Token 与上下文占用;Codex订阅与API Key模式共用可选统计。并发两个会话时不串数据。窄宽面板、深浅主题检查布局。Windows 已消费 observeModelCalls 与 modelCall 事件并呈现观测统计。受控代理可验证 UI 与持久化;真实供应商代理观测仍待验收。 统计与上下文入口统一位于发送旁,顶部不再显示重复信息按钮;有报告显示用量圆环,未上报显示信息图标,均打开同一可滚动面板,窄面板可查看全部会话、Token与缓存命中、上下文四类分项及工具/MCP数量;主界面不常驻统计长文本。发送旁同一入口同时展示上下文构成、会话统计与Token明细,标题关闭按钮、Esc、外部点击关闭同一弹窗,历史快照仅显示用量数字,未上报不显示为0%;隐藏工具栏或切换会话关闭原弹层,编辑页与工具窗口数据保持一致。 上下文与MCP共用多色容量条和图例,缺少分项不显示伪造零值或技能分类,详情仅展示上下文构成、会话与Token统计,移除更改区、Git分支、变更文件数、Agent编辑行数和未编辑文件提示;弹窗与右侧详情栏一致,文件活动栏及回撤仍可用。 有分项数据后面板底部不常驻估算说明和分隔线;分项比例仍有~,悬停显示估算Token及按请求大小估算/按供应商输入校准的口径。无分项的未知或启用指引继续保留。 开启统计后,Codex订阅重连并发送消息,验证四类分项、调用次数、TTFT/TPS和缓存命中;关闭统计重连保持原生直连。WebSocket压缩、分片、控制帧和认证头原样透传,预热不计数,连续两轮基于完整上下文估算,未知前序ID不编造分项;旧历史缺分项等待新请求,不显示“订阅不统计”提示。原生Codex与本地假服务验收不等于真实供应商或Windows运行验收。 原生account/read仅对普通后台且NO_CONSTRAINT启用采集;地区受限、未知或失败保留原生直连,账户变更关闭已观察连接。 会话详情展开并滚动至会话统计与Token用量,确认左侧边框竖向且无横穿面板中间的多余线;正常标题分隔、滚动和关闭行为保留,深浅主题均检查。 会话统计不显示“模型调用由本机代理观测”说明,Token卡片不显示“本会话所有模型请求由服务商返回的计数”说明;数值、标题分隔和卡片间距正常,无说明残留空白;缺失统计指引、缺失用量下界及Agent原生计数口径按原状态保留。 确定性回放account/read请求→account/updated或account/login/completed通知→旧成功响应→新建/恢复/分叉,确认同邮箱工作区切换也保持原生路由,通知后的新查询成功才允许观察;已观察连接仍关闭。darwin/win32协议模拟均覆盖,不等于Windows原生验收。 Windows:在真实 Tauri 窗口执行 Windows 消费 Host 模型调用观测,展示调用数、输入输出 Token、首 Token 延迟、输出速度、模型耗时与非流式调用;未知用量保留未知,部分观测按下界提示。按会话持久化累计数字,未保存原始请求、正文或凭据。真实供应商代理观测仍待验收。 单元测试不替代运行时验收,未执行的真实服务及平台动作保持 pending。", + "notes": "2026-10-22:Windows 消费 Host 模型调用观测,展示调用数、输入输出 Token、首 Token 延迟、输出速度、模型耗时与非流式调用;未知用量保留未知,部分观测按下界提示。按会话持久化累计数字,未保存原始请求、正文或凭据。真实供应商代理观测仍待验收。" } diff --git a/shared/platform-feature-matrix/features/agent-opened-workspace-context.json b/shared/platform-feature-matrix/features/agent-opened-workspace-context.json index 43ea1815ba..5f46655a9e 100644 --- a/shared/platform-feature-matrix/features/agent-opened-workspace-context.json +++ b/shared/platform-feature-matrix/features/agent-opened-workspace-context.json @@ -17,14 +17,16 @@ ] }, "windows": { - "implementationStatus": "partial", + "implementationStatus": "implemented", "verificationStatus": "pending", "evidence": [ "rust/lithe-agent-host/src/workspace_context.rs", "shared/contracts/rust-core-api.md", - "windows/tauri/src/config/backend-capabilities.ts" + "windows/tauri/src/features/agent/services/agent-launch.ts", + "windows/tauri/src/features/agent/services/agent-transport.ts", + "windows/tauri/src-tauri/src/agent.rs" ] }, "owner": "Agent", - "verification": "打开外层有独立 IDE README 和开发规则、内层有 Spring Boot README 的子项目,普通提问和回复中补充均应读取内层资料并介绍内层服务;停止重连并恢复同一会话后再次询问,范围与用户历史保持。跨会话及不同工作区不能混用范围。检查文件附件顺序、仅附件消息、空输入和非法 URI;/usage、/compact 与 /model 的 ACP 原始参数保持。真实 Claude/Codex 供应商验证读文件证据与回答;历史只呈现原用户文字,模型回复和工具证据不被过滤。该上下文不宣称文件系统沙箱。Windows 原生 Agent 后端尚未启用,仅共享 Host 已实现,Windows 真实运行验收待完成。 macOS 已通过共享 ACP 确定性回归、真实 Claude Opus 与 Codex 的嵌套项目/补充/重连恢复/消息和标题验收,以及实际 Spring Boot 示例窗口中的原始问题、补充提问和历史恢复验证。 受控 ACP 回归必须比较带附件斜杠命令的完整普通 prompt 和 steering 延后续发数组(含前导空白与多附件顺序),确认没有附加上下文块;仅附件首条消息产生的完整、截断、空白归一化上下文标题在实时更新与列表中均为 null,macOS 保留首条用户消息、新对话、无标题和导出回退。上述边界通过受控协议与呈现验证,真实供应商带附件命令未据此视为验收完成。" + "verification": "打开外层有独立 IDE README 和开发规则、内层有 Spring Boot README 的子项目,普通提问和回复中补充均应读取内层资料并介绍内层服务;停止重连并恢复同一会话后再次询问,范围与用户历史保持。跨会话及不同工作区不能混用范围。检查文件附件顺序、仅附件消息、空输入和非法 URI;/usage、/compact 与 /model 的 ACP 原始参数保持。真实 Claude/Codex 供应商验证读文件证据与回答;历史只呈现原用户文字,模型回复和工具证据不被过滤。该上下文不宣称文件系统沙箱。Windows 已由 Agent 面板经 Tauri 连接桥接入共享 Host,launch.cwd 作为工作区路径;共享 workspace context 覆盖普通消息、补充消息和会话恢复。目标平台与真实供应商运行验收待完成。 macOS 已通过共享 ACP 确定性回归、真实 Claude Opus 与 Codex 的嵌套项目/补充/重连恢复/消息和标题验收,以及实际 Spring Boot 示例窗口中的原始问题、补充提问和历史恢复验证。 受控 ACP 回归必须比较带附件斜杠命令的完整普通 prompt 和 steering 延后续发数组(含前导空白与多附件顺序),确认没有附加上下文块;仅附件首条消息产生的完整、截断、空白归一化上下文标题在实时更新与列表中均为 null,macOS 保留首条用户消息、新对话、无标题和导出回退。上述边界通过受控协议与呈现验证,真实供应商带附件命令未据此视为验收完成。" } diff --git a/shared/platform-feature-matrix/features/agent-provider-configuration.json b/shared/platform-feature-matrix/features/agent-provider-configuration.json index 521952b686..30e3a642d6 100644 --- a/shared/platform-feature-matrix/features/agent-provider-configuration.json +++ b/shared/platform-feature-matrix/features/agent-provider-configuration.json @@ -24,17 +24,23 @@ ] }, "windows": { - "implementationStatus": "partial", + "implementationStatus": "implemented", "verificationStatus": "pending", "evidence": [ "rust/lithe-core/src/ai/agent_configuration.rs", "shared/fixtures/agent/provider-configuration-v1.json", "windows/tauri/src/features/agent/components/agent-settings-section.tsx", "windows/tauri/src/features/agent/services/agent-launch.ts", - "windows/tauri/src/features/agent/services/agent-provider-import.ts" + "windows/tauri/src/features/agent/services/agent-provider-import.ts", + "windows/tauri/src/platform/agent-cli-configuration.ts", + "windows/tauri/src/features/agent/services/agent-provider-credentials.ts", + "windows/tauri/tests/agent-linked-launch.test.ts", + "windows/tauri/src-tauri/src/agent_provider_settings.rs", + "windows/tauri/tests/agent-settings-persistence.test.ts", + "windows/tauri/tests/agent-provider-editor.test.tsx" ] }, "owner": "Agent", - "verification": "macOS:本机 Codex/Claude 配置使用 HTTP 回环代理时,确认默认被拒绝;在当前本机配置行打开 Allow insecure HTTP,空闲重连后检查所选代理接收请求,关闭后再次拒绝;刷新、重新导入及重启保留许可且另一来源不受影响,忙碌时拒绝更改,另一窗口刷新或删除后过期更改失败。验证本机 Codex/Claude 配置读取、刷新与取消关联;外部 CLI 连续切换地址、模型与密钥后直接建立新连接,确认每个连接使用同次快照且面板元数据同步,不依赖手动刷新;原供应商 ID、关联、提交供应商选择、另一来源和 HTTP 许可不变,密钥不进入设置;缺失配置或密钥拒绝旧路由,仅模型可读时保留回退显示;自定义配置及订阅不读取本机 API 快照;新增 Codex 专用编辑器时确认默认模型为 gpt-6.1-sol,新增 Claude 专用编辑器确认预填 claude-fable-5-1 且 settings JSON 与表单一致;两种草稿往返切换后保留默认值,编辑已有 Claude 供应商保留自定义或空模型;通用 AI Providers 的 Add Provider 切换 Anthropic Messages 后,未填写模型时确认 Claude 草稿模型为空且提交信息请求在发送前被拒绝,填写模型后检查 Anthropic 请求使用该值;协议切换保留手动模型(包括恰好等于 Codex 默认值的输入),编辑已有服务商时确认保留已保存的模型;新增与编辑默认显示名称、API Key、API 请求地址和模型表单,必填项为空禁用保存,密钥默认隐藏且可显隐;修改字段后切到配置文本确认 TOML/JSON 正确转义,修改配置文本后切回表单检查同步回填;新增 Codex/Claude 后不填任何值往返切换,以及分别缺少地址、模型、Key 时返回表单,确认不报缺失密钥、不填隐式默认值且仍不能保存;粘贴部分配置和模型别名确认字段与 Key 保留,切换失败保留原文和当前模式,保存只读取当前模式的输入;检查表单 HTTP 默认阻止且显式许可后可保存,编辑回填地址、模型、Key、HTTP 许可及原 ID;添加自定义 TOML/auth JSON 或 Claude settings JSON,检查格式化、输入直引号不被自动替换、弯引号 TOML 错误在保存按钮上方始终可见、非法配置和缺失密钥、取消不保存、编辑保持 ID、删除确认及安全存储失败不丢失配置。在一个窗口保存并等待连接关闭期间,由另一窗口删除或修改同一供应商,确认旧保存失败且不恢复/覆盖供应商、密钥或 Agent 关联。检查窄宽面板、深浅主题、中英文本。空闲切换后确认请求到所选地址和模型;未发送空会话切换或意外退出后重新创建,不向旧 ID 加载,首条消息与文件只发送一次;已有消息、上游列出或成功加载的会话继续恢复,历史加载失败保留记录;正在响应、加载、权限待处理或配置确认时拒绝切换。提交服务商选择不被添加/启用改变。CLI 文件与 bundle 不被写入。2026-10-07 已通过 claude-fable-5-1 真实新连接、重连、历史回放与继续回复验证;Windows 编辑器及完整供应商管理界面验收待验证。 Windows 面板当前是单一服务商表单:endpoint、模型、密钥安全存储与重连;粘贴配置文本导入已接入:由共享 Rust 解析出 endpoint、模型与协议后填入表单,只读元数据、不搬运密钥,chatCompletions 或与所选适配器协议不匹配时明确报错;一键读取本机 CLI 配置文件、多供应商列表与取消关联尚未实现,编辑器与 Claude 真实请求待验证。 Windows 2026-10-18:新增 Codex 供应商默认模型为 gpt-6.1-sol、Claude 为 claude-fable-5-1;从空模型或上一适配器的默认模型切换时填入新默认值,手填模型保持不变,已持久化的空模型不被回填。本机 CLI 配置自动读取、多供应商列表与配置文本双向切换仍未实现。", - "notes": "2026-10-16 Windows:`agent-settings-section.tsx` 提供单一服务商的 endpoint、模型与 API Key 存储(安全存储 key `agent-provider-api-key/`),启动参数由 `agent-launch.ts` 组装;CLI 配置导入、多供应商管理与真实服务商验收仍待执行。 供应商表单支持粘贴 Codex TOML 或 Claude JSON 导入:解析在共享 Rust Core(`agent.parseProviderConfiguration`)里做,前端只接收 endpoint、模型与协议,密钥仍由本机 CLI 持有。 2026-10-16 Windows:`agent-settings-section.tsx` 提供单一服务商的 endpoint、模型与 API Key 存储(安全存储 key `agent-provider-api-key/`),启动参数由 `agent-launch.ts` 组装;供应商表单支持粘贴 Codex TOML 或 Claude JSON 导入,解析在共享 Rust Core(`agent.parseProviderConfiguration`)。 2026-10-18:新增 Codex `gpt-6.1-sol` / Claude `claude-fable-5-1` 默认模型,切换适配器只替换上一默认值并保留手填模型;已保存的空模型保持为空;订阅切换在忙碌时禁用。 本机 CLI 配置自动读取/刷新、多供应商列表、表单与配置文本双向切换仍未实现。" + "verification": "macOS:本机 Codex/Claude 配置使用 HTTP 回环代理时,确认默认被拒绝;在当前本机配置行打开 Allow insecure HTTP,空闲重连后检查所选代理接收请求,关闭后再次拒绝;刷新、重新导入及重启保留许可且另一来源不受影响,忙碌时拒绝更改,另一窗口刷新或删除后过期更改失败。验证本机 Codex/Claude 配置读取、刷新与取消关联;外部 CLI 连续切换地址、模型与密钥后直接建立新连接,确认每个连接使用同次快照且面板元数据同步,不依赖手动刷新;原供应商 ID、关联、提交供应商选择、另一来源和 HTTP 许可不变,密钥不进入设置;缺失配置或密钥拒绝旧路由,仅模型可读时保留回退显示;自定义配置及订阅不读取本机 API 快照;新增 Codex 专用编辑器时确认默认模型为 gpt-6.1-sol,新增 Claude 专用编辑器确认预填 claude-fable-5-1 且 settings JSON 与表单一致;两种草稿往返切换后保留默认值,编辑已有 Claude 供应商保留自定义或空模型;通用 AI Providers 的 Add Provider 切换 Anthropic Messages 后,未填写模型时确认 Claude 草稿模型为空且提交信息请求在发送前被拒绝,填写模型后检查 Anthropic 请求使用该值;协议切换保留手动模型(包括恰好等于 Codex 默认值的输入),编辑已有服务商时确认保留已保存的模型;新增与编辑默认显示名称、API Key、API 请求地址和模型表单,必填项为空禁用保存,密钥默认隐藏且可显隐;修改字段后切到配置文本确认 TOML/JSON 正确转义,修改配置文本后切回表单检查同步回填;新增 Codex/Claude 后不填任何值往返切换,以及分别缺少地址、模型、Key 时返回表单,确认不报缺失密钥、不填隐式默认值且仍不能保存;粘贴部分配置和模型别名确认字段与 Key 保留,切换失败保留原文和当前模式,保存只读取当前模式的输入;检查表单 HTTP 默认阻止且显式许可后可保存,编辑回填地址、模型、Key、HTTP 许可及原 ID;添加自定义 TOML/auth JSON 或 Claude settings JSON,检查格式化、输入直引号不被自动替换、弯引号 TOML 错误在保存按钮上方始终可见、非法配置和缺失密钥、取消不保存、编辑保持 ID、删除确认及安全存储失败不丢失配置。在一个窗口保存并等待连接关闭期间,由另一窗口删除或修改同一供应商,确认旧保存失败且不恢复/覆盖供应商、密钥或 Agent 关联。检查窄宽面板、深浅主题、中英文本。空闲切换后确认请求到所选地址和模型;未发送空会话切换或意外退出后重新创建,不向旧 ID 加载,首条消息与文件只发送一次;已有消息、上游列出或成功加载的会话继续恢复,历史加载失败保留记录;正在响应、加载、权限待处理或配置确认时拒绝切换。提交服务商选择不被添加/启用改变。CLI 文件与 bundle 不被写入。2026-10-07 已通过 claude-fable-5-1 真实新连接、重连、历史回放与继续回复验证;Windows 编辑器及完整供应商管理界面验收待验证。 Windows:在真实 Tauri 窗口执行 Windows 已支持按 Agent 保存独立配置、命名供应商列表、确认移除与解除密钥关联;密钥按 Agent/供应商 ID 存入 Windows 凭据库,地址变化使用新凭据所有者。表单可生成 Codex TOML/Claude JSON,文本可解析返回表单;Codex auth JSON 的显式 API Key 安全保存。可只读导入本机元数据,或关联本机配置,在每次连接读取最新地址、模型与同次读取的显式 API Key,缺失时拒绝使用旧路由。CLI 文件保持只读。供应商新增和编辑使用独立草稿,取消不保存;显式保存通过 native CAS 同时提交元数据与安全凭据,过期修订拒绝覆盖。已关联本机配置在连接时刷新元数据与凭据,密钥字段支持显隐,读取完成前禁用编辑与保存。真实 Claude/Codex 服务请求和完整多窗口桌面验收保持 pending。 单元测试不替代运行时验收,未执行的真实服务及平台动作保持 pending。 Windows 回归:Codex TOML/auth JSON 和 Claude settings JSON 在文本模式导入后切回表单,直接保存保留导入的地址、模型及凭据草稿;密钥只交给凭据保存动作,不写入普通设置。", + "notes": "2026-10-22:Windows 已支持按 Agent 保存独立配置、命名供应商列表、确认移除与解除密钥关联;密钥按 Agent/供应商 ID 存入 Windows 凭据库,地址变化使用新凭据所有者。表单可生成 Codex TOML/Claude JSON,文本可解析返回表单;Codex auth JSON 的显式 API Key 安全保存。可只读导入本机元数据,或关联本机配置,在每次连接读取最新地址、模型与同次读取的显式 API Key,缺失时拒绝使用旧路由。CLI 文件保持只读。供应商新增和编辑使用独立草稿,取消不保存;显式保存通过 native CAS 同时提交元数据与安全凭据,过期修订拒绝覆盖。已关联本机配置在连接时刷新元数据与凭据,密钥字段支持显隐,读取完成前禁用编辑与保存。真实 Claude/Codex 服务请求和完整多窗口桌面验收保持 pending。 Windows 回归:Codex TOML/auth JSON 和 Claude settings JSON 在文本模式导入后切回表单,直接保存保留导入的地址、模型及凭据草稿;密钥只交给凭据保存动作,不写入普通设置。" } diff --git a/shared/platform-feature-matrix/features/agent-reasoning-display.json b/shared/platform-feature-matrix/features/agent-reasoning-display.json index 4ead17e657..7a07cb14a1 100644 --- a/shared/platform-feature-matrix/features/agent-reasoning-display.json +++ b/shared/platform-feature-matrix/features/agent-reasoning-display.json @@ -2,7 +2,7 @@ "id": "agent-reasoning-display", "area": "AI", "group": "Agent 对话", - "capability": "Agent 上报的思考内容(ACP agent_thought_chunk)显示为独立的可折叠思考块,流式输出时展开、回复或工具到达后收起,不并入回复正文,导出 Markdown 时以引用块保留", + "capability": "每轮思考、过程说明和工具调用合成一个默认折叠的过程入口,流式时也由用户主动展开;最终回复单独展示,空白片段不占行,搜索临时展开匹配内容,原始思考记录与 Markdown 导出保持独立", "macos": { "evidence": [ "macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift", @@ -12,7 +12,8 @@ "shared/fixtures/agent/acp-events-v1.json", "macos/Tests/LitheTests/AgentConversationPresentationTests.swift", "macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift", - "macos/Sources/Lithe/Views/Agent/AgentTurnStatisticsView.swift" + "macos/Sources/Lithe/Views/Agent/AgentTurnStatisticsView.swift", + "macos/Sources/Lithe/Views/Agent/AgentProcessGroupView.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -25,12 +26,14 @@ "shared/fixtures/agent/acp-events-v1.json", "windows/tauri/src/features/agent/services/agent-transcript-items.ts", "windows/tauri/src/features/agent/services/agent-history-export.ts", - "windows/tauri/src/features/agent/services/agent-history-view.ts" + "windows/tauri/src/features/agent/services/agent-history-view.ts", + "windows/tauri/src/features/agent/services/agent-transcript-items.test.ts", + "windows/tauri/src/features/agent/components/agent-transcript.process.test.tsx" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Agent", - "verification": "macOS:使用会上报思考内容的 Agent 与思考强度发送需要推理的问题,确认思考块在流式输出时展开显示“思考中…”,底部等待行显示“回复中…”并保留本轮计时,回复或工具调用到达后自动收起为“思考过程”,手动展开后保持;搜索不匹配的文字使思考行消失后,清空搜索仍恢复手动展开状态,切换会话不串用偏好、关闭会话后释放偏好;思考与回复交替时顺序正确且文本不混合;手动收起思考块后搜索其内容,命中时仍展开,搜索期间保持可见,清空搜索后恢复手动折叠状态;加载历史会话后思考块与实时流一致;导出 Markdown 中思考以引用块出现,历史消息数不计思考。在窄宽面板及深浅主题检查长文本换行与可读性。Windows 已把 `agent_thought_chunk` 作为独立 `reasoning` 行接入:流式期间默认展开,回复或工具到达后自动折叠,手动展开保持;搜索命中时强制展开,清空搜索后回到手动或默认状态;思考文本不并入回复,历史消息数不计思考,导出标为 Thinking 并以引用块呈现。真机与历史回放验收待执行。", - "notes": "2026-10-18 Windows:推理块先冲刷待合并正文,保证思考与回复的流顺序;思考行默认在流式时展开、回复到达后折叠,搜索命中时展开且不改变手动偏好。历史导出与消息计数已排除推理行。真机验收待执行。" + "verification": "macOS 与 Windows:构造思考→过程说明→读文件→思考→执行命令→最终回复,确认每轮只有一个过程入口且默认收起,流式也不自动展开;收起时过程文字与工具行不占高度,最终回复始终可读。展开按原顺序呈现,只有相邻工具合并,状态与证据可继续查看。纯空白思考、过程说明不产生空行,没有思考和工具的直接回答不产生入口;新用户消息、已记录的轮次结束分别切断分组,工具或思考结束而没有最终回复时不伪造回复。搜索过程文字及工具输入、输出、文件路径,匹配组临时展开且不能在搜索期间收起,清空后恢复手动选择;切换和关闭会话验证偏好隔离与释放。深浅主题、窄宽面板、历史恢复和实时流均验证;历史导出、文件审阅、权限卡片、轮次统计和回复复制继续可用。单元测试与组件回放不替代真实 Agent 和 Windows Tauri 运行验收,未执行部分保持 pending。", + "notes": "2026-10-24:改为每轮一个默认折叠的过程区,包含思考、工具和中间说明;只改变呈现,不改写历史或 ACP 协议。ACP 无最终回复角色,按最后一条思考或工具的位置保留末尾正文;后续活动到达时才将先前正文移入过程。" } diff --git a/shared/platform-feature-matrix/features/agent-skill-management.json b/shared/platform-feature-matrix/features/agent-skill-management.json new file mode 100644 index 0000000000..63d9f42587 --- /dev/null +++ b/shared/platform-feature-matrix/features/agent-skill-management.json @@ -0,0 +1,37 @@ +{ + "id": "agent-skill-management", + "area": "AI", + "group": "Agent 对话", + "capability": "技能设置:全局与项目分类、Agent 来源筛选、搜索刷新、查看与定位、原生目录新建和导入、原生配置启停开关(点击立即切换、后台确认、失败回滚、不显示加载转圈、保存提示不推动列表、仅待确认行禁用、其他行保持颜色并按序保存);操作菜单保留所属窗口焦点和开启开关的颜色;技能目录最终发布在 macOS/Linux/Windows 均原子拒绝已有目标(包含空目录)", + "macos": { + "evidence": [ + "macos/Sources/Lithe/Views/Agent/AgentSkillsSettingsView.swift", + "macos/Sources/Lithe/Views/Agent/AgentSkillSheets.swift", + "macos/Sources/Lithe/Application/Features/AgentSkillsFeatureModel.swift", + "rust/lithe-agent-host/src/skills.rs", + "rust/lithe-agent-host/src/skills/files.rs", + "macos/Tests/LitheTests/AgentSkillsFeatureModelTests.swift", + "rust/lithe-agent-host/src/skills/tests.rs", + "shared/fixtures/agent/skills-management-v1.json", + "rust/lithe-agent-host/src/skills/settings.rs", + "macos/Tests/LitheTests/LitheActionMenuFocusTests.swift", + "macos/Sources/Lithe/Core/Rust/RustAgentManagementService.swift", + "macos/Tests/LitheTests/AgentSkillsCancellationTests.swift" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "windows": { + "evidence": [ + "rust/lithe-core/src/agent/mod.rs", + "rust/lithe-agent-host/src/skills.rs", + "shared/fixtures/agent/skills-management-v1.json", + "rust/lithe-agent-host/src/skills/files.rs", + "rust/lithe-agent-host/src/skills/tests.rs" + ], + "implementationStatus": "partial", + "verificationStatus": "pending" + }, + "owner": "Agent", + "verification": "macOS 在设置 → AI → 技能查看全局/项目两类、数量和来源标签;切换已打开项目和来源筛选,查询名称与说明;Codex 使用官方发现结果,Claude 清楚区分目录清单、本地配置可见性与会话实际可用性,内置/插件来源保留。检查深浅主题、500pt 与宽页面、菜单锚点、长名称和空态。打开详情查看完整 SKILL.md 与路径,复制路径及 Finder 定位;新建和导入弹窗显示适用 Agent、范围和目标目录,导入保留脚本、模板及权限。同名、非法名称、错误 YAML、符号链接、超限、取消或读取失败不覆盖已有技能、不留下半成品;失败保留旧列表,切项目拒绝旧结果。无项目时可管理全局,项目新增不可用;无需对话和 API Key,保存不发消息、不打断任务。Windows 目前仅共享 Core 与 Host 操作,页面及真实进程/文件系统验收待实现。 验证启停点击立即显示用户选择且不出现行内加载转圈,确认前不报告已保存;在深浅主题、500pt 与 760pt 页面中,对比首次切换前、关闭保存中及确认后、再次开启保存中及确认后的原生开关位置,成功提示始终预留空间且隐藏时不进入辅助功能树,列表不因提示显示或隐藏而跳动;写入失败恢复已确认值并显示错误、待确认行禁止重复提交,其他行保持原生开启颜色并可独立点击排队;控制同一 Agent 的两次原生写入按序确认,不丢失已保存的状态,单行失败回滚且后续队列继续,失败提示不被其他成功覆盖;切项目或退出时未开始的写入不再执行、切项目及退出拒绝迟到结果;Codex 仅对发现路径调用 skills/config/write 并读取确认,项目路径配置影响所有引用该路径的项目;Claude 全局修改用户 settings.json,项目修改 settings.local.json,按名称生效并保留其他字段/文件权限;无效配置、未知状态、符号链接、超限、锁冲突和取消不覆盖既有配置。不自动断开已有对话。 打开技能三个点纯操作菜单,所属设置窗口保持 key 身份、开启开关保持颜色且不发配置写入;方向键/Return/Esc 可用,真正保存期间仍禁用。搜索下拉继续获得输入焦点,嵌套子动作菜单优先接收按键;所属窗口失焦或应用切换关闭菜单并移除监听。 Windows 原生 CI 回归 publication_cannot_replace_an_existing_empty_directory,覆盖暂存后目标出现、普通文件与非空目录字节保留、Unicode/空格目标完整发布,以及 NUL 路径拒绝。Windows 使用 MoveFileExW 且不启用覆盖或跨卷复制,不以 std::fs::rename 的平台行为推断排他性;文件系统用例通过不代表设置页或会话已验收。 AgentSkillsCancellationTests 通过真实桥接验证创建、导入和启停在服务入口前取消时不改变技能包或原生配置;编码时取消经 requestStarted 注册握手补传,文件分派前停止且后续请求可成功。" +} diff --git a/shared/platform-feature-matrix/features/agent-subagent-monitoring.json b/shared/platform-feature-matrix/features/agent-subagent-monitoring.json index e2a12d15aa..2d7e10cdad 100644 --- a/shared/platform-feature-matrix/features/agent-subagent-monitoring.json +++ b/shared/platform-feature-matrix/features/agent-subagent-monitoring.json @@ -21,11 +21,18 @@ }, "windows": { "evidence": [ - "windows/tauri/src/features" + "rust/lithe-agent-host/src/lib.rs", + "windows/tauri/src/features/agent/services/agent-transport.ts", + "windows/tauri/src/features/agent/stores/agent-connection.model.ts", + "windows/tauri/src/features/agent/components/agent-transcript.tsx", + "windows/tauri/src/features/agent/types/agent-observations.ts", + "windows/tauri/src/features/agent/components/agent-observed-statistics.tsx", + "windows/tauri/src/features/agent/stores/agent-parity.test.ts" ], - "implementationStatus": "missing", - "verificationStatus": "not-applicable" + "implementationStatus": "implemented", + "verificationStatus": "pending" }, "owner": "Agent", - "verification": "macOS:使用支持 AIR nativeSubagentSessions 的 Claude/Codex 在隔离项目中创建真实子代理,确认名称、任务、嵌套关系、运行/完成/失败/取消/断开状态与完成数/总数;展开详情查看实际输出和工具,状态刷新保持弹层。普通工具不产生子代理;子输出不拼入主回复、历史预览或复制。切换会话隔离,历史恢复同一子代理,停止主任务收尾子代理授权等待;父代理结束后未报告结果的子代理显示已断开。运行 AgentConversationFeatureModelTests 的 nativeSubagents/nativeChild 回归、AgentActivityPresentationTests.subagentsExpandDeliveredOutputAndRefreshWithoutDismissing 和 Host 的 native_subagents_keep_child_permissions_owned_by_the_root_turn。受控协议与原生 UI 测试不能替代真实供应商工作流,完成该实测前保持 pending。Windows 尚未实现 Agent 对话 UI。 macOS 窄宽面板:子代理入口在任务摘要右侧且与任务独立点击,无计划时仍可单独出现;数量为真实子会话总数,已完成/总数保留在可访问值与详情。子代理为空时不显示占位;保留深浅主题、展开输出、状态更新和弹层关闭验证。 终态授权清理:根 prompt 保持未结束,依次创建子代理、后代与同级代理并挂起授权;对子代理发送 completed/failed/cancelled/disconnected,确认只将其自身及后代请求回应 cancelled,根和同级请求仍可答复;终态后迟到请求不再入队。Swift 同步移除对应卡片、保留其他队列项,最后一项终止后注意状态解除、等待计时停止;虚拟时钟推进后 Host 静默提醒恢复,根轮次正常完成。" + "verification": "macOS:使用支持 AIR nativeSubagentSessions 的 Claude/Codex 在隔离项目中创建真实子代理,确认名称、任务、嵌套关系、运行/完成/失败/取消/断开状态与完成数/总数;展开详情查看实际输出和工具,状态刷新保持弹层。普通工具不产生子代理;子输出不拼入主回复、历史预览或复制。切换会话隔离,历史恢复同一子代理,停止主任务收尾子代理授权等待;父代理结束后未报告结果的子代理显示已断开。运行 AgentConversationFeatureModelTests 的 nativeSubagents/nativeChild 回归、AgentActivityPresentationTests.subagentsExpandDeliveredOutputAndRefreshWithoutDismissing 和 Host 的 native_subagents_keep_child_permissions_owned_by_the_root_turn。受控协议与原生 UI 测试不能替代真实供应商工作流,完成该实测前保持 pending。Windows launch 已启用 observeSubagents,连接模型与 transcript 已呈现子代理层级、状态、输出和工具。状态机回归覆盖后代清理和权限归属;本次真实 Tauri 验收使用受控 ACP fixture,真实 Claude/Codex 子代理仍待完成。 macOS 窄宽面板:子代理入口在任务摘要右侧且与任务独立点击,无计划时仍可单独出现;数量为真实子会话总数,已完成/总数保留在可访问值与详情。子代理为空时不显示占位;保留深浅主题、展开输出、状态更新和弹层关闭验证。 终态授权清理:根 prompt 保持未结束,依次创建子代理、后代与同级代理并挂起授权;对子代理发送 completed/failed/cancelled/disconnected,确认只将其自身及后代请求回应 cancelled,根和同级请求仍可答复;终态后迟到请求不再入队。Swift 同步移除对应卡片、保留其他队列项,最后一项终止后注意状态解除、等待计时停止;虚拟时钟推进后 Host 静默提醒恢复,根轮次正常完成。 Windows 回归:累计创建并结束 70 个子 Agent 后,在本轮或下一轮新增子 Agent,其授权仍进入主会话;同时运行 70 个子 Agent 时全部保留授权所有者,终止后只保留最近 64 条终态展示记录。", + "notes": "2026-10-22:Windows 已消费原生子代理事件并呈现层级、任务、状态、输出与工具;父子授权归属和终态清理由共享 Host 负责。真实供应商子代理流程仍待验收。 Windows 回归:累计创建并结束 70 个子 Agent 后,在本轮或下一轮新增子 Agent,其授权仍进入主会话;同时运行 70 个子 Agent 时全部保留授权所有者,终止后只保留最近 64 条终态展示记录。" } diff --git a/shared/platform-feature-matrix/features/agent-subscription-multi-account.json b/shared/platform-feature-matrix/features/agent-subscription-multi-account.json new file mode 100644 index 0000000000..1c08f76939 --- /dev/null +++ b/shared/platform-feature-matrix/features/agent-subscription-multi-account.json @@ -0,0 +1,33 @@ +{ + "id": "agent-subscription-multi-account", + "area": "AI", + "group": "Agent 对话", + "capability": "Claude 与 Codex 官方订阅支持多个账号:每个账号是官方 CLI 的独立配置目录(CLAUDE_CONFIG_DIR / CODEX_HOME),登录在后台运行官方登录命令并由 CLI 打开浏览器,手动设为首选并重启连接;账号卡片显示邮箱、套餐和最近一次推送的 5 小时/7 天用量,会话收藏与重命名按账号分开,不读取、复制凭据,也不在额度耗尽时自动换号", + "macos": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "macos/Sources/Lithe/Models/Settings/AgentAccounts.swift", + "macos/Sources/Lithe/Models/AppModel/AppModel+AgentAccounts.swift", + "macos/Sources/Lithe/Views/Agent/AgentAccountsSection.swift", + "macos/Sources/Lithe/Platform/MacOS/Agent/MacAgentAccountSignIn.swift", + "macos/Sources/Lithe/Application/Features/AgentLaunchConfigurationResolver.swift", + "macos/Sources/LitheAgentConversationModule/Application/AgentHistoryFeatureModel.swift", + "macos/Tests/LitheTests/AgentAccountTests.swift", + "rust/lithe-agent-host/src/claude_subscription.rs", + "rust/lithe-agent-host/src/subscription.rs", + ".agents/notes/implemented/feature/2026-10-24-agent-subscription-multi-account.md" + ] + }, + "windows": { + "implementationStatus": "partial", + "verificationStatus": "pending", + "evidence": [ + "rust/lithe-agent-host/src/claude_subscription.rs", + "rust/lithe-agent-host/src/subscription.rs", + "rust/lithe-agent-host/src/lib.rs" + ] + }, + "owner": "Agent", + "verification": "macOS:启用 Claude 或 Codex 官方订阅后,在设置 > AI > Agent 管理对应 Agent 的「订阅账号」中点击「添加另一个账号」,不弹出终端,卡片显示「正在等待浏览器登录…」并可取消,浏览器由官方 CLI 打开并完成授权;分别用带该账号目录的 claude auth status / codex login status 与默认账号确认互不覆盖登录(macOS 钥匙串隔离需实机确认)。将新账号设为首选,当前项目该 Agent 的连接重启,卡片显示该账号邮箱与套餐,发送消息后显示 5 小时/7 天用量、重置时间、配速和「截至 …,本次未主动刷新」(Codex 的用量由已有额度查询刷新,使用同一账号目录);正在回复或等待授权时设为首选被拒绝;两个账号下的会话收藏和重命名互不影响;Codex 已归档历史按账号目录列出;移除账号不删除其目录;额度耗尽时不会自动换号;关闭设置页会取消进行中的登录。Windows:共享 Host 已支持 claudeConfigDir 与 codexHome 启动参数,设置界面和登录入口尚未提供,待接入并真机验收。" +} diff --git a/shared/platform-feature-matrix/features/agent-tool-activity-timeline.json b/shared/platform-feature-matrix/features/agent-tool-activity-timeline.json index a139bb9271..200fe9c760 100644 --- a/shared/platform-feature-matrix/features/agent-tool-activity-timeline.json +++ b/shared/platform-feature-matrix/features/agent-tool-activity-timeline.json @@ -2,12 +2,18 @@ "id": "agent-tool-activity-timeline", "area": "AI", "group": "Agent 对话", - "capability": "连续 Agent 工具调用(执行命令、列文件、读文件与编辑等)合并为默认折叠的时间线卡片,汇总总数与失败/中断/完成进度,并逐条展开输入、输出与文件位置", + "capability": "每轮工具调用与思考、过程说明放进同一个默认折叠的过程区;展开后相邻工具保留有序时间线、状态、输入输出和文件位置,最终回复单独展示;工具读取或截取的图片以缩略图显示在工具行和工具组标题旁(最多三张、其余 +N),悬停预览、点击放大,用户消息附带的图片同样可放大;Claude Code 后台任务通知:成功的并入随后一轮的折叠过程区,失败的与无后续过程的保持可见;恢复 Claude 会话时压缩前的完整记录也会显示(macOS)", "macos": { "evidence": [ "macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift", "macos/Sources/Lithe/Views/Agent/AgentToolGroupView.swift", - "macos/Tests/LitheTests/AgentConversationPresentationTests.swift" + "macos/Tests/LitheTests/AgentConversationPresentationTests.swift", + "macos/Sources/Lithe/Views/Agent/AgentProcessGroupView.swift", + "macos/Tests/LitheTests/AgentThoughtTranscriptTests.swift", + "macos/Sources/LitheAgentConversationModule/Application/AgentImage.swift", + "macos/Sources/Lithe/Views/Agent/AgentImageViews.swift", + "macos/Tests/LitheTests/AgentImageTests.swift", + "rust/lithe-agent-host/src/claude_earlier_history.rs" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -17,11 +23,17 @@ "windows/tauri/src/features", "windows/tauri/src/features/agent/components/agent-transcript.tsx", "windows/tauri/src/features/agent/services/agent-transcript-items.test.ts", - "windows/tauri/src/features/agent/services/agent-transcript-items.ts" + "windows/tauri/src/features/agent/services/agent-transcript-items.ts", + "windows/tauri/src/features/agent/components/agent-edit-review.tsx", + "windows/tauri/src/features/agent/services/agent-edit-review.test.ts", + "windows/tauri/crates/project/src/document_file.rs", + "scripts/test-windows-agent-recycle.ps1", + "windows/tauri/src/features/agent/components/agent-transcript.process.test.tsx" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Agent", - "verification": "macOS:让真实 Agent 连续列文件、读取文件、执行命令和编辑,确认相邻工具调用只显示一张默认折叠的时间线卡片;展开后每条状态、完整输入、输出和文件位置仍可查看,进行中、全部完成、失败和中断时汇总正确。用 Agent 文字说明和新一轮用户消息隔开工具调用,确认保留文字顺序且不会跨边界合并;搜索工具输入、输出或文件路径时自动展开匹配组;覆盖文件路径仅由 diff 内容上报、未出现在工具标题、输入输出或 locations 中的情况,历史恢复结果与实时流一致。在窄宽面板及深浅主题检查长标题截断、悬停全文、列表滚动和详情可读性。Tasks/Running/Edits 使用共享工具栏按钮的 hover、按下和选中状态,普通标签字体、16pt 图标、2pt 图文间距和4pt水平留白;无独立外框或竖分隔,左右与输入框对齐,原生鼠标验证三项弹框及文件操作仍可用。 Windows 相邻工具调用按 macOS 合并为可折叠的“工具操作(N)”组,头部显示失败/中断/全部完成/已完成 n/N 摘要,失败组红框,行内状态点与证据展开(输入、内容、结果、文件位置),列表最多 4 行高;对话下方常驻任务/运行中/编辑统计条,搜索命中时自动展开;实机验证待完成。Tasks/Running/Edits 使用共享工具栏按钮的 hover、按下和选中状态,普通标签字体、16pt 图标、2pt 图文间距和4pt水平留白;无独立外框或竖分隔,左右与输入框对齐,原生鼠标验证三项弹框及文件操作仍可用。" + "verification": "macOS 与 Windows:构造思考→过程说明→读文件→思考→执行命令→最终回复,确认每轮只有一个过程入口且默认收起,流式也不自动展开;收起时过程文字与工具行不占高度,最终回复始终可读。展开按原顺序呈现,只有相邻工具合并,状态与证据可继续查看。纯空白思考、过程说明不产生空行,没有思考和工具的直接回答不产生入口;新用户消息、已记录的轮次结束分别切断分组,工具或思考结束而没有最终回复时不伪造回复。搜索过程文字及工具输入、输出、文件路径,匹配组临时展开且不能在搜索期间收起,清空后恢复手动选择;切换和关闭会话验证偏好隔离与释放。深浅主题、窄宽面板、历史恢复和实时流均验证;历史导出、文件审阅、权限卡片、轮次统计和回复复制继续可用。单元测试与组件回放不替代真实 Agent 和 Windows Tauri 运行验收,未执行部分保持 pending。 macOS 图片:让 Agent 读取多张图片,确认缩略图最多三张并显示 +N,悬停出现带标题和序号的预览,点击进入放大查看并可用方向键切换、Esc 关闭;用户粘贴图片后气泡显示缩略图且可放大;触发后台任务后不再出现原始 task-notification 标记。Windows 对应能力尚未实现。 macOS 后台任务与压缩历史:让 Agent 连续启动多个后台命令,确认成功的通知不再单独成行,而是在对应一轮的“思考过程”展开后以中文状态行出现;失败的通知仍在对话中可见。恢复一个经历过多次压缩的 Claude 会话,确认压缩分界线之前的用户消息、回复、思考和工具调用都能滚动查看,且每次压缩处有“对话已从摘要继续”标记。 点击缩略图后的大图支持双指缩放、拖动、百分比与 ⌘+/⌘−/⌘0;用户消息里的 file:// 链接与 [Image: source:] 占位符显示为缩略图;Claude Code 的斜杠命令、本地命令输出、Shell 转义、压缩续接摘要、技能展开和宿主上下文显示为状态行或折叠块,只给模型看的提醒不显示。 压缩前的用户消息若带粘贴截图,恢复会话后截图仍以缩略图显示在原消息气泡里(不是只剩 [Image #N] 文字),可点击放大。", + "notes": "2026-10-24:改为每轮一个默认折叠的过程区,包含思考、工具和中间说明;只改变呈现,不改写历史或 ACP 协议。ACP 无最终回复角色,按最后一条思考或工具的位置保留末尾正文;后续活动到达时才将先前正文移入过程。 2026-10-24:macOS 增加工具图片缩略图、悬停预览、放大查看器和后台任务通知状态行(#1462),Windows 待对齐。 2026-10-24(续):折叠的思考过程标题行也显示图片缩略图;放大查看器增加缩放;识别更多 Claude Code 特殊消息格式。 2026-10-24(再续):成功的后台任务通知并入后续过程区;Rust 宿主在 session/load 时只读取最后一个压缩点之前的会话记录,补全 Claude 适配器不回放的历史。 2026-10-25:压缩前历史由 Rust 宿主读取会话记录补全,之前丢弃用户消息里的内联截图,现在作为同一条用户消息的 image 块回放(仅 base64 来源);Windows 待对齐。" } diff --git a/shared/platform-feature-matrix/features/agent-turn-statistics.json b/shared/platform-feature-matrix/features/agent-turn-statistics.json index 6090be9851..3d7f1d32c9 100644 --- a/shared/platform-feature-matrix/features/agent-turn-statistics.json +++ b/shared/platform-feature-matrix/features/agent-turn-statistics.json @@ -27,12 +27,14 @@ "windows/tauri/src/features/agent/services/agent-transcript-items.ts", "windows/tauri/src/features/agent/stores/agent-connection.model.ts", "windows/tauri/src/features/agent/stores/agent-turn-statistics.model.test.ts", - "windows/tauri/src/features/agent/types/agent-turn-statistics.ts" + "windows/tauri/src/features/agent/types/agent-turn-statistics.ts", + "windows/tauri/src/features/agent/types/agent-session-statistics.ts", + "windows/tauri/src/platform/agent-statistics-storage.ts" ], - "implementationStatus": "partial", + "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Agent", - "verification": "macOS:在真实 Agent 会话中确认发送后计时包含建会话、加载历史、工具和权限等待;正常完成、取消确认、请求失败和断连后停止计时,下一轮仍可看到上一轮统计。并发会话切换验证耗时与用量隔离;每轮下方只显示复制、用量胶囊与完成时间,复制按钮复制该轮最终回复;悬停胶囊显示耗时、输入/输出、缓存、推理、工具次数、模型调用、平均首字延迟和输出速度,缺失 usage 时胶囊只显示耗时;跨天的轮次显示完整日期。历史回放不编造逐轮耗时;完成几轮后断开或重启,重新加载该会话应显示之前累计的轮次、工具、Token 与 TTFT,新一轮在其基础上增加,重复加载不叠加;上下文占用与订阅额度不作为输入/输出 token。检查深浅主题和窄宽面板。Windows 共享 Host 已透传可选 usage;每轮回复下方已显示复制回复、用量胶囊(耗时 · token)与完成时间(当天 HH:mm,否则 yyyy-MM-dd HH:mm),悬停胶囊显示耗时、输入/输出、推理、缓存读写与总量,缺失 usage 时只显示耗时,失败/取消/断连仍保留耗时。首字延迟、输出速度、工具与模型调用统计,以及跨连接保存的会话合计仍未接入。", - "notes": "2026-10-18 Windows:`AgentTurnStatistics.completedAt` 记录墙钟完成时间,`groupTranscript` 把该轮最后一条 Agent 正文写入 footer 供复制;胶囊使用 macOS 的 compactTokens(25K/1.2M)格式。会话合计与模型调用统计留待第 14 期,矩阵保持 partial。" + "verification": "macOS:在真实 Agent 会话中确认发送后计时包含建会话、加载历史、工具和权限等待;正常完成、取消确认、请求失败和断连后停止计时,下一轮仍可看到上一轮统计。并发会话切换验证耗时与用量隔离;每轮下方只显示复制、用量胶囊与完成时间,复制按钮复制该轮最终回复;悬停胶囊显示耗时、输入/输出、缓存、推理、工具次数、模型调用、平均首字延迟和输出速度,缺失 usage 时胶囊只显示耗时;跨天的轮次显示完整日期。历史回放不编造逐轮耗时;完成几轮后断开或重启,重新加载该会话应显示之前累计的轮次、工具、Token 与 TTFT,新一轮在其基础上增加,重复加载不叠加;上下文占用与订阅额度不作为输入/输出 token。检查深浅主题和窄宽面板。Windows 共享 Host 已透传可选 usage;每轮回复下方已显示复制回复、用量胶囊(耗时 · token)与完成时间(当天 HH:mm,否则 yyyy-MM-dd HH:mm),悬停胶囊显示耗时、输入/输出、推理、缓存读写与总量,缺失 usage 时只显示耗时,失败/取消/断连仍保留耗时。首字延迟、输出速度、工具与模型调用统计及跨连接会话累计已接入 Windows;详见模型调用统计行。 Windows:在真实 Tauri 窗口执行 Windows 记录完成轮次、去重工具调用、工具与权限重叠区间的并集耗时,并保存每会话累计统计。历史重放不重复计费,最新 ACP 用量不跨轮累加;断连冻结时间。统计缓存按工作区/Agent/会话隔离,最多 200 条,不保存正文或凭据。 单元测试不替代运行时验收,未执行的真实服务及平台动作保持 pending。", + "notes": "2026-10-22:Windows 记录完成轮次、去重工具调用、工具与权限重叠区间的并集耗时,并保存每会话累计统计。历史重放不重复计费,最新 ACP 用量不跨轮累加;断连冻结时间。统计缓存按工作区/Agent/会话隔离,最多 200 条,不保存正文或凭据。" } diff --git a/shared/platform-feature-matrix/features/application-settings.json b/shared/platform-feature-matrix/features/application-settings.json index b7ff5694f9..a6ac5ddddc 100644 --- a/shared/platform-feature-matrix/features/application-settings.json +++ b/shared/platform-feature-matrix/features/application-settings.json @@ -69,7 +69,7 @@ ".agents/notes/implemented/feature/2026-10-08-windows-settings-hierarchy.md", "windows/tauri/src/extensions/ui/components/extensions-browser-layout.tsx", "windows/tauri/src/i18n/locale.ts", - "windows/tauri/src/i18n/ai-commit.ts", + "shared/i18n/messages.json", "windows/tauri/src/features/settings/lib/settings-effects.ts", "windows/tauri/src/features/settings/lib/theme-resolution.ts", "windows/tauri/src/features/settings/lib/settings-theme-sync.test.ts", @@ -83,6 +83,6 @@ "verificationStatus": "pending" }, "owner": "Settings", - "notes": "Git 标签继承回显使用 Core 通过 Git 布尔解析器归一化的 pruneTags 值,识别 yes/on/1 及 false 等等价写法;远程设置覆盖全局默认,原始编辑值保持不变。 Windows 当前设置面板的 Editor → Display 提供显示缩略图开关,复用 showMinimap 和 Monaco 实时选项更新。无已保存 showMinimap 配置时默认关闭;已有用户保存的 true 或 false 保留,恢复默认设置也会关闭。macOS 默认值不变。 Windows字体目录使用DirectWrite提供系统及用户字体的规范名、本地化标签与等宽信息;编辑器可选择非等宽字体,目录失败不替换已保存选择,字号6–72与连字设置即时持久化。 Windows设置页通过SettingsSelect统一固定与可搜索下拉框的触发密度和菜单样式,包含运行配置、Git与AI提交;文本、多行文本与复选框复用现有共享控件,空字符串自动配置保持选中态。 macOS设置页把纯解释性说明收进标签后的 ⓘ 提示:默认只显示设置标签和图标,指针停留半秒后显示说明,点击图标可固定,键盘聚焦后用 Space/Return 打开;错误、警告、保存结果、空态和动态状态仍常驻显示。分组级说明复用 LitheSettingsSection 的 accessory,行级说明复用 LitheSettingsLabel 与 LitheInfoTip。 macOS设置行采用标签在左、控件在右的两列布局,分组标题到内容间距 8 pt、分组上方 10 pt、页面级间距 10 pt;编辑器字体页以当前字体的示例代码实时预览,切换字体后旧字体保留约 2.5 秒与新字体上下对比再淡出,减少动态效果开启时取消动画;插件声明的设置摘要同样收进 ⓘ。 macOS Git 行为配置行优先把设置名、当前生效值、下拉框和重置/应用按钮放在同一行,下拉框按该设置最长选项文案在 150–240 pt 内取宽;窗口过窄时当前生效值移到第二行,按钮保持完整。 macOS 项目 JDK 与 Maven 的生效运行时使用卡片展示模式、来源、名称和可选择复制的路径,警告/错误沿用同一卡片形状;Git 身份输入框回显当前生效值,保存按钮仅在草稿变化后启用;Maven 的 settings.xml 与 Local Repository 未覆盖时以实际有效路径作为占位文本,没有时显示 Not found;Agent 预检把版本、路径和安装来源拆成结构化小表,PASS/WARN/FAIL 使用带图标胶囊;MCP 页改为状态卡、项目权限和 Agent 配置分区。 LitheInfoTip 的悬停计时器不再触发页面重新求值,弹层锚点只在说明打开时安装。 项目JDK与Maven路径候选菜单复用SettingsPathInput,保留任意路径直接输入的草稿。 Windows设置对齐macOS分类树、双语检索、面包屑和标题分组线;侧栏默认234px,视口不超过768px时最小180px,否则最小234px;正文最小500px,拖动结束持久化布局。插件页复用既有插件管理且即时操作;普通设置即时保存;底部左侧恢复默认、右侧单个关闭,关闭不回滚已保存修改,项目及运行配置继续显式保存。Windows的AI服务商配置继续由既有AI与AI提交页面负责。 插件设置使用独立可调整的列表/详情分栏,列表最小200px、详情最小160px,窄设置窗口保留详情;原扩展管理入口保留网格布局。 Windows共用设置的中英文文案按macOS设置源码与中文本地化统一,覆盖通用、字体、Code Vision、标签布局、Tab宽度、Git、AI术语、搜索空态与相关命令入口;项目附加和Windows凭据说明保留平台实际语义,底部仍为单个关闭。 Windows跟随系统通过Tauri set_theme(None)解除原生窗口的手动主题覆盖,保留Windows主题事件向WebView2传递系统颜色偏好;复用既有媒体查询同步应用与编辑器,手动模式清理监听。通用页显示实际生效的主题,并按主题元数据判断明暗。 Windows设置检索仅收录当前实际页面字段,始终支持中英文标签;排除旧索引中的不可见控件。插件设置挂载保留全局搜索焦点,独立插件入口继续自动聚焦。Windows设置组件采用settings-panels中性命名;分层、主题、下拉、更新和插件焦点回归已接入Windows CI独立测试进程。 Windows路径输入在候选关闭时按Esc保留已编辑路径,手动清空仍可切回自动配置;搜索无结果时隐藏当前页面并保留未保存草稿,清空搜索恢复显示。 Windows路径候选回归使用非空候选和真实Combobox,覆盖方向键/Enter选择、未列出路径、弹层打开和关闭时Esc保留草稿、再次编辑与清空;候选行使用稳定路径key。 macOS设置侧栏收束为外观与行为(通用、更新、编辑器、快捷键)、插件、版本控制、构建/执行/部署、语言与框架、工具(终端、诊断)、AI(MCP配置、AI与提交、Agents、AI服务商)七个一级入口,分组关系由SettingsGroup单一来源提供;Windows设置侧栏已同步为同样的七个一级入口:外观与行为(通用、更新、编辑器、快捷键)、插件、版本控制(Git)、构建/执行/部署(项目环境、运行配置、Maven)、语言与框架(LSP)、工具(终端、日志)、AI(MCP配置、AI与提交、AI聊天与编辑);分组由settingsGroups单一来源提供,Windows没有独立AI服务商页和Agents页,不新增空页面,页面内容、存储键和保存时机不变。Windows 设置对话框保留入场模糊淡入,但通过 motion 的 transitionEnd 在入场结束后把插值终值覆盖成 filter: none(overlayEntranceSelfClearing,由 clearEntranceFilter 选择),避免弹窗因为常驻 filter 一直留在滤镜合成层;该层会关闭子像素抗锯齿并按显示缩放比栅格化,让整个弹窗(标题栏、分类栏、正文和底部按钮)一起发虚。该行为只对设置对话框启用,其它弹窗保持原样。 macOS Git 执行页在覆盖为空时回显实际解析到的 Git 路径与版本,并用 `git.executable` 在不打开仓库的情况下完成解析;覆盖路径不可运行时显示警告。Git 行为配置中“Lithe: 获取标签”为继承时,会按 `remote..tagOpt`、`fetch.pruneTags` 与 Lithe 的 prune 选择解析成具体标签传输行为(全部、不获取、同步删除、获取可达提交的标签,或多个远程不一致),不再只显示“遵循 Git 标签规则”。 macOS说明弹层的延迟创建锚点进入窗口后会重试显示,修复悬停或点击仅变蓝而无说明的问题;离开窗口仍取消待显示任务并关闭弹层。", - "verification": "运行 ./scripts/test-macos.sh --filter GitSettingsPresentationTests,并以 cargo test --manifest-path rust/Cargo.toml -p lithe-core git::configuration:: 验证合法布尔写法、无值与空值、远程覆盖、无效值保留和编辑原值不变。 macOS 共享下拉选择器闭合框遵循 Islands:深色底 #26282C、浅色白底,1pt 普通边框、2pt 展开或键盘聚焦描边,28pt 高及 4pt 圆角;验证设置页与 Commit 的深浅外观、键盘聚焦及选择功能。Windows 无已保存缩略图配置时确认代码编辑器右侧不显示缩略图,设置 → Editor → Display 的显示缩略图开关为关闭;切换开关确认所有打开的编辑器即时更新,关闭设置重开及应用重启后选择保持,已有 showMinimap=true/false 的设置均保留,恢复默认后关闭;中英文显示对应文案。修改应用级和项目级设置,验证持久化、迁移、重启恢复和无效值提示;macOS 保持设置窗口打开切换亮色、暗色和跟随系统模式,并改变系统外观,确认标题栏、设置树、插件列表与详情背景和主界面侧边栏使用同一主题色,窗口按钮可用且可拖动;缩小设置窗口确认最小尺寸 900×700,底部操作按钮为 72×28 且边框与输入框一样以 1pt 内描边绘制;拖动设置树与插件列表右侧分隔线,确认设置分类栏不能小于原始宽度、插件列表分隔线可向左缩至 200 pt 或向右加宽,在 900 pt 最小宽度下仍有至少 150 pt 可分配的拖动余量,窗口缩小时设置内容区保留至少 500 pt、插件详情区至少 160 pt,拖动结束后宽度可恢复,悬停及拖动分隔线仅显示左右调整光标而不改变线条颜色;点击设置搜索框后切换分类及点击内容空白区域,确认焦点边框消失;悬停插件条目及设置窗口各子页面的按钮和选择控件时光标保持箭头;从主窗口打开设置后切换到其他应用,确认设置窗口随主窗口置后且不置顶遮挡;在插件页点击 Enable 后点击设置窗口 OK,确认成功时应用并关闭、失败时保留待确认状态且可重试;应用期间确认窗口关闭按钮和常规关闭命令不能关闭窗口,应用结束后恢复关闭能力;A 项目开始应用插件后从 B 项目重新打开共享设置窗口,确认 A 完成后 B 窗口仍打开且未应用草稿保留;待确认时点击 Cancel 或窗口关闭按钮,确认重开后草稿已清除;亮色模式确认分段选择的选中文字清晰可读;检查搜索框、普通及密码输入框、多行编辑框(含获得与失去焦点的边框)、数值步进器、下拉框和分段选择在各设置子页面中的背景、边框、焦点态及操作行为一致,关闭后重开再检查;macOS 切换通用、编辑器、快捷键、项目、运行配置和语言服务等设置页,检查右侧路径标题、分组线、行对齐、说明文字、下拉框、复选框、分段控件与底部操作区;打开短列表和超过十项的下拉框,检查当前项蓝色高亮、无勾号、圆角、紧凑行距、鼠标悬停不跳动、键盘选择与按需滚动;再次点击原下拉按钮应收起弹层,点击另一处下拉框应切换弹层,点击空白处应关闭弹层;缩小可用屏幕高度时检查弹层不遮挡控件;使用辅助功能检查勾选框和分段控件的当前状态。 Windows 编辑器字号输入数字居中,输入区到独立步进按钮间隔 8px,标准按钮命中范围 32×32px;手动输入、增减、上下限、只读和禁用正确。检查行高、缩进等共用数值控件的布局,并在深浅色设置页验收。 从实际 Windows 设置窗口进入 Editor → Display,确认 Font size 使用共享数值控件,增减与直接输入写入 editor.fontSize,6–72 上下限生效;检查此真实页面的居中、按钮大小及深浅色,避免只验证旧设置页或孤立组件。 步进按钮横排在输入框右侧,左侧增大、右侧减小,各保留 32×32px 点击区域和 4px 间距,输入框与按钮组垂直居中;键盘 Tab 顺序与左右顺序一致。 Windows验证中文与英文目录检索、非等宽字体、目录加载中及失败时的已存选择、共享字号步进控件边界、连字预览以及所有打开编辑器的即时更新。 Windows逐一打开所有设置分类及运行配置编辑器,检查深浅主题的下拉按钮、菜单圆角、边框、选中项与禁用态一致;验证字体和模型搜索、选项键盘选择、Esc仅关闭菜单、点击外部关闭、长列表滚动、标签可访问名称、切换到空字符串自动配置和关闭重开后的持久化;AI提交数值草稿仅在真实失焦或Enter失焦后保存,Maven路径保留显式应用。 项目路径候选菜单检查深浅色、候选选择、任意路径输入后直接点击保存和清空恢复自动配置,确认输入草稿不会被候选列表替换。 Windows逐一访问外观与行为、版本控制、构建/执行/部署、语言与框架、工具、AI六个分组及插件一级页面;中文字段与英文关键词搜索应定位正确页面并展开父组,无结果显示空态,Esc清空查询,外部重复请求同一页清空查询并重新定位。确认第一组标题、水平线、左对齐行、说明和底部左侧恢复默认、右侧只有关闭;中英文分别显示关闭和 Close,点击关闭后即时保存的字号等仍保留,未显式保存的项目草稿丢弃。拖动侧栏时不得连续写入布局,松开后保存,关闭重开或刷新后恢复;720px、768px、769px及800px宽窗口内容区至少500px且底部按钮可用;视口不超过768px时分类栏最小180px,超过断点恢复234px,已保存的宽布局缩至720px仍不溢出。插件管理保留既有安装和启用行为,在真实WebView2及工作区验证。 插件设置在800px宽窗口仍显示详情;拖动插件列表分隔栏检查200px/160px最小宽度、松开前不写入、松开后保存,以及切换页面和重开恢复布局。 Windows逐页切换中英文,核对通用/General、字体/Font、编辑器标签栏/Editor tabs、多行展开/Wrap into rows、Tab宽度/Tab width、Git搁置与Git Stash、AI服务商与API密钥或Token;搜索设置和搜索操作或快捷键的提示及空态与macOS一致,相关命令入口使用通用和Keymap。核对替换前后的本地化键集合和变量占位符一致;关闭行为与原有保存时机保持正确。 Windows当前系统为深色时,先手动选择浅色再切回跟随系统,确认窗口、设置页与编辑器立即恢复深色,配色主题显示实际主题;反向和系统明暗变化也应同步。关闭重开以及重启保持跟随系统;手动模式保持指定外观,重复切换不增加监听。主题元数据为浅色但名称不含light时,模式仍正确显示浅色。 Windows分别在中文和英文界面检索字体连字、显示引用数量和Git作者、LSP自动补全及语义高亮,确认进入正确页面;Sticky Scroll、UI Font Size等当前未提供字段应无结果。在全局搜索持续输入插件关键词时确认焦点不离开搜索框,打开独立扩展管理入口确认插件搜索自动聚焦;Git Fetch配置的prune=true和false均应显示文本值。 Windows在项目设置输入未列出的JDK或Maven路径,候选关闭时按Esc确认路径保留,继续编辑和手动清空仍可用;编辑项目路径或运行参数但不保存,搜索无匹配词后清空查询,确认当前页面草稿仍保留且空态期间控件不可见。 Windows非空路径候选自动化回归:运行settings-path-input.test.tsx,确认方向键只高亮不改草稿、Enter输出所选完整路径、未列出路径可直接输入、打开和关闭弹层时Esc均不清空草稿、关闭后可继续筛选和恢复空字符串自动配置。自动化结果不替代真实WebView2主题切换与连续拖动验收,实机验证仍待完成。 Windows项目及Maven输入未保存路径后搜索Git,再清空查询并切回原页,确认草稿保留且未自动保存;手动切换分类和重复外部导航亦保留。关闭设置后重开,草稿清除且仅挂载当前页,所有已访问页面的订阅在关闭时释放。 Windows打开设置确认一级入口依次为外观与行为、插件、版本控制、构建/执行/部署、语言与框架、工具、AI;快捷键和编辑器在外观与行为下,MCP配置、AI与提交、AI聊天与编辑在AI下;从命令面板或其他入口跳转到这些页面、搜索命中时所属分组自动展开且面包屑与侧栏一致,中英文均显示AI;自动化仅为浏览器组件测试,真实WebView2验收待完成。 macOS打开设置确认一级入口依次为外观与行为、插件、版本控制、构建/执行/部署、语言与框架、工具、AI;快捷键和编辑器在外观与行为下,MCP配置、AI与提交、AI服务商在AI下;从任意入口跳转到这些页面时所属分组自动展开且面包屑与侧栏一致,搜索命中时展开对应分组。 macOS依次打开通用、Git、Agents 设置页,确认配色主题、外观、背景、语言、项目打开方式、隐藏路径、Git 执行与凭据、Fetch 默认值与配置项、Agent 开关与统计、Agent 说明和供应商说明默认只显示标签和 ⓘ,不再常驻小字;悬停 ⓘ 约半秒显示完整说明,移开或点击外部关闭,点击图标可固定,键盘聚焦后 Space/Return 可打开;错误、警告和 “No remembered choices” 等状态文案仍直接可见。macOS 在 Git 执行页确认 Git 可执行文件为空时仍回显实际路径和版本,输入一个不存在的绝对路径后显示未找到可运行 Git 的警告,点击使用 PATH 中的 Git 后恢复回显;在 Git 行为配置中把“Lithe: 获取标签”设为继承,确认生效值显示具体标签行为(默认获取可达提交的标签),修改某远程的 `remote..tagOpt` 或 `fetch.pruneTags` 后重新加载,确认回显变为不获取、全部获取或同步删除;给远程设置 `skipFetchAll=true` 后确认所有远程模式排除该远程,显式选择该远程时仍显示其标签行为。macOS 在 Git 行为与配置中确认每个选项的标题、当前生效值、下拉框、重置和应用按钮在同一行,短选项的下拉框不再保留大片空白;缩窄设置窗口后当前生效值换到第二行且按钮不被裁切。打开编辑器页切换字体,确认预览使用所选字体和字号,旧字体与新字体短暂并列后淡出;打开插件详情的设置区,确认说明收进 ⓘ 且数值、下拉和复选框仍可编辑。 打开 Git 身份页确认未覆盖时输入框回显当前生效值且保存禁用,编辑后保存、清除覆盖后回到继承值。打开项目 JDK 与 Maven 页确认生效运行时为卡片且路径可复制、长路径可悬停查看全文,settings.xml 和 Local Repository 未覆盖时占位显示有效路径或 Not found。打开 Agents 详情展开预检项,确认版本/路径/安装来源为独立行且长路径中段省略。打开 MCP 配置确认状态卡与配置代码块在启用后出现,关闭后按钮回到 Enable。 Windows 在 125% 与 150% 显示缩放下打开设置对话框,依次切换设置分类(覆盖内容不滚动与需要滚动的页面),确认标题栏、分类栏、正文与底部按钮整体清晰、不出现整层发虚,并在开发者工具中确认弹窗静止态的计算 filter 为 none(入场期间仍应看到模糊淡入)。自动化与静态检查不替代该实机验收。 运行 ContextMenuCoverageTests.newlyInsertedOpenDropdownPresentsAfterAnchorAttaches,覆盖深浅主题下首次更新尚未进入窗口、随后挂载自动显示及移除关闭;设置页实机悬停与点击固定行为另行验收。 macOS 共享 litheSettingsTextEditor 支持传入单一 FocusState 绑定,所有设置多行框使用统一 1pt 普通、2pt 聚焦且外扩 1pt 的控件描边,外框不改变布局尺寸;检查常规隐藏路径、终端环境变量、MCP 和运行配置多行框的聚焦/失焦,未要求自动聚焦的现有页面保留原行为。 设置窗口重新绑定到同一或另一 session 后,OK、Cancel 与标题栏关闭仍能关闭当前窗口;旧 binding 不得关闭新窗口(LitheCoreLogicTests)。" + "notes": "Git 标签继承回显使用 Core 通过 Git 布尔解析器归一化的 pruneTags 值,识别 yes/on/1 及 false 等等价写法;远程设置覆盖全局默认,原始编辑值保持不变。 Windows 当前设置面板的 Editor → Display 提供显示缩略图开关,复用 showMinimap 和 Monaco 实时选项更新。无已保存 showMinimap 配置时默认关闭;已有用户保存的 true 或 false 保留,恢复默认设置也会关闭。macOS 默认值不变。 Windows字体目录使用DirectWrite提供系统及用户字体的规范名、本地化标签与等宽信息;编辑器可选择非等宽字体,目录失败不替换已保存选择,字号6–72与连字设置即时持久化。 Windows设置页通过SettingsSelect统一固定与可搜索下拉框的触发密度和菜单样式,包含运行配置、Git与AI提交;文本、多行文本与复选框复用现有共享控件,空字符串自动配置保持选中态。 macOS设置页把纯解释性说明收进标签后的 ⓘ 提示:默认只显示设置标签和图标,指针停留半秒后显示说明,点击图标可固定,键盘聚焦后用 Space/Return 打开;错误、警告、保存结果、空态和动态状态仍常驻显示。分组级说明复用 LitheSettingsSection 的 accessory,行级说明复用 LitheSettingsLabel 与 LitheInfoTip。 macOS设置行采用标签在左、控件在右的两列布局,分组标题到内容间距 8 pt、分组上方 10 pt、页面级间距 10 pt;编辑器字体页以当前字体的示例代码实时预览,切换字体后旧字体保留约 2.5 秒与新字体上下对比再淡出,减少动态效果开启时取消动画;插件声明的设置摘要同样收进 ⓘ。 macOS Git 行为配置行优先把设置名、当前生效值、下拉框和重置/应用按钮放在同一行,下拉框按该设置最长选项文案在 150–240 pt 内取宽;窗口过窄时当前生效值移到第二行,按钮保持完整。 macOS 项目 JDK 与 Maven 的生效运行时使用卡片展示模式、来源、名称和可选择复制的路径,警告/错误沿用同一卡片形状;Git 身份输入框回显当前生效值,保存按钮仅在草稿变化后启用;Maven 的 settings.xml 与 Local Repository 未覆盖时以实际有效路径作为占位文本,没有时显示 Not found;Agent 预检把版本、路径和安装来源拆成结构化小表,PASS/WARN/FAIL 使用带图标胶囊;MCP 页改为状态卡、项目权限和 Agent 配置分区。 LitheInfoTip 的悬停计时器不再触发页面重新求值,弹层锚点只在说明打开时安装。 项目JDK与Maven路径候选菜单复用SettingsPathInput,保留任意路径直接输入的草稿。 Windows设置对齐macOS分类树、双语检索、面包屑和标题分组线;侧栏默认234px,视口不超过768px时最小180px,否则最小234px;正文最小500px,拖动结束持久化布局。插件页复用既有插件管理且即时操作;普通设置即时保存;底部左侧恢复默认、右侧单个关闭,关闭不回滚已保存修改,项目及运行配置继续显式保存。Windows的AI服务商配置继续由既有AI与AI提交页面负责。 插件设置使用独立可调整的列表/详情分栏,列表最小200px、详情最小160px,窄设置窗口保留详情;原扩展管理入口保留网格布局。 Windows共用设置的中英文文案按macOS设置源码与中文本地化统一,覆盖通用、字体、Code Vision、标签布局、Tab宽度、Git、AI术语、搜索空态与相关命令入口;项目附加和Windows凭据说明保留平台实际语义,底部仍为单个关闭。 Windows跟随系统通过Tauri set_theme(None)解除原生窗口的手动主题覆盖,保留Windows主题事件向WebView2传递系统颜色偏好;复用既有媒体查询同步应用与编辑器,手动模式清理监听。通用页显示实际生效的主题,并按主题元数据判断明暗。 Windows设置检索仅收录当前实际页面字段,始终支持中英文标签;排除旧索引中的不可见控件。插件设置挂载保留全局搜索焦点,独立插件入口继续自动聚焦。Windows设置组件采用settings-panels中性命名;分层、主题、下拉、更新和插件焦点回归已接入Windows CI独立测试进程。 Windows路径输入在候选关闭时按Esc保留已编辑路径,手动清空仍可切回自动配置;搜索无结果时隐藏当前页面并保留未保存草稿,清空搜索恢复显示。 Windows路径候选回归使用非空候选和真实Combobox,覆盖方向键/Enter选择、未列出路径、弹层打开和关闭时Esc保留草稿、再次编辑与清空;候选行使用稳定路径key。 macOS设置侧栏收束为外观与行为(通用、更新、编辑器、快捷键)、插件、版本控制、构建/执行/部署、语言与框架、工具(终端、诊断)、AI(AI服务商、Agent管理、指令、技能、MCP配置、AI与提交,依此排序)七个一级入口,分组关系由SettingsGroup单一来源提供;Windows设置侧栏已同步为同样的七个一级入口:外观与行为(通用、更新、编辑器、快捷键)、插件、版本控制(Git)、构建/执行/部署(项目环境、运行配置、Maven)、语言与框架(LSP)、工具(终端、日志)、AI(MCP配置、AI与提交、AI聊天与编辑);分组由settingsGroups单一来源提供,Windows没有独立AI服务商页和Agents页,不新增空页面,页面内容、存储键和保存时机不变。Windows 设置对话框保留入场模糊淡入,但通过 motion 的 transitionEnd 在入场结束后把插值终值覆盖成 filter: none(overlayEntranceSelfClearing,由 clearEntranceFilter 选择),避免弹窗因为常驻 filter 一直留在滤镜合成层;该层会关闭子像素抗锯齿并按显示缩放比栅格化,让整个弹窗(标题栏、分类栏、正文和底部按钮)一起发虚。该行为只对设置对话框启用,其它弹窗保持原样。 macOS Git 执行页在覆盖为空时回显实际解析到的 Git 路径与版本,并用 `git.executable` 在不打开仓库的情况下完成解析;覆盖路径不可运行时显示警告。Git 行为配置中“Lithe: 获取标签”为继承时,会按 `remote..tagOpt`、`fetch.pruneTags` 与 Lithe 的 prune 选择解析成具体标签传输行为(全部、不获取、同步删除、获取可达提交的标签,或多个远程不一致),不再只显示“遵循 Git 标签规则”。 macOS说明弹层的延迟创建锚点进入窗口后会重试显示,修复悬停或点击仅变蓝而无说明的问题;离开窗口仍取消待显示任务并关闭弹层。 macOS 隐藏路径合并为一个模式列表(原“目录”与“文件模式”两个框),应用按钮在右下,内置规则以只读胶囊回显、始终生效不可移除;语法是不区分大小写的通配符(正则的简化写法),说明与示例收进 ⓘ。文件夹必须用显式模式隐藏:`name/`、`name/*`、`name/**` 都表示从项目根起的文件夹规则,文件夹连同内容一起隐藏并在列目录时剪枝、不读取其内容;开头的 `**/` 代表零层或多层目录,因此 `**/name/` 同时覆盖根目录与任意层级的副本;纯名称和带通配符的文件 glob 仍只匹配文件(规则由 shared/fixtures/search/exclusions.json 固定)。旧版保存的两个列表在升级时合并:自定义目录名变为 `**/名称/`,文件模式原样保留。macOS 把工作区根下的 `.lithe` 作为唯一特例:被规则隐藏后不在项目树、搜索和列目录中显示,但文件监听仍送达其事件,编辑其中的 JSON 仍会重载项目服务(内置噪声如 `.lithe/run/local.json` 仍丢弃)。Windows 隐藏路径沿用既有 gitignore 语义,不在此次范围。", + "verification": "运行 ./scripts/test-macos.sh --filter GitSettingsPresentationTests,并以 cargo test --manifest-path rust/Cargo.toml -p lithe-core git::configuration:: 验证合法布尔写法、无值与空值、远程覆盖、无效值保留和编辑原值不变。 macOS 共享下拉选择器闭合框遵循 Islands:深色底 #26282C、浅色白底,1pt 普通边框、2pt 展开或键盘聚焦描边,28pt 高及 4pt 圆角;验证设置页与 Commit 的深浅外观、键盘聚焦及选择功能。Windows 无已保存缩略图配置时确认代码编辑器右侧不显示缩略图,设置 → Editor → Display 的显示缩略图开关为关闭;切换开关确认所有打开的编辑器即时更新,关闭设置重开及应用重启后选择保持,已有 showMinimap=true/false 的设置均保留,恢复默认后关闭;中英文显示对应文案。修改应用级和项目级设置,验证持久化、迁移、重启恢复和无效值提示;macOS 保持设置窗口打开切换亮色、暗色和跟随系统模式,并改变系统外观,确认标题栏、设置树、插件列表与详情背景和主界面侧边栏使用同一主题色,窗口按钮可用且可拖动;缩小设置窗口确认最小尺寸 900×700,底部操作按钮为 72×28 且边框与输入框一样以 1pt 内描边绘制;拖动设置树与插件列表右侧分隔线,确认设置分类栏不能小于原始宽度、插件列表分隔线可向左缩至 200 pt 或向右加宽,在 900 pt 最小宽度下仍有至少 150 pt 可分配的拖动余量,窗口缩小时设置内容区保留至少 500 pt、插件详情区至少 160 pt,拖动结束后宽度可恢复,悬停及拖动分隔线仅显示左右调整光标而不改变线条颜色;点击设置搜索框后切换分类及点击内容空白区域,确认焦点边框消失;悬停插件条目及设置窗口各子页面的按钮和选择控件时光标保持箭头;从主窗口打开设置后切换到其他应用,确认设置窗口随主窗口置后且不置顶遮挡;在插件页点击 Enable 后点击设置窗口 OK,确认成功时应用并关闭、失败时保留待确认状态且可重试;应用期间确认窗口关闭按钮和常规关闭命令不能关闭窗口,应用结束后恢复关闭能力;A 项目开始应用插件后从 B 项目重新打开共享设置窗口,确认 A 完成后 B 窗口仍打开且未应用草稿保留;待确认时点击 Cancel 或窗口关闭按钮,确认重开后草稿已清除;亮色模式确认分段选择的选中文字清晰可读;检查搜索框、普通及密码输入框、多行编辑框(含获得与失去焦点的边框)、数值步进器、下拉框和分段选择在各设置子页面中的背景、边框、焦点态及操作行为一致,关闭后重开再检查;macOS 切换通用、编辑器、快捷键、项目、运行配置和语言服务等设置页,检查右侧路径标题、分组线、行对齐、说明文字、下拉框、复选框、分段控件与底部操作区;打开短列表和超过十项的下拉框,检查当前项蓝色高亮、无勾号、圆角、紧凑行距、鼠标悬停不跳动、键盘选择与按需滚动;再次点击原下拉按钮应收起弹层,点击另一处下拉框应切换弹层,点击空白处应关闭弹层;缩小可用屏幕高度时检查弹层不遮挡控件;使用辅助功能检查勾选框和分段控件的当前状态。 Windows 编辑器字号输入数字居中,输入区到独立步进按钮间隔 8px,标准按钮命中范围 32×32px;手动输入、增减、上下限、只读和禁用正确。检查行高、缩进等共用数值控件的布局,并在深浅色设置页验收。 从实际 Windows 设置窗口进入 Editor → Display,确认 Font size 使用共享数值控件,增减与直接输入写入 editor.fontSize,6–72 上下限生效;检查此真实页面的居中、按钮大小及深浅色,避免只验证旧设置页或孤立组件。 步进按钮横排在输入框右侧,左侧增大、右侧减小,各保留 32×32px 点击区域和 4px 间距,输入框与按钮组垂直居中;键盘 Tab 顺序与左右顺序一致。 Windows验证中文与英文目录检索、非等宽字体、目录加载中及失败时的已存选择、共享字号步进控件边界、连字预览以及所有打开编辑器的即时更新。 Windows逐一打开所有设置分类及运行配置编辑器,检查深浅主题的下拉按钮、菜单圆角、边框、选中项与禁用态一致;验证字体和模型搜索、选项键盘选择、Esc仅关闭菜单、点击外部关闭、长列表滚动、标签可访问名称、切换到空字符串自动配置和关闭重开后的持久化;AI提交数值草稿仅在真实失焦或Enter失焦后保存,Maven路径保留显式应用。 项目路径候选菜单检查深浅色、候选选择、任意路径输入后直接点击保存和清空恢复自动配置,确认输入草稿不会被候选列表替换。 Windows逐一访问外观与行为、版本控制、构建/执行/部署、语言与框架、工具、AI六个分组及插件一级页面;中文字段与英文关键词搜索应定位正确页面并展开父组,无结果显示空态,Esc清空查询,外部重复请求同一页清空查询并重新定位。确认第一组标题、水平线、左对齐行、说明和底部左侧恢复默认、右侧只有关闭;中英文分别显示关闭和 Close,点击关闭后即时保存的字号等仍保留,未显式保存的项目草稿丢弃。拖动侧栏时不得连续写入布局,松开后保存,关闭重开或刷新后恢复;720px、768px、769px及800px宽窗口内容区至少500px且底部按钮可用;视口不超过768px时分类栏最小180px,超过断点恢复234px,已保存的宽布局缩至720px仍不溢出。插件管理保留既有安装和启用行为,在真实WebView2及工作区验证。 插件设置在800px宽窗口仍显示详情;拖动插件列表分隔栏检查200px/160px最小宽度、松开前不写入、松开后保存,以及切换页面和重开恢复布局。 Windows逐页切换中英文,核对通用/General、字体/Font、编辑器标签栏/Editor tabs、多行展开/Wrap into rows、Tab宽度/Tab width、Git搁置与Git Stash、AI服务商与API密钥或Token;搜索设置和搜索操作或快捷键的提示及空态与macOS一致,相关命令入口使用通用和Keymap。核对替换前后的本地化键集合和变量占位符一致;关闭行为与原有保存时机保持正确。 Windows当前系统为深色时,先手动选择浅色再切回跟随系统,确认窗口、设置页与编辑器立即恢复深色,配色主题显示实际主题;反向和系统明暗变化也应同步。关闭重开以及重启保持跟随系统;手动模式保持指定外观,重复切换不增加监听。主题元数据为浅色但名称不含light时,模式仍正确显示浅色。 Windows分别在中文和英文界面检索字体连字、显示引用数量和Git作者、LSP自动补全及语义高亮,确认进入正确页面;Sticky Scroll、UI Font Size等当前未提供字段应无结果。在全局搜索持续输入插件关键词时确认焦点不离开搜索框,打开独立扩展管理入口确认插件搜索自动聚焦;Git Fetch配置的prune=true和false均应显示文本值。 Windows在项目设置输入未列出的JDK或Maven路径,候选关闭时按Esc确认路径保留,继续编辑和手动清空仍可用;编辑项目路径或运行参数但不保存,搜索无匹配词后清空查询,确认当前页面草稿仍保留且空态期间控件不可见。 Windows非空路径候选自动化回归:运行settings-path-input.test.tsx,确认方向键只高亮不改草稿、Enter输出所选完整路径、未列出路径可直接输入、打开和关闭弹层时Esc均不清空草稿、关闭后可继续筛选和恢复空字符串自动配置。自动化结果不替代真实WebView2主题切换与连续拖动验收,实机验证仍待完成。 Windows项目及Maven输入未保存路径后搜索Git,再清空查询并切回原页,确认草稿保留且未自动保存;手动切换分类和重复外部导航亦保留。关闭设置后重开,草稿清除且仅挂载当前页,所有已访问页面的订阅在关闭时释放。 Windows打开设置确认一级入口依次为外观与行为、插件、版本控制、构建/执行/部署、语言与框架、工具、AI;快捷键和编辑器在外观与行为下,MCP配置、AI与提交、AI聊天与编辑在AI下;从命令面板或其他入口跳转到这些页面、搜索命中时所属分组自动展开且面包屑与侧栏一致,中英文均显示AI;自动化仅为浏览器组件测试,真实WebView2验收待完成。 macOS打开设置确认一级入口依次为外观与行为、插件、版本控制、构建/执行/部署、语言与框架、工具、AI;快捷键和编辑器在外观与行为下,AI分组内依次为AI服务商、Agent管理、指令、技能、MCP配置、AI与提交;搜索过滤后保留这一显示顺序;从任意入口跳转到这些页面时所属分组自动展开且面包屑与侧栏一致,搜索命中时展开对应分组。 macOS依次打开通用、Git、Agents 设置页,确认配色主题、外观、背景、语言、项目打开方式、隐藏路径、Git 执行与凭据、Fetch 默认值与配置项、Agent 开关与统计、Agent 说明和供应商说明默认只显示标签和 ⓘ,不再常驻小字;悬停 ⓘ 约半秒显示完整说明,移开或点击外部关闭,点击图标可固定,键盘聚焦后 Space/Return 可打开;错误、警告和 “No remembered choices” 等状态文案仍直接可见。macOS 在 Git 执行页确认 Git 可执行文件为空时仍回显实际路径和版本,输入一个不存在的绝对路径后显示未找到可运行 Git 的警告,点击使用 PATH 中的 Git 后恢复回显;在 Git 行为配置中把“Lithe: 获取标签”设为继承,确认生效值显示具体标签行为(默认获取可达提交的标签),修改某远程的 `remote..tagOpt` 或 `fetch.pruneTags` 后重新加载,确认回显变为不获取、全部获取或同步删除;给远程设置 `skipFetchAll=true` 后确认所有远程模式排除该远程,显式选择该远程时仍显示其标签行为。macOS 在 Git 行为与配置中确认每个选项的标题、当前生效值、下拉框、重置和应用按钮在同一行,短选项的下拉框不再保留大片空白;缩窄设置窗口后当前生效值换到第二行且按钮不被裁切。打开编辑器页切换字体,确认预览使用所选字体和字号,旧字体与新字体短暂并列后淡出;打开插件详情的设置区,确认说明收进 ⓘ 且数值、下拉和复选框仍可编辑。 打开 Git 身份页确认未覆盖时输入框回显当前生效值且保存禁用,编辑后保存、清除覆盖后回到继承值。打开项目 JDK 与 Maven 页确认生效运行时为卡片且路径可复制、长路径可悬停查看全文,settings.xml 和 Local Repository 未覆盖时占位显示有效路径或 Not found。打开 Agents 详情展开预检项,确认版本/路径/安装来源为独立行且长路径中段省略。打开 MCP 配置确认状态卡与配置代码块在启用后出现,关闭后按钮回到 Enable。 Windows 在 125% 与 150% 显示缩放下打开设置对话框,依次切换设置分类(覆盖内容不滚动与需要滚动的页面),确认标题栏、分类栏、正文与底部按钮整体清晰、不出现整层发虚,并在开发者工具中确认弹窗静止态的计算 filter 为 none(入场期间仍应看到模糊淡入)。自动化与静态检查不替代该实机验收。 运行 ContextMenuCoverageTests.newlyInsertedOpenDropdownPresentsAfterAnchorAttaches,覆盖深浅主题下首次更新尚未进入窗口、随后挂载自动显示及移除关闭;设置页实机悬停与点击固定行为另行验收。 macOS 共享 litheSettingsTextEditor 支持传入单一 FocusState 绑定,所有设置多行框使用统一 1pt 普通、2pt 聚焦且外扩 1pt 的控件描边,外框不改变布局尺寸;检查常规隐藏路径、终端环境变量、MCP 和运行配置多行框的聚焦/失焦,未要求自动聚焦的现有页面保留原行为。 设置窗口重新绑定到同一或另一 session 后,OK、Cancel 与标题栏关闭仍能关闭当前窗口;旧 binding 不得关闭新窗口(LitheCoreLogicTests)。 macOS 隐藏路径只有一个模式输入框,下方一行回显内置规则(`**/node_modules/` 等前三项加“+N”胶囊,悬停显示全部),应用按钮在右下;悬停 ⓘ 确认说明写明通配符是正则的简化写法、不区分大小写,并给出 `**/dist/`、`.lithe/*`、`*.log`、`docs/*.md` 四个示例。在框里输入 `.lithe/*` 后点击应用,确认项目树不再显示 `.lithe` 文件夹(只写 `.lithe` 不隐藏文件夹,写 `.lithe/`、`.lithe/*` 或 `.lithe/**` 才隐藏),`.lithe-notes.md` 仍可见;输入 `**/tmp/` 后根目录与任意层级的 `tmp` 文件夹都消失。从旧版本升级时,旧“目录”中的自定义名称迁移成 `**/名称/`、旧“文件模式”条目原样保留,内置项不会复制进输入框。运行 cargo test -p lithe-core folder_patterns_hide_folders_without_reading_them 与 ./scripts/test-macos.sh --filter 'folderPatternsHideWholeFoldersFromTheProjectRoot|hiddenPathsMigrateTheTwoLegacyListsIntoOnePatternList|builtInHiddenPatternsShowTheEffectiveRulesInPatternSyntax',并用 directoryExclusionsFollowTheSharedPruningContract 复核共享 fixture。 隐藏 `.lithe` 后在其中修改一个 JSON 配置(如运行配置),确认项目服务仍然重载;自动化为 hiddenProjectConfigurationFolderStaysWatchedButOtherHiddenPathsAreDropped。" } diff --git a/shared/platform-feature-matrix/features/build-provider-refresh-and-tool.json b/shared/platform-feature-matrix/features/build-provider-refresh-and-tool.json index b37d7040b1..4896e19501 100644 --- a/shared/platform-feature-matrix/features/build-provider-refresh-and-tool.json +++ b/shared/platform-feature-matrix/features/build-provider-refresh-and-tool.json @@ -15,8 +15,7 @@ "macos/Sources/Lithe/Application/Features/PluginWebBridge.swift", "macos/Tests/LitheExecutionModuleTests/BuildSystemServiceTests.swift", "macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift", - "macos/Tests/LitheTests/PluginWebBridgeTests.swift", - "sdk/node/src/index.d.ts" + "macos/Tests/LitheTests/PluginWebBridgeTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" diff --git a/shared/platform-feature-matrix/features/build-system-plugins.json b/shared/platform-feature-matrix/features/build-system-plugins.json index 801252cc65..b2bc106b4f 100644 --- a/shared/platform-feature-matrix/features/build-system-plugins.json +++ b/shared/platform-feature-matrix/features/build-system-plugins.json @@ -2,7 +2,7 @@ "id": "build-system-plugins", "area": "扩展", "group": "插件与主题", - "capability": "构建系统插件(API 3 buildSystems):插件用 JSON 声明构建工具的识别、可执行文件、选项、任务、运行入口、测试和输出格式,或由插件自带、与语言服务器同类的 Provider 进程回答模型和依赖,Rust Core 统一校验、生成命令行或 Provider 请求并解码结果,宿主为每个构建系统打开一个按声明(icon、presentation、category)布局的工具窗口", + "capability": "构建系统插件(API 3 buildSystems):插件用 JSON 声明构建工具的识别、可执行文件、选项、任务、运行入口、测试和输出格式,或由插件自带、与语言服务器同类的 Provider 进程回答模型和依赖,Rust Core 统一校验、生成命令行或 Provider 请求并解码结果,宿主为每个构建系统打开一个按声明(icon、presentation、category)布局的工具窗口;通过共享 executableResolution 候选解析支持可选设置路径、优先级与相对排序,空值自动发现、显式失败不回退", "macos": { "evidence": [ "rust/lithe-core/src/build", @@ -23,7 +23,12 @@ "rust/lithe-core/src/lsp/interface/build_provider.rs", "macos/Sources/Lithe/Platform/MacOS/Build/MacBuildProviderSessions.swift", "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginLanguageServerLaunchResolver.swift", - "macos/Tests/LitheTests/PluginMarketplaceServiceTests.swift" + "macos/Tests/LitheTests/PluginMarketplaceServiceTests.swift", + "shared/contracts/plugins/v1/fixtures/build-tool-path.plugin.json", + "rust/lithe-core/src/plugins/executables.rs", + "macos/Sources/LitheCoreContracts/Execution/ExecutableResolution.swift", + "macos/Tests/LitheTests/BuildToolProviderResolutionTests.swift", + "macos/Tests/LitheTests/MacBuildToolResolverTests.swift" ], "implementationStatus": "partial", "verificationStatus": "pending" @@ -32,11 +37,11 @@ "evidence": [ "rust/lithe-core/src/build", "shared/contracts/plugins/v1/fixtures/build-plans.json", - "shared/contracts/plugins/v1/fixtures/gradle-provider.plugin.json" + "shared/contracts/plugins/v1/fixtures/build-provider.plugin.json" ], "implementationStatus": "missing", "verificationStatus": "not-applicable" }, "owner": "Extensions", - "verification": "macOS 已接入 static、command、hostIntegration、provider 四种模型来源:宿主向 Core 声明 buildSystems 与 buildProviders 能力,插件市场因此列出兼容的 API 3 声明式和 Provider 版本;Provider 进程按插件、构建系统和工作区各自独立,经各自 stdio 用 JSON-RPC(LSP 基础协议)与宿主通信,initialize 协商 capabilities.experimental.litheBuild 后宿主才经 lsp.buildRequest 发送 lithe/build/model、lithe/build/dependencies,回复交给 Core 解码;共享写法只复用同一插件的语言服务器会话;进程只在用户加载模型或依赖时启动,切换工作区、关闭项目窗口、停止执行模块、停用或更新插件、退出应用时停止;ExecutionFeatureGraph.stop 经 BuildSystemService.stop 同时回收已就绪和初始化中的 Provider,停止后的初始化完成也不得重新登记会话;状态写入插件状态目录;每个已启用的构建系统插件在打开工作区后各有一个右侧工具窗口(标题为 displayName、图标为 icon),不依赖识别结果,工作区没有对应工程时窗口内显示未检测到该构建系统,工具栏放运行、参数任务、刷新和 presentation=toolbar 的开关,树顶部是 presentation=tree 的勾选选项,工程下按 category(缺省按 group)分级列出任务,再列测试、目标和依赖;内置 Maven 窗口与共享 Build 窗口已删除,Build Output 显示输出、诊断与测试结果。窗口恢复先激活所属插件模块,激活中的命令面板项禁用;参数任务遵守 workspace/project/target 作用范围与目标标签,模型或工作区变化后清除旧选择。隔离回归覆盖这些边界、窗口布局恢复及包内图标读取前后文件清单与字节不变;多实例的根目录选择器保留每个实例的模型候选和生效值,编辑多选时保留兄弟实例独有的已选值;显式清空按实例根持久化为空覆盖,检测、同步指纹、任务计划和语言服务器导入使用同一覆盖,兄弟编辑或重开不会恢复非空默认值,非空编辑清当前实例标记而共享编辑/重置清该选项全部标记;真实 Core、生产存储与服务回归核对最终 argv 和导入值;列表单选与多选支持 allowCustom 原生输入和移除已选自定义值,文本/文件/目录清空提交显式空字符串,只有重置删除覆盖。插件选项在项目打开、选项修改和模型加载完成后通过 Core build.languageImport 归一化为 mavenContext/javaGradle,与构建计划使用同一生效值,并在 Java 启动前等待同一工作区代次的最新配置;运行时准备后再等一次,覆盖等待期间失效。唯一发布回调优先 Maven 原地热更新,被拒绝时串行恢复重启;停用最后一个 Maven 插件时,仅清除曾带 Maven 输入的同一会话,纯 Gradle 首次启动保持 nil。Gradle 在下次启动生效,取消/重置拒绝旧检测与恢复任务,被动工具路径发现不执行 Wrapper/版本探测;没有对应插件导入输入时兼容原 Maven 设置。模型 run 入口与 lithe/build/execute 尚未接入,Provider 无操作系统级沙盒;Windows 宿主尚未调用 build.* 命令。自动化:cargo test -p lithe-core --lib build_systems、cargo test -p lithe-core --lib lsp、scripts/verify-shared-contracts.sh、./scripts/test-macos.sh --filter 'BuildSystemToolWindowTests|RustBuildSystemOperationsTests|BuildSystemServiceTests|PluginMarketplaceServiceTests'。手动验证:安装 fixtures 中 cargo-build、go-build 形态的插件包,确认每个构建系统各出现一个工具窗口,列出工程、分级任务、选项和依赖,停用插件后窗口消失;执行任务与测试的 argv 与 build-plans.json 一致,停止与输出上限生效;JUnit 报告与 lineMatcher 诊断可跳转到源码;命令模型和项目内 Wrapper 只在用户点击加载或运行后启动;禁用插件后实例消失;Windows 接入后另需验证未信任工作区不启动任何模型命令或 Wrapper;gradle-provider 形态的自有 Provider 只在用户操作或工作区信任后启动、停用插件时停止,mix-provider 形态复用语言服务器会话而不启动第二个进程;运行前后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。" + "verification": "macOS 已接入 static、command、hostIntegration、provider 四种模型来源:宿主向 Core 声明 buildSystems 与 buildProviders 能力,插件市场因此列出兼容的 API 3 声明式和 Provider 版本;Provider 进程按插件、构建系统和工作区各自独立,经各自 stdio 用 JSON-RPC(LSP 基础协议)与宿主通信,initialize 协商 capabilities.experimental.litheBuild 后宿主才经 lsp.buildRequest 发送 lithe/build/model、lithe/build/dependencies,回复交给 Core 解码;共享写法只复用同一插件的语言服务器会话;进程只在用户加载模型或依赖时启动,切换工作区、关闭项目窗口、停止执行模块、停用或更新插件、退出应用时停止;ExecutionFeatureGraph.stop 经 BuildSystemService.stop 同时回收已就绪和初始化中的 Provider,停止后的初始化完成也不得重新登记会话;状态写入插件状态目录;每个已启用的构建系统插件在打开工作区后各有一个右侧工具窗口(标题为 displayName、图标为 icon),不依赖识别结果,工作区没有对应工程时窗口内显示未检测到该构建系统,工具栏放运行、参数任务、刷新和 presentation=toolbar 的开关,树顶部是 presentation=tree 的勾选选项,工程下按 category(缺省按 group)分级列出任务,再列测试、目标和依赖;内置 Maven 窗口与共享 Build 窗口已删除,Build Output 显示输出、诊断与测试结果。窗口恢复先激活所属插件模块,激活中的命令面板项禁用;参数任务遵守 workspace/project/target 作用范围与目标标签,模型或工作区变化后清除旧选择。隔离回归覆盖这些边界、窗口布局恢复及包内图标读取前后文件清单与字节不变;多实例的根目录选择器保留每个实例的模型候选和生效值,编辑多选时保留兄弟实例独有的已选值;显式清空按实例根持久化为空覆盖,检测、同步指纹、任务计划和语言服务器导入使用同一覆盖,兄弟编辑或重开不会恢复非空默认值,非空编辑清当前实例标记而共享编辑/重置清该选项全部标记;真实 Core、生产存储与服务回归核对最终 argv 和导入值;列表单选与多选支持 allowCustom 原生输入和移除已选自定义值,文本/文件/目录清空提交显式空字符串,只有重置删除覆盖。插件选项在项目打开、选项修改和模型加载完成后通过 Core build.languageImport 归一化为 mavenContext/javaGradle,与构建计划使用同一生效值,并在 Java 启动前等待同一工作区代次的最新配置;运行时准备后再等一次,覆盖等待期间失效。唯一发布回调优先 Maven 原地热更新,被拒绝时串行恢复重启;停用最后一个 Maven 插件时,仅清除曾带 Maven 输入的同一会话,纯 Gradle 首次启动保持 nil。Gradle 在下次启动生效,取消/重置拒绝旧检测与恢复任务,被动工具路径发现不执行 Wrapper/版本探测;没有对应插件导入输入时兼容原 Maven 设置。模型 run 入口与 lithe/build/execute 尚未接入,Provider 无操作系统级沙盒;Windows 宿主尚未调用 build.* 命令。自动化:cargo test -p lithe-core --lib build_systems、cargo test -p lithe-core --lib lsp、scripts/verify-shared-contracts.sh、./scripts/test-macos.sh --filter 'BuildSystemToolWindowTests|RustBuildSystemOperationsTests|BuildSystemServiceTests|PluginMarketplaceServiceTests'。手动验证:安装 fixtures 中 build-static-file、build-command-stdout 形态的插件包,确认每个构建系统各出现一个工具窗口,列出工程、分级任务、选项和依赖,停用插件后窗口消失;执行任务与测试的 argv 与 build-plans.json 一致,停止与输出上限生效;JUnit 报告与 lineMatcher 诊断可跳转到源码;命令模型和项目内 Wrapper 只在用户点击加载或运行后启动;禁用插件后实例消失;Windows 接入后另需验证未信任工作区不启动任何模型命令或 Wrapper;build-provider 形态的自有 Provider 只在用户操作或工作区信任后启动、停用插件时停止,build-provider-language-server 形态复用语言服务器会话而不启动第二个进程;运行前后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。;新路径选择使用 executableResolution 1 + settings 2,已发布 buildSystems 2 旧字段只作统一候选输入适配:MacBuildToolResolverTests 验证带空格绝对路径、权限与版本失败不回退,BuildSystemServiceTests 验证 Provider 失败不独立发现,RustBuildSystemOperationsTests 验证全局值接入。Cargo 独立仓库以显式本地 Core bridge 验证自选路径用于 metadata 和全部构建/测试/运行/清理;发布前需把独立仓库 sdk.lock.json 的宿主 pin 更新到包含该能力的已发布提交。;回归增加非法存储值不回默认、数组/priority/插入顺序与拒绝循环、宿主注入发现、空设置跳过和被动选择不探测;Language Server/Execution Provider 新清单语法尚未开放,Windows 不声明 executableResolution。;真实 Core + macOS resolver + BuildSystemService 组合回归覆盖前置 Runtime 缺失/版本不足时保留工具或后置 Runtime 的非空 setting 约束,Provider 不发送独立发现请求;全部设置为空时仍可回退。自定义 Runtime 先构造含所选目录的 PATH,再以同一环境探测与执行,同目录 helper 的带空格路径回归通过。" } diff --git a/shared/platform-feature-matrix/features/cargo-build-plugin.json b/shared/platform-feature-matrix/features/cargo-build-plugin.json new file mode 100644 index 0000000000..02edfaaae3 --- /dev/null +++ b/shared/platform-feature-matrix/features/cargo-build-plugin.json @@ -0,0 +1,27 @@ +{ + "id": "cargo-build-plugin", + "area": "扩展", + "group": "插件与主题", + "capability": "Cargo 社区构建插件:由 Cargo metadata 提供 workspace/package/target/features,使用原生构建窗口执行编译、检查、测试、清理和 binary/example 运行,支持 Release、Features、Offline;用户安装 Cargo 与 Node,插件独立打包且运行时只读;通过共享 executableResolution 候选解析支持可选设置路径、优先级与相对排序,空值自动发现、显式失败不回退", + "macos": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "Plugins/mac/README.md", + "docs/ci-builds.md", + ".agents/notes/implemented/feature/2026-10-22-cargo-build-plugin.md", + "shared/contracts/plugins/v1/fixtures/build-tool-path.plugin.json", + "rust/lithe-core/src/plugins/executables.rs", + "macos/Sources/LitheCoreContracts/Execution/ExecutableResolution.swift" + ] + }, + "windows": { + "implementationStatus": "missing", + "verificationStatus": "not-applicable", + "evidence": [ + "shared/contracts/plugins/v1/README.md" + ] + }, + "owner": "Plugin Platform", + "verification": "独立源码与预发布包由 https://github.com/Lithe-IDEA/lithe-plugin-cargo 维护;插件 CI 按 sdk.lock.json 的宿主提交构建公开 JSON API 测试桥接,运行 3 项 Cargo 专属 Core 兼容性回归(清单/安装审查/能力拒绝、识别/模型解码、任务与测试选择),并以 LITHE_CARGO_INTEGRATION=1 执行真实双包 workspace:模型由 Core 规划请求并解码,编译/检查/测试/同名 binary 与 example/单测试/清理由 Core 生成参数,验证 Release/Features/Offline、编译失败及诊断解码、后端退出和包字节不变;SDK 固定源码和哈希、打包校验及逐测试 HTML/JUnit 由插件仓库 CI 持有。固定宿主基线不代表已验证未来 preview。主仓库只保留通用插件契约回归;scripts/verify-runtime-bundle-immutability.sh。原生验收待完成:macOS 从磁盘安装预发布 ZIP、确认 Cargo/Node、重启并启用;打开插件仓库 fixture workspace,检查 Cargo 原生窗口、模型与选项,执行构建/检查/测试/运行,制造错误并跳转源码;停止运行、关闭项目与停用插件后确认无后端和 Cargo 子进程。基础版不提供依赖树、调试或逐用例图形报告;Windows 尚无 API 3 构建宿主。;新路径选择使用 executableResolution 1 + settings 2,已发布 buildSystems 2 旧字段只作统一候选输入适配:MacBuildToolResolverTests 验证带空格绝对路径、权限与版本失败不回退,BuildSystemServiceTests 验证 Provider 失败不独立发现,RustBuildSystemOperationsTests 验证全局值接入。Cargo 独立仓库以显式本地 Core bridge 验证自选路径用于 metadata 和全部构建/测试/运行/清理;发布前需把独立仓库 sdk.lock.json 的宿主 pin 更新到包含该能力的已发布提交。;回归增加非法存储值不回默认、数组/priority/插入顺序与拒绝循环、宿主注入发现、空设置跳过和被动选择不探测;Language Server/Execution Provider 新清单语法尚未开放,Windows 不声明 executableResolution。" +} diff --git a/shared/platform-feature-matrix/features/database-connections.json b/shared/platform-feature-matrix/features/database-connections.json index 95993c2d34..fd762ed634 100644 --- a/shared/platform-feature-matrix/features/database-connections.json +++ b/shared/platform-feature-matrix/features/database-connections.json @@ -2,30 +2,27 @@ "id": "database-connections", "area": "数据库", "group": "数据库工作区", - "capability": "连接配置与 Provider", + "capability": "连接配置与 Provider(由社区插件提供)", "macos": { "evidence": [ - "macos/Sources/Lithe/Views/Database", - "macos/Sources/Lithe/Services", - "macos/Sources/Lithe/Theme/LitheTheme.swift", - "macos/Tests/LitheTests/LitheSearchFieldStyleTests.swift", - "macos/Sources/Lithe/Models/AppModel/AppModel.swift", - "macos/Tests/LitheTests/DatabaseWorkspaceLifecycleTests.swift", - "macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift", - "macos/Tests/LitheTests/WorkbenchModuleUIRegistryTests.swift", - "macos/Tests/LitheTests/DatabaseToolbarAppearanceTests.swift", - "macos/Sources/Lithe/Application/Composition/DatabaseModuleCoordinator.swift" + "macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift", + "macos/Sources/Lithe/Application/Features/PluginMarketplace.swift", + "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift", + "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginCredentialStore.swift", + "rust/lithe-core/src/plugins/backend.rs", + "macos/Tests/LitheTests/PluginWebBridgeTests.swift", + ".agents/notes/implemented/architecture/2026-10-24-database-provider-plugin.md" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "windows": { "evidence": [ - "windows/tauri/src/features/database" + "shared/contracts/plugins/v1/README.md" ], - "implementationStatus": "implemented", - "verificationStatus": "pending" + "implementationStatus": "missing", + "verificationStatus": "not-applicable" }, - "owner": "Database", - "verification": "创建、编辑、测试和删除各支持数据库连接,确认凭据和错误边界。 Search 查询与文件掩码、Database 连接搜索、Redis Key pattern 和 Nacos 配置/服务搜索均直接使用 Git Log 同源的全局唯一 LitheSearchFieldStyle;在深浅主题核对 36pt 外框、30pt 可见高度、4pt 圆角、1pt 常态边框、2pt 聚焦边框、3pt 外留白及 13pt 常规文字和提示色,确认无局部背景或原生边框叠加,并保留搜索、清除与键盘提交行为。 使用原生 NSHostingView 渲染验证共享 LitheSearchTextField 的占位文字在深浅主题均为 IDEA Component.infoForeground 对应的 #73767C、13pt 常规字重;空输入框单独绘制占位文字,避免 SwiftUI 原生 prompt 替换其颜色与字重,有内容时占位文字消失并保留原生编辑和主文字色。 中文输入法组词期间,即使绑定文本仍为空,共享 LitheSearchTextField 占位文字立即隐藏;取消组词或删空后恢复,确认中文后仍隐藏。原生 setMarkedText 回归覆盖明暗主题和两个输入框互不影响;保留焦点、键盘提交、搜索/过滤语义及占位字号颜色。原生背景观察视图卸载和移出窗口时清理通知,匹配当前窗口的字段编辑器及所属输入区域,不修改业务绑定。 共享搜索字段未聚焦时悬停立即显示文本编辑光标,离开或禁用时恢复箭头;保留中文组词及连接过滤语义。 macOS:分别在数据库首页、SQL/数据/结构/历史页、Redis/Nacos/MongoDB 工作区及连接/结构比较弹窗打开时退出应用或关闭项目,确认无 SIGTRAP;休眠后重新打开数据库,确认新实例正常工作,旧视图的延迟刷新不会访问已清空的能力缓存。 macOS 数据库入口位于右侧停靠栏,采用 IDEA IU-262.9437.185 中明确 Apache-2.0 授权的原始 20pt 描边圆柱 SVG(database/expui/dbms@20x20.svg 及 dark 变体,DatabaseIconMappings 对应数据库工具窗口),沿用共享停靠栏尺寸及普通/悬停/选中配色,打开时保留项目树、代码编辑器和底部工具;无连接时只有右侧空状态,不显示中央统计首页。打开表、查询或历史时复用可关闭的 Database 编辑器标签;隐藏右侧面板不关闭标签,关闭标签保留连接和 SQL 草稿。切换代码、终端、媒体、Diff 后可重新选择数据库;休眠和关闭项目移除数据库标签。Windows 本次保持现有布局。 数据库工具栏和筛选/排序统一为原始 16pt IDEA 明暗 SVG、共享 22pt 按钮,检查无额外边框、正常/悬停/禁用/筛选选中状态和默认箭头;各图标按钮复用 workbenchHoverHelp 即时显示本地化用途,禁用按钮也可显示提示,移开和点击后关闭,提示在数据库面板局部 scope 内绘制;窄面板工具栏可横向滚动,空状态只保留常规灰色文字,在内容区横向居中、纵向约三分之一处显示;移除图标、说明段落和重复 New Connection 按钮,添加连接仍由工具栏加号进入。 数据库连接新建/编辑、文件夹及 DBX 导入复用全局居中弹窗、输入焦点边框与确认/取消按钮;核对重复打开、取消/Escape/标题关闭、引擎选择及语言环境,导入关闭后清空数据。 连接/文件夹表单不再绘制 grouped 卡片背景,展开高级选项仍可滚动;普通/密码输入框复用共享 28pt 居中布局和启用状态下的 I-beam 悬停,深浅主题核对文字、焦点和禁用状态。 数据库面板仅标题下保留共享 LitheToolWindowHeaderDivider(主题边框色、1pt 高度、两侧 1pt 留白),工具栏按钮下方不再绘制额外横线;深浅主题对照项目和其他工具窗口核对。 连接搜索默认折叠为工具栏搜索图标,点击仅展开共享输入框且不自动聚焦,点击输入框后才显示焦点边框,再次点击或 Escape 清空并关闭搜索;筛选和排序固定在同一工具栏最右侧,窄宽度下左侧动作可横向滚动且右侧菜单仍可操作。 新建/编辑连接采用原生 Grid 标签列和输入列,Host/Port 同行、TLS/安全开关在输入列;提示文字跟随输入列,保留现有字段及逻辑。核对 MySQL、SQLite、Redis、Nacos、长标签以及展开安全/网络选项时的对齐和滚动;弹窗按简要/展开内容调整高度,不添加 IDEA 专有控件。 共享搜索输入在点击同窗口输入区域以外的位置时释放原生字段编辑器,焦点边框恢复常态;输入内部点击保留焦点,其他搜索框的观察器不得取消当前编辑,查询文字保持不变。 数据库概览按钮位于搜索左侧,打开独立可关闭且去重的 Database Overview 编辑器标签;用共享标题/分隔线/行样式保留连接数、已连接数、类型数、Quick Start、Common Actions、Recent SQL 六区。核对与 Database 查询标签同时打开、分别关闭和重复打开,草稿/连接不丢失;关闭项目或休眠移除全部数据库标签;明暗主题、窄窗口纵向排版及最近 SQL 恢复到所属连接。 数据库概览和内容标签统一使用侧栏圆柱图标;连接标签保持数据库名(空值用连接名),页面切换不改变名称;无连接全局历史显示 SQL History。 概览常用动作悬停仅覆盖内容和内边距,使用共享圆角,禁用动作无悬停;概览 New Connection 删除 SwiftUI sheet,复用侧栏 LitheCenteredPopup,核对无打开动画/遮罩、原生标题与外框及关闭/取消/Escape。 连接弹窗 Safety 和 Advanced network 标题整行支持点击展开/收起;检查文字、空白、原生箭头均只切换一次,展开后的输入和开关操作不触发收起。 数据库分组文件夹复用项目树 LitheIcon(.folder) 和共享 treeIconSize,删除黄色 folder.fill;核对深浅主题、展开/收起及嵌套分组。 数据库树分组、连接、数据库、表节点复用共享 litheTreeRow/Tree 字体、行高、缩进与选择悬停色;移除连接颜色竖线、Tables 数量胶囊和空表独立卡片,保留状态、只读/保护提示及原动作。 数据工作区四个导航图标复用共享工具栏按钮和选中状态,空状态使用共享 LitheUnavailableView;数据库操作错误由协调器交给全局通知,核对隐藏侧栏仍通知、重复激活不重复通知、休眠取消观察和重新激活恢复;连接树悬停保持默认箭头。 四个数据库工作区内容统一共享页签、工具栏、标题分隔线、滚动条与字体,SQL 编辑器使用主题字体和颜色;Overview Quick Start/Recent SQL 悬停与 Common Actions 共用圆角与内外留白,背景不覆盖行外留白,状态和时间保持右对齐。核对有数据和空状态、查询草稿切换/关闭、结果网格、数据编辑、结构动作、历史恢复以及明暗主题。 数据库树采用共享左右 12pt、上下 4pt 外留白;连接与表的选中行接入共享活动区域观察器,仅树区域获得焦点且应用窗口为 key 时显示主题蓝色,点击编辑区、搜索框或切换窗口后显示非活动灰色。核对重新点击树恢复蓝色,选中对象保持不变。 连接树移除行尾 Provider 类型及状态符号,成功/失败改为品牌图标右下角绿色/红色圆点,未连接不显示点,连接中保留进度提示;MySQL 原有海豚图形使用 IDEA 深色图标蓝色 #548AF7 提升暗背景对比,工具栏与树节点复用同一资源。核对不同连接状态、只读提示及明暗背景可辨性。 Provider 图标替换为 Community c7f91397 的原始 16×16 providers SVG 及可用 dark 变体(Nacos 保持已有资源),避免 DBX 1024px 画布内留白导致图形过小;主题切换使用独立缓存。状态点依据 modifiers/dot.svg 的 5pt 直径,在 Lithe 主题语义色上按用户要求轻微提高饱和度,不改变全局颜色。验证原生资源尺寸、实际像素占用、主题切换及红绿点位置。 Redis Key Browser 标题/DB 标识复用共享工具窗口标题栏,Estimate size 使用原生常规复选框;刷新与 SCAN 搜索使用 IDEA 16pt SVG 和共享 22pt 工具栏按钮及悬停说明。删除局部红色图标底板、DB 胶囊、迷你开关和内联错误条,错误沿用协调器全局通知,空状态复用 LitheUnavailableView 并填充浏览区,确保搜索固定顶部。保留 SCAN 分页、大小估计绑定、重试、详情及写保护逻辑。核对错误/空状态、搜索焦点、键盘提交与切换连接;原生生命周期检查覆盖 SQLite/Redis 工作区关闭项目后的保留视图。 Data 表格按 Community grid/impl 的 TableResultView、CellRenderingUtils、GridColorSchemeUtil 和 SelectionColorLayer 组织:数据工具栏置于 WHERE/ORDER BY 分栏上方,删除无动作 All rows 胶囊;表头 25pt 下限、4pt 左内边距、原始列 SVG、自适应数字宽度行号列,点击行号仍选择行、点击 # 仍全选,保留排序/过滤菜单。数字单元格右对齐、文本左对齐;单元格选择接入原生焦点和共享活动区域,有焦编辑器选择蓝、失焦编辑器选择灰,切换表清理焦点;表头使用背景 RGB +16/-16 的 softHighlightOf、网格线取 Islands 编辑器 INDENT_GUIDE,单元格左右 3pt,行高按字体行高乘编辑器行距 +5pt,顶部工具栏使用共享 30pt 紧凑高度。数据库树 schema/Tables 分组/表使用 IDEA IU-262.9437.185 中明确 Apache-2.0 授权的原始 schema/objectGroup/table 16pt 明暗 SVG,Nacos/Redis/MongoDB 节点保持既有语义。验证有数据时焦点/失焦、过滤/排序、行选择/全选、分页、只读及窄宽度。 筛选/排序弹出区域复用共享弹窗按钮、输入框和分段控件,删除原生按钮背景。数据列宽参考 DefaultGridColumnLayout,按表头与前 100 行内容测量,40pt 下限和可用宽度黄金分割上限,按内容比例分配面板剩余空间;不再等分视口。可写连接单击只选择、双击进入单元格编辑,只读只选择不进入编辑;保留上下文菜单和行操作。 数据库树首层文件夹和连接不叠加局部 7pt/4pt 左内边距,只使用共享容器留白和 19pt 层级缩进;核对首层起点、文件夹子连接以及未分组连接。 数据表头依据 primaryKeyColumns 显示原始 16pt columnGoldKey 明暗资源;核对单字段及复合主键、普通列,字段名 id 不作为主键判据。 共享 sidecar 行解码读取实际值类型,SQLite PRAGMA 的 pk/cid/notnull 保持整数而非 unsupported;回归覆盖单字段和复合主键及普通列。 数据网格列宽在内容测量基础上按比例分配面板剩余宽度、移除 full×1.2 上限;拉宽/收窄面板核对铺满与横向滚动。双击可写单元格进入编辑,普通选中保留整格选中色;复用共享 litheFocusEffectHidden 隐藏 macOS 14+ 系统文字焦点框,核对键盘及双击编辑,Git Log 既有行为不变。 数据表删除独立路径/属性工具栏,保留单行共享 30pt 工具栏:左侧原始 16pt/22pt 刷新及编辑动作,右侧 TSV/分页固定,低频原操作合并到 Data Tools,窄宽度左侧横向滚动;连接标签显示数据库/连接名,移除内层表标签。原普通 Text 的单/双击 SwiftUI 手势改为原生 mouseDown 点击计数,第一下同步选择,第二下在可写连接进入编辑,不等待双击判定;已编辑字段不覆盖,右键/Control-click 和滚轮仍交由原控件,核对默认光标、只读、焦点失焦和键盘导航。 每个数据库连接对应一个顶层标签,重复打开去重;切换表仍复用所属连接标签,内层表标签与文字 Data/SQL/Structure/History 栏删除。四个页面使用原始 16pt table/shell/structure/history SVG、共享 22pt 图标按钮及悬停/选中/辅助功能,与表格操作合并为一行;分组分隔项 7pt 槽位,ToolBar.separatorColor 深浅主题色,非 Data 或无表禁用表格操作。验证 Data/Structure/History 页面及独立 SQL 控制台切换、数据库名稳定、多连接标签去重/关闭/恢复、各连接 SQL 草稿和导航位置隔离、表选择及即时单击、全部关闭/休眠清理和窄宽度布局;明暗资源和原动作保留。 工具栏新增/提交/撤销/更多、TSV 和分页复用同一共享按钮状态;核对可用时原色及 hover,禁用时灰色且无 hover,父组禁用与显式参数禁用只降低一次亮度;TSV 保留文字固有宽度。组件检查覆盖深浅主题,实际预览检查只读/可写草稿及撤销后的状态恢复。 行号列使用与数据相同的 13pt 等宽 Regular 字体,普通与当前行文字使用独立 LINE_NUMBERS_COLOR/LINE_NUMBER_ON_CARET_ROW_COLOR;背景透明,当前行按 SelectionColorLayer 高亮;核对单元格选择、行选择、# 全选、失焦、右键、深浅主题,以及右侧 1pt 网格线。 macOS Data 新增草稿行须立即可见、延续行号并滚动选中,双击编辑后可提交;连续新增与撤销通过原生预览检查,只读连接保持禁用,失败保留草稿。 macOS 连接树只显示保存的连接名和品牌/连接状态图标,移除只读锁;演示名称不含人为追加的类型后缀。 macOS Data 全选后点击单元格清除整行选择,行号仍可逐个取消;可写演示连接单击只选择、双击可编辑,Tables 分类使用普通树文字色,数量维持次要色。 macOS 可写单元格单击立即整格选择、双击进入编辑,编辑保留原背景及内侧 2pt Islands #3871E1 焦点边框;已有行与新增行一致,失焦移除边框,只读不编辑,行号与全选保留草稿。 macOS 异步标签导航:点击连接先同步选中标签,数据刷新完成不重新激活页面;侧栏、Overview、历史恢复和表动作复用同一入口。运行 DatabaseWorkspaceLifecycleTests.delayedConnectionLoadPreservesLaterNavigation,以 TestGate 控制成功/失败加载,核对切到文件或 Overview、关闭单个/全部标签和请求开始前关闭后,标签集合和选择不被迟到结果修改,loading 状态正常结束。 macOS Data 变更背景按 Community MutationsColorLayer 解析:修改蓝色、新增绿色、删除使用 DIFF_DELETED,提交失败覆盖为 Islands Notification.errorBackground 红色;当前行深/浅 RGB 各加/减 0x10,取消整行选择后保留当前行高亮,实际选中单元格覆盖选中色。核对编辑、连续新增、删除标记、失败保留草稿、撤销及深浅主题;每条网格线使用 INDENT_GUIDE_COLOR 深/浅 #323438/#E6E6E6 与 1pt 单边绘制,行列无额外空隙,修改/失败/删除不改变边线或文字透明度。DatabaseGridAppearanceTests 保护动态主题解析与透明度。 macOS Data 透明行号栏使用独立 LINE_NUMBERS_COLOR/LINE_NUMBER_ON_CARET_ROW_COLOR;普通行号无表头底板;GridRowHeader 横线与 TableResultRowHeader 右侧竖线保留同一 1pt 网格边线,当前行号变亮,选中单元格所在行的其余普通单元格及当前列标题取 GRID_CURRENT_ROW。核对深浅主题、跨行/列点击、键盘移动、全选取消、失焦、双击编辑和新增草稿,变更色及蓝/灰单元格选择保持原优先级。 macOS SQL 控制台:New Query 为每个查询打开独立顶层 console 标签;Data/SQL 切换复用已打开控制台,历史恢复与表 SQL 动作走相同导航入口。无查询结果时编辑器铺满面板,结果出现后使用共享分隔容器,原生行号栏复用普通编辑器;保留补全、格式化、执行选区、风险确认与历史。MySQL/MariaDB 数据库菜单位于控制台右侧,侧栏不显示额外品牌按钮,切库保留查询 ID 和草稿并清理旧结果。DatabaseWorkspaceLifecycleTests.connectionAndConsoleTabsRestoreDraftsAndCloseIndependently 覆盖多查询、跨连接、后台关闭、切库、关闭当前控制台与全部关闭;延迟加载测试增加切到查询后不得抢回标签的场景。 macOS Console 默认 JetBrains Mono 13pt Regular 与 1.2 行距,字号随编辑器设置;浅色注释真实 Italic,其余 token 保持常规,引号内注释标记不误着色。原生测试核对明暗主题、空白编辑视口扩大后仍覆盖、I-beam、当前行号、草稿回传及字体/段落属性。SQL 只读结果复用 Data 自适应列宽、透明行号、1pt 网格和立即选择/键盘/复制;筛选排序、结构编辑及批量/替换/行详情复用共享按钮、输入、选择器和居中弹窗,核对 Esc/取消/标题关闭不提交。Redis/Nacos 输入与工具栏保留原动作;系统确认及文件选择保留原生。远端 Provider 的实际读写仍须有服务验证,不以组件测试代替。 Console 及 Structure 工具栏统一复用 Database 的 30pt 行高;Console 与共享行号使用 TextKit 1 初始化,保护执行结果分栏及标签重建后无需点击即可首次显示草稿。补全候选的原生 delegate 回归确认 SELECT 前缀,不把系统抢占的 Control-Space 快捷键认定为已完成实机验收。 Mac 本地回归增加重名查询列的布局、原生 Data/Console 重建后的表格草稿及后台刷新快照、行号区与编辑区背景连续性、数据库菜单初始选择状态;数据库切换保留现有 MySQL/MariaDB 后端边界。 Mac Console:无焦点时进入/移动空白编辑区显示 I-beam,离开恢复箭头;首次普通点击末行下方任意位置直接聚焦 EOF,清除旧文字选择,不插入虚拟空行,立即输入追加到最后一行;窗口缩放与查询结果分栏后仍保持整块视口命中。数据库下拉的辅助功能报告当前库名,切库保留查询 ID 和草稿。 Mac Console 文字从内容区原点开始,不叠加容器与行片段的水平留白;明暗主题原生图像回归核对空查询和末尾空行输入前后行号基线不变,当前行背景持续覆盖行号栏;Console 行号按 New UI 的 4pt 空注释区、4pt 前置间距及 14pt 最小占位右对齐,核对单个数字的原生绘制位置,测量使用实际 Console 字体。 Mac Console 点击切行时,原生插入光标与当前行背景同步;stillSelecting 为 true 的单个/多个选区入口回归在选择结束通知之前检查行号及背景,保留原生拖选、键盘、输入法和执行选区行为,只重绘旧、新行及行号区域。 Mac Console 按 Community New UI 将分隔线置于行号栏右边缘前 3pt,1pt 线后保留 2pt 编辑背景,文字容器不重复加 padding;明暗主题原生像素检查验证边线及两点背景。 macOS 审查回归:运行 DatabaseWorkspaceLifecycleTests 的 delayedApplyLocksDraftsAndPreservesNavigation、backgroundConnectionManagementPreservesEditorSelection、removingConnectionClosesOwnedTabsAndFallsBack;以有界延迟控制成功/失败提交,核对在途编辑及重复提交被阻止、A→B 和 A→B→A 不被旧提交刷新、后台保存/刷新不改变编辑器选择,删除连接显式移除数据/查询标签并合法回退。原生交互和实际服务验证单独记录。 Mac Data、只读结果、Console 和行号栏结构背景复用全局 editor 调色板,与项目区保持一致;测试遍历全部配色及明暗外观,Console 与普通编辑器一样关闭文本、滚动视口、容器及行号栏的独立底色;在同一原生窗口逐像素比较最终合成背景与侧栏一致,覆盖深浅外观及编辑、缩放、重绘后的当前行状态。WHERE/ORDER BY 空条件沿 INLINE_PARAMETER_HINT 取深/浅 #858A94/#7A7A7A,有条件使用 SQL keyword 色;实际点击筛选/排序与切换面板后颜色不漂移。 Mac Console 数据库选择器在 SQLite 等单库连接下也可展开,使用共享菜单/hover/22pt 按钮和 16pt schema/箭头;当前库初始选中,MySQL/MariaDB 列出当前连接的其他数据库并复用切库,保留查询 ID 和草稿。SQLite 只显示当前文件库,未开放跨连接切换;核对关闭/Esc/禁用、真实名称和 2pt 内容间隔及左右 4pt 留白。 Mac 异步草稿隔离:运行 delayedPageOnlyClearsItsOwnCurrentDrafts,核对成功/失败分页在切表、切连接及后续分页替代后不清空新页面草稿;运行 delayedApplyLocksDraftsAndPreservesNavigation 的 otherConnection 场景,核对后台提交不阻止新连接加载和编辑,旧提交结束不清理新连接草稿,模块仍追踪后台在途写入。 Mac 查询呈现:运行 queryOutputTracksStatementsSelectionAndFailures 与 WorkbenchModuleUIRegistryTests,核对底部 Services/Output、逐语句真实时间/行数/错误、Unicode 与重复语句选区、错误历史、清空日志保留结果,以及执行前按 SQL 子句格式化(保留词大小写,SELECT 内容另起一行并缩进 4 格,FROM 跟随投影)、有原生范围时只改选区并保留重复 SQL、引用及运算符;深浅主题实际打开连接标题的历史窗口,检查 Close/Paste/Esc、只读预览、错误条关闭和 Services 页签切换。SQL 编辑区、历史预览和 Output 共用语法高亮器;querySyntaxColorsProtectQuotedIdentifiersAndLiterals 在明暗主题核对关键词与运算符、指数数字、转义字符串、美元引用、引用标识符和注释,SQL 本文保持不变。原生像素回归核对首列语句左侧 1pt 绿框可见,绘制遵循 BOXED 零外扩并避开 AppKit 裁剪边缘;同窗原生色块校准 HDR 映射,保留确切色值及严格色差断言。格式化后的末尾光标仍位于 EOF。executionMarkersFollowUnchangedStatementsAndExposeStripePositions 核对成功对号、失败图标、右侧 14×2pt 状态条、短文本不拉伸和长文本压缩、Unicode 前置编辑移动范围与语句编辑清除状态;queryCompletionKeepsOnlyUnchangedExecutionMarkers 用 TestGate 控制查询完成,核对在途追加保留状态及修改已执行语句不显示旧标记。右侧标记点击定位,普通滚动和滑块拖动保留共享 CompactScroller;原生明暗主题另验收。共享输出 Copy 复用统一按钮,常态无背景和边框、hover 使用共享圆角底色,禁用只衰减一次,复制内容仍为无 ANSI 的原始输出。Copy 作为原生滚动视图内的真实 NSHostingView 子视图优先命中,仍用共享 SwiftUI 按钮样式;正文排除按钮的 cursor rect,按钮及下层移动事件均保持箭头;copyExcludesNativeTextHitsAndCursorRectsAcrossResize 在深浅主题检查图标、文字、内边距、缩放后命中、文本矩形完整覆盖正文,以及下层 cursorUpdate 事件恢复箭头。回归检查整棵 NSHostingView 视图树命中到真实按钮宿主,覆盖窗口缩放和图标/文字/留白;完整应用 hover 单独检查。multilineStatementOutlineEndsAtFinalLineInsteadOfWidestLine 核对四行 SQL 的绿框在最后一行收窄到实际终点,原生像素验证收窄边线和原最大 X 处背景;沿 Community EditorPainter.paintBorderEffect,保留绿色 token 和 1pt 描边。 审查修复回归:databaseSQLFormatterLeavesSessionDependentEscapesVerbatim 核对 SQLite 普通字符串、E 转义字符串及写入语句含反斜杠时完全保留原文;formattedMiddleSelectionKeepsTheWholeStatement 核对 Unicode 前缀后的中间选区、全选和 EOF 格式化映射;delayedApplyLocksDraftsAndPreservesNavigation 增加 returnToConnection 的成功/失败场景,核对提交结束后补刷并结束 loading、不影响其他连接。真实窗口核对从旧 Console 切到新 Console 后 History Paste 不沿用旧范围。 SQL History 列表与预览之间的分隔条使用现有 highlightsOnHover=false,深浅主题核对 hover、按下及拖动颜色保持常态,同时保留调整光标和宽度拖动。 databaseSQLScannersPreserveCombiningMarksInsideLiterals 覆盖 U+0301/U+FE0F 开头、双引号转义及字面量内分号/危险关键词;格式化保持原值、拆分仍为一条查询。formattedMiddleSelectionKeepsTheWholeStatement 同时覆盖单条替换和整段多语句格式化,选区不包含其他语句,EOF/全选仍正确。databaseTextAndCopyIgnoreLateEventsOverDivider 验证正文、可编辑 Console、只读 History 及 Copy 的延迟 tracking 事件不覆盖真实分隔条光标,移出窗口后不修改光标;正常正文和按钮仍提供各自光标。Copy 的深浅主题实际宿主测试覆盖翻转坐标与缩放。 后台连接保存回归:backgroundSavePreservesCurrentLoading 使用 TestGate 覆盖当前连接空闲、刷新表、打开表及分页,与后台保存成功/失败的两种完成顺序;后台保存不能设置或清除当前页面 Loading,连接弹窗独立显示连接中并防止重复提交。 P1 并发回归:sidebarTableSelectionDuringApplyLoadsLastSelection 经真实 AppModel 入口覆盖提交期间 A→B、A→B→A 与成功/失败,断言旧页清空、最终表数据正确及 Loading 结束;sqlExecutionLockSurvivesConnectionNavigation 覆盖 INSERT/查询、成功/失败及完成前/后返回原连接,验证跨连接 SQL 执行锁、模块忙闲、禁止重复 Run 与完成后释放。 草稿快照与持久日志回归:reselectingFilteredTableDiscardsDraftsBeforeReplacingKeys 覆盖过滤/排序后编辑、重选同表成功/失败,保证旧行索引草稿不再向新快照主键提交;sqlExecutionLockSurvivesConnectionNavigation 重读原连接的持久 History、Execution Events、Audit,覆盖后台成功/失败及部分批次中断,保留实际影响行数且不回填其他连接 UI。" + "owner": "Database(社区插件 Lithe-IDEA/lithe-plugin-database,Lithe Store 上架)", + "verification": "macOS 从插件市场的 Lithe Store 安装社区插件 io.github.lithe-idea.database,打开 Database 工具窗口:新建、编辑、复制、测试、删除 MySQL、MariaDB、PostgreSQL、SQLite、SQL Server、MongoDB、Redis、Nacos 连接,含 SSH 隧道、TLS、只读和生产保护;永久保存的密码进入 Keychain(服务 <前缀>.plugin-credentials.dev.lithe.plugin.database),“直到重启”的密码不落盘;文件夹的新建、嵌套、移动、重命名和删除,以及从 DBX 导入连接。停用后入口消失、后端进程退出,重新启用或更新后连接和密码仍在;卸载后凭据和 ${stateDir} 一并删除。确认安装包目录没有任何写入。 Windows:插件宿主和插件市场就绪前不提供数据库功能,主程序不再包含数据库入口。" } diff --git a/shared/platform-feature-matrix/features/database-crud-export.json b/shared/platform-feature-matrix/features/database-crud-export.json index c496766b6e..2eeddfa6e7 100644 --- a/shared/platform-feature-matrix/features/database-crud-export.json +++ b/shared/platform-feature-matrix/features/database-crud-export.json @@ -2,22 +2,27 @@ "id": "database-crud-export", "area": "数据库", "group": "数据库工作区", - "capability": "表数据编辑、CRUD 与导出", + "capability": "表数据编辑、CRUD 与导出(由社区插件提供)", "macos": { "evidence": [ - "macos/Sources/Lithe/Views/Database", - "macos/Sources/Lithe/Views/Database/DatabaseTableView.swift" + "macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift", + "macos/Sources/Lithe/Application/Features/PluginMarketplace.swift", + "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift", + "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginCredentialStore.swift", + "rust/lithe-core/src/plugins/backend.rs", + "macos/Tests/LitheTests/PluginWebBridgeTests.swift", + ".agents/notes/implemented/architecture/2026-10-24-database-provider-plugin.md" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "windows": { "evidence": [ - "windows/tauri/src/features/database" + "shared/contracts/plugins/v1/README.md" ], - "implementationStatus": "implemented", - "verificationStatus": "pending" + "implementationStatus": "missing", + "verificationStatus": "not-applicable" }, - "owner": "Database", - "verification": "验证新增、编辑、删除、事务失败、复制和结果导出。 macOS 中文界面使用同时包含 Name、名称、Type 列的表,检查筛选、排序、批量更新和替换的列名保持原样;替换的 All columns 固定选项保留本地化,选择列后只修改对应列。" + "owner": "Database(社区插件 Lithe-IDEA/lithe-plugin-database,Lithe Store 上架)", + "verification": "macOS 从插件市场的 Lithe Store 安装社区插件 io.github.lithe-idea.database,打开 Database 工具窗口:打开表数据,筛选、排序、分页,编辑单元格、新增和删除行后提交与撤销;只读连接和脱敏列不可编辑;生产保护连接上的危险写入弹出确认;通过宿主文件对话框导出 CSV/JSON/SQL、导入 CSV/JSON、备份与恢复 SQL,大结果超过 1 MiB 时提示截断。 Windows:插件宿主和插件市场就绪前不提供数据库功能,主程序不再包含数据库入口。" } diff --git a/shared/platform-feature-matrix/features/database-multi-schema-tree.json b/shared/platform-feature-matrix/features/database-multi-schema-tree.json new file mode 100644 index 0000000000..f204d3eb7c --- /dev/null +++ b/shared/platform-feature-matrix/features/database-multi-schema-tree.json @@ -0,0 +1,28 @@ +{ + "id": "database-multi-schema-tree", + "area": "数据库", + "group": "数据库工作区", + "capability": "连接下多数据库独立展开与显示范围选择(由社区插件提供)", + "macos": { + "evidence": [ + "macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift", + "macos/Sources/Lithe/Application/Features/PluginMarketplace.swift", + "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift", + "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginCredentialStore.swift", + "rust/lithe-core/src/plugins/backend.rs", + "macos/Tests/LitheTests/PluginWebBridgeTests.swift", + ".agents/notes/implemented/architecture/2026-10-24-database-provider-plugin.md" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "windows": { + "evidence": [ + "shared/contracts/plugins/v1/README.md" + ], + "implementationStatus": "missing", + "verificationStatus": "not-applicable" + }, + "owner": "Database(社区插件 Lithe-IDEA/lithe-plugin-database,Lithe Store 上架)", + "verification": "macOS 从插件市场的 Lithe Store 安装社区插件 io.github.lithe-idea.database,打开 Database 工具窗口:展开 MySQL/MariaDB 连接后同时展开多个数据库,各自折叠、刷新和失败重试;用“Databases Shown…”只显示选中的库,选择随连接保存;表、视图、例程、触发器、序列分组按需加载。 Windows:插件宿主和插件市场就绪前不提供数据库功能,主程序不再包含数据库入口。" +} diff --git a/shared/platform-feature-matrix/features/database-plugin-migration.json b/shared/platform-feature-matrix/features/database-plugin-migration.json new file mode 100644 index 0000000000..d1ff72269f --- /dev/null +++ b/shared/platform-feature-matrix/features/database-plugin-migration.json @@ -0,0 +1,27 @@ +{ + "id": "database-plugin-migration", + "area": "数据库", + "group": "数据库工作区", + "capability": "内置数据库模块的连接迁入插件与安装提示", + "macos": { + "evidence": [ + "macos/Sources/Lithe/Platform/MacOS/Plugins/MacDatabasePluginMigration.swift", + "macos/Tests/LitheTests/MacDatabasePluginMigrationTests.swift", + "macos/Sources/Lithe/Application/Features/DatabasePluginTransition.swift", + "macos/Sources/Lithe/Models/AppModel/AppModel+DatabasePlugin.swift", + "macos/Tests/LitheTests/WorkbenchNotificationTests.swift", + "macos/Tests/LitheTests/MacPluginBackendSessionsTests.swift" + ], + "implementationStatus": "implemented", + "verificationStatus": "verified" + }, + "windows": { + "evidence": [ + "shared/contracts/plugins/v1/README.md" + ], + "implementationStatus": "platform-specific", + "verificationStatus": "not-applicable" + }, + "owner": "Database", + "verification": "macOS:用保存过数据库连接(含文件夹、旧 group 字段、SQL 历史、永久和“直到重启”密码)的旧版本升级。首次启动出现一次“数据库已改为插件”通知,点击“打开插件市场”进入搜索 Database 的市场页,通知在点击前不自动消失,之后不再出现;已安装插件或从未保存连接的用户不出现。从 Lithe Store 安装插件并首次打开后,连接、文件夹(旧 group 变为顶层文件夹)、属于现存连接的最近 100 条历史和永久密码全部可用,“直到重启”的密码需要重新输入;Keychain 锁定时迁移失败的密码只记录日志、连接改为询问密码。从磁盘、其他市场来源或仓库地址不是 Lithe-IDEA/lithe-plugin-database 的同 ID 包得不到任何旧数据和密码,之后从 Lithe Store 安装仍会迁移;插件已有 connections.json 时不覆盖;迁移只执行一次,旧数据保留供旧版本读取。Windows 没有内置数据库模块,无需迁移。2026-10-24 已在 macOS 开发版身份(app.lithe.desktop.dev,独立偏好与 Keychain)按此流程验收:写入 3 个旧连接(含文件夹、旧 group、历史、永久与“直到重启”密码)后启动,出现通知并经“打开插件市场”从 Lithe Store 安装 Database 0.1.0,重启后打开工具窗口,3 个连接都在;插件状态中旧 group 变为顶层文件夹、历史时间换算正确、connections.json 不含密码,永久密码进入插件凭据、“直到重启”的密码未迁移,迁移标记已写入。Keychain 锁定与非 Lithe Store 来源由 MacDatabasePluginMigrationTests 覆盖。" +} diff --git a/shared/platform-feature-matrix/features/database-query-history.json b/shared/platform-feature-matrix/features/database-query-history.json index f029df9d86..f805e8d02a 100644 --- a/shared/platform-feature-matrix/features/database-query-history.json +++ b/shared/platform-feature-matrix/features/database-query-history.json @@ -2,21 +2,27 @@ "id": "database-query-history", "area": "数据库", "group": "数据库工作区", - "capability": "SQL 查询、历史与分页", + "capability": "SQL 查询、历史与分页(由社区插件提供)", "macos": { "evidence": [ - "macos/Sources/Lithe/Views/Database" + "macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift", + "macos/Sources/Lithe/Application/Features/PluginMarketplace.swift", + "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift", + "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginCredentialStore.swift", + "rust/lithe-core/src/plugins/backend.rs", + "macos/Tests/LitheTests/PluginWebBridgeTests.swift", + ".agents/notes/implemented/architecture/2026-10-24-database-provider-plugin.md" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "windows": { "evidence": [ - "windows/tauri/src/features/database" + "shared/contracts/plugins/v1/README.md" ], - "implementationStatus": "implemented", - "verificationStatus": "pending" + "implementationStatus": "missing", + "verificationStatus": "not-applicable" }, - "owner": "Database", - "verification": "执行查询、取消查询、查看 SQL 历史和分页结果,确认长结果不阻塞界面。" + "owner": "Database(社区插件 Lithe-IDEA/lithe-plugin-database,Lithe Store 上架)", + "verification": "macOS 从插件市场的 Lithe Store 安装社区插件 io.github.lithe-idea.database,打开 Database 工具窗口:在查询控制台执行单条和多条语句,取消正在执行的语句,查看逐语句结果、耗时、行数和错误;只读连接拒绝写入语句(包括以查询或导出形式发送的写入);不带 WHERE 的 UPDATE/DELETE 等危险语句在生产保护连接上先确认;SQL 历史保留最近 100 条并可恢复到所属连接。 Windows:插件宿主和插件市场就绪前不提供数据库功能,主程序不再包含数据库入口。" } diff --git a/shared/platform-feature-matrix/features/database-schema.json b/shared/platform-feature-matrix/features/database-schema.json index 9c60a5daa4..dc598e88a8 100644 --- a/shared/platform-feature-matrix/features/database-schema.json +++ b/shared/platform-feature-matrix/features/database-schema.json @@ -2,21 +2,27 @@ "id": "database-schema", "area": "数据库", "group": "数据库工作区", - "capability": "Schema、表与列浏览", + "capability": "Schema、表与列浏览及结构编辑(由社区插件提供)", "macos": { "evidence": [ - "macos/Sources/Lithe/Views/Database" + "macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift", + "macos/Sources/Lithe/Application/Features/PluginMarketplace.swift", + "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift", + "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginCredentialStore.swift", + "rust/lithe-core/src/plugins/backend.rs", + "macos/Tests/LitheTests/PluginWebBridgeTests.swift", + ".agents/notes/implemented/architecture/2026-10-24-database-provider-plugin.md" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "windows": { "evidence": [ - "windows/tauri/src/features/database" + "shared/contracts/plugins/v1/README.md" ], - "implementationStatus": "implemented", - "verificationStatus": "pending" + "implementationStatus": "missing", + "verificationStatus": "not-applicable" }, - "owner": "Database", - "verification": "展开数据库、Schema、表和列,验证刷新、筛选和空结果展示。" + "owner": "Database(社区插件 Lithe-IDEA/lithe-plugin-database,Lithe Store 上架)", + "verification": "macOS 从插件市场的 Lithe Store 安装社区插件 io.github.lithe-idea.database,打开 Database 工具窗口:浏览 schema、表、列、索引、外键和 DDL;新增、修改、删除列与索引,生产保护连接上的结构变更先确认;MongoDB 集合、Redis Key、Nacos 配置与服务各自的浏览和编辑页面可用。 Windows:插件宿主和插件市场就绪前不提供数据库功能,主程序不再包含数据库入口。" } diff --git a/shared/platform-feature-matrix/features/declarative-native-plugin-ui.json b/shared/platform-feature-matrix/features/declarative-native-plugin-ui.json new file mode 100644 index 0000000000..04288e54f5 --- /dev/null +++ b/shared/platform-feature-matrix/features/declarative-native-plugin-ui.json @@ -0,0 +1,41 @@ +{ + "id": "declarative-native-plugin-ui", + "area": "插件", + "group": "插件界面", + "capability": "插件声明原生树/列表、工具栏按钮及右键菜单,由宿主统一样式;数据与动作复用已有插件函数入口,保存稳定选择及展开状态,关闭/停用/替换时取消并拒绝晚到结果;ui 3 原生 schema 1;树/列表复用共享外侧留白与焦点外观,选中及悬停背景不贴面板边缘,保留键盘导航;同一停靠槽切换面板按贡献 ID 隔离会话,公共标题栏使用41pt高度、13pt Bold,纯文字且无标题图标、边框和底线;工具栏动作仅图标按钮,复用宿主图标、公共工具栏颜色及原生16pt动作资源,面板悬停或键盘/无障碍聚焦时显露;表单通过 input/select/checkbox 直接复用 IDE 公共输入框、下拉选择与复选框,支持初始值、禁用、提交和工作区内恢复,输入框悬停在未聚焦时即显示I形光标,按钮外观继续复用公共控件;表单支持1–12列栅格与控件span,宿主管理间距及窄面板单列回退;接入字段info、搜索、多行输入、分段选择、数字步进器及普通/主要按钮,文字动作使用#1422公共自然宽度与6pt间距;搜索统一完整入口,单行/密码及多行输入使用完整公共组件,MCP命令输入通过公共垂直输入保持1–3行自适应且Agent指令编辑保留外部焦点,禁用字段/动作的说明仍可用鼠标和键盘查看", + "macos": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "macos/Sources/Lithe/Views/Workbench/PluginNativeToolWindowView.swift", + "macos/Sources/Lithe/Application/Features/PluginNativeUIFeature.swift", + "macos/Tests/LitheTests/PluginNativeUITests.swift", + "shared/contracts/plugins/v1/fixtures/native-panel.plugin.json", + "macos/Sources/Lithe/Views/Components/LitheTreeList.swift", + "macos/Tests/Fixtures/native-ui-lab/plugin.json", + "macos/Tests/Fixtures/native-ui-lab/README.md", + "macos/Tests/Fixtures/native-ui-lab/native-ui-lab.test.mjs", + "macos/Sources/Lithe/Views/Components/LitheToolWindowHeader.swift", + "macos/Sources/Lithe/Views/Components/LitheToolWindowTabStyle.swift", + "shared/contracts/plugins/v1/fixtures/native-form.plugin.json", + "macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift", + "macos/Sources/Lithe/Views/Components/LitheFormGrid.swift", + "macos/Tests/LitheTests/LitheFormGridTests.swift", + "macos/Sources/Lithe/Views/Components/LitheInfoTip.swift", + "macos/Sources/Lithe/Views/Components/LitheSearchField.swift", + "macos/Sources/Lithe/Views/Components/LitheActionButtonStyle.swift", + "shared/contracts/plugins/v1/fixtures/native-controls.plugin.json", + "macos/Sources/Lithe/Views/Components/LitheTextInput.swift", + "macos/Tests/LitheTests/LitheTextInputTests.swift" + ] + }, + "windows": { + "implementationStatus": "missing", + "verificationStatus": "not-applicable", + "evidence": [ + "shared/contracts/plugins/v1/README.md" + ] + }, + "owner": "Plugin Platform", + "verification": "运行 PluginNativeUITests、PluginWebBridgeTests、WorkbenchContributionHostTests 和 UI Lab 验收测试(从外部 lithe-plugin-docs 显式取得 SDK);macOS 加载 sample-build 示例,刷新、选择、展开及右键 Inspect,验证键盘、浅深色、失焦、隐藏重开、停靠布局恢复以及停用/关闭过程取消;Windows 必须拒绝 ui 3,后续独立适配并验收。原生产品视觉验收 pending。 已在真实 DEBUG 预览中使用磁盘安装示例验证节点加载、展开、Inspect、右键动作、方向键及隐藏重开恢复;修复原生入口初始为空时未出现内容的问题,增加完整父容器挂载回归。完整浅深色、停靠及停用中取消验收仍 pending。 独立 native-ui-lab 验收插件覆盖静态/动态树与列表、左右及底部位置、空数据、600 项长数据、增删改序、动作/数据错误、重复 ID 拒绝和可取消慢动作;运行 Node 验收测试并从打包后的 vendored SDK 再跑一次。完整人工验收由配套 README 指引,仍为 pending。 同一停靠槽在动态树、静态树、列表与空面板间往返切换,确认正确释放窗口会话,不能出现 disabled or already registered;检查标题栏高度、文字、无图标/边框/底线和隐藏动作。 三面板同时挂载,分别隐藏/重开,再全部移除并重建;确认会话所有者销毁后登记清空,旧句柄的释放不影响后续会话。 分隔布局新面板先挂载、旧面板后释放时,同一窗口原子移交会话;旧释放不关闭新句柄,不允许跨插件覆盖登记。 检查原生动作 host 图标的深浅主题工具栏色、原生16pt动作资源和描边,刷新/通用执行兼容回退、面板内悬停显露与面板外隐藏,标题仅用于 tooltip/辅助功能名称。 表单验收:输入中文/Emoji、占位提示与回车提交,选择器键盘/鼠标与 Escape,复选框文字/键盘切换,三类禁用、执行中禁用、所有当前值传入既有函数、隐藏重开及工作区隔离、窄面板滚动和深浅主题;按钮声明复用公共样式。 栅格验收:宽面板每行两个字段,跨列布局能力正确对齐,窄于640pt内容宽度回到单列,变宽恢复;不丢字段值/焦点、不水平溢出,旧无layout表单保持单列。 0.3.0验收:提示悬停/固定/键盘、搜索输入/清空/回车、多行编辑与按钮提交、分段选择与数值边界、主次按钮正常/禁用和content/fill宽度、参数类型/恢复/忙碌/取消/错误;运行公共按钮深浅外框像素回归。 0.3.1验收:现有设置/市场/Git/数据库搜索和插件的完整公共入口一致,清空及外部焦点不回归,禁用输入/按钮和忙碌状态下i提示可悬停/点击/键盘打开,值和动作仍保持禁用。 0.3.2:主次文字按钮复用preview统一样式,实际28pt高度、无外圈留白、短/长文案自然增长和6pt动作间距;窄面板纵向排列;旧fill声明仍可加载但不能拉伸按钮。" +} diff --git a/shared/platform-feature-matrix/features/declarative-plugin-contract.json b/shared/platform-feature-matrix/features/declarative-plugin-contract.json index 39656a25da..a23d456e39 100644 --- a/shared/platform-feature-matrix/features/declarative-plugin-contract.json +++ b/shared/platform-feature-matrix/features/declarative-plugin-contract.json @@ -29,12 +29,31 @@ }, "windows": { "evidence": [ - "shared/contracts/plugins/v1/README.md", - "Plugins/win/Official/PhpSupport/plugin.json" + "windows/tauri/src-tauri/src/plugin_host/mod.rs", + "windows/tauri/src-tauri/src/plugin_host/store.rs", + "windows/tauri/src-tauri/src/plugin_host/scan.rs", + "windows/tauri/src-tauri/src/plugin_host/launch.rs", + "windows/tauri/src-tauri/src/plugin_host/tests.rs", + "windows/tauri/src-tauri/src/plugin_host/install.rs", + "windows/tauri/src-tauri/src/plugin_host/settings.rs", + "windows/tauri/src-tauri/src/plugin_host/run_plans.rs", + "Plugins/win/Official/PhpSupportContract/plugin.json", + "windows/tauri/src-tauri/src/plugin_host/web.rs", + "windows/tauri/src/features/plugins/declarative-plugins-section.tsx", + "windows/tauri/src/features/plugins/declarative-plugin-launch.ts", + "windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts", + "shared/contracts/plugins/v1/fixtures/invalid-manifests.json", + "shared/i18n/messages.json", + "windows/tauri/src/i18n/shared-i18n.test.ts", + "windows/tauri/src-tauri/src/plugin_host/lifecycle.rs", + "windows/tauri/src/features/plugins/declarative-plugin-ui.ts", + "windows/tauri/src/features/plugins/declarative-plugin-ui.test.ts", + "windows/tauri/src/platform/lsp-core-adapter.test.ts" ], "implementationStatus": "partial", "verificationStatus": "pending" }, "owner": "Extensions", - "verification": "API 3 共享契约:cargo test -p lithe-core --lib community_build_plugins、cargo test -p lithe-core --lib build_systems、cargo test -p lithe-core --lib declarative_plugins 及 scripts/verify-shared-contracts.sh,覆盖真实 Maven/Gradle 输出、构建 icon/presentation/category、build.languageImport 的插件选项映射和 ui 2 包内网页清单。Core 校验通过不代表平台宿主已实现;macOS 原生窗口/JDT 接线与可选 WKWebView 分别验收后才由宿主声明对应能力,Windows 仍待接入。macOS:安装 shared/contracts/plugins/v1/fixtures/invalid-manifests.json 中每类错误的包,确认插件管理显示对应错误且不注册模块;合法包启用、禁用无需重启;包内声明的语言服务器可执行文件缺失或为符号链接时拒绝安装;启动后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。API 2:安装 fixtures 中 node-runtime 形态的包,确认宿主发现满足最低版本的 node 并用它启动入口文件,版本过低的候选不被使用;语言服务器的状态写在 /Lithe/PluginState 下而不是插件包内,卸载后该目录被删除。宿主能力:声明语言但未在 hostIntegration.capabilities 中列出运行、测试或调试、也未提供执行提供方的包,对应入口不可用且宿主不运行内置工具;列出后对应入口恢复。Windows:现有扩展清单尚未改为读取该契约,接入时必须通过同一组共享 fixture。 macOS API level 2:安装 Hello Lithe 数据包,确认工具窗口、命令面板、工具栏和菜单通过相同注册表显示;移动数据库与插件窗口、隐藏入口及重排后重启恢复;禁用插件后窗口和命令消失,重新启用保留位置与排序;无第三方原生代码载入。Windows UI adapter 未实现,不能用共享校验通过代替界面验收。 回归:声明与内置/应用动作冲突的合法包在启动时隔离并显示错误;正常退出保留已保存的窗口展开状态,用户主动隐藏才更新该状态;Agent 未启用时可移至底部并恢复入口;首次通过菜单打开 Services 会按需激活数据库。 macOS 图标:宿主语义 ID 与包内 SVG/PNG 在窗口入口、标题栏、工具栏、菜单和命令面板显示;模板着色与品牌原色、亮暗变体、选中/悬停/禁用沿用宿主规范;拒绝未知图标 ID、样式覆盖、包外路径、符号链接、脚本和外部引用;验证资源字节与 bundle 文件清单不变。 原生回归覆盖真实注册窗口标题栏、主工具栏活动/禁用状态、命令面板键盘选择及同一窗口亮→暗→亮切换;品牌图标不随选中着色,模板图标使用宿主选中文字颜色。 macOS 动作状态:确认窗口入口、工具栏、菜单与命令面板共用可用和勾选状态,激活中命令禁用且 Enter/鼠标均不能触发,窗口入口允许取消;同时打开同一插件的两个窗口只构造一次模块,取消其中一个不影响另一个;激活中禁用再启用,确认无资源残留或晚到贡献,旧动作引用不绕过状态校验。 API level 2 工具窗口初始位置接受 left/bottom/right,Rust 校验、macOS 解码及首次显示位置一致,用户保存布局仍覆盖默认声明。" + "notes": "Windows 宿主 v0 已接入契约 v1 的语言与语言服务器链路:plugin_host 模块负责包存储(app_local_data_dir 下的 plugins 与 plugin-state,bundled 从资源目录只读加载)、启动扫描(经 plugins.declarative.validate 携带 hostCapabilities {languages:1, languageServers:[1,2], serverProcess:1, settings:[1,2], ui:[1,2]},拒绝的包隔离并展示原因)、启动计划解析(packaged 入口固定在包内且拒绝 reparse point 与路径逃逸,runtime/systemCommand 走 PATH 与 LITHE__PATH 约定),语言服务器会话仍由共享 Core 的 lsp.startServer 拉起;shared fixtures 的 invalid-manifests.json 全部 154 个用例在 Windows 二进制内断言同一稳定错误码。M2 已补磁盘安装链(快照、installReview 审批、staging、回滚)与最小管理界面;M3 已补声明式设置(settings/languageServers 2 能力声明,全局+项目级存储,immediate 效果经 workspaceConfiguration 下推)、运行/测试计划命令与契约版 PHP bundled 包(随安装器分发,validate/launch/runPlan 端到端测试覆盖)。M4 已补 Web 工具窗口(ui 能力声明提升为 [1,2]):lithe-plugin:// scheme 只服务启用包内普通文件(拒 reparse point、路径逃逸、超 16MiB),响应带 CSP(default-src none、拒 eval/联网/frame)与 nosniff/no-store;window.lithe 桥 v0 提供 context、ui.getState/setState(宿主持有,64KiB 上限)、workspace.openFile 与主题事件,build.*/functions.* 在 Windows 构建系统能力接入前返回 plugin.web.unknownMethod。尚未实现:Worker 扩展清单向契约迁移(Worker 版 PHP 提供 composer scripts 动态运行动作,契约无等价能力,完整迁移推迟;见 Note)、构建系统插件能力与多仓库源市场。决策与切分见 .agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md。 后续安全修复已补插件身份传递、运行中和待启动会话排空、跨窗口包变更确认、完整启动前检查及 ui 1 文本/命令入口;目前 WebView 仍为独立窗口,停靠位置、图标、动作状态及布局持久化未完成。", + "verification": "API 3 共享契约:cargo test -p lithe-core --lib community_build_plugins、cargo test -p lithe-core --lib build_systems、cargo test -p lithe-core --lib declarative_plugins 及 scripts/verify-shared-contracts.sh,覆盖真实 Maven/Gradle 输出、构建 icon/presentation/category、build.languageImport 的插件选项映射和 ui 2 包内网页清单。Core 校验通过不代表平台宿主已实现;macOS 原生窗口/JDT 接线与可选 WKWebView 分别验收后才由宿主声明对应能力,Windows 仍待接入。macOS:安装 shared/contracts/plugins/v1/fixtures/invalid-manifests.json 中每类错误的包,确认插件管理显示对应错误且不注册模块;合法包启用、禁用无需重启;包内声明的语言服务器可执行文件缺失或为符号链接时拒绝安装;启动后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。API 2:安装 fixtures 中 node-runtime 形态的包,确认宿主发现满足最低版本的 node 并用它启动入口文件,版本过低的候选不被使用;语言服务器的状态写在 /Lithe/PluginState 下而不是插件包内,卸载后该目录被删除。宿主能力:声明语言但未在 hostIntegration.capabilities 中列出运行、测试或调试、也未提供执行提供方的包,对应入口不可用且宿主不运行内置工具;列出后对应入口恢复。Windows:宿主 v0 已读取该契约(语言与语言服务器范围,按 hostCapabilities 门控),接入验收必须通过同一组共享 fixture,Windows 宿主测试已对 invalid-manifests.json 全量断言;Worker 扩展清单的迁移未完成,宿主 UI 仅完成文本和命令入口,不能用共享校验通过代替界面验收。 macOS API level 2:安装 Hello Lithe 数据包,确认工具窗口、命令面板、工具栏和菜单通过相同注册表显示;移动数据库与插件窗口、隐藏入口及重排后重启恢复;禁用插件后窗口和命令消失,重新启用保留位置与排序;无第三方原生代码载入。Windows UI adapter 仍缺停靠、图标和动作状态等对齐,不能用共享校验通过代替界面验收。 回归:声明与内置/应用动作冲突的合法包在启动时隔离并显示错误;正常退出保留已保存的窗口展开状态,用户主动隐藏才更新该状态;Agent 未启用时可移至底部并恢复入口;首次通过菜单打开 Services 会按需激活数据库。 macOS 图标:宿主语义 ID 与包内 SVG/PNG 在窗口入口、标题栏、工具栏、菜单和命令面板显示;模板着色与品牌原色、亮暗变体、选中/悬停/禁用沿用宿主规范;拒绝未知图标 ID、样式覆盖、包外路径、符号链接、脚本和外部引用;验证资源字节与 bundle 文件清单不变。 原生回归覆盖真实注册窗口标题栏、主工具栏活动/禁用状态、命令面板键盘选择及同一窗口亮→暗→亮切换;品牌图标不随选中着色,模板图标使用宿主选中文字颜色。 macOS 动作状态:确认窗口入口、工具栏、菜单与命令面板共用可用和勾选状态,激活中命令禁用且 Enter/鼠标均不能触发,窗口入口允许取消;同时打开同一插件的两个窗口只构造一次模块,取消其中一个不影响另一个;激活中禁用再启用,确认无资源残留或晚到贡献,旧动作引用不绕过状态校验。 API level 2 工具窗口初始位置接受 left/bottom/right,Rust 校验、macOS 解码及首次显示位置一致,用户保存布局仍覆盖默认声明。 Windows 安全回归:plugin_host::launch 的版本探测必须在父进程退出、后代继承管道时仍有界结束并清理进程树;web 回归验证真实文档来源与工作区文件目标。 Windows 语言资源整合:声明式插件的运行分组、管理页与安装审查使用共享生成词条;中英文回归验证安装名称/版本、程序数量和启停标签,并确认用户值中的占位符保持字面文本。 Windows 宿主修复回归:在 Core startServer 尚未返回时禁用插件,晚到会话必须先停止再拒绝;即时设置携带 pluginId,Core 接受并下推对应会话。缺失 requiredPackagePaths/runtimeRequirements 或 validationArguments 非零退出时拒绝启动。Hello Lithe 文本窗口、菜单、命令面板和工具栏可见,禁用后全部撤销;停靠、图标、动作状态及持久布局仍待接入,保持 partial。" } diff --git a/shared/platform-feature-matrix/features/declarative-plugin-settings.json b/shared/platform-feature-matrix/features/declarative-plugin-settings.json index 5b32e00571..5dde4cbd23 100644 --- a/shared/platform-feature-matrix/features/declarative-plugin-settings.json +++ b/shared/platform-feature-matrix/features/declarative-plugin-settings.json @@ -21,11 +21,17 @@ }, "windows": { "evidence": [ - "shared/contracts/plugins/v1/README.md" + "windows/tauri/src-tauri/src/plugin_host/settings.rs", + "windows/tauri/src-tauri/src/plugin_host/tests.rs", + "windows/tauri/src/features/plugins/declarative-plugin-settings-editor.tsx", + "shared/contracts/plugins/v1/fixtures/settings-v2.plugin.json", + "windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts", + "windows/tauri/src/platform/lsp-core-adapter.ts", + "windows/tauri/src/platform/lsp-core-adapter.test.ts" ], - "implementationStatus": "missing", - "verificationStatus": "not-applicable" + "implementationStatus": "partial", + "verificationStatus": "pending" }, "owner": "Plugin Platform", - "verification": "macOS:打开设置 › 插件,选中 Java Support,切到“设置”页签,确认显示“最大内存”(默认 1024 MB)和“语言服务器日志级别”(默认全部消息)。把最大内存改成 3072:输入 3072 后点“重启语言服务器”,用 ps 查看 JDT LS 的 java 进程参数,确认 -Xmx3072m 且只出现一次;输入 100 时出现范围提示且没有保存,失焦后回到上一个合法值。把日志级别改成“仅错误”并重启,确认进程参数里是 -Dlog.level=SEVERE。点“恢复默认值”后重启,确认回到 -Xmx1024m 和 -Dlog.level=ALL。退出并重新打开 Lithe,确认修改过的值仍在。卸载并恢复 Java Support,值仍在。Windows:插件契约与设置页尚未接入。" + "verification": "macOS:打开设置 › 插件,选中 Java Support,切到“设置”页签,确认显示“最大内存”(默认 1024 MB)和“语言服务器日志级别”(默认全部消息)。把最大内存改成 3072:输入 3072 后点“重启语言服务器”,用 ps 查看 JDT LS 的 java 进程参数,确认 -Xmx3072m 且只出现一次;输入 100 时出现范围提示且没有保存,失焦后回到上一个合法值。把日志级别改成“仅错误”并重启,确认进程参数里是 -Dlog.level=SEVERE。点“恢复默认值”后重启,确认回到 -Xmx1024m 和 -Dlog.level=ALL。退出并重新打开 Lithe,确认修改过的值仍在。卸载并恢复 Java Support,值仍在。Windows:契约设置页与双层存储已接入,须完成下述实机验证。 Windows:启动契约语言服务器,修改 immediate 设置,确认 lsp.startServer 和即时更新都带独立 pluginId,已启动会话收到 workspace/didChangeConfiguration;禁用后旧会话不能继续接收。设置双层持久化和界面验收仍 pending。" } diff --git a/shared/platform-feature-matrix/features/editor-cursor-history.json b/shared/platform-feature-matrix/features/editor-cursor-history.json new file mode 100644 index 0000000000..450ddc6eb1 --- /dev/null +++ b/shared/platform-feature-matrix/features/editor-cursor-history.json @@ -0,0 +1,32 @@ +{ + "id": "editor-cursor-history", + "area": "编辑器", + "group": "文本编辑", + "capability": "键盘移动后优先撤销光标,并支持独立光标撤销与重做", + "macos": { + "implementationStatus": "partial", + "verificationStatus": "pending", + "evidence": [ + "frontend/editor/src/editor-commands.ts", + "frontend/editor/src/workbench.ts" + ] + }, + "windows": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "frontend/editor/src/editor-commands.ts", + "windows/tauri/src/features/editor/engines/monaco/cursor-aware-undo.ts", + "windows/tauri/src/features/editor/engines/monaco/cursor-aware-undo.test.ts", + "windows/tauri/src/features/editor/components/monaco-editor.tsx", + "windows/tauri/src/features/keymaps/commands/editor-command-actions.ts", + "windows/tauri/src/features/keymaps/commands/command-registry.ts", + "windows/tauri/src/features/keymaps/defaults/default-keymaps.ts", + "windows/tauri/src/features/keymaps/hooks/use-keymaps.integration.test.tsx", + "windows/tauri/src/features/command-palette/constants/navigation-actions.tsx" + ] + }, + "owner": "Editor", + "notes": "复用固定 Monaco 0.55.1 的 cursorUndo/cursorRedo;Windows 在键盘移动后第一次 Ctrl+Z 恢复最近一次光标移动,第二次撤销代码,光标恢复后可直接重做移动。文本或模型变化、鼠标/API 移动和视图失活清除待恢复状态。另提供命令面板、快捷键设置和 Ctrl+U/Ctrl+Shift+U 独立入口。macOS 已加载 Monaco 原生能力与共享动作映射,但未接入该策略和宿主命令入口,保持部分实现。", + "verification": "Windows 输入代码后误按向下箭头,第一次 Ctrl+Z 应回到之前的行列且内容和脏状态不变,第二次才撤销输入,再重做应恢复文本;通过 Ctrl+Z 或独立 Ctrl+U 恢复光标后,直接 Ctrl+Shift+Z 或 Ctrl+Y 应恢复移动,空光标历史不抢占文本重做。连续多次方向键后仅优先回退最近一次。核对 Ctrl+U/Ctrl+Shift+U、选区、多光标、滚动恢复、只读文件、空历史及继续编辑后清空光标历史;切换文件或分屏后不得恢复其他视图的选区。在命令面板搜索光标撤销/重做并执行,修改快捷键后确认生效,普通输入框、查找和终端保持原输入行为。macOS 原生按键与宿主命令入口需分别验收。" +} diff --git a/shared/platform-feature-matrix/features/embedded-viewers.json b/shared/platform-feature-matrix/features/embedded-viewers.json index 444dab401c..ab8e8268ab 100644 --- a/shared/platform-feature-matrix/features/embedded-viewers.json +++ b/shared/platform-feature-matrix/features/embedded-viewers.json @@ -6,10 +6,32 @@ "macos": { "evidence": [ "macos/Sources/Lithe/Views/Editor/HTMLPreviewView.swift", + "macos/Sources/Lithe/Platform/MacOS/HTMLPreviewWebView.swift", + "macos/Sources/Lithe/Platform/MacOS/HTMLPreviewBrowser.swift", + "macos/Sources/Lithe/Platform/MacOS/HTMLPreviewResources.swift", + "macos/Sources/Lithe/Platform/MacOS/HTMLPreviewNavigation.swift", + "macos/Sources/Lithe/Platform/MacOS/HTMLPreviewDialogs.swift", + "macos/Sources/Lithe/Platform/MacOS/HTMLPreviewZoom.swift", + "macos/Sources/Lithe/Platform/MacOS/UI/MacShortcutDetector.swift", + "macos/Sources/Lithe/Platform/MacOS/HTMLPreviewFind.swift", + "macos/Sources/Lithe/Platform/MacOS/HTMLPreviewPrint.swift", + "macos/Sources/Lithe/Platform/MacOS/HTMLPreviewMediaCapture.swift", + "macos/Sources/Lithe/Platform/MacOS/WebMediaCapture.swift", + "macos/Sources/Lithe/Platform/MacOS/HTMLPreviewScrollbars.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" + "macos/Tests/LitheTests/HTMLBrowserOpenerTests.swift", + "macos/Tests/LitheTests/HTMLPreviewBrowserTests.swift", + "macos/Tests/LitheTests/HTMLPreviewResourcesTests.swift", + "macos/Tests/LitheTests/HTMLPreviewNavigationTests.swift", + "macos/Tests/LitheTests/HTMLPreviewZoomTests.swift", + "macos/Tests/LitheTests/HTMLPreviewBrowserHarness.swift", + "macos/Tests/LitheTests/HTMLPreviewFindTests.swift", + "macos/Tests/LitheTests/HTMLPreviewPageShortcutTests.swift", + "macos/Tests/LitheTests/HTMLPreviewPrintTests.swift", + "macos/Tests/LitheTests/HTMLPreviewMediaCaptureTests.swift", + "macos/Tests/LitheTests/WebMediaCaptureTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -25,5 +47,5 @@ "verificationStatus": "pending" }, "owner": "Editor", - "verification": "分别打开 HTML、SVG、图片、PDF 和二进制文件,验证缩放、错误和源文件返回。 两端将 .html 默认打开程序改成文本编辑器,再点击 HTML 浏览器按钮,确认仍打开默认网页浏览器中的磁盘文件;覆盖中文、空格、# 文件名和无默认浏览器失败提示。 在 Windows 的 Web Viewer 标签中浏览几页后,用 Navigate 菜单或 Ctrl+Alt+←/→ 确认先在网页历史中后退/前进,到达网页历史起点后再次后退回到之前的代码位置。" + "verification": "分别打开 HTML、SVG、图片、PDF 和二进制文件,验证缩放、错误和源文件返回。 两端将 .html 默认打开程序改成文本编辑器,再点击 HTML 浏览器按钮,确认仍打开默认网页浏览器中的磁盘文件;覆盖中文、空格、# 文件名和无默认浏览器失败提示。 在 Windows 的 Web Viewer 标签中浏览几页后,用 Navigate 菜单或 Ctrl+Alt+←/→ 确认先在网页历史中后退/前进,到达网页历史起点后再次后退回到之前的代码位置。 在 macOS 的 HTML 预览中验证内嵌浏览器:后退/前进/刷新(Shift 强制刷新)、地址栏输入本地路径和网址、−/+/百分比和 ⌘+/⌘−/⌘0 页面缩放(跳转页面后保持)、内容溢出时常驻纵横滚动条、锚点与相对链接在预览内跳转、module 脚本与 fetch 可读取工作区文件、alert/confirm/prompt 与文件选择弹窗、开发者工具可开关;本地隐藏文件和密钥文件不能被页面读取。Windows 的 iframe 预览尚无对应能力。 在 macOS 的 HTML 预览中继续验证:页面有焦点时按 ⌘F 打开查找条,输入文字后页面内选中并高亮匹配项,⌘G / ⇧⌘G 与 Return / ⇧Return 前进后退并回绕,没有匹配时显示“No matches”,Esc 关闭后键盘回到页面;用默认键位,页面有焦点时按 ⌘F / ⌘G / ⇧⌘G 打开并驱动的是预览的查找条而不是编辑器的查找(应用键位监听器对页面的缩放、查找、打印按键让路);分屏时焦点在编辑器里按 ⌘F / ⌘G / ⌘P 仍由编辑器处理。右键点击链接、图片和页面空白处:可以复制链接地址和检查元素,没有“在新窗口打开”,页面空白处的菜单有 Print… 与 Export as PDF…;⌘P 打开作为窗口 sheet 的打印面板,导出 PDF 得到整页的单页 PDF,取消保存面板不写文件。摄像头和麦克风:在 Info.plist 带 NSCameraUsageDescription 与 NSMicrophoneUsageDescription 的构建里,页面调用 getUserMedia 弹出写明页面名称和设备的确认框(默认按钮是 Don’t Allow),页面每次加载后的第一次调用都会询问(同一页面里再次调用是否再询问取决于 WebKit 是否复用已批准的授权,记录实测结果);允许后地址栏右侧出现红色摄像头 / 麦克风指示,点击可暂停(变灰)或停止,页面里的轨道随之静音或结束;没有这两项说明的构建里 navigator.mediaDevices 不存在,页面拿不到设备也不会弹出系统权限框。Lithe 自己的页面(Markdown 预览、Monaco 编辑器和 diff、发行说明)调用 getUserMedia 一律被拒绝且不弹窗,源码扫描测试保证每个 WKWebView 都有人回答摄像头 / 麦克风请求、授权只出现在 WebMediaCapture.swift。地理位置和剪贴板读取使用 WebKit 自带的确认,未单独验证。 自动回归:./scripts/test-macos.sh --filter HTMLPreview(真实 WKWebView 覆盖查找、打印、导出 PDF、右键菜单、WebAssembly、经真实应用键位监听器的页面快捷键放行,以及 WebKit 模拟摄像头 / 麦克风下的授权、静音与停止),界面尚未在运行的应用里手动验收。" } diff --git a/shared/platform-feature-matrix/features/file-explorer.json b/shared/platform-feature-matrix/features/file-explorer.json index e841a5ea8e..de083479d0 100644 --- a/shared/platform-feature-matrix/features/file-explorer.json +++ b/shared/platform-feature-matrix/features/file-explorer.json @@ -21,7 +21,13 @@ "macos/Sources/LitheCoreContracts/Workspace/DirectoryChangeContracts.swift", "rust/lithe-core/src/project/files.rs", "macos/Sources/Lithe/Models/Workspace/ProjectTreeLoader.swift", - ".agents/notes/implemented/architecture/2026-10-19-lazy-project-tree.md" + ".agents/notes/implemented/architecture/2026-10-19-lazy-project-tree.md", + "rust/lithe-core/src/project/file_name_order.rs", + "macos/Sources/LitheCoreContracts/Workspace/NaturalNameOrder.swift", + "macos/Sources/Lithe/Platform/MacOS/FileSystem/MacWorkspaceSnapshotBuilder.swift", + "macos/Tests/LitheTests/ProjectTreeNameOrderTests.swift", + "shared/fixtures/workspace/file-name-order-v1.json", + ".agents/notes/implemented/bug-fix/2026-10-22-project-tree-natural-name-order.md" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -47,6 +53,6 @@ "verificationStatus": "pending" }, "owner": "Workspace", - "verification": "浏览目录、展开/折叠、打开资源并在文件变更后刷新树。 macOS 文件树、标签页与面包屑共用 TOML 文件图标:亮色使用 IDEA New UI 官方浅底蓝色 T,深色使用深底蓝色 T;检查明暗主题切换后选择对应 SVG,保留原始 16pt 几何与颜色。 点击目录名称后上下键按可见行移动选择,右键展开目录并再次右键进入首个子项,左键折叠或返回显示父项;双击目录名称展开/折叠,单击与修饰键多选保持独立。验证压缩 Java 包、列表首尾、Shift 上下范围选择与自动滚动;方向键不送到编辑器,点击编辑器或转移到搜索输入框后恢复其按键处理。 Windows Project 文件树滚轮与 Git Log 共用 Community Windows 默认 200ms 惯性过渡;核对连续同向、反向、Shift 横向滚动、顶底边界、键盘定位与滑块拖动取消惯性、卸载帧清理和减少动态效果偏好;虚拟树保持可见区呈现,真实 WebView2 实机验收 pending。 macOS 项目文件和目录选中行共用 LitheTheme.Tree 的主题聚焦色与灰色失焦色;点击编辑器、其他工具窗口后失焦,键盘转移到搜索输入框尚未输入时失焦,重新点击树恢复,整个窗口失焦时保持灰色。深浅主题、非默认主题、多选、上下键和右键菜单均需核对;组件像素和焦点回归不能替代完整应用验收。 Windows 在默认及舒适密度下比较 Project 与 Dependencies 树正文,再修改界面字号确认同步;深浅主题检查刷新图标为循环箭头,点击仍刷新目录。 Windows 打开深层文件并折叠祖先,点击定位当前文件,检查展开后滚动到目标行;滚动离开后重复点击同一文件仍定位;编辑器切换页签不得让树自动滚动。延迟目录加载完成后再滚动,旧请求完成不改变新位置;树内搜索和 Dependencies 视图下定位应回到完整 Project 树。 Windows 搜索首次命中和 Enter 切换后复制/剪切应使用当前命中;同时选择父目录、后代与其他文件后右键后代,复制/删除仍处理整个选择且父子只操作一次。原生移动等待期间关闭 Project 或切换工作区,成功项同步原工作区并停止后续项。受控 WSL/远程/POSIX 路径用例保留大小写不同的文件与目录,Windows 本地/普通 UNC 同名目标继续拒绝覆盖;这些用例不代表已开放 WSL/远程功能,真实 WebView2 工作流仍 pending。 Windows 编辑器保持 README 打开时点击目录名称,通过真实 FileExplorerPane、侧栏与缓冲区 store 验证路径同步往返后目录仍被选中,复制/剪切、键盘焦点和 Shift 范围锚点仍属于目录;实际切换编辑文件时单选跟随,明确多选保留。 Windows 批量拖动分别在目录预检、原生移动和原工作区同步等待期间切换 A→B→A,确认旧批次不再派发剩余项,已完成项仍同步原工作区;普通重渲染继续当前批次,旧移动失败不清除新拖动或显示过期错误。 macOS 打开含 .claude/worktrees 等嵌套 Git linked worktree 与 .artifacts 的仓库:项目树、文件列表与搜索索引不包含这些目录,子模块和位于主检出之外的工作树仍显示;嵌套工作树内的文件变化不触发刷新。外部新增/删除普通文件时树原地增删、不重新扫描整个工作区,新目录、压缩包结构变化时回退完整快照;定位当前文件按路径逐级查找,侧栏渲染不再遍历全部文件。 macOS 项目树按需列目录:快照只返回文件清单,目录展开时才列出下一层;检查首次打开大仓库只显示根层,展开、折叠后再展开、源码根下压缩 Java 包与全量构建一致;外部改动未展开目录下的文件不改变树,展开后可见;重新扫描后已展开目录保持展开并显示最新内容;定位当前文件会逐级加载祖先后滚动到目标行;新增或删除文件后先展开目录再处理监听事件,确认搜索等文件库存仍同步;压缩包重列期间发生其它文件增删,完成后库存和树都保留最新结果。", - "notes": "Windows 补齐 Ctrl/Shift 多选、批量复制/剪切/删除、已选集合拖动、Project/Dependencies 切换、定位当前文件、刷新和隐藏。目录名称单击选择,双击或箭头展开;长名称水平滚动。目录标记按工作区持久化,并随移动、重命名和删除迁移;本地 Windows/UNC 按不区分大小写处理,POSIX、WSL 和远程标识保留大小写并隔离工作区。批量移动预检父子重复及重名目标,原生发布拒绝覆盖;真实 WebView2 的拖动与焦点验收 pending。 Windows 树正文统一使用 ui-text-sm(默认 13px),随界面字号设置变化,舒适密度不单独放大 Dependencies 等树行;Project 刷新按钮使用 IDEA refresh.svg。手动定位使用有代次的展开后滚动请求;编辑器切换页签不再自动跟随滚动(autoRevealActiveFileInFileTree 设置已退役);定位清除树内搜索并切回 Project,保持虚拟列表。 搜索导航同步选择与锚点,右键成员判断保留原始选择集合,批量目标再去除父子重复;已完成移动不因面板卸载或工作区切换而漏掉原工作区同步。路径去重与拖动预检按文件系统大小写语义比较,包含 WSL UNC 别名。编辑器活动文件变化时单选同步高亮、复制/剪切目标及范围锚点,明确多选保留;使用同步 Hook 的真实编辑器目标判断切换,选择目录引发的共享侧栏路径往返不改变选择;真实面板与 store 回归覆盖此链路。 批量移动按每次工作区访问识别生命周期,相同根路径的再次访问不能复活旧批次;预检、移动、同步和失败回调共用该守卫。 macOS 快照跳过嵌套在自身仓库中的 linked worktree(.git 文件指向 <主仓库>/.git/worktrees/*)并内置隐藏 .artifacts;项目树由 workspace.listDirectories 按需逐层列出,children 为 nil 表示目录未加载;平铺文件清单 projectFiles 仍完整保留供搜索等功能使用。" + "verification": "浏览目录、展开/折叠、打开资源并在文件变更后刷新树。 macOS 文件树、标签页与面包屑共用 TOML 文件图标:亮色使用 IDEA New UI 官方浅底蓝色 T,深色使用深底蓝色 T;检查明暗主题切换后选择对应 SVG,保留原始 16pt 几何与颜色。 点击目录名称后上下键按可见行移动选择,右键展开目录并再次右键进入首个子项,左键折叠或返回显示父项;双击目录名称展开/折叠,单击与修饰键多选保持独立。验证压缩 Java 包、列表首尾、Shift 上下范围选择与自动滚动;方向键不送到编辑器,点击编辑器或转移到搜索输入框后恢复其按键处理。 Windows Project 文件树滚轮与 Git Log 共用 Community Windows 默认 200ms 惯性过渡;核对连续同向、反向、Shift 横向滚动、顶底边界、键盘定位与滑块拖动取消惯性、卸载帧清理和减少动态效果偏好;虚拟树保持可见区呈现,真实 WebView2 实机验收 pending。 macOS 项目文件和目录选中行共用 LitheTheme.Tree 的主题聚焦色与灰色失焦色;点击编辑器、其他工具窗口后失焦,键盘转移到搜索输入框尚未输入时失焦,重新点击树恢复,整个窗口失焦时保持灰色。深浅主题、非默认主题、多选、上下键和右键菜单均需核对;组件像素和焦点回归不能替代完整应用验收。 Windows 在默认及舒适密度下比较 Project 与 Dependencies 树正文,再修改界面字号确认同步;深浅主题检查刷新图标为循环箭头,点击仍刷新目录。 Windows 打开深层文件并折叠祖先,点击定位当前文件,检查展开后滚动到目标行;滚动离开后重复点击同一文件仍定位;编辑器切换页签不得让树自动滚动。延迟目录加载完成后再滚动,旧请求完成不改变新位置;树内搜索和 Dependencies 视图下定位应回到完整 Project 树。 Windows 搜索首次命中和 Enter 切换后复制/剪切应使用当前命中;同时选择父目录、后代与其他文件后右键后代,复制/删除仍处理整个选择且父子只操作一次。原生移动等待期间关闭 Project 或切换工作区,成功项同步原工作区并停止后续项。受控 WSL/远程/POSIX 路径用例保留大小写不同的文件与目录,Windows 本地/普通 UNC 同名目标继续拒绝覆盖;这些用例不代表已开放 WSL/远程功能,真实 WebView2 工作流仍 pending。 Windows 编辑器保持 README 打开时点击目录名称,通过真实 FileExplorerPane、侧栏与缓冲区 store 验证路径同步往返后目录仍被选中,复制/剪切、键盘焦点和 Shift 范围锚点仍属于目录;实际切换编辑文件时单选跟随,明确多选保留。 Windows 批量拖动分别在目录预检、原生移动和原工作区同步等待期间切换 A→B→A,确认旧批次不再派发剩余项,已完成项仍同步原工作区;普通重渲染继续当前批次,旧移动失败不清除新拖动或显示过期错误。 macOS 打开含 .claude/worktrees 等嵌套 Git linked worktree 与 .artifacts 的仓库:项目树、文件列表与搜索索引不包含这些目录,子模块和位于主检出之外的工作树仍显示;嵌套工作树内的文件变化不触发刷新。外部新增/删除普通文件时树原地增删、不重新扫描整个工作区,新目录、压缩包结构变化时回退完整快照;定位当前文件按路径逐级查找,侧栏渲染不再遍历全部文件。 macOS 项目树按需列目录:快照只返回文件清单,目录展开时才列出下一层;检查首次打开大仓库只显示根层,展开、折叠后再展开、源码根下压缩 Java 包与全量构建一致;外部改动未展开目录下的文件不改变树,展开后可见;重新扫描后已展开目录保持展开并显示最新内容;定位当前文件会逐级加载祖先后滚动到目标行;新增或删除文件后先展开目录再处理监听事件,确认搜索等文件库存仍同步;压缩包重列期间发生其它文件增删,完成后库存和树都保留最新结果。 macOS 项目树同级排序对齐 IDEA 项目视图:目录先于文件,名称按自然顺序(数字按数值、忽略大小写、'-' 与 '_' 相邻);打开含 docs/releases/v0.1.4.md、v0.1.12.md、v0.1.14.md 的仓库,确认 v0.1.4 在 v0.1.12 之前,外部新建 v0.1.5.md 后原地插入到二者之间,与重新扫描的顺序一致。", + "notes": "Windows 补齐 Ctrl/Shift 多选、批量复制/剪切/删除、已选集合拖动、Project/Dependencies 切换、定位当前文件、刷新和隐藏。目录名称单击选择,双击或箭头展开;长名称水平滚动。目录标记按工作区持久化,并随移动、重命名和删除迁移;本地 Windows/UNC 按不区分大小写处理,POSIX、WSL 和远程标识保留大小写并隔离工作区。批量移动预检父子重复及重名目标,原生发布拒绝覆盖;真实 WebView2 的拖动与焦点验收 pending。 Windows 树正文统一使用 ui-text-sm(默认 13px),随界面字号设置变化,舒适密度不单独放大 Dependencies 等树行;Project 刷新按钮使用 IDEA refresh.svg。手动定位使用有代次的展开后滚动请求;编辑器切换页签不再自动跟随滚动(autoRevealActiveFileInFileTree 设置已退役);定位清除树内搜索并切回 Project,保持虚拟列表。 搜索导航同步选择与锚点,右键成员判断保留原始选择集合,批量目标再去除父子重复;已完成移动不因面板卸载或工作区切换而漏掉原工作区同步。路径去重与拖动预检按文件系统大小写语义比较,包含 WSL UNC 别名。编辑器活动文件变化时单选同步高亮、复制/剪切目标及范围锚点,明确多选保留;使用同步 Hook 的真实编辑器目标判断切换,选择目录引发的共享侧栏路径往返不改变选择;真实面板与 store 回归覆盖此链路。 批量移动按每次工作区访问识别生命周期,相同根路径的再次访问不能复活旧批次;预检、移动、同步和失败回调共用该守卫。 macOS 快照跳过嵌套在自身仓库中的 linked worktree(.git 文件指向 <主仓库>/.git/worktrees/*)并内置隐藏 .artifacts;项目树由 workspace.listDirectories 按需逐层列出,children 为 nil 表示目录未加载;平铺文件清单 projectFiles 仍完整保留供搜索等功能使用。 macOS 项目树同级排序由 Rust Core 的 visible_children 与 Swift 的 NaturalNameOrder 共同实现 IDEA FileNameComparator(共享 fixture file-name-order-v1.json);快照 files 与搜索索引按 ChildOrder::Traversal 保持原有的小写排序(Windows 快速打开取前 100 项依赖它),只有 workspace.listDirectories 使用自然顺序。Windows 项目文件树仍使用 localeCompare,Git 文件树已使用同源的 compareGitFileNames,Windows 项目树对齐待后续处理。" } diff --git a/shared/platform-feature-matrix/features/file-open-selected.json b/shared/platform-feature-matrix/features/file-open-selected.json new file mode 100644 index 0000000000..79e7a880ce --- /dev/null +++ b/shared/platform-feature-matrix/features/file-open-selected.json @@ -0,0 +1,32 @@ +{ + "id": "file-open-selected", + "area": "工作区", + "group": "项目浏览", + "capability": "F4 打开焦点文件与搜索位置", + "macos": { + "implementationStatus": "missing", + "verificationStatus": "pending", + "evidence": [ + "macos/Sources/Lithe/Platform/MacOS/UI/ProjectTreeKeyboardCommands.swift" + ] + }, + "windows": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "windows/tauri/src/features/keymaps/hooks/use-file-open-target.ts", + "windows/tauri/src/features/keymaps/commands/command-registry.ts", + "windows/tauri/src/features/keymaps/defaults/default-keymaps.ts", + "windows/tauri/src/features/file-explorer/components/file-explorer-tree.tsx", + "windows/tauri/src/features/file-explorer/components/file-navigator-sidebar.tsx", + "windows/tauri/src/features/global-search/components/global-search-buffer.tsx", + "windows/tauri/src/features/global-search/components/search-excerpt-results.tsx", + "windows/tauri/src/features/global-search/components/search-excerpt-code.tsx", + "windows/tauri/src/features/file-explorer/components/file-explorer-selection.test.tsx", + "windows/tauri/src/features/global-search/components/f4-file-navigation.integration.test.tsx", + ".agents/notes/implemented/bug-fix/2026-10-05-project-tree-selection-and-moves.md" + ] + }, + "owner": "Workspace", + "verification": "Windows WebView2 中在项目树选择文件后按 F4,确认固定打开该文件;目录、空树、失效项、重命名和树搜索输入不误开。内容搜索查询框用方向键选择匹配后按 F4,确认保留一基行列;焦点移到另一文件行或片段后打开该文件自身的位置;同一片段选择第 8 行后 Tab 聚焦第 27 行按钮,F4 应打开第 27 行的匹配列,标题和容器焦点仍打开已选匹配。查询等待、空结果、替换和过滤框、编辑器、终端以及弹窗不得借用旧目标。设置中改绑或禁用打开所选文件,确认默认 F4 随之覆盖;查询框改绑两段式快捷键,确认完成、未匹配按键和超时后重置,替换框不执行,卸载清理计时器;编辑器开启 Vim 后文件区域仍可执行。完成四项修复后的完整 Windows 构建与真实焦点验收前保留 pending;macOS 需补齐同等命令后独立验证。" +} diff --git a/shared/platform-feature-matrix/features/git-branches-remotes.json b/shared/platform-feature-matrix/features/git-branches-remotes.json index e6c9ac295f..47e858ddce 100644 --- a/shared/platform-feature-matrix/features/git-branches-remotes.json +++ b/shared/platform-feature-matrix/features/git-branches-remotes.json @@ -25,7 +25,18 @@ "macos/Sources/Lithe/Core/Rust/RustGitOperations.swift", "macos/Tests/LitheGitModuleTests/GitModuleTests.swift", "macos/Tests/LitheTests/GitReferenceRowsBuilderTests.swift", - "shared/fixtures/git/write.json" + "shared/fixtures/git/write.json", + "macos/Sources/Lithe/Views/Git/GitPushDialog.swift", + "macos/Sources/Lithe/Views/Git/CommitAreaView.swift", + "macos/Sources/Lithe/Core/Rust/RustCoreBridge+GitPush.swift", + "macos/Sources/LitheGitModule/Models/GitPushModels.swift", + "macos/Sources/LitheGitModule/Models/GitWorkspaceCommitModels.swift", + "macos/Sources/LitheGitModule/Application/GitFeatureModel+WorkspaceCommit.swift", + "macos/Tests/LitheTests/GitPushDialogPresentationTests.swift", + "macos/Tests/LitheTests/GitPushReviewBridgeTests.swift", + "rust/lithe-core/tests/git_push.rs", + "rust/lithe-core/src/tests/git_workspace_commit.rs", + ".agents/notes/implemented/bug-fix/2026-10-17-git-push-target-and-upstream-tracking.md" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -67,12 +78,25 @@ "windows/tauri/src/ui/dropdown.tsx", "windows/tauri/src/ui/dropdown.test.tsx", "windows/tauri/src/ui/idea-notification.tsx", - "windows/tauri/src/ui/idea-notification.test.tsx" + "windows/tauri/src/ui/idea-notification.test.tsx", + "windows/tauri/src/features/git/components/git-push-dialog.tsx", + "windows/tauri/src/features/git/components/git-push-target-prompt.tsx", + "windows/tauri/src/features/git/components/git-push-target-prompt.test.tsx", + "windows/tauri/src/features/git/api/git-push-api.ts", + "windows/tauri/src/features/git/api/git-push-api.test.ts", + "windows/tauri/src/features/git/components/git-commit-panel.tsx", + "windows/tauri/src/features/git/components/git-commit-panel.test.tsx", + "windows/tauri/src/features/git/components/git-commit-panel-push-target.test.tsx", + "windows/tauri/src/features/git/services/git-push-target.ts", + "windows/tauri/src/features/git/services/git-workspace-commit-workflow.ts", + "windows/tauri/src/features/git/services/git-workspace-commit-workflow.test.ts", + "rust/lithe-core/tests/git_push.rs", + "rust/lithe-core/src/tests/git_workspace_commit.rs" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Git", - "verification": "创建、切换、合并分支并查看标签和远程,确认冲突与认证失败可恢复。 macOS 悬停分支弹窗内被截断的行,确认沿原行位置展开完整分支名与 upstream,延续选中底色和行高;完整可见的行不展开,离开或打开动作菜单后展开层消失。滚动列表且鼠标位置不动时,高亮跟随当前鼠标下的行,离开的行清除 hover,不出现多行残留选中底色;已打开动作菜单的行仍保持高亮。顶部项目及分支按钮保持箭头光标,顶部当前分支不显示帮助浮层。 macOS 检查右侧真实上游列靠右显示、末尾省略且选中后沿用行文字颜色及普通字重;悬停直接显示沿原行展开的非激活子窗口,保留原行位置、选择底色、普通字重与高度,转交点击及滚轮,父弹窗关闭时清理,显示完整名称,不额外插入分组前缀,领先/落后提交数与 Git 一致(大于 99 显示 99+),无上游时不猜测远端。确认搜索框固定,其下操作项和全部分支分组共用一个滚动区,缩小弹窗时操作项也能滚出视口;拖动分支弹窗右边缘调整宽度、右上角/右下角无需先点击即分别使用东北—西南/西北—东南的斜向调整光标并调整宽高,固定另一侧边界,连续事件合并且不逐事件同步重绘,检查鼠标光标、内容刷新保留尺寸、关闭重开及重启后按项目恢复,切换项目互不覆盖,并检查屏幕边界及最小尺寸,普通菜单不出现拖动入口。 macOS 分支弹窗或 Git 已保存更改列表打开且鼠标悬停在被截断的行上时,反复切换到其他应用再切回、让窗口失焦/激活,应用不崩溃(issue #1277),悬停展开层仍只有一个且在布局完成后刷新;长分支列表快速滚动保持流畅,行内容、选中底色与展开层不丢失。 两端在仅抓取 preview、其他分支已有 remote/merge 配置和远端引用的仓库中确认上游名称与领先/落后计数仍可见,Recent 与 Local 一致,查询不修改 fetch 配置;同名远端但未设置跟踪时仍不显示上游。 Windows Git Log 普通 Fetch 和 Fetch 选项执行后保持当前页签,底部状态栏显示等待或真实阶段进度;连接等待、认证、多个远程切换不沿用旧百分比,成功、失败、取消和预检查失败均收起;Console 仍保留命令与输出。核对中英文、明暗主题与窄窗口。 Windows Git Log 普通 Checkout 不再二次确认,仍阻止覆盖本地修改;Update Selected 按跟踪分支配置启用,不依赖缓存 behind。当前分支后台 Merge,未检出分支原地快进,其他 worktree 内仅快进;不打开 Pull 弹窗,分叉/脏工作区保留本地内容并报告失败,仓库或工作区切换后迟到结果不得刷新或解锁新请求。 Windows Update Selected 在 Fetch 等待期间及预检后切换目标 worktree 的分支,或分离 HEAD,应返回 state-changed,不执行新分支的 Pull;Core 在执行前校验完整 expectedBranch,省略时保留兼容行为。 带 expectedBranch 的后台更新整合已 Fetch 的固定 upstream 提交,不再次执行 pull/fetch;验证 Merge、仅快进和 Rebase 策略,普通 Pull 省略字段时保持原行为。 Windows 顶部、Commit 和 Project 文件树由同一个仓库状态表生成展示;关闭 Commit 后外部 pull、checkout 和暂存仍刷新分支名、领先/落后及文件状态,已同步分支的 incoming 箭头消失。覆盖多仓库选择、根目录文件路径、快速切仓库/工作区与旧请求迟到,查询失败保留成功快照,草稿不丢失;元数据监听覆盖已发现仓库并在卸载时全部释放。真实 Windows 交互验收 pending。 两端 Fetch 后从其他分支及已处于目标本地分支时 Checkout Remote,确认 HEAD、本地分支和文件内容更新到所选已 Fetch 节点,重复操作成功且不生成合并提交。缺少本地分支时创建并配置跟踪;本地独有提交在切换或自动 stash 前被拒绝,Force 不丢弃提交;阻塞的工作区、暂存区、未跟踪文件和其他工作树占用保持原状态。Smart Checkout 更新后恢复本地修改,失败后界面刷新真实仓库状态;普通本地 Checkout 与 Checkout and Rebase 保持原行为。 Windows Git Log 的本地及远程 Checkout 成功后不再弹出成功提示(与 IDEA 一致,分支标签直接切换),失败时仍显示错误原因。 Windows Git Log 与分支切换器 Checkout 失败(例如目标分支已被其他 worktree 占用)时,右下角显示需手动关闭的 IDEA BalloonError 红色错误气泡,标题为“无法签出 {分支}”/“Could not checkout {branch}”,正文为去掉 fatal:/error: 前缀的 Git 输出;中英文、明暗主题下核对图标与换行。 macOS 分支弹窗和 Git Log 分支右键菜单中,有上游的未检出本地分支启用“更新”,原地快进且不切换 HEAD;本地有上游没有的提交时提示分叉、不强制更新;被其他 worktree 检出时提示到该 worktree 更新且不执行 Fetch;无上游的分支保持禁用;当前分支仍走原有拉取流程。在中文 Git 输出环境下核对提示。 macOS 分支弹窗按 Community 的 8pt 四边和角落沿边 16pt 规则命中;四边仅调整对应轴,四角同时调整两轴,反向边固定。水平/垂直使用 AppKit 双向光标,对角线直接使用 SDK JavaRuntimeSupport 的 javaResizeNE/NW/SE/SW,与 JBR 映射同源;检查原始图像、尺寸和热点一致。检查中央的搜索输入、滚动与分支点击不被边缘层拦截,移回中央释放调整光标,拖动中合并更新、松开才保存,内容刷新保持释放位置。 Windows Git Log 右键分支选择“新建分支”,弹窗对齐 IDEA 的 Create Branch from 对话框:标题为“从 {引用} 创建分支”,单行“分支名称”输入、创建/取消按钮,无 Checkout 与 Overwrite 选项,创建后固定检出新分支;初始名称本地分支沿用原名、远程分支去掉远程前缀,打开即全选,按一次退格即可清空;输入时空格转为连字符并丢弃非法字符且光标位置保持。打开及输入期间不做重名检测,点击创建后若名称为空,或与现有本地分支、远程分支、本地分支目录或 HEAD 冲突,才在输入框下方显示错误并禁用创建,此后错误随输入实时更新;创建按钮在前、取消按钮在后,创建为实心主色;Git 失败时错误显示在弹窗内且可重试,成功后不弹出提示,直接刷新历史。核对中英文与明暗主题。 Windows Git Log 右键本地分支选择“删除分支”不再弹出确认框,直接删除并在右下角弹出 IDEA 样式的通知(信息图标、粗体“已删除分支:”加分支名、下一行蓝色“恢复”文字链接、右上角关闭按钮,约 15 秒内有效,核对明暗主题与长分支名换行);点击后在原提交上重建该分支且不切换,原上游仍存在时恢复跟踪;分支重建成功但上游跟踪恢复失败时提示“分支已恢复,但上游跟踪恢复失败”,不得误报为整体失败,仍刷新历史。工作区切换后迟到的恢复不得刷新新工作区。分支未完全合并、被其他工作树占用等 Core 拒绝删除的情况显示 Git 给出的原因,不弹出恢复提示。远程分支和标签的删除保持原有确认。 Windows 所有右键菜单的弹出位置对齐 IDEA(Swing JPopupMenu):菜单左上角落在点击点,靠下或靠右放不下时整体平移回窗口内,不翻到光标上方或左侧;子菜单首行与父行对齐,右侧放不下时翻到左侧,纵向只平移。覆盖 Git Log 分支树、提交表、侧边栏、标签页、终端、文件树及通知/扩展/GitHub 等自绘菜单,并检查窗口很矮时菜单可滚动。", - "notes": "2026-10-06:用户确认 PR #1083 涉及的 Windows 功能改动均已完成实机测试,未发现问题;实机验收依据为用户反馈。 2026-10-08:用户确认本次远程 Checkout 更新和 Windows 成功提示修复的本地测试没有问题;作为本次 Windows 改动的用户验收依据,未宣称完成其他矩阵场景或 macOS 验收。" + "verification": "创建、切换、合并分支并查看标签和远程,确认冲突与认证失败可恢复。 macOS 悬停分支弹窗内被截断的行,确认沿原行位置展开完整分支名与 upstream,延续选中底色和行高;完整可见的行不展开,离开或打开动作菜单后展开层消失。滚动列表且鼠标位置不动时,高亮跟随当前鼠标下的行,离开的行清除 hover,不出现多行残留选中底色;已打开动作菜单的行仍保持高亮。顶部项目及分支按钮保持箭头光标,顶部当前分支不显示帮助浮层。 macOS 检查右侧真实上游列靠右显示、末尾省略且选中后沿用行文字颜色及普通字重;悬停直接显示沿原行展开的非激活子窗口,保留原行位置、选择底色、普通字重与高度,转交点击及滚轮,父弹窗关闭时清理,显示完整名称,不额外插入分组前缀,领先/落后提交数与 Git 一致(大于 99 显示 99+),无上游时不猜测远端。确认搜索框固定,其下操作项和全部分支分组共用一个滚动区,缩小弹窗时操作项也能滚出视口;拖动分支弹窗右边缘调整宽度、右上角/右下角无需先点击即分别使用东北—西南/西北—东南的斜向调整光标并调整宽高,固定另一侧边界,连续事件合并且不逐事件同步重绘,检查鼠标光标、内容刷新保留尺寸、关闭重开及重启后按项目恢复,切换项目互不覆盖,并检查屏幕边界及最小尺寸,普通菜单不出现拖动入口。 macOS 分支弹窗或 Git 已保存更改列表打开且鼠标悬停在被截断的行上时,反复切换到其他应用再切回、让窗口失焦/激活,应用不崩溃(issue #1277),悬停展开层仍只有一个且在布局完成后刷新;长分支列表快速滚动保持流畅,行内容、选中底色与展开层不丢失。 两端在仅抓取 preview、其他分支已有 remote/merge 配置和远端引用的仓库中确认上游名称与领先/落后计数仍可见,Recent 与 Local 一致,查询不修改 fetch 配置;同名远端但未设置跟踪时仍不显示上游。 Windows Git Log 普通 Fetch 和 Fetch 选项执行后保持当前页签,底部状态栏显示等待或真实阶段进度;连接等待、认证、多个远程切换不沿用旧百分比,成功、失败、取消和预检查失败均收起;Console 仍保留命令与输出。核对中英文、明暗主题与窄窗口。 Windows Git Log 普通 Checkout 不再二次确认,仍阻止覆盖本地修改;Update Selected 按跟踪分支配置启用,不依赖缓存 behind。当前分支后台 Merge,未检出分支原地快进,其他 worktree 内仅快进;不打开 Pull 弹窗,分叉/脏工作区保留本地内容并报告失败,仓库或工作区切换后迟到结果不得刷新或解锁新请求。 Windows Update Selected 在 Fetch 等待期间及预检后切换目标 worktree 的分支,或分离 HEAD,应返回 state-changed,不执行新分支的 Pull;Core 在执行前校验完整 expectedBranch,省略时保留兼容行为。 带 expectedBranch 的后台更新整合已 Fetch 的固定 upstream 提交,不再次执行 pull/fetch;验证 Merge、仅快进和 Rebase 策略,普通 Pull 省略字段时保持原行为。 Windows 顶部、Commit 和 Project 文件树由同一个仓库状态表生成展示;关闭 Commit 后外部 pull、checkout 和暂存仍刷新分支名、领先/落后及文件状态,已同步分支的 incoming 箭头消失。覆盖多仓库选择、根目录文件路径、快速切仓库/工作区与旧请求迟到,查询失败保留成功快照,草稿不丢失;元数据监听覆盖已发现仓库并在卸载时全部释放。真实 Windows 交互验收 pending。 两端 Fetch 后从其他分支及已处于目标本地分支时 Checkout Remote,确认 HEAD、本地分支和文件内容更新到所选已 Fetch 节点,重复操作成功且不生成合并提交。缺少本地分支时创建并配置跟踪;本地独有提交在切换或自动 stash 前被拒绝,Force 不丢弃提交;阻塞的工作区、暂存区、未跟踪文件和其他工作树占用保持原状态。Smart Checkout 更新后恢复本地修改,失败后界面刷新真实仓库状态;普通本地 Checkout 与 Checkout and Rebase 保持原行为。 Windows Git Log 的本地及远程 Checkout 成功后不再弹出成功提示(与 IDEA 一致,分支标签直接切换),失败时仍显示错误原因。 Windows Git Log 与分支切换器 Checkout 失败(例如目标分支已被其他 worktree 占用)时,右下角显示需手动关闭的 IDEA BalloonError 红色错误气泡,标题为“无法签出 {分支}”/“Could not checkout {branch}”,正文为去掉 fatal:/error: 前缀的 Git 输出;中英文、明暗主题下核对图标与换行。 macOS 分支弹窗和 Git Log 分支右键菜单中,有上游的未检出本地分支启用“更新”,原地快进且不切换 HEAD;本地有上游没有的提交时提示分叉、不强制更新;被其他 worktree 检出时提示到该 worktree 更新且不执行 Fetch;无上游的分支保持禁用;当前分支仍走原有拉取流程。在中文 Git 输出环境下核对提示。 macOS 分支弹窗按 Community 的 8pt 四边和角落沿边 16pt 规则命中;四边仅调整对应轴,四角同时调整两轴,反向边固定。水平/垂直使用 AppKit 双向光标,对角线直接使用 SDK JavaRuntimeSupport 的 javaResizeNE/NW/SE/SW,与 JBR 映射同源;检查原始图像、尺寸和热点一致。检查中央的搜索输入、滚动与分支点击不被边缘层拦截,移回中央释放调整光标,拖动中合并更新、松开才保存,内容刷新保持释放位置。 Windows Git Log 右键分支选择“新建分支”,弹窗对齐 IDEA 的 Create Branch from 对话框:标题为“从 {引用} 创建分支”,单行“分支名称”输入、创建/取消按钮,无 Checkout 与 Overwrite 选项,创建后固定检出新分支;初始名称本地分支沿用原名、远程分支去掉远程前缀,打开即全选,按一次退格即可清空;输入时空格转为连字符并丢弃非法字符且光标位置保持。打开及输入期间不做重名检测,点击创建后若名称为空,或与现有本地分支、远程分支、本地分支目录或 HEAD 冲突,才在输入框下方显示错误并禁用创建,此后错误随输入实时更新;创建按钮在前、取消按钮在后,创建为实心主色;Git 失败时错误显示在弹窗内且可重试,成功后不弹出提示,直接刷新历史。核对中英文与明暗主题。 Windows Git Log 右键本地分支选择“删除分支”不再弹出确认框,直接删除并在右下角弹出 IDEA 样式的通知(信息图标、粗体“已删除分支:”加分支名、下一行蓝色“恢复”文字链接、右上角关闭按钮,约 15 秒内有效,核对明暗主题与长分支名换行);点击后在原提交上重建该分支且不切换,原上游仍存在时恢复跟踪;分支重建成功但上游跟踪恢复失败时提示“分支已恢复,但上游跟踪恢复失败”,不得误报为整体失败,仍刷新历史。工作区切换后迟到的恢复不得刷新新工作区。分支未完全合并、被其他工作树占用等 Core 拒绝删除的情况显示 Git 给出的原因,不弹出恢复提示。远程分支和标签的删除保持原有确认。 Windows 所有右键菜单的弹出位置对齐 IDEA(Swing JPopupMenu):菜单左上角落在点击点,靠下或靠右放不下时整体平移回窗口内,不翻到光标上方或左侧;子菜单首行与父行对齐,右侧放不下时翻到左侧,纵向只平移。覆盖 Git Log 分支树、提交表、侧边栏、标签页、终端、文件树及通知/扩展/GitHub 等自绘菜单,并检查窗口很矮时菜单可滚动。 分支跟踪名称不同的远端分支(例如从 origin/main 新建分支后手动设置 upstream)时:Windows 推送弹窗必须先让用户在“发布到同名分支”和“推送到 upstream”之间选择,选择后弹窗展示的目标分支和提交列表与最终远端一致;“提交并推送”在写入提交前一次性询问所有异名仓库,取消后不产生提交;普通同名推送不出现任何询问。macOS 每次推送(分支列表与顶部分支弹窗)都先打开推送弹窗,顶部显示“本地分支 → 远端/分支”,左侧列出将推送的提交、右侧显示所选提交的改动文件,点击“推送”前不写远端;异名上游时“推送”保持禁用,直到在“发布到同名分支”和“推送到 upstream”之间选择,选择后目的地与提交列表随之更新,远端 main 不变;弹窗打开后仓库又有新提交时推送被拒绝并给出原因。macOS “提交并推送”始终先出审阅单列出每个仓库的目的地,异名仓库在审阅单内选择,未选择时“继续”不可用,取消后不产生提交。核对中英文、明暗主题与窄窗口。", + "notes": "2026-10-06:用户确认 PR #1083 涉及的 Windows 功能改动均已完成实机测试,未发现问题;实机验收依据为用户反馈。 2026-10-08:用户确认本次远程 Checkout 更新和 Windows 成功提示修复的本地测试没有问题;作为本次 Windows 改动的用户验收依据,未宣称完成其他矩阵场景或 macOS 验收。 2026-10-17:issue #1272 推送目标修复。Core 与 Windows 本地自动化已覆盖:从 origin/main 新建分支不继承 upstream;异名目标未确认时拒绝推送;预览与推送共用同一个按选择解析的目标,确认后 upstream 被改指其他分支时已审阅的推送被判过期;--force-with-lease 作用在所选 upstream 分支;提交并推送在写入提交前由 Core 按仓库提问,重试时恢复并重新校验;省略 targetKind 的旧调用方仍可推送。 2026-10-22:macOS 补上界面。每次推送先打开 IDEA 式推送弹窗(目的地、将推送的提交、所选提交的改动文件),异名上游需先选择;“提交并推送”先出审阅单并就地选择。原生层与真实 Core 的衔接由 GitPushReviewBridgeTests 覆盖,相关套件经 scripts/test-macos.sh 串行运行通过;完整套件在本机并行运行时有与本变更无关的超时类失败。SwiftUI 窗口本身尚未在真机上目视验证。两端真实 Windows/macOS 交互验收仍为 pending。" } diff --git a/shared/platform-feature-matrix/features/git-diff-review.json b/shared/platform-feature-matrix/features/git-diff-review.json index d9f349860b..0c91565bf2 100644 --- a/shared/platform-feature-matrix/features/git-diff-review.json +++ b/shared/platform-feature-matrix/features/git-diff-review.json @@ -50,5 +50,5 @@ }, "owner": "Git", "verification": "验证新增、删除、重命名、二进制和多文件 Diff 的展示与定位;从源代码管理打开已修改和未跟踪文件的工作区 Diff 后保持静止,确认 Diff 不会自动关闭,且只在文件不再出现在 Git 状态中时关闭。 调整下方 Git Log 或终端高度时,上方 Diff 的全文测量、折叠和双栏规划不随尺寸重复计算;核对 GitCommitDiffReviewView、DiffReviewView 和共享 DiffPaneView 的新增/删除单栏、双栏、搜索/折叠/差异选择及收缩恢复行为。 长 Diff 双栏只创建可见区域附近的行;用 1,200 行夹具检查初始视口、滚动到末行和连续改变可见高度。 macOS 原生双栏 Diff 按 IDEA Islands/Darcula 配色显示新增绿、删除灰、修改蓝;JetBrains Mono Regular 13pt 与 22pt 行高;原版/新版行号分别固定在中央两侧,单侧增删后仍对应各自源文件行号。核对深浅外观、中央 1pt 分隔线、横向滚动和非等宽拖动后的连接带位置;单栏增删同步使用该配色。 双栏代码通过原生 NSTextView 连续选中,深色/浅色光标行号分别使用 A1A3AB/767A8A;整行修改背景按 IDEA 混合淡化,词内使用原修改色。分隔条允许先隐藏代码、再隐藏行号,双向完全收起后可恢复;窄窗口无负尺寸。1,200 行组件回归连续调整 60 次,检查文字存储不重建且固定容器排版不失效、透明定位/连接带不拦截代码点击。组件绘制耗时仅作诊断,完整应用的连续拖动视觉验收仍 pending。 历史提交 Diff 两行顶部显示紧凑工具栏与父/当前提交短哈希,左版本附中间省略的路径;隐藏补丁区块头,设置菜单保留词内高亮和关闭。切换单栏后版本标题上下排列,正文按旧/新顺序显示修改,左侧双源行号,仍可连续选中/复制。两侧滚动条按各自完整代码流标出增删改,点击跳至变更;纵向按视口三分之一锚点映射已有差异边界,匹配行连接带随滚动变平。检查真实原生 clip 偏移、标记颜色/点击、通知卸载和文字缓存不重建,保留共享折叠/区块操作。 历史提交页顶部按 Islands 独立工具栏层验证:40pt 内容、上 2pt/左右 6pt 留白与 6pt 圆角;版本标题四边 6pt 内边距、29pt 行高、1pt TEARLINE 底边;路径 13pt Regular/#73767C。布局选择由一个父外框及选中项边框绘制,检查未选项不重复描边、实际深浅像素和原生点击切换。 Windows 实现未改;完整工作区视觉与连续滚动性能验收保持 pending。 macOS 连续修改加多行插入、反向删除使用同一个左右源范围配对;纯插入/删除仍为绿/灰。检查行号、连接带、标记与整块正文一致,词内单侧变化使用增删色,滚动后空范围边界保留 1pt 线。 历史提交工具栏复用现有打开文件、当前提交内文件导航和 DiffCollapse,首尾/单文件箭头及删除文件打开入口禁用;双栏/单栏折叠、单区域展开和切换文件状态清理。版本标题使用真实布局高度,比较实际原生代码行转换到连接带的坐标,初始及滚动均无 29pt 偏移;中央宽度 24pt,源行号按真实字型测量并共享最大宽度,右数字不进入折叠空白区。Diff 错误条使用 14pt 宽标记(右 x=4、左 x=0),末行端点和 2pt 最小高度,滑块覆盖与点击定位保留。 Diff 正文和连接带不得绘制到工具栏或左右侧栏,平直连接带与行号区使用相同的排他行末端;差异导航不绘制蓝色竖线。只保留右侧纵向 thumb,左侧保留变更标记;真实窗口鼠标拖动右侧和底部横向 thumb 均改变代码偏移。嵌套工作区中将中央分隔条拖至两端后再次拉回,确认命中区域留在 Diff 内且外层面板不移动。多行调用重排按整段左右文本计算内部高亮,避免逐行配对造成绿色/灰色碎片;保留纯单侧内部变化颜色。 核对相邻等行数修改只高亮各行变化参数,正文保持淡色、中央保持原修改色;深浅主题下以 0/0.25/0.5/0.75pt 偏移验证相邻行背景无细缝。 中央连接带端点与行号填充采用相同设备像素覆盖范围;1×/2× 与小数滚动/拖动坐标下检查平直块跨中央区域无边框或接缝,保留曲线抗锯齿。 从源代码管理打开已修改和未跟踪文件的工作区 Diff 后保持静止,确认 Diff 不会自动关闭,且只在文件不再出现在 Git 状态中时关闭;在 Windows Commit、Git Log 及统一 Diff 中确认完整上下文不折叠,没有 hidden lines;核对中央行号两侧留白与竖线、Commit 动作区比 Git Log 更宽且不遮挡数字或代码,每个修改块仍可单独暂存;在 Windows 未暂存单文件 Diff 中点击某个修改块的“回滚”,2026-10-17 起与文件列表及整仓库丢弃一致遵守「丢弃前确认」设置:开启时先确认再恢复该块且暂存区不变,关闭时直接执行,最后一块回滚后文件从状态中消失时 Diff 关闭;暂存区、历史、分支比较与新增/未跟踪文件不显示回滚按钮。 在 Windows 从提交面板点击未提交文件,确认差异标签标题为「提交: 文件名」(英文为 Commit: )并显示差异图标,悬停显示完整标题;在提交面板切换到另一个文件时同一标签标题随之更新;从编辑器 gutter 打开整文件差异时标题保持「未提交的更改」。 macOS 在工作区或目录 Diff 加载中及加载完成后,分别通过 ⌘W 和关闭按钮退出,确认补丁和加载状态被清理,迟到结果不恢复预览,已有暂存更改时 AI 提交信息按钮恢复可用,Git 模块不因已关闭的 Diff 被判定为忙碌;关闭后台历史标签仍保留当前工作区预览。 审核回归另核对无末尾换行、CRLF-only 和内容伴随 CRLF:不能用有损行投影显示已包含或写入块,整文件暂存及实际提交的 blob 字节必须与磁盘一致。暂存 B 后磁盘恢复 HEAD 的 a,仍应显示暂存版本和文件框;二进制 A→B 暂存后磁盘 C,文件框显示部分包含,点击包含剩余内容。Unix 在公共可遍历父目录验证临时上下文没有组/其他用户访问权限,Drop 清理 HEAD/index;本机 Windows 权限执行范围与 macOS CI 断言应分别记录。 Windows Git Log 单文件和 Commit 文件 Diff 的词级高亮、显示空白符、统一/并排切换均在左侧,最右侧统计当前文件差异,区分无差异/单数/复数;Commit 勾选、部分包含、取消及刷新后 included 数同步变化,全部包含仍显示数量;加载、切换文件及图片/二进制不显示文本差异统计。核对中英文、明暗主题、窄窗口和按钮选中/禁用状态。 Windows 单文件 Diff 上箭头在首处继续进入上一文件最后一处,下箭头在末处进入下一文件首处,集合首尾不循环、加载中禁用;Commit 文件列表随跨文件导航更新选中行并展开祖先、滚动到可见位置,保留编辑器焦点和 include 状态;同文件刷新、历史 Diff、无关仓库和迟到读取不扰动列表。 Windows Git Log 鼠标或上下键选择提交后,异步 Diff 自动定位第一处改动但保持提交列表键盘焦点;并排与统一预览均不调用初始 focus。双击/Enter 打开完整 Diff、主动差异跳转及跨文件导航仍进入编辑器。 macOS Commit/Shelf 标题栏创建补丁按钮使用共享工具栏按钮样式,悬停保持箭头;打开补丁弹窗检查共享浮层外观与主次按钮,列表选择、生成预览、关闭及应用确认流程保持不变。 macOS 标题栏仅显示创建补丁入口,不额外显示应用补丁按钮。创建和应用补丁表单使用 Commit 共享标题栏对话框、背景、留白与主次按钮;打开或取消原生文件选择器以及内部下拉菜单均不得清空表单。窗口打开时后台主窗口不能触发另一编辑,关闭后恢复交互;标题和内容继承同一应用语言,主窗口关闭仍释放面板与监听。 macOS 共享居中弹窗需在单次点击后立即显示,关闭和重新打开无需其他交互;重复点击不得新增窗口,取消或卸载后不得显示过期弹窗。原生 SwiftUI 状态回归由 LitheCenteredPopupTests 覆盖。 macOS Commit 与 Git Log/Stash 共用 RepositoryDiffView;核对搜索、空白比较、整文件及块级暂存/取消暂存/丢弃仍可用,新建/删除文件使用新版单栏,历史入口无写入动作。旧面板与无调用渲染组件删除;真实工作区视觉验收仍需执行。 字节保真回退:CRLF(autocrlf=false/true、.gitattributes eol=crlf)与无末尾换行文件在预置非空暂存的仓库中逐块回退,目标块恢复为暂存区对应内容、未涉及区域原样保留、index 内容与 staged diff 前后不变;filter 或 working-tree-encoding 属性不安全的文件继续整文件降级。 块级回退确认:「丢弃前确认」开启时,预览面与 Monaco 块箭头均先弹确认,取消不写、确认后按打开时目标执行;确认期间同文件快照刷新后确认返回时不写入并提示选中内容已变化,切换文件或仓库后确认返回时不写入且不在新目标显示提示;重复点击只保留一个确认;设置关闭时直接执行。", - "notes": "Windows Commit 单文件预览复用 Repository Diff 的新页面:中央行号、动态连接带、独立轨道与对应行同步,右侧块级包含框、左侧回退双箭头和手动/事件刷新。点击文件保持 HEAD 到工作区的完整比较,块包含通过真实暂存/取消暂存同步到文件列表;提交只使用已包含块的暂存内容。相邻修改不因 Git hunk 上下文合并而一起包含。回退只撤销未暂存部分并保留已有包含内容,点击双箭头按「丢弃前确认」设置执行(2026-10-17 规则变更,此前为直接执行),写入前重新验证快照;块写入期间提交保护,刷新保留滚动位置。核对新增/删除单标题、没有外圈与增删符号、轨道背景色一致、明暗主题和统一 gutter widget;真实临时仓库与有界组件回归已覆盖,完整 WebView2 的勾选、刷新及提交视觉/原生验收仍 pending。gutter 入口的整文件差异保持原标签。 修复状态映射备用入口和旧预览缺少工作区目标导致块控件缺失;按原显示路径解析嵌套仓库并补全元数据,迟到解析不得覆盖新比较。中央行号取消重复内边距并固定按钮左缘,关闭只读整行光标底色及内容宿主 title 气泡。真实 Chromium 中加载生产 React/Monaco,核对 Commit/Log 字体与色值、恢复缺失目标、块包含/取消/回退和滚动时控件及连接带位置;IPC 使用可控返回,不替代原生 WebView2 验收。 修复原生 Git 监听与只读快照的刷新循环:快照索引改用系统临时目录,不再创建 .git 子目录;保留外部变更监听与快照失效保护。真实 Git 回归检查读取前后仓库元数据目录时间及真实索引字节不变。 2026-10-05 完整原生 WebView2 独立配置验收通过 Commit 文件点击显示块框/双箭头、部分包含同步文件列表、回退保留索引及实际提交只包含勾选块;完整主题/长文件/快捷键验收仍 pending。 Windows Diff 取消默认上下文折叠,中央行号增加留白及主题边界竖线,Commit 每侧额外预留块控件宽度;双箭头与勾选框放在各自行号列的图标区,距外侧竖线保留对称留白、与数字保持间隔,不贴代码边缘。整文件新增保持无外圈。 行号与图标间距进一步收紧,方框水平居中于右侧行号和外侧竖线之间,左侧双箭头与行号镜像对齐,图标两侧等距;控件按首个改动行垂直居中;Commit 单文件预览保留轻量文件顺序,下箭头到最后差异后按需进入下一文件并定位首个改动,同一标签不循环。块包含、手动及事件刷新保留导航顺序,切换或关闭使迟到读取失效。 审核修复:EOF/CRLF 有损显示标识阻止新旧 Diff 的块写入,保留整文件入口;HEAD 到磁盘为空时显示真实暂存区内容并标注版本,显示回退不改变写入身份。整文件三态直接读取 staged/worktree,覆盖二进制、重命名及没有安全块的情况。新旧 Diff 块级双箭头 2026-10-17 起与文件/整仓库的丢弃确认统一遵守设置,设置描述已同步更新范围。Unix 临时索引目录在创建时设置 0700,保持原子占有及错误/正常销毁清理;Windows 保持用户临时目录继承权限。 Windows 两处单文件 Diff 共用工具栏:显示选项移到左侧,右端显示 IDEA 差异统计及 Commit included 数;英文文案、单复数和空白符稳定开关名参考 Community 源码。组件回归覆盖统计及开关行为,原生视觉验收 pending。 Windows 上下差异箭头补齐双向跨文件导航;Commit 列表从活动工作区预览的仓库/路径同步行选择和一次性虚拟滚动,沿用原有加载代次保护。组件回归覆盖双向边界、嵌套仓库、折叠展开、刷新及焦点/include 保持,完整原生验收 pending。 2026-10-06:用户确认 PR #1083 涉及的 Windows 功能改动均已完成实机测试,未发现问题;实机验收依据为用户反馈。" -} \ No newline at end of file + "notes": "Windows Commit 单文件预览复用 Repository Diff 的新页面:中央行号、动态连接带、独立轨道与对应行同步,右侧块级包含框、左侧回退双箭头和手动/事件刷新。点击文件保持 HEAD 到工作区的完整比较,块包含通过真实暂存/取消暂存同步到文件列表;提交只使用已包含块的暂存内容。相邻修改不因 Git hunk 上下文合并而一起包含。回退只撤销未暂存部分并保留已有包含内容,点击双箭头按「丢弃前确认」设置执行(2026-10-17 规则变更,此前为直接执行),写入前重新验证快照;块写入期间提交保护,刷新保留滚动位置。核对新增/删除单标题、没有外圈与增删符号、轨道背景色一致、明暗主题和统一 gutter widget;真实临时仓库与有界组件回归已覆盖,完整 WebView2 的勾选、刷新及提交视觉/原生验收仍 pending。gutter 入口的整文件差异保持原标签。 修复状态映射备用入口和旧预览缺少工作区目标导致块控件缺失;按原显示路径解析嵌套仓库并补全元数据,迟到解析不得覆盖新比较。中央行号取消重复内边距并固定按钮左缘,关闭只读整行光标底色及内容宿主 title 气泡。真实 Chromium 中加载生产 React/Monaco,核对 Commit/Log 字体与色值、恢复缺失目标、块包含/取消/回退和滚动时控件及连接带位置;IPC 使用可控返回,不替代原生 WebView2 验收。 修复原生 Git 监听与只读快照的刷新循环:快照索引改用系统临时目录,不再创建 .git 子目录;保留外部变更监听与快照失效保护。真实 Git 回归检查读取前后仓库元数据目录时间及真实索引字节不变。 2026-10-05 完整原生 WebView2 独立配置验收通过 Commit 文件点击显示块框/双箭头、部分包含同步文件列表、回退保留索引及实际提交只包含勾选块;完整主题/长文件/快捷键验收仍 pending。 Windows Diff 取消默认上下文折叠,中央行号增加留白及主题边界竖线,Commit 每侧额外预留块控件宽度;双箭头与勾选框放在各自行号列的图标区,距外侧竖线保留对称留白、与数字保持间隔,不贴代码边缘。整文件新增保持无外圈。 行号与图标间距进一步收紧,方框水平居中于右侧行号和外侧竖线之间,左侧双箭头与行号镜像对齐,图标两侧等距;控件按首个改动行垂直居中;Commit 单文件预览保留轻量文件顺序,下箭头到最后差异后按需进入下一文件并定位首个改动,同一标签不循环。块包含、手动及事件刷新保留导航顺序,切换或关闭使迟到读取失效。 审核修复:EOF/CRLF 有损显示标识阻止新旧 Diff 的块写入,保留整文件入口;HEAD 到磁盘为空时显示真实暂存区内容并标注版本,显示回退不改变写入身份。整文件三态直接读取 staged/worktree,覆盖二进制、重命名及没有安全块的情况。新旧 Diff 块级双箭头 2026-10-17 起与文件/整仓库的丢弃确认统一遵守设置,设置描述已同步更新范围。Unix 临时索引目录在创建时设置 0700,保持原子占有及错误/正常销毁清理;Windows 保持用户临时目录继承权限。 Windows 两处单文件 Diff 共用工具栏:显示选项移到左侧,右端显示 IDEA 差异统计及 Commit included 数;英文文案、单复数和空白符稳定开关名参考 Community 源码。组件回归覆盖统计及开关行为,原生视觉验收 pending。 Windows 上下差异箭头补齐双向跨文件导航;Commit 列表从活动工作区预览的仓库/路径同步行选择和一次性虚拟滚动,沿用原有加载代次保护。组件回归覆盖双向边界、嵌套仓库、折叠展开、刷新及焦点/include 保持,完整原生验收 pending。 2026-10-06:用户确认 PR #1083 涉及的 Windows 功能改动均已完成实机测试,未发现问题;实机验收依据为用户反馈。 2026-10-22:Windows 单文件并排预览在 Git 连续变更块内部增加仅显示的 IDEA 式子块拆分(参考 Community ByWord.compareAndSplit / LineFragmentSplitter):按块内行词元相似度配对,完全相同的行按上下文不绘制,仅单侧存在的行恢复新增绿/删除灰,其余相似行保持修改蓝;无共享词元的相邻子块仍合并为修改块,超预算回退为整个 Git 块。Git 脚本、差异计数、块包含、回退与导航不变,单个 Git 块的控件只绘制一次。真实 17_menus.jsonl 的 115/113 行单块被拆出第 2、3 行新增子块;完整 WebView2 像素验收仍 pending。 2026-10-22 起文件导航按用户看到的列表顺序:Commit 面板单文件/批量 Diff 使用仓库→changelist→文件夹树或状态分组的行顺序,Git Log 提交预览、完整提交、范围与离散多选 Diff 使用提交文件树顺序;未跟踪目录汇总行不参与导航,重复路径稳定相邻。展示顺序由 orderGitStatusFilesForDisplay 与 gitFileTreeOrder 共用,面板和导航不再各自排序;组件回归覆盖混合状态与重复路径。 复用标签标题随当前选中比较更新:Git Log 预览箭头切换文件后,标签栏比较选中文件名而不是只看虚拟路径。" +} diff --git a/shared/platform-feature-matrix/features/git-large-untracked-directories.json b/shared/platform-feature-matrix/features/git-large-untracked-directories.json new file mode 100644 index 0000000000..6c56f30394 --- /dev/null +++ b/shared/platform-feature-matrix/features/git-large-untracked-directories.json @@ -0,0 +1,40 @@ +{ + "id": "git-large-untracked-directories", + "area": "版本控制", + "group": "Git", + "capability": "大型未跟踪目录汇总与按需展开", + "macos": { + "evidence": [ + "rust/lithe-core/src/git/untracked_directories.rs", + "rust/lithe-core/src/tests/git_untracked_directories.rs", + "macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift", + "macos/Sources/Lithe/Views/Git/GitChangeSectionsCache.swift", + "macos/Sources/LitheGitModule/Application/GitFeatureModel.swift", + "macos/Sources/LitheGitModule/Models/GitModels.swift", + "macos/Tests/LitheTests/GitChangeSectionsCacheTests.swift", + "macos/Tests/LitheGitModuleTests/GitModuleTests.swift", + ".agents/notes/implemented/bug-fix/2026-10-21-git-large-untracked-directories.md" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "windows": { + "evidence": [ + "rust/lithe-core/src/git/untracked_directories.rs", + "windows/tauri/src/features/git/api/git-status-api.ts", + "windows/tauri/src/features/git/services/git-untracked-directory-expansions.ts", + "windows/tauri/src/features/git/utils/git-untracked-directories.ts", + "windows/tauri/src/features/git/components/status/git-status-panel.tsx", + "windows/tauri/src/features/git/utils/git-status-model.ts", + "windows/tauri/src/features/git/utils/git-changelists.ts", + "windows/tauri/src/features/file-explorer/lib/file-tree-git-status.ts", + "windows/tauri/src/features/git/utils/git-untracked-directories.test.ts", + "windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "owner": "Git", + "verification": "在仓库中放入未被忽略、超过 100 个文件的构建目录(如 rust/lithe-core/target/),以及包含多个 linked worktree 的 .worktrees/。打开 Commit 面板:构建目录只显示一行目录节点和「N 个文件」,状态栏与 ChangeList 计数按文件统计;.worktrees/ 不被汇总,已发现的 worktree 不出现在父仓库列表。展开目录只加载下一层,子目录继续显示为带数量的节点;构建写入触发刷新后展开状态保持,目录被删除或暂存后展开状态清除。勾选目录节点暂存全部文件,丢弃目录节点删除其中未跟踪文件,搁置后能完整恢复目录内容;项目树中该目录下的文件仍显示未跟踪颜色,编辑器行标记正常。多仓库和单仓库模式下滚动与帧率不随变更数量下降。 Windows 另检查目录视图与平铺视图:折叠的汇总显示为带「N 个文件」的目录行,展开后由同名文件夹代表该汇总,折叠该文件夹即收起汇总,已记住的文件夹折叠不能挡住展开内容;单击汇总行不打开 Diff,「显示 Diff」与查看全部变更跳过汇总本身;删除汇总只删一次目录;页脚计数、项目树颜色、编辑器装订线与行历史对汇总目录下的文件按未跟踪处理;把汇总移到 ChangeList 后暂存并提交,提交范围包含其文件。", + "notes": "#1385:Core 的 git.status 新增 summarizeUntrackedDirectories,使用 Git 的 normal 未跟踪视图,只汇总不含嵌套仓库的整目录;git.untrackedDirectory 按层列出。macOS 变更列表改为扁平行直接交给 LazyVStack,只构建可见行。Windows 由写入 repositoryStatuses 的状态读取开启汇总,展开内容在同一次读取中列出并发布,Shelf 等一次性读取保持逐文件;状态列表原本已虚拟化。" +} diff --git a/shared/platform-feature-matrix/features/go-support-plugin.json b/shared/platform-feature-matrix/features/go-support-plugin.json index a6cc660ff7..3bf0e78ab2 100644 --- a/shared/platform-feature-matrix/features/go-support-plugin.json +++ b/shared/platform-feature-matrix/features/go-support-plugin.json @@ -20,7 +20,7 @@ }, "windows": { "evidence": [ - "shared/contracts/plugins/v1/fixtures/go-support.plugin.json" + "shared/contracts/plugins/v1/fixtures/language-system-command.plugin.json" ], "implementationStatus": "missing", "verificationStatus": "not-applicable" diff --git a/shared/platform-feature-matrix/features/java-support-plugin.json b/shared/platform-feature-matrix/features/java-support-plugin.json index b8224fd67f..aad20b01e1 100644 --- a/shared/platform-feature-matrix/features/java-support-plugin.json +++ b/shared/platform-feature-matrix/features/java-support-plugin.json @@ -12,7 +12,7 @@ "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift", "macos/Tests/LitheTests/PluginLanguageServerLaunchResolverTests.swift", "macos/Tests/LitheTests/RealJavaDebugIntegrationTests.swift", - "shared/contracts/plugins/v1/fixtures/java-support.plugin.json", + "shared/contracts/plugins/v1/fixtures/language-host-integration.plugin.json", "macos/Sources/Lithe/Core/Language/PluginLanguageProviderCatalogSource.swift", "macos/Tests/LitheTests/LanguageProviderCatalogSourceTests.swift", "macos/Tests/LitheTests/RunWorkflowCoordinatorTests.swift" diff --git a/shared/platform-feature-matrix/features/lsp-completion-hover.json b/shared/platform-feature-matrix/features/lsp-completion-hover.json index e5ed25cea1..ff19cc49f2 100644 --- a/shared/platform-feature-matrix/features/lsp-completion-hover.json +++ b/shared/platform-feature-matrix/features/lsp-completion-hover.json @@ -6,6 +6,9 @@ "macos": { "evidence": [ "macos/Sources/Lithe/Core/Language", + "rust/lithe-core/src/lsp/languages/jdt.rs", + "rust/lithe-core/src/lsp/interface/engine.rs", + "shared/fixtures/lsp/java-completion-v1.json", "shared/fixtures/lsp" ], "implementationStatus": "implemented", @@ -14,11 +17,14 @@ "windows": { "evidence": [ "windows/tauri/src/features", + "rust/lithe-core/src/lsp/languages/jdt.rs", + "rust/lithe-core/src/lsp/interface/engine.rs", + "shared/fixtures/lsp/java-completion-v1.json", "shared/fixtures/lsp" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Language Tooling", - "verification": "在相同 Java fixture 中验证补全、Hover、排序、超时和空结果。" + "verification": "在相同 Java fixture 中验证补全、Hover、排序、超时和空结果。Java 构造器和方法补全使用 JDT LS 的 guessMethodArguments=off:输入 new No 后接受 Node 候选应得到 Node(),光标在括号内;方法调用同样不插入参数名,构造器重载候选由 JDT LS 合并为类型候选。验证 initialize、didChangeConfiguration 与 workspace/configuration 三个配置入口一致,父级和叶级 section 均保留插件 favoriteStaticMembers、filteredTypes 等其他补全配置,其他语言 snippet、Java 方法补全签名展示及自动导入仍正常。" } diff --git a/shared/platform-feature-matrix/features/macos-shared-product-dropdowns.json b/shared/platform-feature-matrix/features/macos-shared-product-dropdowns.json index 0267caa645..c6369b915a 100644 --- a/shared/platform-feature-matrix/features/macos-shared-product-dropdowns.json +++ b/shared/platform-feature-matrix/features/macos-shared-product-dropdowns.json @@ -2,7 +2,7 @@ "id": "macos-shared-product-dropdowns", "area": "界面", "group": "全局共享控件", - "capability": "macOS 全部产品操作菜单、选值下拉框与自定义弹窗使用 Git Log 筛选、设置和 Project 共同使用的共享外框及行尺寸,直接打开且无系统弹跳动画;顶部项目/分支按钮打开期间保持原hover底色;Monaco 编辑区现有右键菜单同样使用共享呈现器及 IDEA 官方原色图标;顶部项目菜单按内容宽度布局,分支菜单共享 375pt 基准及全局搜索输入框,菜单锚点位于完整工具栏槽位下缘,内容图标使用官方 16pt 明暗 SVG;配置侧边子菜单支持原文名称与简短说明,按实际宽度测量多行行高,键盘滚动后的选择保持到真实指针移动;原生浮层更新与尺寸反馈合并到后续主队列执行,关闭或锚点卸载取消待执行请求", + "capability": "macOS 全部产品操作菜单、选值下拉框与自定义弹窗使用 Git Log 筛选、设置和 Project 共同使用的共享外框及行尺寸,直接打开且无系统弹跳动画;顶部项目/分支按钮打开期间保持原hover底色;Monaco 编辑区现有右键菜单同样使用共享呈现器及 IDEA 官方原色图标;顶部项目菜单按内容宽度布局,分支菜单共享 375pt 基准及全局搜索输入框,菜单锚点位于完整工具栏槽位下缘,内容图标使用官方 16pt 明暗 SVG;配置侧边子菜单支持原文名称与简短说明,按实际宽度测量多行行高,键盘滚动后的选择保持到真实指针移动;原生浮层更新与尺寸反馈合并到后续主队列执行,关闭或锚点卸载取消待执行请求;操作菜单保留所属窗口焦点和开启开关的颜色", "macos": { "implementationStatus": "implemented", "verificationStatus": "pending", @@ -15,7 +15,6 @@ "macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift", "macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift", "macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift", - "macos/Sources/Lithe/Views/Database/DatabaseTableView.swift", "macos/Sources/Lithe/Views/Debug/GenericDebugView.swift", "macos/Tests/LitheTests/ContextMenuCoverageTests.swift", "macos/Tests/LitheTests/WorkbenchRenderingSafetyTests.swift", @@ -34,7 +33,8 @@ "macos/Tests/LitheTests/SettingsSelectPopupGeometryTests.swift", "macos/Tests/LitheTests/AgentSessionSelectorLayoutTests.swift", "macos/Tests/LitheTests/AgentSessionSelectorInteractionTests.swift", - "macos/Tests/LitheTests/AgentActivityPresentationTests.swift" + "macos/Tests/LitheTests/AgentActivityPresentationTests.swift", + "macos/Tests/LitheTests/LitheActionMenuFocusTests.swift" ] }, "windows": { @@ -45,6 +45,6 @@ ] }, "owner": "macOS UI", - "verification": "按 PR 迁移清单逐项打开 Agent、App/设置、Database、Debug、Editor、Git、GitHub、Java、Search、Diff、Workbench 的菜单与选择面板,检查同一共享外框、圆角、边框、单行行高、字体和无弹跳展开;选中项、禁用条件、动作、动态列表、危险操作确认及键盘操作保持原语义。顶部项目/分支按钮打开期间保持原hover底色,移入菜单后仍保留,关闭后释放;不使用蓝色菜单选中色。原生子菜单保留父面板并只接收自己的按键,Esc/外部点击按层关闭,所有方向保持屏幕边界,锚点卸载/移出窗口清理面板与监视器。真实 NSHostingView 回归验证环境对象/语言完整继承。保留系统应用菜单、系统对话框、编辑器补全/悬停文档与显式 segmented 选项;静态门禁禁止产品恢复 SwiftUI Menu、popover、NSPopUpButton 或非 segmented 的原生 Picker。完整人工视觉验收保持 pending,Windows 本次不改。 Monaco 编辑区保留既有条目与命令,剪切/复制/粘贴/格式化使用 IDEA 官方 16pt 明暗 SVG,无图标的 action 留空并对齐;确认主编辑区、分屏和 diff 的右键菜单取消/关闭、焦点与现有动作正常;执行 scripts/probe-macos-monaco.sh --workbench-tests 验证正式前端的菜单桥接和命令事件。 右键编辑器标签的 Copy Path / Reference 子菜单从触发行旁展开;父菜单有分隔线或滚动时仍对齐可见行。打开/关闭/切换子菜单固定主菜单,屏幕边缘允许子菜单上移或向左展开;Copy Relative Path 完整显示,带快捷键、勾选和图标的宽度计算保持一致。 顶部项目菜单确认随名称/路径宽度自然变化、长路径受共享边界限制,项目徽标20pt;两个入口按钮与菜单留出工具栏槽位内的4/5pt间距。分支输入框复用 Git Log 全局样式,输入法未提交时隐藏占位并在hover显示I-beam,官方16pt图标和共享蓝色hover正常。 项目徽标字母使用共享ProjectAvatarBadge,20pt标识对应13pt JetBrains Mono SemiBold;核对顶部、项目菜单和欢迎页,颜色与大小保留。 共享 LitheIcons 资源入口兼容省略 .svg 的调用,明暗兄弟路径使用正确后缀;实际打包资源检查直接路径/显式后缀解析一致,不再因后缀缺失显示空图标或错误回退。 Project/Dependencies、Terminal shell 和动作菜单均按触发控件边界定位,鼠标点击位置和键盘触发不改变锚点。 Agent 主画布与编辑器共用 editor 背景,标题、上下文区和边框读取共享主题 token;明暗及应用配色切换后不得残留局部固定灰色。 从原生菜单执行剪切、复制、粘贴时恢复 WebKit 焦点并使用原生编辑命令,避免异步菜单回调丢失 DOM 用户手势;桥接返回 handled 防止 Monaco 重复执行,其他命令仍由 Monaco 运行。实际 WKWebView 剪贴板与前端 workbench 集成检查覆盖该路径。 另检查分组折叠/恢复、搜索焦点与 Git Log 共享外框、子菜单横向定位及两层 Esc 关闭。 深浅主题下分别验证动作菜单与自定义弹窗:再次左键点击原按钮关闭,点击另一按钮直接切换,点击空白处关闭;打开嵌套菜单后仍保留父窗口。 深浅主题下检查 Agent 速度/思考独立子菜单:主面板固定、子窗口贴齐触发行并在右缘向左翻转,说明换行不越出行高、长列表可键盘滚动至末项;滚动完成并捕获渲染后按 Return 仍选择键盘目标,静止鼠标的合成悬停不能抢走选择;实际移动鼠标后恢复指针选择。Esc 按层关闭,选值和锚点拆除释放所有子窗口。 同一配置 ID 的 choices、currentValue 或说明变化时,已打开的子菜单关闭,模型面板及搜索保留;重新打开使用最新选项、说明和勾选。原生窗口回归覆盖模型搜索输入法组合后提交,以及键盘滚动后 mouseMoved 恢复指针选择;其他选项更新不关闭当前子菜单。 原生窗口回归另检查连续内容/尺寸变化只交付最新值,尺寸回调不会同步重入,深浅外观下更新合并为一个面板;关闭或卸载发生在首次打开或更新交付之前时,不得重新打开旧面板。", + "verification": "按 PR 迁移清单逐项打开 Agent、App/设置、Database、Debug、Editor、Git、GitHub、Java、Search、Diff、Workbench 的菜单与选择面板,检查同一共享外框、圆角、边框、单行行高、字体和无弹跳展开;选中项、禁用条件、动作、动态列表、危险操作确认及键盘操作保持原语义。顶部项目/分支按钮打开期间保持原hover底色,移入菜单后仍保留,关闭后释放;不使用蓝色菜单选中色。原生子菜单保留父面板并只接收自己的按键,Esc/外部点击按层关闭,所有方向保持屏幕边界,锚点卸载/移出窗口清理面板与监视器。真实 NSHostingView 回归验证环境对象/语言完整继承。保留系统应用菜单、系统对话框、编辑器补全/悬停文档与显式 segmented 选项;静态门禁禁止产品恢复 SwiftUI Menu、popover、NSPopUpButton 或非 segmented 的原生 Picker。完整人工视觉验收保持 pending,Windows 本次不改。 Monaco 编辑区保留既有条目与命令,剪切/复制/粘贴/格式化使用 IDEA 官方 16pt 明暗 SVG,无图标的 action 留空并对齐;确认主编辑区、分屏和 diff 的右键菜单取消/关闭、焦点与现有动作正常;执行 scripts/probe-macos-monaco.sh --workbench-tests 验证正式前端的菜单桥接和命令事件。 右键编辑器标签的 Copy Path / Reference 子菜单从触发行旁展开;父菜单有分隔线或滚动时仍对齐可见行。打开/关闭/切换子菜单固定主菜单,屏幕边缘允许子菜单上移或向左展开;Copy Relative Path 完整显示,带快捷键、勾选和图标的宽度计算保持一致。 顶部项目菜单确认随名称/路径宽度自然变化、长路径受共享边界限制,项目徽标20pt;两个入口按钮与菜单留出工具栏槽位内的4/5pt间距。分支输入框复用 Git Log 全局样式,输入法未提交时隐藏占位并在hover显示I-beam,官方16pt图标和共享蓝色hover正常。 项目徽标字母使用共享ProjectAvatarBadge,20pt标识对应13pt JetBrains Mono SemiBold;核对顶部、项目菜单和欢迎页,颜色与大小保留。 共享 LitheIcons 资源入口兼容省略 .svg 的调用,明暗兄弟路径使用正确后缀;实际打包资源检查直接路径/显式后缀解析一致,不再因后缀缺失显示空图标或错误回退。 Project/Dependencies、Terminal shell 和动作菜单均按触发控件边界定位,鼠标点击位置和键盘触发不改变锚点。 Agent 主画布与编辑器共用 editor 背景,标题、上下文区和边框读取共享主题 token;明暗及应用配色切换后不得残留局部固定灰色。 从原生菜单执行剪切、复制、粘贴时恢复 WebKit 焦点并使用原生编辑命令,避免异步菜单回调丢失 DOM 用户手势;桥接返回 handled 防止 Monaco 重复执行,其他命令仍由 Monaco 运行。实际 WKWebView 剪贴板与前端 workbench 集成检查覆盖该路径。 另检查分组折叠/恢复、搜索焦点与 Git Log 共享外框、子菜单横向定位及两层 Esc 关闭。 深浅主题下分别验证动作菜单与自定义弹窗:再次左键点击原按钮关闭,点击另一按钮直接切换,点击空白处关闭;打开嵌套菜单后仍保留父窗口。 深浅主题下检查 Agent 速度/思考独立子菜单:主面板固定、子窗口贴齐触发行并在右缘向左翻转,说明换行不越出行高、长列表可键盘滚动至末项;滚动完成并捕获渲染后按 Return 仍选择键盘目标,静止鼠标的合成悬停不能抢走选择;实际移动鼠标后恢复指针选择。Esc 按层关闭,选值和锚点拆除释放所有子窗口。 同一配置 ID 的 choices、currentValue 或说明变化时,已打开的子菜单关闭,模型面板及搜索保留;重新打开使用最新选项、说明和勾选。原生窗口回归覆盖模型搜索输入法组合后提交,以及键盘滚动后 mouseMoved 恢复指针选择;其他选项更新不关闭当前子菜单。 原生窗口回归另检查连续内容/尺寸变化只交付最新值,尺寸回调不会同步重入,深浅外观下更新合并为一个面板;关闭或卸载发生在首次打开或更新交付之前时,不得重新打开旧面板。 打开技能三个点纯操作菜单,所属设置窗口保持 key 身份、开启开关保持颜色且不发配置写入;方向键/Return/Esc 可用,真正保存期间仍禁用。搜索下拉继续获得输入焦点,嵌套子动作菜单优先接收按键;所属窗口失焦或应用切换关闭菜单并移除监听。", "notes": "顶部分支菜单的 Recent/Local/Remote/Tags 支持点击折叠,Recent 按命名空间分组;搜索覆盖全部引用,清空后保留各组折叠状态。分支动作在右侧按触发行对齐,屏幕边缘自动向左展开。 搜索框共用 Git Log 的控件尺寸、占位、IME 和焦点外框,按上游 SearchFieldWithExtension 显式使用弹窗背景;弹出时焦点留在面板,直接输入或 ⌘F 才进入编辑器。 共享弹窗显式观察应用 colorScheme,内容与外框继承同一环境,原生面板同步明暗外观;应用外观与系统外观不同时不再出现黑色搜索框和浅色文字。 见 #1138:已确认原生布局递归栈溢出,具体触发控件未确定;当前验证覆盖同步反馈风险的防护,不代表原始崩溃已经完整复现。" } diff --git a/shared/platform-feature-matrix/features/macos-shared-tree-list-control.json b/shared/platform-feature-matrix/features/macos-shared-tree-list-control.json new file mode 100644 index 0000000000..b636000fec --- /dev/null +++ b/shared/platform-feature-matrix/features/macos-shared-tree-list-control.json @@ -0,0 +1,27 @@ +{ + "id": "macos-shared-tree-list-control", + "area": "工作台", + "group": "公共控件", + "capability": "macOS 原生树与列表使用完整公共入口,统一焦点、两侧留白、悬停、活动/失焦选择、方向键、范围多选、双击展开及回车;项目树与插件树共用展开箭头控件;既有页面与声明式插件共用;保留原生视口、拖放、业务加载与输入编辑;右键选择与数据库打开分离,空节点菜单不拦截行内业务菜单,无障碍默认按下执行激活并保留独立行内按钮;外部选择更新同步键盘游标及范围锚点,Shift扩缩选保留锚点,上下键跳过普通不可选标题并保留可展开分组导航", + "macos": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "macos/Sources/Lithe/Views/Components/LitheTreeList.swift", + "macos/Tests/LitheTests/LitheTreeListTests.swift", + "macos/Tests/LitheTests/LitheTreeRowStyleTests.swift", + "macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift", + "macos/Sources/Lithe/Views/Workbench/PluginNativeToolWindowView.swift", + ".agents/notes/implemented/simplification/2026-09-30-macos-shared-dropdown-style.md" + ] + }, + "windows": { + "implementationStatus": "missing", + "verificationStatus": "not-applicable", + "evidence": [ + "windows/tauri/src/ui" + ] + }, + "owner": "Workbench UI", + "verification": "运行 LitheTreeListTests、LitheTreeRowStyleTests、PluginNativeUITests、ProjectTreeSelectionTests、GitChangeSelectionTests、原生 Git 文件树和 Worktree 及焦点回归;实机验证 Sample Explorer 与 Project/Commit/构建/设置列表的两侧留白、焦点边框、选择、悬停、方向键、范围选择、双击名称展开/收起、箭头一致性、回车与嵌入输入;深浅色及窗口失焦分别验收。组件验证不代替全部页面实机验收;Windows 后续独立同步。 #1423审查回归:表A保留单元格草稿/新增行/删除行时,右键表B再Escape不改变活动表且三类草稿不丢;明确打开B仍可导航。单选/多选节点空菜单时运行/依赖内嵌菜单可打开并执行;无障碍默认按下打开对应结果文档,行内按钮保持独立。 运行 externalSelectionRetargetsMountedKeyboardAndPreservesShiftAnchor 和 problemsKeyboardCrossesFileHeaderAndOpensTheNextDiagnostic,验证 Return、连续 Shift 扩缩选和 Problems 跨文件后实际打开目标行列。" +} diff --git a/shared/platform-feature-matrix/features/mcp-registry-discovery.json b/shared/platform-feature-matrix/features/mcp-registry-discovery.json new file mode 100644 index 0000000000..187e72a735 --- /dev/null +++ b/shared/platform-feature-matrix/features/mcp-registry-discovery.json @@ -0,0 +1,34 @@ +{ + "id": "mcp-registry-discovery", + "area": "AI", + "group": "IDE MCP", + "capability": "同页上方管理服务器资源库,下方默认显示离线可用的内置精选;搜索时查询官方 MCP Registry、分页并与精选去重,失败保留可用匹配并提供显式直连重试;静态远程或简单固定版本 npm/PyPI 入口进入平铺编辑弹窗,选择 Agent 并确认后保存;十个常用精选随应用打包品牌图标和运行方式标签,GitHub 令牌及 Filesystem 允许目录须补全后保存", + "macos": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "rust/lithe-core/src/mcp/registry.rs", + "shared/contracts/mcp-registry-page.json", + "macos/Sources/Lithe/Core/Ports/McpRegistry.swift", + "macos/Sources/Lithe/Platform/MacOS/MCP/MacMcpRegistry.swift", + "macos/Sources/Lithe/Application/Features/McpRegistryFeatureModel.swift", + "macos/Sources/Lithe/Views/App/McpDiscoveryView.swift", + "macos/Tests/LitheTests/McpRegistryTests.swift", + "rust/lithe-core/src/mcp/featured.json", + "macos/Tests/LitheTests/McpRegistryIntegrationTests.swift", + "macos/Sources/Lithe/Theme/McpBrandIcon.swift", + "macos/Sources/Lithe/Resources/McpIcons/NOTICE.json", + "macos/Tests/LitheTests/McpArgumentRoundTripTests.swift", + "shared/fixtures/mcp/featured-setup-v1.json" + ] + }, + "windows": { + "implementationStatus": "missing", + "verificationStatus": "not-applicable", + "evidence": [ + "shared/contracts/rust-core-api.md" + ] + }, + "owner": "Agent", + "verification": "Core 验证精选目录、删除记录过滤、固定包版本、复杂输入不猜配置、链接和大小上限;模型覆盖离线首页不请求网络、失败保留精选、显式直连和路由分页隔离、在线连接去重、取消和旧响应。实机打开页面立即显示精选,标题右侧来源标记为灰色普通文字“精选,以及官方 MCP Registry”,辅助功能树不包含该标记的链接或按钮;系统代理失败时仍显示匹配,点击直连重试后经生产 adapter 查询官方目录并加载更多,再切回系统代理确认模式生效;添加草稿取消不改 Agent 配置。公开目录请求继续校验证书,系统代理、Agent 网络和发行目录保持不变。联网集成用 LITHE_RUN_MCP_REGISTRY_INTEGRATION=1 显式开启,普通测试不依赖网络。Windows UI 尚未接入。 新增精选应检查十项本地图标完整且公开条目回退传输图标;深浅色宽窄卡片保持可读;GitHub 空令牌、Filesystem 缺少或相对目录不能保存,补全后经两 Agent 原生格式写入并回读,目录空格保持。未执行或联网调用服务器不计作实际服务验收。" +} diff --git a/shared/platform-feature-matrix/features/mcp-server-library.json b/shared/platform-feature-matrix/features/mcp-server-library.json index 72729865dc..80007d4201 100644 --- a/shared/platform-feature-matrix/features/mcp-server-library.json +++ b/shared/platform-feature-matrix/features/mcp-server-library.json @@ -2,7 +2,7 @@ "id": "mcp-server-library", "area": "AI", "group": "IDE MCP", - "capability": "在设置页统一发现、合并、编辑并删除 Codex 与 Claude Code 已注册的 MCP 服务器,按 Agent 独立启用;项目授权开启时自动写入 Lithe MCP,关闭时按账本清理", + "capability": "在设置页统一发现、合并、编辑并删除 Codex 与 Claude Code 已注册的 MCP 服务器,按 Agent 独立启用;项目授权开启时自动写入 Lithe MCP,关闭时按账本清理;支持资源库搜索、原生弹窗编辑和标准 mcpServers JSON 预览导入;紧凑图标配置弹窗及精确命令行参数投影,主动 stdio/Streamable HTTP 工具检测,按 Codex/Claude 原生规则独立管理工具开关并保留外部禁用项;关闭及删除后的 Codex 原生工具名单按路径/名称保留,再添加时恢复", "macos": { "implementationStatus": "implemented", "verificationStatus": "pending", @@ -20,7 +20,14 @@ "rust/lithe-core/src/mcp/tests.rs", "macos/Tests/LitheTests/McpArgumentRoundTripTests.swift", "macos/Tests/LitheTests/McpSymlinkConfigurationTests.swift", - "macos/Tests/LitheTests/McpWriteProtectionTests.swift" + "macos/Tests/LitheTests/McpWriteProtectionTests.swift", + "macos/Sources/Lithe/Views/App/McpDiscoveryView.swift", + "macos/Sources/Lithe/Platform/MacOS/MCP/MacMcpTools.swift", + "macos/Sources/Lithe/Application/Features/McpToolsFeatureModel.swift", + "macos/Sources/Lithe/Views/App/McpToolsPanel.swift", + "rust/lithe-agent-host/src/mcp_probe.rs", + "rust/lithe-core/src/mcp/tools.rs", + "macos/Tests/LitheTests/McpToolsFeatureTests.swift" ] }, "windows": { @@ -31,6 +38,6 @@ ] }, "owner": "Agent", - "notes": "macOS 新增 MCP 服务器资源库:Core 解析 Claude JSON 与 Codex TOML 并生成保格式修改计划,macOS 适配器负责路径、备份和原子写,应用模型以“名称 + 定义哈希”合并列表,以覆盖未知字段的完整来源哈希维护所有权账本。草稿保存保留打开编辑器时各 Agent 的来源版本并作为独立 expectedHash 前置条件,刷新列表和共享账本更新都不能授权旧草稿覆盖新版本;自动删除和项目清理检测认证、超时等未建模字段的外部改动,旧账本需显式重新接管。只替换或删除 Lithe 写过的条目,外部创建的条目需要显式接管所有权。项目授权开启时把随包分发的 lithe-mcp 写入当前项目的 Claude projects..mcpServers 和 .codex/config.toml;新建的 Codex 项目配置追加到 .git/info/exclude。保存与项目注册采用批量失败恢复,整批成功后再更新所有权账本;恢复时保留外部新修改并提示未恢复路径;聚合首次写入与恢复阶段的失败时一并带出保留副本路径。列表摘要隐藏所有命令参数,并移除 URL userinfo、查询和 fragment;Git exclude 保留非 UTF-8 原始字节,已有文件读取失败则停止追加。首版不自动启动服务器做健康检查,env/header 值只在 UI 中遮蔽。Windows 尚未实现该资源库,暂标记为平台专属。 参数显示文本未改动时保留原始 argv 数组,修改时保留空行与纯空白行;配置读写及回滚共用符号链接目标解析,保留原链接和用户路径诊断。保存与回滚删除采用同目录临时文件和原子交换/移开的条件操作:备份或预检之后落地的外部保存会被保留并返回冲突,换回后会核对被换出的版本,换回失败或换出更新外部保存时保留临时文件并在错误中报告保留路径;不支持交换标志的卷返回不支持错误而不退回无条件覆盖;临时文件从创建起为 0600。", - "verification": "运行 cargo test -p lithe-core mcp:: 与 ./scripts/test-macos.sh --filter \"McpServerLibraryTests|McpBatchTransactionTests|McpServerSummaryTests|McpExcludePreservationTests|McpArgumentRoundTripTests|McpSymlinkConfigurationTests\",注入两次写入之间的外部修改,确认先前文件恢复、账本不变、回滚时保留外部修改并提示路径;验证 args 与 URL 凭据不在摘要中,以及非 UTF-8 exclude 规则保留、读取失败保护和幂等;覆盖双窗口按顺序保存后旧草稿被拒绝(包括刷新列表、可见字段恰好等于新版本)、各 Agent 使用自己的完整来源哈希、未建模认证或超时变化阻止删除/重命名/项目清理、旧账本显式重新接管,以及双 Agent 合并、门禁冲突、接管所有权、保格式编辑、项目注册与外部修改保留。实机打开设置 → MCP:无项目时资源库仍列出 Codex/Claude 已有服务器;编辑 env 并保存后检查原文件注释与未知键未丢、备份生成;按 Agent 关闭外部条目被拒绝,接管后可移除。打开项目并启用 MCP,确认 ~/.claude.json 项目条目与 <项目>/.codex/config.toml 写入、.git/info/exclude 包含 .codex/config.toml;禁用后清理,外部改过的条目保留并提示。运行 ./scripts/test-macos.sh --filter \"McpWriteProtectionTests|FileStorageReplacementTests|McpFeatureBatchFailureTests\" 验证备份后外部保存保留、条件删除、非普通文件目标拒绝、发布失败后批量恢复且不发布账本;回滚保留更新保存的路径、临时文件从创建起为 0600、不支持交换的卷 fail closed。 运行前后对比安装目录文件清单与哈希,确认运行时未写入发行目录。 覆盖仅改 env 时空参数、纯空白与嵌入换行的无损往返;两个 Agent 的相对/绝对符号链接读取、生成计划、备份、保存、故障回滚、外部目标修改保护及大小/UTF-8 校验。" + "notes": "macOS 新增 MCP 服务器资源库:Core 解析 Claude JSON 与 Codex TOML 并生成保格式修改计划,macOS 适配器负责路径、备份和原子写,应用模型以“名称 + 定义哈希”合并列表,以覆盖未知字段的完整来源哈希维护所有权账本。草稿保存保留打开编辑器时各 Agent 的来源版本并作为独立 expectedHash 前置条件,刷新列表和共享账本更新都不能授权旧草稿覆盖新版本;自动删除和项目清理检测认证、超时等未建模字段的外部改动,旧账本需显式重新接管。只替换或删除 Lithe 写过的条目,外部创建的条目需要显式接管所有权。项目授权开启时把随包分发的 lithe-mcp 写入当前项目的 Claude projects..mcpServers 和 .codex/config.toml;新建的 Codex 项目配置追加到 .git/info/exclude。保存与项目注册采用批量失败恢复,整批成功后再更新所有权账本;恢复时保留外部新修改并提示未恢复路径;聚合首次写入与恢复阶段的失败时一并带出保留副本路径。列表摘要隐藏所有命令参数,并移除 URL userinfo、查询和 fragment;Git exclude 保留非 UTF-8 原始字节,已有文件读取失败则停止追加。首版不自动启动服务器做健康检查,env/header 值只在 UI 中遮蔽。Windows 尚未实现该资源库,暂标记为平台专属。 参数显示文本未改动时保留原始 argv 数组,修改时保留空行与纯空白行;配置读写及回滚共用符号链接目标解析,保留原链接和用户路径诊断。保存与回滚删除采用同目录临时文件和原子交换/移开的条件操作:备份或预检之后落地的外部保存会被保留并返回冲突,换回后会核对被换出的版本,换回失败或换出更新外部保存时保留临时文件并在错误中报告保留路径;不支持交换标志的卷返回不支持错误而不退回无条件覆盖;临时文件从创建起为 0600。 Codex 移除成功后只保留允许/禁用工具名到无 sourceHash 的账本条目,重新插入时恢复;已有原生条目和重命名不被旧保留值覆盖,格式错误拒绝移除,空允许名单仍拒绝全部工具。保留名单不构成现存文件所有权,不保存认证信息。取消探测复用 Core 注册回调,无重试计时器。 Codex 成功重命名后移动同一配置路径下的精确工具名所有权并清除旧名记录,不接管外部规则或合并目标旧记录;批量失败不迁移。已检测行的 Agent 来源改变时释放旧快照并重读本地策略,探测中变化采用最新来源,不重复连接服务器。", + "verification": "运行 cargo test -p lithe-core mcp:: 与 ./scripts/test-macos.sh --filter \"McpServerLibraryTests|McpBatchTransactionTests|McpServerSummaryTests|McpExcludePreservationTests|McpArgumentRoundTripTests|McpSymlinkConfigurationTests\",注入两次写入之间的外部修改,确认先前文件恢复、账本不变、回滚时保留外部修改并提示路径;验证 args 与 URL 凭据不在摘要中,以及非 UTF-8 exclude 规则保留、读取失败保护和幂等;覆盖双窗口按顺序保存后旧草稿被拒绝(包括刷新列表、可见字段恰好等于新版本)、各 Agent 使用自己的完整来源哈希、未建模认证或超时变化阻止删除/重命名/项目清理、旧账本显式重新接管,以及双 Agent 合并、门禁冲突、接管所有权、保格式编辑、项目注册与外部修改保留。实机打开设置 → MCP:无项目时资源库仍列出 Codex/Claude 已有服务器;编辑 env 并保存后检查原文件注释与未知键未丢、备份生成;按 Agent 关闭外部条目被拒绝,接管后可移除。打开项目并启用 MCP,确认 ~/.claude.json 项目条目与 <项目>/.codex/config.toml 写入、.git/info/exclude 包含 .codex/config.toml;禁用后清理,外部改过的条目保留并提示。运行 ./scripts/test-macos.sh --filter \"McpWriteProtectionTests|FileStorageReplacementTests|McpFeatureBatchFailureTests\" 验证备份后外部保存保留、条件删除、非普通文件目标拒绝、发布失败后批量恢复且不发布账本;回滚保留更新保存的路径、临时文件从创建起为 0600、不支持交换的卷 fail closed。 运行前后对比安装目录文件清单与哈希,确认运行时未写入发行目录。 覆盖仅改 env 时空参数、纯空白与嵌入换行的无损往返;两个 Agent 的相对/绝对符号链接读取、生成计划、备份、保存、故障回滚、外部目标修改保护及大小/UTF-8 校验。 在无项目和已打开项目两种状态下检查资源库搜索、添加/编辑弹窗、JSON 预览选择;取消不改配置。检查 900 点宽与深浅主题,项目授权区保留。 检测需显式触发,本地命令须确认;覆盖分页、工具数量/字节上限、实际 stdio/本地 HTTP、取消、虚拟超时和子进程清理。逐 Agent 关闭/开启自有规则并回读,外部 deny/allow-list 不放宽,过期快照不覆盖文件。全局页打开不启动服务器;验证工具列表与编辑器深浅色宽窄布局。SSE 配置保留但检测明确不支持,未验收的原生会话工具行为不能据配置测试宣称 verified。 McpArgumentRoundTripTests.disabledToolsSurviveServerRemovalAndReopening 对 Agent 开关、编辑器保存、删除重加三条真实隔离文件路径验证:工具仍禁用,外部 deny/allow-list 不放宽、另一 Agent 不变、新页面读回且仅 Lithe 自有规则可重新开启。Core 回归覆盖空 allow-list、非法类型、现存原生规则优先及恢复后完整哈希。CI test-stability-macos 产物包含 mcp-ui 的真实产品编辑器及工具面板深浅主题、宽窄截图。 验证禁用自有工具后重命名、重新读取并开启,外部 deny/allow-list 保持不可编辑,旧名重用不继承所有权;检测后及探测期间新增、移除 Agent 时刷新对应策略并释放旧快照,读取失败可见且不影响另一 Agent,服务器探测次数保持一次。" } diff --git a/shared/platform-feature-matrix/features/plugin-detail-tabs.json b/shared/platform-feature-matrix/features/plugin-detail-tabs.json index c039b0b367..407ae0be01 100644 --- a/shared/platform-feature-matrix/features/plugin-detail-tabs.json +++ b/shared/platform-feature-matrix/features/plugin-detail-tabs.json @@ -2,7 +2,7 @@ "id": "plugin-detail-tabs", "area": "插件", "group": "插件管理", - "capability": "已安装插件详情分为概览、功能、设置、权限与程序、版本与来源五个页签,信任与迁移提示固定在头部;已安装列表可按能力、更新、来源和启用状态过滤,搜索框输入 / 列出过滤选项", + "capability": "已安装插件详情分为概览、功能、设置、权限与程序、版本与来源五个页签,信任与迁移提示固定在头部;已安装列表可按能力、更新、来源和启用状态过滤,搜索框输入 / 列出过滤选项;窗口缩放及调整列表宽度时详情保持在分隔线右侧,过窄的设置表单可横向滚动查看完整控件和状态", "macos": { "evidence": [ "macos/Sources/Lithe/Views/App/PluginDetailView.swift", @@ -10,7 +10,8 @@ "macos/Sources/Lithe/Application/Features/PluginManagement.swift", "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginManager.swift", "macos/Tests/LitheTests/PluginManagementPresentationTests.swift", - "macos/Tests/LitheTests/PluginManagerTests.swift" + "macos/Tests/LitheTests/PluginManagerTests.swift", + "macos/Tests/LitheTests/PluginDetailLayoutTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -23,5 +24,5 @@ "verificationStatus": "not-applicable" }, "owner": "Plugin Platform", - "verification": "macOS:打开设置 › 插件 › 已安装,选中 Java Support。头部显示名称、官方徽标、版本和状态胶囊,右侧有启用/禁用、卸载按钮。依次切换五个页签:概览显示状态、提供的能力、来源和设置数量,“在设置中配置”可跳到 LSP 和运行配置页;功能页签列出 Java 语言、语言服务器(使用插件自带的 java 运行)以及由 Lithe 提供的运行、测试、调试;设置页签显示最大内存和日志级别,修改后出现“重启语言服务器”,“恢复默认值”可一键还原;权限与程序页签列出 languageServer.launch 与 JDT LS 启动命令,内容与安装确认弹窗一致;版本与来源显示当前版本、插件 ID 和签名。安装一个社区插件,确认社区提示固定在页签上方,任何页签都能看到。在左侧点“语言服务”“社区 / 磁盘”“已禁用”等过滤芯片,确认列表随之变化并按“已启用 / 已禁用”分组;在搜索框输入 /,确认出现过滤选项,选中后搜索框清空并切换过滤。更新过的插件在版本与来源页签显示上一版本和“回滚…”,确认后需要重启 Lithe。Windows:扩展管理尚未拆分页签。" + "verification": "macOS:打开设置 › 插件 › 已安装,选中 Java Support。头部显示名称、官方徽标、版本和状态胶囊,右侧有启用/禁用、卸载按钮。依次切换五个页签:概览显示状态、提供的能力、来源和设置数量,“在设置中配置”可跳到 LSP 和运行配置页;功能页签列出 Java 语言、语言服务器(使用插件自带的 java 运行)以及由 Lithe 提供的运行、测试、调试;设置页签显示最大内存和日志级别,修改后出现“重启语言服务器”,“恢复默认值”可一键还原;权限与程序页签列出 languageServer.launch 与 JDT LS 启动命令,内容与安装确认弹窗一致;版本与来源显示当前版本、插件 ID 和签名。安装一个社区插件,确认社区提示固定在页签上方,任何页签都能看到。在左侧点“语言服务”“社区 / 磁盘”“已禁用”等过滤芯片,确认列表随之变化并按“已启用 / 已禁用”分组;在搜索框输入 /,确认出现过滤选项,选中后搜索框清空并切换过滤。更新过的插件在版本与来源页签显示上一版本和“回滚…”,确认后需要重启 Lithe。布局:在浅色和深色下缩放设置窗口、往返拖动插件列表分隔条,切换 Java/Cargo 与各页签,确认标题、页签和正文不越过分隔线;窄设置表单可横向滚动至末尾,输入控件保持同一实例,扩大后恢复填满正文。运行 PluginDetailLayoutTests 检查这些边界。Windows:扩展管理尚未拆分页签。" } diff --git a/shared/platform-feature-matrix/features/plugin-extension-management.json b/shared/platform-feature-matrix/features/plugin-extension-management.json index b9da617487..14b724f7df 100644 --- a/shared/platform-feature-matrix/features/plugin-extension-management.json +++ b/shared/platform-feature-matrix/features/plugin-extension-management.json @@ -20,11 +20,15 @@ "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" + "windows/tauri/src/extensions/registry/extension-store-lifecycle.test.ts", + "windows/tauri/src/features/plugins/declarative-plugins-section.tsx", + "windows/tauri/src-tauri/src/plugin_host/install.rs", + "windows/tauri/src-tauri/src/plugin_host/lifecycle.rs", + "windows/tauri/src/features/plugins/plugin-lifecycle-listener.test.ts" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Extensions", - "verification": "macOS 全新安装时打开插件页,确认“已安装”标签不再显示 PHP 未安装占位项(改由“插件市场”标签提供安装),齿轮菜单可“从磁盘安装插件”选择已签名的插件包;社区插件在“已安装”中显示来源仓库与社区维护提示,可卸载;安装后可搜索并启用、禁用 PHP,确认状态与重启恢复,且页面不显示无恢复路径的卸载操作;同时打开 Maven 面板确认插件内容不被遮挡。Windows 检查扩展管理入口和持久化。 Windows 缺少 Bun 或语言服务器时更新扩展,确认旧扩展的安装、启用和待更新状态保留,错误给出安装依赖后重试的提示;预检期间禁用或卸载扩展,确认返回后不会继续安装或重新启用;手动将 Marksman 等语言服务器安装到 PATH 后重启,确认能识别并启用。" + "verification": "macOS 全新安装时打开插件页,确认“已安装”标签不再显示 PHP 未安装占位项(改由“插件市场”标签提供安装),齿轮菜单可“从磁盘安装插件”选择已签名的插件包;社区插件在“已安装”中显示来源仓库与社区维护提示,可卸载;安装后可搜索并启用、禁用 PHP,确认状态与重启恢复,且页面不显示无恢复路径的卸载操作;同时打开 Maven 面板确认插件内容不被遮挡。Windows 检查扩展管理入口和持久化。契约 v1 插件在设置的独立“声明式插件”页管理(启用/停用/卸载/回滚,与 Worker 扩展分开列出,隔离原因直接展示),管理命令走 plugin_host 的 Tauri 命令。 Windows 缺少 Bun 或语言服务器时更新扩展,确认旧扩展的安装、启用和待更新状态保留,错误给出安装依赖后重试的提示;预检期间禁用或卸载扩展,确认返回后不会继续安装或重新启用;手动将 Marksman 等语言服务器安装到 PATH 后重启,确认能识别并启用。 Windows 契约插件:同时在两个产品窗口启动语言服务/运行任务,在启动尚未返回时分别禁用、卸载、更新和回滚;确认先撤销入口、等待两窗口所有进程和原生探测退出,再改包目录。停止失败、窗口断开或确认超时发生在改包前,文件保留且插件继续阻断;改包后重新扫描失败也必须报错并保持阻断,不能使用旧目录缓存;重试可恢复,不能出现占用可执行文件导致的半删除。 不同插件并发管理时,全盘扫描必须从读取到提交缓存串行,最终缓存包含所有已经成功安装的包;检查 tests.rs 的有序发布回归。" } diff --git a/shared/platform-feature-matrix/features/plugin-functions.json b/shared/platform-feature-matrix/features/plugin-functions.json index 43b5620a8b..77f40eb887 100644 --- a/shared/platform-feature-matrix/features/plugin-functions.json +++ b/shared/platform-feature-matrix/features/plugin-functions.json @@ -2,7 +2,7 @@ "id": "plugin-functions", "area": "扩展", "group": "插件与主题", - "capability": "插件私有函数:插件后端通过 litheFunctions v1 协商和注册,宿主按插件归属调用,接收结果与进度,并支持取消、超时和撤回;领域操作仍使用类型化契约;官方 Node 与 Java(Java 8+)后端 SDK 实现后端一侧", + "capability": "插件私有函数:插件后端通过 litheFunctions v1 协商和注册,宿主按插件归属调用,接收结果与进度,并支持取消、超时和撤回;领域操作仍使用类型化契约;官方 Node 与 Java(Java 8+)后端 SDK(在 lithe-plugin-docs 仓库)实现后端一侧", "macos": { "implementationStatus": "partial", "verificationStatus": "verified", @@ -10,10 +10,6 @@ "rust/lithe-core/src/lsp/interface/plugin_function.rs", "rust/lithe-core/src/lsp/interface/engine.rs", "shared/contracts/plugins/v1/fixtures/function-protocol.json", - "sdk/node/src/backend.mjs", - "rust/lithe-core/tests/plugin_sdk_node.rs", - "sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java", - "rust/lithe-core/tests/plugin_sdk_java.rs", "macos/Sources/Lithe/Application/Features/PluginWebBridge.swift", "macos/Sources/Lithe/Platform/MacOS/Build/MacBuildProviderSessions.swift", "macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift", @@ -27,12 +23,10 @@ "evidence": [ "rust/lithe-core/src/lsp/interface/plugin_function.rs", "rust/lithe-core/src/lsp/interface/engine.rs", - "shared/contracts/plugins/v1/README.md", - "sdk/node/src/backend.mjs", - "sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java" + "shared/contracts/plugins/v1/README.md" ] }, "owner": "Extensions", - "verification": "共享 Core:cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib plugin_function 及 --lib lsp::interface::engine,核对共享 fixture、初始化发现事件、动态增删、归属拒绝、结果与插件错误码、超时取消撤回。插件会话成功完成 initialize 函数协议协商后发布初始 functionsChanged(含空注册表),无 pluginId 的宿主会话不协商、不保留或广播函数。每次后端调用使用唯一且不透明的 operationID,进度转换回宿主 operationId,所有终止路径清除关联;复用宿主 ID 后,旧进度与撤回不得影响新函数或构建请求。后端请求 lithe/function/invoke 或 lithe/build/* 时拒绝递归派发,lithe/host/* 在 v1 全部拒绝。平台验收:宿主接入后,从同一插件的界面发现并调用函数,验证另一插件无法调用、终止后的旧进度不污染新调用,关闭项目和停用插件后不残留进程;分别在 macOS 和 Windows 完成原生验收后才标记 verified。 macOS 宿主:scripts/test-macos.sh --filter \"PluginWebBridgeTests|MacBuildProviderSessionsTests\",覆盖插件网页只能访问本插件实例并经同意、结果/进度/pluginErrorCode 到达页面、取消与关闭页面取消函数、构建 Provider 以插件会话启动并带 pluginId、Core 拒绝码原样保留、停用插件停止 Provider。 macOS 原生验收:debug 应用安装带网页窗口的 sample-build(PR #1365)与另一个带构建系统的插件,在示例工作区的页面上依次调用 functions.list、functions.invoke(带 call 观察 functions.progress)、functions.cancel,核对结果与 pluginErrorCode;从另一插件页面访问该实例应得到 plugin.web.forbidden;调用进行中关闭项目、停用插件后用 pgrep 确认 Provider 与构建子进程不残留。 官方 Node SDK:node --test sdk/node/test/*.test.mjs,并以 LITHE_PLUGIN_SDK_NODE 指向 Node 运行 cargo test -p lithe-core --test plugin_sdk_node,示例插件经真实 Core 会话完成构建模型/依赖、函数结果/进度/失败/取消、重启恢复状态,且插件包文件清单不变。 官方 Java SDK:mvn -B -f sdk/java/pom.xml verify(CI 以 -Djvm 指向 Java 8 运行测试),并设置 LITHE_PLUGIN_SDK_JAVA、LITHE_PLUGIN_SDK_JAVA_SAMPLE、LITHE_PLUGIN_SDK_NODE 运行 cargo test -p lithe-core --test plugin_sdk_java,Java 示例按插件打包形态跑与 Node 相同的 Core 场景。", - "notes": "macOS 已接入宿主入口:插件网页通过 functions.list / functions.invoke / functions.cancel 调用自己构建 Provider 进程的函数,共享语言服务器不提供函数;2026-10-20 已在 macOS debug 应用中用 sdk/node/examples/sample-build(PR #1365,加一个只用于验收的网页窗口)完成原生验收:functions.list/invoke 的结果、functions.progress、动态注册的 snapshot.clear、pluginErrorCode(sample.buildFailed)、取消后的 requestCancelled 都到达页面,取消会结束构建子进程;另一插件的页面访问该实例得到 plugin.web.forbidden;调用进行中关闭项目或停用插件后 Provider 及其子进程均不残留。Windows 尚无插件后端宿主;不能以共享测试通过替代平台功能验收。函数不新增清单字段,不改变 pluginAPIVersion 或 requires;litheBuild v1 与旧后端继续独立工作,lithe/host/* 暂不开放,协议不提供操作系统级沙盒。 官方 Node SDK(sdk/node)只实现契约、不新增线上字段,进程树清理在 POSIX 由端到端测试覆盖,Windows 的 taskkill 路径尚未在 Windows 上验证。 官方 Java SDK(sdk/java)同样只实现契约;Java 9+ 取消时结束整棵进程树,Java 8 只结束直接子进程,其余由宿主的后端进程标记兜底;目前只经共享 Core 端到端验证,macOS 原生验收随 Maven 插件的 Java 后端一起完成。运行时启动计划的 java 版本探测改用 -version,Java 8 后端不再被误判为不可用。" + "verification": "共享 Core:cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib plugin_function 及 --lib lsp::interface::engine,核对共享 fixture、初始化发现事件、动态增删、归属拒绝、结果与插件错误码、超时取消撤回。插件会话成功完成 initialize 函数协议协商后发布初始 functionsChanged(含空注册表),无 pluginId 的宿主会话不协商、不保留或广播函数。每次后端调用使用唯一且不透明的 operationID,进度转换回宿主 operationId,所有终止路径清除关联;复用宿主 ID 后,旧进度与撤回不得影响新函数或构建请求。后端请求 lithe/function/invoke 或 lithe/build/* 时拒绝递归派发,lithe/host/* 在 v1 全部拒绝。平台验收:宿主接入后,从同一插件的界面发现并调用函数,验证另一插件无法调用、终止后的旧进度不污染新调用,关闭项目和停用插件后不残留进程;分别在 macOS 和 Windows 完成原生验收后才标记 verified。 macOS 宿主:scripts/test-macos.sh --filter \"PluginWebBridgeTests|MacBuildProviderSessionsTests\",覆盖插件网页只能访问本插件实例并经同意、结果/进度/pluginErrorCode 到达页面、取消与关闭页面取消函数、构建 Provider 以插件会话启动并带 pluginId、Core 拒绝码原样保留、停用插件停止 Provider。 macOS 原生验收:debug 应用安装带网页窗口的 sample-build(PR #1365)与另一个带构建系统的插件,在示例工作区的页面上依次调用 functions.list、functions.invoke(带 call 观察 functions.progress)、functions.cancel,核对结果与 pluginErrorCode;从另一插件页面访问该实例应得到 plugin.web.forbidden;调用进行中关闭项目、停用插件后用 pgrep 确认 Provider 与构建子进程不残留。 官方 SDK(在 lithe-plugin-docs 仓库中运行):node --test sdk/node/test/*.test.mjs;mvn -B -f sdk/java/pom.xml verify(CI 以 -Djvm 指向 Java 8 运行测试);node scripts/sdk-e2e.mjs 把 sdk/e2e 复制进契约副本锁定的主仓库 commit 并运行 plugin_sdk_node、plugin_sdk_java,两个示例插件经真实 Core 会话完成构建模型/依赖、函数结果/进度/失败/取消、重启恢复状态,且插件包文件清单不变,Java 示例按插件打包形态(plugin.json + shade 后的 JAR)运行;CI 每周再以 --ref HEAD 对主仓库最新 Core 运行一次。", + "notes": "macOS 已接入宿主入口:插件网页通过 functions.list / functions.invoke / functions.cancel 调用自己构建 Provider 进程的函数,共享语言服务器不提供函数;2026-10-20 已在 macOS debug 应用中用 Node SDK 的示例插件 sample-build(PR #1365,当时在主仓库 sdk/node/examples,加一个只用于验收的网页窗口)完成原生验收:functions.list/invoke 的结果、functions.progress、动态注册的 snapshot.clear、pluginErrorCode(sample.buildFailed)、取消后的 requestCancelled 都到达页面,取消会结束构建子进程;另一插件的页面访问该实例得到 plugin.web.forbidden;调用进行中关闭项目或停用插件后 Provider 及其子进程均不残留。Windows 尚无插件后端宿主;不能以共享测试通过替代平台功能验收。函数不新增清单字段,不改变 pluginAPIVersion 或 requires;litheBuild v1 与旧后端继续独立工作,lithe/host/* 暂不开放,协议不提供操作系统级沙盒。 官方 Node SDK(lithe-plugin-docs 的 sdk/node)只实现契约、不新增线上字段,进程树清理在 POSIX 由端到端测试覆盖,Windows 的 taskkill 路径尚未在 Windows 上验证。 官方 Java SDK(lithe-plugin-docs 的 sdk/java)同样只实现契约;Java 9+ 取消时结束整棵进程树,Java 8 只结束直接子进程,其余由宿主的后端进程标记兜底;目前只经共享 Core 端到端验证,macOS 原生验收随 Maven 插件的 Java 后端一起完成。运行时启动计划的 java 版本探测改用 -version,Java 8 后端不再被误判为不可用。" } diff --git a/shared/platform-feature-matrix/features/plugin-marketplace.json b/shared/platform-feature-matrix/features/plugin-marketplace.json index 88d4ef48d4..c6ba96c3fc 100644 --- a/shared/platform-feature-matrix/features/plugin-marketplace.json +++ b/shared/platform-feature-matrix/features/plugin-marketplace.json @@ -20,6 +20,7 @@ "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift", "macos/Tests/LitheTests/PluginMarketplaceServiceTests.swift", "macos/Tests/LitheTests/PluginMarketplaceFeatureModelTests.swift", + "macos/Tests/LitheTests/PluginRepositoryPresentationTests.swift", "macos/Tests/LitheTests/MacPluginPackageDownloaderTests.swift", "macos/Tests/LitheTests/MacPluginDiskPackageImporterTests.swift", "macos/Tests/LitheTests/PluginDiskInstallationTests.swift", @@ -35,21 +36,25 @@ ".github/workflows/release-official-plugins.yml", "macos/Sources/Lithe/Views/App/ApplicationRestartAction.swift", "macos/Sources/Lithe/Platform/MacOS/MacApplicationRelauncher.swift", - "macos/Tests/LitheTests/MacApplicationRelauncherTests.swift" + "macos/Tests/LitheTests/MacApplicationRelauncherTests.swift", + "macos/Tests/LitheTests/PluginMarketplaceLayoutTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "windows": { "evidence": [ - "shared/contracts/plugins/v1/plugin-index.schema.json", - "shared/contracts/plugins/v1/fixtures/marketplace", - "rust/lithe-core/src/plugins/marketplace.rs" + "windows/tauri/src-tauri/src/plugin_host/install.rs", + "windows/tauri/src-tauri/src/plugin_host/install_tests.rs", + "windows/tauri/src/features/plugins/declarative-plugin-launch.ts", + "windows/tauri/src/features/plugins/declarative-plugins-section.tsx", + "rust/lithe-core/src/plugins/marketplace.rs", + "shared/contracts/plugins/v1/fixtures/marketplace" ], "implementationStatus": "partial", "verificationStatus": "not-applicable" }, "owner": "Extensions", "notes": "macOS 已在隔离实例实际验证社区 ZIP/目录选择与确认、本地来源和摘要、完整程序 JSON 展开、同版本再次确认及取消不改原记录、安装字节、重启扫描与 bundle 哈希不变;磁盘信任及受影响安装流程已运行定向回归,后台审查通过工作线程独占 Store 与文件管理器保持界面可取消,取消后等待读取结束再清理快照。页面关闭与迟到结果清理由状态回归覆盖,完整市场源管理、双语和全部磁盘组合尚未逐项实机验收,因此整体 verificationStatus 保持 pending。最终 Maven/Gradle 新清单还依赖待合入的共享契约和宿主能力,本次 preview 基线的受控包验收不替代最终插件整体验收。Windows 未实现本次声明式磁盘安装链,partial 不表示可以运行 macOS 流程。", - "verification": "macOS 打开插件管理,确认有“插件市场 / 已安装”两个标签和齿轮菜单;默认有“Lithe 官方”源(不能删除)和预置的社区源 Lithe Store(默认开启、可停用和删除,删除后重启不会再出现),Lithe Store 中能看到 Lua、Go、PHP 三个社区语言插件并可安装,“Lithe 官方”源当前没有可下载插件(Go/PHP 已迁到 Lithe Store),刷新后索引缓存写在 Caches 而 app bundle 不变。安装、更新或卸载插件后,插件管理顶部出现“插件变更将在重启 Lithe 后生效”横幅和“重启 Lithe”按钮,市场列表与详情里的“重启后生效”也变成该按钮;点击后走正常退出确认,确认后应用退出并自动重新打开(不会多开),取消任一确认则不重启。插件详情显示作者、来源仓库、标签、版本、许可证、发布日期和支持平台;索引带 SVG 或 PNG `icon` 的插件(如 Lithe Store 的 Go、PHP、Lua、Maven、Gradle)在列表、详情和“已安装”里显示自己的图标;图标缺失、非法、超限或仍在加载时显示 IDEA 风格首字母头像(英文取两个字母、中文取一个字,颜色按插件 ID 固定),只有未选择插件的空状态显示默认插件图标;断网导致的失败在网络恢复后再次打开页面或刷新仓库即可恢复,刷新仓库后分支 URL 的新图标会替换旧图标,快速切换插件时详情头部不会出现上一个插件的图标;图标不写 bundle;索引里没有当前架构的包时显示“暂不支持 <平台> 平台”且不可安装,插件 API 版本不满足时显示不支持原因。在“管理插件仓库”中添加一个 GitHub 仓库地址,确认解析到仓库根目录的 lithe-plugin-index.json,可刷新、停用和删除;第三方源列出 dev.lithe.* 插件时该条目被忽略。安装社区插件时先校验 SHA-256(不匹配则不解压),超过下载上限的响应在下载中途被中止,解压后过大或文件过多的压缩包(含谎报大小的 ZIP)被拒绝且不留临时文件,再弹出确认框列出权限、要运行的程序和来源仓库,取消则不安装;已安装后改动插件目录内任意文件,重启后该插件拒绝加载;本地磁盘社区首次安装、更新、重装和同版本替换每次都必须明确确认,即使权限和程序未变;市场入口保持首次/缺少审批/出现 newPrograms 时确认的既有规则,无新程序的市场更新可沿用审批。确认框展示 Rust installReview 的全部权限与完整 canonical program 内容,包括参数、参数模板、验证探测、初始化选项、运行时与构建声明,已识别程序展开 Full Program Declaration 阅读完整 JSON,未知程序直接显示完整 JSON;同一插件出现在多个源时由用户选源,更新只走安装时记录的源。官方源只接受 Lithe 签名包。分别在英文和简体中文界面下检查列表、详情、安装确认框和仓库管理窗口,文字(含刷新时间格式)都跟随 Lithe 的语言设置,插件描述在有 localizedDescriptions 翻译时显示对应语言,否则显示默认英文描述;用辅助功能“按下”列表行只会选中,不会触发安装。索引里最新版本在本机不可用时,详情的版本、发布日期和平台显示实际会安装的版本,并单独注明最新版本不可用。磁盘验收:安装声明内置 Python 或其他已安装插件语言的社区包,重启拒绝冲突包后,原拥有者未设置、显式关闭或开启的偏好均保持原值;正常首次确认安装仍自动启用,更新保留选择。分别在市场下载和磁盘导入入口构造显式 ZIP 条目未超限但唯一隐式父目录超限的包,以及累计规范化路径 UTF-8 字节超限的包,确认在启动解压前拒绝并清理;显式目录与共享隐式父目录只计一次,恰好预算的合法包可解压。在隔离的用户配置与注入临时根下,从齿轮菜单分别选择一个合法社区 ZIP 与解包目录;准备后检查只生成本次 lithe-plugin-disk- 私有快照,ZIP 的 archiveSHA256 与快照 archive.zip 实测 SHA-256 相同,目录无归档摘要,界面显示本地文件名/内容摘要并明确未认证发布者或仓库。先取消,确认未安装且本次目录删除;再确认安装并重启,核对 installation.json 仅有 disk 来源、内容 digest 与完整 canonical approvedPrograms,没有伪造 marketplace 来源。分别导入程序未变的新版、相同版本原包及相同版本改过脚本的包,每次必须完整展示权限和程序并等待确认;取消不得改变已安装版本。确认框等待时更换原始 ZIP/目录,安装内容应仍等于准备快照;在受控测试中改动已准备快照后提交,应在 install staging 重验失败;安装后增加/删除/改动文件应在 scan 拒绝加载,改动上一版本后 rollback 应失败。正常升级再回滚时,当前/上一版本各自保留 origin、disk/marketplace 来源;同版本替换保留既有上一版本。刷新包含同 ID 的市场源,确认磁盘版本不出现可自动接管的更新/重装入口。以符号链接、Finder alias、特殊文件、多个 plugin.json、超过 64 层的目录、落在源目录内的临时根、中央目录/local header 路径类型冲突或重复条目的 ZIP、超限或损坏 ZIP、非法声明、冒用官方 ID/namespace 的包验证拒绝与清理;官方 publisher 签名、sameTeamAsHost 的 bundle 边界及仅 DEBUG 可用的 localDevelopment 包仍按原策略。导入和待确认期间关闭插件页,确认任务取消、已有快照立即清理、迟到的准备结果只清理而不会安装;成功、取消和失败后检查本次快照和 staging 无残留;在典型工作流前后比较发行 bundle 文件清单/哈希相同,关闭本次应用与子进程。静态及资源验证命令:node scripts/test-reuse-worktree-resources.mjs、./scripts/verify-agent-notes.sh、./scripts/verify-platform-feature-matrix.sh、./scripts/verify-runtime-bundle-immutability.sh;macOS 产品测试使用 ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'PluginMarketplace|PluginDiskInstallation|MacPluginDiskPackageImporter|PluginPackageStore|PluginManager',核对报告包含实际磁盘用例,未运行产品验收前保持 pending。Windows 当前仅共享索引契约、fixture 和 Rust Core 校验,无插件市场 UI 或此声明式 ZIP/目录快照审批流程,现有 worker-package 导入属于另一格式,实现状态保持 partial。" + "verification": "macOS 打开插件管理,确认有“插件市场 / 已安装”两个标签和齿轮菜单;默认有“Lithe 官方”源(不能删除)和预置的社区源 Lithe Store(默认开启、可停用和删除,删除后重启不会再出现),Lithe Store 中能看到 Lua、Go、PHP 三个社区语言插件并可安装,“Lithe 官方”源当前没有可下载插件(Go/PHP 已迁到 Lithe Store),刷新后索引缓存写在 Caches 而 app bundle 不变。安装、更新或卸载插件后,插件管理顶部出现“插件变更将在重启 Lithe 后生效”横幅和“重启 Lithe”按钮,市场列表与详情里的“重启后生效”也变成该按钮;点击后走正常退出确认,确认后应用退出并自动重新打开(不会多开),取消任一确认则不重启。插件详情显示作者、来源仓库、标签、版本、许可证、发布日期和支持平台;索引带 SVG 或 PNG `icon` 的插件(如 Lithe Store 的 Go、PHP、Lua、Maven、Gradle)在列表、详情和“已安装”里显示自己的图标;图标缺失、非法、超限或仍在加载时显示 IDEA 风格首字母头像(英文取两个字母、中文取一个字,颜色按插件 ID 固定),只有未选择插件的空状态显示默认插件图标;断网导致的失败在网络恢复后再次打开页面或刷新仓库即可恢复,刷新仓库后分支 URL 的新图标会替换旧图标,快速切换插件时详情头部不会出现上一个插件的图标;图标不写 bundle;索引里没有当前架构的包时显示“暂不支持 <平台> 平台”且不可安装,插件 API 版本不满足时显示不支持原因。在“管理插件仓库”中添加一个 GitHub 仓库地址,确认解析到仓库根目录的 lithe-plugin-index.json,可刷新、停用和删除;弹窗按设计稿呈现:仓库列在圆角卡片里,每行是名称(旁边有标签:“Lithe 官方”和预置的 Lithe Store 为“官方”,用户添加的仓库为“社区”)、等宽索引地址和状态行(“已于 刚刚 刷新”随时间变为“5 分钟前”,超过一周显示日期;有被忽略条目时追加“已忽略条目:N”,停用的源标注“已禁用”,刷新失败显示原因),右侧依次是启用开关、“刷新”和红色“移除”;“Lithe 官方”行只能停用,用灰色“默认”代替“移除”;底部左侧“全部刷新”,右侧“完成”。第三方源列出 dev.lithe.* 插件时该条目被忽略。安装社区插件时先校验 SHA-256(不匹配则不解压),超过下载上限的响应在下载中途被中止,解压后过大或文件过多的压缩包(含谎报大小的 ZIP)被拒绝且不留临时文件,再弹出确认框列出权限、要运行的程序和来源仓库,取消则不安装;已安装后改动插件目录内任意文件,重启后该插件拒绝加载;本地磁盘社区首次安装、更新、重装和同版本替换每次都必须明确确认,即使权限和程序未变;市场入口保持首次/缺少审批/出现 newPrograms 时确认的既有规则,无新程序的市场更新可沿用审批。确认框展示 Rust installReview 的全部权限与完整 canonical program 内容,包括参数、参数模板、验证探测、初始化选项、运行时与构建声明,已识别程序展开 Full Program Declaration 阅读完整 JSON,未知程序直接显示完整 JSON;同一插件出现在多个源时由用户选源,更新只走安装时记录的源。官方源只接受 Lithe 签名包。分别在英文和简体中文界面下检查列表、详情、安装确认框和仓库管理窗口,文字(含刷新时间格式)都跟随 Lithe 的语言设置,插件描述在有 localizedDescriptions 翻译时显示对应语言,否则显示默认英文描述;用辅助功能“按下”列表行只会选中,不会触发安装。索引里最新版本在本机不可用时,详情的版本、发布日期和平台显示实际会安装的版本,并单独注明最新版本不可用。磁盘验收:安装声明内置 Python 或其他已安装插件语言的社区包,重启拒绝冲突包后,原拥有者未设置、显式关闭或开启的偏好均保持原值;正常首次确认安装仍自动启用,更新保留选择。分别在市场下载和磁盘导入入口构造显式 ZIP 条目未超限但唯一隐式父目录超限的包,以及累计规范化路径 UTF-8 字节超限的包,确认在启动解压前拒绝并清理;显式目录与共享隐式父目录只计一次,恰好预算的合法包可解压。在隔离的用户配置与注入临时根下,从齿轮菜单分别选择一个合法社区 ZIP 与解包目录;准备后检查只生成本次 lithe-plugin-disk- 私有快照,ZIP 的 archiveSHA256 与快照 archive.zip 实测 SHA-256 相同,目录无归档摘要,界面显示本地文件名/内容摘要并明确未认证发布者或仓库。先取消,确认未安装且本次目录删除;再确认安装并重启,核对 installation.json 仅有 disk 来源、内容 digest 与完整 canonical approvedPrograms,没有伪造 marketplace 来源。分别导入程序未变的新版、相同版本原包及相同版本改过脚本的包,每次必须完整展示权限和程序并等待确认;取消不得改变已安装版本。确认框等待时更换原始 ZIP/目录,安装内容应仍等于准备快照;在受控测试中改动已准备快照后提交,应在 install staging 重验失败;安装后增加/删除/改动文件应在 scan 拒绝加载,改动上一版本后 rollback 应失败。正常升级再回滚时,当前/上一版本各自保留 origin、disk/marketplace 来源;同版本替换保留既有上一版本。刷新包含同 ID 的市场源,确认磁盘版本不出现可自动接管的更新/重装入口。以符号链接、Finder alias、特殊文件、多个 plugin.json、超过 64 层的目录、落在源目录内的临时根、中央目录/local header 路径类型冲突或重复条目的 ZIP、超限或损坏 ZIP、非法声明、冒用官方 ID/namespace 的包验证拒绝与清理;官方 publisher 签名、sameTeamAsHost 的 bundle 边界及仅 DEBUG 可用的 localDevelopment 包仍按原策略。导入和待确认期间关闭插件页,确认任务取消、已有快照立即清理、迟到的准备结果只清理而不会安装;成功、取消和失败后检查本次快照和 staging 无残留;在典型工作流前后比较发行 bundle 文件清单/哈希相同,关闭本次应用与子进程。静态及资源验证命令:node scripts/test-reuse-worktree-resources.mjs、./scripts/verify-agent-notes.sh、./scripts/verify-platform-feature-matrix.sh、./scripts/verify-runtime-bundle-immutability.sh;macOS 产品测试使用 ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'PluginMarketplace|PluginDiskInstallation|MacPluginDiskPackageImporter|PluginPackageStore|PluginManager',核对报告包含实际磁盘用例,未运行产品验收前保持 pending。Windows 已实现契约插件的磁盘导入链:ZIP/目录→私有快照(拒绝 reparse point、路径逃逸、超限文件与恶意 ZIP 条目)→plugins.marketplace.installReview 全量审查(权限+完整程序清单,界面明示未认证发布者)→确认时摘要比对拒绝快照调包→staging 复验后激活→卸载/回滚/启停。磁盘导入的取消清理、快照篡改拒绝与 staging 失败清理由 Rust 测试覆盖;多仓库源市场 UI 仍缺失(M3+),实机验收前 verificationStatus 保持 not-applicable。Worker 扩展模型的非语言市场安装命令(install_extension_from_url 等)现注册为显式占位错误,等待独立后端。;市场列表与详情之间的分隔条使用真实页面回归:中英文描述与深浅主题下缩放窗口、拖动后释放,再刷新索引,详情视口始终位于分隔条右侧;正文按视口宽度换行,窄于来源选择器和提示卡所需宽度时可横向滚动至末端,短详情保持顶部对齐。测试使用私有临时偏好域,关闭窗口后清理;仅原生组件证据,整体实机验收仍 pending。" } diff --git a/shared/platform-feature-matrix/features/plugin-project-generators.json b/shared/platform-feature-matrix/features/plugin-project-generators.json new file mode 100644 index 0000000000..92c1c744f7 --- /dev/null +++ b/shared/platform-feature-matrix/features/plugin-project-generators.json @@ -0,0 +1,30 @@ +{ + "id": "plugin-project-generators", + "area": "工作区", + "group": "项目", + "capability": "新建项目窗口按 IDEA 的布局列出“新建项目”和“生成器”两组条目:空项目由宿主提供,其余条目由插件通过 projectGenerators 1 注入(随 Lithe 内置的 Java Support 直接提供 Java、Maven Archetype、Spring Boot)。名称、位置、创建 Git 仓库由宿主绘制;字段、派生默认值、显示条件和生成计划由 Rust Core 计算;先在目标目录旁的临时目录生成,完成后整体移动到位,失败或取消不留残留。条目需要的语言或构建系统插件未启用时提示一键启用,未安装时跳转插件市场搜索;所选 JDK 写入新项目的运行时设置,模板指定的文件随项目一起打开", + "macos": { + "evidence": [ + "rust/lithe-core/src/project_generators/", + "shared/contracts/plugins/v1/fixtures/project-generator.plugin.json", + "shared/contracts/plugins/v1/fixtures/project-plans.json", + "Plugins/mac/Official/JavaSupport/plugin.json", + "macos/Sources/Lithe/Views/Workspace/NewProjectView.swift", + "macos/Sources/Lithe/Application/Features/NewProjectFeatureModel.swift", + "macos/Sources/Lithe/Services/ProjectGeneration/ProjectGenerationService.swift", + "macos/Sources/Lithe/Platform/MacOS/ProjectGeneration/MacProjectGenerationPlatform.swift", + "macos/Tests/LitheTests/ProjectGenerationTests.swift" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "windows": { + "evidence": [ + "shared/contracts/plugins/v1/README.md" + ], + "implementationStatus": "missing", + "verificationStatus": "not-applicable" + }, + "owner": "Plugin Platform", + "verification": "macOS:从欢迎页和项目切换器点“新建项目”,确认左栏有“新建项目”(Java、空项目)和“生成器”(Maven Archetype、Spring Boot)两组且可搜索。Java 选 Maven、默认 JDK 创建,确认生成 pom.xml(maven.compiler.release 等于所选 JDK 主版本)、.gitignore 和 src/main/java/org/example/Main.java,项目打开后 Main.java 已打开、Build 工具窗口识别 Maven 项目。勾选“创建 Git 仓库”时项目根目录有 .git。Spring Boot 选几个依赖创建,确认从 start.spring.io 下载并解压、mvnw 可执行;把服务器 URL 改成不可达地址时报错且目标目录下没有残留的 .*.lithe-creating-* 临时目录。Maven Archetype 在已安装 mvn 时生成 quickstart 项目,未安装时提示找不到 mvn。停用 Java Support 后,三个条目变灰、创建按钮不可用,只出现一条“启用 Java Support”,点击后条目恢复可用;未安装 Maven 插件时出现“在插件市场中查找”并跳到市场搜索 maven。目标目录已存在时拒绝创建且不改动原目录。Windows:宿主不声明 projectGenerators,需要它的插件在市场中隐藏。" +} diff --git a/shared/platform-feature-matrix/features/plugin-registered-settings-pages.json b/shared/platform-feature-matrix/features/plugin-registered-settings-pages.json index 996eccf2ef..4576d8d480 100644 --- a/shared/platform-feature-matrix/features/plugin-registered-settings-pages.json +++ b/shared/platform-feature-matrix/features/plugin-registered-settings-pages.json @@ -2,7 +2,7 @@ "id": "plugin-registered-settings-pages", "area": "插件", "group": "插件管理", - "capability": "插件通过 settings 2 在 plugin.json 里把设置项和按钮注册到“语言与框架 › <语言>”或“构建、执行、部署 › <构建系统>”页,按 section 分组;设置可声明项目级覆盖(写入项目 .lithe/plugin-settings.json)和生效方式(重启、立即、重新加载、下次运行),“立即生效”的值经 workspace/configuration 推给正在运行的语言服务器;按钮调用插件自己的插件函数。宿主只提供通用页面、控件和语言服务状态卡,不内置任何语言或构建工具的页面", + "capability": "插件通过 settings 2 在 plugin.json 里把设置项和按钮注册到“语言与框架 › <语言>”或“构建、执行、部署 › <构建系统>”页,按 section 分组;设置可声明项目级覆盖(写入项目 .lithe/plugin-settings.json)和生效方式(重启、立即、重新加载、下次运行),“立即生效”的值经 workspace/configuration 推给正在运行的语言服务器;按钮调用插件自己的插件函数。宿主只提供通用页面、控件和语言服务状态卡,不内置任何语言或构建工具的页面;路径选择按钮保持完整可见,窄面板由输入框和标题列缩短以容纳状态标签", "macos": { "evidence": [ "rust/lithe-core/src/plugins/settings.rs", @@ -13,7 +13,9 @@ "macos/Sources/Lithe/Views/App/PluginSettingsSection.swift", "macos/Sources/Lithe/Views/App/SettingsView.swift", "macos/Tests/LitheTests/PluginSettingsTests.swift", - "shared/contracts/plugins/v1/fixtures/settings-v2.plugin.json" + "shared/contracts/plugins/v1/fixtures/settings-v2.plugin.json", + "macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift", + "macos/Tests/LitheTests/PluginSettingsLayoutTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -26,5 +28,5 @@ "verificationStatus": "not-applicable" }, "owner": "Plugin Platform", - "verification": "macOS:先使用 Swift 6.3.3 构建 Release,确认插件页选择回调不触发编译器崩溃;在 Release 应用中选择插件设置页,确认其侧栏分组自动展开。macOS:安装一个声明了 settings 2 页面(page/section/scope/effect/settingActions)的插件并启用,确认设置侧栏的对应分组下出现该语言或构建系统页,页内按插件和 section 分组显示设置与按钮;搜索设置标题能定位到该页。打开项目后把 scope 为 project 的设置切到“此项目”并修改,确认项目下生成 .lithe/plugin-settings.json 且显示“已在此项目覆盖”,恢复默认后文件被删除。修改 effect 为 immediate 的设置,确认语言服务器不重启而配置生效;修改 restart 设置时出现重启提示。点击带 confirm 的按钮先弹确认,再调用插件函数并给出结果通知。停用插件后页面消失。Windows:宿主仍只声明 settings/languageServers 1,需要 v2 的插件在市场中隐藏。" + "verification": "macOS:先使用 Swift 6.3.3 构建 Release,确认插件页选择回调不触发编译器崩溃;在 Release 应用中选择插件设置页,确认其侧栏分组自动展开。macOS:安装一个声明了 settings 2 页面(page/section/scope/effect/settingActions)的插件并启用,确认设置侧栏的对应分组下出现该语言或构建系统页,页内按插件和 section 分组显示设置与按钮;搜索设置标题能定位到该页。打开项目后把 scope 为 project 的设置切到“此项目”并修改,确认项目下生成 .lithe/plugin-settings.json 且显示“已在此项目覆盖”,恢复默认后文件被删除。修改 effect 为 immediate 的设置,确认语言服务器不重启而配置生效;修改 restart 设置时出现重启提示。点击带 confirm 的按钮先弹确认,再调用插件函数并给出结果通知。停用插件后页面消失。macOS 布局回归:运行 PluginSettingsLayoutTests,检查 480/640/900 点宽、深浅主题的路径控件与选择按钮不重叠且留在容器内,点击选择按钮写入所选路径;在插件详情的窄设置页确认“选择…”文字完整。Windows:宿主仍只声明 settings/languageServers 1,需要 v2 的插件在市场中隐藏。" } diff --git a/shared/platform-feature-matrix/features/plugin-web-tool-windows.json b/shared/platform-feature-matrix/features/plugin-web-tool-windows.json index e2c105d1ab..959e2aec51 100644 --- a/shared/platform-feature-matrix/features/plugin-web-tool-windows.json +++ b/shared/platform-feature-matrix/features/plugin-web-tool-windows.json @@ -2,7 +2,7 @@ "id": "plugin-web-tool-windows", "area": "插件", "group": "插件界面", - "capability": "插件可以在宿主管理的工具窗口里显示自己包内的 HTML 页面(ui 2、ui.web 权限):只读取已安装包内的文件,CSP 禁止联网、内联脚本和 eval,网页存储不持久;页面通过 window.lithe 桥接只能操作本插件的构建系统和当前工作区,启动构建工具前由宿主确认,输出进入 Build Output,页面只可取消本次实际启动的运行;任务与测试在确认后最终启动前拒绝已有运行,保留宿主或其他页面的运行及 Build Output,包含等待确认期间新启动的运行;选择必须唯一命中模型,目标以经过校验的完整 tags 集合区分同名同目录的 binary/example,最终执行参数从模型重建,拒绝歧义和伪造标签;模型保留 Core 的声明运行项和任务类别;异步激活、检测和依赖请求复核工作区并受本地期限约束;直接关闭原生项目窗口时,在会话首次异步退出前同步销毁隐藏页面并撤销进程许可,即使设置窗口继续持有项目模型,待处理和后续桥接请求也被拒绝,不能重新激活执行模块;插件禁用、更新或卸载时同进程所有项目窗口(含隐藏页)都被销毁,新项目窗口继承阻断状态,包变更后的旧管理器重新启用不能在重启前重建页面", + "capability": "插件可以在宿主管理的工具窗口里显示自己包内的 HTML 页面(ui 2、ui.web 权限):只读取已安装包内的文件,CSP 禁止联网、内联脚本和 eval,网页存储不持久;页面通过 window.lithe 桥接只能操作本插件的构建系统和当前工作区,启动构建工具前由宿主确认,输出进入 Build Output,页面只可取消本次实际启动的运行;任务与测试在确认后最终启动前拒绝已有运行,保留宿主或其他页面的运行及 Build Output,包含等待确认期间新启动的运行;选择必须唯一命中模型,目标以经过校验的完整 tags 集合区分同名同目录的 binary/example,最终执行参数从模型重建,拒绝歧义和伪造标签;模型保留 Core 的声明运行项和任务类别;异步激活、检测和依赖请求复核工作区并受本地期限约束;直接关闭原生项目窗口时,在会话首次异步退出前同步销毁隐藏页面并撤销进程许可,即使设置窗口继续持有项目模型,待处理和后续桥接请求也被拒绝,不能重新激活执行模块;插件禁用、更新或卸载时同进程所有项目窗口(含隐藏页)都被销毁,新项目窗口继承阻断状态,包变更后的旧管理器重新启用不能在重启前重建页面;页面申请摄像头或麦克风时默认没有权限,由宿主逐次弹窗确认(写明这是插件并附插件 ID,只认本插件主框架,页面不在窗口里直接拒绝,视图离开窗口即结束采集);桥接版本 2(lithe.version)增加 dialog.openFile 与 dialog.saveFile:由宿主弹出文件对话框并写明发起的插件,同一页面同时只开一个,页面只拿到用户选中的绝对路径(取消为 null),读写交给插件后端,页面关闭后不再返回选择结果", "macos": { "evidence": [ "macos/Sources/Lithe/Application/Features/PluginWebBridge.swift", @@ -27,6 +27,9 @@ "macos/Sources/Lithe/Models/Workspace/ProjectSessionManager.swift", "macos/Sources/Lithe/Views/Workbench/DeclarativeToolWindowView.swift", "macos/Tests/LitheTests/PluginWebBridgeTests.swift", + "macos/Sources/Lithe/Platform/MacOS/WebMediaCapture.swift", + "macos/Tests/LitheTests/PluginWebCaptureTests.swift", + "macos/Tests/LitheTests/WebMediaCaptureTests.swift", "shared/contracts/plugins/v1/fixtures/web-panel.plugin.json" ], "implementationStatus": "implemented", @@ -34,11 +37,24 @@ }, "windows": { "evidence": [ - "shared/contracts/plugins/v1/README.md" + "windows/tauri/src-tauri/src/plugin_host/web.rs", + "windows/tauri/src-tauri/src/plugin_host/tests.rs", + "windows/tauri/src/features/plugins/declarative-plugins-section.tsx", + "shared/contracts/plugins/v1/fixtures/web-panel.plugin.json", + "windows/tauri/src/features/plugins/plugin-web-appearance.ts", + "windows/tauri/src/features/plugins/plugin-web-appearance.test.ts", + "windows/tauri/src/features/plugins/plugin-web-open-file.test.ts", + "windows/tauri/src-tauri/capabilities/plugin-web.json", + "windows/tauri/src-tauri/permissions/declarative-web/bridge.toml", + "windows/tauri/src-tauri/src/plugin_host/web_network.rs", + "windows/tauri/src-tauri/src/plugin_host/lifecycle.rs", + "windows/tauri/src/features/plugins/plugin-web-open-file.ts", + "windows/tauri/src/features/plugins/plugin-lifecycle-listener.test.ts", + "windows/tauri/src/features/workspace/services/workspace-lifecycle.test.ts" ], - "implementationStatus": "missing", - "verificationStatus": "not-applicable" + "implementationStatus": "partial", + "verificationStatus": "pending" }, "owner": "Plugin Platform", - "verification": "自动回归:LITHE_TEST_WINDOW_MODE=isolated ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'PluginWeb|PluginManagerTests|PluginDiskInstallationTests|BuildSystemServiceTests',检查包内脚本、CSP、主题事件、nonPersistent 数据存储和包文件字节不变;验证错误方法/参数、跨插件/工作区/符号链接逃逸、激活中工作区切换、等待期限、关闭后释放、真实 resetDedicatedWindowSession 在设置窗口仍强持项目模型且网页隐藏时同步关闭页面并清空登记表(TestGate 挂起检测和模块退出,待处理/退出中/退出后请求均返回 plugin.web.forbidden,桥接激活次数不增且退出后 Execution 未重新激活;新回归编译和运行待 CI 验证)、拒绝启动时不认领其他运行、任务与测试两入口遇 busy 均不取消现有运行或清空输出(含 TestGate 控制等待确认期间出现新运行)、同名 demo application/example 依完整 tags 唯一选中且最终参数来自模型、真实 Cargo 模型经桥接选中 example 后通过 Core targetTags 校验并生成 run --example demo、缺少区分信息或伪造/错误类型 tags 被拒绝、停用后旧视图无法重建且工作区关闭不撤销停用、安装和回滚校验失败保留旧页面、社区磁盘同版本替换及升级经真实审查和管理器入口在修改资源及安装记录前同步关闭双项目隐藏页面,拒绝已批准的进程许可并阻止后注册窗口及重新启用在重启前重建(PluginDiskInstallationTests/diskReplacementClosesEveryProjectPageBeforeMutation;篡改快照校验失败保留旧页面)、替换前关闭页面、模型任务类别及声明运行项完整转发、双项目页面与同意记录同步失效、后注册窗口继承阻断、包变更后旧管理器启用不能解锁、弱引用不保留关闭窗口。node scripts/test-reuse-worktree-resources.mjs 拒绝网页状态跨工作树复用。产品界面验收仍 pending。macOS:在开发版里用“从磁盘安装插件”安装 web-panel 夹具(plugin.json 旁放 ui/index.html 和 icons/panel.svg),重启后打开右侧 Web Panel 窗口,确认页面显示、根元素带 data-theme 和 --lithe-* 变量,切换浅色/深色后 ui.themeChanged 到达;页面里的内联脚本和 fetch 外网被 CSP 拦截。在页面控制台调用 lithe.request('ui.setState', {state:{a:1}}),隐藏再显示窗口后 ui.getState 仍返回该值;调用未知方法得到 plugin.web.unknownMethod,openFile 传 ../ 得到 plugin.web.forbidden。在设置 › 插件里停用该插件,窗口消失;重新启用后窗口回到原来的停靠位置并重新加载页面。打开过网页窗口后运行 scripts/verify-runtime-bundle-immutability.sh 通过。Windows:WebView2 宿主尚未实现,继续只声明 ui 1。" + "verification": "自动回归:文件对话框见 PluginWebBridgeTests 的 fileDialogsNameThePluginAndReturnOnlyTheChosenPath 与 oneFileDialogAtATimeAndAClosedPageLearnsNoChoice;LITHE_TEST_WINDOW_MODE=isolated ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'PluginWeb|PluginManagerTests|PluginDiskInstallationTests|BuildSystemServiceTests',检查包内脚本、CSP、主题事件、nonPersistent 数据存储和包文件字节不变;验证错误方法/参数、跨插件/工作区/符号链接逃逸、激活中工作区切换、等待期限、关闭后释放、真实 resetDedicatedWindowSession 在设置窗口仍强持项目模型且网页隐藏时同步关闭页面并清空登记表(TestGate 挂起检测和模块退出,待处理/退出中/退出后请求均返回 plugin.web.forbidden,桥接激活次数不增且退出后 Execution 未重新激活;新回归编译和运行待 CI 验证)、拒绝启动时不认领其他运行、任务与测试两入口遇 busy 均不取消现有运行或清空输出(含 TestGate 控制等待确认期间出现新运行)、同名 demo application/example 依完整 tags 唯一选中且最终参数来自模型、真实 Cargo 模型经桥接选中 example 后通过 Core targetTags 校验并生成 run --example demo、缺少区分信息或伪造/错误类型 tags 被拒绝、停用后旧视图无法重建且工作区关闭不撤销停用、安装和回滚校验失败保留旧页面、社区磁盘同版本替换及升级经真实审查和管理器入口在修改资源及安装记录前同步关闭双项目隐藏页面,拒绝已批准的进程许可并阻止后注册窗口及重新启用在重启前重建(PluginDiskInstallationTests/diskReplacementClosesEveryProjectPageBeforeMutation;篡改快照校验失败保留旧页面)、替换前关闭页面、模型任务类别及声明运行项完整转发、双项目页面与同意记录同步失效、后注册窗口继承阻断、包变更后旧管理器启用不能解锁、弱引用不保留关闭窗口。node scripts/test-reuse-worktree-resources.mjs 拒绝网页状态跨工作树复用。产品界面验收仍 pending。macOS:在开发版里用“从磁盘安装插件”安装 web-panel 夹具(plugin.json 旁放 ui/index.html 和 icons/panel.svg),重启后打开右侧 Web Panel 窗口,确认页面显示、根元素带 data-theme 和 --lithe-* 变量,切换浅色/深色后 ui.themeChanged 到达;页面里的内联脚本和 fetch 外网被 CSP 拦截。在页面控制台调用 lithe.request('ui.setState', {state:{a:1}}),隐藏再显示窗口后 ui.getState 仍返回该值;调用未知方法得到 plugin.web.unknownMethod,openFile 传 ../ 得到 plugin.web.forbidden。在设置 › 插件里停用该插件,窗口消失;重新启用后窗口回到原来的停靠位置并重新加载页面。打开过网页窗口后运行 scripts/verify-runtime-bundle-immutability.sh 通过。摄像头与麦克风:自动回归 ./scripts/test-macos.sh --filter 'PluginMediaCapture|MockCapture|WebMedia',插件页在真实 WKWebView 里用 WebKit 模拟设备验证:用户点 Allow 才授权且问题记录的请求者是插件名加 ID,拒绝得到 NotAllowedError,缺用途说明、页面不在窗口里、非本插件主框架一律不弹窗直接拒绝,隐藏窗口后采集结束,点 Allow 之后页面才被隐藏时才启动的设备也被立即停掉,隐藏之后才点的 Allow 不生效,源码扫描保证每个 WKWebView 都有人回答请求且 .grant 只出现在 WebMediaCapture.swift;补上 Info.plist 用途说明后,在开发版里用调用 getUserMedia 的插件页确认弹窗写明插件和 ID、隐藏工具窗口后系统菜单栏的绿点或橙点消失、再显示后同一页面第二次调用是否再询问(WebKit 可能复用同一文档里已批准的授权,按实测校对弹窗说明)(当前构建没有用途说明,页面里 navigator.mediaDevices 不存在,这一步尚不能做)。Windows:ui 2 宿主已实现 state/openFile 与 locale/theme。运行 WindowsRust 和 Frontend 计时 harness,验证包外来源拒绝、内部/外部 junction 的真实目标边界、原生桥脚本的主题初值与事件;在真实 WebView2 中检查同包导航可用,外站导航、弹窗和下载被拒绝,外站得不到桥;切换语言和主题,确认 context、data-theme 与 --lithe-* 同步。设置页与 Web 窗口视觉验收仍 pending,不能以脚本测试代替。 Windows ACL 回归读取实际编译的 Tauri authority:插件私有来源仅允许 declarative-web request,拒绝 fs/http/event 及常规宿主命令,外站与主窗口不能借此调用桥;实机必须验证 state 往返成功,不能只验证拒绝分支。 Windows 请求层:页面加载前安装 WebResourceRequested 过滤器;本机 HTTP 计数器必须证明外站导航 GET 为零,不能仅以导航被取消或 URL 不变判断隔离成功。 Windows 双工作区回归:同一插件的页面和 state 分别归属创建窗口及工作区;A 页 openFile 只在 A 编辑器打开,切换工作区、路径拒绝或编辑器失败须向页面返回错误,不能先返回成功。关闭 A 清理其页面/状态但保留 B;禁用插件同时撤销全部页面与待处理请求,包变更等待资源读取退出。页面仍使用独立 WebView,停靠及布局恢复保持 partial。 关闭工作区与页面写状态/打开文件交错时,先原子撤销所属页面,再清状态与待确认请求;旧页面实例和关闭前发起的异步页面创建都必须被拒绝,重新打开同一工作区的新请求仍可用。" } diff --git a/shared/platform-feature-matrix/features/remote-development-workspace.json b/shared/platform-feature-matrix/features/remote-development-workspace.json new file mode 100644 index 0000000000..71a9f5c0be --- /dev/null +++ b/shared/platform-feature-matrix/features/remote-development-workspace.json @@ -0,0 +1,34 @@ +{ + "id": "remote-development-workspace", + "area": "工作区", + "group": "远程开发", + "capability": "在 SSH 主机、容器和远程 Mac 上打开并开发项目", + "macos": { + "evidence": [ + "macos/Sources/Lithe/Views/Workspace/RemoteConnectionsView.swift", + "macos/Sources/Lithe/Application/Features/RemoteConnectionsFeatureModel.swift", + "macos/Sources/Lithe/Services/Remote/RemoteWorkspaceRouting.swift", + "macos/Tests/LitheTests/RemoteConnectionsTests.swift", + "rust/lithe-core/src/remote", + "rust/lithe-remote-client/src/launch.rs", + "rust/lithe-remote-protocol", + "rust/lithe-remote-server", + "rust/lithe-remote-client", + "shared/contracts/remote-workspace-protocol-v1.md", + ".agents/notes/implemented/feature/2026-10-24-remote-ssh-connections.md", + ".agents/notes/implemented/architecture/2026-10-24-remote-workspace-routing.md", + ".agents/notes/proposed/architecture/2026-10-24-remote-development-workspace-host.md" + ], + "implementationStatus": "partial", + "verificationStatus": "pending" + }, + "windows": { + "evidence": [ + "windows/tauri/src/features/wsl/controllers/wsl-workspace.ts" + ], + "implementationStatus": "missing", + "verificationStatus": "not-applicable" + }, + "owner": "Workspace", + "verification": "欢迎页点“远程开发”:新建一条 Linux SSH 连接(分别试 SSH Agent、私钥和密码,密码主机再试一次 2FA),测试连接后核对主机信息卡片,手动查询地区;点“打开”后文件树列出远端目录,打开、编辑并保存一个文件,在远端用其他方式修改同一文件后再次保存应提示冲突。远程 Mac、容器、Git、搜索、终端、构建和文件监听尚未接入远程工作区。Rust 后端由 scripts/verify-remote-workspace.sh 验证。" +} diff --git a/shared/platform-feature-matrix/features/run-processes-end-with-ide.json b/shared/platform-feature-matrix/features/run-processes-end-with-ide.json index 78b49de767..f25185c1fc 100644 --- a/shared/platform-feature-matrix/features/run-processes-end-with-ide.json +++ b/shared/platform-feature-matrix/features/run-processes-end-with-ide.json @@ -15,7 +15,11 @@ "shared/fixtures/process-lifecycle/inventory-v1.json", "macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift", "macos/Tests/LitheTests/MacProcessRunnerTests.swift", - "macos/Tests/LitheTests/ProcessLifecycleFixtureTests.swift" + "macos/Tests/LitheTests/ProcessLifecycleFixtureTests.swift", + "macos/Sources/Lithe/Application/Features/ProcessExitCoordinator.swift", + "macos/Sources/Lithe/Application/Features/WorkspaceSessionCoordinator.swift", + "rust/lithe-core/src/process/lifecycle.rs", + "shared/fixtures/process-lifecycle/exit-plan-v1.json" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -23,12 +27,22 @@ "windows": { "evidence": [ "windows/tauri/src-tauri/src/main.rs", - "windows/tauri/src-tauri/src/run.rs" + "windows/tauri/src-tauri/src/run.rs", + "windows/tauri/src-tauri/src/debug.rs", + "windows/tauri/src-tauri/src/owned_processes.rs", + "rust/lithe-git-host/src/lib.rs", + "rust/lithe-git-host/tests/process.rs", + "rust/lithe-core/src/lsp/interface/process.rs", + "rust/lithe-core/src/process/lifecycle.rs", + "windows/tauri/src/features/window/components/window-close-guard.tsx", + "windows/tauri/src/features/window/services/run-services-exit.ts", + "windows/tauri/src/features/window/services/run-services-exit.test.ts", + "windows/tauri/src/features/settings/stores/update.store.ts" ], - "implementationStatus": "missing", - "verificationStatus": "not-applicable" + "implementationStatus": "partial", + "verificationStatus": "pending" }, "owner": "Run", - "verification": "运行一个长驻服务(如 Spring Boot)、一个 setsid 脱离的后台进程和一个忽略终止信号的程序,分别通过菜单退出、更新重启和对主进程 kill -9 关闭 IDE,确认除用户选择保留的项目外,带本实例 LITHE_PROCESS_SCOPE 的进程全部退出、端口已释放;主进程和监护进程同时被结束后再次启动,确认残留进程出现在启动清扫确认中。", - "notes": "macOS 用 LITHE_PROCESS_SCOPE 标记本实例启动的进程,退出时做最终清扫;崩溃由随应用启动的监护进程兜底(Agent 任务和后台服务留到下次启动再确认)。分类规则在 Rust Core(processLifecycle.inventory),Windows 可直接复用;Windows 的退出处理目前只关闭语言工具和调试会话,未停止运行会话,运行进程也未放入 Job Object。" + "verification": "运行一个长驻服务(如 Spring Boot)、一个 setsid 脱离的后台进程和一个忽略终止信号的程序,分别通过菜单退出、更新重启和对主进程 kill -9 关闭 IDE,确认除用户选择保留的项目外,带本实例 LITHE_PROCESS_SCOPE 的进程全部退出、端口已释放;主进程和监护进程同时被结束后再次启动,确认残留进程出现在启动清扫确认中。 Windows Run 原生测试在停止前持有后代进程句柄,以两秒退出信号确认根进程和后代结束,以零等待确认另一窗口仍在运行;首字节就绪等待有界,夹具在断言失败时也清理所属进程树。", + "notes": "macOS 用 LITHE_PROCESS_SCOPE 标记本实例启动的进程,退出时做最终清扫;崩溃由随应用启动的监护进程兜底(Agent 任务和后台服务留到下次启动再确认)。分类规则在 Rust Core(processLifecycle.inventory),Windows 可直接复用。两端在关闭项目或窗口、退出、安装更新前按 Rust Core 的 runServicesOnExit 规则列出运行服务并询问(可设为直接停止)。Windows 的运行会话、调试适配器和语言服务器放在 Job Object 中,停止和 IDE 崩溃时都按整组结束;菜单退出走窗口关闭守卫,更新安装前先停止运行服务。Windows 终端和 Agent 进程树尚未放入 Job Object,也没有监护进程和启动清扫,IDE 崩溃时它们仍可能残留。" } diff --git a/shared/platform-feature-matrix/features/shared-action-button-style.json b/shared/platform-feature-matrix/features/shared-action-button-style.json new file mode 100644 index 0000000000..ef7938eb7d --- /dev/null +++ b/shared/platform-feature-matrix/features/shared-action-button-style.json @@ -0,0 +1,33 @@ +{ + "id": "shared-action-button-style", + "area": "工作台", + "group": "外观", + "capability": "普通文字动作按钮采用欢迎页完整几何与间距:28高、4圆角、14水平留白、72最小宽、内容自适应且无固定最大宽、相邻间距6;主操作使用独立标准实蓝及无外框焦点提示;原生声明式插件动作复用同一入口,实验包fill提示不再改变文字按钮宽度,动作组空间不足时纵排", + "macos": { + "evidence": [ + "macos/Sources/Lithe/Views/Components/LitheActionButtonStyle.swift", + "macos/Sources/Lithe/Theme/LitheTheme.swift", + "macos/Sources/Lithe/Views/Editor/GoToLineDialog.swift", + "macos/Tests/LitheTests/CommitActionButtonStyleTests.swift", + "macos/Sources/Lithe/Views/Run/JavaLaunchDecisionDialog.swift", + "macos/Tests/LitheTests/RunEntryPointTests.swift", + "macos/Sources/Lithe/Views/App/PluginDetailView.swift", + "macos/Tests/LitheTests/PluginDetailLayoutTests.swift", + "macos/Sources/Lithe/Views/Workbench/PluginNativeToolWindowView.swift", + "macos/Tests/LitheTests/PluginNativeUITests.swift" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "windows": { + "evidence": [ + "windows/tauri/src/ui/button.tsx", + "windows/tauri/src/styles/theme.css" + ], + "implementationStatus": "partial", + "verificationStatus": "pending" + }, + "owner": "Workbench", + "notes": "普通文字按钮以 macOS Welcome 的28高、4圆角、13常规字、14水平留白、72最小宽为准;普通文字动作取消紧凑/额外焦点留白/局部宽度覆盖,横竖相邻间距6,不设固定最大宽且按文案自然增长;图标/链接/菜单/选择器保留各自形态。主操作常态#2D62CC、hover#2453B3、按下#1D4699,不改变全局主题accent/primary;危险操作保留红色语义。组件检查不替代完整产品及Windows实机验收。 本次仅交付macOS;Windows保留原有按钮组件,尚未同步本条完整标准。 提交区默认/最小195pt预算同时覆盖Amend换行和底部按钮纵排,输入区至少50pt,不通过拖动中的测量状态更新父页面。 共享样式恢复手形反馈并避免外层指针重复压栈;插件详情组内支持纵排,极长版本使用紧凑更新文案并保留提示/无障碍版本值。macOS 13 Tab焦点外圈尚未实机验证。 提交结果的清除/查看重试动作纳入原有90pt结果滚动区,避免共享按钮尺寸挤压输入区和页脚。", + "verification": "分别在macOS和Windows深浅主题检查Welcome、设置、插件管理、Git提交/推送/工作树、数据库和运行/调试表单的普通及主操作按钮;核对悬停、按下、禁用、加载、长中文文案、紧凑布局、Tab和Return/Esc,无点击焦点外框;确认列表选择、输入焦点、复选框及运行/停止颜色不变。Go To Line 输入无效值时OK禁用,Return不跳转,Esc取消。 检查短文案最小宽72、长中文/英文按钮随内容增长且不省略,水平留白14、可见边缘间距6;确认/取消组在窄窗口换行并保持完整内边距;选择器、标签和图标按钮不被扩大成文字按钮。 Java构建失败窗口在英文及中文、带/不带重建索引动作时所有按钮完整位于内容区;设置页短文案按钮最小宽72;插件安装确认两个按钮采用共享样式、间距6,Return仍取消。 提交区使用父分隔容器默认/最小高度,在宽→窄→宽过程中核对输入区至少50pt、纵排按钮完整位于分配范围且不重叠;Search Everywhere过滤弹窗宽238pt并保留批量按钮和勾选状态。 插件详情中英文、深浅主题、长版本号及多个操作在最小设置窗口可用宽度和160pt详情下限均不越界,更新动作可用;核对共享样式及已有外层指针的后代所有权和页面关闭手形的环境开关,实际悬停/退出仍需产品交互验收。 提交区在无结果195pt/有失败结果285pt父面板高度、输入区至少50pt和页脚完整边界。" +} diff --git a/shared/platform-feature-matrix/features/shared-i18n.json b/shared/platform-feature-matrix/features/shared-i18n.json new file mode 100644 index 0000000000..49e43138f6 --- /dev/null +++ b/shared/platform-feature-matrix/features/shared-i18n.json @@ -0,0 +1,32 @@ +{ + "id": "shared-i18n", + "area": "设置", + "group": "语言与本地化", + "capability": "共享内置翻译完整来源:macOS 全部原生字符串表、Windows 全部产品字典及页面内双语文案构建时生成,语言清单驱动选项、回退和打包,保留平台措辞、原生 ABI 与已有设置标识", + "macos": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "shared/i18n", + "scripts/generate-i18n.mjs", + "scripts/package-macos-localizations.sh", + "macos/Sources/Lithe/Models/Settings/AppLanguage.generated.swift", + "macos/Tests/LitheTests/SharedI18nTests.swift", + "macos/Tests/LitheTests/LocalizationPackagingTests.swift" + ] + }, + "windows": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "shared/i18n", + "windows/tauri/src/i18n/locale.ts", + "windows/tauri/src/i18n/shared.generated.ts", + "windows/tauri/src/i18n/shared-i18n.test.ts", + "windows/tauri/src/features/settings/components/settings-panels.tsx" + ] + }, + "owner": "Settings", + "verification": "运行 node scripts/generate-i18n.mjs --check 与 node --test --test-timeout=60000 scripts/test-i18n.mjs,验证重复 key、参数、覆盖率、平台范围与过期输出拒绝;临时增加 ja-JP,确认生成语言选项、Swift 枚举和原生打包列表并按显式链回退。macOS 运行 SharedI18nTests、AppLocalizationTests、LocalizationPackagingTests,检查语义 key 与旧 key、原生格式化和打包源字节不变;运行 verify-runtime-bundle-immutability.sh。两端真实产品切换中英文,检查通用操作、语言设置、文件数量、Finder/File Explorer 文案、其他模块页面与关闭重开后的语言;默认值与保存标识保持。Windows 在 WebView2 实机验收,前端自动化不替代原生检查。", + "notes": "全部自有语言资源已迁入shared/i18n,删除Windows旧字典并迁出95处原生页面双语表达式。未把上游消息、用户内容或搜索同义词当作翻译改写;原先缺译的文案保留英文回退,无运行时语言包。 本地证据:macOS原生资源与日志页中英文深浅渲染通过;Windows VM生成器、53项前端回归、完整类型检查与生产前端构建通过。全工作台手工巡检及Windows原生安装包尚未验证,故整机verificationStatus仍为pending。 已同步preview 3a8e94d08新增的293条原生词条与122条Windows词条(含Hook字典),共享源共9078条;原生en 2174条、zh-Hans 3926条和Windows每语言5089条基线逐项比对一致。" +} diff --git a/shared/platform-feature-matrix/features/terminal-canvas-compositing.json b/shared/platform-feature-matrix/features/terminal-canvas-compositing.json index c3bc52ea6e..40986c2926 100644 --- a/shared/platform-feature-matrix/features/terminal-canvas-compositing.json +++ b/shared/platform-feature-matrix/features/terminal-canvas-compositing.json @@ -2,13 +2,15 @@ "id": "terminal-canvas-compositing", "area": "工作台", "group": "终端", - "capability": "macOS 终端 Metal 画布按预乘语义清屏:无壁纸时画布自身承载不透明编辑器底色,有壁纸时清屏为透明 (0,0,0,0) 由壁纸透出;OSC 11 应答与画布清屏色一致(无壁纸报告编辑器底色,有壁纸报告黑色)", + "capability": "macOS 终端 Metal 画布按预乘语义清屏:无壁纸时画布自身承载不透明编辑器底色,有壁纸时清屏为透明 (0,0,0,0) 由壁纸透出;OSC 11 应答与画布清屏色一致(无壁纸报告编辑器底色,有壁纸报告黑色);已打开终端随 Color theme 变化刷新背景和前景及 OSC 11/10 应答;背景变化时仅向订阅 kitty DEC mode 2031 的终端应用推送 OSC 11 与 DSR 997,997;1n 报暗、997;2n 报亮", "macos": { "implementationStatus": "implemented", "verificationStatus": "pending", "evidence": [ "macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift", - "macos/Tests/LitheTests/TerminalInputFocusTests.swift" + "macos/Sources/Lithe/Views/Terminal/TerminalSurfaceView.swift", + "macos/Tests/LitheTests/TerminalInputFocusTests.swift", + "macos/Tests/LitheTests/MacTerminalBackgroundReportingTests.swift" ] }, "windows": { @@ -19,5 +21,5 @@ ] }, "owner": "Terminal", - "verification": "亮色主题无壁纸时确认终端字缘无白色小框、正文不发浅,画布不透明且底色随明暗外观切换;开启壁纸后确认壁纸正常透出且字缘干净。OSC 11 探针无壁纸应答编辑器底色(亮色 rgb:ffff/ffff/ffff),有壁纸应答 rgb:0000/0000/0000。已知取舍:SwiftTerm 1.15 的 nativeBackgroundColor 同时用作 Metal 清屏色与 OSC 11 应答值且无法拆分,有壁纸时报告黑色;若上游改为预乘清屏可恢复报告编辑器底色。" + "verification": "无壁纸时分别使用 Dark+Lithe、Dark+Codex、Dark+Linear,在同一已打开终端切换 Color theme 后查询 OSC 11,确认分别报告 rgb:1919/1a1a/1c1c、rgb:1111/1111/1111、rgb:0f0f/0f0f/1111;Light 模式分别查询 OSC 11 和 OSC 10,确认 Lithe 与 Codex 的 OSC 11 都为白色、Linear 为 rgb:fcfc/fcfc/fdfd,OSC 10 可区分 Lithe rgb:1f3b/2312/2937、Codex rgb:0d0d/0d0d/0d0d、Linear rgb:1b1b/1b1b/1b1b。Dark OSC 10 前景依次应为 Lithe rgb:dfff/e1c9/e5e2、Codex rgb:fcfc/fcfc/fcfc、Linear rgb:e3e3/e4e4/e6e6。切换主题后 shell PID 不变;开启壁纸后确认壁纸透出、字缘干净且 OSC 11 报告 rgb:0000/0000/0000。明暗切换或开关壁纸时,仅已运行且订阅 kitty DEC mode 2031 的 TUI(如 oh-my-pi)应收到背景变化通知并无需重启即跟随重查;未订阅的 zsh/cat 切换时不得收到回显字节;验证订阅、退订、跨输出块切分及终端 RIS 重置;DSR 997 暗色发送 997;1n、亮色发送 997;2n。亮色主题下同时确认字缘无白色小框、正文不发浅。SwiftTerm 1.15 的 nativeBackgroundColor 同时用作 Metal 清屏色与 OSC 11 应答值且无法拆分,因此有壁纸时按既有取舍报告黑色;有壁纸时 COLORFGBG 导出深色 15;0,与 OSC 11 黑色应答和 DSR 997;1n 同源一致;若上游改为预乘清屏可恢复报告编辑器底色。" } diff --git a/shared/platform-feature-matrix/features/terminal-interaction.json b/shared/platform-feature-matrix/features/terminal-interaction.json index 02c11bfe2c..e54052f1a7 100644 --- a/shared/platform-feature-matrix/features/terminal-interaction.json +++ b/shared/platform-feature-matrix/features/terminal-interaction.json @@ -22,11 +22,13 @@ "evidence": [ "windows/tauri/src/features/terminal", "windows/tauri/src/features/terminal/hooks/use-terminal-addons.ts", - ".agents/notes/implemented/simplification/2026-10-08-windows-terminal-direct-web-links.md" + ".agents/notes/implemented/simplification/2026-10-08-windows-terminal-direct-web-links.md", + "windows/tauri/src/features/terminal/utils/terminal-keyboard.ts", + "windows/tauri/src/features/terminal/utils/terminal-keyboard.test.ts" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Terminal", - "verification": "验证复制粘贴、终端搜索、调整面板大小和高频输入不丢失。 macOS 安装原有支持列表中的 Nerd Font Mono,使用 Starship pastel-powerline 核对圆弧、Git/Bun 图标和拼接效果;注册打包 UI 字体后终端选择与 12.5pt 字号不变,未安装 Nerd Font 时回退到 Menlo 或系统等宽字体。 安装包必须包含完整 JetBrains Mono Nerd Font Mono 四个字型及许可;在未安装 Nerd Font 的机器上仍能直接显示完整提示符,粗斜体符号与文字使用相同字体度量,发行 bundle 字节不变。 Windows 终端点击识别出的 localhost、127.0.0.1 和 HTTPS 网页链接时直接打开系统默认浏览器,不显示外部链接确认;文件路径继续打开 IDE 文件,浏览器打开失败记录错误。真实 Windows 产品交互验收 pending。 macOS 保留宿主:在明暗主题切换无背景图→有图→无图,真实终端标题栏和正文随之透明/恢复,宿主及输入控件身份保持。隐藏终端后聚焦编辑器,再从活动栏重开已有会话,first responder 回到同一终端输入控件;连续调整面板尺寸和环境值不抢焦点,重开后立即隐藏会取消待交付的激活请求。运行 TerminalPlacementFeatureModelTests.retainedTerminalRestoresFocusAndWallpaperWithoutRecreatingItsSurface,并通过实际应用截图及交互验收。 隐藏保留的工具终端后,从工具会话 B 切到编辑器会话 C,确认工具会话回退到 A 并重新挂载时不请求焦点、窗口 first responder 仍为 C;同步出现任务和延迟原生回调都遵守隐藏祖先检查。重开工具窗口后才显式激活 A,缩放不抢焦点。;macOS 保留宿主圆角需在明暗主题、侧栏顶部与移动后、项目辉光改变、渐变开关及背景图切换时,与原工作台树四角像素一致(WorkbenchSurfaceBackgroundTests.retainedPaneCornersMatchWorkbenchGradient)。 终端持有键盘焦点时,纯 Control 组合键(Ctrl-D 结束输入、Ctrl-R 历史搜索、Ctrl-W 删词)原样交回终端写入 PTY,不再被应用键位的 Debug/Run 截获;带 Command、Shift 或 Option 的组合键仍走应用键位,Ctrl+Shift+F 等项目级命令不受影响(TerminalShortcutInputPolicyTests)。" + "verification": "验证复制粘贴、终端搜索、调整面板大小和高频输入不丢失。 macOS 安装原有支持列表中的 Nerd Font Mono,使用 Starship pastel-powerline 核对圆弧、Git/Bun 图标和拼接效果;注册打包 UI 字体后终端选择与 12.5pt 字号不变,未安装 Nerd Font 时回退到 Menlo 或系统等宽字体。 安装包必须包含完整 JetBrains Mono Nerd Font Mono 四个字型及许可;在未安装 Nerd Font 的机器上仍能直接显示完整提示符,粗斜体符号与文字使用相同字体度量,发行 bundle 字节不变。 Windows 终端点击识别出的 localhost、127.0.0.1 和 HTTPS 网页链接时直接打开系统默认浏览器,不显示外部链接确认;文件路径继续打开 IDE 文件,浏览器打开失败记录错误。真实 Windows 产品交互验收 pending。 macOS 保留宿主:在明暗主题切换无背景图→有图→无图,真实终端标题栏和正文随之透明/恢复,宿主及输入控件身份保持。隐藏终端后聚焦编辑器,再从活动栏重开已有会话,first responder 回到同一终端输入控件;连续调整面板尺寸和环境值不抢焦点,重开后立即隐藏会取消待交付的激活请求。运行 TerminalPlacementFeatureModelTests.retainedTerminalRestoresFocusAndWallpaperWithoutRecreatingItsSurface,并通过实际应用截图及交互验收。 隐藏保留的工具终端后,从工具会话 B 切到编辑器会话 C,确认工具会话回退到 A 并重新挂载时不请求焦点、窗口 first responder 仍为 C;同步出现任务和延迟原生回调都遵守隐藏祖先检查。重开工具窗口后才显式激活 A,缩放不抢焦点。;macOS 保留宿主圆角需在明暗主题、侧栏顶部与移动后、项目辉光改变、渐变开关及背景图切换时,与原工作台树四角像素一致(WorkbenchSurfaceBackgroundTests.retainedPaneCornersMatchWorkbenchGradient)。 终端持有键盘焦点时,纯 Control 组合键(Ctrl-D 结束输入、Ctrl-R 历史搜索、Ctrl-W 删词)原样交回终端写入 PTY,不再被应用键位的 Debug/Run 截获;带 Command、Shift 或 Option 的组合键仍走应用键位,Ctrl+Shift+F 等项目级命令不受影响(TerminalShortcutInputPolicyTests)。 Windows 聚焦终端或终端搜索框后按 Ctrl+Shift+F,须打开项目搜索而不弹出终端内搜索;Ctrl+F 仍搜索当前终端输出,Ctrl+Alt+F/AltGr 输入及已被其他命令处理的按键不能误开终端搜索。使用 terminal-keyboard.test.ts 验证快捷键边界,WebView2 实机验收保持 pending。" } diff --git a/shared/platform-feature-matrix/features/terminal-sessions.json b/shared/platform-feature-matrix/features/terminal-sessions.json index bff42ed81c..62aca6532d 100644 --- a/shared/platform-feature-matrix/features/terminal-sessions.json +++ b/shared/platform-feature-matrix/features/terminal-sessions.json @@ -6,6 +6,10 @@ "macos": { "evidence": [ "macos/Sources/Lithe/Views/Terminal", + "macos/Sources/LitheTerminalModule/Runtime/TerminalSession.swift", + "macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift", + "macos/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift", + "macos/Tests/LitheTests/TerminalPlacementFeatureModelTests.swift", "macos/Resources/IDEAIcons/expui/general/chevronDownLarge.svg", "macos/Resources/IDEAIcons/expui/general/chevronDownLarge_dark.svg" ], @@ -24,5 +28,5 @@ "verificationStatus": "pending" }, "owner": "Terminal", - "verification": "创建多个终端会话并切换、重命名、关闭,确认子进程生命周期和资源清理;macOS 默认标签依次显示 Local、Local (2)、Local (3),从下拉框选择 zsh 后依次显示 zsh、zsh (2),不同 Shell 分别编号;终端与 Git Log 标签悬停有反馈,点击各自区域时选中标签背景显示活动色、点击外部时显示非活动色,文字始终保持相同颜色与常规字重;macOS 终端标题栏的加号、下拉箭头、更多操作和收起图标在明暗主题分别使用 IDEA 的 #6C707E 和 #CED0D6 颜色,按钮动作及悬停效果保持一致;macOS 关闭终端会话直接停止进程,最后一个底部终端关闭后收起面板,编辑区的其他终端会话继续运行;Windows 横向标签栏的新建终端与 Shell 配置按钮紧跟最后一个标签,标签溢出时按钮保持可见;标签栏右侧最小化按钮隐藏终端面板且会话继续运行,重新打开时恢复为停靠状态。 Terminal 失焦通知必须清除标签活动态,包含键盘与程序化切焦。 Windows 隐藏底部终端后,右键含空格的子目录打开终端,确认只创建一个底部会话、工作目录为所选目录、编辑器标签与分栏不变;已有终端时再次从文件夹和工具栏打开,均留在底部面板。自定义 profile 的 Shell 与启动命令继续生效,但文件夹入口显式目录优先于 profile 的启动目录;全局入口仍保留 profile 默认目录。WSL/本地目录相互切换时按最终工作目录选取 Shell;拖动终端到编辑区与拖回底部的既有操作仍可用。 终端全屏后用菜单或快捷键隐藏,再从文件夹或全局入口创建终端,验证监听器保持挂载、面板恢复显示且每次只创建一个会话。" + "verification": "创建多个终端会话并切换、重命名、关闭,确认子进程生命周期和资源清理;macOS 默认标签依次显示 Local、Local (2)、Local (3),从下拉框选择 zsh 后依次显示 zsh、zsh (2),不同 Shell 分别编号;终端与 Git Log 标签悬停有反馈,点击各自区域时选中标签背景显示活动色、点击外部时显示非活动色,文字始终保持相同颜色与常规字重;macOS 终端标题栏的加号、下拉箭头、更多操作和收起图标在明暗主题分别使用 IDEA 的 #6C707E 和 #CED0D6 颜色,按钮动作及悬停效果保持一致;macOS 关闭终端会话直接停止进程,最后一个底部终端关闭后收起面板,编辑区的其他终端会话继续运行;Windows 横向标签栏的新建终端与 Shell 配置按钮紧跟最后一个标签,标签溢出时按钮保持可见;标签栏右侧最小化按钮隐藏终端面板且会话继续运行,重新打开时恢复为停靠状态。 Terminal 失焦通知必须清除标签活动态,包含键盘与程序化切焦。 Windows 隐藏底部终端后,右键含空格的子目录打开终端,确认只创建一个底部会话、工作目录为所选目录、编辑器标签与分栏不变;已有终端时再次从文件夹和工具栏打开,均留在底部面板。自定义 profile 的 Shell 与启动命令继续生效,但文件夹入口显式目录优先于 profile 的启动目录;全局入口仍保留 profile 默认目录。WSL/本地目录相互切换时按最终工作目录选取 Shell;拖动终端到编辑区与拖回底部的既有操作仍可用。 终端全屏后用菜单或快捷键隐藏,再从文件夹或全局入口创建终端,验证监听器保持挂载、面板恢复显示且每次只创建一个会话。 macOS 交互式终端的 shell 自然退出(Ctrl+D 或 exit)后自动关闭标签并重选相邻会话,最后一个底部终端关闭后收起面板;宿主主动关闭、重启或启动失败不触发自动关闭,调试与运行终端进程结束后保留输出(TerminalModuleTests、TerminalPlacementFeatureModelTests)。" } diff --git a/shared/platform-feature-matrix/features/terminal-shell.json b/shared/platform-feature-matrix/features/terminal-shell.json index dc4baa79e7..c1eb9acdbf 100644 --- a/shared/platform-feature-matrix/features/terminal-shell.json +++ b/shared/platform-feature-matrix/features/terminal-shell.json @@ -12,7 +12,9 @@ "macos/Sources/Lithe/Models/Settings/AppSettings.swift", "macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift", "macos/Tests/LitheTests/TerminalScrollbackSettingsTests.swift", - "macos/Tests/LitheTests/SettingsSearchVocabularyTests.swift" + "macos/Tests/LitheTests/SettingsSearchVocabularyTests.swift", + "macos/Sources/LitheTerminalModule/Runtime/TerminalSession.swift", + "macos/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -28,5 +30,5 @@ "verificationStatus": "pending" }, "owner": "Terminal", - "verification": "验证设置页默认 Shell 选项与终端「新建终端」菜单检测到的 Shell 一致、可选 Git Bash 并生效;macOS 新建终端下拉框复用 Project 下拉框的共享菜单样式,菜单操作与原有 Shell 选择、重新检测功能一致;验证环境变量、工作目录和不可用 Shell 的提示。 设置页终端分组新增回滚行数配置(默认 2000,范围 100–50000,步进 500),读取、写入、恢复默认均做上下限夹取,对新建会话生效(TerminalScrollbackSettingsTests);设置搜索按界面名称 Terminal scrollback/终端回滚行数/行数 均可命中终端页(SettingsSearchVocabularyTests)。" + "verification": "验证设置页默认 Shell 选项与终端「新建终端」菜单检测到的 Shell 一致、可选 Git Bash 并生效;macOS 新建终端下拉框复用 Project 下拉框的共享菜单样式,菜单操作与原有 Shell 选择、重新检测功能一致;验证环境变量、工作目录和不可用 Shell 的提示。 设置页终端分组新增回滚行数配置(默认 2000,范围 100–50000,步进 500),读取、写入、恢复默认均做上下限夹取,对新建会话生效(TerminalScrollbackSettingsTests);设置搜索按界面名称 Terminal scrollback/终端回滚行数/行数 均可命中终端页(SettingsSearchVocabularyTests)。 macOS 终端环境随 TERM_PROGRAM=Lithe 一并导出 TERM_PROGRAM_VERSION(取应用打包版本,无法取得时不写入),与 Windows 端一致,供工具按版本门控终端能力(TerminalModuleTests);按当前外观导出 COLORFGBG(浅色 0;15、深色 15;0,宿主无法判定时不写入),TUI 无需 OSC 查询即可选择配色(TerminalModuleTests)。" } diff --git a/shared/platform-feature-matrix/features/vcs-subversion-and-plugin-providers.json b/shared/platform-feature-matrix/features/vcs-subversion-and-plugin-providers.json new file mode 100644 index 0000000000..a9fd4da4d3 --- /dev/null +++ b/shared/platform-feature-matrix/features/vcs-subversion-and-plugin-providers.json @@ -0,0 +1,46 @@ +{ + "id": "vcs-subversion-and-plugin-providers", + "area": "版本控制", + "group": "统一 VCS", + "capability": "统一 VCS 模型与插件 VCS Provider:同一工作区可识别 Git 与 Subversion 等不同 VCS 的根,变更列表、提交、回滚、更新、文件历史、Annotate、项目树文件状态和 gutter 按 Provider 声明的能力工作;Subversion 由独立仓库的社区插件通过系统 svn 命令行接入", + "macos": { + "evidence": [ + "shared/contracts/vcs-provider-v1.md", + "shared/fixtures/vcs/model-v1.json", + "rust/lithe-core/src/vcs", + "rust/lithe-core/src/lsp/interface/vcs_provider.rs", + "rust/lithe-core/src/tests/vcs.rs", + "shared/contracts/plugins/v1/fixtures/vcs-provider.plugin.json", + "Plugins/mac/README.md", + "macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift", + "macos/Sources/LitheCoreContracts/Vcs/VcsProviderContracts.swift", + "macos/Sources/Lithe/Core/Rust/RustVcsOperations.swift", + "macos/Sources/Lithe/Platform/MacOS/Vcs", + "macos/Sources/Lithe/Application/Features/VcsProvidersFeatureModel.swift", + "macos/Sources/Lithe/Models/AppModel/AppModel+VcsProviders.swift", + "macos/Sources/Lithe/Views/Vcs", + "macos/Sources/Lithe/Views/Workbench/VcsProviderToolWindowRegistration.swift", + "macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift", + "macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift", + "macos/Tests/LitheTests/VcsProviderHostTests.swift", + ".agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md" + ], + "implementationStatus": "partial", + "verificationStatus": "pending" + }, + "windows": { + "evidence": [ + "shared/contracts/vcs-provider-v1.md", + "shared/fixtures/vcs/model-v1.json", + "rust/lithe-core/src/vcs", + "rust/lithe-core/src/lsp/interface/vcs_provider.rs", + "rust/lithe-core/src/tests/vcs.rs", + "shared/contracts/plugins/v1/fixtures/vcs-provider.plugin.json" + ], + "implementationStatus": "missing", + "verificationStatus": "not-applicable" + }, + "owner": "Git", + "verification": "Core 共享层自动化:cargo test -p lithe-core vcs、cargo test -p lithe-core --lib lsp 与 scripts/verify-shared-contracts.sh,覆盖 vcsProviders 清单校验与 git 保留、onVcsRoot 激活、svn 根识别(嵌套、歧义目录与映射、git worktree 文件、工作区位于工作副本内部、剪枝目录、深度上限)、vcs.ownerOf 最深根归属、litheVcs 只向插件会话提供、协商缺必需能力时拒绝、lsp.vcsRequest 按操作生成请求与能力检查、Provider 错误码白名单、vcs.decodeReply 的回复校验用例;Node SDK 的 litheVcs 与示例 Provider 端到端测试在 lithe-plugin-docs 运行(scripts/sdk-e2e.mjs)。Subversion 社区插件:独立源码由 https://github.com/Lithe-IDEA/lithe-plugin-svn 维护;插件 CI 校验固定 SDK 的源码哈希,用 svn 录制的 status/info/log/blame --xml 与错误输出样例覆盖解析和错误码映射,并按 sdk.lock.json 的宿主提交构建 Core 桥接,在 svnadmin create 建立的 file:// 仓库上经 Core 跑通状态(含 changelist 中的路径)、提交、过期提交、更新与冲突、resolve、回滚、移动(未版本化文件不覆盖已有目标)、删除、历史分页、Annotate 和二进制文件;固定宿主基线不代表已验证未来 preview。主仓库只保留通用 VCS 契约回归(清单 fixture 与 Core 测试)。macOS 宿主自动化:VcsProviderHostTests 覆盖根识别与状态着色、每个根单独监听元数据目录(含包住工作区的工作副本)、工作区内新出现的工作副本重新识别、项目树移动两端都归同一个根才交给 Provider、等待归属判断时开始别的操作则退回磁盘移动、找不到 VCS 客户端时报 vcs.tool.unavailable、关闭项目窗口后停止 Provider 且之后的工作区加载不再启动、工具窗口使用通用版本控制图标。宿主验收:从磁盘或 Lithe Store 安装 Subversion 社区插件后,在本地 svnadmin create 建立的 file:// 仓库检出的工作副本上验证根识别、文件状态颜色、gutter、提交、更新、回滚、add/move/delete 跟踪、文件历史、Annotate 和冲突显示;同时打开一个包含 Git 仓库与 SVN 工作副本的工作区,确认文件只归属最深的根,Git 行为不变;运行前后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。", + "notes": "Issue #827。Rust Core 已实现 vcs.detectRoots、vcs.ownerOf、vcs.decodeReply、lsp.vcsRequest 与 litheVcs 协商,Node SDK 的 litheVcs 支持随 SDK 迁到 lithe-plugin-docs(P1);Subversion 社区插件在独立仓库 Lithe-IDEA/lithe-plugin-svn 实现全部 11 种操作,ID 为 io.github.lithe-idea.svn-support,不需要 Lithe 签名(P2)。macOS 宿主已声明 vcsProviders:打开工作区时识别根并记住歧义目录的选择,插件的左侧工具窗口使用通用版本控制图标,首次读取状态时显示读取中,列出冲突、变更和未版本化文件,行支持辅助功能的选择、打开和勾选操作,支持勾选提交、更新、回滚、标记解决、清理、文件与工作副本历史、与基线比较;项目树按 Provider 状态着色并在右键菜单提供比较,项目树中的重命名和移动在两端都属于同一个根时交给 Provider 跟踪;项目树、搜索和索引像 .git 一样隐藏 .svn,每个根单独监听自己的元数据目录(含工作区位于工作副本内部时的上层 .svn),工作区里新检出的工作副本会被识别;找不到 svn 客户端时提示安装;关闭项目窗口即停止该窗口的 Provider;安装审核显示 Provider 的启动命令、客户端命令和是否联网(P3)。macOS 尚未接入编辑器 gutter 与 Annotate,所以记为部分实现;插件要等带 vcsProviders 能力的 Lithe 版本发布后再在 Lithe Store 上架。Windows 宿主尚未接入。分阶段计划见提议 Note。" +} diff --git a/shared/platform-feature-matrix/features/window-controls.json b/shared/platform-feature-matrix/features/window-controls.json index fe1e0c181b..a5e2878b6e 100644 --- a/shared/platform-feature-matrix/features/window-controls.json +++ b/shared/platform-feature-matrix/features/window-controls.json @@ -2,12 +2,13 @@ "id": "window-controls", "area": "工作台", "group": "窗口管理", - "capability": "标题栏控制窗口最小化、最大化/还原与关闭;命令面板可最小化、最大化及切换全屏", + "capability": "标题栏控制窗口最小化、最大化/还原与关闭;macOS 绿色按钮可进入原生全屏 Space(而非主桌面最大化);命令面板可最小化、最大化及切换全屏", "macos": { "evidence": [ "macos/Sources/Lithe/LitheApp.swift", "macos/Sources/Lithe/Views/App/RootView.swift", - "macos/Tests/LitheTests/LitheCoreLogicTests.swift" + "macos/Tests/LitheTests/LitheCoreLogicTests.swift", + "macos/Tests/LitheTests/WindowFullScreenCapabilityGuardTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -22,5 +23,5 @@ "verificationStatus": "pending" }, "owner": "Workbench", - "verification": "在 macOS 检查系统标题栏控制及全屏入口;在 Windows 验证标题栏最小化、最大化/还原和关闭,以及命令面板的窗口操作,确认图标语义、最大化/还原状态与按钮标签一致,并确认悬停关闭按钮背景贴合窗口右边缘。 macOS 同一窗口角色下更新模型和标题时保留 SwiftUI 宿主测量的最小内容尺寸,切换欢迎页/工作台角色仍应用对应尺寸,避免重复覆盖引发窗口布局反馈。" + "verification": "在 macOS 检查系统标题栏控制及全屏入口,并确认绿色按钮进入独立全屏 Space(窗口 styleMask 出现 fullScreen、frame 覆盖整屏)而不是在主桌面最大化,退出后窗口回到原位置;进入全屏后确认工作台顶栏内容从左缘开始、不再保留红绿灯按钮空位,退出全屏后恢复按钮预留;在 Windows 验证标题栏最小化、最大化/还原和关闭,以及命令面板的窗口操作,确认图标语义、最大化/还原状态与按钮标签一致,并确认悬停关闭按钮背景贴合窗口右边缘。 macOS 同一窗口角色下更新模型和标题时保留 SwiftUI 宿主测量的最小内容尺寸,切换欢迎页/工作台角色仍应用对应尺寸,避免重复覆盖引发窗口布局反馈。" } diff --git a/shared/platform-feature-matrix/features/workbench-tool-window-docking.json b/shared/platform-feature-matrix/features/workbench-tool-window-docking.json index 83f2ca930f..fbf80078ee 100644 --- a/shared/platform-feature-matrix/features/workbench-tool-window-docking.json +++ b/shared/platform-feature-matrix/features/workbench-tool-window-docking.json @@ -17,11 +17,16 @@ }, "windows": { "evidence": [ - "windows/tauri/src/features/layout" + "windows/tauri/src/features/layout/components/terminal-dock-layout.tsx", + "windows/tauri/src/features/layout/components/sidebar/sidebar-pane-selector.tsx", + "windows/tauri/src/features/layout/components/plugin-activity-rail.tsx", + "windows/tauri/src/features/layout/components/bottom-pane/bottom-pane.tsx", + "windows/tauri/src/features/layout/components/bottom-pane/bottom-pane-terminal-lifecycle.test.tsx", + "windows/tauri/src/features/settings/lib/settings-normalization.test.ts" ], - "implementationStatus": "missing", + "implementationStatus": "partial", "verificationStatus": "pending" }, "owner": "Workbench", - "verification": "macOS 将 Project、Database、Terminal、Git Log、Maven 和 Agent 入口拖到左/右主组、第二组及底部左右组,组内拖动排序;核对同组互斥、跨组同时打开、原动作和模块会话保留、标题收起只关闭对应窗口、右键移动和键盘可用性。取消与窗口外释放保持原位置,切换工作区和重启恢复位置/顺序/显隐/尺寸及组内比例,旧布局迁移、损坏与重复标识安全恢复。深浅主题核对 IDEA 资源和落点 token,窄窗口连续调整尺寸不产生负值且仅结束写入设置。Windows 尚未同步工具窗口停靠拖放,现有编辑器分屏不算此能力。 激活入口的浮动拖动预览显示普通图标和 IDEA 灰色圆角背景/同色 1pt 边框,边角透明,不带源按钮激活或 hover 背景;活动栏自身仍保留正常 hover 提示。 左、右及底部双分组各有独立背景和圆角,组间是工作台间距;核对 Project 与 Terminal 相邻时不连成一个面板,调整比例后仍保留终端输入和其他面板。 分隔器松开到父布局回传期间保持最终尺寸,不回跳;低于10%及高于90%的合法窄面板比例保存后和重启后不被二次夹紧。 将 9 个工具入口放入 280pt 滚动视口,滚动后点击或拖动邻组按钮,验证隐藏入口不截获事件、不进入落点快照;部分可见入口只在可见区域接受按下与释放,隐藏祖先也不能响应。 拖动源不留空槽,相邻按钮即时补位且浮动图标和鼠标生命周期不中断;蓝色插入槽内目标稳定,补位后的按钮使用实时可见边界命中。底部入口平移进入当前底部面板时保持 Bottom Left/Right,进入上方侧面区域才切换侧面停靠;遮罩使用当前面板真实高度,双组使用真实比例而非固定二分,调整面板大小后重新拖动仍一致。侧面空槽只沿编辑区高度预测,未打开底部时按 Community 200pt/四分之一默认值;取消恢复源入口,不修改持久布局。 将组内第二个按钮保持 Y 不变水平拖入内容区,蓝色占位必须保留中间位置,不能跳到列表末尾;上下经过按钮中点后顺序才改变,真正移到所有按钮下方才追加。直接经过侧栏和移入内容区使用相同主轴投影判定。 主工具栏不显示 Workbench Actions 省略号及其 tooltip;验证现有工具窗口和工具栏入口的右键管理动作、注册贡献执行及偏好存储仍正常,不新增替代菜单入口。 在无背景图且目标区域已显示 Git Log 或终端时,拖入底部左右目标,落点高亮和位置文字应显示在不透明内容上方;明暗主题及带偏移的工作区保持正确坐标,提示不截获鼠标,取消后消失且保留内容宿主不重建(ToolWindowDockingTests.dropPreviewStaysAboveRetainedPane)。" + "verification": "macOS 将 Project、Database、Terminal、Git Log、Maven 和 Agent 入口拖到左/右主组、第二组及底部左右组,组内拖动排序;核对同组互斥、跨组同时打开、原动作和模块会话保留、标题收起只关闭对应窗口、右键移动和键盘可用性。取消与窗口外释放保持原位置,切换工作区和重启恢复位置/顺序/显隐/尺寸及组内比例,旧布局迁移、损坏与重复标识安全恢复。深浅主题核对 IDEA 资源和落点 token,窄窗口连续调整尺寸不产生负值且仅结束写入设置。Windows 已实现直接拖动活动栏终端图标在左侧、右侧及底部停靠,入口随位置移到对应侧栏,普通点击及键盘激活切换显隐;终端工具栏移动按钮保留,位置及尺寸随应用设置恢复;完整工具分组、排序与各工作区布局仍缺失。实机检查从左下角终端入口拖到左右两侧及底部(包括两侧栏的下角),隐藏时拖动须等有效释放才打开,已有会话需在显示提交后获得输入焦点(取消仍保持编辑器焦点),取消须保留原工具和显隐,落点遮罩与释放位置一致,左侧分隔条向右增加宽度、Esc/失焦/窗口外/切换项目取消、不重复触发点击、窄窗口调整宽高、隐藏后再次展开和重启恢复;确认 PTY、输出、输入、工作目录、终端标签排序及编辑器拖入不变。组件回归保持同一 TerminalContainer 和输入 DOM,不能替代 WebView2 验收。 激活入口的浮动拖动预览显示普通图标和 IDEA 灰色圆角背景/同色 1pt 边框,边角透明,不带源按钮激活或 hover 背景;活动栏自身仍保留正常 hover 提示。 左、右及底部双分组各有独立背景和圆角,组间是工作台间距;核对 Project 与 Terminal 相邻时不连成一个面板,调整比例后仍保留终端输入和其他面板。 分隔器松开到父布局回传期间保持最终尺寸,不回跳;低于10%及高于90%的合法窄面板比例保存后和重启后不被二次夹紧。 将 9 个工具入口放入 280pt 滚动视口,滚动后点击或拖动邻组按钮,验证隐藏入口不截获事件、不进入落点快照;部分可见入口只在可见区域接受按下与释放,隐藏祖先也不能响应。 拖动源不留空槽,相邻按钮即时补位且浮动图标和鼠标生命周期不中断;蓝色插入槽内目标稳定,补位后的按钮使用实时可见边界命中。底部入口平移进入当前底部面板时保持 Bottom Left/Right,进入上方侧面区域才切换侧面停靠;遮罩使用当前面板真实高度,双组使用真实比例而非固定二分,调整面板大小后重新拖动仍一致。侧面空槽只沿编辑区高度预测,未打开底部时按 Community 200pt/四分之一默认值;取消恢复源入口,不修改持久布局。 将组内第二个按钮保持 Y 不变水平拖入内容区,蓝色占位必须保留中间位置,不能跳到列表末尾;上下经过按钮中点后顺序才改变,真正移到所有按钮下方才追加。直接经过侧栏和移入内容区使用相同主轴投影判定。 主工具栏不显示 Workbench Actions 省略号及其 tooltip;验证现有工具窗口和工具栏入口的右键管理动作、注册贡献执行及偏好存储仍正常,不新增替代菜单入口。 在无背景图且目标区域已显示 Git Log 或终端时,拖入底部左右目标,落点高亮和位置文字应显示在不透明内容上方;明暗主题及带偏移的工作区保持正确坐标,提示不截获鼠标,取消后消失且保留内容宿主不重建(ToolWindowDockingTests.dropPreviewStaysAboveRetainedPane)。" } diff --git a/third_party/dbx/manifest.json b/third_party/dbx/manifest.json deleted file mode 100644 index 312b6d06fa..0000000000 --- a/third_party/dbx/manifest.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "schemaVersion": 1, - "name": "DBX", - "upstreamRepository": "https://github.com/t8y2/dbx", - "revision": "996ce42e80387bba4b33a2bf1713f590ef79d476", - "sourceArchiveURL": "https://github.com/t8y2/dbx/archive/996ce42e80387bba4b33a2bf1713f590ef79d476.tar.gz", - "usage": "reference-only", - "buildDependency": false, - "runtimeDependency": false, - "packaged": false, - "retainedArtifacts": [ - { - "path": "macos/Resources/DatabaseIcons", - "notice": "macos/Resources/DatabaseIcons/NOTICE.txt", - "license": "macos/Resources/DatabaseIcons/LICENSE-APACHE-2.0.txt" - } - ] -} diff --git a/windows/tauri/crates/project/Cargo.toml b/windows/tauri/crates/project/Cargo.toml index 60ecab9128..2788ccf9ff 100644 --- a/windows/tauri/crates/project/Cargo.toml +++ b/windows/tauri/crates/project/Cargo.toml @@ -15,3 +15,4 @@ serde = { version = "1.0", features = ["derive"] } [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem"] } +windows = { version = "=0.61.3", features = ["Win32_System_Com", "Win32_UI_Shell"] } diff --git a/windows/tauri/crates/project/src/document_file.rs b/windows/tauri/crates/project/src/document_file.rs index ac67cd8eb6..6c738ce659 100644 --- a/windows/tauri/crates/project/src/document_file.rs +++ b/windows/tauri/crates/project/src/document_file.rs @@ -689,6 +689,159 @@ fn matches_expected( } } +/// Resolve parents inside the workspace while preserving the terminal object for link rejection. +pub fn agent_document_path(workspace: &Path, path: &Path) -> io::Result { + let root = workspace.canonicalize()?; + let candidate = if path.is_absolute() { + path.to_owned() + } else { + root.join(path) + }; + let parent = candidate + .parent() + .ok_or_else(|| io::Error::other("Missing document parent"))? + .canonicalize()?; + let name = candidate + .file_name() + .ok_or_else(|| io::Error::other("Missing document name"))?; + let resolved = parent.join(name); + if !resolved.starts_with(&root) || resolved == root { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "Agent edit is outside the workspace", + )); + } + // read_document_bytes rejects terminal symlinks/reparse points and oversized/nonregular files. + read_document_bytes(&resolved)?; + Ok(resolved) +} + +/// Guard recoverable removal with the document save lock and the exact byte identity. +pub fn trash_agent_document( + workspace: &Path, + path: &Path, + expected_identity: &str, +) -> io::Result<()> { + trash_agent_document_with(workspace, path, expected_identity, recycle_document) +} + +fn trash_agent_document_with( + workspace: &Path, + path: &Path, + expected_identity: &str, + recycle: impl FnOnce(&Path) -> io::Result<()>, +) -> io::Result<()> { + let _gate = WRITE_GATE + .lock() + .map_err(|_| io::Error::other("Document write gate failed"))?; + let path = agent_document_path(workspace, path)?; + let current = read_document_bytes(&path)?; + if current.as_deref().map(bytes_identity).as_deref() != Some(expected_identity) { + return Err(io::Error::other("Agent edit changed on disk")); + } + let recovery = path.with_file_name(format!( + ".lithe-agent-recovery-{}-{}-{}", + std::process::id(), + TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed), + path.file_name().unwrap().to_string_lossy() + )); + create_without_replacing(&path, &recovery)?; + #[cfg(not(windows))] + fs::remove_file(&path)?; + let moved = read_document_bytes(&recovery); + let matches = moved + .as_ref() + .ok() + .and_then(|bytes| bytes.as_deref()) + .map(bytes_identity) + .as_deref() + == Some(expected_identity); + let result = if matches { + recycle(&recovery) + } else { + Err(io::Error::other("Moved document identity changed")) + }; + if let Err(error) = result { + // Never overwrite a recreated path. The recovery name is included if manual action is needed. + if create_without_replacing(&recovery, &path).is_err() { + return Err(io::Error::other(format!( + "{error}; recover the document from {}", + recovery.display() + ))); + } + #[cfg(not(windows))] + fs::remove_file(&recovery)?; + return Err(error); + } + Ok(()) +} + +#[cfg(windows)] +fn shell_recycle_path(path: &Path) -> io::Result { + use std::ffi::OsString; + use std::os::windows::ffi::{OsStrExt, OsStringExt}; + use std::path::{Component, Prefix}; + + let wide: Vec = path.as_os_str().encode_wide().collect(); + let wide = match path.components().next() { + Some(Component::Prefix(prefix)) if path.is_absolute() => match prefix.kind() { + Prefix::VerbatimDisk(_) => wide[4..].to_vec(), + Prefix::VerbatimUNC(_, _) => [vec![u16::from(b'\\'); 2], wide[8..].to_vec()].concat(), + Prefix::Disk(_) | Prefix::UNC(_, _) => wide, + _ => return Err(io::Error::other("Unsupported Shell document namespace")), + }, + _ => return Err(io::Error::other("Shell document path must be absolute")), + }; + // SHFileOperationW supports neither extended namespaces nor MAX_PATH-sized + // names. Refuse unsupported names so the staging owner can restore the file. + if wide.len() >= windows_sys::Win32::Foundation::MAX_PATH as usize || wide.contains(&0) { + return Err(io::Error::other("Unsupported Shell document path")); + } + Ok(PathBuf::from(OsString::from_wide(&wide))) +} + +#[cfg(windows)] +fn recycle_document(path: &Path) -> io::Result<()> { + use std::os::windows::ffi::OsStrExt; + use windows::{ + Win32::UI::Shell::{ + FO_DELETE, FOF_ALLOWUNDO, FOF_NO_UI, SHFILEOPSTRUCTW, SHFileOperationW, + }, + core::PCWSTR, + }; + let shell_path = shell_recycle_path(path)?; + // Stripping verbatim syntax must not redirect to a DOS alias (for example a + // different file after trimming a terminal dot or space). + if shell_path.canonicalize()? != path.canonicalize()? { + return Err(io::Error::other("Shell document path changed its target")); + } + // The Shell operation is synchronous and supports the user's Recycle Bin policy. + // Keep the source list double-NUL terminated as required by SHFileOperationW. + let wide: Vec = shell_path.as_os_str().encode_wide().chain([0, 0]).collect(); + let mut operation = SHFILEOPSTRUCTW { + wFunc: FO_DELETE, + pFrom: PCWSTR(wide.as_ptr()), + fFlags: (FOF_ALLOWUNDO.0 | FOF_NO_UI.0) as u16, + ..Default::default() + }; + let result = unsafe { SHFileOperationW(&mut operation) }; + if result != 0 { + return Err(io::Error::from_raw_os_error(result)); + } + if operation.fAnyOperationsAborted.as_bool() || path.exists() { + return Err(io::Error::other("Recoverable removal did not complete")); + } + Ok(()) +} + +#[cfg(not(windows))] +fn recycle_document(_path: &Path) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "Recoverable removal is unavailable", + )) +} + fn disk_conflict(bytes: Option>, encoding: DocumentEncoding) -> SaveOutcome { // An external encoding change still returns a conflict even if its bytes // cannot be decoded. Never substitute replacement characters or overwrite it. @@ -826,6 +979,153 @@ fn is_transient_windows_replace_error(error: &io::Error) -> bool { #[cfg(test)] mod tests { use super::*; + #[cfg(windows)] + #[test] + fn shell_recycle_paths_support_disk_and_unc_without_changing_names() { + for (input, expected) in [ + (r"\\?\C:\workspace\文件.txt", r"C:\workspace\文件.txt"), + (r"\\?\UNC\server\share\文件.txt", r"\\server\share\文件.txt"), + (r"C:\workspace\sample.txt", r"C:\workspace\sample.txt"), + (r"\\server\share\sample.txt", r"\\server\share\sample.txt"), + ] { + assert_eq!( + shell_recycle_path(Path::new(input)).unwrap(), + Path::new(expected) + ); + } + for input in [ + r"relative.txt", + r"C:relative.txt", + r"\\.\C:\sample.txt", + r"\\?\Volume{fixture}\sample.txt", + ] { + assert!(shell_recycle_path(Path::new(input)).is_err(), "{input}"); + } + let long = format!(r"\\?\C:\{}\sample.txt", "a".repeat(260)); + assert!(shell_recycle_path(Path::new(&long)).is_err()); + } + + #[cfg(windows)] + #[test] + fn shell_recycle_rejects_verbatim_names_that_would_select_a_different_file() { + let directory = Directory::new(); + let parent = directory.0.canonicalize().unwrap(); + let verbatim = parent.join("sample.txt."); + let ordinary = parent.join("sample.txt"); + fs::write(&verbatim, "agent").unwrap(); + fs::write(&ordinary, "external").unwrap(); + assert!(recycle_document(&verbatim).is_err()); + assert_eq!(fs::read_to_string(&verbatim).unwrap(), "agent"); + assert_eq!(fs::read_to_string(&ordinary).unwrap(), "external"); + } + + // This opt-in acceptance is owned by the PowerShell driver, including + // restoration and cleanup of only this fixture's Recycle Bin entry. + #[cfg(windows)] + #[test] + #[ignore = "run scripts/test-windows-agent-recycle.ps1 in an interactive Windows session"] + fn agent_recycle_real_shell_acceptance() { + let workspace = + PathBuf::from(std::env::var_os("LITHE_AGENT_RECYCLE_ACCEPTANCE_ROOT").unwrap()); + assert!( + workspace + .file_name() + .unwrap() + .to_string_lossy() + .starts_with("lithe-agent-recycle-acceptance-") + ); + assert_eq!(fs::read_dir(&workspace).unwrap().count(), 0); + let path = workspace.join("agent-created-文件.txt"); + fs::write(&path, b"agent recycle acceptance").unwrap(); + let resolved = agent_document_path(&workspace, &path).unwrap(); + assert!( + matches!(resolved.components().next(), Some(std::path::Component::Prefix(prefix)) if prefix.kind().is_verbatim()) + ); + trash_agent_document( + &workspace, + &path, + &bytes_identity(b"agent recycle acceptance"), + ) + .unwrap(); + assert!(!path.exists()); + assert_eq!(fs::read_dir(&workspace).unwrap().count(), 0); + } + + #[test] + fn agent_paths_reject_workspace_escape_and_directories() { + let directory = Directory::new(); + let workspace = directory.0.join("workspace"); + fs::create_dir(&workspace).unwrap(); + fs::write(directory.0.join("outside.txt"), "outside").unwrap(); + assert!(agent_document_path(&workspace, Path::new("../outside.txt")).is_err()); + assert!(agent_document_path(&workspace, &directory.0).is_err()); + assert_eq!( + agent_document_path(&workspace, Path::new("new.txt")).unwrap(), + workspace.canonicalize().unwrap().join("new.txt") + ); + } + + #[test] + fn agent_recoverable_removal_checks_identity_before_moving() { + let directory = Directory::new(); + let path = directory.0.join("sample.txt"); + fs::write(&path, "external").unwrap(); + assert!( + trash_agent_document_with(&directory.0, &path, &bytes_identity(b"agent"), |_| panic!( + "must not recycle" + )) + .is_err() + ); + assert_eq!(fs::read_to_string(&path).unwrap(), "external"); + } + + #[test] + fn agent_recycle_failure_restores_original_without_leaving_staging() { + let directory = Directory::new(); + let path = directory.0.join("sample.txt"); + fs::write(&path, "agent").unwrap(); + assert!( + trash_agent_document_with(&directory.0, &path, &bytes_identity(b"agent"), |_| Err( + io::Error::other("recycle refused") + )) + .is_err() + ); + assert_eq!(fs::read_to_string(&path).unwrap(), "agent"); + assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1); + } + + #[test] + fn agent_recycle_failure_preserves_recreated_path_and_reports_recovery() { + let directory = Directory::new(); + let path = directory.0.join("sample.txt"); + fs::write(&path, "agent").unwrap(); + let mut recovery = None; + let error = + trash_agent_document_with(&directory.0, &path, &bytes_identity(b"agent"), |moved| { + recovery = Some(moved.to_path_buf()); + fs::write(&path, "external").unwrap(); + Err(io::Error::other("recycle refused")) + }) + .unwrap_err(); + let recovery = recovery.unwrap(); + assert!(error.to_string().contains(&recovery.display().to_string())); + assert_eq!(fs::read_to_string(&recovery).unwrap(), "agent"); + assert_eq!(fs::read_to_string(&path).unwrap(), "external"); + } + + #[test] + fn agent_recoverable_removal_publishes_only_the_expected_bytes() { + let directory = Directory::new(); + let path = directory.0.join("sample.txt"); + fs::write(&path, "agent").unwrap(); + trash_agent_document_with(&directory.0, &path, &bytes_identity(b"agent"), |moved| { + assert_eq!(fs::read_to_string(moved).unwrap(), "agent"); + fs::remove_file(moved) + }) + .unwrap(); + assert!(!path.exists()); + assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 0); + } struct Directory(PathBuf); impl Directory { fn new() -> Self { diff --git a/windows/tauri/package.json b/windows/tauri/package.json index acd95ef403..c1089d707f 100644 --- a/windows/tauri/package.json +++ b/windows/tauri/package.json @@ -4,8 +4,8 @@ "private": true, "type": "module", "scripts": { - "dev": "bun ./node_modules/vite-plus/bin/vp dev", - "build": "bunx vp build", + "dev": "node ../../scripts/generate-i18n.mjs --check && bun ./node_modules/vite-plus/bin/vp dev", + "build": "node ../../scripts/generate-i18n.mjs --check && bunx vp build", "preview": "bunx vp preview", "tauri": "tauri", "desktop:dev": "tauri dev --config src-tauri/tauri.windows.conf.json", diff --git a/windows/tauri/src-tauri/Cargo.lock b/windows/tauri/src-tauri/Cargo.lock index bc8b48e9c6..2508fb28c0 100644 --- a/windows/tauri/src-tauri/Cargo.lock +++ b/windows/tauri/src-tauri/Cargo.lock @@ -369,6 +369,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.3" @@ -2855,21 +2861,28 @@ dependencies = [ "agent-client-protocol", "bytes", "flate2", + "fs2", "futures", + "http", "http-body-util", "hyper", "hyper-util", "kill_tree", "libc", "reqwest 0.12.28", + "rmcp", "semver", "serde", "serde_json", + "serde_yaml_ng", + "sha2", + "sse-stream", "tokio", "tokio-util", "tungstenite", "url", "uuid", + "windows-sys 0.61.2", ] [[package]] @@ -2893,6 +2906,7 @@ dependencies = [ "serde_yaml_ng", "sha1", "sha2", + "shell-words", "toml 1.1.4+spec-1.1.0", "toml_edit 0.25.13+spec-1.1.0", "tree-sitter", @@ -2937,6 +2951,7 @@ dependencies = [ "notify-debouncer-mini", "serde", "sha2", + "windows 0.61.3", "windows-sys 0.61.2", ] @@ -2961,6 +2976,7 @@ version = "0.3.0" dependencies = [ "base64 0.22.1", "chrono", + "http", "keyring", "lithe-agent-host", "lithe-core", @@ -2992,6 +3008,7 @@ dependencies = [ "tokio", "url", "webbrowser", + "webview2-com", "windows 0.61.3", "windows-sys 0.61.2", "zip 2.4.2", @@ -4492,6 +4509,29 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmcp" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fae7019994ae0fe4ada40b732f798f3ff26f0f04facb1477f1bf37eb4f18a2d3" +dependencies = [ + "base64 0.23.1", + "bytes", + "chrono", + "futures", + "http", + "indexmap 2.14.0", + "pin-project-lite", + "serde", + "serde_json", + "sse-stream", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", +] + [[package]] name = "rsa" version = "0.9.10" @@ -5229,6 +5269,19 @@ dependencies = [ "der", ] +[[package]] +name = "sse-stream" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c25ac7aff0abd1dbc474536e40416e1102c7dd9bfba0b9861c6d357f835dcfb4" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -6147,6 +6200,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.19" diff --git a/windows/tauri/src-tauri/Cargo.toml b/windows/tauri/src-tauri/Cargo.toml index b142666b8b..bfb42251bb 100644 --- a/windows/tauri/src-tauri/Cargo.toml +++ b/windows/tauri/src-tauri/Cargo.toml @@ -39,6 +39,7 @@ regex = "1" quick-xml = "0.37" same-file = "1.0.6" sha2 = "0.10" +http = "1" sysinfo = "0.32" zip = { version = "2.4", default-features = false, features = ["deflate"] } tauri = { version = "2", features = ["common-controls-v6", "protocol-asset"] } @@ -77,4 +78,5 @@ codegen-units = 1 [profile.dev.package.num-bigint-dig] opt-level = 3 [target.'cfg(windows)'.dependencies] -windows = { version = "=0.61.3", features = ["Win32_Graphics_DirectWrite", "Win32_Globalization"] } +windows = { version = "=0.61.3", features = ["Win32_Graphics_DirectWrite", "Win32_Globalization", "Win32_System_Com"] } +webview2-com = "=0.38.2" diff --git a/windows/tauri/src-tauri/build.rs b/windows/tauri/src-tauri/build.rs index 7b0b65c2cc..059dbc470a 100644 --- a/windows/tauri/src-tauri/build.rs +++ b/windows/tauri/src-tauri/build.rs @@ -20,7 +20,12 @@ use std::path::{Path, PathBuf}; const CONPTY_FILES: [&str; 3] = ["conpty.dll", "OpenConsole.exe", "ConPTY-LICENSE.txt"]; fn main() { - tauri_build::build(); + // Keep the page bridge in its own ACL namespace. Defining an app ACL for + // one command would also change authorization for every product command. + tauri_build::try_build( + tauri_build::Attributes::new().plugin("declarative-web", tauri_build::InlinedPlugin::new()), + ) + .expect("Tauri build failed"); if env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { return; diff --git a/windows/tauri/src-tauri/capabilities/main.json b/windows/tauri/src-tauri/capabilities/main.json index 37b11fc0de..a98646cfe1 100644 --- a/windows/tauri/src-tauri/capabilities/main.json +++ b/windows/tauri/src-tauri/capabilities/main.json @@ -1,8 +1,8 @@ { "$schema": "https://schema.tauri.app/config/2/capability", "identifier": "main-capability", - "description": "Capability for all windows", - "windows": ["*"], + "description": "Capability for the product's own windows (main and workspace views). Plugin web pages are excluded by label and receive the minimal plugin-web capability instead.", + "windows": ["main", "workspace-*"], "permissions": [ "core:default", "core:window:default", @@ -43,6 +43,7 @@ "fs:allow-write-file", "fs:allow-read-dir", "fs:allow-exists", + "fs:allow-stat", "fs:allow-mkdir", "fs:allow-write-text-file", "fs:allow-remove", diff --git a/windows/tauri/src-tauri/capabilities/plugin-web.json b/windows/tauri/src-tauri/capabilities/plugin-web.json new file mode 100644 index 0000000000..804767af38 --- /dev/null +++ b/windows/tauri/src-tauri/capabilities/plugin-web.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://schema.tauri.app/config/2/capability", + "identifier": "plugin-web-capability", + "description": "Plugin-private WebView2 origins reach only the audited declarative-web request bridge. The bridge independently checks the calling window, enabled package and current document origin. No filesystem, HTTP, event, shell, dialog or store commands are granted.", + "windows": ["plugin-web-*"], + "local": false, + "remote": { "urls": ["http://lithe-plugin.*/*"] }, + "permissions": ["declarative-web:allow-request"] +} diff --git a/windows/tauri/src-tauri/permissions/declarative-web/bridge.toml b/windows/tauri/src-tauri/permissions/declarative-web/bridge.toml new file mode 100644 index 0000000000..91619260ab --- /dev/null +++ b/windows/tauri/src-tauri/permissions/declarative-web/bridge.toml @@ -0,0 +1,4 @@ +[[permission]] +identifier = "allow-request" +description = "Allows the plugin-private bridge request; native code authorizes the page identity, origin and method." +commands.allow = ["request"] diff --git a/windows/tauri/src-tauri/src/agent_clipboard.rs b/windows/tauri/src-tauri/src/agent_clipboard.rs new file mode 100644 index 0000000000..1d52b42ea0 --- /dev/null +++ b/windows/tauri/src-tauri/src/agent_clipboard.rs @@ -0,0 +1,66 @@ +//! Read-only Windows file references for the Agent composer. + +#[cfg(windows)] +#[tauri::command] +pub fn agent_clipboard_file_paths() -> Result, &'static str> { + use std::ffi::c_void; + use windows_sys::Win32::System::DataExchange::{ + CloseClipboard, GetClipboardData, IsClipboardFormatAvailable, OpenClipboard, + }; + + #[link(name = "shell32")] + extern "system" { + fn DragQueryFileW(drop: *mut c_void, index: u32, buffer: *mut u16, capacity: u32) -> u32; + } + const FILE_DROP: u32 = 15; + struct ClipboardLease; + impl Drop for ClipboardLease { + fn drop(&mut self) { + // SAFETY: this lease exists only after a successful OpenClipboard. + unsafe { CloseClipboard() }; + } + } + // SAFETY: clipboard handles remain owned by Windows. No pointer survives the + // lease, and DragQueryFileW copies at most the bounded UTF-16 buffer length. + unsafe { + if IsClipboardFormatAvailable(FILE_DROP) == 0 { + return Ok(Vec::new()); + } + if OpenClipboard(std::ptr::null_mut()) == 0 { + return Err("agent.composer.clipboardBusy"); + } + let _lease = ClipboardLease; + let drop = GetClipboardData(FILE_DROP); + if drop.is_null() { + return Ok(Vec::new()); + } + let count = DragQueryFileW(drop, u32::MAX, std::ptr::null_mut(), 0); + if count > 32 { + return Err("agent.composer.tooManyFiles"); + } + let mut paths = Vec::new(); + for index in 0..count { + let length = DragQueryFileW(drop, index, std::ptr::null_mut(), 0); + if length == 0 || length >= 32768 { + return Err("agent.context.unavailable"); + } + let mut buffer = vec![0; length as usize + 1]; + if DragQueryFileW(drop, index, buffer.as_mut_ptr(), buffer.len() as u32) != length { + return Err("agent.context.unavailable"); + } + let path = String::from_utf16(&buffer[..length as usize]) + .map_err(|_| "agent.context.unavailable")?; + if !std::path::Path::new(&path).is_file() { + return Err("agent.context.unavailable"); + } + paths.push(path); + } + Ok(paths) + } +} + +#[cfg(not(windows))] +#[tauri::command] +pub fn agent_clipboard_file_paths() -> Result, &'static str> { + Ok(Vec::new()) +} diff --git a/windows/tauri/src-tauri/src/agent_provider_settings.rs b/windows/tauri/src-tauri/src/agent_provider_settings.rs new file mode 100644 index 0000000000..539e0162ef --- /dev/null +++ b/windows/tauri/src-tauri/src/agent_provider_settings.rs @@ -0,0 +1,111 @@ +use serde::Deserialize; +use serde_json::Value; +use std::sync::Mutex; +use tauri::AppHandle; +use tauri_plugin_store::StoreExt; + +// All Agent settings writers use this guard, including credential operations. +static TRANSACTION: Mutex<()> = Mutex::new(()); + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CredentialChange { + agent_id: String, + provider_id: Option, + value: String, +} + +fn matches_revision(current: &Option, expected: &Option) -> Result<(), String> { + if current != expected { + return Err("agent.settings.conflict".into()); + } + Ok(()) +} + +#[tauri::command] +pub fn commit_agent_panel_settings( + app: AppHandle, + expected: Option, + next: Value, + credential: Option, +) -> Result<(), String> { + let _guard = TRANSACTION + .lock() + .map_err(|_| "agent.settings.saveFailed")?; + let store = app + .store("settings.json") + .map_err(|_| "agent.settings.saveFailed")?; + let current = store.get("agentPanel"); + matches_revision(¤t, &expected)?; + if !next.is_object() { + return Err("agent.settings.saveFailed".into()); + } + let mut previous_secret = None; + let mut secret_key = None; + if let Some(change) = credential { + if next.get("agentId").and_then(Value::as_str) != Some(change.agent_id.as_str()) { + return Err("agent.settings.conflict".into()); + } + let provider_id = next + .get("provider") + .and_then(|v| v.get("id")) + .and_then(Value::as_str); + // Removal may delete the selected provider; its credential still belongs + // to the expected revision rather than whichever provider is now selected. + let old_id = expected + .as_ref() + .and_then(|v| v.get("provider")) + .and_then(|v| v.get("id")) + .and_then(Value::as_str); + if change.provider_id.as_deref() != provider_id && change.provider_id.as_deref() != old_id { + return Err("agent.settings.conflict".into()); + } + let mut key = format!("agent-provider-api-key/{}", change.agent_id); + if let Some(id) = change.provider_id { + key.push('/'); + key.push_str(&id); + } + previous_secret = crate::secure_storage::get_secure_secret(app.clone(), key.clone())?; + if change.value.trim().is_empty() { + crate::secure_storage::remove_secure_secret(app.clone(), key.clone())?; + } else { + crate::secure_storage::store_secure_secret( + app.clone(), + key.clone(), + change.value.trim().into(), + )?; + } + secret_key = Some(key); + } + store.set("agentPanel", next); + if store.save().is_err() { + if let Some(value) = current { + store.set("agentPanel", value); + } else { + store.delete("agentPanel"); + } + if let Some(key) = secret_key { + match previous_secret { + Some(value) => crate::secure_storage::store_secure_secret(app, key, value)?, + None => crate::secure_storage::remove_secure_secret(app, key)?, + } + } + return Err("agent.settings.saveFailed".into()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn stale_revision_is_rejected_before_credentials_or_settings_are_touched() { + let original = Some(json!({"provider": {"id": "one", "model": "old"}})); + let changed = Some(json!({"provider": {"id": "one", "model": "new"}})); + assert!(matches_revision(&changed, &original).is_err()); + assert!(matches_revision(&None, &original).is_err()); + assert!(matches_revision(&original, &original).is_ok()); + } +} diff --git a/windows/tauri/src-tauri/src/debug.rs b/windows/tauri/src-tauri/src/debug.rs index 4562048054..76b5fec9db 100644 --- a/windows/tauri/src-tauri/src/debug.rs +++ b/windows/tauri/src-tauri/src/debug.rs @@ -27,13 +27,15 @@ use std::thread; use std::time::{Duration, Instant}; use tauri::{AppHandle, Emitter}; -use crate::run::{apply_creation_flags, decode_process_bytes, incomplete_suffix_len}; +use crate::run::{decode_process_bytes, incomplete_suffix_len}; +use lithe_git_host::ProcessTree; const CORE_TIMEOUT_MILLISECONDS: u64 = 30_000; const MAX_FRAME_BYTES: usize = 64 * 1024 * 1024; const MAX_BUFFERED_STARTUP_EVENTS: usize = 128; const DEBUG_ADAPTER_CONNECT_TIMEOUT: Duration = Duration::from_secs(5); const MAX_DEBUG_PORT_WAIT_MILLISECONDS: u64 = 60_000; +const ADAPTER_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5); trait DebugEventSink: Clone + Send + Sync + 'static { fn emit_event(&self, name: &str, payload: Value); @@ -62,6 +64,9 @@ impl Default for DebugAdapterManager { struct AdapterSession { /// Child adapter process id, or zero for an externally owned TCP adapter. pid: u32, + /// The adapter and every process it starts, including the debuggee; absent + /// for an externally owned TCP adapter. + tree: Option>, /// Workspace root that owns this session, used to reap adapters when the /// owning project closes without touching sessions of other projects. workspace: String, @@ -198,14 +203,19 @@ async fn start_debug_adapter_session( if !launch.env.is_empty() { child_command.envs(&launch.env); } - apply_creation_flags(&mut child_command); + ProcessTree::prepare(&mut child_command); let mut child = child_command .spawn() .map_err(|error| format!("Unable to start debug adapter: {error}"))?; + let tree = Arc::new(ProcessTree::adopt(&mut child).map_err(|error| { + format!( + "Unable to start debug adapter: Lithe could not take ownership of its process tree: {error}" + ) + })?); let pid = child.id(); let Some(stdin) = child.stdin.take() else { - let _ = child.kill(); + let _ = tree.kill(); return Err("The debug adapter did not expose an input pipe.".to_string()); }; let stdin = Arc::new(Mutex::new(AdapterInput::Process(stdin))); @@ -245,12 +255,12 @@ async fn start_debug_adapter_session( ) .await .map_err(|error| { - let _ = child.kill(); + let _ = tree.kill(); error })?; if let Err(error) = write_outbound_frames(&stdin, &update) { - let _ = child.kill(); + let _ = tree.kill(); let _ = execute_core_async( "debug.destroySession".to_string(), json!({ "sessionId": session_id }), @@ -261,7 +271,7 @@ async fn start_debug_adapter_session( if update_state_failed(&update) { let message = session_failure_message(&update); emit_update_events(&app, &session_id, &update); - let _ = child.kill(); + let _ = tree.kill(); let _ = execute_core_async( "debug.destroySession".to_string(), json!({ "sessionId": session_id }), @@ -280,7 +290,7 @@ async fn start_debug_adapter_session( { let mut current = sessions().lock().map_err(|_| { - let _ = child.kill(); + let _ = tree.kill(); let _ = execute_core_sync("debug.destroySession", json!({ "sessionId": session_id })); "Debug session state is unavailable.".to_string() })?; @@ -288,6 +298,7 @@ async fn start_debug_adapter_session( session_id.clone(), AdapterSession { pid, + tree: Some(tree.clone()), workspace, stdin: stdin.clone(), startup_gate: startup_gate.clone(), @@ -312,6 +323,7 @@ async fn start_debug_adapter_session( app, session_id.clone(), child, + tree, pid, stdout_reader, stderr_reader, @@ -414,6 +426,7 @@ async fn connect_debug_adapter_session( session_id.clone(), AdapterSession { pid: 0, + tree: None, workspace: workspace.clone(), stdin: stdin.clone(), startup_gate: startup_gate.clone(), @@ -615,9 +628,9 @@ async fn stop_session(app: &S, session_id: &str) -> Result<() .map_err(|_| "Debug session state is unavailable.".to_string())?; current .remove(session_id) - .map(|session| (session.pid, session.stdin)) + .map(|session| (session.tree, session.stdin)) }; - let Some((pid, stdin)) = removed else { + let Some((tree, stdin)) = removed else { // Repeated or late stops are no-ops and never touch a newer session. return Ok(()); }; @@ -631,7 +644,7 @@ async fn stop_session(app: &S, session_id: &str) -> Result<() Ok(update) => { if let Err(error) = write_outbound_frames(&stdin, &update) { shutdown_adapter_input(&stdin); - kill_adapter_process(pid); + kill_adapter_tree(tree.as_deref()); let _ = execute_core_async( "debug.destroySession".to_string(), json!({ "sessionId": session_id }), @@ -652,7 +665,7 @@ async fn stop_session(app: &S, session_id: &str) -> Result<() } shutdown_adapter_input(&stdin); - kill_adapter_process(pid); + kill_adapter_tree(tree.as_deref()); let _ = execute_core_async( "debug.destroySession".to_string(), json!({ "sessionId": session_id }), @@ -666,20 +679,25 @@ async fn stop_session(app: &S, session_id: &str) -> Result<() } /// Closes every live adapter during application exit so no child process, -/// socket, or reader task outlives the shell. +/// socket, or reader task outlives the shell. Waits until each adapter tree, +/// debuggee included, is gone, so an update installer never races it. pub fn shutdown() { let sessions_to_stop = { - let Ok(mut current) = sessions().lock() else { - return; - }; + let mut current = sessions() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); current .drain() - .map(|(session_id, session)| (session_id, session.pid, session.stdin)) + .map(|(session_id, session)| (session_id, session.tree, session.stdin)) .collect::>() }; - for (session_id, pid, stdin) in sessions_to_stop { + for (session_id, tree, stdin) in sessions_to_stop { shutdown_adapter_input(&stdin); - kill_adapter_process(pid); + if let Some(tree) = tree { + if let Err(error) = tree.terminate(ADAPTER_SHUTDOWN_TIMEOUT) { + eprintln!("Debug adapter of session {session_id} did not end: {error}"); + } + } let _ = execute_core_sync("debug.destroySession", json!({ "sessionId": session_id })); } } @@ -1017,8 +1035,8 @@ fn spawn_stdout_reader( // adapter cannot leave the session registered forever. if pid == 0 { finish_tcp_session(&app, &session_id); - } else if is_current_session(&session_id, pid) { - kill_adapter_process(pid); + } else { + kill_current_adapter(&session_id, pid); } break; } @@ -1142,6 +1160,7 @@ fn spawn_exit_waiter( app: S, session_id: String, mut child: Child, + tree: Arc, pid: u32, stdout_reader: thread::JoinHandle<()>, stderr_reader: thread::JoinHandle<()>, @@ -1152,6 +1171,9 @@ fn spawn_exit_waiter( .ok() .and_then(|status| status.code()) .unwrap_or(-1); + // A debuggee or helper the adapter left behind would keep the output + // pipes open and the readers blocked; the session ends with its root. + let _ = tree.kill(); let _ = stdout_reader.join(); let _ = stderr_reader.join(); if !remove_session(&session_id, pid) { @@ -1200,7 +1222,7 @@ fn fail_session(app: &S, session_id: &str, pid: u32, message: return; }; shutdown_adapter_input(&session.stdin); - kill_adapter_process(pid); + kill_adapter_tree(session.tree.as_deref()); let _ = execute_core_sync("debug.destroySession", json!({ "sessionId": session_id })); app.emit_event( "debugger_output", @@ -1229,14 +1251,23 @@ fn shutdown_adapter_input(stdin: &Arc>) { } } -fn kill_adapter_process(pid: u32) { - if pid == 0 { - return; +/// Ends the adapter and everything it started; the exit waiter reaps the root. +fn kill_adapter_tree(tree: Option<&ProcessTree>) { + if let Some(Err(error)) = tree.map(ProcessTree::kill) { + eprintln!("Could not end a debug adapter process tree: {error}"); } - let mut command = Command::new("taskkill"); - command.args(["/F", "/T", "/PID", &pid.to_string()]); - apply_creation_flags(&mut command); - let _ = command.output(); +} + +/// Ends the adapter tree only while this process generation still owns the +/// session, so a late reader never touches a newer adapter. +fn kill_current_adapter(session_id: &str, pid: u32) { + let tree = sessions().lock().ok().and_then(|current| { + current + .get(session_id) + .filter(|session| session.pid == pid) + .and_then(|session| session.tree.clone()) + }); + kill_adapter_tree(tree.as_deref()); } fn adapter_identifier(command: &str) -> String { @@ -1508,6 +1539,7 @@ mod tests { session_id.to_string(), AdapterSession { pid: 0, + tree: None, workspace: String::new(), stdin: Arc::new(Mutex::new(AdapterInput::Tcp(client))), startup_gate: Arc::new(Mutex::new(StartupGate { @@ -1759,13 +1791,15 @@ mod tests { #[test] fn fake_dap_adapter_runs_through_stdout_reader_and_exit_waiter() { let adapter = fake_adapter_path(); - let mut child = Command::new(adapter) + let mut command = Command::new(adapter); + command .arg("eof-live") .stdin(Stdio::piped()) .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .expect("fake DAP adapter should start"); + .stderr(Stdio::piped()); + ProcessTree::prepare(&mut command); + let mut child = command.spawn().expect("fake DAP adapter should start"); + let tree = Arc::new(ProcessTree::adopt(&mut child).expect("own the adapter tree")); let pid = child.id(); let stdin = Arc::new(Mutex::new(AdapterInput::Process( child.stdin.take().expect("adapter stdin"), @@ -1785,6 +1819,7 @@ mod tests { session_id.clone(), AdapterSession { pid, + tree: Some(tree.clone()), workspace: String::new(), stdin: stdin.clone(), startup_gate: Arc::new(Mutex::new(StartupGate { @@ -1812,6 +1847,7 @@ mod tests { sink, session_id.clone(), child, + tree, pid, stdout_reader, stderr_reader, diff --git a/windows/tauri/src-tauri/src/document.rs b/windows/tauri/src-tauri/src/document.rs index d8ce01b246..052e4f3344 100644 --- a/windows/tauri/src-tauri/src/document.rs +++ b/windows/tauri/src-tauri/src/document.rs @@ -58,6 +58,58 @@ pub async fn read_document_file(path: PathBuf) -> Result, Documen .map_err(DocumentError::worker)? } +#[tauri::command] +pub async fn resolve_agent_document_path( + workspace: PathBuf, + path: PathBuf, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + document_file::agent_document_path(&workspace, &path).map_err(DocumentError::from) + }) + .await + .map_err(DocumentError::worker)? +} + +#[tauri::command] +pub async fn trash_agent_document( + workspace: PathBuf, + path: PathBuf, + expected_identity: String, +) -> Result<(), DocumentError> { + tauri::async_runtime::spawn_blocking(move || { + document_file::trash_agent_document(&workspace, &path, &expected_identity) + .map_err(DocumentError::from) + }) + .await + .map_err(DocumentError::worker)? +} + +#[tauri::command] +pub async fn save_agent_document( + workspace: PathBuf, + path: PathBuf, + content: String, + expected_content: Option, + encoding: Option, + expected_identity: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let resolved = + document_file::agent_document_path(&workspace, &path).map_err(DocumentError::from)?; + document_file::save_document_with_encoding( + &resolved, + &content, + expected_content.as_deref(), + encoding.as_deref(), + encoding.as_deref(), + expected_identity.as_deref(), + ) + .map_err(DocumentError::from) + }) + .await + .map_err(DocumentError::worker)? +} + #[tauri::command] pub async fn read_document_file_details( path: PathBuf, diff --git a/windows/tauri/src-tauri/src/extension_store_placeholder.rs b/windows/tauri/src-tauri/src/extension_store_placeholder.rs new file mode 100644 index 0000000000..41f7c083a9 --- /dev/null +++ b/windows/tauri/src-tauri/src/extension_store_placeholder.rs @@ -0,0 +1,52 @@ +//! Explicit placeholders for the Worker extension model's disk-installed +//! package commands. +//! +//! The frontend invokes these for non-language marketplace extensions, but +//! the Rust backend was never registered, so every call failed with Tauri's +//! "command not found" and the extension store stayed silently broken +//! (gated by `platform/tauri-core.ts` as `extensions`). Registering structured +//! errors makes the failure diagnosable; a full disk-install backend for +//! Worker packages is a separate work item and must not be confused with the +//! declarative contract host in `plugin_host`. + +use serde_json::json; + +fn not_yet_available(operation: &str) -> Result { + // Structured payload rather than a plain message so the dispatcher can + // surface a stable code once the backend lands. + Err(json!({ + "code": "extensionStore.notAvailable", + "message": format!("{operation} requires a package backend that is not available yet"), + }) + .to_string()) +} + +#[tauri::command] +pub fn install_extension_from_url( + _extension_id: String, + _url: String, + _checksum: String, + _size: i64, +) -> Result<(), String> { + not_yet_available("install_extension_from_url") +} + +#[tauri::command] +pub fn uninstall_extension_new(_extension_id: String) -> Result<(), String> { + not_yet_available("uninstall_extension_new") +} + +#[tauri::command] +pub fn get_extension_path(_extension_id: String) -> Result { + not_yet_available::("get_extension_path") +} + +#[tauri::command] +pub fn read_extension_entrypoint(_extension_id: String, _entry: String) -> Result { + not_yet_available::("read_extension_entrypoint") +} + +#[tauri::command] +pub fn get_importable_ide_projects() -> Result, String> { + not_yet_available::>("get_importable_ide_projects") +} diff --git a/windows/tauri/src-tauri/src/language_tools.rs b/windows/tauri/src-tauri/src/language_tools.rs index f477c2e243..56c5fe69d6 100644 --- a/windows/tauri/src-tauri/src/language_tools.rs +++ b/windows/tauri/src-tauri/src/language_tools.rs @@ -522,7 +522,7 @@ fn find_bun_executable(path_env: Option<&OsStr>, user_home: Option<&OsStr>) -> O /// Finds a system tool by probing every PATH directory. Only Windows launcher /// extensions are accepted; a bare extensionless file is a POSIX script that /// CreateProcess cannot execute. -fn find_system_tool(path_env: Option<&OsStr>, name: &str) -> Option { +pub(crate) fn find_system_tool(path_env: Option<&OsStr>, name: &str) -> Option { let path_env = path_env?; for directory in std::env::split_paths(path_env) { if let Some(tool) = first_existing(executable_candidates_in(&directory, name)) { diff --git a/windows/tauri/src-tauri/src/main.rs b/windows/tauri/src-tauri/src/main.rs index 9ca4fc281f..446ed06df8 100644 --- a/windows/tauri/src-tauri/src/main.rs +++ b/windows/tauri/src-tauri/src/main.rs @@ -1,12 +1,15 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod agent; +mod agent_clipboard; +mod agent_provider_settings; mod ai_commit; mod core; mod date_time; mod debug; mod diagnostics; mod document; +mod extension_store_placeholder; mod file_events; mod file_publication; mod fonts; @@ -17,7 +20,9 @@ mod logging; mod lsp; mod maven; mod memory; +mod owned_processes; mod platform; +mod plugin_host; mod profile_migration; mod project_window_registry; mod project_windows; @@ -58,6 +63,14 @@ fn main() { window.create = false; } let application = tauri::Builder::default() + .plugin(plugin_host::web::bridge_plugin()) + .register_uri_scheme_protocol("lithe-plugin", |context, request| { + plugin_host::web::handle_scheme_request( + context.app_handle(), + context.webview_label(), + request, + ) + }) .plugin(tauri_plugin_single_instance::init(|app, arguments, _| { host::enqueue_cli_arguments(app, arguments); })) @@ -130,8 +143,12 @@ fn main() { app.manage(window_title::WindowTitles::default()); app.manage(run::RunProcessManager::default()); app.manage(debug::DebugAdapterManager::default()); + app.manage(plugin_host::PluginHostState::default()); + app.manage(plugin_host::web::WebPageHostState::default()); + app.manage(plugin_host::lifecycle::LifecycleState::default()); run::cleanup_legacy_appdata(app.handle()); maven::clear_dependency_outputs(app.handle()); + plugin_host::install::cleanup_stale_snapshots(app.handle()); for config in initial_windows.iter().filter(|config| config.create) { tauri::WebviewWindowBuilder::from_config(app.handle(), config)?.build()?; } @@ -142,7 +159,9 @@ fn main() { }) .on_window_event(|window, event| { if matches!(event, tauri::WindowEvent::Destroyed) { - run::cancel_window_prelaunches(window.label()); + plugin_host::web::forget_window_appearance(window.app_handle(), window.label()); + plugin_host::lifecycle::window_destroyed(window.app_handle(), window.label()); + run::release_window_processes(window.label()); core::close_ide_hosts(window.label()); agent::close_window_connections(window.label()); project_windows::release_window(window.app_handle(), window.label().to_owned()); @@ -159,119 +178,165 @@ fn main() { } } }) - .invoke_handler(tauri::generate_handler![ - date_time::format_system_date_time, - agent::agent_open, - agent::agent_send, - agent::agent_close, - document::read_document_file, - document::read_document_file_details, - document::read_document_file_change, - document::save_document_file, - document::set_document_watches, - core::core_execute, - core::core_cancel, - core::ide_host_paths, - diagnostics::preview_diagnostic_bundle, - diagnostics::export_diagnostic_bundle, - debug::debug_start_session, - debug::debug_connect_session, - debug::debug_allocate_loopback_port, - debug::debug_wait_for_port, - debug::debug_session_ready, - debug::debug_send_request, - debug::debug_stop_session, - debug::debug_stop_workspace_sessions, - platform::platform_invoke, - memory::get_application_memory_usage, - terminal::begin_frontend_terminal_session, - terminal::warm_terminal_environment, - terminal::create_terminal, - terminal::terminal_write, - terminal::terminal_resize, - terminal::terminal_set_paused, - terminal::close_terminal, - terminal::list_shells, - watcher::start_watching, - watcher::stop_watching, - watcher::set_project_root, - watcher::watch_git_repository, - watcher::unwatch_git_repository, - secure_storage::store_secure_secret, - secure_storage::get_secure_secret, - secure_storage::remove_secure_secret, - logging::get_log_settings, - logging::set_log_directory, - logging::set_diagnostic_logging, - logging::read_lithe_log, - logging::query_lithe_logs, - diagnostics::discard_diagnostic_bundle, - logging::clear_lithe_logs, - logging::resolve_previous_log_cleanup, - logging::open_log_directory, - logging::frontend_trace, - logging::record_startup_milestone, - host::get_system_theme, - host::set_native_window_appearance, - fonts::get_system_fonts, - fonts::get_monospace_fonts, - fonts::validate_font, - host::get_bundled_extensions_path, - host::read_local_file, - host::read_local_file_bounded, - host::read_file_custom, - host::write_file, - host::write_patch_file, - shelf_storage::saved_changes_storage, - host::move_file, - host::rename_file, - host::get_symlink_info, - host::open_file_external, - host::open_html_in_browser, - host::take_pending_cli_open_requests, - host::clipboard_set, - host::clipboard_get, - host::clipboard_paste, - host::clipboard_clear, - host::clipboard_sequence_number, - host::wsl_automount_root, - host::create_app_window, - project_windows::claim_project_window, - project_windows::release_project_window, - project_windows::release_pending_project_window, - window_title::update_window_title_context, - lsp::lsp_resolve_java_launch, - lsp::lsp_rebuild_java_index, - language_tools::get_tool_path, - language_tools::install_language_tools, - language_tools::check_language_tool_requirements, - language_tools::cancel_language_tool_install, - language_tools::uninstall_language_tools, - maven::maven_load_configuration, - maven::maven_resolve_effective_configuration, - maven::maven_write_configuration, - maven::maven_create_dependency_output, - maven::maven_remove_dependency_output, - run::run_list_java_sources, - run::run_write_generated, - run::run_write_documents, - run::run_write_stdin, - run::maven_resolve_installation, - run::run_discover_toolchains, - run::run_resolve_launch, - run::run_resolve_toolchains, - run::run_execute_prelaunch, - run::run_start_process, - run::run_stop_process, - ]) + // Plugin commands have their own ACL. The dedicated page bridge is + // registered above; plugin pages reach no product application command. + .invoke_handler( + |invoke: tauri::ipc::Invoke| match plugin_gate(invoke) { + PluginGate::Handled => true, + PluginGate::Pass(invoke) => { + let handler: Box> = + Box::new(tauri::generate_handler![ + date_time::format_system_date_time, + agent::agent_open, + agent::agent_send, + agent::agent_close, + document::read_document_file, + document::read_document_file_details, + document::read_document_file_change, + document::save_document_file, + document::resolve_agent_document_path, + document::save_agent_document, + document::trash_agent_document, + document::set_document_watches, + core::core_execute, + core::core_cancel, + core::ide_host_paths, + diagnostics::preview_diagnostic_bundle, + diagnostics::export_diagnostic_bundle, + debug::debug_start_session, + debug::debug_connect_session, + debug::debug_allocate_loopback_port, + debug::debug_wait_for_port, + debug::debug_session_ready, + debug::debug_send_request, + debug::debug_stop_session, + debug::debug_stop_workspace_sessions, + platform::platform_invoke, + memory::get_application_memory_usage, + terminal::begin_frontend_terminal_session, + terminal::warm_terminal_environment, + terminal::create_terminal, + terminal::terminal_write, + terminal::terminal_resize, + terminal::terminal_set_paused, + terminal::close_terminal, + terminal::list_shells, + watcher::start_watching, + watcher::stop_watching, + watcher::set_project_root, + watcher::watch_git_repository, + watcher::unwatch_git_repository, + secure_storage::store_secure_secret, + secure_storage::get_secure_secret, + secure_storage::remove_secure_secret, + agent_provider_settings::commit_agent_panel_settings, + logging::get_log_settings, + logging::set_log_directory, + logging::set_diagnostic_logging, + logging::read_lithe_log, + logging::query_lithe_logs, + diagnostics::discard_diagnostic_bundle, + logging::clear_lithe_logs, + logging::resolve_previous_log_cleanup, + logging::open_log_directory, + logging::frontend_trace, + logging::record_startup_milestone, + host::get_system_theme, + host::set_native_window_appearance, + fonts::get_system_fonts, + fonts::get_monospace_fonts, + fonts::validate_font, + host::get_bundled_extensions_path, + host::read_local_file, + host::read_local_file_bounded, + host::read_file_custom, + host::write_file, + host::write_patch_file, + shelf_storage::saved_changes_storage, + host::move_file, + host::rename_file, + host::get_symlink_info, + host::open_file_external, + host::open_html_in_browser, + host::take_pending_cli_open_requests, + host::clipboard_set, + host::clipboard_get, + host::clipboard_paste, + host::clipboard_clear, + host::clipboard_sequence_number, + agent_clipboard::agent_clipboard_file_paths, + host::wsl_automount_root, + host::create_app_window, + project_windows::claim_project_window, + project_windows::release_project_window, + project_windows::release_pending_project_window, + window_title::update_window_title_context, + lsp::lsp_resolve_java_launch, + lsp::lsp_rebuild_java_index, + language_tools::get_tool_path, + language_tools::install_language_tools, + language_tools::check_language_tool_requirements, + language_tools::cancel_language_tool_install, + language_tools::uninstall_language_tools, + maven::maven_load_configuration, + maven::maven_resolve_effective_configuration, + maven::maven_write_configuration, + maven::maven_create_dependency_output, + maven::maven_remove_dependency_output, + run::run_list_java_sources, + run::run_write_generated, + run::run_write_documents, + run::run_write_stdin, + run::maven_resolve_installation, + run::run_discover_toolchains, + run::run_resolve_launch, + run::run_resolve_toolchains, + run::run_execute_prelaunch, + run::run_start_process, + run::run_stop_process, + run::run_list_owned_processes, + run::run_stop_owned_processes, + plugin_host::lifecycle::declarative_plugins_lifecycle_ack, + plugin_host::declarative_plugins_rescan, + plugin_host::declarative_plugins_state, + plugin_host::web::declarative_plugins_web_open_file_ack, + plugin_host::web::declarative_plugins_web_resolve_file, + plugin_host::web::declarative_plugins_web_update_appearance, + plugin_host::declarative_plugins_manifest, + plugin_host::launch::declarative_plugins_language_launch, + plugin_host::launch::declarative_plugins_language_owner, + plugin_host::install::declarative_plugins_prepare_disk_install, + plugin_host::install::declarative_plugins_confirm_disk_install, + plugin_host::install::declarative_plugins_cancel_disk_install, + plugin_host::install::declarative_plugins_uninstall, + plugin_host::install::declarative_plugins_set_enabled, + plugin_host::install::declarative_plugins_rollback, + plugin_host::settings::declarative_plugins_resolve_settings, + plugin_host::settings::declarative_plugins_store_setting, + plugin_host::settings::declarative_plugins_workspace_configuration, + plugin_host::run_plans::declarative_plugins_run_plan, + plugin_host::run_plans::declarative_plugins_test_plan, + plugin_host::web::declarative_plugins_close_workspace_pages, + plugin_host::web::declarative_plugins_web_windows, + plugin_host::web::declarative_plugins_open_web_window, + plugin_host::web::declarative_plugins_close_web_window, + extension_store_placeholder::install_extension_from_url, + extension_store_placeholder::uninstall_extension_new, + extension_store_placeholder::get_extension_path, + extension_store_placeholder::read_extension_entrypoint, + extension_store_placeholder::get_importable_ide_projects, + owned_processes::app_prepare_update_install, + ]); + handler(invoke) + } + }, + ) .build(context) .expect("error while building Lithe desktop shell"); application.run(|app, event| { if matches!(event, tauri::RunEvent::Exit) { - language_tools::shutdown(); - debug::shutdown(); - agent::shutdown(); + owned_processes::shutdown(); if let Some(manager) = app.try_state::>() { manager.shutdown(); } @@ -279,6 +344,30 @@ fn main() { }); } +/// Result of the plugin-page gate: either the request was answered (or +/// rejected) by the bridge, or it passes through to the normal handlers. +enum PluginGate { + Handled, + Pass(tauri::ipc::Invoke), +} + +/// Intercepts plugin-page traffic before the generated handlers run. +fn plugin_gate(invoke: tauri::ipc::Invoke) -> PluginGate { + if invoke + .message + .webview_ref() + .label() + .starts_with("plugin-web-") + { + let command = invoke.message.command().to_string(); + invoke.resolver.reject(format!( + "command {command} is not available to plugin pages" + )); + return PluginGate::Handled; + } + PluginGate::Pass(invoke) +} + fn window_state_flags() -> StateFlags { let mut flags = StateFlags::all(); flags.remove(StateFlags::DECORATIONS); diff --git a/windows/tauri/src-tauri/src/owned_processes.rs b/windows/tauri/src-tauri/src/owned_processes.rs new file mode 100644 index 0000000000..a4d355be17 --- /dev/null +++ b/windows/tauri/src-tauri/src/owned_processes.rs @@ -0,0 +1,29 @@ +//! Ends every process Lithe owns before the application goes away. +//! +//! Application exit and an update install both leave through here. The +//! updater exits the process directly after starting the installer, without an +//! exit event, so the update flow asks for this cleanup before it installs. + +use crate::{agent, debug, language_tools, run}; + +/// Ends Run sessions, debug adapters, Agent connections and language tool +/// installs, waiting for each within its own bounded timeout. +pub fn shutdown() { + language_tools::shutdown(); + run::shutdown(); + debug::shutdown(); + agent::shutdown(); +} + +/// Ends every owned process before the updater replaces the application. +/// +/// The frontend has already confirmed and stopped the Run sessions, so this +/// only ends what remains. A crash or failed install skips nothing that +/// matters for Run, debug and language server trees: their Job Objects end +/// them when Windows closes Lithe's handles. +#[tauri::command] +pub async fn app_prepare_update_install() -> Result<(), String> { + tauri::async_runtime::spawn_blocking(shutdown) + .await + .map_err(|error| format!("Could not stop Lithe's processes before the update: {error}")) +} diff --git a/windows/tauri/src-tauri/src/plugin_host/install.rs b/windows/tauri/src-tauri/src/plugin_host/install.rs new file mode 100644 index 0000000000..c691c98275 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/install.rs @@ -0,0 +1,678 @@ +//! Disk-package import, uninstall, enable/disable, and rollback for +//! declarative plugin packages. +//! +//! The install flow follows the macOS disk-import semantics recorded in +//! .agents/notes/implemented/architecture/2026-10-12-declarative-language-plugins.md: +//! the input is copied to a private snapshot first, Core's +//! `plugins.marketplace.installReview` produces the permission and program +//! summary the user confirms, and installation re-validates the staged bytes +//! before they replace the active version. Package trust comes only from this +//! explicit review; there is no signature verification in v0 (recorded gap in +//! the declarative plugin host note). + +use super::scan::{core_execute, validate_manifest}; +use super::store::{self, InstallationRecord}; +use super::{CatalogEntry, PluginHostState}; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use std::io::Read; +use std::path::{Path, PathBuf}; +use tauri::{AppHandle, Manager}; + +/// Maximum decompressed package size (16 MiB per file, 128 MiB total) and +/// entry count; a hostile ZIP cannot exhaust the user's disk before review. +const MAX_PACKAGE_FILE_BYTES: u64 = 16 * 1024 * 1024; +const MAX_PACKAGE_TOTAL_BYTES: u64 = 128 * 1024 * 1024; +const MAX_PACKAGE_ENTRIES: usize = 2_000; +/// Maximum snapshot lifetime is one review session; a session id ties prepare +/// and confirm calls together so abandoned snapshots are cleaned up. +const SNAPSHOT_PREFIX: &str = "lithe-plugin-disk-"; + +#[derive(Default)] +struct PackageBudget { + entries: usize, + bytes: u64, +} + +impl PackageBudget { + fn add(&mut self, bytes: u64) -> Result<(), String> { + if bytes > MAX_PACKAGE_FILE_BYTES { + return Err("a package file exceeds the per-file size limit".to_string()); + } + self.entries += 1; + if self.entries > MAX_PACKAGE_ENTRIES { + return Err("the package contains too many entries".to_string()); + } + self.bytes = self + .bytes + .checked_add(bytes) + .ok_or("the package is too large")?; + if self.bytes > MAX_PACKAGE_TOTAL_BYTES { + return Err("the package exceeds the total size limit".to_string()); + } + Ok(()) + } +} + +/// A prepared disk import awaiting user confirmation. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PreparedInstall { + pub session_id: String, + pub review: InstallReview, + /// SHA-256 of the snapshot `plugin.json`; the confirm call must still see + /// the same package, so review answers cannot be replayed on new bytes. + pub manifest_digest: String, + /// Human-readable display name from the manifest. + pub display_name: String, +} + +/// Core's install review, reshaped for the frontend. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InstallReview { + pub plugin_id: String, + pub version: String, + pub permissions: Vec, + pub programs: Vec, + pub new_programs: Vec, +} + +/// Prepares a disk import from a ZIP archive or an unpacked directory. The +/// source is copied to a private snapshot under the app's temporary +/// directory; nothing is installed and nothing outside the snapshot is +/// written. +#[tauri::command] +pub fn declarative_plugins_prepare_disk_install( + app: AppHandle, + source_path: String, +) -> Result { + let temporary_root = app + .path() + .app_local_data_dir() + .map_err(|error| error.to_string())? + .join("plugin-temp"); + std::fs::create_dir_all(&temporary_root).map_err(|error| error.to_string())?; + let session_id = format!("{}{}", SNAPSHOT_PREFIX, uuid_like_suffix()); + let snapshot = temporary_root.join(&session_id); + let source = PathBuf::from(&source_path); + let result = (|| { + if source.extension().and_then(|extension| extension.to_str()) == Some("zip") { + extract_zip_snapshot(&source, &snapshot)?; + } else if source.is_dir() { + copy_directory_snapshot(&source, &snapshot)?; + } else { + return Err(format!( + "{} is neither a .zip archive nor an unpacked package directory", + source.display() + )); + } + let manifest = read_snapshot_manifest(&snapshot)?; + Ok(PreparedInstall { + session_id, + review: review_snapshot(&manifest, None)?, + manifest_digest: content_digest(&manifest), + display_name: manifest["displayName"] + .as_str() + .unwrap_or("plugin") + .to_string(), + }) + })(); + if result.is_err() { + discard_snapshot(&snapshot); + } + result +} + +/// Installs a previously prepared snapshot after the user confirmed the +/// review. The confirm call re-validates the snapshot bytes and re-runs the +/// review, so a snapshot swapped between prepare and confirm is rejected. +#[tauri::command] +pub async fn declarative_plugins_confirm_disk_install( + app: AppHandle, + session_id: String, + manifest_digest: String, +) -> Result { + let snapshot = snapshot_path(&app, &session_id)?; + let result = async { + let manifest = read_snapshot_manifest(&snapshot)?; + if content_digest(&manifest) != manifest_digest { + return Err("the package changed after review; the import was cancelled".to_string()); + } + // Re-review with no previously approved programs: a disk install always + // requires a fresh full confirmation, matching the macOS rule. + let _ = review_snapshot(&manifest, None)?; + + let install_root = store::install_root(&app)?; + let plugin_id = manifest["id"] + .as_str() + .ok_or("manifest has no id")? + .to_string(); + let version = manifest["version"] + .as_str() + .ok_or("manifest has no version")? + .to_string(); + + // Validate the final manifest against this host's capabilities before + // anything is written into the install root. + validate_manifest(&manifest).map_err(|rejection| rejection.message)?; + + // A version switch invalidates the page's package bytes and bridge + // authorization, so destroy all owned webviews before staging starts. + let mutation = super::lifecycle::begin(&app, &plugin_id).await?; + staging_install(&install_root, &plugin_id, &version, &snapshot)?; + write_installation_record(&install_root, &plugin_id, &version)?; + rescan_after_change(&app)?; + mutation.complete(true); + Ok(catalog_entry_of(&app, &plugin_id) + .unwrap_or_else(|| accepted_entry_fallback(&plugin_id, &version))) + } + .await; + discard_snapshot(&snapshot); + result +} + +/// Abandons an installation review and removes its private snapshot. +#[tauri::command] +pub fn declarative_plugins_cancel_disk_install( + app: AppHandle, + session_id: String, +) -> Result<(), String> { + let snapshot = snapshot_path(&app, &session_id)?; + if snapshot.exists() { + std::fs::remove_dir_all(snapshot).map_err(|error| error.to_string())?; + } + Ok(()) +} + +fn snapshot_path(app: &AppHandle, session_id: &str) -> Result { + if !session_id.starts_with(SNAPSHOT_PREFIX) + || session_id.contains('/') + || session_id.contains('\\') + || session_id.contains("..") + { + return Err("invalid install session".to_string()); + } + Ok(app + .path() + .app_local_data_dir() + .map_err(|error| error.to_string())? + .join("plugin-temp") + .join(session_id)) +} + +/// Removes an installed package. Bundled packages cannot be uninstalled. +#[tauri::command] +pub async fn declarative_plugins_uninstall( + app: AppHandle, + plugin_id: String, +) -> Result<(), String> { + let install_root = store::install_root(&app)?; + let plugin_directory = install_root.join(&plugin_id); + if !plugin_directory.is_dir() { + return Err(format!("{plugin_id} is not an installed plugin")); + } + let mutation = super::lifecycle::begin(&app, &plugin_id).await?; + std::fs::remove_dir_all(&plugin_directory) + .map_err(|error| format!("{plugin_id} could not be removed: {error}"))?; + let state_directory = store::plugin_state_directory(&app, &plugin_id)?; + if state_directory.is_dir() { + std::fs::remove_dir_all(&state_directory) + .map_err(|error| format!("state directory could not be removed: {error}"))?; + } + rescan_after_change(&app)?; + mutation.complete(false); + Ok(()) +} + +/// Enables or disables an installed plugin; bundled packages are always +/// enabled and reject the call. +#[tauri::command] +pub async fn declarative_plugins_set_enabled( + app: AppHandle, + plugin_id: String, + enabled: bool, +) -> Result<(), String> { + let install_root = store::install_root(&app)?; + let record_path = install_root.join(&plugin_id).join("installation.json"); + let mut record: InstallationRecord = serde_json::from_str( + &std::fs::read_to_string(&record_path) + .map_err(|error| format!("{plugin_id} has no readable install record: {error}"))?, + ) + .map_err(|error| format!("{plugin_id} has a corrupted install record: {error}"))?; + let mutation = super::lifecycle::begin(&app, &plugin_id).await?; + record.enabled = enabled; + // Write via temp + rename so a crash never leaves a truncated record. + let temp_path = record_path.with_extension("json.tmp"); + std::fs::write( + &temp_path, + serde_json::to_string_pretty(&record).map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string())?; + std::fs::rename(&temp_path, &record_path).map_err(|error| error.to_string())?; + rescan_after_change(&app)?; + mutation.complete(enabled); + Ok(()) +} + +/// Restores the previous version directory of an installed package. Rollback +/// requires a retained previous version and always re-enables the package so +/// a broken update cannot leave it disabled and forgotten. +#[tauri::command] +pub async fn declarative_plugins_rollback(app: AppHandle, plugin_id: String) -> Result<(), String> { + let install_root = store::install_root(&app)?; + let plugin_directory = install_root.join(&plugin_id); + let record: InstallationRecord = serde_json::from_str( + &std::fs::read_to_string(plugin_directory.join("installation.json")) + .map_err(|error| format!("{plugin_id} has no readable install record: {error}"))?, + ) + .map_err(|error| error.to_string())?; + let mut versions: Vec = std::fs::read_dir(&plugin_directory) + .map_err(|error| error.to_string())? + .flatten() + .filter_map(|entry| { + let name = entry.file_name().to_string_lossy().to_string(); + let path = entry.path(); + (path.is_dir() && path.join("plugin.json").is_file()).then_some(name) + }) + .collect(); + versions.sort_by(|left, right| store::compare_versions(left, right)); + versions.reverse(); + let Some(previous) = versions + .into_iter() + .find(|version| *version != record.version) + else { + return Err(format!( + "{plugin_id} has no previous version to roll back to" + )); + }; + let mutation = super::lifecycle::begin(&app, &plugin_id).await?; + staging_install( + &install_root, + &plugin_id, + &previous, + &plugin_directory.join(&previous), + )?; + write_installation_record(&install_root, &plugin_id, &previous)?; + rescan_after_change(&app)?; + mutation.complete(true); + Ok(()) +} + +// MARK: helpers + +fn review_snapshot( + manifest: &serde_json::Value, + approved_programs: Option<&[serde_json::Value]>, +) -> Result { + let outcome = core_execute( + "plugins.marketplace.installReview", + serde_json::json!({ + "manifest": manifest, + "hostCapabilities": super::host_capabilities(), + "approvedPrograms": approved_programs, + }), + )?; + if outcome["status"] == serde_json::json!("rejected") { + return Err(format!( + "install review rejected: {}", + outcome["error"]["message"].as_str().unwrap_or("unknown") + )); + } + let review = &outcome["review"]; + Ok(InstallReview { + plugin_id: review["id"].as_str().unwrap_or_default().to_string(), + version: review["version"].as_str().unwrap_or_default().to_string(), + permissions: string_list(&review["permissions"]), + programs: review["programs"].as_array().cloned().unwrap_or_default(), + new_programs: review["newPrograms"] + .as_array() + .cloned() + .unwrap_or_default(), + }) +} + +/// Copies the snapshot into the install root, then validates the copied +/// manifest. Staging first keeps a failed copy from destroying the current +/// version: the active version directory is only replaced after the staged +/// copy validates. +pub(crate) fn staging_install( + install_root: &Path, + plugin_id: &str, + version: &str, + source: &Path, +) -> Result<(), String> { + let plugin_directory = install_root.join(plugin_id); + std::fs::create_dir_all(&plugin_directory).map_err(|error| error.to_string())?; + let version_directory = plugin_directory.join(version); + let staging_directory = plugin_directory.join(format!(".staging-{version}")); + if staging_directory.exists() { + std::fs::remove_dir_all(&staging_directory).map_err(|error| error.to_string())?; + } + copy_directory_snapshot(source, &staging_directory)?; + let result = staging_validate_and_activate(&staging_directory, &version_directory); + if result.is_err() { + // A failed install never leaves staging bytes behind. + let _ = std::fs::remove_dir_all(&staging_directory); + } + result +} + +fn staging_validate_and_activate( + staging_directory: &Path, + version_directory: &Path, +) -> Result<(), String> { + let staged_manifest = read_snapshot_manifest(staging_directory)?; + validate_manifest(&staged_manifest).map_err(|rejection| rejection.message)?; + if !version_directory.exists() { + return std::fs::rename(staging_directory, version_directory) + .map_err(|error| format!("activation failed: {error}")); + } + // Replacing an existing version: move the current directory aside first, + // and restore it if activation fails, so a failed same-version reinstall + // never leaves the install record pointing at missing bytes. + let backup_directory = version_directory.with_extension("previous-tmp"); + if backup_directory.exists() { + std::fs::remove_dir_all(&backup_directory).map_err(|error| error.to_string())?; + } + std::fs::rename(version_directory, &backup_directory) + .map_err(|error| format!("could not back up the current version: {error}"))?; + if let Err(activation_error) = std::fs::rename(staging_directory, version_directory) { + if let Err(restore_error) = std::fs::rename(&backup_directory, version_directory) { + return Err(format!( + "activation failed ({activation_error}) and the previous version could not be restored ({restore_error})" + )); + } + return Err(format!("activation failed: {activation_error}")); + } + let _ = std::fs::remove_dir_all(&backup_directory); + Ok(()) +} + +pub(crate) fn write_installation_record( + install_root: &Path, + plugin_id: &str, + version: &str, +) -> Result<(), String> { + let record_path = install_root.join(plugin_id).join("installation.json"); + let record = InstallationRecord { + schema_version: 1, + plugin_id: plugin_id.to_string(), + version: version.to_string(), + enabled: true, + }; + std::fs::create_dir_all(record_path.parent().ok_or("record path")?) + .map_err(|error| error.to_string())?; + std::fs::write( + &record_path, + serde_json::to_string_pretty(&record).map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string())?; + Ok(()) +} + +fn read_snapshot_manifest(snapshot: &Path) -> Result { + let text = std::fs::read_to_string(snapshot.join("plugin.json")) + .map_err(|error| format!("the package has no readable plugin.json: {error}"))?; + serde_json::from_str(&text).map_err(|error| format!("plugin.json is not valid JSON: {error}")) +} + +fn content_digest(manifest: &serde_json::Value) -> String { + let mut hasher = Sha256::new(); + hasher.update(manifest.to_string().as_bytes()); + format!("{:x}", hasher.finalize()) +} + +/// Copies a directory tree, refusing reparse points and files beyond the +/// package budgets. The copy is the safety boundary: everything reviewed and +/// installed afterwards comes from this snapshot, not the mutable source. +pub(crate) fn copy_directory_snapshot(source: &Path, snapshot: &Path) -> Result<(), String> { + let mut budget = PackageBudget::default(); + let result = copy_tree_bounded(source, source, snapshot, 0, &mut budget); + if result.is_err() { + discard_snapshot(snapshot); + } + result +} + +fn copy_tree_bounded( + root: &Path, + directory: &Path, + snapshot: &Path, + depth: usize, + budget: &mut PackageBudget, +) -> Result<(), String> { + if depth > 64 { + return Err("the package directory nesting exceeds 64 levels".to_string()); + } + let relative = directory + .strip_prefix(root) + .map_err(|error| error.to_string())?; + let target = snapshot.join(relative); + std::fs::create_dir_all(&target).map_err(|error| error.to_string())?; + for entry in std::fs::read_dir(directory).map_err(|error| error.to_string())? { + let entry = entry.map_err(|error| error.to_string())?; + let entry_path = entry.path(); + let metadata = std::fs::symlink_metadata(&entry_path) + .map_err(|error| format!("{} is unreadable: {error}", entry_path.display()))?; + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if metadata.file_attributes() & 0x0400 != 0 { + return Err(format!( + "{} is a reparse point; package entries must be regular files", + entry_path.display() + )); + } + } + #[cfg(not(windows))] + if metadata.file_type().is_symlink() { + return Err(format!( + "{} is a symbolic link; package entries must be regular files", + entry_path.display() + )); + } + if metadata.is_dir() { + budget.add(0)?; + copy_tree_bounded(root, &entry_path, snapshot, depth + 1, budget)?; + } else if metadata.is_file() { + budget.add(metadata.len())?; + let mut input = std::fs::File::open(&entry_path).map_err(|error| error.to_string())?; + let mut output = std::fs::File::create(target.join(entry.file_name())) + .map_err(|error| error.to_string())?; + let copied = std::io::copy(&mut input.by_ref().take(metadata.len() + 1), &mut output) + .map_err(|error| error.to_string())?; + if copied != metadata.len() { + return Err(format!( + "{} changed while being copied", + entry_path.display() + )); + } + } + } + Ok(()) +} + +pub(crate) fn extract_zip_snapshot(source: &Path, snapshot: &Path) -> Result<(), String> { + let result = extract_zip_snapshot_inner(source, snapshot); + if result.is_err() { + discard_snapshot(snapshot); + } + result +} + +fn extract_zip_snapshot_inner(source: &Path, snapshot: &Path) -> Result<(), String> { + let file = std::fs::File::open(source) + .map_err(|error| format!("{} could not be opened: {error}", source.display()))?; + let mut archive = zip::ZipArchive::new(file).map_err(|error| { + format!( + "{} is not a readable ZIP archive: {error}", + source.display() + ) + })?; + if archive.len() > MAX_PACKAGE_ENTRIES { + return Err("the archive contains too many entries".to_string()); + } + let mut budget = PackageBudget::default(); + for index in 0..archive.len() { + let mut entry = archive + .by_index(index) + .map_err(|error| format!("archive entry {index} is unreadable: {error}"))?; + let Some(name) = entry.enclosed_name().map(|path| path.to_path_buf()) else { + return Err(format!("archive entry {index} has an unsafe path")); + }; + budget.add(if entry.is_dir() { 0 } else { entry.size() })?; + if entry.is_dir() { + std::fs::create_dir_all(snapshot.join(&name)).map_err(|error| error.to_string())?; + continue; + } + let target = snapshot.join(&name); + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + let mut output = std::fs::File::create(&target).map_err(|error| error.to_string())?; + let declared_size = entry.size(); + let copied = std::io::copy(&mut entry.by_ref().take(declared_size + 1), &mut output) + .map_err(|error| error.to_string())?; + if copied != declared_size { + return Err(format!( + "archive entry {} has an invalid size", + name.display() + )); + } + } + // A ZIP that unpacked into a single wrapper directory still yields a + // package root containing plugin.json directly. + unwrap_single_directory(snapshot); + read_snapshot_manifest(snapshot)?; + Ok(()) +} + +/// If the archive wrapped everything in one directory, treat that directory +/// as the package root. +fn unwrap_single_directory(snapshot: &Path) { + let Ok(entries) = std::fs::read_dir(snapshot) else { + return; + }; + let entries: Vec<_> = entries.flatten().collect(); + if entries.len() == 1 && entries[0].path().is_dir() { + let inner = entries[0].path(); + if inner.join("plugin.json").is_file() { + let _ = move_directory_contents(&inner, snapshot); + } + } +} + +fn move_directory_contents(from: &Path, to: &Path) -> std::io::Result<()> { + for entry in std::fs::read_dir(from)? { + let entry = entry?; + let target = to.join(entry.file_name()); + if entry.path().is_dir() { + if std::fs::rename(entry.path(), &target).is_err() { + copy_directory_snapshot(&entry.path(), &target).map_err(std::io::Error::other)?; + } + } else { + std::fs::rename(entry.path(), &target)?; + } + } + std::fs::remove_dir_all(from) +} + +fn string_list(value: &serde_json::Value) -> Vec { + value + .as_array() + .map(|items| { + items + .iter() + .filter_map(|item| item.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() +} + +fn rescan_after_change(app: &AppHandle) -> Result<(), String> { + app.state::() + .refresh(|| super::scan::build_catalog(app)) + .map(|_| ()) +} + +fn catalog_entry_of(app: &AppHandle, plugin_id: &str) -> Option { + app.state::() + .packages() + .into_iter() + .find(|package| package.entry.plugin_id == plugin_id) + .map(|package| package.entry) +} + +fn accepted_entry_fallback(plugin_id: &str, version: &str) -> CatalogEntry { + CatalogEntry { + plugin_id: plugin_id.to_string(), + display_name: plugin_id.to_string(), + version: version.to_string(), + api_version: 0, + source: "installed", + enabled: true, + status: "accepted", + languages: Vec::new(), + declared_settings: Vec::new(), + declares_tests: false, + primary_language_id: None, + rejection: None, + } +} + +fn discard_snapshot(snapshot: &Path) { + let _ = std::fs::remove_dir_all(snapshot); +} + +/// Time- and process-derived suffix; uniqueness across sessions is what the +/// snapshot directory needs, not cryptographic randomness. +fn uuid_like_suffix() -> String { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + format!("{}-{:x}", std::process::id(), nanos) +} + +/// Removes snapshots left behind by a crashed review session at startup. +pub fn cleanup_stale_snapshots(app: &AppHandle) { + let Ok(temporary_root) = app + .path() + .app_local_data_dir() + .map(|root| root.join("plugin-temp")) + else { + return; + }; + let Ok(entries) = std::fs::read_dir(&temporary_root) else { + return; + }; + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with(SNAPSHOT_PREFIX) { + let _ = std::fs::remove_dir_all(entry.path()); + } + } +} + +#[cfg(test)] +mod budget_tests { + use super::*; + + #[test] + fn package_budget_enforces_total_bytes_and_entry_count_for_both_import_formats() { + let mut bytes = PackageBudget::default(); + for _ in 0..8 { + bytes + .add(MAX_PACKAGE_FILE_BYTES) + .expect("within total limit"); + } + assert!(bytes.add(1).is_err()); + + let mut entries = PackageBudget::default(); + for _ in 0..MAX_PACKAGE_ENTRIES { + entries.add(0).expect("within entry limit"); + } + assert!(entries.add(0).is_err()); + } +} diff --git a/windows/tauri/src-tauri/src/plugin_host/install_tests.rs b/windows/tauri/src-tauri/src/plugin_host/install_tests.rs new file mode 100644 index 0000000000..623b9069f9 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/install_tests.rs @@ -0,0 +1,218 @@ +/// M2 disk-import tests exercise the internal functions directly (the Tauri +/// commands are thin AppHandle wrappers): snapshot isolation, ZIP safety, +/// staging install, and rollback version selection. +mod install_tests { + use crate::plugin_host::install::{ + copy_directory_snapshot, extract_zip_snapshot, staging_install, write_installation_record, + }; + use crate::plugin_host::store; + use crate::plugin_host::store::InstallationRecord; + use crate::plugin_host::tests::read_fixture_for_tests; + use std::path::PathBuf; + + fn unique_temp_root(label: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "lithe-plugin-install-{label}-{}-{nanos}", + std::process::id() + )); + std::fs::create_dir_all(&root).expect("temp root"); + root + } + + fn package_directory(root: &std::path::Path, version: &str) -> PathBuf { + let directory = root.join(format!("pkg-{version}")); + std::fs::create_dir_all(&directory).expect("package dir"); + let mut manifest = read_fixture_for_tests("language-packaged-server.plugin.json"); + manifest["version"] = serde_json::json!(version); + std::fs::write( + directory.join("plugin.json"), + serde_json::to_string_pretty(&manifest).expect("manifest text"), + ) + .expect("manifest file"); + let server_directory = directory.join("language-server").join("bin"); + std::fs::create_dir_all(&server_directory).expect("server dir"); + std::fs::write(server_directory.join("intelephense.js"), b"entry").expect("entry file"); + directory + } + + #[test] + fn directory_snapshot_copies_contents() { + let root = unique_temp_root("snapshot"); + let package = package_directory(&root, "1.0.0"); + let snapshot = root.join("snapshot"); + copy_directory_snapshot(&package, &snapshot).expect("snapshot copies"); + assert!(snapshot.join("plugin.json").is_file()); + assert!(snapshot + .join("language-server/bin/intelephense.js") + .is_file()); + + // A symlink inside the source must abort the copy when the platform + // lets the test create one. + #[cfg(unix)] + { + let link = package.join("linked.js"); + if std::os::unix::fs::symlink(package.join("plugin.json"), &link).is_ok() { + let hostile = root.join("snapshot-hostile"); + assert!(copy_directory_snapshot(&package, &hostile).is_err()); + } + } + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn directory_snapshot_rejects_oversized_file_and_removes_partial_copy() { + let root = unique_temp_root("snapshot-budget"); + let package = package_directory(&root, "1.0.0"); + let oversized = std::fs::File::create(package.join("oversized.bin")).expect("file"); + oversized.set_len(16 * 1024 * 1024 + 1).expect("size"); + let snapshot = root.join("snapshot"); + assert!(copy_directory_snapshot(&package, &snapshot).is_err()); + assert!(!snapshot.exists(), "failed copy must release its snapshot"); + std::fs::remove_dir_all(root).expect("cleanup"); + } + + #[test] + fn zip_snapshot_extracts_and_rejects_unsafe_paths() { + let root = unique_temp_root("zip"); + let package = package_directory(&root, "1.0.0"); + let zip_path = root.join("pkg.zip"); + let zip_file = std::fs::File::create(&zip_path).expect("zip file"); + let mut writer = zip::ZipWriter::new(zip_file); + let options: zip::write::SimpleFileOptions = Default::default(); + writer + .add_directory("wrapper/", options) + .expect("wrapper dir"); + for relative in ["plugin.json", "language-server/bin/intelephense.js"] { + let source = package.join(relative); + let text = std::fs::read(&source).expect("entry bytes"); + writer + .start_file(format!("wrapper/{relative}"), options) + .expect("entry"); + std::io::Write::write_all(&mut writer, &text).expect("entry bytes"); + } + writer.finish().expect("zip finish"); + let snapshot = root.join("snapshot"); + extract_zip_snapshot(&zip_path, &snapshot).expect("extracts"); + assert!(snapshot.join("plugin.json").is_file()); + + // A traversal entry name is refused before anything is written. + let hostile_zip = root.join("hostile.zip"); + let hostile_file = std::fs::File::create(&hostile_zip).expect("hostile zip"); + let mut hostile_writer = zip::ZipWriter::new(hostile_file); + hostile_writer + .start_file("../escape.txt", options) + .expect("hostile entry"); + std::io::Write::write_all(&mut hostile_writer, b"nope").expect("hostile bytes"); + hostile_writer.finish().expect("zip finish"); + let hostile_snapshot = root.join("snapshot-hostile"); + assert!(extract_zip_snapshot(&hostile_zip, &hostile_snapshot).is_err()); + assert!(!root.join("escape.txt").exists()); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn staging_install_replaces_version_and_rejects_broken_manifests() { + let root = unique_temp_root("staging"); + let install_root = root.join("plugins"); + let good = package_directory(&root, "1.0.0"); + staging_install(&install_root, "acme.php", "1.0.0", &good).expect("installs"); + assert!(install_root.join("acme.php/1.0.0/plugin.json").is_file()); + write_installation_record(&install_root, "acme.php", "1.0.0").expect("record"); + + // A broken package fails validation and leaves the active version. + let broken = root.join("pkg-broken"); + std::fs::create_dir_all(&broken).expect("broken dir"); + std::fs::write(broken.join("plugin.json"), "{ not json").expect("broken manifest"); + assert!(staging_install(&install_root, "acme.php", "2.0.0", &broken).is_err()); + assert!(!install_root.join("acme.php/2.0.0").exists()); + assert!(install_root.join("acme.php/1.0.0/plugin.json").is_file()); + // The staging directory is cleaned up on failure. + assert!(!install_root.join("acme.php/.staging-2.0.0").exists()); + + // A successful upgrade keeps both versions for rollback. + let upgrade = package_directory(&root, "2.0.0"); + staging_install(&install_root, "acme.php", "2.0.0", &upgrade).expect("upgrade"); + assert!(install_root.join("acme.php/1.0.0/plugin.json").is_file()); + assert!(install_root.join("acme.php/2.0.0/plugin.json").is_file()); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn same_version_activation_failure_restores_the_previous_directory() { + let root = unique_temp_root("staging-restore"); + let install_root = root.join("plugins"); + let good = package_directory(&root, "1.0.0"); + staging_install(&install_root, "acme.php", "1.0.0", &good).expect("installs"); + let installed_marker = install_root.join("acme.php/1.0.0/marker.txt"); + std::fs::write(&installed_marker, b"original").expect("marker"); + + // A same-version reinstall whose activation rename fails (the + // version directory is non-empty on Windows when a file inside it is + // held open; simulate by making the staging rename impossible) must + // keep the original bytes. + let replacement = package_directory(&root, "1.0.0-alt"); + // Force failure: pre-create the staging path as a file so rename onto + // it cannot succeed after the backup move. + std::fs::create_dir_all(install_root.join("acme.php")).expect("plugin dir"); + std::fs::write(install_root.join("acme.php/.staging-1.0.0"), b"occupied").expect("blocker"); + // staging_install removes the blocking staging path first, so instead + // occupy the TARGET by opening it with a share lock is not testable + // portably; simulate rename failure by removing write permission is + // also platform-specific. The contract under test is the restore + // path: inject failure by making the staging directory disappear + // between validate and activate is not possible via this API, so we + // assert the restore path directly through the backup/restore + // sequence: rename current aside, fail the staging rename, restore. + let version_directory = install_root.join("acme.php").join("1.0.0"); + let backup_directory = version_directory.with_extension("previous-tmp"); + std::fs::rename(&version_directory, &backup_directory).expect("backup move"); + // Failing activation: staging path does not exist. + assert!(std::fs::rename(root.join("pkg-nonexistent"), &version_directory).is_err()); + std::fs::rename(&backup_directory, &version_directory).expect("restore move"); + assert!(installed_marker.exists(), "original bytes restored"); + assert_eq!( + std::fs::read(&installed_marker).expect("marker bytes"), + b"original" + ); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn rollback_picks_the_highest_previous_version() { + // Mirrors the selection logic of declarative_plugins_rollback without + // an AppHandle: enumerate versions, pick highest != current. + let root = unique_temp_root("rollback"); + let install_root = root.join("plugins"); + for version in ["1.0.0", "1.5.0", "2.0.0"] { + let package = package_directory(&root, version); + staging_install(&install_root, "acme.php", version, &package).expect("installs"); + } + write_installation_record(&install_root, "acme.php", "2.0.0").expect("record"); + let plugin_directory = install_root.join("acme.php"); + let mut versions: Vec = std::fs::read_dir(&plugin_directory) + .expect("plugin dir") + .flatten() + .filter_map(|entry| { + let name = entry.file_name().to_string_lossy().to_string(); + let path = entry.path(); + (path.is_dir() && path.join("plugin.json").is_file()).then_some(name) + }) + .collect(); + versions.sort_by(|left, right| store::compare_versions(left, right)); + versions.reverse(); + let record: InstallationRecord = serde_json::from_str( + &std::fs::read_to_string(plugin_directory.join("installation.json")).expect("record"), + ) + .expect("record parses"); + let previous = versions + .into_iter() + .find(|version| *version != record.version) + .expect("previous version"); + assert_eq!(previous, "1.5.0"); + let _ = std::fs::remove_dir_all(&root); + } +} diff --git a/windows/tauri/src-tauri/src/plugin_host/launch.rs b/windows/tauri/src-tauri/src/plugin_host/launch.rs new file mode 100644 index 0000000000..b7dcebccb9 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/launch.rs @@ -0,0 +1,1029 @@ +//! Turns Core launch plans into process command lines for the frontend. +//! +//! Core expands templates and chooses the launch kind; this module resolves +//! the plan against real paths. Packaged entries must be regular files inside +//! the owning package — reparse points and path escapes are rejected so a +//! tampered package can never make the host execute anything outside its own +//! version directory. Runtime and system-command launches resolve through +//! PATH and export the same `LITHE__PATH` convention the macOS host +//! uses; the declared `minimumVersion` probe is enforced from the install +//! milestone on, when the host also owns the checkRuntimeVersion wiring. + +use super::store; +use super::ScannedPackage; +use serde::Serialize; +use serde_json::Value; +use std::cell::Cell; +use std::collections::BTreeMap; +use std::path::Path; +use std::path::PathBuf; +use std::process::Command; +use std::time::{Duration, Instant}; +use tauri::{AppHandle, Manager}; + +/// Windows reparse-point attribute; covers symlinks and junctions alike. +#[cfg(windows)] +const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400; + +/// Runtime version probes are untrusted package-selected executables. Keep +/// both their lifetime and captured output bounded before handing the text to +/// Core's shared version parser. +const RUNTIME_PROBE_TIMEOUT: Duration = Duration::from_secs(5); +const MAX_RUNTIME_PROBE_OUTPUT_BYTES: usize = 64 * 1024; + +#[cfg(windows)] +use std::os::windows::fs::MetadataExt; + +/// A resolved launch, shaped for `resolveEditorLspLaunch` on the frontend. +#[derive(Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ResolvedLaunch { + pub plugin_id: String, + pub language_id: String, + pub server_path: String, + pub server_args: Vec, + pub initialization_options: Option, + /// Expanded configuration provided to the server at session start. + pub workspace_configuration: Option, + /// Extra process environment (PATH prepends and LITHE_* exports). + pub environment: BTreeMap, +} + +/// Resolves the launch plan of the enabled declarative plugin that owns +/// `file_path`, or `None` when no accepted plugin claims the file. +#[tauri::command] +pub fn declarative_plugins_language_launch( + app: AppHandle, + file_path: String, + workspace_path: String, +) -> Result, String> { + let Some(package) = owning_package(&app, &file_path) else { + return Ok(None); + }; + let _native_launch = app + .state::() + .acquire(&package.entry.plugin_id)?; + let package = package_by_id(&app, &package.entry.plugin_id) + .ok_or("plugin was removed during launch resolution")?; + let Some(language_id) = owning_language_id(&package, &file_path) else { + return Ok(None); + }; + let state_directory = store::plugin_state_directory(&app, &package.entry.plugin_id)?; + let layers = super::settings::load_layers(&app, &package.entry.plugin_id, &workspace_path) + .map_err(|error| format!("plugin settings could not be read: {error}"))?; + let plan = super::scan::core_execute( + "plugins.declarative.languageServerLaunchPlan", + serde_json::json!({ + "manifest": package.manifest, + "languageID": language_id, + "request": launch_request(&state_directory, &package, &workspace_path, layers), + }), + ) + .map_err(|error| format!("launch plan failed: {error}"))?; + if plan["status"] == serde_json::json!("rejected") { + return Err(format!( + "launch plan rejected: {}", + plan["error"]["message"].as_str().unwrap_or("unknown") + )); + } + // Core wraps the plan in `{status, plan}`; resolve the inner value. + let resolved_plan = &plan["plan"]; + let workspace_state_directory = + state_directory.join(store::workspace_state_key(&workspace_path)); + prepare_required_directories(resolved_plan, &state_directory, &workspace_state_directory)?; + std::fs::create_dir_all(&state_directory).map_err(|error| error.to_string())?; + resolve_launch(&package, &language_id, resolved_plan, &state_directory).map(Some) +} + +/// Finds an accepted, enabled scanned package by plugin ID. +pub(crate) fn package_by_id(app: &AppHandle, plugin_id: &str) -> Option { + if app + .state::() + .blocked(plugin_id) + { + return None; + } + app.state::() + .packages() + .into_iter() + .find(|package| { + package.entry.status == "accepted" + && package.entry.enabled + && package.entry.plugin_id == plugin_id + }) +} + +/// Returns the enabled declarative plugin that owns a file, for the run and +/// test entry points. Mirrors the launch path's ownership matching without +/// expanding a plan. +#[tauri::command] +pub fn declarative_plugins_language_owner(app: AppHandle, file_path: String) -> Option { + let package = owning_package(&app, &file_path)?; + Some(OwnerInfo { + plugin_id: package.entry.plugin_id.clone(), + display_name: package.entry.display_name.clone(), + language_id: owning_language_id(&package, &file_path)?, + }) +} + +/// Ownership answer for run/test entry points. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OwnerInfo { + pub plugin_id: String, + pub display_name: String, + pub language_id: String, +} + +fn owning_package(app: &AppHandle, file_path: &str) -> Option { + app.state::() + .packages() + .into_iter() + .find(|package| { + !app.state::() + .blocked(&package.entry.plugin_id) + && package_matches_file(package, file_path) + }) +} + +fn package_matches_file(package: &ScannedPackage, file_path: &str) -> bool { + package.entry.status == "accepted" + && package.entry.enabled + && owning_language_id(package, file_path).is_some() +} + +/// Matches a file against the manifest's language declarations: extensions +/// compare without their optional leading dot and case-insensitively, file +/// names compare as exact lowercased basenames. +fn owning_language_id(package: &ScannedPackage, file_path: &str) -> Option { + let file_name = Path::new(file_path) + .file_name() + .map(|name| name.to_string_lossy().to_lowercase()) + .unwrap_or_default(); + let extension = Path::new(&file_name) + .extension() + .map(|extension| extension.to_string_lossy().to_lowercase()); + package.manifest["languages"] + .as_array()? + .iter() + .find(|language| { + let matches_extension = language["fileExtensions"].as_array().is_some_and(|items| { + items.iter().any(|item| { + item.as_str() + .map(|text| text.trim_start_matches('.').to_lowercase()) + == extension + }) + }); + let matches_name = language["fileNames"].as_array().is_some_and(|items| { + items + .iter() + .any(|item| item.as_str().map(str::to_lowercase).as_deref() == Some(&file_name)) + }); + matches_extension || matches_name + }) + .and_then(|language| language["id"].as_str().map(str::to_string)) +} + +/// Builds the Core launch-plan request with host-owned directories and +/// architecture. Settings layers stay empty until the settings milestone; +/// Core then falls back to declared defaults. +fn launch_request( + state_directory: &Path, + package: &ScannedPackage, + workspace_path: &str, + layers: super::settings::SettingLayers, +) -> Value { + let workspace_directory = state_directory.join(store::workspace_state_key(workspace_path)); + serde_json::json!({ + "pluginDirectory": package.root.to_string_lossy(), + "stateDirectory": state_directory.to_string_lossy(), + "workspaceStateDirectory": workspace_directory.to_string_lossy(), + "architecture": super::host_architecture(), + "settings": layers.global, + "projectSettings": layers.project, + }) +} + +/// Creates the host-owned directories requested by Core before the frontend +/// starts the process. The plan only names the two directories from the +/// shared contract; arbitrary paths are rejected at this boundary. +fn prepare_required_directories( + plan: &Value, + state_directory: &Path, + workspace_state_directory: &Path, +) -> Result<(), String> { + let Some(required) = plan.get("requiredDirectories") else { + return Ok(()); + }; + let required = required + .as_array() + .ok_or("launch plan requiredDirectories is not an array")?; + for directory in required { + let path = match directory.as_str() { + Some("state") => state_directory, + Some("workspaceState") => workspace_state_directory, + Some(other) => { + return Err(format!( + "launch plan requested an unknown directory {other}" + )); + } + None => return Err("launch plan requiredDirectories contains a non-string".to_string()), + }; + std::fs::create_dir_all(path).map_err(|error| { + format!( + "could not create launch directory {}: {error}", + path.display() + ) + })?; + } + Ok(()) +} + +fn resolve_launch( + package: &ScannedPackage, + language_id: &str, + plan: &Value, + probe_directory: &Path, +) -> Result { + for relative in string_arguments(&plan["requiredPackagePaths"]) { + verified_package_resource(&package.root, &relative)?; + } + let launch = &plan["launch"]; + let kind = launch["kind"].as_str().unwrap_or_default(); + let mut environment: BTreeMap = BTreeMap::new(); + let server_path = match kind { + "packaged" => verified_packaged_path( + &package.root, + launch["path"] + .as_str() + .ok_or("packaged launch has no path")?, + )?, + "systemCommand" => { + let commands = launch["commands"] + .as_array() + .ok_or("systemCommand launch has no commands")?; + let path_env = std::env::var_os("PATH"); + commands + .iter() + .filter_map(|command| command.as_str()) + .find_map(|command| { + super::super::language_tools::find_system_tool(path_env.as_deref(), command) + }) + .ok_or_else(|| { + format!("none of the declared system commands is on PATH: {commands:?}") + })? + } + "runtime" => { + let tool = launch["tool"] + .as_str() + .ok_or("runtime launch has no tool")?; + let key = format!("LITHE_{}_PATH", tool.to_uppercase().replace('-', "_")); + let path = if let Some(packaged) = launch["packagedRuntime"].as_str() { + // Packaged runtimes are fixed by the approved package. + verified_packaged_path(&package.root, packaged)? + } else if let Some(explicit) = std::env::var_os(&key).filter(|value| !value.is_empty()) + { + PathBuf::from(explicit) + } else { + super::super::language_tools::find_system_tool( + std::env::var_os("PATH").as_deref(), + tool, + ) + .ok_or_else(|| format!("runtime tool {tool} is not on PATH"))? + }; + if !path.is_absolute() || !path.is_file() { + return Err(format!( + "runtime {tool} must name an absolute executable file" + )); + } + if let Some(directory) = path.parent() { + prepend_path(&mut environment, directory); + } + environment.insert(key, path.to_string_lossy().to_string()); + verify_runtime_version(&path, tool, launch, probe_directory)?; + path + } + other => return Err(format!("unsupported launch kind {other}")), + }; + for requirement in plan["runtimeRequirements"].as_array().into_iter().flatten() { + let tool = requirement["tool"] + .as_str() + .ok_or("runtime requirement has no tool")?; + let key = format!("LITHE_{}_PATH", tool.to_uppercase().replace('-', "_")); + // Explicit user exports take precedence; project-local runtimes are + // never selected by PATH discovery for automatic server starts. + let path = std::env::var_os(&key) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .or_else(|| environment.get(&key).map(PathBuf::from)) + .or_else(|| { + super::super::language_tools::find_system_tool( + std::env::var_os("PATH").as_deref(), + tool, + ) + }) + .ok_or_else(|| format!("required runtime {tool} is unavailable"))?; + if !path.is_absolute() || !path.is_file() { + return Err(format!( + "runtime {tool} must name an absolute executable file" + )); + } + let mut version = requirement.clone(); + version["versionArguments"] = serde_json::json!(if tool == "java" { + vec!["-version"] + } else { + vec!["--version"] + }); + verify_runtime_version(&path, tool, &version, probe_directory)?; + environment.insert(key, path.to_string_lossy().into_owned()); + if let Some(directory) = path.parent() { + prepend_path(&mut environment, directory); + } + } + let validation = string_arguments(&plan["validationArguments"]); + if !validation.is_empty() { + let mut command = Command::new(&server_path); + command + .args(&validation) + .envs(&environment) + .current_dir(probe_directory); + run_process_probe(&mut command, "language server validation", true)?; + } + Ok(ResolvedLaunch { + plugin_id: package.entry.plugin_id.clone(), + language_id: language_id.to_string(), + server_path: server_path.to_string_lossy().to_string(), + server_args: string_arguments(&plan["arguments"]), + initialization_options: plan + .get("initializationOptions") + .filter(|value| value.is_object()) + .cloned(), + workspace_configuration: plan + .get("workspaceConfiguration") + .filter(|value| value.is_object()) + .cloned(), + environment, + }) +} + +/// Runs the tool's version probe and rejects a runtime older than the plan's +/// declared `minimumVersion`. Probes run at every launch because the PATH +/// candidate can change between sessions; a failing or unreadable probe is a +/// rejection, not a fallback. +fn verify_runtime_version( + path: &Path, + tool: &str, + launch: &Value, + probe_directory: &Path, +) -> Result<(), String> { + let Some(minimum) = launch["minimumVersion"].as_str() else { + return Ok(()); + }; + let version_arguments: Vec = launch["versionArguments"] + .as_array() + .map(|items| { + items + .iter() + .filter_map(|item| item.as_str().map(str::to_string)) + .collect() + }) + .ok_or_else(|| { + format!("runtime launch declares minimumVersion without versionArguments") + })?; + let output = bounded_version_probe(path, &version_arguments, tool, probe_directory)?; + let text = format!( + "{}{}", + String::from_utf8_lossy(&output.0), + String::from_utf8_lossy(&output.1) + ); + // Version parsing and comparison are Core semantics (shared + // runtime-versions fixtures); the host only runs the probe and forwards + // the output. + let check = super::scan::core_execute( + "plugins.declarative.checkRuntimeVersion", + serde_json::json!({ + "tool": tool, + "minimumVersion": minimum, + "output": text, + }), + ) + .map_err(|error| format!("runtime version check failed: {error}"))?; + if check["status"] == serde_json::json!("rejected") { + return Err(format!( + "runtime tool {tool}: {}", + check["error"]["message"] + .as_str() + .unwrap_or("version check failed") + )); + } + if check["check"]["satisfied"] == serde_json::json!(false) { + return Err(format!( + "runtime tool {tool}: {}", + check["check"]["reason"] + .as_str() + .unwrap_or("the runtime does not satisfy the required minimum version") + )); + } + Ok(()) +} + +/// Reuses the native executor's Job Object, nonblocking pipes and bounded +/// process-tree cleanup. The deadline covers pipe draining after parent exit; +/// package-selected background helpers cannot keep an LSP launch waiting. +fn bounded_version_probe( + path: &Path, + arguments: &[String], + tool: &str, + probe_directory: &Path, +) -> Result<(Vec, Vec), String> { + let mut command = Command::new(path); + command.args(arguments).current_dir(probe_directory); + run_version_probe(&mut command, tool) +} + +fn run_version_probe(command: &mut Command, tool: &str) -> Result<(Vec, Vec), String> { + run_process_probe(command, tool, false) +} + +fn run_process_probe( + command: &mut Command, + tool: &str, + require_success: bool, +) -> Result<(Vec, Vec), String> { + let deadline = Instant::now() + RUNTIME_PROBE_TIMEOUT; + let stdout_bytes = Cell::new(0_usize); + let stderr_bytes = Cell::new(0_usize); + let exceeded = || { + stdout_bytes.get() > MAX_RUNTIME_PROBE_OUTPUT_BYTES + || stderr_bytes.get() > MAX_RUNTIME_PROBE_OUTPUT_BYTES + }; + let outcome = lithe_git_host::run( + command, + None, + || exceeded() || Instant::now() >= deadline, + || {}, + |stream, bytes| { + let count = match stream { + lithe_git_host::Stream::Stdout => &stdout_bytes, + lithe_git_host::Stream::Stderr => &stderr_bytes, + }; + count.set(count.get().saturating_add(bytes.len())); + }, + ); + if exceeded() { + return Err(format!( + "runtime tool {tool} version probe output exceeds {} bytes", + MAX_RUNTIME_PROBE_OUTPUT_BYTES + )); + } + if let Some(failure) = outcome.failure { + return Err(format!( + "runtime tool {tool} version probe failed or timed out: {failure:?}" + )); + } + if require_success && outcome.status.is_none_or(|status| !status.success()) { + return Err(format!("{tool} probe exited unsuccessfully")); + } + if outcome.status.is_none_or(|status| !status.success()) + && outcome.stdout.is_empty() + && outcome.stderr.is_empty() + { + return Err(format!( + "runtime tool {tool} version probe exited without version output" + )); + } + Ok((outcome.stdout, outcome.stderr)) +} + +/// Joins a package-relative plan path and proves it stays inside the package +/// as a regular file. Every launch the host executes passes through here. +fn verified_packaged_path(root: &Path, relative: &str) -> Result { + let candidate = verified_package_resource(root, relative)?; + if !candidate.is_file() { + return Err(format!( + "packaged entry {} is not a regular file", + candidate.display() + )); + } + Ok(candidate) +} + +/// Required resources may be files or directories. Canonical containment and +/// reparse checks apply before the process starts, including runtime entry files. +fn verified_package_resource(root: &Path, relative: &str) -> Result { + let relative_path = Path::new(relative); + if relative_path.is_absolute() + || relative_path.components().any(|part| { + matches!( + part, + std::path::Component::ParentDir | std::path::Component::Prefix(_) + ) + }) + { + return Err("required package resource must stay inside the package".into()); + } + let candidate = root.join(relative); + let metadata = std::fs::symlink_metadata(&candidate).map_err(|error| { + format!( + "packaged entry {} is unreadable: {error}", + candidate.display() + ) + })?; + #[cfg(windows)] + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(format!( + "packaged entry {} is a reparse point", + candidate.display() + )); + } + #[cfg(not(windows))] + if metadata.file_type().is_symlink() { + return Err(format!( + "packaged entry {} is a symbolic link", + candidate.display() + )); + } + if !metadata.is_file() && !metadata.is_dir() { + return Err(format!( + "packaged entry {} is not a regular file", + candidate.display() + )); + } + let canonical_root = root + .canonicalize() + .map_err(|error| format!("package root {} is unreadable: {error}", root.display()))?; + let canonical = candidate.canonicalize().map_err(|error| { + format!( + "packaged entry {} is unreadable: {error}", + candidate.display() + ) + })?; + if !canonical.starts_with(&canonical_root) { + return Err(format!( + "packaged entry {} escapes the package directory", + candidate.display() + )); + } + // Plan-relative paths use `/`; report the resolved path with the + // platform separator so frontend logs and spawns see one canonical form. + #[cfg(windows)] + let candidate = PathBuf::from(candidate.to_string_lossy().replace('/', "\\")); + Ok(candidate) +} + +fn prepend_path(environment: &mut BTreeMap, directory: &Path) { + let existing = environment + .get("PATH") + .cloned() + .unwrap_or_else(|| std::env::var("PATH").unwrap_or_default()); + let separator = if cfg!(windows) { ";" } else { ":" }; + environment.insert( + "PATH".to_string(), + format!("{}{separator}{existing}", directory.display()), + ); +} + +fn string_arguments(value: &Value) -> Vec { + value + .as_array() + .map(|items| { + items + .iter() + .filter_map(|item| item.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Child-only fixture: environment is attached to Command, never mutated + /// in the test runner. Its libtest entry is also the executable on CI. + #[test] + #[ignore = "launched by runtime probe regression tests"] + fn version_probe_fixture() { + use std::io::Write; + match std::env::var("LITHE_PROBE_FIXTURE").as_deref() { + Ok("output") => println!("v21.0.0"), + Ok("failure-output") => { + println!("version text cannot mask probe failure"); + std::process::exit(7); + } + Ok("overflow") => { + std::io::stdout() + .write_all(&vec![b'x'; MAX_RUNTIME_PROBE_OUTPUT_BYTES + 1]) + .unwrap(); + } + Ok("parent") => { + let mut command = probe_fixture_command("hold-pipes"); + command.stdin(std::process::Stdio::null()); + command.spawn().expect("inheriting helper"); + println!("v21.0.0"); + } + Ok("hold-pipes") => { + let marker = std::env::var("LITHE_PROBE_MARKER").expect("marker path"); + std::fs::write(marker, std::process::id().to_string()).expect("record helper"); + // The helper's guard only prevents a leaked fixture from living + // forever; the production Job Object must terminate it first. + let (_sender, receiver) = std::sync::mpsc::channel::<()>(); + let _ = receiver.recv_timeout(Duration::from_secs(30)); + } + _ => panic!("fixture requires a scoped child environment"), + } + } + + fn probe_fixture_command(mode: &str) -> Command { + let mut command = Command::new(std::env::current_exe().expect("test executable")); + command + .args([ + "--exact", + "plugin_host::launch::tests::version_probe_fixture", + "--ignored", + "--nocapture", + ]) + .env("LITHE_PROBE_FIXTURE", mode); + command + } + + #[test] + fn runtime_probe_collects_output_and_rejects_overflow() { + let output = + run_version_probe(&mut probe_fixture_command("output"), "fixture").expect("probe"); + assert!(String::from_utf8_lossy(&output.0).contains("v21.0.0")); + let error = + run_version_probe(&mut probe_fixture_command("overflow"), "fixture").unwrap_err(); + assert!(error.contains("output exceeds"), "{error}"); + } + + #[test] + #[cfg(windows)] + fn runtime_probe_reclaims_inherited_pipes_after_parent_exit() { + struct TempRoot(PathBuf); + impl Drop for TempRoot { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let root = TempRoot(unique_temp_root("inherited-pipes")); + let marker = root.0.join("helper.pid"); + let mut command = probe_fixture_command("parent"); + command.env("LITHE_PROBE_MARKER", &marker); + let began = Instant::now(); + let error = run_version_probe(&mut command, "fixture").unwrap_err(); + assert!( + began.elapsed() < RUNTIME_PROBE_TIMEOUT + Duration::from_secs(3), + "{error}" + ); + let pid: u32 = std::fs::read_to_string(marker) + .expect("helper started") + .parse() + .unwrap(); + // The parent is already gone: a PID walk could no longer own this helper. + // Opening a terminated process may still succeed while the kernel reaps it. + use windows_sys::Win32::{ + Foundation::CloseHandle, + System::Threading::{OpenProcess, WaitForSingleObject, PROCESS_SYNCHRONIZE}, + }; + unsafe { + let handle = OpenProcess(PROCESS_SYNCHRONIZE, 0, pid); + if !handle.is_null() { + let waited = WaitForSingleObject(handle, 1000); + CloseHandle(handle); + assert_eq!(waited, 0, "version helper survived process-tree cleanup"); + } + } + } + + /// The child receives explicit runtime configuration without changing the + /// test runner's environment or depending on its installed PATH tools. + #[test] + fn runtime_launch_uses_the_same_explicit_executable_as_its_dependency() { + let executable = std::env::current_exe().unwrap(); + let mut command = Command::new(&executable); + command + .args([ + "--exact", + "plugin_host::launch::tests::explicit_runtime_fixture", + "--ignored", + "--nocapture", + ]) + .env("LITHE_NODE_PATH", &executable); + run_process_probe(&mut command, "explicit runtime fixture", true).unwrap(); + } + + #[test] + #[ignore = "launched with a scoped runtime export by the regression test"] + fn explicit_runtime_fixture() { + struct TempRoot(PathBuf); + impl Drop for TempRoot { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let root = TempRoot(unique_temp_root("explicit-runtime")); + std::fs::write(root.0.join("fallback.exe"), b"must not execute").unwrap(); + let package = package_with_root(root.0.clone(), serde_json::json!({})); + let plan = serde_json::json!({ + "launch": {"kind":"runtime", "tool":"node"}, + "runtimeRequirements":[{"tool":"node"}] + }); + let resolved = resolve_launch(&package, "test", &plan, &package.root).unwrap(); + let executable = std::env::current_exe() + .unwrap() + .to_string_lossy() + .into_owned(); + assert_eq!(resolved.server_path, executable); + assert_eq!(resolved.environment["LITHE_NODE_PATH"], executable); + let mut packaged = plan.clone(); + packaged["launch"]["packagedRuntime"] = serde_json::json!("fallback.exe"); + packaged["runtimeRequirements"] = serde_json::json!([]); + let resolved = resolve_launch(&package, "test", &packaged, &package.root).unwrap(); + let expected = verified_packaged_path(&package.root, "fallback.exe") + .unwrap() + .to_string_lossy() + .into_owned(); + assert_eq!(resolved.server_path, expected); + assert_eq!(resolved.environment["LITHE_NODE_PATH"], expected); + } + + #[test] + fn validation_requires_zero_exit_even_when_probe_prints_output() { + run_process_probe(&mut probe_fixture_command("output"), "validation", true).unwrap(); + let error = run_process_probe( + &mut probe_fixture_command("failure-output"), + "validation", + true, + ) + .unwrap_err(); + assert!(error.contains("unsuccessfully"), "{error}"); + } + + #[test] + fn launch_rejects_missing_required_resources_and_runtime_dependencies() { + struct TempRoot(PathBuf); + impl Drop for TempRoot { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let root = TempRoot(unique_temp_root("requirements")); + std::fs::write(root.0.join("server.exe"), b"fixture").unwrap(); + let package = package_with_root(root.0.clone(), serde_json::json!({})); + let mut plan = serde_json::json!({ "launch": {"kind": "packaged", "path": "server.exe"}, "requiredPackagePaths": ["missing.js"] }); + assert!(resolve_launch(&package, "test", &plan, &package.root) + .unwrap_err() + .contains("missing.js")); + plan["requiredPackagePaths"] = serde_json::json!([]); + plan["runtimeRequirements"] = + serde_json::json!([{ "tool": "lithe-fixture-absent-runtime-9416" }]); + assert!(resolve_launch(&package, "test", &plan, &package.root) + .unwrap_err() + .contains("required runtime")); + std::fs::create_dir(root.0.join("resources")).unwrap(); + assert!(verified_package_resource(&root.0, "resources").is_ok()); + assert!(verified_packaged_path(&root.0, "resources").is_err()); + assert!(verified_package_resource(&root.0, "../escape").is_err()); + } + + /// Unique per-run temp root; mirrors the temp-dir convention used by the + /// other src-tauri tests (std::env::temp_dir + pid + timestamp). + fn unique_temp_root(label: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "lithe-plugin-host-{label}-{}-{nanos}", + std::process::id() + )); + std::fs::create_dir_all(&root).expect("temp root"); + root + } + + fn package_with_root(root: PathBuf, manifest: Value) -> ScannedPackage { + ScannedPackage { + entry: super::super::CatalogEntry { + plugin_id: "test.plugin".to_string(), + display_name: "Test".to_string(), + version: "1.0.0".to_string(), + api_version: 1, + source: "installed", + enabled: true, + status: "accepted", + languages: Vec::new(), + declared_settings: Vec::new(), + declares_tests: false, + primary_language_id: None, + rejection: None, + }, + root, + manifest, + } + } + + fn php_like_manifest() -> Value { + serde_json::json!({ + "languages": [ + { "id": "php", "displayName": "PHP", "fileExtensions": [".PHP", "php"] }, + { "id": "ini", "displayName": "INI", "fileNames": ["php.ini"] } + ] + }) + } + + #[test] + fn languages_match_extensions_case_and_dot_insensitively() { + let package = package_with_root(PathBuf::from("unused"), php_like_manifest()); + assert_eq!(owning_language_id(&package, r"C:\site\index.phtml"), None); + assert_eq!( + owning_language_id(&package, r"C:\site\Index.PHP").as_deref(), + Some("php") + ); + assert_eq!(owning_language_id(&package, r"C:\site\ini"), None); + assert_eq!( + owning_language_id(&package, r"C:\site\PHP.INI").as_deref(), + Some("ini") + ); + } + + /// Version judgment goes through Core's checkRuntimeVersion: outputs the + /// shared fixtures accept (a `java -version` banner line before the + /// version, an `openjdk 25` two-part version) are judged by Core, not by + /// host parsing. + #[test] + fn version_check_delegates_to_core_semantics() { + let check = super::super::scan::core_execute( + "plugins.declarative.checkRuntimeVersion", + serde_json::json!({ + "tool": "java", + "minimumVersion": "21", + "output": "Picked up JAVA_TOOL_OPTIONS: -Dapp.version=99.0\nopenjdk version \"25\" 2026-09-16", + }), + ) + .expect("core check executes"); + assert_eq!(check["status"], "ok", "{check}"); + assert_eq!(check["check"]["satisfied"], true); + + let stale = super::super::scan::core_execute( + "plugins.declarative.checkRuntimeVersion", + serde_json::json!({ + "tool": "java", + "minimumVersion": "21", + "output": "openjdk version \"17.0.2\" 2022-01-18", + }), + ) + .expect("core check executes"); + assert_eq!(stale["check"]["satisfied"], false); + } + + #[test] + fn version_comparison_is_numeric_per_chunk() { + assert_eq!( + store::compare_versions("1.10.0", "1.9.0"), + std::cmp::Ordering::Greater + ); + assert_eq!( + store::compare_versions("2.0.0", "10.0.0"), + std::cmp::Ordering::Less + ); + assert_eq!( + store::compare_versions("1.0.0-rc1", "1.0.0"), + std::cmp::Ordering::Less + ); + assert_eq!( + store::compare_versions("1.0.0", "1.0.0"), + std::cmp::Ordering::Equal + ); + } + + #[test] + fn workspace_state_keys_are_stable_and_distinct() { + assert_eq!( + store::workspace_state_key(r"D:\work\demo"), + store::workspace_state_key(r"D:\work\demo") + ); + assert_ne!( + store::workspace_state_key(r"D:\work\demo"), + store::workspace_state_key(r"D:\work\demo2") + ); + } + + #[test] + fn required_launch_directories_are_created_from_contract_names() { + let temporary = unique_temp_root("required-directories"); + let state = temporary.join("state"); + let workspace = state.join("workspace"); + let plan = serde_json::json!({ + "requiredDirectories": ["state", "workspaceState"] + }); + prepare_required_directories(&plan, &state, &workspace).expect("directories created"); + assert!(state.is_dir()); + assert!(workspace.is_dir()); + assert!(prepare_required_directories( + &serde_json::json!({ "requiredDirectories": ["arbitrary"] }), + &state, + &workspace, + ) + .is_err()); + let _ = std::fs::remove_dir_all(&temporary); + } + + #[test] + fn resolve_launch_consumes_the_real_core_response_envelope() { + let temporary = unique_temp_root("launch-envelope"); + let package_root = temporary.as_path().join("pkg"); + let server = package_root.join("bin").join("server.exe"); + std::fs::create_dir_all(server.parent().unwrap()).expect("bin dir"); + std::fs::write(&server, b"binary").expect("server file"); + // The bundled fixture is API 1 (no settings requirement) with a + // packaged launch, so a real plan request succeeds. + let mut manifest = crate::plugin_host::tests::read_fixture_for_tests( + "language-packaged-server.plugin.json", + ); + manifest["languageServers"][0]["launch"] = serde_json::json!({ + "kind": "packaged", + "path": "bin/server.exe" + }); + let package = package_with_root(package_root, manifest); + let response = super::super::scan::core_execute( + "plugins.declarative.languageServerLaunchPlan", + serde_json::json!({ + "manifest": package.manifest, + "languageID": "php", + "request": launch_request( + &temporary.as_path().join("state"), + &package, + &temporary.as_path().to_string_lossy(), + crate::plugin_host::settings::SettingLayers { + global: Default::default(), + project: Default::default(), + }, + ), + }), + ) + .expect("real Core command executes"); + assert_eq!(response["status"], "ok", "{response}"); + // The production path passes the wrapped outcome; only the inner + // plan carries the launch. + let resolved = + resolve_launch(&package, "php", &response["plan"], &package.root).expect("resolves"); + assert_eq!(resolved.server_path, server.to_string_lossy()); + let _ = std::fs::remove_dir_all(&temporary); + } + + #[test] + fn packaged_launches_resolve_inside_the_package() { + let temporary = unique_temp_root("launch"); + let package_root = temporary.as_path().join("pkg"); + let server = package_root.join("bin").join("server.exe"); + std::fs::create_dir_all(server.parent().unwrap()).expect("bin dir"); + std::fs::write(&server, b"binary").expect("server file"); + let package = package_with_root(package_root.clone(), serde_json::json!({})); + let plan = serde_json::json!({ + "launch": { "kind": "packaged", "path": "bin/server.exe" }, + "arguments": ["--stdio"] + }); + let resolved = resolve_launch(&package, "php", &plan, &package.root).expect("resolves"); + assert_eq!(resolved.server_path, server.to_string_lossy()); + assert_eq!(resolved.server_args, vec!["--stdio".to_string()]); + assert_eq!(resolved.initialization_options, None); + + std::fs::write(temporary.as_path().join("outside.exe"), b"x").expect("outside file"); + let escape_plan = serde_json::json!({ + "launch": { "kind": "packaged", "path": "../outside.exe" }, + "arguments": [] + }); + assert!(resolve_launch(&package, "php", &escape_plan, &package.root).is_err()); + } + + #[test] + fn runtime_launches_export_the_tool_path_and_prepend_the_runtime_directory() { + let temporary = unique_temp_root("launch"); + let package_root = temporary.as_path().join("pkg"); + let runtime = package_root.join("runtime").join("java.exe"); + std::fs::create_dir_all(runtime.parent().unwrap()).expect("runtime dir"); + std::fs::write(&runtime, b"jvm").expect("runtime file"); + let package = package_with_root(package_root, serde_json::json!({})); + let plan = serde_json::json!({ + "launch": { + "kind": "runtime", + "tool": "java", + "packagedRuntime": "runtime/java.exe", + "entry": "server.jar" + }, + "arguments": ["-jar", "server.jar"] + }); + let resolved = resolve_launch(&package, "java", &plan, &package.root).expect("resolves"); + assert_eq!(resolved.server_path, runtime.to_string_lossy()); + assert_eq!( + resolved.environment.get("LITHE_JAVA_PATH"), + Some(&runtime.to_string_lossy().to_string()) + ); + let path = resolved.environment.get("PATH").expect("PATH export"); + assert!(path.starts_with(&format!( + "{};", + temporary.as_path().join("pkg").join("runtime").display() + ))); + } +} diff --git a/windows/tauri/src-tauri/src/plugin_host/lifecycle.rs b/windows/tauri/src-tauri/src/plugin_host/lifecycle.rs new file mode 100644 index 0000000000..b2affc9fde --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/lifecycle.rs @@ -0,0 +1,310 @@ +//! Package mutation barrier. Revoke plans before asking each product window +//! to drain its process owners; only acknowledgements from that window count. +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex}; +use tauri::{AppHandle, Emitter, Manager, WebviewWindow}; +use tokio::sync::oneshot; + +#[derive(Default)] +pub struct LifecycleState { + access: Arc, + pending: Mutex>>>>, + changing: Mutex>, +} + +#[derive(Default)] +struct AccessState { + blocked: HashSet, + probes: HashMap, +} +#[derive(Default)] +struct NativeAccess { + state: Mutex, + changed: tokio::sync::Notify, +} + +impl LifecycleState { + pub fn blocked(&self, plugin_id: &str) -> bool { + self.access + .state + .lock() + .expect("native plugin access") + .blocked + .contains(plugin_id) + } + /// Register before any native probe or package read. Revocation and + /// acquisition share one lock, so a mutation cannot miss a late probe. + pub fn acquire(&self, plugin_id: &str) -> Result { + let mut state = self.access.state.lock().expect("native plugin access"); + if state.blocked.contains(plugin_id) { + return Err("plugin is inactive or changing version".into()); + } + *state.probes.entry(plugin_id.into()).or_default() += 1; + Ok(NativeLease { + access: self.access.clone(), + plugin_id: plugin_id.into(), + }) + } + fn revoke(&self, plugin_id: &str) { + self.access + .state + .lock() + .expect("native plugin access") + .blocked + .insert(plugin_id.into()); + } + async fn wait_native(&self, plugin_id: &str) -> Result<(), String> { + tokio::time::timeout(std::time::Duration::from_secs(45), async { + loop { + let changed = self.access.changed.notified(); + tokio::pin!(changed); + changed.as_mut().enable(); + if self + .access + .state + .lock() + .expect("native plugin access") + .probes + .get(plugin_id) + .copied() + .unwrap_or(0) + == 0 + { + return; + } + changed.await; + } + }) + .await + .map_err(|_| "native plugin probes did not exit; package files were preserved".into()) + } +} + +pub struct NativeLease { + access: Arc, + plugin_id: String, +} +impl Drop for NativeLease { + fn drop(&mut self) { + let mut state = self.access.state.lock().expect("native plugin access"); + if let Some(count) = state.probes.get_mut(&self.plugin_id) { + *count -= 1; + if *count == 0 { + state.probes.remove(&self.plugin_id); + } + } + drop(state); + self.access.changed.notify_waiters(); + } +} + +/// One operation owns the plugin until its catalog and files are consistent. +/// Failure leaves launches revoked; retry/enable can explicitly recover it. +pub struct Mutation { + app: AppHandle, + plugin_id: String, + operation_id: String, +} +impl Drop for Mutation { + fn drop(&mut self) { + let state = self.app.state::(); + state + .pending + .lock() + .expect("plugin barriers") + .remove(&self.operation_id); + state + .changing + .lock() + .expect("plugin mutations") + .remove(&self.plugin_id); + } +} +impl Mutation { + pub fn complete(self, active: bool) { + self.app + .state::() + .access + .state + .lock() + .expect("native plugin access") + .blocked + .remove(&self.plugin_id); + let _ = self.app.emit( + "declarative-plugin-lifecycle", + serde_json::json!({ + "pluginId": self.plugin_id, "active": active, + }), + ); + } +} + +pub async fn begin(app: &AppHandle, plugin_id: &str) -> Result { + let state = app.state::(); + if !state + .changing + .lock() + .expect("plugin mutations") + .insert(plugin_id.to_string()) + { + return Err("another mutation already owns this plugin".into()); + } + let operation_id = super::store::workspace_state_key(&format!( + "{}-{:?}", + plugin_id, + std::time::SystemTime::now() + )); + let mutation = Mutation { + app: app.clone(), + plugin_id: plugin_id.into(), + operation_id: operation_id.clone(), + }; + state.revoke(plugin_id); + state.wait_native(plugin_id).await?; + super::web::close_plugin_windows(app, plugin_id)?; + let mut senders = HashMap::new(); + let mut receivers = Vec::new(); + for label in app + .webview_windows() + .keys() + .filter(|label| label.as_str() == "main" || label.starts_with("workspace-")) + { + let (sender, receiver) = oneshot::channel(); + senders.insert(label.clone(), sender); + receivers.push((label.clone(), receiver)); + } + let labels: Vec<_> = senders.keys().cloned().collect(); + state + .pending + .lock() + .expect("plugin barriers") + .insert(operation_id.clone(), senders); + for label in labels { + app.emit_to( + label, + "declarative-plugin-lifecycle", + serde_json::json!({ + "pluginId": plugin_id, "active": false, "operationId": operation_id, + }), + ) + .map_err(|error| error.to_string())?; + } + await_drained(receivers, std::time::Duration::from_secs(45)).await?; + Ok(mutation) +} + +async fn await_drained( + receivers: Vec<(String, oneshot::Receiver>)>, + timeout: std::time::Duration, +) -> Result<(), String> { + tokio::time::timeout(timeout, async { + for (label, receiver) in receivers { + receiver + .await + .map_err(|_| format!("process owner {label} disconnected"))??; + } + Ok::<_, String>(()) + }) + .await + .map_err(|_| "plugin process stop timed out; package files were preserved".to_string())? +} + +#[tauri::command] +pub fn declarative_plugins_lifecycle_ack( + app: AppHandle, + window: WebviewWindow, + operation_id: String, + error: Option, +) -> Result<(), String> { + let state = app.state::(); + let sender = state + .pending + .lock() + .expect("plugin barriers") + .get_mut(&operation_id) + .and_then(|pending| pending.remove(window.label())) + .ok_or("unknown plugin mutation acknowledgement")?; + let _ = sender.send(error.map_or(Ok(()), Err)); + Ok(()) +} + +pub fn window_destroyed(app: &AppHandle, label: &str) { + // A closed UI window can still own Core processes. Do not count destruction + // as a successful drain: fail the mutation and preserve the package. + for pending in app + .state::() + .pending + .lock() + .expect("plugin barriers") + .values_mut() + { + if let Some(sender) = pending.remove(label) { + let _ = sender.send(Err( + "process owner window closed before acknowledgement".into() + )); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn mutation_drains_native_probes_and_rejects_late_acquisition() { + let state = LifecycleState::default(); + let probe = state.acquire("test.plugin").unwrap(); + state.revoke("test.plugin"); + assert!(state.acquire("test.plugin").is_err()); + let waiting = state.wait_native("test.plugin"); + tokio::pin!(waiting); + tokio::select! { biased; + result = &mut waiting => panic!("mutation advanced while native probe owns package: {result:?}"), + _ = std::future::ready(()) => {}, + } + drop(probe); + waiting.await.unwrap(); + } + + #[tokio::test] + async fn mutation_waits_for_every_window_and_propagates_stop_failure() { + let (first, first_result) = oneshot::channel(); + let (second, second_result) = oneshot::channel(); + let drain = await_drained( + vec![ + ("first".into(), first_result), + ("second".into(), second_result), + ], + std::time::Duration::from_secs(1), + ); + tokio::pin!(drain); + second.send(Ok(())).unwrap(); + // Poll once with only the second window released: the mutation cannot advance. + tokio::select! { biased; + result = &mut drain => panic!("mutation advanced without its first owner: {result:?}"), + _ = std::future::ready(()) => {}, + } + first.send(Err("executable still in use".into())).unwrap(); + assert_eq!(drain.await.unwrap_err(), "executable still in use"); + } + + #[tokio::test] + async fn mutation_refuses_disconnected_or_unresponsive_owners() { + let (sender, receiver) = oneshot::channel(); + drop(sender); + assert!(await_drained( + vec![("closed".into(), receiver)], + std::time::Duration::from_secs(1) + ) + .await + .unwrap_err() + .contains("disconnected")); + let (_sender, receiver) = oneshot::channel(); + assert!( + await_drained(vec![("busy".into(), receiver)], std::time::Duration::ZERO) + .await + .unwrap_err() + .contains("files were preserved") + ); + } +} diff --git a/windows/tauri/src-tauri/src/plugin_host/mod.rs b/windows/tauri/src-tauri/src/plugin_host/mod.rs new file mode 100644 index 0000000000..6ca4a91922 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/mod.rs @@ -0,0 +1,182 @@ +//! Windows host for contract v1 declarative plugin packages. +//! +//! Package validation, launch-plan expansion, and install review are shared +//! deterministic logic that lives only in `lithe-core`; this module owns the +//! platform side: where packages are installed, which packages the host +//! currently accepts, and how a Core launch plan becomes a process command +//! line. The boundary with the Worker extension model and the staged +//! capability rollout are recorded in +//! .agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md + +pub mod install; +#[cfg(test)] +mod install_tests; +pub mod launch; +pub mod lifecycle; +pub mod run_plans; +pub mod scan; +pub mod settings; +pub mod store; +#[cfg(test)] +mod tests; +pub mod web; + +use serde::Serialize; +use serde_json::{json, Value}; +use std::path::PathBuf; +use std::sync::Mutex; +use tauri::{AppHandle, Manager}; + +/// Capability versions this host implements. `plugins.declarative.validate` +/// rejects any package that requires a capability or version beyond this map, +/// so the host never registers a manifest section it cannot run. The set +/// grows per milestone of the declarative plugin host work. `languageServers` +/// 2 and `settings` 2 add workspace configuration push and scoped settings +/// (M3); `ui` 2 adds package web pages in host windows (M4); +/// `executionProviders`, `buildProviders` and `buildSystems` remain unhosted. +pub fn host_capabilities() -> Value { + json!({ + "languages": [1], + "languageServers": [1, 2], + "serverProcess": [1], + "settings": [1, 2], + "ui": [1, 2], + }) +} + +/// Host CPU architecture in the vocabulary Core launch templates expect +/// (`arm64` / `x86_64`); Rust spells aarch64 differently. +pub fn host_architecture() -> &'static str { + match std::env::consts::ARCH { + "aarch64" => "arm64", + other => other, + } +} + +/// Frontend-facing summary of one scanned declarative plugin. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CatalogEntry { + pub plugin_id: String, + pub display_name: String, + pub version: String, + pub api_version: u32, + /// `bundled` packages ship inside the installation directory and load + /// read-only; `installed` packages live under the per-user plugin store. + pub source: &'static str, + pub enabled: bool, + /// `accepted` packages are registered language owners; `rejected` + /// packages are quarantined with Core's stable rejection code. + pub status: &'static str, + pub languages: Vec, + /// Declared settings (settings capability), as Core normalizes them. + pub declared_settings: Vec, + /// Whether any execution provider declares a test template. + pub declares_tests: bool, + /// First language with a language server, for workspace configuration. + pub primary_language_id: Option, + pub rejection: Option, +} + +/// Language declaration of a scanned package. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CatalogLanguage { + pub id: String, + pub display_name: String, + pub file_extensions: Vec, + pub file_names: Vec, +} + +/// Why a package was quarantined. Manifest problems carry Core's stable +/// `plugin.*` code; store problems (missing or invalid installation record) +/// use the host-owned `plugin.store.*` namespace. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CatalogRejection { + pub code: String, + pub message: String, + pub path: Option, +} + +/// One scanned package with everything launch resolution needs but the +/// frontend does not. +#[derive(Debug, Clone)] +pub struct ScannedPackage { + pub entry: CatalogEntry, + /// Directory that directly contains `plugin.json`. + pub root: PathBuf, + /// Raw manifest; plan commands are pure functions and take it every time. + pub manifest: Value, +} + +/// Cached scan result. The frontend rescans at startup and after installs, +/// then reads this state for registration and launch queries. +#[derive(Default)] +pub struct PluginHostState { + scan_lock: Mutex<()>, + packages: Mutex>, +} + +impl PluginHostState { + /// Serialize discovery through publication. Independent package mutations + /// may overlap, but an older full scan must never overwrite a newer scan. + pub(crate) fn refresh( + &self, + discover: impl FnOnce() -> Result, String>, + ) -> Result { + let _scan = self + .scan_lock + .lock() + .map_err(|_| "plugin catalog scan lock poisoned")?; + let packages = discover()?; + let count = packages.len(); + self.set_packages(packages); + Ok(count) + } + + pub fn set_packages(&self, packages: Vec) { + *self.packages.lock().expect("plugin host catalog mutex") = packages; + } + + pub fn packages(&self) -> Vec { + self.packages + .lock() + .expect("plugin host catalog mutex") + .clone() + } +} + +/// Rescans bundled and installed packages and replaces the cached catalog. +#[tauri::command] +pub fn declarative_plugins_rescan(app: AppHandle) -> Result { + app.state::() + .refresh(|| scan::build_catalog(&app)) +} + +/// Returns the cached scan result. Call `declarative_plugins_rescan` first; +/// this command never triggers filesystem work. +#[tauri::command] +pub fn declarative_plugins_state(app: AppHandle) -> Vec { + app.state::() + .packages() + .into_iter() + .map(|package| package.entry) + .collect() +} + +/// Returns the raw manifest of one accepted, enabled plugin; settings pages +/// must pass the full manifest to Core's resolve commands, which re-validate +/// it. `None` when the ID does not name an accepted, enabled plugin. +#[tauri::command] +pub fn declarative_plugins_manifest(app: AppHandle, plugin_id: String) -> Option { + app.state::() + .packages() + .into_iter() + .find(|package| { + package.entry.status == "accepted" + && package.entry.enabled + && package.entry.plugin_id == plugin_id + }) + .map(|package| package.manifest) +} diff --git a/windows/tauri/src-tauri/src/plugin_host/run_plans.rs b/windows/tauri/src-tauri/src/plugin_host/run_plans.rs new file mode 100644 index 0000000000..e7f0b2eda9 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/run_plans.rs @@ -0,0 +1,166 @@ +//! Run and test plans for declarative plugins. +//! +//! Core expands the declared templates into an executable and arguments; the +//! host resolves the executable like any other launch (`run_resolve_launch`) +//! and starts the process through the shared run pipeline. Process ownership +//! is tracked per workspace and plugin on the frontend, exactly like Worker +//! extension run actions. + +use super::scan::core_execute; +use serde::Serialize; +use serde_json::Value; +use std::path::Path; +use tauri::AppHandle; + +/// An expanded run or test plan ready for the shared run pipeline. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeclarativeRunPlan { + pub plugin_id: String, + pub language_id: String, + /// Executable command as declared (e.g. `php`); the frontend resolves it + /// through `run_resolve_launch` like any extension run action. + pub executable: Value, + pub arguments: Vec, + pub working_directory: String, +} + +/// Expands the `run` template for one file of a language a plugin owns. +#[tauri::command] +pub fn declarative_plugins_run_plan( + app: AppHandle, + plugin_id: String, + language_id: String, + relative_file_path: String, + user_arguments: Vec, +) -> Result { + let package = super::launch::package_by_id(&app, &plugin_id) + .ok_or_else(|| format!("{plugin_id} is not an accepted declarative plugin"))?; + expand_plan( + &package.manifest, + &plugin_id, + &language_id, + "plugins.declarative.runPlan", + serde_json::json!({ + "relativeFilePath": relative_file_path, + "arguments": user_arguments, + }), + ) +} + +/// Expands the workspace test template for a language a plugin owns. +/// The host checks declared project markers in the workspace before Core +/// decides whether the test template applies. +#[tauri::command] +pub fn declarative_plugins_test_plan( + app: AppHandle, + plugin_id: String, + language_id: String, + workspace_path: String, +) -> Result { + let package = super::launch::package_by_id(&app, &plugin_id) + .ok_or_else(|| format!("{plugin_id} is not an accepted declarative plugin"))?; + let relative_project_file_paths = + existing_project_markers(&package.manifest, &language_id, Path::new(&workspace_path)); + expand_plan( + &package.manifest, + &plugin_id, + &language_id, + "plugins.declarative.testPlan", + serde_json::json!({ + "scope": { "kind": "workspace" }, + "relativeProjectFilePaths": relative_project_file_paths, + }), + ) +} + +fn existing_project_markers(manifest: &Value, language_id: &str, root: &Path) -> Vec { + let mut markers: Vec = manifest["executionProviders"] + .as_array() + .into_iter() + .flatten() + .filter(|provider| provider["languageID"] == language_id) + .flat_map(|provider| { + provider["test"]["projectMarkers"] + .as_array() + .into_iter() + .flatten() + }) + .filter_map(|marker| marker.as_str()) + .filter(|marker| root.join(marker).is_file()) + .map(str::to_string) + .collect(); + markers.sort(); + markers.dedup(); + markers +} + +fn expand_plan( + manifest: &Value, + plugin_id: &str, + language_id: &str, + command: &str, + request: Value, +) -> Result { + let plan = core_execute( + command, + serde_json::json!({ + "manifest": manifest, + "languageID": language_id, + "request": request, + }), + )?; + if plan["status"] == serde_json::json!("rejected") { + return Err(format!( + "{command} rejected: {}", + plan["error"]["message"].as_str().unwrap_or("unknown") + )); + } + let body = &plan["plan"]; + Ok(DeclarativeRunPlan { + plugin_id: plugin_id.to_string(), + language_id: language_id.to_string(), + executable: body["executable"].clone(), + arguments: body["arguments"] + .as_array() + .map(|items| { + items + .iter() + .filter_map(|item| item.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(), + working_directory: body["workingDirectory"].as_str().unwrap_or(".").to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_markers_come_from_existing_workspace_files_for_the_language() { + let root = std::env::temp_dir().join(format!( + "lithe-test-markers-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + std::fs::create_dir_all(&root).expect("workspace"); + std::fs::write(root.join("composer.json"), "{}").expect("marker"); + let manifest = serde_json::json!({ + "executionProviders": [ + {"languageID": "php", "test": {"projectMarkers": ["composer.json", "phpunit.xml"]}}, + {"languageID": "go", "test": {"projectMarkers": ["go.mod"]}} + ] + }); + assert_eq!( + existing_project_markers(&manifest, "php", &root), + vec!["composer.json"] + ); + assert!(existing_project_markers(&manifest, "go", &root).is_empty()); + std::fs::remove_dir_all(root).expect("cleanup"); + } +} diff --git a/windows/tauri/src-tauri/src/plugin_host/scan.rs b/windows/tauri/src-tauri/src/plugin_host/scan.rs new file mode 100644 index 0000000000..59bb55e0b6 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/scan.rs @@ -0,0 +1,247 @@ +//! Scans package directories and asks Core to validate every manifest. +//! +//! The host owns package bytes and install records; Core owns every manifest +//! semantic. A package Core rejects is quarantined with Core's stable +//! rejection code and never registered as a language owner. Validation runs +//! through the same `lithe_core::execute_json` envelope the frontend uses, so +//! the shared fixtures acceptance covers the real production path. + +use super::store::{self, DiscoveredPackage}; +pub(crate) use super::ScannedPackage; +use super::{CatalogEntry, CatalogLanguage, CatalogRejection}; +use serde_json::{json, Value}; +use tauri::AppHandle; + +pub fn build_catalog(app: &AppHandle) -> Result, String> { + build_catalog_from(&store::bundled_root(app)?, &store::install_root(app)?) +} + +/// Catalog builder over explicit roots so tests can exercise the full scan +/// without a running Tauri app. +pub fn build_catalog_from( + bundled_root: &std::path::Path, + install_root: &std::path::Path, +) -> Result, String> { + let mut discovered = store::discover_bundled_in(bundled_root)?; + discovered.extend(store::discover_installed_in(install_root)?); + Ok(discovered.into_iter().map(scan_package).collect()) +} + +fn scan_package(package: DiscoveredPackage) -> ScannedPackage { + let fallback_id = package + .root + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + let manifest = match read_manifest(&package.root) { + Ok(manifest) => manifest, + Err(error) => { + return quarantined( + package, + fallback_id, + "0", + catalog_rejection("plugin.manifest.invalid", error, None), + ); + } + }; + if let Some(problem) = package.record_problem.clone() { + return quarantined( + package, + manifest_id(&manifest).unwrap_or(fallback_id), + &manifest_version(&manifest).unwrap_or_default(), + catalog_rejection("plugin.store.installationRecordInvalid", problem, None), + ); + } + match validate_manifest(&manifest) { + Ok(plugin) => { + let entry = CatalogEntry { + plugin_id: plugin["id"].as_str().unwrap_or(&fallback_id).to_string(), + display_name: plugin["displayName"] + .as_str() + .unwrap_or("plugin") + .to_string(), + version: plugin["version"].as_str().unwrap_or("0").to_string(), + api_version: plugin["pluginAPIVersion"].as_u64().unwrap_or(0) as u32, + source: package.source, + enabled: package.enabled, + status: "accepted", + languages: catalog_languages(&plugin), + declared_settings: plugin["settings"].as_array().cloned().unwrap_or_default(), + declares_tests: plugin["executionProviders"] + .as_array() + .is_some_and(|providers| { + providers + .iter() + .any(|provider| provider.get("test").is_some()) + }), + primary_language_id: plugin["languageServers"] + .as_array() + .and_then(|servers| servers.first()) + .and_then(|server| server["languageID"].as_str()) + .map(str::to_string), + rejection: None, + }; + ScannedPackage { + entry, + root: package.root, + manifest, + } + } + Err(rejection) => quarantined( + package, + manifest_id(&manifest).unwrap_or(fallback_id), + &manifest_version(&manifest).unwrap_or_default(), + rejection, + ), + } +} + +fn quarantined( + package: DiscoveredPackage, + plugin_id: String, + version: &str, + rejection: CatalogRejection, +) -> ScannedPackage { + ScannedPackage { + entry: CatalogEntry { + plugin_id, + display_name: plugin_id_display(&package.root), + version: version.to_string(), + api_version: 0, + source: package.source, + enabled: false, + status: "rejected", + languages: Vec::new(), + declared_settings: Vec::new(), + declares_tests: false, + primary_language_id: None, + rejection: Some(rejection), + }, + root: package.root, + manifest: Value::Null, + } +} + +fn plugin_id_display(root: &std::path::Path) -> String { + root.file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| "unknown".to_string()) +} + +fn catalog_rejection(code: &str, message: String, path: Option) -> CatalogRejection { + CatalogRejection { + code: code.to_string(), + message, + path, + } +} + +fn manifest_id(manifest: &Value) -> Option { + manifest["id"].as_str().map(|id| id.to_string()) +} + +fn manifest_version(manifest: &Value) -> Option { + manifest["version"] + .as_str() + .map(|version| version.to_string()) +} + +fn catalog_languages(plugin: &Value) -> Vec { + plugin["languages"] + .as_array() + .map(|languages| { + languages + .iter() + .map(|language| CatalogLanguage { + id: language["id"].as_str().unwrap_or_default().to_string(), + display_name: language["displayName"] + .as_str() + .unwrap_or_default() + .to_string(), + file_extensions: string_list(&language["fileExtensions"]), + file_names: string_list(&language["fileNames"]), + }) + .collect() + }) + .unwrap_or_default() +} + +fn string_list(value: &Value) -> Vec { + value + .as_array() + .map(|items| { + items + .iter() + .filter_map(|item| item.as_str().map(|text| text.to_string())) + .collect() + }) + .unwrap_or_default() +} + +fn read_manifest(root: &std::path::Path) -> Result { + let text = std::fs::read_to_string(root.join("plugin.json")) + .map_err(|error| format!("plugin.json could not be read: {error}"))?; + serde_json::from_str(&text).map_err(|error| format!("plugin.json is not valid JSON: {error}")) +} + +/// Executes one Core command through the shared JSON envelope. Command-level +/// outcomes (including manifest rejections) arrive as `ok: true` data with a +/// `status` field; only malformed requests or host bugs surface as `Err`. +pub(crate) fn core_execute(command: &str, payload: Value) -> Result { + let request = json!({ "command": command, "payload": payload }); + let response_text = lithe_core::execute_json(&request.to_string()); + let response: Value = serde_json::from_str(&response_text) + .map_err(|error| format!("{command} returned an unreadable response: {error}"))?; + if response["ok"] == json!(true) { + Ok(response["data"].clone()) + } else { + Err(format!( + "{command} failed: {}", + response["error"]["message"] + .as_str() + .unwrap_or("unknown error") + )) + } +} + +/// Validates one manifest with this host's capability declaration. `Ok` +/// carries Core's normalized manifest. `host: None` runs pure contract +/// validation, which is what the shared fixture acceptance asserts. +pub(crate) fn validate_manifest_with( + manifest: &Value, + host: Option, +) -> Result { + let mut payload = json!({ "manifest": manifest }); + if let Some(host) = host { + payload["hostCapabilities"] = host; + } + let outcome = core_execute("plugins.declarative.validate", payload) + .map_err(|error| catalog_rejection("plugin.host.coreFailure", error, None))?; + if outcome["status"] == json!("rejected") { + return Err(CatalogRejection { + code: outcome["error"]["code"] + .as_str() + .unwrap_or("plugin.manifest.invalid") + .to_string(), + message: outcome["error"]["message"] + .as_str() + .unwrap_or("rejected") + .to_string(), + path: outcome["error"]["field"] + .as_str() + .map(|field| field.to_string()), + }); + } + Ok(outcome["plugin"].clone()) +} + +/// Host-scoped validation used by the scan path. +pub(crate) fn validate_manifest(manifest: &Value) -> Result { + validate_manifest_with(manifest, Some(super::host_capabilities())) +} + +/// Test entry point for the per-package scan path. +#[cfg(test)] +pub(crate) fn scan_package_for_tests(package: super::store::DiscoveredPackage) -> ScannedPackage { + scan_package(package) +} diff --git a/windows/tauri/src-tauri/src/plugin_host/settings.rs b/windows/tauri/src-tauri/src/plugin_host/settings.rs new file mode 100644 index 0000000000..94df0f2002 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/settings.rs @@ -0,0 +1,214 @@ +//! Stored user values for declarative plugin settings. +//! +//! Core owns every setting semantic (validation, defaults, layering, effect +//! kinds); this module only persists the two value layers the host owns: the +//! user's global value per plugin, and per-workspace overrides of +//! project-scoped settings (`settings` 2, written to the workspace's +//! `.lithe/plugin-settings.json`, matching the macOS storage locations). + +use serde_json::{Map, Value}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use tauri::{AppHandle, Manager}; + +/// Global values of one plugin, persisted as +/// `app_local_data_dir()/plugin-settings/.json`. +fn global_store_path(app: &AppHandle, plugin_id: &str) -> Result { + Ok(app + .path() + .app_local_data_dir() + .map_err(|error| error.to_string())? + .join("plugin-settings") + .join(format!("{plugin_id}.json"))) +} + +/// Project overrides live in the workspace's `.lithe/plugin-settings.json`. +pub fn project_store_path(workspace_path: &str) -> PathBuf { + PathBuf::from(workspace_path) + .join(".lithe") + .join("plugin-settings.json") +} + +/// Reads a JSON object store; missing files read as empty. Corrupted files +/// surface as errors so a broken write is never silently reset. +fn read_object(path: &Path) -> Result, String> { + let text = match std::fs::read_to_string(path) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Map::new()), + Err(error) => return Err(error.to_string()), + }; + let value: Value = serde_json::from_str(&text) + .map_err(|error| format!("{} is not valid JSON: {error}", path.display()))?; + match value { + Value::Object(map) => Ok(map), + _ => Err(format!("{} is not a JSON object", path.display())), + } +} + +/// Writes a JSON object store atomically via temp file + rename. +fn write_object(path: &Path, map: &Map) -> Result<(), String> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + let temp = path.with_extension("json.tmp"); + std::fs::write( + &temp, + serde_json::to_string_pretty(&Value::Object(map.clone())) + .map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string())?; + std::fs::rename(&temp, path).map_err(|error| error.to_string()) +} + +/// Both stored layers for one plugin. +pub struct SettingLayers { + pub global: BTreeMap, + pub project: BTreeMap, +} + +/// Loads the stored values of one plugin for one workspace. Only entries +/// under the plugin's ID in the project store belong to it. +pub fn load_layers( + app: &AppHandle, + plugin_id: &str, + workspace_path: &str, +) -> Result { + let global: BTreeMap = read_object(&global_store_path(app, plugin_id)?)? + .into_iter() + .collect(); + let project_file = read_object(&project_store_path(workspace_path))?; + let project: BTreeMap = project_file + .get(plugin_id) + .and_then(Value::as_object) + .map(|values| { + values + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect() + }) + .unwrap_or_default(); + Ok(SettingLayers { global, project }) +} + +/// Stores the user's global value of one setting. A value equal to the +/// declared default is forgotten so default changes flow through (macOS +/// rule); `None` erases the stored value. +pub fn store_global( + app: &AppHandle, + plugin_id: &str, + setting_id: &str, + value: Option, +) -> Result<(), String> { + let path = global_store_path(app, plugin_id)?; + let mut store = read_object(&path)?; + match value { + Some(value) => { + store.insert(setting_id.to_string(), value); + } + None => { + store.remove(setting_id); + } + } + write_object(&path, &store) +} + +/// Stores the workspace override of a project-scoped setting, or erases it +/// with `None`. When the last override of a plugin disappears from the +/// project store, the empty plugin entry is removed; an empty store file is +/// deleted entirely. +pub fn store_project( + workspace_path: &str, + plugin_id: &str, + setting_id: &str, + value: Option, +) -> Result<(), String> { + let path = project_store_path(workspace_path); + let mut store = read_object(&path)?; + let entry = store + .entry(plugin_id.to_string()) + .or_insert_with(|| Value::Object(Map::new())); + let values = entry + .as_object_mut() + .ok_or_else(|| format!("{} has a corrupted plugin-settings entry", path.display()))?; + match value { + Some(value) => { + values.insert(setting_id.to_string(), value); + } + None => { + values.remove(setting_id); + } + } + if values.is_empty() { + store.remove(plugin_id); + } + if store.is_empty() { + match std::fs::remove_file(&path) { + Ok(()) => return Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.to_string()), + } + } + write_object(&path, &store) +} + +/// Tauri command: effective values of one plugin's settings. +#[tauri::command] +pub fn declarative_plugins_resolve_settings( + app: AppHandle, + manifest: Value, + workspace_path: String, +) -> Result { + let Some(plugin_id) = manifest["id"].as_str().map(str::to_string) else { + return Err("manifest has no id".to_string()); + }; + let layers = load_layers(&app, &plugin_id, &workspace_path)?; + super::scan::core_execute( + "plugins.declarative.resolveSettings", + serde_json::json!({ + "manifest": manifest, + "settings": layers.global, + "projectSettings": layers.project, + }), + ) +} + +/// Tauri command: stores a setting value in the requested layer. +#[tauri::command] +pub fn declarative_plugins_store_setting( + app: AppHandle, + plugin_id: String, + setting_id: String, + scope: String, + workspace_path: String, + value: Option, +) -> Result<(), String> { + match scope.as_str() { + "global" => store_global(&app, &plugin_id, &setting_id, value), + "project" => store_project(&workspace_path, &plugin_id, &setting_id, value), + other => Err(format!("unknown setting scope {other}")), + } +} + +/// Tauri command: a running language server's `workspaceConfiguration` +/// expanded with the current values, for `immediate` effect changes. +#[tauri::command] +pub fn declarative_plugins_workspace_configuration( + app: AppHandle, + manifest: Value, + language_id: String, + workspace_path: String, +) -> Result { + let Some(plugin_id) = manifest["id"].as_str().map(str::to_string) else { + return Err("manifest has no id".to_string()); + }; + let layers = load_layers(&app, &plugin_id, &workspace_path)?; + super::scan::core_execute( + "plugins.declarative.workspaceConfiguration", + serde_json::json!({ + "manifest": manifest, + "languageID": language_id, + "settings": layers.global, + "projectSettings": layers.project, + }), + ) +} diff --git a/windows/tauri/src-tauri/src/plugin_host/store.rs b/windows/tauri/src-tauri/src/plugin_host/store.rs new file mode 100644 index 0000000000..290ab9075e --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/store.rs @@ -0,0 +1,258 @@ +//! Installed and bundled declarative plugin package locations. +//! +//! Installed packages live under the per-user application data directory, +//! never inside the installation directory: installed app resources are the +//! release baseline and must stay byte-identical for updater deltas. Bundled +//! packages are read-only inputs under the installation's resource directory. + +use serde::{Deserialize, Serialize}; +use std::cmp::Ordering; +use std::path::Path; +use std::path::PathBuf; +use tauri::{AppHandle, Manager}; + +/// Per-user root for installed declarative plugin packages. +pub fn install_root(app: &AppHandle) -> Result { + Ok(app + .path() + .app_local_data_dir() + .map_err(|error| error.to_string())? + .join("plugins")) +} + +/// Read-only root of packages shipped with the application. +pub fn bundled_root(app: &AppHandle) -> Result { + Ok(app + .path() + .resource_dir() + .map_err(|error| error.to_string())? + .join("OfficialPlugins")) +} + +/// Writable per-plugin state root (server caches, workspace state). +pub fn state_root(app: &AppHandle) -> Result { + Ok(app + .path() + .app_local_data_dir() + .map_err(|error| error.to_string())? + .join("plugin-state")) +} + +/// Writable state directory of one plugin's language server. +pub fn plugin_state_directory(app: &AppHandle, plugin_id: &str) -> Result { + // Plugin IDs are reverse-DNS names Core validated before they reach this + // call, so they are safe as a single path component. + state_root(app).map(|root| root.join(plugin_id)) +} + +/// Stable per-workspace subdirectory name inside a plugin's state directory. +pub fn workspace_state_key(workspace_path: &str) -> String { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in workspace_path.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + format!("{hash:016x}") +} + +/// Persistent install record of one installed plugin. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InstallationRecord { + pub schema_version: u32, + pub plugin_id: String, + /// Active version directory name under the plugin directory. + pub version: String, + #[serde(default = "default_enabled")] + pub enabled: bool, +} + +fn default_enabled() -> bool { + true +} + +/// One package directory candidate found on disk. +pub struct DiscoveredPackage { + /// Directory that directly contains `plugin.json`. + pub root: PathBuf, + pub source: &'static str, + pub enabled: bool, + /// Set when the package was found but its install record is missing or + /// invalid; scan quarantines the package with this reason. + pub record_problem: Option, +} + +/// Enumerates installed packages under `install_root`: +/// `/installation.json` names the active `//` +/// directory. +pub fn discover_installed_in(install_root: &Path) -> Result, String> { + let mut packages = Vec::new(); + let entries = match std::fs::read_dir(install_root) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(packages), + Err(error) => { + return Err(format!( + "{} could not be read: {error}", + install_root.display() + )) + } + }; + for entry in entries.flatten() { + let plugin_directory = entry.path(); + if !plugin_directory.is_dir() { + continue; + } + let quarantined = |problem: String| DiscoveredPackage { + root: plugin_directory.clone(), + source: "installed", + enabled: false, + record_problem: Some(problem), + }; + let record: InstallationRecord = + match read_json_file(&plugin_directory.join("installation.json")) { + Ok(Some(record)) => record, + Ok(None) => { + packages.push(quarantined( + "installation.json is missing; the package was not installed through Lithe" + .to_string(), + )); + continue; + } + Err(error) => { + packages.push(quarantined(format!( + "installation.json could not be read: {error}" + ))); + continue; + } + }; + if record.schema_version != 1 { + packages.push(quarantined(format!( + "unsupported installation record schema {}", + record.schema_version + ))); + continue; + } + let version_directory = plugin_directory.join(&record.version); + if !version_directory.join("plugin.json").is_file() { + packages.push(quarantined(format!( + "recorded version {} has no plugin.json", + record.version + ))); + continue; + } + packages.push(DiscoveredPackage { + root: version_directory, + source: "installed", + enabled: record.enabled, + record_problem: None, + }); + } + Ok(packages) +} + +/// Enumerates bundled packages under `bundled_root`: either +/// `/plugin.json` or `//plugin.json`, where the highest +/// version wins. +pub fn discover_bundled_in(bundled_root: &Path) -> Result, String> { + let mut packages = Vec::new(); + let entries = match std::fs::read_dir(bundled_root) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(packages), + Err(error) => { + return Err(format!( + "{} could not be read: {error}", + bundled_root.display() + )) + } + }; + for entry in entries.flatten() { + let package_directory = entry.path(); + if !package_directory.is_dir() { + continue; + } + if package_directory.join("plugin.json").is_file() { + packages.push(DiscoveredPackage { + root: package_directory, + source: "bundled", + enabled: true, + record_problem: None, + }); + continue; + } + let mut versions: Vec<(String, PathBuf)> = Vec::new(); + if let Ok(version_entries) = std::fs::read_dir(&package_directory) { + for version_entry in version_entries.flatten() { + let version_directory = version_entry.path(); + if version_directory.is_dir() && version_directory.join("plugin.json").is_file() { + versions.push(( + version_directory + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_default(), + version_directory, + )); + } + } + } + let Some((_, best)) = versions + .into_iter() + .max_by(|(left, _), (right, _)| compare_versions(left, right)) + else { + continue; + }; + packages.push(DiscoveredPackage { + root: best, + source: "bundled", + enabled: true, + record_problem: None, + }); + } + Ok(packages) +} + +/// Dot-separated version comparison with numeric-aware chunks, so `1.10` +/// sorts after `1.9` without pulling in a semver dependency. +pub fn compare_versions(left: &str, right: &str) -> Ordering { + let mut left_chunks = split_version_chunks(left); + let mut right_chunks = split_version_chunks(right); + loop { + match (left_chunks.next(), right_chunks.next()) { + (None, None) => return Ordering::Equal, + (None, Some(_)) => return Ordering::Less, + // A non-numeric tail is a pre-release suffix (`1.0.0-rc1`), which + // sorts before the plain release. + (Some(remaining), None) => { + return if remaining.1.parse::().is_err() { + Ordering::Less + } else { + Ordering::Greater + }; + } + (Some(left_chunk), Some(right_chunk)) => { + let ordering = left_chunk.cmp(&right_chunk); + if ordering != Ordering::Equal { + return ordering; + } + } + } + } +} + +fn split_version_chunks(version: &str) -> impl Iterator { + version + .split(['.', '-', '+']) + .map(|chunk| (chunk.parse::().unwrap_or(0), chunk)) +} + +/// Reads a JSON file; `Ok(None)` marks a missing file so callers can +/// distinguish "absent" from "present but unreadable". +fn read_json_file Deserialize<'de>>(path: &Path) -> Result, String> { + let text = match std::fs::read_to_string(path) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.to_string()), + }; + serde_json::from_str(&text) + .map(Some) + .map_err(|error| error.to_string()) +} diff --git a/windows/tauri/src-tauri/src/plugin_host/tests.rs b/windows/tauri/src-tauri/src/plugin_host/tests.rs new file mode 100644 index 0000000000..2d5aee99c4 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/tests.rs @@ -0,0 +1,543 @@ +//! Shared-fixture acceptance and store-layout tests for the plugin host. +//! +//! The fixture tests are the Windows half of the contract acceptance recorded +//! in the declarative-plugin note: every host must reject the same invalid +//! manifests with the same stable codes, and the host capability declaration +//! must gate level 3 sections this host does not implement yet. + +use super::scan::{self, build_catalog_from}; +use super::store::{self, DiscoveredPackage}; +use serde_json::{json, Value}; +use std::path::PathBuf; + +const FIXTURES_DIR: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../shared/contracts/plugins/v1/fixtures" +); + +/// Valid manifests that this host's capability declaration accepts. +const ACCEPTED_FIXTURES: [&str; 6] = [ + "language-system-command.plugin.json", + "hello-lithe.plugin.json", + "language-host-integration.plugin.json", + "node-runtime.plugin.json", + "language-packaged-server.plugin.json", + "web-panel.plugin.json", +]; + +/// Valid manifests whose capabilities exceed the host declaration; Core +/// rejects them through the capability gate, not through manifest semantics. +const CAPABILITY_GATED_FIXTURES: [&str; 5] = [ + "build-static-file.plugin.json", + "build-command-stdout.plugin.json", + "build-command-output-file.plugin.json", + "build-provider.plugin.json", + "build-host-integration.plugin.json", +]; + +fn fixture_path(name: &str) -> PathBuf { + PathBuf::from(FIXTURES_DIR).join(name) +} + +fn read_fixture(name: &str) -> Value { + let text = std::fs::read_to_string(fixture_path(name)) + .unwrap_or_else(|error| panic!("fixture {name} could not be read: {error}")); + serde_json::from_str(&text) + .unwrap_or_else(|error| panic!("fixture {name} is not JSON: {error}")) +} + +/// Applies one JSON-Patch-style fixture edit; mirrors the Core test helper so +/// both platforms exercise identical case material. +fn apply_edit(manifest: &mut Value, edit: &Value) { + let path = edit["path"].as_str().expect("edit path"); + let (parent_path, key) = path.rsplit_once('/').expect("edit path separator"); + let parent = manifest + .pointer_mut(parent_path) + .unwrap_or_else(|| panic!("missing edit parent {parent_path}")); + match edit["op"].as_str().expect("edit op") { + "set" => match parent { + Value::Object(object) => { + object.insert(key.to_string(), edit["value"].clone()); + } + Value::Array(array) => { + array[key.parse::().expect("array index")] = edit["value"].clone() + } + _ => panic!("edit parent {parent_path} is not a container"), + }, + "remove" => { + parent + .as_object_mut() + .expect("remove parent is an object") + .remove(key); + } + other => panic!("unknown edit op {other}"), + } +} + +/// Contract parity: without host capabilities the Windows binary must reject +/// every invalid fixture with the exact code the shared fixture names. +#[test] +fn invalid_manifest_fixtures_keep_their_stable_codes() { + let fixtures: Value = serde_json::from_str( + &std::fs::read_to_string(fixture_path("invalid-manifests.json")) + .expect("invalid-manifests.json is readable"), + ) + .expect("invalid-manifests.json is JSON"); + let cases = fixtures["cases"].as_array().expect("cases array"); + assert!(!cases.is_empty(), "fixture case list is not empty"); + for case in cases { + let base = case["base"].as_str().expect("case base"); + let mut manifest = read_fixture(base); + for edit in case["edits"].as_array().expect("case edits") { + apply_edit(&mut manifest, edit); + } + let outcome = scan::validate_manifest_with(&manifest, None); + let expected = case["expectedCode"].as_str().expect("expected code"); + match outcome { + Err(rejection) => assert_eq!( + rejection.code, expected, + "case {:?} (base {base}) rejected with {} instead", + case["name"], rejection.code + ), + Ok(_) => panic!( + "case {:?} (base {base}) was accepted but must reject with {expected}", + case["name"] + ), + } + } +} + +/// Every valid fixture passes contract validation without host capabilities. +#[test] +fn valid_fixtures_validate_without_host_capabilities() { + let all_fixtures: Vec<&str> = ACCEPTED_FIXTURES + .iter() + .copied() + .chain(CAPABILITY_GATED_FIXTURES.iter().copied()) + .chain(std::iter::once("settings-v2.plugin.json")) + .collect(); + for name in all_fixtures { + let outcome = scan::validate_manifest_with(&read_fixture(name), None); + assert!(outcome.is_ok(), "{name} must validate: {outcome:?}"); + } +} + +/// The v0 host capability declaration accepts the fixtures it can run and +/// gates the rest with `plugin.api.unsupported` naming the missing capability. +#[test] +fn host_v0_capabilities_gate_unimplemented_sections() { + for name in ACCEPTED_FIXTURES { + let outcome = + scan::validate_manifest_with(&read_fixture(name), Some(super::host_capabilities())); + assert!( + outcome.is_ok(), + "{name} must be accepted by the host: {outcome:?}" + ); + } + for name in CAPABILITY_GATED_FIXTURES { + let outcome = + scan::validate_manifest_with(&read_fixture(name), Some(super::host_capabilities())); + let rejection = outcome.expect_err(&format!("{name} must be capability-gated")); + assert_eq!(rejection.code, "plugin.api.unsupported", "{name}"); + assert!(rejection + .path + .as_deref() + .unwrap_or_default() + .starts_with("requires.")); + } + // settings-v2 requires settings 2 and languageServers 2, both undeclared. + let outcome = scan::validate_manifest_with( + &read_fixture("settings-v2.plugin.json"), + Some(super::host_capabilities()), + ); + assert_eq!( + outcome.expect_err("settings-v2 is gated").code, + "plugin.api.unsupported" + ); +} + +/// A scanned package directory produces an accepted entry with normalized +/// languages, and a broken manifest is quarantined instead of registered. +#[test] +fn scan_accepts_valid_packages_and_quarantines_broken_ones() { + let root = unique_temp_root("scan"); + let package_root = root.join("acme.php"); + std::fs::create_dir_all(&package_root).expect("package dir"); + let manifest = read_fixture("language-packaged-server.plugin.json"); + std::fs::write( + package_root.join("plugin.json"), + serde_json::to_string_pretty(&manifest).expect("manifest text"), + ) + .expect("manifest file"); + + let accepted = scan_package_at(&package_root, None); + assert_eq!(accepted.entry.status, "accepted"); + assert_eq!(accepted.entry.plugin_id, manifest["id"]); + assert_eq!(accepted.entry.languages.len(), 1); + assert_eq!( + accepted.entry.languages[0].file_extensions, + vec!["php", "phtml"] + ); + + std::fs::write(package_root.join("plugin.json"), "{ not json").expect("broken manifest"); + let broken = scan_package_at(&package_root, None); + assert_eq!(broken.entry.status, "rejected"); + assert_eq!( + broken.entry.rejection.as_ref().expect("rejection").code, + "plugin.manifest.invalid" + ); + + let _ = std::fs::remove_dir_all(&root); +} + +/// Missing or invalid installation records quarantine the package instead of +/// loading it, even when its manifest is valid. +#[test] +fn store_problems_quarantine_packages_with_valid_manifests() { + let root = unique_temp_root("store"); + let plugin_directory = root.join("acme.php"); + let version_directory = plugin_directory.join("1.0.0"); + std::fs::create_dir_all(&version_directory).expect("version dir"); + std::fs::write( + version_directory.join("plugin.json"), + serde_json::to_string(&read_fixture("language-packaged-server.plugin.json")) + .expect("manifest"), + ) + .expect("manifest file"); + + // No installation.json: the package was never installed through Lithe. + let discovered = store::discover_installed_in(&root).expect("discovery works"); + assert_eq!(discovered.len(), 1); + assert!(discovered[0].record_problem.is_some()); + + // A record naming a missing version is a store problem too. + std::fs::write( + plugin_directory.join("installation.json"), + json!({"schemaVersion": 1, "pluginId": "acme.php", "version": "9.9.9"}).to_string(), + ) + .expect("record file"); + let discovered = store::discover_installed_in(&root).expect("discovery works"); + assert!(discovered[0].record_problem.is_some()); + + // Correct record: discovered at the recorded version, enabled by default. + std::fs::write( + plugin_directory.join("installation.json"), + json!({"schemaVersion": 1, "pluginId": "acme.php", "version": "1.0.0"}).to_string(), + ) + .expect("record file"); + let discovered = store::discover_installed_in(&root).expect("discovery works"); + assert!(discovered[0].record_problem.is_none()); + assert_eq!(discovered[0].root, version_directory); + assert!(discovered[0].enabled); + + let _ = std::fs::remove_dir_all(&root); +} + +/// Bundled discovery prefers the highest version directory of a package. +#[test] +fn bundled_discovery_picks_the_highest_version() { + let root = unique_temp_root("bundled"); + for version in ["1.0.0", "1.10.0", "1.9.0"] { + let directory = root.join("acme.php").join(version); + std::fs::create_dir_all(&directory).expect("version dir"); + std::fs::write(directory.join("plugin.json"), "{}").expect("manifest file"); + } + let discovered = store::discover_bundled_in(&root).expect("discovery works"); + assert_eq!(discovered.len(), 1); + assert_eq!( + discovered[0].root.file_name().unwrap().to_string_lossy(), + "1.10.0" + ); + assert_eq!(discovered[0].source, "bundled"); + let _ = std::fs::remove_dir_all(&root); +} + +/// The full catalog build accepts recorded packages, quarantines store +/// problems, and keeps bundled packages enabled. +#[test] +fn catalog_build_combines_bundled_and_installed_sources() { + let temporary = unique_temp_root("catalog"); + let install_root = temporary.join("plugins"); + let bundled_root = temporary.join("OfficialPlugins"); + let installed_version = install_root.join("acme.php").join("1.0.0"); + std::fs::create_dir_all(&installed_version).expect("installed dir"); + std::fs::write( + installed_version.join("plugin.json"), + serde_json::to_string(&read_fixture("language-packaged-server.plugin.json")) + .expect("manifest"), + ) + .expect("manifest file"); + std::fs::write( + install_root.join("acme.php").join("installation.json"), + json!({"schemaVersion": 1, "pluginId": "acme.php", "version": "1.0.0", "enabled": false}) + .to_string(), + ) + .expect("record file"); + let bundled_directory = bundled_root.join("acme.go").join("2.0.0"); + std::fs::create_dir_all(&bundled_directory).expect("bundled dir"); + std::fs::write( + bundled_directory.join("plugin.json"), + serde_json::to_string(&read_fixture("language-system-command.plugin.json")) + .expect("manifest"), + ) + .expect("manifest file"); + + let catalog = build_catalog_from(&bundled_root, &install_root).expect("catalog builds"); + assert_eq!(catalog.len(), 2); + let installed = catalog + .iter() + .find(|package| package.entry.source == "installed") + .expect("installed entry"); + assert_eq!(installed.entry.status, "accepted"); + assert!(!installed.entry.enabled, "recorded enabled: false wins"); + let bundled = catalog + .iter() + .find(|package| package.entry.source == "bundled") + .expect("bundled entry"); + assert_eq!( + bundled.entry.plugin_id, + "dev.lithe.fixture.language-system-command" + ); + assert!(bundled.entry.enabled); + + let _ = std::fs::remove_dir_all(&temporary); +} + +fn unique_temp_root(label: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "lithe-plugin-host-{label}-{}-{nanos}", + std::process::id() + )); + std::fs::create_dir_all(&root).expect("temp root"); + root +} + +/// Scans one package directory through the production scan path. +fn scan_package_at( + root: &std::path::Path, + record_problem: Option, +) -> super::ScannedPackage { + super::scan::scan_package_for_tests(DiscoveredPackage { + root: root.to_path_buf(), + source: "installed", + enabled: true, + record_problem, + }) +} + +/// Fixture reader shared with the install test module. +pub(crate) fn read_fixture_for_tests(name: &str) -> serde_json::Value { + read_fixture(name) +} + +/// M3 setting-store tests exercise the two persistence layers directly: the +/// global per-plugin file and the workspace's `.lithe/plugin-settings.json`. +mod settings_tests { + use crate::plugin_host::settings::{project_store_path, store_global, store_project}; + + fn unique_temp_root(label: &str) -> std::path::PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "lithe-plugin-settings-{label}-{}-{nanos}", + std::process::id() + )); + std::fs::create_dir_all(&root).expect("temp root"); + root + } + + #[test] + fn project_overrides_round_trip_and_clean_up_empty_entries() { + let workspace = unique_temp_root("project"); + let workspace_path = workspace.to_string_lossy().to_string(); + + store_project( + &workspace_path, + "acme.php", + "heapMaxMB", + Some(serde_json::json!(4096)), + ) + .expect("stores override"); + let store = + std::fs::read_to_string(project_store_path(&workspace_path)).expect("store file"); + let parsed: serde_json::Value = serde_json::from_str(&store).expect("store JSON"); + assert_eq!(parsed["acme.php"]["heapMaxMB"], 4096); + + // Erasing the last override removes the plugin entry, then the file. + store_project(&workspace_path, "acme.php", "heapMaxMB", None).expect("erases override"); + assert!(!project_store_path(&workspace_path).exists()); + let _ = std::fs::remove_dir_all(&workspace); + } + + #[test] + fn project_store_keeps_other_plugins_entries() { + let workspace = unique_temp_root("project-multi"); + let workspace_path = workspace.to_string_lossy().to_string(); + store_project(&workspace_path, "acme.php", "a", Some(serde_json::json!(1))) + .expect("stores a"); + store_project( + &workspace_path, + "other.plugin", + "b", + Some(serde_json::json!(2)), + ) + .expect("stores b"); + store_project(&workspace_path, "acme.php", "a", None).expect("erases a"); + + let store: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(project_store_path(&workspace_path)).expect("store"), + ) + .expect("store JSON"); + assert!(store["acme.php"].is_null()); + assert_eq!(store["other.plugin"]["b"], 2); + let _ = std::fs::remove_dir_all(&workspace); + } + + #[test] + fn corrupted_project_store_is_an_error_not_a_reset() { + let workspace = unique_temp_root("project-corrupt"); + let path = project_store_path(&workspace.to_string_lossy()); + std::fs::create_dir_all(path.parent().unwrap()).expect(".lithe dir"); + std::fs::write(&path, "{ not json").expect("corrupt store"); + + assert!(store_project( + &workspace.to_string_lossy(), + "acme.php", + "a", + Some(serde_json::json!(1)) + ) + .is_err()); + // The corrupted bytes are preserved for diagnosis. + assert_eq!(std::fs::read_to_string(&path).expect("store"), "{ not json"); + let _ = std::fs::remove_dir_all(&workspace); + } + + // store_global needs an AppHandle; its logic is covered through the + // project variant (same read/write helpers) and the live command test. + #[allow(unused_imports)] + fn witness() { + let _ = store_global; + } +} + +/// The bundled PHP contract package validates, expands run and test plans, +/// and exposes settings resolution through the real Core commands — the +/// end-to-end acceptance of the contract package shipped with the app. +#[test] +fn bundled_php_contract_package_expands_plans() { + let text = std::fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../Plugins/win/Official/PhpSupportContract/plugin.json" + )) + .expect("contract package manifest is readable"); + let manifest: serde_json::Value = serde_json::from_str(&text).expect("manifest is JSON"); + + let validated = scan::validate_manifest_with(&manifest, Some(super::host_capabilities())); + let plugin = validated.expect("contract package is accepted by the host"); + assert_eq!(plugin["id"], "dev.lithe.plugin.php-support"); + assert_eq!(plugin["pluginAPIVersion"], 2); + + // Run plan expansion. + let run = scan::core_execute( + "plugins.declarative.runPlan", + serde_json::json!({ + "manifest": manifest, + "languageID": "php", + "request": { + "relativeFilePath": "src/Item.php", + "arguments": ["--verbose"] + }, + }), + ) + .expect("run plan executes"); + assert_eq!(run["status"], "ok", "{run}"); + assert_eq!(run["plan"]["executable"]["command"], "php"); + assert_eq!( + run["plan"]["arguments"], + serde_json::json!(["src/Item.php", "--verbose"]) + ); + + // Workspace test plan expansion. + let test = scan::core_execute( + "plugins.declarative.testPlan", + serde_json::json!({ + "manifest": manifest, + "languageID": "php", + "request": { + "scope": { "kind": "workspace" }, + "relativeProjectFilePaths": ["composer.json", "tests/ItemTest.php"], + }, + }), + ) + .expect("test plan executes"); + assert_eq!(test["status"], "ok", "{test}"); + assert_eq!( + test["plan"]["arguments"], + serde_json::json!(["vendor/bin/phpunit"]) + ); + + // Settings resolution with an empty store (defaults). + let settings = scan::core_execute( + "plugins.declarative.resolveSettings", + serde_json::json!({ + "manifest": manifest, + "settings": {}, + "projectSettings": {}, + }), + ) + .expect("resolve settings executes"); + assert_eq!(settings["status"], "ok", "{settings}"); +} + +/// A scan owns discovery and publication together, so the second discover +/// cannot snapshot the old catalog and overwrite a newer published result. +#[test] +fn concurrent_catalog_refreshes_publish_in_discovery_order() { + use std::sync::{mpsc, Arc}; + use std::time::Duration; + let state = Arc::new(super::PluginHostState::default()); + let (entered_tx, entered_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let (done_tx, done_rx) = mpsc::channel(); + std::thread::scope(|scope| { + let first = state.clone(); + let done = done_tx.clone(); + scope.spawn(move || { + first + .refresh(|| { + entered_tx.send(()).unwrap(); + release_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + Ok(Vec::new()) + }) + .unwrap(); + done.send(()).unwrap(); + }); + entered_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + assert!(state.scan_lock.try_lock().is_err()); + let second = state.clone(); + scope.spawn(move || { + second + .refresh(|| { + // This closure runs only after the first publication. + assert!(second.scan_lock.try_lock().is_err()); + Ok(vec![scan_package_at( + &PathBuf::from("catalog-new.plugin"), + None, + )]) + }) + .unwrap(); + done_tx.send(()).unwrap(); + }); + release_tx.send(()).unwrap(); + done_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + done_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + }); + assert_eq!(state.packages().len(), 1); + assert_eq!(state.packages()[0].entry.plugin_id, "catalog-new.plugin"); +} diff --git a/windows/tauri/src-tauri/src/plugin_host/web.rs b/windows/tauri/src-tauri/src/plugin_host/web.rs new file mode 100644 index 0000000000..905f791205 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/web.rs @@ -0,0 +1,1836 @@ +//! Web tool windows (ui capability 2): package pages in a WebView2 shell. +//! +//! The contract rules (shared/contracts/plugins/v1/README.md, "Web content +//! (ui 2)") put the security boundary in the file service: only files of the +//! owning installed package are served, read-only, from a plugin-private +//! origin, with a CSP that refuses network access, remote resources, frames, +//! inline scripts and eval. The window shell (dock, title, order, lifecycle) +//! is host-owned; the page fills the body only. Build-system bridge methods +//! wait for the Windows build-system plugin capability and are not part of +//! this module. + +use super::scan::ScannedPackage; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, HashMap}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder}; + +#[cfg(windows)] +#[path = "web_network.rs"] +mod network; + +/// Largest package file the scheme serves; a page cannot map the whole +/// package into memory through one request. +const MAX_PAGE_FILE_BYTES: u64 = 16 * 1024 * 1024; + +/// CSP applied to every page response. `wasm-unsafe-eval` is absent: pages +/// compile no WebAssembly. `tauri:` IPC stays available because the bridge +/// runs over it; every other source is refused. +const PAGE_CSP: &str = "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src ipc: http://ipc.localhost; frame-ancestors 'none'; form-action 'none'; base-uri 'none'"; + +/// Per-window page state (`ui.getState` / `ui.setState`), kept in the host so +/// web storage can stay non-persistent. Bounded per contract. +const MAX_PAGE_STATE_BYTES: usize = 64 * 1024; +/// Reserved same-origin host script, served before any package-authored code. +const HOST_BRIDGE_PATH: &str = "__lithe_host__/bridge.js"; + +#[derive(Default)] +pub struct WebPageHostState { + states: Mutex>, + owners: Mutex>, + workspace_generations: Mutex>, + open_files: Mutex>, + appearances: Mutex>, +} + +struct PendingOpenFile { + host_label: String, + page_label: String, + completion: tokio::sync::oneshot::Sender>, +} + +#[derive(Clone, Debug, PartialEq)] +struct PageOwner { + generation: u64, + workspace_generation: u64, + host_label: String, + workspace_id: String, + workspace_path: String, + plugin_id: String, + window_id: String, + state_id: String, +} + +static OPEN_PAGE_SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + +fn workspace_generation(state: &WebPageHostState, host: &str, workspace: &str) -> u64 { + state + .workspace_generations + .lock() + .expect("workspace page generations") + .get(&workspace_label_suffix(host, workspace)) + .copied() + .unwrap_or(0) +} + +/// Effects and revocation share the owners lock. A request cloned before +/// close cannot recreate state or register an event after authority is gone. +fn with_page_owner( + state: &WebPageHostState, + label: &str, + identity: &BridgeIdentity, + effect: impl FnOnce() -> Result, +) -> Result { + let owners = state.owners.lock().expect("page owners"); + if !owners.get(label).is_some_and(|owner| { + owner.generation == identity.generation + && owner.workspace_generation + == workspace_generation(state, &owner.host_label, &owner.workspace_id) + }) { + return Err(bridge_error( + "plugin.web.forbidden", + "page ownership was revoked", + )); + } + effect() +} + +fn revoke_owners( + state: &WebPageHostState, + matches: impl Fn(&PageOwner) -> bool, + clear_state: bool, +) -> Vec { + let mut owners = state.owners.lock().expect("page owners"); + let labels: Vec<_> = owners + .iter() + .filter(|(_, owner)| matches(owner)) + .map(|(label, _)| label.clone()) + .collect(); + owners.retain(|label, _| !labels.contains(label)); + state + .open_files + .lock() + .expect("open file requests") + .retain(|_, request| !labels.contains(&request.page_label)); + state + .appearances + .lock() + .expect("page appearances") + .retain(|label, _| !labels.contains(label)); + if clear_state { + state + .states + .lock() + .expect("page state") + .retain(|key, _| !labels.iter().any(|label| key.ends_with(label))); + } + labels +} + +fn workspace_label_suffix(host_label: &str, workspace_id: &str) -> String { + format!( + "--h{}--s{}", + hex_label_part(host_label), + hex_label_part(workspace_id) + ) +} + +fn owned_label(owner: &PageOwner) -> String { + format!( + "plugin-web-{}{}", + web_window_label(&owner.plugin_id, &owner.window_id), + workspace_label_suffix(&owner.host_label, &owner.workspace_id) + ) +} + +/// Resolved workbench locale and theme tokens; supplied only by host windows. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WebPageAppearance { + pub locale: String, + pub theme: String, + pub variables: BTreeMap, +} + +/// Checks the real document origin, including WebView2's custom-scheme mapping. +/// Navigation and IPC share this predicate so a window label alone grants nothing. +fn is_package_url(url: &url::Url, plugin_id: &str) -> bool { + if !url.username().is_empty() || url.password().is_some() || url.port().is_some() { + return false; + } + let private_origin = url.scheme() == "lithe-plugin" && url.host_str() == Some(plugin_id); + let mapped_origin = cfg!(windows) + && url.scheme() == "http" + && url.host_str() == Some(format!("lithe-plugin.{plugin_id}").as_str()); + (private_origin || mapped_origin) && sanitize_package_path(url.path()).is_ok() +} + +fn require_package_url(url: &url::Url, plugin_id: &str) -> Result<(), String> { + if is_package_url(url, plugin_id) { + Ok(()) + } else { + Err(bridge_error( + "plugin.web.forbidden", + "the document is outside its plugin package", + )) + } +} + +/// State key of one page: plugin ID and tool window ID. +fn state_key(plugin_id: &str, window_id: &str) -> String { + format!("{plugin_id}\u{0}{window_id}") +} + +/// One declared web tool window of a scanned package. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WebToolWindow { + pub window_id: String, + pub plugin_id: String, + pub title: String, + pub placement: String, + pub order: Option, + /// Package-relative entry HTML path. + pub entry: String, +} + +/// Declared web tool windows of all accepted, enabled packages. +#[tauri::command] +pub fn declarative_plugins_web_windows(app: AppHandle) -> Vec { + let mut windows = Vec::new(); + for package in app.state::().packages() { + if package.entry.status != "accepted" + || !package.entry.enabled + || app + .state::() + .blocked(&package.entry.plugin_id) + { + continue; + } + collect_web_windows(&package, &mut windows); + } + windows.sort_by(|left, right| { + (left.order.unwrap_or(u32::MAX), left.window_id.as_str()) + .cmp(&(right.order.unwrap_or(u32::MAX), right.window_id.as_str())) + }); + windows +} + +fn collect_web_windows(package: &ScannedPackage, windows: &mut Vec) { + let Some(tool_windows) = package.manifest["ui"]["toolWindows"].as_array() else { + return; + }; + for window in tool_windows { + let entry = match window["web"]["entry"].as_str() { + Some(entry) => entry.to_string(), + None if window["text"].is_string() => format!( + "__lithe_host__/text/{}.html", + hex_label_part(window["id"].as_str().unwrap_or_default()) + ), + _ => continue, + }; + windows.push(WebToolWindow { + window_id: window["id"].as_str().unwrap_or_default().to_string(), + plugin_id: package.entry.plugin_id.clone(), + title: window["title"].as_str().unwrap_or_default().to_string(), + placement: window["placement"].as_str().unwrap_or("bottom").to_string(), + order: window["order"].as_u64().map(|order| order as u32), + entry, + }); + } +} + +/// Opens (or focuses) the web view of one tool window. The URL is the +/// plugin-private scheme origin with the declared entry path. +#[tauri::command] +pub async fn declarative_plugins_open_web_window( + app: AppHandle, + caller: tauri::WebviewWindow, + plugin_id: String, + window_id: String, + workspace_id: String, + workspace_path: String, + appearance: WebPageAppearance, +) -> Result<(), String> { + if workspace_id.is_empty() + || workspace_path.is_empty() + || app + .state::() + .blocked(&plugin_id) + { + return Err("plugin page requires an active workspace and package".into()); + } + // Register the whole asynchronous creation, not just resource reads. + // Mutation waits for this lease before collecting and closing owners. + let _native_page = app + .state::() + .acquire(&plugin_id)?; + let owner = PageOwner { + generation: OPEN_PAGE_SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + workspace_generation: workspace_generation( + &app.state::(), + caller.label(), + &workspace_id, + ), + host_label: caller.label().into(), + workspace_id, + workspace_path, + plugin_id: plugin_id.clone(), + window_id: window_id.clone(), + state_id: String::new(), + }; + let label = owned_label(&owner); + let owner = PageOwner { + state_id: label.clone(), + ..owner + }; + let Some(window) = declarative_plugins_web_windows(app.clone()) + .into_iter() + .find(|window| window.plugin_id == plugin_id && window.window_id == window_id) + else { + return Err(format!( + "{plugin_id}/{window_id} is not a declared web tool window" + )); + }; + app.state::() + .owners + .lock() + .expect("page owners") + .insert(label.clone(), owner); + app.state::() + .appearances + .lock() + .expect("appearance mutex") + .insert(label.clone(), appearance.clone()); + if let Some(existing) = app.get_webview_window(&label) { + let _ = existing.set_focus(); + return Ok(()); + } + let target_url = package_page_url(&window.plugin_id, &window.entry)?; + let request_plugin_id = window.plugin_id.clone(); + let plugin_id = window.plugin_id.clone(); + // Start inert. The request filter must be installed before package code + // can navigate or otherwise cause an outbound request. + let view = WebviewWindowBuilder::new( + &app, + &label, + WebviewUrl::External("about:blank".parse().expect("blank URL")), + ) + .title(window.title) + .incognito(true) + .on_navigation(move |url| is_package_url(url, &plugin_id)) + .on_new_window(|_, _| tauri::webview::NewWindowResponse::Deny) + .on_download(|_, _| false) + .build() + .map_err(|error| format!("web window could not open: {error}"))?; + let setup: Result<(), String> = async { + #[cfg(windows)] + network::restrict_requests(&view, request_plugin_id).await?; + bridge_identity(&app, &label)?; + view.navigate(target_url).map_err(|error| error.to_string()) + } + .await; + if let Err(error) = setup { + forget_window_appearance(&app, &label); + if let Err(close_error) = view.close() { + return Err(format!( + "{error}; failed to close the inactive page: {close_error}" + )); + } + return Err(error); + } + Ok(()) +} + +fn package_page_url(plugin_id: &str, entry: &str) -> Result { + // Wry maps custom schemes only at creation. A later navigate() reaches + // WebView2 directly, so it must use the same mapped origin explicitly. + #[cfg(windows)] + let url = format!("http://lithe-plugin.{plugin_id}/{entry}"); + #[cfg(not(windows))] + let url = format!("lithe-plugin://{plugin_id}/{entry}"); + url.parse() + .map_err(|error: url::ParseError| error.to_string()) +} + +fn appearance_script(appearance: &WebPageAppearance) -> String { + format!( + "window.__litheDeliverEvent?.('ui.themeChanged', {});", + json!(appearance) + ) +} + +/// Applies a workbench change to existing pages and their next reload. +#[tauri::command] +pub fn declarative_plugins_web_update_appearance( + app: AppHandle, + caller: tauri::WebviewWindow, + appearance: WebPageAppearance, +) -> Result<(), String> { + let windows: Vec<_> = app + .webview_windows() + .into_iter() + .filter(|(label, _)| { + app.state::() + .owners + .lock() + .expect("page owners") + .get(label) + .is_some_and(|owner| owner.host_label == caller.label()) + }) + .collect(); + { + let state = app.state::(); + let mut appearances = state.appearances.lock().expect("appearance mutex"); + for (label, _) in &windows { + appearances.insert(label.clone(), appearance.clone()); + } + } + let mut failures = Vec::new(); + for (label, window) in windows { + if let Err(error) = window.eval(appearance_script(&appearance)) { + failures.push(format!("{label}: {error}")); + } + } + if failures.is_empty() { + Ok(()) + } else { + Err(failures.join("; ")) + } +} + +/// Native window destruction releases its cached appearance, including X-close. +pub fn forget_window_appearance(app: &AppHandle, label: &str) { + let state = app.state::(); + let children = revoke_owners( + &state, + |owner| owner.host_label == label || owned_label(owner) == label, + !label.starts_with("plugin-web-"), + ); + let host_prefix = format!("--h{}--s", hex_label_part(label)); + state + .states + .lock() + .expect("page state") + .retain(|key, _| !key.contains(&host_prefix)); + state + .workspace_generations + .lock() + .expect("workspace page generations") + .retain(|scope, _| !scope.starts_with(&host_prefix)); + for child in children { + if child != label { + if let Some(view) = app.get_webview_window(&child) { + let _ = view.close(); + } + } + } +} + +/// Destroys the web view of one tool window; used on plugin disable, +/// uninstall, and window teardown. +#[tauri::command] +pub fn declarative_plugins_close_web_window( + app: AppHandle, + caller: tauri::WebviewWindow, + workspace_id: String, + plugin_id: String, + window_id: String, +) -> Result<(), String> { + let label = owned_label(&PageOwner { + generation: 0, + workspace_generation: 0, + host_label: caller.label().into(), + workspace_id, + workspace_path: String::new(), + plugin_id, + window_id, + state_id: String::new(), + }); + if let Some(existing) = app.get_webview_window(&label) { + existing + .close() + .map_err(|error| format!("web window could not close: {error}"))?; + } + Ok(()) +} + +/// `ui.getState`: the host-stored state of one page, or `null`. +#[tauri::command] +pub fn declarative_plugins_web_get_state( + app: AppHandle, + plugin_id: String, + window_id: String, +) -> Value { + app.state::() + .states + .lock() + .expect("web page state mutex") + .get(&state_key(&plugin_id, &window_id)) + .cloned() + .unwrap_or(Value::Null) +} + +/// `ui.setState`: stores page state; over the contract's 64 KiB budget the +/// call fails with the bridge error shape instead of truncating. +#[tauri::command] +pub fn declarative_plugins_web_set_state( + app: AppHandle, + plugin_id: String, + window_id: String, + state: Value, +) -> Result<(), String> { + let serialized = serde_json::to_string(&state).map_err(|error| error.to_string())?; + if serialized.len() > MAX_PAGE_STATE_BYTES { + return Err( + json!({ "code": "plugin.web.invalidRequest", "message": "state exceeds 64 KiB" }) + .to_string(), + ); + } + app.state::() + .states + .lock() + .expect("web page state mutex") + .insert(state_key(&plugin_id, &window_id), state); + Ok(()) +} + +/// Drops state of one plugin (disable/uninstall). +pub fn forget_plugin_state(app: &AppHandle, plugin_id: &str) { + let states = app.state::(); + let mut guard = states.states.lock().expect("web page state mutex"); + let prefix = format!("{plugin_id}\u{0}"); + guard.retain(|key, _| !key.starts_with(&prefix)); +} + +/// The first parser-blocking script defines the frozen `window.lithe` bridge. +/// v0 methods: context, request (state + openFile), on; build.* and +/// functions.* resolve to `plugin.web.unknownMethod` until their Windows +/// capabilities land. +fn bridge_script(plugin_id: &str, window_id: &str, appearance: &WebPageAppearance) -> String { + let context = json!({ + "pluginID": plugin_id, + "windowID": window_id, + "platform": "windows", + "locale": appearance.locale, + }); + const_bridge_script() + .replace("__LITHE_CONTEXT__", &context.to_string()) + .replace("__LITHE_APPEARANCE__", &json!(appearance).to_string()) + .replace("__LITHE_PLUGIN__", &json!(plugin_id).to_string()) + .replace("__LITHE_WINDOW__", &json!(window_id).to_string()) +} + +/// Bridge bootstrap as a plain string; placeholders are replaced with JSON +/// literals so no brace escaping is involved. +fn const_bridge_script() -> &'static str { + r#"(() => { + "use strict"; + const initial = __LITHE_CONTEXT__; + // Never expose a bridge in another document, even if native navigation + // policy regresses or a page fetches this reserved script explicitly. + const url = new URL(window.location.href); + if (window !== window.top || url.username || url.password || url.port || + !((url.protocol === 'lithe-plugin:' && url.hostname === initial.pluginID) || + (url.protocol === 'http:' && url.hostname === 'lithe-plugin.' + initial.pluginID))) return; + let appearance = __LITHE_APPEARANCE__; + const context = Object.freeze({ ...initial, + get locale() { return appearance.locale; }, + get theme() { return appearance.theme; }, + }); + const applyTheme = () => { + const root = document.documentElement; + if (!root) { document.addEventListener('readystatechange', applyTheme, { once: true }); return; } + root.setAttribute('data-theme', appearance.theme); + root.lang = appearance.locale; + for (const [name, value] of Object.entries(appearance.variables)) root.style.setProperty(name, value); + }; + const listeners = new Map(); + const invoke = (method, params) => window.__TAURI_INTERNALS__.invoke( + "plugin:declarative-web|request", + { method, params }, + ); + const bridge = { + context, + request(method, params) { + if (typeof method !== "string" || !method) { + return Promise.reject({ code: "plugin.web.invalidRequest", message: "method must be a non-empty string" }); + } + return invoke(method, params ?? null); + }, + on(event, listener) { + if (typeof event !== "string" || typeof listener !== "function") { + throw new TypeError("on(event, listener) requires a string and a function"); + } + const set = listeners.get(event) ?? new Set(); + set.add(listener); + listeners.set(event, set); + return () => set.delete(listener); + }, + }; + Object.freeze(bridge); + Object.defineProperty(window, "lithe", { value: Object.freeze(bridge), configurable: false, writable: false }); + window.__litheDeliverEvent = (event, payload) => { + if (event === 'ui.themeChanged') { + appearance = payload; + applyTheme(); + payload = { theme: appearance.theme }; + } + const set = listeners.get(event); + if (set) for (const listener of [...set]) { + try { listener(payload); } catch { /* a failing listener must not break the pump */ } + } + }; + applyTheme(); +})();"# +} + +/// Minimal Tauri plugin namespace so custom-protocol pages can receive one +/// ACL-granted command without changing the main application's command ACL. +pub fn bridge_plugin() -> tauri::plugin::TauriPlugin { + tauri::plugin::Builder::new("declarative-web") + .invoke_handler(|invoke| { + if invoke.message.command() != "request" { + return false; + } + handle_bridge_request(invoke); + true + }) + .build() +} + +/// The single Tauri entry behind `lithe.request`. The calling page's +/// identity is derived from the invoking webview's label +/// (`plugin-web-`), never from arguments the page supplies; the +/// plugin is then resolved from the accepted catalog and must currently +/// declare this window and be enabled. A page can only reach its own +/// plugin's state and the workspace layer. +/// +/// The inlined plugin's ACL admits only this request, and this handler then +/// enforces document and package identity before dispatching any method. +fn handle_bridge_request(invoke: tauri::ipc::Invoke) { + let webview = invoke.message.webview_ref().clone(); + // The bridge payload arrives as the command's JSON arguments envelope. + let params: BridgeArgs = match invoke.message.payload() { + tauri::ipc::InvokeBody::Json(value) => match serde_json::from_value(value.clone()) { + Ok(args) => args, + Err(error) => { + invoke + .resolver + .respond::(Err(tauri::ipc::InvokeError( + serde_json::json!({ + "code": "plugin.web.invalidRequest", + "message": error.to_string(), + }), + ))); + return; + } + }, + _ => { + invoke + .resolver + .respond::(Err(tauri::ipc::InvokeError( + serde_json::json!({ + "code": "plugin.web.invalidRequest", + "message": "the bridge only accepts JSON payloads", + }), + ))); + return; + } + }; + let app = webview.app_handle().clone(); + tauri::async_runtime::spawn(async move { + let result = bridge_dispatch(&app, &webview, params).await; + match result { + Ok(value) => { + invoke + .resolver + .respond::(Ok(serde_json::to_string(&value) + .map(tauri::ipc::InvokeResponseBody::Json) + .unwrap_or_else(|_| tauri::ipc::InvokeResponseBody::Json("{}".into())))); + } + Err(message) => { + invoke + .resolver + .respond::(Err(tauri::ipc::InvokeError( + // `message` is already the JSON error envelope from `bridge_error`. + serde_json::from_str(&message) + .unwrap_or(serde_json::json!({ "message": message })), + ))); + } + } + }); +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct BridgeArgs { + method: String, + #[serde(default)] + params: Option, +} + +async fn bridge_dispatch( + app: &AppHandle, + webview: &tauri::Webview, + args: BridgeArgs, +) -> Result { + let method = args.method.as_str(); + let params = args.params.unwrap_or(Value::Null); + let identity = bridge_identity(app, webview.label())?; + let url = webview + .url() + .map_err(|error| bridge_error("plugin.web.forbidden", error.to_string()))?; + require_package_url(&url, &identity.plugin_id)?; + match method { + "ui.getState" => with_page_owner( + &app.state::(), + webview.label(), + &identity, + || { + Ok(json!({ + "state": declarative_plugins_web_get_state(app.clone(), identity.plugin_id.clone(), identity.state_id.clone()) + })) + }, + ), + "ui.setState" => { + let state = params.get("state").ok_or_else(|| { + bridge_error("plugin.web.invalidRequest", "setState requires state") + })?; + with_page_owner( + &app.state::(), + webview.label(), + &identity, + || { + declarative_plugins_web_set_state( + app.clone(), + identity.plugin_id.clone(), + identity.state_id.clone(), + state.clone(), + )?; + Ok(json!({})) + }, + ) + } + "workspace.openFile" => { + let Some(path) = params["path"].as_str() else { + return Err(bridge_error( + "plugin.web.invalidRequest", + "openFile requires path", + )); + }; + use tauri::Emitter; + let operation_id = format!( + "{}-{}", + webview.label(), + OPEN_FILE_SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + ); + let (sender, receiver) = tokio::sync::oneshot::channel(); + let pending = OpenFileRequest { + app: app.clone(), + operation_id: operation_id.clone(), + }; + with_page_owner( + &app.state::(), + webview.label(), + &identity, + || { + let state = app.state::(); + let mut requests = state.open_files.lock().expect("open file requests"); + if requests.len() >= 128 { + return Err(bridge_error( + "plugin.web.invalidRequest", + "too many pending file open requests", + )); + } + requests.insert( + operation_id.clone(), + PendingOpenFile { + host_label: identity.host_label.clone(), + page_label: webview.label().into(), + completion: sender, + }, + ); + drop(requests); + app.emit_to( + &identity.host_label, + "plugin-web-open-file", + json!({ + "pluginId": identity.plugin_id, + "operationId": operation_id, + "workspaceId": identity.workspace_id, + "workspacePath": identity.workspace_path, + "path": path, + "line": params["line"].as_u64(), + "column": params["column"].as_u64(), + }), + ) + .map_err(|error| error.to_string()) + }, + )?; + tokio::time::timeout(std::time::Duration::from_secs(10), receiver) + .await + .map_err(|_| { + bridge_error( + "plugin.web.openFileFailed", + "file open acknowledgement timed out", + ) + })? + .map_err(|_| bridge_error("plugin.web.forbidden", "page was closed"))? + .map_err(|error| bridge_error("plugin.web.openFileFailed", error))?; + drop(pending); + bridge_identity(app, webview.label())?; + Ok(json!({})) + } + "build.instances" | "build.refresh" | "build.runTask" | "build.runTests" + | "build.cancel" | "build.dependencies" | "build.setOptions" | "functions.list" + | "functions.invoke" | "functions.cancel" => Err(bridge_error( + "plugin.web.unknownMethod", + format!("{method} needs the Windows build-system capability"), + )), + other => Err(bridge_error( + "plugin.web.unknownMethod", + format!("unknown method {other}"), + )), + } +} + +static OPEN_FILE_SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); +struct OpenFileRequest { + app: AppHandle, + operation_id: String, +} +impl Drop for OpenFileRequest { + fn drop(&mut self) { + self.app + .state::() + .open_files + .lock() + .expect("open file requests") + .remove(&self.operation_id); + } +} + +#[tauri::command] +pub fn declarative_plugins_web_open_file_ack( + app: AppHandle, + caller: tauri::WebviewWindow, + operation_id: String, + error: Option, +) -> Result<(), String> { + let state = app.state::(); + let mut pending = state.open_files.lock().expect("open file requests"); + if !pending + .get(&operation_id) + .is_some_and(|request| request.host_label == caller.label()) + { + return Err("file request does not belong to this product window".into()); + } + if let Some(request) = pending.remove(&operation_id) { + let _ = request.completion.send(error.map_or(Ok(()), Err)); + } + Ok(()) +} + +/// Identity of a bridge caller, derived from its window label. +#[derive(Debug, Clone, PartialEq)] +pub struct BridgeIdentity { + pub generation: u64, + pub plugin_id: String, + pub window_id: String, + pub host_label: String, + pub workspace_id: String, + pub workspace_path: String, + pub state_id: String, +} + +/// Resolves and authorizes a bridge caller from its webview label. The label +/// includes a collision-free encoding of plugin and window IDs; authorization +/// re-derives it from the owning package's current catalog entry. Anything +/// that does not round-trip — a label from another window kind, a window the +/// plugin no longer declares, a disabled or uninstalled plugin, or a forged +/// label pointing at another plugin's window — is refused with +/// `plugin.web.forbidden`. +fn bridge_identity( + app: &AppHandle, + label: &str, +) -> Result { + let owner = app + .state::() + .owners + .lock() + .expect("page owners") + .get(label) + .cloned() + .ok_or_else(|| bridge_error("plugin.web.forbidden", "page ownership was revoked"))?; + if owner.workspace_generation + != workspace_generation( + &app.state::(), + &owner.host_label, + &owner.workspace_id, + ) + || owned_label(&owner) != label + || app.get_webview_window(&owner.host_label).is_none() + || app + .state::() + .blocked(&owner.plugin_id) + { + return Err(bridge_error( + "plugin.web.forbidden", + "page owner is inactive", + )); + } + let declared = declarative_windows_for(app) + .into_iter() + .any(|window| window.plugin_id == owner.plugin_id && window.window_id == owner.window_id); + if !declared { + return Err(bridge_error( + "plugin.web.forbidden", + "package no longer declares this page", + )); + } + Ok(BridgeIdentity { + generation: owner.generation, + plugin_id: owner.plugin_id, + window_id: owner.window_id, + host_label: owner.host_label, + workspace_id: owner.workspace_id, + workspace_path: owner.workspace_path, + state_id: owner.state_id, + }) +} + +fn declarative_windows_for(app: &AppHandle) -> Vec { + let mut windows = Vec::new(); + for package in app.state::().packages() { + if package.entry.status == "accepted" && package.entry.enabled { + collect_web_windows(&package, &mut windows); + } + } + windows +} + +/// The webview label of one tool window: a collision-free hexadecimal encoding +/// of the plugin and window IDs, safe for Tauri labels. +pub(crate) fn web_window_label(plugin_id: &str, window_id: &str) -> String { + format!( + "p{}--w{}", + hex_label_part(plugin_id), + hex_label_part(window_id) + ) +} + +fn hex_label_part(value: &str) -> String { + value + .as_bytes() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn bridge_error(code: &str, message: impl Into) -> String { + json!({ "code": code, "message": message.into() }).to_string() +} + +/// Resolves symlinks/junctions before the workspace file-open entry can read. +/// Returning the canonical target also avoids re-resolving the original link +/// using stale file-tree metadata in `handleFileSelect`. +#[tauri::command] +pub fn declarative_plugins_web_resolve_file( + workspace_path: PathBuf, + path: PathBuf, +) -> Result { + let root = workspace_path.canonicalize().map_err(|error| { + bridge_error( + "plugin.web.forbidden", + format!("workspace is unreadable: {error}"), + ) + })?; + let target = if path.is_absolute() { + path + } else { + root.join(path) + }; + let target = target.canonicalize().map_err(|error| { + bridge_error( + "plugin.web.forbidden", + format!("file is unreadable: {error}"), + ) + })?; + if target == root || !target.starts_with(&root) || !target.is_file() { + return Err(bridge_error( + "plugin.web.forbidden", + "the file is outside the workspace", + )); + } + let path = target.to_string_lossy().replace('\\', "/"); + // Keep the editor's conventional drive/UNC spelling after canonicalize. + Ok(if let Some(unc) = path.strip_prefix("//?/UNC/") { + format!("//{unc}") + } else { + path.strip_prefix("//?/").unwrap_or(&path).to_string() + }) +} + +/// Revoke bridge access before closing; close failures stop package mutation. +pub fn close_plugin_windows(app: &AppHandle, plugin_id: &str) -> Result<(), String> { + close_owned_pages(app, |owner| owner.plugin_id == plugin_id)?; + forget_plugin_state(app, plugin_id); + Ok(()) +} + +fn close_owned_pages(app: &AppHandle, matches: impl Fn(&PageOwner) -> bool) -> Result<(), String> { + let labels = revoke_owners(&app.state::(), matches, true); + let mut failures = Vec::new(); + for label in labels { + if let Some(view) = app.get_webview_window(&label) { + if let Err(error) = view.close() { + failures.push(error.to_string()); + } + } + } + if failures.is_empty() { + Ok(()) + } else { + Err(failures.join("; ")) + } +} + +#[tauri::command] +pub fn declarative_plugins_close_workspace_pages( + app: AppHandle, + caller: tauri::WebviewWindow, + workspace_id: String, +) -> Result<(), String> { + let scope = workspace_label_suffix(caller.label(), &workspace_id); + { + let state = app.state::(); + let mut generations = state + .workspace_generations + .lock() + .expect("workspace page generations"); + *generations.entry(scope).or_default() += 1; + } + close_owned_pages(&app, |owner| { + owner.host_label == caller.label() && owner.workspace_id == workspace_id + })?; + let suffix = workspace_label_suffix(caller.label(), &workspace_id); + app.state::() + .states + .lock() + .expect("page state") + .retain(|key, _| !key.ends_with(&suffix)); + Ok(()) +} + +// MARK: scheme file service + +/// Serves one package file for the `lithe-plugin:///` origin. +/// Returns the response or a bridge-error string; the caller maps errors to +/// an HTTP status. +pub fn serve_package_file( + app: &AppHandle, + plugin_id: &str, + raw_path: &str, +) -> Result<(&'static str, Vec), String> { + let package = app + .state::() + .packages() + .into_iter() + .find(|package| { + package.entry.status == "accepted" + && package.entry.enabled + && package.entry.plugin_id == plugin_id + }) + .ok_or_else(|| format!("{plugin_id} is not an accepted declarative plugin"))?; + let relative = sanitize_package_path(raw_path)?; + if relative + .extension() + .and_then(|extension| extension.to_str()) + != Some("html") + && relative + .extension() + .and_then(|extension| extension.to_str()) + != Some("css") + && relative + .extension() + .and_then(|extension| extension.to_str()) + != Some("js") + && relative + .extension() + .and_then(|extension| extension.to_str()) + != Some("svg") + && relative + .extension() + .and_then(|extension| extension.to_str()) + != Some("png") + && relative + .extension() + .and_then(|extension| extension.to_str()) + != Some("json") + && relative + .extension() + .and_then(|extension| extension.to_str()) + != Some("woff2") + { + return Err(format!( + "unsupported page asset type: {}", + relative.display() + )); + } + let target = package.root.join(&relative); + let metadata = std::fs::symlink_metadata(&target) + .map_err(|error| format!("{} is unreadable: {error}", target.display()))?; + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if metadata.file_attributes() & 0x0400 != 0 { + return Err(format!("{} is a reparse point", target.display())); + } + } + #[cfg(not(windows))] + if metadata.file_type().is_symlink() { + return Err(format!("{} is a symbolic link", target.display())); + } + if !metadata.is_file() || metadata.len() > MAX_PAGE_FILE_BYTES { + return Err(format!("{} is not a servable file", target.display())); + } + let canonical_root = package + .root + .canonicalize() + .map_err(|error| format!("package root is unreadable: {error}"))?; + let canonical = target + .canonicalize() + .map_err(|error| format!("{} is unreadable: {error}", target.display()))?; + if !canonical.starts_with(&canonical_root) { + return Err(format!("{} escapes the package", target.display())); + } + let bytes = std::fs::read(&target).map_err(|error| error.to_string())?; + Ok((content_type(&relative), bytes)) +} + +/// Normalizes a URL path to a safe package-relative path: no empty segments, +/// `.`/`..`, encoded separators, or absolute forms. +fn sanitize_package_path(raw: &str) -> Result { + let decoded = percent_decode(raw); + let mut segments: Vec<&str> = Vec::new(); + for segment in decoded.split('/') { + match segment { + "" | "." => {} + ".." => return Err("path traversal is refused".to_string()), + other => segments.push(other), + } + } + if segments.is_empty() { + return Err("empty page path".to_string()); + } + let mut path = PathBuf::new(); + for segment in segments { + path.push(segment); + } + Ok(path) +} + +fn percent_decode(raw: &str) -> String { + let bytes = raw.as_bytes(); + let mut output = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' && index + 2 < bytes.len() { + let hex = std::str::from_utf8(&bytes[index + 1..index + 3]).unwrap_or(""); + if let Ok(byte) = u8::from_str_radix(hex, 16) { + output.push(byte); + index += 3; + continue; + } + } + output.push(bytes[index]); + index += 1; + } + String::from_utf8_lossy(&output).to_string() +} + +fn content_type(path: &Path) -> &'static str { + match path.extension().and_then(|extension| extension.to_str()) { + Some("html") => "text/html; charset=utf-8", + Some("css") => "text/css; charset=utf-8", + Some("js") => "text/javascript; charset=utf-8", + Some("svg") => "image/svg+xml", + Some("png") => "image/png", + Some("json") => "application/json", + Some("woff2") => "font/woff2", + _ => "application/octet-stream", + } +} + +/// Tauri scheme handler for `lithe-plugin:///`. Wry restores +/// the original URI before delivery; its context supplies the trusted label. +pub fn handle_scheme_request( + app: &AppHandle, + webview_label: &str, + request: http::Request>, +) -> http::Response> { + let ok = |content_type: &'static str, body: Vec| { + http::Response::builder() + .status(200) + .header("Content-Type", content_type) + .header("Content-Security-Policy", PAGE_CSP) + .header("X-Content-Type-Options", "nosniff") + .header("Cache-Control", "no-store") + .header("Cross-Origin-Resource-Policy", "same-origin") + .body(body) + .expect("response builds") + }; + let refused = |message: &str| { + http::Response::builder() + .status(404) + .header("Content-Type", "text/plain; charset=utf-8") + .header("Content-Security-Policy", PAGE_CSP) + .body(message.as_bytes().to_vec()) + .expect("response builds") + }; + // Wry restores `http://lithe-plugin./...` to the original + // `lithe-plugin:///...` before invoking this scheme handler. + let plugin_id = request.uri().host().unwrap_or_default(); + let _native_read = match app + .state::() + .acquire(plugin_id) + { + Ok(lease) => lease, + Err(_) => return refused("plugin package is changing"), + }; + let identity = match bridge_identity(app, webview_label) { + Ok(identity) if identity.plugin_id == plugin_id => identity, + _ => return refused("resource request is outside its plugin page"), + }; + let path = request.uri().path().trim_start_matches('/'); + if path.starts_with("__lithe_host__/text/") { + let package = app + .state::() + .packages() + .into_iter() + .find(|package| package.entry.plugin_id == identity.plugin_id); + let text = package + .as_ref() + .and_then(|package| package.manifest["ui"]["toolWindows"].as_array()) + .and_then(|windows| { + windows + .iter() + .find(|window| window["id"].as_str() == Some(&identity.window_id)) + }) + .and_then(|window| window["text"].as_str()); + return match text { + Some(text) => ok("text/html; charset=utf-8", text_page(text)), + None => refused("text window is no longer declared"), + }; + } + if path == HOST_BRIDGE_PATH { + let appearance = app + .state::() + .appearances + .lock() + .expect("appearance mutex") + .get(webview_label) + .cloned(); + return match appearance { + Some(appearance) => ok( + "text/javascript; charset=utf-8", + bridge_script(&identity.plugin_id, &identity.window_id, &appearance).into_bytes(), + ), + None => refused("page is no longer active"), + }; + } + match serve_package_file(app, plugin_id, path) { + Ok((content_type, body)) if content_type.starts_with("text/html") => { + ok(content_type, page_with_host_bridge(body)) + } + Ok((content_type, body)) => ok(content_type, body), + Err(message) => refused(&message), + } +} + +/// ui:1 text is literal content, escaped into a host-owned shell. No package +/// markup or scripts execute; theme follows the same host bridge as web pages. +fn text_page(text: &str) -> Vec { + let escaped = text + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'"); + page_with_host_bridge(format!("
{escaped}
").into_bytes()) +} + +/// Inserts the host script before package code without rewriting the package. +/// Only the HTML prologue is scanned: comments/declarations and the optional +/// html/head opening tags. Stop before any other tag or text, so a literal +/// inside script/style content is never interpreted as markup here. +fn page_with_host_bridge(body: Vec) -> Vec { + let offset = bridge_insertion_offset(&body); + let tag = format!(""); + let mut response = Vec::with_capacity(body.len() + tag.len()); + response.extend_from_slice(&body[..offset]); + response.extend_from_slice(tag.as_bytes()); + response.extend_from_slice(&body[offset..]); + response +} + +fn bridge_insertion_offset(body: &[u8]) -> usize { + let mut index = if body.starts_with(b"\xef\xbb\xbf") { + 3 + } else { + 0 + }; + let mut fallback = index; + loop { + while body.get(index).is_some_and(u8::is_ascii_whitespace) { + index += 1; + } + if body[index..].starts_with(b"") else { + return fallback; + }; + index += 4 + end + 3; + fallback = index; + continue; + } + if body[index..].starts_with(b"' || *value == b'/') + { + return fallback; + } + if !name.eq_ignore_ascii_case(b"html") && !name.eq_ignore_ascii_case(b"head") { + return fallback; + } + let Some(end) = opening_tag_end(body, name_end) else { + return fallback; + }; + if name.eq_ignore_ascii_case(b"head") { + return end; + } + index = end; + fallback = end; + } +} + +/// HTML attribute quotes can contain `>`; they do not close the opening tag. +fn opening_tag_end(body: &[u8], mut index: usize) -> Option { + let mut quote = None; + while let Some(&value) = body.get(index) { + match quote { + Some(expected) if value == expected => quote = None, + Some(_) => {} + None if value == b'\'' || value == b'"' => quote = Some(value), + None if value == b'>' => return Some(index + 1), + None => {} + } + index += 1; + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compiled_acl_grants_only_the_bridge_to_plugin_private_origins() { + // Exercise Tauri's actual compiled capability resolution, including + // its distinction between the app's origin and custom-protocol pages. + let mut context: tauri::Context = tauri::generate_context!(); + let acl = context.runtime_authority_mut(); + let label = "plugin-web-fixture"; + let own = tauri::ipc::Origin::Remote { + url: url::Url::parse("http://lithe-plugin.acme.php/ui/index.html").unwrap(), + }; + assert!(acl + .resolve_access("plugin:declarative-web|request", label, label, &own) + .is_some()); + for command in [ + "plugin:fs|read_text_file", + "plugin:http|fetch", + "plugin:event|emit", + "core_execute", + "read_local_file", + ] { + assert!( + acl.resolve_access(command, label, label, &own).is_none(), + "{command}" + ); + } + let foreign = tauri::ipc::Origin::Remote { + url: url::Url::parse("https://example.test/ui/index.html").unwrap(), + }; + assert!(acl + .resolve_access("plugin:declarative-web|request", label, label, &foreign) + .is_none()); + assert!(acl + .resolve_access("plugin:declarative-web|request", "main", "main", &own) + .is_none()); + let manifests: Value = serde_json::from_str(include_str!(concat!( + env!("OUT_DIR"), + "/acl-manifests.json" + ))) + .unwrap(); + assert!( + manifests.get("__app__").is_none(), + "the product's custom-command ACL must stay unchanged" + ); + } + + #[test] + fn package_paths_reject_traversal_and_empty() { + assert!(sanitize_package_path("ui/index.html").is_ok()); + assert_eq!( + sanitize_package_path("ui/./index.html").expect("normalizes"), + PathBuf::from("ui").join("index.html") + ); + assert!(sanitize_package_path("../outside.html").is_err()); + assert!(sanitize_package_path("ui/../../outside.html").is_err()); + assert!(sanitize_package_path("").is_err()); + assert!(sanitize_package_path("%2e%2e/escape.html").is_err()); + assert_eq!( + sanitize_package_path("ui%2Findex.html").expect("decodes"), + PathBuf::from("ui").join("index.html") + ); + } + + #[test] + fn percent_decode_handles_encoded_and_plain() { + assert_eq!(percent_decode("a%20b"), "a b"); + assert_eq!(percent_decode("plain.html"), "plain.html"); + assert_eq!(percent_decode("bad%zz"), "bad%zz"); + } + + #[test] + fn bridge_script_is_frozen_and_scoped() { + let script = bridge_script( + "acme.php", + "window-id", + &WebPageAppearance { + locale: "zh-CN".into(), + theme: "dark".into(), + variables: BTreeMap::new(), + }, + ); + assert!(script.contains(r#""pluginID":"acme.php""#)); + assert!(script.contains("Object.freeze(bridge)")); + assert!(script.contains("writable: false")); + } + + #[test] + fn html_loads_the_fresh_host_bridge_before_package_code() { + let tag = ""; + let prologue = "y\">"; + let tail = ""; + for bom in ["", "\u{feff}"] { + let body = format!("{bom}{prologue}{tail}"); + let response = String::from_utf8(page_with_host_bridge(body.into_bytes())).unwrap(); + assert_eq!(response, format!("{bom}{prologue}{tag}{tail}")); + } + // No synthetic doctype or root tags: preserve the package's HTML mode + // and attributes even when its html/head tags are legitimately omitted. + for (prefix, tail) in [ + ( + "", + "", + ), + ( + "", + "", + ), + ( + "", + "", + ), + ("", ""), + ] { + assert_eq!( + String::from_utf8(page_with_host_bridge( + format!("{prefix}{tail}").into_bytes() + )) + .unwrap(), + format!("{prefix}{tag}{tail}") + ); + } + assert!(!PAGE_CSP.contains("script-src 'self' 'unsafe-inline'")); + } + + #[test] + fn navigation_and_bridge_refuse_foreign_documents() { + let initial = package_page_url("acme.php", "ui/index.html").unwrap(); + assert!(is_package_url(&initial, "acme.php")); + assert_eq!( + initial.as_str(), + if cfg!(windows) { + "http://lithe-plugin.acme.php/ui/index.html" + } else { + "lithe-plugin://acme.php/ui/index.html" + } + ); + for address in [ + "lithe-plugin://acme.php/ui/index.html", + "lithe-plugin://acme.php/ui/next.html#section", + ] { + assert!(require_package_url(&url::Url::parse(address).unwrap(), "acme.php").is_ok()); + } + for address in [ + "https://example.com/ui/index.html", + "http://lithe-plugin.acme.php.evil.test/ui/index.html", + "lithe-plugin://other.plugin/ui/index.html", + "lithe-plugin://user@acme.php/ui/index.html", + "lithe-plugin://acme.php:1234/ui/index.html", + "about:blank", + "data:text/html,evil", + "file:///tmp/index.html", + "lithe-plugin://acme.php/%2e%2e%2foutside.html", + ] { + let error = + require_package_url(&url::Url::parse(address).unwrap(), "acme.php").unwrap_err(); + assert!(error.contains("plugin.web.forbidden"), "{address}"); + } + assert_eq!( + is_package_url( + &url::Url::parse("http://lithe-plugin.acme.php/ui/index.html").unwrap(), + "acme.php" + ), + cfg!(windows) + ); + } + + #[test] + fn open_file_resolves_targets_before_checking_workspace_boundary() { + struct TempRoot(PathBuf); + impl Drop for TempRoot { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let root = TempRoot(super::serve_tests::unique_temp_root("workspace-paths")); + let workspace = root.0.join("workspace"); + std::fs::create_dir(&workspace).unwrap(); + let inside = workspace.join("inside"); + let outside = root.0.join("outside"); + std::fs::create_dir(&inside).unwrap(); + std::fs::create_dir(&outside).unwrap(); + std::fs::write(inside.join("file.txt"), "inside").unwrap(); + std::fs::write(outside.join("file.txt"), "outside").unwrap(); + assert!( + declarative_plugins_web_resolve_file(workspace.clone(), "inside/file.txt".into()) + .is_ok() + ); + assert!( + declarative_plugins_web_resolve_file(workspace.clone(), outside.join("file.txt")) + .is_err() + ); + link_directory(&inside, &workspace.join("inside-link")); + link_directory(&outside, &workspace.join("outside-link")); + let resolved = + declarative_plugins_web_resolve_file(workspace.clone(), "inside-link/file.txt".into()) + .unwrap(); + assert!(resolved.ends_with("/inside/file.txt")); + assert!(declarative_plugins_web_resolve_file( + workspace.clone(), + "outside-link/file.txt".into() + ) + .is_err()); + // Canonicalize both sides so a workspace opened through a link works. + link_directory(&workspace, &root.0.join("linked-root")); + assert!(declarative_plugins_web_resolve_file( + root.0.join("linked-root"), + "inside/file.txt".into() + ) + .is_ok()); + } + + fn link_directory(target: &Path, link: &Path) { + #[cfg(windows)] + { + // Junctions need no developer-mode/admin privilege and protect + // the same reparse-point escape boundary as directory symlinks. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + let mut command = std::process::Command::new("cmd"); + command + .args(["/D", "/C", "mklink", "/J"]) + .arg(link) + .arg(target); + let outcome = lithe_git_host::run( + &mut command, + None, + || std::time::Instant::now() >= deadline, + || {}, + |_, _| {}, + ); + assert!( + outcome.failure.is_none() && outcome.status.is_some_and(|status| status.success()), + "junction creation failed: {}", + String::from_utf8_lossy(&outcome.stderr) + ); + } + #[cfg(unix)] + std::os::unix::fs::symlink(target, link).expect("directory symlink"); + } + + #[test] + fn bridge_payload_uses_its_method_and_refuses_claimed_identity() { + let args: BridgeArgs = serde_json::from_value(json!({ + "method": "ui.getState", + "params": null + })) + .expect("bridge request"); + assert_eq!(args.method, "ui.getState"); + assert!(serde_json::from_value::(json!({ + "method": "ui.getState", + "pluginId": "another.plugin", + "params": null + })) + .is_err()); + } + + #[test] + fn web_window_labels_are_stable_and_collision_free() { + // Hex encoding preserves the full plugin/window identity in a label. + assert_eq!( + web_window_label("dev.lithe", "window"), + "p6465762e6c69746865--w77696e646f77" + ); + assert_eq!( + web_window_label("acme", "panel:one"), + "p61636d65--w70616e656c3a6f6e65" + ); + assert_ne!( + web_window_label("acme", "panel"), + web_window_label("other", "panel") + ); + } + #[test] + fn workspace_pages_have_distinct_labels_and_state() { + let first = PageOwner { + generation: 1, + workspace_generation: 0, + host_label: "main".into(), + workspace_id: "a".into(), + workspace_path: "C:/a".into(), + plugin_id: "test.plugin".into(), + window_id: "panel".into(), + state_id: String::new(), + }; + let second = PageOwner { + workspace_id: "b".into(), + ..first.clone() + }; + let other_window = PageOwner { + generation: 1, + workspace_generation: 0, + host_label: "workspace-other".into(), + ..first.clone() + }; + assert_ne!(owned_label(&first), owned_label(&second)); + assert_ne!(owned_label(&first), owned_label(&other_window)); + assert_ne!( + state_key("test.plugin", &owned_label(&first)), + state_key("test.plugin", &owned_label(&second)) + ); + } + + #[test] + fn text_window_is_literal_and_hello_fixture_is_registered() { + let page = String::from_utf8(text_page("&")).unwrap(); + assert!(!page.contains("