Skip to content

feat(history): change history and revisions - #133

Merged
devnull03 merged 17 commits into
mainfrom
feat/change-history
Sep 21, 2026
Merged

devnull03 merged 17 commits into
mainfrom
feat/change-history

Conversation

@devnull03

Copy link
Copy Markdown
Owner

Closes #132 (Notion ID: 3da21d32-b13b-81f8-bc41-e0ad8b446aa8)

Summary

  • Log (settings::history): every data and note change — cell edits, row/column add/delete/rename/move, notes, undo/redo — recorded in __history/__history_changes inside the .qrate file, keyed by row id and column name, with origin (typed, paste, fix label, …) and author. Written in the same transaction as the data, so unsaved changes that are discarded leave no entries.
  • Restore: table::restore_to reverses everything after an entry by identity as one undoable step and logs it as a new entry (append-only); restore_value puts one cell back.
  • Fixes on the way: column moves are now an undo step instead of clearing undo; undoing a rename re-keys notes, column settings and __columns.
  • History panel (right dock): unsaved changes on top, saved ones by day with bursts collapsed; right-click to restore the project, restore a value, or name a version; filters and Clear History. Saved dock layouts adopt new panels instead of losing them.
  • Row and cell history: a History section in Details with per-value restore, and "Show edit history" on the cell menu, which narrows the History panel to that cell across column renames.
  • The author setting is relabelled "Attribute notes and changes to".

Verification

./scripts/ci.sh (fmt, clippy -D warnings, cargo test --workspace) passes locally. New tests cover log round-trips, restore across a mixed edit sequence, undo logging, paging, naming, clearing, rename-following and cell narrowing. Not verified: the new UI has not been clicked through in the running app.

🤖 Generated with Claude Code

Every edit, structural change, undo, redo and note change is recorded in
__history/__history_changes, keyed by row id and column name, with its
origin and author. Entries are written in the same transaction as the
data they describe. Column moves are now undoable instead of clearing
undo, rename undo re-keys notes and settings, and restore_to/restore_value
put the grid back by identity as one undoable step.

Refs #132
A right-dock panel lists every change newest first: unsaved changes on
top, saved ones under day headers, with bursts of edits by one person
collapsed. Right-clicking a change restores the project to it, restores
a single value, or names the version; the header filters by kind or to
named versions and can clear the history. Saved layouts that predate a
panel now adopt it into its default dock instead of losing it.

Refs #132
Details gains a collapsible History section listing the selected item's
changes, old value beside new, with a button to restore a saved value.
The cell menu's "Show edit history" brings the History panel forward
narrowed to that cell, following its column back through renames. The
author setting now says it signs changes as well as notes.

Refs #132
Every change in the History panel and the Details section now reads with
its date as well as its time, unsaved ones included. Truncated summaries
show their full text on hover, like the Problems panel. The panel's
status-bar icon is Lucide's history clock, shipped as an app asset, so
PanelMeta now names an icon by asset path.

Refs #132
The status bar already carries one button per panel and is the real
switcher, so a dock holding two tools drew a tab strip that said the same
thing twice. Each panel now reports `visible` only while it is the one its
group is showing, which is the gate the skin already uses to draw a plain
title bar in place of the strip. `set_active` is driven off the group's
active index rather than off visibility, so exactly one panel per dock
ever answers yes and there is no feedback loop.

Nothing in PanelRegistry changes: `toggle` already brought a hidden panel
to the front and closed the dock on the shown one.

History also drew its own ellipsis button beside the one the tab bar
always draws. Its item moves onto `Panel::dropdown_menu`, which the
library builds from whichever panel is on screen, so the menu follows the
dock's contents for free.
… contents

Dragging the History handle up hid the Notes header entirely, and the
fields painted through the section below it. The three splits nest, but
each pane declared a minimum with no reference to what it holds: the
history split's first pane floored at the fields' own 80px while holding
the fields *and* Notes, and the preview split's second pane took the
100px default while holding all three sections. The resizer shrank them
past what their children fit in, and nothing clipped the surplus.

The floors were not even the ones written. gpui-base applies a caller's
style refinement and then overwrites min_h with size_range.start, and the
drag clamp reads size_range too, so `min_h` on a resizable_panel binds
nothing at either end. Those panes never set a range, so both ends used
the 100px default.

Every floor is now the sum of its contents, via size_range, and a
collapsed section is boxed at its real height (header plus the rule it
draws above itself) rather than 1px short. The panel clips at its own
edge so a floor that is still wrong truncates a section instead of
drawing two over each other.

