Skip to content

Commit 4fa6b9e

Browse files
committed
Give a dock workspace card two rows that say two different things
The dock's cards were three rows tall and spent most of that space repeating themselves. The name row carried a project tag next to a name usually derived from that same project; the middle row put the branch and the git summary into a *single* right-aligned group, so the branch floated in the middle of the card instead of starting at its left edge; and the third row was the PR badge, blank on every workspace without a PR. Five screen rows per workspace, for about two rows of information. A card is now two rows, each one "what it is" on the left and "how it's doing" flush right, so the status of every workspace lines up in one column down the dock: ╭──────────────────────────────╮ │ * fresh-27 ↑2 +14 │ │ ▸ fix/wrapped-nav PR #2784 │ ╰──────────────────────────────╯ The second row never repeats the first: the branch takes it only when it differs from the workspace name (a worktree's branch usually *is* the name), otherwise the project does, and when that would echo the name too — a plain folder opened as its own workspace — the row stays empty. `(detached)` is now reserved for a real repo with no branch instead of labelling every non-git folder. A being-created workspace keeps its whole status message on row two, with the retry key moved up to row one's right slot. The alignment itself is host-side: a card row can now carry `align: "between"` plus the byte offset where its two groups meet, and `render_tree_card` — which alone knows the card's real width, responsive or dragged — inserts the gap, always keeping at least one column so the groups can't run together on a narrow dock.
1 parent 2befe5f commit 4fa6b9e

5 files changed

