T13c Files: FileTree with git status and filter, DirectoryListing, SelectableList activation (review pending: codex 401)
This commit is contained in:
11 files changed
+859
-10
No files matched your search
@@ -190,3 +190,4 @@ Per-component lines live in `tasks/`. Tags there:
|
|||||||
| T12d | 1 | — | pending: codex still logged out |
|
| T12d | 1 | — | pending: codex still logged out |
|
||||||
| T13a | 1 | — | pending: codex still logged out |
|
| T13a | 1 | — | pending: codex still logged out |
|
||||||
| T13b | 1 | — | pending: codex still logged out |
|
| T13b | 1 | — | pending: codex still logged out |
|
||||||
|
| T13c | 1 | — | pending: codex still logged out |
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
use ely_gpui_component::{
|
||||||
|
lists::{DirEntry, DirectoryListing, FileTree, GitStatus},
|
||||||
|
theme::{ActiveTheme, Radius},
|
||||||
|
typography::Caption,
|
||||||
|
};
|
||||||
|
use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
|
||||||
|
use jiff::{Timestamp, ToSpan};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
probe::probe,
|
||||||
|
ui::{keep, section, set},
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROJECT: [&str; 12] = [
|
||||||
|
"Cargo.toml",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE",
|
||||||
|
"src/main.rs",
|
||||||
|
"src/app.rs",
|
||||||
|
"src/ui/sidebar.rs",
|
||||||
|
"src/ui/editor.rs",
|
||||||
|
"src/ui/toolbar.rs",
|
||||||
|
"src/sync/cloud.rs",
|
||||||
|
"assets/logo.svg",
|
||||||
|
"assets/fonts/Inter.ttf",
|
||||||
|
"tests/editing.rs",
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn file_tree(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||||
|
let opened = keep("file-opened", || None::<SharedString>, window, cx);
|
||||||
|
let (now, store) = (opened.read(cx).clone(), opened.clone());
|
||||||
|
let theme = cx.theme();
|
||||||
|
section(
|
||||||
|
"FileTree",
|
||||||
|
"A project's files: folders first, an icon for each kind, git status by letter and tint, and a dot on folders with changes. Type to filter; matches keep their folders, which open.",
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.child(probe(
|
||||||
|
"file-tree",
|
||||||
|
div()
|
||||||
|
.w(px(320.))
|
||||||
|
.h(px(360.))
|
||||||
|
.p_2()
|
||||||
|
.rounded(theme.radius(Radius::Md))
|
||||||
|
.border_1()
|
||||||
|
.border_color(theme.colors.border)
|
||||||
|
.child(
|
||||||
|
FileTree::new("files", PROJECT)
|
||||||
|
.status("src/ui/sidebar.rs", GitStatus::Modified)
|
||||||
|
.status("src/sync/cloud.rs", GitStatus::Added)
|
||||||
|
.status("tests/editing.rs", GitStatus::Untracked)
|
||||||
|
.status("LICENSE", GitStatus::Deleted)
|
||||||
|
.on_open(move |path, _, cx| set(&store, Some(path.clone()), cx))
|
||||||
|
.size_full(),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.child(Caption::new(match now {
|
||||||
|
Some(path) => format!("Opened {path}"),
|
||||||
|
None => "Double press a file, or press Enter, to open it.".to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The demo disk: each folder's path and what it holds.
|
||||||
|
fn listing(path: &[&str], now: Timestamp) -> Vec<DirEntry> {
|
||||||
|
let ago = |hours: i64| now.checked_sub(hours.hours()).expect("a time in range");
|
||||||
|
match path.last().copied() {
|
||||||
|
Some("Projects") => vec![
|
||||||
|
DirEntry::folder("Ely", ago(2)),
|
||||||
|
DirEntry::file("Roadmap.key", 18_400_000, ago(30)),
|
||||||
|
DirEntry::file("Sketches.fig", 6_200_000, ago(90)),
|
||||||
|
],
|
||||||
|
_ => vec![
|
||||||
|
DirEntry::folder("Projects", ago(3)),
|
||||||
|
DirEntry::folder("Receipts", ago(240)),
|
||||||
|
DirEntry::file("Budget 2026.numbers", 2_310_000, ago(26)),
|
||||||
|
DirEntry::file("Letter.pages", 410_000, ago(5)),
|
||||||
|
DirEntry::file("Scan 12.pdf", 1_120_000, ago(120)),
|
||||||
|
DirEntry::file("notes.txt", 3_200, ago(1)),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn directory(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||||
|
let opened = keep("dir-opened", Timestamp::now, window, cx);
|
||||||
|
let at = keep(
|
||||||
|
"dir-path",
|
||||||
|
|| vec!["Macintosh HD", "Users", "grace", "Documents"],
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
let (path, into, back) = (at.read(cx).clone(), at.clone(), at.clone());
|
||||||
|
let entries = listing(&path, *opened.read(cx));
|
||||||
|
section(
|
||||||
|
"DirectoryListing",
|
||||||
|
"A folder's contents in columns. A header sorts by its column and a second press reverses it; folders stay first. Open a folder with a double press or Enter; the path above climbs back.",
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div().w(px(560.)).child(
|
||||||
|
DirectoryListing::new("directory", path.clone(), entries)
|
||||||
|
.on_open(move |entry, _, cx| {
|
||||||
|
if entry.is_folder() && entry.name().as_ref() == "Projects" {
|
||||||
|
let mut next = into.read(cx).clone();
|
||||||
|
next.push("Projects");
|
||||||
|
set(&into, next, cx);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on_climb(move |level, _, cx| {
|
||||||
|
let mut next = back.read(cx).clone();
|
||||||
|
next.truncate(level + 1);
|
||||||
|
set(&back, next, cx);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
mod files;
|
||||||
mod long;
|
mod long;
|
||||||
mod rows;
|
mod rows;
|
||||||
mod trees;
|
mod trees;
|
||||||
@@ -42,6 +43,12 @@ const SCRIPT: &[Step] = &[
|
|||||||
Step::UpAt("outline", 40.0, 150.0),
|
Step::UpAt("outline", 40.0, 150.0),
|
||||||
Step::Wait(500),
|
Step::Wait(500),
|
||||||
Step::Shot("outline-moved"),
|
Step::Shot("outline-moved"),
|
||||||
|
Step::DownAt("file-tree", 120.0, 22.0),
|
||||||
|
Step::UpAt("file-tree", 120.0, 22.0),
|
||||||
|
Step::Key("cmd-a"),
|
||||||
|
Step::Type("side"),
|
||||||
|
Step::Wait(300),
|
||||||
|
Step::Shot("files-filtered"),
|
||||||
Step::Rest,
|
Step::Rest,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -58,5 +65,7 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement {
|
|||||||
.child(trees::checkbox(window, cx))
|
.child(trees::checkbox(window, cx))
|
||||||
.child(trees::select(window, cx))
|
.child(trees::select(window, cx))
|
||||||
.child(trees::outline(window, cx))
|
.child(trees::outline(window, cx))
|
||||||
|
.child(files::file_tree(window, cx))
|
||||||
|
.child(files::directory(window, cx))
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use gpui::{
|
||||||
|
App, ElementId, InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce,
|
||||||
|
SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*,
|
||||||
|
};
|
||||||
|
use jiff::Timestamp;
|
||||||
|
|
||||||
|
use super::{ListItem, SelectableList};
|
||||||
|
use crate::{
|
||||||
|
navigation::{Breadcrumb, Crumb},
|
||||||
|
primitives::{Icon, IconName},
|
||||||
|
theme::{ActiveTheme, IconSize, TextSize},
|
||||||
|
typography::{format, tabular},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// One entry of a directory: a folder, or a file with its size; both with when they last changed.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct DirEntry {
|
||||||
|
name: SharedString,
|
||||||
|
size: Option<u64>,
|
||||||
|
modified: Timestamp,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DirEntry {
|
||||||
|
pub fn folder(name: impl Into<SharedString>, modified: Timestamp) -> Self {
|
||||||
|
Self {
|
||||||
|
name: name.into(),
|
||||||
|
size: None,
|
||||||
|
modified,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn file(name: impl Into<SharedString>, size: u64, modified: Timestamp) -> Self {
|
||||||
|
Self {
|
||||||
|
name: name.into(),
|
||||||
|
size: Some(size),
|
||||||
|
modified,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn name(&self) -> &SharedString {
|
||||||
|
&self.name
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_folder(&self) -> bool {
|
||||||
|
self.size.is_none()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A column a listing sorts by.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
enum Column {
|
||||||
|
Name,
|
||||||
|
Size,
|
||||||
|
Modified,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entries sorted by `column`, folders always first, ties by name.
|
||||||
|
fn sorted(mut entries: Vec<DirEntry>, column: Column, ascending: bool) -> Vec<DirEntry> {
|
||||||
|
let by_name = |a: &DirEntry, b: &DirEntry| a.name.to_lowercase().cmp(&b.name.to_lowercase());
|
||||||
|
entries.sort_by(|a, b| {
|
||||||
|
let chosen = match column {
|
||||||
|
Column::Name => by_name(a, b),
|
||||||
|
Column::Size => a.size.cmp(&b.size),
|
||||||
|
Column::Modified => a.modified.cmp(&b.modified),
|
||||||
|
};
|
||||||
|
let chosen = if ascending { chosen } else { chosen.reverse() };
|
||||||
|
b.is_folder()
|
||||||
|
.cmp(&a.is_folder())
|
||||||
|
.then(chosen)
|
||||||
|
.then_with(|| by_name(a, b))
|
||||||
|
});
|
||||||
|
entries
|
||||||
|
}
|
||||||
|
|
||||||
|
type OnEntry = Rc<dyn Fn(&DirEntry, &mut Window, &mut App)>;
|
||||||
|
type OnClimb = Rc<dyn Fn(usize, &mut Window, &mut App)>;
|
||||||
|
|
||||||
|
/// A directory's entries in columns: name, size, and when each changed. A header sorts by its column and a second press reverses it; folders stay first. Enter or a double press opens an entry; the path above climbs to an earlier level.
|
||||||
|
#[derive(IntoElement)]
|
||||||
|
pub struct DirectoryListing {
|
||||||
|
id: ElementId,
|
||||||
|
path: Vec<SharedString>,
|
||||||
|
entries: Vec<DirEntry>,
|
||||||
|
on_open: Option<OnEntry>,
|
||||||
|
on_climb: Option<OnClimb>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DirectoryListing {
|
||||||
|
/// `path` names each level from the root down to this directory.
|
||||||
|
pub fn new(
|
||||||
|
id: impl Into<ElementId>,
|
||||||
|
path: impl IntoIterator<Item = impl Into<SharedString>>,
|
||||||
|
entries: impl IntoIterator<Item = DirEntry>,
|
||||||
|
) -> Self {
|
||||||
|
let path: Vec<SharedString> = path.into_iter().map(Into::into).collect();
|
||||||
|
assert!(!path.is_empty(), "a listing needs the directory it lists");
|
||||||
|
Self {
|
||||||
|
id: id.into(),
|
||||||
|
path,
|
||||||
|
entries: entries.into_iter().collect(),
|
||||||
|
on_open: None,
|
||||||
|
on_climb: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_open(mut self, handler: impl Fn(&DirEntry, &mut Window, &mut App) + 'static) -> Self {
|
||||||
|
self.on_open = Some(Rc::new(handler));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the level pressed in the path, zero at the root.
|
||||||
|
pub fn on_climb(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
|
||||||
|
self.on_climb = Some(Rc::new(handler));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderOnce for DirectoryListing {
|
||||||
|
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
|
let order =
|
||||||
|
window.use_keyed_state((self.id.clone(), "order"), cx, |_, _| (Column::Name, true));
|
||||||
|
let picked = window.use_keyed_state((self.id.clone(), "picked"), cx, |_, _| {
|
||||||
|
Vec::<SharedString>::new()
|
||||||
|
});
|
||||||
|
let (column, ascending) = *order.read(cx);
|
||||||
|
let entries = Rc::new(sorted(self.entries, column, ascending));
|
||||||
|
let theme = cx.theme();
|
||||||
|
let colors = &theme.colors;
|
||||||
|
let (size_width, date_width) = (theme.label_width() * 0.5, theme.label_width() * 0.75);
|
||||||
|
let now = Timestamp::now();
|
||||||
|
let header = |label: &'static str, this: Column| {
|
||||||
|
let order = order.clone();
|
||||||
|
div()
|
||||||
|
.id((self.id.clone(), label))
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.gap_1()
|
||||||
|
.cursor_pointer()
|
||||||
|
.hover(|style| style.text_color(colors.fg))
|
||||||
|
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
|
||||||
|
.on_click(move |_, _, cx| {
|
||||||
|
order.update(cx, |order, cx| {
|
||||||
|
*order = if order.0 == this {
|
||||||
|
(this, !order.1)
|
||||||
|
} else {
|
||||||
|
(this, true)
|
||||||
|
};
|
||||||
|
log::info!(
|
||||||
|
"directory listing: sorted by {:?}, {}",
|
||||||
|
order.0,
|
||||||
|
if order.1 { "rising" } else { "falling" }
|
||||||
|
);
|
||||||
|
cx.notify();
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.child(label)
|
||||||
|
.when(column == this, |header| {
|
||||||
|
header.child(
|
||||||
|
Icon::new(if ascending {
|
||||||
|
IconName::ArrowUpNarrowWide
|
||||||
|
} else {
|
||||||
|
IconName::ArrowDownWideNarrow
|
||||||
|
})
|
||||||
|
.size(IconSize::Xs)
|
||||||
|
.color(colors.fg_muted),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let heading = div()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.gap_3()
|
||||||
|
.px_3()
|
||||||
|
.py_1p5()
|
||||||
|
.border_b_1()
|
||||||
|
.border_color(colors.border)
|
||||||
|
.text_size(theme.text_size(TextSize::Xs))
|
||||||
|
.text_color(colors.fg_muted)
|
||||||
|
.child(div().flex_1().child(header("Name", Column::Name)))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.w(size_width)
|
||||||
|
.flex()
|
||||||
|
.justify_end()
|
||||||
|
.child(header("Size", Column::Size)),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.w(date_width)
|
||||||
|
.flex()
|
||||||
|
.justify_end()
|
||||||
|
.child(header("Modified", Column::Modified)),
|
||||||
|
);
|
||||||
|
let list = entries.iter().enumerate().fold(
|
||||||
|
SelectableList::new((self.id.clone(), "entries")).selected(picked.read(cx).clone()),
|
||||||
|
|list, (ix, entry)| {
|
||||||
|
let size = entry
|
||||||
|
.size
|
||||||
|
.map(|bytes| format::file_size(bytes, false))
|
||||||
|
.unwrap_or_else(|| "—".into());
|
||||||
|
let cells = div()
|
||||||
|
.flex()
|
||||||
|
.gap_3()
|
||||||
|
.child(
|
||||||
|
tabular(div())
|
||||||
|
.w(size_width)
|
||||||
|
.flex()
|
||||||
|
.justify_end()
|
||||||
|
.child(size),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.w(date_width)
|
||||||
|
.flex()
|
||||||
|
.justify_end()
|
||||||
|
.child(format::relative(entry.modified, now)),
|
||||||
|
);
|
||||||
|
let item =
|
||||||
|
ListItem::new((self.id.clone(), format!("entry-{ix}")), entry.name.clone())
|
||||||
|
.leading(
|
||||||
|
Icon::new(if entry.is_folder() {
|
||||||
|
IconName::Folder
|
||||||
|
} else {
|
||||||
|
IconName::File
|
||||||
|
})
|
||||||
|
.size(IconSize::Md)
|
||||||
|
.color(colors.fg_muted),
|
||||||
|
)
|
||||||
|
.trailing(cells);
|
||||||
|
list.row(entry.name.clone(), item)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let (store, shown, on_open) = (picked.clone(), entries.clone(), self.on_open);
|
||||||
|
let crumbs = self.path.iter().enumerate().map(|(ix, name)| {
|
||||||
|
let crumb = Crumb::new(SharedString::from(ix.to_string()), name.clone());
|
||||||
|
if ix == 0 {
|
||||||
|
crumb.icon(IconName::HardDrive)
|
||||||
|
} else {
|
||||||
|
crumb
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let on_climb = self.on_climb;
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap_2()
|
||||||
|
.child(
|
||||||
|
Breadcrumb::new((self.id.clone(), "path"), crumbs).on_select(
|
||||||
|
move |level, _, window, cx| {
|
||||||
|
if let Some(on_climb) = &on_climb {
|
||||||
|
on_climb(level, window, cx);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(heading)
|
||||||
|
.child(
|
||||||
|
list.on_change(move |keys, _, cx| {
|
||||||
|
store.update(cx, |picked, cx| {
|
||||||
|
*picked = keys.to_vec();
|
||||||
|
cx.notify();
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.on_activate(move |name, window, cx| {
|
||||||
|
let entry = shown
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.name == *name)
|
||||||
|
.expect("a listed entry");
|
||||||
|
if let Some(on_open) = &on_open {
|
||||||
|
on_open(entry, window, cx);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use jiff::Timestamp;
|
||||||
|
|
||||||
|
use super::{Column, DirEntry, sorted};
|
||||||
|
|
||||||
|
fn names(entries: Vec<DirEntry>) -> Vec<String> {
|
||||||
|
entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|entry| entry.name.to_string())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn folders_stay_first_whichever_way_it_sorts() {
|
||||||
|
let at = |seconds: i64| Timestamp::from_second(seconds).expect("a time");
|
||||||
|
let entries = vec![
|
||||||
|
DirEntry::file("notes.md", 900, at(30)),
|
||||||
|
DirEntry::folder("Photos", at(10)),
|
||||||
|
DirEntry::file("archive.zip", 50_000, at(20)),
|
||||||
|
DirEntry::folder("apps", at(40)),
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
names(sorted(entries.clone(), Column::Name, true)),
|
||||||
|
["apps", "Photos", "archive.zip", "notes.md"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
names(sorted(entries.clone(), Column::Size, false)),
|
||||||
|
["apps", "Photos", "archive.zip", "notes.md"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
names(sorted(entries.clone(), Column::Modified, true)),
|
||||||
|
["Photos", "apps", "archive.zip", "notes.md"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
names(sorted(entries, Column::Name, false)),
|
||||||
|
["Photos", "apps", "notes.md", "archive.zip"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
use std::{collections::BTreeMap, rc::Rc};
|
||||||
|
|
||||||
|
use gpui::{
|
||||||
|
App, Div, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, StyleRefinement,
|
||||||
|
Styled, Window, div,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{Tree, TreeNode};
|
||||||
|
use crate::{
|
||||||
|
data_display::Tone,
|
||||||
|
forms::{SearchInput, TextInput},
|
||||||
|
primitives::IconName,
|
||||||
|
theme::ControlSize,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A file's state in git.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum GitStatus {
|
||||||
|
Modified,
|
||||||
|
Added,
|
||||||
|
Deleted,
|
||||||
|
Untracked,
|
||||||
|
Renamed,
|
||||||
|
Conflicted,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GitStatus {
|
||||||
|
fn letter(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Modified => "M",
|
||||||
|
Self::Added => "A",
|
||||||
|
Self::Deleted => "D",
|
||||||
|
Self::Untracked => "U",
|
||||||
|
Self::Renamed => "R",
|
||||||
|
Self::Conflicted => "!",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tone(self) -> Tone {
|
||||||
|
match self {
|
||||||
|
Self::Modified => Tone::Warning,
|
||||||
|
Self::Added | Self::Untracked => Tone::Success,
|
||||||
|
Self::Deleted | Self::Conflicted => Tone::Danger,
|
||||||
|
Self::Renamed => Tone::Info,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
folders: BTreeMap<String, Folder>,
|
||||||
|
files: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `name` matches `query`, ignoring case; an empty query matches all.
|
||||||
|
fn matches(name: &str, query: &str) -> bool {
|
||||||
|
query.is_empty() || name.to_lowercase().contains(&query.to_lowercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paths as nodes, folders first, then files, each by name; with a query, only matches and the folders that hold them, which it opens.
|
||||||
|
fn nodes(
|
||||||
|
paths: &[SharedString],
|
||||||
|
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 state = |path: &str| {
|
||||||
|
status
|
||||||
|
.iter()
|
||||||
|
.find(|(known, _)| known.as_ref() == path)
|
||||||
|
.map(|(_, state)| *state)
|
||||||
|
};
|
||||||
|
fn walk(
|
||||||
|
folder: &Folder,
|
||||||
|
prefix: &str,
|
||||||
|
query: &str,
|
||||||
|
state: &dyn Fn(&str) -> Option<GitStatus>,
|
||||||
|
open: &mut Vec<SharedString>,
|
||||||
|
) -> (Vec<TreeNode>, bool) {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut changed = false;
|
||||||
|
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, inner_changed) = walk(inner, &format!("{path}/"), query, state, open);
|
||||||
|
if children.is_empty() && !matches(name, query) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !query.is_empty() {
|
||||||
|
open.push(path.clone().into());
|
||||||
|
}
|
||||||
|
changed |= inner_changed;
|
||||||
|
let node = TreeNode::new(path, name.clone())
|
||||||
|
.icon(IconName::Folder)
|
||||||
|
.children(children);
|
||||||
|
out.push(if inner_changed {
|
||||||
|
node.note("•").tone(Tone::Warning)
|
||||||
|
} else {
|
||||||
|
node
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mut files: Vec<_> = folder
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.filter(|name| matches(name, query))
|
||||||
|
.collect();
|
||||||
|
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));
|
||||||
|
out.push(match state(&path) {
|
||||||
|
Some(state) => {
|
||||||
|
changed = true;
|
||||||
|
node.note(state.letter()).tone(state.tone())
|
||||||
|
}
|
||||||
|
None => node,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
(out, changed)
|
||||||
|
}
|
||||||
|
let mut open = Vec::new();
|
||||||
|
let (nodes, _) = walk(&root, "", query, &state, &mut open);
|
||||||
|
(nodes, open)
|
||||||
|
}
|
||||||
|
|
||||||
|
type OnOpen = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
|
||||||
|
|
||||||
|
/// A project's files as a tree: folders first, an icon for each kind, git status by letter and tint, a dot on folders with changes, and a filter that keeps matches and opens the folders that hold them. Enter or a double press opens a file. Give it a height.
|
||||||
|
#[derive(IntoElement)]
|
||||||
|
pub struct FileTree {
|
||||||
|
id: ElementId,
|
||||||
|
base: Div,
|
||||||
|
paths: Vec<SharedString>,
|
||||||
|
status: Vec<(SharedString, GitStatus)>,
|
||||||
|
on_open: Option<OnOpen>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileTree {
|
||||||
|
/// Files by their path from the project's root, with `/` between folders.
|
||||||
|
pub fn new(
|
||||||
|
id: impl Into<ElementId>,
|
||||||
|
paths: impl IntoIterator<Item = impl Into<SharedString>>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id: id.into(),
|
||||||
|
base: div(),
|
||||||
|
paths: paths.into_iter().map(Into::into).collect(),
|
||||||
|
status: Vec::new(),
|
||||||
|
on_open: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn status(mut self, path: impl Into<SharedString>, status: GitStatus) -> Self {
|
||||||
|
self.status.push((path.into(), status));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_open(
|
||||||
|
mut self,
|
||||||
|
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
|
||||||
|
) -> Self {
|
||||||
|
self.on_open = Some(Rc::new(handler));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Styled for FileTree {
|
||||||
|
fn style(&mut self) -> &mut StyleRefinement {
|
||||||
|
self.base.style()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderOnce for FileTree {
|
||||||
|
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
|
let filter = window.use_keyed_state((self.id.clone(), "filter"), cx, |window, cx| {
|
||||||
|
TextInput::new(window, cx)
|
||||||
|
});
|
||||||
|
let picked = window.use_keyed_state((self.id.clone(), "picked"), cx, |_, _| {
|
||||||
|
Vec::<SharedString>::new()
|
||||||
|
});
|
||||||
|
let query = filter.read(cx).text().trim().to_string();
|
||||||
|
let (nodes, open) = nodes(&self.paths, &self.status, &query);
|
||||||
|
let top: Vec<SharedString> = nodes
|
||||||
|
.iter()
|
||||||
|
.filter(|node| node.opens())
|
||||||
|
.map(|node| node.key.clone())
|
||||||
|
.collect();
|
||||||
|
let (id, on_open, store) = (self.id.clone(), self.on_open, picked.clone());
|
||||||
|
let tree = Tree::new((self.id.clone(), "tree"), nodes)
|
||||||
|
.open(if query.is_empty() { top } else { open })
|
||||||
|
.selected(picked.read(cx).clone())
|
||||||
|
.on_select(move |keys, _, cx| {
|
||||||
|
store.update(cx, |picked, cx| {
|
||||||
|
*picked = keys.to_vec();
|
||||||
|
cx.notify();
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.on_activate(move |path, window, cx| {
|
||||||
|
log::info!("file tree {id:?}: opened {path}");
|
||||||
|
if let Some(on_open) = &on_open {
|
||||||
|
on_open(path, window, cx);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.flex_1()
|
||||||
|
.min_h_0();
|
||||||
|
self.base
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap_2()
|
||||||
|
.child(SearchInput::new((self.id, "search"), &filter).size(ControlSize::Sm))
|
||||||
|
.child(tree)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use gpui::SharedString;
|
||||||
|
|
||||||
|
use super::{GitStatus, nodes};
|
||||||
|
use crate::lists::tree::{Shown, rows};
|
||||||
|
|
||||||
|
fn paths() -> Vec<SharedString> {
|
||||||
|
[
|
||||||
|
"src/main.rs",
|
||||||
|
"README.md",
|
||||||
|
"src/ui/Button.rs",
|
||||||
|
"assets/logo.svg",
|
||||||
|
"src/app.rs",
|
||||||
|
"Cargo.toml",
|
||||||
|
]
|
||||||
|
.map(SharedString::from)
|
||||||
|
.to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Labels in row order with everything open, each with its note.
|
||||||
|
fn shown(query: &str) -> (Vec<String>, Vec<SharedString>) {
|
||||||
|
let status = [
|
||||||
|
("src/ui/Button.rs".into(), GitStatus::Modified),
|
||||||
|
("README.md".into(), GitStatus::Added),
|
||||||
|
];
|
||||||
|
let (nodes, open) = nodes(&paths(), &status, query);
|
||||||
|
let every = ["assets", "src", "src/ui"]
|
||||||
|
.map(SharedString::from)
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
let labels = rows(&nodes, &every)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|row| match row.shown {
|
||||||
|
Shown::Node { label, note, .. } => Some(format!(
|
||||||
|
"{label}{}",
|
||||||
|
note.map(|note| format!(" {note}")).unwrap_or_default()
|
||||||
|
)),
|
||||||
|
Shown::Loading => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
(labels, open)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn folders_come_first_then_files_by_name_with_their_status() {
|
||||||
|
let (labels, open) = shown("");
|
||||||
|
assert_eq!(
|
||||||
|
labels,
|
||||||
|
[
|
||||||
|
"assets",
|
||||||
|
"logo.svg",
|
||||||
|
"src •",
|
||||||
|
"ui •",
|
||||||
|
"Button.rs M",
|
||||||
|
"app.rs",
|
||||||
|
"main.rs",
|
||||||
|
"Cargo.toml",
|
||||||
|
"README.md A"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert!(open.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_filter_keeps_matches_and_opens_their_folders() {
|
||||||
|
let (labels, open) = shown("butt");
|
||||||
|
assert_eq!(labels, ["src •", "ui •", "Button.rs M"]);
|
||||||
|
assert_eq!(open, ["src/ui", "src"].map(SharedString::from));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
mod directory;
|
||||||
|
mod files;
|
||||||
mod grouped;
|
mod grouped;
|
||||||
mod item;
|
mod item;
|
||||||
mod long;
|
mod long;
|
||||||
@@ -9,6 +11,8 @@ mod swipe;
|
|||||||
mod tests;
|
mod tests;
|
||||||
mod tree;
|
mod tree;
|
||||||
|
|
||||||
|
pub use directory::{DirEntry, DirectoryListing};
|
||||||
|
pub use files::{FileTree, GitStatus};
|
||||||
pub use grouped::GroupedList;
|
pub use grouped::GroupedList;
|
||||||
pub use item::{List, ListItem};
|
pub use item::{List, ListItem};
|
||||||
pub use long::{InfiniteList, VirtualList};
|
pub use long::{InfiniteList, VirtualList};
|
||||||
|
|||||||
+30
-1
@@ -47,6 +47,7 @@ struct Cursor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type OnSelect = Rc<dyn Fn(&[SharedString], &mut Window, &mut App)>;
|
type OnSelect = Rc<dyn Fn(&[SharedString], &mut Window, &mut App)>;
|
||||||
|
type OnActivate = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
|
||||||
type Picker = Rc<dyn Fn(usize, Pick, &mut Window, &mut App)>;
|
type Picker = Rc<dyn Fn(usize, Pick, &mut Window, &mut App)>;
|
||||||
|
|
||||||
/// Rows you select. A press picks one; with `multiple`, Cmd-press adds or drops one and Shift-press takes the range from the last pick. Up and Down move, Shift with them extends, Space toggles, Cmd-A takes all.
|
/// Rows you select. A press picks one; with `multiple`, Cmd-press adds or drops one and Shift-press takes the range from the last pick. Up and Down move, Shift with them extends, Space toggles, Cmd-A takes all.
|
||||||
@@ -58,6 +59,7 @@ pub struct SelectableList {
|
|||||||
selected: Vec<SharedString>,
|
selected: Vec<SharedString>,
|
||||||
multiple: bool,
|
multiple: bool,
|
||||||
on_change: Option<OnSelect>,
|
on_change: Option<OnSelect>,
|
||||||
|
on_activate: Option<OnActivate>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SelectableList {
|
impl SelectableList {
|
||||||
@@ -69,6 +71,7 @@ impl SelectableList {
|
|||||||
selected: Vec::new(),
|
selected: Vec::new(),
|
||||||
multiple: false,
|
multiple: false,
|
||||||
on_change: None,
|
on_change: None,
|
||||||
|
on_activate: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +91,15 @@ impl SelectableList {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enter, or a double press, on a row.
|
||||||
|
pub fn on_activate(
|
||||||
|
mut self,
|
||||||
|
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
|
||||||
|
) -> Self {
|
||||||
|
self.on_activate = Some(Rc::new(handler));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Gets the keys now selected, in row order.
|
/// Gets the keys now selected, in row order.
|
||||||
pub fn on_change(
|
pub fn on_change(
|
||||||
mut self,
|
mut self,
|
||||||
@@ -150,12 +162,21 @@ impl RenderOnce for SelectableList {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
let activate: OnActivate = {
|
||||||
|
let (id, on_activate) = (self.id.clone(), self.on_activate);
|
||||||
|
Rc::new(move |key, window, cx| {
|
||||||
|
log::info!("selectable list {id:?}: activated {key}");
|
||||||
|
if let Some(on_activate) = &on_activate {
|
||||||
|
on_activate(key, window, cx);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
let rows: Vec<_> = self
|
let rows: Vec<_> = self
|
||||||
.rows
|
.rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(ix, (key, item))| {
|
.map(|(ix, (key, item))| {
|
||||||
let (pick, focus) = (pick.clone(), focus.clone());
|
let (pick, focus, activate) = (pick.clone(), focus.clone(), activate.clone());
|
||||||
item.selected(selected.contains(&key))
|
item.selected(selected.contains(&key))
|
||||||
.current(focused && ix == at)
|
.current(focused && ix == at)
|
||||||
.on_click(move |event, window, cx| {
|
.on_click(move |event, window, cx| {
|
||||||
@@ -167,6 +188,9 @@ impl RenderOnce for SelectableList {
|
|||||||
};
|
};
|
||||||
window.focus(&focus);
|
window.focus(&focus);
|
||||||
pick(ix, how, window, cx);
|
pick(ix, how, window, cx);
|
||||||
|
if event.click_count() == 2 {
|
||||||
|
activate(&key, window, cx);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -191,6 +215,11 @@ impl RenderOnce for SelectableList {
|
|||||||
"home" => (0, extend),
|
"home" => (0, extend),
|
||||||
"end" => (count - 1, extend),
|
"end" => (count - 1, extend),
|
||||||
"space" => (at, Pick::Toggle),
|
"space" => (at, Pick::Toggle),
|
||||||
|
"enter" => {
|
||||||
|
cx.stop_propagation();
|
||||||
|
activate(&keys[at], window, cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
"a" if held.platform => (at, Pick::All),
|
"a" if held.platform => (at, Pick::All),
|
||||||
_ => return,
|
_ => return,
|
||||||
};
|
};
|
||||||
|
|||||||
+36
-4
@@ -36,12 +36,12 @@ fn settle(cx: &mut VisualTestContext) {
|
|||||||
|
|
||||||
const NAMES: [&str; 4] = ["a", "b", "c", "d"];
|
const NAMES: [&str; 4] = ["a", "b", "c", "d"];
|
||||||
|
|
||||||
/// A multiple-choice list of four rows that keeps what it reports.
|
/// A multiple-choice list of four rows that keeps what it reports, and what was opened.
|
||||||
struct Picks(Vec<SharedString>);
|
struct Picks(Vec<SharedString>, Vec<SharedString>);
|
||||||
|
|
||||||
impl Render for Picks {
|
impl Render for Picks {
|
||||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
let view = cx.entity();
|
let (view, opened) = (cx.entity(), cx.entity());
|
||||||
NAMES
|
NAMES
|
||||||
.iter()
|
.iter()
|
||||||
.fold(SelectableList::new("picks").multiple(), |list, name| {
|
.fold(SelectableList::new("picks").multiple(), |list, name| {
|
||||||
@@ -49,6 +49,7 @@ impl Render for Picks {
|
|||||||
})
|
})
|
||||||
.selected(self.0.clone())
|
.selected(self.0.clone())
|
||||||
.on_change(move |keys, _, cx| view.update(cx, |picks, _| picks.0 = keys.to_vec()))
|
.on_change(move |keys, _, cx| view.update(cx, |picks, _| picks.0 = keys.to_vec()))
|
||||||
|
.on_activate(move |key, _, cx| opened.update(cx, |picks, _| picks.1.push(key.clone())))
|
||||||
.w(px(240.0))
|
.w(px(240.0))
|
||||||
.h(px(200.0))
|
.h(px(200.0))
|
||||||
}
|
}
|
||||||
@@ -68,7 +69,7 @@ fn row(ix: usize) -> Point<Pixels> {
|
|||||||
#[gpui::test]
|
#[gpui::test]
|
||||||
fn presses_pick_one_toggle_with_cmd_and_range_with_shift(cx: &mut TestAppContext) {
|
fn presses_pick_one_toggle_with_cmd_and_range_with_shift(cx: &mut TestAppContext) {
|
||||||
setup(cx);
|
setup(cx);
|
||||||
let (view, cx) = cx.add_window_view(|_, _| Picks(Vec::new()));
|
let (view, cx) = cx.add_window_view(|_, _| Picks(Vec::new(), Vec::new()));
|
||||||
settle(cx);
|
settle(cx);
|
||||||
cx.simulate_click(row(0), Modifiers::none());
|
cx.simulate_click(row(0), Modifiers::none());
|
||||||
settle(cx);
|
settle(cx);
|
||||||
@@ -272,3 +273,34 @@ fn a_long_list_builds_only_what_is_near_the_view(cx: &mut TestAppContext) {
|
|||||||
let most = built.borrow().iter().copied().max().expect("some rows");
|
let most = built.borrow().iter().copied().max().expect("some rows");
|
||||||
assert!((9..60).contains(&most), "built up to row {most} of 1000");
|
assert!((9..60).contains(&most), "built up to row {most} of 1000");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn enter_or_a_double_press_opens_a_row(cx: &mut TestAppContext) {
|
||||||
|
setup(cx);
|
||||||
|
let (view, cx) = cx.add_window_view(|_, _| Picks(Vec::new(), Vec::new()));
|
||||||
|
settle(cx);
|
||||||
|
cx.simulate_click(row(1), Modifiers::none());
|
||||||
|
settle(cx);
|
||||||
|
cx.simulate_keystrokes("enter");
|
||||||
|
settle(cx);
|
||||||
|
for count in [1, 2] {
|
||||||
|
cx.simulate_event(gpui::MouseDownEvent {
|
||||||
|
button: gpui::MouseButton::Left,
|
||||||
|
position: row(3),
|
||||||
|
modifiers: Modifiers::none(),
|
||||||
|
click_count: count,
|
||||||
|
first_mouse: false,
|
||||||
|
});
|
||||||
|
cx.simulate_event(gpui::MouseUpEvent {
|
||||||
|
button: gpui::MouseButton::Left,
|
||||||
|
position: row(3),
|
||||||
|
modifiers: Modifiers::none(),
|
||||||
|
click_count: count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
settle(cx);
|
||||||
|
let opened: Vec<String> = view.read_with(cx, |picks, _| {
|
||||||
|
picks.1.iter().map(|key| key.to_string()).collect()
|
||||||
|
});
|
||||||
|
assert_eq!(opened, ["b", "d"]);
|
||||||
|
}
|
||||||
+21
-1
@@ -2,7 +2,7 @@ use std::collections::HashSet;
|
|||||||
|
|
||||||
use gpui::SharedString;
|
use gpui::SharedString;
|
||||||
|
|
||||||
use crate::{forms::CheckState, primitives::IconName};
|
use crate::{data_display::Tone, forms::CheckState, primitives::IconName};
|
||||||
|
|
||||||
/// What a node holds under it.
|
/// What a node holds under it.
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
@@ -19,6 +19,8 @@ pub struct TreeNode {
|
|||||||
pub(crate) key: SharedString,
|
pub(crate) key: SharedString,
|
||||||
pub(crate) label: SharedString,
|
pub(crate) label: SharedString,
|
||||||
pub(crate) icon: Option<IconName>,
|
pub(crate) icon: Option<IconName>,
|
||||||
|
pub(crate) note: Option<SharedString>,
|
||||||
|
pub(crate) tone: Option<Tone>,
|
||||||
pub(crate) children: Children,
|
pub(crate) children: Children,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,10 +31,24 @@ impl TreeNode {
|
|||||||
key: key.into(),
|
key: key.into(),
|
||||||
label: label.into(),
|
label: label.into(),
|
||||||
icon: None,
|
icon: None,
|
||||||
|
note: None,
|
||||||
|
tone: None,
|
||||||
children: Children::Leaf,
|
children: Children::Leaf,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Quiet text at the row's end, such as a status letter.
|
||||||
|
pub fn note(mut self, note: impl Into<SharedString>) -> Self {
|
||||||
|
self.note = Some(note.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tints the label and the note.
|
||||||
|
pub fn tone(mut self, tone: impl Into<Tone>) -> Self {
|
||||||
|
self.tone = Some(tone.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn icon(mut self, icon: IconName) -> Self {
|
pub fn icon(mut self, icon: IconName) -> Self {
|
||||||
self.icon = Some(icon);
|
self.icon = Some(icon);
|
||||||
self
|
self
|
||||||
@@ -89,6 +105,8 @@ pub(crate) enum Shown {
|
|||||||
key: SharedString,
|
key: SharedString,
|
||||||
label: SharedString,
|
label: SharedString,
|
||||||
icon: Option<IconName>,
|
icon: Option<IconName>,
|
||||||
|
note: Option<SharedString>,
|
||||||
|
tone: Option<Tone>,
|
||||||
opens: bool,
|
opens: bool,
|
||||||
open: bool,
|
open: bool,
|
||||||
},
|
},
|
||||||
@@ -129,6 +147,8 @@ pub(crate) fn rows(nodes: &[TreeNode], open: &HashSet<SharedString>) -> Vec<Row>
|
|||||||
key: node.key.clone(),
|
key: node.key.clone(),
|
||||||
label: node.label.clone(),
|
label: node.label.clone(),
|
||||||
icon: node.icon,
|
icon: node.icon,
|
||||||
|
note: node.note.clone(),
|
||||||
|
tone: node.tone,
|
||||||
opens: node.opens(),
|
opens: node.opens(),
|
||||||
open: is_open,
|
open: is_open,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ fn row(rows: &Rows, ix: usize, window: &mut Window, cx: &mut App) -> AnyElement
|
|||||||
icon,
|
icon,
|
||||||
opens,
|
opens,
|
||||||
open,
|
open,
|
||||||
|
..
|
||||||
} => (key.clone(), label.clone(), *icon, *opens, *open),
|
} => (key.clone(), label.clone(), *icon, *opens, *open),
|
||||||
Shown::Loading => return waiting(rows, ix, item.depth, cx),
|
Shown::Loading => return waiting(rows, ix, item.depth, cx),
|
||||||
};
|
};
|
||||||
@@ -100,6 +101,10 @@ fn row(rows: &Rows, ix: usize, window: &mut Window, cx: &mut App) -> AnyElement
|
|||||||
let theme = cx.theme();
|
let theme = cx.theme();
|
||||||
let colors = &theme.colors;
|
let colors = &theme.colors;
|
||||||
let indent = theme.tree_indent();
|
let indent = theme.tree_indent();
|
||||||
|
let (note, ink) = match &item.shown {
|
||||||
|
Shown::Node { note, tone, .. } => (note.clone(), tone.map(|tone| tone.colors(colors).1)),
|
||||||
|
Shown::Loading => (None, None),
|
||||||
|
};
|
||||||
let selected = rows.selected.contains(&key);
|
let selected = rows.selected.contains(&key);
|
||||||
let (toggle, pick, activate, focus) = (
|
let (toggle, pick, activate, focus) = (
|
||||||
rows.toggle.clone(),
|
rows.toggle.clone(),
|
||||||
@@ -147,6 +152,7 @@ fn row(rows: &Rows, ix: usize, window: &mut Window, cx: &mut App) -> AnyElement
|
|||||||
.flex_1()
|
.flex_1()
|
||||||
.min_w_0()
|
.min_w_0()
|
||||||
.truncate()
|
.truncate()
|
||||||
|
.when_some(ink, |text, ink| text.text_color(ink))
|
||||||
.child(label.clone())
|
.child(label.clone())
|
||||||
.into_any_element(),
|
.into_any_element(),
|
||||||
};
|
};
|
||||||
@@ -202,6 +208,13 @@ fn row(rows: &Rows, ix: usize, window: &mut Window, cx: &mut App) -> AnyElement
|
|||||||
)
|
)
|
||||||
.children(icon.map(|icon| Icon::new(icon).size(IconSize::Sm).color(colors.fg_muted)))
|
.children(icon.map(|icon| Icon::new(icon).size(IconSize::Sm).color(colors.fg_muted)))
|
||||||
.child(body)
|
.child(body)
|
||||||
|
.children(note.map(|note| {
|
||||||
|
div()
|
||||||
|
.flex_none()
|
||||||
|
.text_size(theme.text_size(TextSize::Xs))
|
||||||
|
.text_color(ink.unwrap_or(colors.fg_subtle))
|
||||||
|
.child(note)
|
||||||
|
}))
|
||||||
.children(landing.filter(|at| *at != DropAt::Inside).map(line))
|
.children(landing.filter(|at| *at != DropAt::Inside).map(line))
|
||||||
.on_click(move |event, window, cx| {
|
.on_click(move |event, window, cx| {
|
||||||
window.focus(&focus);
|
window.focus(&focus);
|
||||||
|
|||||||
+4
-4
@@ -111,10 +111,10 @@
|
|||||||
|
|
||||||
## T13c Lists & Trees · Files
|
## T13c Lists & Trees · Files
|
||||||
|
|
||||||
- [ ] FileTree
|
- [x] FileTree → `lists::FileTree`, `GitStatus`; built on `lists::Tree`
|
||||||
- [ ] DirectoryListing
|
- [x] DirectoryListing → `lists::DirectoryListing`, `DirEntry`; rows through `lists::SelectableList`
|
||||||
- [ ] Menu List → `menus::Menu`
|
- [x] Menu List → `menus::Menu`
|
||||||
- [ ] Transfer List → `forms::TransferList`
|
- [x] Transfer List → `forms::TransferList`
|
||||||
|
|
||||||
## T14 Tables — `src/tables`
|
## T14 Tables — `src/tables`
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user