Improve background job cards and log browser
This commit is contained in:
@@ -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(¶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<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,5 +1,6 @@
|
|||||||
//! Agent-requested commands run independently of a conversation's current model turn.
|
//! Agent-requested commands run independently of a conversation's current model turn.
|
||||||
|
|
||||||
|
mod log_format;
|
||||||
pub(crate) mod remote;
|
pub(crate) mod remote;
|
||||||
mod runtime;
|
mod runtime;
|
||||||
pub mod view;
|
pub mod view;
|
||||||
|
|||||||
+338
-266
@@ -6,14 +6,15 @@ use std::sync::LazyLock;
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use galaxy_core::ui::icons::Icon;
|
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_core::HostId;
|
||||||
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
|
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
|
||||||
use galaxy_util::standardized_path::StandardizedPath;
|
use galaxy_util::standardized_path::StandardizedPath;
|
||||||
use galaxyui::elements::{
|
use galaxyui::elements::{
|
||||||
Border, ChildView, Clipped, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox,
|
Border, ChildView, Clipped, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox,
|
||||||
Container, CrossAxisAlignment, Flex, Hoverable, MainAxisAlignment, MouseStateHandle, Padding,
|
Container, CrossAxisAlignment, Expanded, Flex, Hoverable, MainAxisAlignment, MainAxisSize,
|
||||||
ParentElement, Point, ScrollbarWidth, Shrinkable, Text,
|
MouseStateHandle, Padding, ParentElement, Point, RowBackground, ScrollbarWidth, Shrinkable,
|
||||||
|
Table, TableColumnWidth, TableConfig, TableHeader, TableStateHandle, TableVerticalSizing, Text,
|
||||||
};
|
};
|
||||||
use galaxyui::event::DispatchedEvent;
|
use galaxyui::event::DispatchedEvent;
|
||||||
use galaxyui::fonts::{Properties, Weight};
|
use galaxyui::fonts::{Properties, Weight};
|
||||||
@@ -30,8 +31,8 @@ use uuid::Uuid;
|
|||||||
use super::{BackgroundJob, BackgroundJobStatus, BackgroundJobsModel};
|
use super::{BackgroundJob, BackgroundJobStatus, BackgroundJobsModel};
|
||||||
use crate::appearance::Appearance;
|
use crate::appearance::Appearance;
|
||||||
use crate::editor::{
|
use crate::editor::{
|
||||||
EditorOptions, EditorView, Event as EditorEvent, InteractionState, SingleLineEditorOptions,
|
EditorOptions, EditorView, Event as EditorEvent, InteractionState, PlainTextEditorViewAction,
|
||||||
TextOptions,
|
SingleLineEditorOptions, TextColors, TextOptions,
|
||||||
};
|
};
|
||||||
use crate::modal::ModalAction;
|
use crate::modal::ModalAction;
|
||||||
use crate::view_components::action_button::{
|
use crate::view_components::action_button::{
|
||||||
@@ -58,6 +59,7 @@ pub enum BackgroundJobsAction {
|
|||||||
LoadMore,
|
LoadMore,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
struct JobRowControls {
|
struct JobRowControls {
|
||||||
mouse: MouseStateHandle,
|
mouse: MouseStateHandle,
|
||||||
view_logs: ViewHandle<ActionButton>,
|
view_logs: ViewHandle<ActionButton>,
|
||||||
@@ -86,8 +88,11 @@ pub struct BackgroundJobsView {
|
|||||||
back_button: ViewHandle<ActionButton>,
|
back_button: ViewHandle<ActionButton>,
|
||||||
more_button: ViewHandle<ActionButton>,
|
more_button: ViewHandle<ActionButton>,
|
||||||
list_scroll: ClippedScrollStateHandle,
|
list_scroll: ClippedScrollStateHandle,
|
||||||
|
table_state: TableStateHandle,
|
||||||
logs_scroll: ClippedScrollStateHandle,
|
logs_scroll: ClippedScrollStateHandle,
|
||||||
|
command_scroll: ClippedScrollStateHandle,
|
||||||
displayed_logs: String,
|
displayed_logs: String,
|
||||||
|
displayed_log_colors: Option<TerminalColors>,
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +109,7 @@ impl BackgroundJobsView {
|
|||||||
_ => {}
|
_ => {}
|
||||||
});
|
});
|
||||||
let logs_font = Appearance::as_ref(ctx).monospace_font_family();
|
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 logs = ctx.add_typed_action_view(|ctx| {
|
||||||
let mut editor = EditorView::new(
|
let mut editor = EditorView::new(
|
||||||
EditorOptions {
|
EditorOptions {
|
||||||
@@ -116,6 +121,7 @@ impl BackgroundJobsView {
|
|||||||
autogrow: true,
|
autogrow: true,
|
||||||
soft_wrap: true,
|
soft_wrap: true,
|
||||||
supports_vim_mode: false,
|
supports_vim_mode: false,
|
||||||
|
use_settings_line_height_ratio: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
ctx,
|
ctx,
|
||||||
@@ -123,6 +129,11 @@ impl BackgroundJobsView {
|
|||||||
editor.set_interaction_state(InteractionState::Selectable, ctx);
|
editor.set_interaction_state(InteractionState::Selectable, ctx);
|
||||||
editor
|
editor
|
||||||
});
|
});
|
||||||
|
ctx.subscribe_to_view(&logs, |_, _, event, ctx| {
|
||||||
|
if matches!(event, EditorEvent::Escape) {
|
||||||
|
ctx.dispatch_typed_action(&ModalAction::Close);
|
||||||
|
}
|
||||||
|
});
|
||||||
let scope_buttons = [
|
let scope_buttons = [
|
||||||
(JobScopeFilter::Project, "Current project"),
|
(JobScopeFilter::Project, "Current project"),
|
||||||
(JobScopeFilter::Directory, "Current directory"),
|
(JobScopeFilter::Directory, "Current directory"),
|
||||||
@@ -187,6 +198,12 @@ impl BackgroundJobsView {
|
|||||||
me.synchronize(ctx);
|
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 {
|
Self {
|
||||||
working_directory: None,
|
working_directory: None,
|
||||||
@@ -209,8 +226,11 @@ impl BackgroundJobsView {
|
|||||||
back_button,
|
back_button,
|
||||||
more_button,
|
more_button,
|
||||||
list_scroll: Default::default(),
|
list_scroll: Default::default(),
|
||||||
|
table_state: TableStateHandle::new(0, |_, _| Vec::new()),
|
||||||
logs_scroll: Default::default(),
|
logs_scroll: Default::default(),
|
||||||
|
command_scroll: Default::default(),
|
||||||
displayed_logs: String::new(),
|
displayed_logs: String::new(),
|
||||||
|
displayed_log_colors: None,
|
||||||
error: None,
|
error: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -246,10 +266,13 @@ impl BackgroundJobsView {
|
|||||||
};
|
};
|
||||||
self.status = None;
|
self.status = None;
|
||||||
self.selected = selected;
|
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.visible = true;
|
||||||
self.display_limit = 100;
|
self.display_limit = 100;
|
||||||
self.error = None;
|
self.error = None;
|
||||||
|
self.logs_scroll = Default::default();
|
||||||
|
self.command_scroll = Default::default();
|
||||||
self.search.update(ctx, |editor, ctx| {
|
self.search.update(ctx, |editor, ctx| {
|
||||||
editor.clear_buffer_and_reset_undo_stack(ctx)
|
editor.clear_buffer_and_reset_undo_stack(ctx)
|
||||||
});
|
});
|
||||||
@@ -269,8 +292,12 @@ impl BackgroundJobsView {
|
|||||||
}
|
}
|
||||||
self.synchronize(ctx);
|
self.synchronize(ctx);
|
||||||
self.schedule_log_availability_refresh(ctx);
|
self.schedule_log_availability_refresh(ctx);
|
||||||
|
if self.logs_open {
|
||||||
|
ctx.focus(&self.logs);
|
||||||
|
} else {
|
||||||
ctx.focus(&self.search);
|
ctx.focus(&self.search);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn on_close(&mut self) {
|
pub fn on_close(&mut self) {
|
||||||
self.visible = false;
|
self.visible = false;
|
||||||
@@ -420,11 +447,36 @@ impl BackgroundJobsView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn set_logs(&mut self, text: String, ctx: &mut ViewContext<Self>) {
|
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| {
|
self.logs.update(ctx, |editor, ctx| {
|
||||||
editor.set_buffer_text_ignoring_undo(&text, 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| {
|
||||||
|
// 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_logs = text;
|
||||||
|
self.displayed_log_colors = Some(colors);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,12 +492,14 @@ impl BackgroundJobsView {
|
|||||||
fn render_list(&self, app: &AppContext) -> Box<dyn Element> {
|
fn render_list(&self, app: &AppContext) -> Box<dyn Element> {
|
||||||
let appearance = Appearance::as_ref(app);
|
let appearance = Appearance::as_ref(app);
|
||||||
let theme = appearance.theme();
|
let theme = appearance.theme();
|
||||||
let mut list = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
|
let jobs = self
|
||||||
let jobs = self.filtered_jobs(app);
|
.filtered_jobs(app)
|
||||||
|
.into_iter()
|
||||||
|
.take(self.display_limit)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
if jobs.is_empty() {
|
if jobs.is_empty() {
|
||||||
list.add_child(
|
return Container::new(
|
||||||
Container::new(
|
Text::new(
|
||||||
Text::new_inline(
|
|
||||||
"No background jobs match these filters.",
|
"No background jobs match these filters.",
|
||||||
appearance.ui_font_family(),
|
appearance.ui_font_family(),
|
||||||
appearance.ui_font_size(),
|
appearance.ui_font_size(),
|
||||||
@@ -453,31 +507,50 @@ impl BackgroundJobsView {
|
|||||||
.with_color(theme.sub_text_color(theme.background()).into())
|
.with_color(theme.sub_text_color(theme.background()).into())
|
||||||
.finish(),
|
.finish(),
|
||||||
)
|
)
|
||||||
.with_padding(Padding::uniform(16.))
|
.with_padding(Padding::uniform(24.))
|
||||||
.finish(),
|
.finish();
|
||||||
);
|
|
||||||
}
|
}
|
||||||
for id in jobs.into_iter().take(self.display_limit) {
|
let headers = [
|
||||||
let Some(job) = BackgroundJobsModel::as_ref(app).job(id) else {
|
("Command", TableColumnWidth::Flex(1.5)),
|
||||||
continue;
|
("Directory", TableColumnWidth::Flex(1.)),
|
||||||
};
|
("Started", TableColumnWidth::Fixed(126.)),
|
||||||
let selected = self.selected == Some(job.id);
|
("Status", TableColumnWidth::Fixed(156.)),
|
||||||
let mut row = Flex::row()
|
("Actions", TableColumnWidth::Fixed(216.)),
|
||||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
]
|
||||||
.with_child(
|
.into_iter()
|
||||||
Container::new(status_icon(&job.status, app))
|
.map(|(label, width)| {
|
||||||
.with_margin_right(8.)
|
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(),
|
.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
|
let command = job
|
||||||
.command
|
.command
|
||||||
.lines()
|
.lines()
|
||||||
.next()
|
.next()
|
||||||
.unwrap_or(&job.command)
|
.unwrap_or(&job.command)
|
||||||
.to_owned();
|
.to_owned();
|
||||||
let mut text = Flex::column()
|
let command_cell = Hoverable::new(controls.mouse.clone(), move |_| {
|
||||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
|
||||||
.with_child(
|
|
||||||
Text::new_inline(
|
Text::new_inline(
|
||||||
command,
|
command,
|
||||||
appearance.monospace_font_family(),
|
appearance.monospace_font_family(),
|
||||||
@@ -485,89 +558,80 @@ impl BackgroundJobsView {
|
|||||||
)
|
)
|
||||||
.with_color(theme.active_ui_text_color().into())
|
.with_color(theme.active_ui_text_color().into())
|
||||||
.with_clip(ClipConfig::ellipsis())
|
.with_clip(ClipConfig::ellipsis())
|
||||||
.finish(),
|
.finish()
|
||||||
);
|
})
|
||||||
let location = match &job.host_id {
|
.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()),
|
Some(host) => format!("{host}: {}", job.working_directory.display()),
|
||||||
None => job.working_directory.display().to_string(),
|
None => job.working_directory.display().to_string(),
|
||||||
};
|
};
|
||||||
text.add_child(
|
let directory = Text::new_inline(
|
||||||
Text::new_inline(
|
directory,
|
||||||
location,
|
|
||||||
appearance.ui_font_family(),
|
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())
|
.with_clip(ClipConfig::ellipsis())
|
||||||
.finish(),
|
.finish();
|
||||||
);
|
let started = Text::new(
|
||||||
row.add_child(Shrinkable::new(1., text.finish()).finish());
|
|
||||||
row.add_child(
|
|
||||||
ConstrainedBox::new(
|
|
||||||
Text::new_inline(
|
|
||||||
job.started_at
|
job.started_at
|
||||||
.with_timezone(&chrono::Local)
|
.with_timezone(&chrono::Local)
|
||||||
.format("%b %d, %H:%M")
|
.format("%b %d, %H:%M")
|
||||||
.to_string(),
|
.to_string(),
|
||||||
appearance.ui_font_family(),
|
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())
|
||||||
.finish(),
|
.finish();
|
||||||
)
|
let status = Flex::row()
|
||||||
.with_width(120.)
|
.with_spacing(6.)
|
||||||
.finish(),
|
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||||
);
|
.with_child(status_icon(&job.status, app))
|
||||||
if let Some(controls) = self.rows.get(&id) {
|
.with_child(
|
||||||
row.add_child(
|
Shrinkable::new(
|
||||||
ConstrainedBox::new(
|
1.,
|
||||||
Text::new_inline(
|
Text::new(
|
||||||
status_label(&job),
|
status_label(job),
|
||||||
appearance.ui_font_family(),
|
appearance.ui_font_family(),
|
||||||
appearance.ui_font_size() - 1.,
|
appearance.ui_font_size() - 1.,
|
||||||
)
|
)
|
||||||
.with_color(theme.sub_text_color(theme.background()).into())
|
.with_color(theme.active_ui_text_color().into())
|
||||||
.with_clip(ClipConfig::ellipsis())
|
|
||||||
.finish(),
|
.finish(),
|
||||||
)
|
)
|
||||||
.with_width(135.)
|
|
||||||
.finish(),
|
.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) {
|
if controls.logs_available && BackgroundJobsModel::as_ref(app).logs_available(id) {
|
||||||
actions.add_child(ChildView::new(&controls.view_logs).finish());
|
actions.add_child(ChildView::new(&controls.view_logs).finish());
|
||||||
}
|
}
|
||||||
if job.status == BackgroundJobStatus::Running {
|
if job.status == BackgroundJobStatus::Running {
|
||||||
actions.add_child(ChildView::new(&controls.terminate).finish());
|
actions.add_child(ChildView::new(&controls.terminate).finish());
|
||||||
}
|
}
|
||||||
row.add_child(
|
vec![command_cell, directory, started, status, actions.finish()]
|
||||||
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, _, _| {
|
.with_config(TableConfig {
|
||||||
ctx.dispatch_typed_action(BackgroundJobsAction::Select(id))
|
border_width: 1.,
|
||||||
})
|
border_color: theme.outline().into(),
|
||||||
.with_cursor(Cursor::PointingHand)
|
outer_border: true,
|
||||||
.finish(),
|
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(
|
ClippedScrollable::vertical(
|
||||||
self.list_scroll.clone(),
|
self.list_scroll.clone(),
|
||||||
list.finish(),
|
table.finish(),
|
||||||
ScrollbarWidth::Auto,
|
ScrollbarWidth::Auto,
|
||||||
theme.nonactive_ui_detail().into(),
|
theme.nonactive_ui_detail().into(),
|
||||||
theme.active_ui_detail().into(),
|
theme.active_ui_detail().into(),
|
||||||
@@ -575,6 +639,153 @@ impl BackgroundJobsView {
|
|||||||
)
|
)
|
||||||
.finish()
|
.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> {
|
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> {
|
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||||
|
if self.logs_open {
|
||||||
|
return self.render_detail(app);
|
||||||
|
}
|
||||||
let appearance = Appearance::as_ref(app);
|
let appearance = Appearance::as_ref(app);
|
||||||
let theme = appearance.theme();
|
let theme = appearance.theme();
|
||||||
let mut filters = Flex::row()
|
let mut scopes = Flex::row()
|
||||||
.with_spacing(4.)
|
.with_spacing(4.)
|
||||||
.with_cross_axis_alignment(CrossAxisAlignment::Center);
|
.with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||||
for (_, button) in &self.scope_buttons {
|
for (_, button) in &self.scope_buttons {
|
||||||
filters.add_child(ChildView::new(button).finish());
|
scopes.add_child(ChildView::new(button).finish());
|
||||||
}
|
}
|
||||||
filters.add_child(
|
let search = Container::new(Clipped::new(ChildView::new(&self.search).finish()).finish())
|
||||||
Shrinkable::new(
|
.with_horizontal_padding(10.)
|
||||||
1.,
|
.with_vertical_padding(8.)
|
||||||
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()))
|
.with_border(Border::all(1.).with_border_color(theme.outline().into()))
|
||||||
.finish(),
|
.finish();
|
||||||
)
|
|
||||||
.finish(),
|
|
||||||
);
|
|
||||||
let mut statuses = Flex::row().with_spacing(4.);
|
let mut statuses = Flex::row().with_spacing(4.);
|
||||||
for (_, button) in &self.status_buttons {
|
for (_, button) in &self.status_buttons {
|
||||||
statuses.add_child(ChildView::new(button).finish());
|
statuses.add_child(ChildView::new(button).finish());
|
||||||
@@ -774,27 +982,33 @@ impl View for BackgroundJobsView {
|
|||||||
}
|
}
|
||||||
.map(|path| path.display().to_string())
|
.map(|path| path.display().to_string())
|
||||||
.unwrap_or_else(|| "All saved projects and directories".to_owned());
|
.unwrap_or_else(|| "All saved projects and directories".to_owned());
|
||||||
|
let count = self.filtered_jobs(app).len();
|
||||||
let mut column = Flex::column()
|
let mut column = Flex::column()
|
||||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
.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(statuses.finish())
|
||||||
.with_child(
|
.with_child(
|
||||||
Container::new(
|
|
||||||
Text::new_inline(
|
Text::new_inline(
|
||||||
scope_text,
|
scope_text,
|
||||||
appearance.ui_font_family(),
|
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.background()).into())
|
||||||
.with_clip(ClipConfig::ellipsis())
|
.with_clip(ClipConfig::ellipsis())
|
||||||
.finish(),
|
.finish(),
|
||||||
)
|
|
||||||
.with_padding(Padding::uniform(8.))
|
|
||||||
.finish(),
|
|
||||||
);
|
);
|
||||||
if let Some(error) = &self.error {
|
if let Some(error) = &self.error {
|
||||||
column.add_child(
|
column.add_child(
|
||||||
Text::new_inline(
|
Text::new(
|
||||||
error.clone(),
|
error.clone(),
|
||||||
appearance.ui_font_family(),
|
appearance.ui_font_family(),
|
||||||
appearance.ui_font_size(),
|
appearance.ui_font_size(),
|
||||||
@@ -803,172 +1017,23 @@ impl View for BackgroundJobsView {
|
|||||||
.finish(),
|
.finish(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
column.add_child(Expanded::new(1., self.render_list(app)).finish());
|
||||||
if !self.logs_open {
|
let mut footer = Flex::row()
|
||||||
let label = |text| {
|
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||||
|
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||||
|
.with_child(
|
||||||
Text::new_inline(
|
Text::new_inline(
|
||||||
text,
|
format!("{} of {count} jobs", self.display_limit.min(count)),
|
||||||
appearance.ui_font_family(),
|
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.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(),
|
.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 {
|
if count > self.display_limit {
|
||||||
column.add_child(
|
footer.add_child(ChildView::new(&self.more_button).finish());
|
||||||
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();
|
column.add_child(footer.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.finish()
|
column.finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -992,17 +1057,24 @@ impl TypedActionView for BackgroundJobsView {
|
|||||||
}
|
}
|
||||||
BackgroundJobsAction::Select(id) => {
|
BackgroundJobsAction::Select(id) => {
|
||||||
self.selected = Some(*id);
|
self.selected = Some(*id);
|
||||||
|
self.logs_open = true;
|
||||||
|
self.logs_scroll = Default::default();
|
||||||
|
self.command_scroll = Default::default();
|
||||||
self.synchronize(ctx);
|
self.synchronize(ctx);
|
||||||
|
ctx.focus(&self.logs);
|
||||||
}
|
}
|
||||||
BackgroundJobsAction::ViewLogs(id) => {
|
BackgroundJobsAction::ViewLogs(id) => {
|
||||||
self.selected = Some(*id);
|
self.selected = Some(*id);
|
||||||
self.logs_open = true;
|
self.logs_open = true;
|
||||||
self.load_selected_logs(ctx);
|
self.logs_scroll = Default::default();
|
||||||
ctx.notify();
|
self.command_scroll = Default::default();
|
||||||
|
self.synchronize(ctx);
|
||||||
|
ctx.focus(&self.logs);
|
||||||
}
|
}
|
||||||
BackgroundJobsAction::Back => {
|
BackgroundJobsAction::Back => {
|
||||||
self.logs_open = false;
|
self.logs_open = false;
|
||||||
self.synchronize(ctx);
|
self.synchronize(ctx);
|
||||||
|
ctx.focus(&self.search);
|
||||||
}
|
}
|
||||||
BackgroundJobsAction::Cancel(id) => {
|
BackgroundJobsAction::Cancel(id) => {
|
||||||
self.error = BackgroundJobsModel::handle(ctx)
|
self.error = BackgroundJobsModel::handle(ctx)
|
||||||
|
|||||||
@@ -1199,33 +1199,39 @@ impl RequestedCommandView {
|
|||||||
None => "Job history unavailable",
|
None => "Job history unavailable",
|
||||||
};
|
};
|
||||||
let mut row = Flex::row()
|
let mut row = Flex::row()
|
||||||
|
.with_main_axis_size(MainAxisSize::Max)
|
||||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||||
.with_spacing(8.);
|
.with_spacing(8.);
|
||||||
row.add_child(crate::ai::background_jobs::view::status_icon(&status, app));
|
row.add_child(crate::ai::background_jobs::view::status_icon(&status, app));
|
||||||
row.add_child(
|
row.add_child(
|
||||||
Expanded::new(
|
Expanded::new(
|
||||||
1.,
|
1.,
|
||||||
Text::new_inline(
|
Text::new(
|
||||||
command.to_owned(),
|
|
||||||
appearance.monospace_font_family(),
|
|
||||||
appearance.monospace_font_size() - 1.,
|
|
||||||
)
|
|
||||||
.with_color(theme.font_color(theme.surface_2()).into())
|
|
||||||
.with_clip(galaxyui::text_layout::ClipConfig::ellipsis())
|
|
||||||
.finish(),
|
|
||||||
)
|
|
||||||
.finish(),
|
|
||||||
);
|
|
||||||
row.add_child(
|
|
||||||
Text::new_inline(
|
|
||||||
label.to_owned(),
|
label.to_owned(),
|
||||||
appearance.ui_font_family(),
|
appearance.ui_font_family(),
|
||||||
appearance.monospace_font_size() - 2.,
|
appearance.monospace_font_size() - 2.,
|
||||||
)
|
)
|
||||||
.with_color(theme.sub_text_color(theme.surface_2()).into())
|
.with_color(theme.sub_text_color(theme.surface_2()).into())
|
||||||
.finish(),
|
.finish(),
|
||||||
|
)
|
||||||
|
.finish(),
|
||||||
);
|
);
|
||||||
let content = Container::new(row.finish())
|
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(),
|
||||||
|
)
|
||||||
|
.finish();
|
||||||
|
let content = Container::new(content)
|
||||||
.with_padding(Padding::uniform(12.))
|
.with_padding(Padding::uniform(12.))
|
||||||
.finish();
|
.finish();
|
||||||
let content = if job.is_some() {
|
let content = if job.is_some() {
|
||||||
@@ -1241,7 +1247,7 @@ impl RequestedCommandView {
|
|||||||
} else {
|
} else {
|
||||||
content
|
content
|
||||||
};
|
};
|
||||||
render_tool_pane_shell(content, false, true, false, app)
|
render_tool_pane_shell(content, false, false, false, app)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_header(
|
fn render_header(
|
||||||
|
|||||||
Reference in New Issue
Block a user