fix(desktop): show update check status

This commit is contained in:
Kayshen-X 2026-07-30 02:14:54 +08:00
parent 9a09d3cdbe
commit 76af87930f
7 changed files with 255 additions and 29 deletions

View file

@ -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:?}"
);
}
}

View file

@ -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(&current_version, 10.0);
let version = TextLayout::single_run(
&current_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.

View file

@ -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 {

View file

@ -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");

View file

@ -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)]

View file

@ -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();

View file

@ -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`).