feat(tray): 关闭按钮询问「最小化到托盘 / 退出应用」,并修复 Windows 重复启动

关闭行为从「托盘可用就隐藏」改成先看用户偏好:

1. 新增 `AppSettings.close_button_behavior`(`ask` / `minimize_to_tray` / `quit`,
   默认 `ask`,未知值回退 `ask`),`main_window_close_action(tray_ready, behavior)`
   纯策略三态:托盘不可用一律 `RequestQuit`,不与偏好互动 —— 宁可退出也不能
   制造用户找不到、也没法恢复的隐藏窗口。
2. `AskUser` 弹窗的两条出路各自渲染按钮(`tray-close-minimize` / `tray-close-quit`),
   带「记住我的选择」,勾选后落盘。刻意不用默认 ok/cancel footer:默认 footer
   只有两个固定按钮,「取消」兼职退出会让 Esc 和点遮罩也走成退出应用;而
   `button_props` 跟在 `confirm` 之后设置还会把 `show_cancel` 重置、吞掉第二个
   按钮(前一版就是这么丢的)。
3. 「记住」勾选框放在独立 entity:弹窗 body 由 `Root` 渲染,`NavopApp::notify`
   重绘不到它。`close_choice_prompt_open` 防连点叠窗,关窗复位。
4. 设置页通用页新增「关闭窗口行为」下拉,与弹窗写同一份偏好;托盘不可用时
   该设置不生效。

Windows 单实例:interprocess 的命名管道带 `FILE_FLAG_FIRST_PIPE_INSTANCE`,名字
已存在时返回 `ERROR_ACCESS_DENIED(5)` 而非 `AddrInUse`,且错误原样透传、无 error
kind 归一化,旧判定永不命中 ⇒ 第二个实例把自己当主实例、起出完整进程。改为
`instance_name_taken()` 认 `AddrInUse` 或 `raw_os_error` ∈ {5, 231},其余错误不
误判为「已占用」。

