Skip to content

Add a toggleable file sidebar and autosave - #22

Open
ejuro wants to merge 10 commits into
omacom:masterfrom
ejuro:file-sidebar
Open

Add a toggleable file sidebar and autosave#22
ejuro wants to merge 10 commits into
omacom:masterfrom
ejuro:file-sidebar

Conversation

@ejuro

@ejuro ejuro commented Aug 21, 2026

Copy link
Copy Markdown

Adds a toggleable sidebar (Ctrl+E) for switching between Markdown files without leaving the editor.

screenshot

The sidebar stays out of the way until opened and supports mouse navigation, arrow keys, and Vim-style controls (j/k, Enter, h/Backspace). Files and folders can be created inline with a/A. The sidebar is resizable, and its width is preserved across text-size changes.

This change also adds autosave. Without it, faster note switching would trigger the existing unsaved-changes prompt every time you move between files. Changes are saved 750ms after typing stops and whenever you leave a document. Untitled documents take their name from the first line; empty ones are discarded. Ctrl+S remains available when you want to name a document explicitly. The "Unsaved" status and title asterisk are removed along with the prompt.

Tests: 36 passing. All 10 commits build and pass independently.

ejuro and others added 4 commits August 21, 2026 19:30
Moving between notes meant Ctrl+O and the portal file picker, a
full-screen modal that drops you out of the writing context and shows
nothing about what else lives beside the open document. Add a sidebar
listing the Markdown in that document's folder, so the next note is one
click away and a writing folder can be taken in at a glance.

It stays out of the way: closed on launch, no remembered open state, and
zero chrome until asked for. Ctrl+B is Bold and none of the shortcuts
here use punctuation keys, so the panel takes Ctrl+E for Explorer,
alongside a footer icon next to Save and Open.

The listing lives on Backend, next to the watchers it resembles: a
QFileSystemWatcher on the browsed folder refreshes the list when files
appear or vanish elsewhere, and the folder follows the open document
because setFileUrl points it at the file's directory. Folders are exempt
from the name filter so an empty one can still be walked into, while
files stay narrowed to what Omawrite can open. Opening a document goes
through requestOpen, so unsaved work is guarded by the same dialog Ctrl+O
already uses, and the last-save-directory fallback is now shared between
the save dialog and the initial folder rather than spelled out twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The sidebar could only be reached with the mouse, which is a poor fit for
a writing app: leaving the keyboard to change document undoes the point of
having the panel there at all. Ctrl+E now puts focus in it, and pressing it
again takes the panel away and hands the text back — it never reaches into
the panel while you are writing, because the writing is what you were
doing. Escape leaves the panel open but returns to writing, and the
selection dims while it is not the thing being driven.

Arrow keys and their vim counterparts both move, so neither habit has to
be unlearned: Up/Down or j/k step through the folder, Enter or l opens a
document and walks into a folder, Backspace or h goes back up. Keys are
thin wrappers over named functions, which is also how the tests drive
them without a window manager.

Notes often need making, not only opening, so a starts a new Markdown
file and A a new folder, named inline in the row where it will appear.
The name is cleaned exactly as a saved document's is — the sanitizing
half of suggestedFileName is now shared rather than copied — an existing
name is reported instead of overwritten, a new document opens straight
away, and the selection lands on whatever was just made.

The right edge drags to widen the panel for longer titles. The handle sits
on the edge it moves, so a width measured as a delta from it would feed the
panel's own width back into the next measurement and make the panel chase
the hand at half speed; the pointer's distance from the panel's fixed left
edge is what the width follows instead. Widths are kept at text scale 1
like every other dimension here, so a dragged width survives a change of
desktop text size, and it stops short of squeezing the writing column
below its usual measure. Settings are written when the drag ends rather
than every frame. The column is centred in what is left, so a pixel of
panel moves it half of one: rounding that to whole pixels buys crisp
glyphs only where the text is natively rendered, and costs a visible stair
step everywhere else, so it is now rounded on the same condition the
renderer is chosen by.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Opening a document from the sidebar left three small things wrong, all of
them the kind that make a panel feel bolted on rather than part of the app.

The keyboard stayed in the sidebar, so a note could be chosen but not
written in until the panel was closed. The document you just opened is the
one you want to type in, so focus goes with it; walking into a folder is
not opening anything and keeps the keyboard where it is. Focus follows the
loaded document rather than the act of asking for one, so a file that
cannot be read never takes the keyboard away from the browsing. When the
unsaved-changes prompt stands in the way it has to be waited out rather
than raced: a modal hands focus back to whatever held it before it opened,
which quietly undid a handoff made while it was still closing.

