Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -57,6 +60,21 @@ public static String attributeTokenAt(int cursor, String filter) {
return token.isEmpty() ? null : 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) {
return Character.isLetterOrDigit(c) || c == '-' || c == '.';
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -927,6 +928,7 @@ private Optional<String> 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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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; }
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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();
Expand Down
168 changes: 131 additions & 37 deletions src/main/java/com/pointbluetech/arborj/view/MainView.java
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ 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;
private ListView<String> searchSuggestionList;
/** 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);

Expand Down Expand Up @@ -121,8 +128,19 @@ 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()) {
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);
e.consume();
Expand Down Expand Up @@ -479,6 +497,89 @@ 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;
}

/**
* 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() {
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;
}

/**
* 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<String> 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<String> 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");
Expand All @@ -496,13 +597,20 @@ private VBox buildSearchBar() {

// Autocomplete popup
ListView<String> autoComplete = new ListView<>();
searchSuggestionList = autoComplete;
autoComplete.setMaxHeight(200);
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);
// 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 = () -> {
Expand All @@ -516,7 +624,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());
Expand Down Expand Up @@ -556,54 +673,31 @@ 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());

// 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();
}
Expand Down
Loading
Loading