@yokira404/dsh-git-guard 0.0.0-stage → 1.0.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/client.js ADDED
@@ -0,0 +1,645 @@
1
+ /**
2
+ * Client half of @yokira404/dsh-git-guard.
3
+ *
4
+ * One contribution: a pair of icon switches in the Session header's right-aligned
5
+ * utilities list (`conversation.session.header.utilities`), at the far right of that
6
+ * row — beside the window controls, separated from whatever else the row holds by a
7
+ * hairline rule.
8
+ *
9
+ * Which seat, and why: the header is a two-column grid whose second column is the
10
+ * title row, and inside it the actions list sits beside the title while the utilities
11
+ * list is pushed right (`margin-left:auto` on the header's own corner, `:empty` making
12
+ * both vanish when unused). Membership of the utilities list is therefore what ties a
13
+ * control to the window controls rather than to the title, and it is what makes the
14
+ * group move when other entries appear or go: the list is sorted by `order` and laid
15
+ * out in one flex row, so nothing has to be positioned by hand.
16
+ *
17
+ * The pair is a picture of one rule, not the rule itself. Enforcement lives in the
18
+ * Host half, so a click here is a *request*: the new pair is normalised, drawn
19
+ * immediately, and written to the Host's own route; the Host's answer is the
20
+ * truth, and a request that fails puts the pair back where it was rather than
21
+ * leaving two switches that claim to be protecting something they are not.
22
+ *
23
+ * The two switches are named for what they stop:
24
+ *
25
+ * left (local) blocks `git add` / `git commit` — the local repository;
26
+ * right (remote) blocks `git push` / `git remote` / `gh` — the upload.
27
+ *
28
+ * They are drawn as the host's own chrome buttons are — same box, same radius, same
29
+ * tokens, no fill of their own — and the state is carried by the *icon*: an unguarded
30
+ * repository is the repository alone, and switching a guard on adds the ring and the
31
+ * stroke the reference uses for "forbidden".
32
+ *
33
+ * Shape of the pair: both start on (the protective default), all four states stay
34
+ * reachable, nothing is ever locked, and the pair's behaviour is exactly two clicks,
35
+ * carrying each other in opposite directions:
36
+ *
37
+ * clicking the LEFT switch toggles it; landing ON turns the RIGHT one on too,
38
+ * landing OFF leaves the RIGHT one alone;
39
+ * clicking the RIGHT switch toggles it; landing OFF turns the LEFT one off too,
40
+ * landing ON leaves the LEFT one alone.
41
+ *
42
+ * So a guard is never *asked for* at the cost of the other one — only released that
43
+ * way. `local off · remote on`, the pairing the reader asked to be able to reach from
44
+ * both-off with a right-hand click, is reached by that click or by handling the local
45
+ * guard back on its own; it is never reached by a right-hand click that was meant to
46
+ * do something else.
47
+ *
48
+ * Both buttons are therefore always clickable, and the pair never needs a disabled
49
+ * look to explain itself.
50
+ */
51
+ window.__ModuleLoader__.load({
52
+ id: '@yokira404/dsh-git-guard',
53
+ factory(require) {
54
+ const React = require('react')
55
+ const { createElement: h, useCallback, useEffect, useRef, useState } = React
56
+
57
+ /* ───────────────────────────── constants ───────────────────────────── */
58
+
59
+ const NS = 'dsh-git-guard'
60
+ const LOCALE_NS = 'gitGuard'
61
+ const VERSION = '1.0.0'
62
+ /** This package's own two routes on the Host's Web carrier, same origin as the GUI. */
63
+ const ROUTE = '/dsh-git-guard/state'
64
+ /** A switch is asked for, never waited on forever: the draw must never hang. */
65
+ const REQUEST_TIMEOUT_MS = 4000
66
+ /** Slow reconciliation, so a second window's choice reaches this one eventually. */
67
+ const RECONCILE_MS = 20000
68
+ /**
69
+ * The Client's own head start, byte-identical in meaning to the Host's default.
70
+ * Both on: the reader asked for a guard that is protecting the moment it loads.
71
+ */
72
+ const DEFAULT_STATE = { local: true, remote: true }
73
+
74
+ /* ──────────────────────────── dictionaries ─────────────────────────── */
75
+
76
+ const ZH = {
77
+ 'local.on': '本地仓库保护:开(阻止 git add / git commit 等本地写入;打开时若远端是关的,会一并打开)',
78
+ 'local.off': '本地仓库保护:关(允许本地仓库写入)',
79
+ 'local.label': '本地仓库保护',
80
+ 'remote.on': '远端仓库保护:开(阻止 git push / git remote / gh 等上传)',
81
+ 'remote.off': '远端仓库保护:关(允许上传远端仓库;关闭时本地保护会一并关闭)',
82
+ 'remote.label': '远端仓库保护',
83
+ 'failed': '未能同步到 DSH 主机,开关已还原',
84
+ }
85
+ const EN = {
86
+ 'local.on': 'Local repository guard: on (blocks git add / git commit and other local writes; switching it on also arms the remote guard when that one is off)',
87
+ 'local.off': 'Local repository guard: off (local repository writes allowed)',
88
+ 'local.label': 'Local repository guard',
89
+ 'remote.on': 'Remote repository guard: on (blocks git push / git remote / gh and other uploads)',
90
+ 'remote.off': 'Remote repository guard: off (uploads allowed; switching it off also releases the local guard)',
91
+ 'remote.label': 'Remote repository guard',
92
+ 'failed': 'Could not reach the DSH Host; the switches were put back',
93
+ }
94
+
95
+ /* ────────────────────────────── icons ─────────────────────────────── */
96
+
97
+ /*
98
+ * Geometry for the two states of one icon.
99
+ *
100
+ * The reference marks the "forbidden" reading with a ring and a stroke, and the
101
+ * pair's own state is what the ring is *for*: an unguarded repository is drawn as
102
+ * the repository alone, and switching the guard on adds the ring and the stroke.
103
+ * The two states therefore differ by a whole mark rather than by a colour, which
104
+ * is what lets these buttons drop the black/white fill and sit beside the window
105
+ * controls looking like the host drew them.
106
+ *
107
+ * The art is drawn on a 32-unit grid rather than a 24 one, and that is what makes
108
+ * it big enough to read: the whole picture is a ring around a repository, and a
109
+ * ring around something needs more room than a bare glyph does. Everything below
110
+ * is in those units, and the button is the host's 28px box.
111
+ *
112
+ * The ring encloses the art rather than sitting on top of it, which fixes both
113
+ * sizes: at r 10.6 with a 1.5 stroke its inner edge is at 9.85, so everything
114
+ * inside clears 9.85 while the ring's outer edge (11.35) still leaves room in the
115
+ * 32 grid for the round cap of the slash. The drawing therefore keeps a margin
116
+ * *inside its own grid*, which is what the host's header buttons do — the icon is
117
+ * a glyph in the row, not a tile that fills its box.
118
+ */
119
+ const RING = { cx: 16, cy: 16, r: 10.6 }
120
+ /**
121
+ * The cylinder: a lid, a body, and three bands seen from just above the equator.
122
+ *
123
+ * Its *optical* centre has to be y 16 — the ring's centre and the GitHub mark's
124
+ * centre — and the optical centre of a cylinder is the middle of its body, not the
125
+ * middle of its bounding box: the lid above reads as a face and the base below as
126
+ * an edge, so the eye balances them around the body. Taking the bounding box as
127
+ * the shape put the whole glyph 1.8 units high, which is what "the left icon sits
128
+ * a bit high" looked like. The body therefore runs y 10.55 to 21.45 (centre 16),
129
+ * with the lid's ellipse above it and the base's below.
130
+ *
131
+ * Half-width 6.1 at a 1.4 stroke puts its edge at 6.8, clear of the ring's inner
132
+ * edge at 9.85 — the gap between the cylinder and the ring is what makes the two
133
+ * read as subject and mark rather than as one cluster.
134
+ *
135
+ * The bottom of the body is a cubic segment rather than an elliptical arc, and
136
+ * deliberately so: an `a rx ry …` sweep across a half-ellipse needs the radius to
137
+ * be *half the chord*, and the chord here is 2*sqrt(6.1² + 2.55²) = 6.61, not 6.1.
138
+ * Writing the horizontal half-width as the radius scales the arc down past its
139
+ * endpoints and the renderer clamps it into a spike — which is exactly what one
140
+ * revision of this drawing looked like.
141
+ */
142
+ const CYLINDER_LID = { cx: 16, cy: 10.55, rx: 6.1, ry: 2.55 }
143
+ const CYLINDER_OUTLINE = 'M9.9 10.55v10.9c0 1.41 2.73 2.55 6.1 2.55s6.1-1.14 6.1-2.55V10.55'
144
+ const CYLINDER_BANDS = [
145
+ 'M9.9 13.2c0 1.41 2.73 2.55 6.1 2.55s6.1-1.14 6.1-2.55',
146
+ 'M9.9 16c0 1.41 2.73 2.55 6.1 2.55s6.1-1.14 6.1-2.55',
147
+ 'M9.9 18.8c0 1.41 2.73 2.55 6.1 2.55s6.1-1.14 6.1-2.55',
148
+ ]
149
+
150
+ /**
151
+ * The GitHub mark. The official path is drawn for a 98 box and every unit square
152
+ * of that box carries ink, which is what lets one transform place it without
153
+ * measuring the outline. It is the larger of the two drawings on purpose: a solid
154
+ * silhouette has to be bigger than a line drawing to carry the same weight, so the
155
+ * mark's tile (18.4) exceeds the cylinder's width (12.2 plus its stroke) and the
156
+ * ring crosses its corners the way the reference's ring crosses its subject.
157
+ */
158
+ const MARK_SOURCE = 98
159
+ const MARK_TILE = 18.4
160
+ const MARK_OFFSET = (32 - MARK_TILE) / 2
161
+ const MARK_SCALE = MARK_TILE / MARK_SOURCE
162
+ const MARK_PATH =
163
+ 'M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z'
164
+
165
+ /*
166
+ * The line weight. The reference draws its bands distinctly — each band separate,
167
+ * with air between them — and a 2.2 stroke on this cylinder is heavy enough to
168
+ * close those gaps into one dark mass at 26px, which is what "the lines are too
169
+ * thick" looks like. 1.4 matches the ring's own weight, so the guarded state reads
170
+ * as one drawing rather than a heavy subject inside a light ring, and it leaves
171
+ * 1.4 units of clear space between bands (about 1.1px at the button's real size,
172
+ * where the previous weight left under 0.9px and the bands merged under
173
+ * anti-aliasing).
174
+ */
175
+ const BODY_STROKE = { fill: 'none', stroke: 'currentColor', strokeWidth: 1.4, strokeLinejoin: 'round' }
176
+ const RING_STROKE = { fill: 'none', stroke: 'currentColor', strokeWidth: 1.5 }
177
+ const SLASH_STROKE = { fill: 'none', stroke: 'currentColor', strokeWidth: 1.9, strokeLinecap: 'round' }
178
+ /**
179
+ * One diagonal across the ring, end to end, sized so both round caps land just
180
+ * inside it rather than poking through.
181
+ */
182
+ const SLASH_PATH = 'M8.6 23.4 23.4 8.6'
183
+
184
+ /**
185
+ * The two marks a guarded icon adds. Both are drawn only while the guard is on —
186
+ * that is the entire difference between the states, so it is the only thing these
187
+ * two components have to decide.
188
+ */
189
+ function GuardMarks() {
190
+ return [
191
+ h('circle', { key: 'ring', cx: RING.cx, cy: RING.cy, r: RING.r, ...RING_STROKE }),
192
+ h('path', { key: 'slash', d: SLASH_PATH, ...SLASH_STROKE }),
193
+ ]
194
+ }
195
+
196
+ function IconBase(children) {
197
+ return h(
198
+ 'svg',
199
+ {
200
+ viewBox: '0 0 32 32',
201
+ width: 32,
202
+ height: 32,
203
+ 'aria-hidden': 'true',
204
+ focusable: 'false',
205
+ style: { display: 'block', width: '100%', height: '100%' },
206
+ },
207
+ children,
208
+ )
209
+ }
210
+
211
+ /** The local repository: the database cylinder, ringed and struck while guarded. */
212
+ function LocalIcon(props) {
213
+ return IconBase([
214
+ h('path', { key: 'body', d: CYLINDER_OUTLINE, ...BODY_STROKE }),
215
+ h('ellipse', { key: 'lid', cx: CYLINDER_LID.cx, cy: CYLINDER_LID.cy, rx: CYLINDER_LID.rx, ry: CYLINDER_LID.ry, ...BODY_STROKE }),
216
+ ...CYLINDER_BANDS.map((d, index) => h('path', { key: 'band' + index, d, ...BODY_STROKE })),
217
+ ...(props !== null && props !== undefined && props.on === true ? GuardMarks() : []),
218
+ ])
219
+ }
220
+
221
+ /** The remote repository: the GitHub mark, ringed and struck while guarded. */
222
+ function RemoteIcon(props) {
223
+ return IconBase([
224
+ h('g', { key: 'mark', transform: 'translate(' + MARK_OFFSET + ' ' + MARK_OFFSET + ') scale(' + MARK_SCALE + ')' }, [
225
+ h('path', { key: 'path', d: MARK_PATH, fill: 'currentColor' }),
226
+ ]),
227
+ ...(props !== null && props !== undefined && props.on === true ? GuardMarks() : []),
228
+ ])
229
+ }
230
+
231
+ /* ───────────────────────────── stylesheet ──────────────────────────── */
232
+
233
+ /*
234
+ * The ground carries the state, and the ground is the host's own control wash.
235
+ *
236
+ * A switch that is *off* has no ground at all: an icon sitting in the header, the
237
+ * way the chrome's bare glyph buttons sit there. A switch that is *on* wears
238
+ * `--dsw-alias-interactive-bg-hover` — the fill DSH's segmented tracks use, which
239
+ * the host's own stylesheet describes as "the same translucent fill the hover
240
+ * state uses, so it reads as a place rather than a second button". So "on" reads
241
+ * as a pressed, filled chip and "off" reads as plain chrome, which is the reading
242
+ * a pair of guards should have.
243
+ *
244
+ * Hover steps one notch up the host's ladder to `--dsw-alias-interactive-bg-active`
245
+ * — the same family, one shade stronger — so the pointer gets an answer whatever
246
+ * the state, without a second colour being invented and without the heavy
247
+ * `--dsw-alias-button-tool-bar-hover` (a 60% grey that is right for a toolbar at
248
+ * the edge of a panel and far too strong for a header).
249
+ *
250
+ * Every token here is the host's; `evidence/selftest.mjs` asserts both that this
251
+ * stylesheet still names them and that the theme really defines each one — an
252
+ * alias that resolves to nothing is exactly how a button ends up with the wrong
253
+ * colour, which is how this rule arrived at its present shape.
254
+ *
255
+ * The icon keeps the host's own icon grey in **both** states —
256
+ * `--dsw-alias-label-secondary`, the `#545557` the chrome's panel toggles are drawn
257
+ * in. An earlier revision stepped the guarded icon up to
258
+ * `--dsw-alias-label-primary`, which the light theme resolves to `#0f1115`: a
259
+ * near-black that made the switch look like a foreign control sitting among the
260
+ * window buttons. The state is not told by weight at all — the ring and the stroke
261
+ * say it, and the ground says it a second time.
262
+ */
263
+ const CSS = `
264
+ .dsh-gg-root{display:inline-flex;align-items:center;gap:2px}
265
+ /* The group's own left edge. It sits in the header's right-aligned utilities list,
266
+ immediately left of the window controls, so the rule separates the guards from
267
+ whatever else the utilities hold — and moves with them when they change. The token
268
+ is the same hairline the header itself draws its bottom border with. */
269
+ .dsh-gg-rule{flex:none;width:1px;height:16px;margin:0 6px;background:var(--dsw-alias-border-l3)}
270
+ .dsh-gg-btn{
271
+ position:relative;
272
+ box-sizing:border-box;
273
+ display:inline-flex;align-items:center;justify-content:center;
274
+ flex:none;
275
+ width:28px;height:28px;
276
+ padding:0;
277
+ border:none;
278
+ border-radius:var(--dsw-radius-sm);
279
+ background:0 0;
280
+ color:var(--dsw-alias-label-secondary);
281
+ cursor:pointer;
282
+ -webkit-appearance:none;appearance:none;
283
+ transition:background-color .15s var(--ds-ease-in-out, ease), color .15s var(--ds-ease-in-out, ease);
284
+ }
285
+ /* Off is bare chrome; on adds the host's control wash. The icon stays the host's icon
286
+ grey either way — the ring and the stroke are what say "guarded". */
287
+ .dsh-gg-btn[data-on="1"]{background:var(--dsw-alias-interactive-bg-hover)}
288
+ .dsh-gg-btn:hover{background:var(--dsw-alias-interactive-bg-active)}
289
+ .dsh-gg-btn:active{background:var(--dsw-alias-interactive-bg-active)}
290
+ .dsh-gg-btn:focus-visible{
291
+ outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));
292
+ outline-offset:-2px;
293
+ }
294
+ .dsh-gg-btn:disabled{cursor:default;opacity:.45}
295
+ /* 26px of the host's 28px box: the icon is the button's whole content, so it takes
296
+ the box in full and leaves only the 1px hairline that keeps the ring off the
297
+ rounded edge. The box itself, its radius and its hover ground are the host's, and
298
+ the drawing keeps the rest of its margin inside its own 32-unit grid. */
299
+ .dsh-gg-icon{display:block;width:26px;height:26px}
300
+ .dsh-gg-note{
301
+ position:absolute;top:calc(100% + 6px);right:0;
302
+ z-index:40;
303
+ max-width:220px;
304
+ padding:5px 8px;
305
+ border-radius:var(--dsw-radius-sm);
306
+ background:var(--dsw-alias-bg-overlay);
307
+ color:var(--dsw-alias-label-primary);
308
+ font-size:12px;line-height:1.35;white-space:nowrap;
309
+ box-shadow:0 6px 18px rgba(0,0,0,.28);
310
+ pointer-events:none;
311
+ }
312
+ `
313
+
314
+ function insertStyles(css) {
315
+ try {
316
+ const builtin = typeof window === 'undefined' ? undefined : window.styles
317
+ if (builtin !== undefined && typeof builtin.insert === 'function') return builtin.insert(css)
318
+ } catch (error) {
319
+ console.error('[' + NS + '] styles builtin failed', error)
320
+ }
321
+ const tag = document.createElement('style')
322
+ tag.setAttribute('data-dsh-gg', 'style')
323
+ tag.textContent = css
324
+ document.head.appendChild(tag)
325
+ return () => {
326
+ tag.remove()
327
+ }
328
+ }
329
+
330
+ /* ─────────────────────────────── state ─────────────────────────────── */
331
+
332
+ /**
333
+ * Coerce a value into a drawable pair. This is type repair only: the pair's two
334
+ * behavioural rules are properties of the *gestures* that produce them and live
335
+ * in `nextState`, never here. Putting such a rule here made it an invariant of
336
+ * every state instead, and an invariant of that shape locks a switch on for as
337
+ * long as its partner is off — the local switch could not be turned off again.
338
+ */
339
+ function normalize(value) {
340
+ const source = value !== null && typeof value === 'object' ? value : {}
341
+ return { local: source.local !== false, remote: source.remote !== false }
342
+ }
343
+
344
+ /**
345
+ * The pair's behaviour is exactly two clicks, and this is both of them. The Host
346
+ * half carries a byte-identical copy — the two halves are served to different
347
+ * runtimes and cannot import each other — and `evidence/selftest.mjs` asserts the
348
+ * two spellings have not drifted apart.
349
+ *
350
+ * @param {{ local: boolean, remote: boolean }} state - the pair as it stands.
351
+ * @param {'local' | 'remote'} clicked - which switch the reader pressed.
352
+ * @returns {{ local: boolean, remote: boolean }} the pair after that click.
353
+ */
354
+ function nextState(state, clicked) {
355
+ if (clicked === 'local') {
356
+ const local = state.local !== true
357
+ /* Landing ON turns the remote guard on too; landing OFF leaves it exactly as it was. */
358
+ return { local, remote: local === true ? true : state.remote === true }
359
+ }
360
+ const remote = state.remote !== true
361
+ /* Releasing the upload guard releases the local one with it; asking for it moves nothing. */
362
+ return { local: remote === false ? false : state.local === true, remote }
363
+ }
364
+
365
+ function same(a, b) {
366
+ return a.local === b.local && a.remote === b.remote
367
+ }
368
+
369
+ function stateUrl(sessionId) {
370
+ return ROUTE + '?session=' + encodeURIComponent(sessionId === undefined || sessionId === null ? '' : String(sessionId))
371
+ }
372
+
373
+ /** One request that always settles: the draw must never wait on the Host. */
374
+ async function request(url, init) {
375
+ const controller = typeof AbortController === 'function' ? new AbortController() : null
376
+ const timer = controller === null ? null : window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
377
+ try {
378
+ const response = await window.fetch(url, {
379
+ ...init,
380
+ ...(controller === null ? {} : { signal: controller.signal }),
381
+ credentials: 'same-origin',
382
+ })
383
+ if (response.ok !== true) throw new Error('HTTP ' + response.status)
384
+ return await response.json()
385
+ } finally {
386
+ if (timer !== null) window.clearTimeout(timer)
387
+ }
388
+ }
389
+
390
+ async function readState(sessionId) {
391
+ const payload = await request(stateUrl(sessionId), { method: 'GET' })
392
+ if (payload === null || payload.ok !== true) throw new Error('unexpected payload')
393
+ return normalize(payload.state)
394
+ }
395
+
396
+ async function writeState(sessionId, state) {
397
+ const payload = await request(stateUrl(sessionId), {
398
+ method: 'POST',
399
+ headers: { 'content-type': 'application/json' },
400
+ body: JSON.stringify({ state }),
401
+ })
402
+ if (payload === null || payload.ok !== true) throw new Error('unexpected payload')
403
+ return normalize(payload.state)
404
+ }
405
+
406
+ /* ───────────────────────────── component ───────────────────────────── */
407
+
408
+ /**
409
+ * One square switch. `aria-pressed` carries the state for a screen reader and
410
+ * `data-on` carries it for the stylesheet, so neither has to be inferred from
411
+ * the colours — which is what lets the same two colours mean two states.
412
+ *
413
+ * There is no disabled arm: every state this pair can be in is reachable by a
414
+ * click, so a button that refuses clicks would only ever be a lie.
415
+ */
416
+ function Switch(props) {
417
+ const { on, title, onClick, children } = props
418
+ return h(
419
+ 'button',
420
+ {
421
+ type: 'button',
422
+ className: 'dsh-gg-btn',
423
+ 'data-on': on === true ? '1' : '0',
424
+ 'data-dsh-gg': props.mark,
425
+ title,
426
+ 'aria-label': props.ariaLabel ?? title,
427
+ 'aria-pressed': on === true ? 'true' : 'false',
428
+ onClick,
429
+ },
430
+ h('span', { className: 'dsh-gg-icon' }, children),
431
+ )
432
+ }
433
+
434
+ function HookBadge(props) {
435
+ const { sessionId, t } = props
436
+ const [state, setState] = useState(DEFAULT_STATE)
437
+ const [failed, setFailed] = useState(false)
438
+ const live = useRef(true)
439
+
440
+ useEffect(() => {
441
+ live.current = true
442
+ return () => {
443
+ live.current = false
444
+ }
445
+ }, [])
446
+
447
+ /*
448
+ * Read once per Session, and keep reading slowly: a second window, or this
449
+ * one reloaded while the Host kept its own copy, has to converge on the Host
450
+ * rather than on whichever tab drew last.
451
+ */
452
+ useEffect(() => {
453
+ let cancelled = false
454
+ const pull = async () => {
455
+ try {
456
+ const next = await readState(sessionId)
457
+ if (cancelled === true || live.current === false) return
458
+ setFailed(false)
459
+ setState((current) => (same(current, next) === true ? current : next))
460
+ } catch {
461
+ /* A Host that is not answering is not a reason to draw the wrong state. */
462
+ }
463
+ }
464
+ void pull()
465
+ const timer = window.setInterval(() => void pull(), RECONCILE_MS)
466
+ return () => {
467
+ cancelled = true
468
+ window.clearInterval(timer)
469
+ }
470
+ }, [sessionId])
471
+
472
+ /**
473
+ * Draw the request first, then ask: a switch that waits for a round trip
474
+ * before moving feels broken, and the Host's answer is the only thing that
475
+ * can turn the draw back.
476
+ */
477
+ const apply = useCallback(
478
+ (next) => {
479
+ const wanted = normalize(next)
480
+ setState(wanted)
481
+ void (async () => {
482
+ try {
483
+ const confirmed = await writeState(sessionId, wanted)
484
+ if (live.current === false) return
485
+ setFailed(false)
486
+ setState(confirmed)
487
+ } catch {
488
+ if (live.current === false) return
489
+ setFailed(true)
490
+ /* Put the pair back: an unconfirmed switch must not claim to guard. */
491
+ try {
492
+ const current = await readState(sessionId)
493
+ if (live.current === true) setState(current)
494
+ } catch {
495
+ if (live.current === true) setState(DEFAULT_STATE)
496
+ }
497
+ }
498
+ })()
499
+ },
500
+ [sessionId],
501
+ )
502
+
503
+ /*
504
+ * The pair's whole behaviour is `nextState` above — two clicks, no invariants.
505
+ * Both switches stay clickable in every state, so the reader can always reach
506
+ * the combination they want.
507
+ */
508
+ const toggleLocal = useCallback(() => {
509
+ apply(nextState(state, 'local'))
510
+ }, [apply, state])
511
+
512
+ const toggleRemote = useCallback(() => {
513
+ apply(nextState(state, 'remote'))
514
+ }, [apply, state])
515
+
516
+ const localTitle = state.local === true ? t('local.on') : t('local.off')
517
+ const remoteTitle = state.remote === true ? t('remote.on') : t('remote.off')
518
+
519
+ return h(
520
+ 'div',
521
+ { className: 'dsh-gg-root', 'data-dsh-gg': 'root', 'data-session': sessionId ?? '', 'data-version': VERSION },
522
+ /* The rule comes first: the group's left edge, and the only thing in this
523
+ entry that is not a switch. */
524
+ h('span', { className: 'dsh-gg-rule', 'aria-hidden': 'true' }),
525
+ h(
526
+ Switch,
527
+ {
528
+ mark: 'local',
529
+ on: state.local === true,
530
+ title: localTitle,
531
+ ariaLabel: t('local.label'),
532
+ onClick: toggleLocal,
533
+ },
534
+ /*
535
+ * The state travels into the icon: the ring and the stroke ARE the switch's
536
+ * on state, so `on` is not decoration here, it is the whole reading.
537
+ */
538
+ h(LocalIcon, { on: state.local === true }),
539
+ ),
540
+ h(
541
+ Switch,
542
+ {
543
+ mark: 'remote',
544
+ on: state.remote === true,
545
+ title: remoteTitle,
546
+ ariaLabel: t('remote.label'),
547
+ onClick: toggleRemote,
548
+ },
549
+ h(RemoteIcon, { on: state.remote === true }),
550
+ ),
551
+ failed === true ? h('div', { className: 'dsh-gg-note', role: 'status' }, t('failed')) : null,
552
+ )
553
+ }
554
+
555
+ /* ─────────────────────────────── runtime ───────────────────────────── */
556
+
557
+ const INJECT = ['slots', 'locale']
558
+
559
+ /** Register or re-register the header action; the registry owns the disposal. */
560
+ let disposeEntry = null
561
+ let entryLive = false
562
+
563
+ function openEntry(ctx) {
564
+ disposeEntry = ctx.slots.register(
565
+ {
566
+ name: 'conversation.session.header.utilities',
567
+ id: NS,
568
+ /*
569
+ * The utilities list is right-aligned inside the header's title row, so
570
+ * membership of it — not a position inside the title — is what puts the
571
+ * pair beside the window controls. A high order keeps it at that end as
572
+ * other entries are added: the shipped open-in-app and session-log
573
+ * controls register at -10 and 0, the sidebar toggle at 10, and the list
574
+ * is sorted ascending, so 100 is past them and stays past them.
575
+ */
576
+ order: 100,
577
+ locale: LOCALE_NS,
578
+ inject: () => ({}),
579
+ },
580
+ HookBadge,
581
+ )
582
+ entryLive = true
583
+ return disposeEntry
584
+ }
585
+
586
+ return {
587
+ inject: INJECT,
588
+ apply(ctx) {
589
+ ctx.effect(() => insertStyles(CSS), NS + ':styles')
590
+ try {
591
+ ctx.locale.register(LOCALE_NS, { zh: ZH, en: EN })
592
+ } catch (error) {
593
+ /*
594
+ * A dictionary is presentation: if the locale service refuses it the pair
595
+ * still works, it just falls back to the names below. This must never be
596
+ * the reason the plugin fails to load.
597
+ */
598
+ console.error('[' + NS + '] locale registration failed', error)
599
+ }
600
+ /*
601
+ * The shell declares this Slot while the conversation panel boots, so the
602
+ * injection is retried a few times: a first attempt that lands too early
603
+ * must not cost the reader the pair for the whole session.
604
+ */
605
+ let attempts = 0
606
+ const timers = new Set()
607
+ const injectEntry = () => {
608
+ try {
609
+ ctx.slots.inject('conversation.session.header.utilities', () => {
610
+ try {
611
+ openEntry(ctx)
612
+ return () => {
613
+ entryLive = false
614
+ if (disposeEntry !== null) disposeEntry()
615
+ disposeEntry = null
616
+ }
617
+ } catch (error) {
618
+ console.error('[' + NS + '] register failed', error)
619
+ throw error
620
+ }
621
+ })
622
+ } catch (error) {
623
+ attempts += 1
624
+ console.error('[' + NS + '] inject failed (attempt ' + attempts + ')', error)
625
+ if (attempts >= 3) return
626
+ const timer = window.setTimeout(() => {
627
+ timers.delete(timer)
628
+ injectEntry()
629
+ }, 400 * attempts)
630
+ timers.add(timer)
631
+ }
632
+ }
633
+ injectEntry()
634
+ ctx.effect(
635
+ () => () => {
636
+ for (const timer of timers) window.clearTimeout(timer)
637
+ timers.clear()
638
+ if (entryLive === true && disposeEntry !== null) disposeEntry()
639
+ },
640
+ NS + ':entry',
641
+ )
642
+ },
643
+ }
644
+ },
645
+ })
@@ -0,0 +1,11 @@
1
+ # Bundle layer for @yokira404/dsh-git-guard.
2
+ #
3
+ # Listing this package in the profile's dsh.profile.bundles makes DSH read this
4
+ # patch and insert the row below, which loads the Host half — the half that owns
5
+ # the `tools/pre-execute` rule and the two HTTP routes the switches write to. The
6
+ # Host half also makes `dsh.client` visible to the client-modules scan, which
7
+ # serves ./client.js to the browser, where the pair is drawn.
8
+ - insert:
9
+ - id: git-guard
10
+ name: '@yokira404/dsh-git-guard'
11
+ config: {}