merge: include committed terminal controls in local preview
No files matched your search
@@ -13,6 +13,10 @@ Every release entry is provided in English and Simplified Chinese.
|
||||
- Added a confirmation dialog to Settings → Restore defaults, with an explicit cancel action before any preferences are reset. Contributed by [@WilliamWang1721](https://github.com/WilliamWang1721) in [#115](https://github.com/Kuddev/pebrel/pull/115).
|
||||
- Added Windows login-startup and silent-start switches in Settings → Advanced → Session lifecycle. Silent startup requires the system tray; opening a directory explicitly still shows a window. Contributed by [@WilliamWang1721](https://github.com/WilliamWang1721) in [#117](https://github.com/Kuddev/pebrel/pull/117).
|
||||
- Added separate interface font and size controls, plus an optional CJK terminal font fallback list, in Settings → Appearance. Interface size can be adjusted from 10 to 24 px independently of terminal text. Contributed by [@WilliamWang1721](https://github.com/WilliamWang1721) in [#127](https://github.com/Kuddev/pebrel/pull/127).
|
||||
- Added “Focus follows mouse” in Settings → Interaction, off by default, and “Dim inactive panes” in Settings → Appearance, on by default. Both preferences take effect immediately.
|
||||
- Added right-click actions to the Shell launcher for choosing the default Shell and, on Windows, opening the selected Shell inside a new administrator Pebrel window.
|
||||
- Added Connect, Edit, and Delete to SSH launcher context menus. Delete uses the existing confirmation and undo flow.
|
||||
- Added Trae CLI recognition and its tab icon, plus a dedicated Oh My Pi icon.
|
||||
|
||||
#### Fixed
|
||||
|
||||
@@ -20,10 +24,14 @@ Every release entry is provided in English and Simplified Chinese.
|
||||
- Aligned macOS title-bar controls and tab spacing with the native window-button geometry. Contributed by [@WilliamWang1721](https://github.com/WilliamWang1721) in [#114](https://github.com/Kuddev/pebrel/pull/114).
|
||||
- Fixed ignored terminal selection foreground colors and preserved custom selection colors in themes that do not define their own. Contributed by [@Aschenbath](https://github.com/Aschenbath) in [#125](https://github.com/Kuddev/pebrel/pull/125).
|
||||
- Kept selected text readable in the answer reader and other selectable text views by limiting the highlight opacity, while retaining solid list-selection backgrounds. Contributed by [@Aschenbath](https://github.com/Aschenbath) in [#126](https://github.com/Kuddev/pebrel/pull/126).
|
||||
- Fixed the top Settings tab disappearing when switching to another tab. It now remains available until explicitly closed, including when tabs overflow or the tab layout changes.
|
||||
- Fixed `Alt+1–9` and `Ctrl+1–9` tab shortcuts losing priority to terminal input. They now work while a terminal or CLI has focus and respect custom shortcut changes without restarting.
|
||||
|
||||
#### Improved
|
||||
|
||||
|
||||
- Localized existing Git controls, status messages, history timestamps and file-operation notices. Switching the interface language preserves the commit draft and its text selection. Contributed by [@gao-jian-bin](https://github.com/gao-jian-bin) in [#118](https://github.com/Kuddev/pebrel/pull/118).
|
||||
- Replaced the low-resolution Claude Code icon with artwork exported from SVG at 1024 pixels, and prepared Agent icons at their display size for smoother tab edges.
|
||||
|
||||
### 中文
|
||||
|
||||
@@ -32,6 +40,10 @@ Every release entry is provided in English and Simplified Chinese.
|
||||
- 为“设置 → 恢复默认设置”加入确认弹窗,可在重置任何偏好前明确取消操作。由 [@WilliamWang1721](https://github.com/WilliamWang1721) 在 [#115](https://github.com/Kuddev/pebrel/pull/115) 中贡献。
|
||||
- 在 Windows 的“设置 → 高级 → 会话生命周期”中新增“登录时自启动”和“静默启动”开关。静默启动需要启用系统托盘;显式打开目录时仍显示窗口。由 [@WilliamWang1721](https://github.com/WilliamWang1721) 在 [#117](https://github.com/Kuddev/pebrel/pull/117) 中贡献。
|
||||
- 在“设置 → 外观”中新增独立的界面字体、界面字号,以及可选的中文终端字体回退列表。界面字号可在 10–24 px 间调整,不影响终端文字字号。由 [@WilliamWang1721](https://github.com/WilliamWang1721) 在 [#127](https://github.com/Kuddev/pebrel/pull/127) 中贡献。
|
||||
- 在“设置 → 交互”中加入“焦点跟随鼠标”,默认关闭;在“设置 → 外观”中加入“调暗非活动窗格”,默认开启。两项设置均即时生效。
|
||||
- Shell 启动菜单新增右键操作,可设为默认 Shell;Windows 下还可在新的管理员 Pebrel 窗口内打开所选 Shell。
|
||||
- SSH 启动菜单新增“连接、编辑、删除”右键操作;删除沿用已有的确认与撤销流程。
|
||||
- 新增 Trae CLI 识别与标签图标,并为 Oh My Pi 加入独立图标。
|
||||
|
||||
#### 修复
|
||||
|
||||
@@ -39,10 +51,14 @@ Every release entry is provided in English and Simplified Chinese.
|
||||
- 让 macOS 标题栏控件与标签间距跟随系统窗口按钮的实际几何位置。由 [@WilliamWang1721](https://github.com/WilliamWang1721) 在 [#114](https://github.com/Kuddev/pebrel/pull/114) 中贡献。
|
||||
- 修复终端忽略选中文字前景色的问题;主题未定义自己的选区颜色时,保留用户的自定义选区颜色。由 [@Aschenbath](https://github.com/Aschenbath) 在 [#125](https://github.com/Kuddev/pebrel/pull/125) 中贡献。
|
||||
- 限制回答阅读器等文本视图的选区高亮不透明度,让选中文字保持可读,同时保留列表选中项的实色背景。由 [@Aschenbath](https://github.com/Aschenbath) 在 [#126](https://github.com/Kuddev/pebrel/pull/126) 中贡献。
|
||||
- 修复顶部设置标签在切换到其他标签后消失的问题。现在只有主动关闭才会移除,标签溢出或切换标签布局时也会保留。
|
||||
- 修复 `Alt+1–9` 和 `Ctrl+1–9` 标签快捷键优先级低于终端输入的问题。终端或 CLI 获得焦点时仍可切换标签,自定义快捷键的修改也无需重启即可生效。
|
||||
|
||||
#### 改进
|
||||
|
||||
|
||||
- 为现有 Git 控件、状态提示、历史时间和文件操作提示补充国际化;切换界面语言时保留提交草稿及其文字选区。由 [@gao-jian-bin](https://github.com/gao-jian-bin) 在 [#118](https://github.com/Kuddev/pebrel/pull/118) 中贡献。
|
||||
- 将 Claude Code 的低分辨率图标替换为从 SVG 导出的 1024 像素图像,并按显示尺寸处理 Agent 图标,使标签图标边缘更平滑。
|
||||
|
||||
## 1.7.0 - 2026-09-12
|
||||
|
||||
|
||||
@@ -306,3 +306,40 @@ unrelated feature's growth. Remote approval/enforcement is not implied by this l
|
||||
Native compilation and UI results must be reported separately.
|
||||
- **Revisit condition:** Add separate system-notification or per-agent controls
|
||||
only when requested, rather than expanding the meaning of this persisted key.
|
||||
|
||||
## ADR-0010 — Administrator shells hosted by the GPUI product
|
||||
|
||||
- **Status:** Requested by the maintainer and implemented, 2026-09-13. Native
|
||||
Windows behavior tests passed; interactive UAC acceptance remains manual.
|
||||
- **Context:** Elevating a shell executable directly opens an external console.
|
||||
Elevating Pebrel without preserving the explicit startup command can instead
|
||||
reach the ordinary resident process and lose both privilege and Shell selection.
|
||||
- **Decision:** The launcher requests Windows UAC for the current Pebrel executable
|
||||
on a worker. It passes the selected Shell's argument vector and working directory
|
||||
through the existing CLI, using Windows argument quoting without a command shell.
|
||||
GPUI startup consumes that command as its first terminal. An already elevated
|
||||
process creates the terminal in its current workspace.
|
||||
- **Ownership:** A process-token check separates elevated instances from ordinary
|
||||
resident forwarding. Elevated instances use the existing loopback API transport
|
||||
with a private endpoint supplied only in local PTY child environments, including
|
||||
WSL passthrough. They do not publish a privileged bearer token in `runtime.port`,
|
||||
acquire its ownership lock, restore/write the shared session, or hide on close.
|
||||
Ordinary discovery files and settings formats remain compatible. Token-query
|
||||
failure uses the isolated policy; launch failure remains visible to the user.
|
||||
- **Lifetime and cost:** UAC runs off the UI thread, one request per workspace at
|
||||
a time. Completion updates only a still-live workspace and does not dismiss a
|
||||
subsequently opened picker. Token status is cached; endpoint injection runs only
|
||||
during PTY creation. No application dependency or additional server is added.
|
||||
- **Validation:** Tests cover native argument parsing, explicit-command startup,
|
||||
ordinary-session exclusion, private discovery/authentication, right-click versus
|
||||
launch, keyboard dismissal, and SSH target stability. Automated coverage does not
|
||||
imply a completed UAC desktop acceptance test.
|
||||
- **Revisit condition:** Supporting an elevated pane inside an existing ordinary
|
||||
process requires a separately reviewed broker and authenticated PTY transport.
|
||||
A future privileged-residency feature must have explicit recovery/discovery.
|
||||
|
||||
The two pane preferences in the same request reuse `nebula_settings`: mouse focus
|
||||
has an optional GUI override of the compatible TOML setting (default off), while
|
||||
inactive-pane dimming defaults on to preserve the existing appearance. Both are
|
||||
cached by the GPUI settings adapter; pointer movement and rendering do not read
|
||||
settings files.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 LobeHub
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -92,6 +92,42 @@ by Git and are not required by the asset tests.
|
||||
|
||||
## Third-Party Logo Assets
|
||||
|
||||
### Claude Code and Trae CLI
|
||||
|
||||
`ai_claude.svg` and `ai_trae.svg` are vector masters from LobeHub's icon
|
||||
collection, retrieved on 2026-09-13 at these exact revisions:
|
||||
|
||||
- Claude: https://github.com/lobehub/lobe-icons/blob/f25f22c0e7295a76077fe417d23deeb56eefdf3e/packages/static-svg/icons/claude-color.svg
|
||||
- Trae: https://github.com/lobehub/lobe-icons/blob/0935313ef153c255958754d9d2523862e069c2c9/packages/static-svg/icons/trae-color.svg
|
||||
- Collection license: [MIT](LICENSE-lobe-icons), copyright LobeHub. Product names
|
||||
and trademarks remain with their respective owners; use identifies running
|
||||
programs and does not imply endorsement.
|
||||
|
||||
The matching PNGs are 1024 x 1024 transparent exports made with
|
||||
`@resvg/resvg-js` 2.6.2 and `fitTo: { mode: 'width', value: 1024 }`, without
|
||||
changing geometry or colors. The renderer is a development tool, not a product
|
||||
dependency. Both shells prepare antialiased textures at the requested physical
|
||||
size and keep the source colors on light and dark themes.
|
||||
|
||||
- Claude PNG SHA-256: `00EF583DAA55F37717C79269A2641C81B0E77F303BEA9E7EEF08482C84D76269`
|
||||
- Trae PNG SHA-256: `C502272694D1ABDD70DBD783F51171A497C136391BE035F2D67146BD9EC0CF23`
|
||||
|
||||
Trae's `trae-cli` executable name is declared by ByteDance's
|
||||
[`trae-agent` project](https://github.com/bytedance/trae-agent/blob/main/pyproject.toml)
|
||||
under `[project.scripts]`. Detection and branding do not imply Hook, cold-start,
|
||||
resume, or fork support.
|
||||
|
||||
### Oh My Pi
|
||||
|
||||
`ai_omp.svg` is a Pebrel-drawn purple gradient π mark following the maintainer's
|
||||
2026-09-13 visual reference. Its simple vector geometry is authored here; no
|
||||
third-party application asset is embedded. This is a presentation variant, not the
|
||||
upstream white π / orange connector artwork currently published in
|
||||
[oh-my-pi/assets/icon.svg](https://github.com/can1357/oh-my-pi/blob/2be354322dee0cdd95bc42fc61c72ef9767b653b/assets/icon.svg).
|
||||
`ai_omp.png` is a transparent 1024 x 1024 export made with the same resvg settings
|
||||
as Claude and Trae above. It identifies the existing `omp` / `oh-my-pi` agent
|
||||
identity and preserves its gradient in both themes.
|
||||
|
||||
### Grok
|
||||
|
||||
`ai_grok_dark.png` and `ai_grok_light.png` are unmodified copies of
|
||||
|
||||
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 45 KiB |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 85 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||
<title>Oh My Pi</title>
|
||||
<defs>
|
||||
<linearGradient id="pi" x1="10" y1="12" x2="51" y2="55" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#e873d6"/>
|
||||
<stop offset=".58" stop-color="#9362ef"/>
|
||||
<stop offset="1" stop-color="#57a8ed"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path fill="url(#pi)" d="M8 12h48v10H44v34H34V22H24v25H14V22H8z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 448 B |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>TRAE</title><path d="M24 20.541H3.428v-3.426H0V3.4h24V20.54zM3.428 17.115h17.144V6.827H3.428v10.288zm8.573-5.196l-2.425 2.424-2.424-2.424 2.424-2.424 2.425 2.424zm6.857-.001l-2.424 2.423-2.425-2.423 2.425-2.425 2.424 2.425z" fill="#32F08C"></path></svg>
|
||||
|
After Width: | Height: | Size: 378 B |
@@ -125,7 +125,16 @@
|
||||
"silent_start": "Start silently",
|
||||
"silent_start_help": "Start in the background without showing a window. Enables the system tray; click its icon to reopen. Applies while the tray is enabled, except when opening a directory explicitly.",
|
||||
"save_failed": "Could not change startup settings: {error}"
|
||||
}
|
||||
},
|
||||
"mouse": {
|
||||
"focus_follows_mouse": "Focus follows mouse",
|
||||
"focus_follows_mouse_description": "Focus a terminal pane when the pointer moves over it. Off by default."
|
||||
},
|
||||
"panes": {
|
||||
"dim_inactive": "Dim inactive panes",
|
||||
"dim_inactive_description": "Darken other split panes to make the active pane easier to identify."
|
||||
},
|
||||
"save_failed": "Could not save settings: {error}"
|
||||
},
|
||||
"provider": {
|
||||
"test": {
|
||||
@@ -525,5 +534,17 @@
|
||||
"following_directory": "The selected directory is unavailable; following the current directory",
|
||||
"command_failed": "{program} failed",
|
||||
"task_start_failed": "Could not start the version control task: {error}"
|
||||
},
|
||||
"launcher": {
|
||||
"open_admin": "Open as administrator",
|
||||
"set_default": "Set as default Shell",
|
||||
"current_default": "Current default Shell",
|
||||
"connect": "Connect",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"default_saved": "{shell} is now the default Shell.",
|
||||
"admin_started": "Requested {shell} in a new administrator Pebrel window.",
|
||||
"admin_cancelled": "Administrator launch cancelled.",
|
||||
"admin_failed": "Could not open an administrator terminal: {error}"
|
||||
}
|
||||
}
|
||||
@@ -125,7 +125,16 @@
|
||||
"silent_start": "静默启动",
|
||||
"silent_start_help": "启动后在后台运行,不显示窗口。开启时会同时启用系统托盘,点击托盘图标可重新打开窗口。托盘启用时生效,显式打开目录时除外。",
|
||||
"save_failed": "无法更改启动设置:{error}"
|
||||
}
|
||||
},
|
||||
"mouse": {
|
||||
"focus_follows_mouse": "焦点跟随鼠标",
|
||||
"focus_follows_mouse_description": "鼠标移入终端窗格时自动聚焦,默认关闭。"
|
||||
},
|
||||
"panes": {
|
||||
"dim_inactive": "调暗非活动窗格",
|
||||
"dim_inactive_description": "降低其他分屏窗格的亮度,突出当前活动窗格。"
|
||||
},
|
||||
"save_failed": "无法保存设置:{error}"
|
||||
},
|
||||
"provider": {
|
||||
"test": {
|
||||
@@ -525,5 +534,17 @@
|
||||
"following_directory": "所选目录不可用,已跟随当前目录",
|
||||
"command_failed": "{program} 失败",
|
||||
"task_start_failed": "无法启动版本控制任务: {error}"
|
||||
},
|
||||
"launcher": {
|
||||
"open_admin": "以管理员身份打开",
|
||||
"set_default": "设为默认 Shell",
|
||||
"current_default": "当前默认 Shell",
|
||||
"connect": "连接",
|
||||
"edit": "编辑",
|
||||
"delete": "删除",
|
||||
"default_saved": "已将 {shell} 设为默认 Shell。",
|
||||
"admin_started": "已请求在新的管理员 Pebrel 窗口中打开 {shell}。",
|
||||
"admin_cancelled": "已取消管理员启动。",
|
||||
"admin_failed": "无法打开管理员终端:{error}"
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,7 @@ pub fn apply(env: &mut HashMap<String, String>, pane_id: impl Display) {
|
||||
}
|
||||
}
|
||||
|
||||
crate::runtime_api::apply_child_endpoint(env);
|
||||
merge_wslenv(env);
|
||||
}
|
||||
|
||||
@@ -172,6 +173,7 @@ const WSLENV_ENTRIES: &[&str] = &[
|
||||
"PEBREL_CLI/p",
|
||||
"PEBREL_BIN_DIR/p",
|
||||
PROCESS_ENV,
|
||||
crate::runtime_api::ENDPOINT_ENV,
|
||||
"NEBULA_CLI/p",
|
||||
"NEBULA_BIN_DIR/p",
|
||||
];
|
||||
@@ -308,6 +310,7 @@ mod tests {
|
||||
assert!(names.contains(&CLI_ENV), "{CLI_ENV} missing from WSLENV passthrough: {names:?}");
|
||||
assert!(names.contains(&BIN_DIR_ENV), "{BIN_DIR_ENV} missing: {names:?}");
|
||||
assert!(names.contains(&PROCESS_ENV), "{PROCESS_ENV} missing: {names:?}");
|
||||
assert!(names.contains(&crate::runtime_api::ENDPOINT_ENV));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
|
||||
@@ -47,10 +47,11 @@ pub enum AgentKind {
|
||||
Kilo,
|
||||
Qoder,
|
||||
Maki,
|
||||
Trae,
|
||||
}
|
||||
|
||||
impl AgentKind {
|
||||
pub const ALL: [Self; 25] = [
|
||||
pub const ALL: [Self; 26] = [
|
||||
Self::Claude,
|
||||
Self::Codex,
|
||||
Self::Gemini,
|
||||
@@ -76,6 +77,7 @@ impl AgentKind {
|
||||
Self::Kilo,
|
||||
Self::Qoder,
|
||||
Self::Maki,
|
||||
Self::Trae,
|
||||
];
|
||||
|
||||
pub fn slug(self) -> &'static str {
|
||||
@@ -105,6 +107,7 @@ impl AgentKind {
|
||||
Self::Kilo => "kilo",
|
||||
Self::Qoder => "qodercli",
|
||||
Self::Maki => "maki",
|
||||
Self::Trae => "trae-cli",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +138,7 @@ impl AgentKind {
|
||||
Self::Kilo => "Kilo Code",
|
||||
Self::Qoder => "Qoder",
|
||||
Self::Maki => "Maki",
|
||||
Self::Trae => "Trae CLI",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +175,8 @@ impl AgentKind {
|
||||
Self::Kilo => &["kilo", "kilo-code"],
|
||||
Self::Qoder => &["qodercli", "qoderclicn", "qoder", "qodercn"],
|
||||
Self::Maki => &["maki"],
|
||||
// ByteDance's trae-agent declares this console entry point.
|
||||
Self::Trae => &["trae-cli"],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,7 +256,8 @@ impl AgentKind {
|
||||
| Self::Kiro
|
||||
| Self::Kilo
|
||||
| Self::Qoder
|
||||
| Self::Maki => return None,
|
||||
| Self::Maki
|
||||
| Self::Trae => return None,
|
||||
Self::Kimi => format!("kimi --session {session_id}"),
|
||||
})
|
||||
}
|
||||
@@ -816,6 +823,18 @@ mod tests {
|
||||
assert_eq!(AgentKind::parse("cargo"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trae_cli_identity_does_not_guess_session_commands() {
|
||||
for command in ["trae-cli", r"C:\tools\TRAE-CLI.EXE", "/usr/bin/trae-cli"] {
|
||||
assert_eq!(AgentKind::parse(command), Some(AgentKind::Trae));
|
||||
}
|
||||
assert_eq!(AgentKind::parse_command("uv run trae-cli --help"), Some(AgentKind::Trae));
|
||||
assert_eq!(AgentKind::parse("trae-cli-helper"), None);
|
||||
assert_eq!(AgentKind::Trae.start_command(), None);
|
||||
assert_eq!(AgentKind::Trae.resume_command("session-1"), None);
|
||||
assert_eq!(AgentKind::Trae.fork_command("session-1"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submitted_commands_resolve_agents_across_wsl_launch_forms() {
|
||||
for agent in AgentKind::ALL {
|
||||
|
||||
@@ -2412,12 +2412,11 @@ impl Display {
|
||||
let grok_uses_light_mark =
|
||||
u32::from(ink.r) * 299 + u32::from(ink.g) * 587 + u32::from(ink.b) * 114 >= 128_000;
|
||||
let key = match logo {
|
||||
AiLogo::Claude | AiLogo::Antigravity => (logo, [0, 0, 0], target_size),
|
||||
AiLogo::Grok if grok_uses_light_mark => (logo, [255, 255, 255], target_size),
|
||||
AiLogo::Grok => (logo, [0, 0, 0], target_size),
|
||||
AiLogo::OpenAi | AiLogo::OpenCode | AiLogo::Pi => {
|
||||
(logo, [ink.r, ink.g, ink.b], target_size)
|
||||
},
|
||||
_ => (logo, [0, 0, 0], target_size),
|
||||
};
|
||||
if let Some(cached) = self.nebula_ai_logo_cache.get(&key) {
|
||||
return Some(cached.clone());
|
||||
@@ -2432,11 +2431,7 @@ impl Display {
|
||||
},
|
||||
};
|
||||
logo.tint_pixels(&mut rgba, [ink.r, ink.g, ink.b]);
|
||||
let (rgba, width, height) = if matches!(logo, AiLogo::Grok | AiLogo::Antigravity) {
|
||||
prepare_ai_logo_texture(&rgba, width, height, target_size)
|
||||
} else {
|
||||
(rgba, width, height)
|
||||
};
|
||||
let (rgba, width, height) = prepare_ai_logo_texture(&rgba, width, height, target_size);
|
||||
let id = AI_LOGO_ID_BASE + self.nebula_ai_logo_cache.len() as u64;
|
||||
let entry = (id, std::sync::Arc::new(rgba), (width, height));
|
||||
self.nebula_ai_logo_cache.insert(key, entry.clone());
|
||||
|
||||
@@ -10,11 +10,21 @@ pub enum AiLogo {
|
||||
Pi,
|
||||
Grok,
|
||||
Antigravity,
|
||||
Trae,
|
||||
OhMyPi,
|
||||
}
|
||||
|
||||
impl AiLogo {
|
||||
pub(crate) const ALL: [Self; 6] =
|
||||
[Self::Claude, Self::OpenAi, Self::OpenCode, Self::Pi, Self::Grok, Self::Antigravity];
|
||||
pub(crate) const ALL: [Self; 8] = [
|
||||
Self::Claude,
|
||||
Self::OpenAi,
|
||||
Self::OpenCode,
|
||||
Self::Pi,
|
||||
Self::Grok,
|
||||
Self::Antigravity,
|
||||
Self::Trae,
|
||||
Self::OhMyPi,
|
||||
];
|
||||
|
||||
/// One source catalog for both shells. Official color assets are embedded unchanged.
|
||||
pub(crate) fn png(self, light_ink: bool) -> &'static [u8] {
|
||||
@@ -26,6 +36,8 @@ impl AiLogo {
|
||||
Self::Grok if light_ink => include_bytes!("../../../extra/logo/ai_grok_light.png"),
|
||||
Self::Grok => include_bytes!("../../../extra/logo/ai_grok_dark.png"),
|
||||
Self::Antigravity => include_bytes!("../../../extra/logo/ai_antigravity.png"),
|
||||
Self::Trae => include_bytes!("../../../extra/logo/ai_trae.png"),
|
||||
Self::OhMyPi => include_bytes!("../../../extra/logo/ai_omp.png"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +46,7 @@ impl AiLogo {
|
||||
Self::OpenAi | Self::Pi => false,
|
||||
// OpenCode stores a luma map: the frame is white, the inner block gray.
|
||||
Self::OpenCode => true,
|
||||
Self::Claude | Self::Grok | Self::Antigravity => return,
|
||||
Self::Claude | Self::Grok | Self::Antigravity | Self::Trae | Self::OhMyPi => return,
|
||||
};
|
||||
for pixel in pixels.chunks_exact_mut(4) {
|
||||
let luma = if preserve_luma { u16::from(pixel[0]) } else { 255 };
|
||||
@@ -109,6 +121,8 @@ pub(crate) fn ai_logo_for_program(program: &str) -> Option<AiLogo> {
|
||||
AgentKind::Pi => Some(AiLogo::Pi),
|
||||
AgentKind::Grok => Some(AiLogo::Grok),
|
||||
AgentKind::Antigravity => Some(AiLogo::Antigravity),
|
||||
AgentKind::Trae => Some(AiLogo::Trae),
|
||||
AgentKind::OhMyPi => Some(AiLogo::OhMyPi),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -128,8 +142,8 @@ pub(crate) fn program_icon(program: &str) -> &'static str {
|
||||
"copilot" => "\u{f4b8}",
|
||||
"cursor" | "cursor-agent" => "\u{f0ec3}",
|
||||
"aider" | "goose" | "crush" | "ollama" => "\u{f06a9}",
|
||||
"opencode" => "\u{f489}",
|
||||
"pi" => "\u{f135}",
|
||||
"opencode" | "trae-cli" => "\u{f489}",
|
||||
"pi" | "omp" | "oh-my-pi" => "\u{f135}",
|
||||
"git" | "lazygit" => "\u{f418}",
|
||||
"vim" | "nvim" | "vi" | "hx" | "nano" => "\u{e62b}",
|
||||
"ssh" | "mosh" => "\u{f489}",
|
||||
@@ -291,7 +305,7 @@ mod tests {
|
||||
fn shared_tint_preserves_color_assets_and_opencode_luminance() {
|
||||
let source = [255, 255, 255, 128, 128, 128, 128, 64];
|
||||
let ink = [100, 200, 240];
|
||||
for logo in [AiLogo::Claude, AiLogo::Grok, AiLogo::Antigravity] {
|
||||
for logo in [AiLogo::Claude, AiLogo::Grok, AiLogo::Antigravity, AiLogo::Trae] {
|
||||
let mut pixels = source;
|
||||
logo.tint_pixels(&mut pixels, ink);
|
||||
assert_eq!(pixels, source);
|
||||
@@ -305,4 +319,34 @@ mod tests {
|
||||
AiLogo::OpenCode.tint_pixels(&mut pixels, ink);
|
||||
assert_eq!(pixels, [100, 200, 240, 128, 50, 100, 120, 64]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_sourced_logos_keep_color_and_antialiased_edges_at_tab_sizes() {
|
||||
for logo in [AiLogo::Claude, AiLogo::Trae, AiLogo::OhMyPi] {
|
||||
let (width, height, source) = decode_png(logo.png(false));
|
||||
assert_eq!((width, height), (1024, 1024));
|
||||
for size in [16, 18, 24, 27, 36, 48] {
|
||||
let (pixels, width, height) = prepare_ai_logo_texture(&source, width, height, size);
|
||||
assert_eq!((width, height), (size, size));
|
||||
assert!(pixels.chunks_exact(4).any(|p| p[3] == 0));
|
||||
assert!(pixels.chunks_exact(4).any(|p| p[3] == 255));
|
||||
let edge_levels: std::collections::HashSet<_> = pixels
|
||||
.chunks_exact(4)
|
||||
.map(|p| p[3])
|
||||
.filter(|alpha| *alpha > 0 && *alpha < 255)
|
||||
.collect();
|
||||
assert!(edge_levels.len() >= 8, "{logo:?} at {size}px");
|
||||
for ink in [[236, 239, 245], [35, 40, 50]] {
|
||||
let mut tinted = pixels.clone();
|
||||
logo.tint_pixels(&mut tinted, ink);
|
||||
assert_eq!(tinted, pixels);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(logo_for_command("TRAE-CLI.EXE"), Some(AiLogo::Trae));
|
||||
assert_eq!(logo_for_command("trae-cli-helper"), None);
|
||||
for command in ["omp", "oh-my-pi", r"C:\tools\OMP.EXE"] {
|
||||
assert_eq!(logo_for_command(command), Some(AiLogo::OhMyPi));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,9 @@ pub struct Settings {
|
||||
pub cursor_blink: Option<bool>,
|
||||
/// 选区完成即复制(旧壳 `copy_on_select` 设置)。
|
||||
pub copy_on_select: bool,
|
||||
/// Pointer handlers and split rendering only read these cached preferences.
|
||||
pub focus_follows_mouse: bool,
|
||||
pub dim_inactive_panes: bool,
|
||||
/// Cached in-app toast preference, independent of native system notifications.
|
||||
pub ai_toasts: bool,
|
||||
/// 标签关闭按钮与标签插入动画都在渲染热路径读取,必须随全局设置驻留内存。
|
||||
@@ -179,6 +182,10 @@ impl Settings {
|
||||
}),
|
||||
cursor_blink: runtime.cursor_blink,
|
||||
copy_on_select: runtime.copy_on_select,
|
||||
focus_follows_mouse: runtime
|
||||
.focus_follows_mouse
|
||||
.unwrap_or(raw.mouse.focus_follows_mouse),
|
||||
dim_inactive_panes: runtime.dim_inactive_panes,
|
||||
ai_toasts: runtime.ai_toasts,
|
||||
tab_close_visible: runtime.tab_close_visible,
|
||||
tab_reveal: runtime.tab_reveal,
|
||||
@@ -448,6 +455,13 @@ fn merge_values(base: toml::Value, other: toml::Value) -> toml::Value {
|
||||
struct RawConfig {
|
||||
font: RawFont,
|
||||
colors: RawColors,
|
||||
mouse: RawMouse,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
struct RawMouse {
|
||||
focus_follows_mouse: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
|
||||
@@ -75,7 +75,10 @@ pub(crate) enum GpuiShellEvent {
|
||||
///
|
||||
/// GPUI 拥有自己的消息循环:主窗形态从主线程调用(winit 不启动);
|
||||
/// spike 形态从专用线程调用。一个进程内只允许调用一次。
|
||||
pub fn run_shell(initial_cwd: Option<std::path::PathBuf>) {
|
||||
pub fn run_shell(
|
||||
initial_cwd: Option<std::path::PathBuf>,
|
||||
initial_command: Option<crate::config::ui_config::Program>,
|
||||
) {
|
||||
let (shell_tx, shell_rx) = std::sync::mpsc::channel();
|
||||
crate::notify::init_gpui_activation(shell_tx.clone());
|
||||
crate::ssh_prompt::install({
|
||||
@@ -111,7 +114,10 @@ pub fn run_shell(initial_cwd: Option<std::path::PathBuf>) {
|
||||
},
|
||||
runtime_hub.clone(),
|
||||
);
|
||||
if runtime_server.is_none() {
|
||||
if runtime_server.is_none()
|
||||
&& initial_command.is_none()
|
||||
&& !crate::platform::elevation::requires_isolation()
|
||||
{
|
||||
// 与另一份进程同时启动时,对方可能已经拿到 owner lock、但尚未来得及
|
||||
// 发布 endpoint。短暂等待并交接,避免继续打开一个无控制面的空窗口。
|
||||
for _ in 0..40 {
|
||||
@@ -151,7 +157,7 @@ pub fn run_shell(initial_cwd: Option<std::path::PathBuf>) {
|
||||
{
|
||||
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, initial_command);
|
||||
});
|
||||
crate::tray::shutdown();
|
||||
}
|
||||
@@ -224,9 +230,16 @@ fn open_main_window(
|
||||
shell_events: std::sync::mpsc::Receiver<GpuiShellEvent>,
|
||||
runtime_hub: crate::runtime_api::RuntimeHub,
|
||||
initial_cwd: Option<std::path::PathBuf>,
|
||||
initial_command: Option<crate::config::ui_config::Program>,
|
||||
) {
|
||||
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,
|
||||
initial_command,
|
||||
);
|
||||
}
|
||||
|
||||
/// 按 `tray` 设置挂上或摘掉系统托盘图标(旧壳 `tray::set_enabled`)。
|
||||
|
||||
@@ -53,6 +53,7 @@ mod theme_picker;
|
||||
|
||||
mod initialization;
|
||||
mod keymap;
|
||||
mod launcher_actions;
|
||||
mod localization;
|
||||
mod navigation;
|
||||
mod shell_picker;
|
||||
@@ -70,7 +71,7 @@ use status::{
|
||||
|
||||
/// 宿主(workspace)监听:设置已写盘 / 终端目录已变 / 请求打开 SSH 会话。
|
||||
pub enum SettingsPaneEvent {
|
||||
/// Return to the workspace. Settings is a window-level page, not a tab.
|
||||
/// Explicitly close Settings and return to the workspace.
|
||||
Close,
|
||||
Changed,
|
||||
/// 导入 Profile 已落盘;Tab 的 Shell 面板若正打开,需要重建候选快照。
|
||||
@@ -330,7 +331,7 @@ impl SettingsPane {
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
if key == "ai_toasts" {
|
||||
if matches!(key, "ai_toasts" | "focus_follows_mouse" | "dim_inactive_panes") {
|
||||
if let Err(error) = self.try_persist(&[(key, (value as u8).to_string())], cx) {
|
||||
let language = crate::gpui_shell::config::ui_language(cx);
|
||||
super::toast::toast(
|
||||
@@ -338,7 +339,11 @@ impl SettingsPane {
|
||||
cx,
|
||||
super::toast::ToastKind::Warning,
|
||||
language.format(
|
||||
crate::i18n::Message::SettingsNotificationsSaveFailed,
|
||||
if key == "ai_toasts" {
|
||||
crate::i18n::Message::SettingsNotificationsSaveFailed
|
||||
} else {
|
||||
crate::i18n::Message::SettingsSaveFailed
|
||||
},
|
||||
&[("error", &error.to_string())],
|
||||
),
|
||||
);
|
||||
@@ -679,6 +684,8 @@ impl SettingsPane {
|
||||
match key {
|
||||
"follow_system_theme" => flag!(follow_system_theme),
|
||||
"copy_on_select" => flag!(copy_on_select),
|
||||
"focus_follows_mouse" => Some((cur.focus_follows_mouse.is_some(), String::new())),
|
||||
"dim_inactive_panes" => flag!(dim_inactive_panes),
|
||||
"multiline_paste_confirm" => flag!(multiline_paste_confirm),
|
||||
"tab_close_visible" => flag!(tab_close_visible),
|
||||
"terminal_proxy" => flag!(terminal_proxy),
|
||||
@@ -1070,6 +1077,18 @@ impl SettingsPane {
|
||||
.gap(px(GROUP_GAP))
|
||||
.child(
|
||||
self.group(language.pick("鼠标与选区", "Mouse and selection"), cx)
|
||||
.child(
|
||||
self.switch_row(
|
||||
"focus_follows_mouse",
|
||||
language.text(crate::i18n::Message::SettingsMouseFocusFollowsMouse),
|
||||
language.text(
|
||||
crate::i18n::Message::SettingsMouseFocusFollowsMouseDescription,
|
||||
),
|
||||
cx.try_global::<crate::gpui_shell::config::Settings>()
|
||||
.is_some_and(|settings| settings.focus_follows_mouse),
|
||||
cx,
|
||||
),
|
||||
)
|
||||
.child(self.switch_row(
|
||||
"copy_on_select",
|
||||
language.pick("选中即复制", "Copy on select"),
|
||||
|
||||
@@ -72,6 +72,13 @@ impl SettingsPane {
|
||||
),
|
||||
)
|
||||
.child(self.font_size_row(true, cx))
|
||||
.child(self.switch_row(
|
||||
"dim_inactive_panes",
|
||||
language.text(crate::i18n::Message::SettingsPanesDimInactive),
|
||||
language.text(crate::i18n::Message::SettingsPanesDimInactiveDescription),
|
||||
self.runtime.dim_inactive_panes,
|
||||
cx,
|
||||
))
|
||||
.child(self.switch_row(
|
||||
"tab_close_visible",
|
||||
language.pick("显示标签关闭按钮", "Show tab close buttons"),
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
//! Settings entry points for actions initiated in the Shell/SSH launcher.
|
||||
|
||||
use super::*;
|
||||
|
||||
impl SettingsPane {
|
||||
pub(in crate::gpui_shell) fn sync_launcher_shell(
|
||||
&mut self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.runtime = RuntimeSettings::load();
|
||||
self.restore_shell_selection(window, cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(in crate::gpui_shell) fn manage_launcher_ssh(
|
||||
&mut self,
|
||||
host: String,
|
||||
edit: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.settings_search_input.update(cx, |input, cx| input.set_value("", window, cx));
|
||||
self.search_origin_section = None;
|
||||
self.active_section = SECTION_IDS.iter().position(|section| *section == "ssh").unwrap();
|
||||
self.ssh_hosts = crate::gpui_shell::ssh_hosts::SshHostLists::load();
|
||||
self.prepare_launcher_ssh_host(&host, window, cx);
|
||||
if edit {
|
||||
self.open_ssh_editor(Some(host), window, cx);
|
||||
} else {
|
||||
if self.ssh_editor.is_some() {
|
||||
self.close_ssh_editor(window, cx);
|
||||
}
|
||||
self.ssh_delete_confirm = Some(host);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
@@ -27,12 +27,12 @@ pub(super) const SECTION_IDS: [&str; 10] = [
|
||||
/// section that owns the control instead of merely filtering the current page.
|
||||
pub(super) const SECTION_SEARCH_TERMS: [&str; 10] = [
|
||||
"application app 应用 update 更新 version 版本 github support 支持",
|
||||
"appearance 外观 theme 主题 font 字体 opacity 透明度 background 背景 cursor 光标 icon 图标",
|
||||
"appearance 外观 theme 主题 font 字体 opacity 透明度 background 背景 cursor 光标 icon 图标 dim inactive panes 调暗非活动窗格 分屏变暗",
|
||||
"profiles 配置文件 shell terminal 终端 completion 补全 startup 启动 ai message notifications toast alerts bell 提醒 通知 弹窗 消息 右下角 ai消息通知 ai 消息通知 ai消息弹窗 ai 消息弹窗 铃声",
|
||||
"providers provider ai 供应商 模型 api",
|
||||
"ssh host 主机 remote 远程 connection 连接",
|
||||
"network 网络 proxy 代理 connectivity 连接",
|
||||
"interaction 交互 copy 复制 paste 粘贴 tab 标签 panel 面板",
|
||||
"interaction 交互 copy 复制 paste 粘贴 tab 标签 panel 面板 focus follows mouse 焦点跟随鼠标 自动聚焦",
|
||||
"keymap key binding shortcut quick terminal 快速终端 独立窗口 已有窗口 按键映射 快捷键",
|
||||
"advanced 高级 session 会话 tray 托盘 restore 恢复 startup autostart login silent 自启动 静默启动 开机 登录",
|
||||
"backup 备份 export 导出 restore 恢复",
|
||||
|
||||
@@ -53,6 +53,18 @@ impl HostLibraryState {
|
||||
}
|
||||
|
||||
impl SettingsPane {
|
||||
pub(in crate::gpui_shell) fn prepare_launcher_ssh_host(
|
||||
&mut self,
|
||||
host: &str,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.ssh_library.scope = HostScope::All;
|
||||
self.ssh_library.group_filter = None;
|
||||
self.ssh_library.search.update(cx, |input, cx| input.set_value(host, window, cx));
|
||||
self.ssh_library.reset_scroll();
|
||||
}
|
||||
|
||||
fn filtered_library_hosts(&self, cx: &gpui::App) -> Vec<String> {
|
||||
let query = self.ssh_library.search.read(cx).value();
|
||||
let hosts = if self.ssh_library.scope == HostScope::Recent {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
use super::*;
|
||||
use gpui_component::WindowExt as _;
|
||||
|
||||
#[cfg(all(test, feature = "gpui-test-support"))]
|
||||
mod tests;
|
||||
|
||||
impl TerminalView {
|
||||
pub(in crate::gpui_shell::terminal) fn scrollbar_thumb(
|
||||
@@ -453,7 +457,7 @@ impl TerminalView {
|
||||
pub(super) fn on_mouse_move(
|
||||
&mut self,
|
||||
event: &MouseMoveEvent,
|
||||
_window: &mut Window,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.move_completion_popup_scrollbar(event, cx) {
|
||||
@@ -502,6 +506,19 @@ impl TerminalView {
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
// The element's hitbox already excludes occluding menus. Do not move
|
||||
// focus during a drag or let pointer movement dismiss modal input.
|
||||
if event.pressed_button.is_none()
|
||||
&& !cx.has_active_drag()
|
||||
&& window.is_window_active()
|
||||
&& !self.focus_handle.is_focused(window)
|
||||
&& cx.try_global::<Settings>().is_some_and(|settings| settings.focus_follows_mouse)
|
||||
&& !window.has_active_dialog(cx)
|
||||
&& !window.has_active_sheet(cx)
|
||||
{
|
||||
window.focus(&self.focus_handle, cx);
|
||||
cx.emit(TerminalViewEvent::FocusRequested);
|
||||
}
|
||||
if self.mouse_mode_active(&event.modifiers) {
|
||||
self.clear_link_hover(cx);
|
||||
// 鼠标模式的移动上报:拖动 = 按钮码+32(需 DRAG 或 MOTION 任一),
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
use super::*;
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{Entity, Focusable as _, Modifiers, Render, TestAppContext, VisualTestContext};
|
||||
use gpui_component::{
|
||||
Root,
|
||||
input::{Input, InputState},
|
||||
};
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
struct Probe {
|
||||
terminal: Entity<TerminalView>,
|
||||
input: Entity<InputState>,
|
||||
overlay: bool,
|
||||
requests: Rc<Cell<usize>>,
|
||||
_subscription: gpui::Subscription,
|
||||
}
|
||||
|
||||
impl Render for Probe {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut gpui::Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.size_full()
|
||||
.relative()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(div().h(px(40.0)).child(Input::new(&self.input)))
|
||||
.child(
|
||||
div()
|
||||
.id("mouse-terminal")
|
||||
.debug_selector(|| "mouse-terminal".to_owned())
|
||||
.flex_1()
|
||||
.child(self.terminal.clone()),
|
||||
)
|
||||
.when(self.overlay, |root| root.child(div().absolute().inset_0().occlude()))
|
||||
.children(Root::render_dialog_layer(window, cx))
|
||||
}
|
||||
}
|
||||
|
||||
fn open(cx: &mut TestAppContext) -> (Entity<Probe>, VisualTestContext) {
|
||||
cx.update(|cx| {
|
||||
gpui_component::init(cx);
|
||||
let mut settings = Settings::load(nebula_settings::ThemeName::Nord);
|
||||
settings.focus_follows_mouse = false;
|
||||
cx.set_global(settings);
|
||||
});
|
||||
let mut output = None;
|
||||
let (_, window) = cx.add_window_view(|window, cx| {
|
||||
let terminal = cx.new(|cx| {
|
||||
TerminalView::new(
|
||||
1,
|
||||
(80, 24),
|
||||
super::super::TerminalLaunch::Local {
|
||||
cwd: None,
|
||||
// Exercise the real terminal element without creating a shell session.
|
||||
shell: Some(nebula_terminal::tty::Shell::new(
|
||||
"pebrel-test-missing-shell-executable".into(),
|
||||
Vec::new(),
|
||||
)),
|
||||
shell_name: None,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
let input = cx.new(|cx| InputState::new(window, cx));
|
||||
input.read(cx).focus_handle(cx).focus(window, cx);
|
||||
let view = cx.new(|cx| {
|
||||
let requests = Rc::new(Cell::new(0));
|
||||
let count = requests.clone();
|
||||
let subscription = cx.subscribe(&terminal, move |_, _, event, _| {
|
||||
if matches!(event, TerminalViewEvent::FocusRequested) {
|
||||
count.set(count.get() + 1);
|
||||
}
|
||||
});
|
||||
Probe { terminal, input, overlay: false, requests, _subscription: subscription }
|
||||
});
|
||||
output = Some(view.clone());
|
||||
Root::new(view, window, cx)
|
||||
});
|
||||
let mut window = window.clone();
|
||||
window.simulate_resize(gpui::size(px(700.0), px(500.0)));
|
||||
// TestPlatform creates inactive windows; real pointer focus requires the
|
||||
// OS activation event as well as an element focus handle.
|
||||
window.update(|window, _| window.activate_window());
|
||||
draw(&mut window);
|
||||
window.update(|window, _| assert!(window.is_window_active()));
|
||||
(output.unwrap(), window)
|
||||
}
|
||||
|
||||
fn draw(cx: &mut VisualTestContext) {
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, cx| {
|
||||
window.refresh();
|
||||
window.draw(cx).clear(cx);
|
||||
});
|
||||
}
|
||||
|
||||
fn move_over(cx: &mut VisualTestContext, button: Option<MouseButton>) {
|
||||
let position = cx.debug_bounds("mouse-terminal").unwrap().center();
|
||||
cx.simulate_mouse_move(position, button, Modifiers::default());
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn hover_focus_requires_opt_in_and_emits_once(cx: &mut TestAppContext) {
|
||||
let (probe, mut cx) = open(cx);
|
||||
move_over(&mut cx, None);
|
||||
assert_eq!(probe.read_with(&cx, |probe, _| probe.requests.get()), 0);
|
||||
cx.update(|_, cx| cx.global_mut::<Settings>().focus_follows_mouse = true);
|
||||
move_over(&mut cx, None);
|
||||
cx.update(|window, cx| {
|
||||
assert!(probe.read(cx).terminal.read(cx).focus_handle.is_focused(window))
|
||||
});
|
||||
move_over(&mut cx, None);
|
||||
assert_eq!(probe.read_with(&cx, |probe, _| probe.requests.get()), 1);
|
||||
cx.update(|window, cx| {
|
||||
cx.global_mut::<Settings>().focus_follows_mouse = false;
|
||||
probe.read(cx).input.read(cx).focus_handle(cx).focus(window, cx);
|
||||
});
|
||||
move_over(&mut cx, None);
|
||||
assert_eq!(probe.read_with(&cx, |probe, _| probe.requests.get()), 1);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn hover_does_not_steal_focus_during_drag_overlay_dialog_or_inactive_window(
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
let (probe, mut cx) = open(cx);
|
||||
cx.update(|_, cx| cx.global_mut::<Settings>().focus_follows_mouse = true);
|
||||
for button in [MouseButton::Left, MouseButton::Right, MouseButton::Middle] {
|
||||
move_over(&mut cx, Some(button));
|
||||
}
|
||||
probe.update(&mut cx, |probe, cx| {
|
||||
probe.overlay = true;
|
||||
cx.notify();
|
||||
});
|
||||
draw(&mut cx);
|
||||
move_over(&mut cx, None);
|
||||
probe.update(&mut cx, |probe, cx| {
|
||||
probe.overlay = false;
|
||||
cx.notify();
|
||||
});
|
||||
draw(&mut cx);
|
||||
cx.update(|window, cx| window.open_dialog(cx, |dialog, _, _| dialog.title("focus fixture")));
|
||||
draw(&mut cx);
|
||||
move_over(&mut cx, None);
|
||||
cx.update(|window, cx| window.close_dialog(cx));
|
||||
draw(&mut cx);
|
||||
cx.deactivate_window();
|
||||
cx.update(|window, _| assert!(!window.is_window_active()));
|
||||
move_over(&mut cx, None);
|
||||
assert_eq!(probe.read_with(&cx, |probe, _| probe.requests.get()), 0);
|
||||
}
|
||||
@@ -49,10 +49,14 @@ mod agents;
|
||||
mod closing;
|
||||
mod command_manager;
|
||||
mod keyboard_bindings;
|
||||
use keyboard_bindings::custom_workspace_binding;
|
||||
#[cfg(test)]
|
||||
use keyboard_bindings::{
|
||||
STATIC_DEFAULT_COMBOS, custom_workspace_binding, default_workspace_bindings, gpui_binding_combo,
|
||||
};
|
||||
mod documents;
|
||||
mod file_tree;
|
||||
mod key_actions;
|
||||
mod launcher_menu;
|
||||
mod notifications;
|
||||
mod palette;
|
||||
mod pane_header;
|
||||
@@ -157,162 +161,9 @@ fn title_bar_panel_controls() -> gpui::Div {
|
||||
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
|
||||
}
|
||||
|
||||
/// 工作区静态默认绑定的 combo 集([`init`] 的镜像)。撤销已失效的自定义
|
||||
/// 注入时要排除:gpui 的 NoAction 打在静态默认键上会误杀基础功能。
|
||||
const STATIC_DEFAULT_COMBOS: &[&str] = &[
|
||||
"ctrl-shift-t",
|
||||
"ctrl-shift-e",
|
||||
"ctrl-shift-w",
|
||||
"ctrl-shift-b",
|
||||
"ctrl-,",
|
||||
"ctrl-shift-p",
|
||||
"ctrl-k",
|
||||
"ctrl-shift-f",
|
||||
"escape",
|
||||
"ctrl-shift-d",
|
||||
"ctrl-alt-shift-s",
|
||||
"f2",
|
||||
"ctrl-shift-enter",
|
||||
"ctrl-alt-left",
|
||||
"ctrl-alt-right",
|
||||
"ctrl-alt-up",
|
||||
"ctrl-alt-down",
|
||||
"ctrl-tab",
|
||||
"ctrl-shift-tab",
|
||||
"ctrl-shift-pageup",
|
||||
"ctrl-shift-pagedown",
|
||||
"ctrl-shift-g",
|
||||
"ctrl-=",
|
||||
"ctrl-+",
|
||||
"ctrl--",
|
||||
"ctrl-0",
|
||||
"ctrl-shift-c",
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
"ctrl-c",
|
||||
"ctrl-v",
|
||||
"ctrl-shift-v",
|
||||
"alt-enter",
|
||||
"ctrl-shift-o",
|
||||
];
|
||||
|
||||
/// `keybind=` 自定义表(两壳共读)中 config::Action → GPUI 工作区动作的
|
||||
/// 映射。仍跳过未接线的动作(prompt 跳转、搜索):编辑器可读写,这里不
|
||||
/// 注入。`CreateNewWindow` 复用同一个 GPUI App 和进程级 hook/runtime。
|
||||
|
||||
/// 存储格式 combo(`ctrl+shift+t`)→ gpui 绑定串(`ctrl-shift-t`)。键名
|
||||
/// 两套体系同构(小写命名键 + 单字符);digitN 折回数字,plus/minus 折回
|
||||
/// `+`/`-`(`+` 是存储分隔符,必须先占位再替换)。
|
||||
fn gpui_binding_combo(combo: &str) -> String {
|
||||
combo
|
||||
.replace("plus", "\u{1}")
|
||||
.replace("minus", "\u{2}")
|
||||
.replace('+', "-")
|
||||
.replace("digit", "")
|
||||
.replace('\u{1}', "+")
|
||||
.replace('\u{2}', "-")
|
||||
}
|
||||
|
||||
/// 注册工作区快捷键;在 `gpui_component::init` 之后调用一次。
|
||||
pub fn init(cx: &mut App) {
|
||||
cx.bind_keys(default_workspace_bindings());
|
||||
#[cfg(target_os = "macos")]
|
||||
bind_macos_command_keys(cx);
|
||||
}
|
||||
|
||||
/// 工作区静态默认键位表;与 [`STATIC_DEFAULT_COMBOS`] 互为镜像。
|
||||
fn default_workspace_bindings() -> Vec<KeyBinding> {
|
||||
[
|
||||
KeyBinding::new("ctrl-shift-t", NewTerminal, None),
|
||||
KeyBinding::new("ctrl-alt-shift-s", recipes::OpenLayoutRecipes, None),
|
||||
KeyBinding::new("ctrl-shift-e", NewWindow, None),
|
||||
KeyBinding::new("ctrl-shift-w", CloseActiveTerminal, None),
|
||||
KeyBinding::new("ctrl-shift-b", ToggleSidebar, None),
|
||||
KeyBinding::new("ctrl-,", OpenSettings, None),
|
||||
KeyBinding::new("ctrl-shift-p", ToggleCommandPalette, None),
|
||||
KeyBinding::new("ctrl-k", ToggleShellPicker, None),
|
||||
KeyBinding::new("ctrl-shift-f", ToggleFileTree, None),
|
||||
// Esc 只在命令/Shell 面板打开时关面板。绑成 `None` 会在终端聚焦时
|
||||
// 抢走按键,CC/Codex 收不到 0x1b(旧壳无 overlay 时 Esc 一定进 PTY)。
|
||||
KeyBinding::new("escape", CloseCommandPalette, Some(PALETTE_KEY_CONTEXT)),
|
||||
// 分屏(旧壳 nebula_key_bindings 同键位):ctrl+shift+d 左右、
|
||||
// ctrl+shift+s 上下、ctrl+shift+enter 缩放、ctrl+alt+方向切聚焦。
|
||||
KeyBinding::new("ctrl-shift-d", SplitRight, None),
|
||||
KeyBinding::new("ctrl-shift-s", SplitDown, None),
|
||||
// F2 重命名活动标签(旧壳同键位);右键菜单的键帽读的就是这条。
|
||||
KeyBinding::new("f2", RenameActiveTab, None),
|
||||
KeyBinding::new("ctrl-shift-enter", ToggleZoom, None),
|
||||
KeyBinding::new("ctrl-alt-left", FocusPaneLeft, None),
|
||||
KeyBinding::new("ctrl-alt-right", FocusPaneRight, None),
|
||||
KeyBinding::new("ctrl-alt-up", FocusPaneUp, None),
|
||||
KeyBinding::new("ctrl-alt-down", FocusPaneDown, None),
|
||||
KeyBinding::new("ctrl-tab", SelectNextTab, None),
|
||||
KeyBinding::new("ctrl-shift-tab", SelectPreviousTab, None),
|
||||
// 标签位置左右移动(WT 的 moveTab forward/backward;键位取 VS Code
|
||||
// 的「移动编辑器」同构)。我们自己的回滚翻页只吃**不带 ctrl** 的
|
||||
// shift+pageup(view.rs 的滚动分支),两者不冲突。
|
||||
KeyBinding::new("ctrl-shift-pageup", MoveTabLeft, None),
|
||||
KeyBinding::new("ctrl-shift-pagedown", MoveTabRight, None),
|
||||
KeyBinding::new("ctrl-shift-g", ToggleGitPanel, None),
|
||||
KeyBinding::new("ctrl-=", IncreaseFontSize, None),
|
||||
KeyBinding::new("ctrl-+", IncreaseFontSize, None),
|
||||
KeyBinding::new("ctrl--", DecreaseFontSize, None),
|
||||
KeyBinding::new("ctrl-0", ResetFontSize, None),
|
||||
KeyBinding::new("ctrl-shift-c", CopySelection, None),
|
||||
// 复制优先(WT 语义):终端聚焦时有选区复制并清选区,无选区经 handler
|
||||
// 的 `cx.propagate()` 落成 ^C。带终端上下文,重命名/输入框聚焦时
|
||||
// ctrl+c 归输入框自己(Input -> Copy)。
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
KeyBinding::new("ctrl-c", CopySelection, Some(crate::gpui_shell::terminal::KEY_CONTEXT)),
|
||||
// 终端粘贴只在终端焦点路径命中。Input 自己带 `Input -> Paste`;这里若
|
||||
// 无上下文,会因注册更晚而抢走弹窗/设置页输入框的 Ctrl+V。
|
||||
KeyBinding::new("ctrl-v", PasteClipboard, Some(crate::gpui_shell::terminal::KEY_CONTEXT)),
|
||||
KeyBinding::new(
|
||||
"ctrl-shift-v",
|
||||
PasteClipboard,
|
||||
Some(crate::gpui_shell::terminal::KEY_CONTEXT),
|
||||
),
|
||||
KeyBinding::new("ctrl-shift-v", gpui_component::input::Paste, Some("Input")),
|
||||
KeyBinding::new("alt-enter", ToggleFullscreen, None),
|
||||
KeyBinding::new("ctrl-shift-o", OpenQuickJump, None),
|
||||
]
|
||||
.into()
|
||||
}
|
||||
|
||||
/// macOS 的原生修饰键是 ⌘:在 Ctrl 绑定之外**追加**一套 ⌘ 绑定,不替换。
|
||||
/// 追加而非替换有两个原因:Ctrl+Shift 组合在 Mac 终端里没有别的含义,留着
|
||||
/// 不碍事;而 ⌘C/⌘V 必须存在,否则 Mac 用户第一反应就是「复制粘贴坏了」。
|
||||
/// 终端里的 Ctrl+C 仍然是 SIGINT——这里只绑 ⌘,不碰 Ctrl 的语义。
|
||||
#[cfg(target_os = "macos")]
|
||||
fn bind_macos_command_keys(cx: &mut App) {
|
||||
cx.bind_keys([
|
||||
KeyBinding::new("cmd-t", NewTerminal, None),
|
||||
KeyBinding::new("cmd-n", NewWindow, None),
|
||||
KeyBinding::new("cmd-w", CloseActiveTerminal, None),
|
||||
KeyBinding::new("cmd-b", ToggleSidebar, None),
|
||||
KeyBinding::new("cmd-,", OpenSettings, None),
|
||||
KeyBinding::new("cmd-shift-p", ToggleCommandPalette, None),
|
||||
KeyBinding::new("cmd-k", ToggleShellPicker, None),
|
||||
KeyBinding::new("cmd-shift-f", ToggleFileTree, None),
|
||||
KeyBinding::new("cmd-d", SplitRight, None),
|
||||
KeyBinding::new("cmd-shift-d", SplitDown, None),
|
||||
KeyBinding::new("cmd-shift-enter", ToggleZoom, None),
|
||||
KeyBinding::new("cmd-alt-left", FocusPaneLeft, None),
|
||||
KeyBinding::new("cmd-alt-right", FocusPaneRight, None),
|
||||
KeyBinding::new("cmd-alt-up", FocusPaneUp, None),
|
||||
KeyBinding::new("cmd-alt-down", FocusPaneDown, None),
|
||||
KeyBinding::new("cmd-shift-]", SelectNextTab, None),
|
||||
KeyBinding::new("cmd-shift-[", SelectPreviousTab, None),
|
||||
KeyBinding::new("cmd-shift-g", ToggleGitPanel, None),
|
||||
KeyBinding::new("cmd-=", IncreaseFontSize, None),
|
||||
KeyBinding::new("cmd-+", IncreaseFontSize, None),
|
||||
KeyBinding::new("cmd--", DecreaseFontSize, None),
|
||||
KeyBinding::new("cmd-0", ResetFontSize, None),
|
||||
KeyBinding::new("cmd-c", CopySelection, Some(crate::gpui_shell::terminal::KEY_CONTEXT)),
|
||||
KeyBinding::new("cmd-v", PasteClipboard, Some(crate::gpui_shell::terminal::KEY_CONTEXT)),
|
||||
KeyBinding::new("cmd-v", gpui_component::input::Paste, Some("Input")),
|
||||
KeyBinding::new("cmd-ctrl-f", ToggleFullscreen, None),
|
||||
KeyBinding::new("cmd-shift-o", OpenQuickJump, None),
|
||||
]);
|
||||
keyboard_bindings::init(cx);
|
||||
}
|
||||
|
||||
/// 一个终端 pane:视图实体 + 宿主订阅。id 即 `TerminalView::pane_id`
|
||||
@@ -852,6 +703,8 @@ pub struct NebulaWorkspace {
|
||||
/// Window-level Settings surface. It intentionally lives outside `tabs`:
|
||||
/// opening preferences must not alter tab order, session state, or PTYs.
|
||||
settings_surface: Option<(Entity<SettingsPane>, Subscription)>,
|
||||
/// Presence of the singleton Settings tab, independent of the active page.
|
||||
settings_tab_open: bool,
|
||||
settings_open: bool,
|
||||
/// Sidebar state sampled on entry. `None` means top-tab mode, where there
|
||||
/// is no left rail to fold and the setting must remain untouched.
|
||||
@@ -930,6 +783,8 @@ pub struct NebulaWorkspace {
|
||||
/// 三点 / Ctrl+K 打开的是旧壳 `PaletteMode::Profiles`,要画
|
||||
/// 全部/SSH/Shell 芯片;Ctrl+Shift+P 的命令目录不走这条。
|
||||
shell_picker_open: bool,
|
||||
launcher_menu: Option<launcher_menu::LauncherContextMenu>,
|
||||
launcher_admin_task: Option<gpui::Task<()>>,
|
||||
launcher_filter: crate::display::command_palette::LauncherFilter,
|
||||
/// `Some` 表示当前是统一 Quick Jump;值就是 provider scope。
|
||||
/// 命令面板与 Shell picker 必须保持 `None`,避免跨入口泄漏筛选状态。
|
||||
@@ -1157,6 +1012,7 @@ impl NebulaWorkspace {
|
||||
.saturating_add(1),
|
||||
active: 0,
|
||||
settings_surface: None,
|
||||
settings_tab_open: false,
|
||||
settings_open: false,
|
||||
settings_restore_sidebar_collapsed: None,
|
||||
settings_restore_side_panel_open: false,
|
||||
@@ -1198,6 +1054,8 @@ impl NebulaWorkspace {
|
||||
vcs_discard_confirm: None,
|
||||
palette_override: None,
|
||||
shell_picker_open: false,
|
||||
launcher_menu: None,
|
||||
launcher_admin_task: None,
|
||||
launcher_filter: crate::display::command_palette::LauncherFilter::All,
|
||||
quick_jump_filter: None,
|
||||
command_palette_scroll: gpui::ScrollHandle::new(),
|
||||
@@ -1257,6 +1115,9 @@ impl NebulaWorkspace {
|
||||
windowing::WorkspaceStartup::NewTerminal { cwd } => {
|
||||
this.add_terminal_at(cwd, None, window, cx);
|
||||
},
|
||||
windowing::WorkspaceStartup::LaunchTerminal { launch, cwd } => {
|
||||
this.add_terminal_with(launch, cwd, None, window, cx);
|
||||
},
|
||||
windowing::WorkspaceStartup::Empty => {},
|
||||
}
|
||||
if let Some(ai_events) = ai_events {
|
||||
@@ -2166,59 +2027,6 @@ impl NebulaWorkspace {
|
||||
Session::new(active_out, tabs)
|
||||
}
|
||||
|
||||
/// Open Settings as a window-level page while preserving the active tab.
|
||||
/// Side-tab mode folds its real left rail; top-tab mode has no such rail,
|
||||
/// so touching `sidebar_collapsed` there would only create hidden state.
|
||||
fn open_settings(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.settings_open {
|
||||
if let Some((view, _)) = self.settings_surface.as_ref() {
|
||||
let focus = view.read(cx).focus_handle(cx);
|
||||
window.defer(cx, move |window, cx| window.focus(&focus, cx));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if self.settings_surface.is_none() {
|
||||
let view = cx.new(|cx| SettingsPane::new(window, cx));
|
||||
let subscription = cx.subscribe_in(&view, window, Self::on_settings_event);
|
||||
self.settings_surface = Some((view, subscription));
|
||||
}
|
||||
|
||||
self.settings_open = true;
|
||||
self.sync_settings_layout();
|
||||
|
||||
self.settings_restore_side_panel_open = self.side_panel.open;
|
||||
if self.side_panel.open {
|
||||
self.side_panel.open = false;
|
||||
self.side_panel_anim_armed = true;
|
||||
self.file_tree_menu = None;
|
||||
}
|
||||
|
||||
if let Some((view, _)) = self.settings_surface.as_ref() {
|
||||
let focus = view.read(cx).focus_handle(cx);
|
||||
window.defer(cx, move |window, cx| window.focus(&focus, cx));
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn close_settings(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if !self.settings_open {
|
||||
return;
|
||||
}
|
||||
self.leave_settings(window, cx);
|
||||
if self.tabs.is_empty() {
|
||||
windowing::close_empty_workspace_window(self.runtime_window_id, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_settings(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.settings_open {
|
||||
self.close_settings(window, cx);
|
||||
} else {
|
||||
self.open_settings(window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// 按视图实体反查 (tab 下标, pane id)。
|
||||
fn locate_pane(&self, entity_id: gpui::EntityId) -> Option<(usize, u64)> {
|
||||
self.tabs.iter().enumerate().find_map(|(ix, tab)| match tab {
|
||||
@@ -2388,9 +2196,13 @@ impl NebulaWorkspace {
|
||||
}
|
||||
}
|
||||
|
||||
if self.tabs.is_empty() && !self.settings_open {
|
||||
windowing::close_empty_workspace_window(self.runtime_window_id, window, cx);
|
||||
return;
|
||||
if self.tabs.is_empty() {
|
||||
if self.settings_tab_open {
|
||||
self.open_settings(window, cx);
|
||||
} else {
|
||||
windowing::close_empty_workspace_window(self.runtime_window_id, window, cx);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if ix < self.active {
|
||||
self.active -= 1;
|
||||
@@ -2409,9 +2221,10 @@ impl NebulaWorkspace {
|
||||
}
|
||||
|
||||
fn activate_tab(&mut self, ix: usize, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.settings_open {
|
||||
self.close_settings(window, cx);
|
||||
if ix >= self.tabs.len() {
|
||||
return;
|
||||
}
|
||||
self.leave_settings(window, cx);
|
||||
if ix < self.tabs.len() && ix != self.active {
|
||||
self.clear_reader_focus(cx);
|
||||
self.active = ix;
|
||||
@@ -2788,6 +2601,7 @@ impl NebulaWorkspace {
|
||||
}
|
||||
|
||||
fn dismiss_palette_state(&mut self) {
|
||||
self.launcher_menu = None;
|
||||
self.command_palette_open = false;
|
||||
self.palette_override = None;
|
||||
self.shell_picker_open = false;
|
||||
@@ -3541,7 +3355,13 @@ impl NebulaWorkspace {
|
||||
// 与旧壳一致:不用焦点描边,仅给非活动 pane 覆 30%
|
||||
// 黑色 veil。veil 只盖终端区——压暗标题条会把四个
|
||||
// pane 的标题一起糊成灰。
|
||||
.when(!is_focused, |pane| pane.child(veil)),
|
||||
.when(
|
||||
!is_focused
|
||||
&& cx
|
||||
.try_global::<crate::gpui_shell::config::Settings>()
|
||||
.is_none_or(|settings| settings.dim_inactive_panes),
|
||||
|pane| pane.child(veil),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
},
|
||||
@@ -4041,6 +3861,7 @@ impl Render for NebulaWorkspace {
|
||||
.on_action(cx.listener(|this, _: &FocusPaneDown, window, cx| {
|
||||
this.navigate_pane(SplitNav::Down, window, cx);
|
||||
}))
|
||||
.on_action(cx.listener(Self::select_tab))
|
||||
.on_action(cx.listener(|this, _: &SelectNextTab, window, cx| {
|
||||
this.select_adjacent_tab(true, window, cx);
|
||||
}))
|
||||
@@ -4344,6 +4165,7 @@ impl Render for NebulaWorkspace {
|
||||
root.child(menu)
|
||||
})
|
||||
.when_some(self.render_tab_context_menu(), |root, menu| root.child(menu))
|
||||
.when_some(self.render_launcher_context_menu(), |root, menu| root.child(menu))
|
||||
.when_some(self.render_selection_context_menu(), |root, menu| {
|
||||
root.child(menu)
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Workspace 快捷键动作实现(从 `workspace.rs` 拆出以守行数预算)。
|
||||
//! Workspace keyboard actions and their tab/focus transitions.
|
||||
|
||||
use gpui::{Context, Window};
|
||||
|
||||
@@ -15,12 +15,43 @@ pub(super) fn move_target(active: usize, len: usize, right: bool) -> Option<usiz
|
||||
}
|
||||
|
||||
impl NebulaWorkspace {
|
||||
pub(super) fn select_tab(
|
||||
&mut self,
|
||||
action: &super::keyboard_bindings::SelectTab,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
// Validate before activate_tab, which also leaves Settings. Missing
|
||||
// numbered tabs must not change the active page or its focus.
|
||||
let top = self.tabs_position == nebula_settings::TabsPositionName::Top;
|
||||
let count = if top { self.top_tab_count() } else { self.tabs.len() };
|
||||
let Some(index) = action.index_for(count) else { return };
|
||||
if top {
|
||||
self.activate_top_tab(index, window, cx);
|
||||
} else {
|
||||
self.activate_tab(index, window, cx);
|
||||
}
|
||||
// Selecting the current tab still returns focus from a side panel.
|
||||
self.focus_active(window, cx);
|
||||
}
|
||||
|
||||
pub(super) fn select_adjacent_tab(
|
||||
&mut self,
|
||||
next: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.tabs_position == nebula_settings::TabsPositionName::Top {
|
||||
let len = self.top_tab_count();
|
||||
if len == 0 {
|
||||
return;
|
||||
}
|
||||
let current = if self.settings_open { self.tabs.len() } else { self.active };
|
||||
let next = if next { (current + 1) % len } else { (current + len - 1) % len };
|
||||
self.activate_top_tab(next, window, cx);
|
||||
self.focus_active(window, cx);
|
||||
return;
|
||||
}
|
||||
let len = self.tabs.len();
|
||||
if len == 0 {
|
||||
return;
|
||||
@@ -44,6 +75,9 @@ impl NebulaWorkspace {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.settings_open {
|
||||
return;
|
||||
}
|
||||
let Some(to) = move_target(self.active, self.tabs.len(), right) else { return };
|
||||
let from = self.active;
|
||||
self.move_tab(from, to, window, cx);
|
||||
|
||||
@@ -1,42 +1,205 @@
|
||||
//! Registration and restoration of user keyboard overrides.
|
||||
//! Workspace keyboard registration, shared action adaptation and user overrides.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 工作区静态默认绑定的 combo 集([`init`] 的镜像)。撤销已失效的自定义
|
||||
/// 注入时要排除:gpui 的 NoAction 打在静态默认键上会误杀基础功能。
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn cleared_shortcut_reaches_terminal_and_can_be_restored_without_restart() {
|
||||
pub(super) const STATIC_DEFAULT_COMBOS: &[&str] = &[
|
||||
"ctrl-shift-t",
|
||||
"ctrl-shift-e",
|
||||
"ctrl-shift-w",
|
||||
"ctrl-shift-b",
|
||||
"ctrl-,",
|
||||
"ctrl-shift-p",
|
||||
"ctrl-k",
|
||||
"ctrl-shift-f",
|
||||
"escape",
|
||||
"ctrl-shift-d",
|
||||
"ctrl-alt-shift-s",
|
||||
"f2",
|
||||
"ctrl-shift-enter",
|
||||
"ctrl-alt-left",
|
||||
"ctrl-alt-right",
|
||||
"ctrl-alt-up",
|
||||
"ctrl-alt-down",
|
||||
"ctrl-tab",
|
||||
"ctrl-shift-tab",
|
||||
"ctrl-shift-pageup",
|
||||
"ctrl-shift-pagedown",
|
||||
"ctrl-shift-g",
|
||||
"ctrl-=",
|
||||
"ctrl-+",
|
||||
"ctrl--",
|
||||
"ctrl-0",
|
||||
"ctrl-shift-c",
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
"ctrl-c",
|
||||
"ctrl-v",
|
||||
"ctrl-shift-v",
|
||||
"alt-enter",
|
||||
"ctrl-shift-o",
|
||||
];
|
||||
|
||||
/// 存储格式 combo(`ctrl+shift+t`)→ gpui 绑定串(`ctrl-shift-t`)。键名
|
||||
/// 两套体系同构(小写命名键 + 单字符);digitN 折回数字,plus/minus 折回
|
||||
/// `+`/`-`(`+` 是存储分隔符,必须先占位再替换)。
|
||||
pub(super) fn gpui_binding_combo(combo: &str) -> String {
|
||||
combo
|
||||
.to_ascii_lowercase()
|
||||
.replace("plus", "\u{1}")
|
||||
.replace("minus", "\u{2}")
|
||||
.replace('+', "-")
|
||||
.replace("digit", "")
|
||||
.replace('\u{1}', "+")
|
||||
.replace('\u{2}', "-")
|
||||
}
|
||||
|
||||
/// 注册工作区快捷键;在 `gpui_component::init` 之后调用一次。
|
||||
pub(super) fn init(cx: &mut App) {
|
||||
cx.bind_keys(default_workspace_bindings());
|
||||
#[cfg(target_os = "macos")]
|
||||
bind_macos_command_keys(cx);
|
||||
}
|
||||
|
||||
/// 工作区静态默认键位表;与 [`STATIC_DEFAULT_COMBOS`] 互为镜像。
|
||||
pub(super) fn default_workspace_bindings() -> Vec<KeyBinding> {
|
||||
let mut bindings = vec![
|
||||
KeyBinding::new("ctrl-shift-t", NewTerminal, None),
|
||||
KeyBinding::new("ctrl-alt-shift-s", recipes::OpenLayoutRecipes, None),
|
||||
KeyBinding::new("ctrl-shift-e", NewWindow, None),
|
||||
KeyBinding::new("ctrl-shift-w", CloseActiveTerminal, None),
|
||||
KeyBinding::new("ctrl-shift-b", ToggleSidebar, None),
|
||||
KeyBinding::new("ctrl-,", OpenSettings, None),
|
||||
KeyBinding::new("ctrl-shift-p", ToggleCommandPalette, None),
|
||||
KeyBinding::new("ctrl-k", ToggleShellPicker, None),
|
||||
KeyBinding::new("ctrl-shift-f", ToggleFileTree, None),
|
||||
// Esc 只在命令/Shell 面板打开时关面板。绑成 `None` 会在终端聚焦时
|
||||
// 抢走按键,CC/Codex 收不到 0x1b(旧壳无 overlay 时 Esc 一定进 PTY)。
|
||||
KeyBinding::new("escape", CloseCommandPalette, Some(PALETTE_KEY_CONTEXT)),
|
||||
// 分屏(旧壳 nebula_key_bindings 同键位):ctrl+shift+d 左右、
|
||||
// ctrl+shift+s 上下、ctrl+shift+enter 缩放、ctrl+alt+方向切聚焦。
|
||||
KeyBinding::new("ctrl-shift-d", SplitRight, None),
|
||||
KeyBinding::new("ctrl-shift-s", SplitDown, None),
|
||||
// F2 重命名活动标签(旧壳同键位);右键菜单的键帽读的就是这条。
|
||||
KeyBinding::new("f2", RenameActiveTab, None),
|
||||
KeyBinding::new("ctrl-shift-enter", ToggleZoom, None),
|
||||
KeyBinding::new("ctrl-alt-left", FocusPaneLeft, None),
|
||||
KeyBinding::new("ctrl-alt-right", FocusPaneRight, None),
|
||||
KeyBinding::new("ctrl-alt-up", FocusPaneUp, None),
|
||||
KeyBinding::new("ctrl-alt-down", FocusPaneDown, None),
|
||||
KeyBinding::new("ctrl-tab", SelectNextTab, None),
|
||||
KeyBinding::new("ctrl-shift-tab", SelectPreviousTab, None),
|
||||
// 移动标签与终端回滚翻页分开:回滚只处理不带 Ctrl 的
|
||||
// Shift+PageUp / PageDown,带 Ctrl 时移动当前标签。
|
||||
KeyBinding::new("ctrl-shift-pageup", MoveTabLeft, None),
|
||||
KeyBinding::new("ctrl-shift-pagedown", MoveTabRight, None),
|
||||
KeyBinding::new("ctrl-shift-g", ToggleGitPanel, None),
|
||||
KeyBinding::new("ctrl-=", IncreaseFontSize, None),
|
||||
KeyBinding::new("ctrl-+", IncreaseFontSize, None),
|
||||
KeyBinding::new("ctrl--", DecreaseFontSize, None),
|
||||
KeyBinding::new("ctrl-0", ResetFontSize, None),
|
||||
KeyBinding::new("ctrl-shift-c", CopySelection, None),
|
||||
// 复制优先:终端聚焦时有选区复制并清选区,无选区经 handler
|
||||
// 的 `cx.propagate()` 落成 ^C。带终端上下文,重命名/输入框聚焦时
|
||||
// ctrl+c 归输入框自己(Input -> Copy)。
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
KeyBinding::new("ctrl-c", CopySelection, Some(crate::gpui_shell::terminal::KEY_CONTEXT)),
|
||||
// 终端粘贴只在终端焦点路径命中。Input 自己带 `Input -> Paste`;这里若
|
||||
// 无上下文,会因注册更晚而抢走弹窗/设置页输入框的 Ctrl+V。
|
||||
KeyBinding::new("ctrl-v", PasteClipboard, Some(crate::gpui_shell::terminal::KEY_CONTEXT)),
|
||||
KeyBinding::new(
|
||||
"ctrl-shift-v",
|
||||
PasteClipboard,
|
||||
Some(crate::gpui_shell::terminal::KEY_CONTEXT),
|
||||
),
|
||||
KeyBinding::new("ctrl-shift-v", gpui_component::input::Paste, Some("Input")),
|
||||
KeyBinding::new("alt-enter", ToggleFullscreen, None),
|
||||
KeyBinding::new("ctrl-shift-o", OpenQuickJump, None),
|
||||
];
|
||||
// Numeric shortcuts come from the same defaults shown by Settings and
|
||||
// used by the legacy shell, including each platform's existing modifiers.
|
||||
bindings.extend(crate::display::keymap::default_shortcuts().into_iter().filter_map(
|
||||
|(combo, action)| {
|
||||
let action = SelectTab::from_config(&action)?;
|
||||
Some(KeyBinding::new(&gpui_binding_combo(&combo), action, None))
|
||||
},
|
||||
));
|
||||
bindings
|
||||
}
|
||||
|
||||
/// macOS 的原生修饰键是 ⌘:在 Ctrl 绑定之外**追加**一套 ⌘ 绑定,不替换。
|
||||
/// 追加而非替换有两个原因:Ctrl+Shift 组合在 Mac 终端里没有别的含义,留着
|
||||
/// 不碍事;而 ⌘C/⌘V 必须存在,否则 Mac 用户第一反应就是「复制粘贴坏了」。
|
||||
/// 终端里的 Ctrl+C 仍然是 SIGINT——这里只绑 ⌘,不碰 Ctrl 的语义。
|
||||
#[cfg(target_os = "macos")]
|
||||
fn bind_macos_command_keys(cx: &mut App) {
|
||||
cx.bind_keys([
|
||||
KeyBinding::new("cmd-t", NewTerminal, None),
|
||||
KeyBinding::new("cmd-n", NewWindow, None),
|
||||
KeyBinding::new("cmd-w", CloseActiveTerminal, None),
|
||||
KeyBinding::new("cmd-b", ToggleSidebar, None),
|
||||
KeyBinding::new("cmd-,", OpenSettings, None),
|
||||
KeyBinding::new("cmd-shift-p", ToggleCommandPalette, None),
|
||||
KeyBinding::new("cmd-k", ToggleShellPicker, None),
|
||||
KeyBinding::new("cmd-shift-f", ToggleFileTree, None),
|
||||
KeyBinding::new("cmd-d", SplitRight, None),
|
||||
KeyBinding::new("cmd-shift-d", SplitDown, None),
|
||||
KeyBinding::new("cmd-shift-enter", ToggleZoom, None),
|
||||
KeyBinding::new("cmd-alt-left", FocusPaneLeft, None),
|
||||
KeyBinding::new("cmd-alt-right", FocusPaneRight, None),
|
||||
KeyBinding::new("cmd-alt-up", FocusPaneUp, None),
|
||||
KeyBinding::new("cmd-alt-down", FocusPaneDown, None),
|
||||
KeyBinding::new("cmd-shift-]", SelectNextTab, None),
|
||||
KeyBinding::new("cmd-shift-[", SelectPreviousTab, None),
|
||||
KeyBinding::new("cmd-shift-g", ToggleGitPanel, None),
|
||||
KeyBinding::new("cmd-=", IncreaseFontSize, None),
|
||||
KeyBinding::new("cmd-+", IncreaseFontSize, None),
|
||||
KeyBinding::new("cmd--", DecreaseFontSize, None),
|
||||
KeyBinding::new("cmd-0", ResetFontSize, None),
|
||||
KeyBinding::new("cmd-c", CopySelection, Some(crate::gpui_shell::terminal::KEY_CONTEXT)),
|
||||
KeyBinding::new("cmd-v", PasteClipboard, Some(crate::gpui_shell::terminal::KEY_CONTEXT)),
|
||||
KeyBinding::new("cmd-v", gpui_component::input::Paste, Some("Input")),
|
||||
KeyBinding::new("cmd-ctrl-f", ToggleFullscreen, None),
|
||||
KeyBinding::new("cmd-shift-o", OpenQuickJump, None),
|
||||
]);
|
||||
}
|
||||
|
||||
/// Typed GPUI adapter for the shared numbered/last-tab actions. The existing
|
||||
/// keybind format remains the authority; this is not another persisted action.
|
||||
#[derive(Clone, Debug, PartialEq, gpui::Action)]
|
||||
#[action(namespace = nebula_workspace, no_json)]
|
||||
pub(super) struct SelectTab {
|
||||
index: Option<usize>,
|
||||
}
|
||||
|
||||
impl SelectTab {
|
||||
fn from_config(action: &crate::config::Action) -> Option<Self> {
|
||||
use crate::config::Action;
|
||||
use gpui::{KeyContext, Keymap, Keystroke};
|
||||
let contexts = [
|
||||
KeyContext::parse("Root").unwrap(),
|
||||
KeyContext::parse(crate::gpui_shell::terminal::KEY_CONTEXT).unwrap(),
|
||||
];
|
||||
let input = [Keystroke::parse("ctrl-k").unwrap()];
|
||||
let original = custom_workspace_binding("ctrl+k", &Action::ToggleShellPicker).unwrap();
|
||||
let clear = workspace_binding_in_context(
|
||||
"ctrl+k",
|
||||
&Action::ReceiveChar,
|
||||
Some(crate::gpui_shell::terminal::KEY_CONTEXT),
|
||||
)
|
||||
.unwrap();
|
||||
let disabled = Keymap::new(vec![original.clone(), clear.clone()]);
|
||||
let (bindings, pending) = disabled.bindings_for_input(&input, &contexts);
|
||||
assert!(bindings.is_empty() && !pending, "No app action should consume the key");
|
||||
let restored = workspace_binding_in_context(
|
||||
"ctrl+k",
|
||||
&Action::ToggleShellPicker,
|
||||
Some(crate::gpui_shell::terminal::KEY_CONTEXT),
|
||||
)
|
||||
.unwrap();
|
||||
let restored = Keymap::new(vec![original, clear, restored]);
|
||||
let (bindings, pending) = restored.bindings_for_input(&input, &contexts);
|
||||
assert!(!pending);
|
||||
assert!(bindings[0].action().as_any().is::<ToggleShellPicker>());
|
||||
let index = match action {
|
||||
Action::SelectTab1 => Some(0),
|
||||
Action::SelectTab2 => Some(1),
|
||||
Action::SelectTab3 => Some(2),
|
||||
Action::SelectTab4 => Some(3),
|
||||
Action::SelectTab5 => Some(4),
|
||||
Action::SelectTab6 => Some(5),
|
||||
Action::SelectTab7 => Some(6),
|
||||
Action::SelectTab8 => Some(7),
|
||||
Action::SelectTab9 => Some(8),
|
||||
Action::SelectLastTab => None,
|
||||
_ => return None,
|
||||
};
|
||||
Some(Self { index })
|
||||
}
|
||||
|
||||
pub(super) fn index_for(&self, tab_count: usize) -> Option<usize> {
|
||||
self.index.or_else(|| tab_count.checked_sub(1)).filter(|&index| index < tab_count)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub(super) fn custom_workspace_binding(
|
||||
combo: &str,
|
||||
action: &crate::config::Action,
|
||||
@@ -51,6 +214,9 @@ fn workspace_binding_in_context(
|
||||
) -> Option<KeyBinding> {
|
||||
use crate::config::Action;
|
||||
let combo = gpui_binding_combo(combo);
|
||||
if let Some(action) = SelectTab::from_config(action) {
|
||||
return Some(KeyBinding::new(&combo, action, scope));
|
||||
}
|
||||
match action {
|
||||
Action::ToggleCommandPalette => Some(KeyBinding::new(&combo, ToggleCommandPalette, scope)),
|
||||
Action::ToggleShellPicker => Some(KeyBinding::new(&combo, ToggleShellPicker, scope)),
|
||||
@@ -92,7 +258,10 @@ fn workspace_binding_in_context(
|
||||
|
||||
impl NebulaWorkspace {
|
||||
pub(super) fn apply_custom_keybinds(&mut self, cx: &mut Context<Self>) {
|
||||
let raw = nebula_settings::keybind_pairs();
|
||||
self.update_keybinds(nebula_settings::keybind_pairs(), cx);
|
||||
}
|
||||
|
||||
fn update_keybinds(&mut self, raw: Vec<(String, String)>, cx: &mut Context<Self>) {
|
||||
let applied: Vec<_> = raw.iter().map(|(combo, _)| gpui_binding_combo(combo)).collect();
|
||||
let defaults = crate::display::keymap::default_shortcuts();
|
||||
let mut bindings = Vec::new();
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
use super::*;
|
||||
#[test]
|
||||
fn cleared_shortcut_reaches_terminal_and_can_be_restored_without_restart() {
|
||||
use crate::config::Action;
|
||||
use gpui::{KeyContext, Keymap, Keystroke};
|
||||
let contexts = [
|
||||
KeyContext::parse("Root").unwrap(),
|
||||
KeyContext::parse(crate::gpui_shell::terminal::KEY_CONTEXT).unwrap(),
|
||||
];
|
||||
let input = [Keystroke::parse("ctrl-k").unwrap()];
|
||||
let original = custom_workspace_binding("ctrl+k", &Action::ToggleShellPicker).unwrap();
|
||||
let clear = workspace_binding_in_context(
|
||||
"ctrl+k",
|
||||
&Action::ReceiveChar,
|
||||
Some(crate::gpui_shell::terminal::KEY_CONTEXT),
|
||||
)
|
||||
.unwrap();
|
||||
let disabled = Keymap::new(vec![original.clone(), clear.clone()]);
|
||||
let (bindings, pending) = disabled.bindings_for_input(&input, &contexts);
|
||||
assert!(bindings.is_empty() && !pending, "No app action should consume the key");
|
||||
let restored = workspace_binding_in_context(
|
||||
"ctrl+k",
|
||||
&Action::ToggleShellPicker,
|
||||
Some(crate::gpui_shell::terminal::KEY_CONTEXT),
|
||||
)
|
||||
.unwrap();
|
||||
let restored = Keymap::new(vec![original, clear, restored]);
|
||||
let (bindings, pending) = restored.bindings_for_input(&input, &contexts);
|
||||
assert!(!pending);
|
||||
assert!(bindings[0].action().as_any().is::<ToggleShellPicker>());
|
||||
}
|
||||
|
||||
#[cfg(feature = "gpui-test-support")]
|
||||
mod dispatch {
|
||||
use super::*;
|
||||
use gpui::{FocusHandle, Keystroke, TestAppContext, VisualTestContext};
|
||||
use nebula_terminal::term::TermMode;
|
||||
|
||||
fn press(combo: &str, cx: &mut VisualTestContext) {
|
||||
let keystroke = Keystroke::parse(combo).unwrap();
|
||||
cx.simulate_event(KeyDownEvent {
|
||||
keystroke: keystroke.clone(),
|
||||
is_held: false,
|
||||
prefer_character_input: false,
|
||||
});
|
||||
cx.simulate_event(gpui::KeyUpEvent { keystroke });
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
fn open_workspace(
|
||||
count: usize,
|
||||
cx: &mut TestAppContext,
|
||||
) -> (tempfile::TempDir, Entity<NebulaWorkspace>, VisualTestContext) {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let paths: Vec<_> = (0..count)
|
||||
.map(|index| {
|
||||
let path = directory.path().join(format!("tab-{index}.txt"));
|
||||
std::fs::write(&path, "fixture\n").unwrap();
|
||||
path
|
||||
})
|
||||
.collect();
|
||||
let hub = crate::runtime_api::RuntimeHub::new();
|
||||
cx.update(|cx| {
|
||||
gpui_component::init(cx);
|
||||
crate::gpui_shell::math_view::register(cx);
|
||||
crate::gpui_shell::file_editor::init(cx);
|
||||
init(cx);
|
||||
windowing::initialize(cx, hub.clone());
|
||||
});
|
||||
let mut workspace_out = None;
|
||||
let (_, window) = cx.add_window_view(|window, cx| {
|
||||
let workspace = cx.new(|cx| {
|
||||
NebulaWorkspace::new(
|
||||
window,
|
||||
None,
|
||||
None,
|
||||
1,
|
||||
hub,
|
||||
windowing::WorkspaceStartup::Empty,
|
||||
windowing::WindowRole::Regular,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
// Exercise real workspace tabs without spawning a Shell or
|
||||
// altering the machine's saved shortcuts/session.
|
||||
workspace.update_keybinds(Vec::new(), cx);
|
||||
for path in paths {
|
||||
let view = cx
|
||||
.new(|cx| crate::gpui_shell::code_tab::CodeTabView::new(path, window, cx));
|
||||
let subscription = cx.subscribe(&view, |_, _, _, _| {});
|
||||
workspace.insert_tab_at(
|
||||
workspace.tabs.len(),
|
||||
WorkspaceTab::Code { view, _subscription: subscription },
|
||||
TabMeta::default(),
|
||||
);
|
||||
}
|
||||
workspace.focus_active(window, cx);
|
||||
});
|
||||
workspace_out = Some(workspace.clone());
|
||||
Root::new(workspace, window, cx)
|
||||
});
|
||||
window.run_until_parked();
|
||||
(directory, workspace_out.unwrap(), window.clone())
|
||||
}
|
||||
|
||||
fn assert_active(
|
||||
workspace: &Entity<NebulaWorkspace>,
|
||||
index: usize,
|
||||
cx: &mut VisualTestContext,
|
||||
) {
|
||||
cx.update(|window, cx| {
|
||||
let workspace = workspace.read(cx);
|
||||
assert_eq!(workspace.active, index);
|
||||
assert!(!workspace.settings_open);
|
||||
let WorkspaceTab::Code { view, .. } = &workspace.tabs[index] else {
|
||||
panic!("expected the selected fixture tab");
|
||||
};
|
||||
assert!(view.read(cx).focus_handle(cx).is_focused(window));
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn digits_switch_real_tabs_and_focus_in_both_tab_layouts(cx: &mut TestAppContext) {
|
||||
let (_directory, workspace, mut cx) = open_workspace(10, cx);
|
||||
for position in
|
||||
[nebula_settings::TabsPositionName::Sidebar, nebula_settings::TabsPositionName::Top]
|
||||
{
|
||||
workspace.update(&mut cx, |workspace, cx| {
|
||||
workspace.tabs_position = position;
|
||||
workspace.tab_meta[8].has_bell = true;
|
||||
cx.notify();
|
||||
});
|
||||
for modifier in ["ctrl", "alt"] {
|
||||
for digit in (1..=9).rev() {
|
||||
press(&format!("{modifier}-{digit}"), &mut cx);
|
||||
assert_active(&workspace, digit - 1, &mut cx);
|
||||
}
|
||||
}
|
||||
assert!(!workspace.read_with(&cx, |workspace, _| workspace.tab_meta[8].has_bell));
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn missing_tab_preserves_settings_and_reselecting_a_tab_restores_focus(
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
let (_directory, workspace, mut cx) = open_workspace(3, cx);
|
||||
cx.update(|window, cx| {
|
||||
workspace.update(cx, |workspace, cx| workspace.open_settings(window, cx));
|
||||
});
|
||||
press("alt-9", &mut cx);
|
||||
press("ctrl-4", &mut cx);
|
||||
cx.update(|window, cx| {
|
||||
let workspace = workspace.read(cx);
|
||||
assert!(workspace.settings_open);
|
||||
assert_eq!(workspace.active, 0);
|
||||
let settings = &workspace.settings_surface.as_ref().unwrap().0;
|
||||
assert!(settings.read(cx).focus_handle(cx).contains_focused(window, cx));
|
||||
});
|
||||
// The active index was already zero: this still has to leave Settings
|
||||
// and return focus to the tab, rather than treating it as a no-op.
|
||||
press("ctrl-1", &mut cx);
|
||||
assert_active(&workspace, 0, &mut cx);
|
||||
|
||||
cx.update(|window, cx| {
|
||||
workspace.update(cx, |workspace, cx| workspace.begin_rename(0, window, cx));
|
||||
});
|
||||
press("alt-1", &mut cx);
|
||||
assert_active(&workspace, 0, &mut cx);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn numbered_shortcut_overrides_clear_and_restore_without_restart(cx: &mut TestAppContext) {
|
||||
let (_directory, workspace, mut cx) = open_workspace(3, cx);
|
||||
workspace.update(&mut cx, |workspace, cx| {
|
||||
workspace.update_keybinds(
|
||||
vec![
|
||||
("ctrl+1".into(), "SelectTab3".into()),
|
||||
("alt+8".into(), "SelectLastTab".into()),
|
||||
],
|
||||
cx,
|
||||
);
|
||||
});
|
||||
press("ctrl-1", &mut cx);
|
||||
assert_active(&workspace, 2, &mut cx);
|
||||
press("ctrl-2", &mut cx);
|
||||
press("alt-8", &mut cx);
|
||||
assert_active(&workspace, 2, &mut cx);
|
||||
|
||||
workspace.update(&mut cx, |workspace, cx| {
|
||||
workspace.update_keybinds(vec![("ctrl+1".into(), "ReceiveChar".into())], cx);
|
||||
});
|
||||
press("ctrl-1", &mut cx);
|
||||
assert_active(&workspace, 2, &mut cx);
|
||||
// Removing this override must find the shared display spelling
|
||||
// ("Ctrl+1") and restore it despite the stored spelling ("ctrl+1").
|
||||
workspace.update(&mut cx, |workspace, cx| workspace.update_keybinds(Vec::new(), cx));
|
||||
press("ctrl-1", &mut cx);
|
||||
assert_active(&workspace, 0, &mut cx);
|
||||
}
|
||||
|
||||
struct TerminalKeyProbe {
|
||||
focus: FocusHandle,
|
||||
selected: Option<usize>,
|
||||
mode: TermMode,
|
||||
input: Vec<Keystroke>,
|
||||
encoded: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Render for TerminalKeyProbe {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.size_full()
|
||||
.on_action(cx.listener(|this, action: &SelectTab, _, _| {
|
||||
this.selected = action.index_for(10);
|
||||
}))
|
||||
.child(
|
||||
div()
|
||||
.size_full()
|
||||
.key_context(crate::gpui_shell::terminal::KEY_CONTEXT)
|
||||
.track_focus(&self.focus)
|
||||
.on_key_down(cx.listener(|this, event: &KeyDownEvent, _, cx| {
|
||||
this.input.push(event.keystroke.clone());
|
||||
if let Some(bytes) = crate::gpui_shell::terminal::keymap::encode(
|
||||
&event.keystroke,
|
||||
&this.mode,
|
||||
) {
|
||||
this.encoded.extend(bytes);
|
||||
cx.stop_propagation();
|
||||
}
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn numeric_actions_precede_terminal_encoding_and_unbound_digits_pass_through(
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
cx.update(|cx| {
|
||||
gpui_component::init(cx);
|
||||
init(cx);
|
||||
});
|
||||
let mut probe_out = None;
|
||||
let (_, cx) = cx.add_window_view(|window, cx| {
|
||||
let probe = cx.new(|cx| TerminalKeyProbe {
|
||||
focus: cx.focus_handle(),
|
||||
selected: None,
|
||||
mode: TermMode::empty(),
|
||||
input: Vec::new(),
|
||||
encoded: Vec::new(),
|
||||
});
|
||||
let focus = probe.read(cx).focus.clone();
|
||||
focus.focus(window, cx);
|
||||
probe_out = Some(probe.clone());
|
||||
Root::new(probe, window, cx)
|
||||
});
|
||||
let probe = probe_out.unwrap();
|
||||
for mode in [
|
||||
TermMode::empty(),
|
||||
TermMode::WIN32_INPUT_MODE,
|
||||
TermMode::DISAMBIGUATE_ESC_CODES,
|
||||
TermMode::REPORT_ALL_KEYS_AS_ESC,
|
||||
] {
|
||||
probe.update(cx, |probe, _| probe.mode = mode);
|
||||
for modifier in ["ctrl", "alt"] {
|
||||
for digit in 1..=9 {
|
||||
press(&format!("{modifier}-{digit}"), cx);
|
||||
probe.read_with(cx, |probe, _| {
|
||||
assert_eq!(probe.selected, Some(digit - 1));
|
||||
assert!(probe.input.is_empty(), "tab shortcuts must not reach PTY input");
|
||||
assert!(probe.encoded.is_empty());
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// Plain digits and additional modifiers retain their terminal meaning.
|
||||
for combo in ["1", "alt-0", "ctrl-alt-2", "ctrl-shift-2"] {
|
||||
press(combo, cx);
|
||||
probe.read_with(cx, |probe, _| {
|
||||
assert_eq!(probe.selected, Some(8));
|
||||
assert_eq!(probe.input.last(), Some(&Keystroke::parse(combo).unwrap()));
|
||||
});
|
||||
}
|
||||
cx.update(|_, cx| {
|
||||
cx.bind_keys([workspace_binding_in_context(
|
||||
"alt+2",
|
||||
&crate::config::Action::ReceiveChar,
|
||||
Some(crate::gpui_shell::terminal::KEY_CONTEXT),
|
||||
)
|
||||
.unwrap()]);
|
||||
});
|
||||
press("alt-2", cx);
|
||||
probe.read_with(cx, |probe, _| {
|
||||
assert_eq!(probe.selected, Some(8));
|
||||
assert_eq!(probe.input.last(), Some(&Keystroke::parse("alt-2").unwrap()));
|
||||
assert!(!probe.encoded.is_empty());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
//! One context-menu host for the Shell/SSH launcher, with stable launch targets.
|
||||
|
||||
use super::*;
|
||||
use crate::gpui_shell::toast::{ToastKind, toast};
|
||||
use crate::i18n::Message;
|
||||
use gpui::{Anchor, DismissEvent, Point, anchored, deferred};
|
||||
use gpui_component::menu::PopupMenuItem;
|
||||
|
||||
pub(super) struct LauncherContextMenu {
|
||||
menu: Entity<PopupMenu>,
|
||||
position: Point<Pixels>,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(super) enum LauncherTarget {
|
||||
Shell(crate::shell_detect::DetectedShell),
|
||||
Profile(crate::config::ui_config::Profile),
|
||||
Ssh(String),
|
||||
}
|
||||
|
||||
impl LauncherTarget {
|
||||
pub(super) fn from_action(action: &WorkspacePaletteAction) -> Option<Self> {
|
||||
match action {
|
||||
WorkspacePaletteAction::LaunchShell(shell) => Some(Self::Shell(shell.clone())),
|
||||
WorkspacePaletteAction::LaunchProfile(profile) => Some(Self::Profile(profile.clone())),
|
||||
WorkspacePaletteAction::LaunchSshHost(host) => Some(Self::Ssh(host.clone())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
match self {
|
||||
Self::Shell(shell) => &shell.name,
|
||||
Self::Profile(profile) => &profile.name,
|
||||
Self::Ssh(host) => host,
|
||||
}
|
||||
}
|
||||
|
||||
fn default_id(&self) -> Option<String> {
|
||||
match self {
|
||||
Self::Shell(shell) => Some(shell.id.clone()),
|
||||
Self::Profile(profile) => profile.settings_id(),
|
||||
Self::Ssh(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn local_launch(&self) -> Option<crate::session::LaunchSession> {
|
||||
match self {
|
||||
Self::Shell(detected) => {
|
||||
let shell = detected.shell();
|
||||
Some(crate::session::LaunchSession::Shell {
|
||||
name: detected.name.clone(),
|
||||
program: shell.program().to_owned(),
|
||||
args: shell.args().to_vec(),
|
||||
})
|
||||
},
|
||||
Self::Profile(profile) => {
|
||||
Some(NebulaWorkspace::profile_launch_session(profile.clone()))
|
||||
},
|
||||
Self::Ssh(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum LauncherCommand {
|
||||
OpenAdmin,
|
||||
SetDefault,
|
||||
Connect,
|
||||
Edit,
|
||||
Delete,
|
||||
}
|
||||
|
||||
impl LauncherCommand {
|
||||
fn label(self) -> Message {
|
||||
match self {
|
||||
Self::OpenAdmin => Message::LauncherOpenAdmin,
|
||||
Self::SetDefault => Message::LauncherSetDefault,
|
||||
Self::Connect => Message::LauncherConnect,
|
||||
Self::Edit => Message::LauncherEdit,
|
||||
Self::Delete => Message::LauncherDelete,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn commands(target: &LauncherTarget) -> Vec<LauncherCommand> {
|
||||
if matches!(target, LauncherTarget::Ssh(_)) {
|
||||
vec![LauncherCommand::Connect, LauncherCommand::Edit, LauncherCommand::Delete]
|
||||
} else {
|
||||
let mut commands = Vec::new();
|
||||
if crate::platform::elevation::SUPPORTED {
|
||||
commands.push(LauncherCommand::OpenAdmin);
|
||||
}
|
||||
commands.push(LauncherCommand::SetDefault);
|
||||
commands
|
||||
}
|
||||
}
|
||||
|
||||
impl NebulaWorkspace {
|
||||
pub(super) fn open_launcher_context_menu(
|
||||
&mut self,
|
||||
target: LauncherTarget,
|
||||
position: Point<Pixels>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if !self.shell_picker_open || !self.command_palette_open {
|
||||
return;
|
||||
}
|
||||
let current = crate::platform::shell::effective_shell_id(
|
||||
cx.try_global::<crate::gpui_shell::config::Settings>()
|
||||
.and_then(|settings| settings.shell_id.as_deref()),
|
||||
);
|
||||
let is_default = target.default_id().is_some_and(|id| id.eq_ignore_ascii_case(¤t));
|
||||
let pending = self.launcher_admin_task.is_some();
|
||||
let weak = cx.entity().downgrade();
|
||||
let language = workspace_ui_language();
|
||||
let menu = PopupMenu::build(window, cx, move |menu, _, _| {
|
||||
commands(&target).into_iter().fold(menu.external_link_icon(false), |menu, command| {
|
||||
let target = target.clone();
|
||||
let weak = weak.clone();
|
||||
let label = if command == LauncherCommand::SetDefault && is_default {
|
||||
Message::LauncherCurrentDefault
|
||||
} else {
|
||||
command.label()
|
||||
};
|
||||
menu.item(
|
||||
PopupMenuItem::new(language.text(label))
|
||||
.disabled(
|
||||
(command == LauncherCommand::SetDefault && is_default)
|
||||
|| (command == LauncherCommand::OpenAdmin && pending),
|
||||
)
|
||||
.on_click(move |_, window, cx| {
|
||||
if let Some(workspace) = weak.upgrade() {
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
workspace.run_launcher_command(
|
||||
command,
|
||||
target.clone(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
});
|
||||
menu.focus_handle(cx).focus(window, cx);
|
||||
let subscription =
|
||||
cx.subscribe_in(&menu, window, |this, _, _: &DismissEvent, window, cx| {
|
||||
this.launcher_menu = None;
|
||||
if this.command_palette_open && this.shell_picker_open {
|
||||
this.command_palette_input.read(cx).focus_handle(cx).focus(window, cx);
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
self.launcher_menu =
|
||||
Some(LauncherContextMenu { menu, position, _subscription: subscription });
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(super) fn render_launcher_context_menu(&self) -> Option<gpui::AnyElement> {
|
||||
let state = self.launcher_menu.as_ref()?;
|
||||
Some(
|
||||
deferred(
|
||||
anchored()
|
||||
.position(state.position)
|
||||
.anchor(Anchor::TopLeft)
|
||||
.snap_to_window_with_margin(px(8.0))
|
||||
.child(
|
||||
div()
|
||||
.id("launcher-context-menu")
|
||||
.debug_selector(|| "launcher-context-menu".to_owned())
|
||||
.child(state.menu.clone()),
|
||||
),
|
||||
)
|
||||
.with_priority(2)
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
|
||||
fn run_launcher_command(
|
||||
&mut self,
|
||||
command: LauncherCommand,
|
||||
target: LauncherTarget,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.launcher_menu = None;
|
||||
match command {
|
||||
LauncherCommand::SetDefault => self.set_launcher_default(&target, window, cx),
|
||||
LauncherCommand::OpenAdmin => self.launch_administrator(&target, window, cx),
|
||||
LauncherCommand::Connect => {
|
||||
if let LauncherTarget::Ssh(host) = target {
|
||||
self.dismiss_palette_state();
|
||||
self.add_ssh_terminal(host, window, cx);
|
||||
}
|
||||
},
|
||||
LauncherCommand::Edit | LauncherCommand::Delete => {
|
||||
let LauncherTarget::Ssh(host) = target else {
|
||||
return;
|
||||
};
|
||||
self.dismiss_palette_state();
|
||||
self.open_settings(window, cx);
|
||||
if let Some((pane, _)) = &self.settings_surface {
|
||||
let edit = command == LauncherCommand::Edit;
|
||||
let pane = pane.clone();
|
||||
window.defer(cx, move |window, cx| {
|
||||
pane.update(cx, |pane, cx| {
|
||||
pane.manage_launcher_ssh(host, edit, window, cx)
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
if self.command_palette_open && self.shell_picker_open {
|
||||
self.command_palette_input.read(cx).focus_handle(cx).focus(window, cx);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn set_launcher_default(
|
||||
&mut self,
|
||||
target: &LauncherTarget,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(id) = target.default_id() else {
|
||||
return;
|
||||
};
|
||||
let language = workspace_ui_language();
|
||||
if let Err(error) = nebula_settings::persist_keys(&[("shell", id.clone())]) {
|
||||
toast(
|
||||
window,
|
||||
cx,
|
||||
ToastKind::Warning,
|
||||
language.format(Message::SettingsSaveFailed, &[("error", &error.to_string())]),
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.apply_runtime_settings(cx);
|
||||
if let Some((pane, _)) = &self.settings_surface {
|
||||
pane.update(cx, |pane, cx| pane.sync_launcher_shell(window, cx));
|
||||
}
|
||||
if let Some(rows) = &mut self.palette_override {
|
||||
for row in rows.iter_mut() {
|
||||
let Some(target) = LauncherTarget::from_action(&row.action) else {
|
||||
continue;
|
||||
};
|
||||
let Some(row_id) = target.default_id() else {
|
||||
continue;
|
||||
};
|
||||
let selected = row_id.eq_ignore_ascii_case(&id);
|
||||
row.group_order = if selected { 0 } else { 1 };
|
||||
row.group = language
|
||||
.pick(
|
||||
if selected { "推荐" } else { "所有 Shell" },
|
||||
if selected { "Recommended" } else { "All shells" },
|
||||
)
|
||||
.to_owned();
|
||||
}
|
||||
rows.sort_by_key(|row| row.group_order);
|
||||
}
|
||||
self.command_palette_selected = self
|
||||
.filtered_palette_rows(cx)
|
||||
.iter()
|
||||
.position(|row| {
|
||||
LauncherTarget::from_action(&row.action)
|
||||
.and_then(|target| target.default_id())
|
||||
.is_some_and(|row_id| row_id.eq_ignore_ascii_case(&id))
|
||||
})
|
||||
.unwrap_or(0);
|
||||
toast(
|
||||
window,
|
||||
cx,
|
||||
ToastKind::Success,
|
||||
language.format(Message::LauncherDefaultSaved, &[("shell", target.name())]),
|
||||
);
|
||||
}
|
||||
|
||||
fn launch_administrator(
|
||||
&mut self,
|
||||
target: &LauncherTarget,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
use crate::platform::elevation;
|
||||
if self.launcher_admin_task.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(launch) = target.local_launch() else {
|
||||
return;
|
||||
};
|
||||
let cwd = Self::startup_directory().or_else(|| {
|
||||
self.tabs
|
||||
.get(self.active)
|
||||
.and_then(WorkspaceTab::focused_view)
|
||||
.and_then(|view| view.read(cx).local_cwd())
|
||||
});
|
||||
match elevation::is_elevated() {
|
||||
Ok(true) => {
|
||||
self.dismiss_palette_state();
|
||||
self.add_terminal_with(launch, cwd, None, window, cx);
|
||||
return;
|
||||
},
|
||||
Err(error) => {
|
||||
toast(
|
||||
window,
|
||||
cx,
|
||||
ToastKind::Warning,
|
||||
workspace_ui_language()
|
||||
.format(Message::LauncherAdminFailed, &[("error", &error.to_string())]),
|
||||
);
|
||||
return;
|
||||
},
|
||||
Ok(false) => {},
|
||||
}
|
||||
let crate::gpui_shell::terminal::view::TerminalLaunch::Local {
|
||||
shell: Some(shell),
|
||||
cwd,
|
||||
..
|
||||
} = Self::terminal_launch_from_session(&launch, cwd)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let args = elevation::arguments(shell.program(), shell.args(), cwd.as_deref());
|
||||
let name = target.name().to_owned();
|
||||
self.close_command_palette(window, cx);
|
||||
let task = cx.background_executor().spawn(async move { elevation::launch(&args) });
|
||||
self.launcher_admin_task = Some(cx.spawn_in(window, async move |this, cx| {
|
||||
let result = task.await;
|
||||
let _ = this.update_in(cx, |this, window, cx| {
|
||||
this.launcher_admin_task = None;
|
||||
let language = workspace_ui_language();
|
||||
let (kind, text) = match result {
|
||||
Ok(elevation::LaunchOutcome::Started) => (
|
||||
ToastKind::Success,
|
||||
language.format(Message::LauncherAdminStarted, &[("shell", &name)]),
|
||||
),
|
||||
Ok(elevation::LaunchOutcome::Cancelled) => {
|
||||
(ToastKind::Info, language.text(Message::LauncherAdminCancelled).to_owned())
|
||||
},
|
||||
Err(error) => (
|
||||
ToastKind::Warning,
|
||||
language
|
||||
.format(Message::LauncherAdminFailed, &[("error", &error.to_string())]),
|
||||
),
|
||||
};
|
||||
toast(window, cx, kind, text);
|
||||
cx.notify();
|
||||
});
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "gpui-test-support"))]
|
||||
mod tests;
|
||||
@@ -0,0 +1,145 @@
|
||||
use super::*;
|
||||
use gpui::{Modifiers, TestAppContext, VisualTestContext};
|
||||
|
||||
fn shell(id: &str) -> crate::shell_detect::DetectedShell {
|
||||
crate::shell_detect::DetectedShell {
|
||||
id: id.into(),
|
||||
name: id.into(),
|
||||
program: format!("{id}.exe"),
|
||||
args: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn open(cx: &mut TestAppContext) -> (Entity<NebulaWorkspace>, VisualTestContext) {
|
||||
let hub = crate::runtime_api::RuntimeHub::new();
|
||||
cx.update(|cx| {
|
||||
gpui_component::init(cx);
|
||||
crate::gpui_shell::math_view::register(cx);
|
||||
crate::gpui_shell::file_editor::init(cx);
|
||||
super::super::init(cx);
|
||||
windowing::initialize(cx, hub.clone());
|
||||
});
|
||||
let mut output = None;
|
||||
let (_, window) = cx.add_window_view(|window, cx| {
|
||||
let view = cx.new(|cx| {
|
||||
NebulaWorkspace::new(
|
||||
window,
|
||||
None,
|
||||
None,
|
||||
1,
|
||||
hub,
|
||||
windowing::WorkspaceStartup::Empty,
|
||||
windowing::WindowRole::Regular,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
view.update(cx, |workspace, cx| {
|
||||
workspace.palette_override = Some(shell_palette_rows(
|
||||
vec![shell("cmd"), shell("pwsh")],
|
||||
vec![],
|
||||
["test-host".into()],
|
||||
"cmd",
|
||||
workspace_ui_language(),
|
||||
1.0,
|
||||
));
|
||||
workspace.command_palette_open = true;
|
||||
workspace.shell_picker_open = true;
|
||||
workspace.command_palette_input.read(cx).focus_handle(cx).focus(window, cx);
|
||||
});
|
||||
output = Some(view.clone());
|
||||
Root::new(view, window, cx)
|
||||
});
|
||||
let mut window = window.clone();
|
||||
window.simulate_resize(gpui::size(px(1200.0), px(900.0)));
|
||||
draw(&mut window);
|
||||
(output.unwrap(), window)
|
||||
}
|
||||
|
||||
fn draw(cx: &mut VisualTestContext) {
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, cx| {
|
||||
window.refresh();
|
||||
window.draw(cx).clear(cx);
|
||||
});
|
||||
}
|
||||
|
||||
fn right_click(selector: &'static str, cx: &mut VisualTestContext) {
|
||||
let bounds = cx.debug_bounds(selector).expect("launcher row");
|
||||
cx.simulate_mouse_down(bounds.center(), MouseButton::Right, Modifiers::default());
|
||||
cx.simulate_mouse_up(bounds.center(), MouseButton::Right, Modifiers::default());
|
||||
draw(cx);
|
||||
assert!(cx.debug_bounds("launcher-context-menu").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_and_ssh_commands_do_not_cross_categories() {
|
||||
let ssh = LauncherTarget::Ssh("host".into());
|
||||
assert_eq!(
|
||||
commands(&ssh),
|
||||
[LauncherCommand::Connect, LauncherCommand::Edit, LauncherCommand::Delete]
|
||||
);
|
||||
assert!(ssh.default_id().is_none() && ssh.local_launch().is_none());
|
||||
let local = LauncherTarget::Shell(shell("cmd"));
|
||||
assert!(commands(&local).contains(&LauncherCommand::SetDefault));
|
||||
assert!(!commands(&local).contains(&LauncherCommand::Delete));
|
||||
assert_eq!(
|
||||
commands(&local).contains(&LauncherCommand::OpenAdmin),
|
||||
crate::platform::elevation::SUPPORTED
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn right_click_opens_one_menu_without_launch_and_escape_restores_search(cx: &mut TestAppContext) {
|
||||
let (workspace, mut cx) = open(cx);
|
||||
for selector in ["command-palette-row-0", "command-palette-row-1", "command-palette-row-2"] {
|
||||
right_click(selector, &mut cx);
|
||||
workspace.read_with(&cx, |workspace, _| {
|
||||
assert!(workspace.tabs.is_empty(), "right click must not create a session");
|
||||
assert!(workspace.command_palette_open && workspace.launcher_menu.is_some());
|
||||
});
|
||||
cx.simulate_keystrokes("escape");
|
||||
draw(&mut cx);
|
||||
cx.update(|window, cx| {
|
||||
let workspace = workspace.read(cx);
|
||||
assert!(workspace.launcher_menu.is_none());
|
||||
assert!(workspace.command_palette_open, "Escape should only dismiss the context menu");
|
||||
assert!(workspace.command_palette_input.read(cx).focus_handle(cx).is_focused(window));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn ssh_menu_keeps_its_target_when_launcher_rows_change(cx: &mut TestAppContext) {
|
||||
let (workspace, mut cx) = open(cx);
|
||||
right_click("command-palette-row-2", &mut cx);
|
||||
workspace.update(&mut cx, |workspace, cx| {
|
||||
workspace.palette_override.as_mut().unwrap().reverse();
|
||||
cx.notify();
|
||||
});
|
||||
// No item is initially selected; the second Down selects Edit.
|
||||
cx.simulate_keystrokes("down down enter");
|
||||
draw(&mut cx);
|
||||
workspace.read_with(&cx, |workspace, cx| {
|
||||
assert!(workspace.tabs.is_empty());
|
||||
assert!(workspace.settings_open);
|
||||
let pane = workspace.settings_surface.as_ref().unwrap().0.read(cx);
|
||||
assert_eq!(
|
||||
pane.ssh_editor.as_ref().unwrap().original_destination.as_deref(),
|
||||
Some("test-host")
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn ssh_delete_only_arms_the_existing_confirmation(cx: &mut TestAppContext) {
|
||||
let (workspace, mut cx) = open(cx);
|
||||
right_click("command-palette-row-2", &mut cx);
|
||||
cx.simulate_keystrokes("down down down enter");
|
||||
draw(&mut cx);
|
||||
workspace.read_with(&cx, |workspace, cx| {
|
||||
assert!(workspace.settings_open);
|
||||
let pane = workspace.settings_surface.as_ref().unwrap().0.read(cx);
|
||||
assert_eq!(pane.ssh_delete_confirm.as_deref(), Some("test-host"));
|
||||
assert!(pane.ssh_delete_undo.is_none(), "no deletion before the user confirms");
|
||||
});
|
||||
}
|
||||
@@ -216,11 +216,16 @@ impl NebulaWorkspace {
|
||||
};
|
||||
|
||||
let action = item.action.clone();
|
||||
let context_target = self
|
||||
.shell_picker_open
|
||||
.then(|| launcher_menu::LauncherTarget::from_action(&action))
|
||||
.flatten();
|
||||
let row_tooltip = item.label.clone();
|
||||
let selected = ix == self.command_palette_selected;
|
||||
let hover_group = SharedString::from(format!("command-palette-row-hover-{ix}"));
|
||||
let row_content = h_flex()
|
||||
.id(SharedString::from(format!("command-palette-row-{ix}")))
|
||||
.debug_selector(move || format!("command-palette-row-{ix}"))
|
||||
.group(hover_group.clone())
|
||||
.h(px(PALETTE_ROW_HEIGHT))
|
||||
.flex_shrink_0()
|
||||
@@ -248,6 +253,21 @@ impl NebulaWorkspace {
|
||||
.child(item.label),
|
||||
)
|
||||
.when_some(hint, |row, hint| row.child(hint))
|
||||
.when_some(context_target, |row, target| {
|
||||
row.on_mouse_down(
|
||||
MouseButton::Right,
|
||||
cx.listener(move |this, event: &gpui::MouseDownEvent, window, cx| {
|
||||
cx.stop_propagation();
|
||||
this.command_palette_selected = ix;
|
||||
this.open_launcher_context_menu(
|
||||
target.clone(),
|
||||
event.position,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
)
|
||||
})
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.command_palette_selected = ix;
|
||||
match action.clone() {
|
||||
|
||||
@@ -986,6 +986,10 @@ impl NebulaWorkspace {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> bool {
|
||||
// Private administrator windows have no public resident discovery path.
|
||||
if crate::platform::elevation::requires_isolation() {
|
||||
return false;
|
||||
}
|
||||
let runtime = nebula_settings::RuntimeSettings::load();
|
||||
// 平台藏不了窗口就没有「驻留」可言:不拦关闭,否则 Unix 上会变成
|
||||
// 既不关也不藏(设置页那一行也按同一能力位隐藏)。
|
||||
|
||||
@@ -8,11 +8,22 @@ pub(super) enum SaveReason {
|
||||
Quit,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct SessionPersistence {
|
||||
latest: Option<Session>,
|
||||
saved: Option<Session>,
|
||||
quitting: bool,
|
||||
isolated: bool,
|
||||
}
|
||||
|
||||
impl Default for SessionPersistence {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
latest: None,
|
||||
saved: None,
|
||||
quitting: false,
|
||||
isolated: crate::platform::elevation::requires_isolation(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionPersistence {
|
||||
@@ -26,6 +37,11 @@ impl SessionPersistence {
|
||||
reason: SaveReason,
|
||||
write: impl FnOnce(&Session) -> std::io::Result<()>,
|
||||
) {
|
||||
// An administrator window must not overwrite the ordinary workspace or
|
||||
// cause its privileged shell command to be restored in a later session.
|
||||
if self.isolated {
|
||||
return;
|
||||
}
|
||||
let retry_checkpoint = reason == SaveReason::Checkpoint
|
||||
&& current.as_ref().is_none_or(|session| session.tabs.is_empty());
|
||||
let candidate = if self.quitting {
|
||||
@@ -85,6 +101,22 @@ pub(super) fn combine_sessions(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn isolated_windows_never_write_shared_session_storage() {
|
||||
let mut state = SessionPersistence { isolated: true, ..SessionPersistence::default() };
|
||||
for reason in [
|
||||
SaveReason::Checkpoint,
|
||||
SaveReason::TabsClosed,
|
||||
SaveReason::WindowClose,
|
||||
SaveReason::Quit,
|
||||
] {
|
||||
state.save_with(Some(sample_session()), reason, |_| {
|
||||
panic!("privileged session reached shared storage")
|
||||
});
|
||||
}
|
||||
assert!(state.latest.is_none());
|
||||
}
|
||||
use crate::session::{AgentSession, LayoutSession, TabSession};
|
||||
|
||||
fn sample_session() -> Session {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use gpui::{App, Context, Window};
|
||||
use gpui::{App, AppContext as _, Context, Focusable as _, Window};
|
||||
use nebula_settings::TabsPositionName;
|
||||
|
||||
use super::{NebulaWorkspace, SidebarActivity, TabPresentation};
|
||||
use super::{NebulaWorkspace, SettingsPane, SidebarActivity, TabPresentation, windowing};
|
||||
|
||||
#[cfg(all(test, feature = "gpui-test-support"))]
|
||||
mod ui_tests;
|
||||
|
||||
fn sidebar_state(
|
||||
settings_open: bool,
|
||||
@@ -17,6 +20,63 @@ fn sidebar_state(
|
||||
}
|
||||
|
||||
impl NebulaWorkspace {
|
||||
/// Open Settings as a window-level page while preserving the active tab.
|
||||
/// Side-tab mode folds its real left rail; top-tab mode has no such rail,
|
||||
/// so touching `sidebar_collapsed` there would only create hidden state.
|
||||
pub(super) fn open_settings(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.settings_open {
|
||||
if let Some((view, _)) = self.settings_surface.as_ref() {
|
||||
let focus = view.read(cx).focus_handle(cx);
|
||||
window.defer(cx, move |window, cx| window.focus(&focus, cx));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if self.settings_surface.is_none() {
|
||||
let view = cx.new(|cx| SettingsPane::new(window, cx));
|
||||
let subscription = cx.subscribe_in(&view, window, Self::on_settings_event);
|
||||
self.settings_surface = Some((view, subscription));
|
||||
}
|
||||
|
||||
self.settings_tab_open = true;
|
||||
self.settings_open = true;
|
||||
self.sync_settings_layout();
|
||||
|
||||
self.settings_restore_side_panel_open = self.side_panel.open;
|
||||
if self.side_panel.open {
|
||||
self.side_panel.open = false;
|
||||
self.side_panel_anim_armed = true;
|
||||
self.file_tree_menu = None;
|
||||
}
|
||||
|
||||
if let Some((view, _)) = self.settings_surface.as_ref() {
|
||||
let focus = view.read(cx).focus_handle(cx);
|
||||
window.defer(cx, move |window, cx| window.focus(&focus, cx));
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(super) fn close_settings(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if !self.settings_tab_open {
|
||||
return;
|
||||
}
|
||||
self.settings_tab_open = false;
|
||||
self.leave_settings(window, cx);
|
||||
self.reveal_active_tab();
|
||||
cx.notify();
|
||||
if self.tabs.is_empty() {
|
||||
windowing::close_empty_workspace_window(self.runtime_window_id, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn toggle_settings(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.settings_open {
|
||||
self.close_settings(window, cx);
|
||||
} else {
|
||||
self.open_settings(window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn sync_settings_layout(&mut self) {
|
||||
let (collapsed, restore) = sidebar_state(
|
||||
self.settings_open,
|
||||
@@ -49,7 +109,7 @@ impl NebulaWorkspace {
|
||||
}
|
||||
|
||||
pub(super) fn top_tab_count(&self) -> usize {
|
||||
self.tabs.len() + usize::from(self.settings_open)
|
||||
self.tabs.len() + usize::from(self.settings_tab_open)
|
||||
}
|
||||
|
||||
pub(super) fn top_tab_presentation(
|
||||
@@ -80,7 +140,7 @@ impl NebulaWorkspace {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.settings_open && index == self.tabs.len() {
|
||||
if self.settings_tab_open && index == self.tabs.len() {
|
||||
self.open_settings(window, cx);
|
||||
} else {
|
||||
self.activate_tab(index, window, cx);
|
||||
@@ -93,7 +153,7 @@ impl NebulaWorkspace {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.settings_open && index == self.tabs.len() {
|
||||
if self.settings_tab_open && index == self.tabs.len() {
|
||||
self.close_settings(window, cx);
|
||||
} else {
|
||||
self.request_close_tab(index, window, cx);
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
use super::super::*;
|
||||
use gpui::{Modifiers, TestAppContext, VisualTestContext};
|
||||
|
||||
fn open_workspace(
|
||||
count: usize,
|
||||
cx: &mut TestAppContext,
|
||||
) -> (tempfile::TempDir, Entity<NebulaWorkspace>, VisualTestContext) {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let hub = crate::runtime_api::RuntimeHub::new();
|
||||
cx.update(|cx| {
|
||||
gpui_component::init(cx);
|
||||
cx.set_global(crate::gpui_shell::config::Settings::load(nebula_settings::ThemeName::Nord));
|
||||
crate::gpui_shell::math_view::register(cx);
|
||||
crate::gpui_shell::file_editor::init(cx);
|
||||
init(cx);
|
||||
windowing::initialize(cx, hub.clone());
|
||||
});
|
||||
let mut workspace_out = None;
|
||||
let (_, window) = cx.add_window_view(|window, cx| {
|
||||
let workspace = cx.new(|cx| {
|
||||
NebulaWorkspace::new(
|
||||
window,
|
||||
None,
|
||||
None,
|
||||
1,
|
||||
hub,
|
||||
windowing::WorkspaceStartup::Empty,
|
||||
windowing::WindowRole::Regular,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
workspace.tabs_position = nebula_settings::TabsPositionName::Top;
|
||||
for index in 0..count {
|
||||
let path = directory.path().join(format!("tab-{index}.txt"));
|
||||
std::fs::write(&path, "fixture\n").unwrap();
|
||||
let view =
|
||||
cx.new(|cx| crate::gpui_shell::code_tab::CodeTabView::new(path, window, cx));
|
||||
let subscription = cx.subscribe(&view, |_, _, _, _| {});
|
||||
workspace.insert_tab_at(
|
||||
workspace.tabs.len(),
|
||||
WorkspaceTab::Code { view, _subscription: subscription },
|
||||
TabMeta::default(),
|
||||
);
|
||||
}
|
||||
workspace.open_settings(window, cx);
|
||||
});
|
||||
workspace_out = Some(workspace.clone());
|
||||
Root::new(workspace, window, cx)
|
||||
});
|
||||
window.run_until_parked();
|
||||
window.simulate_resize(gpui::size(px(1280.0), px(900.0)));
|
||||
workspace_out.as_ref().unwrap().update(window, |workspace, cx| {
|
||||
workspace.tabs_position = nebula_settings::TabsPositionName::Top;
|
||||
workspace.sync_settings_layout();
|
||||
cx.notify();
|
||||
});
|
||||
window.run_until_parked();
|
||||
(directory, workspace_out.unwrap(), window.clone())
|
||||
}
|
||||
|
||||
fn click_tab(selector: &'static str, cx: &mut VisualTestContext) {
|
||||
let bounds = tab_bounds(selector, cx);
|
||||
cx.simulate_click(bounds.center(), Modifiers::default());
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
fn tab_bounds(selector: &'static str, cx: &mut VisualTestContext) -> gpui::Bounds<gpui::Pixels> {
|
||||
cx.update(|window, cx| {
|
||||
window.refresh();
|
||||
window.draw(cx).clear(cx);
|
||||
});
|
||||
cx.debug_bounds(selector).expect("visible tab bounds")
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn switching_top_tabs_keeps_settings_reachable_until_explicit_close(cx: &mut TestAppContext) {
|
||||
let (_directory, workspace, mut cx) = open_workspace(2, cx);
|
||||
let settings_id = workspace.read_with(&cx, |workspace, _| {
|
||||
assert_eq!(workspace.tabs_position, nebula_settings::TabsPositionName::Top);
|
||||
assert_eq!(workspace.top_tab_count(), 3);
|
||||
workspace.settings_surface.as_ref().unwrap().0.entity_id()
|
||||
});
|
||||
for (index, selector) in [(0, "top-tab-0"), (1, "top-tab-1"), (0, "top-tab-0")] {
|
||||
click_tab(selector, &mut cx);
|
||||
workspace.read_with(&cx, |workspace, _| {
|
||||
assert!(!workspace.settings_open);
|
||||
assert_eq!(workspace.active, index);
|
||||
assert_eq!(workspace.top_tab_count(), 3, "switching must retain the settings tab");
|
||||
});
|
||||
click_tab("top-tab-2", &mut cx);
|
||||
workspace.read_with(&cx, |workspace, _| {
|
||||
assert!(workspace.settings_open);
|
||||
assert_eq!(workspace.top_tab_count(), 3);
|
||||
assert_eq!(workspace.settings_surface.as_ref().unwrap().0.entity_id(), settings_id);
|
||||
});
|
||||
}
|
||||
click_tab("top-tab-0", &mut cx);
|
||||
let bounds = tab_bounds("top-tab-2", &mut cx);
|
||||
cx.simulate_mouse_down(bounds.center(), gpui::MouseButton::Middle, Modifiers::default());
|
||||
cx.simulate_mouse_up(bounds.center(), gpui::MouseButton::Middle, Modifiers::default());
|
||||
cx.run_until_parked();
|
||||
workspace.read_with(&cx, |workspace, _| {
|
||||
assert!(!workspace.settings_open);
|
||||
assert_eq!(workspace.active, 0);
|
||||
assert_eq!(workspace.top_tab_count(), 2);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn keyboard_navigation_can_return_to_background_settings(cx: &mut TestAppContext) {
|
||||
let (_directory, workspace, mut cx) = open_workspace(2, cx);
|
||||
for shortcut in ["ctrl-1", "alt-1"] {
|
||||
cx.simulate_keystrokes(shortcut);
|
||||
assert!(!workspace.read_with(&cx, |workspace, _| workspace.settings_open));
|
||||
cx.simulate_keystrokes("ctrl-3");
|
||||
assert!(workspace.read_with(&cx, |workspace, _| workspace.settings_open));
|
||||
cx.simulate_keystrokes("ctrl-tab");
|
||||
assert!(!workspace.read_with(&cx, |workspace, _| workspace.settings_open));
|
||||
cx.simulate_keystrokes("ctrl-shift-tab");
|
||||
assert!(workspace.read_with(&cx, |workspace, _| workspace.settings_open));
|
||||
}
|
||||
cx.simulate_keystrokes("ctrl-shift-w");
|
||||
assert_eq!(workspace.read_with(&cx, |workspace, _| workspace.top_tab_count()), 2);
|
||||
cx.simulate_keystrokes("ctrl-3");
|
||||
assert!(!workspace.read_with(&cx, |workspace, _| workspace.settings_open));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn settings_survives_overflow_and_layout_changes(cx: &mut TestAppContext) {
|
||||
let (_directory, workspace, mut cx) = open_workspace(12, cx);
|
||||
cx.simulate_resize(gpui::size(px(760.0), px(900.0)));
|
||||
cx.simulate_keystrokes("ctrl-1");
|
||||
for position in
|
||||
[nebula_settings::TabsPositionName::Sidebar, nebula_settings::TabsPositionName::Top]
|
||||
{
|
||||
workspace.update(&mut cx, |workspace, cx| {
|
||||
workspace.tabs_position = position;
|
||||
workspace.sync_settings_layout();
|
||||
cx.notify();
|
||||
});
|
||||
assert_eq!(workspace.read_with(&cx, |workspace, _| workspace.top_tab_count()), 13);
|
||||
}
|
||||
cx.simulate_keystrokes("ctrl-shift-tab");
|
||||
let settings = tab_bounds("top-tab-12", &mut cx);
|
||||
assert!(settings.origin.x >= px(0.0) && settings.right() <= px(760.0));
|
||||
workspace.read_with(&cx, |workspace, _| assert!(workspace.settings_open));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn closing_last_regular_tab_keeps_settings_until_it_is_closed(cx: &mut TestAppContext) {
|
||||
let (_directory, workspace, mut cx) = open_workspace(1, cx);
|
||||
click_tab("top-tab-0", &mut cx);
|
||||
cx.simulate_keystrokes("ctrl-shift-w");
|
||||
workspace.read_with(&cx, |workspace, _| {
|
||||
assert!(workspace.tabs.is_empty());
|
||||
assert!(workspace.settings_open);
|
||||
assert_eq!(workspace.top_tab_count(), 1);
|
||||
});
|
||||
cx.simulate_keystrokes("ctrl-shift-w");
|
||||
assert!(cx.read(|cx| cx.windows().is_empty()));
|
||||
}
|
||||
@@ -167,8 +167,12 @@ impl NebulaWorkspace {
|
||||
let items_running = std::cell::Cell::new(false);
|
||||
let items = (0..self.top_tab_count())
|
||||
.map(|ix| {
|
||||
let settings_navigation = self.settings_open && ix == self.tabs.len();
|
||||
let active = settings_navigation || (!self.settings_open && ix == self.active);
|
||||
let settings_navigation = self.settings_tab_open && ix == self.tabs.len();
|
||||
let active = if settings_navigation {
|
||||
self.settings_open
|
||||
} else {
|
||||
!self.settings_open && ix == self.active
|
||||
};
|
||||
let TabPresentation {
|
||||
title,
|
||||
is_settings,
|
||||
@@ -254,6 +258,7 @@ impl NebulaWorkspace {
|
||||
|
||||
let row = h_flex()
|
||||
.id(("top-tab", ix))
|
||||
.debug_selector(|| format!("top-tab-{ix}"))
|
||||
.group(hover_group.clone())
|
||||
.relative()
|
||||
.w(px(tab_w))
|
||||
|
||||
@@ -43,6 +43,7 @@ use crate::runtime_api::{
|
||||
pub(crate) enum WorkspaceStartup {
|
||||
RestoreOrDefault,
|
||||
NewTerminal { cwd: Option<PathBuf> },
|
||||
LaunchTerminal { cwd: Option<PathBuf>, launch: crate::session::LaunchSession },
|
||||
Empty,
|
||||
}
|
||||
|
||||
@@ -250,11 +251,13 @@ pub(crate) fn open_initial_window(
|
||||
ai_events: std::sync::mpsc::Receiver<crate::ai_hook::AiHookEvent>,
|
||||
shell_events: std::sync::mpsc::Receiver<GpuiShellEvent>,
|
||||
initial_cwd: Option<PathBuf>,
|
||||
initial_command: Option<crate::config::ui_config::Program>,
|
||||
) {
|
||||
let startup = match initial_cwd {
|
||||
Some(cwd) => WorkspaceStartup::NewTerminal { cwd: Some(cwd) },
|
||||
None => WorkspaceStartup::RestoreOrDefault,
|
||||
};
|
||||
let startup = initial_startup(
|
||||
initial_cwd,
|
||||
initial_command,
|
||||
crate::platform::elevation::requires_isolation(),
|
||||
);
|
||||
open_workspace_window(
|
||||
cx,
|
||||
startup,
|
||||
@@ -266,6 +269,67 @@ pub(crate) fn open_initial_window(
|
||||
.expect("failed to open Pebrel GPUI window");
|
||||
}
|
||||
|
||||
fn initial_startup(
|
||||
cwd: Option<PathBuf>,
|
||||
command: Option<crate::config::ui_config::Program>,
|
||||
isolated: bool,
|
||||
) -> WorkspaceStartup {
|
||||
if let Some(command) = command {
|
||||
let program = command.program().to_owned();
|
||||
let name = program.rsplit(['/', '\\']).next().unwrap_or(&program).to_owned();
|
||||
return WorkspaceStartup::LaunchTerminal {
|
||||
cwd,
|
||||
launch: crate::session::LaunchSession::Shell {
|
||||
name,
|
||||
program,
|
||||
args: command.args().to_vec(),
|
||||
},
|
||||
};
|
||||
}
|
||||
if cwd.is_some() || isolated {
|
||||
WorkspaceStartup::NewTerminal { cwd }
|
||||
} else {
|
||||
WorkspaceStartup::RestoreOrDefault
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod startup_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn explicit_program_is_kept_with_its_arguments_and_directory() {
|
||||
let command = crate::config::ui_config::Program::WithArgs {
|
||||
program: "shell.exe".into(),
|
||||
args: vec!["--literal=two words".into()],
|
||||
};
|
||||
let cwd = Some(PathBuf::from("C:/work area"));
|
||||
let WorkspaceStartup::LaunchTerminal { launch, cwd: actual_cwd } =
|
||||
initial_startup(cwd.clone(), Some(command), true)
|
||||
else {
|
||||
panic!("explicit launch was discarded");
|
||||
};
|
||||
assert_eq!(actual_cwd, cwd);
|
||||
assert_eq!(
|
||||
launch,
|
||||
crate::session::LaunchSession::Shell {
|
||||
name: "shell.exe".into(),
|
||||
program: "shell.exe".into(),
|
||||
args: vec!["--literal=two words".into()]
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn privileged_startup_never_restores_the_ordinary_session() {
|
||||
assert!(matches!(initial_startup(None, None, false), WorkspaceStartup::RestoreOrDefault));
|
||||
assert!(matches!(
|
||||
initial_startup(None, None, true),
|
||||
WorkspaceStartup::NewTerminal { cwd: None }
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn open_recipe_window(session: crate::session::Session, cx: &mut App) {
|
||||
let result =
|
||||
open_workspace_window(cx, WorkspaceStartup::Empty, None, None, true, WindowRole::Regular);
|
||||
@@ -1479,6 +1543,9 @@ impl NebulaWorkspace {
|
||||
} else {
|
||||
let _ = source_handle.update(cx, move |_, source_window, cx| {
|
||||
let _ = source.update(cx, |source, cx| {
|
||||
if source.tabs.is_empty() && source.settings_tab_open {
|
||||
source.open_settings(source_window, cx);
|
||||
}
|
||||
source.reveal_active_tab();
|
||||
source.focus_active(source_window, cx);
|
||||
source.sync_side_panel_to_active(true, cx);
|
||||
@@ -1515,7 +1582,7 @@ impl NebulaWorkspace {
|
||||
if !self.tabs.is_empty() {
|
||||
self.active = self.active.min(self.tabs.len() - 1);
|
||||
}
|
||||
let source_became_empty = self.tabs.is_empty();
|
||||
let source_became_empty = self.tabs.is_empty() && !self.settings_tab_open;
|
||||
Some(DetachedTerminalTab {
|
||||
tab,
|
||||
meta,
|
||||
|
||||
@@ -239,7 +239,7 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
if try_hand_over_to_resident(&options) {
|
||||
return Ok(());
|
||||
}
|
||||
gpui_shell::run_shell(initial_cwd);
|
||||
gpui_shell::run_shell(initial_cwd, options.window_options.terminal_options.command());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -519,6 +519,9 @@ fn wants_gpui_shell(options: &Options) -> bool {
|
||||
/// 驻留进程,再 `tab.new`。GPUI 与 winit 共用,避免第二份进程无声退出。
|
||||
#[cfg(windows)]
|
||||
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 launch_dir = options
|
||||
.window_options
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
//! Windows UAC launch adapter. The requested program is always hosted by Pebrel.
|
||||
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
pub(crate) const SUPPORTED: bool = cfg!(windows);
|
||||
|
||||
/// A failed token query must not publish a potentially privileged control plane.
|
||||
pub(crate) fn requires_isolation() -> bool {
|
||||
is_elevated().unwrap_or(true)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum LaunchOutcome {
|
||||
Started,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// CLI arguments, rather than a shell command: shell metacharacters stay literal.
|
||||
pub(crate) fn arguments(program: &str, args: &[String], cwd: Option<&Path>) -> Vec<OsString> {
|
||||
let mut result = vec![OsString::from("--gpui")];
|
||||
if let Some(cwd) = cwd {
|
||||
result.push("--working-directory".into());
|
||||
result.push(cwd.as_os_str().to_owned());
|
||||
}
|
||||
result.push("-e".into());
|
||||
result.push(program.into());
|
||||
result.extend(args.iter().map(OsString::from));
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn is_elevated() -> io::Result<bool> {
|
||||
use std::sync::OnceLock;
|
||||
static ELEVATED: OnceLock<Result<bool, i32>> = OnceLock::new();
|
||||
ELEVATED.get_or_init(query_token).clone().map_err(io::Error::from_raw_os_error)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn query_token() -> Result<bool, i32> {
|
||||
use windows_sys::Win32::Foundation::{CloseHandle, GetLastError};
|
||||
use windows_sys::Win32::Security::{
|
||||
GetTokenInformation, TOKEN_ELEVATION, TOKEN_QUERY, TokenElevation,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
|
||||
|
||||
// SAFETY: buffers have their declared sizes; the token is closed on every path.
|
||||
unsafe {
|
||||
let mut token = std::ptr::null_mut();
|
||||
if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) == 0 {
|
||||
return Err(GetLastError() as i32);
|
||||
}
|
||||
let mut info: TOKEN_ELEVATION = std::mem::zeroed();
|
||||
let mut returned = 0;
|
||||
let ok = GetTokenInformation(
|
||||
token,
|
||||
TokenElevation,
|
||||
(&mut info as *mut TOKEN_ELEVATION).cast(),
|
||||
std::mem::size_of::<TOKEN_ELEVATION>() as u32,
|
||||
&mut returned,
|
||||
);
|
||||
let error = GetLastError();
|
||||
CloseHandle(token);
|
||||
if ok == 0 { Err(error as i32) } else { Ok(info.TokenIsElevated != 0) }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub(crate) fn is_elevated() -> io::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn quoted_argument(argument: &OsStr) -> io::Result<Vec<u16>> {
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
let mut result = vec![b'"' as u16];
|
||||
let mut backslashes = 0;
|
||||
for ch in argument.encode_wide() {
|
||||
if ch == 0 {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "argument contains NUL"));
|
||||
}
|
||||
if ch == b'\\' as u16 {
|
||||
backslashes += 1;
|
||||
continue;
|
||||
}
|
||||
let count = if ch == b'"' as u16 { backslashes * 2 + 1 } else { backslashes };
|
||||
result.extend(std::iter::repeat_n(b'\\' as u16, count));
|
||||
result.push(ch);
|
||||
backslashes = 0;
|
||||
}
|
||||
result.extend(std::iter::repeat_n(b'\\' as u16, backslashes * 2));
|
||||
result.push(b'"' as u16);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Runs on a worker: UAC may wait for the user and must not block the UI loop.
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn launch(args: &[OsString]) -> io::Result<LaunchOutcome> {
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows_sys::Win32::Foundation::{ERROR_CANCELLED, GetLastError};
|
||||
use windows_sys::Win32::System::Com::{
|
||||
COINIT_APARTMENTTHREADED, CoInitializeEx, CoUninitialize,
|
||||
};
|
||||
use windows_sys::Win32::UI::Shell::{
|
||||
SEE_MASK_FLAG_NO_UI, SEE_MASK_NOASYNC, SHELLEXECUTEINFOW, ShellExecuteExW,
|
||||
};
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL;
|
||||
|
||||
let executable = std::env::current_exe()?;
|
||||
let file: Vec<u16> = executable.as_os_str().encode_wide().chain(Some(0)).collect();
|
||||
let mut parameters = Vec::new();
|
||||
for argument in args {
|
||||
if !parameters.is_empty() {
|
||||
parameters.push(b' ' as u16);
|
||||
}
|
||||
parameters.extend(quoted_argument(argument)?);
|
||||
}
|
||||
parameters.push(0);
|
||||
let verb: Vec<u16> = "runas\0".encode_utf16().collect();
|
||||
// SAFETY: all strings and the initialized structure outlive ShellExecuteExW.
|
||||
// SEE_MASK_NOASYNC completes the shell's launch handshake before COM teardown.
|
||||
unsafe {
|
||||
let com = CoInitializeEx(std::ptr::null(), COINIT_APARTMENTTHREADED as u32);
|
||||
let mut info: SHELLEXECUTEINFOW = std::mem::zeroed();
|
||||
info.cbSize = std::mem::size_of::<SHELLEXECUTEINFOW>() as u32;
|
||||
info.fMask = SEE_MASK_NOASYNC | SEE_MASK_FLAG_NO_UI;
|
||||
info.lpVerb = verb.as_ptr();
|
||||
info.lpFile = file.as_ptr();
|
||||
info.lpParameters = parameters.as_ptr();
|
||||
info.nShow = SW_SHOWNORMAL;
|
||||
let ok = ShellExecuteExW(&mut info);
|
||||
let error = GetLastError();
|
||||
if com >= 0 {
|
||||
CoUninitialize();
|
||||
}
|
||||
if ok != 0 {
|
||||
Ok(LaunchOutcome::Started)
|
||||
} else if error == ERROR_CANCELLED {
|
||||
Ok(LaunchOutcome::Cancelled)
|
||||
} else {
|
||||
Err(io::Error::from_raw_os_error(error as i32))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub(crate) fn launch(_args: &[OsString]) -> io::Result<LaunchOutcome> {
|
||||
Err(io::Error::new(io::ErrorKind::Unsupported, "UAC is only available on Windows"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,65 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn launch_arguments_target_pebrel_cli_and_preserve_selected_shell() {
|
||||
let shell_args = vec!["--literal".into(), "$(value); `echo nope`".into()];
|
||||
let args = arguments("shell path.exe", &shell_args, Some(Path::new("C:\\work area\\")));
|
||||
assert_eq!(
|
||||
args,
|
||||
[
|
||||
"--gpui",
|
||||
"--working-directory",
|
||||
"C:\\work area\\",
|
||||
"-e",
|
||||
"shell path.exe",
|
||||
"--literal",
|
||||
"$(value); `echo nope`"
|
||||
]
|
||||
.map(OsString::from)
|
||||
);
|
||||
assert_eq!(arguments("pwsh.exe", &[], None), ["--gpui", "-e", "pwsh.exe"].map(OsString::from));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn native_windows_argument_parser_round_trips_quotes_paths_and_metacharacters() {
|
||||
use std::os::windows::ffi::OsStringExt;
|
||||
use windows_sys::Win32::Foundation::LocalFree;
|
||||
use windows_sys::Win32::UI::Shell::CommandLineToArgvW;
|
||||
|
||||
let inputs = [
|
||||
"",
|
||||
"simple",
|
||||
"C:\\Program Files\\shell.exe",
|
||||
"C:\\工作区\\",
|
||||
"a\"b",
|
||||
"x\\\"y",
|
||||
"$(echo hidden); `literal` & | < > %PATH%",
|
||||
"--option=two words",
|
||||
];
|
||||
let mut command: Vec<u16> = "pebrel.exe".encode_utf16().collect();
|
||||
for input in inputs {
|
||||
command.push(b' ' as u16);
|
||||
command.extend(quoted_argument(OsStr::new(input)).unwrap());
|
||||
}
|
||||
command.push(0);
|
||||
// SAFETY: the parser returns `count` NUL-terminated strings; LocalFree owns cleanup.
|
||||
unsafe {
|
||||
let mut count = 0;
|
||||
let argv = CommandLineToArgvW(command.as_ptr(), &mut count);
|
||||
assert!(!argv.is_null());
|
||||
let parsed: Vec<_> = std::slice::from_raw_parts(argv, count as usize)
|
||||
.iter()
|
||||
.map(|value| {
|
||||
let mut len = 0;
|
||||
while *value.add(len) != 0 {
|
||||
len += 1;
|
||||
}
|
||||
OsString::from_wide(std::slice::from_raw_parts(*value, len))
|
||||
})
|
||||
.collect();
|
||||
LocalFree(argv.cast());
|
||||
assert_eq!(&parsed[1..], inputs.map(OsString::from));
|
||||
}
|
||||
assert!(quoted_argument(OsStr::new("nul\0suffix")).is_err());
|
||||
}
|
||||
@@ -13,6 +13,7 @@ pub(crate) mod ai_session_identity;
|
||||
pub mod capabilities;
|
||||
pub mod credentials;
|
||||
pub mod dirs;
|
||||
pub(crate) mod elevation;
|
||||
pub(crate) mod environment;
|
||||
pub(crate) mod file_drag;
|
||||
pub mod file_manager;
|
||||
|
||||
@@ -28,6 +28,7 @@ pub(crate) use command::{
|
||||
};
|
||||
#[cfg(feature = "legacy-shell")]
|
||||
pub use server::dispatch_prompt;
|
||||
pub(crate) use server::{ENDPOINT_ENV, apply_child_endpoint};
|
||||
use server::{Endpoint, endpoint_addr, read_endpoint};
|
||||
pub use server::{
|
||||
RuntimeServer, try_open_default_tab_existing, try_open_directory_existing,
|
||||
|
||||
@@ -8,6 +8,29 @@ pub(super) struct Endpoint {
|
||||
pub(super) token: String,
|
||||
}
|
||||
|
||||
pub(crate) const ENDPOINT_ENV: &str = "PEBREL_RUNTIME_ENDPOINT";
|
||||
static CHILD_ENDPOINT: Mutex<Option<Endpoint>> = Mutex::new(None);
|
||||
|
||||
/// Private instances publish discovery only to their own local PTY children.
|
||||
pub(crate) fn apply_child_endpoint(env: &mut std::collections::HashMap<String, String>) {
|
||||
env.retain(|key, _| !key.eq_ignore_ascii_case(ENDPOINT_ENV));
|
||||
if let Some(endpoint) =
|
||||
CHILD_ENDPOINT.lock().unwrap_or_else(|error| error.into_inner()).as_ref()
|
||||
{
|
||||
env.insert(ENDPOINT_ENV.to_owned(), format!("{} {}", endpoint.port, endpoint.token));
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_endpoint(data: &str) -> Option<Endpoint> {
|
||||
let mut parts = data.split_whitespace();
|
||||
let port = parts.next()?.parse().ok()?;
|
||||
let token = parts.next()?;
|
||||
if port == 0 || token.len() != 32 || !token.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
Some(Endpoint { port, token: token.to_owned() })
|
||||
}
|
||||
|
||||
fn port_file() -> PathBuf {
|
||||
crate::display::nebula_data_dir().join("runtime.port")
|
||||
}
|
||||
@@ -17,13 +40,22 @@ fn legacy_port_file() -> PathBuf {
|
||||
}
|
||||
|
||||
pub(super) fn read_endpoint() -> Option<Endpoint> {
|
||||
// A hosted instance owns its endpoint even when launched from another
|
||||
// Pebrel terminal. Child CLI processes have no server and use the env below.
|
||||
if let Some(endpoint) = CHILD_ENDPOINT.lock().unwrap_or_else(|error| error.into_inner()).clone()
|
||||
{
|
||||
return Some(endpoint);
|
||||
}
|
||||
if let Some(value) = std::env::var_os(ENDPOINT_ENV) {
|
||||
// An invalid explicit endpoint must not silently select another instance.
|
||||
return value.to_str().and_then(parse_endpoint);
|
||||
}
|
||||
read_endpoint_from(port_file())
|
||||
}
|
||||
|
||||
fn read_endpoint_from(path: PathBuf) -> Option<Endpoint> {
|
||||
let data = std::fs::read_to_string(path).ok()?;
|
||||
let mut parts = data.split_whitespace();
|
||||
Some(Endpoint { port: parts.next()?.parse().ok()?, token: parts.next()?.to_owned() })
|
||||
parse_endpoint(&data)
|
||||
}
|
||||
|
||||
pub(super) fn endpoint_addr(endpoint: &Endpoint) -> SocketAddr {
|
||||
@@ -92,6 +124,13 @@ fn try_open_tab_existing(dir: Option<&std::path::Path>) -> bool {
|
||||
}
|
||||
|
||||
fn legacy_request(verb: &str) -> Option<()> {
|
||||
let local = CHILD_ENDPOINT.lock().unwrap_or_else(|error| error.into_inner()).clone();
|
||||
if let Some(endpoint) = local {
|
||||
return legacy_request_to(verb, &endpoint);
|
||||
}
|
||||
if std::env::var_os(ENDPOINT_ENV).is_some() {
|
||||
return read_endpoint().and_then(|endpoint| legacy_request_to(verb, &endpoint));
|
||||
}
|
||||
read_endpoint()
|
||||
.and_then(|endpoint| legacy_request_to(verb, &endpoint))
|
||||
// 已运行的 pre-v1 版本只发布 mux.port;升级期间仍允许普通启动交接。
|
||||
@@ -114,8 +153,8 @@ fn legacy_request_to(verb: &str, endpoint: &Endpoint) -> Option<()> {
|
||||
/// Resident versioned runtime API server.
|
||||
pub struct RuntimeServer {
|
||||
endpoint: Endpoint,
|
||||
port_file: PathBuf,
|
||||
_owner_lock: crate::atomic_file::LifetimeFileLock,
|
||||
port_file: Option<PathBuf>,
|
||||
_owner_lock: Option<crate::atomic_file::LifetimeFileLock>,
|
||||
}
|
||||
|
||||
impl RuntimeServer {
|
||||
@@ -133,19 +172,33 @@ impl RuntimeServer {
|
||||
}
|
||||
|
||||
fn spawn_with_sink(sink: EventSink, hub: RuntimeHub) -> Option<Self> {
|
||||
let path = port_file();
|
||||
let owner_lock = match crate::atomic_file::try_lifetime_lock(&path) {
|
||||
Ok(Some(lock)) => lock,
|
||||
Ok(None) => {
|
||||
Self::spawn_at(
|
||||
sink,
|
||||
hub,
|
||||
(!crate::platform::elevation::requires_isolation()).then(port_file),
|
||||
)
|
||||
}
|
||||
|
||||
fn spawn_at(sink: EventSink, hub: RuntimeHub, path: Option<PathBuf>) -> Option<Self> {
|
||||
let owner_lock = match path.as_ref().map(|path| crate::atomic_file::try_lifetime_lock(path))
|
||||
{
|
||||
Some(Ok(Some(lock))) => Some(lock),
|
||||
None => None,
|
||||
Some(Ok(None)) => {
|
||||
info!("Runtime API owner is starting or already running; staying client-only");
|
||||
return None;
|
||||
},
|
||||
Err(error) => {
|
||||
Some(Err(error)) => {
|
||||
warn!("Runtime API: cannot lock {path:?}: {error}; control plane disabled");
|
||||
return None;
|
||||
},
|
||||
};
|
||||
if read_endpoint().and_then(|endpoint| legacy_request_to("PING", &endpoint)).is_some() {
|
||||
if path
|
||||
.as_ref()
|
||||
.and_then(|path| read_endpoint_from(path.clone()))
|
||||
.and_then(|endpoint| legacy_request_to("PING", &endpoint))
|
||||
.is_some()
|
||||
{
|
||||
info!("Runtime API server already running; this instance stays client-only");
|
||||
return None;
|
||||
}
|
||||
@@ -153,7 +206,10 @@ impl RuntimeServer {
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).ok()?;
|
||||
let endpoint = Endpoint { port: listener.local_addr().ok()?.port(), token: fresh_token() };
|
||||
let contents = format!("{} {} {}\n", endpoint.port, endpoint.token, PROTOCOL_VERSION);
|
||||
if crate::atomic_file::write(&path, contents.as_bytes()).is_err() {
|
||||
if path
|
||||
.as_ref()
|
||||
.is_some_and(|path| crate::atomic_file::write(path, contents.as_bytes()).is_err())
|
||||
{
|
||||
warn!("Runtime API: cannot write {path:?}; control plane disabled");
|
||||
return None;
|
||||
}
|
||||
@@ -163,15 +219,74 @@ impl RuntimeServer {
|
||||
.name("nebula-runtime-api".into())
|
||||
.spawn(move || serve(listener, server_token, sink, hub))
|
||||
.is_ok();
|
||||
spawned.then(|| Self { endpoint, port_file: path, _owner_lock: owner_lock })
|
||||
if spawned {
|
||||
*CHILD_ENDPOINT.lock().unwrap_or_else(|error| error.into_inner()) =
|
||||
Some(endpoint.clone());
|
||||
Some(Self { endpoint, port_file: path, _owner_lock: owner_lock })
|
||||
} else {
|
||||
if let Some(path) = path {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RuntimeServer {
|
||||
fn drop(&mut self) {
|
||||
// Do not delete another process's newer discovery record.
|
||||
if read_endpoint().as_ref() == Some(&self.endpoint) {
|
||||
let _ = std::fs::remove_file(&self.port_file);
|
||||
if let Some(path) = self.port_file.as_ref()
|
||||
&& read_endpoint_from(path.clone()).as_ref() == Some(&self.endpoint)
|
||||
{
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
let mut child = CHILD_ENDPOINT.lock().unwrap_or_else(|error| error.into_inner());
|
||||
if child.as_ref() == Some(&self.endpoint) {
|
||||
*child = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn discovery_accepts_existing_records_and_rejects_invalid_endpoints() {
|
||||
let token = "0123456789abcdef0123456789abcdef";
|
||||
assert_eq!(
|
||||
parse_endpoint(&format!("12345 {token} 1\n")),
|
||||
Some(Endpoint { port: 12345, token: token.into() })
|
||||
);
|
||||
for invalid in [
|
||||
"",
|
||||
"0 0123456789abcdef0123456789abcdef",
|
||||
"65536 token",
|
||||
"12 invalid",
|
||||
"12 0123456789abcdef0123456789abcdeg",
|
||||
] {
|
||||
assert!(parse_endpoint(invalid).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_server_keeps_discovery_off_disk_and_supplies_its_children() {
|
||||
let server =
|
||||
RuntimeServer::spawn_at(EventSink::Callback(Arc::new(|_| {})), RuntimeHub::new(), None)
|
||||
.unwrap();
|
||||
assert!(server.port_file.is_none());
|
||||
assert!(server._owner_lock.is_none());
|
||||
assert_eq!(read_endpoint().as_ref(), Some(&server.endpoint));
|
||||
assert!(legacy_request_to("PING", &server.endpoint).is_some());
|
||||
let mut env =
|
||||
std::collections::HashMap::from([(ENDPOINT_ENV.to_owned(), "old endpoint".into())]);
|
||||
apply_child_endpoint(&mut env);
|
||||
assert_eq!(parse_endpoint(&env[ENDPOINT_ENV]).as_ref(), Some(&server.endpoint));
|
||||
let mut wrong = server.endpoint.clone();
|
||||
wrong.token = "00000000000000000000000000000000".into();
|
||||
assert!(legacy_request_to("PING", &wrong).is_none());
|
||||
drop(server);
|
||||
apply_child_endpoint(&mut env);
|
||||
assert!(!env.contains_key(ENDPOINT_ENV));
|
||||
}
|
||||
}
|
||||
@@ -950,6 +950,10 @@ pub struct RuntimeSettings {
|
||||
pub cursor_shape: Option<CursorShapeName>,
|
||||
pub cursor_blink: Option<bool>,
|
||||
pub copy_on_select: bool,
|
||||
/// GUI override for mouse.focus_follows_mouse in TOML; absent there too means false.
|
||||
pub focus_follows_mouse: Option<bool>,
|
||||
/// Preserve the existing dimming of inactive split panes unless explicitly disabled.
|
||||
pub dim_inactive_panes: bool,
|
||||
/// 裸 shell 风险粘贴确认:开 = 换行、提权命令或控制字符先确认;关 = 直接粘贴。
|
||||
pub multiline_paste_confirm: bool,
|
||||
/// 标签页关闭按钮(叉号)是否渲染:关 = 不渲染,仍可用中键关闭。
|
||||
@@ -1093,6 +1097,8 @@ impl RuntimeSettings {
|
||||
cursor_shape: raw.value("cursor_shape").and_then(CursorShapeName::from_settings),
|
||||
cursor_blink: raw.bool_on("cursor_blink"),
|
||||
copy_on_select: raw.bool_on("copy_on_select").unwrap_or(false),
|
||||
focus_follows_mouse: raw.bool_on("focus_follows_mouse"),
|
||||
dim_inactive_panes: raw.bool_on("dim_inactive_panes").unwrap_or(true),
|
||||
multiline_paste_confirm: raw.bool_on("multiline_paste_confirm").unwrap_or(true),
|
||||
tab_close_visible: raw.bool_on("tab_close_visible").unwrap_or(true),
|
||||
terminal_proxy: raw.bool_on("terminal_proxy").unwrap_or(false),
|
||||
@@ -1219,6 +1225,33 @@ pub fn format_hex_rgb(rgb: Rgb8) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn pane_preferences_round_trip_and_allow_a_missing_mouse_override() {
|
||||
let defaults = RuntimeSettings::from_raw(&RawSettings::default());
|
||||
assert_eq!(defaults.focus_follows_mouse, None);
|
||||
assert!(defaults.dim_inactive_panes);
|
||||
let mut text = "theme=Nord\nfuture_option=keep\n".to_owned();
|
||||
for (focus, dim) in [(true, false), (false, true)] {
|
||||
text = apply_updates(
|
||||
&text,
|
||||
&[
|
||||
("focus_follows_mouse", (focus as u8).to_string()),
|
||||
("dim_inactive_panes", (dim as u8).to_string()),
|
||||
],
|
||||
);
|
||||
let settings = RuntimeSettings::from_raw(&RawSettings::from_text(&text));
|
||||
assert_eq!(settings.focus_follows_mouse, Some(focus));
|
||||
assert_eq!(settings.dim_inactive_panes, dim);
|
||||
assert_eq!(settings.theme, ThemeName::Nord);
|
||||
assert!(text.contains("future_option=keep"));
|
||||
}
|
||||
text = apply_updates(&text, &[("focus_follows_mouse", String::new())]);
|
||||
assert_eq!(
|
||||
RuntimeSettings::from_raw(&RawSettings::from_text(&text)).focus_follows_mouse,
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_quick_terminal_hotkey_is_distinct_from_an_unset_preference() {
|
||||
let unset = RuntimeSettings::from_raw(&RawSettings::from_text("theme=Nord\n"));
|
||||
|
||||
@@ -17,6 +17,8 @@ const RESET_KEYS: &[&str] = &[
|
||||
"cursor_shape",
|
||||
"cursor_blink",
|
||||
"copy_on_select",
|
||||
"focus_follows_mouse",
|
||||
"dim_inactive_panes",
|
||||
"multiline_paste_confirm",
|
||||
"tab_close_visible",
|
||||
"terminal_proxy",
|
||||
@@ -120,6 +122,16 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::{RawSettings, RuntimeSettings};
|
||||
|
||||
#[test]
|
||||
fn reset_restores_split_dimming_and_removes_mouse_override() {
|
||||
let restored =
|
||||
default_settings_text("focus_follows_mouse=1\ndim_inactive_panes=0\ncustom=keep\n");
|
||||
assert_eq!(restored, "custom=keep\n");
|
||||
let settings = RuntimeSettings::from_raw(&RawSettings::from_text(&restored));
|
||||
assert_eq!(settings.focus_follows_mouse, None);
|
||||
assert!(settings.dim_inactive_panes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resetting_preferences_reenables_ai_toasts_without_erasing_other_data() {
|
||||
let original = "ai_toasts=0\ncustom_data=keep\n";
|
||||
|
||||