The caret arrived at the end of the new text, which is where writing
carries on from, but it was drawn somewhere else entirely — a line above
the writing in a short document, a page above it in a long one, dropping
into place only at the first keystroke. The editor moves the caret item
when the cursor moves, and loading a document does not move it: the text
arrives with the cursor already at its end. So the caret keeps the position
it was given part-way through the load, before the highlighter shrank the
hidden markers and the typography pass stretched every line. Handing the
delegate back builds a fresh caret against the finished text, which works
in an empty document too, where there is no cursor to nudge.

The selection jumped back to the top of the folder whenever the folder was
re-read — and it is re-read whenever anything in it changes, so saving was
enough to lose your place mid-browse. The selection is now held by name
rather than by an index into a model that is rebuilt from scratch, falling
back to the open document when the row it was on is gone, and coming back
to the panel starts from the document being written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every switch between documents met the same dialog: any keystroke marks the
document modified, and both Ctrl+O and the sidebar stop to ask before
letting go of it. With a panel that makes changing notes a keypress away,
that prompt is what stands between you and the next note nearly every time,
and answering it is not a decision anyone wants to make while writing.

Writing apps of this kind — iA Writer, Ulysses, Bear, Obsidian, Apple
Notes — do not ask. They save on a short pause and let undo be the way
back. So the prompt goes rather than gets better: work is written 750ms
after the typing stops, and again whenever the document is left — switching
files, closing the window, or moving to another app. The debounce is the
recovery timer, which already had exactly this shape and now decides where
the work goes rather than only drafting it: to the document's own file if
it has one, to the recovery draft if it does not. Saves still go through
the same atomic write, so the watcher that guards against outside edits
cannot mistake the app's own.

A document that was never named has nowhere to write, and stopping to ask
for a name is the prompt again by another route. It takes one from its
first line when it is left, the way the sidebar names a new file, giving
way to "... 2" if that name is taken — a save cannot report a clash back
the way creating a document can. One with nothing written in it is not kept
at all: there is nothing to name and nothing to lose, which is what every
editor already does with an empty untitled buffer.

With nothing left to save by hand, the indicators that counted on it only
flicker. The "Unsaved" status and the asterisk in the title would appear
while typing and vanish under a second later, on every pause, so both are
gone. Ctrl+S and the footer button still work, and are still how a document
gets a name you chose yourself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Aug 21, 2026

Copy link
Copy Markdown

Greptile Summary

The PR adds a keyboard-accessible, resizable file sidebar and automatic document persistence, including conflict-aware saves and recovery fallback.

  • Adds sidebar navigation and inline file/folder creation.
  • Autosaves after editing, before document switches, on deactivation, and during close.
  • Preserves external-change decisions by routing current-file writes through a conflict guard.
  • Refuses unsafe switches and first close attempts when current work cannot be persisted.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
src/backend.cpp Implements autosave, persistence result propagation, recovery fallback, external-change guarding, sidebar filesystem operations, and folder watching.
src/Main.qml Integrates sidebar controls and focus handling while gating document switches and window closure on persistence outcomes.
src/ExternalChangeDialog.qml Makes conflict resolution modal and prevents Escape from leaving the backend conflict state unresolved.
src/FileSidebar.qml Adds the resizable, keyboard- and mouse-navigable file sidebar with inline creation controls.
tests/tst_omawrite.cpp Expands coverage for sidebar behavior, autosave, conflict handling, recovery, failed persistence, and close retry behavior.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    E[Editor text changes] --> T[750 ms persistence timer]
    T --> G{External change pending?}
    G -- No --> F[Save current file]
    G -- Yes --> R[Write recovery draft]
    S[Switch document] --> L{Save to own file succeeds?}
    L -- Yes --> O[Open selected document]
    L -- No --> R2[Write recovery draft and refuse switch]
    C[Close window] --> P{File or recovery write succeeds?}
    P -- Yes --> X[Close]
    P -- No --> Q[Refuse first close]
    Q --> D[Second close may discard]
    N[Further editing] --> Z[Clear discard authorization]
Loading

Reviews (5): Last reviewed commit: "Retry persistence after editing past a r..." | Re-trigger Greptile

Comment thread src/Main.qml
Comment thread src/backend.cpp
ejuro and others added 3 commits August 21, 2026 19:58
Saving on the way out assumed the write always succeeds. It does not: a
read-only file, a full disk, or a directory QSaveFile cannot put its
temporary file in all return from saveTo having changed nothing, leaving
the document modified. The old prompt gated the switch on saveSucceeded,
and replacing it dropped that gate — so requestOpen opened the next
document over unsaved work and the window closed on top of it. Nor was
there a draft to fall back on, because a named document only ever went
through saveTo.

