From a8fff635d0c032deea74640e309874119fae39d5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 1 Oct 2026 14:15:23 +0000 Subject: [PATCH 1/7] Fix search-filter attribute suggestions trapping the keyboard Stop offering attribute names once the caret is past an assertion's equals sign, dismiss the suggestion popup with Esc without clearing search results, and add a setting to turn the suggestions off. Co-authored-by: Jerry Combs --- .../service/AttributeSuggestSettings.java | 37 +++++ .../arborj/util/LDAPFilterValidator.java | 26 ++- .../arborj/view/AttributeTableView.java | 2 + .../arborj/view/EntryContextMenu.java | 5 + .../arborj/view/FilterBuilderDialog.java | 2 + .../pointbluetech/arborj/view/MainView.java | 152 +++++++++++++----- .../arborj/view/SettingsDialog.java | 22 ++- .../arborj/view/edir/EffectiveRightsView.java | 4 + .../arborj/util/LDAPFilterValidatorTest.java | 57 +++++++ 9 files changed, 267 insertions(+), 40 deletions(-) create mode 100644 src/main/java/com/pointbluetech/arborj/service/AttributeSuggestSettings.java diff --git a/src/main/java/com/pointbluetech/arborj/service/AttributeSuggestSettings.java b/src/main/java/com/pointbluetech/arborj/service/AttributeSuggestSettings.java new file mode 100644 index 0000000..4ed9c44 --- /dev/null +++ b/src/main/java/com/pointbluetech/arborj/service/AttributeSuggestSettings.java @@ -0,0 +1,37 @@ +package com.pointbluetech.arborj.service; + +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; + +import java.util.prefs.Preferences; + +/** + * Whether ArborJ suggests schema attribute names while the user types. + * Default is on. Shared by the search filter, Filter Builder, Add Attribute, + * and Effective Rights. + */ +public final class AttributeSuggestSettings { + + private static final Preferences prefs = Preferences.userNodeForPackage(AttributeSuggestSettings.class); + private static final AttributeSuggestSettings INSTANCE = new AttributeSuggestSettings(); + private static final String PREF_SUGGEST_ATTRIBUTE_NAMES = "suggestAttributeNames"; + + private final BooleanProperty enabled = new SimpleBooleanProperty(); + + private AttributeSuggestSettings() { + enabled.set(prefs.getBoolean(PREF_SUGGEST_ATTRIBUTE_NAMES, true)); + enabled.addListener((obs, oldVal, newVal) -> prefs.putBoolean(PREF_SUGGEST_ATTRIBUTE_NAMES, newVal)); + } + + public static AttributeSuggestSettings getInstance() { + return INSTANCE; + } + + public boolean isEnabled() { + return enabled.get(); + } + + public BooleanProperty enabledProperty() { + return enabled; + } +} diff --git a/src/main/java/com/pointbluetech/arborj/util/LDAPFilterValidator.java b/src/main/java/com/pointbluetech/arborj/util/LDAPFilterValidator.java index 8f70830..de35d98 100644 --- a/src/main/java/com/pointbluetech/arborj/util/LDAPFilterValidator.java +++ b/src/main/java/com/pointbluetech/arborj/util/LDAPFilterValidator.java @@ -41,10 +41,13 @@ public static boolean isValid(String filter) { /** * Extract the attribute name token at the given cursor position. - * Used for autocomplete — returns the partial attribute name being typed. + * Used for autocomplete — returns the partial attribute name being typed, + * or null when the caret is on the value side of an assertion (after {@code =}), + * including DN components inside that value. */ public static String attributeTokenAt(int cursor, String filter) { if (filter == null || cursor <= 0 || cursor > filter.length()) return null; + if (isOnAssertionValueSide(filter, cursor)) return null; char[] chars = filter.toCharArray(); // Walk left from cursor to find start of identifier int start = cursor - 1; @@ -54,7 +57,26 @@ public static String attributeTokenAt(int cursor, String filter) { while (end + 1 < chars.length && isAttrChar(chars[end + 1])) end++; if (start > end) return null; String token = filter.substring(start, end + 1); - return token.isEmpty() ? null : token; + if (token.isEmpty()) return null; + for (int i = 0; i < token.length(); i++) { + if (!isAttrChar(token.charAt(i))) return null; + } + return token; + } + + /** + * True when {@code cursor} is past the assertion operator of the filter + * item that contains it ({@code =}, {@code ~=}, {@code >=}, {@code <=}, + * {@code :=}). Scanning left, an {@code =} before {@code (} or {@code )} + * means the caret is in the value. + */ + private static boolean isOnAssertionValueSide(String filter, int cursor) { + for (int i = Math.min(cursor, filter.length()) - 1; i >= 0; i--) { + char c = filter.charAt(i); + if (c == '(' || c == ')') return false; + if (c == '=') return true; + } + return false; } private static boolean isAttrChar(char c) { diff --git a/src/main/java/com/pointbluetech/arborj/view/AttributeTableView.java b/src/main/java/com/pointbluetech/arborj/view/AttributeTableView.java index df01852..1aa9645 100644 --- a/src/main/java/com/pointbluetech/arborj/view/AttributeTableView.java +++ b/src/main/java/com/pointbluetech/arborj/view/AttributeTableView.java @@ -3,6 +3,7 @@ import com.pointbluetech.arborj.controller.MainController; import com.pointbluetech.arborj.controller.MainController.AttributeRow; import com.pointbluetech.arborj.model.LDAPAttributeSyntax; +import com.pointbluetech.arborj.service.AttributeSuggestSettings; import com.pointbluetech.arborj.service.FontSettings; import javafx.beans.property.SimpleStringProperty; import javafx.collections.transformation.FilteredList; @@ -927,6 +928,7 @@ private Optional promptForAttributeName(String dn) { ContextMenu suggestions = new ContextMenu(); field.textProperty().addListener((obs, oldVal, newVal) -> { suggestions.hide(); + if (!AttributeSuggestSettings.getInstance().isEnabled()) return; if (newVal == null || newVal.length() < 2) return; var attrMap = controller.getSchemaService().getAttributeMap(); if (attrMap == null) return; diff --git a/src/main/java/com/pointbluetech/arborj/view/EntryContextMenu.java b/src/main/java/com/pointbluetech/arborj/view/EntryContextMenu.java index b31e38c..569b9cd 100644 --- a/src/main/java/com/pointbluetech/arborj/view/EntryContextMenu.java +++ b/src/main/java/com/pointbluetech/arborj/view/EntryContextMenu.java @@ -1,6 +1,7 @@ package com.pointbluetech.arborj.view; import com.pointbluetech.arborj.controller.MainController; +import com.pointbluetech.arborj.service.AttributeSuggestSettings; import com.pointbluetech.arborj.model.DirectoryType; import com.pointbluetech.arborj.model.LDAPAttributeInfo; import com.pointbluetech.arborj.model.LDAPAttributeSyntax; @@ -340,6 +341,10 @@ private static void attachSchemaAutocomplete(TextField field, MainController con }; Runnable update = () -> { + if (!AttributeSuggestSettings.getInstance().isEnabled()) { + hide.run(); + return; + } String text = field.getText(); String lower = text == null ? "" : text.toLowerCase().trim(); if (lower.length() < 1) { hide.run(); return; } diff --git a/src/main/java/com/pointbluetech/arborj/view/FilterBuilderDialog.java b/src/main/java/com/pointbluetech/arborj/view/FilterBuilderDialog.java index 4d84b14..39714dd 100644 --- a/src/main/java/com/pointbluetech/arborj/view/FilterBuilderDialog.java +++ b/src/main/java/com/pointbluetech/arborj/view/FilterBuilderDialog.java @@ -1,6 +1,7 @@ package com.pointbluetech.arborj.view; import com.pointbluetech.arborj.controller.MainController; +import com.pointbluetech.arborj.service.AttributeSuggestSettings; import javafx.collections.FXCollections; import javafx.geometry.Insets; import javafx.geometry.Pos; @@ -830,6 +831,7 @@ private void setupAutocomplete(TextField field) { ContextMenu suggestions = new ContextMenu(); field.textProperty().addListener((_, _, newVal) -> { suggestions.hide(); + if (!AttributeSuggestSettings.getInstance().isEnabled()) return; if (newVal == null || newVal.length() < 2) return; try { var attrMap = controller.getSchemaService().getAttributeMap(); diff --git a/src/main/java/com/pointbluetech/arborj/view/MainView.java b/src/main/java/com/pointbluetech/arborj/view/MainView.java index 30e96ac..b302119 100644 --- a/src/main/java/com/pointbluetech/arborj/view/MainView.java +++ b/src/main/java/com/pointbluetech/arborj/view/MainView.java @@ -34,6 +34,12 @@ public class MainView { private Label tableViewLink; private boolean smartInsertEnabled = java.util.prefs.Preferences .userNodeForPackage(MainView.class).getBoolean("smartInsert", true); + /** Search-filter attribute suggestions. A heavyweight Popup, so it can take focus. */ + private javafx.stage.Popup searchSuggestionPopup; + /** When Esc dismissed suggestions; a follow-up delivery of that key must not clear results. */ + private boolean suggestionEscapeDismissed; + private long suggestionEscapeDismissedAtNanos; + private static final long SUGGESTION_ESCAPE_GUARD_NANOS = 250_000_000L; private final javafx.beans.property.BooleanProperty logPaneVisible = new javafx.beans.property.SimpleBooleanProperty(false); @@ -121,8 +127,14 @@ public MainView(MainController controller) { refreshSelectedNodeOrTree(); e.consume(); } else if (e.getCode() == javafx.scene.input.KeyCode.ESCAPE) { - // Escape: Clear search results - if (!controller.getSearchResults().isEmpty()) { + // Dismiss attribute suggestions first. This filter runs in the + // capture phase, before the text field, and Esc must not also + // clear search results — including a second delivery after the + // popup window hid and focus returned here. + if (isSearchSuggestionPopupShowing() || suggestionEscapeJustDismissed()) { + dismissSearchSuggestions(); + e.consume(); + } else if (!controller.getSearchResults().isEmpty()) { controller.clearSearch(); contentSplit.getItems().remove(searchResultsList); e.consume(); @@ -479,6 +491,82 @@ settingsItem, showLogsItem, checkUpdateItem, new SeparatorMenuItem(), return toolbar; } + private boolean isSearchSuggestionPopupShowing() { + return searchSuggestionPopup != null && searchSuggestionPopup.isShowing(); + } + + private boolean suggestionEscapeJustDismissed() { + return suggestionEscapeDismissed + && System.nanoTime() - suggestionEscapeDismissedAtNanos < SUGGESTION_ESCAPE_GUARD_NANOS; + } + + private boolean searchSuggestionPopupHasFocus() { + if (!isSearchSuggestionPopupShowing()) return false; + if (searchSuggestionPopup.isFocused()) return true; + var scene = searchSuggestionPopup.getScene(); + return scene != null && scene.getFocusOwner() != null; + } + + /** + * Hide attribute suggestions and return keyboard focus to the search filter. + * Does not clear search results. + */ + private void dismissSearchSuggestions() { + if (searchSuggestionPopup != null) { + searchSuggestionPopup.hide(); + } + suggestionEscapeDismissed = true; + suggestionEscapeDismissedAtNanos = System.nanoTime(); + if (searchFilterField != null) { + searchFilterField.requestFocus(); + } + } + + private void handleSearchSuggestionKeys(javafx.scene.input.KeyEvent ev, + ListView list, + Runnable applySuggestion) { + if (!isSearchSuggestionPopupShowing()) return; + switch (ev.getCode()) { + case DOWN -> { + int last = list.getItems().size() - 1; + if (last < 0) return; + int idx = list.getSelectionModel().getSelectedIndex(); + list.getSelectionModel().select(Math.min(idx + 1, last)); + list.scrollTo(list.getSelectionModel().getSelectedIndex()); + ev.consume(); + } + case UP -> { + if (list.getItems().isEmpty()) return; + int idx = list.getSelectionModel().getSelectedIndex(); + list.getSelectionModel().select(Math.max(idx - 1, 0)); + list.scrollTo(list.getSelectionModel().getSelectedIndex()); + ev.consume(); + } + case ENTER, TAB -> { + if (list.getSelectionModel().getSelectedIndex() >= 0) { + applySuggestion.run(); + ev.consume(); + } + } + case ESCAPE -> { + dismissSearchSuggestions(); + ev.consume(); + } + default -> {} + } + } + + /** Keys still arrive when focus is on the popup scene rather than the list. */ + private void installSearchSuggestionPopupKeys(ListView list, Runnable applySuggestion) { + var scene = list.getScene(); + if (scene == null) return; + final String marker = "arborjSearchSuggestionKeys"; + if (Boolean.TRUE.equals(scene.getProperties().get(marker))) return; + scene.getProperties().put(marker, Boolean.TRUE); + scene.addEventFilter(javafx.scene.input.KeyEvent.KEY_PRESSED, ev -> + handleSearchSuggestionKeys(ev, list, applySuggestion)); + } + private VBox buildSearchBar() { VBox searchBar = new VBox(4); searchBar.getStyleClass().add("search-bar"); @@ -500,9 +588,13 @@ private VBox buildSearchBar() { autoComplete.setPrefHeight(150); autoComplete.setStyle("-fx-font-family: monospaced; -fx-font-size: 12;"); - // Autocomplete as a floating popup (not in layout) + // Autocomplete as a floating popup (not in layout). On Windows this + // heavyweight window can take keyboard focus, so keys are also handled + // on the popup content. javafx.stage.Popup autoPopup = new javafx.stage.Popup(); + searchSuggestionPopup = autoPopup; autoPopup.setAutoHide(true); + autoPopup.setHideOnEscape(true); autoPopup.getContent().add(autoComplete); Runnable showAutoComplete = () -> { @@ -516,7 +608,16 @@ private VBox buildSearchBar() { }; Runnable hideAutoComplete = () -> autoPopup.hide(); + var suggestSettings = com.pointbluetech.arborj.service.AttributeSuggestSettings.getInstance(); + suggestSettings.enabledProperty().addListener((obs, wasEnabled, enabled) -> { + if (!enabled) hideAutoComplete.run(); + }); + Runnable updateSuggestions = () -> { + if (!suggestSettings.isEnabled()) { + hideAutoComplete.run(); + return; + } int cursor = filterField.getCaretPosition(); String token = com.pointbluetech.arborj.util.LDAPFilterValidator .attributeTokenAt(cursor, filterField.getText()); @@ -556,7 +657,7 @@ private VBox buildSearchBar() { filterField.positionCaret(tokenStart + selected.length()); } hideAutoComplete.run(); - }; + }; filterField.textProperty().addListener((obs, o, n) -> updateSuggestions.run()); filterField.caretPositionProperty().addListener((obs, o, n) -> updateSuggestions.run()); @@ -564,46 +665,23 @@ private VBox buildSearchBar() { // Click to apply suggestion autoComplete.setOnMouseClicked(ev -> applySuggestion.run()); - // Arrow keys and Enter in filter field navigate the autocomplete - filterField.addEventFilter(javafx.scene.input.KeyEvent.KEY_PRESSED, ev -> { - if (!autoPopup.isShowing()) return; - switch (ev.getCode()) { - case DOWN -> { - int idx = autoComplete.getSelectionModel().getSelectedIndex(); - autoComplete.getSelectionModel().select(Math.min(idx + 1, autoComplete.getItems().size() - 1)); - autoComplete.scrollTo(autoComplete.getSelectionModel().getSelectedIndex()); - ev.consume(); - } - case UP -> { - int idx = autoComplete.getSelectionModel().getSelectedIndex(); - autoComplete.getSelectionModel().select(Math.max(idx - 1, 0)); - autoComplete.scrollTo(autoComplete.getSelectionModel().getSelectedIndex()); - ev.consume(); - } - case ENTER, TAB -> { - if (autoComplete.getSelectionModel().getSelectedIndex() >= 0) { - applySuggestion.run(); - ev.consume(); - } - } - case ESCAPE -> { - hideAutoComplete.run(); - ev.consume(); - } - default -> {} - } - }); + // Keys on the text field, and on the popup when it has taken focus. + filterField.addEventFilter(javafx.scene.input.KeyEvent.KEY_PRESSED, ev -> + handleSearchSuggestionKeys(ev, autoComplete, applySuggestion)); + autoComplete.addEventFilter(javafx.scene.input.KeyEvent.KEY_PRESSED, ev -> + handleSearchSuggestionKeys(ev, autoComplete, applySuggestion)); + autoPopup.setOnShown(ev -> installSearchSuggestionPopupKeys(autoComplete, applySuggestion)); - // Hide autocomplete when focus leaves filter + // Hide autocomplete when focus leaves the filter, unless the popup + // itself has focus (a click on the list, or Windows moving focus there). filterField.focusedProperty().addListener((obs, o, focused) -> { if (!focused) { - // Delay to allow click on autocomplete list javafx.animation.PauseTransition pause = new javafx.animation.PauseTransition( javafx.util.Duration.millis(200)); pause.setOnFinished(ev -> { - if (!filterField.isFocused()) { + if (!filterField.isFocused() && !searchSuggestionPopupHasFocus()) { hideAutoComplete.run(); - } + } }); pause.play(); } diff --git a/src/main/java/com/pointbluetech/arborj/view/SettingsDialog.java b/src/main/java/com/pointbluetech/arborj/view/SettingsDialog.java index b3d12fd..549f9fc 100644 --- a/src/main/java/com/pointbluetech/arborj/view/SettingsDialog.java +++ b/src/main/java/com/pointbluetech/arborj/view/SettingsDialog.java @@ -1,6 +1,7 @@ package com.pointbluetech.arborj.view; import com.pointbluetech.arborj.ArborJApp; +import com.pointbluetech.arborj.service.AttributeSuggestSettings; import com.pointbluetech.arborj.service.FontSettings; import javafx.geometry.Insets; import javafx.scene.Scene; @@ -50,7 +51,7 @@ public SettingsDialog(com.pointbluetech.arborj.service.CredentialStore credentia } VBox root = new VBox(tabs); - Scene scene = new Scene(root, 520, 520); + Scene scene = new Scene(root, 520, 640); stage.setScene(scene); } @@ -160,6 +161,25 @@ private Tab buildAppearanceTab() { preview.setMaxWidth(Double.MAX_VALUE); grid.add(preview, 1, row++); + grid.add(new Separator(), 0, row++, 2, 1); + + Label suggestHeader = new Label("Suggestions"); + suggestHeader.setStyle("-fx-font-weight: bold;"); + grid.add(suggestHeader, 0, row++, 2, 1); + + CheckBox suggestNames = new CheckBox("Suggest attribute names as you type"); + suggestNames.selectedProperty().bindBidirectional( + AttributeSuggestSettings.getInstance().enabledProperty()); + grid.add(suggestNames, 0, row++, 2, 1); + + Label suggestNote = new Label( + "Shows matching schema attribute names in the search filter, Filter Builder, " + + "Add Attribute, and Effective Rights."); + suggestNote.setStyle("-fx-text-fill: -color-fg-muted; -fx-font-size: 11;"); + suggestNote.setWrapText(true); + suggestNote.setMaxWidth(460); + grid.add(suggestNote, 0, row++, 2, 1); + // Column constraints ColumnConstraints labelCol = new ColumnConstraints(); labelCol.setPrefWidth(90); diff --git a/src/main/java/com/pointbluetech/arborj/view/edir/EffectiveRightsView.java b/src/main/java/com/pointbluetech/arborj/view/edir/EffectiveRightsView.java index be1f5ab..302f33b 100644 --- a/src/main/java/com/pointbluetech/arborj/view/edir/EffectiveRightsView.java +++ b/src/main/java/com/pointbluetech/arborj/view/edir/EffectiveRightsView.java @@ -1,6 +1,7 @@ package com.pointbluetech.arborj.view.edir; import com.pointbluetech.arborj.controller.MainController; +import com.pointbluetech.arborj.service.AttributeSuggestSettings; import com.pointbluetech.arborj.model.EffectiveRights; import com.pointbluetech.arborj.model.EffectiveRights.AttributeRight; import com.pointbluetech.arborj.model.EffectiveRights.EntryRight; @@ -104,6 +105,9 @@ public EffectiveRightsView(MainController controller, javafx.stage.Window owner) // Filter as user types attributeCombo.getEditor().textProperty().addListener((obs, oldVal, newVal) -> { + if (!AttributeSuggestSettings.getInstance().isEnabled()) { + return; + } if (newVal == null || newVal.isEmpty()) { if (attrMap != null) { attributeCombo.getItems().setAll(attrMap.keySet().stream().sorted().toList()); diff --git a/src/test/java/com/pointbluetech/arborj/util/LDAPFilterValidatorTest.java b/src/test/java/com/pointbluetech/arborj/util/LDAPFilterValidatorTest.java index 6fa6ba6..3f309a8 100644 --- a/src/test/java/com/pointbluetech/arborj/util/LDAPFilterValidatorTest.java +++ b/src/test/java/com/pointbluetech/arborj/util/LDAPFilterValidatorTest.java @@ -126,4 +126,61 @@ void attributeTokenAtHandlesBadCursor() { // Cursor past the end of string => null. assertNull(LDAPFilterValidator.attributeTokenAt(50, "(cn=alice)")); } + + @Test + @DisplayName("attributeTokenAt stops at the assertion operator") + void attributeTokenAtStopsAfterEquals() { + String filter = "(cn=bob)"; + assertEquals("cn", LDAPFilterValidator.attributeTokenAt(2, filter)); + assertEquals("cn", LDAPFilterValidator.attributeTokenAt(3, filter)); // caret before '=' + assertNull(LDAPFilterValidator.attributeTokenAt(4, filter)); // caret just after '=' + assertNull(LDAPFilterValidator.attributeTokenAt(5, filter)); // caret in the value + assertNull(LDAPFilterValidator.attributeTokenAt(filter.length(), filter)); + } + + @Test + @DisplayName("attributeTokenAt ignores DN components inside an assertion value") + void attributeTokenAtIgnoresDnValues() { + String filter = "(member=cn=admin,ou=users)"; + assertEquals("member", LDAPFilterValidator.attributeTokenAt(filter.indexOf("member") + 3, filter)); + assertNull(LDAPFilterValidator.attributeTokenAt(filter.indexOf("=cn=") + 2, filter)); + assertNull(LDAPFilterValidator.attributeTokenAt(filter.indexOf("admin") + 1, filter)); + assertNull(LDAPFilterValidator.attributeTokenAt(filter.indexOf("ou=") + 1, filter)); + assertNull(LDAPFilterValidator.attributeTokenAt(filter.length(), filter)); + } + + @Test + @DisplayName("attributeTokenAt still finds attributes in later filter items") + void attributeTokenAtFindsLaterAssertions() { + String filter = "(&(objectClass=person)(!(cn=bob)))"; + assertEquals("objectClass", + LDAPFilterValidator.attributeTokenAt(filter.indexOf("objectClass") + 4, filter)); + assertNull(LDAPFilterValidator.attributeTokenAt(filter.indexOf("person") + 1, filter)); + int cn = filter.indexOf("(cn=") + 1; + assertEquals("cn", LDAPFilterValidator.attributeTokenAt(cn + 2, filter)); + assertNull(LDAPFilterValidator.attributeTokenAt(cn + 3, filter)); + assertNull(LDAPFilterValidator.attributeTokenAt(filter.indexOf("bob") + 1, filter)); + } + + @Test + @DisplayName("attributeTokenAt treats compound operators as the value boundary") + void attributeTokenAtCompoundOperators() { + assertEquals("age", LDAPFilterValidator.attributeTokenAt(3, "(age>=18)")); + assertNull(LDAPFilterValidator.attributeTokenAt("(age>=18)".indexOf('=') + 1, "(age>=18)")); + assertNull(LDAPFilterValidator.attributeTokenAt("(cn~=smith)".indexOf("smith") + 1, "(cn~=smith)")); + String spaced = "( cn = bob )"; + assertEquals("cn", LDAPFilterValidator.attributeTokenAt(spaced.indexOf("cn") + 1, spaced)); + assertNull(LDAPFilterValidator.attributeTokenAt(spaced.indexOf("bob") + 1, spaced)); + } + + @Test + @DisplayName("attributeTokenAt still returns a partial name before any operator") + void attributeTokenAtPartialName() { + assertEquals("mail", LDAPFilterValidator.attributeTokenAt(5, "(mail")); + assertEquals("cn", LDAPFilterValidator.attributeTokenAt(2, "cn")); + assertNull(LDAPFilterValidator.attributeTokenAt(3, "cn=")); + String ext = "(cn:caseExactMatch:=Fred)"; + assertEquals("cn", LDAPFilterValidator.attributeTokenAt(ext.indexOf("cn") + 1, ext)); + assertNull(LDAPFilterValidator.attributeTokenAt(ext.indexOf("Fred") + 1, ext)); + } } From 446851b4fe79869e31f6ee2536e441288d393aa4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 1 Oct 2026 14:19:50 +0000 Subject: [PATCH 2/7] Stop the suggestion Esc guard from re-arming A follow-up Escape after the popup closes is consumed once and does not reset the guard or pull focus back to the filter, so a later Escape can clear search results. Also hide the list when the popup window itself is not focused, and drop the unused token re-check. Co-authored-by: Jerry Combs --- .../arborj/util/LDAPFilterValidator.java | 6 +----- .../java/com/pointbluetech/arborj/view/MainView.java | 12 +++++++----- .../arborj/util/LDAPFilterValidatorTest.java | 2 -- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/pointbluetech/arborj/util/LDAPFilterValidator.java b/src/main/java/com/pointbluetech/arborj/util/LDAPFilterValidator.java index de35d98..1dfc495 100644 --- a/src/main/java/com/pointbluetech/arborj/util/LDAPFilterValidator.java +++ b/src/main/java/com/pointbluetech/arborj/util/LDAPFilterValidator.java @@ -57,11 +57,7 @@ public static String attributeTokenAt(int cursor, String filter) { while (end + 1 < chars.length && isAttrChar(chars[end + 1])) end++; if (start > end) return null; String token = filter.substring(start, end + 1); - if (token.isEmpty()) return null; - for (int i = 0; i < token.length(); i++) { - if (!isAttrChar(token.charAt(i))) return null; - } - return token; + return token.isEmpty() ? null : token; } /** diff --git a/src/main/java/com/pointbluetech/arborj/view/MainView.java b/src/main/java/com/pointbluetech/arborj/view/MainView.java index b302119..bee2993 100644 --- a/src/main/java/com/pointbluetech/arborj/view/MainView.java +++ b/src/main/java/com/pointbluetech/arborj/view/MainView.java @@ -131,9 +131,14 @@ public MainView(MainController controller) { // capture phase, before the text field, and Esc must not also // clear search results — including a second delivery after the // popup window hid and focus returned here. - if (isSearchSuggestionPopupShowing() || suggestionEscapeJustDismissed()) { + if (isSearchSuggestionPopupShowing()) { dismissSearchSuggestions(); e.consume(); + } else if (suggestionEscapeJustDismissed()) { + // Same key (or a repeat) after the popup hid. Consume it + // without re-arming the guard or moving focus, so a later + // Esc can still clear search results. + e.consume(); } else if (!controller.getSearchResults().isEmpty()) { controller.clearSearch(); contentSplit.getItems().remove(searchResultsList); @@ -501,10 +506,7 @@ private boolean suggestionEscapeJustDismissed() { } private boolean searchSuggestionPopupHasFocus() { - if (!isSearchSuggestionPopupShowing()) return false; - if (searchSuggestionPopup.isFocused()) return true; - var scene = searchSuggestionPopup.getScene(); - return scene != null && scene.getFocusOwner() != null; + return isSearchSuggestionPopupShowing() && searchSuggestionPopup.isFocused(); } /** diff --git a/src/test/java/com/pointbluetech/arborj/util/LDAPFilterValidatorTest.java b/src/test/java/com/pointbluetech/arborj/util/LDAPFilterValidatorTest.java index 3f309a8..dc51949 100644 --- a/src/test/java/com/pointbluetech/arborj/util/LDAPFilterValidatorTest.java +++ b/src/test/java/com/pointbluetech/arborj/util/LDAPFilterValidatorTest.java @@ -135,7 +135,6 @@ void attributeTokenAtStopsAfterEquals() { assertEquals("cn", LDAPFilterValidator.attributeTokenAt(3, filter)); // caret before '=' assertNull(LDAPFilterValidator.attributeTokenAt(4, filter)); // caret just after '=' assertNull(LDAPFilterValidator.attributeTokenAt(5, filter)); // caret in the value - assertNull(LDAPFilterValidator.attributeTokenAt(filter.length(), filter)); } @Test @@ -146,7 +145,6 @@ void attributeTokenAtIgnoresDnValues() { assertNull(LDAPFilterValidator.attributeTokenAt(filter.indexOf("=cn=") + 2, filter)); assertNull(LDAPFilterValidator.attributeTokenAt(filter.indexOf("admin") + 1, filter)); assertNull(LDAPFilterValidator.attributeTokenAt(filter.indexOf("ou=") + 1, filter)); - assertNull(LDAPFilterValidator.attributeTokenAt(filter.length(), filter)); } @Test From 0ac883cbc4250b9443ba5308c9169049065f3533 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 1 Oct 2026 14:21:53 +0000 Subject: [PATCH 3/7] Disable the suggestion popup's built-in Esc hide PopupWindow hides on Esc by default, which would close the list without arming the search-results guard. Esc now goes only through the explicit dismiss path. Co-authored-by: Jerry Combs --- src/main/java/com/pointbluetech/arborj/view/MainView.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/pointbluetech/arborj/view/MainView.java b/src/main/java/com/pointbluetech/arborj/view/MainView.java index bee2993..b31584b 100644 --- a/src/main/java/com/pointbluetech/arborj/view/MainView.java +++ b/src/main/java/com/pointbluetech/arborj/view/MainView.java @@ -596,7 +596,9 @@ private VBox buildSearchBar() { javafx.stage.Popup autoPopup = new javafx.stage.Popup(); searchSuggestionPopup = autoPopup; autoPopup.setAutoHide(true); - autoPopup.setHideOnEscape(true); + // Default is true, which hides the popup without arming the guard. + // Esc is handled only by the explicit dismiss path. + autoPopup.setHideOnEscape(false); autoPopup.getContent().add(autoComplete); Runnable showAutoComplete = () -> { From 7ad82593d65aed1a55c944d8cf8cfe190d119dd3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 1 Oct 2026 14:26:00 +0000 Subject: [PATCH 4/7] Restore the full Effective Rights attribute list when suggestions are off Turning suggestions off, or typing while they are off, puts the whole schema list back in the combo and closes a filtered dropdown. Co-authored-by: Jerry Combs --- .../arborj/view/edir/EffectiveRightsView.java | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/pointbluetech/arborj/view/edir/EffectiveRightsView.java b/src/main/java/com/pointbluetech/arborj/view/edir/EffectiveRightsView.java index 302f33b..0e8eb9a 100644 --- a/src/main/java/com/pointbluetech/arborj/view/edir/EffectiveRightsView.java +++ b/src/main/java/com/pointbluetech/arborj/view/edir/EffectiveRightsView.java @@ -98,19 +98,36 @@ public EffectiveRightsView(MainController controller, javafx.stage.Window owner) // Populate from schema var attrMap = controller.getSchemaService().getAttributeMap(); - if (attrMap != null && !attrMap.isEmpty()) { - List sorted = attrMap.keySet().stream().sorted().toList(); - attributeCombo.setItems(FXCollections.observableArrayList(sorted)); + List allAttributes = (attrMap == null || attrMap.isEmpty()) + ? List.of() + : attrMap.keySet().stream().sorted().toList(); + if (!allAttributes.isEmpty()) { + attributeCombo.setItems(FXCollections.observableArrayList(allAttributes)); } + // Suggestions off: full schema list, dropdown closed. Also runs when the + // preference flips off while this dialog is open. + Runnable showFullAttributeList = () -> { + if (!allAttributes.isEmpty() && !attributeCombo.getItems().equals(allAttributes)) { + String editorText = attributeCombo.getEditor().getText(); + attributeCombo.getItems().setAll(allAttributes); + attributeCombo.getEditor().setText(editorText); + } + attributeCombo.hide(); + }; + AttributeSuggestSettings.getInstance().enabledProperty().addListener((obs, wasEnabled, enabled) -> { + if (!enabled) showFullAttributeList.run(); + }); + // Filter as user types attributeCombo.getEditor().textProperty().addListener((obs, oldVal, newVal) -> { if (!AttributeSuggestSettings.getInstance().isEnabled()) { + showFullAttributeList.run(); return; } if (newVal == null || newVal.isEmpty()) { - if (attrMap != null) { - attributeCombo.getItems().setAll(attrMap.keySet().stream().sorted().toList()); + if (!allAttributes.isEmpty()) { + attributeCombo.getItems().setAll(allAttributes); } return; } From 645bb2023ef1f2693c99934f460acb0fbd6e18b2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 1 Oct 2026 14:29:58 +0000 Subject: [PATCH 5/7] Keep the full Effective Rights dropdown open while typing Restore and hide only when the combo is still filtered or suggestions are turned off. Drop the settings listener when the dialog closes. Co-authored-by: Jerry Combs --- .../arborj/view/edir/EffectiveRightsView.java | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/pointbluetech/arborj/view/edir/EffectiveRightsView.java b/src/main/java/com/pointbluetech/arborj/view/edir/EffectiveRightsView.java index 0e8eb9a..74055b1 100644 --- a/src/main/java/com/pointbluetech/arborj/view/edir/EffectiveRightsView.java +++ b/src/main/java/com/pointbluetech/arborj/view/edir/EffectiveRightsView.java @@ -7,6 +7,7 @@ import com.pointbluetech.arborj.model.EffectiveRights.EntryRight; import com.pointbluetech.arborj.view.DNPickerDialog; import javafx.application.Platform; +import javafx.beans.value.ChangeListener; import javafx.collections.FXCollections; import javafx.geometry.Insets; import javafx.geometry.Pos; @@ -105,24 +106,29 @@ public EffectiveRightsView(MainController controller, javafx.stage.Window owner) attributeCombo.setItems(FXCollections.observableArrayList(allAttributes)); } - // Suggestions off: full schema list, dropdown closed. Also runs when the - // preference flips off while this dialog is open. - Runnable showFullAttributeList = () -> { - if (!allAttributes.isEmpty() && !attributeCombo.getItems().equals(allAttributes)) { - String editorText = attributeCombo.getEditor().getText(); - attributeCombo.getItems().setAll(allAttributes); - attributeCombo.getEditor().setText(editorText); - } + // Restore the full schema list only while the combo is still filtered. + // A keystroke against the complete list must leave an open dropdown alone. + Runnable restoreFullAttributeList = () -> { + if (allAttributes.isEmpty() || attributeCombo.getItems().equals(allAttributes)) return; + String editorText = attributeCombo.getEditor().getText(); + attributeCombo.getItems().setAll(allAttributes); + attributeCombo.getEditor().setText(editorText); attributeCombo.hide(); }; - AttributeSuggestSettings.getInstance().enabledProperty().addListener((obs, wasEnabled, enabled) -> { - if (!enabled) showFullAttributeList.run(); - }); + ChangeListener suggestListener = (obs, wasEnabled, enabled) -> { + if (!enabled) { + restoreFullAttributeList.run(); + attributeCombo.hide(); + } + }; + var suggestSettings = AttributeSuggestSettings.getInstance(); + suggestSettings.enabledProperty().addListener(suggestListener); + setOnHidden(e -> suggestSettings.enabledProperty().removeListener(suggestListener)); // Filter as user types attributeCombo.getEditor().textProperty().addListener((obs, oldVal, newVal) -> { if (!AttributeSuggestSettings.getInstance().isEnabled()) { - showFullAttributeList.run(); + restoreFullAttributeList.run(); return; } if (newVal == null || newVal.isEmpty()) { From 84a231cf34aabc43fa8a960e6a97d2ac10a1e55e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 1 Oct 2026 14:31:52 +0000 Subject: [PATCH 6/7] Treat suggestion focus as the list, not the owner window Popup.isFocused() stays true whenever the main window is focused, so the suggestion list never hid after the filter lost focus. Focus now counts only when the popup scene's focus owner is the suggestion list. Co-authored-by: Jerry Combs --- .../com/pointbluetech/arborj/view/MainView.java | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/pointbluetech/arborj/view/MainView.java b/src/main/java/com/pointbluetech/arborj/view/MainView.java index b31584b..bbaddd5 100644 --- a/src/main/java/com/pointbluetech/arborj/view/MainView.java +++ b/src/main/java/com/pointbluetech/arborj/view/MainView.java @@ -36,6 +36,7 @@ public class MainView { .userNodeForPackage(MainView.class).getBoolean("smartInsert", true); /** Search-filter attribute suggestions. A heavyweight Popup, so it can take focus. */ private javafx.stage.Popup searchSuggestionPopup; + private ListView searchSuggestionList; /** When Esc dismissed suggestions; a follow-up delivery of that key must not clear results. */ private boolean suggestionEscapeDismissed; private long suggestionEscapeDismissedAtNanos; @@ -505,8 +506,18 @@ private boolean suggestionEscapeJustDismissed() { && System.nanoTime() - suggestionEscapeDismissedAtNanos < SUGGESTION_ESCAPE_GUARD_NANOS; } + /** + * True when keyboard focus is in the suggestion list. Popup.isFocused() + * follows the owner window, so it stays true while the main window is focused. + */ private boolean searchSuggestionPopupHasFocus() { - return isSearchSuggestionPopupShowing() && searchSuggestionPopup.isFocused(); + if (!isSearchSuggestionPopupShowing() || searchSuggestionList == null) return false; + var scene = searchSuggestionPopup.getScene(); + if (scene == null) return false; + for (var node = scene.getFocusOwner(); node != null; node = node.getParent()) { + if (node == searchSuggestionList) return true; + } + return false; } /** @@ -586,6 +597,7 @@ private VBox buildSearchBar() { // Autocomplete popup ListView autoComplete = new ListView<>(); + searchSuggestionList = autoComplete; autoComplete.setMaxHeight(200); autoComplete.setPrefHeight(150); autoComplete.setStyle("-fx-font-family: monospaced; -fx-font-size: 12;"); From ce5efc54ac0f254c488b3f3cf515d6a28809c520 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 15:50:19 +0000 Subject: [PATCH 7/7] README: recommend native Arbor / ArborPro to Mac users Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KqCmx8MWAPFyiJVE7bLxyz --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index 0b34929..588cead 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,18 @@ unicodePwd, schema variations). Signed installers for macOS and Windows and a Linux tarball are available from [pointbluetech.com/arborj](https://www.pointbluetech.com/arborj/). +### On a Mac? Try Arbor + +**Mac users should consider [Arbor](https://www.pointbluetech.com/)**, the +commercial native macOS version of this tool. It's built in SwiftUI, needs no +Java runtime, runs natively on Apple Silicon and Intel, and syncs profiles +through iCloud. It's on the Mac App Store in two editions: + +- [Arbor LDAP Browser](https://apps.apple.com/us/app/arbor-ldap-browser/id6759270047?mt=12): + free, with Pro features as an in-app purchase. +- [ArborPro LDAP Browser](https://apps.apple.com/us/app/arborpro-ldap-browser/id6802792427?mt=12): + a one-time upfront purchase, suited to enterprise and volume purchasing. + --- ## Table of contents