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.
- package/LICENSE +21 -0
- package/README.md +103 -0
- package/package.json +46 -0
- package/src/build.mjs +37 -0
- package/src/cli.mjs +120 -0
- package/src/clusters.mjs +11 -0
- package/src/config.mjs +42 -0
- package/src/embed.mjs +179 -0
- package/src/graph.mjs +11 -0
- package/src/markdown.mjs +10 -0
- package/src/page.mjs +140 -0
- package/src/scan.mjs +37 -0
- package/src/serve.mjs +380 -0
- package/src/ui/app.js +1505 -0
- package/src/ui/labels.cjs +38 -0
- package/src/ui/pipeline.cjs +867 -0
- package/src/ui/resolve.cjs +104 -0
- package/src/ui/shell.js +829 -0
- package/src/ui/style.css +558 -0
- package/vendor/LICENSE.d3 +17 -0
- package/vendor/d3.v7.min.js +2 -0
package/src/ui/shell.js
ADDED
|
@@ -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
|
+
})();
|