Skip to content

Undo for GemStone code changes — manual/UX review first, please - #507

Open
ericwinger wants to merge 51 commits into
mainfrom
eric/issue434-refactoring-undo
Open

Undo for GemStone code changes — manual/UX review first, please#507
ericwinger wants to merge 51 commits into
mainfrom
eric/issue434-refactoring-undo

Conversation

@ericwinger

@ericwinger ericwinger commented Aug 26, 2026

Copy link
Copy Markdown
Member

Important

Please review this by USING it, not by reading it. Press F5, work through the script below,
and tell me whether the feature feels right — where the buttons belong, whether you can find them,
whether the messages say the right thing.

Not yet: file layout, code structure, naming, test coverage, bugs in the diff. There is a lot
of it (160 files) and none of it is worth your time until the shape of the feature is settled. I
will fix whatever comes out of this pass, and then ask for a proper code review.

Editing GemStone code has been one-way. This makes it reversible: one Undo covering everything
you have done since you connected, most recent first.

Try it (about ten minutes)

Press F5, connect to a stone, and open the GemStone Explorer.

  1. Save a method. Change a method, save it. A notice appears with an Undo button — press it.
    The old source comes back, and the editor showing it reloads.
  2. Dismiss the notice, then undo anyway. Save a method again, dismiss the toast, and look at the
    left end of the status bar: a purple ↩ Undo. Hover it — it names the exact change. Click it.
  3. Delete a method, then undo. It comes back with its category, and the Explorer selects it.
  4. Add a class variable and say yes to accessors. Undo once — the variable and both accessors go.
  5. Add a class variable, then add a method that uses it, then undo the variable. It warns you
    first, naming the method that would be left reading nil.
  6. Rename a method category, then undo. Same methods, renamed back, nothing recompiled.
  7. Remove a dictionary, then undo. It comes back at the position it held, with its classes.
  8. Move a class to another dictionary — right-click a class in the Classes or
    Hierarchy pane → Move to Dictionary… — then undo. It goes back where it came from.
    (Both this and Move to Category… were System-Browser-only before; they are on the Explorer's
    class menu now.)
  9. Rename a class category onto one that already exists — the classes merge — then undo. Only
    the classes that actually moved go back; the ones already there stay put.
  10. Change a class definition (add an instance variable), then undo. Note the button now says
    Revert, and it tells you what that costs before doing it.
  11. Apply a refactoring (rename a method, say), then undo. This one opens a preview, because it
    can have rewritten methods all over the hierarchy.
  12. Press Ctrl+K U a few times and walk backwards through what you did.

Nothing commits, in either direction.

What I would most like your opinion on

1. Where should the Undo button live? There are five ways to reach it right now, which may be
four too many:

the notice that follows an action the one that actually gets used — it is where you are already looking
the status bar, far left purple, stays put while connected, dims when there is nothing to undo, tooltip names the change
the Explorer Methods pane title bar ↩ icon
the editor title bar ↩ icon
the Command Palette Undo Last Change…

Which of these earn their place? Is there somewhere better I have not thought of? The status-bar one
deliberately stays visible but dimmed when the stack is empty, on the theory that a control which
vanishes cannot be learned — you find it once by accident and then never again. Tell me if that is
wrong, or if it is just clutter.

2. Is Ctrl+K U the right shortcut? It follows the Ctrl+K prefix the other Jasper bindings use,
and it deliberately does not take Ctrl+Z — that stays with the editor, so undoing your typing
and undoing a GemStone change never get confused. The cost is that the obvious key does not do the
obvious thing when your cursor is in a GemStone pane rather than an editor. Would you rather Ctrl+Z
did GemStone undo when focus is in the Explorer, and accept the risk of getting the wrong one?

Where this is honestly not "undo"

Worth knowing before you report these as bugs — each is deliberate, and each says so in the UI:

  • Changing a class definition is a REVERT, not a rollback. GemStone has no transaction savepoints
    and re-versions a class on every shape change, so what happens is that the earlier version is bound
    again
    . The class history grows; it never shrinks. Anything written on the newer version since is
    left behind on it, and a modal names those methods before it happens. The button says Revert
    rather than Undo wherever this is what you will get.
  • Renames, and instance-variable add/remove, apply the opposite operation. Reversing a rename is a
    fresh rename in the other direction, not a rollback — which is actually safer here, since work you
    did after the rename is carried forward rather than discarded.
  • A still-empty method category exists only in the pane. Creating one with + does not touch the
    stone until something is filed in it, so undoing that is a pane-only change. If you file a method
    into it first, the undo notices and acts on the stone instead.
  • Some things refuse, and say why. A refactoring that migrated instances or deleted class-history
    versions records nothing — both commit and both are irreversible. Undoing the creation of a method
    category that has since been filled refuses rather than acting, because GemStone's removeCategory:
    deletes the methods in a category along with it.
  • Undo is per session and per connection. A reconnect starts empty, and an abort clears it — an
    abort has already rewound the stone underneath every entry. The stack holds the last 25 things.

What is covered

Saving a method, adding one, deleting one, moving one to another category; creating a class, changing
its definition, removing it (with its subclasses, as one entry), saving its comment; adding a class
variable and its accessors; creating and renaming method categories; restoring a class version;
creating, renaming and removing a dictionary; moving a class between dictionaries; renaming a class
category and moving a class into another; and the refactorings — change signature, extract/inline
method, extract/inline temporary, move method, push up/down, rename method, rename temporary, plus
class renames, instance/class-variable renames, instance-variable add/remove, extract superclass,
insert superclass and split class.

Reading the commits, if you want to

Grouped by area rather than chronologically — 42 commits, and the first of each group is the one worth
reading:

  • the stack itself — a generic per-session stack that knows nothing about refactorings; the
    refactoring engine is one kind of entry on it
  • the reversers — one per kind: method slots, class bindings, class comment, class variable,
    method category, symbol-list dictionary
  • the recording sites — the Explorer, the System Browser, the file-system provider, the file-in
    mirror
  • the engineGsRefactoringUndo and its 71 SUnit tests, filed in with the rest of the payload

Testing

Full suite green on both 3.6.2 and 3.7.5 with the engine installed: 7 214 passing across 463
files. The engine's own SUnit suites run in-stone on each version. The generic undo has its own
integration suite that is deliberately ungated — nothing in it needs the refactoring engine, which is
the claim that ordinary undo works on a bare stone.

Closes #434. Closes #452. Closes #174. Closes #297.

Every command in Jasper that changes code is now undoable. Two items on #452's list have no command
to record — delete a category's methods and revert a method (the latter looks like editor revert,
which is VS Code's own undo). If something has been missed, it is worth a fresh issue rather than
holding these open.

ericwinger and others added 30 commits August 17, 2026 17:23
Adds GsRefactoringUndo: the undo record for the most recent applied refactoring,
plus the executor that puts it back.

The record is STATE-BASED rather than kind-based. Recording wraps an ordinary
apply: work out the (class, side, selector) method slots the about-to-be-applied
changes touch, snapshot each slot's live source and category before the apply,
run the refactoring's own applyDeselected: untouched, snapshot again, and diff
the two into an inverse GsRefactoringChangeSet. A change that failed or was a
no-op shows no difference and contributes nothing, so the undo never tries to
reverse something that did not happen, and no per-kind inverse table has to be
kept in step with the seventeen engines.

Because the inverse is an ordinary change set, the client can preview and apply
an undo through the same paginated preview + per-change deselection path every
forward refactoring already uses. The inverse is ordered restore, then
recompile, then remove, so undoing a rename brings the original selector back
before the new one goes.

Scope is method changes only (methodAdd / methodRemove / methodRecompile /
methodRename). A change set that reshapes a class records no entry at all and
says so via the apply result's undoRecorded field -- half an undo is worse than
none, and class shape wants the classHistory revert path instead.

Drift is a warning, not a refusal: a method edited since the refactoring is
flagged per change in the preview so the user can deselect it.

Nothing commits, either direction.

[GS SUnit] GsRefactoringUndoTest -- 26 tests, green on 3.6.2. Apply-then-undo for
each method-only refactoring asserts the whole class is restored on BOTH sides,
including methods no change set mentions, with categories intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Client half of refactoring undo. Every method-only refactoring's apply now goes
through GsRefactoringUndo, so applying one records how to put it back, and three
entry points reach that record:

  1. an Undo button on the post-apply toast -- the cheapest and most discoverable,
     and what VS Code itself does after a rename;
  2. an Explorer context-menu item, on classes, methods and the hierarchy view;
  3. a command palette entry, "GemStone: Undo Last Refactoring…".

The toast is deliberately not the only way: it is easy to miss and easy to
dismiss, and the record survives it. (2) and (3) are gated on a new
`gemstone.refactoringUndoAvailable` context key, so they appear exactly when
there is something to undo and vanish once it is used up.

Undoing gets the same preview treatment the forward direction gets: a paginated,
per-change panel with a diff on every row and a checkbox on every row, so any
part of an undo can be kept. Rows are badged with what undoing DOES -- Restore /
Revert / Delete -- rather than with the engine's change kind, which reads
backwards in an inverse change set. Drift (a method edited since the
refactoring) is shown inline per row and summarised at the top; it is a warning,
never a refusal. The panel reuses renameMethodPanelView.js verbatim rather than
growing a near-copy that could drift.

The recorded apply reaches GsRefactoringUndo through `objectNamed:`, never by
name, so the doit still COMPILES against a stone whose refactoring engine
predates undo -- there it takes the engine's own apply path, records nothing, and
the post-apply notice falls back to exactly the status-bar / toast message the
refactoring showed before. A nice-to-have must not turn into "Apply fails".

The ~10 per-refactoring success notices are consolidated into one
notifyRefactoringApplied helper, which is also where the context key is
republished after every apply.

Nothing commits, either direction.

[client .ts] 61 new tests over the model, the query builders, the recorded-apply
expression, the panel HTML, the toast and the command flow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
[GCI integration] refactoringUndo.integration.test.ts -- 18 tests over the real GCI
transport, driving apply-then-undo through the same query builders and parsers the
extension uses.

One round trip per undoable refactoring: rename method (instance and class side),
extract method, inline method, move method, push up, push down, change signature,
rename temporary, extract temporary, inline temporary. Each asserts the WHOLE class
is back where it started -- every selector on BOTH sides, each with its source AND
its category, including the methods the refactoring had no reason to touch. Those
last ones are the point: no change set mentions them, so nothing else would catch
their loss, and a category dropped to 'as yet unclassified' fails the comparison too.

Also pinned: the status probe; drift surfacing as a per-change warning that does not
block the undo; per-change deselection leaving that one slot alone; a clean undo
consuming the record while a partial one keeps it; the undo preview paginating (every
page is loaded and the total checked); and the safety rule that a change set touching
class shape records no entry at all.

The engine's GS SUnit suite runs in-stone here too, as the other refactoring
integration tests do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A class-reshaping refactoring records no undo of its own -- class shape has its
own restore path -- but it also makes any PREVIOUSLY recorded method undo
untrustworthy: it creates new class versions, so the recorded sources may no
longer compile into the class they name, and offering "undo the last
refactoring" would name the wrong one. Each of those seven applies now forgets
the record, applied once at the browserQueries seam every one of them passes
through rather than at each command's several success paths, and running whether
the apply succeeded, partly failed, or threw.

Also here:

- docs/how-to/manually-test-refactoring-undo.md -- a numbered F5 test plan
  covering the three entry points (including the case that matters most: a
  DISMISSED toast must not strand the undo), the preview, drift, partial undo,
  what undo will not offer, and session/commit behaviour.
- The engine README's class table gains GsRefactoringUndo.
- CHANGELOG entry under Unreleased.

[client .ts] 10 tests pinning the invalidation for all seven class-reshaping
applies, including the apply-throws path, and pinning that a method refactoring
does NOT forget the record it just made.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment-only sweep over what the previous commits left describing the old
behaviour. No code change.

- The nine apply query builders each still documented their answer as the bare
  {applied, failed} envelope and said nothing about being routed through the
  recorder. Each now names `undoRecorded` and points at recordedApplyExpr.
- renameMethodPanelView.js called itself the rename-method panel's view; it is
  now shared verbatim with the undo panel, so its header says so -- including
  the instruction to keep it panel-agnostic, and why the global name stays
  `RenameMethodPanel`.
- previewEnvelope.ts enumerated who produces this envelope; undo added two
  producers (a recorded apply, and the undo apply itself) and neither was
  listed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The merge brought in #387's `.commented` class row: the Classes pane now sets
`contextValue` to `explorerClass.commented` when a class has a comment, and every
class-row menu clause was widened to `viewItem =~ /^explorerClass(\.commented)?$/`
so both variants offer the same actions.

`gemstone.undoLastRefactoring` was written before that landed and still used the
exact `viewItem == explorerClass`, so it was the one class action that VANISHED
from the context menu of any class that has a comment. Widened to match its
neighbours.

Caught by main's own guard (explorerFilterUx387.manifest.test.ts, "the
.commented contextValue only ever adds a button") -- a semantic conflict a clean
textual merge could not have shown. The Methods-pane and Hierarchy-pane entries
already matched their neighbours and are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Renaming a class, an instance variable, or a class variable is now reversible
from the same Undo affordances as the method refactorings. Eric's framing, and
the right one: it is not a true undo, it is "put the name back by renaming
again", and the UI says so rather than implying a rollback.

Why these three and not the rest of the class-reshaping family: a rename is the
only one whose forward operation IS its own inverse once the two names are
swapped. The others need machinery that does not exist yet (a class-removal
primitive, an inverse ordering that stays top-down) or would lose data a by-name
reversal cannot restore -- add/remove instance variable, instance-variable
structure, extract superclass and split class still record nothing.

GsRefactoringUndo grows a second mechanism. The change-set diff cannot express a
rename: it is keyed by class NAME and by (class, side, selector) slots, and a
rename is precisely what moves the key. So a rename records the OPERATION --
kind, class, from-name, to-name, scope -- and reversing REBUILDS the same rename
engine in the opposite direction, then previews and applies its change set. Both
mechanisms answer an ordinary change set, so the client path is unchanged.

Consequences handled rather than papered over:

- The preview now renders CLASS-shape rows (classRename / classReparent /
  classDefinitionEdit), because a reverse rename's change set contains them.
  Rows are badged Rename back / Re-version / Redefine, and a methodRecompile
  reads "Rewrite" under a reverse rename but "Revert" under a recorded inverse
  -- one kind, two meanings, so the badge takes the mechanism.
- A COLLISION refuses up front. The rename engines deliberately do not enforce
  `newNameCollision` while building a change set: the forward flow's preview
  surfaces it and the client refuses. A reversal is not driven from that panel,
  so without an explicit check the guard would simply be absent and the reversal
  would fail partway. Reachable in practice -- rename A to B, create a new A,
  reverse. The instance-variable engine has no collision check at all, so the
  reversal makes the one that matters (the name it renames back TO must not
  already be declared).
- The caveat is stated once, above the list, and in the post-undo toast: class
  history GROWS on a reversal, never shrinks. The compensation is real and worth
  saying -- a rename carries methods forward, so work written after the rename
  SURVIVES the reversal, where a classHistory revert would have discarded it.

[GS SUnit] GsRefactoringUndoTest is now 47 tests, green on 3.6.2 AND 3.7.5.
[client .ts] mechanism parsing, class-shape rows, badges, the caveat banner.
[GCI integration] 5 more round trips: class rename reversed, work written after
the rename surviving it, the preview being the reverse rename's own change set,
an instance-variable rename reversed, and the collision refusing without
clobbering the newcomer.

Note for the matrix: 3.6.2's `Array class` has no `with:` past four arguments,
so the six-slot record is filled slot by slot.

