From 70b90f188083ba1c378732848447c13c5e9f180f Mon Sep 17 00:00:00 2001 From: AnxForever <130662349+AnxForever@users.noreply.github.com> Date: Mon, 14 Sep 2026 23:00:13 +0800 Subject: [PATCH] feat(gpui): open a folder in a chosen shell from the Explorer menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-clicking a folder has always opened the configured default shell — usually PowerShell — with no way to ask for a WSL distribution instead. - `--shell ` takes the same ids the `shell` setting uses (`pwsh`, `cmd`, `wsl:Ubuntu`, or an imported profile's settings id) and applies to the first tab this process creates. It carries an id rather than a path so every launch re-resolves the executable. - The id travels both startup paths: the cold start (`run_shell` → `WorkspaceStartup::NewTerminal`), and the resident hand-off, which is the common case on Windows. `tab.new`/`window.create` gained an optional `shell` field, so clicking the menu while Pebrel is already running adds a tab to the existing window instead of starting a second process. - An explicit request never silently degrades. An unresolvable id fails (`invalid_shell`, or a startup error before any window opens), and a `wsl:` id that the registry no longer lists still launches `wsl.exe -d `, leaving the complaint to the distribution itself. The `shell` setting keeps its documented fallback to the platform default. - The winit shell rejects a shell request instead of ignoring it, so the caller falls back to a cold GPUI start and still gets the requested shell. The resolution lives in a new `workspace::shell_launch` module: `main.rs` has to validate `--shell` before it opens a window, and none of it needs `NebulaWorkspace` state — `workspace.rs` is also at its line budget, which this keeps from growing (`architecture/file-budgets.txt`). The installer registers one context-menu entry per registered WSL distribution (`PebrelWsl`, skipping `docker-desktop*`) and the uninstaller reclaims them by key prefix plus the command line it wrote. `--shell` is passed before `--working-directory`: a drive-root `%V` ends in `\"`, which would swallow the rest of the line if the directory came first. Both the string and the order are pinned by installer assertions. Docs (runtime API schema + control API) and shell completions are updated. --- docs/runtime-api-v1.schema.json | 7 +- docs/runtime-control-api.md | 11 +- extra/completions/_pebrel | 1 + extra/completions/pebrel.bash | 6 +- extra/completions/pebrel.fish | 3 +- extra/completions/windows/_pebrel | 1 + extra/completions/windows/pebrel.bash | 6 +- extra/completions/windows/pebrel.fish | 3 +- nebula_app/src/cli.rs | 64 +++++++ nebula_app/src/event/runtime_control.rs | 13 +- nebula_app/src/gpui_shell/mod.rs | 25 ++- nebula_app/src/gpui_shell/workspace.rs | 91 +++++---- nebula_app/src/gpui_shell/workspace/agents.rs | 2 +- .../src/gpui_shell/workspace/residency.rs | 14 +- .../src/gpui_shell/workspace/shell_launch.rs | 175 ++++++++++++++++++ .../gpui_shell/workspace/tab_duplication.rs | 2 +- .../src/gpui_shell/workspace/windowing.rs | 49 +++-- .../workspace/windowing/quick_window.rs | 4 +- nebula_app/src/main.rs | 42 +++-- nebula_app/src/runtime_api.rs | 5 + nebula_app/src/runtime_api/command.rs | 13 +- nebula_app/src/runtime_api/orchestrate.rs | 8 +- nebula_app/src/runtime_api/server.rs | 38 +++- nebula_app/src/runtime_api/tests.rs | 39 ++++ nebula_app/src/shell_detect.rs | 17 +- scripts/installer-migration.iss | 102 +++++++++- scripts/installer.iss | 6 + scripts/tests/installer.tests.ps1 | 33 ++++ 28 files changed, 657 insertions(+), 123 deletions(-) create mode 100644 nebula_app/src/gpui_shell/workspace/shell_launch.rs 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_app/src/cli.rs b/nebula_app/src/cli.rs index 7e64f95..106834f 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,18 @@ 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 +1759,49 @@ 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")); + assert_eq!(terminal.resolved_working_directory(), Some(PathBuf::from("D:\\"))); + } + + /// `--shell` 与 `-e/--command` 是互相矛盾的意图:`-e` 在 GPUI 路径根本没被 + /// 消费(`main.rs` 只取 working_directory),两个一起给只会让人以为生效了。 + #[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 69a55c6..2bdbc93 100644 --- a/nebula_app/src/gpui_shell/mod.rs +++ b/nebula_app/src/gpui_shell/mod.rs @@ -75,7 +75,7 @@ pub(crate) enum GpuiShellEvent { /// /// GPUI 拥有自己的消息循环:主窗形态从主线程调用(winit 不启动); /// spike 形态从专用线程调用。一个进程内只允许调用一次。 -pub fn run_shell(initial_cwd: Option) { +pub fn run_shell(initial_cwd: Option, shell_id: Option) { let (shell_tx, shell_rx) = std::sync::mpsc::channel(); crate::notify::init_gpui_activation(shell_tx.clone()); crate::ssh_prompt::install({ @@ -118,12 +118,20 @@ pub fn run_shell(initial_cwd: Option) { let behavior = nebula_settings::RuntimeSettings::load().windowing_behavior; let handed_over = match behavior { nebula_settings::WindowingBehaviorName::UseNew => { - crate::runtime_api::try_open_window_existing(initial_cwd.as_deref()) + crate::runtime_api::try_open_window_existing( + initial_cwd.as_deref(), + shell_id.as_deref(), + ) + }, + _ => match initial_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()) + }, }, - _ => initial_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(); @@ -147,7 +155,7 @@ pub fn run_shell(initial_cwd: Option) { crate::ai_hook::spawn_config_guard(); init(cx); cx.activate(true); - open_main_window(cx, ai_events, shell_rx, runtime_hub, initial_cwd); + open_main_window(cx, ai_events, shell_rx, runtime_hub, initial_cwd, shell_id); }); crate::tray::shutdown(); } @@ -220,9 +228,10 @@ fn open_main_window( shell_events: std::sync::mpsc::Receiver, runtime_hub: crate::runtime_api::RuntimeHub, initial_cwd: Option, + shell_id: Option, ) { workspace::windowing::initialize(cx, runtime_hub); - workspace::windowing::open_initial_window(cx, ai_events, shell_events, initial_cwd); + workspace::windowing::open_initial_window(cx, ai_events, shell_events, initial_cwd, shell_id); } /// 按 `tray` 设置挂上或摘掉系统托盘图标(旧壳 `tray::set_enabled`)。 diff --git a/nebula_app/src/gpui_shell/workspace.rs b/nebula_app/src/gpui_shell/workspace.rs index 47c1fe6..cd5ed11 100644 --- a/nebula_app/src/gpui_shell/workspace.rs +++ b/nebula_app/src/gpui_shell/workspace.rs @@ -63,6 +63,7 @@ mod remote_files; mod residency; mod send_to_chat; mod session_persistence; +pub(crate) mod shell_launch; mod shell_picker; use shell_picker::shell_palette_rows; mod settings_navigation; @@ -1254,8 +1255,25 @@ impl NebulaWorkspace { this.add_terminal(window, cx); } }, - windowing::WorkspaceStartup::NewTerminal { cwd } => { - this.add_terminal_at(cwd, None, window, cx); + windowing::WorkspaceStartup::NewTerminal { cwd, shell_id } => match shell_id { + // 命令行指名了 shell(右键菜单「在 Pebrel 中打开(Ubuntu)」就走这里): + // 解析成启动身份后交给与侧栏/选择器同一个终点。 + Some(shell_id) => { + // 冷启动时 main.rs 已经校验过一次;走到这里的失败(解析器 + // 与菜单不同步、IPC 直连)至少要留痕,并且用默认 shell 把 + // 标签开出来——不能因为一个坏 id 让用户连终端都没有。 + let launch = match shell_launch::resolve_shell_id(&shell_id) { + Ok(launch) => launch, + Err(error) => { + log::warn!("shell id 解析失败,改用默认 shell: {error}"); + crate::session::LaunchSession::Default + }, + }; + this.add_terminal_with(launch, cwd, None, window, cx); + }, + None => { + this.add_terminal_at(cwd, None, window, cx); + }, }, windowing::WorkspaceStartup::Empty => {}, } @@ -1335,51 +1353,7 @@ 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 使用 Tab 的 /// launch;其它分屏继续沿用旧壳合同,按当前默认 Shell 重建。 @@ -1524,7 +1498,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) } @@ -3019,7 +2993,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) @@ -4359,5 +4333,26 @@ impl Render for NebulaWorkspace { } } +/// `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; 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/residency.rs b/nebula_app/src/gpui_shell/workspace/residency.rs index 5d22639..f565f13 100644 --- a/nebula_app/src/gpui_shell/workspace/residency.rs +++ b/nebula_app/src/gpui_shell/workspace/residency.rs @@ -225,9 +225,19 @@ 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_id(shell_id) + .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/shell_launch.rs b/nebula_app/src/gpui_shell/workspace/shell_launch.rs new file mode 100644 index 0000000..df5dfe9 --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/shell_launch.rs @@ -0,0 +1,175 @@ +//! 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; + + + +/// 冻结“新建这一刻”的默认 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(profile) = 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(requested)) + }) + }, + ) { + return Ok(profile_launch_session(profile)); + } + if let Some(launch) = wsl_launch_for_id(requested) { + return Ok(launch); + } + Err(format!("unknown shell id \"{requested}\"; available: {}", shell_id_menu().join(", "))) +} + +/// 报错时列给用户的可选 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::shell_detect::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 { +/// `--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); +} +} diff --git a/nebula_app/src/gpui_shell/workspace/tab_duplication.rs b/nebula_app/src/gpui_shell/workspace/tab_duplication.rs index 1a08a2d..3529836 100644 --- a/nebula_app/src/gpui_shell/workspace/tab_duplication.rs +++ b/nebula_app/src/gpui_shell/workspace/tab_duplication.rs @@ -35,7 +35,7 @@ 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 29265b0..b33d3e5 100644 --- a/nebula_app/src/gpui_shell/workspace/windowing.rs +++ b/nebula_app/src/gpui_shell/workspace/windowing.rs @@ -42,7 +42,9 @@ use crate::runtime_api::{ /// 明确创建一个新终端或暂时保持空白,不能把同一份 session 重放多次。 pub(crate) enum WorkspaceStartup { RestoreOrDefault, - NewTerminal { cwd: Option }, + /// 开一个新终端标签。`shell_id` 来自命令行 `--shell`(或驻留交接里同一字段): + /// 有值时按它解析启动身份,而不是用设置里的默认 shell。 + NewTerminal { cwd: Option, shell_id: Option }, Empty, } @@ -250,10 +252,17 @@ pub(crate) fn open_initial_window( ai_events: std::sync::mpsc::Receiver, shell_events: std::sync::mpsc::Receiver, initial_cwd: Option, + shell_id: Option, ) { - let startup = match initial_cwd { - Some(cwd) => WorkspaceStartup::NewTerminal { cwd: Some(cwd) }, - None => WorkspaceStartup::RestoreOrDefault, + let startup = match (initial_cwd, shell_id) { + (Some(cwd), shell_id) => WorkspaceStartup::NewTerminal { cwd: Some(cwd), shell_id }, + // 指名了 shell 就必须建标签,哪怕没给目录:`--shell` 是显式请求,不能 + // 因为缺 cwd 就退回去恢复上次会话、把它悄悄丢掉。跳过会话恢复与 + // "带了 `--working-directory` 就不恢复"是同一条既有契约。 + (None, Some(shell_id)) => { + WorkspaceStartup::NewTerminal { cwd: None, shell_id: Some(shell_id) } + }, + (None, None) => WorkspaceStartup::RestoreOrDefault, }; open_workspace_window( cx, @@ -431,7 +440,7 @@ fn open_workspace_window( pub(crate) fn open_new_window(cx: &mut App, cwd: Option) -> gpui::Result<(u64, u64)> { let (window_id, workspace) = open_workspace_window( cx, - WorkspaceStartup::NewTerminal { cwd }, + WorkspaceStartup::NewTerminal { cwd, shell_id: None }, None, None, true, @@ -441,10 +450,17 @@ 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)> { +/// 新窗口形态的运行时请求(`window.create`,以及 UseNew 下的 `tab.new`)。 +/// `shell_id` 跟着命令行/交接一路传到这里,语义与 +/// [`WorkspaceStartup::NewTerminal`] 的同一字段一致。 +fn open_runtime_window( + cx: &mut App, + cwd: Option, + shell_id: Option, +) -> gpui::Result<(u64, u64)> { let (window_id, workspace) = open_workspace_window( cx, - WorkspaceStartup::NewTerminal { cwd }, + WorkspaceStartup::NewTerminal { cwd, shell_id }, None, None, false, @@ -858,8 +874,8 @@ fn dispatch_runtime(dispatch: Arc, cx: &mut App) { ); return; }, - RuntimeCommand::NewWindow { cwd } => { - let response = open_runtime_window(cx, cwd.clone()) + RuntimeCommand::NewWindow { cwd, shell_id } => { + let response = open_runtime_window(cx, cwd.clone(), shell_id.clone()) .map_err(|error| ApiError::new("window_create_failed", error.to_string())) .map(|(window_id, pane_id)| { let snapshot = publish_runtime_snapshot(cx); @@ -871,11 +887,11 @@ fn dispatch_runtime(dispatch: Arc, cx: &mut App) { 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()) + let response = open_runtime_window(cx, cwd.clone(), shell_id.clone()) .map_err(|error| ApiError::new("window_create_failed", error.to_string())) .map(|(window_id, pane_id)| { let snapshot = publish_runtime_snapshot(cx); @@ -926,8 +942,11 @@ fn dispatch_runtime(dispatch: Arc, cx: &mut App) { 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()) + let RuntimeCommand::NewTab { cwd, shell_id, .. } = &dispatch.command + else { + unreachable!() + }; + let response = open_runtime_window(cx, cwd.clone(), shell_id.clone()) .map_err(|create| { ApiError::new( "window_create_failed", @@ -1713,14 +1732,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/gpui_shell/workspace/windowing/quick_window.rs b/nebula_app/src/gpui_shell/workspace/windowing/quick_window.rs index 4786098..25cfa6b 100644 --- a/nebula_app/src/gpui_shell/workspace/windowing/quick_window.rs +++ b/nebula_app/src/gpui_shell/workspace/windowing/quick_window.rs @@ -62,7 +62,7 @@ pub(crate) fn toggle_quick_terminal_window(cx: &mut App) { } else { let _ = open_workspace_window( cx, - WorkspaceStartup::NewTerminal { cwd: None }, + WorkspaceStartup::NewTerminal { cwd: None, shell_id: None }, None, None, true, @@ -156,7 +156,7 @@ fn open_quick_terminal_window(cx: &mut App) { }; let opened = open_workspace_window( cx, - WorkspaceStartup::NewTerminal { cwd: None }, + WorkspaceStartup::NewTerminal { cwd: None, shell_id: None }, None, None, false, diff --git a/nebula_app/src/main.rs b/nebula_app/src/main.rs index 864983c..78fa307 100644 --- a/nebula_app/src/main.rs +++ b/nebula_app/src/main.rs @@ -227,19 +227,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); + gpui_shell::run_shell(initial_cwd, shell_id); return Ok(()); } @@ -519,23 +531,21 @@ fn wants_gpui_shell(options: &Options) -> bool { /// 驻留进程,再 `tab.new`。GPUI 与 winit 共用,避免第二份进程无声退出。 #[cfg(windows)] fn try_hand_over_to_resident(options: &Options) -> bool { - let has_command = options.window_options.terminal_options.command().is_some(); - let launch_dir = options - .window_options - .terminal_options - .resolved_working_directory() - .filter(|path| path.is_dir()); + let terminal_options = &options.window_options.terminal_options; + let has_command = terminal_options.command().is_some(); + // `--shell` 必须跟着交接一起走:驻留实例那边默认只认目录,漏掉它就会把 + // 「用 Ubuntu 打开」变成又一个 PowerShell 标签,而且毫无提示。 + let shell_id = terminal_options.shell_id(); + let launch_dir = terminal_options.resolved_working_directory().filter(|path| path.is_dir()); if !options.daemon && !has_command && 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 - && options.window_options.terminal_options.working_directory.is_none() - && !has_command; - if plain_launch && runtime_api::try_open_default_tab_existing() { + let plain_launch = !options.daemon && terminal_options.working_directory.is_none() && !has_command; + if plain_launch && runtime_api::try_open_default_tab_existing(shell_id.as_deref()) { return true; } // Explorer 右键「在 Nebula 中打开」带着 --working-directory 走到这里。 @@ -544,7 +554,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/runtime_api.rs b/nebula_app/src/runtime_api.rs index aeb8229..3358b1a 100644 --- a/nebula_app/src/runtime_api.rs +++ b/nebula_app/src/runtime_api.rs @@ -757,6 +757,9 @@ pub enum RuntimeCommand { NewWindow { /// 普通启动 / Explorer 右键要求新开窗口时,把目标目录一路带到首个标签。 cwd: Option, + /// 命令行 `--shell ` 指名要用的 shell;`None` = 用设置里的默认 shell。 + /// 右键菜单「在 Pebrel 中打开(Ubuntu)」靠它把 WSL 意图带进已有实例。 + shell_id: Option, }, CloseWindow { window_id: Option, @@ -768,6 +771,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..1588aa9 100644 --- a/nebula_app/src/runtime_api/orchestrate.rs +++ b/nebula_app/src/runtime_api/orchestrate.rs @@ -675,8 +675,12 @@ fn execute_step( let mut tail_request = None; let command = match step { - OrchestrateStep::NewTab { window_id, cwd, .. } => { - RuntimeCommand::NewTab { window_id: *window_id, cwd: cwd.clone() } + // 编排步骤暂不带 shell:`orchestrate` 里开标签用的一直是默认 shell, + // 与 `tab.new` 的 `shell` 参数是两件事(要接的话得同时扩 schema 与文档)。 + OrchestrateStep::NewTab { window_id, cwd, .. } => 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 27b2012..9e03673 100644 --- a/nebula_app/src/runtime_api/server.rs +++ b/nebula_app/src/runtime_api/server.rs @@ -45,8 +45,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(不回车)。 @@ -64,31 +64,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 c4684da..de0b5f4 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 76d0b14..ab6269c 100644 --- a/nebula_app/src/shell_detect.rs +++ b/nebula_app/src/shell_detect.rs @@ -742,15 +742,22 @@ pub fn wsl_distro_names() -> Vec { } #[cfg(windows)] +/// 本机 `wsl.exe` 的绝对路径(`System32` 下那个);没装 WSL 就没有。 +/// +/// [`find_wsl_distros`] 与「id 指名了 `wsl:<发行版>`、但当前枚举不到」的兜底共用 +/// 这一个来源——两处各拼一次路径,迟早会分叉。 +pub fn wsl_executable() -> Option { + env_path("SystemRoot").and_then(|root| existing(root.join(r"System32\wsl.exe"))) +} + 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 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/scripts/installer-migration.iss b/scripts/installer-migration.iss index 8c6e1c4..ad65ad7 100644 --- a/scripts/installer-migration.iss +++ b/scripts/installer-migration.iss @@ -257,6 +257,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; @@ -324,7 +420,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 f8a9fbb..e38d780 100644 --- a/scripts/installer.iss +++ b/scripts/installer.iss @@ -71,6 +71,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. @@ -82,6 +83,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 中运行的程序,然后重试安装。 @@ -163,6 +165,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 5acaf8e..d700744 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"' @@ -97,6 +100,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