heyquilt 0.0.1 → 0.3.106

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.
Files changed (221) hide show
  1. package/LICENSE +85 -0
  2. package/README.md +633 -2
  3. package/RELEASES.md +862 -0
  4. package/assets/logo.svg +1 -0
  5. package/bin/quilt.js +849 -0
  6. package/desktop/main.js +315 -0
  7. package/package.json +163 -7
  8. package/src/account.js +591 -0
  9. package/src/admit-policy.js +32 -0
  10. package/src/agent-join.js +370 -0
  11. package/src/agent-task-workflow.js +176 -0
  12. package/src/agents/actions.js +85 -0
  13. package/src/agents/claude-code.js +187 -0
  14. package/src/agents/common.js +21 -0
  15. package/src/agents/cursor-transcripts.js +177 -0
  16. package/src/agents/cursor.js +404 -0
  17. package/src/agents/index.js +69 -0
  18. package/src/ai-vm-signin.js +110 -0
  19. package/src/ai-vm.js +522 -0
  20. package/src/api/access.js +75 -0
  21. package/src/api/activity.js +181 -0
  22. package/src/api/agent-auth.js +136 -0
  23. package/src/api/agent-placement.js +157 -0
  24. package/src/api/audit.js +83 -0
  25. package/src/api/auth.js +17 -0
  26. package/src/api/domains.js +30 -0
  27. package/src/api/file-paths.js +40 -0
  28. package/src/api/file-store.js +99 -0
  29. package/src/api/fly-machines.js +133 -0
  30. package/src/api/github-app.js +172 -0
  31. package/src/api/http.js +34 -0
  32. package/src/api/invite-email.js +53 -0
  33. package/src/api/issues.js +110 -0
  34. package/src/api/join-text.js +78 -0
  35. package/src/api/mailer.js +47 -0
  36. package/src/api/memory-store.js +1219 -0
  37. package/src/api/org-access.js +54 -0
  38. package/src/api/permissions.js +71 -0
  39. package/src/api/relay-cluster.js +545 -0
  40. package/src/api/relay-machines.js +92 -0
  41. package/src/api/releases.js +87 -0
  42. package/src/api/routes/access-types.js +54 -0
  43. package/src/api/routes/agent-invites.js +115 -0
  44. package/src/api/routes/agents.js +252 -0
  45. package/src/api/routes/ai-vm.js +495 -0
  46. package/src/api/routes/github.js +380 -0
  47. package/src/api/routes/grants.js +70 -0
  48. package/src/api/routes/invites.js +188 -0
  49. package/src/api/routes/issues.js +47 -0
  50. package/src/api/routes/join.js +167 -0
  51. package/src/api/routes/members.js +110 -0
  52. package/src/api/routes/orgs.js +179 -0
  53. package/src/api/routes/relay-cluster.js +90 -0
  54. package/src/api/routes/relay.js +76 -0
  55. package/src/api/routes/secrets.js +448 -0
  56. package/src/api/routes/session-invites.js +119 -0
  57. package/src/api/routes/sessions.js +92 -0
  58. package/src/api/routes/teams.js +132 -0
  59. package/src/api/routes/workspace-agents.js +332 -0
  60. package/src/api/routes/workspace-files.js +277 -0
  61. package/src/api/routes/workspace-invites.js +196 -0
  62. package/src/api/routes/workspaces.js +232 -0
  63. package/src/api/sandbox-keys.js +57 -0
  64. package/src/api/secrets-access.js +93 -0
  65. package/src/api/server.js +654 -0
  66. package/src/api/slugs.js +20 -0
  67. package/src/api/supabase-store.js +875 -0
  68. package/src/api/team-access.js +30 -0
  69. package/src/api/tokens.js +31 -0
  70. package/src/api/workspace-access.js +62 -0
  71. package/src/api/workspace-reach.js +60 -0
  72. package/src/autowork-tools.js +166 -0
  73. package/src/autowork.js +375 -0
  74. package/src/blobstore.js +99 -0
  75. package/src/boxes.js +37 -0
  76. package/src/branchdocs.js +235 -0
  77. package/src/branches.js +194 -0
  78. package/src/catchup.js +111 -0
  79. package/src/chat-archive.js +85 -0
  80. package/src/chat-links.js +923 -0
  81. package/src/clash.js +128 -0
  82. package/src/cloud-ai.js +50 -0
  83. package/src/commit-request.js +59 -0
  84. package/src/commit.js +213 -0
  85. package/src/connection.js +569 -0
  86. package/src/control.js +248 -0
  87. package/src/conversation.js +125 -0
  88. package/src/doctor.js +156 -0
  89. package/src/duties.js +303 -0
  90. package/src/editors.js +228 -0
  91. package/src/fsutil.js +97 -0
  92. package/src/git-hook.js +48 -0
  93. package/src/git.js +147 -0
  94. package/src/github-commit.js +320 -0
  95. package/src/gitignore.js +49 -0
  96. package/src/gitstate.js +924 -0
  97. package/src/history.js +307 -0
  98. package/src/hooks.js +371 -0
  99. package/src/hosted-agents.js +24 -0
  100. package/src/hosted-client.js +112 -0
  101. package/src/identity.js +99 -0
  102. package/src/inbox.js +237 -0
  103. package/src/integrations.js +221 -0
  104. package/src/key-cli.js +115 -0
  105. package/src/largefiles.js +91 -0
  106. package/src/legacy.js +25 -0
  107. package/src/mcp-proxy.js +45 -0
  108. package/src/mcp-room.js +32 -0
  109. package/src/mcp.js +1258 -0
  110. package/src/merge3.js +240 -0
  111. package/src/merges.js +148 -0
  112. package/src/pass-source.js +183 -0
  113. package/src/passes.js +64 -0
  114. package/src/pathrules.js +134 -0
  115. package/src/persona.js +101 -0
  116. package/src/presence.js +360 -0
  117. package/src/private-file.js +41 -0
  118. package/src/procs.js +70 -0
  119. package/src/project-rules.js +178 -0
  120. package/src/protocol.js +92 -0
  121. package/src/quiet-warnings.js +9 -0
  122. package/src/relay-ai-vm.js +82 -0
  123. package/src/relay-ask.js +141 -0
  124. package/src/relay-ci.js +111 -0
  125. package/src/relay-clash.js +214 -0
  126. package/src/relay-cluster.js +454 -0
  127. package/src/relay-commit.js +887 -0
  128. package/src/relay-filekey.js +46 -0
  129. package/src/relay-git.js +274 -0
  130. package/src/relay-githttp.js +166 -0
  131. package/src/relay-github.js +109 -0
  132. package/src/relay-lease.js +137 -0
  133. package/src/relay-lfs.js +37 -0
  134. package/src/relay-mcp.js +1691 -0
  135. package/src/relay-metrics.js +140 -0
  136. package/src/relay-outbound.js +1273 -0
  137. package/src/relay-sync.js +716 -0
  138. package/src/relay-turn-sync.js +506 -0
  139. package/src/relay-upstream.js +804 -0
  140. package/src/relay-webhooks.js +111 -0
  141. package/src/releases.js +138 -0
  142. package/src/report.js +129 -0
  143. package/src/runner.js +307 -0
  144. package/src/same-edit.js +18 -0
  145. package/src/secret-import.js +55 -0
  146. package/src/secret-names.js +26 -0
  147. package/src/secret-run.js +80 -0
  148. package/src/secrets-client.js +56 -0
  149. package/src/server.js +4214 -0
  150. package/src/session-access.js +170 -0
  151. package/src/session-name.js +15 -0
  152. package/src/session.js +7446 -0
  153. package/src/settings.js +46 -0
  154. package/src/setup.js +184 -0
  155. package/src/shell-path.js +46 -0
  156. package/src/status.js +197 -0
  157. package/src/summarize.js +109 -0
  158. package/src/sync-words.js +42 -0
  159. package/src/task-comments.js +92 -0
  160. package/src/tasks.js +670 -0
  161. package/src/textdiff.js +20 -0
  162. package/src/turn-sync-plan.js +145 -0
  163. package/src/turn-sync.js +212 -0
  164. package/src/ui/access-form.js +37 -0
  165. package/src/ui/agent-env-key.js +76 -0
  166. package/src/ui/agent-guide.js +127 -0
  167. package/src/ui/agent-kinds.js +175 -0
  168. package/src/ui/agent-place.js +265 -0
  169. package/src/ui/app.css +2494 -0
  170. package/src/ui/app.js +546 -0
  171. package/src/ui/board.js +465 -0
  172. package/src/ui/boxes.js +76 -0
  173. package/src/ui/branches.js +111 -0
  174. package/src/ui/catchup.js +77 -0
  175. package/src/ui/changes.js +159 -0
  176. package/src/ui/chat.js +266 -0
  177. package/src/ui/cloud-ai-model.js +108 -0
  178. package/src/ui/cloud-ai.js +221 -0
  179. package/src/ui/common.js +433 -0
  180. package/src/ui/csv.js +21 -0
  181. package/src/ui/diffview.js +74 -0
  182. package/src/ui/feed-convs.js +39 -0
  183. package/src/ui/feed.js +174 -0
  184. package/src/ui/files.js +579 -0
  185. package/src/ui/fileview.js +150 -0
  186. package/src/ui/git-rule.js +60 -0
  187. package/src/ui/home.js +1322 -0
  188. package/src/ui/index.html +16 -0
  189. package/src/ui/invite.js +97 -0
  190. package/src/ui/invites.js +79 -0
  191. package/src/ui/loom-model.js +516 -0
  192. package/src/ui/loom.css +365 -0
  193. package/src/ui/loom.js +786 -0
  194. package/src/ui/mark.js +104 -0
  195. package/src/ui/rail-model.js +75 -0
  196. package/src/ui/rail.js +173 -0
  197. package/src/ui/releases.js +182 -0
  198. package/src/ui/sandbox-keys-model.js +53 -0
  199. package/src/ui/sandbox-keys.js +132 -0
  200. package/src/ui/schedule.js +156 -0
  201. package/src/ui/secret-rules.js +255 -0
  202. package/src/ui/secrets-view.js +162 -0
  203. package/src/ui/secrets.js +283 -0
  204. package/src/ui/session.js +3292 -0
  205. package/src/ui/signin.js +75 -0
  206. package/src/ui/sounds.js +110 -0
  207. package/src/ui/tool-logo.js +86 -0
  208. package/src/ui/tree.js +281 -0
  209. package/src/ui/workspaces.js +547 -0
  210. package/src/ui-server.js +1381 -0
  211. package/src/update-check.js +67 -0
  212. package/src/upstream.js +192 -0
  213. package/src/vault/crypto.js +96 -0
  214. package/src/vault/kms.js +61 -0
  215. package/src/vault/main.js +33 -0
  216. package/src/vault/server.js +279 -0
  217. package/src/vault/sigv4.js +38 -0
  218. package/src/webhooks.js +235 -0
  219. package/src/workspace-cli.js +62 -0
  220. package/src/workspace-tools.js +340 -0
  221. package/index.js +0 -2
