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
2 changes: 1 addition & 1 deletion app/src/main/java/app/drydock/DrydockApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
533 changes: 336 additions & 197 deletions app/src/main/java/app/drydock/ui/RepositorySidebar.java

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion app/src/main/java/app/drydock/ui/ShortcutsOverlay.java
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
125 changes: 125 additions & 0 deletions app/src/main/java/app/drydock/ui/SidebarQuery.java
Original file line number Diff line number Diff line change
@@ -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:
*
* <ul>
* <li><b>Substring</b> (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.</li>
* <li><b>Glob</b> -- when the query contains {@code *} or {@code ?} (and
* is not a regex form), translated to an equivalent regex with
* {@code *} &rarr; {@code .*} and {@code ?} &rarr; {@code .}, matched
* with {@code find} (so {@code *log*} matches anywhere, like substring
* does). Other regex metacharacters are escaped.</li>
* <li><b>Regex</b> -- 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.</li>
* </ul>
*
* <p>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()}.</p>
*/
final class SidebarQuery {

private final Predicate<String> test;
private final boolean trivial;

private SidebarQuery(Predicate<String> 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();
}
}
34 changes: 34 additions & 0 deletions app/src/main/resources/app/drydock/ui/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>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");
Expand Down
Loading