Files · Find and look: preview, recent files, search, duplicates and archives

FilePreview shows a file large: its picture in the host's shape, the first
lines of its text, or its kind's icon, over its name, kind, size and
change; the gallery opens it on Space as a quick look. RecentFiles lists
files newest first under a header a day, a list a day. FileSearch sits
over a field the host owns and lists what the host found with its folder.
DuplicateFinder marks copies to remove and keeps the last of each group,
its mark shut, with the bytes the marks free beside Remove. ArchiveViewer
draws an archive's entries as a tree with sizes and savings, from paths
gathered by the folder builder the file tree now shares. A checkbox's
label wraps inside its box instead of running past it. Favorites and the
hex viewer point to documents and debug.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-26 20:42:06 -04:00
1 parent 4717aad596
commit d76a489934
17 files changed
+1507 -22

No files matched your search

+184
View File
@@ -0,0 +1,184 @@
use std::rc::Rc;
use gpui::{
App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled,
Window, div, prelude::*,
};
use super::FileIcon;
use crate::{
lists::{Folder, Tree, TreeNode},
primitives::{IconName, file_icon},
theme::{ActiveTheme, IconSize, TextSize},
typography::{MiddleEllipsis, format},
};
type OnArchived = Rc<dyn Fn(&ArchiveEntry, &mut Window, &mut App)>;
/// A file inside an archive: its path there, with `/` between folders, its size and its packed size, in bytes.
#[derive(Clone, Debug, PartialEq)]
pub struct ArchiveEntry {
pub path: SharedString,
pub size: u64,
pub packed: u64,
}
/// How much smaller packing made `size`, a whole percent.
pub(crate) fn saving(size: u64, packed: u64) -> u64 {
if size == 0 {
return 0;
}
((size - packed.min(size)) as f64 / size as f64 * 100.0).round() as u64
}
/// Nodes for `folder` under `prefix`, folders first, then files, each by name; and the bytes it holds.
fn nodes(folder: &Folder, prefix: &str, entries: &[ArchiveEntry]) -> (Vec<TreeNode>, u64) {
let mut out = Vec::new();
let mut held = 0;
let mut folders: Vec<_> = folder.folders.iter().collect();
folders.sort_by_key(|(name, _)| name.to_lowercase());
for (name, inner) in folders {
let path = format!("{prefix}{name}");
let (children, bytes) = nodes(inner, &format!("{path}/"), entries);
held += bytes;
let node = TreeNode::new(path, name.clone())
.icon(IconName::Folder)
.note(format::file_size(bytes, false))
.children(children);
out.push(node);
}
let mut files: Vec<&String> = folder.files.iter().collect();
files.sort_by_key(|name| name.to_lowercase());
for name in files {
let path = format!("{prefix}{name}");
let entry = entries
.iter()
.find(|entry| entry.path.as_ref() == path)
.expect("a listed entry");
held += entry.size;
let note = format!(
"{} · {}%",
format::file_size(entry.size, false),
saving(entry.size, entry.packed)
);
out.push(
TreeNode::new(path, name.clone())
.icon(file_icon(name))
.note(note),
);
}
(out, held)
}
/// What an archive holds, as a tree of its folders: each folder with its size, each file with its size and how much packing saved; the archive's name, its count, its size and its saving above. Enter or a double press on a file hands it to the host. Give it a height.
#[derive(IntoElement)]
pub struct ArchiveViewer {
id: ElementId,
name: SharedString,
entries: Vec<ArchiveEntry>,
on_open: Option<OnArchived>,
}
impl ArchiveViewer {
pub fn new(
id: impl Into<ElementId>,
name: impl Into<SharedString>,
entries: impl IntoIterator<Item = ArchiveEntry>,
) -> Self {
let entries: Vec<ArchiveEntry> = entries.into_iter().collect();
for (ix, entry) in entries.iter().enumerate() {
assert!(
!entries[..ix].iter().any(|other| other.path == entry.path),
"{} twice",
entry.path
);
}
Self {
id: id.into(),
name: name.into(),
entries,
on_open: None,
}
}
pub fn on_open(
mut self,
handler: impl Fn(&ArchiveEntry, &mut Window, &mut App) + 'static,
) -> Self {
self.on_open = Some(Rc::new(handler));
self
}
}
impl RenderOnce for ArchiveViewer {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = &theme.colors;
let folders = Folder::of(self.entries.iter().map(|entry| entry.path.as_ref()));
let (nodes, size) = nodes(&folders, "", &self.entries);
let packed: u64 = self.entries.iter().map(|entry| entry.packed).sum();
let top: Vec<SharedString> = folders
.folders
.keys()
.map(|name| name.clone().into())
.collect();
let summary = format!(
"{} · {} · {}% smaller",
match self.entries.len() {
1 => "1 file".to_string(),
count => format!("{count} files"),
},
format::file_size(size, false),
saving(size, packed)
);
let (id, entries, on_open) = (self.id.clone(), Rc::new(self.entries), self.on_open);
let tree = Tree::new((self.id.clone(), "tree"), nodes)
.open(top)
.flex_1()
.min_h_0()
.on_activate(move |key, window, cx| {
let entry = entries
.iter()
.find(|entry| entry.path == *key)
.expect("a leaf is a listed file");
log::info!("archive viewer {id:?}: open {}", entry.path);
if let Some(on_open) = &on_open {
on_open(entry, window, cx);
}
});
div()
.debug_selector(|| "archive-viewer".into())
.size_full()
.flex()
.flex_col()
.gap_3()
.child(
div()
.flex_none()
.flex()
.items_center()
.gap_3()
.child(FileIcon::file(&self.name).size(IconSize::Xl))
.child(
div()
.flex_1()
.min_w_0()
.flex()
.flex_col()
.child(
div()
.font_weight(FontWeight::SEMIBOLD)
.text_color(colors.fg)
.child(MiddleEllipsis::new(self.name)),
)
.child(
div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_muted)
.child(summary),
),
),
)
.child(tree)
}
}
+222
View File
@@ -0,0 +1,222 @@
use std::rc::Rc;
use gpui::{
App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled,
Window, div, prelude::*,
};
use super::FileIcon;
use crate::{
buttons::{Button, ButtonVariant},
forms::Checkbox,
theme::{ActiveTheme, ControlSize, TextSize},
typography::{MiddleEllipsis, format},
};
type OnMark = Rc<dyn Fn(&SharedString, bool, &mut Window, &mut App)>;
type OnRemove = Rc<dyn Fn(&[SharedString], &mut Window, &mut App)>;
/// Copies of one file: its name, its size in bytes, and where each copy lives.
#[derive(Clone, Debug, PartialEq)]
pub struct Duplicates {
pub name: SharedString,
pub size: u64,
pub paths: Vec<SharedString>,
}
/// The bytes the marked copies take.
pub(crate) fn freed(groups: &[Duplicates], marked: &[SharedString]) -> u64 {
groups
.iter()
.map(|group| {
let copies = group
.paths
.iter()
.filter(|path| marked.contains(path))
.count();
group.size * copies as u64
})
.sum()
}
/// Files found more than once, a group each: every copy with a mark to remove it; the one copy a group has left unmarked stays, its mark shut. What the marked copies free sits above, beside Remove.
#[derive(IntoElement)]
pub struct DuplicateFinder {
id: ElementId,
groups: Vec<Duplicates>,
marked: Vec<SharedString>,
on_mark: Option<OnMark>,
on_remove: Option<OnRemove>,
}
impl DuplicateFinder {
pub fn new(id: impl Into<ElementId>, groups: impl IntoIterator<Item = Duplicates>) -> Self {
let groups: Vec<Duplicates> = groups.into_iter().collect();
for group in &groups {
assert!(group.paths.len() >= 2, "{} has one copy", group.name);
}
let paths: Vec<&SharedString> = groups.iter().flat_map(|group| &group.paths).collect();
for (ix, path) in paths.iter().enumerate() {
assert!(!paths[..ix].contains(path), "{path} twice");
}
Self {
id: id.into(),
groups,
marked: Vec::new(),
on_mark: None,
on_remove: None,
}
}
/// The copies marked to remove, by path; each group keeps one.
pub fn marked(mut self, paths: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
let paths: Vec<SharedString> = paths.into_iter().map(Into::into).collect();
for path in &paths {
assert!(
self.groups.iter().any(|group| group.paths.contains(path)),
"no copy {path}"
);
}
for group in &self.groups {
assert!(
group.paths.iter().any(|path| !paths.contains(path)),
"every copy of {} is marked",
group.name
);
}
self.marked = paths;
self
}
/// Gets a copy's path and whether it is marked now.
pub fn on_mark(
mut self,
handler: impl Fn(&SharedString, bool, &mut Window, &mut App) + 'static,
) -> Self {
self.on_mark = Some(Rc::new(handler));
self
}
/// Gets the marked copies to remove.
pub fn on_remove(
mut self,
handler: impl Fn(&[SharedString], &mut Window, &mut App) + 'static,
) -> Self {
self.on_remove = Some(Rc::new(handler));
self
}
}
impl RenderOnce for DuplicateFinder {
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 freed = freed(&self.groups, &self.marked);
let marked: Rc<[SharedString]> = self.marked.into();
let remove = Button::new((self.id.clone(), "remove"), "Remove marked")
.variant(ButtonVariant::Danger)
.size(ControlSize::Sm)
.disabled(marked.is_empty() || self.on_remove.is_none());
let remove = match self.on_remove {
Some(on_remove) => {
let (id, marked) = (self.id.clone(), marked.clone());
remove.on_click(move |_, window, cx| {
log::info!("duplicate finder {id:?}: remove {} copies", marked.len());
on_remove(&marked, window, cx)
})
}
None => remove,
};
let header = div()
.flex()
.flex_wrap()
.items_center()
.justify_between()
.gap_2()
.child(
div()
.flex_1()
.min_w(theme.label_width())
.flex()
.flex_col()
.child(
div()
.font_weight(FontWeight::SEMIBOLD)
.text_color(colors.fg)
.child(match self.groups.len() {
1 => "1 file found more than once".to_string(),
count => format!("{count} files found more than once"),
}),
)
.child(
div()
.text_size(small)
.text_color(colors.fg_muted)
.child(format!("{} to free", format::file_size(freed, false))),
),
)
.child(div().flex_none().child(remove));
let groups = self.groups.iter().enumerate().map(|(g, group)| {
let left = group
.paths
.iter()
.filter(|path| !marked.contains(path))
.count();
let copies = group.paths.iter().enumerate().map(|(c, path)| {
let on = marked.contains(path);
let check = Checkbox::new((self.id.clone(), format!("copy-{g}-{c}")), on)
.label(path.clone())
.disabled(self.on_mark.is_none() || (!on && left == 1));
match self.on_mark.clone() {
Some(on_mark) => {
let (id, path) = (self.id.clone(), path.clone());
check.on_change(move |to, window, cx| {
log::info!("duplicate finder {id:?}: {path} marked {to}");
on_mark(&path, to, window, cx)
})
}
None => check,
}
});
div()
.flex()
.flex_col()
.gap_1p5()
.child(
div()
.flex()
.items_center()
.gap_2()
.child(FileIcon::file(&group.name))
.child(
div()
.flex_1()
.min_w_0()
.text_color(colors.fg)
.child(MiddleEllipsis::new(group.name.clone())),
)
.child(
div()
.flex_none()
.text_size(small)
.text_color(colors.fg_muted)
.child(format!(
"{} copies · {} each",
group.paths.len(),
format::file_size(group.size, false)
)),
),
)
.child(div().pl_6().flex().flex_col().gap_1().children(copies))
});
div()
.debug_selector(|| "duplicate-finder".into())
.w_full()
.flex()
.flex_col()
.gap_4()
.child(header)
.children(groups)
}
}
+10
View File
@@ -4,18 +4,28 @@ use gpui::{App, Window};
use crate::lists::DirEntry;
mod archive;
mod columns;
mod duplicates;
mod explorer;
mod grid;
mod icon;
mod item;
mod preview;
mod recent;
mod search;
type OnEntry = Rc<dyn Fn(&DirEntry, &mut Window, &mut App)>;
#[cfg(all(test, feature = "test-support"))]
mod tests;
pub use archive::{ArchiveEntry, ArchiveViewer};
pub use duplicates::{DuplicateFinder, Duplicates};
pub use explorer::{FileExplorer, FileView};
pub use grid::FileGrid;
pub use icon::FileIcon;
pub use item::FileItem;
pub use preview::FilePreview;
pub use recent::{RecentFile, RecentFiles};
pub use search::{FileSearch, FoundFile};
+146
View File
@@ -0,0 +1,146 @@
use gpui::{
AnyElement, App, ElementId, FontWeight, ImageSource, IntoElement, ParentElement, RenderOnce,
SharedString, Styled, Window, div, prelude::*, relative,
};
use jiff::Timestamp;
use super::FileIcon;
use crate::{
lists::DirEntry,
primitives::{Image, checked_ratio, framed},
theme::{ActiveTheme, IconSize, Radius, TextSize},
typography::{Ellipsis, LEADING, MiddleEllipsis, format},
};
/// Lines of a text a preview shows.
const LINES: usize = 12;
/// The shape of the well an icon sits in, wide over high.
const WELL: f32 = 4.0 / 3.0;
/// What a preview shows of a file.
enum Look {
Picture(ImageSource, f32),
Text(SharedString),
Icon,
}
/// A file's kind, from its extension: "PDF", "File" without one, "Folder" for a folder.
pub(crate) fn kind(entry: &DirEntry) -> String {
if entry.is_folder() {
return "Folder".into();
}
match entry.name().rsplit_once('.') {
Some((stem, extension)) if !stem.is_empty() => extension.to_uppercase(),
_ => "File".into(),
}
}
/// A file shown large, as a quick look shows it: its picture in the host's shape, the first lines of its text, or its kind's icon; its name, its kind and size, and when it changed below.
#[derive(IntoElement)]
pub struct FilePreview {
id: ElementId,
entry: DirEntry,
look: Look,
}
impl FilePreview {
pub fn new(id: impl Into<ElementId>, entry: DirEntry) -> Self {
Self {
id: id.into(),
entry,
look: Look::Icon,
}
}
/// Its picture, `ratio` wide over high.
pub fn picture(mut self, source: impl Into<ImageSource>, ratio: f32) -> Self {
self.look = Look::Picture(source.into(), checked_ratio(ratio));
self
}
/// Its text; the first lines show.
pub fn text(mut self, text: impl Into<SharedString>) -> Self {
self.look = Look::Text(text.into());
self
}
}
impl RenderOnce for FilePreview {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = &theme.colors;
let round = theme.radius(Radius::Lg);
let stage: AnyElement = match self.look {
Look::Picture(source, ratio) => framed(ratio, cx)
.debug_selector(|| "file-preview-stage".into())
.rounded(round)
.child(
Image::new((self.id.clone(), "picture"), source)
.size_full()
.rounded(round),
)
.into_any_element(),
Look::Text(text) => div()
.w_full()
.p_4()
.rounded(round)
.bg(colors.sunken)
.font_family(theme.mono_family.clone())
.text_size(theme.text_size(TextSize::Sm))
.line_height(relative(LEADING))
.text_color(colors.fg)
.children(text.lines().take(LINES).map(|line| {
let shown = if line.is_empty() { " " } else { line };
Ellipsis::new(shown.to_string())
}))
.into_any_element(),
Look::Icon => framed(WELL, cx)
.rounded(round)
.flex()
.items_center()
.justify_center()
.child(FileIcon::entry(&self.entry).size(IconSize::Xxl))
.into_any_element(),
};
let detail = match self.entry.size() {
Some(bytes) => format!(
"{} · {}",
kind(&self.entry),
format::file_size(bytes, false)
),
None => kind(&self.entry),
};
let changed = format!(
"Changed {}",
format::relative(self.entry.modified(), Timestamp::now())
);
let muted = |text: String| {
div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_muted)
.child(text)
};
div()
.debug_selector(|| "file-preview".into())
.w_full()
.flex()
.flex_col()
.gap_3()
.child(stage)
.child(
div()
.flex()
.flex_col()
.gap_0p5()
.child(
div()
.text_size(theme.text_size(TextSize::Md))
.font_weight(FontWeight::SEMIBOLD)
.text_color(colors.fg)
.child(MiddleEllipsis::new(self.entry.name().clone())),
)
.child(muted(detail))
.child(muted(changed)),
)
}
}
+131
View File
@@ -0,0 +1,131 @@
use std::rc::Rc;
use gpui::{
App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled,
Window, div, prelude::*,
};
use jiff::{Timestamp, civil::Date, tz::TimeZone};
use super::FileIcon;
use crate::{
lists::{ListItem, SelectableList},
theme::{ActiveTheme, TextSize},
typography::format::{datetime, system_zone},
};
type OnRecent = Rc<dyn Fn(&RecentFile, &mut Window, &mut App)>;
/// A file opened lately: its name, the folder it lives in, and when it was opened.
#[derive(Clone, Debug, PartialEq)]
pub struct RecentFile {
pub name: SharedString,
pub folder: SharedString,
pub opened: Timestamp,
}
/// The header over a day's files: Today, Yesterday, or the weekday and date.
pub(crate) fn day_label(day: Date, today: Date) -> String {
if day == today {
"Today".into()
} else if today.yesterday().ok() == Some(day) {
"Yesterday".into()
} else {
day.strftime("%A, %B %-d").to_string()
}
}
/// Files opened lately, newest first, under a header for each day: Today, Yesterday, then the weekday and date. Each day is a list; Tab moves between days, and Enter or a double press opens a file.
#[derive(IntoElement)]
pub struct RecentFiles {
id: ElementId,
files: Vec<RecentFile>,
zone: Option<TimeZone>,
on_open: Option<OnRecent>,
}
impl RecentFiles {
pub fn new(id: impl Into<ElementId>, files: impl IntoIterator<Item = RecentFile>) -> Self {
Self {
id: id.into(),
files: files.into_iter().collect(),
zone: None,
on_open: None,
}
}
/// The zone days turn in; the system's by default.
pub fn zone(mut self, zone: TimeZone) -> Self {
self.zone = Some(zone);
self
}
pub fn on_open(
mut self,
handler: impl Fn(&RecentFile, &mut Window, &mut App) + 'static,
) -> Self {
self.on_open = Some(Rc::new(handler));
self
}
}
impl RenderOnce for RecentFiles {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let zone = self.zone.unwrap_or_else(|| system_zone("recent files"));
let today = Timestamp::now().to_zoned(zone.clone()).date();
let mut files = self.files;
files.sort_by_key(|file| std::cmp::Reverse(file.opened));
let files: Rc<[RecentFile]> = files.into();
let theme = cx.theme();
let colors = &theme.colors;
let mut days: Vec<(Date, Vec<usize>)> = Vec::new();
for (ix, file) in files.iter().enumerate() {
let day = file.opened.to_zoned(zone.clone()).date();
match days.last_mut() {
Some((last, those)) if *last == day => those.push(ix),
_ => days.push((day, vec![ix])),
}
}
let groups = days.into_iter().map(|(day, those)| {
let list = those.iter().fold(
SelectableList::new((self.id.clone(), format!("day-{day}"))),
|list, ix| {
let file = &files[*ix];
let time = datetime(file.opened, &zone, "%H:%M").expect("a time formats");
let row =
ListItem::new((self.id.clone(), format!("recent-{ix}")), file.name.clone())
.leading(FileIcon::file(&file.name))
.description(file.folder.clone())
.trailing(time);
list.row(SharedString::from(ix.to_string()), row)
},
);
let (id, files, on_open) = (self.id.clone(), files.clone(), self.on_open.clone());
div()
.flex()
.flex_col()
.gap_1()
.child(
div()
.px_3()
.text_size(theme.text_size(TextSize::Sm))
.font_weight(FontWeight::SEMIBOLD)
.text_color(colors.fg_muted)
.child(day_label(day, today)),
)
.child(list.on_activate(move |key, window, cx| {
let file = &files[key.parse::<usize>().expect("a row's place")];
log::info!("recent files {id:?}: open {}", file.name);
if let Some(on_open) = &on_open {
on_open(file, window, cx);
}
}))
});
div()
.debug_selector(|| "recent-files".into())
.w_full()
.flex()
.flex_col()
.gap_4()
.children(groups)
}
}
+114
View File
@@ -0,0 +1,114 @@
use std::rc::Rc;
use gpui::{
App, ElementId, Entity, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window,
div, prelude::*,
};
use super::FileIcon;
use crate::{
forms::{SearchInput, TextInput},
lists::{ListItem, SelectableList},
theme::{ActiveTheme, TextSize},
};
type OnFound = Rc<dyn Fn(&FoundFile, &mut Window, &mut App)>;
/// A file a search found: its name and the folder it lives in.
#[derive(Clone, Debug, PartialEq)]
pub struct FoundFile {
pub name: SharedString,
pub folder: SharedString,
}
/// A search over files: a field the host owns, and what it found, each file with its folder and a count above. The host searches as the text changes; Enter or a double press opens a file.
#[derive(IntoElement)]
pub struct FileSearch {
id: ElementId,
query: Entity<TextInput>,
found: Vec<FoundFile>,
on_open: Option<OnFound>,
}
impl FileSearch {
pub fn new(
id: impl Into<ElementId>,
query: &Entity<TextInput>,
found: impl IntoIterator<Item = FoundFile>,
) -> Self {
Self {
id: id.into(),
query: query.clone(),
found: found.into_iter().collect(),
on_open: None,
}
}
pub fn on_open(
mut self,
handler: impl Fn(&FoundFile, &mut Window, &mut App) + 'static,
) -> Self {
self.on_open = Some(Rc::new(handler));
self
}
}
impl RenderOnce for FileSearch {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let text = self.query.read(cx).text().trim().to_string();
let theme = cx.theme();
let quiet = |line: String| {
div()
.debug_selector(|| "file-search-note".into())
.px_3()
.text_size(theme.text_size(TextSize::Sm))
.text_color(theme.colors.fg_muted)
.child(line)
};
let found: Rc<[FoundFile]> = self.found.into();
let results = match (text.is_empty(), found.is_empty()) {
(true, _) => quiet("Type a name to search.".into()).into_any_element(),
(false, true) => quiet(format!("No files match “{text}”.")).into_any_element(),
(false, false) => {
let list = found.iter().enumerate().fold(
SelectableList::new((self.id.clone(), "found")),
|list, (ix, file)| {
let row = ListItem::new(
(self.id.clone(), format!("found-{ix}")),
file.name.clone(),
)
.leading(FileIcon::file(&file.name))
.description(file.folder.clone());
list.row(SharedString::from(ix.to_string()), row)
},
);
let (id, shown, on_open) = (self.id.clone(), found.clone(), self.on_open);
let count = match found.len() {
1 => "1 file".to_string(),
count => format!("{count} files"),
};
div()
.flex()
.flex_col()
.gap_1()
.child(quiet(count))
.child(list.on_activate(move |key, window, cx| {
let file = &shown[key.parse::<usize>().expect("a row's place")];
log::info!("file search {id:?}: open {}", file.name);
if let Some(on_open) = &on_open {
on_open(file, window, cx);
}
}))
.into_any_element()
}
};
div()
.debug_selector(|| "file-search".into())
.w_full()
.flex()
.flex_col()
.gap_3()
.child(SearchInput::new((self.id.clone(), "field"), &self.query))
.child(results)
}
}
+311
View File
@@ -0,0 +1,311 @@
use gpui::{
AppContext as _, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
TestAppContext, VisualTestContext, Window, div, px,
};
use jiff::{SignedDuration, Timestamp, tz::TimeZone};
use super::{press, settle, setup, then};
use crate::{
files::{
ArchiveEntry, ArchiveViewer, DuplicateFinder, Duplicates, FilePreview, FileSearch,
FoundFile, RecentFile, RecentFiles, archive::saving, duplicates::freed, preview::kind,
recent::day_label,
},
forms::TextInput,
lists::DirEntry,
};
/// Moves focus to the `nth` Tab stop from none.
fn tab_to(nth: usize, cx: &mut VisualTestContext) {
cx.update(|window, _| {
window.blur();
for _ in 0..nth {
window.focus_next();
}
});
settle(cx);
}
#[test]
fn a_kind_reads_from_the_extension() {
assert_eq!(kind(&DirEntry::file("Brief.md", 1, then())), "MD");
assert_eq!(kind(&DirEntry::file("Makefile", 1, then())), "File");
assert_eq!(kind(&DirEntry::file(".env", 1, then())), "File");
assert_eq!(kind(&DirEntry::folder("Design", then())), "Folder");
}
#[test]
fn a_day_reads_as_today_yesterday_or_its_date() {
let today = jiff::civil::date(2026, 9, 22);
assert_eq!(day_label(today, today), "Today");
assert_eq!(
day_label(jiff::civil::date(2026, 9, 21), today),
"Yesterday"
);
assert_eq!(
day_label(jiff::civil::date(2026, 9, 20), today),
"Sunday, September 20"
);
}
#[test]
fn packing_saves_a_whole_percent_and_marks_free_their_bytes() {
assert_eq!(
(saving(1000, 250), saving(0, 0), saving(10, 20)),
(75, 0, 0)
);
let groups = [Duplicates {
name: "a.jpg".into(),
size: 100,
paths: vec!["x/a.jpg".into(), "y/a.jpg".into(), "z/a.jpg".into()],
}];
assert_eq!(freed(&groups, &["y/a.jpg".into(), "z/a.jpg".into()]), 200);
}
/// A preview of a picture four wide over three, 400 across.
struct Looking {
path: std::path::PathBuf,
}
impl Render for Looking {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let entry = DirEntry::file("a.png", 10, then());
let preview = FilePreview::new("preview", entry).picture(self.path.clone(), 4.0 / 3.0);
div().w(px(400.0)).child(preview)
}
}
#[gpui::test]
fn a_picture_keeps_the_hosts_shape(cx: &mut TestAppContext) {
setup(cx);
let path = std::env::temp_dir().join("ely-files-preview.png");
image::RgbaImage::from_pixel(40, 30, image::Rgba([90, 120, 150, 255]))
.save(&path)
.expect("the picture writes");
let (_, cx) = cx.add_window_view(|_, _| Looking { path });
settle(cx);
let stage = cx
.debug_bounds("file-preview-stage")
.expect("the stage draws");
let shape = stage.size.width / stage.size.height;
assert!((shape - 4.0 / 3.0).abs() < 0.02, "{stage:?}");
}
/// Files opened today and yesterday in UTC, and the ones it opened.
struct Recalling {
opened: Vec<SharedString>,
}
impl Render for Recalling {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let owner = cx.entity();
let midnight = Timestamp::now()
.to_zoned(TimeZone::UTC)
.start_of_day()
.expect("a day starts")
.timestamp();
let file = |name: &str, minutes: i64| RecentFile {
name: SharedString::from(name.to_string()),
folder: "Home".into(),
opened: midnight + SignedDuration::from_mins(minutes),
};
let files = [
file("early.md", 5),
file("late.md", -300),
file("later.md", 9),
];
div().w(px(320.0)).child(
RecentFiles::new("recent", files)
.zone(TimeZone::UTC)
.on_open(move |file, _, cx| {
owner.update(cx, |host, _| host.opened.push(file.name.clone()))
}),
)
}
}
#[gpui::test]
fn days_list_the_newest_first_and_enter_opens(cx: &mut TestAppContext) {
setup(cx);
let (host, cx) = cx.add_window_view(|_, _| Recalling { opened: Vec::new() });
cx.update(|window, _| window.activate_window());
settle(cx);
tab_to(1, cx);
press("enter", cx);
tab_to(2, cx);
press("enter", cx);
assert_eq!(
host.read_with(cx, |host, _| host.opened.clone()),
["later.md", "late.md"]
);
}
/// A file search over a field it owns, finding what the test says, and what it opened.
struct Seeking {
query: Entity<TextInput>,
found: Vec<FoundFile>,
opened: Vec<SharedString>,
}
impl Render for Seeking {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let owner = cx.entity();
div().w(px(320.0)).child(
FileSearch::new("search", &self.query, self.found.clone()).on_open(
move |file, _, cx| owner.update(cx, |host, _| host.opened.push(file.name.clone())),
),
)
}
}
fn seeking<'a>(
text: &str,
found: Vec<FoundFile>,
cx: &'a mut TestAppContext,
) -> (Entity<Seeking>, &'a mut VisualTestContext) {
setup(cx);
let text = text.to_string();
let (host, cx) = cx.add_window_view(move |window, cx| {
let query = cx.new(|cx| {
let mut field = TextInput::new(window, cx);
field.set_text(text, cx);
field
});
Seeking {
query,
found,
opened: Vec::new(),
}
});
cx.update(|window, _| window.activate_window());
settle(cx);
(host, cx)
}
#[gpui::test]
fn a_search_with_nothing_found_says_so(cx: &mut TestAppContext) {
let (_, cx) = seeking("zebra", Vec::new(), cx);
assert!(cx.debug_bounds("file-search-note").is_some());
}
#[gpui::test]
fn a_found_file_opens_on_enter(cx: &mut TestAppContext) {
let found = vec![FoundFile {
name: "Brief.md".into(),
folder: "Home/Atrium".into(),
}];
let (host, cx) = seeking("brief", found, cx);
tab_to(3, cx);
press("enter", cx);
assert_eq!(
host.read_with(cx, |host, _| host.opened.clone()),
["Brief.md"]
);
}
/// Two copies of a file with the second marked, and what it was told.
struct Sparing {
removed: Vec<SharedString>,
marks: Vec<(SharedString, bool)>,
}
impl Render for Sparing {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let (remove, mark) = (cx.entity(), cx.entity());
let group = Duplicates {
name: "a.jpg".into(),
size: 100,
paths: vec!["x/a.jpg".into(), "y/a.jpg".into()],
};
div().w(px(320.0)).child(
DuplicateFinder::new("dupes", [group])
.marked(["y/a.jpg"])
.on_remove(move |paths, _, cx| {
remove.update(cx, |host, _| host.removed.extend(paths.iter().cloned()))
})
.on_mark(move |path, on, _, cx| {
mark.update(cx, |host, _| host.marks.push((path.clone(), on)))
}),
)
}
}
#[gpui::test]
fn the_last_copy_stays_and_remove_takes_the_marked(cx: &mut TestAppContext) {
setup(cx);
let (host, cx) = cx.add_window_view(|_, _| Sparing {
removed: Vec::new(),
marks: Vec::new(),
});
cx.update(|window, _| window.activate_window());
settle(cx);
tab_to(1, cx);
press("space", cx);
tab_to(2, cx);
press("space", cx);
let heard = host.read_with(cx, |host, _| (host.removed.clone(), host.marks.clone()));
assert_eq!(
heard,
(vec!["y/a.jpg".into()], vec![("y/a.jpg".into(), false)]),
"x/a.jpg is kept, its mark shut"
);
}
#[test]
#[should_panic(expected = "every copy of a.jpg is marked")]
fn a_group_keeps_a_copy() {
let group = Duplicates {
name: "a.jpg".into(),
size: 1,
paths: vec!["x".into(), "y".into()],
};
let _ = DuplicateFinder::new("dupes", [group]).marked(["x", "y"]);
}
/// An archive of four files in two folders, and the files it handed over.
struct Unpacking {
opened: Vec<SharedString>,
}
impl Render for Unpacking {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let owner = cx.entity();
let entry = |path: &str| ArchiveEntry {
path: SharedString::from(path.to_string()),
size: 400,
packed: 100,
};
let entries = ["docs/readme.md", "docs/guide.md", "src/main.rs", "LICENSE"].map(entry);
div().w(px(320.0)).h(px(320.0)).child(
ArchiveViewer::new("archive", "site.zip", entries).on_open(move |entry, _, cx| {
owner.update(cx, |host, _| host.opened.push(entry.path.clone()))
}),
)
}
}
#[gpui::test]
fn a_file_in_an_open_folder_goes_to_the_host_on_enter(cx: &mut TestAppContext) {
setup(cx);
let (host, cx) = cx.add_window_view(|_, _| Unpacking { opened: Vec::new() });
cx.update(|window, _| window.activate_window());
settle(cx);
tab_to(1, cx);
press("down", cx);
press("enter", cx);
assert_eq!(
host.read_with(cx, |host, _| host.opened.clone()),
["docs/guide.md"]
);
}
#[test]
#[should_panic(expected = "LICENSE twice")]
fn an_archive_lists_a_path_once() {
let entry = ArchiveEntry {
path: "LICENSE".into(),
size: 1,
packed: 1,
};
let _ = ArchiveViewer::new("archive", "a.zip", [entry.clone(), entry]);
}
+1
View File
@@ -8,6 +8,7 @@ use super::{FileGrid, item::detail};
use crate::{lists::DirEntry, primitives::FocusNext, theme::Theme};
mod explorer;
mod find;
pub(super) fn setup(cx: &mut TestAppContext) {
cx.update(|cx| {
+7 -1
View File
@@ -171,7 +171,13 @@ impl RenderOnce for Checkbox {
})
})
.child(check_mark(self.state, focused, self.disabled, changes, cx))
.children(self.label)
.children(self.label.map(|label| {
div()
.debug_selector(|| "checkbox-label".into())
.flex_1()
.min_w_0()
.child(label)
}))
}
}
+21
View File
@@ -475,3 +475,24 @@ fn a_long_choice_keeps_its_list_inside_the_window(cx: &mut TestAppContext) {
"{list:?}"
);
}
/// A checkbox whose label runs past its narrow box.
struct Worded;
impl Render for Worded {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let label = "Projects/Atrium/Recordings/Walkthrough of the atrium at noon.mov";
div()
.w(px(200.0))
.child(Checkbox::new("worded", false).label(label))
}
}
#[gpui::test]
fn a_long_checkbox_label_wraps_inside_its_box(cx: &mut TestAppContext) {
setup(cx);
let (_, cx) = cx.add_window_view(|_, _| Worded);
cx.run_until_parked();
let label = cx.debug_bounds("checkbox-label").expect("the label draws");
assert!(label.right() <= px(200.0), "{label:?}");
}
+20 -12
View File
@@ -59,9 +59,25 @@ impl GitStatus {
/// A folder of the tree being built: its folders and its files, each by name.
#[derive(Default)]
struct Folder {
folders: BTreeMap<String, Folder>,
files: Vec<String>,
pub(crate) struct Folder {
pub folders: BTreeMap<String, Folder>,
pub files: Vec<String>,
}
impl Folder {
/// Paths with `/` between folders, gathered into the folders that hold them.
pub(crate) fn of<'a>(paths: impl IntoIterator<Item = &'a str>) -> Self {
let mut root = Folder::default();
for path in paths {
let mut parts: Vec<&str> = path.split('/').collect();
let file = parts.pop().expect("a path has a name").to_string();
let folder = parts.iter().fold(&mut root, |folder, part| {
folder.folders.entry(part.to_string()).or_default()
});
folder.files.push(file);
}
root
}
}
/// Whether `name` matches `query`, ignoring case; an empty query matches all.
@@ -75,15 +91,7 @@ fn nodes(
status: &[(SharedString, GitStatus)],
query: &str,
) -> (Vec<TreeNode>, Vec<SharedString>) {
let mut root = Folder::default();
for path in paths {
let mut parts: Vec<&str> = path.split('/').collect();
let file = parts.pop().expect("a path has a name").to_string();
let folder = parts.iter().fold(&mut root, |folder, part| {
folder.folders.entry(part.to_string()).or_default()
});
folder.files.push(file);
}
let root = Folder::of(paths.iter().map(|path| path.as_ref()));
let state = |path: &str| {
status
.iter()
+1
View File
@@ -13,6 +13,7 @@ mod tree;
pub use directory::{DirEntry, DirectoryListing};
pub(crate) use directory::{OnClimb, listed, path_bar};
pub(crate) use files::Folder;
pub use files::{FileTree, GitStatus};
pub use grouped::GroupedList;
pub use item::{List, ListItem};