@@ -0,0 +1,150 @@
1
+ // A read-only view of one shared file, with who's editing it, its claim, and
2
+ // freshly changed lines highlighted for a few seconds. Its Changes tab lists
3
+ // every change to the file with its diff (diffview.js).
4
+ import { esc, bytes, I, loadingNote } from './common.js'
5
+ import { claimFolder, claimHolder, claimTitle } from './tree.js'
6
+ import { historyMarkup, changeCount } from './diffview.js'
7
+
8
+ const MAX_LINES = 20000
9
+
10
+ /**
11
+ * @param {HTMLElement} el
12
+ * @param {object} o
13
+ * @param {object|null} o.file response from /file, or { missing: true }
14
+ * @param {object|null} o.meta tree entry ({ edited, claim })
15
+ * @param {string} o.me
16
+ * @param {string|null} o.prevText previous text, to highlight what changed
17
+ * @param {'file'|'changes'} [o.tab] which tab is shown
18
+ * @param {object[]|null} [o.history] the file's changes, newest first (null: not loaded yet)
19
+ * @param {object} [o.people] { who, colorOf } for the Changes tab
20
+ * @param {object|null} [o.summary] the file's totals in this session (changes.js fileChanges)
21
+ */
22
+ export function renderFileView (el, { path, file, meta, me, prevText, bannerOnly = false, tab = 'file', history = null, people, summary = null, chatHref = null }) {
23
+ const scroller = el.querySelector('.fv-scroll')
24
+ const keep = scroller && scroller.dataset.path === path ? scroller.scrollTop : 0
25
+ // The tree tells which files came from chat: one opened before it loaded is drawn again once it has.
26
+ if (meta && meta.chat) return bannerOnly && scroller && scroller.dataset.tab === 'chat' && scroller.dataset.path === path ? undefined : renderChatFile(el, { path, file, chat: meta.chat, me, href: chatHref, keep: scroller && scroller.dataset.path === path ? keep : 0 })
27
+
28
+ const edited = meta && meta.edited
29
+ const claim = meta && meta.claim
30
+ const editedText = edited ? `Edited by ${edited.by === me ? 'you' : esc(edited.by)} ${agoLong(edited.ts)}` : 'Not edited recently'
31
+ const claimText = claim
32
+ ? `<span class="fv-claim" title="${esc(claimTitle(claim, me))}">${I.lock}Claimed by ${esc(claimHolder(claim, me))}${claimFolder(claim.pattern) !== path ? ` (via <code>${esc(claim.pattern)}</code>)` : ''}${claim.note ? `: ${esc(claim.note)}` : ''}${claim.queue && claim.queue.length ? ` · ${claim.queue.length} waiting` : ''}</span>`
33
+ : '<span class="hint">Not claimed</span>'
34
+ let action = ''
35
+ if (!file || !file.missing) {
36
+ if (!claim) action = `<button class="btn sm" data-fv="claim">${I.lock}Claim</button>`
37
+ else if (claim.by === me && !(claim.queue || []).length) action = `<button class="btn sm ghost" data-fv="release" data-pattern="${esc(claim.pattern)}">Release</button>`
38
+ }
39
+
40
+ const banner = `<div class="fv-banner"><span class="fv-path mono">${esc(path)}</span><span class="fv-edited">${editedText}</span>${claimText}<span class="spacer"></span>${action}<span class="tag">Read-only</span></div>`
41
+ // Refresh who-edited/claim info without re-rendering (and un-highlighting) the code.
42
+ const existing = el.querySelector('.fv-banner')
43
+ if (bannerOnly && existing && scroller && scroller.dataset.path === path) { existing.outerHTML = banner; return }
44
+
45
+ let body
46
+ if (tab === 'changes') body = history ? `<div class="fv-history">${historyMarkup(history, { ...people, summary })}</div>` : '<div class="fv-note">Reading what changed…</div>'
47
+ else if (!file) body = `<div class="fv-note">${loadingNote()}</div>`
48
+ else if (file.missing) body = `<div class="fv-note">${file.deleted ? 'This file was deleted.' : 'This file isn’t in the session. It may be too large to sync, or ignored.'}</div>`
49
+ else if (file.binary) body = `<div class="fv-note">${I.file} Binary file · ${bytes(file.size)}. Only text files can be shown here.</div>`
50
+ else {
51
+ const lines = file.text.split('\n')
52
+ if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop()
53
+ const changed = prevText != null && prevText !== file.text ? changedLines(prevText.split('\n'), lines) : new Set()
54
+ const shown = lines.slice(0, MAX_LINES)
55
+ body = `<table class="fv-code"><tbody>${shown.map((l, i) =>
56
+ `<tr${changed.has(i) ? ' class="chg"' : ''}><td class="ln">${i + 1}</td><td class="lt">${esc(l) || ' '}</td></tr>`).join('')}</tbody></table>
57
+ ${lines.length > MAX_LINES ? `<div class="fv-note">Showing the first ${MAX_LINES.toLocaleString()} lines.</div>` : ''}`
58
+ }
59
+
60
+ el.innerHTML = `<div class="fv">${banner}${tabsMarkup(tab, history, summary)}<div class="fv-scroll" data-path="${esc(path)}" data-tab="${tab}">${body}</div></div>`
61
+ const s = el.querySelector('.fv-scroll')
62
+ if (scroller && scroller.dataset.path === path && scroller.dataset.tab !== s.dataset.tab) { s.scrollTop = 0; return } // the other tab starts at the top
63
+ const first = s.querySelector('tr.chg')
64
+ if (first && prevText != null) {
65
+ // Bring the change into view if it's off screen.
66
+ s.scrollTop = keep
67
+ const top = first.offsetTop
68
+ if (top < s.scrollTop || top > s.scrollTop + s.clientHeight - 40) s.scrollTop = Math.max(0, top - 80)
69
+ } else {
70
+ s.scrollTop = keep
71
+ }
72
+ }
73
+
74
+ const IMAGE = /\.(png|jpe?g|gif|webp)$/i
75
+
76
+ /**
77
+ * A file sent in chat, from the chat files folder: who sent it, a download, and the file itself
78
+ * (text, or a picture). No claim and no changes: it is this person's copy, never synced.
79
+ */
80
+ function renderChatFile (el, { path, file, chat, me, href, keep }) {
81
+ const who = (n) => n === me ? 'you' : esc(n)
82
+ const banner = `<div class="fv-banner"><span class="fv-path mono">${esc(path)}</span><span class="fv-edited">Sent in chat by ${who(chat.by)}${chat.to ? ` to ${who(chat.to)}` : ''} ${agoLong(chat.ts)}</span>
83
+ <span class="hint">Only people in the session get it. Never synced or committed.</span><span class="spacer"></span>${href ? `<a class="btn sm" href="${href}" download>${I.down}<span>Download</span></a>` : ''}</div>`
84
+ let body
85
+ if (!file) body = `<div class="fv-note">${loadingNote()}</div>`
86
+ else if (file.missing) body = '<div class="fv-note">This file is no longer in your Chat files folder.</div>'
87
+ else if (file.binary && IMAGE.test(path) && href) body = `<div class="fv-image"><img src="${href}&amp;inline=1" alt="${esc(path.split('/').pop())}"></div>`
88
+ else if (file.binary) body = `<div class="fv-note">${I.file} ${bytes(file.size)}. It is in the Chat files folder in this session's folder.</div>`
89
+ else {
90
+ const lines = file.text.split('\n')
91
+ if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop()
92
+ body = `<table class="fv-code"><tbody>${lines.slice(0, MAX_LINES).map((l, i) => `<tr><td class="ln">${i + 1}</td><td class="lt">${esc(l) || ' '}</td></tr>`).join('')}</tbody></table>
93
+ ${lines.length > MAX_LINES ? `<div class="fv-note">Showing the first ${MAX_LINES.toLocaleString()} lines.</div>` : ''}`
94
+ }
95
+ el.innerHTML = `<div class="fv">${banner}<div class="fv-scroll" data-path="${esc(path)}" data-tab="chat">${body}</div></div>`
96
+ el.querySelector('.fv-scroll').scrollTop = keep
97
+ }
98
+
99
+ const tabsMarkup = (tab, history, summary) => `<div class="fv-tabs segmented" role="tablist" aria-label="File or its changes">
100
+ <button type="button" role="tab" data-fvtab="file" class="${tab === 'file' ? 'on' : ''}" aria-selected="${tab === 'file'}">File</button>
101
+ <button type="button" role="tab" data-fvtab="changes" class="${tab === 'changes' ? 'on' : ''}" aria-selected="${tab === 'changes'}" title="Every change to this file, with who made it and what changed">Changes${history ? ` <span class="n">${changeCount(history, summary)}</span>` : ''}</button>
102
+ </div>`
103
+
104
+ /** Updates the tabs (the Changes count) without re-rendering, so freshly changed lines stay lit. */
105
+ export function renderFileTabs (el, { tab, history, summary = null }) {
106
+ const tabs = el.querySelector('.fv-tabs')
107
+ if (tabs) tabs.outerHTML = tabsMarkup(tab, history, summary)
108
+ }
109
+
110
+ function agoLong (ts) {
111
+ const s = Math.max(0, Math.round((Date.now() - ts) / 1000))
112
+ if (s < 5) return 'just now'
113
+ if (s < 60) return `${s}s ago`
114
+ if (s < 3600) return `${Math.round(s / 60)}m ago`
115
+ if (s < 86400) return `${Math.round(s / 3600)}h ago`
116
+ return new Date(ts).toLocaleDateString()
117
+ }
118
+
119
+ /** Indexes of lines in `b` that are new or changed compared with `a`. */
120
+ export function changedLines (a, b) {
121
+ const out = new Set()
122
+ let start = 0
123
+ while (start < a.length && start < b.length && a[start] === b[start]) start++
124
+ let endA = a.length - 1
125
+ let endB = b.length - 1
126
+ while (endA >= start && endB >= start && a[endA] === b[endB]) { endA--; endB-- }
127
+ if (endB < start) return out // only deletions
128
+ const midA = a.slice(start, endA + 1)
129
+ const midB = b.slice(start, endB + 1)
130
+ if (midA.length * midB.length > 1_000_000) {
131
+ for (let i = start; i <= endB; i++) out.add(i)
132
+ return out
133
+ }
134
+ // Longest common subsequence over the changed middle section.
135
+ const n = midA.length
136
+ const m = midB.length
137
+ const dp = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1))
138
+ for (let i = n - 1; i >= 0; i--) {
139
+ for (let j = m - 1; j >= 0; j--) {
140
+ dp[i][j] = midA[i] === midB[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1])
141
+ }
142
+ }
143
+ let i = 0
144
+ let j = 0
145
+ while (j < m) {
146
+ if (i < n && midA[i] === midB[j]) { i++; j++ } else if (i < n && dp[i + 1][j] >= dp[i][j + 1]) i++
147
+ else { out.add(start + j); j++ }
148
+ }
149
+ return out
150
+ }
@@ -0,0 +1,60 @@
1
+ // Git for everyone's one control (docs/superpowers/specs/2026-10-17-git-for-everyone.md, mockup option A):
2
+ // off for everyone, or on with agents pushing to branches of their own or to any branch. The same
3
+ // segmented control in the session's settings, a workspace's and an org's GitHub settings (the app) and a
4
+ // team's and an org's settings (the website, which imports this file). A rule above (org, team, workspace)
5
+ // is a ceiling: the choices above it are greyed, with who set it and who changes it. Pure: no DOM.
6
+
7
+ export const GIT_SEGMENTS = [
8
+ { value: 'off', head: 'Off', sub: 'for everyone' },
9
+ { value: 'branches', head: 'On', sub: 'agents: own branches' },
10
+ { value: 'any', head: 'On', sub: 'agents: any branch' }
11
+ ]
12
+ const RANK = { off: 0, branches: 1, any: 2 }
13
+ const UP_TO = { off: 'git off', branches: 'up to own branches', any: 'up to any branch' }
14
+ const LEVEL = { org: 'Org', team: 'Team', workspace: 'Workspace' }
15
+ const FIXER = {
16
+ workspace: 'Its admins change that in its GitHub settings.',
17
+ org: 'Its admins change that in the org\'s settings.',
18
+ team: 'Whoever may update the org\'s teams changes that in the team\'s settings, on the website.'
19
+ }
20
+
21
+ /** "Workspace Platform", "Org Acme", "Team Night shift" (or "The workspace" without a name). */
22
+ export function ruleWho (rule) {
23
+ const level = LEVEL[rule && rule.by] || 'Workspace'
24
+ return rule && rule.name ? `${level} ${rule.name}` : `The ${level.toLowerCase()}`
25
+ }
26
+
27
+ /**
28
+ * The control's state. `value`: the level's own choice ('' when it sets nothing), or for a session what
29
+ * holds. `rule`: { by, name, mode } from the level above that holds, or null. `canEdit`: false greys it all.
30
+ */
31
+ export function gitControl ({ value = '', rule = null, canEdit = true } = {}) {
32
+ const ceiling = rule && rule.mode in RANK ? rule.mode : null
33
+ const above = (v) => ceiling !== null && RANK[v] > RANK[ceiling]
34
+ const holds = value in RANK ? (above(value) ? ceiling : value) : ''
35
+ const segments = GIT_SEGMENTS.map((s) => ({ ...s, on: s.value === holds, disabled: !canEdit || above(s.value) }))
36
+ if (!ceiling) return { segments, rulePill: '', lockedHint: '' }
37
+ const who = ruleWho(rule)
38
+ const whoLower = who.charAt(0).toLowerCase() + who.slice(1)
39
+ const allows = whoLower.startsWith('the ') ? `what ${whoLower} allows` : `what the ${whoLower} allows`
40
+ const fixer = FIXER[rule.by] || FIXER.workspace
41
+ const lockedHint = ceiling === 'off'
42
+ ? `Git on is above ${allows}: it turned git off. ${fixer}`
43
+ : ceiling === 'branches' ? `"Any branch" is above ${allows}. ${fixer}` : ''
44
+ return { segments, rulePill: `${who}: ${UP_TO[ceiling]}`, lockedHint }
45
+ }
46
+
47
+ /** The status line ("Git is on for everyone here, through …"), its first clause bold as in the mockup. */
48
+ export function gitStatusParts (words) {
49
+ const s = String(words || '')
50
+ const m = s.match(/^(.*?)(, | \(|: )/)
51
+ return m ? { strong: m[1], rest: s.slice(m[1].length) } : { strong: s, rest: '' }
52
+ }
53
+
54
+ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
55
+
56
+ /** The control as the app draws it: `attr` is the data attribute each button carries its value in. */
57
+ export function gitSegHtml (model, { attr, label }) {
58
+ return `<div class="seg" role="group" aria-label="${esc(label)}">${model.segments.map((s) =>
59
+ `<button type="button"${s.on ? ' class="on"' : ''} ${attr}="${s.value}" aria-pressed="${s.on}"${s.disabled ? ' disabled' : ''}><b>${esc(s.head)}</b>${esc(s.sub)}</button>`).join('')}</div>`
60
+ }