Lines changed: 481 additions & 81 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ Keyboard and mouse input is now parsed by our own `fresh-input-parser` crate ins
3434
* Codex "Auto mode" works again (it was passing a flag recent Codex CLI rejects).
3535
* Dock rows are fully clickable in compact (list) view, ordered by recency, and auto-name themselves from their terminal.
3636
* Fixed a crash when navigating to an unreachable remote workspace.
37+
* Searching the dock and then opening one of the matches no longer wipes the search — the filtered list is still there when you come back for the next one. Leaving the dock (`Esc`, clicking the editor) still clears it.
38+
* **Tidier workspace cards** - a card is now two rows instead of three: name with its git summary flush right, then the branch with the PR badge flush right. The branch starts at the card's left edge rather than floating mid-row, the empty third row is gone, and a branch that just repeats the workspace name gives its place to the project.
3739
* **Terminal**
3840
* Scrollback no longer loses output or gets stuck mid-scroll (#2649, reported by @dmknght).
3941
* **Tabs & splits**

crates/fresh-editor/plugins/orchestrator.ts

Lines changed: 132 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1368,17 +1368,69 @@ function sessionNodeEntry(id: number, activeId: number): TextPropertyEntry {
13681368
}
13691369

13701370
// The dock's "card" density renders each session leaf as a fixed
1371-
// 3-content-row card inside the tree; with `cardBorders` the host
1372-
// wraps those rows in a rounded `╭─…─╮` border (5 screen rows total),
1373-
// restoring the modal picker's pill look: line 1 = glyph · [facet] ·
1374-
// NAME + project; line 2 = branch + git summary (right-aligned against
1375-
// the card border); line 3 = PR badge (blank when none).
1376-
const DOCK_CARD_HEIGHT = 3;
1371+
// 2-content-row card inside the tree; with `cardBorders` the host wraps
1372+
// those rows in a rounded `╭─…─╮` border (4 screen rows total). Two
1373+
// rows, each one "what it is" on the left and "how it's doing" flush
1374+
// right, so the status columns line up down the dock:
1375+
//
1376+
// ╭──────────────────────────────╮
1377+
// │ * fresh-27 ↑2 +14 │ name git
1378+
// │ ▸ fix/wrapped-nav PR #2784 │ branch / project PR
1379+
// ╰──────────────────────────────╯
1380+
//
1381+
// It was three rows: the third held the PR badge and stood empty on
1382+
// every session without one, and the branch shared the middle row with
1383+
// the git summary as a single right-aligned group — which left it
1384+
// floating in the middle of the card instead of starting at the left
1385+
// edge. The name-repeating branch (a worktree branch usually *is* the
1386+
// workspace name) and the project tag beside the name were dropped for
1387+
// the same reason: they spent a card's scarce width on what the row
1388+
// already said.
1389+
const DOCK_CARD_HEIGHT = 2;
1390+
1391+
// A card row split into a left group and a right group flush against
1392+
// the card's right border. Tree card rows are plain text entries (no
1393+
// host flex spacer like the modal pill's `flexLine`), so the split
1394+
// point rides along as a byte offset and the host's `render_tree_card`
1395+
// — which knows the card's *actual* inner width, responsive or dragged
1396+
// — inserts the gap. Plugin-side padding could only estimate that width
1397+
// and drifted at every other one.
1398+
function cardSplitRow(left: Entry[], right: Entry[]): TextPropertyEntry {
1399+
const row = styledRow([...left, ...right] as Parameters<typeof styledRow>[0]);
1400+
if (right.length === 0) return row;
1401+
row.properties = {
1402+
align: "between",
1403+
splitByte: left.reduce((n, e) => n + utf8Len(e.text), 0),
1404+
};
1405+
return row;
1406+
}
1407+
1408+
// Truncate to `cols` display columns, ellipsising when it doesn't fit.
1409+
// Code-point aware (`Array.from`), so a multi-byte name can't be cut
1410+
// mid-character.
1411+
function capText(s: string, cols: number): string {
1412+
const chars = Array.from(s);
1413+
if (chars.length <= cols) return s;
1414+
return chars.slice(0, Math.max(1, cols - 1)).join("") + "…";
1415+
}
1416+
1417+
// Columns a group of entries occupies, for the left-group cap below.
1418+
function entriesWidth(entries: Entry[]): number {
1419+
return entries.reduce((n, e) => n + Array.from(e.text).length, 0);
1420+
}
1421+
1422+
// Inner width of a card at the dock's default width — an estimate (the
1423+
// dock can be dragged), used only to cap the branch so it doesn't shove
1424+
// the right-hand group off the row. The host does the exact alignment.
1425+
function cardInnerColsEstimate(): number {
1426+
return Math.max(12, dockContentCols(dockDefaultWidth()) - 2);
1427+
}
13771428

13781429
// Card line 1 (the tree node's primary text): state glyph, optional
1379-
// remote facet, the name (highlighted when active), then a dim project
1380-
// tag. Distinct from the compact `sessionNodeEntry`, which trails the
1381-
// branch on the single line instead of the project.
1430+
// remote facet, the name (highlighted when active) and — for a remote
1431+
// session — its backend target, with the git summary flush right.
1432+
// Distinct from the compact `sessionNodeEntry`, which trails the git
1433+
// summary on the single line it has.
13821434
function sessionCardPrimary(id: number, activeId: number): TextPropertyEntry {
13831435
const s = orchestratorSessions.get(id);
13841436
if (!s) return styledRow([{ text: editor.t("pill.unknown") }]);
@@ -1394,61 +1446,87 @@ function sessionCardPrimary(id: number, activeId: number): TextPropertyEntry {
13941446
text: s.label,
13951447
style: { fg: isActive ? "ui.help_key_fg" : undefined, bold: true },
13961448
});
1397-
const proj = editor.pathBasename(projectKeyOf(s));
1398-
segs.push({ text: " " + PROJECT_ICON + " ", style: { fg: "ui.menu_disabled_fg" } });
1399-
segs.push({ text: proj, style: { fg: "ui.menu_disabled_fg", italic: true } });
14001449
// A remote session surfaces its backend target (host / ns·pod) coloured
14011450
// by the connection state — pill parity (the pill shows it at the right
1402-
// end of line 1). The discovered "· on-disk" tag is NOT repeated here:
1403-
// the card's third line (prLineEntries) already carries it, exactly like
1404-
// the pill.
1451+
// end of line 1).
14051452
if (s.remote) {
14061453
segs.push({
14071454
text: " " + s.remote.detail,
14081455
style: { fg: remoteStateFg(s.remote.state), italic: true },
14091456
});
14101457
}
1411-
return styledRow(segs as Parameters<typeof styledRow>[0]);
1458+
// Right group. A being-created placeholder has no git summary; it gets
1459+
// the one-key affordance instead ("↵ Retry"), so the row below is free
1460+
// for the whole status message.
1461+
if (s.pending) {
1462+
return cardSplitRow(
1463+
segs,
1464+
pendingActionable(s.pending)
1465+
? [{
1466+
text: "↵ " + editor.t("dock.ctx_retry"),
1467+
style: { fg: "ui.menu_disabled_fg", italic: true },
1468+
}]
1469+
: [],
1470+
);
1471+
}
1472+
return cardSplitRow(segs, gitLineParts(s).right);
14121473
}
14131474

