Files
md-manuscript/src/app/file_list.rs
T
landon 5acda8c097 feat: Add file panel width persistence to config
- Add file_panel_width field to Config struct with default of 260.0
- Use stored width as default_width for the file list panel
- Update config when panel is resized by dragging separator
- This ensures panel maintains its width across sessions and only
  changes when user explicitly drags the separator

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-17 08:02:41 -05:00

1013 lines
44 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::*;
/// Maximum number of characters to display for a file/folder name.
const MAX_DISPLAY_NAME_LEN: usize = 30;
/// Convert a manuscript folder path to its corresponding outline file path.
///
/// For the snowflake template:
/// - `05-First Draft/Act 1` → `04-Outline/scene_breakdown/act_01.md`
/// - `05-First Draft/Act 2` → `04-Outline/scene_breakdown/act_02.md`
/// - etc.
fn outline_path_for_folder(folder_path: &str) -> Option<String> {
// Split the path into components
let parts: Vec<&str> = folder_path.split('/').collect();
// We expect paths like "05-First Draft/Act 1"
if parts.len() < 2 {
return None;
}
let last_part = parts.last()?;
let folder_name = last_part.trim();
// Try to extract the act number from the folder name
// "Act 1", "Act 2", "Act 3" etc.
let act_num = if folder_name.starts_with("Act ") {
folder_name.trim_start_matches("Act ").trim().parse::<u32>().ok()
} else {
None
};
act_num.map(|n| {
// Format as act_01, act_02, etc.
let act_str = if n < 10 { format!("act_0{}", n) } else { format!("act_{}", n) };
format!("04-Outline/scene_breakdown/{}.md", act_str)
})
}
/// Horizontal indent per folder level, in points.
const INDENT: f32 = 12.0;
/// Truncate a display name to the maximum length, adding ellipsis if needed.
fn truncate_display_name(name: &str) -> String {
if name.chars().count() <= MAX_DISPLAY_NAME_LEN {
name.to_string()
} else {
let mut s = String::new();
for (i, c) in name.chars().enumerate() {
if i >= MAX_DISPLAY_NAME_LEN - 3 {
s.push_str("…");
break;
}
s.push(c);
}
s
}
}
/// 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;
if show_files {
let panel = egui::SidePanel::left("files")
.resizable(true)
.default_width(self.config.file_panel_width)
.width_range(260.0..=600.0);
let response = panel.show(ctx, |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 mut outline_action: Option<String> = 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} 🗀 {}",
truncate_display_name(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);
let button_response = ui
.add_sized(
[name_w, 20.0],
egui::Button::new(text)
.frame(false)
.truncate(),
)
.on_hover_text(&row.path);
if button_response.clicked() {
toggled = Some(row.path.clone());
}
// Add context menu for folders that have corresponding outline files
if let Some(outline_path) = outline_path_for_folder(&row.path) {
button_response.context_menu(|ui| {
if ui.button("Outline").clicked() {
outline_action = Some(outline_path);
ui.close_menu();
}
});
}
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 display_name = truncate_display_name(base_name(&name));
let text = egui::RichText::new(display_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);
}
if let Some(outline_path) = outline_action {
// Open the outline file
self.open_outline_file = Some(outline_path);
}
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 Scene")
.on_hover_text(
"Create a new scene file with YAML frontmatter in 05-First Draft"
)
.clicked()
{
self.create_scene_pair();
}
});
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;
}
}
});
}
});
// Update the config with the current panel width if it changed
let new_width = response.response.rect.width();
if (new_width - self.config.file_panel_width).abs() > 0.1 {
self.config.file_panel_width = new_width;
self.config.save();
}
}
}
}
/// 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(truncate_display_name(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,500–2,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());
}
}
impl App {
/// The outline file popup window: displays the content of an outline file
/// opened from a folder context menu.
pub(super) fn outline_popup_window(&mut self, ctx: &egui::Context) {
let outline_path = match self.open_outline_file.clone() {
Some(p) => p,
None => return,
};
let abs_path = self.path_for(&outline_path);
let content = match std::fs::read_to_string(&abs_path) {
Ok(content) => content,
Err(e) => {
self.status = format!("Could not read {}: {}", outline_path, e);
self.open_outline_file = None;
return;
}
};
let mut open = true;
egui::Window::new(format!("Outline — {}", outline_path))
.open(&mut open)
.resizable(true)
.collapsible(false)
.default_width(680.0)
.default_height(460.0)
.show(ctx, |ui| {
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
ui.add(
egui::TextEdit::multiline(&mut content.clone())
.code_editor()
.desired_width(f32::INFINITY)
.interactive(false),
);
});
});
if !open {
self.open_outline_file = None;
}
}
}