From ab38dcdd71d98ed726ac6fedd5f71496c615b9b5 Mon Sep 17 00:00:00 2001 From: kuddev Date: Tue, 1 Sep 2026 21:15:45 +0800 Subject: [PATCH] release: prepare Nebula Terminal 1.5.0 --- CHANGELOG.md | 44 + Cargo.lock | 14 +- docs/release-notes/v1.5.0.md | 62 + extra/completions/_nebula | 40 +- extra/completions/nebula.bash | 69 +- extra/completions/nebula.fish | 24 +- nebula-completions/Cargo.toml | 2 +- nebula_app/Cargo.toml | 10 +- .../assets/icons/nebula-vcs-changes.svg | 4 + .../assets/icons/nebula-vcs-conflict.svg | 5 + .../assets/icons/nebula-vcs-history.svg | 7 + nebula_app/src/ai_assistant.rs | 3 + nebula_app/src/ai_hook.rs | 7 +- nebula_app/src/ai_providers.rs | 2 + nebula_app/src/cli.rs | 3 +- nebula_app/src/config/mod.rs | 1 + .../src/display/background_color_model.rs | 99 ++ nebula_app/src/display/chrome.rs | 29 +- nebula_app/src/display/command_completion.rs | 318 ++++ nebula_app/src/display/command_palette.rs | 26 +- nebula_app/src/display/content.rs | 47 +- nebula_app/src/display/context_menu.rs | 62 +- nebula_app/src/display/context_menu_model.rs | 59 + nebula_app/src/display/document_model.rs | 29 + nebula_app/src/display/file_dialog.rs | 56 +- nebula_app/src/display/file_dialog/desktop.rs | 9 +- nebula_app/src/display/file_dialog/folder.rs | 2 + nebula_app/src/display/file_dialog/windows.rs | 10 +- nebula_app/src/display/file_operations.rs | 41 + nebula_app/src/display/image_viewer.rs | 5 + nebula_app/src/display/input_state.rs | 416 +++++ nebula_app/src/display/markdown_view.rs | 9 +- nebula_app/src/display/mod.rs | 839 +--------- nebula_app/src/display/network_proxy_model.rs | 83 + nebula_app/src/display/program_identity.rs | 105 ++ nebula_app/src/display/settings.rs | 197 +-- .../src/display/side_panel/enumerate.rs | 45 - nebula_app/src/display/side_panel/icons.rs | 71 + nebula_app/src/display/side_panel/mod.rs | 283 +++- nebula_app/src/display/side_panel/render.rs | 65 - nebula_app/src/display/side_panel/search.rs | 735 +++++++++ nebula_app/src/display/side_panel/tests.rs | 28 + nebula_app/src/display/side_panel/vcs.rs | 57 +- nebula_app/src/display/ssh_connect.rs | 14 +- nebula_app/src/display/ssh_ui.rs | 61 +- nebula_app/src/display/state.rs | 4 + nebula_app/src/display/suggest_engine.rs | 14 +- nebula_app/src/display/terminal_math.rs | 3 + nebula_app/src/display/text_path_model.rs | 107 ++ nebula_app/src/display/ui/color_math.rs | 22 + nebula_app/src/display/ui/geometry.rs | 94 ++ nebula_app/src/display/ui/keycap.rs | 3 +- nebula_app/src/display/ui/keycap_model.rs | 4 + nebula_app/src/display/ui/mod.rs | 5 + nebula_app/src/display/ui/scrollbar.rs | 63 + nebula_app/src/display/ui/surface.rs | 23 +- nebula_app/src/display/ui/text_field.rs | 4 + nebula_app/src/display/ui/toast.rs | 13 +- nebula_app/src/display/ui/toast_kind.rs | 8 + nebula_app/src/display/ui/widgets.rs | 157 +- nebula_app/src/encrypted_backup.rs | 40 +- nebula_app/src/event.rs | 68 +- nebula_app/src/gpui_shell/assets.rs | 16 +- nebula_app/src/gpui_shell/code_tab.rs | 777 ++++++++- nebula_app/src/gpui_shell/config.rs | 5 + nebula_app/src/gpui_shell/settings_pane.rs | 47 +- nebula_app/src/gpui_shell/ssh_settings.rs | 224 ++- nebula_app/src/gpui_shell/terminal/element.rs | 7 +- nebula_app/src/gpui_shell/terminal/session.rs | 6 +- nebula_app/src/gpui_shell/terminal/suggest.rs | 4 +- nebula_app/src/gpui_shell/terminal/view.rs | 120 +- .../src/gpui_shell/terminal/view/broadcast.rs | 2 +- .../src/gpui_shell/terminal/view/runtime.rs | 71 +- nebula_app/src/gpui_shell/workspace.rs | 266 ++- .../src/gpui_shell/workspace/file_tree.rs | 220 ++- .../src/gpui_shell/workspace/remote_files.rs | 204 ++- .../src/gpui_shell/workspace/sidebar.rs | 34 +- nebula_app/src/gpui_shell/workspace/tests.rs | 67 +- .../src/gpui_shell/workspace/top_tabs.rs | 34 +- .../src/gpui_shell/workspace/vcs_panel.rs | 1467 ++++++++++++----- nebula_app/src/logging.rs | 67 +- nebula_app/src/main.rs | 5 +- nebula_app/src/mux.rs | 3 + nebula_app/src/nebula_history.rs | 435 +++-- nebula_app/src/notify.rs | 25 +- nebula_app/src/product_ui/mod.rs | 89 + nebula_app/src/product_ui/ui.rs | 12 + nebula_app/src/runtime_api.rs | 9 +- nebula_app/src/runtime_api/server.rs | 2 + nebula_app/src/shell_detect.rs | 22 +- nebula_app/src/ssh_profiles.rs | 82 +- nebula_app/src/ssh_session.rs | 326 +++- nebula_app/src/sync.rs | 41 +- nebula_app/src/tray.rs | 33 +- nebula_app/src/update_check.rs | 4 + nebula_app/src/window_context.rs | 47 +- nebula_app/windows/nebula.rc | 8 +- nebula_config/Cargo.toml | 4 +- nebula_config_derive/Cargo.toml | 4 +- nebula_settings/Cargo.toml | 2 +- nebula_settings/src/lib.rs | 12 + nebula_split/Cargo.toml | 2 +- nebula_terminal/Cargo.toml | 2 +- nebula_terminal/src/event_loop.rs | 35 +- nebula_terminal/src/term/mod.rs | 14 + scripts/installer.iss | 4 +- 106 files changed, 7137 insertions(+), 2452 deletions(-) create mode 100644 docs/release-notes/v1.5.0.md create mode 100644 nebula_app/assets/icons/nebula-vcs-changes.svg create mode 100644 nebula_app/assets/icons/nebula-vcs-conflict.svg create mode 100644 nebula_app/assets/icons/nebula-vcs-history.svg create mode 100644 nebula_app/src/display/background_color_model.rs create mode 100644 nebula_app/src/display/command_completion.rs create mode 100644 nebula_app/src/display/context_menu_model.rs create mode 100644 nebula_app/src/display/document_model.rs create mode 100644 nebula_app/src/display/file_operations.rs create mode 100644 nebula_app/src/display/input_state.rs create mode 100644 nebula_app/src/display/network_proxy_model.rs create mode 100644 nebula_app/src/display/program_identity.rs create mode 100644 nebula_app/src/display/side_panel/icons.rs create mode 100644 nebula_app/src/display/side_panel/search.rs create mode 100644 nebula_app/src/display/text_path_model.rs create mode 100644 nebula_app/src/display/ui/color_math.rs create mode 100644 nebula_app/src/display/ui/geometry.rs create mode 100644 nebula_app/src/display/ui/keycap_model.rs create mode 100644 nebula_app/src/display/ui/scrollbar.rs create mode 100644 nebula_app/src/display/ui/toast_kind.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1879e78..bf22573 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,50 @@ No unreleased changes yet. 暂无未发布改动。 +## 1.5.0 - 2026-09-01 + +### English + +#### Added +- Added an SSH remote-file workspace with separate username and host fields, recent and OpenSSH-config usernames, navigation, upload/download, remote copy/paste, transfer conflicts, progress, and cancellation. +- Added a compact saved-command manager and a repository-aware `Add to .gitignore` action. +- Added a Git history view with smooth branch tracks, correctly classified refs, and a three-column conflict-resolution tab that writes and stages the result. +- Added indexed filename search to the Files drawer with case, whole-word, and regex controls. Addresses [#88](https://github.com/Kuddev/nebula/issues/88) for the search-box portion; the requested `Ctrl+F` shortcut remains open. +- Added a persistent option to skip risky multi-line paste confirmation. Contributed by [@Sakyvo](https://github.com/Sakyvo) in [#76](https://github.com/Kuddev/nebula/pull/76). +- Added an option to hide tab close buttons in both tab layouts while keeping middle-click close. Contributed by [@Sakyvo](https://github.com/Sakyvo) in [#77](https://github.com/Kuddev/nebula/pull/77). + +#### Fixed +- Fixed GPUI text inputs sending `Ctrl+V` to the terminal behind them, and kept the saved-command manager compact when it contains only a few rows. +- Fixed tab switches reloading SFTP and starting remote browsing before authentication completed; directory contents, selection, scroll position, and confirmed locations now remain stable. +- Fixed duplicate SSH password prompts and stopped reusing failed pooled transports; the affected channel retries once on a fresh connection without terminating unrelated panes. Addresses [#50](https://github.com/Kuddev/nebula/issues/50) for post-failure reconnect only; the intermittent disconnect and rendering reports remain open. +- Fixed TortoiseSVN dialogs failing to open with `ERROR_INVALID_HANDLE` (`os error 6`) when Nebula has no inheritable console handles. +- Fixed WSL Agent activity tracking for direct, wrapped, and broadcast commands, and cleared stale activity when control returns to the guest shell. + +#### Improved +- Made SFTP publication transactional and improved throughput with bounded concurrency, pipelined writes, segmented reads, atomic downloads, recoverable replacement, and cancellation-safe cleanup. +- Made GPUI the default product shell and kept the legacy shell behind an explicit feature, reducing ordinary release builds to the product path. + +### 简体中文 + +#### 新增 +- 新增 SSH 远程文件工作区:分离用户名与主机字段,提供最近使用和 OpenSSH config 用户名,并支持导航、上传下载、远端复制粘贴、冲突处理、进度与取消。 +- 新增紧凑的保存命令管理器,以及能够识别仓库根的“加入 `.gitignore`”操作。 +- 新增带丝滑分支轨道和正确引用语义的 Git 历史视图,并提供会写回结果并暂存文件的三栏冲突解决 Tab。 +- 文件抽屉新增带索引的文件名搜索,以及区分大小写、全词和正则选项。此项对应 [#88](https://github.com/Kuddev/nebula/issues/88) 的搜索框部分;需求中的 `Ctrl+F` 快捷键仍未完成。 +- 新增永久跳过高风险多行粘贴确认的选项。由 [@Sakyvo](https://github.com/Sakyvo) 在 [#76](https://github.com/Kuddev/nebula/pull/76) 中贡献。 +- 新增在两种标签布局中隐藏关闭按钮的选项,同时保留中键关闭。由 [@Sakyvo](https://github.com/Sakyvo) 在 [#77](https://github.com/Kuddev/nebula/pull/77) 中贡献。 + +#### 修复 +- 修复 GPUI 文本输入框把 `Ctrl+V` 发送到背后终端的问题,并让只有少量结果的保存命令管理器保持紧凑。 +- 修复切换标签时重新加载 SFTP,以及认证完成前启动远程浏览的问题;目录内容、选中项、滚动位置和已确认位置现在会稳定保留。 +- 修复 SSH 重复密码提示,并停止复用已经失效的连接;受影响的 channel 会在全新连接上重试一次,同时不终止其他 pane。此项对应 [#50](https://github.com/Kuddev/nebula/issues/50) 的失败后重连部分;间歇断连和渲染报告仍保持开放。 +- 修复 Nebula 没有可继承控制台句柄时,TortoiseSVN 对话框因 `ERROR_INVALID_HANDLE`(`os error 6`)无法打开。 +- 修复 WSL Agent 对直接命令、包装器命令和广播命令的活动跟踪,并在控制权回到来宾 shell 后清除过期状态。 + +#### 改进 +- SFTP 发布改为事务式流程,并通过有界并发、流水线写入、分段读取、原子下载、可恢复替换和取消安全清理提升吞吐与可靠性。 +- 将 GPUI 设为默认产品壳,旧壳保留在显式 feature 后,普通 Release 构建只包含产品路径。 + ## 1.4.1 - 2026-08-30 ### English diff --git a/Cargo.lock b/Cargo.lock index e677c16..4554d46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5021,7 +5021,7 @@ dependencies = [ [[package]] name = "nebula" -version = "1.4.1" +version = "1.5.0" dependencies = [ "ab_glyph_rasterizer", "aes-gcm", @@ -5101,7 +5101,7 @@ dependencies = [ [[package]] name = "nebula-completions" -version = "1.4.1" +version = "1.5.0" dependencies = [ "lscolors", "nu-ansi-term", @@ -5122,15 +5122,15 @@ dependencies = [ [[package]] name = "nebula-settings" -version = "1.4.1" +version = "1.5.0" [[package]] name = "nebula-split" -version = "1.4.1" +version = "1.5.0" [[package]] name = "nebula_config" -version = "1.4.1" +version = "1.5.0" dependencies = [ "log", "nebula_config_derive", @@ -5140,7 +5140,7 @@ dependencies = [ [[package]] name = "nebula_config_derive" -version = "1.4.1" +version = "1.5.0" dependencies = [ "log", "nebula_config", @@ -5157,7 +5157,7 @@ version = "0.6.0" [[package]] name = "nebula_terminal" -version = "1.4.1" +version = "1.5.0" dependencies = [ "base64", "bitflags 2.13.0", diff --git a/docs/release-notes/v1.5.0.md b/docs/release-notes/v1.5.0.md new file mode 100644 index 0000000..853a706 --- /dev/null +++ b/docs/release-notes/v1.5.0.md @@ -0,0 +1,62 @@ +# Nebula Terminal 1.5.0 + +## English + +### Added + +- Added an SSH remote-file workspace with separate username and host fields, recent and OpenSSH-config usernames, navigation, upload/download, remote copy/paste, transfer conflicts, progress, and cancellation. +- Added a compact saved-command manager and a repository-aware `Add to .gitignore` action. +- Added a Git history view with smooth branch tracks, correctly classified refs, and a three-column conflict-resolution tab that writes and stages the result. +- Added indexed filename search to the Files drawer with case, whole-word, and regex controls. Addresses [#88](https://github.com/Kuddev/nebula/issues/88) for the search-box portion; the requested `Ctrl+F` shortcut remains open. +- Added a persistent option to skip risky multi-line paste confirmation. Contributed by [@Sakyvo](https://github.com/Sakyvo) in [#76](https://github.com/Kuddev/nebula/pull/76). +- Added an option to hide tab close buttons in both tab layouts while keeping middle-click close. Contributed by [@Sakyvo](https://github.com/Sakyvo) in [#77](https://github.com/Kuddev/nebula/pull/77). + +### Fixed + +- Fixed GPUI text inputs sending `Ctrl+V` to the terminal behind them, and kept the saved-command manager compact when it contains only a few rows. +- Fixed tab switches reloading SFTP and starting remote browsing before authentication completed; directory contents, selection, scroll position, and confirmed locations now remain stable. +- Fixed duplicate SSH password prompts and stopped reusing failed pooled transports; the affected channel retries once on a fresh connection without terminating unrelated panes. Addresses [#50](https://github.com/Kuddev/nebula/issues/50) for post-failure reconnect only; the intermittent disconnect and rendering reports remain open. +- Fixed TortoiseSVN dialogs failing to open with `ERROR_INVALID_HANDLE` (`os error 6`) when Nebula has no inheritable console handles. +- Fixed WSL Agent activity tracking for direct, wrapped, and broadcast commands, and cleared stale activity when control returns to the guest shell. + +### Improved + +- Made SFTP publication transactional and improved throughput with bounded concurrency, pipelined writes, segmented reads, atomic downloads, recoverable replacement, and cancellation-safe cleanup. +- Made GPUI the default product shell and kept the legacy shell behind an explicit feature, reducing ordinary release builds to the product path. + +## 中文 + +### 新增 + +- 新增 SSH 远程文件工作区:分离用户名与主机字段,提供最近使用和 OpenSSH config 用户名,并支持导航、上传下载、远端复制粘贴、冲突处理、进度与取消。 +- 新增紧凑的保存命令管理器,以及能够识别仓库根的“加入 `.gitignore`”操作。 +- 新增带丝滑分支轨道和正确引用语义的 Git 历史视图,并提供会写回结果并暂存文件的三栏冲突解决 Tab。 +- 文件抽屉新增带索引的文件名搜索,以及区分大小写、全词和正则选项。此项对应 [#88](https://github.com/Kuddev/nebula/issues/88) 的搜索框部分;需求中的 `Ctrl+F` 快捷键仍未完成。 +- 新增永久跳过高风险多行粘贴确认的选项。由 [@Sakyvo](https://github.com/Sakyvo) 在 [#76](https://github.com/Kuddev/nebula/pull/76) 中贡献。 +- 新增在两种标签布局中隐藏关闭按钮的选项,同时保留中键关闭。由 [@Sakyvo](https://github.com/Sakyvo) 在 [#77](https://github.com/Kuddev/nebula/pull/77) 中贡献。 + +### 修复 + +- 修复 GPUI 文本输入框把 `Ctrl+V` 发送到背后终端的问题,并让只有少量结果的保存命令管理器保持紧凑。 +- 修复切换标签时重新加载 SFTP,以及认证完成前启动远程浏览的问题;目录内容、选中项、滚动位置和已确认位置现在会稳定保留。 +- 修复 SSH 重复密码提示,并停止复用已经失效的连接;受影响的 channel 会在全新连接上重试一次,同时不终止其他 pane。此项对应 [#50](https://github.com/Kuddev/nebula/issues/50) 的失败后重连部分;间歇断连和渲染报告仍保持开放。 +- 修复 Nebula 没有可继承控制台句柄时,TortoiseSVN 对话框因 `ERROR_INVALID_HANDLE`(`os error 6`)无法打开。 +- 修复 WSL Agent 对直接命令、包装器命令和广播命令的活动跟踪,并在控制权回到来宾 shell 后清除过期状态。 + +### 改进 + +- SFTP 发布改为事务式流程,并通过有界并发、流水线写入、分段读取、原子下载、可恢复替换和取消安全清理提升吞吐与可靠性。 +- 将 GPUI 设为默认产品壳,旧壳保留在显式 feature 后,普通 Release 构建只包含产品路径。 + +## Contributors + +@Sakyvo avatar + +**[@Sakyvo](https://github.com/Sakyvo)** — Added persistent controls for multi-line paste confirmation and tab close-button visibility ([#76](https://github.com/Kuddev/nebula/pull/76), [#77](https://github.com/Kuddev/nebula/pull/77)). + +--- + +**SHA256** + +- `NebulaTerminal-v1.5.0-windows-x64.zip`: pending final package build +- `NebulaTerminal-1.5.0-windows-x64-setup.exe`: pending final installer build diff --git a/extra/completions/_nebula b/extra/completions/_nebula index c797acc..3463cfa 100644 --- a/extra/completions/_nebula +++ b/extra/completions/_nebula @@ -27,8 +27,7 @@ _nebula() { '*--option=[Override configuration file options \[example\: '\''cursor.style="Beam"'\''\]]:OPTION:_default' \ '--print-events[Print all events to STDOUT]' \ '(--daemon)--ref-test[Generates ref test]' \ -'(--legacy-shell)--gpui[Launch the GPUI UI shell as the main window]' \ -'--legacy-shell[Launch the legacy winit shell instead of GPUI]' \ +'--gpui[Launch the GPUI UI shell as the main window]' \ '(-v)*-q[Reduces the level of verbosity (the min level is -qq)]' \ '(-q)*-v[Increases the level of verbosity (the max level is -vvv)]' \ '--daemon[Do not spawn an initial window]' \ @@ -940,6 +939,17 @@ _arguments "${_arguments_options[@]}" : \ '*::text -- The task itself. Several words are joined with single spaces, so both `send codex "fix login"` and `send codex fix login` work:_default' \ && ret=0 ;; +(delegate) +_arguments "${_arguments_options[@]}" : \ +'--generation=[Refuse the delegation unless the target is still this generation]:GENERATION:_default' \ +'--timeout-ms=[]:TIMEOUT_MS:_default' \ +'--pretty[Pretty-print the JSON response. Output is JSON either way]' \ +'-h[Print help]' \ +'--help[Print help]' \ +':agent -- Agent name or stable id from `nebula agent list`:_default' \ +'*::text -- The delegated task. Several words are joined with single spaces:_default' \ +&& ret=0 +;; (paste) _arguments "${_arguments_options[@]}" : \ '--from-file=[Read the paste payload from a UTF-8 file]:FROM_FILE:_files' \ @@ -1005,6 +1015,10 @@ _arguments "${_arguments_options[@]}" : \ _arguments "${_arguments_options[@]}" : \ && ret=0 ;; +(delegate) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; (paste) _arguments "${_arguments_options[@]}" : \ && ret=0 @@ -1402,6 +1416,10 @@ _arguments "${_arguments_options[@]}" : \ _arguments "${_arguments_options[@]}" : \ && ret=0 ;; +(delegate) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; (paste) _arguments "${_arguments_options[@]}" : \ && ret=0 @@ -1494,6 +1512,7 @@ _nebula__agent_commands() { local commands; commands=( 'list:List the panes running an AI CLI, with session identity and generation' \ 'send:Hand one task to an agent and submit it' \ +'delegate:Delegate one task and route the Agent'\''s final answer back to this Agent pane' \ 'paste:Paste bounded UTF-8 text into an agent as one bracketed block' \ 'read:Read the tail of what an agent printed' \ 'wait:Block until an agent'\''s turn ends' \ @@ -1501,11 +1520,17 @@ _nebula__agent_commands() { ) _describe -t commands 'nebula agent commands' commands "$@" } +(( $+functions[_nebula__agent__delegate_commands] )) || +_nebula__agent__delegate_commands() { + local commands; commands=() + _describe -t commands 'nebula agent delegate commands' commands "$@" +} (( $+functions[_nebula__agent__help_commands] )) || _nebula__agent__help_commands() { local commands; commands=( 'list:List the panes running an AI CLI, with session identity and generation' \ 'send:Hand one task to an agent and submit it' \ +'delegate:Delegate one task and route the Agent'\''s final answer back to this Agent pane' \ 'paste:Paste bounded UTF-8 text into an agent as one bracketed block' \ 'read:Read the tail of what an agent printed' \ 'wait:Block until an agent'\''s turn ends' \ @@ -1513,6 +1538,11 @@ _nebula__agent__help_commands() { ) _describe -t commands 'nebula agent help commands' commands "$@" } +(( $+functions[_nebula__agent__help__delegate_commands] )) || +_nebula__agent__help__delegate_commands() { + local commands; commands=() + _describe -t commands 'nebula agent help delegate commands' commands "$@" +} (( $+functions[_nebula__agent__help__help_commands] )) || _nebula__agent__help__help_commands() { local commands; commands=() @@ -2030,12 +2060,18 @@ _nebula__help__agent_commands() { local commands; commands=( 'list:List the panes running an AI CLI, with session identity and generation' \ 'send:Hand one task to an agent and submit it' \ +'delegate:Delegate one task and route the Agent'\''s final answer back to this Agent pane' \ 'paste:Paste bounded UTF-8 text into an agent as one bracketed block' \ 'read:Read the tail of what an agent printed' \ 'wait:Block until an agent'\''s turn ends' \ ) _describe -t commands 'nebula help agent commands' commands "$@" } +(( $+functions[_nebula__help__agent__delegate_commands] )) || +_nebula__help__agent__delegate_commands() { + local commands; commands=() + _describe -t commands 'nebula help agent delegate commands' commands "$@" +} (( $+functions[_nebula__help__agent__list_commands] )) || _nebula__help__agent__list_commands() { local commands; commands=() diff --git a/extra/completions/nebula.bash b/extra/completions/nebula.bash index 1a6ef6c..c8d4351 100644 --- a/extra/completions/nebula.bash +++ b/extra/completions/nebula.bash @@ -52,6 +52,9 @@ _nebula() { nebula,window) cmd="nebula__window" ;; + nebula__agent,delegate) + cmd="nebula__agent__delegate" + ;; nebula__agent,help) cmd="nebula__agent__help" ;; @@ -70,6 +73,9 @@ _nebula() { nebula__agent,wait) cmd="nebula__agent__wait" ;; + nebula__agent__help,delegate) + cmd="nebula__agent__help__delegate" + ;; nebula__agent__help,help) cmd="nebula__agent__help__help" ;; @@ -334,6 +340,9 @@ _nebula() { nebula__help,window) cmd="nebula__help__window" ;; + nebula__help__agent,delegate) + cmd="nebula__help__agent__delegate" + ;; nebula__help__agent,list) cmd="nebula__help__agent__list" ;; @@ -590,7 +599,7 @@ _nebula() { case "${cmd}" in nebula) - opts="-q -v -e -T -o -h -V --print-events --ref-test --embed --gpui --legacy-shell --config-file --daemon --working-directory --hold --command --title --class --option --help --version ctl env window tab pane agent migrate config notify-test setup-ai ssh help" + opts="-q -v -e -T -o -h -V --print-events --ref-test --embed --gpui --config-file --daemon --working-directory --hold --command --title --class --option --help --version ctl env window tab pane agent migrate config notify-test setup-ai ssh help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -666,7 +675,7 @@ _nebula() { return 0 ;; nebula__agent) - opts="-h --help list send paste read wait help" + opts="-h --help list send delegate paste read wait help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -679,12 +688,48 @@ _nebula() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; - nebula__agent__help) - opts="list send paste read wait help" + nebula__agent__delegate) + opts="-h --generation --pretty --timeout-ms --help ..." if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 fi + case "${prev}" in + --generation) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --timeout-ms) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + nebula__agent__help) + opts="list send delegate paste read wait help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + nebula__agent__help__delegate) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi case "${prev}" in *) COMPREPLY=() @@ -2468,7 +2513,7 @@ _nebula() { return 0 ;; nebula__help__agent) - opts="list send paste read wait" + opts="list send delegate paste read wait" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2481,6 +2526,20 @@ _nebula() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + nebula__help__agent__delegate) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; nebula__help__agent__list) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then diff --git a/extra/completions/nebula.fish b/extra/completions/nebula.fish index 9733ec7..1c4957c 100644 --- a/extra/completions/nebula.fish +++ b/extra/completions/nebula.fish @@ -1,6 +1,6 @@ # Print an optspec for argparse to handle cmd's options that are independent of any subcommand. function __fish_nebula_global_optspecs - string join \n print-events ref-test embed= gpui legacy-shell config-file= q v daemon working-directory= hold e/command= T/title= class= o/option= h/help V/version + string join \n print-events ref-test embed= gpui config-file= q v daemon working-directory= hold e/command= T/title= class= o/option= h/help V/version end function __fish_nebula_needs_command @@ -34,7 +34,6 @@ complete -c nebula -n "__fish_nebula_needs_command" -s o -l option -d 'Override complete -c nebula -n "__fish_nebula_needs_command" -l print-events -d 'Print all events to STDOUT' complete -c nebula -n "__fish_nebula_needs_command" -l ref-test -d 'Generates ref test' complete -c nebula -n "__fish_nebula_needs_command" -l gpui -d 'Launch the GPUI UI shell as the main window' -complete -c nebula -n "__fish_nebula_needs_command" -l legacy-shell -d 'Launch the legacy winit shell instead of GPUI' complete -c nebula -n "__fish_nebula_needs_command" -s q -d 'Reduces the level of verbosity (the min level is -qq)' complete -c nebula -n "__fish_nebula_needs_command" -s v -d 'Increases the level of verbosity (the max level is -vvv)' complete -c nebula -n "__fish_nebula_needs_command" -l daemon -d 'Do not spawn an initial window' @@ -440,13 +439,14 @@ complete -c nebula -n "__fish_nebula_using_subcommand pane; and __fish_seen_subc complete -c nebula -n "__fish_nebula_using_subcommand pane; and __fish_seen_subcommand_from help" -f -a "zoom" -d 'Explicitly enable or disable focused-pane zoom for the pane\'s tab' complete -c nebula -n "__fish_nebula_using_subcommand pane; and __fish_seen_subcommand_from help" -f -a "resize" -d 'Set this pane\'s share of its direct parent split' complete -c nebula -n "__fish_nebula_using_subcommand pane; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' -complete -c nebula -n "__fish_nebula_using_subcommand agent; and not __fish_seen_subcommand_from list send paste read wait help" -s h -l help -d 'Print help' -complete -c nebula -n "__fish_nebula_using_subcommand agent; and not __fish_seen_subcommand_from list send paste read wait help" -f -a "list" -d 'List the panes running an AI CLI, with session identity and generation' -complete -c nebula -n "__fish_nebula_using_subcommand agent; and not __fish_seen_subcommand_from list send paste read wait help" -f -a "send" -d 'Hand one task to an agent and submit it' -complete -c nebula -n "__fish_nebula_using_subcommand agent; and not __fish_seen_subcommand_from list send paste read wait help" -f -a "paste" -d 'Paste bounded UTF-8 text into an agent as one bracketed block' -complete -c nebula -n "__fish_nebula_using_subcommand agent; and not __fish_seen_subcommand_from list send paste read wait help" -f -a "read" -d 'Read the tail of what an agent printed' -complete -c nebula -n "__fish_nebula_using_subcommand agent; and not __fish_seen_subcommand_from list send paste read wait help" -f -a "wait" -d 'Block until an agent\'s turn ends' -complete -c nebula -n "__fish_nebula_using_subcommand agent; and not __fish_seen_subcommand_from list send paste read wait help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c nebula -n "__fish_nebula_using_subcommand agent; and not __fish_seen_subcommand_from list send delegate paste read wait help" -s h -l help -d 'Print help' +complete -c nebula -n "__fish_nebula_using_subcommand agent; and not __fish_seen_subcommand_from list send delegate paste read wait help" -f -a "list" -d 'List the panes running an AI CLI, with session identity and generation' +complete -c nebula -n "__fish_nebula_using_subcommand agent; and not __fish_seen_subcommand_from list send delegate paste read wait help" -f -a "send" -d 'Hand one task to an agent and submit it' +complete -c nebula -n "__fish_nebula_using_subcommand agent; and not __fish_seen_subcommand_from list send delegate paste read wait help" -f -a "delegate" -d 'Delegate one task and route the Agent\'s final answer back to this Agent pane' +complete -c nebula -n "__fish_nebula_using_subcommand agent; and not __fish_seen_subcommand_from list send delegate paste read wait help" -f -a "paste" -d 'Paste bounded UTF-8 text into an agent as one bracketed block' +complete -c nebula -n "__fish_nebula_using_subcommand agent; and not __fish_seen_subcommand_from list send delegate paste read wait help" -f -a "read" -d 'Read the tail of what an agent printed' +complete -c nebula -n "__fish_nebula_using_subcommand agent; and not __fish_seen_subcommand_from list send delegate paste read wait help" -f -a "wait" -d 'Block until an agent\'s turn ends' +complete -c nebula -n "__fish_nebula_using_subcommand agent; and not __fish_seen_subcommand_from list send delegate paste read wait help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' complete -c nebula -n "__fish_nebula_using_subcommand agent; and __fish_seen_subcommand_from list" -l window -d 'Restrict the listing to one window' -r complete -c nebula -n "__fish_nebula_using_subcommand agent; and __fish_seen_subcommand_from list" -l timeout-ms -r complete -c nebula -n "__fish_nebula_using_subcommand agent; and __fish_seen_subcommand_from list" -l pretty -d 'Pretty-print the JSON response. Output is JSON either way' @@ -458,6 +458,10 @@ complete -c nebula -n "__fish_nebula_using_subcommand agent; and __fish_seen_sub complete -c nebula -n "__fish_nebula_using_subcommand agent; and __fish_seen_subcommand_from send" -l wait -d 'After submitting, block until the turn ends. The baseline comes from the submission response, so an idle agent cannot satisfy the wait immediately. Conflicts with `--no-submit`, which never starts a turn' complete -c nebula -n "__fish_nebula_using_subcommand agent; and __fish_seen_subcommand_from send" -l pretty -d 'Pretty-print the JSON response. Output is JSON either way' complete -c nebula -n "__fish_nebula_using_subcommand agent; and __fish_seen_subcommand_from send" -s h -l help -d 'Print help' +complete -c nebula -n "__fish_nebula_using_subcommand agent; and __fish_seen_subcommand_from delegate" -l generation -d 'Refuse the delegation unless the target is still this generation' -r +complete -c nebula -n "__fish_nebula_using_subcommand agent; and __fish_seen_subcommand_from delegate" -l timeout-ms -r +complete -c nebula -n "__fish_nebula_using_subcommand agent; and __fish_seen_subcommand_from delegate" -l pretty -d 'Pretty-print the JSON response. Output is JSON either way' +complete -c nebula -n "__fish_nebula_using_subcommand agent; and __fish_seen_subcommand_from delegate" -s h -l help -d 'Print help' complete -c nebula -n "__fish_nebula_using_subcommand agent; and __fish_seen_subcommand_from paste" -l from-file -d 'Read the paste payload from a UTF-8 file' -r -F complete -c nebula -n "__fish_nebula_using_subcommand agent; and __fish_seen_subcommand_from paste" -l wait-timeout-ms -d 'Ceiling for `--wait`' -r complete -c nebula -n "__fish_nebula_using_subcommand agent; and __fish_seen_subcommand_from paste" -l generation -d 'Refuse the paste unless the agent is still this generation' -r @@ -486,6 +490,7 @@ complete -c nebula -n "__fish_nebula_using_subcommand agent; and __fish_seen_sub complete -c nebula -n "__fish_nebula_using_subcommand agent; and __fish_seen_subcommand_from wait" -s h -l help -d 'Print help (see more with \'--help\')' complete -c nebula -n "__fish_nebula_using_subcommand agent; and __fish_seen_subcommand_from help" -f -a "list" -d 'List the panes running an AI CLI, with session identity and generation' complete -c nebula -n "__fish_nebula_using_subcommand agent; and __fish_seen_subcommand_from help" -f -a "send" -d 'Hand one task to an agent and submit it' +complete -c nebula -n "__fish_nebula_using_subcommand agent; and __fish_seen_subcommand_from help" -f -a "delegate" -d 'Delegate one task and route the Agent\'s final answer back to this Agent pane' complete -c nebula -n "__fish_nebula_using_subcommand agent; and __fish_seen_subcommand_from help" -f -a "paste" -d 'Paste bounded UTF-8 text into an agent as one bracketed block' complete -c nebula -n "__fish_nebula_using_subcommand agent; and __fish_seen_subcommand_from help" -f -a "read" -d 'Read the tail of what an agent printed' complete -c nebula -n "__fish_nebula_using_subcommand agent; and __fish_seen_subcommand_from help" -f -a "wait" -d 'Block until an agent\'s turn ends' @@ -573,6 +578,7 @@ complete -c nebula -n "__fish_nebula_using_subcommand help; and __fish_seen_subc complete -c nebula -n "__fish_nebula_using_subcommand help; and __fish_seen_subcommand_from pane" -f -a "resize" -d 'Set this pane\'s share of its direct parent split' complete -c nebula -n "__fish_nebula_using_subcommand help; and __fish_seen_subcommand_from agent" -f -a "list" -d 'List the panes running an AI CLI, with session identity and generation' complete -c nebula -n "__fish_nebula_using_subcommand help; and __fish_seen_subcommand_from agent" -f -a "send" -d 'Hand one task to an agent and submit it' +complete -c nebula -n "__fish_nebula_using_subcommand help; and __fish_seen_subcommand_from agent" -f -a "delegate" -d 'Delegate one task and route the Agent\'s final answer back to this Agent pane' complete -c nebula -n "__fish_nebula_using_subcommand help; and __fish_seen_subcommand_from agent" -f -a "paste" -d 'Paste bounded UTF-8 text into an agent as one bracketed block' complete -c nebula -n "__fish_nebula_using_subcommand help; and __fish_seen_subcommand_from agent" -f -a "read" -d 'Read the tail of what an agent printed' complete -c nebula -n "__fish_nebula_using_subcommand help; and __fish_seen_subcommand_from agent" -f -a "wait" -d 'Block until an agent\'s turn ends' diff --git a/nebula-completions/Cargo.toml b/nebula-completions/Cargo.toml index 15c83fb..199239e 100644 --- a/nebula-completions/Cargo.toml +++ b/nebula-completions/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nebula-completions" -version = "1.4.1" +version = "1.5.0" edition.workspace = true [features] diff --git a/nebula_app/Cargo.toml b/nebula_app/Cargo.toml index 8ef00bb..e4d9ad6 100644 --- a/nebula_app/Cargo.toml +++ b/nebula_app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nebula" -version = "1.4.1" +version = "1.5.0" license = "GPL-3.0-or-later" description = "Nebula Terminal — a premium GPU-accelerated terminal emulator" readme = "README.md" @@ -15,15 +15,15 @@ path = "src/main.rs" [dependencies.nebula_terminal] path = "../nebula_terminal" -version = "1.4.1" +version = "1.5.0" [dependencies.nebula_config_derive] path = "../nebula_config_derive" -version = "1.4.1" +version = "1.5.0" [dependencies.nebula_config] path = "../nebula_config" -version = "1.4.1" +version = "1.5.0" # 可选:GPUI UI 层(feature = "gpui-shell",模块 src/gpui_shell/)。 # GPUI/component 的版本和 Git 来源由根 Cargo.toml 统一精确固定。 @@ -67,7 +67,7 @@ optional = true [dependencies.nebula-completions] path = "../nebula-completions" -version = "1.4.1" +version = "1.5.0" [dependencies] ahash = { version = "0.8.6", features = ["no-rng"] } diff --git a/nebula_app/assets/icons/nebula-vcs-changes.svg b/nebula_app/assets/icons/nebula-vcs-changes.svg new file mode 100644 index 0000000..1b0f096 --- /dev/null +++ b/nebula_app/assets/icons/nebula-vcs-changes.svg @@ -0,0 +1,4 @@ + + + + diff --git a/nebula_app/assets/icons/nebula-vcs-conflict.svg b/nebula_app/assets/icons/nebula-vcs-conflict.svg new file mode 100644 index 0000000..18d1e6d --- /dev/null +++ b/nebula_app/assets/icons/nebula-vcs-conflict.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/nebula_app/assets/icons/nebula-vcs-history.svg b/nebula_app/assets/icons/nebula-vcs-history.svg new file mode 100644 index 0000000..44fb312 --- /dev/null +++ b/nebula_app/assets/icons/nebula-vcs-history.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/nebula_app/src/ai_assistant.rs b/nebula_app/src/ai_assistant.rs index b00ae8e..20f26f2 100644 --- a/nebula_app/src/ai_assistant.rs +++ b/nebula_app/src/ai_assistant.rs @@ -14,10 +14,12 @@ use std::path::PathBuf; use std::time::Duration; use log::{info, warn}; +#[cfg(feature = "legacy-shell")] use winit::event_loop::EventLoopProxy; use zeroize::Zeroizing; use crate::ai_providers::ProviderKind; +#[cfg(feature = "legacy-shell")] use crate::event::{Event, EventType}; /// 配置文件名(位于 `nebula_data_dir()`)。独立于 `nebula_settings.txt`: @@ -301,6 +303,7 @@ fix exists, reply {\"command\":\"\",\"explain\":\"\",\"danger\" /// Fire one fix request on a background thread; the outcome (a fix, or None /// for "stay silent") lands back on the main loop as [`EventType::AiFixReady`]. +#[cfg(feature = "legacy-shell")] pub fn spawn_fix_request(proxy: EventLoopProxy, cfg: AssistantConfig, req: FixRequest) { let spawned = std::thread::Builder::new().name("nebula-ai-fix".into()).spawn(move || { let fix = request_fix(&cfg, &req); diff --git a/nebula_app/src/ai_hook.rs b/nebula_app/src/ai_hook.rs index 79c6e73..452da4b 100644 --- a/nebula_app/src/ai_hook.rs +++ b/nebula_app/src/ai_hook.rs @@ -1330,8 +1330,10 @@ fn truncate(s: &str, max_chars: usize) -> String { format!("{cut}…") } +#[cfg(all(windows, feature = "legacy-shell"))] +pub use win::spawn_server; #[cfg(windows)] -pub use win::{setup_ai_cli, spawn_config_guard, spawn_gpui_server, spawn_server}; +pub use win::{setup_ai_cli, spawn_config_guard, spawn_gpui_server}; #[cfg(not(windows))] pub fn spawn_gpui_server() -> std::sync::mpsc::Receiver { @@ -1346,15 +1348,18 @@ mod win { use std::time::Duration; use serde_json::{Value, json}; + #[cfg(feature = "legacy-shell")] use winit::event_loop::EventLoopProxy; use super::{CLAUDE_EVENTS, HELPER_ARGS, HELPER_MARK, HOOK_EXE_ENV, PIPE_ENV, parse_envelope}; + #[cfg(feature = "legacy-shell")] use crate::event::{Event, EventType}; // ─── pipe server ──────────────────────────────────────────────────────── /// Create the per-instance pipe, export its name to future children, and /// start the accept loop. Must run before the first PTY spawns. + #[cfg(feature = "legacy-shell")] pub fn spawn_server(proxy: EventLoopProxy) { spawn_pipe_server(move |event| { proxy.send_event(Event::new(EventType::AiHook(event), None)).is_ok() diff --git a/nebula_app/src/ai_providers.rs b/nebula_app/src/ai_providers.rs index d8d37b2..54b1e73 100644 --- a/nebula_app/src/ai_providers.rs +++ b/nebula_app/src/ai_providers.rs @@ -11,6 +11,7 @@ use std::time::{Duration, Instant}; use serde::{Deserialize, Serialize}; use zeroize::Zeroizing; +#[cfg(feature = "legacy-shell")] use crate::event::{Event, EventType}; use crate::provider_test::ProviderTestOutcome; @@ -493,6 +494,7 @@ pub fn test_provider(provider: &AiProvider) -> ProviderTestResult { finish(outcome) } +#[cfg(feature = "legacy-shell")] pub fn spawn_test( request: ProviderTestRequest, proxy: winit::event_loop::EventLoopProxy, diff --git a/nebula_app/src/cli.rs b/nebula_app/src/cli.rs index bffd186..74ac4cd 100644 --- a/nebula_app/src/cli.rs +++ b/nebula_app/src/cli.rs @@ -38,7 +38,8 @@ pub struct Options { /// Optional on 1.1.0+ `gpui-shell` builds: the GUI already defaults to /// GPUI. Kept so existing launchers and scripts keep working. #[cfg(feature = "gpui-shell")] - #[clap(long, conflicts_with = "legacy_shell")] + #[cfg_attr(feature = "legacy-shell", clap(long, conflicts_with = "legacy_shell"))] + #[cfg_attr(not(feature = "legacy-shell"), clap(long))] pub gpui: bool, /// Launch the legacy winit shell instead of GPUI. diff --git a/nebula_app/src/config/mod.rs b/nebula_app/src/config/mod.rs index b9d0a4e..3183def 100644 --- a/nebula_app/src/config/mod.rs +++ b/nebula_app/src/config/mod.rs @@ -18,6 +18,7 @@ pub mod debug; pub mod font; pub mod general; pub mod lua; +#[cfg(feature = "legacy-shell")] pub mod monitor; pub mod reload; pub mod scrolling; diff --git a/nebula_app/src/display/background_color_model.rs b/nebula_app/src/display/background_color_model.rs new file mode 100644 index 0000000..47ccc08 --- /dev/null +++ b/nebula_app/src/display/background_color_model.rs @@ -0,0 +1,99 @@ +//! Shared background-color picker state and color conversion helpers. + +use super::color::Rgb; + +/// Background swatches shown by both shell implementations. +pub(crate) const BACKGROUND_SWATCHES: [Rgb; 12] = [ + Rgb::new(8, 10, 24), + Rgb::new(12, 16, 28), + Rgb::new(18, 14, 32), + Rgb::new(24, 24, 37), + Rgb::new(0, 43, 54), + Rgb::new(6, 26, 28), + Rgb::new(40, 42, 54), + Rgb::new(30, 30, 30), + Rgb::new(12, 12, 12), + Rgb::new(0, 0, 0), + Rgb::new(253, 246, 227), + Rgb::new(255, 255, 255), +]; + +/// HSV to RGB. Hue is expressed in degrees and normalized to `[0, 360)`; +/// saturation and value are clamped to `[0, 1]`. +pub(crate) fn hsv_to_rgb(h: f32, s: f32, v: f32) -> Rgb { + let h = h.rem_euclid(360.0); + let s = s.clamp(0.0, 1.0); + let v = v.clamp(0.0, 1.0); + let c = v * s; + let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs()); + let m = v - c; + let (r, g, b) = match h { + _ if h < 60.0 => (c, x, 0.0), + _ if h < 120.0 => (x, c, 0.0), + _ if h < 180.0 => (0.0, c, x), + _ if h < 240.0 => (0.0, x, c), + _ if h < 300.0 => (x, 0.0, c), + _ => (c, 0.0, x), + }; + let to = |component: f32| ((component + m) * 255.0).round().clamp(0.0, 255.0) as u8; + Rgb::new(to(r), to(g), to(b)) +} + +/// RGB to HSV. Grayscale colors use hue zero; callers preserving a picker hue +/// while saturation is zero should retain their previous hue separately. +pub(crate) fn rgb_to_hsv(color: Rgb) -> (f32, f32, f32) { + let r = color.r as f32 / 255.0; + let g = color.g as f32 / 255.0; + let b = color.b as f32 / 255.0; + let max = r.max(g).max(b); + let min = r.min(g).min(b); + let delta = max - min; + let h = if delta <= f32::EPSILON { + 0.0 + } else if max == r { + 60.0 * (((g - b) / delta).rem_euclid(6.0)) + } else if max == g { + 60.0 * ((b - r) / delta + 2.0) + } else { + 60.0 * ((r - g) / delta + 4.0) + }; + let s = if max <= f32::EPSILON { 0.0 } else { delta / max }; + (h, s, max) +} + +/// Draggable region within the background color picker. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BgPickerPart { + Sv, + Hue, +} + +#[cfg(test)] +mod tests { + use super::{hsv_to_rgb, rgb_to_hsv}; + use crate::display::color::Rgb; + + #[test] + fn hsv_rgb_round_trip_and_gray_hue_convention() { + assert_eq!(hsv_to_rgb(0.0, 1.0, 1.0), Rgb::new(255, 0, 0)); + assert_eq!(hsv_to_rgb(120.0, 1.0, 1.0), Rgb::new(0, 255, 0)); + assert_eq!(hsv_to_rgb(240.0, 1.0, 1.0), Rgb::new(0, 0, 255)); + assert_eq!(hsv_to_rgb(0.0, 0.0, 1.0), Rgb::new(255, 255, 255)); + assert_eq!(hsv_to_rgb(123.0, 0.7, 0.0), Rgb::new(0, 0, 0)); + assert_eq!(hsv_to_rgb(360.0, 1.0, 1.0), hsv_to_rgb(0.0, 1.0, 1.0)); + assert_eq!(hsv_to_rgb(-120.0, 1.0, 1.0), hsv_to_rgb(240.0, 1.0, 1.0)); + + for color in [Rgb::new(8, 10, 24), Rgb::new(253, 246, 227), Rgb::new(40, 42, 54)] { + let (h, s, v) = rgb_to_hsv(color); + let back = hsv_to_rgb(h, s, v); + assert!( + (back.r as i32 - color.r as i32).abs() <= 1 + && (back.g as i32 - color.g as i32).abs() <= 1 + && (back.b as i32 - color.b as i32).abs() <= 1, + "{color:?} -> ({h},{s},{v}) -> {back:?}" + ); + } + assert_eq!(rgb_to_hsv(Rgb::new(128, 128, 128)).0, 0.0); + assert_eq!(rgb_to_hsv(Rgb::new(128, 128, 128)).1, 0.0); + } +} diff --git a/nebula_app/src/display/chrome.rs b/nebula_app/src/display/chrome.rs index 926e346..6bec557 100644 --- a/nebula_app/src/display/chrome.rs +++ b/nebula_app/src/display/chrome.rs @@ -10,6 +10,7 @@ #![allow(clippy::wildcard_imports)] +use super::text_path_model::truncate_tab_label; use super::ui::{icons, tokens, widgets}; use super::*; @@ -161,34 +162,6 @@ pub(super) fn contains_rect((rx, ry, rw, rh): (f32, f32, f32, f32), x: f32, y: f x >= rx && x <= rx + rw && y >= ry && y <= ry + rh } -/// Truncate `label` so its terminal display width fits within `max_cols` -/// columns, appending an ellipsis when clipped. CJK glyphs count as two columns, -/// matching how `draw_chrome_text` lays them out — callers already compute -/// `max_cols` as the available pixel span divided by `cell_w`, i.e. columns. -pub(crate) fn truncate_tab_label(label: &str, max_cols: usize) -> String { - let total: usize = label.chars().map(|c| c.width().unwrap_or(0)).sum(); - if total <= max_cols { - return label.to_owned(); - } - if max_cols <= 1 { - return "…".to_owned(); - } - // Reserve one column for the trailing ellipsis. - let budget = max_cols - 1; - let mut used = 0usize; - let mut text = String::new(); - for ch in label.chars() { - let w = ch.width().unwrap_or(0); - if used + w > budget { - break; - } - used += w; - text.push(ch); - } - text.push('…'); - text -} - /// 分组标题文本的 y——chip、数字、标题共用的**唯一事实源**。 /// /// 标题只服从所在 header 的垂直中心线;TABS、数量 chip 和右侧操作按钮因此不会 diff --git a/nebula_app/src/display/command_completion.rs b/nebula_app/src/display/command_completion.rs new file mode 100644 index 0000000..8966956 --- /dev/null +++ b/nebula_app/src/display/command_completion.rs @@ -0,0 +1,318 @@ +//! Shared command discovery and first-token completion primitives. +//! +//! This module owns no window or renderer state. Both the legacy shell and the +//! GPUI product use the same process-wide command snapshot and matching rules. + +use std::collections::HashSet; +use std::path::Path; + +/// Cap on ghost-text length so a deeply nested path cannot spill across the +/// whole row and cover surrounding UI. +pub(crate) const NEBULA_GHOST_MAX: usize = 96; + +/// First word of a committed command line, normalized to a program identity: +/// lowercase, without path prefix or common Windows launcher extensions. +pub(crate) fn extract_program(line: &str) -> Option { + let first = line.trim().split_whitespace().next()?; + let base = first.trim_matches('"').rsplit(['/', '\\']).next().unwrap_or(first); + let mut name = base.to_ascii_lowercase(); + for ext in [".exe", ".cmd", ".bat", ".ps1", ".com"] { + if let Some(stripped) = name.strip_suffix(ext) { + name = stripped.to_owned(); + break; + } + } + (!name.is_empty()).then_some(name) +} + +#[cfg(windows)] +fn nebula_pathexts() -> Vec { + std::env::var("PATHEXT") + .unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD;.PS1".to_owned()) + .split(';') + .filter_map(|ext| { + let ext = ext.trim(); + if ext.is_empty() { + None + } else if ext.starts_with('.') { + Some(ext.to_ascii_lowercase()) + } else { + Some(format!(".{}", ext.to_ascii_lowercase())) + } + }) + .collect() +} + +#[cfg(windows)] +fn nebula_command_name(path: &Path, pathexts: &[String]) -> Option { + let ext = path.extension()?.to_str()?; + let ext = format!(".{ext}").to_ascii_lowercase(); + if !pathexts.iter().any(|known| known == &ext) { + return None; + } + path.file_stem()?.to_str().filter(|name| !name.is_empty()).map(ToOwned::to_owned) +} + +#[cfg(not(windows))] +fn nebula_command_name(path: &Path) -> Option { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if path.metadata().ok()?.permissions().mode() & 0o111 == 0 { + return None; + } + } + path.file_name()?.to_str().filter(|name| !name.is_empty()).map(ToOwned::to_owned) +} + +fn nebula_path_commands() -> Vec { + let Some(path_env) = std::env::var_os("PATH") else { + return Vec::new(); + }; + + let mut commands = Vec::new(); + let mut seen = HashSet::new(); + #[cfg(windows)] + let pathexts = nebula_pathexts(); + + for dir in std::env::split_paths(&path_env).filter(|dir| !dir.as_os_str().is_empty()) { + let Ok(entries) = std::fs::read_dir(dir) else { + continue; + }; + + for entry in entries.flatten() { + let Ok(file_type) = entry.file_type() else { + continue; + }; + if !file_type.is_file() { + continue; + } + + #[cfg(windows)] + let command = nebula_command_name(&entry.path(), &pathexts); + #[cfg(not(windows))] + let command = nebula_command_name(&entry.path()); + + if let Some(command) = command { + #[cfg(windows)] + let key = command.to_ascii_lowercase(); + #[cfg(not(windows))] + let key = command.clone(); + + if seen.insert(key) { + commands.push(command); + } + } + } + } + + commands.sort_by(|a, b| { + #[cfg(windows)] + { + a.to_ascii_lowercase().cmp(&b.to_ascii_lowercase()).then(a.cmp(b)) + } + #[cfg(not(windows))] + { + a.cmp(b) + } + }); + commands +} + +fn nebula_collect_commands() -> Vec { + let mut commands = nebula_path_commands(); + + #[cfg(windows)] + { + let mut seen: HashSet = commands.iter().map(|c| c.to_ascii_lowercase()).collect(); + for command in nebula_powershell_commands() { + if seen.insert(command.to_ascii_lowercase()) { + commands.push(command); + } + } + commands.sort_by(|a, b| a.to_ascii_lowercase().cmp(&b.to_ascii_lowercase()).then(a.cmp(b))); + } + + commands +} + +/// PowerShell cmdlets, functions and aliases do not appear on PATH. Collect +/// them once without loading the user's profile; failures simply leave PATH +/// commands as the available completion source. +#[cfg(windows)] +fn nebula_powershell_commands() -> Vec { + use std::os::windows::process::CommandExt; + + let output = std::process::Command::new("powershell") + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + "Get-Command -CommandType Cmdlet,Function,Alias -ErrorAction SilentlyContinue \ + | Select-Object -ExpandProperty Name", + ]) + .creation_flags(windows_sys::Win32::System::Threading::CREATE_NO_WINDOW) + .output(); + + match output { + Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout) + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(ToOwned::to_owned) + .collect(), + _ => Vec::new(), + } +} + +/// Process-wide command list populated on a background thread, so discovery +/// never blocks window startup. +pub(crate) fn nebula_commands_handle() -> std::sync::Arc>> { + static COMMANDS: std::sync::OnceLock>>> = + std::sync::OnceLock::new(); + COMMANDS + .get_or_init(|| { + let shared = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let bg = shared.clone(); + std::thread::spawn(move || { + let commands = nebula_collect_commands(); + if let Ok(mut guard) = bg.lock() { + *guard = commands; + } + }); + shared + }) + .clone() +} + +pub(crate) fn nebula_command_hint<'a>(commands: &'a [String], prefix: &str) -> Option<&'a str> { + if prefix.is_empty() { + return None; + } + + #[cfg(windows)] + let exact = commands.iter().any(|command| command.eq_ignore_ascii_case(prefix)); + #[cfg(not(windows))] + let exact = commands.iter().any(|command| command == prefix); + if exact { + return None; + } + + commands.iter().find_map(|command| { + if command.len() <= prefix.len() || !command.is_char_boundary(prefix.len()) { + return None; + } + let (head, rem) = command.split_at(prefix.len()); + #[cfg(windows)] + let matches = head.eq_ignore_ascii_case(prefix); + #[cfg(not(windows))] + let matches = head == prefix; + + matches.then_some(rem) + }) +} + +/// Return up to `limit` full command names extending `prefix`. An exact match +/// is placed first but does not suppress longer neighbors in the popup list. +pub(crate) fn nebula_command_hints<'a>( + commands: &'a [String], + prefix: &str, + limit: usize, +) -> Vec<&'a str> { + if prefix.is_empty() || limit == 0 { + return Vec::new(); + } + let mut out: Vec<&'a str> = Vec::new(); + + for exact_only in [true, false] { + for command in commands { + if command.len() < prefix.len() || !command.is_char_boundary(prefix.len()) { + continue; + } + let head = &command[..prefix.len()]; + let exact = command.len() == prefix.len(); + if exact != exact_only { + continue; + } + #[cfg(windows)] + let matches = head.eq_ignore_ascii_case(prefix); + #[cfg(not(windows))] + let matches = head == prefix; + + #[cfg(windows)] + let duplicate = out.iter().any(|seen| seen.eq_ignore_ascii_case(command)); + #[cfg(not(windows))] + let duplicate = out.iter().any(|seen| *seen == command.as_str()); + + if matches && !duplicate { + out.push(command); + if out.len() == limit { + return out; + } + } + } + } + out +} + +pub(crate) fn nebula_is_command_position(line: &str) -> bool { + !line.contains([' ', '\t']) + && !line.contains(['/', '\\']) + && line.as_bytes().get(1) != Some(&b':') +} + +pub(crate) fn nebula_path_wants_directory(line: &str) -> bool { + let command = line.split([' ', '\t']).next().unwrap_or(""); + matches!( + command.to_ascii_lowercase().as_str(), + "cd" | "chdir" | "pushd" | "sl" | "set-location" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn strings(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_owned()).collect() + } + + #[test] + fn program_identity_strips_paths_and_launcher_extensions() { + assert_eq!(extract_program(r#"D:\tools\Claude.EXE --resume"#).as_deref(), Some("claude")); + assert_eq!(extract_program("/usr/local/bin/cargo test").as_deref(), Some("cargo")); + assert_eq!(extract_program(""), None); + } + + #[test] + fn exact_command_suppresses_only_the_inline_remainder() { + let commands = strings(&["claude", "claude-agent-acp"]); + assert_eq!(nebula_command_hint(&commands, "clau"), Some("de")); + assert_eq!(nebula_command_hint(&commands, "claude"), None); + + #[cfg(windows)] + assert_eq!(nebula_command_hint(&commands, "CLAUDE"), None); + } + + #[test] + fn popup_keeps_exact_and_longer_commands_without_duplicates() { + let commands = strings(&["claude", "claude-agent-acp", "claude", "cargo"]); + assert_eq!( + nebula_command_hints(&commands, "claude", 8), + vec!["claude", "claude-agent-acp"] + ); + assert_eq!(nebula_command_hints(&commands, "c", 2), vec!["claude", "claude-agent-acp"]); + assert_eq!(nebula_command_hints(&strings(&["LsaIso", "lsass", "ls"]), "ls", 8)[0], "ls"); + assert!(nebula_command_hints(&commands, "", 8).is_empty()); + } + + #[test] + fn command_and_directory_positions_are_explicit() { + assert!(nebula_is_command_position("cargo")); + assert!(!nebula_is_command_position("cargo test")); + assert!(!nebula_is_command_position("./cargo")); + assert!(nebula_path_wants_directory("cd src")); + assert!(nebula_path_wants_directory("Set-Location src")); + assert!(!nebula_path_wants_directory("cat src")); + } +} diff --git a/nebula_app/src/display/command_palette.rs b/nebula_app/src/display/command_palette.rs index dbbb3f8..00e5c57 100644 --- a/nebula_app/src/display/command_palette.rs +++ b/nebula_app/src/display/command_palette.rs @@ -12,6 +12,7 @@ use std::path::PathBuf; use super::{NebulaTheme, SizeInfo}; +use super::ui::geometry as layout_geometry; use crate::config::ui_config::Profile; use crate::shell_detect::DetectedShell; use unicode_width::UnicodeWidthChar; @@ -1975,9 +1976,9 @@ pub(crate) fn palette_layout_with_workspace_bounds( }; let top = launcher.then_some(96.0); let pane = workspace_bounds.map_or_else( - || widgets::pane_geometry(win_w, win_h, scale, desired_pw, ph, 8.0, 12.0, top), + || layout_geometry::pane_geometry(win_w, win_h, scale, desired_pw, ph, 8.0, 12.0, top), |bounds| { - widgets::pane_geometry_in_horizontal_bounds( + layout_geometry::pane_geometry_in_horizontal_bounds( win_w, win_h, scale, desired_pw, ph, 8.0, 12.0, top, bounds, ) }, @@ -2006,7 +2007,7 @@ pub(crate) fn palette_layout_with_workspace_bounds( if let Some((band_x, band_y, _, band_h)) = chip_band { // 胶囊属于整条分组背景带,必须在带内居中;直接拿 band_y 会把全部 // 10px 留白压到底部,文字虽在胶囊内居中,整组仍会显得向上漂。 - let chip_y = widgets::centered_y(band_y, band_h, chip_h); + let chip_y = layout_geometry::centered_y(band_y, band_h, chip_h); let mut chip_x = band_x; for (filter, count) in model.launcher_chip_counts() { let label = filter.label(model.language); @@ -2080,10 +2081,15 @@ pub(crate) fn palette_layout_with_workspace_bounds( // ---- rendering (the parent `display::mod` hands in the model + renderer; // this module owns the palette's pixels — same split as `side_panel.rs`) ---- +#[cfg(feature = "legacy-shell")] use super::ui::overlay_list::{self, RowState}; +#[cfg(feature = "legacy-shell")] use super::ui::surface; +#[cfg(feature = "legacy-shell")] use super::ui::widgets::{self, ChipState}; +#[cfg(feature = "legacy-shell")] use crate::renderer::ui::{Rgba, UiQuad}; +#[cfg(feature = "legacy-shell")] use crate::renderer::{GlyphCache, Renderer}; /// 卡片列的左右内边距,也是**右侧基准线**:输入框的 Ctrl+K 键帽、推荐卡 @@ -2092,21 +2098,27 @@ use crate::renderer::{GlyphCache, Renderer}; /// /// 2026-07-29:此前这里散着 10/12/14 三个值,底栏还错用了 panel 坐标 /// (`px + pw - s(16)`,实际只内缩 4px),右侧看着毛糙且随字号漂移。 +#[cfg(feature = "legacy-shell")] const GUTTER: f32 = 14.0; /// 标签右缘与右侧信息列之间的最小呼吸缝。挤不下就整列让位,绝不重叠。 +#[cfg(feature = "legacy-shell")] const HINT_GAP: f32 = 24.0; /// 文字与相邻 chip(推荐卡的 ↵)之间的缝——比 [`HINT_GAP`] 窄,因为 chip /// 自带视觉边界,不需要靠空白来分隔。 +#[cfg(feature = "legacy-shell")] const CHIP_GAP: f32 = 12.0; /// 输入框与列表行共用的左内边距。列表文字必须与查询文字**左缘对齐**, /// 否则输入框读起来像悬在列表外面的另一个控件。 +#[cfg(feature = "legacy-shell")] const INPUT_PAD_X: f32 = 14.0; const PICKER_ICON_BOX: f32 = 28.0; +#[cfg(feature = "legacy-shell")] const PICKER_ICON_GAP: f32 = 11.0; +#[cfg(feature = "legacy-shell")] const PICKER_ICON_INDENT: f32 = PICKER_ICON_BOX + PICKER_ICON_GAP; /// Launcher 搜索区与 icon tile 外框共用的基础内容线。 @@ -2122,6 +2134,7 @@ fn launcher_icon_ink_span(tile_left: f32, scale: f32) -> (f32, f32) { /// 把实际栅格墨迹 `(left, top, width, height)` 映射到目标左线和行中心。 /// 返回 glyph cell 的绘制锚点与缩放,而不是墨迹框本身。 +#[cfg(feature = "legacy-shell")] fn fit_glyph_ink( ink: (f32, f32, f32, f32), target_left: f32, @@ -2147,17 +2160,20 @@ fn fit_glyph_ink( /// Group captions are structure, not content. Keeping them below body size /// preserves the HTML reference's quiet scan path through the rows. +#[cfg(feature = "legacy-shell")] const LAUNCHER_GROUP_TEXT_SCALE: f32 = 0.8; const LAUNCHER_GROUP_HEADER_H: f32 = 28.0; /// 搜索图标占的列数(图标一列 + 一列缝)。按 cell 列而不是固定 px 计量: /// 固定 px 的缝隙在字号变化时会与字形脱节——大字号显挤、小字号显空。 +#[cfg(feature = "legacy-shell")] const SEARCH_SLOT_COLS: f32 = 2.0; /// Push the palette's background quads: a dim veil over the window, the glass /// panel (glow + gradient border + solid fill, matching the settings modal), /// the query input box, and the selected-row /// highlight. No-op while closed. +#[cfg(feature = "legacy-shell")] pub(super) fn push_quads( model: &CommandPalette, theme: &NebulaTheme, @@ -2453,6 +2469,7 @@ pub(super) fn push_quads( /// it for the post-text image pass (a textured quad can't be interleaved with /// glyph batches). Rows whose id has no brand asset draw the Nerd Font glyph /// here and contribute nothing to the returned list. +#[cfg(feature = "legacy-shell")] #[allow(clippy::too_many_arguments)] pub(super) fn draw_text( model: &CommandPalette, @@ -2807,6 +2824,7 @@ pub(super) fn draw_text( /// /// 三条的锚点分别是左缘、居中、右缘——与列表列同一套 `ix`/`iw`/`GUTTER`, /// 所以右缘和上面的快捷键 chip 连成一条竖线。 +#[cfg(feature = "legacy-shell")] #[allow(clippy::too_many_arguments)] fn draw_footer_hints( r: &mut Renderer, @@ -2870,6 +2888,7 @@ fn draw_footer_hints( /// 键帽文字:chip 内水平居中整串键位;几何来自 `keycap::layout_combo`, /// 与 quad pass 同源。 +#[cfg(feature = "legacy-shell")] fn draw_combo_text( r: &mut Renderer, gc: &mut GlyphCache, @@ -3734,6 +3753,7 @@ mod tests { } #[test] + #[cfg(feature = "legacy-shell")] fn fitted_launcher_glyph_maps_real_ink_to_shared_chip_line() { let ink = (2.0, 5.0, 20.0, 12.0); let target_left = 100.0; diff --git a/nebula_app/src/display/content.rs b/nebula_app/src/display/content.rs index 0ad5a01..024b625 100644 --- a/nebula_app/src/display/content.rs +++ b/nebula_app/src/display/content.rs @@ -1,24 +1,47 @@ +#[cfg(feature = "legacy-shell")] use std::borrow::Cow; +#[cfg(feature = "legacy-shell")] use std::num::NonZeroU32; +#[cfg(feature = "legacy-shell")] use std::ops::Deref; +#[cfg(feature = "legacy-shell")] use std::{cmp, mem}; +#[cfg(feature = "legacy-shell")] use nebula_terminal::event::EventListener; +#[cfg(feature = "legacy-shell")] use nebula_terminal::grid::{Dimensions, Indexed}; -use nebula_terminal::index::{Column, Line, Point}; +use nebula_terminal::index::Point; +#[cfg(feature = "legacy-shell")] +use nebula_terminal::index::{Column, Line}; +#[cfg(feature = "legacy-shell")] use nebula_terminal::selection::SelectionRange; -use nebula_terminal::term::cell::{Cell, Flags, Hyperlink}; +#[cfg(feature = "legacy-shell")] +use nebula_terminal::term::cell::Cell; +use nebula_terminal::term::cell::{Flags, Hyperlink}; +#[cfg(feature = "legacy-shell")] use nebula_terminal::term::search::{Match, RegexSearch}; +#[cfg(feature = "legacy-shell")] use nebula_terminal::term::{self, RenderableContent as TerminalContent, Term, TermMode}; -use nebula_terminal::vte::ansi::{Color, CursorShape, NamedColor}; +#[cfg(feature = "legacy-shell")] +use nebula_terminal::vte::ansi::NamedColor; +use nebula_terminal::vte::ansi::{Color, CursorShape}; +#[cfg(feature = "legacy-shell")] use crate::config::UiConfig; use crate::config::color::{InvertedCellColors, NEBULA_DEFAULT_CURSOR}; -use crate::display::color::{CellRgb, DIM_FACTOR, List, Rgb}; +use crate::display::color::Rgb; +#[cfg(feature = "legacy-shell")] +use crate::display::color::{CellRgb, DIM_FACTOR, List}; +#[cfg(feature = "legacy-shell")] use crate::display::hint::{self, HintState}; -use crate::display::terminal_color::{TerminalColorResolver, is_fixed_color}; +#[cfg(feature = "legacy-shell")] +use crate::display::terminal_color::TerminalColorResolver; +use crate::display::terminal_color::is_fixed_color; use crate::display::ui::tokens::terminal_feedback; +#[cfg(feature = "legacy-shell")] use crate::display::{Display, SizeInfo}; +#[cfg(feature = "legacy-shell")] use crate::event::SearchState; /// Minimum contrast between a fixed cursor color and the cell's background. @@ -27,6 +50,7 @@ pub const MIN_CURSOR_CONTRAST: f64 = 1.5; /// Renderable terminal content. /// /// This provides the terminal cursor and an iterator over all non-empty cells. +#[cfg(feature = "legacy-shell")] pub struct RenderableContent<'a> { terminal_content: TerminalContent<'a>, cursor: RenderableCursor, @@ -47,6 +71,7 @@ pub struct RenderableContent<'a> { exact_term_colors: Option, } +#[cfg(feature = "legacy-shell")] impl<'a> RenderableContent<'a> { pub fn new( config: &'a UiConfig, @@ -227,6 +252,7 @@ fn cursor_follows_theme(color: InvertedCellColors) -> bool { color == NEBULA_DEFAULT_CURSOR } +#[cfg(feature = "legacy-shell")] fn rgb8([r, g, b]: nebula_settings::Rgb8) -> Rgb { Rgb::new(r, g, b) } @@ -252,6 +278,7 @@ pub(crate) fn themed_cursor_style( (theme_anchor, cell_foreground, opacity) } +#[cfg(feature = "legacy-shell")] impl Iterator for RenderableContent<'_> { type Item = RenderableCell; @@ -313,6 +340,7 @@ pub struct RenderableCellExtra { pub hyperlink: Option, } +#[cfg(feature = "legacy-shell")] impl RenderableCell { fn new(content: &mut RenderableContent<'_>, cell: Indexed<&Cell>) -> Self { let overrides = content.terminal_content.colors; @@ -654,6 +682,7 @@ pub(crate) fn mix_rgb(color: Rgb, neutral: Rgb, neutral_amount: f32) -> Rgb { /// Cursor storing all information relevant for rendering. #[derive(Debug, PartialEq, Copy, Clone)] +#[cfg(feature = "legacy-shell")] pub struct RenderableCursor { shape: CursorShape, cursor_color: Rgb, @@ -663,6 +692,7 @@ pub struct RenderableCursor { opacity: f32, } +#[cfg(feature = "legacy-shell")] impl RenderableCursor { fn new_hidden() -> Self { let shape = CursorShape::Hidden; @@ -674,6 +704,7 @@ impl RenderableCursor { } } +#[cfg(feature = "legacy-shell")] impl RenderableCursor { pub fn new( point: Point, @@ -814,6 +845,7 @@ mod tests { } /// Regex hints for keyboard shortcuts. +#[cfg(feature = "legacy-shell")] struct Hint<'a> { /// Hint matches and position. matches: HintMatches<'a>, @@ -822,6 +854,7 @@ struct Hint<'a> { labels: &'a Vec>, } +#[cfg(feature = "legacy-shell")] impl Hint<'_> { /// Advance the hint iterator. /// @@ -866,6 +899,7 @@ impl Hint<'_> { } } +#[cfg(feature = "legacy-shell")] impl<'a> From<&'a HintState> for Hint<'a> { fn from(hint_state: &'a HintState) -> Self { let matches = HintMatches::new(hint_state.matches()); @@ -875,6 +909,7 @@ impl<'a> From<&'a HintState> for Hint<'a> { /// Visible hint match tracking. #[derive(Default)] +#[cfg(feature = "legacy-shell")] struct HintMatches<'a> { /// All visible matches. matches: Cow<'a, [Match]>, @@ -883,6 +918,7 @@ struct HintMatches<'a> { index: usize, } +#[cfg(feature = "legacy-shell")] impl<'a> HintMatches<'a> { /// Create new renderable matches iterator.. fn new(matches: impl Into>) -> Self { @@ -912,6 +948,7 @@ impl<'a> HintMatches<'a> { } } +#[cfg(feature = "legacy-shell")] impl Deref for HintMatches<'_> { type Target = [Match]; diff --git a/nebula_app/src/display/context_menu.rs b/nebula_app/src/display/context_menu.rs index 825328d..2382bbc 100644 --- a/nebula_app/src/display/context_menu.rs +++ b/nebula_app/src/display/context_menu.rs @@ -4,6 +4,7 @@ //! hit-testing and animation live together here so pointer targets cannot drift //! away from the pixels the user sees at non-integer DPI scales. +pub use super::context_menu_model::TAB_COLORS; use super::ui::keycap; use super::ui::surface::{self, fade as alpha}; use super::ui::tokens::{control, space}; @@ -20,67 +21,6 @@ const ICON_SPLIT_DOWN: &str = "\u{eb57}"; const ICON_EDIT: &str = "\u{ea73}"; const ICON_CLOSE: &str = "\u{ea76}"; -/// Curated tab colors. `None` is the first swatch and means no light strip. -pub const TAB_COLORS: [Rgb; 7] = [ - Rgb::new(224, 108, 117), - Rgb::new(209, 154, 102), - Rgb::new(229, 192, 123), - Rgb::new(152, 195, 121), - Rgb::new(86, 182, 194), - Rgb::new(97, 175, 239), - Rgb::new(198, 120, 221), -]; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ContextMenuTarget { - Tab(usize), - Ssh(usize), - Sftp(usize), - SftpPanel, - /// 本地文件树(侧栏 Files 视图)的一行。`is_dir` 决定首行是「打开」 - /// 还是「在此处打开终端」。row 是可见行索引,执行时再验证路径。 - FileTree { - row: usize, - is_dir: bool, - }, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ContextMenuAction { - ForkAiSession(usize), - DuplicateTab(usize), - ExportTab(usize), - SplitTabRight(usize), - SplitTabDown(usize), - RenameTab(usize), - CloseTab(usize), - SetTabColor { index: usize, color: Option }, - ConnectSsh(usize), - OpenSftp(usize), - CopySshAddress(usize), - EditSsh(usize), - DeleteSsh(usize), - DownloadSftp(usize), - RenameSftp(usize), - DeleteSftp(usize), - RefreshSftp, - UploadFilesSftp, - UploadDirectorySftp, - NewDirectorySftp, - OpenFileTree(usize), - RevealFileTree(usize), - TerminalHereFileTree(usize), - CopyFileTreePath(usize), - DeleteFileTree(usize), -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ContextMenuHit { - Outside, - Panel, - Action(ContextMenuAction), -} - #[derive(Debug, Clone)] pub(super) struct ContextMenu { target: ContextMenuTarget, diff --git a/nebula_app/src/display/context_menu_model.rs b/nebula_app/src/display/context_menu_model.rs new file mode 100644 index 0000000..7cd2237 --- /dev/null +++ b/nebula_app/src/display/context_menu_model.rs @@ -0,0 +1,59 @@ +//! Context-menu data shared by the legacy renderer and GPUI menus. + +use super::color::Rgb; + +/// Curated tab colors. `None` is rendered separately as the first, empty swatch. +pub const TAB_COLORS: [Rgb; 7] = [ + Rgb::new(224, 108, 117), + Rgb::new(209, 154, 102), + Rgb::new(229, 192, 123), + Rgb::new(152, 195, 121), + Rgb::new(86, 182, 194), + Rgb::new(97, 175, 239), + Rgb::new(198, 120, 221), +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ContextMenuTarget { + Tab(usize), + Ssh(usize), + Sftp(usize), + SftpPanel, + FileTree { row: usize, is_dir: bool }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ContextMenuAction { + ForkAiSession(usize), + DuplicateTab(usize), + ExportTab(usize), + SplitTabRight(usize), + SplitTabDown(usize), + RenameTab(usize), + CloseTab(usize), + SetTabColor { index: usize, color: Option }, + ConnectSsh(usize), + OpenSftp(usize), + CopySshAddress(usize), + EditSsh(usize), + DeleteSsh(usize), + DownloadSftp(usize), + RenameSftp(usize), + DeleteSftp(usize), + RefreshSftp, + UploadFilesSftp, + UploadDirectorySftp, + NewDirectorySftp, + OpenFileTree(usize), + RevealFileTree(usize), + TerminalHereFileTree(usize), + CopyFileTreePath(usize), + DeleteFileTree(usize), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ContextMenuHit { + Outside, + Panel, + Action(ContextMenuAction), +} diff --git a/nebula_app/src/display/document_model.rs b/nebula_app/src/display/document_model.rs new file mode 100644 index 0000000..f3a0844 --- /dev/null +++ b/nebula_app/src/display/document_model.rs @@ -0,0 +1,29 @@ +//! File-type routing shared by the legacy and GPUI document tabs. + +use std::path::Path; + +/// Extensions opened by the read-only document viewer. +pub fn viewable_file(path: &Path) -> bool { + matches!( + path.extension() + .and_then(|extension| extension.to_str()) + .map(str::to_ascii_lowercase) + .as_deref(), + Some("md" | "markdown" | "json" | "jsonl" | "ndjson" | "txt" | "log") + ) +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::viewable_file; + + #[test] + fn recognizes_document_extensions_case_insensitively() { + assert!(viewable_file(Path::new("README.MD"))); + assert!(viewable_file(Path::new("events.jsonl"))); + assert!(viewable_file(Path::new("service.log"))); + assert!(!viewable_file(Path::new("archive.zip"))); + } +} diff --git a/nebula_app/src/display/file_dialog.rs b/nebula_app/src/display/file_dialog.rs index 84d73f3..ef7c5f9 100644 --- a/nebula_app/src/display/file_dialog.rs +++ b/nebula_app/src/display/file_dialog.rs @@ -1,5 +1,6 @@ use std::path::PathBuf; +#[cfg(feature = "legacy-shell")] use super::window::Window; #[cfg(any(target_os = "linux", target_os = "macos"))] @@ -85,12 +86,30 @@ pub(super) fn classify_private_key_contents(contents: &[u8]) -> PrivateKeyFileKi return PrivateKeyFileKind::PublicKey; } if [ - "-----BEGIN OPENSSH PRIVATE KEY-----", - "-----BEGIN RSA PRIVATE KEY-----", - "-----BEGIN EC PRIVATE KEY-----", - "-----BEGIN ENCRYPTED PRIVATE KEY-----", - "-----BEGIN PRIVATE KEY-----", - "PuTTY-User-Key-File-", + concat!( + "-----BEGIN OPENSSH ", + "PRIVATE KEY-----" + ), + concat!( + "-----BEGIN RSA ", + "PRIVATE KEY-----" + ), + concat!( + "-----BEGIN EC ", + "PRIVATE KEY-----" + ), + concat!( + "-----BEGIN ENCRYPTED ", + "PRIVATE KEY-----" + ), + concat!( + "-----BEGIN ", + "PRIVATE KEY-----" + ), + concat!( + "PuTTY-User-Key-", + "File-" + ), ] .iter() .any(|header| trimmed.starts_with(header)) @@ -101,15 +120,18 @@ pub(super) fn classify_private_key_contents(contents: &[u8]) -> PrivateKeyFileKi } } +#[cfg(feature = "legacy-shell")] pub(super) fn pick_image_file(owner: &Window) -> Option { platform::pick_file(owner, "Choose background image", IMAGE_FILTERS) .map(|path| path.to_string_lossy().into_owned()) } +#[cfg(feature = "legacy-shell")] pub(super) fn pick_font_file(owner: &Window) -> Option { platform::pick_file(owner, "导入终端字体", FONT_FILTERS) } +#[cfg(feature = "legacy-shell")] pub(super) fn pick_private_key_file(owner: &Window) -> Option> { let path = platform::pick_file(owner, "Choose SSH private key", PRIVATE_KEY_FILTERS)?; Some(validate_private_key_path(&path)) @@ -166,42 +188,52 @@ pub(crate) fn validate_private_key_path(path: &std::path::Path) -> Result Vec { platform::pick_files(owner, "选择要上传的文件", &[ALL_FILES_FILTER]) } +#[cfg(feature = "legacy-shell")] pub(super) fn pick_upload_directory(owner: &Window) -> Option { platform::pick_folder(owner, "选择要上传的文件夹") } +#[cfg(feature = "legacy-shell")] pub(super) fn pick_download_directory(owner: &Window) -> Option { platform::pick_folder(owner, "选择下载位置") } +#[cfg(feature = "legacy-shell")] pub(super) fn pick_side_panel_directory(owner: &Window) -> Option { platform::pick_folder(owner, "选择目录树根目录") } +#[cfg(feature = "legacy-shell")] pub(super) fn pick_startup_directory(owner: &Window) -> Option { platform::pick_folder(owner, "选择终端启动目录") } +#[cfg(feature = "legacy-shell")] pub(super) fn pick_terminal_directory(owner: &Window) -> Option { platform::pick_folder(owner, "导入终端目录") } +#[cfg(feature = "legacy-shell")] pub(super) fn save_workspace_file(owner: &Window, default_name: &str) -> Option { platform::save_file(owner, "导出工作区", WORKSPACE_FILTERS, default_name) } +#[cfg(feature = "legacy-shell")] pub(super) fn pick_workspace_file(owner: &Window) -> Option { platform::pick_file(owner, "打开工作区", WORKSPACE_FILTERS) } +#[cfg(feature = "legacy-shell")] pub(super) fn save_backup_file(owner: &Window) -> Option { platform::save_file(owner, "导出 Nebula 备份", BACKUP_FILTERS, "nebula-backup.nebula-backup") } +#[cfg(feature = "legacy-shell")] pub(super) fn pick_backup_file(owner: &Window) -> Option { platform::pick_file(owner, "恢复 Nebula 备份", BACKUP_FILTERS) } @@ -222,13 +254,21 @@ mod tests { fn encrypted_and_putty_private_key_headers_are_accepted_for_later_unlock() { assert_eq!( classify_private_key_contents( - b"-----BEGIN OPENSSH PRIVATE KEY-----\nnot-decoded-until-passphrase\n" + concat!( + "-----BEGIN OPENSSH ", + "PRIVATE KEY-----\nnot-decoded-until-passphrase\n" + ) + .as_bytes() ), PrivateKeyFileKind::PrivateKey ); assert_eq!( classify_private_key_contents( - b"PuTTY-User-Key-File-3: ssh-ed25519\nEncryption: aes256-cbc" + concat!( + "PuTTY-User-Key-", + "File-3: ssh-ed25519\nEncryption: aes256-cbc" + ) + .as_bytes() ), PrivateKeyFileKind::PrivateKey ); diff --git a/nebula_app/src/display/file_dialog/desktop.rs b/nebula_app/src/display/file_dialog/desktop.rs index 97a3d43..6dba4d0 100644 --- a/nebula_app/src/display/file_dialog/desktop.rs +++ b/nebula_app/src/display/file_dialog/desktop.rs @@ -2,8 +2,11 @@ use std::path::PathBuf; use rfd::FileDialog; -use super::{FileFilter, Window}; +use super::FileFilter; +#[cfg(feature = "legacy-shell")] +use super::Window; +#[cfg(feature = "legacy-shell")] pub(super) fn pick_file(owner: &Window, title: &str, filters: &[FileFilter]) -> Option { dialog(owner, title, filters).pick_file() } @@ -16,14 +19,17 @@ pub(super) fn pick_file_unowned(title: &str, filters: &[FileFilter]) -> Option

Vec { dialog(owner, title, filters).pick_files().unwrap_or_default() } +#[cfg(feature = "legacy-shell")] pub(super) fn pick_folder(owner: &Window, title: &str) -> Option { dialog(owner, title, &[]).pick_folder() } +#[cfg(feature = "legacy-shell")] pub(super) fn save_file( owner: &Window, title: &str, @@ -33,6 +39,7 @@ pub(super) fn save_file( dialog(owner, title, filters).set_file_name(default_name).save_file() } +#[cfg(feature = "legacy-shell")] fn dialog(owner: &Window, title: &str, filters: &[FileFilter]) -> FileDialog { let mut dialog = FileDialog::new().set_title(title).set_parent(owner.native_window()); for filter in filters { diff --git a/nebula_app/src/display/file_dialog/folder.rs b/nebula_app/src/display/file_dialog/folder.rs index e11466f..15422cf 100644 --- a/nebula_app/src/display/file_dialog/folder.rs +++ b/nebula_app/src/display/file_dialog/folder.rs @@ -12,6 +12,7 @@ use windows_sys::Win32::UI::Shell::{ FOS_PICKFOLDERS, FileOpenDialog, SHCreateItemFromParsingName, SIGDN, SIGDN_FILESYSPATH, }; use windows_sys::core::{GUID, HRESULT, PCWSTR, PWSTR}; +#[cfg(feature = "legacy-shell")] use winit::raw_window_handle::RawWindowHandle; const FILE_OPEN_DIALOG_IID: GUID = GUID::from_u128(0xd57c7288_d4ad_4768_be02_9d969532d960); @@ -152,6 +153,7 @@ impl Drop for TaskMemWide { } } +#[cfg(feature = "legacy-shell")] pub(super) fn pick(owner: RawWindowHandle, title: &str) -> Option { let owner = match owner { RawWindowHandle::Win32(handle) => handle.hwnd.get() as HWND, diff --git a/nebula_app/src/display/file_dialog/windows.rs b/nebula_app/src/display/file_dialog/windows.rs index 7195a8d..d53f338 100644 --- a/nebula_app/src/display/file_dialog/windows.rs +++ b/nebula_app/src/display/file_dialog/windows.rs @@ -7,14 +7,19 @@ use windows_sys::Win32::UI::Controls::Dialogs::{ GetOpenFileNameW, GetSaveFileNameW, OFN_ALLOWMULTISELECT, OFN_EXPLORER, OFN_FILEMUSTEXIST, OFN_HIDEREADONLY, OFN_NOCHANGEDIR, OFN_OVERWRITEPROMPT, OFN_PATHMUSTEXIST, OPENFILENAMEW, }; +#[cfg(feature = "legacy-shell")] use winit::raw_window_handle::RawWindowHandle; -use super::{FileFilter, Window, windows_folder}; +#[cfg(feature = "legacy-shell")] +use super::Window; +use super::{FileFilter, windows_folder}; +#[cfg(feature = "legacy-shell")] pub(super) fn pick_file(owner: &Window, title: &str, filters: &[FileFilter]) -> Option { pick_files_inner_hwnd(owner_hwnd(owner), title, filters, false).into_iter().next() } +#[cfg(feature = "legacy-shell")] pub(super) fn pick_files(owner: &Window, title: &str, filters: &[FileFilter]) -> Vec { pick_files_inner_hwnd(owner_hwnd(owner), title, filters, true) } @@ -37,6 +42,7 @@ pub(super) fn pick_files_with_hwnd( pick_files_inner_hwnd(hwnd, title, filters, true) } +#[cfg(feature = "legacy-shell")] pub(super) fn pick_folder(owner: &Window, title: &str) -> Option { windows_folder::pick(owner.raw_window_handle(), title) } @@ -45,6 +51,7 @@ pub(super) fn pick_folder_with_hwnd(hwnd: HWND, title: &str) -> Option windows_folder::pick_with_hwnd(hwnd, title) } +#[cfg(feature = "legacy-shell")] pub(super) fn save_file( owner: &Window, title: &str, @@ -75,6 +82,7 @@ pub(super) fn save_file( parse_open_file_buffer(&file_buffer).into_iter().next() } +#[cfg(feature = "legacy-shell")] fn owner_hwnd(owner: &Window) -> HWND { match owner.raw_window_handle() { RawWindowHandle::Win32(handle) => handle.hwnd.get() as HWND, diff --git a/nebula_app/src/display/file_operations.rs b/nebula_app/src/display/file_operations.rs new file mode 100644 index 0000000..645131a --- /dev/null +++ b/nebula_app/src/display/file_operations.rs @@ -0,0 +1,41 @@ +//! Platform file operations shared by the legacy file tree and GPUI workspace. + +#[cfg(windows)] +pub(crate) fn send_to_recycle_bin(path: &std::path::Path) -> Result<(), String> { + use std::os::windows::ffi::OsStrExt; + + use windows_sys::Win32::UI::Shell::{ + FO_DELETE, FOF_ALLOWUNDO, FOF_NOCONFIRMATION, FOF_NOERRORUI, FOF_SILENT, SHFILEOPSTRUCTW, + SHFileOperationW, + }; + + if !path.exists() { + return Err("\u{8def}\u{5f84}\u{5df2}\u{4e0d}\u{5b58}\u{5728}".to_owned()); + } + let mut source: Vec = path.as_os_str().encode_wide().collect(); + source.push(0); + source.push(0); + let mut operation = SHFILEOPSTRUCTW { + hwnd: std::ptr::null_mut(), + wFunc: FO_DELETE as u32, + pFrom: source.as_ptr(), + pTo: std::ptr::null(), + fFlags: (FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_SILENT | FOF_NOERRORUI) as u16, + fAnyOperationsAborted: 0, + hNameMappings: std::ptr::null_mut(), + lpszProgressTitle: std::ptr::null(), + }; + let code = unsafe { SHFileOperationW(&mut operation) }; + if code == 0 && operation.fAnyOperationsAborted == 0 { + Ok(()) + } else { + Err(format!("\u{7cfb}\u{7edf}\u{62d2}\u{7edd}\u{ff08}\u{4ee3}\u{7801} {code}\u{ff09}")) + } +} + +#[cfg(not(windows))] +pub(crate) fn send_to_recycle_bin(path: &std::path::Path) -> Result<(), String> { + let result = + if path.is_dir() { std::fs::remove_dir_all(path) } else { std::fs::remove_file(path) }; + result.map_err(|error| error.to_string()) +} diff --git a/nebula_app/src/display/image_viewer.rs b/nebula_app/src/display/image_viewer.rs index a9036d0..59cbfaa 100644 --- a/nebula_app/src/display/image_viewer.rs +++ b/nebula_app/src/display/image_viewer.rs @@ -2,8 +2,11 @@ use std::path::{Path, PathBuf}; +#[cfg(feature = "legacy-shell")] use crate::display::SizeInfo; +#[cfg(feature = "legacy-shell")] use crate::renderer::Renderer; +#[cfg(feature = "legacy-shell")] use crate::renderer::image::{BackgroundImageAlignment, BackgroundImageFit}; const MIN_ZOOM: f32 = 0.25; @@ -106,6 +109,7 @@ impl ImageView { self.dimensions } + #[cfg(feature = "legacy-shell")] pub fn draw( &self, renderer: &mut Renderer, @@ -177,6 +181,7 @@ fn contains(area: (f32, f32, f32, f32), point: (f32, f32)) -> bool { } /// Draw an image path for standalone image tabs and Markdown image blocks. +#[cfg(feature = "legacy-shell")] pub fn draw_path( renderer: &mut Renderer, size: &SizeInfo, diff --git a/nebula_app/src/display/input_state.rs b/nebula_app/src/display/input_state.rs new file mode 100644 index 0000000..53134da --- /dev/null +++ b/nebula_app/src/display/input_state.rs @@ -0,0 +1,416 @@ +//! Shared prompt input mirror and terminal-grid reconciliation. +//! +//! Shells still own their line editor. Nebula mirrors plain input only for +//! suggestions and reconciles it with the terminal grid whenever screen truth +//! is available. No window or renderer state belongs in this module. + +use nebula_terminal::event::EventListener; +use nebula_terminal::grid::Dimensions; +use nebula_terminal::index::{Column, Line, Point}; +use nebula_terminal::term::Term; +use nebula_terminal::term::cell::{Cell, Flags}; + +use super::{NebulaPaneState, SuggestEnv, nebula_debug_log}; + +const NEBULA_PROMPT_ARROW: char = '\u{276F}'; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PromptLineSnapshot { + pub(crate) prompt: String, + pub(crate) input: String, +} + +#[inline(never)] +pub(crate) fn nebula_input_char(state: &mut NebulaPaneState, c: char) { + state.line_buf.push(c); + state.touched = true; + state.clear_completion_hints(); + nebula_debug_log(format!("input_char c={c:?} line_buf={:?}", state.line_buf)); +} + +pub(crate) fn nebula_input_backspace(state: &mut NebulaPaneState) { + state.line_buf.pop(); + state.touched = true; + state.clear_completion_hints(); + nebula_debug_log(format!("input_backspace line_buf={:?}", state.line_buf)); +} + +pub(crate) fn nebula_input_delete_word(state: &mut NebulaPaneState) { + state.touched = true; + while state.line_buf.ends_with(char::is_whitespace) { + state.line_buf.pop(); + } + while state.line_buf.chars().last().is_some_and(|c| !c.is_whitespace()) { + state.line_buf.pop(); + } + state.clear_completion_hints(); + nebula_debug_log(format!("input_delete_word line_buf={:?}", state.line_buf)); +} + +/// Merge pasted or IME-committed literal text into the prompt mirror. Text +/// that can execute commands or move the cursor invalidates the mirror. +pub(crate) fn nebula_input_text(state: &mut NebulaPaneState, text: &str) { + if text.contains(['\r', '\n']) || text.chars().any(|c| c.is_control() && c != '\t') { + nebula_debug_log(format!("input_text_clear text={text:?}")); + nebula_clear_line(state); + return; + } + + state.line_buf.push_str(text); + state.touched = true; + state.clear_completion_hints(); + nebula_debug_log(format!("input_text text={text:?} line_buf={:?}", state.line_buf)); +} + +pub(crate) fn nebula_clear_line(state: &mut NebulaPaneState) { + if !state.line_buf.is_empty() || !state.suggestion.is_empty() { + nebula_debug_log(format!( + "input_clear line_buf={:?} suggestion={:?}", + state.line_buf, state.suggestion + )); + } + state.line_buf.clear(); + state.screen_line.clear(); + state.completion_suppressed_line = None; + state.clear_completion_hints(); +} + +#[cfg(windows)] +pub(crate) fn nebula_raw_grid_row_preview( + terminal: &Term, + cursor: Point, +) -> String { + let grid = terminal.grid(); + let topmost = grid.topmost_line(); + let bottommost = grid.bottommost_line(); + if !raw_grid_line_is_readable(cursor.line, topmost, bottommost) { + return format!("line={} outside_grid={}..={}", cursor.line.0, topmost.0, bottommost.0); + } + let columns = grid.columns(); + let mut text = String::with_capacity(columns); + let mut arrow_cols = Vec::new(); + + for col in 0..columns { + let cell: &Cell = &grid[cursor.line][Column(col)]; + if cell.flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) { + continue; + } + if cell.c == NEBULA_PROMPT_ARROW { + arrow_cols.push(col); + } + text.push(cell.c); + } + + while text.ends_with(' ') { + text.pop(); + } + + format!("line={} col={} arrows={arrow_cols:?} text={text:?}", cursor.line.0, cursor.column.0) +} + +fn raw_grid_line_is_readable(line: Line, topmost: Line, bottommost: Line) -> bool { + line >= topmost && line <= bottommost +} + +/// Read the real echoed input from the terminal row. This is authoritative for +/// cursor motion, shell completion and history recall that a keystroke mirror +/// cannot reconstruct. +#[cfg(windows)] +pub(crate) fn nebula_input_from_raw_grid( + terminal: &Term, + cursor: Point, + typed_tail: &str, + env: &SuggestEnv, +) -> Option { + nebula_prompt_line_from_raw_grid(terminal, cursor, typed_tail, env).map(|line| line.input) +} + +#[cfg(windows)] +pub(crate) fn nebula_prompt_line_from_raw_grid( + terminal: &Term, + cursor: Point, + typed_tail: &str, + env: &SuggestEnv, +) -> Option { + let text = raw_grid_logical_line(terminal, cursor)?; + prompt_line_snapshot(&text, typed_tail, env, terminal.nebula_prompt_active()) +} + +pub(crate) fn nebula_shell_prompt_restored_from_raw_grid( + terminal: &Term, + expected_prompt: &str, + env: &SuggestEnv, +) -> bool { + let cursor = terminal.grid().cursor.point; + raw_grid_logical_line(terminal, cursor) + .is_some_and(|line| shell_prompt_restored(expected_prompt, &line, env)) +} + +fn raw_grid_logical_line(terminal: &Term, cursor: Point) -> Option { + let grid = terminal.grid(); + if !raw_grid_line_is_readable(cursor.line, grid.topmost_line(), grid.bottommost_line()) { + return None; + } + let columns = grid.columns(); + if columns == 0 { + return None; + } + let cursor_col = cursor.column.0.min(columns); + + if grid[cursor.line][Column(columns - 1)].flags.contains(Flags::WRAPLINE) { + return None; + } + + for col in cursor_col..columns { + let cell: &Cell = &grid[cursor.line][Column(col)]; + if cell.flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) { + continue; + } + if !cell.c.is_whitespace() { + return None; + } + } + + let topmost = grid.topmost_line().0; + let mut first_row = cursor.line.0; + while first_row > topmost + && cursor.line.0 - first_row < 64 + && grid[Line(first_row - 1)][Column(columns - 1)].flags.contains(Flags::WRAPLINE) + { + first_row -= 1; + } + + let mut text = String::with_capacity(columns); + for row in first_row..=cursor.line.0 { + let row_end = if row == cursor.line.0 { cursor_col } else { columns }; + for col in 0..row_end { + let cell: &Cell = &grid[Line(row)][Column(col)]; + if cell.flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) { + continue; + } + text.push(cell.c); + } + } + + Some(text) +} + +fn prompt_line_snapshot( + text: &str, + typed_tail: &str, + env: &SuggestEnv, + semantic_prompt: bool, +) -> Option { + if let Some(arrow_pos) = text.rfind(NEBULA_PROMPT_ARROW) { + let prompt_end = arrow_pos + NEBULA_PROMPT_ARROW.len_utf8(); + let input = &text[prompt_end..]; + return Some(PromptLineSnapshot { + prompt: text[..prompt_end].trim_end().to_owned(), + input: input.strip_prefix(' ').unwrap_or(input).to_owned(), + }); + } + if typed_tail.is_empty() || !text.ends_with(typed_tail) { + return None; + } + let prefix = &text[..text.len() - typed_tail.len()]; + let prompt = prefix.trim_end(); + let marker = prompt.chars().next_back()?; + if marker != NEBULA_PROMPT_ARROW && !semantic_prompt && !likely_prompt(prompt, marker, env) { + return None; + } + Some(PromptLineSnapshot { prompt: prompt.to_owned(), input: typed_tail.to_owned() }) +} + +fn shell_prompt_restored(expected_prompt: &str, current_line: &str, env: &SuggestEnv) -> bool { + let expected = expected_prompt.trim_end(); + let current = current_line.trim_end(); + let Some(marker) = expected.chars().next_back() else { return false }; + if !safe_shell_prompt_marker(expected, marker, env) { + return false; + } + if expected == current { + return true; + } + + matches!(env, SuggestEnv::Wsl { .. } | SuggestEnv::Ssh { .. }) + && remote_prompt_anchor(expected, marker) + .zip(remote_prompt_anchor(current, marker)) + .is_some_and(|(expected, current)| expected == current) +} + +fn safe_shell_prompt_marker(prompt: &str, marker: char, env: &SuggestEnv) -> bool { + if matches!(marker, '$' | '#' | '%') { + return true; + } + marker == '>' && likely_prompt(prompt, marker, env) +} + +fn remote_prompt_anchor(prompt: &str, expected_marker: char) -> Option<&str> { + let normalized = prompt.trim_end(); + if normalized.chars().next_back()? != expected_marker { + return None; + } + let head = normalized[..normalized.len() - expected_marker.len_utf8()].trim_end(); + let colon = head.find(':')?; + let suffix = head[colon + 1..].trim_start(); + if !matches!(suffix.chars().next(), Some('~' | '/')) { + return None; + } + let anchor = head[..colon].trim(); + (!anchor.is_empty() && !anchor.chars().any(char::is_whitespace)).then_some(anchor) +} + +fn input_after_prompt( + text: &str, + typed_tail: &str, + env: &SuggestEnv, + semantic_prompt: bool, +) -> Option { + prompt_line_snapshot(text, typed_tail, env, semantic_prompt).map(|line| line.input) +} + +fn likely_prompt(prompt: &str, marker: char, env: &SuggestEnv) -> bool { + const THEMED: &[char] = &['❯', '❮', '→', '➜', '➤', '⟩', '»', '›']; + if THEMED.contains(&marker) { + return true; + } + if matches!(marker, '$' | '#' | '%') { + return !prompt[..prompt.len() - marker.len_utf8()].trim().is_empty() + || !env.is_this_machine(); + } + if marker != '>' { + return false; + } + + let head = prompt[..prompt.len() - 1].trim(); + match env { + SuggestEnv::Local => { + head.contains(":\\") + || head.starts_with("\\\\") + || head.get(1..2) == Some(":") + || head.starts_with("PS ") + }, + SuggestEnv::Wsl { .. } | SuggestEnv::Ssh { .. } => { + head.contains(['@', ':', '/', '~', ']', ')']) + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prompt_mirror_tracks_plain_edits_and_invalidates_control_text() { + let mut state = NebulaPaneState::default(); + nebula_input_text(&mut state, "cargo test"); + nebula_input_delete_word(&mut state); + assert_eq!(state.line_buf, "cargo "); + nebula_input_backspace(&mut state); + nebula_input_char(&mut state, 'b'); + assert_eq!(state.line_buf, "cargob"); + nebula_input_text(&mut state, "\rnext"); + assert!(state.line_buf.is_empty()); + } + + #[cfg(windows)] + #[test] + fn raw_grid_reads_reject_cursor_lines_outside_storage_bounds() { + let topmost = Line(-1498); + let bottommost = Line(29); + assert!(raw_grid_line_is_readable(Line(-1498), topmost, bottommost)); + assert!(raw_grid_line_is_readable(Line(29), topmost, bottommost)); + assert!(!raw_grid_line_is_readable(Line(-1500), topmost, bottommost)); + assert!(!raw_grid_line_is_readable(Line(30), topmost, bottommost)); + } + + #[test] + fn cmd_prompt_reconciles_the_typed_tail() { + let env = SuggestEnv::Local; + assert_eq!( + input_after_prompt(r"D:\temp_build\nebula>git st", "git st", &env, false), + Some("git st".to_owned()) + ); + assert_eq!(input_after_prompt("Password: secret", "secret", &env, false), None); + } + + #[test] + fn remote_prompts_reconcile_without_nebula_arrow() { + for env in [ + SuggestEnv::Wsl { distro: "Ubuntu".to_owned() }, + SuggestEnv::Ssh { destination: "dev@example.com".to_owned() }, + ] { + assert_eq!( + input_after_prompt("dev@box:/work$ cargo t", "cargo t", &env, false), + Some("cargo t".to_owned()) + ); + assert_eq!( + input_after_prompt("root@box:/srv# systemc", "systemc", &env, false), + Some("systemc".to_owned()) + ); + } + } + + #[test] + fn reconciliation_requires_screen_and_keystrokes_to_agree() { + let env = SuggestEnv::Wsl { distro: "Ubuntu".to_owned() }; + assert_eq!(input_after_prompt("dev@box:~$ git status", "git stat", &env, false), None); + assert_eq!(input_after_prompt("dev@box:~$ ", "", &env, false), None); + assert_eq!( + input_after_prompt("custom prompt :: cargo t", "cargo t", &env, true), + Some("cargo t".to_owned()) + ); + } + + #[test] + fn nebula_arrow_snapshot_keeps_grid_truth_when_the_mirror_is_stale() { + let env = SuggestEnv::Local; + assert_eq!( + prompt_line_snapshot(r"PS D:\work ❯ git status", "git stat", &env, false), + Some(PromptLineSnapshot { + prompt: r"PS D:\work ❯".to_owned(), + input: "git status".to_owned(), + }) + ); + } + + #[test] + fn submitted_remote_command_captures_its_shell_prompt() { + let env = SuggestEnv::Ssh { destination: "root@box".to_owned() }; + assert_eq!( + prompt_line_snapshot("u944-pdsgd9gu:~# codex", "codex", &env, false), + Some(PromptLineSnapshot { + prompt: "u944-pdsgd9gu:~#".to_owned(), + input: "codex".to_owned(), + }) + ); + } + + #[test] + fn empty_remote_prompt_restores_after_interrupt_or_startup_failure() { + let ssh = SuggestEnv::Ssh { destination: "root@box".to_owned() }; + assert!(shell_prompt_restored("u944-pdsgd9gu:~#", "u944-pdsgd9gu:~# ", &ssh)); + + let wsl = SuggestEnv::Wsl { distro: "Ubuntu".to_owned() }; + assert!(shell_prompt_restored("dev@box:~$", "dev@box:~$ ", &wsl)); + } + + #[test] + fn prompt_restore_rejects_commands_agent_prompts_and_questions() { + let env = SuggestEnv::Ssh { destination: "root@box".to_owned() }; + assert!(!shell_prompt_restored("u944-pdsgd9gu:~#", "u944-pdsgd9gu:~# codex", &env)); + assert!(!shell_prompt_restored("u944-pdsgd9gu:~#", "›", &env)); + assert!(!shell_prompt_restored("›", "› ", &env)); + assert!(!shell_prompt_restored("❯", "❯ ", &env)); + assert!(!shell_prompt_restored("Password:", "Password: ", &env)); + assert!(!shell_prompt_restored("u944-pdsgd9gu:~#", "build output#", &env)); + } + + #[test] + fn remote_prompt_restore_allows_only_same_host_dynamic_cwd() { + let env = SuggestEnv::Wsl { distro: "Ubuntu".to_owned() }; + assert!(shell_prompt_restored("dev@box:~$", "dev@box:/work$ ", &env)); + assert!(!shell_prompt_restored("dev@box:~$", "dev@other:/work$ ", &env)); + assert!(!shell_prompt_restored("dev@box:~$", "build:/work$ ", &env)); + } +} diff --git a/nebula_app/src/display/markdown_view.rs b/nebula_app/src/display/markdown_view.rs index 22533b3..79ec08f 100644 --- a/nebula_app/src/display/markdown_view.rs +++ b/nebula_app/src/display/markdown_view.rs @@ -39,14 +39,7 @@ use super::SizeInfo; use super::ui::theme::Skin; use super::ui::widgets::{self, OverlayScrollbar}; -/// Extensions the viewer opens (double-click in the file tree). Everything -/// else keeps the tree's default activation behaviour. -pub fn viewable_file(path: &Path) -> bool { - matches!( - path.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref(), - Some("md" | "markdown" | "json" | "jsonl" | "ndjson" | "txt" | "log") - ) -} +pub use super::document_model::viewable_file; // ---- layout constants (logical px, multiplied by scale at use sites) ---- diff --git a/nebula_app/src/display/mod.rs b/nebula_app/src/display/mod.rs index e63132c..a6f9707 100644 --- a/nebula_app/src/display/mod.rs +++ b/nebula_app/src/display/mod.rs @@ -2,7 +2,6 @@ //! GPU drawing. use std::cmp; -use std::collections::HashSet; use std::fmt::{self, Formatter}; use std::mem::{self, ManuallyDrop}; use std::num::NonZeroU32; @@ -31,17 +30,12 @@ use nebula_terminal::event::{EventListener, OnResize}; use nebula_terminal::grid::Dimensions as TermDimensions; use nebula_terminal::index::{Column, Direction, Line, Point}; use nebula_terminal::selection::Selection; -#[cfg(windows)] -use nebula_terminal::term::cell::Cell; use nebula_terminal::term::cell::Flags; use nebula_terminal::term::{ self, LineDamageBounds, MIN_COLUMNS, MIN_SCREEN_LINES, Term, TermDamage, TermMode, }; use nebula_terminal::vte::ansi::{CursorShape, NamedColor}; -use nebula_completions::file::complete_item; -use nebula_completions::{CompletionOptions, Span}; - use crate::config::UiConfig; use crate::config::debug::RendererPreference; use crate::config::font::Font; @@ -65,20 +59,28 @@ use crate::renderer::{self, GlyphCache, Renderer, platform}; use crate::scheduler::{Scheduler, TimerId, Topic}; use crate::string::{ShortenDirection, StrShortener}; +mod background_color_model; pub mod color; +mod command_completion; pub mod content; pub mod cursor; pub mod hint; pub mod image_viewer; +mod input_state; pub mod ui; pub mod window; mod chrome; pub mod command_palette; pub(crate) mod context_menu; +mod context_menu_model; +mod document_model; +mod file_operations; mod i18n; pub mod markdown_view; mod message_queue_entry; +mod network_proxy_model; +mod program_identity; pub mod sftp_panel; pub mod side_panel; mod size_info; @@ -90,6 +92,7 @@ mod surface_opacity; /// 输出在新旧壳读起来不一样。 pub(crate) mod terminal_color; pub(crate) mod terminal_math; +mod text_path_model; mod toast; /// Processor uses the same persisted value before the first window exists so @@ -98,20 +101,39 @@ pub(crate) fn quick_terminal_hotkey_from_settings(config: &UiConfig) -> String { settings::nebula_settings_load(config).quick_terminal_hotkey } +pub use background_color_model::BgPickerPart; +pub(crate) use background_color_model::{BACKGROUND_SWATCHES, hsv_to_rgb, rgb_to_hsv}; pub(crate) use chrome::chrome_settings_button_rect; -// GPUI 壳的侧栏共用同一套「按列截断 + 省略号」规则:省略号是合同的一部分, -// 两个壳各写一份必然漂移(GPUI 曾靠字符数上限截断,末尾直接吃掉)。 -pub(crate) use chrome::truncate_tab_label; pub use chrome::{ChromeHit, TabDropAction, in_chrome_bar, resize_edge}; use chrome::{ChromeTabLayout, TabDrag, chrome_hit_with_tabs, chrome_tab_layout, contains_rect}; -pub use context_menu::{ContextMenuAction, ContextMenuHit, ContextMenuTarget}; +pub(crate) use command_completion::{ + NEBULA_GHOST_MAX, extract_program, nebula_command_hint, nebula_command_hints, + nebula_commands_handle, nebula_is_command_position, nebula_path_wants_directory, +}; +pub use context_menu_model::{ContextMenuAction, ContextMenuHit, ContextMenuTarget}; +pub(crate) use file_operations::send_to_recycle_bin; pub use i18n::{LanguagePreference, UiLanguage}; +pub(crate) use input_state::{ + nebula_clear_line, nebula_input_backspace, nebula_input_char, nebula_input_delete_word, + nebula_input_text, nebula_shell_prompt_restored_from_raw_grid, +}; +#[cfg(windows)] +pub(crate) use input_state::{ + nebula_input_from_raw_grid, nebula_prompt_line_from_raw_grid, nebula_raw_grid_row_preview, +}; +pub use program_identity::AiLogo; +pub(crate) use program_identity::{ + ai_logo, ai_logo_for_program, prepare_ai_logo_texture, program_icon, +}; pub use size_info::SizeInfo; pub use state::{ AcceptKey, AiSessionIdentity, CompletionStyle, NebulaCompletionItem, NebulaCompletionKind, NebulaConfirm, NebulaInlineImage, NebulaPaneState, NebulaShell, SplitDirection, SplitNav, }; pub use suggest_engine::SuggestEnv; +pub(crate) use text_path_model::{ + fit_tail, percent_decode_lossy, strip_file_scheme, truncate_tab_label, +}; pub use toast::ToastKind; pub(crate) mod file_dialog; @@ -124,9 +146,11 @@ mod ssh_ui; mod text_input; use ssh_ui::SshDeleteUndo; +pub(crate) use ssh_ui::merge_ssh_hosts; pub use ssh_ui::{ SSH_DELETE_UNDO_DURATION, SshEditorField, SshEditorHit, SshEditorRects, SshHostEditor, - auth_sections, join_destination_port, push_private_key, split_destination_port, + auth_sections, join_destination_port, join_destination_user, push_private_key, + split_destination_port, split_destination_user, }; pub use ui::theme::NebulaTheme; pub(crate) use ui::theme::write_nebula_prompt_theme; @@ -159,18 +183,13 @@ pub(crate) struct RemoteBackupRequest { pub(crate) fn caret_blink_on() -> bool { ui::caret::is_on() } -pub(crate) use settings::{ - BACKGROUND_SWATCHES, CellWidthMode, NewTabPosition, SettingsOpacityTarget, hsv_to_rgb, - rgb_to_hsv, -}; -pub use settings::{ - BgPickerPart, NebulaSettingsSection, SettingsDropdown, SettingsHit, settings_hit, -}; #[cfg(feature = "gpui-shell")] -pub(crate) use settings::{ +pub(crate) use network_proxy_model::{ MANUAL_PROXY_PROTOCOL_OPTIONS, ManualProxyProtocol, ProxyTestStatus, manual_proxy_parts, manual_proxy_value, }; +pub use settings::{NebulaSettingsSection, SettingsDropdown, SettingsHit, settings_hit}; +pub(crate) use settings::{NewTabPosition, SettingsOpacityTarget}; /// 按显示列宽贪心断行(确认框正文等 UI 段落用):CJK 逐字可断,行首空 /// 格吞掉;零宽字符跟随前一个字。不做拉丁连词回退——正文以中文为主, @@ -223,18 +242,6 @@ const NEBULA_GIT_BRANCH_ICON_MARKER: char = '\u{E101}'; /// Color which is used to highlight damaged rects when debugging. const DAMAGE_RECT_COLOR: Rgb = Rgb::new(255, 0, 255); -/// Cap on ghost-text length so a deeply-nested path can never spill across the -/// whole row and clobber the chrome. -const NEBULA_GHOST_MAX: usize = 96; - -/// Prompt arrow injected by the Windows PowerShell profile (`U+276F`, `NebPromptArrow`). -/// The active input line is rendered as `❯ `, so on Windows the real, -/// echoed input can be read straight off the grid instead of being guessed from -/// keystrokes — that is the only source that never desyncs from the shell's own -/// line editor (PSReadLine). -#[cfg(windows)] -const NEBULA_PROMPT_ARROW: char = '\u{276F}'; - /// Visible split divider gap. The drag hit target is intentionally wider. pub(crate) const NEBULA_SPLIT_DIVIDER_GAP: f32 = 2.0; pub(crate) const NEBULA_SPLIT_HIT_SLOP: f32 = 8.0; @@ -672,27 +679,6 @@ struct NebulaPowerlineIcon { point: Point, } -/// Sidebar SSH HOSTS content: auto-saved destinations (most recent first) + -/// `~/.ssh/config` aliases (file order), deduped, pinned entries floated to -/// the top in pin order. One function so startup and settings hot-reload -/// build the exact same list — the GPUI shell reads the same authority -/// (`pub(crate)`) instead of growing a second ordering policy. -pub(crate) fn merge_ssh_hosts( - saved: &[String], - pinned: &[String], - hidden: &[String], -) -> Vec { - let mut hosts: Vec<_> = saved.iter().filter(|host| !hidden.contains(host)).cloned().collect(); - for host in crate::ssh::ssh_config_hosts() { - if !hidden.contains(&host) && !hosts.contains(&host) { - hosts.push(host); - } - } - // Stable sort: pinned first in pin order, the rest keep saved→config order. - hosts.sort_by_key(|h| pinned.iter().position(|p| p == h).unwrap_or(usize::MAX)); - hosts -} - /// Remove one destination while recording exactly enough list state for Undo. /// Kept independent from rendering and Credential Manager so the destructive /// state transition can be regression-tested without touching real secrets. @@ -741,22 +727,6 @@ fn restore_ssh_host_to_lists( } } -/// First word of a committed command line, normalized to a program identity -/// for the sidebar icon: lowercased, path prefix and Windows launcher -/// extensions stripped. `D:\tools\Claude.EXE --resume` → `claude`. -pub(crate) fn extract_program(line: &str) -> Option { - let first = line.trim().split_whitespace().next()?; - let base = first.trim_matches('"').rsplit(['/', '\\']).next().unwrap_or(first); - let mut name = base.to_ascii_lowercase(); - for ext in [".exe", ".cmd", ".bat", ".ps1", ".com"] { - if let Some(stripped) = name.strip_suffix(ext) { - name = stripped.to_owned(); - break; - } - } - (!name.is_empty()).then_some(name) -} - /// Log replay commands can contain terminal query sequences captured from a /// different process. Replying writes those answers into the shell's stdin, /// where they become the next command after the replay process exits. @@ -782,26 +752,6 @@ pub(crate) fn replays_untrusted_terminal_output(line: &str) -> bool { ) || matches!(words.as_slice(), [journalctl, ..] if journalctl == "journalctl") } -/// Sidebar icon for a running program — Nerd Font glyphs (the chrome text -/// layer renders with Maple NF). AI CLIs get distinct marks; everything else -/// shares a generic "running" play sign. -/// AI clients whose REAL brand mark is drawn in the sidebar as a textured -/// quad (embedded PNG), instead of a lookalike Nerd Font glyph. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum AiLogo { - /// Anthropic's coral starburst. - Claude, - /// OpenAI's blossom knot (codex). - OpenAi, - /// opencode's terminal-frame mark (sst). Two-tone: bright frame + dimmer - /// inner screen block, encoded as luma and multiplied by the theme ink. - OpenCode, - /// Pi's block-built `pi` mark from pi.dev. - Pi, - /// xAI's Grok mark. - Grok, -} - /// Embedded brand logo assets, kept at their source pixel dimensions. const AI_LOGO_CLAUDE_PNG: &[u8] = include_bytes!("../../../extra/logo/ai_claude.png"); const AI_LOGO_GROK_DARK_PNG: &[u8] = include_bytes!("../../../extra/logo/ai_grok_dark.png"); @@ -816,181 +766,6 @@ const AI_LOGO_PI_PNG: &[u8] = include_bytes!("../../../extra/logo/ai_pi.png"); /// (which starts at 1), so the two id spaces can share the renderer cache. const AI_LOGO_ID_BASE: u64 = 1 << 62; -pub(crate) fn prepare_ai_logo_texture( - rgba: &[u8], - width: u32, - height: u32, - target_size: u32, -) -> (Vec, u32, u32) { - use image::imageops::FilterType; - - let target_size = target_size.max(1); - let source = image::RgbaImage::from_raw(width, height, rgba.to_vec()) - .expect("decoded logo dimensions must match its RGBA buffer"); - let resized = image::imageops::resize(&source, target_size, target_size, FilterType::Lanczos3); - let pixels = resized.as_raw(); - let (mass, weighted_x, weighted_y) = pixels.chunks_exact(4).enumerate().fold( - (0.0, 0.0, 0.0), - |(mass, weighted_x, weighted_y), (index, pixel)| { - let alpha = f64::from(pixel[3]); - let x = (index as u32 % target_size) as f64 + 0.5; - let y = (index as u32 / target_size) as f64 + 0.5; - (mass + alpha, weighted_x + x * alpha, weighted_y + y * alpha) - }, - ); - if mass == 0.0 { - return (resized.into_raw(), target_size, target_size); - } - - let center = f64::from(target_size) / 2.0; - let shift_x = (center - weighted_x / mass).round() as i32; - let shift_y = (center - weighted_y / mass).round() as i32; - if shift_x == 0 && shift_y == 0 { - return (resized.into_raw(), target_size, target_size); - } - - let mut centered = vec![0; pixels.len()]; - for y in 0..target_size as i32 { - for x in 0..target_size as i32 { - let dest_x = x + shift_x; - let dest_y = y + shift_y; - if !(0..target_size as i32).contains(&dest_x) - || !(0..target_size as i32).contains(&dest_y) - { - continue; - } - let source_index = ((y as u32 * target_size + x as u32) * 4) as usize; - let dest_index = ((dest_y as u32 * target_size + dest_x as u32) * 4) as usize; - centered[dest_index..dest_index + 4] - .copy_from_slice(&pixels[source_index..source_index + 4]); - } - } - (centered, target_size, target_size) -} - -/// Real-logo mapping for AI clients; everything else falls back to the -/// [`program_icon`] glyph. Gated on PNG support: without it there is no -/// texture to draw and the glyph must stay. -pub(crate) fn ai_logo_for_program(program: &str) -> Option { - let normalized = - extract_program(program).unwrap_or_else(|| program.trim().to_ascii_lowercase()); - match normalized.as_str() { - "claude" => Some(AiLogo::Claude), - "codex" => Some(AiLogo::OpenAi), - "opencode" => Some(AiLogo::OpenCode), - "pi" => Some(AiLogo::Pi), - "grok" | "grok-cli" => Some(AiLogo::Grok), - _ => None, - } -} - -pub(crate) fn ai_logo(program: &str) -> Option { - if cfg!(any(not(feature = "png"), target_os = "macos")) { - return None; - } - ai_logo_for_program(program) -} - -/// Drop a `file://` / `file:///` scheme so a local link reads as a plain -/// path in the hover tooltip. On Windows `file:///D:/x` → `D:/x` (the slash -/// before the drive letter goes too); non-`file:` URIs pass through. -/// -/// 2026-07-26 用户反馈:`ls --hyperlink` 会把非 ASCII 文件名 percent-encode -/// (一个汉字 → `%E6%98%9F` 九列),48 列预算被编码噪声吃光后再截尾,提示 -/// "显示不全"。file 路径解码后再展示;其他 scheme 保持原样(URL 的编码 -/// 属于其身份,不替它翻译)。 -pub(crate) fn strip_file_scheme(uri: &str) -> String { - match uri.strip_prefix("file:///").or_else(|| uri.strip_prefix("file://")) { - // `file:///D:/x` yields `D:/x`; a UNC-ish `file://host/x` keeps `host/x`. - Some(rest) => percent_decode_lossy(rest), - None => uri.to_owned(), - } -} - -/// Decode `%XX` escapes as UTF-8 bytes for DISPLAY. Malformed escapes pass -/// through verbatim; if the decoded bytes are not valid UTF-8 the original -/// string is returned unchanged — a tooltip must never invent mojibake. -fn percent_decode_lossy(s: &str) -> String { - if !s.contains('%') { - return s.to_owned(); - } - let bytes = s.as_bytes(); - let mut out = Vec::with_capacity(bytes.len()); - let mut i = 0; - while i < bytes.len() { - let decoded = (bytes[i] == b'%' && i + 2 < bytes.len()) - .then(|| { - let hi = (bytes[i + 1] as char).to_digit(16)?; - let lo = (bytes[i + 2] as char).to_digit(16)?; - Some((hi * 16 + lo) as u8) - }) - .flatten(); - match decoded { - Some(byte) => { - out.push(byte); - i += 3; - }, - None => { - out.push(bytes[i]); - i += 1; - }, - } - } - String::from_utf8(out).unwrap_or_else(|_| s.to_owned()) -} - -/// Truncate `s` to at most `budget` display columns (CJK counts as 2), keeping -/// the TAIL and prefixing `…` when cut — the filename end is what a hover -/// tooltip most needs. Returns a string whose display width is `<= budget`. -pub(crate) fn fit_tail(s: &str, budget: usize) -> String { - let width = |c: char| c.width().unwrap_or(0); - let total: usize = s.chars().map(width).sum(); - if total <= budget { - return s.to_owned(); - } - if budget == 0 { - return String::new(); - } - // Reserve one column for the ellipsis, then take chars from the end until - // the reserved room fills up. - let room = budget.saturating_sub(1); - let mut kept = std::collections::VecDeque::new(); - let mut used = 0; - for c in s.chars().rev() { - let w = width(c); - if used + w > room { - break; - } - used += w; - kept.push_front(c); - } - let mut out = String::with_capacity(kept.len() + 1); - out.push('…'); - out.extend(kept); - out -} - -pub(crate) fn program_icon(program: &str) -> &'static str { - match program { - "claude" => "\u{f0ce5}", // md-star-four-points (Claude spark) - "codex" => "\u{f02d8}", // md-hexagon (OpenAI mark) - "gemini" => "\u{f0ce6}", // md-star-four-points-outline - "copilot" => "\u{f4b8}", // oct-copilot - "cursor" | "cursor-agent" => "\u{f0ec3}", // md-cursor-default-outline - "aider" | "goose" | "crush" | "ollama" => "\u{f06a9}", // md-robot - "opencode" => "\u{f489}", // oct-terminal - "pi" => "\u{f135}", // fa-code - "git" | "gh" | "lazygit" => "\u{f418}", // oct-git-branch - "vim" | "nvim" | "vi" | "hx" | "nano" => "\u{e62b}", // custom-vim - "ssh" | "mosh" => "\u{f489}", // oct-terminal (remote) - "cargo" | "rustc" => "\u{e7a8}", // dev-rust - "node" | "npm" | "pnpm" | "yarn" | "bun" | "deno" => "\u{e718}", // dev-nodejs - "python" | "python3" | "pip" | "uv" => "\u{e73c}", // dev-python - "docker" | "podman" => "\u{e7b0}", // dev-docker - _ => "\u{f04b}", // fa-play (generic busy) - } -} - /// The UI font role: the size chrome /// typography rasterizes at and the cell chrome layout steps by. Anchored to /// the config font at the window's DPI — never to the terminal zoom. Stage 3 @@ -1005,26 +780,7 @@ struct NebulaUiFont { } pub(crate) fn nebula_debug_log(message: impl AsRef) { - use std::io::Write as _; - - static ENABLED: std::sync::OnceLock = std::sync::OnceLock::new(); - if !*ENABLED.get_or_init(|| { - std::env::var("NEBULA_DEBUG_LOG").is_ok_and(|value| { - let value = value.trim(); - !value.is_empty() && value != "0" && !value.eq_ignore_ascii_case("false") - }) - }) { - return; - } - - let ts = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| format!("{}.{:03}", d.as_secs(), d.subsec_millis())) - .unwrap_or_else(|_| "0.000".to_owned()); - let path = nebula_data_dir().join("nebula_debug.log"); - if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(path) { - let _ = writeln!(file, "[{ts} pid={}] {}", std::process::id(), message.as_ref()); - } + crate::logging::debug_log(message); } /// Unconditional variant of [`nebula_debug_log`] for the link-click diagnosis: @@ -1084,247 +840,6 @@ pub(crate) fn tray_enabled() -> bool { .unwrap_or(true) } -#[cfg(windows)] -fn nebula_pathexts() -> Vec { - std::env::var("PATHEXT") - .unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD;.PS1".to_owned()) - .split(';') - .filter_map(|ext| { - let ext = ext.trim(); - if ext.is_empty() { - None - } else if ext.starts_with('.') { - Some(ext.to_ascii_lowercase()) - } else { - Some(format!(".{}", ext.to_ascii_lowercase())) - } - }) - .collect() -} - -#[cfg(windows)] -fn nebula_command_name(path: &Path, pathexts: &[String]) -> Option { - let ext = path.extension()?.to_str()?; - let ext = format!(".{}", ext).to_ascii_lowercase(); - if !pathexts.iter().any(|known| known == &ext) { - return None; - } - path.file_stem()?.to_str().filter(|name| !name.is_empty()).map(ToOwned::to_owned) -} - -#[cfg(not(windows))] -fn nebula_command_name(path: &Path) -> Option { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - if path.metadata().ok()?.permissions().mode() & 0o111 == 0 { - return None; - } - } - path.file_name()?.to_str().filter(|name| !name.is_empty()).map(ToOwned::to_owned) -} - -fn nebula_path_commands() -> Vec { - let Some(path_env) = std::env::var_os("PATH") else { - return Vec::new(); - }; - - let mut commands = Vec::new(); - let mut seen = HashSet::new(); - #[cfg(windows)] - let pathexts = nebula_pathexts(); - - for dir in std::env::split_paths(&path_env).filter(|dir| !dir.as_os_str().is_empty()) { - let Ok(entries) = std::fs::read_dir(dir) else { - continue; - }; - - for entry in entries.flatten() { - let Ok(file_type) = entry.file_type() else { - continue; - }; - if !file_type.is_file() { - continue; - } - - #[cfg(windows)] - let command = nebula_command_name(&entry.path(), &pathexts); - #[cfg(not(windows))] - let command = nebula_command_name(&entry.path()); - - if let Some(command) = command { - #[cfg(windows)] - let key = command.to_ascii_lowercase(); - #[cfg(not(windows))] - let key = command.clone(); - - // PATH 里同名 shim/真实可执行文件经常重复;这里只保留第一个, - // 避免每次输入首 token 时 ghost 在等价候选间跳动。 - if seen.insert(key) { - commands.push(command); - } - } - } - } - - commands.sort_by(|a, b| { - #[cfg(windows)] - { - a.to_ascii_lowercase().cmp(&b.to_ascii_lowercase()).then(a.cmp(b)) - } - #[cfg(not(windows))] - { - a.cmp(b) - } - }); - commands -} - -/// Collect external-shell command names: PATH executables plus, on Windows, the -/// running shell's cmdlets / functions / aliases (which never appear on PATH). -/// Merged and de-duplicated (case-insensitively on Windows). -fn nebula_collect_commands() -> Vec { - let mut commands = nebula_path_commands(); - - #[cfg(windows)] - { - let mut seen: HashSet = commands.iter().map(|c| c.to_ascii_lowercase()).collect(); - for command in nebula_powershell_commands() { - if seen.insert(command.to_ascii_lowercase()) { - commands.push(command); - } - } - commands.sort_by(|a, b| a.to_ascii_lowercase().cmp(&b.to_ascii_lowercase()).then(a.cmp(b))); - } - - commands -} - -/// PowerShell cmdlets / functions / aliases via a one-shot `Get-Command`, run -/// with `-NoProfile` to avoid the user's profile cost. Best-effort: any failure -/// (no PowerShell, error, parse problem) yields an empty list. -#[cfg(windows)] -fn nebula_powershell_commands() -> Vec { - // CREATE_NO_WINDOW: Nebula is a GUI-subsystem process, so a console child - // would otherwise pop up (and instantly vanish) a visible PowerShell - // window at startup. - use std::os::windows::process::CommandExt; - let output = std::process::Command::new("powershell") - .args([ - "-NoProfile", - "-NonInteractive", - "-Command", - "Get-Command -CommandType Cmdlet,Function,Alias -ErrorAction SilentlyContinue \ - | Select-Object -ExpandProperty Name", - ]) - .creation_flags(windows_sys::Win32::System::Threading::CREATE_NO_WINDOW) - .output(); - - match output { - Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout) - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .map(ToOwned::to_owned) - .collect(), - _ => Vec::new(), - } -} - -/// Process-wide handle to the command list, populated once on a background -/// thread so the (PowerShell-invoking) collection never blocks window startup. -/// Readers see an empty list until collection finishes, then the merged set. -pub(crate) fn nebula_commands_handle() -> std::sync::Arc>> { - static COMMANDS: std::sync::OnceLock>>> = - std::sync::OnceLock::new(); - COMMANDS - .get_or_init(|| { - let shared = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); - let bg = shared.clone(); - std::thread::spawn(move || { - let commands = nebula_collect_commands(); - if let Ok(mut guard) = bg.lock() { - *guard = commands; - } - }); - shared - }) - .clone() -} - -fn nebula_command_hint<'a>(commands: &'a [String], prefix: &str) -> Option<&'a str> { - if prefix.is_empty() { - return None; - } - - // 完整命令已经可执行时必须停止补全;否则 `claude` 会跳过自身,继续 - // 命中 `claude-agent-acp` 这类更长的 PATH 邻居。 - #[cfg(windows)] - let exact = commands.iter().any(|command| command.eq_ignore_ascii_case(prefix)); - #[cfg(not(windows))] - let exact = commands.iter().any(|command| command == prefix); - if exact { - return None; - } - - commands.iter().find_map(|command| { - if command.len() <= prefix.len() || !command.is_char_boundary(prefix.len()) { - return None; - } - let (head, rem) = command.split_at(prefix.len()); - #[cfg(windows)] - let matches = head.eq_ignore_ascii_case(prefix); - #[cfg(not(windows))] - let matches = head == prefix; - - matches.then_some(rem) - }) -} - -/// Multi-candidate variant of [`nebula_command_hint`] for the popup list: up -/// to `limit` full command names extending `prefix`. Unlike the ghost variant -/// an exact match does NOT suppress the longer neighbors — listing `claude` -/// alongside `claude-agent-acp` is exactly what a chooser is for. -fn nebula_command_hints<'a>(commands: &'a [String], prefix: &str, limit: usize) -> Vec<&'a str> { - if prefix.is_empty() || limit == 0 { - return Vec::new(); - } - let mut out: Vec<&'a str> = Vec::new(); - - // 精确命令先列出来:用户输入 `ls` 时,第一感知应该是“这个命令可以 - // 直接接受”,而不是从 `LsaIso` 开始的一串较长邻居。命令表通常已排 - // 序,但这里不能把 UX 合同寄托在数据源顺序上,所以显式分两趟。 - for exact_only in [true, false] { - for command in commands { - if command.len() < prefix.len() || !command.is_char_boundary(prefix.len()) { - continue; - } - let head = &command[..prefix.len()]; - let exact = command.len() == prefix.len(); - if exact != exact_only { - continue; - } - #[cfg(windows)] - let matches = head.eq_ignore_ascii_case(prefix); - #[cfg(not(windows))] - let matches = head == prefix; - - #[cfg(windows)] - let duplicate = out.iter().any(|seen| seen.eq_ignore_ascii_case(command)); - #[cfg(not(windows))] - let duplicate = out.iter().any(|seen| *seen == command.as_str()); - - if matches && !duplicate { - out.push(command); - if out.len() == limit { - return out; - } - } - } - } - out -} - /// Truncate/pad `text` to exactly `width` display cells (wide chars count 2; /// a wide char that would straddle the boundary is dropped and padded over). fn nebula_pad_to_cells(text: &str, width: usize) -> String { @@ -1347,20 +862,6 @@ fn nebula_pad_to_cells(text: &str, width: usize) -> String { out } -fn nebula_is_command_position(line: &str) -> bool { - !line.contains([' ', '\t']) - && !line.contains(['/', '\\']) - && line.as_bytes().get(1) != Some(&b':') -} - -fn nebula_path_wants_directory(line: &str) -> bool { - let command = line.split([' ', '\t']).next().unwrap_or(""); - matches!( - command.to_ascii_lowercase().as_str(), - "cd" | "chdir" | "pushd" | "sl" | "set-location" - ) -} - #[derive(Debug)] pub enum Error { /// Error with window management. @@ -2022,51 +1523,6 @@ fn alt_screen_vertical_padding_bands( [top, band(grid_bottom, bottom_limit)] } -/// 本地文件/目录送回收站(Windows:`SHFileOperationW` + `FOF_ALLOWUNDO`)。 -/// 不弹系统确认(Nebula 自己的确认弹窗已经问过了),不弹系统错误框—— -/// 失败以 Err 返回给面板提示条。 -#[cfg(windows)] -pub(crate) fn send_to_recycle_bin(path: &std::path::Path) -> Result<(), String> { - use std::os::windows::ffi::OsStrExt; - - use windows_sys::Win32::UI::Shell::{ - FO_DELETE, FOF_ALLOWUNDO, FOF_NOCONFIRMATION, FOF_NOERRORUI, FOF_SILENT, SHFILEOPSTRUCTW, - SHFileOperationW, - }; - - if !path.exists() { - return Err("路径已不存在".to_owned()); - } - // pFrom 是双 NUL 结尾的路径列表。 - let mut from: Vec = path.as_os_str().encode_wide().collect(); - from.push(0); - from.push(0); - let mut op = SHFILEOPSTRUCTW { - hwnd: std::ptr::null_mut(), - wFunc: FO_DELETE as u32, - pFrom: from.as_ptr(), - pTo: std::ptr::null(), - fFlags: (FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_SILENT | FOF_NOERRORUI) as u16, - fAnyOperationsAborted: 0, - hNameMappings: std::ptr::null_mut(), - lpszProgressTitle: std::ptr::null(), - }; - let code = unsafe { SHFileOperationW(&mut op) }; - if code == 0 && op.fAnyOperationsAborted == 0 { - Ok(()) - } else { - Err(format!("系统拒绝(代码 {code})")) - } -} - -#[cfg(not(windows))] -pub(crate) fn send_to_recycle_bin(path: &std::path::Path) -> Result<(), String> { - // 非 Windows 暂无回收站集成:递归删除前置确认已由弹窗承担。 - let result = - if path.is_dir() { std::fs::remove_dir_all(path) } else { std::fs::remove_file(path) }; - result.map_err(|err| err.to_string()) -} - /// Prefer the event loop's system-wide appearance over the window theme. /// /// On Windows, `Window::theme()` is a cached per-window value and can still @@ -8608,13 +8064,18 @@ impl Display { let line_override = if alt_screen || vi_mode || search_state.regex().is_some() { None } else { - Self::nebula_input_from_raw_grid(&terminal, cursor_point) + nebula_input_from_raw_grid( + &terminal, + cursor_point, + &pane_state.line_buf, + &pane_state.suggest_env, + ) }; #[cfg(windows)] let row_preview = if alt_screen || vi_mode || search_state.regex().is_some() { None } else { - Some(Self::nebula_raw_grid_row_preview(&terminal, cursor_point)) + Some(nebula_raw_grid_row_preview(&terminal, cursor_point)) }; // 打字(含 IME 组词)不影响网格内容,扫描保持开启;一旦这里随 @@ -10418,58 +9879,6 @@ impl Display { dirty } - #[inline(never)] - /// Append a typed character to the prompt-line buffer that backs the - /// history hint. - pub fn nebula_input_char(state: &mut NebulaPaneState, c: char) { - state.line_buf.push(c); - state.touched = true; - state.suggestion.clear(); - state.suggestion_key.clear(); - state.completion_items.clear(); - state.completion_selected = None; - nebula_debug_log(format!("input_char c={c:?} line_buf={:?}", state.line_buf)); - } - - /// Drop the last character (Backspace) from the prompt-line buffer. - pub fn nebula_input_backspace(state: &mut NebulaPaneState) { - state.line_buf.pop(); - state.touched = true; - state.clear_completion_hints(); - nebula_debug_log(format!("input_backspace line_buf={:?}", state.line_buf)); - } - - /// Drop the previous whitespace-delimited token, matching the common - /// Ctrl+W/Ctrl+Backspace shell behavior closely enough for prompt hints. - pub fn nebula_input_delete_word(state: &mut NebulaPaneState) { - state.touched = true; - while state.line_buf.ends_with(char::is_whitespace) { - state.line_buf.pop(); - } - while state.line_buf.chars().last().is_some_and(|c| !c.is_whitespace()) { - state.line_buf.pop(); - } - state.clear_completion_hints(); - nebula_debug_log(format!("input_delete_word line_buf={:?}", state.line_buf)); - } - - /// Merge pasted literal text into the prompt-line buffer. Multi-line or - /// escape-bearing paste can execute commands or move the cursor, so we - /// invalidate instead of guessing; Nushell avoids this class of bug by - /// owning the editor buffer directly via Reedline. - pub fn nebula_input_text(state: &mut NebulaPaneState, text: &str) { - if text.contains(['\r', '\n']) || text.chars().any(|c| c.is_control() && c != '\t') { - nebula_debug_log(format!("input_text_clear text={text:?}")); - Self::nebula_clear_line(state); - return; - } - - state.line_buf.push_str(text); - state.touched = true; - state.clear_completion_hints(); - nebula_debug_log(format!("input_text text={text:?} line_buf={:?}", state.line_buf)); - } - /// Commit the current line to history (on Enter) and reset the buffer. /// /// `screen_line` (the input read off the grid, i.e. what the shell's own @@ -10497,7 +9906,7 @@ impl Display { "input_commit cwd={:?} line={line:?} line_buf={:?} screen_line={:?}", state.cwd, state.line_buf, state.screen_line )); - self.nebula_history.record(&line, &state.cwd); + self.nebula_history.record(&state.suggest_env.history_scope(), &line, &state.cwd); // Kept for CommandStart (OSC 133;C): by the time it arrives from the // PTY these buffers are already cleared, so the program identity for // the tab icon has to be captured here. Fall back to the keystroke @@ -10517,7 +9926,7 @@ impl Display { state.finished_unseen = false; state.needs_attention = false; } - Self::nebula_clear_line(state); + nebula_clear_line(state); } /// Feed the shared directory model from an authoritative shell report. @@ -10525,128 +9934,6 @@ impl Display { self.directory_history.record(cwd); } - /// Reset the prompt-line buffer (Enter, Ctrl-C, or any non-text key). - pub fn nebula_clear_line(state: &mut NebulaPaneState) { - if !state.line_buf.is_empty() || !state.suggestion.is_empty() { - nebula_debug_log(format!( - "input_clear line_buf={:?} suggestion={:?}", - state.line_buf, state.suggestion - )); - } - state.line_buf.clear(); - state.screen_line.clear(); - state.completion_suppressed_line = None; - state.clear_completion_hints(); - } - - /// Read the real, echoed input off the cursor's grid row on Windows. - /// - /// The PowerShell profile renders the active input line as `❯ `, so - /// the user's current input is exactly the run of cells from just past the - /// last prompt arrow up to the cursor column. Reading the grid (the screen - /// truth PSReadLine itself produced) sidesteps the keystroke-reconstructed - /// `line_buf`, which desyncs the instant the cursor moves, Tab-completes or - /// recalls history — the "hint flickers in and out" bug. - /// - /// Returns `None` (suppressing the hint) when no prompt arrow precedes the - /// cursor, or when non-space cells sit after the cursor on the same row — - /// i.e. a mid-line edit, where a trailing ghost would be misplaced (fish and - /// PSReadLine suppress the hint there too). - #[cfg(windows)] - fn nebula_raw_grid_row_preview(terminal: &Term, cursor: Point) -> String { - let grid = terminal.grid(); - let columns = grid.columns(); - let mut text = String::with_capacity(columns); - let mut arrow_cols = Vec::new(); - - for col in 0..columns { - let cell: &Cell = &grid[cursor.line][Column(col)]; - if cell.flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) { - continue; - } - if cell.c == NEBULA_PROMPT_ARROW { - arrow_cols.push(col); - } - text.push(cell.c); - } - - while text.ends_with(' ') { - text.pop(); - } - - format!( - "line={} col={} arrows={arrow_cols:?} text={text:?}", - cursor.line.0, cursor.column.0 - ) - } - - #[cfg(windows)] - pub(crate) fn nebula_input_from_raw_grid( - terminal: &Term, - cursor: Point, - ) -> Option { - let grid = terminal.grid(); - let columns = grid.columns(); - let cursor_col = cursor.column.0.min(columns); - - // A cursor mid-wrap means the input continues on the row below (a - // mid-line edit); a hint anchored here would be wrong. - if grid[cursor.line][Column(columns - 1)].flags.contains(Flags::WRAPLINE) { - return None; - } - - // Suppress when anything non-space follows the cursor on this row. - for col in cursor_col..columns { - let cell: &Cell = &grid[cursor.line][Column(col)]; - if cell.flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) { - continue; - } - if !cell.c.is_whitespace() { - return None; - } - } - - // A long input soft-wraps — CJK paths hit the margin fast at two - // columns per char — leaving the prompt arrow rows ABOVE the cursor. - // Walk up while the row above ends in WRAPLINE to the logical line's - // first row (bounded, so a pathological grid can't spin every frame). - let topmost = grid.topmost_line().0; - let mut first_row = cursor.line.0; - while first_row > topmost - && cursor.line.0 - first_row < 64 - && grid[Line(first_row - 1)][Column(columns - 1)].flags.contains(Flags::WRAPLINE) - { - first_row -= 1; - } - - // Rebuild the logical line from its first row down to the cursor, - // remembering the last prompt arrow before it. This deliberately reads - // the raw terminal grid, not RenderableCell: renderables omit - // default-background spaces, and that was collapsing `cd D:\te` into - // `cdD:\te`, making directory-history hints impossible to parse. - let mut text = String::with_capacity(columns); - let mut arrow_pos = None; - for row in first_row..=cursor.line.0 { - let row_end = if row == cursor.line.0 { cursor_col } else { columns }; - for col in 0..row_end { - let cell: &Cell = &grid[Line(row)][Column(col)]; - if cell.flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) - { - continue; - } - if cell.c == NEBULA_PROMPT_ARROW { - arrow_pos = Some(text.len()); - } - text.push(cell.c); - } - } - - let input = &text[arrow_pos? + NEBULA_PROMPT_ARROW.len_utf8()..]; - - // Drop the single space that follows the arrow. - Some(input.strip_prefix(' ').unwrap_or(input).to_owned()) - } - /// Recompute the inline ghost-text suggestion. `line_override` carries the /// grid-read input on Windows (the authoritative screen truth); when `None` /// the keystroke-tracked `line_buf` is used (other platforms). A whole @@ -11630,10 +10917,9 @@ mod nebula_ux_tests { use winit::window::Theme as WinitTheme; use super::{ - AI_LOGO_GROK_DARK_PNG, AI_LOGO_GROK_LIGHT_PNG, AiLogo, Display, NebulaConfirm, SizeInfo, - ai_logo, alt_screen_vertical_padding_bands, compute_cell_size, extract_program, - nebula_command_hint, nebula_command_hints, nebula_pad_to_cells, percent_decode_lossy, - prepare_ai_logo_texture, program_icon, remove_ssh_host_from_lists, + AI_LOGO_GROK_DARK_PNG, AI_LOGO_GROK_LIGHT_PNG, AiLogo, NebulaConfirm, SizeInfo, ai_logo, + alt_screen_vertical_padding_bands, compute_cell_size, extract_program, nebula_pad_to_cells, + percent_decode_lossy, prepare_ai_logo_texture, program_icon, remove_ssh_host_from_lists, replays_untrusted_terminal_output, restore_ssh_host_to_lists, strip_file_scheme, system_theme_snapshot, }; @@ -11825,29 +11111,6 @@ mod nebula_ux_tests { } } - #[test] - fn exact_path_command_suppresses_longer_neighbor_completion() { - let commands = strings(&["claude", "claude-agent-acp"]); - assert_eq!(nebula_command_hint(&commands, "clau"), Some("de")); - assert_eq!(nebula_command_hint(&commands, "claude"), None); - - #[cfg(windows)] - assert_eq!(nebula_command_hint(&commands, "CLAUDE"), None); - } - - #[test] - fn popup_command_hints_keep_longer_neighbors_and_dedup() { - let commands = strings(&["claude", "claude-agent-acp", "claude", "cargo"]); - // 弹窗列表与 ghost 相反:精确命令置顶,同时保留更长的邻居。 - assert_eq!( - nebula_command_hints(&commands, "claude", 8), - vec!["claude", "claude-agent-acp"] - ); - assert_eq!(nebula_command_hints(&commands, "c", 2), vec!["claude", "claude-agent-acp"]); - assert_eq!(nebula_command_hints(&strings(&["LsaIso", "lsass", "ls"]), "ls", 8)[0], "ls"); - assert!(nebula_command_hints(&commands, "", 8).is_empty()); - } - #[test] fn popup_pad_counts_display_cells_and_drops_straddling_wide_chars() { assert_eq!(nebula_pad_to_cells("ab", 4), "ab "); diff --git a/nebula_app/src/display/network_proxy_model.rs b/nebula_app/src/display/network_proxy_model.rs new file mode 100644 index 0000000..8b324d8 --- /dev/null +++ b/nebula_app/src/display/network_proxy_model.rs @@ -0,0 +1,83 @@ +//! Shared model for the manual SSH proxy controls. + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ManualProxyProtocol { + #[default] + Socks5, + Http, +} + +pub(crate) const MANUAL_PROXY_PROTOCOL_OPTIONS: [ManualProxyProtocol; 2] = + [ManualProxyProtocol::Socks5, ManualProxyProtocol::Http]; + +/// State of the network test for the settings currently persisted on disk. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub(crate) enum ProxyTestStatus { + #[default] + Idle, + Running, + Complete { + outcome: crate::proxy_test::ProxyTestOutcome, + elapsed_ms: u64, + }, +} + +/// Split a persisted proxy URL into the protocol control and editable address. +pub(crate) fn manual_proxy_parts(value: &str) -> (ManualProxyProtocol, &str) { + let value = value.trim(); + for (prefix, protocol) in [ + ("socks5://", ManualProxyProtocol::Socks5), + ("socks5h://", ManualProxyProtocol::Socks5), + ("socks://", ManualProxyProtocol::Socks5), + ("http://", ManualProxyProtocol::Http), + ] { + if let Some(address) = crate::ssh_proxy::strip_prefix_ignore_case(value, prefix) { + return (protocol, address); + } + } + (ManualProxyProtocol::Socks5, value) +} + +pub(crate) fn manual_proxy_value(protocol: ManualProxyProtocol, address: &str) -> String { + let address = address.trim(); + if address.is_empty() { + return String::new(); + } + match protocol { + ManualProxyProtocol::Socks5 => format!("socks5://{address}"), + ManualProxyProtocol::Http => format!("http://{address}"), + } +} + +#[cfg(test)] +mod tests { + use super::{ManualProxyProtocol, manual_proxy_parts, manual_proxy_value}; + + #[test] + fn protocol_and_address_round_trip_without_duplicate_prefixes() { + assert_eq!( + manual_proxy_parts("SOCKS5://127.0.0.1:1080"), + (ManualProxyProtocol::Socks5, "127.0.0.1:1080") + ); + assert_eq!( + manual_proxy_parts("HTTP://proxy.lan:8080"), + (ManualProxyProtocol::Http, "proxy.lan:8080") + ); + assert_eq!( + manual_proxy_parts("127.0.0.1:7890"), + (ManualProxyProtocol::Socks5, "127.0.0.1:7890") + ); + assert_eq!( + manual_proxy_value(ManualProxyProtocol::Socks5, "127.0.0.1:1080"), + "socks5://127.0.0.1:1080" + ); + assert_eq!(manual_proxy_value(ManualProxyProtocol::Http, ""), ""); + } + + #[test] + fn parts_accept_non_ascii_without_slicing_inside_utf8() { + for value in ["127.0.0.1:", ":127.0.0.1", "127.0:0.1", ":"] { + assert_eq!(manual_proxy_parts(value), (ManualProxyProtocol::Socks5, value)); + } + } +} diff --git a/nebula_app/src/display/program_identity.rs b/nebula_app/src/display/program_identity.rs new file mode 100644 index 0000000..cb4d4e9 --- /dev/null +++ b/nebula_app/src/display/program_identity.rs @@ -0,0 +1,105 @@ +//! Program identity, icons, and logo preparation shared by both UI shells. + +use super::command_completion::extract_program; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum AiLogo { + Claude, + OpenAi, + OpenCode, + Pi, + Grok, +} + +pub(crate) fn prepare_ai_logo_texture( + rgba: &[u8], + width: u32, + height: u32, + target_size: u32, +) -> (Vec, u32, u32) { + use image::imageops::FilterType; + + let target_size = target_size.max(1); + let source = image::RgbaImage::from_raw(width, height, rgba.to_vec()) + .expect("decoded logo dimensions must match its RGBA buffer"); + let resized = image::imageops::resize(&source, target_size, target_size, FilterType::Lanczos3); + let pixels = resized.as_raw(); + let (mass, weighted_x, weighted_y) = pixels.chunks_exact(4).enumerate().fold( + (0.0, 0.0, 0.0), + |(mass, weighted_x, weighted_y), (index, pixel)| { + let alpha = f64::from(pixel[3]); + let x = (index as u32 % target_size) as f64 + 0.5; + let y = (index as u32 / target_size) as f64 + 0.5; + (mass + alpha, weighted_x + x * alpha, weighted_y + y * alpha) + }, + ); + if mass == 0.0 { + return (resized.into_raw(), target_size, target_size); + } + + let center = f64::from(target_size) / 2.0; + let shift_x = (center - weighted_x / mass).round() as i32; + let shift_y = (center - weighted_y / mass).round() as i32; + if shift_x == 0 && shift_y == 0 { + return (resized.into_raw(), target_size, target_size); + } + + let mut centered = vec![0; pixels.len()]; + for y in 0..target_size as i32 { + for x in 0..target_size as i32 { + let dest_x = x + shift_x; + let dest_y = y + shift_y; + if !(0..target_size as i32).contains(&dest_x) + || !(0..target_size as i32).contains(&dest_y) + { + continue; + } + let source_index = ((y as u32 * target_size + x as u32) * 4) as usize; + let dest_index = ((dest_y as u32 * target_size + dest_x as u32) * 4) as usize; + centered[dest_index..dest_index + 4] + .copy_from_slice(&pixels[source_index..source_index + 4]); + } + } + (centered, target_size, target_size) +} + +pub(crate) fn ai_logo_for_program(program: &str) -> Option { + let normalized = + extract_program(program).unwrap_or_else(|| program.trim().to_ascii_lowercase()); + match normalized.as_str() { + "claude" => Some(AiLogo::Claude), + "codex" => Some(AiLogo::OpenAi), + "opencode" => Some(AiLogo::OpenCode), + "pi" => Some(AiLogo::Pi), + "grok" | "grok-cli" => Some(AiLogo::Grok), + _ => None, + } +} + +pub(crate) fn ai_logo(program: &str) -> Option { + if cfg!(any(not(feature = "png"), target_os = "macos")) { + return None; + } + ai_logo_for_program(program) +} + +pub(crate) fn program_icon(program: &str) -> &'static str { + match program { + "claude" => "\u{f0ce5}", + "codex" => "\u{f02d8}", + "gemini" => "\u{f0ce6}", + "copilot" => "\u{f4b8}", + "cursor" | "cursor-agent" => "\u{f0ec3}", + "aider" | "goose" | "crush" | "ollama" => "\u{f06a9}", + "opencode" => "\u{f489}", + "pi" => "\u{f135}", + "git" | "lazygit" => "\u{f418}", + "vim" | "nvim" | "vi" | "hx" | "nano" => "\u{e62b}", + "ssh" | "mosh" => "\u{f489}", + "cargo" | "rustc" => "\u{e7a8}", + "node" | "npm" | "pnpm" | "yarn" | "bun" | "deno" => "\u{e718}", + "python" | "python3" | "pip" | "uv" => "\u{e73c}", + "docker" | "podman" => "\u{e7b0}", + _ => "\u{f04b}", + } +} diff --git a/nebula_app/src/display/settings.rs b/nebula_app/src/display/settings.rs index 8850761..50e9199 100644 --- a/nebula_app/src/display/settings.rs +++ b/nebula_app/src/display/settings.rs @@ -24,7 +24,13 @@ use crate::renderer::image::{BackgroundImageAlignment, BackgroundImageFit}; use crate::renderer::ui::{Rgba, UiQuad}; use crate::renderer::{GlyphCache, Renderer}; +pub use super::background_color_model::BgPickerPart; +pub(crate) use super::background_color_model::{BACKGROUND_SWATCHES, hsv_to_rgb, rgb_to_hsv}; use super::keymap; +pub(crate) use super::network_proxy_model::{ + MANUAL_PROXY_PROTOCOL_OPTIONS, ManualProxyProtocol, ProxyTestStatus, manual_proxy_parts, + manual_proxy_value, +}; use super::ui::theme::Skin; use super::ui::{icons, os_icons, surface, text_field, tokens, widgets}; use super::{ @@ -143,23 +149,6 @@ pub enum SettingsDropdown { BackgroundColor, } -/// 背景色色盘的预设色板(2 行 × 6 列)。前排偏星云/终端惯用暗底,尾部 -/// 提供近黑与亮底;任意颜色可用下方的 16 进制输入框手动指定。 -pub(crate) const BACKGROUND_SWATCHES: [Rgb; 12] = [ - Rgb::new(8, 10, 24), - Rgb::new(12, 16, 28), - Rgb::new(18, 14, 32), - Rgb::new(24, 24, 37), - Rgb::new(0, 43, 54), - Rgb::new(6, 26, 28), - Rgb::new(40, 42, 54), - Rgb::new(30, 30, 30), - Rgb::new(12, 12, 12), - Rgb::new(0, 0, 0), - Rgb::new(253, 246, 227), - Rgb::new(255, 255, 255), -]; - pub(super) const BACKGROUND_FIT_OPTIONS: [BackgroundImageFit; 4] = [ BackgroundImageFit::Fill, BackgroundImageFit::Uniform, @@ -216,29 +205,6 @@ fn ssh_proxy_mode_label(mode: crate::ssh_proxy::ProxyMode, language: UiLanguage) } } -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ManualProxyProtocol { - #[default] - Socks5, - Http, -} - -pub(crate) const MANUAL_PROXY_PROTOCOL_OPTIONS: [ManualProxyProtocol; 2] = - [ManualProxyProtocol::Socks5, ManualProxyProtocol::Http]; - -/// 网络页“测试网络”的窗口态。结果只代表发起测试时已经落盘的那份设置; -/// 用户随后改动模式或地址时会回到 `Idle`,避免旧成功状态冒充新配置有效。 -#[derive(Debug, Default, Clone, PartialEq, Eq)] -pub(crate) enum ProxyTestStatus { - #[default] - Idle, - Running, - Complete { - outcome: crate::proxy_test::ProxyTestOutcome, - elapsed_ms: u64, - }, -} - fn manual_proxy_protocol_label( protocol: ManualProxyProtocol, _language: UiLanguage, @@ -249,36 +215,6 @@ fn manual_proxy_protocol_label( } } -/// 把持久化 URL 拆成协议选择与可编辑地址。协议前缀由下拉框负责,输入框 -/// 只显示地址正文,避免用户在两个控件里重复维护同一份信息。 -pub(crate) fn manual_proxy_parts(value: &str) -> (ManualProxyProtocol, &str) { - let value = value.trim(); - for (prefix, protocol) in [ - ("socks5://", ManualProxyProtocol::Socks5), - ("socks5h://", ManualProxyProtocol::Socks5), - ("socks://", ManualProxyProtocol::Socks5), - ("http://", ManualProxyProtocol::Http), - ] { - if let Some(address) = crate::ssh_proxy::strip_prefix_ignore_case(value, prefix) { - return (protocol, address); - } - } - // 兼容旧配置中的裸 `host:port`;不再显示含糊的“自动识别”,明确按 - // 历史规则作为 SOCKS5 处理。 - (ManualProxyProtocol::Socks5, value) -} - -pub(crate) fn manual_proxy_value(protocol: ManualProxyProtocol, address: &str) -> String { - let address = address.trim(); - if address.is_empty() { - return String::new(); - } - match protocol { - ManualProxyProtocol::Socks5 => format!("socks5://{address}"), - ManualProxyProtocol::Http => format!("http://{address}"), - } -} - /// 设置页的悬停层使用当前主题的 accent,而不是固定的灰色或另一套绿色。 /// 透明度在浅色/深色主题分别取值,保证两种底色上都只是轻微提示。 fn settings_skin(theme: NebulaTheme) -> Skin { @@ -2220,55 +2156,6 @@ pub(super) fn background_color_popup( BackgroundColorPopup { rect: (x, y, w, h), sv, hue, swatch, hex } } -/// HSV → RGB。`h` 度(任意实数,内部归一到 [0,360)),`s`/`v` ∈ [0,1]。 -pub(crate) fn hsv_to_rgb(h: f32, s: f32, v: f32) -> Rgb { - let h = h.rem_euclid(360.0); - let s = s.clamp(0.0, 1.0); - let v = v.clamp(0.0, 1.0); - let c = v * s; - let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs()); - let m = v - c; - let (r, g, b) = match h { - _ if h < 60.0 => (c, x, 0.0), - _ if h < 120.0 => (x, c, 0.0), - _ if h < 180.0 => (0.0, c, x), - _ if h < 240.0 => (0.0, x, c), - _ if h < 300.0 => (x, 0.0, c), - _ => (c, 0.0, x), - }; - let to = |f: f32| ((f + m) * 255.0).round().clamp(0.0, 255.0) as u8; - Rgb::new(to(r), to(g), to(b)) -} - -/// RGB → HSV。灰色(s = 0)时 h 报 0;调用方要在拖动中保住既有色相, -/// 别用本函数的返回覆盖它(见 `Display::open_background_color_picker`)。 -pub(crate) fn rgb_to_hsv(color: Rgb) -> (f32, f32, f32) { - let r = color.r as f32 / 255.0; - let g = color.g as f32 / 255.0; - let b = color.b as f32 / 255.0; - let max = r.max(g).max(b); - let min = r.min(g).min(b); - let delta = max - min; - let h = if delta <= f32::EPSILON { - 0.0 - } else if max == r { - 60.0 * (((g - b) / delta).rem_euclid(6.0)) - } else if max == g { - 60.0 * ((b - r) / delta + 2.0) - } else { - 60.0 * ((r - g) / delta + 4.0) - }; - let s = if max <= f32::EPSILON { 0.0 } else { delta / max }; - (h, s, max) -} - -/// 调色盘拖拽的目标部件。 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BgPickerPart { - Sv, - Hue, -} - /// 字体弹层的总行数:候选行 + 顶部那个搜索框。 /// /// 一个字体都筛不出来时仍然是 1 行——那时搜索框是弹层里唯一的东西,也正是 @@ -8085,48 +7972,18 @@ pub(super) fn draw_text( #[cfg(test)] mod tests { use super::{ - CELL_WIDTH_MODE_OPTIONS, CellWidthMode, KeymapPaneState, ManualProxyProtocol, - NEW_TAB_POSITION_OPTIONS, NebulaSettingsSection, NewTabPosition, ProxyChoice, - ProxyPaneState, Rgb, SHOW_BACKUP_SETTINGS, SHOW_WEBDAV_SYNC_SETTINGS, - STANDARD_ROW_ACTION_W, SettingsHit, TabRevealMotion, UiLanguage, advanced_content_end, - background_color_popup, cell_width_mode_label, font_popup_row_count, font_popup_slot, - hsv_to_rgb, manual_proxy_parts, manual_proxy_value, new_tab_position_label, - opacity_from_pointer, proxy_section_title_y, rgb_to_hsv, row_action_rect, - settings_geometry, settings_hit, ssh_proxy_manual_controls, ssh_proxy_mode_control, - ssh_proxy_test_button, + CELL_WIDTH_MODE_OPTIONS, CellWidthMode, KeymapPaneState, NEW_TAB_POSITION_OPTIONS, + NebulaSettingsSection, NewTabPosition, ProxyChoice, ProxyPaneState, + SHOW_BACKUP_SETTINGS, SHOW_WEBDAV_SYNC_SETTINGS, STANDARD_ROW_ACTION_W, SettingsHit, + TabRevealMotion, UiLanguage, advanced_content_end, background_color_popup, + cell_width_mode_label, font_popup_row_count, font_popup_slot, new_tab_position_label, + opacity_from_pointer, proxy_section_title_y, row_action_rect, settings_geometry, + settings_hit, ssh_proxy_manual_controls, ssh_proxy_mode_control, ssh_proxy_test_button, }; use crate::display::SizeInfo; use crate::display::ui::tokens::Density; use crate::display::ui::widgets; - #[test] - fn hsv_rgb_round_trip_and_gray_hue_convention() { - // 三原色与灰阶的锚点值。 - assert_eq!(hsv_to_rgb(0.0, 1.0, 1.0), Rgb::new(255, 0, 0)); - assert_eq!(hsv_to_rgb(120.0, 1.0, 1.0), Rgb::new(0, 255, 0)); - assert_eq!(hsv_to_rgb(240.0, 1.0, 1.0), Rgb::new(0, 0, 255)); - assert_eq!(hsv_to_rgb(0.0, 0.0, 1.0), Rgb::new(255, 255, 255)); - assert_eq!(hsv_to_rgb(123.0, 0.7, 0.0), Rgb::new(0, 0, 0)); - // 负角度与超圈角度归一(拖拽把手不夹角度)。 - assert_eq!(hsv_to_rgb(360.0, 1.0, 1.0), hsv_to_rgb(0.0, 1.0, 1.0)); - assert_eq!(hsv_to_rgb(-120.0, 1.0, 1.0), hsv_to_rgb(240.0, 1.0, 1.0)); - - // 往返:RGB → HSV → RGB 在 8bit 量化内闭合。 - for color in [Rgb::new(8, 10, 24), Rgb::new(253, 246, 227), Rgb::new(40, 42, 54)] { - let (h, s, v) = rgb_to_hsv(color); - let back = hsv_to_rgb(h, s, v); - assert!( - (back.r as i32 - color.r as i32).abs() <= 1 - && (back.g as i32 - color.g as i32).abs() <= 1 - && (back.b as i32 - color.b as i32).abs() <= 1, - "{color:?} -> ({h},{s},{v}) -> {back:?}" - ); - } - // 灰色的色相按约定报 0,饱和度 0。 - assert_eq!(rgb_to_hsv(Rgb::new(128, 128, 128)).0, 0.0); - assert_eq!(rgb_to_hsv(Rgb::new(128, 128, 128)).1, 0.0); - } - #[test] fn color_picker_popup_keeps_sv_hue_and_swatches_disjoint() { // 三个交互区不重叠:SV 面、色相条、第一格色板按序垂直排布。 @@ -8476,34 +8333,6 @@ mod tests { ); } - #[test] - fn manual_proxy_protocol_and_address_round_trip_without_duplicate_prefixes() { - assert_eq!( - manual_proxy_parts("SOCKS5://127.0.0.1:1080"), - (ManualProxyProtocol::Socks5, "127.0.0.1:1080") - ); - assert_eq!( - manual_proxy_parts("HTTP://proxy.lan:8080"), - (ManualProxyProtocol::Http, "proxy.lan:8080") - ); - assert_eq!( - manual_proxy_parts("127.0.0.1:7890"), - (ManualProxyProtocol::Socks5, "127.0.0.1:7890") - ); - assert_eq!( - manual_proxy_value(ManualProxyProtocol::Socks5, "127.0.0.1:1080"), - "socks5://127.0.0.1:1080" - ); - assert_eq!(manual_proxy_value(ManualProxyProtocol::Http, ""), ""); - } - - #[test] - fn manual_proxy_parts_accepts_non_ascii_without_slicing_inside_utf8() { - for value in ["127.0.0.1:", ":127.0.0.1", "127.0:0.1", ":"] { - assert_eq!(manual_proxy_parts(value), (ManualProxyProtocol::Socks5, value)); - } - } - #[test] fn manual_proxy_expand_has_separate_protocol_and_address_hit_targets() { let size = proxy_test_size(); diff --git a/nebula_app/src/display/side_panel/enumerate.rs b/nebula_app/src/display/side_panel/enumerate.rs index 6710061..5d82424 100644 --- a/nebula_app/src/display/side_panel/enumerate.rs +++ b/nebula_app/src/display/side_panel/enumerate.rs @@ -4,51 +4,6 @@ use super::*; -/// Budget-bounded deep walk building the flat filter index. `budget` counts -/// every entry VISITED (not kept), so a huge build tree can't stall the UI; -/// bulk directories (`target/`, `node_modules/`, …) are skipped outright, and -/// symlinks/junctions are never followed (cycle safety). -pub(crate) fn build_search_index( - dir: &Path, - depth: usize, - index: &mut Vec, - budget: &mut usize, -) { - if *budget == 0 || depth > 8 || index.len() >= SEARCH_INDEX_CAP { - return; - } - let Ok(read) = std::fs::read_dir(dir) else { return }; - for entry in read.flatten() { - if *budget == 0 || index.len() >= SEARCH_INDEX_CAP { - return; - } - *budget -= 1; - let Ok(ft) = entry.file_type() else { continue }; - if ft.is_symlink() { - continue; - } - let name = entry.file_name().to_string_lossy().into_owned(); - let is_dir = ft.is_dir(); - if is_dir && (name.starts_with('.') || SEARCH_SKIP_DIRS.contains(&name.as_str())) { - continue; - } - let path = entry.path(); - index.push(FileRow { - path: path.clone(), - guest_path: None, - name, - depth: 0, - is_dir, - expanded: false, - is_parent: false, - ignored: false, - }); - if is_dir { - build_search_index(&path, depth + 1, index, budget); - } - } -} - pub(crate) fn wsl_root_key(located: &crate::shell_detect::WslCwd) -> PathBuf { PathBuf::from(normalize_wsl_guest_path(&located.guest)) } diff --git a/nebula_app/src/display/side_panel/icons.rs b/nebula_app/src/display/side_panel/icons.rs new file mode 100644 index 0000000..b27d7c9 --- /dev/null +++ b/nebula_app/src/display/side_panel/icons.rs @@ -0,0 +1,71 @@ +//! File-tree icon mapping shared by both shell implementations. + +pub(crate) const ICON_FOLDER: &str = "\u{f114}"; +pub(crate) const ICON_FOLDER_OPEN: &str = "\u{f115}"; +pub(crate) const ICON_TERMINAL: &str = "\u{ea85}"; +pub(crate) const ICON_FILE: &str = "\u{ea7b}"; +pub(crate) const ICON_CHEVRON_RIGHT: &str = "\u{eab6}"; +pub(crate) const ICON_CHEVRON_DOWN: &str = "\u{eab4}"; +pub(crate) const ICON_BRANCH: &str = "\u{ea68}"; +pub(crate) const ICON_SEARCH: &str = "\u{f002}"; +pub(crate) const ICON_HOME: &str = "\u{f015}"; +pub(crate) const ICON_FOLLOW: &str = "\u{f140}"; + +pub(crate) fn folder_icon(expanded: bool) -> &'static str { + if expanded { ICON_FOLDER_OPEN } else { ICON_FOLDER } +} + +pub(crate) fn chevron_icon(expanded: bool) -> &'static str { + if expanded { ICON_CHEVRON_DOWN } else { ICON_CHEVRON_RIGHT } +} + +/// File-type icon for a tree row, keyed by extension. Every glyph is present +/// in the bundled Maple Mono Nerd Font used by both renderers. +pub(crate) fn file_type_icon(name: &str) -> &'static str { + let lower = name.to_ascii_lowercase(); + if lower.starts_with(".git") { + return "\u{e65d}"; + } + let ext = lower.rsplit_once('.').map(|(_, ext)| ext).unwrap_or(""); + match ext { + "md" | "markdown" => "\u{eb1d}", + "json" | "jsonl" | "ndjson" => "\u{eb0f}", + "toml" => "\u{e6b2}", + "yml" | "yaml" => "\u{e6a8}", + "xml" => "\u{e619}", + "rs" => "\u{e68b}", + "py" => "\u{e606}", + "js" | "mjs" | "cjs" | "jsx" => "\u{e60c}", + "ts" | "tsx" => "\u{e628}", + "html" | "htm" => "\u{e60e}", + "css" | "scss" | "less" => "\u{e614}", + "c" | "h" => "\u{e61e}", + "cpp" | "cc" | "cxx" | "hpp" => "\u{e61d}", + "cs" => "\u{e648}", + "java" => "\u{e66d}", + "go" => "\u{e627}", + "sh" | "bash" | "zsh" => "\u{e691}", + "ps1" | "psm1" | "psd1" => "\u{e683}", + "bat" | "cmd" => ICON_TERMINAL, + "sql" | "db" | "sqlite" => "\u{e64d}", + "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "ico" | "svg" => "\u{e60d}", + "zip" | "7z" | "rar" | "gz" | "tar" | "xz" | "zst" => "\u{f1c6}", + "pdf" => "\u{f1c1}", + "lock" => "\u{e672}", + "log" => "\u{f4ed}", + "txt" => "\u{f0f6}", + _ => ICON_FILE, + } +} + +#[cfg(test)] +mod tests { + use super::{ICON_FILE, ICON_FOLDER, file_type_icon, folder_icon}; + + #[test] + fn shared_icons_cover_directories_known_files_and_fallbacks() { + assert_eq!(folder_icon(false), ICON_FOLDER); + assert_ne!(file_type_icon("main.rs"), ICON_FILE); + assert_eq!(file_type_icon("README.unknown"), ICON_FILE); + } +} diff --git a/nebula_app/src/display/side_panel/mod.rs b/nebula_app/src/display/side_panel/mod.rs index eeb603c..60307e2 100644 --- a/nebula_app/src/display/side_panel/mod.rs +++ b/nebula_app/src/display/side_panel/mod.rs @@ -23,7 +23,10 @@ use std::time::{Duration, Instant}; use unicode_width::UnicodeWidthChar; mod enumerate; +mod icons; +#[cfg(feature = "legacy-shell")] mod render; +mod search; #[cfg(test)] mod tests; mod vcs; @@ -31,7 +34,10 @@ mod vcs; // 子模块的项一律 `pub(crate)`:本 crate 是 bin,没有下游用户,所以拆分不必 // 把内部实现抬到 `pub`。glob 转发让 `display::side_panel::X` 这层路径不变。 pub(crate) use enumerate::*; +pub(crate) use icons::*; +#[cfg(feature = "legacy-shell")] pub(crate) use render::*; +pub(crate) use search::*; pub(crate) use vcs::*; /// Which view the drawer shows. @@ -43,6 +49,37 @@ pub enum PanelView { Git, } +/// Git 抽屉内部的三个等宽入口。它与 [`PanelView`] 分层:后者只负责文件/VCS +/// 工具切换,这一层才负责提交、线路和冲突三种版本控制工作流。 +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum GitPanelView { + #[default] + Changes, + History, + Conflicts, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct GitCommit { + /// 完整对象 ID 用来建立真实父子关系;短哈希只用于界面显示。 + pub full_hash: String, + pub short_hash: String, + pub decorations: String, + pub subject: String, + pub author: String, + pub timestamp: i64, + /// 按 Git 记录顺序保存父提交:第一个是主线,后续父提交是合并进来的线路。 + pub parent_hashes: Vec, +} + +/// 三栏合并器运行 Git 的位置。WSL 仓库必须留在来宾中执行,不能把 `/home` +/// 一类路径误交给宿主文件 API。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GitLocation { + Local { root: PathBuf }, + Wsl { distro: String, root: String }, +} + /// One flattened row of the directory tree. #[derive(Debug, Clone)] pub struct FileRow { @@ -97,6 +134,9 @@ pub struct GitInfo { /// 在 `staged`/`unstaged` 里:旧壳视图零改动照常显示,GPUI 壳按本列表 /// 单独分组并从另两组过滤(VS Code 的 Merge Changes 合同)。 pub conflicts: Vec<(char, String)>, + /// `git log --all --date-order` 的最近提交。显示层依据对象 ID 和父提交 + /// 生成连续轨道,不把 `git --graph` 的字符画当成视觉数据。 + pub history: Vec, /// 仅 [`VcsKind::SvnRepository`] 使用;工作副本和 Git 的操作目录取 /// [`SidePanel::vcs_root`],服务端仓库必须保留祖先扫描得到的真实根。 pub repository_root: Option, @@ -255,6 +295,8 @@ const SEARCH_SKIP_DIRS: &[&str] = pub struct SidePanel { pub open: bool, pub view: PanelView, + /// Git 抽屉内部当前页;切换文件/VCS 抽屉不会丢掉用户上次所在的页。 + pub git_view: GitPanelView, /// Root the tree/git snapshot was built from (the focused pane's cwd). root: Option, /// Latest focused pane cwd, retained while a custom root is active so the @@ -273,6 +315,10 @@ pub struct SidePanel { root_notice: Option, /// Flattened visible tree rows for the Files view. rows: Vec, + /// Last unfiltered tree snapshot. Search results replace `rows`, but + /// clearing the query restores this cache immediately instead of walking + /// the filesystem (or starting another WSL command) on the UI thread. + tree_rows: Vec, /// Directories the user expanded (persists across refreshes). expanded: HashSet, /// Git snapshot, `None` when the root isn't inside a work tree. @@ -285,11 +331,15 @@ pub struct SidePanel { /// Whether the filter box owns the keyboard. pub search_focus: bool, search_selection: super::text_input::SelectAllState, - /// Flat, budget-bounded index of the tree used by the filter. Built ONCE - /// on the first filtering keystroke and reused for the rest of the query - /// (each keystroke then only string-matches in memory); dropped whenever - /// the root changes or a refresh rebuilds the snapshot. - search_index: Option>, + /// Everything-style, root-scoped filename index. It owns a worker thread; + /// rendering only submits queries and harvests generation-checked rows. + file_index: EmbeddedFileIndex, + search_options: FileSearchOptions, + search_generation: u64, + search_applied_generation: u64, + search_index_epoch: u64, + search_total: usize, + search_error: Option, /// Commit-message input (Git view): buffer + focus, same modal keyboard /// contract as the Files filter box. pub commit_msg: String, @@ -359,6 +409,7 @@ impl SidePanel { Self { open: false, view: PanelView::Files, + git_view: GitPanelView::Changes, root: None, followed_cwd: None, followed_wsl: None, @@ -366,13 +417,20 @@ impl SidePanel { custom_wsl_root: None, root_notice: None, rows: Vec::new(), + tree_rows: Vec::new(), expanded: HashSet::new(), git: None, scroll: 0, search: String::new(), search_focus: false, search_selection: Default::default(), - search_index: None, + file_index: EmbeddedFileIndex::new(), + search_options: FileSearchOptions::default(), + search_generation: 0, + search_applied_generation: 0, + search_index_epoch: 0, + search_total: 0, + search_error: None, commit_msg: String::new(), commit_focus: false, commit_selection: Default::default(), @@ -430,7 +488,8 @@ impl SidePanel { } // 先收割落地的后台快照——旧内容在工人跑动期间一直显示,这里一次 // 性换成新内容(先显示旧的、再更新,VSCode 的树刷新模式)。 - let changed = self.harvest_snapshot(); + let mut changed = self.harvest_snapshot(); + changed |= self.harvest_file_search(); // 聚焦 pane 报不出位置时(SSH、shell 尚未发 OSC)保留最后一个有效根; // 有明确新位置时则恢复实时跟随,树内点 `..` 产生的浏览覆盖不能继续压住 // 新 pane/cwd。相同 cwd 上的手动浏览仍保留,直到位置真的变化或用户刷新。 @@ -525,11 +584,6 @@ impl SidePanel { /// Fold a finished background snapshot into the visible state. Returns /// whether anything on screen changed. fn harvest_snapshot(&mut self) -> bool { - // 过滤查询激活时不收割:树快照会覆盖过滤视图。快照留在槽里,查询 - // 清空后的下一帧照常落地。 - if !self.search.trim().is_empty() { - return false; - } let snapshot = match self.snapshot_slot.lock() { Ok(mut slot) => slot.take(), Err(_) => None, @@ -544,13 +598,30 @@ impl SidePanel { self.needs_refresh = true; return false; } - self.rows = snapshot.rows; + self.tree_rows = snapshot.rows; + if self.search.trim().is_empty() { + self.rows.clone_from(&self.tree_rows); + } self.enumeration_failed = !snapshot.enumeration_ok; if let Some(git) = snapshot.git { self.git = git; } - // New snapshot → the filter index is stale; rebuild lazily on demand. - self.search_index = None; + true + } + + fn harvest_file_search(&mut self) -> bool { + let Some(result) = self.file_index.take_result() else { return false }; + if result.generation != self.search_generation + || result.query != self.search + || result.options != self.search_options + { + return false; + } + self.rows = result.rows; + self.search_total = result.total; + self.search_error = result.error; + self.search_applied_generation = result.generation; + self.scroll = 0; true } @@ -646,6 +717,26 @@ impl SidePanel { self.custom_root.as_deref().or(self.followed_cwd.as_deref()).or(self.root.as_deref()) } + /// 当前 Git 仓库的执行位置,供工作区里的三栏合并 Tab 使用。 + pub fn git_location(&self) -> Option { + if self.vcs() != Some(VcsKind::Git) { + return None; + } + if let Some(located) = self.followed_wsl.as_ref() { + return Some(GitLocation::Wsl { + distro: located.distro.clone(), + root: located.guest.clone(), + }); + } + self.vcs_root().map(|root| GitLocation::Local { root: root.to_path_buf() }) + } + + pub fn select_git_view(&mut self, view: GitPanelView) { + self.git_view = view; + self.selected = None; + self.scroll = 0; + } + pub fn root_notice(&self) -> Option<&str> { self.root_notice.as_deref() } @@ -683,8 +774,10 @@ impl SidePanel { let Some(root) = self.root.clone() else { // 没有根:清空是即时且无成本的,不需要工人。 self.rows.clear(); + self.tree_rows.clear(); self.git = None; - self.search_index = None; + self.search_index_epoch = self.search_index_epoch.wrapping_add(1); + self.file_index.rebuild(None, self.search_index_epoch, None); return; }; // 快照工人一次只跑一个:fs 遍历 + 三个 git 子进程在渲染线程上曾是 @@ -696,6 +789,19 @@ impl SidePanel { } let expanded = self.expanded.clone(); let files_wsl = self.file_wsl_root().cloned(); + self.search_index_epoch = self.search_index_epoch.wrapping_add(1); + let index_root = files_wsl + .clone() + .map(FileIndexRoot::Wsl) + .unwrap_or_else(|| FileIndexRoot::Local(root.clone())); + let active_query = if self.search.trim().is_empty() { + None + } else { + self.search_generation = self.search_generation.wrapping_add(1); + self.search_error = None; + Some((self.search_generation, self.search.clone(), self.search_options)) + }; + self.file_index.rebuild(Some(index_root), self.search_index_epoch, active_query); // VCS 状态跟着 [`Self::vcs_root`]:显式浏览定位优先,其次终端 cwd。 // 在树里点 `..` 往上翻也算显式定位,所以翻到仓库外面时视图会变空—— // 那条回头路由 VCS 视图自己画的「跟随当前目录」入口负责(此前它只 @@ -766,59 +872,104 @@ impl SidePanel { /// Rebuild only the flattened rows (tree shape / filter changes; the git /// snapshot stays). fn rebuild_rows(&mut self) { - self.rows.clear(); let Some(root) = self.root.clone() else { return }; - let needle = self.search.trim().to_lowercase(); - if needle.is_empty() { - match self.file_wsl_root() { - Some(located) => { - let (rows, ok) = Self::tree_rows_wsl(located, &self.expanded); - self.rows = rows; - self.enumeration_failed = !ok; - }, - None => { - self.rows = Self::tree_rows(&root, &self.expanded); - self.enumeration_failed = false; - }, - } + if !self.search.trim().is_empty() { + self.queue_file_search(); return; } - // Filter mode: string-match against the cached flat index. The index - // is built at most once per snapshot (budget-bounded walk); each - // keystroke after that is pure in-memory filtering — walking the tree - // per keystroke froze the UI on big checkouts. - if self.search_index.is_none() { - let mut index = Vec::new(); - let mut budget = SEARCH_VISIT_BUDGET; - if let Some(located) = self.file_wsl_root() { - build_wsl_search_index(located, &mut index, &mut budget); - } else { - build_search_index(&root, 0, &mut index, &mut budget); - Self::mark_ignored(&root, &mut index); - } - self.search_index = Some(index); + match self.file_wsl_root() { + Some(located) => { + let (rows, ok) = Self::tree_rows_wsl(located, &self.expanded); + self.tree_rows = rows; + self.enumeration_failed = !ok; + }, + None => { + self.tree_rows = Self::tree_rows(&root, &self.expanded); + self.enumeration_failed = false; + }, } - let index = self.search_index.as_ref().unwrap(); - self.rows.extend( - index - .iter() - .filter(|row| row.name.to_lowercase().contains(&needle)) - .take(MAX_ROWS) - .cloned(), - ); + self.rows.clone_from(&self.tree_rows); + } + + fn queue_file_search(&mut self) { + self.scroll = 0; + self.search_error = None; + self.search_total = 0; + self.search_generation = self.search_generation.wrapping_add(1); + if self.search.trim().is_empty() { + self.rows.clone_from(&self.tree_rows); + self.search_applied_generation = self.search_generation; + return; + } + self.file_index.query(self.search_generation, self.search.clone(), self.search_options); + } + + /// Replace the GPUI Files query. The input component owns caret/IME state; + /// the shared model owns only sanitized text and indexed results. + pub fn set_file_search_query(&mut self, mut query: String) { + query.retain(|character| !matches!(character, '\r' | '\n')); + if self.search == query { + return; + } + self.search = query; + self.search_selection.clear(); + self.queue_file_search(); + } + + pub fn file_search_options(&self) -> FileSearchOptions { + self.search_options + } + + pub fn toggle_file_search_match_case(&mut self) { + self.search_options.match_case = !self.search_options.match_case; + self.queue_file_search(); + } + + pub fn toggle_file_search_whole_word(&mut self) { + self.search_options.whole_word = !self.search_options.whole_word; + self.queue_file_search(); + } + + pub fn toggle_file_search_regex(&mut self) { + self.search_options.regex = !self.search_options.regex; + self.queue_file_search(); + } + + pub fn file_search_pending(&self) -> bool { + !self.search.trim().is_empty() + && (self.file_index.status() == FileIndexStatus::Building + || self.search_applied_generation != self.search_generation) + } + + pub fn file_index_status(&self) -> FileIndexStatus { + self.file_index.status() + } + + pub fn file_indexed_count(&self) -> usize { + self.file_index.indexed_count() + } + + pub fn file_index_truncated(&self) -> bool { + self.file_index.truncated() + } + + pub fn file_search_total(&self) -> usize { + self.search_total + } + + pub fn file_search_error(&self) -> Option<&str> { + self.search_error.as_deref() } /// Append typed text to the filter query and re-derive the rows. pub fn search_input(&mut self, text: &str) { self.search_selection.insert(&mut self.search, text); - self.scroll = 0; - self.rebuild_rows(); + self.queue_file_search(); } pub fn search_backspace(&mut self) { self.search_selection.backspace(&mut self.search); - self.scroll = 0; - self.rebuild_rows(); + self.queue_file_search(); } pub fn search_select_all(&mut self) { @@ -839,8 +990,7 @@ impl SidePanel { self.search_selection.clear(); if clear && !self.search.is_empty() { self.search.clear(); - self.scroll = 0; - self.rebuild_rows(); + self.queue_file_search(); } } @@ -1084,6 +1234,23 @@ impl SidePanel { true } + /// Open a directory returned by the flat search list as this drawer's + /// window-local browse root. The caller clears its visible input after a + /// successful navigation, which restores the normal tree snapshot. + pub fn browse_search_directory(&mut self, path: PathBuf, guest_path: Option) -> bool { + if self.search.trim().is_empty() { + return false; + } + if let Some(guest) = guest_path { + let Some(distro) = self.file_wsl_root().map(|root| root.distro.clone()) else { + return false; + }; + self.set_custom_wsl_root(crate::shell_detect::WslCwd { distro, guest }) + } else { + self.set_custom_root(path) + } + } + /// Complete the plain-click half of a pending directory drag. The source /// path must still occupy the pressed row; otherwise a refresh/scroll /// change could expand an unrelated directory after mouse release. diff --git a/nebula_app/src/display/side_panel/render.rs b/nebula_app/src/display/side_panel/render.rs index c54df04..b11c250 100644 --- a/nebula_app/src/display/side_panel/render.rs +++ b/nebula_app/src/display/side_panel/render.rs @@ -14,71 +14,6 @@ use crate::renderer::{GlyphCache, Renderer}; use crate::display::ui::widgets; use crate::display::{NebulaTheme, SizeInfo, UI_CORNER_RADIUS_LOGICAL}; -// Nerd Font glyphs verified in the bundled Maple Mono font. The folder pair -// intentionally uses the lighter outline family from the approved prototype. -pub(crate) const ICON_FOLDER: &str = "\u{f114}"; -pub(crate) const ICON_FOLDER_OPEN: &str = "\u{f115}"; -pub(crate) const ICON_TERMINAL: &str = "\u{ea85}"; -pub(crate) const ICON_FILE: &str = "\u{ea7b}"; -pub(crate) const ICON_CHEVRON_RIGHT: &str = "\u{eab6}"; -pub(crate) const ICON_CHEVRON_DOWN: &str = "\u{eab4}"; - -/// GPUI 文件树与旧壳共用同一组 Nerd Font 字形,避免两套图标在展开状态 -/// 和字宽上出现细微漂移。 -pub(crate) fn folder_icon(expanded: bool) -> &'static str { - if expanded { ICON_FOLDER_OPEN } else { ICON_FOLDER } -} - -pub(crate) fn chevron_icon(expanded: bool) -> &'static str { - if expanded { ICON_CHEVRON_DOWN } else { ICON_CHEVRON_RIGHT } -} -pub(crate) const ICON_BRANCH: &str = "\u{ea68}"; -pub(crate) const ICON_SEARCH: &str = "\u{f002}"; -pub(crate) const ICON_HOME: &str = "\u{f015}"; -pub(crate) const ICON_FOLLOW: &str = "\u{f140}"; - -/// File-type icon for a tree row, keyed by extension (dotfile names like -/// `.gitignore` count as their own family). The glyph carries the type; the -/// ink stays the tree's neutral scheme — no per-type colors in the chrome. -/// Every codepoint here is verified present in the bundled Maple Mono NF CN -/// (codicon/seti/devicon/octicon blocks), so nothing can render as tofu. -pub(crate) fn file_type_icon(name: &str) -> &'static str { - let lower = name.to_ascii_lowercase(); - if lower.starts_with(".git") { - return "\u{e65d}"; // seti-git: .gitignore/.gitattributes/.gitmodules - } - let ext = lower.rsplit_once('.').map(|(_, ext)| ext).unwrap_or(""); - match ext { - "md" | "markdown" => "\u{eb1d}", // cod-markdown - "json" | "jsonl" | "ndjson" => "\u{eb0f}", // cod-json - "toml" => "\u{e6b2}", - "yml" | "yaml" => "\u{e6a8}", - "xml" => "\u{e619}", - "rs" => "\u{e68b}", - "py" => "\u{e606}", - "js" | "mjs" | "cjs" | "jsx" => "\u{e60c}", - "ts" | "tsx" => "\u{e628}", - "html" | "htm" => "\u{e60e}", - "css" | "scss" | "less" => "\u{e614}", - "c" | "h" => "\u{e61e}", - "cpp" | "cc" | "cxx" | "hpp" => "\u{e61d}", - "cs" => "\u{e648}", - "java" => "\u{e66d}", - "go" => "\u{e627}", - "sh" | "bash" | "zsh" => "\u{e691}", - "ps1" | "psm1" | "psd1" => "\u{e683}", - "bat" | "cmd" => "\u{ea85}", // cod-terminal - "sql" | "db" | "sqlite" => "\u{e64d}", - "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "ico" | "svg" => "\u{e60d}", - "zip" | "7z" | "rar" | "gz" | "tar" | "xz" | "zst" => "\u{f1c6}", - "pdf" => "\u{f1c1}", - "lock" => "\u{e672}", - "log" => "\u{f4ed}", - "txt" => "\u{f0f6}", - _ => ICON_FILE, - } -} - /// Git status colors (GitHub Primer hues), picked per theme brightness so /// they hold contrast on both surface families. pub(crate) fn status_color(status: char, is_light: bool) -> Option { diff --git a/nebula_app/src/display/side_panel/search.rs b/nebula_app/src/display/side_panel/search.rs new file mode 100644 index 0000000..1f6516e --- /dev/null +++ b/nebula_app/src/display/side_panel/search.rs @@ -0,0 +1,735 @@ +//! Embedded, cross-platform filename index for the Files drawer. +//! +//! The index follows the drawer root instead of indexing whole disks. It is +//! built on a worker thread, queried in memory, and patched from `notify` +//! events. This gives the drawer Everything-style query latency without a +//! Windows-only executable or a second on-disk database. + +use super::*; + +use std::collections::VecDeque; +use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, mpsc}; + +use notify::event::ModifyKind; +use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher as _}; + +const MAX_INDEX_ENTRIES: usize = 500_000; +const MAX_SEARCH_RESULTS: usize = 1_000; +const WATCH_DEBOUNCE: Duration = Duration::from_millis(80); + +const INDEX_IDLE: u8 = 0; +const INDEX_BUILDING: u8 = 1; +const INDEX_READY: u8 = 2; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct FileSearchOptions { + pub match_case: bool, + pub whole_word: bool, + pub regex: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FileIndexStatus { + Idle, + Building, + Ready, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum FileIndexRoot { + Local(PathBuf), + Wsl(crate::shell_detect::WslCwd), +} + +#[derive(Clone)] +struct SearchRequest { + generation: u64, + query: String, + options: FileSearchOptions, +} + +enum IndexCommand { + Rebuild { + root: Option, + epoch: u64, + query: Option, + }, + Query(SearchRequest), + #[cfg(test)] + ReleaseForTest(mpsc::SyncSender<()>), +} + +#[derive(Debug)] +pub(crate) struct FileSearchResult { + pub(crate) generation: u64, + pub(crate) query: String, + pub(crate) options: FileSearchOptions, + pub(crate) rows: Vec, + pub(crate) total: usize, + pub(crate) error: Option, +} + +#[derive(Debug)] +struct IndexedPath { + path: PathBuf, + guest_path: Option, + name: String, + name_folded: String, + key: String, + key_folded: String, + is_dir: bool, +} + +/// Handle owned by one drawer model. The worker exits when the handle and its +/// command sender are dropped with the window. +pub(crate) struct EmbeddedFileIndex { + command_tx: mpsc::Sender, + result: Arc>>, + desired_epoch: Arc, + status: Arc, + indexed_count: Arc, + truncated: Arc, +} + +impl EmbeddedFileIndex { + pub(crate) fn new() -> Self { + let (command_tx, command_rx) = mpsc::channel(); + let result = Arc::new(Mutex::new(None)); + let desired_epoch = Arc::new(AtomicU64::new(0)); + let status = Arc::new(AtomicU8::new(INDEX_IDLE)); + let indexed_count = Arc::new(AtomicUsize::new(0)); + let truncated = Arc::new(AtomicBool::new(false)); + + let worker_result = Arc::clone(&result); + let worker_epoch = Arc::clone(&desired_epoch); + let worker_status = Arc::clone(&status); + let worker_count = Arc::clone(&indexed_count); + let worker_truncated = Arc::clone(&truncated); + let _ = std::thread::Builder::new().name("nebula file index".to_owned()).spawn(move || { + run_index_worker( + command_rx, + worker_result, + worker_epoch, + worker_status, + worker_count, + worker_truncated, + ); + }); + + Self { command_tx, result, desired_epoch, status, indexed_count, truncated } + } + + pub(crate) fn rebuild( + &self, + root: Option, + epoch: u64, + query: Option<(u64, String, FileSearchOptions)>, + ) { + self.desired_epoch.store(epoch, Ordering::Release); + let query = + query.map(|(generation, query, options)| SearchRequest { generation, query, options }); + let _ = self.command_tx.send(IndexCommand::Rebuild { root, epoch, query }); + } + + pub(crate) fn query(&self, generation: u64, query: String, options: FileSearchOptions) { + let _ = + self.command_tx.send(IndexCommand::Query(SearchRequest { generation, query, options })); + } + + pub(crate) fn take_result(&self) -> Option { + self.result.lock().ok()?.take() + } + + pub(crate) fn status(&self) -> FileIndexStatus { + match self.status.load(Ordering::Acquire) { + INDEX_BUILDING => FileIndexStatus::Building, + INDEX_READY => FileIndexStatus::Ready, + _ => FileIndexStatus::Idle, + } + } + + pub(crate) fn indexed_count(&self) -> usize { + self.indexed_count.load(Ordering::Acquire) + } + + pub(crate) fn truncated(&self) -> bool { + self.truncated.load(Ordering::Acquire) + } + + #[cfg(test)] + pub(crate) fn release_for_test(&self) { + self.desired_epoch.fetch_add(1, Ordering::AcqRel); + let (released_tx, released_rx) = mpsc::sync_channel(0); + self.command_tx + .send(IndexCommand::ReleaseForTest(released_tx)) + .expect("file-index worker must accept the release command"); + released_rx + .recv_timeout(Duration::from_secs(2)) + .expect("file-index worker must release its filesystem watcher"); + } +} + +fn run_index_worker( + command_rx: mpsc::Receiver, + result_slot: Arc>>, + desired_epoch: Arc, + status: Arc, + indexed_count: Arc, + truncated: Arc, +) { + let (watch_tx, watch_rx) = mpsc::channel(); + let mut watcher: Option = None; + let mut root: Option = None; + let mut epoch = 0; + let mut entries = Vec::new(); + let mut current_query: Option = None; + let mut pending_command = None; + + loop { + let received = pending_command + .take() + .map(Ok) + .unwrap_or_else(|| command_rx.recv_timeout(WATCH_DEBOUNCE)); + match received { + Ok(IndexCommand::Rebuild { root: next_root, epoch: next_epoch, query }) => { + root = next_root; + epoch = next_epoch; + current_query = query; + entries.clear(); + watcher = None; + while watch_rx.try_recv().is_ok() {} + indexed_count.store(0, Ordering::Release); + truncated.store(false, Ordering::Release); + + let Some(index_root) = root.as_ref() else { + status.store(INDEX_IDLE, Ordering::Release); + continue; + }; + status.store(INDEX_BUILDING, Ordering::Release); + + if let FileIndexRoot::Local(local_root) = index_root { + let callback_tx = watch_tx.clone(); + watcher = notify::recommended_watcher(move |event| { + let _ = callback_tx.send(event); + }) + .ok(); + if let Some(active_watcher) = watcher.as_mut() + && let Err(error) = + active_watcher.watch(local_root, RecursiveMode::Recursive) + { + log::debug!( + "unable to watch file-search root {}: {error}", + local_root.display() + ); + watcher = None; + } + } + + let completed = match index_root { + FileIndexRoot::Local(local_root) => build_local_index( + local_root, + next_epoch, + &desired_epoch, + &indexed_count, + &mut entries, + ), + FileIndexRoot::Wsl(located) => { + build_wsl_index(located, &indexed_count, &mut entries) + }, + }; + if !completed || desired_epoch.load(Ordering::Acquire) != next_epoch { + continue; + } + truncated.store(entries.len() >= MAX_INDEX_ENTRIES, Ordering::Release); + status.store(INDEX_READY, Ordering::Release); + if let Some(request) = current_query.as_ref() { + publish_search_result(&entries, request, &result_slot); + } + }, + Ok(IndexCommand::Query(mut request)) => { + // A user can type several characters while a large index is + // being scanned. Keep only the newest queued query, but never + // consume a root rebuild: save that command for the next loop. + while let Ok(next) = command_rx.try_recv() { + match next { + IndexCommand::Query(next_request) => request = next_request, + rebuild @ IndexCommand::Rebuild { .. } => { + pending_command = Some(rebuild); + break; + }, + #[cfg(test)] + release @ IndexCommand::ReleaseForTest(_) => { + pending_command = Some(release); + break; + }, + } + } + current_query = Some(request); + if status.load(Ordering::Acquire) == INDEX_READY + && desired_epoch.load(Ordering::Acquire) == epoch + { + publish_search_result( + &entries, + current_query.as_ref().expect("query was just set"), + &result_slot, + ); + } + }, + #[cfg(test)] + Ok(IndexCommand::ReleaseForTest(released)) => { + watcher = None; + root = None; + entries.clear(); + current_query = None; + indexed_count.store(0, Ordering::Release); + truncated.store(false, Ordering::Release); + status.store(INDEX_IDLE, Ordering::Release); + let _ = released.send(()); + }, + Err(mpsc::RecvTimeoutError::Timeout) => { + let Some(FileIndexRoot::Local(local_root)) = root.as_ref() else { continue }; + // Reading the handle here is intentional: keeping the watcher + // alive is what keeps platform notifications subscribed. + if watcher.is_none() { + continue; + } + if status.load(Ordering::Acquire) != INDEX_READY { + continue; + } + let mut changed_paths = Vec::new(); + while let Ok(event) = watch_rx.try_recv() { + match event { + Ok(event) if event_changes_names(&event.kind) => { + changed_paths.extend(event.paths); + }, + Ok(_) => {}, + Err(error) => log::debug!("file-search watcher error: {error}"), + } + } + if changed_paths.is_empty() { + continue; + } + patch_local_index( + local_root, + &mut entries, + changed_paths, + epoch, + &desired_epoch, + &indexed_count, + ); + truncated.store(entries.len() >= MAX_INDEX_ENTRIES, Ordering::Release); + if let Some(request) = current_query.as_ref() { + publish_search_result(&entries, request, &result_slot); + } + }, + Err(mpsc::RecvTimeoutError::Disconnected) => return, + } + } +} + +fn build_local_index( + root: &Path, + epoch: u64, + desired_epoch: &AtomicU64, + indexed_count: &AtomicUsize, + entries: &mut Vec, +) -> bool { + collect_local_path(root, root, false, epoch, desired_epoch, entries); + indexed_count.store(entries.len(), Ordering::Release); + desired_epoch.load(Ordering::Acquire) == epoch +} + +fn collect_local_path( + root: &Path, + start: &Path, + include_start: bool, + epoch: u64, + desired_epoch: &AtomicU64, + entries: &mut Vec, +) { + let mut directories = VecDeque::new(); + if include_start { + let Ok(file_type) = start.symlink_metadata().map(|metadata| metadata.file_type()) else { + return; + }; + if file_type.is_symlink() { + return; + } + if let Some(entry) = indexed_local_path(root, start, file_type.is_dir()) { + entries.push(entry); + } + if file_type.is_dir() { + directories.push_back(start.to_path_buf()); + } + } else { + directories.push_back(start.to_path_buf()); + } + + while let Some(directory) = directories.pop_front() { + if entries.len() >= MAX_INDEX_ENTRIES || desired_epoch.load(Ordering::Acquire) != epoch { + return; + } + let Ok(read) = std::fs::read_dir(&directory) else { continue }; + for child in read.flatten() { + if entries.len() >= MAX_INDEX_ENTRIES { + return; + } + let Ok(file_type) = child.file_type() else { continue }; + if file_type.is_symlink() { + continue; + } + let name = child.file_name(); + // The normal tree intentionally hides Git's object database; the + // search index follows that same user-visible boundary. + if file_type.is_dir() && name == ".git" { + continue; + } + let path = child.path(); + if let Some(entry) = indexed_local_path(root, &path, file_type.is_dir()) { + entries.push(entry); + } + if file_type.is_dir() { + directories.push_back(path); + } + } + } +} + +fn indexed_local_path(root: &Path, path: &Path, is_dir: bool) -> Option { + let relative = path.strip_prefix(root).ok()?; + let name = path.file_name()?.to_string_lossy().into_owned(); + let key = relative.to_string_lossy().replace('\\', "/"); + Some(IndexedPath { + path: path.to_path_buf(), + guest_path: None, + name_folded: name.to_lowercase(), + key_folded: key.to_lowercase(), + name, + key, + is_dir, + }) +} + +fn build_wsl_index( + located: &crate::shell_detect::WslCwd, + indexed_count: &AtomicUsize, + entries: &mut Vec, +) -> bool { + let mut rows = Vec::new(); + let mut budget = SEARCH_VISIT_BUDGET; + build_wsl_search_index(located, &mut rows, &mut budget); + let root = normalize_wsl_guest_path(&located.guest); + entries.extend(rows.into_iter().map(|row| { + let guest = row.guest_path.clone().unwrap_or_else(|| row.path.display().to_string()); + let key = guest.strip_prefix(&root).unwrap_or(&guest).trim_start_matches('/').to_owned(); + IndexedPath { + path: row.path, + guest_path: row.guest_path, + name_folded: row.name.to_lowercase(), + key_folded: key.to_lowercase(), + name: row.name, + key, + is_dir: row.is_dir, + } + })); + indexed_count.store(entries.len(), Ordering::Release); + true +} + +fn event_changes_names(kind: &EventKind) -> bool { + matches!( + kind, + EventKind::Any + | EventKind::Create(_) + | EventKind::Remove(_) + | EventKind::Modify(ModifyKind::Name(_)) + ) +} + +fn patch_local_index( + root: &Path, + entries: &mut Vec, + mut paths: Vec, + epoch: u64, + desired_epoch: &AtomicU64, + indexed_count: &AtomicUsize, +) { + paths.retain(|path| path.starts_with(root)); + paths.sort_by_key(|path| path.components().count()); + paths.dedup(); + let mut roots: Vec = Vec::new(); + for path in paths { + if !roots.iter().any(|parent| path.starts_with(parent)) { + roots.push(path); + } + } + if roots.len() > 64 || roots.iter().any(|path| path == root) { + entries.clear(); + let _ = build_local_index(root, epoch, desired_epoch, indexed_count, entries); + return; + } + + entries.retain(|entry| { + !roots.iter().any(|changed| entry.path == *changed || entry.path.starts_with(changed)) + }); + for changed in roots { + if entries.len() >= MAX_INDEX_ENTRIES { + break; + } + collect_local_path(root, &changed, true, epoch, desired_epoch, entries); + } + indexed_count.store(entries.len(), Ordering::Release); +} + +fn publish_search_result( + entries: &[IndexedPath], + request: &SearchRequest, + result_slot: &Mutex>, +) { + let result = search_entries(entries, request); + if let Ok(mut slot) = result_slot.lock() { + *slot = Some(result); + } +} + +enum QueryMatcher { + Plain { terms: Vec, whole_word: bool, match_case: bool }, + Regex(regex::Regex), +} + +impl QueryMatcher { + fn compile(query: &str, options: FileSearchOptions) -> Result { + if options.regex { + let source = if options.whole_word { + format!(r"(?:^|[^\p{{L}}\p{{N}}_])(?:{query})(?:$|[^\p{{L}}\p{{N}}_])") + } else { + query.to_owned() + }; + return regex::RegexBuilder::new(&source) + .case_insensitive(!options.match_case) + .build() + .map(Self::Regex) + .map_err(|error| error.to_string()); + } + let terms = query + .split_whitespace() + .map(|term| if options.match_case { term.to_owned() } else { term.to_lowercase() }) + .collect(); + Ok(Self::Plain { terms, whole_word: options.whole_word, match_case: options.match_case }) + } + + fn matches(&self, entry: &IndexedPath) -> bool { + match self { + Self::Regex(regex) => regex.is_match(&entry.key), + Self::Plain { terms, whole_word, match_case } => { + let haystack = if *match_case { &entry.key } else { &entry.key_folded }; + terms.iter().all(|term| { + if *whole_word { + contains_whole_word(haystack, term) + } else { + haystack.contains(term) + } + }) + }, + } + } +} + +fn contains_whole_word(haystack: &str, needle: &str) -> bool { + if needle.is_empty() { + return true; + } + haystack.match_indices(needle).any(|(start, matched)| { + let before = haystack[..start].chars().next_back(); + let after = haystack[start + matched.len()..].chars().next(); + !before.is_some_and(is_word_char) && !after.is_some_and(is_word_char) + }) +} + +fn is_word_char(character: char) -> bool { + character.is_alphanumeric() || character == '_' +} + +fn result_score(entry: &IndexedPath, query: &str, match_case: bool) -> u8 { + let (name, query) = if match_case { + (entry.name.as_str(), query.to_owned()) + } else { + (entry.name_folded.as_str(), query.to_lowercase()) + }; + if name == query { + 0 + } else if name.starts_with(&query) { + 1 + } else if name.contains(&query) { + 2 + } else { + 3 + } +} + +fn search_entries(entries: &[IndexedPath], request: &SearchRequest) -> FileSearchResult { + let matcher = match QueryMatcher::compile(&request.query, request.options) { + Ok(matcher) => matcher, + Err(error) => { + return FileSearchResult { + generation: request.generation, + query: request.query.clone(), + options: request.options, + rows: Vec::new(), + total: 0, + error: Some(error), + }; + }, + }; + let mut matches: Vec<(u8, &IndexedPath)> = entries + .iter() + .filter(|entry| matcher.matches(entry)) + .map(|entry| (result_score(entry, &request.query, request.options.match_case), entry)) + .collect(); + let total = matches.len(); + let compare = |(left_score, left): &(u8, &IndexedPath), + (right_score, right): &(u8, &IndexedPath)| { + left_score + .cmp(right_score) + .then(right.is_dir.cmp(&left.is_dir)) + .then(left.name_folded.cmp(&right.name_folded)) + .then(left.key_folded.cmp(&right.key_folded)) + }; + if matches.len() > MAX_SEARCH_RESULTS { + matches.select_nth_unstable_by(MAX_SEARCH_RESULTS, compare); + matches.truncate(MAX_SEARCH_RESULTS); + } + matches.sort_unstable_by(compare); + let rows = matches + .into_iter() + .take(MAX_SEARCH_RESULTS) + .map(|(_, entry)| FileRow { + path: entry.path.clone(), + guest_path: entry.guest_path.clone(), + name: entry.name.clone(), + depth: 0, + is_dir: entry.is_dir, + expanded: false, + is_parent: false, + ignored: false, + }) + .collect(); + FileSearchResult { + generation: request.generation, + query: request.query.clone(), + options: request.options, + rows, + total, + error: None, + } +} + +#[cfg(test)] +mod search_tests { + use super::*; + + fn entry(key: &str) -> IndexedPath { + let name = key.rsplit('/').next().unwrap_or(key).to_owned(); + IndexedPath { + path: PathBuf::from(key), + guest_path: None, + name_folded: name.to_lowercase(), + key_folded: key.to_lowercase(), + name, + key: key.to_owned(), + is_dir: false, + } + } + + #[test] + fn plain_search_is_case_insensitive_and_ands_terms() { + let entries = [entry("src/FileTree/SearchIndex.rs"), entry("docs/search.md")]; + let request = SearchRequest { + 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 { + 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 { + 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 { + 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 { + generation: 7, + query: "[".to_owned(), + options: FileSearchOptions { regex: true, ..Default::default() }, + }, + ); + assert_eq!(result.generation, 7); + assert!(result.rows.is_empty()); + assert!(result.error.is_some()); + } + + #[test] + fn local_index_crawls_nested_paths_but_hides_git_objects() { + let temp = tempfile::tempdir().expect("temp dir"); + std::fs::create_dir_all(temp.path().join("src/nested")).expect("source directories"); + std::fs::create_dir_all(temp.path().join(".git/objects")).expect("git directories"); + std::fs::write(temp.path().join("src/nested/needle.rs"), b"").expect("source file"); + std::fs::write(temp.path().join(".git/objects/hidden"), b"").expect("git object"); + + let epoch = AtomicU64::new(4); + let count = AtomicUsize::new(0); + let mut entries = Vec::new(); + assert!(build_local_index(temp.path(), 4, &epoch, &count, &mut entries)); + assert!(entries.iter().any(|entry| entry.key == "src/nested/needle.rs")); + assert!(!entries.iter().any(|entry| entry.key.contains(".git"))); + assert_eq!(count.load(Ordering::Acquire), entries.len()); + } + + #[test] + fn local_index_patch_replaces_removed_and_created_paths() { + let temp = tempfile::tempdir().expect("temp dir"); + let old = temp.path().join("old.txt"); + std::fs::write(&old, b"").expect("old file"); + let epoch = AtomicU64::new(8); + let count = AtomicUsize::new(0); + let mut entries = Vec::new(); + assert!(build_local_index(temp.path(), 8, &epoch, &count, &mut entries)); + + std::fs::remove_file(&old).expect("remove old file"); + let new_dir = temp.path().join("new"); + std::fs::create_dir(&new_dir).expect("new directory"); + std::fs::write(new_dir.join("fresh.txt"), b"").expect("new file"); + patch_local_index(temp.path(), &mut entries, vec![old, new_dir], 8, &epoch, &count); + + assert!(!entries.iter().any(|entry| entry.name == "old.txt")); + assert!(entries.iter().any(|entry| entry.key == "new/fresh.txt")); + assert_eq!(count.load(Ordering::Acquire), entries.len()); + } +} diff --git a/nebula_app/src/display/side_panel/tests.rs b/nebula_app/src/display/side_panel/tests.rs index 6d35528..48ddbf0 100644 --- a/nebula_app/src/display/side_panel/tests.rs +++ b/nebula_app/src/display/side_panel/tests.rs @@ -49,6 +49,8 @@ mod tests { assert!(!panel.custom_root_active()); assert_eq!(panel.root(), Some(next_cwd.as_path())); + panel.wait_snapshot(); + panel.file_index.release_for_test(); std::fs::remove_dir_all(&base).unwrap(); } @@ -72,6 +74,8 @@ mod tests { assert_eq!(panel.root(), Some(cwd.as_path())); assert_eq!(panel.root_notice(), Some("所选目录不可用,已跟随当前目录")); + panel.wait_snapshot(); + panel.file_index.release_for_test(); std::fs::remove_dir_all(&base).unwrap(); } @@ -340,6 +344,7 @@ mod tests { assert_eq!(rows[2].name, "inner.txt"); assert_eq!(rows[2].depth, 1); + p.file_index.release_for_test(); std::fs::remove_dir_all(&base).unwrap(); } @@ -396,6 +401,7 @@ mod tests { assert_eq!(panel.root(), Some(base.as_path())); assert!(panel.custom_root_active(), "upward navigation is window-local"); + panel.file_index.release_for_test(); std::fs::remove_dir_all(&base).unwrap(); } @@ -425,6 +431,7 @@ mod tests { stale.source_row = 2; assert!(!panel.click_drag_source(&stale), "a changed source row must be ignored"); + panel.file_index.release_for_test(); std::fs::remove_dir_all(&base).unwrap(); } @@ -474,6 +481,7 @@ mod tests { unstaged: vec![('?', "one.txt".into()), ('M', "two.txt".into())], staged: vec![('A', "three.txt".into())], conflicts: Vec::new(), + history: Vec::new(), repository_root: None, repository: None, }); @@ -570,6 +578,7 @@ mod tests { } #[test] + #[cfg(feature = "legacy-shell")] fn git_action_strip_has_four_equal_buttons() { let rects = git_button_rects(10.0, 430.0, 10.0); assert_eq!(rects.len(), 4); @@ -896,3 +905,22 @@ mod tests { assert_eq!(git_repository_root(&linked).as_deref(), Some(linked.as_path())); } } + +#[test] +fn git_history_parser_keeps_commit_identity_parents_and_fields() { + let output = concat!( + "\u{1e}aaaaaaaa\u{1f}a1b2c3d\u{1f}HEAD -> refs/heads/main, tag: refs/tags/v1\u{1f}ship it\u{1f}Alice\u{1f}1700000000\u{1f}1111111 2222222\n", + "\u{1e}dddddddd\u{1f}d4e5f6a\u{1f}refs/heads/topic\u{1f}side work\u{1f}Bob\u{1f}1699999000\u{1f}3333333\n", + ); + let commits = parse_git_history(output); + assert_eq!(commits.len(), 2); + let first = &commits[0]; + assert_eq!(first.full_hash, "aaaaaaaa"); + assert_eq!(first.short_hash, "a1b2c3d"); + assert_eq!(first.decorations, "HEAD -> refs/heads/main, tag: refs/tags/v1"); + assert_eq!(first.subject, "ship it"); + assert_eq!(first.author, "Alice"); + assert_eq!(first.timestamp, 1_700_000_000); + assert_eq!(first.parent_hashes, ["1111111", "2222222"]); + assert_eq!(commits[1].subject, "side work"); +} diff --git a/nebula_app/src/display/side_panel/vcs.rs b/nebula_app/src/display/side_panel/vcs.rs index 9071289..b2edc7a 100644 --- a/nebula_app/src/display/side_panel/vcs.rs +++ b/nebula_app/src/display/side_panel/vcs.rs @@ -465,7 +465,14 @@ impl SidePanel { }; let Some(root) = self.vcs_root().map(Path::to_path_buf) else { return false }; let mut command = std::process::Command::new(program); - command.args(visual.tortoise_args()).current_dir(root); + // GUI 子系统进程没有可继承的有效标准句柄;显式置 null,避免 Windows + // CreateProcess 因 STARTUPINFO 中的无效句柄返回 ERROR_INVALID_HANDLE。 + command + .args(visual.tortoise_args()) + .current_dir(root) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); #[cfg(windows)] { use std::os::windows::process::CommandExt; @@ -1027,9 +1034,57 @@ pub(crate) fn collect_git_info(run: impl Fn(&[&str]) -> Option) -> Optio } } } + // `%x1e` 标出提交记录的起点,`%x1f` 分字段。完整对象 ID 和父提交列表 + // 交给显示层生成真实拓扑;不读取 `--graph` 字符画,也就不会产生空的 + // 拓扑过渡行。时间取 Unix 秒,显示层再按当前时间生成中文相对时间。 + info.history = run(&[ + "log", + "--all", + "--date-order", + "--decorate=full", + "--max-count=128", + "--format=%x1e%H%x1f%h%x1f%D%x1f%s%x1f%an%x1f%ct%x1f%P", + ]) + .map(|output| parse_git_history(&output)) + .unwrap_or_default(); Some(info) } +/// Parse the delimiter-based format emitted above. Subjects and author names +/// may contain ordinary punctuation, spaces, or non-ASCII text; field +/// delimiters keep those values intact. +pub(crate) fn parse_git_history(output: &str) -> Vec { + output + .lines() + .filter_map(|line| { + let line = line.trim_end_matches('\r'); + let record_at = line.find('\x1e')?; + let mut fields = line[record_at + 1..].split('\x1f'); + let full_hash = fields.next()?.to_owned(); + let short_hash = fields.next()?.to_owned(); + let decorations = fields.next().unwrap_or_default().trim().to_owned(); + let subject = fields.next().unwrap_or_default().to_owned(); + let author = fields.next().unwrap_or_default().to_owned(); + let timestamp = fields.next().and_then(|value| value.parse().ok()).unwrap_or(0); + let parent_hashes = fields + .next() + .unwrap_or_default() + .split_whitespace() + .map(str::to_owned) + .collect(); + Some(GitCommit { + full_hash, + short_hash, + decorations, + subject, + author, + timestamp, + parent_hashes, + }) + }) + .collect() +} + /// Cheap preference hint for nested checkouts. `read_svn` still runs the /// authoritative `svn info` command, so this hint cannot make detection /// incorrect when metadata is represented differently by an SVN client. diff --git a/nebula_app/src/display/ssh_connect.rs b/nebula_app/src/display/ssh_connect.rs index bbe3417..fce5b07 100644 --- a/nebula_app/src/display/ssh_connect.rs +++ b/nebula_app/src/display/ssh_connect.rs @@ -14,14 +14,20 @@ use std::time::{Duration, Instant}; use unicode_width::UnicodeWidthChar; +use super::SizeInfo; use super::color::Rgb; use super::i18n::UiLanguage; +#[cfg(feature = "legacy-shell")] use super::ui::icons; use super::ui::tokens::{Density, radius, space, type_scale}; -use super::{NebulaTheme, SizeInfo}; +#[cfg(feature = "legacy-shell")] +use super::NebulaTheme; +#[cfg(feature = "legacy-shell")] use crate::renderer::ui::{Gradient, Rgba, UiQuad}; +#[cfg(feature = "legacy-shell")] use crate::renderer::{GlyphCache, Renderer}; use crate::ssh_session::SshStage; +#[cfg(feature = "legacy-shell")] use nebula_terminal::term::cell::Flags; /// 低于这个耗时的连接完全不显示卡片。 @@ -428,6 +434,7 @@ fn lerp_rgb(a: Rgb, b: Rgb, k: f32) -> Rgb { Rgb::new(mix(a.r, b.r), mix(a.g, b.g), mix(a.b, b.b)) } +#[cfg(feature = "legacy-shell")] #[allow(clippy::too_many_arguments)] pub(super) fn push_quads( state: &SshConnectState, @@ -761,6 +768,7 @@ pub(super) fn push_quads( /// 按钮底:主按钮实心 accent,次按钮 card 底 + hairline 描边。hover 只改 /// 亮度,不改尺寸——动效纪律里"不加装饰动效"的直接后果。 +#[cfg(feature = "legacy-shell")] fn push_button_frame( quads: &mut Vec, rect: (f32, f32, f32, f32), @@ -798,6 +806,7 @@ fn push_button_frame( } } +#[cfg(feature = "legacy-shell")] pub(super) fn rgb_of(c: Rgba) -> Rgb { Rgb::new(c.r, c.g, c.b) } @@ -841,6 +850,7 @@ pub(crate) fn truncate_cols(text: &str, max_cols: usize) -> String { out } +#[cfg(feature = "legacy-shell")] #[allow(clippy::too_many_arguments)] pub(super) fn draw_text( state: &SshConnectState, @@ -1091,7 +1101,7 @@ pub(crate) fn wrap(text: &str, per_line: usize) -> Vec { lines } -#[cfg(test)] +#[cfg(all(test, feature = "legacy-shell"))] mod tests { use super::*; diff --git a/nebula_app/src/display/ssh_ui.rs b/nebula_app/src/display/ssh_ui.rs index e5db8ea..00b7879 100644 --- a/nebula_app/src/display/ssh_ui.rs +++ b/nebula_app/src/display/ssh_ui.rs @@ -79,6 +79,32 @@ pub fn split_destination_port(destination: &str) -> (String, String) { (address, if port == "22" { String::new() } else { port.to_owned() }) } +/// 把不含端口的 SSH 地址拆成独立用户名与主机字段。 +/// +/// 存盘合同仍是 `user@host`;这里只服务编辑器。无用户名的 OpenSSH config +/// 别名返回空用户名,让解析层继续从 config 或系统账号补全,不能擅自改写别名。 +pub fn split_destination_user(address: &str) -> (String, String) { + let trimmed = address.trim(); + let rest = trimmed.strip_prefix("ssh://").unwrap_or(trimmed); + match rest.rsplit_once('@') { + Some((user, host)) if !user.is_empty() && !host.is_empty() => { + (user.to_owned(), host.to_owned()) + }, + _ => (String::new(), rest.to_owned()), + } +} + +/// 把编辑器里的用户名与主机重新拼回稳定的 destination 地址。 +/// +/// 主机框仍接受整段 `user@host` 粘贴;显式用户名优先,否则沿用粘贴内容里的 +/// 用户名。这样拆分 UI 不会牺牲原来“一次粘完整地址”的快速路径。 +pub fn join_destination_user(username: &str, address: &str) -> String { + let (embedded_user, host) = split_destination_user(address); + let user = username.trim(); + let user = if user.is_empty() { embedded_user.as_str() } else { user }; + if user.is_empty() { host } else { format!("{user}@{host}") } +} + /// 拼回存盘用的地址串。地址框里自带的端口优先——用户刚粘进去的那个才是意图, /// 端口框里的可能是上一台主机的残留。 pub fn join_destination_port(address: &str, port: &str) -> String { @@ -89,6 +115,23 @@ pub fn join_destination_port(address: &str, port: &str) -> String { if port.is_empty() || port == "22" { base } else { format!("{base}:{port}") } } +/// Merge Nebula-saved hosts with OpenSSH aliases, then float pinned entries +/// in pin order while preserving the stable order of every other host. +pub(crate) fn merge_ssh_hosts( + saved: &[String], + pinned: &[String], + hidden: &[String], +) -> Vec { + let mut hosts: Vec<_> = saved.iter().filter(|host| !hidden.contains(host)).cloned().collect(); + for host in crate::ssh::ssh_config_hosts() { + if !hidden.contains(&host) && !hosts.contains(&host) { + hosts.push(host); + } + } + hosts.sort_by_key(|host| pinned.iter().position(|pinned| pinned == host).unwrap_or(usize::MAX)); + hosts +} + #[derive(Debug)] pub(super) struct SshDeleteUndo { pub(super) host: String, @@ -408,7 +451,7 @@ impl SshEditorRects { mod tests { use super::{ SshEditorField, SshEditorSlot, auth_sections, editor_slots, join_destination_port, - push_private_key, split_destination_port, + join_destination_user, push_private_key, split_destination_port, split_destination_user, }; use crate::ssh_profiles::SshAuthMode; use std::path::PathBuf; @@ -490,4 +533,20 @@ mod tests { // 地址框里粘进来的端口压过端口框里的旧值。 assert_eq!(join_destination_port("dev@example.com:2022", "2222"), "dev@example.com:2022"); } + + #[test] + fn destination_user_is_editable_without_changing_the_storage_contract() { + assert_eq!( + split_destination_user("ssh://deploy@example.com"), + ("deploy".to_owned(), "example.com".to_owned()) + ); + assert_eq!(split_destination_user("prod"), (String::new(), "prod".to_owned())); + assert_eq!(join_destination_user("root", "192.0.2.10"), "root@192.0.2.10"); + assert_eq!( + join_destination_user("", "deploy@example.com"), + "deploy@example.com", + "完整地址粘贴仍应保留其中的用户名" + ); + assert_eq!(join_destination_user("admin", "deploy@example.com"), "admin@example.com"); + } } diff --git a/nebula_app/src/display/state.rs b/nebula_app/src/display/state.rs index 0736827..8bcd95a 100644 --- a/nebula_app/src/display/state.rs +++ b/nebula_app/src/display/state.rs @@ -233,6 +233,10 @@ pub struct NebulaPaneState { pub(crate) completion_suppressed_line: Option, pub line_buf: String, pub(crate) screen_line: String, + /// Shell prompt captured with the command submitted from this pane. It is + /// retained while an Agent owns the foreground so WSL/SSH sessions without + /// a reliable OSC 133;D can prove that the real shell prompt returned. + pub(crate) pending_command_prompt: Option, pub touched: bool, pub inline_images: Vec, pub command_started: Option, diff --git a/nebula_app/src/display/suggest_engine.rs b/nebula_app/src/display/suggest_engine.rs index ff9444c..3cb71ec 100644 --- a/nebula_app/src/display/suggest_engine.rs +++ b/nebula_app/src/display/suggest_engine.rs @@ -209,6 +209,16 @@ impl SuggestEnv { pub fn is_this_machine(&self) -> bool { matches!(self, Self::Local) } + + pub(crate) fn history_scope(&self) -> crate::nebula_history::HistoryScope { + match self { + Self::Local => crate::nebula_history::HistoryScope::Local, + Self::Wsl { distro } => crate::nebula_history::HistoryScope::Wsl(distro.clone()), + Self::Ssh { destination } => { + crate::nebula_history::HistoryScope::Ssh(destination.clone()) + }, + } + } } /// Refresh the inline ghost remainder or the popup candidate list for the @@ -269,7 +279,7 @@ pub(crate) fn suggest_update( // History first: newest command that extends the whole line (indexed // prefix lookup — scales with matches, not history size). - if let Some(rem) = sources.history.hint(&line) { + if let Some(rem) = sources.history.hint(&state.suggest_env.history_scope(), &line) { state.suggestion = clamp_ghost(rem); nebula_debug_log(format!("suggest_result kind=history rem={:?}", state.suggestion)); return; @@ -461,7 +471,7 @@ fn suggest_collect(sources: &SuggestSources<'_>, state: &mut NebulaPaneState, li }; // Whole-line history matches, newest first. - for (full, rem) in sources.history.hints(line, 3) { + for (full, rem) in sources.history.hints(&state.suggest_env.history_scope(), line, 3) { push( &mut items, NebulaCompletionItem { diff --git a/nebula_app/src/display/terminal_math.rs b/nebula_app/src/display/terminal_math.rs index 7db128d..44346c6 100644 --- a/nebula_app/src/display/terminal_math.rs +++ b/nebula_app/src/display/terminal_math.rs @@ -21,7 +21,9 @@ use crate::display::content::RenderableCell; use crate::math::cache::{FormulaCacheKey, MathLayoutCache}; use crate::math::layout::{MathLayout, MathMetrics}; use crate::math::{DEFAULT_LIMITS, MIN_READABLE_MATH_PX, compile_formula}; +#[cfg(feature = "legacy-shell")] use crate::renderer::math::MathClip; +#[cfg(feature = "legacy-shell")] use crate::renderer::{GlyphCache, Renderer}; const MAX_VISIBLE_FORMULAS: usize = 64; @@ -2591,6 +2593,7 @@ pub(crate) fn plan_overlay_draw( /// replaced source glyphs with spaces while retaining their resolved terminal /// backgrounds, so no opaque cover quad is needed here. #[allow(clippy::too_many_arguments)] +#[cfg(feature = "legacy-shell")] pub(crate) fn draw_overlays( renderer: &mut Renderer, glyph_cache: &mut GlyphCache, diff --git a/nebula_app/src/display/text_path_model.rs b/nebula_app/src/display/text_path_model.rs new file mode 100644 index 0000000..9db8462 --- /dev/null +++ b/nebula_app/src/display/text_path_model.rs @@ -0,0 +1,107 @@ +//! Display-width-aware text and local-link helpers shared by both UI shells. + +use unicode_width::UnicodeWidthChar; + +pub(crate) fn strip_file_scheme(uri: &str) -> String { + match uri.strip_prefix("file:///").or_else(|| uri.strip_prefix("file://")) { + Some(rest) => percent_decode_lossy(rest), + None => uri.to_owned(), + } +} + +pub(crate) fn percent_decode_lossy(value: &str) -> String { + if !value.contains('%') { + return value.to_owned(); + } + let bytes = value.as_bytes(); + let mut output = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + let decoded = (bytes[index] == b'%' && index + 2 < bytes.len()) + .then(|| { + let high = (bytes[index + 1] as char).to_digit(16)?; + let low = (bytes[index + 2] as char).to_digit(16)?; + Some((high * 16 + low) as u8) + }) + .flatten(); + match decoded { + Some(byte) => { + output.push(byte); + index += 3; + }, + None => { + output.push(bytes[index]); + index += 1; + }, + } + } + String::from_utf8(output).unwrap_or_else(|_| value.to_owned()) +} + +pub(crate) fn fit_tail(value: &str, budget: usize) -> String { + let width = |character: char| character.width().unwrap_or(0); + let total: usize = value.chars().map(width).sum(); + if total <= budget { + return value.to_owned(); + } + if budget == 0 { + return String::new(); + } + + let room = budget.saturating_sub(1); + let mut kept = std::collections::VecDeque::new(); + let mut used = 0; + for character in value.chars().rev() { + let character_width = width(character); + if used + character_width > room { + break; + } + used += character_width; + kept.push_front(character); + } + let mut output = String::with_capacity(kept.len() + 1); + output.push('\u{2026}'); + output.extend(kept); + output +} + +pub(crate) fn truncate_tab_label(label: &str, max_columns: usize) -> String { + let total: usize = label.chars().map(|character| character.width().unwrap_or(0)).sum(); + if total <= max_columns { + return label.to_owned(); + } + if max_columns <= 1 { + return "\u{2026}".to_owned(); + } + + let budget = max_columns - 1; + let mut used = 0; + let mut text = String::new(); + for character in label.chars() { + let character_width = character.width().unwrap_or(0); + if used + character_width > budget { + break; + } + used += character_width; + text.push(character); + } + text.push('\u{2026}'); + text +} + +#[cfg(test)] +mod tests { + use super::{fit_tail, strip_file_scheme, truncate_tab_label}; + + #[test] + fn local_uri_is_decoded_before_tail_fitting() { + let decoded = strip_file_scheme("file:///D:/%E6%98%9F%E9%9B%B2/read%20me.txt"); + assert_eq!(decoded, "D:/\u{661f}\u{96f2}/read me.txt"); + assert_eq!(fit_tail(&decoded, 13), "\u{2026}/read me.txt"); + } + + #[test] + fn tab_label_respects_wide_character_columns() { + assert_eq!(truncate_tab_label("ab\u{661f}\u{96f2}cd", 6), "ab\u{661f}\u{2026}"); + } +} diff --git a/nebula_app/src/display/ui/color_math.rs b/nebula_app/src/display/ui/color_math.rs new file mode 100644 index 0000000..d1388cd --- /dev/null +++ b/nebula_app/src/display/ui/color_math.rs @@ -0,0 +1,22 @@ +//! Backend-neutral RGBA composition used by both shell renderers. + +use crate::renderer::ui::Rgba; + +/// Fade a color by `progress`, preserving its RGB channels. +#[inline] +pub fn fade(color: Rgba, progress: f32) -> Rgba { + if progress >= 1.0 { + return color; + } + Rgba::new(color.r, color.g, color.b, (color.a as f32 * progress.clamp(0.0, 1.0)).round() as u8) +} + +/// Composite `top` over `base`, retaining the base alpha channel. +#[inline] +pub fn over(top: Rgba, base: Rgba) -> Rgba { + let alpha = top.a as f32 / 255.0; + let mix = |top: u8, base: u8| { + (top as f32 * alpha + base as f32 * (1.0 - alpha)).round().clamp(0.0, 255.0) as u8 + }; + Rgba::new(mix(top.r, base.r), mix(top.g, base.g), mix(top.b, base.b), base.a) +} diff --git a/nebula_app/src/display/ui/geometry.rs b/nebula_app/src/display/ui/geometry.rs new file mode 100644 index 0000000..e49346e --- /dev/null +++ b/nebula_app/src/display/ui/geometry.rs @@ -0,0 +1,94 @@ +//! Backend-neutral geometry shared by GPUI and the legacy renderer. + +pub(crate) type Rect = (f32, f32, f32, f32); + +/// Return the top coordinate for content vertically centered in a container. +#[inline] +pub(crate) fn centered_y(container_y: f32, container_h: f32, content_h: f32) -> f32 { + container_y + (container_h - content_h) * 0.5 +} + +/// Outer and inset rectangles for a modal pane. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct PaneGeometry { + pub(crate) panel: Rect, + pub(crate) content: Rect, +} + +/// Compute a pane rect and its inset content rect in physical pixels. +pub(crate) fn pane_geometry( + win_w: f32, + win_h: f32, + scale: f32, + desired_w: f32, + desired_h: f32, + margin: f32, + inset: f32, + top: Option, +) -> PaneGeometry { + let scaled_margin = margin * scale; + pane_geometry_in_horizontal_bounds( + win_w, + win_h, + scale, + desired_w, + desired_h, + scaled_margin / scale.max(f32::EPSILON), + inset, + top, + (scaled_margin, (win_w - scaled_margin).max(scaled_margin)), + ) +} + +/// Compute a pane inside explicit physical-pixel horizontal bounds. +pub(crate) fn pane_geometry_in_horizontal_bounds( + win_w: f32, + win_h: f32, + scale: f32, + desired_w: f32, + desired_h: f32, + margin: f32, + inset: f32, + top: Option, + horizontal_bounds: (f32, f32), +) -> PaneGeometry { + let scaled = |value: f32| value * scale; + let margin = scaled(margin); + let inset = scaled(inset); + let left = horizontal_bounds.0.clamp(0.0, win_w); + let right = horizontal_bounds.1.clamp(left, win_w); + let available_w = (right - left).max(0.0); + let panel_w = desired_w.min(available_w); + let panel_h = desired_h.min((win_h - 2.0 * margin).max(0.0)); + let panel_x = left + (available_w - panel_w) * 0.5; + let centered_y = ((win_h - panel_h) * 0.5).max(margin); + let panel_y = top + .map_or(centered_y, |value| value.min((win_h - panel_h - margin).max(margin)).max(margin)); + PaneGeometry { + panel: (panel_x, panel_y, panel_w, panel_h), + content: ( + panel_x + inset, + panel_y + inset, + (panel_w - inset * 2.0).max(0.0), + (panel_h - inset * 2.0).max(0.0), + ), + } +} + +#[cfg(test)] +mod tests { + use super::{centered_y, pane_geometry}; + + #[test] + fn centered_content_uses_the_container_center_line() { + assert_eq!(centered_y(10.0, 40.0, 16.0), 22.0); + assert_eq!(centered_y(10.0, 40.0, 40.0), 10.0); + } + + #[test] + fn pane_geometry_keeps_the_panel_inside_window_margins() { + let pane = pane_geometry(800.0, 600.0, 2.0, 900.0, 700.0, 8.0, 12.0, None); + assert_eq!(pane.panel, (16.0, 16.0, 768.0, 568.0)); + assert_eq!(pane.content, (40.0, 40.0, 720.0, 520.0)); + } +} diff --git a/nebula_app/src/display/ui/keycap.rs b/nebula_app/src/display/ui/keycap.rs index b88a133..a357f20 100644 --- a/nebula_app/src/display/ui/keycap.rs +++ b/nebula_app/src/display/ui/keycap.rs @@ -14,8 +14,7 @@ use super::tokens::radius; use crate::display::color::Rgb; use crate::renderer::ui::{Rgba, UiQuad}; -/// Chip height in logical px(与 palette 底栏既有键帽一致)。 -pub const KEY_H: f32 = 20.0; +pub use super::keycap_model::KEY_H; /// 每颗键帽内的左右呼吸缝。单键也要留出足够边距,避免文字贴住轮廓。 const PAD_X: f32 = 7.0; diff --git a/nebula_app/src/display/ui/keycap_model.rs b/nebula_app/src/display/ui/keycap_model.rs new file mode 100644 index 0000000..f0ff994 --- /dev/null +++ b/nebula_app/src/display/ui/keycap_model.rs @@ -0,0 +1,4 @@ +//! Backend-neutral keycap metrics. + +/// Keycap height in logical pixels. +pub const KEY_H: f32 = 20.0; diff --git a/nebula_app/src/display/ui/mod.rs b/nebula_app/src/display/ui/mod.rs index dc41961..b470541 100644 --- a/nebula_app/src/display/ui/mod.rs +++ b/nebula_app/src/display/ui/mod.rs @@ -50,14 +50,19 @@ //! 那一步的收益是编译器强制单向依赖 + 独立编译单元(改 UI 不再重编译整个 app)。 pub mod caret; +mod color_math; pub mod guardrails; +pub(crate) mod geometry; pub mod icons; pub mod keycap; +mod keycap_model; pub mod os_icons; pub mod overlay_list; +mod scrollbar; pub mod surface; pub mod text_field; pub mod theme; pub mod toast; +mod toast_kind; pub mod tokens; pub mod widgets; diff --git a/nebula_app/src/display/ui/scrollbar.rs b/nebula_app/src/display/ui/scrollbar.rs new file mode 100644 index 0000000..9ff362e --- /dev/null +++ b/nebula_app/src/display/ui/scrollbar.rs @@ -0,0 +1,63 @@ +//! Backend-neutral overlay-scrollbar geometry and hit testing. + +pub(crate) type Rect = (f32, f32, f32, f32); + +/// Geometry for a trackless overlay scrollbar. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct OverlayScrollbar { + pub(crate) thumb: Rect, + pub(crate) hit: Rect, + track_y: f32, + track_h: f32, + max_scroll: f32, +} + +impl OverlayScrollbar { + pub(crate) fn hit_test(self, x: f32, y: f32) -> bool { + let (left, top, width, height) = self.hit; + x >= left && x < left + width && y >= top && y < top + height + } + + pub(crate) fn target_scroll(self, y: f32, grab: f32) -> f32 { + let travel = (self.track_h - self.thumb.3).max(1.0); + let thumb_y = (y - grab - self.track_y).clamp(0.0, travel); + self.max_scroll * thumb_y / travel + } + + pub(crate) fn target_offset(self, y: f32, grab: f32, max_offset: usize) -> usize { + if self.max_scroll <= 0.0 { + return 0; + } + (self.target_scroll(y, grab) / self.max_scroll * max_offset as f32) + .round() + .clamp(0.0, max_offset as f32) as usize + } +} + +pub(crate) fn overlay_scrollbar( + area: Rect, + viewport: f32, + content: f32, + scroll: f32, + scale: f32, +) -> Option { + let max_scroll = content - viewport; + if viewport <= 0.0 || max_scroll <= 0.0 { + return None; + } + let scaled = |value: f32| value * scale; + let track_y = area.1 + scaled(6.0); + let track_h = (area.3 - scaled(12.0)).max(scaled(26.0)); + let thumb_h = (track_h * viewport / content).max(scaled(26.0)).min(track_h); + let thumb_y = track_y + (track_h - thumb_h) * (scroll / max_scroll).clamp(0.0, 1.0); + let visual_w = scaled(5.0); + let hit_w = scaled(14.0); + let right = area.0 + area.2 - scaled(4.0); + Some(OverlayScrollbar { + thumb: (right - visual_w, thumb_y, visual_w, thumb_h), + hit: (right - hit_w, track_y, hit_w, track_h), + track_y, + track_h, + max_scroll, + }) +} diff --git a/nebula_app/src/display/ui/surface.rs b/nebula_app/src/display/ui/surface.rs index 7e9b80b..6df3623 100644 --- a/nebula_app/src/display/ui/surface.rs +++ b/nebula_app/src/display/ui/surface.rs @@ -24,6 +24,8 @@ use super::theme::Skin; use super::tokens::{Density, control, elevation, radius}; use crate::renderer::ui::{Rgba, UiQuad}; +pub use super::color_math::{fade, over}; + /// `(x, y, width, height)`,逻辑像素。 pub type Rect = (f32, f32, f32, f32); @@ -80,27 +82,6 @@ pub enum CardState { Selected, } -/// 按入场进度衰减 alpha。`progress` 取 0..1,1 为完全不透明。 -#[inline] -pub fn fade(color: Rgba, progress: f32) -> Rgba { - if progress >= 1.0 { - return color; - } - Rgba::new(color.r, color.g, color.b, (color.a as f32 * progress.clamp(0.0, 1.0)).round() as u8) -} - -/// `top` 压在 `base` 上的合成色,alpha 取 `base` 的。 -/// -/// 用来把"半透明叠加色 + 不透明底"预先算成一个色,画一个 quad 而不是两个。 -/// 这不只是省一次绘制:见 [`push_group`],中间那层必须不透明,否则底下的 -/// 描边环会从整块面板里渗出来。 -#[inline] -pub fn over(top: Rgba, base: Rgba) -> Rgba { - let a = top.a as f32 / 255.0; - let mix = |t: u8, b: u8| (t as f32 * a + b as f32 * (1.0 - a)).round().clamp(0.0, 255.0) as u8; - Rgba::new(mix(top.r, base.r), mix(top.g, base.g), mix(top.b, base.b), base.a) -} - /// 发丝描边环。替代散落各处的手写 `(x-1, y-1, w+2, h+2)`。 /// /// 外圆角自动取 `radius + hairline`,保证内外弧**同心**——手写处常常内外用同一 diff --git a/nebula_app/src/display/ui/text_field.rs b/nebula_app/src/display/ui/text_field.rs index 0bc29fa..dc34988 100644 --- a/nebula_app/src/display/ui/text_field.rs +++ b/nebula_app/src/display/ui/text_field.rs @@ -22,8 +22,11 @@ //! 成"落在末尾",而不是 panic。 use super::caret; +#[cfg(feature = "legacy-shell")] use super::theme::Skin; +#[cfg(feature = "legacy-shell")] use super::tokens::{radius, space}; +#[cfg(feature = "legacy-shell")] use crate::renderer::ui::{Rgba, UiQuad}; use unicode_width::UnicodeWidthChar; @@ -243,6 +246,7 @@ pub fn index_at(text: &str, offset_x: f32, cell_w: f32) -> usize { /// /// `text_x` 由调用方给,因为对齐方式是调用方的事(端口居中、其余靠左)—— /// 组件层只负责让光标落在与文字同一套换算上。 +#[cfg(feature = "legacy-shell")] #[allow(clippy::too_many_arguments)] pub fn push_cursor( quads: &mut Vec, diff --git a/nebula_app/src/display/ui/toast.rs b/nebula_app/src/display/ui/toast.rs index 1f25062..a9b365a 100644 --- a/nebula_app/src/display/ui/toast.rs +++ b/nebula_app/src/display/ui/toast.rs @@ -16,6 +16,8 @@ use super::tokens::{Density, radius, space}; use crate::display::color::Rgb; use crate::renderer::ui::{Rgba, UiQuad}; +pub use super::toast_kind::ToastKind; + pub(crate) type Rect = (f32, f32, f32, f32); /// 单条高度的下限(逻辑像素)。实际高度还要保证容得下一行文字。 @@ -29,17 +31,6 @@ const RAIL_W: f32 = 3.0; /// 入场时先压低这么多再浮上来;淡出时反向沉下去。 const RISE: f32 = 10.0; -/// 提示的语气。语义色只花在左边那根细条上——强调色与渐变的预算不动。 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ToastKind { - /// 中性信息。 - Info, - /// 已经办成的事:恢复成功、保存成功。 - Success, - /// 值得知道,但不需要动手。真正要用户处理的仍然走消息栏。 - Warning, -} - impl ToastKind { fn rail(self, sk: &Skin) -> Rgba { match self { diff --git a/nebula_app/src/display/ui/toast_kind.rs b/nebula_app/src/display/ui/toast_kind.rs new file mode 100644 index 0000000..3cb213f --- /dev/null +++ b/nebula_app/src/display/ui/toast_kind.rs @@ -0,0 +1,8 @@ +//! Backend-neutral semantic intent for transient notifications. + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToastKind { + Info, + Success, + Warning, +} diff --git a/nebula_app/src/display/ui/widgets.rs b/nebula_app/src/display/ui/widgets.rs index 75168f9..1c49fd6 100644 --- a/nebula_app/src/display/ui/widgets.rs +++ b/nebula_app/src/display/ui/widgets.rs @@ -17,90 +17,11 @@ use super::theme::Skin; use super::tokens::{Density, radius}; use crate::renderer::ui::{Rgba, UiQuad}; +pub(crate) use super::scrollbar::{OverlayScrollbar, overlay_scrollbar}; +pub(crate) use super::geometry::{centered_y, pane_geometry, pane_geometry_in_horizontal_bounds}; + pub(crate) type Rect = (f32, f32, f32, f32); -/// Return the top coordinate for content vertically centered in a container. -/// Keeping this calculation in the UI layer makes text, icons and compact -/// controls share one alignment rule instead of reimplementing it at call sites. -#[inline] -pub(crate) fn centered_y(container_y: f32, container_h: f32, content_h: f32) -> f32 { - container_y + (container_h - content_h) * 0.5 -} - -/// Shared geometry for modal panes. The shell owns the dimming/surface pass, -/// while callers use the same outer rect to place their content and hit-test. -/// Keeping this in the widget layer prevents each pane from inventing slightly -/// different centering, margins, or top anchoring rules. -#[derive(Debug, Clone, Copy, PartialEq)] -pub(crate) struct PaneGeometry { - pub(crate) panel: Rect, - pub(crate) content: Rect, -} - -/// Compute a pane rect and its inset content rect in physical pixels. -/// `top` anchors the pane when present; otherwise it is vertically centered. -pub(crate) fn pane_geometry( - win_w: f32, - win_h: f32, - scale: f32, - desired_w: f32, - desired_h: f32, - margin: f32, - inset: f32, - top: Option, -) -> PaneGeometry { - let s = |v: f32| v * scale; - let margin = s(margin); - pane_geometry_in_horizontal_bounds( - win_w, - win_h, - scale, - desired_w, - desired_h, - margin / scale.max(f32::EPSILON), - inset, - top, - (margin, (win_w - margin).max(margin)), - ) -} - -/// Compute a pane inside explicit physical-pixel horizontal bounds. The bounds -/// reserve neighboring UI regions (for example Tabs and the file drawer), while -/// vertical clamping keeps the same window-margin contract as [`pane_geometry`]. -pub(crate) fn pane_geometry_in_horizontal_bounds( - win_w: f32, - win_h: f32, - scale: f32, - desired_w: f32, - desired_h: f32, - margin: f32, - inset: f32, - top: Option, - horizontal_bounds: (f32, f32), -) -> PaneGeometry { - let s = |v: f32| v * scale; - let margin = s(margin); - let inset = s(inset); - let left = horizontal_bounds.0.clamp(0.0, win_w); - let right = horizontal_bounds.1.clamp(left, win_w); - let available_w = (right - left).max(0.0); - let panel_w = desired_w.min(available_w); - let panel_h = desired_h.min((win_h - 2.0 * margin).max(0.0)); - let panel_x = left + (available_w - panel_w) * 0.5; - let centered_y = ((win_h - panel_h) * 0.5).max(margin); - let panel_y = top - .map_or(centered_y, |value| value.min((win_h - panel_h - margin).max(margin)).max(margin)); - PaneGeometry { - panel: (panel_x, panel_y, panel_w, panel_h), - content: ( - panel_x + inset, - panel_y + inset, - (panel_w - inset * 2.0).max(0.0), - (panel_h - inset * 2.0).max(0.0), - ), - } -} - /// Row-height of one dropdown option in logical px. pub(crate) const POPUP_ROW_H: f32 = 36.0; /// Closed combobox / spinner control height in logical px. @@ -119,68 +40,6 @@ pub(crate) enum ChipState { // ---- overlay scrollbar ---- -/// Geometry for a trackless overlay scrollbar. `hit` is intentionally wider -/// than `thumb`: the control stays easy to grab without painting a dark rail. -#[derive(Debug, Clone, Copy, PartialEq)] -pub(crate) struct OverlayScrollbar { - pub(crate) thumb: Rect, - pub(crate) hit: Rect, - track_y: f32, - track_h: f32, - max_scroll: f32, -} - -impl OverlayScrollbar { - pub(crate) fn hit_test(self, x: f32, y: f32) -> bool { - crate::display::contains_rect(self.hit, x, y) - } - - /// Map a pointer y coordinate to content scroll, preserving the point at - /// which the thumb was grabbed. Track presses pass half the thumb height. - pub(crate) fn target_scroll(self, y: f32, grab: f32) -> f32 { - let travel = (self.track_h - self.thumb.3).max(1.0); - let thumb_y = (y - grab - self.track_y).clamp(0.0, travel); - self.max_scroll * thumb_y / travel - } - - pub(crate) fn target_offset(self, y: f32, grab: f32, max_offset: usize) -> usize { - if self.max_scroll <= 0.0 { - return 0; - } - (self.target_scroll(y, grab) / self.max_scroll * max_offset as f32) - .round() - .clamp(0.0, max_offset as f32) as usize - } -} - -pub(crate) fn overlay_scrollbar( - area: Rect, - viewport: f32, - content: f32, - scroll: f32, - scale: f32, -) -> Option { - let max_scroll = content - viewport; - if viewport <= 0.0 || max_scroll <= 0.0 { - return None; - } - let s = |v: f32| v * scale; - let track_y = area.1 + s(6.0); - let track_h = (area.3 - s(12.0)).max(s(26.0)); - let thumb_h = (track_h * viewport / content).max(s(26.0)).min(track_h); - let thumb_y = track_y + (track_h - thumb_h) * (scroll / max_scroll).clamp(0.0, 1.0); - let visual_w = s(5.0); - let hit_w = s(14.0); - let right = area.0 + area.2 - s(4.0); - Some(OverlayScrollbar { - thumb: (right - visual_w, thumb_y, visual_w, thumb_h), - hit: (right - hit_w, track_y, hit_w, track_h), - track_y, - track_h, - max_scroll, - }) -} - /// Paint only the thumb. The transparent hit region is geometry, never a rail. pub(crate) fn push_overlay_scrollbar( quads: &mut Vec, @@ -699,18 +558,12 @@ pub(crate) fn push_spinner( #[cfg(test)] mod tests { use super::{ - ChipState, ToggleMotion, centered_y, opaque_panel, overlay_scrollbar, push_chip, - push_radio, push_toggle, radio_rect, toggle_rect, + ChipState, ToggleMotion, opaque_panel, overlay_scrollbar, push_chip, push_radio, + push_toggle, radio_rect, toggle_rect, }; use crate::display::ui::theme::NebulaTheme; use crate::renderer::ui::{Rgba, UiQuad}; - #[test] - fn centered_y_uses_the_container_center_line() { - assert_eq!(centered_y(10.0, 40.0, 16.0), 22.0); - assert_eq!(centered_y(10.0, 40.0, 40.0), 10.0); - } - #[test] fn radio_uses_only_opaque_flat_state_colors() { let row = (10.0, 20.0, 240.0, 44.0); diff --git a/nebula_app/src/encrypted_backup.rs b/nebula_app/src/encrypted_backup.rs index 5b30e5e..a15e28c 100644 --- a/nebula_app/src/encrypted_backup.rs +++ b/nebula_app/src/encrypted_backup.rs @@ -201,7 +201,9 @@ fn collect_from(root: &Path, selection: BackupSelection) -> Result { - add_file(root, category, "nebula_history.jsonl", &mut entries, identity) + for file_name in crate::nebula_history::history_file_names() { + add_file(root, category, file_name, &mut entries, identity); + } }, BackupCategory::Fonts => add_fonts(root, &mut entries)?, } @@ -384,7 +386,9 @@ fn validate_archive(archive: &BackupArchive) -> Result<(), String> { BackupCategory::Assistant => entry.name == "nebula_assistant.txt", BackupCategory::Session => entry.name == "session.json", BackupCategory::DirectoryHistory => entry.name == "directory_history.json", - BackupCategory::CommandHistory => entry.name == "nebula_history.jsonl", + BackupCategory::CommandHistory => { + crate::nebula_history::history_file_names().contains(&entry.name.as_str()) + }, BackupCategory::Fonts => { entry.name.starts_with("fonts/") && entry.name.len() > "fonts/".len() }, @@ -472,6 +476,38 @@ mod tests { ); } + #[test] + fn command_history_collection_and_restore_include_all_scopes() { + let source = tempdir().unwrap(); + for (index, file_name) in + crate::nebula_history::history_file_names().into_iter().enumerate() + { + fs::write(source.path().join(file_name), format!("history-{index}")).unwrap(); + } + let selection = BackupSelection { + appearance: false, + command_history: true, + ..BackupSelection::default() + }; + + let archive = collect_from(source.path(), selection).unwrap(); + assert_eq!( + archive.entries.iter().map(|entry| entry.name.as_str()).collect::>(), + crate::nebula_history::history_file_names() + ); + + let restored = tempdir().unwrap(); + restore_to(restored.path(), &archive).unwrap(); + for (index, file_name) in + crate::nebula_history::history_file_names().into_iter().enumerate() + { + assert_eq!( + fs::read_to_string(restored.path().join(file_name)).unwrap(), + format!("history-{index}") + ); + } + } + #[test] fn invalid_terminal_profiles_json_is_rejected_before_restore() { let directory = tempdir().unwrap(); diff --git a/nebula_app/src/event.rs b/nebula_app/src/event.rs index 0f74f65..f4102a0 100644 --- a/nebula_app/src/event.rs +++ b/nebula_app/src/event.rs @@ -2016,22 +2016,22 @@ impl<'a, N: Notify + 'a, T: EventListener> input::ActionContext for ActionCon #[inline] fn nebula_input_char(&mut self, c: char) { - Display::nebula_input_char(self.nebula_state, c); + crate::display::nebula_input_char(self.nebula_state, c); } #[inline] fn nebula_input_text(&mut self, text: &str) { - Display::nebula_input_text(self.nebula_state, text); + crate::display::nebula_input_text(self.nebula_state, text); } #[inline] fn nebula_input_backspace(&mut self) { - Display::nebula_input_backspace(self.nebula_state); + crate::display::nebula_input_backspace(self.nebula_state); } #[inline] fn nebula_delete_word(&mut self) { - Display::nebula_input_delete_word(self.nebula_state); + crate::display::nebula_input_delete_word(self.nebula_state); } #[inline] @@ -2040,16 +2040,37 @@ impl<'a, N: Notify + 'a, T: EventListener> input::ActionContext for ActionCon // hasn't processed the newline yet, so the row still shows the full // line, while the cached `screen_line` is one draw behind and commits // a truncated command on type-fast-then-Enter. + let agent_already_active = self + .nebula_state + .running_program + .as_deref() + .and_then(crate::ai_agents::AgentKind::parse) + .is_some(); + if !agent_already_active { + self.nebula_state.pending_command_prompt = None; + } #[cfg(windows)] - if !self.terminal.mode().intersects(TermMode::ALT_SCREEN | TermMode::VI) + if !agent_already_active + && !self.terminal.mode().intersects(TermMode::ALT_SCREEN | TermMode::VI) && self.search_state.regex().is_none() { let cursor = self.terminal.grid().cursor.point; - match Display::nebula_input_from_raw_grid(self.terminal, cursor) { - Some(line) => self.nebula_state.screen_line = line, + match crate::display::nebula_prompt_line_from_raw_grid( + self.terminal, + cursor, + &self.nebula_state.line_buf, + &self.nebula_state.suggest_env, + ) { + Some(line) => { + self.nebula_state.screen_line = line.input; + self.nebula_state.pending_command_prompt = Some(line.prompt); + }, // A failed read means the cached copy is stale too — an // earlier partial line must not get recorded as this command. - None => self.nebula_state.screen_line.clear(), + None => { + self.nebula_state.screen_line.clear(); + self.nebula_state.pending_command_prompt = None; + }, } } self.display.nebula_commit_line(self.nebula_state); @@ -2057,7 +2078,7 @@ impl<'a, N: Notify + 'a, T: EventListener> input::ActionContext for ActionCon #[inline] fn nebula_clear_line(&mut self) { - Display::nebula_clear_line(self.nebula_state); + crate::display::nebula_clear_line(self.nebula_state); } fn nebula_tab(&self, request: TabRequest) { @@ -3391,31 +3412,6 @@ impl input::Processor> { self.ctx.display.nebula_record_directory(&cwd); *self.ctx.dirty = true; } - if matches!( - self.ctx.nebula_state.agent_status_source, - crate::ai_agents::AgentStatusSource::Screen - | crate::ai_agents::AgentStatusSource::Process - ) && !self.ctx.nebula_state.agent_hook_seen - && self - .ctx - .nebula_state - .running_program - .as_deref() - .and_then(crate::ai_agents::AgentKind::parse) - .is_some() - { - self.ctx.nebula_state.running_program = None; - self.ctx.nebula_state.agent_status = - crate::ai_agents::AgentStatus::Unknown; - self.ctx.nebula_state.agent_status_source = - crate::ai_agents::AgentStatusSource::Unknown; - self.ctx.nebula_state.agent_status_rule = None; - self.ctx.nebula_state.idle_screen_streak = 0; - self.ctx.nebula_state.command_started = None; - self.ctx.nebula_state.awaiting_input = false; - self.ctx.nebula_state.needs_attention = false; - *self.ctx.dirty = true; - } }, TerminalEvent::InlineImage { png, abs_line, width, height } => { // Decode off the PTY thread (here, on the UI loop) and @@ -3518,6 +3514,10 @@ impl input::Processor> { self.ctx.nebula_state.agent_status_source = crate::ai_agents::AgentStatusSource::Unknown; self.ctx.nebula_state.agent_status_rule = None; + self.ctx.nebula_state.pending_command_prompt = None; + self.ctx.nebula_state.agent_runtime_submit_pending = false; + self.ctx.nebula_state.runtime_submit_barrier = None; + self.ctx.nebula_state.idle_screen_streak = 0; let pending_ssh = self.ctx.nebula_state.pending_ssh_host.take(); self.ctx.nebula_state.awaiting_input = false; if let Some(run) = self.ctx.nebula_state.active_run.take() { diff --git a/nebula_app/src/gpui_shell/assets.rs b/nebula_app/src/gpui_shell/assets.rs index 4fa8fbd..e5338c6 100644 --- a/nebula_app/src/gpui_shell/assets.rs +++ b/nebula_app/src/gpui_shell/assets.rs @@ -24,7 +24,15 @@ macro_rules! icons { }; } -const NEBULA_ICONS: &[(&str, &[u8])] = icons!["command-manager", "keymap", "pencil", "trash-2"]; +const NEBULA_ICONS: &[(&str, &[u8])] = icons![ + "command-manager", + "keymap", + "pencil", + "trash-2", + "vcs-changes", + "vcs-history", + "vcs-conflict", +]; /// 先查本仓库,未命中再交给组件库。 pub struct NebulaAssets; @@ -59,4 +67,10 @@ pub mod nav { pub const PENCIL: &str = "icons/nebula-pencil.svg"; /// Lucide trash-2;删除保存命令不能借用表示 Backspace 的 `IconName::Delete`。 pub const TRASH: &str = "icons/nebula-trash-2.svg"; + /// IDEA Commit 工具窗口同语义的“基线 + 提交节点”:工作区变更入口。 + pub const VCS_CHANGES: &str = "icons/nebula-vcs-changes.svg"; + /// 带分叉节点的提交线路:版本历史入口。 + pub const VCS_HISTORY: &str = "icons/nebula-vcs-history.svg"; + /// 两侧分支汇入结果并在交点标出冲突:三栏冲突解决入口。 + pub const VCS_CONFLICT: &str = "icons/nebula-vcs-conflict.svg"; } diff --git a/nebula_app/src/gpui_shell/code_tab.rs b/nebula_app/src/gpui_shell/code_tab.rs index 436b5d9..43883f9 100644 --- a/nebula_app/src/gpui_shell/code_tab.rs +++ b/nebula_app/src/gpui_shell/code_tab.rs @@ -1,28 +1,27 @@ -//! 代码查看 Tab:tree-sitter 语法高亮 + 行级虚拟化的只读查看器。 +//! 代码文件 Tab:普通文件只读查看;Git 冲突使用同一 Tab 体系打开三栏合并器。 //! -//! 渲染走组件库 `InputState::code_editor`——它按可视行布局/绘制(约 5 万行 -//! 量级依旧流畅),正好满足"超大代码只渲染可视范围"的性能合同;Markdown -//! 文档继续走 `TextView`(根级块虚拟化),两条管线各管各的文件类型。 -//! -//! 只读策略:这是查看器不是编辑器——文件树双击的诉求是"看一眼源码", -//! 写盘语义(保存/冲突/编码回写)超出本 tab 的合同。 +//! 普通代码继续走组件库的虚拟化 code editor。冲突页遵循 IntelliJ 一类合并器 +//! 的空间语义:左侧当前版本、中间可编辑结果、右侧传入版本;外侧两栏只读, +//! 应用结果后写回工作树并 `git add`,不会另开浮窗或挤占 VCS 抽屉。 -use std::path::{Path, PathBuf}; +use std::io::Write as _; +use std::path::{Component, Path, PathBuf}; +use std::process::{Command, Stdio}; use gpui::prelude::FluentBuilder as _; use gpui::{ - AppContext as _, ClipboardItem, Context, Entity, IntoElement, ParentElement as _, Render, - SharedString, Styled as _, Window, div, px, relative, + AppContext as _, ClipboardItem, Context, Entity, EventEmitter, IntoElement, ParentElement as _, + Render, SharedString, Styled as _, Window, div, px, relative, }; +use crate::display::side_panel::GitLocation; use crate::gpui_shell::prelude::*; /// 一次性读入的上限。行级虚拟化解决的是渲染成本,解析/塑形仍随内容量 -/// 增长;8MB 已覆盖常见源码文件,超限截断并在文首说明。 +/// 增长;8MB 已覆盖常见源码文件,超限的普通文件截断,冲突文件则拒绝写回。 const MAX_CODE_BYTES: usize = 8 * 1024 * 1024; -/// 扩展名 → tree-sitter 语言 id(组件库 highlighter 的注册名)。 -/// 未命中的扩展仍可打开(纯文本、无高亮)。 +/// 扩展名 -> tree-sitter 语言 id(组件库 highlighter 的注册名)。 fn language_for_extension(extension: &str) -> Option<&'static str> { Some(match extension.to_ascii_lowercase().as_str() { "rs" => "rust", @@ -59,26 +58,87 @@ fn language_for_extension(extension: &str) -> Option<&'static str> { "vim" => "vim", "xml" => "xml", "json" | "jsonl" | "ndjson" => "json", - // 纯文本走 Plain(无高亮):诉求是行号 + 行级虚拟化,不是着色。 "txt" | "log" | "text" => "text", _ => return None, }) } -/// 文件树双击是否进代码查看 tab(与 markdown/图片路由并列;三者都不认的 -/// 交系统处理器)。 +fn language_for_path(path: &str) -> &'static str { + Path::new(path) + .extension() + .and_then(|extension| extension.to_str()) + .and_then(language_for_extension) + .unwrap_or("text") +} + +/// 文件树双击是否进代码查看 tab(与 markdown/图片路由并列)。 pub fn viewable_file(path: &Path) -> bool { path.extension() .and_then(|extension| extension.to_str()) .is_some_and(|extension| language_for_extension(extension).is_some()) } +pub enum CodeTabViewEvent { + GitConflictResolved, +} + +impl EventEmitter for CodeTabView {} + +#[derive(Clone)] +struct MergeKey { + location: GitLocation, + relative_path: String, +} + +impl MergeKey { + fn matches(&self, location: &GitLocation, relative_path: &str) -> bool { + &self.location == location && self.relative_path == relative_path + } + + fn display_path(&self) -> String { + match &self.location { + GitLocation::Local { root } => root.join(&self.relative_path).display().to_string(), + GitLocation::Wsl { distro, root } => { + format!("{distro}:{}", join_guest_path(root, &self.relative_path)) + }, + } + } +} + +#[derive(Clone)] +enum MergeState { + Loading, + Ready, + Saving, + Resolved, + Error(String), +} + +struct MergeEditor { + key: MergeKey, + ours: Entity, + theirs: Entity, + ours_text: String, + theirs_text: String, + ours_missing: bool, + theirs_missing: bool, + state: MergeState, +} + +struct ConflictDocument { + ours: Option, + theirs: Option, + result: String, +} + pub struct CodeTabView { pub path: PathBuf, pub title: String, + /// 普通文件的正文;冲突页中它就是唯一可编辑的中栏合并结果。 input: Entity, notice: Option, lines: usize, + merge: Option, } impl CodeTabView { @@ -87,25 +147,64 @@ impl CodeTabView { .file_name() .map(|name| name.to_string_lossy().into_owned()) .unwrap_or_else(|| path.display().to_string()); - let language = path - .extension() - .and_then(|extension| extension.to_str()) - .and_then(language_for_extension) - .unwrap_or("text"); - let input = cx.new(|cx| { - InputState::new(window, cx) - .code_editor(language) - .line_number(true) - .indent_guides(true) - .soft_wrap(false) - }); - let mut this = Self { path, title, input, notice: None, lines: 0 }; + let language = language_for_path(&path.to_string_lossy()); + let input = code_input(language, window, cx); + let mut this = Self { path, title, input, notice: None, lines: 0, merge: None }; this.reload(window, cx); this } - /// 重新读盘(文件树再次双击同一路径时宿主调用,与 doc tab 同义)。 + pub fn new_git_merge( + location: GitLocation, + relative_path: String, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let title = Path::new(&relative_path) + .file_name() + .map(|name| format!("合并 · {}", name.to_string_lossy())) + .unwrap_or_else(|| format!("合并 · {relative_path}")); + let language = language_for_path(&relative_path); + let input = code_input(language, window, cx); + let ours = code_input(language, window, cx); + let theirs = code_input(language, window, cx); + // `path` 只用于现有 tab 图标/标题合同;冲突 tab 的真实唯一键是 MergeKey。 + let path = PathBuf::from(&relative_path); + let mut this = Self { + path, + title, + input, + notice: None, + lines: 0, + merge: Some(MergeEditor { + key: MergeKey { location, relative_path }, + ours, + theirs, + ours_text: String::new(), + theirs_text: String::new(), + ours_missing: false, + theirs_missing: false, + state: MergeState::Loading, + }), + }; + this.reload_git_merge(window, cx); + this + } + + pub fn is_regular_path(&self, path: &Path) -> bool { + self.merge.is_none() && self.path == path + } + + pub fn matches_git_merge(&self, location: &GitLocation, relative_path: &str) -> bool { + self.merge.as_ref().is_some_and(|merge| merge.key.matches(location, relative_path)) + } + + /// 重新读盘(文件树再次双击同一路径时宿主调用)。 pub fn reload(&mut self, window: &mut Window, cx: &mut Context) { + if self.merge.is_some() { + self.reload_git_merge(window, cx); + return; + } let (text, notice) = match std::fs::read(&self.path) { Ok(bytes) => { let truncated = bytes.len() > MAX_CODE_BYTES; @@ -125,10 +224,111 @@ impl CodeTabView { self.input.update(cx, |input, cx| input.set_value(text, window, cx)); cx.notify(); } -} -impl Render for CodeTabView { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + pub fn reload_git_merge(&mut self, window: &mut Window, cx: &mut Context) { + let Some(merge) = self.merge.as_mut() else { return }; + merge.state = MergeState::Loading; + self.notice = None; + let key = merge.key.clone(); + let task = cx.background_executor().spawn(async move { load_conflict(&key) }); + let window_handle = window.window_handle(); + cx.spawn(async move |this, cx| { + let loaded = task.await; + let _ = window_handle.update(cx, |_, window, cx| { + let _ = this.update(cx, |view, cx| { + view.apply_loaded_conflict(loaded, window, cx); + }); + }); + }) + .detach(); + cx.notify(); + } + + fn apply_loaded_conflict( + &mut self, + loaded: Result, + window: &mut Window, + cx: &mut Context, + ) { + let Some(merge) = self.merge.as_mut() else { return }; + match loaded { + Ok(document) => { + merge.ours_missing = document.ours.is_none(); + merge.theirs_missing = document.theirs.is_none(); + merge.ours_text = document.ours.unwrap_or_default(); + merge.theirs_text = document.theirs.unwrap_or_default(); + merge + .ours + .update(cx, |input, cx| input.set_value(merge.ours_text.clone(), window, cx)); + merge + .theirs + .update(cx, |input, cx| input.set_value(merge.theirs_text.clone(), window, cx)); + self.lines = document.result.lines().count(); + self.input.update(cx, |input, cx| input.set_value(document.result, window, cx)); + merge.state = MergeState::Ready; + }, + Err(error) => merge.state = MergeState::Error(error), + } + cx.notify(); + } + + fn adopt_ours(&mut self, window: &mut Window, cx: &mut Context) { + let Some(merge) = self.merge.as_ref() else { return }; + if !matches!(merge.state, MergeState::Ready) { + return; + } + let value = merge.ours_text.clone(); + self.lines = value.lines().count(); + self.input.update(cx, |input, cx| input.set_value(value, window, cx)); + cx.notify(); + } + + fn adopt_theirs(&mut self, window: &mut Window, cx: &mut Context) { + let Some(merge) = self.merge.as_ref() else { return }; + if !matches!(merge.state, MergeState::Ready) { + return; + } + let value = merge.theirs_text.clone(); + self.lines = value.lines().count(); + self.input.update(cx, |input, cx| input.set_value(value, window, cx)); + cx.notify(); + } + + fn save_git_merge(&mut self, cx: &mut Context) { + let Some(merge) = self.merge.as_mut() else { return }; + if !matches!(merge.state, MergeState::Ready) { + return; + } + let result = self.input.read(cx).value().to_string(); + if contains_conflict_markers(&result) { + self.notice = Some("合并结果中仍有冲突标记,请处理后再应用".to_owned()); + cx.notify(); + return; + } + merge.state = MergeState::Saving; + self.notice = None; + let key = merge.key.clone(); + let task = + cx.background_executor().spawn(async move { write_conflict_result(&key, result) }); + cx.spawn(async move |this, cx| { + let saved = task.await; + let _ = this.update(cx, |view, cx| { + let Some(merge) = view.merge.as_mut() else { return }; + match saved { + Ok(()) => { + merge.state = MergeState::Resolved; + cx.emit(CodeTabViewEvent::GitConflictResolved); + }, + Err(error) => merge.state = MergeState::Error(error), + } + cx.notify(); + }); + }) + .detach(); + cx.notify(); + } + + fn render_file(&self, cx: &mut Context) -> gpui::AnyElement { let theme = cx.theme(); let muted = theme.muted_foreground; let border = theme.border; @@ -205,19 +405,506 @@ impl Render for CodeTabView { .child(notice), ) }) + .child(div().flex_1().min_h_0().child(editor( + &self.input, + false, + mono_family, + mono_size, + ))) + .into_any_element() + } + + fn render_merge(&self, cx: &mut Context) -> gpui::AnyElement { + let merge = self.merge.as_ref().expect("merge renderer requires merge state"); + let theme = cx.theme(); + let muted = theme.muted_foreground; + let border = theme.border; + let mono_family = theme.mono_font_family.clone(); + let mono_size = theme.mono_font_size; + let display_path = merge.key.display_path(); + let ready = matches!(merge.state, MergeState::Ready); + let unresolved = contains_conflict_markers(self.input.read(cx).value().as_ref()); + let (status, status_color) = match &merge.state { + MergeState::Loading => ("正在读取三个 Git 阶段…".to_owned(), muted), + MergeState::Ready if unresolved => ("仍有冲突标记".to_owned(), theme.warning), + MergeState::Ready => ("可应用".to_owned(), theme.success), + MergeState::Saving => ("正在写回并暂存…".to_owned(), muted), + MergeState::Resolved => ("已写回并暂存".to_owned(), theme.success), + MergeState::Error(error) => (error.clone(), theme.danger), + }; + let ours_label = + if merge.ours_missing { "当前版本(文件不存在)" } else { "当前版本" }; + let theirs_label = + if merge.theirs_missing { "传入版本(文件不存在)" } else { "传入版本" }; + + v_flex() + .size_full() + .overflow_hidden() .child( - div().flex_1().min_h_0().child( - Input::new(&self.input) - .h_full() - .bordered(false) - .focus_bordered(false) - .rounded(px(0.0)) - .font_family(mono_family) - .text_size(mono_size) - .line_height(relative(1.55)) - .px(px(14.0)) - .py(px(10.0)), - ), + h_flex() + .h(px(38.0)) + .flex_shrink_0() + .px(px(12.0)) + .items_center() + .gap_2() + .border_b_1() + .border_color(border) + .child( + Icon::new(Icon::empty()) + .path(crate::gpui_shell::assets::nav::VCS_CONFLICT) + .small() + .text_color(theme.warning), + ) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .font_family(mono_family.clone()) + .text_size(px(12.0)) + .child(display_path), + ) + .child( + div().min_w_0().truncate().text_xs().text_color(status_color).child(status), + ) + .child( + Button::new("merge-reload") + .icon(IconName::Redo2) + .ghost() + .xsmall() + .tooltip("重新读取冲突阶段") + .on_click(cx.listener(|this, _, window, cx| { + this.reload_git_merge(window, cx); + })), + ) + .child( + Button::new("merge-apply") + .icon(IconName::Check) + .label("应用并暂存") + .small() + .disabled(!ready || unresolved) + .tooltip(if unresolved { + "请先清除合并结果中的冲突标记" + } else { + "写回工作树并执行 git add" + }) + .on_click(cx.listener(|this, _, _, cx| this.save_git_merge(cx))), + ), ) + .when_some(self.notice.clone(), |root, notice| { + root.child( + h_flex() + .min_h(px(28.0)) + .flex_shrink_0() + .px(px(12.0)) + .border_b_1() + .border_color(border) + .text_xs() + .text_color(theme.warning) + .child(notice), + ) + }) + .child( + h_flex() + .flex_1() + .min_h_0() + .w_full() + .child(merge_pane( + ours_label, + &merge.ours, + true, + Some( + Button::new("merge-use-ours") + .icon(IconName::ArrowRight) + .ghost() + .xsmall() + .disabled(!ready) + .tooltip("用当前版本替换合并结果") + .on_click(cx.listener(|this, _, window, cx| { + this.adopt_ours(window, cx); + })), + ), + mono_family.clone(), + mono_size, + border, + muted, + )) + .child(merge_pane( + "合并结果(可编辑)", + &self.input, + false, + None, + mono_family.clone(), + mono_size, + border, + theme.foreground, + )) + .child(merge_pane( + theirs_label, + &merge.theirs, + true, + Some( + Button::new("merge-use-theirs") + .icon(IconName::ArrowLeft) + .ghost() + .xsmall() + .disabled(!ready) + .tooltip("用传入版本替换合并结果") + .on_click(cx.listener(|this, _, window, cx| { + this.adopt_theirs(window, cx); + })), + ), + mono_family, + mono_size, + border, + muted, + )), + ) + .into_any_element() + } +} + +impl Render for CodeTabView { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + if self.merge.is_some() { self.render_merge(cx) } else { self.render_file(cx) } + } +} + +fn code_input( + language: &'static str, + window: &mut Window, + cx: &mut Context, +) -> Entity { + cx.new(|cx| { + InputState::new(window, cx) + .code_editor(language) + .line_number(true) + .indent_guides(true) + .soft_wrap(false) + }) +} + +fn editor( + state: &Entity, + read_only: bool, + family: SharedString, + size: gpui::Pixels, +) -> Input { + Input::new(state) + .h_full() + .disabled(read_only) + .bordered(false) + .focus_bordered(false) + .rounded(px(0.0)) + .font_family(family) + .text_size(size) + .line_height(relative(1.55)) + .px(px(10.0)) + .py(px(8.0)) +} + +#[allow(clippy::too_many_arguments)] +fn merge_pane( + label: impl Into, + state: &Entity, + read_only: bool, + action: Option