diff --git a/CHANGELOG.md b/CHANGELOG.md index 609f02b..a7cad15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,46 @@ Every release entry is provided in English and Simplified Chinese. 每个版本条目均同时提供英文和简体中文说明。 +## Unreleased / 未发布 + +### English + +#### Added + +- 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 + +- Fixed Windows terminal focus consuming `Alt+F4` and `Alt+Space`. `Alt+F4` now uses the normal window-close flow, and `Alt+Space`, followed by `N`, uses the system menu to minimize the window. +- 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 + +- 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. + +### 中文 + +#### 新增 + +- 在“设置 → 交互”中加入“焦点跟随鼠标”,默认关闭;在“设置 → 外观”中加入“调暗非活动窗格”,默认开启。两项设置均即时生效。 +- Shell 启动菜单新增右键操作,可设为默认 Shell;Windows 下还可在新的管理员 Pebrel 窗口内打开所选 Shell。 +- SSH 启动菜单新增“连接、编辑、删除”右键操作;删除沿用已有的确认与撤销流程。 +- 新增 Trae CLI 识别与标签图标,并为 Oh My Pi 加入独立图标。 + +#### 修复 + +- 修复 Windows 终端获得焦点时吞掉 `Alt+F4` 和 `Alt+Space` 的问题。`Alt+F4` 现在进入正常窗口关闭流程,`Alt+Space` 后按 `N` 可通过系统菜单最小化窗口。 +- 修复顶部设置标签在切换到其他标签后消失的问题。现在只有主动关闭才会移除,标签溢出或切换标签布局时也会保留。 +- 修复 `Alt+1–9` 和 `Ctrl+1–9` 标签快捷键优先级低于终端输入的问题。终端或 CLI 获得焦点时仍可切换标签,自定义快捷键的修改也无需重启即可生效。 + +#### 改进 + +- 将 Claude Code 的低分辨率图标替换为从 SVG 导出的 1024 像素图像,并按显示尺寸处理 Agent 图标,使标签图标边缘更平滑。 + ## 1.7.0 - 2026-09-12 ### English diff --git a/docs/architecture-decisions.md b/docs/architecture-decisions.md index 442a049..bf67bb8 100644 --- a/docs/architecture-decisions.md +++ b/docs/architecture-decisions.md @@ -306,3 +306,117 @@ 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. + +## ADR-0012 — Bounded background images and explicit atlas retirement + +- **Status:** Requested by the maintainer, 2026-09-14; implementation and validation in progress. +- **Evidence:** With blur disabled, twenty window size changes retained an additional + 84 MiB (card) / 190 MiB (window-cover) of dedicated GPU memory on the local Windows + QA binary. The pinned GPUI atlas does not retire image IDs when Rust Arcs drop. +- **Decision:** Keep one bounded CPU RenderImage shared by the background layers and + windows. Use the existing renderer authority for fit/alignment and GPUI image + bounds for GPU sampling; resizing no longer produces new images. Opacity is a + layer property. Explicitly retire replaced image IDs across window atlases and + invalidate replayed scenes. Platform atlas copies remain per window. +- **Loading and ownership:** The App visual-effects state owns one active background + executor job and one latest desired source. Generation checks cancel superseded + work before expensive stages and prevent stale publication; dropping the owner + invalidates outstanding work. Metadata and decoding run off the UI thread. There + is no new service, thread pool, dependency, persisted format or AI lifecycle change. +- **Memory policy:** Retained BGRA is limited to 8 MiB and an edge of 2048 pixels; + encoded input is streamed with a 64 MiB file limit, and decoder/output admission + is limited to 128 MiB. Integer thumbnailing precedes RGBA conversion and avoids + a full-image floating-point resize buffer. These are owned-resource limits, not + a whole-process or undocumented decoder-scratch guarantee. Existing native-fit + geometry remains independent of reduced texture resolution. +- **Tradeoff:** High-resolution wallpaper detail is reduced and inputs over the + admission limits receive a visible error. The window can appear with its normal + base color while the background loads. These favor the user's explicit memory + and responsiveness priorities. +- **Validation:** Targeted decode/lifetime/geometry tests, Windows GPUI build, + repeated-size memory probes and real card/crop/opacity visual checks are required. + Results are recorded separately and are not implied by this decision. +- **Revisit condition:** Replace manual atlas retirement if upstream introduces + equivalent ownership-aware image resources. Adopt target-size native decoding + only with verified peak accounting and compatibility evidence. + +## ADR-0013 — Bounded, on-demand filename search + +- **Date:** 2026-09-14 +- **Context:** Opening Files previously crawled up to 500,000 paths even with an + empty query. The index retained its full array and several strings per path. + Users requested approximately 10–15 MiB of sustained browsing/search overhead, + unchanged matching options and reuse during repeated panel use. +- **Decision:** Keep filename matching in the shared side-panel model. Empty + queries only enumerate the visible tree. Nonempty queries reuse one bounded + cache and stream uncached paths through the same matcher/ranker. Cache capacity + never determines search coverage. Plain, case-sensitive, whole-word and regex + matching retain their existing semantics and best-first ordering. +- **Ownership and budgets:** Each existing worker owns one root cache (6 MiB); + all caches share an 8 MiB allocation quota. Published rows carry a lease into + their consuming view (512 KiB per snapshot, 2 MiB shared). One process-wide + execution lock bounds simultaneous traversal/ranking buffers; UI threads never + acquire it. Ranking retains at most 1,000 candidates and 1 MiB. Query regex and + path parsing have independent bounds. These are allocation budgets, not a + promise that OS working set or total application memory equals those values. +- **Lifecycle:** A latest-request mailbox replaces the unbounded command queue. + Revisions cancel obsolete traversal and reject stale publication. Clearing or + closing Files releases result rows, while a bounded warm cache survives reopening. + Root changes replace that cache. Local nonrecursive watches are installed before + enumeration, capped at 128 directories / 64 KiB of path storage. Caches with + incomplete watch coverage (including WSL) expire after two seconds. WSL search + executes `find` directly with `wsl.exe --exec`, streams NUL records through a + four-record channel and terminates its owned command on cancellation/timeout. + Direct execution preserves paths and `find` arguments containing shell syntax. + It never terminates terminal sessions. +- **Tradeoff:** Unchanged directories that fit in the watched cache avoid repeat + walks. Larger trees reuse a prefix for early results but require streaming for + full coverage. Search reports partial results at its existing 500,000-entry + ceiling, depth 64, inaccessible paths or result limits. Explicit refresh remains + available. No new dependency, persisted index or daemon is introduced. +- **Validation:** Matching, watch changes, cancellation, panel reopening, root + replacement, bounded ranking and allocation ownership have focused regressions. + An opt-in Windows stress test drives the production panel through multiple + directories, clear/query/reopen cycles and records actual Vec/String capacities, + the Windows array heap block and process private commit after allocator warmup. + Runtime evidence must accompany any claim about the sustained memory target. +- **Replacement condition:** Revisit the budgets or an OS-backed index only with + measured query latency, completeness and sustained allocation evidence. Do not + restore eager full-tree indexing to improve a synthetic latency number. diff --git a/extra/logo/LICENSE-lobe-icons b/extra/logo/LICENSE-lobe-icons new file mode 100644 index 0000000..1dd53d2 --- /dev/null +++ b/extra/logo/LICENSE-lobe-icons @@ -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. diff --git a/extra/logo/README.md b/extra/logo/README.md index 9d6d778..55d3678 100644 --- a/extra/logo/README.md +++ b/extra/logo/README.md @@ -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 diff --git a/extra/logo/ai_claude.png b/extra/logo/ai_claude.png index 7484ce6..e1a9373 100644 Binary files a/extra/logo/ai_claude.png and b/extra/logo/ai_claude.png differ diff --git a/extra/logo/ai_claude.svg b/extra/logo/ai_claude.svg new file mode 100644 index 0000000..db1cbc5 --- /dev/null +++ b/extra/logo/ai_claude.svg @@ -0,0 +1 @@ +Claude diff --git a/extra/logo/ai_omp.png b/extra/logo/ai_omp.png new file mode 100644 index 0000000..8671e96 Binary files /dev/null and b/extra/logo/ai_omp.png differ diff --git a/extra/logo/ai_omp.svg b/extra/logo/ai_omp.svg new file mode 100644 index 0000000..6c5e055 --- /dev/null +++ b/extra/logo/ai_omp.svg @@ -0,0 +1,11 @@ + + Oh My Pi + + + + + + + + + diff --git a/extra/logo/ai_trae.png b/extra/logo/ai_trae.png new file mode 100644 index 0000000..97e1fb3 Binary files /dev/null and b/extra/logo/ai_trae.png differ diff --git a/extra/logo/ai_trae.svg b/extra/logo/ai_trae.svg new file mode 100644 index 0000000..dbf31a6 --- /dev/null +++ b/extra/logo/ai_trae.svg @@ -0,0 +1 @@ +TRAE diff --git a/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json index cee0540..e6f50da 100644 --- a/nebula_app/i18n/en-US.json +++ b/nebula_app/i18n/en-US.json @@ -109,7 +109,16 @@ "dedicated": "Dedicated window", "existing": "Show existing window" }, - "search_empty": "No matching settings" + "search_empty": "No matching settings", + "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": { @@ -509,5 +518,33 @@ "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}" + }, + "wallpaper": { + "load_failed": "Could not load the background image. Choose a readable PNG, JPEG, WebP, GIF or BMP image.", + "too_large": "The background image exceeds the memory limit. Choose a smaller image." + }, + "files.search_failed": "Unable to complete search", + "files.search_running": "Searching…", + "files.search_limited": "Showing {count} results · narrow the search for more", + "files.search_count": "{count} results", + "files.search_retry": "Check the directory and query, or turn off the search options and try again.", + "files.search_progress": "Matching files appear as the search progresses.", + "files.search_continue": "You can continue typing or switch directories.", + "files.search_incomplete": "Search is incomplete", + "files.search_limit_reason": "The search reached a limit or some directories could not be read.", + "files.search_narrow": "Choose a smaller directory or use a more specific query.", + "files.search_empty": "No matching files", + "files.search_empty_reason": "No files or folders match this query in the current directory." } diff --git a/nebula_app/i18n/zh-CN.json b/nebula_app/i18n/zh-CN.json index 650fc46..5bfc2a6 100644 --- a/nebula_app/i18n/zh-CN.json +++ b/nebula_app/i18n/zh-CN.json @@ -109,7 +109,16 @@ "dedicated": "独立窗口", "existing": "唤起已有窗口" }, - "search_empty": "没有匹配的设置" + "search_empty": "没有匹配的设置", + "mouse": { + "focus_follows_mouse": "焦点跟随鼠标", + "focus_follows_mouse_description": "鼠标移入终端窗格时自动聚焦,默认关闭。" + }, + "panes": { + "dim_inactive": "调暗非活动窗格", + "dim_inactive_description": "降低其他分屏窗格的亮度,突出当前活动窗格。" + }, + "save_failed": "无法保存设置:{error}" }, "provider": { "test": { @@ -509,5 +518,33 @@ "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}" + }, + "wallpaper": { + "load_failed": "无法加载背景图。请选择可正常读取的 PNG、JPEG、WebP、GIF 或 BMP 图片。", + "too_large": "背景图超出内存限制,请选择尺寸更小的图片。" + }, + "files.search_failed": "无法完成搜索", + "files.search_running": "正在搜索…", + "files.search_limited": "显示 {count} 项结果 · 缩小范围可查找更多", + "files.search_count": "{count} 个结果", + "files.search_retry": "检查目录和关键词,或关闭搜索选项后重试。", + "files.search_progress": "找到的文件会逐步显示。", + "files.search_continue": "可以继续输入关键词或切换目录。", + "files.search_incomplete": "搜索尚未覆盖全部文件", + "files.search_limit_reason": "搜索已达到限制,或部分目录无法读取。", + "files.search_narrow": "选择更小的目录,或输入更具体的关键词。", + "files.search_empty": "没有匹配的文件", + "files.search_empty_reason": "当前目录中没有符合查询条件的文件或文件夹。" } diff --git a/nebula_app/src/agent_env.rs b/nebula_app/src/agent_env.rs index 0fe8039..70768d7 100644 --- a/nebula_app/src/agent_env.rs +++ b/nebula_app/src/agent_env.rs @@ -91,6 +91,7 @@ pub fn apply(env: &mut HashMap, 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)] diff --git a/nebula_app/src/ai_agents.rs b/nebula_app/src/ai_agents.rs index d867a9f..4106a1f 100644 --- a/nebula_app/src/ai_agents.rs +++ b/nebula_app/src/ai_agents.rs @@ -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 { diff --git a/nebula_app/src/display/mod.rs b/nebula_app/src/display/mod.rs index 6e64731..272757f 100644 --- a/nebula_app/src/display/mod.rs +++ b/nebula_app/src/display/mod.rs @@ -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()); diff --git a/nebula_app/src/display/program_identity.rs b/nebula_app/src/display/program_identity.rs index d3616f1..1fd6dc7 100644 --- a/nebula_app/src/display/program_identity.rs +++ b/nebula_app/src/display/program_identity.rs @@ -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 { 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)); + } + } } diff --git a/nebula_app/src/display/side_panel/mod.rs b/nebula_app/src/display/side_panel/mod.rs index 5a5f90d..d1bfd41 100644 --- a/nebula_app/src/display/side_panel/mod.rs +++ b/nebula_app/src/display/side_panel/mod.rs @@ -416,9 +416,10 @@ pub struct SidePanel { /// Whether the filter box owns the keyboard. pub search_focus: bool, search_selection: super::text_input::SelectAllState, - /// Everything-style, root-scoped filename index. It owns a worker thread; + /// Root-scoped bounded filename cache with on-demand streamed search; /// rendering only submits queries and harvests generation-checked rows. file_index: EmbeddedFileIndex, + search_memory: Option, search_options: FileSearchOptions, search_generation: u64, search_applied_generation: u64, @@ -512,6 +513,7 @@ impl SidePanel { search_focus: false, search_selection: Default::default(), file_index: EmbeddedFileIndex::new(), + search_memory: None, search_options: FileSearchOptions::default(), search_generation: 0, search_applied_generation: 0, @@ -543,12 +545,30 @@ impl SidePanel { pub fn toggle(&mut self, view: PanelView) { if self.open && self.view == view { self.open = false; + self.file_index.clear_query(); + self.rows = Vec::new(); + self.search_memory = None; self.selected = None; self.drag_file = None; return; } + let resume_search = !self.open || self.view != PanelView::Files; self.open = true; self.view = view; + if view == PanelView::Git { + self.file_index.clear_query(); + self.rows = Vec::new(); + self.search_memory = None; + } else if resume_search && !self.search.trim().is_empty() { + self.file_index.query( + self.search_index_epoch, + self.search_generation, + self.search.clone(), + self.search_options, + ); + } else if resume_search { + self.rows = self.tree_rows.clone(); + } self.scroll = 0; self.needs_refresh = true; } @@ -700,7 +720,9 @@ impl SidePanel { fn harvest_file_search(&mut self) -> bool { let Some(result) = self.file_index.take_result() else { return false }; - if result.epoch != self.search_index_epoch + if !self.open + || self.view != PanelView::Files + || result.epoch != self.search_index_epoch || self.search_index_root != self.current_index_root() || result.generation != self.search_generation || result.query != self.search @@ -709,10 +731,13 @@ impl SidePanel { return false; } self.rows = result.rows; + self.search_memory = Some(result.memory); self.search_total = result.total; self.search_error = result.error; + if self.search_applied_generation != result.generation { + self.scroll = 0; + } self.search_applied_generation = result.generation; - self.scroll = 0; true } @@ -887,10 +912,11 @@ impl SidePanel { self.search_generation = self.search_generation.wrapping_add(1); self.search_error = None; self.search_total = 0; - let active_query = if self.search.trim().is_empty() { + let active_query = if self.search.trim().is_empty() || self.view != PanelView::Files { None } else { - self.rows.clear(); + self.rows = Vec::new(); + self.search_memory = None; Some((self.search_generation, self.search.clone(), self.search_options)) }; self.file_index.rebuild(root, self.search_index_epoch, active_query); @@ -901,8 +927,9 @@ impl SidePanel { self.needs_refresh = false; let Some(root) = self.root.clone() else { // 没有根:清空是即时且无成本的,不需要工人。 - self.rows.clear(); - self.tree_rows.clear(); + self.rows = Vec::new(); + self.tree_rows = Vec::new(); + self.search_memory = None; self.git = None; self.sync_file_index(); return; @@ -1013,7 +1040,8 @@ impl SidePanel { self.search_generation = self.search_generation.wrapping_add(1); if self.search.trim().is_empty() { self.file_index.clear_query(); - self.rows.clone_from(&self.tree_rows); + self.rows = self.tree_rows.clone(); + self.search_memory = None; self.search_applied_generation = self.search_generation; return; } @@ -1222,18 +1250,15 @@ impl SidePanel { return; } let Ok(read) = std::fs::read_dir(dir) else { return }; - let entries: Vec<(bool, String, PathBuf)> = read - .flatten() - .filter_map(|e| { - let name = e.file_name().to_string_lossy().into_owned(); - // `.git` is noise in a file tree; everything else shows. - if name == ".git" { - return None; - } - let is_dir = e.file_type().ok()?.is_dir(); - Some((is_dir, name, e.path())) - }) - .collect(); + let entries = read.flatten().filter_map(|e| { + let name = e.file_name().to_string_lossy().into_owned(); + // `.git` is noise in a file tree; everything else shows. + if name == ".git" { + return None; + } + let is_dir = e.file_type().ok()?.is_dir(); + Some((is_dir, name, e.path())) + }); for (is_dir, name, path) in Self::ordered_entries(entries, MAX_PER_DIR) { if rows.len() >= MAX_ROWS { return; @@ -1263,12 +1288,37 @@ impl SidePanel { /// this repo's 318 entries start with `.`) that pushes `nebula_app`, `docs` /// and the rest of the real tree past the cap, leaving a screen of `.tmp-*`. fn ordered_entries( - mut entries: Vec<(bool, String, PathBuf)>, + entries: impl IntoIterator, cap: usize, ) -> Vec<(bool, String, PathBuf)> { - entries.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.to_lowercase().cmp(&b.1.to_lowercase()))); - entries.truncate(cap); - entries + // Retain the alphabetical head while enumerating, including for a + // single directory with hundreds of thousands of entries. Collecting + // everything before truncate defeats both the row and memory limits. + let mut kept: Vec<(bool, String, PathBuf)> = Vec::with_capacity(cap); + let mut bytes = kept.capacity() * std::mem::size_of::<(bool, String, PathBuf)>(); + for entry in entries { + let position = kept + .binary_search_by(|other| { + entry.0.cmp(&other.0).then(other.1.to_lowercase().cmp(&entry.1.to_lowercase())) + }) + .unwrap_or_else(|position| position); + if position >= cap { + continue; + } + let cost = entry.1.capacity() + entry.2.capacity(); + while kept.len() >= cap || bytes + cost > 512 * 1024 { + if kept.len() <= position { + break; + } + let Some(removed) = kept.pop() else { break }; + bytes -= removed.1.capacity() + removed.2.capacity(); + } + if position <= kept.len() && kept.len() < cap && bytes + cost <= 512 * 1024 { + bytes += cost; + kept.insert(position, entry); + } + } + kept } /// Annotate the already-sorted snapshot in one `git check-ignore` call. This diff --git a/nebula_app/src/display/side_panel/search.rs b/nebula_app/src/display/side_panel/search.rs index 3f0b267..e545fd6 100644 --- a/nebula_app/src/display/side_panel/search.rs +++ b/nebula_app/src/display/side_panel/search.rs @@ -1,24 +1,28 @@ -//! Embedded, cross-platform filename index for the Files drawer. -//! -//! The index follows the drawer root instead of indexing whole disks. It is -//! built on a worker thread, queried in memory, and patched from `notify` -//! events. This gives the drawer Everything-style query latency without a -//! Windows-only executable or a second on-disk database. +//! On-demand filename search with bounded warm caches and streamed results. use super::*; - -use std::collections::VecDeque; -use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, AtomicUsize, Ordering}; -use std::sync::{Arc, Mutex, mpsc}; - use notify::event::ModifyKind; use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher as _}; +use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Arc, Condvar, Mutex}; + +mod cache; +mod matching; +mod memory; +#[cfg(test)] +mod stress; +#[cfg(test)] +mod tests; +mod walk; +mod worker; + +use matching::*; +pub(crate) use memory::FileSearchMemory; +pub(crate) use worker::EmbeddedFileIndex; -const MAX_INDEX_ENTRIES: usize = 500_000; const MAX_SEARCH_RESULTS: usize = 1_000; -const WATCH_DEBOUNCE: Duration = Duration::from_millis(80); -const WATCH_QUEUE_CAPACITY: usize = 4096; - +const SEARCH_DEBOUNCE: Duration = Duration::from_millis(120); +const WATCH_DEBOUNCE: Duration = Duration::from_millis(100); const INDEX_IDLE: u8 = 0; const INDEX_BUILDING: u8 = 1; const INDEX_READY: u8 = 2; @@ -51,20 +55,6 @@ struct SearchRequest { options: FileSearchOptions, } -enum IndexCommand { - Rebuild { - root: Option, - epoch: u64, - query: Option, - }, - Query(Option), - Refresh { - epoch: u64, - }, - #[cfg(test)] - ReleaseForTest(mpsc::SyncSender<()>), -} - #[derive(Debug)] pub(crate) struct FileSearchResult { pub(crate) epoch: u64, @@ -74,9 +64,11 @@ pub(crate) struct FileSearchResult { pub(crate) rows: Vec, pub(crate) total: usize, pub(crate) error: Option, + pub(crate) memory: FileSearchMemory, + pub(crate) complete: bool, } -#[derive(Debug)] +#[derive(Debug, Clone)] struct IndexedPath { path: PathBuf, guest_path: Option, @@ -87,410 +79,25 @@ struct IndexedPath { is_dir: bool, } -/// Handle owned by one drawer model. The worker exits when the handle and its -/// command sender are dropped with the window. -pub(crate) struct EmbeddedFileIndex { - command_tx: mpsc::Sender, - result: Arc>>, - desired_epoch: Arc, - status: Arc, - indexed_count: Arc, - truncated: Arc, -} - -impl EmbeddedFileIndex { - pub(crate) fn new() -> Self { - let (command_tx, command_rx) = mpsc::channel(); - let result = Arc::new(Mutex::new(None)); - let desired_epoch = Arc::new(AtomicU64::new(0)); - let status = Arc::new(AtomicU8::new(INDEX_IDLE)); - let indexed_count = Arc::new(AtomicUsize::new(0)); - let truncated = Arc::new(AtomicBool::new(false)); - - let worker_result = Arc::clone(&result); - let worker_epoch = Arc::clone(&desired_epoch); - let worker_status = Arc::clone(&status); - let worker_count = Arc::clone(&indexed_count); - let worker_truncated = Arc::clone(&truncated); - let _ = std::thread::Builder::new().name("nebula file index".to_owned()).spawn(move || { - run_index_worker( - command_rx, - worker_result, - worker_epoch, - worker_status, - worker_count, - worker_truncated, - ); - }); - - Self { command_tx, result, desired_epoch, status, indexed_count, truncated } - } - - pub(crate) fn rebuild( - &self, - root: Option, - epoch: u64, - query: Option<(u64, String, FileSearchOptions)>, - ) { - self.desired_epoch.store(epoch, Ordering::Release); - let query = query.map(|(generation, query, options)| SearchRequest { - epoch, - generation, - query, - options, - }); - if let Ok(mut slot) = self.result.lock() { - *slot = None; - } - self.status - .store(if root.is_some() { INDEX_BUILDING } else { INDEX_IDLE }, Ordering::Release); - let _ = self.command_tx.send(IndexCommand::Rebuild { root, epoch, query }); - } - - pub(crate) fn query( - &self, - epoch: u64, - generation: u64, - query: String, - options: FileSearchOptions, - ) { - let _ = self.command_tx.send(IndexCommand::Query(Some(SearchRequest { - epoch, - generation, - query, - options, - }))); - } - - pub(crate) fn clear_query(&self) { - let _ = self.command_tx.send(IndexCommand::Query(None)); - } - - pub(crate) fn refresh(&self, epoch: u64) { - let _ = self.command_tx.send(IndexCommand::Refresh { epoch }); - } - - pub(crate) fn take_result(&self) -> Option { - self.result.lock().ok()?.take() - } - - pub(crate) fn status(&self) -> FileIndexStatus { - match self.status.load(Ordering::Acquire) { - INDEX_BUILDING => FileIndexStatus::Building, - INDEX_READY => FileIndexStatus::Ready, - _ => FileIndexStatus::Idle, - } - } - - pub(crate) fn indexed_count(&self) -> usize { - self.indexed_count.load(Ordering::Acquire) - } - - pub(crate) fn truncated(&self) -> bool { - self.truncated.load(Ordering::Acquire) - } - - #[cfg(test)] - pub(crate) fn release_for_test(&self) { - self.desired_epoch.fetch_add(1, Ordering::AcqRel); - let (released_tx, released_rx) = mpsc::sync_channel(0); - self.command_tx - .send(IndexCommand::ReleaseForTest(released_tx)) - .expect("file-index worker must accept the release command"); - released_rx - .recv_timeout(Duration::from_secs(2)) - .expect("file-index worker must release its filesystem watcher"); +impl IndexedPath { + fn heap_bytes(&self) -> usize { + self.path.capacity() + + self.guest_path.as_ref().map_or(0, String::capacity) + + self.name.capacity() + + self.name_folded.capacity() + + self.key.capacity() + + self.key_folded.capacity() } } -impl Drop for EmbeddedFileIndex { - fn drop(&mut self) { - self.desired_epoch.fetch_add(1, Ordering::AcqRel); - } -} - -fn run_index_worker( - command_rx: mpsc::Receiver, - result_slot: Arc>>, - desired_epoch: Arc, - status: Arc, - indexed_count: Arc, - truncated: Arc, -) { - let (watch_tx, watch_rx) = mpsc::sync_channel(WATCH_QUEUE_CAPACITY); - let rescan_requested = Arc::new(AtomicBool::new(false)); - let mut watcher: Option = None; - let mut root: Option = None; - let mut epoch = 0; - let mut entries = Vec::new(); - let mut current_query: Option = None; - let mut pending_command = None; - let mut last_watch_check = Instant::now(); - - loop { - let received = pending_command.take().map(Ok).unwrap_or_else(|| { - command_rx.recv_timeout(WATCH_DEBOUNCE.saturating_sub(last_watch_check.elapsed())) - }); - match received { - Ok(IndexCommand::Rebuild { root: next_root, epoch: next_epoch, query }) => { - if desired_epoch.load(Ordering::Acquire) != next_epoch { - continue; - } - root = next_root; - epoch = next_epoch; - current_query = query; - entries.clear(); - watcher = None; - while watch_rx.try_recv().is_ok() {} - rescan_requested.store(false, Ordering::Release); - indexed_count.store(0, Ordering::Release); - truncated.store(false, Ordering::Release); - - let Some(index_root) = root.as_ref() else { - status.store(INDEX_IDLE, Ordering::Release); - continue; - }; - status.store(INDEX_BUILDING, Ordering::Release); - - if let FileIndexRoot::Local(local_root) = index_root { - let callback_tx = watch_tx.clone(); - let callback_rescan = Arc::clone(&rescan_requested); - let callback_root = local_root.clone(); - let canonical_root = - local_root.canonicalize().unwrap_or_else(|_| local_root.clone()); - watcher = notify::recommended_watcher( - move |mut event: notify::Result| { - if let Ok(event) = &mut event - && !event.need_rescan() - { - if !event_changes_names(&event.kind) { - return; - } - event.paths.retain_mut(|path| { - let Some(mapped) = - watched_local_path(&callback_root, &canonical_root, path) - else { - return false; - }; - *path = mapped; - true - }); - if event.paths.is_empty() { - return; - } - } - if let Err(mpsc::TrySendError::Full(_)) = callback_tx.try_send(event) { - callback_rescan.store(true, Ordering::Release); - } - }, - ) - .ok(); - if let Some(active_watcher) = watcher.as_mut() - && let Err(error) = - active_watcher.watch(local_root, RecursiveMode::Recursive) - { - log::debug!( - "unable to watch file-search root {}: {error}", - local_root.display() - ); - watcher = None; - } - } - - let completed = match index_root { - FileIndexRoot::Local(local_root) => build_local_index( - local_root, - next_epoch, - &desired_epoch, - &indexed_count, - &mut entries, - ), - FileIndexRoot::Wsl(located) => { - build_wsl_index(located, &indexed_count, &mut entries) - }, - }; - if !completed || desired_epoch.load(Ordering::Acquire) != next_epoch { - continue; - } - truncated.store(entries.len() >= MAX_INDEX_ENTRIES, Ordering::Release); - status.store(INDEX_READY, Ordering::Release); - if let Some(request) = current_query.as_ref() { - publish_search_result(&entries, request, &result_slot); - } - }, - Ok(IndexCommand::Query(mut request)) => { - // A user can type several characters while a large index is - // being scanned. Keep only the newest queued query, but never - // consume a root rebuild: save that command for the next loop. - while let Ok(next) = command_rx.try_recv() { - match next { - IndexCommand::Query(next_request) => request = next_request, - command => { - pending_command = Some(command); - break; - }, - } - } - current_query = request.filter(|request| request.epoch == epoch); - if status.load(Ordering::Acquire) == INDEX_READY - && desired_epoch.load(Ordering::Acquire) == epoch - && let Some(request) = current_query.as_ref() - { - publish_search_result(&entries, request, &result_slot); - } - }, - Ok(IndexCommand::Refresh { epoch: requested_epoch }) => { - if requested_epoch == epoch - && desired_epoch.load(Ordering::Acquire) == epoch - && root.is_some() - && watcher.is_none() - { - pending_command = Some(IndexCommand::Rebuild { - root: root.clone(), - epoch, - query: current_query.clone(), - }); - } - }, - #[cfg(test)] - Ok(IndexCommand::ReleaseForTest(released)) => { - watcher = None; - root = None; - entries.clear(); - current_query = None; - indexed_count.store(0, Ordering::Release); - truncated.store(false, Ordering::Release); - status.store(INDEX_IDLE, Ordering::Release); - let _ = released.send(()); - }, - Err(mpsc::RecvTimeoutError::Timeout) => {}, - Err(mpsc::RecvTimeoutError::Disconnected) => return, - } - if last_watch_check.elapsed() < WATCH_DEBOUNCE { - continue; - } - last_watch_check = Instant::now(); - let Some(FileIndexRoot::Local(local_root)) = root.as_ref() else { continue }; - if watcher.is_none() - || status.load(Ordering::Acquire) != INDEX_READY - || desired_epoch.load(Ordering::Acquire) != epoch - { - continue; - } - let mut changed_paths = Vec::new(); - let mut rescan = rescan_requested.swap(false, Ordering::AcqRel); - for event in watch_rx.try_iter().take(WATCH_QUEUE_CAPACITY) { - match event { - Ok(event) if event.need_rescan() => rescan = true, - Ok(event) if event_changes_names(&event.kind) => { - changed_paths.extend(event.paths); - }, - Ok(_) => {}, - Err(error) => { - log::debug!("file-search watcher error: {error}"); - rescan = true; - }, - } - } - if rescan { - pending_command = Some(IndexCommand::Rebuild { - root: root.clone(), - epoch, - query: current_query.clone(), - }); - continue; - } - if changed_paths.is_empty() { - continue; - } - patch_local_index( - local_root, - &mut entries, - changed_paths, - epoch, - &desired_epoch, - &indexed_count, - ); - if desired_epoch.load(Ordering::Acquire) != epoch { - continue; - } - truncated.store(entries.len() >= MAX_INDEX_ENTRIES, Ordering::Release); - if let Some(request) = current_query.as_ref() { - publish_search_result(&entries, request, &result_slot); - } - } -} - -fn build_local_index( - root: &Path, - epoch: u64, - desired_epoch: &AtomicU64, - indexed_count: &AtomicUsize, - entries: &mut Vec, -) -> bool { - collect_local_path(root, root, false, epoch, desired_epoch, entries); - indexed_count.store(entries.len(), Ordering::Release); - desired_epoch.load(Ordering::Acquire) == epoch -} - -fn collect_local_path( - root: &Path, - start: &Path, - include_start: bool, - epoch: u64, - desired_epoch: &AtomicU64, - entries: &mut Vec, -) { - if !indexable_local_path(root, start) { - return; - } - let mut directories = VecDeque::new(); - if include_start { - let Ok(file_type) = start.symlink_metadata().map(|metadata| metadata.file_type()) else { - return; - }; - if file_type.is_symlink() { - return; - } - if let Some(entry) = indexed_local_path(root, start, file_type.is_dir()) { - entries.push(entry); - } - if file_type.is_dir() { - directories.push_back(start.to_path_buf()); - } - } else { - directories.push_back(start.to_path_buf()); - } - - while let Some(directory) = directories.pop_front() { - if entries.len() >= MAX_INDEX_ENTRIES || desired_epoch.load(Ordering::Acquire) != epoch { - return; - } - let Ok(read) = std::fs::read_dir(&directory) else { continue }; - for child in read.flatten() { - if entries.len() >= MAX_INDEX_ENTRIES || desired_epoch.load(Ordering::Acquire) != epoch - { - return; - } - let Ok(file_type) = child.file_type() else { continue }; - if file_type.is_symlink() { - continue; - } - let name = child.file_name(); - // The normal tree intentionally hides Git's object database; the - // search index follows that same user-visible boundary. - if name == ".git" { - continue; - } - let path = child.path(); - if let Some(entry) = indexed_local_path(root, &path, file_type.is_dir()) { - entries.push(entry); - } - if file_type.is_dir() { - directories.push_back(path); - } - } - } +fn event_changes_names(kind: &EventKind) -> bool { + matches!( + kind, + EventKind::Any + | EventKind::Create(_) + | EventKind::Remove(_) + | EventKind::Modify(ModifyKind::Name(_)) + ) } fn indexed_local_path(root: &Path, path: &Path, is_dir: bool) -> Option { @@ -507,516 +114,3 @@ fn indexed_local_path(root: &Path, path: &Path, is_dir: bool) -> Option, -) -> bool { - let mut rows = Vec::new(); - let mut budget = SEARCH_VISIT_BUDGET; - build_wsl_search_index(located, &mut rows, &mut budget); - let root = normalize_wsl_guest_path(&located.guest); - entries.extend(rows.into_iter().map(|row| { - let guest = row.guest_path.clone().unwrap_or_else(|| row.path.display().to_string()); - let key = guest.strip_prefix(&root).unwrap_or(&guest).trim_start_matches('/').to_owned(); - IndexedPath { - path: row.path, - guest_path: row.guest_path, - name_folded: row.name.to_lowercase(), - key_folded: key.to_lowercase(), - name: row.name, - key, - is_dir: row.is_dir, - } - })); - indexed_count.store(entries.len(), Ordering::Release); - true -} - -fn event_changes_names(kind: &EventKind) -> bool { - matches!( - kind, - EventKind::Any - | EventKind::Create(_) - | EventKind::Remove(_) - | EventKind::Modify(ModifyKind::Name(_)) - ) -} - -fn indexable_local_path(root: &Path, path: &Path) -> bool { - path.strip_prefix(root).is_ok_and(|relative| { - !relative.components().any(|component| component.as_os_str() == ".git") - }) -} - -fn watched_local_path(root: &Path, canonical_root: &Path, path: &Path) -> Option { - let relative = path.strip_prefix(root).or_else(|_| path.strip_prefix(canonical_root)).ok()?; - let visible_path = root.join(relative); - indexable_local_path(root, &visible_path).then_some(visible_path) -} - -fn patch_local_index( - root: &Path, - entries: &mut Vec, - mut paths: Vec, - epoch: u64, - desired_epoch: &AtomicU64, - indexed_count: &AtomicUsize, -) { - if desired_epoch.load(Ordering::Acquire) != epoch { - return; - } - paths.retain(|path| indexable_local_path(root, path)); - paths.sort(); - paths.dedup(); - let mut roots: Vec = Vec::new(); - for path in paths { - if roots.last().is_none_or(|parent| !path.starts_with(parent)) { - roots.push(path); - } - } - if roots.iter().any(|path| path == root) { - entries.clear(); - let _ = build_local_index(root, epoch, desired_epoch, indexed_count, entries); - return; - } - - if roots.is_empty() { - return; - } - let changed_roots: HashSet<&Path> = roots.iter().map(PathBuf::as_path).collect(); - entries.retain(|entry| !entry.path.ancestors().any(|path| changed_roots.contains(path))); - for changed in roots { - if entries.len() >= MAX_INDEX_ENTRIES { - break; - } - collect_local_path(root, &changed, true, epoch, desired_epoch, entries); - } - indexed_count.store(entries.len(), Ordering::Release); -} - -fn publish_search_result( - entries: &[IndexedPath], - request: &SearchRequest, - result_slot: &Mutex>, -) { - let result = search_entries(entries, request); - if let Ok(mut slot) = result_slot.lock() { - *slot = Some(result); - } -} - -enum QueryMatcher { - Plain { terms: Vec, whole_word: bool, match_case: bool }, - Regex(regex::Regex), -} - -impl QueryMatcher { - fn compile(query: &str, options: FileSearchOptions) -> Result { - if options.regex { - let source = if options.whole_word { - format!(r"(?:^|[^\p{{L}}\p{{N}}_])(?:{query})(?:$|[^\p{{L}}\p{{N}}_])") - } else { - query.to_owned() - }; - return regex::RegexBuilder::new(&source) - .case_insensitive(!options.match_case) - .build() - .map(Self::Regex) - .map_err(|error| error.to_string()); - } - let terms = query - .split_whitespace() - .map(|term| if options.match_case { term.to_owned() } else { term.to_lowercase() }) - .collect(); - Ok(Self::Plain { terms, whole_word: options.whole_word, match_case: options.match_case }) - } - - fn matches(&self, entry: &IndexedPath) -> bool { - match self { - Self::Regex(regex) => regex.is_match(&entry.key), - Self::Plain { terms, whole_word, match_case } => { - let haystack = if *match_case { &entry.key } else { &entry.key_folded }; - terms.iter().all(|term| { - if *whole_word { - contains_whole_word(haystack, term) - } else { - haystack.contains(term) - } - }) - }, - } - } -} - -fn contains_whole_word(haystack: &str, needle: &str) -> bool { - if needle.is_empty() { - return true; - } - haystack.match_indices(needle).any(|(start, matched)| { - let before = haystack[..start].chars().next_back(); - let after = haystack[start + matched.len()..].chars().next(); - !before.is_some_and(is_word_char) && !after.is_some_and(is_word_char) - }) -} - -fn is_word_char(character: char) -> bool { - character.is_alphanumeric() || character == '_' -} - -fn result_score(entry: &IndexedPath, query: &str, match_case: bool) -> u8 { - let name = if match_case { &entry.name } else { &entry.name_folded }; - if name == query { - 0 - } else if name.starts_with(query) { - 1 - } else if name.contains(query) { - 2 - } else { - 3 - } -} - -fn search_entries(entries: &[IndexedPath], request: &SearchRequest) -> FileSearchResult { - let matcher = match QueryMatcher::compile(&request.query, request.options) { - Ok(matcher) => matcher, - Err(error) => { - return FileSearchResult { - epoch: request.epoch, - generation: request.generation, - query: request.query.clone(), - options: request.options, - rows: Vec::new(), - total: 0, - error: Some(error), - }; - }, - }; - let ranking_query = if request.options.match_case { - request.query.clone() - } else { - request.query.to_lowercase() - }; - let mut matches: Vec<(u8, &IndexedPath)> = entries - .iter() - .filter(|entry| matcher.matches(entry)) - .map(|entry| (result_score(entry, &ranking_query, request.options.match_case), entry)) - .collect(); - let total = matches.len(); - let compare = |(left_score, left): &(u8, &IndexedPath), - (right_score, right): &(u8, &IndexedPath)| { - left_score - .cmp(right_score) - .then(right.is_dir.cmp(&left.is_dir)) - .then(left.name_folded.cmp(&right.name_folded)) - .then(left.key_folded.cmp(&right.key_folded)) - }; - if matches.len() > MAX_SEARCH_RESULTS { - matches.select_nth_unstable_by(MAX_SEARCH_RESULTS, compare); - matches.truncate(MAX_SEARCH_RESULTS); - } - matches.sort_unstable_by(compare); - let rows = matches - .into_iter() - .take(MAX_SEARCH_RESULTS) - .map(|(_, entry)| FileRow { - path: entry.path.clone(), - guest_path: entry.guest_path.clone(), - name: entry.name.clone(), - depth: 0, - is_dir: entry.is_dir, - expanded: false, - is_parent: false, - ignored: false, - }) - .collect(); - FileSearchResult { - epoch: request.epoch, - generation: request.generation, - query: request.query.clone(), - options: request.options, - rows, - total, - error: None, - } -} - -#[cfg(test)] -mod search_tests { - use super::*; - - fn entry(key: &str) -> IndexedPath { - let name = key.rsplit('/').next().unwrap_or(key).to_owned(); - IndexedPath { - path: PathBuf::from(key), - guest_path: None, - name_folded: name.to_lowercase(), - key_folded: key.to_lowercase(), - name, - key: key.to_owned(), - is_dir: false, - } - } - - #[test] - fn plain_search_is_case_insensitive_and_ands_terms() { - let entries = [entry("src/FileTree/SearchIndex.rs"), entry("docs/search.md")]; - let request = SearchRequest { - epoch: 1, - generation: 1, - query: "TREE index".to_owned(), - options: FileSearchOptions::default(), - }; - let result = search_entries(&entries, &request); - assert_eq!(result.total, 1); - assert_eq!(result.rows[0].name, "SearchIndex.rs"); - } - - #[test] - fn case_whole_word_and_regex_options_share_one_matcher() { - let entries = [entry("src/Foo.rs"), entry("src/foo_bar.rs"), entry("src/food.rs")]; - let whole_word = SearchRequest { - epoch: 1, - generation: 1, - query: "foo".to_owned(), - options: FileSearchOptions { whole_word: true, ..Default::default() }, - }; - assert_eq!(search_entries(&entries, &whole_word).total, 1); - - let case_sensitive = SearchRequest { - epoch: 1, - generation: 2, - query: "Foo".to_owned(), - options: FileSearchOptions { match_case: true, ..Default::default() }, - }; - assert_eq!(search_entries(&entries, &case_sensitive).total, 1); - - let regex = SearchRequest { - epoch: 1, - generation: 3, - query: r"foo(?:d|_bar)".to_owned(), - options: FileSearchOptions { regex: true, ..Default::default() }, - }; - assert_eq!(search_entries(&entries, ®ex).total, 2); - } - - #[test] - fn invalid_regex_is_reported_without_stale_rows() { - let result = search_entries( - &[entry("src/main.rs")], - &SearchRequest { - epoch: 1, - generation: 7, - query: "[".to_owned(), - options: FileSearchOptions { regex: true, ..Default::default() }, - }, - ); - assert_eq!(result.generation, 7); - assert!(result.rows.is_empty()); - assert!(result.error.is_some()); - } - - #[test] - fn local_index_crawls_nested_paths_but_hides_git_objects() { - let temp = tempfile::tempdir().expect("temp dir"); - std::fs::create_dir_all(temp.path().join("src/nested")).expect("source directories"); - std::fs::create_dir_all(temp.path().join(".git/objects")).expect("git directories"); - std::fs::write(temp.path().join("src/nested/needle.rs"), b"").expect("source file"); - std::fs::write(temp.path().join(".git/objects/hidden"), b"").expect("git object"); - - let epoch = AtomicU64::new(4); - let count = AtomicUsize::new(0); - let mut entries = Vec::new(); - assert!(build_local_index(temp.path(), 4, &epoch, &count, &mut entries)); - assert!(entries.iter().any(|entry| entry.key == "src/nested/needle.rs")); - assert!(!entries.iter().any(|entry| entry.key.contains(".git"))); - assert_eq!(count.load(Ordering::Acquire), entries.len()); - } - - #[test] - fn local_index_patch_replaces_removed_and_created_paths() { - let temp = tempfile::tempdir().expect("temp dir"); - let old = temp.path().join("old.txt"); - std::fs::write(&old, b"").expect("old file"); - let epoch = AtomicU64::new(8); - let count = AtomicUsize::new(0); - let mut entries = Vec::new(); - assert!(build_local_index(temp.path(), 8, &epoch, &count, &mut entries)); - - std::fs::remove_file(&old).expect("remove old file"); - let new_dir = temp.path().join("new"); - std::fs::create_dir(&new_dir).expect("new directory"); - std::fs::write(new_dir.join("fresh.txt"), b"").expect("new file"); - patch_local_index(temp.path(), &mut entries, vec![old, new_dir], 8, &epoch, &count); - - assert!(!entries.iter().any(|entry| entry.name == "old.txt")); - assert!(entries.iter().any(|entry| entry.key == "new/fresh.txt")); - assert_eq!(count.load(Ordering::Acquire), entries.len()); - } - - #[test] - fn large_change_batches_preserve_unrelated_index_entries() { - let temp = tempfile::tempdir().unwrap(); - let epoch = AtomicU64::new(2); - let count = AtomicUsize::new(0); - let untouched = temp.path().join("untouched.txt"); - let mut entries = vec![indexed_local_path(temp.path(), &untouched, false).unwrap()]; - let paths: Vec<_> = (0..128) - .map(|number| { - let path = temp.path().join(format!("changed-{number}.txt")); - std::fs::write(&path, b"").unwrap(); - path - }) - .collect(); - patch_local_index(temp.path(), &mut entries, paths, 2, &epoch, &count); - - assert!(entries.iter().any(|entry| entry.path == untouched)); - assert_eq!(entries.len(), 129); - assert_eq!(count.load(Ordering::Acquire), 129); - } - - #[test] - fn local_patch_deduplicates_overlapping_paths_and_hides_git_events() { - let temp = tempfile::tempdir().unwrap(); - let nested = temp.path().join("src/nested"); - let source = nested.join("needle.rs"); - let sibling = temp.path().join("src-other"); - let git_object = temp.path().join(".git/objects/hidden"); - std::fs::create_dir_all(&nested).unwrap(); - std::fs::create_dir_all(&sibling).unwrap(); - std::fs::create_dir_all(git_object.parent().unwrap()).unwrap(); - std::fs::write(&source, b"").unwrap(); - std::fs::write(&git_object, b"").unwrap(); - let epoch = AtomicU64::new(1); - let count = AtomicUsize::new(0); - let mut entries = Vec::new(); - patch_local_index( - temp.path(), - &mut entries, - vec![ - source.clone(), - sibling.clone(), - nested, - temp.path().join("src"), - source.clone(), - temp.path().join(".git"), - git_object, - ], - 1, - &epoch, - &count, - ); - - assert_eq!(entries.iter().filter(|entry| entry.path == source).count(), 1); - assert!(entries.iter().any(|entry| entry.path == sibling)); - assert!(!entries.iter().any(|entry| entry.key.contains(".git"))); - assert_eq!(entries.len(), 4); - } - - #[test] - fn cancelled_patch_leaves_index_intact() { - let temp = tempfile::tempdir().unwrap(); - let original = temp.path().join("original.txt"); - let mut entries = vec![indexed_local_path(temp.path(), &original, false).unwrap()]; - let count = AtomicUsize::new(1); - patch_local_index( - temp.path(), - &mut entries, - vec![original.clone()], - 1, - &AtomicU64::new(2), - &count, - ); - assert_eq!(entries[0].path, original); - assert_eq!(count.load(Ordering::Acquire), 1); - } - - fn wait_for_result( - index: &EmbeddedFileIndex, - matches: impl Fn(&FileSearchResult) -> bool, - ) -> FileSearchResult { - let deadline = Instant::now() + Duration::from_secs(10); - while Instant::now() < deadline { - if let Some(result) = index.take_result() - && matches(&result) - { - return result; - } - std::thread::sleep(Duration::from_millis(10)); - } - panic!("file index did not publish the expected result"); - } - - #[test] - fn canonical_watch_events_keep_the_visible_root_for_create_rename_and_delete() { - let root = Path::new("visible").join("checkout"); - let canonical = Path::new("resolved").join("checkout"); - for relative in ["needle.txt", "needle-renamed.txt", "deleted.txt"] { - let visible = root.join(relative); - assert_eq!( - watched_local_path(&root, &canonical, &canonical.join(relative)), - Some(visible.clone()) - ); - assert_eq!(watched_local_path(&root, &canonical, &visible), Some(visible)); - } - assert_eq!(watched_local_path(&root, &canonical, &canonical), Some(root)); - } - - #[test] - fn canonical_watch_events_still_reject_other_roots_and_git_internals() { - let root = Path::new("visible").join("checkout"); - let canonical = Path::new("resolved").join("checkout"); - for path in [ - root.join(".git/objects/hidden"), - canonical.join(".git/objects/hidden"), - Path::new("resolved").join("checkout-other/needle.txt"), - ] { - assert!(watched_local_path(&root, &canonical, &path).is_none()); - } - } - - #[test] - fn watched_index_tracks_create_rename_delete_without_rebuilding() { - let temp = tempfile::tempdir().unwrap(); - let index = EmbeddedFileIndex::new(); - index.rebuild( - Some(FileIndexRoot::Local(temp.path().to_owned())), - 1, - Some((1, "needle".to_owned(), FileSearchOptions::default())), - ); - wait_for_result(&index, |result| result.total == 0); - - let created = temp.path().join("needle.txt"); - std::fs::write(&created, b"").unwrap(); - wait_for_result(&index, |result| result.total == 1); - let renamed = temp.path().join("needle-renamed.txt"); - std::fs::rename(&created, &renamed).unwrap(); - wait_for_result(&index, |result| result.total == 1 && result.rows[0].path == renamed); - std::fs::remove_file(&renamed).unwrap(); - wait_for_result(&index, |result| result.total == 0); - assert_eq!(index.desired_epoch.load(Ordering::Acquire), 1); - index.release_for_test(); - } - - #[test] - fn switching_roots_discards_queued_old_queries() { - let first = tempfile::tempdir().unwrap(); - let second = tempfile::tempdir().unwrap(); - std::fs::write(first.path().join("needle-first.txt"), b"").unwrap(); - std::fs::write(second.path().join("needle-second.txt"), b"").unwrap(); - let index = EmbeddedFileIndex::new(); - index.rebuild(Some(FileIndexRoot::Local(first.path().to_owned())), 1, None); - index.query(1, 1, "needle".to_owned(), FileSearchOptions::default()); - index.rebuild( - Some(FileIndexRoot::Local(second.path().to_owned())), - 2, - Some((2, "needle".to_owned(), FileSearchOptions::default())), - ); - let result = wait_for_result(&index, |result| result.epoch == 2); - assert_eq!(result.generation, 2); - assert_eq!(result.total, 1); - assert_eq!(result.rows[0].name, "needle-second.txt"); - index.release_for_test(); - } -} diff --git a/nebula_app/src/display/side_panel/search/cache.rs b/nebula_app/src/display/side_panel/search/cache.rs new file mode 100644 index 0000000..5da6c0e --- /dev/null +++ b/nebula_app/src/display/side_panel/search/cache.rs @@ -0,0 +1,113 @@ +//! Bounded warm cache. A traversal prefix is retained once and reused by queries. + +use super::*; + +pub(super) struct FileCache { + pub entries: Vec, + pub memory: FileSearchMemory, + pub full: bool, + pub complete: bool, + pub watches: DirectoryWatches, + pub finished: Instant, +} + +pub(super) struct DirectoryWatches { + pub dirty: Arc, + watcher: Option, + watched: HashSet, + watch_bytes: usize, + pub unwatched: bool, +} + +impl FileCache { + pub fn new() -> Self { + Self { + entries: Vec::new(), + memory: FileSearchMemory::cache(), + full: false, + complete: false, + watches: DirectoryWatches::new(), + finished: Instant::now(), + } + } + + pub fn record(&mut self, position: usize, entry: &IndexedPath) { + if position < self.entries.len() { + if self.entries[position].path != entry.path + || self.entries[position].is_dir != entry.is_dir + { + // An unobserved edit changed the traversal prefix. Do not + // append duplicates or label this cache complete. + self.entries = Vec::new(); + self.memory = FileSearchMemory::cache(); + self.full = true; + } + return; + } + if self.full || position != self.entries.len() { + return; + } + let growth = if self.entries.len() == self.entries.capacity() { 128 } else { 0 }; + let bytes = entry.heap_bytes() + growth * std::mem::size_of::(); + if self.memory.bytes() + bytes > memory::CACHE_LIMIT || !self.memory.grow(bytes) { + self.full = true; + return; + } + if growth > 0 { + self.entries.reserve_exact(growth); + } + self.entries.push(entry.clone()); + } + + pub fn finish_prefix(&mut self, visited: usize) { + if visited < self.entries.len() { + let bytes = self.entries[visited..].iter().map(IndexedPath::heap_bytes).sum(); + self.entries.truncate(visited); + self.memory.release(bytes); + } + } +} + +impl DirectoryWatches { + pub fn new() -> Self { + Self { + dirty: Arc::new(AtomicBool::new(false)), + watcher: None, + watched: HashSet::new(), + watch_bytes: 0, + unwatched: false, + } + } + + pub fn watch(&mut self, directory: &Path) { + if self.watched.contains(directory) { + return; + } + if self.watched.len() >= 128 || self.watch_bytes + directory.as_os_str().len() > 64 * 1024 { + self.unwatched = true; + return; + } + if self.watcher.is_none() { + let dirty = self.dirty.clone(); + self.watcher = + notify::recommended_watcher(move |event: notify::Result| { + if event.as_ref().map_or(true, |event| { + event.need_rescan() || event_changes_names(&event.kind) + }) { + dirty.store(true, Ordering::Release); + } + }) + .ok(); + } + let Some(watcher) = self.watcher.as_mut() else { + self.unwatched = true; + return; + }; + if watcher.watch(directory, RecursiveMode::NonRecursive).is_ok() { + self.watch_bytes += directory.as_os_str().len(); + self.watched.insert(directory.to_owned()); + } else { + self.unwatched = true; + } + } +} diff --git a/nebula_app/src/display/side_panel/search/matching.rs b/nebula_app/src/display/side_panel/search/matching.rs new file mode 100644 index 0000000..0b8a7d1 --- /dev/null +++ b/nebula_app/src/display/side_panel/search/matching.rs @@ -0,0 +1,201 @@ +//! Matching semantics and bounded ranking shared by cached and streamed queries. + +use super::*; + +pub(super) enum QueryMatcher { + Plain { terms: Vec, whole_word: bool, match_case: bool }, + Regex(regex::Regex), +} + +impl QueryMatcher { + pub(super) fn compile(query: &str, options: FileSearchOptions) -> Result { + if query.len() > 4096 { + return Err("The file search query is too long (maximum 4096 bytes)".to_owned()); + } + if options.regex { + let source = if options.whole_word { + format!(r"(?:^|[^\p{{L}}\p{{N}}_])(?:{query})(?:$|[^\p{{L}}\p{{N}}_])") + } else { + query.to_owned() + }; + return regex::RegexBuilder::new(&source) + .case_insensitive(!options.match_case) + .size_limit(256 * 1024) + .dfa_size_limit(256 * 1024) + .build() + .map(Self::Regex) + .map_err(|error| error.to_string()); + } + let terms = query + .split_whitespace() + .map(|term| if options.match_case { term.to_owned() } else { term.to_lowercase() }) + .collect(); + Ok(Self::Plain { terms, whole_word: options.whole_word, match_case: options.match_case }) + } + + pub(super) fn matches(&self, entry: &IndexedPath) -> bool { + match self { + Self::Regex(regex) => regex.is_match(&entry.key), + Self::Plain { terms, whole_word, match_case } => { + let haystack = if *match_case { &entry.key } else { &entry.key_folded }; + terms.iter().all(|term| { + if *whole_word { + contains_whole_word(haystack, term) + } else { + haystack.contains(term) + } + }) + }, + } + } +} + +fn contains_whole_word(haystack: &str, needle: &str) -> bool { + if needle.is_empty() { + return true; + } + haystack.match_indices(needle).any(|(start, matched)| { + let before = haystack[..start].chars().next_back(); + let after = haystack[start + matched.len()..].chars().next(); + !before.is_some_and(is_word_char) && !after.is_some_and(is_word_char) + }) +} + +fn is_word_char(character: char) -> bool { + character.is_alphanumeric() || character == '_' +} + +fn result_score(entry: &IndexedPath, query: &str, match_case: bool) -> u8 { + let name = if match_case { &entry.name } else { &entry.name_folded }; + if name == query { + 0 + } else if name.starts_with(query) { + 1 + } else if name.contains(query) { + 2 + } else { + 3 + } +} + +pub(super) struct BestMatches { + rows: Vec<(u8, IndexedPath)>, + bytes: usize, + total: usize, + query: String, + match_case: bool, + pub limited: bool, +} + +fn compare(left: &(u8, IndexedPath), right: &(u8, IndexedPath)) -> std::cmp::Ordering { + left.0 + .cmp(&right.0) + .then(right.1.is_dir.cmp(&left.1.is_dir)) + .then(left.1.name_folded.cmp(&right.1.name_folded)) + .then(left.1.key_folded.cmp(&right.1.key_folded)) +} + +impl BestMatches { + pub fn new(request: &SearchRequest) -> Self { + let rows = Vec::with_capacity(MAX_SEARCH_RESULTS); + Self { + bytes: rows.capacity() * std::mem::size_of::<(u8, IndexedPath)>(), + rows, + total: 0, + query: if request.options.match_case { + request.query.clone() + } else { + request.query.to_lowercase() + }, + match_case: request.options.match_case, + limited: false, + } + } + + pub fn add(&mut self, entry: IndexedPath) { + self.total += 1; + let bytes = entry.heap_bytes(); + let ranked = (result_score(&entry, &self.query, self.match_case), entry); + let position = + self.rows.binary_search_by(|row| compare(row, &ranked)).unwrap_or_else(|pos| pos); + if position >= MAX_SEARCH_RESULTS { + self.limited = true; + return; + } + while self.rows.len() >= MAX_SEARCH_RESULTS || self.bytes + bytes > memory::MATCH_LIMIT { + self.limited = true; + if self.rows.len() <= position { + return; + } + let Some((_, removed)) = self.rows.pop() else { return }; + self.bytes -= removed.heap_bytes(); + } + self.bytes += bytes; + self.rows.insert(position, ranked); + } + + pub fn result(&self, request: &SearchRequest) -> (FileSearchResult, bool) { + let mut memory = FileSearchMemory::results(); + let array_bytes = self.rows.len() * std::mem::size_of::(); + let mut rows = Vec::new(); + let mut limited = false; + if memory.grow(array_bytes) { + rows.reserve_exact(self.rows.len()); + for (_, entry) in &self.rows { + let bytes = entry.path.as_os_str().len() + + entry.name.len() + + entry.guest_path.as_ref().map_or(0, String::len); + if memory.bytes() + bytes > memory::RESULT_LIMIT || !memory.grow(bytes) { + limited = true; + break; + } + rows.push(FileRow { + path: entry.path.clone(), + guest_path: entry.guest_path.clone(), + name: entry.name.clone(), + depth: 0, + is_dir: entry.is_dir, + expanded: false, + is_parent: false, + ignored: false, + }); + } + } else { + limited = true; + } + ( + FileSearchResult { + epoch: request.epoch, + generation: request.generation, + query: request.query.clone(), + options: request.options, + rows, + total: self.total, + error: None, + memory, + complete: true, + }, + limited, + ) + } +} + +#[cfg(test)] +pub(super) fn search_entries(entries: &[IndexedPath], request: &SearchRequest) -> FileSearchResult { + let mut best = BestMatches::new(request); + match QueryMatcher::compile(&request.query, request.options) { + Ok(matcher) => { + for entry in entries { + if matcher.matches(entry) { + best.add(entry.clone()); + } + } + best.result(request).0 + }, + Err(error) => { + let mut result = best.result(request).0; + result.error = Some(error); + result + }, + } +} diff --git a/nebula_app/src/display/side_panel/search/memory.rs b/nebula_app/src/display/side_panel/search/memory.rs new file mode 100644 index 0000000..f82b46b --- /dev/null +++ b/nebula_app/src/display/side_panel/search/memory.rs @@ -0,0 +1,61 @@ +//! Process-wide bounds for retained filename caches and published search rows. +//! +//! Leases follow the allocation owner, including results already handed to a +//! view. Replacing a query does not grant another budget to its retiring task. + +use std::sync::atomic::{AtomicUsize, Ordering}; + +pub(super) const CACHE_LIMIT: usize = 6 * 1024 * 1024; +pub(super) const MATCH_LIMIT: usize = 1024 * 1024; +pub(super) const RESULT_LIMIT: usize = 512 * 1024; +pub(super) const PATH_LIMIT: usize = 64 * 1024; + +static CACHES: AtomicUsize = AtomicUsize::new(0); +static RESULTS: AtomicUsize = AtomicUsize::new(0); + +#[derive(Debug)] +pub(crate) struct FileSearchMemory { + counter: &'static AtomicUsize, + limit: usize, + bytes: usize, +} + +impl FileSearchMemory { + pub(super) fn cache() -> Self { + Self { counter: &CACHES, limit: 8 * 1024 * 1024, bytes: 0 } + } + + pub(super) fn results() -> Self { + Self { counter: &RESULTS, limit: 2 * 1024 * 1024, bytes: 0 } + } + + pub(super) fn grow(&mut self, bytes: usize) -> bool { + if self + .counter + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |used| { + used.checked_add(bytes).filter(|next| *next <= self.limit) + }) + .is_err() + { + return false; + } + self.bytes += bytes; + true + } + + pub(super) fn bytes(&self) -> usize { + self.bytes + } + + pub(super) fn release(&mut self, bytes: usize) { + assert!(bytes <= self.bytes); + self.counter.fetch_sub(bytes, Ordering::AcqRel); + self.bytes -= bytes; + } +} + +impl Drop for FileSearchMemory { + fn drop(&mut self) { + self.release(self.bytes); + } +} diff --git a/nebula_app/src/display/side_panel/search/stress.rs b/nebula_app/src/display/side_panel/search/stress.rs new file mode 100644 index 0000000..e560f71 --- /dev/null +++ b/nebula_app/src/display/side_panel/search/stress.rs @@ -0,0 +1,175 @@ +//! Explicit, slower filesystem/allocator exercise using the production worker. +//! Only owned temporary fixtures are created, searched and removed. + +use super::*; + +fn answer(panel: &mut SidePanel, expected: usize) { + let deadline = Instant::now() + Duration::from_secs(90); + loop { + panel.harvest_snapshot(); + panel.harvest_file_search(); + if !panel.file_search_pending() && panel.file_search_total() == expected { + assert!(panel.file_search_error().is_none()); + assert_eq!(panel.file_rows().first().map(|row| row.name.as_str()), Some("needle")); + return; + } + assert!(Instant::now() < deadline, "search stress timed out"); + std::thread::sleep(Duration::from_millis(10)); + } +} + +fn row_bytes(rows: &Vec) -> usize { + rows.capacity() * std::mem::size_of::() + + rows + .iter() + .map(|row| { + row.path.capacity() + + row.name.capacity() + + row.guest_path.as_ref().map_or(0, String::capacity) + }) + .sum::() +} + +#[cfg(windows)] +fn process_memory(array: usize) -> serde_json::Value { + #[repr(C)] + struct Counters { + size: u32, + faults: u32, + values: [usize; 9], + } + #[link(name = "psapi")] + unsafe extern "system" { + fn GetProcessMemoryInfo(process: isize, counters: *mut Counters, size: u32) -> i32; + } + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetProcessHeap() -> isize; + fn HeapSize(heap: isize, flags: u32, memory: *const std::ffi::c_void) -> usize; + } + let mut counters = + Counters { size: std::mem::size_of::() as u32, faults: 0, values: [0; 9] }; + let ok = + unsafe { GetProcessMemoryInfo(-1, &mut counters, std::mem::size_of::() as u32) }; + assert_ne!(ok, 0); + let block = if array > 4096 { + let bytes = unsafe { HeapSize(GetProcessHeap(), 0, array as *const _) }; + (bytes != usize::MAX).then_some(bytes) + } else { + None + }; + serde_json::json!({"private_commit": counters.values[8], "working_set": counters.values[1], "array_heap_block_bytes": block}) +} + +#[cfg(not(windows))] +fn process_memory(_: usize) -> serde_json::Value { + serde_json::Value::Null +} + +#[test] +#[ignore = "Creates 27,200 files and exercises repeated scans, root switches and allocator retention"] +fn file_search_memory_stress() { + let fixture = tempfile::tempdir().unwrap(); + let mut roots = Vec::new(); + for (folder, files) in [("wide", 16_000), ("medium", 6_000), ("small", 2_000), ("nested", 160)] + { + let root = fixture.path().join(folder); + std::fs::create_dir(&root).unwrap(); + for number in 0..files { + if folder == "nested" { + let directory = root.join(format!("directory-{number:04}")); + std::fs::create_dir(&directory).unwrap(); + for child in 0..20 { + std::fs::write(directory.join(format!("item-{child:02}-needle.txt")), b"") + .unwrap(); + } + continue; + } + let name = format!("item-{number:05}-{}-needle.txt", "abcdefghij".repeat(7)); + std::fs::write(root.join(name), b"").unwrap(); + } + std::fs::write(root.join("needle"), b"").unwrap(); + roots.push(root); + } + let baseline = process_memory(0); + let mut panel = SidePanel::new(); + panel.toggle(PanelView::Files); + let mut samples = Vec::new(); + + for cycle in 0..30 { + for (root_number, root) in roots.iter().enumerate() { + let started = Instant::now(); + panel.set_custom_root(root.clone()); + panel.wait_snapshot(); + panel.set_file_search_query("needle".to_owned()); + let expected = [16_001, 6_001, 2_001, 3_201][root_number]; + answer(&mut panel, expected); + let allocation = panel.file_index.allocation_snapshot(); + assert!(allocation.array_bytes + allocation.string_bytes <= memory::CACHE_LIMIT); + let scans = panel.file_index.scans(); + let address = allocation.array_address; + let elapsed = started.elapsed().as_millis(); + for repetition in 0..3 { + panel.set_file_search_query(String::new()); + assert!(panel.search_memory.is_none()); + panel.set_file_search_query("needle".to_owned()); + answer(&mut panel, expected); + panel.toggle(PanelView::Files); + assert_eq!(panel.rows.capacity(), 0); + assert!(panel.search_memory.is_none()); + panel.toggle(PanelView::Files); + answer(&mut panel, expected); + assert_eq!( + panel.file_index.allocation_snapshot().array_address, + address, + "cache reallocated during reuse" + ); + if root_number == 2 { + assert_eq!( + panel.file_index.scans(), + scans, + "small directory rebuilt during reuse" + ); + } + let allocation = panel.file_index.allocation_snapshot(); + samples.push(serde_json::json!({ + "cycle":cycle,"root":root_number,"repetition":repetition, + "first_search_ms":elapsed,"cache":allocation, + "memory":process_memory(allocation.array_address),"scans":panel.file_index.scans(), + "tree_bytes":row_bytes(&panel.tree_rows),"result_bytes":row_bytes(&panel.rows), + })); + } + } + println!("FILE_SEARCH_MEMORY_CYCLE {cycle}"); + } + panel.toggle(PanelView::Files); + panel.file_index.release_for_test(); + assert_eq!(panel.file_index.allocation_snapshot().array_bytes, 0); + assert_eq!(panel.file_index.allocation_snapshot().string_bytes, 0); + let final_memory = process_memory(0); + let evidence = serde_json::json!({"pid":std::process::id(),"baseline":baseline,"samples":samples,"after_release":final_memory}); + println!("FILE_SEARCH_MEMORY_STRESS {}", evidence); + // Compare the same root after allocator warmup. Residency can be trimmed by + // the OS, so the Windows stability check uses private commit, not working set. + if let (Some(first), Some(last)) = ( + samples + .iter() + .find(|row| row["cycle"] == 25 && row["root"] == 0) + .and_then(|row| row["memory"]["private_commit"].as_u64()), + samples + .iter() + .rev() + .find(|row| row["root"] == 0) + .and_then(|row| row["memory"]["private_commit"].as_u64()), + ) { + assert!(last <= first + 2 * 1024 * 1024, "private commit kept growing: {first} -> {last}"); + let baseline = evidence["baseline"]["private_commit"].as_u64().unwrap(); + for row in &samples { + let used = row["memory"]["private_commit"].as_u64().unwrap(); + assert!( + used <= baseline + 15 * 1024 * 1024, + "sustained browsing/search overhead exceeded 15 MiB: {baseline} -> {used}" + ); + } + } +} diff --git a/nebula_app/src/display/side_panel/search/tests.rs b/nebula_app/src/display/side_panel/search/tests.rs new file mode 100644 index 0000000..a127c3e --- /dev/null +++ b/nebula_app/src/display/side_panel/search/tests.rs @@ -0,0 +1,360 @@ +use super::*; + +fn wait_for_result( + index: &EmbeddedFileIndex, + matches: impl Fn(&FileSearchResult) -> bool, +) -> FileSearchResult { + let deadline = Instant::now() + Duration::from_secs(15); + while Instant::now() < deadline { + if let Some(result) = index.take_result() + && matches(&result) + && result.complete + { + return result; + } + std::thread::sleep(Duration::from_millis(10)); + } + panic!("file search did not publish the expected result"); +} + +fn start(root: &Path, query: &str) -> EmbeddedFileIndex { + let index = EmbeddedFileIndex::new(); + index.rebuild( + Some(FileIndexRoot::Local(root.to_owned())), + 1, + Some((1, query.to_owned(), FileSearchOptions::default())), + ); + index +} + +fn scan(root: &Path) -> Vec { + let mut entries = Vec::new(); + let result = walk::local(root, || false, |_| {}, |entry| entries.push(entry)); + assert!(result.error.is_none()); + assert!(!result.limited); + entries +} + +#[test] +fn local_index_crawls_nested_paths_but_hides_git_objects() { + let temp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(temp.path().join("src/nested")).unwrap(); + std::fs::create_dir_all(temp.path().join(".git/objects")).unwrap(); + std::fs::write(temp.path().join("src/nested/needle.rs"), b"").unwrap(); + std::fs::write(temp.path().join(".git/objects/hidden"), b"").unwrap(); + let entries = scan(temp.path()); + assert!(entries.iter().any(|entry| entry.key == "src/nested/needle.rs")); + assert!(!entries.iter().any(|entry| entry.key.contains(".git"))); +} + +#[test] +fn watched_index_tracks_create_rename_delete_without_changing_root() { + let temp = tempfile::tempdir().unwrap(); + let index = start(temp.path(), "needle"); + wait_for_result(&index, |result| result.total == 0); + let created = temp.path().join("needle.txt"); + std::fs::write(&created, b"").unwrap(); + wait_for_result(&index, |result| result.total == 1); + let renamed = temp.path().join("needle-renamed.txt"); + std::fs::rename(&created, &renamed).unwrap(); + wait_for_result(&index, |result| result.total == 1 && result.rows[0].path == renamed); + std::fs::remove_file(&renamed).unwrap(); + let result = wait_for_result(&index, |result| result.total == 0); + assert_eq!(result.epoch, 1); + index.release_for_test(); +} + +#[test] +fn large_change_batches_preserve_unrelated_index_entries() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write(temp.path().join("needle-untouched.txt"), b"").unwrap(); + let index = start(temp.path(), "needle"); + wait_for_result(&index, |result| result.total == 1); + for number in 0..128 { + std::fs::write(temp.path().join(format!("needle-changed-{number}.txt")), b"").unwrap(); + } + let result = wait_for_result(&index, |result| result.total == 129); + assert!(result.rows.iter().any(|row| row.name == "needle-untouched.txt")); + index.release_for_test(); +} + +#[test] +fn refreshed_index_deduplicates_overlapping_paths_and_hides_git_events() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("src/nested/needle.rs"); + let hidden = temp.path().join(".git/objects/needle"); + std::fs::create_dir_all(source.parent().unwrap()).unwrap(); + std::fs::create_dir_all(hidden.parent().unwrap()).unwrap(); + std::fs::write(&source, b"").unwrap(); + std::fs::write(&hidden, b"").unwrap(); + let index = start(temp.path(), "needle"); + wait_for_result(&index, |result| result.total == 1); + for _ in 0..8 { + index.refresh(1); + } + let result = wait_for_result(&index, |result| result.total == 1); + assert_eq!(result.rows[0].path, source); + index.release_for_test(); +} + +#[test] +fn cancelled_search_cannot_publish_after_newer_query_or_clear() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write(temp.path().join("newest.txt"), b"").unwrap(); + let index = start(temp.path(), "old"); + for generation in 2..100 { + index.query(1, generation, "old".to_owned(), FileSearchOptions::default()); + } + index.query(1, 100, "newest".to_owned(), FileSearchOptions::default()); + let result = wait_for_result(&index, |result| result.generation == 100); + assert_eq!(result.rows.len(), 1); + assert_eq!(index.scans(), 1, "superseded debounce requests must not start walks"); + index.clear_query(); + std::thread::sleep(Duration::from_millis(250)); + assert!(index.take_result().is_none()); + assert_eq!(index.status(), FileIndexStatus::Idle); + index.release_for_test(); +} + +#[test] +fn empty_search_does_no_index_work_and_repeated_queries_reuse_cache() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write(temp.path().join("needle.rs"), b"").unwrap(); + let index = EmbeddedFileIndex::new(); + index.rebuild(Some(FileIndexRoot::Local(temp.path().to_owned())), 1, None); + std::thread::sleep(Duration::from_millis(200)); + assert_eq!(index.scans(), 0); + assert_eq!(index.retained_bytes(), 0); + index.query(1, 1, "needle".to_owned(), FileSearchOptions::default()); + wait_for_result(&index, |result| result.total == 1); + let bytes = index.retained_bytes(); + for generation in 2..8 { + index.clear_query(); + index.query(1, generation, "needle".to_owned(), FileSearchOptions::default()); + wait_for_result(&index, |result| result.generation == generation && result.total == 1); + assert_eq!(index.retained_bytes(), bytes); + } + assert_eq!(index.scans(), 1); + index.release_for_test(); + assert_eq!(index.retained_bytes(), 0); +} + +#[test] +fn root_switch_replaces_owned_cache_and_returns_the_new_roots_paths() { + let first = tempfile::tempdir().unwrap(); + let second = tempfile::tempdir().unwrap(); + std::fs::write(first.path().join("needle-a.txt"), b"").unwrap(); + std::fs::write(second.path().join("needle-b.txt"), b"").unwrap(); + let index = start(first.path(), "needle"); + wait_for_result(&index, |result| result.rows[0].name == "needle-a.txt"); + for epoch in 2..10 { + let root = if epoch % 2 == 0 { second.path() } else { first.path() }; + index.rebuild( + Some(FileIndexRoot::Local(root.to_owned())), + epoch, + Some((epoch, "needle".to_owned(), FileSearchOptions::default())), + ); + let result = wait_for_result(&index, |result| result.epoch == epoch); + assert_eq!(result.rows.len(), 1); + assert!(result.rows[0].path.starts_with(root)); + assert!(index.retained_bytes() < 64 * 1024); + } + index.release_for_test(); +} + +#[test] +fn repeated_cache_prefix_visits_do_not_append_or_reallocate() { + let mut cache = cache::FileCache::new(); + let entries: Vec<_> = (0..100).map(|n| entry(&format!("src/{n}.rs"))).collect(); + for (position, entry) in entries.iter().enumerate() { + cache.record(position, entry); + } + let bytes = cache.memory.bytes(); + let address = cache.entries.as_ptr(); + for _ in 0..100 { + for (position, entry) in entries.iter().enumerate() { + cache.record(position, entry); + } + } + assert_eq!(cache.memory.bytes(), bytes); + assert_eq!(cache.entries.as_ptr(), address); + assert_eq!(cache.entries.len(), entries.len()); +} + +#[test] +fn cache_limit_counts_strings_and_array_capacity_not_only_record_count() { + let mut cache = cache::FileCache::new(); + for position in 0..50_000 { + cache.record(position, &entry(&format!("{}/{position}.rs", "long-path/".repeat(64)))); + } + assert!(cache.full); + assert!(cache.entries.len() < 50_000); + let actual = cache.entries.capacity() * std::mem::size_of::() + + cache.entries.iter().map(IndexedPath::heap_bytes).sum::(); + assert!(actual <= cache.memory.bytes()); + assert!(cache.memory.bytes() <= memory::CACHE_LIMIT); +} + +#[test] +fn streamed_ranking_keeps_a_late_exact_match_and_bounds_matching_storage() { + let request = SearchRequest { + epoch: 1, + generation: 1, + query: "needle".to_owned(), + options: FileSearchOptions::default(), + }; + let mut best = BestMatches::new(&request); + for n in 0..20_000 { + best.add(entry(&format!("src/needle-{n:06}.rs"))); + } + best.add(entry("deep/needle")); + let (result, _) = best.result(&request); + assert_eq!(result.total, 20_001); + assert_eq!(result.rows.len(), MAX_SEARCH_RESULTS); + assert_eq!(result.rows[0].name, "needle"); + assert!(best.limited); + assert!(result.memory.bytes() <= memory::RESULT_LIMIT); +} + +#[test] +fn unreadable_root_is_reported_instead_of_successful_empty_results() { + let temp = tempfile::tempdir().unwrap(); + let missing = temp.path().join("gone"); + let index = start(&missing, "needle"); + let result = wait_for_result(&index, |result| result.error.is_some()); + assert!(result.rows.is_empty()); + index.release_for_test(); +} + +#[test] +fn walk_obeys_cancellation_before_reading_descendants() { + let temp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(temp.path().join("nested/deep")).unwrap(); + let mut calls = 0; + let result = walk::local(temp.path(), || true, |_| {}, |_| calls += 1); + assert_eq!(calls, 0); + assert_eq!(result.visited, 0); +} + +fn entry(key: &str) -> IndexedPath { + let name = key.rsplit('/').next().unwrap_or(key).to_owned(); + IndexedPath { + path: PathBuf::from(key), + guest_path: None, + name_folded: name.to_lowercase(), + key_folded: key.to_lowercase(), + name, + key: key.to_owned(), + is_dir: false, + } +} + +#[test] +fn plain_search_is_case_insensitive_and_ands_terms() { + let entries = [entry("src/FileTree/SearchIndex.rs"), entry("docs/search.md")]; + let request = SearchRequest { + epoch: 1, + generation: 1, + query: "TREE index".to_owned(), + options: FileSearchOptions::default(), + }; + let result = search_entries(&entries, &request); + assert_eq!(result.total, 1); + assert_eq!(result.rows[0].name, "SearchIndex.rs"); +} + +#[test] +fn case_whole_word_and_regex_options_share_one_matcher() { + let entries = [entry("src/Foo.rs"), entry("src/foo_bar.rs"), entry("src/food.rs")]; + let whole_word = SearchRequest { + epoch: 1, + generation: 1, + query: "foo".to_owned(), + options: FileSearchOptions { whole_word: true, ..Default::default() }, + }; + assert_eq!(search_entries(&entries, &whole_word).total, 1); + + let case_sensitive = SearchRequest { + epoch: 1, + generation: 2, + query: "Foo".to_owned(), + options: FileSearchOptions { match_case: true, ..Default::default() }, + }; + assert_eq!(search_entries(&entries, &case_sensitive).total, 1); + + let regex = SearchRequest { + epoch: 1, + generation: 3, + query: r"foo(?:d|_bar)".to_owned(), + options: FileSearchOptions { regex: true, ..Default::default() }, + }; + assert_eq!(search_entries(&entries, ®ex).total, 2); +} + +#[test] +fn invalid_regex_is_reported_without_stale_rows() { + let result = search_entries( + &[entry("src/main.rs")], + &SearchRequest { + epoch: 1, + generation: 7, + query: "[".to_owned(), + options: FileSearchOptions { regex: true, ..Default::default() }, + }, + ); + assert_eq!(result.generation, 7); + assert!(result.rows.is_empty()); + assert!(result.error.is_some()); +} + +#[test] +fn nested_watch_invalidates_a_warm_cache() { + let temp = tempfile::tempdir().unwrap(); + let nested = temp.path().join("nested"); + std::fs::create_dir(&nested).unwrap(); + let index = start(temp.path(), "needle"); + wait_for_result(&index, |result| result.total == 0); + std::fs::write(nested.join("needle.txt"), b"").unwrap(); + wait_for_result(&index, |result| result.total == 1); + index.release_for_test(); +} + +#[test] +fn published_rows_keep_their_memory_lease_after_worker_clear() { + let temp = tempfile::tempdir().unwrap(); + for number in 0..100 { + std::fs::write(temp.path().join(format!("needle-{number}.txt")), b"").unwrap(); + } + let index = start(temp.path(), "needle"); + let result = wait_for_result(&index, |result| result.total == 100); + let actual = result.rows.capacity() * std::mem::size_of::() + + result + .rows + .iter() + .map(|row| { + row.path.capacity() + + row.name.capacity() + + row.guest_path.as_ref().map_or(0, String::capacity) + }) + .sum::(); + assert!(actual <= result.memory.bytes()); + index.release_for_test(); + assert_eq!(index.retained_bytes(), 0); + assert!(result.memory.bytes() >= actual, "view-owned rows still consume their quota"); +} + +#[test] +fn shorter_fresh_walk_drops_stale_cached_tail() { + let root = Path::new("fixture"); + let mut cache = cache::FileCache::new(); + for position in 0..3 { + let entry = indexed_local_path(root, &root.join(format!("{position}.txt")), false).unwrap(); + cache.record(position, &entry); + } + let before = cache.memory.bytes(); + let address = cache.entries.as_ptr(); + cache.finish_prefix(1); + assert_eq!(cache.entries.len(), 1); + assert!(cache.memory.bytes() < before); + assert_eq!(cache.entries.as_ptr(), address); +} diff --git a/nebula_app/src/display/side_panel/search/walk.rs b/nebula_app/src/display/side_panel/search/walk.rs new file mode 100644 index 0000000..c29e7db --- /dev/null +++ b/nebula_app/src/display/side_panel/search/walk.rs @@ -0,0 +1,301 @@ +//! Streaming filename enumeration. Only one application search walks at a time. + +use super::*; +use std::io::{BufRead, BufReader, Read}; +use std::process::{Command, Stdio}; +use std::sync::mpsc; + +pub(super) const VISIT_LIMIT: usize = 500_000; +const DEPTH_LIMIT: usize = 64; + +#[derive(Default)] +pub(super) struct WalkOutcome { + pub visited: usize, + pub limited: bool, + pub error: Option, +} + +pub(super) fn local( + root: &Path, + cancelled: impl Fn() -> bool, + mut directory: impl FnMut(&Path), + mut visit: impl FnMut(IndexedPath), +) -> WalkOutcome { + let mut outcome = WalkOutcome::default(); + directory(root); + let read = match std::fs::read_dir(root) { + Ok(read) => read, + Err(error) => { + outcome.error = Some(error.to_string()); + return outcome; + }, + }; + // Keep directory iterators, not every pending descendant path. A wide + // node_modules or build tree therefore cannot fill an unbounded BFS queue. + let mut stack = vec![read]; + while let Some(read) = stack.last_mut() { + if cancelled() { + break; + } + let Some(next) = read.next() else { + stack.pop(); + continue; + }; + let child = match next { + Ok(child) => child, + Err(_) => { + outcome.limited = true; + continue; + }, + }; + if outcome.visited >= VISIT_LIMIT { + outcome.limited = true; + break; + } + let Ok(kind) = child.file_type() else { + outcome.limited = true; + continue; + }; + if kind.is_symlink() || child.file_name() == ".git" { + continue; + } + let path = child.path(); + if path.as_os_str().as_encoded_bytes().len() > memory::PATH_LIMIT { + outcome.limited = true; + continue; + } + if let Some(entry) = indexed_local_path(root, &path, kind.is_dir()) { + outcome.visited += 1; + visit(entry); + } + if kind.is_dir() { + if stack.len() >= DEPTH_LIMIT { + outcome.limited = true; + continue; + } + directory(&path); + match std::fs::read_dir(path) { + Ok(read) => stack.push(read), + Err(_) => outcome.limited = true, + } + } + } + outcome +} + +fn read_field(reader: &mut impl BufRead) -> std::io::Result>> { + let mut field = Vec::new(); + let count = reader.take(memory::PATH_LIMIT as u64 + 1).read_until(0, &mut field)?; + if count == 0 { + return Ok(None); + } + if field.last() != Some(&0) || count > memory::PATH_LIMIT { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "File search received an incomplete or oversized path", + )); + } + field.pop(); + Ok(Some(field)) +} + +pub(super) fn wsl( + located: &crate::shell_detect::WslCwd, + cancelled: impl Fn() -> bool, + mut visit: impl FnMut(IndexedPath), +) -> WalkOutcome { + let mut outcome = WalkOutcome::default(); + let root = normalize_wsl_guest_path(&located.guest); + let mut command = Command::new("wsl.exe"); + // `--` still goes through the distro's default shell, which interprets + // find's parentheses and path metacharacters. Direct exec also preserves + // the single backslash required by GNU find's NUL format. + command.args(["-d", &located.distro, "--exec", "find", &root]); + command.args([ + "-mindepth", + "1", + "-maxdepth", + "64", + "(", + "-type", + "d", + "-name", + ".git", + "-prune", + ")", + "-o", + "(", + "!", + "-type", + "l", + "-printf", + r"%y\0%p\0", + ")", + ]); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + command.creation_flags(0x0800_0000); + } + command.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()); + let mut child = match command.spawn() { + Ok(child) => child, + Err(error) => { + outcome.error = Some(error.to_string()); + return outcome; + }, + }; + let stdout = child.stdout.take().expect("piped search stdout"); + let stderr = child.stderr.take().expect("piped search stderr"); + let (sender, receiver) = mpsc::sync_channel::)>>(4); + let reader = std::thread::spawn(move || { + let mut reader = BufReader::new(stdout); + loop { + let pair = (|| { + let Some(kind) = read_field(&mut reader)? else { return Ok(None) }; + let path = read_field(&mut reader)?.ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "Incomplete find record") + })?; + Ok(Some((kind.first().copied().unwrap_or_default(), path))) + })(); + match pair { + Ok(None) => break, + Ok(Some(pair)) => { + if sender.send(Ok(pair)).is_err() { + break; + } + }, + Err(error) => { + let _ = sender.send(Err(error)); + break; + }, + } + } + }); + // Retain a small diagnostic prefix, then drain the rest without buffering. + let errors = std::thread::spawn(move || { + let mut stderr = stderr; + let mut buffer = [0; 4096]; + let mut diagnostic = Vec::new(); + while let Ok(count) = stderr.read(&mut buffer) { + if count == 0 { + break; + } + let keep = count.min(2048usize.saturating_sub(diagnostic.len())); + diagnostic.extend_from_slice(&buffer[..keep]); + } + String::from_utf8_lossy(&diagnostic).trim().to_owned() + }); + let deadline = Instant::now() + WSL_COMMAND_TIMEOUT; + let mut abort = false; + loop { + if cancelled() || outcome.visited >= VISIT_LIMIT || Instant::now() >= deadline { + outcome.limited = !cancelled(); + abort = true; + break; + } + match receiver.recv_timeout(Duration::from_millis(20)) { + Ok(Ok((kind, bytes))) => { + let guest_path = String::from_utf8_lossy(&bytes).into_owned(); + let Some(relative) = guest_path.strip_prefix(&root) else { continue }; + if root != "/" && !relative.starts_with('/') { + continue; + } + let key = relative.trim_start_matches('/').to_owned(); + if kind == b'd' && key.split('/').count() >= DEPTH_LIMIT { + outcome.limited = true; + } + let name = key.rsplit('/').next().unwrap_or(&key).to_owned(); + let entry = IndexedPath { + path: PathBuf::from(&guest_path), + guest_path: Some(guest_path), + name_folded: name.to_lowercase(), + key_folded: key.to_lowercase(), + name, + key, + is_dir: kind == b'd', + }; + outcome.visited += 1; + visit(entry); + }, + Ok(Err(error)) => { + outcome.error = Some(error.to_string()); + abort = true; + break; + }, + Err(mpsc::RecvTimeoutError::Timeout) => {}, + Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + } + if abort { + let _ = child.kill(); + } + drop(receiver); + let status = child.wait(); + let _ = reader.join(); + let diagnostic = errors.join().unwrap_or_default(); + if !abort && !matches!(status, Ok(status) if status.success()) { + if outcome.visited == 0 { + outcome.error = Some(if diagnostic.is_empty() { + "Unable to read the WSL search directory".to_owned() + } else { + diagnostic + }); + } else { + outcome.limited = true; + } + } + outcome +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn nul_reader_bounds_long_or_incomplete_paths_without_losing_newlines() { + let mut valid = std::io::Cursor::new(b"name\nwith newline\0"); + assert_eq!(read_field(&mut valid).unwrap(), Some(b"name\nwith newline".to_vec())); + assert_eq!(read_field(&mut valid).unwrap(), None); + let mut incomplete = std::io::Cursor::new(b"incomplete"); + assert!(read_field(&mut incomplete).is_err()); + let mut huge = std::io::Cursor::new(vec![b'x'; memory::PATH_LIMIT * 2]); + assert!(read_field(&mut huge).is_err()); + assert_eq!(huge.position(), memory::PATH_LIMIT as u64 + 1); + } +} + +#[cfg(windows)] +#[test] +#[ignore = "Requires Debian WSL; searches only an owned temporary directory"] +fn wsl_search_streams_and_cancels_its_owned_find() { + let owner = tempfile::tempdir().unwrap(); + let fixture = owner.path().join("中文 space & (literal)"); + std::fs::create_dir(&fixture).unwrap(); + for number in 0..256 { + std::fs::write(fixture.join(format!("needle-中文-{number}.txt")), b"").unwrap(); + } + let windows = fixture.to_string_lossy().replace('\\', "/"); + let (drive, rest) = windows.split_once(":/").expect("Windows drive-backed fixture"); + let located = crate::shell_detect::WslCwd { + distro: "Debian".to_owned(), + guest: format!("/mnt/{}/{}", drive.to_ascii_lowercase(), rest), + }; + let mut count = 0; + let complete = wsl( + &located, + || false, + |entry| { + assert!(entry.name.starts_with("needle-中文-")); + assert!(entry.guest_path.as_ref().unwrap().starts_with(&located.guest)); + count += 1; + }, + ); + assert!(complete.error.is_none(), "{:?}", complete.error); + assert!(!complete.limited); + assert_eq!(count, 256); + let visits = std::cell::Cell::new(0); + let cancelled = wsl(&located, || visits.get() > 0, |_| visits.set(visits.get() + 1)); + assert_eq!(cancelled.visited, 1); + assert_eq!(visits.get(), 1, "cancellation must stop enumeration before the next record"); +} diff --git a/nebula_app/src/display/side_panel/search/worker.rs b/nebula_app/src/display/side_panel/search/worker.rs new file mode 100644 index 0000000..9f0f61f --- /dev/null +++ b/nebula_app/src/display/side_panel/search/worker.rs @@ -0,0 +1,358 @@ +//! Search ownership, latest-request mailbox, cancellation and warm-cache reuse. + +use super::cache::FileCache; +use super::*; + +// Only one CPU/I/O search runs at a time; views never wait for this lock. +// Queued windows retain one latest request, not one task per keystroke. +static SEARCH_WORK: Mutex<()> = Mutex::new(()); + +#[derive(Clone, Default)] +struct DesiredSearch { + root: Option, + query: Option, + epoch: u64, + refresh: u64, +} + +struct SearchState { + desired: Mutex, + wake: Condvar, + revision: AtomicU64, + processed: AtomicU64, + stopped: AtomicBool, + result: Mutex>, + status: AtomicU8, + indexed_count: AtomicUsize, + truncated: AtomicBool, + #[cfg(test)] + scans: AtomicUsize, + #[cfg(test)] + retained_bytes: AtomicUsize, + #[cfg(test)] + allocations: Mutex, +} + +#[cfg(test)] +#[derive(Clone, Debug, Default, serde::Serialize)] +pub(crate) struct CacheAllocations { + pub records: usize, + pub array_capacity: usize, + pub array_address: usize, + pub array_bytes: usize, + pub string_bytes: usize, +} + +#[cfg(test)] +fn record_allocations(state: &SearchState, cache: &FileCache) { + state.retained_bytes.store(cache.memory.bytes(), Ordering::Release); + *state.allocations.lock().unwrap() = CacheAllocations { + records: cache.entries.len(), + array_capacity: cache.entries.capacity(), + array_address: cache.entries.as_ptr() as usize, + array_bytes: cache.entries.capacity() * std::mem::size_of::(), + string_bytes: cache.entries.iter().map(IndexedPath::heap_bytes).sum(), + }; +} + +pub(crate) struct EmbeddedFileIndex { + state: Arc, +} + +impl EmbeddedFileIndex { + pub(crate) fn new() -> Self { + let state = Arc::new(SearchState { + desired: Mutex::new(DesiredSearch::default()), + wake: Condvar::new(), + revision: AtomicU64::new(1), + processed: AtomicU64::new(0), + stopped: AtomicBool::new(false), + result: Mutex::new(None), + status: AtomicU8::new(INDEX_IDLE), + indexed_count: AtomicUsize::new(0), + truncated: AtomicBool::new(false), + #[cfg(test)] + scans: AtomicUsize::new(0), + #[cfg(test)] + retained_bytes: AtomicUsize::new(0), + #[cfg(test)] + allocations: Mutex::new(CacheAllocations::default()), + }); + let worker = state.clone(); + let _ = std::thread::Builder::new() + .name("pebrel file search".to_owned()) + .spawn(move || run_search_worker(worker)); + Self { state } + } + + fn update(&self, edit: impl FnOnce(&mut DesiredSearch)) -> u64 { + let mut desired = self.state.desired.lock().unwrap_or_else(|e| e.into_inner()); + edit(&mut desired); + *self.state.result.lock().unwrap_or_else(|e| e.into_inner()) = None; + self.state.status.store( + if desired.query.is_some() { INDEX_BUILDING } else { INDEX_IDLE }, + Ordering::Release, + ); + self.state.truncated.store(false, Ordering::Release); + let revision = self.state.revision.fetch_add(1, Ordering::AcqRel) + 1; + self.state.wake.notify_all(); + revision + } + + pub(crate) fn rebuild( + &self, + root: Option, + epoch: u64, + query: Option<(u64, String, FileSearchOptions)>, + ) { + self.update(|desired| { + desired.root = root; + desired.epoch = epoch; + desired.query = query.map(|(generation, query, options)| SearchRequest { + epoch, + generation, + query, + options, + }); + }); + } + + pub(crate) fn query( + &self, + epoch: u64, + generation: u64, + query: String, + options: FileSearchOptions, + ) { + self.update(|desired| { + desired.query = Some(SearchRequest { epoch, generation, query, options }); + }); + } + + pub(crate) fn clear_query(&self) { + self.update(|desired| desired.query = None); + } + + pub(crate) fn refresh(&self, epoch: u64) { + self.update(|desired| { + if desired.epoch == epoch { + desired.refresh = desired.refresh.wrapping_add(1); + } + }); + } + + pub(crate) fn take_result(&self) -> Option { + self.state.result.lock().ok()?.take() + } + + pub(crate) fn status(&self) -> FileIndexStatus { + match self.state.status.load(Ordering::Acquire) { + INDEX_BUILDING => FileIndexStatus::Building, + INDEX_READY => FileIndexStatus::Ready, + _ => FileIndexStatus::Idle, + } + } + + pub(crate) fn indexed_count(&self) -> usize { + self.state.indexed_count.load(Ordering::Acquire) + } + + pub(crate) fn truncated(&self) -> bool { + self.state.truncated.load(Ordering::Acquire) + } + + #[cfg(test)] + pub(crate) fn scans(&self) -> usize { + self.state.scans.load(Ordering::Acquire) + } + + #[cfg(test)] + pub(crate) fn retained_bytes(&self) -> usize { + self.state.retained_bytes.load(Ordering::Acquire) + } + + #[cfg(test)] + pub(crate) fn allocation_snapshot(&self) -> CacheAllocations { + self.state.allocations.lock().unwrap().clone() + } + + #[cfg(test)] + pub(crate) fn release_for_test(&self) { + let revision = self.update(|desired| *desired = DesiredSearch::default()); + let deadline = Instant::now() + Duration::from_secs(5); + while self.state.processed.load(Ordering::Acquire) < revision { + assert!(Instant::now() < deadline, "search worker did not release its resources"); + std::thread::sleep(Duration::from_millis(10)); + } + } +} + +impl Drop for EmbeddedFileIndex { + fn drop(&mut self) { + self.state.stopped.store(true, Ordering::Release); + self.state.revision.fetch_add(1, Ordering::AcqRel); + self.state.wake.notify_all(); + } +} + +fn wait_for_work(state: &SearchState, duration: Duration) { + let guard = state.desired.lock().unwrap_or_else(|e| e.into_inner()); + let _ = state.wake.wait_timeout(guard, duration); +} + +fn run_search_worker(state: Arc) { + let mut cache = FileCache::new(); + let mut root = None; + let mut epoch = 0; + let mut refresh = 0; + loop { + if state.stopped.load(Ordering::Acquire) { + return; + } + let (desired, revision) = { + let desired = state.desired.lock().unwrap_or_else(|e| e.into_inner()); + (desired.clone(), state.revision.load(Ordering::Acquire)) + }; + let changed = root != desired.root || epoch != desired.epoch || refresh != desired.refresh; + let dirty = cache.watches.dirty.swap(false, Ordering::AcqRel); + if changed || dirty { + cache = FileCache::new(); + root = desired.root.clone(); + epoch = desired.epoch; + refresh = desired.refresh; + } else if revision == state.processed.load(Ordering::Acquire) { + wait_for_work(&state, WATCH_DEBOUNCE); + continue; + } + let cancelled = || { + state.stopped.load(Ordering::Acquire) + || state.revision.load(Ordering::Acquire) != revision + }; + let Some(request) = desired.query.as_ref().filter(|query| query.epoch == epoch) else { + state.indexed_count.store(cache.entries.len(), Ordering::Release); + state.status.store(INDEX_IDLE, Ordering::Release); + #[cfg(test)] + record_allocations(&state, &cache); + state.processed.store(revision, Ordering::Release); + state.wake.notify_all(); + continue; + }; + wait_for_work(&state, SEARCH_DEBOUNCE); + if cancelled() { + continue; + } + let _work = match SEARCH_WORK.try_lock() { + Ok(work) => work, + Err(std::sync::TryLockError::Poisoned(error)) => error.into_inner(), + Err(std::sync::TryLockError::WouldBlock) => { + wait_for_work(&state, WATCH_DEBOUNCE); + continue; + }, + }; + let matcher = match QueryMatcher::compile(&request.query, request.options) { + Ok(matcher) => matcher, + Err(error) => { + publish( + &state, + revision, + request, + &BestMatches::new(request), + Some(error), + false, + true, + ); + continue; + }, + }; + let mut best = BestMatches::new(request); + for entry in &cache.entries { + if cancelled() { + break; + } + if matcher.matches(entry) { + best.add(entry.clone()); + } + } + let reusable = cache.complete + && (!cache.watches.unwatched || cache.finished.elapsed() < Duration::from_secs(2)); + if reusable { + publish(&state, revision, request, &best, None, false, true); + continue; + } + if !cache.entries.is_empty() { + publish(&state, revision, request, &best, None, true, false); + } + best = BestMatches::new(request); + let mut position = 0; + let mut last_publish = Instant::now(); + // Install each non-recursive watch before enumerating that directory. + // Keep it independent from entry storage so both traversal callbacks + // can mutate their owner without delaying watches until after the scan. + let mut watches = std::mem::replace(&mut cache.watches, cache::DirectoryWatches::new()); + #[cfg(test)] + state.scans.fetch_add(1, Ordering::AcqRel); + let mut visit = |entry: IndexedPath| { + cache.record(position, &entry); + position += 1; + if matcher.matches(&entry) { + best.add(entry); + } + if last_publish.elapsed() >= Duration::from_millis(100) { + state.indexed_count.store(position, Ordering::Release); + publish(&state, revision, request, &best, None, true, false); + last_publish = Instant::now(); + } + }; + let outcome = match root.as_ref() { + Some(FileIndexRoot::Local(root)) => walk::local( + root, + cancelled, + |directory| { + watches.watch(directory); + }, + &mut visit, + ), + Some(FileIndexRoot::Wsl(located)) => walk::wsl(located, cancelled, &mut visit), + None => walk::WalkOutcome::default(), + }; + if matches!(root, Some(FileIndexRoot::Wsl(_))) { + watches.unwatched = true; + } + cache.watches = watches; + if !cancelled() && !outcome.limited && outcome.error.is_none() { + cache.finish_prefix(position); + } + cache.complete = !cancelled() && !outcome.limited && outcome.error.is_none() && !cache.full; + cache.finished = Instant::now(); + state.indexed_count.store(outcome.visited, Ordering::Release); + #[cfg(test)] + record_allocations(&state, &cache); + publish(&state, revision, request, &best, outcome.error, outcome.limited, true); + } +} + +fn publish( + state: &SearchState, + revision: u64, + request: &SearchRequest, + best: &BestMatches, + error: Option, + limited: bool, + complete: bool, +) { + let (mut result, result_limited) = best.result(request); + result.error = error; + result.complete = complete; + let desired = state.desired.lock().unwrap_or_else(|e| e.into_inner()); + if state.revision.load(Ordering::Acquire) != revision || state.stopped.load(Ordering::Acquire) { + return; + } + *state.result.lock().unwrap_or_else(|e| e.into_inner()) = Some(result); + state.truncated.store(limited || result_limited || best.limited, Ordering::Release); + state.status.store(if complete { INDEX_READY } else { INDEX_BUILDING }, Ordering::Release); + if complete { + state.processed.store(revision, Ordering::Release); + } + drop(desired); + state.wake.notify_all(); +} diff --git a/nebula_app/src/display/side_panel/tests.rs b/nebula_app/src/display/side_panel/tests.rs index d6fcc3e..be53af9 100644 --- a/nebula_app/src/display/side_panel/tests.rs +++ b/nebula_app/src/display/side_panel/tests.rs @@ -1003,3 +1003,46 @@ fn git_history_parser_keeps_commit_identity_parents_and_fields() { assert_eq!(first.parent_hashes, ["1111111", "2222222"]); assert_eq!(commits[1].subject, "side work"); } + +#[test] +fn repeated_panel_close_and_reopen_reuses_cache_and_releases_visible_results() { + let fixture = tempfile::tempdir().unwrap(); + std::fs::write(fixture.path().join("needle.txt"), b"").unwrap(); + let mut panel = SidePanel::new(); + panel.toggle(PanelView::Files); + panel.sync(Some(fixture.path().to_owned())); + panel.wait_snapshot(); + panel.set_file_search_query("needle".to_owned()); + for _ in 0..12 { + let deadline = Instant::now() + Duration::from_secs(10); + while panel.file_search_pending() || panel.rows.is_empty() { + panel.harvest_file_search(); + assert!(Instant::now() < deadline); + std::thread::sleep(Duration::from_millis(10)); + } + assert_eq!(panel.rows[0].name, "needle.txt"); + assert_eq!(panel.file_index.scans(), 1); + assert!(panel.search_memory.is_some()); + panel.toggle(PanelView::Files); + assert_eq!(panel.rows.capacity(), 0); + assert!(panel.search_memory.is_none()); + assert!(panel.file_index.take_result().is_none()); + panel.toggle(PanelView::Files); + } + panel.toggle(PanelView::Files); + panel.file_index.release_for_test(); +} + +#[test] +fn wide_directory_selection_bounds_capacity_and_keeps_late_best_entries() { + let entries = (0..50_000).rev().map(|number| { + let name = format!("item-{number:05}-{}", "a".repeat(80)); + (false, name.clone(), PathBuf::from(name)) + }); + let kept = SidePanel::ordered_entries(entries, MAX_PER_DIR); + assert_eq!(kept.len(), MAX_PER_DIR); + assert!(kept.first().unwrap().1.starts_with("item-00000-")); + let bytes = kept.capacity() * std::mem::size_of::<(bool, String, PathBuf)>() + + kept.iter().map(|row| row.1.capacity() + row.2.capacity()).sum::(); + assert!(bytes <= 512 * 1024); +} diff --git a/nebula_app/src/gpui_shell/config.rs b/nebula_app/src/gpui_shell/config.rs index 3f98075..02ee97d 100644 --- a/nebula_app/src/gpui_shell/config.rs +++ b/nebula_app/src/gpui_shell/config.rs @@ -59,6 +59,9 @@ pub struct Settings { pub cursor_blink: Option, /// 选区完成即复制(旧壳 `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, /// 标签关闭按钮与标签插入动画都在渲染热路径读取,必须随全局设置驻留内存。 @@ -176,6 +179,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, @@ -431,6 +438,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)] diff --git a/nebula_app/src/gpui_shell/mod.rs b/nebula_app/src/gpui_shell/mod.rs index 69a55c6..8423216 100644 --- a/nebula_app/src/gpui_shell/mod.rs +++ b/nebula_app/src/gpui_shell/mod.rs @@ -75,7 +75,10 @@ pub(crate) enum GpuiShellEvent { /// /// GPUI 拥有自己的消息循环:主窗形态从主线程调用(winit 不启动); /// spike 形态从专用线程调用。一个进程内只允许调用一次。 -pub fn run_shell(initial_cwd: Option) { +pub fn run_shell( + initial_cwd: Option, + initial_command: Option, +) { 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) { }, 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 { @@ -147,7 +153,7 @@ pub fn run_shell(initial_cwd: Option) { crate::ai_hook::spawn_config_guard(); init(cx); cx.activate(true); - open_main_window(cx, ai_events, shell_rx, runtime_hub, initial_cwd); + open_main_window(cx, ai_events, shell_rx, runtime_hub, initial_cwd, initial_command); }); crate::tray::shutdown(); } @@ -220,9 +226,16 @@ fn open_main_window( shell_events: std::sync::mpsc::Receiver, runtime_hub: crate::runtime_api::RuntimeHub, initial_cwd: Option, + initial_command: Option, ) { workspace::windowing::initialize(cx, runtime_hub); - workspace::windowing::open_initial_window(cx, ai_events, shell_events, initial_cwd); + workspace::windowing::open_initial_window( + cx, + ai_events, + shell_events, + initial_cwd, + initial_command, + ); } /// 按 `tray` 设置挂上或摘掉系统托盘图标(旧壳 `tray::set_enabled`)。 diff --git a/nebula_app/src/gpui_shell/settings_pane.rs b/nebula_app/src/gpui_shell/settings_pane.rs index 5d99305..8b1a023 100644 --- a/nebula_app/src/gpui_shell/settings_pane.rs +++ b/nebula_app/src/gpui_shell/settings_pane.rs @@ -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 面板若正打开,需要重建候选快照。 @@ -300,7 +301,7 @@ impl SettingsPane { self.request_cover_chrome(value, window, cx); 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( @@ -308,7 +309,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())], ), ); @@ -659,6 +664,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), @@ -1050,6 +1057,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::() + .is_some_and(|settings| settings.focus_follows_mouse), + cx, + ), + ) .child(self.switch_row( "copy_on_select", language.pick("选中即复制", "Copy on select"), diff --git a/nebula_app/src/gpui_shell/settings_pane/appearance_advanced.rs b/nebula_app/src/gpui_shell/settings_pane/appearance_advanced.rs index 741eb29..5716f7c 100644 --- a/nebula_app/src/gpui_shell/settings_pane/appearance_advanced.rs +++ b/nebula_app/src/gpui_shell/settings_pane/appearance_advanced.rs @@ -61,6 +61,13 @@ impl SettingsPane { )); let interface = self .group(language.pick("界面", "Interface"), 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"), diff --git a/nebula_app/src/gpui_shell/settings_pane/launcher_actions.rs b/nebula_app/src/gpui_shell/settings_pane/launcher_actions.rs new file mode 100644 index 0000000..b5072c8 --- /dev/null +++ b/nebula_app/src/gpui_shell/settings_pane/launcher_actions.rs @@ -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.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.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(); + } +} diff --git a/nebula_app/src/gpui_shell/settings_pane/navigation.rs b/nebula_app/src/gpui_shell/settings_pane/navigation.rs index 7cad2b7..f05556c 100644 --- a/nebula_app/src/gpui_shell/settings_pane/navigation.rs +++ b/nebula_app/src/gpui_shell/settings_pane/navigation.rs @@ -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 恢复", "backup 备份 export 导出 restore 恢复", diff --git a/nebula_app/src/gpui_shell/ssh_settings/library.rs b/nebula_app/src/gpui_shell/ssh_settings/library.rs index 65ea75c..815fda1 100644 --- a/nebula_app/src/gpui_shell/ssh_settings/library.rs +++ b/nebula_app/src/gpui_shell/ssh_settings/library.rs @@ -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.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 { let query = self.ssh_library.search.read(cx).value(); let hosts = if self.ssh_library.scope == HostScope::Recent { diff --git a/nebula_app/src/gpui_shell/terminal/keymap.rs b/nebula_app/src/gpui_shell/terminal/keymap.rs index 655ee79..adbbd25 100644 --- a/nebula_app/src/gpui_shell/terminal/keymap.rs +++ b/nebula_app/src/gpui_shell/terminal/keymap.rs @@ -209,8 +209,24 @@ fn kitty_sequence(ks: &Keystroke, mode: &TermMode) -> Option> { Some(build_sequence(&input, modifiers, *mode)) } -/// 返回 `None` 表示这次按键不由编码器处理(交给 IME/文本输入路径)。 +/// These Windows chords must reach DefWindowProc so the existing close guard +/// and native system menu run. Other modifiers retain their terminal meaning. +pub(super) fn is_native_window_shortcut(ks: &Keystroke) -> bool { + let mods = &ks.modifiers; + cfg!(windows) + && mods.alt + && !mods.control + && !mods.shift + && !mods.platform + && !mods.function + && matches!(ks.key.as_str(), "f4" | "space") +} + +/// 返回 `None` 表示这次按键交给系统窗口处理或 IME/文本输入路径。 pub fn encode(ks: &Keystroke, mode: &TermMode) -> Option> { + if is_native_window_shortcut(ks) { + return None; + } let mods = &ks.modifiers; #[cfg(windows)] @@ -331,6 +347,132 @@ mod tests { Keystroke { modifiers: gpui::Modifiers::default(), key: key.to_owned(), key_char: None } } + #[cfg(windows)] + #[test] + fn native_window_shortcuts_never_enter_a_terminal_protocol() { + for mode in [ + TermMode::empty(), + TermMode::WIN32_INPUT_MODE, + TermMode::DISAMBIGUATE_ESC_CODES, + TermMode::REPORT_ALL_KEYS_AS_ESC, + TermMode::WIN32_INPUT_MODE | TermMode::DISAMBIGUATE_ESC_CODES, + ] { + for screen in [TermMode::empty(), TermMode::ALT_SCREEN] { + for combo in ["alt-f4", "alt-space"] { + let mut key = Keystroke::parse(combo).unwrap(); + for text in [None, Some(" ".to_owned())] { + key.key_char = text; + assert_eq!(encode(&key, &(mode | screen)), None, "{combo}: {mode:?}"); + } + } + } + } + } + + #[test] + fn neighboring_alt_and_function_keys_keep_their_terminal_encoding() { + for (combo, expected) in [ + ("f4", b"\x1bOS".as_slice()), + ("alt-f3", b"\x1b[1;3R".as_slice()), + ("alt-n", b"\x1bn".as_slice()), + ("ctrl-alt-f4", b"\x1b[1;7S".as_slice()), + ("ctrl-alt-space", b"\x1b\0".as_slice()), + ] { + assert_eq!( + encode(&Keystroke::parse(combo).unwrap(), &TermMode::empty()).as_deref(), + Some(expected), + "{combo}", + ); + } + #[cfg(not(windows))] + { + let mut space = Keystroke::parse("alt-space").unwrap(); + space.key_char = Some(" ".to_owned()); + assert_eq!(encode(&space, &TermMode::empty()), Some(b"\x1b ".to_vec())); + assert_eq!( + encode(&Keystroke::parse("alt-f4").unwrap(), &TermMode::empty()), + Some(b"\x1b[1;3S".to_vec()), + ); + } + } + + #[cfg(all(windows, feature = "gpui-test-support"))] + #[gpui::test] + fn native_window_shortcuts_propagate_through_root_and_terminal(cx: &mut gpui::TestAppContext) { + use gpui::{ + AppContext as _, Context, FocusHandle, InteractiveElement as _, IntoElement, + KeyDownEvent, ParentElement as _, Render, Styled as _, Window, div, + }; + use gpui_component::Root; + + struct InputProbe { + focus: FocusHandle, + mode: TermMode, + received: usize, + encoded: Vec, + } + + impl Render for InputProbe { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + 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.received += 1; + if let Some(bytes) = encode(&event.keystroke, &this.mode) { + this.encoded.extend(bytes); + cx.stop_propagation(); + } + })) + } + } + + cx.update(|cx| { + gpui_component::init(cx); + crate::gpui_shell::workspace::init(cx); + }); + let mut probe_out = None; + let (_, cx) = cx.add_window_view(|window, cx| { + let probe = cx.new(|cx| InputProbe { + focus: cx.focus_handle(), + mode: TermMode::empty(), + received: 0, + 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(); + cx.run_until_parked(); + for mode in [ + TermMode::empty(), + TermMode::WIN32_INPUT_MODE, + TermMode::DISAMBIGUATE_ESC_CODES, + TermMode::REPORT_ALL_KEYS_AS_ESC | TermMode::ALT_SCREEN, + ] { + probe.update(cx, |probe, _| probe.mode = mode); + for combo in ["alt-f4", "alt-space"] { + cx.update(|window, cx| { + let before = probe.read(cx).received; + let result = window.dispatch_event( + gpui::PlatformInput::KeyDown(KeyDownEvent { + keystroke: Keystroke::parse(combo).unwrap(), + is_held: false, + prefer_character_input: false, + }), + cx, + ); + assert!(result.propagate, "Windows must receive {combo} in {mode:?}"); + assert!(probe.read(cx).encoded.is_empty()); + assert_eq!(probe.read(cx).received, before + 1); + }); + } + } + } + /// 传统 VT 路径(子进程没要过 DECSET 9001):Esc 就是裸 `\x1b`。 #[test] fn escape_stays_a_bare_byte_on_the_legacy_vt_path() { @@ -663,13 +805,20 @@ mod tests { } #[test] - fn kitty_modified_space_and_ascii_punctuation_use_csi_u() { + fn kitty_modified_space_and_ascii_punctuation_respect_native_shortcuts() { for (name, codepoint) in [("space", 32), ("[", 91), ("/", 47)] { let mut key = keystroke(name); key.modifiers.alt = true; + let expected = if cfg!(windows) && name == "space" { + None + } else { + Some(format!("\x1b[{codepoint};3u").into_bytes()) + }; + assert_eq!(encode(&key, &pi_keyboard_mode()), expected); + key.modifiers.control = true; assert_eq!( encode(&key, &pi_keyboard_mode()), - Some(format!("\x1b[{codepoint};3u").into_bytes()) + Some(format!("\x1b[{codepoint};7u").into_bytes()) ); } } diff --git a/nebula_app/src/gpui_shell/terminal/view.rs b/nebula_app/src/gpui_shell/terminal/view.rs index fa7ea52..48f7c7d 100644 --- a/nebula_app/src/gpui_shell/terminal/view.rs +++ b/nebula_app/src/gpui_shell/terminal/view.rs @@ -1756,10 +1756,10 @@ impl TerminalView { if self.exited.is_some() || self.answer_reader.is_some() { return; } - // 旧壳 `keyboard.rs`:IME 组合中不编码、不拦截。GPUI Windows 在 - // `stop_propagation` 后会跳过 `TranslateMessage`,组合中若把按键 - // 吃掉,候选窗和退格都会坏。 - if self.marked_text.is_some() { + // IME 组合和 Windows 原生窗口快捷键不编码、不拦截。GPUI Windows + // 在 `stop_propagation` 后会跳过 `TranslateMessage` 和 `DispatchMessage`, + // 必须保留输入法组合、窗口关闭和系统菜单的默认处理。 + if self.marked_text.is_some() || keymap::is_native_window_shortcut(&event.keystroke) { return; } let ks = &event.keystroke; diff --git a/nebula_app/src/gpui_shell/terminal/view/pointer.rs b/nebula_app/src/gpui_shell/terminal/view/pointer.rs index 8d165ef..9458367 100644 --- a/nebula_app/src/gpui_shell/terminal/view/pointer.rs +++ b/nebula_app/src/gpui_shell/terminal/view/pointer.rs @@ -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, ) { 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::().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 任一), diff --git a/nebula_app/src/gpui_shell/terminal/view/pointer/tests.rs b/nebula_app/src/gpui_shell/terminal/view/pointer/tests.rs new file mode 100644 index 0000000..afaf667 --- /dev/null +++ b/nebula_app/src/gpui_shell/terminal/view/pointer/tests.rs @@ -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, + input: Entity, + overlay: bool, + requests: Rc>, + _subscription: gpui::Subscription, +} + +impl Render for Probe { + fn render(&mut self, window: &mut Window, cx: &mut gpui::Context) -> 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, 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) { + 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::().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::().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::().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); +} diff --git a/nebula_app/src/gpui_shell/theme.rs b/nebula_app/src/gpui_shell/theme.rs index 4208b98..3e7f9a3 100644 --- a/nebula_app/src/gpui_shell/theme.rs +++ b/nebula_app/src/gpui_shell/theme.rs @@ -499,6 +499,13 @@ pub fn reapply_shell_opacity(name: ThemeName, follow_system: bool, cx: &mut App) apply_shell_opacity(chrome, cx); } +/// Wallpaper readiness changes on the cold load-completion path without a +/// settings event. Resolve the active theme before reapplying its surface alpha. +pub(super) fn reapply_prepared_surface_opacity(cx: &mut App) { + let chrome = chrome_theme(effective_theme_name(cx)); + apply_shell_opacity(chrome, cx); +} + /// 一体化外壳(对齐旧壳 draw_chrome):窗口背景、侧栏、顶栏是同一块 /// 壳色,各自的分隔线取同色隐形;唯一的结构分界是内容区那张圆角卡。 /// 壳色带用户透明度(文字 token 不带——对比度不塌,旧壳裁定)。 diff --git a/nebula_app/src/gpui_shell/wallpaper.rs b/nebula_app/src/gpui_shell/wallpaper.rs index 0a59c6c..77631e5 100644 --- a/nebula_app/src/gpui_shell/wallpaper.rs +++ b/nebula_app/src/gpui_shell/wallpaper.rs @@ -9,134 +9,210 @@ //! fit/alignment/透明度语义;图自身透明度独立于窗口 opacity)。 //! //! 设置来源是共享层 `nebula_settings`(新增壁纸五键),解码结果按 -//! (路径, mtime, 透明度) 缓存;[`refresh`] 在启动与设置热应用时调用。 +//! (路径, 文件状态) 缓存;加载在后台串行执行,绘制只复用一张纹理。 use std::collections::HashSet; use std::path::PathBuf; -use std::sync::{Arc, Mutex}; -use std::time::SystemTime; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; #[cfg(windows)] use std::sync::OnceLock; use gpui::{ - App, Bounds, ContentMask, Corners, Hsla, Pixels, RenderImage, Window, - WindowBackgroundAppearance, fill, point, px, size, + App, Bounds, ContentMask, Corners, Hsla, IntoElement, ParentElement, Pixels, RenderImage, + Styled, Window, WindowBackgroundAppearance, div, fill, point, px, size, }; -use image::{Frame, RgbaImage, imageops}; +use image::{Frame, RgbaImage}; + +mod image_loader; +#[cfg(all(test, feature = "gpui-test-support"))] +mod tests; use nebula_settings::BlurModeName; -use crate::renderer::image::{BackgroundImageAlignment, BackgroundImageFit}; +use crate::renderer::image::{BackgroundImageAlignment, BackgroundImageFit, wallpaper_rect}; -/// 全局视效状态(App global)。 +/// App-owned wallpaper loading uses the existing GPUI executor: one job and one +/// latest request, with generation checks before expensive work and publication. pub struct VisualEffects { pub opacity: f32, pub blur: BlurModeName, wallpaper: Option, + generation: Arc, + loading: bool, } impl gpui::Global for VisualEffects {} +impl Drop for VisualEffects { + fn drop(&mut self) { + self.generation.fetch_add(1, Ordering::Release); + } +} + struct Wallpaper { - image: Arc, - /// 已烘焙壁纸透明度、并转换成 GPUI 所需 BGRA 顺序的原始像素。 - /// 卡片模式要先把目标图裁成卡片大小,才能让 GPUI 对最终边界做圆角。 - source: Arc, - /// 原图像素尺寸(fit 数学用)。 + path: PathBuf, + image: Option>, + stamp: Option, width: u32, height: u32, fit: BackgroundImageFit, alignment: BackgroundImageAlignment, - /// 铺满整窗(chrome 之下也画)而非仅终端卡。 cover_chrome: bool, - /// 解码缓存键。 - path: PathBuf, - mtime: Option, - baked_opacity: f32, - /// 当前窗口只显示一张正文卡;保留最近一次尺寸即可覆盖拖拽 resize, - /// 又不会让连续缩放积累大量 GPU 纹理。 - card_cache: Mutex>, - /// 铺满整窗时的窗口级合成图。绝不能每帧把原图像素丢给 GPU。 - window_cache: Mutex>, + opacity: f32, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct CardWallpaperKey { - card_width: u32, - card_height: u32, - anchor_width: u32, - anchor_height: u32, - crop_x: i32, - crop_y: i32, -} - -struct CardWallpaper { - key: CardWallpaperKey, - image: Arc, -} - -/// 读取设置并重建视效状态;壁纸命中缓存键则复用已解码纹理。 -/// 随后把窗口层效果(背景外观 + DWM backdrop)应用到所有已开窗口。 +/// Refresh prepared visual state without reading or decoding image files on the UI thread. pub fn refresh(cx: &mut App) { let rt = nebula_settings::RuntimeSettings::load(); + update_wallpaper(&rt, rt.opacity, rt.blur, cx); + apply_window_effects(cx); + refresh_surface_opacity(cx); +} - let wallpaper = rt.background_image.as_ref().and_then(|raw_path| { - let path = PathBuf::from(raw_path); - let mtime = std::fs::metadata(&path).and_then(|m| m.modified()).ok(); - let opacity = rt.background_image_opacity; - let fit = rt +fn update_wallpaper( + rt: &nebula_settings::RuntimeSettings, + opacity: f32, + blur: BlurModeName, + cx: &mut App, +) { + if !cx.has_global::() { + cx.set_global(VisualEffects { + opacity, + blur, + wallpaper: None, + generation: Arc::new(AtomicU64::new(0)), + loading: false, + }); + } + let desired = rt.background_image.as_ref().map(PathBuf::from); + let effects = cx.global_mut::(); + effects.opacity = opacity; + effects.blur = blur; + let retired = if effects.wallpaper.as_ref().map(|wp| &wp.path) != desired.as_ref() { + let retired = effects.wallpaper.take().and_then(|wp| wp.image); + effects.wallpaper = desired.map(|path| Wallpaper { + path, + image: None, + stamp: None, + width: 1, + height: 1, + fit: BackgroundImageFit::default(), + alignment: BackgroundImageAlignment::default(), + cover_chrome: false, + opacity: 1.0, + }); + retired + } else { + None + }; + if let Some(wp) = effects.wallpaper.as_mut() { + wp.fit = rt .background_image_fit .as_deref() .and_then(BackgroundImageFit::parse) .unwrap_or_default(); - let alignment = rt + wp.alignment = rt .background_image_alignment .as_deref() .and_then(BackgroundImageAlignment::parse) .unwrap_or_default(); + wp.cover_chrome = rt.background_image_cover_chrome; + wp.opacity = rt.background_image_opacity.clamp(0.0, 1.0); + } + effects.generation.fetch_add(1, Ordering::Release); + retire_image(retired, cx); + start_load(cx); +} - let cover_chrome = rt.background_image_cover_chrome; - - // 缓存:路径/mtime/烘焙透明度都没变就不重解码。 - let cached = cx.try_global::().and_then(|v| v.wallpaper.as_ref()); - if let Some(prev) = cached { - if prev.path == path && prev.mtime == mtime && prev.baked_opacity == opacity { - return Some(Wallpaper { - image: prev.image.clone(), - source: prev.source.clone(), - width: prev.width, - height: prev.height, - fit, - alignment, - cover_chrome, - path, - mtime, - baked_opacity: opacity, - card_cache: Mutex::new(None), - window_cache: Mutex::new(None), - }); +fn start_load(cx: &mut App) { + let effects = cx.global_mut::(); + if effects.loading { + return; + } + let Some(wp) = effects.wallpaper.as_ref() else { return }; + let request = image_loader::Request { + path: wp.path.clone(), + cached: wp.stamp.clone(), + generation: effects.generation.clone(), + version: effects.generation.load(Ordering::Acquire), + }; + let version = request.version; + effects.loading = true; + let task = cx.background_executor().spawn(async move { image_loader::load(request) }); + cx.spawn(async move |cx| { + let result = task.await; + cx.update(|cx| { + let Some(effects) = cx.try_global::() else { return }; + let stale = effects.generation.load(Ordering::Acquire) != version; + cx.global_mut::().loading = false; + if stale { + start_load(cx); + return; } + match result { + Ok(Some(loaded)) => { + let Some(wp) = cx.global_mut::().wallpaper.as_mut() else { + return; + }; + wp.width = loaded.layout_width; + wp.height = loaded.layout_height; + wp.stamp = Some(loaded.stamp); + let image = Arc::new(RenderImage::new([Frame::new(loaded.pixels)])); + let retired = wp.image.replace(image); + retire_image(retired, cx); + refresh_surface_opacity(cx); + cx.refresh_windows(); + }, + Ok(None) => {}, + Err(error) => { + log::warn!("background image load failed: {error:?}"); + show_load_error(error, cx); + }, + } + }); + }) + .detach(); +} + +fn refresh_surface_opacity(cx: &mut App) { + if cx.has_global::() + && cx.has_global::() + { + crate::gpui_shell::theme::reapply_prepared_surface_opacity(cx); + } +} + +fn retire_image(image: Option>, cx: &mut App) { + if let Some(image) = image { + // Settings callbacks may have taken the current window out of App.windows. + // Defer until all windows are back, and invalidate cached scene replay. + cx.defer(move |cx| { + cx.drop_image(image, None); + cx.refresh_windows(); + }); + } +} + +fn show_load_error(error: image_loader::LoadError, cx: &mut App) { + use crate::i18n::Message; + cx.defer(move |cx| { + let message = match error { + image_loader::LoadError::TooLarge => Message::WallpaperTooLarge, + _ => Message::WallpaperLoadFailed, + }; + let text = crate::gpui_shell::config::ui_language(cx).text(message); + if let Some(handle) = cx.windows().first() { + let _ = handle.update(cx, |_, window, cx| { + crate::gpui_shell::toast::toast( + window, + cx, + crate::gpui_shell::toast::ToastKind::Warning, + text, + ); + }); } - - let (image, source, width, height) = decode(&path, opacity)?; - Some(Wallpaper { - image, - source, - width, - height, - fit, - alignment, - cover_chrome, - path, - mtime, - baked_opacity: opacity, - card_cache: Mutex::new(None), - window_cache: Mutex::new(None), - }) }); - - cx.set_global(VisualEffects { opacity: rt.opacity, blur: rt.blur, wallpaper }); - apply_window_effects(cx); } /// 当前窗口透明度(无全局时视为不透明)。 @@ -156,28 +232,25 @@ pub fn set_opacity_live(opacity: f32, cx: &mut App) { } } -/// 壳/卡透明度严格跟随用户滑块。模糊是独立的窗口背景外观属性,不能反向 -/// 篡改透明度;否则打开模糊会把用户设置的 100% 偷改成 88%,与旧壳语义 -/// 不一致。铺满 chrome 的壁纸仍沿用自己的可见性上限。 -/// -/// 推论:不透明度 100% 时开模糊在画面上看不出变化——模糊的是窗口**后方** -/// 的内容,被完全不透明的像素挡住了。这是两个开关正交的必然结果,不是 bug。 +/// Preserve the original extended-wallpaper scrim: shell and card surfaces +/// retain the user's opacity, capped at 0.78, while text remains opaque. +/// Before an image is ready (or after clearing it), use the normal surface. pub fn chrome_surface_opacity(cx: &App) -> f32 { let Some(effects) = cx.try_global::() else { return 1.0; }; - let mut alpha = effects.opacity.clamp(0.0, 1.0); - if effects.wallpaper.as_ref().is_some_and(|wp| wp.cover_chrome) { - alpha = alpha.min(0.78); + if effects.wallpaper.as_ref().is_some_and(|wp| wp.cover_chrome && wp.image.is_some()) { + return effects.opacity.clamp(0.0, 1.0).min(0.78); } - alpha + effects.opacity.clamp(0.0, 1.0) } /// 开窗参数用。GPUI 通用层在窗口创建时就会把这个值下发到平台层 /// (`gpui::Window::new` → `platform_window.set_background_appearance`)。 /// Mica / Mica Alt 因此从首帧就走平台原生 backdrop,不再先挂一层普通透明背景。 pub fn initial_background_appearance() -> WindowBackgroundAppearance { - background_appearance(nebula_settings::RuntimeSettings::load().blur) + let runtime = nebula_settings::RuntimeSettings::load(); + background_appearance(runtime.blur) } /// 模糊开关 → 窗口背景外观。**唯一落笔点**,启动与热应用共用。 @@ -539,34 +612,6 @@ fn apply_windows_accent_policy( } } -/// 解码壁纸:RGBA8 → 把图片透明度烘进 alpha → BGRA(gpui 图像帧的 -/// 通道序,zed 的图片资产装载器同款处理)。 -fn decode( - path: &std::path::Path, - opacity: f32, -) -> Option<(Arc, Arc, u32, u32)> { - let bytes = std::fs::read(path).ok()?; - let mut rgba = image::load_from_memory(&bytes).ok()?.into_rgba8(); - // 4K+ 原图若每帧按窗口重采样会卡死设置页开关。先压到长边 2560。 - const MAX_EDGE: u32 = 2560; - let (src_w, src_h) = rgba.dimensions(); - if src_w.max(src_h) > MAX_EDGE { - let scale = MAX_EDGE as f32 / src_w.max(src_h) as f32; - let w = (src_w as f32 * scale).round().max(1.0) as u32; - let h = (src_h as f32 * scale).round().max(1.0) as u32; - rgba = imageops::resize(&rgba, w, h, imageops::FilterType::Triangle); - } - let (width, height) = rgba.dimensions(); - let factor = opacity.clamp(0.0, 1.0); - for pixel in rgba.chunks_exact_mut(4) { - pixel[3] = (f32::from(pixel[3]) * factor).round() as u8; - pixel.swap(0, 2); - } - let source = Arc::new(rgba); - let image = Arc::new(RenderImage::new([Frame::new((*source).clone())])); - Some((image, source, width, height)) -} - // ---- 以下一组只服务已停用的 [`paint_glass_overlay`](见其文档)。按用户要求 // ---- 保留实现,因此统一标 `dead_code`,不要因为"没人用"就删掉。 @@ -610,7 +655,7 @@ fn noise_tile() -> Arc { // 取高位:LCG 的低位周期极短,直接用会出现肉眼可见的条带。 let luma = (state >> 24) as u8; // GPUI 的图像帧是预乘 alpha,颜色必须先乘进去,否则半透明噪点 - // 整体偏亮。三通道同值,所以不必像 `decode` 那样 swap 成 BGRA。 + // 整体偏亮。三通道同值,所以不必加载壁纸时那样 swap 成 BGRA。 let premultiplied = ((u16::from(luma) * u16::from(NOISE_ALPHA)) / 255) as u8; pixel[0] = premultiplied; pixel[1] = premultiplied; @@ -679,186 +724,83 @@ pub fn paint_glass_overlay(bounds: Bounds, window: &mut Window, cx: &App }); } -/// 终端卡的壁纸层(卡底色之上、内容之下;由卡容器的 canvas 调用, -/// 覆盖整卡含内边距带)。 -/// -/// 卡模式:以卡为定位空间。铺满整窗模式:以整窗为定位空间、这里只 -/// 画卡内那片切片——图在窗口坐标系里连续,与 chrome 底层无缝。 -pub fn paint_wallpaper_card(bounds: Bounds, window: &mut Window, cx: &App) { - let Some(effects) = cx.try_global::() else { return }; - let Some(wp) = &effects.wallpaper else { return }; - let Some(image) = card_wallpaper(bounds, window, wp) else { return }; - - // GPUI 的 ContentMask 只有矩形,paint_image 的半径又作用于图片自身 - // bounds,而不是独立的裁剪矩形。cover/contain/native 的图片 bounds - // 往往不等于卡片,直接画会把四角重新铺方。这里传入已裁成卡片尺寸的 - // 纹理,使图片 bounds 与旧壳 shader 的圆角 clip rect 完全重合。 - let _ = window.paint_image( - bounds, - bounds, - Corners::all(crate::gpui_shell::theme::card_radius(cx)), - image, - 0, - false, - ); +/// Card-only wallpaper sits above the card background. Extended wallpaper sits +/// below the shell/card surfaces, preserving the original visible scrim. +/// Both modes reuse one image; opacity and layout never rebake its pixels. +pub fn card_layer(cx: &App) -> impl IntoElement { + layer(false, cx) } -/// 生成卡片尺寸的壁纸纹理。卡片模式以卡片为布局锚点;铺满整窗模式先按 -/// 整窗布局,再截取卡片所在切片,保证卡内外图案连续。 -fn card_wallpaper( - bounds: Bounds, - window: &Window, - wp: &Wallpaper, -) -> Option> { - let scale = window.scale_factor().max(0.5); - let card_width = (f32::from(bounds.size.width) * scale).ceil().max(1.0) as u32; - let card_height = (f32::from(bounds.size.height) * scale).ceil().max(1.0) as u32; +pub fn window_layer(cx: &App) -> impl IntoElement { + layer(true, cx) +} - let (anchor_width, anchor_height, crop_x, crop_y) = if wp.cover_chrome { - let viewport = window.viewport_size(); - ( - (f32::from(viewport.width) * scale).ceil().max(1.0) as u32, - (f32::from(viewport.height) * scale).ceil().max(1.0) as u32, - (f32::from(bounds.origin.x) * scale).floor() as i32, - (f32::from(bounds.origin.y) * scale).floor() as i32, +fn layer(under_chrome: bool, cx: &App) -> impl IntoElement { + let opacity = cx + .try_global::() + .and_then(|effects| effects.wallpaper.as_ref()) + .map_or(1.0, |wp| wp.opacity); + // Canvas's style.paint does not apply element opacity in pinned GPUI; + // Div owns that scope for its child, without baking alpha into the image. + div().absolute().inset_0().opacity(opacity).child( + gpui::canvas( + |_, _, _| (), + move |bounds, _, window, cx| paint_wallpaper(bounds, under_chrome, window, cx), ) - } else { - (card_width, card_height, 0, 0) - }; - - let key = - CardWallpaperKey { card_width, card_height, anchor_width, anchor_height, crop_x, crop_y }; - if let Some(image) = wp.card_cache.lock().ok().and_then(|cache| { - cache.as_ref().filter(|cached| cached.key == key).map(|c| c.image.clone()) - }) { - return Some(image); - } - - let anchor = compose_anchor(wp, anchor_width, anchor_height); - let card = if wp.cover_chrome { crop_card(&anchor, key) } else { anchor }; - let image = Arc::new(RenderImage::new([Frame::new(card)])); - if let Ok(mut cache) = wp.card_cache.lock() { - *cache = Some(CardWallpaper { key, image: image.clone() }); - } - Some(image) + .size_full(), + ) } -/// 按旧层 `wallpaper_rect` 的 fit/alignment 语义合成完整锚点图。对 cover -/// 先裁源图再缩放,避免极端长宽比图片产生远大于窗口的临时位图。 -fn compose_anchor(wp: &Wallpaper, width: u32, height: u32) -> RgbaImage { - use image::imageops::FilterType; - - let source = wp.source.as_ref(); - let source_width = source.width().max(1); - let source_height = source.height().max(1); - let (fx, fy) = wp.alignment.factors(); - - match wp.fit { - BackgroundImageFit::Fill => imageops::resize(source, width, height, FilterType::Triangle), - BackgroundImageFit::UniformToFill => { - let source_aspect = source_width as f32 / source_height as f32; - let target_aspect = width as f32 / height as f32; - let (crop_width, crop_height) = if source_aspect > target_aspect { - ( - ((source_height as f32 * target_aspect).round() as u32).clamp(1, source_width), - source_height, - ) - } else { - ( - source_width, - ((source_width as f32 / target_aspect).round() as u32).clamp(1, source_height), - ) - }; - let crop_x = ((source_width - crop_width) as f32 * fx).round() as u32; - let crop_y = ((source_height - crop_height) as f32 * fy).round() as u32; - let cropped = imageops::crop_imm(source, crop_x, crop_y, crop_width, crop_height); - imageops::resize(&cropped.to_image(), width, height, FilterType::Triangle) - }, - BackgroundImageFit::Uniform => { - let scale = - (width as f32 / source_width as f32).min(height as f32 / source_height as f32); - let draw_width = (source_width as f32 * scale).round().max(1.0) as u32; - let draw_height = (source_height as f32 * scale).round().max(1.0) as u32; - let resized = imageops::resize(source, draw_width, draw_height, FilterType::Triangle); - let mut output = RgbaImage::new(width, height); - let x = ((width - draw_width) as f32 * fx).round() as i64; - let y = ((height - draw_height) as f32 * fy).round() as i64; - imageops::overlay(&mut output, &resized, x, y); - output - }, - BackgroundImageFit::None => { - let mut output = RgbaImage::new(width, height); - let x = ((width as i64 - source_width as i64) as f32 * fx).round() as i64; - let y = ((height as i64 - source_height as i64) as f32 * fy).round() as i64; - imageops::overlay(&mut output, source, x, y); - output - }, - } +fn image_bounds(wp: &Wallpaper, anchor: Bounds, scale: f32) -> Bounds { + let (x, y, width, height) = wallpaper_rect( + f32::from(anchor.size.width) * scale, + f32::from(anchor.size.height) * scale, + wp.width as f32, + wp.height as f32, + wp.fit, + wp.alignment, + ); + Bounds::new( + anchor.origin + point(px(x / scale), px(y / scale)), + size(px(width / scale), px(height / scale)), + ) } -/// 从整窗锚点图中取出卡片切片。常规布局完全位于视口内;边界保护用于 -/// DPI/开窗过渡帧,避免 1px 的负坐标或越界导致 panic。 -fn crop_card(anchor: &RgbaImage, key: CardWallpaperKey) -> RgbaImage { - let mut card = RgbaImage::new(key.card_width, key.card_height); - let source_x = key.crop_x.max(0) as u32; - let source_y = key.crop_y.max(0) as u32; - let target_x = key.crop_x.saturating_neg() as u32; - let target_y = key.crop_y.saturating_neg() as u32; - let copy_width = - key.card_width.saturating_sub(target_x).min(anchor.width().saturating_sub(source_x)); - let copy_height = - key.card_height.saturating_sub(target_y).min(anchor.height().saturating_sub(source_y)); - if copy_width == 0 || copy_height == 0 { - return card; - } - - let slice = imageops::crop_imm(anchor, source_x, source_y, copy_width, copy_height).to_image(); - imageops::replace(&mut card, &slice, i64::from(target_x), i64::from(target_y)); - card -} - -/// 整窗壁纸底层(chrome 之下),仅铺满整窗模式绘制;workspace 根部的 -/// canvas 调用。侧栏/标题栏以壳色 alpha 盖在其上(透明度低时透出), -/// 终端卡内的那片由卡容器在卡底色之上重画(旧壳同一层模型)。 -pub fn paint_wallpaper_under_chrome(bounds: Bounds, window: &mut Window, cx: &App) { +fn paint_wallpaper(bounds: Bounds, under_chrome: bool, window: &mut Window, cx: &App) { let Some(effects) = cx.try_global::() else { return }; - let Some(wp) = &effects.wallpaper else { return }; - if !wp.cover_chrome { + let Some(wp) = effects.wallpaper.as_ref() else { return }; + // In the previous CPU crop path an offset card produced an empty overlay + // (negative crop offsets cast to u32). Repainting the source here would + // remove the visible scrim. Preserve that appearance directly, without + // retaining the broken crop arithmetic or an empty card-sized bitmap. + if wp.opacity <= 0.0 || under_chrome != wp.cover_chrome { return; } - let Some(image) = window_wallpaper(bounds, window, wp) else { return }; - window.with_content_mask(Some(ContentMask { bounds }), |window| { - let _ = window.paint_image(bounds, bounds, Corners::all(px(0.0)), image, 0, false); - }); + let Some(image) = wp.image.as_ref() else { return }; + let anchor = if wp.cover_chrome { + Bounds::new(point(px(0.0), px(0.0)), window.viewport_size()) + } else { + bounds + }; + let image_bounds = image_bounds(wp, anchor, window.scale_factor().max(0.5)); + let radius = if under_chrome { px(0.0) } else { crate::gpui_shell::theme::card_radius(cx) }; + let corners = image_corners(bounds, image_bounds, radius); + if let Err(error) = window.paint_image(bounds, image_bounds, corners, image.clone(), 0, false) { + log::warn!("background image paint failed: {error}"); + } } -/// 整窗壁纸:按视口物理像素合成一次并缓存。打开 cover 时绝不能把 -/// 原图丢给 GPU 每帧缩放——那是设置开关「一点就卡死」的根因。 -fn window_wallpaper( - bounds: Bounds, - window: &Window, - wp: &Wallpaper, -) -> Option> { - let scale = window.scale_factor().max(0.5); - let width = (f32::from(bounds.size.width) * scale).ceil().max(1.0) as u32; - let height = (f32::from(bounds.size.height) * scale).ceil().max(1.0) as u32; - let key = CardWallpaperKey { - card_width: width, - card_height: height, - anchor_width: width, - anchor_height: height, - crop_x: 0, - crop_y: 0, - }; - if let Some(image) = wp.window_cache.lock().ok().and_then(|cache| { - cache.as_ref().filter(|cached| cached.key == key).map(|c| c.image.clone()) - }) { - return Some(image); +fn image_corners(bounds: Bounds, image: Bounds, radius: Pixels) -> Corners { + // GPUI rounds the visible intersection. Interior letterbox edges are square; + // only corners shared with the card inherit its radius. + let left = image.left() <= bounds.left(); + let right = image.right() >= bounds.right(); + let top = image.top() <= bounds.top(); + let bottom = image.bottom() >= bounds.bottom(); + Corners { + top_left: if left && top { radius } else { px(0.0) }, + top_right: if right && top { radius } else { px(0.0) }, + bottom_left: if left && bottom { radius } else { px(0.0) }, + bottom_right: if right && bottom { radius } else { px(0.0) }, } - let composed = compose_anchor(wp, width, height); - let image = Arc::new(RenderImage::new([Frame::new(composed)])); - if let Ok(mut cache) = wp.window_cache.lock() { - *cache = Some(CardWallpaper { key, image: image.clone() }); - } - Some(image) } diff --git a/nebula_app/src/gpui_shell/wallpaper/image_loader.rs b/nebula_app/src/gpui_shell/wallpaper/image_loader.rs new file mode 100644 index 0000000..9afc12c --- /dev/null +++ b/nebula_app/src/gpui_shell/wallpaper/image_loader.rs @@ -0,0 +1,189 @@ +//! Bounded, serial wallpaper preparation. No GPUI objects or UI-thread file I/O. +use std::fs::File; +use std::io::BufReader; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::SystemTime; + +use image::{DynamicImage, ImageDecoder, ImageError, ImageReader, RgbaImage}; + +// These bound the resources owned by this loader, not the entire process or a +// decoder's undocumented scratch space. One job runs at a time across windows. +const MAX_FILE_BYTES: u64 = 64 * 1024 * 1024; +const MAX_DECODE_BYTES: u64 = 128 * 1024 * 1024; +pub(super) const MAX_IMAGE_BYTES: u64 = 8 * 1024 * 1024; +const MAX_IMAGE_EDGE: u32 = 2048; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct FileStamp { + modified: Option, + bytes: u64, +} + +pub(super) struct Request { + pub path: PathBuf, + pub cached: Option, + pub generation: Arc, + pub version: u64, +} + +impl Request { + fn cancelled(&self) -> bool { + self.generation.load(Ordering::Acquire) != self.version + } +} + +pub(super) struct LoadedImage { + pub pixels: RgbaImage, + pub layout_width: u32, + pub layout_height: u32, + pub stamp: FileStamp, +} + +#[derive(Debug, PartialEq, Eq)] +pub(super) enum LoadError { + TooLarge, + Read, + Decode, +} + +impl From for LoadError { + fn from(error: ImageError) -> Self { + match error { + ImageError::Limits(_) => Self::TooLarge, + ImageError::IoError(_) => Self::Read, + _ => Self::Decode, + } + } +} + +pub(super) fn load(request: Request) -> Result, LoadError> { + if request.cancelled() { + return Ok(None); + } + let file = File::open(&request.path).map_err(|_| LoadError::Read)?; + let metadata = file.metadata().map_err(|_| LoadError::Read)?; + let stamp = FileStamp { modified: metadata.modified().ok(), bytes: metadata.len() }; + if stamp.bytes > MAX_FILE_BYTES { + return Err(LoadError::TooLarge); + } + if request.cached.as_ref() == Some(&stamp) { + return Ok(None); + } + + // Stream the encoded input instead of retaining a second full-file Vec. + let mut reader = ImageReader::new(BufReader::new(file)) + .with_guessed_format() + .map_err(|_| LoadError::Read)?; + let mut limits = image::Limits::default(); + limits.max_alloc = Some(MAX_DECODE_BYTES); + limits.max_image_width = Some(32768); + limits.max_image_height = Some(32768); + reader.limits(limits); + let decoder = reader.into_decoder()?; + let (width, height) = decoder.dimensions(); + admit_dimensions(width, height, decoder.total_bytes())?; + if request.cancelled() { + return Ok(None); + } + let (target_width, target_height) = bounded_dimensions(width, height); + let mut decoded = DynamicImage::from_decoder(decoder)?; + if request.cancelled() { + return Ok(None); + } + if (width, height) != (target_width, target_height) { + // Integer area thumbnailing avoids Triangle's full-width RGBA32F + // intermediate. Shrink before RGBA conversion, including 16-bit input. + decoded = decoded.thumbnail_exact(target_width, target_height); + } + let mut pixels = decoded.into_rgba8(); + if request.cancelled() { + return Ok(None); + } + for pixel in pixels.chunks_exact_mut(4) { + pixel.swap(0, 2); + } + // Preserve the previous native-fit geometry independently of texture quality. + let layout_scale = (2560.0_f64 / f64::from(width.max(height))).min(1.0); + let layout_width = (f64::from(width) * layout_scale).round().max(1.0) as u32; + let layout_height = (f64::from(height) * layout_scale).round().max(1.0) as u32; + Ok(Some(LoadedImage { pixels, layout_width, layout_height, stamp })) +} + +fn admit_dimensions(width: u32, height: u32, decoded_bytes: u64) -> Result<(), LoadError> { + let rgba_bytes = u64::from(width) + .checked_mul(u64::from(height)) + .and_then(|pixels| pixels.checked_mul(4)) + .ok_or(LoadError::TooLarge)?; + if width == 0 || height == 0 { + return Err(LoadError::Decode); + } + if decoded_bytes > MAX_DECODE_BYTES || rgba_bytes > MAX_DECODE_BYTES { + return Err(LoadError::TooLarge); + } + Ok(()) +} + +fn bounded_dimensions(width: u32, height: u32) -> (u32, u32) { + let pixels = f64::from(width) * f64::from(height); + let scale = (f64::from(MAX_IMAGE_EDGE) / f64::from(width.max(height))) + .min((MAX_IMAGE_BYTES as f64 / (pixels * 4.0)).sqrt()) + .min(1.0); + ( + (f64::from(width) * scale).floor().max(1.0) as u32, + (f64::from(height) * scale).floor().max(1.0) as u32, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn request(path: PathBuf) -> Request { + Request { path, cached: None, generation: Arc::new(AtomicU64::new(1)), version: 1 } + } + + #[test] + fn output_budget_handles_large_square_panorama_and_portrait() { + for (w, h) in [(7680, 4320), (4096, 4096), (32000, 8), (8, 32000), (7, 3)] { + let (width, height) = bounded_dimensions(w, h); + assert!(width <= MAX_IMAGE_EDGE && height <= MAX_IMAGE_EDGE); + assert!(u64::from(width) * u64::from(height) * 4 <= MAX_IMAGE_BYTES); + assert!(width <= w && height <= h); + } + assert_eq!(bounded_dimensions(7, 3), (7, 3)); + } + + #[test] + fn source_budget_is_checked_before_allocating_pixels() { + assert!(admit_dimensions(7680, 4320, 7680 * 4320 * 4).is_ok()); + assert_eq!(admit_dimensions(10000, 10000, 1), Err(LoadError::TooLarge)); + assert_eq!(admit_dimensions(16, 16, MAX_DECODE_BYTES + 1), Err(LoadError::TooLarge)); + assert_eq!(admit_dimensions(0, 16, 0), Err(LoadError::Decode)); + } + + #[test] + fn pixels_move_to_bgra_without_baking_opacity_and_unchanged_files_are_reused() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("wallpaper.png"); + RgbaImage::from_pixel(3, 2, image::Rgba([10, 20, 30, 128])).save(&path).unwrap(); + let loaded = load(request(path.clone())).unwrap().unwrap(); + assert_eq!(loaded.pixels.get_pixel(0, 0).0, [30, 20, 10, 128]); + let mut cached = request(path.clone()); + cached.cached = Some(loaded.stamp); + assert!(load(cached).unwrap().is_none()); + let mut stale = request(path); + stale.version = 0; + assert!(load(stale).unwrap().is_none()); + } + + #[test] + fn corrupt_and_missing_images_fail_without_a_render_resource() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("bad.png"); + assert!(matches!(load(request(path.clone())), Err(LoadError::Read))); + std::fs::write(&path, b"not an image").unwrap(); + assert!(matches!(load(request(path)), Err(LoadError::Decode))); + } +} diff --git a/nebula_app/src/gpui_shell/wallpaper/tests.rs b/nebula_app/src/gpui_shell/wallpaper/tests.rs new file mode 100644 index 0000000..fdc1bc0 --- /dev/null +++ b/nebula_app/src/gpui_shell/wallpaper/tests.rs @@ -0,0 +1,139 @@ +use super::*; +use gpui::TestAppContext; + +#[test] +fn letterbox_edges_stay_square_and_native_alignment_inherits_card_corners() { + let card = Bounds::new(point(px(0.0), px(0.0)), size(px(800.0), px(600.0))); + let letterbox = Bounds::new(point(px(0.0), px(100.0)), size(px(800.0), px(400.0))); + assert_eq!(image_corners(card, letterbox, px(12.0)), Corners::all(px(0.0))); + assert_eq!(image_corners(card, card, px(12.0)), Corners::all(px(12.0))); + let native = Bounds::new(card.origin, size(px(400.0), px(300.0))); + let corners = image_corners(card, native, px(12.0)); + assert_eq!(corners.top_left, px(12.0)); + assert_eq!(corners.top_right, px(0.0)); + assert_eq!(corners.bottom_left, px(0.0)); + assert_eq!(corners.bottom_right, px(0.0)); +} + +fn settings(path: Option) -> nebula_settings::RuntimeSettings { + let mut settings = nebula_settings::RuntimeSettings::load(); + settings.background_image = path.map(|path| path.to_string_lossy().into_owned()); + settings.background_image_opacity = 0.38; + settings.background_image_fit = Some("fill".into()); + settings.background_image_alignment = Some("center".into()); + settings.background_image_cover_chrome = false; + settings +} + +fn fixture(path: &std::path::Path, color: [u8; 4]) { + RgbaImage::from_pixel(64, 32, image::Rgba(color)).save(path).unwrap(); +} + +#[gpui::test] +fn loading_yields_and_layout_opacity_refreshes_reuse_the_same_image(cx: &mut TestAppContext) { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("wallpaper.png"); + fixture(&path, [10, 20, 30, 255]); + let mut rt = settings(Some(path)); + rt.background_image_cover_chrome = true; + cx.update(|cx| { + update_wallpaper(&rt, 1.0, BlurModeName::None, cx); + let effects = cx.global::(); + assert!(effects.loading); + assert!(effects.wallpaper.as_ref().unwrap().image.is_none()); + assert_eq!(chrome_surface_opacity(cx), 1.0); + }); + cx.run_until_parked(); + let original = cx.update(|cx| { + assert!(!cx.global::().loading); + assert_eq!(chrome_surface_opacity(cx), 0.78); + cx.global::().wallpaper.as_ref().unwrap().image.clone().unwrap() + }); + rt.background_image_opacity = 0.75; + rt.background_image_fit = Some("contain".into()); + rt.background_image_cover_chrome = true; + cx.update(|cx| update_wallpaper(&rt, 0.8, BlurModeName::None, cx)); + cx.run_until_parked(); + cx.update(|cx| { + let wp = cx.global::().wallpaper.as_ref().unwrap(); + assert!(Arc::ptr_eq(&original, wp.image.as_ref().unwrap())); + assert_eq!(wp.opacity, 0.75); + assert_eq!(wp.fit, BackgroundImageFit::Uniform); + assert_eq!(chrome_surface_opacity(cx), 0.78); + for width in 800..1000 { + let bounds = Bounds::new(point(px(100.0), px(30.0)), size(px(width as f32), px(600.0))); + let _ = image_bounds(wp, bounds, 1.5); + assert!(Arc::ptr_eq(&original, wp.image.as_ref().unwrap())); + } + rt.background_image_opacity = 0.0; + update_wallpaper(&rt, 0.8, BlurModeName::None, cx); + assert_eq!(chrome_surface_opacity(cx), 0.78); + set_opacity_live(0.4, cx); + assert_eq!(chrome_surface_opacity(cx), 0.4); + rt.background_image_cover_chrome = false; + update_wallpaper(&rt, 0.8, BlurModeName::None, cx); + assert_eq!(chrome_surface_opacity(cx), 0.8); + update_wallpaper(&settings(None), 0.8, BlurModeName::None, cx); + assert_eq!(chrome_surface_opacity(cx), 0.8); + }); +} + +#[gpui::test] +fn latest_request_wins_and_clearing_during_decode_cannot_restore_an_image(cx: &mut TestAppContext) { + let directory = tempfile::tempdir().unwrap(); + let first = directory.path().join("first.png"); + let last = directory.path().join("last.png"); + fixture(&first, [255, 0, 0, 255]); + fixture(&last, [0, 0, 255, 255]); + cx.update(|cx| { + update_wallpaper(&settings(Some(first)), 1.0, BlurModeName::None, cx); + update_wallpaper(&settings(Some(last.clone())), 1.0, BlurModeName::None, cx); + }); + cx.run_until_parked(); + cx.update(|cx| { + let wp = cx.global::().wallpaper.as_ref().unwrap(); + assert_eq!(wp.path, last); + assert_eq!(&wp.image.as_ref().unwrap().as_bytes(0).unwrap()[..4], &[255, 0, 0, 255]); + update_wallpaper( + &settings(Some(directory.path().join("missing.png"))), + 1.0, + BlurModeName::None, + cx, + ); + update_wallpaper(&settings(None), 1.0, BlurModeName::None, cx); + }); + cx.run_until_parked(); + cx.update(|cx| { + assert!(cx.global::().wallpaper.is_none()); + assert!(!cx.global::().loading); + }); +} + +#[test] +fn card_and_chrome_share_the_window_anchor_and_preserve_native_physical_size() { + let mut wp = Wallpaper { + path: PathBuf::new(), + image: None, + stamp: None, + width: 800, + height: 400, + fit: BackgroundImageFit::UniformToFill, + alignment: BackgroundImageAlignment::Center, + cover_chrome: true, + opacity: 0.38, + }; + let anchor = Bounds::new(point(px(0.0), px(0.0)), size(px(600.0), px(600.0))); + assert_eq!( + image_bounds(&wp, anchor, 1.0), + Bounds::new(point(px(-300.0), px(0.0)), size(px(1200.0), px(600.0))) + ); + wp.fit = BackgroundImageFit::Uniform; + assert_eq!( + image_bounds(&wp, anchor, 1.0), + Bounds::new(point(px(0.0), px(150.0)), size(px(600.0), px(300.0))) + ); + wp.fit = BackgroundImageFit::None; + assert_eq!(image_bounds(&wp, anchor, 2.0).size, size(px(400.0), px(200.0))); + wp.alignment = BackgroundImageAlignment::BottomRight; + assert_eq!(image_bounds(&wp, anchor, 2.0).origin, point(px(200.0), px(400.0))); +} diff --git a/nebula_app/src/gpui_shell/workspace.rs b/nebula_app/src/gpui_shell/workspace.rs index 47c1fe6..438e473 100644 --- a/nebula_app/src/gpui_shell/workspace.rs +++ b/nebula_app/src/gpui_shell/workspace.rs @@ -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::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, 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_admin_task: Option>, 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) { - 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) { - 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) { - 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) { - 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::() + .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); })) @@ -4084,17 +3905,8 @@ impl Render for NebulaWorkspace { .child( // 用户显式配置的背景图画在 chrome 之下;系统 Mica/Aero/Acrylic // 位于整个 GPUI 内容层下方,由 DWM 合成,不能在这里读取壁纸仿画。 - // 卡外区域由这层负责,卡内切片由终端元素在卡底色之上重画。 - gpui::canvas( - |_, _, _| (), - |bounds, _, window, cx| { - crate::gpui_shell::wallpaper::paint_wallpaper_under_chrome( - bounds, window, cx, - ); - }, - ) - .absolute() - .inset_0(), + // 拓展模式只在此绘图,壳/卡衬底在其上保留原有文字对比度。 + crate::gpui_shell::wallpaper::window_layer(cx), ) .child( self.render_window_title_bar( @@ -4198,18 +4010,9 @@ impl Render for NebulaWorkspace { .overflow_hidden() .child( // 壁纸层(卡底色之上、内容之下,覆盖整卡含 - // 内边距带):卡模式按卡定位,铺满整窗模式画 - // 窗口锚定的卡内切片。 - gpui::canvas( - |_, _, _| (), - |bounds, _, window, cx| { - crate::gpui_shell::wallpaper::paint_wallpaper_card( - bounds, window, cx, - ); - }, - ) - .absolute() - .inset_0(), + // 内边距带):卡模式按卡定位;拓展模式由 + // 窗口底层统一绘图,此处不覆盖原有衬底。 + crate::gpui_shell::wallpaper::card_layer(cx), ) .children(content), ) @@ -4344,6 +4147,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) }) diff --git a/nebula_app/src/gpui_shell/workspace/file_tree.rs b/nebula_app/src/gpui_shell/workspace/file_tree.rs index a1ba0ec..ea12f94 100644 --- a/nebula_app/src/gpui_shell/workspace/file_tree.rs +++ b/nebula_app/src/gpui_shell/workspace/file_tree.rs @@ -317,32 +317,22 @@ impl NebulaWorkspace { let search_active = !self.side_panel.search.trim().is_empty(); let search_pending = self.side_panel.file_search_pending(); let search_error = self.side_panel.file_search_error().is_some(); - let indexed_count = self.side_panel.file_indexed_count(); - let indexed_more = if self.side_panel.file_index_truncated() { "+" } else { "" }; let search_status = if search_active { + use crate::i18n::Message; Some(if search_error { - language.pick("正则表达式无效", "Invalid regular expression").to_owned() - } else if self.side_panel.file_index_status() - == crate::display::side_panel::FileIndexStatus::Building - { - language.pick("正在建立文件索引…", "Building file index...").to_owned() + language.text(Message::FilesSearchFailed).to_owned() } else if search_pending { - language.pick("正在搜索…", "Searching...").to_owned() + language.text(Message::FilesSearchRunning).to_owned() + } else if self.side_panel.file_index_truncated() { + language.format( + Message::FilesSearchLimited, + &[("count", &self.side_panel.file_rows().len().to_string())], + ) } else { - match language { - crate::display::UiLanguage::ZhCn => format!( - "{} 个结果 · 已索引 {}{} 项", - self.side_panel.file_search_total(), - indexed_count, - indexed_more - ), - _ => format!( - "{} results · {}{} indexed", - self.side_panel.file_search_total(), - indexed_count, - indexed_more - ), - } + language.format( + Message::FilesSearchCount, + &[("count", &self.side_panel.file_search_total().to_string())], + ) }) } else { None @@ -625,26 +615,35 @@ impl NebulaWorkspace { return None; } if !self.side_panel.search.trim().is_empty() { + use crate::i18n::Message; + let language = super::workspace_ui_language(); return Some(if let Some(error) = self.side_panel.file_search_error() { crate::ux::EmptyState::new( - "正则表达式无效", + language.text(Message::FilesSearchFailed), error.to_owned(), - "修改表达式,或关闭右侧的 .* 选项。", + language.text(Message::FilesSearchRetry), ) } else if self.side_panel.file_search_pending() { crate::ux::EmptyState::new( - "正在建立索引", - "文件名索引正在后台准备,界面仍可继续使用。", - "索引完成后会自动显示当前查询的结果。", + language.text(Message::FilesSearchRunning), + language.text(Message::FilesSearchProgress), + language.text(Message::FilesSearchContinue), + ) + } else if self.side_panel.file_index_truncated() { + crate::ux::EmptyState::new( + language.text(Message::FilesSearchIncomplete), + language.text(Message::FilesSearchLimitReason), + language.text(Message::FilesSearchNarrow), ) } else { crate::ux::EmptyState::new( - "没有匹配的文件", - "当前目录的索引中没有符合条件的文件或文件夹。", - "修改查询,或关闭大小写、全词、正则选项后重试。", + language.text(Message::FilesSearchEmpty), + language.text(Message::FilesSearchEmptyReason), + language.text(Message::FilesSearchRetry), ) }); } + Some(if self.side_panel.snapshot_pending() { crate::ux::EmptyState::new( "正在读取目录", diff --git a/nebula_app/src/gpui_shell/workspace/key_actions.rs b/nebula_app/src/gpui_shell/workspace/key_actions.rs index 3d87833..2500393 100644 --- a/nebula_app/src/gpui_shell/workspace/key_actions.rs +++ b/nebula_app/src/gpui_shell/workspace/key_actions.rs @@ -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, + ) { + // 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, ) { + 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, ) { + 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); diff --git a/nebula_app/src/gpui_shell/workspace/keyboard_bindings.rs b/nebula_app/src/gpui_shell/workspace/keyboard_bindings.rs index 498c40c..8dd3357 100644 --- a/nebula_app/src/gpui_shell/workspace/keyboard_bindings.rs +++ b/nebula_app/src/gpui_shell/workspace/keyboard_bindings.rs @@ -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 { + 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, +} + +impl SelectTab { + fn from_config(action: &crate::config::Action) -> Option { 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::()); + 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 { + 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 { 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) { - 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) { let applied: Vec<_> = raw.iter().map(|(combo, _)| gpui_binding_combo(combo)).collect(); let defaults = crate::display::keymap::default_shortcuts(); let mut bindings = Vec::new(); diff --git a/nebula_app/src/gpui_shell/workspace/keyboard_bindings/tests.rs b/nebula_app/src/gpui_shell/workspace/keyboard_bindings/tests.rs new file mode 100644 index 0000000..f31baef --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/keyboard_bindings/tests.rs @@ -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::()); +} + +#[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, 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, + 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, + mode: TermMode, + input: Vec, + encoded: Vec, + } + + impl Render for TerminalKeyProbe { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> 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()); + }); + } +} diff --git a/nebula_app/src/gpui_shell/workspace/launcher_menu.rs b/nebula_app/src/gpui_shell/workspace/launcher_menu.rs new file mode 100644 index 0000000..0b83e94 --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/launcher_menu.rs @@ -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, + position: Point, + _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 { + 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 { + match self { + Self::Shell(shell) => Some(shell.id.clone()), + Self::Profile(profile) => profile.settings_id(), + Self::Ssh(_) => None, + } + } + + fn local_launch(&self) -> Option { + 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 { + 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, + window: &mut Window, + cx: &mut Context, + ) { + if !self.shell_picker_open || !self.command_palette_open { + return; + } + let current = crate::platform::shell::effective_shell_id( + cx.try_global::() + .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 { + 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.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, + ) { + 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, + ) { + 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; diff --git a/nebula_app/src/gpui_shell/workspace/launcher_menu/tests.rs b/nebula_app/src/gpui_shell/workspace/launcher_menu/tests.rs new file mode 100644 index 0000000..7fe6cc3 --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/launcher_menu/tests.rs @@ -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, 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"); + }); +} diff --git a/nebula_app/src/gpui_shell/workspace/palette.rs b/nebula_app/src/gpui_shell/workspace/palette.rs index ea09ef3..5749359 100644 --- a/nebula_app/src/gpui_shell/workspace/palette.rs +++ b/nebula_app/src/gpui_shell/workspace/palette.rs @@ -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() { diff --git a/nebula_app/src/gpui_shell/workspace/residency.rs b/nebula_app/src/gpui_shell/workspace/residency.rs index 5d22639..b1bde0c 100644 --- a/nebula_app/src/gpui_shell/workspace/residency.rs +++ b/nebula_app/src/gpui_shell/workspace/residency.rs @@ -986,6 +986,10 @@ impl NebulaWorkspace { window: &mut Window, cx: &mut Context, ) -> 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 上会变成 // 既不关也不藏(设置页那一行也按同一能力位隐藏)。 diff --git a/nebula_app/src/gpui_shell/workspace/session_persistence.rs b/nebula_app/src/gpui_shell/workspace/session_persistence.rs index 5bdd782..581b9bd 100644 --- a/nebula_app/src/gpui_shell/workspace/session_persistence.rs +++ b/nebula_app/src/gpui_shell/workspace/session_persistence.rs @@ -8,11 +8,22 @@ pub(super) enum SaveReason { Quit, } -#[derive(Default)] pub(super) struct SessionPersistence { latest: Option, saved: Option, 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 { diff --git a/nebula_app/src/gpui_shell/workspace/settings_navigation.rs b/nebula_app/src/gpui_shell/workspace/settings_navigation.rs index bb4d4de..4daa683 100644 --- a/nebula_app/src/gpui_shell/workspace/settings_navigation.rs +++ b/nebula_app/src/gpui_shell/workspace/settings_navigation.rs @@ -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) { + 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.toggle(self.side_panel.view); + 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) { + 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) { + 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, ) { - 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, ) { - 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); diff --git a/nebula_app/src/gpui_shell/workspace/settings_navigation/ui_tests.rs b/nebula_app/src/gpui_shell/workspace/settings_navigation/ui_tests.rs new file mode 100644 index 0000000..6788cc8 --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/settings_navigation/ui_tests.rs @@ -0,0 +1,162 @@ +use super::super::*; +use gpui::{Modifiers, TestAppContext, VisualTestContext}; + +fn open_workspace( + count: usize, + cx: &mut TestAppContext, +) -> (tempfile::TempDir, Entity, 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 { + 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())); +} diff --git a/nebula_app/src/gpui_shell/workspace/top_tabs.rs b/nebula_app/src/gpui_shell/workspace/top_tabs.rs index d50dd06..79795d9 100644 --- a/nebula_app/src/gpui_shell/workspace/top_tabs.rs +++ b/nebula_app/src/gpui_shell/workspace/top_tabs.rs @@ -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)) diff --git a/nebula_app/src/gpui_shell/workspace/windowing.rs b/nebula_app/src/gpui_shell/workspace/windowing.rs index 29265b0..fb42621 100644 --- a/nebula_app/src/gpui_shell/workspace/windowing.rs +++ b/nebula_app/src/gpui_shell/workspace/windowing.rs @@ -43,6 +43,7 @@ use crate::runtime_api::{ pub(crate) enum WorkspaceStartup { RestoreOrDefault, NewTerminal { cwd: Option }, + LaunchTerminal { cwd: Option, launch: crate::session::LaunchSession }, Empty, } @@ -250,11 +251,13 @@ pub(crate) fn open_initial_window( ai_events: std::sync::mpsc::Receiver, shell_events: std::sync::mpsc::Receiver, initial_cwd: Option, + initial_command: Option, ) { - 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, + command: Option, + 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); @@ -1471,6 +1535,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); @@ -1507,7 +1574,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, diff --git a/nebula_app/src/main.rs b/nebula_app/src/main.rs index 864983c..f892b38 100644 --- a/nebula_app/src/main.rs +++ b/nebula_app/src/main.rs @@ -239,7 +239,7 @@ fn main() -> Result<(), Box> { 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 diff --git a/nebula_app/src/platform/elevation.rs b/nebula_app/src/platform/elevation.rs new file mode 100644 index 0000000..004f1ef --- /dev/null +++ b/nebula_app/src/platform/elevation.rs @@ -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 { + 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 { + use std::sync::OnceLock; + static ELEVATED: OnceLock> = OnceLock::new(); + ELEVATED.get_or_init(query_token).clone().map_err(io::Error::from_raw_os_error) +} + +#[cfg(windows)] +fn query_token() -> Result { + 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::() 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 { + Ok(false) +} + +#[cfg(windows)] +fn quoted_argument(argument: &OsStr) -> io::Result> { + 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 { + 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 = 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 = "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::() 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 { + Err(io::Error::new(io::ErrorKind::Unsupported, "UAC is only available on Windows")) +} + +#[cfg(test)] +mod tests; diff --git a/nebula_app/src/platform/elevation/tests.rs b/nebula_app/src/platform/elevation/tests.rs new file mode 100644 index 0000000..ba8c9a0 --- /dev/null +++ b/nebula_app/src/platform/elevation/tests.rs @@ -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 = "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()); +} diff --git a/nebula_app/src/platform/mod.rs b/nebula_app/src/platform/mod.rs index 868374f..8c89a02 100644 --- a/nebula_app/src/platform/mod.rs +++ b/nebula_app/src/platform/mod.rs @@ -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; diff --git a/nebula_app/src/product_renderer.rs b/nebula_app/src/product_renderer.rs index e2453f9..d0542fd 100644 --- a/nebula_app/src/product_renderer.rs +++ b/nebula_app/src/product_renderer.rs @@ -3,98 +3,8 @@ //! 这里刻意不暴露旧 `Renderer`、GL shader 或 crossfont 栅格器;这些类型 //! 只是配置和布局合同,供 GPUI 与 `legacy-shell` 各自的绘制后端消费。 -pub mod image { - /// Wallpaper sizing modes. - #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] - pub enum BackgroundImageFit { - Fill, - Uniform, - #[default] - UniformToFill, - None, - } - - impl BackgroundImageFit { - pub fn parse(value: &str) -> Option { - match value.trim().to_ascii_lowercase().as_str() { - "fill" | "stretch" => Some(Self::Fill), - "uniform" | "contain" => Some(Self::Uniform), - "uniform_to_fill" | "uniformtofill" | "cover" => Some(Self::UniformToFill), - "none" | "native" => Some(Self::None), - _ => None, - } - } - - pub const fn settings_value(self) -> &'static str { - match self { - Self::Fill => "fill", - Self::Uniform => "uniform", - Self::UniformToFill => "uniform_to_fill", - Self::None => "none", - } - } - } - - /// Anchor used when the fitted wallpaper is larger or smaller than the window. - #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] - pub enum BackgroundImageAlignment { - TopLeft, - Top, - TopRight, - Left, - #[default] - Center, - Right, - BottomLeft, - Bottom, - BottomRight, - } - - impl BackgroundImageAlignment { - pub fn parse(value: &str) -> Option { - match value.trim().to_ascii_lowercase().replace('_', "-").as_str() { - "top-left" => Some(Self::TopLeft), - "top" => Some(Self::Top), - "top-right" => Some(Self::TopRight), - "left" => Some(Self::Left), - "center" | "centre" => Some(Self::Center), - "right" => Some(Self::Right), - "bottom-left" => Some(Self::BottomLeft), - "bottom" => Some(Self::Bottom), - "bottom-right" => Some(Self::BottomRight), - _ => None, - } - } - - pub const fn settings_value(self) -> &'static str { - match self { - Self::TopLeft => "top_left", - Self::Top => "top", - Self::TopRight => "top_right", - Self::Left => "left", - Self::Center => "center", - Self::Right => "right", - Self::BottomLeft => "bottom_left", - Self::Bottom => "bottom", - Self::BottomRight => "bottom_right", - } - } - - pub const fn factors(self) -> (f32, f32) { - match self { - Self::TopLeft => (0.0, 0.0), - Self::Top => (0.5, 0.0), - Self::TopRight => (1.0, 0.0), - Self::Left => (0.0, 0.5), - Self::Center => (0.5, 0.5), - Self::Right => (1.0, 0.5), - Self::BottomLeft => (0.0, 1.0), - Self::Bottom => (0.5, 1.0), - Self::BottomRight => (1.0, 1.0), - } - } - } -} +#[path = "renderer/image_layout.rs"] +pub mod image; pub mod ui { /// Straight-alpha color shared by configuration and GPUI adapters. diff --git a/nebula_app/src/renderer/image.rs b/nebula_app/src/renderer/image.rs index 2341464..19aae78 100644 --- a/nebula_app/src/renderer/image.rs +++ b/nebula_app/src/renderer/image.rs @@ -24,125 +24,8 @@ use crate::renderer::shader::{ShaderProgram, ShaderVersion}; const IMAGE_SHADER_F: &str = include_str!("../../res/image.f.glsl"); const IMAGE_SHADER_V: &str = include_str!("../../res/image.v.glsl"); -/// Wallpaper sizing modes. -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] -pub enum BackgroundImageFit { - /// Distort the image to exactly match the window. - Fill, - /// Preserve aspect ratio and keep the entire image visible. - Uniform, - /// Preserve aspect ratio and crop the overflow (CSS `cover`). - #[default] - UniformToFill, - /// Draw at the image's native pixel size. - None, -} - -impl BackgroundImageFit { - pub fn parse(value: &str) -> Option { - match value.trim().to_ascii_lowercase().as_str() { - "fill" | "stretch" => Some(Self::Fill), - "uniform" | "contain" => Some(Self::Uniform), - "uniform_to_fill" | "uniformtofill" | "cover" => Some(Self::UniformToFill), - "none" | "native" => Some(Self::None), - _ => None, - } - } - - pub const fn settings_value(self) -> &'static str { - match self { - Self::Fill => "fill", - Self::Uniform => "uniform", - Self::UniformToFill => "uniform_to_fill", - Self::None => "none", - } - } - - pub const fn next(self) -> Self { - match self { - Self::Fill => Self::Uniform, - Self::Uniform => Self::UniformToFill, - Self::UniformToFill => Self::None, - Self::None => Self::Fill, - } - } -} - -/// Anchor used when the fitted wallpaper is larger or smaller than the window. -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] -pub enum BackgroundImageAlignment { - TopLeft, - Top, - TopRight, - Left, - #[default] - Center, - Right, - BottomLeft, - Bottom, - BottomRight, -} - -impl BackgroundImageAlignment { - pub fn parse(value: &str) -> Option { - match value.trim().to_ascii_lowercase().replace('_', "-").as_str() { - "top-left" => Some(Self::TopLeft), - "top" => Some(Self::Top), - "top-right" => Some(Self::TopRight), - "left" => Some(Self::Left), - "center" | "centre" => Some(Self::Center), - "right" => Some(Self::Right), - "bottom-left" => Some(Self::BottomLeft), - "bottom" => Some(Self::Bottom), - "bottom-right" => Some(Self::BottomRight), - _ => None, - } - } - - pub const fn settings_value(self) -> &'static str { - match self { - Self::TopLeft => "top_left", - Self::Top => "top", - Self::TopRight => "top_right", - Self::Left => "left", - Self::Center => "center", - Self::Right => "right", - Self::BottomLeft => "bottom_left", - Self::Bottom => "bottom", - Self::BottomRight => "bottom_right", - } - } - - pub const fn next(self) -> Self { - match self { - Self::TopLeft => Self::Top, - Self::Top => Self::TopRight, - Self::TopRight => Self::Left, - Self::Left => Self::Center, - Self::Center => Self::Right, - Self::Right => Self::BottomLeft, - Self::BottomLeft => Self::Bottom, - Self::Bottom => Self::BottomRight, - Self::BottomRight => Self::TopLeft, - } - } - - /// 对齐锚点系数(0=贴起始边,0.5=居中,1=贴结束边)。`pub`:GPUI 壳 - /// 的壁纸绘制复用同一套布局数学(gpui_shell::wallpaper)。 - pub const fn factors(self) -> (f32, f32) { - match self { - Self::TopLeft => (0.0, 0.0), - Self::Top => (0.5, 0.0), - Self::TopRight => (1.0, 0.0), - Self::Left => (0.0, 0.5), - Self::Center => (0.5, 0.5), - Self::Right => (1.0, 0.5), - Self::BottomLeft => (0.0, 1.0), - Self::Bottom => (0.5, 1.0), - Self::BottomRight => (1.0, 1.0), - } - } -} +use super::image_layout::wallpaper_rect; +pub use super::image_layout::{BackgroundImageAlignment, BackgroundImageFit}; #[repr(C)] #[derive(Debug, Clone, Copy)] @@ -556,37 +439,6 @@ fn wallpaper_vertices( }) } -fn wallpaper_rect( - window_w: f32, - window_h: f32, - image_w: f32, - image_h: f32, - fit: BackgroundImageFit, - alignment: BackgroundImageAlignment, -) -> (f32, f32, f32, f32) { - let window_w = window_w.max(1.0); - let window_h = window_h.max(1.0); - let image_w = image_w.max(1.0); - let image_h = image_h.max(1.0); - - let (draw_w, draw_h) = match fit { - BackgroundImageFit::Fill => (window_w, window_h), - BackgroundImageFit::Uniform => { - let scale = (window_w / image_w).min(window_h / image_h); - (image_w * scale, image_h * scale) - }, - BackgroundImageFit::UniformToFill => { - let scale = (window_w / image_w).max(window_h / image_h); - (image_w * scale, image_h * scale) - }, - BackgroundImageFit::None => (image_w, image_h), - }; - let (align_x, align_y) = alignment.factors(); - let x0 = (window_w - draw_w) * align_x; - let y0 = (window_h - draw_h) * align_y; - (x0, y0, draw_w, draw_h) -} - /// Vertices for an image drawn 1:1 at an explicit pixel rect. fn rect_vertices(window_w: f32, window_h: f32, x: f32, y: f32, w: f32, h: f32) -> [ImageVertex; 6] { quad_vertices(window_w.max(1.0), window_h.max(1.0), x, y, w.max(1.0), h.max(1.0)) diff --git a/nebula_app/src/renderer/image_layout.rs b/nebula_app/src/renderer/image_layout.rs new file mode 100644 index 0000000..a203218 --- /dev/null +++ b/nebula_app/src/renderer/image_layout.rs @@ -0,0 +1,152 @@ +//! Shared wallpaper sizing and alignment, independent of either renderer. + +/// Wallpaper sizing modes. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum BackgroundImageFit { + /// Distort the image to exactly match the window. + Fill, + /// Preserve aspect ratio and keep the entire image visible. + Uniform, + /// Preserve aspect ratio and crop the overflow (CSS `cover`). + #[default] + UniformToFill, + /// Draw at the image's native pixel size. + None, +} + +impl BackgroundImageFit { + pub fn parse(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "fill" | "stretch" => Some(Self::Fill), + "uniform" | "contain" => Some(Self::Uniform), + "uniform_to_fill" | "uniformtofill" | "cover" => Some(Self::UniformToFill), + "none" | "native" => Some(Self::None), + _ => None, + } + } + + pub const fn settings_value(self) -> &'static str { + match self { + Self::Fill => "fill", + Self::Uniform => "uniform", + Self::UniformToFill => "uniform_to_fill", + Self::None => "none", + } + } + + pub const fn next(self) -> Self { + match self { + Self::Fill => Self::Uniform, + Self::Uniform => Self::UniformToFill, + Self::UniformToFill => Self::None, + Self::None => Self::Fill, + } + } +} + +/// Anchor used when the fitted wallpaper is larger or smaller than the window. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum BackgroundImageAlignment { + TopLeft, + Top, + TopRight, + Left, + #[default] + Center, + Right, + BottomLeft, + Bottom, + BottomRight, +} + +impl BackgroundImageAlignment { + pub fn parse(value: &str) -> Option { + match value.trim().to_ascii_lowercase().replace('_', "-").as_str() { + "top-left" => Some(Self::TopLeft), + "top" => Some(Self::Top), + "top-right" => Some(Self::TopRight), + "left" => Some(Self::Left), + "center" | "centre" => Some(Self::Center), + "right" => Some(Self::Right), + "bottom-left" => Some(Self::BottomLeft), + "bottom" => Some(Self::Bottom), + "bottom-right" => Some(Self::BottomRight), + _ => None, + } + } + + pub const fn settings_value(self) -> &'static str { + match self { + Self::TopLeft => "top_left", + Self::Top => "top", + Self::TopRight => "top_right", + Self::Left => "left", + Self::Center => "center", + Self::Right => "right", + Self::BottomLeft => "bottom_left", + Self::Bottom => "bottom", + Self::BottomRight => "bottom_right", + } + } + + pub const fn next(self) -> Self { + match self { + Self::TopLeft => Self::Top, + Self::Top => Self::TopRight, + Self::TopRight => Self::Left, + Self::Left => Self::Center, + Self::Center => Self::Right, + Self::Right => Self::BottomLeft, + Self::BottomLeft => Self::Bottom, + Self::Bottom => Self::BottomRight, + Self::BottomRight => Self::TopLeft, + } + } + + /// 对齐锚点系数(0=贴起始边,0.5=居中,1=贴结束边)。`pub`:GPUI 壳 + /// 的壁纸绘制复用同一套布局数学(gpui_shell::wallpaper)。 + pub const fn factors(self) -> (f32, f32) { + match self { + Self::TopLeft => (0.0, 0.0), + Self::Top => (0.5, 0.0), + Self::TopRight => (1.0, 0.0), + Self::Left => (0.0, 0.5), + Self::Center => (0.5, 0.5), + Self::Right => (1.0, 0.5), + Self::BottomLeft => (0.0, 1.0), + Self::Bottom => (0.5, 1.0), + Self::BottomRight => (1.0, 1.0), + } + } +} + +pub(crate) fn wallpaper_rect( + window_w: f32, + window_h: f32, + image_w: f32, + image_h: f32, + fit: BackgroundImageFit, + alignment: BackgroundImageAlignment, +) -> (f32, f32, f32, f32) { + let window_w = window_w.max(1.0); + let window_h = window_h.max(1.0); + let image_w = image_w.max(1.0); + let image_h = image_h.max(1.0); + + let (draw_w, draw_h) = match fit { + BackgroundImageFit::Fill => (window_w, window_h), + BackgroundImageFit::Uniform => { + let scale = (window_w / image_w).min(window_h / image_h); + (image_w * scale, image_h * scale) + }, + BackgroundImageFit::UniformToFill => { + let scale = (window_w / image_w).max(window_h / image_h); + (image_w * scale, image_h * scale) + }, + BackgroundImageFit::None => (image_w, image_h), + }; + let (align_x, align_y) = alignment.factors(); + let x0 = (window_w - draw_w) * align_x; + let y0 = (window_h - draw_h) * align_y; + (x0, y0, draw_w, draw_h) +} diff --git a/nebula_app/src/renderer/mod.rs b/nebula_app/src/renderer/mod.rs index 99c8d71..d566f76 100644 --- a/nebula_app/src/renderer/mod.rs +++ b/nebula_app/src/renderer/mod.rs @@ -29,6 +29,7 @@ use crate::renderer::shader::ShaderError; use crate::renderer::ui::{UiQuad, UiRenderer}; pub mod image; +mod image_layout; pub(crate) mod math; pub mod platform; pub mod rects; diff --git a/nebula_app/src/runtime_api.rs b/nebula_app/src/runtime_api.rs index aeb8229..bfa7c18 100644 --- a/nebula_app/src/runtime_api.rs +++ b/nebula_app/src/runtime_api.rs @@ -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, diff --git a/nebula_app/src/runtime_api/server.rs b/nebula_app/src/runtime_api/server.rs index 27b2012..f7d6775 100644 --- a/nebula_app/src/runtime_api/server.rs +++ b/nebula_app/src/runtime_api/server.rs @@ -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> = 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) { + 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 { + 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 { + // 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 { 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, + _owner_lock: Option, } impl RuntimeServer { @@ -133,19 +172,33 @@ impl RuntimeServer { } fn spawn_with_sink(sink: EventSink, hub: RuntimeHub) -> Option { - 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) -> Option { + 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)); + } +} diff --git a/nebula_settings/src/lib.rs b/nebula_settings/src/lib.rs index e097f80..5a3204a 100644 --- a/nebula_settings/src/lib.rs +++ b/nebula_settings/src/lib.rs @@ -947,6 +947,10 @@ pub struct RuntimeSettings { pub cursor_shape: Option, pub cursor_blink: Option, pub copy_on_select: bool, + /// GUI override for mouse.focus_follows_mouse in TOML; absent there too means false. + pub focus_follows_mouse: Option, + /// Preserve the existing dimming of inactive split panes unless explicitly disabled. + pub dim_inactive_panes: bool, /// 裸 shell 风险粘贴确认:开 = 换行、提权命令或控制字符先确认;关 = 直接粘贴。 pub multiline_paste_confirm: bool, /// 标签页关闭按钮(叉号)是否渲染:关 = 不渲染,仍可用中键关闭。 @@ -1085,6 +1089,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), @@ -1210,6 +1216,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")); diff --git a/nebula_settings/src/reset.rs b/nebula_settings/src/reset.rs index 2e8c87c..db3267a 100644 --- a/nebula_settings/src/reset.rs +++ b/nebula_settings/src/reset.rs @@ -14,6 +14,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", @@ -116,6 +118,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";