Skip to content

Commit 2befe5f

Browse files
committed
Keep the dock's search filter when you pick a workspace out of it
Filtering the orchestrator dock to a handful of workspaces and then clicking one wiped the search: the list sprang back to every workspace and the box reset to its placeholder, so working through a filtered set meant retyping the needle for every workspace opened. The dock clears its filter on blur so a stale needle can't silently hide sessions on the next focus (F5). But a click — or Enter — on a row blurs the dock only to hand the keyboard to the chosen session: it is acting on the filtered list, not leaving it. Mark those dive paths so the blur handler keeps the filter, and leave every other blur (Esc, an editor click) clearing it as before, which keeps the one-key escape from a stale filter intact. Also adds an interactive tmux reproducer covering both gestures.
1 parent 5c99886 commit 2befe5f

3 files changed

Lines changed: 306 additions & 1 deletion

File tree

crates/fresh-editor/plugins/orchestrator.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -820,6 +820,16 @@ function dockContentCols(dockWidth: number): number {
820820
// (dispatch_floating_widget_key) reads the panel focus directly to route
821821
// Enter/Esc/Space//'; this mirror is informational for the plugin.
822822
let dockFocus: "list" | "filter" = "list";
823+
// Set just before the dock blurs *because the user picked a workspace*
824+
// (a row click, Enter on the list, or attaching a discovered worktree
825+
// with `dive`). The `blur` handler clears the search filter so a stale
826+
// needle can't silently hide sessions on the next focus (F5) — but a
827+
// dive isn't "leaving the dock", it's acting on the very list the
828+
// filter produced. Wiping the needle there forces a retype for every
829+
// workspace picked out of a search. Consumed (and reset) by the next
830+
// `blur`; also reset on `focus` so an unconsumed flag can't leak into a
831+
// later, unrelated blur.
832+
let dockDiveBlur = false;
823833
// Full focused-widget mirror for the open dialog (both dock and
824834
// centered-picker modes). Updated from every `focus` widget_event.
825835
// Used by `toggleSelectCurrent` so a Space keypress while focus is
@@ -5051,6 +5061,9 @@ function diveDockSelectionFromClick(fromEdge: "top" | "bottom" | null): void {
50515061
else editor.setActiveWindow(id);
50525062
}
50535063
// Hand keyboard focus to the activated window (mirror `dock_activate`).
5064+
// Picking a row is not "leaving the dock", so the search filter that
5065+
// produced this row survives the blur (see `dockDiveBlur`).
5066+
dockDiveBlur = true;
50545067
dockBlurred = true;
50555068
editor.floatingPanelControl(openPanel.id(), "blur", 0);
50565069
editor.setEditorMode(null);
@@ -8978,6 +8991,8 @@ async function attachToWorktree(opts: {
89788991
// (arrow-nav, row click) deliberately keep the dock focused, exactly
89798992
// like switching to a live session does.
89808993
if (opts.dive && dockMode && openPanel) {
8994+
// A dive out of the dock keeps the search filter (see `dockDiveBlur`).
8995+
dockDiveBlur = true;
89818996
dockBlurred = true;
89828997
editor.floatingPanelControl(openPanel.id(), "blur", 0);
89838998
editor.setEditorMode(null);
@@ -9807,6 +9822,8 @@ editor.on("widget_event", (e) => {
98079822
// leave, editor click, or an unhandled chord like Ctrl+P). The
98089823
// dock stays visible; the host stops routing keys to it.
98099824
if (dockMode) {
9825+
const wasDive = dockDiveBlur;
9826+
dockDiveBlur = false;
98109827
dockBlurred = true;
98119828
// Leaving the dock also closes the project dropdown so it
98129829
// doesn't linger over the blurred dock.
@@ -9816,7 +9833,13 @@ editor.on("widget_event", (e) => {
98169833
// "/gamma") otherwise silently hides sessions on the next
98179834
// focus, with only the filter box as a clue — and there is no
98189835
// one-key clear from the list. (See F5.)
9819-
if (openDialog.filter.value !== "") {
9836+
//
9837+
// A *dive* is the exception: clicking a row (or Enter on it) is
9838+
// acting on the filtered list, not abandoning it, so the needle
9839+
// stays put and the next workspace can be picked out of the same
9840+
// search without retyping it. Esc/editor-click still clear, so
9841+
// the one-key escape from a stale filter is unchanged.
9842+
if (!wasDive && openDialog.filter.value !== "") {
98209843
openDialog.filter.value = "";
98219844
openDialog.filter.cursor = 0;
98229845
dockFocus = "list";
@@ -9849,6 +9872,9 @@ editor.on("widget_event", (e) => {
98499872
pickerFocusKey = e.widget_key;
98509873
}
98519874
if (dockMode) {
9875+
// A dive that never produced a `blur` (already blurred, say)
9876+
// must not leave the flag armed for the next, unrelated one.
9877+
dockDiveBlur = false;
98529878
const wasBlurred = dockBlurred;
98539879
dockBlurred = false;
98549880
dockFocus = e.widget_key === "filter" ? "filter" : "list";
@@ -9916,6 +9942,9 @@ editor.on("widget_event", (e) => {
99169942
if (typeof id === "number" && id > 0 && id !== editor.activeWindow()) {
99179943
editor.setActiveWindow(id);
99189944
}
9945+
// Same as the row click: the filter that surfaced this row is kept
9946+
// across the dive (see `dockDiveBlur`).
9947+
dockDiveBlur = true;
99199948
dockBlurred = true;
99209949
editor.floatingPanelControl(openPanel.id(), "blur", 0);
99219950
editor.setEditorMode(null);

crates/fresh-editor/tests/e2e/orchestrator_dock.rs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1767,6 +1767,88 @@ fn dock_filter_clears_when_focus_leaves_so_reentry_shows_all() {
17671767
h.assert_screen_contains("Search Tasks");
17681768
}
17691769

1770+
/// The F5 clear-on-leave must NOT fire when the user *picks* a workspace
1771+
/// out of the filtered list. Enter on a row (and a click on one) blurs the
1772+
/// dock to hand the keyboard to the chosen session — a dive, not a
1773+
/// departure — so the search that produced the row has to survive it.
1774+
/// Wiping it there meant retyping the needle for every workspace opened
1775+
/// out of one search, which is the common case with a long list.
1776+
#[test]
1777+
fn dock_filter_survives_diving_into_a_filtered_workspace() {
1778+
let (_tmp, root) = setup_project("alphaproj");
1779+
let mut h =
1780+
EditorTestHarness::with_config_and_working_dir(120, 32, Default::default(), root.clone())
1781+
.unwrap();
1782+
h.editor_mut()
1783+
.create_window_at(root.join("wt-beta"), "beta".to_string());
1784+
h.editor_mut()
1785+
.create_window_at(root.join("wt-gamma"), "gamma".to_string());
1786+
h.render().unwrap();
1787+
open_dock(&mut h);
1788+
h.wait_until(|h| {
1789+
let s = h.screen_to_string();
1790+
s.contains("beta") && s.contains("gamma")
1791+
})
1792+
.unwrap();
1793+
1794+
// Filter to "gamma"; "beta" drops out of the list.
1795+
h.send_key(KeyCode::Char('/'), KeyModifiers::NONE).unwrap();
1796+
h.type_text("gamma").unwrap();
1797+
h.wait_until(|h| !h.screen_to_string().contains("beta"))
1798+
.unwrap();
1799+
1800+
// Enter returns to the list, a second Enter dives into the highlighted
1801+
// "gamma" row — the dock blurs, handing the keyboard to that session.
1802+
h.send_key(KeyCode::Enter, KeyModifiers::NONE).unwrap();
1803+
h.send_key(KeyCode::Enter, KeyModifiers::NONE).unwrap();
1804+
h.wait_until(|h| !h.editor().is_dock_focused()).unwrap();
1805+
1806+
// The dive must leave the filter alone: "beta" stays hidden and the
1807+
// box still reads "gamma" (an emptied box shows the "Search Tasks"
1808+
// placeholder instead).
1809+
let after_dive = h.screen_to_string();
1810+
assert!(
1811+
!after_dive.contains("beta"),
1812+
"diving into a filtered workspace must keep the filter applied, but the \
1813+
filtered-out 'beta' row came back:\n{after_dive}"
1814+
);
1815+
assert!(
1816+
!after_dive.contains("Search Tasks"),
1817+
"diving into a filtered workspace must keep the search text, but the box \
1818+
fell back to its placeholder:\n{after_dive}"
1819+
);
1820+
1821+
// Re-focusing the dock finds the same filtered list, so the next
1822+
// workspace can be picked out of the search without retyping it.
1823+
h.send_key(KeyCode::Char('o'), KeyModifiers::ALT).unwrap();
1824+
h.wait_until(|h| h.editor().is_dock_focused()).unwrap();
1825+
let refocused = h.screen_to_string();
1826+
assert!(
1827+
!refocused.contains("beta"),
1828+
"re-entering the dock after a dive must show the still-filtered list:\n{refocused}"
1829+
);
1830+
1831+
// A click on a row is the same gesture as Enter, and keeps the filter
1832+
// for the same reason. (The row's own line, not the filter box, which
1833+
// also spells the needle — that one shares its row with "New Task".)
1834+
let gamma_row =
1835+
h.screen_to_string()
1836+
.lines()
1837+
.position(|l| l.contains("gamma") && !l.contains("New Task"))
1838+
.unwrap_or_else(|| panic!("no 'gamma' row:\n{}", h.screen_to_string())) as u16;
1839+
h.mouse_click(3, gamma_row).unwrap();
1840+
h.wait_until(|h| !h.editor().is_dock_focused()).unwrap();
1841+
let after_click = h.screen_to_string();
1842+
assert!(
1843+
!after_click.contains("beta"),
1844+
"clicking a filtered workspace must keep the filter applied:\n{after_click}"
1845+
);
1846+
assert!(
1847+
!after_click.contains("Search Tasks"),
1848+
"clicking a filtered workspace must keep the search text:\n{after_click}"
1849+
);
1850+
}
1851+
17701852
/// F6: the auto-generated session name is rooted in the project
17711853
/// (`<project>-N`) rather than a bare `session-N`, so a dock row tells
17721854
/// you which project a session belongs to.
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Interactive tmux repro — Orchestrator dock: does selecting a workspace from
4+
# a filtered list wipe the filter?
5+
#
6+
# Lays out a project with a handful of workspaces (created from an isolated
7+
# init.ts so the dock has enough rows for a filter to matter), opens the dock,
8+
# types a search needle, then activates a matching row two ways:
9+
#
10+
# 1. Enter on the highlighted row ("dive in")
11+
# 2. A real mouse click on a row (SGR mouse report)
12+
#
13+
# After each, it re-focuses the dock and reports whether the needle survived.
14+
# Expected after the fix: the filter is still applied and the search box still
15+
# reads the needle — selecting a workspace is not "leaving the dock".
16+
#
17+
# Usage: scripts/interactive/dock_filter_select_tmux_repro.sh
18+
# Requires: tmux. Run from the repo root.
19+
20+
set -uo pipefail
21+
22+
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
23+
SESSION="fresh-dock-filter"
24+
BIN="$REPO/target/debug/fresh"
25+
OUT="$(mktemp -d /tmp/fresh-dock-filter-out.XXXX)"
26+
WORK="$(mktemp -d /tmp/fresh-dock-filter-work.XXXX)"
27+
PASS=0
28+
FAIL=0
29+
30+
cleanup() { tmux kill-session -t "$SESSION" 2>/dev/null || true; }
31+
trap cleanup EXIT
32+
33+
note() { printf '\033[36m• %s\033[0m\n' "$*"; }
34+
pass() { printf '\033[32m PASS\033[0m %s\n' "$*"; PASS=$((PASS + 1)); }
35+
fail() { printf '\033[31m FAIL\033[0m %s\n' "$*"; FAIL=$((FAIL + 1)); }
36+
37+
S() { tmux send-keys -t "$SESSION" "$@"; }
38+
typ() { tmux send-keys -t "$SESSION" -l "$1"; }
39+
cap() { tmux capture-pane -t "$SESSION" -p; }
40+
shot() { cap > "$OUT/$1.txt"; }
41+
42+
wait_for() { # pattern timeout_iters
43+
local pat="$1" n="${2:-30}" i=0
44+
while [ "$i" -lt "$n" ]; do
45+
cap | grep -qE "$pat" && return 0
46+
sleep 0.5
47+
i=$((i + 1))
48+
done
49+
return 1
50+
}
51+
52+
# Click cell (1-based col,row) with an SGR mouse press+release.
53+
click() { # col row
54+
tmux send-keys -t "$SESSION" -l $'\033[<0;'"$1"';'"$2"'M'
55+
tmux send-keys -t "$SESSION" -l $'\033[<0;'"$1"';'"$2"'m'
56+
}
57+
58+
# 1-based pane row of the first line matching $1 (empty if absent).
59+
row_of() { cap | grep -nE "$1" | head -1 | cut -d: -f1; }
60+
61+
# --- 0. build + workspace ----------------------------------------------------
62+
[ -x "$BIN" ] || { note "building fresh (debug)…"; (cd "$REPO" && cargo build --bin fresh) || exit 2; }
63+
64+
note "workspace: $WORK"
65+
export HOME="$WORK/home"
66+
mkdir -p "$HOME/.config/fresh"
67+
mkdir -p "$WORK/proj" && (cd "$WORK/proj" && git init -q && git config user.email t@t && git config user.name t && printf 'root\n' > README.md && git add -A && git commit -qm init)
68+
for w in alpha beta gamma delta epsilon zeta eta theta; do
69+
mkdir -p "$WORK/proj/$w" && printf '%s\n' "$w" > "$WORK/proj/$w/notes.md"
70+
done
71+
72+
# Enough workspaces that the filter has something to hide.
73+
cat > "$HOME/.config/fresh/init.ts" <<TS
74+
for (const w of ["alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta", "theta"]) {
75+
editor.createWindow("$WORK/proj/" + w, w);
76+
}
77+
TS
78+
79+
# --- 1. launch ---------------------------------------------------------------
80+
cleanup
81+
tmux new-session -d -s "$SESSION" -x 200 -y 50
82+
S "cd '$WORK/proj' && HOME='$HOME' TERM=xterm-256color '$BIN' --no-restore README.md" Enter
83+
wait_for "Palette: Ctrl\+P" 60 || { note "editor did not start"; cap; exit 2; }
84+
sleep 1
85+
shot "00-boot"
86+
87+
# --- 2. open + focus the dock ------------------------------------------------
88+
note "opening the orchestrator dock (Alt+O)"
89+
S M-o
90+
wait_for "gamma" 30 || { note "dock did not show the workspaces"; cap; exit 2; }
91+
sleep 0.5
92+
shot "01-dock-open"
93+
94+
if cap | grep -q "epsilon" && cap | grep -q "gamma"; then
95+
pass "dock lists the workspaces"
96+
else
97+
fail "dock is missing workspaces"
98+
fi
99+
100+
# --- 3. filter ---------------------------------------------------------------
101+
filter_to() { # needle
102+
note "filtering to '$1'"
103+
S "/"
104+
sleep 0.4
105+
typ "$1"
106+
sleep 1
107+
}
108+
109+
filter_to "gamma"
110+
shot "02-filtered"
111+
if cap | grep -q "gamma" && ! cap | grep -q "epsilon"; then
112+
pass "filter narrows the list to 'gamma'"
113+
else
114+
fail "filter did not narrow the list"
115+
fi
116+
117+
# Enter in the filter box returns to the list (filter still applied).
118+
S Enter
119+
sleep 0.6
120+
shot "03-filter-enter-back-to-list"
121+
122+
# --- 4a. select with Enter ---------------------------------------------------
123+
note "selecting the highlighted workspace with Enter (dive in)"
124+
S Enter
125+
sleep 1.2
126+
shot "04-after-enter-dive"
127+
note "re-focusing the dock (Alt+O)"
128+
S M-o
129+
sleep 1
130+
shot "05-dock-refocused-after-enter"
131+
if cap | grep -q "epsilon"; then
132+
fail "Enter-select wiped the filter (non-matching 'epsilon' is back)"
133+
else
134+
pass "Enter-select kept the filter (non-matching rows still hidden)"
135+
fi
136+
if cap | grep -q "Search Tasks"; then
137+
fail "Enter-select cleared the search box (placeholder is showing)"
138+
else
139+
pass "Enter-select kept the search box text"
140+
fi
141+
142+
# --- 4b. select with a mouse click ------------------------------------------
143+
# Reset: leave the dock (Esc) so we start from a clean, unfiltered list.
144+
S Escape
145+
sleep 0.8
146+
S M-o
147+
sleep 0.8
148+
filter_to "delta"
149+
S Enter
150+
sleep 0.6
151+
shot "06-filtered-delta"
152+
153+
DROW="$(row_of ' *delta')"
154+
if [ -n "$DROW" ]; then
155+
note "clicking the 'delta' row at pane row $DROW"
156+
click 8 "$DROW"
157+
sleep 1.2
158+
shot "07-after-click"
159+
note "re-focusing the dock (Alt+O)"
160+
S M-o
161+
sleep 1
162+
shot "08-dock-refocused-after-click"
163+
if cap | grep -q "epsilon"; then
164+
fail "click-select wiped the filter (non-matching 'epsilon' is back)"
165+
else
166+
pass "click-select kept the filter (non-matching rows still hidden)"
167+
fi
168+
if cap | grep -q "Search Tasks"; then
169+
fail "click-select cleared the search box (placeholder is showing)"
170+
else
171+
pass "click-select kept the search box text"
172+
fi
173+
else
174+
fail "could not locate the 'delta' row to click"
175+
fi
176+
177+
# --- 4c. Esc still clears ----------------------------------------------------
178+
note "leaving the dock with Esc must still clear the filter"
179+
S Escape
180+
sleep 0.8
181+
S M-o
182+
sleep 1
183+
shot "09-dock-after-esc"
184+
if cap | grep -q "epsilon"; then
185+
pass "Esc out of the dock clears the filter (full list is back)"
186+
else
187+
fail "Esc out of the dock did not clear the filter"
188+
fi
189+
190+
# --- 5. summary --------------------------------------------------------------
191+
echo
192+
note "pane snapshots saved under: $OUT"
193+
printf '\033[1mRESULT: %d passed, %d failed\033[0m\n' "$PASS" "$FAIL"
194+
[ "$FAIL" -eq 0 ]

0 commit comments

Comments
 (0)