Docs swept for what this made stale: CHANGELOG, the manual test plan (new
section 6b), and the invalidation helper's comment -- the renames still clear
what was recorded BEFORE them, then record a reversal of their own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…the checkboxes (#434)

Second of the "undo for all" batch. Adding or removing an instance variable is
now reversible from the same Undo affordances, by the same route as the renames:
the opposite operation of the same engine (`class:addInstVar:` <->
`class:removeInstVar:`), which re-versions the class and carries methods forward.

The mechanism is generalised from #renameBack to #mirror, since it no longer
describes only renames, and the entry now carries `reverseKind` so the caveat can
be worded per operation. That matters: a rename reversal is BETTER than a
rollback (it carries later work forward), while an instance-variable reversal is
worse, and one generic sentence would misstate one of them.

  - reversing an ADD removes the variable again, which DELETES any method written
    since that uses it. `mirrorDropCount` predicts how many, from the engine's own
    willNotRecompile analysis, and the banner names the number.
  - reversing a REMOVE declares the name again but restores neither the values it
    held nor the methods the removal dropped -- said plainly rather than implied.

An apply that MIGRATED instances or DELETED history records nothing: both commit,
both are irreversible, and a reversal must never be the thing that makes the
stone unrecoverable. The reversal itself hardcodes migrate:false and
deleteHistory:false for the same reason.

Found while wiring this up, and fixed: un-ticking a preview row means THREE
different things across these engines, and the panel had been rendering them
identically.

  #perChange   the change is skipped (rename class)
  #dropsMethod the method is NOT carried onto the new version -- it is DELETED
               (rename instance variable: the only way a method disappears there)
  #ignored     the engine applies its whole change set regardless (rename class
               variable, add/remove instance variable)

So the summary "un-tick anything you want to keep" was exactly backwards for the
instance-variable rename, and every #ignored row offered a checkbox that silently
did nothing. Rows now render disabled where deselection is ignored, the counter
reads "(all applied)" rather than "selected", and the destructive case gets a
warning-styled banner. `deselectionFor:` is the one place that decides.

[GS SUnit] GsRefactoringUndoTest is now 57 tests, green on 3.6.2.
[client .ts] per-kind caveats, deselection notes, disabled rows, the unknown
reverseKind guard.
[GCI integration] 4 more round trips: an add reversed, a remove reversed
(asserting the dropped accessor does NOT come back), the deselection + drop-count
report, and the refusal when the variable is already gone.

Docs swept: CHANGELOG, and the manual plan gains 6b.12-6b.15 including the
migrate case that records nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#434)

Completes undo across all seventeen refactorings. The last four -- instance-variable
push up / push down / convert temporary, extract superclass, insert superclass,
split class -- have no opposite operation to mirror, so they get a third
mechanism, #historyRevert:

  1. before the apply, capture the classHistory index of the target class and
     every descendant, TOP-DOWN;
  2. to reverse, revert each to that version via
     GsClassHistory>>revertClassNamed:toIndex:, in the captured order;
  3. then unbind any class the refactoring CREATED -- an inserted superclass or an
     extracted component has no earlier version to revert to.

Top-down is the correctness trap, not a preference: GsClassHistory re-versions
each subclass onto its parent's restored version, so a child reverted first would
be re-parented onto a version its parent is about to supersede.

Per Eric (2026-08-19): the reversal puts the thing refactored back as it WAS, and
the user is told that is what is happening. A revert takes the historical
version's methods as well as its shape, so anything written on those classes since
the refactoring is discarded. That is detected rather than discovered:
`discardedMethodsFor:atIndex:` diffs each class's current methods against the
target version, the banner names the count (and turns from a note into a warning
when it is non-zero), and every row names its own methods.

The capture is held PENDING and only becomes an entry once the apply is known to
have landed, so a cancel, an error, a partial reshape, or an apply that COMMITTED
(migrate instances / delete history -- the two irreversible options) all drop it
rather than offer an undo against a state the capture does not describe.

The preview stays one panel: a synthetic change set of one classDefinitionEdit per
reverted class (a real definition diff, badged Restore) plus a new #classRemove
kind for each unbind. classRemove is staged only by an undo -- no forward
refactoring produces it. Rows are disabled, since a revert is all-or-nothing.

[GS SUnit] GsRefactoringUndoTest is now 71 tests, green on 3.6.2: capture ordering,
pending-capture lifecycle, the discarded-methods detection, the definition diff,
the unbind, the refusal when every class is gone, and no-commit.
[client .ts] the third mechanism, the classRemove kind and its badge, the
pre-refactoring-state caveat in both tones.

Docs swept: CHANGELOG, and the manual plan gains section 6c -- including 6c.6/6c.7,
which deliberately walk into the discard so the warning can be judged.

