Guard data-loss paths: quit, reload, close, recovery answers, save-as

- Quit checks every buffer for unsaved changes and names them; it used
  to look only at the current buffer and exit, dropping the rest (an
  unnamed buffer's text was unrecoverable). Save-and-quit does not quit
  while other buffers are dirty.
- Reload of a dirty buffer needs a second C-k l with no edit in between
  (it discarded edits, undo history and the journal at once), and a file
  deleted on disk is not 'reloaded' into an empty buffer.
- At the destructive y/n prompts ("Recover swap edits?" and "Save
  changes to X?"), only an explicit 'n' discards; Enter or any other key
  cancels. Keys typed while the startup recovery prompt appeared used to
  delete the crash journal. SwapRecoveryPrompt_Discard now answers 'n'.
- Saving from the close prompt refuses to overwrite a file changed on
  disk, like save.
- Save-as onto a file open in another buffer is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
This commit is contained in:
Claude
2026-09-24 09:16:04 -07:00
committed by Kyle Isom
parent fb50a02f6a
commit 6c2727789e
4 changed files with 207 additions and 15 deletions
+76
View File
@@ -898,3 +898,79 @@ TEST(Audit_ForEachRegexMatch_LongLine)
ASSERT_EQ(n, (std::size_t) 1);
ASSERT_EQ(total, line.size());
}
// Quit checks every buffer for unsaved changes, not just the current one.
TEST(Audit_Quit_ChecksAllBuffers)
{
TestHarness h;
Editor &ed = h.EditorRef();
h.Buf().insert_text(0, 0, "unsaved");
h.Buf().SetDirty(true);
Buffer other;
ed.AddBuffer(std::move(other));
ed.SwitchTo(ed.BufferCount() - 1);
ASSERT_TRUE(!ed.CurrentBuffer()->Dirty());
ASSERT_TRUE(h.Exec(CommandId::Quit));
ASSERT_TRUE(!ed.QuitRequested());
ASSERT_TRUE(h.Exec(CommandId::Quit));
ASSERT_TRUE(ed.QuitRequested());
}
// Reload refuses a file deleted on disk and asks before discarding edits.
TEST(Audit_Reload_ConfirmsAndRefusesDeletedFile)
{
TempDir d("reload_safety");
const std::string file = (d.path / "r.txt").string();
std::ofstream(file) << "disk\n";
TestHarness h;
Editor &ed = h.EditorRef();
std::string err;
ASSERT_TRUE(ed.OpenFile(file, err));
ASSERT_TRUE(h.Exec(CommandId::InsertText, "EDIT "));
ASSERT_TRUE(h.Exec(CommandId::ReloadBuffer));
ASSERT_EQ(h.Line(0), std::string("EDIT disk"));
std::filesystem::remove(file);
(void) h.Exec(CommandId::ReloadBuffer);
(void) h.Exec(CommandId::ReloadBuffer);
ASSERT_EQ(h.Line(0), std::string("EDIT disk"));
}
// Closing a dirty buffer: Enter alone at "Save changes?" cancels instead of
// discarding the edits.
TEST(Audit_CloseConfirm_EmptyAnswerCancels)
{
TestHarness h;
Editor &ed = h.EditorRef();
h.Buf().insert_text(0, 0, "work");
h.Buf().SetDirty(true);
const std::size_t n = ed.BufferCount();
ASSERT_TRUE(h.Exec(CommandId::BufferClose));
ASSERT_TRUE(ed.PromptActive());
ASSERT_TRUE(h.Exec(CommandId::Newline));
ASSERT_EQ(ed.BufferCount(), n);
ASSERT_EQ(h.Text(), std::string("work"));
}
// Save-as onto a file that is open in another buffer is refused (it would
// leave two buffers, and one journal, for the same file).
TEST(Audit_SaveAs_FileOpenInOtherBuffer_Refused)
{
TempDir d("saveas_open");
std::ofstream(d.path / "y.txt") << "y\n";
std::ofstream(d.path / "z.txt") << "z\n";
TestHarness h;
Editor &ed = h.EditorRef();
std::string err;
ASSERT_TRUE(ed.OpenFile((d.path / "y.txt").string(), err));
ASSERT_TRUE(ed.OpenFile((d.path / "z.txt").string(), err));
const std::size_t n = ed.BufferCount();
(void) Execute(ed, "save-as", (d.path / "y.txt").string());
ASSERT_TRUE(!ed.PromptActive());
ASSERT_EQ(slurp(d.path / "y.txt"), std::string("y\n"));
ASSERT_EQ(ed.BufferCount(), n);
ASSERT_EQ(ed.FindOpenBuffer((d.path / "y.txt").string()) != ed.CurrentBufferIndex(), true);
}