perf: avoid full pane renders for spinner animation (#1868)
This commit is contained in:
parent
da40b1aa34
commit
b01fc37e66
|
|
@ -444,6 +444,19 @@ pub struct CellData {
|
|||
pub hyperlink: Option<u32>,
|
||||
}
|
||||
|
||||
impl CellData {
|
||||
pub(crate) fn from_ratatui_cell(cell: &ratatui::buffer::Cell) -> Self {
|
||||
Self {
|
||||
symbol: cell.symbol().to_owned(),
|
||||
fg: color_to_u32(cell.fg),
|
||||
bg: color_to_u32(cell.bg),
|
||||
modifier: modifier_to_u16(cell.modifier),
|
||||
skip: cell.skip,
|
||||
hyperlink: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cursor shape encoded as a DECSCUSR parameter.
|
||||
///
|
||||
/// 0 = terminal default, 1 = blinking block, 2 = steady block,
|
||||
|
|
@ -527,14 +540,9 @@ impl FrameData {
|
|||
index
|
||||
}))
|
||||
});
|
||||
cells.push(CellData {
|
||||
symbol: cell.symbol().to_owned(),
|
||||
fg: color_to_u32(cell.fg),
|
||||
bg: color_to_u32(cell.bg),
|
||||
modifier: modifier_to_u16(cell.modifier),
|
||||
skip: cell.skip,
|
||||
hyperlink,
|
||||
});
|
||||
let mut cell = CellData::from_ratatui_cell(cell);
|
||||
cell.hyperlink = hyperlink;
|
||||
cells.push(cell);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -145,6 +145,14 @@ impl RenderImpact {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
enum ScheduledRenderImpact {
|
||||
#[default]
|
||||
None,
|
||||
Animation,
|
||||
Full,
|
||||
}
|
||||
|
||||
fn record_render_impact(source: &'static str, impact: RenderImpact) {
|
||||
let event = match (source, impact) {
|
||||
("api_requests", RenderImpact::Graphics) => "graphics_render_cause.api_requests",
|
||||
|
|
@ -493,6 +501,7 @@ impl HeadlessServer {
|
|||
let mut needs_render = true;
|
||||
let mut needs_full_render = true;
|
||||
let mut needs_graphics_render = false;
|
||||
let mut needs_animation_render = false;
|
||||
|
||||
loop {
|
||||
crate::render_prof::event("loop.tick");
|
||||
|
|
@ -589,11 +598,19 @@ impl HeadlessServer {
|
|||
|
||||
// 6. Handle scheduled tasks.
|
||||
let now = Instant::now();
|
||||
if self.handle_scheduled_tasks_headless(now, needs_render) {
|
||||
needs_render = true;
|
||||
needs_full_render = true;
|
||||
needs_graphics_render = false;
|
||||
crate::render_prof::event("full_render_cause.scheduled_tasks");
|
||||
match self.handle_scheduled_tasks_headless(now, needs_render) {
|
||||
ScheduledRenderImpact::None => {}
|
||||
ScheduledRenderImpact::Animation => {
|
||||
needs_render = true;
|
||||
needs_animation_render = true;
|
||||
crate::render_prof::event("animation_render_cause.scheduled_tasks");
|
||||
}
|
||||
ScheduledRenderImpact::Full => {
|
||||
needs_render = true;
|
||||
needs_full_render = true;
|
||||
needs_graphics_render = false;
|
||||
crate::render_prof::event("full_render_cause.scheduled_tasks");
|
||||
}
|
||||
}
|
||||
|
||||
if self.handle_deferred_requests_headless() {
|
||||
|
|
@ -630,7 +647,16 @@ impl HeadlessServer {
|
|||
crate::render_prof::event("retained_gate.not_pty_dirty");
|
||||
}
|
||||
let mut deferred_graphics = false;
|
||||
let rendered_retained = if needs_graphics_render && !needs_full_render && !pty_dirty
|
||||
let rendered_retained = if needs_animation_render
|
||||
&& !needs_full_render
|
||||
&& !needs_graphics_render
|
||||
&& !pty_dirty
|
||||
{
|
||||
self.render_retained_animation_update_and_stream()
|
||||
} else if needs_graphics_render
|
||||
&& !needs_full_render
|
||||
&& !needs_animation_render
|
||||
&& !pty_dirty
|
||||
{
|
||||
match self.render_retained_graphics_update_and_stream() {
|
||||
RetainedGraphicsOutcome::Sent => true,
|
||||
|
|
@ -641,7 +667,10 @@ impl HeadlessServer {
|
|||
RetainedGraphicsOutcome::Fallback => false,
|
||||
}
|
||||
} else {
|
||||
pty_dirty && !needs_full_render && self.render_retained_pty_update_and_stream()
|
||||
pty_dirty
|
||||
&& !needs_full_render
|
||||
&& !needs_animation_render
|
||||
&& self.render_retained_pty_update_and_stream()
|
||||
};
|
||||
if deferred_graphics {
|
||||
needs_render = false;
|
||||
|
|
@ -655,6 +684,7 @@ impl HeadlessServer {
|
|||
needs_render = false;
|
||||
needs_full_render = false;
|
||||
needs_graphics_render = false;
|
||||
needs_animation_render = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -3396,6 +3426,69 @@ impl HeadlessServer {
|
|||
}
|
||||
}
|
||||
|
||||
fn render_retained_animation_update_and_stream(&mut self) -> bool {
|
||||
crate::render_prof::event("retained_animation.attempt");
|
||||
if !self.retained_pty_update_allowed_by_app_state()
|
||||
|| self.app.state.config_diagnostic.is_some()
|
||||
{
|
||||
crate::render_prof::event("retained_animation_fallback.unsafe_app_state");
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut targets = Vec::new();
|
||||
for (client_id, (cols, rows), _, _, mode) in
|
||||
render_targets(&self.clients, self.foreground_client_id)
|
||||
{
|
||||
if !matches!(mode, ClientConnectionMode::App) {
|
||||
continue;
|
||||
}
|
||||
let Some(client) = self.clients.get(&client_id) else {
|
||||
return false;
|
||||
};
|
||||
if client.deferred_render() != DeferredRender::None
|
||||
|| client.graphics_surface_reset_pending
|
||||
{
|
||||
crate::render_prof::event("retained_animation_fallback.client_state");
|
||||
return false;
|
||||
}
|
||||
let Some(previous) = client.render_state.last_frame().cloned() else {
|
||||
crate::render_prof::event("retained_animation_fallback.no_last_frame");
|
||||
return false;
|
||||
};
|
||||
if previous.width != cols || previous.height != rows {
|
||||
crate::render_prof::event("retained_animation_fallback.frame_size_mismatch");
|
||||
return false;
|
||||
}
|
||||
targets.push((client_id, previous));
|
||||
}
|
||||
|
||||
let mut frames = Vec::with_capacity(targets.len());
|
||||
for (client_id, previous) in targets {
|
||||
let Some(frame) = crate::server::render_stream::render_working_animation_from_frame(
|
||||
&mut self.app.state,
|
||||
&self.app.terminal_runtimes,
|
||||
previous,
|
||||
) else {
|
||||
crate::render_prof::event("retained_animation_fallback.render_failed");
|
||||
return false;
|
||||
};
|
||||
frames.push((client_id, frame));
|
||||
}
|
||||
|
||||
let mut broken_clients = Vec::new();
|
||||
let mut sent_all = true;
|
||||
for (client_id, frame) in frames {
|
||||
sent_all &= self.send_retained_frame_to_client(client_id, frame, &mut broken_clients);
|
||||
}
|
||||
for client_id in broken_clients {
|
||||
self.remove_client_and_resize_if_needed(client_id);
|
||||
}
|
||||
if sent_all {
|
||||
crate::render_prof::event("retained_animation.success");
|
||||
}
|
||||
sent_all
|
||||
}
|
||||
|
||||
fn render_retained_pty_update_and_stream(&mut self) -> bool {
|
||||
crate::render_prof::event("retained.attempt");
|
||||
let retained_started = crate::render_prof::timer();
|
||||
|
|
@ -3892,8 +3985,13 @@ impl HeadlessServer {
|
|||
///
|
||||
/// Similar to `App::handle_scheduled_tasks` but without resize polling
|
||||
/// (the server doesn't have a terminal to resize).
|
||||
fn handle_scheduled_tasks_headless(&mut self, now: Instant, geometry_dirty: bool) -> bool {
|
||||
fn handle_scheduled_tasks_headless(
|
||||
&mut self,
|
||||
now: Instant,
|
||||
geometry_dirty: bool,
|
||||
) -> ScheduledRenderImpact {
|
||||
let mut changed = false;
|
||||
let mut animation_changed = false;
|
||||
|
||||
self.app.sync_headless_animation_timer(now);
|
||||
|
||||
|
|
@ -3960,7 +4058,7 @@ impl HeadlessServer {
|
|||
.spinner_tick
|
||||
.wrapping_add(app::HEADLESS_ANIMATION_TICK_STEP);
|
||||
self.app.next_animation_tick = Some(now + app::HEADLESS_ANIMATION_INTERVAL);
|
||||
changed = true;
|
||||
animation_changed = true;
|
||||
}
|
||||
|
||||
if self
|
||||
|
|
@ -4020,7 +4118,13 @@ impl HeadlessServer {
|
|||
.start_pending_agent_resumes(self.app.pending_agent_resume_due(now));
|
||||
}
|
||||
self.app.sync_headless_animation_timer(now);
|
||||
changed
|
||||
if changed {
|
||||
ScheduledRenderImpact::Full
|
||||
} else if animation_changed {
|
||||
ScheduledRenderImpact::Animation
|
||||
} else {
|
||||
ScheduledRenderImpact::None
|
||||
}
|
||||
}
|
||||
|
||||
/// Initiates graceful shutdown.
|
||||
|
|
@ -4825,6 +4929,22 @@ mod tests {
|
|||
(server, client_rx, pane_id)
|
||||
}
|
||||
|
||||
fn set_first_test_pane_working(server: &mut HeadlessServer) {
|
||||
server.app.state.ensure_test_terminals();
|
||||
let pane_id = server.app.state.workspaces[0].tabs[0].root_pane;
|
||||
let terminal_id = server.app.state.workspaces[0].tabs[0].panes[&pane_id]
|
||||
.attached_terminal_id
|
||||
.clone();
|
||||
let terminal = server
|
||||
.app
|
||||
.state
|
||||
.terminals
|
||||
.get_mut(&terminal_id)
|
||||
.expect("test terminal");
|
||||
terminal.detected_agent = Some(crate::detect::Agent::Pi);
|
||||
terminal.state = crate::detect::AgentState::Working;
|
||||
}
|
||||
|
||||
fn assert_frame_data_eq(actual: &FrameData, expected: &FrameData) {
|
||||
assert_eq!(
|
||||
(actual.width, actual.height),
|
||||
|
|
@ -4851,6 +4971,88 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retained_animation_matches_full_render_for_mixed_client_sizes() {
|
||||
fn test_server_with_mobile_client() -> (
|
||||
HeadlessServer,
|
||||
std::sync::mpsc::Receiver<Vec<u8>>,
|
||||
std::sync::mpsc::Receiver<Vec<u8>>,
|
||||
) {
|
||||
let (mut server, desktop_rx, _) = retained_test_server(
|
||||
b"\x1b]8;;https://example.com\x1b\\\x1b[4:3mlinked\x1b[0m\x1b]8;;\x1b\\",
|
||||
);
|
||||
set_first_test_pane_working(&mut server);
|
||||
let (mobile_tx, _mobile_control_rx, mobile_rx) = test_client_writer();
|
||||
server.clients.insert(
|
||||
2,
|
||||
ClientConnection::new(
|
||||
(44, 20),
|
||||
crate::kitty_graphics::HostCellSize::default(),
|
||||
crate::terminal_theme::TerminalTheme::default(),
|
||||
None,
|
||||
2,
|
||||
RenderEncoding::SemanticFrame,
|
||||
Some(mobile_tx),
|
||||
),
|
||||
);
|
||||
(server, desktop_rx, mobile_rx)
|
||||
}
|
||||
|
||||
let (mut retained_server, retained_desktop_rx, retained_mobile_rx) =
|
||||
test_server_with_mobile_client();
|
||||
let (mut full_server, full_desktop_rx, full_mobile_rx) = test_server_with_mobile_client();
|
||||
|
||||
retained_server.render_and_stream();
|
||||
let _ = retained_desktop_rx
|
||||
.recv_timeout(Duration::from_millis(100))
|
||||
.expect("retained desktop baseline");
|
||||
let _ = retained_mobile_rx
|
||||
.recv_timeout(Duration::from_millis(100))
|
||||
.expect("retained mobile baseline");
|
||||
full_server.render_and_stream();
|
||||
let _ = full_desktop_rx
|
||||
.recv_timeout(Duration::from_millis(100))
|
||||
.expect("full desktop baseline");
|
||||
let _ = full_mobile_rx
|
||||
.recv_timeout(Duration::from_millis(100))
|
||||
.expect("full mobile baseline");
|
||||
|
||||
retained_server.app.state.spinner_tick = app::HEADLESS_ANIMATION_TICK_STEP;
|
||||
full_server.app.state.spinner_tick = app::HEADLESS_ANIMATION_TICK_STEP;
|
||||
|
||||
assert!(retained_server.render_retained_animation_update_and_stream());
|
||||
full_server.render_and_stream();
|
||||
|
||||
let retained_desktop = read_server_frame(
|
||||
retained_desktop_rx
|
||||
.recv_timeout(Duration::from_millis(100))
|
||||
.expect("retained desktop animation"),
|
||||
);
|
||||
let retained_mobile = read_server_frame(
|
||||
retained_mobile_rx
|
||||
.recv_timeout(Duration::from_millis(100))
|
||||
.expect("retained mobile animation"),
|
||||
);
|
||||
let full_desktop = read_server_frame(
|
||||
full_desktop_rx
|
||||
.recv_timeout(Duration::from_millis(100))
|
||||
.expect("full desktop animation"),
|
||||
);
|
||||
let full_mobile = read_server_frame(
|
||||
full_mobile_rx
|
||||
.recv_timeout(Duration::from_millis(100))
|
||||
.expect("full mobile animation"),
|
||||
);
|
||||
|
||||
assert_frame_data_eq(&retained_desktop, &full_desktop);
|
||||
assert_frame_data_eq(&retained_mobile, &full_mobile);
|
||||
assert_eq!(
|
||||
retained_server.app.state.view.layout,
|
||||
crate::app::state::ViewLayout::Desktop
|
||||
);
|
||||
assert!(!retained_desktop.hyperlinks.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreground_client_applies_client_keybindings() {
|
||||
let mut server = test_headless_server();
|
||||
|
|
@ -5938,7 +6140,10 @@ next_tab = ""
|
|||
Some("short lived")
|
||||
);
|
||||
|
||||
assert!(server.handle_scheduled_tasks_headless(deadline + Duration::from_millis(1), false));
|
||||
assert_eq!(
|
||||
server.handle_scheduled_tasks_headless(deadline + Duration::from_millis(1), false),
|
||||
ScheduledRenderImpact::Full
|
||||
);
|
||||
|
||||
assert_eq!(server.app.agent_metadata_deadline, None);
|
||||
assert_eq!(
|
||||
|
|
@ -5968,13 +6173,39 @@ next_tab = ""
|
|||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headless_scheduled_animation_is_distinct_but_other_changes_win() {
|
||||
let mut server = test_headless_server();
|
||||
server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("working")];
|
||||
set_first_test_pane_working(&mut server);
|
||||
let now = Instant::now();
|
||||
server.app.next_animation_tick = Some(now);
|
||||
|
||||
assert_eq!(
|
||||
server.handle_scheduled_tasks_headless(now, false),
|
||||
ScheduledRenderImpact::Animation
|
||||
);
|
||||
|
||||
server.app.next_animation_tick = Some(now);
|
||||
server.app.state.config_diagnostic = Some("expired".into());
|
||||
server.app.config_diagnostic_deadline = Some(now);
|
||||
assert_eq!(
|
||||
server.handle_scheduled_tasks_headless(now, false),
|
||||
ScheduledRenderImpact::Full
|
||||
);
|
||||
assert!(server.app.state.config_diagnostic.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headless_scheduled_tasks_clears_disabled_agent_manifest_update_deadline() {
|
||||
let mut server = test_headless_server();
|
||||
let now = Instant::now();
|
||||
server.app.next_agent_manifest_update_check = Some(now - Duration::from_millis(1));
|
||||
|
||||
assert!(!server.handle_scheduled_tasks_headless(now, false));
|
||||
assert_eq!(
|
||||
server.handle_scheduled_tasks_headless(now, false),
|
||||
ScheduledRenderImpact::None
|
||||
);
|
||||
assert_eq!(server.app.next_agent_manifest_update_check, None);
|
||||
}
|
||||
|
||||
|
|
@ -6030,7 +6261,10 @@ next_tab = ""
|
|||
});
|
||||
server.app.pending_agent_resume_deadline = Some(Instant::now() - Duration::from_millis(1));
|
||||
|
||||
assert!(!server.handle_scheduled_tasks_headless(Instant::now(), true));
|
||||
assert_eq!(
|
||||
server.handle_scheduled_tasks_headless(Instant::now(), true),
|
||||
ScheduledRenderImpact::None
|
||||
);
|
||||
assert!(server.app.terminal_runtimes.get(&terminal_id).is_none());
|
||||
assert!(server
|
||||
.app
|
||||
|
|
@ -6084,7 +6318,10 @@ next_tab = ""
|
|||
});
|
||||
server.app.pending_agent_resume_deadline = Some(Instant::now() - Duration::from_millis(1));
|
||||
|
||||
assert!(!server.handle_scheduled_tasks_headless(Instant::now(), false));
|
||||
assert_eq!(
|
||||
server.handle_scheduled_tasks_headless(Instant::now(), false),
|
||||
ScheduledRenderImpact::None
|
||||
);
|
||||
assert!(server.app.terminal_runtimes.get(&terminal_id).is_none());
|
||||
assert!(server
|
||||
.app
|
||||
|
|
@ -9311,7 +9548,10 @@ next_tab = ""
|
|||
}
|
||||
);
|
||||
let deadline = server.app.toast_deadline.expect("api toast deadline");
|
||||
assert!(server.handle_scheduled_tasks_headless(deadline, false));
|
||||
assert_eq!(
|
||||
server.handle_scheduled_tasks_headless(deadline, false),
|
||||
ScheduledRenderImpact::Full
|
||||
);
|
||||
assert!(server.app.state.toast.is_none());
|
||||
assert!(server.app.toast_deadline.is_none());
|
||||
}
|
||||
|
|
@ -9438,7 +9678,10 @@ next_tab = ""
|
|||
.state
|
||||
.next_pending_agent_notification_deadline()
|
||||
.expect("pending notification deadline");
|
||||
assert!(server.handle_scheduled_tasks_headless(deadline, false));
|
||||
assert_eq!(
|
||||
server.handle_scheduled_tasks_headless(deadline, false),
|
||||
ScheduledRenderImpact::Full
|
||||
);
|
||||
|
||||
let first = read_server_message(
|
||||
client_control_rx
|
||||
|
|
@ -9526,7 +9769,10 @@ next_tab = ""
|
|||
.state
|
||||
.next_pending_agent_notification_deadline()
|
||||
.expect("pending notification deadline");
|
||||
assert!(server.handle_scheduled_tasks_headless(deadline, false));
|
||||
assert_eq!(
|
||||
server.handle_scheduled_tasks_headless(deadline, false),
|
||||
ScheduledRenderImpact::Full
|
||||
);
|
||||
|
||||
let first = read_server_message(
|
||||
client_control_rx
|
||||
|
|
|
|||
|
|
@ -345,6 +345,45 @@ pub(crate) fn render_virtual_with_runtime_registry(
|
|||
(buffer, cursor)
|
||||
}
|
||||
|
||||
pub(crate) fn render_working_animation_from_frame(
|
||||
app_state: &mut AppState,
|
||||
terminal_runtimes: &TerminalRuntimeRegistry,
|
||||
mut frame: FrameData,
|
||||
) -> Option<FrameData> {
|
||||
let area = Rect::new(0, 0, frame.width, frame.height);
|
||||
crate::ui::compute_view_without_resizing_panes(app_state, terminal_runtimes, area);
|
||||
|
||||
let backend = CursorTrackingBackend::new(area.width, area.height);
|
||||
let mut terminal = ratatui::Terminal::new(backend).ok()?;
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
crate::ui::render_working_animation(app_state, terminal_runtimes, frame);
|
||||
})
|
||||
.ok()?;
|
||||
|
||||
let updated_area = if app_state.view.layout == crate::app::state::ViewLayout::Mobile {
|
||||
app_state.view.mobile_header_rect
|
||||
} else {
|
||||
app_state.view.sidebar_rect
|
||||
};
|
||||
if updated_area.x.saturating_add(updated_area.width) > frame.width
|
||||
|| updated_area.y.saturating_add(updated_area.height) > frame.height
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let rendered = terminal.backend().buffer();
|
||||
frame.graphics.clear();
|
||||
for y in updated_area.y..updated_area.y.saturating_add(updated_area.height) {
|
||||
for x in updated_area.x..updated_area.x.saturating_add(updated_area.width) {
|
||||
let index = usize::from(y) * usize::from(frame.width) + usize::from(x);
|
||||
let cell = rendered.cell((x, y))?;
|
||||
frame.cells[index] = crate::protocol::CellData::from_ratatui_cell(cell);
|
||||
}
|
||||
}
|
||||
Some(frame)
|
||||
}
|
||||
|
||||
fn popup_terminal_cursor(
|
||||
app_state: &AppState,
|
||||
terminal_runtimes: &TerminalRuntimeRegistry,
|
||||
|
|
|
|||
27
src/ui.rs
27
src/ui.rs
|
|
@ -394,19 +394,10 @@ pub fn render_with_runtime_registry(
|
|||
terminal_runtimes: &TerminalRuntimeRegistry,
|
||||
frame: &mut Frame,
|
||||
) {
|
||||
let sidebar_area = app.view.sidebar_rect;
|
||||
let tab_bar_area = app.view.tab_bar_rect;
|
||||
let terminal_area = app.view.terminal_area;
|
||||
|
||||
if app.view.layout == ViewLayout::Mobile {
|
||||
render_mobile_header(app, terminal_runtimes, frame, app.view.mobile_header_rect);
|
||||
} else if sidebar_area.width > 0 {
|
||||
if app.sidebar_collapsed {
|
||||
render_sidebar_collapsed(app, frame, sidebar_area);
|
||||
} else {
|
||||
render_sidebar(app, terminal_runtimes, frame, sidebar_area);
|
||||
}
|
||||
}
|
||||
render_working_animation(app, terminal_runtimes, frame);
|
||||
if app.view.layout != ViewLayout::Mobile {
|
||||
render_tab_bar(app, frame, tab_bar_area);
|
||||
}
|
||||
|
|
@ -457,6 +448,22 @@ pub fn render_with_runtime_registry(
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) fn render_working_animation(
|
||||
app: &AppState,
|
||||
terminal_runtimes: &TerminalRuntimeRegistry,
|
||||
frame: &mut Frame,
|
||||
) {
|
||||
if app.view.layout == ViewLayout::Mobile {
|
||||
render_mobile_header(app, terminal_runtimes, frame, app.view.mobile_header_rect);
|
||||
} else if app.view.sidebar_rect.width > 0 {
|
||||
if app.sidebar_collapsed {
|
||||
render_sidebar_collapsed(app, frame, app.view.sidebar_rect);
|
||||
} else {
|
||||
render_sidebar(app, terminal_runtimes, frame, app.view.sidebar_rect);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_notifications(app: &AppState, frame: &mut Frame, terminal_area: Rect) {
|
||||
let has_config_diagnostic = app.config_diagnostic.is_some();
|
||||
if let Some(message) = &app.config_diagnostic {
|
||||
|
|
|
|||
Loading…
Reference in New Issue