Still to verify: 3.7.5, and integration round trips for these four.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nism back (#434)

Eric asked for integration coverage on the class-reshape reversals. It found a
defect the GS SUnit tests could not: GsClassHistory>>revertClassNamed:toIndex:
does NOT restore a class's own SUPERCLASS. It restores shape and methods, and
re-parents SUBCLASSES onto the restored version, but leaves the class itself
under whatever parent it currently has.

That is fine for a reshape that only changes a class's own instance variables --
which is all the SUnit tests exercised, and why they passed -- and wrong for one
that changes parentage. Reversing an inserted-superclass refactoring would leave
the class under the inserted parent, and the reversal then UNBINDS that parent:
the class would be left pointing at an unbound class. Not shippable.

So the four class reshapes (instance-variable structure, extract superclass,
insert superclass, split class) record NO undo for now. Each command still takes
the capture -- harmless, and it keeps the protocol in one shape -- and then drops
it, behind a comment saying why. The engine mechanism and its 71 SUnit tests stay
in place; nothing offers it, so nothing can act on it.

The six integration tests that exercise it are kept and `it.skip`ped: they are the
reproduction now and the acceptance criteria later. The two that cover the
pending-capture lifecycle pass and stay active.

The fix, for when this is picked up: the reversal has to re-declare each class
under the superclass named in its CAPTURED definition (already recorded, plan slot
3) and copy methods forward -- what GsRenameClassRefactoring>>restoreClass:toVersion:
does, plus the parent.

CHANGELOG and the manual plan corrected so neither claims these four are covered;
section 6c carries the hold-back notice.

Undo remains fully working, on both boundaries, for the thirteen refactorings
covered by the earlier batches.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Brings in the 1.8.12 release, the #432 selector-hover counts, and PR #451.

CHANGELOG conflicted where the release inserted its 1.8.12 section under
[Unreleased]: both sides kept, this branch's undo entries staying under
[Unreleased] above the released section. Also collapsed a sentence this branch
had said twice about the migrate/delete-history case.

Gate green after the merge: lint, format:check, compile, 403 files / 6 068 tests.
…orings (#434)

The defect the integration tests found, fixed at its root, and the last four
reversals turned on.

GsRenameClassRefactoring>>applyClassRename: passed `old superclass` to
makeNewVersionOf: -- the CURRENT superclass -- when it should pass the shape
source's. For an ordinary rename the two are the same object, so only the RESTORE
path was affected: restoring a version rebuilt the class with that version's shape
and methods but left it under whatever parent it has now. Restoring across a
re-parenting therefore did not restore the definition it claimed to.

That is a latent bug in the shipped Class Definition History Restore (#191), not
only in undo -- so it is fixed there, in one place, and the CHANGELOG says so.
`superclassForShapeSource:of:` resolves the historical parent BY NAME to whatever
version is current, since re-parenting onto a superseded version would strand the
class off the live hierarchy; if that name no longer resolves it keeps the current
parent rather than failing to create the class at all.

With that, the four held-back reshapes are on: instance-variable structure,
extract superclass, insert superclass, split class. All 17 refactorings now have
an undo.

Also fixed: a test-fixture bug of mine that the same run exposed. The push-down
cases moved `balance`, which UndoItAccount's own accessor reads -- so the engine
DECLINED the push-down, nothing was applied, and the reversal was a no-op the
assertions then blamed on the mechanism. The fixture now carries `undoSpare`,
read by nothing, and the comment says why a push-down test needs one.

[GCI integration] 35 tests, all passing -- the six that were the reproduction are
now the regression guard.
[client .ts] 403 files / 6 074 tests.

CHANGELOG and the manual plan updated: 6c loses its hold-back notice, and the
Restore fix is listed as the user-visible fix it is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e method (#434)

Two things Eric hit in F5 testing.

1. Revealing a class hid the rest of the dictionary.

revealClass pinned the Class Categories pane to the revealed class's OWN
category, which filters the Classes pane to that category. On a category holding
a single class -- DemoCatB in Demo-Cat-Sub -- renaming it left the Explorer
showing one class and the rest of the dictionary apparently gone.

Pre-existing, not a regression from undo: this path is untouched by the undo
work. The old comment claimed the pinning was so the target row is "guaranteed
visible", which it achieves by hiding everything else.

A reveal now never IMPOSES a category. It keeps one the user had already chosen
when the revealed class is actually in it -- a deliberate filter should not be
yanked away -- and otherwise clears it, so the class appears in the dictionary's
full class list. Crossing dictionaries always clears, since a category selection
belongs to the dictionary it was made in.

This affects every reveal caller (rename, reshape, GemStone Search, Find Class,
move class), which is the point: they all had the same surprise.

2. Undoing a method rename left the Explorer where it was.

The undo now selects the method that came back. The inverse change set is ordered
restore-first, so the first methodAdd IS the restored method; falls back to the
first method change of any kind. A class-shape reversal deliberately lands on
nothing -- there is no single method to select, and the refresh already re-reads
the class. New gemstone.explorer.revealMethodByName command, resolving the class
across the symbol list.

[client .ts] 5 tests driving the real revealClass -- verified they fail 3/5 with
the old behaviour and pass 5/5 with the new, so they pin the fix rather than
restate it -- plus 2 on the undo landing. 404 files / 6 081 tests green.

CHANGELOG and the manual plan updated (new steps 3.6b and 6b.6b).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…434)

Eric, F5 testing: "I really don't like that Undo last refactoring would have to
appear on every context menu." He is right -- it was already missing from the
instance-variable rows, and the fix for that would have been a fourth, then a
fifth entry.

Moved to a BUTTON in the GemStone Explorer's Dictionaries pane title bar, beside
Commit and Abort. That is where it belongs: those are the other session-level
actions on uncommitted state, and a title-bar button gated on
`gemstone.refactoringUndoAvailable` appears the moment a refactoring is applied
and disappears when the record is used up -- which is exactly the behaviour
asked for. The three context-menu entries are gone; the palette entry stays as
the keyboard path.

Icon is $(debug-step-back) rather than $(discard): Abort already uses $(discard)
in the same toolbar, and two identical icons side by side would be worse than
either choice on its own.

Also fixed, same session: an undo that put a CLASS back left the Explorer
pointing elsewhere. It now lands on the class, the way a method undo already
landed on the method. A reversed class rename ends up under the name it went
BACK to -- carried on the row as `newName` -- so landing on the pre-undo name
would have selected a class that no longer exists.

[client .ts] 2 more command tests (class rename reversal lands on the restored
name; a history revert lands on the reshaped class). 404 files / 6 082 green.

CHANGELOG and the manual plan updated throughout: section 1 is now "the two ways
in", and the steps that said "right-click / context menu" now check the title-bar
button appearing and disappearing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Eric, F5: the Explorer title-bar button works but "I couldn't find it. Neither
will a user." So the action also sits in the status bar, visible whatever has
focus.

Three things make it findable:

- PURPLE, via the `charts.purple` theme colour rather than a hardcoded hex, so it
  renders correctly in light and dark. It is the only coloured item in a row of
  neutral ones, which is the whole point.
- A text label, not a bare glyph: "↩ Undo Refactoring".
- A tooltip that says BOTH which extension it belongs to and which refactoring it
  will undo -- "GemStone — Undo Rename 'total' → 'sum'". That second half is
  something the Explorer button and the palette entry can never do: contributed
  menu titles are static, so they can only ever say "Undo Last Refactoring…".

It shows and hides on the same probe that drives the context key, so all three
affordances appear and disappear together.

The Explorer title-bar icon is now a purple SVG rather than a codicon, for the
same stand-out reason -- a contributed menu icon cannot be themed from
package.json, so the colour is baked into the asset. It is the first custom icon
in the extension; everything else uses codicons.

Kept the title-bar button rather than replacing it: it is the right home for
someone already working in the Explorer, and it costs nothing now that it is not
also on every context menu.

[client .ts] 5 tests: wiring, the theme colour, show/hide with the record, the
tooltip naming GemStone and the refactoring, and no-item safety. 405 files /
6 087 green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
)

Undo was a refactoring feature: the record lived in the stone's SessionTemps,
inside the refactoring engine, and the only way to reach it was through the
engine's preview panel. That put every other undoable action behind an install
it does not need, and behind a preview it does not want.

The stack now lives in the extension (client/src/undo/), and the refactoring
engine is one KIND of entry on it:

- a METHOD EDIT -- saving a method, adding one, deleting one -- holds its own
  before-state and reverses immediately, with no preview. The reversal is plain
  compileMethod: / removeSelector: against the kernel, so it works on any stone
  Jasper can log in to, engine or no engine.
- a REFACTORING keeps its preview, because it can rewrite dozens of methods
  across a hierarchy. Its record stays in the stone; the stack entry is a
  pointer carrying the stone's sequence number, so a record the stone has since
  replaced or forgotten is dropped and the dispatcher falls through to what is
  under it.

undoLastCommand.ts is the only module in undo/ that knows refactorings exist.
Another kind later means a branch there and a reverser beside it -- not a change
to the stack, the UI, or any recording site.

Two consequences of going generic, both deliberate:

- One entry became a bounded stack (25). A single slot was fine when the
  recorded events were rare and deliberate; once every save records one it is
  churned constantly, and the previous source of anything but the last edit
  would be gone.
- Stone undo is never bound to Ctrl+Z. The editor's own text undo rewinds the
  buffer and not the stone; these are different scopes and must stay different
  gestures. After a stone undo, clean open editors for the touched methods are
  reloaded so the buffer cannot save the undo straight back.

Method source has to travel to the client and back, so it goes escaped: a
backslash escapes itself and anything outside printable ASCII becomes \uXXXX
(\UXXXXXXXX above the BMP). Pure ASCII on the wire for the same reason the
engine's preview payload is -- a Unicode-promoted result trips the client's
character-based GCI fetch -- but lossless, because this text is recompiled
rather than displayed.

Recording is best-effort throughout and never allowed to break the edit it
wraps: a failed snapshot means no undo, not a failed save.

Renamed with the scope: gemstone.undoLast / "Undo Last Change...",
gemstone.undoAvailable, a purple "Undo" button, resources/undo.svg, and
docs/how-to/manually-test-undo.md. extractSelector moved to methodPattern.ts --
leaving it in systemBrowser.ts would have closed an import cycle with the
file-system provider, which now needs it to know which slot a save touches.

Still to record: copy/move method to class, recategorize, the MCP tools, and
file-in's cleanup deletions. Anything that is not a method slot needs a second
entry kind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lace (#434)

Eric, on the F5 host: "I couldn't find it again." The button existed only while
there was something to undo, and a control that is usually absent cannot be
learned -- you find it once, by accident, and then have nowhere to look. Four
changes, in the order they matter:

- Every undoable action's own notice now carries the Undo button, not just a
  refactoring's. Saving a method said "Compiled method X" and stopped there;
  deleting one said nothing at all. This is the affordance with no discovery
  cost, because it appears where the user is already looking at the moment they
  would want it. `commit` answers the entry it recorded so a site can offer the
  button without asking a second time -- and offers nothing when nothing was
  recorded, since a dead Undo button is worse than none.

- Ctrl+K U (Cmd+K U), joining the existing chord family. Nobody hunts for undo;
  they press a key. Plain Ctrl+Z is not available and should not be: in an
  editor it is the buffer's own undo, a different scope, and Jasper already
  binds it for dismissing a display result.

- The status-bar button stays put for as long as a session is connected, dimmed
  rather than gone when the stack is empty, and says so in its tooltip. It is
  the fixed landmark; the dimmed state still responds, so clicking it explains
  what the button is for rather than doing nothing. It hides only when there is
  no session, since undo is per session.

- The Explorer title-bar icon moved off the Dictionaries pane, which is about
  dictionaries and had no business hosting a global action, onto the Methods
  pane -- where methods are created and deleted -- and onto the title bar of an
  open gemstone:// method editor, directly above the method just saved. Those
  two stay contextual on purpose: they are cues, and the status bar is the
  landmark.

Tooltips name the keybinding, which is how a shortcut actually gets learned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Creating a class, changing its definition, and removing it now go on the same
stack as method edits and refactorings, reached by the same button and the same
Ctrl+K U. Semantically it is the same act, so splitting it into a second command
would only make the user choose.

Called a REVERT wherever it shows, because the distinction is real. GemStone has
no transaction savepoints and gives a class a new version on every shape change,
so reversing binds the EARLIER VERSION again: the history grows rather than
shrinks, and anything written on the newer version since stays on that version,
out of reach under the restored one. A modal names those methods before it
happens rather than after -- the same trade, stated the same way, as the
refactoring engine's class-reshape undo.

Reversal binds the class OBJECT back, held in SessionTemps from the moment of
capture, rather than recompiling a saved definition. Two reasons, both verified
against a 3.7.5 stone:

- Re-sending `Object subclass: ...` with a changed shape does NOT carry methods
  forward. It answers a new, EMPTY version. Reverting by recompiling a definition
  would restore the shape and discard every method on it.
- `deleteClass` only unbinds the name; the class version itself survives. So a
  removed class goes back exactly as it was -- methods, class-side methods,
  history, instances. Holding it is also what keeps it reachable, since an
  unbound, unreferenced version can go.

That same GemStone behaviour is a sharp edge in Jasper today, independent of this
work: saving a shape change from the class-definition editor produces a version
with no methods, which is why the refactoring engine has its own Add/Remove
Instance Variable. Reverting is now the quickest way back from it without
aborting the transaction. Written up in the how-to and the changelog; a proper
fix belongs to its own issue.

Removing a class with subclasses is recorded as ONE entry for the subtree --
putting half a subtree back is not a reversal of anything the user asked for.
Order does not matter on the way back, unlike the engine's history revert,
because nothing is re-versioned.

Two bugs found while wiring it up, both fixed here and both affecting the method
path as well:

- Both reversers asked about drift before working out whether there was anything
  to do, so an entry the user had already reversed by hand produced a modal about
  a slot nothing was going to touch. Plan first, prompt second.
- recordClassEdit resolved the query executor outside its guard, so a failure
  there would have propagated into the edit it wraps. Recording must never be why
  an edit fails; the method recorder's comment now says so explicitly too.

The file-system provider's test file needed a per-test reset of the two capture
mocks: `clearAllMocks` clears recorded calls but leaves implementations, so a
`mockReturnValue` from the recording suites leaked into the plain writeFile ones
and turned their toast into one carrying an Undo button -- visible only under
some shuffled seeds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The status-bar button and the toast both said "Undo" for every entry, including a
class edit -- whose every subsequent message says "Revert", because binding an
earlier class version is not a rollback. Promising an undo and handing over a
revert is worse than one button that names what it will do, so the verb now
follows the entry: Revert for a class edit, Undo for a method edit or a
refactoring.

Still one button, one command, one keybinding. Semantically it is the same act,
and making the user choose between two commands would be a worse trade than
changing a word.

The tooltip gains a colon -- "GemStone - Revert: Remove class Account" rather
than "Revert Remove class Account", which ran two verbs together and read as
neither. It already named the specific change; that has not changed.

The contributed menu titles stay "Undo Last Change..." because VS Code menu
titles are static, which is the same reason the status-bar tooltip exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
)

The unit tests covered the pure logic well and the edges hardly at all: the
recorders had no direct tests, afterUndo had none, the two Explorer recording
sites had none, and NOTHING exercised the generated Smalltalk. The doits had only
ever been run by hand in topaz -- which is not coverage, it is a demo.

Adds 174 tests across 14 files for the undo layer, up from 105 across 10.

The important addition is undo.integration.test.ts: 31 tests over the real GCI
transport, through the same builders and parsers the extension uses, run against
a live stone. Deliberately UNGATED, unlike the refactoring undo's suite beside
it -- nothing here needs the engine, so it runs anywhere Jasper can log in. That
claim is the reason the stack lives in the client at all, and a suite that
actually runs on a bare stone is the only thing that proves it. Three layers:

- what a capture reads and an apply does, including absent classes, absent
  selectors, the metaclass side, a source carrying quotes/backslashes/tabs/
  newlines, and per-operation failure that does not abandon the rest;
- record-then-reverse round trips asserting the WHOLE slot is back -- source AND
  category for a method, methods on BOTH sides plus the bound version for a
  class, and a removed subtree restored in one plan;
- the GemStone facts the class design rests on, pinned so they cannot change
  underneath it without a red test: a shape-changing redefinition answers a new
  EMPTY version, an identical one is a no-op, and an unbound class survives to be
  bound again.

The escaping contract is tested through the escaper directly rather than a
fixture method, because 3.6.x rejects a doit whose own source carries non-ASCII
-- a fixture cannot be written that way from the client at all. It asserts both
halves that matter: pure ASCII on the wire, and exact text after decoding.

Also new: direct tests for both recorders, concentrating on the promise that is
easiest to break by accident -- recording must never be why an edit fails, so
every way a capture can go wrong is pinned; afterUndo, where the rule with teeth
is that a DIRTY editor is left alone; and the Explorer's method- and
class-removal sites, pinning that the capture straddles the removal and that an
entry is only recorded once GemStone confirms it went.

Two integration tests grew an extra assertion rather than dropping an unused
binding: drift now also asserts what the reversal still targets, which is the
point of drift being a warning and not a redirection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…434)

The Setup section asked the reviewer to hand-build a class in the Explorer, with
unprefixed selectors -- `total`, `report`, `make` -- that the dev stone's other
demo classes already implement. That makes the refactoring sections' sender lists
wrong, which is the same trap the safe-delete fixture hit twice.

gs-src/fixtures/undoDemo.gs builds it instead: UndoDemoAccount with
UndoDemoSavings under it and UndoDemoLedger beside it, every selector
`ud`-prefixed. Verified in gs64stone_375 -- one implementor each, zero foreign.
Each element earns its place: udReport is the caller a rename must rewrite and an
undo must put back, udBalanceValue gives Remove Instance Variable a method to
drop, udSpare is read by nothing so pushing it down is declined, and udUntouched
plus class-side udMake are the canaries -- udMake also being the class-side slot
a class revert has to bring back.

It COMMITS, deliberately. Steps 0.12 and 7.4 exercise Abort, and an uncommitted
fixture would vanish underneath the reviewer at exactly that point. Only the
fixture is committed; the edits made during the pass stay abortable, which is
what makes those two steps mean anything.

UndoDemoFixture carries install / reset / removeDemo and is never touched by any
step, so a section that renames or removes the material classes cannot strand the
cleanup. `reset` matters more here than in earlier fixtures: this walkthrough
mutates the fixture on purpose -- a definition turned into an empty new version,
a class left renamed -- and the next section assumes the original shape.

Deliberately no extra dictionary. Nothing here tests shadowing, and inserting one
shifts every symbol-list index, which has bitten two earlier fixtures.

The how-to is renamed through to match, and Setup now says which sections need no
engine at all: 0 and 0b run on a bare stone, which is the point of the
client-side undo.

Two topaz notes worth keeping: `method: X class` silently ignores the `class` and
compiles onto the instance side -- class-side methods need `classmethod: X`; and
the re-load preamble unbinds names directly rather than calling removeDemo, so a
half-loaded or older fixture cannot make the reload fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…our work (#434)

Two problems, one of them a real hazard.

The document had grown into a checklist -- sections bolted on in the order they
were written, "Tidy up" and "Reporting" stranded ahead of 6c, and no indication
of where a reviewer's time is actually worth spending now that 174 unit tests and
31 GCI integration tests cover the mechanism. It is now a plan: passes ordered by
how likely each is to be wrong, every pass naming what it verifies and what a
FAILURE looks like, and a table up front saying which claims are already proven by
automation and which no test can reach.

The ordering is deliberate. Pass A is findability, because that is the part that
was wrong twice and has nothing behind it but package.json contributions. Pass D
is abort/logout clearing the stack, which has NO automated coverage at all and
where a wrong result -- a still-live entry pointing at a rewound transaction --
is only discovered by pressing it. Those two are called out as the ones to run if
there is no time for the rest.

Section 1 was folded down to the one thing Pass A does not cover: that a dismissed
post-refactoring toast does not strand the undo. The rest of it had become a
duplicate, and duplicated instructions rot.

The hazard: `UndoDemoFixture reset` ended in `System commitTransaction`, which
commits EVERYTHING uncommitted in the session -- so a reviewer holding a
half-finished experiment would have had it written into the dev stone permanently
by the very command meant to give them a clean slate. `reset` and `removeDemo`
now abort first, so the commit carries the fixture and nothing else. Verified
against gs64stone_375: a stray method compiled before `reset` is gone afterwards
rather than committed.

Tidy up was still telling the reviewer to abort and check the fixture had
vanished, which stopped being true when the fixture became committed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The test plan is scaffolding for a review pass, not documentation the project
should carry: it names a fixture that lives only in a dev stone, and it goes stale
the moment the review is done. Eric's call -- it lives outside the checkout now.

Removes docs/how-to/manually-test-undo.md and drops the fixture header's pointer
at it. Nothing else referenced the file.

The fixture itself stays: gs-src/fixtures/undoDemo.gs is reusable, is loaded and
committed in the dev stone, and is the thing a future review pass would want back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Saving a class-definition tab under a different name does not rename anything: it
compiles a definition for a class that does not exist yet and leaves the class the
tab was opened on untouched. The recorder watched the URI's class, saw no change,
and recorded nothing -- so the class the user had just created could not be
reverted. The slot's name now comes from the definition source, and "created" vs
"redefined" comes from what the capture saw rather than from the URI kind.

Editing `inDictionary:` moves the class the same way, and everything downstream was
still looking in the dictionary the TAB came from. Nothing failed loudly:
`canBeWritten` reads a class it cannot find as NOT writable, so a save that
persisted fine reported "recompiled transiently -- NOT persisted"; the category was
silently not applied; the capture saw no change in the old dictionary and recorded
nothing; and the mirror export and the reopened tab both named the old dictionary.
The target dictionary now comes from `inDictionary:` for both URI kinds. The URI's
SymbolList index still wins while the definition leaves the class where it was (an
index is unambiguous where a name is not) and is dropped once the class moves.

Also adds `notifyChanged(uris)`, the provider half of putting an open editor back in
step after an undo: an undo recompiles straight over GCI and never reaches
`writeFile`, so VS Code was never told the resource changed. Its caller lands next.

Tests cover the rename, the move, the unmoved save that must keep using the index,
the notification, and -- while in the file -- the two helpers that had none
(`unescapeSelectorSlashes`, `tabInputUri`).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two things an undo changed without telling anyone.

The open editor. An undo recompiles straight over GCI and never touches the file
system provider, so VS Code was never told the resource changed -- the editor kept
showing the source the undo had just discarded, which is how an undo gets silently
re-saved on the next Ctrl+S. `reloadVisibleGemstoneEditors` tried to compensate by
focusing each visible editor and running `workbench.action.files.revert`, which
depends on focus landing right and never reaches a tab that is not on top. It now
sends the signal a save sends -- `notifyChanged` on every open CLEAN gemstone
document -- and keeps the revert as belt-and-braces. Renamed to
`reloadGemstoneEditors`, since "visible" is no longer the scope. Dirty editors are
still left alone: an undo of something else is not licence to discard typing.

GemStone Search. It caches its class corpus, so a class an undo unbound stayed on
offer as a hit and opening it landed on "Class not found" -- the panel showing a
class the Explorer no longer had. `refreshSearch` runs the same resync a commit or
an abort already does. Blunt on purpose: an undo can restore a class, remove one,
restore a method or take one away, and folding each in per corpus would be a second,
subtler copy of the reversal planner.

Both reach their provider through an internal command, so `undo/` stays free of the
Explorer, the FS provider and Omni Search -- the same way it already reached the
Explorer refresh.

Also publishes the two context keys the title-bar icons gate on, mutually exclusive,
so an affordance never promises an undo and hands the user a revert. The menu
entries that use them land next.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The status-bar tooltip is set at runtime, so it reads "GemStone -- Revert: Remove
class Account (Ctrl+K U)". The three contributed affordances -- the icon on the
Explorer's Methods pane, the one on the editor title bar, and the palette entry --
all read "Undo Last Change..." even when the status bar said Revert. A contributed
menu title is a fixed string, so none of them can NAME the change; that part is a
platform limit and stays. Everything else about them was fixable:

- the VERB. A second command, `gemstone.revertLast`, titled "Revert Last Class
  Change...", running the same dispatcher, with each menu entry gated on one of two
  mutually exclusive booleans. Exactly one is ever visible, so no affordance
  promises an undo and hands the user a revert.
- the CHORD. VS Code appends a keybinding to a title-bar tooltip only for a command
  that carries one, so Revert's hover was losing the Ctrl+K U that Undo's showed.
  Both are bound to it now, on opposite conditions, so the chord is never ambiguous
  and still works when there is nothing to reverse (the Undo binding wins and the
  user gets the refusal rather than a dead key).
- a POINTER. Both titles end with "(the status bar names it)", so a hover that
  cannot say what it would reverse at least says where that is written.
- WHERE IT SHOWS. The editor entry also carried `resourceScheme == gemstone`, and
  that key is per-editor: the icon appeared on the title bar of gemstone:// tabs and
  nowhere else, so looking at any other file it vanished while the status-bar button
  stayed lit. Reads as "the undo was lost". Availability is now the whole condition.

`keybindings.test.ts` required one command per chord letter. That rule is now "a
letter may repeat only for commands that can never both be live", checked by looking
for a context key one clause requires and the other forbids -- so a genuine
duplicate chord still fails.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Push up / push down an instance variable, extract superclass and split class all
record their reversal in the stone -- and then announced themselves with a bare
`showInformationMessage`. That is the one notice that does NOT push onto Jasper's
undo stack, and the stack is what lights the status-bar button. So the reversal
existed server-side with nothing able to reach it: no button on the toast, a dimmed
status bar, and Ctrl+K U saying there was nothing to undo. All three now go through
`notifyRefactoringApplied`. When nothing was recorded -- a committing apply that
migrated instances, which is irreversible -- it falls back to exactly the plain
toast they showed before.

Also leaves breadcrumbs on the path that decides whether an Undo is offered at all:
what the stone answered when recording a reversal, the raw status JSON on every
probe, and which branch the post-apply notice took. Every failure along it is
already handled quietly and on purpose -- a rename that has landed must not fail
because its bookkeeping did -- which means "no Undo was offered" is otherwise
completely silent, and a stone that answers `available:false` looks identical to a
probe that threw. These three lines separated a real bug from a red herring twice in
one review pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`npm run test:coverage -- 'src/undo/**'` runs the client's default project under the
v8 provider and reports on the files the path matches. The whole suite runs either
way, so a file reads as uncovered only when no test ANYWHERE exercised it -- which is
the question worth asking, since plenty of these modules are driven through their
callers rather than directly.

Scoped rather than whole-repo on purpose: a number over 400 files is a number nobody
acts on, and the interesting question is always about the part being worked on.

Adds @vitest/coverage-v8, which pulled vitest 4.1.7 -> 4.1.11 within the existing
`^4.0.18` range. `lint:supply-chain` and `lint:lockfile` both pass; the one audit
advisory (nanoid, via an existing transitive dep) predates this and is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`clearAllMocks` clears recorded CALLS without removing an implementation, so a
`mockReturnValue` set anywhere in this file stayed in force for every test that ran
after it -- and the file's order is SHUFFLED, so which tests those were changed with
the seed. The file already guarded its two undo-capture mocks this way; `compileMethod`
and `compileClassDefinition` had the same problem and no guard.

Seen for real: "shows success message after compiling a method" asserted
`Array>>#at:` against a toast naming `Array>>#total`, because the undo-recording suite
below had set `compileMethod` persistently and happened to run first.

Both are now put back to their factory default per test, in the same place and for the
same stated reason as the two that were already handled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ericwinger and others added 12 commits August 25, 2026 15:18
`src/undo` went 96.4% -> 99.4% statements, 87.4% -> 93.9% branches and 93% -> 100%
functions; `src/refactoring/*undo*` went 73.3% -> 97.1% statements and 78.6% -> 100%
functions. Line coverage across `src/undo` is now complete.

The gaps worth naming:

- `undoRefactoringPanel` was at 1.8%. Its HTML had a test; its CONVERSATION with the
  webview had none -- including the two rules that exist because undoing is a real
  operation and not a preview: apply is one-shot (a double-click would reverse the
  reversal), and cleanup runs exactly once on every way out, since it drops a preview
  session the stone is holding open. Also pinned: a failed fetch or apply leaves the
  panel open and retryable rather than closing over a lost undo.
- `undoRefactoringCommand`'s handler arrows -- the panel is UI-only, and these are
  what turn "fetch page N" and "undo, skipping these" into GCI calls against the
  token THIS preview opened.
- the availability probe's contract that it never throws, and that each outcome is
  logged, since a stone answering `available:false` and a probe that threw produce
  the same notice and only the log separates them.
- `undoStack`: the listener disposer (a listener that fires after its owner is gone
  is a leak that surfaces much later), the depth accessor, and drops for a session or
  an id the stack does not hold -- neither of which is a change, so neither may wake
  the listeners that redraw the UI.
- the reversers refusing to spend an entry when the reversal could not even RUN, as
  distinct from one the stone refused per slot.
- the message shapes a user actually reads: plural drift wording, the ten-plus-remainder
  cap on the methods a revert would leave behind, and every branch of the post-undo
  summary.
- the parsers' behaviour on a malformed answer -- empty rather than undefined, and an
  unrecognised backslash escape passed through rather than eaten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two more everyday actions join the same stack, the same button and the same
toast. Both are plain kernel Smalltalk, so they need nothing installed on the
stone, and both are UNDOs rather than reverts: neither `comment:` nor
`addClassVarName:` re-versions the class, so putting the earlier state back is
exact and leaves nothing behind on an older version.

The comment is recorded at both save sites -- the Comment panel and a
`gemstone://.../comment` editor. Undoing writes the earlier text back; a class
that had no comment is the empty string as far as GemStone is concerned, so
undoing a first comment empties it again.

Adding a class variable is reversed by removing it, and the accessors the same
action generated go with it, accessors first -- one action, one Undo. An
accessor that already existed (and was therefore skipped by the add) is left
alone, because the ordinary method-slot planner sees its state unchanged.

The cost that has to be named up front is OTHER methods. A class variable is
shared with the whole subtree, so anything written between the add and the undo
references it, and removing the declaration does not take those methods with it.
GemStone severs the reference instead: each one stays in place, reads nil from
then on, and no longer recompiles. That is pinned by an integration test, and it
is a modal naming the methods -- both sides, whole subtree -- before it happens.

Also fixed on the way past: `setClassComment` reports a class it cannot resolve
by RETURNING a status string rather than raising, and both save sites ignored it,
so "Comment updated for ..." appeared over a save that wrote nothing. Both now
surface what the stone said, and the undo entry is recorded only once the save
has actually landed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…434)

The last five actions on the gap list, three of which need no new machinery.

ADD ACCESSORS, standalone, is an ordinary method edit: it records the slots it
compiled and only those, so an accessor that already existed (and was therefore
skipped) is left alone by the undo. Run as the follow-up to Add Class Variable it
records nothing of its own -- that flow already covers the accessors on the
variable's entry, and a second entry would make the user press Undo twice for one
action.

RESTORE A CLASS VERSION is an ordinary class edit: restoring binds a new version
under the class name, so it reverts by binding back the version bound now. A
restore ACROSS A RENAME unbinds one name and binds another, so both are recorded
-- the target version's own name is what history reports for it -- and the revert
undoes both halves.

RENAME A METHOD CATEGORY gets its own kind, renamed back rather than reversed as
N method edits: `renameCategory:to:` moves every method in one message and
recompiles none. GemStone REFUSES a rename onto an existing category, which is
what makes the reversal exact -- a rename is one name becoming another, never two
categories merging -- and the reverser checks for that collision itself so the
user gets a sentence rather than error 2032. A still-empty category renamed only
in the client overlay records nothing; there is nothing on the stone to put back.

THE SYMBOL LIST gets the other new kind, covering both shapes. A renamed
dictionary is found under its new name and renamed back. A removed one is put
back AT THE POSITION IT HELD: a symbol list is ordered and name resolution walks
it in order, so appending it would silently change what a bare name resolves to.
It comes back with every class it held, because `symbolList remove:` unlists the
dictionary without destroying it -- SessionTemps is what keeps it reachable in
the meantime, exactly as it does for a removed class. Both ways of removing one
are covered: the Explorer's Remove Dictionary and deleting a dictionary directory
from the file-in mirror. Those two reported themselves in the status bar, which
cannot carry a button, so they now use a notice like every other undoable action.

Two things the stone taught us, both guarded and both pinned by an integration
test: `insertDictionary:at:` raises on an index past the end of a list that has
since got shorter, and it will happily list the same dictionary twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Explorer's "+ new category" leaves the stone untouched until something is
filed there -- deliberately, so a category you create and abandon costs the stone
nothing. Dropping a method on one of those rows therefore targeted a category
GemStone did not have, and `moveMethod:toCategory:` answered
classErrMethCatNotFound over a row the pane was showing.

`recategorizeMethod` now creates the category when it is missing, so a drop makes
an overlay category real exactly as compiling a method into it already did. The
create is guarded, because `addCategory:` raises classErrMethCatExists on one
that is already there, and `categoryNames` is compared as SYMBOLS -- comparing
`each asString` to a String literal raises "Unicode argument disallowed in String
comparison" on a stone in legacy string mode.

Also corrects a comment that had this backwards: an empty category CAN exist on
the server (`addCategory:` makes one, and it persists). What is true is that
Jasper only ever puts one there when a method lands in it.

Found while testing method-category undo, but present before it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fix for dropping a method on a "+"-button category was right -- verify the
category is there, create it if not -- but it was only proven on a category that
had never existed. The failure reported from the Explorer was the OTHER shape: a
rename had taken `accessing` and its methods away, and a fresh `accessing` from
the "+" button had taken its name, so the target had a familiar name and no
server existence at all. That sequence now has its own integration test, against
the real stone, and it passes.

`dragMoveToCategory` had no test of its own, which is why the query looked
covered and the drop did not. It has one now: the overlay-category case, several
methods at once, a method already in the target, the class side staying on the
class side, and a refused move being reported rather than swallowed.

One trap worth naming: the drop test's `makeController(undefined)` was silently
handing back a REAL session, because passing undefined triggers a default
parameter. It takes null now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Renaming a category the "+" button made was left unrecorded, on the grounds that
it had never reached the stone. That is Jasper's bookkeeping, not the user's: they
renamed something in the pane, it stayed renamed, and the Undo button said there
was nothing to undo.

It records now, and looks no different. The reversal happens in the Explorer's own
overlay -- the only place such a category exists -- through an internal command,
since this is the one reversal that has to be asked of the view rather than the
stone. It answers what happened rather than a bare boolean, so the undo can tell
"the pane has moved on" from "that name is taken" and say which.

Which of the two renames runs is decided from the LIVE state, never from a flag on
the entry, because a category crosses between them: filing a method into a fresh
one -- by compiling, or by dropping a method on it -- makes it real, and the undo
then has to rename it on the stone. An entry recorded over an empty category does
the right thing either way.

The overlay is discarded whenever the browsed class changes, so an entry can
outlive the category it describes. That is not a failure and not a refusal: the
status bar says the category is no longer listed, the entry is spent, and the next
Undo reaches whatever is under it -- rather than a dead entry sitting on top of the
stack refusing forever.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Renaming a category the "+" button made became undoable last commit; creating one
was still the odd action out. The row appears in the pane and stays there, so it
has to be as undoable as anything else that appears and stays.

Recorded as the same kind with a NULL `before` -- there was no category before, so
the reversal takes it away rather than renaming it. Which reversal runs is still
decided from the live state: a category that has since become real is removed on
the stone, one still in the overlay is removed there.

The removal is guarded, and the guard is the point. GemStone's `removeCategory:`
does not refuse a category that holds methods -- it deletes them along with it --
so a bare call is a silent way to lose every method in a category. Undoing a
create whose category has been filled since therefore REFUSES and says how many
methods are in it, rather than attempting anything. The check and the removal are
one doit, so nothing can file a method into it in between. Both the guarded
refusal and the unguarded behaviour it exists for are pinned by integration tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reported from the Explorer: create a category, drag a method into it, press Undo
-- and the undo refused, because the move was not recorded at all and the top of
the stack was still the CREATE, which correctly will not remove a category that
now holds a method.

The guard was right; the gap was the move. The last thing the user did has to be
the first thing Undo reaches. Both ways of doing it are covered now: dropping
methods on a category row in the Explorer, and the System Browser's Move to
Category.

No new kind. A captured method slot already carries its CATEGORY as well as its
source, so an ordinary method edit is all a category move is: the reversal
recompiles the same source under the category it came from, and every rule that
already applies -- drift warnings, partial-failure reporting, editor reload,
revealing what came back -- applies unchanged. A drag that moved several methods
is one entry, because it was one drag.

That also puts Eric's sequence in the right order: the first Undo moves the method
home, leaving the category the move created behind and empty, which is exactly the
state the create's own entry can then remove. Both halves are pinned by an
integration test against the real stone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An audit pass, prompted by everything in the manual list coming back verified.
`npm run test:coverage -- 'src/undo/**'` put the layer at 96.4% statements and
88.7% branches, with three real holes rather than cosmetic ones:

  - the two OVERLAY bridges and the symbol-list refresh had no tests at all. They
    are thin, but the thing they encode is a judgement: an Explorer that is not
    registered, and one that answers nothing, both read as 'not-listed' rather
    than as a failure, because the overlay is the only place such a category
    exists and its absence is not an error.
  - `applyClassVarOp`'s SENTINELS were untested. These queries report trouble by
    returning a string rather than raising, so reading one wrongly reports success
    over a stone that did nothing -- and 'not-defined' on a removal is the state
    the reversal was aiming at, not a failure. That distinction now has a test,
    including that it does NOT hold in the declare direction.
  - the file-in mirror's dictionary removal recorded an entry that nothing
    checked. It is the same destructive act as the Explorer's Remove Dictionary
    and had only the Explorer's half tested.

Also covered: the dictionary doits as data (position parsing, the clamp for a
list that has since got shorter, the refusal to list one dictionary twice),
`readMethodSlotState`'s two guards, the comment recorder's non-text guard, and
the raise-rather-than-return paths through every reverser.

Now 98.9% statements, 100% functions, 99.7% lines. What remains uncovered is
defensive branches -- `instanceof Error` message extraction and nil fallbacks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Removing and renaming a dictionary became undoable; creating one was still the odd
action out, and it is also the one that made the rename's collision refusal
reachable at all -- nothing was pushed on top of the rename entry.

Recorded AFTER the fact rather than around it, unlike every other recorder here:
there is nothing to capture before a dictionary exists, and the position it landed
at is only knowable afterwards. Both ways in are covered -- the Explorer's "+" and
the System Browser's Add Dictionary.

An absent `before` is what makes the reversal a removal rather than a restore, and
it needs no stash: nothing is being held for a later reversal.

The one thing it asks about first is CONTENT. The dictionary was empty when it was
made, so anything in it arrived afterwards, and unlisting it puts all of that out
of reach -- verified: the names stop resolving. Nothing is destroyed, since
`symbolList remove:` unlists rather than deletes, but that is worth being told
before it happens rather than discovered after, so it is a modal naming the count.

Counting is not `size`. A SymbolDictionary holds its own name by identity
(`#Name -> theDict`), so a genuinely empty one reports a size of ONE; the
self-entry is discounted, or a brand-new dictionary would warn about holding
itself. Pinned by an integration test.

The doit's temporary is `selfEntry`, not `self` -- a reserved word cannot be a
temporary, and the integration test is what caught it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ericwinger and others added 5 commits August 26, 2026 15:37
The last three code-changing commands that had no way back, so "everything that
changes code is undoable" is now literally true rather than nearly true.

MOVE TO DICTIONARY needs no new machinery. `moveClass` does `removeKey:` from one
dictionary and `at:put:` into another -- the same class object, rebound -- so it is
an ordinary class edit over two slots: the name it left and the name it arrived
under. The reversal rebinds the first and unbinds the second, which the class
reverser already does.

CLASS CATEGORIES get a new kind, recorded PER CLASS rather than as a pair of
category names. That is the whole design, and it is forced by what the rename
actually does: it reassigns every class carrying the old label AND the
dash-segmented subtree beneath it, it MERGES into a category that already exists
rather than refusing, and it skips any class it cannot write. So the set of
classes that moved is only knowable by diffing the dictionary before and after --
and putting a NAME back would drag along the classes that were already there,
while putting back each class's own former label is exact in every one of those
cases. A partial rename needs no special handling either: a class that did not
move shows no difference and contributes nothing.

Moving a single class to another category is the same shape with one class, so it
shares the kind and the reverser.

Neither recompiles anything and neither commits -- a class category is a label.

Also converts the class-category rename's success report from a status-bar message
to a notice, for the same reason the dictionary commands were converted: only a
notice can carry the Undo button. A rename that already warned is left alone rather
than given a second message.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both commands existed only in the classic System Browser, which is not what anyone
uses any more -- so "move a class" was effectively unreachable, and the undo built
for it in the previous commit could only be exercised by recompiling a class
definition by hand.

They are on the class context menu now, in the Classes pane AND the Hierarchy
pane: either is a reasonable place to be looking at the class you want to move.
The handlers take a row from either pane or fall back to the selection, which is
the same shape every other class-level command here uses.

The Explorer versions are not straight ports. Move to Dictionary refuses a class
the repository will not let you write, before prompting rather than after, and
reports a stone that answered with a status string rather than reading it as
success -- `moveClass` reports a missing class by RETURNING one. Move to Category
offers still-empty categories from the "+" button alongside the real ones, since
filing a class into one is exactly what makes it real.

Both record undo through the machinery added in the previous commit, so the
notice carries the way back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both moves left the panes describing where the class USED to be. After a move to
another dictionary the Classes pane still showed the dictionary that no longer
held it; after a move to another category the Class Categories pane had not
learned about the new label, and a Classes pane filtered to the category the class
had just left simply stopped showing it.

Both now go through `revealClass`, which is the cascade the Explorer already uses
whenever it needs to follow a class: it refetches that dictionary's class
categories and the class's environments, switches the Dictionaries pane, reloads
the hierarchy and methods, and -- the part that matters here -- keeps a deliberate
category filter only while the class is still inside it, clearing it otherwise so
the class is not hidden by a filter it no longer matches.

A dictionary move also drops the class from anything caching a class corpus. Those
caches key by dictionary, so the entry for the OLD one now points where the class
will not resolve; a row that fails to open is worse than a row that is briefly
absent, and it returns under its new dictionary on the next resync.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The panes refetched and the class was revealed, but no category row lit up.

`revealClass` deliberately leaves a revealed class's OWN category unselected: doing
that after a rename filtered the Classes pane down to a single class and made the
rest of the dictionary look like it had vanished. That reasoning holds for an
incidental reveal -- and does not hold here. The user picked this category by name
from a list; highlighting it is the answer to what they asked for, not a filter
sprung on them.

So the move now selects the chosen category after the reveal, and reveals its row.
The class stays selected, because it is inside the category being selected --
`selectClassCategory` only drops the class when the category would filter it out
of view. A dash-segmented path selects on its last segment, like every other
category row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The undo refiled the class correctly and the panes did not follow. The cause is
the forward action: moving a class to a category SELECTS that category, so after
undoing the move the Class Categories pane was still filtered to the one the class
had just left -- the class was back where it belonged and invisible.

A plain refresh does not fix that. `refreshRetainingSelection` does refetch the
categories, but it keeps the selection, and the selection is the problem. So the
reverser now tells the Explorer specifically that classes were refiled, through an
internal command like the symbol-list one.

With ONE class moved it follows that class to whatever it is filed under now,
which is the exact mirror of the forward action. With many -- a category rename
undone -- following one would be arbitrary, so it drops a filter that no longer
holds the selected class and leaves the rest alone. A filter the class is still
inside is kept either way; a deliberate filter should not be yanked away.

Also fixes a flake I introduced in the same file: the "read fails" test stubs the
category query to throw, and `clearAllMocks` keeps IMPLEMENTATIONS, so under a
shuffled order that throw leaked into neighbouring tests. Reset, like the other
two files that hit this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ericwinger
ericwinger marked this pull request as ready for review August 26, 2026 23:46
…ue434-refactoring-undo

# Conflicts:
#	CHANGELOG.md
#	client/package.json
#	client/src/__tests__/gemstoneFileSystemProvider.test.ts
#	package-lock.json
#	package.json
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant