8b352d25ed
Right-clicking a file row now offers "Rename / Move…", mirroring the manuscript file panel's combined rename-and-move pattern: editing the name renames, editing the folder part moves it across the tree. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
822 lines
33 KiB
Rust
822 lines
33 KiB
Rust
//! Story Bible breakout editor: a dedicated window for editing Story Bible items.
|
||
//!
|
||
//! Provides a tree view of Story Bible items on the left and an editor on the right.
|
||
|
||
use super::*;
|
||
use std::fs;
|
||
use std::path::{Path, PathBuf};
|
||
|
||
/// A tree node for the Story Bible tree view.
|
||
#[derive(Debug, Clone)]
|
||
pub enum BibleTreeNode {
|
||
/// A directory/folder node with children
|
||
Directory {
|
||
name: String,
|
||
path: String,
|
||
children: Vec<BibleTreeNode>,
|
||
},
|
||
/// A file/node (leaf)
|
||
File {
|
||
name: String,
|
||
path: String,
|
||
},
|
||
}
|
||
|
||
impl BibleTreeNode {
|
||
/// Create a directory node.
|
||
fn dir(name: String, path: String, children: Vec<BibleTreeNode>) -> Self {
|
||
Self::Directory { name, path, children }
|
||
}
|
||
|
||
/// Create a file node.
|
||
fn file(name: String, path: String) -> Self {
|
||
Self::File { name, path }
|
||
}
|
||
}
|
||
|
||
impl App {
|
||
/// Build the Story Bible tree structure from the Story Bible directory.
|
||
pub(super) fn build_story_bible_tree(&self) -> Vec<BibleTreeNode> {
|
||
let story_bible_dir = self.find_story_bible_directory();
|
||
let Some(base_dir) = story_bible_dir else {
|
||
return Vec::new();
|
||
};
|
||
|
||
let mut root_nodes = Vec::new();
|
||
|
||
// Read the directory structure
|
||
if let Ok(entries) = fs::read_dir(&base_dir) {
|
||
let mut entries: Vec<_> = entries.filter_map(Result::ok).collect();
|
||
// Sort entries: directories first, then files, both alphabetically
|
||
entries.sort_by(|a, b| {
|
||
let a_is_dir = a.file_type().map_or(false, |ft| ft.is_dir());
|
||
let b_is_dir = b.file_type().map_or(false, |ft| ft.is_dir());
|
||
if a_is_dir != b_is_dir {
|
||
b_is_dir.cmp(&a_is_dir) // dirs first
|
||
} else {
|
||
a.file_name().cmp(&b.file_name())
|
||
}
|
||
});
|
||
|
||
for entry in entries {
|
||
let path = entry.path();
|
||
let file_name = entry.file_name().to_string_lossy().to_string();
|
||
|
||
// Skip hidden files/directories
|
||
if file_name.starts_with('.') {
|
||
continue;
|
||
}
|
||
|
||
let is_dir = entry.file_type().map_or(false, |ft| ft.is_dir());
|
||
|
||
if is_dir {
|
||
// Recursively build subtree
|
||
let rel_path = path.strip_prefix(&base_dir).unwrap_or(&path).to_string_lossy().to_string();
|
||
let display_name = if rel_path.is_empty() {
|
||
base_dir.file_name().unwrap_or_else(|| "Story Bible".as_ref()).to_string_lossy().to_string()
|
||
} else {
|
||
path.file_name().unwrap_or_else(|| "".as_ref()).to_string_lossy().to_string()
|
||
};
|
||
let children = Self::build_tree_recursive(&path, &base_dir);
|
||
root_nodes.push(BibleTreeNode::dir(display_name, rel_path, children));
|
||
} else if path.extension().and_then(|s| s.to_str()) == Some("md") {
|
||
// It's a markdown file
|
||
let rel_path = path.strip_prefix(&base_dir).unwrap_or(&path).to_string_lossy().to_string();
|
||
let display_name = path.file_stem().unwrap_or_else(|| "".as_ref()).to_string_lossy().to_string();
|
||
root_nodes.push(BibleTreeNode::file(display_name, rel_path));
|
||
}
|
||
}
|
||
}
|
||
|
||
root_nodes
|
||
}
|
||
|
||
/// Recursively build tree from a directory.
|
||
fn build_tree_recursive(dir_path: &PathBuf, base_dir: &PathBuf) -> Vec<BibleTreeNode> {
|
||
let mut children = Vec::new();
|
||
|
||
if let Ok(entries) = fs::read_dir(dir_path) {
|
||
let mut entries: Vec<_> = entries.filter_map(Result::ok).collect();
|
||
// Sort entries: directories first, then files, both alphabetically
|
||
entries.sort_by(|a, b| {
|
||
let a_is_dir = a.file_type().map_or(false, |ft| ft.is_dir());
|
||
let b_is_dir = b.file_type().map_or(false, |ft| ft.is_dir());
|
||
if a_is_dir != b_is_dir {
|
||
b_is_dir.cmp(&a_is_dir) // dirs first
|
||
} else {
|
||
a.file_name().cmp(&b.file_name())
|
||
}
|
||
});
|
||
|
||
for entry in entries {
|
||
let path = entry.path();
|
||
let file_name = entry.file_name().to_string_lossy().to_string();
|
||
|
||
// Skip hidden files/directories
|
||
if file_name.starts_with('.') {
|
||
continue;
|
||
}
|
||
|
||
let is_dir = entry.file_type().map_or(false, |ft| ft.is_dir());
|
||
|
||
if is_dir {
|
||
let rel_path = path.strip_prefix(base_dir).unwrap_or(&path).to_string_lossy().to_string();
|
||
let display_name = path.file_name().unwrap_or_else(|| "".as_ref()).to_string_lossy().to_string();
|
||
let grandchildren = Self::build_tree_recursive(&path, base_dir);
|
||
children.push(BibleTreeNode::dir(display_name, rel_path, grandchildren));
|
||
} else if path.extension().and_then(|s| s.to_str()) == Some("md") {
|
||
let rel_path = path.strip_prefix(base_dir).unwrap_or(&path).to_string_lossy().to_string();
|
||
let display_name = path.file_stem().unwrap_or_else(|| "".as_ref()).to_string_lossy().to_string();
|
||
children.push(BibleTreeNode::file(display_name, rel_path));
|
||
}
|
||
}
|
||
}
|
||
|
||
children
|
||
}
|
||
|
||
/// Find the Story Bible directory path.
|
||
pub(super) fn find_story_bible_directory(&self) -> Option<PathBuf> {
|
||
use std::path::Path;
|
||
let ws = self.config.workspace.clone();
|
||
|
||
// Look for Story Bible directory - common locations
|
||
let story_bible_dirs = [
|
||
"03-Story-Bible",
|
||
"03-Characters",
|
||
"02-Story Bible",
|
||
"Story Bible",
|
||
"Characters",
|
||
];
|
||
|
||
for dir_name in story_bible_dirs.iter() {
|
||
let dir_path = Path::new(&ws).join(dir_name);
|
||
if dir_path.exists() && dir_path.is_dir() {
|
||
return Some(dir_path.to_path_buf());
|
||
}
|
||
}
|
||
|
||
None
|
||
}
|
||
|
||
/// Render the Story Bible tree recursively. `add_requested` collects the
|
||
/// directory path a right-click menu's "Add … entry…" was clicked for,
|
||
/// and `rename_requested` the file path a "Rename / Move…" was clicked
|
||
/// for, so the caller can open the relevant dialog once rendering is
|
||
/// done rather than mutating `self` from inside this `&self` walk.
|
||
fn render_bible_tree(
|
||
&self,
|
||
ui: &mut egui::Ui,
|
||
nodes: &[BibleTreeNode],
|
||
selected_path: &Option<String>,
|
||
add_requested: &mut Option<String>,
|
||
rename_requested: &mut Option<String>,
|
||
) -> Option<String> {
|
||
let mut clicked_path: Option<String> = None;
|
||
|
||
for node in nodes {
|
||
match node {
|
||
BibleTreeNode::Directory { name, path, children } => {
|
||
let _is_selected = selected_path.as_ref().map_or(false, |p| p == path);
|
||
|
||
let header = egui::CollapsingHeader::new(name)
|
||
.default_open(true)
|
||
.show(ui, |ui| {
|
||
if let Some(child_clicked) = self.render_bible_tree(
|
||
ui,
|
||
children,
|
||
selected_path,
|
||
add_requested,
|
||
rename_requested,
|
||
) {
|
||
clicked_path = Some(child_clicked);
|
||
}
|
||
});
|
||
header.header_response.context_menu(|ui| {
|
||
let entry_type = infer_entry_type(path);
|
||
let label = format!("➕ Add {entry_type} entry…");
|
||
if ui.button(label).clicked() {
|
||
*add_requested = Some(path.clone());
|
||
ui.close_menu();
|
||
}
|
||
});
|
||
}
|
||
BibleTreeNode::File { name, path } => {
|
||
let is_selected = selected_path.as_ref().map_or(false, |p| p == path);
|
||
|
||
let response = ui.selectable_label(is_selected, name);
|
||
if response.clicked() {
|
||
clicked_path = Some(path.clone());
|
||
}
|
||
response.context_menu(|ui| {
|
||
if ui.button("📝 Rename / Move…").clicked() {
|
||
*rename_requested = Some(path.clone());
|
||
ui.close_menu();
|
||
}
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
clicked_path
|
||
}
|
||
|
||
/// Show the Story Bible breakout editor window.
|
||
pub(super) fn story_bible_editor_window(&mut self, ctx: &egui::Context) {
|
||
// Build tree structure
|
||
let tree = self.build_story_bible_tree();
|
||
|
||
// Create a window with custom sizing - we want tree on left 1/3, editor on right 2/3
|
||
let mut open = self.show_story_bible_editor;
|
||
|
||
egui::Window::new("Story Bible Editor")
|
||
.open(&mut open)
|
||
.resizable(true)
|
||
.collapsible(false)
|
||
.default_width(1000.0)
|
||
.default_height(700.0)
|
||
// A window only paints as large as its content actually
|
||
// measures (`default_width`/`default_height` are no more than
|
||
// hints, silently ignored the moment content isn't a single
|
||
// top-level ScrollArea), so what actually renders is whatever
|
||
// size the tree/editor panes below claim for themselves —
|
||
// that's why they're given explicit heights rather than left to
|
||
// `auto_shrink(false)` alone, which only stops a pane from
|
||
// shrinking *below* whatever height it's handed, and doesn't
|
||
// make it claim more. This is also why the resize corner used
|
||
// to appear to do nothing: every frame's content re-measure
|
||
// snapped the drag straight back to that same small size.
|
||
.min_width(1000.0)
|
||
.min_height(700.0)
|
||
.show(ctx, |ui| {
|
||
self.story_bible_editor_contents(ui, &tree);
|
||
});
|
||
|
||
self.show_story_bible_editor = open;
|
||
|
||
// Clear editor state when window is closed
|
||
if !open {
|
||
self.story_bible_editor_content.clear();
|
||
self.story_bible_editor_path = None;
|
||
self.story_bible_editor_focused = false;
|
||
}
|
||
}
|
||
|
||
/// Contents of the Story Bible editor window.
|
||
fn story_bible_editor_contents(&mut self, ui: &mut egui::Ui, tree: &[BibleTreeNode]) {
|
||
// Set by the editor's text area below, read back out once the closures
|
||
// that borrow `self` have finished.
|
||
let mut focused = false;
|
||
// Directory a right-click menu's "Add … entry…" was clicked for, if
|
||
// any — applied after the tree finishes rendering, same reason.
|
||
let mut add_requested: Option<String> = None;
|
||
// File path a right-click menu's "Rename / Move…" was clicked for,
|
||
// if any — same deferred-handling reason as `add_requested`.
|
||
let mut rename_requested: Option<String> = None;
|
||
ui.horizontal(|ui| {
|
||
// Left panel: Tree view (1/3 width). The width must be set on
|
||
// this panel's own child `Ui`, not the shared horizontal one —
|
||
// that had been shrinking the whole row (tree, separator, and
|
||
// the editor panel with it) down to a third of the window,
|
||
// which is why the editor's heading used to wrap one letter at
|
||
// a time.
|
||
let tree_width = ui.available_width() / 3.0;
|
||
|
||
ui.vertical(|ui| {
|
||
ui.set_width(tree_width);
|
||
ui.heading("Story Bible");
|
||
ui.separator();
|
||
|
||
// Scroll area for tree. `auto_shrink(false)` only stops
|
||
// this from shrinking *below* whatever height the
|
||
// surrounding layout hands it — it doesn't claim more on
|
||
// its own, and `max_height` is a ceiling, not a floor (it
|
||
// can only shrink, never grow, what the ambient layout
|
||
// already offered). `min_scrolled_height` is the one
|
||
// that's actually enforced unconditionally, which is what
|
||
// establishes — and drives the window's own measured size
|
||
// up to — a real, spacious height.
|
||
egui::ScrollArea::vertical()
|
||
.id_salt("story_bible_editor_tree")
|
||
.auto_shrink(false)
|
||
.min_scrolled_height(600.0)
|
||
.show(ui, |ui| {
|
||
if tree.is_empty() {
|
||
ui.label("No Story Bible items found");
|
||
} else {
|
||
// Render tree
|
||
if let Some(clicked_path) = self.render_bible_tree(
|
||
ui,
|
||
tree,
|
||
&self.story_bible_editor_path,
|
||
&mut add_requested,
|
||
&mut rename_requested,
|
||
) {
|
||
// Load the clicked file
|
||
self.load_story_bible_item(&clicked_path);
|
||
}
|
||
}
|
||
});
|
||
});
|
||
|
||
ui.separator();
|
||
|
||
// Right panel: Editor (2/3 width)
|
||
ui.vertical(|ui| {
|
||
// Show current file info
|
||
if let Some(ref path) = self.story_bible_editor_path {
|
||
ui.heading(path);
|
||
} else {
|
||
ui.heading("Select an item to edit");
|
||
}
|
||
ui.separator();
|
||
|
||
// The Save/Close row lives in its own panel, claimed *before*
|
||
// the editor's scroll area: a resizable window fights the
|
||
// user's own resize drag when a ScrollArea isn't the last
|
||
// thing measured in its content (the cursor shows the resize
|
||
// affordance but dragging does nothing) — reserving this
|
||
// panel's height up front, the same way file_list.rs's
|
||
// `left_pane` does for its own action row, avoids that.
|
||
egui::TopBottomPanel::bottom("story_bible_editor_actions")
|
||
.frame(egui::Frame::none().inner_margin(egui::Margin::symmetric(0.0, 4.0)))
|
||
.show_inside(ui, |ui| {
|
||
ui.separator();
|
||
ui.horizontal(|ui| {
|
||
if ui.button("Save (Ctrl+S)").clicked() {
|
||
if let Some(ref path) = self.story_bible_editor_path {
|
||
self.save_story_bible_item(path.clone());
|
||
}
|
||
}
|
||
|
||
if ui.button("Close").clicked() {
|
||
self.show_story_bible_editor = false;
|
||
}
|
||
});
|
||
});
|
||
|
||
// Editor area — see the tree pane's comment above on why
|
||
// `min_scrolled_height` (not `max_height`) is needed.
|
||
egui::ScrollArea::vertical()
|
||
.id_salt("story_bible_editor_content")
|
||
.auto_shrink(false)
|
||
.min_scrolled_height(520.0)
|
||
.show(ui, |ui| {
|
||
let text_edit = egui::TextEdit::multiline(&mut self.story_bible_editor_content)
|
||
.id_salt("story_bible_editor_textedit")
|
||
.code_editor()
|
||
.desired_width(f32::INFINITY)
|
||
.desired_rows(20);
|
||
|
||
let response = ui.add(text_edit);
|
||
|
||
// Recorded rather than acted on: the save needs `&mut
|
||
// self`, which this closure is already borrowing to
|
||
// draw with. `App::update` reads the flag next frame and
|
||
// routes Ctrl+S here instead of to the manuscript.
|
||
focused = response.has_focus();
|
||
});
|
||
});
|
||
});
|
||
self.story_bible_editor_focused = focused;
|
||
|
||
if let Some(dir) = add_requested {
|
||
self.add_story_bible_type = infer_entry_type(&dir);
|
||
self.add_story_bible_dir = dir;
|
||
self.add_story_bible_name.clear();
|
||
self.show_add_story_bible_item = true;
|
||
self.add_story_bible_focus = true;
|
||
}
|
||
|
||
if let Some(path) = rename_requested {
|
||
self.rename_story_bible_input = strip_md(&path).to_string();
|
||
self.rename_story_bible_target = Some(path);
|
||
self.show_rename_story_bible = true;
|
||
self.rename_story_bible_focus = true;
|
||
}
|
||
}
|
||
|
||
/// Load a Story Bible item from the workspace.
|
||
pub(super) fn load_story_bible_item(&mut self, workspace_rel_path: &str) {
|
||
let story_bible_dir = self.find_story_bible_directory();
|
||
let Some(base_dir) = story_bible_dir else {
|
||
self.status = "Story Bible directory not found".to_string();
|
||
return;
|
||
};
|
||
|
||
let full_path = base_dir.join(workspace_rel_path);
|
||
|
||
match fs::read_to_string(&full_path) {
|
||
Ok(content) => {
|
||
self.story_bible_editor_content = content;
|
||
self.story_bible_editor_path = Some(workspace_rel_path.to_string());
|
||
}
|
||
Err(e) => {
|
||
self.status = format!("Could not read {}: {}", workspace_rel_path, e);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Save the current Story Bible item.
|
||
pub(super) fn save_story_bible_item(&mut self, workspace_rel_path: String) {
|
||
let story_bible_dir = self.find_story_bible_directory();
|
||
let Some(base_dir) = story_bible_dir else {
|
||
self.status = "Story Bible directory not found".to_string();
|
||
return;
|
||
};
|
||
|
||
let full_path = base_dir.join(&workspace_rel_path);
|
||
|
||
match fs::write(&full_path, &self.story_bible_editor_content) {
|
||
Ok(_) => {
|
||
self.status = format!("Saved: {}", workspace_rel_path);
|
||
// Rebuild the Story Bible index to pick up any changes
|
||
self.build_story_bible_index();
|
||
}
|
||
Err(e) => {
|
||
self.status = format!("Could not save {}: {}", workspace_rel_path, e);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The "Add … entry…" dialog opened from a directory's right-click menu.
|
||
/// Mirrors `rename_window`'s shape (`src/app/workspace.rs`): a small
|
||
/// modal with a focused name field, Enter-to-submit, Create/Cancel.
|
||
pub(super) fn add_story_bible_item_window(&mut self, ctx: &egui::Context) {
|
||
let mut open = self.show_add_story_bible_item;
|
||
let mut submit = false;
|
||
let mut cancel = false;
|
||
let title = format!("Add {} entry", capitalize(&self.add_story_bible_type));
|
||
egui::Window::new(title)
|
||
.open(&mut open)
|
||
.resizable(false)
|
||
.collapsible(false)
|
||
.default_width(320.0)
|
||
.show(ctx, |ui| {
|
||
ui.label(
|
||
egui::RichText::new(format!("in {}/", self.add_story_bible_dir))
|
||
.small()
|
||
.weak(),
|
||
);
|
||
ui.add_space(4.0);
|
||
let r = ui.add(
|
||
egui::TextEdit::singleline(&mut self.add_story_bible_name)
|
||
.desired_width(f32::INFINITY)
|
||
.hint_text("e.g. Captain Reyes"),
|
||
);
|
||
if self.add_story_bible_focus {
|
||
r.request_focus();
|
||
self.add_story_bible_focus = false;
|
||
}
|
||
submit |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||
ui.label(
|
||
egui::RichText::new(format!(
|
||
"Starts with id/name/aliases/summary/type: {} frontmatter — \
|
||
edit any of it once it's created.",
|
||
self.add_story_bible_type
|
||
))
|
||
.small()
|
||
.weak(),
|
||
);
|
||
ui.separator();
|
||
ui.horizontal(|ui| {
|
||
submit |= ui.button("Create").clicked();
|
||
cancel |= ui.button("Cancel").clicked();
|
||
});
|
||
});
|
||
if cancel || !open {
|
||
self.show_add_story_bible_item = false;
|
||
return;
|
||
}
|
||
if submit {
|
||
if self.create_story_bible_item() {
|
||
self.show_add_story_bible_item = false;
|
||
} else {
|
||
// Rejected (empty name): keep the dialog up, same as Rename.
|
||
self.add_story_bible_focus = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Create the new Story Bible entry `add_story_bible_name` names, inside
|
||
/// `add_story_bible_dir`, and load it into the editor. Returns `false`
|
||
/// (leaving the dialog open) when there's no usable name yet.
|
||
fn create_story_bible_item(&mut self) -> bool {
|
||
let name = self.add_story_bible_name.trim();
|
||
if name.is_empty() {
|
||
self.status = "Name needed".to_string();
|
||
return false;
|
||
}
|
||
let Some(base_dir) = self.find_story_bible_directory() else {
|
||
self.status = "Story Bible directory not found".to_string();
|
||
return false;
|
||
};
|
||
let dir = base_dir.join(&self.add_story_bible_dir);
|
||
if let Err(e) = fs::create_dir_all(&dir) {
|
||
self.status = format!("Could not create {}: {e}", dir.display());
|
||
return false;
|
||
}
|
||
|
||
let stem = unique_slug(&dir, &slugify(name));
|
||
let rel_path = format!("{}/{stem}.md", self.add_story_bible_dir);
|
||
let full_path = base_dir.join(&rel_path);
|
||
|
||
let content = format!(
|
||
"---\n\
|
||
id: {stem}\n\
|
||
name: \"{name}\"\n\
|
||
aliases: []\n\
|
||
summary: \"\"\n\
|
||
type: {}\n\
|
||
status: active\n\
|
||
---\n\n\
|
||
# {name}\n",
|
||
self.add_story_bible_type,
|
||
);
|
||
|
||
match fs::write(&full_path, &content) {
|
||
Ok(()) => {
|
||
self.status = format!("Created {rel_path}");
|
||
self.story_bible_editor_content = content;
|
||
self.story_bible_editor_path = Some(rel_path);
|
||
self.build_story_bible_index();
|
||
true
|
||
}
|
||
Err(e) => {
|
||
self.status = format!("Could not create {rel_path}: {e}");
|
||
false
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The rename/move dialog opened from a file row's context menu. Renaming
|
||
/// and moving are the same operation here, same as the manuscript file
|
||
/// panel's `rename_window`: editing the leaf renames, editing the folder
|
||
/// part moves.
|
||
pub(super) fn rename_story_bible_item_window(&mut self, ctx: &egui::Context) {
|
||
if self.rename_story_bible_target.is_none() {
|
||
// The tree changed underneath us; nothing left to rename.
|
||
self.show_rename_story_bible = false;
|
||
return;
|
||
}
|
||
let mut open = self.show_rename_story_bible;
|
||
let mut submit = false;
|
||
let mut cancel = false;
|
||
egui::Window::new("Rename / Move Story Bible item")
|
||
.open(&mut open)
|
||
.resizable(false)
|
||
.collapsible(false)
|
||
.default_width(320.0)
|
||
.show(ctx, |ui| {
|
||
if let Some(ref current) = self.rename_story_bible_target {
|
||
ui.label(egui::RichText::new(current.as_str()).small().weak());
|
||
}
|
||
ui.add_space(4.0);
|
||
let r = ui.add(
|
||
egui::TextEdit::singleline(&mut self.rename_story_bible_input)
|
||
.desired_width(f32::INFINITY)
|
||
.hint_text("name, or a path to move it"),
|
||
);
|
||
if self.rename_story_bible_focus {
|
||
r.request_focus();
|
||
self.rename_story_bible_focus = false;
|
||
}
|
||
submit |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||
ui.label(
|
||
egui::RichText::new(
|
||
"The path within the Story Bible folder — edit the folder part \
|
||
to move it. The .md extension is added for you.",
|
||
)
|
||
.small()
|
||
.weak(),
|
||
);
|
||
ui.separator();
|
||
ui.horizontal(|ui| {
|
||
submit |= ui.button("Rename").clicked();
|
||
cancel |= ui.button("Cancel").clicked();
|
||
});
|
||
});
|
||
if cancel || !open {
|
||
self.show_rename_story_bible = false;
|
||
return;
|
||
}
|
||
if submit {
|
||
if self.rename_selected_story_bible_item() {
|
||
self.show_rename_story_bible = false;
|
||
} else {
|
||
self.rename_story_bible_focus = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Move/rename the Story Bible item named by `rename_story_bible_target`
|
||
/// to `rename_story_bible_input`. Returns `false` (leaving the dialog
|
||
/// open) when the input isn't usable or the destination is taken.
|
||
fn rename_selected_story_bible_item(&mut self) -> bool {
|
||
let Some(old_path) = self.rename_story_bible_target.clone() else {
|
||
return false;
|
||
};
|
||
let Some(new_path) = sanitize_rel_path(&self.rename_story_bible_input) else {
|
||
self.status = "Enter a new name".to_string();
|
||
return false;
|
||
};
|
||
if new_path == old_path {
|
||
// Already what was asked for.
|
||
self.rename_story_bible_target = None;
|
||
return true;
|
||
}
|
||
let Some(base_dir) = self.find_story_bible_directory() else {
|
||
self.status = "Story Bible directory not found".to_string();
|
||
return false;
|
||
};
|
||
if base_dir.join(&new_path).exists() {
|
||
self.status = format!("{new_path} already exists");
|
||
return false;
|
||
}
|
||
match workspace::move_manuscript_file(&base_dir, &old_path, &new_path) {
|
||
Ok(()) => {
|
||
if self.story_bible_editor_path.as_deref() == Some(old_path.as_str()) {
|
||
self.story_bible_editor_path = Some(new_path.clone());
|
||
}
|
||
self.build_story_bible_index();
|
||
self.status = format!("Renamed to {new_path}");
|
||
self.rename_story_bible_target = None;
|
||
true
|
||
}
|
||
Err(e) => {
|
||
self.status = format!("Rename failed: {e}");
|
||
false
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Open the Story Bible editor.
|
||
pub(super) fn open_story_bible_editor(&mut self) {
|
||
self.show_story_bible_editor = true;
|
||
|
||
// If we have a tree, try to select the first item
|
||
let tree = self.build_story_bible_tree();
|
||
if !tree.is_empty() {
|
||
if let Some(first_file) = Self::find_first_file(&tree) {
|
||
self.load_story_bible_item(&first_file);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Find the first file in the tree (depth-first).
|
||
fn find_first_file(nodes: &[BibleTreeNode]) -> Option<String> {
|
||
for node in nodes {
|
||
match node {
|
||
BibleTreeNode::Directory { children, .. } => {
|
||
if let Some(file) = Self::find_first_file(children) {
|
||
return Some(file);
|
||
}
|
||
}
|
||
BibleTreeNode::File { path, .. } => {
|
||
return Some(path.clone());
|
||
}
|
||
}
|
||
}
|
||
None
|
||
}
|
||
}
|
||
|
||
/// Best-guess entry `type:` for a new Story Bible item, from the directory
|
||
/// it's being added to: the last path component, a leading `NN-`/`NN_`/`NN `
|
||
/// ordering prefix dropped, underscores turned to spaces, lowercased, and
|
||
/// singularized by a simple suffix heuristic (`cities` -> `city`, `Objects`
|
||
/// -> `object`). Only a default — nothing stops the file's own `type:`
|
||
/// field being edited afterward if the guess is off.
|
||
fn infer_entry_type(dir_path: &str) -> String {
|
||
let last = dir_path.rsplit('/').next().unwrap_or(dir_path).trim();
|
||
let rest = last.trim_start_matches(|c: char| c.is_ascii_digit());
|
||
let stripped = match rest.strip_prefix(['-', '_', ' ']) {
|
||
Some(after) if !after.trim().is_empty() => after.trim(),
|
||
_ => last,
|
||
};
|
||
let lower = stripped.replace(['_', '-'], " ").to_lowercase();
|
||
match lower.strip_suffix("ies") {
|
||
Some(stem) => format!("{stem}y"),
|
||
None => match lower.strip_suffix('s') {
|
||
Some(stem) => stem.to_string(),
|
||
None => lower,
|
||
},
|
||
}
|
||
}
|
||
|
||
/// Capitalize the first character, leaving the rest alone.
|
||
fn capitalize(s: &str) -> String {
|
||
let mut chars = s.chars();
|
||
match chars.next() {
|
||
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
|
||
None => String::new(),
|
||
}
|
||
}
|
||
|
||
/// Turn a typed display name into a filename stem: lowercase, runs of
|
||
/// anything that isn't a letter or digit collapsed to a single `_`, trimmed
|
||
/// of leading/trailing `_`. Falls back to `entry` if nothing alphanumeric
|
||
/// survives (an emoji-only name, say), so a file always gets created.
|
||
fn slugify(name: &str) -> String {
|
||
let mut out = String::with_capacity(name.len());
|
||
let mut last_was_sep = false;
|
||
for c in name.chars() {
|
||
if c.is_alphanumeric() {
|
||
out.extend(c.to_lowercase());
|
||
last_was_sep = false;
|
||
} else if !last_was_sep && !out.is_empty() {
|
||
out.push('_');
|
||
last_was_sep = true;
|
||
}
|
||
}
|
||
while out.ends_with('_') {
|
||
out.pop();
|
||
}
|
||
if out.is_empty() {
|
||
"entry".to_string()
|
||
} else {
|
||
out
|
||
}
|
||
}
|
||
|
||
/// `slug`, or `slug_2`/`slug_3`/… if a file by that name already exists in
|
||
/// `dir` — the same "find the first free name" shape as the main file
|
||
/// panel's `+ New from template`.
|
||
fn unique_slug(dir: &Path, slug: &str) -> String {
|
||
if !dir.join(format!("{slug}.md")).exists() {
|
||
return slug.to_string();
|
||
}
|
||
let mut n = 2;
|
||
loop {
|
||
let candidate = format!("{slug}_{n}");
|
||
if !dir.join(format!("{candidate}.md")).exists() {
|
||
return candidate;
|
||
}
|
||
n += 1;
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn infers_type_from_a_top_level_category() {
|
||
assert_eq!(infer_entry_type("01-Characters"), "character");
|
||
assert_eq!(infer_entry_type("04-Objects"), "object");
|
||
assert_eq!(infer_entry_type("05-Relationships"), "relationship");
|
||
}
|
||
|
||
#[test]
|
||
fn infers_type_from_the_closest_subfolder() {
|
||
assert_eq!(infer_entry_type("01-Characters/main_characters"), "main character");
|
||
assert_eq!(infer_entry_type("02-Locations/cities"), "city");
|
||
assert_eq!(infer_entry_type("03-Lore/factions"), "faction");
|
||
}
|
||
|
||
#[test]
|
||
fn a_name_with_no_trailing_s_is_left_alone() {
|
||
assert_eq!(infer_entry_type("06-Timeline"), "timeline");
|
||
assert_eq!(infer_entry_type("03-Lore/history"), "history");
|
||
}
|
||
|
||
#[test]
|
||
fn capitalize_only_touches_the_first_character() {
|
||
assert_eq!(capitalize("character"), "Character");
|
||
assert_eq!(capitalize(""), "");
|
||
assert_eq!(capitalize("main character"), "Main character");
|
||
}
|
||
|
||
#[test]
|
||
fn slugify_collapses_punctuation_and_spaces() {
|
||
assert_eq!(slugify("Captain Reyes"), "captain_reyes");
|
||
assert_eq!(slugify(" Mira Voss "), "mira_voss");
|
||
assert_eq!(slugify("O'Brien's Tavern!"), "o_brien_s_tavern");
|
||
}
|
||
|
||
#[test]
|
||
fn slugify_falls_back_rather_than_producing_an_empty_name() {
|
||
assert_eq!(slugify("***"), "entry");
|
||
assert_eq!(slugify(""), "entry");
|
||
}
|
||
|
||
#[test]
|
||
fn unique_slug_finds_the_first_free_name() {
|
||
let dir = std::env::temp_dir().join(format!(
|
||
"md-manuscript-sbe-slug-test-{}",
|
||
std::process::id()
|
||
));
|
||
let _ = fs::remove_dir_all(&dir);
|
||
fs::create_dir_all(&dir).unwrap();
|
||
assert_eq!(unique_slug(&dir, "mira"), "mira");
|
||
|
||
fs::write(dir.join("mira.md"), "").unwrap();
|
||
assert_eq!(unique_slug(&dir, "mira"), "mira_2");
|
||
|
||
fs::write(dir.join("mira_2.md"), "").unwrap();
|
||
assert_eq!(unique_slug(&dir, "mira"), "mira_3");
|
||
|
||
let _ = fs::remove_dir_all(&dir);
|
||
}
|
||
}
|