saveTo now reports whether the write landed. Persisting falls back to the
recovery draft whenever it did not, so quitting after a failed save still
comes back, and saveBeforeLeaving returns that verdict so switching
documents can decline: the writer keeps looking at their own text, with
the status line saying why. Closing still goes through, on the draft — a
window that cannot be closed would be a worse failure than the one being
guarded against.

The same write could also land on top of somebody else. The file watcher
raises a conflict and the dialog asks which version to keep, but nothing
stopped the debounce from firing first and overwriting the external
contents while that question was still on screen. A conflict is now
remembered until it is answered, and until then the work waits in a draft
rather than on disk. Answering it either way lets saving resume: keeping
your version is a decision to write over theirs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The guard against writing over an outside change sat on the autosave path
only, so Ctrl+S went straight through it: pressing save while the prompt
was still asking which version to keep put the local text on top of the
file it was asking about. The guard belongs where every save passes, so it
moved into saveTo, which also makes it one rule to keep rather than one
per caller. Saving somewhere else is still allowed — Save As to another
file is not the contested one.

Closing had the opposite problem. Leaving falls back to a recovery draft
when the file cannot be written, but that write can fail too, and its
result was thrown away; a full disk could therefore take the last copy of
the work with the window. Writing a draft now reports whether it landed,
and closing asks the weaker question of whether the work reached anywhere
at all. Where it reached nowhere the first close is refused and the status
says why, and a second one is taken as meaning it — a window that cannot
be closed would be its own kind of failure.

That splits the two questions leaving has to ask. Switching documents
still declines unless the work reached the file it belongs in, so nothing
is swapped out from under a writer who can then only recover it by
restarting; closing settles for it having reached a draft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closing settled for a recovery draft existing on disk, which proves only
that something was written at some point — not that it holds what is on
screen. A draft from an earlier attempt, followed by more writing and then
a disk that has stopped accepting either the document or a new draft,
therefore let the window close over the newest text and restored the older
version afterwards.

The question closing has to ask is whether the work reached anywhere
during this attempt, so it now uses the result of the write it just made.
Both leaving paths share one method for putting the document in the file
it belongs in, and differ only in what they will settle for: switching
documents insists on that file, because the writer is about to lose sight
of the text, while closing takes a fresh draft as good enough.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread src/backend.cpp
ejuro and others added 2 commits August 21, 2026 20:44
Refusing to save over an outside change assumed the prompt asking about it
stays until it is answered. Escape closed it instead, emitting neither
answer, and the two methods that clear the flag are reachable from nowhere
else — so every later save was refused for the rest of the session and the
work lived only in recovery drafts. That is a worse way to lose a document
than the overwrite the flag was added to prevent.

Escape cannot stand in for an answer, because each meaning it could carry
decides the question being asked: dropping the flag lets the next autosave
overwrite their version, reloading discards yours, keeping yours discards
theirs. So the prompt no longer closes on it, and the two buttons remain
the only ways out, as they already were for a click outside.

Two things around it were as brittle. The flag outlived the document it
was about: the guard compares the write's target with the current file, and
opening another document left it set, so a file that had never changed
underneath anyone could not be saved either. Opening a document now ends
whatever was contested about the last one. And the watcher was not re-armed
after reporting a change, so a replacement — which is what an atomic save
from another editor looks like — took the watched inode away and with it
any second chance to ask. It is re-armed, so a file that keeps changing
keeps saying so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ending a conflict when a document is opened was written as though opening
always succeeds. It clears at the top of open(), before the URL is checked
and before the file is read, so an open that failed took the guard with it
and left the contested document loaded without one — the next autosave
would then write over the change it was there to protect.

The guard now falls when the new text is in hand, which is the moment the
old document stops being the one on screen. Reloading no longer clears it
in advance either: a reload that could not read the file has answered
nothing. Because the prompt closes itself before asking for the reload,
that would leave the guard standing with nothing able to clear it, so a
failed reload raises the question again instead — the file may well have
been deleted between the asking and the answering, which is a thing the
prompt knows how to say.

The Escape test that came with the previous commit asserts the policy
rather than pressing the key. A synthetic Escape never reaches a popup on
a window that is never shown, so the behavioural version passed whether
the policy allowed it or not; it is gone, and the comment says why.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread src/Main.qml
@ejuro

ejuro commented Aug 21, 2026

Copy link
Copy Markdown
Author

Addressed all review findings.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant