Allow editing plaintext Files

This commit is contained in:
2026-09-10 14:46:57 +02:00
parent 4884aca3dd
commit 79cb3301b5
6 changed files with 914 additions and 101 deletions
+108 -21
View File
@@ -79,9 +79,15 @@ impl App {
// -- file actions -----------------------------------------------------
/// Whether anything would be lost by replacing the open canvas: changes to
/// the canvas itself, or to a file edited through one of its nodes.
fn has_unsaved_changes(&self) -> bool {
self.doc.is_dirty() || self.view.files.is_dirty()
}
/// Runs `pending` now, or asks about unsaved changes first.
fn guard(&mut self, pending: Pending) -> bool {
if self.doc.is_dirty() {
if self.has_unsaved_changes() {
self.pending = Some(pending);
false
} else {
@@ -130,21 +136,52 @@ impl App {
self.view.zoom_to_fit(&self.doc.canvas);
}
/// Saves, asking for a location when the document has never been saved.
/// Returns `true` when the file made it to disk.
/// Saves the canvas, asking for a location when it has never been saved,
/// and writes every file edited through a file node back to its own file.
///
/// Returns `true` when everything made it to disk.
fn save(&mut self, ctx: &Context) -> bool {
match self.doc.save() {
Ok(true) => {
let name = self.doc.display_name();
self.set_status(format!("Saved {name}"), ctx);
true
}
Ok(false) => self.save_as(ctx),
Err(err) => {
self.error = Some(err);
false
// A canvas that has never been saved is only asked for a location when
// it has changes of its own: an edit made through a file node goes to
// that file and needs nothing from the canvas.
let canvas_pending = self.doc.is_dirty() || self.doc.path().is_some();
let mut ok = true;
let mut saved_canvas = false;
if canvas_pending {
match self.doc.save() {
Ok(true) => saved_canvas = true,
Ok(false) => {
ok = self.save_as(ctx);
saved_canvas = ok;
}
Err(err) => {
self.error = Some(err);
ok = false;
}
}
}
// The files second, so that one that will not open for writing cannot
// keep the canvas from being saved.
let (files, errors) = self.view.files.save_all();
if !errors.is_empty() {
self.error = Some(errors.join("\n\n"));
ok = false;
}
let mut saved: Vec<String> = Vec::new();
if saved_canvas {
saved.push(self.doc.display_name());
}
match files {
0 => {}
1 => saved.push("1 file".to_owned()),
n => saved.push(format!("{n} files")),
}
if !saved.is_empty() {
self.set_status(format!("Saved {}", saved.join(" and ")), ctx);
}
ok
}
fn save_as(&mut self, ctx: &Context) -> bool {
@@ -540,7 +577,7 @@ impl App {
}
fn request_quit(&mut self, ctx: &Context) {
if self.doc.is_dirty() {
if self.has_unsaved_changes() {
self.pending = Some(Pending::Quit);
} else {
self.quitting = true;
@@ -624,9 +661,22 @@ impl App {
self.save_as(ctx);
}
ui.separator();
if ui.button("Reload file previews").clicked() {
self.view.previews.clear();
self.set_status("Reloaded previews", ctx);
if ui
.button("Reload files")
.on_hover_text("Read the files behind file nodes again")
.clicked()
{
// Files with edits that have not been saved yet are left
// alone: reading over them would throw the edits away.
let dropped = self.view.files.reload();
let kept = self.view.files.dirty_count();
self.set_status(
match kept {
0 => format!("Reloaded {dropped} file(s)"),
n => format!("Reloaded {dropped} file(s), kept {n} with edits"),
},
ctx,
);
ui.close();
}
ui.separator();
@@ -836,6 +886,22 @@ impl App {
);
ui.separator();
// Files edited through their nodes are written by Save along with
// the canvas, so say how many are waiting for it.
let edited = self.view.files.dirty_count();
if edited > 0 {
ui.label(
RichText::new(match edited {
1 => "1 edited file".to_owned(),
n => format!("{n} edited files"),
})
.size(12.0)
.color(palette.accent),
)
.on_hover_text("Saving the canvas writes these back too");
ui.separator();
}
if let Some((message, at)) = &self.status {
if ctx.input(|i| i.time) - at < STATUS_SECONDS {
ui.label(RichText::new(message).size(12.0).color(palette.text_muted));
@@ -866,11 +932,28 @@ impl App {
fn dialogs(&mut self, ctx: &Context) {
if self.pending.is_some() {
let name = self.doc.display_name();
let edited = self.view.files.dirty_count();
egui::Modal::new(egui::Id::new("unsaved")).show(ctx, |ui| {
ui.set_width(360.0);
ui.heading("Unsaved changes");
ui.add_space(6.0);
ui.label(format!("“{name}” has changes that are not saved yet."));
let what = match (self.doc.is_dirty(), edited) {
(true, 0) => format!("“{name}” has changes that are not saved yet."),
(true, 1) => format!(
"“{name}” and one of its files have changes that are not saved yet."
),
(true, n) => format!(
"“{name}” and {n} of its files have changes that are not saved yet."
),
(false, 1) => {
"A file edited on this canvas has changes that are not saved yet."
.to_owned()
}
(false, n) => format!(
"{n} files edited on this canvas have changes that are not saved yet."
),
};
ui.label(what);
ui.add_space(12.0);
ui.horizontal(|ui| {
if ui.button("Save and continue").clicked() {
@@ -951,7 +1034,11 @@ impl App {
let title = format!(
"{}{} — JSON Canvas Editor",
self.doc.display_name(),
if self.doc.is_dirty() { " •" } else { "" }
if self.has_unsaved_changes() {
" •"
} else {
""
}
);
if title != self.title {
ctx.send_viewport_cmd(ViewportCommand::Title(title.clone()));
@@ -976,7 +1063,7 @@ impl eframe::App for App {
// Closing the window with unsaved changes asks first.
if ctx.input(|i| i.viewport().close_requested()) && !self.quitting {
if self.doc.is_dirty() {
if self.has_unsaved_changes() {
ctx.send_viewport_cmd(ViewportCommand::CancelClose);
self.pending = Some(Pending::Quit);
} else {
@@ -1045,7 +1132,7 @@ const SHORTCUTS: &[(&str, &str)] = &[
("F", "Zoom to the selection"),
("Escape", "Stop editing, clear the selection"),
("Arrow keys", "Nudge (hold Shift for 10 units)"),
("Double-click", "New note, or edit the one under the cursor"),
("Double-click", "New note, or edit what is under the cursor"),
("Middle / right drag", "Pan the canvas"),
("Ctrl+wheel", "Zoom"),
("Ctrl+0 / Ctrl+1", "Zoom to fit / actual size"),