1414-
// Card lines 2 & 3 (continuation rows). Line 2 is the branch + a
1415-
// compact git summary, right-aligned as one group against the card's
1416-
// right border; line 3 is the PR badge (or a blank spacer when there's
1417-
// no PR, keeping every card the same height). Tree card rows are plain
1418-
// text entries (no host flex spacer), so the line carries the
1419-
// `align: "right"` entry property and the host's `render_tree_card` —
1420-
// which knows the card's *actual* inner width, responsive or dragged —
1421-
// pads it flush against the right border.
1475+
// Card line 2 (the continuation row): what this workspace *is* on the
1476+
// left — its branch when that says something the name doesn't, else the
1477+
// project it belongs to — and its PR badge (or the on-disk tag) flush
1478+
// right.
14221479
function sessionCardExtraLines(id: number): TextPropertyEntry[] {
14231480
const s = orchestratorSessions.get(id);
14241481
if (!s) return [];
1425-
// A being-created placeholder shows its status in place of the git / PR
1426-
// lines: line 2 is the creating/connecting/error message, line 3 is a
1427-
// retry/resume hint (blank while still creating).
1482+
// A being-created placeholder spends the row on its status message
1483+
// (the retry affordance sits on line 1).
14281484
if (s.pending) {
14291485
const p = s.pending;
1430-
const actionable = pendingActionable(p);
14311486
return [
1432-
styledRow([{ text: p.message, style: { fg: pendingMsgFg(p), italic: actionable } }]),
1433-
styledRow([
1434-
actionable
1435-
? { text: pendingHintText(p), style: { fg: "ui.menu_disabled_fg", italic: true } }
1436-
: { text: " " },
1437-
] as Parameters<typeof styledRow>[0]),
1487+
styledRow([{
1488+
text: p.message,
1489+
style: { fg: pendingMsgFg(p), italic: pendingActionable(p) },
1490+
}]),
14381491
];
14391492
}
1440-
const git = gitLineParts(s);
1441-
const gitSegs: Entry[] = [...git.left];
1442-
if (git.right.length) {
1443-
gitSegs.push({ text: " " });
1444-
gitSegs.push(...git.right);
1445-
}
1446-
const gitLine = styledRow(gitSegs as Parameters<typeof styledRow>[0]);
1447-
gitLine.properties = { align: "right" };
1448-
const pr = prLineEntries(s);
1493+
const dim = "ui.menu_disabled_fg";
1494+
const right = prLineEntries(s);
1495+
// The branch earns the row only when it differs from the workspace
1496+
// name — a worktree's branch is usually named after it, and printing
1497+
// it twice was pure noise. Otherwise the project takes the slot: it's
1498+
// the context the name doesn't carry. `(detached)` is reserved for a
1499+
// real repo with no branch; a plain directory just shows its project.
1500+
const branch = s.branch ||
1501+
(s.discovered
1502+
? editor.t("pill.branch_worktree")
1503+
: s.git?.info
1504+
? editor.t("pill.branch_detached")
1505+
: "");
1506+
const showBranch = branch !== "" && branch !== s.label;
1507+
const proj = editor.pathBasename(projectKeyOf(s));
1508+
const icon = showBranch ? BRANCH_ICON : PROJECT_ICON;
1509+
const text = showBranch ? branch : proj;
1510+
// Nothing to say that the name doesn't already say (a plain folder
1511+
// opened as its own project, whose label *is* the folder). The row
1512+
// stays empty rather than echoing the line above it.
1513+
if (text === s.label) {
1514+
return [cardSplitRow(right.length > 0 ? [] : [{ text: " " }], right)];
1515+
}
1516+
// Cap the branch/project so the badge keeps its columns: the host
1517+
// truncates the row's *end*, which is the badge. Budget = the card's
1518+
// inner width less this row's icon (2 cols), the badge, and the one
1519+
// column that always separates the two groups.
1520+
const cap = cardInnerColsEstimate() - 2 - entriesWidth(right) -
1521+
(right.length > 0 ? 1 : 0);
14491522
return [
1450-
gitLine,
1451-
styledRow((pr.length ? pr : [{ text: " " }]) as Parameters<typeof styledRow>[0]),
1523+
cardSplitRow(
1524+
[
1525+
{ text: icon + " ", style: { fg: dim } },
1526+
{ text: capText(text, Math.max(8, cap)), style: { fg: dim, italic: !showBranch } },
1527+
],
1528+
right,
1529+
),
14521530
];
14531531
}
14541532

