heyquilt 0.3.90 → 0.3.107
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/README.md +14 -11
- package/RELEASES.md +135 -2
- package/bin/quilt.js +45 -12
- package/package.json +3 -3
- package/src/account.js +28 -0
- package/src/agents/claude-code.js +11 -4
- package/src/agents/cursor-transcripts.js +11 -4
- package/src/ai-vm-signin.js +14 -2
- package/src/ai-vm.js +150 -8
- package/src/api/agent-auth.js +9 -3
- package/src/api/fly-machines.js +72 -15
- package/src/api/memory-store.js +142 -6
- package/src/api/relay-cluster.js +337 -43
- package/src/api/relay-machines.js +35 -3
- package/src/api/releases.js +87 -0
- package/src/api/routes/agents.js +111 -16
- package/src/api/routes/ai-vm.js +146 -19
- package/src/api/routes/orgs.js +3 -1
- package/src/api/routes/secrets.js +448 -0
- package/src/api/routes/workspaces.js +8 -1
- package/src/api/sandbox-keys.js +57 -0
- package/src/api/secrets-access.js +93 -0
- package/src/api/server.js +40 -9
- package/src/api/supabase-store.js +121 -11
- package/src/autowork.js +3 -15
- package/src/catchup.js +1 -1
- package/src/clash.js +10 -22
- package/src/control.js +18 -15
- package/src/fsutil.js +11 -1
- package/src/gitstate.js +29 -5
- package/src/hooks.js +1 -1
- package/src/mcp-proxy.js +7 -2
- package/src/mcp.js +22 -25
- package/src/merge3.js +94 -12
- package/src/pass-source.js +9 -2
- package/src/pathrules.js +7 -0
- package/src/relay-clash.js +101 -484
- package/src/relay-githttp.js +36 -7
- package/src/relay-mcp.js +19 -7
- package/src/relay-metrics.js +66 -1
- package/src/relay-outbound.js +154 -25
- package/src/relay-sync.js +133 -136
- package/src/relay-turn-sync.js +250 -51
- package/src/relay-upstream.js +49 -179
- package/src/releases.js +16 -9
- package/src/runner.js +3 -2
- package/src/secret-import.js +55 -0
- package/src/secret-names.js +26 -0
- package/src/secret-run.js +80 -0
- package/src/secrets-client.js +56 -0
- package/src/server.js +71 -44
- package/src/session.js +347 -262
- package/src/status.js +3 -5
- package/src/sync-words.js +1 -1
- package/src/turn-sync-plan.js +13 -10
- package/src/turn-sync.js +32 -11
- package/src/ui/agent-guide.js +4 -4
- package/src/ui/app.css +208 -23
- package/src/ui/app.js +4 -0
- package/src/ui/cloud-ai-model.js +3 -2
- package/src/ui/common.js +2 -0
- package/src/ui/fileview.js +28 -1
- package/src/ui/home.js +32 -33
- package/src/ui/loom.css +18 -2
- package/src/ui/loom.js +41 -8
- package/src/ui/rail-model.js +6 -9
- package/src/ui/releases.js +1 -1
- package/src/ui/sandbox-keys-model.js +53 -0
- package/src/ui/sandbox-keys.js +132 -0
- package/src/ui/secret-rules.js +255 -0
- package/src/ui/secrets-view.js +162 -0
- package/src/ui/secrets.js +283 -0
- package/src/ui/session.js +47 -149
- package/src/ui/tree.js +23 -18
- package/src/ui/workspaces.js +5 -0
- package/src/ui-server.js +140 -28
- package/src/update-check.js +1 -1
- package/src/upstream.js +75 -38
- package/src/vault/crypto.js +96 -0
- package/src/vault/kms.js +61 -0
- package/src/vault/main.js +33 -0
- package/src/vault/server.js +279 -0
- package/src/vault/sigv4.js +38 -0
- package/src/ui/merges.js +0 -158
package/src/ui/session.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The session workspace: file tree on the left; in the middle the Loom (everyone's
|
|
2
2
|
// AI conversations at once), one person's AI chat, a shared file or the task board;
|
|
3
3
|
// the team chat on the right.
|
|
4
|
+
import { mountSecrets } from './secrets.js'
|
|
4
5
|
import { TOKEN, I, state, $, esc, basename, bytes, clock, ago, avatar, colorFor, toast, api, ask, remember, recall, AGENT_COMMIT_WORDS, toolsOf, busyPeople, NO_POSTING, ACCOUNT_KEY, loadAccessTypes, typeOptions, accessLine, loadingHtml, loadingNote } from './common.js'
|
|
5
6
|
import { openInvite, renderTabs, markRead, go } from './app.js'
|
|
6
7
|
import { renderFeed } from './feed.js'
|
|
@@ -18,7 +19,6 @@ import { openSettings } from './home.js'
|
|
|
18
19
|
import { fileCardHref, renderable, textHtml, mentionAt, mentionCandidates, completeMention, ALL_AGENTS, RELAY_NAME, isRelay, relayPrompts, typingNames, typingHtml, TYPING_MS, foldPersonas, aiOwners, aiName, shownName, ownAiChatter } from './chat.js'
|
|
19
20
|
import { renderBoard, taskNotesModalHtml, openTaskMenu, foldPlan, matchesSearch } from './board.js'
|
|
20
21
|
import { accessFormValues, accessSaveBody, grantsLoading, grantsLoaded, grantsFailed } from './access-form.js'
|
|
21
|
-
import { bindMerges, renderMergeView } from './merges.js'
|
|
22
22
|
import { workspaceFilePicker } from './files.js'
|
|
23
23
|
import { sessionAgentsHtml } from './agent-place.js'
|
|
24
24
|
import { renderCatchUp, bindCatchUp } from './catchup.js'
|
|
@@ -39,7 +39,7 @@ let boxView = { id: null, sig: null, at: 0, loading: false, data: { on: false, b
|
|
|
39
39
|
const BOXES_FRESH_MS = 3000 // while the panel is open, a box's change is fetched again at most this often
|
|
40
40
|
|
|
41
41
|
// ------------------------------------------------------------ layout state --
|
|
42
|
-
// Per session: mode ('ai' | 'files'
|
|
42
|
+
// Per session: mode ('ai' | 'files'), open tabs per mode, expanded folders.
|
|
43
43
|
function ws (id) {
|
|
44
44
|
if (!state.ws.has(id)) {
|
|
45
45
|
let saved = null
|
|
@@ -52,7 +52,6 @@ function ws (id) {
|
|
|
52
52
|
fileTabs: [],
|
|
53
53
|
fileSel: null,
|
|
54
54
|
fileTab: {}, // file path -> 'changes' when its Changes tab is shown (absent: the file)
|
|
55
|
-
mergeSel: null, // merge id shown in the compare view
|
|
56
55
|
expanded: {},
|
|
57
56
|
treeCollapsed: true, // the Files pane starts folded into its rail (rail.js)
|
|
58
57
|
stale: {}, // file path -> changed while not visible
|
|
@@ -160,7 +159,6 @@ export function mountSession (id) {
|
|
|
160
159
|
<div class="pane-head">
|
|
161
160
|
<button type="button" class="tree-collapse wide-tree" id="collapse-tree" title="Collapse files" aria-expanded="true" aria-controls="tree">${I.arrowLeft}</button>
|
|
162
161
|
<span class="pane-title">Files</span><span class="hint" id="file-count"></span>
|
|
163
|
-
<button type="button" class="merge-pill" id="merge-filter" aria-pressed="false" title="Show only the files to merge" hidden></button>
|
|
164
162
|
</div>
|
|
165
163
|
<div class="tree-rail" id="tree-rail" role="toolbar" aria-orientation="vertical" aria-label="Locks"></div>
|
|
166
164
|
<div class="tree-scroll" id="tree"></div>
|
|
@@ -200,7 +198,6 @@ export function mountSession (id) {
|
|
|
200
198
|
|
|
201
199
|
bindTop()
|
|
202
200
|
bindAccess()
|
|
203
|
-
bindMerges($('#main'), { sessionId: () => current, onCompare: openMerge, editors: mergeEditors })
|
|
204
201
|
bindChanges(id, mounted.signal, { onOpen: (path) => openFile(path, { tab: 'changes' }), close: () => setGitOpen(false), onCount: renderGitChip })
|
|
205
202
|
bindCatchUp($('#catchup'), { sessionId: () => current, onOpen: openFile })
|
|
206
203
|
bindGithubCard()
|
|
@@ -232,8 +229,6 @@ export function mountSession (id) {
|
|
|
232
229
|
setTimeout(() => cover.remove(), 300)
|
|
233
230
|
})
|
|
234
231
|
}
|
|
235
|
-
const shown = ws(id).mode === 'merge' && shownMerge()
|
|
236
|
-
if (shown && !shown.binary) refreshFile(shown.path, false) // the cached copy may be from before
|
|
237
232
|
loadNewPeople(id)
|
|
238
233
|
// Relative times ("4s ago") and recent-edit badges age out.
|
|
239
234
|
timers.push(setInterval(() => { renderTreePane(); renderTop() }, 15000))
|
|
@@ -305,7 +300,6 @@ export function sessionFileChanged (id, { path }) {
|
|
|
305
300
|
scheduleTree()
|
|
306
301
|
changesChanged()
|
|
307
302
|
const w = ws(id)
|
|
308
|
-
if (w.mode === 'merge' && shownMerge()?.path === path) refreshFile(path, false)
|
|
309
303
|
if (w.fileTabs.includes(path)) {
|
|
310
304
|
if (w.mode === 'files' && w.fileSel === path) { refreshFile(path, true); scheduleHistory(path) } else { w.stale[path] = true; histories.delete(fileKey(path)); renderMainBar() }
|
|
311
305
|
}
|
|
@@ -327,13 +321,6 @@ function editorsByPreference () {
|
|
|
327
321
|
return mine ? [mine, ...all.filter((e) => e !== mine)] : all
|
|
328
322
|
}
|
|
329
323
|
|
|
330
|
-
/** The merge bar's apps: your AI on its own first when you turned that on (Settings → Your AI), then the apps. */
|
|
331
|
-
function mergeEditors () {
|
|
332
|
-
const a = state.profile?.auto
|
|
333
|
-
const apps = editorsByPreference()
|
|
334
|
-
return a && a.mode === 'auto' && a.supported ? [{ id: 'auto', name: 'your AI', auto: true }, ...apps] : apps
|
|
335
|
-
}
|
|
336
|
-
|
|
337
324
|
function openInMarkup () {
|
|
338
325
|
const [first, ...rest] = editorsByPreference()
|
|
339
326
|
if (!first) return ''
|
|
@@ -680,7 +667,6 @@ function renderTop () {
|
|
|
680
667
|
renderAccess()
|
|
681
668
|
renderCatchUp($('#catchup'), st.catchUp, { colors: new Map([st.me, ...st.peers].map((p) => [p.name, p.color])) })
|
|
682
669
|
renderGithubCard()
|
|
683
|
-
renderMerges()
|
|
684
670
|
renderGitChip()
|
|
685
671
|
$('#rename-btn').hidden = !st.access?.owner
|
|
686
672
|
$('#move-ws-btn').hidden = !(st.access?.owner && state.workspacesOn && (state.workspaces || []).some((w) => w.access === 'edit'))
|
|
@@ -1169,66 +1155,6 @@ async function moveToWorkspace () {
|
|
|
1169
1155
|
} catch (err) { toast(err.message) }
|
|
1170
1156
|
}
|
|
1171
1157
|
|
|
1172
|
-
// ----------------------------------------------------------------- merges --
|
|
1173
|
-
const isViewer = () => { const a = sum().status.access || {}; return !!(a.controlled && a.role === 'viewer') }
|
|
1174
|
-
const shownMerge = () => (sum().status.merges || []).find((m) => m.id === ws(current).mergeSel) || null
|
|
1175
|
-
|
|
1176
|
-
// Status carries merge records without their texts; the compare view fetches
|
|
1177
|
-
// the full record once. Keyed by session, id and state: ours and base never
|
|
1178
|
-
// change for a record, but "edit by hand" and settling change its state.
|
|
1179
|
-
const mergeTexts = new Map() // `${session}\n${id}\n${state}` -> { ours, base } | 'loading'
|
|
1180
|
-
const mergeKey = (sid, m) => `${sid}\n${m.id}\n${m.state}`
|
|
1181
|
-
|
|
1182
|
-
/** The shown merge's ours and base, or undefined while they load. */
|
|
1183
|
-
function fullMerge (m) {
|
|
1184
|
-
const sid = current
|
|
1185
|
-
const got = mergeTexts.get(mergeKey(sid, m))
|
|
1186
|
-
if (got && got !== 'loading') return got
|
|
1187
|
-
if (got) return undefined
|
|
1188
|
-
mergeTexts.set(mergeKey(sid, m), 'loading')
|
|
1189
|
-
api('GET', `/api/sessions/${sid}/merges`).then((r) => {
|
|
1190
|
-
for (const full of r.merges || []) mergeTexts.set(mergeKey(sid, full), { ours: full.ours ?? null, base: full.base ?? null })
|
|
1191
|
-
if (mergeTexts.get(mergeKey(sid, m)) === 'loading') mergeTexts.set(mergeKey(sid, m), { ours: null, base: null }) // gone meanwhile
|
|
1192
|
-
if (current === sid && ws(sid).mode === 'merge') renderMain()
|
|
1193
|
-
}).catch(() => {
|
|
1194
|
-
mergeTexts.set(mergeKey(sid, m), { ours: null, base: null }) // shown as not available, not refetched on every status
|
|
1195
|
-
if (current === sid && ws(sid).mode === 'merge') renderMain()
|
|
1196
|
-
})
|
|
1197
|
-
return undefined
|
|
1198
|
-
}
|
|
1199
|
-
|
|
1200
|
-
/** Files to merge, marked in the tree, and the compare view when it's showing (it only redraws on a change). */
|
|
1201
|
-
let lastMergeSig = ''
|
|
1202
|
-
function renderMerges () {
|
|
1203
|
-
if (!current || !sum()) return
|
|
1204
|
-
const merges = sum().status.merges || []
|
|
1205
|
-
const w = ws(current)
|
|
1206
|
-
// The tree marks the files to merge: drawn again only when they change.
|
|
1207
|
-
const sig = JSON.stringify(merges.filter((m) => m.state !== 'done').map((m) => [m.id, m.path, m.heldBy || '']))
|
|
1208
|
-
if (sig !== lastMergeSig) { lastMergeSig = sig; renderTreePane() }
|
|
1209
|
-
if (w.mode !== 'merge' || !w.mergeSel) return
|
|
1210
|
-
// The same file can conflict again after its record was settled: the compare
|
|
1211
|
-
// tab then follows the new open record instead of showing the old one.
|
|
1212
|
-
const shown = merges.find((m) => m.id === w.mergeSel)
|
|
1213
|
-
if (shown && shown.state === 'done') {
|
|
1214
|
-
const next = merges.find((m) => m.path === shown.path && m.state !== 'done')
|
|
1215
|
-
if (next) { w.mergeSel = next.id; saveWs(current); renderMainBar() }
|
|
1216
|
-
}
|
|
1217
|
-
renderMain()
|
|
1218
|
-
}
|
|
1219
|
-
|
|
1220
|
-
function openMerge (id) {
|
|
1221
|
-
const w = ws(current)
|
|
1222
|
-
w.mergeSel = id
|
|
1223
|
-
w.mode = 'merge'
|
|
1224
|
-
saveWs(current)
|
|
1225
|
-
renderMainBar()
|
|
1226
|
-
renderMain()
|
|
1227
|
-
renderTreePane()
|
|
1228
|
-
// Always fetch: a cached copy may be from before the session moved on.
|
|
1229
|
-
const m = shownMerge()
|
|
1230
|
-
if (m && !m.binary) refreshFile(m.path, false)
|
|
1231
|
-
}
|
|
1232
1158
|
|
|
1233
1159
|
/** Is member `m` an agent (a chat link is one too)? */
|
|
1234
1160
|
const isAgentMember = (m) => m.kind === 'agent' || !!m.chat
|
|
@@ -1560,15 +1486,20 @@ const SS_TABS = [
|
|
|
1560
1486
|
{ id: 'chat', label: 'Your AI chat', icon: 'chat' },
|
|
1561
1487
|
{ id: 'vc', label: 'Version control', icon: 'branch' },
|
|
1562
1488
|
{ id: 'agents', label: 'Agents', icon: 'bot' },
|
|
1563
|
-
{ id: 'people', label: 'People', icon: 'users' }
|
|
1489
|
+
{ id: 'people', label: 'People', icon: 'users' },
|
|
1490
|
+
// Secrets (secrets.js): only once the accounts API has them switched on.
|
|
1491
|
+
{ id: 'secrets', label: 'Secrets', icon: 'lock', on: () => state.secretsOn }
|
|
1564
1492
|
]
|
|
1493
|
+
const ssTabs = () => SS_TABS.filter((x) => !x.on || x.on())
|
|
1494
|
+
// The Secrets tab's card while it is drawn: kept across redraws of the window, so a typed name isn't lost.
|
|
1495
|
+
let ssSecrets = null
|
|
1565
1496
|
let ssTab = 'chat'
|
|
1566
1497
|
|
|
1567
1498
|
function openSessionSettings (tab) {
|
|
1568
1499
|
document.querySelector('.session-settings-back')?.remove()
|
|
1569
1500
|
$('#people-menu').hidden = true
|
|
1570
1501
|
$('#people-btn').setAttribute('aria-expanded', 'false')
|
|
1571
|
-
if (tab &&
|
|
1502
|
+
if (tab && ssTabs().some((t) => t.id === tab)) ssTab = tab
|
|
1572
1503
|
const back = document.createElement('div')
|
|
1573
1504
|
back.className = 'modal-back top session-settings-back'
|
|
1574
1505
|
back.innerHTML = `<div class="card modal session-settings" role="dialog" aria-modal="true" aria-labelledby="ss-title">
|
|
@@ -1597,8 +1528,9 @@ function openSessionSettings (tab) {
|
|
|
1597
1528
|
back.querySelector('.ss-tabs').addEventListener('keydown', (e) => {
|
|
1598
1529
|
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return
|
|
1599
1530
|
e.preventDefault()
|
|
1600
|
-
const i =
|
|
1601
|
-
|
|
1531
|
+
const i = ssTabs().findIndex((t) => t.id === ssTab)
|
|
1532
|
+
const list = ssTabs()
|
|
1533
|
+
ssTab = list[(i + (e.key === 'ArrowDown' ? 1 : list.length - 1)) % list.length].id
|
|
1602
1534
|
renderSessionSettings({ force: true })
|
|
1603
1535
|
back.querySelector(`[data-ss-tab="${ssTab}"]`)?.focus()
|
|
1604
1536
|
})
|
|
@@ -1621,7 +1553,7 @@ function renderSessionSettings ({ force = false } = {}) {
|
|
|
1621
1553
|
const st = sum().status
|
|
1622
1554
|
// The list is drawn once (a redraw under a click would lose it); here only which is on, and the counts.
|
|
1623
1555
|
const tabs = back.querySelector('.ss-tabs')
|
|
1624
|
-
if (!tabs.children.length) tabs.innerHTML =
|
|
1556
|
+
if (!tabs.children.length) tabs.innerHTML = ssTabs().map((t) => `<button type="button" role="tab" class="ss-tab" data-ss-tab="${t.id}">${I[t.icon]}<span>${t.label}</span><span class="ss-count"></span></button>`).join('')
|
|
1625
1557
|
for (const b of tabs.children) {
|
|
1626
1558
|
const on = b.dataset.ssTab === ssTab
|
|
1627
1559
|
b.classList.toggle('on', on)
|
|
@@ -1630,7 +1562,17 @@ function renderSessionSettings ({ force = false } = {}) {
|
|
|
1630
1562
|
b.querySelector('.ss-count').textContent = ssCount(st, b.dataset.ssTab)
|
|
1631
1563
|
}
|
|
1632
1564
|
box.dataset.tab = ssTab
|
|
1633
|
-
back.querySelector('.ss-name').textContent =
|
|
1565
|
+
back.querySelector('.ss-name').textContent = (ssTabs().find((t) => t.id === ssTab) || ssTabs()[0]).label
|
|
1566
|
+
if (ssTab === 'secrets') {
|
|
1567
|
+
const room = st.room
|
|
1568
|
+
const mounted = box.querySelector(`[data-secrets-room="${CSS.escape(String(room || ''))}"]`)
|
|
1569
|
+
if (mounted && !force) return
|
|
1570
|
+
ssSecrets?.destroy()
|
|
1571
|
+
box.innerHTML = `<div class="ss-secrets" data-secrets-room="${esc(room || '')}"></div>`
|
|
1572
|
+
ssSecrets = mountSecrets(box.firstElementChild, { type: 'session', id: room, importFrom: { sessionId: current } })
|
|
1573
|
+
return
|
|
1574
|
+
}
|
|
1575
|
+
if (ssSecrets) { ssSecrets.destroy(); ssSecrets = null }
|
|
1634
1576
|
// Don't redraw under the owner while they change someone's access (unless fresh grants
|
|
1635
1577
|
// arrived: then the form is redrawn from them, and keeps its focus).
|
|
1636
1578
|
const active = box.contains(document.activeElement) && document.activeElement.closest('.pm-member') ? document.activeElement : null
|
|
@@ -1649,17 +1591,6 @@ function bindMain () {
|
|
|
1649
1591
|
const close = e.target.closest('[data-close]')
|
|
1650
1592
|
const tab = e.target.closest('[data-tab]')
|
|
1651
1593
|
const w = ws(current)
|
|
1652
|
-
if (close && close.dataset.kind === 'merge') {
|
|
1653
|
-
e.stopPropagation()
|
|
1654
|
-
w.mergeSel = null
|
|
1655
|
-
if (w.mode === 'merge') w.mode = w.fileSel ? 'files' : 'ai'
|
|
1656
|
-
saveWs(current)
|
|
1657
|
-
renderMainBar()
|
|
1658
|
-
renderMain()
|
|
1659
|
-
renderTreePane()
|
|
1660
|
-
return
|
|
1661
|
-
}
|
|
1662
|
-
if (tab && tab.dataset.kind === 'merge') { openMerge(tab.dataset.tab); return }
|
|
1663
1594
|
if (tab && tab.dataset.kind === 'loom') { openLoom(); return }
|
|
1664
1595
|
if (close) {
|
|
1665
1596
|
e.stopPropagation()
|
|
@@ -1779,20 +1710,10 @@ function renderMainBar () {
|
|
|
1779
1710
|
}).join('') + (w.fileTabs.length ? '<span class="ws-tab-sep"></span>' : '') +
|
|
1780
1711
|
w.fileTabs.map((path) => `<div class="ws-tab${fileOn(path) ? ' on' : ''}" role="tab" aria-selected="${fileOn(path)}" tabindex="0" data-kind="file" data-tab="${esc(path)}" title="${esc(path)}">
|
|
1781
1712
|
<span class="ico">${I.file}</span><span class="nm">${esc(basename(path))}</span>${w.stale[path] ? '<span class="changed" title="Changed"></span>' : ''}
|
|
1782
|
-
<button class="x" data-kind="file" data-close="${esc(path)}" aria-label="Close ${esc(basename(path))}">${I.x}</button></div>`).join('')
|
|
1783
|
-
(w.mergeSel ? mergeTabHtml(w) : '')
|
|
1713
|
+
<button class="x" data-kind="file" data-close="${esc(path)}" aria-label="Close ${esc(basename(path))}">${I.x}</button></div>`).join('')
|
|
1784
1714
|
// On Everyone with nothing else open, the row would only name the view you are in: hide it until
|
|
1785
1715
|
// there is a tab to switch to (or, on the Tasks board, Everyone to go back to).
|
|
1786
|
-
$('#mainbar').hidden = loomOn && !w.aiTabs.length && !w.fileTabs.length
|
|
1787
|
-
}
|
|
1788
|
-
|
|
1789
|
-
function mergeTabHtml (w) {
|
|
1790
|
-
const m = shownMerge()
|
|
1791
|
-
const on = w.mode === 'merge'
|
|
1792
|
-
const name = m ? `Merge ${basename(m.path)}` : 'Merge'
|
|
1793
|
-
return `<span class="ws-tab-sep"></span><div class="ws-tab${on ? ' on' : ''}" role="tab" aria-selected="${on}" tabindex="0" data-kind="merge" data-tab="${esc(w.mergeSel)}" title="${esc(m ? `Compare the two versions of ${m.path}` : 'Merge')}">
|
|
1794
|
-
<span class="ico">${I.branch}</span><span class="nm">${esc(name)}</span>
|
|
1795
|
-
<button class="x" data-kind="merge" data-close="${esc(w.mergeSel)}" aria-label="Close merge">${I.x}</button></div>`
|
|
1716
|
+
$('#mainbar').hidden = loomOn && !w.aiTabs.length && !w.fileTabs.length
|
|
1796
1717
|
}
|
|
1797
1718
|
|
|
1798
1719
|
// A card being renamed, or a drag in progress, must not be rebuilt under the pointer.
|
|
@@ -2420,17 +2341,7 @@ function renderMain () {
|
|
|
2420
2341
|
</div>`
|
|
2421
2342
|
return
|
|
2422
2343
|
}
|
|
2423
|
-
if (w.mode === 'merge'
|
|
2424
|
-
if (w.mode === 'merge') {
|
|
2425
|
-
const m = shownMerge()
|
|
2426
|
-
const cached = m ? state.files.get(fileKey(m.path)) : null
|
|
2427
|
-
const f = cached && cached.file
|
|
2428
|
-
const theirs = !cached ? undefined : f.missing || f.binary ? null : f.text
|
|
2429
|
-
const texts = m && !m.binary ? fullMerge(m) : { ours: null, base: null }
|
|
2430
|
-
renderMergeView(el, { merge: m, texts, theirs, me: me(), editors: mergeEditors(), viewer: isViewer() })
|
|
2431
|
-
if (m && !m.binary && !cached) refreshFile(m.path, false)
|
|
2432
|
-
return
|
|
2433
|
-
}
|
|
2344
|
+
if (w.mode === 'merge') w.mode = 'ai' // a compare tab from before Quilt merged on its own
|
|
2434
2345
|
if (w.mode === 'ai') {
|
|
2435
2346
|
if (!w.aiSel) {
|
|
2436
2347
|
const people = st.peers
|
|
@@ -2467,6 +2378,7 @@ function renderMain () {
|
|
|
2467
2378
|
return
|
|
2468
2379
|
}
|
|
2469
2380
|
const cached = state.files.get(fileKey(w.fileSel))
|
|
2381
|
+
if (!cached) loadFileOnce(w.fileSel) // a tab put back after a reload has nothing read yet
|
|
2470
2382
|
renderFileView(el, {
|
|
2471
2383
|
path: w.fileSel,
|
|
2472
2384
|
file: cached ? cached.file : null,
|
|
@@ -2476,7 +2388,8 @@ function renderMain () {
|
|
|
2476
2388
|
tab: fileTabOf(w.fileSel),
|
|
2477
2389
|
history: histories.get(fileKey(w.fileSel)) || null,
|
|
2478
2390
|
summary: fileChanges(w.fileSel),
|
|
2479
|
-
people: { who: (name) => name === me() ? 'you' : name, colorOf: (name) => personInfo(name).color }
|
|
2391
|
+
people: { who: (name) => name === me() ? 'you' : name, colorOf: (name) => personInfo(name).color },
|
|
2392
|
+
chatHref: treeMeta(w.fileSel)?.chat ? fileCardHref(current, treeMeta(w.fileSel).chat.id, TOKEN) : null
|
|
2480
2393
|
})
|
|
2481
2394
|
if (cached && fileTabOf(w.fileSel) === 'file') cached.prevText = null // highlight once, on the File tab
|
|
2482
2395
|
}
|
|
@@ -2528,6 +2441,14 @@ function treeMeta (path) {
|
|
|
2528
2441
|
return { path, edited: null, claim: null }
|
|
2529
2442
|
}
|
|
2530
2443
|
|
|
2444
|
+
const fileLoads = new Set() // fileKey -> being read for the first time
|
|
2445
|
+
function loadFileOnce (path) {
|
|
2446
|
+
const key = fileKey(path)
|
|
2447
|
+
if (fileLoads.has(key)) return
|
|
2448
|
+
fileLoads.add(key)
|
|
2449
|
+
refreshFile(path, false).finally(() => fileLoads.delete(key))
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2531
2452
|
async function refreshFile (path, highlight) {
|
|
2532
2453
|
const id = current
|
|
2533
2454
|
const key = fileKey(path)
|
|
@@ -2542,7 +2463,7 @@ async function refreshFile (path, highlight) {
|
|
|
2542
2463
|
if (id !== current) return
|
|
2543
2464
|
state.files.set(key, { file, prevText })
|
|
2544
2465
|
const w = ws(current)
|
|
2545
|
-
if (
|
|
2466
|
+
if (w.mode === 'files' && w.fileSel === path) renderMain()
|
|
2546
2467
|
}
|
|
2547
2468
|
|
|
2548
2469
|
// ------------------------------------------------------------------ Loom --
|
|
@@ -2667,7 +2588,8 @@ async function loadTree () {
|
|
|
2667
2588
|
if (w.mode === 'files' && w.fileSel) {
|
|
2668
2589
|
// Keep the banner (editor, claim) fresh without re-highlighting.
|
|
2669
2590
|
const cached = state.files.get(fileKey(w.fileSel))
|
|
2670
|
-
|
|
2591
|
+
const meta = treeMeta(w.fileSel)
|
|
2592
|
+
if (cached) renderFileView($('#main'), { path: w.fileSel, file: cached.file, meta, me: me(), bannerOnly: true, chatHref: meta?.chat ? fileCardHref(id, meta.chat.id, TOKEN) : null })
|
|
2671
2593
|
}
|
|
2672
2594
|
} catch {}
|
|
2673
2595
|
}
|
|
@@ -2678,20 +2600,10 @@ function renderTreePane () {
|
|
|
2678
2600
|
const w = ws(current)
|
|
2679
2601
|
const tree = state.trees.get(current)
|
|
2680
2602
|
const scroll = el.scrollTop
|
|
2681
|
-
|
|
2682
|
-
if (!merges.length) w.mergesOnly = false
|
|
2683
|
-
const shown = w.mode === 'merge' ? merges.find((m) => m.id === w.mergeSel)?.path : null
|
|
2684
|
-
renderTree(el, tree, { me: me(), expanded: w.expanded, selected: w.mode === 'files' ? w.fileSel : shown || null, merges, mergesOnly: !!w.mergesOnly })
|
|
2603
|
+
renderTree(el, tree, { me: me(), expanded: w.expanded, selected: w.mode === 'files' ? w.fileSel : null })
|
|
2685
2604
|
el.scrollTop = scroll
|
|
2686
|
-
$('#file-count').textContent = tree ? `${tree.files.length}` : ''
|
|
2687
|
-
|
|
2688
|
-
if (pill) {
|
|
2689
|
-
pill.hidden = !merges.length
|
|
2690
|
-
pill.innerHTML = `<span class="mp-n">${merges.length}</span><span class="mp-l">to merge</span>`
|
|
2691
|
-
pill.setAttribute('aria-pressed', String(!!w.mergesOnly))
|
|
2692
|
-
pill.title = w.mergesOnly ? 'Show every file' : 'Show only the files to merge'
|
|
2693
|
-
}
|
|
2694
|
-
renderRailPane(tree, merges)
|
|
2605
|
+
$('#file-count').textContent = tree ? `${tree.files.filter((f) => !f.chat).length}` : ''
|
|
2606
|
+
renderRailPane(tree)
|
|
2695
2607
|
// The rows were replaced: keep the card on the one still under the pointer, with fresh numbers.
|
|
2696
2608
|
if (cardPath) {
|
|
2697
2609
|
const row = treeRow(cardPath)
|
|
@@ -2817,25 +2729,21 @@ function openTaskNotes (task, { instant = false } = {}) {
|
|
|
2817
2729
|
if (instant) back.querySelector('.task-comment-text').focus()
|
|
2818
2730
|
}
|
|
2819
2731
|
|
|
2820
|
-
/** The folded Files pane: locks and who holds them (rail.js)
|
|
2821
|
-
function renderRailPane (tree = state.trees.get(current)
|
|
2732
|
+
/** The folded Files pane: locks and who holds them (rail.js). */
|
|
2733
|
+
function renderRailPane (tree = state.trees.get(current)) {
|
|
2822
2734
|
const el = $('#tree-rail')
|
|
2823
2735
|
const s = sum()
|
|
2824
2736
|
if (!el || !s) return
|
|
2825
2737
|
const st = s.status
|
|
2826
|
-
const w = ws(current)
|
|
2827
2738
|
const model = railModel({
|
|
2828
2739
|
claims: st.claims || [],
|
|
2829
|
-
merges,
|
|
2830
2740
|
me: st.me.name,
|
|
2831
2741
|
owners: owners(s),
|
|
2832
2742
|
aiName,
|
|
2833
2743
|
files: tree ? tree.files.map((f) => f.path) : [],
|
|
2834
|
-
open: [...w.fileTabs, ...(w.mergeSel ? [merges.find((m) => m.id === w.mergeSel)?.path] : [])].filter(Boolean),
|
|
2835
2744
|
focusOf: (name) => (name === st.me.name ? st.me.focus : st.peers.find((p) => p.name === name)?.focus) || '',
|
|
2836
2745
|
kindOf: (name) => personInfo(name).kind
|
|
2837
2746
|
})
|
|
2838
|
-
$('#merge-filter')?.classList.toggle('urgent', model.merges.urgent)
|
|
2839
2747
|
renderRail(el, model, { me: st.me.name, colorOf: (name) => personInfo(name).color, onFile: (path) => openFile(path) })
|
|
2840
2748
|
}
|
|
2841
2749
|
|
|
@@ -2958,15 +2866,6 @@ function bindChatPane () {
|
|
|
2958
2866
|
function bindTreeEvents () {
|
|
2959
2867
|
const collapse = $('#collapse-tree')
|
|
2960
2868
|
if (collapse) collapse.onclick = () => toggleTreeCollapsed()
|
|
2961
|
-
const filter = $('#merge-filter')
|
|
2962
|
-
if (filter) {
|
|
2963
|
-
filter.onclick = () => {
|
|
2964
|
-
const w = ws(current)
|
|
2965
|
-
if (w.treeCollapsed) { w.mergesOnly = true; toggleTreeCollapsed() } else w.mergesOnly = !w.mergesOnly
|
|
2966
|
-
saveWs(current)
|
|
2967
|
-
renderTreePane()
|
|
2968
|
-
}
|
|
2969
|
-
}
|
|
2970
2869
|
const el = $('#tree')
|
|
2971
2870
|
el.addEventListener('click', (e) => {
|
|
2972
2871
|
const more = e.target.closest('[data-more]')
|
|
@@ -2984,8 +2883,7 @@ function bindTreeEvents () {
|
|
|
2984
2883
|
return
|
|
2985
2884
|
}
|
|
2986
2885
|
const file = e.target.closest('[data-file]')
|
|
2987
|
-
if (file
|
|
2988
|
-
else if (file) openFile(file.dataset.file)
|
|
2886
|
+
if (file) openFile(file.dataset.file)
|
|
2989
2887
|
})
|
|
2990
2888
|
el.addEventListener('contextmenu', (e) => {
|
|
2991
2889
|
const row = e.target.closest('[data-file],[data-dir]')
|
package/src/ui/tree.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// The shared project's file tree: who edited what recently, what's claimed.
|
|
2
2
|
import { esc, I, ago, colorFor, loadingNote } from './common.js'
|
|
3
3
|
|
|
4
|
+
/** Where each person's chat files are kept (CHAT_FILES in pathrules.js, which the page can't load). */
|
|
5
|
+
export const CHAT_FILES = 'Chat files'
|
|
6
|
+
|
|
4
7
|
export const RECENT_MS = 2 * 60 * 1000
|
|
5
8
|
/** How long a file brought in from GitHub keeps its GitHub badge (an edit in the session takes it away sooner). */
|
|
6
9
|
export const GITHUB_BADGE_MS = 10 * 60 * 1000
|
|
@@ -76,15 +79,10 @@ function hasRecent (node, me) {
|
|
|
76
79
|
* @param {{ files: object[], claims: object[] }} tree
|
|
77
80
|
* @param {{ me: string, expanded: object, selected: string|null }} view
|
|
78
81
|
*/
|
|
79
|
-
export function renderTree (el, tree, { me, expanded, selected
|
|
82
|
+
export function renderTree (el, tree, { me, expanded, selected }) {
|
|
80
83
|
if (!tree) { el.innerHTML = `<div class="empty-note" style="padding:12px 14px">${loadingNote('Loading files…')}</div>`; return }
|
|
81
|
-
|
|
82
|
-
const mergeOf = new Map(merges.map((m) => [m.path, m]))
|
|
83
|
-
const known = new Set(tree.files.map((f) => f.path))
|
|
84
|
-
let files = [...tree.files, ...merges.filter((m) => !known.has(m.path)).map((m) => ({ path: m.path }))]
|
|
85
|
-
if (mergesOnly) files = files.filter((f) => mergeOf.has(f.path))
|
|
84
|
+
const files = tree.files
|
|
86
85
|
if (!files.length) { el.innerHTML = '<div class="empty-note" style="padding:12px 14px">No shared files yet.</div>'; return }
|
|
87
|
-
const mergesIn = (dir) => merges.filter((m) => m.path.startsWith(`${dir}/`)).length
|
|
88
86
|
const folderClaims = new Map()
|
|
89
87
|
for (const c of tree.claims) folderClaims.set(claimFolder(c.pattern), c)
|
|
90
88
|
const root = buildTree(files)
|
|
@@ -97,34 +95,41 @@ export function renderTree (el, tree, { me, expanded, selected, merges = [], mer
|
|
|
97
95
|
// Who just edited a file: a button to its Changes tab. Its +/− shows in the row's change card.
|
|
98
96
|
const editBadge = (f, byOther) => `<button type="button" class="t-badge ${byOther ? 'edit' : 'mine'}" data-changes="${esc(f.path)}" aria-label="See what changed in ${esc(f.path)}">${esc(f.edited.by === me ? 'you' : f.edited.by)} · ${shortAgo(f.edited.ts)}</button>`
|
|
99
97
|
const walk = (node, depth) => {
|
|
100
|
-
const dirs = [...node.dirs.values()].sort((a, b) => a.name.localeCompare(b.name))
|
|
98
|
+
const dirs = [...node.dirs.values()].sort((a, b) => (a.path === CHAT_FILES) - (b.path === CHAT_FILES) || a.name.localeCompare(b.name)) // chat files last
|
|
101
99
|
for (const d of dirs) {
|
|
102
|
-
const open =
|
|
100
|
+
const open = expanded[d.path] ?? depth === 0
|
|
103
101
|
const claim = folderClaims.get(d.path)
|
|
104
|
-
|
|
102
|
+
// The chat files folder (session.js): each person's own copy of what was sent in chat, nothing to claim.
|
|
103
|
+
if (d.path === CHAT_FILES) {
|
|
104
|
+
rows.push(`<div class="t-row t-dir t-chat" role="treeitem" aria-expanded="${open}" tabindex="-1" data-dir="${esc(d.path)}" style="--depth:${depth}" title="Files sent in this session's chat. Only people in the session get them; never synced or committed.">
|
|
105
|
+
<span class="t-caret${open ? ' open' : ''}">${I.caret}</span><span class="t-name">${esc(d.name)}</span><span class="t-badge chat">${I.lock}session only</span></div>`)
|
|
106
|
+
if (open) walk(d, depth + 1)
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
105
109
|
rows.push(`<div class="t-row t-dir" role="treeitem" aria-expanded="${open}" tabindex="-1" data-dir="${esc(d.path)}" style="--depth:${depth}">
|
|
106
110
|
<span class="t-caret${open ? ' open' : ''}">${I.caret}</span><span class="t-name">${esc(d.name)}</span>
|
|
107
|
-
${inside ? `<span class="t-badge merge" title="${inside} file${inside === 1 ? '' : 's'} to merge inside">${inside}</span>` : ''}
|
|
108
111
|
${!open && hasRecent(d, me) ? '<span class="t-dot" title="Recently edited inside"></span>' : ''}${claim ? claimBadge(claim) : !open ? insideBadge(claimsInside(tree.claims, d.path), me) : ''}
|
|
109
112
|
<button class="t-more" data-more="${esc(d.path)}" data-kind="dir" aria-label="More for ${esc(d.name)}">${I.more}</button></div>`)
|
|
110
113
|
if (open) walk(d, depth + 1)
|
|
111
114
|
}
|
|
112
115
|
for (const f of node.files.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
116
|
+
if (f.chat) {
|
|
117
|
+
rows.push(`<div class="t-row t-file${selected === f.path ? ' on' : ''}" role="treeitem" tabindex="-1" data-file="${esc(f.path)}" style="--depth:${depth}" title="${esc(`Sent in chat by ${f.chat.by === me ? 'you' : f.chat.by}${f.chat.to ? ` to ${f.chat.to === me ? 'you' : f.chat.to}` : ''}`)}">
|
|
118
|
+
<span class="t-ico">${I.file}</span><span class="t-name">${esc(f.name)}</span><span class="t-badge chat">${esc(f.chat.by === me ? 'you' : f.chat.by)}</span></div>`)
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
113
121
|
const recent = !!f.edited && Date.now() - f.edited.ts < RECENT_MS
|
|
114
122
|
const byOther = recent && f.edited.by !== me
|
|
115
123
|
const ownClaim = f.claim && claimFolder(f.claim.pattern) === f.path ? f.claim : null
|
|
116
124
|
// The holder of a claim is the one editing it: their claim badge says so, one badge is enough on a narrow row.
|
|
117
|
-
// A file to merge says so instead of who last edited it.
|
|
118
|
-
const m = mergeOf.get(f.path)
|
|
119
|
-
const mergeBadge = m ? `<span class="t-badge merge" title="${esc(m.heldBy && m.heldBy !== me ? `${m.heldBy} is merging it` : 'Changed in two places: open it to compare and settle')}">${m.heldBy && m.heldBy !== me ? esc(m.heldBy) : 'merge'}</span>` : ''
|
|
120
125
|
// GitHub's version, not edited in the session since: GitHub's commit takes the "who edited" place, for ten
|
|
121
126
|
// minutes after it came in (an older relay doesn't say when: then after the commit).
|
|
122
|
-
const gh =
|
|
127
|
+
const gh = f.github && Date.now() - (f.github.came || f.github.at || 0) < GITHUB_BADGE_MS ? f.github : null
|
|
123
128
|
const ghBadge = gh ? `<span class="t-badge gh" title="${esc(`From GitHub: ${gh.sha.slice(0, 7)}${gh.by ? ` by ${gh.by}` : ''}${gh.subject ? `: ${gh.subject}` : ''}. Not edited in the session since.`)}">${I.github}<span class="t-gh">${esc(gh.by || 'GitHub')}${gh.at ? ` · ${shortAgo(gh.at)}` : ''}</span></span>` : ''
|
|
124
129
|
const ghost = ownClaim && ownClaim.box && !boxMine(ownClaim)
|
|
125
|
-
rows.push(`<div class="t-row t-file${selected === f.path ? ' on' : ''}${ghost ? ' ghost' : ''}" role="treeitem" tabindex="-1" data-file="${esc(f.path)}"
|
|
126
|
-
<span class="t-ico">${I.file}</span><span class="t-name">${esc(f.name)}</span>${
|
|
127
|
-
${recent && !
|
|
130
|
+
rows.push(`<div class="t-row t-file${selected === f.path ? ' on' : ''}${ghost ? ' ghost' : ''}" role="treeitem" tabindex="-1" data-file="${esc(f.path)}" style="--depth:${depth}" title="${esc(f.path)}">
|
|
131
|
+
<span class="t-ico">${I.file}</span><span class="t-name">${esc(f.name)}</span>${ghBadge}
|
|
132
|
+
${recent && !gh && !(ownClaim && ownClaim.by === f.edited.by) ? editBadge(f, byOther) : ''}
|
|
128
133
|
${claimBadge(ownClaim)}
|
|
129
134
|
<button class="t-more" data-more="${esc(f.path)}" data-kind="file" aria-label="More for ${esc(f.name)}">${I.more}</button></div>`)
|
|
130
135
|
}
|
package/src/ui/workspaces.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Workspaces: the home grid of cards, the Add workspace card, and a workspace's page
|
|
2
2
|
// (Sessions, Files, People & agents, Settings). Sessions stay in session.js; the file library
|
|
3
3
|
// (the Files section, All files and uploads) is files.js.
|
|
4
|
+
import { mountSecrets } from './secrets.js'
|
|
4
5
|
import { I, state, $, esc, basename, toast, api, ask, avatar, colorFor, ago, bytes, COLORS, loadingHtml, AGENT_COMMIT_WORDS } from './common.js'
|
|
5
6
|
import { gitControl, gitSegHtml } from './git-rule.js'
|
|
6
7
|
import { filesSectionHtml, bindFilesSection } from './files.js'
|
|
@@ -276,6 +277,7 @@ export function workspacePageHtml () {
|
|
|
276
277
|
: `<p class="hint ws-empty">No sessions yet.${d.access.access === 'edit' ? ' Start one with New session: it opens a folder of yours here.' : ''}</p>`}
|
|
277
278
|
</section>
|
|
278
279
|
${filesSectionHtml(d)}
|
|
280
|
+
${state.secretsOn ? '<section class="sec ws-secrets" data-ws-secrets></section>' : ''}
|
|
279
281
|
</div>
|
|
280
282
|
</div>
|
|
281
283
|
</div>`
|
|
@@ -375,6 +377,9 @@ export function bindWorkspacePage (root, { go, rerender, newSessionDialog, invit
|
|
|
375
377
|
root.querySelector('[data-ws-settings]')?.addEventListener('click', () => settingsDialog(reload, go, dialog))
|
|
376
378
|
root.querySelector('[data-ws-github]')?.addEventListener('click', () => githubDialog(id, dialog))
|
|
377
379
|
bindFilesSection(root, { id, reload, go })
|
|
380
|
+
// Secrets: the workspace's, with its team's above (secrets.js); .env files come from its sessions on this computer.
|
|
381
|
+
const sec = root.querySelector('[data-ws-secrets]')
|
|
382
|
+
if (sec) mountSecrets(sec, { type: 'workspace', id, importFrom: { workspaceId: id } })
|
|
378
383
|
}
|
|
379
384
|
|
|
380
385
|
/** "1.2 GB of 5.0 GB used · 34 files", from the workspace's usage; nothing without one. */
|