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,579 @@
1
+ // A workspace's file library: the Files section on the workspace page, the All files view
2
+ // (folders, tiles or a list, a preview panel, rename, move, delete, versions), uploads through
3
+ // the app's local server with progress, and a picker other screens use to choose a file.
4
+ // Bytes always come from the local server (/api/workspaces/<id>/files/<fid>/data), so
5
+ // thumbnails and previews stay same-origin under the page's CSP.
6
+ import { I, state, esc, toast, api, ask, ago, bytes, TOKEN, loadingNote } from './common.js'
7
+ import { parseCsv } from './csv.js'
8
+
9
+ const wsUrl = (id, rest = '') => `/api/workspaces/${encodeURIComponent(id)}${rest}`
10
+ const fileUrl = (id, fid, rest = '') => wsUrl(id, '/files/' + encodeURIComponent(fid) + rest)
11
+ /** The file's bytes for <img>, <video>, <audio>, <iframe> and links: the token rides in the query. */
12
+ const dataUrl = (id, f, extra = '') => fileUrl(id, f.id, '/data?t=' + encodeURIComponent(TOKEN || '')) + `&v=${encodeURIComponent(f.version || 1)}${extra}`
13
+ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`
14
+ const MAX_TEXT_PREVIEW = 2 * 1024 * 1024
15
+ const CSV_ROWS = 200
16
+ const NEWEST = 8
17
+ const MAX_UPLOAD = 500 * 1024 * 1024 // the local server's cap too
18
+ const TOO_BIG = 'File is too large (500 MB at most).'
19
+ const NO_SLASH = 'A name cannot contain a slash. Use Move to change the folder.'
20
+ const viewWorkspace = () => String(state.view).replace(/^(ws|wsfiles):/, '')
21
+
22
+ // ----------------------------------------------------------------- types --
23
+ const EXT_MIME = {
24
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', avif: 'image/avif',
25
+ mp4: 'video/mp4', m4v: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
26
+ mp3: 'audio/mpeg', wav: 'audio/wav', m4a: 'audio/mp4', ogg: 'audio/ogg', flac: 'audio/flac',
27
+ pdf: 'application/pdf', csv: 'text/csv', md: 'text/markdown', markdown: 'text/markdown', txt: 'text/plain', json: 'application/json'
28
+ }
29
+ const TEXT_EXT = new Set(['txt', 'md', 'markdown', 'json', 'yml', 'yaml', 'toml', 'xml', 'html', 'css', 'js', 'mjs', 'ts', 'tsx', 'jsx', 'py', 'rb', 'go', 'rs', 'sh', 'sql', 'log', 'ini', 'env', 'srt', 'vtt'])
30
+ const TEXT_MIME = new Set(['application/json', 'application/xml', 'application/javascript', 'application/x-yaml', 'application/yaml', 'application/toml', 'application/sql'])
31
+ const extOf = (name) => { const m = /\.([A-Za-z0-9]{1,8})$/.exec(String(name)); return m ? m[1].toLowerCase() : '' }
32
+ const mimeOf = (f) => String(f.mime || EXT_MIME[extOf(f.name)] || '').split(';')[0].toLowerCase()
33
+
34
+ /** What the preview panel does with a file: image, video, audio, pdf, csv, text or none. */
35
+ function previewKind (f) {
36
+ const m = mimeOf(f)
37
+ if (m.startsWith('image/')) return 'image'
38
+ if (m.startsWith('video/')) return 'video'
39
+ if (m.startsWith('audio/')) return 'audio'
40
+ if (m === 'application/pdf') return 'pdf'
41
+ if (m === 'text/csv') return 'csv'
42
+ if (m === 'text/markdown' || m.startsWith('text/') || TEXT_MIME.has(m) || TEXT_EXT.has(extOf(f.name))) return 'text'
43
+ return 'none'
44
+ }
45
+
46
+ const BADGE = { image: '#c8361a', video: '#7b4f9e', audio: '#2e7a80', pdf: '#b3372e', csv: '#a8701c', sheet: '#3d7a4c', text: '#6e6c7e', none: '#6e6c7e' }
47
+ const SHEETS = new Set(['xlsx', 'xls', 'numbers', 'ods'])
48
+ const badgeText = (f) => (extOf(f.name) || 'file').slice(0, 4).toUpperCase()
49
+ const badgeColor = (f) => (SHEETS.has(extOf(f.name)) ? BADGE.sheet : BADGE[previewKind(f)])
50
+
51
+ // ------------------------------------------------------------------ data --
52
+ const filesOf = (d) => (d.files || []).filter((f) => f.kind !== 'folder')
53
+ const foldersOf = (d) => (d.files || []).filter((f) => f.kind === 'folder')
54
+ const newestFirst = (a, b) => (b.uploadedAt || 0) - (a.uploadedAt || 0)
55
+ const byName = (a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' })
56
+ const canEdit = (d) => d.access?.access === 'edit'
57
+ const inside = (folder, path) => folder === '' || path.startsWith(folder + '/')
58
+
59
+ /** Who uploaded: a member's name when the workspace knows them, else the account's kind. */
60
+ function whoOf (d, account) {
61
+ if (!account) return ''
62
+ const people = [d.owner, ...(d.members || [])].filter(Boolean)
63
+ const m = people.find((p) => p.account === account)
64
+ if (m?.name) return m.name
65
+ if (state.account && account === `person:${state.account.id}`) return state.account.name || 'You'
66
+ return String(account).startsWith('agent:') ? 'agent' : 'person'
67
+ }
68
+
69
+ function folderStats (d, path) {
70
+ const list = filesOf(d).filter((f) => f.path.startsWith(path + '/'))
71
+ return { count: list.length, size: list.reduce((n, f) => n + (f.size || 0), 0) }
72
+ }
73
+
74
+ function usageText (d) {
75
+ const u = d.usage
76
+ if (!u) return ''
77
+ return Number.isFinite(u.quotaBytes) ? `${bytes(u.usedBytes || 0)} of ${bytes(u.quotaBytes)}` : `${bytes(u.usedBytes || 0)} used`
78
+ }
79
+
80
+ /** "5m ago", "3h ago", "now", or a date. */
81
+ const when = (ts) => { if (!ts) return ''; const a = ago(ts); return /^\d+[mh]$/.test(a) ? `${a} ago` : a }
82
+
83
+ // ----------------------------------------------------------- upload state --
84
+ // One upload at a time per page, kept outside the DOM so a re-render (the 20 s poll) still
85
+ // shows its progress.
86
+ let progress = null // { id, n, m, pct }
87
+ const progressLabel = () => `Uploading ${progress.n} of ${progress.m} · ${progress.pct}%`
88
+ const uploadingTo = (id) => !!progress && progress.id === id
89
+
90
+ function paintProgress () {
91
+ document.querySelectorAll('[data-progress]').forEach((el) => { el.textContent = progress ? progressLabel() : (el.dataset.idle || '') })
92
+ document.querySelectorAll('[data-progress-bar]').forEach((el) => { el.style.width = `${progress ? progress.pct : 0}%` })
93
+ }
94
+
95
+ /** `reload` redraws the screen the upload started on; `go` redraws whichever screen of the
96
+ * same workspace you moved to meanwhile. Elsewhere, nothing: the 20 s poll catches up. */
97
+ async function startUpload (id, list, folder, { reload, go }) {
98
+ const files = [...list]
99
+ if (!files.length) return
100
+ if (progress) return toast('Wait for the upload in progress to finish.')
101
+ const view = state.view
102
+ progress = { id, n: 1, m: files.length, pct: 0 }
103
+ paintProgress()
104
+ document.querySelectorAll('[data-upload-tile]').forEach((t) => { t.dataset.busy = '' })
105
+ try {
106
+ await uploadFiles(id, files, {
107
+ folder,
108
+ onProgress: (file, loaded, total) => {
109
+ progress = { id, n: files.indexOf(file) + 1, m: files.length, pct: total ? Math.min(100, Math.round((loaded / total) * 100)) : 0 }
110
+ paintProgress()
111
+ }
112
+ })
113
+ } finally {
114
+ progress = null
115
+ paintProgress()
116
+ document.querySelectorAll('[data-upload-tile]').forEach((t) => { delete t.dataset.busy })
117
+ if (state.view === view) await reload()
118
+ else if (viewWorkspace() === id) await go(state.view)
119
+ }
120
+ }
121
+
122
+ /** One PUT per file to the local server, which streams it on to the workspace's storage. */
123
+ export async function uploadFiles (id, files, { folder = '', onProgress = () => {} } = {}) {
124
+ for (const file of files) {
125
+ try {
126
+ if (file.size > MAX_UPLOAD) throw new Error(TOO_BIG)
127
+ await new Promise((resolve, reject) => {
128
+ const xhr = new XMLHttpRequest()
129
+ xhr.open('PUT', wsUrl(id, '/upload'))
130
+ xhr.setRequestHeader('x-quilt-token', TOKEN || '')
131
+ xhr.setRequestHeader('x-path', encodeURIComponent(folder ? folder + '/' + file.name : file.name))
132
+ xhr.setRequestHeader('content-type', file.type || 'application/octet-stream')
133
+ xhr.upload.onprogress = (e) => onProgress(file, e.loaded, e.lengthComputable ? e.total : file.size)
134
+ xhr.onload = () => {
135
+ let body = {}
136
+ try { body = JSON.parse(xhr.responseText || '{}') } catch {}
137
+ if (xhr.status >= 200 && xhr.status < 300) { onProgress(file, file.size, file.size); return resolve(body) }
138
+ if (xhr.status === 401 && body.signedOut) window.dispatchEvent(new Event('quilt-signed-out'))
139
+ reject(new Error(body.error || `Upload failed (${xhr.status})`))
140
+ }
141
+ xhr.onerror = () => reject(new Error('The upload did not reach Quilt.'))
142
+ xhr.send(file)
143
+ })
144
+ } catch (err) {
145
+ toast(`${file.name}: ${err.message}`)
146
+ }
147
+ }
148
+ }
149
+
150
+ // ----------------------------------------------------------------- tiles --
151
+ /** A folder's Rename and Delete buttons (All files, for editors). */
152
+ const folderActsHtml = (f) => `<span class="folder-acts"><button type="button" class="btn sm ghost icon" data-folder-rename="${esc(f.id)}" title="Rename ${esc(f.name)}" aria-label="Rename ${esc(f.name)}">${I.pencil}</button><button type="button" class="btn sm ghost icon danger" data-folder-delete="${esc(f.id)}" title="Delete ${esc(f.name)}" aria-label="Delete ${esc(f.name)}">${I.trash}</button></span>`
153
+
154
+ function folderTileHtml (d, f, { acts = false } = {}) {
155
+ const s = folderStats(d, f.path)
156
+ const tile = `
157
+ <button type="button" class="tile folder" data-folder="${esc(f.path)}" title="${esc(f.path)}">
158
+ <span class="thumb">${I.folder}</span>
159
+ <span class="nm">${esc(f.name)}</span><span class="mu">${esc(plural(s.count, 'file', 'files'))}${s.count ? ` · ${esc(bytes(s.size))}` : ''}</span>
160
+ </button>`
161
+ return acts ? `<div class="tile-wrap">${tile}${folderActsHtml(f)}</div>` : tile
162
+ }
163
+
164
+ function thumbHtml (d, f) {
165
+ const kind = previewKind(f)
166
+ const inner = kind === 'image'
167
+ ? `<img loading="lazy" alt="" src="${esc(dataUrl(d.workspace.id, f))}">`
168
+ : kind === 'video' ? `<span class="play">${I.play}</span>` : `<span class="stem">${esc(f.name.replace(/\.[^.]+$/, '').slice(0, 14))}</span>`
169
+ return `<span class="thumb${kind === 'image' ? ' img' : kind === 'video' ? ' vid' : ''}">${inner}<span class="badge" style="background:${badgeColor(f)}">${esc(badgeText(f))}</span>${f.version > 1 ? `<span class="ver">v${esc(f.version)}</span>` : ''}</span>`
170
+ }
171
+
172
+ function fileTileHtml (d, f, { meta, on = false }) {
173
+ return `
174
+ <button type="button" class="tile${on ? ' on' : ''}" data-pick-file="${esc(f.id)}" title="${esc(f.path)}"${canEdit(d) ? ' draggable="true"' : ''}>
175
+ ${thumbHtml(d, f)}
176
+ <span class="nm">${esc(f.name)}</span><span class="mu">${esc(meta)}</span>
177
+ </button>`
178
+ }
179
+
180
+ function uploadTileHtml (id, label) {
181
+ const busy = uploadingTo(id)
182
+ return `
183
+ <button type="button" class="tile add" data-upload-tile${busy ? ' data-busy' : ''}>
184
+ <span class="up">${I.upload}</span><span class="lbl" data-progress data-idle="${esc(label)}">${esc(busy ? progressLabel() : label)}</span>
185
+ <span class="bar"><span data-progress-bar style="width:${busy ? progress.pct : 0}%"></span></span>
186
+ </button>
187
+ <input type="file" multiple hidden data-upload-input>`
188
+ }
189
+
190
+ // ------------------------------------------------- the workspace's section --
191
+ export function filesSectionHtml (d) {
192
+ const root = foldersOf(d).filter((f) => f.folder === '').sort(byName)
193
+ const newest = filesOf(d).filter((f) => f.folder === '').sort(newestFirst).slice(0, NEWEST)
194
+ const count = filesOf(d).length
195
+ const usage = usageText(d)
196
+ const empty = !root.length && !newest.length && !canEdit(d)
197
+ return `
198
+ <section class="sec files-sec" data-files-drop>
199
+ <div class="sec-head"><h2>Files</h2><span class="count">${count}</span>${usage ? `<span class="hint">${esc(usage)}</span>` : ''}<span class="spacer"></span>
200
+ ${canEdit(d) ? `<button type="button" class="btn sm ghost" data-new-folder>${I.plus}<span>New folder</span></button>` : ''}
201
+ <a href="#" class="sec-link" data-all-files>All files ${I.caret}</a></div>
202
+ ${empty ? '<p class="hint">No files yet.</p>' : `<div class="tiles">
203
+ ${root.map((f) => folderTileHtml(d, f)).join('')}
204
+ ${newest.map((f) => fileTileHtml(d, f, { meta: [whoOf(d, f.uploadedBy), when(f.uploadedAt)].filter(Boolean).join(' · ') })).join('')}
205
+ ${canEdit(d) ? uploadTileHtml(d.workspace.id, 'Upload or drop files') : ''}
206
+ </div>`}
207
+ </section>`
208
+ }
209
+
210
+ const FILE_DRAG = 'application/x-quilt-file'
211
+
212
+ /** Asks for a name and makes a folder inside `parent` ('' for the top). */
213
+ async function newFolder (id, parent, reload) {
214
+ const name = await ask({ title: 'New folder', message: parent ? `Inside ${parent}.` : 'Folders keep files sorted. Drag a file onto one to move it there.', input: { placeholder: 'Name' }, ok: 'Create' })
215
+ if (!name) return
216
+ if (name.includes('/')) return toast(NO_SLASH)
217
+ try { await api('POST', wsUrl(id, '/folders'), { path: parent ? `${parent}/${name}` : name }); toast('Folder made'); await reload() } catch (err) { toast(err.message) }
218
+ }
219
+
220
+ /**
221
+ * Files you may edit can be dragged onto a folder (a tile, a list row or a breadcrumb)
222
+ * to move them there. Uploads from the desktop still use the section's own drop zone.
223
+ */
224
+ function bindMoveToFolder (root, d, id, reload) {
225
+ root.querySelectorAll('[data-pick-file][draggable="true"]').forEach((el) => {
226
+ el.addEventListener('dragstart', (e) => {
227
+ e.dataTransfer.setData(FILE_DRAG, el.dataset.pickFile)
228
+ e.dataTransfer.effectAllowed = 'move'
229
+ el.classList.add('dragging')
230
+ })
231
+ el.addEventListener('dragend', () => el.classList.remove('dragging'))
232
+ })
233
+ const ours = (e) => [...(e.dataTransfer?.types || [])].includes(FILE_DRAG)
234
+ root.querySelectorAll('[data-folder]').forEach((target) => {
235
+ target.addEventListener('dragover', (e) => {
236
+ if (!ours(e)) return
237
+ e.preventDefault()
238
+ e.stopPropagation()
239
+ e.dataTransfer.dropEffect = 'move'
240
+ target.classList.add('drop-on')
241
+ })
242
+ target.addEventListener('dragleave', (e) => { if (!target.contains(e.relatedTarget)) target.classList.remove('drop-on') })
243
+ target.addEventListener('drop', async (e) => {
244
+ if (!ours(e)) return
245
+ e.preventDefault()
246
+ e.stopPropagation()
247
+ target.classList.remove('drop-on')
248
+ const f = filesOf(d).find((x) => x.id === e.dataTransfer.getData(FILE_DRAG))
249
+ const folder = target.dataset.folder
250
+ if (!f || f.folder === folder) return
251
+ try {
252
+ await api('POST', fileUrl(id, f.id, '/update'), { path: folder ? `${folder}/${f.name}` : f.name })
253
+ toast(`Moved to ${folder ? folder.split('/').pop() : d.workspace.name}`)
254
+ await reload()
255
+ } catch (err) { toast(err.message) }
256
+ })
257
+ })
258
+ }
259
+
260
+ /** Drag and drop onto an element: highlights while files hover, uploads them on drop. */
261
+ function bindDrop (zone, onFiles) {
262
+ if (!zone) return
263
+ const hasFiles = (e) => [...(e.dataTransfer?.types || [])].includes('Files')
264
+ zone.addEventListener('dragover', (e) => {
265
+ if (!hasFiles(e)) return
266
+ e.preventDefault()
267
+ e.dataTransfer.dropEffect = 'copy'
268
+ zone.classList.add('files-dragging')
269
+ })
270
+ zone.addEventListener('dragleave', (e) => { if (!zone.contains(e.relatedTarget)) zone.classList.remove('files-dragging') })
271
+ zone.addEventListener('drop', (e) => {
272
+ if (!hasFiles(e)) return
273
+ e.preventDefault()
274
+ zone.classList.remove('files-dragging')
275
+ onFiles(e.dataTransfer.files)
276
+ })
277
+ }
278
+
279
+ function bindUpload (root, upload) {
280
+ const input = root.querySelector('[data-upload-input]')
281
+ if (!input) return
282
+ input.onchange = () => { const list = [...input.files]; input.value = ''; upload(list) }
283
+ root.querySelectorAll('[data-upload-tile], [data-upload]').forEach((b) => { b.onclick = () => { if (!progress) input.click() } })
284
+ }
285
+
286
+ export function bindFilesSection (root, { id, reload, go }) {
287
+ const d = state.workspace
288
+ const sec = root.querySelector('[data-files-drop]')
289
+ if (!d || !sec) return
290
+ const open = (patch) => { state.filesView = { ...state.filesView, folder: '', picked: null, ...patch }; go(`wsfiles:${id}`) }
291
+ sec.querySelector('[data-all-files]').onclick = (e) => { e.preventDefault(); open({}) }
292
+ sec.querySelectorAll('[data-folder]').forEach((b) => { b.onclick = () => open({ folder: b.dataset.folder }) })
293
+ sec.querySelectorAll('[data-pick-file]').forEach((b) => {
294
+ b.onclick = () => { const f = filesOf(d).find((x) => x.id === b.dataset.pickFile); if (f) open({ folder: f.folder, picked: f.id }) }
295
+ })
296
+ if (!canEdit(d)) return
297
+ const upload = (list) => startUpload(id, list, '', { reload, go })
298
+ bindUpload(sec, upload)
299
+ bindDrop(sec, upload)
300
+ sec.querySelector('[data-new-folder]').onclick = () => newFolder(id, '', reload)
301
+ bindMoveToFolder(sec, d, id, reload)
302
+ }
303
+
304
+ // ------------------------------------------------------------- All files --
305
+ function crumbsHtml (d, folder) {
306
+ const parts = folder ? folder.split('/') : []
307
+ const crumbs = [`<a href="#" data-folder="">${esc(d.workspace.name)}</a>`]
308
+ parts.forEach((p, i) => {
309
+ const path = parts.slice(0, i + 1).join('/')
310
+ crumbs.push(i === parts.length - 1 ? `<span class="here">${esc(p)}</span>` : `<a href="#" data-folder="${esc(path)}">${esc(p)}</a>`)
311
+ })
312
+ return `<h1 class="crumbs">${crumbs.join('<span class="sep">/</span>')}</h1>`
313
+ }
314
+
315
+ function listHtml (d, folders, files, picked) {
316
+ const edit = canEdit(d)
317
+ return `
318
+ <div class="files-scroll"><table class="files-list">
319
+ <thead><tr><th>Name</th><th>Size</th><th>Uploaded by</th><th>Date</th><th>Version</th>${edit ? '<th><span class="sr">Actions</span></th>' : ''}</tr></thead>
320
+ <tbody>
321
+ ${folders.map((f) => { const s = folderStats(d, f.path); return `<tr class="is-folder" tabindex="0" data-folder="${esc(f.path)}"><td class="nm">${I.folder}<span>${esc(f.name)}</span></td><td>${esc(plural(s.count, 'file', 'files'))}</td><td></td><td></td><td></td>${edit ? `<td class="acts">${folderActsHtml(f)}</td>` : ''}</tr>` }).join('')}
322
+ ${files.map((f) => `<tr tabindex="0" data-pick-file="${esc(f.id)}" class="${f.id === picked ? 'on' : ''}"${edit ? ' draggable="true"' : ''}><td class="nm"><span class="badge" style="background:${badgeColor(f)}">${esc(badgeText(f))}</span><span>${esc(f.name)}</span></td><td>${esc(bytes(f.size || 0))}</td><td>${esc(whoOf(d, f.uploadedBy))}</td><td>${esc(f.uploadedAt ? new Date(f.uploadedAt).toLocaleDateString() : '')}</td><td>v${esc(f.version || 1)}</td>${edit ? '<td></td>' : ''}</tr>`).join('')}
323
+ </tbody>
324
+ </table></div>`
325
+ }
326
+
327
+ // Preview text and tables, cached per file version so the poll's re-render doesn't flicker.
328
+ const previewCache = new Map() // `${fid}:${version}` -> html
329
+
330
+ function previewMediaHtml (d, f) {
331
+ const id = d.workspace.id
332
+ const src = esc(dataUrl(id, f))
333
+ const kind = previewKind(f)
334
+ if (kind === 'image') return `<img src="${src}" alt="${esc(f.name)}">`
335
+ if (kind === 'video') return `<video controls preload="metadata" src="${src}"></video>`
336
+ if (kind === 'audio') return `<audio controls preload="metadata" src="${src}"></audio>`
337
+ if (kind === 'pdf') return `<iframe src="${src}" title="${esc(f.name)}"></iframe>`
338
+ if (kind === 'csv' || kind === 'text') {
339
+ if ((f.size || 0) > MAX_TEXT_PREVIEW) return '<p class="hint">Too big to preview; download it.</p>'
340
+ const cached = previewCache.get(`${f.id}:${f.version}`)
341
+ return cached || `<div class="fp-text" data-preview-load="${kind}"><p class="hint">${loadingNote()}</p></div>`
342
+ }
343
+ return '<p class="hint">No preview for this kind of file.</p>'
344
+ }
345
+
346
+ const csvTableHtml = (rows) => `<div class="fp-table"><table>${rows.map((r, i) => `<tr>${r.map((c) => (i === 0 ? `<th>${esc(c)}</th>` : `<td>${esc(c)}</td>`)).join('')}</tr>`).join('')}</table></div>`
347
+
348
+ async function loadPreviewText (root, d, f) {
349
+ const box = root.querySelector('[data-preview-load]')
350
+ if (!box) return
351
+ const kind = box.dataset.previewLoad
352
+ let html
353
+ try {
354
+ const res = await fetch(fileUrl(d.workspace.id, f.id, '/data') + `?v=${encodeURIComponent(f.version || 1)}`, { headers: { 'x-quilt-token': TOKEN || '' } })
355
+ if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `Could not load it (${res.status}).`)
356
+ const text = await res.text()
357
+ html = kind === 'csv' ? csvTableHtml(parseCsv(text, CSV_ROWS)) : `<pre class="fp-pre">${esc(text)}</pre>`
358
+ previewCache.set(`${f.id}:${f.version}`, html)
359
+ } catch (err) {
360
+ html = `<p class="hint">${esc(err.message)}</p>`
361
+ }
362
+ if (box.isConnected) box.outerHTML = html
363
+ }
364
+
365
+ function previewHtml (d, f) {
366
+ const edit = canEdit(d)
367
+ const who = whoOf(d, f.uploadedBy)
368
+ return `
369
+ <aside class="file-preview" aria-label="${esc(f.name)}">
370
+ <div class="fp-top"><b class="fp-name" title="${esc(f.path)}">${esc(f.name)}</b><button class="btn sm ghost icon" data-close-preview title="Close" aria-label="Close the preview">${I.x}</button></div>
371
+ <div class="fp-media">${previewMediaHtml(d, f)}</div>
372
+ <div class="fp-meta">${esc(bytes(f.size || 0))} · ${esc(mimeOf(f) || 'unknown type')}<br>${esc([who, when(f.uploadedAt)].filter(Boolean).join(' · '))}</div>
373
+ ${f.note ? `<p class="fp-note">${esc(f.note)}</p>` : ''}
374
+ <div class="fp-acts">
375
+ <a class="btn sm primary" download="${esc(f.name)}" href="${esc(dataUrl(d.workspace.id, f, '&download=1'))}">${I.down}<span>Download</span></a>
376
+ ${edit ? `<button class="btn sm" data-rename>Rename</button><button class="btn sm" data-move>Move</button><button class="btn sm ghost danger" data-delete>Delete</button>` : ''}
377
+ </div>
378
+ <div class="fp-versions" data-versions-box>
379
+ <button class="btn sm ghost" data-versions>Versions${f.version > 1 ? ` (${esc(f.version)})` : ''}</button>
380
+ </div>
381
+ </aside>`
382
+ }
383
+
384
+ export function allFilesHtml (d) {
385
+ const id = String(state.view).slice('wsfiles:'.length)
386
+ if (!d || d.workspace.id !== id) return `<p class="hint">${loadingNote()}</p>`
387
+ const fv = state.filesView
388
+ // A folder someone removed (or a stale saved view) falls back to the top.
389
+ if (fv.folder && !foldersOf(d).some((f) => f.path === fv.folder)) fv.folder = ''
390
+ const folders = foldersOf(d).filter((f) => f.folder === fv.folder).sort(byName)
391
+ const files = filesOf(d).filter((f) => f.folder === fv.folder).sort(byName)
392
+ const picked = filesOf(d).find((f) => f.id === fv.picked) || null
393
+ if (!picked) fv.picked = null
394
+ const here = filesOf(d).filter((f) => inside(fv.folder, f.path))
395
+ const usage = usageText(d)
396
+ const edit = canEdit(d)
397
+ const grid = !folders.length && !files.length && !edit ? ''
398
+ : fv.mode === 'list' ? (folders.length || files.length ? listHtml(d, folders, files, fv.picked) : '')
399
+ : `<div class="tiles">
400
+ ${folders.map((f) => folderTileHtml(d, f, { acts: edit })).join('')}
401
+ ${files.map((f) => fileTileHtml(d, f, { on: f.id === fv.picked, meta: [bytes(f.size || 0), whoOf(d, f.uploadedBy)].filter(Boolean).join(' · ') })).join('')}
402
+ ${edit ? uploadTileHtml(id, fv.folder ? `Upload to ${fv.folder.split('/').pop()}` : 'Upload or drop files') : ''}
403
+ </div>`
404
+ return `
405
+ <a class="ws-back" href="#" data-ws-open="${esc(id)}">${I.caret} ${esc(d.workspace.name)}</a>
406
+ <header class="files-head">
407
+ <div class="t">${crumbsHtml(d, fv.folder)}<p class="hint">${esc(`${plural(here.length, 'file', 'files')} · ${bytes(here.reduce((n, f) => n + (f.size || 0), 0))}${usage ? ` · ${usage} in this workspace` : ''}`)}</p></div>
408
+ <span class="spacer"></span>
409
+ <div class="segmented files-mode" role="tablist" aria-label="Show as">
410
+ <button type="button" role="tab" data-files-mode="tiles" class="${fv.mode !== 'list' ? 'on' : ''}" aria-selected="${fv.mode !== 'list'}">Tiles</button>
411
+ <button type="button" role="tab" data-files-mode="list" class="${fv.mode === 'list' ? 'on' : ''}" aria-selected="${fv.mode === 'list'}">List</button>
412
+ </div>
413
+ ${edit ? `<button class="btn sm" data-new-folder>${I.plus}<span>New folder</span></button><button class="btn sm primary" data-upload>${I.upload}<span>Upload</span></button>` : ''}
414
+ </header>
415
+ ${fv.mode === 'list' && edit ? `<p class="hint files-progress" data-progress>${uploadingTo(id) ? esc(progressLabel()) : ''}</p><input type="file" multiple hidden data-upload-input>` : ''}
416
+ <div class="files-body" data-files-drop>
417
+ <div class="files-main">${grid || '<p class="hint">Nothing in this folder yet.</p>'}</div>
418
+ ${picked ? previewHtml(d, picked) : '<aside class="file-preview empty" aria-hidden="true"><p class="hint">Pick a file to see it here.</p></aside>'}
419
+ </div>`
420
+ }
421
+
422
+ /** `rerender` draws the page again from state (home.js passes renderShell); without it, go(). */
423
+ export function bindAllFiles (root, { id, reload, go, rerender = () => go(`wsfiles:${id}`) }) {
424
+ const d = state.workspace
425
+ if (!d || d.workspace.id !== id) return
426
+ const fv = state.filesView
427
+ root.querySelector('[data-ws-open]').onclick = (e) => { e.preventDefault(); go(`ws:${id}`) }
428
+ root.querySelectorAll('[data-folder]').forEach((el) => {
429
+ const open = (e) => { e?.preventDefault(); fv.folder = el.dataset.folder; fv.picked = null; rerender() }
430
+ el.onclick = open
431
+ if (el.tagName === 'TR') el.onkeydown = (e) => { if (e.key === 'Enter') open(e) }
432
+ })
433
+ root.querySelectorAll('[data-pick-file]').forEach((el) => {
434
+ const pick = () => { fv.picked = fv.picked === el.dataset.pickFile ? null : el.dataset.pickFile; rerender() }
435
+ el.onclick = pick
436
+ if (el.tagName === 'TR') el.onkeydown = (e) => { if (e.key === 'Enter') pick() }
437
+ })
438
+ root.querySelectorAll('[data-files-mode]').forEach((b) => { b.onclick = () => { fv.mode = b.dataset.filesMode; rerender() } })
439
+ root.querySelector('[data-close-preview]')?.addEventListener('click', () => { fv.picked = null; rerender() })
440
+
441
+ if (canEdit(d)) {
442
+ const upload = (list) => startUpload(id, list, fv.folder, { reload, go })
443
+ bindUpload(root, upload)
444
+ bindDrop(root.querySelector('[data-files-drop]'), upload)
445
+ root.querySelector('[data-new-folder]').onclick = () => newFolder(id, fv.folder, reload)
446
+ bindFolderActs(root, d, id, reload)
447
+ bindMoveToFolder(root, d, id, reload)
448
+ }
449
+
450
+ const f = filesOf(d).find((x) => x.id === fv.picked)
451
+ if (!f) return
452
+ loadPreviewText(root, d, f)
453
+ const panel = root.querySelector('.file-preview:not(.empty)')
454
+ if (panel && getComputedStyle(panel).position === 'static') panel.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
455
+ /** Saves the patch; `then` runs only once it saved, before the redraw. */
456
+ const update = async (patch, done, then = () => {}) => {
457
+ try { await api('POST', fileUrl(id, f.id, '/update'), patch) } catch (err) { return toast(err.message) }
458
+ then(); toast(done); await reload()
459
+ }
460
+ root.querySelector('[data-rename]')?.addEventListener('click', async () => {
461
+ const name = await ask({ title: `Rename ${f.name}`, input: { label: 'Name', value: f.name }, ok: 'Rename' })
462
+ if (!name || name === f.name) return
463
+ if (name.includes('/')) return toast(NO_SLASH)
464
+ await update({ path: f.folder ? `${f.folder}/${name}` : name }, 'Renamed')
465
+ })
466
+ root.querySelector('[data-move]')?.addEventListener('click', async () => {
467
+ // A select of the folders there are: '/' stands for the top, since ask() wants a value.
468
+ const options = [{ value: '/', label: d.workspace.name }, ...foldersOf(d).map((x) => ({ value: x.path, label: x.path })).sort((a, b) => a.label.localeCompare(b.label))]
469
+ const to = await ask({ title: `Move ${f.name} to`, input: { label: 'Folder', select: options, value: f.folder || '/' }, ok: 'Move' })
470
+ if (!to) return
471
+ const folder = to === '/' ? '' : to
472
+ if (folder === f.folder) return
473
+ await update({ path: folder ? `${folder}/${f.name}` : f.name }, 'Moved', () => { fv.folder = folder })
474
+ })
475
+ root.querySelector('[data-delete]')?.addEventListener('click', async () => {
476
+ if (!await ask({ title: `Delete ${f.name}?`, message: 'It goes for everyone in this workspace, with its earlier versions.', ok: 'Delete', danger: true })) return
477
+ try { await api('POST', fileUrl(id, f.id, '/delete')); fv.picked = null; toast('Deleted'); await reload() } catch (err) { toast(err.message) }
478
+ })
479
+ const showVersions = async () => {
480
+ const box = root.querySelector('[data-versions-box]')
481
+ const btn = box.querySelector('[data-versions]')
482
+ if (btn) btn.disabled = true
483
+ versionsOpen = f.id
484
+ try {
485
+ // The list holds the earlier versions; the one people get now is the file itself.
486
+ const { versions: earlier } = await api('GET', fileUrl(id, f.id, '/versions'))
487
+ const versions = [f, ...earlier.filter((v) => v.version !== f.version)]
488
+ box.innerHTML = `<b>Versions</b>${versions.map((v) => `
489
+ <div class="fp-version"><span class="mono">v${esc(v.version)}</span><span class="grow">${esc([whoOf(d, v.uploadedBy), when(v.uploadedAt), bytes(v.size || 0)].filter(Boolean).join(' · '))}${v.version === f.version ? ' · <b class="cur">current</b>' : ''}${v.note ? `<span class="hint">${esc(v.note)}</span>` : ''}</span>
490
+ <a href="${esc(fileUrl(id, f.id, '/data?t=' + encodeURIComponent(TOKEN || '')) + `&version=${encodeURIComponent(v.version)}&download=1`)}" download="${esc(f.name)}">Download this version</a></div>`).join('')}${earlier.length ? '' : '<p class="hint">No earlier versions.</p>'}`
491
+ } catch (err) {
492
+ versionsOpen = null
493
+ toast(err.message)
494
+ if (btn?.isConnected) btn.disabled = false
495
+ }
496
+ }
497
+ root.querySelector('[data-versions]')?.addEventListener('click', showVersions)
498
+ // A redraw (the poll, an upload) keeps an open Versions list open.
499
+ if (versionsOpen === f.id) showVersions()
500
+ }
501
+
502
+ // Which file's Versions list is open, so a redraw opens it again.
503
+ let versionsOpen = null
504
+
505
+ /** Rename and Delete on folder tiles and rows. The API moves or removes what is inside too. */
506
+ function bindFolderActs (root, d, id, reload) {
507
+ const byId = (fid) => foldersOf(d).find((x) => x.id === fid)
508
+ root.querySelectorAll('[data-folder-rename]').forEach((b) => {
509
+ b.onclick = async (e) => {
510
+ e.stopPropagation()
511
+ const f = byId(b.dataset.folderRename)
512
+ if (!f) return
513
+ const name = await ask({ title: `Rename ${f.name}`, input: { label: 'Name', value: f.name }, ok: 'Rename' })
514
+ if (!name || name === f.name) return
515
+ if (name.includes('/')) return toast(NO_SLASH)
516
+ try { await api('POST', fileUrl(id, f.id, '/update'), { path: f.folder ? `${f.folder}/${name}` : name }); toast('Renamed'); await reload() } catch (err) { toast(err.message) }
517
+ }
518
+ })
519
+ root.querySelectorAll('[data-folder-delete]').forEach((b) => {
520
+ b.onclick = async (e) => {
521
+ e.stopPropagation()
522
+ const f = byId(b.dataset.folderDelete)
523
+ if (!f) return
524
+ const n = folderStats(d, f.path).count
525
+ const message = n ? `Everything in it goes too (${plural(n, 'file', 'files')}), for everyone in this workspace.` : 'It is empty.'
526
+ if (!await ask({ title: `Delete the folder ${f.name}?`, message, ok: 'Delete', danger: true })) return
527
+ try {
528
+ await api('POST', fileUrl(id, f.id, '/delete'))
529
+ toast('Deleted'); await reload()
530
+ } catch (err) { toast(err.message) }
531
+ }
532
+ })
533
+ root.querySelectorAll('.folder-acts').forEach((el) => el.addEventListener('keydown', (e) => e.stopPropagation()))
534
+ }
535
+
536
+ /** True while a preview is playing, so the poll's re-render doesn't restart it. */
537
+ export function previewBusy () {
538
+ return [...document.querySelectorAll('.file-preview video, .file-preview audio')].some((m) => !m.paused)
539
+ }
540
+
541
+ // ---------------------------------------------------------------- picker --
542
+ /** A dialog to choose one of the workspace's files. Resolves { id, name }, or null. */
543
+ export async function workspaceFilePicker (id) {
544
+ let files
545
+ try { files = (await api('GET', wsUrl(id, '/files'))).files.filter((f) => f.kind !== 'folder').sort((a, b) => a.path.localeCompare(b.path)) } catch (err) { toast(err.status === 404 || /not found/i.test(err.message) ? "Only this workspace's members can see its files." : err.message); return null }
546
+ return new Promise((resolve) => {
547
+ const back = document.createElement('div')
548
+ back.className = 'modal-back'
549
+ back.innerHTML = `<div class="card modal file-picker" role="dialog" aria-modal="true" aria-labelledby="fpk-title">
550
+ <h3 id="fpk-title">Choose a file</h3>
551
+ <input class="input" type="search" placeholder="Search files" aria-label="Search files" data-pick-search>
552
+ <div class="fpk-list" data-pick-list></div>
553
+ <div class="actions"><button type="button" class="btn" data-no>Cancel</button></div>
554
+ </div>`
555
+ document.body.appendChild(back)
556
+ const list = back.querySelector('[data-pick-list]')
557
+ const search = back.querySelector('[data-pick-search]')
558
+ const done = (v) => { back.remove(); resolve(v) }
559
+ const draw = () => {
560
+ const q = search.value.trim().toLowerCase()
561
+ const shown = files.filter((f) => !q || f.path.toLowerCase().includes(q))
562
+ list.innerHTML = shown.length
563
+ ? shown.map((f) => `<button type="button" class="fpk-row" data-pick="${esc(f.id)}"><span class="badge" style="background:${badgeColor(f)}">${esc(badgeText(f))}</span><span class="grow">${esc(f.path)}</span><span class="hint">${esc(bytes(f.size || 0))}</span></button>`).join('')
564
+ : `<p class="hint">${files.length ? 'No file matches.' : 'This workspace has no files yet.'}</p>`
565
+ }
566
+ draw()
567
+ search.oninput = draw
568
+ search.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); list.querySelector('[data-pick]')?.click() } }
569
+ list.onclick = (e) => {
570
+ const b = e.target.closest('[data-pick]')
571
+ const f = b && files.find((x) => x.id === b.dataset.pick)
572
+ if (f) done({ id: f.id, name: f.name })
573
+ }
574
+ back.addEventListener('mousedown', (e) => { if (e.target === back) done(null) })
575
+ back.addEventListener('keydown', (e) => { if (e.key === 'Escape') done(null) })
576
+ back.querySelector('[data-no]').onclick = () => done(null)
577
+ search.focus()
578
+ })
579
+ }