From f9c1541fdbccb990ea4e5ee102bf95dd4669bf3c Mon Sep 17 00:00:00 2001 From: Ryan Ward Date: Fri, 30 Oct 2026 05:01:05 +0000 Subject: [PATCH] Improve background job cards and log browser --- app/src/ai/background_jobs/log_format.rs | 348 +++++++++ app/src/ai/background_jobs/mod.rs | 1 + app/src/ai/background_jobs/view.rs | 668 ++++++++++-------- .../inline_action/requested_command.rs | 38 +- 4 files changed, 741 insertions(+), 314 deletions(-) create mode 100644 app/src/ai/background_jobs/log_format.rs diff --git a/app/src/ai/background_jobs/log_format.rs b/app/src/ai/background_jobs/log_format.rs new file mode 100644 index 00000000..52a4b974 --- /dev/null +++ b/app/src/ai/background_jobs/log_format.rs @@ -0,0 +1,348 @@ +//! Read-only presentation of captured command output, without creating a terminal session. + +use std::ops::Range; + +use galaxy_core::ui::theme::{AnsiColors, TerminalColors}; +use galaxyui::color::ColorU; +use galaxyui::text_layout::TextStyle; +use string_offset::ByteOffset; +use vte::{Params, Parser, Perform}; + +/// Removes terminal controls and resolves ANSI colors against the current theme. +/// +/// The caller supplies the already bounded log tail. Memory grows linearly with +/// that input: cursor movement cannot allocate padding or expand the output. +/// Reformat when the theme changes; returned colors are concrete theme colors. +pub(super) fn format_job_logs( + raw: &str, + colors: &TerminalColors, +) -> (String, Vec<(Range, TextStyle)>) { + let mut formatter = LogFormatter { + colors, + text: String::with_capacity(raw.len()), + styles: Vec::new(), + line: Vec::new(), + cursor: 0, + style: TextStyle::default(), + underline: false, + }; + let mut parser = Parser::new(); + for byte in raw.bytes() { + parser.advance(&mut formatter, byte); + } + formatter.flush_line(); + (formatter.text, formatter.styles) +} + +#[derive(Clone, Copy)] +struct LogCell { + character: char, + style: TextStyle, +} + +struct LogFormatter<'a> { + colors: &'a TerminalColors, + text: String, + styles: Vec<(Range, TextStyle)>, + line: Vec, + cursor: usize, + style: TextStyle, + underline: bool, +} + +impl LogFormatter<'_> { + fn flush_line(&mut self) { + for cell in self.line.drain(..) { + let start = self.text.len(); + self.text.push(cell.character); + let end = self.text.len(); + if cell.style == TextStyle::default() { + continue; + } + if let Some((range, style)) = self.styles.last_mut() { + if *style == cell.style && range.end.as_usize() == start { + range.end = ByteOffset::from(end); + continue; + } + } + self.styles + .push((ByteOffset::from(start)..ByteOffset::from(end), cell.style)); + } + self.cursor = 0; + } + + fn displayed_style(&self) -> TextStyle { + let mut style = self.style; + if self.underline { + style.underline_color = Some( + style + .foreground_color + .unwrap_or_else(|| self.colors.normal.white.into()), + ); + } + style + } + + fn set_graphics(&mut self, params: &Params) { + let params: Vec<_> = params.iter().collect(); + let mut index = 0; + while let Some(parameter) = params.get(index) { + match parameter.first().copied().unwrap_or(0) { + 0 => { + self.style = TextStyle::default(); + self.underline = false; + } + 4 => self.underline = parameter.get(1).copied() != Some(0), + 9 => self.style.show_strikethrough = true, + 24 => self.underline = false, + 29 => self.style.show_strikethrough = false, + value @ 30..=37 => { + self.style.foreground_color = Some(self.indexed_color((value - 30) as u8)); + } + value @ 40..=47 => { + self.style.background_color = Some(self.indexed_color((value - 40) as u8)); + } + value @ 90..=97 => { + self.style.foreground_color = Some(self.indexed_color((value - 90 + 8) as u8)); + } + value @ 100..=107 => { + self.style.background_color = Some(self.indexed_color((value - 100 + 8) as u8)); + } + 39 => self.style.foreground_color = None, + 49 => self.style.background_color = None, + target @ (38 | 48) => { + let (color, consumed) = if parameter.len() > 1 { + (self.extended_color(¶meter[1..]), 0) + } else { + let mode = params + .get(index + 1) + .and_then(|value| value.first()) + .copied(); + let count = match mode { + Some(5) => 2, + Some(2) => 4, + _ => 1, + }; + let values: Vec<_> = params + .iter() + .skip(index + 1) + .take(count) + .filter_map(|value| value.first().copied()) + .collect(); + (self.extended_color(&values), count) + }; + if let Some(color) = color { + if target == 38 { + self.style.foreground_color = Some(color); + } else { + self.style.background_color = Some(color); + } + } + index += consumed; + } + // Font weight/italics, cursor controls and other terminal state + // are not represented by this selectable text-log view. + _ => {} + } + index += 1; + } + } + + fn extended_color(&self, values: &[u16]) -> Option { + match values.first()? { + 5 => Some(self.indexed_color(u8::try_from(*values.get(1)?).ok()?)), + // Colon notation may include a color-space field before RGB. + 2 if values.len() >= 4 => { + let rgb = &values[values.len() - 3..]; + Some(ColorU::new( + u8::try_from(rgb[0]).ok()?, + u8::try_from(rgb[1]).ok()?, + u8::try_from(rgb[2]).ok()?, + 255, + )) + } + _ => None, + } + } + + fn indexed_color(&self, index: u8) -> ColorU { + match index { + 0..=7 => palette_color(&self.colors.normal, index), + 8..=15 => palette_color(&self.colors.bright, index - 8), + 16..=231 => { + let cube = index - 16; + let component = |value| if value == 0 { 0 } else { 55 + 40 * value }; + ColorU::new( + component(cube / 36), + component(cube / 6 % 6), + component(cube % 6), + 255, + ) + } + 232..=255 => { + let level = 8 + (index - 232) * 10; + ColorU::new(level, level, level, 255) + } + } + } +} + +fn palette_color(palette: &AnsiColors, index: u8) -> ColorU { + match index { + 0 => palette.black, + 1 => palette.red, + 2 => palette.green, + 3 => palette.yellow, + 4 => palette.blue, + 5 => palette.magenta, + 6 => palette.cyan, + _ => palette.white, + } + .into() +} + +impl Perform for LogFormatter<'_> { + fn print(&mut self, character: char) { + let cell = LogCell { + character, + style: self.displayed_style(), + }; + if let Some(existing) = self.line.get_mut(self.cursor) { + *existing = cell; + } else { + self.line.push(cell); + } + self.cursor += 1; + } + + fn execute(&mut self, byte: u8) { + match byte { + b'\n' => { + self.flush_line(); + self.text.push('\n'); + } + b'\r' => self.cursor = 0, + b'\t' => self.print('\t'), + 0x08 => self.cursor = self.cursor.saturating_sub(1), + _ => {} + } + } + + fn csi_dispatch(&mut self, params: &Params, intermediates: &[u8], ignore: bool, action: char) { + if ignore || !intermediates.is_empty() { + return; + } + match action { + 'm' => self.set_graphics(params), + // Support the erase-line controls commonly paired with CR progress + // output. Other cursor movement is ignored and cannot grow memory. + 'K' => match params + .iter() + .next() + .and_then(|value| value.first()) + .copied() + .unwrap_or(0) + { + 0 => self.line.truncate(self.cursor), + 1 => { + let count = self.cursor.saturating_add(1).min(self.line.len()); + self.line[..count].fill(LogCell { + character: ' ', + style: TextStyle::default(), + }); + } + 2 => { + self.line.clear(); + self.cursor = 0; + } + _ => {} + }, + _ => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use galaxy_core::ui::theme::AnsiColor; + + fn colors() -> TerminalColors { + let palette = |red| { + AnsiColors::new( + AnsiColor { r: 0, g: 0, b: 0 }, + AnsiColor { r: red, g: 1, b: 2 }, + AnsiColor { r: 0, g: 180, b: 0 }, + AnsiColor { + r: 180, + g: 180, + b: 0, + }, + AnsiColor { r: 0, g: 0, b: 180 }, + AnsiColor { + r: 180, + g: 0, + b: 180, + }, + AnsiColor { + r: 0, + g: 180, + b: 180, + }, + AnsiColor { + r: 230, + g: 230, + b: 230, + }, + ) + }; + TerminalColors::new(palette(160), palette(250)) + } + + #[test] + fn ansi_colors_reset_and_unicode_ranges_use_output_bytes() { + let colors = colors(); + let (text, styles) = + format_job_logs("é\x1b[31m红\x1b[0m plain \x1b[91mbright\x1b[39m!", &colors); + assert_eq!(text, "é红 plain bright!"); + assert_eq!(styles.len(), 2); + assert_eq!(styles[0].0, ByteOffset::from(2)..ByteOffset::from(5)); + assert_eq!(styles[0].1.foreground_color, Some(colors.normal.red.into())); + assert_eq!(styles[1].1.foreground_color, Some(colors.bright.red.into())); + for (range, _) in styles { + assert!(text + .get(range.start.as_usize()..range.end.as_usize()) + .is_some()); + } + } + + #[test] + fn strips_opaque_controls_and_replaces_progress_lines() { + let (text, _) = format_job_logs( + "\x1b]0;hidden title\x07loading 10%\rloading 90%\r\x1b[2Kdone\r\n\x1b]8;;https://example.test\x1b\\link\x1b]8;;\x1b\\\tend\x07\x1b[99999C!", + &colors(), + ); + assert_eq!(text, "done\nlink\tend!"); + } + + #[test] + fn supports_indexed_and_rgb_colors_in_both_sgr_notations() { + let (text, styles) = format_job_logs( + "\x1b[38;5;196mred\x1b[48;2;1;2;3mBG\x1b[0m\x1b[38:2::4:5:6mRGB\x1b[0m", + &colors(), + ); + assert_eq!(text, "redBGRGB"); + assert_eq!( + styles[0].1.foreground_color, + Some(ColorU::new(255, 0, 0, 255)) + ); + assert_eq!( + styles[1].1.background_color, + Some(ColorU::new(1, 2, 3, 255)) + ); + assert_eq!( + styles[2].1.foreground_color, + Some(ColorU::new(4, 5, 6, 255)) + ); + } +} diff --git a/app/src/ai/background_jobs/mod.rs b/app/src/ai/background_jobs/mod.rs index d0618346..01fbcac7 100644 --- a/app/src/ai/background_jobs/mod.rs +++ b/app/src/ai/background_jobs/mod.rs @@ -1,5 +1,6 @@ //! Agent-requested commands run independently of a conversation's current model turn. +mod log_format; pub(crate) mod remote; mod runtime; pub mod view; diff --git a/app/src/ai/background_jobs/view.rs b/app/src/ai/background_jobs/view.rs index 3611b99f..fce603d6 100644 --- a/app/src/ai/background_jobs/view.rs +++ b/app/src/ai/background_jobs/view.rs @@ -6,14 +6,15 @@ use std::sync::LazyLock; use std::time::{Duration, Instant}; use galaxy_core::ui::icons::Icon; -use galaxy_core::ui::theme::color::internal_colors; +use galaxy_core::ui::theme::{color::internal_colors, TerminalColors}; use galaxy_core::HostId; use galaxy_util::local_or_remote_path::LocalOrRemotePath; use galaxy_util::standardized_path::StandardizedPath; use galaxyui::elements::{ Border, ChildView, Clipped, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, - Container, CrossAxisAlignment, Flex, Hoverable, MainAxisAlignment, MouseStateHandle, Padding, - ParentElement, Point, ScrollbarWidth, Shrinkable, Text, + Container, CrossAxisAlignment, Expanded, Flex, Hoverable, MainAxisAlignment, MainAxisSize, + MouseStateHandle, Padding, ParentElement, Point, RowBackground, ScrollbarWidth, Shrinkable, + Table, TableColumnWidth, TableConfig, TableHeader, TableStateHandle, TableVerticalSizing, Text, }; use galaxyui::event::DispatchedEvent; use galaxyui::fonts::{Properties, Weight}; @@ -30,8 +31,8 @@ use uuid::Uuid; use super::{BackgroundJob, BackgroundJobStatus, BackgroundJobsModel}; use crate::appearance::Appearance; use crate::editor::{ - EditorOptions, EditorView, Event as EditorEvent, InteractionState, SingleLineEditorOptions, - TextOptions, + EditorOptions, EditorView, Event as EditorEvent, InteractionState, PlainTextEditorViewAction, + SingleLineEditorOptions, TextColors, TextOptions, }; use crate::modal::ModalAction; use crate::view_components::action_button::{ @@ -58,6 +59,7 @@ pub enum BackgroundJobsAction { LoadMore, } +#[derive(Clone)] struct JobRowControls { mouse: MouseStateHandle, view_logs: ViewHandle, @@ -86,8 +88,11 @@ pub struct BackgroundJobsView { back_button: ViewHandle, more_button: ViewHandle, list_scroll: ClippedScrollStateHandle, + table_state: TableStateHandle, logs_scroll: ClippedScrollStateHandle, + command_scroll: ClippedScrollStateHandle, displayed_logs: String, + displayed_log_colors: Option, error: Option, } @@ -104,7 +109,7 @@ impl BackgroundJobsView { _ => {} }); let logs_font = Appearance::as_ref(ctx).monospace_font_family(); - let logs_size = Appearance::as_ref(ctx).ui_font_size(); + let logs_size = Appearance::as_ref(ctx).monospace_font_size(); let logs = ctx.add_typed_action_view(|ctx| { let mut editor = EditorView::new( EditorOptions { @@ -116,6 +121,7 @@ impl BackgroundJobsView { autogrow: true, soft_wrap: true, supports_vim_mode: false, + use_settings_line_height_ratio: true, ..Default::default() }, ctx, @@ -123,6 +129,11 @@ impl BackgroundJobsView { editor.set_interaction_state(InteractionState::Selectable, ctx); editor }); + ctx.subscribe_to_view(&logs, |_, _, event, ctx| { + if matches!(event, EditorEvent::Escape) { + ctx.dispatch_typed_action(&ModalAction::Close); + } + }); let scope_buttons = [ (JobScopeFilter::Project, "Current project"), (JobScopeFilter::Directory, "Current directory"), @@ -187,6 +198,12 @@ impl BackgroundJobsView { me.synchronize(ctx); } }); + ctx.subscribe_to_model(&Appearance::handle(ctx), |me, _, _, ctx| { + if me.visible && me.logs_open { + me.set_logs(me.displayed_logs.clone(), ctx); + ctx.notify(); + } + }); Self { working_directory: None, @@ -209,8 +226,11 @@ impl BackgroundJobsView { back_button, more_button, list_scroll: Default::default(), + table_state: TableStateHandle::new(0, |_, _| Vec::new()), logs_scroll: Default::default(), + command_scroll: Default::default(), displayed_logs: String::new(), + displayed_log_colors: None, error: None, } } @@ -246,10 +266,13 @@ impl BackgroundJobsView { }; self.status = None; self.selected = selected; - self.logs_open = false; + self.logs_open = + selected.is_some_and(|id| BackgroundJobsModel::as_ref(ctx).job(id).is_some()); self.visible = true; self.display_limit = 100; self.error = None; + self.logs_scroll = Default::default(); + self.command_scroll = Default::default(); self.search.update(ctx, |editor, ctx| { editor.clear_buffer_and_reset_undo_stack(ctx) }); @@ -269,7 +292,11 @@ impl BackgroundJobsView { } self.synchronize(ctx); self.schedule_log_availability_refresh(ctx); - ctx.focus(&self.search); + if self.logs_open { + ctx.focus(&self.logs); + } else { + ctx.focus(&self.search); + } } pub fn on_close(&mut self) { @@ -420,11 +447,36 @@ impl BackgroundJobsView { } fn set_logs(&mut self, text: String, ctx: &mut ViewContext) { - if text != self.displayed_logs { + let theme = Appearance::as_ref(ctx).theme(); + let colors = theme.terminal_colors().clone(); + let foreground = theme.foreground(); + self.logs.update(ctx, |editor, ctx| { + editor.set_text_colors( + TextColors { + default_color: foreground, + disabled_color: foreground, + hint_color: foreground, + }, + ctx, + ); + }); + if text != self.displayed_logs || self.displayed_log_colors.as_ref() != Some(&colors) { + let (plain, styles) = super::log_format::format_job_logs(&text, &colors); self.logs.update(ctx, |editor, ctx| { - editor.set_buffer_text_ignoring_undo(&text, ctx) + // Programmatic replacement must work while the viewer remains read-only. + let interaction = editor.interaction_state(ctx); + editor.set_interaction_state(InteractionState::Editable, ctx); + editor.clear_buffer_and_reset_undo_stack(ctx); + editor.insert_with_styles( + &plain, + &styles, + PlainTextEditorViewAction::ReplaceBuffer, + ctx, + ); + editor.set_interaction_state(interaction, ctx); }); self.displayed_logs = text; + self.displayed_log_colors = Some(colors); } } @@ -440,44 +492,65 @@ impl BackgroundJobsView { fn render_list(&self, app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); - let mut list = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch); - let jobs = self.filtered_jobs(app); + let jobs = self + .filtered_jobs(app) + .into_iter() + .take(self.display_limit) + .collect::>(); if jobs.is_empty() { - list.add_child( - Container::new( - Text::new_inline( - "No background jobs match these filters.", - appearance.ui_font_family(), - appearance.ui_font_size(), - ) - .with_color(theme.sub_text_color(theme.background()).into()) - .finish(), + return Container::new( + Text::new( + "No background jobs match these filters.", + appearance.ui_font_family(), + appearance.ui_font_size(), ) - .with_padding(Padding::uniform(16.)) + .with_color(theme.sub_text_color(theme.background()).into()) .finish(), - ); + ) + .with_padding(Padding::uniform(24.)) + .finish(); } - for id in jobs.into_iter().take(self.display_limit) { - let Some(job) = BackgroundJobsModel::as_ref(app).job(id) else { - continue; - }; - let selected = self.selected == Some(job.id); - let mut row = Flex::row() - .with_cross_axis_alignment(CrossAxisAlignment::Center) - .with_child( - Container::new(status_icon(&job.status, app)) - .with_margin_right(8.) - .finish(), - ); - let command = job - .command - .lines() - .next() - .unwrap_or(&job.command) - .to_owned(); - let mut text = Flex::column() - .with_cross_axis_alignment(CrossAxisAlignment::Stretch) - .with_child( + let headers = [ + ("Command", TableColumnWidth::Flex(1.5)), + ("Directory", TableColumnWidth::Flex(1.)), + ("Started", TableColumnWidth::Fixed(126.)), + ("Status", TableColumnWidth::Fixed(156.)), + ("Actions", TableColumnWidth::Fixed(216.)), + ] + .into_iter() + .map(|(label, width)| { + TableHeader::new( + Text::new( + label, + appearance.ui_font_family(), + appearance.ui_font_size() - 1., + ) + .with_style(Properties::default().weight(Weight::Semibold)) + .with_color(theme.nonactive_ui_text_color().into()) + .finish(), + ) + .with_width(width) + }) + .collect(); + let rows = self.rows.clone(); + let table = Table::new(self.table_state.clone(), 0., 0.) + .with_headers(headers) + .with_row_count(jobs.len()) + .with_row_render_fn(move |index, app| { + let appearance = Appearance::as_ref(app); + let theme = appearance.theme(); + let id = jobs[index]; + let Some(job) = BackgroundJobsModel::as_ref(app).job(id) else { + return Vec::new(); + }; + let controls = &rows[&id]; + let command = job + .command + .lines() + .next() + .unwrap_or(&job.command) + .to_owned(); + let command_cell = Hoverable::new(controls.mouse.clone(), move |_| { Text::new_inline( command, appearance.monospace_font_family(), @@ -485,89 +558,80 @@ impl BackgroundJobsView { ) .with_color(theme.active_ui_text_color().into()) .with_clip(ClipConfig::ellipsis()) - .finish(), - ); - let location = match &job.host_id { - Some(host) => format!("{host}: {}", job.working_directory.display()), - None => job.working_directory.display().to_string(), - }; - text.add_child( - Text::new_inline( - location, + .finish() + }) + .on_click(move |ctx, _, _| { + ctx.dispatch_typed_action(BackgroundJobsAction::ViewLogs(id)) + }) + .with_cursor(Cursor::PointingHand) + .finish(); + let directory = match &job.host_id { + Some(host) => format!("{host}: {}", job.working_directory.display()), + None => job.working_directory.display().to_string(), + }; + let directory = Text::new_inline( + directory, appearance.ui_font_family(), - appearance.ui_font_size() - 2., + appearance.ui_font_size() - 1., ) - .with_color(theme.sub_text_color(theme.background()).into()) + .with_color(theme.sub_text_color(theme.surface_1()).into()) .with_clip(ClipConfig::ellipsis()) - .finish(), - ); - row.add_child(Shrinkable::new(1., text.finish()).finish()); - row.add_child( - ConstrainedBox::new( - Text::new_inline( - job.started_at - .with_timezone(&chrono::Local) - .format("%b %d, %H:%M") - .to_string(), - appearance.ui_font_family(), - appearance.ui_font_size() - 2., - ) - .with_color(theme.sub_text_color(theme.background()).into()) - .finish(), + .finish(); + let started = Text::new( + job.started_at + .with_timezone(&chrono::Local) + .format("%b %d, %H:%M") + .to_string(), + appearance.ui_font_family(), + appearance.ui_font_size() - 1., ) - .with_width(120.) - .finish(), - ); - if let Some(controls) = self.rows.get(&id) { - row.add_child( - ConstrainedBox::new( - Text::new_inline( - status_label(&job), - appearance.ui_font_family(), - appearance.ui_font_size() - 1., + .with_color(theme.sub_text_color(theme.surface_1()).into()) + .finish(); + let status = Flex::row() + .with_spacing(6.) + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_child(status_icon(&job.status, app)) + .with_child( + Shrinkable::new( + 1., + Text::new( + status_label(job), + appearance.ui_font_family(), + appearance.ui_font_size() - 1., + ) + .with_color(theme.active_ui_text_color().into()) + .finish(), ) - .with_color(theme.sub_text_color(theme.background()).into()) - .with_clip(ClipConfig::ellipsis()) .finish(), ) - .with_width(135.) - .finish(), - ); - let mut actions = Flex::row().with_spacing(4.); + .finish(); + let mut actions = Flex::row() + .with_spacing(4.) + .with_cross_axis_alignment(CrossAxisAlignment::Center); if controls.logs_available && BackgroundJobsModel::as_ref(app).logs_available(id) { actions.add_child(ChildView::new(&controls.view_logs).finish()); } if job.status == BackgroundJobStatus::Running { actions.add_child(ChildView::new(&controls.terminate).finish()); } - row.add_child( - ConstrainedBox::new(actions.finish()) - .with_width(180.) - .finish(), - ); - let background = if selected { - theme.surface_overlay_2() - } else { - theme.surface_2() - }; - list.add_child( - Hoverable::new(controls.mouse.clone(), move |_| { - Container::new(row.finish()) - .with_padding(Padding::uniform(12.)) - .with_background(background) - .finish() - }) - .on_click(move |ctx, _, _| { - ctx.dispatch_typed_action(BackgroundJobsAction::Select(id)) - }) - .with_cursor(Cursor::PointingHand) - .finish(), - ); - } - } + vec![command_cell, directory, started, status, actions.finish()] + }) + .with_config(TableConfig { + border_width: 1., + border_color: theme.outline().into(), + outer_border: true, + column_dividers: false, + row_dividers: true, + cell_padding: 12., + header_background: theme.surface_2().into(), + row_background: RowBackground::uniform(theme.surface_1().into()), + fixed_header: false, + vertical_sizing: TableVerticalSizing::ExpandToContent, + measure_body_cells_for_intrinsic_widths: false, + }); ClippedScrollable::vertical( self.list_scroll.clone(), - list.finish(), + table.finish(), ScrollbarWidth::Auto, theme.nonactive_ui_detail().into(), theme.active_ui_detail().into(), @@ -575,6 +639,153 @@ impl BackgroundJobsView { ) .finish() } + + fn render_detail(&self, app: &AppContext) -> Box { + let appearance = Appearance::as_ref(app); + let theme = appearance.theme(); + let mut detail = Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_main_axis_size(MainAxisSize::Max) + .with_spacing(12.); + let selected = self + .selected + .and_then(|id| BackgroundJobsModel::as_ref(app).job(id)); + let mut header = Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_spacing(8.) + .with_child(ChildView::new(&self.back_button).finish()); + if let Some(job) = selected { + header.add_child( + Expanded::new( + 1., + Flex::row() + .with_spacing(6.) + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_child(status_icon(&job.status, app)) + .with_child( + Text::new( + status_label(job), + appearance.ui_font_family(), + appearance.ui_font_size(), + ) + .with_style(Properties::default().weight(Weight::Semibold)) + .with_color(theme.active_ui_text_color().into()) + .finish(), + ) + .finish(), + ) + .finish(), + ); + if BackgroundJobsModel::as_ref(app).logs_available(job.id) { + header.add_child(ChildView::new(&self.reload_button).finish()); + } + if job.status == BackgroundJobStatus::Running { + header.add_child(ChildView::new(&self.cancel_button).finish()); + } + } + detail.add_child(header.finish()); + let Some(job) = selected else { + detail.add_child( + Text::new( + "Select a job to view its command, status, and logs.", + appearance.ui_font_family(), + appearance.ui_font_size(), + ) + .finish(), + ); + return detail.finish(); + }; + let label = |text: String| { + Text::new( + text, + appearance.ui_font_family(), + appearance.ui_font_size() - 1., + ) + .with_color(theme.sub_text_color(theme.surface_2()).into()) + .finish() + }; + detail.add_child(label(format!( + "{} · Started {}", + job.working_directory.display(), + job.started_at + .with_timezone(&chrono::Local) + .format("%b %d, %H:%M:%S") + ))); + let command = Text::new( + job.command.clone(), + appearance.monospace_font_family(), + appearance.ui_font_size(), + ) + .soft_wrap(true) + .with_color(theme.active_ui_text_color().into()) + .finish(); + let command = ClippedScrollable::vertical( + self.command_scroll.clone(), + command, + ScrollbarWidth::Auto, + theme.nonactive_ui_detail().into(), + theme.active_ui_detail().into(), + galaxyui::elements::Fill::None, + ) + .finish(); + detail.add_child( + Container::new(ConstrainedBox::new(command).with_max_height(120.).finish()) + .with_padding(Padding::uniform(12.)) + .with_background(theme.surface_2()) + .with_border(Border::all(1.).with_border_color(theme.outline().into())) + .finish(), + ); + if let Some(error) = self.error.as_ref().or(job.error.as_ref()) { + detail.add_child( + Text::new( + error.clone(), + appearance.ui_font_family(), + appearance.ui_font_size(), + ) + .with_color(theme.ui_error_color().into()) + .finish(), + ); + } + detail.add_child( + Flex::row() + .with_main_axis_size(MainAxisSize::Max) + .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) + .with_child( + Text::new( + "Output", + appearance.ui_font_family(), + appearance.ui_font_size(), + ) + .with_style(Properties::default().weight(Weight::Semibold)) + .with_color(theme.active_ui_text_color().into()) + .finish(), + ) + .with_child(label("Select text to copy".to_owned())) + .finish(), + ); + let logs = ClippedScrollable::vertical( + self.logs_scroll.clone(), + Container::new(ChildView::new(&self.logs).finish()) + .with_padding(Padding::uniform(12.)) + .finish(), + ScrollbarWidth::Auto, + theme.nonactive_ui_detail().into(), + theme.active_ui_detail().into(), + galaxyui::elements::Fill::None, + ) + .finish(); + detail.add_child( + Expanded::new( + 1., + Container::new(logs) + .with_background(theme.background()) + .with_border(Border::all(1.).with_border_color(theme.outline().into())) + .finish(), + ) + .finish(), + ); + detail.finish() + } } pub fn status_icon(status: &BackgroundJobStatus, app: &AppContext) -> Box { @@ -744,25 +955,22 @@ impl View for BackgroundJobsView { } fn render(&self, app: &AppContext) -> Box { + if self.logs_open { + return self.render_detail(app); + } let appearance = Appearance::as_ref(app); let theme = appearance.theme(); - let mut filters = Flex::row() + let mut scopes = Flex::row() .with_spacing(4.) .with_cross_axis_alignment(CrossAxisAlignment::Center); for (_, button) in &self.scope_buttons { - filters.add_child(ChildView::new(button).finish()); + scopes.add_child(ChildView::new(button).finish()); } - filters.add_child( - Shrinkable::new( - 1., - Container::new(Clipped::new(ChildView::new(&self.search).finish()).finish()) - .with_horizontal_padding(8.) - .with_vertical_padding(6.) - .with_border(Border::all(1.).with_border_color(theme.outline().into())) - .finish(), - ) - .finish(), - ); + let search = Container::new(Clipped::new(ChildView::new(&self.search).finish()).finish()) + .with_horizontal_padding(10.) + .with_vertical_padding(8.) + .with_border(Border::all(1.).with_border_color(theme.outline().into())) + .finish(); let mut statuses = Flex::row().with_spacing(4.); for (_, button) in &self.status_buttons { statuses.add_child(ChildView::new(button).finish()); @@ -774,27 +982,33 @@ impl View for BackgroundJobsView { } .map(|path| path.display().to_string()) .unwrap_or_else(|| "All saved projects and directories".to_owned()); + let count = self.filtered_jobs(app).len(); let mut column = Flex::column() .with_cross_axis_alignment(CrossAxisAlignment::Stretch) - .with_child(filters.finish()) + .with_main_axis_size(MainAxisSize::Max) + .with_spacing(10.) + .with_child( + Flex::row() + .with_spacing(16.) + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_child(scopes.finish()) + .with_child(Expanded::new(1., search).finish()) + .finish(), + ) .with_child(statuses.finish()) .with_child( - Container::new( - Text::new_inline( - scope_text, - appearance.ui_font_family(), - appearance.ui_font_size() - 2., - ) - .with_color(theme.sub_text_color(theme.background()).into()) - .with_clip(ClipConfig::ellipsis()) - .finish(), + Text::new_inline( + scope_text, + appearance.ui_font_family(), + appearance.ui_font_size() - 1., ) - .with_padding(Padding::uniform(8.)) + .with_color(theme.sub_text_color(theme.background()).into()) + .with_clip(ClipConfig::ellipsis()) .finish(), ); if let Some(error) = &self.error { column.add_child( - Text::new_inline( + Text::new( error.clone(), appearance.ui_font_family(), appearance.ui_font_size(), @@ -803,172 +1017,23 @@ impl View for BackgroundJobsView { .finish(), ); } - - if !self.logs_open { - let label = |text| { + column.add_child(Expanded::new(1., self.render_list(app)).finish()); + let mut footer = Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) + .with_child( Text::new_inline( - text, + format!("{} of {count} jobs", self.display_limit.min(count)), appearance.ui_font_family(), - appearance.ui_font_size() - 2., + appearance.ui_font_size() - 1., ) .with_color(theme.sub_text_color(theme.background()).into()) - .finish() - }; - column.add_child( - Container::new( - Flex::row() - .with_cross_axis_alignment(CrossAxisAlignment::Center) - .with_child(ConstrainedBox::new(label("")).with_width(24.).finish()) - .with_child(Shrinkable::new(1., label("Command / project")).finish()) - .with_child( - ConstrainedBox::new(label("Started")) - .with_width(120.) - .finish(), - ) - .with_child( - ConstrainedBox::new(label("Status")) - .with_width(135.) - .finish(), - ) - .with_child( - ConstrainedBox::new(label("Actions")) - .with_width(180.) - .finish(), - ) - .finish(), - ) - .with_horizontal_padding(12.) - .with_vertical_padding(6.) .finish(), ); - column.add_child( - ConstrainedBox::new(self.render_list(app)) - .with_height(470.) - .finish(), - ); - let count = self.filtered_jobs(app).len(); - if count > self.display_limit { - column.add_child( - Flex::row() - .with_cross_axis_alignment(CrossAxisAlignment::Center) - .with_child(ChildView::new(&self.more_button).finish()) - .with_child( - Text::new_inline( - format!("Showing {} of {count} jobs", self.display_limit), - appearance.ui_font_family(), - appearance.ui_font_size() - 2., - ) - .with_color(theme.sub_text_color(theme.background()).into()) - .finish(), - ) - .finish(), - ); - } - return column.finish(); + if count > self.display_limit { + footer.add_child(ChildView::new(&self.more_button).finish()); } - let selected = self - .selected - .and_then(|id| BackgroundJobsModel::as_ref(app).job(id)); - let mut detail = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch); - detail.add_child(ChildView::new(&self.back_button).finish()); - if let Some(job) = selected { - let status = status_label(job); - detail.add_child( - Flex::row() - .with_cross_axis_alignment(CrossAxisAlignment::Center) - .with_spacing(8.) - .with_child(status_icon(&job.status, app)) - .with_child( - Text::new_inline( - status, - appearance.ui_font_family(), - appearance.ui_font_size(), - ) - .with_style(Properties::default().weight(Weight::Semibold)) - .with_color(theme.active_ui_text_color().into()) - .finish(), - ) - .finish(), - ); - detail.add_child( - Container::new( - Text::new_inline( - job.command.clone(), - appearance.monospace_font_family(), - appearance.ui_font_size(), - ) - .with_color(theme.active_ui_text_color().into()) - .finish(), - ) - .with_vertical_padding(8.) - .finish(), - ); - detail.add_child( - Text::new_inline( - job.working_directory.display().to_string(), - appearance.ui_font_family(), - appearance.ui_font_size() - 2., - ) - .with_color(theme.sub_text_color(theme.background()).into()) - .with_clip(ClipConfig::ellipsis()) - .finish(), - ); - let mut buttons = Flex::row() - .with_main_axis_alignment(MainAxisAlignment::End) - .with_spacing(8.); - if BackgroundJobsModel::as_ref(app).logs_available(job.id) { - buttons.add_child(ChildView::new(&self.reload_button).finish()); - } - if job.status == BackgroundJobStatus::Running { - buttons.add_child(ChildView::new(&self.cancel_button).finish()); - } - detail.add_child( - Container::new(buttons.finish()) - .with_vertical_padding(8.) - .finish(), - ); - if let Some(error) = &job.error { - detail.add_child( - Text::new_inline( - error.clone(), - appearance.ui_font_family(), - appearance.ui_font_size(), - ) - .with_color(theme.ui_error_color().into()) - .finish(), - ); - } - detail.add_child( - ConstrainedBox::new( - ClippedScrollable::vertical( - self.logs_scroll.clone(), - ChildView::new(&self.logs).finish(), - ScrollbarWidth::Auto, - theme.nonactive_ui_detail().into(), - theme.active_ui_detail().into(), - galaxyui::elements::Fill::None, - ) - .finish(), - ) - .with_height(340.) - .finish(), - ); - } else { - detail.add_child( - Text::new_inline( - "Select a job to view its command, status, and logs.", - appearance.ui_font_family(), - appearance.ui_font_size(), - ) - .with_color(theme.sub_text_color(theme.background()).into()) - .finish(), - ); - } - column.add_child( - Container::new(detail.finish()) - .with_padding(Padding::uniform(12.)) - .finish(), - ); + column.add_child(footer.finish()); column.finish() } } @@ -992,17 +1057,24 @@ impl TypedActionView for BackgroundJobsView { } BackgroundJobsAction::Select(id) => { self.selected = Some(*id); + self.logs_open = true; + self.logs_scroll = Default::default(); + self.command_scroll = Default::default(); self.synchronize(ctx); + ctx.focus(&self.logs); } BackgroundJobsAction::ViewLogs(id) => { self.selected = Some(*id); self.logs_open = true; - self.load_selected_logs(ctx); - ctx.notify(); + self.logs_scroll = Default::default(); + self.command_scroll = Default::default(); + self.synchronize(ctx); + ctx.focus(&self.logs); } BackgroundJobsAction::Back => { self.logs_open = false; self.synchronize(ctx); + ctx.focus(&self.search); } BackgroundJobsAction::Cancel(id) => { self.error = BackgroundJobsModel::handle(ctx) diff --git a/app/src/ai/blocklist/inline_action/requested_command.rs b/app/src/ai/blocklist/inline_action/requested_command.rs index 39283d75..728e754a 100644 --- a/app/src/ai/blocklist/inline_action/requested_command.rs +++ b/app/src/ai/blocklist/inline_action/requested_command.rs @@ -1199,33 +1199,39 @@ impl RequestedCommandView { None => "Job history unavailable", }; let mut row = Flex::row() + .with_main_axis_size(MainAxisSize::Max) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_spacing(8.); row.add_child(crate::ai::background_jobs::view::status_icon(&status, app)); row.add_child( Expanded::new( 1., - Text::new_inline( - command.to_owned(), - appearance.monospace_font_family(), - appearance.monospace_font_size() - 1., + Text::new( + label.to_owned(), + appearance.ui_font_family(), + appearance.monospace_font_size() - 2., ) - .with_color(theme.font_color(theme.surface_2()).into()) - .with_clip(galaxyui::text_layout::ClipConfig::ellipsis()) + .with_color(theme.sub_text_color(theme.surface_2()).into()) .finish(), ) .finish(), ); - row.add_child( - Text::new_inline( - label.to_owned(), - appearance.ui_font_family(), - appearance.monospace_font_size() - 2., + let content = Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_spacing(8.) + .with_child(row.finish()) + .with_child( + Text::new( + command.to_owned(), + appearance.monospace_font_family(), + appearance.monospace_font_size() - 1., + ) + .soft_wrap(true) + .with_color(theme.font_color(theme.surface_2()).into()) + .finish(), ) - .with_color(theme.sub_text_color(theme.surface_2()).into()) - .finish(), - ); - let content = Container::new(row.finish()) + .finish(); + let content = Container::new(content) .with_padding(Padding::uniform(12.)) .finish(); let content = if job.is_some() { @@ -1241,7 +1247,7 @@ impl RequestedCommandView { } else { content }; - render_tool_pane_shell(content, false, true, false, app) + render_tool_pane_shell(content, false, false, false, app) } fn render_header(