diff --git a/CHANGELOG.md b/CHANGELOG.md index 05f2282..0ef7e8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,96 @@ Every release entry is provided in English and Simplified Chinese. 每个版本条目均同时提供英文和简体中文说明。 +## 1.8.2 - 2026-09-17 + +### English + +#### Added + +- Added Windows Explorer entries for opening a folder in a registered WSL distribution. Both a folder's context menu and the folder-background menu offer the distribution choices installed with Pebrel. +- Added `--shell ` to select a Shell or saved terminal profile for a new terminal. The choice reaches both a fresh launch and an already-running instance; an explicitly supplied directory overrides the profile's saved directory without changing the saved profile. +- Added persistent command groups, with drag-and-drop assignment and context-menu actions to move commands between groups or return them to Ungrouped. Deleting a group keeps its commands. +- Added persistent deletion of built-in command recipes. Removed recipes stay hidden after reopening the command manager, including after changes from another window; saved user commands are retained. + +#### Fixed + +- Fixed WSL repeatedly printing `PROMPT_COMMAND` syntax errors at `__pebrel_connection() {` when Windows line endings entered embedded Shell scripts. Shared scripts are normalized before injection, preserving command exit codes and existing user SSH functions. +- Fixed Git Bash leaving leading characters behind when editing or clearing wrapped input in byte-oriented locales. The managed prompt uses an ASCII marker when required and retains the Unicode marker in UTF-8 locales. +- Fixed premature completion notifications when the integration reports active background commands or monitors, which were previously missed by the background-work counter. +- Fixed hook-enabled AI sessions showing a completion toast merely because the screen stopped changing while the task was still working. +- Fixed background operations briefly opening console windows on Windows, including process probes, Git operations and SSH helper commands. +- Fixed SSH terminals retaining their initial grid size after resizing a window or pane; the visible grid and remote terminal now receive the updated dimensions. +- Fixed "Reveal in file manager" opening the wrong folder when a Windows path contains spaces. The containing folder opens with the requested file selected. Addresses [#167](https://github.com/Kuddev/nebula/issues/167). +- Fixed moving the final tab to another window triggering a conflicting workspace update when the original window closes. +- Fixed saved SSH connection names being replaced by remote directory or program titles. Both the sidebar and top-tab layouts retain the configured connection name. +- Fixed Windows SSH configuration and identity-file paths containing spaces or quotes. Host discovery and connection resolution use the same user configuration file. +- Fixed SSH connection tests mishandling host-key confirmation and cancellation. Closing or editing the connection cancels the owned test, and an old result cannot overwrite the edited connection's status. +- Fixed the multiline command editor collapsing instead of retaining a usable editing height. +- Fixed Windows taskbar and relaunch icon properties and refreshed matching pinned shortcuts while preserving their launch arguments. + +#### Improved + +- Integrated keyboard-binding search into the global settings search, including action names and key combinations. Searching for the keyboard-settings section still shows the complete page. +- Made short, fixed settings choices directly visible as segmented buttons, including tab placement, new-tab position, display density and completion style. +- Expanded terminal-tab hover details to show the connection, working directory and current program or AI task without replacing the tab's stable name. +- Improved fallback handling for simple SSH configurations when the system SSH client cannot expand them. Advanced directives that require OpenSSH report a clear error instead of silently resolving an incomplete connection. +- Simplified the built-in recipes to selected Docker/Conda operations and Python/Git installation commands, while preserving saved user commands and persistent deletion choices. + +Windows packages include a Pebrel installer and portable ZIP. Linux x64 packages and macOS Apple Silicon/Intel DMGs retain their Preview designation. macOS requires version 14 or later. Molecular structure rendering remains disabled. + +### 中文 + +#### 新增 + +- 新增:Windows 资源管理器可选择已注册的 WSL 发行版打开目录;文件夹及文件夹空白处的右键菜单均提供随 Pebrel 安装注册的发行版选项。 +- 新增:`--shell ` 可为新终端指定 Shell 或已保存的终端配置;首次启动和已运行实例都能接收该选择。本次明确指定的目录覆盖配置保存的目录,保存的配置本身保持不变。 +- 新增:可持久保存的命令分组,支持拖动分配、右键移入其他分组或移回“未分组”;删除分组会保留其中的命令。 +- 新增:内置命令配方支持持久删除;重新打开命令管理器或从另一窗口修改后,被删除的配方仍保持隐藏,用户保存的命令予以保留。 + +#### 修复 + +- 修复:Windows 换行符混入内置 Shell 脚本后,WSL 在 `__pebrel_connection() {` 处反复输出 `PROMPT_COMMAND` 语法错误的问题。共享脚本在注入前统一换行,保留命令退出码及用户已有的 SSH 函数。 +- 修复:Git Bash 在按字节处理字符的语言环境中,编辑或清空折行输入后残留行首字符的问题;受管理的提示符在需要时使用 ASCII 标记,在 UTF-8 环境中保留 Unicode 标记。 +- 修复:集成已上报正在运行的后台命令或监控任务,却因后台任务统计遗漏而提前弹出完成通知的问题。 +- 修复:已启用 Hook 的 AI 会话仍在工作时,仅因屏幕暂时没有变化就弹出完成提示的问题。 +- 修复:Windows 后台操作短暂弹出控制台窗口的问题,覆盖进程探测、Git 操作及 SSH 辅助命令等路径。 +- 修复:调整窗口或窗格尺寸后,SSH 终端仍保持初始网格大小的问题;可见网格与远端终端同步接收新的尺寸。 +- 修复:Windows 路径含空格时,“在文件管理器中显示”打开错误目录的问题;现在会打开所在目录并选中目标文件。对应 [#167](https://github.com/Kuddev/nebula/issues/167)。 +- 修复:将最后一个标签移动到另一窗口、原窗口随之关闭时触发工作区更新冲突的问题。 +- 修复:已保存的 SSH 连接名称被远端目录或程序标题替换的问题;侧栏和顶部标签布局均保留配置中的连接名称。 +- 修复:Windows SSH 配置及密钥文件路径含空格或引号时的处理问题;主机发现与连接解析使用同一份用户配置文件。 +- 修复:SSH 连接测试中的主机密钥确认和取消处理;关闭或编辑连接会取消该测试,旧测试结果不能覆盖修改后连接的状态。 +- 修复:多行命令编辑器收缩、无法保持可用编辑高度的问题。 +- 修复:Windows 任务栏和重新启动入口的图标属性,并在保留启动参数的同时刷新匹配的已固定快捷方式。 + +#### 改进 + +- 改进:快捷键搜索统一接入设置页全局搜索,支持动作名称和按键组合;搜索快捷键设置分类时仍展示完整页面。 +- 改进:选项较少且固定的设置直接显示为分段按钮,覆盖标签布局、新标签位置、显示密度和补全样式等选项。 +- 改进:终端标签悬停提示展示连接、工作目录及当前程序或 AI 任务,同时保留稳定的标签名称。 +- 改进:系统 SSH 客户端无法展开配置时,对简单 SSH 配置提供回退处理;需要 OpenSSH 的高级指令会明确报错,避免静默使用不完整的连接配置。 +- 改进:将内置命令配方精简为选定的 Docker/Conda 操作及 Python/Git 安装命令,保留用户保存的命令和已持久保存的删除选择。 + +Windows 提供 Pebrel 安装器和 ZIP 便携包;Linux x64 包以及 macOS Apple Silicon/Intel DMG 继续标记为 Preview。macOS 最低运行版本为 14。分子结构渲染仍处于禁用状态。 + +### Contributors + +- [@AnxForever](https://github.com/AnxForever) — [#142](https://github.com/Kuddev/pebrel/pull/142), [#143](https://github.com/Kuddev/pebrel/pull/143), [#145](https://github.com/Kuddev/pebrel/pull/145) +- [@AlanWanco](https://github.com/AlanWanco) — [#166](https://github.com/Kuddev/pebrel/pull/166) +- [@Traveritas](https://github.com/Traveritas) — [#168](https://github.com/Kuddev/pebrel/pull/168) + +--- + +**SHA256** + +- `Pebrel-v1.8.2-windows-x64.zip`: `PENDING FINAL BUILD` +- `Pebrel-v1.8.2-windows-x64-setup.exe`: `PENDING FINAL BUILD` +- `Pebrel-v1.8.2-linux-x64-preview.AppImage`: `PENDING FINAL BUILD` +- `Pebrel-v1.8.2-linux-x64-preview.deb`: `PENDING FINAL BUILD` +- `Pebrel-v1.8.2-linux-x64-preview.tar.gz`: `PENDING FINAL BUILD` +- `Pebrel-v1.8.2-macos-arm64-preview.dmg`: `PENDING FINAL BUILD` +- `Pebrel-v1.8.2-macos-x64-preview.dmg`: `PENDING FINAL BUILD` + ## 1.8.1 - 2026-09-15 ### English diff --git a/Cargo.lock b/Cargo.lock index 6a54cf9..660a707 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5087,7 +5087,7 @@ dependencies = [ [[package]] name = "nebula" -version = "1.8.1" +version = "1.8.2" dependencies = [ "ab_glyph_rasterizer", "aes-gcm", @@ -5174,7 +5174,7 @@ dependencies = [ [[package]] name = "nebula-completions" -version = "1.8.1" +version = "1.8.2" dependencies = [ "lscolors", "nu-ansi-term", @@ -5195,15 +5195,15 @@ dependencies = [ [[package]] name = "nebula-settings" -version = "1.8.1" +version = "1.8.2" [[package]] name = "nebula-split" -version = "1.8.1" +version = "1.8.2" [[package]] name = "nebula_config" -version = "1.8.1" +version = "1.8.2" dependencies = [ "log", "nebula_config_derive", @@ -5213,7 +5213,7 @@ dependencies = [ [[package]] name = "nebula_config_derive" -version = "1.8.1" +version = "1.8.2" dependencies = [ "log", "nebula_config", @@ -5230,7 +5230,7 @@ version = "0.6.0" [[package]] name = "nebula_terminal" -version = "1.8.1" +version = "1.8.2" dependencies = [ "base64", "bitflags 2.13.0", diff --git a/docs/release-notes/v1.8.2.md b/docs/release-notes/v1.8.2.md new file mode 100644 index 0000000..9477d51 --- /dev/null +++ b/docs/release-notes/v1.8.2.md @@ -0,0 +1,89 @@ +# Pebrel 1.8.2 + +## English + +### Added + +- Added Windows Explorer entries for opening a folder in a registered WSL distribution. Both a folder's context menu and the folder-background menu offer the distribution choices installed with Pebrel. +- Added `--shell ` to select a Shell or saved terminal profile for a new terminal. The choice reaches both a fresh launch and an already-running instance; an explicitly supplied directory overrides the profile's saved directory without changing the saved profile. +- Added persistent command groups, with drag-and-drop assignment and context-menu actions to move commands between groups or return them to Ungrouped. Deleting a group keeps its commands. +- Added persistent deletion of built-in command recipes. Removed recipes stay hidden after reopening the command manager, including after changes from another window; saved user commands are retained. + +### Fixed + +- Fixed WSL repeatedly printing `PROMPT_COMMAND` syntax errors at `__pebrel_connection() {` when Windows line endings entered embedded Shell scripts. Shared scripts are normalized before injection, preserving command exit codes and existing user SSH functions. +- Fixed Git Bash leaving leading characters behind when editing or clearing wrapped input in byte-oriented locales. The managed prompt uses an ASCII marker when required and retains the Unicode marker in UTF-8 locales. +- Fixed premature completion notifications when the integration reports active background commands or monitors, which were previously missed by the background-work counter. +- Fixed hook-enabled AI sessions showing a completion toast merely because the screen stopped changing while the task was still working. +- Fixed background operations briefly opening console windows on Windows, including process probes, Git operations and SSH helper commands. +- Fixed SSH terminals retaining their initial grid size after resizing a window or pane; the visible grid and remote terminal now receive the updated dimensions. +- Fixed "Reveal in file manager" opening the wrong folder when a Windows path contains spaces. The containing folder opens with the requested file selected. Addresses [#167](https://github.com/Kuddev/nebula/issues/167). +- Fixed moving the final tab to another window triggering a conflicting workspace update when the original window closes. +- Fixed saved SSH connection names being replaced by remote directory or program titles. Both the sidebar and top-tab layouts retain the configured connection name. +- Fixed Windows SSH configuration and identity-file paths containing spaces or quotes. Host discovery and connection resolution use the same user configuration file. +- Fixed SSH connection tests mishandling host-key confirmation and cancellation. Closing or editing the connection cancels the owned test, and an old result cannot overwrite the edited connection's status. +- Fixed the multiline command editor collapsing instead of retaining a usable editing height. +- Fixed Windows taskbar and relaunch icon properties and refreshed matching pinned shortcuts while preserving their launch arguments. + +### Improved + +- Integrated keyboard-binding search into the global settings search, including action names and key combinations. Searching for the keyboard-settings section still shows the complete page. +- Made short, fixed settings choices directly visible as segmented buttons, including tab placement, new-tab position, display density and completion style. +- Expanded terminal-tab hover details to show the connection, working directory and current program or AI task without replacing the tab's stable name. +- Improved fallback handling for simple SSH configurations when the system SSH client cannot expand them. Advanced directives that require OpenSSH report a clear error instead of silently resolving an incomplete connection. +- Simplified the built-in recipes to selected Docker/Conda operations and Python/Git installation commands, while preserving saved user commands and persistent deletion choices. + +Windows packages include a Pebrel installer and portable ZIP. Linux x64 packages and macOS Apple Silicon/Intel DMGs retain their Preview designation. macOS requires version 14 or later. Molecular structure rendering remains disabled. + +## 中文 + +### 新增 + +- 新增:Windows 资源管理器可选择已注册的 WSL 发行版打开目录;文件夹及文件夹空白处的右键菜单均提供随 Pebrel 安装注册的发行版选项。 +- 新增:`--shell ` 可为新终端指定 Shell 或已保存的终端配置;首次启动和已运行实例都能接收该选择。本次明确指定的目录覆盖配置保存的目录,保存的配置本身保持不变。 +- 新增:可持久保存的命令分组,支持拖动分配、右键移入其他分组或移回“未分组”;删除分组会保留其中的命令。 +- 新增:内置命令配方支持持久删除;重新打开命令管理器或从另一窗口修改后,被删除的配方仍保持隐藏,用户保存的命令予以保留。 + +### 修复 + +- 修复:Windows 换行符混入内置 Shell 脚本后,WSL 在 `__pebrel_connection() {` 处反复输出 `PROMPT_COMMAND` 语法错误的问题。共享脚本在注入前统一换行,保留命令退出码及用户已有的 SSH 函数。 +- 修复:Git Bash 在按字节处理字符的语言环境中,编辑或清空折行输入后残留行首字符的问题;受管理的提示符在需要时使用 ASCII 标记,在 UTF-8 环境中保留 Unicode 标记。 +- 修复:集成已上报正在运行的后台命令或监控任务,却因后台任务统计遗漏而提前弹出完成通知的问题。 +- 修复:已启用 Hook 的 AI 会话仍在工作时,仅因屏幕暂时没有变化就弹出完成提示的问题。 +- 修复:Windows 后台操作短暂弹出控制台窗口的问题,覆盖进程探测、Git 操作及 SSH 辅助命令等路径。 +- 修复:调整窗口或窗格尺寸后,SSH 终端仍保持初始网格大小的问题;可见网格与远端终端同步接收新的尺寸。 +- 修复:Windows 路径含空格时,“在文件管理器中显示”打开错误目录的问题;现在会打开所在目录并选中目标文件。对应 [#167](https://github.com/Kuddev/nebula/issues/167)。 +- 修复:将最后一个标签移动到另一窗口、原窗口随之关闭时触发工作区更新冲突的问题。 +- 修复:已保存的 SSH 连接名称被远端目录或程序标题替换的问题;侧栏和顶部标签布局均保留配置中的连接名称。 +- 修复:Windows SSH 配置及密钥文件路径含空格或引号时的处理问题;主机发现与连接解析使用同一份用户配置文件。 +- 修复:SSH 连接测试中的主机密钥确认和取消处理;关闭或编辑连接会取消该测试,旧测试结果不能覆盖修改后连接的状态。 +- 修复:多行命令编辑器收缩、无法保持可用编辑高度的问题。 +- 修复:Windows 任务栏和重新启动入口的图标属性,并在保留启动参数的同时刷新匹配的已固定快捷方式。 + +### 改进 + +- 改进:快捷键搜索统一接入设置页全局搜索,支持动作名称和按键组合;搜索快捷键设置分类时仍展示完整页面。 +- 改进:选项较少且固定的设置直接显示为分段按钮,覆盖标签布局、新标签位置、显示密度和补全样式等选项。 +- 改进:终端标签悬停提示展示连接、工作目录及当前程序或 AI 任务,同时保留稳定的标签名称。 +- 改进:系统 SSH 客户端无法展开配置时,对简单 SSH 配置提供回退处理;需要 OpenSSH 的高级指令会明确报错,避免静默使用不完整的连接配置。 +- 改进:将内置命令配方精简为选定的 Docker/Conda 操作及 Python/Git 安装命令,保留用户保存的命令和已持久保存的删除选择。 + +Windows 提供 Pebrel 安装器和 ZIP 便携包;Linux x64 包以及 macOS Apple Silicon/Intel DMG 继续标记为 Preview。macOS 最低运行版本为 14。分子结构渲染仍处于禁用状态。 + +## Contributors + +- [@AnxForever](https://github.com/AnxForever) — [#142](https://github.com/Kuddev/pebrel/pull/142), [#143](https://github.com/Kuddev/pebrel/pull/143), [#145](https://github.com/Kuddev/pebrel/pull/145) +- [@AlanWanco](https://github.com/AlanWanco) — [#166](https://github.com/Kuddev/pebrel/pull/166) +- [@Traveritas](https://github.com/Traveritas) — [#168](https://github.com/Kuddev/pebrel/pull/168) + +--- + +**SHA256** + +- `Pebrel-v1.8.2-windows-x64.zip`: `PENDING FINAL BUILD` +- `Pebrel-v1.8.2-windows-x64-setup.exe`: `PENDING FINAL BUILD` +- `Pebrel-v1.8.2-linux-x64-preview.AppImage`: `PENDING FINAL BUILD` +- `Pebrel-v1.8.2-linux-x64-preview.deb`: `PENDING FINAL BUILD` +- `Pebrel-v1.8.2-linux-x64-preview.tar.gz`: `PENDING FINAL BUILD` +- `Pebrel-v1.8.2-macos-arm64-preview.dmg`: `PENDING FINAL BUILD` +- `Pebrel-v1.8.2-macos-x64-preview.dmg`: `PENDING FINAL BUILD` diff --git a/docs/runtime-api-v1.schema.json b/docs/runtime-api-v1.schema.json index f20a8fd..5af2c7f 100644 --- a/docs/runtime-api-v1.schema.json +++ b/docs/runtime-api-v1.schema.json @@ -224,7 +224,9 @@ "window_params": { "type": "object", "properties": { - "window_id": { "$ref": "#/$defs/nullable_window_id" } + "window_id": { "$ref": "#/$defs/nullable_window_id" }, + "cwd": { "type": ["string", "null"] }, + "shell": { "type": ["string", "null"] } }, "additionalProperties": false }, @@ -487,7 +489,8 @@ "type": "object", "properties": { "window_id": { "$ref": "#/$defs/nullable_window_id" }, - "cwd": { "type": ["string", "null"] } + "cwd": { "type": ["string", "null"] }, + "shell": { "type": ["string", "null"] } }, "additionalProperties": false }, diff --git a/docs/runtime-control-api.md b/docs/runtime-control-api.md index 91fa2c0..8fb14d8 100644 --- a/docs/runtime-control-api.md +++ b/docs/runtime-control-api.md @@ -159,10 +159,10 @@ Pane ID 当前在 Window 内稳定,而不是进程内全局唯一。存在多 | `agent.paste` | 向同一 Agent generation 发送受控 bracketed-paste 文本 | `agent`, `generation?`, `text`, `submit?` | | `agent.read` | 读取命名 Agent 所在 Pane 的真实 Grid 尾部 | `agent`, `generation?`, `lines?` | | `agent.wait` | 等待同一 Agent generation 的状态跃迁;被替换/退出即明确失败 | `agent`, `generation`, `state`, `timeout_ms`, `after_seq?` | -| `window.create` | 创建新窗口 | 无 | +| `window.create` | 创建新窗口 | `cwd?`, `shell?` | | `window.close` | 关闭空闲窗口;忙碌 Pane 返回显式确认错误 | `window_id?` | | `window.focus` | 聚焦窗口或 Pane | `window_id?`, `pane_id?` | -| `tab.new` | 创建默认 Shell 标签 | `window_id?` | +| `tab.new` | 创建标签(默认 Shell,或 `shell` 点名的那个) | `window_id?`, `cwd?`, `shell?` | | `tab.close` | 按窗口内零基索引关闭空闲 Tab | `window_id?`, `tab_index` | | `tab.rename` | 设置或清除 Tab 自定义名称 | `window_id?`, `tab_index`, `name` | | `tab.move` | 在同一窗口内移动 Tab | `window_id?`, `tab_index`, `to_index` | @@ -183,6 +183,13 @@ Pane ID 当前在 Window 内稳定,而不是进程内全局唯一。存在多 任意终端字节注入接口。控制键走 `pane.send_key`:只开放命名键,字母必须配 `control=true`,`repeat` 上限 64;API 不接受任意 bytes 或 ANSI 字符串。 +`tab.new` / `window.create` 的 `shell` 用与 `shell=` 设置相同的 id(`pwsh`、`cmd`、 +`wsl:Ubuntu`,或某个 profile 的 settings id),只作用于这一次创建;缺省照旧用设置里的 +默认 Shell。id 解析不出来时请求以 `invalid_shell` 失败,**不会**回落到默认 Shell—— +静默换掉用户点名要的 Shell 是最难查的失败。注意参数是 `deny_unknown_fields` 的: +升级后的客户端带 `shell` 请求旧运行时会在解析阶段被拒(`invalid_params`),因此升级后 +需要重启驻留实例。 + `pane.paste` 专用于确实需要保留换行的输入:只接受 UTF-8,限制 32 KiB,拒绝 ESC、NUL 与危险控制字符,并要求目标终端已启用 bracketed-paste。SSH Pane 明确拒绝本地文件/文本 转发。资源 CLI 可从字面文本、`--stdin` 或 `--from-file` 读取,但协议边界始终只接收验证后的 diff --git a/extra/completions/_pebrel b/extra/completions/_pebrel index a8fc754..65c9783 100644 --- a/extra/completions/_pebrel +++ b/extra/completions/_pebrel @@ -19,6 +19,7 @@ _pebrel() { '--config-file=[Specify an alternative configuration file.]:CONFIG_FILE:_files' \ '--socket=[Path for IPC socket creation]:SOCKET:_files' \ '--working-directory=[Start the shell in the specified working directory]:WORKING_DIRECTORY:_files' \ +'(-e --command)--shell=[Start this shell instead of the configured default one, by the same id the \`shell\` setting uses (\`pwsh\`, \`cmd\`, \`wsl\:Ubuntu\`, or a profile'\''s settings id)]:ID:_default' \ '*-e+[Command and args to execute (must be last argument)]:COMMAND:_default' \ '*--command=[Command and args to execute (must be last argument)]:COMMAND:_default' \ '-T+[Defines the window title \[default\: Pebrel\]]:TITLE:_default' \ diff --git a/extra/completions/pebrel.bash b/extra/completions/pebrel.bash index 148a057..ef364ee 100644 --- a/extra/completions/pebrel.bash +++ b/extra/completions/pebrel.bash @@ -581,7 +581,7 @@ _pebrel() { case "${cmd}" in pebrel) - opts="-q -v -e -T -o -h -V --print-events --ref-test --embed --gpui --config-file --socket --daemon --working-directory --hold --command --title --class --option --help --version ctl env window tab pane agent migrate config help" + opts="-q -v -e -T -o -h -V --print-events --ref-test --embed --gpui --config-file --socket --daemon --working-directory --shell --hold --command --title --class --option --help --version ctl env window tab pane agent migrate config help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -636,6 +636,10 @@ _pebrel() { fi return 0 ;; + --shell) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; --command) COMPREPLY=($(compgen -f "${cur}")) return 0 diff --git a/extra/completions/pebrel.fish b/extra/completions/pebrel.fish index ae317ed..ff552e0 100644 --- a/extra/completions/pebrel.fish +++ b/extra/completions/pebrel.fish @@ -1,6 +1,6 @@ # Print an optspec for argparse to handle cmd's options that are independent of any subcommand. function __fish_pebrel_global_optspecs - string join \n print-events ref-test embed= gpui config-file= socket= q v daemon working-directory= hold e/command= T/title= class= o/option= h/help V/version + string join \n print-events ref-test embed= gpui config-file= socket= q v daemon working-directory= shell= hold e/command= T/title= class= o/option= h/help V/version end function __fish_pebrel_needs_command @@ -28,6 +28,7 @@ complete -c pebrel -n "__fish_pebrel_needs_command" -l embed -d 'X11 window ID t complete -c pebrel -n "__fish_pebrel_needs_command" -l config-file -d 'Specify an alternative configuration file.' -r -F complete -c pebrel -n "__fish_pebrel_needs_command" -l socket -d 'Path for IPC socket creation' -r -F complete -c pebrel -n "__fish_pebrel_needs_command" -l working-directory -d 'Start the shell in the specified working directory' -r -F +complete -c pebrel -n "__fish_pebrel_needs_command" -l shell -d 'Start this shell instead of the configured default one, by the same id the `shell` setting uses (`pwsh`, `cmd`, `wsl:Ubuntu`, or a profile\'s settings id)' -r complete -c pebrel -n "__fish_pebrel_needs_command" -s e -l command -d 'Command and args to execute (must be last argument)' -r complete -c pebrel -n "__fish_pebrel_needs_command" -s T -l title -d 'Defines the window title [default: Pebrel]' -r complete -c pebrel -n "__fish_pebrel_needs_command" -l class -d 'Defines window class/app_id on X11/Wayland [default: Pebrel]' -r diff --git a/extra/completions/windows/_pebrel b/extra/completions/windows/_pebrel index 56159e8..f7fb8bb 100644 --- a/extra/completions/windows/_pebrel +++ b/extra/completions/windows/_pebrel @@ -18,6 +18,7 @@ _pebrel() { '--embed=[X11 window ID to embed Pebrel within (decimal or hexadecimal with "0x" prefix)]:EMBED:_default' \ '--config-file=[Specify an alternative configuration file.]:CONFIG_FILE:_files' \ '--working-directory=[Start the shell in the specified working directory]:WORKING_DIRECTORY:_files' \ +'(-e --command)--shell=[Start this shell instead of the configured default one, by the same id the \`shell\` setting uses (\`pwsh\`, \`cmd\`, \`wsl\:Ubuntu\`, or a profile'\''s settings id)]:ID:_default' \ '*-e+[Command and args to execute (must be last argument)]:COMMAND:_default' \ '*--command=[Command and args to execute (must be last argument)]:COMMAND:_default' \ '-T+[Defines the window title \[default\: Pebrel\]]:TITLE:_default' \ diff --git a/extra/completions/windows/pebrel.bash b/extra/completions/windows/pebrel.bash index 5172662..6e4bb92 100644 --- a/extra/completions/windows/pebrel.bash +++ b/extra/completions/windows/pebrel.bash @@ -599,7 +599,7 @@ _pebrel() { case "${cmd}" in pebrel) - opts="-q -v -e -T -o -h -V --print-events --ref-test --embed --gpui --config-file --daemon --working-directory --hold --command --title --class --option --help --version ctl env window tab pane agent migrate config notify-test setup-ai ssh help" + opts="-q -v -e -T -o -h -V --print-events --ref-test --embed --gpui --config-file --daemon --working-directory --shell --hold --command --title --class --option --help --version ctl env window tab pane agent migrate config notify-test setup-ai ssh help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -639,6 +639,10 @@ _pebrel() { fi return 0 ;; + --shell) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; --command) COMPREPLY=($(compgen -f "${cur}")) return 0 diff --git a/extra/completions/windows/pebrel.fish b/extra/completions/windows/pebrel.fish index a7550c3..401cc6d 100644 --- a/extra/completions/windows/pebrel.fish +++ b/extra/completions/windows/pebrel.fish @@ -1,6 +1,6 @@ # Print an optspec for argparse to handle cmd's options that are independent of any subcommand. function __fish_pebrel_global_optspecs - string join \n print-events ref-test embed= gpui config-file= q v daemon working-directory= hold e/command= T/title= class= o/option= h/help V/version + string join \n print-events ref-test embed= gpui config-file= q v daemon working-directory= shell= hold e/command= T/title= class= o/option= h/help V/version end function __fish_pebrel_needs_command @@ -27,6 +27,7 @@ end complete -c pebrel -n "__fish_pebrel_needs_command" -l embed -d 'X11 window ID to embed Pebrel within (decimal or hexadecimal with "0x" prefix)' -r complete -c pebrel -n "__fish_pebrel_needs_command" -l config-file -d 'Specify an alternative configuration file.' -r -F complete -c pebrel -n "__fish_pebrel_needs_command" -l working-directory -d 'Start the shell in the specified working directory' -r -F +complete -c pebrel -n "__fish_pebrel_needs_command" -l shell -d 'Start this shell instead of the configured default one, by the same id the `shell` setting uses (`pwsh`, `cmd`, `wsl:Ubuntu`, or a profile\'s settings id)' -r complete -c pebrel -n "__fish_pebrel_needs_command" -s e -l command -d 'Command and args to execute (must be last argument)' -r complete -c pebrel -n "__fish_pebrel_needs_command" -s T -l title -d 'Defines the window title [default: Pebrel]' -r complete -c pebrel -n "__fish_pebrel_needs_command" -l class -d 'Defines window class/app_id on X11/Wayland [default: Pebrel]' -r diff --git a/nebula-completions/Cargo.toml b/nebula-completions/Cargo.toml index 68c74d5..dc8b24d 100644 --- a/nebula-completions/Cargo.toml +++ b/nebula-completions/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nebula-completions" -version = "1.8.1" +version = "1.8.2" edition.workspace = true [features] diff --git a/nebula_app/Cargo.toml b/nebula_app/Cargo.toml index 85a4a8a..c4c6696 100644 --- a/nebula_app/Cargo.toml +++ b/nebula_app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nebula" -version = "1.8.1" +version = "1.8.2" license = "GPL-3.0-or-later" description = "Pebrel — a GPU-accelerated terminal emulator" readme = "README.md" @@ -15,15 +15,15 @@ path = "src/main.rs" [dependencies.nebula_terminal] path = "../nebula_terminal" -version = "1.8.1" +version = "1.8.2" [dependencies.nebula_config_derive] path = "../nebula_config_derive" -version = "1.8.1" +version = "1.8.2" [dependencies.nebula_config] path = "../nebula_config" -version = "1.8.1" +version = "1.8.2" # 可选:GPUI UI 层(feature = "gpui-shell",模块 src/gpui_shell/)。 # GPUI/component 的版本和 Git 来源由根 Cargo.toml 统一精确固定。 @@ -67,7 +67,7 @@ optional = true [dependencies.nebula-completions] path = "../nebula-completions" -version = "1.8.1" +version = "1.8.2" [dependencies] ahash = { version = "0.8.6", features = ["no-rng"] } @@ -145,7 +145,7 @@ rusqlite = { version = "0.40.2", features = ["bundled"] } sha1 = "0.11.0" [build-dependencies] -nebula-settings = { path = "../nebula_settings", version = "1.8.1" } +nebula-settings = { path = "../nebula_settings", version = "1.8.2" } gl_generator = { version = "0.14.0", optional = true } serde = "1" serde_json = "1" diff --git a/nebula_app/src/cli.rs b/nebula_app/src/cli.rs index 7e64f95..8d00542 100644 --- a/nebula_app/src/cli.rs +++ b/nebula_app/src/cli.rs @@ -166,6 +166,15 @@ pub struct TerminalOptions { #[clap(long, value_hint = ValueHint::FilePath)] pub working_directory: Option, + /// Start this shell instead of the configured default one, by the same id the + /// `shell` setting uses (`pwsh`, `cmd`, `wsl:Ubuntu`, or a profile's settings id). + /// + /// The Explorer menu uses it to open a folder in a specific WSL distribution. + /// An id that no longer resolves still starts `wsl.exe` for `wsl:` + /// rather than silently falling back to the default shell. + #[clap(long, value_name = "ID", conflicts_with = "command")] + pub shell: Option, + /// Remain open after child process exit. #[clap(long)] pub hold: bool, @@ -209,6 +218,14 @@ impl TerminalOptions { self.working_directory.clone().map(repair_context_menu_dir) } + /// Shell id requested on the command line, normalized: blank counts as absent. + /// + /// Kept as an id (not a program path) so every launch re-resolves the + /// executable, exactly like the `shell` setting does. + pub fn shell_id(&self) -> Option { + self.shell.as_deref().map(str::trim).filter(|shell| !shell.is_empty()).map(str::to_owned) + } + /// Override the [`PtyOptions`]'s fields with the [`TerminalOptions`]. pub fn override_pty_config(&self, pty_config: &mut PtyOptions) { if let Some(working_directory) = self.resolved_working_directory() { @@ -1738,6 +1755,50 @@ mod tests { ); } + /// 右键菜单那条命令行的形状:`--shell` 必须排在 `--working-directory` **之前**。 + /// + /// 这不是风格问题。`--working-directory "D:\"` 的收尾反斜杠会吃掉它的收尾 + /// 引号(issue #36),若它排在前头,`CommandLineToArgvW` 会把 `--shell` 整段 + /// 并进那个参数——目录变成垃圾、shell 被吞掉,而 clap 依然认为解析成功, + /// 最后开出一个没有 cwd 的默认 shell 标签。安装器里的书写顺序由 + /// `scripts/tests/installer.tests.ps1` 的断言钉住,这里钉住解析侧。 + #[test] + fn shell_flag_parses_ahead_of_the_working_directory() { + let options = Options::try_parse_from([ + "pebrel", + "--gpui", + "--shell", + "wsl:Ubuntu", + "--working-directory", + "D:\"", + ]) + .expect("context-menu argv parses"); + let terminal = &options.window_options.terminal_options; + assert_eq!(terminal.shell_id().as_deref(), Some("wsl:Ubuntu")); + let expected = if cfg!(windows) { "D:\\" } else { "D:\"" }; + assert_eq!(terminal.resolved_working_directory(), Some(PathBuf::from(expected))); + } + + /// `--shell` selects a saved shell identity; `-e` supplies a command directly. + /// Reject their combination instead of silently ignoring either explicit request. + #[test] + fn shell_and_command_conflict() { + assert!( + Options::try_parse_from(["pebrel", "--gpui", "--shell", "pwsh", "-e", "cmd"]).is_err() + ); + } + + /// 空白/空的 `--shell` 当作没给:脚本传了空串时不该去解析一个空 id, + /// 更不该因此报错挡住启动。 + #[test] + fn blank_shell_id_counts_as_absent() { + for value in ["", " "] { + let options = Options::try_parse_from(["pebrel", "--gpui", "--shell", value]) + .expect("blank shell parses"); + assert_eq!(options.window_options.terminal_options.shell_id(), None); + } + } + #[test] fn valid_decimal() { let value = parse_hex_or_decimal("10485773"); diff --git a/nebula_app/src/event/runtime_control.rs b/nebula_app/src/event/runtime_control.rs index 239e387..294a086 100644 --- a/nebula_app/src/event/runtime_control.rs +++ b/nebula_app/src/event/runtime_control.rs @@ -103,7 +103,16 @@ impl Processor { match command { RuntimeCommand::Snapshot => serde_json::to_value(self.publish_runtime_snapshot()) .map_err(|error| ApiError::new("serialization_failed", error.to_string())), - RuntimeCommand::NewWindow { cwd: _ } => { + // 旧壳(winit/GL)没有"按 id 选 shell"的能力。显式拒绝而不是忽略: + // 调用方(右键菜单的第二份进程)拿到失败会冷启动一份 GPUI 实例, + // 用户最终得到的是他要的那个发行版;静默忽略则会在旧壳里开一个 + // 默认 shell 的标签,正是这条功能要消灭的失败。 + RuntimeCommand::NewWindow { shell_id: Some(_), .. } + | RuntimeCommand::NewTab { shell_id: Some(_), .. } => Err(ApiError::new( + "invalid_shell", + "this runtime does not support shell selection on tab.new/window.create", + )), + RuntimeCommand::NewWindow { cwd: _, .. } => { // GL backends require every current context to be released // before another window surface is created. for window in self.windows.values_mut() { @@ -145,7 +154,7 @@ impl Processor { "pane_id": pane_id })) }, - RuntimeCommand::NewTab { window_id, cwd } => { + RuntimeCommand::NewTab { window_id, cwd, .. } => { let id = self.runtime_target_window(*window_id, None)?; let window = self.windows.get_mut(&id).expect("resolved runtime window exists"); let pane_id = window.runtime_new_tab(cwd.clone())?; diff --git a/nebula_app/src/gpui_shell/mod.rs b/nebula_app/src/gpui_shell/mod.rs index 37aba9c..29dd9a9 100644 --- a/nebula_app/src/gpui_shell/mod.rs +++ b/nebula_app/src/gpui_shell/mod.rs @@ -78,6 +78,7 @@ pub(crate) enum GpuiShellEvent { pub fn run_shell( initial_cwd: Option, initial_command: Option, + shell_id: Option, ) { if crate::platform::CAPABILITIES.self_update_install { match crate::update_download::handoff::installation_in_progress() { @@ -142,12 +143,17 @@ pub fn run_shell( let behavior = nebula_settings::RuntimeSettings::load().windowing_behavior; let handed_over = match behavior { nebula_settings::WindowingBehaviorName::UseNew => { - crate::runtime_api::try_open_window_existing(handover_cwd.as_deref()) + crate::runtime_api::try_open_window_existing( + handover_cwd.as_deref(), + shell_id.as_deref(), + ) + }, + _ => match handover_cwd.as_deref() { + Some(dir) => { + crate::runtime_api::try_open_directory_existing(dir, shell_id.as_deref()) + }, + None => crate::runtime_api::try_open_default_tab_existing(shell_id.as_deref()), }, - _ => handover_cwd.as_deref().map_or_else( - crate::runtime_api::try_open_default_tab_existing, - crate::runtime_api::try_open_directory_existing, - ), }; if handed_over { crate::tray::shutdown(); @@ -175,7 +181,7 @@ pub fn run_shell( { cx.activate(true); } - open_main_window(cx, ai_events, shell_rx, runtime_hub, initial_cwd, initial_command); + open_main_window(cx, ai_events, shell_rx, runtime_hub, initial_cwd, initial_command, shell_id); }); crate::tray::shutdown(); } @@ -256,6 +262,7 @@ fn open_main_window( runtime_hub: crate::runtime_api::RuntimeHub, initial_cwd: Option, initial_command: Option, + shell_id: Option, ) { workspace::windowing::initialize(cx, runtime_hub); workspace::windowing::open_initial_window( @@ -264,6 +271,7 @@ fn open_main_window( shell_events, initial_cwd, initial_command, + shell_id, ); } diff --git a/nebula_app/src/gpui_shell/workspace.rs b/nebula_app/src/gpui_shell/workspace.rs index 6218e42..62c4939 100644 --- a/nebula_app/src/gpui_shell/workspace.rs +++ b/nebula_app/src/gpui_shell/workspace.rs @@ -68,6 +68,7 @@ mod residency; mod send_to_chat; mod session_persistence; mod session_recovery; +pub(crate) mod shell_launch; mod shell_picker; use shell_picker::shell_palette_rows; mod settings_navigation; @@ -1111,7 +1112,7 @@ impl NebulaWorkspace { windowing::WorkspaceStartup::NewTerminal { cwd } => { this.add_terminal_at(cwd, None, window, cx); }, - windowing::WorkspaceStartup::LaunchTerminal { launch, cwd } => { + windowing::WorkspaceStartup::LaunchTerminal { cwd, launch } => { this.add_terminal_with(launch, cwd, None, window, cx); }, windowing::WorkspaceStartup::Empty => {}, @@ -1192,52 +1193,6 @@ impl NebulaWorkspace { crate::shell_detect::shell_short_tag(&crate::platform::shell::default_shell_id()).into() } - /// 冻结“新建这一刻”的默认 Shell 为共享 v4 launch 身份。 - /// - /// 旧壳通过 `TabLaunch::Shell` 保存同样的 name/program/args;GPUI 以前只 - /// 保存 UI 短标,冷恢复时因此失去了真正的启动命令。检测失败才保留 - /// `Default`,让跨机器工作区按 schema 的既有降级规则使用当地默认值。 - fn configured_local_launch(cx: &App) -> crate::session::LaunchSession { - let shell_id = cx - .try_global::() - .and_then(|settings| settings.shell_id.clone()); - let Some(shell_id) = shell_id.filter(|id| !id.trim().is_empty()) else { - return crate::session::LaunchSession::Default; - }; - if let Some(detected) = crate::shell_detect::detect_shells() - .into_iter() - .find(|shell| shell.id.eq_ignore_ascii_case(&shell_id)) - { - let shell = detected.shell(); - return crate::session::LaunchSession::Shell { - name: detected.name, - program: shell.program().to_owned(), - args: shell.args().to_vec(), - }; - } - crate::terminal_profiles::TerminalProfiles::load() - .ok() - .and_then(|store| { - store.as_config_profiles().into_iter().find(|profile| { - profile.settings_id().is_some_and(|id| id.eq_ignore_ascii_case(&shell_id)) - }) - }) - .map(Self::profile_launch_session) - .unwrap_or(crate::session::LaunchSession::Default) - } - - fn profile_launch_session( - profile: crate::config::ui_config::Profile, - ) -> crate::session::LaunchSession { - crate::session::LaunchSession::Profile { - name: profile.name, - command: profile.command, - args: profile.args, - cwd: profile.cwd.map(|path| path.to_string_lossy().into_owned()), - shell_id: profile.shell_id, - } - } - /// 把一份冻结的会话 launch 还原为一次 GPUI PTY 启动。逐 pane 选择 /// 与旧快照回退由 session_recovery 统一负责。 fn terminal_launch_from_session( @@ -1378,7 +1333,7 @@ impl NebulaWorkspace { // 默认 shell 只在“创建新 Tab”的这一刻取样,并把实际 program/args // 一起冻结进 Tab launch。设置页随后改默认值只影响下一次创建;冷 // 恢复也按本 Tab 的 launch 重建,不会把混合工作区抹成同一种 shell。 - let launch_session = Self::configured_local_launch(cx); + let launch_session = shell_launch::configured_local_launch(cx); self.add_terminal_with(launch_session, cwd, command, window, cx) } @@ -2652,7 +2607,7 @@ impl NebulaWorkspace { cx: &mut Context, ) { self.dismiss_palette_state(); - let launch = Self::profile_launch_session(profile); + let launch = shell_launch::profile_launch_session(profile); let cwd = Self::startup_directory().or_else(|| { self.tabs .get(self.active) diff --git a/nebula_app/src/gpui_shell/workspace/agents.rs b/nebula_app/src/gpui_shell/workspace/agents.rs index ff82c2c..8c5027b 100644 --- a/nebula_app/src/gpui_shell/workspace/agents.rs +++ b/nebula_app/src/gpui_shell/workspace/agents.rs @@ -339,7 +339,7 @@ impl NebulaWorkspace { // Default 与旧壳一致取「分叉这一刻」的默认 shell,不是源 tab // 创建时的快照。 None | Some(crate::session::LaunchSession::Default) => { - Self::configured_local_launch(cx) + super::shell_launch::configured_local_launch(cx) }, Some(shell @ crate::session::LaunchSession::Shell { .. }) => shell, // Profile 可能直接把 agent 当启动命令,SSH 会把命令注入认证 diff --git a/nebula_app/src/gpui_shell/workspace/launcher_menu.rs b/nebula_app/src/gpui_shell/workspace/launcher_menu.rs index 0b83e94..56e9165 100644 --- a/nebula_app/src/gpui_shell/workspace/launcher_menu.rs +++ b/nebula_app/src/gpui_shell/workspace/launcher_menu.rs @@ -56,7 +56,7 @@ impl LauncherTarget { }) }, Self::Profile(profile) => { - Some(NebulaWorkspace::profile_launch_session(profile.clone())) + Some(super::shell_launch::profile_launch_session(profile.clone())) }, Self::Ssh(_) => None, } diff --git a/nebula_app/src/gpui_shell/workspace/residency.rs b/nebula_app/src/gpui_shell/workspace/residency.rs index 3cea446..b15a1f3 100644 --- a/nebula_app/src/gpui_shell/workspace/residency.rs +++ b/nebula_app/src/gpui_shell/workspace/residency.rs @@ -225,9 +225,20 @@ impl NebulaWorkspace { cx, ) }, - RuntimeCommand::NewTab { window_id, cwd } => { + RuntimeCommand::NewTab { window_id, cwd, shell_id } => { self.runtime_window_requested(*window_id)?; - let pane_id = self.add_terminal_at(cwd.clone(), None, window, cx); + // 带 shell 的请求(右键「在 Pebrel 中打开(Ubuntu)」并入驻留实例) + // 必须走 add_terminal_with:add_terminal_at 只认目录,会把 shell + // 悄悄丢掉,用户拿到一个自己没要过的 PowerShell 标签。 + let pane_id = match shell_id { + Some(shell_id) => { + let launch = + super::shell_launch::resolve_shell_at(shell_id, cwd.as_deref()) + .map_err(|error| ApiError::new("invalid_shell", error))?; + self.add_terminal_with(launch, cwd.clone(), None, window, cx) + }, + None => self.add_terminal_at(cwd.clone(), None, window, cx), + }; self.runtime_result( json!({ "window_id": self.runtime_window_id, "pane_id": pane_id }), window, diff --git a/nebula_app/src/gpui_shell/workspace/session_recovery.rs b/nebula_app/src/gpui_shell/workspace/session_recovery.rs index c360290..5edfc07 100644 --- a/nebula_app/src/gpui_shell/workspace/session_recovery.rs +++ b/nebula_app/src/gpui_shell/workspace/session_recovery.rs @@ -96,10 +96,14 @@ impl NebulaWorkspace { for (index, leaf) in layout.leaves().into_iter().enumerate() { let LayoutSession::Pane { cwd, agent, launch } = leaf else { continue }; let mut launch_session = launch.clone().unwrap_or_else(|| { - if index == 0 { saved_launch.clone() } else { Self::configured_local_launch(cx) } + if index == 0 { + saved_launch.clone() + } else { + super::shell_launch::configured_local_launch(cx) + } }); if matches!(launch_session, LaunchSession::Default) { - launch_session = Self::configured_local_launch(cx); + launch_session = super::shell_launch::configured_local_launch(cx); } let guest_directory = tab_duplication::inherit_guest_directory(&mut launch_session, cwd); diff --git a/nebula_app/src/gpui_shell/workspace/shell_launch.rs b/nebula_app/src/gpui_shell/workspace/shell_launch.rs new file mode 100644 index 0000000..4b3a89b --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/shell_launch.rs @@ -0,0 +1,231 @@ +//! shell id → 启动身份:`shell=` 设置、`--shell` 与 IPC 的 `shell` 共用这一份解析。 +//! +//! 独立成模块有两个理由。其一,`main.rs` 要在开窗**之前**校验一次 `--shell`, +//! 而这段解析不需要 `NebulaWorkspace` 的任何状态;其二,`workspace.rs` 早就贴着 +//! 仓库的行数上限,一个能独立成篇的解析器不该继续往里塞(见 +//! `architecture/file-budgets.txt`)。 +//! +//! 两道语义上的分界,改这里之前先读: +//! +//! * **显式请求不静默降级**。[`resolve_shell_id`] 解析不出来返回错误,只有设置项 +//! 那条路([`configured_local_launch`])才把它吞成默认 shell。 +//! * **`wsl:<发行版>` 永远落得下去**。枚举不到也合成 `wsl.exe -d <发行版>`, +//! 让 wsl.exe 自己报错,而不是换成用户没要的 PowerShell。 + +use gpui::App; +use std::path::Path; + +/// Resolve one explicit request, applying its directory only to the launch snapshot. +pub(super) fn resolve_shell_at( + shell_id: &str, + cwd: Option<&Path>, +) -> Result { + resolve_shell_id(shell_id).map(|launch| at_explicit_directory(launch, cwd)) +} + +fn at_explicit_directory( + mut launch: crate::session::LaunchSession, + cwd: Option<&Path>, +) -> crate::session::LaunchSession { + let Some(cwd) = cwd else { return launch }; + let (program, args) = match &mut launch { + crate::session::LaunchSession::Profile { command, args, cwd: saved_cwd, .. } => { + *saved_cwd = Some(cwd.to_string_lossy().into_owned()); + (command, args) + }, + crate::session::LaunchSession::Shell { program, args, .. } => (program, args), + _ => return launch, + }; + // A WSL profile's old --cd must not override the explicit host working directory. + if let Some(inherited) = crate::shell_detect::wsl_args_in_host_directory(program, args) { + *args = inherited; + } + launch +} + +/// 冻结“新建这一刻”的默认 Shell 为共享 v4 launch 身份。 +/// +/// 旧壳通过 `TabLaunch::Shell` 保存同样的 name/program/args;GPUI 以前只 +/// 保存 UI 短标,冷恢复时因此失去了真正的启动命令。检测失败才保留 +/// `Default`,让跨机器工作区按 schema 的既有降级规则使用当地默认值。 +pub(super) fn configured_local_launch(cx: &App) -> crate::session::LaunchSession { + let shell_id = cx + .try_global::() + .and_then(|settings| settings.shell_id.clone()); + match shell_id.filter(|id| !id.trim().is_empty()) { + // 设置项是"记着的偏好":它在别的机器上落空(配置同步、发行版改名) + // 按既有契约回落默认 shell,不打扰用户。 + Some(shell_id) => { + resolve_shell_id(&shell_id).unwrap_or(crate::session::LaunchSession::Default) + }, + None => crate::session::LaunchSession::Default, + } +} + +/// 把一个 shell id 解析成启动身份。 +/// +/// 顺序:本机检测到的 shell(`pwsh`、`wsl:Ubuntu`…)→ `terminal_profiles.json` +/// 里导入/手建的入口(`Profile::settings_id()`)→ WSL 的合成兜底 +/// ([`wsl_launch_for_id`])。 +/// +/// 解析不出来返回错误,**不**回落默认 shell:设置项那条路( +/// [`configured_local_launch`])自己决定要不要吞掉这个错,而显式请求 +/// (`--shell`、IPC 的 `shell`)必须让调用方看见——静默把"用 Ubuntu 打开" +/// 变成 PowerShell 标签是最难查的一类失败。 +pub(crate) fn resolve_shell_id(shell_id: &str) -> Result { + let requested = shell_id.trim(); + if requested.is_empty() { + return Err("--shell needs a shell id".to_owned()); + } + if let Some(detected) = crate::shell_detect::detect_shells() + .into_iter() + .find(|shell| shell.id.eq_ignore_ascii_case(requested)) + { + let shell = detected.shell(); + return Ok(crate::session::LaunchSession::Shell { + name: detected.name, + program: shell.program().to_owned(), + args: shell.args().to_vec(), + }); + } + if let Some(launch) = crate::terminal_profiles::TerminalProfiles::load() + .ok() + .and_then(|store| profile_launch_for_id(store.as_config_profiles(), requested)) + { + return Ok(launch); + } + if let Some(launch) = wsl_launch_for_id(requested) { + return Ok(launch); + } + Err(format!("unknown shell id \"{requested}\"; available: {}", shell_id_menu().join(", "))) +} + +// Imported profile ids are opaque and may include case-sensitive directory names. +// Only detected shell aliases above allow case-insensitive matching. +fn profile_launch_for_id( + profiles: Vec, + requested: &str, +) -> Option { + profiles + .into_iter() + .find(|profile| profile.settings_id().as_deref() == Some(requested)) + .map(profile_launch_session) +} + +/// 报错时列给用户的可选 id(检测到的 shell + 磁盘上的导入入口)。 +fn shell_id_menu() -> Vec { + let mut ids: Vec = + crate::shell_detect::detect_shells().into_iter().map(|shell| shell.id).collect(); + if let Ok(store) = crate::terminal_profiles::TerminalProfiles::load() { + ids.extend(store.as_config_profiles().iter().filter_map(|profile| profile.settings_id())); + } + ids +} + +/// WSL id 的兜底:`detect_shells()` 枚举不到也照样起得来。 +/// +/// 枚举结果只覆盖**已注册**的发行版。名字被改、注册表还没刷新、或菜单项是 +/// 更早一次安装留下的,都会走到这里。此时仍然合成 `wsl.exe -d <发行版>`: +/// 让 `wsl.exe` 在自己的窗口里报"没有这个发行版",而不是悄悄换成一个用户 +/// 没要的 PowerShell 标签——后者才是真正难查的那种失败。 +/// +/// 裸 `wsl` 同理由 `wsl.exe` 自己挑系统默认发行版。 +fn wsl_launch_for_id(shell_id: &str) -> Option { + let (name, args) = wsl_launch_args(shell_id)?; + Some(crate::session::LaunchSession::Shell { + name, + program: crate::platform::shell::wsl_executable()?, + args, + }) +} + +pub(super) fn profile_launch_session( + profile: crate::config::ui_config::Profile, +) -> crate::session::LaunchSession { + crate::session::LaunchSession::Profile { + name: profile.name, + command: profile.command, + args: profile.args, + cwd: profile.cwd.map(|path| path.to_string_lossy().into_owned()), + shell_id: profile.shell_id, + } +} + +/// `wsl:<发行版>` / 裸 `wsl` 的 id 语义:显示名与 argv 尾部。 +/// +/// 与可执行文件的查找分开,好让 id 语义能脱离"本机装没装 WSL"来测。 +/// 只认 `wsl:` 前缀(大小写不敏感);只写 `wsl:` 不给发行版名不算命中, +/// 交给调用方回落——`wsl.exe` 没有"空发行版"这回事。 +fn wsl_launch_args(shell_id: &str) -> Option<(String, Vec)> { + let trimmed = shell_id.trim(); + let distro = trimmed + .get(..4) + .filter(|prefix| prefix.eq_ignore_ascii_case("wsl:")) + .map(|_| trimmed[4..].trim()) + .filter(|distro| !distro.is_empty()); + match distro { + Some(distro) => Some((format!("WSL · {distro}"), vec!["-d".to_owned(), distro.to_owned()])), + None if trimmed.eq_ignore_ascii_case("wsl") => Some(("WSL".to_owned(), Vec::new())), + None => None, + } +} + +#[cfg(test)] +mod tests { + + #[test] + fn profile_ids_preserve_case_sensitive_directory_identity() { + let profiles: Vec<_> = ["Project", "project"] + .into_iter() + .map(|name| crate::config::ui_config::Profile { + name: name.to_owned(), + command: "shell".into(), + args: Vec::new(), + cwd: None, + shell_id: Some("sh".into()), + terminal_profile_id: Some(format!("qa-{name}")), + }) + .collect(); + for name in ["Project", "project"] { + let launch = + super::profile_launch_for_id(profiles.clone(), &format!("profile:sh|qa-{name}")) + .unwrap(); + assert!( + matches!(launch, crate::session::LaunchSession::Profile { name: actual, .. } if actual == name) + ); + } + assert!(super::profile_launch_for_id(profiles, "profile:sh|qa-PROJECT").is_none()); + } + + /// `--shell` 的 WSL id 语义:`wsl:<发行版>` 必须落成一条真会跑的 + /// `wsl.exe -d <发行版>`,而不是"解析不到就回落默认 shell"——右键点「Ubuntu」 + /// 却开出 PowerShell 是这个功能最容易出的错,而且没有任何提示。 + /// + /// 这里只测 id 解析(纯函数)。可执行文件的查找(`wsl_executable()`)与真正的 + /// 启动留给端到端验证,免得测试依赖"本机装了 WSL"。 + #[test] + fn wsl_shell_ids_resolve_to_a_distro_launch() { + assert_eq!( + super::wsl_launch_args("wsl:Ubuntu"), + Some(("WSL · Ubuntu".to_owned(), vec!["-d".to_owned(), "Ubuntu".to_owned()])) + ); + // id 大小写不敏感(`detect_shells()` 给的是 `wsl:<原名>`,注册表/手写可能不同)。 + assert_eq!(super::wsl_launch_args("WSL:Ubuntu"), super::wsl_launch_args("wsl:Ubuntu")); + // 发行版名里的空格保留,只去掉外围空白。 + assert_eq!( + super::wsl_launch_args(" wsl:Team Linux "), + Some(("WSL · Team Linux".to_owned(), vec!["-d".to_owned(), "Team Linux".to_owned()])) + ); + // 裸 `wsl` = 系统默认发行版(交给 wsl.exe 自己挑),不是"没有 shell"。 + assert_eq!(super::wsl_launch_args("wsl"), Some(("WSL".to_owned(), Vec::new()))); + assert_eq!(super::wsl_launch_args("WSL"), Some(("WSL".to_owned(), Vec::new()))); + // 只有前缀没有发行版名、以及别的 id 都不算命中,交给调用方去报错。 + assert_eq!(super::wsl_launch_args("wsl:"), None); + assert_eq!(super::wsl_launch_args("wsl: "), None); + assert_eq!(super::wsl_launch_args("pwsh"), None); + assert_eq!(super::wsl_launch_args(""), None); + } +} + +#[cfg(test)] +mod directory_tests; diff --git a/nebula_app/src/gpui_shell/workspace/shell_launch/directory_tests.rs b/nebula_app/src/gpui_shell/workspace/shell_launch/directory_tests.rs new file mode 100644 index 0000000..82448fa --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/shell_launch/directory_tests.rs @@ -0,0 +1,90 @@ +use super::*; +use crate::gpui_shell::terminal::view::TerminalLaunch; +use crate::gpui_shell::workspace::NebulaWorkspace; +use crate::session::LaunchSession; + +#[test] +fn explicit_directory_reaches_the_terminal_without_changing_the_saved_profile() { + let saved = tempfile::tempdir().unwrap(); + let requested = tempfile::tempdir().unwrap(); + let profile = LaunchSession::Profile { + name: "Project shell".into(), + command: "custom-shell".into(), + args: vec!["--login".into()], + cwd: Some(saved.path().to_string_lossy().into_owned()), + shell_id: Some("custom".into()), + }; + let snapshot = at_explicit_directory(profile.clone(), Some(requested.path())); + let TerminalLaunch::Local { cwd, shell, .. } = + NebulaWorkspace::terminal_launch_from_session(&snapshot, Some(requested.path().into())) + else { + panic!("expected a local terminal"); + }; + assert_eq!(cwd.as_deref(), Some(requested.path())); + let shell = shell.unwrap(); + assert_eq!(shell.program(), "custom-shell"); + assert_eq!(shell.args(), &["--login"]); + + // Starting the same saved profile again without an override keeps its default. + let unchanged = at_explicit_directory(profile.clone(), None); + assert_eq!(unchanged, profile); + let TerminalLaunch::Local { cwd, .. } = + NebulaWorkspace::terminal_launch_from_session(&unchanged, None) + else { + panic!("expected a local terminal"); + }; + assert_eq!(cwd.as_deref(), Some(saved.path())); +} + +#[test] +fn explicit_host_directory_replaces_wsl_profile_directory_and_keeps_guest_arguments() { + let requested = tempfile::tempdir().unwrap(); + let args = [ + "-d", + "Team Linux", + "--cd", + "/old", + "--user", + "guest", + "--cd=/older", + "--exec", + "zsh", + "--cd", + "/guest-option", + ] + .map(String::from) + .to_vec(); + for launch in [ + LaunchSession::Profile { + name: "WSL project".into(), + command: "wsl.exe".into(), + args: args.clone(), + cwd: None, + shell_id: Some("wsl:Team Linux".into()), + }, + LaunchSession::Shell { + name: "WSL project".into(), + program: "wsl.exe".into(), + args: args.clone(), + }, + ] { + assert_eq!(at_explicit_directory(launch.clone(), None), launch); + let snapshot = at_explicit_directory(launch, Some(requested.path())); + let TerminalLaunch::Local { cwd, shell, .. } = + NebulaWorkspace::terminal_launch_from_session(&snapshot, Some(requested.path().into())) + else { + panic!("expected WSL to launch through the local PTY"); + }; + assert_eq!(cwd.as_deref(), Some(requested.path())); + assert_eq!( + shell.unwrap().args(), + &["-d", "Team Linux", "--user", "guest", "--exec", "zsh", "--cd", "/guest-option",] + ); + } +} + +#[test] +fn directory_override_cannot_turn_an_invalid_shell_request_into_a_default_shell() { + let requested = tempfile::tempdir().unwrap(); + assert!(resolve_shell_at("pebrel-missing-shell-id", Some(requested.path())).is_err()); +} diff --git a/nebula_app/src/gpui_shell/workspace/tab_duplication.rs b/nebula_app/src/gpui_shell/workspace/tab_duplication.rs index b1ea7e7..053aed8 100644 --- a/nebula_app/src/gpui_shell/workspace/tab_duplication.rs +++ b/nebula_app/src/gpui_shell/workspace/tab_duplication.rs @@ -35,7 +35,9 @@ impl NebulaWorkspace { } else { // Old snapshots without an identity resolve the current default shell. let mut launch = match meta.launch { - None | Some(LaunchSession::Default) => Self::configured_local_launch(cx), + None | Some(LaunchSession::Default) => { + super::shell_launch::configured_local_launch(cx) + }, Some(launch) => launch, }; // A guest path must never become CreateProcess's host working directory. diff --git a/nebula_app/src/gpui_shell/workspace/windowing.rs b/nebula_app/src/gpui_shell/workspace/windowing.rs index 41fce6d..cf77ae0 100644 --- a/nebula_app/src/gpui_shell/workspace/windowing.rs +++ b/nebula_app/src/gpui_shell/workspace/windowing.rs @@ -264,6 +264,7 @@ pub(crate) fn open_initial_window( shell_events: std::sync::mpsc::Receiver, initial_cwd: Option, initial_command: Option, + shell_id: Option, ) { if !crate::platform::elevation::requires_isolation() && let Some(sessions) = crate::update_download::handoff::restore_ticket() @@ -294,11 +295,21 @@ pub(crate) fn open_initial_window( return; } } - let startup = initial_startup( - initial_cwd, - initial_command, - crate::platform::elevation::requires_isolation(), - ); + let startup = if shell_id.is_some() { + match runtime_window_startup(initial_cwd, shell_id.as_deref()) { + Ok(startup) => startup, + Err(error) => { + log::error!("Could not resolve requested shell: {error:?}"); + return; + }, + } + } else { + initial_startup( + initial_cwd, + initial_command, + crate::platform::elevation::requires_isolation(), + ) + }; open_workspace_window( cx, startup, @@ -338,6 +349,24 @@ fn initial_startup( mod startup_tests { use super::*; + #[cfg(feature = "gpui-test-support")] + #[gpui::test] + fn invalid_explicit_shell_cannot_create_a_runtime_window(cx: &mut gpui::TestAppContext) { + let error = cx + .update(|cx| open_runtime_window(cx, None, Some("pebrel-missing-shell-id".into()))) + .unwrap_err(); + assert_eq!(error.code, "invalid_shell"); + assert!(cx.update(|cx| cx.windows().is_empty())); + } + + #[test] + fn default_runtime_startup_keeps_the_requested_directory() { + let cwd = Some(PathBuf::from("project")); + assert!( + matches!(runtime_window_startup(cwd.clone(), None).unwrap(), WorkspaceStartup::NewTerminal { cwd: actual } if actual == cwd) + ); + } + #[test] fn explicit_program_is_kept_with_its_arguments_and_directory() { let command = crate::config::ui_config::Program::WithArgs { @@ -554,15 +583,28 @@ pub(crate) fn open_new_window(cx: &mut App, cwd: Option) -> gpui::Resul Ok((window_id, pane_id)) } -fn open_runtime_window(cx: &mut App, cwd: Option) -> gpui::Result<(u64, u64)> { - let (window_id, workspace) = open_workspace_window( - cx, - WorkspaceStartup::NewTerminal { cwd }, - None, - None, - false, - WindowRole::Regular, - )?; +/// Resolve explicit shell identity before any window or terminal is created. +fn runtime_window_startup( + cwd: Option, + shell_id: Option<&str>, +) -> Result { + match shell_id { + Some(id) => super::shell_launch::resolve_shell_at(id, cwd.as_deref()) + .map(|launch| WorkspaceStartup::LaunchTerminal { cwd, launch }) + .map_err(|error| ApiError::new("invalid_shell", error)), + None => Ok(WorkspaceStartup::NewTerminal { cwd }), + } +} + +fn open_runtime_window( + cx: &mut App, + cwd: Option, + shell_id: Option, +) -> Result<(u64, u64), ApiError> { + let startup = runtime_window_startup(cwd, shell_id.as_deref())?; + let (window_id, workspace) = + open_workspace_window(cx, startup, None, None, false, WindowRole::Regular) + .map_err(|error| ApiError::new("window_create_failed", error.to_string()))?; let pane_id = workspace.read(cx).active_terminal_pane_id().unwrap_or_default(); Ok((window_id, pane_id)) } @@ -971,32 +1013,32 @@ fn dispatch_runtime(dispatch: Arc, cx: &mut App) { ); return; }, - RuntimeCommand::NewWindow { cwd } => { - let response = open_runtime_window(cx, cwd.clone()) - .map_err(|error| ApiError::new("window_create_failed", error.to_string())) - .map(|(window_id, pane_id)| { + RuntimeCommand::NewWindow { cwd, shell_id } => { + let response = open_runtime_window(cx, cwd.clone(), shell_id.clone()).map( + |(window_id, pane_id)| { let snapshot = publish_runtime_snapshot(cx); json!({ "action": { "window_id": window_id, "pane_id": pane_id }, "snapshot": snapshot }) - }); + }, + ); dispatch.respond(response); return; }, - RuntimeCommand::NewTab { window_id: None, cwd } + RuntimeCommand::NewTab { window_id: None, cwd, shell_id } if nebula_settings::RuntimeSettings::load().windowing_behavior == nebula_settings::WindowingBehaviorName::UseNew => { - let response = open_runtime_window(cx, cwd.clone()) - .map_err(|error| ApiError::new("window_create_failed", error.to_string())) - .map(|(window_id, pane_id)| { + let response = open_runtime_window(cx, cwd.clone(), shell_id.clone()).map( + |(window_id, pane_id)| { let snapshot = publish_runtime_snapshot(cx); json!({ "action": { "window_id": window_id, "pane_id": pane_id }, "snapshot": snapshot }) - }); + }, + ); dispatch.respond(response); return; }, @@ -1036,27 +1078,21 @@ fn dispatch_runtime(dispatch: Arc, cx: &mut App) { let entry = match route_entry(&dispatch.command, cx) { Ok(entry) => entry, - Err(error) + Err(_error) if matches!(dispatch.command, RuntimeCommand::NewTab { window_id: None, .. }) => { - let RuntimeCommand::NewTab { cwd, .. } = &dispatch.command else { unreachable!() }; - let response = open_runtime_window(cx, cwd.clone()) - .map_err(|create| { - ApiError::new( - "window_create_failed", - format!( - "{}: {}; creating a fallback window also failed: {create}", - error.code, error.message - ), - ) - }) - .map(|(window_id, pane_id)| { + let RuntimeCommand::NewTab { cwd, shell_id, .. } = &dispatch.command else { + unreachable!() + }; + let response = open_runtime_window(cx, cwd.clone(), shell_id.clone()).map( + |(window_id, pane_id)| { let snapshot = publish_runtime_snapshot(cx); json!({ "action": { "window_id": window_id, "pane_id": pane_id }, "snapshot": snapshot }) - }); + }, + ); dispatch.respond(response); return; }, @@ -1823,14 +1859,14 @@ mod tests { RuntimeWindowPolicy::Focus ); assert_eq!( - runtime_window_policy(&RuntimeCommand::NewWindow { cwd: None }), + runtime_window_policy(&RuntimeCommand::NewWindow { cwd: None, shell_id: None }), RuntimeWindowPolicy::CreateWithoutActivation ); let preserve = vec![ RuntimeCommand::Snapshot, RuntimeCommand::CloseWindow { window_id: Some(1) }, - RuntimeCommand::NewTab { window_id: Some(1), cwd: None }, + RuntimeCommand::NewTab { window_id: Some(1), cwd: None, shell_id: None }, RuntimeCommand::CloseTab { window_id: Some(1), tab_index: 0 }, RuntimeCommand::RenameTab { window_id: Some(1), diff --git a/nebula_app/src/main.rs b/nebula_app/src/main.rs index 7769f5e..46a9546 100644 --- a/nebula_app/src/main.rs +++ b/nebula_app/src/main.rs @@ -231,19 +231,31 @@ fn main() -> Result<(), Box> { // (ATTACH),不能再拉一套 PTY。 #[cfg(feature = "gpui-shell")] if wants_gpui_shell(&options) { - let initial_cwd = options - .window_options - .terminal_options + let terminal_options = &options.window_options.terminal_options; + let initial_cwd = terminal_options .resolved_working_directory() .filter(|path| path.is_dir()) .and_then(|path| std::path::absolute(path).ok()); + // `--shell `(右键菜单「在 Pebrel 中打开(Ubuntu)」用它)一路带到 + // 首个标签;缺省仍然用设置里的默认 shell。 + let shell_id = terminal_options.shell_id(); platform::startup::prepare_gui(); let _log_file = logging::initialize(&options).expect("Unable to initialize logger"); + // 显式点名的 shell 解析不了就别开窗:悄悄换回默认 shell 是这条路径最该 + // 避免的失败(用户点的是 Ubuntu,拿到的却是 PowerShell)。校验排在交接 + // 之前,坏 id 不会被丢给驻留实例。 + if let Some(shell_id) = shell_id.as_deref() + && let Err(error) = + crate::gpui_shell::workspace::shell_launch::resolve_shell_id(shell_id) + { + platform::startup::report_error(&error, true); + return Err(error.into()); + } #[cfg(windows)] if try_hand_over_to_resident(&options) { return Ok(()); } - gpui_shell::run_shell(initial_cwd, options.window_options.terminal_options.command()); + gpui_shell::run_shell(initial_cwd, terminal_options.command(), shell_id); return Ok(()); } @@ -526,7 +538,9 @@ fn try_hand_over_to_resident(options: &Options) -> bool { if platform::elevation::requires_isolation() { return false; } - let has_command = options.window_options.terminal_options.command().is_some(); + let terminal_options = &options.window_options.terminal_options; + let shell_id = terminal_options.shell_id(); + let has_command = terminal_options.command().is_some(); let launch_dir = options .window_options .terminal_options @@ -539,10 +553,10 @@ fn try_hand_over_to_resident(options: &Options) -> bool { && nebula_settings::RuntimeSettings::load().windowing_behavior == nebula_settings::WindowingBehaviorName::UseNew { - return runtime_api::try_open_window_existing(launch_dir.as_deref()); + return runtime_api::try_open_window_existing(launch_dir.as_deref(), shell_id.as_deref()); } let plain_launch = !options.daemon && launch_dir.is_none() && !has_command; - if plain_launch && runtime_api::try_open_default_tab_existing() { + if plain_launch && runtime_api::try_open_default_tab_existing(shell_id.as_deref()) { return true; } // Explorer 右键「在 Nebula 中打开」带着 --working-directory 走到这里。 @@ -551,7 +565,7 @@ fn try_hand_over_to_resident(options: &Options) -> bool { let dir_launch = !options.daemon && !has_command; if dir_launch && let Some(dir) = launch_dir - && runtime_api::try_open_directory_existing(&dir) + && runtime_api::try_open_directory_existing(&dir, shell_id.as_deref()) { return true; } diff --git a/nebula_app/src/platform/shell.rs b/nebula_app/src/platform/shell.rs index 370d293..35d3768 100644 --- a/nebula_app/src/platform/shell.rs +++ b/nebula_app/src/platform/shell.rs @@ -8,6 +8,20 @@ #[cfg(unix)] use std::path::Path; +/// Locate the host's WSL launcher; unsupported hosts never synthesize a WSL launch. +pub(crate) fn wsl_executable() -> Option { + #[cfg(windows)] + { + let path = + std::path::PathBuf::from(std::env::var_os("SystemRoot")?).join(r"System32\wsl.exe"); + path.is_file().then(|| path.to_string_lossy().into_owned()) + } + #[cfg(not(windows))] + { + None + } +} + /// Stable id for the shell the PTY backend starts when no override is set. pub fn default_shell_id() -> String { #[cfg(windows)] @@ -126,6 +140,15 @@ pub(crate) fn default_wsl_distro() -> Option { mod tests { use super::*; + #[cfg(all(not(windows), feature = "gpui-shell"))] + #[test] + fn unix_rejects_wsl_instead_of_selecting_another_shell() { + assert!(wsl_executable().is_none()); + for id in ["wsl", "wsl:Ubuntu"] { + assert!(crate::gpui_shell::workspace::shell_launch::resolve_shell_id(id).is_err()); + } + } + #[test] fn default_shell_id_is_never_empty() { assert!(!default_shell_id().is_empty()); diff --git a/nebula_app/src/runtime_api.rs b/nebula_app/src/runtime_api.rs index bfa7c18..64940d3 100644 --- a/nebula_app/src/runtime_api.rs +++ b/nebula_app/src/runtime_api.rs @@ -758,6 +758,9 @@ pub enum RuntimeCommand { NewWindow { /// 普通启动 / Explorer 右键要求新开窗口时,把目标目录一路带到首个标签。 cwd: Option, + /// 命令行 `--shell ` 指名要用的 shell;`None` = 用设置里的默认 shell。 + /// 右键菜单「在 Pebrel 中打开(Ubuntu)」靠它把 WSL 意图带进已有实例。 + shell_id: Option, }, CloseWindow { window_id: Option, @@ -769,6 +772,8 @@ pub enum RuntimeCommand { NewTab { window_id: Option, cwd: Option, + /// 见 [`RuntimeCommand::NewWindow::shell_id`]。 + shell_id: Option, }, CloseTab { window_id: Option, diff --git a/nebula_app/src/runtime_api/command.rs b/nebula_app/src/runtime_api/command.rs index ea2d45a..b3bfb8b 100644 --- a/nebula_app/src/runtime_api/command.rs +++ b/nebula_app/src/runtime_api/command.rs @@ -19,6 +19,11 @@ struct WindowParams { /// `tab.new` 可选:新标签的工作目录(Explorer 右键并入驻留实例时携带)。 #[serde(default)] cwd: Option, + /// `tab.new` / `window.create` 可选:新标签用哪个 shell(`shell=` 设置与 + /// `--shell` 同一套 id,如 `wsl:Ubuntu`)。缺省 = 设置里的默认 shell,所以 + /// 老客户端不带这个字段的行为不变。 + #[serde(default)] + shell: Option, } #[derive(Debug, Deserialize)] @@ -222,7 +227,7 @@ impl RuntimeCommand { "runtime.snapshot" => Ok(Self::Snapshot), "window.create" => { let params: WindowParams = parse_params(&request.params)?; - Ok(Self::NewWindow { cwd: params.cwd }) + Ok(Self::NewWindow { cwd: params.cwd, shell_id: params.shell }) }, "window.close" => { let params: WindowTargetParams = parse_params(&request.params)?; @@ -234,7 +239,11 @@ impl RuntimeCommand { }, "tab.new" => { let params: WindowParams = parse_params(&request.params)?; - Ok(Self::NewTab { window_id: params.window_id, cwd: params.cwd }) + Ok(Self::NewTab { + window_id: params.window_id, + cwd: params.cwd, + shell_id: params.shell, + }) }, "tab.close" => { let params: TabTargetParams = parse_params(&request.params)?; diff --git a/nebula_app/src/runtime_api/orchestrate.rs b/nebula_app/src/runtime_api/orchestrate.rs index dd102d7..7dcbafb 100644 --- a/nebula_app/src/runtime_api/orchestrate.rs +++ b/nebula_app/src/runtime_api/orchestrate.rs @@ -675,8 +675,10 @@ fn execute_step( let mut tail_request = None; let command = match step { + // 编排步骤暂不带 shell:`orchestrate` 里开标签用的一直是默认 shell, + // 与 `tab.new` 的 `shell` 参数是两件事(要接的话得同时扩 schema 与文档)。 OrchestrateStep::NewTab { window_id, cwd, .. } => { - RuntimeCommand::NewTab { window_id: *window_id, cwd: cwd.clone() } + RuntimeCommand::NewTab { window_id: *window_id, cwd: cwd.clone(), shell_id: None } }, OrchestrateStep::Focus { id, target } => { let (window_id, pane_id) = resolve_target(id, target, actions)?; diff --git a/nebula_app/src/runtime_api/server.rs b/nebula_app/src/runtime_api/server.rs index f7d6775..b3b9bca 100644 --- a/nebula_app/src/runtime_api/server.rs +++ b/nebula_app/src/runtime_api/server.rs @@ -77,8 +77,8 @@ fn fresh_token() -> String { } /// 普通二次启动并入驻留实例:先恢复/聚焦窗口,再新建一个默认 shell 标签页。 -pub fn try_open_default_tab_existing() -> bool { - try_open_tab_existing(None) +pub fn try_open_default_tab_existing(shell: Option<&str>) -> bool { + try_open_tab_existing(None, shell) } /// 后台任务把一行文本作为输入敲进某个 pane(不回车)。 @@ -96,31 +96,49 @@ pub fn dispatch_prompt(proxy: &EventLoopProxy, pane_id: u64, text: String } /// Explorer 右键或带 `--working-directory` 的启动并入驻留实例。 -pub fn try_open_directory_existing(dir: &std::path::Path) -> bool { - try_open_tab_existing(Some(dir)) +/// +/// `shell` 是命令行 `--shell ` 的原文(如 `wsl:Ubuntu`);驻留实例按同一个 +/// id 解析启动身份。缺省 = 那边的默认 shell。 +pub fn try_open_directory_existing(dir: &std::path::Path, shell: Option<&str>) -> bool { + try_open_tab_existing(Some(dir), shell) } /// 按“创建新窗口”策略把一次普通启动交给驻留进程。 /// /// 仍先发送 ATTACH,保证隐藏驻留进程被唤醒;真正的窗口由同一 GPUI App /// 创建,避免第二个进程争抢 runtime.port 和托盘所有权。 -pub fn try_open_window_existing(dir: Option<&std::path::Path>) -> bool { +pub fn try_open_window_existing(dir: Option<&std::path::Path>, shell: Option<&str>) -> bool { if legacy_request("ATTACH").is_none() { return false; } - let params = dir.map_or_else(|| json!({}), |dir| json!({ "cwd": dir })); - cli::request_once("window.create", params, IO_TIMEOUT) + cli::request_once("window.create", handover_params(dir, shell), IO_TIMEOUT) .map(|response| response.ok) .unwrap_or(false) } -fn try_open_tab_existing(dir: Option<&std::path::Path>) -> bool { +/// 交接请求的参数:目录与 shell 都可缺省。 +/// +/// 只放**有值**的键——第二份进程与驻留实例可能不是同一个构建,缺省键让老 +/// 那边按 `serde(default)` 走原行为;空白 shell 也当作没给。 +fn handover_params(dir: Option<&std::path::Path>, shell: Option<&str>) -> serde_json::Value { + let mut params = serde_json::Map::new(); + if let Some(dir) = dir { + params.insert("cwd".to_owned(), serde_json::json!(dir)); + } + if let Some(shell) = shell.map(str::trim).filter(|shell| !shell.is_empty()) { + params.insert("shell".to_owned(), serde_json::json!(shell)); + } + serde_json::Value::Object(params) +} + +fn try_open_tab_existing(dir: Option<&std::path::Path>, shell: Option<&str>) -> bool { if legacy_request("ATTACH").is_none() { return false; } // ATTACH 与 tab.new 落到同一事件队列,窗口先恢复,新标签随后创建。 - let params = dir.map_or_else(|| json!({}), |dir| json!({ "cwd": dir })); - cli::request_once("tab.new", params, IO_TIMEOUT).map(|response| response.ok).unwrap_or(false) + cli::request_once("tab.new", handover_params(dir, shell), IO_TIMEOUT) + .map(|response| response.ok) + .unwrap_or(false) } fn legacy_request(verb: &str) -> Option<()> { diff --git a/nebula_app/src/runtime_api/tests.rs b/nebula_app/src/runtime_api/tests.rs index 21a5736..01b4e6a 100644 --- a/nebula_app/src/runtime_api/tests.rs +++ b/nebula_app/src/runtime_api/tests.rs @@ -230,6 +230,45 @@ fn paste_accepts_layout_whitespace_but_not_terminal_control_sequences() { assert!(matches!(agent, RuntimeCommand::AgentPaste { generation: Some(9), submit: true, .. })); } +/// `tab.new` / `window.create` 的 `shell` 是可选字段。 +/// +/// 老客户端(升级前的第二份进程、`pebrel ctl tab new`)不带它,必须逐字保持 +/// 原行为;带了它的新客户端由驻留实例按 id 解析。注意 `WindowParams` 是 +/// `deny_unknown_fields`:**反向**不兼容(新客户端 → 旧驻留实例)会得到一个 +/// `invalid_params`,调用方会退回冷启动——这是升级后要重启 Pebrel 的原因。 +#[test] +fn tab_and_window_requests_take_an_optional_shell() { + let tab = RuntimeCommand::from_request(&ApiRequest::new( + "token".into(), + "tab.new", + json!({ "cwd": "D:\\work", "shell": "wsl:Ubuntu" }), + )) + .expect("tab.new with shell parses"); + assert!(matches!( + tab, + RuntimeCommand::NewTab { shell_id: Some(ref id), .. } if id == "wsl:Ubuntu" + )); + + let plain = RuntimeCommand::from_request(&ApiRequest::new( + "token".into(), + "tab.new", + json!({ "cwd": "D:\\work" }), + )) + .expect("tab.new without shell still parses"); + assert!(matches!(plain, RuntimeCommand::NewTab { shell_id: None, .. })); + + let window = RuntimeCommand::from_request(&ApiRequest::new( + "token".into(), + "window.create", + json!({ "shell": "wsl:Ubuntu" }), + )) + .expect("window.create with shell parses"); + assert!(matches!( + window, + RuntimeCommand::NewWindow { shell_id: Some(ref id), .. } if id == "wsl:Ubuntu" + )); +} + #[test] fn layout_mutations_parse_and_enforce_their_bounds() { let parse = |method: &str, params: Value| { diff --git a/nebula_app/src/shell_detect.rs b/nebula_app/src/shell_detect.rs index d3a6e52..8899db0 100644 --- a/nebula_app/src/shell_detect.rs +++ b/nebula_app/src/shell_detect.rs @@ -543,14 +543,27 @@ pub fn wsl_guest_cwd(cwd: &str) -> Option<&str> { /// Set the WSL guest cwd without changing its distribution, user or command. pub fn wsl_args_at(program: &str, args: &[String], cwd: &str) -> Option> { + if !cwd.starts_with('/') || cwd.chars().any(char::is_control) { + return None; + } + wsl_args_with_directory(program, args, Some(cwd)) +} + +/// Let WSL inherit an explicit host cwd, removing only its own --cd options. +pub fn wsl_args_in_host_directory(program: &str, args: &[String]) -> Option> { + wsl_args_with_directory(program, args, None) +} + +fn wsl_args_with_directory( + program: &str, + args: &[String], + cwd: Option<&str>, +) -> Option> { let name = program.rsplit(['/', '\\']).next()?; if !name.eq_ignore_ascii_case("wsl.exe") && !name.eq_ignore_ascii_case("wsl") { return None; } - if !cwd.starts_with('/') || cwd.chars().any(char::is_control) { - return None; - } - let mut result = vec!["--cd".to_owned(), cwd.to_owned()]; + let mut result = cwd.map_or_else(Vec::new, |cwd| vec!["--cd".to_owned(), cwd.to_owned()]); let mut arguments = args.iter(); while let Some(arg) = arguments.next() { match arg.as_str() { @@ -750,11 +763,10 @@ fn find_wsl_distros() -> Vec { use winreg::RegKey; use winreg::enums::HKEY_CURRENT_USER; - let wsl_exe = - match env_path("SystemRoot").and_then(|root| existing(root.join(r"System32\wsl.exe"))) { - Some(path) => path, - None => return Vec::new(), - }; + let wsl_exe = match crate::platform::shell::wsl_executable() { + Some(path) => path, + None => return Vec::new(), + }; let lxss = match RegKey::predef(HKEY_CURRENT_USER) .open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Lxss") diff --git a/nebula_app/windows/nebula.rc b/nebula_app/windows/nebula.rc index 0fc8ff1..89f847e 100644 --- a/nebula_app/windows/nebula.rc +++ b/nebula_app/windows/nebula.rc @@ -10,8 +10,8 @@ APP_MANIFEST RT_MANIFEST nebula.manifest #endif 1 VERSIONINFO -FILEVERSION 1,8,1,0 -PRODUCTVERSION 1,8,1,0 +FILEVERSION 1,8,2,0 +PRODUCTVERSION 1,8,2,0 FILEFLAGSMASK 0x3fL FILEFLAGS 0x0L FILEOS 0x40004L @@ -24,11 +24,11 @@ BEGIN BEGIN VALUE "CompanyName", "Kuddev" VALUE "FileDescription", "Pebrel" - VALUE "FileVersion", "1.8.1" + VALUE "FileVersion", "1.8.2" VALUE "InternalName", "nebula" VALUE "OriginalFilename", "pebrel.exe" VALUE "ProductName", "Pebrel" - VALUE "ProductVersion", "1.8.1" + VALUE "ProductVersion", "1.8.2" END END BLOCK "VarFileInfo" diff --git a/nebula_config/Cargo.toml b/nebula_config/Cargo.toml index d9d451f..fabbcbd 100644 --- a/nebula_config/Cargo.toml +++ b/nebula_config/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nebula_config" -version = "1.8.1" +version = "1.8.2" license = "GPL-3.0-or-later" description = "Nebula configuration abstractions" homepage = "https://github.com/Kuddev/pebrel" @@ -14,5 +14,5 @@ serde = "1.0.163" toml.workspace = true [dev-dependencies] -nebula_config_derive = { version = "1.8.1", path = "../nebula_config_derive" } +nebula_config_derive = { version = "1.8.2", path = "../nebula_config_derive" } serde = { version = "1.0.163", features = ["derive"] } diff --git a/nebula_config_derive/Cargo.toml b/nebula_config_derive/Cargo.toml index a4d3d6b..211e52a 100644 --- a/nebula_config_derive/Cargo.toml +++ b/nebula_config_derive/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nebula_config_derive" -version = "1.8.1" +version = "1.8.2" license = "GPL-3.0-or-later" description = "Failure resistant deserialization derive" homepage = "https://github.com/Kuddev/pebrel" @@ -18,7 +18,7 @@ syn = { version = "2.0.16", features = ["derive", "parsing", "proc-macro", "prin [dev-dependencies.nebula_config] path = "../nebula_config" -version = "1.8.1" +version = "1.8.2" [dev-dependencies] log = "0.4.11" diff --git a/nebula_settings/Cargo.toml b/nebula_settings/Cargo.toml index d8c2206..82de5c2 100644 --- a/nebula_settings/Cargo.toml +++ b/nebula_settings/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nebula-settings" -version = "1.8.1" +version = "1.8.2" license = "GPL-3.0-or-later" description = "Shared reader for Nebula runtime user settings (nebula_settings.txt) and theme terminal-color tables" publish = false diff --git a/nebula_split/Cargo.toml b/nebula_split/Cargo.toml index 87ebcad..1642326 100644 --- a/nebula_split/Cargo.toml +++ b/nebula_split/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nebula-split" -version = "1.8.1" +version = "1.8.2" license = "GPL-3.0-or-later" description = "Shared split-pane rules: layout tree, geometry, divider drag and directional focus" publish = false diff --git a/nebula_terminal/Cargo.toml b/nebula_terminal/Cargo.toml index 050fafe..7ea01a3 100644 --- a/nebula_terminal/Cargo.toml +++ b/nebula_terminal/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nebula_terminal" -version = "1.8.1" +version = "1.8.2" license = "GPL-3.0-or-later" description = "Nebula terminal emulation core" readme = "../README.md" diff --git a/scripts/installer-migration.iss b/scripts/installer-migration.iss index fb27130..033dd7d 100644 --- a/scripts/installer-migration.iss +++ b/scripts/installer-migration.iss @@ -275,6 +275,102 @@ begin RaiseException('Unable to remove old context menu: ' + Key); end; +{ —— 按 WSL 发行版注册的右键项 —— + + 「在 Pebrel 中打开(Ubuntu)」这类菜单项按**安装那一刻**机器上的发行版动态生成: + `Lxss` 里的 `DistributionName` 是唯一真相,枚举口径与 Rust 侧 + `shell_detect::find_wsl_distros` 一致(跳过 `docker-desktop*` 这类 plumbing 发行版)。 + + 走 `[Code]` 而不是 `[Registry]` 的原因只有一个:键的条数取决于机器上有几个发行版。 + 代价是这些键没有 `uninsdeletekey`,卸载由 `RemoveOwnedWslContextMenus` 按名字前缀 + 加命令串里的可执行文件与 `--shell` 认领后删除。 } + +const + LxssRoot = 'Software\Microsoft\Windows\CurrentVersion\Lxss'; + +function WslDistroNames: TArrayOfString; +var + Names: TArrayOfString; + Index, Count: Integer; + Name: string; +begin + SetArrayLength(Result, 0); + if not RegGetSubkeyNames(HKCU, LxssRoot, Names) then + Exit; + SetArrayLength(Result, GetArrayLength(Names)); + Count := 0; + for Index := 0 to GetArrayLength(Names) - 1 do + if RegQueryStringValue(HKCU, LxssRoot + '\' + Names[Index], 'DistributionName', Name) then + if (Name <> '') and (Pos('docker-desktop', Lowercase(Name)) <> 1) then begin + Result[Count] := Name; + Count := Count + 1; + end; + SetArrayLength(Result, Count); +end; + +{ 删掉本安装器写下的 WSL 右键项:键名以 `PebrelWsl` 开头、命令串里同时有我们的 + 可执行文件和 `--shell`,两条都满足才算我们写的。发行版后来被移除也不会留下 + 点不动的死项。 } +procedure RemoveOwnedWslContextMenus; +var + Roots: array[0..1] of string; + Root, Key, Command: string; + Names: TArrayOfString; + Index, NameIndex: Integer; +begin + Roots[0] := 'Software\Classes\Directory\shell'; + Roots[1] := 'Software\Classes\Directory\Background\shell'; + for Index := 0 to 1 do begin + Root := Roots[Index]; + if RegGetSubkeyNames(HKCU, Root, Names) then + for NameIndex := 0 to GetArrayLength(Names) - 1 do + if Pos('PebrelWsl', Names[NameIndex]) = 1 then begin + Key := Root + '\' + Names[NameIndex]; + Command := ''; + if RegQueryStringValue(HKCU, Key + '\command', '', Command) and + (Pos(ExpandConstant('{app}\pebrel.exe'), Command) > 0) and + (Pos('--shell', Command) > 0) then + if not RegDeleteKeyIncludingSubkeys(HKCU, Key) then + RaiseException('Unable to remove an owned WSL context menu: ' + Key); + end; + end; +end; + +procedure RegisterWslContextMenus; +var + Distros: TArrayOfString; + Index: Integer; + Distro, Verb, LabelText, Executable, Command, Key: string; +begin + { 先清掉上一次写下的:发行版被移除后,残留的索引键会指向一个已经不存在的发行版。 } + RemoveOwnedWslContextMenus; + Distros := WslDistroNames; + if GetArrayLength(Distros) = 0 then + Exit; + Executable := ExpandConstant('{app}\pebrel.exe'); + for Index := 0 to GetArrayLength(Distros) - 1 do begin + Distro := Distros[Index]; + { 键名用序号而不是发行版名:注册表键名里带空格与非 ASCII 只会给自己找麻烦, + 何况我们靠前缀认领。 } + Verb := 'PebrelWsl' + IntToStr(Index); + LabelText := CustomMessage('OpenInPebrelWsl') + ' (' + Distro + ')'; + { `--shell` 必须排在 `--working-directory` 之前:盘根(`D:\`)时后者的收尾 + 反斜杠会吃掉它的收尾引号,并把后面整段并进同一个参数(issue #36 的另一面), + 顺序写反会静默开出一个既没有 cwd、也没用上指定发行版的标签。 } + Command := '"' + Executable + '" --gpui --shell "wsl:' + Distro + '" --working-directory '; + Key := 'Software\Classes\Directory\shell\' + Verb; + if not RegWriteStringValue(HKCU, Key, 'MUIVerb', LabelText) or + not RegWriteStringValue(HKCU, Key, 'Icon', Executable + ',0') or + not RegWriteStringValue(HKCU, Key + '\command', '', Command + '"%1"') then + RaiseException('Unable to register the WSL context menu for ' + Distro + '.'); + Key := 'Software\Classes\Directory\Background\shell\' + Verb; + if not RegWriteStringValue(HKCU, Key, 'MUIVerb', LabelText) or + not RegWriteStringValue(HKCU, Key, 'Icon', Executable + ',0') or + not RegWriteStringValue(HKCU, Key + '\command', '', Command + '"%V"') then + RaiseException('Unable to register the WSL background context menu for ' + Distro + '.'); + end; +end; + procedure MigrateLegacyIntegrations; var Executable, ExistingPath, Key: string; @@ -342,7 +438,11 @@ end; procedure CurStepChanged(CurStep: TSetupStep); begin - if (CurStep <> ssPostInstall) or (LegacyInstallDir = '') then + if CurStep <> ssPostInstall then + Exit; + { 与旧版迁移无关,装完就要做:菜单项取决于本机装了哪些 WSL 发行版。 } + RegisterWslContextMenus; + if LegacyInstallDir = '' then Exit; try if not RegWriteStringValue(HKCU, ProductSettingsKey, 'PendingLegacyInstallDir', LegacyInstallDir) or diff --git a/scripts/installer.iss b/scripts/installer.iss index f3bc60e..31e0ed3 100644 --- a/scripts/installer.iss +++ b/scripts/installer.iss @@ -1,9 +1,9 @@ #ifndef AppVersion - #define AppVersion "1.8.1" + #define AppVersion "1.8.2" #endif #ifndef NumericVersion - #define NumericVersion "1.8.1.0" + #define NumericVersion "1.8.2.0" #endif #ifndef Configuration @@ -77,6 +77,7 @@ english.AutoStart=Start Pebrel when I sign in to Windows english.InstallFont=Install Maple Mono font for the current user english.AddToPath=Add Pebrel to the user PATH english.OpenInPebrel=Open in Pebrel +english.OpenInPebrelWsl=Open in Pebrel english.LaunchProgram=Launch Pebrel english.UninstallProgram=Uninstall Pebrel english.CloseLegacyProgram=Close the application at %1, then retry the installation. @@ -88,6 +89,7 @@ chinesesimplified.AutoStart=登录 Windows 后启动 Pebrel chinesesimplified.InstallFont=为当前用户安装 Maple Mono 字体 chinesesimplified.AddToPath=将 Pebrel 添加到当前用户 PATH chinesesimplified.OpenInPebrel=在 Pebrel 中打开 +chinesesimplified.OpenInPebrelWsl=在 Pebrel 中打开 chinesesimplified.LaunchProgram=启动 Pebrel chinesesimplified.UninstallProgram=卸载 Pebrel chinesesimplified.CloseLegacyProgram=请关闭 %1 中运行的程序,然后重试安装。 @@ -180,6 +182,10 @@ begin if CurUninstallStep <> usUninstall then Exit; + { WSL 右键项是 [Code] 动态写的、没有 uninsdeletekey,必须自己认领删除; + 这一步排在 PATH 那段的早退之前——两条互不依赖。 } + RemoveOwnedWslContextMenus; + if not RegValueExists(HKCU, 'Software\Pebrel', 'InstallerAddedToPath') then Exit; diff --git a/scripts/tests/installer.tests.ps1 b/scripts/tests/installer.tests.ps1 index 9f1373f..69cbd9b 100644 --- a/scripts/tests/installer.tests.ps1 +++ b/scripts/tests/installer.tests.ps1 @@ -36,6 +36,9 @@ $requiredPatterns = [ordered]@{ 'Pebrel default asset name' = '#define PackageBrand "Pebrel"' 'explicit package brand' = 'OutputBaseFilename=\{#PackageBrand\}-v\{#AppVersion\}-windows-x64-setup' 'localized Chinese context menu label' = 'chinesesimplified\.OpenInPebrel=\S.+' + 'localized WSL context menu label' = 'english\.OpenInPebrelWsl=Open in Pebrel' + 'localized Chinese WSL context menu label' = 'chinesesimplified\.OpenInPebrelWsl=\S.+' + 'WSL context menu uninstall cleanup' = 'RemoveOwnedWslContextMenus;' 'directory background context menu' = 'Software\\Classes\\Directory\\Background\\shell\\Pebrel' 'selected directory context menu' = 'Software\\Classes\\Directory\\shell\\Pebrel' 'context menu executable icon' = 'ValueName: "Icon"; ValueData: "\{app\}\\pebrel\.exe,0"' @@ -114,6 +117,36 @@ if ($migration -match 'DelTree\(|TerminateProcess\(|taskkill') { throw 'Migration must not recursively delete user data or forcefully terminate applications.' } +# 按 WSL 发行版注册的右键项(installer-migration.iss 里的 [Code]): +$wslPatterns = [ordered]@{ + 'WSL distribution registry' = 'Software\\Microsoft\\Windows\\CurrentVersion\\Lxss' + 'plumbing distros are skipped' = "Pos\('docker-desktop'" + 'distinct verb namespace' = "'PebrelWsl' \+ IntToStr\(Index\)" + 'registration runs at post-install' = 'RegisterWslContextMenus;' + 'owned keys are reclaimed by prefix and command' = "Pos\('PebrelWsl', Names\[NameIndex\]\) = 1" + 'uninstall sweeps both roots' = "Directory\\Background\\shell'" +} +foreach ($entry in $wslPatterns.GetEnumerator()) { + if ($migration -notmatch $entry.Value) { + throw "Installer migration is missing $($entry.Key): $($entry.Value)" + } +} + +# 命令串里 `--shell` 必须排在 `--working-directory` 之前:盘根(`D:\`)时后者的 +# 收尾反斜杠会吃掉收尾引号并把后面整段并进同一个参数(issue #36 的另一面),顺序 +# 写反会静默开出一个既没有 cwd、也没用上指定发行版的标签。 +$commandTemplates = @( + $migration -split "`n" | Where-Object { $_ -match '^\s*Command :=' -and $_ -match '--shell' } +) +if ($commandTemplates.Count -ne 1) { + throw "Expected exactly one WSL context-menu command template, found $($commandTemplates.Count)." +} +$shellAt = $commandTemplates[0].IndexOf('--shell') +$directoryAt = $commandTemplates[0].IndexOf('--working-directory') +if ($shellAt -lt 0 -or $directoryAt -lt 0 -or $shellAt -gt $directoryAt) { + throw "The WSL context-menu command must pass --shell before --working-directory: $($commandTemplates[0].Trim())" +} + $validationArguments = @{ SkipBuild = $true; AllowStale = $true; ValidateOnly = $true } if (-not [string]::IsNullOrWhiteSpace($TargetDirectory)) { $validationArguments.TargetDirectory = $TargetDirectory diff --git a/tools/i18n-contract/Cargo.lock b/tools/i18n-contract/Cargo.lock index da0db86..7501d92 100644 --- a/tools/i18n-contract/Cargo.lock +++ b/tools/i18n-contract/Cargo.lock @@ -16,7 +16,7 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "nebula-settings" -version = "1.8.1" +version = "1.8.2" [[package]] name = "pebrel-i18n-contract"