运行:`cargo test -p one-core --lib settings`(112 passed)、`cargo test -p main`
(634 passed);`cargo check -p main --all-targets`、`cargo clippy -p main
--all-targets`(改动文件零告警)通过。Windows 重复启动与弹窗交互仍需真机冒烟。
This commit is contained in:
胡飞
2026-09-18 16:30:52 +08:00
parent ec34181bc1
commit bd34cc4643
7 changed files with 590 additions and 50 deletions
+92 -3
View File
@@ -888,12 +888,48 @@ fn default_connection_sidebar_tree_width() -> u32 {
DEFAULT_CONNECTION_SIDEBAR_TREE_WIDTH
}
/// 点击主窗口关闭按钮时的行为。
///
/// 只在系统托盘可用时生效:托盘不可用时一律回退到退出确认,不制造无法恢复的隐藏窗口。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CloseButtonBehavior {
/// 弹窗询问:最小化到托盘,还是退出应用(弹窗内可记住选择)
#[default]
Ask,
/// 直接最小化到托盘
MinimizeToTray,
/// 直接走退出确认
Quit,
}
impl CloseButtonBehavior {
pub fn as_str(&self) -> &'static str {
match self {
CloseButtonBehavior::Ask => "ask",
CloseButtonBehavior::MinimizeToTray => "minimize_to_tray",
CloseButtonBehavior::Quit => "quit",
}
}
/// 未知取值回退到 [`CloseButtonBehavior::Ask`]:旧版本写下的值不能把新版本卡死。
pub fn from_str(value: &str) -> Self {
match value {
"minimize_to_tray" => CloseButtonBehavior::MinimizeToTray,
"quit" => CloseButtonBehavior::Quit,
_ => CloseButtonBehavior::Ask,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppSettings {
#[serde(default)]
pub main_window_size: Option<MainWindowSize>,
#[serde(default)]
pub main_window_state: Option<MainWindowState>,
#[serde(default)]
pub close_button_behavior: CloseButtonBehavior,
#[serde(default = "default_locale")]
pub locale: String,
#[serde(default = "default_theme_mode")]
@@ -1312,6 +1348,7 @@ impl Default for AppSettings {
Self {
main_window_size: None,
main_window_state: None,
close_button_behavior: CloseButtonBehavior::default(),
locale: default_locale(),
theme_mode: default_theme_mode(),
auto_switch_theme: false,
@@ -1693,9 +1730,10 @@ mod tests {
use gpui_component::{Theme, ThemeMode};
use super::{
AiChatSettings, AiChatToolExecutionMode, AppSettings, ConnectionSortOrder, CustomFont,
DEFAULT_AI_REQUEST_TIMEOUT_SECS, DEFAULT_MCP_APPROVAL_TIMEOUT_MS, DEFAULT_TERMINAL_THEME,
HomeConnectionLayout, LOCALE_SYSTEM, LargeTextCellEditorOpenMode, LocalTerminalProfileKind,
AiChatSettings, AiChatToolExecutionMode, AppSettings, CloseButtonBehavior,
ConnectionSortOrder, CustomFont, DEFAULT_AI_REQUEST_TIMEOUT_SECS,
DEFAULT_MCP_APPROVAL_TIMEOUT_MS, DEFAULT_TERMINAL_THEME, HomeConnectionLayout,
LOCALE_SYSTEM, LargeTextCellEditorOpenMode, LocalTerminalProfileKind,
LocalTerminalProfileSettings, MAX_AI_REQUEST_TIMEOUT_SECS, MAX_CUSTOM_SYSTEM_PROMPT_CHARS,
MIN_AI_REQUEST_TIMEOUT_SECS, MainWindowState, McpPermissionMode, McpServerMode,
PersonalSyncBackendKind, RemoteFileOpenMode, SqlFormatSettings, SqlIndentStyle,
@@ -1779,6 +1817,57 @@ mod tests {
assert!(settings.main_window_state.is_none());
}
#[test]
fn close_button_behavior_defaults_to_asking_every_time() {
assert_eq!(
CloseButtonBehavior::Ask,
AppSettings::default().close_button_behavior
);
}
#[test]
fn legacy_app_settings_without_close_button_behavior_ask_every_time() {
let settings: AppSettings =
serde_json::from_value(serde_json::json!({})).expect("旧版设置应能反序列化");
assert_eq!(CloseButtonBehavior::Ask, settings.close_button_behavior);
}
#[test]
fn close_button_behavior_round_trips_through_settings_json() {
for behavior in [
CloseButtonBehavior::Ask,
CloseButtonBehavior::MinimizeToTray,
CloseButtonBehavior::Quit,
] {
let mut settings = AppSettings::default();
settings.close_button_behavior = behavior;
let json = serde_json::to_value(&settings).expect("serialize settings");
let restored: AppSettings = serde_json::from_value(json).expect("deserialize settings");
assert_eq!(behavior, restored.close_button_behavior);
}
}
#[test]
fn close_button_behavior_strings_are_closed_and_fall_back_to_asking() {
for behavior in [
CloseButtonBehavior::Ask,
CloseButtonBehavior::MinimizeToTray,
CloseButtonBehavior::Quit,
] {
assert_eq!(behavior, CloseButtonBehavior::from_str(behavior.as_str()));
}
// 未知取值必须回退到询问,而不是静默变成「退出应用」。
assert_eq!(
CloseButtonBehavior::Ask,
CloseButtonBehavior::from_str("something_else")
);
assert_eq!(CloseButtonBehavior::Ask, CloseButtonBehavior::from_str(""));
}
#[test]
fn app_settings_enables_direct_server_transfer_by_default() {
assert!(AppSettings::default().direct_server_transfer_enabled);
@@ -10,9 +10,14 @@
### 关闭与最小化
- 点击主窗口关闭按钮时:
- 托盘初始化成功,则隐藏主窗口,不关闭窗口实体、不销毁标签页、不终止进程;
- 托盘初始化失败,则回退到现有退出确认,禁止产生无法恢复的隐藏窗口。
- 点击主窗口关闭按钮时,托盘可用则按用户偏好分流
- 偏好为「每次询问」(默认):弹窗让用户当场选择「最小化到托盘」还是「退出应用」,
弹窗内提供「记住我的选择」,勾选后写入设置,之后不再询问;
- 偏好为「最小化到托盘」:直接隐藏主窗口,不关闭窗口实体、不销毁标签页、不终止进程;
- 偏好为「退出应用」:走现有退出确认与标签页关闭检查。
- 托盘初始化失败时不弹窗:任何偏好都不能制造无法恢复的隐藏窗口,一律回退到现有退出确认。
- 隐藏到托盘这一步失败时同样回退退出确认(宁可直接退出,也不留下找不到的隐藏窗口)。
- 托盘可用性的判断先于偏好:偏好只在托盘真的可用时才起作用。
- 点击系统最小化按钮时,继续使用 GPUI 当前的系统最小化行为。
- Windows 当前绑定为 `QuitApp``Alt+F4`、macOS 的 `Cmd+Q`、应用菜单退出操作继续表示显式退出,不改为隐藏。
@@ -153,16 +158,41 @@ App 借用 ⇒ 二次借用失败,日志只剩 `ERROR gpui::window: RefCell al
```rust
enum MainWindowCloseAction {
AskUser,
HideToTray,
RequestQuit,
}
fn main_window_close_action(
tray_ready: bool,
behavior: CloseButtonBehavior,
) -> MainWindowCloseAction;
```
- 托盘可用时返回 `HideToTray`
- 托盘可用时返回 `RequestQuit`
- 托盘可用时一律返回 `RequestQuit`,无视偏好
- 托盘可用时 `AppSettings.close_button_behavior` 返回
`Ask``AskUser``MinimizeToTray``HideToTray``Quit``RequestQuit`
执行 `HideToTray` 时调用窗口可见性适配器并返回 `false`,阻止 GPUI 销毁窗口。若隐藏失败,立即调用现有 `request_quit`,仍返回 `false`,由现有退出流程决定是否退出。
`AskUser` 时同样返回 `false`,由弹窗决定后续:
- 弹窗用 `WindowExt::open_dialog` 打开,两条出路用自定义 `DialogFooter` 各自渲染一个
按钮(`tray-close-minimize` / `tray-close-quit`),点击后先 `window.close_dialog(cx)`
再走对应流程。**不要用默认的 ok/cancel footer**:默认 footer 只有两个固定按钮,
把「取消」当成第二条出路会让 Esc 和点遮罩也走成退出应用;而 `button_props` 若在
`confirm` 之后设置,还会把 `show_cancel` 重置、直接吞掉第二个按钮;
- 「记住我的选择」的勾选状态放在独立 entity(`CloseChoiceRememberState`)里:
弹窗 body 由 `Root` 渲染,`NavopApp``notify()` 不会重绘它,勾选框必须自己刷新;
- `close_choice_prompt_open` 标记防止连点关闭按钮叠出第二个弹窗,弹窗被关掉时复位;
- 用户选择「最小化到托盘」沿用 `HideToTray` 路径,选择「退出应用」沿用现有
`request_quit`,两条去向都复用既有流程,弹窗只负责选一次;
- 勾选「记住」时把结果写入 `AppSettings.close_button_behavior` 并落盘。
偏好同时在设置页「通用 → 关闭窗口行为」提供下拉项(默认值即 `Ask`),
文案键为 `Settings.General.CloseBehavior.*`,托盘弹窗文案键为 `Tray.*`
三套语言(en / zh-CN / zh-HK)齐备。
### 显式退出复用
托盘“退出 Navop”不得直接调用 `cx.quit()`。统一流程为:
@@ -213,8 +243,13 @@ enum MainWindowCloseAction {
### 纯逻辑测试
- 托盘可用时关闭策略返回 `HideToTray`
- 托盘不可用时关闭策略返回 `RequestQuit`
- 托盘可用且偏好为「每次询问」时关闭策略返回 `AskUser`;偏好为「最小化到托盘」返回
`HideToTray`,偏好为「退出应用」返回 `RequestQuit`
- 托盘不可用时关闭策略返回 `RequestQuit`;任何偏好都不能隐藏窗口;
- 关闭弹窗同时提供「最小化到托盘」与「退出应用」两条去向,并带「记住我的选择」勾选框;
- 勾选记住后偏好写入 `AppSettings.close_button_behavior` 并落盘;
- 用户偏好三值(`ask` / `minimize_to_tray` / `quit`)在设置 JSON 上 round-trip
旧 JSON 与未知值回退 `ask`
- 托盘图标点击(左键抬起)映射为 `ShowMainWindow`;左键按下、右键、中键都不映射;
- “显示 Navop”映射为 `ShowMainWindow`
- “退出 Navop”映射为 `QuitApplication`
@@ -226,7 +261,11 @@ enum MainWindowCloseAction {
- 主应用使用 `QuitMode::Explicit`
- 托盘初始化发生在窗口系统初始化之后、`OnetCliApp` 安装关闭 handler 之前;
- 主窗口关闭 handler 不再无条件调用 `request_quit`,而是先过托盘策略
- 主窗口关闭 handler 不再无条件调用 `request_quit`,而是先过托盘策略,并区分
`AskUser` / `HideToTray` / `RequestQuit` 三条分支;
- 关闭弹窗用自定义 footer 渲染两条去向按钮与记住勾选框,不回退到默认 ok/cancel footer
连点关闭按钮不会叠弹窗;Esc 与点遮罩只关询问、不触发退出;
- 设置页通用页存在「关闭窗口行为」下拉项,三套语言文案齐备;
- 托盘退出路径调用现有 `request_window_quit`,不直接调用 `cx.quit()`
- 平台回调只入队命令,不触碰 `Window` / `update_window`
- `TrayIcon` 保存在 `thread_local!` 中而不是任何 `Send` 容器里;
@@ -254,6 +293,24 @@ enum MainWindowCloseAction {
7. 再次选择退出并确认,确认应用进程终止且托盘图标消失;
8. 从 Dock reopen 隐藏中的应用,确认主窗口恢复。
## 附:Windows 单实例重复启动修复
症状:Windows 上重复双击启动 Navop 会开出第二个完整进程,第二个进程不再把启动请求
转发给已有实例。
根因(`main/src/windows_single_instance.rs` + `interprocess` 2.4.4):
- `interprocess` 的 Windows 命名管道监听器带 `FILE_FLAG_FIRST_PIPE_INSTANCE`
名字已存在时 `CreateNamedPipeW` 返回 `ERROR_ACCESS_DENIED(5)`
- 该错误被原样透传,没有做 error kind 归一化,所以 `kind()` 永远不是 `AddrInUse`
- 于是「名字已被占用」的分支在 Windows 上永不命中,第二个实例误判自己为主实例并继续
完整启动。
修复:把判定抽成 `instance_name_taken(&io::Error)`,接受 `AddrInUse`
`raw_os_error()` ∈ {`ERROR_ACCESS_DENIED(5)`, `ERROR_PIPE_BUSY(231)`};其余错误
(如 `PermissionDenied`)不能被误判为「已占用」,否则主实例会把启动请求转发给不存在
的管道。不更换机制,仍是命名管道 + 转发启动路径;转发失败仍按现状记录日志后继续启动。
## 风险与取舍
- Linux 桌面环境不一定提供 StatusNotifierItem watcher。此时功能明确降级为原有关闭确认,而不是隐藏到不可恢复状态。
@@ -265,12 +322,13 @@ enum MainWindowCloseAction {
## 验收标准
- macOS、Windows 和支持 StatusNotifierItem 的 Linux 桌面显示 Navop 托盘图标;
- 点击主窗口关闭按钮时,托盘可用的平台保留进程和主窗口状态;
- 点击主窗口关闭按钮时,托盘可用的平台保留进程和主窗口状态;首次关闭按偏好询问并记住选择;
- 系统最小化按钮保持原行为;
- 托盘单击和“显示 Navop”恢复同一个主窗口,不创建重复窗口;
- 托盘“退出 Navop”进入现有退出确认和标签页关闭检查;
- `Cmd+Q``Alt+F4` 和应用菜单退出继续表示显式退出;
- 托盘初始化或窗口隐藏失败时回退现有退出确认;
- Windows 重复启动只保留一个实例,第二个进程把启动请求转发给已有实例;
- macOS Dock reopen 可恢复隐藏窗口;
- Linux X11 使用真正隐藏,Wayland 使用有记录的最小化回退;
- 托盘图标使用内嵌的现有 Navop 品牌资源;
+50
View File
@@ -78,6 +78,29 @@ Quit:
zh-CN: 退出
zh-HK: 結束
# 系统托盘
Tray:
close_title:
en: Close Navop?
zh-CN: 关闭 Navop
zh-HK: 關閉 Navop
close_message:
en: Minimize Navop to the system tray and keep everything running, or quit the application.
zh-CN: 可以最小化到系统托盘并保持连接与后台任务继续运行,也可以直接退出应用。
zh-HK: 可以最小化到系統匣並保持連線與背景工作繼續執行,也可以直接結束應用程式。
close_minimize:
en: Minimize to tray
zh-CN: 最小化到托盘
zh-HK: 最小化到系統匣
close_quit:
en: Quit
zh-CN: 退出应用
zh-HK: 結束應用程式
close_remember:
en: Remember my choice and stop asking
zh-CN: 记住我的选择,不再询问
zh-HK: 記住我的選擇,不再詢問
# MCP 审批
McpApproval:
dialog_title:
@@ -2355,6 +2378,33 @@ Settings:
zh-CN: 可用时提供服务器间直接传输;关闭后,服务器间复制将始终通过 Navop 中转。
zh-HK: 可用時提供伺服器間直接傳輸;關閉後,伺服器間複製將始終透過 Navop 中轉。
# 关闭主窗口行为
CloseBehavior:
group_title:
en: Closing the Window
zh-CN: 关闭窗口行为
zh-HK: 關閉視窗行為
behavior:
en: When Closing the Main Window
zh-CN: 点击关闭按钮时
zh-HK: 點擊關閉按鈕時
behavior_desc:
en: Only applies when the system tray is available. "Ask" shows a dialog with the choice to minimize to the tray or quit, and you can remember that choice there.
zh-CN: 仅在系统托盘可用时生效。「每次询问」会弹窗让你选择最小化到托盘还是退出应用,并可在弹窗里记住选择。
zh-HK: 僅在系統匣可用時生效。「每次詢問」會彈窗讓你選擇最小化到系統匣還是結束應用程式,並可在彈窗裡記住選擇。
ask:
en: Ask every time
zh-CN: 每次询问
zh-HK: 每次詢問
minimize_to_tray:
en: Minimize to tray
zh-CN: 最小化到托盘
zh-HK: 最小化到系統匣
quit:
en: Quit the application
zh-CN: 退出应用
zh-HK: 結束應用程式
# 笔记设置
Notes:
group_title:
+253 -22
View File
@@ -9,9 +9,17 @@ use gpui::{
App, AppContext, AsyncApp, Context, Entity, ExternalPaths, InteractiveElement, IntoElement,
KeyBinding, Keystroke, ParentElement, Render, Styled, Task, Window, actions, div,
};
use gpui_component::{WindowExt, dialog::DialogButtonProps, kbd::Kbd, notification::Notification};
use gpui_component::{
WindowExt,
button::{Button, ButtonVariant, ButtonVariants as _},
checkbox::Checkbox,
dialog::{DialogButtonProps, DialogFooter},
kbd::Kbd,
notification::Notification,
};
use one_core::gpui_tokio::{JoinError, Tokio};
use one_core::keybindings::{action_id, rebind_keybindings, shortcuts_for};
use one_core::settings::CloseButtonBehavior;
use raw_window_handle::HasWindowHandle;
#[cfg(any(target_os = "macos", target_os = "windows"))]
use raw_window_handle::RawWindowHandle;
@@ -1376,10 +1384,33 @@ pub struct NavopApp {
tab_container: Entity<TabContainer>,
connection_sidebar: Entity<PersistentConnectionSidebar>,
quit_state: QuitRequestState,
/// 「最小化到托盘 / 退出应用」弹窗是否已经打开:标题栏关闭按钮可能被连点,
/// 不能叠出第二个完全相同的弹窗。
close_choice_prompt_open: bool,
main_window_size_save_task: Option<Task<()>>,
_appearance_subscription: gpui::Subscription,
}
/// 关闭弹窗里「记住我的选择」勾选框的状态。
///
/// 刻意做成独立实体而不是直接读 [`NavopApp`] 的字段:弹窗 body 由 `Root` 渲染,
/// `NavopApp` 的 `notify()` 不会重绘它,勾选框必须自己负责刷新。
struct CloseChoiceRememberState {
remember: bool,
}
impl Render for CloseChoiceRememberState {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
Checkbox::new("tray-close-remember")
.checked(self.remember)
.label(t!("Tray.close_remember").to_string())
.on_click(cx.listener(|this, checked, _, cx| {
this.remember = *checked;
cx.notify();
}))
}
}
impl NavopApp {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let app_entity = cx.entity();
@@ -1388,20 +1419,21 @@ impl NavopApp {
});
let app = app_entity.downgrade();
window.on_window_should_close(cx, move |window, cx| {
match crate::system_tray::main_window_close_action(crate::system_tray::is_available()) {
match crate::system_tray::main_window_close_action(
crate::system_tray::is_available(),
AppSettings::current(cx).close_button_behavior,
) {
crate::system_tray::MainWindowCloseAction::AskUser => {
// 托盘可用但用户还没固化偏好:先问「最小化到托盘还是退出」,
// 窗口一律保留(返回 false),由弹窗里的选择决定下一步。
let _ = app.update(cx, |app, cx| {
app.show_close_choice_prompt(window, cx);
});
}
crate::system_tray::MainWindowCloseAction::HideToTray => {
// 隐藏失败必须回退到退出确认:宁可直接退出,也不能留下一个
// 用户找不到、也没法恢复的隐藏窗口。
if let Err(error) = crate::window_visibility::hide_main_window(window) {
tracing::warn!(%error, "隐藏主窗口失败,回退到退出确认");
let _ = app.update(cx, |app, cx| {
app.request_quit(window, cx);
});
} else {
// 冒烟与排障的唯一可观测点:窗口不可见之后,日志是唯一能证明
// 「关闭按钮走的是托盘路径、而不是退出路径」的证据。
tracing::info!("主窗口已隐藏到系统托盘,进程继续在后台运行");
}
let _ = app.update(cx, |app, cx| {
app.hide_main_window_to_tray(window, cx);
});
}
crate::system_tray::MainWindowCloseAction::RequestQuit => {
let _ = app.update(cx, |app, cx| {
@@ -1576,6 +1608,7 @@ impl NavopApp {
tab_container,
connection_sidebar,
quit_state: QuitRequestState::default(),
close_choice_prompt_open: false,
main_window_size_save_task: None,
_appearance_subscription: appearance_subscription,
}
@@ -1647,6 +1680,124 @@ impl NavopApp {
});
}
/// 关闭按钮的「最小化到托盘 / 退出应用」弹窗。
///
/// 只在托盘可用且用户没有固化过偏好时打开;两条去向都复用既有流程
/// (隐藏到托盘 / 退出确认),弹窗本身只负责让用户选一次。
fn show_close_choice_prompt(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.close_choice_prompt_open {
return;
}
self.close_choice_prompt_open = true;
let remember = cx.new(|_| CloseChoiceRememberState { remember: false });
let app_for_minimize = cx.entity().downgrade();
let app_for_quit = cx.entity().downgrade();
let app_for_close = cx.entity().downgrade();
window.open_dialog(cx, move |dialog, _window, _cx| {
let remember_for_minimize = remember.clone();
let remember_for_quit = remember.clone();
let app_for_minimize = app_for_minimize.clone();
let app_for_quit = app_for_quit.clone();
let app_for_close = app_for_close.clone();
// 两条出路都自己渲染,不用默认的 ok/cancel footer:默认 footer 只有两个固定
// 按钮,「取消」一旦被当成第二条出路,Esc 和点遮罩也会走成退出应用;并且
// button_props 若在 confirm 之后设置,还会把 show_cancel 重置、吞掉第二个按钮。
let minimize_button = Button::new("tray-close-minimize")
.label(t!("Tray.close_minimize").to_string())
.with_variant(ButtonVariant::Primary)
.on_click(move |_, window, cx| {
let remember = remember_for_minimize.read(cx).remember;
window.close_dialog(cx);
let _ = app_for_minimize.update(cx, |app, cx| {
app.minimize_main_window_to_tray(remember, window, cx);
});
});
let quit_button = Button::new("tray-close-quit")
.label(t!("Tray.close_quit").to_string())
.on_click(move |_, window, cx| {
let remember = remember_for_quit.read(cx).remember;
window.close_dialog(cx);
let _ = app_for_quit.update(cx, |app, cx| {
app.quit_from_close_choice_prompt(remember, window, cx);
});
});
dialog
.title(t!("Tray.close_title").to_string())
.child(t!("Tray.close_message").to_string())
.child(remember.clone())
.footer(
DialogFooter::new()
.child(quit_button)
.child(minimize_button),
)
.on_close(move |_, _, cx| {
// Esc 或点遮罩只是关掉询问,不做任何动作;但不能留下「弹窗还开着」
// 的标记,否则下次点关闭按钮再也不弹。
let _ = app_for_close.update(cx, |app, _cx| {
app.close_choice_prompt_open = false;
});
})
});
}
/// 弹窗选择「最小化到托盘」。
fn minimize_main_window_to_tray(
&mut self,
remember: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.close_choice_prompt_open = false;
if remember {
self.remember_close_button_behavior(CloseButtonBehavior::MinimizeToTray, cx);
}
self.hide_main_window_to_tray(window, cx);
}
/// 弹窗选择「退出应用」。
fn quit_from_close_choice_prompt(
&mut self,
remember: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.close_choice_prompt_open = false;
if remember {
self.remember_close_button_behavior(CloseButtonBehavior::Quit, cx);
}
self.request_quit(window, cx);
}
fn remember_close_button_behavior(
&mut self,
behavior: CloseButtonBehavior,
cx: &mut Context<Self>,
) {
AppSettings::update_and_save(cx, |settings| {
settings.close_button_behavior = behavior;
});
}
/// 隐藏主窗口到托盘。
///
/// 隐藏失败必须回退到退出确认:宁可直接退出,也不能留下一个用户找不到、
/// 也没法恢复的隐藏窗口。
fn hide_main_window_to_tray(&mut self, window: &mut Window, cx: &mut Context<Self>) {
match crate::window_visibility::hide_main_window(window) {
// 冒烟与排障的唯一可观测点:窗口不可见之后,日志是唯一能证明
// 「关闭按钮走的是托盘路径、而不是退出路径」的证据。
Ok(()) => tracing::info!("主窗口已隐藏到系统托盘,进程继续在后台运行"),
Err(error) => {
tracing::warn!(%error, "隐藏主窗口失败,回退到退出确认");
self.request_quit(window, cx);
}
}
}
fn request_quit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.save_main_window_state(window, cx);
if self.tab_container.read(cx).is_empty() {
@@ -2071,11 +2222,89 @@ mod tests {
"关闭按钮必须按托盘可用性分流"
);
assert!(contains_code(new_fn, &availability_fn));
assert!(contains_code(new_fn, "MainWindowCloseAction::AskUser"));
assert!(contains_code(new_fn, "show_close_choice_prompt"));
assert!(contains_code(new_fn, "MainWindowCloseAction::HideToTray"));
assert!(contains_code(new_fn, "window_visibility::hide_main_window"));
assert!(contains_code(new_fn, "hide_main_window_to_tray"));
assert!(contains_code(new_fn, "MainWindowCloseAction::RequestQuit"));
}
/// 弹窗必须真的给用户两条出路(最小化 / 退出),并且「记住我的选择」写回设置;
/// 少了任一条,用户点关闭按钮就会被卡在一个只有默认按钮的弹窗里。
#[test]
fn close_choice_prompt_offers_both_destinations_and_remembers_the_choice() {
let source = include_str!("navop_app.rs");
let prompt = fn_body(source, "fn show_close_choice_prompt");
let remember_label = ["Tray.close_", "remember"].concat();
let minimize_label = ["Tray.close_", "minimize"].concat();
let quit_label = ["Tray.close_", "quit"].concat();
// 两条出路必须是各自独立的按钮:默认 footer 只有 ok/cancel
// button_props 跟在 confirm 后面还会把 show_cancel 重置掉、吞掉第二个按钮,
// 所以这里禁止回退到默认 footer。
assert!(contains_code(prompt, "DialogFooter::new()"));
assert!(contains_code(
prompt,
r#"Button::new("tray-close-minimize")"#
));
assert!(contains_code(prompt, r#"Button::new("tray-close-quit")"#));
assert!(!contains_code(prompt, ".confirm()"));
assert!(!contains_code(prompt, ".button_props("));
assert!(contains_code(prompt, &minimize_label));
assert!(contains_code(prompt, &quit_label));
assert!(contains_code(prompt, "window.close_dialog(cx)"));
assert!(contains_code(prompt, "CloseChoiceRememberState"));
assert!(contains_code(prompt, "minimize_main_window_to_tray"));
assert!(contains_code(prompt, "quit_from_close_choice_prompt"));
// 连点关闭按钮不能叠出第二个弹窗。
assert!(contains_code(prompt, "if self.close_choice_prompt_open"));
// 「记住我的选择」勾选框自带实体,标签与受控回写都在它的 render 里。
let checkbox = fn_body(source, "impl Render for CloseChoiceRememberState");
assert!(contains_code(checkbox, &remember_label));
assert!(contains_code(checkbox, "Checkbox::new"));
assert!(contains_code(checkbox, "cx.listener"));
let minimize = fn_body(source, "fn minimize_main_window_to_tray");
assert!(contains_code(
minimize,
"CloseButtonBehavior::MinimizeToTray"
));
let quit = fn_body(source, "fn quit_from_close_choice_prompt");
assert!(contains_code(quit, "CloseButtonBehavior::Quit"));
// 记住选择必须落盘,否则下次启动又被问一遍。
let remember = fn_body(source, "fn remember_close_button_behavior");
assert!(contains_code(remember, "AppSettings::update_and_save"));
assert!(contains_code(
remember,
"settings.close_button_behavior = behavior"
));
}
/// 隐藏失败必须回退到退出确认,不能留下一个用户找不到、也没法恢复的窗口。
#[test]
fn hiding_to_the_tray_falls_back_to_quit_when_it_fails() {
let source = include_str!("navop_app.rs");
let body = fn_body(source, "fn hide_main_window_to_tray");
let hide = ["crate::window_visibility::", "hide_main_window(window)"].concat();
assert!(contains_code(body, &hide));
assert!(contains_code(body, "Err(error) =>"));
assert!(contains_code(body, "self.request_quit(window, cx)"));
}
/// 取一个顶层方法的完整函数体(从签名到下一个同缩进的 `\n }`)。
fn fn_body<'a>(source: &'a str, signature: &str) -> &'a str {
let start = source
.find(signature)
.unwrap_or_else(|| panic!("{signature}"));
let end = source[start..]
.find("\n }\n")
.map(|offset| start + offset)
.unwrap_or_else(|| panic!("{signature} end"));
&source[start..end]
}
/// 源码守卫比对用:两侧都去掉全部空白再比对,避免 rustfmt 折行或补空格让断言
/// 静默失效(`(window, cx)` 这类参数列表也会被 rustfmt 重新折行)。
fn contains_code(source: &str, needle: &str) -> bool {
@@ -2496,10 +2725,11 @@ impl Render for NavopApp {
sidebar.render_docked_connection_tree(window, cx)
})
});
let floating_tree = (sidebar_expanded && auto_hide_tree && !home_has_navigation_sidebar).then(|| {
self.connection_sidebar
.update(cx, |sidebar, cx| sidebar.render_floating_tree(window, cx))
});
let floating_tree = (sidebar_expanded && auto_hide_tree && !home_has_navigation_sidebar)
.then(|| {
self.connection_sidebar
.update(cx, |sidebar, cx| sidebar.render_floating_tree(window, cx))
});
// macOS:浮动树覆盖 tab 栏左侧,需要给红绿灯占位;停靠模式并排渲染则不需要。
#[cfg(target_os = "macos")]
if sidebar_expanded {
@@ -2570,9 +2800,10 @@ impl Render for NavopApp {
.child(main_content),
)
})
.when(sidebar_expanded && auto_hide_tree && !home_has_navigation_sidebar, |this| {
this.when_some(floating_tree, |this, tree| this.child(tree))
})
.when(
sidebar_expanded && auto_hide_tree && !home_has_navigation_sidebar,
|this| this.when_some(floating_tree, |this, tree| this.child(tree)),
)
.children(sheet_layer)
.children(dialog_layer)
.children(notification_layer)
+43 -4
View File
@@ -66,10 +66,10 @@ const TEAM_KEYS_SETTINGS_PAGE_INDEX: usize = 6;
use gpui_component::input::InputEvent;
pub use one_core::settings::{
AppSettings, CustomFont, DatabaseOpenMode, GlobalCurrentUser, GlobalProxySettings, LOCALE_EN,
LOCALE_SYSTEM, LOCALE_ZH_CN, LOCALE_ZH_HK, PersonalSyncBackendKind, PersonalSyncSettings,
ProxyType, SyncProvider, effective_locale_for_setting, is_installed_font_family,
is_supported_grid_monospace_font,
AppSettings, CloseButtonBehavior, CustomFont, DatabaseOpenMode, GlobalCurrentUser,
GlobalProxySettings, LOCALE_EN, LOCALE_SYSTEM, LOCALE_ZH_CN, LOCALE_ZH_HK,
PersonalSyncBackendKind, PersonalSyncSettings, ProxyType, SyncProvider,
effective_locale_for_setting, is_installed_font_family, is_supported_grid_monospace_font,
};
use one_core::tab_container::{TabContent, TabContentEvent};
use one_core::utils::auto_save_config::AutoSaveConfig;
@@ -632,6 +632,7 @@ impl SettingsPanel {
.to_string(),
),
),
close_behavior_setting_group(default_settings.close_button_behavior),
notes_setting_group(),
SettingGroup::new()
.title(t!("Settings.General.Appearance.group_title"))
@@ -1008,6 +1009,44 @@ impl SettingsPanel {
}
}
/// 关闭主窗口时的行为。托盘不可用时该设置不生效,仍走退出确认。
fn close_behavior_setting_group(default: CloseButtonBehavior) -> SettingGroup {
SettingGroup::new()
.title(t!("Settings.General.CloseBehavior.group_title"))
.item(
SettingItem::new(
t!("Settings.General.CloseBehavior.behavior"),
SettingField::dropdown(
vec![
(
SharedString::from(CloseButtonBehavior::Ask.as_str()),
t!("Settings.General.CloseBehavior.ask").into(),
),
(
SharedString::from(CloseButtonBehavior::MinimizeToTray.as_str()),
t!("Settings.General.CloseBehavior.minimize_to_tray").into(),
),
(
SharedString::from(CloseButtonBehavior::Quit.as_str()),
t!("Settings.General.CloseBehavior.quit").into(),
),
],
|cx: &App| {
SharedString::from(AppSettings::global(cx).close_button_behavior.as_str())
},
|val: SharedString, cx: &mut App| {
AppSettings::update_and_save(cx, |settings| {
settings.close_button_behavior =
CloseButtonBehavior::from_str(val.as_ref());
});
},
)
.default_value(SharedString::from(default.as_str())),
)
.description(t!("Settings.General.CloseBehavior.behavior_desc").to_string()),
)
}
fn sync_setting_group(
sync_enabled_default: bool,
sync_provider_default: SyncProvider,
+42 -11
View File
@@ -24,6 +24,7 @@
use anyhow::Context as _;
use gpui::{AnyWindowHandle, App, AppContext as _, AsyncApp};
use one_core::settings::CloseButtonBehavior;
use one_core::tab_container::GlobalTabContainer;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -44,9 +45,11 @@ pub(crate) enum TrayCommand {
/// 主窗口关闭按钮的行为。
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum MainWindowCloseAction {
/// 托盘可用:隐藏窗口,保留进程、标签页和后台任务
/// 托盘可用但用户还没固化偏好:弹窗让用户当场选「最小化到托盘 / 退出应用」
AskUser,
/// 用户已选择最小化到托盘:隐藏窗口,保留进程、标签页和后台任务。
HideToTray,
/// 托盘不可用(或初始化/隐藏失败):走既有退出确认。
/// 托盘不可用(或用户已选择退出):走既有退出确认。
RequestQuit,
}
@@ -61,13 +64,22 @@ pub(crate) const TRAY_SESSION_LIMIT: usize = 8;
/// 会话标题最长保留的字符数,超出用省略号收尾。
pub(crate) const TRAY_SESSION_LABEL_MAX_CHARS: usize = 40;
/// 关闭按钮的纯策略:只有托盘确实可用时才隐藏窗口,否则必须回退到退出确认,
/// 关闭按钮的纯策略:只有托盘确实可用时才可能隐藏窗口,否则必须回退到退出确认,
/// 不能制造一个无法恢复的隐藏窗口。
pub(crate) const fn main_window_close_action(tray_ready: bool) -> MainWindowCloseAction {
if tray_ready {
MainWindowCloseAction::HideToTray
} else {
MainWindowCloseAction::RequestQuit
///
/// 托盘可用时再按用户偏好细分:`Ask` 交回弹窗由用户当场选择(并可在弹窗里记住),
/// `MinimizeToTray` / `Quit` 是用户已经固化过的选择,不再打扰。
pub(crate) const fn main_window_close_action(
tray_ready: bool,
behavior: CloseButtonBehavior,
) -> MainWindowCloseAction {
if !tray_ready {
return MainWindowCloseAction::RequestQuit;
}
match behavior {
CloseButtonBehavior::Ask => MainWindowCloseAction::AskUser,
CloseButtonBehavior::MinimizeToTray => MainWindowCloseAction::HideToTray,
CloseButtonBehavior::Quit => MainWindowCloseAction::RequestQuit,
}
}
@@ -474,17 +486,36 @@ mod tests {
use super::*;
#[test]
fn close_action_hides_only_when_the_tray_is_ready() {
fn close_action_asks_the_user_until_a_choice_is_remembered() {
assert_eq!(
MainWindowCloseAction::AskUser,
main_window_close_action(true, CloseButtonBehavior::Ask)
);
assert_eq!(
MainWindowCloseAction::HideToTray,
main_window_close_action(true)
main_window_close_action(true, CloseButtonBehavior::MinimizeToTray)
);
assert_eq!(
MainWindowCloseAction::RequestQuit,
main_window_close_action(false)
main_window_close_action(true, CloseButtonBehavior::Quit)
);
}
#[test]
fn close_action_without_a_tray_never_hides_the_window() {
for behavior in [
CloseButtonBehavior::Ask,
CloseButtonBehavior::MinimizeToTray,
CloseButtonBehavior::Quit,
] {
assert_eq!(
MainWindowCloseAction::RequestQuit,
main_window_close_action(false, behavior),
"托盘不可用时任何偏好都不能隐藏窗口"
);
}
}
#[test]
fn menu_ids_map_to_the_minimal_command_set() {
assert_eq!(
+43 -1
View File
@@ -162,6 +162,25 @@ fn invalid_data(message: &'static str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message)
}
/// Windows 命名管道 `ERROR_ACCESS_DENIED`interprocess 给监听器加了
/// `FILE_FLAG_FIRST_PIPE_INSTANCE`,名字已存在时 `CreateNamedPipeW` 返回它。
const WINDOWS_ERROR_ACCESS_DENIED: i32 = 5;
/// Windows 命名管道 `ERROR_PIPE_BUSY`:所有实例都在忙,同样说明名字已被占用。
const WINDOWS_ERROR_PIPE_BUSY: i32 = 231;
/// 创建监听器失败是否意味着「这个名字已经被占用」。
///
/// interprocess 的 Windows 分支会原样透传系统错误、不做 error kind 归一化,
/// 所以只看 `AddrInUse` 会漏掉真实的「已占用」,第二个实例遂误判成主实例、
/// 起出两个完整进程。这里把两种形态都认下来。
fn instance_name_taken(error: &io::Error) -> bool {
error.kind() == io::ErrorKind::AddrInUse
|| matches!(
error.raw_os_error(),
Some(WINDOWS_ERROR_ACCESS_DENIED) | Some(WINDOWS_ERROR_PIPE_BUSY)
)
}
#[cfg(target_os = "windows")]
pub(crate) fn claim_or_forward(
config_dir: &Path,
@@ -198,7 +217,7 @@ pub(crate) fn claim_or_forward(
})?;
Ok(SingleInstanceOutcome::Primary)
}
Err(error) if error.kind() == io::ErrorKind::AddrInUse => {
Err(error) if instance_name_taken(&error) => {
forward_request::<Stream>(&instance_name, &request)?;
Ok(SingleInstanceOutcome::Forwarded)
}
@@ -322,4 +341,27 @@ mod tests {
let oversized_payload = vec![0; MAX_PAYLOAD_BYTES + 1];
assert!(decode_request(&oversized_payload).is_err());
}
#[test]
fn taken_instance_name_includes_windows_pipe_errors() {
// 名字已存在的两种真实形态:Windows 命名管道返回 ACCESS_DENIED / PIPE_BUSY
// 其它平台返回 AddrInUse。
assert!(instance_name_taken(&io::Error::new(
io::ErrorKind::AddrInUse,
"in use"
)));
assert!(instance_name_taken(&io::Error::from_raw_os_error(
WINDOWS_ERROR_ACCESS_DENIED
)));
assert!(instance_name_taken(&io::Error::from_raw_os_error(
WINDOWS_ERROR_PIPE_BUSY
)));
// 其它错误不能被当成「已占用」,否则主实例会把启动请求转发给不存在的管道。
assert!(!instance_name_taken(&io::Error::new(
io::ErrorKind::PermissionDenied,
"denied"
)));
assert!(!instance_name_taken(&io::Error::from_raw_os_error(2)));
}
}