use eframe::egui::{self, CornerRadius, RichText, Sense, Stroke, vec2};
use crate::app::components::icons::{self, Icon};
use crate::app::components::widgets;
use crate::app::page::{Intent, PageCtx};
use crate::docker::{FileEntry, FileKind, FileOp, Preview, format_size, join_path, parent_path};
use crate::theme::Palette;
pub(super) struct Target<'a> {
pub container: &'a str,
pub root: &'a str,
pub writable: bool,
pub note: Option<&'a str>,
}
#[derive(Default)]
pub(super) struct FilesTab {
container: String,
root: String,
path: String,
selected: Option<String>,
via_link: Option<String>,
}
impl FilesTab {
fn follow(&mut self, container: &str, root: &str) {
if self.container != container || self.root != root {
*self = Self {
container: container.to_owned(),
root: root.to_owned(),
path: root.to_owned(),
selected: None,
via_link: None,
};
}
}
fn open(&mut self, path: String) {
self.path = path;
self.selected = None;
self.via_link = None;
}
fn select(&mut self, ctx: &mut PageCtx, path: String) {
ctx.request(Intent::Files(FileOp::Preview {
container: self.container.clone(),
path: path.clone(),
}));
self.selected = Some(path);
}
fn op_list(&self) -> FileOp {
FileOp::List {
container: self.container.clone(),
path: self.path.clone(),
}
}
}
pub(super) fn show(ui: &mut egui::Ui, ctx: &mut PageCtx, tab: &mut FilesTab, target: &Target) {
let p = ctx.palette;
tab.follow(target.container, target.root);
let view = ctx
.store
.files
.as_ref()
.filter(|v| v.container_id == target.container);
if view.is_none_or(|v| !v.shows(target.container, &tab.path)) {
ctx.request(Intent::Files(tab.op_list()));
}
if target.writable {
uploads(ui, ctx, tab);
}
toolbar(ui, ctx, tab, view.is_some_and(|v| v.transfers > 0));
ui.add_space(8.0);
let listing = view.and_then(|v| v.listing.as_ref());
let Some(listing) = listing else {
ui.add_space(20.0);
ui.vertical_centered(|ui| ui.spinner());
return;
};
let listing = match listing {
Ok(listing) => listing,
Err(_) if tab.via_link.as_deref() == Some(tab.path.as_str()) => {
let file = tab.path.clone();
tab.open(parent_path(&file));
tab.select(ctx, file);
return;
}
Err(message) => {
ui.label(RichText::new(message).color(p.danger));
return;
}
};
if let Some(note) = target.note {
ui.label(RichText::new(note).size(12.0).color(p.muted));
ui.add_space(4.0);
}
if !listing.complete {
ui.label(
RichText::new("This folder is too big to list completely.")
.size(12.0)
.color(p.warning),
);
}
let preview = tab.selected.is_some();
let list_height = if preview {
(ui.available_height() * 0.45).max(120.0)
} else {
ui.available_height() - 24.0
};
egui::Frame::new()
.fill(p.bg)
.stroke(Stroke::new(1.0, p.border))
.corner_radius(CornerRadius::same(8))
.inner_margin(egui::Margin::same(4))
.show(ui, |ui| {
egui::ScrollArea::vertical()
.id_salt(("files", &tab.path))
.max_height(list_height)
.auto_shrink([false, false])
.show(ui, |ui| {
if tab.path != tab.root && row(ui, p, None, false).clicked() {
tab.open(parent_path(&tab.path));
}
if listing.entries.is_empty() {
ui.label(RichText::new(" Empty folder").color(p.muted));
}
for entry in &listing.entries {
let path = join_path(&tab.path, &entry.name);
let selected = tab.selected.as_deref() == Some(path.as_str());
if row(ui, p, Some(entry), selected).clicked() {
match entry.kind {
FileKind::Dir => tab.open(path),
FileKind::Link => {
tab.open(path.clone());
tab.via_link = Some(path);
}
FileKind::File => tab.select(ctx, path),
FileKind::Other => {}
}
}
}
});
});
if let Some(path) = tab.selected.clone() {
ui.add_space(10.0);
preview_pane(ui, ctx, tab, &path);
} else if target.writable {
ui.add_space(6.0);
ui.label(
RichText::new("Drop files here to copy them into this folder.")
.size(12.0)
.color(p.muted),
);
}
}
fn toolbar(ui: &mut egui::Ui, ctx: &mut PageCtx, tab: &mut FilesTab, busy: bool) {
let p = ctx.palette;
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
let mut target = None;
let top = match tab.root.trim_matches('/') {
"" => "/",
name => name,
};
if crumb(ui, p, top, tab.path == tab.root).clicked() {
target = Some(tab.root.clone());
}
let below = tab
.path
.strip_prefix(tab.root.as_str())
.unwrap_or(&tab.path);
let parts: Vec<&str> = below.split('/').filter(|s| !s.is_empty()).collect();
for (i, part) in parts.iter().enumerate() {
if i > 0 || tab.root != "/" {
ui.label(RichText::new("/").color(p.muted));
}
let path = join_path(&tab.root, &parts[..=i].join("/"));
if crumb(ui, p, part, i + 1 == parts.len()).clicked() {
target = Some(path);
}
}
if let Some(path) = target {
tab.open(path);
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::icon_button(ui, Icon::Refresh, "List again").clicked() {
ctx.request(Intent::Files(tab.op_list()));
}
let download = widgets::icon_button(
ui,
Icon::Download,
"Download this folder to your Downloads folder",
);
if download.clicked() {
ctx.request(Intent::Files(FileOp::Download {
container: tab.container.clone(),
path: tab.path.clone(),
}));
}
if busy {
ui.spinner();
ui.label(RichText::new("Copying…").size(12.0).color(p.muted));
}
});
});
}
fn crumb(ui: &mut egui::Ui, p: &Palette, label: &str, current: bool) -> egui::Response {
let color = if current { p.text } else { p.accent };
ui.add(egui::Label::new(RichText::new(label).monospace().color(color)).sense(Sense::click()))
.on_hover_cursor(egui::CursorIcon::PointingHand)
}
fn row(
ui: &mut egui::Ui,
p: &Palette,
entry: Option<&FileEntry>,
selected: bool,
) -> egui::Response {
let height = 26.0;
let (rect, response) =
ui.allocate_exact_size(vec2(ui.available_width(), height), Sense::click());
let painter = ui.painter_at(rect);
if selected {
painter.rect_filled(rect, CornerRadius::same(6), p.accent.gamma_multiply(0.18));
} else if response.hovered() {
painter.rect_filled(rect, CornerRadius::same(6), p.raised);
}
let (icon, name, color) = match entry {
None => (Icon::Folder, "..".to_owned(), p.muted),
Some(e) => match e.kind {
FileKind::Dir => (Icon::Folder, e.name.clone(), p.text),
FileKind::Link => (
Icon::File,
format!("{} → {}", e.name, e.target.as_deref().unwrap_or("?")),
p.text,
),
FileKind::File => (Icon::File, e.name.clone(), p.text),
FileKind::Other => (Icon::File, e.name.clone(), p.muted),
},
};
let icon_color = match entry.map(|e| e.kind) {
Some(FileKind::Dir) | None => p.accent,
_ => p.muted,
};
icons::paint(
&painter,
egui::Rect::from_center_size(rect.left_center() + vec2(16.0, 0.0), vec2(15.0, 15.0)),
icon,
icon_color,
);
let font = egui::FontId::proportional(13.0);
let meta_font = egui::FontId::proportional(12.0);
let meta = entry
.map(|e| {
let size = match e.kind {
FileKind::File => format_size(e.size as i64),
_ => String::new(),
};
format!("{size:>9} {}", e.modified)
})
.unwrap_or_default();
let meta_galley = painter.layout_no_wrap(meta, meta_font, p.muted);
let meta_pos =
rect.right_center() - vec2(meta_galley.size().x + 10.0, meta_galley.size().y / 2.0);
let name_width = (meta_pos.x - rect.left() - 44.0).max(40.0);
let mut job =
egui::text::LayoutJob::single_section(name, egui::TextFormat::simple(font, color));
job.wrap = egui::text::TextWrapping::truncate_at_width(name_width);
let name_galley = painter.layout_job(job);
painter.galley(
rect.left_center() + vec2(32.0, -name_galley.size().y / 2.0),
name_galley,
color,
);
painter.galley(meta_pos, meta_galley, p.muted);
let label = entry.map_or("..", |e| e.name.as_str()).to_owned();
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &label));
response.on_hover_cursor(egui::CursorIcon::PointingHand)
}
fn preview_pane(ui: &mut egui::Ui, ctx: &mut PageCtx, tab: &mut FilesTab, path: &str) {
let p = ctx.palette;
let name = path.rsplit('/').next().unwrap_or(path);
ui.horizontal(|ui| {
ui.label(RichText::new(name).strong().color(p.text));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::icon_button(ui, Icon::Close, "Close the preview").clicked() {
tab.selected = None;
}
if widgets::button(ui, Icon::Download, "Download").clicked() {
ctx.request(Intent::Files(FileOp::Download {
container: tab.container.clone(),
path: path.to_owned(),
}));
}
});
});
ui.add_space(4.0);
let preview = ctx
.store
.files
.as_ref()
.and_then(|v| v.preview.as_ref())
.filter(|(asked, _)| asked == path)
.and_then(|(_, result)| result.as_ref());
let message = |ui: &mut egui::Ui, text: &str| {
ui.label(RichText::new(text).color(p.muted));
};
match preview {
None => {
ui.spinner();
}
Some(Err(e)) => {
ui.label(RichText::new(e).color(p.danger));
}
Some(Ok(Preview::Binary)) => message(ui, "A binary file: download it to open it."),
Some(Ok(Preview::TooLarge(size))) => message(
ui,
&format!(
"{} is too big to preview: download it to open it.",
format_size(*size as i64)
),
),
Some(Ok(Preview::Text(text))) if text.is_empty() => message(ui, "Empty file."),
Some(Ok(Preview::Text(text))) => {
egui::Frame::new()
.fill(p.bg)
.stroke(Stroke::new(1.0, p.border))
.corner_radius(CornerRadius::same(8))
.inner_margin(egui::Margin::same(10))
.show(ui, |ui| {
egui::ScrollArea::both()
.id_salt(("preview", path))
.auto_shrink([false, false])
.show(ui, |ui| {
ui.add(
egui::Label::new(
RichText::new(text).monospace().size(12.0).color(p.text),
)
.extend(),
);
});
});
}
}
}
fn uploads(ui: &mut egui::Ui, ctx: &mut PageCtx, tab: &FilesTab) {
let p = ctx.palette;
let (hovering, dropped) = ui.input(|i| {
let dropped: Vec<_> = i
.raw
.dropped_files
.iter()
.map(|f| f.path().to_path_buf())
.filter(|p| !p.as_os_str().is_empty())
.collect();
(!i.raw.hovered_files.is_empty(), dropped)
});
if !dropped.is_empty() {
ctx.request(Intent::Files(FileOp::Upload {
container: tab.container.clone(),
dir: tab.path.clone(),
files: dropped,
}));
}
if hovering {
egui::Frame::new()
.fill(p.accent.gamma_multiply(0.15))
.stroke(Stroke::new(1.5, p.accent))
.corner_radius(CornerRadius::same(8))
.inner_margin(egui::Margin::same(12))
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.label(
RichText::new(format!("Drop to copy into {}", tab.path))
.strong()
.color(p.accent),
);
});
ui.add_space(8.0);
}
}