Files
md-manuscript/src/app/file_list.rs
T
landon aa050cf411 Add a right-click context menu to file rows
Renaming, archiving and deleting a file all lived in buttons under the
list, and all of them required selecting the file first. The same
operations are now on the row itself: right-click a file for Rename,
Copy path, Archive and Delete.

The menu records what was picked rather than acting on it, because the
list is drawn inside a closure still borrowing `self` — the same
deferred pattern the click and drag-drop handling already use. Right-
clicking selects the row first, so the menu always acts on the file you
clicked; selecting is a no-op when it is already current, so this cannot
reload the buffer out from under an unsaved edit.

Rename opens a focused dialog with the path pre-filled, and stays open
if the name is rejected so a clash does not cost what was typed;
rename_selected reports success for that. Delete asks first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBWj9TphFMCoh7VHaSRnvQ
2026-09-07 11:57:49 -05:00

882 lines
39 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The left-hand file list: a collapsible folder tree of the workspace's
//! markdown files, drag-to-reorder (and drag-to-move) rows, chapter titles and
//! the per-file word-count progress bar.
use super::*;
/// Horizontal indent per folder level, in points.
const INDENT: f32 = 12.0;
/// One drawn line of the file tree.
pub(super) struct Row {
/// Workspace-relative path: the folder for a folder row, the file for a
/// file row.
pub path: String,
/// Nesting level; 0 sits directly in the workspace root.
pub depth: usize,
pub kind: RowKind,
}
pub(super) enum RowKind {
/// A folder header: how many markdown files live under it at any depth, and
/// the flat index of the first of them (where a drop into the folder lands).
Folder { count: usize, first: usize },
/// A file. The panel resolves its own index against `App::files`, because a
/// status filter means the row list and the file list no longer line up.
File,
}
/// Where a dragged file was let go: which file moved, the flat position it
/// should take, and the folder it should end up in (`""` = the workspace root).
pub(super) struct FileDrop {
pub from: usize,
pub to: usize,
pub dir: String,
}
/// Whether a collapsed folder somewhere above `path` is hiding it. The last
/// component of `path` is the item itself, so it is never its own concealer.
fn hidden_by_collapse(path: &str, collapsed: &HashSet<String>) -> bool {
let mut ancestor = String::new();
let mut parts = path.split('/').peekable();
while let Some(part) = parts.next() {
if parts.peek().is_none() {
return false;
}
if !ancestor.is_empty() {
ancestor.push('/');
}
ancestor.push_str(part);
if collapsed.contains(&ancestor) {
return true;
}
}
false
}
/// Turn the flat, folder-tree-ordered file list into the rows to draw, opening
/// a folder header wherever the path prefix changes and skipping everything
/// inside a collapsed folder.
///
/// This relies on `files` being in [`crate::order::tree_order`]: because each
/// folder's files are contiguous there, a header is needed exactly once, and
/// the file that opens it is by construction the folder's first.
pub(super) fn build_rows(files: &[String], collapsed: &HashSet<String>) -> Vec<Row> {
let mut rows = Vec::new();
// The folder components currently open, outermost first.
let mut open: Vec<&str> = Vec::new();
for (idx, path) in files.iter().enumerate() {
let mut parts: Vec<&str> = path.split('/').collect();
parts.pop(); // the file name itself
let shared = open
.iter()
.zip(&parts)
.take_while(|(a, b)| a == b)
.count();
open.truncate(shared);
for dir in &parts[shared..] {
open.push(dir);
let folder = open.join("/");
if hidden_by_collapse(&folder, collapsed) {
continue;
}
// Contiguity makes the run of files under this folder easy to count.
let prefix = format!("{folder}/");
let count = files[idx..]
.iter()
.take_while(|f| f.starts_with(&prefix))
.count();
rows.push(Row {
depth: open.len() - 1,
kind: RowKind::Folder { count, first: idx },
path: folder,
});
}
if !hidden_by_collapse(path, collapsed) {
rows.push(Row {
path: path.clone(),
depth: parts.len(),
kind: RowKind::File,
});
}
}
rows
}
impl App {
/// The file list down the left-hand side.
///
/// Collapses through `show_animated`, which gives the width back to the
/// editor rather than leaving an empty column.
pub(super) fn left_pane(&mut self, ctx: &egui::Context) {
let show_files = self.config.show_file_panel;
egui::SidePanel::left("files")
.resizable(true)
.default_width(260.0)
// A hard ceiling: the panel is sized from its content, so any row
// that asks for more width than there is would otherwise push it
// wider every frame.
.width_range(160.0..=460.0)
.show_animated(ctx, show_files, |ui| {
// The default theme renders unselected list rows fairly dim; bump
// the widget text colours so file names stay legible (especially in
// dark mode) without affecting the rest of the app.
boost_list_contrast(ui.visuals_mut());
ui.add_space(4.0);
ui.heading("Files");
ui.label(
egui::RichText::new("drag ⠿ to reorder · drop on a folder to move")
.small()
.weak(),
);
if self.manuscript_dir.is_some() {
let note = if self.config.show_reference_files {
"dimmed = reference, not part of the book"
} else {
"manuscript only · View ▸ Show reference files"
};
ui.label(egui::RichText::new(note).small().weak());
}
let statuses = self.known_statuses();
if !statuses.is_empty() {
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Status:").small().weak());
let current = self
.status_filter
.clone()
.unwrap_or_else(|| "all".to_string());
egui::ComboBox::from_id_salt("status_filter")
.selected_text(egui::RichText::new(current).small())
.show_ui(ui, |ui| {
if ui
.selectable_label(self.status_filter.is_none(), "all")
.clicked()
{
self.status_filter = None;
}
for status in &statuses {
let picked = self
.status_filter
.as_deref()
.is_some_and(|s| s == status);
if ui.selectable_label(picked, status).clicked() {
self.status_filter = Some(status.clone());
}
}
});
});
}
ui.separator();
let mut clicked: Option<usize> = None;
let mut toggled: Option<String> = None;
let mut dropped: Option<FileDrop> = None;
// What the right-click menu picked, applied further down: the
// row closure below is still borrowing `self` to draw with.
let mut action: Option<FileAction> = None;
let pointer = ui.input(|i| i.pointer.interact_pos());
// Filtering happens on the flat list, so folders left with no
// files simply stop appearing.
let show_reference = self.config.show_reference_files;
let visible: Vec<String> = self
.files
.iter()
.filter(|name| show_reference || self.is_manuscript(name))
.filter(|name| match &self.status_filter {
None => true,
Some(want) => self
.file_meta
.get(*name)
.and_then(|m| m.status.as_deref())
.is_some_and(|s| s.eq_ignore_ascii_case(want)),
})
.cloned()
.collect();
let rows = build_rows(&visible, &self.collapsed);
// Measured once, from the panel rather than from the scrolled
// content: reading `available_width()` inside a row makes the
// content's width depend on the content's width.
let row_width = ui.available_width();
let nested = rows.iter().any(|r| r.depth > 0);
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.max_height(ui.available_height() - 120.0)
.show(ui, |ui| {
for row in &rows {
let indent = row.depth as f32 * INDENT;
match row.kind {
RowKind::Folder { count, first } => {
let open = !self.collapsed.contains(&row.path);
// In a project, one folder holds the book.
let is_manuscript_root = self
.manuscript_dir
.as_deref()
.is_some_and(|d| d == row.path);
let in_book = self.is_manuscript(&row.path);
let header = ui
.horizontal(|ui| {
ui.add_space(indent);
let arrow = if open { "" } else { "" };
let label = format!(
"{arrow} 🗀 {}",
base_name(&row.path)
);
let text = egui::RichText::new(label);
let text = if in_book {
text.strong()
} else {
text.weak()
};
// Sized from the panel and truncated:
// an unconstrained label is as wide as
// its text, and the panel is sized from
// its content, so a long folder name
// would widen the panel and keep it
// widened. The full path is on hover.
let name_w =
(row_width - indent - COUNT_W).max(48.0);
if ui
.add_sized(
[name_w, 20.0],
egui::Button::new(text)
.frame(false)
.truncate(),
)
.on_hover_text(&row.path)
.clicked()
{
toggled = Some(row.path.clone());
}
ui.label(
egui::RichText::new(count.to_string())
.small()
.weak(),
);
if is_manuscript_root {
ui.label(
egui::RichText::new("· manuscript")
.small()
.weak(),
)
.on_hover_text(
"These files are the book: ordered, \
numbered and exported. Everything \
else in the project is reference.",
);
}
})
.response;
// Widened only for hit-testing and painting,
// which cannot affect the layout's width.
let header = full_width_row(ui, &header, &row.path);
if drop_highlight(ui, &header) {
if let Some(payload) =
header.dnd_release_payload::<usize>()
{
// `first` indexes the filtered list.
let to = visible
.get(first)
.and_then(|n| {
self.files.iter().position(|f| f == n)
})
.unwrap_or(self.files.len());
dropped = Some(FileDrop {
from: *payload,
to,
dir: row.path.clone(),
});
}
}
}
RowKind::File => {
let name = row.path.clone();
// `build_rows` indexed the filtered list; the
// rest of the app speaks in `files` indices.
let Some(idx) =
self.files.iter().position(|f| *f == name)
else {
continue;
};
let selected = self.selected == Some(idx);
let in_book = self.is_manuscript(&name);
// The selected file's fields are read live from
// the buffer (so unsaved edits show); others come
// from the cache filled on open/save.
let meta = if selected {
FileMeta::from_markdown(
&self.document(),
&self.config.draft_marker,
)
} else {
self.file_meta.get(&name).cloned().unwrap_or_default()
};
let tooltip = meta.tooltip();
let file_row = ui
.horizontal(|ui| {
ui.add_space(indent);
ui.dnd_drag_source(
egui::Id::new(("dnd", &name)),
idx,
|ui| {
ui.label(
egui::RichText::new("")
.monospace()
.weak(),
);
},
);
// Reserve room on the right for a per-file
// word-count-target bar when the file sets one.
let bar_w = 44.0;
let reserve = if meta.goal.is_some() {
bar_w + 6.0
} else {
0.0
};
let label_w = (row_width
- indent
- HANDLE_W
- reserve)
.max(24.0);
let text = egui::RichText::new(base_name(&name));
let text = if in_book { text } else { text.weak() };
let mut label = ui.add_sized(
[label_w, 20.0],
egui::SelectableLabel::new(selected, text),
);
if let Some(tooltip) = &tooltip {
label = label.on_hover_text(tooltip);
}
if label.clicked() {
clicked = Some(idx);
}
label.context_menu(|ui| {
file_context_menu(
ui,
&name,
idx,
&mut action,
);
});
if let Some(goal) = meta.goal {
row_goal_bar(
ui,
bar_w,
goal,
meta.prose_words,
);
}
if let Some(status) = &meta.status {
ui.label(
egui::RichText::new(status_tag(status))
.small()
.weak(),
)
.on_hover_text(format!("Status: {status}"));
}
})
.response;
// Drop handling: is a dragged item hovering this row?
if file_row.dnd_hover_payload::<usize>().is_some() {
let rect = file_row.rect;
let before = pointer
.map(|p| p.y < rect.center().y)
.unwrap_or(true);
let y = if before { rect.top() } else { rect.bottom() };
ui.painter().hline(
rect.x_range(),
y,
// `Stroke::new` takes `impl Into<f32>`, which
// gives an unsuffixed literal no concrete type
// to infer; suffix it rather than lean on the
// f32 fallback that rustc is removing.
egui::Stroke::new(
2.0_f32,
ui.visuals().selection.stroke.color,
),
);
if let Some(payload) =
file_row.dnd_release_payload::<usize>()
{
dropped = Some(FileDrop {
from: *payload,
to: if before { idx } else { idx + 1 },
// Dropping among a folder's files
// means joining that folder.
dir: parent_dir(&name).to_string(),
});
}
}
}
}
}
// With folders in play there is otherwise no row to aim at
// to get a file back out to the top level.
if nested {
ui.add_space(2.0);
let target = ui
.horizontal(|ui| {
ui.label(
egui::RichText::new("↥ drop here for the top level")
.small()
.weak(),
);
})
.response;
let target = full_width_row(ui, &target, "\u{0}root-drop");
if drop_highlight(ui, &target) {
if let Some(payload) = target.dnd_release_payload::<usize>() {
dropped = Some(FileDrop {
from: *payload,
to: self.files.len(),
dir: String::new(),
});
}
}
}
});
if let Some(idx) = clicked {
self.select(idx);
}
if let Some(path) = toggled {
if !self.collapsed.remove(&path) {
self.collapsed.insert(path);
}
}
if let Some(drop) = dropped {
self.apply_drop(drop);
}
if let Some(action) = action {
let ctx = ui.ctx().clone();
self.apply_file_action(action, &ctx);
}
ui.separator();
ui.horizontal(|ui| {
ui.add(
egui::TextEdit::singleline(&mut self.new_name)
.hint_text("new file name")
.desired_width(150.0),
)
.on_hover_text(
"A name, or a path to nest it: part-1/ch-01 creates the \
folder along with the file.",
);
if ui.button(" New").clicked() {
self.create_file();
}
});
if ui
.button(" New from template")
.on_hover_text(
"Create untitled-N.md seeded from the template \
(Settings ▸ New-file template…), in the current file's folder",
)
.clicked()
{
self.create_file_from_template();
}
if self.selected.is_some() {
ui.horizontal(|ui| {
ui.add(
egui::TextEdit::singleline(&mut self.rename_input)
.hint_text("rename")
.desired_width(150.0),
)
.on_hover_text(
"The file's path within the workspace — edit the folder \
part to move it.",
);
if ui.button("Rename").clicked() {
self.rename_selected();
}
});
ui.horizontal(|ui| {
if ui
.button("🗄 Archive")
.on_hover_text(
"Move this file into the archive folder and out of \
the manuscript, keeping it on disk",
)
.clicked()
{
self.archive_selected();
}
});
ui.horizontal(|ui| {
if !self.pending_delete {
if ui.button("🗑 Delete").clicked() {
self.pending_delete = true;
}
} else {
ui.label("Delete file?");
if ui
.button(egui::RichText::new("Yes").color(egui::Color32::RED))
.clicked()
{
self.delete_selected();
}
if ui.button("No").clicked() {
self.pending_delete = false;
}
}
});
}
});
}
}
/// What a file row's right-click menu asked for. The menu records the choice
/// rather than acting on it, because the list is drawn inside a closure that is
/// still borrowing `self`; [`App::apply_file_action`] runs it once that ends.
///
/// Each carries the row's own index, so the menu acts on the file you clicked
/// even when a different one is selected.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(super) enum FileAction {
/// Open the rename dialog on this file.
Rename(usize),
/// Put its workspace-relative path on the clipboard.
CopyPath(usize),
/// Move it to the archive folder.
Archive(usize),
/// Open the delete confirmation for it.
Delete(usize),
}
impl FileAction {
/// The row this action came from.
pub(super) fn index(self) -> usize {
match self {
FileAction::Rename(i)
| FileAction::CopyPath(i)
| FileAction::Archive(i)
| FileAction::Delete(i) => i,
}
}
}
/// The right-click menu on a file row, offering the same operations as the
/// buttons under the list without having to select the file first.
fn file_context_menu(ui: &mut egui::Ui, name: &str, idx: usize, action: &mut Option<FileAction>) {
ui.label(egui::RichText::new(base_name(name)).strong());
ui.separator();
let mut pick = |ui: &mut egui::Ui, label: &str, hover: &str, chosen: FileAction| {
if ui.button(label).on_hover_text(hover).clicked() {
*action = Some(chosen);
ui.close_menu();
}
};
pick(
ui,
"\u{1f4dd} Rename\u{2026}",
"Rename the file, or edit the folder part of its path to move it",
FileAction::Rename(idx),
);
pick(
ui,
"\u{29c9} Copy path",
"Copy the file's path within the workspace to the clipboard",
FileAction::CopyPath(idx),
);
ui.separator();
pick(
ui,
"\u{1f5c4} Archive",
"Move it into the archive folder and out of the manuscript, keeping it on disk",
FileAction::Archive(idx),
);
pick(
ui,
"\u{1f5d1} Delete\u{2026}",
"Remove it from disk \u{2014} asks first",
FileAction::Delete(idx),
);
}
/// Outline a whole-row drop target while a file is dragged over it, and report
/// whether it is being hovered (so the caller can look for the release).
fn drop_highlight(ui: &egui::Ui, response: &egui::Response) -> bool {
if response.dnd_hover_payload::<usize>().is_none() {
return false;
}
ui.painter().rect_stroke(
response.rect,
egui::Rounding::same(2.0),
egui::Stroke::new(2.0_f32, ui.visuals().selection.stroke.color),
);
true
}
/// Width the drag handle occupies in a file row, so a row's label can be sized
/// from the panel width rather than from whatever is left of the content.
const HANDLE_W: f32 = 22.0;
/// Room left after a folder's name for its file count and the manuscript tag.
const COUNT_W: f32 = 86.0;
/// A response covering the whole visible width of `ui` at the row's height.
///
/// Rows want to be drop targets across their full width, but *claiming* that
/// width makes the content as wide as the panel, and the panel is sized from
/// its content — which grows it, frame after frame. Interacting with a rect
/// taken from the clip rectangle sidesteps that: it is the visible area, not
/// the content, so it cannot feed back into the layout.
fn full_width_row(ui: &egui::Ui, response: &egui::Response, key: &str) -> egui::Response {
let rect = egui::Rect::from_x_y_ranges(ui.clip_rect().x_range(), response.rect.y_range());
ui.interact(rect, egui::Id::new(("row", key)), egui::Sense::hover())
}
/// A compact badge for a `Status:` value: the first letters of its words, so a
/// long stage name still fits beside a file name.
pub(super) fn status_tag(status: &str) -> String {
let initials: String = status
.split_whitespace()
.filter_map(|w| w.chars().find(|c| c.is_alphanumeric()))
.collect();
if initials.chars().count() >= 2 {
initials.to_uppercase()
} else {
status.chars().take(4).collect::<String>().to_uppercase()
}
}
/// Resolve a chapter's title: a non-empty manual `override_title` wins, then the
/// `# Title:` header value, otherwise the chapter's 1-based position followed by
/// a period (e.g. "3."), zero-padded to `pad_width` digits (`1` = no padding).
pub(super) fn resolve_chapter_title(
override_title: Option<&str>,
header_title: Option<&str>,
index: usize,
pad_width: usize,
) -> String {
override_title
.map(str::trim)
.filter(|t| !t.is_empty())
.or_else(|| header_title.map(str::trim).filter(|t| !t.is_empty()))
.map(|t| t.to_string())
.unwrap_or_else(|| format!("{:0width$}.", index + 1, width = pad_width))
}
/// Progress toward a word-count target: the bar fraction (0..=1), a fill colour
/// (amber under the range, green within it, blue over it), and a label like
/// "1,234 / 1,5002,000".
pub(super) fn goal_progress(
goal: crate::preprocess::WordGoal,
prose: usize,
) -> (f32, egui::Color32, String) {
let frac = if goal.max == 0 {
0.0
} else {
(prose as f32 / goal.max as f32).clamp(0.0, 1.0)
};
let color = if prose < goal.min {
egui::Color32::from_rgb(0xC8, 0x8A, 0x2A) // amber: below target
} else if prose <= goal.max {
egui::Color32::from_rgb(0x3F, 0x9E, 0x4F) // green: in range
} else {
egui::Color32::from_rgb(0x3B, 0x82, 0xF6) // blue: over target
};
let target = if goal.min == goal.max {
thousands(goal.max)
} else {
format!("{}{}", thousands(goal.min), thousands(goal.max))
};
(frac, color, format!("{} / {}", thousands(prose), target))
}
/// Paint a compact word-count-target progress bar for one file-list row, with
/// the numeric progress (and target) as a hover tooltip. The colour matches the
/// status-bar bar: amber under target, green in range, blue over.
pub(super) fn row_goal_bar(
ui: &mut egui::Ui,
width: f32,
goal: crate::preprocess::WordGoal,
prose: usize,
) {
let (frac, color, text) = goal_progress(goal, prose);
let (rect, resp) = ui.allocate_exact_size(egui::vec2(width, 8.0), egui::Sense::hover());
let rounding = egui::Rounding::same(2.0);
let track = ui.visuals().extreme_bg_color;
let painter = ui.painter();
painter.rect_filled(rect, rounding, track);
if frac > 0.0 {
let fill = egui::Rect::from_min_size(
rect.min,
egui::vec2((rect.width() * frac).max(1.0), rect.height()),
);
painter.rect_filled(fill, rounding, color);
}
resp.on_hover_text(format!("{text} words"));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_file_action_carries_the_row_it_came_from() {
// The four arms of `index` are near-identical, so a copy-paste slip
// would silently point an action at the wrong file — and for Delete
// that is destructive. Pin every variant to the row it was built with.
for action in [
FileAction::Rename(3),
FileAction::CopyPath(3),
FileAction::Archive(3),
FileAction::Delete(3),
] {
assert_eq!(action.index(), 3, "{action:?} lost its row index");
}
}
#[test]
fn chapter_title_falls_back_to_position() {
// No override, no header title -> 1-based index with a period (width 1).
assert_eq!(resolve_chapter_title(None, None, 0, 1), "1.");
assert_eq!(resolve_chapter_title(None, None, 2, 1), "3.");
// Header title is used when present.
assert_eq!(resolve_chapter_title(None, Some("The Gate"), 4, 1), "The Gate");
// Override wins over everything, even a header title.
assert_eq!(
resolve_chapter_title(Some("My Override"), Some("The Gate"), 4, 1),
"My Override"
);
// Blank/whitespace override or header title are ignored.
assert_eq!(resolve_chapter_title(Some(" "), None, 1, 1), "2.");
assert_eq!(resolve_chapter_title(Some(""), Some(" "), 6, 1), "7.");
}
#[test]
fn chapter_number_zero_pads_to_width() {
// Width 2 pads single digits; wider numbers are unaffected.
assert_eq!(resolve_chapter_title(None, None, 0, 2), "01.");
assert_eq!(resolve_chapter_title(None, None, 8, 2), "09.");
assert_eq!(resolve_chapter_title(None, None, 11, 2), "12.");
assert_eq!(resolve_chapter_title(None, None, 4, 3), "005.");
// Padding never applies to a real title.
assert_eq!(
resolve_chapter_title(None, Some("The Gate"), 0, 3),
"The Gate"
);
}
/// Render the rows as `depth:kind:path` lines, which is compact enough to
/// assert the whole tree shape in one go.
fn sketch(files: &[&str], collapsed: &[&str]) -> Vec<String> {
let files: Vec<String> = files.iter().map(|s| s.to_string()).collect();
let collapsed: HashSet<String> = collapsed.iter().map(|s| s.to_string()).collect();
build_rows(&files, &collapsed)
.iter()
.map(|r| match r.kind {
RowKind::Folder { count, first } => {
format!("{}:dir({count},{first}):{}", r.depth, r.path)
}
RowKind::File => format!("{}:file:{}", r.depth, r.path),
})
.collect()
}
#[test]
fn flat_files_get_no_folder_rows() {
assert_eq!(
sketch(&["a.md", "b.md"], &[]),
vec!["0:file:a.md", "0:file:b.md"]
);
}
#[test]
fn a_folder_header_is_opened_once_for_its_run_of_files() {
assert_eq!(
sketch(&["p/a.md", "p/b.md", "top.md"], &[]),
vec![
"0:dir(2,0):p",
"1:file:p/a.md",
"1:file:p/b.md",
"0:file:top.md",
]
);
}
#[test]
fn nested_folders_indent_and_count_everything_below_them() {
assert_eq!(
sketch(&["p/q/a.md", "p/b.md"], &[]),
vec![
// `p` counts both files; `q` only its own.
"0:dir(2,0):p",
"1:dir(1,0):p/q",
"2:file:p/q/a.md",
"1:file:p/b.md",
]
);
}
#[test]
fn collapsing_a_folder_hides_its_files_but_keeps_its_header() {
assert_eq!(
sketch(&["p/a.md", "p/b.md", "top.md"], &["p"]),
vec!["0:dir(2,0):p", "0:file:top.md"]
);
}
#[test]
fn collapsing_hides_nested_headers_too() {
assert_eq!(
sketch(&["p/q/a.md", "p/b.md", "top.md"], &["p"]),
vec!["0:dir(2,0):p", "0:file:top.md"]
);
// Collapsing only the inner folder leaves the outer one drawn.
assert_eq!(
sketch(&["p/q/a.md", "p/b.md"], &["p/q"]),
vec!["0:dir(2,0):p", "1:dir(1,0):p/q", "1:file:p/b.md"]
);
}
#[test]
fn a_collapsed_name_only_hides_its_own_children() {
// `part-1` must not swallow `part-10`, which merely shares a prefix.
assert_eq!(
sketch(&["part-1/a.md", "part-10/b.md"], &["part-1"]),
vec![
"0:dir(1,0):part-1",
"0:dir(1,1):part-10",
"1:file:part-10/b.md",
]
);
}
#[test]
fn sibling_folders_each_get_their_own_header() {
assert_eq!(
sketch(&["p/a.md", "q/b.md"], &[]),
vec![
"0:dir(1,0):p",
"1:file:p/a.md",
"0:dir(1,1):q",
"1:file:q/b.md",
]
);
}
/// The first index a folder header reports is where a drop into that folder
/// lands, so it has to point at the folder's own first file.
#[test]
fn a_folder_header_points_at_its_first_file() {
let rows = sketch(&["top.md", "p/a.md", "p/b.md"], &[]);
assert_eq!(rows[1], "0:dir(2,1):p");
}
#[test]
fn an_empty_list_draws_nothing() {
assert!(sketch(&[], &[]).is_empty());
}
}