@1agh/maude 0.35.0 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,16 +3,19 @@
3
3
  // REDESIGNED to match .design/ui/RepoBranchSwitcher.tsx: not a top header — a
4
4
  // compact ONE-LINE dock at the BOTTOM of the sidebar (mounted directly above the
5
5
  // IdentityBar avatar, so the two form one bottom dock — the gi-rail/gi-menu
6
- // anatomy). The trigger reads "📁 <project> · <version> ⌃" and opens ONE popup
7
- // UPWARD with a Project section (recents + Open another folder…) and a Version
8
- // section (Shared version / drafts / the fold-back CTA / New draft).
6
+ // anatomy). The trigger reads "📁 <project> · <branch> ⌃" and opens ONE popup
7
+ // UPWARD with a Project section (recents + Open another folder…) and a Branch
8
+ // section (default branch / other branches / the merge-to-default CTA / New branch).
9
9
  //
10
- // Vocabulary contract: a git branch is a "draft", main/master is the "Shared
11
- // version" no branch/checkout/main/merge jargon. Project switch open_local_project
12
- // (Tauri) reloads the webview; draft switch POST /_api/git/checkout then reload
13
- // (the git-lifecycle HEAD-watcher flushes Yjs first DDR-051, not duplicated here);
14
- // "Add this draft to the Shared version" → POST /_api/git/fold. Renders nothing
15
- // until the project is a git repo. CSS (rb-*) in 3-shell-maude.css.
10
+ // Vocabulary contract (DDR-133 git-native for this surface, superseding the
11
+ // DDR-110/119 plain-language layer for the developer persona): branches show their
12
+ // real names, main/master is "the default branch", the fold action is "Merge this
13
+ // branch main". Project switch open_local_project (Tauri) reloads the webview;
14
+ // branch switch → POST /_api/git/checkout then reload (the git-lifecycle HEAD-watcher
15
+ // flushes Yjs first DDR-051, not duplicated here); "Merge → main" → POST
16
+ // /_api/git/fold; "Fetch remote branches" → POST /_api/git/fetch. The local branch
17
+ // list is re-asserted from disk on every popup open (DDR-133) so a flaky network never
18
+ // blanks it. Renders nothing until the project is a git repo. CSS in 3-shell-maude.css.
16
19
 
17
20
  import { useEffect, useRef, useState } from 'react';
18
21
 
@@ -35,6 +38,10 @@ function Icon({ name, size = 16, className }) {
35
38
  plus: (<><line x1="8" y1="3" x2="8" y2="13" /><line x1="3" y1="8" x2="13" y2="8" /></>),
36
39
  // "lift this draft up into the Shared version" — the fold-back action.
37
40
  'arrow-up-to-line': (<><line x1="3.5" y1="3" x2="12.5" y2="3" /><line x1="8" y1="13" x2="8" y2="6" /><polyline points="5 8.5 8 5.5 11 8.5" /></>),
41
+ // a teammate's draft that lives on the remote, not downloaded yet.
42
+ cloud: <path d="M4.5 12h6a2.5 2.5 0 0 0 .3-5A3.5 3.5 0 0 0 4 6.4 2.8 2.8 0 0 0 4.5 12z" />,
43
+ // refresh drafts — a circular arrow.
44
+ refresh: (<><path d="M12.5 8a4.5 4.5 0 1 1-1.3-3.2" /><polyline points="12.8 2.5 12.8 5 10.3 5" /></>),
38
45
  spinner: <path d="M8 2.2a5.8 5.8 0 1 0 5.8 5.8" />,
39
46
  }[name];
