sidebranch 0.2.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/CHANGELOG.md +83 -0
- package/LICENSE +28 -0
- package/README.md +368 -0
- package/SECURITY.md +161 -0
- package/bin/sidebranch.js +12 -0
- package/package.json +49 -0
- package/src/assets/boot-tag.js +20 -0
- package/src/assets/geist-pixel.LICENSE.txt +133 -0
- package/src/assets/geist-pixel.woff2 +0 -0
- package/src/assets/shell.html +930 -0
- package/src/assets/widget-core.js +898 -0
- package/src/cli.js +342 -0
- package/src/config.js +120 -0
- package/src/daemon.js +323 -0
- package/src/daemonfile.js +156 -0
- package/src/gitops.js +264 -0
- package/src/install.js +115 -0
- package/src/manager.js +260 -0
- package/src/processes.js +250 -0
- package/src/proxy.js +136 -0
- package/src/security.js +123 -0
|
@@ -0,0 +1,898 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sidebranch widget (core) — the entire in-page UI, minus how it gets its
|
|
3
|
+
* credentials. This file defines `globalThis.__sidebranchStart(options)` and
|
|
4
|
+
* does nothing else on load; a *boot* file calls it.
|
|
5
|
+
*
|
|
6
|
+
* There are two boots, because there are two ways to deliver the widget:
|
|
7
|
+
*
|
|
8
|
+
* - `boot-tag.js` — for `<script src="http://localhost:49400/widget.js">`.
|
|
9
|
+
* The daemon concatenates core + this boot and substitutes the token and
|
|
10
|
+
* port into it at response time, exactly as it always has.
|
|
11
|
+
* - the browser extension's content script — which cannot use a rendered
|
|
12
|
+
* template at all, because Manifest V3 forbids executing remotely-fetched
|
|
13
|
+
* code. It ships this file verbatim and calls the same entry point with a
|
|
14
|
+
* token it fetched from `GET /handshake` at runtime.
|
|
15
|
+
*
|
|
16
|
+
* Hence the split: one widget body, two credential sources, no forked code.
|
|
17
|
+
* Nothing in here may assume which boot called it.
|
|
18
|
+
*
|
|
19
|
+
* Behavior contract:
|
|
20
|
+
* - Runs ONLY when the embedding page itself is served from loopback.
|
|
21
|
+
* Anywhere else (including production, if the tag ever ships) it does
|
|
22
|
+
* nothing: no network calls, no DOM, no globals. Inert by construction.
|
|
23
|
+
* - Renders nothing until the daemon has answered — if the daemon isn't
|
|
24
|
+
* running, the page is visually untouched.
|
|
25
|
+
* - The session token lives inside this closure. It is never written to
|
|
26
|
+
* storage, cookies, URLs, or the DOM.
|
|
27
|
+
* - One persistent pill element grows into a toolbar on click (no
|
|
28
|
+
* separate elements swapped in/out); Esc backs out one level at a time
|
|
29
|
+
* (flyout -> toolbar -> nothing); clicking outside collapses it.
|
|
30
|
+
* The "Hide for this session" toggle (in Settings) fades the whole
|
|
31
|
+
* widget out, then hides it for the tab's session (sessionStorage);
|
|
32
|
+
* corner position persists across sessions (localStorage).
|
|
33
|
+
*/
|
|
34
|
+
/* `??=` so that loading core twice in one context (a page with two script
|
|
35
|
+
* tags, say) doesn't replace a definition the first load may already be
|
|
36
|
+
* running from. Note the parameter is a plain identifier destructured in the
|
|
37
|
+
* body rather than `({ token, port })`: a destructuring parameter list makes
|
|
38
|
+
* the "use strict" directive below a SyntaxError. */
|
|
39
|
+
globalThis.__sidebranchStart ??= (options) => {
|
|
40
|
+
"use strict";
|
|
41
|
+
|
|
42
|
+
const { token, port, fontSource, channel = "tag", force = false } = options;
|
|
43
|
+
|
|
44
|
+
// ---- hard gate: loopback pages only ------------------------------------
|
|
45
|
+
// Kept here, not in the boots, so it protects both channels. The extension
|
|
46
|
+
// only injects on loopback matches anyway; this is the belt to that braces.
|
|
47
|
+
const h = location.hostname;
|
|
48
|
+
const isLoop = h === "localhost" || h === "127.0.0.1" || h === "[::1]" || h === "::1" || /^127\./.test(h);
|
|
49
|
+
if (!isLoop) return;
|
|
50
|
+
if (window.__sidebranchLoaded) return;
|
|
51
|
+
|
|
52
|
+
const TOKEN = token;
|
|
53
|
+
const DAEMON = "http://localhost:" + port;
|
|
54
|
+
const HIDE_KEY = "sidebranch:hidden";
|
|
55
|
+
const POS_KEY = "sidebranch:position";
|
|
56
|
+
|
|
57
|
+
/* "Hide for this session" is sessionStorage, so it is per tab and clears
|
|
58
|
+
* itself when the tab closes. `force` is how it gets undone *without*
|
|
59
|
+
* closing the tab: the extension's popup asks for a re-mount, we clear the
|
|
60
|
+
* flag and carry on. The loaded-guard above is deliberately checked before
|
|
61
|
+
* this and cleared by the hide handler, so a forced start after a hide sees
|
|
62
|
+
* a clean slate while a forced start over a *live* widget still no-ops. */
|
|
63
|
+
try {
|
|
64
|
+
if (sessionStorage.getItem(HIDE_KEY) === "1") {
|
|
65
|
+
if (!force) return;
|
|
66
|
+
sessionStorage.removeItem(HIDE_KEY);
|
|
67
|
+
}
|
|
68
|
+
} catch { /* storage blocked — continue */ }
|
|
69
|
+
|
|
70
|
+
window.__sidebranchLoaded = true;
|
|
71
|
+
|
|
72
|
+
const POSITIONS = {
|
|
73
|
+
br: "right:16px;bottom:16px;",
|
|
74
|
+
bl: "left:16px;bottom:16px;",
|
|
75
|
+
tr: "right:16px;top:16px;",
|
|
76
|
+
tl: "left:16px;top:16px;",
|
|
77
|
+
};
|
|
78
|
+
let position = "br";
|
|
79
|
+
try {
|
|
80
|
+
const stored = localStorage.getItem(POS_KEY);
|
|
81
|
+
if (stored && POSITIONS[stored]) position = stored;
|
|
82
|
+
} catch { /* storage blocked — default position */ }
|
|
83
|
+
|
|
84
|
+
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
85
|
+
const SPIN_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
86
|
+
|
|
87
|
+
const api = async (p, opts = {}) => {
|
|
88
|
+
const res = await fetch(DAEMON + p, {
|
|
89
|
+
...opts,
|
|
90
|
+
headers: {
|
|
91
|
+
Authorization: "Bearer " + TOKEN,
|
|
92
|
+
...(opts.body ? { "Content-Type": "application/json" } : {}),
|
|
93
|
+
...(opts.headers || {}),
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
const data = await res.json().catch(() => ({}));
|
|
97
|
+
if (!res.ok) throw Object.assign(new Error(data.error || res.statusText), { code: data.code });
|
|
98
|
+
return data;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// ---- boot: silently probe; render only on success -----------------------
|
|
102
|
+
let state = null;
|
|
103
|
+
api("/api/state")
|
|
104
|
+
.then((s) => { state = s; mount(); })
|
|
105
|
+
.catch(() => { /* daemon not running — stay invisible */ });
|
|
106
|
+
|
|
107
|
+
/* "Geist Pixel" must be registered against the document's font set for the
|
|
108
|
+
* shadow DOM to see it (@font-face inside a shadow root is ignored — see the
|
|
109
|
+
* note in the shadow <style>). document.fonts.add() is the one unavoidable
|
|
110
|
+
* touch of the host document's font set; it's a single named face, loopback-
|
|
111
|
+
* only, and inert if the file can't load — we fall through to the mono stack.
|
|
112
|
+
* Guarded so repeated mounts (or a second widget instance) never double-add.
|
|
113
|
+
*
|
|
114
|
+
* `fontSource` is the second thing (with the token) that differs per channel.
|
|
115
|
+
* The tag channel leaves it undefined and the face is fetched from the daemon
|
|
116
|
+
* by URL. That fetch is issued by the *page's* document, so it answers to the
|
|
117
|
+
* page's `font-src` CSP — fine for the tag channel, which a strict-CSP page
|
|
118
|
+
* would have blocked at the <script> already. The extension has no such luck:
|
|
119
|
+
* it runs on pages that never opted in, so it passes the font in as an
|
|
120
|
+
* ArrayBuffer read from its own package. A FontFace built from binary data
|
|
121
|
+
* performs no fetch at all and therefore cannot be blocked by any page CSP.
|
|
122
|
+
* Either way the failure mode is the same and already handled: no face, mono
|
|
123
|
+
* fallback, everything still legible. */
|
|
124
|
+
function loadFont() {
|
|
125
|
+
if (window.__sidebranchFont || typeof FontFace === "undefined") return;
|
|
126
|
+
window.__sidebranchFont = true;
|
|
127
|
+
try {
|
|
128
|
+
const source = fontSource ?? `url("${DAEMON}/geist-pixel.woff2") format("woff2")`;
|
|
129
|
+
const face = new FontFace("Geist Pixel", source, { display: "swap" });
|
|
130
|
+
face.load().then((f) => document.fonts.add(f)).catch(() => { /* mono fallback */ });
|
|
131
|
+
} catch { /* FontFace unsupported/blocked — mono fallback */ }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function mount() {
|
|
135
|
+
loadFont();
|
|
136
|
+
const host = document.createElement("sidebranch-widget");
|
|
137
|
+
host.style.cssText = "position:fixed;z-index:2147483646;" + POSITIONS[position];
|
|
138
|
+
host.dataset.pos = position;
|
|
139
|
+
const root = host.attachShadow({ mode: "closed" });
|
|
140
|
+
root.innerHTML = `
|
|
141
|
+
<style>
|
|
142
|
+
/* NB: "Geist Pixel" is registered at *document* scope in loadFont() below,
|
|
143
|
+
not with an @font-face here — an @font-face declared inside a shadow root
|
|
144
|
+
is ignored by the browser (font matching resolves against the document's
|
|
145
|
+
font set, never a shadow tree's), so it would silently never apply. */
|
|
146
|
+
:host{all:initial;--ease:cubic-bezier(0.34,0.8,0.23,0.97);--bg:#191919;--surface:#222;
|
|
147
|
+
opacity:1;transition:opacity .22s var(--ease)}
|
|
148
|
+
:host(.hiding){opacity:0}
|
|
149
|
+
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
|
150
|
+
.sb{font:12px/1.45 "Geist Pixel","Geist Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:#d7dce2}
|
|
151
|
+
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
|
|
152
|
+
input{font:inherit;color:inherit;background:none;border:0}
|
|
153
|
+
:focus-visible{outline:1px solid #e3b341;outline-offset:-1px}
|
|
154
|
+
|
|
155
|
+
.dot{display:flex;flex:none;color:#8a949e}
|
|
156
|
+
.dot.ready{color:#59c26f}.dot.busy{color:#e3b341;animation:sb-p 1s infinite}
|
|
157
|
+
.dot.error{color:#e5534b}
|
|
158
|
+
@keyframes sb-p{50%{opacity:.35}}
|
|
159
|
+
@media (prefers-reduced-motion:reduce){.dot.busy{animation:none}}
|
|
160
|
+
|
|
161
|
+
/* ---- the one persistent pill/toolbar element — always fully rounded,
|
|
162
|
+
it only ever grows/shrinks, never squares off ---- */
|
|
163
|
+
.bar{display:flex;align-items:center;border-radius:999px;
|
|
164
|
+
background:var(--bg);border:1px solid rgba(255,255,255,.04);
|
|
165
|
+
box-shadow:0 2px 10px rgba(0,0,0,.2);
|
|
166
|
+
animation:sb-pop .18s var(--ease);padding:4px}
|
|
167
|
+
@keyframes sb-pop{from{opacity:0;transform:scale(.85)}to{opacity:1;transform:scale(1)}}
|
|
168
|
+
@media (prefers-reduced-motion:reduce){.bar{animation:none}}
|
|
169
|
+
|
|
170
|
+
.branch-btn{display:flex;align-items:center;padding:6px;border-radius:999px;
|
|
171
|
+
user-select:none;white-space:nowrap;transition:background .15s var(--ease),padding .27s var(--ease)}
|
|
172
|
+
.branch-btn:hover,.branch-btn[aria-expanded="true"]{background:rgba(255,255,255,.06)}
|
|
173
|
+
.bar.expanded .branch-btn{padding:6px 11px}
|
|
174
|
+
.branch-btn .name{max-width:0;opacity:0;overflow:hidden;text-overflow:ellipsis;line-height: 16px;
|
|
175
|
+
transition:max-width .27s var(--ease),opacity .23s var(--ease),margin-left .27s var(--ease)}
|
|
176
|
+
.bar.expanded .branch-btn .name{max-width:260px;opacity:1;margin-left:7px}
|
|
177
|
+
.branch-btn .chev{flex:none;width:0;opacity:0;overflow:hidden;color:#8a949e;
|
|
178
|
+
transition:width .27s var(--ease),opacity .23s var(--ease),transform .23s var(--ease),margin-left .27s var(--ease)}
|
|
179
|
+
.bar.expanded .branch-btn .chev{width:10px;opacity:1;margin-left:4px}
|
|
180
|
+
.branch-btn[aria-expanded="true"] .chev{transform:rotate(180deg)}
|
|
181
|
+
@media (prefers-reduced-motion:reduce){.branch-btn,.branch-btn .chev,.branch-btn .name{transition:none}}
|
|
182
|
+
|
|
183
|
+
/* grid-template-columns 0fr -> 1fr grows to the content's natural width
|
|
184
|
+
without knowing it up front — the standard dependency-free technique
|
|
185
|
+
for animating toward "auto". */
|
|
186
|
+
.extra-wrap{display:grid;grid-template-columns:0fr;transition:grid-template-columns .31s var(--ease)}
|
|
187
|
+
.bar.expanded .extra-wrap{grid-template-columns:1fr}
|
|
188
|
+
.extra{min-width:0;overflow:hidden;display:flex;align-items:center;gap:3px;
|
|
189
|
+
opacity:0;transition:opacity .25s var(--ease) .04s}
|
|
190
|
+
.bar.expanded .extra{opacity:1}
|
|
191
|
+
@media (prefers-reduced-motion:reduce){.extra-wrap,.extra{transition:none}}
|
|
192
|
+
|
|
193
|
+
.tb-icon{display:flex;align-items:center;justify-content:center;width:30px;height:30px;
|
|
194
|
+
border-radius:999px;color:#8a949e;flex:none;transition:background .15s var(--ease),color .15s var(--ease)}
|
|
195
|
+
.tb-icon:hover,.tb-icon[aria-expanded="true"]{background:rgba(255,255,255,.08);color:#fff}
|
|
196
|
+
.tb-sep{width:1px;height:16px;background:rgba(255,255,255,.1);margin:0 2px;flex:none}
|
|
197
|
+
@media (prefers-reduced-motion:reduce){.tb-icon{transition:none}}
|
|
198
|
+
|
|
199
|
+
/* flyouts and the status bubble are all top-level siblings of .bar (not
|
|
200
|
+
nested inside .extra, which needs overflow:hidden for the morph) so
|
|
201
|
+
none of them are ever clipped by that. All anchor to the same corner. */
|
|
202
|
+
.flyout,.status-bubble{position:absolute;right:0;bottom:calc(100% + 8px);
|
|
203
|
+
border-radius:10px;background:var(--bg);border:1px solid rgba(255,255,255,.13);
|
|
204
|
+
box-shadow:0 8px 28px rgba(0,0,0,.5);
|
|
205
|
+
opacity:0;transform:scale(.96) translateY(6px);pointer-events:none;
|
|
206
|
+
transition:opacity .16s var(--ease),transform .16s var(--ease)}
|
|
207
|
+
.flyout.open,.status-bubble.open{opacity:1;transform:none;pointer-events:auto}
|
|
208
|
+
:host([data-pos^="t"]) .flyout,:host([data-pos^="t"]) .status-bubble{
|
|
209
|
+
bottom:auto;top:calc(100% + 8px);transform:scale(.96) translateY(-6px)}
|
|
210
|
+
:host([data-pos^="t"]) .flyout.open,:host([data-pos^="t"]) .status-bubble.open{transform:none}
|
|
211
|
+
:host([data-pos$="l"]) .flyout,:host([data-pos$="l"]) .status-bubble{right:auto;left:0}
|
|
212
|
+
@media (prefers-reduced-motion:reduce){.flyout,.status-bubble{transition:none}}
|
|
213
|
+
|
|
214
|
+
.flyout{width:250px;max-height:60vh;display:flex;flex-direction:column;overflow:hidden}
|
|
215
|
+
.fly-hd{display:flex;align-items:center;gap:8px;padding:9px 11px;border-bottom:1px solid rgba(255,255,255,.09)}
|
|
216
|
+
.fly-hd b{font-weight:400;color:#fff;flex:1}
|
|
217
|
+
.fly-hd button{display:flex;align-items:center;justify-content:center;width:28px;height:28px;
|
|
218
|
+
color:#8a949e;border-radius:6px;
|
|
219
|
+
transition:background .15s var(--ease),color .15s var(--ease)}
|
|
220
|
+
.fly-hd button:hover{color:#fff;background:rgba(255,255,255,.08)}
|
|
221
|
+
.fly-hd button:disabled{opacity:.6;cursor:default}
|
|
222
|
+
.fly-hd button.spinning svg{animation:sb-spin .8s linear infinite}
|
|
223
|
+
@keyframes sb-spin{to{transform:rotate(360deg)}}
|
|
224
|
+
.fly-filter{width:100%;padding:7px 11px;outline:0;border-bottom:1px solid rgba(255,255,255,.09)}
|
|
225
|
+
.fly-filter::placeholder{color:#626b74}
|
|
226
|
+
/* Replace the UA search-cancel glyph (a gray gradient X) with a plain white,
|
|
227
|
+
round-capped X on a rounded hover chip, matching the icon style here. */
|
|
228
|
+
.fly-filter::-webkit-search-cancel-button{-webkit-appearance:none;appearance:none;
|
|
229
|
+
width:16px;height:16px;border-radius:999px;cursor:pointer;
|
|
230
|
+
background:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23fff' stroke-width='1.6' stroke-linecap='round'%3E%3Cpath d='M4.5 4.5l7 7M11.5 4.5l-7 7'/%3E%3C/svg%3E") center/11px no-repeat;
|
|
231
|
+
transition:background-color .15s var(--ease)}
|
|
232
|
+
.fly-filter::-webkit-search-cancel-button:hover{background-color:rgba(255,255,255,.14)}
|
|
233
|
+
.fly-list{overflow-y:auto;flex:1;max-height:280px}
|
|
234
|
+
.row{display:flex;align-items:center;gap:8px;width:100%;padding:7px 11px;text-align:left;
|
|
235
|
+
transition:background .15s var(--ease)}
|
|
236
|
+
.row:hover,.row:focus-visible{background:rgba(255,255,255,.06);outline:none}
|
|
237
|
+
.row .name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
238
|
+
.row[aria-selected="true"]{background:rgba(89,194,111,.12)}
|
|
239
|
+
.row .cur{color:#59c26f}
|
|
240
|
+
.row .check{display:flex;flex:none;color:#59c26f;opacity:0}
|
|
241
|
+
.row[aria-selected="true"] .check{opacity:1}
|
|
242
|
+
.row-empty{padding:10px 11px;color:#626b74}
|
|
243
|
+
|
|
244
|
+
.fly-settings{padding:0;width:auto}
|
|
245
|
+
.fly-body{padding:11px}
|
|
246
|
+
.fs-label{color:#8a949e;font-size:11px;padding-bottom:9px}
|
|
247
|
+
|
|
248
|
+
/* one rectangle standing in for "the page", with a dot inset at each
|
|
249
|
+
corner — clicking one picks that corner. The active dot turns solid
|
|
250
|
+
white and grows into a small pill, pinned to the same two edges it's
|
|
251
|
+
already anchored to (e.g. "br" is pinned right+bottom, so it grows
|
|
252
|
+
leftward) — the same corner-pinned growth the real pill/toolbar uses
|
|
253
|
+
in POSITIONS above, just in miniature. */
|
|
254
|
+
.pos-frame{position:relative;width:100%;height:96px;margin:0 auto 18px;
|
|
255
|
+
border-radius:10px;background:var(--surface)}
|
|
256
|
+
.pos-dot{position:absolute;width:12px;height:12px;border-radius:999px;
|
|
257
|
+
background:rgba(255,255,255,.28);
|
|
258
|
+
transition:width .2s var(--ease),background .15s var(--ease)}
|
|
259
|
+
.pos-dot:hover{background:rgba(255,255,255,.55)}
|
|
260
|
+
.pos-dot[aria-pressed="true"]{background:#fff;width:28px}
|
|
261
|
+
.pos-dot[data-pos="tl"]{left:8px;top:8px}
|
|
262
|
+
.pos-dot[data-pos="tr"]{right:8px;top:8px}
|
|
263
|
+
.pos-dot[data-pos="bl"]{left:8px;bottom:8px}
|
|
264
|
+
.pos-dot[data-pos="br"]{right:8px;bottom:8px}
|
|
265
|
+
|
|
266
|
+
.fly-info{width:290px}
|
|
267
|
+
.fly-info .fly-body{overflow-y:auto;max-height:52vh}
|
|
268
|
+
.info-sec{margin-bottom:12px}
|
|
269
|
+
.info-sec:last-child{margin-bottom:0}
|
|
270
|
+
.info-note{color:#8a949e;font-size:11px;line-height:1.5;margin:0 0 8px;overflow-wrap:anywhere}
|
|
271
|
+
.info-note b{color:#d7dce2;font-weight:400}
|
|
272
|
+
.cmd{display:flex;align-items:center;gap:8px;width:100%;text-align:left;padding:6px 8px;
|
|
273
|
+
border-radius:6px;background:rgba(255,255,255,.05);margin-bottom:4px;
|
|
274
|
+
transition:background .12s ease}
|
|
275
|
+
.cmd:hover{background:rgba(255,255,255,.1)}
|
|
276
|
+
.cmd code{flex:1;font:inherit;color:#d7dce2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
277
|
+
.cmd .copied{color:#7ee0c0;font-size:10px;opacity:0;transition:opacity .12s ease}
|
|
278
|
+
.cmd.done .copied{opacity:1}
|
|
279
|
+
.cmd svg{flex:none;color:#626b74}
|
|
280
|
+
.cmd:hover svg{color:#8a949e}
|
|
281
|
+
@media (prefers-reduced-motion:reduce){.cmd,.cmd .copied{transition:none}}
|
|
282
|
+
.fly-toggle{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:2px 0}
|
|
283
|
+
.fly-toggle-label{color:#8a949e;font-size:11px;}
|
|
284
|
+
.switch{flex:none;width:32px;height:18px;border-radius:999px;background:rgba(255,255,255,.16);
|
|
285
|
+
padding:2px;transition:background .15s var(--ease)}
|
|
286
|
+
.switch::before{content:"";display:block;width:14px;height:14px;border-radius:999px;background:#d7dce2;
|
|
287
|
+
transition:transform .15s var(--ease),background .15s var(--ease)}
|
|
288
|
+
.switch[aria-checked="true"]{background:#e3b341}
|
|
289
|
+
.switch[aria-checked="true"]::before{transform:translateX(14px);background:#191919}
|
|
290
|
+
@media (prefers-reduced-motion:reduce){.fly-hd button,.row,.pos-dot,.switch,.switch::before{transition:none}
|
|
291
|
+
.fly-hd button.spinning svg{animation:none}}
|
|
292
|
+
|
|
293
|
+
.status-bubble{display:flex;align-items:center;gap:7px;padding:7px 11px;white-space:nowrap}
|
|
294
|
+
.status-bubble.err{border-color:rgba(229,83,75,.35)}
|
|
295
|
+
.status-bubble.err .msg{color:#e5534b}
|
|
296
|
+
.spinner{min-width:1ch;text-align:center;color:#e3b341;flex:none}
|
|
297
|
+
.msg{animation:sb-fade .18s var(--ease)}
|
|
298
|
+
@keyframes sb-fade{from{opacity:0}to{opacity:1}}
|
|
299
|
+
@media (prefers-reduced-motion:reduce){.msg{animation:none}}
|
|
300
|
+
|
|
301
|
+
/* one shared tooltip, positioned via JS getBoundingClientRect (like
|
|
302
|
+
shell.html's #tooltip) rather than CSS anchored to its trigger, since
|
|
303
|
+
triggers live at every corner of the screen depending on position,
|
|
304
|
+
and .extra's overflow:hidden would clip anything anchored inside it. */
|
|
305
|
+
.tooltip{position:fixed;left:0;top:0;z-index:2147483647;padding:6px 10px;border-radius:8px;
|
|
306
|
+
background:var(--bg);border:1px solid rgba(255,255,255,.13);
|
|
307
|
+
box-shadow:0 8px 28px rgba(0,0,0,.5);font-size:11px;color:#d7dce2;white-space:nowrap;
|
|
308
|
+
max-width:220px;pointer-events:none;
|
|
309
|
+
transform:translate(-50%,-100%) scale(.96);
|
|
310
|
+
opacity:0;transition:opacity .14s var(--ease),transform .14s var(--ease)}
|
|
311
|
+
.tooltip.open{opacity:1;transform:translate(-50%,-100%) scale(1)}
|
|
312
|
+
.tooltip.below{transform:translate(-50%,0) scale(.96)}
|
|
313
|
+
.tooltip.below.open{transform:translate(-50%,0) scale(1)}
|
|
314
|
+
@media (prefers-reduced-motion:reduce){.tooltip{transition:none}}
|
|
315
|
+
</style>
|
|
316
|
+
<div class="sb">
|
|
317
|
+
<div class="bar" id="bar">
|
|
318
|
+
<button class="branch-btn" aria-haspopup="listbox" aria-expanded="false" aria-label="sidebranch">
|
|
319
|
+
<span class="dot" aria-hidden="true">
|
|
320
|
+
<svg viewBox="0 0 100 100" width="16" height="16" fill="currentColor">
|
|
321
|
+
<path d="M45 11C50.5228 11 55 15.4772 55 21V39H40C34.4772 39 30 43.4772 30 49V78C30 83.5228 34.4772 88 40 88H26C20.4772 88 16 83.5228 16 78V21C16 15.4772 20.4772 11 26 11H45ZM73 39C78.5228 39 83 43.4772 83 49V78C83 83.5228 78.5228 88 73 88H45C50.5228 88 55 83.5228 55 78V39H73Z"/>
|
|
322
|
+
</svg>
|
|
323
|
+
</span><span class="name">sidebranch</span>
|
|
324
|
+
<svg class="chev" viewBox="0 0 10 6" width="10" height="6" aria-hidden="true">
|
|
325
|
+
<path d="M1 1l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
|
|
326
|
+
</svg>
|
|
327
|
+
</button>
|
|
328
|
+
<div class="extra-wrap">
|
|
329
|
+
<div class="extra">
|
|
330
|
+
<button class="tb-icon" data-action="compare" data-tip="Compare side by side (new tab)" aria-label="Compare side by side (opens in a new tab)">
|
|
331
|
+
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
|
|
332
|
+
<rect x="1.5" y="3" width="5.5" height="10" rx="1" fill="none" stroke="currentColor" stroke-width="1.3"/>
|
|
333
|
+
<rect x="9" y="3" width="5.5" height="10" rx="1" fill="none" stroke="currentColor" stroke-width="1.3"/>
|
|
334
|
+
</svg>
|
|
335
|
+
</button>
|
|
336
|
+
<button class="tb-icon" data-toggle="external" aria-haspopup="listbox" aria-expanded="false" data-tip="Open branch in new tab" aria-label="Open branch in a new tab">
|
|
337
|
+
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">
|
|
338
|
+
<path d="M6.5 3H3.6A1.1 1.1 0 0 0 2.5 4.1v8.4A1.1 1.1 0 0 0 3.6 13.6h8.4a1.1 1.1 0 0 0 1.1-1.1V9.5"/>
|
|
339
|
+
<path d="M9 2.5h4.5V7"/><path d="M13.4 2.6 7.7 8.3"/>
|
|
340
|
+
</svg>
|
|
341
|
+
</button>
|
|
342
|
+
<div class="tb-sep"></div>
|
|
343
|
+
<button class="tb-icon" data-toggle="info" aria-haspopup="true" aria-expanded="false" data-tip="Setup commands" aria-label="Setup and teardown commands">
|
|
344
|
+
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">
|
|
345
|
+
<circle cx="8" cy="8" r="6.2"/><path d="M8 7.3v4"/><path d="M8 4.9h.01"/>
|
|
346
|
+
</svg>
|
|
347
|
+
</button>
|
|
348
|
+
<button class="tb-icon" data-toggle="settings" aria-haspopup="true" aria-expanded="false" data-tip="Settings" aria-label="Settings">
|
|
349
|
+
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round">
|
|
350
|
+
<line x1="2" y1="5" x2="14" y2="5"/><circle cx="6.5" cy="5" r="1.5" fill="currentColor" stroke="none"/>
|
|
351
|
+
<line x1="2" y1="11" x2="14" y2="11"/><circle cx="10" cy="11" r="1.5" fill="currentColor" stroke="none"/>
|
|
352
|
+
</svg>
|
|
353
|
+
</button>
|
|
354
|
+
<button class="tb-icon tb-close" data-action="close" data-tip="Collapse" aria-label="Collapse toolbar">
|
|
355
|
+
<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
|
|
356
|
+
<path d="M4 4l8 8M12 4l-8 8"/>
|
|
357
|
+
</svg>
|
|
358
|
+
</button>
|
|
359
|
+
</div>
|
|
360
|
+
</div>
|
|
361
|
+
</div>
|
|
362
|
+
|
|
363
|
+
<div class="flyout" data-flyout="switch" role="listbox" aria-label="Switch to branch">
|
|
364
|
+
<div class="fly-hd"><b>Switch to</b>
|
|
365
|
+
<button data-fetch data-tip="git fetch --all" aria-label="Fetch remotes">
|
|
366
|
+
<svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>
|
|
367
|
+
</button>
|
|
368
|
+
</div>
|
|
369
|
+
<input class="fly-filter" type="search" placeholder="Filter branches…" aria-label="Filter branches">
|
|
370
|
+
<div class="fly-list"></div>
|
|
371
|
+
</div>
|
|
372
|
+
<div class="flyout" data-flyout="external" role="listbox" aria-label="Open branch in a new tab">
|
|
373
|
+
<div class="fly-hd"><b>Open in new tab</b>
|
|
374
|
+
<button data-fetch data-tip="git fetch --all" aria-label="Fetch remotes">
|
|
375
|
+
<svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>
|
|
376
|
+
</button>
|
|
377
|
+
</div>
|
|
378
|
+
<input class="fly-filter" type="search" placeholder="Filter branches…" aria-label="Filter branches">
|
|
379
|
+
<div class="fly-list"></div>
|
|
380
|
+
</div>
|
|
381
|
+
<div class="flyout fly-info" data-flyout="info">
|
|
382
|
+
<div class="fly-hd"><b>Running sidebranch</b></div>
|
|
383
|
+
<div class="fly-body">
|
|
384
|
+
<div class="info-sec">
|
|
385
|
+
<p class="info-note" data-info-channel></p>
|
|
386
|
+
</div>
|
|
387
|
+
<div class="info-sec">
|
|
388
|
+
<div class="fs-label">Start</div>
|
|
389
|
+
<div data-info-start></div>
|
|
390
|
+
</div>
|
|
391
|
+
<div class="info-sec">
|
|
392
|
+
<div class="fs-label">Stop</div>
|
|
393
|
+
<div data-info-stop></div>
|
|
394
|
+
</div>
|
|
395
|
+
<div class="info-sec">
|
|
396
|
+
<div class="fs-label" data-info-alt-label></div>
|
|
397
|
+
<p class="info-note" data-info-alt></p>
|
|
398
|
+
</div>
|
|
399
|
+
</div>
|
|
400
|
+
</div>
|
|
401
|
+
<div class="flyout fly-settings" data-flyout="settings">
|
|
402
|
+
<div class="fly-hd"><b>Settings</b></div>
|
|
403
|
+
<div class="fly-body">
|
|
404
|
+
<div class="fs-label">Position</div>
|
|
405
|
+
<div class="pos-frame" role="group" aria-label="Widget position">
|
|
406
|
+
<button class="pos-dot" data-pos="tl" aria-pressed="false" aria-label="Top left" data-tip="Top left"></button>
|
|
407
|
+
<button class="pos-dot" data-pos="tr" aria-pressed="false" aria-label="Top right" data-tip="Top right"></button>
|
|
408
|
+
<button class="pos-dot" data-pos="bl" aria-pressed="false" aria-label="Bottom left" data-tip="Bottom left"></button>
|
|
409
|
+
<button class="pos-dot" data-pos="br" aria-pressed="false" aria-label="Bottom right" data-tip="Bottom right"></button>
|
|
410
|
+
</div>
|
|
411
|
+
<div class="fly-toggle">
|
|
412
|
+
<span class="fly-toggle-label">Hide for this session</span>
|
|
413
|
+
<button class="switch" role="switch" aria-checked="false" data-hide-toggle aria-label="Hide widget for this session"></button>
|
|
414
|
+
</div>
|
|
415
|
+
</div>
|
|
416
|
+
</div>
|
|
417
|
+
|
|
418
|
+
<div class="status-bubble" role="status" aria-live="polite">
|
|
419
|
+
<span class="spinner" aria-hidden="true"></span><span class="msg"></span>
|
|
420
|
+
</div>
|
|
421
|
+
<div class="tooltip" role="tooltip"><span class="tt-text"></span></div>
|
|
422
|
+
</div>`;
|
|
423
|
+
|
|
424
|
+
// Append as early as possible: even if something below throws, the
|
|
425
|
+
// pill itself is already visible rather than silently never existing.
|
|
426
|
+
document.documentElement.appendChild(host);
|
|
427
|
+
|
|
428
|
+
try {
|
|
429
|
+
wire(root, host);
|
|
430
|
+
} catch (err) {
|
|
431
|
+
console.error("sidebranch widget failed to initialize:", err);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function wire(root, host) {
|
|
436
|
+
const $ = (s) => root.querySelector(s);
|
|
437
|
+
const bar = $(".bar");
|
|
438
|
+
const branchBtn = $(".branch-btn"), branchDot = $(".branch-btn .dot"), branchName = $(".branch-btn .name");
|
|
439
|
+
const btnCompare = $('[data-action="compare"]');
|
|
440
|
+
const btnExternalToggle = $('[data-toggle="external"]');
|
|
441
|
+
const btnInfoToggle = $('[data-toggle="info"]');
|
|
442
|
+
const btnSettingsToggle = $('[data-toggle="settings"]');
|
|
443
|
+
const btnClose = $('[data-action="close"]');
|
|
444
|
+
const flySwitch = $('[data-flyout="switch"]');
|
|
445
|
+
const flyExternal = $('[data-flyout="external"]');
|
|
446
|
+
const flyInfo = $('[data-flyout="info"]');
|
|
447
|
+
const flySettings = $('[data-flyout="settings"]');
|
|
448
|
+
const statusBubble = $(".status-bubble");
|
|
449
|
+
const statusMsg = statusBubble.querySelector(".msg");
|
|
450
|
+
const statusSpinner = statusBubble.querySelector(".spinner");
|
|
451
|
+
const posButtons = root.querySelectorAll("[data-pos]");
|
|
452
|
+
const hideToggle = $("[data-hide-toggle]");
|
|
453
|
+
const tooltip = $(".tooltip");
|
|
454
|
+
const tooltipText = tooltip.querySelector(".tt-text");
|
|
455
|
+
|
|
456
|
+
let expanded = false;
|
|
457
|
+
let openFlyoutName = null; // "switch" | "external" | "info" | "settings" | null
|
|
458
|
+
let busy = false;
|
|
459
|
+
let filterText = "";
|
|
460
|
+
let activeAction = null; // { kind: "switch"|"open", pane } — correlates SSE progress
|
|
461
|
+
let spinTimer = null;
|
|
462
|
+
let hideTimer = null;
|
|
463
|
+
let tooltipModeActive = false;
|
|
464
|
+
let tooltipShowTimer = null;
|
|
465
|
+
let tooltipModeTimer = null;
|
|
466
|
+
|
|
467
|
+
const paneById = (id) => state?.panes?.find((p) => p.id === id) || null;
|
|
468
|
+
|
|
469
|
+
/** Is *this tab* currently looking at one of the daemon's pane servers? */
|
|
470
|
+
function currentPaneId() {
|
|
471
|
+
const myPort = location.port;
|
|
472
|
+
if (!myPort) return null;
|
|
473
|
+
const p = state?.panes?.find((pane) => String(pane.port) === myPort);
|
|
474
|
+
return p ? p.id : null;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** The branch this tab is actually showing right now, for orientation:
|
|
478
|
+
* the bound pane's branch if we're on one, else the reviewer's own
|
|
479
|
+
* working tree's current branch (not just a static "sidebranch" label). */
|
|
480
|
+
function currentBranchLabel() {
|
|
481
|
+
const curId = currentPaneId();
|
|
482
|
+
if (curId) return paneById(curId)?.branch || "…";
|
|
483
|
+
return state?.main?.branch || "sidebranch";
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function startSpinner() {
|
|
487
|
+
stopSpinner();
|
|
488
|
+
if (reducedMotion) { statusSpinner.textContent = "•"; return; }
|
|
489
|
+
let i = 0;
|
|
490
|
+
statusSpinner.textContent = SPIN_FRAMES[0];
|
|
491
|
+
spinTimer = setInterval(() => {
|
|
492
|
+
i = (i + 1) % SPIN_FRAMES.length;
|
|
493
|
+
statusSpinner.textContent = SPIN_FRAMES[i];
|
|
494
|
+
}, 90);
|
|
495
|
+
}
|
|
496
|
+
function stopSpinner() {
|
|
497
|
+
if (spinTimer) { clearInterval(spinTimer); spinTimer = null; }
|
|
498
|
+
statusSpinner.textContent = "";
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function showStatus(text, isErr = false) {
|
|
502
|
+
closeFlyout();
|
|
503
|
+
statusBubble.classList.add("open");
|
|
504
|
+
statusBubble.classList.toggle("err", isErr);
|
|
505
|
+
statusMsg.classList.remove("msg"); void statusMsg.offsetWidth; statusMsg.classList.add("msg");
|
|
506
|
+
statusMsg.textContent = text;
|
|
507
|
+
if (isErr) stopSpinner(); else startSpinner();
|
|
508
|
+
}
|
|
509
|
+
function hideStatus() {
|
|
510
|
+
statusBubble.classList.remove("open", "err");
|
|
511
|
+
stopSpinner();
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/* -------------------------------- tooltips --------------------------------
|
|
515
|
+
* Delayed like a native title (~1s) on first hover, but once one has
|
|
516
|
+
* opened, hovering straight into the next trigger reopens instantly for
|
|
517
|
+
* a short grace period — the same "tooltip mode" convention used by
|
|
518
|
+
* shell.html's dock tooltip, so a reviewer skimming several icons in a
|
|
519
|
+
* row doesn't re-wait out the delay for each one. */
|
|
520
|
+
const TOOLTIP_DELAY = 1000;
|
|
521
|
+
const TOOLTIP_MODE_GRACE = 400;
|
|
522
|
+
function showTooltipNow(el) {
|
|
523
|
+
const text = el.dataset.tip;
|
|
524
|
+
if (!text) return;
|
|
525
|
+
tooltipText.textContent = text;
|
|
526
|
+
const rect = el.getBoundingClientRect();
|
|
527
|
+
const below = host.dataset.pos.startsWith("t");
|
|
528
|
+
tooltip.classList.toggle("below", below);
|
|
529
|
+
tooltip.style.left = rect.left + rect.width / 2 + "px";
|
|
530
|
+
tooltip.style.top = (below ? rect.bottom + 8 : rect.top - 8) + "px";
|
|
531
|
+
tooltip.classList.add("open");
|
|
532
|
+
}
|
|
533
|
+
function scheduleTooltip(el) {
|
|
534
|
+
clearTimeout(tooltipShowTimer);
|
|
535
|
+
clearTimeout(tooltipModeTimer);
|
|
536
|
+
if (tooltipModeActive) showTooltipNow(el);
|
|
537
|
+
else tooltipShowTimer = setTimeout(() => { tooltipModeActive = true; showTooltipNow(el); }, TOOLTIP_DELAY);
|
|
538
|
+
}
|
|
539
|
+
function hideTooltip() {
|
|
540
|
+
clearTimeout(tooltipShowTimer);
|
|
541
|
+
tooltip.classList.remove("open");
|
|
542
|
+
clearTimeout(tooltipModeTimer);
|
|
543
|
+
tooltipModeTimer = setTimeout(() => { tooltipModeActive = false; }, TOOLTIP_MODE_GRACE);
|
|
544
|
+
}
|
|
545
|
+
for (const el of root.querySelectorAll("[data-tip]")) {
|
|
546
|
+
el.addEventListener("mouseenter", () => scheduleTooltip(el));
|
|
547
|
+
el.addEventListener("mouseleave", hideTooltip);
|
|
548
|
+
// Only auto-show on a real keyboard visit, not the focus a click also
|
|
549
|
+
// produces — otherwise dismissing on mousedown (below) would just get
|
|
550
|
+
// immediately undone by the focus event a click fires right after it.
|
|
551
|
+
el.addEventListener("focus", () => { if (el.matches(":focus-visible")) showTooltipNow(el); });
|
|
552
|
+
el.addEventListener("blur", hideTooltip);
|
|
553
|
+
// Clicking into the action the tooltip was explaining should dismiss
|
|
554
|
+
// it right away — the reviewer already knows what they clicked.
|
|
555
|
+
el.addEventListener("mousedown", hideTooltip);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function render() {
|
|
559
|
+
const curId = currentPaneId();
|
|
560
|
+
const curPane = curId ? paneById(curId) : null;
|
|
561
|
+
const dotClass = "dot " + (busy ? "busy" : curPane?.status === "error" ? "error" : curPane?.status === "ready" ? "ready" : "");
|
|
562
|
+
branchDot.className = dotClass;
|
|
563
|
+
branchName.textContent = currentBranchLabel();
|
|
564
|
+
if (openFlyoutName === "switch" || openFlyoutName === "external") renderList();
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/* ------------------------------ expand/collapse ------------------------------ */
|
|
568
|
+
function setExpanded(v) {
|
|
569
|
+
expanded = v;
|
|
570
|
+
bar.classList.toggle("expanded", v);
|
|
571
|
+
const extraWrap = $(".extra-wrap");
|
|
572
|
+
extraWrap.inert = !v;
|
|
573
|
+
if (!v) { closeFlyout(); hideStatus(); tooltip.classList.remove("open"); }
|
|
574
|
+
else render();
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function closeFlyout() {
|
|
578
|
+
if (!openFlyoutName) return;
|
|
579
|
+
openFlyoutName = null;
|
|
580
|
+
flySwitch.classList.remove("open"); flySwitch.inert = true;
|
|
581
|
+
flyExternal.classList.remove("open"); flyExternal.inert = true;
|
|
582
|
+
flyInfo.classList.remove("open"); flyInfo.inert = true;
|
|
583
|
+
flySettings.classList.remove("open"); flySettings.inert = true;
|
|
584
|
+
branchBtn.setAttribute("aria-expanded", "false");
|
|
585
|
+
btnExternalToggle.setAttribute("aria-expanded", "false");
|
|
586
|
+
btnInfoToggle.setAttribute("aria-expanded", "false");
|
|
587
|
+
btnSettingsToggle.setAttribute("aria-expanded", "false");
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function showFlyout(name) {
|
|
591
|
+
closeFlyout();
|
|
592
|
+
hideStatus();
|
|
593
|
+
openFlyoutName = name;
|
|
594
|
+
if (name === "switch") {
|
|
595
|
+
filterText = ""; flySwitch.querySelector(".fly-filter").value = "";
|
|
596
|
+
flySwitch.classList.add("open"); flySwitch.inert = false;
|
|
597
|
+
branchBtn.setAttribute("aria-expanded", "true");
|
|
598
|
+
renderList();
|
|
599
|
+
requestAnimationFrame(() => flySwitch.querySelector(".fly-filter").focus());
|
|
600
|
+
} else if (name === "external") {
|
|
601
|
+
filterText = ""; flyExternal.querySelector(".fly-filter").value = "";
|
|
602
|
+
flyExternal.classList.add("open"); flyExternal.inert = false;
|
|
603
|
+
btnExternalToggle.setAttribute("aria-expanded", "true");
|
|
604
|
+
renderList();
|
|
605
|
+
requestAnimationFrame(() => flyExternal.querySelector(".fly-filter").focus());
|
|
606
|
+
} else if (name === "info") {
|
|
607
|
+
renderInfo();
|
|
608
|
+
flyInfo.classList.add("open"); flyInfo.inert = false;
|
|
609
|
+
btnInfoToggle.setAttribute("aria-expanded", "true");
|
|
610
|
+
} else if (name === "settings") {
|
|
611
|
+
flySettings.classList.add("open"); flySettings.inert = false;
|
|
612
|
+
btnSettingsToggle.setAttribute("aria-expanded", "true");
|
|
613
|
+
for (const b of posButtons) b.setAttribute("aria-pressed", String(b.dataset.pos === position));
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/* ------------------------------- setup info -------------------------------
|
|
618
|
+
* The commands people actually need, with this daemon's real port already
|
|
619
|
+
* substituted, and with the two delivery channels described honestly:
|
|
620
|
+
* whichever one you are reading this through is the one described first.
|
|
621
|
+
* `channel` is supplied by the boot — the widget body itself has no way to
|
|
622
|
+
* know how it was delivered, and must not guess. */
|
|
623
|
+
const PORT_FLAG = String(port) === "49400" ? "" : ` --port ${port}`;
|
|
624
|
+
|
|
625
|
+
function cmdRow(text) {
|
|
626
|
+
const btn = document.createElement("button");
|
|
627
|
+
btn.className = "cmd";
|
|
628
|
+
btn.type = "button";
|
|
629
|
+
// No data-tip: tooltips are bound once over the static markup at wire()
|
|
630
|
+
// time, and these rows are built on demand. The aria-label and the
|
|
631
|
+
// inline "copied" flash carry it instead.
|
|
632
|
+
btn.setAttribute("aria-label", `Copy: ${text}`);
|
|
633
|
+
const code = document.createElement("code");
|
|
634
|
+
code.textContent = text;
|
|
635
|
+
const copied = document.createElement("span");
|
|
636
|
+
copied.className = "copied";
|
|
637
|
+
copied.textContent = "copied";
|
|
638
|
+
const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
639
|
+
icon.setAttribute("viewBox", "0 0 16 16");
|
|
640
|
+
icon.setAttribute("width", "12");
|
|
641
|
+
icon.setAttribute("height", "12");
|
|
642
|
+
icon.setAttribute("aria-hidden", "true");
|
|
643
|
+
icon.setAttribute("fill", "none");
|
|
644
|
+
icon.setAttribute("stroke", "currentColor");
|
|
645
|
+
icon.setAttribute("stroke-width", "1.3");
|
|
646
|
+
const p1 = document.createElementNS("http://www.w3.org/2000/svg", "rect");
|
|
647
|
+
p1.setAttribute("x", "5.5"); p1.setAttribute("y", "5.5");
|
|
648
|
+
p1.setAttribute("width", "8"); p1.setAttribute("height", "8"); p1.setAttribute("rx", "1.4");
|
|
649
|
+
const p2 = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
650
|
+
p2.setAttribute("d", "M10.5 3.5A1.5 1.5 0 0 0 9 2.5H4A1.5 1.5 0 0 0 2.5 4v5a1.5 1.5 0 0 0 1 1.4");
|
|
651
|
+
icon.append(p1, p2);
|
|
652
|
+
btn.append(code, copied, icon);
|
|
653
|
+
btn.onclick = async () => {
|
|
654
|
+
try { await navigator.clipboard.writeText(text); } catch { return; }
|
|
655
|
+
btn.classList.add("done");
|
|
656
|
+
setTimeout(() => btn.classList.remove("done"), 1200);
|
|
657
|
+
};
|
|
658
|
+
return btn;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function fillCommands(el, commands) {
|
|
662
|
+
el.replaceChildren(...commands.map(cmdRow));
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function renderInfo() {
|
|
666
|
+
const viaExtension = channel === "extension";
|
|
667
|
+
$("[data-info-channel]").textContent = viaExtension
|
|
668
|
+
? "You're seeing this through the browser extension, so this app needs no script tag — only a running daemon."
|
|
669
|
+
: "You're seeing this through the script tag in this app's HTML.";
|
|
670
|
+
|
|
671
|
+
fillCommands($("[data-info-start]"), [
|
|
672
|
+
"npx sidebranch init",
|
|
673
|
+
`npx sidebranch start${PORT_FLAG}`,
|
|
674
|
+
]);
|
|
675
|
+
fillCommands($("[data-info-stop]"), [
|
|
676
|
+
"npx sidebranch stop",
|
|
677
|
+
"npx sidebranch clean",
|
|
678
|
+
]);
|
|
679
|
+
|
|
680
|
+
$("[data-info-alt-label]").textContent = viaExtension ? "Without the extension" : "With the extension";
|
|
681
|
+
$("[data-info-alt]").textContent = viaExtension
|
|
682
|
+
? `Add <script src="${DAEMON}/widget.js" defer> to the app, in dev builds only.`
|
|
683
|
+
: "Install the sidebranch extension and you can drop the script tag — it puts this widget on every loopback app, no HTML changes.";
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function toggleFlyout(name) {
|
|
687
|
+
if (openFlyoutName === name) closeFlyout();
|
|
688
|
+
else showFlyout(name);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/* --------------------------------- branch lists -------------------------------- */
|
|
692
|
+
function renderList() {
|
|
693
|
+
const target = openFlyoutName === "switch" ? flySwitch : openFlyoutName === "external" ? flyExternal : null;
|
|
694
|
+
if (!target) return;
|
|
695
|
+
const list = target.querySelector(".fly-list");
|
|
696
|
+
// The branch this tab is actually on — the bound pane's branch if we're
|
|
697
|
+
// on a pane, else the reviewer's own working-tree branch. Mirrors
|
|
698
|
+
// currentBranchLabel() so the highlighted row always matches the pill.
|
|
699
|
+
// (Previously fell back to pane A's branch when not on a pane, which lit
|
|
700
|
+
// up a seemingly-random branch unrelated to what this tab shows.)
|
|
701
|
+
const curId = currentPaneId();
|
|
702
|
+
const curBranch = curId ? paneById(curId)?.branch : state?.main?.branch;
|
|
703
|
+
const branches = (state?.branches || []).filter((br) => br.name.includes(filterText));
|
|
704
|
+
if (branches.length === 0) {
|
|
705
|
+
list.replaceChildren(Object.assign(document.createElement("div"), { className: "row-empty", textContent: "No matching branches." }));
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
list.replaceChildren(...branches.slice(0, 200).map((br) => {
|
|
709
|
+
const row = document.createElement("button");
|
|
710
|
+
row.type = "button";
|
|
711
|
+
row.className = "row";
|
|
712
|
+
row.setAttribute("role", "option");
|
|
713
|
+
const isCurrent = br.name === curBranch;
|
|
714
|
+
row.setAttribute("aria-selected", String(isCurrent));
|
|
715
|
+
const name = document.createElement("span");
|
|
716
|
+
name.className = "name" + (isCurrent ? " cur" : "");
|
|
717
|
+
name.textContent = br.name + (br.local ? "" : " ⇣");
|
|
718
|
+
row.appendChild(name);
|
|
719
|
+
const check = document.createElement("span");
|
|
720
|
+
check.className = "check";
|
|
721
|
+
check.setAttribute("aria-hidden", "true");
|
|
722
|
+
check.innerHTML = `<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 8.5l3 3 6-6.5"/></svg>`;
|
|
723
|
+
row.appendChild(check);
|
|
724
|
+
row.onclick = () => {
|
|
725
|
+
if (openFlyoutName === "switch") doSwitch(br.name);
|
|
726
|
+
else doOpenNewTab(br.name);
|
|
727
|
+
};
|
|
728
|
+
return row;
|
|
729
|
+
}));
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function wireFilter(flyoutEl) {
|
|
733
|
+
const input = flyoutEl.querySelector(".fly-filter");
|
|
734
|
+
input.oninput = () => { filterText = input.value.trim(); renderList(); };
|
|
735
|
+
input.addEventListener("keydown", (e) => {
|
|
736
|
+
if (e.key === "Escape") { e.stopPropagation(); closeFlyout(); branchBtn.focus(); }
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
wireFilter(flySwitch);
|
|
740
|
+
wireFilter(flyExternal);
|
|
741
|
+
|
|
742
|
+
async function doFetch(e) {
|
|
743
|
+
e.stopPropagation();
|
|
744
|
+
const btn = e.currentTarget;
|
|
745
|
+
btn.disabled = true;
|
|
746
|
+
btn.classList.add("spinning");
|
|
747
|
+
showStatus("Fetching remotes…");
|
|
748
|
+
try { await api("/api/fetch", { method: "POST" }); await refresh(); hideStatus(); }
|
|
749
|
+
catch (err) { showStatus(err.message, true); }
|
|
750
|
+
finally { btn.disabled = false; btn.classList.remove("spinning"); }
|
|
751
|
+
}
|
|
752
|
+
flySwitch.querySelector("[data-fetch]").onclick = doFetch;
|
|
753
|
+
flyExternal.querySelector("[data-fetch]").onclick = doFetch;
|
|
754
|
+
|
|
755
|
+
/* ------------------------------ actions ------------------------------ */
|
|
756
|
+
/** If this tab is already a pane, switching branch updates that same
|
|
757
|
+
* pane and reloads in place. Otherwise (this tab is the reviewer's own
|
|
758
|
+
* working tree) it builds pane A and navigates the tab into it — we
|
|
759
|
+
* never touch the working tree's own dev server. */
|
|
760
|
+
async function doSwitch(branch) {
|
|
761
|
+
const here = currentPaneId();
|
|
762
|
+
const paneId = here || "a";
|
|
763
|
+
const pane = paneById(paneId);
|
|
764
|
+
if (here && pane?.branch === branch && pane.status === "ready") { closeFlyout(); setExpanded(false); return; }
|
|
765
|
+
busy = true; activeAction = { kind: "switch", pane: paneId }; render();
|
|
766
|
+
showStatus(`Switching to ${branch}…`);
|
|
767
|
+
try {
|
|
768
|
+
const info = await api("/api/pane", { method: "POST", body: JSON.stringify({ pane: paneId, branch }) });
|
|
769
|
+
if (here) location.reload();
|
|
770
|
+
else location.href = info.url;
|
|
771
|
+
} catch (err) {
|
|
772
|
+
busy = false; activeAction = null;
|
|
773
|
+
showStatus(err.message, true);
|
|
774
|
+
render();
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/** Build (or reuse) whichever pane this tab is NOT currently viewing,
|
|
779
|
+
* and open it in a new tab without touching this tab at all. */
|
|
780
|
+
async function doOpenNewTab(branch) {
|
|
781
|
+
const here = currentPaneId();
|
|
782
|
+
const paneId = here === "a" ? "b" : "a";
|
|
783
|
+
busy = true; activeAction = { kind: "open", pane: paneId }; render();
|
|
784
|
+
showStatus(`Opening ${branch}…`);
|
|
785
|
+
try {
|
|
786
|
+
const info = await api("/api/pane", { method: "POST", body: JSON.stringify({ pane: paneId, branch }) });
|
|
787
|
+
busy = false; activeAction = null;
|
|
788
|
+
hideStatus();
|
|
789
|
+
if (info?.url) window.open(info.url, "_blank", "noopener");
|
|
790
|
+
closeFlyout(); setExpanded(false);
|
|
791
|
+
render();
|
|
792
|
+
} catch (err) {
|
|
793
|
+
busy = false; activeAction = null;
|
|
794
|
+
showStatus(err.message, true);
|
|
795
|
+
render();
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
btnCompare.onclick = () => {
|
|
800
|
+
window.open(DAEMON + "/shell", "_blank", "noopener");
|
|
801
|
+
setExpanded(false);
|
|
802
|
+
};
|
|
803
|
+
branchBtn.onclick = () => {
|
|
804
|
+
if (!expanded) { setExpanded(true); return; }
|
|
805
|
+
toggleFlyout(openFlyoutName === "switch" ? null : "switch");
|
|
806
|
+
};
|
|
807
|
+
btnExternalToggle.onclick = () => toggleFlyout(openFlyoutName === "external" ? null : "external");
|
|
808
|
+
btnInfoToggle.onclick = () => toggleFlyout(openFlyoutName === "info" ? null : "info");
|
|
809
|
+
btnSettingsToggle.onclick = () => toggleFlyout(openFlyoutName === "settings" ? null : "settings");
|
|
810
|
+
btnClose.onclick = () => setExpanded(false);
|
|
811
|
+
|
|
812
|
+
/* ------------------------------ settings ------------------------------ */
|
|
813
|
+
for (const b of posButtons) {
|
|
814
|
+
b.onclick = () => {
|
|
815
|
+
position = b.dataset.pos;
|
|
816
|
+
try { localStorage.setItem(POS_KEY, position); } catch { /* fine, just won't persist */ }
|
|
817
|
+
host.style.cssText = "position:fixed;z-index:2147483646;" + POSITIONS[position];
|
|
818
|
+
host.dataset.pos = position;
|
|
819
|
+
for (const btn of posButtons) btn.setAttribute("aria-pressed", String(btn.dataset.pos === position));
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
/* Toggling on fades the whole widget out, then removes it — toggling
|
|
823
|
+
* back off mid-fade cancels the hide and fades it back in, rather than
|
|
824
|
+
* committing the moment the switch is flipped. */
|
|
825
|
+
hideToggle.onclick = () => {
|
|
826
|
+
const on = hideToggle.getAttribute("aria-checked") !== "true";
|
|
827
|
+
hideToggle.setAttribute("aria-checked", String(on));
|
|
828
|
+
clearTimeout(hideTimer);
|
|
829
|
+
if (!on) { host.classList.remove("hiding"); return; }
|
|
830
|
+
host.classList.add("hiding");
|
|
831
|
+
hideTimer = setTimeout(() => {
|
|
832
|
+
try { sessionStorage.setItem(HIDE_KEY, "1"); } catch { /* fine */ }
|
|
833
|
+
document.removeEventListener("pointerdown", onOutsidePointerDown, true);
|
|
834
|
+
host.remove();
|
|
835
|
+
// The widget is gone from the DOM, so this world is free to mount a
|
|
836
|
+
// new one. Without this the extension's "show it again" would be
|
|
837
|
+
// blocked by the loaded-guard even after clearing the hide flag.
|
|
838
|
+
window.__sidebranchLoaded = false;
|
|
839
|
+
}, reducedMotion ? 0 : 220);
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
/* --------------------------- open toggle + dismissal --------------------------- */
|
|
843
|
+
function onOutsidePointerDown(e) {
|
|
844
|
+
if (!expanded) return;
|
|
845
|
+
if (e.composedPath().includes(host)) return;
|
|
846
|
+
setExpanded(false);
|
|
847
|
+
}
|
|
848
|
+
document.addEventListener("pointerdown", onOutsidePointerDown, true);
|
|
849
|
+
|
|
850
|
+
root.addEventListener("keydown", (e) => {
|
|
851
|
+
if (e.key !== "Escape") return;
|
|
852
|
+
if (openFlyoutName) {
|
|
853
|
+
const trigger = openFlyoutName === "switch" ? branchBtn
|
|
854
|
+
: openFlyoutName === "info" ? btnInfoToggle
|
|
855
|
+
: openFlyoutName === "external" ? btnExternalToggle
|
|
856
|
+
: btnSettingsToggle;
|
|
857
|
+
closeFlyout();
|
|
858
|
+
trigger.focus();
|
|
859
|
+
} else if (expanded) {
|
|
860
|
+
setExpanded(false);
|
|
861
|
+
branchBtn.focus();
|
|
862
|
+
}
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
/* --------------------------------- live state --------------------------------- */
|
|
866
|
+
async function refresh() {
|
|
867
|
+
try { state = await api("/api/state"); render(); } catch { /* daemon gone; keep last */ }
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
(async () => {
|
|
871
|
+
try {
|
|
872
|
+
const res = await fetch(DAEMON + "/api/events", { headers: { Authorization: "Bearer " + TOKEN } });
|
|
873
|
+
const reader = res.body.getReader();
|
|
874
|
+
const dec = new TextDecoder();
|
|
875
|
+
let buf = "";
|
|
876
|
+
for (;;) {
|
|
877
|
+
const { value, done } = await reader.read();
|
|
878
|
+
if (done) break;
|
|
879
|
+
buf += dec.decode(value, { stream: true });
|
|
880
|
+
let i;
|
|
881
|
+
while ((i = buf.indexOf("\n\n")) >= 0) {
|
|
882
|
+
const chunk = buf.slice(0, i); buf = buf.slice(i + 2);
|
|
883
|
+
const m = /^data: (.*)$/m.exec(chunk);
|
|
884
|
+
if (!m) continue;
|
|
885
|
+
const ev = JSON.parse(m[1]);
|
|
886
|
+
if (!activeAction || ev.pane !== activeAction.pane) continue;
|
|
887
|
+
if (ev.type === "pane:installing") showStatus("Installing dependencies…");
|
|
888
|
+
else if (ev.type === "pane:starting") showStatus("Starting dev server…");
|
|
889
|
+
// pane:ready / pane:error resolve via the awaited POST above, not here.
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
} catch { /* stream unavailable — status text just won't live-update mid-build */ }
|
|
893
|
+
})();
|
|
894
|
+
|
|
895
|
+
render();
|
|
896
|
+
setInterval(refresh, 5000);
|
|
897
|
+
}
|
|
898
|
+
};
|