diff --git a/AGENTS.md b/AGENTS.md
index c146040..f47a965 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -112,6 +112,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- gpui 0.2.2 keeps a no-wrap line's first measure, taken at full width inside flex and scroll boxes, so `.truncate()` clips there instead of ending in an ellipsis. A line that may overflow is a `typography::Ellipsis`.
- 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.
+- 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.
- A picture drawn in code reaches gpui as a `RenderImage`. A host that replaces one frees the old through `App::drop_image`, or every frame stays in the sprite atlas (the gallery's model).
- `img()` keeps loading state, and moves an animated picture's frames, only with an id. Content masks are rectangles, so rounded corners survive only when the image fills its box without cropping.
diff --git a/TASKS.md b/TASKS.md
index 5a4269c..a4cd119 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -93,8 +93,11 @@ Per-component lines live in `tasks/`. Tags there:
- [x] T26b Media · Crop and annotate
- [x] T26c Media · Video
- [x] T26d Media · Audio and controls
-- [ ] T26e Media · Capture and devices
-- [ ] T27 Files
+- [x] T26e Media · Capture and devices
+- [ ] T27a Files · Items
+- [ ] T27b Files · Explorer
+- [ ] T27c Files · Find and look
+- [ ] T27d Files · Transfers
- [ ] T28 Messaging
- [ ] T29 Mail
- [ ] T30 Calendar
@@ -317,3 +320,4 @@ Per-component lines live in `tasks/`. Tags there:
| T26d | 1 | FAIL | the playlist's keys started from the first row, not the song under way; the waveform's bar fit sat untested in paint |
| T26d | 2 | PASS | |
| T26e | 1 | FAIL | an option list had no width limit, so a long device name ran it past the window |
+| T26e | 2 | PASS | |
diff --git a/assets/icons/file-archive.svg b/assets/icons/file-archive.svg
new file mode 100644
index 0000000..06f0b3f
--- /dev/null
+++ b/assets/icons/file-archive.svg
@@ -0,0 +1,20 @@
+
+
diff --git a/assets/icons/file-audio.svg b/assets/icons/file-audio.svg
new file mode 100644
index 0000000..81083d7
--- /dev/null
+++ b/assets/icons/file-audio.svg
@@ -0,0 +1,17 @@
+
+
diff --git a/assets/icons/file-code.svg b/assets/icons/file-code.svg
new file mode 100644
index 0000000..d9788f3
--- /dev/null
+++ b/assets/icons/file-code.svg
@@ -0,0 +1,18 @@
+
+
diff --git a/assets/icons/file-cog.svg b/assets/icons/file-cog.svg
new file mode 100644
index 0000000..16ce1c8
--- /dev/null
+++ b/assets/icons/file-cog.svg
@@ -0,0 +1,26 @@
+
+
diff --git a/assets/icons/file-image.svg b/assets/icons/file-image.svg
new file mode 100644
index 0000000..ec87e2b
--- /dev/null
+++ b/assets/icons/file-image.svg
@@ -0,0 +1,18 @@
+
+
diff --git a/assets/icons/file-json.svg b/assets/icons/file-json.svg
new file mode 100644
index 0000000..5e4df55
--- /dev/null
+++ b/assets/icons/file-json.svg
@@ -0,0 +1,18 @@
+
+
diff --git a/assets/icons/file-spreadsheet.svg b/assets/icons/file-spreadsheet.svg
new file mode 100644
index 0000000..4b35eea
--- /dev/null
+++ b/assets/icons/file-spreadsheet.svg
@@ -0,0 +1,20 @@
+
+
diff --git a/assets/icons/file-terminal.svg b/assets/icons/file-terminal.svg
new file mode 100644
index 0000000..6ae8e6a
--- /dev/null
+++ b/assets/icons/file-terminal.svg
@@ -0,0 +1,18 @@
+
+
diff --git a/assets/icons/file-type.svg b/assets/icons/file-type.svg
new file mode 100644
index 0000000..3e84b1c
--- /dev/null
+++ b/assets/icons/file-type.svg
@@ -0,0 +1,19 @@
+
+
diff --git a/assets/icons/file-video-camera.svg b/assets/icons/file-video-camera.svg
new file mode 100644
index 0000000..fecffa6
--- /dev/null
+++ b/assets/icons/file-video-camera.svg
@@ -0,0 +1,18 @@
+
+
diff --git a/examples/gallery/pages/files/mod.rs b/examples/gallery/pages/files/mod.rs
new file mode 100644
index 0000000..5c81bb2
--- /dev/null
+++ b/examples/gallery/pages/files/mod.rs
@@ -0,0 +1,169 @@
+use ely_gpui_component::{
+ files::{FileGrid, FileIcon, FileItem},
+ lists::{DirEntry, DirectoryListing, SelectableList},
+ theme::{ActiveTheme, IconSize, TextSize},
+};
+use gpui::{AnyElement, App, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
+use jiff::{Timestamp, ToSpan};
+
+use super::Page;
+use crate::{
+ probe::probe,
+ script::Step,
+ ui::{change, keep, section},
+};
+
+pub const PAGE: Page = Page {
+ number: 27,
+ slug: "files",
+ title: "Files",
+ summary: "Files and folders: their icons, rows and tiles, and the listings, searches and transfers around them.",
+ render,
+ script: SCRIPT,
+};
+
+const SCRIPT: &[Step] = &[
+ Step::DownAt("files-grid", 150.0, 40.0),
+ Step::UpAt("files-grid", 150.0, 40.0),
+ Step::Key("right"),
+ Step::Key("down"),
+ Step::Wait(200),
+ Step::Shot("grid-picked"),
+];
+
+/// A project's entries, changed a few hours to a few weeks ago.
+fn entries() -> Vec {
+ let now = Timestamp::now();
+ let ago = |hours: i64| now - hours.hours();
+ vec![
+ DirEntry::folder("Design", ago(3)),
+ DirEntry::folder("Recordings", ago(30)),
+ DirEntry::file("Brief.md", 4_800, ago(2)),
+ DirEntry::file("Budget.csv", 18_300, ago(26)),
+ DirEntry::file("Atrium.jpg", 2_140_000, ago(50)),
+ DirEntry::file("Walkthrough.mov", 184_000_000, ago(120)),
+ DirEntry::file("main.rs", 9_200, ago(1)),
+ DirEntry::file("Archive.zip", 48_000_000, ago(700)),
+ ]
+}
+
+fn icons(cx: &mut App) -> impl IntoElement + use<> {
+ let theme = cx.theme();
+ let (small, muted) = (theme.text_size(TextSize::Xs), theme.colors.fg_muted);
+ let names = [
+ "Brief.md",
+ "main.rs",
+ "build.sh",
+ "config.toml",
+ "data.json",
+ "Atrium.jpg",
+ "Theme.mp3",
+ "Clip.mov",
+ "Archive.zip",
+ "Budget.csv",
+ "Inter.ttf",
+ "Makefile",
+ ];
+ let tile = |icon: FileIcon, name: &'static str| {
+ div()
+ .w(px(88.))
+ .flex()
+ .flex_col()
+ .items_center()
+ .gap_1p5()
+ .child(icon.size(IconSize::Xl))
+ .child(div().text_size(small).text_color(muted).child(name))
+ };
+ section(
+ "FileIcon",
+ "A file's icon by its name's extension: code, text, pictures, sound, video, archives, tables, fonts and scripts; a folder shut or open.",
+ cx,
+ )
+ .child(probe(
+ "files-icons",
+ div()
+ .w(px(640.))
+ .flex()
+ .flex_wrap()
+ .gap_y_4()
+ .child(tile(FileIcon::folder(false), "Folder"))
+ .child(tile(FileIcon::folder(true), "Open"))
+ .children(names.map(|name| tile(FileIcon::file(name), name))),
+ ))
+}
+
+fn items(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
+ let picked = keep("files-items", || SharedString::from("Brief.md"), window, cx);
+ let now = picked.read(cx).clone();
+ let pick = picked.clone();
+ let list =
+ entries()
+ .into_iter()
+ .take(5)
+ .fold(SelectableList::new("files-items"), |list, entry| {
+ let key = entry.name().clone();
+ list.row(
+ key.clone(),
+ FileItem::new(SharedString::from(format!("files-item-{key}")), entry).into(),
+ )
+ });
+ section(
+ "FileItem",
+ "A file or folder as a row: its icon and name over its size and when it changed.",
+ cx,
+ )
+ .child(probe(
+ "files-items",
+ div()
+ .w(px(420.))
+ .child(list.selected([now]).on_change(move |keys, _, cx| {
+ if let Some(key) = keys.first() {
+ change(&pick, cx, |now| *now = key.clone())
+ }
+ })),
+ ))
+}
+
+fn listing(cx: &mut App) -> impl IntoElement + use<> {
+ section(
+ "FileList",
+ "A folder's entries in columns, as lists::DirectoryListing draws them: sort by a column, climb the path, open with Enter or a double press.",
+ cx,
+ )
+ .child(probe(
+ "files-listing",
+ div().w(px(640.)).h(px(360.)).child(
+ DirectoryListing::new("files-listing", ["Home", "Projects", "Atrium"], entries())
+ .on_open(|entry, _, _| log::info!("gallery: open {}", entry.name()))
+ .on_climb(|level, _, _| log::info!("gallery: climb to {level}")),
+ ),
+ ))
+}
+
+fn grid(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
+ let picked = keep("files-grid", || None::, window, cx);
+ let now = picked.read(cx).clone();
+ let pick = picked.clone();
+ let grid = FileGrid::new("files-grid", entries())
+ .on_select(move |name, _, cx| change(&pick, cx, |now| *now = Some(name.clone())))
+ .on_open(|entry, _, _| log::info!("gallery: open {}", entry.name()));
+ let grid = match now {
+ Some(name) => grid.selected(name),
+ None => grid,
+ };
+ section(
+ "FileGrid",
+ "Files and folders as tiles. A press picks one and a double press opens it; with focus the arrows move by tile and by row, and Enter opens.",
+ cx,
+ )
+ .child(probe("files-grid", div().w(px(640.)).child(grid)))
+}
+
+fn render(window: &mut Window, cx: &mut App) -> AnyElement {
+ div()
+ .child(icons(cx))
+ .child(items(window, cx))
+ .child(listing(cx))
+ .child(grid(window, cx))
+ .into_any_element()
+}
diff --git a/examples/gallery/pages/mod.rs b/examples/gallery/pages/mod.rs
index d8a1cf8..6e423d4 100644
--- a/examples/gallery/pages/mod.rs
+++ b/examples/gallery/pages/mod.rs
@@ -8,6 +8,7 @@ mod debug;
mod documents;
mod editor;
mod feedback;
+mod files;
mod finance;
mod forms;
mod generative;
@@ -67,6 +68,7 @@ pub const ALL: &[Page] = &[
agent::PAGE,
generative::PAGE,
media::PAGE,
+ files::PAGE,
theme::PAGE,
];
diff --git a/src/agent/changes.rs b/src/agent/changes.rs
index 471d2a9..ced7ea0 100644
--- a/src/agent/changes.rs
+++ b/src/agent/changes.rs
@@ -8,11 +8,10 @@ use gpui::{
use crate::{
buttons::{Button, ButtonVariant},
- chat::file_icon,
collab::Decision,
forms::OnFlag,
git::{DiffStat, DiffViewer, stat},
- primitives::{Disclosure, FocusRing, Icon, IconName},
+ primitives::{Disclosure, FocusRing, Icon, IconName, file_icon},
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
typography::Ellipsis,
};
diff --git a/src/chat/attach.rs b/src/chat/attach.rs
index ff9fcdb..43cbe75 100644
--- a/src/chat/attach.rs
+++ b/src/chat/attach.rs
@@ -5,13 +5,12 @@ use gpui::{
Styled, Window, div, relative,
};
-use super::media::file_icon;
use crate::{
buttons::{ButtonVariant, IconButton},
data_display::Tag,
documents::source,
forms::{Pick, Run},
- primitives::{Icon, IconName, Image},
+ primitives::{Icon, IconName, Image, file_icon},
theme::{ActiveTheme, AvatarSize, ControlSize, IconSize, Radius, TextSize},
typography::{Ellipsis, format::file_size},
};
diff --git a/src/chat/media.rs b/src/chat/media.rs
index 837c277..31fe0a5 100644
--- a/src/chat/media.rs
+++ b/src/chat/media.rs
@@ -10,7 +10,7 @@ use crate::{
buttons::{ButtonVariant, IconButton},
documents::source,
forms::{Pick, Run},
- primitives::{Icon, IconName, Image},
+ primitives::{Icon, IconName, Image, file_icon},
theme::{ActiveTheme, AvatarSize, ContainerSize, IconSize, Radius, TextSize},
typography::{Ellipsis, format::file_size},
};
@@ -205,16 +205,6 @@ impl RenderOnce for ImageGrid {
}
}
-/// The icon a file's name suggests.
-pub(crate) fn file_icon(name: &str) -> IconName {
- let extension = name.rsplit_once('.').map(|(_, ext)| ext.to_lowercase());
- match extension.as_deref() {
- Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "heic") => IconName::Image,
- Some("pdf" | "md" | "txt" | "doc" | "docx" | "rtf" | "pages") => IconName::FileText,
- _ => IconName::File,
- }
-}
-
/// A file in a message: its kind's icon, its name and size, and a way to download it.
#[derive(IntoElement)]
pub struct FileMessage {
@@ -412,15 +402,3 @@ impl RenderOnce for LinkPreviewCard {
}))
}
}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn a_file_name_picks_its_icon() {
- assert_eq!(file_icon("lift.PNG"), IconName::Image);
- assert_eq!(file_icon("notes.md"), IconName::FileText);
- assert_eq!(file_icon("archive"), IconName::File);
- }
-}
diff --git a/src/chat/mod.rs b/src/chat/mod.rs
index 6704e7c..2ff2256 100644
--- a/src/chat/mod.rs
+++ b/src/chat/mod.rs
@@ -28,7 +28,6 @@ pub use code::CodeBlock;
pub use composer::{AttachmentButton, DragDropOverlay, InputHint, PromptInput, SendButton};
pub use conversations::{Conversation, ConversationItem, ConversationList, new_chat_button};
pub use list::{DateSeparator, MessageList, ScrollToBottomButton};
-pub(crate) use media::file_icon;
pub use media::{FileMessage, ImageGrid, ImageMessage, LinkPreviewCard};
pub use message::{
ChatContainer, MessageAvatar, MessageBubble, MessageFooter, MessageHeader, Role,
diff --git a/src/chat/projects.rs b/src/chat/projects.rs
index 30d00dd..53b2958 100644
--- a/src/chat/projects.rs
+++ b/src/chat/projects.rs
@@ -7,13 +7,12 @@ use gpui::{
};
use jiff::Timestamp;
-use super::media::file_icon;
use crate::{
buttons::{Button, ButtonVariant, IconButton},
data_display::Meter,
forms::{CheckState, Checkbox, Choice, OnFlag, OnValue, Pick, RadioGroup, Run},
overlays::Dialog,
- primitives::{FocusRing, Icon, IconName},
+ primitives::{FocusRing, Icon, IconName, file_icon},
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
typography::{Ellipsis, RelativeTime, format::file_size, tabular},
};
diff --git a/src/files/grid.rs b/src/files/grid.rs
new file mode 100644
index 0000000..dc5e207
--- /dev/null
+++ b/src/files/grid.rs
@@ -0,0 +1,252 @@
+use std::rc::Rc;
+
+use gpui::{
+ App, ElementId, InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce,
+ SharedString, StatefulInteractiveElement, Styled, Window, canvas, div, prelude::*, relative,
+ rems, transparent_black,
+};
+
+use super::FileIcon;
+use crate::{
+ forms::OnValue,
+ layout::{columns_for, seeded::use_seeded},
+ lists::DirEntry,
+ primitives::{FocusRing, tab_stop},
+ theme::{ActiveTheme, IconSize, Radius, TextSize},
+ typography::LEADING,
+};
+
+type OnEntry = Rc;
+
+/// The tile a key moves to from `at` in `count` tiles laid `columns` a row: across by one, up and down by a row, Home and End to the ends; down from over a short last row lands on its last tile.
+pub(crate) fn stepped(at: usize, count: usize, columns: usize, key: &str) -> Option {
+ let last = count.checked_sub(1)?;
+ match key {
+ "left" => at.checked_sub(1),
+ "right" => (at < last).then_some(at + 1),
+ "up" => at.checked_sub(columns),
+ "down" if at + columns <= last => Some(at + columns),
+ "down" => (at / columns < last / columns).then_some(last),
+ "home" => Some(0),
+ "end" => Some(last),
+ _ => None,
+ }
+}
+
+/// Files and folders as tiles, in the order given: a large icon over the name in two lines at most. A press picks a tile and a double press opens it; with focus the arrows move by tile and by row, Home and End go to the ends, and Enter opens.
+#[derive(IntoElement)]
+pub struct FileGrid {
+ id: ElementId,
+ entries: Vec,
+ selected: Option,
+ on_select: Option,
+ on_open: Option,
+}
+
+impl FileGrid {
+ pub fn new(id: impl Into, entries: impl IntoIterator- ) -> Self {
+ let entries: Vec = entries.into_iter().collect();
+ for (ix, entry) in entries.iter().enumerate() {
+ assert!(
+ !entries[..ix]
+ .iter()
+ .any(|other| other.name() == entry.name()),
+ "{} twice",
+ entry.name()
+ );
+ }
+ Self {
+ id: id.into(),
+ entries,
+ selected: None,
+ on_select: None,
+ on_open: None,
+ }
+ }
+
+ /// The entry picked, by name.
+ pub fn selected(mut self, name: impl Into) -> Self {
+ let name = name.into();
+ assert!(
+ self.entries.iter().any(|entry| *entry.name() == name),
+ "no entry {name}"
+ );
+ self.selected = Some(name);
+ self
+ }
+
+ /// Gets the name of the entry picked.
+ pub fn on_select(
+ mut self,
+ handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
+ ) -> Self {
+ self.on_select = Some(Rc::new(handler));
+ self
+ }
+
+ pub fn on_open(mut self, handler: impl Fn(&DirEntry, &mut Window, &mut App) + 'static) -> Self {
+ self.on_open = Some(Rc::new(handler));
+ self
+ }
+}
+
+impl RenderOnce for FileGrid {
+ fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
+ let focus = tab_stop((self.id.clone(), "focus").into(), true, window, cx);
+ let cursor = use_seeded((self.id.clone(), "cursor"), self.selected, window, cx);
+ let width = window.use_keyed_state((self.id.clone(), "width"), cx, |_, _| Pixels::ZERO);
+ let entries: Rc<[DirEntry]> = self.entries.into();
+ let count = entries.len();
+ let at = cursor
+ .read(cx)
+ .value
+ .as_ref()
+ .and_then(|name| entries.iter().position(|entry| entry.name() == name));
+ let theme = cx.theme();
+ let colors = &theme.colors;
+ let rem = window.rem_size();
+ let (tile, gap) = (theme.files().tile, rems(0.5));
+ let columns =
+ columns_for(*width.read(cx), tile.to_pixels(rem), gap.to_pixels(rem)) as usize;
+ let pick = {
+ let (id, entries, cursor, on_select) = (
+ self.id.clone(),
+ entries.clone(),
+ cursor.clone(),
+ self.on_select,
+ );
+ Rc::new(move |ix: usize, window: &mut Window, cx: &mut App| {
+ let name = entries[ix].name().clone();
+ cursor.update(cx, |cursor, cx| {
+ cursor.value = Some(name.clone());
+ cx.notify();
+ });
+ log::info!("file grid {id:?}: picked {name}");
+ if let Some(on_select) = &on_select {
+ on_select(&name, window, cx);
+ }
+ })
+ };
+ let open = {
+ let (id, entries, on_open) = (self.id.clone(), entries.clone(), self.on_open);
+ Rc::new(move |ix: usize, window: &mut Window, cx: &mut App| {
+ log::info!("file grid {id:?}: open {}", entries[ix].name());
+ if let Some(on_open) = &on_open {
+ on_open(&entries[ix], window, cx);
+ }
+ })
+ };
+ let tiles = entries.iter().enumerate().map(|(ix, entry)| {
+ let (pick, open) = (pick.clone(), open.clone());
+ div()
+ .id((self.id.clone(), format!("tile-{ix}")))
+ .flex_none()
+ .w(tile)
+ .flex()
+ .flex_col()
+ .items_center()
+ .gap_1()
+ .px_1()
+ .py_2()
+ .rounded(theme.radius(Radius::Md))
+ .cursor_pointer()
+ .when(at == Some(ix), |tile| tile.bg(colors.active))
+ .when(at != Some(ix), |tile| {
+ tile.hover(|style| style.bg(colors.hover))
+ })
+ .child(FileIcon::entry(entry).size(IconSize::Xxl))
+ .child(
+ div()
+ .w_full()
+ .text_center()
+ .text_size(theme.text_size(TextSize::Sm))
+ .line_height(relative(LEADING))
+ .text_color(colors.fg)
+ .line_clamp(2)
+ .child(entry.name().clone()),
+ )
+ .on_click(move |event, window, cx| {
+ pick(ix, window, cx);
+ if event.click_count() == 2 {
+ open(ix, window, cx);
+ }
+ })
+ });
+ let tiles: Vec<_> = tiles.collect();
+ let measured = width.clone();
+ div()
+ .id(self.id)
+ .debug_selector(|| "file-grid".into())
+ .relative()
+ .w_full()
+ .flex()
+ .flex_wrap()
+ .gap(gap)
+ .rounded(theme.radius(Radius::Md))
+ .border_1()
+ .border_color(transparent_black())
+ .track_focus(&focus)
+ .focus_ring(cx)
+ .child(
+ canvas(
+ move |bounds, window, cx| {
+ if *measured.read(cx) != bounds.size.width {
+ measured.update(cx, |width, cx| {
+ *width = bounds.size.width;
+ cx.notify();
+ });
+ window.request_animation_frame();
+ }
+ },
+ |_, _, _, _| {},
+ )
+ .absolute()
+ .top_0()
+ .left_0()
+ .size_full(),
+ )
+ .children(tiles)
+ .on_key_down(move |event, window, cx| {
+ let held = &event.keystroke.modifiers;
+ if held.platform || held.control {
+ return;
+ }
+ let key = event.keystroke.key.as_str();
+ if key == "enter" {
+ if let Some(at) = at {
+ cx.stop_propagation();
+ open(at, window, cx);
+ }
+ return;
+ }
+ let to = match at {
+ Some(at) => stepped(at, count, columns, key),
+ None if key == "end" => count.checked_sub(1),
+ None => stepped(0, count, columns, "home")
+ .filter(|_| matches!(key, "left" | "right" | "up" | "down" | "home")),
+ };
+ if let Some(to) = to {
+ cx.stop_propagation();
+ pick(to, window, cx);
+ }
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::stepped;
+
+ #[test]
+ fn keys_move_across_by_one_and_down_by_a_row() {
+ assert_eq!(stepped(2, 7, 3, "right"), Some(3));
+ assert_eq!(stepped(0, 7, 3, "left"), None);
+ assert_eq!(stepped(1, 7, 3, "down"), Some(4));
+ assert_eq!(stepped(4, 7, 3, "down"), Some(6), "onto the short last row");
+ assert_eq!(stepped(6, 7, 3, "down"), None);
+ assert_eq!(stepped(5, 7, 3, "up"), Some(2));
+ assert_eq!(stepped(1, 7, 3, "up"), None);
+ assert_eq!(stepped(3, 7, 3, "end"), Some(6));
+ assert_eq!(stepped(0, 0, 3, "home"), None);
+ }
+}
diff --git a/src/files/icon.rs b/src/files/icon.rs
new file mode 100644
index 0000000..0126f03
--- /dev/null
+++ b/src/files/icon.rs
@@ -0,0 +1,55 @@
+use gpui::{App, IntoElement, RenderOnce, Window};
+
+use crate::{
+ lists::DirEntry,
+ primitives::{Icon, IconName, file_icon},
+ theme::{ActiveTheme, IconSize},
+};
+
+/// A file's icon by its name's extension, or a folder's, open or shut.
+#[derive(IntoElement)]
+pub struct FileIcon {
+ icon: IconName,
+ size: IconSize,
+}
+
+impl FileIcon {
+ pub fn file(name: impl AsRef) -> Self {
+ Self {
+ icon: file_icon(name.as_ref()),
+ size: IconSize::Md,
+ }
+ }
+
+ pub fn folder(open: bool) -> Self {
+ Self {
+ icon: if open {
+ IconName::FolderOpen
+ } else {
+ IconName::Folder
+ },
+ size: IconSize::Md,
+ }
+ }
+
+ /// The icon for a listing's entry: a shut folder, or the file's by its name.
+ pub fn entry(entry: &DirEntry) -> Self {
+ match entry.is_folder() {
+ true => Self::folder(false),
+ false => Self::file(entry.name()),
+ }
+ }
+
+ pub fn size(mut self, size: IconSize) -> Self {
+ self.size = size;
+ self
+ }
+}
+
+impl RenderOnce for FileIcon {
+ fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
+ Icon::new(self.icon)
+ .size(self.size)
+ .color(cx.theme().colors.fg_muted)
+ }
+}
diff --git a/src/files/item.rs b/src/files/item.rs
new file mode 100644
index 0000000..11f09e0
--- /dev/null
+++ b/src/files/item.rs
@@ -0,0 +1,48 @@
+use gpui::{App, ElementId, IntoElement, RenderOnce, Window};
+use jiff::Timestamp;
+
+use super::FileIcon;
+use crate::{
+ lists::{DirEntry, ListItem},
+ typography::format,
+};
+
+/// A file's size and when it changed, or a folder's word and when it changed.
+pub(crate) fn detail(entry: &DirEntry, now: Timestamp) -> String {
+ let when = format::relative(entry.modified(), now);
+ match entry.size() {
+ Some(bytes) => format!("{} · {when}", format::file_size(bytes, false)),
+ None => format!("Folder · {when}"),
+ }
+}
+
+/// A file or folder as a row: its icon and name over its size and when it changed. As a `ListItem`, it goes into a `SelectableList`.
+#[derive(IntoElement)]
+pub struct FileItem {
+ id: ElementId,
+ entry: DirEntry,
+}
+
+impl FileItem {
+ pub fn new(id: impl Into, entry: DirEntry) -> Self {
+ Self {
+ id: id.into(),
+ entry,
+ }
+ }
+}
+
+impl From for ListItem {
+ fn from(item: FileItem) -> Self {
+ let detail = detail(&item.entry, Timestamp::now());
+ ListItem::new(item.id, item.entry.name().clone())
+ .leading(FileIcon::entry(&item.entry))
+ .description(detail)
+ }
+}
+
+impl RenderOnce for FileItem {
+ fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
+ ListItem::from(self)
+ }
+}
diff --git a/src/files/mod.rs b/src/files/mod.rs
new file mode 100644
index 0000000..9f61dfc
--- /dev/null
+++ b/src/files/mod.rs
@@ -0,0 +1,10 @@
+mod grid;
+mod icon;
+mod item;
+
+#[cfg(all(test, feature = "test-support"))]
+mod tests;
+
+pub use grid::FileGrid;
+pub use icon::FileIcon;
+pub use item::FileItem;
diff --git a/src/files/tests.rs b/src/files/tests.rs
new file mode 100644
index 0000000..1543da1
--- /dev/null
+++ b/src/files/tests.rs
@@ -0,0 +1,158 @@
+use gpui::{
+ Context, Entity, IntoElement, KeyBinding, KeyUpEvent, Keystroke, Modifiers, ParentElement,
+ Render, SharedString, Styled, TestAppContext, VisualTestContext, Window, div, point, px,
+};
+use jiff::{Timestamp, ToSpan};
+
+use super::{FileGrid, item::detail};
+use crate::{lists::DirEntry, primitives::FocusNext, theme::Theme};
+
+fn setup(cx: &mut TestAppContext) {
+ cx.update(|cx| {
+ Theme::init(cx);
+ Theme::update(cx, |theme| theme.reduced_motion = true);
+ cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]);
+ });
+}
+
+fn settle(cx: &mut VisualTestContext) {
+ for _ in 0..3 {
+ cx.run_until_parked();
+ cx.update(|window, _| window.refresh());
+ }
+ cx.run_until_parked();
+}
+
+fn press(key: &str, cx: &mut VisualTestContext) {
+ cx.simulate_keystrokes(key);
+ settle(cx);
+ cx.simulate_event(KeyUpEvent {
+ keystroke: Keystroke::parse(key).expect("a key"),
+ });
+ settle(cx);
+}
+
+fn then() -> Timestamp {
+ Timestamp::from_second(1_700_000_000).expect("a time")
+}
+
+#[test]
+fn a_row_says_a_files_size_and_a_folders_kind_with_when_each_changed() {
+ let now = then() + 2.hours();
+ assert_eq!(
+ detail(&DirEntry::file("a.txt", 2_400, then()), now),
+ "2.4 KB · 2 hours ago"
+ );
+ assert_eq!(
+ detail(&DirEntry::folder("Docs", then()), now),
+ "Folder · 2 hours ago"
+ );
+}
+
+/// Seven files in a grid three tiles across, and what it picked and opened.
+struct Tiled {
+ selected: Option,
+ picked: Vec,
+ opened: Vec,
+}
+
+impl Render for Tiled {
+ fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement {
+ let (pick, open) = (cx.entity(), cx.entity());
+ let names = ["a", "b", "c", "d", "e", "f", "g"];
+ let grid = FileGrid::new("grid", names.map(|name| DirEntry::file(name, 10, then())))
+ .on_select(move |name, _, cx| pick.update(cx, |view, _| view.picked.push(name.clone())))
+ .on_open(move |entry, _, cx| {
+ open.update(cx, |view, _| view.opened.push(entry.name().clone()))
+ });
+ let grid = match self.selected.clone() {
+ Some(name) => grid.selected(name),
+ None => grid,
+ };
+ div().w(px(320.0)).child(grid)
+ }
+}
+
+fn tiled<'a>(
+ selected: Option<&'static str>,
+ cx: &'a mut TestAppContext,
+) -> (Entity, &'a mut VisualTestContext) {
+ setup(cx);
+ let selected = selected.map(|name| SharedString::from(name.to_string()));
+ let (view, cx) = cx.add_window_view(move |_, _| Tiled {
+ selected,
+ picked: Vec::new(),
+ opened: Vec::new(),
+ });
+ cx.update(|window, _| window.activate_window());
+ settle(cx);
+ (view, cx)
+}
+
+fn heard(
+ view: &Entity,
+ cx: &mut VisualTestContext,
+) -> (Vec, Vec) {
+ view.read_with(cx, |view, _| (view.picked.clone(), view.opened.clone()))
+}
+
+#[gpui::test]
+fn the_arrows_move_by_tile_and_by_row_and_enter_opens(cx: &mut TestAppContext) {
+ let (view, cx) = tiled(None, cx);
+ cx.update(|window, _| window.focus_next());
+ for key in ["right", "right", "down", "down", "cmd-left"] {
+ press(key, cx);
+ }
+ press("enter", cx);
+ let (picked, opened) = heard(&view, cx);
+ assert_eq!(picked, ["a", "b", "e", "g"], "a command key passes");
+ assert_eq!(opened, ["g"]);
+}
+
+#[gpui::test]
+fn the_keys_start_from_the_tile_picked(cx: &mut TestAppContext) {
+ let (view, cx) = tiled(Some("c"), cx);
+ cx.update(|window, _| window.focus_next());
+ press("down", cx);
+ assert_eq!(heard(&view, cx).0, ["f"]);
+}
+
+#[gpui::test]
+fn a_press_picks_a_tile_and_a_double_press_opens_it(cx: &mut TestAppContext) {
+ let (view, cx) = tiled(None, cx);
+ let grid = cx.debug_bounds("file-grid").expect("the grid draws");
+ let first = grid.origin + point(px(12.0), px(8.0));
+ cx.simulate_click(first, Modifiers::none());
+ settle(cx);
+ assert_eq!(heard(&view, cx), (vec!["a".into()], vec![]));
+ cx.simulate_event(gpui::MouseDownEvent {
+ button: gpui::MouseButton::Left,
+ position: first,
+ modifiers: Modifiers::none(),
+ click_count: 2,
+ first_mouse: false,
+ });
+ cx.simulate_event(gpui::MouseUpEvent {
+ button: gpui::MouseButton::Left,
+ position: first,
+ modifiers: Modifiers::none(),
+ click_count: 2,
+ });
+ settle(cx);
+ assert_eq!(heard(&view, cx).1, ["a"]);
+}
+
+#[test]
+#[should_panic(expected = "a twice")]
+fn a_name_is_listed_once() {
+ let _ = FileGrid::new(
+ "grid",
+ ["a", "a"].map(|name| DirEntry::file(name, 1, then())),
+ );
+}
+
+#[test]
+#[should_panic(expected = "no entry z")]
+fn the_pick_is_one_of_the_entries() {
+ let _ = FileGrid::new("grid", [DirEntry::file("a", 1, then())]).selected("z");
+}
diff --git a/src/layout/grid.rs b/src/layout/grid.rs
index 82c4036..384e54a 100644
--- a/src/layout/grid.rs
+++ b/src/layout/grid.rs
@@ -5,11 +5,18 @@ use gpui::{
use smallvec::SmallVec;
/// Columns for `width`, each at least `min`, `gap` apart.
-fn columns_for(width: Pixels, min: Pixels, gap: Pixels) -> u16 {
+pub(crate) fn columns_for(width: Pixels, min: Pixels, gap: Pixels) -> u16 {
let fit = ((width + gap) / (min + gap)).floor();
fit.clamp(1.0, f32::from(u16::MAX)) as u16
}
+/// How many items `item` wide with `gap` between fit across `width`, at least one, and the inset that centers them.
+pub(crate) fn fit(width: Pixels, item: Pixels, gap: Pixels) -> (usize, Pixels) {
+ let count = columns_for(width, item, gap) as usize;
+ let used = (item + gap) * count as f32 - gap;
+ (count, ((width - used) / 2.0).max(Pixels::ZERO))
+}
+
/// Index of the shortest column; ties go left.
fn shortest(heights: &[Pixels]) -> usize {
heights
@@ -174,7 +181,14 @@ impl RenderOnce for Masonry {
mod tests {
use gpui::px;
- use super::{columns_for, shortest};
+ use super::{columns_for, fit, shortest};
+
+ #[test]
+ fn what_fits_sits_centered() {
+ assert_eq!(fit(px(100.0), px(3.0), px(2.0)), (20, px(1.0)));
+ assert_eq!(fit(px(98.0), px(3.0), px(2.0)), (20, px(0.0)));
+ assert_eq!(fit(px(2.0), px(3.0), px(2.0)), (1, px(0.0)));
+ }
#[test]
fn columns_fit_the_width() {
diff --git a/src/layout/mod.rs b/src/layout/mod.rs
index 369b577..f03db1b 100644
--- a/src/layout/mod.rs
+++ b/src/layout/mod.rs
@@ -19,6 +19,7 @@ pub use collapse::{Accordion, AccordionItem, Collapsible};
pub use dock::{Dock, DockLayout, DockPanel, DockSide, Spot};
pub use floating::FloatingPanel;
pub use grid::{Masonry, SimpleGrid};
+pub(crate) use grid::{columns_for, fit};
pub use page::{AppShell, MasterDetail, Page};
pub use panes::{PaneGroup, PaneId, PaneLayout};
pub(crate) use scroll::bring_into_view;
diff --git a/src/lib.rs b/src/lib.rs
index 9d8c819..8ec1296 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -10,6 +10,7 @@ pub mod debug;
pub mod documents;
pub mod editor;
pub mod feedback;
+pub mod files;
pub mod finance;
pub mod forms;
pub mod generative;
diff --git a/src/lists/directory.rs b/src/lists/directory.rs
index 723395a..7c211cb 100644
--- a/src/lists/directory.rs
+++ b/src/lists/directory.rs
@@ -9,7 +9,7 @@ use jiff::Timestamp;
use super::{ListItem, SelectableList};
use crate::{
navigation::{Breadcrumb, Crumb},
- primitives::{Icon, IconName},
+ primitives::{Icon, IconName, file_icon},
theme::{ActiveTheme, IconSize, TextSize},
typography::{format, tabular},
};
@@ -46,6 +46,15 @@ impl DirEntry {
pub fn is_folder(&self) -> bool {
self.size.is_none()
}
+
+ /// A file's size in bytes; none for a folder.
+ pub fn size(&self) -> Option {
+ self.size
+ }
+
+ pub fn modified(&self) -> Timestamp {
+ self.modified
+ }
}
/// A column a listing sorts by.
@@ -223,7 +232,7 @@ impl RenderOnce for DirectoryListing {
Icon::new(if entry.is_folder() {
IconName::Folder
} else {
- IconName::File
+ file_icon(&entry.name)
})
.size(IconSize::Md)
.color(colors.fg_muted),
diff --git a/src/lists/files.rs b/src/lists/files.rs
index 57ddb05..e3e22fa 100644
--- a/src/lists/files.rs
+++ b/src/lists/files.rs
@@ -9,7 +9,7 @@ use super::{Tree, TreeNode};
use crate::{
data_display::Tone,
forms::{SearchInput, TextInput},
- primitives::IconName,
+ primitives::{IconName, file_icon},
theme::ControlSize,
};
@@ -57,19 +57,6 @@ impl GitStatus {
}
}
-/// The icon a file's name suggests.
-fn icon_for(name: &str) -> IconName {
- let kind = name.rsplit_once('.').map(|(_, kind)| kind.to_lowercase());
- match kind.as_deref() {
- Some("rs" | "ts" | "tsx" | "js" | "py" | "go" | "swift" | "c" | "h" | "sh") => {
- IconName::Code
- }
- Some("png" | "jpg" | "jpeg" | "gif" | "svg" | "webp") => IconName::Image,
- Some("toml" | "json" | "yaml" | "yml" | "lock") => IconName::SlidersHorizontal,
- _ => IconName::FileText,
- }
-}
-
/// A folder of the tree being built: its folders and its files, each by name.
#[derive(Default)]
struct Folder {
@@ -141,7 +128,7 @@ fn nodes(
files.sort_by_key(|name| name.to_lowercase());
for name in files {
let path = format!("{prefix}{name}");
- let node = TreeNode::new(path.clone(), name.clone()).icon(icon_for(name));
+ let node = TreeNode::new(path.clone(), name.clone()).icon(file_icon(name));
out.push(match state(&path) {
Some(state) => {
changed = true;
diff --git a/src/media/audio.rs b/src/media/audio.rs
index 1ae82c9..971cd50 100644
--- a/src/media/audio.rs
+++ b/src/media/audio.rs
@@ -9,6 +9,7 @@ use gpui::{
use super::scrubber::{OnTime, clock, time_at, time_tip};
use crate::{
forms::keyed,
+ layout::fit,
primitives::{FocusRing, tab_stop},
theme::{ActiveTheme, Radius},
};
@@ -32,13 +33,6 @@ pub(crate) fn resample(peaks: &[f32], count: usize) -> Vec {
.collect()
}
-/// How many bars `bar` wide with `gap` between fit across `width`, at least one, and the inset that centers them.
-pub(crate) fn fit(width: f32, bar: f32, gap: f32) -> (usize, f32) {
- let count = ((width + gap) / (bar + gap)).floor().max(1.0) as usize;
- let used = (bar + gap) * count as f32 - gap;
- (count, ((width - used) / 2.0).max(0.0))
-}
-
/// A seeking drag on a waveform, marked with its waveform.
struct Seek(EntityId);
@@ -121,7 +115,8 @@ impl RenderOnce for AudioWaveform {
move |bounds, _, window, _| {
let width = f32::from(bounds.size.width);
let pitch = f32::from(bar + gap);
- let (count, inset) = fit(width, f32::from(bar), f32::from(gap));
+ let (count, inset) = fit(bounds.size.width, bar, gap);
+ let inset = f32::from(inset);
for (ix, peak) in resample(&peaks, count).into_iter().enumerate() {
let left = inset + pitch * ix as f32;
let tall = (bounds.size.height * peak).max(bar);
@@ -248,14 +243,7 @@ impl RenderOnce for AudioSpectrum {
#[cfg(test)]
mod tests {
- use super::{fit, resample};
-
- #[test]
- fn the_bars_that_fit_sit_centered() {
- assert_eq!(fit(100.0, 3.0, 2.0), (20, 1.0));
- assert_eq!(fit(98.0, 3.0, 2.0), (20, 0.0));
- assert_eq!(fit(2.0, 3.0, 2.0), (1, 0.0));
- }
+ use super::resample;
#[test]
fn peaks_gather_into_bars_by_their_loudest() {
diff --git a/src/primitives/files.rs b/src/primitives/files.rs
new file mode 100644
index 0000000..6e20faf
--- /dev/null
+++ b/src/primitives/files.rs
@@ -0,0 +1,41 @@
+use super::IconName;
+
+/// The icon a file's name suggests, by its extension.
+pub(crate) fn file_icon(name: &str) -> IconName {
+ let extension = name.rsplit_once('.').map(|(_, ext)| ext.to_lowercase());
+ match extension.as_deref() {
+ Some(
+ "rs" | "ts" | "tsx" | "js" | "jsx" | "py" | "go" | "swift" | "c" | "h" | "cpp" | "java"
+ | "kt" | "rb" | "php" | "css" | "html",
+ ) => IconName::FileCode,
+ Some("sh" | "zsh" | "bash" | "command") => IconName::FileTerminal,
+ Some("toml" | "yaml" | "yml" | "ini" | "lock" | "env" | "conf") => IconName::FileCog,
+ Some("json") => IconName::FileJson,
+ Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "heic" | "bmp" | "tiff") => {
+ IconName::FileImage
+ }
+ Some("mp3" | "wav" | "flac" | "aac" | "m4a" | "ogg" | "aiff") => IconName::FileAudio,
+ Some("mp4" | "mov" | "mkv" | "webm" | "avi") => IconName::FileVideoCamera,
+ Some("zip" | "tar" | "gz" | "tgz" | "rar" | "7z") => IconName::FileArchive,
+ Some("csv" | "xls" | "xlsx" | "numbers") => IconName::FileSpreadsheet,
+ Some("ttf" | "otf" | "woff" | "woff2") => IconName::FileType,
+ Some("md" | "txt" | "rtf" | "pdf" | "doc" | "docx" | "pages") => IconName::FileText,
+ _ => IconName::File,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{IconName, file_icon};
+
+ #[test]
+ fn a_name_picks_its_icon_by_its_last_extension() {
+ assert_eq!(file_icon("main.RS"), IconName::FileCode);
+ assert_eq!(file_icon("lift.PNG"), IconName::FileImage);
+ assert_eq!(file_icon("notes.md"), IconName::FileText);
+ assert_eq!(file_icon("backup.tar.gz"), IconName::FileArchive);
+ assert_eq!(file_icon(".env"), IconName::FileCog);
+ assert_eq!(file_icon("take.mov"), IconName::FileVideoCamera);
+ assert_eq!(file_icon("Makefile"), IconName::File);
+ }
+}
diff --git a/src/primitives/icon.rs b/src/primitives/icon.rs
index 927162d..a27d1da 100644
--- a/src/primitives/icon.rs
+++ b/src/primitives/icon.rs
@@ -129,9 +129,19 @@ icons! {
ExternalLink => "external-link",
EyeOff => "eye-off",
Eye => "eye",
+ FileArchive => "file-archive",
+ FileAudio => "file-audio",
+ FileCode => "file-code",
+ FileCog => "file-cog",
+ FileImage => "file-image",
+ FileJson => "file-json",
FilePlus => "file-plus",
+ FileSpreadsheet => "file-spreadsheet",
+ FileTerminal => "file-terminal",
FileText => "file-text",
File => "file",
+ FileType => "file-type",
+ FileVideoCamera => "file-video-camera",
Filter => "filter",
Fingerprint => "fingerprint",
Flag => "flag",
diff --git a/src/primitives/mod.rs b/src/primitives/mod.rs
index a483038..8583838 100644
--- a/src/primitives/mod.rs
+++ b/src/primitives/mod.rs
@@ -1,6 +1,7 @@
mod backdrop;
mod disclosure;
mod divider;
+mod files;
mod focus;
mod ghost;
mod icon;
@@ -13,6 +14,7 @@ mod tooltip;
pub use backdrop::{Backdrop, Place};
pub use disclosure::Disclosure;
pub use divider::Divider;
+pub(crate) use files::file_icon;
pub use focus::{FocusNext, FocusPrev, FocusRing, FocusScope};
pub(crate) use focus::{Takeover, give_back, hand_back, hold_focus, tab_stop, take_focus};
pub use ghost::DragGhost;
diff --git a/src/theme/files.rs b/src/theme/files.rs
new file mode 100644
index 0000000..57e34df
--- /dev/null
+++ b/src/theme/files.rs
@@ -0,0 +1,18 @@
+use gpui::Rems;
+
+use super::{Theme, tokens::px_to_rems};
+
+/// File views' measures.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct FileSizes {
+ /// A tile in a grid of files, wide.
+ pub tile: Rems,
+}
+
+impl Theme {
+ pub fn files(&self) -> FileSizes {
+ FileSizes {
+ tile: px_to_rems(96.0),
+ }
+ }
+}
diff --git a/src/theme/mod.rs b/src/theme/mod.rs
index 0b71b5d..4809c95 100644
--- a/src/theme/mod.rs
+++ b/src/theme/mod.rs
@@ -1,4 +1,5 @@
mod chart;
+mod files;
mod generative;
mod media;
mod palette;
@@ -10,6 +11,7 @@ use gpui::{App, Global, SharedString, WindowAppearance};
use crate::motion;
pub use chart::ChartSizes;
+pub use files::FileSizes;
pub use generative::GenerativeSizes;
pub use media::MediaSizes;
pub use palette::{Mix, Palette, Syntax};
diff --git a/tasks/ch21-30.md b/tasks/ch21-30.md
index 2e551f3..308de0c 100644
--- a/tasks/ch21-30.md
+++ b/tasks/ch21-30.md
@@ -287,23 +287,25 @@
- [x] DeviceSelector
- [x] 3DModelViewer — host frames, orbit input
-## T27 Files — `src/files`
+## T27a Files · Items — `src/files`
+
+- [x] FileIcon — one map from name to icon, in `primitives`
+- [x] FileItem
+- [x] FileList → `lists::DirectoryListing`
+- [x] FileGrid
+
+## T27b Files · Explorer
- [ ] FileExplorer — list, icons, columns
-- [ ] FileGrid
-- [ ] FileList
-- [ ] FileItem
-- [ ] FileIcon
-- [ ] FilePreview / QuickLook
- [ ] FileBreadcrumb / PathBar → `navigation::Breadcrumb`
- [ ] FolderTree → `lists::FileTree`
- [ ] FileContextMenu → `menus::ContextMenu`
- [ ] RenameInline → `forms::InlineEdit`
- [ ] DragDropFiles → `forms::DropZone`
-- [ ] FileOperationProgress
-- [ ] TransferQueue
-- [ ] DownloadManager
-- [ ] StorageUsage → `data_display::UsageBar`
+
+## T27c Files · Find and look
+
+- [ ] FilePreview / QuickLook
- [ ] RecentFiles
- [ ] Favorites / Bookmarks → `documents::Favorites`
- [ ] FileSearch
@@ -311,6 +313,13 @@
- [ ] ArchiveViewer — host entries
- [ ] HexViewer → `debug::HexViewer`
+## T27d Files · Transfers
+
+- [ ] FileOperationProgress
+- [ ] TransferQueue
+- [ ] DownloadManager
+- [ ] StorageUsage → `data_display::UsageBar`
+
## T28 Messaging — `src/messaging`
- [ ] ChannelList / ChatList