Improve background job cards and log browser

This commit is contained in:
Ryan Ward
2026-10-30 05:01:05 +00:00
parent 06c25af731
commit f9c1541fdb
4 changed files with 741 additions and 314 deletions
+348
View File
@@ -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<ByteOffset>, 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<ByteOffset>, TextStyle)>,
line: Vec<LogCell>,
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(&parameter[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<ColorU> {
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))
);
}
}
+1
View File
@@ -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;
+370 -298
View File
@@ -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<ActionButton>,
@@ -86,8 +88,11 @@ pub struct BackgroundJobsView {
back_button: ViewHandle<ActionButton>,
more_button: ViewHandle<ActionButton>,
list_scroll: ClippedScrollStateHandle,
table_state: TableStateHandle,
logs_scroll: ClippedScrollStateHandle,
command_scroll: ClippedScrollStateHandle,
displayed_logs: String,
displayed_log_colors: Option<TerminalColors>,
error: Option<String>,
}
@@ -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<Self>) {
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<dyn Element> {
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::<Vec<_>>();
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<dyn Element> {
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<dyn Element> {
@@ -744,25 +955,22 @@ impl View for BackgroundJobsView {
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
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)
@@ -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(