From 5513afd5737330eb251ccdb6da3a843c21477f8d Mon Sep 17 00:00:00 2001 From: kuddev Date: Tue, 14 Jul 2026 17:48:36 +0800 Subject: [PATCH] checkpoint: complete SSH and input UX contracts --- TASKS.md | 116 ++++ nebula_app/src/display/chrome.rs | 244 ++++--- nebula_app/src/display/command_palette.rs | 104 ++- nebula_app/src/display/context_menu.rs | 504 ++++++++++++++ nebula_app/src/display/mod.rs | 778 ++++++++++++++++++++-- nebula_app/src/display/settings.rs | 117 +++- nebula_app/src/display/side_panel.rs | 111 ++- nebula_app/src/display/text_input.rs | 71 ++ nebula_app/src/event.rs | 36 +- nebula_app/src/input/keyboard.rs | 189 +++++- nebula_app/src/input/mod.rs | 237 ++++++- nebula_app/src/scheduler.rs | 2 + 12 files changed, 2304 insertions(+), 205 deletions(-) create mode 100644 TASKS.md create mode 100644 nebula_app/src/display/context_menu.rs create mode 100644 nebula_app/src/display/text_input.rs diff --git a/TASKS.md b/TASKS.md new file mode 100644 index 0000000..add7777 --- /dev/null +++ b/TASKS.md @@ -0,0 +1,116 @@ +# 当前任务(2026-07-14) + +- [ ] 使用真实 SSH 服务器验证直连认证顺序、首次连接确认、输入输出、Resize 和实际连接耗时。 +- [ ] 验证远端 `nebula-hook` 的部署/发现方式,确认 Claude、Codex、OpenCode 在 SSH Pane 内能触发本地通知。 +- [x] 为 SSH 条目与 Tab 实现统一的原生右键菜单组件和短促开合动画;外部点击、Esc、普通按键和窗口失焦可自然释放轻量菜单。 +- [x] SSH 菜单:连接、复制地址、编辑、删除;不再提供右键置顶。删除已补来源说明、二次确认、8 秒 Undo / Ctrl+Z、凭据延迟清除及设置页长期恢复入口。 +- [x] Tab 菜单:复制标签页、左右分屏、上下分屏、重命名、关闭和颜色选择。 +- [x] Tab 左侧窄光条只在用户明确设置自定义颜色后显示;默认不显示侧边条(按用户 2026-07-14 最新要求覆盖旧设计)。 +- [x] 保存并恢复 Tab 自定义颜色及必要的会话元数据。 +- [x] 自绘输入框统一支持 Ctrl+A / Ctrl+C / Ctrl+V;全选后输入/粘贴替换,选择有可见背景;隐藏密码仅允许粘贴,显示后才允许复制。 +- [x] 侧栏按 `D:/下载/gemini-code-1784017517204.html` 收敛:TABS / SSH HOSTS 使用真实小字号层级,两个加号仅标题 hover 时显现,Tabs 下拉改为竖向三点。 +- [x] `cargo check -p nebula` 与 `cargo test -p nebula` 通过(173 passed,0 failed;2026-07-14)。 +- [ ] 完成 Debug 运行检查和真实窗口交互验收(hover-only 控件、菜单脱焦、删除/撤销/恢复、输入框快捷键和 DPI 字体观感)。 +- [ ] 验收完成后重打 0.3.0 Release,并替换现有发布压缩包。 + +--- + +# Nebula 任务交接(2026-07-12 17:10 写于会话结束前) + +> 交接给新对话。持久记忆里有同步副本(`nebula-task-handoff` 记忆),本文件更详尽。 +> 已交付包:`target/release/nebula.exe`(16:59+,含下述两个已完成修复 + 诊断日志)。 +> 不要为了检查代码反复关闭用户正在查看的 Nebula。只有在用户同意进行 Release 重打、且运行实例确实占用目标 exe 时,才单独说明后关闭对应实例。 + +## ✅ 本轮已完成(已在包里) + +### 1. 标题/缩放文字"拥挤粘连+虚胖"— 已修 +- 根因:crossfont::Size 单位是 **pt**,`Size::new(base_size.as_px() * scale)` 把 px 当 pt,字号多乘 96/72=1.333 → 字形比步进宽 33% → 标题粘成墨块。 +- 修复:`glyph_cache.font_size = base_size.scale(scale)`(renderer/mod.rs `draw_doc_text` 内,~467 行)。 +- 用户验证方法:md 标题/设置面板标题应字距正常且整体小一号(之前虚胖);侧栏 SSH 提示真正变小(0.85× 之前实际渲染 1.13×)。 + +### 2. 鼠标选择"只能选整行/拖拽粒度错"— 已修(代码完成,待用户手感验证) +- 根因:`on_mouse_press` 中 click-state 被**双重推进**——chrome 块(原 ~1238)推一次更新时间戳,`ChromeHit::None` 落下后终端块(原 ~1401)再推一次,elapsed≈0 → 每次终端单击必然升级 DoubleClick/TripleClick → 词/行粒度选择与拖拽。 +- 修复(input/mod.rs): + - 新增 `advance_click_state()`:press 入口调用**恰好一次**(WT `_numberOfClicks` 模型)。 + - 补 WT 位置约束:两次点击须落在系统双击矩形内(`GetSystemMetrics(SM_CXDOUBLECLK)/2`),时间用系统 `GetDoubleClickTime()`(Cargo.toml 已加 windows-sys feature `Win32_UI_Input_KeyboardAndMouse`)。 + - `Mouse` 新增 `last_click_pos` 字段(event.rs)。 + - Shift+点击 = 扩展现有选区(WT 行为),不再清空。 + - 测试 `input_delay` 引用改为 `multi_click_time()`;7 个 click 测试失败为**遗留**(mock display() unimplemented,input/mod.rs:2286 panic),非回归。 + +## 🧊 已沉淀暂缓:#6 抽屉/目录树 → TUI 历史刷新风暴(用户 07-12 指示"别死磕,先做别的") + +已埋诊断日志(`nebula_link_log` → `%APPDATA%\Nebula\nebula_debug.log`): +- display/mod.rs handle_update HUD 处:`grid_resize x px=.. pad_x/pad_r/pad_y cell=.. drawer=.. sidebar=.. reserved=..` +- window_context/split.rs `apply_view_pty`:`pty_resize cols=.. lines=.. view_px=..` +复现脚本:`scripts/drawer_repro.ps1`(启动+自动点抽屉按钮各一次,间隔 2s;FindWindowW 按 title "nebula" 找不到窗口那次是 race,重跑即可)。 + +**⚠️ 上轮误判已推翻(07-12 二次侦察):** +> 上轮写"异常 B:抽屉状态自发振荡、无人点击也振荡"是**错的**。真相:日志里每对 84↔120 的 grid_resize 前后都紧跟一条 `link_release`——那是 `drawer_repro.ps1` 脚本的模拟点击,不是自发的。**根本没有自发振荡**,别再往"每帧派发 toggle / session 恢复反复 toggle"方向查了。 + +**真正的实锤(重新读日志得出):单击一次 = 开+关两次 toggle。** +``` +[..419.485] grid_resize 84x20 ... drawer=462 ← 开 +[..419.485] pty_resize cols=84 +[..419.616] link_release ← 脚本第 1 次点击的 release +[..419.812] grid_resize 120x20 ... drawer=0 ← 立刻又关(约 300ms 内) +[..419.813] pty_resize cols=120 +``` +每次点击都产生"开(drawer=462)→约300ms→关(drawer=0)"成对 resize,即**一次物理点击触发了两次 toggle_side_panel**。press 和 release 各调一次是头号嫌疑: +- `on_mouse_press` 顶栏按钮路径 input/mod.rs:1368-1381(`ChromeHit::PanelFiles/PanelGit → toggle_side_panel`) +- `on_mouse_release` 里是否有对称的第二次分发(tab-drag `end_tab_drag`→Click、或 chrome 命中在 release 再跑一遍)→ **下一步只需读 on_mouse_release 全程 + arm/end_tab_drag,确认 press 已 return 后 release 不该再命中同一按钮**。 +- 另一嫌疑:`ChromeHit::PanelFiles` 命中矩形与抽屉打开后某元素重叠,开抽屉后同一坐标又命中"关"。 + +**异常 A(独立问题,仍未解):`cell=13x40` — 行高 40px 偏大**(宽 13 正常约配 26 高)。 +- 计算链已定位:display/mod.rs:4936 `compute_cell_size` → `(metrics.line_height + offset_y).floor()`;metrics 来自 glyph_cache.rs:112 `rasterizer.metrics(key, font.size())`,font.size() 已 `.scale(scale_factor)`(display/mod.rs:1444)。 +- 存疑:cell_width=13 说明 average_advance 未被 DPI 双乘,但 line_height=40 偏大 → line_height 单独异常。**下一步**:加日志打印 `metrics.average_advance / metrics.line_height / offset` 原始值,看是 crossfont 返回的 line_height 本身比例失调,还是 config.font.offset.y 被设过大。注意用户设置 `powerline=1`,排查是否 powerline/字体 fallback 拉高行盒。 +- 用户运行时设置(`%APPDATA%\Nebula\nebula_settings.txt`):theme=SilverLight, shell=powershell, powerline=1, keep_session=0, background=#ffffff。 + +**修复原则(留给复工时):** press 已处理的点击,release 必须 early-return,不得二次 toggle;toggle 目标状态幂等(相同 open 值不 set_dimensions)。PTY resize settle 机制已就绪(window_context.rs:1931 leading-edge 300ms + 150ms trailing)——源头去重后风暴应自愈。 + +## 📋 待办(按用户优先级:先 bug 后新功能) + +### #7 SSH 弹外部 conhost 黑窗(bug,未开工) +- 现象:点 SSH host,`C:\Windows\System32\OpenSSH\ssh.exe` 独立黑窗弹出,密码在外部输入。 +- 根因判断:`spawn_tab_ssh`(window_context.rs:816)经 `nebula.exe ssh `(main.rs:108 → `ssh::run(options.args)`,ssh.rs)。nebula.exe 是 GUI 子系统进程、无控制台可继承 → 子进程 ssh.exe 被分配新可见 conhost。 +- 下一步:读 `ssh::run`;候选修法:包装进程入口 AttachConsole/AllocConsole(隐藏窗口);或 PTY 直接跑 ssh(需另路注入远端集成);或 CreateProcess 时正确传递 pseudoconsole。 + +### #2-#5 执行器下拉 + WT 同款默认 Shell 设置(✅ 07-12 完成,含 #5 默认 shell picker) +- 执行器下拉已交付:`shell_detect.rs`(Tabby 探测逻辑移植,缓存于 `Display::nebula_detected_shells`)、chevron 按钮(`ChromeHit::NewTabMenu`)、palette profiles-only 菜单、`TabRequest::NewShell`。 +- #5 默认 Shell(WT parity)已交付,全链路: + - 存储:`shell=` 支持富 id(cmd/pwsh/nu/wsl:X)。`NebulaRuntimeSettings.shell_id: Option` 原样读写(settings.rs),2 值枚举 `NebulaShell` 继续跟踪 PTY 集成执行器族(powershell/bash 的提示符注入基座)。 + - 生效:`spawn_tab` → `default_shell_override()`(window_context.rs:800)经 `shell_detect::resolve_id` 解析;powershell/bash 族返回 None 走 PTY 自有 bootstrap。 + - UI:设置页"默认 Shell"行点击 → `open_default_shell_picker()`(palette 置 `picking_default`,confirm 返回 `SetDefaultShell` 而非 launch)→ `set_default_shell()` 持久化。行值显示 icon+名称(`display_name_for_id`,无 IO,每帧安全)。旧 `cycle_shell` 2 值轮换已删除。 + - palette 行现携带 `(icon, label, hint)` 三元组,shell 行画 Nerd Font 图标(icon_for_id)。 + - 顺手修复:keyboard.rs 重复 `LaunchShell` arm、input/mod.rs 重复 `NewTabMenu` arm(均为前次会话残留的 unreachable pattern)。 +- 验证:cargo build 0 error;测试 137 passed / 8 failed(8 个失败与干净基线完全一致 = 遗留 mock 问题,palette 测试 9/9 过)。 + +### #2-#5 原侦察记录(已实现,留档) +- 需求:tabs 加号旁下箭头 → 菜单列执行器(PowerShell/pwsh/CMD/Git Bash/WSL 发行版)→ 点击用该 shell 开新 tab。参考 Tabby(源码 D:\temp_build\tabby)。**不造轮子**。 +- 现成管线:`Profile{name,command,args,cwd}`(config/ui_config.rs:684)、`TabRequest::NewProfile(usize)`→`spawn_tab_profile`(window_context.rs:787)→`spawn_pane_detached_with(shell override)`(1078);右键加号已弹菜单=命令面板 profiles-only(`open_profile_menu` display/mod.rs:2162;palette `LaunchProfile(i)`→keyboard.rs:539)。 +- 检测逻辑(照搬 Tabby tabby-electron/src/shells/*.ts,已读): + - PowerShell:知名路径顺序探测 `%LOCALAPPDATA%\Microsoft\WindowsApps\pwsh.exe` → `%ProgramFiles%\PowerShell\7\pwsh.exe` → `%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe`,args `-nologo` + - pwsh:注册表 HKLM `SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\pwsh.exe` 默认值 + - CMD:`cmd.exe` + - Git Bash:HKLM→HKCU `Software\GitForWindows` `InstallPath` → `{path}\bin\bash.exe --login -i` + - WSL:HKCU `Software\Microsoft\Windows\CurrentVersion\Lxss` 枚举子键读 `DistributionName` → `%windir%\system32\wsl.exe -d `;无 Lxss 但 system32\bash.exe 存在 → "WSL/Bash on Windows" + - 注册表:windows-sys feature `Win32_System_Registry` 已在依赖(或加 winreg,Cargo.lock 已有) +- UI:chrome.rs `ChromeTabLayout` 加 chevron rect(plus 是 TABS header 右端 s(20) 方块,chrome.rs:299;chevron 放其左),`ChromeHit::NewTabMenu`,hit chrome.rs:429 旁,hover chrome.rs:860 旁,图标 chrome.rs:1010(`draw_centered_icon` + codicon chevron-down)。 +- 路由:菜单名单 = 检测项+config.profiles 合并(一个函数三处共用);新 `TabRequest::NewShell{name,shell}`,keyboard.rs:539 做 index→请求映射;Ctrl+Shift+数字(keyboard.rs:273)保持 config.profiles 语义。 + +### #9 设置改为内容区 tab(新功能,用户已细化) +- 设置作为 tabs 列表一项,点设置按钮 → 设置页在中间内容区打开(VS Code 风格,废除悬浮模态)。 +- 设置 tab 激活时:右上角文件/git 抽屉按钮**隐藏**、抽屉功能禁用。 +- 参考先例:OpenDoc 查看器 tab(TabEntry.doc 字段、markdown_view)是现成的"非终端 tab"。 + +### #10 快捷键扩充 + 一键跳转(新功能) +- 对齐 WT;核对 Alt+1..9 / Ctrl+1..9 切 tab 是否真实生效(设置页写着有);[[keyboard.bindings]] 自定义已有配置系统,扩默认表+文档化;"点击一下自动连接/启动"与执行器下拉、SSH hosts 一键行合并交付。 + +### #11 大文件拆分(重构,用户点名"顺手做") +- display/mod.rs ~4900 行、input/mod.rs ~2500、event.rs ~2900、window_context.rs ~2000。沿 chrome.rs/settings.rs/split.rs 子模块先例按职责拆。 +- 顺带修 8 个遗留测试失败(input 点击测试的 mock display() unimplemented → 解耦 chrome 命中)。 + +## 环境与流程 +- 效果验证:用户装包截图反馈;渲染问题用"量截图"法(列投影+自相关量步进/墨宽,见 nebula-render-crispness 记忆第 6 条)。 +- 诊断日志常开:`%APPDATA%\Nebula\nebula_debug.log`(grid_resize/pty_resize/link_press 等)。修完 #6 后考虑移除或门控这两条 resize 日志。 +- dist 打包需带 conpty.dll + OpenConsole.exe(target/release 已有,7/2 版本)。 +- 上轮遗留:设置面板标题层级(真粗体+字号梯度已上车,看效果再调)。 diff --git a/nebula_app/src/display/chrome.rs b/nebula_app/src/display/chrome.rs index 84d609a..87ac290 100644 --- a/nebula_app/src/display/chrome.rs +++ b/nebula_app/src/display/chrome.rs @@ -29,7 +29,6 @@ pub enum ChromeHit { /// A row in the sidebar's "SSH HOSTS" section (index into the host list). Host(usize), AddSshHost, - HostDelete(usize), /// The "TABS" section header — click toggles the accordion fold. TabsSection, /// The "SSH HOSTS" section header — click toggles the accordion fold. @@ -101,7 +100,6 @@ pub(super) struct ChromeTabLayout { /// SSH host rows, same indexing contract as `tabs`: entry `i` is host `i`, /// scrolled-out rows carry a zero rect (skipped by drawing and hit-tests). pub(super) hosts: Vec<(f32, f32, f32, f32)>, - pub(super) host_closes: Vec<(f32, f32, f32, f32)>, pub(super) hosts_add: (f32, f32, f32, f32), /// Accordion headers ("TABS" / "SSH HOSTS" caption bands). Zero when the /// panel is folded; `hosts_header` is zero when there are no hosts at all. @@ -262,7 +260,6 @@ pub(super) fn chrome_tab_layout( toggle, panel: (0.0, 0.0, 0.0, 0.0), hosts: Vec::new(), - host_closes: Vec::new(), hosts_add: (0.0, 0.0, 0.0, 0.0), tabs_header: (0.0, 0.0, 0.0, 0.0), hosts_header: (0.0, 0.0, 0.0, 0.0), @@ -377,22 +374,13 @@ pub(super) fn chrome_tab_layout( let hosts_header = (panel_x, hosts_header_y, panel_w, hosts_header_h); let hosts_top = hosts_header_y + hosts_header_h; let mut hosts = Vec::with_capacity(host_count); - let mut host_closes = Vec::with_capacity(host_count); for i in 0..host_count { if !model.hosts_open || i < hosts_scroll || i >= hosts_scroll + hosts_show { hosts.push(zero); - host_closes.push(zero); continue; } let y = hosts_top + (i - hosts_scroll) as f32 * pitch; hosts.push((tab_x, y, tab_w, row_h)); - let close_size = (row_h * 0.58).max(s(16.0)); - host_closes.push(( - tab_x + tab_w - close_size - s(10.0), - y + (row_h - close_size) * 0.5, - close_size, - close_size, - )); } let hosts_band_h = if hosts_show > 0 { hosts_show as f32 * pitch - gap } else { 0.0 }; let hosts_band = (hosts_top, hosts_top + hosts_band_h); @@ -419,7 +407,6 @@ pub(super) fn chrome_tab_layout( toggle, panel, hosts, - host_closes, hosts_add: ( hosts_header.0 + hosts_header.2 - s(42.0), hosts_header.1 + (hosts_header.3 - s(20.0)) * 0.5, @@ -437,6 +424,73 @@ pub(super) fn chrome_tab_layout( } } +/// Draw the new-tab mark from the same physical rectangle used for its hit and +/// hover area. Maple's Codicon `add` glyph has asymmetric outline bounds, so +/// centering it by terminal-cell metrics makes the ink look offset even though +/// Maple itself is a monospaced text font. A two-stroke primitive keeps this +/// one control optically and geometrically centered at every DPI. +fn push_centered_add_icon( + quads: &mut Vec, + rect: (f32, f32, f32, f32), + scale: f32, + color: Rgb, +) { + let (x, y, width, height) = rect; + let side = width.min(height); + if side <= 0.0 { + return; + } + + let center_x = x + width * 0.5; + let center_y = y + height * 0.5; + let stroke = (side * 0.105).clamp((scale * 1.25).max(1.0), scale * 2.0); + let arm = side * 0.285; + let ink = Rgba::new(color.r, color.g, color.b, 235); + + quads.push(UiQuad::solid( + center_x - arm, + center_y - stroke * 0.5, + arm * 2.0, + stroke, + stroke * 0.5, + ink, + )); + quads.push(UiQuad::solid( + center_x - stroke * 0.5, + center_y - arm, + stroke, + arm * 2.0, + stroke * 0.5, + ink, + )); +} + +/// Native three-dot menu mark. Its geometry comes from the same control rect +/// as hover/hit-testing, so Maple private-use glyph metrics cannot skew it. +fn push_centered_more_icon( + quads: &mut Vec, + rect: (f32, f32, f32, f32), + scale: f32, + color: Rgb, +) { + let (x, y, width, height) = rect; + let diameter = (2.2 * scale).max(2.0); + let gap = (3.2 * scale).max(diameter + scale); + let center_x = x + width * 0.5; + let center_y = y + height * 0.5; + let ink = Rgba::new(color.r, color.g, color.b, 235); + for offset in [-gap, 0.0, gap] { + quads.push(UiQuad::solid( + center_x - diameter * 0.5, + center_y + offset - diameter * 0.5, + diameter, + diameter, + diameter * 0.5, + ink, + )); + } +} + pub(super) fn chrome_hit_with_tabs( size_info: &SizeInfo, scale_factor: f32, @@ -478,11 +532,6 @@ pub(super) fn chrome_hit_with_tabs( return ChromeHit::Tab(index); } } - for (index, rect) in layout.host_closes.iter().copied().enumerate() { - if contains_rect(rect, x, y) { - return ChromeHit::HostDelete(index); - } - } for (index, rect) in layout.hosts.iter().copied().enumerate() { if contains_rect(rect, x, y) { return ChromeHit::Host(index); @@ -780,9 +829,9 @@ pub(super) fn draw_chrome(d: &mut Display) { let tab_draw_x = tab_x; let tab_draw_y = tab_y; if index == d.nebula_active_tab { - // Floating-pill active tab (design language): a soft accent - // wash over the pill plus a hairline accent border — no - // accent bar, state is carried by brightness alone. + // Floating-pill active tab: a soft accent wash over the pill plus + // a hairline border. The narrow identity light below carries the + // optional user-picked color. let accent = palette.edge_r; quads.push(UiQuad::solid( tab_draw_x - s(1.0), @@ -822,6 +871,39 @@ pub(super) fn draw_chrome(d: &mut Display) { if tab_hovered { quads.push(UiQuad::solid(tab_draw_x, tab_draw_y, tab_w, tab_h, pill_r, HOVER_FILL)); } + // 侧边光条仅表示用户明确设置的标签色;默认 Tab 不额外占用视觉层级。 + if let Some(strip_color) = d.nebula_tab_colors.get(index).copied().flatten() { + let strip_x = tab_draw_x + s(4.0); + let strip_y = tab_draw_y + s(7.0); + let strip_w = s(2.5).max(2.0); + let strip_h = (tab_h - s(14.0)).max(s(10.0)); + let glow = s(12.0); + quads.push(UiQuad::glow( + strip_x + strip_w * 0.5 - glow * 0.5, + strip_y + strip_h * 0.5 - glow * 0.5, + glow, + glow, + Rgba::new( + strip_color.r, + strip_color.g, + strip_color.b, + if index == d.nebula_active_tab { 72 } else { 38 }, + ), + )); + quads.push(UiQuad::solid( + strip_x, + strip_y, + strip_w, + strip_h, + strip_w * 0.5, + Rgba::new( + strip_color.r, + strip_color.g, + strip_color.b, + if index == d.nebula_active_tab { 245 } else { 176 }, + ), + )); + } let (close_x, _, close_w, close_h) = tab_layout.closes[index]; let close_y = tab_draw_y + (tab_h - close_h) / 2.0; if close_hovered { @@ -903,11 +985,37 @@ pub(super) fn draw_chrome(d: &mut Display) { if d.nebula_chrome_hover == ChromeHit::NewTab { quads.push(UiQuad::solid(plus_x, plus_y, plus_w, plus_h, pill_r, HOVER_FILL_STRONG)); } + let expanded_sidebar = d.left_sidebar_visible() && tab_layout.panel.2 > 0.0; + let tabs_plus_visible = !expanded_sidebar + || matches!( + d.nebula_chrome_hover, + ChromeHit::TabsSection | ChromeHit::NewTab | ChromeHit::NewTabMenu + ); + if !d.nebula_settings_open && tabs_plus_visible { + let plus_ink = if d.nebula_chrome_hover == ChromeHit::NewTab { + sk.icon_hover + } else { + sk.icon + }; + push_centered_add_icon(&mut quads, tab_layout.plus, scale, plus_ink); + } // The dropdown chevron beside "+": same hover-only lift. let (menu_x, menu_y, menu_w, menu_h) = tab_layout.menu; if d.nebula_chrome_hover == ChromeHit::NewTabMenu { quads.push(UiQuad::solid(menu_x, menu_y, menu_w, menu_h, pill_r, HOVER_FILL_STRONG)); } + if !d.nebula_settings_open { + push_centered_more_icon( + &mut quads, + tab_layout.menu, + scale, + if d.nebula_chrome_hover == ChromeHit::NewTabMenu { + sk.icon_hover + } else { + sk.icon + }, + ); + } // SSH HOSTS section (quad layer): hover fill per row + the per-section // overlay scrollbar thumbs. Row icons/labels render in the text pass. @@ -916,20 +1024,29 @@ pub(super) fn draw_chrome(d: &mut Display) { if hw <= 0.0 { continue; } - if matches!(d.nebula_chrome_hover, ChromeHit::Host(i) | ChromeHit::HostDelete(i) if i == index) - { + if matches!(d.nebula_chrome_hover, ChromeHit::Host(i) if i == index) { quads.push(UiQuad::solid(hx, hy, hw, hh, pill_r, HOVER_FILL)); } - if matches!(d.nebula_chrome_hover, ChromeHit::HostDelete(i) if i == index) { - if let Some((cx, cy, cw, ch)) = tab_layout.host_closes.get(index).copied() { - quads.push(UiQuad::solid(cx, cy, cw, ch, cw * 0.35, HOVER_FILL_STRONG)); - } - } } if d.nebula_chrome_hover == ChromeHit::AddSshHost { let (ax, ay, aw, ah) = tab_layout.hosts_add; quads.push(UiQuad::solid(ax, ay, aw, ah, s(6.0), HOVER_FILL_STRONG)); } + if matches!( + d.nebula_chrome_hover, + ChromeHit::HostsSection | ChromeHit::AddSshHost + ) { + push_centered_add_icon( + &mut quads, + tab_layout.hosts_add, + scale, + if d.nebula_chrome_hover == ChromeHit::AddSshHost { + sk.icon_hover + } else { + sk.icon + }, + ); + } let thumb_c = sk.scrollbar_thumb; for bar in [tab_layout.tabs_scrollbar, tab_layout.hosts_scrollbar].into_iter().flatten() { let (bx, by, bw, bh) = bar; @@ -997,6 +1114,7 @@ pub(super) fn draw_chrome(d: &mut Display) { &d.nebula_theme, &mut quads, scale, + size.cell_width(), ); } @@ -1025,8 +1143,6 @@ pub(super) fn draw_chrome(d: &mut Display) { let (TXT, TXT_ON_ACCENT, TXT_DIM, ICON, ICON_HOVER) = (sk.ink, sk.ink_strong, sk.ink_dim, sk.icon, sk.icon_hover); const ICON_SETTINGS: &str = "\u{eb51}"; - const ICON_ADD: &str = "\u{ea60}"; - const ICON_CHEVRON_DOWN: &str = "\u{eab4}"; const ICON_CLOSE: &str = "\u{ea76}"; const ICON_CHROME_MINIMIZE: &str = "\u{eaba}"; const ICON_CHROME_MAXIMIZE: &str = "\u{eab9}"; @@ -1063,31 +1179,6 @@ pub(super) fn draw_chrome(d: &mut Display) { if settings_hovered { ICON_HOVER } else { ICON }, ICON_SETTINGS, ); - // The settings modal veils the sidebar, so its "+" must not float on - // top of the glass (the veil lives in the quad layer below text). - if !d.nebula_settings_open { - draw_centered_icon( - &mut d.renderer, - &mut d.glyph_cache, - &size, - cell_w, - cell_h, - tab_layout.plus, - if d.nebula_chrome_hover == ChromeHit::NewTab { ICON_HOVER } else { ICON }, - ICON_ADD, - ); - // The dropdown chevron (codicon chevron-down) beside "+". - draw_centered_icon( - &mut d.renderer, - &mut d.glyph_cache, - &size, - cell_w, - cell_h, - tab_layout.menu, - if d.nebula_chrome_hover == ChromeHit::NewTabMenu { ICON_HOVER } else { ICON }, - ICON_CHEVRON_DOWN, - ); - } for (hit, cx, cy) in chrome_control_centers(w, top_y, bar_h, scale) { let hovered = d.nebula_chrome_hover == hit; let icon = match hit { @@ -1138,11 +1229,15 @@ pub(super) fn draw_chrome(d: &mut Display) { // inside the header band to keep clearance from the join. let (pnl_x, pnl_y, _, _) = tab_layout.panel; let tabs_chevron = if d.nebula_tabs_section_open { "\u{eab4}" } else { "\u{eab6}" }; - d.renderer.draw_chrome_text( + const SECTION_TITLE_SCALE: f32 = 0.75; + let section_title_flags = nebula_terminal::term::cell::Flags::BOLD; + d.renderer.draw_doc_text( &size, pnl_x + s(16.0), - pnl_y + s(18.0), + pnl_y + s(22.0), + SECTION_TITLE_SCALE, TXT_DIM, + section_title_flags, &format!("TABS {tabs_chevron}"), &mut d.glyph_cache, ); @@ -1314,15 +1409,6 @@ pub(super) fn draw_chrome(d: &mut Display) { &label, &mut d.glyph_cache, ); - let (ax, ay, aw, ah) = tab_layout.hosts_add; - d.renderer.draw_chrome_text( - &size, - ax + (aw - cell_w) / 2.0, - ay + (ah - cell_h) / 2.0, - if d.nebula_chrome_hover == ChromeHit::AddSshHost { sk.accent } else { TXT_DIM }, - "+", - &mut d.glyph_cache, - ); if tab_hovered { let (close_x, _, close_w, close_h) = tab_layout.closes[index]; let close_y = draw_row_y + (tab_h - close_h) / 2.0; @@ -1354,11 +1440,13 @@ pub(super) fn draw_chrome(d: &mut Display) { if tab_layout.hosts_header.2 > 0.0 { let (hh_x, hh_y, _, hh_h) = tab_layout.hosts_header; let hosts_chevron = if d.nebula_hosts_section_open { "\u{eab4}" } else { "\u{eab6}" }; - d.renderer.draw_chrome_text( + d.renderer.draw_doc_text( &size, hh_x + s(16.0), - hh_y + (hh_h - cell_h) / 2.0, + hh_y + (hh_h - cell_h * SECTION_TITLE_SCALE) / 2.0, + SECTION_TITLE_SCALE, TXT_DIM, + section_title_flags, &format!("SSH HOSTS {hosts_chevron}"), &mut d.glyph_cache, ); @@ -1373,7 +1461,7 @@ pub(super) fn draw_chrome(d: &mut Display) { // would bleed across the seam onto the terminal grid. if d.nebula_ssh_hosts.is_empty() && d.nebula_hosts_section_open { use unicode_width::UnicodeWidthChar; - const HINT_SCALE: f32 = 0.85; + const HINT_SCALE: f32 = 0.80; let hint_flags = nebula_terminal::term::cell::Flags::empty(); let (pnl_x, _, pnl_w, _) = tab_layout.panel; let text_x = hh_x + s(28.0); @@ -1423,7 +1511,7 @@ pub(super) fn draw_chrome(d: &mut Display) { if hw <= 0.0 { continue; } - let hovered = matches!(d.nebula_chrome_hover, ChromeHit::Host(i) | ChromeHit::HostDelete(i) if i == index); + let hovered = matches!(d.nebula_chrome_hover, ChromeHit::Host(i) if i == index); let color = if hovered { TXT_ON_ACCENT } else { TXT }; let cy = row_text_cy(hy, hh); let name = d.nebula_ssh_hosts.get(index).map(String::as_str).unwrap_or("?"); @@ -1458,22 +1546,6 @@ pub(super) fn draw_chrome(d: &mut Display) { &mut d.glyph_cache, ); } - if hovered { - if let Some((cx, cy, cw, ch)) = tab_layout.host_closes.get(index).copied() { - d.renderer.draw_chrome_text( - &size, - cx + (cw - cell_w) / 2.0, - cy + (ch - cell_h) / 2.0, - if matches!(d.nebula_chrome_hover, ChromeHit::HostDelete(i) if i == index) { - Rgb::new(sk.danger.r, sk.danger.g, sk.danger.b) - } else { - TXT_DIM - }, - "×", - &mut d.glyph_cache, - ); - } - } } } } else { diff --git a/nebula_app/src/display/command_palette.rs b/nebula_app/src/display/command_palette.rs index 0fa9665..407224b 100644 --- a/nebula_app/src/display/command_palette.rs +++ b/nebula_app/src/display/command_palette.rs @@ -11,6 +11,7 @@ use super::{NebulaTheme, SizeInfo}; use crate::shell_detect::DetectedShell; +use unicode_width::UnicodeWidthChar; /// A dynamic quick-launch row: a config profile (launched by index) or a /// detected shell (spec carried inline). Built fresh on every menu open. @@ -261,6 +262,7 @@ const RECENT_MAX: usize = 6; pub struct CommandPalette { open: bool, query: String, + query_selection: super::text_input::SelectAllState, /// Indices into the combined item space — `0..ITEMS.len()` are the static /// actions, `ITEMS.len()..` map onto `profiles`. Best match first. filtered: Vec, @@ -294,6 +296,7 @@ impl CommandPalette { let mut palette = Self { open: false, query: String::new(), + query_selection: Default::default(), filtered: Vec::new(), selected: None, // No selection until user navigates recent: Vec::new(), @@ -318,6 +321,12 @@ impl CommandPalette { self.picking_default } + /// Lightweight shell/profile selector mode. Unlike the full command + /// palette it has no search field and should release focus naturally. + pub fn is_picker(&self) -> bool { + self.open && self.profiles_only + } + /// Refresh the dynamic quick-launch rows from the config's profile names. /// Called by the full-palette open path so a reloaded config is reflected. pub fn set_profiles(&mut self, names: &[String]) { @@ -377,6 +386,7 @@ impl CommandPalette { self.profiles_only = false; self.picking_default = false; self.query.clear(); + self.query_selection.clear(); self.refilter(); } @@ -386,6 +396,7 @@ impl CommandPalette { self.profiles_only = true; self.picking_default = false; self.query.clear(); + self.query_selection.clear(); self.refilter(); } @@ -396,6 +407,7 @@ impl CommandPalette { self.profiles_only = true; self.picking_default = true; self.query.clear(); + self.query_selection.clear(); self.refilter(); } @@ -404,6 +416,7 @@ impl CommandPalette { self.profiles_only = false; self.picking_default = false; self.hover = None; + self.query_selection.clear(); } /// Update hover based on mouse position. `row` is the index within the @@ -443,15 +456,33 @@ impl CommandPalette { if c.is_control() { return; } - self.query.push(c); + let mut encoded = [0u8; 4]; + self.query_selection.insert(&mut self.query, c.encode_utf8(&mut encoded)); + self.refilter(); + } + + pub fn input_text(&mut self, text: &str) { + self.query_selection.insert(&mut self.query, text); self.refilter(); } pub fn backspace(&mut self) { - self.query.pop(); + self.query_selection.backspace(&mut self.query); self.refilter(); } + pub fn select_all(&mut self) { + self.query_selection.select(&self.query); + } + + pub fn selected_text(&self) -> Option { + self.query_selection.selected_text(&self.query) + } + + pub fn query_all_selected(&self) -> bool { + self.query_selection.is_selected() + } + /// Move the selection by `delta` rows, wrapping at both ends. pub fn move_selection(&mut self, delta: i32) { if self.filtered.is_empty() { @@ -466,7 +497,10 @@ impl CommandPalette { /// Confirm the current selection: records it as recent, closes the palette, /// and returns the action to run, or `None` when nothing matches. pub fn confirm(&mut self) -> Option { - let selected = self.selected?; + // The first row is visibly selected on a freshly opened palette. Make + // Enter execute that same row even before arrow navigation, otherwise + // the UI advertises an action that the keyboard cannot confirm. + let selected = self.selected.unwrap_or(0); let idx = *self.filtered.get(selected)?; self.close(); if let Some(profile) = idx.checked_sub(ITEMS.len()) { @@ -556,11 +590,13 @@ impl CommandPalette { if self.filtered.is_empty() || max_rows == 0 { return (Vec::new(), None); } - // No selection yet (initial state) — show first page, no highlight + // No stored selection yet: the first row is the visual/default target. + // `selected` stays None until navigation so Up from a fresh palette + // still wraps to the last row, matching the existing keyboard model. let Some(selected) = self.selected else { let rows = self.filtered.iter().take(max_rows).map(|&idx| self.row_for_idx(idx)).collect(); - return (rows, None); + return (rows, Some(0)); }; // Keep the selection visible: once it passes the last row, scroll so it // sits on the bottom line of the window. @@ -699,7 +735,7 @@ pub(super) fn push_quads( let s = |v: f32| v * scale; let palette = theme.palette(); let sk = theme.skin(); - let with_input = !model.is_picking_default(); + let with_input = !model.is_picker(); let layout = palette_layout(w, h, scale, with_input); let (px, py, pw, ph) = layout.panel; let (ix, iy, iw, ih) = layout.input; @@ -736,6 +772,20 @@ pub(super) fn push_quads( // Query input box: only in full palette mode, not the WT-style picker. if with_input { quads.push(UiQuad::solid(ix, iy, iw, ih, s(10.0), sk.input)); + if model.query_all_selected() && !model.query.is_empty() { + let cell_w = size.cell_width(); + let columns: usize = model.query.chars().map(|c| c.width().unwrap_or(0)).sum(); + let selection_x = ix + s(14.0 + 28.0); + let selection_w = (columns as f32 * cell_w).min(iw - s(56.0)); + quads.push(UiQuad::solid( + selection_x - s(2.0), + iy + s(7.0), + selection_w + s(4.0), + ih - s(14.0), + s(4.0), + sk.accent_soft, + )); + } } // Hover background: subtle highlight when the mouse is over a row. @@ -795,7 +845,7 @@ pub(super) fn draw_text( let h = size.height(); let cell_w = size.cell_width(); let cell_h = size.cell_height(); - let with_input = !model.is_picking_default(); + let with_input = !model.is_picker(); let layout = palette_layout(w, h, scale, with_input); let (ix, iy, iw, ih) = layout.input; @@ -821,7 +871,11 @@ pub(super) fn draw_text( r.draw_chrome_text(size, query_x, text_y, sk.ink_strong, cursor, gc); } else { // Show query + cursor at the end - let shown = format!("{query}{cursor}"); + let shown = if model.query_all_selected() { + query.to_owned() + } else { + format!("{query}{cursor}") + }; r.draw_chrome_text(size, query_x, text_y, sk.ink_strong, &shown, gc); } } @@ -909,7 +963,9 @@ mod tests { args: vec![], }, }]; - palette.refilter(); + // Dynamic shell rows belong to the shell/profile picker. The full + // command palette deliberately keeps static actions first. + palette.open_profiles(); let (rows, _) = palette.visible(10); assert!(!rows.is_empty()); assert_eq!(rows.last().unwrap().label, "PowerShell"); @@ -1010,4 +1066,34 @@ mod tests { // The bottom visible row is the actually-selected item (field .label). assert_eq!(rows[max - 1].label, ITEMS[palette.filtered[7]].label); } + + #[test] + fn only_profile_modes_are_lightweight_pickers() { + let mut palette = CommandPalette::new(); + assert!(!palette.is_picker()); + + palette.open(); + assert!(!palette.is_picker(), "full palette keeps its search focus"); + + palette.open_profiles(); + assert!(palette.is_picker(), "new-tab shell menu releases focus naturally"); + + palette.close(); + assert!(!palette.is_picker()); + + palette.open_default_picker(); + assert!(palette.is_picker(), "default-shell selector shares picker semantics"); + } + + #[test] + fn select_all_copy_and_paste_replace_the_query() { + let mut palette = CommandPalette::new(); + palette.open(); + palette.input_text("old query"); + palette.select_all(); + assert_eq!(palette.selected_text().as_deref(), Some("old query")); + palette.input_text("new\r\nquery"); + assert_eq!(palette.query(), "newquery"); + assert!(!palette.query_all_selected()); + } } diff --git a/nebula_app/src/display/context_menu.rs b/nebula_app/src/display/context_menu.rs new file mode 100644 index 0000000..0e2a2f9 --- /dev/null +++ b/nebula_app/src/display/context_menu.rs @@ -0,0 +1,504 @@ +//! Unified native context menu for sidebar tabs and SSH destinations. +//! +//! The menu is rendered by Nebula's existing OpenGL UI pipeline. Geometry, +//! hit-testing and animation live together here so pointer targets cannot drift +//! away from the pixels the user sees at non-integer DPI scales. + +use std::time::{Duration, Instant}; + +use super::*; + +const OPEN_DURATION: Duration = Duration::from_millis(120); +const CLOSE_DURATION: Duration = Duration::from_millis(90); + +// Maple Mono Normal NF CN ships the stable Codicon block below. Keep menu +// symbols on that block: newer Codicon additions such as U+EC86 are absent +// from the installed Maple version and rasterize as a missing-glyph box. +const ICON_COPY: &str = "\u{ebcc}"; +const ICON_SPLIT_RIGHT: &str = "\u{eb56}"; +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), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ContextMenuAction { + DuplicateTab(usize), + SplitTabRight(usize), + SplitTabDown(usize), + RenameTab(usize), + CloseTab(usize), + SetTabColor { index: usize, color: Option }, + ConnectSsh(usize), + CopySshAddress(usize), + EditSsh(usize), + DeleteSsh(usize), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ContextMenuHit { + Outside, + Panel, + Action(ContextMenuAction), +} + +#[derive(Debug, Clone)] +pub(super) struct ContextMenu { + target: ContextMenuTarget, + anchor: (f32, f32), + opened_at: Instant, + closing_at: Option, + hover: Option, + current_color: Option, +} + +impl ContextMenu { + pub(super) fn new( + target: ContextMenuTarget, + anchor: (f32, f32), + current_color: Option, + ) -> Self { + Self { + target, + anchor, + opened_at: Instant::now(), + closing_at: None, + hover: None, + current_color, + } + } + + pub(super) fn begin_close(&mut self) { + self.closing_at.get_or_insert_with(Instant::now); + self.hover = None; + } + + pub(super) fn set_hover(&mut self, action: Option) -> bool { + if self.hover == action { + return false; + } + self.hover = action; + true + } + + fn progress(&self) -> f32 { + if let Some(start) = self.closing_at { + return (1.0 - start.elapsed().as_secs_f32() / CLOSE_DURATION.as_secs_f32()) + .clamp(0.0, 1.0); + } + (self.opened_at.elapsed().as_secs_f32() / OPEN_DURATION.as_secs_f32()).clamp(0.0, 1.0) + } + + pub(super) fn finished(&self) -> bool { + self.closing_at.is_some_and(|start| start.elapsed() >= CLOSE_DURATION) + } + + pub(super) fn animating(&self) -> bool { + self.closing_at.is_some() || self.opened_at.elapsed() < OPEN_DURATION + } + + pub(super) fn interactive(&self) -> bool { + self.closing_at.is_none() + } +} + +#[derive(Debug, Clone, Copy)] +struct Row { + action: ContextMenuAction, + icon: &'static str, + label: &'static str, + hint: &'static str, + rect: (f32, f32, f32, f32), + danger: bool, +} + +#[derive(Debug, Clone)] +struct MenuLayout { + panel: (f32, f32, f32, f32), + rows: Vec, + separators: Vec<(f32, f32, f32, f32)>, + color_label: Option<(f32, f32)>, + colors: Vec<(Option, ContextMenuAction, (f32, f32, f32, f32))>, +} + +fn contains((x, y, w, h): (f32, f32, f32, f32), px: f32, py: f32) -> bool { + px >= x && px <= x + w && py >= y && py <= y + h +} + +fn layout(menu: &ContextMenu, size: SizeInfo, scale: f32, animated_y_offset: f32) -> MenuLayout { + let s = |v: f32| v * scale; + let width = s(252.0); + let row_h = s(38.0); + let pad = s(7.0); + let sep_h = s(9.0); + let color_h = s(62.0); + let (row_count, separators, extra) = match menu.target { + ContextMenuTarget::Tab(_) => (5usize, 2usize, color_h), + ContextMenuTarget::Ssh(_) => (4usize, 1usize, 0.0), + }; + let height = pad * 2.0 + row_count as f32 * row_h + separators as f32 * sep_h + extra; + let margin = s(8.0); + let mut x = menu.anchor.0; + let mut y = menu.anchor.1 + animated_y_offset; + if x + width > size.width() - margin { + x = (menu.anchor.0 - width).max(margin); + } + if y + height > size.height() - margin { + y = (size.height() - margin - height).max(margin); + } + // The anchor may itself sit inside the safety margin (a right-click on a + // close glyph at the edge), so clamp after choosing the preferred side as + // well. Without this final cap the menu could still overhang by the + // anchor-to-margin distance. + let max_x = (size.width() - margin - width).max(margin); + let max_y = (size.height() - margin - height).max(margin); + x = x.clamp(margin, max_x); + y = y.clamp(margin, max_y); + + let panel = (x, y, width, height); + let mut rows = Vec::with_capacity(row_count); + let mut separator_rects = Vec::with_capacity(separators); + let mut cursor_y = y + pad; + let row = |action, icon, label, hint, y, danger| Row { + action, + icon, + label, + hint, + rect: (x + pad, y, width - 2.0 * pad, row_h), + danger, + }; + let mut separator = |cursor_y: &mut f32| { + separator_rects.push((x + s(12.0), *cursor_y + sep_h * 0.5, width - s(24.0), s(1.0))); + *cursor_y += sep_h; + }; + + let mut color_label = None; + let mut colors = Vec::new(); + match menu.target { + ContextMenuTarget::Tab(index) => { + rows.push(row( + ContextMenuAction::DuplicateTab(index), + ICON_COPY, + "复制标签页", + "", + cursor_y, + false, + )); + cursor_y += row_h; + separator(&mut cursor_y); + rows.push(row( + ContextMenuAction::SplitTabRight(index), + ICON_SPLIT_RIGHT, + "左右分屏", + "Ctrl+Shift+D", + cursor_y, + false, + )); + cursor_y += row_h; + rows.push(row( + ContextMenuAction::SplitTabDown(index), + ICON_SPLIT_DOWN, + "上下分屏", + "Ctrl+Shift+S", + cursor_y, + false, + )); + cursor_y += row_h; + separator(&mut cursor_y); + rows.push(row( + ContextMenuAction::RenameTab(index), + ICON_EDIT, + "重命名", + "F2", + cursor_y, + false, + )); + cursor_y += row_h; + rows.push(row( + ContextMenuAction::CloseTab(index), + ICON_CLOSE, + "关闭", + "Ctrl+Shift+W", + cursor_y, + true, + )); + cursor_y += row_h; + + color_label = Some((x + s(14.0), cursor_y + s(8.0))); + let swatch = s(20.0); + let gap = s(8.0); + let start_x = x + s(14.0); + let swatch_y = cursor_y + s(32.0); + let all = std::iter::once(None).chain(TAB_COLORS.into_iter().map(Some)); + for (slot, color) in all.enumerate() { + let rect = (start_x + slot as f32 * (swatch + gap), swatch_y, swatch, swatch); + colors.push((color, ContextMenuAction::SetTabColor { index, color }, rect)); + } + }, + ContextMenuTarget::Ssh(index) => { + rows.push(row( + ContextMenuAction::ConnectSsh(index), + "\u{f489}", + "连接", + "Enter", + cursor_y, + false, + )); + cursor_y += row_h; + rows.push(row( + ContextMenuAction::CopySshAddress(index), + ICON_COPY, + "复制地址", + "", + cursor_y, + false, + )); + cursor_y += row_h; + rows.push(row( + ContextMenuAction::EditSsh(index), + ICON_EDIT, + "编辑", + "", + cursor_y, + false, + )); + cursor_y += row_h; + separator(&mut cursor_y); + rows.push(row( + ContextMenuAction::DeleteSsh(index), + "\u{ea81}", + "删除", + "", + cursor_y, + true, + )); + }, + } + + MenuLayout { panel, rows, separators: separator_rects, color_label, colors } +} + +pub(super) fn hit_test( + menu: &ContextMenu, + size: SizeInfo, + scale: f32, + x: f32, + y: f32, +) -> ContextMenuHit { + let layout = layout(menu, size, scale, 0.0); + for row in &layout.rows { + if contains(row.rect, x, y) { + return ContextMenuHit::Action(row.action); + } + } + for (_, action, rect) in &layout.colors { + if contains(*rect, x, y) { + return ContextMenuHit::Action(*action); + } + } + if contains(layout.panel, x, y) { ContextMenuHit::Panel } else { ContextMenuHit::Outside } +} + +fn alpha(color: Rgba, opacity: f32) -> Rgba { + Rgba::new(color.r, color.g, color.b, (color.a as f32 * opacity).round() as u8) +} + +fn fade_ink(base: Rgba, ink: Rgb, opacity: f32) -> Rgb { + let mix = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * opacity).round() as u8; + Rgb::new(mix(base.r, ink.r), mix(base.g, ink.g), mix(base.b, ink.b)) +} + +/// Paint the menu after the chrome/logo pass and before true modal dialogs. +pub(super) fn draw(display: &mut Display) { + let Some(menu) = display.nebula_context_menu.clone() else { return }; + if menu.finished() { + display.nebula_context_menu = None; + return; + } + + let progress = menu.progress(); + let eased = 1.0 - (1.0 - progress) * (1.0 - progress) * (1.0 - progress); + let size = display.size_info; + let scale = display.window.scale_factor as f32; + let s = |v: f32| v * scale; + let layout = layout(&menu, size, scale, -s(5.0) * (1.0 - eased)); + let sk = display.nebula_theme.skin(); + let radius = s(9.0); + let (px, py, pw, ph) = layout.panel; + + let mut quads = vec![ + UiQuad::glow( + px - s(16.0), + py - s(12.0), + pw + s(32.0), + ph + s(30.0), + alpha(Rgba::new(0, 0, 0, 72), progress), + ), + UiQuad::solid(px, py, pw, ph, radius, alpha(sk.panel, progress)), + UiQuad::solid(px, py, pw, s(1.0).max(1.0), radius, alpha(sk.hairline, progress)), + ]; + for separator in &layout.separators { + quads.push(UiQuad::solid( + separator.0, + separator.1, + separator.2, + separator.3, + 0.0, + alpha(sk.hairline, progress), + )); + } + for row in &layout.rows { + if menu.hover == Some(row.action) { + quads.push(UiQuad::solid( + row.rect.0, + row.rect.1, + row.rect.2, + row.rect.3, + s(6.0), + alpha(sk.hover, progress), + )); + } + } + + for (color, action, rect) in &layout.colors { + let selected = *color == menu.current_color; + if menu.hover == Some(*action) || selected { + let ring = if selected { sk.accent } else { sk.ink_dim }; + quads.push(UiQuad::solid( + rect.0 - s(3.0), + rect.1 - s(3.0), + rect.2 + s(6.0), + rect.3 + s(6.0), + s(7.0), + alpha(Rgba::new(ring.r, ring.g, ring.b, 210), progress), + )); + } + let swatch = color.unwrap_or(sk.accent); + quads.push(UiQuad::solid( + rect.0, + rect.1, + rect.2, + rect.3, + s(5.0), + alpha(Rgba::new(swatch.r, swatch.g, swatch.b, 255), progress), + )); + } + display.renderer.draw_ui(&size, &quads); + + // Text has no alpha channel in the glyph pipeline, so fade it toward the + // panel color. This avoids a one-frame label pop during both directions. + let ink = fade_ink(sk.panel, sk.ink, progress); + let dim = fade_ink(sk.panel, sk.ink_dim, progress); + let danger = fade_ink(sk.panel, Rgb::new(sk.danger.r, sk.danger.g, sk.danger.b), progress); + let cell_h = size.cell_height(); + for row in &layout.rows { + let y = row.rect.1 + (row.rect.3 - cell_h) * 0.5; + display.renderer.draw_chrome_text( + &size, + row.rect.0 + s(10.0), + y, + if row.danger { danger } else { dim }, + row.icon, + &mut display.glyph_cache, + ); + display.renderer.draw_chrome_text( + &size, + row.rect.0 + s(38.0), + y, + if row.danger { danger } else { ink }, + row.label, + &mut display.glyph_cache, + ); + if !row.hint.is_empty() { + let hint_w = row.hint.chars().count() as f32 * size.cell_width(); + display.renderer.draw_chrome_text( + &size, + row.rect.0 + row.rect.2 - hint_w - s(10.0), + y, + dim, + row.hint, + &mut display.glyph_cache, + ); + } + } + if let Some((x, y)) = layout.color_label { + display.renderer.draw_chrome_text(&size, x, y, dim, "标签颜色", &mut display.glyph_cache); + } + if let Some((None, _, rect)) = layout.colors.first() { + display.renderer.draw_chrome_text( + &size, + rect.0 + (rect.2 - size.cell_width()) * 0.5, + rect.1 + (rect.3 - cell_h) * 0.5, + fade_ink( + Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 255), + sk.ink_on_accent, + progress, + ), + "A", + &mut display.glyph_cache, + ); + } + + if menu.animating() { + display.window.request_redraw(); + display.pending_update.dirty = true; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn size() -> SizeInfo { + SizeInfo::new(800.0, 600.0, 9.0, 18.0, 8.0, 8.0, false) + } + + #[test] + fn tab_menu_stays_inside_bottom_right_window_edge() { + let menu = ContextMenu::new(ContextMenuTarget::Tab(2), (796.0, 596.0), None); + let layout = layout(&menu, size(), 1.0, 0.0); + assert!(layout.panel.0 >= 8.0); + assert!(layout.panel.1 >= 8.0); + assert!(layout.panel.0 + layout.panel.2 <= 792.0); + assert!(layout.panel.1 + layout.panel.3 <= 592.0); + } + + #[test] + fn first_tab_row_and_auto_color_have_actions() { + let menu = ContextMenu::new(ContextMenuTarget::Tab(3), (100.0, 100.0), None); + let layout = layout(&menu, size(), 1.0, 0.0); + let first = layout.rows[0]; + assert_eq!( + hit_test( + &menu, + size(), + 1.0, + first.rect.0 + 2.0, + first.rect.1 + 2.0, + ), + ContextMenuHit::Action(ContextMenuAction::DuplicateTab(3)) + ); + assert_eq!(layout.colors.len(), 8); + assert_eq!(layout.colors[0].0, None); + assert_eq!( + layout.colors[0].1, + ContextMenuAction::SetTabColor { index: 3, color: None } + ); + } +} diff --git a/nebula_app/src/display/mod.rs b/nebula_app/src/display/mod.rs index 1af4de7..059592e 100644 --- a/nebula_app/src/display/mod.rs +++ b/nebula_app/src/display/mod.rs @@ -72,12 +72,14 @@ pub mod hint; pub mod window; mod chrome; +mod context_menu; pub mod command_palette; pub mod markdown_view; pub mod side_panel; pub(crate) use chrome::chrome_settings_button_rect; pub use chrome::{ChromeHit, TabDropAction, in_chrome_bar, resize_edge}; +pub use context_menu::{ContextMenuAction, ContextMenuHit, ContextMenuTarget}; use chrome::{ ChromeTabLayout, TabDrag, chrome_hit_with_tabs, chrome_tab_layout, contains_rect, truncate_tab_label, @@ -85,6 +87,7 @@ use chrome::{ mod settings; mod theme; +mod text_input; pub use theme::NebulaTheme; pub(crate) use theme::write_nebula_prompt_theme; @@ -425,6 +428,37 @@ pub enum NebulaConfirm { CloseWindow { process: String }, /// Paste text that contains newlines (would execute in most shells). Paste { text: String, bracketed: bool, lines: usize }, + /// Remove a saved host or hide an alias originating in `~/.ssh/config`. + /// The source flag lets the dialog explain that Nebula never edits the + /// user's SSH config file. + DeleteSsh { host: String, from_config: bool }, +} + +/// How long the destructive SSH action stays reversible in the in-app bar. +pub const SSH_DELETE_UNDO_DURATION: std::time::Duration = std::time::Duration::from_secs(8); + +/// Exact list state removed by one SSH deletion. Credential deletion is +/// deliberately delayed until this value is dropped at undo expiry: Undo can +/// therefore restore the complete action without retaining a password copy in +/// Nebula's memory. +#[derive(Debug)] +struct SshDeleteUndo { + host: String, + saved_index: Option, + pinned_index: Option, + was_hidden: bool, + from_config: bool, + started_at: std::time::Instant, + delete_credential_on_drop: bool, +} + +impl Drop for SshDeleteUndo { + fn drop(&mut self) { + #[cfg(windows)] + if self.delete_credential_on_drop { + let _ = crate::ssh_credentials::forget_password(&self.host); + } + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -447,11 +481,16 @@ pub enum SshEditorHit { #[derive(Debug, Clone)] pub struct SshHostEditor { + /// Destination before editing, when this modal was opened from a row. + /// Kept separately so changing an address can retire the old saved entry. + pub original_destination: Option, pub destination: String, pub password: String, pub save_password: bool, pub show_password: bool, pub field: SshEditorField, + destination_selection: text_input::SelectAllState, + password_selection: text_input::SelectAllState, } #[derive(Debug, Clone, Copy)] @@ -536,10 +575,10 @@ pub struct NebulaPaneState { /// `~/.ssh/config` aliases (file order), deduped, pinned entries floated to /// the top in pin order. One function so startup and settings hot-reload /// build the exact same list. -fn merge_ssh_hosts(saved: &[String], pinned: &[String]) -> Vec { - let mut hosts = saved.to_vec(); +fn merge_ssh_hosts(saved: &[String], pinned: &[String], hidden: &[String]) -> Vec { + let mut hosts: Vec<_> = saved.iter().filter(|host| !hidden.contains(host)).cloned().collect(); for host in crate::ssh::ssh_config_hosts() { - if !hosts.contains(&host) { + if !hidden.contains(&host) && !hosts.contains(&host) { hosts.push(host); } } @@ -548,6 +587,48 @@ fn merge_ssh_hosts(saved: &[String], pinned: &[String]) -> Vec { hosts } +/// Remove one destination while recording exactly enough list state for Undo. +/// Kept independent from rendering and Credential Manager so the destructive +/// state transition can be regression-tested without touching real secrets. +fn remove_ssh_host_from_lists( + host: &str, + saved: &mut Vec, + pinned: &mut Vec, + hidden: &mut Vec, +) -> (Option, Option, bool) { + let saved_index = saved.iter().position(|entry| entry == host); + let pinned_index = pinned.iter().position(|entry| entry == host); + let was_hidden = hidden.iter().any(|entry| entry == host); + saved.retain(|entry| entry != host); + pinned.retain(|entry| entry != host); + if !was_hidden { + hidden.push(host.to_owned()); + } + (saved_index, pinned_index, was_hidden) +} + +fn restore_ssh_host_to_lists( + host: &str, + saved_index: Option, + pinned_index: Option, + was_hidden: bool, + saved: &mut Vec, + pinned: &mut Vec, + hidden: &mut Vec, +) { + saved.retain(|entry| entry != host); + if let Some(index) = saved_index { + saved.insert(index.min(saved.len()), host.to_owned()); + } + pinned.retain(|entry| entry != host); + if let Some(index) = pinned_index { + pinned.insert(index.min(pinned.len()), host.to_owned()); + } + if !was_hidden { + hidden.retain(|entry| entry != host); + } +} + /// First word of a committed command line, normalized to a program identity /// for the sidebar icon: lowercased, path prefix and Windows launcher /// extensions stripped. `D:\tools\Claude.EXE --resume` → `claude`. @@ -1315,6 +1396,11 @@ pub struct Display { /// by `draw_confirm_modal` each frame so the mouse hit-test can never /// drift from what was actually drawn. `None` while no modal shows. pub nebula_confirm_buttons: Option<((f32, f32, f32, f32), (f32, f32, f32, f32))>, + /// Most recently deleted SSH host while its action is still reversible. + nebula_ssh_delete_undo: Option, + /// Undo button geometry published by the draw pass for exact hit-testing. + nebula_ssh_delete_undo_rect: Option<(f32, f32, f32, f32)>, + nebula_ssh_delete_undo_hover: bool, pub nebula_ssh_editor: Option, pub nebula_ssh_editor_rects: Option, nebula_ssh_editor_open: bool, @@ -1344,7 +1430,12 @@ pub struct Display { nebula_settings_section: NebulaSettingsSection, nebula_chrome_hover: ChromeHit, nebula_settings_hover: SettingsHit, + /// Unified native right-click menu shared by tab and SSH rows. The menu + /// owns its short open/close animation so no input path needs timers. + nebula_context_menu: Option, nebula_tab_labels: Vec, + /// Per-tab custom accent. `None` follows the live theme accent. + nebula_tab_colors: Vec>, nebula_tab_bells: Vec, /// Per-tab "command is running" flags driving the sidebar spinners. nebula_tab_running: Vec, @@ -1383,6 +1474,10 @@ pub struct Display { /// first, persisted. Merged into `nebula_ssh_hosts` after the pinned /// block, before the `~/.ssh/config` aliases. nebula_saved_hosts: Vec, + /// User-deleted SSH config aliases. Config files remain untouched; hiding + /// them here makes Delete stable instead of letting the next merge revive + /// the row immediately. + nebula_hidden_hosts: Vec, /// Accordion fold state of the two sidebar sections. nebula_tabs_section_open: bool, nebula_hosts_section_open: bool, @@ -1670,6 +1765,9 @@ impl Display { nebula_split_reveal: None, nebula_confirm: None, nebula_confirm_buttons: None, + nebula_ssh_delete_undo: None, + nebula_ssh_delete_undo_rect: None, + nebula_ssh_delete_undo_hover: false, nebula_ssh_editor: None, nebula_ssh_editor_rects: None, nebula_ssh_editor_open: false, @@ -1685,8 +1783,10 @@ impl Display { nebula_settings_section: NebulaSettingsSection::default(), nebula_chrome_hover: ChromeHit::None, nebula_settings_hover: SettingsHit::None, + nebula_context_menu: None, nebula_shell_picker_open: false, nebula_tab_labels: vec![".".to_owned()], + nebula_tab_colors: vec![None], nebula_tab_bells: vec![false], nebula_tab_running: vec![false], nebula_tab_logos: vec![None], @@ -1700,9 +1800,11 @@ impl Display { nebula_ssh_hosts: merge_ssh_hosts( &settings_init.saved_hosts, &settings_init.pinned_hosts, + &settings_init.hidden_hosts, ), nebula_pinned_hosts: settings_init.pinned_hosts.clone(), nebula_saved_hosts: settings_init.saved_hosts.clone(), + nebula_hidden_hosts: settings_init.hidden_hosts.clone(), nebula_tabs_section_open: true, nebula_hosts_section_open: true, nebula_tabs_scroll: 0, @@ -1739,6 +1841,7 @@ impl Display { } pub fn select_settings_section(&mut self, section: NebulaSettingsSection) { + self.close_shell_picker(); if self.nebula_settings_section != section { self.nebula_settings_section = section; // Each section starts reading from its top. @@ -1760,6 +1863,7 @@ impl Display { self.nebula_settings_section, self.nebula_shell_picker_open, self.nebula_detected_shells.as_ref().map_or(0, Vec::len), + self.nebula_hidden_hosts.len(), ); let next = (self.nebula_settings_scroll + delta).clamp(0.0, max); if (next - self.nebula_settings_scroll).abs() > f32::EPSILON { @@ -1777,8 +1881,15 @@ impl Display { self.nebula_detected_shells.as_ref().map_or(0, Vec::len) } + pub fn hidden_ssh_host_count(&self) -> usize { + self.nebula_hidden_hosts.len() + } + pub fn open_ssh_editor(&mut self) { self.nebula_ssh_editor = Some(SshHostEditor { + original_destination: None, + destination_selection: Default::default(), + password_selection: Default::default(), destination: String::new(), password: String::new(), save_password: true, @@ -1793,6 +1904,29 @@ impl Display { self.pending_update.dirty = true; } + pub fn edit_ssh_host(&mut self, index: usize) { + let Some(destination) = self.nebula_ssh_hosts.get(index).cloned() else { return }; + self.nebula_ssh_editor = Some(SshHostEditor { + original_destination: Some(destination.clone()), + destination_selection: Default::default(), + password_selection: Default::default(), + destination, + // Never pull a stored secret back into a text field. Leaving this + // blank preserves the existing credential when the address stays + // unchanged; typing a new value explicitly replaces it. + password: String::new(), + save_password: true, + show_password: false, + field: SshEditorField::Destination, + }); + self.nebula_ssh_editor_rects = None; + self.nebula_ssh_editor_open = true; + self.nebula_ssh_editor_hover = SshEditorHit::None; + self.nebula_ui_anims.ssh_editor = UiAnim::new(0.0); + self.pending_update.dirty = true; + self.window.request_redraw(); + } + pub fn ssh_editor_active(&self) -> bool { self.nebula_ssh_editor_open && self.nebula_ssh_editor.is_some() } @@ -1837,29 +1971,60 @@ impl Display { pub fn ssh_editor_insert(&mut self, text: &str) { if let Some(editor) = self.nebula_ssh_editor.as_mut() { - let field = match editor.field { - SshEditorField::Destination => &mut editor.destination, - SshEditorField::Password => &mut editor.password, - }; - field.extend(text.chars().filter(|c| !c.is_control())); + match editor.field { + SshEditorField::Destination => { + editor.destination_selection.insert(&mut editor.destination, text) + }, + SshEditorField::Password => { + editor.password_selection.insert(&mut editor.password, text) + }, + } } } pub fn ssh_editor_backspace(&mut self) { if let Some(editor) = self.nebula_ssh_editor.as_mut() { match editor.field { - SshEditorField::Destination => { - editor.destination.pop(); - }, + SshEditorField::Destination => editor + .destination_selection + .backspace(&mut editor.destination), SshEditorField::Password => { - editor.password.pop(); + editor.password_selection.backspace(&mut editor.password) }, } } } + pub fn ssh_editor_select_all(&mut self) { + if let Some(editor) = self.nebula_ssh_editor.as_mut() { + match editor.field { + SshEditorField::Destination => { + editor.destination_selection.select(&editor.destination) + }, + SshEditorField::Password => editor.password_selection.select(&editor.password), + } + } + } + + /// Copying an invisible password would persist a secret in the system + /// clipboard without visible intent, so it is enabled only after Reveal. + pub fn ssh_editor_selected_text(&self) -> Option { + let editor = self.nebula_ssh_editor.as_ref()?; + match editor.field { + SshEditorField::Destination => { + editor.destination_selection.selected_text(&editor.destination) + }, + SshEditorField::Password if editor.show_password => { + editor.password_selection.selected_text(&editor.password) + }, + SshEditorField::Password => None, + } + } + pub fn ssh_editor_next_field(&mut self) { if let Some(editor) = self.nebula_ssh_editor.as_mut() { + editor.destination_selection.clear(); + editor.password_selection.clear(); editor.field = match editor.field { SshEditorField::Destination => SshEditorField::Password, SshEditorField::Password => SshEditorField::Destination, @@ -1878,6 +2043,8 @@ impl Display { let hit = |r: (f32, f32, f32, f32)| x >= r.0 && x < r.0 + r.2 && y >= r.1 && y < r.1 + r.3; if hit(rects.destination) { if let Some(e) = self.nebula_ssh_editor.as_mut() { + e.destination_selection.clear(); + e.password_selection.clear(); e.field = SshEditorField::Destination; } return true; @@ -1890,6 +2057,8 @@ impl Display { } if hit(rects.password) { if let Some(e) = self.nebula_ssh_editor.as_mut() { + e.destination_selection.clear(); + e.password_selection.clear(); e.field = SshEditorField::Password; } return true; @@ -1920,11 +2089,30 @@ impl Display { self.nebula_ssh_editor = Some(editor); return; } + if let Some(original) = editor.original_destination.as_deref() { + if original != destination { + self.nebula_saved_hosts.retain(|host| host != original); + self.nebula_pinned_hosts.retain(|host| host != original); + if !self.nebula_hidden_hosts.iter().any(|host| host == original) { + self.nebula_hidden_hosts.push(original.to_owned()); + } + #[cfg(windows)] + { + let _ = crate::ssh_credentials::forget_password(original); + } + } + } + // Saving/editing is an explicit request to surface this destination, + // so it also undoes a previous Delete of the same address. + self.nebula_hidden_hosts.retain(|host| host != &destination); self.nebula_saved_hosts.retain(|host| host != &destination); self.nebula_saved_hosts.insert(0, destination.clone()); self.nebula_saved_hosts.truncate(20); - self.nebula_ssh_hosts = - merge_ssh_hosts(&self.nebula_saved_hosts, &self.nebula_pinned_hosts); + self.nebula_ssh_hosts = merge_ssh_hosts( + &self.nebula_saved_hosts, + &self.nebula_pinned_hosts, + &self.nebula_hidden_hosts, + ); if editor.save_password && !editor.password.is_empty() { #[cfg(windows)] { @@ -1941,23 +2129,124 @@ impl Display { self.close_ssh_editor(); } - pub fn delete_ssh_host(&mut self, index: usize) { + /// Ask before removing a saved destination. Config aliases use different + /// wording because Delete hides them inside Nebula and never edits + /// `~/.ssh/config` itself. + pub fn request_delete_ssh_host(&mut self, index: usize) { let Some(host) = self.nebula_ssh_hosts.get(index).cloned() else { return }; - self.nebula_saved_hosts.retain(|entry| entry != &host); - self.nebula_pinned_hosts.retain(|entry| entry != &host); - #[cfg(windows)] - { - let _ = crate::ssh_credentials::forget_password(&host); + let from_config = crate::ssh::ssh_config_hosts().contains(&host); + self.nebula_confirm = Some(NebulaConfirm::DeleteSsh { host, from_config }); + self.pending_update.dirty = true; + self.window.request_redraw(); + } + + /// Apply a confirmed deletion and arm a complete, credential-safe Undo. + /// Replacing an older Undo finalizes that older credential deletion first. + pub fn confirm_delete_ssh_host(&mut self, host: &str) -> bool { + if !self.nebula_ssh_hosts.iter().any(|entry| entry == host) { + return false; } - self.nebula_ssh_hosts = - merge_ssh_hosts(&self.nebula_saved_hosts, &self.nebula_pinned_hosts); + + let from_config = crate::ssh::ssh_config_hosts().iter().any(|entry| entry == host); + + // Taking the previous record commits its pending Credential Manager + // deletion through Drop. Only the most recent destructive action is + // reversible, matching standard snackbar Undo behavior. + self.nebula_ssh_delete_undo.take(); + + let (saved_index, pinned_index, was_hidden) = remove_ssh_host_from_lists( + host, + &mut self.nebula_saved_hosts, + &mut self.nebula_pinned_hosts, + &mut self.nebula_hidden_hosts, + ); + self.nebula_ssh_hosts = merge_ssh_hosts( + &self.nebula_saved_hosts, + &self.nebula_pinned_hosts, + &self.nebula_hidden_hosts, + ); + self.nebula_ssh_delete_undo = Some(SshDeleteUndo { + host: host.to_owned(), + saved_index, + pinned_index, + was_hidden, + from_config, + started_at: std::time::Instant::now(), + delete_credential_on_drop: true, + }); self.persist_nebula_settings(); self.pending_update.dirty = true; + self.window.request_redraw(); + true + } + + /// Reverse the complete host-list mutation. The credential was intentionally + /// kept alive during the grace period, so disarming Drop restores it without + /// ever copying secret bytes into the UI process. + pub fn undo_delete_ssh_host(&mut self) -> bool { + let Some(mut undo) = self.nebula_ssh_delete_undo.take() else { return false }; + if undo.started_at.elapsed() >= SSH_DELETE_UNDO_DURATION { + // Drop commits the pending credential deletion. + return false; + } + + undo.delete_credential_on_drop = false; + restore_ssh_host_to_lists( + &undo.host, + undo.saved_index, + undo.pinned_index, + undo.was_hidden, + &mut self.nebula_saved_hosts, + &mut self.nebula_pinned_hosts, + &mut self.nebula_hidden_hosts, + ); + self.nebula_ssh_hosts = merge_ssh_hosts( + &self.nebula_saved_hosts, + &self.nebula_pinned_hosts, + &self.nebula_hidden_hosts, + ); + self.nebula_ssh_delete_undo_rect = None; + self.nebula_ssh_delete_undo_hover = false; + self.persist_nebula_settings(); + self.pending_update.dirty = true; + self.window.request_redraw(); + true + } + + /// Commit the pending Credential Manager deletion when the Undo timer ends. + pub fn expire_ssh_delete_undo(&mut self) { + self.nebula_ssh_delete_undo.take(); + self.nebula_ssh_delete_undo_rect = None; + self.nebula_ssh_delete_undo_hover = false; + self.pending_update.dirty = true; + self.window.request_redraw(); + } + + pub fn ssh_delete_undo_available(&self) -> bool { + self.nebula_ssh_delete_undo + .as_ref() + .is_some_and(|undo| undo.started_at.elapsed() < SSH_DELETE_UNDO_DURATION) + } + + pub fn ssh_delete_undo_hit(&self, x: f32, y: f32) -> bool { + self.ssh_delete_undo_available() + && self.nebula_ssh_delete_undo_rect.is_some_and(|rect| { + x >= rect.0 && x < rect.0 + rect.2 && y >= rect.1 && y < rect.1 + rect.3 + }) + } + + pub fn set_ssh_delete_undo_hover(&mut self, hovered: bool) { + if self.nebula_ssh_delete_undo_hover != hovered { + self.nebula_ssh_delete_undo_hover = hovered; + self.pending_update.dirty = true; + self.window.request_redraw(); + } } pub fn set_chrome_tabs( &mut self, labels: Vec, + mut colors: Vec>, mut dots: Vec, mut running: Vec, mut logos: Vec>, @@ -1965,6 +2254,9 @@ impl Display { reorderable: bool, ) { self.nebula_tab_labels = if labels.is_empty() { vec![".".to_owned()] } else { labels }; + colors.truncate(self.nebula_tab_labels.len()); + colors.resize(self.nebula_tab_labels.len(), None); + self.nebula_tab_colors = colors; dots.truncate(self.nebula_tab_labels.len()); dots.resize(self.nebula_tab_labels.len(), false); self.nebula_tab_bells = dots; @@ -2231,6 +2523,81 @@ impl Display { } } + pub fn context_menu_interactive(&self) -> bool { + self.nebula_context_menu.as_ref().is_some_and(context_menu::ContextMenu::interactive) + } + + pub fn open_tab_context_menu(&mut self, index: usize, x: f32, y: f32) { + if index >= self.nebula_tab_labels.len() { + return; + } + let color = self.nebula_tab_colors.get(index).copied().flatten(); + self.nebula_context_menu = Some(context_menu::ContextMenu::new( + ContextMenuTarget::Tab(index), + (x, y), + color, + )); + self.nebula_tab_drag = None; + self.pending_update.dirty = true; + self.window.request_redraw(); + } + + pub fn open_ssh_context_menu(&mut self, index: usize, x: f32, y: f32) { + if index >= self.nebula_ssh_hosts.len() { + return; + } + self.nebula_context_menu = Some(context_menu::ContextMenu::new( + ContextMenuTarget::Ssh(index), + (x, y), + None, + )); + self.pending_update.dirty = true; + self.window.request_redraw(); + } + + pub fn context_menu_hit(&self, x: f32, y: f32) -> ContextMenuHit { + self.nebula_context_menu.as_ref().map_or(ContextMenuHit::Outside, |menu| { + context_menu::hit_test( + menu, + self.size_info, + self.window.scale_factor as f32, + x, + y, + ) + }) + } + + pub fn context_menu_hover(&mut self, x: f32, y: f32) -> ContextMenuHit { + let hit = self.context_menu_hit(x, y); + let action = match hit { + ContextMenuHit::Action(action) => Some(action), + ContextMenuHit::Outside | ContextMenuHit::Panel => None, + }; + if self.nebula_context_menu.as_mut().is_some_and(|menu| menu.set_hover(action)) { + self.pending_update.dirty = true; + self.window.request_redraw(); + } + hit + } + + /// Resolve one menu click and start the close animation. A click inside + /// the panel but between targets is swallowed without dismissing it. + pub fn context_menu_click(&mut self, x: f32, y: f32) -> ContextMenuHit { + let hit = self.context_menu_hit(x, y); + if matches!(hit, ContextMenuHit::Action(_) | ContextMenuHit::Outside) { + self.close_context_menu(); + } + hit + } + + pub fn close_context_menu(&mut self) { + if let Some(menu) = self.nebula_context_menu.as_mut() { + menu.begin_close(); + self.pending_update.dirty = true; + self.window.request_redraw(); + } + } + pub fn chrome_hit(&self, x: f32, y: f32) -> ChromeHit { chrome_hit_with_tabs( &self.size_info, @@ -2287,6 +2654,7 @@ impl Display { }) .unwrap_or_default(), shell_id: self.nebula_shell_id.clone(), + hidden_hosts: self.nebula_hidden_hosts.clone(), fetch: self.nebula_fetch_enabled, powerline: self.nebula_powerline_enabled, keep_session: self.nebula_keep_session, @@ -2300,6 +2668,9 @@ impl Display { pub fn toggle_settings(&mut self) { self.nebula_settings_open = !self.nebula_settings_open; + if !self.nebula_settings_open { + self.nebula_shell_picker_open = false; + } // Fresh open always starts at the top. self.nebula_settings_scroll = 0.0; self.pending_update.dirty = true; @@ -2308,6 +2679,7 @@ impl Display { pub fn dismiss_settings(&mut self) { if self.nebula_settings_open { self.nebula_settings_open = false; + self.nebula_shell_picker_open = false; self.pending_update.dirty = true; } } @@ -2353,6 +2725,14 @@ impl Display { self.pending_update.dirty = true; } + pub fn close_shell_picker(&mut self) { + if self.nebula_shell_picker_open { + self.nebula_shell_picker_open = false; + self.pending_update.dirty = true; + self.window.request_redraw(); + } + } + /// WT-style default-shell picker (command palette mode). Kept for compatibility. /// detected-shell dropdown as the "+" chevron, but confirming SETS the /// default instead of launching a tab. Replaces the old 2-value cycle. @@ -2386,6 +2766,34 @@ impl Display { self.pending_update.dirty = true; } + /// Restore a destination after the short Undo period. Config aliases only + /// need to leave `hidden_hosts`; manually saved addresses are re-added to + /// the saved list. Expired credentials intentionally remain deleted. + pub fn restore_hidden_ssh_host(&mut self, index: usize) { + let Some(host) = self.nebula_hidden_hosts.get(index).cloned() else { return }; + let pending_same_host = self + .nebula_ssh_delete_undo + .as_ref() + .is_some_and(|undo| undo.host == host); + if pending_same_host && self.undo_delete_ssh_host() { + return; + } + + self.nebula_hidden_hosts.retain(|entry| entry != &host); + let from_config = crate::ssh::ssh_config_hosts().iter().any(|entry| entry == &host); + if !from_config && !self.nebula_saved_hosts.iter().any(|entry| entry == &host) { + self.nebula_saved_hosts.insert(0, host); + } + self.nebula_ssh_hosts = merge_ssh_hosts( + &self.nebula_saved_hosts, + &self.nebula_pinned_hosts, + &self.nebula_hidden_hosts, + ); + self.persist_nebula_settings(); + self.pending_update.dirty = true; + self.window.request_redraw(); + } + pub fn toggle_fetch(&mut self) { self.nebula_fetch_enabled = !self.nebula_fetch_enabled; self.persist_nebula_settings(); @@ -2504,6 +2912,10 @@ impl Display { self.nebula_palette.is_picking_default() } + pub fn command_palette_picker_open(&self) -> bool { + self.nebula_palette.is_picker() + } + pub fn close_command_palette(&mut self) { self.nebula_palette.close(); self.pending_update.dirty = true; @@ -2514,6 +2926,20 @@ impl Display { self.pending_update.dirty = true; } + pub fn palette_input_text(&mut self, text: &str) { + self.nebula_palette.input_text(text); + self.pending_update.dirty = true; + } + + pub fn palette_select_all(&mut self) { + self.nebula_palette.select_all(); + self.pending_update.dirty = true; + } + + pub fn palette_selected_text(&self) -> Option { + self.nebula_palette.selected_text() + } + pub fn palette_backspace(&mut self) { self.nebula_palette.backspace(); self.pending_update.dirty = true; @@ -2576,6 +3002,10 @@ impl Display { /// Insert `text` at the caret. A pending select-all is replaced wholesale /// (type-to-overwrite), matching every native text field. pub fn tab_rename_insert(&mut self, text: &str) { + let text: String = text.chars().filter(|character| !character.is_control()).collect(); + if text.is_empty() { + return; + } let select_all = self.nebula_tab_rename_select_all; let caret = self.nebula_tab_rename_caret; let Some((_, buf)) = self.nebula_tab_rename.as_mut() else { return }; @@ -2586,7 +3016,7 @@ impl Display { } let caret = if select_all { 0 } else { caret.min(buf.chars().count()) }; let byte = buf.char_indices().nth(caret).map(|(b, _)| b).unwrap_or(buf.len()); - buf.insert_str(byte, text); + buf.insert_str(byte, &text); self.nebula_tab_rename_caret = caret + text.chars().count(); self.pending_update.dirty = true; } @@ -2610,6 +3040,20 @@ impl Display { self.pending_update.dirty = true; } + pub fn tab_rename_select_all(&mut self) { + if let Some((_, text)) = self.nebula_tab_rename.as_ref() { + self.nebula_tab_rename_select_all = !text.is_empty(); + self.nebula_tab_rename_caret = text.chars().count(); + self.pending_update.dirty = true; + } + } + + pub fn tab_rename_selected_text(&self) -> Option { + self.nebula_tab_rename_select_all + .then(|| self.nebula_tab_rename.as_ref().map(|(_, text)| text.clone())) + .flatten() + } + /// Move the caret by `delta` chars. A select-all collapses to the matching /// end first (left → start, right → end) without moving further. pub fn tab_rename_move_caret(&mut self, delta: i32) { @@ -2789,24 +3233,6 @@ impl Display { true } - /// Pin the host at `index` to the top of the SSH HOSTS section - /// (right-click). Pinning an already-pinned host unpins it. The pinned - /// set persists in the runtime settings file. - pub fn pin_host(&mut self, index: usize) { - let Some(name) = self.nebula_ssh_hosts.get(index).cloned() else { return }; - if let Some(pos) = self.nebula_pinned_hosts.iter().position(|p| *p == name) { - self.nebula_pinned_hosts.remove(pos); - } else { - self.nebula_pinned_hosts.insert(0, name); - } - // Re-sort: pinned first (in pin order), the rest keep config order. - let pinned = self.nebula_pinned_hosts.clone(); - self.nebula_ssh_hosts - .sort_by_key(|h| pinned.iter().position(|p| p == h).unwrap_or(usize::MAX)); - self.persist_nebula_settings(); - self.pending_update.dirty = true; - } - /// Auto-save an SSH destination the user typed and successfully connected /// to — armed at OSC 133;C, confirmed by a remote `NEBULA|` title or a /// session that outlived [`crate::ssh::SAVE_MIN_SESSION`]. Tabby-style @@ -2819,6 +3245,7 @@ impl Display { return; } self.nebula_saved_hosts.retain(|h| h != host); + self.nebula_hidden_hosts.retain(|h| h != host); self.nebula_saved_hosts.insert(0, host.to_owned()); self.nebula_saved_hosts.truncate(SAVED_HOSTS_CAP); if !self.nebula_ssh_hosts.iter().any(|h| h == host) { @@ -2850,6 +3277,7 @@ impl Display { theme: self.nebula_theme, pinned_hosts: self.nebula_pinned_hosts.clone(), saved_hosts: self.nebula_saved_hosts.clone(), + hidden_hosts: self.nebula_hidden_hosts.clone(), }); self.nebula_settings_mtime = settings::nebula_settings_mtime(); } @@ -2886,8 +3314,12 @@ impl Display { // host that window just saved or pinned. self.nebula_pinned_hosts = settings.pinned_hosts; self.nebula_saved_hosts = settings.saved_hosts; - self.nebula_ssh_hosts = - merge_ssh_hosts(&self.nebula_saved_hosts, &self.nebula_pinned_hosts); + self.nebula_hidden_hosts = settings.hidden_hosts; + self.nebula_ssh_hosts = merge_ssh_hosts( + &self.nebula_saved_hosts, + &self.nebula_pinned_hosts, + &self.nebula_hidden_hosts, + ); if image_changed { self.renderer.invalidate_background_image(); } @@ -3967,6 +4399,25 @@ impl Display { "粘贴 Enter", false, ), + NebulaConfirm::DeleteSsh { host, from_config } => { + let host = truncate_tab_label(host, 28); + if *from_config { + ( + format!("隐藏 SSH 主机 {host}?"), + "只从 Nebula 隐藏;~/.ssh/config 不会修改,保存的密码将在撤销期后清除。" + .to_owned(), + "隐藏 Enter", + true, + ) + } else { + ( + format!("删除 SSH 主机 {host}?"), + "会从主机列表移除,保存的 Windows 密码将在撤销期后清除。".to_owned(), + "删除 Enter", + true, + ) + } + }, }; let cancel_label = "取消 Esc"; @@ -4246,22 +4697,44 @@ impl Display { if sk.is_light { sk.hover } else { sk.hover_strong }, )); } - let (caret_rect, caret_cols) = match editor.field { - SshEditorField::Destination => (destination, editor.destination.chars().count()), - SshEditorField::Password => (password, editor.password.chars().count()), + let (caret_rect, caret_cols, all_selected) = match editor.field { + SshEditorField::Destination => ( + destination, + editor.destination.chars().count(), + editor.destination_selection.is_selected(), + ), + SshEditorField::Password => ( + password, + editor.password.chars().count(), + editor.password_selection.is_selected(), + ), }; - let caret_x = (caret_rect.0 + s(12.0) + caret_cols as f32 * cell_w).min( - caret_rect.0 + caret_rect.2 - - if editor.field == SshEditorField::Password { s(48.0) } else { s(10.0) }, - ); - quads.push(UiQuad::solid( - caret_x, - caret_rect.1 + s(10.0), - s(1.5).max(1.0), - caret_rect.3 - s(20.0), - 0.0, - editor_caret, - )); + if all_selected && caret_cols > 0 { + let right_pad = if editor.field == SshEditorField::Password { s(48.0) } else { s(10.0) }; + let selection_w = (caret_cols as f32 * cell_w) + .min(caret_rect.2 - s(12.0) - right_pad); + quads.push(UiQuad::solid( + caret_rect.0 + s(10.0), + caret_rect.1 + s(7.0), + selection_w + s(4.0), + caret_rect.3 - s(14.0), + s(4.0), + sk.accent_soft, + )); + } else { + let caret_x = (caret_rect.0 + s(12.0) + caret_cols as f32 * cell_w).min( + caret_rect.0 + caret_rect.2 + - if editor.field == SshEditorField::Password { s(48.0) } else { s(10.0) }, + ); + quads.push(UiQuad::solid( + caret_x, + caret_rect.1 + s(10.0), + s(1.5).max(1.0), + caret_rect.3 - s(20.0), + 0.0, + editor_caret, + )); + } self.renderer.draw_ui(&size, &quads); let gc = &mut self.glyph_cache; @@ -4377,6 +4850,132 @@ impl Display { } } + /// Bottom-center reversible-action bar for SSH deletion. Its action rect is + /// published to input after layout, keeping hover/click geometry identical + /// to the pixels on screen. + fn draw_ssh_delete_undo(&mut self) { + let Some(undo) = self.nebula_ssh_delete_undo.as_ref() else { + self.nebula_ssh_delete_undo_rect = None; + self.nebula_ssh_delete_undo_hover = false; + return; + }; + if undo.started_at.elapsed() >= SSH_DELETE_UNDO_DURATION { + self.expire_ssh_delete_undo(); + return; + } + + let size = self.size_info; + let scale = self.window.scale_factor as f32; + let s = |value: f32| value * scale; + let cell_w = size.cell_width(); + let cell_h = size.cell_height(); + let sk = self.nebula_theme.skin(); + + let fixed_cols = 20usize; + let host_budget = (((size.width() - s(300.0)).max(cell_w * 8.0) / cell_w) as usize) + .saturating_sub(fixed_cols) + .max(8); + let host = truncate_tab_label(&undo.host, host_budget.min(28)); + let message = if undo.from_config { + format!("已隐藏 {host}(SSH config 未修改)") + } else { + format!("已移除 {host}") + }; + let hint = "Ctrl+Z"; + let action = "撤销"; + let text_cols = |text: &str| -> usize { + text.chars().map(|ch| ch.width().unwrap_or(1).max(1)).sum() + }; + + let pad = s(14.0); + let gap = s(12.0); + let action_w = s(76.0); + let bar_h = s(48.0).max(cell_h + s(12.0)); + let content_w = (text_cols(&message) + text_cols(hint) + 2) as f32 * cell_w; + let bar_w = (pad * 2.0 + content_w + gap + action_w) + .max(s(360.0)) + .min(size.width() - s(24.0)); + let bar_x = (size.width() - bar_w) * 0.5; + let bar_y = size.height() - bar_h - s(18.0); + let action_rect = ( + bar_x + bar_w - pad - action_w, + bar_y + (bar_h - s(34.0)) * 0.5, + action_w, + s(34.0), + ); + self.nebula_ssh_delete_undo_rect = Some(action_rect); + + let mut quads = vec![ + UiQuad::glow( + bar_x - s(10.0), + bar_y - s(8.0), + bar_w + s(20.0), + bar_h + s(16.0), + Rgba::new(0, 0, 0, 72), + ), + UiQuad::solid( + bar_x - s(1.0), + bar_y - s(1.0), + bar_w + s(2.0), + bar_h + s(2.0), + s(11.0), + sk.hairline, + ), + UiQuad::solid(bar_x, bar_y, bar_w, bar_h, s(10.0), sk.panel), + UiQuad::solid( + action_rect.0, + action_rect.1, + action_rect.2, + action_rect.3, + s(7.0), + if self.nebula_ssh_delete_undo_hover { sk.hover_strong } else { sk.surface }, + ), + ]; + if self.nebula_ssh_delete_undo_hover { + quads.push(UiQuad::solid( + action_rect.0, + action_rect.1 + action_rect.3 - s(2.0), + action_rect.2, + s(2.0), + s(1.0), + Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 220), + )); + } + self.renderer.draw_ui(&size, &quads); + + let text_y = bar_y + (bar_h - cell_h) * 0.5; + let message_x = bar_x + pad; + self.renderer.draw_chrome_text( + &size, + message_x, + text_y, + sk.ink, + &message, + &mut self.glyph_cache, + ); + let hint_x = action_rect.0 - gap - text_cols(hint) as f32 * cell_w; + self.renderer.draw_chrome_text( + &size, + hint_x, + text_y, + sk.ink_faint, + hint, + &mut self.glyph_cache, + ); + let action_x = + action_rect.0 + (action_rect.2 - text_cols(action) as f32 * cell_w) * 0.5; + let action_y = action_rect.1 + (action_rect.3 - cell_h) * 0.5; + self.renderer.draw_chrome_text_styled( + &size, + action_x, + action_y, + if self.nebula_ssh_delete_undo_hover { sk.ink_strong } else { sk.accent }, + nebula_terminal::term::cell::Flags::BOLD, + action, + &mut self.glyph_cache, + ); + } + /// Draw the window chrome and present the accumulated frame. /// Overlay a transient, fading "cols × rows" HUD centered in the window, /// shown briefly after a resize (a resize overlay HUD). Keeps requesting @@ -4457,6 +5056,8 @@ impl Display { // Transient resize HUD painted on top of the chrome. self.draw_resize_hud(); + context_menu::draw(self); + self.draw_ssh_delete_undo(); self.draw_ssh_editor_modal(); self.draw_confirm_modal(); @@ -5624,3 +6225,64 @@ fn window_size( PhysicalSize::new(width as u32, height as u32) } + +#[cfg(test)] +mod nebula_ux_tests { + use super::{remove_ssh_host_from_lists, restore_ssh_host_to_lists}; + + fn strings(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_owned()).collect() + } + + #[test] + fn ssh_delete_undo_restores_saved_and_pinned_order() { + let mut saved = strings(&["alpha", "target", "omega"]); + let mut pinned = strings(&["target", "alpha"]); + let mut hidden = strings(&["already-hidden"]); + + let snapshot = + remove_ssh_host_from_lists("target", &mut saved, &mut pinned, &mut hidden); + assert_eq!(snapshot, (Some(1), Some(0), false)); + assert_eq!(saved, strings(&["alpha", "omega"])); + assert_eq!(pinned, strings(&["alpha"])); + assert_eq!(hidden, strings(&["already-hidden", "target"])); + + restore_ssh_host_to_lists( + "target", + snapshot.0, + snapshot.1, + snapshot.2, + &mut saved, + &mut pinned, + &mut hidden, + ); + assert_eq!(saved, strings(&["alpha", "target", "omega"])); + assert_eq!(pinned, strings(&["target", "alpha"])); + assert_eq!(hidden, strings(&["already-hidden"])); + } + + #[test] + fn ssh_config_only_hide_is_fully_reversible() { + let mut saved = Vec::new(); + let mut pinned = Vec::new(); + let mut hidden = Vec::new(); + + let snapshot = + remove_ssh_host_from_lists("config-alias", &mut saved, &mut pinned, &mut hidden); + assert_eq!(snapshot, (None, None, false)); + assert_eq!(hidden, strings(&["config-alias"])); + + restore_ssh_host_to_lists( + "config-alias", + snapshot.0, + snapshot.1, + snapshot.2, + &mut saved, + &mut pinned, + &mut hidden, + ); + assert!(saved.is_empty()); + assert!(pinned.is_empty()); + assert!(hidden.is_empty()); + } +} diff --git a/nebula_app/src/display/settings.rs b/nebula_app/src/display/settings.rs index 65206ee..a135663 100644 --- a/nebula_app/src/display/settings.rs +++ b/nebula_app/src/display/settings.rs @@ -93,6 +93,8 @@ pub enum SettingsHit { ShellCycle, /// One of the expanded shell picker rows (index into detected_shells). ShellPickerRow(usize), + /// Restore one address from the persistent hidden-host list. + RestoreHiddenSsh(usize), FetchToggle, PowerlineToggle, OpacityDown, @@ -135,6 +137,10 @@ pub(super) struct NebulaRuntimeSettings { /// SSH destinations auto-saved after a successful typed `ssh` connection, /// most recent first (see `Display::nebula_save_ssh_host`). pub(super) saved_hosts: Vec, + /// SSH aliases explicitly removed from the sidebar. This is separate from + /// `saved_hosts` because entries discovered in `~/.ssh/config` would + /// otherwise reappear on the very next merge. + pub(super) hidden_hosts: Vec, } /// Load runtime UI settings from `Nebula/nebula_settings.txt`; defaults when @@ -163,6 +169,7 @@ pub(super) fn nebula_settings_load(config: &UiConfig) -> NebulaRuntimeSettings { theme: NebulaTheme::default(), pinned_hosts: Vec::new(), saved_hosts: Vec::new(), + hidden_hosts: Vec::new(), }; if let Ok(data) = std::fs::read_to_string(path) { for line in data.lines() { @@ -223,6 +230,14 @@ pub(super) fn nebula_settings_load(config: &UiConfig) -> NebulaRuntimeSettings { .map(str::to_owned) .collect(); }, + Some(("hidden_hosts", v)) => { + settings.hidden_hosts = v + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .collect(); + }, _ => {}, } } @@ -276,10 +291,11 @@ pub(super) fn nebula_settings_write(settings: &NebulaRuntimeSettings) { let path = nebula_data_dir().join("nebula_settings.txt"); let pinned_hosts = settings.pinned_hosts.join(","); let saved_hosts = settings.saved_hosts.join(","); + let hidden_hosts = settings.hidden_hosts.join(","); let _ = std::fs::write( path, format!( - "theme={theme}\nghost={}\naccept={accept}\nshell={shell}\nfetch={}\npowerline={}\nkeep_session={}\nopacity={:.2}\nbackground={background}\nbackground_image={background_image}\nbackground_image_opacity={:.2}\npinned_hosts={pinned_hosts}\nsaved_hosts={saved_hosts}\n", + "theme={theme}\nghost={}\naccept={accept}\nshell={shell}\nfetch={}\npowerline={}\nkeep_session={}\nopacity={:.2}\nbackground={background}\nbackground_image={background_image}\nbackground_image_opacity={:.2}\npinned_hosts={pinned_hosts}\nsaved_hosts={saved_hosts}\nhidden_hosts={hidden_hosts}\n", settings.ghost as u8, settings.fetch as u8, settings.powerline as u8, @@ -308,6 +324,8 @@ struct SettingsGeometry { ghost: (f32, f32, f32, f32), accept: (f32, f32, f32, f32), open_config_file: (f32, f32, f32, f32), + hidden_host_row0: (f32, f32, f32, f32), + hidden_host_count: usize, /// Full-width "窗口透明度" row (the frame); the stepper buttons below sit /// inside it. opacity_row: (f32, f32, f32, f32), @@ -344,9 +362,16 @@ pub(super) fn settings_max_scroll( section: NebulaSettingsSection, shell_picker_open: bool, shell_picker_count: usize, + hidden_host_count: usize, ) -> f32 { - let geometry = - settings_geometry(size_info, scale_factor, 0.0, shell_picker_open, shell_picker_count); + let geometry = settings_geometry( + size_info, + scale_factor, + 0.0, + shell_picker_open, + shell_picker_count, + hidden_host_count, + ); let (_, _, _, ph) = geometry.popup; let content_h = match section { NebulaSettingsSection::Appearance => geometry.appearance_h, @@ -363,6 +388,7 @@ fn settings_geometry( scroll: f32, shell_picker_open: bool, shell_picker_count: usize, + hidden_host_count: usize, ) -> SettingsGeometry { let s = |v: f32| v * scale_factor; let w = size_info.width(); @@ -445,7 +471,13 @@ fn settings_geometry( let picker_extra = if shell_picker_open { shell_picker_count as f32 * ROW_H } else { 0.0 }; let ghost_y0 = shell_y0 + 3.0 * ROW_H + picker_extra + GROUP_ADVANCE; let open_y0 = ghost_y0 + 2.0 * ROW_H + GROUP_ADVANCE; - let profiles_h = s(open_y0 + ROW_H + 32.0 - 72.0); + let hidden_y0 = open_y0 + ROW_H + GROUP_ADVANCE; + let profiles_end = if hidden_host_count == 0 { + open_y0 + ROW_H + } else { + hidden_y0 + hidden_host_count as f32 * ROW_H + }; + let profiles_h = s(profiles_end + 32.0 - 72.0); // Keymap: one contiguous group of read-only shortcut rows. let keymap_y0 = 146.0; @@ -488,6 +520,8 @@ fn settings_geometry( ghost: (row_x, at(ghost_y0), row_w, row_h), accept: (row_x, at(ghost_y0 + ROW_H), row_w, row_h), open_config_file: (row_x, at(open_y0), row_w, row_h), + hidden_host_row0: (row_x, at(hidden_y0), row_w, row_h), + hidden_host_count, reset: (popup_x + popup_w - s(170.0), popup_y + s(24.0), s(150.0), s(42.0)), content_top, appearance_h, @@ -513,9 +547,16 @@ pub fn settings_hit( scroll: f32, shell_picker_open: bool, shell_picker_count: usize, + hidden_host_count: usize, ) -> SettingsHit { - let geometry = - settings_geometry(size_info, scale_factor, scroll, shell_picker_open, shell_picker_count); + let geometry = settings_geometry( + size_info, + scale_factor, + scroll, + shell_picker_open, + shell_picker_count, + hidden_host_count, + ); if contains_rect(geometry.gear, x, y) { return SettingsHit::Toggle; @@ -594,6 +635,13 @@ pub fn settings_hit( if contains_rect(geometry.open_config_file, x, y) { return SettingsHit::OpenConfigFile; } + let (row_x, row_y, row_w, row_h) = geometry.hidden_host_row0; + for index in 0..geometry.hidden_host_count { + let rect = (row_x, row_y + index as f32 * row_h, row_w, row_h); + if contains_rect(rect, x, y) { + return SettingsHit::RestoreHiddenSsh(index); + } + } }, NebulaSettingsSection::Keymap => {}, NebulaSettingsSection::Advanced => { @@ -626,6 +674,9 @@ pub(super) struct SettingsView { /// Detected shells for the picker (cached once per process). pub(super) shells: Vec<(String, String, String)>, // (id, name, program) pub(super) shell_id: Option, + /// Persistent soft-deleted destinations. Rows provide a discoverable + /// recovery path after the short Undo bar has expired. + pub(super) hidden_hosts: Vec, pub(super) fetch: bool, pub(super) powerline: bool, pub(super) keep_session: bool, @@ -652,8 +703,14 @@ pub(super) fn push_quads( let h = size.height(); let sk = view.theme.skin(); - let geometry = - settings_geometry(size, scale, view.scroll, view.shell_picker_open, view.shells.len()); + let geometry = settings_geometry( + size, + scale, + view.scroll, + view.shell_picker_open, + view.shells.len(), + view.hidden_hosts.len(), + ); let (px, py, pw, ph) = geometry.popup; // Header band height: the title row sits above the content, and the header // separator + big title are all measured from here. @@ -991,6 +1048,18 @@ pub(super) fn push_quads( } group_frame(quads, geometry.ghost, 2); group_frame(quads, geometry.open_config_file, 1); + if geometry.hidden_host_count > 0 { + group_frame(quads, geometry.hidden_host_row0, geometry.hidden_host_count); + for index in 0..geometry.hidden_host_count { + let mut rect = geometry.hidden_host_row0; + rect.1 += index as f32 * rect.3; + row_hover( + quads, + rect, + view.hover == SettingsHit::RestoreHiddenSsh(index), + ); + } + } for (hit, rect) in [ (SettingsHit::ShellCycle, geometry.shell), (SettingsHit::FetchToggle, geometry.fetch), @@ -1127,8 +1196,14 @@ pub(super) fn draw_text( let cell_h = size.cell_height(); let sk = view.theme.skin(); - let geometry = - settings_geometry(size, scale, view.scroll, view.shell_picker_open, view.shells.len()); + let geometry = settings_geometry( + size, + scale, + view.scroll, + view.shell_picker_open, + view.shells.len(), + view.hidden_hosts.len(), + ); let mut icon_draws = Vec::new(); let (px, py, _pw, ph) = geometry.popup; let (content_x, content_y, _content_w, _) = geometry.content; @@ -1428,6 +1503,28 @@ pub(super) fn draw_text( gc, ); } + + if geometry.hidden_host_count > 0 { + let (hx, hy, hw, hh) = geometry.hidden_host_row0; + if visible(group_y(hy), title_h) { + section_title( + r, + gc, + size, + scale, + &sk, + hx, + group_y(hy), + "已隐藏 SSH 主机 · 密码不会恢复", + ); + } + for (index, host) in view.hidden_hosts.iter().enumerate() { + let rect = (hx, hy + index as f32 * hh, hw, hh); + if visible(rect.1, rect.3) { + row_label(r, gc, size, scale, &sk, rect, host, "恢复", sk.accent); + } + } + } }, NebulaSettingsSection::Keymap => { let (kx, ky, kw, kh) = geometry.keymap_row0; diff --git a/nebula_app/src/display/side_panel.rs b/nebula_app/src/display/side_panel.rs index 215521a..9ae6f92 100644 --- a/nebula_app/src/display/side_panel.rs +++ b/nebula_app/src/display/side_panel.rs @@ -14,6 +14,7 @@ use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; +use unicode_width::UnicodeWidthChar; /// Which view the drawer shows. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -117,6 +118,7 @@ pub struct SidePanel { pub search: String, /// 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 @@ -126,6 +128,7 @@ pub struct SidePanel { /// contract as the Files filter box. pub commit_msg: String, pub commit_focus: bool, + commit_selection: super::text_input::SelectAllState, /// Last clicked file row (path + when), for double-click-to-open. pub last_file_click: Option<(PathBuf, Instant)>, /// In-progress drag of a file row toward the terminal. @@ -161,9 +164,11 @@ impl SidePanel { scroll: 0, search: String::new(), search_focus: false, + search_selection: Default::default(), search_index: None, commit_msg: String::new(), commit_focus: false, + commit_selection: Default::default(), last_file_click: None, drag_file: None, selected: None, @@ -268,22 +273,33 @@ impl SidePanel { /// Append typed text to the filter query and re-derive the rows. pub fn search_input(&mut self, text: &str) { - for c in text.chars().filter(|c| !c.is_control()) { - self.search.push(c); - } + self.search_selection.insert(&mut self.search, text); self.scroll = 0; self.rebuild_rows(); } pub fn search_backspace(&mut self) { - self.search.pop(); + self.search_selection.backspace(&mut self.search); self.scroll = 0; self.rebuild_rows(); } + pub fn search_select_all(&mut self) { + self.search_selection.select(&self.search); + } + + pub fn search_selected_text(&self) -> Option { + self.search_selection.selected_text(&self.search) + } + + pub fn search_all_selected(&self) -> bool { + self.search_selection.is_selected() + } + /// Leave the filter box; `clear` also resets the query (Esc). pub fn search_unfocus(&mut self, clear: bool) { self.search_focus = false; + self.search_selection.clear(); if clear && !self.search.is_empty() { self.search.clear(); self.scroll = 0; @@ -356,9 +372,41 @@ impl SidePanel { pub fn git_begin_commit(&mut self) { if self.git.as_ref().is_some_and(|g| !g.staged.is_empty()) && !self.op_running() { self.commit_focus = true; + self.commit_selection.clear(); } } + pub fn commit_input(&mut self, text: &str) { + self.commit_selection.insert(&mut self.commit_msg, text); + } + + pub fn commit_backspace(&mut self) { + self.commit_selection.backspace(&mut self.commit_msg); + } + + pub fn commit_select_all(&mut self) { + self.commit_selection.select(&self.commit_msg); + } + + pub fn commit_selected_text(&self) -> Option { + self.commit_selection.selected_text(&self.commit_msg) + } + + pub fn commit_all_selected(&self) -> bool { + self.commit_selection.is_selected() + } + + pub fn commit_cancel(&mut self) { + self.commit_focus = false; + self.commit_msg.clear(); + self.commit_selection.clear(); + } + + pub fn commit_unfocus(&mut self) { + self.commit_focus = false; + self.commit_selection.clear(); + } + /// Enter in the message box: run `git commit -m `. pub fn git_commit_submit(&mut self) { let msg = self.commit_msg.trim().to_string(); @@ -367,6 +415,7 @@ impl SidePanel { } self.commit_focus = false; self.commit_msg.clear(); + self.commit_selection.clear(); self.spawn_git(vec!["commit".into(), "-m".into(), msg]); } @@ -752,6 +801,7 @@ pub(super) fn push_quads( theme: &NebulaTheme, quads: &mut Vec, scale: f32, + cell_w: f32, ) { let s = |v: f32| v * scale; let palette = theme.palette(); @@ -845,6 +895,19 @@ pub(super) fn push_quads( )); } quads.push(UiQuad::solid(sx, sy, sw, sh, radius, sk.input)); + if panel.search_all_selected() && !panel.search.is_empty() { + let columns: usize = panel.search.chars().map(|c| c.width().unwrap_or(0)).sum(); + let selection_x = sx + s(8.0) + cell_w * 1.8; + let selection_w = (columns as f32 * cell_w).min(sw - (selection_x - sx) - s(8.0)); + quads.push(UiQuad::solid( + selection_x - s(2.0), + sy + s(6.0), + selection_w + s(4.0), + sh - s(12.0), + s(4.0), + sk.accent_soft, + )); + } // The selected file row wears the tab's floating-pill language: an // accent halo + the tab 底色 + a soft accent wash — the same treatment @@ -923,6 +986,19 @@ pub(super) fn push_quads( Rgba::new(a.r, a.g, a.b, 200), )); quads.push(UiQuad::solid(sx, sy, sw, sh, radius, sk.input)); + if panel.commit_all_selected() && !panel.commit_msg.is_empty() { + let columns: usize = + panel.commit_msg.chars().map(|c| c.width().unwrap_or(0)).sum(); + let selection_w = (columns as f32 * cell_w).min(sw - s(16.0)); + quads.push(UiQuad::solid( + sx + s(6.0), + sy + s(6.0), + selection_w + s(4.0), + sh - s(12.0), + s(4.0), + sk.accent_soft, + )); + } } else { for (bx, bw) in git_button_rects(sx, sw, s(8.0)) { quads.push(UiQuad::solid(bx, sy, bw, sh, radius, sk.input)); @@ -1033,7 +1109,10 @@ pub(super) fn draw_text( if panel.search.is_empty() && !panel.search_focus { r.draw_chrome_text(size, qx, search_ty, sk.ink_faint, "筛选文件…", gc); } else { - let shown = if panel.search_focus && super::caret_blink_on() { + let shown = if panel.search_focus + && !panel.search_all_selected() + && super::caret_blink_on() + { format!("{}▏", panel.search) } else { panel.search.clone() @@ -1134,7 +1213,11 @@ pub(super) fn draw_text( let (sx, sy, sw, sh) = layout.search; let strip_ty = sy + (sh - cell_h) / 2.0; if panel.commit_focus { - let caret = if super::caret_blink_on() { "▏" } else { "" }; + let caret = if !panel.commit_all_selected() && super::caret_blink_on() { + "▏" + } else { + "" + }; let shown = format!("{}{caret}", panel.commit_msg); let hint = if panel.commit_msg.is_empty() { "提交信息… Enter 提交 · Esc 取消" @@ -1296,4 +1379,20 @@ mod tests { assert_eq!(panel_hit(&l, px + pw - 5.0, py + 5.0), PanelHit::ViewGit); assert_eq!(panel_hit(&l, px + 5.0, l.list_y + l.row_h * 1.5), PanelHit::Row(1)); } + + #[test] + fn self_drawn_fields_replace_select_all_on_paste() { + let mut panel = SidePanel::new(); + panel.search_input("old"); + panel.search_select_all(); + assert_eq!(panel.search_selected_text().as_deref(), Some("old")); + panel.search_input("new\nvalue"); + assert_eq!(panel.search, "newvalue"); + + panel.commit_input("old commit"); + panel.commit_select_all(); + assert_eq!(panel.commit_selected_text().as_deref(), Some("old commit")); + panel.commit_input("new commit"); + assert_eq!(panel.commit_msg, "new commit"); + } } diff --git a/nebula_app/src/display/text_input.rs b/nebula_app/src/display/text_input.rs new file mode 100644 index 0000000..787b0ab --- /dev/null +++ b/nebula_app/src/display/text_input.rs @@ -0,0 +1,71 @@ +//! Shared select-all editing semantics for Nebula's small self-drawn fields. +//! +//! These fields do not yet expose arbitrary drag selections, but they must +//! still agree on the Windows muscle-memory baseline: Ctrl+A selects the whole +//! buffer, Ctrl+C copies that selection, and typing/paste replaces it. + +#[derive(Debug, Clone, Default)] +pub(crate) struct SelectAllState { + selected: bool, +} + +impl SelectAllState { + pub(crate) fn select(&mut self, text: &str) { + self.selected = !text.is_empty(); + } + + pub(crate) fn clear(&mut self) { + self.selected = false; + } + + pub(crate) fn is_selected(&self) -> bool { + self.selected + } + + pub(crate) fn selected_text(&self, text: &str) -> Option { + self.selected.then(|| text.to_owned()) + } + + pub(crate) fn insert(&mut self, text: &mut String, incoming: &str) { + if self.selected { + text.clear(); + self.selected = false; + } + text.extend(incoming.chars().filter(|character| !character.is_control())); + } + + pub(crate) fn backspace(&mut self, text: &mut String) { + if self.selected { + text.clear(); + self.selected = false; + } else { + text.pop(); + } + } +} + +#[cfg(test)] +mod tests { + use super::SelectAllState; + + #[test] + fn paste_replaces_a_select_all_buffer() { + let mut state = SelectAllState::default(); + let mut text = "old".to_owned(); + state.select(&text); + assert_eq!(state.selected_text(&text).as_deref(), Some("old")); + state.insert(&mut text, "new\r\n"); + assert_eq!(text, "new"); + assert!(!state.is_selected()); + } + + #[test] + fn backspace_clears_a_select_all_buffer() { + let mut state = SelectAllState::default(); + let mut text = "selected".to_owned(); + state.select(&text); + state.backspace(&mut text); + assert!(text.is_empty()); + assert!(!state.is_selected()); + } +} diff --git a/nebula_app/src/event.rs b/nebula_app/src/event.rs index fc6d761..4f71a4f 100644 --- a/nebula_app/src/event.rs +++ b/nebula_app/src/event.rs @@ -743,6 +743,13 @@ impl ApplicationHandler for Processor { window_context.display.window.request_redraw(); } }, + (EventType::SshDeleteUndoExpired, Some(window_id)) => { + if let Some(window_context) = self.windows.get_mut(window_id) { + window_context.display.expire_ssh_delete_undo(); + window_context.dirty = true; + window_context.display.window.request_redraw(); + } + }, (EventType::NebulaTab(request), Some(window_id)) => { if let Some(window_context) = self.windows.get_mut(window_id) { let close_window = window_context.handle_tab_request(request); @@ -925,6 +932,8 @@ pub enum EventType { /// An interactive resize settled (no size change for the debounce window): /// flush the deferred PTY resizes + welcome-intro reprint in one shot. NebulaResizeSettled, + /// The SSH deletion grace period ended; commit its delayed credential cleanup. + SshDeleteUndoExpired, /// Typed AI-CLI lifecycle event from the nebula-hook pipe (see `ai_hook`). AiHook(crate::ai_hook::AiHookEvent), /// A toast was clicked: focus the originating window and, when known, @@ -955,6 +964,8 @@ pub enum TabRequest { OpenDoc(std::path::PathBuf), Close, CloseIndex(usize), + /// Duplicate a tab using the launch identity captured when it was opened. + Duplicate(usize), CloseWindow, SelectNext, SelectPrev, @@ -968,6 +979,12 @@ pub enum TabRequest { }, /// Toggle a split (left/right or top/bottom) with an independent shell. SplitToggle(crate::display::SplitDirection), + /// Select a specific tab and split it. Context menus can target inactive + /// tabs, so this must be atomic rather than relying on two queued events. + SplitIndex { + index: usize, + direction: crate::display::SplitDirection, + }, /// Dock the whole layout of tab `source` into the ACTIVE tab, splitting it /// on `nav`'s side (drag a sidebar tab into the terminal area to drop). DockSplit { @@ -982,6 +999,11 @@ pub enum TabRequest { BeginRename(usize), /// Commit the rename with the provided new name. CommitRename(String), + /// Set a tab's custom light-strip color; `None` follows the theme accent. + SetColor { + index: usize, + color: Option, + }, /// Cancel the current rename operation. CancelRename, } @@ -2431,7 +2453,7 @@ impl input::Processor> { // Clock ticks are handled at the window-context level. EventType::NebulaTick | EventType::NebulaAttach => (), // Resize settling is handled at the window-context level. - EventType::NebulaResizeSettled => (), + EventType::NebulaResizeSettled | EventType::SshDeleteUndoExpired => (), // AI hook events are handled at the Processor level (they may // target any window's pane); FocusWindow likewise. EventType::AiHook(_) | EventType::FocusWindow { .. } => (), @@ -2769,7 +2791,7 @@ impl input::Processor> { } let panel = &mut self.ctx.display.nebula_side_panel; panel.search_unfocus(false); - panel.commit_focus = false; + panel.commit_unfocus(); } // Nebula: always redraw on focus change, and clear the @@ -2835,13 +2857,13 @@ impl input::Processor> { // box, not paste into the shell behind it. self.ctx.display.nebula_side_panel.search_input(&text); } else if self.ctx.display.nebula_side_panel.commit_focus { - self.ctx - .display - .nebula_side_panel - .commit_msg - .extend(text.chars().filter(|c| !c.is_control())); + self.ctx.display.nebula_side_panel.commit_input(&text); } else if self.ctx.display.nebula_ssh_editor.is_some() { self.ctx.display.ssh_editor_insert(&text); + } else if self.ctx.display.command_palette_open() + && !self.ctx.display.command_palette_picker_open() + { + self.ctx.display.palette_input_text(&text); } else { // Don't use bracketed paste for single char input. self.ctx.paste(&text, text.chars().count() > 1); diff --git a/nebula_app/src/input/keyboard.rs b/nebula_app/src/input/keyboard.rs index 8510e55..11cbf11 100644 --- a/nebula_app/src/input/keyboard.rs +++ b/nebula_app/src/input/keyboard.rs @@ -8,7 +8,7 @@ use winit::keyboard::{Key, KeyLocation, ModifiersState, NamedKey}; use winit::platform::macos::OptionAsAlt; use nebula_terminal::event::EventListener; -use nebula_terminal::term::TermMode; +use nebula_terminal::term::{ClipboardType, TermMode}; use winit::platform::modifier_supplement::KeyEventExtModifierSupplement; use crate::config::{Action, BindingKey, BindingMode, KeyBinding}; @@ -36,6 +36,29 @@ impl> Processor { return; } + // A context menu is a transient focus scope, not a true modal. Esc + // only dismisses it; any other key dismisses first and then continues + // through the normal shortcut/terminal path. + if self.ctx.display().context_menu_interactive() { + let escape = matches!(key.logical_key, Key::Named(NamedKey::Escape)); + self.ctx.display().close_context_menu(); + self.ctx.mark_dirty(); + if escape { + return; + } + } + + // The inline default-shell list is a transient picker. Esc only + // dismisses; any other key dismisses and then follows its normal path. + if self.ctx.display().nebula_shell_picker_open { + let escape = matches!(key.logical_key, Key::Named(NamedKey::Escape)); + self.ctx.display().close_shell_picker(); + self.ctx.mark_dirty(); + if escape { + return; + } + } + // Tab rename input: consume keyboard when editing a tab name (before // command palette, so rename can't be interrupted by palette shortcuts) if self.ctx.display().nebula_tab_rename.is_some() { @@ -72,6 +95,26 @@ impl> Processor { self.ctx.display().tab_rename_caret_edge(true); self.ctx.mark_dirty(); }, + Key::Character(c) + if mods.control_key() && !mods.alt_key() && c.eq_ignore_ascii_case("a") => + { + self.ctx.display().tab_rename_select_all(); + self.ctx.mark_dirty(); + }, + Key::Character(c) + if mods.control_key() && !mods.alt_key() && c.eq_ignore_ascii_case("c") => + { + if let Some(text) = self.ctx.display().tab_rename_selected_text() { + self.ctx.clipboard_mut().store(ClipboardType::Clipboard, text); + } + }, + Key::Character(c) + if mods.control_key() && !mods.alt_key() && c.eq_ignore_ascii_case("v") => + { + let text = self.ctx.clipboard_mut().load(ClipboardType::Clipboard); + self.ctx.display().tab_rename_insert(&text); + self.ctx.mark_dirty(); + }, Key::Character(s) if mods.is_empty() || mods.shift_key() => { // Insert at the caret (type-to-overwrite on select-all). // Note: on Windows/IME, printable text arrives via @@ -100,6 +143,25 @@ impl> Processor { Key::Named(NamedKey::Backspace) => { self.ctx.display().nebula_side_panel.search_backspace(); }, + Key::Character(c) + if mods.control_key() && !mods.alt_key() && c.eq_ignore_ascii_case("a") => + { + self.ctx.display().nebula_side_panel.search_select_all(); + }, + Key::Character(c) + if mods.control_key() && !mods.alt_key() && c.eq_ignore_ascii_case("c") => + { + let text = self.ctx.display().nebula_side_panel.search_selected_text(); + if let Some(text) = text { + self.ctx.clipboard_mut().store(ClipboardType::Clipboard, text); + } + }, + Key::Character(c) + if mods.control_key() && !mods.alt_key() && c.eq_ignore_ascii_case("v") => + { + let text = self.ctx.clipboard_mut().load(ClipboardType::Clipboard); + self.ctx.display().nebula_side_panel.search_input(&text); + }, Key::Character(s) if mods.is_empty() || mods.shift_key() => { let text = s.clone(); self.ctx.display().nebula_side_panel.search_input(&text); @@ -114,23 +176,35 @@ impl> Processor { if self.ctx.display().nebula_side_panel.commit_focus { match &key.logical_key { Key::Named(NamedKey::Escape) => { - let panel = &mut self.ctx.display().nebula_side_panel; - panel.commit_focus = false; - panel.commit_msg.clear(); + self.ctx.display().nebula_side_panel.commit_cancel(); }, Key::Named(NamedKey::Enter) => { self.ctx.display().nebula_side_panel.git_commit_submit(); }, Key::Named(NamedKey::Backspace) => { - self.ctx.display().nebula_side_panel.commit_msg.pop(); + self.ctx.display().nebula_side_panel.commit_backspace(); + }, + Key::Character(c) + if mods.control_key() && !mods.alt_key() && c.eq_ignore_ascii_case("a") => + { + self.ctx.display().nebula_side_panel.commit_select_all(); + }, + Key::Character(c) + if mods.control_key() && !mods.alt_key() && c.eq_ignore_ascii_case("c") => + { + let text = self.ctx.display().nebula_side_panel.commit_selected_text(); + if let Some(text) = text { + self.ctx.clipboard_mut().store(ClipboardType::Clipboard, text); + } + }, + Key::Character(c) + if mods.control_key() && !mods.alt_key() && c.eq_ignore_ascii_case("v") => + { + let text = self.ctx.clipboard_mut().load(ClipboardType::Clipboard); + self.ctx.display().nebula_side_panel.commit_input(&text); }, Key::Character(s) if mods.is_empty() || mods.shift_key() => { - let text = s.clone(); - self.ctx - .display() - .nebula_side_panel - .commit_msg - .extend(text.chars().filter(|c| !c.is_control())); + self.ctx.display().nebula_side_panel.commit_input(s); }, _ => {}, } @@ -145,6 +219,32 @@ impl> Processor { Key::Named(NamedKey::Enter) => self.ctx.display().save_ssh_editor(), Key::Named(NamedKey::Tab) => self.ctx.display().ssh_editor_next_field(), Key::Named(NamedKey::Backspace) => self.ctx.display().ssh_editor_backspace(), + Key::Character(c) + if mods.control_key() + && !mods.alt_key() + && c.eq_ignore_ascii_case("a") => + { + self.ctx.display().ssh_editor_select_all(); + }, + Key::Character(c) + if mods.control_key() + && !mods.alt_key() + && c.eq_ignore_ascii_case("c") => + { + if let Some(text) = self.ctx.display().ssh_editor_selected_text() { + self.ctx.clipboard_mut().store(ClipboardType::Clipboard, text); + } + }, + Key::Character(c) + if mods.control_key() + && !mods.alt_key() + && c.eq_ignore_ascii_case("v") => + { + // SSH 编辑器是应用自有文本框。直接写入当前焦点字段,避免后续 + // 终端粘贴绑定把地址或密码误发送到后台 PTY。 + let text = self.ctx.clipboard_mut().load(ClipboardType::Clipboard); + self.ctx.display().ssh_editor_insert(&text); + }, Key::Character(c) if mods.is_empty() || mods.shift_key() => { self.ctx.display().ssh_editor_insert(c) }, @@ -155,7 +255,45 @@ impl> Processor { return; } - // Nebula command palette owns the keyboard while open: route typing / + // The new-tab shell/profile list is a lightweight menu: navigation and + // confirmation stay inside it, while unrelated keys dismiss it and + // continue through normal shortcuts/terminal input. + if self.ctx.display().command_palette_picker_open() { + let handled = match &key.logical_key { + Key::Named(NamedKey::Escape) => { + self.ctx.display().close_command_palette(); + true + }, + Key::Named(NamedKey::Enter) => { + if let Some(action) = self.ctx.display().palette_confirm() { + self.run_palette_action(action); + } + true + }, + Key::Named(NamedKey::Tab) => { + self.ctx.display().palette_move(if mods.shift_key() { -1 } else { 1 }); + true + }, + Key::Named(NamedKey::ArrowDown) => { + self.ctx.display().palette_move(1); + true + }, + Key::Named(NamedKey::ArrowUp) => { + self.ctx.display().palette_move(-1); + true + }, + _ => { + self.ctx.display().close_command_palette(); + false + }, + }; + self.ctx.mark_dirty(); + if handled { + return; + } + } + + // The full command palette owns the keyboard while open: route typing / // navigation / confirm into it and swallow everything else, so no // terminal binding fires behind the modal. if self.ctx.display().command_palette_open() { @@ -173,9 +311,17 @@ impl> Processor { Key::Named(NamedKey::ArrowUp) => self.ctx.display().palette_move(-1), Key::Named(NamedKey::Backspace) => self.ctx.display().palette_backspace(), Key::Character(c) if mods.control_key() => { - // Ctrl+Shift+P (the opener) toggles the palette shut; other - // ctrl-combos are swallowed rather than typed as text. - if mods.shift_key() && c.eq_ignore_ascii_case("p") { + if c.eq_ignore_ascii_case("a") { + self.ctx.display().palette_select_all(); + } else if c.eq_ignore_ascii_case("c") { + if let Some(text) = self.ctx.display().palette_selected_text() { + self.ctx.clipboard_mut().store(ClipboardType::Clipboard, text); + } + } else if c.eq_ignore_ascii_case("v") { + let text = self.ctx.clipboard_mut().load(ClipboardType::Clipboard); + self.ctx.display().palette_input_text(&text); + } else if mods.shift_key() && c.eq_ignore_ascii_case("p") { + // Ctrl+Shift+P (the opener) toggles the full palette. self.ctx.display().close_command_palette(); } }, @@ -206,6 +352,19 @@ impl> Processor { return; } + // Ctrl+Z reverses the latest SSH deletion while the action bar lives. + // Text-entry and true-modal scopes above retain their own contracts. + if mods.control_key() + && !mods.alt_key() + && !mods.shift_key() + && matches!(&key.logical_key, Key::Character(c) if c.eq_ignore_ascii_case("z")) + && self.ctx.display().ssh_delete_undo_available() + { + self.undo_ssh_delete(); + self.ctx.mark_dirty(); + return; + } + // Document-viewer tab: bare navigation keys scroll the document. Only // unmodified keys are taken — chords (Ctrl+Tab, Ctrl+Shift+W, …) fall // through to the normal tab bindings, and any stray text ends in the diff --git a/nebula_app/src/input/mod.rs b/nebula_app/src/input/mod.rs index 99f343c..fd402ff 100644 --- a/nebula_app/src/input/mod.rs +++ b/nebula_app/src/input/mod.rs @@ -126,6 +126,12 @@ pub trait ActionContext { fn scroll(&mut self, _scroll: Scroll) {} fn window(&mut self) -> &mut Window; fn display(&mut self) -> &mut Display; + /// Whether this context owns Nebula's window chrome. Unit tests that only + /// exercise terminal selection can turn it off instead of constructing an + /// OpenGL Display just to get through unrelated hit-testing. + fn nebula_chrome_active(&self) -> bool { + true + } fn terminal(&self) -> &Term; fn terminal_mut(&mut self) -> &mut Term; fn nebula_accept(&self) -> crate::display::AcceptKey { @@ -569,6 +575,7 @@ impl> Processor { // open, and the closing animation swallows pointer motion until the // retained editor snapshot is released. if self.ctx.display().nebula_ssh_editor.is_some() { + self.ctx.display().set_ssh_delete_undo_hover(false); let hit = if self.ctx.display().ssh_editor_active() { self.ctx.display().ssh_editor_hit(x as f32, y as f32) } else { @@ -633,6 +640,30 @@ impl> Processor { let settings_scroll = self.ctx.display().settings_scroll(); let shell_picker_open = self.ctx.display().nebula_shell_picker_open; let shell_picker_count = self.ctx.display().shell_picker_count(); + let hidden_host_count = self.ctx.display().hidden_ssh_host_count(); + // A native context menu is a pointer-modal overlay: while it is open, + // underlying links, tabs and drawer rows must not react to hover. + if self.ctx.display().context_menu_interactive() { + self.ctx.display().set_ssh_delete_undo_hover(false); + let hit = self.ctx.display().context_menu_hover(x as f32, y as f32); + self.ctx.window().set_mouse_cursor(if matches!( + hit, + crate::display::ContextMenuHit::Action(_) + ) { + CursorIcon::Pointer + } else { + CursorIcon::Default + }); + return; + } + let undo_hover = self.ctx.display().nebula_confirm.is_none() + && !self.ctx.display().command_palette_open() + && self.ctx.display().ssh_delete_undo_hit(x as f32, y as f32); + self.ctx.display().set_ssh_delete_undo_hover(undo_hover); + if undo_hover { + self.ctx.window().set_mouse_cursor(CursorIcon::Pointer); + return; + } let settings_hover = crate::display::settings_hit( &window_size, scale, @@ -643,6 +674,7 @@ impl> Processor { settings_scroll, shell_picker_open, shell_picker_count, + hidden_host_count, ); let chrome_hover = if crate::display::in_chrome_bar(&window_size, scale, x as f32, y as f32) { @@ -676,6 +708,7 @@ impl> Processor { | crate::display::SettingsHit::AcceptCycle | crate::display::SettingsHit::ShellCycle | crate::display::SettingsHit::ShellPickerRow(_) + | crate::display::SettingsHit::RestoreHiddenSsh(_) | crate::display::SettingsHit::FetchToggle | crate::display::SettingsHit::PowerlineToggle | crate::display::SettingsHit::KeepSessionToggle @@ -760,7 +793,7 @@ impl> Processor { if self.ctx.display().command_palette_open() { let px = x as f32; let py = y as f32; - let with_input = !self.ctx.display().command_palette_picking_default(); + let with_input = !self.ctx.display().command_palette_picker_open(); let layout = crate::display::command_palette::palette_layout( window_size.width(), window_size.height(), @@ -993,9 +1026,42 @@ impl> Processor { self.ctx.display().nebula_confirm = None; self.ctx.paste_now(&text, bracketed); }, + NebulaConfirm::DeleteSsh { host, .. } => { + self.ctx.display().nebula_confirm = None; + if self.ctx.display().confirm_delete_ssh_host(&host) { + self.schedule_ssh_delete_undo_expiry(); + } + }, } } + /// Keep timer creation and cancellation identical across keyboard, mouse, + /// and confirm-dialog paths. The delayed event owns only window routing; + /// sensitive credential state stays inside `Display`. + fn schedule_ssh_delete_undo_expiry(&mut self) { + let window_id = self.ctx.window().id(); + let timer_id = TimerId::new(Topic::SshDeleteUndo, window_id); + let event = Event::new(EventType::SshDeleteUndoExpired, window_id); + let scheduler = self.ctx.scheduler_mut(); + scheduler.unschedule(timer_id); + scheduler.schedule( + event, + crate::display::SSH_DELETE_UNDO_DURATION, + false, + timer_id, + ); + } + + /// Cancel expiry first so a late event cannot immediately dispose a host + /// that the user has just restored. + fn undo_ssh_delete(&mut self) -> bool { + let window_id = self.ctx.window().id(); + self.ctx + .scheduler_mut() + .unschedule(TimerId::new(Topic::SshDeleteUndo, window_id)); + self.ctx.display().undo_delete_ssh_host() + } + /// Advance the multi-click state machine for this press — exactly ONCE /// per press, at the top of `on_mouse_press` (WT's `_numberOfClicks` /// model). A press upgrades Click→Double→Triple only when it is the same @@ -1028,6 +1094,51 @@ impl> Processor { state } + fn run_context_menu_action(&mut self, action: crate::display::ContextMenuAction) { + use crate::display::ContextMenuAction::*; + match action { + DuplicateTab(index) => { + self.ctx.nebula_tab(crate::event::TabRequest::Duplicate(index)); + }, + SplitTabRight(index) => { + self.ctx.nebula_tab(crate::event::TabRequest::SplitIndex { + index, + direction: crate::display::SplitDirection::LeftRight, + }); + }, + SplitTabDown(index) => { + self.ctx.nebula_tab(crate::event::TabRequest::SplitIndex { + index, + direction: crate::display::SplitDirection::TopBottom, + }); + }, + RenameTab(index) => { + self.ctx.nebula_tab(crate::event::TabRequest::BeginRename(index)); + }, + CloseTab(index) => { + self.ctx.nebula_tab(crate::event::TabRequest::CloseIndex(index)); + }, + SetTabColor { index, color } => { + self.ctx.nebula_tab(crate::event::TabRequest::SetColor { index, color }); + }, + ConnectSsh(index) => { + let host = self.ctx.display().nebula_ssh_hosts.get(index).cloned(); + if let Some(host) = host { + self.ctx.nebula_tab(crate::event::TabRequest::NewSsh(host)); + } + }, + CopySshAddress(index) => { + let host = self.ctx.display().nebula_ssh_hosts.get(index).cloned(); + if let Some(host) = host { + self.ctx.clipboard_mut().store(ClipboardType::Clipboard, host); + } + }, + EditSsh(index) => self.ctx.display().edit_ssh_host(index), + DeleteSsh(index) => self.ctx.display().request_delete_ssh_host(index), + } + self.ctx.mark_dirty(); + } + fn on_mouse_press(&mut self, button: MouseButton) { // Multi-click bookkeeping happens here and nowhere else. It used to // be advanced both by the chrome block and the terminal block below; @@ -1035,6 +1146,43 @@ impl> Processor { // to a double/triple — plain drags selected by word or whole line. self.advance_click_state(button); + // Window chrome is deliberately optional at the input boundary. The + // terminal's click-state machine is useful and testable without a GPU + // window; real application contexts keep the default `true` value. + if self.ctx.nebula_chrome_active() { + + // Non-primary presses also release lightweight menus. Right-click on + // a context menu is handled below so it can naturally retarget. + if button != MouseButton::Left { + if button != MouseButton::Right && self.ctx.display().context_menu_interactive() { + self.ctx.display().close_context_menu(); + self.ctx.mark_dirty(); + return; + } + if self.ctx.display().command_palette_picker_open() { + self.ctx.display().close_command_palette(); + self.ctx.mark_dirty(); + return; + } + if self.ctx.display().nebula_shell_picker_open { + self.ctx.display().close_shell_picker(); + self.ctx.mark_dirty(); + return; + } + } + + if button == MouseButton::Left && self.ctx.display().context_menu_interactive() { + let x = self.ctx.mouse().x as f32; + let y = self.ctx.mouse().y as f32; + if let crate::display::ContextMenuHit::Action(action) = + self.ctx.display().context_menu_click(x, y) + { + self.run_context_menu_action(action); + } + self.ctx.mark_dirty(); + return; + } + // A left press anywhere OUTSIDE the rename box ends the edit // (canceling, like Esc) — the click itself still lands wherever it // was aimed. Clicking inside the box is caret placement (below). @@ -1065,7 +1213,7 @@ impl> Processor { let y = self.ctx.mouse().y as f32; let size = self.ctx.display().size_info; let scale = self.ctx.window().scale_factor as f32; - let with_input = !self.ctx.display().command_palette_picking_default(); + let with_input = !self.ctx.display().command_palette_picker_open(); let layout = crate::display::command_palette::palette_layout( size.width(), size.height(), @@ -1087,6 +1235,21 @@ impl> Processor { return; } + // The reversible-action bar floats above drawer/chrome content. True + // modals still retain pointer ownership and therefore block this path. + let undo_pointer = (self.ctx.mouse().x as f32, self.ctx.mouse().y as f32); + if button == MouseButton::Left + && self.ctx.display().nebula_confirm.is_none() + && self + .ctx + .display() + .ssh_delete_undo_hit(undo_pointer.0, undo_pointer.1) + { + self.undo_ssh_delete(); + self.ctx.mark_dirty(); + return; + } + // Right-side drawer (directory tree / git): header tabs switch views, // directory rows expand/collapse. Sits under the modal layers, so // only when no modal owns the pointer. @@ -1224,6 +1387,15 @@ impl> Processor { if button == MouseButton::Right { let x = self.ctx.mouse().x as f32; let y = self.ctx.mouse().y as f32; + let menu_was_open = self.ctx.display().context_menu_interactive(); + if menu_was_open + && !matches!( + self.ctx.display().context_menu_hit(x, y), + crate::display::ContextMenuHit::Outside + ) + { + return; + } match self.ctx.display().chrome_hit(x, y) { crate::display::ChromeHit::NewTab => { let profiles: Vec = @@ -1234,14 +1406,20 @@ impl> Processor { self.ctx.mark_dirty(); return; }, - crate::display::ChromeHit::Tab(index) => { - if index > 0 { - self.ctx.nebula_tab(crate::event::TabRequest::Move { from: index, to: 0 }); - } + crate::display::ChromeHit::Tab(index) + | crate::display::ChromeHit::TabClose(index) => { + self.ctx.display().open_tab_context_menu(index, x, y); self.ctx.mark_dirty(); return; }, - crate::display::ChromeHit::Host(_) => { + crate::display::ChromeHit::Host(index) => { + self.ctx.display().open_ssh_context_menu(index, x, y); + self.ctx.mark_dirty(); + return; + }, + _ if menu_was_open => { + self.ctx.display().close_context_menu(); + self.ctx.mark_dirty(); return; }, _ => {}, @@ -1284,7 +1462,8 @@ impl> Processor { let settings_scroll = self.ctx.display().settings_scroll(); let shell_picker_open = self.ctx.display().nebula_shell_picker_open; let shell_picker_count = self.ctx.display().shell_picker_count(); - match crate::display::settings_hit( + let hidden_host_count = self.ctx.display().hidden_ssh_host_count(); + let settings_hit = crate::display::settings_hit( &size, scale, x, @@ -1294,7 +1473,22 @@ impl> Processor { settings_scroll, shell_picker_open, shell_picker_count, - ) { + hidden_host_count, + ); + if shell_picker_open + && !matches!( + settings_hit, + crate::display::SettingsHit::ShellCycle + | crate::display::SettingsHit::ShellPickerRow(_) + ) + { + // First outside click dismisses the picker without activating + // an unrelated control hidden behind its temporary focus scope. + self.ctx.display().close_shell_picker(); + self.ctx.mark_dirty(); + return; + } + match settings_hit { crate::display::SettingsHit::Toggle => { self.ctx.display().toggle_settings(); self.ctx.mark_dirty(); @@ -1331,6 +1525,11 @@ impl> Processor { self.ctx.mark_dirty(); return; }, + crate::display::SettingsHit::RestoreHiddenSsh(index) => { + self.ctx.display().restore_hidden_ssh_host(index); + self.ctx.mark_dirty(); + return; + }, crate::display::SettingsHit::FetchToggle => { self.ctx.display().toggle_fetch(); self.ctx.mark_dirty(); @@ -1407,11 +1606,6 @@ impl> Processor { self.ctx.mark_dirty(); return; }, - crate::display::ChromeHit::HostDelete(index) => { - self.ctx.display().delete_ssh_host(index); - self.ctx.mark_dirty(); - return; - }, crate::display::ChromeHit::TabClose(index) => { self.ctx.nebula_tab(crate::event::TabRequest::CloseIndex(index)); return; @@ -1517,6 +1711,7 @@ impl> Processor { return; } } + } // Nebula: right-click copies the selection, or pastes when there is // none (Windows Terminal-style), unless the app is in mouse mode. @@ -1924,6 +2119,16 @@ impl> Processor { } pub fn on_focus_change(&mut self, is_focused: bool) { + if !is_focused { + // Lightweight pointer menus must never remain stranded over an + // inactive window. True modals intentionally survive focus loss. + self.ctx.display().close_context_menu(); + self.ctx.display().close_shell_picker(); + if self.ctx.display().command_palette_picker_open() { + self.ctx.display().close_command_palette(); + } + self.ctx.mark_dirty(); + } if self.ctx.terminal().mode().contains(TermMode::FOCUS_IN_OUT) { let chr = if is_focused { "I" } else { "O" }; @@ -2384,6 +2589,10 @@ mod tests { unimplemented!(); } + fn nebula_chrome_active(&self) -> bool { + false + } + fn pop_message(&mut self) { self.message_buffer.pop(); } diff --git a/nebula_app/src/scheduler.rs b/nebula_app/src/scheduler.rs index 66f8f2c..870a562 100644 --- a/nebula_app/src/scheduler.rs +++ b/nebula_app/src/scheduler.rs @@ -34,6 +34,8 @@ pub enum Topic { /// in-box ConPTY repaints its whole viewport on every resize, so per-tick /// notifications flood the scrollback with shredded repaints. NebulaResizeSettle, + /// Grace-period timer for committing an SSH host deletion after Undo expires. + SshDeleteUndo, } /// Event scheduled to be emitted at a specific time.