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