Also carries the Details panel's own set_active/visible hooks from the
previous commit: the two topics share this file and splitting it would
have meant rewriting it.
The change log is append-only and a working project accumulates one entry
per edit, so every read path that scaled with the whole log rather than
with the page being shown was a stall waiting to happen.

Reading a page ran one `WHERE entry_id = ?` per entry, and the only index
on `__history_changes` was `(row_id, column_name)` — so a page of 200 was
200 full scans of every change ever recorded. The changes for a page now
come back in one query, and the table carries an `(entry_id, seq)` index.
Existing projects pick the index up at their next write; the batching
already removes the N+1 without it.

The panel's unforced refresh re-read every page the reader had loaded, so
writing a note cost more the further back they had paged. It now re-reads
only the newest page and splices it onto the pages below, which the log
being append-only makes sound. A forced reload — a rename, a clear —
still re-reads the lot.

The Details panel's History section built and formatted every line even
while collapsed, when all it draws is a header strip; it now asks only
whether anything is behind it.
The batched change fetch distributes one query's rows across the entries
of a page, so an entry holding several changes is what catches a
distribution that mixes two entries together or loses the sequence they
were written in. Nothing covered that: every entry the existing tests
write holds exactly one change, and a reordered pair reads as the wrong
before-value for a restore.
Every loaded entry became an element on every frame — a hover, a scroll,
a keystroke in the grid each rebuilt the whole list. One page is 200
entries and "Load older" has no ceiling, so the panel got heavier the
longer a researcher worked and the further back they looked.

The list is now flattened to one addressable row per line and drawn
through `v_virtual_list`, which builds only the visible range. The rows
hold indices into the filtered view rather than text, so formatting a
description — the expensive part — happens for the rows on screen and
nowhere else.

The list places rows by fixed heights and gives the rows those same
heights, rather than measuring: measuring means building every row to ask
how tall it is, which is the cost this removes. The constants and the
rows they belong to have to be changed together, which is what the note
on them says.

The unsaved entries' local times were already fetched in one call; they
now happen once per render rather than once per row built.
The log had no retention path at all: it only ever grew, and "Clear
History…" was the single lever — all of it or none. A collection worked
on for months has no way to bound its `.qrate` file short of throwing the
whole audit trail away.

Retention is off by default, because this log is an audit trail and
deleting from it is a decision the archivist makes rather than one qrate
makes for them. Set a limit and the oldest changes go, except that a
named version is never dropped: a name is the archivist saying this is
the entry to come back to, so the rule they set to bound a file must not
be what takes it away. Named entries also still count towards the number
kept, so turning a limit on can never cost more than the number it names.

Pruning happens when the project opens, not as entries are written. The
History panel holds the page it last read, and pruning underneath it
would leave the list pointing at entries that are no longer there.
Converting a unix timestamp to the archivist's local day and time needs
timezone rules, and this crate had no date library — so it borrowed
SQLite's calendar. Saved entries got theirs as three extra `localtime`
columns on the page query; entries still in memory had no query to ride
on, so `local_times` opened a brand-new empty in-memory database purely
to do the arithmetic, once per timestamp, on every render.

chrono is already in the tree through calamine with `clock` enabled, so
one function converts both now and nothing new is compiled. The clock is
read once per batch rather than once per row, the page query loses three
columns, and a saved entry and an unsaved one agree about what day a
change was made on because they are converted by the same code.

An hour a clock change repeated resolves to its first reading rather than
refusing to be a time; one a clock change skipped is shown as the UTC it
was recorded as rather than not shown at all.
The lint step has been a no-op since the remap was configured. Cargo runs
a linted crate as `<wrapper> <clippy-driver> <rustc> <args>` and
clippy-driver copes by dropping the rustc path, but not once the remap
has rewritten it — so the crate sees that path as a second input file and
rustc stops with "multiple input filenames provided" on the first crate
it reaches. A plain crate lints fine; every qrate worktree sets the
remap, which is why this only shows up here.

`[env]` in `.cargo/config.toml` does not force, so an empty value in the
environment wins while zccache stays the wrapper and keeps caching. Only
this step loses the cross-worktree sharing the remap buys; the test step
below it, and every ordinary build, keep it.
One tracked path was holding a different plan on every branch, so each
planning branch collided with main on it as a matter of course — three
open PRs conflict on this file and nothing else. Nothing references it,
and git history keeps every version that was ever in it.
@devnull03
devnull03 merged commit e9876ce into main Sep 21, 2026
3 checks passed
@devnull03
devnull03 deleted the feat/change-history branch September 21, 2026 06:39
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.

ASNT-104: Change history & revisions

1 participant