@1agh/maude 0.34.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.
- package/apps/studio/client/app.jsx +90 -3
- package/apps/studio/client/panels/GitPanel.jsx +6 -0
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +199 -68
- package/apps/studio/client/styles/3-shell-maude.css +22 -0
- package/apps/studio/dist/client.bundle.js +21 -53108
- package/apps/studio/dist/comment-mount.js +1 -2048
- package/apps/studio/dist/styles.css +1 -12220
- package/apps/studio/git/endpoints.ts +17 -0
- package/apps/studio/git/service.ts +483 -40
- package/apps/studio/github/endpoints.ts +28 -4
- package/apps/studio/github/identity-cache.ts +138 -0
- package/apps/studio/github/service.ts +40 -23
- package/apps/studio/http.ts +12 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +2 -0
- package/apps/studio/test/git-branches.test.ts +141 -3
- package/apps/studio/test/github-api.test.ts +58 -1
- package/apps/studio/test/identity-cache.test.ts +83 -0
- package/apps/studio/test/remote-ahead-behind-cache.test.ts +89 -0
- package/apps/studio/whats-new.json +18 -0
- package/package.json +15 -10
- package/plugins/flow/.claude-plugin/config.schema.json +4 -4
|
@@ -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> · <
|
|
7
|
-
// UPWARD with a Project section (recents + Open another folder…) and a
|
|
8
|
-
// section (
|
|
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
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 ?
|
|
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">
|
|
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">
|
|
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
|
-
{
|
|
242
|
-
{
|
|
243
|
-
<
|
|
244
|
-
<
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
252
|
-
|
|
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
|
|
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">
|
|
265
|
-
<span className="rb-fold-sub">
|
|
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
|
|
269
|
-
|
|
270
|
-
<
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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={
|
|
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
|
|
289
|
-
<span className="rb-pop-sub">a separate line of work
|
|
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
|
|
299
|
-
<input className="input rb-newdraft-input" type="text" value={draftName} placeholder="
|
|
300
|
-
{slug && <span className="rb-pop-sub">Creates
|
|
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
|
|
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
|
|
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 ? <>
|
|
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} · ${
|
|
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">{
|
|
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
|
-
{/*
|
|
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">
|
|
337
|
-
<p className="rb-sheet-body" id="rb-sheet-body">Everything in <b>“{currentDraft?.name || branch}”</b>
|
|
338
|
-
<p className="rb-sheet-meta">
|
|
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} />
|
|
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); }
|