heyquilt 0.0.1 → 0.3.90
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/LICENSE +85 -0
- package/README.md +630 -2
- package/RELEASES.md +733 -0
- package/assets/logo.svg +1 -0
- package/bin/quilt.js +816 -0
- package/desktop/main.js +315 -0
- package/package.json +163 -7
- package/src/account.js +563 -0
- package/src/admit-policy.js +32 -0
- package/src/agent-join.js +370 -0
- package/src/agent-task-workflow.js +176 -0
- package/src/agents/actions.js +85 -0
- package/src/agents/claude-code.js +180 -0
- package/src/agents/common.js +21 -0
- package/src/agents/cursor-transcripts.js +170 -0
- package/src/agents/cursor.js +404 -0
- package/src/agents/index.js +69 -0
- package/src/ai-vm-signin.js +98 -0
- package/src/ai-vm.js +380 -0
- package/src/api/access.js +75 -0
- package/src/api/activity.js +181 -0
- package/src/api/agent-auth.js +130 -0
- package/src/api/agent-placement.js +157 -0
- package/src/api/audit.js +83 -0
- package/src/api/auth.js +17 -0
- package/src/api/domains.js +30 -0
- package/src/api/file-paths.js +40 -0
- package/src/api/file-store.js +99 -0
- package/src/api/fly-machines.js +76 -0
- package/src/api/github-app.js +172 -0
- package/src/api/http.js +34 -0
- package/src/api/invite-email.js +53 -0
- package/src/api/issues.js +110 -0
- package/src/api/join-text.js +78 -0
- package/src/api/mailer.js +47 -0
- package/src/api/memory-store.js +1083 -0
- package/src/api/org-access.js +54 -0
- package/src/api/permissions.js +71 -0
- package/src/api/relay-cluster.js +251 -0
- package/src/api/relay-machines.js +60 -0
- package/src/api/routes/access-types.js +54 -0
- package/src/api/routes/agent-invites.js +115 -0
- package/src/api/routes/agents.js +157 -0
- package/src/api/routes/ai-vm.js +369 -0
- package/src/api/routes/github.js +380 -0
- package/src/api/routes/grants.js +70 -0
- package/src/api/routes/invites.js +188 -0
- package/src/api/routes/issues.js +47 -0
- package/src/api/routes/join.js +167 -0
- package/src/api/routes/members.js +110 -0
- package/src/api/routes/orgs.js +177 -0
- package/src/api/routes/relay-cluster.js +90 -0
- package/src/api/routes/relay.js +76 -0
- package/src/api/routes/session-invites.js +119 -0
- package/src/api/routes/sessions.js +92 -0
- package/src/api/routes/teams.js +132 -0
- package/src/api/routes/workspace-agents.js +332 -0
- package/src/api/routes/workspace-files.js +277 -0
- package/src/api/routes/workspace-invites.js +196 -0
- package/src/api/routes/workspaces.js +225 -0
- package/src/api/server.js +623 -0
- package/src/api/slugs.js +20 -0
- package/src/api/supabase-store.js +765 -0
- package/src/api/team-access.js +30 -0
- package/src/api/tokens.js +31 -0
- package/src/api/workspace-access.js +62 -0
- package/src/api/workspace-reach.js +60 -0
- package/src/autowork-tools.js +166 -0
- package/src/autowork.js +387 -0
- package/src/blobstore.js +99 -0
- package/src/boxes.js +37 -0
- package/src/branchdocs.js +235 -0
- package/src/branches.js +194 -0
- package/src/catchup.js +111 -0
- package/src/chat-archive.js +85 -0
- package/src/chat-links.js +923 -0
- package/src/clash.js +140 -0
- package/src/cloud-ai.js +50 -0
- package/src/commit-request.js +59 -0
- package/src/commit.js +213 -0
- package/src/connection.js +569 -0
- package/src/control.js +245 -0
- package/src/conversation.js +125 -0
- package/src/doctor.js +156 -0
- package/src/duties.js +303 -0
- package/src/editors.js +228 -0
- package/src/fsutil.js +87 -0
- package/src/git-hook.js +48 -0
- package/src/git.js +147 -0
- package/src/github-commit.js +320 -0
- package/src/gitignore.js +49 -0
- package/src/gitstate.js +900 -0
- package/src/history.js +307 -0
- package/src/hooks.js +371 -0
- package/src/hosted-agents.js +24 -0
- package/src/hosted-client.js +112 -0
- package/src/identity.js +99 -0
- package/src/inbox.js +237 -0
- package/src/integrations.js +221 -0
- package/src/key-cli.js +115 -0
- package/src/largefiles.js +91 -0
- package/src/legacy.js +25 -0
- package/src/mcp-proxy.js +40 -0
- package/src/mcp-room.js +32 -0
- package/src/mcp.js +1261 -0
- package/src/merge3.js +158 -0
- package/src/merges.js +148 -0
- package/src/pass-source.js +176 -0
- package/src/passes.js +64 -0
- package/src/pathrules.js +127 -0
- package/src/persona.js +101 -0
- package/src/presence.js +360 -0
- package/src/private-file.js +41 -0
- package/src/procs.js +70 -0
- package/src/project-rules.js +178 -0
- package/src/protocol.js +92 -0
- package/src/quiet-warnings.js +9 -0
- package/src/relay-ai-vm.js +82 -0
- package/src/relay-ask.js +141 -0
- package/src/relay-ci.js +111 -0
- package/src/relay-clash.js +597 -0
- package/src/relay-cluster.js +454 -0
- package/src/relay-commit.js +887 -0
- package/src/relay-filekey.js +46 -0
- package/src/relay-git.js +274 -0
- package/src/relay-githttp.js +137 -0
- package/src/relay-github.js +109 -0
- package/src/relay-lease.js +137 -0
- package/src/relay-lfs.js +37 -0
- package/src/relay-mcp.js +1679 -0
- package/src/relay-metrics.js +75 -0
- package/src/relay-outbound.js +1144 -0
- package/src/relay-sync.js +719 -0
- package/src/relay-turn-sync.js +307 -0
- package/src/relay-upstream.js +934 -0
- package/src/relay-webhooks.js +111 -0
- package/src/releases.js +131 -0
- package/src/report.js +129 -0
- package/src/runner.js +306 -0
- package/src/same-edit.js +18 -0
- package/src/server.js +4187 -0
- package/src/session-access.js +170 -0
- package/src/session-name.js +15 -0
- package/src/session.js +7361 -0
- package/src/settings.js +46 -0
- package/src/setup.js +184 -0
- package/src/shell-path.js +46 -0
- package/src/status.js +199 -0
- package/src/summarize.js +109 -0
- package/src/sync-words.js +42 -0
- package/src/task-comments.js +92 -0
- package/src/tasks.js +670 -0
- package/src/textdiff.js +20 -0
- package/src/turn-sync-plan.js +142 -0
- package/src/turn-sync.js +191 -0
- package/src/ui/access-form.js +37 -0
- package/src/ui/agent-env-key.js +76 -0
- package/src/ui/agent-guide.js +127 -0
- package/src/ui/agent-kinds.js +175 -0
- package/src/ui/agent-place.js +265 -0
- package/src/ui/app.css +2309 -0
- package/src/ui/app.js +542 -0
- package/src/ui/board.js +465 -0
- package/src/ui/boxes.js +76 -0
- package/src/ui/branches.js +111 -0
- package/src/ui/catchup.js +77 -0
- package/src/ui/changes.js +159 -0
- package/src/ui/chat.js +266 -0
- package/src/ui/cloud-ai-model.js +107 -0
- package/src/ui/cloud-ai.js +221 -0
- package/src/ui/common.js +431 -0
- package/src/ui/csv.js +21 -0
- package/src/ui/diffview.js +74 -0
- package/src/ui/feed-convs.js +39 -0
- package/src/ui/feed.js +174 -0
- package/src/ui/files.js +579 -0
- package/src/ui/fileview.js +123 -0
- package/src/ui/git-rule.js +60 -0
- package/src/ui/home.js +1323 -0
- package/src/ui/index.html +16 -0
- package/src/ui/invite.js +97 -0
- package/src/ui/invites.js +79 -0
- package/src/ui/loom-model.js +516 -0
- package/src/ui/loom.css +349 -0
- package/src/ui/loom.js +753 -0
- package/src/ui/mark.js +104 -0
- package/src/ui/merges.js +158 -0
- package/src/ui/rail-model.js +78 -0
- package/src/ui/rail.js +173 -0
- package/src/ui/releases.js +182 -0
- package/src/ui/schedule.js +156 -0
- package/src/ui/session.js +3394 -0
- package/src/ui/signin.js +75 -0
- package/src/ui/sounds.js +110 -0
- package/src/ui/tool-logo.js +86 -0
- package/src/ui/tree.js +276 -0
- package/src/ui/workspaces.js +542 -0
- package/src/ui-server.js +1272 -0
- package/src/update-check.js +67 -0
- package/src/upstream.js +155 -0
- package/src/webhooks.js +235 -0
- package/src/workspace-cli.js +62 -0
- package/src/workspace-tools.js +340 -0
- package/index.js +0 -2
package/src/ui/mark.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// The Quilt mark (brand/SPEC.md): Patch Q. A nine-patch ring of eight pieces around an open
|
|
2
|
+
// centre; the bottom-right piece is a half-square triangle, and its vermilion half is the Q's
|
|
3
|
+
// tail. The wordmark is a drawn lowercase "quilt" whose i is dotted with the same folded square.
|
|
4
|
+
// One source for every drawing: the app, the website (web/lib/mark.js is an identical copy) and
|
|
5
|
+
// the brand kit (scripts/make-brand.cjs imports the geometry below). Colours come from the page:
|
|
6
|
+
// --mark-ink for the pieces and letters, --mark-accent for the vermilion half (app.css, globals.css).
|
|
7
|
+
|
|
8
|
+
/** The 57-unit grid: patch 17, gap 3, corner radius 2. */
|
|
9
|
+
export const GRID = { size: 57, patch: 17, gap: 3, radius: 2 }
|
|
10
|
+
/** The seven square pieces, clockwise from the top-left; the corner piece comes last (piece 8). */
|
|
11
|
+
export const PIECES = [[0, 0], [20, 0], [40, 0], [40, 20], [20, 40], [0, 40], [0, 20]]
|
|
12
|
+
/** The corner piece's two halves, split by a 3-unit seam (long edges on x + y = 94.88 and 99.12). */
|
|
13
|
+
export const CORNER = {
|
|
14
|
+
ink: 'M42 40 H54.88 L40 54.88 V42 A2 2 0 0 1 42 40 Z',
|
|
15
|
+
accent: 'M57 42.12 V55 A2 2 0 0 1 55 57 H42.12 Z'
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The wordmark on its 162 x 90 box (viewBox -6 -4 162 90): 11-unit strokes with flat ends.
|
|
19
|
+
* Guide lines: ascender 2, x-height 19, baseline 61.5, descender 80.
|
|
20
|
+
*/
|
|
21
|
+
export const WORD = {
|
|
22
|
+
viewBox: '-6 -4 162 90',
|
|
23
|
+
stroke: 11,
|
|
24
|
+
bowl: { cx: 22, cy: 40.25, r: 15.75 },
|
|
25
|
+
paths: [
|
|
26
|
+
'M37.75 19 V80', // q stem
|
|
27
|
+
'M55 19 V41 A15 15 0 0 0 85 41 V19 M85 19 V61.5', // u
|
|
28
|
+
'M101 19 V61.5', // i
|
|
29
|
+
'M117 2 V61.5', // l
|
|
30
|
+
'M138 8 V46 Q138 56 148 56 H152', // t
|
|
31
|
+
'M128 24.5 H152' // t bar
|
|
32
|
+
],
|
|
33
|
+
dot: { ink: 'M95.5 2 L106.5 13 H95.5 Z', accent: 'M95.5 2 H106.5 V13 Z' }
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Lockups: where the mark and the wordmark sit. Horizontal: the mark as tall as the "l" and one
|
|
37
|
+
* patch from the q. Stacked: the mark half the wordmark's width, one patch above it.
|
|
38
|
+
*/
|
|
39
|
+
export const LOCKUP = {
|
|
40
|
+
horizontal: { viewBox: '0 0 229 82', mark: 'translate(0 2) scale(1.04386)', word: 'translate(76.25 0)' },
|
|
41
|
+
stacked: { viewBox: '0 0 152 177', mark: 'translate(38 0) scale(1.324561)', word: 'translate(-0.75 96)' }
|
|
42
|
+
}
|
|
43
|
+
/** Pixel masters for 16, 24 and 32 px: [patch, gap, corner ink if px + py <= this, vermilion if >= this]. */
|
|
44
|
+
export const PIXEL_MASTERS = { 16: [4, 2, 2, 4], 24: [6, 3, 3, 7], 32: [8, 4, 4, 10] }
|
|
45
|
+
|
|
46
|
+
const rect = ([x, y]) => `x="${x}" y="${y}" width="${GRID.patch}" height="${GRID.patch}" rx="${GRID.radius}"`
|
|
47
|
+
|
|
48
|
+
/** The mark's shapes. Pieces carry --o, their place in the piecing order, for the animations. */
|
|
49
|
+
function pieces () {
|
|
50
|
+
return PIECES.map((p, i) => `<rect class="qm-p qm-i" style="--o:${i}" ${rect(p)}/>`).join('') +
|
|
51
|
+
`<path class="qm-p qm-i qm-c" style="--o:7" d="${CORNER.ink}"/><path class="qm-p qm-a qm-c" style="--o:7" d="${CORNER.accent}"/>`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The wordmark's shapes. */
|
|
55
|
+
function letters () {
|
|
56
|
+
const { bowl } = WORD
|
|
57
|
+
return `<g class="qm-w" fill="none" stroke-width="${WORD.stroke}"><circle cx="${bowl.cx}" cy="${bowl.cy}" r="${bowl.r}"/>${WORD.paths.map((d) => `<path d="${d}"/>`).join('')}</g>` +
|
|
58
|
+
`<path class="qm-i" d="${WORD.dot.ink}"/><path class="qm-a" d="${WORD.dot.accent}"/>`
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Every pixel of a pixel master: { ink: [[x, y, w, h]], accent: [...] }, rows merged into runs.
|
|
63
|
+
* The seven square pieces sit at 0, patch + gap and twice that; the corner piece is split along
|
|
64
|
+
* its diagonal by the rule in PIXEL_MASTERS.
|
|
65
|
+
*/
|
|
66
|
+
export function pixelRects (size) {
|
|
67
|
+
const m = PIXEL_MASTERS[size]
|
|
68
|
+
if (!m) throw new Error(`no pixel master for ${size} px`)
|
|
69
|
+
const [p, g, inkMax, accentMin] = m
|
|
70
|
+
const at = [0, p + g, 2 * (p + g)]
|
|
71
|
+
const ink = PIECES.map(([x, y]) => [at[x / 20], at[y / 20], p, p])
|
|
72
|
+
const accent = []
|
|
73
|
+
const c = at[2]
|
|
74
|
+
for (let py = 0; py < p; py++) {
|
|
75
|
+
if (inkMax - py >= 0) ink.push([c, c + py, inkMax - py + 1, 1])
|
|
76
|
+
const from = accentMin - py
|
|
77
|
+
if (from <= p - 1) accent.push([c + Math.max(0, from), c + py, p - Math.max(0, from), 1])
|
|
78
|
+
}
|
|
79
|
+
return { ink, accent }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let sewn = false
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The mark as inline SVG.
|
|
86
|
+
* word: the horizontal lockup, mark and "quilt" (default); false for the mark alone
|
|
87
|
+
* sew: piece it together once (`'first'`: only the first time this is called in the page)
|
|
88
|
+
* loop: the loading animation: pieced patch by patch, clockwise, the corner last, over and over
|
|
89
|
+
* px: 16, 24 or 32 for the pixel master (crisp at that size; no animation)
|
|
90
|
+
*/
|
|
91
|
+
export function quiltMark ({ word = true, sew = false, loop = false, px = 0, cls = '' } = {}) {
|
|
92
|
+
const extra = cls ? ' ' + cls : ''
|
|
93
|
+
const label = loop ? 'Loading' : 'Quilt'
|
|
94
|
+
if (px) {
|
|
95
|
+
const { ink, accent } = pixelRects(px)
|
|
96
|
+
const r = (cl) => ([x, y, w, h]) => `<rect class="${cl}" x="${x}" y="${y}" width="${w}" height="${h}"/>`
|
|
97
|
+
return `<svg class="qm qm-sym qm-px${extra}" viewBox="0 0 ${px} ${px}" width="${px}" height="${px}" shape-rendering="crispEdges" role="img" aria-label="${label}">${ink.map(r('qm-i')).join('')}${accent.map(r('qm-a')).join('')}</svg>`
|
|
98
|
+
}
|
|
99
|
+
const doSew = sew === 'first' ? !sewn && (sewn = true) : !!sew
|
|
100
|
+
const mode = loop ? ' qm-loop' : doSew ? ' qm-sew' : ''
|
|
101
|
+
if (!word) return `<svg class="qm qm-sym${mode}${extra}" viewBox="0 0 ${GRID.size} ${GRID.size}" role="img" aria-label="${label}">${pieces()}</svg>`
|
|
102
|
+
const L = LOCKUP.horizontal
|
|
103
|
+
return `<svg class="qm qm-word${mode}${extra}" viewBox="${L.viewBox}" role="img" aria-label="${label}"><g transform="${L.mark}">${pieces()}</g><g class="qm-wm" transform="${L.word}">${letters()}</g></svg>`
|
|
104
|
+
}
|
package/src/ui/merges.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Merges: the buttons for a file changed in two places (the file tree marks them) and
|
|
2
|
+
// the side-by-side view of one.
|
|
3
|
+
import { esc, I, api, toast, loadingNote } from './common.js'
|
|
4
|
+
import { changedLines } from './fileview.js'
|
|
5
|
+
|
|
6
|
+
const you = (name, me, fallback = 'someone') => !name ? fallback : name === me ? 'you' : esc(name)
|
|
7
|
+
const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1)
|
|
8
|
+
|
|
9
|
+
/** How the opener's side came about (merges.js VIAS). */
|
|
10
|
+
export function done (m, deleted = false) {
|
|
11
|
+
if (m.via === 'pull') return deleted ? 'pulled commits that delete this' : 'pulled commits that change this'
|
|
12
|
+
if (m.via === 'hold') return deleted ? 'deleted this during a git command' : 'changed this during a git command'
|
|
13
|
+
return deleted ? 'deleted this offline' : 'changed this offline'
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** One plain sentence: who changed what, where; then who holds the file now. */
|
|
17
|
+
function describe (m, me) {
|
|
18
|
+
const held = m.heldBy && m.heldBy !== me && m.kind !== 'claimed' ? ` ${cap(esc(m.heldBy))} has it claimed now.` : ''
|
|
19
|
+
return what(m, me) + held
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function what (m, me) {
|
|
23
|
+
const who = cap(you(m.by, me))
|
|
24
|
+
const other = you(m.others[0], me)
|
|
25
|
+
const did = done(m)
|
|
26
|
+
if (m.kind === 'ai') return `${who} ${did} and ${other} changed it in the session. An AI combined the two: have a look.`
|
|
27
|
+
if (m.kind === 'claimed' && !m.heldBy) return `${who} ${did} while ${you(m.claimedBy, me)} had it claimed. The session's version is in the file.`
|
|
28
|
+
if (m.kind === 'claimed') return `${who} ${did}, but ${you(m.heldBy, me)} ${m.heldBy === me ? 'have' : 'has'} it claimed. The session's version is in the file.`
|
|
29
|
+
if (m.oursDeleted) return `${who} ${done(m, true)} and ${other} changed it in the session.`
|
|
30
|
+
if (m.theirsHash === null) return `${who} ${did}, but it was deleted in the session.`
|
|
31
|
+
return `${who} ${did} and ${other} changed it in the session${m.reason ? ` (${esc(m.reason)})` : ''}.`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const mineLabel = (m, me) => m.oursDeleted ? 'Delete it' : m.by === me ? 'Keep mine' : `Keep ${esc(m.by)}'s`
|
|
35
|
+
const theirsLabel = (m, me) => m.theirsHash === null ? 'Keep it deleted' : !m.others[0] ? "Keep the session's" : m.others[0] === me ? 'Keep mine' : `Keep ${esc(m.others[0])}'s`
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The buttons for one record. `full` (the compare view) also offers both
|
|
39
|
+
* versions on rows an AI combined. Viewers see the record but can't act.
|
|
40
|
+
*/
|
|
41
|
+
export function mergeActionsHtml (m, me, editors, { full = false, viewer = false } = {}) {
|
|
42
|
+
const id = esc(m.id)
|
|
43
|
+
const b = (how, label, cls = 'ghost') => `<button class="btn sm ${cls}" data-merge="${id}" data-how="${how}">${label}</button>`
|
|
44
|
+
if (m.state === 'done') return `<span class="tag">Settled by ${you(m.resolvedBy, me)}</span>`
|
|
45
|
+
if (viewer) return '<span class="tag">View only</span>'
|
|
46
|
+
// Markers are in the file: only "done" and "keep mine" still make sense.
|
|
47
|
+
if (m.state === 'editing') return `<span class="hint">Markers are in the file</span>${b('mine', mineLabel(m, me))}${b('agent', 'Resolved', 'primary')}`
|
|
48
|
+
if (m.kind === 'ai') return (full ? b('mine', mineLabel(m, me)) : '') + b('review', 'Looks fine', 'primary')
|
|
49
|
+
// Settling writes the file, and so would an AI sent to merge it: ask whoever holds it, or keep theirs.
|
|
50
|
+
if (m.heldBy && m.heldBy !== me) {
|
|
51
|
+
const ask = m.asked
|
|
52
|
+
? `<span class="tag">Asked ${esc(m.heldBy)} for it</span>`
|
|
53
|
+
: `<button class="btn sm" data-merge="${id}" data-ask="${esc(m.path)}" title="Join the file queue: ${esc(m.heldBy)} hands it to you when done">Ask ${esc(m.heldBy)} for it</button>`
|
|
54
|
+
return b('theirs', theirsLabel(m, me)) + ask
|
|
55
|
+
}
|
|
56
|
+
const hand = m.binary || m.oursDeleted || m.theirsHash === null ? '' : b('hand', 'Edit by hand')
|
|
57
|
+
const [first, ...rest] = editors
|
|
58
|
+
const send = first
|
|
59
|
+
? `<span class="merge-send"><button class="btn sm" data-merge="${id}" data-send="${esc(first.id)}" ${first.auto ? 'title="Your AI merges it on its own, on this computer, and says how it went in chat"' : ''}>${first.auto ? 'Have your AI merge it' : `Send to ${esc(first.name)}`}</button>${rest.length
|
|
60
|
+
? `<select class="input" data-merge-apps="${id}" aria-label="Send to another app"><option value="">Other app…</option>${rest.map((e) => `<option value="${esc(e.id)}">${e.auto ? 'Your AI, on its own' : esc(e.name)}</option>`).join('')}</select>`
|
|
61
|
+
: ''}</span>`
|
|
62
|
+
: ''
|
|
63
|
+
return b('mine', mineLabel(m, me)) + b('theirs', theirsLabel(m, me)) + hand + send
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function sentToast (r, name) {
|
|
67
|
+
if (r.app === 'auto') { toast(`Your AI is on it in ${r.tool || 'your AI tool'}. It says how it went in chat.`); return }
|
|
68
|
+
toast(r.filled ? `Opened ${name} with the merge prompt filled in: send it.` : r.copied ? `Opened ${name}. The merge prompt is on your clipboard: paste it in.` : `Opened ${name}.`)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const settledToast = { hand: 'Markers are in the file. Remove them and save to finish.', mine: 'Done', theirs: 'Done', review: 'Thanks for checking', agent: 'Done' }
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Wires the merge buttons inside `el` (the bar, or the compare view). Call once
|
|
75
|
+
* per mount: the listeners are delegated, so redraws need no rebinding.
|
|
76
|
+
* `onCompare(id)` opens the compare view; `editors()` names the apps.
|
|
77
|
+
*/
|
|
78
|
+
export function bindMerges (el, { sessionId, onCompare, editors }) {
|
|
79
|
+
const nameOf = (app) => (editors().find((e) => e.id === app) || {}).name || 'the app'
|
|
80
|
+
const send = async (id, app) => sentToast(await api('POST', `/api/sessions/${sessionId()}/merges/send`, { id, app }), nameOf(app))
|
|
81
|
+
el.addEventListener('click', async (e) => {
|
|
82
|
+
const cmp = e.target.closest('[data-compare]')
|
|
83
|
+
if (cmp) { onCompare(cmp.dataset.compare); return }
|
|
84
|
+
const btn = e.target.closest('button[data-merge]')
|
|
85
|
+
if (!btn) return
|
|
86
|
+
btn.disabled = true
|
|
87
|
+
try {
|
|
88
|
+
if (btn.dataset.send) await send(btn.dataset.merge, btn.dataset.send)
|
|
89
|
+
else if (btn.dataset.ask) {
|
|
90
|
+
const file = btn.dataset.ask
|
|
91
|
+
await api('POST', `/api/sessions/${sessionId()}/request-file`, { path: file, title: `Settle the merge of ${file}`, description: 'I changed this file outside the session while you had it. When you hand it over, I will merge my changes with yours.' })
|
|
92
|
+
toast(`Asked for ${file}. It comes to you, with their notes, when they hand it over.`)
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
await api('POST', `/api/sessions/${sessionId()}/merges/resolve`, { id: btn.dataset.merge, how: btn.dataset.how })
|
|
96
|
+
toast(settledToast[btn.dataset.how] || 'Done')
|
|
97
|
+
}
|
|
98
|
+
} catch (err) { toast(err.message) }
|
|
99
|
+
btn.disabled = false // a settled row goes away on the next status
|
|
100
|
+
})
|
|
101
|
+
el.addEventListener('change', async (e) => {
|
|
102
|
+
const sel = e.target.closest('[data-merge-apps]')
|
|
103
|
+
if (!sel || !sel.value) return
|
|
104
|
+
const app = sel.value
|
|
105
|
+
sel.value = ''
|
|
106
|
+
sel.blur() // lets the bar redraw again
|
|
107
|
+
try { await send(sel.dataset.mergeApps, app) } catch (err) { toast(err.message) }
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** One side: its lines, with the ones that differ from the base highlighted. */
|
|
112
|
+
function column (title, text, base, note) {
|
|
113
|
+
let body
|
|
114
|
+
if (text == null) body = `<div class="fv-note">${note}</div>`
|
|
115
|
+
else {
|
|
116
|
+
const lines = text.split('\n')
|
|
117
|
+
if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop()
|
|
118
|
+
const changed = changedLines(base == null ? [] : base.split('\n'), lines)
|
|
119
|
+
body = `<table class="fv-code"><tbody>${lines.map((l, i) =>
|
|
120
|
+
`<tr${changed.has(i) ? ' class="mchg"' : ''}><td class="ln">${i + 1}</td><td class="lt">${esc(l) || ' '}</td></tr>`).join('')}</tbody></table>`
|
|
121
|
+
}
|
|
122
|
+
return `<div class="merge-col"><div class="merge-col-head">${title}</div><div class="fv-scroll">${body}</div></div>`
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A short fingerprint of a text (FNV-1a and length), so a redraw check needn't keep or compare whole files. */
|
|
126
|
+
function textSig (t) {
|
|
127
|
+
if (t == null) return t === undefined ? 'u' : 'n'
|
|
128
|
+
let h = 0x811c9dc5
|
|
129
|
+
for (let i = 0; i < t.length; i++) h = Math.imul(h ^ t.charCodeAt(i), 0x01000193)
|
|
130
|
+
return `${t.length}:${(h >>> 0).toString(36)}`
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The compare view. `m`: the record without its texts, as status has it.
|
|
135
|
+
* `texts`: { ours, base } from the full record, `undefined` while it loads.
|
|
136
|
+
* `theirs`: the session's text now, `null` when it isn't there, `undefined` while it loads.
|
|
137
|
+
*/
|
|
138
|
+
export function renderMergeView (el, { merge: m, texts, theirs, me, editors, viewer = false }) {
|
|
139
|
+
const showing = el.querySelector(':scope > .merge-view')
|
|
140
|
+
// Don't redraw under someone picking an app.
|
|
141
|
+
if (showing && el.contains(document.activeElement) && document.activeElement.tagName === 'SELECT') return
|
|
142
|
+
const sig = JSON.stringify([m, textSig(texts?.ours), textSig(texts?.base), texts === undefined, textSig(theirs), me, editors.map((e) => e.id), viewer])
|
|
143
|
+
if (showing && el._mergeSig === sig) return
|
|
144
|
+
el._mergeSig = sig
|
|
145
|
+
if (!m) { el.innerHTML = '<div class="main-empty merge-view"><p class="hint">That merge is settled or gone.</p></div>'; return }
|
|
146
|
+
const ours = texts ? texts.ours : null
|
|
147
|
+
const base = texts ? texts.base : null
|
|
148
|
+
const mine = m.by === me ? 'Yours, from offline' : `${esc(m.by)}'s, from offline`
|
|
149
|
+
const sess = m.kind === 'ai' ? 'In the session now, combined by AI' : `In the session${m.others[0] ? `, from ${you(m.others[0], me)}` : ''}`
|
|
150
|
+
const oursNote = m.oursDeleted ? 'Deleted offline' : texts === undefined ? loadingNote() : m.local ? `Too big to show here. It's in the merge folder on ${m.by === me ? 'this computer' : `${esc(m.by)}'s computer`}.` : 'Not available'
|
|
151
|
+
const theirsNote = theirs === undefined ? loadingNote() : m.theirsHash === null ? 'Deleted in the session' : 'Not in the session any more'
|
|
152
|
+
el.innerHTML = `<div class="fv merge-view">
|
|
153
|
+
<div class="fv-banner"><span class="fv-path mono">${esc(m.path)}</span><span class="fv-edited">${describe(m, me)}</span><span class="spacer"></span>${mergeActionsHtml(m, me, editors, { full: true, viewer })}</div>
|
|
154
|
+
${m.binary
|
|
155
|
+
? `<div class="fv-note">${I.file} Binary file: pick a version above.</div>`
|
|
156
|
+
: `<div class="merge-cols">${column(mine, ours, base, oursNote)}${column(sess, theirs, base, theirsNote)}</div>`}
|
|
157
|
+
</div>`
|
|
158
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// The Files rail (rail.js): the folded Files pane as signals instead of names. Who holds
|
|
2
|
+
// what, since when, why, who waits for it and when it frees up on its own; files to merge.
|
|
3
|
+
// Pure, so tests can run it without a page.
|
|
4
|
+
import { stitchTime } from './loom-model.js'
|
|
5
|
+
|
|
6
|
+
/** A claim whose holder does nothing in the session this long is let go of (the relay's claimIdleMs). */
|
|
7
|
+
export const CLAIM_IDLE_MS = 20 * 60 * 1000
|
|
8
|
+
|
|
9
|
+
const isFolder = (pattern) => /\/(\*\*)?$/.test(String(pattern))
|
|
10
|
+
const claimFolder = (pattern) => String(pattern).replace(/\/\*\*$/, '').replace(/\/+$/, '') // as tree.js
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Everything the rail and its lock list show. `claims`: the session's ({ pattern, by, ts, note,
|
|
14
|
+
* active, activeAt, queue }); `merges`: files to merge ({ id, path, state }); `me`: your name;
|
|
15
|
+
* `owners`: AI session name -> its person (chat.js aiOwners), so a person's AI sessions are one
|
|
16
|
+
* holder, "<person>'s AI"; `aiName`: person -> that name; `files`: the session's paths (to count a
|
|
17
|
+
* folder's); `open`: paths you have open (a merge there is urgent); `focusOf`: name -> what they
|
|
18
|
+
* said they're doing (a lock's why when it has no note); `kindOf`: name -> 'agent' for an AI agent.
|
|
19
|
+
* Returns { locks, holders, waiting, mine, merges: { count, urgent } }, locks oldest first.
|
|
20
|
+
*/
|
|
21
|
+
export function railModel ({ claims = [], merges = [], me = '', owners = new Map(), aiName = (p) => `${p}'s AI`, files = [], open = [], focusOf = () => '', kindOf = () => '', now = Date.now(), idleMs = CLAIM_IDLE_MS } = {}) {
|
|
22
|
+
const fileSet = new Set(files)
|
|
23
|
+
const holderOf = (by) => owners.has(by) ? aiName(owners.get(by)) : by
|
|
24
|
+
const locks = (claims || []).filter((c) => c && c.pattern && c.by).map((c) => {
|
|
25
|
+
const bare = claimFolder(c.pattern)
|
|
26
|
+
// "src/auth", "src/auth/" and "src/auth/**" all claim the folder (tree.js claimFolder).
|
|
27
|
+
const folder = isFolder(c.pattern) || (!fileSet.has(bare) && files.some((f) => f.startsWith(`${bare}/`)))
|
|
28
|
+
const path = folder ? bare : String(c.pattern)
|
|
29
|
+
const queue = Array.isArray(c.queue) ? c.queue : []
|
|
30
|
+
const last = typeof c.activeAt === 'number' ? c.activeAt : c.ts || now
|
|
31
|
+
return {
|
|
32
|
+
pattern: c.pattern,
|
|
33
|
+
path,
|
|
34
|
+
folder,
|
|
35
|
+
count: folder ? files.filter((f) => f.startsWith(`${path}/`)).length : 1,
|
|
36
|
+
by: c.by,
|
|
37
|
+
holder: holderOf(c.by),
|
|
38
|
+
mine: c.by === me,
|
|
39
|
+
ours: c.by === me || owners.get(c.by) === me, // yours or one of your AI sessions'
|
|
40
|
+
away: c.active === false,
|
|
41
|
+
since: c.ts || now,
|
|
42
|
+
why: c.note || focusOf(c.by) || '',
|
|
43
|
+
queue: queue.map((r) => ({ by: holderOf(r.by), title: r.title || '' })),
|
|
44
|
+
freesAt: last + idleMs
|
|
45
|
+
}
|
|
46
|
+
}).sort((a, b) => a.since - b.since)
|
|
47
|
+
|
|
48
|
+
const holders = []
|
|
49
|
+
for (const l of locks) {
|
|
50
|
+
let h = holders.find((x) => x.name === l.holder)
|
|
51
|
+
if (!h) holders.push(h = { name: l.holder, by: l.by, ours: l.ours, ai: owners.has(l.by) || kindOf(l.by) === 'agent', locks: 0, waiting: 0 })
|
|
52
|
+
h.locks++
|
|
53
|
+
h.waiting += l.queue.length
|
|
54
|
+
}
|
|
55
|
+
const openSet = new Set(open)
|
|
56
|
+
const toMerge = (merges || []).filter((m) => m && m.state !== 'done')
|
|
57
|
+
return {
|
|
58
|
+
locks,
|
|
59
|
+
holders,
|
|
60
|
+
waiting: locks.filter((l) => l.mine && l.queue.length), // someone waits for a file you hold: yours to act on
|
|
61
|
+
mine: locks.filter((l) => l.ours).length,
|
|
62
|
+
merges: { count: toMerge.length, urgent: toMerge.some((m) => openSet.has(m.path)) }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** "25m", how long a lock has been held. */
|
|
67
|
+
export const heldFor = (l, now = Date.now()) => stitchTime(l.since, now)
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* When a lock frees up if its holder stays idle: "frees in 12m if idle", or, with someone
|
|
71
|
+
* waiting, "goes to Sriram in 12m if idle"; "frees any moment" once that time has come.
|
|
72
|
+
*/
|
|
73
|
+
export function freesText (l, now = Date.now()) {
|
|
74
|
+
const next = l.queue[0]
|
|
75
|
+
const left = l.freesAt - now
|
|
76
|
+
if (left <= 30 * 1000) return next ? `goes to ${next.by} any moment` : 'frees any moment'
|
|
77
|
+
return `${next ? `goes to ${next.by}` : 'frees'} in ${stitchTime(now - left, now)} if idle`
|
|
78
|
+
}
|
package/src/ui/rail.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// The Files rail: the folded Files pane (session.js) as signals instead of names. An hourglass
|
|
2
|
+
// when someone waits for a file you hold (it pulses: it's yours to act on), the lock with how
|
|
3
|
+
// many files are locked, then one avatar per holder with their count. Each opens the lock list
|
|
4
|
+
// beside it (rail-model.js): who holds what, for how long, why, who waits for it, and when it
|
|
5
|
+
// frees up on its own. The Loom's "holds" opens the same list for one lane.
|
|
6
|
+
import { esc, I, avatar, basename } from './common.js'
|
|
7
|
+
import { heldFor, freesText, CLAIM_IDLE_MS } from './rail-model.js'
|
|
8
|
+
|
|
9
|
+
export const HOURGLASS = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2h12M6 22h12"/><path d="M7 2v4a5 5 0 0 0 10 0V2"/><path d="M7 22v-4a5 5 0 0 1 10 0v4"/></svg>'
|
|
10
|
+
const AVATARS = 5 // holders shown on the rail; past that, a "+N" that opens everyone's
|
|
11
|
+
|
|
12
|
+
let pop = null // the lock list, once made
|
|
13
|
+
let open = null // { filter, anchor, pinned }: what the open list shows and where
|
|
14
|
+
let last = null // { model, opts }: the latest model, so an open list follows it
|
|
15
|
+
let hoverTimer = 0
|
|
16
|
+
let leaveTimer = 0
|
|
17
|
+
|
|
18
|
+
const who = (name, me) => name === me ? 'You' : name
|
|
19
|
+
const whose = (name, me) => name === me ? 'Your locks' : `${name}'s locks`
|
|
20
|
+
|
|
21
|
+
/** The rail's buttons for model `m` (railModel). */
|
|
22
|
+
export function railHtml (m, { me, colorOf }) {
|
|
23
|
+
const out = []
|
|
24
|
+
if (m.waiting.length) {
|
|
25
|
+
const n = m.waiting.reduce((s, l) => s + l.queue.length, 0)
|
|
26
|
+
const what = `${n} waiting for ${m.waiting.length === 1 ? `${basename(m.waiting[0].path)}, which you hold` : `${m.waiting.length} files you hold`}`
|
|
27
|
+
out.push(`<button type="button" class="rail-sig waiting" data-rail="waiting" title="${esc(what)}" aria-label="${esc(what)}">${HOURGLASS}<span class="rail-n">${n}</span></button>`)
|
|
28
|
+
}
|
|
29
|
+
const total = m.locks.length
|
|
30
|
+
const lockWhat = total ? `${total} locked${m.mine ? `, ${m.mine} by you or your AI` : ''}. Who holds what, and for how long` : 'Nothing is locked'
|
|
31
|
+
out.push(`<button type="button" class="rail-sig lock${m.mine ? ' mine' : ''}${total ? '' : ' none'}" data-rail="all" title="${esc(lockWhat)}" aria-label="${esc(lockWhat)}">${I.lock}${total ? `<span class="rail-n">${total}</span>` : ''}</button>`)
|
|
32
|
+
if (m.holders.length) {
|
|
33
|
+
out.push('<span class="rail-sep" aria-hidden="true"></span>')
|
|
34
|
+
const shown = m.holders.length > AVATARS + 1 ? m.holders.slice(0, AVATARS) : m.holders
|
|
35
|
+
for (const h of shown) {
|
|
36
|
+
const what = `${who(h.name, me)}: ${h.locks} locked${h.waiting ? `, ${h.waiting} waiting` : ''}`
|
|
37
|
+
out.push(`<button type="button" class="rail-who${h.ai ? ' ai' : ''}" data-rail="who" data-name="${esc(h.name)}" title="${esc(what)}" aria-label="${esc(what)}">${avatar(h.name, colorOf(h.by))}<span class="rail-n">${h.locks}</span>${h.waiting ? '<span class="rail-q" aria-hidden="true"></span>' : ''}</button>`)
|
|
38
|
+
}
|
|
39
|
+
if (shown.length < m.holders.length) out.push(`<button type="button" class="rail-more" data-rail="all" title="Everyone's locks">+${m.holders.length - shown.length}</button>`)
|
|
40
|
+
}
|
|
41
|
+
return out.join('')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The lock list for `filter`: 'all', 'waiting', or { who: holder }. */
|
|
45
|
+
export function locksHtml (m, filter, { me, colorOf, now = Date.now() }) {
|
|
46
|
+
const byWho = filter && typeof filter === 'object' ? filter.who : null
|
|
47
|
+
const rows = filter === 'waiting' ? m.waiting : byWho ? m.locks.filter((l) => l.holder === byWho) : m.locks
|
|
48
|
+
const title = filter === 'waiting' ? 'Waiting on you' : byWho ? whose(byWho, me) : 'Locked files'
|
|
49
|
+
const row = (l) => {
|
|
50
|
+
const dir = l.folder ? '' : l.path.includes('/') ? l.path.slice(0, l.path.lastIndexOf('/') + 1) : ''
|
|
51
|
+
const name = l.folder ? `${l.path}/` : basename(l.path)
|
|
52
|
+
const queue = l.queue.length
|
|
53
|
+
? `<div class="lk-q">${HOURGLASS}<span>${l.queue.length} waiting: ${esc(l.queue.map((r) => who(r.by, me)).join(', '))} · ${esc(freesText(l, now))}</span></div>`
|
|
54
|
+
: `<div class="lk-free">${esc(freesText(l, now))}</div>`
|
|
55
|
+
return `<li class="lk-row${l.ours ? ' ours' : ''}">
|
|
56
|
+
<button type="button" class="lk-main"${l.folder ? '' : ` data-lk-file="${esc(l.path)}"`} title="${esc(l.folder ? `${l.path}/ and everything in it` : `Open ${l.path}`)}"${l.folder ? ' disabled' : ''}>
|
|
57
|
+
<span class="lk-dot" style="--c:${esc(colorOf(l.by))}"></span>
|
|
58
|
+
<span class="lk-name">${esc(name)}${l.folder ? `<span class="lk-count">${l.count} file${l.count === 1 ? '' : 's'}</span>` : ''}</span>
|
|
59
|
+
<span class="lk-held" title="Held since ${esc(new Date(l.since).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }))}">${esc(heldFor(l, now))}</span>
|
|
60
|
+
</button>
|
|
61
|
+
${dir ? `<div class="lk-dir">${esc(dir)}</div>` : ''}
|
|
62
|
+
<div class="lk-who"><b>${esc(who(l.holder, me))}</b>${l.holder !== l.by ? ` <i>${esc(l.by)}</i>` : ''}${l.away ? ' <i>(away)</i>' : ''}${l.why ? ` · ${esc(l.why)}` : ''}</div>
|
|
63
|
+
${queue}
|
|
64
|
+
</li>`
|
|
65
|
+
}
|
|
66
|
+
return `<div class="lk-head"><span>${esc(title)}</span><span class="lk-total">${rows.length}</span></div>
|
|
67
|
+
${rows.length ? `<ul class="lk-list">${rows.map(row).join('')}</ul>` : `<p class="lk-none">${filter === 'waiting' ? 'Nobody is waiting for your files.' : byWho ? 'Nothing locked.' : 'Nothing is locked right now.'}</p>`}
|
|
68
|
+
<div class="lk-foot">A lock is let go after ${Math.round(CLAIM_IDLE_MS / 60000)} minutes with no activity from its holder.</div>`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Draws the rail into `el` and keeps an open lock list up to date. `opts`: { me, colorOf, onFile }.
|
|
73
|
+
*/
|
|
74
|
+
export function renderRail (el, model, opts) {
|
|
75
|
+
last = { model, opts }
|
|
76
|
+
const html = railHtml(model, opts)
|
|
77
|
+
if (el.dataset.html !== html) { el.innerHTML = html; el.dataset.html = html }
|
|
78
|
+
if (!el.dataset.bound) bindRail(el)
|
|
79
|
+
if (open && pop && !pop.hidden) {
|
|
80
|
+
if (!open.anchor.isConnected) {
|
|
81
|
+
// The rail redrew: keep the list on the button that opened it.
|
|
82
|
+
const again = el.querySelector(open.filter && typeof open.filter === 'object' ? `[data-rail="who"][data-name="${CSS.escape(open.filter.who)}"]` : `[data-rail="${open.filter}"]`)
|
|
83
|
+
if (again) open.anchor = again
|
|
84
|
+
}
|
|
85
|
+
fill()
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function bindRail (el) {
|
|
90
|
+
el.dataset.bound = '1'
|
|
91
|
+
const filterOf = (b) => b.dataset.rail === 'who' ? { who: b.dataset.name } : b.dataset.rail
|
|
92
|
+
el.addEventListener('click', (e) => {
|
|
93
|
+
const b = e.target.closest('[data-rail]')
|
|
94
|
+
if (!b) return
|
|
95
|
+
const filter = filterOf(b)
|
|
96
|
+
if (open && open.pinned && same(open.filter, filter)) { closeLocks(); return }
|
|
97
|
+
openLocks(b, filter, { pinned: true })
|
|
98
|
+
})
|
|
99
|
+
el.addEventListener('pointerover', (e) => {
|
|
100
|
+
const b = e.target.closest('[data-rail]')
|
|
101
|
+
if (!b || e.pointerType === 'touch') return
|
|
102
|
+
clearTimeout(leaveTimer)
|
|
103
|
+
if (open && open.pinned) return
|
|
104
|
+
clearTimeout(hoverTimer)
|
|
105
|
+
hoverTimer = setTimeout(() => openLocks(b, filterOf(b), { pinned: false }), open ? 0 : 250)
|
|
106
|
+
})
|
|
107
|
+
el.addEventListener('pointerleave', () => { clearTimeout(hoverTimer); leaveSoon() })
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const same = (a, b) => (a && typeof a === 'object' && b && typeof b === 'object') ? a.who === b.who : a === b
|
|
111
|
+
|
|
112
|
+
function leaveSoon () {
|
|
113
|
+
if (!open || open.pinned) return
|
|
114
|
+
clearTimeout(leaveTimer)
|
|
115
|
+
leaveTimer = setTimeout(closeLocks, 300)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Opens the lock list beside `anchor`. `pinned`: opened by a click, so it stays until a click elsewhere or Escape. */
|
|
119
|
+
export function openLocks (anchor, filter, { pinned = true } = {}) {
|
|
120
|
+
if (!last) return
|
|
121
|
+
if (!pop) {
|
|
122
|
+
pop = document.createElement('div')
|
|
123
|
+
pop.className = 'lk-pop'
|
|
124
|
+
pop.id = 'lock-list'
|
|
125
|
+
pop.setAttribute('role', 'dialog')
|
|
126
|
+
pop.setAttribute('aria-label', 'Locked files')
|
|
127
|
+
pop.hidden = true
|
|
128
|
+
document.body.appendChild(pop)
|
|
129
|
+
pop.addEventListener('click', (e) => {
|
|
130
|
+
const f = e.target.closest('[data-lk-file]')
|
|
131
|
+
if (f) { closeLocks(); last.opts.onFile(f.dataset.lkFile) }
|
|
132
|
+
})
|
|
133
|
+
pop.addEventListener('pointerenter', () => clearTimeout(leaveTimer))
|
|
134
|
+
pop.addEventListener('pointerleave', leaveSoon)
|
|
135
|
+
document.addEventListener('pointerdown', (e) => {
|
|
136
|
+
if (!open || pop.hidden || pop.contains(e.target) || open.anchor.contains(e.target)) return
|
|
137
|
+
if (e.target.closest?.('[data-rail], [data-loom-holds]')) return // their own click decides
|
|
138
|
+
closeLocks()
|
|
139
|
+
}, true)
|
|
140
|
+
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && open && !pop.hidden) { const a = open.anchor; closeLocks(); a.focus?.() } })
|
|
141
|
+
}
|
|
142
|
+
clearTimeout(leaveTimer)
|
|
143
|
+
const fresh = pop.hidden
|
|
144
|
+
open = { filter, anchor, pinned: pinned || !!(open && open.pinned && !fresh) }
|
|
145
|
+
pop.hidden = false
|
|
146
|
+
fill()
|
|
147
|
+
if (fresh) { pop.classList.remove('in'); void pop.offsetWidth; pop.classList.add('in') }
|
|
148
|
+
for (const b of document.querySelectorAll('[aria-controls="lock-list"]')) b.setAttribute('aria-expanded', 'false')
|
|
149
|
+
anchor.setAttribute('aria-controls', 'lock-list')
|
|
150
|
+
anchor.setAttribute('aria-expanded', 'true')
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function closeLocks () {
|
|
154
|
+
clearTimeout(hoverTimer)
|
|
155
|
+
clearTimeout(leaveTimer)
|
|
156
|
+
if (open) open.anchor.setAttribute?.('aria-expanded', 'false')
|
|
157
|
+
open = null
|
|
158
|
+
if (pop) pop.hidden = true
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function fill () {
|
|
162
|
+
const { model, opts } = last
|
|
163
|
+
pop.innerHTML = locksHtml(model, open.filter, opts)
|
|
164
|
+
// Beside its button: to the right (the rail, a lane head), else below.
|
|
165
|
+
const r = open.anchor.getBoundingClientRect()
|
|
166
|
+
const w = pop.offsetWidth
|
|
167
|
+
const h = pop.offsetHeight
|
|
168
|
+
const right = r.right + 8 + w <= window.innerWidth - 8
|
|
169
|
+
const left = right ? r.right + 8 : Math.max(8, Math.min(r.left, window.innerWidth - w - 8))
|
|
170
|
+
const top = right ? r.top - 6 : r.bottom + 6
|
|
171
|
+
pop.style.left = `${left}px`
|
|
172
|
+
pop.style.top = `${Math.max(8, Math.min(top, window.innerHeight - h - 8))}px`
|
|
173
|
+
}
|