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 @@
+
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 @@
+
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 @@
+
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