The workspace: open notes, tabs and panes
Issue #23; the plan and its four decisions are in the issue’s comment.
The model
lib/src/workspace/ holds the open notes of one library, on one device:
Workspacehas one or two panes (splitrightordown), the focused pane, and the divider’s fraction. It is an immutable value, and every change returns a new one.WorkspacePaneholds its tabs in order, plus the index of the tab that shows.WorkspaceTabholds a library-relative path, aNoteMementoand a runtimemissingflag.NoteMementorecords where the note was left: selection, scroll, editor, preview. It does not hold undo, which lives in a mounted editor only.
Its rules are tested in test/unit/workspace_test.dart:
| Change | Rule |
|---|---|
| open | A note already open shows where it is: one place only. A new one opens after the showing tab of the focused pane. |
| close | The showing tab gives way to its right neighbour, or its left one at the end of the row. A second pane left empty closes the split. |
| rename / move | The note, or every note under a folder, keeps its tab and memento at the new path. |
| delete | The note, or every note under a folder, closes. |
| gone from disk | The tab — and, for a gone folder, every tab under it — stays, marked missing. The mark is never stored. A later removal adds marks and lifts none: it names what one re-index removed, not everything that is gone. |
| back on disk | The mark goes once the index holds the note again; the shell asks it about the marked tabs at every index change. |
Where it is kept
The workspace is stored in the app database, table workspaces, one row
per library path (schema v23). It is not in .niman/settings.json: that
file syncs, and a phone must not inherit a desktop’s tabs.
WorkspaceStore reads and writes it, and forgetting a library drops its
row. The stored form is versioned and forgiving: an unreadable form, or
one from a newer build, reads as nothing open.
WorkspaceController publishes changes and writes them 500 ms after the
last one in a burst. adopt takes what was read back unless the user has
already changed something.
What drives it
ShellWorkspace (lib/src/ui/shell_workspace.dart) is the shell’s side:
- Loading. The workspace loads when the library opens. The desktop draws its tabs from it at once; the phone’s switcher lists the same notes.
- Opening. On the desktop, a click in the tree shows a note in the
focused pane’s tab (
replaceActive). Ctrl+click, the row menu’s Open in new tab and the tab row’s + open alongside (open). Open to the side opens it in the other pane (openBeside). On the phone a note opened joins the open ones (follow(alongside: true)). - Library changes. The row actions report every rename, move and
delete as
from → toor as a path, so tabs under a renamed folder follow it. - A file gone from outside the app. The library hands the shell the
paths a re-index pruned (
LibrarySession.removals, #289) and the ones a loading workspace no longer finds (missingPaths); both go toShellWorkspace.missing, which flags those tabs and keeps them (#372). A delete asked for in the app closes the tab instead. - Keeping editors alive.
mounted()decides which editors stay mounted: each pane’s showing tab, plus the four most recently shown, minus notes over 200K characters. Each mounted note has aGlobalKey, so a tab moved to the other pane takes its editor, and its undo, along.
Mementos
NoteView hands in a NoteMemento:
- when its tab goes behind another, and when it goes away;
- one second after the last move, scroll or edit of the note on screen;
- when the app leaves the foreground.
The second trigger matters because a window closed with nothing unsaved
closes without the app being asked (the close guard only intercepts
while there are edits to protect). A memento still arrives while the
editor is being taken down, so ShellWorkspace.remember applies it in
a microtask. The shell flushes the workspace when the app leaves the
foreground, after those microtasks. The shell rebuilds only when
something it draws changes: tabs, panes, and each tab’s editor and
preview. A caret or a scroll handed in redraws nothing.
A new NoteView gets its tab’s memento as initialMemento. The
selection is put back only in the editor it was taken in, because source
and WYSIWYG offsets count different things. The scroll is put back
after the first layout.
The acceptance criteria
test/widget/workspace_acceptance_test.dart checks #23’s criteria on
the deck the shell runs:
- two notes edited independently keep their text and their own undo across a switch;
- where a note was left survives the store and a new view;
- closing the window asks once about every unsaved note and writes them all.
shell_split_test covers the split and open_notes_switcher_test
covers the phone.