Fix undo B - Ids on undo changes, rewordings and split of UndoManager, minor clarifications - #16680
Fix undo B - Ids on undo changes, rewordings and split of UndoManager, minor clarifications#16680calixtus wants to merge 26 commits into
Conversation
`hasChanged()` compared a net edit balance against the balance recorded at save time. Two different histories reach the same balance, because pushing a change clears the redo stack: save, undo, then make a different edit, and the balance is back where it started although the library now holds something that was never saved. That is not only a stale modified marker. `hasChanged()` feeds `LibraryTab.markChangedOrUnChanged()`, which sets `changedProperty`, which `requestClose()` consults before offering to save — so the false negative closes a library holding unsaved work without asking. Positions are now identified rather than counted. Every push takes an id from a counter that only ever increments, the id travels with the change across both stacks so redo returns to the position it came from, and `markUnchanged()` stores the id at the top of the undo stack. Because ids are never reused, a saved position discarded by a redo-stack clear or by the LIMIT trim can never be matched again, which is the right answer in both cases: it is no longer reachable. The id lives in a private `UndoJournalEntry` record rather than on `BibChange`. A change is a value describing a modification; a position in this journal is bookkeeping only the manager needs. Putting it on the change would also cost `inverted()` its involution, since it would have to either copy the id — giving two distinct positions one identity — or drop it. The empty stack needs its own id for the same reason: once a trim or a `clear()` has discarded history for good, "undone back to nothing" is no longer the state the library started in. Three tests cover the defect and fail without this change. The two existing trim tests pin the behaviour the id scheme had to preserve. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194MmVeMweN5EKQEx5yNoC1
`apply()` was `change.apply(); addEdit(change);` — two separate acquisitions of this object's monitor, with the model write outside it. Between them the library holds the change and the journal does not, so an undo arriving there reverts the *previous* change while the new one stays applied but unrecorded. The history that ends up on the stack then describes a library state that never existed. The class header already stated the opposite as an invariant: "Applying the change is inside the lock. It has to be: if the stack transition and the model write could interleave, two threads could undo the same change." `undo()` and `redo()` honour it; `apply()` was the one path that did not, and commands do push from background tasks, so the two threads the header describes exist. The stack transition moves into a private `push()` that requires the monitor, and `apply()` takes it once around both the model write and the push. Applying foreign code under the lock is already the accepted cost here — `undo()` does it — so this brings one path in line rather than changing the design. Inside an `addEdit` block there is no window to close: the recorder belongs to one thread and nothing reaches the stacks until the block ends, so that path takes no lock. `CompoundEdit.apply()` keeps its two-step form for the same reason, and its javadoc now says so instead of pointing at a lock invariant that does not apply to it. The regression test asks the applying thread, from inside a `BibEntry` subclass that probes on `setField`, whether it holds the journal's monitor at that moment. Staging a second thread would only have shown that it did not get in within some interval, which makes the assertion a statement about a timeout; whether the lock is held is a fact available on the spot, and no threads, executors or waits are needed to read it. The probe overrides rather than subscribing to the entry's field events, so the test adds no use of Guava's deprecated EventBus. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194MmVeMweN5EKQEx5yNoC1
P15 stopped `apply()` from delegating to `addEdit()`: it has to hold the monitor across the model write and the stack push, and `addEdit()` ends by notifying listeners, which must never run under the lock. The branch deciding whether a change reaches the stacks at all therefore had to be restated in `apply()`, and the two methods came to read as near duplicates of one another. They are not duplicates. The only difference is who performs the change: `addEdit` records one the caller has already made — the common case, because the model hands the caller a `FieldChange` as a by-product — while the other makes it and records it as one operation. Everything after that is the same journal entry. `apply` said only that it performed the change and left the recording to its javadoc, so the pair is now `addEdit` / `applyEdit`: one verb apart, same noun, and the javadoc's first sentence states the recording outright. `CompoundEdit` carries the same pair, so code inside a recording block reads like code outside one. The empty-step guard the two share is one `isEmptyStep` helper rather than the same condition written twice with two different comments, so each method now reads as the same three-branch dispatch: hand to the enclosing recorder, skip an empty step, or lock and push. Renames only; no behaviour change, and the compiler enumerated all 28 call sites. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194MmVeMweN5EKQEx5yNoC1
`AutomaticFieldEditorUndoableEdit` existed to add one `int` to `CompoundEdit`, and `CompoundEdit` gave up `final` to allow it. The subtype added no behaviour: the count is what the "Automatic field editor" notification tells the user, and nothing in the undo model ever read it — a value type carrying a field for the benefit of a dialog. The count now travels as a parameter, `addEdit(CompoundEdit, int)`, to the one method that consumed it. Every call site already had it in a local variable, so the change removes a setter call rather than adding an argument to compute. `AutomaticFieldEditorUndoableEdit` is deleted and `CompoundEdit` is `final` again, leaving the change model with no inheritance at all. Recorded in the plan as the P11 follow-up, and promoted from "no behavioural gain" by the SOLID review: it is the only place where inheritance was standing in for composition in this area. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194MmVeMweN5EKQEx5yNoC1
Roughly 120 classes hold an undo handle, and almost all of them do one thing with it: edit the library and hand the change over. Undoing, redoing, asking whether the library differs from the last saved position and subscribing to stack changes are the business of five classes. Passing the whole manager to the rest handed every field editor, cleanup and import task the ability to rewrite the user's history, when all any of them does is describe what it just changed. `UndoManager` is now the recording interface — `addEdit(BibChange)`, `addEdit(String, Consumer)`, `applyEdit(BibChange)` — and `JabRefUndoManager` is the implementation, following the shape JabRef already uses for DialogService / JabRefDialogService. The class header argued against a separate recording type on the grounds that it "would mean threading a second handle everywhere the first one already goes"; that holds against a second object and not against an interface on the same one, and the header now says so. Keeping the interface under the old name is what makes this small. The ~118 recording clients keep the type name and the handle name they already had, so the diff is 21 files rather than 133, and afterburner keeps resolving injection sites because the registered key still matches the declared type. `JabRefGUI` registers the one instance under both keys, for the few classes that ask for the implementation. Only five classes call anything beyond recording: GuiUndoManager, UndoAction, RedoAction, EditAction and LibraryTab (SaveDatabaseAction reaches markUnchanged through LibraryTab's getter). Sixteen further classes hold the implementation and call nothing on it at all — they carry it to reach one of those five, which is P12's problem, not an interface gap: what they would need is undo/redo/canUndo/hasChanged, which is the whole class again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194MmVeMweN5EKQEx5yNoC1
P16 left the implementation type threaded through the GUI: twenty
classes named `JabRefUndoManager`, and only five of them called
anything on it. The other fifteen carried it to reach one of those
five, so narrowing them meant narrowing what they carried it *to*.
The layering JabRef already uses for preferences does that in one
move:
CliPreferences <- JabRefCliPreferences
GuiPreferences extends CliPreferences
JabRefGuiPreferences extends JabRefCliPreferences implements GuiPreferences
UndoManager <- JabRefUndoManager
GuiUndoManager extends UndoManager
JabRefGuiUndoManager extends JabRefUndoManager implements GuiUndoManager
`GuiUndoManager` is now an interface: the recording half it inherits,
the stack controls the undo UI needs, and the two JavaFX properties the
menus bind to. It declares the controls rather than inheriting them
because an interface cannot inherit from a class; `JabRefUndoManager`
already implements every one, and `JabRefGuiUndoManager` brings the two
together.
No class type is threaded anywhere now. In jabgui, `JabRefUndoManager`
appears only in that extends clause, and `JabRefGuiUndoManager` only in
`JabRefGUI`, which creates the single instance and registers it under
both interfaces — as `Launcher` registers `JabRefGuiPreferences` under
`GuiPreferences.class`.
Extending rather than wrapping also removes the unwrap: `UndoAction`
and `RedoAction` already received the facade and did
`guiUndoManager.getUndoManager().undo()`. That accessor is gone, and
with it the second object the application never wanted — `JabRefFrame`
no longer builds a facade around the manager it was passed, and
`MainMenu` and `MainToolBar` take one handle where they took two.
This is inheritance for a layer specialization, which is why it does
not contradict the P11 follow-up two commits ago: that subclass hung a
dialog's counter on a value type and the owner never read it, while
this adds the GUI's view of a service, the shape this codebase already
uses for preferences and dialogs. The cost is that `JabRefUndoManager`
is now an extension point: it stays non-final, the subclass shares its
monitor, and the refresh runs from a listener the subclass registers on
itself, after the monitor is released.
Three tests that had to mock the class can now mock an interface.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194MmVeMweN5EKQEx5yNoC1
`CompoundEdit` takes any string and `toChangeSet()` hands it on as `ChangeSet.name`, which that record's javadoc calls "the only user-facing text in the change model". Nothing converts, validates or localizes it in between, so the promotion from developer label to user text happens silently at that one call. Eight of the twenty-six step names had never been written for a reader: CHANGE_SELECTED_FIELD, APPEND_TO_SELECTED_FIELD, CLEAR_SELECTED_FIELD, COPY_FIELD_VALUE, MOVE_EDIT, SWAP_FIELD_VALUES, RENAME_EDIT, and EDIT_FIELDS — the last published as `NAMED_COMPOUND_EDITS`, a leftover of the `NamedCompoundEdit` class workstream A deleted. Each now carries the label of the control the user actually activated: Set, Append, Clear field content, Copy content, Move content, Swap content, Rename field, and the dialog's own title for the step that spans all of them. Every one of those was already a translated key, so no key is added and none retired. Four more names were hand-copies of a StandardActions label — three "Merge entries" and one "Automatically set file links" — and now come from the action, which is where the text the user just clicked already lives and where it will stay correct when the menu wording changes. Nothing renders these yet: the sole reader is the warning ChangeSet logs when part of a set fails to apply. P5 is what will show them, and this is its prerequisite — a name written for a log is a name written for a person, or P5 ships "Could not fully apply CHANGE_SELECTED_FIELD". The javadoc on both types now states that rule instead of leaving it to the field's type. `AutomaticFieldEditorViewModel#cancelChanges` is documented rather than changed: it reverts what the tabs already wrote and records nothing, which is correct because nothing reaches the stack until OK — but it is the one deliberate write outside the journal and read like the defect `applyEdit` exists to prevent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194MmVeMweN5EKQEx5yNoC1
Three step names described the command the user had just invoked, in
wording that differed from the command's own label:
"Autogenerate citation keys" -> GENERATE_CITE_KEYS "Generate citation keys"
"Update keywords" -> MANAGE_KEYWORDS "Manage keywords"
"Replace string" -> REPLACE_ALL "Find and replace"
Each now takes its text from the action, so the undo step says what the
menu item said, and stays right when the menu wording changes. That
also settles the casing drift, which was the visible symptom: "Replace
string" beside a dialog titled "Replace String" beside a menu entry
reading "Find and replace", for one operation.
A fourth, "duplicate removal", is deliberately left alone, because the
rule is not "use the action label" but "name what will be reversed".
Find duplicates only searches; the removals and merges come from the
user's decisions about each pair afterwards, so the action's label
describes something no undo can take back. A comment at the call site
says so, since the substitution looks obvious until you read the step.
"Update keywords" existed only as a step name, so its key is removed
from the English properties file — the same handling A3 gave the 19
presentation-name keys it retired, with the translations left to
Crowdin. The other keys stay: KeyBinding still uses them.
Nothing rendered these names before this commit and nothing renders
them after it, so no user-visible text changes today. What changes is
that P5 can surface any of them without first having to ask whether
this particular one was written for a person.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194MmVeMweN5EKQEx5yNoC1
Undo and Redo in `EditorContextAction` act on the text control's own stack. That is correct for the search field, whose text is not a value of the library, and wrong for a field editor, where `Ctrl+Z` is routed to the library's journal by the filter in `FieldEditorFX` (#11420): a menu Undo would revert the control's text, the text listener would see that as a fresh edit, and the undo the user asked for would land on the stack as a new forward change. `getDefaultContextMenuItems` has left both items out since 2019 (d1307a8), so the hazard is already avoided — but the avoidance rested on a parenthesis reading "(except undo/redo)", with nothing to tell the next contributor why the omission is deliberate or what to do instead. Both javadocs now say it. No behaviour change; the two items are still built for the search field, which is the one control they suit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194MmVeMweN5EKQEx5yNoC1
Undo covers the bibliography, not the file system (decision D7). Two operations left the user to discover that for themselves. Deleting a linked file removes it from disk and journals the entry's `file` field, so undo brings the link back and points it at a file that is no longer there. Renaming one is worse in a quieter way: the rename happens on disk, only the link is journalled, so undoing restores the old link — a name no file has any more. Both already ask before acting, so both now say so at the point where the user decides, rather than notifying afterwards about something already done. Not covered, deliberately: renaming to the suggested name, moving to a directory, downloading, and the file cleanups have no decision point to attach a sentence to, and a toast on every such operation is noise that teaches people to dismiss toasts. The cleanup that renames PDFs already asks its own "does not support undo" question, which is the pattern to follow if the others ever grow one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194MmVeMweN5EKQEx5yNoC1
One sentence each, under twenty words, starting with "We fixed" and "We changed", with no internals named. The links were pointing at #16627, the already-merged predecessor of this branch; they are TODO placeholders until this pull request has a number, per AGENTS.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194MmVeMweN5EKQEx5yNoC1
`Deque.peek()` answers "empty" with `null`, so reading the top of a stack meant a null check in three places. `isEmpty()` plus `getFirst()` says the same thing without one, and keeps the order that undo needs: the inverse is applied before the entry moves across, so a change that throws stays undoable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194MmVeMweN5EKQEx5yNoC1
The logger still named `UndoManager`, which is now the interface, so every message from the journal was tagged with a type that has no implementation of its own. Caught by `rewriteDryRun`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194MmVeMweN5EKQEx5yNoC1
`MainTableColumnModel` asked the injector for an undo journal in its constructor, although only `getDisplayName()` needs one, and only for a special field's label. Preference migrations run in `Launcher` before `JabRefGUI` registers anything, and they build column models. While `UndoManager` was a class, afterburner quietly reflected a throwaway instance into existence; now that it is an interface, the same call throws "Cannot instantiate view" and takes the startup down with it — for the users whose stored preferences still need that migration. Moving the lookup to the point of use fixes the crash and removes a dependency a column-preference value object never had a reason to hold. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194MmVeMweN5EKQEx5yNoC1
PR Summary by QodoMake undo history identity-safe and separate recording from GUI control
AI Description
Diagram
High-Level Assessment
Files changed (70)
|
Code Review by Qodo
1. refresh() updates are uncoalesced
|
The checklist asks for a `docs/requirements/` entry when a change is a significant bug fix, and both halves of this branch qualify: one decides whether a library can be closed while holding unsaved work, the other whether the recorded history can describe a state the library never had. `docs/requirements/undo.md` states them as behaviour rather than mechanism — a library is unmodified exactly when its history stands at the position it was saved at, and a change performed through the journal reaches the library and the stack as one operation. The journal and the tests that pin them carry the OpenFastTrace links, so `traceRequirements` reports both covered; removing either link makes it report the requirement uncovered, which is how the coverage was verified rather than assumed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194MmVeMweN5EKQEx5yNoC1
`ReplaceStringViewModelTest` needs something typed as `GuiUndoManager`, because that is what `LibraryTab#getUndoManager` returns, and it needs a real journal, because it asserts what undo does. It was given `JabRefGuiUndoManager`, which refreshes its properties through the JavaFX thread on every push. The test passes either way — the journal catches what a listener throws — so what this actually produced was thirteen swallowed `IllegalStateException: Toolkit not initialized`, with stack traces, in the log of a test that has nothing to do with JavaFX. It also left the test resting on that catch: the day a listener failure stops being swallowed, an unrelated test would start failing. `HeadlessGuiUndoManager` is the journal with properties that follow the stacks directly, without the hop — a JavaFX property needs no toolkit, only `Platform.runLater` does. So a test reading them sees the truth rather than a constant `false`, and the test now asserts that. What the double skips is the marshalling, which is what makes it unfit for anything but a single-threaded test, and which `JabRefGuiUndoManagerTest` covers by starting the toolkit on purpose. Regression from this branch: before the GUI layering, the test used a plain journal, which had no JavaFX in it at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194MmVeMweN5EKQEx5yNoC1
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194MmVeMweN5EKQEx5yNoC1
There was a problem hiding this comment.
Pull request overview
Fixes undo-history correctness and separates recording, core journal, and JavaFX-facing APIs.
Changes:
- Identifies saved history positions instead of counting edits.
- Adds atomic apply-and-record operations and layered undo interfaces.
- Improves undo labels and file-operation warnings.
Reviewed changes
Copilot reviewed 74 out of 74 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
CHANGELOG.md |
Documents user-visible fixes. |
docs/decisions/0070-split-the-undo-manager-into-recording-and-gui-layers.md |
Records the undo architecture. |
docs/requirements/undo.md |
Defines undo correctness requirements. |
jablib/src/main/java/org/jabref/logic/undo/JabRefUndoManager.java |
Implements the core journal. |
jablib/src/main/java/org/jabref/logic/undo/UndoManager.java |
Defines the recording interface. |
jablib/src/main/java/org/jabref/model/undo/ChangeSet.java |
Clarifies change-set naming. |
jablib/src/main/java/org/jabref/model/undo/CompoundEdit.java |
Renames and documents edit application. |
jablib/src/main/resources/l10n/JabRef_en.properties |
Adds undo warnings. |
jabgui/src/main/java/org/jabref/gui/JabRefGUI.java |
Registers the layered manager. |
jabgui/src/main/java/org/jabref/gui/LibraryTab.java |
Uses the GUI manager API. |
jabgui/src/main/java/org/jabref/gui/citationkeypattern/GenerateCitationKeyAction.java |
Uses action-based undo naming. |
jabgui/src/main/java/org/jabref/gui/collab/entryadd/EntryAdd.java |
Adopts applyEdit. |
jabgui/src/main/java/org/jabref/gui/collab/entrychange/EntryChange.java |
Adopts applyEdit. |
jabgui/src/main/java/org/jabref/gui/collab/entrydelete/EntryDelete.java |
Adopts applyEdit. |
jabgui/src/main/java/org/jabref/gui/collab/preamblechange/PreambleChange.java |
Adopts applyEdit. |
jabgui/src/main/java/org/jabref/gui/collab/stringadd/BibTexStringAdd.java |
Adopts applyEdit. |
jabgui/src/main/java/org/jabref/gui/collab/stringchange/BibTexStringChange.java |
Adopts applyEdit. |
jabgui/src/main/java/org/jabref/gui/collab/stringdelete/BibTexStringDelete.java |
Adopts applyEdit. |
jabgui/src/main/java/org/jabref/gui/collab/stringrename/BibTexStringRename.java |
Adopts applyEdit. |
jabgui/src/main/java/org/jabref/gui/duplicationFinder/DuplicateSearch.java |
Updates grouped edit application. |
jabgui/src/main/java/org/jabref/gui/edit/EditAction.java |
Accepts the GUI manager API. |
jabgui/src/main/java/org/jabref/gui/edit/ManageKeywordsViewModel.java |
Uses user-facing undo names. |
jabgui/src/main/java/org/jabref/gui/edit/ReplaceStringViewModel.java |
Uses the invoked action name. |
jabgui/src/main/java/org/jabref/gui/edit/automaticfieldeditor/AbstractAutomaticFieldEditorTabViewModel.java |
Separates counts from edits. |
jabgui/src/main/java/org/jabref/gui/edit/automaticfieldeditor/AutomaticFieldEditorUndoableEdit.java |
Removes the obsolete subclass. |
jabgui/src/main/java/org/jabref/gui/edit/automaticfieldeditor/AutomaticFieldEditorViewModel.java |
Groups dialog edits. |
jabgui/src/main/java/org/jabref/gui/edit/automaticfieldeditor/MoveFieldValueAction.java |
Adopts applyEdit. |
jabgui/src/main/java/org/jabref/gui/edit/automaticfieldeditor/clearcontent/ClearContentViewModel.java |
Uses localized edit naming. |
jabgui/src/main/java/org/jabref/gui/edit/automaticfieldeditor/copyormovecontent/CopyOrMoveFieldContentTabViewModel.java |
Updates copy, move, and swap edits. |
jabgui/src/main/java/org/jabref/gui/edit/automaticfieldeditor/editfieldcontent/EditFieldContentViewModel.java |
Updates set and append edits. |
jabgui/src/main/java/org/jabref/gui/edit/automaticfieldeditor/renamefield/RenameFieldViewModel.java |
Updates rename edits. |
jabgui/src/main/java/org/jabref/gui/entryeditor/SourceTab.java |
Adopts applyEdit. |
jabgui/src/main/java/org/jabref/gui/entryeditor/citationrelationtab/CitationRelationsTab.java |
Updates merge recording. |
jabgui/src/main/java/org/jabref/gui/externalfiles/AutoLinkFilesAction.java |
Uses action-based undo naming. |
jabgui/src/main/java/org/jabref/gui/fieldeditors/AbstractEditorViewModel.java |
Applies field edits atomically. |
jabgui/src/main/java/org/jabref/gui/fieldeditors/LinkedFileViewModel.java |
Warns about rename undo limits. |
jabgui/src/main/java/org/jabref/gui/fieldeditors/contextmenu/EditorContextAction.java |
Documents text-control undo behavior. |
jabgui/src/main/java/org/jabref/gui/frame/JabRefFrame.java |
Uses one GUI undo manager. |
jabgui/src/main/java/org/jabref/gui/frame/MainMenu.java |
Consolidates undo dependencies. |
jabgui/src/main/java/org/jabref/gui/frame/MainToolBar.java |
Consolidates undo dependencies. |
jabgui/src/main/java/org/jabref/gui/importer/actions/OpenDatabaseAction.java |
Accepts the GUI manager. |
jabgui/src/main/java/org/jabref/gui/libraryproperties/preamble/PreamblePropertiesViewModel.java |
Applies preamble edits atomically. |
jabgui/src/main/java/org/jabref/gui/linkedfile/DeleteFileAction.java |
Warns about disk deletion. |
jabgui/src/main/java/org/jabref/gui/maintable/MainTable.java |
Uses the GUI manager type. |
jabgui/src/main/java/org/jabref/gui/maintable/MainTableColumnModel.java |
Defers undo-manager lookup. |
jabgui/src/main/java/org/jabref/gui/maintable/RightClickMenu.java |
Accepts the GUI manager. |
jabgui/src/main/java/org/jabref/gui/maintable/columns/ContentSelectorColumn.java |
Adopts applyEdit. |
jabgui/src/main/java/org/jabref/gui/mergeentries/BatchEntryMergeTask.java |
Uses action-based naming. |
jabgui/src/main/java/org/jabref/gui/mergeentries/threewaymerge/MergeTwoEntriesAction.java |
Updates merge recording. |
jabgui/src/main/java/org/jabref/gui/openoffice/OpenOfficePanel.java |
Uses the GUI manager type. |
jabgui/src/main/java/org/jabref/gui/shared/SharedDatabaseLoginDialogView.java |
Injects the GUI manager. |
jabgui/src/main/java/org/jabref/gui/shared/SharedDatabaseLoginDialogViewModel.java |
Uses the GUI manager type. |
jabgui/src/main/java/org/jabref/gui/shared/SharedDatabaseUIManager.java |
Uses the GUI manager type. |
jabgui/src/main/java/org/jabref/gui/sidepane/SidePane.java |
Uses the GUI manager type. |
jabgui/src/main/java/org/jabref/gui/sidepane/SidePaneContentFactory.java |
Propagates the GUI manager. |
jabgui/src/main/java/org/jabref/gui/sidepane/SidePaneViewModel.java |
Accepts the GUI manager. |
jabgui/src/main/java/org/jabref/gui/undo/GuiUndoManager.java |
Defines the JavaFX-facing interface. |
jabgui/src/main/java/org/jabref/gui/undo/JabRefGuiUndoManager.java |
Implements JavaFX properties. |
jabgui/src/main/java/org/jabref/gui/undo/RedoAction.java |
Uses the unified GUI manager. |
jabgui/src/main/java/org/jabref/gui/undo/UndoAction.java |
Uses the unified GUI manager. |
jabgui/src/main/java/org/jabref/gui/walkthrough/declarative/sideeffect/OpenLibrarySideEffect.java |
Resolves the GUI manager. |
jabgui/src/main/java/org/jabref/gui/welcome/WelcomeTab.java |
Propagates the GUI manager. |
jabgui/src/test/java/org/jabref/gui/edit/ManageKeywordsViewModelTest.java |
Updates manager construction. |
jabgui/src/test/java/org/jabref/gui/edit/ReplaceStringViewModelTest.java |
Uses the headless GUI manager. |
jabgui/src/test/java/org/jabref/gui/entryeditor/SourceTabTest.java |
Updates manager construction. |
jabgui/src/test/java/org/jabref/gui/exporter/SaveDatabaseActionTest.java |
Updates manager mocking. |
jabgui/src/test/java/org/jabref/gui/fieldeditors/JournalEditorViewModelTest.java |
Updates manager construction. |
jabgui/src/test/java/org/jabref/gui/fieldeditors/optioneditors/LanguageEditorViewModelTest.java |
Updates manager construction. |
jabgui/src/test/java/org/jabref/gui/importer/actions/OpenDatabaseActionTest.java |
Updates manager mocking. |
jabgui/src/test/java/org/jabref/gui/mergeentries/UpdateOriginalEntryTest.java |
Updates manager construction. |
jabgui/src/test/java/org/jabref/gui/sidepane/SidePaneViewModelTest.java |
Updates manager mocking. |
jabgui/src/test/java/org/jabref/gui/undo/HeadlessGuiUndoManager.java |
Adds a headless test implementation. |
jabgui/src/test/java/org/jabref/gui/undo/JabRefGuiUndoManagerTest.java |
Tests JavaFX property synchronization. |
jabgui/src/test/java/org/jabref/gui/undo/JabRefUndoManagerTest.java |
Tests journal correctness and locking. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| CompoundEdit compound = active.get(); | ||
| if (compound != null) { | ||
| compound.applyEdit(change); |
There was a problem hiding this comment.
Inherited from before. Noted for follow up pr. too broad to fix in this pr.
…'s price Four points from an automated review of this branch. Three describe behaviour that predates it; each is answered where this branch can answer it. A block that dies on an `Error` — a model assertion, a stack overflow on a deep nested set — skipped the handover entirely, because it sat after a `catch (RuntimeException)`. Everything the block had already written to the library became un-undoable, which is precisely what the catch existed to prevent. The handover moves into the `finally`, so whatever ends the block hands over what it collected and the failure travels on untouched. The `failure` variable disappears with it. A test covers the `Error` path. The class javadoc said applying happens inside the lock and that foreign code runs outside it, which cannot both hold for `applyEdit`. The rule it actually imposes is now written down: a change's `apply()` must write to the model and return, never wait for another thread, or it deadlocks against a menu refresh already blocked in `canUndo()`. Every change today is a plain model write; `undo()` has applied under the lock since the first PR of this series. `clear()` has no caller anywhere, and cannot get one while a single journal serves every library — closing one would discard the others' history. Its javadoc says that instead of implying a caller exists. The requirement for the saved position claimed a per-library rule that one shared journal cannot honour: with several libraries open, the saved position of one is the saved position of all. It now records that limit rather than overstating the guarantee. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194MmVeMweN5EKQEx5yNoC1
A trimmed change stays applied to the library, so an empty undo stack does not mean "the state the library started in" — it means "that change, and nothing after it". Handing the empty stack a fresh id treated it as a position nothing could match, which is wrong at exactly one boundary: save a change, push past the limit until that change is trimmed, undo everything still on the stack, and the library is the one that was saved, while `hasChanged()` insisted it was modified for the rest of the session. The empty stack now inherits the id of the change that fell off, and repeated trimming walks that identity forward one dropped change at a time. `clear()` keeps taking a fresh id, because it discards history the library keeps — the two cases look alike and are opposites. The reasoning this corrects was mine, in the first PR of the pair: I read "the saved entry is gone from the stack" as "the saved state is unreachable". The stack entry is gone; the state it denoted is where undoing everything lands. The new test sits between the two existing trim tests, which pin the cases either side of it and still pass unchanged. The requirement text claiming every trimmed saved position is unreachable is corrected too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194MmVeMweN5EKQEx5yNoC1
The javadoc on `applyEdit` said that inside a recording block "there is no window to close", which is not true and was mine. A block applies each change to the library as it goes and pushes the step only when it ends, so the window is open for the whole block. What the thread-local recorder rules out is two threads writing one recorder — not an undo interleaving with a block's writes. The javadoc now says that, and says why this class cannot close it alone: only the command knows when its block ends. P21 in the plan records the interleaving with the field-level walkthrough, the two candidate policies — undo waits, or undo refuses while a command is writing — and the constraint that either must also cover the five manual collectors that mutate on background tasks, or it will look complete while the noisiest paths stay unguarded. No behaviour change: this is the same shape every version of the code has had, the Swing one included. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194MmVeMweN5EKQEx5yNoC1
"...without asking to save unsaved changes" is broader than what shipped: with several libraries open, one journal still holds one saved position for all of them, so a library can still be closed while holding work another library's save marked as saved. The entry now names the case that is fixed — changes made after an undo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194MmVeMweN5EKQEx5yNoC1
Summary
Second PR of the undo/redo series. this one fixes what reviewing that model turned up, and finishes the separation it started.
hasChanged()compared a net edit balance against the balance recorded at save time, so two different histories reached the same number: edit, save, undo, edit again, and the library reported itself unmodified although it held work that was never saved. Since that flag decides whether closing a library asks to save, the library closed silently and the second edit was lost. Positions are now identified rather than counted, so a saved position that history has discarded can never be matched again.applyrenamed in this iteration toapplyEdit. performs the change and records it under one acquisition of the journal's monitor, closing the window in which an undo from another thread reverted the previous change while the new one stayed applied but unrecorded.UndoManagerbecomes the recording interface that the ~118 classes editing the library depend on,JabRefUndoManagerthe implementation, andGuiUndoManager/JabRefGuiUndoManagerthe JavaFX layer above it (cp.CliPreferences/GuiPreferences).No class type is threaded through the GUI any more: what a class asks for now says what it does with the journal.
Undo step names are now written for whoever will read them, taken from the action the user invoked where one exists, instead of string tokens like
CHANGE_SELECTED_FIELD;Two file operations that undo cannot reverse say so where the user decides, rather than leaving a dangling link to be discovered later.
Steps to test
Still open issues for follow-up:
Related issues and pull requests
Follow-up to #16627
AI usage
AIL3-AIL4
"Claude Code (model claude-opus-5-0)"
AI CHECKLIST.md walkthrough
1. Code self-review
Nullability and control flow
== null/!= nullchecks — JSpecify annotations used instead. ThreeDeque.peek()guards were replaced byisEmpty()+getFirst(). Two!= nullchecks onThreadLocal.get()remain inaddEdit/applyEdit; the first is pre-existing and the second mirrors it deliberately, because a thread-local recorder has no annotated absence to express. Flagged rather than hidden.Objects.requireNonNull(...)@NullMarked—UndoManager,GuiUndoManager,JabRefGuiUndoManagerOptionalconsumed withifPresent/map/orElseThrowStringUtil.isBlank(...)where applicable — no new blank checkscatch (Exception e)in added code — the one innotifyListenerspredates this PRthrow new RuntimeException(...)/IllegalStateException(...)addedStyle and idioms
BibEntrywithers — no newBibEntryconstructionList.of, switch patternsPattern— no regexes addedBackgroundTask— no new threading[Type]and backticks, no{@link}/{@code}User-facing text
ChangeSet.nameare gone!, no label colonsTests
logiccovered — five tests added toJabRefUndoManagerTest, four of which fail without the corresponding fix (verified by reverting each)@DisplayName, no swallowed exceptions2. Verification commands
./gradlew :jabgui:test— 1009 tests, 7 failing, all failing identically onmainin this environment (clipboard and TestFX window-focus tests, plus the knownKeyBindingViewModelTest)./gradlew :jablib:test— 11034 tests, 1 failing:RemoteSetupTest.pingReturnsFalseForNoServerListening, which fails because a JabRef instance was running locally./gradlew checkstyleMain checkstyleTest— clean, all modules./gradlew modernizer— clean./gradlew --no-configuration-cache :rewriteDryRun— clean; it caught a logger still named after the renamed type, fixed in its own commit./gradlew javadoc— cleanmarkdownlint— no Markdown changed3. Documentation
CHANGELOG.md— two entries, one sentence each, under twenty words, end-user wordingTODOplaceholders were keptdocs/requirements/— bug fixes and refactorsdocs/— architecture changed (the interface/implementation split); worth a look before merge4. Pull request
gh pr create --body-file— for the author to runTODOplaceholders inCHANGELOG.mdreplaced with the PR number after creationChecklist
CHANGELOG.mddescribing the change from the user's point of view (if the change is visible to the user)