diff --git a/app/src/main/java/app/drydock/DrydockApplication.java b/app/src/main/java/app/drydock/DrydockApplication.java
index 76060784..5710d72e 100644
--- a/app/src/main/java/app/drydock/DrydockApplication.java
+++ b/app/src/main/java/app/drydock/DrydockApplication.java
@@ -1018,7 +1018,7 @@ private void installGlobalShortcuts(RepositorySidebar sidebar) {
} else if (cmd && event.getCode() == KeyCode.DIGIT0) {
appShell.toggleSidebar();
event.consume();
- } else if (cmd && event.getCode() == KeyCode.F) {
+ } else if (cmd && event.getCode() == KeyCode.K) {
sidebar.focusFilter();
event.consume();
} else if (cmd && event.getCode() == KeyCode.N) {
diff --git a/app/src/main/java/app/drydock/ui/RepositorySidebar.java b/app/src/main/java/app/drydock/ui/RepositorySidebar.java
index 6f274926..7a7a6393 100644
--- a/app/src/main/java/app/drydock/ui/RepositorySidebar.java
+++ b/app/src/main/java/app/drydock/ui/RepositorySidebar.java
@@ -48,6 +48,8 @@
import javafx.scene.control.OverrunStyle;
import javafx.scene.control.SeparatorMenuItem;
import javafx.scene.control.TextField;
+import javafx.scene.control.ToggleButton;
+import javafx.scene.control.ToggleGroup;
import javafx.scene.control.Tooltip;
import javafx.scene.control.TreeCell;
import javafx.scene.control.TreeItem;
@@ -55,6 +57,7 @@
import javafx.scene.input.KeyCode;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
+import javafx.scene.layout.FlowPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
@@ -91,15 +94,19 @@
/**
* The repository sidebar, rebuilt to the design handoff (README section 2)
* and remodeled WORKTREE-FIRST for the worktree lifecycle handoff (section
- * B "Discovering worktrees"): expanding a repository lists every worktree
- * {@code git worktree list} finds on disk -- including worktrees created
- * outside this app -- reconciled against the managed sessions by
- * {@code worktreeRoot}. Worktrees WITH a session render as session rows
- * (status dot, branch tag, dirty dot, PR chip, idle Resume pill);
- * worktrees WITHOUT one render UNOPENED (branch + short path + an accent
- * "Start βΈ" pill + a one-click π delete, guarded off the main checkout).
- * Each repo header gains a β³ rescan that re-runs discovery; newly-found
- * rows get a one-shot highlight.
+ * B "Discovering worktrees"): a wrapping subtab strip selects one
+ * repository at a time, and the tree below it lists every worktree
+ * {@code git worktree list} finds on disk for THAT repo -- including
+ * worktrees created outside this app -- reconciled against the managed
+ * sessions by {@code worktreeRoot}. Worktrees WITH a session render as
+ * session rows (status dot, branch tag, dirty dot, PR chip, idle Resume
+ * pill); worktrees WITHOUT one render UNOPENED (branch + short path + an
+ * accent "Start βΈ" pill + a one-click π delete, guarded off the main
+ * checkout). The selected repo's header row carries a β³ rescan that
+ * re-runs discovery; newly-found rows get a one-shot highlight. A subtab
+ * is shown iff the repo name matches the text filter OR any of its
+ * session/worktree rows do -- the same filter the old flat treeview used,
+ * now deciding subtab visibility instead of expand-and-keep.
*
*
All session/status data renders from the shared {@link
* WorkspaceViewModel}: this sidebar's async git-status/worktree fetches
@@ -132,6 +139,29 @@ public final class RepositorySidebar extends VBox {
private SessionFilter filter = SessionFilter.none();
private final SessionFilterBar filterBar;
+ /**
+ * The compiled text filter, refreshed whenever the filter field or chips
+ * change and at the top of every {@link #rebuildTree()}. Centralising
+ * the compile here (rather than passing a lowercased substring around)
+ * is what lets the matcher offer glob/regex shapes -- see {@link SidebarQuery}.
+ */
+ private SidebarQuery queryMatcher = SidebarQuery.matchAll();
+
+ /**
+ * The repository whose subtree the tree currently shows. The sidebar is
+ * one-repo-at-a-time now (a subtab strip selects it); {@code null} means
+ * no subtab is selected -- an explicit deselect (test hook) or the very
+ * first build before the constructor nudges one open.
+ */
+ private final ObjectProperty selectedRepoId = new SimpleObjectProperty<>();
+
+ /** The wrapping strip of per-repository subtabs above the tree. */
+ private final FlowPane repoTabStrip = new FlowPane();
+ /** Radio-style: exactly one subtab is selected at a time (or none, before the first pick). */
+ private final ToggleGroup repoTabGroup = new ToggleGroup();
+ /** Shown in the tree's place when a filter leaves survivors but none is selected. */
+ private final Label selectRepoPlaceholder = new Label("Select a repository");
+
/** Open findings for a worktree checkout, if any -- the per-row β¨n badge. */
private Function> openFindingsAt = path -> Optional.empty();
@@ -157,21 +187,6 @@ public final class RepositorySidebar extends VBox {
private final Label footerLabel = new Label();
private final Region footerDot = new Region();
- /** Which repository subtrees are expanded; new repositories start expanded. */
- private final Set collapsed = new HashSet<>();
-
- /** The user's collapse set, stashed while a filter forces every repo open. */
- private Set collapsedBeforeFilter;
-
- /**
- * Set in the two places a filter can change (the {@link SessionFilterBar}
- * callback and the {@link #filterDebounce} handler) and consumed at the
- * top of {@link #rebuildTree()}. Force-expansion must fire only on an
- * actual filter change, not on every rebuild, or the disclosure triangle
- * is a dead control for as long as a filter is on.
- */
- private boolean filterChangedSinceLastRebuild;
-
/**
* Diagnostic-only ({@code app.drydock.diag.tabScript} "forcehover"
* verb): the row Node, if any, whose actions strip should render
@@ -182,7 +197,7 @@ public final class RepositorySidebar extends VBox {
*/
private final ObjectProperty diagForcedHoverRow = new SimpleObjectProperty<>();
- /** Repos whose stale bucket is expanded. Distinct from {@code collapsed} (repo-level). */
+ /** Repos whose stale bucket is expanded. */
private final Set staleBucketExpanded = new HashSet<>();
/** Repos whose locked-worktree bucket is expanded. */
@@ -191,7 +206,7 @@ public final class RepositorySidebar extends VBox {
/** Repositories with a rescan in flight (spins the β³ button, prevents double-scans). */
private final Set scanning = ConcurrentHashMap.newKeySet();
- /** Repos whose pull-request group is expanded. Distinct from {@code collapsed} (repo-level). */
+ /** Repos whose pull-request group is expanded. */
private final Set pullRequestsExpanded = new HashSet<>();
/** Repositories with a pull-request scan in flight (also spins the β³ button, prevents double-scans). */
private final Set scanningPullRequests = ConcurrentHashMap.newKeySet();
@@ -199,7 +214,7 @@ public final class RepositorySidebar extends VBox {
private final Set pendingPullRequestRescan = ConcurrentHashMap.newKeySet();
/** The worktree list the in-flight PR scan for a repo started with (see refreshPullRequests / shouldQueuePullRequestRescan). */
private final Map> pullRequestScanWorktrees = new ConcurrentHashMap<>();
- /** Repos whose worktree list changed while collapsed, so their PR outcome is known stale; rescanned on next expand. */
+ /** Repos whose worktree list changed while their subtab was not selected, so their PR outcome is known stale; rescanned on next subtab select. */
private final Set pullRequestsStale = ConcurrentHashMap.newKeySet();
/** Worktree paths discovered by the latest rescan, highlighted one-shot until the timer clears them. */
private final Set recentlyDiscovered = new HashSet<>();
@@ -303,7 +318,7 @@ public RepositorySidebar(RepositoryManager repositoryManager, GitStatusService g
filterField.getStyleClass().add("filter-field");
filterField.setPromptText("β Filter repos & sessionsβ¦");
filterDebounce.setOnFinished(e -> {
- filterChangedSinceLastRebuild = true;
+ refreshQueryMatcher();
rebuildTree();
});
filterField.textProperty().addListener((obs, oldText, newText) -> filterDebounce.playFromStart());
@@ -364,12 +379,21 @@ public RepositorySidebar(RepositoryManager repositoryManager, GitStatusService g
emptyBanner.setVisible(false);
emptyBanner.setManaged(false);
+ // -- Subtab strip (one button per repository) ----------------------
+ repoTabStrip.getStyleClass().add("repo-tab-strip");
+ repoTabStrip.setHgap(4);
+ repoTabStrip.setVgap(4);
+
+ selectRepoPlaceholder.getStyleClass().add("sidebar-empty-message");
+ selectRepoPlaceholder.setMaxWidth(Double.MAX_VALUE);
+ selectRepoPlaceholder.setAlignment(Pos.CENTER);
+
// -- Footer ---------------------------------------------------------
footerDot.getStyleClass().addAll("status-dot", "dot-5");
HBox footer = new HBox(footerDot, footerLabel);
footer.getStyleClass().add("sidebar-footer");
- getChildren().addAll(collapseHeader.node(), header, emptyBanner, tree, footer);
+ getChildren().addAll(collapseHeader.node(), header, emptyBanner, repoTabStrip, tree, footer);
// Keep the displayed list in sync with EVERY repository mutation,
// not just the ones initiated by this sidebar's own handlers. The
@@ -416,6 +440,16 @@ public void activeSessionChanged(Optional previous,
requestRebuild();
return;
}
+ // Switching the active session may move to another repository:
+ // make that repo's subtab the selected one so its subtree is
+ // the one showing when syncActiveSelection selects the row.
+ if (current.flatMap(viewModel::sessionById)
+ .map(ManagedAgentSession::repositoryId)
+ .filter(repoId -> !repoId.equals(selectedRepoId.get()))
+ .isPresent()) {
+ requestRebuild();
+ return;
+ }
previous.ifPresent(RepositorySidebar.this::updateSessionRow);
current.ifPresent(RepositorySidebar.this::updateSessionRow);
syncActiveSelection();
@@ -479,7 +513,7 @@ public void setOnNewWorktree(Consumer handler) {
*/
private void activateNode(TreeItem item) {
switch (item.getValue()) {
- case SidebarNode.RepoNode repoNode -> item.setExpanded(!item.isExpanded());
+ case SidebarNode.RepoNode repoNode -> { /* pane header: no toggle, the subtab selects it */ }
case SidebarNode.SessionNode sessionNode ->
viewModel.sessionById(sessionNode.session().id()).ifPresent(navigator::resumeSession);
case SidebarNode.UnopenedWorktreeNode worktreeNode ->
@@ -559,7 +593,7 @@ public void refreshWorktreesFor(Repository repository) {
refreshWorktrees(repository, false);
}
- /** Focuses the filter field (βF). */
+ /** Focuses the filter field (βK). */
public void focusFilter() {
filterField.requestFocus();
filterField.selectAll();
@@ -618,27 +652,18 @@ public void diagForceHoverRow(Node row) {
}
/**
- * Diagnostic-only: sets a repository row's expansion to {@code expanded}
- * through the same {@code TreeItem.setExpanded} call the repo row's own
- * mouse-click handler uses (see {@code buildRepoRow}), so the
- * {@code expandedProperty} listener installed in {@link #rebuildTree}
- * fires the real expand path -- including the B1 rescan a repo whose PR
- * outcome went stale while collapsed self-heals on. Exists so a headless
- * test can drive the expand trigger without a TestFX robot click, which
- * intermittently fails to toggle a TreeView row under monocle/load (the
- * click reports success without the {@code expandedProperty} listener
- * ever firing, leaving the rescan the test exists to prove never
- * started). No-op (no listener fire) when the row is already in that
- * state, matching {@code setExpanded}'s own contract. Not reachable
- * outside tests.
+ * Diagnostic-only: selects ({@code expanded = true}) or deselects
+ * ({@code expanded = false}) a repository's subtab through the same
+ * {@link #selectRepo} path a real subtab click uses, so a headless test
+ * can drive the selection trigger -- including the B1 rescan a repo
+ * whose PR outcome went stale while NOT selected self-heals on when its
+ * subtab is picked again. Replaces the old repo-row expand/collapse
+ * hook: the sidebar is one-repo-at-a-time now, so "not looking at a
+ * repo" is "its subtab is not selected", not "its row is collapsed".
+ * Not reachable outside tests.
*/
public void diagSetRepoExpanded(RepositoryId repoId, boolean expanded) {
- for (TreeItem item : treeRoot.getChildren()) {
- if (item.getValue() instanceof SidebarNode.RepoNode repo && repo.repository().id().equals(repoId)) {
- item.setExpanded(expanded);
- return;
- }
- }
+ selectRepo(expanded ? repoId : null);
}
/**
@@ -811,10 +836,10 @@ static String pullRequestRowText(GhCliService.OpenPullRequest pullRequest) {
* already has an outcome -- of any kind, {@code Absent} included --
* from being rescanned on every rebuild. NOT the whole story where
* staleness is concerned, though: a repo can hold a correct-when-taken
- * outcome that a LATER worktree change invalidated while the repo was
- * collapsed (deliberately not auto-rescanned then -- see {@code
- * refreshWorktrees}'s completion); every caller of this method also
- * consults {@link #pullRequestsStale} alongside it, since {@code
+ * outcome that a LATER worktree change invalidated while the repo's
+ * subtab was not selected (deliberately not auto-rescanned then -- see
+ * {@code refreshWorktrees}'s completion); every caller of this method
+ * also consults {@link #pullRequestsStale} alongside it, since {@code
* pullRequestsScanned} alone cannot tell "scanned" from "scanned, but
* no longer accurate" apart.
*
@@ -1012,12 +1037,28 @@ private boolean filtering() {
/** Chip callback: re-reads {@link #filterBar} and coalesces into one rebuild. */
private void onFilterChipsChanged() {
filter = filterBar.filter();
- filterChangedSinceLastRebuild = true;
requestRebuild();
}
+ /** The raw (non-lowercased) filter text; {@link SidebarQuery} handles case. */
+ private String rawQueryText() {
+ return filterField.getText() == null ? "" : filterField.getText().strip();
+ }
+
+ /** Recompile {@link #queryMatcher} from the current filter field text. */
+ private void refreshQueryMatcher() {
+ queryMatcher = SidebarQuery.of(rawQueryText());
+ }
+
+ /**
+ * Kept for the diag driver and {@link #pullRequestNarrowQuery}: the
+ * lowercased substring view of the filter. With glob/regex active this is
+ * a lossy approximation, but those callers only use it to decide whether
+ * ANY narrowing applies, and {@link #queryMatcher} is the authority for
+ * the actual match.
+ */
private String currentQuery() {
- return filterField.getText() == null ? "" : filterField.getText().strip().toLowerCase(Locale.ROOT);
+ return rawQueryText().toLowerCase(Locale.ROOT);
}
/** The frontmost session is always rendered -- see {@link #applyFacets}. */
@@ -1026,29 +1067,18 @@ private boolean isExempt(ManagedSessionId sessionId) {
}
private void rebuildTree() {
- // A filter is a global question ("where are my errors?"); repo
- // expansion is a local reading preference. Re-assert the expansion on
- // every change to the filter -- not only on entry, or switching from
- // `running` to `error` would leave the sole matching session inside a
- // repo the user collapsed earlier.
- if (filtering()) {
- if (collapsedBeforeFilter == null) {
- collapsedBeforeFilter = new HashSet<>(collapsed);
- }
- if (filterChangedSinceLastRebuild) {
- collapsed.clear();
- }
- } else if (collapsedBeforeFilter != null) {
- collapsed.clear();
- collapsed.addAll(collapsedBeforeFilter);
- collapsedBeforeFilter = null;
- }
- filterChangedSinceLastRebuild = false;
-
- String query = currentQuery();
+ refreshQueryMatcher();
+ boolean textFiltering = !queryMatcher.isTrivial();
List repositories = sorted(repositoryManager.repositories());
- List> repoItems = new ArrayList<>();
+ // One TreeItem per surviving repository, kept in a map so the
+ // subtab strip and the single visible subtree are built from the
+ // SAME computation -- a survivor is a repo whose name/branch
+ // matches OR any of its session/worktree rows match, exactly the
+ // old treeview rule, now deciding subtab visibility instead of
+ // expand-and-keep.
+ List survivorRepos = new ArrayList<>();
+ Map> survivorItems = new LinkedHashMap<>();
// Surviving rows of ANY kind (session, unopened worktree, stale/
// locked bucket), except a session row that is present only because
// {@code isExempt} accepted it despite failing the filter -- the
@@ -1067,9 +1097,9 @@ private void rebuildTree() {
// The filter matches the repo itself (name/branch) OR any of
// its worktree/session rows; a repo matched only through its
// children narrows to exactly the matching rows.
- boolean repoMatchedByName = !query.isEmpty() && matchesRepo(repository, query);
- if (!query.isEmpty() && !repoMatchedByName) {
- children = children.stream().filter(child -> matchesNode(child, query)).toList();
+ boolean repoMatchedByName = textFiltering && matchesRepo(repository);
+ if (textFiltering && !repoMatchedByName) {
+ children = children.stream().filter(this::matchesNode).toList();
}
// Only drop a childless repo while filtering: with no filter at
// all, a freshly added repository with no worktrees and no
@@ -1091,63 +1121,183 @@ private void rebuildTree() {
matchCount++;
}
}
- TreeItem repoItem = new TreeItem<>(new SidebarNode.RepoNode(repository));
- for (SidebarNode child : children) {
- repoItem.getChildren().add(
- pullRequestGroupItem(child, repository).orElseGet(() -> new TreeItem<>(child)));
- }
- repoItem.setExpanded(!collapsed.contains(repository.id()));
- repoItem.expandedProperty().addListener((obs, was, is) -> {
- if (is) {
- collapsed.remove(repository.id());
- // A repo row expanding is one of refreshPullRequests's
- // four triggers (see its javadoc) -- and the one that
- // actually fires the FIRST scan for a repo that starts
- // collapsed, or recovers one whose outcome went stale
- // while collapsed: refreshWorktrees's completion
- // deliberately does not scan a collapsed repo (see the
- // comment there), marking it stale instead, and
- // pullRequestScanDue is what notices that mark here.
- if (pullRequestScanDue(repository)) {
- refreshPullRequests(repository);
- }
- } else {
- collapsed.add(repository.id());
- }
- // Re-render the header so the βΆ caret tracks EVERY expansion
- // change -- keyboard toggles (Enter, β/β) included, not just
- // the row's own mouse handler.
- updateRepoRow(repository.id());
- });
- if (repoItem.isExpanded() && pullRequestScanDue(repository)) {
- refreshPullRequests(repository);
- }
- repoItems.add(repoItem);
+ survivorRepos.add(repository);
+ survivorItems.put(repository.id(), buildRepoTreeItem(repository, children));
}
- treeRoot.getChildren().setAll(repoItems);
+ rebuildRepoTabStrip(survivorRepos);
+ RepositoryId selected = resolveSelectedRepo(survivorRepos);
+ selectedRepoId.set(selected);
+
+ if (selected != null) {
+ treeRoot.getChildren().setAll(survivorItems.get(selected));
+ // The subtab's selection is the old "repo-row expand" trigger:
+ // the one moment a repo the user is now looking at self-heals a
+ // PR outcome that went stale while its subtab was NOT selected
+ // (see refreshWorktrees's collapsed-skip, now a not-selected-skip).
+ Repository repo = repoForId(selected, repositories);
+ if (repo != null && pullRequestScanDue(repo)) {
+ refreshPullRequests(repo);
+ }
+ } else {
+ treeRoot.getChildren().clear();
+ }
// Two forms, because an exempt row can leave the tree non-empty while
// nothing actually matched. Swap only when there is nothing to show
// at all; otherwise the exempt row would be deleted from the screen,
// re-creating the failure the exemption exists to prevent.
boolean nothingMatched = filtering() && matchCount == 0;
- boolean treeIsEmpty = treeRoot.getChildren().isEmpty();
boolean noRepositoriesAtAll = repositoryManager.repositories().isEmpty();
- showEmptyState(nothingMatched && !noRepositoriesAtAll, treeIsEmpty);
+ boolean treeIsEmpty = selected == null;
+ boolean survivorsExist = !survivorRepos.isEmpty();
+ showEmptyState(nothingMatched && !noRepositoriesAtAll, treeIsEmpty, survivorsExist);
updateFooter();
syncActiveSelection();
}
/**
- * Whether {@code repository} is due for a PR scan right now, at either
- * of the two places {@code rebuildTree} asks: on repository add (a
- * newly built, expanded {@code TreeItem}) and on repo-row expand. True
- * for either half of {@link #needsPullRequestScan}'s reason (discovery
- * has landed, nothing scanned yet) OR {@link #pullRequestsStale} (an
- * outcome exists, but a worktree change invalidated it while the repo
- * was collapsed) -- {@code needsPullRequestScan} alone cannot tell
+ * Builds the always-expanded TreeItem for one repository's subtree --
+ * sessions, unopened worktrees, the stale/locked buckets, and the
+ * pull-request group. The repo row is a pane header now (the subtab
+ * selects it), so it never collapses; a force-re-expand guard defeats
+ * the keyboard β that would otherwise fold a TreeView row.
+ */
+ private TreeItem buildRepoTreeItem(Repository repository, List children) {
+ TreeItem repoItem = new TreeItem<>(new SidebarNode.RepoNode(repository));
+ for (SidebarNode child : children) {
+ repoItem.getChildren().add(
+ pullRequestGroupItem(child, repository).orElseGet(() -> new TreeItem<>(child)));
+ }
+ repoItem.setExpanded(true);
+ repoItem.expandedProperty().addListener((obs, was, is) -> {
+ if (!is) {
+ // The repo pane is never collapsible; a keyboard β (or any
+ // other path) that folded it is undone here, in the same
+ // listener that used to track the collapse set.
+ repoItem.setExpanded(true);
+ }
+ updateRepoRow(repository.id());
+ });
+ return repoItem;
+ }
+
+ /**
+ * Rebuilds the subtab strip from the surviving repositories. Each subtab
+ * is a {@link ToggleButton} whose label wraps (so a long repo name takes
+ * as many rows as it needs) inside a bounded width; the {@link FlowPane}
+ * wraps the buttons themselves across rows as the sidebar narrows.
+ */
+ private void rebuildRepoTabStrip(List survivors) {
+ repoTabGroup.getToggles().clear();
+ repoTabStrip.getChildren().clear();
+ RepositoryId selected = selectedRepoId.get();
+ for (Repository repository : survivors) {
+ ToggleButton tab = new ToggleButton();
+ tab.getStyleClass().add("repo-tab");
+ tab.setToggleGroup(repoTabGroup);
+ tab.setMaxWidth(160);
+ tab.setMinWidth(0);
+ tab.setFocusTraversable(false);
+ Label name = new Label(repository.displayName());
+ name.setWrapText(true);
+ name.setMaxWidth(Double.MAX_VALUE);
+ tab.setGraphic(name);
+ tab.setTooltip(new Tooltip(repository.displayName()));
+ if (repository.id().equals(selected)) {
+ tab.setSelected(true);
+ }
+ tab.setOnAction(e -> selectRepo(repository.id()));
+ repoTabStrip.getChildren().add(tab);
+ }
+ }
+
+ /**
+ * Resolves which repository's subtree the tree should show. Keeps a
+ * surviving selection; otherwise follows the active session; otherwise
+ * keeps an explicit deselect (a prior selection that the user -- or a
+ * test hook -- cleared) as deselect; otherwise picks the first survivor
+ * on the very first build only, so an initial launch with no active
+ * session still opens something rather than an empty pane.
+ */
+ private RepositoryId resolveSelectedRepo(List survivors) {
+ if (survivors.isEmpty()) {
+ return null;
+ }
+ RepositoryId current = selectedRepoId.get();
+ if (current != null && survivorIds(survivors).contains(current)) {
+ return current;
+ }
+ RepositoryId activeRepo = viewModel.activeSession()
+ .flatMap(active -> viewModel.sessionById(active))
+ .map(ManagedAgentSession::repositoryId)
+ .filter(id -> survivorIds(survivors).contains(id))
+ .orElse(null);
+ if (activeRepo != null) {
+ return activeRepo;
+ }
+ if (current != null) {
+ // A previously selected repo dropped out (filtered/removed):
+ // fall through to the first survivor rather than stranding the
+ // tree on a deselected repo that no longer exists.
+ return survivors.get(0).id();
+ }
+ // Initial build (no selection ever made): open the first survivor so
+ // the pane is not empty on launch. A later explicit deselect sets
+ // {@code current} to null via the test hook and re-enters here, but
+ // then {@code current == null} AND there is no active session, so it
+ // stays deselected -- the deselect sticks.
+ if (!initialAutoSelectDone) {
+ initialAutoSelectDone = true;
+ return survivors.get(0).id();
+ }
+ return null;
+ }
+
+ private boolean initialAutoSelectDone;
+
+ private static Set survivorIds(List survivors) {
+ Set ids = new HashSet<>();
+ for (Repository repository : survivors) {
+ ids.add(repository.id());
+ }
+ return ids;
+ }
+
+ private static Repository repoForId(RepositoryId id, List repositories) {
+ for (Repository repository : repositories) {
+ if (repository.id().equals(id)) {
+ return repository;
+ }
+ }
+ return null;
+ }
+
+ /** Whether {@code repository} is the currently selected subtab. */
+ private boolean isSelectedRepo(RepositoryId repositoryId) {
+ return repositoryId != null && repositoryId.equals(selectedRepoId.get());
+ }
+
+ /**
+ * Selects a repository's subtab and rebuilds so its subtree shows. The
+ * public entry point for a subtab click; also the test hook's path. A
+ * {@code null} id deselects (no subtree) -- used by the diag hook to
+ * model "not looking at this repo".
+ */
+ private void selectRepo(RepositoryId repositoryId) {
+ selectedRepoId.set(repositoryId);
+ requestRebuild();
+ }
+
+ /**
+ * Whether {@code repository} is due for a PR scan right now, at the
+ * two places {@code rebuildTree} asks: on repository add (a newly built
+ * subtab's subtree) and on subtab selection. True for either half of
+ * {@link #needsPullRequestScan}'s reason (discovery has landed,
+ * nothing scanned yet) OR {@link #pullRequestsStale} (an outcome
+ * exists, but a worktree change invalidated it while the repo's subtab
+ * was not selected) -- {@code needsPullRequestScan} alone cannot tell
* "scanned" from "scanned, but no longer accurate" apart.
*/
private boolean pullRequestScanDue(Repository repository) {
@@ -1162,9 +1312,9 @@ private boolean pullRequestScanDue(Repository repository) {
* request in a landed {@code Rows} outcome, none for {@code
* Unavailable} -- rather than being a leaf, so the TreeView's own
* expand/collapse drives it. Starts collapsed; its expand state
- * survives rebuilds in {@link #pullRequestsExpanded}, the same way a
- * repository row's does in {@link #collapsed}. Empty for every other
- * {@code SidebarNode}, so the caller falls back to a plain leaf item.
+ * survives rebuilds in {@link #pullRequestsExpanded}. Empty for every
+ * other {@code SidebarNode}, so the caller falls back to a plain leaf
+ * item.
*/
private Optional> pullRequestGroupItem(SidebarNode child, Repository repository) {
if (!(child instanceof SidebarNode.PullRequestGroupNode groupNode)) {
@@ -1172,7 +1322,7 @@ private Optional> pullRequestGroupItem(SidebarNode child,
}
TreeItem groupItem = new TreeItem<>(groupNode);
groupItem.getChildren().setAll(
- pullRequestChildItems(groupNode.outcome(), repository, pullRequestNarrowQuery(repository)));
+ pullRequestChildItems(groupNode.outcome(), repository, pullRequestShouldNarrow(repository)));
groupItem.setExpanded(pullRequestsExpanded.contains(repository.id()));
groupItem.expandedProperty().addListener((obs, was, is) -> {
if (is) {
@@ -1190,19 +1340,14 @@ private Optional> pullRequestGroupItem(SidebarNode child,
}
/**
- * The query {@link #pullRequestGroupItem} and {@link
- * #updatePullRequestGroupRow} narrow a group's PR rows by -- empty
- * unless a text filter is active AND the repo did not already match by
- * its own name/branch, mirroring {@code rebuildTree}'s own top-level
- * children filter exactly (a repo matched by name shows everything
- * under it, unnarrowed).
+ * Whether a group's PR rows should be narrowed by the text filter --
+ * true only when a text filter is active AND the repo did not already
+ * match by its own name/branch, mirroring {@code rebuildTree}'s own
+ * top-level children filter exactly (a repo matched by name shows
+ * everything under it, unnarrowed).
*/
- private String pullRequestNarrowQuery(Repository repository) {
- String query = currentQuery();
- if (query.isEmpty() || matchesRepo(repository, query)) {
- return "";
- }
- return query;
+ private boolean pullRequestShouldNarrow(Repository repository) {
+ return !queryMatcher.isTrivial() && !matchesRepo(repository);
}
/**
@@ -1218,7 +1363,7 @@ private String pullRequestNarrowQuery(Repository repository) {
* filter active at all.
*/
private List> pullRequestChildItems(RepositoryPullRequests.Outcome outcome,
- Repository repository, String narrowQuery) {
+ Repository repository, boolean narrow) {
List> items = new ArrayList<>();
if (!(outcome instanceof RepositoryPullRequests.Outcome.Rows rows)) {
return items;
@@ -1228,7 +1373,7 @@ private List> pullRequestChildItems(RepositoryPullRequests
// Routed through matchesNode's own PullRequestNode case
// (rather than a private duplicate of the same check) so that
// case is the one place this decision is made.
- if (!narrowQuery.isEmpty() && !matchesNode(prNode, narrowQuery)) {
+ if (narrow && !matchesNode(prNode)) {
continue;
}
items.add(new TreeItem<>(prNode));
@@ -1276,7 +1421,7 @@ private void updatePullRequestGroupRow(RepositoryId repositoryId) {
pullRequestGroupNodeFor(repository).ifPresent(fresh -> {
if (fresh instanceof SidebarNode.PullRequestGroupNode freshGroup) {
List> freshChildren = pullRequestChildItems(
- freshGroup.outcome(), repository, pullRequestNarrowQuery(repository));
+ freshGroup.outcome(), repository, pullRequestShouldNarrow(repository));
// Most calls here repaint content that did not
// change at all -- a completion whose outcome
// matched what was stored, and the group's own
@@ -1315,9 +1460,14 @@ private static List nodeValuesOf(List> items)
* turning Space into "Clear filters". The banner form moves no focus at
* all, because nothing leaves the scene.
*/
- private void showEmptyState(boolean nothingMatched, boolean treeIsEmpty) {
+ private void showEmptyState(boolean nothingMatched, boolean treeIsEmpty, boolean survivorsExist) {
boolean swap = nothingMatched && treeIsEmpty;
boolean banner = nothingMatched && !treeIsEmpty;
+ // Survivors exist but none is selected (an explicit deselect, or the
+ // initial build before the first pick): show a placeholder in the
+ // tree's place instead of the "nothing matched" banner -- the
+ // subtab strip above is what the user picks from.
+ boolean placeholder = !nothingMatched && treeIsEmpty && survivorsExist;
Node focusOwner = getScene() == null ? null : getScene().getFocusOwner();
@@ -1328,13 +1478,19 @@ private void showEmptyState(boolean nothingMatched, boolean treeIsEmpty) {
if (treeHadFocus) {
emptyState.getChildren().get(1).requestFocus();
}
- } else if (!swap && getChildren().contains(emptyState)) {
+ } else if (placeholder && !getChildren().contains(selectRepoPlaceholder)) {
+ getChildren().set(getChildren().indexOf(tree), selectRepoPlaceholder);
+ VBox.setVgrow(selectRepoPlaceholder, Priority.ALWAYS);
+ } else if (!swap && !placeholder && getChildren().contains(emptyState)) {
boolean buttonHadFocus = isDescendantOf(focusOwner, emptyState);
getChildren().set(getChildren().indexOf(emptyState), tree);
VBox.setVgrow(tree, Priority.ALWAYS);
if (buttonHadFocus) {
filterField.requestFocus();
}
+ } else if (!swap && !placeholder && getChildren().contains(selectRepoPlaceholder)) {
+ getChildren().set(getChildren().indexOf(selectRepoPlaceholder), tree);
+ VBox.setVgrow(tree, Priority.ALWAYS);
}
emptyBanner.setVisible(banner);
emptyBanner.setManaged(banner);
@@ -1462,10 +1618,6 @@ private void pruneRowCaches() {
newSessionMenus.keySet().retainAll(repoIds);
unopenedTooltips.keySet().retainAll(worktreePaths);
unopenedMenus.keySet().retainAll(worktreePaths);
- collapsed.retainAll(repoIds);
- if (collapsedBeforeFilter != null) {
- collapsedBeforeFilter.retainAll(repoIds);
- }
staleBucketExpanded.retainAll(repoIds);
lockedBucketExpanded.retainAll(repoIds);
pullRequestsExpanded.retainAll(repoIds);
@@ -1548,8 +1700,8 @@ private void updateWorktreeRow(Path worktreeRoot) {
/**
* Mirrors the currently selected session tab into the tree: selects the
* matching row and -- only while the sidebar is actually attached to
- * the scene, so a collapsed sidebar (β0) is never disturbed -- expands
- * its repository node and scrolls the row into view. The scroll fires
+ * the scene, so a collapsed sidebar (β0) is never disturbed -- scrolls
+ * the row into view. The scroll fires
* once per active-session change, not on every status-refresh rebuild.
*/
private void syncActiveSelection() {
@@ -1578,16 +1730,16 @@ private void syncActiveSelection() {
}
boolean sidebarShowing = getScene() != null;
boolean activeChanged = !active.equals(lastRevealedSession);
- if (sidebarShowing && activeChanged) {
- match.getParent().setExpanded(true);
- }
+ // The repo pane is always expanded (a subtab selects it; it never
+ // collapses), so the row is always visible once its repo is the
+ // selected subtab. Select it and scroll it into view on an
+ // active-session change.
// Select only while the row is actually visible: TreeView's
// selection model force-expands collapsed ancestors of a hidden
- // selection target, which would re-open a repository the user just
- // collapsed on every subsequent rebuild. Visibility is checked via
- // the parent's expanded state, NOT getRow() -- getRow() reports an
- // index for rows under a collapsed parent too (it counts as if
- // everything were expanded), so it cannot serve as this guard.
+ // selection target. Visibility is checked via the parent's expanded
+ // state, NOT getRow() -- getRow() reports an index for rows under a
+ // collapsed parent too (it counts as if everything were expanded),
+ // so it cannot serve as this guard.
if (match.getParent().isExpanded()) {
if (tree.getSelectionModel().getSelectedItem() != match) {
tree.getSelectionModel().select(match);
@@ -1662,16 +1814,16 @@ private SidebarChildren childrenOf(Repository repository) {
return SidebarChildren.classify(worktrees, sessionsFor(repository), viewModel::activityOf);
}
- private boolean matchesRepo(Repository repository, String query) {
- if (repository.displayName().toLowerCase(Locale.ROOT).contains(query)) {
+ private boolean matchesRepo(Repository repository) {
+ if (queryMatcher.matches(repository.displayName())) {
return true;
}
GitStatus status = viewModel.repoStatus(repository.id()).orElse(null);
- return status != null && UiFormats.branchText(status).toLowerCase(Locale.ROOT).contains(query);
+ return status != null && queryMatcher.matches(UiFormats.branchText(status));
}
/** Whether one worktree/session row matches the filter: session name, branch, or worktree path. */
- private boolean matchesNode(SidebarNode node, String query) {
+ private boolean matchesNode(SidebarNode node) {
return switch (node) {
case SidebarNode.RepoNode repoNode -> false;
case SidebarNode.SessionNode sessionNode -> {
@@ -1683,32 +1835,32 @@ private boolean matchesNode(SidebarNode node, String query) {
text.append(' ').append(UiFormats.branchText(status));
}
});
- yield text.toString().toLowerCase(Locale.ROOT).contains(query);
+ yield queryMatcher.matches(text.toString());
}
case SidebarNode.UnopenedWorktreeNode worktreeNode -> {
String text = worktreeNode.worktree().branch().orElse("")
+ " " + worktreeNode.worktree().path();
- yield text.toLowerCase(Locale.ROOT).contains(query);
+ yield queryMatcher.matches(text);
}
case SidebarNode.StaleWorktreesNode staleNode -> staleNode.worktrees().stream().anyMatch(worktree -> {
String text = worktree.branch().orElse("") + " " + worktree.path();
- return text.toLowerCase(Locale.ROOT).contains(query);
+ return queryMatcher.matches(text);
});
case SidebarNode.LockedWorktreesNode lockedNode -> lockedNode.worktrees().stream().anyMatch(worktree -> {
String text = worktree.branch().orElse("") + " " + worktree.path();
- return text.toLowerCase(Locale.ROOT).contains(query);
+ return queryMatcher.matches(text);
});
- case SidebarNode.PullRequestNode pullRequestNode -> matchesPullRequest(pullRequestNode.pullRequest(), query);
+ case SidebarNode.PullRequestNode pullRequestNode -> matchesPullRequest(pullRequestNode.pullRequest());
case SidebarNode.PullRequestGroupNode groupNode ->
groupNode.outcome() instanceof RepositoryPullRequests.Outcome.Rows rows
- && rows.pullRequests().stream().anyMatch(pullRequest -> matchesPullRequest(pullRequest, query));
+ && rows.pullRequests().stream().anyMatch(this::matchesPullRequest);
};
}
/** A pull request matches on its number, title or head branch -- the same fields the row renders. */
- private static boolean matchesPullRequest(GhCliService.OpenPullRequest pullRequest, String query) {
+ private boolean matchesPullRequest(GhCliService.OpenPullRequest pullRequest) {
String text = "#" + pullRequest.number() + " " + pullRequest.title() + " " + pullRequest.headRefName();
- return text.toLowerCase(Locale.ROOT).contains(query);
+ return queryMatcher.matches(text);
}
/**
@@ -1814,17 +1966,17 @@ private void refreshWorktrees(Repository repository, boolean userInitiated) {
// the very first landing corrects any earlier scan that
// had to run before discovery had anything to dedup
// against (worktreeListChanged's `previous == null`
- // case). A collapsed repo's worktree discovery still
+ // case). A deselected repo's worktree discovery still
// runs as it always has, but must not cascade into a
- // `gh pr list` spawn for a row nobody is looking at --
+ // `gh pr list` spawn for a repo nobody is looking at --
// marked stale instead of rescanned, so ANY outcome it
// already holds is known to need a fresh scan without
// actually running one, and pullRequestScanDue (via
- // pullRequestsStale) picks it up the moment the repo is
- // next expanded. A repo NOT collapsed rescans right
- // away, same as before.
+ // pullRequestsStale) picks it up the moment the repo's
+ // subtab is next selected. The selected repo rescans
+ // right away, same as before.
if (worktreeListChanged(previous, worktrees)) {
- if (collapsed.contains(repository.id())) {
+ if (!isSelectedRepo(repository.id())) {
pullRequestsStale.add(repository.id());
} else {
refreshPullRequests(repository);
@@ -1846,12 +1998,13 @@ private void refreshWorktrees(Repository repository, boolean userInitiated) {
* its (now orphaned) id, but {@code rebuildTree} only ever iterates
* {@code repositoryManager.repositories()}, so it is simply never
* rendered. Always a no-op for a remote repository, which has no local
- * checkout to ask {@code gh} about. Four call sites: repository add and
- * repo-row expansion (both via {@link #rebuildTree()}, gated by {@link
- * #pullRequestScanDue}), the β³ rescan, and {@link #refreshWorktrees}'s
- * completion re-running this whenever the worktree list actually
- * changes AND the repo is expanded (a collapsed repo is marked {@link
- * #pullRequestsStale} instead -- see that method).
+ * checkout to ask {@code gh} about. Three call sites: repository add
+ * and subtab selection (both via {@link #rebuildTree()}, gated by
+ * {@link #pullRequestScanDue}), the β³ rescan, and {@link
+ * #refreshWorktrees}'s completion re-running this whenever the worktree
+ * list actually changes AND the repo's subtab is selected (a deselected
+ * repo is marked {@link #pullRequestsStale} instead -- see that
+ * method).
*
* A request arriving while a scan is already in flight is NOT
* simply dropped: {@link #shouldQueuePullRequestRescan} compares the
@@ -2318,11 +2471,6 @@ protected void updateItem(SidebarNode node, boolean empty) {
}
private StackPane buildRepoRow(Repository repository) {
- Label caret = new Label("βΆ");
- caret.getStyleClass().add("repo-caret");
- boolean expanded = getTreeItem() != null && getTreeItem().isExpanded();
- caret.setRotate(expanded ? 90 : 0);
-
Label name = new Label(repository.displayName());
name.getStyleClass().add("repo-name");
// Keep the truncation `name` had when it sat directly in the VBox:
@@ -2420,19 +2568,10 @@ private StackPane buildRepoRow(Repository repository) {
HBox actions = new HBox(2, rescan, newSession);
actions.setAlignment(Pos.CENTER_RIGHT);
- HBox row = new HBox(7, caret, text, count);
+ HBox row = new HBox(7, text, count);
row.getStyleClass().add("repo-row");
row.setAlignment(Pos.CENTER_LEFT);
- row.setOnMouseClicked(event -> {
- if (event.getButton() == MouseButton.PRIMARY && getTreeItem() != null) {
- boolean nowExpanded = !getTreeItem().isExpanded();
- getTreeItem().setExpanded(nowExpanded);
- RotateTransition rotate = new RotateTransition(Duration.seconds(0.12), caret);
- rotate.setToAngle(nowExpanded ? 90 : 0);
- rotate.play();
- event.consume();
- }
- });
+
// Diag-only override folded into the same binding, not a
// separate unbind path -- see diagForceHoverRow.
actions.visibleProperty().bind(hoverProperty().or(
diff --git a/app/src/main/java/app/drydock/ui/ShortcutsOverlay.java b/app/src/main/java/app/drydock/ui/ShortcutsOverlay.java
index ac1fa53b..968e005a 100644
--- a/app/src/main/java/app/drydock/ui/ShortcutsOverlay.java
+++ b/app/src/main/java/app/drydock/ui/ShortcutsOverlay.java
@@ -40,7 +40,7 @@ private record Section(String title, String[][] shortcuts) { }
{" (in the Explorer: back / forward along the trail)", ""},
{"Previous / next live session", "ββ / ββ"},
{"Toggle sidebar", "β0"},
- {"Filter repositories", "βF"},
+ {"Session search", "βK"},
{"Toggle theme", "ββ§L"},
{"Settings", "β,"},
{"Cancel / close", "Esc"},
diff --git a/app/src/main/java/app/drydock/ui/SidebarQuery.java b/app/src/main/java/app/drydock/ui/SidebarQuery.java
new file mode 100644
index 00000000..96c3269b
--- /dev/null
+++ b/app/src/main/java/app/drydock/ui/SidebarQuery.java
@@ -0,0 +1,125 @@
+package app.drydock.ui;
+
+import java.util.Locale;
+import java.util.function.Predicate;
+import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
+
+/**
+ * The sidebar filter's text matcher: compiles the raw filter field text once
+ * per rebuild into a {@link Predicate} that {@link RepositorySidebar}'s
+ * {@code matchesRepo}/{@code matchesNode}/{@code matchesPullRequest} consult
+ * instead of a plain {@code String.contains}. Three shapes, picked by syntax:
+ *
+ *
+ * - Substring (default, no special syntax) -- the historical
+ * behaviour: case-insensitive {@code contains}. Existing tests that
+ * type {@code "login"} and expect it to match {@code "login session"}
+ * keep passing untouched.
+ * - Glob -- when the query contains {@code *} or {@code ?} (and
+ * is not a regex form), translated to an equivalent regex with
+ * {@code *} → {@code .*} and {@code ?} → {@code .}, matched
+ * with {@code find} (so {@code *log*} matches anywhere, like substring
+ * does). Other regex metacharacters are escaped.
+ * - Regex -- when the query is wrapped in {@code /β¦/} or prefixed
+ * with {@code re:}, the body is compiled with {@code CASE_INSENSITIVE
+ * | DOTALL} and matched with {@code find}. An unparseable body falls
+ * back to a literal substring match of the body, never throwing --
+ * a typo in the filter must not blank the sidebar.
+ *
+ *
+ * Every shape is case-insensitive; the substring/glob paths lowercase
+ * both sides, the regex path uses the {@code CASE_INSENSITIVE} flag. A
+ * {@code null}/blank raw query yields {@link #matchAll()}.
+ */
+final class SidebarQuery {
+
+ private final Predicate test;
+ private final boolean trivial;
+
+ private SidebarQuery(Predicate test, boolean trivial) {
+ this.test = test;
+ this.trivial = trivial;
+ }
+
+ /** The matcher that accepts everything (no filter text). */
+ static SidebarQuery matchAll() {
+ return new SidebarQuery(text -> true, true);
+ }
+
+ /** Compiles {@code raw} (the filter field's exact text) into a matcher. */
+ static SidebarQuery of(String raw) {
+ String query = raw == null ? "" : raw.strip();
+ if (query.isEmpty()) {
+ return matchAll();
+ }
+ String regexBody = regexBody(query);
+ if (regexBody != null) {
+ try {
+ Pattern pattern = Pattern.compile(regexBody, Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
+ return new SidebarQuery(text -> text != null && pattern.matcher(text).find(), false);
+ } catch (PatternSyntaxException e) {
+ return substring(regexBody);
+ }
+ }
+ if (hasGlobMeta(query)) {
+ try {
+ Pattern pattern = Pattern.compile(globToRegex(query), Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
+ return new SidebarQuery(text -> text != null && pattern.matcher(text).find(), false);
+ } catch (PatternSyntaxException e) {
+ return substring(query);
+ }
+ }
+ return substring(query);
+ }
+
+ /** Whether {@code text} matches the compiled query. {@code null} text never matches. */
+ boolean matches(String text) {
+ return text != null && test.test(text);
+ }
+
+ /** Whether this matcher is the no-filter {@link #matchAll()} (no text typed). */
+ boolean isTrivial() {
+ return trivial;
+ }
+
+ /** Case-insensitive substring, the historical default. */
+ private static SidebarQuery substring(String query) {
+ String needle = query.toLowerCase(Locale.ROOT);
+ return new SidebarQuery(text -> text != null && text.toLowerCase(Locale.ROOT).contains(needle), false);
+ }
+
+ /** Returns the regex body if {@code query} selects the regex form, else {@code null}. */
+ private static String regexBody(String query) {
+ if (query.startsWith("re:")) {
+ return query.substring(3);
+ }
+ if (query.length() >= 2 && query.startsWith("/") && query.endsWith("/")) {
+ return query.substring(1, query.length() - 1);
+ }
+ return null;
+ }
+
+ private static boolean hasGlobMeta(String query) {
+ return query.indexOf('*') >= 0 || query.indexOf('?') >= 0;
+ }
+
+ /** Translates a glob ({@code *}/{@code ?}) to a regex, escaping every other regex metacharacter. */
+ private static String globToRegex(String glob) {
+ StringBuilder out = new StringBuilder(glob.length() * 2);
+ for (int i = 0; i < glob.length(); i++) {
+ char c = glob.charAt(i);
+ switch (c) {
+ case '*' -> out.append(".*");
+ case '?' -> out.append('.');
+ default -> {
+ if ("\\.[]{}()+^$|/".indexOf(c) >= 0) {
+ out.append('\\');
+ }
+ out.append(c);
+ }
+ }
+ }
+ return out.toString();
+ }
+}
diff --git a/app/src/main/resources/app/drydock/ui/app.css b/app/src/main/resources/app/drydock/ui/app.css
index d5563883..bc692eb4 100644
--- a/app/src/main/resources/app/drydock/ui/app.css
+++ b/app/src/main/resources/app/drydock/ui/app.css
@@ -190,6 +190,40 @@
-fx-background-insets: 0;
-fx-padding: 0 6 0 6;
}
+
+/* Per-repository subtab strip: one wrapping button per repo above the tree.
+ * FlowPane wraps the buttons across rows; each button's label wraps inside
+ * its bounded width so a long repo name takes as many rows as it needs. */
+.repo-tab-strip {
+ -fx-padding: 6 8 2 8;
+ -fx-background-color: -drydock-sidebar;
+}
+.repo-tab {
+ -fx-background-color: -drydock-input-bg;
+ -fx-background-radius: 8px;
+ -fx-border-color: -drydock-border;
+ -fx-border-radius: 8px;
+ -fx-border-width: 1;
+ -fx-text-fill: -drydock-text;
+ -fx-font-size: 11.5px;
+ -fx-padding: 4 8 4 8;
+ -fx-cursor: hand;
+ -fx-alignment: center-left;
+}
+.repo-tab:hover {
+ -fx-background-color: -drydock-hover;
+}
+.repo-tab:selected {
+ -fx-background-color: -drydock-active-bg;
+ -fx-border-color: -drydock-accent;
+ -fx-font-weight: 600;
+}
+.repo-tab > .label {
+ -fx-text-fill: -drydock-text;
+}
+.repo-tab:selected > .label {
+ -fx-text-fill: -drydock-text;
+}
.repo-tree .tree-cell {
-fx-background-color: transparent;
-fx-padding: 0;
diff --git a/app/src/test/java/app/drydock/ui/RepositorySidebarPullRequestDedupFxTest.java b/app/src/test/java/app/drydock/ui/RepositorySidebarPullRequestDedupFxTest.java
index 5780e8b3..49e7f22c 100644
--- a/app/src/test/java/app/drydock/ui/RepositorySidebarPullRequestDedupFxTest.java
+++ b/app/src/test/java/app/drydock/ui/RepositorySidebarPullRequestDedupFxTest.java
@@ -202,56 +202,63 @@ void aWorktreeCheckingOutAListedPrMakesItsRowDisappear() throws Exception {
}
/**
- * B1, and the laziness N2 restored: a repo's worktree list changing
- * while its row is collapsed must not spawn an automatic PR scan (that
- * would be a {@code gh pr list} network spawn for a row nobody is
- * looking at), but the resulting stale outcome must not be stranded
- * either -- expanding the row has to notice and rescan. Without B1,
- * {@code needsPullRequestScan} alone cannot tell "scanned" from
- * "scanned, but a worktree appeared since" apart, so the mark from the
- * collapsed skip is the only thing that can recover it.
+ * B1, and the laziness N2 restored: a repo whose subtab is NOT selected
+ * is a repo nobody is looking at, so its worktree list changing must not
+ * spawn an automatic PR scan (that would be a {@code gh pr list} network
+ * spawn for a repo nobody is looking at), but the resulting stale
+ * outcome must not be stranded either -- reselecting its subtab has to
+ * notice and rescan. Without B1, {@code needsPullRequestScan} alone
+ * cannot tell "scanned" from "scanned, but a worktree appeared since"
+ * apart, so the mark from the not-selected skip is the only thing that
+ * can recover it.
+ *
+ * Driven through the workspace seam ({@link
+ * RepositorySidebar#refreshWorktreesFor}) rather than the β³ button: the
+ * rescan button lives on the repo row, which a deselected repo does not
+ * show, so there is no button to click while "not looking". The seam
+ * is exactly the path a real materialization takes to land a new
+ * worktree.
*/
@Test
- void aRepoThatChangesWorktreesWhileCollapsedSelfHealsOnExpand() throws Exception {
+ void aRepoThatChangesWorktreesWhileNotSelectedSelfHealsOnReselect() throws Exception {
awaitCallCount(1, "the first PR scan");
source.complete(0, listing(pr(7, "Fix login", "pr-7")));
WaitForAsyncUtils.waitForFxEvents();
assertTrue(groupRowPresent(), "PR #7 has no worktree yet: its group row must be present");
- interact(() -> sidebar.diagSetRepoExpanded(repository.id(), false)); // collapse
+ // Deselect the repo's subtab: nobody is looking at it now.
+ interact(() -> sidebar.diagSetRepoExpanded(repository.id(), false));
git(repoRoot, "branch", "pr-7");
- Path worktreePath = newWorktreePath("pr-7-collapsed-worktree");
+ Path worktreePath = newWorktreePath("pr-7-deselected-worktree");
git(repoRoot, "worktree", "add", worktreePath.toString(), "pr-7");
- clickRescan();
- awaitCallCount(2, "the rescan's own direct PR scan (fired by the click regardless of collapse)");
- source.complete(1, listing(pr(7, "Fix login", "pr-7")));
- WaitForAsyncUtils.waitForFxEvents();
+ // Drive the worktree rescan through the workspace seam -- the β³
+ // button lives on the repo row, which a deselected repo does not show.
+ interact(() -> sidebar.refreshWorktreesFor(repository));
// N2: the worktree rescan's completion notices the list changed
- // but must NOT spawn a third scan while the repo stays collapsed.
+ // but must NOT spawn a second scan while the repo stays deselected.
// Wait on the observable rather than on the clock: once the view
// model holds the new list, the runLater that wrote it -- and
- // therefore the collapsed-vs-rescan decision it makes right
+ // therefore the selected-vs-stale decision it makes right
// afterwards, in the same FX task -- has already run to completion
- // (viewModelSeesPr7Worktree drains the FX queue). A fixed sleep
- // instead passes vacuously on any machine where `git worktree list`
- // outlasts it.
+ // (viewModelSeesPr7Worktree drains the FX queue).
awaitCondition(this::viewModelSeesPr7Worktree,
- "the collapsed repo's worktree rescan landing its new list");
- assertEquals(2, source.callCount(),
- "a collapsed repo's worktree change must not spawn an automatic PR scan on its own (N2)");
+ "the deselected repo's worktree rescan landing its new list");
+ assertEquals(1, source.callCount(),
+ "a deselected repo's worktree change must not spawn an automatic PR scan on its own (N2)");
- interact(() -> sidebar.diagSetRepoExpanded(repository.id(), true)); // expand
+ // Reselect the subtab.
+ interact(() -> sidebar.diagSetRepoExpanded(repository.id(), true));
- // B1: expanding must notice the outcome is stale (marked so by the
- // collapsed skip above) and rescan -- otherwise PR #7 keeps a row
- // despite now having a local worktree, forever.
- awaitCallCount(3, "the rescan B1 fires on expand for a repo marked stale while collapsed");
- assertTrue(hasPr7(worktreeListAt(2)),
- "the expand-triggered rescan must use the worktree list that already includes pr-7");
- source.complete(2, listing(pr(7, "Fix login", "pr-7")));
+ // B1: reselecting must notice the outcome is stale (marked so by
+ // the not-selected skip above) and rescan -- otherwise PR #7 keeps
+ // a row despite now having a local worktree, forever.
+ awaitCallCount(2, "the rescan B1 fires on reselect for a repo marked stale while not selected");
+ assertTrue(hasPr7(worktreeListAt(1)),
+ "the reselect-triggered rescan must use the worktree list that already includes pr-7");
+ source.complete(1, listing(pr(7, "Fix login", "pr-7")));
WaitForAsyncUtils.waitForFxEvents();
awaitCondition(() -> !groupRowPresent(), "the group row disappearing once the dedup sees the new worktree");