canon-atlas 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,829 @@
1
+ /* The pure-client shell: no server, no per-project command. Pick a folder in
2
+ the page, read its markdown, build the wire data with the shared
3
+ AtlasPipeline (the very code the CLI runs), and mount the constellation.
4
+
5
+ Two pickers, best available wins. The File System Access API (Chrome and
6
+ Edge, over http or https; browsers like Brave remove it, and file:// pages
7
+ block it) gives the full experience: editing writes back through the
8
+ directory handle, enforcing the same collection mutability the server does,
9
+ and picked folders are remembered in IndexedDB. Everywhere else the page
10
+ falls back to the plain directory input, which reads the same folder into
11
+ the same constellation, read only. Each browser tab holds one project. */
12
+
13
+ (function () {
14
+ "use strict";
15
+
16
+ var MD = /\.(md|markdown)$/i;
17
+ var CONFIG = "canon-atlas.json";
18
+ var SKIP = { ".git": 1, "node_modules": 1, ".obsidian": 1, ".atlas": 1 };
19
+
20
+ var stage = document.getElementById("stage");
21
+ var handle = null; // the current project's FileSystemDirectoryHandle
22
+ var currentData = null; // last built wire data, for mutability checks
23
+
24
+ var supported = typeof window.showDirectoryPicker === "function";
25
+ var isFile = location.protocol === "file:";
26
+
27
+ /* ── remembered folders (IndexedDB stores the directory handles) ─────────── */
28
+
29
+ var DB = "canon-atlas";
30
+ var STORE = "folders";
31
+
32
+ function openDb() {
33
+ return new Promise(function (res, rej) {
34
+ var r = indexedDB.open(DB, 1);
35
+ r.onupgradeneeded = function () {
36
+ if (!r.result.objectStoreNames.contains(STORE)) r.result.createObjectStore(STORE);
37
+ };
38
+ r.onsuccess = function () { res(r.result); };
39
+ r.onerror = function () { rej(r.error); };
40
+ });
41
+ }
42
+ function tx(mode, fn) {
43
+ return openDb().then(function (db) {
44
+ return new Promise(function (res, rej) {
45
+ var t = db.transaction(STORE, mode);
46
+ var req = fn(t.objectStore(STORE));
47
+ t.oncomplete = function () { res(req && req.result); };
48
+ t.onerror = function () { rej(t.error); };
49
+ t.onabort = function () { rej(t.error); };
50
+ });
51
+ });
52
+ }
53
+ /* A row is { handle, manual }: manual marks a name the user typed (the API
54
+ never reveals paths, so a hand-written path is the only way to see one),
55
+ and a manual name is never overwritten by the automatic labeler. Older
56
+ rows stored the bare handle; listFolders normalizes both shapes. */
57
+ function rememberFolder(name, h, manual) {
58
+ return tx("readwrite", function (s) { return s.put({ handle: h, manual: !!manual }, name); });
59
+ }
60
+ function forgetFolder(name) { return tx("readwrite", function (s) { return s.delete(name); }); }
61
+ function listFolders() {
62
+ return tx("readonly", function (s) { return s.getAllKeys(); }).then(function (keys) {
63
+ return Promise.all((keys || []).map(function (k) {
64
+ return tx("readonly", function (s) { return s.get(k); }).then(function (v) {
65
+ return v && v.handle
66
+ ? { name: k, handle: v.handle, manual: !!v.manual }
67
+ : { name: k, handle: v };
68
+ });
69
+ }));
70
+ });
71
+ }
72
+
73
+ /* ── permission ──────────────────────────────────────────────────────────── */
74
+
75
+ /* A permission request can go unanswered when the browser fails to render its
76
+ prompt (seen with re-enabled APIs); racing it means the app degrades with a
77
+ message instead of waiting forever. The unanswered promise lingers inertly. */
78
+ function raceMs(promise, ms) {
79
+ return new Promise(function (res, rej) {
80
+ var done = false;
81
+ var t = setTimeout(function () {
82
+ if (!done) { done = true; rej(new Error("timeout")); }
83
+ }, ms);
84
+ promise.then(
85
+ function (v) { if (!done) { done = true; clearTimeout(t); res(v); } },
86
+ function (e) { if (!done) { done = true; clearTimeout(t); rej(e); } }
87
+ );
88
+ });
89
+ }
90
+
91
+ function ensurePermission(h, mode) {
92
+ var opts = { mode: mode };
93
+ return Promise.resolve(h.queryPermission(opts)).then(function (p) {
94
+ if (p === "granted") return true;
95
+ return Promise.resolve(h.requestPermission(opts)).then(function (q) { return q === "granted"; });
96
+ });
97
+ }
98
+
99
+ /* What the handle already carries, asked without prompting. */
100
+ function grantedMode(h) {
101
+ return Promise.resolve(h.queryPermission({ mode: "readwrite" })).then(function (rw) {
102
+ if (rw === "granted") return "fs";
103
+ return Promise.resolve(h.queryPermission({ mode: "read" })).then(function (r) {
104
+ return r === "granted" ? "browse" : null;
105
+ });
106
+ });
107
+ }
108
+
109
+ /* ── folder reading ──────────────────────────────────────────────────────── */
110
+
111
+ /* A fat catalog is legitimate: the pi-canon campaign statement names 10,000
112
+ articles, and corpora that size mount and draw (measured; see the gate
113
+ notes). What this line catches is a tree of trees (a repo of fixtures, a
114
+ home directory), where the honest move is to stop fast and say so rather
115
+ than read half a million files into a tab. */
116
+ var MAX_DOCS = 25000;
117
+
118
+ function tooMany() {
119
+ return new Error(
120
+ "stopped past " + MAX_DOCS + " markdown documents: this folder looks bigger than one catalog. Pick a narrower folder, like the store itself."
121
+ );
122
+ }
123
+
124
+ /* Enumerate first, then read. Listing is cheap and gives the cap an early
125
+ answer; reading goes through a bounded pool, because fanning every file
126
+ out at once exhausts descriptors on a fat corpus and reads start failing
127
+ with NotReadableError. */
128
+ function listDir(dir, prefix, out) {
129
+ var pending = [];
130
+ return (async function () {
131
+ for await (const entry of dir.values()) {
132
+ if (SKIP[entry.name]) continue;
133
+ if (out.length > MAX_DOCS) break;
134
+ const rel = prefix ? prefix + "/" + entry.name : entry.name;
135
+ if (entry.kind === "directory") pending.push(listDir(entry, rel, out));
136
+ else if (MD.test(entry.name)) out.push({ path: rel, entry: entry });
137
+ }
138
+ await Promise.all(pending);
139
+ return out;
140
+ })();
141
+ }
142
+
143
+ var READ_CONCURRENCY = 64;
144
+
145
+ /* Run one worker per item through a fixed number of lanes. A failure skips
146
+ that item and is collected, never sinking the rest: one unreadable file in
147
+ a ten-thousand-document corpus is a fact to report, not a reason to stop. */
148
+ function eachLimit(items, limit, worker) {
149
+ var i = 0;
150
+ var failures = [];
151
+ function next() {
152
+ if (i >= items.length) return Promise.resolve();
153
+ var item = items[i++];
154
+ return worker(item)
155
+ .catch(function (e) { failures.push({ item: item, error: e }); })
156
+ .then(next);
157
+ }
158
+ var lanes = [];
159
+ for (var l = 0; l < Math.min(limit, items.length); l++) lanes.push(next());
160
+ return Promise.all(lanes).then(function () { return failures; });
161
+ }
162
+
163
+ function readListed(list, progress) {
164
+ var files = [];
165
+ return eachLimit(list, READ_CONCURRENCY, function (it) {
166
+ return it.entry
167
+ .getFile()
168
+ .then(function (f) { return f.text(); })
169
+ .then(function (text) {
170
+ files.push({ path: it.path, text: text });
171
+ if (progress) progress(files.length);
172
+ });
173
+ }).then(function (failures) {
174
+ if (failures.length) {
175
+ console.warn(
176
+ "canon-atlas: skipped " + failures.length + " unreadable file(s), e.g. " +
177
+ failures[0].item.path + ": " + String((failures[0].error && failures[0].error.message) || failures[0].error)
178
+ );
179
+ }
180
+ if (!files.length && list.length) {
181
+ throw new Error("none of the " + list.length + " documents could be read");
182
+ }
183
+ return files;
184
+ });
185
+ }
186
+
187
+ function readJson(name) {
188
+ return handle
189
+ .getFileHandle(name)
190
+ .then(function (fh) { return fh.getFile(); })
191
+ .then(function (f) { return f.text(); })
192
+ .then(function (t) { return JSON.parse(t); })
193
+ .catch(function () { return null; });
194
+ }
195
+
196
+ /* "fs" when the handle came with write permission, "browse" when it is
197
+ read-only (a dropped folder whose write grant was declined still reads). */
198
+ var handleMode = "fs";
199
+
200
+ function buildFromFolder(progress) {
201
+ return listDir(handle, "", [])
202
+ .then(function (list) {
203
+ if (list.length > MAX_DOCS) throw tooMany();
204
+ return readListed(list, progress);
205
+ })
206
+ .then(function (files) {
207
+ return readJson(CONFIG).then(function (configJson) {
208
+ var vecName = (configJson && configJson.embeddings) || "embeddings.json";
209
+ return readJson(vecName).then(function (vectorsJson) {
210
+ currentData = AtlasPipeline.buildWireData(files, {
211
+ configJson: configJson,
212
+ vectorsJson: vectorsJson,
213
+ mode: handleMode,
214
+ });
215
+ return currentData;
216
+ });
217
+ });
218
+ });
219
+ }
220
+
221
+ /* ── writing, confined to the corpus and to mutable collections ──────────── */
222
+
223
+ function confine(path) {
224
+ if (typeof path !== "string" || !path) throw new Error("path required");
225
+ if (!MD.test(path)) throw new Error("path must end in .md");
226
+ var segs = path.split("/");
227
+ if (path.charAt(0) === "/" || segs.some(function (s) { return !s || s === "." || s === ".."; })) {
228
+ throw new Error("path escapes the corpus");
229
+ }
230
+ var cols = (currentData && currentData.collections) || [];
231
+ for (var i = 0; i < cols.length; i++) {
232
+ if (path.indexOf(cols[i].match) === 0) return cols[i];
233
+ }
234
+ throw new Error("no collection claims this path");
235
+ }
236
+
237
+ // Walk the directory handles by path segment; create intermediates on demand.
238
+ function fileHandleFor(path, create) {
239
+ var parts = path.split("/");
240
+ var dir = Promise.resolve(handle);
241
+ for (var i = 0; i < parts.length - 1; i++) {
242
+ (function (name) {
243
+ dir = dir.then(function (d) { return d.getDirectoryHandle(name, { create: !!create }); });
244
+ })(parts[i]);
245
+ }
246
+ return dir.then(function (d) { return d.getFileHandle(parts[parts.length - 1], { create: !!create }); });
247
+ }
248
+
249
+ function writeThrough(fh, content) {
250
+ return fh.createWritable().then(function (w) {
251
+ return w.write(content == null ? "" : String(content)).then(function () { return w.close(); });
252
+ });
253
+ }
254
+
255
+ var fsBackend = {
256
+ graph: function () { return buildFromFolder(); },
257
+ readDoc: function (path) {
258
+ confine(path);
259
+ return fileHandleFor(path, false)
260
+ .then(function (fh) { return fh.getFile(); })
261
+ .then(function (f) { return f.text(); })
262
+ .then(function (raw) { return { path: path, raw: raw }; });
263
+ },
264
+ writeDoc: function (path, content) {
265
+ var col = confine(path);
266
+ if (col.immutable) return Promise.reject(new Error(col.name + " is immutable"));
267
+ return fileHandleFor(path, false).then(function (fh) { return writeThrough(fh, content); }).then(function () {
268
+ return { path: path };
269
+ });
270
+ },
271
+ createDoc: function (path, content) {
272
+ confine(path);
273
+ // Refuse to overwrite: succeed only if the file does not already exist.
274
+ return fileHandleFor(path, false).then(
275
+ function () { throw new Error("document already exists"); },
276
+ function () {
277
+ return fileHandleFor(path, true).then(function (fh) { return writeThrough(fh, content); }).then(function () {
278
+ return { path: path };
279
+ });
280
+ }
281
+ );
282
+ },
283
+ deleteDoc: function (path) {
284
+ var col = confine(path);
285
+ if (col.immutable) return Promise.reject(new Error(col.name + " is immutable"));
286
+ var parts = path.split("/");
287
+ var dir = Promise.resolve(handle);
288
+ for (var i = 0; i < parts.length - 1; i++) {
289
+ (function (name) {
290
+ dir = dir.then(function (d) { return d.getDirectoryHandle(name); });
291
+ })(parts[i]);
292
+ }
293
+ return dir.then(function (d) { return d.removeEntry(parts[parts.length - 1]); }).then(function () {
294
+ return { path: path };
295
+ });
296
+ },
297
+ };
298
+
299
+ /* ── the read-only fallback picker ───────────────────────────────────────────
300
+ A plain directory input works in every browser and from file://, where the
301
+ File System Access API is absent (Brave removes it) or blocked. It reads
302
+ the same folder into the same constellation; it just cannot write back, so
303
+ the page mounts in browse mode and the editor never appears. Nothing is
304
+ uploaded anywhere: the browser only hands the page the files locally. */
305
+
306
+ var dirInput = document.createElement("input");
307
+ dirInput.type = "file";
308
+ dirInput.webkitdirectory = true;
309
+ dirInput.style.display = "none";
310
+ document.body.appendChild(dirInput);
311
+ dirInput.addEventListener("change", function () {
312
+ if (dirInput.files && dirInput.files.length) openFileList(dirInput.files);
313
+ dirInput.value = "";
314
+ });
315
+ function pickBrowse() {
316
+ dirInput.click();
317
+ }
318
+
319
+ function relOf(f) {
320
+ var rel = f.webkitRelativePath || f.name;
321
+ var parts = rel.split("/");
322
+ parts.shift(); // the picked folder's own name
323
+ for (var i = 0; i < parts.length - 1; i++) if (SKIP[parts[i]]) return null;
324
+ return parts.join("/");
325
+ }
326
+
327
+ /* The read-only picker hands over the whole tree at once: read the config,
328
+ the vectors, and the markdown, then mount without a write path. */
329
+ function openFileList(list) {
330
+ var byPath = {};
331
+ for (var i = 0; i < list.length; i++) {
332
+ var rel = relOf(list[i]);
333
+ if (rel) byPath[rel] = list[i];
334
+ }
335
+ var mdPaths = Object.keys(byPath).filter(function (p) { return MD.test(p); });
336
+ if (!mdPaths.length) {
337
+ showLanding("no markdown documents in that folder");
338
+ return;
339
+ }
340
+ if (mdPaths.length > MAX_DOCS) {
341
+ showLanding(String(tooMany().message));
342
+ return;
343
+ }
344
+ function textOf(p) {
345
+ return byPath[p] ? byPath[p].text() : Promise.resolve(null);
346
+ }
347
+ function jsonOf(p) {
348
+ return textOf(p).then(function (t) {
349
+ try { return t ? JSON.parse(t) : null; } catch (e) { return null; }
350
+ });
351
+ }
352
+ setStatus("reading " + mdPaths.length + " documents…");
353
+ jsonOf(CONFIG)
354
+ .then(function (configJson) {
355
+ var vecName = (configJson && configJson.embeddings) || "embeddings.json";
356
+ return jsonOf(vecName).then(function (vectorsJson) {
357
+ return Promise.all(
358
+ mdPaths.map(function (p) {
359
+ return textOf(p).then(function (t) { return { path: p, text: t }; });
360
+ })
361
+ ).then(function (docs) {
362
+ var mem = {};
363
+ docs.forEach(function (f) { mem[f.path] = f.text; });
364
+ var data = AtlasPipeline.buildWireData(docs, {
365
+ configJson: configJson,
366
+ vectorsJson: vectorsJson,
367
+ mode: "browse",
368
+ });
369
+ currentData = data;
370
+ hideLanding();
371
+ AtlasApp.mount(data, browseBackendFor(mem, data));
372
+ });
373
+ });
374
+ })
375
+ .catch(function (e) { showLanding(String((e && e.message) || e)); });
376
+ }
377
+
378
+ function browseBackendFor(mem, data) {
379
+ var readOnly = function () {
380
+ return Promise.reject(new Error("read only: this picker cannot write the folder"));
381
+ };
382
+ return {
383
+ graph: function () { return Promise.resolve(data); },
384
+ readDoc: function (p) { return Promise.resolve({ path: p, raw: mem[p] }); },
385
+ writeDoc: readOnly,
386
+ createDoc: readOnly,
387
+ deleteDoc: readOnly,
388
+ };
389
+ }
390
+
391
+ /* ── workspaces by path, when the local host serves the page ──────────────
392
+ The `canon-atlas open` host owns a registry of projects by absolute path,
393
+ which no in-browser picker can ever reveal. When the page detects that
394
+ host, the landing and the folders panel offer the path door first: type a
395
+ path, the host reads the corpus behind /w/<id>/api with the same
396
+ confinement and mutability rules as canon-atlas serve, and the recents
397
+ are real paths instead of guessed labels. On any other origin the fetch
398
+ finds no host and none of this renders. */
399
+
400
+ var hostList = null; // [{ id, path, exists }] when the canon-atlas host answers
401
+
402
+ function hostApi(method, url, body) {
403
+ return fetch(url, {
404
+ method: method,
405
+ headers: body ? { "Content-Type": "application/json" } : undefined,
406
+ body: body ? JSON.stringify(body) : undefined,
407
+ cache: "no-store",
408
+ }).then(function (res) {
409
+ return res.json().then(function (j) {
410
+ if (!res.ok) throw new Error(j.error || res.statusText);
411
+ return j;
412
+ });
413
+ });
414
+ }
415
+ function detectHost() {
416
+ if (location.protocol !== "http:" && location.protocol !== "https:") return Promise.resolve(null);
417
+ var name = location.hostname;
418
+ if (name !== "127.0.0.1" && name !== "localhost" && name !== "[::1]") return Promise.resolve(null);
419
+ return fetch("/api/workspaces", { cache: "no-store" })
420
+ .then(function (r) {
421
+ if (!r.ok || r.headers.get("x-canon-atlas") !== "app") return null;
422
+ return r.json().then(function (j) { return j.workspaces || []; });
423
+ })
424
+ .catch(function () { return null; });
425
+ }
426
+ function refreshHost() {
427
+ return detectHost().then(function (l) { hostList = l; });
428
+ }
429
+ function hostBackend(prefix) {
430
+ return {
431
+ graph: function () { return hostApi("GET", prefix + "/graph"); },
432
+ readDoc: function (p) { return hostApi("GET", prefix + "/doc?path=" + encodeURIComponent(p)); },
433
+ writeDoc: function (p, c) { return hostApi("PUT", prefix + "/doc", { path: p, content: c }); },
434
+ createDoc: function (p, c) { return hostApi("POST", prefix + "/doc", { path: p, content: c }); },
435
+ deleteDoc: function (p) { return hostApi("DELETE", prefix + "/doc?path=" + encodeURIComponent(p)); },
436
+ };
437
+ }
438
+ function openWorkspace(ws) {
439
+ setStatus("reading " + ws.path + "…");
440
+ var be = hostBackend("/w/" + ws.id + "/api");
441
+ return be.graph()
442
+ .then(function (data) {
443
+ if (!data.nodes.length) throw new Error("no markdown documents in " + ws.path);
444
+ currentData = data;
445
+ handle = null;
446
+ hideLanding();
447
+ AtlasApp.mount(data, be);
448
+ return refreshHost().then(renderFoldersPanel);
449
+ })
450
+ .catch(function (e) { showLanding(String((e && e.message) || e)); });
451
+ }
452
+ function openPath(path) {
453
+ setStatus("opening " + path + "…");
454
+ return hostApi("POST", "/api/workspace", { path: path })
455
+ .then(openWorkspace)
456
+ .catch(function (e) { showLanding(String((e && e.message) || e)); });
457
+ }
458
+
459
+ var MONO_ROW = "font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;";
460
+
461
+ /* The path door and the remembered workspaces, rendered into a landing box
462
+ or the folders panel alike; mkHead styles the heading for its context and
463
+ rerender repaints that context after a forget. */
464
+ function hostSection(container, mkHead, rerender) {
465
+ var row = el("div", "display:flex;gap:6px;margin-bottom:8px;align-items:stretch;");
466
+ var input = el("input", CARD + "flex:1;min-width:0;padding:7px 10px;" + MONO_ROW);
467
+ input.placeholder = "/absolute/path/to/project";
468
+ var go = el("button", CARD + "color:#9aaa3a;border-color:#4a5220;padding:7px 12px;", "Open");
469
+ function commit() {
470
+ var p = input.value.trim();
471
+ if (p) openPath(p);
472
+ }
473
+ go.addEventListener("click", commit);
474
+ input.addEventListener("keydown", function (ev) { if (ev.key === "Enter") commit(); });
475
+ row.appendChild(input);
476
+ row.appendChild(go);
477
+ container.appendChild(row);
478
+ if (!hostList.length) return;
479
+ container.appendChild(mkHead("workspaces"));
480
+ hostList.forEach(function (w) {
481
+ var r = el("div", "display:flex;gap:6px;margin-bottom:6px;align-items:stretch;");
482
+ var pick = el("button",
483
+ CARD + "flex:1;padding:7px 10px;min-width:0;word-break:break-all;" + MONO_ROW +
484
+ (w.exists ? "" : "opacity:.45;"), w.path);
485
+ pick.title = w.exists ? w.path : w.path + " (folder is missing)";
486
+ pick.addEventListener("click", function () { openWorkspace(w); });
487
+ var forget = el("button", CARD + "color:#8a8775;padding:7px 10px;", "×");
488
+ forget.title = "forget this workspace";
489
+ forget.addEventListener("click", function () {
490
+ hostApi("DELETE", "/api/workspace?id=" + encodeURIComponent(w.id))
491
+ .then(refreshHost)
492
+ .then(rerender)
493
+ .catch(function () {});
494
+ });
495
+ r.appendChild(pick);
496
+ r.appendChild(forget);
497
+ container.appendChild(r);
498
+ });
499
+ }
500
+
501
+ /* ── landing ─────────────────────────────────────────────────────────────── */
502
+
503
+ var landing = null;
504
+ var statusEl = null;
505
+
506
+ /* Progress under the landing buttons, so a large folder reads as work
507
+ happening rather than a hang. */
508
+ function setStatus(text) {
509
+ if (statusEl) statusEl.textContent = text || "";
510
+ }
511
+
512
+ function el(tag, css, text) {
513
+ var e = document.createElement(tag);
514
+ if (css) e.style.cssText = css;
515
+ if (text != null) e.textContent = text;
516
+ return e;
517
+ }
518
+
519
+ var CARD = "background:#131409;border:1px solid #33341f;color:#d4cfc3;font:400 14px Outfit,system-ui,sans-serif;" +
520
+ "padding:9px 14px;border-radius:8px;cursor:pointer;text-align:left;";
521
+
522
+ function showLanding(message) {
523
+ hideLanding();
524
+ landing = el("div",
525
+ "position:absolute;inset:0;display:flex;align-items:center;justify-content:center;" +
526
+ "background:#090b09;z-index:5;");
527
+ var box = el("div", "max-width:440px;width:100%;padding:24px;");
528
+ var h = el("div",
529
+ "font:500 22px Outfit,system-ui,sans-serif;color:#ede8db;margin-bottom:6px;", "canon-atlas");
530
+ var sub = el("div", "font:300 14px Outfit,system-ui,sans-serif;color:#8a8775;margin-bottom:20px;",
531
+ hostList
532
+ ? "Open a project by its absolute path, or drop a folder anywhere on the page. The local host reads it; nothing leaves your computer."
533
+ : "Open a folder of cross-referencing markdown to see it as a constellation, or drop one anywhere on the page. Nothing leaves your computer.");
534
+ box.appendChild(h);
535
+ box.appendChild(sub);
536
+
537
+ var canEdit = supported && !isFile;
538
+ if (hostList) {
539
+ hostSection(box, function (text) {
540
+ return el("div",
541
+ "font:500 12px Outfit,system-ui,sans-serif;color:#8a8775;text-transform:uppercase;letter-spacing:.08em;margin:14px 0 10px;",
542
+ text);
543
+ }, function () { showLanding(); });
544
+ }
545
+ if (canEdit) {
546
+ var open = el("button", CARD + "width:100%;font-size:15px;color:#9aaa3a;border-color:#4a5220;" +
547
+ (hostList ? "margin-top:14px;font-size:14px;color:#8a8775;border-color:#33341f;" : ""), "Open a folder…");
548
+ open.addEventListener("click", pickFolder);
549
+ box.appendChild(open);
550
+ var alt = el("button", CARD + "width:100%;margin-top:8px;color:#8a8775;", "Browse a folder read-only…");
551
+ alt.addEventListener("click", pickBrowse);
552
+ box.appendChild(alt);
553
+ } else {
554
+ var browse = el("button", CARD + "width:100%;" +
555
+ (hostList ? "margin-top:14px;color:#8a8775;" : "font-size:15px;color:#9aaa3a;border-color:#4a5220;"),
556
+ "Browse a folder… (read only)");
557
+ browse.addEventListener("click", pickBrowse);
558
+ box.appendChild(browse);
559
+ if (!hostList) {
560
+ var why = isFile && supported
561
+ ? "Editing needs this page served over http: browsers block the writable folder picker on file:// pages. Launch it with canon-atlas open and editing lights up."
562
+ : "Editing needs the File System Access API: Chrome or Edge over http or https, or Brave with its file-system-access flag enabled.";
563
+ box.appendChild(el("div", "color:#8a8775;font:300 13px Outfit,system-ui,sans-serif;line-height:1.5;margin-top:12px;", why));
564
+ }
565
+ }
566
+
567
+ if (message) {
568
+ box.appendChild(el("div", "color:#e08a6e;font:400 13px Outfit,system-ui,sans-serif;margin-top:12px;", message));
569
+ }
570
+
571
+ statusEl = el("div", "color:#9aaa3a;font:300 13px Outfit,system-ui,sans-serif;margin-top:12px;min-height:18px;", "");
572
+ box.appendChild(statusEl);
573
+
574
+ var recent = el("div", "margin-top:22px;");
575
+ box.appendChild(recent);
576
+ landing.appendChild(box);
577
+ stage.appendChild(landing);
578
+
579
+ if (!canEdit) return; // remembered handles need the rich API to reopen
580
+ listFolders().then(function (folders) {
581
+ if (!folders.length) return;
582
+ recent.appendChild(el("div",
583
+ "font:500 12px Outfit,system-ui,sans-serif;color:#8a8775;text-transform:uppercase;letter-spacing:.08em;margin-bottom:10px;",
584
+ "recent"));
585
+ folders.forEach(function (f) {
586
+ if (!f.handle) return;
587
+ var row = el("div", "display:flex;gap:8px;margin-bottom:8px;align-items:stretch;");
588
+ var pick = el("button", CARD + "flex:1;", f.name);
589
+ pick.addEventListener("click", function () { openHandle(f.handle, f.name, f.manual); });
590
+ var forget = el("button", CARD + "color:#8a8775;padding:9px 11px;", "×");
591
+ forget.title = "forget this folder";
592
+ forget.addEventListener("click", function () { forgetFolder(f.name).then(function () { showLanding(); }); });
593
+ row.appendChild(pick);
594
+ row.appendChild(forget);
595
+ recent.appendChild(row);
596
+ });
597
+ });
598
+ }
599
+
600
+ function hideLanding() {
601
+ if (landing && landing.parentNode) landing.parentNode.removeChild(landing);
602
+ landing = null;
603
+ }
604
+
605
+ /* ── open flows ──────────────────────────────────────────────────────────── */
606
+
607
+ function pickFolder() {
608
+ window
609
+ .showDirectoryPicker({ id: "canon-atlas", mode: "readwrite" })
610
+ .then(function (h) { return openHandle(h, h.name); })
611
+ .catch(function (e) {
612
+ if (e && e.name === "AbortError") return; // the user dismissed the picker
613
+ if (e && e.name === "SecurityError") {
614
+ showLanding("the writable folder picker is blocked here; browse read-only, or serve this page from localhost to edit");
615
+ return;
616
+ }
617
+ showLanding(String((e && e.message) || e));
618
+ });
619
+ }
620
+
621
+ function openHandle(h, name, manual) {
622
+ // A handle from the readwrite picker or a fresh drop already carries its
623
+ // grant, so learn what it has before prompting at all; only a stale
624
+ // remembered handle needs a request, and a request is never allowed to
625
+ // hang the app on a prompt the browser fails to show.
626
+ setStatus("checking permission…");
627
+ return grantedMode(h)
628
+ .then(function (mode) {
629
+ if (mode) return mode;
630
+ return raceMs(ensurePermission(h, "readwrite"), 12000)
631
+ .then(function (ok) { return ok ? "fs" : null; }, function () { return null; })
632
+ .then(function (m) {
633
+ if (m) return m;
634
+ return raceMs(ensurePermission(h, "read"), 12000).then(
635
+ function (ok) {
636
+ if (ok) return "browse";
637
+ throw new Error("permission to read the folder was declined");
638
+ },
639
+ function () {
640
+ throw new Error("the browser did not answer the permission request; try the read-only picker");
641
+ }
642
+ );
643
+ });
644
+ })
645
+ .then(function (mode) {
646
+ handle = h;
647
+ handleMode = mode;
648
+ setStatus("reading the folder…");
649
+ return buildFromFolder(function (n) {
650
+ if (n % 20 === 0) setStatus("reading the folder… " + n + " documents");
651
+ });
652
+ })
653
+ .then(function (data) {
654
+ if (!data.nodes.length) throw new Error("no markdown documents in this folder");
655
+ setStatus("drawing " + data.nodes.length + " documents…");
656
+ // Remember writable folders under a label the corpus itself provides,
657
+ // because every store is named ".canon" and the API never shows paths.
658
+ // A name the user typed stays exactly as typed.
659
+ var done = Promise.resolve();
660
+ if (handleMode === "fs" && !manual) {
661
+ var label = folderLabel(h, data);
662
+ if (name && name !== label) done = forgetFolder(name);
663
+ done = done.then(function () { return rememberFolder(label, h); });
664
+ }
665
+ return done.then(function () {
666
+ hideLanding();
667
+ AtlasApp.mount(data, fsBackend);
668
+ renderFoldersPanel();
669
+ });
670
+ })
671
+ .catch(function (e) { showLanding(String((e && e.message) || e)); });
672
+ }
673
+
674
+ /* The File System Access API never reveals a folder's path, so a remembered
675
+ ".canon" is told apart by what it holds: the configured corpus title when
676
+ canon-atlas.json names one, otherwise the hub document of the graph. */
677
+ function folderLabel(h, data) {
678
+ /* The configured title is the workspace's own name; without one, describe
679
+ the workspace by its most common top-level address segments, which read
680
+ like a directory listing rather than one arbitrary document's title. */
681
+ var mark = data.title || "";
682
+ if (!mark) {
683
+ var counts = {};
684
+ data.nodes.forEach(function (n) {
685
+ if (!n.exists || !n.address) return;
686
+ var seg = n.address.split("/")[0];
687
+ counts[seg] = (counts[seg] || 0) + 1;
688
+ });
689
+ mark = Object.keys(counts)
690
+ .sort(function (a, b) { return counts[b] - counts[a] || a.localeCompare(b); })
691
+ .slice(0, 3)
692
+ .join(" · ");
693
+ }
694
+ mark = mark.slice(0, 40);
695
+ return mark && mark !== h.name ? h.name + " · " + mark : h.name;
696
+ }
697
+
698
+ /* ── drop a folder anywhere on the page ──────────────────────────────────────
699
+ Dropping is its own door into the File System Access API: the drop gesture
700
+ grants the handle, no picker involved. Where the handle API is absent the
701
+ visible browse button already covers the folder, so the drop just points
702
+ at it instead of duplicating that path. */
703
+
704
+ function wireDrop() {
705
+ window.addEventListener("dragover", function (e) { e.preventDefault(); });
706
+ window.addEventListener("drop", function (e) {
707
+ e.preventDefault();
708
+ // A drag from a file manager can carry string items beside the file item.
709
+ var items = (e.dataTransfer && e.dataTransfer.items) || [];
710
+ var item = null;
711
+ for (var i = 0; i < items.length; i++) {
712
+ if (items[i].kind === "file") { item = items[i]; break; }
713
+ }
714
+ if (!item) return;
715
+ if (typeof item.getAsFileSystemHandle !== "function") {
716
+ showLanding("this browser cannot take a dropped folder; use the browse button instead");
717
+ return;
718
+ }
719
+ item.getAsFileSystemHandle()
720
+ .then(function (h) {
721
+ if (h && h.kind === "directory") return openHandle(h, h.name);
722
+ showLanding("drop a folder, not a file");
723
+ })
724
+ .catch(function (err) { showLanding(String((err && err.message) || err)); });
725
+ });
726
+ }
727
+
728
+ /* The folders panel behind the rail's folder icon: open or browse another
729
+ project, and reopen or forget remembered folders, without leaving the
730
+ corpus. Each tab still holds one project at a time. */
731
+ function renderFoldersPanel() {
732
+ var btn = document.getElementById("rail-folders");
733
+ var panel = document.getElementById("folders");
734
+ if (!btn || !panel) return;
735
+ btn.hidden = false;
736
+ while (panel.firstChild) panel.removeChild(panel.firstChild);
737
+ var canEdit = supported && !isFile;
738
+ var kind = el("p", "", "folders");
739
+ kind.className = "side-kind";
740
+ panel.appendChild(kind);
741
+ if (hostList) {
742
+ hostSection(panel, function (text) {
743
+ var head = el("p", "", text);
744
+ head.className = "side-sec";
745
+ return head;
746
+ }, renderFoldersPanel);
747
+ }
748
+ var open = el("button", CARD + "width:100%;" +
749
+ (hostList ? "margin-top:8px;color:#8a8775;" : "color:#9aaa3a;border-color:#4a5220;"),
750
+ canEdit ? "Open a folder…" : "Browse a folder… (read only)");
751
+ open.addEventListener("click", canEdit ? pickFolder : pickBrowse);
752
+ panel.appendChild(open);
753
+ if (canEdit) {
754
+ var alt = el("button", CARD + "width:100%;margin-top:8px;color:#8a8775;", "Browse read-only…");
755
+ alt.addEventListener("click", pickBrowse);
756
+ panel.appendChild(alt);
757
+ }
758
+ if (!canEdit) return; // remembered handles need the rich API to reopen
759
+ listFolders().then(function (folders) {
760
+ folders = folders.filter(function (f) { return f.handle; });
761
+ if (!folders.length) return;
762
+ var head = el("p", "", "recent");
763
+ head.className = "side-sec";
764
+ panel.appendChild(head);
765
+ folders.forEach(function (f) {
766
+ var row = el("div", "display:flex;gap:6px;margin-bottom:6px;align-items:stretch;");
767
+ var pick = el("button", CARD + "flex:1;padding:7px 10px;font-size:13px;min-width:0;overflow:hidden;text-overflow:ellipsis;", f.name);
768
+ pick.addEventListener("click", function () { openHandle(f.handle, f.name, f.manual); });
769
+ /* The browser never reveals a folder's path, so a hand-typed name is
770
+ the only way to see one; renamed rows keep their name forever. */
771
+ var ren = el("button", CARD + "color:#8a8775;padding:7px 10px;", "✎");
772
+ ren.title = "rename; a name you type here is kept as written";
773
+ ren.addEventListener("click", function () {
774
+ var input = el("input", CARD + "flex:1;padding:7px 10px;font-size:13px;min-width:0;");
775
+ input.value = f.name;
776
+ row.replaceChild(input, pick);
777
+ input.focus();
778
+ input.select();
779
+ var settled = false;
780
+ function commit() {
781
+ if (settled) return;
782
+ settled = true;
783
+ var next = input.value.trim();
784
+ if (!next || next === f.name) { renderFoldersPanel(); return; }
785
+ forgetFolder(f.name)
786
+ .then(function () { return rememberFolder(next, f.handle, true); })
787
+ .then(renderFoldersPanel);
788
+ }
789
+ input.addEventListener("keydown", function (ev) {
790
+ if (ev.key === "Enter") commit();
791
+ if (ev.key === "Escape") { settled = true; renderFoldersPanel(); }
792
+ });
793
+ input.addEventListener("blur", commit);
794
+ });
795
+ var forget = el("button", CARD + "color:#8a8775;padding:7px 10px;", "×");
796
+ forget.title = "forget this folder";
797
+ forget.addEventListener("click", function () { forgetFolder(f.name).then(renderFoldersPanel); });
798
+ row.appendChild(pick);
799
+ row.appendChild(ren);
800
+ row.appendChild(forget);
801
+ panel.appendChild(row);
802
+ });
803
+ });
804
+ }
805
+
806
+ /* When the page comes from the local `canon-atlas open` host, every atlas tab
807
+ beats it so the host can tell an idle day from a working one and exit
808
+ itself instead of lingering forever. Anywhere else this never runs. */
809
+ function heartbeat() {
810
+ if (location.protocol !== "http:" && location.protocol !== "https:") return;
811
+ var name = location.hostname;
812
+ if (name !== "127.0.0.1" && name !== "localhost" && name !== "[::1]") return;
813
+ var beat = function () { fetch("/ping", { cache: "no-store" }).catch(function () {}); };
814
+ beat();
815
+ setInterval(beat, 30000);
816
+ }
817
+
818
+ renderFoldersPanel();
819
+ wireDrop();
820
+ heartbeat();
821
+ showLanding();
822
+ /* The host answers in a round trip; when it does, the landing and the
823
+ panel repaint with the path door and the remembered workspaces. */
824
+ refreshHost().then(function () {
825
+ if (!hostList) return;
826
+ renderFoldersPanel();
827
+ if (landing) showLanding();
828
+ });
829
+ })();