Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
5536229
Fix field editor focus for non-required fields and scroll into view
adeifv Aug 20, 2026
d60efad
Allow jump-to-field to search all fields
adeifv Aug 20, 2026
9a3953a
Add changes to CHANGELOG.md
adeifv Aug 20, 2026
3765a9d
Replace null checks with Optional and instanceof pattern matching
adeifv Aug 20, 2026
50e03b9
Move changes under unreleased section
adeifv Aug 20, 2026
adecf23
Fix runLater guards
adeifv Aug 20, 2026
c24e6ae
Refactor field handling and scrolling logic
adeifv Aug 20, 2026
9291e1a
Simplify comments in runLater logic for clarity
adeifv Aug 20, 2026
a178cfb
Fix dialog not opening after left-click on another entry in the main …
adeifv Aug 22, 2026
9b0b16a
Refactor scrolling logic by extracting to ScrollUtils
adeifv Aug 23, 2026
b4b9d98
Introduce NodeTraversalUtils for improved text input node traversal
adeifv Aug 23, 2026
ec4f3cf
Fix focus fallback for composite field editors
adeifv Aug 23, 2026
d8f42ae
Include citation key and entry fields in jump suggestions
adeifv Aug 23, 2026
e9f1b83
keep setFocusToField read-only and add focusOrAddField for jump path
adeifv Aug 23, 2026
e596873
Add jump-to-field targets under their database-mode canonical name
adeifv Aug 23, 2026
5203352
Parse jump-to-field input with the entry's type
adeifv Aug 23, 2026
917ac38
Open add-file dialog only when adding File via its chip.
adeifv Aug 24, 2026
f60d6c0
Return getAllPossibleTabs to model
adeifv Aug 24, 2026
f89b3b3
Use AllFieldsTab.getDatabaseMode()
adeifv Aug 25, 2026
f727465
Guard jump-to-field dialog against hidden All Fields tab
adeifv Aug 25, 2026
a1d7306
Add comment explaining the double Platform.runLater pulse pattern
adeifv Aug 25, 2026
54a9101
Merge branch 'main' into fix-issue-16593
adeifv Aug 25, 2026
9661493
Fix keyValueShouldBeEqualForEnglishPropertiesMessages
adeifv Aug 25, 2026
45ef1bc
Remove check guard for jump-to-field dialog
adeifv Aug 28, 2026
7f24f8b
Merge branch 'main' into fix-issue-16593
adeifv Aug 28, 2026
785cd75
Refactor field focus handling to include alias field lookup
adeifv Aug 28, 2026
a970eae
Refactor duplicated focus-field logic
adeifv Aug 29, 2026
7def69b
Open the entry editor when jumping to a field
koppor Sep 7, 2026
190841e
Make Enter in the jump-to-field dialog jump on the first press
koppor Sep 7, 2026
3a30381
Tell the user when jumping to a field creates it
koppor Sep 7, 2026
2dff091
Say why a jump does nothing when the Main tab is hidden
koppor Sep 7, 2026
a9dc018
Merge remote-tracking branch 'origin/main' into ctrlj-editor-closed
koppor Sep 7, 2026
8a87864
Apply IntelliJ formatting
koppor Sep 7, 2026
4af0f8b
Fix import order
koppor Sep 7, 2026
3834d79
Explain why the section panes are cleared before a layout pass
koppor Sep 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ Note that this project **does not** adhere to [Semantic Versioning](https://semv
- We fixed an issue where the button shape changed when hovering over it. [#16188](https://github.com/JabRef/jabref/issues/16188)
- We fixed handling of `exit` in the LSP server. [#16268](https://github.com/JabRef/jabref/pull/16268)
- We fixed an issue where `LinkedFile.isOnlineLink()` did not recognize `ftp://` links as online links. [#16400](https://github.com/JabRef/jabref/issues/16400)
- We fixed an issue where the "Jump to field" dialog only searched fields already shown in the entry editor. It now searches all known fields, adds the selected field if it is not yet visible, and creates the field if it does not exist yet. [#16593](https://github.com/JabRef/jabref/issues/16593)
- We fixed an issue where deleting an entry removed the group filter and defaulted to the home view. [#16036](https://github.com/JabRef/jabref/issues/16036)
- We fixed an issue where opening an online link with a query string could open a truncated URL. [#16774](https://github.com/JabRef/jabref/pull/16774)
- We fixed an issue where the main table showed a tooltip with the entry's full title even when the title was already fully visible; the tooltip now appears only when the title is truncated. [#16607](https://github.com/JabRef/jabref/issues/16607)
Expand Down
75 changes: 43 additions & 32 deletions jabgui/src/main/java/org/jabref/gui/entryeditor/AllFieldsTab.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
import javafx.scene.control.Label;
import javafx.scene.control.TextArea;
import javafx.scene.control.TextField;
import javafx.scene.control.TextInputControl;
import javafx.scene.control.TitledPane;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.ColumnConstraints;
Expand All @@ -52,6 +51,7 @@
import org.jabref.gui.undo.RedoAction;
import org.jabref.gui.undo.UndoAction;
import org.jabref.gui.util.FieldsUtil;
import org.jabref.gui.util.NodeTraversalUtils;
import org.jabref.logic.journals.JournalAbbreviationRepository;
import org.jabref.logic.l10n.Localization;
import org.jabref.logic.util.BackgroundTask;
Expand Down Expand Up @@ -122,6 +122,11 @@ public class AllFieldsTab extends FieldsEditorTab {
private final Map<FieldListSections.SectionType, Boolean> sectionExpandOverrides =
new EnumMap<>(FieldListSections.SectionType.class);

/// Tracks the [TitledPane] created for each section type, so we can expand a collapsed
/// section on demand (e.g. when jump-to-field targets a field inside it).
private final Map<FieldListSections.SectionType, TitledPane> sectionPanes =
new EnumMap<>(FieldListSections.SectionType.class);

/// Sticky per tab instance: whether the secondary-optional chips are expanded.
private boolean showSecondaryOptionalChips;

Expand Down Expand Up @@ -301,13 +306,24 @@ private void refreshShownFieldsIfNeeded(FieldChangedEvent event) {
}
}

@Override
public void requestFocus(Field fieldName) {
Optional.ofNullable(sectionPanes.get(FieldListSections.sectionOf(fieldName)))
.filter(pane -> !pane.isExpanded())
.ifPresent(pane -> pane.setExpanded(true));
super.requestFocus(fieldName);
}

/// Main fields as a grid with natural row heights, then the optional-field chip bar,
/// then the always-present collapsible sections (identifiers / files & links /
/// bibliometrics / comments / meta, collapsed when empty) each with its own add-chips,
/// then the free-form add row. The whole column scrolls instead of stretching to the
/// tab height.
@Override
protected void layoutEditors(BibDatabaseContext bibDatabaseContext, BibEntry entry, boolean compressed, List<Label> labels) {
// Every layout pass builds fresh TitledPanes, so the ones tracked from the previous pass are
// detached from the scene graph: expanding one (requestFocus) would do nothing visible.
sectionPanes.clear();
// labels were created in editors-map iteration order (see FieldsEditorTab#setupPanel)
Map<Field, Label> labelForField = new LinkedHashMap<>();
int labelIndex = 0;
Expand Down Expand Up @@ -409,7 +425,7 @@ private Node wrapWithRemoveButton(BibDatabaseContext bibDatabaseContext, BibEntr
/// preserving its HBox grow priority) and overlays `button` on it. Returns the new overlay
/// pane, or empty if the editor exposes no plain text input to overlay onto.
private static Optional<StackPane> overlayInsideTextInput(Node editorNode, Button button) {
return findPrimaryTextInput(editorNode).flatMap(input -> {
return NodeTraversalUtils.findFirstTextInput(editorNode).flatMap(input -> {
if (!(input.getParent() instanceof Pane parent)) {
return Optional.empty();
}
Expand All @@ -429,23 +445,6 @@ private static Optional<StackPane> overlayInsideTextInput(Node editorNode, Butto
});
}

/// First [TextInputControl] in the editor node's subtree (the row-filling text field/area),
/// or empty for composite editors that have none.
private static Optional<TextInputControl> findPrimaryTextInput(Node node) {
if (node instanceof TextInputControl textInput) {
return Optional.of(textInput);
}
if (node instanceof Parent parent) {
for (Node child : parent.getChildrenUnmodifiable()) {
Optional<TextInputControl> found = findPrimaryTextInput(child);
if (found.isPresent()) {
return found;
}
}
}
return Optional.empty();
}

/// Hides a still-empty, user-added field row again (reachable only for non-required,
/// currently blank fields; see [#wrapWithRemoveButton]).
private void removeFieldRow(BibDatabaseContext bibDatabaseContext, BibEntry entry, Field field) {
Expand Down Expand Up @@ -491,6 +490,7 @@ private TitledPane createSectionPane(FieldListSections.SectionType type,
if (pane.isExpanded()) {
populateContent.run();
}
sectionPanes.put(type, pane);
return pane;
}

Expand Down Expand Up @@ -615,31 +615,42 @@ private void addFreeFormField(BibDatabaseContext bibDatabaseContext, BibEntry en
/// field if necessary) and focuses it.
// [impl->req~entry-editor.main-tab.add-chips~1]
private void showFieldEditor(BibDatabaseContext bibDatabaseContext, BibEntry entry, Field field) {
showFieldEditor(bibDatabaseContext, entry, field, true);
}

private void showFieldEditor(BibDatabaseContext bibDatabaseContext, BibEntry entry, Field field, boolean openAddFileDialogForFile) {
userAddedFields.add(field);
rebuildPanel(bibDatabaseContext, entry);
// The outer runLater lets one pulse pass so the editors rebuilt become focusable
// before the inner runLater requests focus on them.
Platform.runLater(() -> {
// The tab may have been rebound to a different entry before this deferred block runs;
// the editors map would then belong to that other entry, so focusing/adding here would
// act on the wrong entry. Bail out unless we are still showing the entry we started with.
if (getCurrentEntry() != entry) {
return;
}
requestFocus(field);
// Adding the File field via its "+" chip should immediately open the add-file dialog,
// since an empty File editor has no other purpose than to receive a file.
if ((StandardField.FILE == field) && (editors.get(field) instanceof LinkedFilesEditor linkedFilesEditor)) {
linkedFilesEditor.addNewFile();
}
Platform.runLater(() -> {
Comment thread
adeifv marked this conversation as resolved.
if (getCurrentEntry() != entry) {
return;
}
requestFocus(field);
// Adding the File field via its "+" chip should immediately open the add-file dialog,
// since an empty File editor has no other purpose than to receive a file.
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
// navigation must not pop up a modal dialog as a side effect.
if (openAddFileDialogForFile && (StandardField.FILE == field) && (editors.get(field) instanceof LinkedFilesEditor linkedFilesEditor)) {
linkedFilesEditor.addNewFile();
}
});
});
}

public void addFieldAndFocus(Field field) {
Comment thread
adeifv marked this conversation as resolved.
Optional.ofNullable(getCurrentEntry())
.ifPresent(entry -> showFieldEditor(activeDatabaseContext(), entry, field, false));
}

private void rebuildPanel(BibDatabaseContext bibDatabaseContext, BibEntry entry) {
setupPanel(bibDatabaseContext, entry, false);
}

// endregion

private BibDatabaseMode getDatabaseMode() {
BibDatabaseMode getDatabaseMode() {
return stateManager.getActiveDatabase()
.map(BibDatabaseContext::getMode)
.orElse(BibDatabaseMode.BIBLATEX);
Expand Down
19 changes: 8 additions & 11 deletions jabgui/src/main/java/org/jabref/gui/entryeditor/EntryEditor.java
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ public EntryEditor(Supplier<LibraryTab> tabSupplier, UndoAction undoAction, Redo
typeLabel.textProperty().bind(viewModel.typeLabelTextProperty());
Bindings.bindContent(tabbed.getTabs(), viewModel.visibleTabs());

this.focusUtils = new EntryEditorFocusUtils(tabbed, this);
this.focusUtils = new EntryEditorFocusUtils(tabbed, this, dialogService);

setupKeyBindings();
setupDragAndDrop();
Expand Down Expand Up @@ -250,10 +250,6 @@ private void setupKeyBindings() {
tabSupplier.get().selectPreviousEntry();
event.consume();
}
case JUMP_TO_FIELD -> {
openJumpToFieldDialog();
event.consume();
}
case HELP -> {
new HelpAction(HelpFile.ENTRY_EDITOR, dialogService, preferences.getExternalApplicationsPreferences()).execute();
event.consume();
Expand Down Expand Up @@ -294,7 +290,9 @@ private void jumpToFieldButton() {
openJumpToFieldDialog();
}

private void openJumpToFieldDialog() {
/// Shows the jump-to-field dialog for the currently edited entry.
/// Handled globally in [org.jabref.gui.frame.JabRefFrame] so that it works regardless of where the keyboard focus lies.
public void openJumpToFieldDialog() {
if (jumpToFieldDialog != null && jumpToFieldDialog.isShowing()) {
BaseDialog.bringToFront(jumpToFieldDialog);
return;
Expand Down Expand Up @@ -327,10 +325,6 @@ private void navigateToNextEntry() {
return viewModel.getCurrentlyEditedEntry();
}

public List<EntryEditorTab> getAllPossibleTabs() {
return viewModel.getAllPossibleTabs();
}

private void onEntryChanged(@NonNull BibEntry entry) {
// Tabs observe viewModel.currentlyEditedEntryProperty() directly (bound in rebuildTabs), so no fan-out here.
// Type changes are handled by onEntryTypeChanged via the view model's single type subscription.
Expand Down Expand Up @@ -395,7 +389,10 @@ private void setupToolBar() {
}

public void selectField(String fieldName) {
focusUtils.setFocusToField(FieldFactory.parseField(fieldName));
Optional.ofNullable(viewModel.getCurrentlyEditedEntry())
.ifPresent(entry -> {
focusUtils.focusOrAddField(FieldFactory.parseField(entry.getType(), fieldName));
});
}

public void setFocusToField(Field field) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.jabref.gui.entryeditor;

import java.util.Collection;
import java.util.Map;
import java.util.Optional;

import javafx.application.Platform;
Expand All @@ -13,9 +14,13 @@
import javafx.scene.input.KeyEvent;

import org.jabref.gui.util.UiTaskExecutor;
import org.jabref.logic.l10n.Localization;
import org.jabref.logic.util.NotificationService;
import org.jabref.model.database.BibDatabaseMode;
import org.jabref.model.entry.EntryConverter;
import org.jabref.model.entry.field.Field;
import org.jabref.model.entry.field.FieldFactory;
import org.jabref.model.entry.field.FieldTextMapper;

import org.jspecify.annotations.Nullable;

Expand All @@ -27,12 +32,14 @@ class EntryEditorFocusUtils {

private final TabPane tabPane;
private final Node sceneSource;
private final NotificationService notificationService;

private @Nullable Field lastFocusedField;

EntryEditorFocusUtils(TabPane tabPane, Node sceneSource) {
EntryEditorFocusUtils(TabPane tabPane, Node sceneSource, NotificationService notificationService) {
this.tabPane = tabPane;
this.sceneSource = sceneSource;
this.notificationService = notificationService;
}

// region — field focus capture / restore
Expand Down Expand Up @@ -68,15 +75,34 @@ void restoreLastFocusedField() {
// region — jump to field

void setFocusToField(Field field) {
focusField(field, () -> {
});
}

void focusOrAddField(Field field) {
Comment thread
adeifv marked this conversation as resolved.
Comment thread
adeifv marked this conversation as resolved.
focusField(field, () -> addFieldViaAllFieldsTab(field));
}

private void focusField(Field field, Runnable onNotFound) {
UiTaskExecutor.runInJavaFXThread(() -> getTabContainingField(field).ifPresentOrElse(
tab -> selectTabAndField(tab, field),
() -> {
Field aliasField = EntryConverter.FIELD_ALIASES.get(field);
getTabContainingField(aliasField).ifPresent(tab -> selectTabAndField(tab, aliasField));
getTabContainingField(aliasField).ifPresentOrElse(
tab -> selectTabAndField(tab, aliasField),
onNotFound
);
}
));
}

private Field canonicalFieldForActiveMode(Field field, BibDatabaseMode mode) {
Comment thread
adeifv marked this conversation as resolved.
Map<Field, Field> aliasesToCanonical = mode == BibDatabaseMode.BIBTEX
? EntryConverter.FIELD_ALIASES_BIBLATEX_TO_BIBTEX
: EntryConverter.FIELD_ALIASES_BIBTEX_TO_BIBLATEX;
return aliasesToCanonical.getOrDefault(field, field);
}

private Optional<FieldsEditorTab> getTabContainingField(Field field) {
return tabPane.getTabs().stream()
.filter(FieldsEditorTab.class::isInstance)
Expand All @@ -85,6 +111,24 @@ private Optional<FieldsEditorTab> getTabContainingField(Field field) {
.findFirst();
}

private void addFieldViaAllFieldsTab(Field field) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the main tab is hidden in preferences, findFirst is empty and ctrl+j silently does nothing

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now it checks whether the All Fields tab is present before opening the dialog and shows a notification if it's disabled. what do u think?
Screenshot from 2026-08-25 15-04-26

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's the easy way out. Good enough for now, because it would be really good to have this PR in main, but please write this down as an issue, which then can be fixed in a follow up. Maybe add a junit test, so if we ever were to remove the main tab completely, this will not regress.

@adeifv adeifv Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed this upon comment

After removing the check, when the Main tab is hidden, ctrl+j still opens and correctly jumps to any visible field. It does nothing when the target field isn't shown anywhere.
And without the Main tab, there's nowhere to add a field into.

I think silently doing nothing is a safe behavior, but it's not user-friendly, as the user gets no feedback about why the field wasn't jumped to.

Maybe a good solution would be to tell the user something like "this field can't be jumped to because its tab isn't visible" That needs some thought around, so I'd prefer to handle it in a follow-up PR and keep this one mergeable.

What do you think?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Generated with Claude Code

Correct, and it got worse in the meantime: the guard that used to bail out on unknown field names is gone (custom names are created now), so this findFirst was the last silent no-op left.

Fixed in 2dff091ifPresentOrElse now notifies instead of dropping the jump: Cannot show "Volume" because the "Main" tab is hidden. No other tab can show a field it was not configured for, so there is nowhere to fall back to.

Verified with showAllFieldsTab=false in an isolated prefs profile: the toast appears, and with the tab visible the jump behaves as before.

tabPane.getTabs().stream()
.filter(AllFieldsTab.class::isInstance)
.map(AllFieldsTab.class::cast)
.findFirst()
.ifPresentOrElse(allFieldsTab -> {
BibDatabaseMode mode = allFieldsTab.getDatabaseMode();
// Custom field names are added as they are typed, like the tab's free-form add row does.
Field canonicalField = canonicalFieldForActiveMode(field, mode);
tabPane.getSelectionModel().select(allFieldsTab);
allFieldsTab.addFieldAndFocus(canonicalField);
},
// No other tab can show a field it was not configured for, so say why nothing happens
// instead of swallowing the jump.
() -> notificationService.notify(Localization.lang("Cannot show \"%0\" because the \"%1\" tab is hidden",
FieldTextMapper.getDisplayName(field), EntryEditorTabModel.BuiltIn.ALL_FIELDS.displayName())));
}

private void selectTabAndField(FieldsEditorTab tab, Field field) {
Platform.runLater(() -> {
tabPane.getSelectionModel().select(tab);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
package org.jabref.gui.entryeditor;

import java.util.Locale;

import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.fxml.FXML;
import javafx.scene.control.Button;
import javafx.scene.control.ButtonType;
import javafx.scene.control.Label;
import javafx.scene.control.TextField;

import org.jabref.gui.util.BaseDialog;
import org.jabref.logic.l10n.Localization;

import com.airhacks.afterburner.views.ViewLoader;
import org.controlsfx.control.textfield.AutoCompletionBinding;
import org.controlsfx.control.textfield.TextFields;

public class JumpToFieldDialog extends BaseDialog<Void> {
@FXML private TextField searchField;
@FXML private Label newFieldHint;
private final EntryEditor entryEditor;
private JumpToFieldViewModel viewModel;

Expand All @@ -29,7 +35,9 @@ public JumpToFieldDialog(EntryEditor entryEditor) {

this.setResultConverter(button -> {
if (button == ButtonType.OK) {
jumpToSelectedField();
// Closing the dialog restores focus to whatever had it before, which would undo the
// focus the jump puts on the field. Therefore jump only once the dialog is gone.
Platform.runLater(this::jumpToSelectedField);
}
return null;
});
Expand All @@ -41,17 +49,36 @@ public JumpToFieldDialog(EntryEditor entryEditor) {
private void initialize() {
viewModel = new JumpToFieldViewModel(this.entryEditor);
searchField.textProperty().bindBidirectional(viewModel.searchTextProperty());
TextFields.bindAutoCompletion(searchField, viewModel.getFieldNames());

// Prefix matching instead of ControlsFX' default substring matching: the popup always preselects
// its first suggestion, so "file" would offer (and jump to) "dayfiled" first.
AutoCompletionBinding<String> autoCompletion = TextFields.bindAutoCompletion(searchField, request -> {
String userText = request.getUserText().toLowerCase(Locale.ROOT);
return viewModel.getFieldNames().stream()
.filter(fieldName -> fieldName.toLowerCase(Locale.ROOT).startsWith(userText))
.toList();
});
// The open suggestion popup swallows Enter, so the dialog never sees it: jump on the
// completion event instead. This also makes clicking a suggestion jump right away.
autoCompletion.setOnAutoCompleted(_ -> confirm());

newFieldHint.managedProperty().bind(newFieldHint.visibleProperty());
newFieldHint.visibleProperty().bind(Bindings.createBooleanBinding(
() -> viewModel.isNewField(searchField.getText()), searchField.textProperty()));

searchField.setOnAction(event -> {
Button okButton = (Button) getDialogPane().lookupButton(ButtonType.OK);
if (okButton != null) {
okButton.fire();
}
confirm();
event.consume();
});
}

private void confirm() {
Button okButton = (Button) getDialogPane().lookupButton(ButtonType.OK);
if (okButton != null) {
okButton.fire();
}
}

private void jumpToSelectedField() {
String selectedField = searchField.getText();

Expand Down
Loading
Loading