Undo for GemStone code changes — manual/UX review first, please - #507
Open
ericwinger wants to merge 51 commits into
Open
Undo for GemStone code changes — manual/UX review first, please#507ericwinger wants to merge 51 commits into
ericwinger wants to merge 51 commits into
Conversation
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>
`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>
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
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
…ue434-refactoring-undo
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
The old source comes back, and the editor showing it reloads.
left end of the status bar: a purple ↩ Undo. Hover it — it names the exact change. Click it.
first, naming the method that would be left reading nil.
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.)
the classes that actually moved go back; the ones already there stay put.
Revert, and it tells you what that costs before doing it.
can have rewritten methods all over the hierarchy.
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:
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 Uthe right shortcut? It follows theCtrl+Kprefix the other Jasper bindings use,and it deliberately does not take
Ctrl+Z— that stays with the editor, so undoing your typingand 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+Zdid 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:
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.
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.
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.
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.
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:
refactoring engine is one kind of entry on it
method category, symbol-list dictionary
mirror
GsRefactoringUndoand its 71 SUnit tests, filed in with the rest of the payloadTesting
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.