From 76af87930f71121c663bd7290ef123b7fb8a3f1d Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Thu, 30 Jul 2026 02:14:54 +0800 Subject: [PATCH] fix(desktop): show update check status --- .../agent_settings_panel_tests/system_tab.rs | 45 +++++- .../src/widgets/agent_settings_system.rs | 132 ++++++++++++++++-- crates/op-host-desktop/src/app_state.rs | 4 + crates/op-host-desktop/src/menu_action.rs | 22 +-- crates/op-host-desktop/src/update_check.rs | 12 +- .../src/widget_host/shortcut_surface_tests.rs | 31 +++- .../src/widget_host/shortcuts.rs | 38 ++++- 7 files changed, 255 insertions(+), 29 deletions(-) diff --git a/crates/op-editor-ui/src/widgets/agent_settings_panel_tests/system_tab.rs b/crates/op-editor-ui/src/widgets/agent_settings_panel_tests/system_tab.rs index 374e5dd28..a0a21fa0f 100644 --- a/crates/op-editor-ui/src/widgets/agent_settings_panel_tests/system_tab.rs +++ b/crates/op-editor-ui/src/widgets/agent_settings_panel_tests/system_tab.rs @@ -24,14 +24,53 @@ fn system_auto_update_switch_has_click_target() { } #[test] -fn system_tab_uses_ts_compact_auto_update_card_height() { +fn system_tab_reserves_space_for_update_status() { let mut state = EditorState::default(); state.editor_ui.agent_settings.tab = AgentSettingsTab::System; let panel = AgentSettingsPanel::for_editor(&state); assert_eq!( panel.content_total_height(), - 320.0, - "System tab = title + auto-update + experimental + pencil-cursor picker" + 378.0, + "System tab = title + update status + experimental + pencil-cursor picker" ); } + +#[test] +fn system_tab_paints_each_update_probe_result() { + let cases = [ + (op_editor_core::UpdateStatus::Idle, "Not checked yet"), + (op_editor_core::UpdateStatus::Checking, "Checking…"), + (op_editor_core::UpdateStatus::UpToDate, "Up to date"), + ( + op_editor_core::UpdateStatus::Available { + version: "9.8.7".to_string(), + }, + "Update available v9.8.7", + ), + (op_editor_core::UpdateStatus::Error, "Check failed"), + ]; + + for (status, expected) in cases { + let mut state = EditorState::default(); + state.editor_ui.locale = op_editor_core::Locale::EnUs; + state.editor_ui.agent_settings.tab = AgentSettingsTab::System; + state.editor_ui.update_status = status; + let panel = AgentSettingsPanel::for_editor(&state); + let rect = panel.rect(1200.0, 800.0); + let mut backend = CaptureBackend::default(); + let mut cx = PaintCx { + backend: &mut backend, + }; + + panel.paint(&mut cx, rect); + + assert!( + backend + .text_effective_points + .iter() + .any(|(text, _)| text == expected), + "System tab should paint update status {expected:?}" + ); + } +} diff --git a/crates/op-editor-ui/src/widgets/agent_settings_system.rs b/crates/op-editor-ui/src/widgets/agent_settings_system.rs index 84ad3eb14..d2dd4d4e0 100644 --- a/crates/op-editor-ui/src/widgets/agent_settings_system.rs +++ b/crates/op-editor-ui/src/widgets/agent_settings_system.rs @@ -1,7 +1,7 @@ //! System tab of the settings modal. //! -//! Renders the auto-update preference row plus the experimental-features -//! opt-in (gates canvas Preview mode + the property-panel Widget section). +//! Renders the auto-update preference and live release-probe status plus the +//! experimental-features opt-in and pencil-cursor picker. use crate::theme::Theme; use crate::widgets::agent_settings_i18n::t as t_settings; @@ -10,14 +10,16 @@ use crate::widgets::agent_settings_switch::{ }; use crate::widgets::button::tokens_from_theme; use crate::widgets::PaintCx; -use crate::{Point2D, Rect, TextLayout}; +use crate::{Color, Point2D, Rect, TextLayout}; use jian_widgets::components::card::Card; use op_editor_core::agent_settings::AgentSettings; -use op_editor_core::editor_ui_state::EditorUiState; +use op_editor_core::editor_ui_state::{EditorUiState, UpdateStatus}; const TITLE_H: f32 = 36.0; -const CARD_H: f32 = 58.0; +const TOGGLE_CARD_H: f32 = 58.0; +const UPDATE_CARD_H: f32 = 116.0; const CARD_GAP: f32 = 12.0; +const STATUS_DOT_RADIUS: f32 = 4.0; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SystemHit { @@ -34,13 +36,18 @@ const CURSOR_SWATCH: f32 = 52.0; const CURSOR_SWATCH_GAP: f32 = 10.0; pub(super) fn content_height() -> f32 { - 12.0 + TITLE_H + CARD_H + CARD_GAP + CARD_H + CARD_GAP + CURSOR_CARD_H + 24.0 + let update = if AUTO_UPDATE_AVAILABLE { + UPDATE_CARD_H + CARD_GAP + } else { + 0.0 + }; + 12.0 + TITLE_H + update + TOGGLE_CARD_H + CARD_GAP + CURSOR_CARD_H + 24.0 } fn cursor_card_rect(content: Rect) -> Rect { let prev = experimental_card_rect(content); Rect { - origin: Point2D::new(content.origin.x, prev.origin.y + CARD_H + CARD_GAP), + origin: Point2D::new(content.origin.x, prev.origin.y + TOGGLE_CARD_H + CARD_GAP), size: Point2D::new(content.size.x, CURSOR_CARD_H), } } @@ -60,7 +67,7 @@ fn cursor_swatch_rect(card: Rect, i: usize) -> Rect { fn auto_update_card_rect(content: Rect) -> Rect { Rect { origin: Point2D::new(content.origin.x, content.origin.y + 12.0 + TITLE_H), - size: Point2D::new(content.size.x, CARD_H), + size: Point2D::new(content.size.x, UPDATE_CARD_H), } } @@ -79,9 +86,9 @@ fn experimental_card_rect(content: Rect) -> Rect { Rect { origin: Point2D::new( content.origin.x, - content.origin.y + 12.0 + TITLE_H + CARD_H + CARD_GAP, + content.origin.y + 12.0 + TITLE_H + UPDATE_CARD_H + CARD_GAP, ), - size: Point2D::new(content.size.x, CARD_H), + size: Point2D::new(content.size.x, TOGGLE_CARD_H), } } @@ -91,7 +98,7 @@ fn switch_rect_for(card: Rect) -> Rect { Rect { origin: Point2D::new( card.origin.x + card.size.x - 16.0 - SETTINGS_SWITCH_W, - card.origin.y + (CARD_H - SETTINGS_SWITCH_H) / 2.0, + card.origin.y + (TOGGLE_CARD_H - SETTINGS_SWITCH_H) / 2.0, ), size: Point2D::new(SETTINGS_SWITCH_W, SETTINGS_SWITCH_H), } @@ -143,6 +150,7 @@ pub(super) fn paint_system_tab( "settings.autoUpdateDesc", settings.auto_update_enabled, ); + paint_update_status(cx, theme, ui, auto_update_card_rect(content)); } paint_toggle_card( cx, @@ -156,6 +164,108 @@ pub(super) fn paint_system_tab( paint_cursor_card(cx, theme, ui, cursor_card_rect(content)); } +/// Lower half of the auto-update card: current probe state and build version. +fn paint_update_status(cx: &mut PaintCx<'_>, theme: &Theme, ui: &EditorUiState, card: Rect) { + let (color, status_key, description_key) = status_view(theme, &ui.update_status); + let divider_y = card.origin.y + TOGGLE_CARD_H; + cx.backend.stroke_line( + Point2D::new(card.origin.x + 16.0, divider_y), + Point2D::new(card.origin.x + card.size.x - 16.0, divider_y), + theme.border, + 1.0, + ); + + let dot_center_y = divider_y + 20.0; + cx.backend.fill_oval( + Rect { + origin: Point2D::new(card.origin.x + 16.0, dot_center_y - STATUS_DOT_RADIUS), + size: Point2D::new(STATUS_DOT_RADIUS * 2.0, STATUS_DOT_RADIUS * 2.0), + }, + color, + ); + + let status_text = match &ui.update_status { + UpdateStatus::Available { version } => { + format!("{} v{version}", t_settings(ui, status_key)) + } + _ => t_settings(ui, status_key).to_string(), + }; + let status = TextLayout::single_run( + &status_text, + "system-ui", + 12.0, + color.to_jian(), + Point2D::new(0.0, 0.0), + ); + cx.backend.draw_text( + &status, + Point2D::new(card.origin.x + 32.0, divider_y + 24.0), + ); + + let current_version = format!( + "{}: v{}", + t_settings(ui, "settings.system.currentVersion"), + env!("CARGO_PKG_VERSION") + ); + let current_version_w = cx.backend.measure_text(¤t_version, 10.0); + let version = TextLayout::single_run( + ¤t_version, + "system-ui", + 10.0, + theme.muted_foreground.to_jian(), + Point2D::new(0.0, 0.0), + ); + cx.backend.draw_text( + &version, + Point2D::new( + card.origin.x + card.size.x - 16.0 - current_version_w, + divider_y + 24.0, + ), + ); + + let description = TextLayout::single_run( + t_settings(ui, description_key), + "system-ui", + 11.0, + theme.muted_foreground.to_jian(), + Point2D::new(0.0, 0.0), + ); + cx.backend.draw_text( + &description, + Point2D::new(card.origin.x + 16.0, divider_y + 46.0), + ); +} + +fn status_view(theme: &Theme, status: &UpdateStatus) -> (Color, &'static str, &'static str) { + match status { + UpdateStatus::Idle => ( + theme.muted_foreground, + "settings.system.idle", + "settings.system.idleDescription", + ), + UpdateStatus::Checking => ( + theme.primary, + "settings.system.checking", + "settings.system.checkingDescription", + ), + UpdateStatus::UpToDate => ( + theme.status_success, + "settings.system.upToDate", + "settings.system.upToDateDescription", + ), + UpdateStatus::Available { .. } => ( + theme.status_warning, + "settings.system.updateAvailable", + "settings.system.updateAvailableDescription", + ), + UpdateStatus::Error => ( + theme.destructive, + "settings.system.errorStatus", + "settings.system.errorDescription", + ), + } +} + /// The pencil-cursor picker: a labelled card with one swatch per style, /// each drawing its real silhouette at half scale; the active style gets /// an accent ring. diff --git a/crates/op-host-desktop/src/app_state.rs b/crates/op-host-desktop/src/app_state.rs index 97ecd459a..1dc3d26ae 100644 --- a/crates/op-host-desktop/src/app_state.rs +++ b/crates/op-host-desktop/src/app_state.rs @@ -71,6 +71,10 @@ impl DesktopApp { .auto_update_enabled, ) }; + if update_probe.is_pending() { + host.editor_state_mut().editor_ui.update_status = + op_editor_core::UpdateStatus::Checking; + } let model_probe = if cfg!(test) { op_host_services::model_discovery::ModelProbe::idle() } else { diff --git a/crates/op-host-desktop/src/menu_action.rs b/crates/op-host-desktop/src/menu_action.rs index a897813d2..5d5660993 100644 --- a/crates/op-host-desktop/src/menu_action.rs +++ b/crates/op-host-desktop/src/menu_action.rs @@ -244,20 +244,20 @@ impl DesktopApp { false } A::CheckUpdates => { - // Re-run the probe; the System tab reflects `Checking` - // immediately and the result lands on a later frame. - // Skip when a probe is already in flight so repeated - // menu clicks can't stack untracked worker threads. - if self.update_probe.is_pending() { - false - } else { - self.host.editor_state_mut().editor_ui.update_status = - op_editor_core::UpdateStatus::Checking; - self.host.mark_editor_state_dirty(); + // Make every manual check visible immediately. An automatic + // startup probe may already be running, so reveal that same + // worker instead of stacking another request. + self.host.apply_open_agent_settings_tab( + op_editor_core::agent_settings::AgentSettingsTab::System, + ); + self.host.editor_state_mut().editor_ui.update_status = + op_editor_core::UpdateStatus::Checking; + self.host.mark_editor_state_dirty(); + if !self.update_probe.is_pending() { self.update_probe = update_check::UpdateProbe::spawn(); self.update_prompt_shown = false; - true } + true } A::OpenGithub => { update_check::open_url("https://github.com/ZSeven-W/openpencil"); diff --git a/crates/op-host-desktop/src/update_check.rs b/crates/op-host-desktop/src/update_check.rs index ffc0bec2f..27e0c8cf6 100644 --- a/crates/op-host-desktop/src/update_check.rs +++ b/crates/op-host-desktop/src/update_check.rs @@ -95,7 +95,7 @@ impl UpdateProbe { Err(TryRecvError::Empty) => None, Err(TryRecvError::Disconnected) => { self.rx = None; - None + Some(UpdateStatus::Error) } } } @@ -422,6 +422,16 @@ mod tests { assert!(!probe.is_pending()); } + + #[test] + fn disconnected_worker_resolves_as_error_instead_of_staying_checking() { + let (tx, rx) = mpsc::channel(); + drop(tx); + let mut probe = UpdateProbe { rx: Some(rx) }; + + assert_eq!(probe.poll(), Some(UpdateStatus::Error)); + assert!(!probe.is_pending()); + } } #[cfg(test)] diff --git a/crates/op-host-native/src/widget_host/shortcut_surface_tests.rs b/crates/op-host-native/src/widget_host/shortcut_surface_tests.rs index c0e9c78e1..a22989412 100644 --- a/crates/op-host-native/src/widget_host/shortcut_surface_tests.rs +++ b/crates/op-host-native/src/widget_host/shortcut_surface_tests.rs @@ -3,8 +3,8 @@ use super::WidgetHostNative; use op_editor_core::{ - agent_settings::SettingsFocus, figma_import_state::ImportSource, ui_draft::ColorTarget, NodeId, - PropertyFocus, + agent_settings::SettingsFocus, figma_import_state::ImportSource, ui_draft::ColorTarget, + AgentSettingsTab, NodeId, PropertyFocus, }; fn seed(host: &mut WidgetHostNative, json: &str) { @@ -101,6 +101,33 @@ fn closing_settings_clears_focus_before_an_import_shortcut() { assert!(host.editor_state().editor_ui.figma_import_open); } +#[test] +fn opening_a_specific_settings_tab_reveals_it_and_commits_prior_input() { + let mut host = WidgetHostNative::new(); + { + let ui = &mut host.editor_state_mut().editor_ui; + ui.agent_settings.tab = AgentSettingsTab::Mcp; + ui.agent_settings.scroll_y.offset = 84.0; + ui.agent_settings.focus = Some(SettingsFocus::McpPort); + ui.settings_input.set_text("4321"); + ui.design_md_panel.open = true; + ui.component_browser_open = true; + ui.open_icon_picker(false); + } + + assert!(host.apply_open_agent_settings_tab(AgentSettingsTab::System)); + + let ui = &host.editor_state().editor_ui; + assert!(ui.agent_settings_open); + assert_eq!(ui.agent_settings.tab, AgentSettingsTab::System); + assert_eq!(ui.agent_settings.scroll_y.offset, 0.0); + assert!(ui.agent_settings.focus.is_none()); + assert_eq!(ui.agent_settings.mcp_server.port, 4321); + assert!(!ui.design_md_panel.open); + assert!(!ui.component_browser_open); + assert!(!ui.icon_picker.open); +} + #[test] fn import_shortcut_blurs_covered_text_and_ime_owners() { let mut host = WidgetHostNative::new(); diff --git a/crates/op-host-native/src/widget_host/shortcuts.rs b/crates/op-host-native/src/widget_host/shortcuts.rs index 765ed62b9..729e9cf2b 100644 --- a/crates/op-host-native/src/widget_host/shortcuts.rs +++ b/crates/op-host-native/src/widget_host/shortcuts.rs @@ -3,7 +3,7 @@ use super::WidgetHostNative; use op_editor_core::host_keyboard_transitions as shared; -use op_editor_core::{figma_import_state::ImportSource, ReorderDirection}; +use op_editor_core::{figma_import_state::ImportSource, AgentSettingsTab, ReorderDirection}; impl WidgetHostNative { /// True when a non-chat text surface owns keyboard input. Plain-string @@ -541,6 +541,42 @@ impl WidgetHostNative { true } + /// Open the floating settings modal on a specific tab. + /// + /// Native menu commands use this instead of toggling so a command can + /// reveal its live status even when Settings is already open elsewhere. + pub fn apply_open_agent_settings_tab(&mut self, tab: AgentSettingsTab) -> bool { + self.commit_variable_row_focus_if_any(); + self.editor_state.editor_ui.blur_collab_join_input(); + self.commit_settings_focus_if_any(); + shared::commit_editing_for_modal(&mut self.editor_state); + self.design_md_drag = None; + self.component_browser_drag = None; + self.icon_picker_drag = None; + { + let ui = &mut self.editor_state.editor_ui; + ui.close_font_picker(); + ui.close_icon_picker(); + ui.design_md_panel.open = false; + ui.design_md_panel.hover = None; + ui.component_browser_open = false; + ui.component_browser_kit_picker_open = false; + ui.component_browser_confirm_delete_kit = None; + ui.component_browser_hover = None; + ui.layer_context_menu = None; + ui.agent_settings_open = true; + ui.agent_settings.tab = tab; + ui.agent_settings.scroll_y.offset = 0.0; + ui.agent_settings_drag = None; + ui.ime_preedit = None; + } + self.editor_state.ui.path_anchor_menu = None; + self.close_image_popovers_for_higher_overlay(); + self.editor_state.chat.blur_input(self.now_ms); + self.mark_dirty(); + true + } + /// Single-key tool switch (V / R / O / L / T / F / P / H). Also /// DISCARDS any in-flight pen path (TS `onToolChange` resets the /// pen preview without committing, `skia-pen-tool.ts:38-50`).