release: prepare Nebula Terminal 1.5.0
This commit is contained in:
1 parent
02d56a6011
commit
ab38dcdd71
106 files changed
+7137
-2452
No files matched your search
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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=()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
[package]
|
||||
name = "nebula-completions"
|
||||
version = "1.4.1"
|
||||
version = "1.5.0"
|
||||
edition.workspace = true
|
||||
|
||||
[features]
|
||||
|
||||
@@ -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 |
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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) ----
|
||||
|
||||
|
||||
+51
-788
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}",
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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, ®ex).total, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_regex_is_reported_without_stale_rows() {
|
||||
let result = search_entries(
|
||||
&[entry("src/main.rs")],
|
||||
&SearchRequest {
|
||||
generation: 7,
|
||||
query: "[".to_owned(),
|
||||
options: FileSearchOptions { regex: true, ..Default::default() },
|
||||
},
|
||||
);
|
||||
assert_eq!(result.generation, 7);
|
||||
assert!(result.rows.is_empty());
|
||||
assert!(result.error.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_index_crawls_nested_paths_but_hides_git_objects() {
|
||||
let temp = tempfile::tempdir().expect("temp dir");
|
||||
std::fs::create_dir_all(temp.path().join("src/nested")).expect("source directories");
|
||||
std::fs::create_dir_all(temp.path().join(".git/objects")).expect("git directories");
|
||||
std::fs::write(temp.path().join("src/nested/needle.rs"), b"").expect("source file");
|
||||
std::fs::write(temp.path().join(".git/objects/hidden"), b"").expect("git object");
|
||||
|
||||
let epoch = AtomicU64::new(4);
|
||||
let count = AtomicUsize::new(0);
|
||||
let mut entries = Vec::new();
|
||||
assert!(build_local_index(temp.path(), 4, &epoch, &count, &mut entries));
|
||||
assert!(entries.iter().any(|entry| entry.key == "src/nested/needle.rs"));
|
||||
assert!(!entries.iter().any(|entry| entry.key.contains(".git")));
|
||||
assert_eq!(count.load(Ordering::Acquire), entries.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_index_patch_replaces_removed_and_created_paths() {
|
||||
let temp = tempfile::tempdir().expect("temp dir");
|
||||
let old = temp.path().join("old.txt");
|
||||
std::fs::write(&old, b"").expect("old file");
|
||||
let epoch = AtomicU64::new(8);
|
||||
let count = AtomicUsize::new(0);
|
||||
let mut entries = Vec::new();
|
||||
assert!(build_local_index(temp.path(), 8, &epoch, &count, &mut entries));
|
||||
|
||||
std::fs::remove_file(&old).expect("remove old file");
|
||||
let new_dir = temp.path().join("new");
|
||||
std::fs::create_dir(&new_dir).expect("new directory");
|
||||
std::fs::write(new_dir.join("fresh.txt"), b"").expect("new file");
|
||||
patch_local_index(temp.path(), &mut entries, vec![old, new_dir], 8, &epoch, &count);
|
||||
|
||||
assert!(!entries.iter().any(|entry| entry.name == "old.txt"));
|
||||
assert!(entries.iter().any(|entry| entry.key == "new/fresh.txt"));
|
||||
assert_eq!(count.load(Ordering::Acquire), entries.len());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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`,保证内外弧**同心**——手写处常常内外用同一
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
//! Backend-neutral semantic intent for transient notifications.
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ToastKind {
|
||||
Info,
|
||||
Success,
|
||||
Warning,
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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`)。
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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()))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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()
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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,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
Reference in new issue
Block a user