feat(backup): multi-protocol remote backup - folder/WebDAV/S3/SFTP push and restore-latest

设置→备份页正式开放,新增「远程备份」组:口令保护归档(密文出境)可推送
到本地/网络目录、WebDAV、S3 兼容存储(SigV4,路径式,AWS/MinIO/R2/B2)或
SFTP(复用已保存 SSH 主机与认证)。归档时间戳命名、每端保留最近 10 份,
「恢复最新备份」取回最新一份after口令确认落盘。WebDAV/S3 密钥入 Windows
凭据管理器;打包/Argon2/网络全在后台线程(NebulaBackupRemote 事件模型与
WebDAV 同步一致)。OAuth 三家(GitHub/Drive/OneDrive)暂缓。SigV4 附
AWS 官方文档签名值的 KAT 守护。

同车提交并行会话的收尾:托盘 attention(tray.rs 与 main/window_context 接
线)、GPUI 侧栏设置按钮、sync 同步集补 resume_ai/tray 键。工作区整体通过
722 项测试后提交。
This commit is contained in:
kuddev committed 2026-08-13 14:04:08 +08:00
1 parent 076fd3addc
commit 3d738f6bd8
18 files changed
+2767 -23

No files matched your search

+14 -2
View File
@@ -10,6 +10,7 @@ Every release entry is provided in English and Simplified Chinese.
#### Added
- **Remote backups over multiple protocols** — the Settings → Backup page is now available and gains a "Remote backup" group that pushes the existing password-protected archive (Argon2id + AES-256-GCM; servers only ever see ciphertext) to a destination of choice: a local/network folder (NAS UNC paths), WebDAV, S3-compatible storage (AWS/MinIO/R2/B2 via SigV4, path-style), or SFTP reusing saved SSH hosts and their authentication. Archives are timestamped (`nebula-backup-YYYYMMDD-HHMMSS.nbk`), the newest 10 are retained per destination, and "Restore latest" fetches and applies the most recent one after passphrase entry. WebDAV/S3 secrets live in the Windows Credential Manager (env-var fallback elsewhere), non-secret settings persist in `nebula_backup.txt`, and all packing, key derivation, and network I/O run on a background thread. Provider surface modeled on Netcatty's cloud-sync adapters (its OAuth providers — GitHub/Google Drive/OneDrive — are out of scope for now).
- **Two completion styles with a switch** — Settings → Terminal → Completion adds a "Completion style" choice between the existing inline ghost text and a new popup candidate list. The popup gathers history, frecency directories, PATH commands, and filesystem matches (up to 8, with source tags), navigates with Up/Down, accepts with the configured accept key (Tab/Right), and dismisses with Esc without re-opening until the line changes. A command-palette action toggles the style, and the choice persists as `completion_style`.
- **A real color picker for the custom background** — the background-color popup now leads with a saturation/value plane and a hue bar; dragging picks continuously with live terminal preview and persists on release. The preset swatches and the hex field remain, and all three inputs stay in sync (including hue retention across gray/black/white picks).
- **Clipboard screenshot paste, locally and over SSH** — pasting with an image-only clipboard (e.g. right after Win+Shift+S) now converts the bitmap to PNG and pastes a file path instead of nothing. Local panes write a temp file; SSH panes upload via the existing SFTP stack to `/tmp/nebula-paste-<ts>.png` in the background and then type the remote path into the pane — so image-accepting CLIs like codex and claude receive a usable path on both sides (interaction modeled on Netcatty).
@@ -17,13 +18,18 @@ Every release entry is provided in English and Simplified Chinese.
- **Configurable terminal cell width** — Settings → Appearance offers Compact and Relaxed cell-width modes. Changing the mode immediately rebuilds font metrics, the terminal grid, pane layout, and PTY dimensions, and the choice persists across launches.
- **Ordered font fallback chains** — `font_family` accepts comma-separated families such as `JetBrains Mono, LXGW WenKai, Cascadia Code`. The first family remains the primary face, later families fill missing glyphs in order for regular, bold, italic, and bold-italic text, and the embedded Maple font remains the final fallback. System and imported/private fonts now use the same family lookup path for validation, preview, and rendering. (#33)
- **Middle-click to close tabs** — middle-clicking a tab or its close control follows the existing close flow, including confirmation when required.
- **Audible terminal bell** — BEL (`\a`) now plays the system notification sound in addition to the visual bell, so an AI CLI finishing a turn is audible even from another tab. Throttled so a bell-happy program cannot machine-gun it; disable with `bell.audible = false`. (#37)
- **AI conversations resume across restarts** — panes that had a claude/codex conversation open when Nebula closed (or crashed) now type the exact resume command (`claude --resume <id>` / `codex resume <id>`) into the restored shell automatically. Session identity comes from the CLIs' own hook payloads; a claude detected without an id falls back to `claude --continue` in the restored directory. Injection only targets plain restored shells (never SSH/profile seeds), ids are validated before anything reaches the terminal, and Settings → Advanced offers the "resume AI conversations on restore" switch (`resume_ai`, on by default).
- **System tray icon with agent attention** — a resident tray icon flips to an amber-dot state whenever any AI CLI stops and waits for input, even with every window buried or minimized. Right-click lists all agent panes with their state (running / waiting) and jumps straight to the source pane through the same focus path as toast clicks; left-click goes to the most urgent pane. The tray mirrors the sidebar badges (one source of truth) and can be turned off in Settings → Advanced (`tray`, on by default).
#### Fixed
- **Explorer context-menu launches join the resident instance** — "Open in Nebula" used to be treated as explicit intent and always started an independent window, so tabs detached in the resident process looked lost. A directory launch without `-e` now attaches the existing instance first (restoring its tabs) and opens the directory as a new tab in that window, brought to the foreground; without a resident instance it still starts standalone. The runtime API's `tab.new` gained an optional `cwd` parameter to carry this.
- **Imported terminals become selectable in the default-shell dropdown** — the dropdown's hit test counted only detected shells while the list also renders imported quick-launch profiles, leaving the trailing rows visible but unclickable. Both sides now share one count.
- **Git panel no longer blanks on repositories owned by another user** — `git status`/`diff` in the drawer run with a per-invocation `safe.directory` exemption, fixing the silently empty Git view on `\\wsl$\…` roots and elevated-owner checkouts where the same commands work in the user's own shell. Failures now leave a debug-log trace instead of a silent blank.
- **Visible Windows windows recover from stuck render gates** — startup-time occlusion misreports and missing frame callbacks can no longer leave an already visible window accepting input without repainting. Occlusion is ignored unless the window is actually minimized, and the existing 1 Hz window heartbeat idempotently releases stale `occluded` and `has_frame` gates. (#21)
- **Visible Windows windows recover from stuck render gates** — startup-time occlusion misreports and missing frame callbacks can no longer leave an already visible window accepting input without repainting. Occlusion is ignored unless the window is actually minimized, the existing 1 Hz window heartbeat idempotently releases stale `occluded` and `has_frame` gates, and the watchdog is now armed at window creation so even a freeze before the first frame self-heals within a second. (#21, #32)
- **Drive-root context menu launches work** — right-clicking the background of `D:\` (any drive root) and choosing "Open in Nebula" failed with "invalid working directory": Explorer expands `%V` to `D:\`, whose trailing backslash escapes the closing quote on the command line. The mangled path is now repaired, and the error's log hint uses PowerShell syntax (`$env:NEBULA_LOG`) so copy-paste resolves. (#36)
- **Multi-line paste stops interrupting codex** — the multi-line paste confirmation now only fires when newlines are actually headed to a shell that would execute them line by line. Applications in bracketed-paste mode (codex, vim, modern PSReadLine) receive the paste as one atomic chunk, so the warning no longer blocks them. The dark-theme "Enter" keycap on the confirm dialog's accent button also derives from the button's own ink instead of the near-black panel color. (#35)
#### Improved
@@ -33,6 +39,7 @@ Every release entry is provided in English and Simplified Chinese.
#### 新增
- **多协议远程备份** — “设置 → 备份”页面正式开放,并新增「远程备份」组:把既有的口令保护归档(Argon2id + AES-256-GCM,服务器只见密文)推送到所选目的地——本地/网络目录(含 NAS 的 UNC 路径)、WebDAV、S3 兼容存储(SigV4 签名、路径式 URL,AWS/MinIO/R2/B2 通吃)、或复用已保存 SSH 主机及其认证的 SFTP。归档按时间戳命名(`nebula-backup-YYYYMMDD-HHMMSS.nbk`),每个远端保留最近 10 份;「恢复最新备份」取回最新一份、输口令后落盘。WebDAV/S3 的密钥存 Windows 凭据管理器(其他平台走环境变量),非密文配置持久化在 `nebula_backup.txt`;打包、密钥派生与网络全部在后台线程完成。协议面参考 Netcatty 的云同步适配器(其 GitHub/Google Drive/OneDrive 三个 OAuth 后端暂不纳入)。
- **两种补全样式可切换** — “设置 → 终端 → 补全”新增「补全样式」:在既有的行内灰字与新的弹窗候选列表之间选择。弹窗汇总历史命令、常用目录、PATH 命令与文件系统匹配(至多 8 项,带来源标签),↑/↓ 选行、按补全接受键(Tab/→)接受、Esc 关闭且同一行不再重弹;命令面板提供切换动作,选择以 `completion_style` 持久化。
- **自定义背景色改用真调色盘** — 背景色浮层顶部新增饱和度/明度取色面与色相条,按住拖动即连续取色、终端实时预览、松手落盘。预设色板与 16 进制输入保留,三种输入互相同步(灰/黑/白取色时保留既有色相)。
- **剪贴板截图粘贴(本地与 SSH)** — 剪贴板只有位图没有文本时(如 Win+Shift+S 之后),粘贴会把位图转成 PNG 并粘出文件路径:本地 pane 写入临时文件;SSH pane 经既有 SFTP 栈后台上传到远端 `/tmp/nebula-paste-<时间戳>.png` 再把远端路径敲进会话——codex/claude 这类接受图片路径的 CLI 在两侧都能直接用(交互参考 Netcatty)。
@@ -40,13 +47,18 @@ Every release entry is provided in English and Simplified Chinese.
- **可配置终端单元格宽度** — “设置 → 外观”新增“紧凑”和“宽松”两种单元格宽度模式。切换后会立即重算字体度量、终端网格、分屏布局和 PTY 尺寸,并在下次启动时保留选择。
- **有序字体 fallback 链** — `font_family` 支持逗号分隔的字体族,例如 `JetBrains Mono, 霞鹜文楷, Cascadia Code`。第一个字体族仍是主字体,后续字体族按顺序为常规、粗体、斜体和粗斜体补齐缺失字形,内置 Maple 字体始终作为最后兜底。系统字体与导入/私有字体现在统一通过同一族名查找路径完成校验、预览和渲染。(#33)
- **中键关闭标签页** — 在标签页或其关闭控件上单击鼠标中键,会进入既有的关闭流程;需要确认时仍会正常弹出确认。
- **终端铃声** — BEL(`\a`)现在在视觉铃声之外播放系统提示音:AI CLI 在别的标签页里完成回合也听得见。内置节流,刷铃声的程序不会连成机关枪;`bell.audible = false` 可关闭。(#37)
- **AI 对话跨重启接续** — 关闭(或崩溃)时某个 pane 里还开着 claude/codex 对话的,冷恢复后会自动把 resume 命令(`claude --resume <id>` / `codex resume <id>`)敲进恢复出来的 shell。会话身份来自 CLI 自己的 hook 载荷;识别到 claude 但没有 id 时退化为在恢复目录里 `claude --continue`。注入只针对恢复出的裸 shell(绝不注入 SSH/Profile 首格),id 上屏前先做字符集校验;“设置 → 高级”提供「恢复时自动接续 AI 对话」开关(`resume_ai`,默认开)。
- **托盘图标 agent 提醒** — 常驻系统托盘图标:任一 AI CLI 停下来等输入时翻转为橙点 attention 态,窗口全被压住或最小化也看得见。右键列出所有 agent pane 及状态(运行中/等待输入),点击经 toast 同一条聚焦路径直达来源 pane;左键直达最需要人的那个。托盘与侧栏徽章同一事实源;“设置 → 高级”可关(`tray`,默认开)。
#### 修复
- **资源管理器右键打开并入驻留实例** — 「在 Nebula 中打开」此前被当作显式意图而总是启动独立窗口,驻留进程里 detached 的标签看起来就“丢了”。带目录、无 `-e` 命令的启动现在会先 ATTACH 既有实例(找回原有标签),再在该窗口新开定目录标签并置前;没有驻留实例时仍独立启动。runtime API 的 `tab.new` 为此新增可选 `cwd` 参数。
- **导入的终端在默认 Shell 下拉中可以选中** — 下拉的命中测试此前只统计检测到的 shell,而列表还渲染了导入的快速启动配置,末尾几行看得见点不中。绘制与命中现在共用同一计数。
- **Git 面板在他人所有的仓库上不再空白** — 抽屉里的 `git status`/`diff` 以单次调用范围的 `safe.directory` 豁免运行,修复 `\\wsl$\…` 根目录与提升权限检出下“自己 shell 里 git status 正常、面板却空白”的问题;失败现在会留下调试日志而不是无声空白。
- **Windows 可见窗口可从卡死的渲染门控中恢复** — 启动期的遮挡误报或帧回调丢失不再让可见窗口陷入“能接收输入但不重绘”。窗口没有真正最小化时会忽略遮挡误报,既有的 1 Hz 窗口心跳也会以幂等方式释放卡住的 `occluded` 与 `has_frame` 门控。(#21)
- **Windows 可见窗口可从卡死的渲染门控中恢复** — 启动期的遮挡误报或帧回调丢失不再让可见窗口陷入“能接收输入但不重绘”。窗口没有真正最小化时会忽略遮挡误报,既有的 1 Hz 窗口心跳也会以幂等方式释放卡住的 `occluded` 与 `has_frame` 门控;看门狗现在在建窗时即刻武装,首帧之前的冻结也能在一秒内自愈。(#21、#32)
- **盘符根目录右键启动可用** — 在 `D:\` 这类盘符根目录背景右键「在 Nebula 中打开」此前报「无效的工作目录」:资源管理器把 `%V` 展开成 `D:\`,末尾反斜杠在命令行上转义了收尾引号。被吃掉的路径现在会被修复;错误提示里的日志变量改用 PowerShell 语法(`$env:NEBULA_LOG`),复制即可用。(#36)
- **多行粘贴不再打断 codex** — 多行粘贴确认现在只在换行真的会被 shell 逐行执行时弹出。处于 bracketed paste 模式的应用(codex、vim、新版 PSReadLine)会把粘贴当作一个整体接收,警告不再拦路。确认框里深色主题下 accent 主按钮上的「Enter」键帽也改从按钮自身墨色派生,不再是一块近黑色。(#35)
#### 改进
Generated
+1
View File
@@ -4578,6 +4578,7 @@ dependencies = [
"gpui",
"gpui-component",
"gpui-component-assets",
"hmac 0.13.0",
"home",
"html-escape",
"image",
+4
View File
@@ -70,6 +70,9 @@ clap = { version = "4.2.7", features = ["derive", "env"] }
copypasta = { version = "0.10.1", default-features = false }
crossfont = "0.9.0"
glutin = { version = "0.32.2", default-features = false, features = ["egl", "wgl"] }
# 远程备份的 S3 SigV4 签名(backup_remote)。0.13 配 sha2 0.11 的 digest
# 世代;lock 里已有该版本,零新增下载。
hmac = "0.13"
html-escape = "0.2.14"
home = "0.5.5"
libc = "0.2"
@@ -172,6 +175,7 @@ windows-sys = { version = "0.59", features = [
"Win32_UI_Shell_PropertiesSystem",
"Win32_System_Variant",
"Win32_System_Threading",
"Win32_System_LibraryLoader",
"Win32_System_SystemInformation",
"Win32_System_Registry",
"Win32_System_Console",
File diff suppressed because it is too large. Load diff
+297 -2
View File
@@ -128,6 +128,19 @@ pub(crate) use ui::theme::write_nebula_prompt_theme;
enum BackupOperation {
Export(std::path::PathBuf),
Restore(std::path::PathBuf),
/// 远程备份/恢复(协议与目的地在 `nebula_backup.txt`)。口令确认后由
/// 事件层在后台线程执行——网络绝不进 UI 线程。
RemotePush,
RemotePull,
}
/// 口令确认后待执行的远程备份动作,`complete_backup_operation` 返回给
/// 输入层去分发事件(display 自己够不到 event proxy)。
#[derive(Debug, Clone)]
pub(crate) struct RemoteBackupRequest {
pub upload: bool,
pub passphrase: String,
pub selection: crate::encrypted_backup::BackupSelection,
}
/// Shared caret blink phase for the chrome text editors (rename / filter /
@@ -1029,6 +1042,14 @@ pub(crate) fn restore_session_enabled() -> bool {
.unwrap_or(true)
}
/// 托盘图标开关(设置·高级,默认开)。与 [`restore_session_enabled`] 同一
/// 处境:托盘在建窗之前初始化,只能走原始设置读取。
pub(crate) fn tray_enabled() -> bool {
nebula_settings_value("tray")
.map(|v| !matches!(v.trim().to_ascii_lowercase().as_str(), "0" | "false" | "no" | "off"))
.unwrap_or(true)
}
#[cfg(windows)]
fn nebula_pathexts() -> Vec<String> {
std::env::var("PATHEXT")
@@ -1681,6 +1702,9 @@ pub struct Display {
/// 冷恢复时自动接续各 pane 的 AI 对话(claude/codex resume,T1-2)。
/// 消费方在 `window_context::resume_agent_sessions`。
pub nebula_resume_ai: bool,
/// 系统托盘常驻图标 + agent attention 状态(T1-3)。消费方在
/// `crate::tray`;这里只是设置页的开关状态。
pub nebula_tray: bool,
/// Runtime window opacity controlled from Nebula settings.
pub nebula_window_opacity: f32,
/// Which settings combobox (floating option list) is expanded, if any.
@@ -1791,6 +1815,19 @@ pub struct Display {
nebula_provider_codex_confirm: Option<String>,
nebula_backup_selection: crate::encrypted_backup::BackupSelection,
pub(crate) nebula_backup_status: Option<(String, bool)>,
/// 最近一次备份状态来自远程动作(true)还是本地导出/恢复(false)——
/// 状态行画在触发它的那组控件旁边。
nebula_backup_status_remote: bool,
/// 远程备份协议(`nebula_backup.txt` 的缓存,设置页打开时装载)。
nebula_backup_protocol: crate::backup_remote::BackupProtocol,
/// 远程备份的 5 个输入槽草稿(语义随协议变化;密文槽只是「待保存」
/// 缓冲——提交即入凭据管理器并清空,明文从不驻留)。
nebula_backup_remote_inputs: [String; 5],
pub(crate) nebula_backup_remote_focus: Option<usize>,
/// 当前协议的密文凭据是否已在凭据管理器(占位文案用,不回读明文)。
nebula_backup_remote_secret_set: bool,
/// 远程备份/恢复动作进行中(后台线程),按钮变灰防重复分发。
nebula_backup_busy: bool,
/// 聚焦的 SSH 代理输入框(0=代理地址 1=绕过列表;正文直接编辑
/// `nebula_ssh_proxy_url` / `nebula_ssh_proxy_no_proxy`,失焦提交落盘)。
pub(crate) nebula_ssh_proxy_focus: Option<usize>,
@@ -2430,6 +2467,7 @@ impl Display {
nebula_keep_session: settings_init.keep_session,
nebula_restore_session: settings_init.restore_session,
nebula_resume_ai: settings_init.resume_ai,
nebula_tray: settings_init.tray,
nebula_window_opacity: settings_init.opacity,
nebula_background: if settings_init.follow_system_theme {
Some(nebula_theme.palette().term_bg)
@@ -2461,6 +2499,12 @@ impl Display {
nebula_provider_codex_confirm: None,
nebula_backup_selection: crate::encrypted_backup::BackupSelection::default(),
nebula_backup_status: None,
nebula_backup_status_remote: false,
nebula_backup_protocol: Default::default(),
nebula_backup_remote_inputs: Default::default(),
nebula_backup_remote_focus: None,
nebula_backup_remote_secret_set: false,
nebula_backup_busy: false,
nebula_ssh_proxy_focus: None,
nebula_ssh_proxy_cursor: Default::default(),
nebula_ssh_proxy_backup: Default::default(),
@@ -3608,6 +3652,7 @@ impl Display {
keep_session: self.nebula_keep_session,
restore_session: self.nebula_restore_session,
resume_ai: self.nebula_resume_ai,
tray: self.nebula_tray,
opacity: self.nebula_window_opacity,
dragging_opacity: self.nebula_settings_opacity_drag.map(|(target, _, _)| target),
cursor_shape: self.nebula_cursor_shape,
@@ -3682,6 +3727,12 @@ impl Display {
ssh_proxy_overrides: Vec::new(),
backup_selection: self.nebula_backup_selection,
backup_status: self.nebula_backup_status.clone(),
backup_status_remote: self.nebula_backup_status_remote,
backup_protocol: self.nebula_backup_protocol,
backup_remote_inputs: self.nebula_backup_remote_inputs.clone(),
backup_remote_focus: self.nebula_backup_remote_focus,
backup_remote_secret_set: self.nebula_backup_remote_secret_set,
backup_busy: self.nebula_backup_busy,
}
}
@@ -3707,6 +3758,189 @@ impl Display {
self.pending_update.dirty = true;
}
// ---- 设置→备份→远程备份 ----
/// 当前远程备份协议(命中测试要按它裁剪可见输入行)。
pub fn backup_protocol(&self) -> crate::backup_remote::BackupProtocol {
self.nebula_backup_protocol
}
/// 打开设置时装载远程备份状态:协议与非密文字段来自
/// `nebula_backup.txt`,密文只查存在性(明文不进 UI 状态)。
pub fn load_backup_remote_state(&mut self) {
let cfg = crate::backup_remote::BackupRemoteConfig::load();
self.nebula_backup_protocol = cfg.protocol;
self.nebula_backup_remote_inputs = Default::default();
for (index, input) in self.nebula_backup_remote_inputs.iter_mut().enumerate() {
if let Some(value) = cfg.slot(index) {
*input = value.to_owned();
}
}
self.nebula_backup_remote_secret_set =
crate::backup_remote::protocol_secret_set(cfg.protocol);
self.nebula_backup_remote_focus = None;
}
/// 设置页下拉选择远程备份协议:持久化并按新协议重装输入槽。
pub fn set_backup_protocol_option(&mut self, index: usize) {
let Some(protocol) = settings::BACKUP_PROTOCOL_OPTIONS.get(index).copied() else { return };
self.commit_backup_remote_field();
let mut cfg = crate::backup_remote::BackupRemoteConfig::load();
cfg.protocol = protocol;
if let Err(err) = cfg.save() {
self.nebula_backup_status = Some((err, true));
self.nebula_backup_status_remote = true;
}
self.load_backup_remote_state();
self.close_settings_dropdown();
self.pending_update.dirty = true;
}
/// 聚焦某个远程备份输入框;先提交上一个(点击切换即失焦保存)。
pub fn focus_backup_remote_field(&mut self, index: usize) {
if self.nebula_backup_remote_focus == Some(index) {
return;
}
self.commit_backup_remote_field();
let count = crate::backup_remote::field_count(self.nebula_backup_protocol);
if count == 0 {
return;
}
self.nebula_backup_remote_focus = Some(index.min(count - 1));
self.pending_update.dirty = true;
}
pub fn backup_remote_field_push(&mut self, ch: char) {
let Some(index) = self.nebula_backup_remote_focus else { return };
if ch.is_control() {
return;
}
// 密文槽允许内部空格(trim 在保存侧);其余槽拒绝空白——URL、
// 路径、区域名里出现空格只会是误粘贴。
let secret =
crate::backup_remote::secret_field(self.nebula_backup_protocol) == Some(index);
if ch.is_whitespace() && !secret {
return;
}
if self.nebula_backup_remote_inputs[index].chars().count() < 512 {
self.nebula_backup_remote_inputs[index].push(ch);
self.pending_update.dirty = true;
}
}
pub fn backup_remote_field_paste(&mut self, text: &str) {
for ch in text.chars() {
self.backup_remote_field_push(ch);
}
}
pub fn backup_remote_field_backspace(&mut self) {
let Some(index) = self.nebula_backup_remote_focus else { return };
if self.nebula_backup_remote_inputs[index].pop().is_some() {
self.pending_update.dirty = true;
}
}
/// 失焦提交:普通槽写 `nebula_backup.txt`;密文槽若有输入则存入凭据
/// 管理器并清空缓冲。
pub fn commit_backup_remote_field(&mut self) {
let Some(index) = self.nebula_backup_remote_focus.take() else { return };
self.pending_update.dirty = true;
let protocol = self.nebula_backup_protocol;
if crate::backup_remote::secret_field(protocol) == Some(index) {
let secret = std::mem::take(&mut self.nebula_backup_remote_inputs[index]);
if secret.trim().is_empty() {
return;
}
let result = match protocol {
crate::backup_remote::BackupProtocol::WebDav => {
crate::backup_remote::store_webdav_password(
self.nebula_backup_remote_inputs[1].trim(),
&secret,
)
},
crate::backup_remote::BackupProtocol::S3 => {
crate::backup_remote::store_s3_secret(
self.nebula_backup_remote_inputs[3].trim(),
&secret,
)
},
_ => return,
};
match result {
Ok(()) => {
self.nebula_backup_remote_secret_set = true;
},
Err(err) => {
self.nebula_backup_status = Some((err, true));
self.nebula_backup_status_remote = true;
},
}
return;
}
let mut cfg = crate::backup_remote::BackupRemoteConfig::load();
cfg.protocol = protocol;
if cfg.set_slot(index, self.nebula_backup_remote_inputs[index].trim().to_owned()) {
if let Err(err) = cfg.save() {
self.nebula_backup_status = Some((err, true));
self.nebula_backup_status_remote = true;
}
}
}
/// Esc:丢弃当前草稿并失焦(还原为文件值;密文槽清空)。
pub fn cancel_backup_remote_field(&mut self) {
let Some(index) = self.nebula_backup_remote_focus.take() else { return };
let cfg = crate::backup_remote::BackupRemoteConfig::load();
self.nebula_backup_remote_inputs[index] =
cfg.slot(index).map(str::to_owned).unwrap_or_default();
self.pending_update.dirty = true;
}
/// 「备份到远程 / 从远程恢复」按钮:先行校验配置,通过则弹口令确认。
/// 真正的网络动作等口令提交后由事件层在后台线程执行。
pub fn start_backup_remote(&mut self, upload: bool) {
if self.nebula_backup_busy {
return;
}
self.commit_backup_remote_field();
self.nebula_backup_status_remote = true;
if upload && self.nebula_backup_selection.is_empty() {
self.nebula_backup_status = Some((
self.ui_language()
.pick("至少选择一项备份内容", "Select at least one backup item")
.to_owned(),
true,
));
self.window.request_redraw();
return;
}
if let Err(err) = crate::backup_remote::validate() {
self.nebula_backup_status = Some((err, true));
self.window.request_redraw();
return;
}
self.nebula_backup_operation = Some(if upload {
BackupOperation::RemotePush
} else {
BackupOperation::RemotePull
});
self.nebula_backup_passphrase.clear();
self.nebula_backup_passphrase_select_all.clear();
self.nebula_backup_status = None;
self.nebula_confirm = Some(NebulaConfirm::BackupPassphrase { restoring: !upload });
self.window.request_redraw();
}
/// 后台远程备份线程收尾(`NebulaBackupRemoteDone`)。
pub fn backup_remote_done(&mut self, message: &str, error: bool) {
self.nebula_backup_busy = false;
self.nebula_backup_status = Some((message.to_owned(), error));
self.nebula_backup_status_remote = true;
self.pending_update.dirty = true;
self.window.request_redraw();
}
pub fn start_backup_export(&mut self) {
if self.nebula_backup_selection.is_empty() {
self.nebula_backup_status = Some((
@@ -3715,6 +3949,7 @@ impl Display {
.to_owned(),
true,
));
self.nebula_backup_status_remote = false;
self.window.request_redraw();
return;
}
@@ -3775,8 +4010,10 @@ impl Display {
self.window.request_redraw();
}
pub fn complete_backup_operation(&mut self) {
let Some(operation) = self.nebula_backup_operation.clone() else { return };
/// 口令确认。本地导出/恢复同步完成(小文件 + Argon2 一次派生);远程
/// 动作返回请求,由调用方发事件到后台线程——网络不进 UI 线程。
pub fn complete_backup_operation(&mut self) -> Option<RemoteBackupRequest> {
let Some(operation) = self.nebula_backup_operation.clone() else { return None };
let passphrase = self.nebula_backup_passphrase.clone();
let result = match operation {
BackupOperation::Export(path) => {
@@ -3789,7 +4026,47 @@ impl Display {
BackupOperation::Restore(path) => std::fs::read(&path)
.map_err(|error| error.to_string())
.and_then(|packet| crate::encrypted_backup::restore(&packet, &passphrase)),
BackupOperation::RemotePush | BackupOperation::RemotePull => {
self.nebula_backup_status_remote = true;
if passphrase.chars().count() < 8 {
self.nebula_backup_status = Some((
self.ui_language()
.pick(
"备份操作失败: 口令至少 8 个字符",
"Backup operation failed: passphrase must be at least 8 characters",
)
.to_owned(),
true,
));
self.nebula_backup_passphrase.clear();
self.nebula_backup_passphrase_select_all.clear();
self.window.request_redraw();
return None;
}
let upload = matches!(operation, BackupOperation::RemotePush);
self.nebula_backup_busy = true;
self.nebula_backup_status = Some((
self.ui_language()
.pick(
if upload { "备份上传中…" } else { "正在取回远端备份…" },
if upload { "Uploading backup…" } else { "Fetching remote backup…" },
)
.to_owned(),
false,
));
self.nebula_confirm = None;
self.nebula_backup_operation = None;
self.nebula_backup_passphrase.clear();
self.nebula_backup_passphrase_select_all.clear();
self.window.request_redraw();
return Some(RemoteBackupRequest {
upload,
passphrase,
selection: self.nebula_backup_selection,
});
},
};
self.nebula_backup_status_remote = false;
match result {
Ok(()) => {
let restoring = matches!(
@@ -3831,6 +4108,7 @@ impl Display {
},
}
self.window.request_redraw();
None
}
pub fn cancel_backup_operation(&mut self) {
@@ -3860,6 +4138,7 @@ impl Display {
self.cancel_backup_operation();
}
self.commit_sync_field();
self.commit_backup_remote_field();
self.nebula_settings_dropdown = None;
self.nebula_settings_hover = SettingsHit::None;
self.nebula_settings_pressed = SettingsHit::None;
@@ -3867,6 +4146,7 @@ impl Display {
self.nebula_settings_text_drag = None;
} else {
self.load_sync_state();
self.load_backup_remote_state();
// Each explicit visit starts at a predictable page origin.
self.nebula_settings_scroll = 0.0;
self.nebula_settings_text_drag = None;
@@ -7057,9 +7337,19 @@ impl Display {
provider.is_some_and(|provider| provider.codex_goals),
provider.is_some_and(|provider| provider.codex_remote_compaction),
self.nebula_resume_ai,
self.nebula_tray,
]
}
/// 高级:「常驻托盘图标」开关。翻转即生效:托盘线程收到 enable/disable
/// 后立刻挂上或摘掉通知区图标。
pub fn toggle_tray(&mut self) {
self.nebula_tray = !self.nebula_tray;
crate::tray::set_enabled(self.nebula_tray);
self.persist_nebula_settings();
self.pending_update.dirty = true;
}
/// 高级·会话:「恢复时接续 AI 对话」开关。
pub fn toggle_resume_ai(&mut self) {
self.nebula_resume_ai = !self.nebula_resume_ai;
@@ -7511,6 +7801,7 @@ impl Display {
keep_session: self.nebula_keep_session,
restore_session: self.nebula_restore_session,
resume_ai: self.nebula_resume_ai,
tray: self.nebula_tray,
opacity: self.nebula_window_opacity,
background: self.nebula_background,
background_image: self.nebula_background_image.clone(),
@@ -7610,6 +7901,10 @@ impl Display {
self.nebula_keep_session = settings.keep_session;
self.nebula_restore_session = settings.restore_session;
self.nebula_resume_ai = settings.resume_ai;
if self.nebula_tray != settings.tray {
self.nebula_tray = settings.tray;
crate::tray::set_enabled(settings.tray);
}
self.nebula_panel_resize = settings.panel_resize;
// 手改文件把宽度调了的话,和拖拽一样要触发一次 reflow。
let panel_dims_changed = (self.nebula_sidebar_w - settings.sidebar_w).abs() > 0.5
+462 -16
View File
@@ -41,10 +41,10 @@ use super::{
/// 完成前不向用户暴露入口;集中守门可避免绘制、命中和滚动高度各自遗漏。
const SHOW_WEBDAV_SYNC_SETTINGS: bool = false;
/// 备份页的后端已经可用,但页面先不放进侧栏,等恢复流程的可视化预览和
/// 冲突策略交互接入后再重新开放。枚举、命中和导出逻辑继续保留,避免隐藏
/// 入口演变成删除功能。
const SHOW_BACKUP_SETTINGS: bool = false;
/// 备份页对用户开放(2026-08-13):本地加密导出/恢复此前已完整,本次加上
/// 多协议远程备份(目录/WebDAV/S3/SFTP,见 `crate::backup_remote`)后闭环
/// 成立。恢复的可视化预览仍是后续增强,不再作为门禁。
const SHOW_BACKUP_SETTINGS: bool = true;
/// Sidebar sections of the settings panel. Deliberately small: only sections
/// with real functionality behind them are listed.
@@ -127,6 +127,8 @@ pub enum SettingsDropdown {
CompletionStyle,
CursorShape,
TabReveal,
/// 备份:远程备份协议(关闭/目录/WebDAV/S3/SFTP)。
BackupProtocol,
/// 代理:SSH 连接代理模式(关闭/系统/自定义)。
SshProxyMode,
/// 网络→指定代理→手动填写:地址协议(SOCKS5/HTTP)。
@@ -189,6 +191,16 @@ pub(super) const COMPLETION_STYLE_OPTIONS: [CompletionStyle; 2] =
pub(super) const TAB_REVEAL_OPTIONS: [TabRevealMotion; 2] =
[TabRevealMotion::Slide, TabRevealMotion::Instant];
/// 远程备份协议下拉的行序。`display` 的 `set_backup_protocol_option`
/// 按同一数组下标持久化,两侧永不错位。
pub(crate) const BACKUP_PROTOCOL_OPTIONS: [crate::backup_remote::BackupProtocol; 5] = [
crate::backup_remote::BackupProtocol::Off,
crate::backup_remote::BackupProtocol::Folder,
crate::backup_remote::BackupProtocol::WebDav,
crate::backup_remote::BackupProtocol::S3,
crate::backup_remote::BackupProtocol::Sftp,
];
/// 下拉行序即为这里的顺序;连接时的真正决策在 `crate::ssh_proxy`。
pub(super) const SSH_PROXY_MODE_OPTIONS: [crate::ssh_proxy::ProxyMode; 3] = [
crate::ssh_proxy::ProxyMode::Off,
@@ -499,6 +511,91 @@ fn completion_style_label(style: CompletionStyle, language: UiLanguage) -> &'sta
}
}
fn backup_protocol_label(
protocol: crate::backup_remote::BackupProtocol,
language: UiLanguage,
) -> &'static str {
use crate::backup_remote::BackupProtocol;
match protocol {
BackupProtocol::Off => language.pick("关闭", "Off"),
BackupProtocol::Folder => language.pick("本地 / 网络目录", "Local / network folder"),
BackupProtocol::WebDav => "WebDAV",
BackupProtocol::S3 => language.pick("S3 兼容存储", "S3-compatible storage"),
BackupProtocol::Sftp => language.pick("SFTP(SSH 主机)", "SFTP (SSH host)"),
}
}
/// 远程备份输入行的标签(槽位语义随协议变化)。
fn backup_remote_field_label(
protocol: crate::backup_remote::BackupProtocol,
index: usize,
language: UiLanguage,
) -> &'static str {
use crate::backup_remote::BackupProtocol;
match (protocol, index) {
(BackupProtocol::Folder, 0) => language.pick("备份目录", "Backup folder"),
(BackupProtocol::WebDav, 0) => language.pick("目录 URL", "Directory URL"),
(BackupProtocol::WebDav, 1) => language.pick("用户名", "Username"),
(BackupProtocol::WebDav, 2) => language.pick("WebDAV 密码", "WebDAV password"),
(BackupProtocol::S3, 0) => "Endpoint",
(BackupProtocol::S3, 1) => language.pick("区域", "Region"),
(BackupProtocol::S3, 2) => language.pick("存储桶 / 前缀", "Bucket / prefix"),
(BackupProtocol::S3, 3) => "Access Key",
(BackupProtocol::S3, 4) => "Secret Key",
(BackupProtocol::Sftp, 0) => language.pick("SSH 目标", "SSH destination"),
(BackupProtocol::Sftp, 1) => language.pick("远端目录", "Remote directory"),
_ => "",
}
}
/// 远程备份输入框的空值占位示例。
fn backup_remote_field_placeholder(
protocol: crate::backup_remote::BackupProtocol,
index: usize,
language: UiLanguage,
) -> &'static str {
use crate::backup_remote::BackupProtocol;
match (protocol, index) {
(BackupProtocol::Folder, 0) => r"D:\Backups\Nebula 或 \\nas\share\nebula",
(BackupProtocol::WebDav, 0) => "https://dav.example.com/nebula/",
(BackupProtocol::WebDav, 1) => language.pick("WebDAV 用户名", "WebDAV username"),
(BackupProtocol::S3, 0) => "https://s3.us-east-1.amazonaws.com",
(BackupProtocol::S3, 1) => "us-east-1",
(BackupProtocol::S3, 2) => "my-bucket/nebula",
(BackupProtocol::S3, 3) => "AKIA…",
(BackupProtocol::Sftp, 0) => "user@host[:port]",
(BackupProtocol::Sftp, 1) => "/home/user/backups",
_ => language.pick("未设置", "Not set"),
}
}
/// 远程备份输入框的展示内容:`(文本, 是否占位, 列数)`。密文槽显示为掩码
/// 点;超宽截尾部(编辑总发生在末尾)。列数供 caret 定位。
fn backup_remote_input_display(
view: &SettingsView,
index: usize,
max_cols: usize,
) -> (String, bool, usize) {
let language = view.language;
let protocol = view.backup_protocol;
let secret = crate::backup_remote::secret_field(protocol) == Some(index);
let raw = &view.backup_remote_inputs[index];
if raw.is_empty() {
let text = if secret && view.backup_remote_secret_set {
language.pick("已保存(输入以更换)", "Saved (type to replace)").to_owned()
} else {
backup_remote_field_placeholder(protocol, index, language).to_owned()
};
return (text, true, 0);
}
if secret {
let dots = raw.chars().count().min(24);
return ("●".repeat(dots), false, dots);
}
let (text, cols) = text_tail(raw, max_cols);
(text, false, cols)
}
fn language_label(preference: LanguagePreference, language: UiLanguage) -> &'static str {
match preference {
LanguagePreference::System => language.pick("跟随系统", "Follow system"),
@@ -617,6 +714,8 @@ pub enum SettingsHit {
RestoreSessionToggle,
/// 高级: 冷恢复时自动接续各 pane 里的 AI 对话(claude/codex resume)。
ResumeAiToggle,
/// 高级: 常驻系统托盘图标(agent 等待输入时变 attention 态)。
TrayToggle,
/// 高级→同步: 输入框(0=url 1=用户名 2=WebDAV 密码 3=E2E 口令)。
SyncInput(usize),
SyncAutoPullToggle,
@@ -652,12 +751,19 @@ pub enum SettingsHit {
BackupSelection(usize),
BackupExport,
BackupRestore,
/// 备份→远程备份: 协议下拉触发行 + 展开的选项行。
BackupProtocolCycle,
BackupProtocolOption(usize),
/// 备份→远程备份: 输入框(槽位语义随协议变化)。
BackupRemoteField(usize),
BackupRemotePush,
BackupRemotePull,
}
/// Stable animation slots for the settings switches. Rendering and the shared
/// motion clock both use this mapping, so one switch can never borrow another
/// switch's thumb position while the pointer moves between rows.
pub(super) const SETTINGS_TOGGLE_COUNT: usize = 16;
pub(super) const SETTINGS_TOGGLE_COUNT: usize = 17;
pub(super) fn settings_toggle_slot(hit: SettingsHit) -> Option<usize> {
Some(match hit {
@@ -678,6 +784,7 @@ pub(super) fn settings_toggle_slot(hit: SettingsHit) -> Option<usize> {
SettingsHit::ProviderCodexRemoteToggle => 14,
// 追加在尾部:槽位是稳定映射,重排会让开关借走彼此的动画状态。
SettingsHit::ResumeAiToggle => 15,
SettingsHit::TrayToggle => 16,
_ => return None,
})
}
@@ -726,6 +833,8 @@ pub(super) struct NebulaRuntimeSettings {
pub(super) restore_session: bool,
/// 高级·会话:冷恢复时自动接续各 pane 的 AI 对话(claude/codex resume)。
pub(super) resume_ai: bool,
/// 高级:常驻系统托盘图标。
pub(super) tray: bool,
/// 窗口背景模糊。Windows 11 上是 Mica(见
/// `display::window::apply_windows_backdrop`),macOS / Wayland 上走
/// winit 自己的实现。默认开:纯 alpha 会让背景的高频细节直接透上来压在
@@ -815,6 +924,9 @@ pub(super) fn nebula_settings_load(config: &UiConfig) -> NebulaRuntimeSettings {
// 默认开:恢复布局却丢下正聊到一半的对话,等于只恢复了一半现场。
// 关掉它仍恢复标签/分屏/目录,只是不再敲 resume。
resume_ai: true,
// 默认开:托盘是 agent 等待提醒的常驻出口(任务栏闪烁会被忽略、
// toast 会过期);不想要常驻图标的人在设置里关。
tray: true,
// 2026-07-31 用户裁定:默认开。纯 alpha 下背景的高频细节压着文字,
// 那正是"透明度调低就看不清"的物理来源;模糊把它拍成低频色块。想
// 真透出后面窗口内容的人可以关掉。
@@ -920,6 +1032,7 @@ pub(super) fn nebula_settings_load(config: &UiConfig) -> NebulaRuntimeSettings {
Some(("keep_session", v)) => settings.keep_session = parse_bool(v, false),
Some(("restore_session", v)) => settings.restore_session = parse_bool(v, true),
Some(("resume_ai", v)) => settings.resume_ai = parse_bool(v, true),
Some(("tray", v)) => settings.tray = parse_bool(v, true),
Some(("panel_resize", v)) => settings.panel_resize = parse_bool(v, false),
Some(("sidebar_w", v)) => {
if let Ok(w) = v.trim().parse::<f32>() {
@@ -1134,7 +1247,7 @@ pub(super) fn nebula_settings_write(settings: &NebulaRuntimeSettings) {
path,
format!(
"language={}\ntheme={theme}\nfollow_system_theme={}\nghost={}\naccept={accept}\ncompletion_style={completion_style}\nshell={shell}\nstartup_directory={startup_directory}\nfont_family={}\nfont_size={font_size}\ncursor_shape={}\ncursor_blink={}\ncopy_on_select={}\ncjk_bold_regular={}
tab_reveal={}\ndensity={}\nnew_tab_position={}\ncell_width_mode={}\nfetch={}\npowerline={}\nkeep_session={}\nrestore_session={}\nresume_ai={}\nblur={}\nopacity={:.2}\nbackground={background}\nbackground_image={background_image}\nbackground_image_opacity={:.2}\nbackground_image_fit={}\nbackground_image_alignment={}\nbackground_image_cover_chrome={}\npanel_resize={}\nsidebar_w={:.0}\ndrawer_w={:.0}\nhosts_band={:.0}\npinned_hosts={pinned_hosts}\nsaved_hosts={saved_hosts}\nhidden_hosts={hidden_hosts}\nssh_proxy_mode={ssh_proxy_mode}\nssh_proxy_url={ssh_proxy_url}\nssh_proxy_no_proxy={ssh_proxy_no_proxy}\nquick_terminal_hotkey={quick_terminal_hotkey}\n{keybinds}",
tab_reveal={}\ndensity={}\nnew_tab_position={}\ncell_width_mode={}\nfetch={}\npowerline={}\nkeep_session={}\nrestore_session={}\nresume_ai={}\ntray={}\nblur={}\nopacity={:.2}\nbackground={background}\nbackground_image={background_image}\nbackground_image_opacity={:.2}\nbackground_image_fit={}\nbackground_image_alignment={}\nbackground_image_cover_chrome={}\npanel_resize={}\nsidebar_w={:.0}\ndrawer_w={:.0}\nhosts_band={:.0}\npinned_hosts={pinned_hosts}\nsaved_hosts={saved_hosts}\nhidden_hosts={hidden_hosts}\nssh_proxy_mode={ssh_proxy_mode}\nssh_proxy_url={ssh_proxy_url}\nssh_proxy_no_proxy={ssh_proxy_no_proxy}\nquick_terminal_hotkey={quick_terminal_hotkey}\n{keybinds}",
settings.language.as_str(),
settings.follow_system_theme as u8,
settings.ghost as u8,
@@ -1152,6 +1265,7 @@ tab_reveal={}\ndensity={}\nnew_tab_position={}\ncell_width_mode={}\nfetch={}\npo
settings.keep_session as u8,
settings.restore_session as u8,
settings.resume_ai as u8,
settings.tray as u8,
settings.blur as u8,
settings.opacity,
settings.background_image_opacity,
@@ -1278,6 +1392,8 @@ struct SettingsGeometry {
restore_session: (f32, f32, f32, f32),
/// 高级·会话:冷恢复自动接续 AI 对话。
resume_ai: (f32, f32, f32, f32),
/// 高级:常驻系统托盘图标。
tray: (f32, f32, f32, f32),
/// 网络页:主模式仍是下拉框;指定代理分支按 HTML 原型排成扫描标题、
/// 一张连续列表卡、选中项展开、绕过列表与每主机覆盖。
ssh_proxy_mode: (f32, f32, f32, f32),
@@ -1302,6 +1418,10 @@ struct SettingsGeometry {
backup_groups: [(f32, f32, f32, f32); 4],
backup_rows: [(f32, f32, f32, f32); 9],
backup_actions: (f32, f32, f32, f32),
/// 远程备份组:协议下拉行、5 个输入槽行(按协议裁剪可见数)、动作行。
backup_remote_protocol: (f32, f32, f32, f32),
backup_remote_fields: [(f32, f32, f32, f32); 5],
backup_remote_actions: (f32, f32, f32, f32),
backup_h: f32,
}
@@ -1311,7 +1431,7 @@ fn settings_viewport_h(popup_h: f32, scale_factor: f32) -> f32 {
}
fn advanced_content_end(advanced_y0: f32, sync_y0: f32, row_h: f32) -> f32 {
if SHOW_WEBDAV_SYNC_SETTINGS { sync_y0 + 7.0 * row_h } else { advanced_y0 + 3.0 * row_h }
if SHOW_WEBDAV_SYNC_SETTINGS { sync_y0 + 7.0 * row_h } else { advanced_y0 + 4.0 * row_h }
}
/// Max scroll offset for `section` at the current window size. The input
@@ -1627,7 +1747,8 @@ fn settings_geometry(
let keep_session = (row_x, at(advanced_y0), row_w, row_h);
let restore_session = (row_x, at(advanced_y0 + ROW_H), row_w, row_h);
let resume_ai = (row_x, at(advanced_y0 + ROW_H * 2.0), row_w, row_h);
let sync_y0 = advanced_y0 + ROW_H * 3.0 + GROUP_ADVANCE;
let tray = (row_x, at(advanced_y0 + ROW_H * 3.0), row_w, row_h);
let sync_y0 = advanced_y0 + ROW_H * 4.0 + GROUP_ADVANCE;
let sync_row = |i: f32| (row_x, at(sync_y0 + i * ROW_H), row_w, row_h);
let sync_rows = [sync_row(0.0), sync_row(1.0), sync_row(2.0), sync_row(3.0)];
let sync_auto_pull = sync_row(4.0);
@@ -1714,7 +1835,25 @@ fn settings_geometry(
backup_row(812.0), // fonts
];
let backup_actions = backup_segment;
let backup_h = s(864.0 + 40.0 - 72.0);
// 远程备份组接在清单卡之后(清单行位一个不动):协议下拉 + 至多 5 个
// 输入行 + 动作行。行位按字段最多的协议(S3=5)预留;字段更少的协议
// 隐藏尾部行,动作行经 `backup_remote_actions_rect` 上移贴住可见行。
let backup_remote_y0 = 944.0;
let backup_remote_protocol = (row_x, at(backup_remote_y0), row_w, row_h);
let backup_remote_field = |index: usize| {
(row_x, at(backup_remote_y0 + (1.0 + index as f32) * ROW_H), row_w, row_h)
};
let backup_remote_fields = [
backup_remote_field(0),
backup_remote_field(1),
backup_remote_field(2),
backup_remote_field(3),
backup_remote_field(4),
];
let backup_remote_actions_y = backup_remote_y0 + 6.0 * ROW_H + 12.0;
let backup_remote_actions =
(row_x, at(backup_remote_actions_y), s(300.0).min(row_w), s(38.0));
let backup_h = s(backup_remote_actions_y + 38.0 + 64.0 - 72.0);
SettingsGeometry {
gear,
@@ -1824,6 +1963,7 @@ fn settings_geometry(
keep_session,
restore_session,
resume_ai,
tray,
ssh_proxy_mode,
ssh_proxy_scan_head,
ssh_proxy_scan_button,
@@ -1844,6 +1984,9 @@ fn settings_geometry(
backup_groups,
backup_rows,
backup_actions,
backup_remote_protocol,
backup_remote_fields,
backup_remote_actions,
backup_h,
}
}
@@ -2165,6 +2308,9 @@ fn dropdown_anchor(
(Section::Profiles, SettingsDropdown::CompletionStyle) => {
Some((anchor(geometry.completion_style), COMPLETION_STYLE_OPTIONS.len()))
},
(Section::Backup, SettingsDropdown::BackupProtocol) => {
Some((anchor(geometry.backup_remote_protocol), BACKUP_PROTOCOL_OPTIONS.len()))
},
(Section::Appearance, SettingsDropdown::CellWidthMode) => {
Some((anchor(geometry.cell_width_mode), CELL_WIDTH_MODE_OPTIONS.len()))
},
@@ -2413,6 +2559,7 @@ pub fn settings_hit(
proxy: ProxyPaneState,
keymap_pane: KeymapPaneState,
provider_count: usize,
backup_protocol: crate::backup_remote::BackupProtocol,
) -> SettingsHit {
let mut geometry = settings_geometry(
size_info,
@@ -2498,6 +2645,7 @@ pub fn settings_hit(
SettingsDropdown::Language => SettingsHit::Language(LANGUAGE_OPTIONS[index]),
SettingsDropdown::Accept => SettingsHit::AcceptOption(index),
SettingsDropdown::CompletionStyle => SettingsHit::CompletionStyleOption(index),
SettingsDropdown::BackupProtocol => SettingsHit::BackupProtocolOption(index),
SettingsDropdown::TabReveal => SettingsHit::TabRevealOption(index),
SettingsDropdown::Density => SettingsHit::DensityOption(index),
SettingsDropdown::NewTabPosition => SettingsHit::NewTabPositionOption(index),
@@ -2851,6 +2999,9 @@ pub fn settings_hit(
if contains_rect(widgets::toggle_rect(geometry.resume_ai, scale_factor), x, y) {
return SettingsHit::ResumeAiToggle;
}
if contains_rect(widgets::toggle_rect(geometry.tray, scale_factor), x, y) {
return SettingsHit::TrayToggle;
}
if SHOW_WEBDAV_SYNC_SETTINGS {
for (index, rect) in geometry.sync_rows.iter().enumerate() {
// 命中整行都算输入框:行左侧是它的 label,点标签聚焦
@@ -2888,6 +3039,34 @@ pub fn settings_hit(
if contains_rect(restore, x, y) {
return SettingsHit::BackupRestore;
}
if contains_rect(
widgets::combobox_rect(geometry.backup_remote_protocol, scale_factor),
x,
y,
) {
return SettingsHit::BackupProtocolCycle;
}
let field_count = crate::backup_remote::field_count(backup_protocol);
for (index, rect) in
geometry.backup_remote_fields.iter().take(field_count).enumerate()
{
// 命中整行都算输入框:行左侧是它的 label,点标签聚焦
// 输入是 Windows 设置页的惯例(与同步行一致)。
if contains_rect(*rect, x, y) {
return SettingsHit::BackupRemoteField(index);
}
}
if backup_protocol != crate::backup_remote::BackupProtocol::Off {
let actions =
backup_remote_actions_rect(&geometry, scale_factor, field_count);
let [push, pull] = sync_button_rects(actions, scale_factor);
if contains_rect(push, x, y) {
return SettingsHit::BackupRemotePush;
}
if contains_rect(pull, x, y) {
return SettingsHit::BackupRemotePull;
}
}
},
}
}
@@ -2976,6 +3155,8 @@ pub(super) struct SettingsView {
pub(super) restore_session: bool,
/// 高级·会话:冷恢复自动接续 AI 对话。
pub(super) resume_ai: bool,
/// 高级:常驻系统托盘图标。
pub(super) tray: bool,
pub(super) blur: bool,
pub(super) opacity: f32,
/// Which opacity slider is mid-drag, for thumb-dot grow feedback.
@@ -3057,6 +3238,14 @@ pub(super) struct SettingsView {
pub(super) ssh_proxy_overrides: Vec<(String, String, usize)>,
pub(super) backup_selection: BackupSelection,
pub(super) backup_status: Option<(String, bool)>,
/// 状态行画在触发动作的控件旁:true = 远程组下方,false = 清单下方。
pub(super) backup_status_remote: bool,
/// 远程备份:协议、5 个输入槽草稿、聚焦槽位、密文凭据存在性、动作忙。
pub(super) backup_protocol: crate::backup_remote::BackupProtocol,
pub(super) backup_remote_inputs: [String; 5],
pub(super) backup_remote_focus: Option<usize>,
pub(super) backup_remote_secret_set: bool,
pub(super) backup_busy: bool,
}
/// 同步行右侧的输入框矩形(quad/text/hit 三处共用)。行左侧留给标签。
@@ -3315,6 +3504,20 @@ fn backup_segment_rects(
]
}
/// 远程备份动作行紧跟当前协议的最后一个可见输入行(字段数随协议变化,
/// 行位跟着上移)。hit / quad / text 三个 pass 共用,按钮与点击区不漂移。
/// 行距从几何自身推导(ROW_H 随密度变化,是 `settings_geometry` 的局部)。
fn backup_remote_actions_rect(
geometry: &SettingsGeometry,
scale: f32,
field_count: usize,
) -> (f32, f32, f32, f32) {
let (bx, _, bw, bh) = geometry.backup_remote_actions;
let pitch = geometry.backup_remote_fields[1].1 - geometry.backup_remote_fields[0].1;
let y = geometry.backup_remote_fields[0].1 + field_count as f32 * pitch + 12.0 * scale;
(bx, y, bw, bh)
}
fn backup_item_selected(selection: BackupSelection, index: usize) -> bool {
match index {
0 => selection.appearance,
@@ -3403,6 +3606,9 @@ fn dropdown_selected_index(view: &SettingsView, dropdown: SettingsDropdown) -> O
SettingsDropdown::CompletionStyle => {
COMPLETION_STYLE_OPTIONS.iter().position(|style| *style == view.completion_style)
},
SettingsDropdown::BackupProtocol => {
BACKUP_PROTOCOL_OPTIONS.iter().position(|protocol| *protocol == view.backup_protocol)
},
SettingsDropdown::TabReveal => {
TAB_REVEAL_OPTIONS.iter().position(|motion| *motion == view.tab_reveal)
},
@@ -3443,6 +3649,7 @@ fn dropdown_hover_index(hover: SettingsHit, dropdown: SettingsDropdown) -> Optio
(SettingsDropdown::CompletionStyle, SettingsHit::CompletionStyleOption(index)) => {
Some(index)
},
(SettingsDropdown::BackupProtocol, SettingsHit::BackupProtocolOption(index)) => Some(index),
(SettingsDropdown::TabReveal, SettingsHit::TabRevealOption(index)) => Some(index),
(SettingsDropdown::Density, SettingsHit::DensityOption(index)) => Some(index),
(SettingsDropdown::NewTabPosition, SettingsHit::NewTabPositionOption(index)) => Some(index),
@@ -4633,7 +4840,7 @@ pub(super) fn push_quads(
}
},
NebulaSettingsSection::Advanced => {
group_frame(quads, geometry.keep_session, 3);
group_frame(quads, geometry.keep_session, 4);
row_hover(quads, geometry.keep_session, view.hover == SettingsHit::KeepSessionToggle);
toggle(
quads,
@@ -4668,6 +4875,16 @@ pub(super) fn push_quads(
view.hover == SettingsHit::ResumeAiToggle,
view.pressed == SettingsHit::ResumeAiToggle,
);
row_hover(quads, geometry.tray, view.hover == SettingsHit::TrayToggle);
toggle(
quads,
&mut staged,
geometry.tray,
SettingsHit::TrayToggle,
view.tray,
view.hover == SettingsHit::TrayToggle,
view.pressed == SettingsHit::TrayToggle,
);
if SHOW_WEBDAV_SYNC_SETTINGS {
// ---- 同步(WebDAV,spec 003):4 输入行 + 自动拉取开关 ----
@@ -4878,6 +5095,91 @@ pub(super) fn push_quads(
clip(quads, UiQuad::solid(cb.0, cb.1, cb.2, cb.3, chip_radius, sk.panel));
}
}
// ---- 远程备份:协议下拉 + 按协议裁剪的输入行 + 动作行 ----
{
let field_count = crate::backup_remote::field_count(view.backup_protocol);
let group_rows = 1 + field_count;
group_frame(quads, geometry.backup_remote_protocol, group_rows);
row_hover(
quads,
geometry.backup_remote_protocol,
view.hover == SettingsHit::BackupProtocolCycle,
);
combobox(
quads,
&mut staged,
geometry.backup_remote_protocol,
view.hover == SettingsHit::BackupProtocolCycle,
view.dropdown == Some(SettingsDropdown::BackupProtocol),
);
let cell_w = size.cell_width();
for (index, row) in
geometry.backup_remote_fields.iter().take(field_count).enumerate()
{
row_hover(quads, *row, view.hover == SettingsHit::BackupRemoteField(index));
let rect = sync_input_rect(*row, scale);
let (ix, iy, iw, ih) = rect;
let focused = view.backup_remote_focus == Some(index);
let border = if focused {
Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 255)
} else {
Rgba::new(sk.ink_dim.r, sk.ink_dim.g, sk.ink_dim.b, 90)
};
let mut stroke = Vec::new();
surface::push_stroke(&mut stroke, rect, control_radius, scale, border);
for quad in stroke {
clip(quads, quad);
}
clip(quads, UiQuad::solid(ix, iy, iw, ih, control_radius, sk.surface));
if focused && super::caret_blink_on() {
let max_cols = (((iw - s(24.0)) / cell_w) as usize).max(1);
let (_, placeholder, cols) =
backup_remote_input_display(view, index, max_cols);
let cols = if placeholder { 0 } else { cols };
let caret_h = ih - s(10.0);
clip(
quads,
UiQuad::solid(
(ix + s(12.0) + cols as f32 * cell_w).min(ix + iw - s(6.0)),
iy + (ih - caret_h) / 2.0,
(1.5 * scale).max(1.0),
caret_h,
0.0,
Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 255),
),
);
}
}
// 动作行:两个独立按钮(协议关闭时不画,忙时变灰不吃 hover)。
if view.backup_protocol != crate::backup_remote::BackupProtocol::Off {
let actions = backup_remote_actions_rect(&geometry, scale, field_count);
let [push_rect, pull_rect] = sync_button_rects(actions, scale);
for (rect, hit) in [
(push_rect, SettingsHit::BackupRemotePush),
(pull_rect, SettingsHit::BackupRemotePull),
] {
let (bx, by, bw, bh) = rect;
let hot = view.hover == hit && !view.backup_busy;
let mut stroke = Vec::new();
surface::push_stroke(&mut stroke, rect, control_radius, scale, sk.hairline);
for quad in stroke {
clip(quads, quad);
}
clip(
quads,
UiQuad::solid(
bx,
by,
bw,
bh,
control_radius,
if hot { sk.hover } else { sk.panel },
),
);
}
}
}
},
}
@@ -5358,6 +5660,9 @@ pub(super) fn draw_popup_text(
SettingsDropdown::CompletionStyle => {
completion_style_label(COMPLETION_STYLE_OPTIONS[index], language).to_owned()
},
SettingsDropdown::BackupProtocol => {
backup_protocol_label(BACKUP_PROTOCOL_OPTIONS[index], language).to_owned()
},
SettingsDropdown::TabReveal => {
tab_reveal_label(TAB_REVEAL_OPTIONS[index], language).to_owned()
},
@@ -7347,6 +7652,26 @@ pub(super) fn draw_text(
);
}
}
{
let (_, ry, _, rh) = geometry.tray;
if visible(ry, rh) {
// 关掉立即摘图标;agent 提醒仍有 toast 和任务栏闪烁兜底。
row_label(
r,
gc,
size,
scale,
&sk,
geometry.tray,
language.pick(
"常驻系统托盘图标(agent 等待输入时变色提醒)",
"System tray icon (turns amber when an agent needs you)",
),
"",
sk.ink,
);
}
}
if SHOW_WEBDAV_SYNC_SETTINGS {
// ---- 同步(WebDAV)----
@@ -7617,9 +7942,100 @@ pub(super) fn draw_text(
);
}
}
// ---- 远程备份 ----
let field_count = crate::backup_remote::field_count(view.backup_protocol);
{
let (rx, ry, _, rh) = geometry.backup_remote_protocol;
let remote_title_y = ry - s(30.0);
if visible(remote_title_y, title_h) {
section_title(
r,
gc,
size,
scale,
&sk,
rx,
remote_title_y,
language.pick("远程备份", "Remote backup"),
);
}
if visible(ry, rh) {
row_label(
r,
gc,
size,
scale,
&sk,
geometry.backup_remote_protocol,
language.pick("协议", "Protocol"),
"",
sk.ink,
);
combobox_value(
r,
gc,
geometry.backup_remote_protocol,
backup_protocol_label(view.backup_protocol, language),
sk.ink,
);
}
for (index, row) in
geometry.backup_remote_fields.iter().take(field_count).enumerate()
{
if !visible(row.1, row.3) {
continue;
}
row_label(
r,
gc,
size,
scale,
&sk,
*row,
backup_remote_field_label(view.backup_protocol, index, language),
"",
sk.ink,
);
let (ix, iy, iw, ih) = sync_input_rect(*row, scale);
let max_cols = (((iw - s(24.0)) / cell_w) as usize).max(1);
let (text, placeholder, _) = backup_remote_input_display(view, index, max_cols);
let ink = if placeholder { sk.ink_dim } else { sk.ink };
r.draw_chrome_text(size, ix + s(12.0), iy + (ih - cell_h) / 2.0, ink, &text, gc);
}
if view.backup_protocol != crate::backup_remote::BackupProtocol::Off {
let actions = backup_remote_actions_rect(&geometry, scale, field_count);
if visible(actions.1, actions.3) {
let [push_rect, pull_rect] = sync_button_rects(actions, scale);
let captions = [
(push_rect, language.pick("备份到远程", "Back up now")),
(pull_rect, language.pick("恢复最新备份", "Restore latest")),
];
for ((bx, by, bw, bh), caption) in captions {
let cols: usize =
caption.chars().map(|c| c.width().unwrap_or(1).max(1)).sum();
let ink = if view.backup_busy { sk.ink_dim } else { sk.ink };
r.draw_chrome_text(
size,
bx + (bw - cols as f32 * cell_w) / 2.0,
by + (bh - cell_h) / 2.0,
ink,
caption,
gc,
);
}
}
}
}
// 状态行画在触发动作的那组控件下方(本地导出/恢复 → 清单卡尾;
// 远程动作 → 远程按钮行下)。
if let Some((status, error)) = &view.backup_status {
let last = geometry.backup_rows[8];
let status_y = last.1 + last.3 + s(8.0);
let status_y = if view.backup_status_remote {
let actions = backup_remote_actions_rect(&geometry, scale, field_count);
actions.1 + actions.3 + s(8.0)
} else {
let last = geometry.backup_rows[8];
last.1 + last.3 + s(8.0)
};
if visible(status_y, cell_h) {
r.draw_chrome_text(
size,
@@ -7734,13 +8150,40 @@ mod tests {
fn hidden_webdav_group_does_not_extend_advanced_content() {
assert!(!SHOW_WEBDAV_SYNC_SETTINGS);
// SSH 已迁到独立页面;隐藏同步组不能继续把 Advanced 撑高。会话组
// 现为 3 行:保留会话 / 恢复会话 / 恢复时接续 AI 对话(resume_ai)。
assert_eq!(advanced_content_end(146.0, 308.0, 44.0), 278.0);
// 现为 4 行:保留会话 / 恢复会话 / 恢复时接续 AI 对话(resume_ai)
// / 常驻托盘图标(tray)。
assert_eq!(advanced_content_end(146.0, 308.0, 44.0), 322.0);
}
#[test]
fn backup_settings_entry_stays_gated_until_restore_workflow_is_complete() {
assert!(!SHOW_BACKUP_SETTINGS);
fn backup_settings_entry_is_exposed_with_remote_destinations() {
// 2026-08-13:远程备份(目录/WebDAV/S3/SFTP)接入后备份页开放。
assert!(SHOW_BACKUP_SETTINGS);
}
#[test]
fn backup_remote_actions_track_visible_field_count() {
let size = SizeInfo::new(1600.0, 1000.0, 8.0, 16.0, 0.0, 0.0, false);
let area = (0.0, 40.0, 1600.0, 960.0);
let geometry = settings_geometry(
&size,
1.0,
area,
0.0,
0,
0,
Density::Standard,
ProxyPaneState::default(),
KeymapPaneState::default(),
);
// 满配(S3 = 5 字段)时动作行与几何里的静态槽位重合。
let full = super::backup_remote_actions_rect(&geometry, 1.0, 5);
assert!((full.1 - geometry.backup_remote_actions.1).abs() < 0.5);
// 字段更少的协议动作行上移,且始终排在最后一个可见行之后。
let folder = super::backup_remote_actions_rect(&geometry, 1.0, 1);
assert!(folder.1 < full.1);
let first_field = geometry.backup_remote_fields[0];
assert!(folder.1 >= first_field.1 + first_field.3);
}
#[test]
@@ -7868,6 +8311,7 @@ mod tests {
ProxyPaneState::default(),
KeymapPaneState::default(),
provider_count,
Default::default(),
);
assert_eq!(hit, SettingsHit::ProviderRow(13));
}
@@ -7989,6 +8433,7 @@ mod tests {
proxy,
KeymapPaneState::default(),
6,
Default::default(),
)
};
// 命中矩形与绘制矩形同源:用 hit-test 内部同款控件几何函数取靶。
@@ -8067,6 +8512,7 @@ mod tests {
proxy,
KeymapPaneState::default(),
6,
Default::default(),
)
};
let (protocol, address) = ssh_proxy_manual_controls(geometry.ssh_proxy_expand, 1.0);
+3 -1
View File
@@ -33,8 +33,10 @@ pub(crate) enum BackupCategory {
Fonts,
}
// `pub`(而非 pub(crate)):作为 `EventType::NebulaBackupRemote` 的字段随
// 公有枚举可达;bin crate 里只为消除 private-interfaces 告警。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct BackupSelection {
pub struct BackupSelection {
pub appearance: bool,
pub config: bool,
pub ssh: bool,
+56
View File
@@ -911,6 +911,39 @@ impl ApplicationHandler<Event> for Processor {
window_context.handle_sync_done(&message, error, history_changed);
}
},
// 远程备份(设置→备份):打包、Argon2 派生与网络都在后台 OS
// 线程阻塞完成,主循环只发起与收尾——与 WebDAV 同步同一模型。
(EventType::NebulaBackupRemote { upload, passphrase, selection }, _) => {
let proxy = self.proxy.clone();
std::thread::spawn(move || {
let result = if upload {
crate::encrypted_backup::collect(selection)
.and_then(|archive| {
crate::encrypted_backup::seal(&archive, &passphrase)
})
.and_then(|packet| crate::backup_remote::push(&packet))
} else {
crate::backup_remote::pull_latest().and_then(|(name, packet)| {
crate::encrypted_backup::restore(&packet, &passphrase)
.map(|()| format!("已从远端恢复 {name},重启后应用全部设置"))
})
};
crate::backup_remote::warn_result(&result);
let (message, error) = match result {
Ok(message) => (message, false),
Err(err) => (err, true),
};
let _ = proxy.send_event(crate::event::Event::new(
EventType::NebulaBackupRemoteDone { message, error },
None,
));
});
},
(EventType::NebulaBackupRemoteDone { message, error }, _) => {
for window_context in self.windows.values_mut() {
window_context.handle_backup_remote_done(&message, error);
}
},
(EventType::LocalProxyScan, Some(window_id)) => {
let proxy = self.proxy.clone();
let window_id = *window_id;
@@ -1247,6 +1280,15 @@ impl ApplicationHandler<Event> for Processor {
window_context.dirty = true;
window_context.display.window.request_redraw();
}
// 托盘 agent 清单同样搭 1 Hz 时钟:跨窗口聚合,tray::update
// 内容不变时自去抖。多窗口各自的 tick 都会走到这里,最先到
// 的那个完成本秒的发布,其余是廉价 no-op。
let agents = self
.windows
.values()
.flat_map(|window_context| window_context.tray_agents())
.collect();
crate::tray::update(agents);
},
(EventType::NebulaResizeSettled, Some(window_id)) => {
if let Some(window_context) = self.windows.get_mut(window_id) {
@@ -1809,6 +1851,18 @@ impl<'a, N: Notify + 'a, T: EventListener> input::ActionContext<T> for ActionCon
});
}
fn nebula_backup_remote(&self, request: crate::display::RemoteBackupRequest) {
let _ = self.event_proxy.send_event(Event {
window_id: Some(self.display.window.id()),
tab_id: None,
payload: EventType::NebulaBackupRemote {
upload: request.upload,
passphrase: request.passphrase,
selection: request.selection,
},
});
}
fn nebula_local_proxy_scan(&mut self) {
if !self.display.take_local_proxy_scan_request() {
return;
@@ -2989,6 +3043,8 @@ impl input::Processor<EventProxy, ActionContext<'_, Notifier, EventProxy>> {
| EventType::AiFixReady { .. }
| EventType::NebulaSync { .. }
| EventType::NebulaSyncDone { .. }
| EventType::NebulaBackupRemote { .. }
| EventType::NebulaBackupRemoteDone { .. }
| EventType::LocalProxyScan
| EventType::LocalProxyScanDone(_)
| EventType::FocusWindow { .. } => (),
+13
View File
@@ -78,6 +78,19 @@ pub enum EventType {
error: bool,
history_changed: bool,
},
/// 远程备份请求(设置→备份,口令确认后)。打包/加密/网络都在后台
/// 线程;口令只在事件载荷里过一手,线程用完即弃。
NebulaBackupRemote {
upload: bool,
passphrase: String,
selection: crate::encrypted_backup::BackupSelection,
},
/// 后台远程备份线程完成(消息文本已含成功/失败语义,恢复成功的
/// 提示里带「重启后应用」)。
NebulaBackupRemoteDone {
message: String,
error: bool,
},
/// 设置→网络的本机代理握手扫描。请求在后台线程运行,结果回到目标窗口。
LocalProxyScan,
LocalProxyScanDone(Vec<crate::ssh_proxy::LocalProxyEndpoint>),
+12
View File
@@ -260,6 +260,18 @@ impl NebulaWorkspace {
),
)
.child(v_flex().flex_1().gap_1().children(items))
.child(
h_flex().px_1().child(
Button::new("sidebar-settings")
.icon(IconName::Settings)
.ghost()
.small()
.tooltip("设置 (Ctrl+,)")
.on_click(cx.listener(|this, _, window, cx| {
this.open_settings(window, cx);
})),
),
)
}
}
+43 -1
View File
@@ -91,7 +91,11 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
self.ctx.open_path(&directory);
},
NebulaConfirm::BackupPassphrase { .. } => {
self.ctx.display().complete_backup_operation();
// 本地导出/恢复同步完成;远程动作返回请求,转成事件在后台
// 线程执行(打包、Argon2 与网络都不进 UI 线程)。
if let Some(request) = self.ctx.display().complete_backup_operation() {
self.ctx.nebula_backup_remote(request);
}
},
}
}
@@ -815,6 +819,7 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
self.ctx.display().ssh_proxy_pane_state(),
self.ctx.display().keymap_pane_state(),
self.ctx.display().provider_count(),
self.ctx.display().backup_protocol(),
);
// Keep the primary-button target for the settings renderer's
// HTML-like toggle active state until the matching release.
@@ -841,6 +846,9 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
if !matches!(settings_hit, crate::display::SettingsHit::SyncInput(_)) {
self.ctx.display().commit_sync_field();
}
if !matches!(settings_hit, crate::display::SettingsHit::BackupRemoteField(_)) {
self.ctx.display().commit_backup_remote_field();
}
if !matches!(settings_hit, crate::display::SettingsHit::ProviderField(_)) {
self.ctx.display().commit_provider_field();
}
@@ -884,6 +892,33 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
self.ctx.mark_dirty();
return;
},
crate::display::SettingsHit::BackupProtocolCycle => {
self.ctx
.display()
.toggle_settings_dropdown(crate::display::SettingsDropdown::BackupProtocol);
self.ctx.mark_dirty();
return;
},
crate::display::SettingsHit::BackupProtocolOption(index) => {
self.ctx.display().set_backup_protocol_option(index);
self.ctx.mark_dirty();
return;
},
crate::display::SettingsHit::BackupRemoteField(index) => {
self.ctx.display().focus_backup_remote_field(index);
self.ctx.mark_dirty();
return;
},
crate::display::SettingsHit::BackupRemotePush => {
self.ctx.display().start_backup_remote(true);
self.ctx.mark_dirty();
return;
},
crate::display::SettingsHit::BackupRemotePull => {
self.ctx.display().start_backup_remote(false);
self.ctx.mark_dirty();
return;
},
crate::display::SettingsHit::BackupRestore => {
self.ctx.display().start_backup_restore();
self.ctx.mark_dirty();
@@ -927,6 +962,11 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
self.ctx.mark_dirty();
return;
},
crate::display::SettingsHit::TrayToggle => {
self.ctx.display().toggle_tray();
self.ctx.mark_dirty();
return;
},
crate::display::SettingsHit::AcceptCycle => {
self.ctx
.display()
@@ -1662,6 +1702,8 @@ fn settings_dropdown_keeps_open(hit: crate::display::SettingsHit) -> bool {
| Hit::AcceptOption(_)
| Hit::CompletionStyleCycle
| Hit::CompletionStyleOption(_)
| Hit::BackupProtocolCycle
| Hit::BackupProtocolOption(_)
| Hit::TabRevealDropdown
| Hit::TabRevealOption(_)
| Hit::DensityDropdown
+35
View File
@@ -161,6 +161,41 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
return;
}
// 设置→备份→远程备份的输入框独占键盘:语义与同步输入框一致
// (Enter/Tab 提交、Esc 还原、Ctrl+V 粘贴、其余字符追加)。
if self.ctx.display().settings_open()
&& self.ctx.display().nebula_backup_remote_focus.is_some()
{
match &key.logical_key {
Key::Named(NamedKey::Enter) | Key::Named(NamedKey::Tab) => {
self.ctx.display().commit_backup_remote_field();
},
Key::Named(NamedKey::Escape) => {
self.ctx.display().cancel_backup_remote_field();
},
Key::Named(NamedKey::Backspace) => {
self.ctx.display().backup_remote_field_backspace();
},
Key::Named(NamedKey::Space) => {
self.ctx.display().backup_remote_field_push(' ');
},
Key::Character(text) => {
if mods.control_key() && text.eq_ignore_ascii_case("v") {
let paste = self.ctx.clipboard_mut().load(ClipboardType::Clipboard);
self.ctx.display().backup_remote_field_paste(&paste);
} else if !mods.control_key() {
for ch in text.chars() {
self.ctx.display().backup_remote_field_push(ch);
}
}
},
_ => {},
}
self.ctx.display().update_settings_ime_cursor();
self.ctx.mark_dirty();
return;
}
// 设置→供应商输入框:普通元数据可编辑,API Key 只在提交瞬间
// 写入系统凭据管理器,随后立即清空草稿,避免明文残留在 UI 状态。
if self.ctx.display().settings_open() && self.ctx.display().nebula_provider_focus.is_some()
+2
View File
@@ -136,6 +136,8 @@ pub trait ActionContext<T: EventListener> {
fn refresh_terminal_profiles(&mut self) {}
/// Kick a WebDAV sync (spec 003). true = push, false = pull.
fn nebula_sync(&self, _push: bool) {}
/// 口令确认后的远程备份/恢复:发事件到后台线程执行。
fn nebula_backup_remote(&self, _request: crate::display::RemoteBackupRequest) {}
fn nebula_local_proxy_scan(&mut self) {}
fn nebula_proxy_test(&mut self) {}
+8 -1
View File
@@ -355,6 +355,7 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
self.ctx.display().ssh_proxy_pane_state(),
self.ctx.display().keymap_pane_state(),
self.ctx.display().provider_count(),
self.ctx.display().backup_protocol(),
);
let chrome_hover = if crate::display::in_chrome_bar(&window_size, scale, x as f32, y as f32)
{
@@ -448,6 +449,7 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
| crate::display::SettingsHit::KeepSessionToggle
| crate::display::SettingsHit::RestoreSessionToggle
| crate::display::SettingsHit::ResumeAiToggle
| crate::display::SettingsHit::TrayToggle
| crate::display::SettingsHit::BackgroundColor
| crate::display::SettingsHit::BackgroundSvPlane
| crate::display::SettingsHit::BackgroundHueBar
@@ -501,11 +503,16 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
},
crate::display::SettingsHit::BackupSelection(_)
| crate::display::SettingsHit::BackupExport
| crate::display::SettingsHit::BackupRestore => {
| crate::display::SettingsHit::BackupRestore
| crate::display::SettingsHit::BackupProtocolCycle
| crate::display::SettingsHit::BackupProtocolOption(_)
| crate::display::SettingsHit::BackupRemotePush
| crate::display::SettingsHit::BackupRemotePull => {
self.ctx.window().set_mouse_cursor(CursorIcon::Pointer);
return;
},
crate::display::SettingsHit::SyncInput(_)
| crate::display::SettingsHit::BackupRemoteField(_)
| crate::display::SettingsHit::ProviderField(_)
| crate::display::SettingsHit::SshProxyInput(_)
// 搜索框是文本字段,光标要变 I 形——和其它输入框一致。
+10
View File
@@ -32,6 +32,7 @@ mod ai_hook;
mod ai_providers;
mod ai_sessions;
mod atomic_file;
mod backup_remote;
mod cli;
mod clipboard;
mod codex_config;
@@ -84,6 +85,7 @@ mod ssh_sftp;
mod string;
mod sync;
mod terminal_profiles;
mod tray;
mod update_check;
mod ux;
mod window_context;
@@ -303,6 +305,10 @@ fn nebula(mut options: Options) -> Result<(), Box<dyn Error>> {
{
ai_hook::spawn_server(window_event_loop.create_proxy());
ai_hook::spawn_config_guard();
// 托盘 attention(T1-3):常驻图标 + agent 状态菜单。开关读原始
// 设置存储——事件循环还没有任何窗口/Display 可问。
tray::init(window_event_loop.create_proxy());
tray::set_enabled(display::tray_enabled());
}
#[cfg(all(feature = "x11", not(any(target_os = "macos", windows))))]
@@ -385,6 +391,10 @@ fn nebula(mut options: Options) -> Result<(), Box<dyn Error>> {
// Start event loop and block until shutdown.
let result = processor.run(window_event_loop);
// 摘掉托盘图标:进程退出后残影会留在通知区直到用户悬停。
#[cfg(windows)]
tray::shutdown();
// `Processor` must be dropped before calling `FreeConsole`.
//
// This is needed for ConPTY backend. Otherwise a deadlock can occur.
+2
View File
@@ -46,6 +46,8 @@ const SYNC_KEYS: &[&str] = &[
"powerline",
"keep_session",
"restore_session",
"resume_ai",
"tray",
"panel_resize",
"sidebar_w",
"drawer_w",
+590
View File
@@ -0,0 +1,590 @@
//! 系统托盘常驻图标 + agent attention 状态(future_planning `T1-3`)。
//!
//! 终端最常见的姿态是「压在别的窗口底下跑着 agent」:任务栏闪烁会被忽略、
//! toast 会过期,而托盘图标一直在。任一 agent 停下来等输入时图标翻转成
//! attention 态(右下角橙点),右键菜单列出所有 agent pane 与状态,点击
//! 直达来源 pane——复用 toast 点击同一条 [`EventType::FocusWindow`] 路径,
//! 因此聚焦语义(还原最小化、选中 tab、聚焦 pane)与通知横幅完全一致。
//!
//! # 线程模型
//!
//! `Shell_NotifyIconW` 的回调消息需要一个窗口收;不能挂在 winit 的窗口上
//! (fork 治理红线:不往窗口层塞策略)。托盘因此拥有自己的线程:一扇永不
//! 显示的 Win32 窗口 + 独立消息泵。app 侧只做两件事——把 agent 清单写进
//! [`STATE`]、`PostMessageW` 摇醒托盘线程;托盘线程反向只通过
//! `EventLoopProxy` 发用户事件。两个方向都不共享锁跨越阻塞调用。
//!
//! 状态推送挂在既有的 1 Hz chrome 时钟(与会话自动保存同一节拍):托盘是
//! 环境信息面,秒级延迟无感,换来的是零新增定时器与天然去抖。
#![cfg_attr(not(windows), allow(dead_code))]
use winit::window::WindowId;
/// 托盘菜单里的一行:一个正在跑 AI CLI 的 pane。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TrayAgent {
/// 宿主窗口(点击后 `FocusWindow` 的路由目标)。
pub window: WindowId,
pub pane: u64,
/// 菜单文案:「claude · nebula」(程序名 + cwd 尾段)。
pub label: String,
/// CLI 停在半路等用户(权限确认/提问),比「回合完成」更强。
pub needs_attention: bool,
}
#[cfg(windows)]
pub use win::{init, set_enabled, shutdown, update};
#[cfg(not(windows))]
pub fn init(_proxy: winit::event_loop::EventLoopProxy<crate::event::Event>) {}
#[cfg(not(windows))]
pub fn set_enabled(_enabled: bool) {}
#[cfg(not(windows))]
pub fn update(_agents: Vec<TrayAgent>) {}
#[cfg(not(windows))]
pub fn shutdown() {}
#[cfg(windows)]
mod win {
use std::cell::RefCell;
use std::sync::atomic::{AtomicBool, AtomicIsize, Ordering};
use std::sync::{Mutex, OnceLock};
use winit::event_loop::EventLoopProxy;
use super::TrayAgent;
use crate::event::{Event, EventType};
/// 与 notify.rs 的 toast 图标同一份官方 logo,托盘不会出现第二个品牌形态。
const LOGO_PNG: &[u8] = include_bytes!("../../extra/logo/nebula.png");
/// 托盘回调(lParam 携带鼠标消息;未 SETVERSION 的经典 v0 语义,
/// Win7 至今行为一致,不依赖 v4 的坐标打包)。
const WM_APP_TRAY: u32 = windows_sys::Win32::UI::WindowsAndMessaging::WM_APP + 1;
/// STATE.agents 变了:重读并 NIM_MODIFY 图标/气泡提示。
const WM_APP_REFRESH: u32 = windows_sys::Win32::UI::WindowsAndMessaging::WM_APP + 2;
/// STATE.enabled 变了:NIM_ADD / NIM_DELETE。
const WM_APP_SET: u32 = windows_sys::Win32::UI::WindowsAndMessaging::WM_APP + 3;
/// 进程收尾:删图标 + 退出消息泵(不删的话图标要等用户悬停才消失)。
const WM_APP_SHUTDOWN: u32 = windows_sys::Win32::UI::WindowsAndMessaging::WM_APP + 4;
/// 菜单命令 id 的起点;`MENU_AGENT_BASE + i` = 聚焦第 i 个 agent。
const MENU_AGENT_BASE: usize = 1000;
/// 「显示 Nebula」——没有 agent 时托盘仍要能把窗口捞回来。
const MENU_SHOW: usize = 1;
struct Shared {
agents: Vec<TrayAgent>,
enabled: bool,
}
static PROXY: OnceLock<EventLoopProxy<Event>> = OnceLock::new();
static STATE: Mutex<Shared> = Mutex::new(Shared { agents: Vec::new(), enabled: false });
/// 托盘窗口句柄(isize 形态跨线程存取;0 = 线程未起或窗口未建)。
static HWND: AtomicIsize = AtomicIsize::new(0);
static THREAD_STARTED: AtomicBool = AtomicBool::new(false);
fn state() -> std::sync::MutexGuard<'static, Shared> {
// 中毒只表示某线程带锁 panic;清单本身是普通数据,继续用。
STATE.lock().unwrap_or_else(|e| e.into_inner())
}
/// 装好事件代理。真正的线程按需(首次启用)才起。
pub fn init(proxy: EventLoopProxy<Event>) {
let _ = PROXY.set(proxy);
}
/// 开/关托盘图标(设置·高级)。开 = 保证线程在跑并 NIM_ADD;
/// 关 = 只删图标,线程留着(重开是高频往复,不值得反复建窗)。
pub fn set_enabled(enabled: bool) {
state().enabled = enabled;
if enabled {
ensure_thread();
}
post(WM_APP_SET);
}
/// 发布最新 agent 清单。内容没变就不打扰托盘线程(1 Hz 调用方无需去抖)。
pub fn update(agents: Vec<TrayAgent>) {
{
let mut shared = state();
if !shared.enabled || shared.agents == agents {
return;
}
shared.agents = agents;
}
post(WM_APP_REFRESH);
}
/// 进程退出前删掉图标。best-effort:托盘线程没起过就什么都不用做。
pub fn shutdown() {
post(WM_APP_SHUTDOWN);
}
fn post(message: u32) {
let hwnd = HWND.load(Ordering::Acquire);
if hwnd != 0 {
// SAFETY: hwnd 由托盘线程发布且只在 WM_APP_SHUTDOWN 后失效;
// PostMessageW 对已销毁窗口安全失败。
unsafe {
windows_sys::Win32::UI::WindowsAndMessaging::PostMessageW(
hwnd as _,
message,
0,
0,
);
}
}
}
fn ensure_thread() {
if THREAD_STARTED.swap(true, Ordering::SeqCst) {
return;
}
if let Err(err) =
std::thread::Builder::new().name("nebula-tray".into()).spawn(tray_thread)
{
THREAD_STARTED.store(false, Ordering::SeqCst);
log::warn!("tray: failed to spawn tray thread: {err}");
}
}
// ─── 托盘线程 ────────────────────────────────────────────────────────
/// 托盘线程本地资源:两个状态图标 + 图标是否已挂在通知区。
struct Icons {
normal: isize,
attention: isize,
added: bool,
}
thread_local! {
static ICONS: RefCell<Option<Icons>> = const { RefCell::new(None) };
}
fn tray_thread() {
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW;
use windows_sys::Win32::UI::WindowsAndMessaging::{
CW_USEDEFAULT, CreateWindowExW, DispatchMessageW, GetMessageW, MSG, RegisterClassW,
TranslateMessage, WNDCLASSW, WS_OVERLAPPED,
};
let class_name = wide("NebulaTrayWindow");
// SAFETY: 类名/实例句柄在调用期间有效;重复注册返回 0 也无妨——
// 单进程只会走到这里一次(THREAD_STARTED 闸)。
unsafe {
let hinstance = GetModuleHandleW(std::ptr::null());
let wc = WNDCLASSW {
style: 0,
lpfnWndProc: Some(tray_wnd_proc),
cbClsExtra: 0,
cbWndExtra: 0,
hInstance: hinstance,
hIcon: std::ptr::null_mut(),
hCursor: std::ptr::null_mut(),
hbrBackground: std::ptr::null_mut(),
lpszMenuName: std::ptr::null(),
lpszClassName: class_name.as_ptr(),
};
RegisterClassW(&wc);
// 隐藏顶层窗口(不是 message-only:通知区回调对 HWND_MESSAGE
// 窗口在部分 Windows 版本上不投递)。永不 ShowWindow。
let hwnd = CreateWindowExW(
0,
class_name.as_ptr(),
wide("Nebula Tray").as_ptr(),
WS_OVERLAPPED,
CW_USEDEFAULT,
CW_USEDEFAULT,
0,
0,
std::ptr::null_mut(),
std::ptr::null_mut(),
hinstance,
std::ptr::null(),
);
if hwnd.is_null() {
log::warn!("tray: CreateWindowExW failed; tray icon disabled");
return;
}
HWND.store(hwnd as isize, Ordering::Release);
let icons = build_icons();
ICONS.with(|slot| *slot.borrow_mut() = Some(Icons {
normal: icons.0 as isize,
attention: icons.1 as isize,
added: false,
}));
// init 与首次 set_enabled 之间可能已经积累了状态:起跑先对齐。
apply_enabled(hwnd);
let mut msg: MSG = std::mem::zeroed();
while GetMessageW(&mut msg, std::ptr::null_mut(), 0, 0) > 0 {
TranslateMessage(&msg);
DispatchMessageW(&msg);
}
}
}
unsafe extern "system" fn tray_wnd_proc(
hwnd: windows_sys::Win32::Foundation::HWND,
msg: u32,
wparam: usize,
lparam: isize,
) -> isize {
use windows_sys::Win32::UI::WindowsAndMessaging::{
DefWindowProcW, PostQuitMessage, WM_LBUTTONUP, WM_RBUTTONUP,
};
match msg {
WM_APP_TRAY => {
match lparam as u32 {
// 左键 = 最短路径:直达最需要人的 pane。
WM_LBUTTONUP => focus_best(),
WM_RBUTTONUP => show_menu(hwnd),
_ => {},
}
0
},
WM_APP_REFRESH => {
refresh_icon(hwnd);
0
},
WM_APP_SET => {
apply_enabled(hwnd);
0
},
WM_APP_SHUTDOWN => {
remove_icon(hwnd);
// SAFETY: 本线程的消息泵,退出后线程结束。
unsafe { PostQuitMessage(0) };
0
},
// SAFETY: 转发给默认过程,参数原样透传。
_ => unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) },
}
}
/// 左键/菜单兜底的聚焦策略:先挑等输入的,再挑任意 agent,最后裸窗口。
fn focus_best() {
let target = {
let shared = state();
shared
.agents
.iter()
.find(|agent| agent.needs_attention)
.or_else(|| shared.agents.first())
.cloned()
};
send_focus(target);
}
fn send_focus(agent: Option<TrayAgent>) {
let Some(proxy) = PROXY.get() else { return };
let event = match agent {
Some(agent) => {
Event::new(EventType::FocusWindow { pane: Some(agent.pane) }, agent.window)
},
// 没有 agent:把第一扇窗捞到前台(processor 对 None 窗口 id 的
// FocusWindow 就是这个语义)。
None => Event::new(EventType::FocusWindow { pane: None }, None),
};
let _ = proxy.send_event(event);
}
fn show_menu(hwnd: windows_sys::Win32::Foundation::HWND) {
use windows_sys::Win32::Foundation::POINT;
use windows_sys::Win32::UI::WindowsAndMessaging::{
AppendMenuW, CreatePopupMenu, DestroyMenu, GetCursorPos, MF_GRAYED, MF_SEPARATOR,
MF_STRING, SetForegroundWindow, TPM_NONOTIFY, TPM_RETURNCMD, TPM_RIGHTBUTTON,
TrackPopupMenu, WM_NULL,
};
// 菜单期间用快照:TrackPopupMenu 是模态泵,STATE 可能被 1 Hz 更新,
// 命令 id 必须映射回打开菜单那一刻的清单。
let agents = state().agents.clone();
// SAFETY: 菜单句柄本函数创建/销毁;文案缓冲活过 AppendMenuW(菜单
// 复制内容)。SetForegroundWindow + WM_NULL 是 TrackPopupMenu 在
// 托盘场景的官方仪式(否则菜单点外部不收合)。
unsafe {
let menu = CreatePopupMenu();
if menu.is_null() {
return;
}
if agents.is_empty() {
let text = wide("没有正在运行的 agent");
AppendMenuW(menu, MF_STRING | MF_GRAYED, 0, text.as_ptr());
}
for (index, agent) in agents.iter().enumerate() {
// 状态点进文案:菜单不画自定义图形,实心/空心圈已经把
// 「等你」和「在跑」分开。
let mark = if agent.needs_attention { "● " } else { "○ " };
let suffix = if agent.needs_attention { " — 等待输入" } else { "" };
let text = wide(&format!("{mark}{}{suffix}", agent.label));
AppendMenuW(menu, MF_STRING, MENU_AGENT_BASE + index, text.as_ptr());
}
AppendMenuW(menu, MF_SEPARATOR, 0, std::ptr::null());
let show = wide("显示 Nebula");
AppendMenuW(menu, MF_STRING, MENU_SHOW, show.as_ptr());
let mut point = POINT { x: 0, y: 0 };
GetCursorPos(&mut point);
SetForegroundWindow(hwnd);
let cmd = TrackPopupMenu(
menu,
TPM_RIGHTBUTTON | TPM_RETURNCMD | TPM_NONOTIFY,
point.x,
point.y,
0,
hwnd,
std::ptr::null(),
) as usize;
windows_sys::Win32::UI::WindowsAndMessaging::PostMessageW(hwnd, WM_NULL, 0, 0);
DestroyMenu(menu);
if cmd == MENU_SHOW {
send_focus(None);
} else if cmd >= MENU_AGENT_BASE {
send_focus(agents.get(cmd - MENU_AGENT_BASE).cloned());
}
}
}
// ─── 通知区图标 ──────────────────────────────────────────────────────
fn notify_data(
hwnd: windows_sys::Win32::Foundation::HWND,
) -> windows_sys::Win32::UI::Shell::NOTIFYICONDATAW {
// SAFETY: NOTIFYICONDATAW 是纯 POD,零值是合法起点。
let mut data: windows_sys::Win32::UI::Shell::NOTIFYICONDATAW =
unsafe { std::mem::zeroed() };
data.cbSize = std::mem::size_of::<windows_sys::Win32::UI::Shell::NOTIFYICONDATAW>() as u32;
data.hWnd = hwnd;
data.uID = 1;
data
}
/// 依据 STATE 计算图标形态并 NIM_MODIFY;未挂图标时什么都不做。
fn refresh_icon(hwnd: windows_sys::Win32::Foundation::HWND) {
use windows_sys::Win32::UI::Shell::{NIF_ICON, NIF_TIP, NIM_MODIFY, Shell_NotifyIconW};
let (agent_count, attention_count) = {
let shared = state();
let attention =
shared.agents.iter().filter(|agent| agent.needs_attention).count();
(shared.agents.len(), attention)
};
ICONS.with(|slot| {
let slot = slot.borrow();
let Some(icons) = slot.as_ref().filter(|icons| icons.added) else { return };
let mut data = notify_data(hwnd);
data.uFlags = NIF_ICON | NIF_TIP;
data.hIcon =
(if attention_count > 0 { icons.attention } else { icons.normal }) as _;
let tip = if agent_count == 0 {
"Nebula".to_owned()
} else if attention_count > 0 {
format!("Nebula — {attention_count} 个 agent 等待输入")
} else {
format!("Nebula — {agent_count} 个 agent 运行中")
};
copy_tip(&mut data.szTip, &tip);
// SAFETY: data 完整初始化且 hwnd 属于本线程。
unsafe { Shell_NotifyIconW(NIM_MODIFY, &data) };
});
}
fn apply_enabled(hwnd: windows_sys::Win32::Foundation::HWND) {
use windows_sys::Win32::UI::Shell::{
NIF_ICON, NIF_MESSAGE, NIF_TIP, NIM_ADD, Shell_NotifyIconW,
};
let enabled = state().enabled;
ICONS.with(|slot| {
let mut slot = slot.borrow_mut();
let Some(icons) = slot.as_mut() else { return };
if enabled && !icons.added {
let mut data = notify_data(hwnd);
data.uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP;
data.uCallbackMessage = WM_APP_TRAY;
data.hIcon = icons.normal as _;
copy_tip(&mut data.szTip, "Nebula");
// SAFETY: 同上;失败(如 explorer 未起)留 added=false,
// 下一次 WM_APP_SET 重试。
if unsafe { Shell_NotifyIconW(NIM_ADD, &data) } != 0 {
icons.added = true;
}
} else if !enabled && icons.added {
let data = notify_data(hwnd);
// SAFETY: 删除只需要 hWnd/uID。
unsafe {
windows_sys::Win32::UI::Shell::Shell_NotifyIconW(
windows_sys::Win32::UI::Shell::NIM_DELETE,
&data,
)
};
icons.added = false;
}
});
if enabled {
refresh_icon(hwnd);
}
}
fn remove_icon(hwnd: windows_sys::Win32::Foundation::HWND) {
ICONS.with(|slot| {
let mut slot = slot.borrow_mut();
if let Some(icons) = slot.as_mut().filter(|icons| icons.added) {
let data = notify_data(hwnd);
// SAFETY: 同 apply_enabled 的删除分支。
unsafe {
windows_sys::Win32::UI::Shell::Shell_NotifyIconW(
windows_sys::Win32::UI::Shell::NIM_DELETE,
&data,
)
};
icons.added = false;
}
});
}
// ─── 图标像素 ────────────────────────────────────────────────────────
/// 从内嵌 logo 造两个 HICON:常态 + attention(右下角橙点)。
/// 失败退化为空句柄——Shell_NotifyIconW 会用系统默认图标,托盘仍可用。
fn build_icons() -> (
windows_sys::Win32::UI::WindowsAndMessaging::HICON,
windows_sys::Win32::UI::WindowsAndMessaging::HICON,
) {
use windows_sys::Win32::UI::WindowsAndMessaging::{GetSystemMetrics, SM_CXSMICON};
// SAFETY: GetSystemMetrics 无前置条件。
let size = unsafe { GetSystemMetrics(SM_CXSMICON) }.clamp(16, 64) as u32;
let Ok(decoded) = image::load_from_memory(LOGO_PNG) else {
log::warn!("tray: embedded logo failed to decode");
return (std::ptr::null_mut(), std::ptr::null_mut());
};
let base = image::imageops::resize(
&decoded.to_rgba8(),
size,
size,
image::imageops::FilterType::Lanczos3,
);
let normal = create_icon(size, base.as_raw());
let mut attention_pixels = base.clone();
draw_attention_dot(&mut attention_pixels, size);
let attention = create_icon(size, attention_pixels.as_raw());
(normal, attention)
}
/// 右下角实心橙点(约 5/8 直径比)。挑警示橙而不是主题 accent:托盘
/// 图标不知道主题,且橙点在亮暗任务栏上对比都够。
fn draw_attention_dot(pixels: &mut image::RgbaImage, size: u32) {
let radius = (size as f32) * 0.30;
let center = (size as f32) - radius - 0.5;
for y in 0..size {
for x in 0..size {
let dx = x as f32 - center;
let dy = y as f32 - center;
let distance = (dx * dx + dy * dy).sqrt();
// 1px 羽化边,免得小尺寸下是一颗锯齿方块。
let coverage = (radius - distance + 0.5).clamp(0.0, 1.0);
if coverage > 0.0 {
let pixel = pixels.get_pixel_mut(x, y);
let blend = |src: u8, dst: u8| -> u8 {
(src as f32 * coverage + dst as f32 * (1.0 - coverage)) as u8
};
*pixel = image::Rgba([
blend(255, pixel[0]),
blend(140, pixel[1]),
blend(0, pixel[2]),
blend(255, pixel[3]),
]);
}
}
}
}
/// RGBA(直 alpha)→ HICON:32bpp 预乘 BGRA DIB + 单色掩码。
fn create_icon(
size: u32,
rgba: &[u8],
) -> windows_sys::Win32::UI::WindowsAndMessaging::HICON {
use windows_sys::Win32::Graphics::Gdi::{
BI_RGB, BITMAPINFO, BITMAPINFOHEADER, CreateBitmap, CreateDIBSection, DIB_RGB_COLORS,
DeleteObject,
};
use windows_sys::Win32::UI::WindowsAndMessaging::{CreateIconIndirect, ICONINFO};
// SAFETY: header 描述的缓冲由 CreateDIBSection 分配并在拷贝期间有效;
// 两个位图在 CreateIconIndirect 后立即释放(图标持有自己的副本)。
unsafe {
let mut header: BITMAPINFOHEADER = std::mem::zeroed();
header.biSize = std::mem::size_of::<BITMAPINFOHEADER>() as u32;
header.biWidth = size as i32;
// 负高 = 自顶向下,行序与解码像素一致,免得手工翻转。
header.biHeight = -(size as i32);
header.biPlanes = 1;
header.biBitCount = 32;
header.biCompression = BI_RGB;
let info = BITMAPINFO { bmiHeader: header, bmiColors: std::mem::zeroed() };
let mut bits: *mut core::ffi::c_void = std::ptr::null_mut();
let color = CreateDIBSection(
std::ptr::null_mut(),
&info,
DIB_RGB_COLORS,
&mut bits,
std::ptr::null_mut(),
0,
);
if color.is_null() || bits.is_null() {
return std::ptr::null_mut();
}
let out = std::slice::from_raw_parts_mut(bits.cast::<u8>(), rgba.len());
for (dst, src) in out.chunks_exact_mut(4).zip(rgba.chunks_exact(4)) {
let alpha = src[3] as u32;
// 预乘:DWM 按 premultiplied 合成托盘图标,直 alpha 会在
// 深色任务栏上泛白边。
dst[0] = (src[2] as u32 * alpha / 255) as u8;
dst[1] = (src[1] as u32 * alpha / 255) as u8;
dst[2] = (src[0] as u32 * alpha / 255) as u8;
dst[3] = src[3];
}
let mask = CreateBitmap(size as i32, size as i32, 1, 1, std::ptr::null());
let icon_info = ICONINFO {
fIcon: 1,
xHotspot: 0,
yHotspot: 0,
hbmMask: mask,
hbmColor: color,
};
let icon = CreateIconIndirect(&icon_info);
DeleteObject(color);
DeleteObject(mask);
icon
}
}
// ─── 小工具 ─────────────────────────────────────────────────────────
fn wide(text: &str) -> Vec<u16> {
text.encode_utf16().chain(Some(0)).collect()
}
/// UTF-16 截断拷贝进 szTip(128 wchar,含 NUL)。
fn copy_tip(tip: &mut [u16; 128], text: &str) {
let mut cursor = 0;
for unit in text.encode_utf16() {
if cursor >= tip.len() - 1 {
break;
}
tip[cursor] = unit;
cursor += 1;
}
tip[cursor] = 0;
}
}
+62
View File
@@ -2199,6 +2199,54 @@ impl WindowContext {
true
}
/// 托盘菜单的数据面(T1-3):本窗口所有正在跑 AI CLI 的 pane。与侧栏
/// 徽章同一事实源(`running_program` + `needs_attention`),托盘因此
/// 永远和 pane 徽章一致,不另立第二份 agent 状态。
pub fn tray_agents(&self) -> Vec<crate::tray::TrayAgent> {
/// 托盘只列 agent 类 CLI(hook 直报的四家 + 图标表认识的其他家)。
/// vim/cargo 这类长命令不进托盘——它们不会「停下来等你回答」。
fn is_agent(program: &str) -> bool {
matches!(
program,
"claude"
| "codex"
| "opencode"
| "pi"
| "gemini"
| "copilot"
| "cursor-agent"
| "grok"
| "grok-cli"
| "aider"
| "goose"
| "crush"
)
}
self.panes
.iter()
.filter_map(|pane| {
let state = &pane.nebula_state;
let program = state.running_program.as_deref().filter(|p| is_agent(p))?;
// 「哪个项目」比「哪个 pane id」有意义:取 cwd 尾段。
let place = std::path::Path::new(state.cwd.trim())
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default();
let label = if place.is_empty() {
program.to_owned()
} else {
format!("{program} · {place}")
};
Some(crate::tray::TrayAgent {
window: self.display.window.id(),
pane: pane.id,
label,
needs_attention: state.needs_attention,
})
})
.collect()
}
/// 后台修复请求(spec 001)的结果落地:pane 归属本窗口即认领(返回
/// true,AiHook 同款路由契约)。写入前校验 seq——条子已被用户撤掉、或
/// 新失败已顶掉旧请求时,迟到的响应直接丢弃。
@@ -2219,6 +2267,20 @@ impl WindowContext {
true
}
/// 远程备份线程收尾:设置页状态行是第一现场,message bar 兜底通知
/// 没开设置页的窗口。
pub fn handle_backup_remote_done(&mut self, message: &str, error: bool) {
self.display.backup_remote_done(message, error);
let ty = if error {
crate::message_bar::MessageType::Error
} else {
crate::message_bar::MessageType::Warning
};
self.message_buffer.push(crate::message_bar::Message::new(format!("备份:{message}"), ty));
self.dirty = true;
self.display.window.request_redraw();
}
/// 同步线程收尾(spec 003):消息进 message bar;拉到新历史时热加载
/// (ghost 补全立即吃到另一台机器的命令)。settings 变化不用管——
/// mtime 监视在下一帧自动 reload。