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:
1 parent
4717aad596
commit
d76a489934
17 files changed
+1507
-22
No files matched your search
@@ -113,6 +113,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
||||
- gpui 0.2.2's `line_clamp` wraps once and lays the rest on the last line, cut hard; centered, it loses both ends. A name whose ends matter is a `typography::MiddleEllipsis` on one line (`files::FileGrid`).
|
||||
- Set an explicit line height on any box that clips text. gpui's default leading is taller than a tight box, and the clip eats descenders.
|
||||
- gpui's `img` takes its picture's pixel size for any `Auto` side, and its aspect ratio beats percent heights in flow. `Image` pins it absolute at full size, so an `Image` needs a sized box.
|
||||
- Paths with `/` between folders become a tree through `lists::Folder::of`, for `FileTree` and `files::ArchiveViewer` alike.
|
||||
- A folder's path over its entries is `lists::path_bar`, and its first order is `lists::listed`, folders first by name: DirectoryListing and FileExplorer share both, so every view shows one path and one order.
|
||||
- A file's icon comes from `primitives::file_icon`, one map from name to icon for every chapter; `files::FileIcon` draws it. What fits across a width comes from `layout::columns_for`, and `layout::fit` adds the inset that centers it.
|
||||
- Badges, tags and avatars live in `data_display`; forms and shell draw theirs from there, and `data_display` imports neither. Avatars take square pictures.
|
||||
@@ -131,7 +132,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
||||
- A chat holds at its newest message through gpui's `list` with `ListAlignment::Bottom`: it sticks while scrolled to the end, and `scroll_to` one past the last item sticks it again; that call sends no scroll event, so the owner clears its own away state (`chat::MessageList`).
|
||||
- Anything pressable is a Tab stop: a row, card or toggle takes `tab_index(0)` and `focus_ring`, with a 1px border that stays transparent where it shows none; gpui presses it on the release of Enter or Space.
|
||||
- A list of pressable rows goes through `lists::SelectableList`, whose cursor reaches them by key: Up and Down move, Enter or a double press activates (`media::Playlist`). A bare `ListItem` press is the pointer's only.
|
||||
- A row that holds text beside fixed parts lets the text give way: the text takes `flex_1().min_w_0()` and an `Ellipsis`; counts, icons, dates and chevrons take `flex_none`.
|
||||
- A row that holds text beside fixed parts lets the text give way: the text takes `flex_1().min_w_0()` and an `Ellipsis`; counts, icons, dates and chevrons take `flex_none`. A label that must stay whole wraps in that block instead, as `forms::Checkbox`'s does.
|
||||
- A mark over a text field reads the field at paint time, from a canvas laid over it (`collab::RemoteCursor`): by then `TextInput::bounds_for` holds this frame's layout, and a still page never renders again to catch up.
|
||||
- A callback field holds a named `type` alias; clippy's type_complexity rejects `Option<Rc<dyn Fn(..)>>`. A chapter shares one alias per shape.
|
||||
- A component that fills its container says `w_full` on its root. As a window's root, or in a flex row, it would shrink to its text's narrowest width.
|
||||
|
||||
@@ -95,7 +95,7 @@ Per-component lines live in `tasks/`. Tags there:
|
||||
- [x] T26d Media · Audio and controls
|
||||
- [x] T26e Media · Capture and devices
|
||||
- [x] T27a Files · Items
|
||||
- [ ] T27b Files · Explorer
|
||||
- [x] T27b Files · Explorer
|
||||
- [ ] T27c Files · Find and look
|
||||
- [ ] T27d Files · Transfers
|
||||
- [ ] T28 Messaging
|
||||
@@ -324,3 +324,4 @@ Per-component lines live in `tasks/`. Tags there:
|
||||
| T27a | 1 | FAIL | a tile's long name leaned on gpui's line_clamp, which cut its second line at both ends and lost the extension |
|
||||
| T27a | 2 | PASS | |
|
||||
| T27b | 1 | FAIL | the listing's fixed columns left no room for names at 280px; a column keyed by its depth handed its cursor to the next folder there |
|
||||
| T27b | 2 | PASS | |
|
||||
@@ -0,0 +1,309 @@
|
||||
use std::path::Path;
|
||||
|
||||
use ely_gpui_component::{
|
||||
debug::HexViewer,
|
||||
documents::{Favorite, Favorites},
|
||||
files::{
|
||||
ArchiveEntry, ArchiveViewer, DuplicateFinder, Duplicates, FileGrid, FilePreview,
|
||||
FileSearch, FoundFile, RecentFile, RecentFiles,
|
||||
},
|
||||
forms::TextInput,
|
||||
lists::DirEntry,
|
||||
overlays::Dialog,
|
||||
};
|
||||
use gpui::{
|
||||
App, InteractiveElement, IntoElement, ParentElement, SharedString, Styled, Window, div, px,
|
||||
};
|
||||
use jiff::{SignedDuration, Timestamp, ToSpan};
|
||||
|
||||
use crate::{
|
||||
probe::probe,
|
||||
ui::{change, keep, section},
|
||||
};
|
||||
|
||||
const ATRIUM: &str = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/examples/gallery/assets/atrium.jpg"
|
||||
);
|
||||
const BRIEF: &str = "# Atrium\n\nA quiet hall for the new library.\n\n- Light from above, all day\n- One stair, curved, in plaster\n- Olive trees by the windows\n\nNext: the model walkthrough on Friday.";
|
||||
|
||||
fn entry(name: &str, bytes: u64, hours: i64) -> DirEntry {
|
||||
DirEntry::file(name.to_string(), bytes, Timestamp::now() - hours.hours())
|
||||
}
|
||||
|
||||
pub fn previews(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let looking = keep("files-looking", || None::<SharedString>, window, cx);
|
||||
let picked = keep("files-look-picked", || None::<SharedString>, window, cx);
|
||||
let (open, pick) = (looking.clone(), picked.clone());
|
||||
let chosen = picked.read(cx).clone();
|
||||
let preview = |name: &SharedString| match name.as_ref() {
|
||||
"Atrium.jpg" => FilePreview::new("files-look", entry("Atrium.jpg", 2_140_000, 50))
|
||||
.picture(Path::new(ATRIUM), 1.5),
|
||||
"Brief.md" => FilePreview::new("files-look", entry("Brief.md", 4_800, 2)).text(BRIEF),
|
||||
other => FilePreview::new("files-look", entry(other, 48_000_000, 700)),
|
||||
};
|
||||
let tiles = [
|
||||
entry("Atrium.jpg", 2_140_000, 50),
|
||||
entry("Brief.md", 4_800, 2),
|
||||
entry("Archive.zip", 48_000_000, 700),
|
||||
];
|
||||
let grid = FileGrid::new("files-look-grid", tiles)
|
||||
.on_select(move |name, _, cx| change(&pick, cx, |now| *now = Some(name.clone())));
|
||||
let grid = match chosen.clone() {
|
||||
Some(name) => grid.selected(name),
|
||||
None => grid,
|
||||
};
|
||||
let shown = looking.read(cx).clone();
|
||||
let close = looking.clone();
|
||||
section(
|
||||
"FilePreview / QuickLook",
|
||||
"A file shown large: its picture in its shape, its text's first lines, or its kind. Space on a picked tile shows it over the page, as a quick look does; Escape puts it away.",
|
||||
cx,
|
||||
)
|
||||
.child(probe(
|
||||
"files-previews",
|
||||
div()
|
||||
.w(px(640.))
|
||||
.flex()
|
||||
.gap_4()
|
||||
.child(div().flex_1().min_w_0().child(preview(&"Atrium.jpg".into())))
|
||||
.child(div().flex_1().min_w_0().child(preview(&"Brief.md".into())))
|
||||
.child(div().flex_1().min_w_0().child(preview(&"Archive.zip".into()))),
|
||||
))
|
||||
.child(probe(
|
||||
"files-quick",
|
||||
div()
|
||||
.w(px(640.))
|
||||
.on_key_down(move |event, _, cx| {
|
||||
if event.keystroke.key == "space" {
|
||||
let picked = chosen.clone();
|
||||
change(&open, cx, |now| *now = picked);
|
||||
}
|
||||
})
|
||||
.child(grid),
|
||||
))
|
||||
.children(shown.map(|name| {
|
||||
Dialog::new("files-quick-look", name.clone(), move |_, cx| {
|
||||
change(&close, cx, |now| *now = None)
|
||||
})
|
||||
.child(div().w(px(420.)).child(preview(&name)))
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn recent(cx: &mut App) -> impl IntoElement + use<> {
|
||||
let now = Timestamp::now();
|
||||
let file = |name: &str, folder: &str, minutes: i64| RecentFile {
|
||||
name: SharedString::from(name.to_string()),
|
||||
folder: SharedString::from(folder.to_string()),
|
||||
opened: now - SignedDuration::from_mins(minutes),
|
||||
};
|
||||
let files = [
|
||||
file("Brief.md", "Projects/Atrium", 12),
|
||||
file("Budget.csv", "Projects/Atrium", 95),
|
||||
file("Walkthrough.mov", "Projects/Atrium/Recordings", 60 * 26),
|
||||
file("Invoice.pdf", "Documents", 60 * 30),
|
||||
file("Survey.csv", "Projects/Dunes", 60 * 24 * 4),
|
||||
];
|
||||
section(
|
||||
"RecentFiles",
|
||||
"Files opened lately, newest first, under a header for each day. Enter or a double press opens one.",
|
||||
cx,
|
||||
)
|
||||
.child(probe(
|
||||
"files-recent",
|
||||
div().w(px(420.)).child(
|
||||
RecentFiles::new("files-recent", files)
|
||||
.on_open(|file, _, _| log::info!("gallery: open {}", file.name)),
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn favorites(cx: &mut App) -> impl IntoElement + use<> {
|
||||
let pin = |key: &str, icon: &str, title: &str| Favorite {
|
||||
key: key.to_string().into(),
|
||||
icon: icon.to_string().into(),
|
||||
title: title.to_string().into(),
|
||||
};
|
||||
section(
|
||||
"Favorites / Bookmarks",
|
||||
"Places pinned for quick reach, as documents::Favorites keeps them: a press opens one, a drag reorders, the star lets one go.",
|
||||
cx,
|
||||
)
|
||||
.child(probe(
|
||||
"files-favorites",
|
||||
div().w(px(280.)).child(
|
||||
Favorites::new(
|
||||
"files-favorites",
|
||||
[
|
||||
pin("atrium", "📐", "Atrium"),
|
||||
pin("roadmap", "🗺️", "Roadmap.md"),
|
||||
pin("music", "🎧", "Music"),
|
||||
],
|
||||
)
|
||||
.on_open(|key, _, _| log::info!("gallery: open {key}")),
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
/// Every file of the demo whose name holds `text`, ignoring case.
|
||||
fn matching(text: &str) -> Vec<FoundFile> {
|
||||
let text = text.trim().to_lowercase();
|
||||
if text.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut found = Vec::new();
|
||||
let mut walk = vec![vec![SharedString::from("Home")]];
|
||||
while let Some(path) = walk.pop() {
|
||||
for entry in super::explorer::listing(&path) {
|
||||
let mut next = path.clone();
|
||||
next.push(entry.name().clone());
|
||||
if entry.is_folder() {
|
||||
walk.push(next);
|
||||
} else if entry.name().to_lowercase().contains(&text) {
|
||||
let folder: Vec<&str> = path.iter().map(|level| level.as_ref()).collect();
|
||||
found.push(FoundFile {
|
||||
name: entry.name().clone(),
|
||||
folder: folder.join("/").into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
pub fn search(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let query = window.use_keyed_state("files-search-query", cx, |window, cx| {
|
||||
TextInput::new(window, cx)
|
||||
});
|
||||
let found = matching(query.read(cx).text());
|
||||
section(
|
||||
"FileSearch",
|
||||
"A search over files: the host searches as the name is typed, and each file found shows its folder. Enter or a double press opens one.",
|
||||
cx,
|
||||
)
|
||||
.child(probe(
|
||||
"files-search",
|
||||
div().w(px(420.)).child(
|
||||
FileSearch::new("files-search", &query, found)
|
||||
.on_open(|file, _, _| log::info!("gallery: open {}", file.name)),
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn duplicates(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let group = |name: &str, size: u64, paths: &[&str]| Duplicates {
|
||||
name: name.to_string().into(),
|
||||
size,
|
||||
paths: paths
|
||||
.iter()
|
||||
.map(|path| SharedString::from(path.to_string()))
|
||||
.collect(),
|
||||
};
|
||||
let groups = vec![
|
||||
group(
|
||||
"Atrium.jpg",
|
||||
2_140_000,
|
||||
&[
|
||||
"Projects/Atrium/Atrium.jpg",
|
||||
"Downloads/Atrium.jpg",
|
||||
"Desktop/Atrium copy.jpg",
|
||||
],
|
||||
),
|
||||
group(
|
||||
"Walkthrough.mov",
|
||||
184_000_000,
|
||||
&[
|
||||
"Projects/Atrium/Recordings/Walkthrough.mov",
|
||||
"Downloads/Walkthrough.mov",
|
||||
],
|
||||
),
|
||||
];
|
||||
let marked = keep(
|
||||
"files-duplicates",
|
||||
|| {
|
||||
vec![
|
||||
SharedString::from("Downloads/Atrium.jpg"),
|
||||
"Desktop/Atrium copy.jpg".into(),
|
||||
"Downloads/Walkthrough.mov".into(),
|
||||
]
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let now = marked.read(cx).clone();
|
||||
let (mark, remove) = (marked.clone(), marked);
|
||||
section(
|
||||
"DuplicateFinder",
|
||||
"Files found more than once, each copy with a mark; the last copy a group keeps stays. What the marked copies free sits beside Remove.",
|
||||
cx,
|
||||
)
|
||||
.child(probe(
|
||||
"files-duplicates",
|
||||
div().w(px(560.)).child(
|
||||
DuplicateFinder::new("files-duplicates", groups)
|
||||
.marked(now)
|
||||
.on_mark(move |path, on, _, cx| {
|
||||
change(&mark, cx, |marked| {
|
||||
marked.retain(|kept| kept != path);
|
||||
if on {
|
||||
marked.push(path.clone());
|
||||
}
|
||||
})
|
||||
})
|
||||
.on_remove(move |paths, _, cx| {
|
||||
log::info!("gallery: remove {} copies", paths.len());
|
||||
change(&remove, cx, |marked| marked.clear())
|
||||
}),
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn archive(cx: &mut App) -> impl IntoElement + use<> {
|
||||
let entry = |path: &str, size: u64, packed: u64| ArchiveEntry {
|
||||
path: SharedString::from(path.to_string()),
|
||||
size,
|
||||
packed,
|
||||
};
|
||||
let entries = [
|
||||
entry("Atrium/Brief.md", 4_800, 1_900),
|
||||
entry("Atrium/Budget.csv", 18_300, 4_100),
|
||||
entry("Atrium/Design/Moodboard.png", 3_200_000, 3_050_000),
|
||||
entry("Atrium/Design/Palette.json", 2_100, 600),
|
||||
entry("Atrium/main.rs", 9_200, 2_700),
|
||||
entry("README.md", 1_400, 700),
|
||||
];
|
||||
section(
|
||||
"ArchiveViewer",
|
||||
"What an archive holds, as a tree of its folders: each file with its size and how much packing saved.",
|
||||
cx,
|
||||
)
|
||||
.child(probe(
|
||||
"files-archive",
|
||||
div().w(px(420.)).h(px(300.)).child(
|
||||
ArchiveViewer::new("files-archive", "Atrium.zip", entries)
|
||||
.on_open(|entry, _, _| log::info!("gallery: open {}", entry.path)),
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn hex(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let bytes = keep(
|
||||
"files-hex",
|
||||
|| std::fs::read(ATRIUM).expect("the gallery's picture reads")[..512].to_vec(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let bytes = bytes.read(cx).clone();
|
||||
section(
|
||||
"HexViewer",
|
||||
"A file's first bytes, as debug::HexViewer shows them: offsets, hex and text side by side.",
|
||||
cx,
|
||||
)
|
||||
.child(probe(
|
||||
"files-hex",
|
||||
div()
|
||||
.w(px(640.))
|
||||
.h(px(220.))
|
||||
.child(HexViewer::new("files-hex", bytes)),
|
||||
))
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use jiff::{Timestamp, ToSpan};
|
||||
|
||||
mod actions;
|
||||
mod explorer;
|
||||
mod find;
|
||||
|
||||
use super::Page;
|
||||
use crate::{
|
||||
@@ -51,6 +52,17 @@ const SCRIPT: &[Step] = &[
|
||||
Step::Key("enter"),
|
||||
Step::Wait(300),
|
||||
Step::Shot("renamed"),
|
||||
Step::DownAt("files-quick", 150.0, 40.0),
|
||||
Step::UpAt("files-quick", 150.0, 40.0),
|
||||
Step::Key("space"),
|
||||
Step::Wait(300),
|
||||
Step::Shot("quick-look"),
|
||||
Step::Key("escape"),
|
||||
Step::DownAt("files-search", 100.0, 16.0),
|
||||
Step::UpAt("files-search", 100.0, 16.0),
|
||||
Step::Type("a"),
|
||||
Step::Wait(300),
|
||||
Step::Shot("searched"),
|
||||
];
|
||||
|
||||
/// A project's entries, changed a few hours to a few weeks ago.
|
||||
@@ -197,5 +209,12 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement {
|
||||
.child(actions::context_menu(cx))
|
||||
.child(actions::rename(window, cx))
|
||||
.child(actions::drop_files(window, cx))
|
||||
.child(find::previews(window, cx))
|
||||
.child(find::recent(cx))
|
||||
.child(find::favorites(cx))
|
||||
.child(find::search(window, cx))
|
||||
.child(find::duplicates(window, cx))
|
||||
.child(find::archive(cx))
|
||||
.child(find::hex(window, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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};
|
||||
|
||||
+7
-7
@@ -305,13 +305,13 @@
|
||||
|
||||
## T27c Files · Find and look
|
||||
|
||||
- [ ] FilePreview / QuickLook
|
||||
- [ ] RecentFiles
|
||||
- [ ] Favorites / Bookmarks → `documents::Favorites`
|
||||
- [ ] FileSearch
|
||||
- [ ] DuplicateFinder
|
||||
- [ ] ArchiveViewer — host entries
|
||||
- [ ] HexViewer → `debug::HexViewer`
|
||||
- [x] FilePreview / QuickLook
|
||||
- [x] RecentFiles
|
||||
- [x] Favorites / Bookmarks → `documents::Favorites`
|
||||
- [x] FileSearch
|
||||
- [x] DuplicateFinder
|
||||
- [x] ArchiveViewer — host entries
|
||||
- [x] HexViewer → `debug::HexViewer`
|
||||
|
||||
## T27d Files · Transfers
|
||||
|
||||
|
||||
Reference in new issue
Block a user