Split app.rs into an app/ module tree

src/app.rs had grown to 3,700 lines, ~2,400 of them a single impl App
block. Move it to src/app/mod.rs and spread the behaviour across eleven
child modules grouped by feature: workspace, grammar, spelling, beats,
find, editor, autocomplete, file_list, ui, style and util.

The new modules are children of app rather than siblings, so they still
reach App's private fields without widening its interface; methods and
free helpers that are now used across module boundaries are marked
pub(super). mod.rs keeps the state types, App::new and the eframe::App
update loop.

This is pure code motion - every non-blank line of the original file
reappears exactly once, and the only edits are the pub(super) markers,
the module scaffolding, and rewrapping five signatures that the added
prefix pushed past 100 columns. Largest file is now editor.rs at 520
lines. Tests still 56/56, and cargo clippy --release reports the same
five warnings as before the split.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBWj9TphFMCoh7VHaSRnvQ
This commit is contained in:
landon
2026-08-22 09:02:19 -05:00
parent 0972148e1a
commit 423e93c894
13 changed files with 3839 additions and 3700 deletions
+473
View File
@@ -0,0 +1,473 @@
//! Workspace, file-list and git operations: opening a folder, creating,
//! renaming and deleting manuscript files, persisting order and titles, and
//! exporting the assembled manuscript to ODT.
use super::*;
impl App {
pub(super) fn workspace(&self) -> &Path {
&self.config.workspace
}
/// (Re)load the file list for the current workspace, creating the directory
/// if needed, and refresh git status.
pub(super) fn open_workspace(&mut self) {
let ws = self.config.workspace.clone();
if let Err(e) = std::fs::create_dir_all(&ws) {
self.status = format!("Cannot create workspace: {e}");
return;
}
self.files = order::resolve_order(&ws);
self.titles = order::read_titles(&ws);
// Drop overrides for files that no longer exist.
self.titles.retain(|name, _| self.files.contains(name));
self.detect_repo(&ws);
self.selected = None;
self.buffer.clear();
self.dirty = false;
self.pending_delete = false;
self.clear_lt();
self.session_start_counts = self.snapshot_counts();
self.file_meta = self.snapshot_file_meta();
self.rebuild_field_names();
self.autocomplete = None;
if !self.files.is_empty() {
self.select(0);
}
self.persist_order();
self.status = format!("{} file(s) in {}", self.files.len(), ws.display());
}
/// Work out the git situation for a freshly opened workspace.
///
/// If the folder is itself a repository root it is adopted silently. If it
/// is not, but an enclosing parent directory is a git work tree, we stash
/// that parent in `pending_repo` and ask the user to confirm before using
/// it (see [`repo_prompt`](Self::repo_prompt)). Otherwise the workspace is
/// treated as having no repository.
pub(super) fn detect_repo(&mut self, ws: &Path) {
self.pending_repo = None;
// A `.git` entry directly in the folder (dir, or a file for linked
// worktrees/submodules) means this folder is the repo root.
if ws.join(".git").exists() {
self.is_repo = true;
self.repo_root = Some(ws.to_path_buf());
return;
}
match gitsync::repo_root(ws) {
// A parent directory is a repository — ask before adopting it.
Some(root) if root != ws => {
self.is_repo = false;
self.repo_root = None;
self.pending_repo = Some(root);
}
// `--show-toplevel` reported this very folder (shouldn't happen
// without a `.git` here, but treat it as an ordinary repo root).
Some(root) => {
self.is_repo = true;
self.repo_root = Some(root);
}
None => {
self.is_repo = false;
self.repo_root = None;
}
}
}
/// Adopt the parent repository awaiting confirmation, using it for all git
/// operations on the current workspace.
pub(super) fn adopt_pending_repo(&mut self) {
if let Some(root) = self.pending_repo.take() {
self.status = format!("Using git repository at {}", root.display());
self.is_repo = true;
self.repo_root = Some(root);
}
}
/// Decline the parent repository; the workspace stays without version
/// control (the user can still `Init git` to create a nested repo).
pub(super) fn decline_pending_repo(&mut self) {
self.pending_repo = None;
self.is_repo = false;
self.repo_root = None;
self.status = "Not using the enclosing git repository".to_string();
}
/// Read every file and return its current on-disk word count.
pub(super) fn snapshot_counts(&self) -> HashMap<String, usize> {
self.files
.iter()
.map(|name| {
let text = std::fs::read_to_string(self.path_for(name)).unwrap_or_default();
(name.clone(), count_words(&text))
})
.collect()
}
/// Read every file and return its cached header info (slug/POV/goal/prose),
/// for files that carry anything worth showing in the list.
pub(super) fn snapshot_file_meta(&self) -> HashMap<String, FileMeta> {
self.files
.iter()
.filter_map(|name| {
let text = std::fs::read_to_string(self.path_for(name)).unwrap_or_default();
let meta = FileMeta::from_markdown(&text, &self.config.draft_marker);
meta.has_display().then(|| (name.clone(), meta))
})
.collect()
}
pub(super) fn persist_order(&self) {
let _ = order::write_order(self.workspace(), &self.files);
}
pub(super) fn persist_titles(&self) {
let _ = order::write_titles(self.workspace(), &self.titles);
}
/// Store the title override for the selected file from `title_input`.
/// An empty value removes the override (falls back to the auto title).
pub(super) fn set_title_for_current(&mut self) {
if let Some(idx) = self.selected {
if let Some(name) = self.files.get(idx).cloned() {
let trimmed = self.title_input.trim();
if trimmed.is_empty() {
self.titles.remove(&name);
} else {
self.titles.insert(name, trimmed.to_string());
}
self.persist_titles();
}
}
}
pub(super) fn path_for(&self, name: &str) -> PathBuf {
self.workspace().join(name)
}
/// Save the in-memory buffer to disk if it has unsaved changes.
pub(super) fn save_current(&mut self) {
if let Some(idx) = self.selected {
if self.dirty {
if let Some(name) = self.files.get(idx).cloned() {
let path = self.path_for(&name);
match std::fs::write(&path, &self.buffer) {
Ok(_) => {
self.dirty = false;
self.status = format!("Saved {name}");
// Keep the file-list cache (slug/POV/goal/prose) in step.
let meta =
FileMeta::from_markdown(&self.buffer, &self.config.draft_marker);
if meta.has_display() {
self.file_meta.insert(name, meta);
} else {
self.file_meta.remove(&name);
}
self.merge_field_names_from_buffer();
}
Err(e) => self.status = format!("Save failed: {e}"),
}
}
}
}
}
pub(super) fn select(&mut self, idx: usize) {
if self.selected == Some(idx) {
return;
}
self.save_current();
self.clear_lt();
// Matches from the previous file's buffer are meaningless now.
self.find_matches.clear();
self.find_active = 0;
self.find_needs_refresh = true;
// Re-run the live spell check on the newly loaded buffer immediately.
self.spell_matches.clear();
self.spell_checked_text.clear();
self.spell_dirty = true;
self.spell_last_edit = None;
self.spell_menu = None;
if let Some(name) = self.files.get(idx) {
let path = self.path_for(name);
self.buffer = std::fs::read_to_string(&path).unwrap_or_default();
self.selected = Some(idx);
self.dirty = false;
self.pending_delete = false;
self.rename_input = Path::new(name)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
self.title_input = self.titles.get(name).cloned().unwrap_or_default();
}
}
pub(super) fn create_file(&mut self) {
let mut stem = self.new_name.trim().to_string();
if stem.is_empty() {
self.status = "Enter a name for the new file".to_string();
return;
}
if stem.to_lowercase().ends_with(".md") {
stem.truncate(stem.len() - 3);
}
let name = format!("{stem}.md");
let path = self.path_for(&name);
if path.exists() {
self.status = format!("{name} already exists");
return;
}
let seed = format!("# {stem}\n\n");
match std::fs::write(&path, seed) {
Ok(_) => {
self.files.push(name.clone());
self.persist_order();
let idx = self.files.len() - 1;
self.selected = None; // force reload of buffer
self.select(idx);
self.new_name.clear();
self.status = format!("Created {name}");
}
Err(e) => self.status = format!("Create failed: {e}"),
}
}
pub(super) fn delete_selected(&mut self) {
if let Some(idx) = self.selected {
if let Some(name) = self.files.get(idx).cloned() {
let path = self.path_for(&name);
match std::fs::remove_file(&path) {
Ok(_) => {
self.files.remove(idx);
self.titles.remove(&name);
self.session_start_counts.remove(&name);
self.file_meta.remove(&name);
self.persist_titles();
self.persist_order();
self.selected = None;
self.buffer.clear();
self.dirty = false;
if !self.files.is_empty() {
self.select(idx.min(self.files.len() - 1));
}
self.status = format!("Deleted {name}");
}
Err(e) => self.status = format!("Delete failed: {e}"),
}
}
}
self.pending_delete = false;
}
pub(super) fn rename_selected(&mut self) {
let Some(idx) = self.selected else { return };
let mut stem = self.rename_input.trim().to_string();
if stem.to_lowercase().ends_with(".md") {
stem.truncate(stem.len() - 3);
}
if stem.is_empty() {
self.status = "Enter a new name".to_string();
return;
}
let new_name = format!("{stem}.md");
let Some(old_name) = self.files.get(idx).cloned() else {
return;
};
if new_name == old_name {
return;
}
let new_path = self.path_for(&new_name);
if new_path.exists() {
self.status = format!("{new_name} already exists");
return;
}
// Persist any pending edits under the old name first.
self.save_current();
match std::fs::rename(self.path_for(&old_name), &new_path) {
Ok(_) => {
self.files[idx] = new_name.clone();
if let Some(title) = self.titles.remove(&old_name) {
self.titles.insert(new_name.clone(), title);
self.persist_titles();
}
if let Some(words) = self.session_start_counts.remove(&old_name) {
self.session_start_counts.insert(new_name.clone(), words);
}
if let Some(meta) = self.file_meta.remove(&old_name) {
self.file_meta.insert(new_name.clone(), meta);
}
self.persist_order();
self.status = format!("Renamed to {new_name}");
}
Err(e) => self.status = format!("Rename failed: {e}"),
}
}
pub(super) fn git_init(&mut self) {
// Initialising creates a repository in the workspace itself, which
// supersedes any enclosing repo we were about to ask about.
self.pending_repo = None;
let ws = self.workspace().to_path_buf();
let outcome = gitsync::init(&ws);
self.git_log = outcome.log;
self.show_log = true;
self.is_repo = gitsync::is_repo(&ws);
self.repo_root = self.is_repo.then_some(ws);
self.status = if self.is_repo {
"Initialised git repository".to_string()
} else {
"git init failed (see log)".to_string()
};
}
pub(super) fn git_sync(&mut self) {
self.save_current();
self.persist_order();
self.persist_titles();
let msg = format!(
"Sync manuscript {}",
chrono_like_timestamp()
);
let outcome = gitsync::sync(self.workspace(), &msg);
self.git_log = outcome.log;
self.show_log = true;
self.status = if outcome.ok {
"Sync complete".to_string()
} else {
"Sync finished with errors (see log)".to_string()
};
}
/// Digits to zero-pad a defaulted chapter number to: the width of the largest
/// chapter number when padding is enabled, otherwise 1 (no padding).
pub(super) fn index_pad_width(&self) -> usize {
if self.config.zero_pad_index {
self.files.len().to_string().len().max(1)
} else {
1
}
}
/// The title a chapter at `idx` would get without a manual override: its
/// `# Title:` header, or its 1-based position (e.g. "3."). Shown as the hint
/// in the chapter-title field.
pub(super) fn auto_title(&self, idx: usize, markdown: &str) -> String {
let header = crate::preprocess::parse(markdown, &self.config.draft_marker);
resolve_chapter_title(None, header.title.as_deref(), idx, self.index_pad_width())
}
/// The current file's word-count target (from its `Word Count Target:`
/// header) paired with its current prose word count, if a target is set.
/// Prose = the body below the draft marker, so header metadata isn't counted.
pub(super) fn current_goal(&self) -> Option<(crate::preprocess::WordGoal, usize)> {
self.selected?;
let header = crate::preprocess::parse(&self.buffer, &self.config.draft_marker);
let goal = header.goal?;
Some((goal, count_words(&header.body)))
}
pub(super) fn export_odt(&mut self) {
self.save_current();
let marker = self.config.draft_marker.clone();
let pad_width = self.index_pad_width();
let mut chapters = Vec::new();
for (i, name) in self.files.iter().enumerate() {
let raw = std::fs::read_to_string(self.path_for(name)).unwrap_or_default();
// Strip comments + the editorial header, and lift out any
// `# Title:` / `# Slug:` metadata.
let header = crate::preprocess::parse(&raw, &marker);
// Title priority: manual override > `# Title:` metadata > the chapter's
// 1-based position (e.g. "3."). The body from `parse` is used as-is.
let title = resolve_chapter_title(
self.titles.get(name).map(String::as_str),
header.title.as_deref(),
i,
pad_width,
);
chapters.push(Chapter {
title,
slug: header.slug.clone(),
markdown: header.body,
});
}
let out = PathBuf::from(self.export_input.trim());
if let Some(parent) = out.parent() {
let _ = std::fs::create_dir_all(parent);
}
match odt::export(&chapters, &out) {
Ok(_) => {
self.config.export_path = out.clone();
self.config.save();
self.status = format!("Exported {} chapter(s) to {}", chapters.len(), out.display());
}
Err(e) => self.status = format!("Export failed: {e}"),
}
}
/// Open a native folder picker to choose the workspace directory.
pub(super) fn browse_workspace(&mut self) {
let start = PathBuf::from(self.workspace_input.trim());
let mut dialog = rfd::FileDialog::new().set_title("Choose workspace folder");
if start.is_dir() {
dialog = dialog.set_directory(&start);
}
if let Some(path) = dialog.pick_folder() {
self.save_current();
self.workspace_input = path.display().to_string();
self.config.workspace = path;
self.config.save();
self.open_workspace();
}
}
/// Open a native save dialog to choose the export `.odt` path.
pub(super) fn browse_export(&mut self) {
let current = PathBuf::from(self.export_input.trim());
let mut dialog = rfd::FileDialog::new()
.set_title("Choose export file")
.add_filter("OpenDocument Text", &["odt"]);
if let Some(parent) = current.parent().filter(|p| p.is_dir()) {
dialog = dialog.set_directory(parent);
}
let name = current
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("manuscript.odt");
if let Some(mut path) = dialog.set_file_name(name).save_file() {
// Ensure the chosen path ends in .odt even if the user omitted it.
let has_odt = path
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("odt"));
if !has_odt {
path.set_extension("odt");
}
self.export_input = path.display().to_string();
self.config.export_path = path;
self.config.save();
}
}
pub(super) fn reorder(&mut self, from: usize, mut to: usize) {
if from >= self.files.len() || from == to {
return;
}
// Remember the selected file by name so selection follows the move.
let selected_name = self.selected.and_then(|i| self.files.get(i)).cloned();
let item = self.files.remove(from);
if from < to {
to -= 1;
}
to = to.min(self.files.len());
self.files.insert(to, item);
self.persist_order();
if let Some(name) = selected_name {
self.selected = self.files.iter().position(|n| *n == name);
}
self.status = "Reordered".to_string();
}
}