From 8940c46592130d916bf332081b4634e7eb01ad44 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 18 Oct 2026 12:43:26 +0000 Subject: [PATCH 1/9] feat: confirm volume delete and clarify close vs stop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting a container now asks separately before removing named volumes (default off). Closing the window can minimize to a resident tray icon or quit after stopping running containers, with the choice saved in settings. Co-authored-by: 绿岸公式 --- CHANGELOG.md | 7 ++ Cargo.lock | 20 +++ Cargo.toml | 2 + README.md | 4 +- src/app/close.rs | 275 ++++++++++++++++++++++++++++++++++++++++++ src/app/confirm.rs | 105 ++++++++++++++-- src/app/containers.rs | 13 +- src/app/mod.rs | 26 +++- src/app/parsing.rs | 67 +++++++++- src/app/refresh.rs | 16 ++- src/app/settings.rs | 59 ++++++++- src/app/state.rs | 10 +- src/app/tray_icon.rs | 121 +++++++++++++++++++ src/i18n.rs | 79 +++++++++++- src/main.rs | 72 ++++++++++- src/wslc/mod.rs | 21 +++- src/wslc/prefs.rs | 26 +++- 17 files changed, 892 insertions(+), 31 deletions(-) create mode 100644 src/app/close.rs create mode 100644 src/app/tray_icon.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index dd7ae53..448f67e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ ## [Unreleased] +### 新增 + +- 删除容器时可单独确认是否同时删除关联命名卷(默认不删,避免误清数据库等数据)。 + - Removing a container can optionally delete attached named volumes; off by default. +- 关闭窗口首次提示:最小化到系统托盘(容器继续运行)或退出并停止本会话所有运行中容器;选择会写入设置,可随时改。托盘图标常驻,可显示窗口 / 退出并保留容器 / 退出并停止容器。 + - First close asks minimize-to-tray vs quit-and-stop; preference is saved. Resident tray icon for show / quit-keep / quit-stop. + ### 变更 - 启动门禁只读注册表 `HKLM\...\Lxss\Msi\Version`(与 WSL 自身一致),不再调用 `wsl --version`。缺键即视为未安装。SDK 加载后的版本仍用 `wslc::Service::version()`。 diff --git a/Cargo.lock b/Cargo.lock index d6b77e2..532b7f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2865,6 +2865,24 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d337c9acf058238e45ce0570d8a5958439242889492f91ae2d8e324211a61ccd" +[[package]] +name = "gpui-tray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c01af03fe16178c6051e7611ae1699d4d6d5b537d579e38aea7efeec0af2a433" +dependencies = [ + "async-channel", + "gpui-pre", + "image", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "thiserror 2.0.21", + "windows 0.62.2", + "zbus", +] + [[package]] name = "granit-parser" version = "1.3.0" @@ -8470,6 +8488,8 @@ version = "0.1.2" dependencies = [ "anyhow", "gpui-kit", + "gpui-tray", + "raw-window-handle", "serde", "serde_json", "smol", diff --git a/Cargo.toml b/Cargo.toml index 6f9a2a8..4eb3ef7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ upgrade-guid = "31263ED9-28F7-4608-AF04-A8CAE4591EB0" [dependencies] anyhow = "1" gpui-kit = "0.7.1" +raw-window-handle = "0.6" serde = { version = "1", features = ["derive"] } serde_json = "1" smol = "2" @@ -38,6 +39,7 @@ sys-locale = "0.3" wslc = { git = "https://github.com/timeroute/wslc-rs", rev = "9fbcd72f6967f79d0dc805648120eaec77364a2e" } ureq = { version = "2.12", default-features = true, features = ["json"] } zip = { version = "2", default-features = false, features = ["deflate"] } +gpui-tray = { version = "0.1", default-features = false, features = ["gpui-kit", "menu-state"] } windows-sys = { version = "0.61", features = [ "Win32_Foundation", "Win32_System_Registry", diff --git a/README.md b/README.md index 58cc3ec..aa5a858 100644 --- a/README.md +++ b/README.md @@ -31,9 +31,9 @@ | **Containers** | 创建(端口 / bind / 命名卷);行内 Start / Stop / Restart / Logs / Inspect / Exec / Remove;catalog + `OpenContainer` 跨重启恢复 | | **Images** | 拉取弹窗;行内创建容器 / 删除 | | **Volumes** | 弹窗创建(1/2/5/10 GiB);行内删除 | -| **设置** | 可滚动;语言、主题、会话 CPU / 内存(下次新建会话生效) | +| **设置** | 可滚动;语言、主题、关闭窗口行为、会话 CPU / 内存(下次新建会话生效) | -Logs 走 fork 的 `LogsOptions`(底层 `wslc --session wslc-desktop container logs`)。退出应用**不会** `terminate` 会话,便于下次重连。 +Logs 走 fork 的 `LogsOptions`(底层 `wslc --session wslc-desktop container logs`)。关闭窗口可最小化到托盘(容器继续运行),或退出并停止运行中的容器;删除容器时命名卷需单独确认。会话本身不会在退出时 `terminate`,便于下次重连。 ## 从源码构建 diff --git a/src/app/close.rs b/src/app/close.rs new file mode 100644 index 0000000..ee5f37b --- /dev/null +++ b/src/app/close.rs @@ -0,0 +1,275 @@ + +use gpui_kit::component::{ + ActiveTheme, Sizable as _, StyledExt as _, + button::{Button, ButtonVariants as _}, + h_flex, v_flex, +}; +use gpui_kit::prelude::FluentBuilder as _; +use gpui_kit::*; +use smol::Timer; + +use crate::i18n::{self}; +use crate::wslc::{CloseBehavior, WslcClient}; + +use super::widgets::{modal_panel, modal_scrim}; +use super::AppView; + +impl AppView { + /// Window close / Alt+F4 path. Returns `false` to keep the window open. + pub(crate) fn handle_window_close_request( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> bool { + if self.allow_window_close { + return true; + } + if self.close_prompt_open { + return false; + } + match self.session_prefs.close_behavior { + CloseBehavior::Ask => { + self.close_prompt_open = true; + self.close_prompt_remember = true; + cx.notify(); + false + } + CloseBehavior::MinimizeToTray => { + self.minimize_to_tray(window, cx); + false + } + CloseBehavior::QuitAndStopContainers => { + self.begin_quit_and_stop_containers(cx); + false + } + } + } + + pub(crate) fn dismiss_close_prompt(&mut self, cx: &mut Context) { + self.close_prompt_open = false; + cx.notify(); + } + + pub(crate) fn choose_minimize_to_tray( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { + if self.close_prompt_remember { + self.session_prefs.close_behavior = CloseBehavior::MinimizeToTray; + let _ = self.persist_close_behavior(cx); + } + self.close_prompt_open = false; + self.minimize_to_tray(window, cx); + } + + pub(crate) fn choose_quit_and_stop(&mut self, cx: &mut Context) { + if self.close_prompt_remember { + self.session_prefs.close_behavior = CloseBehavior::QuitAndStopContainers; + let _ = self.persist_close_behavior(cx); + } + self.close_prompt_open = false; + self.begin_quit_and_stop_containers(cx); + } + + pub(crate) fn set_close_behavior( + &mut self, + behavior: CloseBehavior, + cx: &mut Context, + ) { + self.session_prefs.close_behavior = behavior; + match self.persist_close_behavior(cx) { + Ok(()) => self.set_status(self.t().settings_close_saved, cx), + Err(err) => { + let msg = i18n::fmt1(self.t().error_prefix, "err", err); + self.set_status(msg, cx); + } + } + } + + fn persist_close_behavior(&mut self, _cx: &mut Context) -> Result<(), String> { + let prefs = self.session_prefs.clone().clamp(); + self.session_prefs = prefs.clone(); + WslcClient::save_session_prefs(prefs).map_err(|e| format!("{e}")) + } + + pub(crate) fn minimize_to_tray(&mut self, window: &mut Window, cx: &mut Context) { + self.close_prompt_open = false; + self.window_hidden = true; + hide_native_window(window); + self.set_status(self.t().minimized_to_tray, cx); + cx.notify(); + } + + pub(crate) fn show_from_tray(&mut self, window: &mut Window, cx: &mut Context) { + self.window_hidden = false; + show_native_window(window); + window.activate_window(); + cx.notify(); + } + + /// Quit the UI process without stopping containers (tray / explicit keep). + pub(crate) fn quit_keep_containers(&mut self, cx: &mut Context) { + self.allow_window_close = true; + self.close_prompt_open = false; + cx.quit(); + } + + pub(crate) fn begin_quit_and_stop_containers(&mut self, cx: &mut Context) { + self.close_prompt_open = false; + let Some(client) = self.client.clone() else { + self.allow_window_close = true; + cx.quit(); + return; + }; + + if self.busy { + // Already mid-op; still quit after a best-effort stop on a fresh thread. + } + + self.set_status(self.t().stopping_containers_on_quit, cx); + let template = self.t().quit_stopped_containers; + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::Builder::new() + .name("wslc-quit-stop".into()) + .spawn(move || { + let result = client + .stop_all_running_containers() + .map(|count| i18n::fmt1(template, "count", count.to_string())); + let _ = tx.send(result); + }) + .expect("spawn quit-stop thread"); + + self.busy = true; + self.busy_rx = Some(rx); + self.pending_quit_after_stop = true; + self.busy_poll_generation = self.busy_poll_generation.wrapping_add(1); + let generation = self.busy_poll_generation; + cx.notify(); + + cx.spawn(async move |this, cx| { + loop { + Timer::after(std::time::Duration::from_millis(50)).await; + let cont = this + .update(cx, |this, cx| { + if this.busy_poll_generation != generation { + return false; + } + this.poll_busy(cx) + }) + .unwrap_or(false); + if !cont { + break; + } + } + }) + .detach(); + } + + pub(crate) fn finish_quit_after_stop(&mut self, cx: &mut Context) { + self.pending_quit_after_stop = false; + self.allow_window_close = true; + cx.quit(); + } + + pub(crate) fn render_close_prompt_modal(&self, cx: &mut Context) -> impl IntoElement { + let t = self.t(); + let remember = self.close_prompt_remember; + + modal_scrim( + "close-prompt-modal", + cx, + modal_panel(cx) + .w(px(480.)) + .child(div().font_semibold().text_base().child(t.close_prompt_title)) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(t.close_prompt_body), + ) + .child( + Button::new("close-prompt-remember") + .small() + .label(t.close_prompt_remember) + .when(remember, |b| b.primary()) + .on_click(cx.listener(|this, _, _, cx| { + this.close_prompt_remember = !this.close_prompt_remember; + cx.notify(); + })), + ) + .child( + v_flex() + .gap_2() + .child( + Button::new("close-prompt-minimize") + .label(t.close_prompt_minimize) + .on_click(cx.listener(|this, _, window, cx| { + this.choose_minimize_to_tray(window, cx); + })), + ) + .child( + Button::new("close-prompt-quit-stop") + .danger() + .label(t.close_prompt_quit_stop) + .on_click(cx.listener(|this, _, _, cx| { + this.choose_quit_and_stop(cx); + })), + ) + .child( + h_flex().justify_end().child( + Button::new("close-prompt-cancel") + .small() + .label(t.cancel) + .on_click(cx.listener(|this, _, _, cx| { + this.dismiss_close_prompt(cx); + })), + ), + ), + ), + ) + } +} + +fn hide_native_window(window: &mut Window) { + #[cfg(windows)] + { + if let Some(hwnd) = native_hwnd(window) { + unsafe { + windows_sys::Win32::UI::WindowsAndMessaging::ShowWindow( + hwnd, + windows_sys::Win32::UI::WindowsAndMessaging::SW_HIDE, + ); + } + return; + } + } + window.minimize_window(); +} + +fn show_native_window(window: &mut Window) { + #[cfg(windows)] + { + if let Some(hwnd) = native_hwnd(window) { + unsafe { + windows_sys::Win32::UI::WindowsAndMessaging::ShowWindow( + hwnd, + windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOW, + ); + } + return; + } + } + window.activate_window(); +} + +#[cfg(windows)] +fn native_hwnd(window: &Window) -> Option { + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + + let handle = window.window_handle().ok()?; + match handle.as_raw() { + RawWindowHandle::Win32(win32) => Some(win32.hwnd.get() as _), + _ => None, + } +} diff --git a/src/app/confirm.rs b/src/app/confirm.rs index 3a73901..b1ddf88 100644 --- a/src/app/confirm.rs +++ b/src/app/confirm.rs @@ -2,8 +2,9 @@ use gpui_kit::component::{ ActiveTheme, Sizable as _, StyledExt as _, button::{Button, ButtonVariants as _}, - h_flex, + h_flex, v_flex, }; +use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use crate::i18n::{self}; @@ -18,6 +19,7 @@ impl AppView { || self.busy || self.pull_phase == PullPhase::Pulling || self.confirm.is_some() + || self.close_prompt_open { return; } @@ -30,16 +32,59 @@ impl AppView { cx.notify(); } + pub(crate) fn toggle_confirm_remove_volumes(&mut self, cx: &mut Context) { + if let Some(ConfirmAction::RemoveContainer { + remove_volumes, + named_volumes, + .. + }) = self.confirm.as_mut() + { + if !named_volumes.is_empty() { + *remove_volumes = !*remove_volumes; + cx.notify(); + } + } + } + pub(crate) fn submit_confirm(&mut self, cx: &mut Context) { let Some(action) = self.confirm.take() else { return; }; match action { - ConfirmAction::RemoveContainer { key, .. } => { - let template = self.t().removed; + ConfirmAction::RemoveContainer { + key, + remove_volumes, + named_volumes, + .. + } => { + let removed = self.t().removed; + let removed_with_volumes = self.t().removed_container_with_volumes; self.with_busy(cx, move |client| { client.remove_container(&key)?; - Ok(i18n::fmt1(template, "name", &key)) + if remove_volumes { + let mut failed = Vec::new(); + for name in &named_volumes { + if let Err(err) = client.remove_volume(name) { + failed.push(format!("{name}: {err}")); + } + } + if failed.is_empty() { + Ok(i18n::fmt2( + removed_with_volumes, + "name", + &key, + "volumes", + named_volumes.join(", "), + )) + } else { + Err(anyhow::anyhow!( + "container removed; volume delete failed: {}", + failed.join("; ") + )) + } + } else { + Ok(i18n::fmt1(removed, "name", &key)) + } }); } ConfirmAction::RemoveImage { reference } => { @@ -66,15 +111,33 @@ impl AppView { .as_ref() .expect("confirm modal rendered only when open"); let title = t.confirm_remove_title; - let body = match action { - ConfirmAction::RemoveContainer { label, .. } => { - i18n::fmt1(t.confirm_remove_container, "name", label) + let (body, volumes_row) = match action { + ConfirmAction::RemoveContainer { + label, + named_volumes, + remove_volumes, + .. + } => { + let body = i18n::fmt1(t.confirm_remove_container, "name", label); + let volumes_row = if named_volumes.is_empty() { + None + } else { + Some(( + i18n::fmt1( + t.confirm_remove_container_volumes, + "volumes", + named_volumes.join(", "), + ), + *remove_volumes, + )) + }; + (body, volumes_row) } ConfirmAction::RemoveImage { reference } => { - i18n::fmt1(t.confirm_remove_image, "name", reference) + (i18n::fmt1(t.confirm_remove_image, "name", reference), None) } ConfirmAction::RemoveVolume { name } => { - i18n::fmt1(t.confirm_remove_volume, "name", name) + (i18n::fmt1(t.confirm_remove_volume, "name", name), None) } }; @@ -82,7 +145,7 @@ impl AppView { "confirm-remove-modal", cx, modal_panel(cx) - .w(px(420.)) + .w(px(460.)) .child(div().font_semibold().text_base().child(title)) .child( div() @@ -90,6 +153,28 @@ impl AppView { .text_color(cx.theme().muted_foreground) .child(body), ) + .when_some(volumes_row, |panel, (hint, selected)| { + panel.child( + v_flex() + .gap_2() + .pt_1() + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(t.confirm_remove_container_volumes_hint), + ) + .child( + Button::new("confirm-toggle-volumes") + .small() + .label(hint) + .when(selected, |b| b.primary()) + .on_click(cx.listener(|this, _, _, cx| { + this.toggle_confirm_remove_volumes(cx); + })), + ), + ) + }) .child( h_flex() .justify_end() diff --git a/src/app/containers.rs b/src/app/containers.rs index a8e8bbe..e7449fb 100644 --- a/src/app/containers.rs +++ b/src/app/containers.rs @@ -16,7 +16,9 @@ use crate::wslc::{ }; use super::AppView; -use super::parsing::{parse_bind_mounts, parse_named_volumes, parse_port_mappings}; +use super::parsing::{ + named_volumes_attached, parse_bind_mounts, parse_named_volumes, parse_port_mappings, +}; use super::state::{ConfirmAction, PullPhase}; use super::widgets::{ list_header, modal_panel, modal_scrim, resource_list, resource_row, row_actions, settings_card, @@ -631,8 +633,15 @@ impl AppView { } else { item.name.clone() }; + let named_volumes = + named_volumes_attached(&item.volumes, &this.volumes); this.request_confirm( - ConfirmAction::RemoveContainer { key, label }, + ConfirmAction::RemoveContainer { + key, + label, + named_volumes, + remove_volumes: false, + }, cx, ); })), diff --git a/src/app/mod.rs b/src/app/mod.rs index bf62606..eda1b04 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -10,6 +10,7 @@ use crate::wslc::{ TemplateStore, VolumeInfo, WslcClient, }; +mod close; mod confirm; mod containers; mod images; @@ -21,6 +22,7 @@ mod sdk_setup; mod settings; mod shell; mod state; +pub(crate) mod tray_icon; mod volumes; mod widgets; @@ -28,6 +30,14 @@ pub use state::Section; use state::{ConfirmAction, PullPhase, SdkSetupPhase}; +/// Shared handles so tray actions can reach the main window / view. +pub struct DesktopShell { + pub window: AnyWindowHandle, + pub view: WeakEntity, +} + +impl Global for DesktopShell {} + pub struct AppView { client: Option, status: EngineStatus, @@ -74,6 +84,11 @@ pub struct AppView { exec_modal_open: bool, exec_target_key: SharedString, confirm: Option, + close_prompt_open: bool, + close_prompt_remember: bool, + window_hidden: bool, + allow_window_close: bool, + pending_quit_after_stop: bool, pull_phase: PullPhase, pull_status: SharedString, pull_progress: f32, @@ -177,6 +192,11 @@ impl AppView { exec_modal_open: false, exec_target_key: SharedString::default(), confirm: None, + close_prompt_open: false, + close_prompt_remember: true, + window_hidden: false, + allow_window_close: false, + pending_quit_after_stop: false, pull_phase: PullPhase::Idle, pull_status: t.pull_hint.into(), pull_progress: 0.0, @@ -256,13 +276,17 @@ impl Render for AppView { .when(self.confirm.is_some(), |root| { root.child(self.render_confirm_modal(cx)) }) + .when(self.close_prompt_open, |root| { + root.child(self.render_close_prompt_modal(cx)) + }) .when( self.busy && !show_setup && !self.pull_modal_open && !self.volume_modal_open && !self.exec_modal_open - && self.confirm.is_none(), + && self.confirm.is_none() + && !self.close_prompt_open, |root| root.child(self.render_busy_overlay(cx)), ) } diff --git a/src/app/parsing.rs b/src/app/parsing.rs index 68b1156..babb220 100644 --- a/src/app/parsing.rs +++ b/src/app/parsing.rs @@ -1,4 +1,4 @@ -use crate::wslc::{PortMapping, VolumeMapping}; +use crate::wslc::{PortMapping, VolumeInfo, VolumeMapping}; pub(crate) fn parse_port_mappings(raw: &str) -> Result, String> { let mut out = Vec::new(); @@ -77,6 +77,46 @@ fn looks_like_windows_drive(host: &str) -> bool { bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' } +/// Session VHD volume names referenced by a container's mounts label. +/// +/// Only names that exist in `known` are returned, so bind mounts and inspect +/// host paths are never treated as deletable named volumes. +pub(crate) fn named_volumes_attached(volumes_label: &str, known: &[VolumeInfo]) -> Vec { + if volumes_label.trim().is_empty() || known.is_empty() { + return Vec::new(); + } + let mut out = Vec::new(); + for part in volumes_label.split(',') { + let part = part.trim(); + if part.is_empty() { + continue; + } + let part = part.strip_prefix("vol:").unwrap_or(part); + let lower = part.to_ascii_lowercase(); + let core = if lower.ends_with(":ro") { + part[..part.len() - 3].trim_end() + } else { + part + }; + let host = if let Some(idx) = core.find(":/") { + core[..idx].trim() + } else { + core.trim() + }; + if host.is_empty() + || host.contains('\\') + || host.contains('/') + || looks_like_windows_drive(host) + { + continue; + } + if known.iter().any(|v| v.name == host) && !out.iter().any(|n| n == host) { + out.push(host.to_string()); + } + } + out +} + #[cfg(test)] mod tests { use super::*; @@ -94,4 +134,29 @@ mod tests { assert!(!bind[0].named); assert!(bind[0].read_only); } + + #[test] + fn extracts_only_known_named_volumes() { + let known = vec![ + VolumeInfo { + name: "data".into(), + driver: "vhd".into(), + mountpoint: String::new(), + created: String::new(), + size: "1 GiB".into(), + }, + VolumeInfo { + name: "db".into(), + driver: "vhd".into(), + mountpoint: String::new(), + created: String::new(), + size: "2 GiB".into(), + }, + ]; + let names = named_volumes_attached( + r"vol:data:/data, C:\logs:/var/log, vol:db:/var/lib/mysql:ro, other:/tmp", + &known, + ); + assert_eq!(names, vec!["data".to_string(), "db".to_string()]); + } } diff --git a/src/app/refresh.rs b/src/app/refresh.rs index f30b209..41e3688 100644 --- a/src/app/refresh.rs +++ b/src/app/refresh.rs @@ -25,6 +25,7 @@ impl AppView { } self.locale = locale; let t = self.t(); + super::tray_icon::refresh_menu(cx); self.name_input.update(cx, |state, cx| { state.set_placeholder(t.name_placeholder, window, cx); }); @@ -270,7 +271,7 @@ impl AppView { } /// Returns `true` while the background op is still running. - fn poll_busy(&mut self, cx: &mut Context) -> bool { + pub(crate) fn poll_busy(&mut self, cx: &mut Context) -> bool { let Some(rx) = self.busy_rx.take() else { return false; }; @@ -295,12 +296,21 @@ impl AppView { if self.create_modal_open { self.create_modal_open = false; } - self.set_status(msg, cx); - self.refresh(cx); + if self.pending_quit_after_stop { + self.set_status(msg, cx); + self.finish_quit_after_stop(cx); + } else { + self.set_status(msg, cx); + self.refresh(cx); + } } Err(err) => { let msg = i18n::fmt1(self.t().error_prefix, "err", err.to_string()); self.set_status(msg, cx); + if self.pending_quit_after_stop { + // Still leave so a stuck stop cannot pin the UI forever. + self.finish_quit_after_stop(cx); + } } } cx.notify(); diff --git a/src/app/settings.rs b/src/app/settings.rs index 965a258..2e2d16f 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -9,7 +9,7 @@ use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use crate::i18n::Locale; -use crate::wslc::{SessionPrefs, WslcClient}; +use crate::wslc::{CloseBehavior, SessionPrefs, WslcClient}; use super::widgets::settings_card; use super::AppView; @@ -36,6 +36,7 @@ impl AppView { let theme_mode = self.theme_mode; let cpu = self.session_prefs.cpu_count; let memory = self.session_prefs.memory_mb; + let close_behavior = self.session_prefs.close_behavior; div() .id("settings-panel") @@ -117,6 +118,62 @@ impl AppView { ), ), ) + .child( + settings_card(cx) + .child(div().font_medium().text_sm().child(t.settings_close)) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(t.settings_close_hint), + ) + .child( + v_flex() + .gap_2() + .child( + Button::new("close-ask") + .small() + .label(t.settings_close_ask) + .when(close_behavior == CloseBehavior::Ask, |b| { + b.primary() + }) + .on_click(cx.listener(|this, _, _, cx| { + this.set_close_behavior(CloseBehavior::Ask, cx); + })), + ) + .child( + Button::new("close-minimize") + .small() + .label(t.settings_close_minimize) + .when( + close_behavior == CloseBehavior::MinimizeToTray, + |b| b.primary(), + ) + .on_click(cx.listener(|this, _, _, cx| { + this.set_close_behavior( + CloseBehavior::MinimizeToTray, + cx, + ); + })), + ) + .child( + Button::new("close-quit-stop") + .small() + .label(t.settings_close_quit_stop) + .when( + close_behavior + == CloseBehavior::QuitAndStopContainers, + |b| b.primary(), + ) + .on_click(cx.listener(|this, _, _, cx| { + this.set_close_behavior( + CloseBehavior::QuitAndStopContainers, + cx, + ); + })), + ), + ), + ) .child( settings_card(cx) .child(div().font_medium().text_sm().child(t.settings_session)) diff --git a/src/app/state.rs b/src/app/state.rs index 28b45f2..e55b0b9 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -56,7 +56,14 @@ pub(crate) enum SdkSetupPhase { #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum ConfirmAction { - RemoveContainer { key: String, label: String }, + RemoveContainer { + key: String, + label: String, + /// Session named volumes attached to this container (may be empty). + named_volumes: Vec, + /// When true, also delete `named_volumes` after the container is removed. + remove_volumes: bool, + }, RemoveImage { reference: String }, RemoveVolume { name: String }, } @@ -82,6 +89,7 @@ impl super::AppView { || self.logs_modal_open || self.exec_modal_open || self.confirm.is_some() + || self.close_prompt_open || self.volume_modal_open || (self.create_modal_open && self.busy) } diff --git a/src/app/tray_icon.rs b/src/app/tray_icon.rs new file mode 100644 index 0000000..97bc6c4 --- /dev/null +++ b/src/app/tray_icon.rs @@ -0,0 +1,121 @@ +//! System tray resident icon (Windows). Non-Windows builds are no-ops. + +use gpui_kit::*; + +/// Actions dispatched from the tray menu / tray activation. +pub mod actions { + use gpui_kit::actions; + actions!( + wslc_desktop_tray, + [ShowMainWindow, QuitKeepContainers, QuitStopContainers] + ); +} + +pub use actions::{QuitKeepContainers, QuitStopContainers, ShowMainWindow}; + +#[cfg(windows)] +mod windows_tray { + use std::sync::Arc; + + use gpui_kit::{App, Global, MenuItem, QuitMode}; + use gpui_tray::{Icon, Tray}; + + use super::{QuitKeepContainers, QuitStopContainers, ShowMainWindow}; + use crate::i18n::Locale; + + pub struct AppTray(pub Tray); + + impl Global for AppTray {} + + pub fn install(cx: &mut App) { + cx.set_quit_mode(QuitMode::Explicit); + + let locale = Locale::from_system(); + let tooltip = locale.strings().tray_tooltip; + match Tray::builder() + .icon(make_icon()) + .tooltip(tooltip) + .on_activate(ShowMainWindow) + .menu(build_menu) + .build(cx) + { + Ok(tray) => cx.set_global(AppTray(tray)), + Err(err) => { + // Tray is best-effort; close-prompt / minimize still work via taskbar minimize. + eprintln!("WSLc Desktop: tray icon unavailable: {err}"); + } + } + } + + fn build_menu(cx: &mut App) -> Vec { + let locale = Locale::from_system(); + // Prefer the live UI locale when the main view already chose one. + let t = if let Some(shell) = cx.try_global::() { + shell + .view + .upgrade() + .map(|view| view.read(cx).t()) + .unwrap_or_else(|| locale.strings()) + } else { + locale.strings() + }; + vec![ + MenuItem::action(t.tray_show_window, ShowMainWindow), + MenuItem::separator(), + MenuItem::action(t.tray_quit_keep, QuitKeepContainers), + MenuItem::action(t.tray_quit_stop, QuitStopContainers), + ] + } + + fn make_icon() -> Icon { + // Simple 32×32 teal mark so we do not need image decoding at startup. + const SIZE: u32 = 32; + let mut rgba = vec![0u8; (SIZE * SIZE * 4) as usize]; + for y in 0..SIZE { + for x in 0..SIZE { + let i = ((y * SIZE + x) * 4) as usize; + let edge = x < 2 || y < 2 || x >= SIZE - 2 || y >= SIZE - 2; + let inner = x >= 8 && x < 24 && y >= 8 && y < 24; + if edge { + rgba[i] = 0x0f; + rgba[i + 1] = 0x76; + rgba[i + 2] = 0x6e; + rgba[i + 3] = 0xff; + } else if inner { + rgba[i] = 0x14; + rgba[i + 1] = 0xb8; + rgba[i + 2] = 0xa6; + rgba[i + 3] = 0xff; + } else { + rgba[i] = 0x13; + rgba[i + 1] = 0x42; + rgba[i + 2] = 0x52; + rgba[i + 3] = 0xff; + } + } + } + Icon::from_rgba(Arc::<[u8]>::from(rgba), SIZE, SIZE).expect("tray icon rgba") + } +} + +#[cfg(windows)] +pub use windows_tray::install; + +#[cfg(not(windows))] +pub fn install(_cx: &mut App) {} + +/// Refresh tray menu labels after a locale change (Windows only). +pub fn refresh_menu(cx: &mut App) { + #[cfg(windows)] + { + use gpui_kit::Global as _; + if let Some(tray) = cx.try_global::() { + let tray = tray.0.clone(); + let _ = tray.refresh_menu(cx); + } + } + #[cfg(not(windows))] + { + let _ = cx; + } +} diff --git a/src/i18n.rs b/src/i18n.rs index d9399d4..8a70fb9 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -87,6 +87,8 @@ pub struct Strings { pub confirm_remove_title: &'static str, pub confirm_remove_container: &'static str, + pub confirm_remove_container_volumes: &'static str, + pub confirm_remove_container_volumes_hint: &'static str, pub confirm_remove_image: &'static str, pub confirm_remove_volume: &'static str, @@ -218,6 +220,30 @@ pub struct Strings { pub settings_cpu: &'static str, pub settings_memory: &'static str, pub settings_prefs_saved: &'static str, + pub settings_close: &'static str, + pub settings_close_hint: &'static str, + pub settings_close_ask: &'static str, + pub settings_close_minimize: &'static str, + pub settings_close_quit_stop: &'static str, + pub settings_close_saved: &'static str, + + pub close_prompt_title: &'static str, + pub close_prompt_body: &'static str, + pub close_prompt_minimize: &'static str, + pub close_prompt_quit_stop: &'static str, + pub close_prompt_remember: &'static str, + /// Used by the Windows system-tray menu builder. + #[cfg_attr(not(windows), allow(dead_code))] + pub tray_tooltip: &'static str, + #[cfg_attr(not(windows), allow(dead_code))] + pub tray_show_window: &'static str, + #[cfg_attr(not(windows), allow(dead_code))] + pub tray_quit_keep: &'static str, + #[cfg_attr(not(windows), allow(dead_code))] + pub tray_quit_stop: &'static str, + pub minimized_to_tray: &'static str, + pub stopping_containers_on_quit: &'static str, + pub quit_stopped_containers: &'static str, pub overview_cpu: &'static str, pub overview_memory: &'static str, @@ -230,6 +256,7 @@ pub struct Strings { pub stopped: &'static str, pub restarted: &'static str, pub removed: &'static str, + pub removed_container_with_volumes: &'static str, pub created_container: &'static str, pub created_volume: &'static str, pub pulling_image: &'static str, @@ -275,7 +302,7 @@ pub static EN: Strings = Strings { overview_status_live: "Connected (LIVE)", overview_status_off: "Unavailable (OFF)", overview_na: "—", - overview_persist_hint: "Containers and volumes are restored after app restart (session is not terminated on exit).", + overview_persist_hint: "Closing the window can minimize to the tray (containers keep running) or quit and stop running containers. Named volumes are never deleted unless you confirm them separately.", overview_cpu: "CPU", overview_memory: "Memory", @@ -301,6 +328,8 @@ pub static EN: Strings = Strings { confirm_remove_title: "Confirm remove", confirm_remove_container: "Remove container “{name}”? This cannot be undone.", + confirm_remove_container_volumes: "Also delete named volumes: {volumes}", + confirm_remove_container_volumes_hint: "Volumes hold database files and other data. Leave this off to keep them.", confirm_remove_image: "Remove image “{name}”? This cannot be undone.", confirm_remove_volume: "Remove volume “{name}”? This cannot be undone.", @@ -420,7 +449,7 @@ pub static EN: Strings = Strings { pull_ok: "OK", pull_failed: "FAILED", - settings_intro: "Language, appearance, and session resources. Language defaults from the system locale (Chinese → 中文, otherwise English).", + settings_intro: "Language, appearance, close-window behavior, and session resources. Language defaults from the system locale (Chinese → 中文, otherwise English).", language: "Language", language_hint: "UI language for menus, buttons, and status text.", theme: "Theme", @@ -432,6 +461,25 @@ pub static EN: Strings = Strings { settings_cpu: "CPU count", settings_memory: "Memory (MiB)", settings_prefs_saved: "Session preferences saved", + settings_close: "Close window", + settings_close_hint: "Choose what the window close button does. “Ask each time” restores the first-run prompt.", + settings_close_ask: "Ask each time", + settings_close_minimize: "Minimize to tray (keep containers)", + settings_close_quit_stop: "Quit and stop running containers", + settings_close_saved: "Close behavior saved", + + close_prompt_title: "Close WSLc Desktop", + close_prompt_body: "Minimize keeps the manager in the system tray and leaves containers running. Quit and stop shuts down every running container in this session, then exits the app.", + close_prompt_minimize: "Minimize to tray (keep containers)", + close_prompt_quit_stop: "Quit and stop running containers", + close_prompt_remember: "Remember this choice (change anytime in Settings)", + tray_tooltip: "WSLc Desktop", + tray_show_window: "Show window", + tray_quit_keep: "Quit app (keep containers running)", + tray_quit_stop: "Quit and stop running containers", + minimized_to_tray: "Minimized to tray · containers still running", + stopping_containers_on_quit: "Stopping running containers before quit…", + quit_stopped_containers: "Stopped {count} container(s); quitting…", list_containers_failed: "List containers failed: {err}", list_images_failed: "List images failed: {err}", @@ -441,6 +489,7 @@ pub static EN: Strings = Strings { stopped: "Stopped {name}", restarted: "Restarted {name}", removed: "Removed {name}", + removed_container_with_volumes: "Removed {name} and volumes {volumes}", created_container: "Created {name}", created_volume: "Created volume {name}", pulling_image: "Pulling {name}…", @@ -486,7 +535,7 @@ pub static ZH: Strings = Strings { overview_status_live: "已连接(LIVE)", overview_status_off: "不可用(OFF)", overview_na: "—", - overview_persist_hint: "退出应用不会终止会话;重启后会自动恢复容器与卷。", + overview_persist_hint: "关闭窗口时可最小化到托盘(容器继续运行),或退出并停止运行中的容器。命名卷不会被顺手删掉,除非你在删除容器时单独确认。", overview_cpu: "CPU", overview_memory: "内存", @@ -512,6 +561,8 @@ pub static ZH: Strings = Strings { confirm_remove_title: "确认删除", confirm_remove_container: "删除容器「{name}」?此操作不可撤销。", + confirm_remove_container_volumes: "同时删除命名卷:{volumes}", + confirm_remove_container_volumes_hint: "命名卷里可能有数据库等数据。默认不勾选,避免误删。", confirm_remove_image: "删除镜像「{name}」?此操作不可撤销。", confirm_remove_volume: "删除卷「{name}」?此操作不可撤销。", @@ -631,7 +682,7 @@ pub static ZH: Strings = Strings { pull_ok: "成功", pull_failed: "失败", - settings_intro: "语言、外观与会话资源。语言默认跟随系统区域设置(中文 → 中文,否则英文)。", + settings_intro: "语言、外观、关闭窗口行为与会话资源。语言默认跟随系统区域设置(中文 → 中文,否则英文)。", language: "语言", language_hint: "菜单、按钮与状态栏使用的界面语言。", theme: "主题", @@ -643,6 +694,25 @@ pub static ZH: Strings = Strings { settings_cpu: "CPU 数量", settings_memory: "内存(MiB)", settings_prefs_saved: "会话偏好已保存", + settings_close: "关闭窗口", + settings_close_hint: "设置点关闭按钮时的行为。「每次询问」会恢复首次关闭时的提示。", + settings_close_ask: "每次询问", + settings_close_minimize: "最小化到托盘(容器继续运行)", + settings_close_quit_stop: "退出并停止运行中的容器", + settings_close_saved: "关闭行为已保存", + + close_prompt_title: "关闭 WSLc Desktop", + close_prompt_body: "最小化会把管理器收进系统托盘,容器继续运行。「退出并停止」会停止本会话中所有运行中的容器,然后退出应用。", + close_prompt_minimize: "最小化到托盘(容器继续运行)", + close_prompt_quit_stop: "退出并停止运行中的容器", + close_prompt_remember: "记住本次选择(可在设置中修改)", + tray_tooltip: "WSLc Desktop", + tray_show_window: "显示窗口", + tray_quit_keep: "退出应用(容器继续运行)", + tray_quit_stop: "退出并停止运行中的容器", + minimized_to_tray: "已最小化到托盘 · 容器仍在运行", + stopping_containers_on_quit: "正在停止运行中的容器,随后退出…", + quit_stopped_containers: "已停止 {count} 个容器;正在退出…", list_containers_failed: "列出容器失败: {err}", list_images_failed: "列出镜像失败: {err}", @@ -652,6 +722,7 @@ pub static ZH: Strings = Strings { stopped: "已停止 {name}", restarted: "已重启 {name}", removed: "已删除 {name}", + removed_container_with_volumes: "已删除 {name} 及卷 {volumes}", created_container: "已创建 {name}", created_volume: "已创建卷 {name}", pulling_image: "正在拉取 {name}…", diff --git a/src/main.rs b/src/main.rs index 25cafb8..01d5315 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,8 @@ mod wslc; use gpui_kit::component::{Theme, ThemeMode, TitleBar}; use gpui_kit::*; -use app::AppView; +use app::tray_icon::{self, QuitKeepContainers, QuitStopContainers, ShowMainWindow}; +use app::{AppView, DesktopShell}; actions!(wslc_desktop, [Quit]); @@ -33,10 +34,35 @@ fn main() { // Override init's default Light theme before the first frame is shown. Theme::change(ThemeMode::Dark, None, cx); + tray_icon::install(cx); + cx.bind_keys([KeyBinding::new("alt-f4", Quit, None)]); cx.on_action(|_: &Quit, cx: &mut App| { - cx.quit(); + dispatch_close_request(cx); + }); + cx.on_action(|_: &ShowMainWindow, cx: &mut App| { + show_main_window(cx); + }); + cx.on_action(|_: &QuitKeepContainers, cx: &mut App| { + if let Some(shell) = cx.try_global::() { + let view = shell.view.clone(); + let _ = view.update(cx, |this, cx| { + this.quit_keep_containers(cx); + }); + } else { + cx.quit(); + } + }); + cx.on_action(|_: &QuitStopContainers, cx: &mut App| { + if let Some(shell) = cx.try_global::() { + let view = shell.view.clone(); + let _ = view.update(cx, |this, cx| { + this.begin_quit_and_stop_containers(cx); + }); + } else { + cx.quit(); + } }); let window_options = WindowOptions { @@ -44,11 +70,49 @@ fn main() { ..TitleBar::window_options() }; - gpui_kit::open_window(window_options, cx, |window, cx| { + let (window_handle, view) = gpui_kit::open_window(window_options, cx, |window, cx| { window.activate_window(); window.set_window_title("WSLc Desktop"); - cx.new(|cx| AppView::new(window, cx)) + let view = cx.new(|cx| AppView::new(window, cx)); + let weak = view.downgrade(); + window.on_window_should_close(cx, move |window, cx| { + weak.update(cx, |this, cx| this.handle_window_close_request(window, cx)) + .unwrap_or(true) + }); + view }) .expect("Failed to open window"); + + cx.set_global(DesktopShell { + window: window_handle, + view: view.downgrade(), + }); + }); +} + +fn dispatch_close_request(cx: &mut App) { + let Some(shell) = cx.try_global::() else { + cx.quit(); + return; + }; + let window = shell.window; + let view = shell.view.clone(); + let _ = window.update(cx, |_, window, cx| { + let _ = view.update(cx, |this, cx| { + let _ = this.handle_window_close_request(window, cx); + }); + }); +} + +fn show_main_window(cx: &mut App) { + let Some(shell) = cx.try_global::() else { + return; + }; + let window = shell.window; + let view = shell.view.clone(); + let _ = window.update(cx, |_, window, cx| { + let _ = view.update(cx, |this, cx| { + this.show_from_tray(window, cx); + }); }); } diff --git a/src/wslc/mod.rs b/src/wslc/mod.rs index 71c4572..03ab7e3 100644 --- a/src/wslc/mod.rs +++ b/src/wslc/mod.rs @@ -27,7 +27,7 @@ mod types; #[cfg(windows)] mod wsl_probe; -pub use prefs::SessionPrefs; +pub use prefs::{CloseBehavior, SessionPrefs}; pub use templates::{ContainerTemplate, TemplateEditError, TemplateStore, UpsertKind}; pub use types::*; @@ -341,6 +341,25 @@ impl WslcClient { } } + /// Stop every running container in this desktop session. Returns how many were stopped. + pub fn stop_all_running_containers(&self) -> Result { + let containers = self.list_containers(true)?; + let mut stopped = 0usize; + for item in containers { + if !item.is_running() { + continue; + } + let key = if item.name.is_empty() { + item.id.clone() + } else { + item.name.clone() + }; + self.stop_container(&key)?; + stopped += 1; + } + Ok(stopped) + } + pub fn restart_container(&self, name_or_id: &str) -> Result<()> { match &self.inner.backend { #[cfg(windows)] diff --git a/src/wslc/prefs.rs b/src/wslc/prefs.rs index 0ae5f59..a0c38c7 100644 --- a/src/wslc/prefs.rs +++ b/src/wslc/prefs.rs @@ -1,4 +1,4 @@ -//! Desktop preferences (session CPU / memory). Applied on session start. +//! Desktop preferences (session CPU / memory / close behavior). Applied on session start. use std::fs; use std::path::PathBuf; @@ -10,10 +10,25 @@ use super::paths::{app_data_dir, write_atomic}; pub const DEFAULT_CPU_COUNT: u32 = 2; pub const DEFAULT_MEMORY_MB: u32 = 2048; +/// What happens when the user closes the main window. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum CloseBehavior { + /// First close (or after reset): ask minimize vs quit-and-stop. + #[default] + Ask, + /// Hide the window; keep the tray icon and leave containers running. + MinimizeToTray, + /// Stop every running container in this session, then quit the app. + QuitAndStopContainers, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct SessionPrefs { pub cpu_count: u32, pub memory_mb: u32, + #[serde(default)] + pub close_behavior: CloseBehavior, } impl Default for SessionPrefs { @@ -21,6 +36,7 @@ impl Default for SessionPrefs { Self { cpu_count: DEFAULT_CPU_COUNT, memory_mb: DEFAULT_MEMORY_MB, + close_behavior: CloseBehavior::Ask, } } } @@ -70,9 +86,17 @@ mod tests { let p = SessionPrefs { cpu_count: 4, memory_mb: 4096, + close_behavior: CloseBehavior::MinimizeToTray, }; let bytes = serde_json::to_vec(&p).unwrap(); let back: SessionPrefs = serde_json::from_slice(&bytes).unwrap(); assert_eq!(back, p); } + + #[test] + fn prefs_default_close_ask_when_missing() { + let json = br#"{"cpu_count":2,"memory_mb":2048}"#; + let back: SessionPrefs = serde_json::from_slice(json).unwrap(); + assert_eq!(back.close_behavior, CloseBehavior::Ask); + } } From 2fe942919d6ebea1d60c75a25b9ecd088879c48d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 18 Oct 2026 12:54:46 +0000 Subject: [PATCH 2/9] fix(windows): use HasWindowHandle UFCS for tray hide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GPUI's Window::window_handle shadows the raw-window-handle trait method, which broke the Windows MSI build. Co-authored-by: 绿岸公式 --- src/app/close.rs | 4 +++- src/app/tray_icon.rs | 1 - 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app/close.rs b/src/app/close.rs index ee5f37b..b24c937 100644 --- a/src/app/close.rs +++ b/src/app/close.rs @@ -267,7 +267,9 @@ fn show_native_window(window: &mut Window) { fn native_hwnd(window: &Window) -> Option { use raw_window_handle::{HasWindowHandle, RawWindowHandle}; - let handle = window.window_handle().ok()?; + // UFCS: GPUI's `Window::window_handle()` returns `AnyWindowHandle` and shadows + // the raw-window-handle trait method of the same name. + let handle = HasWindowHandle::window_handle(window).ok()?; match handle.as_raw() { RawWindowHandle::Win32(win32) => Some(win32.hwnd.get() as _), _ => None, diff --git a/src/app/tray_icon.rs b/src/app/tray_icon.rs index 97bc6c4..837ecd0 100644 --- a/src/app/tray_icon.rs +++ b/src/app/tray_icon.rs @@ -108,7 +108,6 @@ pub fn install(_cx: &mut App) {} pub fn refresh_menu(cx: &mut App) { #[cfg(windows)] { - use gpui_kit::Global as _; if let Some(tray) = cx.try_global::() { let tray = tray.0.clone(); let _ = tray.refresh_menu(cx); From 79d33b902cab9ade7afab62dbccbff9622b388ff Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 18 Oct 2026 13:28:17 +0000 Subject: [PATCH 3/9] ui: unify close settings and simplify close prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match close-behavior buttons to language/theme row style. Always save the first close choice, and replace the cancel footer with a top-right dismiss icon. Co-authored-by: 绿岸公式 --- src/app/close.rs | 55 ++++++++++++++++++--------------------------- src/app/mod.rs | 2 -- src/app/settings.rs | 3 ++- src/i18n.rs | 7 ++---- 4 files changed, 26 insertions(+), 41 deletions(-) diff --git a/src/app/close.rs b/src/app/close.rs index b24c937..afc1b83 100644 --- a/src/app/close.rs +++ b/src/app/close.rs @@ -1,10 +1,9 @@ use gpui_kit::component::{ - ActiveTheme, Sizable as _, StyledExt as _, + ActiveTheme, IconName, Sizable as _, StyledExt as _, button::{Button, ButtonVariants as _}, h_flex, v_flex, }; -use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use smol::Timer; @@ -30,7 +29,6 @@ impl AppView { match self.session_prefs.close_behavior { CloseBehavior::Ask => { self.close_prompt_open = true; - self.close_prompt_remember = true; cx.notify(); false } @@ -55,19 +53,15 @@ impl AppView { window: &mut Window, cx: &mut Context, ) { - if self.close_prompt_remember { - self.session_prefs.close_behavior = CloseBehavior::MinimizeToTray; - let _ = self.persist_close_behavior(cx); - } + self.session_prefs.close_behavior = CloseBehavior::MinimizeToTray; + let _ = self.persist_close_behavior(cx); self.close_prompt_open = false; self.minimize_to_tray(window, cx); } pub(crate) fn choose_quit_and_stop(&mut self, cx: &mut Context) { - if self.close_prompt_remember { - self.session_prefs.close_behavior = CloseBehavior::QuitAndStopContainers; - let _ = self.persist_close_behavior(cx); - } + self.session_prefs.close_behavior = CloseBehavior::QuitAndStopContainers; + let _ = self.persist_close_behavior(cx); self.close_prompt_open = false; self.begin_quit_and_stop_containers(cx); } @@ -174,30 +168,35 @@ impl AppView { pub(crate) fn render_close_prompt_modal(&self, cx: &mut Context) -> impl IntoElement { let t = self.t(); - let remember = self.close_prompt_remember; modal_scrim( "close-prompt-modal", cx, modal_panel(cx) .w(px(480.)) - .child(div().font_semibold().text_base().child(t.close_prompt_title)) + .child( + h_flex() + .items_center() + .justify_between() + .gap_3() + .child(div().font_semibold().text_base().child(t.close_prompt_title)) + .child( + Button::new("close-prompt-dismiss") + .ghost() + .small() + .icon(IconName::Close) + .tooltip(t.close) + .on_click(cx.listener(|this, _, _, cx| { + this.dismiss_close_prompt(cx); + })), + ), + ) .child( div() .text_sm() .text_color(cx.theme().muted_foreground) .child(t.close_prompt_body), ) - .child( - Button::new("close-prompt-remember") - .small() - .label(t.close_prompt_remember) - .when(remember, |b| b.primary()) - .on_click(cx.listener(|this, _, _, cx| { - this.close_prompt_remember = !this.close_prompt_remember; - cx.notify(); - })), - ) .child( v_flex() .gap_2() @@ -215,16 +214,6 @@ impl AppView { .on_click(cx.listener(|this, _, _, cx| { this.choose_quit_and_stop(cx); })), - ) - .child( - h_flex().justify_end().child( - Button::new("close-prompt-cancel") - .small() - .label(t.cancel) - .on_click(cx.listener(|this, _, _, cx| { - this.dismiss_close_prompt(cx); - })), - ), ), ), ) diff --git a/src/app/mod.rs b/src/app/mod.rs index eda1b04..f8e3a3f 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -85,7 +85,6 @@ pub struct AppView { exec_target_key: SharedString, confirm: Option, close_prompt_open: bool, - close_prompt_remember: bool, window_hidden: bool, allow_window_close: bool, pending_quit_after_stop: bool, @@ -193,7 +192,6 @@ impl AppView { exec_target_key: SharedString::default(), confirm: None, close_prompt_open: false, - close_prompt_remember: true, window_hidden: false, allow_window_close: false, pending_quit_after_stop: false, diff --git a/src/app/settings.rs b/src/app/settings.rs index 2e2d16f..a603ef6 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -128,8 +128,9 @@ impl AppView { .child(t.settings_close_hint), ) .child( - v_flex() + h_flex() .gap_2() + .flex_wrap() .child( Button::new("close-ask") .small() diff --git a/src/i18n.rs b/src/i18n.rs index 8a70fb9..181c952 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -231,7 +231,6 @@ pub struct Strings { pub close_prompt_body: &'static str, pub close_prompt_minimize: &'static str, pub close_prompt_quit_stop: &'static str, - pub close_prompt_remember: &'static str, /// Used by the Windows system-tray menu builder. #[cfg_attr(not(windows), allow(dead_code))] pub tray_tooltip: &'static str, @@ -469,10 +468,9 @@ pub static EN: Strings = Strings { settings_close_saved: "Close behavior saved", close_prompt_title: "Close WSLc Desktop", - close_prompt_body: "Minimize keeps the manager in the system tray and leaves containers running. Quit and stop shuts down every running container in this session, then exits the app.", + close_prompt_body: "Minimize keeps the manager in the system tray and leaves containers running. Quit and stop shuts down every running container in this session, then exits the app. Your choice is saved and can be changed later in Settings.", close_prompt_minimize: "Minimize to tray (keep containers)", close_prompt_quit_stop: "Quit and stop running containers", - close_prompt_remember: "Remember this choice (change anytime in Settings)", tray_tooltip: "WSLc Desktop", tray_show_window: "Show window", tray_quit_keep: "Quit app (keep containers running)", @@ -702,10 +700,9 @@ pub static ZH: Strings = Strings { settings_close_saved: "关闭行为已保存", close_prompt_title: "关闭 WSLc Desktop", - close_prompt_body: "最小化会把管理器收进系统托盘,容器继续运行。「退出并停止」会停止本会话中所有运行中的容器,然后退出应用。", + close_prompt_body: "最小化会把管理器收进系统托盘,容器继续运行。「退出并停止」会停止本会话中所有运行中的容器,然后退出应用。本次选择会自动保存,之后可在设置中修改。", close_prompt_minimize: "最小化到托盘(容器继续运行)", close_prompt_quit_stop: "退出并停止运行中的容器", - close_prompt_remember: "记住本次选择(可在设置中修改)", tray_tooltip: "WSLc Desktop", tray_show_window: "显示窗口", tray_quit_keep: "退出应用(容器继续运行)", From 23ccbe832c7ec5876d30d7c4691f19e22564f65c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 18 Oct 2026 13:39:00 +0000 Subject: [PATCH 4/9] fix(volumes): clarify already-exists create errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Map HRESULT 0x800700B7 to a localized message and re-sync orphan session VHDs into the catalog when create hits already-exists. Co-authored-by: 绿岸公式 --- src/app/refresh.rs | 9 ++++++++- src/i18n.rs | 3 +++ src/wslc/errors.rs | 25 +++++++++++++++++++++++++ src/wslc/live.rs | 34 +++++++++++++++++++++++++++------- src/wslc/mod.rs | 1 + 5 files changed, 64 insertions(+), 8 deletions(-) create mode 100644 src/wslc/errors.rs diff --git a/src/app/refresh.rs b/src/app/refresh.rs index 41e3688..ccf9a7c 100644 --- a/src/app/refresh.rs +++ b/src/app/refresh.rs @@ -305,7 +305,14 @@ impl AppView { } } Err(err) => { - let msg = i18n::fmt1(self.t().error_prefix, "err", err.to_string()); + let raw = err.to_string(); + let msg = if let Some(name) = raw.strip_prefix("__VOLUME_EXISTS__\0") { + // Catalog may have been repaired; refresh so the row appears. + self.refresh(cx); + i18n::fmt1(self.t().volume_already_exists, "name", name) + } else { + i18n::fmt1(self.t().error_prefix, "err", raw) + }; self.set_status(msg, cx); if self.pending_quit_after_stop { // Still leave so a stuck stop cannot pin the UI forever. diff --git a/src/i18n.rs b/src/i18n.rs index 181c952..2c9a0b8 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -110,6 +110,7 @@ pub struct Strings { pub create_volume_intro: &'static str, pub volume_name_label: &'static str, pub volume_name_empty: &'static str, + pub volume_already_exists: &'static str, pub pull_image: &'static str, pub pull_hint: &'static str, @@ -350,6 +351,7 @@ pub static EN: Strings = Strings { create_volume_intro: "Creates a dynamic VHD in this desktop session. Pick a size, then create.", volume_name_label: "Name", volume_name_empty: "Volume name is empty", + volume_already_exists: "Volume “{name}” already exists (HRESULT 0x800700B7). It was restored to the list if missing. Pick another name, or delete the volume first.", pull_image: "Pull image", pull_hint: "Enter an image reference and pull", @@ -582,6 +584,7 @@ pub static ZH: Strings = Strings { create_volume_intro: "在本桌面会话中创建动态 VHD。选择容量后创建。", volume_name_label: "名称", volume_name_empty: "卷名称为空", + volume_already_exists: "卷「{name}」已存在(HRESULT 0x800700B7)。若列表里没有,已尝试重新同步。请换个名字,或先删除该卷后再创建。", pull_image: "拉取镜像", pull_hint: "输入镜像引用后拉取", diff --git a/src/wslc/errors.rs b/src/wslc/errors.rs new file mode 100644 index 0000000..6861d39 --- /dev/null +++ b/src/wslc/errors.rs @@ -0,0 +1,25 @@ +//! Shared helpers for mapping SDK / Win32 errors into UI-friendly conditions. + +/// Win32 `ERROR_ALREADY_EXISTS` (183) as HRESULT `0x800700B7`. +pub fn is_already_exists_error(message: &str) -> bool { + let lower = message.to_ascii_lowercase(); + lower.contains("0x800700b7") + || lower.contains("already exists") + || lower.contains("cannot create a file when that file already exists") +} + +#[cfg(test)] +mod tests { + use super::is_already_exists_error; + + #[test] + fn detects_hresult_already_exists() { + assert!(is_already_exists_error( + "WSLC call failed: HRESULT=0x800700b7, Cannot create a file when that file already exists." + )); + assert!(is_already_exists_error("volume already exists: data")); + assert!(!is_already_exists_error( + "WSLC call failed: HRESULT=0x80070005, Access denied" + )); + } +} diff --git a/src/wslc/live.rs b/src/wslc/live.rs index e7f9065..453674b 100644 --- a/src/wslc/live.rs +++ b/src/wslc/live.rs @@ -9,6 +9,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use anyhow::{anyhow, Result}; use super::catalog::{Catalog, PersistedContainer}; +use super::errors::is_already_exists_error; use super::prefs::SessionPrefs; use super::{ split_ref, BackendKind, ContainerInfo, CreateContainerRequest, EngineStatus, ImageInfo, @@ -677,18 +678,38 @@ impl LiveState { return Err(anyhow!("volume name is empty")); } if self.volumes.iter().any(|v| v.name == name) { - return Err(anyhow!("volume already exists: {name}")); + return Err(anyhow!("__VOLUME_EXISTS__\0{name}")); } let size_bytes = if size_bytes == 0 { DEFAULT_VOLUME_BYTES } else { size_bytes.max(64 * 1024 * 1024) }; - self.session - .create_vhd_volume( - wslc::VhdOptions::new(name, size_bytes).vhd_type(wslc::VhdType::Dynamic), - ) - .map_err(|e| anyhow!("{e}"))?; + match self.session.create_vhd_volume( + wslc::VhdOptions::new(name, size_bytes).vhd_type(wslc::VhdType::Dynamic), + ) { + Ok(()) => {} + Err(err) => { + let msg = err.to_string(); + // HRESULT 0x800700B7 = Win32 ERROR_ALREADY_EXISTS. The VHD can + // already live in the session while our local catalog is empty + // (partial create, cleared catalog, etc.). Adopt it so the UI + // shows the volume again instead of a raw HRESULT. + if is_already_exists_error(&msg) { + self.ensure_volume_listed(name, size_bytes); + return Err(anyhow!("__VOLUME_EXISTS__\0{name}")); + } + return Err(anyhow!("{msg}")); + } + } + self.ensure_volume_listed(name, size_bytes); + Ok(()) + } + + fn ensure_volume_listed(&mut self, name: &str, size_bytes: u64) { + if self.volumes.iter().any(|v| v.name == name) { + return; + } self.volumes.insert( 0, VolumeInfo { @@ -700,7 +721,6 @@ impl LiveState { }, ); let _ = self.persist_catalog(); - Ok(()) } fn remove_volume(&mut self, name: &str) -> Result<()> { diff --git a/src/wslc/mod.rs b/src/wslc/mod.rs index 03ab7e3..6217e35 100644 --- a/src/wslc/mod.rs +++ b/src/wslc/mod.rs @@ -10,6 +10,7 @@ use std::thread; use anyhow::{Result, anyhow}; mod catalog; +mod errors; #[cfg(windows)] mod live; mod paths; From f3e3e28543456f60a6a5d6a3424d90162a75568d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 18 Oct 2026 13:39:07 +0000 Subject: [PATCH 5/9] docs: note volume already-exists error mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 绿岸公式 --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 448f67e..06aaa89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ ## [Unreleased] +### 修复 + +- 创建卷遇到 `HRESULT=0x800700B7`(已存在)时给出中文说明,并把会话里已有、列表缺失的卷重新同步进列表。 + - Map volume-create `0x800700B7` (already exists) to a clear message and re-list orphan session VHDs. + ### 新增 - 删除容器时可单独确认是否同时删除关联命名卷(默认不删,避免误清数据库等数据)。 From 94b5a15579dc76e7620b8c06e2812c5d9b45dd7d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 18 Oct 2026 14:16:22 +0000 Subject: [PATCH 6/9] ui: use checkbox for delete-volumes confirm option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the primary-button toggle with a Checkbox so the named-volume delete choice is obvious in the remove-container dialog. Co-authored-by: 绿岸公式 --- src/app/confirm.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/app/confirm.rs b/src/app/confirm.rs index b1ddf88..73e2391 100644 --- a/src/app/confirm.rs +++ b/src/app/confirm.rs @@ -2,6 +2,7 @@ use gpui_kit::component::{ ActiveTheme, Sizable as _, StyledExt as _, button::{Button, ButtonVariants as _}, + checkbox::Checkbox, h_flex, v_flex, }; use gpui_kit::prelude::FluentBuilder as _; @@ -32,7 +33,7 @@ impl AppView { cx.notify(); } - pub(crate) fn toggle_confirm_remove_volumes(&mut self, cx: &mut Context) { + pub(crate) fn set_confirm_remove_volumes(&mut self, checked: bool, cx: &mut Context) { if let Some(ConfirmAction::RemoveContainer { remove_volumes, named_volumes, @@ -40,7 +41,7 @@ impl AppView { }) = self.confirm.as_mut() { if !named_volumes.is_empty() { - *remove_volumes = !*remove_volumes; + *remove_volumes = checked; cx.notify(); } } @@ -165,12 +166,11 @@ impl AppView { .child(t.confirm_remove_container_volumes_hint), ) .child( - Button::new("confirm-toggle-volumes") - .small() + Checkbox::new("confirm-remove-volumes") .label(hint) - .when(selected, |b| b.primary()) - .on_click(cx.listener(|this, _, _, cx| { - this.toggle_confirm_remove_volumes(cx); + .checked(selected) + .on_change(cx.listener(|this, checked, _, cx| { + this.set_confirm_remove_volumes(*checked, cx); })), ), ) From 7d11b54539d467ffba8a094163baa6ecd375d640 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 18 Oct 2026 14:20:19 +0000 Subject: [PATCH 7/9] fix(windows): focus existing window on relaunch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second desktop-icon click shows and activates the running window (minimized or tray-hidden) instead of an already-running dialog. Co-authored-by: 绿岸公式 --- CHANGELOG.md | 2 ++ src/main.rs | 3 ++- src/single_instance.rs | 47 +++++++++++++++++++++++++----------------- 3 files changed, 32 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06aaa89..8ebe335 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ - 创建卷遇到 `HRESULT=0x800700B7`(已存在)时给出中文说明,并把会话里已有、列表缺失的卷重新同步进列表。 - Map volume-create `0x800700B7` (already exists) to a clear message and re-list orphan session VHDs. +- 程序已在运行时再次点桌面图标:不再弹「不能多开」,直接显示并前置已有窗口(含最小化 / 托盘隐藏)。 + - A second launch focuses the existing window (including tray-hidden) instead of showing an already-running dialog. ### 新增 diff --git a/src/main.rs b/src/main.rs index 01d5315..dfd2dcb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,7 +18,8 @@ fn main() { let _instance = match single_instance::try_acquire() { Ok(lock) => Some(lock), Err(single_instance::AcquireError::AlreadyRunning) => { - single_instance::notify_already_running(); + // Focus the running window (including tray-hidden) instead of a dialog. + single_instance::activate_existing_instance(); return; } Err(single_instance::AcquireError::Failed(_)) => { diff --git a/src/single_instance.rs b/src/single_instance.rs index 7536024..08eb8fd 100644 --- a/src/single_instance.rs +++ b/src/single_instance.rs @@ -1,4 +1,7 @@ //! Per-user single-instance guard so two app processes cannot share one session. +//! +//! A second launch finds the existing main window, shows it (even when hidden +//! to the tray), and exits silently — no "already running" MessageBox. pub struct InstanceLock { #[cfg(windows)] @@ -29,11 +32,14 @@ pub fn try_acquire() -> Result { } } -/// Tell the user another instance is already open (Windows MessageBox). -pub fn notify_already_running() { +/// Bring the already-running instance's main window to the foreground. +/// +/// Used when a second process hits the single-instance mutex: show the window +/// even if it was minimized or hidden to the tray, then exit without a dialog. +pub fn activate_existing_instance() { #[cfg(windows)] { - notify_windows(); + activate_windows(); } } @@ -61,24 +67,27 @@ fn acquire_windows() -> Result { } #[cfg(windows)] -fn notify_windows() { - use windows_sys::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_ICONINFORMATION, MB_OK}; - - let (title, body) = if crate::i18n::Locale::from_system() == crate::i18n::Locale::Zh { - ( - "WSLc Desktop", - "WSLc Desktop 已在运行,不能同时打开多个实例。", - ) - } else { - ( - "WSLc Desktop", - "WSLc Desktop is already running. Only one instance is allowed.", - ) +fn activate_windows() { + use windows_sys::Win32::UI::WindowsAndMessaging::{ + BringWindowToTop, FindWindowW, IsIconic, SetForegroundWindow, ShowWindow, SW_RESTORE, + SW_SHOW, }; - let title = wide(title); - let body = wide(body); + + // Must match `window.set_window_title("WSLc Desktop")` in `main`. + let title = wide("WSLc Desktop"); unsafe { - MessageBoxW(std::ptr::null_mut(), body.as_ptr(), title.as_ptr(), MB_OK | MB_ICONINFORMATION); + let hwnd = FindWindowW(std::ptr::null(), title.as_ptr()); + if hwnd.is_null() { + return; + } + if IsIconic(hwnd) != 0 { + ShowWindow(hwnd, SW_RESTORE); + } else { + // Covers normal windows and SW_HIDE (tray minimize). + ShowWindow(hwnd, SW_SHOW); + } + let _ = BringWindowToTop(hwnd); + let _ = SetForegroundWindow(hwnd); } } From 25f67d8e14b1c6fecb93a88363b071e0c74f0760 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 18 Oct 2026 14:28:24 +0000 Subject: [PATCH 8/9] feat(settings): add About with GitHub update check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show version, repo/website links at the bottom of Settings. Compare CARGO_PKG_VERSION to GitHub releases/latest and open the release page when a newer version is available. Co-authored-by: 绿岸公式 --- CHANGELOG.md | 2 + Cargo.toml | 5 +- README.md | 2 +- src/app/mod.rs | 6 +- src/app/overview.rs | 3 +- src/app/settings.rs | 202 +++++++++++++++++++++++++++++++++++++-- src/app/shell.rs | 6 +- src/app/state.rs | 18 ++++ src/i18n.rs | 40 +++++++- src/wslc/mod.rs | 4 + src/wslc/update_check.rs | 150 +++++++++++++++++++++++++++++ 11 files changed, 420 insertions(+), 18 deletions(-) create mode 100644 src/wslc/update_check.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ebe335..6327cc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ - Removing a container can optionally delete attached named volumes; off by default. - 关闭窗口首次提示:最小化到系统托盘(容器继续运行)或退出并停止本会话所有运行中容器;选择会写入设置,可随时改。托盘图标常驻,可显示窗口 / 退出并保留容器 / 退出并停止容器。 - First close asks minimize-to-tray vs quit-and-stop; preference is saved. Resident tray icon for show / quit-keep / quit-stop. +- 设置页底部「关于」:版本号、GitHub / 网站链接;对照 GitHub Releases 检查更新,有新版本时跳转到对应 Release 页。 + - Settings → About: version, GitHub/website links, and a GitHub Releases update check that opens the release page. ### 变更 diff --git a/Cargo.toml b/Cargo.toml index 4eb3ef7..4024de7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,15 +29,16 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" smol = "2" sys-locale = "0.3" +# GitHub release check (About) + Windows NuGet SDK fetch. +ureq = { version = "2.12", default-features = true, features = ["json"] } # Official WSL Containers SDK bindings (`wslcsdk.dll`). Windows host only. -# ureq/zip: download matching Microsoft.WSL.Containers native DLL at runtime. +# zip: extract matching Microsoft.WSL.Containers native DLL at runtime. [target.'cfg(windows)'.dependencies] # Own fork of wslc-rs (OpenContainer / Logs / named volumes / extra SDK exports). # Bump `rev` after pushing patches to https://github.com/timeroute/wslc-rs . # Prefer git over vendor so desktop tracks the fork tip without copying the tree. wslc = { git = "https://github.com/timeroute/wslc-rs", rev = "9fbcd72f6967f79d0dc805648120eaec77364a2e" } -ureq = { version = "2.12", default-features = true, features = ["json"] } zip = { version = "2", default-features = false, features = ["deflate"] } gpui-tray = { version = "0.1", default-features = false, features = ["gpui-kit", "menu-state"] } windows-sys = { version = "0.61", features = [ diff --git a/README.md b/README.md index aa5a858..c2965f5 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ | **Containers** | 创建(端口 / bind / 命名卷);行内 Start / Stop / Restart / Logs / Inspect / Exec / Remove;catalog + `OpenContainer` 跨重启恢复 | | **Images** | 拉取弹窗;行内创建容器 / 删除 | | **Volumes** | 弹窗创建(1/2/5/10 GiB);行内删除 | -| **设置** | 可滚动;语言、主题、关闭窗口行为、会话 CPU / 内存(下次新建会话生效) | +| **设置** | 可滚动;语言、主题、关闭窗口行为、会话 CPU / 内存(下次新建会话生效);关于(版本 / GitHub / 检查更新) | Logs 走 fork 的 `LogsOptions`(底层 `wslc --session wslc-desktop container logs`)。关闭窗口可最小化到托盘(容器继续运行),或退出并停止运行中的容器;删除容器时命名卷需单独确认。会话本身不会在退出时 `terminate`,便于下次重连。 diff --git a/src/app/mod.rs b/src/app/mod.rs index f8e3a3f..c4c968e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -28,7 +28,7 @@ mod widgets; pub use state::Section; -use state::{ConfirmAction, PullPhase, SdkSetupPhase}; +use state::{ConfirmAction, PullPhase, SdkSetupPhase, UpdateCheckPhase}; /// Shared handles so tray actions can reach the main window / view. pub struct DesktopShell { @@ -106,6 +106,8 @@ pub struct AppView { sdk_logs_open: bool, sdk_logs_copied: bool, sdk_log_error: Option, + update_check: UpdateCheckPhase, + update_check_poll_generation: u64, } impl AppView { @@ -212,6 +214,8 @@ impl AppView { sdk_logs_open: false, sdk_logs_copied: false, sdk_log_error: None, + update_check: UpdateCheckPhase::Idle, + update_check_poll_generation: 0, }; view.begin_sdk_setup(false, cx); view diff --git a/src/app/overview.rs b/src/app/overview.rs index 4eda05f..ba2a65f 100644 --- a/src/app/overview.rs +++ b/src/app/overview.rs @@ -7,13 +7,12 @@ use gpui_kit::component::{ }; use gpui_kit::*; -use crate::wslc::BackendKind; +use crate::wslc::{APP_VERSION, BackendKind}; use super::state::Section; use super::AppView; const SESSION_NAME: &str = "wslc-desktop"; -const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); impl AppView { pub(crate) fn render_overview(&self, cx: &mut Context) -> impl IntoElement { diff --git a/src/app/settings.rs b/src/app/settings.rs index a603ef6..c26b374 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -1,16 +1,22 @@ +use std::sync::mpsc; +use std::time::Duration; use gpui_kit::component::{ - ActiveTheme, Sizable as _, StyledExt as _, ThemeMode, + ActiveTheme, Disableable as _, Sizable as _, StyledExt as _, ThemeMode, button::{Button, ButtonVariants as _}, - h_flex, - v_flex, + h_flex, v_flex, }; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; +use smol::Timer; -use crate::i18n::Locale; -use crate::wslc::{CloseBehavior, SessionPrefs, WslcClient}; +use crate::i18n::{self, Locale}; +use crate::wslc::{ + APP_VERSION, CloseBehavior, GITHUB_REPO_URL, HOMEPAGE_URL, SessionPrefs, UpdateStatus, + WslcClient, check_latest, open_url, releases_url, +}; +use super::state::UpdateCheckPhase; use super::widgets::settings_card; use super::AppView; @@ -24,7 +30,96 @@ impl AppView { match WslcClient::save_session_prefs(prefs) { Ok(()) => self.set_status(self.t().settings_prefs_saved, cx), Err(err) => { - let msg = crate::i18n::fmt1(self.t().error_prefix, "err", err.to_string()); + let msg = i18n::fmt1(self.t().error_prefix, "err", err.to_string()); + self.set_status(msg, cx); + } + } + } + + /// Open Settings and kick off a GitHub release check if needed. + pub(crate) fn open_settings(&mut self, cx: &mut Context) { + self.section = super::state::Section::Settings; + self.ensure_update_check(cx); + cx.notify(); + } + + /// Start a release check when idle / after a previous failure. + pub(crate) fn ensure_update_check(&mut self, cx: &mut Context) { + match &self.update_check { + UpdateCheckPhase::Idle | UpdateCheckPhase::Failed { .. } => { + self.begin_update_check(cx); + } + UpdateCheckPhase::Checking + | UpdateCheckPhase::UpToDate { .. } + | UpdateCheckPhase::Available { .. } => {} + } + } + + pub(crate) fn begin_update_check(&mut self, cx: &mut Context) { + if matches!(self.update_check, UpdateCheckPhase::Checking) { + return; + } + let (tx, rx) = mpsc::channel(); + std::thread::Builder::new() + .name("wslc-update-check".into()) + .spawn(move || { + let _ = tx.send(check_latest(APP_VERSION)); + }) + .expect("spawn update-check thread"); + + self.update_check = UpdateCheckPhase::Checking; + self.update_check_poll_generation = self.update_check_poll_generation.wrapping_add(1); + let generation = self.update_check_poll_generation; + cx.notify(); + + cx.spawn(async move |this, cx| { + loop { + Timer::after(Duration::from_millis(50)).await; + let cont = this + .update(cx, |this, cx| { + if this.update_check_poll_generation != generation { + return false; + } + match rx.try_recv() { + Ok(Ok(UpdateStatus::UpToDate { latest })) => { + this.update_check = UpdateCheckPhase::UpToDate { latest }; + cx.notify(); + false + } + Ok(Ok(UpdateStatus::Available { latest, url })) => { + this.update_check = UpdateCheckPhase::Available { latest, url }; + cx.notify(); + false + } + Ok(Err(err)) => { + this.update_check = UpdateCheckPhase::Failed { message: err }; + cx.notify(); + false + } + Err(mpsc::TryRecvError::Empty) => true, + Err(mpsc::TryRecvError::Disconnected) => { + this.update_check = UpdateCheckPhase::Failed { + message: "channel disconnected".into(), + }; + cx.notify(); + false + } + } + }) + .unwrap_or(false); + if !cont { + break; + } + } + }) + .detach(); + } + + fn open_external_url(&mut self, url: &str, cx: &mut Context) { + match open_url(url) { + Ok(()) => {} + Err(err) => { + let msg = i18n::fmt1(self.t().settings_about_open_failed, "err", err); self.set_status(msg, cx); } } @@ -37,6 +132,24 @@ impl AppView { let cpu = self.session_prefs.cpu_count; let memory = self.session_prefs.memory_mb; let close_behavior = self.session_prefs.close_behavior; + let update_status_line = match &self.update_check { + UpdateCheckPhase::Idle => String::new(), + UpdateCheckPhase::Checking => t.settings_about_checking.to_string(), + UpdateCheckPhase::UpToDate { latest } => { + i18n::fmt1(t.settings_about_up_to_date, "version", latest.clone()) + } + UpdateCheckPhase::Available { latest, .. } => { + i18n::fmt1(t.settings_about_update_available, "version", latest.clone()) + } + UpdateCheckPhase::Failed { message } => { + i18n::fmt1(t.settings_about_check_failed, "err", message.clone()) + } + }; + let update_available_url = match &self.update_check { + UpdateCheckPhase::Available { url, .. } => Some(url.clone()), + _ => None, + }; + let checking = matches!(self.update_check, UpdateCheckPhase::Checking); div() .id("settings-panel") @@ -235,6 +348,83 @@ impl AppView { self.session_prefs.memory_mb )), ), + ) + .child( + settings_card(cx) + .child(div().font_medium().text_sm().child(t.settings_about)) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(t.settings_about_hint), + ) + .child( + div().text_sm().child(format!( + "{} {}", + t.settings_about_version, APP_VERSION + )), + ) + .child( + h_flex() + .gap_2() + .flex_wrap() + .child( + Button::new("about-github") + .small() + .label(t.settings_about_github) + .on_click(cx.listener(|this, _, _, cx| { + this.open_external_url(GITHUB_REPO_URL, cx); + })), + ) + .child( + Button::new("about-homepage") + .small() + .label(t.settings_about_homepage) + .on_click(cx.listener(|this, _, _, cx| { + this.open_external_url(HOMEPAGE_URL, cx); + })), + ) + .child( + Button::new("about-check") + .small() + .label(t.settings_about_check) + .disabled(checking) + .on_click(cx.listener(|this, _, _, cx| { + this.begin_update_check(cx); + })), + ) + .when_some(update_available_url.clone(), |row, url| { + row.child( + Button::new("about-open-release") + .small() + .primary() + .label(t.settings_about_open_release) + .on_click(cx.listener(move |this, _, _, cx| { + this.open_external_url(&url, cx); + })), + ) + }), + ) + .when(!update_status_line.is_empty(), |card| { + let accent = match &self.update_check { + UpdateCheckPhase::Available { .. } => cx.theme().foreground, + UpdateCheckPhase::Failed { .. } => cx.theme().red, + UpdateCheckPhase::UpToDate { .. } => cx.theme().green, + _ => cx.theme().muted_foreground, + }; + card.child( + div() + .text_xs() + .text_color(accent) + .child(update_status_line), + ) + }) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(releases_url()), + ), ), ) } diff --git a/src/app/shell.rs b/src/app/shell.rs index 39e8c17..8a4aa8b 100644 --- a/src/app/shell.rs +++ b/src/app/shell.rs @@ -148,13 +148,11 @@ impl AppView { cx.listener(|this, _, window, cx| { window.prevent_default(); cx.stop_propagation(); - this.section = Section::Settings; - cx.notify(); + this.open_settings(cx); }), ) .on_click(cx.listener(|this, _, _, cx| { - this.section = Section::Settings; - cx.notify(); + this.open_settings(cx); })), ), ) diff --git a/src/app/state.rs b/src/app/state.rs index e55b0b9..d0f8de5 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -54,6 +54,24 @@ pub(crate) enum SdkSetupPhase { Failed, } +/// Background GitHub Releases check for the Settings → About card. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub(crate) enum UpdateCheckPhase { + #[default] + Idle, + Checking, + UpToDate { + latest: String, + }, + Available { + latest: String, + url: String, + }, + Failed { + message: String, + }, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum ConfirmAction { RemoveContainer { diff --git a/src/i18n.rs b/src/i18n.rs index 2c9a0b8..e803611 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -227,6 +227,18 @@ pub struct Strings { pub settings_close_minimize: &'static str, pub settings_close_quit_stop: &'static str, pub settings_close_saved: &'static str, + pub settings_about: &'static str, + pub settings_about_hint: &'static str, + pub settings_about_version: &'static str, + pub settings_about_github: &'static str, + pub settings_about_homepage: &'static str, + pub settings_about_check: &'static str, + pub settings_about_checking: &'static str, + pub settings_about_up_to_date: &'static str, + pub settings_about_update_available: &'static str, + pub settings_about_open_release: &'static str, + pub settings_about_check_failed: &'static str, + pub settings_about_open_failed: &'static str, pub close_prompt_title: &'static str, pub close_prompt_body: &'static str, @@ -450,7 +462,7 @@ pub static EN: Strings = Strings { pull_ok: "OK", pull_failed: "FAILED", - settings_intro: "Language, appearance, close-window behavior, and session resources. Language defaults from the system locale (Chinese → 中文, otherwise English).", + settings_intro: "Language, appearance, close-window behavior, session resources, and about. Language defaults from the system locale (Chinese → 中文, otherwise English).", language: "Language", language_hint: "UI language for menus, buttons, and status text.", theme: "Theme", @@ -468,6 +480,18 @@ pub static EN: Strings = Strings { settings_close_minimize: "Minimize to tray (keep containers)", settings_close_quit_stop: "Quit and stop running containers", settings_close_saved: "Close behavior saved", + settings_about: "About", + settings_about_hint: "App version, project links, and a check against the latest GitHub Release.", + settings_about_version: "Version", + settings_about_github: "GitHub", + settings_about_homepage: "Website", + settings_about_check: "Check for updates", + settings_about_checking: "Checking for updates…", + settings_about_up_to_date: "You are on the latest version ({version}).", + settings_about_update_available: "A newer version is available: {version}.", + settings_about_open_release: "Open release page", + settings_about_check_failed: "Could not check for updates: {err}", + settings_about_open_failed: "Could not open link: {err}", close_prompt_title: "Close WSLc Desktop", close_prompt_body: "Minimize keeps the manager in the system tray and leaves containers running. Quit and stop shuts down every running container in this session, then exits the app. Your choice is saved and can be changed later in Settings.", @@ -683,7 +707,7 @@ pub static ZH: Strings = Strings { pull_ok: "成功", pull_failed: "失败", - settings_intro: "语言、外观、关闭窗口行为与会话资源。语言默认跟随系统区域设置(中文 → 中文,否则英文)。", + settings_intro: "语言、外观、关闭窗口行为、会话资源与关于信息。语言默认跟随系统区域设置(中文 → 中文,否则英文)。", language: "语言", language_hint: "菜单、按钮与状态栏使用的界面语言。", theme: "主题", @@ -701,6 +725,18 @@ pub static ZH: Strings = Strings { settings_close_minimize: "最小化到托盘(容器继续运行)", settings_close_quit_stop: "退出并停止运行中的容器", settings_close_saved: "关闭行为已保存", + settings_about: "关于", + settings_about_hint: "应用版本、项目链接,以及对照 GitHub Release 检查是否有新版本。", + settings_about_version: "版本", + settings_about_github: "GitHub", + settings_about_homepage: "项目网站", + settings_about_check: "检查更新", + settings_about_checking: "正在检查更新…", + settings_about_up_to_date: "当前已是最新版本({version})。", + settings_about_update_available: "发现新版本:{version}。", + settings_about_open_release: "打开发布页", + settings_about_check_failed: "检查更新失败:{err}", + settings_about_open_failed: "无法打开链接:{err}", close_prompt_title: "关闭 WSLc Desktop", close_prompt_body: "最小化会把管理器收进系统托盘,容器继续运行。「退出并停止」会停止本会话中所有运行中的容器,然后退出应用。本次选择会自动保存,之后可在设置中修改。", diff --git a/src/wslc/mod.rs b/src/wslc/mod.rs index 6217e35..968176c 100644 --- a/src/wslc/mod.rs +++ b/src/wslc/mod.rs @@ -25,12 +25,16 @@ pub mod setup_log; mod syspath; pub mod templates; mod types; +mod update_check; #[cfg(windows)] mod wsl_probe; pub use prefs::{CloseBehavior, SessionPrefs}; pub use templates::{ContainerTemplate, TemplateEditError, TemplateStore, UpsertKind}; pub use types::*; +pub use update_check::{ + APP_VERSION, GITHUB_REPO_URL, HOMEPAGE_URL, UpdateStatus, check_latest, open_url, releases_url, +}; /// Minimum WSL release the first-launch check accepts (`2.9.3`). pub fn min_wsl_version_text() -> String { diff --git a/src/wslc/update_check.rs b/src/wslc/update_check.rs new file mode 100644 index 0000000..9f4b96b --- /dev/null +++ b/src/wslc/update_check.rs @@ -0,0 +1,150 @@ +//! App version + GitHub Releases latest check (open browser for updates). + +use std::process::Command; + +use serde::Deserialize; + +use super::sdk_version::SemVer; + +/// Built from `Cargo.toml` `[package].version`. +pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Repository homepage from `Cargo.toml` `[package].repository`. +pub const GITHUB_REPO_URL: &str = env!("CARGO_PKG_REPOSITORY"); + +/// Project site from `Cargo.toml` `[package].homepage`. +pub const HOMEPAGE_URL: &str = env!("CARGO_PKG_HOMEPAGE"); + +const RELEASES_URL: &str = "https://github.com/timeroute/wslc-desktop/releases"; +const LATEST_API: &str = + "https://api.github.com/repos/timeroute/wslc-desktop/releases/latest"; + +/// Result of comparing the running build to GitHub `releases/latest`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UpdateStatus { + /// Installed version is newest, or equal to the latest tag. + UpToDate { latest: String }, + /// A newer non-draft release exists. + Available { latest: String, url: String }, +} + +#[derive(Debug, Deserialize)] +struct LatestReleaseJson { + tag_name: String, + html_url: String, + #[serde(default)] + draft: bool, + #[serde(default)] + prerelease: bool, +} + +/// Strip a leading `v`/`V` so `v0.1.2` parses like `0.1.2`. +pub fn normalize_version_tag(tag: &str) -> &str { + let t = tag.trim(); + t.strip_prefix('v') + .or_else(|| t.strip_prefix('V')) + .unwrap_or(t) +} + +/// Compare `current` (e.g. `CARGO_PKG_VERSION`) to a release tag. +pub fn compare_to_tag(current: &str, tag: &str) -> Option { + let cur = SemVer::parse(normalize_version_tag(current))?; + let remote = SemVer::parse(normalize_version_tag(tag))?; + Some(cur.cmp(&remote)) +} + +/// Fetch GitHub `releases/latest` and compare to `current_version`. +pub fn check_latest(current_version: &str) -> Result { + let release = fetch_latest_release()?; + if release.draft || release.prerelease { + return Err("latest release is draft/prerelease".into()); + } + let latest = normalize_version_tag(&release.tag_name).to_string(); + let Some(order) = compare_to_tag(current_version, &release.tag_name) else { + return Err(format!("unparseable version tag: {}", release.tag_name)); + }; + match order { + std::cmp::Ordering::Less => Ok(UpdateStatus::Available { + latest, + url: release.html_url, + }), + std::cmp::Ordering::Equal | std::cmp::Ordering::Greater => { + Ok(UpdateStatus::UpToDate { latest }) + } + } +} + +fn fetch_latest_release() -> Result { + let resp = ureq::get(LATEST_API) + .set("User-Agent", "wslc-desktop") + .set("Accept", "application/vnd.github+json") + .call() + .map_err(|e| format!("GitHub API: {e}"))?; + resp.into_json::() + .map_err(|e| format!("GitHub API JSON: {e}")) +} + +/// Releases list page (fallback when a specific release URL is unavailable). +pub fn releases_url() -> &'static str { + RELEASES_URL +} + +/// Open an http(s) URL in the default browser. +pub fn open_url(url: &str) -> Result<(), String> { + if !(url.starts_with("https://") || url.starts_with("http://")) { + return Err("refusing to open non-http URL".into()); + } + #[cfg(windows)] + { + // `start` is a cmd builtin; empty title arg avoids swallowing the URL. + Command::new("cmd") + .args(["/C", "start", "", url]) + .spawn() + .map(|_| ()) + .map_err(|e| format!("open URL: {e}")) + } + #[cfg(not(windows))] + { + Command::new("xdg-open") + .arg(url) + .spawn() + .map(|_| ()) + .map_err(|e| format!("open URL: {e}")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_v_prefix() { + assert_eq!(normalize_version_tag("v0.1.2"), "0.1.2"); + assert_eq!(normalize_version_tag("V1.0.0"), "1.0.0"); + assert_eq!(normalize_version_tag("0.1.2"), "0.1.2"); + } + + #[test] + fn compares_release_tags() { + assert_eq!( + compare_to_tag("0.1.2", "v0.1.2"), + Some(std::cmp::Ordering::Equal) + ); + assert_eq!( + compare_to_tag("0.1.1", "v0.1.2"), + Some(std::cmp::Ordering::Less) + ); + assert_eq!( + compare_to_tag("0.2.0", "v0.1.9"), + Some(std::cmp::Ordering::Greater) + ); + assert!(compare_to_tag("0.1.2", "not-a-tag").is_none()); + } + + #[test] + fn package_metadata_urls_are_https() { + assert!(GITHUB_REPO_URL.starts_with("https://")); + assert!(HOMEPAGE_URL.starts_with("https://")); + assert!(!APP_VERSION.is_empty()); + } +} From d970f585a9d20f737a0d7d89d8e0b63a13773fea Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 18 Oct 2026 14:40:03 +0000 Subject: [PATCH 9/9] release: prepare 0.1.3 with i18n and shared UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fill remaining EN/ZH UI strings (badges, about, inspect/exec, units, partial volume-delete). Reuse settings cards, status badges, scroll log panes, and default modal width. Bump package to 0.1.3; tag later after testing. Co-authored-by: 绿岸公式 --- CHANGELOG.md | 8 ++++- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 8 ++--- src/app/close.rs | 1 - src/app/confirm.rs | 29 +++++++++++++---- src/app/containers.rs | 31 +++++++----------- src/app/images.rs | 51 ++++++++++------------------- src/app/overview.rs | 16 ++++----- src/app/refresh.rs | 22 +++---------- src/app/sdk_setup.rs | 27 +++------------- src/app/settings.rs | 75 +++++++++++++------------------------------ src/app/shell.rs | 20 ++++-------- src/app/volumes.rs | 7 ++-- src/app/widgets.rs | 55 +++++++++++++++++++++++++++++++ src/i18n.rs | 27 ++++++++++++++++ src/main.rs | 1 + 17 files changed, 198 insertions(+), 184 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6327cc0..9f8adee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ## [Unreleased] +## [0.1.3] - 2026-10-18 + ### 修复 - 创建卷遇到 `HRESULT=0x800700B7`(已存在)时给出中文说明,并把会话里已有、列表缺失的卷重新同步进列表。 @@ -31,6 +33,9 @@ - 移除未使用的 `session_stats` / `SessionStats` / `Cmd::Stats` 路径。 - Remove unused session-stats plumbing. +- 补齐界面文案国际化(引擎徽章、关于 / 更新检查、Inspect·Exec 标题、内存/卷容量单位、卷删除部分失败提示等);统一卡片 / 徽章 / 日志面板 / 模态框宽度等可复用样式。 + - Complete remaining UI i18n and share card / badge / log-pane / modal helpers across screens. + ## [0.1.2] - 2026-10-11 ### 修复 @@ -76,7 +81,8 @@ - 容器日志走 [`timeroute/wslc-rs`](https://github.com/timeroute/wslc-rs) 的 `LogsOptions`(`wslc --session wslc-desktop container logs`)。 - Container logs use the `wslc-rs` fork rather than a separate CLI path. -[Unreleased]: https://github.com/timeroute/wslc-desktop/compare/v0.1.2...HEAD +[Unreleased]: https://github.com/timeroute/wslc-desktop/compare/v0.1.3...HEAD +[0.1.3]: https://github.com/timeroute/wslc-desktop/compare/v0.1.2...v0.1.3 [0.1.2]: https://github.com/timeroute/wslc-desktop/compare/v0.1.1...v0.1.2 [0.1.1]: https://github.com/timeroute/wslc-desktop/compare/v0.1.0...v0.1.1 [0.1.0]: https://github.com/timeroute/wslc-desktop/releases/tag/v0.1.0 diff --git a/Cargo.lock b/Cargo.lock index 532b7f1..8792bdc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8484,7 +8484,7 @@ dependencies = [ [[package]] name = "wslc-desktop" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "gpui-kit", diff --git a/Cargo.toml b/Cargo.toml index 4024de7..cfc3c02 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "wslc-desktop" -version = "0.1.2" +version = "0.1.3" edition = "2024" description = "Lightweight desktop manager for Windows WSL Containers (WSLc)" license = "Apache-2.0" diff --git a/README.md b/README.md index c2965f5..74baea2 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ cargo test ```powershell cargo wix # 产物:target\wix\WSLc.Desktop--x86_64.msi -# 当前 0.1.2:WSLc.Desktop-0.1.2-x86_64.msi +# 当前 0.1.3:WSLc.Desktop-0.1.3-x86_64.msi ``` 可选:`cargo wix --no-build`(已有 release 产物时)、`cargo wix --sign`(需 `signtool`)。 @@ -64,9 +64,9 @@ cargo wix 打 `v*` tag 会触发 [Windows MSI](.github/workflows/windows-msi.yml) 工作流:构建 MSI 并创建 Release 挂载安装包。 ```powershell -# 版本与 Cargo.toml 对齐,例如 0.1.2 → v0.1.2 -git tag v0.1.2 -git push origin v0.1.2 +# 版本与 Cargo.toml 对齐,例如 0.1.3 → v0.1.3 +git tag v0.1.3 +git push origin v0.1.3 ``` 也可:push / PR 到 `main` 仅上传 artifact;或 Actions → **Windows MSI** → **Run workflow**。 diff --git a/src/app/close.rs b/src/app/close.rs index afc1b83..3564651 100644 --- a/src/app/close.rs +++ b/src/app/close.rs @@ -173,7 +173,6 @@ impl AppView { "close-prompt-modal", cx, modal_panel(cx) - .w(px(480.)) .child( h_flex() .items_center() diff --git a/src/app/confirm.rs b/src/app/confirm.rs index 73e2391..225291d 100644 --- a/src/app/confirm.rs +++ b/src/app/confirm.rs @@ -1,6 +1,6 @@ use gpui_kit::component::{ - ActiveTheme, Sizable as _, StyledExt as _, + ActiveTheme, IconName, Sizable as _, StyledExt as _, button::{Button, ButtonVariants as _}, checkbox::Checkbox, h_flex, v_flex, @@ -60,6 +60,7 @@ impl AppView { } => { let removed = self.t().removed; let removed_with_volumes = self.t().removed_container_with_volumes; + let volumes_partial = self.t().removed_volumes_partial; self.with_busy(cx, move |client| { client.remove_container(&key)?; if remove_volumes { @@ -78,10 +79,11 @@ impl AppView { named_volumes.join(", "), )) } else { - Err(anyhow::anyhow!( - "container removed; volume delete failed: {}", + Err(anyhow::anyhow!(i18n::fmt1( + volumes_partial, + "err", failed.join("; ") - )) + ))) } } else { Ok(i18n::fmt1(removed, "name", &key)) @@ -146,8 +148,23 @@ impl AppView { "confirm-remove-modal", cx, modal_panel(cx) - .w(px(460.)) - .child(div().font_semibold().text_base().child(title)) + .child( + h_flex() + .items_center() + .justify_between() + .gap_3() + .child(div().font_semibold().text_base().child(title)) + .child( + Button::new("confirm-dismiss") + .ghost() + .small() + .icon(IconName::Close) + .tooltip(t.close) + .on_click(cx.listener(|this, _, _, cx| { + this.dismiss_confirm(cx); + })), + ), + ) .child( div() .text_sm() diff --git a/src/app/containers.rs b/src/app/containers.rs index e7449fb..16f0a77 100644 --- a/src/app/containers.rs +++ b/src/app/containers.rs @@ -21,7 +21,8 @@ use super::parsing::{ }; use super::state::{ConfirmAction, PullPhase}; use super::widgets::{ - list_header, modal_panel, modal_scrim, resource_list, resource_row, row_actions, settings_card, + list_header, modal_panel, modal_scrim, resource_list, resource_row, row_actions, scroll_log_pane, + settings_card, }; impl AppView { @@ -362,7 +363,7 @@ impl AppView { return; }; let key = Self::container_key(item); - let title = format!("{} · inspect", key); + let title = i18n::fmt1(self.t().container_inspect_heading, "name", &key); let failed = self.t().container_inspect_failed; self.with_busy(cx, move |client| { let text = client @@ -413,7 +414,7 @@ impl AppView { return; } let command = self.exec_input.read(cx).value().to_string(); - let title = format!("{key} · exec"); + let title = i18n::fmt1(self.t().container_exec_heading, "name", &key); let failed = self.t().container_exec_failed; self.exec_modal_open = false; self.with_busy(cx, move |client| { @@ -1107,23 +1108,13 @@ impl AppView { ), ) .child( - div() - .id("container-logs-body") - .h(px(320.)) - .w_full() - .overflow_y_scroll() - .rounded(cx.theme().radius) - .border_1() - .border_color(cx.theme().border) - .bg(cx.theme().background.opacity(0.65)) - .p_2() - .child( - div() - .text_xs() - .font_family("monospace") - .text_color(cx.theme().foreground) - .child(body), - ), + scroll_log_pane("container-logs-body", 320., cx).child( + div() + .text_xs() + .font_family("monospace") + .text_color(cx.theme().foreground) + .child(body), + ), ) .child( h_flex().justify_end().child( diff --git a/src/app/images.rs b/src/app/images.rs index 61602f7..506cc16 100644 --- a/src/app/images.rs +++ b/src/app/images.rs @@ -22,7 +22,8 @@ use super::state::{ConfirmAction, PullPhase, MAX_PULL_LOG_LINES}; use super::AppView; use super::poll::recv_all; use super::widgets::{ - list_header, modal_panel, modal_scrim, resource_list, resource_row, row_actions, + list_header, modal_panel, modal_scrim, resource_list, resource_row, row_actions, scroll_log_pane, + status_badge, }; impl AppView { @@ -174,15 +175,7 @@ impl AppView { PullPhase::Success => cx.theme().green, PullPhase::Failed => cx.theme().red, }; - div() - .px_2() - .py_0p5() - .rounded(cx.theme().radius) - .bg(color.opacity(0.18)) - .text_color(color) - .text_xs() - .font_semibold() - .child(self.pull_phase.label(self.t())) + status_badge(self.pull_phase.label(self.t()), color, cx) } pub(crate) fn render_image_row( &self, @@ -341,30 +334,20 @@ impl AppView { ), ) .child( - div() - .id("pull-log") - .h(px(220.)) - .w_full() - .overflow_y_scroll() - .rounded(cx.theme().radius) - .border_1() - .border_color(cx.theme().border) - .bg(cx.theme().background.opacity(0.65)) - .p_2() - .child( - v_flex().gap_0p5().w_full().map(|list| { - if log_lines.is_empty() { - list.child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(t.pull_logs_placeholder), - ) - } else { - list.children(log_lines) - } - }), - ), + scroll_log_pane("pull-log", 220., cx).child( + v_flex().gap_0p5().w_full().map(|list| { + if log_lines.is_empty() { + list.child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(t.pull_logs_placeholder), + ) + } else { + list.children(log_lines) + } + }), + ), ) .when(can_close, |el| { el.child( diff --git a/src/app/overview.rs b/src/app/overview.rs index ba2a65f..88b4c66 100644 --- a/src/app/overview.rs +++ b/src/app/overview.rs @@ -7,9 +7,11 @@ use gpui_kit::component::{ }; use gpui_kit::*; +use crate::i18n; use crate::wslc::{APP_VERSION, BackendKind}; use super::state::Section; +use super::widgets::settings_card; use super::AppView; const SESSION_NAME: &str = "wslc-desktop"; @@ -95,7 +97,11 @@ impl AppView { if self.status.memory_mb == 0 { t.overview_na.to_string() } else { - format!("{} MiB", self.status.memory_mb) + i18n::fmt1( + t.memory_mib, + "mb", + self.status.memory_mb.to_string(), + ) }, None, cx, @@ -205,15 +211,9 @@ impl AppView { cx: &Context, child: impl IntoElement, ) -> impl IntoElement { - v_flex() + settings_card(cx) .flex_1() .min_w_0() - .gap_3() - .p_4() - .rounded(cx.theme().radius) - .border_1() - .border_color(cx.theme().border) - .bg(cx.theme().secondary.opacity(0.28)) .child(div().font_medium().text_sm().child(title)) .child(child) } diff --git a/src/app/refresh.rs b/src/app/refresh.rs index ccf9a7c..5f86815 100644 --- a/src/app/refresh.rs +++ b/src/app/refresh.rs @@ -1,7 +1,7 @@ use std::sync::mpsc; use std::time::Duration; -use gpui_kit::component::{ActiveTheme, Theme, ThemeMode, h_flex, label::Label, v_flex}; +use gpui_kit::component::{Theme, ThemeMode, label::Label}; use gpui_kit::*; use smol::Timer; @@ -11,7 +11,7 @@ use crate::wslc::{BackendKind, EngineStatus, WslcClient}; use super::AppView; use super::poll::recv_all; use super::state::PullPhase; -use super::widgets::modal_scrim; +use super::widgets::{modal_panel, modal_scrim}; impl AppView { pub(crate) fn apply_locale( @@ -339,22 +339,10 @@ impl AppView { modal_scrim( "busy-overlay", cx, - h_flex() + modal_panel(cx) + .w(px(280.)) .items_center() - .justify_center() - .px_6() - .py_4() - .rounded(cx.theme().radius_lg) - .border_1() - .border_color(cx.theme().border) - .bg(cx.theme().background) - .shadow_md() - .child( - v_flex() - .gap_1() - .items_center() - .child(Label::new(t.working).text_sm()), - ), + .child(Label::new(t.working).text_sm()), ) } } diff --git a/src/app/sdk_setup.rs b/src/app/sdk_setup.rs index ad5e576..a92761d 100644 --- a/src/app/sdk_setup.rs +++ b/src/app/sdk_setup.rs @@ -19,7 +19,7 @@ use crate::wslc::{ use super::AppView; use super::poll::recv_all; use super::state::{MAX_SDK_LOG_LINES, SdkSetupPhase}; -use super::widgets::{modal_panel, modal_scrim}; +use super::widgets::{modal_panel, modal_scrim, scroll_log_pane, status_badge}; impl AppView { pub(crate) fn append_sdk_log(&mut self, line: impl Into) { @@ -301,17 +301,7 @@ impl AppView { .child(index.to_string()), ) .child(div().flex_1().text_sm().font_medium().child(title)) - .child( - div() - .px_2() - .py_0p5() - .rounded(cx.theme().radius) - .bg(color.opacity(0.18)) - .text_color(color) - .text_xs() - .font_semibold() - .child(badge), - ), + .child(status_badge(badge, color, cx)), ) .when_some(detail, |row, (text, color)| { row.child(div().pl_6().text_xs().text_color(color).child(text)) @@ -435,17 +425,8 @@ impl AppView { .when(logs_open, |panel| { panel .child( - div() - .id("sdk-setup-logs") - .h(px(220.)) - .w_full() - .overflow_y_scroll() - .rounded(cx.theme().radius) - .border_1() - .border_color(cx.theme().border) - .bg(cx.theme().background.opacity(0.65)) - .p_2() - .child(v_flex().gap_0p5().w_full().map(|list| { + scroll_log_pane("sdk-setup-logs", 220., cx).child( + v_flex().gap_0p5().w_full().map(|list| { if logs.is_empty() { list.child( div() diff --git a/src/app/settings.rs b/src/app/settings.rs index c26b374..07fb514 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -2,7 +2,7 @@ use std::sync::mpsc; use std::time::Duration; use gpui_kit::component::{ - ActiveTheme, Disableable as _, Sizable as _, StyledExt as _, ThemeMode, + ActiveTheme, Disableable as _, Sizable as _, ThemeMode, button::{Button, ButtonVariants as _}, h_flex, v_flex, }; @@ -17,7 +17,7 @@ use crate::wslc::{ }; use super::state::UpdateCheckPhase; -use super::widgets::settings_card; +use super::widgets::settings_card_section; use super::AppView; const CPU_CHOICES: [u32; 5] = [1, 2, 4, 6, 8]; @@ -99,7 +99,7 @@ impl AppView { Err(mpsc::TryRecvError::Empty) => true, Err(mpsc::TryRecvError::Disconnected) => { this.update_check = UpdateCheckPhase::Failed { - message: "channel disconnected".into(), + message: this.t().op_channel_disconnected.to_string(), }; cx.notify(); false @@ -168,15 +168,7 @@ impl AppView { .child(t.settings_intro), ) .child( - settings_card(cx) - .child(div().font_medium().text_sm().child(t.language)) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(t.language_hint), - ) - .child( + settings_card_section(t.language, t.language_hint, cx).child( h_flex() .gap_2() .child( @@ -200,15 +192,7 @@ impl AppView { ), ) .child( - settings_card(cx) - .child(div().font_medium().text_sm().child(t.theme)) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(t.theme_hint), - ) - .child( + settings_card_section(t.theme, t.theme_hint, cx).child( h_flex() .gap_2() .child( @@ -232,15 +216,7 @@ impl AppView { ), ) .child( - settings_card(cx) - .child(div().font_medium().text_sm().child(t.settings_close)) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(t.settings_close_hint), - ) - .child( + settings_card_section(t.settings_close, t.settings_close_hint, cx).child( h_flex() .gap_2() .flex_wrap() @@ -289,14 +265,7 @@ impl AppView { ), ) .child( - settings_card(cx) - .child(div().font_medium().text_sm().child(t.settings_session)) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(t.settings_session_hint), - ) + settings_card_section(t.settings_session, t.settings_session_hint, cx) .child( div() .text_xs() @@ -327,7 +296,11 @@ impl AppView { |mb| { Button::new(("settings-mem", mb as usize)) .small() - .label(format!("{} MiB", mb)) + .label(i18n::fmt1( + t.memory_mib, + "mb", + mb.to_string(), + )) .when(memory == mb, |b| b.primary()) .on_click(cx.listener(move |this, _, _, cx| { this.session_prefs.memory_mb = mb; @@ -341,23 +314,21 @@ impl AppView { div() .text_xs() .text_color(cx.theme().muted_foreground) - .child(format!( - "{} · {} CPU · {} MiB", - SessionPrefs::path().display(), - self.session_prefs.cpu_count, - self.session_prefs.memory_mb + .child(i18n::fmt2( + &i18n::fmt1( + t.settings_session_summary, + "path", + SessionPrefs::path().display().to_string(), + ), + "cpu", + self.session_prefs.cpu_count.to_string(), + "memory", + self.session_prefs.memory_mb.to_string(), )), ), ) .child( - settings_card(cx) - .child(div().font_medium().text_sm().child(t.settings_about)) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(t.settings_about_hint), - ) + settings_card_section(t.settings_about, t.settings_about_hint, cx) .child( div().text_sm().child(format!( "{} {}", diff --git a/src/app/shell.rs b/src/app/shell.rs index 8a4aa8b..7472e7c 100644 --- a/src/app/shell.rs +++ b/src/app/shell.rs @@ -13,29 +13,21 @@ use crate::i18n::{self}; use crate::wslc::BackendKind; use super::state::Section; +use super::widgets::status_badge; use super::AppView; impl AppView { pub(crate) fn render_engine_badge(&self, cx: &Context) -> impl IntoElement { + let t = self.t(); let (label, color) = match self.status.backend { - BackendKind::Live => ("LIVE", cx.theme().green), - BackendKind::Unavailable => ("OFF", cx.theme().red), + BackendKind::Live => (t.engine_badge_live, cx.theme().green), + BackendKind::Unavailable => (t.engine_badge_off, cx.theme().red), }; h_flex() .items_center() .gap_2() - .child( - div() - .px_2() - .py_0p5() - .rounded(cx.theme().radius) - .bg(color.opacity(0.18)) - .text_color(color) - .text_xs() - .font_semibold() - .child(label), - ) + .child(status_badge(label, color, cx)) .child( div() .text_xs() @@ -70,7 +62,7 @@ impl AppView { div() .font_semibold() .text_sm() - .child("WSLc Desktop"), + .child(t.app_name), ) .child( div() diff --git a/src/app/volumes.rs b/src/app/volumes.rs index 69f819f..6cf6f09 100644 --- a/src/app/volumes.rs +++ b/src/app/volumes.rs @@ -155,7 +155,6 @@ impl AppView { "create-volume-modal", cx, modal_panel(cx) - .w(px(480.)) .child(div().font_semibold().text_base().child(t.create_volume_title)) .child( div() @@ -177,7 +176,11 @@ impl AppView { h_flex().gap_2().children(VOLUME_SIZES_GIB.into_iter().map(|gib| { Button::new(("volume-modal-size", gib as usize)) .small() - .label(format!("{gib} GiB")) + .label(crate::i18n::fmt1( + t.volume_size_gib, + "gib", + gib.to_string(), + )) .when(size_gib == gib, |b| b.primary()) .disabled(creating) .on_click(cx.listener(move |this, _, _, cx| { diff --git a/src/app/widgets.rs b/src/app/widgets.rs index 47d1033..656b0aa 100644 --- a/src/app/widgets.rs +++ b/src/app/widgets.rs @@ -3,6 +3,9 @@ use gpui_kit::*; use super::AppView; +/// Shared width for confirm / close / most modal panels. +pub(crate) const MODAL_WIDTH: f32 = 480.; + impl AppView { pub(crate) fn empty_state( &self, @@ -123,6 +126,7 @@ pub(crate) fn modal_panel(cx: &Context) -> Div { .m_6() .p_5() .gap_3() + .w(px(MODAL_WIDTH)) .rounded(cx.theme().radius_lg) .border_1() .border_color(cx.theme().border) @@ -130,6 +134,41 @@ pub(crate) fn modal_panel(cx: &Context) -> Div { .shadow_md() } +/// Compact status / phase pill (engine LIVE, pull phase, setup step). +pub(crate) fn status_badge( + label: impl Into, + color: Hsla, + cx: &Context, +) -> impl IntoElement { + div() + .px_2() + .py_0p5() + .rounded(cx.theme().radius) + .bg(color.opacity(0.18)) + .text_color(color) + .text_xs() + .font_semibold() + .child(label.into()) +} + +/// Bordered scrollable log body used by pull / setup / container logs modals. +pub(crate) fn scroll_log_pane( + id: impl Into, + height: f32, + cx: &Context, +) -> Stateful
{ + div() + .id(id) + .h(px(height)) + .w_full() + .overflow_y_scroll() + .rounded(cx.theme().radius) + .border_1() + .border_color(cx.theme().border) + .bg(cx.theme().background.opacity(0.65)) + .p_2() +} + /// Settings / overview card surface. pub(crate) fn settings_card(cx: &Context) -> Div { v_flex() @@ -140,3 +179,19 @@ pub(crate) fn settings_card(cx: &Context) -> Div { .border_color(cx.theme().border) .bg(cx.theme().secondary.opacity(0.28)) } + +/// Settings card with shared title + muted hint header. +pub(crate) fn settings_card_section( + title: impl Into, + hint: impl Into, + cx: &Context, +) -> Div { + settings_card(cx) + .child(div().font_medium().text_sm().child(title.into())) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(hint.into()), + ) +} diff --git a/src/i18n.rs b/src/i18n.rs index e803611..118a2e7 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -39,6 +39,7 @@ impl Locale { } pub struct Strings { + pub app_name: &'static str, pub app_subtitle: &'static str, pub browse: &'static str, pub overview: &'static str, @@ -62,7 +63,11 @@ pub struct Strings { pub overview_volumes: &'static str, pub overview_status_live: &'static str, pub overview_status_off: &'static str, + pub engine_badge_live: &'static str, + pub engine_badge_off: &'static str, pub overview_na: &'static str, + pub memory_mib: &'static str, + pub volume_size_gib: &'static str, pub overview_persist_hint: &'static str, pub refresh: &'static str, @@ -196,7 +201,9 @@ pub struct Strings { pub container_logs_title: &'static str, pub container_inspect_title: &'static str, + pub container_inspect_heading: &'static str, pub container_exec_title: &'static str, + pub container_exec_heading: &'static str, pub container_exec_intro: &'static str, pub container_exec_placeholder: &'static str, pub container_exec_run: &'static str, @@ -220,6 +227,7 @@ pub struct Strings { pub settings_session_hint: &'static str, pub settings_cpu: &'static str, pub settings_memory: &'static str, + pub settings_session_summary: &'static str, pub settings_prefs_saved: &'static str, pub settings_close: &'static str, pub settings_close_hint: &'static str, @@ -269,6 +277,7 @@ pub struct Strings { pub restarted: &'static str, pub removed: &'static str, pub removed_container_with_volumes: &'static str, + pub removed_volumes_partial: &'static str, pub created_container: &'static str, pub created_volume: &'static str, pub pulling_image: &'static str, @@ -290,6 +299,7 @@ pub struct Strings { } pub static EN: Strings = Strings { + app_name: "WSLc Desktop", app_subtitle: "WSL Containers", browse: "Browse", overview: "Overview", @@ -313,7 +323,11 @@ pub static EN: Strings = Strings { overview_volumes: "Volumes", overview_status_live: "Connected (LIVE)", overview_status_off: "Unavailable (OFF)", + engine_badge_live: "LIVE", + engine_badge_off: "OFF", overview_na: "—", + memory_mib: "{mb} MiB", + volume_size_gib: "{gib} GiB", overview_persist_hint: "Closing the window can minimize to the tray (containers keep running) or quit and stop running containers. Named volumes are never deleted unless you confirm them separately.", overview_cpu: "CPU", overview_memory: "Memory", @@ -449,7 +463,9 @@ pub static EN: Strings = Strings { container_logs_title: "Container logs", container_inspect_title: "Container inspect", + container_inspect_heading: "{name} · Inspect", container_exec_title: "Exec", + container_exec_heading: "{name} · Exec", container_exec_intro: "Run a one-shot command (non-interactive). Example: uname -a", container_exec_placeholder: "uname -a", container_exec_run: "Run", @@ -473,6 +489,7 @@ pub static EN: Strings = Strings { settings_session_hint: "CPU and memory for the wslc-desktop session. Saved immediately; applied the next time a new session is created (existing sessions keep their original limits).", settings_cpu: "CPU count", settings_memory: "Memory (MiB)", + settings_session_summary: "{path} · {cpu} CPU · {memory} MiB", settings_prefs_saved: "Session preferences saved", settings_close: "Close window", settings_close_hint: "Choose what the window close button does. “Ask each time” restores the first-run prompt.", @@ -514,6 +531,7 @@ pub static EN: Strings = Strings { restarted: "Restarted {name}", removed: "Removed {name}", removed_container_with_volumes: "Removed {name} and volumes {volumes}", + removed_volumes_partial: "Container removed; volume delete failed: {err}", created_container: "Created {name}", created_volume: "Created volume {name}", pulling_image: "Pulling {name}…", @@ -535,6 +553,7 @@ pub static EN: Strings = Strings { }; pub static ZH: Strings = Strings { + app_name: "WSLc Desktop", app_subtitle: "WSL 容器", browse: "浏览", overview: "总览", @@ -558,7 +577,11 @@ pub static ZH: Strings = Strings { overview_volumes: "卷", overview_status_live: "已连接(LIVE)", overview_status_off: "不可用(OFF)", + engine_badge_live: "LIVE", + engine_badge_off: "OFF", overview_na: "—", + memory_mib: "{mb} MiB", + volume_size_gib: "{gib} GiB", overview_persist_hint: "关闭窗口时可最小化到托盘(容器继续运行),或退出并停止运行中的容器。命名卷不会被顺手删掉,除非你在删除容器时单独确认。", overview_cpu: "CPU", overview_memory: "内存", @@ -694,7 +717,9 @@ pub static ZH: Strings = Strings { container_logs_title: "容器日志", container_inspect_title: "容器检查", + container_inspect_heading: "{name} · 检查", container_exec_title: "执行命令", + container_exec_heading: "{name} · 执行", container_exec_intro: "运行一次性命令(非交互)。例如:uname -a", container_exec_placeholder: "uname -a", container_exec_run: "运行", @@ -718,6 +743,7 @@ pub static ZH: Strings = Strings { settings_session_hint: "wslc-desktop 会话的 CPU 与内存。立即保存;仅在新建会话时生效(已有会话仍沿用原限额)。", settings_cpu: "CPU 数量", settings_memory: "内存(MiB)", + settings_session_summary: "{path} · {cpu} CPU · {memory} MiB", settings_prefs_saved: "会话偏好已保存", settings_close: "关闭窗口", settings_close_hint: "设置点关闭按钮时的行为。「每次询问」会恢复首次关闭时的提示。", @@ -759,6 +785,7 @@ pub static ZH: Strings = Strings { restarted: "已重启 {name}", removed: "已删除 {name}", removed_container_with_volumes: "已删除 {name} 及卷 {volumes}", + removed_volumes_partial: "容器已删除,但删除卷失败:{err}", created_container: "已创建 {name}", created_volume: "已创建卷 {name}", pulling_image: "正在拉取 {name}…", diff --git a/src/main.rs b/src/main.rs index dfd2dcb..cd7c50a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -73,6 +73,7 @@ fn main() { let (window_handle, view) = gpui_kit::open_window(window_options, cx, |window, cx| { window.activate_window(); + // Title must stay stable: second-instance activation finds this window by title. window.set_window_title("WSLc Desktop"); let view = cx.new(|cx| AppView::new(window, cx)); let weak = view.downgrade();