Files
Ely-GPUI-Components/src/files/operation.rs
T
ZacharyZhang-NYandClaude Opus 5.5 5779daef82 Capture · Roots: roots drop w_full; hosts give the width
145 component roots drop w_full, the early returns included. Three
field rows keep it, Input, InputGroup and TagInput: stretched without
a width, taffy 0.9 laid the field's flex_1 share at no width in the
template picker at 280px, and the placeholder drew nothing. Container
keeps it, since mx_auto needs a width to fill.

Hosts that relied on a root's width now give it: MessageList boxes
each row at the list's width, the splash gives its bar the column's,
and test stages that mount a component as the window's root sit in a
size_full box. Tests hold a narrow row across the list and the splash
bar across its column; each goes red without its fix.

The gallery gains --narrow <px>, which lays each page in a padded
card at that width and shoots it without its script. The page
header wraps: the title takes the room and the theme switcher drops
below, where a long summary used to push it past the edge. A file
panel step waits for the panel to become key instead of a fixed
pause, which lost a race in a capture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:07:22 -04:00

243 lines
8.4 KiB
Rust

use std::rc::Rc;
use gpui::{
App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled,
Window, div, prelude::*,
};
use super::transfer::TransferState;
use crate::{
buttons::{Button, ButtonVariant},
forms::Run,
motion::ProgressBar,
primitives::{Icon, IconName, Severity},
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
typography::{DurationStyle, Ellipsis, MiddleEllipsis, format},
};
/// What an operation does to files.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FileOperation {
Copy,
Move,
}
/// What an operation says of itself, by what it does and where it stands.
pub(crate) fn headline(
operation: FileOperation,
items: usize,
to: &str,
state: &TransferState,
) -> String {
let things = match items {
1 => "1 item".to_string(),
count => format!("{count} items"),
};
let (doing, done) = match operation {
FileOperation::Copy => ("Copying", "Copied"),
FileOperation::Move => ("Moving", "Moved"),
};
match state {
TransferState::Queued => format!("{doing} {things} to {to} next"),
TransferState::Moving { .. } => format!("{doing} {things} to {to}"),
TransferState::Paused => format!("{doing} {things} to {to}, paused"),
TransferState::Done => format!("{done} {things} to {to}"),
TransferState::Failed(_) => format!("{doing} {things} to {to} stopped"),
}
}
/// A copy or a move under way: what it does and where to, a bar, the bytes done of all with the time left, and the file it takes now. Pause or resume and cancel while it runs; a failure says why in the danger tone.
#[derive(IntoElement)]
pub struct FileOperationProgress {
id: ElementId,
operation: FileOperation,
items: usize,
to: SharedString,
moved: u64,
total: u64,
state: TransferState,
current: Option<SharedString>,
on_pause: Option<Run>,
on_resume: Option<Run>,
on_cancel: Option<Run>,
}
impl FileOperationProgress {
/// `items` things to `to`, `moved` bytes of `total` so far.
pub fn new(
id: impl Into<ElementId>,
operation: FileOperation,
items: usize,
to: impl Into<SharedString>,
(moved, total): (u64, u64),
state: TransferState,
) -> Self {
assert!(
items > 0 && moved <= total,
"{items} items, {moved} of {total} bytes"
);
Self {
id: id.into(),
operation,
items,
to: to.into(),
moved,
total,
state,
current: None,
on_pause: None,
on_resume: None,
on_cancel: None,
}
}
/// The file it takes now.
pub fn current(mut self, name: impl Into<SharedString>) -> Self {
self.current = Some(name.into());
self
}
pub fn on_pause(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_pause = Some(Rc::new(handler));
self
}
pub fn on_resume(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_resume = Some(Rc::new(handler));
self
}
pub fn on_cancel(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_cancel = Some(Rc::new(handler));
self
}
}
impl RenderOnce for FileOperationProgress {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = &theme.colors;
let small = theme.text_size(TextSize::Sm);
let share = if self.total == 0 {
1.0
} else {
self.moved as f32 / self.total as f32
};
let (moved, total) = (
format::file_size(self.moved, false),
format::file_size(self.total, false),
);
let detail = match &self.state {
TransferState::Moving { rate } if *rate > 0 => {
let left = self.total.saturating_sub(self.moved) / rate;
format!(
"{moved} of {total} · {} left",
format::duration(left.max(1), DurationStyle::Compact)
)
}
TransferState::Failed(reason) => reason.to_string(),
TransferState::Done => total,
_ => format!("{moved} of {total}"),
};
let button = |key: &'static str, label: &'static str, run: Option<Run>| {
run.map(|run| {
Button::new((self.id.clone(), key), label)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| {
log::info!("file operation: {key}");
run(window, cx)
})
})
};
let (pause, resume, cancel) = match self.state {
TransferState::Moving { .. } => (self.on_pause, None, self.on_cancel),
TransferState::Paused => (None, self.on_resume, self.on_cancel),
TransferState::Queued => (None, None, self.on_cancel),
_ => (None, None, None),
};
let failed = matches!(self.state, TransferState::Failed(_));
let icon = match (&self.state, self.operation) {
(TransferState::Failed(_), _) => Severity::Danger.icon(),
(TransferState::Done, _) => Severity::Success.icon(),
(_, FileOperation::Copy) => IconName::Copy,
(_, FileOperation::Move) => IconName::FolderOpen,
};
let tone = match &self.state {
TransferState::Failed(_) => Severity::Danger.color(colors),
TransferState::Done => Severity::Success.color(colors),
_ => colors.fg_muted,
};
let running = matches!(
self.state,
TransferState::Moving { .. } | TransferState::Paused
);
div()
.debug_selector(|| "file-operation".into())
.flex()
.flex_col()
.gap_2()
.p_4()
.rounded(theme.radius(Radius::Lg))
.border_1()
.border_color(colors.border)
.child(
div()
.flex()
.flex_wrap()
.items_center()
.justify_between()
.gap_2()
.child(
div()
.flex_1()
.min_w(theme.label_width())
.flex()
.items_center()
.gap_3()
.child(Icon::new(icon).size(IconSize::Lg).color(tone))
.child(
div()
.flex_1()
.min_w_0()
.font_weight(FontWeight::MEDIUM)
.text_color(colors.fg)
.child(Ellipsis::new(headline(
self.operation,
self.items,
&self.to,
&self.state,
))),
),
)
.child(
div()
.flex_none()
.flex()
.children(button("pause", "Pause", pause))
.children(button("resume", "Resume", resume))
.children(button("cancel", "Cancel", cancel)),
),
)
.when(running, |card| {
card.child(ProgressBar::new((self.id.clone(), "bar"), share.min(1.0)))
})
.child(
div()
.text_size(small)
.text_color(if failed {
colors.danger
} else {
colors.fg_muted
})
.child(Ellipsis::new(detail)),
)
.children(self.current.filter(|_| running).map(|name| {
div()
.text_size(small)
.text_color(colors.fg_subtle)
.child(MiddleEllipsis::new(name))
}))
}
}