40
47
  return (
@@ -50,15 +57,40 @@ function basename(p) {
50
57
  function slugify(s) {
51
58
  return s.trim().toLowerCase().replace(/[^a-z0-9._/-]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
52
59
  }
60
+ // Stable data-testid slug for a branch name (desktop-e2e targets testids, not classes).
61
+ function tid(name) {
62
+ return String(name).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
63
+ }
64
+ // Plain "as of …" label for the last Refresh. Coarse on purpose — it's a freshness
65
+ // hint, not a clock. `at` is unix seconds; 0/falsey → no label.
66
+ function relativeTime(at) {
67
+ if (!at) return '';
68
+ const s = Math.max(0, Math.floor(Date.now() / 1000) - at);
69
+ if (s < 45) return 'just now';
70
+ if (s < 3600) return `${Math.round(s / 60)} min ago`;
71
+ if (s < 86400) return `${Math.round(s / 3600)} h ago`;
72
+ return `${Math.round(s / 86400)} d ago`;
73
+ }
53
74
  async function getJson(url) {
54
75
  const r = await fetch(url);
55
76
  return r.json();
56
77
  }
57
- async function postJson(url, body) {
58
- const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
59
- let json = null;
60
- try { json = await r.json(); } catch { /* no body */ }
61
- return { ok: r.ok, status: r.status, json };
78
+ async function postJson(url, body, opts = {}) {
79
+ // A network-bound POST (Refresh) gets an abort timeout so a wedged server can't
80
+ // leave the UI spinning forever — the caller surfaces a plain "timed out".
81
+ const ctrl = opts.timeoutMs ? new AbortController() : null;
82
+ const timer = ctrl ? setTimeout(() => ctrl.abort(), opts.timeoutMs) : null;
83
+ try {
84
+ const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: ctrl?.signal });
85
+ let json = null;
86
+ try { json = await r.json(); } catch { /* no body */ }
87
+ return { ok: r.ok, status: r.status, json };
88
+ } catch (e) {
89
+ if (e?.name === 'AbortError') return { ok: false, status: 0, json: null, timedOut: true };
90
+ return { ok: false, status: 0, json: null, error: String(e?.message || e) };
91
+ } finally {
92
+ if (timer) clearTimeout(timer);
93
+ }
62
94
  }
63
95
 
64
96
  export default function RepoBranchSwitcher({ project, liveBranch }) {
@@ -74,6 +106,10 @@ export default function RepoBranchSwitcher({ project, liveBranch }) {
74
106
  const [busy, setBusy] = useState(false);
75
107
  const [switching, setSwitching] = useState('');
76
108
  const [err, setErr] = useState('');
109
+ const [query, setQuery] = useState('');
110
+ const [refreshing, setRefreshing] = useState(false);
111
+ const [fetchedAt, setFetchedAt] = useState(0); // unix seconds of last Refresh
112
+ const [downloading, setDownloading] = useState(false); // switching onto a remote-only draft
77
113
  const rootRef = useRef(null);
78
114
 
79
115
  useEffect(() => {
@@ -95,9 +131,20 @@ export default function RepoBranchSwitcher({ project, liveBranch }) {
95
131
  return () => { alive = false; };
96
132
  }, [native]);
97
133
 
134
+ // DDR-133: the local branch list is disk-only and instant — re-assert it every
135
+ // time the popup opens (independent of any network Refresh), and clear a stale
136
+ // error (e.g. a prior "Refresh timed out") so a flaky network can never leave the
137
+ // dropdown looking empty or broken on the next open.
138
+ useEffect(() => {
139
+ if (!open || !native || !status?.repo) return;
140
+ setErr('');
141
+ reloadBranches();
142
+ // eslint-disable-next-line react-hooks/exhaustive-deps
143
+ }, [open]);
144
+
98
145
  useEffect(() => {
99
146
  if (!open && !newDraft) return undefined;
100
- const onDoc = (e) => { if (rootRef.current && !rootRef.current.contains(e.target)) { setOpen(false); setNewDraft(false); } };
147
+ const onDoc = (e) => { if (rootRef.current && !rootRef.current.contains(e.target)) { setOpen(false); setNewDraft(false); setQuery(''); } };
101
148
  document.addEventListener('mousedown', onDoc);
102
149
  return () => document.removeEventListener('mousedown', onDoc);
103
150
  }, [open, newDraft]);
@@ -110,8 +157,20 @@ export default function RepoBranchSwitcher({ project, liveBranch }) {
110
157
  const branch = liveBranch || status.branch || 'main';
111
158
  const onShared = SHARED.has(branch);
112
159
  const sharedName = branches.find((b) => SHARED.has(b.name))?.name || 'main';
113
- const drafts = branches.filter((b) => !SHARED.has(b.name));
160
+ // Recents-first: the draft you last committed to floats to the top — far more
161
+ // useful than alphabetical when a project has a long tail of backup/* branches.
162
+ const byRecent = (a, b) => (b.updatedAt || 0) - (a.updatedAt || 0) || a.name.localeCompare(b.name);
163
+ const q = query.trim().toLowerCase();
164
+ const matchesQuery = (b) => !q || b.name.toLowerCase().includes(q);
165
+ const allDrafts = branches.filter((b) => !SHARED.has(b.name)).sort(byRecent);
166
+ const drafts = allDrafts.filter(matchesQuery);
114
167
  const otherDrafts = drafts.filter((b) => b.name !== branch);
168
+ // The Shared version is "main"/"master" in git terms, so a search for "main"
169
+ // should surface it even though it isn't a draft — match on the branch name OR
170
+ // the user-facing label so it never reads as a phantom "no matches".
171
+ const sharedMatchesQuery = !q || sharedName.toLowerCase().includes(q) || 'shared version'.includes(q);
172
+ // Only surface the filter box once the list is long enough to warrant it.
173
+ const showSearch = allDrafts.length > 6;
115
174
  const projectName = project || basename(recents[0] || 'Project');
116
175
 
117
176
  // Web studio (browser, CLI-launched inside a repo): the switcher is awareness
@@ -143,14 +202,41 @@ export default function RepoBranchSwitcher({ project, liveBranch }) {
143
202
  );
144
203
  }
145
204
 
146
- async function switchDraft(name) {
205
+ // Re-read the local branch list from disk (no network — instant). The popup-open
206
+ // effect and a successful fetch both call this so the list is always current.
207
+ async function reloadBranches() {
208
+ try { const b = await getJson('/_api/git/branches'); setBranches(b.branches || []); }
209
+ catch { /* not a repo / offline — keep the prior list, never blank it */ }
210
+ }
211
+
212
+ async function switchDraft(name, where) {
147
213
  setOpen(false);
148
214
  if (name === branch) return;
149
- setSwitching(name === sharedName ? 'the shared version' : name);
215
+ setSwitching(name);
216
+ setDownloading(where === 'remote'); // a remote-only branch is downloaded on switch
150
217
  setErr('');
151
218
  const r = await postJson('/_api/git/checkout', { name });
152
219
  if (r.ok && r.json?.ok) window.location.reload();
153
- else { setErr(r.json?.error || 'Could not switch.'); setSwitching(''); }
220
+ else { setErr(r.json?.error || 'Could not switch.'); setSwitching(''); setDownloading(false); }
221
+ }
222
+
223
+ // "Fetch remote branches" — fetch all remote heads so a teammate's new branch
224
+ // surfaces, then re-read the list. Explicit gesture (never auto-run); token is
225
+ // server-held. A failure surfaces as an in-popup notice (DDR-133) — it NEVER
226
+ // clears the local list, which stays rendered from disk.
227
+ async function refreshDrafts() {
228
+ if (refreshing) return;
229
+ setRefreshing(true); setErr('');
230
+ const r = await postJson('/_api/git/fetch', {}, { timeoutMs: 45000 });
231
+ if (r.ok && r.json?.ok) {
232
+ if (r.json.fetchedAt) setFetchedAt(r.json.fetchedAt);
233
+ await reloadBranches();
234
+ } else if (r.timedOut || r.json?.timedOut) {
235
+ setErr('Couldn’t reach the remote — your local branches are still listed above.');
236
+ } else {
237
+ setErr(r.status === 401 || r.json?.authRequired ? 'Sign in with GitHub to fetch remote branches.' : r.json?.error || 'Could not fetch remote branches.');
238
+ }
239
+ setRefreshing(false);
154
240
  }
155
241
 
156
242
  async function createDraft() {
@@ -170,7 +256,7 @@ export default function RepoBranchSwitcher({ project, liveBranch }) {
170
256
  const r = await postJson('/_api/git/fold', { name: branch });
171
257
  if (r.ok && r.json?.ok) window.location.reload();
172
258
  else {
173
- setErr(r.status === 401 ? 'Sign in with GitHub to publish the Shared version.' : r.json?.error || 'Could not add the draft.');
259
+ setErr(r.status === 401 ? `Sign in with GitHub to merge into ${sharedName}.` : r.json?.error || 'Could not merge the branch.');
174
260
  setFolding('');
175
261
  }
176
262
  }
@@ -197,11 +283,42 @@ export default function RepoBranchSwitcher({ project, liveBranch }) {
197
283
  const slug = slugify(draftName);
198
284
  const currentDraft = onShared ? null : branches.find((b) => b.current);
199
285
 
286
+ // Search-miss (DDR-133, Task 4): a typed name that matches no LOCAL or already-
287
+ // fetched remote branch offers an explicit remote search (the bounded fetch),
288
+ // instead of a dead-end "nothing matches". The local list above stays rendered.
289
+ const searchMissRow = (
290
+ <>
291
+ <div className="rb-pop-empty">No branch matches “{query.trim()}”.</div>
292
+ <button type="button" className="rb-pop-item rb-pop-item--action" role="menuitem" onClick={refreshDrafts} disabled={refreshing}>
293
+ <span className={'rb-pop-icon' + (refreshing ? ' rb-pop-icon--spin' : '')}><Icon name={refreshing ? 'spinner' : 'refresh'} size={14} /></span>
294
+ <span className="rb-pop-tx"><span className="rb-pop-name">{refreshing ? 'Searching the remote…' : 'Search the remote for it'}</span></span>
295
+ </button>
296
+ </>
297
+ );
298
+
299
+ // One switchable draft row. A `remote`-only draft (a teammate's, or one pushed
300
+ // from another machine) gets a cloud glyph + "not downloaded yet" — switching
301
+ // downloads it (the service creates a local tracking branch).
302
+ const draftRow = (b) => {
303
+ const remote = b.where === 'remote';
304
+ return (
305
+ <button type="button" key={b.name} data-testid={`branch-row-${tid(b.name)}`} className="rb-pop-item" role="menuitem" onClick={() => switchDraft(b.name, b.where)}>
306
+ <span className={'rb-pop-icon ' + (remote ? 'rb-pop-icon--remote' : 'rb-pop-icon--draft')}>
307
+ <Icon name={remote ? 'cloud' : 'draft'} size={14} />
308
+ </span>
309
+ <span className="rb-pop-tx">
310
+ <span className="rb-pop-name">{b.name}</span>
311
+ {remote && <span className="rb-pop-sub">remote · not downloaded yet</span>}
312
+ </span>
313
+ </button>
314
+ );
315
+ };
316
+
200
317
  return (
201
318
  <div className="rb-dock-wrap">
202
319
  <div className="rb-dock" ref={rootRef}>
203
320
  {open && (
204
- <div className="rb-pop rb-pop--up" id="rb-switch-pop" role="menu" aria-label="Switch project or version">
321
+ <div className="rb-pop rb-pop--up" id="rb-switch-pop" role="menu" aria-label="Switch project or version" data-testid="repo-switcher-popup">
205
322
  {/* ── Project ── */}
206
323
  <div className="rb-pop-hd">Project</div>
207
324
  {native && recents.length > 0 ? (
@@ -226,67 +343,81 @@ export default function RepoBranchSwitcher({ project, liveBranch }) {
226
343
  )}
227
344
 
228
345
  <div className="rb-pop-sep" />
229
- <div className="rb-pop-hd">Version</div>
346
+ <div className="rb-pop-hd">Branch</div>
230
347
 
231
348
  {onShared ? (
232
349
  <>
233
- <button type="button" className="rb-pop-item is-current" role="menuitem" aria-current="true" onClick={() => switchDraft(sharedName)}>
350
+ <button type="button" data-testid={`branch-row-${tid(sharedName)}`} className="rb-pop-item is-current" role="menuitem" aria-current="true" onClick={() => switchDraft(sharedName)}>
234
351
  <span className="rb-pop-icon rb-pop-icon--shared"><Icon name="share" size={14} /></span>
235
352
  <span className="rb-pop-tx">
236
- <span className="rb-pop-name">Shared version</span>
237
- <span className="rb-pop-sub">what everyone sees</span>
353
+ <span className="rb-pop-name">{sharedName}</span>
354
+ <span className="rb-pop-sub">default branch · what everyone sees</span>
238
355
  </span>
239
356
  <Icon name="check" size={14} className="rb-pop-check" />
240
357
  </button>
241
- {drafts.length > 0 && <div className="rb-pop-grouplabel">Drafts</div>}
242
- {drafts.map((b) => (
243
- <button type="button" key={b.name} className="rb-pop-item" role="menuitem" onClick={() => switchDraft(b.name)}>
244
- <span className="rb-pop-icon rb-pop-icon--draft"><Icon name="draft" size={14} /></span>
245
- <span className="rb-pop-tx"><span className="rb-pop-name">{b.name}</span></span>
246
- </button>
247
- ))}
358
+ {allDrafts.length > 0 && <div className="rb-pop-grouplabel">Other branches</div>}
359
+ {showSearch && (
360
+ <div className="rb-search">
361
+ <input className="input rb-search-input" type="text" value={query} placeholder="Search branches…" aria-label="Search branches" onChange={(e) => setQuery(e.target.value)} onKeyDown={(e) => { if (e.key === 'Escape') setQuery(''); }} />
362
+ </div>
363
+ )}
364
+ {drafts.map(draftRow)}
365
+ {showSearch && q && drafts.length === 0 && !sharedMatchesQuery && searchMissRow}
248
366
  </>
249
367
  ) : (
250
368
  <>
251
- {/* On a draft — it's the current row, and the one strong action is
252
- folding it back into the Shared version (Task 7). */}
369
+ {/* On a branch — it's the current row, and the one strong action is
370
+ merging it into the default branch (DDR-133 fold). */}
253
371
  <button type="button" className="rb-pop-item is-current" role="menuitem" aria-current="true">
254
372
  <span className="rb-pop-icon rb-pop-icon--draft"><Icon name="draft" size={14} /></span>
255
373
  <span className="rb-pop-tx">
256
374
  <span className="rb-pop-name">{currentDraft?.name || branch}</span>
257
- <span className="rb-pop-sub">your draft</span>
375
+ <span className="rb-pop-sub">your branch</span>
258
376
  </span>
259
377
  <Icon name="check" size={14} className="rb-pop-check" />
260
378
  </button>
261
- <button type="button" className="rb-fold" role="menuitem" onClick={() => { setOpen(false); setFoldConfirm(true); }}>
379
+ <button type="button" data-testid="switcher-merge" className="rb-fold" role="menuitem" onClick={() => { setOpen(false); setFoldConfirm(true); }}>
262
380
  <span className="rb-fold-icon"><Icon name="arrow-up-to-line" size={15} /></span>
263
381
  <span className="rb-fold-tx">
264
- <span className="rb-fold-title">Add this draft to the Shared version</span>
265
- <span className="rb-fold-sub">make it the version everyone works from</span>
382
+ <span className="rb-fold-title">Merge this branch {sharedName}</span>
383
+ <span className="rb-fold-sub">into the default branch everyone shares</span>
266
384
  </span>
267
385
  </button>
268
- <div className="rb-pop-grouplabel">Switch to</div>
269
- <button type="button" className="rb-pop-item" role="menuitem" onClick={() => switchDraft(sharedName)}>
270
- <span className="rb-pop-icon rb-pop-icon--shared"><Icon name="share" size={14} /></span>
271
- <span className="rb-pop-tx">
272
- <span className="rb-pop-name">Shared version</span>
273
- <span className="rb-pop-sub">what everyone sees</span>
274
- </span>
275
- </button>
276
- {otherDrafts.map((b) => (
277
- <button type="button" key={b.name} className="rb-pop-item" role="menuitem" onClick={() => switchDraft(b.name)}>
278
- <span className="rb-pop-icon rb-pop-icon--draft"><Icon name="draft" size={14} /></span>
279
- <span className="rb-pop-tx"><span className="rb-pop-name">{b.name}</span></span>
386
+ <div className="rb-pop-grouplabel">Switch branch</div>
387
+ {showSearch && (
388
+ <div className="rb-search">
389
+ <input className="input rb-search-input" type="text" value={query} placeholder="Search branches…" aria-label="Search branches" onChange={(e) => setQuery(e.target.value)} onKeyDown={(e) => { if (e.key === 'Escape') setQuery(''); }} />
390
+ </div>
391
+ )}
392
+ {sharedMatchesQuery && (
393
+ <button type="button" data-testid={`branch-row-${tid(sharedName)}`} className="rb-pop-item" role="menuitem" onClick={() => switchDraft(sharedName)}>
394
+ <span className="rb-pop-icon rb-pop-icon--shared"><Icon name="share" size={14} /></span>
395
+ <span className="rb-pop-tx">
396
+ <span className="rb-pop-name">{sharedName}</span>
397
+ <span className="rb-pop-sub">default branch · what everyone sees</span>
398
+ </span>
280
399
  </button>
281
- ))}
400
+ )}
401
+ {otherDrafts.map(draftRow)}
402
+ {showSearch && q && otherDrafts.length === 0 && !sharedMatchesQuery && searchMissRow}
282
403
  </>
283
404
  )}
284
405
 
285
- <button type="button" className="rb-pop-item rb-pop-item--action" role="menuitem" onClick={() => { setOpen(false); setNewDraft(true); }}>
406
+ <button type="button" data-testid="switcher-fetch" className="rb-pop-item rb-pop-item--action" role="menuitem" onClick={refreshDrafts} disabled={refreshing}>
407
+ <span className={'rb-pop-icon' + (refreshing ? ' rb-pop-icon--spin' : '')}><Icon name={refreshing ? 'spinner' : 'refresh'} size={14} /></span>
408
+ <span className="rb-pop-tx">
409
+ <span className="rb-pop-name">{refreshing ? 'Fetching…' : 'Fetch remote branches'}</span>
410
+ <span className="rb-pop-sub">{fetchedAt ? `as of ${relativeTime(fetchedAt)}` : 'check the remote for new branches'}</span>
411
+ </span>
412
+ </button>
413
+
414
+ {err && !switching && <div className="rb-pop-notice" role="alert">{err}</div>}
415
+
416
+ <button type="button" data-testid="switcher-new-branch" className="rb-pop-item rb-pop-item--action" role="menuitem" onClick={() => { setOpen(false); setNewDraft(true); }}>
286
417
  <span className="rb-pop-icon"><Icon name="plus" size={14} /></span>
287
418
  <span className="rb-pop-tx">
288
- <span className="rb-pop-name">New draft</span>
289
- <span className="rb-pop-sub">a separate line of work, just yours for now</span>
419
+ <span className="rb-pop-name">New branch</span>
420
+ <span className="rb-pop-sub">a separate line of work off the current branch</span>
290
421
  </span>
291
422
  </button>
292
423
  </div>
@@ -295,51 +426,51 @@ export default function RepoBranchSwitcher({ project, liveBranch }) {
295
426
  {newDraft && (
296
427
  <div className="rb-newdraft rb-newdraft--up">
297
428
  <label className="rb-newdraft-field">
298
- <span className="rb-newdraft-label">Name your draft</span>
299
- <input className="input rb-newdraft-input" type="text" value={draftName} placeholder="Nav redesign" aria-label="Draft name" autoFocus onChange={(e) => setDraftName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && slug) createDraft(); if (e.key === 'Escape') { setNewDraft(false); setDraftName(''); } }} />
300
- {slug && <span className="rb-pop-sub">Creates a draft called <b>{slug}</b></span>}
429
+ <span className="rb-newdraft-label">Name your branch</span>
430
+ <input className="input rb-newdraft-input" data-testid="switcher-new-branch-input" type="text" value={draftName} placeholder="nav-redesign" aria-label="Branch name" autoFocus onChange={(e) => setDraftName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && slug) createDraft(); if (e.key === 'Escape') { setNewDraft(false); setDraftName(''); } }} />
431
+ {slug && <span className="rb-pop-sub">Creates branch <b>{slug}</b></span>}
301
432
  </label>
302
433
  {err && <span className="rb-newdraft-err">{err}</span>}
303
434
  <div className="rb-newdraft-actions">
304
435
  <button type="button" className="btn btn--ghost btn--sm" onClick={() => { setNewDraft(false); setDraftName(''); setErr(''); }} disabled={busy}>Cancel</button>
305
- <button type="button" className="btn btn--primary btn--sm" onClick={createDraft} disabled={busy || !slug}><Icon name="draft" size={13} /> {busy ? 'Creating…' : 'Create draft'}</button>
436
+ <button type="button" data-testid="switcher-new-branch-create" className="btn btn--primary btn--sm" onClick={createDraft} disabled={busy || !slug}><Icon name="draft" size={13} /> {busy ? 'Creating…' : 'Create branch'}</button>
306
437
  </div>
307
- <p className="rb-newdraft-hint">A draft is your own copy to try things in. Add it to the Shared version when you're happy, or throw it away — nothing else changes.</p>
438
+ <p className="rb-newdraft-hint">A branch is your own line of work off the current branch. Merge it into {sharedName} when you're happy, or throw it away — nothing else changes.</p>
308
439
  </div>
309
440
  )}
310
441
 
311
442
  {switching ? (
312
443
  <div className="rb-switching" role="status" aria-live="polite">
313
444
  <Icon name="spinner" size={14} className="rb-spin" />
314
- <span>{folding ? <>Adding <b>{folding}</b> to the Shared version…</> : <>Opening <b>{switching}</b>…</>}</span>
445
+ <span>{folding ? <>Merging <b>{folding}</b> {sharedName}…</> : downloading ? <>Downloading <b>{switching}</b>…</> : <>Opening <b>{switching}</b>…</>}</span>
315
446
  </div>
316
447
  ) : (
317
- <button type="button" className={'rb-trigger' + (open ? ' is-open' : '')} aria-expanded={open} aria-haspopup="menu" aria-controls="rb-switch-pop" onClick={() => { setOpen((v) => !v); setNewDraft(false); }} title={`${projectName} · ${onShared ? 'Shared version' : branch}`}>
448
+ <button type="button" data-testid="repo-switcher-trigger" className={'rb-trigger' + (open ? ' is-open' : '')} aria-expanded={open} aria-haspopup="menu" aria-controls="rb-switch-pop" onClick={() => { setOpen((v) => { if (v) setQuery(''); return !v; }); setNewDraft(false); }} title={`${projectName} · ${branch}`}>
318
449
  <span className="rb-trigger-icon"><Icon name="folder" size={14} /></span>
319
450
  <span className="rb-trigger-proj">{projectName}</span>
320
451
  <span className="rb-trigger-sep" aria-hidden="true">·</span>
321
452
  <span className={'rb-trigger-ver' + (onShared ? '' : ' is-draft')}>
322
453
  <Icon name={onShared ? 'share' : 'draft'} size={12} />
323
- <span className="rb-trigger-ver-name">{onShared ? 'Shared version' : branch}</span>
454
+ <span className="rb-trigger-ver-name">{branch}</span>
324
455
  </span>
325
456
  <Icon name="chevron-up" size={13} className="rb-trigger-caret" />
326
457
  </button>
327
458
  )}
328
- {err && !newDraft && !switching && <div className="rb-switcher-err" role="alert">{err}</div>}
459
+ {err && !open && !newDraft && !switching && <div className="rb-switcher-err" role="alert">{err}</div>}
329
460
  </div>
330
461
 
331
- {/* Fold-back confirm — the one modal in this surface. Plain words, no merge UI. */}
462
+ {/* Merge-to-default confirm — the one modal in this surface. No 3-way merge UI. */}
332
463
  {foldConfirm && (
333
464
  <div className="rb-scrim" role="presentation" onClick={() => setFoldConfirm(false)}>
334
465
  <div className="rb-sheet" role="dialog" aria-modal="true" aria-labelledby="rb-sheet-title" aria-describedby="rb-sheet-body" onClick={(e) => e.stopPropagation()} onKeyDown={(e) => { if (e.key === 'Escape') setFoldConfirm(false); }}>
335
466
  <span className="rb-sheet-icon"><Icon name="arrow-up-to-line" size={20} /></span>
336
- <h2 className="rb-sheet-title" id="rb-sheet-title">Add this draft to the Shared version</h2>
337
- <p className="rb-sheet-body" id="rb-sheet-body">Everything in <b>“{currentDraft?.name || branch}”</b> will become part of the Shared version everyone sees.</p>
338
- <p className="rb-sheet-meta">Everyone else picks it up the next time they Pull changes, and this draft is then removed. Nothing else changes.</p>
467
+ <h2 className="rb-sheet-title" id="rb-sheet-title">Merge this branch {sharedName}</h2>
468
+ <p className="rb-sheet-body" id="rb-sheet-body">Everything in <b>“{currentDraft?.name || branch}”</b> becomes part of <b>{sharedName}</b> — the default branch everyone shares.</p>
469
+ <p className="rb-sheet-meta">Teammates pick it up the next time they Get latest, and this branch is then deleted. Nothing else changes.</p>
339
470
  {err && <p className="rb-newdraft-err">{err}</p>}
340
471
  <div className="rb-sheet-actions">
341
472
  <button type="button" className="btn btn--ghost" onClick={() => { setFoldConfirm(false); setErr(''); }}>Cancel</button>
342
- <button type="button" className="btn btn--primary" onClick={foldDraft}><Icon name="arrow-up-to-line" size={15} /> Add to the Shared version</button>
473
+ <button type="button" data-testid="switcher-merge-confirm" className="btn btn--primary" onClick={foldDraft}><Icon name="arrow-up-to-line" size={15} /> Merge {sharedName}</button>
343
474
  </div>
344
475
  </div>
345
476
  </div>
@@ -125,6 +125,15 @@
125
125
  transition: background var(--dur-soft) var(--ease-out), color var(--dur-soft) var(--ease-out);
126
126
  }
127
127
  .st-iconbtn:hover { background: var(--bg-3); color: var(--fg-0); }
128
+ /* Refresh-files button: spin the reload glyph while the tree re-reads so the
129
+ * action visibly registers even when /_index-data returns instantly. Disabled
130
+ * state stays full-opacity (it's a "busy" beat, not an unavailable control). */
131
+ .st-refresh:disabled { cursor: default; opacity: 1; }
132
+ .st-refresh.is-spinning svg { animation: st-refresh-spin 0.6s linear infinite; transform-origin: 50% 50%; }
133
+ @keyframes st-refresh-spin { to { transform: rotate(360deg); } }
134
+ @media (prefers-reduced-motion: reduce) {
135
+ .st-refresh.is-spinning svg { animation: none; }
136
+ }
128
137
 
129
138
  /* ─── Presence avatars (menubar) ──────────────────────────────────────────── */
130
139
  .st-presence { display: flex; align-items: center; }
@@ -1735,6 +1744,12 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
1735
1744
  .rb-pop-item--action .rb-pop-icon { color: var(--accent); }
1736
1745
  .rb-pop-icon--shared { color: var(--presence-online); }
1737
1746
  .rb-pop-icon--draft { color: var(--status-warn); }
1747
+ /* a remote-only draft (on the team's remote, not downloaded yet) reads quieter
1748
+ * than a local draft — it's a "could open" not a "yours". */
1749
+ .rb-pop-icon--remote { color: var(--fg-2); }
1750
+ .rb-pop-icon--spin svg { animation: rb-pop-spin 0.6s linear infinite; transform-origin: 50% 50%; }
1751
+ @keyframes rb-pop-spin { to { transform: rotate(360deg); } }
1752
+ @media (prefers-reduced-motion: reduce) { .rb-pop-icon--spin svg { animation: none; } }
1738
1753
  .rb-pop-tx { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0; }
1739
1754
  .rb-pop-name { font-weight: 600; font-size: var(--type-sm); color: var(--fg-0); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1740
1755
  .rb-pop-item--action .rb-pop-name { color: var(--accent); }
@@ -1742,6 +1757,10 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
1742
1757
  .rb-pop-item.is-current .rb-pop-sub { color: var(--fg-1); }
1743
1758
  .rb-pop-check { color: var(--accent); flex: 0 0 auto; }
1744
1759
  .rb-pop-sep { height: 1px; background: var(--border-default); margin: var(--space-2); }
1760
+ /* draft filter — shown only once the draft list is long enough to warrant it. */
1761
+ .rb-search { padding: var(--space-1) var(--space-2) var(--space-2); }
1762
+ .rb-search-input { width: 100%; }
1763
+ .rb-pop-empty { font-family: var(--font-mono); font-size: var(--type-xs); color: var(--fg-2); padding: var(--space-2) var(--space-3); }
1745
1764
  /* fold-back CTA (Task 7) — the one filled action in the Version section. Compact:
1746
1765
  * type-sm title + type-xs sub + tight padding, so it reads as a strong row, not a
1747
1766
  * giant banner. */
@@ -1760,6 +1779,9 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
1760
1779
  .rb-newdraft-input { width: 100%; }
1761
1780
  .rb-newdraft-actions { display: flex; gap: var(--space-2); justify-content: flex-end; }
1762
1781
  .rb-newdraft-err, .rb-switcher-err { color: var(--status-error); font-size: var(--type-xs); }
1782
+ /* In-popup, non-blocking notice (DDR-133) — a Fetch failure shows here, contextual
1783
+ to the action, and never hides the branch list rendered above it. */
1784
+ .rb-pop-notice { margin: var(--space-1) var(--space-2); padding: var(--space-2) var(--space-3); border-radius: var(--radius-sm); background: color-mix(in oklch, var(--status-error) 12%, transparent); color: var(--status-error); font-size: var(--type-xs); line-height: var(--lh-md); }
1763
1785
  .rb-newdraft-hint { margin: 0; color: var(--fg-2); font-size: var(--type-xs); line-height: var(--lh-md); }
1764
1786
  /* switching state (compact, in the dock). */
1765
1787
  .rb-switching { display: flex; align-items: center; gap: var(--space-2); padding: var(--space-2) var(--space-3); border: 1px solid var(--border-default); border-radius: var(--radius-md); background: var(--bg-2); color: var(--fg-1); font-size: var(--type-sm); }