release: prepare Nebula Terminal 1.5.0

This commit is contained in:
kuddev committed 2026-09-01 21:15:45 +08:00
1 parent 02d56a6011
commit ab38dcdd71
106 files changed
+7137 -2452

No files matched your search

+44
View File
@@ -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
Generated
+7 -7
View File
@@ -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",
+62
View File
@@ -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
<a href="https://github.com/Sakyvo"><img src="https://github.com/Sakyvo.png?size=96" width="64" height="64" alt="@Sakyvo avatar"></a>
**[@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
+38 -2
View File
@@ -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=()
+64 -5
View File
@@ -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 <AGENT> <TEXT>..."
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
+15 -9
View File
@@ -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'
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "nebula-completions"
version = "1.4.1"
version = "1.5.0"
edition.workspace = true
[features]
+5 -5
View File
@@ -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"] }
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M3 12h6M15 12h6" stroke-linecap="round"/>
<circle cx="12" cy="12" r="3.5"/>
</svg>

After

Width:  |  Height:  |  Size: 210 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M5 4 L5 10 M5 10 C 5 12, 12 10, 12 12 L12 20" fill="none" stroke="currentColor" stroke-width="2"/>
<path d="M19 4 L19 10 M19 10 C 19 12, 12 10, 12 12 L12 20" fill="none" stroke="currentColor" stroke-width="2"/>
<path d="M10 10 L14 14 M14 10 L10 14" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 385 B

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 5v14"/>
<path d="M9 9c0 2 6 1 6 4v2"/>
<circle cx="9" cy="5" r="2" fill="currentColor" stroke="none"/>
<circle cx="9" cy="19" r="2" fill="currentColor" stroke="none"/>
<circle cx="15" cy="16" r="2" fill="currentColor" stroke="none"/>
</svg>

After

Width:  |  Height:  |  Size: 424 B

+3
View File
@@ -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\":\"<one-line reason>\",\"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<Event>, cfg: AssistantConfig, req: FixRequest) {
let spawned = std::thread::Builder::new().name("nebula-ai-fix".into()).spawn(move || {
let fix = request_fix(&cfg, &req);
+6 -1
View File
@@ -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<AiHookEvent> {
@@ -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<Event>) {
spawn_pipe_server(move |event| {
proxy.send_event(Event::new(EventType::AiHook(event), None)).is_ok()
+2
View File
@@ -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<Event>,
+2 -1
View File
@@ -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.
+1
View File
@@ -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;
@@ -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);
}
}
+1 -28
View File
@@ -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 和右侧操作按钮因此不会
@@ -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<String> {
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<String> {
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<String> {
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<String> {
#[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<String> {
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<String> {
let mut commands = nebula_path_commands();
#[cfg(windows)]
{
let mut seen: HashSet<String> = 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<String> {
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<std::sync::Mutex<Vec<String>>> {
static COMMANDS: std::sync::OnceLock<std::sync::Arc<std::sync::Mutex<Vec<String>>>> =
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<String> {
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"));
}
}
+23 -3
View File
@@ -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;
+42 -5
View File
@@ -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<nebula_settings::ExactTermColors>,
}
#[cfg(feature = "legacy-shell")]
impl<'a> RenderableContent<'a> {
pub fn new<T: EventListener>(
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<Hyperlink>,
}
#[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<usize>,
@@ -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<Vec<char>>,
}
#[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<Cow<'a, [Match]>>) -> Self {
@@ -912,6 +948,7 @@ impl<'a> HintMatches<'a> {
}
}
#[cfg(feature = "legacy-shell")]
impl Deref for HintMatches<'_> {
type Target = [Match];
+1 -61
View File
@@ -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<Rgb> },
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,
@@ -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<Rgb> },
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),
}
+29
View File
@@ -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")));
}
}
+48 -8
View File
@@ -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<String> {
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<PathBuf> {
platform::pick_file(owner, "导入终端字体", FONT_FILTERS)
}
#[cfg(feature = "legacy-shell")]
pub(super) fn pick_private_key_file(owner: &Window) -> Option<Result<PathBuf, String>> {
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<PathBu
}
}
#[cfg(feature = "legacy-shell")]
pub(super) fn pick_upload_files(owner: &Window) -> Vec<PathBuf> {
platform::pick_files(owner, "选择要上传的文件", &[ALL_FILES_FILTER])
}
#[cfg(feature = "legacy-shell")]
pub(super) fn pick_upload_directory(owner: &Window) -> Option<PathBuf> {
platform::pick_folder(owner, "选择要上传的文件夹")
}
#[cfg(feature = "legacy-shell")]
pub(super) fn pick_download_directory(owner: &Window) -> Option<PathBuf> {
platform::pick_folder(owner, "选择下载位置")
}
#[cfg(feature = "legacy-shell")]
pub(super) fn pick_side_panel_directory(owner: &Window) -> Option<PathBuf> {
platform::pick_folder(owner, "选择目录树根目录")
}
#[cfg(feature = "legacy-shell")]
pub(super) fn pick_startup_directory(owner: &Window) -> Option<PathBuf> {
platform::pick_folder(owner, "选择终端启动目录")
}
#[cfg(feature = "legacy-shell")]
pub(super) fn pick_terminal_directory(owner: &Window) -> Option<PathBuf> {
platform::pick_folder(owner, "导入终端目录")
}
#[cfg(feature = "legacy-shell")]
pub(super) fn save_workspace_file(owner: &Window, default_name: &str) -> Option<PathBuf> {
platform::save_file(owner, "导出工作区", WORKSPACE_FILTERS, default_name)
}
#[cfg(feature = "legacy-shell")]
pub(super) fn pick_workspace_file(owner: &Window) -> Option<PathBuf> {
platform::pick_file(owner, "打开工作区", WORKSPACE_FILTERS)
}
#[cfg(feature = "legacy-shell")]
pub(super) fn save_backup_file(owner: &Window) -> Option<PathBuf> {
platform::save_file(owner, "导出 Nebula 备份", BACKUP_FILTERS, "nebula-backup.nebula-backup")
}
#[cfg(feature = "legacy-shell")]
pub(super) fn pick_backup_file(owner: &Window) -> Option<PathBuf> {
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
);
@@ -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<PathBuf> {
dialog(owner, title, filters).pick_file()
}
@@ -16,14 +19,17 @@ pub(super) fn pick_file_unowned(title: &str, filters: &[FileFilter]) -> Option<P
dialog.pick_file()
}
#[cfg(feature = "legacy-shell")]
pub(super) fn pick_files(owner: &Window, title: &str, filters: &[FileFilter]) -> Vec<PathBuf> {
dialog(owner, title, filters).pick_files().unwrap_or_default()
}
#[cfg(feature = "legacy-shell")]
pub(super) fn pick_folder(owner: &Window, title: &str) -> Option<PathBuf> {
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 {
@@ -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<PathBuf> {
let owner = match owner {
RawWindowHandle::Win32(handle) => handle.hwnd.get() as HWND,
@@ -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<PathBuf> {
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<PathBuf> {
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<PathBuf> {
windows_folder::pick(owner.raw_window_handle(), title)
}
@@ -45,6 +51,7 @@ pub(super) fn pick_folder_with_hwnd(hwnd: HWND, title: &str) -> Option<PathBuf>
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,
+41
View File
@@ -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<u16> = 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())
}
+5
View File
@@ -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,
+416
View File
@@ -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<T: EventListener>(
terminal: &Term<T>,
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<T: EventListener>(
terminal: &Term<T>,
cursor: Point,
typed_tail: &str,
env: &SuggestEnv,
) -> Option<String> {
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<T: EventListener>(
terminal: &Term<T>,
cursor: Point,
typed_tail: &str,
env: &SuggestEnv,
) -> Option<PromptLineSnapshot> {
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<T: EventListener>(
terminal: &Term<T>,
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<T: EventListener>(terminal: &Term<T>, cursor: Point) -> Option<String> {
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<PromptLineSnapshot> {
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<String> {
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));
}
}
+1 -8
View File
@@ -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) ----
File diff suppressed because it is too large. Load diff
@@ -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));
}
}
}
+105
View File
@@ -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<u8>, 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<AiLogo> {
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<AiLogo> {
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}",
}
}
+13 -184
View File
@@ -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();
@@ -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<FileRow>,
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))
}
@@ -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);
}
}
+225 -58
View File
@@ -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<String>,
}
/// 三栏合并器运行 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<GitCommit>,
/// 仅 [`VcsKind::SvnRepository`] 使用;工作副本和 Git 的操作目录取
/// [`SidePanel::vcs_root`],服务端仓库必须保留祖先扫描得到的真实根。
pub repository_root: Option<PathBuf>,
@@ -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<PathBuf>,
/// Latest focused pane cwd, retained while a custom root is active so the
@@ -273,6 +315,10 @@ pub struct SidePanel {
root_notice: Option<String>,
/// Flattened visible tree rows for the Files view.
rows: Vec<FileRow>,
/// 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<FileRow>,
/// Directories the user expanded (persists across refreshes).
expanded: HashSet<PathBuf>,
/// 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<Vec<FileRow>>,
/// 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<String>,
/// 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<GitLocation> {
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<String>) -> 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.
@@ -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<Rgb> {
+735
View File
@@ -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<FileIndexRoot>,
epoch: u64,
query: Option<SearchRequest>,
},
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<FileRow>,
pub(crate) total: usize,
pub(crate) error: Option<String>,
}
#[derive(Debug)]
struct IndexedPath {
path: PathBuf,
guest_path: Option<String>,
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<IndexCommand>,
result: Arc<Mutex<Option<FileSearchResult>>>,
desired_epoch: Arc<AtomicU64>,
status: Arc<AtomicU8>,
indexed_count: Arc<AtomicUsize>,
truncated: Arc<AtomicBool>,
}
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<FileIndexRoot>,
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<FileSearchResult> {
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<IndexCommand>,
result_slot: Arc<Mutex<Option<FileSearchResult>>>,
desired_epoch: Arc<AtomicU64>,
status: Arc<AtomicU8>,
indexed_count: Arc<AtomicUsize>,
truncated: Arc<AtomicBool>,
) {
let (watch_tx, watch_rx) = mpsc::channel();
let mut watcher: Option<RecommendedWatcher> = None;
let mut root: Option<FileIndexRoot> = None;
let mut epoch = 0;
let mut entries = Vec::new();
let mut current_query: Option<SearchRequest> = 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<IndexedPath>,
) -> 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<IndexedPath>,
) {
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<IndexedPath> {
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<IndexedPath>,
) -> 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<IndexedPath>,
mut paths: Vec<PathBuf>,
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<PathBuf> = 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<Option<FileSearchResult>>,
) {
let result = search_entries(entries, request);
if let Ok(mut slot) = result_slot.lock() {
*slot = Some(result);
}
}
enum QueryMatcher {
Plain { terms: Vec<String>, whole_word: bool, match_case: bool },
Regex(regex::Regex),
}
impl QueryMatcher {
fn compile(query: &str, options: FileSearchOptions) -> Result<Self, String> {
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, &regex).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());
}
}
@@ -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");
}
+56 -1
View File
@@ -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<String>) -> 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<GitCommit> {
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.
+12 -2
View File
@@ -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<UiQuad>,
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<String> {
lines
}
#[cfg(test)]
#[cfg(all(test, feature = "legacy-shell"))]
mod tests {
use super::*;
+60 -1
View File
@@ -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<String> {
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");
}
}
+4
View File
@@ -233,6 +233,10 @@ pub struct NebulaPaneState {
pub(crate) completion_suppressed_line: Option<String>,
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<String>,
pub touched: bool,
pub inline_images: Vec<NebulaInlineImage>,
pub command_started: Option<std::time::Instant>,
+12 -2
View File
@@ -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 {
+3
View File
@@ -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,
+107
View File
@@ -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}");
}
}
+22
View File
@@ -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)
}
+94
View File
@@ -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<f32>,
) -> 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<f32>,
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));
}
}
+1 -2
View File
@@ -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;
@@ -0,0 +1,4 @@
//! Backend-neutral keycap metrics.
/// Keycap height in logical pixels.
pub const KEY_H: f32 = 20.0;
+5
View File
@@ -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;
+63
View File
@@ -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<OverlayScrollbar> {
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,
})
}
+2 -21
View File
@@ -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`,保证内外弧**同心**——手写处常常内外用同一
+4
View File
@@ -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<UiQuad>,
+2 -11
View File
@@ -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 {
+8
View File
@@ -0,0 +1,8 @@
//! Backend-neutral semantic intent for transient notifications.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToastKind {
Info,
Success,
Warning,
}
+5 -152
View File
@@ -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<f32>,
) -> 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<f32>,
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<OverlayScrollbar> {
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<UiQuad>,
@@ -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);
+38 -2
View File
@@ -201,7 +201,9 @@ fn collect_from(root: &Path, selection: BackupSelection) -> Result<BackupArchive
add_file(root, category, "directory_history.json", &mut entries, identity)
},
BackupCategory::CommandHistory => {
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::<Vec<_>>(),
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();
+34 -34
View File
@@ -2016,22 +2016,22 @@ impl<'a, N: Notify + 'a, T: EventListener> input::ActionContext<T> 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<T> 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<T> 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<EventProxy, ActionContext<'_, Notifier, EventProxy>> {
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<EventProxy, ActionContext<'_, Notifier, EventProxy>> {
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() {
+15 -1
View File
@@ -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";
}
+732 -45
View File
@@ -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<CodeTabViewEvent> 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<InputState>,
theirs: Entity<InputState>,
ours_text: String,
theirs_text: String,
ours_missing: bool,
theirs_missing: bool,
state: MergeState,
}
struct ConflictDocument {
ours: Option<String>,
theirs: Option<String>,
result: String,
}
pub struct CodeTabView {
pub path: PathBuf,
pub title: String,
/// 普通文件的正文;冲突页中它就是唯一可编辑的中栏合并结果。
input: Entity<InputState>,
notice: Option<String>,
lines: usize,
merge: Option<MergeEditor>,
}
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>,
) -> 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<Self>) {
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<Self>) -> impl IntoElement {
pub fn reload_git_merge(&mut self, window: &mut Window, cx: &mut Context<Self>) {
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<ConflictDocument, String>,
window: &mut Window,
cx: &mut Context<Self>,
) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) -> 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<Self>) -> 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<Self>) -> 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<CodeTabView>,
) -> Entity<InputState> {
cx.new(|cx| {
InputState::new(window, cx)
.code_editor(language)
.line_number(true)
.indent_guides(true)
.soft_wrap(false)
})
}
fn editor(
state: &Entity<InputState>,
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<SharedString>,
state: &Entity<InputState>,
read_only: bool,
action: Option<Button>,
family: SharedString,
size: gpui::Pixels,
border: gpui::Hsla,
label_color: gpui::Hsla,
) -> gpui::AnyElement {
let label: SharedString = label.into();
v_flex()
.flex_1()
.min_w_0()
.h_full()
.border_r_1()
.border_color(border)
.child(
h_flex()
.h(px(32.0))
.flex_shrink_0()
.px(px(10.0))
.items_center()
.border_b_1()
.border_color(border)
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_xs()
.text_color(label_color)
.child(label),
)
.when_some(action, |header, button| header.child(button)),
)
.child(div().flex_1().min_h_0().child(editor(state, read_only, family, size)))
.into_any_element()
}
fn contains_conflict_markers(text: &str) -> bool {
let mut ours = false;
let mut separator = false;
for line in text.lines() {
if line.starts_with("<<<<<<<") {
ours = true;
} else if ours && line.starts_with("=======") {
separator = true;
} else if ours && separator && line.starts_with(">>>>>>>") {
return true;
}
}
false
}
fn validate_relative_path(path: &str) -> Result<(), String> {
if path.is_empty() || path.chars().any(char::is_control) {
return Err("冲突文件路径无效".to_owned());
}
let local = Path::new(path);
if local.components().any(|component| {
matches!(component, Component::Prefix(_) | Component::RootDir | Component::ParentDir)
}) || path.split('/').any(|part| part == "..")
{
return Err("冲突文件不在当前仓库内".to_owned());
}
Ok(())
}
fn load_conflict(key: &MergeKey) -> Result<ConflictDocument, String> {
validate_relative_path(&key.relative_path)?;
let ours = read_stage(key, 2)?;
let theirs = read_stage(key, 3)?;
if ours.is_none() && theirs.is_none() {
return Err("Git 索引里没有可合并的 :2/:3 阶段".to_owned());
}
let result = read_worktree_file(key)?;
Ok(ConflictDocument {
ours: ours.map(|bytes| decode_conflict_text(bytes, "当前版本")).transpose()?,
theirs: theirs.map(|bytes| decode_conflict_text(bytes, "传入版本")).transpose()?,
result: decode_conflict_text(result, "合并结果")?,
})
}
fn decode_conflict_text(bytes: Vec<u8>, label: &str) -> Result<String, String> {
if bytes.len() > MAX_CODE_BYTES {
return Err(format!(
"{label}超过 {} MB,不能在三栏编辑器中安全处理",
MAX_CODE_BYTES / 1024 / 1024
));
}
if bytes.contains(&0) {
return Err(format!("{label}是二进制内容,三栏文本合并器无法处理"));
}
String::from_utf8(bytes)
.map_err(|_| format!("{label}不是 UTF-8 文本,已阻止可能破坏编码的写回"))
}
fn read_stage(key: &MergeKey, stage: u8) -> Result<Option<Vec<u8>>, String> {
let spec = format!(":{stage}:{}", key.relative_path);
let output = git_command(&key.location, &["show", &spec])?;
if output.status.success() { Ok(Some(output.stdout)) } else { Ok(None) }
}
fn read_worktree_file(key: &MergeKey) -> Result<Vec<u8>, String> {
match &key.location {
GitLocation::Local { root } => match std::fs::read(root.join(&key.relative_path)) {
Ok(bytes) => Ok(bytes),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
Err(error) => Err(format!("读取合并结果失败: {error}")),
},
GitLocation::Wsl { distro, root } => {
let path = join_guest_path(root, &key.relative_path);
let mut command = Command::new("wsl.exe");
let output = hidden_command(&mut command)
.args(["-d", distro, "--", "cat", "--", path.as_str()])
.output()
.map_err(|error| format!("无法从 WSL 读取冲突文件: {error}"))?;
if output.status.success() { Ok(output.stdout) } else { Ok(Vec::new()) }
},
}
}
fn write_conflict_result(key: &MergeKey, result: String) -> Result<(), String> {
validate_relative_path(&key.relative_path)?;
if result.len() > MAX_CODE_BYTES {
return Err(format!("合并结果超过 {} MB,已取消写回", MAX_CODE_BYTES / 1024 / 1024));
}
match &key.location {
GitLocation::Local { root } => {
std::fs::write(root.join(&key.relative_path), result.as_bytes())
.map_err(|error| format!("写回合并结果失败: {error}"))?
},
GitLocation::Wsl { distro, root } => {
let path = join_guest_path(root, &key.relative_path);
let mut command = Command::new("wsl.exe");
let mut child = hidden_command(&mut command)
.args(["-d", distro, "--", "sh", "-c", "cat > \"$1\"", "nebula", path.as_str()])
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|error| format!("无法向 WSL 写回冲突文件: {error}"))?;
let write_result = child
.stdin
.take()
.ok_or("无法打开 WSL 写入管道".to_owned())?
.write_all(result.as_bytes());
if let Err(error) = write_result {
let _ = child.kill();
return Err(format!("写入 WSL 冲突文件失败: {error}"));
}
let output =
child.wait_with_output().map_err(|error| format!("等待 WSL 写回失败: {error}"))?;
if !output.status.success() {
return Err(first_command_error(&output.stderr, "WSL 写回失败"));
}
},
}
let staged = git_command(&key.location, &["add", "--", &key.relative_path])?;
if !staged.status.success() {
return Err(first_command_error(&staged.stderr, "git add 失败;文件已写回但尚未暂存"));
}
Ok(())
}
fn git_command(location: &GitLocation, args: &[&str]) -> Result<std::process::Output, String> {
let mut command = match location {
GitLocation::Local { root } => {
let mut command = Command::new("git");
command
.arg("-c")
.arg(format!("safe.directory={}", root.display()))
.arg("--no-optional-locks")
.arg("-C")
.arg(root);
command
},
GitLocation::Wsl { distro, root } => {
let mut command = Command::new("wsl.exe");
command.args(["-d", distro, "--", "git", "-C", root, "--no-optional-locks"]);
command
},
};
hidden_command(&mut command)
.args(args)
.output()
.map_err(|error| format!("无法运行 git: {error}"))
}
fn hidden_command(command: &mut Command) -> &mut Command {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt as _;
command.creation_flags(0x0800_0000);
}
command
}
fn first_command_error(stderr: &[u8], fallback: &str) -> String {
String::from_utf8_lossy(stderr)
.lines()
.find(|line| !line.trim().is_empty())
.map(str::to_owned)
.unwrap_or_else(|| fallback.to_owned())
}
fn join_guest_path(root: &str, relative: &str) -> String {
if root == "/" {
format!("/{relative}")
} else {
format!("{}/{relative}", root.trim_end_matches('/'))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn git(root: &Path, args: &[&str]) -> std::process::Output {
Command::new("git").arg("-C").arg(root).args(args).output().expect("run git")
}
fn commit(root: &Path, message: &str) {
let output = git(
root,
&[
"-c",
"user.name=Nebula Test",
"-c",
"user.email=nebula@example.invalid",
"commit",
"-m",
message,
],
);
assert!(
output.status.success(),
"git commit failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn complete_conflict_marker_triplet_is_detected() {
assert!(contains_conflict_markers(
"a\n<<<<<<< HEAD\nleft\n=======\nright\n>>>>>>> topic\nz"
));
assert!(!contains_conflict_markers("<<<<<<< shown as documentation only"));
assert!(!contains_conflict_markers("left\n=======\nright"));
}
#[test]
fn conflict_path_cannot_escape_repository() {
assert!(validate_relative_path("src/main.rs").is_ok());
assert!(validate_relative_path("../outside.rs").is_err());
assert!(validate_relative_path("/absolute.rs").is_err());
assert!(validate_relative_path("bad\nname.rs").is_err());
}
#[test]
fn local_conflict_loads_three_versions_and_stages_result() {
let directory = tempfile::tempdir().expect("create repository directory");
let root = directory.path();
let path = root.join("conflict.txt");
assert!(git(root, &["init", "--initial-branch=main"]).status.success());
std::fs::write(&path, "base\n").expect("write base");
assert!(git(root, &["add", "conflict.txt"]).status.success());
commit(root, "base");
assert!(git(root, &["checkout", "-b", "incoming"]).status.success());
std::fs::write(&path, "incoming\n").expect("write incoming");
assert!(git(root, &["add", "conflict.txt"]).status.success());
commit(root, "incoming");
assert!(git(root, &["checkout", "main"]).status.success());
std::fs::write(&path, "current\n").expect("write current");
assert!(git(root, &["add", "conflict.txt"]).status.success());
commit(root, "current");
let merge = git(root, &["merge", "incoming"]);
assert!(!merge.status.success(), "merge unexpectedly succeeded");
let key = MergeKey {
location: GitLocation::Local { root: root.to_path_buf() },
relative_path: "conflict.txt".to_owned(),
};
let document = load_conflict(&key).expect("load conflict stages");
assert_eq!(document.ours.as_deref(), Some("current\n"));
assert_eq!(document.theirs.as_deref(), Some("incoming\n"));
assert!(contains_conflict_markers(&document.result));
write_conflict_result(&key, "resolved\n".to_owned()).expect("write and stage result");
assert_eq!(std::fs::read_to_string(&path).unwrap(), "resolved\n");
let unmerged = git(root, &["ls-files", "-u", "--", "conflict.txt"]);
assert!(unmerged.status.success());
assert!(unmerged.stdout.is_empty(), "unmerged index entries remain");
let staged = git(root, &["diff", "--cached", "--name-only", "--", "conflict.txt"]);
assert!(staged.status.success());
assert_eq!(String::from_utf8_lossy(&staged.stdout).trim(), "conflict.txt");
}
}
+5
View File
@@ -59,6 +59,9 @@ pub struct Settings {
pub cursor_blink: Option<bool>,
/// 选区完成即复制(旧壳 `copy_on_select` 设置)。
pub copy_on_select: bool,
/// 标签关闭按钮与标签插入动画都在渲染热路径读取,必须随全局设置驻留内存。
pub tab_close_visible: bool,
pub tab_reveal: nebula_settings::TabRevealName,
/// 命令补全三设置(settings.txt 的 `ghost`/`accept`/`completion_style`),
/// 类型直接用旧壳 display 的语义枚举:接受键判定与样式分支两壳同源。
pub ghost: bool,
@@ -167,6 +170,8 @@ impl Settings {
}),
cursor_blink: runtime.cursor_blink,
copy_on_select: runtime.copy_on_select,
tab_close_visible: runtime.tab_close_visible,
tab_reveal: runtime.tab_reveal,
ghost: runtime.ghost,
accept: match runtime.accept.settings_value() {
"right" => crate::display::AcceptKey::Right,
+45 -2
View File
@@ -919,10 +919,15 @@ pub struct SettingsPane {
/// SSH 区的行为实现拆在 `ssh_settings.rs`(同类型第二个 impl 块)。
pub(super) ssh_hosts: crate::gpui_shell::ssh_hosts::SshHostLists,
/// SSH 编辑器的文本字段常驻,以便所有文本改动都能使进行中的测试失效。
pub(super) ssh_username_input: Entity<InputState>,
pub(super) ssh_destination_input: Entity<InputState>,
pub(super) ssh_port_input: Entity<InputState>,
pub(super) ssh_label_input: Entity<InputState>,
pub(super) ssh_password_input: Entity<InputState>,
/// 用户名输入框本身可自由编辑;候选层只提供最近使用值,不把输入约束成
/// 固定枚举。锚点跟随输入框,滚动/DPI 变化后仍贴在其下方。
pub(super) ssh_username_picker_open: bool,
pub(super) ssh_username_trigger_bounds: Option<gpui::Bounds<gpui::Pixels>>,
/// 身份条头像的图标选择器(旧壳 `SshEditorHit::Avatar` + `icon_popup`):
/// 点头像展开,顶部一个正经搜索框,下面是分组过的目录。不做成右上角
/// 的下拉框——图标属于头像那件事,摆成独立字段就成了可填可不填的杂项。
@@ -1322,9 +1327,11 @@ impl SettingsPane {
let backup_remote_inputs: Vec<Entity<InputState>> =
(0..4).map(|_| cx.new(|cx| InputState::new(window, cx))).collect();
// 占位文案与旧壳 `ssh_editor_render` 一字不差(对齐合同)。
let ssh_username_input = cx.new(|cx| InputState::new(window, cx).placeholder("root"));
// 地址框现在只承担 host/IP;仍兼容粘贴整段 user@host,由共享 helper
// 在保存/测试时拆出内嵌用户名。
let ssh_destination_input =
cx.new(|cx| InputState::new(window, cx).placeholder("user@example.com"));
cx.new(|cx| InputState::new(window, cx).placeholder("example.com / 192.0.2.1"));
// 端口键入即过滤:至多 5 位数字(旧壳同规则;范围校验在保存/测试时做)。
let ssh_port_input = cx.new(|cx| {
InputState::new(window, cx)
@@ -1345,6 +1352,7 @@ impl SettingsPane {
.placeholder(localized_input_placeholder("ssh_icon_filter", language))
});
for input in [
ssh_username_input.clone(),
ssh_destination_input.clone(),
ssh_port_input.clone(),
ssh_label_input.clone(),
@@ -1360,6 +1368,16 @@ impl SettingsPane {
},
));
}
subscriptions.push(cx.subscribe_in(
&ssh_username_input,
window,
|this: &mut Self, _, event: &InputEvent, _, cx: &mut Context<Self>| {
if matches!(event, InputEvent::Focus) && this.ssh_editor.is_some() {
this.ssh_username_picker_open = true;
cx.notify();
}
},
));
let font_family_input =
Self::new_font_family_input(runtime.font_family.clone(), window, cx);
@@ -1422,10 +1440,13 @@ impl SettingsPane {
provider_test_running: false,
provider_codex_confirm: None,
ssh_hosts: crate::gpui_shell::ssh_hosts::SshHostLists::load(),
ssh_username_input,
ssh_destination_input,
ssh_port_input,
ssh_label_input,
ssh_password_input,
ssh_username_picker_open: false,
ssh_username_trigger_bounds: None,
ssh_icon_picker_open: false,
ssh_icon_filter_input,
ssh_icon_trigger_bounds: None,
@@ -2064,6 +2085,8 @@ impl SettingsPane {
match key {
"follow_system_theme" => flag!(follow_system_theme),
"copy_on_select" => flag!(copy_on_select),
"multiline_paste_confirm" => flag!(multiline_paste_confirm),
"tab_close_visible" => flag!(tab_close_visible),
"powerline" => flag!(powerline),
"ghost" => flag!(ghost),
"cjk_bold_regular" => flag!(cjk_bold_regular),
@@ -2891,6 +2914,16 @@ impl SettingsPane {
));
let interface = self
.group(language.pick("界面", "Interface"), cx)
.child(self.switch_row(
"tab_close_visible",
language.pick("显示标签关闭按钮", "Show tab close buttons"),
language.pick(
"关闭后侧栏与顶栏标签不再显示关闭按钮,中键关闭仍然可用。",
"Hides close buttons in the sidebar and top tab strip. Middle-click close remains available.",
),
self.runtime.tab_close_visible,
cx,
))
.child(self.select_row(
"language",
language.pick("语言", "Language"),
@@ -3788,6 +3821,16 @@ impl SettingsPane {
self.runtime.copy_on_select,
cx,
))
.child(self.switch_row(
"multiline_paste_confirm",
language.pick("多行粘贴前询问", "Confirm multi-line paste"),
language.pick(
"关闭后,可能被 shell 逐行执行的长文本会直接粘贴,不再显示确认框。",
"When disabled, long text that a shell may execute line by line is pasted without confirmation.",
),
self.runtime.multiline_paste_confirm,
cx,
))
.child(self.switch_row(
"panel_resize",
language.pick("拖拽调节侧栏宽度", "Drag to resize the sidebar"),
+219 -5
View File
@@ -43,6 +43,7 @@ const SSH_EDITOR_SPACE_S: f32 = 12.0;
const SSH_EDITOR_SPACE_M: f32 = 16.0;
const SSH_EDITOR_SPACE_XL: f32 = 32.0;
const SSH_EDITOR_CTL_H: f32 = 32.0;
const SSH_EDITOR_USERNAME_W: f32 = 118.0;
const SSH_EDITOR_PORT_W: f32 = 76.0;
const SSH_EDITOR_KEY_ROW_H: f32 = 30.0;
const SSH_EDITOR_KEY_ROWS_MAX: usize = 4;
@@ -53,6 +54,13 @@ const SSH_EDITOR_FOOTER_H: f32 = 56.0;
const SSH_HOST_ROW_H: f32 = 58.0;
const SSH_HOST_GAP: f32 = 8.0;
fn push_username_suggestion(suggestions: &mut Vec<String>, username: &str) {
let username = username.trim();
if !username.is_empty() && !suggestions.iter().any(|existing| existing == username) {
suggestions.push(username.to_owned());
}
}
#[derive(Clone, Copy, Debug)]
pub(super) enum SshValidationError {
InvalidPort,
@@ -119,6 +127,9 @@ pub(super) struct SshEditorState {
pub(super) private_keys: Vec<std::path::PathBuf>,
pub(super) save_password: bool,
pub(super) show_password: bool,
/// 最近使用用户名 + 已保存/config 主机里解析出的用户名。只作候选,不
/// 限制自由输入;最终仍拼回原有 destination 存储合同。
pub(super) username_suggestions: Vec<String>,
pub(super) revision: u64,
pub(super) test_request_id: Option<u64>,
pub(super) test_status: Option<SshEditorTestStatus>,
@@ -136,6 +147,7 @@ impl SshEditorState {
private_keys: Vec::new(),
save_password: true,
show_password: false,
username_suggestions: Vec::new(),
revision: 0,
test_request_id: None,
test_status: None,
@@ -280,6 +292,7 @@ impl SettingsPane {
window: &mut Window,
cx: &mut Context<Self>,
) {
let adding = destination.is_none();
let profile_path = crate::display::nebula_data_dir().join("ssh_profiles.json");
let profiles =
crate::ssh_profiles::SshProfiles::load(&profile_path).unwrap_or_else(|err| {
@@ -287,16 +300,29 @@ impl SettingsPane {
crate::ssh_profiles::SshProfiles::default()
});
let profile = destination.as_deref().map(|host| profiles.for_destination(host));
let (address, port) =
let (address_with_user, port) =
destination.as_deref().map(crate::display::split_destination_port).unwrap_or_default();
let (mut username, address) = crate::display::split_destination_user(&address_with_user);
// 新增主机的常用服务器账号直接给出可编辑实值;placeholder 不会参与
// destination 拼装,用户只填 IP 时此前实际会退回本机账号。
if adding {
username = "root".to_owned();
}
self.ssh_editor_seq = self.ssh_editor_seq.wrapping_add(1).max(1);
let mut editor = SshEditorState::new(self.ssh_editor_seq, destination);
editor.username_suggestions = profiles.usernames();
for host in self.ssh_hosts.merged() {
let (address, _) = crate::display::split_destination_port(&host);
let (username, _) = crate::display::split_destination_user(&address);
push_username_suggestion(&mut editor.username_suggestions, &username);
}
if let Some(profile) = profile.as_ref() {
editor.auth = profile.auth;
editor.icon = profile.icon.clone();
editor.private_keys = profile.private_keys.clone();
}
let label = profile.and_then(|profile| profile.label).unwrap_or_default();
self.ssh_username_input.update(cx, |input, cx| input.set_value(username, window, cx));
self.ssh_destination_input.update(cx, |input, cx| input.set_value(address, window, cx));
self.ssh_port_input.update(cx, |input, cx| input.set_value(port, window, cx));
self.ssh_label_input.update(cx, |input, cx| input.set_value(label, window, cx));
@@ -306,6 +332,8 @@ impl SettingsPane {
// 留在框里,下一台打开时列表看着像被莫名筛过。
self.ssh_icon_picker_open = false;
self.ssh_icon_trigger_bounds = None;
self.ssh_username_picker_open = false;
self.ssh_username_trigger_bounds = None;
self.ssh_icon_filter_input.update(cx, |input, cx| input.set_value("", window, cx));
// 弹层只能有一个;字体目录若还开着会用它的页面级拦截层盖住
// SSH 编辑器,因此先把它收起。
@@ -313,12 +341,14 @@ impl SettingsPane {
self.ssh_editor = Some(editor);
self.ssh_status = None;
self.set_ssh_editor_masking(window, cx);
self.ssh_destination_input.update(cx, |input, cx| input.focus(window, cx));
self.ssh_username_input.update(cx, |input, cx| input.focus(window, cx));
cx.notify();
}
pub(super) fn close_ssh_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.ssh_editor = None;
self.ssh_username_picker_open = false;
self.ssh_username_trigger_bounds = None;
self.ssh_password_input.update(cx, |input, cx| input.set_value("", window, cx));
window.focus(&self.focus_handle, cx);
cx.notify();
@@ -333,7 +363,13 @@ impl SettingsPane {
// en-dash 区间写法与旧壳一字不差。
return Err(SshValidationError::InvalidPort);
}
let username = self.ssh_username_input.read(cx).value().trim().to_string();
let address = self.ssh_destination_input.read(cx).value().trim().to_string();
let (_, host) = crate::display::split_destination_user(&address);
if host.is_empty() {
return Err(SshValidationError::MissingDestination);
}
let address = crate::display::join_destination_user(&username, &address);
let destination = crate::display::join_destination_port(&address, &port);
if destination.is_empty() {
return Err(SshValidationError::MissingDestination);
@@ -482,6 +518,9 @@ impl SettingsPane {
),
value => value.to_owned(),
};
let (address, _) = crate::display::split_destination_port(&destination);
let (username, _) = crate::display::split_destination_user(&address);
profiles.remember_username(&username);
profiles.upsert(crate::ssh_profiles::SshProfileAuth {
destination: destination.clone(),
auth: editor.auth,
@@ -561,6 +600,8 @@ impl SettingsPane {
let icon = crate::display::ui::os_icons::resolve(editor.icon.as_deref());
let avatar = self.ssh_avatar(icon, cx);
let icon_popup = self.ssh_icon_popup(cx);
let username_control = self.ssh_username_control(cx);
let username_popup = self.ssh_username_popup(cx);
let theme = cx.theme();
let muted = theme.muted_foreground;
let danger = theme.danger;
@@ -585,12 +626,15 @@ impl SettingsPane {
})
});
let destination_preview: SharedString = {
let username = self.ssh_username_input.read(cx).value();
let address = self.ssh_destination_input.read(cx).value();
let port = self.ssh_port_input.read(cx).value();
if address.trim().is_empty() {
language.pick("未填地址", "No address entered").into()
} else {
crate::display::join_destination_port(address.trim(), port.trim()).into()
let address =
crate::display::join_destination_user(username.trim(), address.trim());
crate::display::join_destination_port(&address, port.trim()).into()
}
};
let select_auth = |mode: crate::ssh_profiles::SshAuthMode| {
@@ -725,6 +769,9 @@ impl SettingsPane {
// 意思是「不选了」,不是「关掉整个编辑器」。
if this.ssh_icon_picker_open {
this.toggle_ssh_icon_picker(window, cx);
} else if this.ssh_username_picker_open {
this.ssh_username_picker_open = false;
cx.notify();
}
}))
.on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| {
@@ -733,6 +780,9 @@ impl SettingsPane {
// Esc 一层一层退:图标选择器开着就只收它。
if this.ssh_icon_picker_open {
this.toggle_ssh_icon_picker(window, cx);
} else if this.ssh_username_picker_open {
this.ssh_username_picker_open = false;
cx.notify();
} else {
this.close_ssh_editor(window, cx);
}
@@ -859,7 +909,25 @@ impl SettingsPane {
)
.child(
div().flex_1().min_w_0().child(
Input::new(&self.ssh_destination_input),
h_flex()
.w_full()
.gap_2()
.items_center()
.child(username_control)
.child(
div()
.flex_shrink_0()
.text_sm()
.text_color(muted)
.child("@"),
)
.child(
div().flex_1().min_w_0().child(
Input::new(
&self.ssh_destination_input,
),
),
),
),
),
)
@@ -895,7 +963,8 @@ impl SettingsPane {
.text_color(muted)
.child(language.pick("默认 22", "Default: 22")),
),
),
)
.children(username_popup),
)
.child(
v_flex()
@@ -1120,6 +1189,151 @@ impl SettingsPane {
)
}
// ---- UI:可编辑用户名历史 ----
fn ssh_username_control(&self, cx: &mut Context<Self>) -> gpui::AnyElement {
let pane = cx.entity().downgrade();
let open = self.ssh_username_picker_open;
h_flex()
.relative()
.w(px(SSH_EDITOR_USERNAME_W))
.flex_shrink_0()
// 输入框/下拉按钮都属于弹层触发器;阻止鼠标冒泡到模态遮罩,
// 否则聚焦刚把候选打开,同一次点击又会被遮罩立即关闭。
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
.child(div().flex_1().min_w_0().child(Input::new(&self.ssh_username_input)))
.child(
Button::new("ssh-username-history")
.icon(if open { IconName::ChevronUp } else { IconName::ChevronDown })
.ghost()
.xsmall()
.tooltip(crate::gpui_shell::config::ui_language(cx).pick(
"最近使用的用户名",
"Recently used usernames",
))
.on_click(cx.listener(|this, _, window, cx| {
this.toggle_ssh_username_picker(window, cx);
})),
)
.child(
gpui::canvas(
move |bounds, _, cx| {
let _ = pane.update(cx, |pane, cx| {
if pane.ssh_username_trigger_bounds == Some(bounds) {
return;
}
pane.ssh_username_trigger_bounds = Some(bounds);
if pane.ssh_username_picker_open {
cx.notify();
}
});
},
|_, _, _, _| {},
)
.absolute()
.size_full(),
)
.into_any_element()
}
fn toggle_ssh_username_picker(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.ssh_username_picker_open = !self.ssh_username_picker_open;
self.ssh_username_input.update(cx, |input, cx| input.focus(window, cx));
cx.notify();
}
fn select_ssh_username(
&mut self,
username: String,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.ssh_username_input.update(cx, |input, cx| {
input.set_value(username, window, cx);
input.focus(window, cx);
});
self.ssh_username_picker_open = false;
cx.notify();
}
fn ssh_username_popup(&mut self, cx: &mut Context<Self>) -> Option<gpui::AnyElement> {
if !self.ssh_username_picker_open {
return None;
}
let trigger = self.ssh_username_trigger_bounds?;
let language = crate::gpui_shell::config::ui_language(cx);
let theme = cx.theme();
let query = self.ssh_username_input.read(cx).value().trim().to_ascii_lowercase();
let current = self.ssh_username_input.read(cx).value().trim().to_owned();
let suggestions = self.ssh_editor.as_ref()?.username_suggestions.clone();
let rows: Vec<gpui::AnyElement> = suggestions
.into_iter()
.filter(|username| query.is_empty() || username.to_ascii_lowercase().contains(&query))
.take(8)
.enumerate()
.map(|(index, username)| {
let selected = username == current;
let picked = username.clone();
h_flex()
.id(SharedString::from(format!("ssh-username-row-{index}")))
.h(px(30.0))
.w_full()
.px_2()
.items_center()
.rounded_md()
.cursor_pointer()
.when(selected, |row| row.bg(theme.list_active))
.hover(|row| row.bg(theme.list_hover))
.child(div().flex_1().min_w_0().truncate().text_sm().child(username))
.when(selected, |row| row.child(Icon::new(IconName::CircleCheck).xsmall()))
.on_click(cx.listener(move |this, _, window, cx| {
this.select_ssh_username(picked.clone(), window, cx);
}))
.into_any_element()
})
.collect();
let row_count = rows.len();
let panel = v_flex()
.w(px(SSH_EDITOR_USERNAME_W + 28.0))
.p_2()
.rounded_lg()
.border_1()
.border_color(theme.border)
.bg(theme.popover)
.shadow_lg()
.occlude()
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
.child(if rows.is_empty() {
div()
.h(px(30.0))
.px_2()
.flex()
.items_center()
.text_xs()
.text_color(theme.muted_foreground)
.child(language.pick("没有匹配的历史用户名", "No matching usernames"))
.into_any_element()
} else {
v_flex()
.h(px((row_count as f32 * 30.0).min(240.0)))
.overflow_y_scrollbar()
.children(rows)
.into_any_element()
});
Some(
deferred(
anchored()
.anchor(gpui::Anchor::TopLeft)
.position(trigger.bottom_left())
.offset(gpui::point(px(0.0), px(6.0)))
.snap_to_window_with_margin(px(8.0))
.child(panel),
)
.with_priority(3)
.into_any_element(),
)
}
// ---- UI:身份条头像与它的图标选择器 ----
/// 身份条头像:画当前图标,点它开合选择器(旧壳 `SshEditorHit::Avatar`)。
@@ -67,7 +67,12 @@ impl TerminalElement {
None
} else {
let cursor = term.grid().cursor.point;
crate::display::Display::nebula_input_from_raw_grid(&term, cursor)
crate::display::nebula_input_from_raw_grid(
&term,
cursor,
&view.suggest.line_buf,
&view.suggest.suggest_env,
)
};
#[cfg(not(windows))]
let prompt_line = None;
@@ -91,9 +91,9 @@ pub(super) fn local_options(
if let Err(error) = tty::refresh_environment(&mut options) {
log::warn!("Could not refresh the Windows environment for a new pane: {error}");
}
// WSL tab 的 cwd 上报(OSC 7):来宾登录 shell 默认不发,目录树与 Git 视图
// 因此永远停在"等待终端上报工作目录"。只对 `wsl.exe` 启动生效,见
// [`crate::shell_detect::wsl_cwd_report_env`]。
// WSL tab 的命令边界(OSC 133;D/A)与 cwd(OSC 7):来宾登录 shell 默认
// 不发,Agent 生命周期、目录树与 Git 视图都会失去来宾侧事实。只对
// `wsl.exe` 启动生效,见 [`crate::shell_detect::wsl_cwd_report_env`]。
if let Some(launch) = &options.shell {
let program = launch.program().to_owned();
let args = launch.args().to_vec();
@@ -65,13 +65,13 @@ pub fn update(
pub fn commit_line(state: &mut NebulaPaneState) {
let line = state.screen_line.trim().to_owned();
if !line.is_empty() {
shared().history.record(&line, &state.cwd);
shared().history.record(&state.suggest_env.history_scope(), &line, &state.cwd);
}
// 旧壳 `nebula_commit_line` 同一条:OSC 133;C 到达时 PTY 已把行缓冲清
// 空,程序身份(侧栏 tab 图标)必须在 Enter 这一刻从屏幕真值捕获。
// grid 读失败时退回按键镜像——取首 token 做身份已足够。
state.last_committed = if line.is_empty() { state.line_buf.trim().to_owned() } else { line };
crate::display::Display::nebula_clear_line(state);
crate::display::nebula_clear_line(state);
}
/// shell 集成上报 cwd 时喂目录 frecency(旧壳 `nebula_record_directory`)。
+58 -62
View File
@@ -13,7 +13,7 @@ use gpui::{
Pixels, Point, Render, ScrollWheelEvent, SharedString, Size, StatefulInteractiveElement as _,
Styled as _, TextRun, UTF16Selection, Window, div, point, px,
};
use gpui_component::WindowExt as _;
use gpui_component::{Sizable as _, WindowExt as _, checkbox::Checkbox};
use nebula_settings::CellWidthModeName;
use nebula_terminal::event::{Event as TermEvent, Notify as _, OnResize as _, WindowSize};
use nebula_terminal::event_loop::Msg;
@@ -1006,35 +1006,6 @@ impl TerminalView {
cx.emit(TerminalViewEvent::TitleChanged);
cx.notify();
}
// WSL agent identity comes from the submitted command or
// branded screen chrome because Linux processes are invisible
// to Windows Toolhelp. Its injected PROMPT_COMMAND reports OSC
// 7 when the guest shell regains control, providing the
// lifecycle edge that clears the icon.
if matches!(
self.agent_status_source,
crate::ai_agents::AgentStatusSource::Screen
| crate::ai_agents::AgentStatusSource::Process
) && !self.agent_hook_seen
&& self
.running_program
.as_deref()
.and_then(crate::ai_agents::AgentKind::parse)
.is_some()
{
self.running_program = None;
self.agent_status = crate::ai_agents::AgentStatus::Unknown;
self.agent_status_source = crate::ai_agents::AgentStatusSource::Unknown;
self.agent_status_rule = None;
self.agent_turn_active = false;
self.idle_screen_streak = 0;
self.command_running = false;
self.command_running_disproved = false;
self.command_started = None;
self.last_process_probe = None;
cx.emit(TerminalViewEvent::TitleChanged);
cx.notify();
}
},
TermEvent::CommandStart => {
// 程序身份来自 Enter 时捕获的完整命令行;直接启动和
@@ -1089,29 +1060,13 @@ impl TerminalView {
self.last_command_failed = exit_code.is_some_and(|code| code != 0);
// 旧壳同款收尾:CLI 退回提示符后,它不再是这个 pane 的前台
// 事实——hook 稍后若仍在跑会重新点亮(handle_ai_hook 覆写)。
if self.running_program.take().is_some() || self.ai_session.take().is_some() {
if self.clear_foreground_agent_state() {
cx.emit(TerminalViewEvent::TitleChanged);
}
self.command_running = false;
self.command_running_disproved = false;
self.command_started = None;
self.last_process_probe = None;
// 回到提示符 = 前台已经没有 agent 了。主 agent 的 pid 必须一起
// 作废,否则下一个 agent(用户 Ctrl+C 掉旧的再起一个,没有
// SessionEnd)会被当成子代理,会话身份再也写不进来。
self.primary_agent_pid = None;
if let Some(run) = self.active_run.take() {
self.last_run =
Some(crate::runtime_api::RuntimeRunOutcome::command_done(run, exit_code));
}
self.agent_status = crate::ai_agents::AgentStatus::Unknown;
self.agent_status_source = crate::ai_agents::AgentStatusSource::Unknown;
self.agent_status_rule = None;
self.agent_hook_seen = false;
self.agent_turn_active = false;
self.idle_screen_streak = 0;
self.agent_runtime_submit_pending = false;
self.pending_runtime_submit = None;
cx.notify();
},
TermEvent::Notify(body) => {
@@ -1147,6 +1102,7 @@ impl TerminalView {
/// `Exited` 只对宿主发一次;重复的退出信号(ChildExit 之后必然跟 Exit)只更新文案。
fn mark_exited(&mut self, message: String, cx: &mut Context<Self>) {
self.pending_runtime_submit = None;
self.suggest.pending_command_prompt = None;
if self.exited.is_none() {
self.exited = Some(message);
cx.emit(TerminalViewEvent::Exited);
@@ -1639,7 +1595,9 @@ impl TerminalView {
pub fn paste(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) else { return };
let lines = paste_line_count(&text);
if paste_needs_confirmation(&text, self.term_mode()) {
if nebula_settings::RuntimeSettings::load().multiline_paste_confirm
&& paste_needs_confirmation(&text, self.term_mode())
{
self.confirm_paste(text, lines, window, cx);
return;
}
@@ -1687,9 +1645,13 @@ impl TerminalView {
// pane」的 view 释放不掉。
let text = Arc::new(text);
let view = cx.entity().downgrade();
let never_ask_again = Arc::new(std::sync::atomic::AtomicBool::new(false));
window.open_dialog(cx, move |dialog, window, _cx| {
let text = text.clone();
let view = view.clone();
let checked = never_ask_again.load(std::sync::atomic::Ordering::Relaxed);
let checkbox_state = never_ask_again.clone();
let persist_state = never_ask_again.clone();
confirm_dialog(
dialog,
window,
@@ -1699,7 +1661,23 @@ impl TerminalView {
cancel_text.clone(),
ButtonVariant::Primary,
)
.child(
Checkbox::new("nebula-paste-never-ask")
.label(language.pick("不再询问", "Don't ask again"))
.checked(checked)
.small()
.on_click(move |checked, window, _| {
checkbox_state.store(*checked, std::sync::atomic::Ordering::Relaxed);
window.refresh();
}),
)
.on_ok(move |_, _window, cx| {
if persist_state.load(std::sync::atomic::Ordering::Relaxed) {
let _ = nebula_settings::persist_keys(&[(
"multiline_paste_confirm",
"0".to_owned(),
)]);
}
let _ = view.update(cx, |this, cx| this.paste_now(&text, cx));
true
})
@@ -1715,7 +1693,7 @@ impl TerminalView {
// 行镜像吃粘贴的字面文本;多行/控制字符由引擎侧作废(与旧壳
// `nebula_input_text` 的防注入契约一致)。
if !self.term_mode().contains(TermMode::ALT_SCREEN) {
crate::display::Display::nebula_input_text(&mut self.suggest, &normalized);
crate::display::nebula_input_text(&mut self.suggest, &normalized);
}
let bytes = if self.term_mode().contains(TermMode::BRACKETED_PASTE) {
let mut b = b"\x1b[200~".to_vec();
@@ -1734,21 +1712,39 @@ impl TerminalView {
}
/// Enter 提交:从 grid 读回显真值(screen truth)记入共享历史,然后清
/// 行镜像。读法与旧壳 `nebula_commit_line` 的 Windows 契约一致:无提示
/// 箭头(cmd/ssh/REPL)或中线编辑读不到就宁缺毋滥——键击重构的
/// line_buf 在光标移动/Tab 补全后就是拼接垃圾,不能进历史。
/// 行镜像。读法与旧壳 `nebula_commit_line` 的 Windows 契约一致:无法证明
/// 是提示符的 REPL 行或中线编辑读不到就宁缺毋滥——键击重构的
/// line_buf 在光标移动/Tab 补全后就是拼接垃圾,不能进历史。Agent 已在
/// 前台时保留最初 shell 提示符,内部交互的 Enter 不得覆盖退出证据。
fn commit_line(&mut self, cx: &mut Context<Self>) {
let agent_already_active =
self.running_program.as_deref().and_then(crate::ai_agents::AgentKind::parse).is_some();
if !agent_already_active {
self.suggest.pending_command_prompt = None;
}
#[cfg(windows)]
if let Some(session) = &self.session {
if !agent_already_active && let Some(session) = &self.session {
let term = session.term.lock();
if !term.mode().intersects(TermMode::ALT_SCREEN | TermMode::VI) {
let cursor = term.grid().cursor.point;
match crate::display::Display::nebula_input_from_raw_grid(&term, cursor) {
Some(line) => self.suggest.screen_line = line,
None => self.suggest.screen_line.clear(),
match crate::display::nebula_prompt_line_from_raw_grid(
&term,
cursor,
&self.suggest.line_buf,
&self.suggest.suggest_env,
) {
Some(line) => {
self.suggest.screen_line = line.input;
self.suggest.pending_command_prompt = Some(line.prompt);
},
None => {
self.suggest.screen_line.clear();
self.suggest.pending_command_prompt = None;
},
}
} else {
self.suggest.screen_line.clear();
self.suggest.pending_command_prompt = None;
}
}
suggest::commit_line(&mut self.suggest);
@@ -1881,10 +1877,10 @@ impl TerminalView {
match ks.key.as_str() {
"enter" => self.commit_line(cx),
"backspace" if mods.control && !mods.alt && !mods.platform => {
crate::display::Display::nebula_input_delete_word(&mut self.suggest);
crate::display::nebula_input_delete_word(&mut self.suggest);
},
"backspace" if plain_mods => {
crate::display::Display::nebula_input_backspace(&mut self.suggest);
crate::display::nebula_input_backspace(&mut self.suggest);
},
key => {
let is_modifier = matches!(
@@ -1894,7 +1890,7 @@ impl TerminalView {
// key_char 非空 = 平台判定它产生文本(随后从 IME 管道到达)。
let produces_text = ks.key_char.as_deref().is_some_and(|text| !text.is_empty());
if !is_modifier && !produces_text {
crate::display::Display::nebula_clear_line(&mut self.suggest);
crate::display::nebula_clear_line(&mut self.suggest);
}
},
}
@@ -2002,7 +1998,7 @@ impl TerminalView {
// 无提示时穿透给 shell 自己的补全(encode 兜底)。
let ghost = std::mem::take(&mut self.suggest.suggestion);
for c in ghost.chars() {
crate::display::Display::nebula_input_char(&mut self.suggest, c);
crate::display::nebula_input_char(&mut self.suggest, c);
}
self.write_user_text(ghost.clone(), false, ghost.into_bytes(), cx);
cx.stop_propagation();
@@ -2338,7 +2334,7 @@ impl TerminalView {
self.suggest.screen_line.clone()
};
for c in insert.chars() {
crate::display::Display::nebula_input_char(&mut self.suggest, c);
crate::display::nebula_input_char(&mut self.suggest, c);
}
self.suggest.completion_suppressed_line = Some(format!("{before}{insert}"));
if !insert.is_empty() {
@@ -2914,7 +2910,7 @@ impl gpui::EntityInputHandler for TerminalView {
if !text.is_empty() && self.exited.is_none() {
// 行镜像吃 IME 管道的字符(含中文提交与普通击键文本)。
if !self.term_mode().contains(TermMode::ALT_SCREEN) {
crate::display::Display::nebula_input_text(&mut self.suggest, text);
crate::display::nebula_input_text(&mut self.suggest, text);
}
self.write_user_text(text.to_owned(), false, text.as_bytes().to_vec(), cx);
} else if had_marked_text {
@@ -83,7 +83,7 @@ impl TerminalView {
self.paste_now_impl(text, false, cx);
} else {
if !self.term_mode().contains(TermMode::ALT_SCREEN) {
crate::display::Display::nebula_input_text(&mut self.suggest, text);
crate::display::nebula_input_text(&mut self.suggest, text);
}
self.write_input(text.as_bytes().to_vec(), cx);
}
@@ -27,6 +27,31 @@ fn progress_sidebar_activity(
}
impl TerminalView {
/// Clear foreground Agent identity after an authoritative command end or
/// after the submitted shell prompt is observed again. OSC 133;D remains
/// the primary edge; the cached-prompt path calls the same reset so the two
/// lifecycle routes cannot drift apart.
pub(super) fn clear_foreground_agent_state(&mut self) -> bool {
let title_changed =
self.running_program.take().is_some() || self.ai_session.take().is_some();
self.primary_agent_pid = None;
self.agent_status = crate::ai_agents::AgentStatus::Unknown;
self.agent_status_source = crate::ai_agents::AgentStatusSource::Unknown;
self.agent_status_rule = None;
self.agent_hook_seen = false;
self.agent_turn_active = false;
self.idle_screen_streak = 0;
self.agent_runtime_submit_pending = false;
self.command_running = false;
self.command_running_disproved = false;
self.command_started = None;
self.last_process_probe = None;
self.pending_runtime_submit = None;
self.suggest.pending_command_prompt = None;
self.awaiting_input = false;
title_changed
}
pub fn runtime_task_state(&self) -> crate::runtime_api::RuntimeTaskState {
use crate::ai_agents::AgentStatus;
use crate::runtime_api::RuntimeTaskState;
@@ -688,16 +713,7 @@ impl TerminalView {
// 而言什么都不是。
AiHookKind::SessionEnd if !from_primary_agent => {},
AiHookKind::SessionEnd => {
self.ai_session = None;
self.running_program = None;
self.primary_agent_pid = None;
self.agent_status = AgentStatus::Unknown;
self.agent_status_source = crate::ai_agents::AgentStatusSource::Unknown;
self.agent_status_rule = None;
self.agent_hook_seen = false;
self.agent_turn_active = false;
self.idle_screen_streak = 0;
self.agent_runtime_submit_pending = false;
self.clear_foreground_agent_state();
},
kind => {
self.running_program = Some(event.source.clone());
@@ -784,18 +800,49 @@ impl TerminalView {
// 这个 pane 永远退出屏幕检测。
self.reconcile_shell_activity(cx);
let Some(session) = &self.session else { return };
let screen = {
let (prompt_restored, screen) = {
let term = session.term.lock();
let lines = term.screen_lines();
if lines == 0 || term.columns() == 0 {
return;
}
let prompt_restored =
self.suggest.pending_command_prompt.as_deref().is_some_and(|expected| {
crate::display::nebula_shell_prompt_restored_from_raw_grid(
&term,
expected,
&self.suggest.suggest_env,
)
});
let take = lines.min(24);
let start = TermPoint::new(Line((lines - take) as i32), Column(0));
let end =
TermPoint::new(Line(lines as i32 - 1), Column(term.columns().saturating_sub(1)));
term.bounds_to_string(start, end)
(prompt_restored, term.bounds_to_string(start, end))
};
if prompt_restored
&& self
.running_program
.as_deref()
.and_then(crate::ai_agents::AgentKind::parse)
.is_some()
{
log::debug!(
"agent lifecycle: submitted shell prompt restored pane={} program={:?}",
self.pane_id,
self.running_program
);
if let Some(run) = self.active_run.take() {
self.last_run =
Some(crate::runtime_api::RuntimeRunOutcome::command_done(run, None));
}
let title_changed = self.clear_foreground_agent_state();
if title_changed {
cx.emit(TerminalViewEvent::TitleChanged);
}
cx.notify();
return;
}
let program = match self.running_program.clone() {
Some(program) => program,
None => {
+232 -34
View File
@@ -35,6 +35,7 @@ use gpui::{
use image::Frame;
use crate::display::color::Rgb;
use crate::gpui_shell::code_tab::CodeTabViewEvent;
use crate::gpui_shell::doc_tabs::DocTabViewEvent;
use crate::gpui_shell::prelude::*;
use crate::gpui_shell::settings_pane::{SettingsPane, SettingsPaneEvent};
@@ -205,7 +206,11 @@ fn custom_workspace_binding(combo: &str, action: &crate::config::Action) -> Opti
Action::DecreaseFontSize => Some(KeyBinding::new(&combo, DecreaseFontSize, None)),
Action::ResetFontSize => Some(KeyBinding::new(&combo, ResetFontSize, None)),
Action::Copy => Some(KeyBinding::new(&combo, CopySelection, None)),
Action::Paste => Some(KeyBinding::new(&combo, PasteClipboard, None)),
Action::Paste => Some(KeyBinding::new(
&combo,
PasteClipboard,
Some(crate::gpui_shell::terminal::KEY_CONTEXT),
)),
Action::ToggleFullscreen => Some(KeyBinding::new(&combo, ToggleFullscreen, None)),
Action::OpenQuickJump => Some(KeyBinding::new(&combo, OpenQuickJump, None)),
// `none` 禁用键:gpui 的 NoAction 绑定在最高优先级命中时吞掉按键,
@@ -266,8 +271,15 @@ pub fn init(cx: &mut App) {
KeyBinding::new("ctrl--", DecreaseFontSize, None),
KeyBinding::new("ctrl-0", ResetFontSize, None),
KeyBinding::new("ctrl-shift-c", CopySelection, None),
KeyBinding::new("ctrl-v", PasteClipboard, None),
KeyBinding::new("ctrl-shift-v", PasteClipboard, None),
// 终端粘贴只在终端焦点路径命中。Input 自己带 `Input -> Paste`;这里若
// 无上下文,会因注册更晚而抢走弹窗/设置页输入框的 Ctrl+V。
KeyBinding::new("ctrl-v", PasteClipboard, Some(crate::gpui_shell::terminal::KEY_CONTEXT)),
KeyBinding::new(
"ctrl-shift-v",
PasteClipboard,
Some(crate::gpui_shell::terminal::KEY_CONTEXT),
),
KeyBinding::new("ctrl-shift-v", gpui_component::input::Paste, Some("Input")),
KeyBinding::new("alt-enter", ToggleFullscreen, None),
KeyBinding::new("ctrl-shift-o", OpenQuickJump, None),
]);
@@ -313,6 +325,7 @@ enum WorkspaceTab {
/// 源码查看 tab(tree-sitter 高亮 + 行级虚拟化,只读)。
Code {
view: Entity<crate::gpui_shell::code_tab::CodeTabView>,
_subscription: Subscription,
},
}
@@ -343,6 +356,11 @@ struct SplitDrag {
tab: usize,
path: Vec<bool>,
direction: SplitDirection,
/// Lightweight visual preview. The pane flex tree keeps using its committed
/// ratio until mouse-up, so dragging never reflows terminal grids.
preview_ratio: f32,
close_target: Option<bool>,
last_notified: std::time::Instant,
}
/// Split 视口 / pane 矩形的帧记录(canvas prepaint 回写,`Rc<RefCell>`
@@ -437,6 +455,57 @@ fn to_split_rect(bounds: &Bounds<Pixels>) -> nebula_split::Rect {
)
}
#[derive(Debug, Clone, Copy, PartialEq)]
struct SplitDragVisual {
divider: nebula_split::Rect,
close_area: Option<nebula_split::Rect>,
}
/// Compute the cheap split-drag overlay without changing either pane's layout.
/// `close_target` uses [`nebula_split::drag_close_target`] semantics: `false`
/// is the first (left/top) child, `true` the second (right/bottom) child.
fn split_drag_visual_geometry(
direction: SplitDirection,
viewport: nebula_split::Rect,
preview_ratio: f32,
close_target: Option<bool>,
) -> SplitDragVisual {
let usable = match direction {
SplitDirection::LeftRight => (viewport.w - DIVIDER_GAP).max(0.0),
SplitDirection::TopBottom => (viewport.h - DIVIDER_GAP).max(0.0),
};
let split = usable * preview_ratio.clamp(0.0, 1.0);
let divider = match direction {
SplitDirection::LeftRight => {
nebula_split::Rect::new(viewport.x + split, viewport.y, DIVIDER_GAP, viewport.h)
},
SplitDirection::TopBottom => {
nebula_split::Rect::new(viewport.x, viewport.y + split, viewport.w, DIVIDER_GAP)
},
};
let close_area = close_target.map(|second| match (direction, second) {
(SplitDirection::LeftRight, false) => {
nebula_split::Rect::new(viewport.x, viewport.y, split, viewport.h)
},
(SplitDirection::LeftRight, true) => nebula_split::Rect::new(
divider.x + divider.w,
viewport.y,
(viewport.w - split - divider.w).max(0.0),
viewport.h,
),
(SplitDirection::TopBottom, false) => {
nebula_split::Rect::new(viewport.x, viewport.y, viewport.w, split)
},
(SplitDirection::TopBottom, true) => nebula_split::Rect::new(
viewport.x,
divider.y + divider.h,
viewport.w,
(viewport.h - split - divider.h).max(0.0),
),
});
SplitDragVisual { divider, close_area }
}
/// 侧栏分界线在终端卡 canvas 里绘制,但视觉上属于整个窗口 chrome。
/// 返回窗口坐标中的一条连续矩形,避免标题栏与正文各画一段产生缩放接缝。
fn pane_card_divider_bounds(
@@ -1003,6 +1072,10 @@ pub struct NebulaWorkspace {
side_panel: crate::display::side_panel::SidePanel,
side_panel_polling: bool,
side_panel_anim_armed: bool,
/// Files drawer search reuses the same real GPUI input as the Shell
/// selector, including caret, selection, clipboard and IME behavior.
file_tree_search_input: Entity<InputState>,
_file_tree_search_subscription: Subscription,
/// 文件树列表的滚动位置。抽屉走 `uniform_list` 虚拟化,滑块(组件库
/// `Scrollbar`)读的是同一个 handle——这是抽屉里**唯一**的滚动模型,
/// 旧壳那套行粒度 `SidePanel::scroll` 在 GPUI 侧恒为 0。
@@ -1137,6 +1210,11 @@ impl NebulaWorkspace {
cx.new(|cx| InputState::new(window, cx).placeholder("搜索命令…"));
let command_manager_input =
cx.new(|cx| InputState::new(window, cx).placeholder("搜索已保存命令…"));
let file_tree_search_input = cx.new(|cx| {
InputState::new(window, cx).placeholder(
workspace_ui_language().pick("搜索文件和文件夹…", "Search files and folders..."),
)
});
let git_commit_input = cx.new(|cx| InputState::new(window, cx).placeholder("提交信息…"));
let command_palette_subscription = cx.subscribe_in(
&command_palette_input,
@@ -1180,6 +1258,8 @@ impl NebulaWorkspace {
}
},
);
let file_tree_search_subscription =
cx.subscribe_in(&file_tree_search_input, window, Self::on_file_tree_search_event);
let sidebar_logo_target_px =
(TAB_LABEL_ICON_SIZE * window.scale_factor()).round().max(1.0) as u32;
let process_dispatcher = shell_events.is_some();
@@ -1237,6 +1317,8 @@ impl NebulaWorkspace {
side_panel: crate::display::side_panel::SidePanel::new(),
side_panel_polling: false,
side_panel_anim_armed: false,
file_tree_search_input,
_file_tree_search_subscription: file_tree_search_subscription,
file_tree_scroll: gpui::UniformListScrollHandle::new(),
file_tree_menu: None,
remote_browser: remote_files::RemoteBrowser::default(),
@@ -1860,6 +1942,7 @@ impl NebulaWorkspace {
*broadcast = false;
}
}
self.remote_browser.forget(pane_id);
self.pane_bounds.borrow_mut().remove(&pane_id);
self.mark_structural_resize(tab_ix, cx);
if tab_ix == self.active {
@@ -2382,16 +2465,50 @@ impl NebulaWorkspace {
cx: &mut Context<Self>,
) {
if let Some(ix) = self.tabs.iter().position(
|tab| matches!(tab, WorkspaceTab::Code { view } if view.read(cx).path == path),
|tab| matches!(tab, WorkspaceTab::Code { view, .. } if view.read(cx).is_regular_path(&path)),
) {
if let Some(WorkspaceTab::Code { view }) = self.tabs.get(ix) {
if let Some(WorkspaceTab::Code { view, .. }) = self.tabs.get(ix) {
view.clone().update(cx, |view, cx| view.reload(window, cx));
}
self.activate_tab(ix, window, cx);
return;
}
let view = cx.new(|cx| crate::gpui_shell::code_tab::CodeTabView::new(path, window, cx));
self.insert_new_tab(WorkspaceTab::Code { view });
let subscription = cx.subscribe(&view, Self::on_code_tab_event);
self.insert_new_tab(WorkspaceTab::Code { view, _subscription: subscription });
self.focus_active(window, cx);
cx.notify();
}
/// 冲突文件仍属于代码 Tab,但以三栏合并形态打开;同一仓库、同一路径复用
/// 已有 Tab,避免用户从冲突列表连续点击后堆出多个独立结果缓冲区。
fn open_git_merge_tab(
&mut self,
relative_path: String,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(location) = self.side_panel.git_location() else { return };
if let Some(ix) = self.tabs.iter().position(|tab| {
matches!(tab, WorkspaceTab::Code { view, .. }
if view.read(cx).matches_git_merge(&location, &relative_path))
}) {
if let Some(WorkspaceTab::Code { view, .. }) = self.tabs.get(ix) {
view.clone().update(cx, |view, cx| view.reload_git_merge(window, cx));
}
self.activate_tab(ix, window, cx);
return;
}
let view = cx.new(|cx| {
crate::gpui_shell::code_tab::CodeTabView::new_git_merge(
location,
relative_path,
window,
cx,
)
});
let subscription = cx.subscribe(&view, Self::on_code_tab_event);
self.insert_new_tab(WorkspaceTab::Code { view, _subscription: subscription });
self.focus_active(window, cx);
cx.notify();
}
@@ -2534,6 +2651,20 @@ impl NebulaWorkspace {
}
}
fn on_code_tab_event(
&mut self,
_: Entity<crate::gpui_shell::code_tab::CodeTabView>,
event: &CodeTabViewEvent,
cx: &mut Context<Self>,
) {
match event {
CodeTabViewEvent::GitConflictResolved => {
self.side_panel.request_refresh();
cx.notify();
},
}
}
/// 热应用设置页变更,并把 SSH 连接请求转为新标签。
fn on_settings_event(
&mut self,
@@ -2564,6 +2695,7 @@ impl NebulaWorkspace {
for pane in panes {
self.runtime_hub.record_pane_closed(self.runtime_window_id, pane.id);
pane.view.read(cx).shutdown();
self.remote_browser.forget(pane.id);
bounds.remove(&pane.id);
}
}
@@ -3318,7 +3450,7 @@ impl NebulaWorkspace {
WorkspaceTab::Settings { .. } => "设置".into(),
WorkspaceTab::Image { view } => view.read(cx).title.clone().into(),
WorkspaceTab::Document { view, .. } => view.read(cx).title.clone().into(),
WorkspaceTab::Code { view } => view.read(cx).title.clone().into(),
WorkspaceTab::Code { view, .. } => view.read(cx).title.clone().into(),
tab @ WorkspaceTab::Terminal { .. } => {
// 标签 = 聚焦 pane 的 cwd 末级目录名(旧壳 chrome_tab_label
// 规则)。分屏计数**不拼在这里**:这份字符串还要喂给 runtime
@@ -4174,9 +4306,12 @@ impl NebulaWorkspace {
)
.into_any_element()
},
SplitTree::Split { direction, ratio, preview_ratio, dragging, first, second } => {
SplitTree::Split { direction, ratio, dragging, first, second, .. } => {
let direction = *direction;
let show_ratio = (*preview_ratio).unwrap_or(*ratio);
// Interactive drag uses a lightweight overlay. Keeping the
// committed ratio here avoids rebuilding/reflowing both terminal
// grids for every high-frequency pointer event.
let show_ratio = *ratio;
let dragging = *dragging;
let key = (tab_ix, path.clone());
let store = self.split_bounds.clone();
@@ -4217,6 +4352,7 @@ impl NebulaWorkspace {
path.pop();
let drag_path = path.clone();
let drag_start_ratio = *ratio;
let divider_id =
SharedString::from(format!("split-divider-{tab_ix}-{drag_path:?}"));
let bar_color =
@@ -4242,8 +4378,22 @@ impl NebulaWorkspace {
.on_mouse_down(
MouseButton::Left,
cx.listener(move |this, _: &MouseDownEvent, _, cx| {
this.split_drag =
Some(SplitDrag { tab: tab_ix, path: drag_path.clone(), direction });
this.split_drag = Some(SplitDrag {
tab: tab_ix,
path: drag_path.clone(),
direction,
preview_ratio: drag_start_ratio,
close_target: None,
last_notified: std::time::Instant::now(),
});
if let Some(WorkspaceTab::Terminal { tree, .. }) =
this.tabs.get_mut(tab_ix)
&& let Some(SplitTree::Split { preview_ratio, dragging, .. }) =
tree.node_mut(&drag_path)
{
*preview_ratio = None;
*dragging = true;
}
cx.notify();
}),
);
@@ -4283,16 +4433,18 @@ impl NebulaWorkspace {
}
}
/// 分隔条拖拽的指针移动:指针位置 → 原始比例 → 预览曲线(常规带跟手、
/// 关闭区钉边示意"松手即关")。PTY 尺寸不追预览——`set_layout` 的
/// resize 合并合同把拖拽期间的矩形变化收敛到松手后一次提交。
/// 分隔条拖拽的指针移动:指针位置 → 原始比例 → 轻量预览线(常规带跟手、
/// 关闭区钉边示意"松手即关")。pane flex、终端网格与 PTY 都不追预览,
/// 松手后才做一次结构提交。高频鼠标事件最多按 120Hz 通知绘制。
fn update_split_drag(
&mut self,
event: &gpui::MouseMoveEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(drag) = &self.split_drag else { return };
if self.split_drag.is_none() {
return;
}
if event.pressed_button != Some(MouseButton::Left) {
let position = event.position;
self.finish_split_drag(position, window, cx);
@@ -4302,14 +4454,60 @@ impl NebulaWorkspace {
Some(raw) => raw,
None => return,
};
let (tab, path) = (drag.tab, drag.path.clone());
if let Some(WorkspaceTab::Terminal { tree, .. }) = self.tabs.get_mut(tab) {
if let Some(SplitTree::Split { preview_ratio, dragging, .. }) = tree.node_mut(&path) {
*preview_ratio = Some(nebula_split::preview_ratio(raw));
*dragging = true;
cx.notify();
}
let preview_ratio = nebula_split::preview_ratio(raw);
let close_target = nebula_split::drag_close_target(raw);
let Some(drag) = self.split_drag.as_mut() else { return };
let changed = (drag.preview_ratio - preview_ratio).abs() > f32::EPSILON
|| drag.close_target != close_target;
if !changed {
return;
}
let close_changed = drag.close_target != close_target;
drag.preview_ratio = preview_ratio;
drag.close_target = close_target;
if close_changed || drag.last_notified.elapsed() >= Duration::from_millis(8) {
drag.last_notified = std::time::Instant::now();
cx.notify();
}
}
/// Visual-only divider/close preview. It lives above the stable pane tree,
/// so moving it does not invoke `TerminalElement::prepaint` with new bounds.
fn split_drag_visual(&self, cx: &mut Context<Self>) -> Option<gpui::AnyElement> {
let drag = self.split_drag.as_ref()?;
let viewport = self.split_bounds.borrow().get(&(drag.tab, drag.path.clone())).copied()?;
let visual = split_drag_visual_geometry(
drag.direction,
to_split_rect(&viewport),
drag.preview_ratio,
drag.close_target,
);
let line = visual.divider;
let close = visual.close_area.map(|area| {
div()
.absolute()
.left(px(area.x))
.top(px(area.y))
.w(px(area.w))
.h(px(area.h))
.bg(cx.theme().warning.opacity(0.12))
});
Some(
div()
.absolute()
.inset_0()
.children(close)
.child(
div()
.absolute()
.left(px(line.x))
.top(px(line.y))
.w(px(line.w))
.h(px(line.h))
.bg(cx.theme().primary.opacity(0.88)),
)
.into_any_element(),
)
}
/// 当前拖拽的指针位置 → 未钳制原始比例(依赖上一帧节点视口记录)。
@@ -4368,7 +4566,15 @@ impl NebulaWorkspace {
}
}
if let Some(pane_id) = close_target {
// remove_leaf 会顺带塌缩本 Split 节点;预览状态随节点一起消亡。
// 繁忙 pane 会先弹确认框而不会立刻 remove_leaf;必须先清掉拖拽态,
// 否则确认框背后会残留一条永久高亮的分隔线。
if let Some(WorkspaceTab::Terminal { tree, .. }) = self.tabs.get_mut(drag.tab)
&& let Some(SplitTree::Split { preview_ratio, dragging, .. }) =
tree.node_mut(&drag.path)
{
*preview_ratio = None;
*dragging = false;
}
self.request_close_pane(drag.tab, pane_id, window, cx);
cx.notify();
return;
@@ -4443,7 +4649,7 @@ impl Render for NebulaWorkspace {
Some(WorkspaceTab::Document { view, .. }) => {
Some(gpui::IntoElement::into_any_element(view.clone()))
},
Some(WorkspaceTab::Code { view }) => {
Some(WorkspaceTab::Code { view, .. }) => {
Some(gpui::IntoElement::into_any_element(view.clone()))
},
None => None,
@@ -4629,16 +4835,7 @@ impl Render for NebulaWorkspace {
}
}))
.on_action(cx.listener(|this, _: &PasteClipboard, window, cx| {
let rename_has_focus = this.tab_rename.as_ref().is_some_and(|rename| {
rename.input.read(cx).focus_handle(cx).is_focused(window)
});
if rename_has_focus {
// 工作区的无上下文绑定注册得更晚,会先于 Input 的 Paste 命中;
// 继续分发同一次按键,才能让重命名框接管,而不是写进背后的 PTY。
cx.propagate();
} else {
this.paste_focused_terminal(window, cx);
}
this.paste_focused_terminal(window, cx);
}))
.on_action(cx.listener(|this, _: &ToggleFullscreen, window, _cx| {
window.toggle_fullscreen();
@@ -4861,6 +5058,7 @@ impl Render for NebulaWorkspace {
})
.children(self.tabs_scrollbar_drag_overlay(cx))
.children(self.pane_drag_overlay(cx))
.children(self.split_drag_visual(cx))
.when(self.sidebar_resizing, |root| {
// 侧栏拖宽罩层(同 split_drag 的指针捕获模式):移动实时改宽
// (夹在共享层的 170..420 之间),松手写盘 `sidebar_w`。
@@ -20,15 +20,15 @@ use crate::gpui_shell::prelude::*;
use super::NebulaWorkspace;
/// 行距(旧壳 `PanelLayout::row_h`)。
const ROW_PITCH: f32 = 34.0;
pub(super) const ROW_PITCH: f32 = 34.0;
/// 行水洗高度(旧壳 `row_h - 4`)。行与行之间那条缝来自水洗比行距矮,
/// **不是** flex gap——用 gap 会把每行推成一颗独立药丸,连续列表的读感就散了。
const ROW_WASH_H: f32 = 30.0;
pub(super) const ROW_WASH_H: f32 = 30.0;
/// 水洗相对抽屉内缘的额外内缩:旧壳的水洗从面板边缘缩 10px,而抽屉自身的
/// `p_2` 已经占掉 8px。
const ROW_WASH_INSET: f32 = 2.0;
pub(super) const ROW_WASH_INSET: f32 = 2.0;
/// 抽屉内容左右留白(旧壳 `px + 12`:比水洗再多 2px)。
const DRAWER_TEXT_INSET: f32 = 4.0;
pub(super) const DRAWER_TEXT_INSET: f32 = 4.0;
/// 文件树右键菜单的宿主:必须挂在 workspace 根上,不能当抽屉行的 child。
///
@@ -57,6 +57,20 @@ pub(super) fn wsl_terminal_launch_at(
}
impl NebulaWorkspace {
pub(super) fn on_file_tree_search_event(
&mut self,
input: &Entity<InputState>,
event: &InputEvent,
_window: &mut Window,
cx: &mut Context<'_, Self>,
) {
if matches!(event, InputEvent::Change) {
self.file_tree_scroll.scroll_to_item(0, gpui::ScrollStrategy::Top);
self.side_panel.set_file_search_query(input.read(cx).value().to_string());
cx.notify();
}
}
/// 单行:34px 行距里画一张 30px 高的水洗(旧壳 side_panel.rs:2712-2800)。
fn render_file_tree_row(&self, visible_ix: usize, cx: &Context<Self>) -> gpui::AnyElement {
let Some(row) = self.side_panel.file_rows().get(visible_ix).cloned() else {
@@ -74,6 +88,29 @@ impl NebulaWorkspace {
// 改变折叠箭头和文件图标的 advance。
let symbol_family: SharedString = crate::font_install::REQUIRED_FONT_FAMILY.into();
let selected = self.side_panel.selected.as_ref() == Some(&row.path);
let searching = !self.side_panel.search.trim().is_empty();
let path_hint = searching.then(|| {
if let Some(guest) = row.guest_path.as_deref() {
let root =
self.side_panel.file_wsl_root().map(|root| root.guest.as_str()).unwrap_or("/");
guest
.rsplit_once('/')
.map(|(parent, _)| parent)
.unwrap_or("")
.strip_prefix(root)
.unwrap_or(guest)
.trim_matches('/')
.to_owned()
} else {
row.path
.parent()
.and_then(|parent| {
self.side_panel.root().and_then(|root| parent.strip_prefix(root).ok())
})
.map(|parent| parent.display().to_string())
.unwrap_or_default()
}
});
let path = row.path.clone();
let open_path = path.clone();
let open_guest_path = row.guest_path.clone();
@@ -163,18 +200,47 @@ impl NebulaWorkspace {
.whitespace_nowrap()
.child(row.name),
)
.when_some(path_hint.filter(|hint| !hint.is_empty()), |item, hint| {
item.child(
div()
.max_w(px(108.0))
.flex_shrink_0()
.text_xs()
.text_color(muted)
.overflow_hidden()
.whitespace_nowrap()
.child(hint),
)
})
.on_click(cx.listener(move |this, _, _, cx| {
if is_dir {
if this.side_panel.click_row(visible_ix) {
cx.notify();
} else {
this.side_panel.selected = Some(path.clone());
cx.notify();
}
} else {
this.side_panel.selected = Some(path.clone());
cx.notify();
}
}))
.when(!is_dir && !is_parent, |item| {
.when(!is_parent, |item| {
item.on_double_click(cx.listener(move |this, _, window, cx| {
if is_dir {
if this.side_panel.browse_search_directory(
open_path.clone(),
open_guest_path.clone(),
) {
this.file_tree_search_input.update(cx, |input, cx| {
input.set_value("", window, cx);
});
this.file_tree_scroll
.scroll_to_item(0, gpui::ScrollStrategy::Top);
cx.notify();
}
return;
}
// 与旧壳 chrome 同合同:应用内能读的(图片/可读文本)
// 开查看 tab,其余交系统处理器。WSL 行先映射为官方 UNC
// 形式,不能把 `/home/...` 直接交给 Windows 文件 API。
@@ -227,12 +293,134 @@ impl NebulaWorkspace {
/// 抽屉。贴满右侧整条竖带,四边不留卡缝(用户 08-26 裁定"无缝");行几何
/// 仍以旧壳 `panel_layout`(side_panel.rs:1695-1729)为基准。
pub(super) fn render_file_tree(&mut self, cx: &mut Context<Self>) -> gpui::AnyElement {
use crate::display::ui::tokens::{control, radius};
// 滚动只由 uniform_list 承担。旧壳那套行粒度 `scroll` 不再参与,否则
// `click_row` 的 `scroll + index` 会把点击算到别的行上。
self.side_panel.scroll = 0;
let view_switch = self.render_side_panel_switch(cx).into_any_element();
let theme = cx.theme();
let muted = theme.muted_foreground;
let language = super::workspace_ui_language();
let search_options = self.side_panel.file_search_options();
let search_active = !self.side_panel.search.trim().is_empty();
let search_pending = self.side_panel.file_search_pending();
let search_error = self.side_panel.file_search_error().is_some();
let indexed_count = self.side_panel.file_indexed_count();
let indexed_more = if self.side_panel.file_index_truncated() { "+" } else { "" };
let search_status = if search_active {
Some(if search_error {
language.pick("正则表达式无效", "Invalid regular expression").to_owned()
} else if self.side_panel.file_index_status()
== crate::display::side_panel::FileIndexStatus::Building
{
language.pick("正在建立文件索引…", "Building file index...").to_owned()
} else if search_pending {
language.pick("正在搜索…", "Searching...").to_owned()
} else {
match language {
crate::display::UiLanguage::ZhCn => format!(
"{} 个结果 · 已索引 {}{} 项",
self.side_panel.file_search_total(),
indexed_count,
indexed_more
),
crate::display::UiLanguage::EnUs => format!(
"{} results · {}{} indexed",
self.side_panel.file_search_total(),
indexed_count,
indexed_more
),
}
})
} else {
None
};
let search_box = v_flex()
.w_full()
.gap_1()
.child(
h_flex()
.w_full()
.h(px(control::MIN_HIT_TARGET))
.flex_shrink_0()
.rounded(px(radius::CONTROL))
.border_1()
.border_color(if search_error { theme.danger } else { theme.border })
.bg(theme.muted)
.overflow_hidden()
.child(
div().flex_1().min_w_0().child(
Input::new(&self.file_tree_search_input)
.w_full()
.appearance(false)
.focus_bordered(false)
.cleanable(true)
.prefix(Icon::new(IconName::Search).xsmall().text_color(muted))
.text_size(px(13.0)),
),
)
.child(
h_flex()
.flex_shrink_0()
.pr_1()
.gap(px(2.0))
.child(
Button::new("file-search-match-case")
.label("Aa")
.ghost()
.xsmall()
.selected(search_options.match_case)
.tooltip(language.pick("区分大小写", "Match case"))
.on_click(cx.listener(|this, _, _, cx| {
this.side_panel.toggle_file_search_match_case();
this.file_tree_scroll
.scroll_to_item(0, gpui::ScrollStrategy::Top);
cx.notify();
})),
)
.child(
Button::new("file-search-whole-word")
.label("ab")
.ghost()
.xsmall()
.selected(search_options.whole_word)
.tooltip(language.pick("全词匹配", "Match whole word"))
.on_click(cx.listener(|this, _, _, cx| {
this.side_panel.toggle_file_search_whole_word();
this.file_tree_scroll
.scroll_to_item(0, gpui::ScrollStrategy::Top);
cx.notify();
})),
)
.child(
Button::new("file-search-regex")
.label(".*")
.ghost()
.xsmall()
.selected(search_options.regex)
.tooltip(
language.pick("使用正则表达式", "Use regular expression"),
)
.on_click(cx.listener(|this, _, _, cx| {
this.side_panel.toggle_file_search_regex();
this.file_tree_scroll
.scroll_to_item(0, gpui::ScrollStrategy::Top);
cx.notify();
})),
),
),
)
.when_some(search_status, |search, status| {
search.child(
div()
.h(px(18.0))
.px(px(DRAWER_TEXT_INSET))
.text_xs()
.text_color(if search_error { theme.danger } else { muted })
.child(status),
)
});
let root_dir = self.side_panel.root().map(std::path::Path::to_path_buf);
let root = root_dir
.as_ref()
@@ -363,6 +551,7 @@ impl NebulaWorkspace {
.when_some(self.side_panel.root_notice(), |panel, notice| {
panel.child(div().text_xs().text_color(theme.warning).child(notice.to_owned()))
})
.child(search_box)
.child(
// 一套滚动模型。此前是 `file_rows().skip(scroll)` 先砍掉上面的行、
// 再套 `overflow_y_scrollbar` 滚剩下的,两套模型打架:滚动条滑块
@@ -436,6 +625,27 @@ impl NebulaWorkspace {
if self.side_panel.file_rows().iter().any(|row| !row.is_parent) {
return None;
}
if !self.side_panel.search.trim().is_empty() {
return Some(if let Some(error) = self.side_panel.file_search_error() {
crate::ux::EmptyState::new(
"正则表达式无效",
error.to_owned(),
"修改表达式,或关闭右侧的 .* 选项。",
)
} else if self.side_panel.file_search_pending() {
crate::ux::EmptyState::new(
"正在建立索引",
"文件名索引正在后台准备,界面仍可继续使用。",
"索引完成后会自动显示当前查询的结果。",
)
} else {
crate::ux::EmptyState::new(
"没有匹配的文件",
"当前目录的索引中没有符合条件的文件或文件夹。",
"修改查询,或关闭大小写、全词、正则选项后重试。",
)
});
}
Some(if self.side_panel.snapshot_pending() {
crate::ux::EmptyState::new(
"正在读取目录",
@@ -37,26 +37,37 @@ use crate::ssh_sftp::{
};
use super::NebulaWorkspace;
use super::file_tree::{DRAWER_TEXT_INSET, ROW_PITCH, ROW_WASH_H, ROW_WASH_INSET};
/// 行距,与本地文件树同源——同一个抽屉里两种列表的行高不一致,切换时整列
/// 文字会跳一下。
const ROW_PITCH: f32 = 34.0;
/// 抽屉内容左右留白,同样与本地树对齐。
const TEXT_INSET: f32 = 4.0;
/// 工具栏与说明文字沿用本地树的抽屉内边距。
const TEXT_INSET: f32 = DRAWER_TEXT_INSET;
#[derive(Clone)]
struct RemoteDirectorySnapshot {
destination: String,
path: String,
entries: Vec<SftpEntry>,
error: Option<String>,
selected: Option<String>,
}
/// 远端浏览器的状态。
///
/// 一份状态服务所有 pane:连接是按目的地复用的,为每个 pane 存一套列表只会
/// 让同一台机器的同一个目录被列多次。按 pane 区分的只有"浏览到哪儿了"。
/// 当前可见状态只有一份,但每个 pane 都保留最近一次已确认的目录快照与滚动
/// 句柄。切 tab 只是换回这份快照,不触发网络列目录;连接池是否复用仍由下层
/// 按目的地裁定,与 UI 缓存的所有权互不混淆。
#[derive(Default)]
pub(super) struct RemoteBrowser {
/// 当前绑定的 pane。`None` 表示抽屉此刻不该画远端内容。
pane: Option<u64>,
/// 当前绑定的远端目的地,用于标题和后续请求。
destination: String,
/// 每个 pane 各自浏览到的目录。切回这个 pane 时直接复原,不用重新导航
/// 到家目录——用户在两个 tab 之间来回看两个目录是常态。
visited: HashMap<u64, String>,
/// 每个 pane 最近一次已确认的列表。不能只记路径:切回来再按路径列一次,
/// 本质上仍是 tab 激活触发刷新,也会丢掉选择和瞬时滚动上下文。
snapshots: HashMap<u64, RemoteDirectorySnapshot>,
/// uniform_list 的 handle 内含滚动位置;每个 pane 各持一个,避免 A 主机的
/// 长目录把 B 主机顶到同一个偏移。
scrolls: HashMap<u64, gpui::UniformListScrollHandle>,
/// 当前列出的目录。
path: String,
entries: Vec<SftpEntry>,
@@ -105,13 +116,80 @@ impl RemoteBrowser {
self.pane.is_some()
}
/// 解绑:聚焦回本地 pane 时调用。
///
/// 只清"当前显示什么",不清 `visited`——那是每个 pane 的浏览位置,下次
/// 切回来还要用。连接本身由下层按目的地缓存,这里也不去动它。
fn park_active(&mut self) {
let Some(pane) = self.pane else { return };
if self.path.is_empty() {
return;
}
self.snapshots.insert(
pane,
RemoteDirectorySnapshot {
destination: self.destination.clone(),
path: self.path.clone(),
entries: self.entries.clone(),
error: self.error.clone(),
selected: self.selected.clone(),
},
);
}
/// 绑定 pane。返回 `true` 表示已有完整快照,调用方不得再列目录。
fn bind(&mut self, pane: u64, destination: String) -> bool {
self.park_active();
self.generation = self.generation.wrapping_add(1);
self.pane = Some(pane);
self.destination = destination.clone();
self.loading = false;
self.scrolls.entry(pane).or_insert_with(gpui::UniformListScrollHandle::new);
let snapshot = self
.snapshots
.get(&pane)
.filter(|snapshot| snapshot.destination == destination)
.cloned();
if let Some(snapshot) = snapshot {
self.path = snapshot.path;
self.entries = snapshot.entries;
self.error = snapshot.error;
self.selected = snapshot.selected;
return true;
}
// 同一个 pane 重新连到另一个目的地时,旧主机快照不能复用。
self.snapshots.remove(&pane);
self.path.clear();
self.entries.clear();
self.error = None;
self.selected = None;
false
}
fn scroll_for(&mut self, pane: u64) -> gpui::UniformListScrollHandle {
self.scrolls.entry(pane).or_insert_with(gpui::UniformListScrollHandle::new).clone()
}
pub(super) fn forget(&mut self, pane: u64) {
self.snapshots.remove(&pane);
self.scrolls.remove(&pane);
if self.pane == Some(pane) {
self.pane = None;
self.destination.clear();
self.path.clear();
self.entries.clear();
self.error = None;
self.loading = false;
self.selected = None;
self.generation = self.generation.wrapping_add(1);
}
}
/// 解绑:聚焦回本地 pane 时调用。目录、选择和滚动位置先按 pane 停放;
/// 连接本身由下层按目的地缓存,这里不触碰 transport。
fn detach(&mut self) {
self.park_active();
self.pane = None;
self.destination.clear();
self.path.clear();
self.entries.clear();
self.error = None;
self.loading = false;
@@ -170,19 +248,15 @@ impl NebulaWorkspace {
_window: &mut Window,
cx: &mut Context<'_, Self>,
) {
self.remote_browser.pane = Some(pane);
self.remote_browser.destination = destination.clone();
self.remote_browser.entries.clear();
self.remote_browser.error = None;
self.remote_browser.selected = None;
// 起点优先用这个 pane 上次看到哪儿;第一次进来则问终端当前在哪个
// 目录,问不到再退到根。直接从根开始是最差的选择:用户在终端里
// `cd` 到很深的地方,浏览器却从 `/` 开始,等于每次都要重新点进去。
if let Some(previous) = self.remote_browser.visited.get(&pane).cloned() {
self.navigate_remote(previous, cx);
let restored = self.remote_browser.bind(pane, destination.clone());
self.remote_files_scroll = self.remote_browser.scroll_for(pane);
if restored {
cx.notify();
return;
}
// 第一次进入才问终端当前在哪个目录,问不到再退到根。切回已有 pane
// 已在上面恢复完整快照,绝不能走到这里制造一次隐式刷新。
self.navigate_remote_to_shell_cwd(pane, destination, cx);
}
@@ -255,7 +329,6 @@ impl NebulaWorkspace {
Some(Ok(entries)) => {
workspace.remote_browser.entries = entries;
workspace.remote_browser.path = path.clone();
workspace.remote_browser.visited.insert(pane, path);
workspace.remote_browser.error = None;
workspace.remote_browser.selected = None;
},
@@ -376,7 +449,6 @@ impl NebulaWorkspace {
if self.remote_browser.path == snapshot.path {
self.remote_browser.entries = snapshot.entries.clone();
self.remote_browser.selected = None;
self.remote_browser.visited.insert(pane, snapshot.path.clone());
}
self.remote_browser.error = None;
},
@@ -1127,7 +1199,7 @@ impl NebulaWorkspace {
// 像两套产品。
let (icon, ink) = match row.kind {
SftpEntryKind::Directory => {
(crate::display::side_panel::folder_icon(false), theme.accent_foreground)
(crate::display::side_panel::folder_icon(false), theme.foreground)
},
SftpEntryKind::Symlink => ("\u{ea71}", theme.muted_foreground),
SftpEntryKind::File => {
@@ -1138,23 +1210,31 @@ impl NebulaWorkspace {
let label = row.name.clone();
let activate = row.clone();
div()
h_flex()
.h(px(ROW_PITCH))
.flex()
.items_center()
.w_full()
.px(px(ROW_WASH_INSET))
.child(
h_flex()
.id(SharedString::from(format!("remote-row-{}", row.path)))
.h(px(ROW_PITCH - 4.0))
.w_full()
.px(px(TEXT_INSET + 2.0))
.h(px(ROW_WASH_H))
.flex_1()
.min_w_0()
.items_center()
.gap_2()
.rounded(px(6.0))
.when(selected, |row| row.bg(theme.tab_active))
.pr_2()
.pl(px(8.0))
.gap_1()
.rounded(px(crate::display::UI_CORNER_RADIUS_LOGICAL))
.border_1()
.border_color(gpui::transparent_black())
.when(selected, |row| {
row.bg(theme.tab_active).border_color(theme.ring.opacity(0.16))
})
.hover(|row| row.bg(theme.list_hover))
.child(div().w(px(12.0)).flex_shrink_0())
.child(
div()
.w(px(16.0))
.font_family(symbol_family)
.text_color(ink)
.flex_shrink_0()
@@ -1164,7 +1244,7 @@ impl NebulaWorkspace {
div()
.flex_1()
.min_w_0()
.text_xs()
.text_sm()
.text_color(theme.foreground)
.overflow_hidden()
.whitespace_nowrap()
@@ -1287,7 +1367,53 @@ where
#[cfg(test)]
mod tests {
use super::format_transfer_bytes;
use super::{RemoteBrowser, SftpEntry, SftpEntryKind, format_transfer_bytes};
fn entry(name: &str, path: &str) -> SftpEntry {
SftpEntry {
name: name.to_owned(),
path: path.to_owned(),
kind: SftpEntryKind::File,
size: 12,
modified: 34,
permissions: "rw-r--r--".to_owned(),
is_parent: false,
}
}
#[test]
fn switching_remote_panes_restores_listing_selection_and_scroll_owner_without_reload() {
let mut browser = RemoteBrowser::default();
assert!(!browser.bind(11, "alice@alpha".to_owned()));
browser.path = "/srv/alpha".to_owned();
browser.entries = vec![entry("alpha.txt", "/srv/alpha/alpha.txt")];
browser.selected = Some("/srv/alpha/alpha.txt".to_owned());
assert!(!browser.bind(22, "bob@beta".to_owned()));
browser.path = "/home/bob".to_owned();
browser.entries = vec![entry("beta.txt", "/home/bob/beta.txt")];
assert_eq!(browser.scrolls.len(), 2, "每个 pane 必须持有独立滚动句柄");
assert!(browser.bind(11, "alice@alpha".to_owned()));
assert_eq!(browser.path, "/srv/alpha");
assert_eq!(browser.entries, vec![entry("alpha.txt", "/srv/alpha/alpha.txt")]);
assert_eq!(browser.selected.as_deref(), Some("/srv/alpha/alpha.txt"));
assert!(!browser.loading);
}
#[test]
fn changing_a_panes_destination_discards_the_old_host_snapshot() {
let mut browser = RemoteBrowser::default();
assert!(!browser.bind(7, "root@old-host".to_owned()));
browser.path = "/root".to_owned();
browser.entries = vec![entry("old", "/root/old")];
assert!(!browser.bind(7, "root@new-host".to_owned()));
assert!(browser.path.is_empty());
assert!(browser.entries.is_empty());
assert!(browser.selected.is_none());
}
#[test]
fn transfer_byte_counts_use_binary_units() {
+20 -14
View File
@@ -188,7 +188,7 @@ impl NebulaWorkspace {
.map(crate::display::program_icon)
.or_else(|| match &self.tabs[ix] {
WorkspaceTab::Document { .. } => Some("\u{eb1d}"),
WorkspaceTab::Code { view } => {
WorkspaceTab::Code { view, .. } => {
Some(crate::display::side_panel::file_type_icon(&view.read(cx).title))
},
WorkspaceTab::Image { view } => {
@@ -236,6 +236,10 @@ impl NebulaWorkspace {
// 标题/标签走稳定的 UI 字体;程序图标是 Nerd Font 字位,固定走随
// 安装包提供的 Maple。用户设置的终端字体不得改变 chrome 几何。
let settings = cx.try_global::<crate::gpui_shell::config::Settings>();
let tab_close_visible = settings.map(|settings| settings.tab_close_visible).unwrap_or(true);
let tab_reveal = settings
.map(|settings| settings.tab_reveal)
.unwrap_or(nebula_settings::TabRevealName::Slide);
let chrome_family = theme.mono_font_family.clone();
let symbol_family: SharedString = crate::font_install::REQUIRED_FONT_FAMILY.into();
// 旧壳合同(display/mod.rs `ui_font_px`):chrome 锚定**配置字号**
@@ -596,16 +600,20 @@ impl NebulaWorkspace {
.items_center()
.invisible()
.group_hover(hover_group, |slot| slot.visible())
.child(
Button::new(("close-tab", ix))
.icon(IconName::Close)
.ghost()
.xsmall()
.on_click(cx.listener(move |this, _, window, cx| {
cx.stop_propagation();
this.request_close_tab(ix, window, cx);
})),
),
.when(tab_close_visible, |slot| {
slot.child(
Button::new(("close-tab", ix))
.icon(IconName::Close)
.ghost()
.xsmall()
.on_click(cx.listener(
move |this, _, window, cx| {
cx.stop_propagation();
this.request_close_tab(ix, window, cx);
},
)),
)
}),
),
)
// 右键只记锚点:菜单由 workspace 根上唯一一份宿主画。挂
@@ -634,9 +642,7 @@ impl NebulaWorkspace {
// 让位滑动:进位方向 ease-out 滑入(旧壳是双向弹簧;回位
// 这里先直落,违和再补逐帧插值)。设置「标签动画=立即」时
// 直接落位(旧壳 TabRevealMotion::Instant 的 Snap 语义)。
if nebula_settings::RuntimeSettings::load().tab_reveal
== nebula_settings::TabRevealName::Instant
{
if tab_reveal == nebula_settings::TabRevealName::Instant {
row.top(px(shift)).into_any_element()
} else {
row.with_animation(
+66 -1
View File
@@ -221,7 +221,7 @@ mod tab_rename_paste_tests {
)
});
assert_eq!(value, "renamed-tab", "Ctrl+V 必须替换重命名框当前选区");
assert_eq!(workspace_paste_actions, 1, "后注册的工作区绑定应先命中一次");
assert_eq!(workspace_paste_actions, 0, "输入框上下文不得命中终端粘贴动作");
assert_eq!(terminal_pastes, 0, "重命名框聚焦时不得向背后的终端粘贴");
}
}
@@ -236,6 +236,32 @@ fn gpui_binding_combo_maps_plus_minus_and_digits() {
);
}
#[test]
fn paste_keymap_keeps_component_inputs_and_terminals_in_separate_contexts() {
use gpui::{KeyContext, Keymap, Keystroke};
let keymap = Keymap::new(vec![
KeyBinding::new("ctrl-v", PasteClipboard, Some(crate::gpui_shell::terminal::KEY_CONTEXT)),
KeyBinding::new("ctrl-v", gpui_component::input::Paste, Some("Input")),
]);
let key = [Keystroke::parse("ctrl-v").unwrap()];
let input = [KeyContext::parse("Root").unwrap(), KeyContext::parse("Input").unwrap()];
let (bindings, pending) = keymap.bindings_for_input(&key, &input);
assert!(!pending);
assert_eq!(bindings.len(), 1);
assert!(bindings[0].action().as_any().is::<gpui_component::input::Paste>());
let terminal = [
KeyContext::parse("Root").unwrap(),
KeyContext::parse(crate::gpui_shell::terminal::KEY_CONTEXT).unwrap(),
];
let (bindings, pending) = keymap.bindings_for_input(&key, &terminal);
assert!(!pending);
assert_eq!(bindings.len(), 1);
assert!(bindings[0].action().as_any().is::<PasteClipboard>());
}
#[test]
fn pane_card_divider_reaches_the_window_top_without_moving_its_bottom() {
let card = Bounds::new(gpui::point(px(230.0), px(48.0)), size(px(850.0), px(672.0)));
@@ -267,6 +293,45 @@ fn pane_card_divider_requires_both_width_and_a_real_sidebar_boundary() {
assert!(pane_card_divider_bounds(sidebar, 0.0, 1.5).is_none());
}
#[test]
fn split_drag_preview_moves_only_the_overlay_divider() {
let viewport = nebula_split::Rect::new(100.0, 40.0, 802.0, 500.0);
let visual = split_drag_visual_geometry(SplitDirection::LeftRight, viewport, 0.25, None);
assert_eq!(visual.divider, nebula_split::Rect::new(300.0, 40.0, 2.0, 500.0));
assert_eq!(visual.close_area, None);
let vertical = split_drag_visual_geometry(
SplitDirection::TopBottom,
nebula_split::Rect::new(20.0, 30.0, 700.0, 402.0),
0.75,
None,
);
assert_eq!(vertical.divider, nebula_split::Rect::new(20.0, 330.0, 700.0, 2.0));
}
#[test]
fn split_drag_close_preview_marks_only_the_squeezed_child() {
let viewport = nebula_split::Rect::new(100.0, 40.0, 802.0, 500.0);
let first = split_drag_visual_geometry(
SplitDirection::LeftRight,
viewport,
nebula_split::preview_ratio(0.01),
nebula_split::drag_close_target(0.01),
);
assert_eq!(first.divider, nebula_split::Rect::new(116.0, 40.0, 2.0, 500.0));
assert_eq!(first.close_area, Some(nebula_split::Rect::new(100.0, 40.0, 16.0, 500.0)));
let second = split_drag_visual_geometry(
SplitDirection::TopBottom,
nebula_split::Rect::new(20.0, 30.0, 700.0, 402.0),
nebula_split::preview_ratio(0.99),
nebula_split::drag_close_target(0.99),
);
assert_eq!(second.divider, nebula_split::Rect::new(20.0, 422.0, 700.0, 2.0));
assert_eq!(second.close_area, Some(nebula_split::Rect::new(20.0, 424.0, 700.0, 8.0)));
}
#[test]
fn ai_hook_routing_never_falls_back_for_a_stale_exact_pane() {
let pane_ids = [7u64, 11];
+19 -15
View File
@@ -137,6 +137,10 @@ impl NebulaWorkspace {
let hover_bg = theme.list_hover;
let dark = theme.is_dark();
let settings = cx.try_global::<crate::gpui_shell::config::Settings>();
let tab_close_visible = settings.map(|settings| settings.tab_close_visible).unwrap_or(true);
let tab_reveal = settings
.map(|settings| settings.tab_reveal)
.unwrap_or(nebula_settings::TabRevealName::Slide);
let chrome_family = theme.mono_font_family.clone();
let symbol_family: SharedString = crate::font_install::REQUIRED_FONT_FAMILY.into();
let label_px = settings.map(|settings| settings.base_font_size_px).unwrap_or(15.0);
@@ -458,18 +462,20 @@ impl NebulaWorkspace {
.items_center()
.invisible()
.group_hover(hover_group, |slot| slot.visible())
.child(
Button::new(("top-close-tab", ix))
.icon(IconName::Close)
.ghost()
.xsmall()
.on_click(cx.listener(
move |this, _, window, cx| {
cx.stop_propagation();
this.request_close_tab(ix, window, cx);
},
)),
),
.when(tab_close_visible, |slot| {
slot.child(
Button::new(("top-close-tab", ix))
.icon(IconName::Close)
.ghost()
.xsmall()
.on_click(cx.listener(
move |this, _, window, cx| {
cx.stop_propagation();
this.request_close_tab(ix, window, cx);
},
)),
)
}),
),
)
.when_some(cross_window_drag, |row, payload| {
@@ -484,9 +490,7 @@ impl NebulaWorkspace {
)
.into_any_element()
} else if shift != 0.0 {
if nebula_settings::RuntimeSettings::load().tab_reveal
== nebula_settings::TabRevealName::Instant
{
if tab_reveal == nebula_settings::TabRevealName::Instant {
row.left(px(shift)).into_any_element()
} else {
row.with_animation(
File diff suppressed because it is too large. Load diff
+61 -6
View File
@@ -13,10 +13,13 @@ use std::time::Instant;
use std::{env, process};
use log::{Level, LevelFilter};
#[cfg(feature = "legacy-shell")]
use winit::event_loop::EventLoopProxy;
use crate::cli::Options;
#[cfg(feature = "legacy-shell")]
use crate::event::{Event, EventType};
#[cfg(feature = "legacy-shell")]
use crate::message_bar::{Message, MessageType};
/// Logging target for IPC config error messages.
@@ -36,6 +39,29 @@ pub const LOG_TARGET_WINIT: &str = "nebula_winit_event";
/// The targets are semicolon separated.
const NEBULA_EXTRA_LOG_TARGETS_ENV: &str = "NEBULA_EXTRA_LOG_TARGETS";
pub(crate) fn debug_log(message: impl AsRef<str>) {
use std::io::Write as _;
static ENABLED: OnceLock<bool> = OnceLock::new();
if !*ENABLED.get_or_init(|| {
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 timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| format!("{}.{:03}", duration.as_secs(), duration.subsec_millis()))
.unwrap_or_else(|_| "0.000".to_owned());
let path = crate::platform::dirs::data_dir().join("nebula_debug.log");
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) {
let _ = writeln!(file, "[{timestamp} pid={}] {}", process::id(), message.as_ref());
}
}
/// User configurable extra log targets to include.
fn extra_log_targets() -> &'static [String] {
static EXTRA_LOG_TARGETS: OnceLock<Vec<String>> = OnceLock::new();
@@ -58,13 +84,25 @@ const ALLOWED_TARGETS: &[&str] = &[
];
/// Initialize the logger to its defaults.
pub fn initialize(
pub fn initialize(options: &Options) -> Result<Option<PathBuf>, log::SetLoggerError> {
initialize_logger(options, Logger::new())
}
/// Initialize the logger with the legacy shell's message-bar event sink.
#[cfg(feature = "legacy-shell")]
pub fn initialize_legacy(
options: &Options,
event_proxy: EventLoopProxy<Event>,
) -> Result<Option<PathBuf>, log::SetLoggerError> {
initialize_logger(options, Logger::new_legacy(event_proxy))
}
fn initialize_logger(
options: &Options,
logger: Logger,
) -> Result<Option<PathBuf>, log::SetLoggerError> {
log::set_max_level(options.log_level());
let logger = Logger::new(event_proxy);
let path = logger.file_path();
log::set_boxed_logger(Box::new(logger))?;
@@ -74,16 +112,30 @@ pub fn initialize(
pub struct Logger {
logfile: Mutex<OnDemandLogFile>,
stdout: Mutex<LineWriter<Stdout>>,
event_proxy: Mutex<EventLoopProxy<Event>>,
#[cfg(feature = "legacy-shell")]
event_proxy: Option<Mutex<EventLoopProxy<Event>>>,
start: Instant,
}
impl Logger {
fn new(event_proxy: EventLoopProxy<Event>) -> Self {
fn new() -> Self {
let logfile = Mutex::new(OnDemandLogFile::new());
let stdout = Mutex::new(LineWriter::new(io::stdout()));
Logger { logfile, stdout, event_proxy: Mutex::new(event_proxy), start: Instant::now() }
Logger {
logfile,
stdout,
#[cfg(feature = "legacy-shell")]
event_proxy: None,
start: Instant::now(),
}
}
#[cfg(feature = "legacy-shell")]
fn new_legacy(event_proxy: EventLoopProxy<Event>) -> Self {
let mut logger = Self::new();
logger.event_proxy = Some(Mutex::new(event_proxy));
logger
}
fn file_path(&self) -> Option<PathBuf> {
@@ -92,6 +144,7 @@ impl Logger {
}
/// Log a record to the message bar.
#[cfg(feature = "legacy-shell")]
fn message_bar_log(&self, record: &log::Record<'_>, logfile_path: &str) {
let message_type = match record.level() {
Level::Error => MessageType::Error,
@@ -99,7 +152,8 @@ impl Logger {
_ => return,
};
let event_proxy = match self.event_proxy.lock() {
let Some(event_proxy) = self.event_proxy.as_ref() else { return };
let event_proxy = match event_proxy.lock() {
Ok(event_proxy) => event_proxy,
Err(_) => return,
};
@@ -150,6 +204,7 @@ impl log::Log for Logger {
let _ = logfile.write_all(message.as_ref());
// Log relevant entries to message bar.
#[cfg(feature = "legacy-shell")]
self.message_bar_log(record, &logfile.path.to_string_lossy());
}
+4 -1
View File
@@ -97,6 +97,7 @@ mod renderer;
mod runtime_api;
mod runtime_exec;
mod saved_commands;
#[cfg(feature = "legacy-shell")]
mod scheduler;
mod session;
mod shell_detect;
@@ -136,6 +137,7 @@ use crate::cli::MessageOptions;
use crate::cli::SocketMessage;
use crate::cli::{Options, Subcommands};
use crate::config::UiConfig;
#[cfg(feature = "legacy-shell")]
use crate::config::monitor::ConfigMonitor;
#[cfg(feature = "legacy-shell")]
use crate::event::{Event, Processor};
@@ -203,6 +205,7 @@ fn main() -> Result<(), Box<dyn Error>> {
// (ATTACH),不能再拉一套 PTY。
#[cfg(feature = "gpui-shell")]
if wants_gpui_shell(&options) {
let _log_file = logging::initialize(&options).expect("Unable to initialize logger");
#[cfg(windows)]
if try_hand_over_to_resident(&options) {
return Ok(());
@@ -337,7 +340,7 @@ fn nebula(mut options: Options) -> Result<(), Box<dyn Error>> {
boot_trace("event loop built");
// Initialize the logger as soon as possible as to capture output from other subsystems.
let log_file = logging::initialize(&options, window_event_loop.create_proxy())
let log_file = logging::initialize_legacy(&options, window_event_loop.create_proxy())
.expect("Unable to initialize logger");
info!("Welcome to Nebula");
+3
View File
@@ -23,8 +23,10 @@ use std::path::{Path, PathBuf};
use std::time::Duration;
use log::{info, warn};
#[cfg(feature = "legacy-shell")]
use winit::event_loop::EventLoopProxy;
#[cfg(feature = "legacy-shell")]
use crate::event::{Event, EventType};
const CONNECT_TIMEOUT: Duration = Duration::from_millis(400);
@@ -82,6 +84,7 @@ impl MuxServer {
/// Start serving attach requests, unless a live server already owns the
/// port file (then this instance stays client-only and returns `None`).
#[allow(dead_code)]
#[cfg(feature = "legacy-shell")]
pub fn spawn(proxy: EventLoopProxy<Event>) -> Option<Self> {
Self::spawn_callback(move || {
let _ = proxy.send_event(Event::new(EventType::NebulaAttach, None));
+299 -136
View File
@@ -1,88 +1,90 @@
//! Persistent, indexed command history backing Nebula's fish-style ghost-text
//! hint.
//! Persistent, scoped command history backing Nebula's fish-style suggestions.
//!
//! Commands are appended to `nebula_history.jsonl` (one `{ts,cwd,cmd}` record
//! per line) and held in memory newest-last. A prefix index keeps the hint
//! lookup at `O(log n + k)` instead of scanning the whole list on every
//! keystroke — important once the history grows into the thousands.
//! Local Windows shells share one pool. WSL commands are isolated by distro,
//! and SSH commands by destination, so a valid command on one machine never
//! becomes a misleading suggestion on another.
use std::collections::BTreeMap;
use std::collections::{BTreeMap, HashMap};
use std::io::Write;
use std::path::PathBuf;
/// Max commands kept in memory.
const HISTORY_MAX: usize = 5_000;
pub(crate) const LOCAL_HISTORY_FILE: &str = "nebula_history.jsonl";
pub(crate) const WSL_HISTORY_FILE: &str = "nebula_history_wsl.jsonl";
pub(crate) const SSH_HISTORY_FILE: &str = "nebula_history_ssh.jsonl";
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
pub enum HistoryScope {
#[default]
Local,
Wsl(String),
Ssh(String),
}
impl HistoryScope {
fn normalized(self) -> Self {
match self {
Self::Local => Self::Local,
Self::Wsl(distro) => Self::Wsl(normalize_identity(&distro)),
Self::Ssh(destination) => Self::Ssh(normalize_ssh_identity(&destination)),
}
}
fn file_name(&self) -> &'static str {
match self {
Self::Local => LOCAL_HISTORY_FILE,
Self::Wsl(_) => WSL_HISTORY_FILE,
Self::Ssh(_) => SSH_HISTORY_FILE,
}
}
fn identity(&self) -> Option<&str> {
match self {
Self::Local => None,
Self::Wsl(identity) | Self::Ssh(identity) => Some(identity),
}
}
}
fn normalize_identity(identity: &str) -> String {
identity.trim().to_ascii_lowercase()
}
fn normalize_ssh_identity(destination: &str) -> String {
let destination = destination.trim();
match destination.rsplit_once('@') {
Some((user, host)) => format!("{user}@{}", host.to_ascii_lowercase()),
None => destination.to_ascii_lowercase(),
}
}
/// An indexed, deduplicated command history.
#[derive(Debug, Default)]
pub struct NebulaHistory {
/// Commands in recency order, oldest first, newest last. Deduplicated:
/// re-running a command moves it to the end rather than adding a copy.
struct HistoryIndex {
entries: Vec<String>,
/// Prefix index: `command -> position in `entries``. A `BTreeMap` so a
/// prefix query becomes a range scan over the small matching window rather
/// than a full linear pass. Values are kept in sync with `entries`.
index: BTreeMap<String, usize>,
}
impl NebulaHistory {
/// Load history from disk, newest-last, capped at [`HISTORY_MAX`].
pub fn load() -> Self {
let mut history = Self::default();
history.load_nebula_history();
history_debug_log(format!("history_load entries={}", history.entries.len()));
history
}
/// Record a freshly run command: persist it and update the in-memory index.
/// No-ops for blank input or an immediate repeat of the last command.
pub fn record(&mut self, cmd: &str, cwd: &str) {
let cmd = cmd.trim();
if cmd.is_empty() {
return;
}
if self.entries.last().map(String::as_str) == Some(cmd) {
history_debug_log(format!("history_record_skip_repeat cmd={cmd:?} cwd={cwd:?}"));
return;
}
append(cmd, cwd);
self.insert(cmd.to_owned());
history_debug_log(format!(
"history_record cmd={cmd:?} cwd={cwd:?} entries={}",
self.entries.len()
));
}
/// The newest command that begins with `prefix` and strictly extends it,
/// returning only the remainder (the part past `prefix`). `None` when
/// nothing matches. Uses the prefix index, so cost scales with the number
/// of matches, not the history size.
pub fn hint(&self, prefix: &str) -> Option<&str> {
impl HistoryIndex {
fn hint(&self, prefix: &str) -> Option<&str> {
if prefix.is_empty() {
return None;
}
// All keys with `prefix` as a prefix form a contiguous BTreeMap range
// `[prefix, prefix++)`, where `prefix++` is `prefix` with its last code
// unit bumped. Scan that window and keep the most-recent (highest pos).
let mut best: Option<(usize, &str)> = None;
for (cmd, &pos) in self.index.range(prefix.to_owned()..) {
if !cmd.starts_with(prefix) {
break;
}
if cmd.len() == prefix.len() {
continue; // exact match — nothing to hint
continue;
}
if best.is_none_or(|(bp, _)| pos > bp) {
if best.is_none_or(|(best_pos, _)| pos > best_pos) {
best = Some((pos, &cmd[prefix.len()..]));
}
}
best.map(|(_, rem)| rem)
best.map(|(_, remainder)| remainder)
}
/// Up to `limit` commands beginning with `prefix` (strict extensions
/// only), newest first, as `(full_command, remainder)` pairs. Same indexed
/// range scan as [`Self::hint`]; feeds the popup completion list.
pub fn hints(&self, prefix: &str, limit: usize) -> Vec<(&str, &str)> {
fn hints(&self, prefix: &str, limit: usize) -> Vec<(&str, &str)> {
if prefix.is_empty() || limit == 0 {
return Vec::new();
}
@@ -91,30 +93,23 @@ impl NebulaHistory {
if !cmd.starts_with(prefix) {
break;
}
if cmd.len() == prefix.len() {
continue; // exact match — nothing to complete
if cmd.len() != prefix.len() {
matches.push((pos, cmd.as_str()));
}
matches.push((pos, cmd.as_str()));
}
matches.sort_unstable_by(|a, b| b.0.cmp(&a.0));
matches.into_iter().take(limit).map(|(_, cmd)| (cmd, &cmd[prefix.len()..])).collect()
}
/// Insert a command, deduplicating and rebuilding the index when an old
/// copy is displaced, and trimming to [`HISTORY_MAX`].
fn insert(&mut self, cmd: String) {
if let Some(&old) = self.index.get(&cmd) {
// Move an existing command to the front of recency: drop the old
// slot and re-push. Positions after it shift, so reindex.
self.entries.remove(old);
self.entries.push(cmd);
self.reindex();
return;
}
self.entries.push(cmd.clone());
let pos = self.entries.len() - 1;
self.index.insert(cmd, pos);
self.index.insert(cmd, self.entries.len() - 1);
if self.entries.len() > HISTORY_MAX {
let drop = self.entries.len() - HISTORY_MAX;
self.entries.drain(0..drop);
@@ -122,30 +117,80 @@ impl NebulaHistory {
}
}
/// Rebuild the prefix index from `entries`. Called only on the rare
/// dedup/trim paths, not on the common append.
fn reindex(&mut self) {
self.index.clear();
for (i, cmd) in self.entries.iter().enumerate() {
self.index.insert(cmd.clone(), i);
}
}
fn load_nebula_history(&mut self) {
if let Ok(data) = std::fs::read_to_string(history_path()) {
for line in data.lines() {
if let Some((cmd, _cwd)) = parse_record(line) {
self.insert(cmd);
}
}
for (position, command) in self.entries.iter().enumerate() {
self.index.insert(command.clone(), position);
}
}
}
/// Path to `nebula_history.jsonl` under the user data dir, creating the
/// directory if needed.
fn history_path() -> PathBuf {
crate::platform::dirs::data_dir().join("nebula_history.jsonl")
#[derive(Debug, Default)]
pub struct NebulaHistory {
pools: HashMap<HistoryScope, HistoryIndex>,
}
impl NebulaHistory {
pub fn load() -> Self {
let mut history = Self::default();
history.load_file(&HistoryScope::Local);
history.load_file(&HistoryScope::Wsl(String::new()));
history.load_file(&HistoryScope::Ssh(String::new()));
history_debug_log(format!(
"history_load scopes={} entries={}",
history.pools.len(),
history.pools.values().map(|pool| pool.entries.len()).sum::<usize>()
));
history
}
pub fn record(&mut self, scope: &HistoryScope, cmd: &str, cwd: &str) {
let scope = scope.clone().normalized();
let cmd = cmd.trim();
if cmd.is_empty() {
return;
}
let pool = self.pools.entry(scope.clone()).or_default();
if pool.entries.last().map(String::as_str) == Some(cmd) {
history_debug_log(format!(
"history_record_skip_repeat scope={scope:?} cmd={cmd:?} cwd={cwd:?}"
));
return;
}
append(&scope, cmd, cwd);
pool.insert(cmd.to_owned());
history_debug_log(format!(
"history_record scope={scope:?} cmd={cmd:?} cwd={cwd:?} entries={}",
pool.entries.len()
));
}
pub fn hint(&self, scope: &HistoryScope, prefix: &str) -> Option<&str> {
self.pools.get(&scope.clone().normalized())?.hint(prefix)
}
pub fn hints(&self, scope: &HistoryScope, prefix: &str, limit: usize) -> Vec<(&str, &str)> {
self.pools
.get(&scope.clone().normalized())
.map_or_else(Vec::new, |pool| pool.hints(prefix, limit))
}
fn load_file(&mut self, category: &HistoryScope) {
let path = history_path(category.file_name());
let Ok(data) = std::fs::read_to_string(path) else { return };
for line in data.lines() {
let Some((scope, cmd)) = parse_record(category, line) else { continue };
self.pools.entry(scope).or_default().insert(cmd);
}
}
}
pub(crate) fn history_file_names() -> [&'static str; 3] {
[LOCAL_HISTORY_FILE, WSL_HISTORY_FILE, SSH_HISTORY_FILE]
}
fn history_path(file_name: &str) -> PathBuf {
crate::platform::dirs::data_dir().join(file_name)
}
#[cfg(test)]
@@ -162,94 +207,212 @@ fn history_debug_log(message: impl AsRef<str>) {
}) {
return;
}
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| format!("{}.{:03}", d.as_secs(), d.subsec_millis()))
.map(|duration| format!("{}.{:03}", duration.as_secs(), duration.subsec_millis()))
.unwrap_or_else(|_| "0.000".to_owned());
if let Some(dir) = history_path().parent().map(PathBuf::from) {
let path = dir.join("nebula_debug.log");
if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(path) {
let _ = writeln!(file, "[{ts}] {}", message.as_ref());
}
let path = history_path(LOCAL_HISTORY_FILE).with_file_name("nebula_debug.log");
if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(path) {
let _ = writeln!(file, "[{ts}] {}", message.as_ref());
}
}
/// Append one `{ts,cwd,cmd}` JSONL record. Best-effort; failures are ignored so
/// persistence never blocks the prompt.
fn append(cmd: &str, cwd: &str) {
fn append(scope: &HistoryScope, cmd: &str, cwd: &str) {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.map(|duration| duration.as_secs())
.unwrap_or(0);
let record = serde_json::json!({ "ts": ts, "cwd": cwd, "cmd": cmd });
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(history_path()) {
let _ = writeln!(f, "{record}");
let mut record = serde_json::json!({ "ts": ts, "cwd": cwd, "cmd": cmd });
if let Some(identity) = scope.identity() {
record["scope"] = serde_json::Value::String(
match scope {
HistoryScope::Wsl(_) => "wsl",
HistoryScope::Ssh(_) => "ssh",
HistoryScope::Local => unreachable!(),
}
.to_owned(),
);
record["target"] = serde_json::Value::String(identity.to_owned());
}
let path = history_path(scope.file_name());
if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(path) {
let _ = writeln!(file, "{record}");
}
}
/// Extract the `cmd` and optional `cwd` fields from one JSONL line, skipping
/// malformed lines.
fn parse_record(line: &str) -> Option<(String, Option<String>)> {
fn parse_record(category: &HistoryScope, line: &str) -> Option<(HistoryScope, String)> {
let value = serde_json::from_str::<serde_json::Value>(line).ok()?;
let cmd = value.get("cmd")?.as_str()?.to_owned();
let cwd = value.get("cwd").and_then(|v| v.as_str()).map(str::to_owned);
Some((cmd, cwd))
let cmd = value.get("cmd")?.as_str()?.trim();
if cmd.is_empty() {
return None;
}
let record_scope = value.get("scope").and_then(serde_json::Value::as_str);
let target = value.get("target").and_then(serde_json::Value::as_str).unwrap_or_default();
let scope = match category {
HistoryScope::Local if record_scope.is_none() => HistoryScope::Local,
HistoryScope::Wsl(_) if record_scope == Some("wsl") && !target.trim().is_empty() => {
HistoryScope::Wsl(target.to_owned())
},
HistoryScope::Ssh(_) if record_scope == Some("ssh") && !target.trim().is_empty() => {
HistoryScope::Ssh(target.to_owned())
},
_ => return None,
}
.normalized();
Some((scope, cmd.to_owned()))
}
pub(crate) fn record_category_file(line: &str) -> Option<&'static str> {
let value = serde_json::from_str::<serde_json::Value>(line).ok()?;
if value.get("cmd")?.as_str()?.trim().is_empty() {
return None;
}
match value.get("scope") {
None => Some(LOCAL_HISTORY_FILE),
Some(scope) => {
let target = value.get("target")?.as_str()?.trim();
if target.is_empty() {
return None;
}
match scope.as_str()? {
"wsl" => Some(WSL_HISTORY_FILE),
"ssh" => Some(SSH_HISTORY_FILE),
_ => None,
}
},
}
}
#[cfg(test)]
mod tests {
use super::*;
fn hist(cmds: &[&str]) -> NebulaHistory {
let mut h = NebulaHistory::default();
for c in cmds {
h.insert((*c).to_owned());
fn hist(scope: HistoryScope, cmds: &[&str]) -> NebulaHistory {
let mut history = NebulaHistory::default();
let pool = history.pools.entry(scope.normalized()).or_default();
for command in cmds {
pool.insert((*command).to_owned());
}
h
history
}
#[test]
fn hint_returns_remainder_of_newest_match() {
let h = hist(&["cargo build", "cargo test", "git status"]);
assert_eq!(h.hint("cargo "), Some("test"));
assert_eq!(h.hint("git "), Some("status"));
let scope = HistoryScope::Local;
let history = hist(scope.clone(), &["cargo build", "cargo test", "git status"]);
assert_eq!(history.hint(&scope, "cargo "), Some("test"));
assert_eq!(history.hint(&scope, "git "), Some("status"));
}
#[test]
fn no_hint_for_exact_or_missing() {
let h = hist(&["cargo build"]);
assert_eq!(h.hint("cargo build"), None); // exact, nothing to add
assert_eq!(h.hint("npm "), None); // no match
assert_eq!(h.hint(""), None); // empty prefix
fn exact_missing_and_empty_prefixes_have_no_hint() {
let scope = HistoryScope::Local;
let history = hist(scope.clone(), &["cargo build"]);
assert_eq!(history.hint(&scope, "cargo build"), None);
assert_eq!(history.hint(&scope, "npm "), None);
assert_eq!(history.hint(&scope, ""), None);
}
#[test]
fn dedup_moves_to_newest() {
// Re-running "ls" should make it win over the later "ll" for prefix "l".
let h = hist(&["ls", "ll", "ls"]);
assert_eq!(h.hint("l"), Some("s"));
fn rerunning_a_command_moves_it_to_the_newest_position() {
let scope = HistoryScope::Local;
let history = hist(scope.clone(), &["ls", "ll", "ls"]);
assert_eq!(history.hint(&scope, "l"), Some("s"));
}
#[test]
fn prefix_range_does_not_bleed() {
let h = hist(&["git push", "gitk"]);
// "git " must not match "gitk" (no space).
assert_eq!(h.hint("git "), Some("push"));
fn prefix_ranges_do_not_bleed_into_adjacent_commands() {
let scope = HistoryScope::Local;
let history = hist(scope.clone(), &["git push", "gitk"]);
assert_eq!(history.hint(&scope, "git "), Some("push"));
}
#[test]
fn hints_lists_matches_newest_first_and_caps_at_limit() {
let h = hist(&["git pull", "git push", "git status", "ls"]);
assert_eq!(h.hints("git ", 2), vec![("git status", "status"), ("git push", "push")]);
assert_eq!(h.hints("git ", 8).len(), 3);
assert!(h.hints("npm ", 8).is_empty());
assert!(h.hints("", 8).is_empty());
fn scopes_do_not_leak_commands() {
let local = HistoryScope::Local;
let wsl = HistoryScope::Wsl("Ubuntu".to_owned());
let ssh = HistoryScope::Ssh("root@example.com".to_owned());
let mut history = NebulaHistory::default();
history
.pools
.entry(local.clone().normalized())
.or_default()
.insert("dir windows".to_owned());
history
.pools
.entry(wsl.clone().normalized())
.or_default()
.insert("sudo apt update".to_owned());
history
.pools
.entry(ssh.clone().normalized())
.or_default()
.insert("systemctl restart api".to_owned());
assert_eq!(history.hint(&local, "dir "), Some("windows"));
assert_eq!(history.hint(&wsl, "sudo "), Some("apt update"));
assert_eq!(history.hint(&ssh, "systemctl "), Some("restart api"));
assert_eq!(history.hint(&local, "sudo "), None);
assert_eq!(history.hint(&wsl, "systemctl "), None);
}
#[test]
fn hints_skips_the_exact_match_like_hint_does() {
let h = hist(&["cargo build", "cargo b"]);
assert_eq!(h.hints("cargo b", 8), vec![("cargo build", "uild")]);
fn remote_identity_is_case_insensitive() {
let stored = HistoryScope::Wsl("ubuntu".to_owned());
let queried = HistoryScope::Wsl("Ubuntu".to_owned());
let history = hist(stored, &["apt update"]);
assert_eq!(history.hint(&queried, "apt "), Some("update"));
}
#[test]
fn ssh_normalization_preserves_case_sensitive_user_names() {
assert_eq!(
HistoryScope::Ssh("BuildUser@EXAMPLE.COM:22".to_owned()).normalized(),
HistoryScope::Ssh("BuildUser@example.com:22".to_owned())
);
assert_ne!(
HistoryScope::Ssh("BuildUser@example.com".to_owned()).normalized(),
HistoryScope::Ssh("builduser@example.com".to_owned()).normalized()
);
}
#[test]
fn hints_list_matches_newest_first_and_skip_exact() {
let scope = HistoryScope::Local;
let history = hist(scope.clone(), &["git pull", "git push", "git status", "git p"]);
assert_eq!(
history.hints(&scope, "git p", 2),
vec![("git push", "ush"), ("git pull", "ull")]
);
}
#[test]
fn sync_records_route_back_to_their_category_files() {
assert_eq!(record_category_file(r#"{"cmd":"dir"}"#), Some(LOCAL_HISTORY_FILE));
assert_eq!(
record_category_file(r#"{"scope":"wsl","target":"ubuntu","cmd":"ls"}"#),
Some(WSL_HISTORY_FILE)
);
assert_eq!(
record_category_file(r#"{"scope":"ssh","target":"me@box","cmd":"pwd"}"#),
Some(SSH_HISTORY_FILE)
);
assert_eq!(record_category_file(r#"{"scope":"ssh","cmd":"pwd"}"#), None);
assert_eq!(record_category_file(r#"{"scope":"future","cmd":"pwd"}"#), None);
assert_eq!(record_category_file(r#"{"cmd":" "}"#), None);
assert_eq!(record_category_file("not json"), None);
}
#[test]
fn category_files_reject_mismatched_record_scopes() {
let wsl_file = HistoryScope::Wsl(String::new());
let ssh_record = r#"{"scope":"ssh","target":"me@box","cmd":"pwd"}"#;
assert_eq!(parse_record(&wsl_file, ssh_record), None);
let local_record = r#"{"cmd":"dir"}"#;
assert_eq!(
parse_record(&HistoryScope::Local, local_record),
Some((HistoryScope::Local, "dir".to_owned()))
);
}
}
+22 -3
View File
@@ -23,21 +23,28 @@
//! crash. A small global throttle keeps a bell-happy background job from
//! flooding the Action Center.
#[cfg(feature = "legacy-shell")]
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
#[cfg(feature = "legacy-shell")]
use winit::event_loop::EventLoopProxy;
#[cfg(feature = "legacy-shell")]
use winit::window::WindowId;
#[cfg(feature = "legacy-shell")]
use crate::display::window::Window;
#[cfg(feature = "legacy-shell")]
use crate::event::{Event, EventType};
/// Event-loop proxy for toast click handlers. A click lands on a WinRT
/// threadpool thread, which can only talk to the app through user events.
/// Set once at boot, before the first toast can exist.
#[cfg(feature = "legacy-shell")]
static PROXY: OnceLock<EventLoopProxy<Event>> = OnceLock::new();
/// Install the proxy used by toast activation (click-to-focus).
#[cfg(feature = "legacy-shell")]
pub fn init_proxy(proxy: EventLoopProxy<Event>) {
let _ = PROXY.set(proxy);
}
@@ -121,6 +128,7 @@ const TOAST_THROTTLE: Duration = Duration::from_secs(3);
///
/// `pane` names the pane the event came from, when known: clicking the toast
/// then focuses the window AND surfaces that pane's tab (mac-style).
#[cfg(feature = "legacy-shell")]
pub fn deliver(window: &Window, notification: &Notification, pane: Option<u64>) {
// Taskbar flash / attention request (winit wraps FlashWindowEx). Always
// fires: it is idempotent, silent, and the shell coalesces repeats.
@@ -147,6 +155,7 @@ pub fn deliver(window: &Window, notification: &Notification, pane: Option<u64>)
}
/// Global toast rate limit. Returns true when this one should be dropped.
#[cfg(feature = "legacy-shell")]
fn throttled() -> bool {
static LAST: Mutex<Option<Instant>> = Mutex::new(None);
// A poisoned lock only means some thread panicked mid-check; the state is
@@ -164,18 +173,27 @@ fn throttled() -> bool {
/// Raise a native system toast. Best-effort: any failure is logged and
/// swallowed (the taskbar flash already fired, so the user is not left with
/// nothing). Runs on the toast worker thread, never on the event loop.
#[cfg(windows)]
#[cfg(all(windows, feature = "legacy-shell"))]
pub(crate) fn toast(title: &str, body: &str) {
toast_clickable(title, body, None);
}
#[cfg(all(windows, not(feature = "legacy-shell")))]
pub(crate) fn toast(title: &str, body: &str) {
toast_clickable(title, body);
}
/// [`toast`], optionally wired for click-to-focus: activating the banner (or
/// its Action Center entry) surfaces `window` and, when a pane is named, its
/// tab. Uses the in-process WinRT Activated handler — no COM server, no
/// protocol registration. The one trade-off: clicks after Nebula exited do
/// nothing, which is exactly right (there is nothing left to focus).
#[cfg(windows)]
fn toast_clickable(title: &str, body: &str, focus: Option<(WindowId, Option<u64>)>) {
fn toast_clickable(
title: &str,
body: &str,
#[cfg(feature = "legacy-shell")] focus: Option<(WindowId, Option<u64>)>,
) {
use tauri_winrt_notification::{IconCrop, Toast};
// Attribute the toast to the Nebula AUMID so it reads "Nebula" instead of
@@ -192,6 +210,7 @@ fn toast_clickable(title: &str, body: &str, focus: Option<(WindowId, Option<u64>
if let Some(icon) = win::icon_path() {
toast = toast.icon(&icon, IconCrop::Square, "Nebula");
}
#[cfg(feature = "legacy-shell")]
if let Some((window, pane)) = focus {
if let Some(proxy) = PROXY.get() {
let proxy = proxy.clone();
@@ -211,7 +230,7 @@ fn toast_clickable(title: &str, body: &str, focus: Option<(WindowId, Option<u64>
#[cfg(not(windows))]
pub(crate) fn toast(_title: &str, _body: &str) {}
#[cfg(not(windows))]
#[cfg(all(not(windows), feature = "legacy-shell"))]
fn toast_clickable(_title: &str, _body: &str, _focus: Option<(WindowId, Option<u64>)>) {}
/// `nebula notify-test` entrypoint: run the full toast pipeline synchronously
+89
View File
@@ -4,19 +4,108 @@
//! 当前需要的无窗口、无 GL、无 crossfont 部分。后续模型继续从旧目录迁出时,
//! 对外路径保持稳定,正式产品也不会重新依赖旧渲染器。
#[path = "../display/background_color_model.rs"]
mod background_color_model;
#[path = "../display/color.rs"]
pub mod color;
#[path = "../display/command_completion.rs"]
mod command_completion;
#[path = "../display/command_palette.rs"]
pub(crate) mod command_palette;
#[path = "../display/content.rs"]
pub(crate) mod content;
#[path = "../display/context_menu_model.rs"]
pub(crate) mod context_menu;
#[path = "../display/document_model.rs"]
mod document_model;
#[path = "../display/file_dialog.rs"]
pub(crate) mod file_dialog;
#[path = "../display/file_operations.rs"]
mod file_operations;
#[path = "../display/hint.rs"]
pub(crate) mod hint;
#[path = "../display/i18n.rs"]
mod i18n;
#[path = "../display/image_viewer.rs"]
pub mod image_viewer;
#[path = "../display/input_state.rs"]
mod input_state;
#[path = "../display/keymap.rs"]
pub(crate) mod keymap;
#[path = "../display/network_proxy_model.rs"]
mod network_proxy_model;
#[path = "../display/program_identity.rs"]
mod program_identity;
#[path = "../display/side_panel/mod.rs"]
pub(crate) mod side_panel;
#[path = "../display/size_info.rs"]
mod size_info;
#[path = "../display/ssh_connect.rs"]
pub(crate) mod ssh_connect;
#[path = "../display/ssh_ui.rs"]
mod ssh_ui;
#[path = "../display/state.rs"]
pub(crate) mod state;
#[path = "../display/suggest_engine.rs"]
pub(crate) mod suggest_engine;
#[path = "../display/terminal_color.rs"]
pub(crate) mod terminal_color;
#[path = "../display/terminal_math.rs"]
pub(crate) mod terminal_math;
#[path = "../display/text_input.rs"]
mod text_input;
#[path = "../display/text_path_model.rs"]
mod text_path_model;
#[path = "../display/ui/toast_kind.rs"]
mod toast_kind;
pub mod ui;
pub use i18n::{LanguagePreference, UiLanguage};
pub use program_identity::AiLogo;
pub use size_info::SizeInfo;
pub(crate) use ssh_ui::merge_ssh_hosts;
pub use ssh_ui::{
auth_sections, join_destination_port, join_destination_user, push_private_key,
split_destination_port, split_destination_user,
};
pub use state::{
AcceptKey, AiSessionIdentity, CompletionStyle, NebulaCompletionItem, NebulaCompletionKind,
NebulaConfirm, NebulaInlineImage, NebulaPaneState, NebulaShell, SplitDirection, SplitNav,
};
pub use suggest_engine::SuggestEnv;
pub use toast_kind::ToastKind;
pub use ui::theme::NebulaTheme;
pub mod markdown_view {
pub use super::document_model::viewable_file;
}
pub use background_color_model::BgPickerPart;
pub(crate) use background_color_model::{BACKGROUND_SWATCHES, hsv_to_rgb, rgb_to_hsv};
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(crate) use file_operations::send_to_recycle_bin;
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};
pub(crate) use network_proxy_model::{
MANUAL_PROXY_PROTOCOL_OPTIONS, ManualProxyProtocol, ProxyTestStatus, manual_proxy_parts,
manual_proxy_value,
};
pub(crate) use program_identity::{ai_logo_for_program, prepare_ai_logo_texture, program_icon};
pub(crate) use text_path_model::{fit_tail, strip_file_scheme, truncate_tab_label};
pub(crate) const NEBULA_UNFOCUSED_SPLIT_DIM: f32 = 0.30;
pub(crate) const UI_CORNER_RADIUS_LOGICAL: f32 = ui::tokens::radius::OVERLAY;
pub(crate) fn nebula_data_dir() -> std::path::PathBuf {
crate::platform::dirs::data_dir().to_path_buf()
}
pub(crate) use crate::logging::debug_log as nebula_debug_log;
+12
View File
@@ -2,7 +2,19 @@
#[path = "../display/ui/caret.rs"]
pub mod caret;
#[path = "../display/ui/geometry.rs"]
pub(crate) mod geometry;
#[path = "../display/ui/keycap_model.rs"]
pub mod keycap;
#[path = "../display/ui/os_icons.rs"]
pub mod os_icons;
#[path = "../display/ui/color_math.rs"]
pub mod surface;
#[path = "../display/ui/theme.rs"]
pub mod theme;
#[path = "../display/ui/text_field.rs"]
pub(crate) mod text_field;
#[path = "../display/ui/tokens.rs"]
pub mod tokens;
#[path = "../display/ui/scrollbar.rs"]
pub mod widgets;
+8 -1
View File
@@ -23,9 +23,11 @@ pub(crate) use command::{
capture_process_tree, capture_terminal_tail, validate_chat_message, validate_command_line,
validate_paste_text, validate_prompt,
};
#[cfg(feature = "legacy-shell")]
pub use server::dispatch_prompt;
use server::{Endpoint, endpoint_addr, read_endpoint};
pub use server::{
RuntimeServer, dispatch_prompt, try_open_default_tab_existing, try_open_directory_existing,
RuntimeServer, try_open_default_tab_existing, try_open_directory_existing,
try_open_window_existing,
};
@@ -43,6 +45,7 @@ use log::{info, warn};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
#[cfg(feature = "legacy-shell")]
use winit::event_loop::EventLoopProxy;
use nebula_terminal::event::EventListener;
@@ -53,6 +56,7 @@ use nebula_terminal::term::Term;
use crate::cli::{
ControlCommand as CliCommand, ControlOptions, ControlSplitDirection, ControlWaitState,
};
#[cfg(feature = "legacy-shell")]
use crate::event::{Event, EventType};
pub const PROTOCOL_NAME: &str = "nebula.runtime";
@@ -907,6 +911,7 @@ pub enum RuntimeCallback {
#[derive(Clone)]
enum EventSink {
#[cfg(feature = "legacy-shell")]
Winit(EventLoopProxy<Event>),
Callback(Arc<dyn Fn(RuntimeCallback) + Send + Sync>),
}
@@ -914,6 +919,7 @@ enum EventSink {
impl EventSink {
fn emit_attach(&self) {
match self {
#[cfg(feature = "legacy-shell")]
Self::Winit(proxy) => {
let _ = proxy.send_event(Event::new(EventType::NebulaAttach, None));
},
@@ -923,6 +929,7 @@ impl EventSink {
fn emit_control(&self, dispatch: Arc<RuntimeDispatch>) -> bool {
match self {
#[cfg(feature = "legacy-shell")]
Self::Winit(proxy) => {
proxy.send_event(Event::new(EventType::RuntimeControl(dispatch), None)).is_ok()
},
+2
View File
@@ -50,6 +50,7 @@ pub fn try_open_default_tab_existing() -> bool {
}
/// 后台任务把一行文本作为输入敲进某个 pane(不回车)。
#[cfg(feature = "legacy-shell")]
pub fn dispatch_prompt(proxy: &EventLoopProxy<Event>, pane_id: u64, text: String) {
let (dispatch, _receiver) = RuntimeDispatch::new(RuntimeCommand::Prompt {
window_id: None,
@@ -118,6 +119,7 @@ pub struct RuntimeServer {
}
impl RuntimeServer {
#[cfg(feature = "legacy-shell")]
pub fn spawn(proxy: EventLoopProxy<Event>, hub: RuntimeHub) -> Option<Self> {
Self::spawn_with_sink(EventSink::Winit(proxy), hub)
}
+14 -8
View File
@@ -506,7 +506,8 @@ pub fn wsl_unc_cwd(located: &WslCwd) -> Option<std::path::PathBuf> {
path.is_dir().then_some(path)
}
/// 让 WSL 来宾在每个提示符前上报 cwd(OSC 7)所需的环境变量;非 WSL 启动返回空。
/// 让 WSL 来宾在每个提示符前上报命令完成、cwd 与新提示符边界所需的环境变量;
/// 非 WSL 启动返回空。
///
/// # 为什么只能走环境变量
///
@@ -516,9 +517,10 @@ pub fn wsl_unc_cwd(located: &WslCwd) -> Option<std::path::PathBuf> {
/// bash 每画一次提示符都会执行 `$PROMPT_COMMAND`,而 `WSLENV` 会把点名的宿主
/// 变量原样送进来宾。
///
/// 少了这条,WSL tab 永远停在"等待终端上报工作目录"——目录树与 Git 视图都是
/// 靠 shell 上报的 cwd 驱动的(`TermEvent::CwdReport`),而 Debian/Ubuntu 的
/// 默认 `.bashrc` 只设 OSC 0 窗口标题,不发 OSC 7(实测 `PROMPT_COMMAND` 为空)。
/// OSC 133;D/A 与终端 shell 状态机同序:`D` 是命令完成的权威边沿,`A`
/// 标记接下来绘制的提示符;OSC 7 只负责 cwd,绝不再兼任 Agent 生命周期信号。
/// Debian/Ubuntu 的默认 `.bashrc` 只设 OSC 0 窗口标题,不发这些标记(实测
/// `PROMPT_COMMAND` 为空)。
///
/// 尽力而为、失败无害:来宾默认 shell 是 zsh/fish(不认 `PROMPT_COMMAND`),
/// 或用户自己在 rc 里直接赋值把它覆盖掉,都只是回到"不上报"的现状,不会影响
@@ -531,10 +533,10 @@ pub fn wsl_cwd_report_env(
if wsl_launch_distro(program, args).is_none() {
return Vec::new();
}
// OSC 7 = `ESC ] 7 ; file://<host><path> BEL`。host 会被解析端丢掉(取第一个
// `/` 之后的部分),给个兜底值就够。用 `$PWD` 而不是 `$(pwd)`:每个提示符
// 都要跑一次,不能带 fork 开销。
const REPORT: &str = r#"printf '\033]7;file://%s%s\007' "${HOSTNAME:-wsl}" "$PWD""#;
// 用 `$PWD` 而不是 `$(pwd)`,整个 PROMPT_COMMAND 只有变量赋值与一个
// builtin printf:每个提示符都执行也不 fork。初始提示符多发的 D 无害,
// Runtime submit barrier 会拒绝把它错配给尚未真正提交的新命令。
const REPORT: &str = r#"__nebula_status=$?; printf '\033]133;D;%s\007\033]7;file://%s%s\007\033]133;A\007' "$__nebula_status" "${HOSTNAME:-wsl}" "$PWD""#;
// 宿主侧可能已经有 WSLENV(别的工具设的),必须追加而不是覆盖。
let mut wslenv = current_wslenv
.map(str::to_owned)
@@ -808,6 +810,10 @@ mod tests {
additions.get("WSLENV").map(String::as_str),
Some("FRESH_REGISTRY_VALUE/u:PROMPT_COMMAND")
);
let prompt_command = additions.get("PROMPT_COMMAND").expect("WSL prompt integration");
assert!(prompt_command.contains("]133;D;%s"));
assert!(prompt_command.contains("]7;file://%s%s"));
assert!(prompt_command.contains("]133;A"));
}
#[test]
+81 -1
View File
@@ -7,6 +7,7 @@ use windows_sys::Win32::Storage::FileSystem::{
};
const PROFILE_VERSION: u32 = 1;
const USERNAME_HISTORY_CAP: usize = 12;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
@@ -59,11 +60,15 @@ pub struct SshProfiles {
version: u32,
#[serde(default)]
profiles: Vec<SshProfileAuth>,
/// 最近使用的 SSH 用户名,最新在前。它不含密码或主机,因此可以和
/// Profile 一起持久化;旧文件缺少字段时由 `serde(default)` 读成空列表。
#[serde(default, skip_serializing_if = "Vec::is_empty")]
usernames: Vec<String>,
}
impl Default for SshProfiles {
fn default() -> Self {
Self { version: PROFILE_VERSION, profiles: Vec::new() }
Self { version: PROFILE_VERSION, profiles: Vec::new(), usernames: Vec::new() }
}
}
@@ -79,6 +84,7 @@ impl SshProfiles {
for profile in &mut profiles.profiles {
deduplicate_key_paths(&mut profile.private_keys);
}
normalize_usernames(&mut profiles.usernames);
profiles.version = PROFILE_VERSION;
Ok(profiles)
}
@@ -135,6 +141,31 @@ impl SshProfiles {
.collect()
}
/// 可编辑用户名下拉的候选。显式历史保持 MRU 次序;旧版本没有历史字段,
/// 因而再从现有 `user@host` Profile 补齐,升级后第一次打开就有可选项。
pub fn usernames(&self) -> Vec<String> {
let mut usernames = self.usernames.clone();
for profile in &self.profiles {
if let Some(username) = destination_username(&profile.destination) {
push_unique_username(&mut usernames, username);
}
}
usernames.truncate(USERNAME_HISTORY_CAP);
usernames
}
/// 记录一次实际保存过的用户名。大小写不同可能代表不同远端账号,去重只做
/// 精确比较;重新使用会被提升到最前面。
pub fn remember_username(&mut self, username: &str) {
let username = username.trim();
if !valid_username_history_entry(username) {
return;
}
self.usernames.retain(|existing| existing != username);
self.usernames.insert(0, username.to_owned());
self.usernames.truncate(USERNAME_HISTORY_CAP);
}
/// 下一个可用的默认标签,形如「主机 6」。
///
/// 取现有默认标签里**编号的最大值 +1**,而不是条目总数:删掉中间几台之后
@@ -186,6 +217,34 @@ fn profile_version() -> u32 {
PROFILE_VERSION
}
fn destination_username(destination: &str) -> Option<&str> {
let address = destination.trim().strip_prefix("ssh://").unwrap_or(destination.trim());
let (username, host) = address.rsplit_once('@')?;
(valid_username_history_entry(username) && !host.is_empty()).then_some(username)
}
fn valid_username_history_entry(username: &str) -> bool {
!username.is_empty()
&& !username
.chars()
.any(|ch| ch.is_whitespace() || ch.is_control() || "@;&|<>\"'`".contains(ch))
}
fn push_unique_username(usernames: &mut Vec<String>, username: &str) {
if valid_username_history_entry(username) && !usernames.iter().any(|entry| entry == username) {
usernames.push(username.to_owned());
}
}
fn normalize_usernames(usernames: &mut Vec<String>) {
let mut normalized = Vec::with_capacity(usernames.len().min(USERNAME_HISTORY_CAP));
for username in std::mem::take(usernames) {
push_unique_username(&mut normalized, username.trim());
}
normalized.truncate(USERNAME_HISTORY_CAP);
*usernames = normalized;
}
fn deduplicate_key_paths(paths: &mut Vec<PathBuf>) {
let mut seen = Vec::<String>::new();
paths.retain(|path| {
@@ -242,6 +301,7 @@ mod tests {
assert_eq!(profile.label.as_deref(), Some("生产库"));
assert_eq!(profile.icon, None, "没写过图标 = 自动识别");
assert!(profiles.icons().is_empty());
assert_eq!(profiles.usernames(), vec!["root"], "旧 Profile 也应补出用户名候选");
}
/// 「自动识别」不落盘。存一个 `"auto"` 进去也能工作,但那样配置文件里
@@ -394,4 +454,24 @@ mod tests {
let loaded = SshProfiles::load(&path).unwrap();
assert_eq!(loaded.for_destination("dev@example.com").auth, SshAuthMode::Auto);
}
#[test]
fn username_history_is_recent_first_bounded_and_backward_compatible() {
let mut profiles = SshProfiles::default();
profiles.remember_username("root");
profiles.remember_username("deploy");
profiles.remember_username("root");
profiles.remember_username("bad user");
assert_eq!(profiles.usernames(), vec!["root", "deploy"]);
let json = serde_json::to_string(&profiles).expect("serialize");
let loaded: SshProfiles = serde_json::from_str(&json).expect("round trip");
assert_eq!(loaded.usernames(), vec!["root", "deploy"]);
let legacy: SshProfiles = serde_json::from_str(
r#"{"version":1,"profiles":[{"destination":"admin@example.com"}]}"#,
)
.expect("旧文件缺少 usernames 时仍应可读");
assert_eq!(legacy.usernames(), vec!["admin"]);
}
}
+237 -89
View File
@@ -24,6 +24,7 @@ use russh::keys::ssh_key;
use russh::keys::{HashAlg, PrivateKeyWithHashAlg};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
#[cfg(feature = "legacy-shell")]
use crate::event::EventProxy;
use crate::proxy_test::{ProxyTestFailure, ProxyTestOutcome, ProxyTestResult, ProxyTestRoute};
@@ -31,6 +32,12 @@ type SessionError = Box<dyn std::error::Error + Send + Sync>;
type ClientSession = client::Handle<ClientHandler>;
type SharedSession = Arc<tokio::sync::Mutex<ClientSession>>;
struct AcquiredSession {
key: String,
session: SharedSession,
reused: bool,
}
/// 直连 SSH 会话的宿主回调抽象:终端事件泵([`EventListener`])+ 连接
/// 阶段上报。旧壳 `EventProxy`(winit 事件循环)与 GPUI 会话代理都实现
/// 它,[`spawn_session`] 因此对 UI 壳无感——同一条 russh 业务路径服务
@@ -46,6 +53,7 @@ pub trait SshEventHost:
}
}
#[cfg(feature = "legacy-shell")]
impl SshEventHost for EventProxy {
fn ssh_stage(&self, stage: SshStage) {
// [`EventProxy`] 自带 `tab_id`,后台 runtime 不需要知道 pane id
@@ -54,6 +62,12 @@ impl SshEventHost for EventProxy {
}
}
#[derive(Clone)]
struct NoopSshEventHost;
impl nebula_terminal::event::EventListener for NoopSshEventHost {}
impl SshEventHost for NoopSshEventHost {}
/// 直连 SSH 会话的连接阶段。
///
/// 这些取值不是估算的进度百分比——因为我们用 russh 自己实现客户端,而不是
@@ -94,6 +108,14 @@ enum AuthMethod {
PromptPassword,
}
#[derive(Debug, Default)]
struct KeyboardInteractiveAttempt {
success: bool,
prompted: bool,
prompt_required: bool,
used_password: bool,
}
fn authentication_plan(
mode: crate::ssh_profiles::SshAuthMode,
explicit_keys: &[PathBuf],
@@ -131,7 +153,14 @@ fn authentication_plan(
methods
},
SshAuthMode::Password => {
vec![AuthMethod::StoredPassword, AuthMethod::PromptPassword]
// 一些 sshd/PAM 只公布 keyboard-interactive,即使提示本质上仍是
// 登录密码。已输入的密码必须先复用到这个单提示流程,不能直接再
// 弹一次系统凭据窗口。
vec![
AuthMethod::StoredPassword,
AuthMethod::KeyboardInteractive,
AuthMethod::PromptPassword,
]
},
SshAuthMode::PublicKey => key_methods(),
SshAuthMode::KeyboardInteractive => vec![AuthMethod::KeyboardInteractive],
@@ -470,32 +499,37 @@ async fn run_session_async<H: SshEventHost>(
receiver: Receiver<Msg>,
ready: Arc<AtomicBool>,
) -> Result<(), SessionError> {
let session = authenticated_session(&destination, &profile, Some(&event_proxy)).await?;
let mut acquired =
authenticated_session_at(&destination, &profile, Some(&event_proxy), 0).await?;
report_stage(Some(&event_proxy), SshStage::OpenShell);
let mut channel = {
let session = session.lock().await;
session.channel_open_session().await?
let shell = open_shell_channel(&acquired.session, initial_size).await;
let (mut channel, hook_token) = match shell {
Ok(shell) => shell,
Err(first_error) if acquired.reused => {
// `Handle::is_closed` 只反映已完成的关闭;TCP reset 与 channel-open
// 失败之间存在窗口。复用对象打不开首个 channel 时精确淘汰并只重试
// 一次,新连接失败则把真实错误交给连接卡片。
evict_pooled_session(&acquired.key, &acquired.session).await;
info!("复用 SSH transport 打开 channel 失败,重建连接: {first_error}");
acquired =
authenticated_session_at(&destination, &profile, Some(&event_proxy), 0).await?;
report_stage(Some(&event_proxy), SshStage::OpenShell);
match open_shell_channel(&acquired.session, initial_size).await {
Ok(shell) => shell,
Err(retry_error) => {
evict_pooled_session(&acquired.key, &acquired.session).await;
return Err(format!(
"SSH channel 打开失败(复用连接错误: {first_error};重建后错误: {retry_error})"
)
.into());
},
}
},
Err(error) => {
evict_pooled_session(&acquired.key, &acquired.session).await;
return Err(error);
},
};
channel
.request_pty(
true,
"xterm-256color",
u32::from(initial_size.num_cols),
u32::from(initial_size.num_lines),
u32::from(initial_size.cell_width) * u32::from(initial_size.num_cols),
u32::from(initial_size.cell_height) * u32::from(initial_size.num_lines),
&[],
)
.await?;
let hook_token = remote_hook_token()?;
channel.set_env(false, "NEBULA_REMOTE_HOOK_TOKEN", hook_token.clone()).await?;
// 三层护栏的第二层(自查层):远端 pane 明确自我声明。愿意自查的被调方
// ——Recipe、skill、包装脚本——可以据此拒绝把本地内容往这条通道里送。这只是
// 护栏,不是强制:环境变量能被清掉。强制层在
// `TerminalView::ensure_local_context_allowed`,判据是 pane 自己的身份。
// `set_env` 取决于远端 sshd 的 AcceptEnv,失败不影响会话本身。
let _ = channel.set_env(false, "NEBULA_PANE_REMOTE", "1").await;
channel.request_shell(true).await?;
// Shell 已就绪:连接卡片到此让位给真实终端,持续重绘随之停止。此后再
// 出错就是会话中途断开,不该复活卡片。
ready.store(true, Ordering::Relaxed);
@@ -513,43 +547,87 @@ async fn run_session_async<H: SshEventHost>(
let mut stream = StreamProcessor::default();
stream.resize(initial_size);
stream.set_remote_hook_token(hook_token);
loop {
let sync_deadline = stream.next_sync_timeout();
tokio::select! {
message = input_rx.recv() => match message {
Some(Msg::Input(bytes)) => channel.data(bytes.as_ref()).await?,
Some(Msg::Resize(size)) => {
stream.resize(size);
channel.window_change(
u32::from(size.num_cols),
u32::from(size.num_lines),
u32::from(size.cell_width) * u32::from(size.num_cols),
u32::from(size.cell_height) * u32::from(size.num_lines),
).await?;
let result: Result<(), SessionError> = async {
loop {
let sync_deadline = stream.next_sync_timeout();
tokio::select! {
message = input_rx.recv() => match message {
Some(Msg::Input(bytes)) => channel.data(bytes.as_ref()).await?,
Some(Msg::Resize(size)) => {
stream.resize(size);
channel.window_change(
u32::from(size.num_cols),
u32::from(size.num_lines),
u32::from(size.cell_width) * u32::from(size.num_cols),
u32::from(size.cell_height) * u32::from(size.num_lines),
).await?;
},
// 只改本地几何:远端保持旧尺寸,等去抖后的 `Resize` 才发
// `window_change`。交互式拖拽每帧都会来一条。
Some(Msg::ResizeGrid(size)) => stream.resize(size),
Some(Msg::Shutdown) | None => {
let _ = channel.eof().await;
break;
},
},
// 只改本地几何:远端保持旧尺寸,等去抖后的 `Resize` 才发
// `window_change`。交互式拖拽每帧都会来一条。
Some(Msg::ResizeGrid(size)) => stream.resize(size),
Some(Msg::Shutdown) | None => {
let _ = channel.eof().await;
break;
message = channel.wait() => match message {
Some(ChannelMsg::Data { data }) | Some(ChannelMsg::ExtendedData { data, .. }) => {
stream.feed(&mut *terminal.lock(), &event_proxy, data.as_ref());
event_proxy.send_event(TerminalEvent::Wakeup);
},
Some(ChannelMsg::ExitStatus { .. }) | Some(ChannelMsg::Eof) | None => break,
_ => {},
},
},
message = channel.wait() => match message {
Some(ChannelMsg::Data { data }) | Some(ChannelMsg::ExtendedData { data, .. }) => {
stream.feed(&mut *terminal.lock(), &event_proxy, data.as_ref());
_ = wait_for_sync(sync_deadline), if sync_deadline.is_some() => {
stream.stop_sync(&mut *terminal.lock());
event_proxy.send_event(TerminalEvent::Wakeup);
},
Some(ChannelMsg::ExitStatus { .. }) | Some(ChannelMsg::Eof) | None => break,
_ => {},
},
_ = wait_for_sync(sync_deadline), if sync_deadline.is_some() => {
stream.stop_sync(&mut *terminal.lock());
event_proxy.send_event(TerminalEvent::Wakeup);
},
}
}
Ok(())
}
Ok(())
.await;
let transport_closed = acquired.session.lock().await.is_closed();
if result.is_err() || transport_closed {
evict_pooled_session(&acquired.key, &acquired.session).await;
}
result
}
async fn open_shell_channel(
session: &SharedSession,
initial_size: WindowSize,
) -> Result<(russh::Channel<client::Msg>, String), SessionError> {
let channel = {
let session = session.lock().await;
session.channel_open_session().await?
};
channel
.request_pty(
true,
"xterm-256color",
u32::from(initial_size.num_cols),
u32::from(initial_size.num_lines),
u32::from(initial_size.cell_width) * u32::from(initial_size.num_cols),
u32::from(initial_size.cell_height) * u32::from(initial_size.num_lines),
&[],
)
.await?;
let hook_token = remote_hook_token()?;
channel.set_env(false, "NEBULA_REMOTE_HOOK_TOKEN", hook_token.clone()).await?;
// 远端 pane 的自声明是安全护栏而非连接前提;AcceptEnv 未放行时忽略。
let _ = channel.set_env(false, "NEBULA_PANE_REMOTE", "1").await;
channel.request_shell(true).await?;
Ok((channel, hook_token))
}
async fn evict_pooled_session(key: &str, session: &SharedSession) -> bool {
let mut pool = connection_pool().lock().await;
let matches = pool.get(key).is_some_and(|pooled| Arc::ptr_eq(pooled, session));
if matches {
pool.remove(key);
}
matches
}
async fn wait_for_sync(deadline: Option<std::time::Instant>) {
@@ -563,7 +641,7 @@ async fn authenticated_session<H: SshEventHost>(
profile: &crate::ssh_profiles::SshProfileAuth,
progress: Option<&H>,
) -> Result<SharedSession, SessionError> {
authenticated_session_at(destination, profile, progress, 0).await
Ok(authenticated_session_at(destination, profile, progress, 0).await?.session)
}
/// [`authenticated_session`] 的带深度版本。`depth` 是当前跳板层级:目标连接
@@ -574,7 +652,7 @@ async fn authenticated_session_at<H: SshEventHost>(
profile: &crate::ssh_profiles::SshProfileAuth,
progress: Option<&H>,
depth: u8,
) -> Result<SharedSession, SessionError> {
) -> Result<AcquiredSession, SessionError> {
// 代理决策先于连接池查找:pool key 必须带上代理身份,否则改完代理设置
// 还在复用旧代理建立的连接,表现为「改了设置没生效」。配置写错在这里
// 直接报错——静默直连会把配置问题伪装成网络问题。
@@ -592,7 +670,7 @@ async fn authenticated_session_at<H: SshEventHost>(
info!("复用已认证 SSH 连接: {key}");
// 复用不上报 Connect/Authenticate:这条路径是瞬时的,交给
// 350ms 门槛把卡片整个吃掉,用户看到的就是直接出 prompt。
return Ok(existing);
return Ok(AcquiredSession { key, session: existing, reused: true });
}
connection_pool().lock().await.remove(&key);
}
@@ -613,11 +691,11 @@ async fn authenticated_session_at<H: SshEventHost>(
let mut pool = connection_pool().lock().await;
if let Some(existing) = pool.get(&key).cloned() {
if !existing.lock().await.is_closed() {
return Ok(existing);
return Ok(AcquiredSession { key, session: existing, reused: true });
}
}
pool.insert(key, session.clone());
Ok(session)
pool.insert(key.clone(), session.clone());
Ok(AcquiredSession { key, session, reused: false })
}
/// SSH/SFTP 的唯一 Nebula 代理决策入口。主机编辑器不再维护第二套代理;
@@ -637,7 +715,7 @@ fn authenticated_session_boxed<'a, H: SshEventHost>(
progress: Option<&'a H>,
depth: u8,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = Result<SharedSession, SessionError>> + Send + 'a>,
Box<dyn std::future::Future<Output = Result<AcquiredSession, SessionError>> + Send + 'a>,
> {
Box::pin(authenticated_session_at(destination, profile, progress, depth))
}
@@ -692,11 +770,12 @@ async fn open_transport(
let jump_session = authenticated_session_boxed(
&jump_destination,
&jump_profile,
None::<&EventProxy>,
None::<&NoopSshEventHost>,
depth + 1,
)
.await
.map_err(|err| format!("连接跳板 {spec} 失败: {err}"))?;
.map_err(|err| format!("连接跳板 {spec} 失败: {err}"))?
.session;
let channel = {
let session = jump_session.lock().await;
session
@@ -753,6 +832,7 @@ async fn authenticate(
let mut reusable_password = None;
let mut loaded_stored_password = false;
let mut stored_password_was_present = false;
let mut interactive_prompt_was_shown = false;
let mut local_key_errors = Vec::new();
for method in plan {
match method {
@@ -778,14 +858,26 @@ async fn authenticate(
}
},
AuthMethod::KeyboardInteractive => {
if try_keyboard_interactive(session, destination, reusable_password.as_deref())
.await?
{
let attempt = try_keyboard_interactive(
session,
destination,
reusable_password.as_deref(),
true,
)
.await?;
interactive_prompt_was_shown |= attempt.prompted;
if attempt.success {
clear_secret(&mut reusable_password);
return Ok(());
}
},
AuthMethod::PromptPassword => {
// 同一轮里已经用过保存密码,或 keyboard-interactive 已经向用户
// 问过一次,就直接报告失败。再次弹相同密码框只会制造重复认证
// 尝试,严重时还会触发服务端 MaxAuthTries/限流。
if stored_password_was_present || interactive_prompt_was_shown {
continue;
}
if let Some((mut password, save)) =
prompt_secret(destination.original.clone(), None, true).await?
{
@@ -843,7 +935,7 @@ pub(crate) async fn exec_capture(
.await
.map_err(|err| format!("SSH 地址解析任务失败: {err}"))??;
let session = authenticated_session(&destination, &profile, None::<&EventProxy>).await?;
let session = authenticated_session(&destination, &profile, None::<&NoopSshEventHost>).await?;
let mut channel = {
let session = session.lock().await;
session.channel_open_session().await?
@@ -910,7 +1002,7 @@ pub(crate) async fn open_sftp(
}
// SFTP 面板自己有加载态,不参与终端 pane 的连接卡片。
let session = authenticated_session(&destination, &profile, None::<&EventProxy>).await?;
let session = authenticated_session(&destination, &profile, None::<&NoopSshEventHost>).await?;
let channel = {
let session = session.lock().await;
session.channel_open_session().await?
@@ -956,7 +1048,8 @@ pub struct SshTestResult {
pub elapsed_ms: u64,
}
/// 执行一次无人值守的草稿测试。绝不弹 AskPass:交互式方法在测试中一律跳过
/// 执行一次无人值守的草稿测试。绝不弹 AskPass:草稿/已存密码可以回答明确的
/// keyboard-interactive 密码问题,OTP 等真正需要用户参与的问题则留给正式连接
/// (见 [`test_authenticate`])。
async fn run_test(request: SshTestRequest) -> SshTestResult {
let started = std::time::Instant::now();
@@ -1011,6 +1104,7 @@ pub fn start_test(
/// 旧 winit 壳的事件投递适配器。测试实现本身在 [`run_test`],避免两个壳
/// 各自维护解析、代理和认证的近似副本。
#[cfg(feature = "legacy-shell")]
pub fn spawn_test(
request: SshTestRequest,
proxy: winit::event_loop::EventLoopProxy<crate::event::Event>,
@@ -1076,6 +1170,7 @@ pub fn start_proxy_test(
/// 使用与 SSH 新连接相同的全局配置解析和代理握手建立字节流,再请求一个
/// 真实 HTTP 页面。只探测代理端口并不能证明代理有出网能力,所以这里必须
/// 收到目标站点的 HTTP 状态行才算成功。
#[cfg(feature = "legacy-shell")]
pub fn spawn_proxy_test(
request_id: u64,
proxy: winit::event_loop::EventLoopProxy<crate::event::Event>,
@@ -1185,14 +1280,19 @@ async fn proxy_test_stream(
.await
.map_err(|error| ProxyTestFailure::JumpTask(error.to_string()))??;
let jump =
authenticated_session_at::<EventProxy>(&jump_destination, &jump_profile, None, 1)
authenticated_session_at::<NoopSshEventHost>(
&jump_destination,
&jump_profile,
None,
1,
)
.await
.map_err(|error| ProxyTestFailure::JumpConnect {
target: spec.clone(),
error: error.to_string(),
})?;
let channel = {
let session = jump.lock().await;
let session = jump.session.lock().await;
session
.channel_open_direct_tcpip(
NETWORK_TEST_HOST,
@@ -1233,8 +1333,9 @@ async fn test_connect(
test_authenticate(&mut session, destination, request).await
}
/// 无人值守版认证:none → 草稿密码 → 密钥/已存密码计划。keyboard-interactive
/// 与「连接时询问」在这里跳过——测试不能弹框,也不能把交互失败误报成配置错。
/// 无人值守版认证:none → 草稿密码 → 密钥/已存密码计划。明确的
/// keyboard-interactive 密码问题可以复用已有密码;OTP 与「连接时询问」在这里
/// 跳过——测试不能弹框,也不能把真实的二次验证误报成配置错误。
async fn test_authenticate(
session: &mut ClientSession,
destination: &SshDestination,
@@ -1256,6 +1357,7 @@ async fn test_authenticate(
let mut stored_password = None;
let mut loaded_stored_password = false;
let mut local_key_errors = Vec::new();
let mut credential_was_attempted = has_draft_password;
for method in plan {
match method {
AuthMethod::PrivateKey(path) => {
@@ -1281,14 +1383,33 @@ async fn test_authenticate(
loaded_stored_password = true;
}
if let Some(password) = stored_password.as_deref() {
credential_was_attempted = true;
if authenticate_password(session, &destination.user, password).await? {
clear_secret(&mut stored_password);
return Ok(());
}
}
},
AuthMethod::KeyboardInteractive | AuthMethod::PromptPassword => {
interactive_skipped = true;
AuthMethod::KeyboardInteractive => {
let password = request
.password
.as_deref()
.filter(|password| !password.is_empty())
.map(str::as_bytes)
.or_else(|| stored_password.as_deref());
let attempt =
try_keyboard_interactive(session, destination, password, false).await?;
credential_was_attempted |= attempt.used_password;
interactive_skipped |= attempt.prompt_required;
if attempt.success {
clear_secret(&mut stored_password);
return Ok(());
}
},
AuthMethod::PromptPassword => {
// 已经拿草稿/存储密码试过时,继续弹框不会让这次“无人值守测试”
// 更可信;直接报告认证失败。完全没有凭据时才说明正式连接需要询问。
interactive_skipped |= !credential_was_attempted;
},
}
}
@@ -1456,29 +1577,37 @@ async fn try_keyboard_interactive(
session: &mut ClientSession,
destination: &SshDestination,
password: Option<&[u8]>,
) -> Result<bool, SessionError> {
allow_prompt: bool,
) -> Result<KeyboardInteractiveAttempt, SessionError> {
let mut attempt = KeyboardInteractiveAttempt::default();
let mut state =
session.authenticate_keyboard_interactive_start(&destination.user, None::<String>).await?;
for _ in 0..8 {
match state {
KeyboardInteractiveAuthResponse::Success => return Ok(true),
KeyboardInteractiveAuthResponse::Failure { .. } => return Ok(false),
KeyboardInteractiveAuthResponse::Success => {
attempt.success = true;
return Ok(attempt);
},
KeyboardInteractiveAuthResponse::Failure { .. } => return Ok(attempt),
KeyboardInteractiveAuthResponse::InfoRequest { name, instructions, prompts } => {
let mut responses = Vec::with_capacity(prompts.len());
for prompt in prompts {
if !prompt.echo
&& prompt.prompt.to_ascii_lowercase().contains("password")
&& password.is_some()
{
if !prompt.echo && is_password_prompt(&prompt.prompt) && password.is_some() {
attempt.used_password = true;
responses.push(String::from_utf8_lossy(password.unwrap()).into_owned());
continue;
}
if !allow_prompt {
attempt.prompt_required = true;
return Ok(attempt);
}
let label = format!(
"{} - {} {} {}",
destination.original, name, instructions, prompt.prompt
);
attempt.prompted = true;
let Some((mut response, _)) = prompt_secret(label, None, false).await? else {
return Ok(false);
return Ok(attempt);
};
responses.push(String::from_utf8_lossy(&response).into_owned());
response.fill(0);
@@ -1487,7 +1616,12 @@ async fn try_keyboard_interactive(
},
}
}
Ok(false)
Ok(attempt)
}
fn is_password_prompt(prompt: &str) -> bool {
let lower = prompt.to_lowercase();
["password", "passphrase", "密码", "口令"].iter().any(|marker| lower.contains(marker))
}
async fn prompt_secret(
@@ -1582,7 +1716,7 @@ fn wide(value: &str) -> Vec<u16> {
#[cfg(test)]
mod tests {
use super::{
AuthMethod, SshDestination, authentication_plan, parse_resolved_config,
AuthMethod, SshDestination, authentication_plan, is_password_prompt, parse_resolved_config,
resolve_network_proxy, ssh_config_probe_target,
};
use crate::ssh_profiles::SshAuthMode;
@@ -1693,17 +1827,31 @@ mod tests {
}
#[test]
fn password_mode_never_falls_back_to_other_methods() {
fn password_mode_supports_pam_without_falling_back_to_keys() {
assert_eq!(
authentication_plan(
SshAuthMode::Password,
&[PathBuf::from(r"C:\Keys\ignored")],
&[PathBuf::from(r"C:\Keys\ignored-config")],
),
vec![AuthMethod::StoredPassword, AuthMethod::PromptPassword]
vec![
AuthMethod::StoredPassword,
AuthMethod::KeyboardInteractive,
AuthMethod::PromptPassword,
]
);
}
#[test]
fn keyboard_interactive_reuses_password_only_for_password_prompts() {
for prompt in ["Password:", "Enter passphrase:", "密码:", "请输入口令:"] {
assert!(is_password_prompt(prompt), "应识别密码问题: {prompt}");
}
for prompt in ["Verification code:", "OTP:", "Token:", "Duo choice:"] {
assert!(!is_password_prompt(prompt), "二次验证不得自动填登录密码: {prompt}");
}
}
#[test]
fn public_key_mode_uses_only_key_sources() {
assert_eq!(
+32 -9
View File
@@ -514,24 +514,47 @@ fn settings_file_path() -> PathBuf {
crate::display::nebula_data_dir().join("nebula_settings.txt")
}
fn history_file_path() -> PathBuf {
crate::display::nebula_data_dir().join("nebula_history.jsonl")
fn history_file_path(file_name: &str) -> PathBuf {
crate::display::nebula_data_dir().join(file_name)
}
/// 本地历史尾部(最近 [`HISTORY_SYNC_CAP`] 行)。
fn history_tail() -> Vec<String> {
let text = std::fs::read_to_string(history_file_path()).unwrap_or_default();
let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
let mut lines = Vec::new();
for file_name in crate::nebula_history::history_file_names() {
let text = std::fs::read_to_string(history_file_path(file_name)).unwrap_or_default();
lines.extend(
text.lines()
.filter(|line| crate::nebula_history::record_category_file(line).is_some())
.map(str::to_owned),
);
}
lines.sort_by_key(|line| {
serde_json::from_str::<serde_json::Value>(line)
.ok()
.and_then(|value| value["ts"].as_u64())
.unwrap_or(0)
});
let start = lines.len().saturating_sub(HISTORY_SYNC_CAP);
lines[start..].iter().map(|l| (*l).to_owned()).collect()
lines.drain(0..start);
lines
}
fn write_history(lines: &[String]) -> Result<(), String> {
let mut text = lines.join("\n");
if !text.is_empty() {
text.push('\n');
let mut categories: HashMap<&'static str, Vec<&str>> = HashMap::new();
for line in lines {
let Some(file_name) = crate::nebula_history::record_category_file(line) else { continue };
categories.entry(file_name).or_default().push(line);
}
std::fs::write(history_file_path(), text).map_err(|err| format!("写入历史失败:{err}"))
for file_name in crate::nebula_history::history_file_names() {
let mut text = categories.remove(file_name).unwrap_or_default().join("\n");
if !text.is_empty() {
text.push('\n');
}
std::fs::write(history_file_path(file_name), text)
.map_err(|err| format!("写入历史失败:{err}"))?;
}
Ok(())
}
// ---- 网络 ----
+21 -12
View File
@@ -41,12 +41,14 @@ pub struct TrayAgent {
pub needs_attention: bool,
}
#[cfg(all(windows, feature = "legacy-shell"))]
pub use win::init;
#[cfg(all(windows, feature = "gpui-shell"))]
pub use win::init_gpui;
#[cfg(windows)]
pub use win::{init, set_enabled, shutdown, update};
pub use win::{set_enabled, shutdown, update};
#[cfg(not(windows))]
#[cfg(all(not(windows), feature = "legacy-shell"))]
pub fn init(_proxy: winit::event_loop::EventLoopProxy<crate::event::Event>) {}
#[cfg(not(windows))]
pub fn init_gpui(_on_command: impl Fn(GpuiTrayCommand) + Send + Sync + 'static) {}
@@ -63,9 +65,11 @@ mod win {
use std::sync::atomic::{AtomicBool, AtomicIsize, Ordering};
use std::sync::{Mutex, OnceLock};
#[cfg(feature = "legacy-shell")]
use winit::event_loop::EventLoopProxy;
use super::TrayAgent;
#[cfg(feature = "legacy-shell")]
use crate::event::{Event, EventType};
/// 与 notify.rs 的 toast 图标同一份官方 logo,托盘不会出现第二个品牌形态。
@@ -93,6 +97,7 @@ mod win {
enabled: bool,
}
#[cfg(feature = "legacy-shell")]
static PROXY: OnceLock<EventLoopProxy<Event>> = OnceLock::new();
/// GPUI 主窗没有 winit `EventLoopProxy`:点击托盘时走命令回调。
static GPUI_COMMAND: OnceLock<Box<dyn Fn(super::GpuiTrayCommand) + Send + Sync>> =
@@ -108,6 +113,7 @@ mod win {
}
/// 装好事件代理。真正的线程按需(首次启用)才起。
#[cfg(feature = "legacy-shell")]
pub fn init(proxy: EventLoopProxy<Event>) {
let _ = PROXY.set(proxy);
}
@@ -303,16 +309,19 @@ mod win {
command(super::GpuiTrayCommand::Focus(agent.as_ref().map(|agent| agent.pane)));
return;
}
let Some(proxy) = PROXY.get() else { return };
let event = match agent {
Some(agent) => {
Event::new(EventType::FocusWindow { pane: Some(agent.pane) }, agent.window)
},
// 没有 agent:把第一扇窗捞到前台(processor 对 None 窗口 id 的
// FocusWindow 就是这个语义)。
None => Event::new(EventType::FocusWindow { pane: None }, None),
};
let _ = proxy.send_event(event);
#[cfg(feature = "legacy-shell")]
{
let Some(proxy) = PROXY.get() else { return };
let event = match agent {
Some(agent) => {
Event::new(EventType::FocusWindow { pane: Some(agent.pane) }, agent.window)
},
// 没有 agent:把第一扇窗捞到前台(processor 对 None 窗口 id 的
// FocusWindow 就是这个语义)。
None => Event::new(EventType::FocusWindow { pane: None }, None),
};
let _ = proxy.send_event(event);
}
}
fn show_menu(hwnd: windows_sys::Win32::Foundation::HWND) {
+4
View File
@@ -11,9 +11,12 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
#[cfg(feature = "legacy-shell")]
use winit::event_loop::EventLoopProxy;
#[cfg(feature = "legacy-shell")]
use crate::event::{Event, EventType};
#[cfg(feature = "legacy-shell")]
use crate::message_bar::{Message, MessageType};
const RELEASES_API: &str = "https://api.github.com/repos/Kuddev/nebula/releases/latest";
@@ -113,6 +116,7 @@ struct GitHubReleaseAsset {
/// Kick off the once-per-process background check. The result (if any)
/// arrives as a regular [`EventType::Message`] banner, which the message bar
/// already knows how to display, deduplicate and dismiss.
#[cfg(feature = "legacy-shell")]
pub fn spawn_once(proxy: EventLoopProxy<Event>) {
static STARTED: AtomicBool = AtomicBool::new(false);
if STARTED.swap(true, Ordering::SeqCst) {
+45 -2
View File
@@ -2425,6 +2425,7 @@ impl WindowContext {
state.agent_runtime_submit_pending = false;
state.ai_session = None;
state.running_program = None;
state.pending_command_prompt = None;
state.awaiting_input = false;
state.needs_attention = false;
},
@@ -2449,18 +2450,60 @@ impl WindowContext {
self.runtime_flush_pending_submit(Some(pane_id));
}
for pane in &mut self.panes {
let screen = {
let (prompt_restored, screen) = {
let term = pane.terminal.lock();
let lines = term.screen_lines();
if lines == 0 || term.columns() == 0 {
continue;
}
let prompt_restored =
pane.nebula_state.pending_command_prompt.as_deref().is_some_and(|expected| {
crate::display::nebula_shell_prompt_restored_from_raw_grid(
&term,
expected,
&pane.nebula_state.suggest_env,
)
});
let take = lines.min(24);
let start = Point::new(Line((lines - take) as i32), Column(0));
let end =
Point::new(Line(lines as i32 - 1), Column(term.columns().saturating_sub(1)));
term.bounds_to_string(start, end)
(prompt_restored, term.bounds_to_string(start, end))
};
if prompt_restored
&& pane
.nebula_state
.running_program
.as_deref()
.and_then(crate::ai_agents::AgentKind::parse)
.is_some()
{
log::debug!(
"agent lifecycle: submitted shell prompt restored pane={} program={:?}",
pane.id,
pane.nebula_state.running_program
);
let state = &mut pane.nebula_state;
if let Some(run) = state.active_run.take() {
state.last_run =
Some(crate::runtime_api::RuntimeRunOutcome::command_done(run, None));
}
state.running_program = None;
state.ai_session = None;
state.agent_status = crate::ai_agents::AgentStatus::Unknown;
state.agent_status_source = crate::ai_agents::AgentStatusSource::Unknown;
state.agent_status_rule = None;
state.agent_hook_seen = false;
state.idle_screen_streak = 0;
state.agent_runtime_submit_pending = false;
state.runtime_submit_barrier = None;
state.command_started = None;
state.pending_command_prompt = None;
state.awaiting_input = false;
state.finished_unseen = false;
state.needs_attention = false;
continue;
}
let program = match pane.nebula_state.running_program.clone() {
Some(program) => program,
None => {
+4 -4
View File
@@ -8,8 +8,8 @@ IDI_ICON ICON "nebula.ico"
APP_MANIFEST RT_MANIFEST nebula.manifest
1 VERSIONINFO
FILEVERSION 1,4,1,0
PRODUCTVERSION 1,4,1,0
FILEVERSION 1,5,0,0
PRODUCTVERSION 1,5,0,0
FILEFLAGSMASK 0x3fL
FILEFLAGS 0x0L
FILEOS 0x40004L
@@ -22,11 +22,11 @@ BEGIN
BEGIN
VALUE "CompanyName", "Kuddev"
VALUE "FileDescription", "Nebula Terminal"
VALUE "FileVersion", "1.4.1"
VALUE "FileVersion", "1.5.0"
VALUE "InternalName", "nebula"
VALUE "OriginalFilename", "nebula.exe"
VALUE "ProductName", "Nebula Terminal"
VALUE "ProductVersion", "1.4.1"
VALUE "ProductVersion", "1.5.0"
END
END
BLOCK "VarFileInfo"
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "nebula_config"
version = "1.4.1"
version = "1.5.0"
license = "GPL-3.0-or-later"
description = "Nebula configuration abstractions"
homepage = "https://github.com/Kuddev/nebula"
@@ -14,5 +14,5 @@ serde = "1.0.163"
toml.workspace = true
[dev-dependencies]
nebula_config_derive = { version = "1.4.1", path = "../nebula_config_derive" }
nebula_config_derive = { version = "1.5.0", path = "../nebula_config_derive" }
serde = { version = "1.0.163", features = ["derive"] }
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "nebula_config_derive"
version = "1.4.1"
version = "1.5.0"
license = "GPL-3.0-or-later"
description = "Failure resistant deserialization derive"
homepage = "https://github.com/Kuddev/nebula"
@@ -18,7 +18,7 @@ syn = { version = "2.0.16", features = ["derive", "parsing", "proc-macro", "prin
[dev-dependencies.nebula_config]
path = "../nebula_config"
version = "1.4.1"
version = "1.5.0"
[dev-dependencies]
log = "0.4.11"
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "nebula-settings"
version = "1.4.1"
version = "1.5.0"
license = "GPL-3.0-or-later"
description = "Shared reader for Nebula runtime user settings (nebula_settings.txt) and theme terminal-color tables"
publish = false
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff. Show more