From 3d6e134a33f2bb9a2fc78c0babe2062a0919f013 Mon Sep 17 00:00:00 2001 From: landon Date: Sun, 4 Oct 2026 10:46:52 -0500 Subject: [PATCH] Lock the scene template against accidental edits _template.md is a reference writers copy from by hand, not something the app reads programmatically, so an accidental edit to it is easy to make and easy to miss. The editor now shows it read-only with a lock banner; an "Unlock" button allows editing for the rest of the session, resetting back to locked the next time a file is selected. Co-Authored-By: Claude Sonnet 5 --- src/app/editor.rs | 27 +++++++++++++++++++++++++++ src/app/mod.rs | 6 ++++++ src/app/workspace.rs | 27 +++++++++++++++++++++++++++ 3 files changed, 60 insertions(+) diff --git a/src/app/editor.rs b/src/app/editor.rs index 94e083a..7d1d090 100644 --- a/src/app/editor.rs +++ b/src/app/editor.rs @@ -5,6 +5,32 @@ use super::*; impl App { pub(super) fn editor(&mut self, ui: &mut egui::Ui) { + // The bundled scene template is a reference writers copy from by + // hand, not something the app reads — so an edit to it just sits + // there, invisible, until someone notices the scene they copied from + // it looks wrong. Lock it against typing until explicitly unlocked. + let is_template = self + .selected + .and_then(|i| self.files.get(i)) + .is_some_and(|f| workspace::is_template_file(f)); + let locked = is_template && !self.template_unlocked; + if is_template { + ui.horizontal(|ui| { + if locked { + ui.label("🔒 This is the scene template — locked to prevent accidental edits."); + if ui.button("Unlock").clicked() { + self.template_unlocked = true; + } + } else { + ui.label("🔓 Unlocked for editing."); + if ui.button("Lock").clicked() { + self.template_unlocked = false; + } + } + }); + ui.add_space(4.0); + } + // Base monospace size scaled by the zoom percentage. `desired_width` stays // infinite and the scroll area is vertical-only, so text wraps to the pane // width at any zoom (no horizontal scrolling). @@ -113,6 +139,7 @@ impl App { .desired_width(f32::INFINITY) .desired_rows(rows) .layouter(&mut layouter) + .interactive(!locked) .show(ui); if output.response.changed() { self.dirty = true; diff --git a/src/app/mod.rs b/src/app/mod.rs index 35c69fc..bc180ab 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -209,6 +209,11 @@ pub struct App { /// The editorial header lifted out of `buffer` while it is collapsed. header_stash: Option, dirty: bool, + /// Whether the currently open `_template.md` has been unlocked for + /// editing this session. Not persisted and not file-specific — it resets + /// to locked whenever a different file is selected, so re-opening the + /// template always starts protected again. + template_unlocked: bool, /// Byte offset of the primary cursor in `buffer`, as of the last frame. /// Used by typewriter mode to notice the cursor moved (and re-center it) /// versus the writer scrolling the view manually, and to find the current @@ -509,6 +514,7 @@ impl App { buffer: String::new(), header_stash: None, dirty: false, + template_unlocked: false, cursor_pos: 0, new_name: String::new(), rename_input: String::new(), diff --git a/src/app/workspace.rs b/src/app/workspace.rs index a4e5c48..5b34d27 100644 --- a/src/app/workspace.rs +++ b/src/app/workspace.rs @@ -615,6 +615,8 @@ impl App { self.selected = Some(idx); self.dirty = false; self.pending_delete = false; + // A lock earned on one file doesn't carry over to the next. + self.template_unlocked = false; // Update Story Bible references for the newly selected file self.update_current_references(); // The rename box holds the whole workspace-relative path, so it doubles @@ -1914,6 +1916,16 @@ pub(super) fn render_template(template: &str, stem: &str, marker: &str, date: &s out } +/// Whether `path` is the bundled scene template (`_template.md`): a reference +/// file writers open to see the expected header fields and copy from by hand, +/// not something the app reads from — "New from template" instead pulls from +/// [`Config::new_file_template`](crate::config::Config::new_file_template). +/// Easy to edit by mistake while skimming it, so the editor locks it against +/// changes until explicitly unlocked — see [`App::editor`](super::App::editor). +pub(super) fn is_template_file(path: &str) -> bool { + base_name(path).eq_ignore_ascii_case("_template.md") +} + #[cfg(test)] mod tests { use super::*; @@ -2097,6 +2109,21 @@ mod tests { assert_eq!(render_template("a\n{{marker}}\nb\n", "n", "", "d"), "a\n\nb\n"); } + #[test] + fn is_template_file_matches_only_the_scene_template_by_name() { + assert!(is_template_file("_template.md")); + assert!(is_template_file("05-First Draft/_template.md")); + // Case-insensitive, like the file system it's read from. + assert!(is_template_file("05-First Draft/_TEMPLATE.MD")); + // A chapter folder could have its own copy — still matches by name. + assert!(is_template_file("Act 1/Chapter 2/_template.md")); + // Other underscore-prefixed files (chapter info, notes) aren't it. + assert!(!is_template_file("_chapter.md")); + assert!(!is_template_file("_notes.md")); + assert!(!is_template_file("scene01.md")); + assert!(!is_template_file("template.md")); + } + /// The point of the default template is that the app's own header parser /// understands what it produces — otherwise the seeded fields are just text. #[test]