@@ -2139,10 +2217,12 @@ const PROJECT_ICON = "▣";
21392217
// already uses.
21402218
const BRANCH_ICON = "▸";
21412219

2142-
// Card line 2: branch (with icon, left) + a compact git summary
2143-
// (right-aligned) that's useful even before any PR exists —
2220+
// The modal pill's branch line: branch (with icon, left) + a compact
2221+
// git summary (right-aligned) that's useful even before any PR exists —
21442222
// ahead/behind the upstream and the uncommitted diffstat
2145-
// (`+added −deleted`), or `clean`.
2223+
// (`+added −deleted`), or `clean`. The dock card takes only the `right`
2224+
// half — its name row carries the summary — so `left` (the branch) is
2225+
// the pill's alone.
21462226
function gitLineParts(s: AgentSession): { left: Entry[]; right: Entry[] } {
21472227
const dim = "ui.menu_disabled_fg";
21482228
let branch = s.branch || (s.discovered ? editor.t("pill.branch_worktree") : editor.t("pill.branch_detached"));
@@ -2272,7 +2352,10 @@ function stateGlyphEntry(s: AgentSession): Entry {
22722352
return { text: sym.glyph + " ", style: { fg: sym.fg, bold: true } };
22732353
}
22742354

2275-
// Build one session row. Two densities, picked by `dockView`:
2355+
// Build one session row for the modal picker's list. (The dock's own
2356+
// tree rows are built by `sessionCardPrimary` / `sessionNodeEntry`; its
2357+
// cards are two rows, not the three below.) Two densities, picked by
2358+
// `dockView`:
22762359
//
22772360
// card (default): a rounded `labeledSection` pill —
22782361
// line 1: <state> NAME (bold) ▣ project

crates/fresh-editor/src/widgets/render.rs

Lines changed: 105 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4910,18 +4910,49 @@ fn render_tree_card(node: &TreeNode, item_height: u32, panel_width: u32) -> Rend
49104910
// The pad is ASCII spaces (1 byte == 1 char each), so shifting
49114911
// overlay offsets by the pad length is unit-correct for both
49124912
// byte- and char-unit overlays.
4913-
let align_right = src
4913+
let align = src
49144914
.properties
49154915
.get("align")
49164916
.and_then(|v| v.as_str())
4917-
.map(|v| v == "right")
4918-
.unwrap_or(false);
4919-
if align_right {
4917+
.unwrap_or("")
4918+
.to_string();
4919+
// `align: "between"` splits the row into a left group and a
4920+
// right one flush against the border — the card equivalent of
4921+
// the flex spacer a widget `Row` gets. The split point is a byte
4922+
// offset into the row's own text (`splitByte`), so the plugin
4923+
// says *where* the groups meet and the host, which alone knows
4924+
// the card's real width, decides how much space goes between
4925+
// them. Overflowing rows get a single separating space and fall
4926+
// through to the usual end-truncation.
4927+
let split = if align == "between" {
4928+
src.properties
4929+
.get("splitByte")
4930+
.and_then(|v| v.as_u64())
4931+
.map(|v| v as usize)
4932+
.filter(|&b| b <= src.text.len() && src.text.is_char_boundary(b))
4933+
} else {
4934+
None
4935+
};
4936+
// Where the padding goes: the row's start (right-aligned) or the
4937+
// group boundary (space-between).
4938+
let pad_at = match (align.as_str(), split) {
4939+
("right", _) => Some(0),
4940+
("between", Some(b)) => Some(b),
4941+
_ => None,
4942+
};
4943+
if let Some(at) = pad_at {
49204944
let width = src.text.chars().count();
4921-
if width < inner_width {
4922-
let pad = " ".repeat(inner_width - width);
4923-
src.text.insert_str(0, &pad);
4924-
for o in src.inline_overlays.iter_mut() {
4945+
// A "between" row always keeps at least one space between
4946+
// the groups so they can't run together when the card is too
4947+
// narrow to hold both.
4948+
let pad_cols = inner_width.saturating_sub(width).max(usize::from(at > 0));
4949+
if pad_cols > 0 {
4950+
let pad = " ".repeat(pad_cols);
4951+
src.text.insert_str(at, &pad);
4952+
// The pad is ASCII spaces (1 byte == 1 char each), so
4953+
// shifting the overlays that sit after it is unit-correct
4954+
// for both byte- and char-unit overlays.
4955+
for o in src.inline_overlays.iter_mut().filter(|o| o.start >= at) {
49254956
o.start += pad.len();
49264957
o.end += pad.len();
49274958
}
@@ -7781,6 +7812,72 @@ mod tests {
77817812
);
77827813
}
77837814

7815+
/// One `align: "between"` card row: `left` and `right` groups meet
7816+
/// at `splitByte`, rendered in a card `width` columns wide.
7817+
fn between_card_row(left: &str, right: &str, width: u32) -> String {
7818+
let mut node = tnode("name", 0, false);
7819+
let mut line = TextPropertyEntry::text(format!("{left}{right}"));
7820+
line.properties.insert(
7821+
"align".to_string(),
7822+
serde_json::Value::String("between".to_string()),
7823+
);
7824+
line.properties.insert(
7825+
"splitByte".to_string(),
7826+
serde_json::Value::Number((left.len() as u64).into()),
7827+
);
7828+
node.extra_lines = vec![line];
7829+
let spec = WidgetSpec::Tree {
7830+
nodes: vec![node],
7831+
item_keys: vec!["x".to_string()],
7832+
selected_index: -1,
7833+
visible_rows: 10,
7834+
expanded_keys: vec![],
7835+
checkable: false,
7836+
item_height: 2,
7837+
card_borders: true,
7838+
key: Some("T".to_string()),
7839+
};
7840+
let out = render_spec(&spec, &HashMap::new(), "", width);
7841+
// Top border, name row, the split row, bottom border.
7842+
out.entries[2].text.trim_end_matches('\n').to_string()
7843+
}
7844+
7845+
/// A card row (the orchestrator dock's workspace cards) can ask for
7846+
/// its right-hand group to sit flush against the card border while
7847+
/// the left group starts at the left one — `align: "between"` with
7848+
/// the group boundary as a byte offset. Only the host knows the
7849+
/// card's real width (the dock is resizable), so it owns the gap.
7850+
#[test]
7851+
fn tree_card_between_alignment_pushes_the_right_group_to_the_border() {
7852+
let row = between_card_row("branch", "PR #7", 30);
7853+
assert!(
7854+
row.starts_with("│branch") && row.ends_with("PR #7│"),
7855+
"left group hugs the left border and the right group the right one, got {row:?}"
7856+
);
7857+
// Padding only between them — not a plugin-side guess that
7858+
// leaves both groups floating mid-card.
7859+
let inner = row.trim_start_matches('│').trim_end_matches('│');
7860+
assert!(
7861+
inner["branch".len()..inner.len() - "PR #7".len()]
7862+
.chars()
7863+
.all(|c| c == ' '),
7864+
"the two groups are separated by padding only, got {inner:?}"
7865+
);
7866+
}
7867+
7868+
/// The groups still get a separating space when the card has no room
7869+
/// to spare — they must never run together into one unreadable word,
7870+
/// even at the exact width where they would just barely both fit.
7871+
#[test]
7872+
fn tree_card_between_alignment_keeps_a_gap_when_the_row_is_full() {
7873+
// Inner width 15 = exactly "abcdefghij" + "PR #7".
7874+
let row = between_card_row("abcdefghij", "PR #7", 17);
7875+
assert!(
7876+
!row.contains("ijPR"),
7877+
"a full row still separates the groups, got {row:?}"
7878+
);
7879+
}
7880+
77847881
#[test]
77857882
fn tree_renders_only_top_level_when_nothing_expanded() {
77867883
let spec = make_tree(

0 commit comments

Comments
 (0)