@nanocollective/roster 0.1.0-alpha.1
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 +129 -0
- package/dist/cli.js +5679 -0
- package/docs/README.md +99 -0
- package/docs/agents.md +163 -0
- package/docs/architecture.md +121 -0
- package/docs/commands.md +223 -0
- package/docs/concepts.md +112 -0
- package/docs/cost.md +61 -0
- package/docs/developing.md +147 -0
- package/docs/doctor-codes.md +74 -0
- package/docs/export.md +113 -0
- package/docs/extending.md +97 -0
- package/docs/getting-started.md +134 -0
- package/docs/hosting.md +72 -0
- package/docs/manual-steps.md +163 -0
- package/docs/memory.md +71 -0
- package/docs/org-yaml.md +143 -0
- package/docs/portal.md +342 -0
- package/docs/prompts.md +133 -0
- package/docs/security.md +122 -0
- package/docs/session-workflow.md +112 -0
- package/docs/staff-yaml.md +163 -0
- package/docs/troubleshooting.md +189 -0
- package/docs/upgrading.md +83 -0
- package/docs/writing-a-charter.md +83 -0
- package/package.json +60 -0
- package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +33 -0
- package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +65 -0
- package/templates/brain/.github/workflows/%%STAFF%%-pr-mention.yaml +50 -0
- package/templates/brain/CHARTER.md +49 -0
- package/templates/brain/README.md +18 -0
- package/templates/brain/drafts/README.md +7 -0
- package/templates/brain/log/decisions.md +6 -0
- package/templates/brain/memory/INDEX.md +28 -0
- package/templates/brain/staff.yaml +44 -0
- package/templates/brain/strategy/README.md +7 -0
- package/templates/briefs/amend.md +60 -0
- package/templates/briefs/charter.md +47 -0
- package/templates/briefs/discover.md +61 -0
- package/templates/briefs/voice.md +53 -0
- package/templates/ops/.github/workflows/session.yaml +333 -0
- package/templates/ops/agents.mjs +143 -0
- package/templates/ops/compose.mjs +333 -0
- package/templates/ops/org/guardrails.md +14 -0
- package/templates/ops/org/operating.md +82 -0
- package/templates/ops/org/voice.md +40 -0
- package/templates/ops/prompts/_identity.md +14 -0
- package/templates/ops/prompts/_paths.md +15 -0
- package/templates/ops/prompts/daily.md +82 -0
- package/templates/ops/prompts/mention.md +53 -0
- package/templates/ops/prompts/pr-mention.md +57 -0
- package/templates/ops/runner-plan.mjs +65 -0
- package/templates/portal/css/base.css +104 -0
- package/templates/portal/css/brain.css +106 -0
- package/templates/portal/css/diff.css +28 -0
- package/templates/portal/css/graph.css +34 -0
- package/templates/portal/css/health.css +41 -0
- package/templates/portal/css/inbox.css +79 -0
- package/templates/portal/css/layout.css +98 -0
- package/templates/portal/css/markdown.css +54 -0
- package/templates/portal/css/setup.css +106 -0
- package/templates/portal/index.html +55 -0
- package/templates/portal/js/api.js +74 -0
- package/templates/portal/js/app.js +282 -0
- package/templates/portal/js/dialog.js +70 -0
- package/templates/portal/js/dom.js +106 -0
- package/templates/portal/js/icons.js +94 -0
- package/templates/portal/js/md.js +386 -0
- package/templates/portal/js/refresh.js +59 -0
- package/templates/portal/js/router.js +20 -0
- package/templates/portal/js/state.js +160 -0
- package/templates/portal/js/textdiff.js +96 -0
- package/templates/portal/js/views/app.js +128 -0
- package/templates/portal/js/views/brain.js +260 -0
- package/templates/portal/js/views/changed.js +157 -0
- package/templates/portal/js/views/checklist.js +87 -0
- package/templates/portal/js/views/docs.js +84 -0
- package/templates/portal/js/views/files.js +95 -0
- package/templates/portal/js/views/graph.js +436 -0
- package/templates/portal/js/views/health.js +158 -0
- package/templates/portal/js/views/inbox.js +549 -0
- package/templates/portal/js/views/memory.js +135 -0
- package/templates/portal/js/views/org.js +175 -0
- package/templates/portal/js/views/paste.js +142 -0
- package/templates/portal/js/views/prompt.js +412 -0
- package/templates/portal/js/views/repos.js +92 -0
- package/templates/portal/js/views/setup.js +344 -0
- package/templates/portal/js/views/staff.js +290 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/* What this agent learned and forgot, and the diff that did it. */
|
|
2
|
+
|
|
3
|
+
import { diffUrl } from "../api.js";
|
|
4
|
+
import { el, esc, markCurrent } from "../dom.js";
|
|
5
|
+
import { iconHTML } from "../icons.js";
|
|
6
|
+
import { S, staff, writeHash } from "../state.js";
|
|
7
|
+
|
|
8
|
+
export function viewChanged(m) {
|
|
9
|
+
const s = staff();
|
|
10
|
+
m.append(el("h1", { textContent: s.handle.toUpperCase() + " · what changed" }));
|
|
11
|
+
m.append(el("p", { className: "sub", textContent:
|
|
12
|
+
"What this agent learned and forgot. Click any row to see the diff that did it." }));
|
|
13
|
+
|
|
14
|
+
const filter = el("input", { type: "search", placeholder: "Filter by slug or commit…", value: S.changedQuery });
|
|
15
|
+
const only = el("select");
|
|
16
|
+
only.append(el("option", { value: "", textContent: "Everything" }),
|
|
17
|
+
el("option", { value: "added", textContent: "Learned" }),
|
|
18
|
+
el("option", { value: "removed", textContent: "Forgotten" }));
|
|
19
|
+
only.value = S.changedFilter;
|
|
20
|
+
m.append(el("div", { className: "row", style: "margin-bottom:16px" }, [filter, only]));
|
|
21
|
+
|
|
22
|
+
const split = el("div", { className: "split" });
|
|
23
|
+
const list = el("div", { className: "tree" });
|
|
24
|
+
const viewer = el("div", { className: "viewer" });
|
|
25
|
+
split.append(list, viewer);
|
|
26
|
+
m.append(split);
|
|
27
|
+
viewer.append(el("p", { className: "empty", textContent: "Pick a change." }));
|
|
28
|
+
|
|
29
|
+
filter.oninput = () => { S.changedQuery = filter.value; writeHash(false); paint(); };
|
|
30
|
+
only.onchange = () => { S.changedFilter = only.value; paint(); };
|
|
31
|
+
paint();
|
|
32
|
+
|
|
33
|
+
function paint() {
|
|
34
|
+
const q = S.changedQuery.trim().toLowerCase();
|
|
35
|
+
list.replaceChildren();
|
|
36
|
+
|
|
37
|
+
const facts = s.factsChanged.filter((c) =>
|
|
38
|
+
(!S.changedFilter || c.change === S.changedFilter) &&
|
|
39
|
+
(!q || (c.slug + " " + c.sha).toLowerCase().includes(q)));
|
|
40
|
+
|
|
41
|
+
if (facts.length) {
|
|
42
|
+
const byDay = new Map();
|
|
43
|
+
for (const c of facts) {
|
|
44
|
+
const d = c.date.slice(0, 10);
|
|
45
|
+
if (!byDay.has(d)) byDay.set(d, []);
|
|
46
|
+
byDay.get(d).push(c);
|
|
47
|
+
}
|
|
48
|
+
for (const [day, items] of byDay) {
|
|
49
|
+
list.append(el("div", { className: "tdir", textContent: day }));
|
|
50
|
+
for (const c of items) {
|
|
51
|
+
const b = el("button", { className: "tfile" });
|
|
52
|
+
b.innerHTML = '<span class="chg ' + (c.change === "added" ? "ok" : "err") + '">' +
|
|
53
|
+
iconHTML(c.change === "added" ? "check" : "dash") + '</span><span class="t">' +
|
|
54
|
+
esc(c.slug) + "</span><i>" + esc(c.sha.slice(0, 7)) + "</i>";
|
|
55
|
+
b.onclick = () => { markCurrent(list, b); showDiff(viewer, s, c.sha, "memory/INDEX.md", c.slug); };
|
|
56
|
+
list.append(b);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const commits = s.recentCommits.filter((c) => !q || (c.subject + " " + c.sha).toLowerCase().includes(q));
|
|
62
|
+
if (commits.length && !S.changedFilter) {
|
|
63
|
+
list.append(el("div", { className: "tdir", textContent: "commits" }));
|
|
64
|
+
for (const c of commits.slice(0, 25)) {
|
|
65
|
+
const b = el("button", { className: "tfile", title: c.subject });
|
|
66
|
+
b.append(el("span", { className: "t", textContent: c.subject }));
|
|
67
|
+
b.append(el("i", { textContent: c.sha.slice(0, 7) }));
|
|
68
|
+
b.onclick = () => { markCurrent(list, b); showDiff(viewer, s, c.sha, "", null); };
|
|
69
|
+
list.append(b);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!list.children.length) list.append(el("p", { className: "empty", textContent: "Nothing matches." }));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function showDiff(viewer, s, sha, path, highlight) {
|
|
78
|
+
viewer.replaceChildren(el("p", { className: "empty", textContent: "Loading diff…" }));
|
|
79
|
+
const res = await fetch(diffUrl(s.dir, sha, path));
|
|
80
|
+
if (!res.ok) { viewer.replaceChildren(el("p", { className: "empty err", textContent: "Could not load that diff." })); return; }
|
|
81
|
+
const text = await res.text();
|
|
82
|
+
|
|
83
|
+
// The first line is author\x1fdate\x1fsubject, prepended by the endpoint.
|
|
84
|
+
const nl = text.indexOf("\n");
|
|
85
|
+
const [author, date, subject] = text.slice(0, nl).split("\x1f");
|
|
86
|
+
const body = text.slice(nl + 1);
|
|
87
|
+
|
|
88
|
+
const head = el("div", { style: "margin-bottom:12px" }, [
|
|
89
|
+
el("div", { style: "font-weight:600", textContent: subject ?? sha }),
|
|
90
|
+
el("div", { className: "meta", textContent: sha + " · " + new Date(date).toLocaleString() + " · " + author }),
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
viewer.replaceChildren(head, ...renderDiff(body, highlight));
|
|
94
|
+
const focus = viewer.querySelector(".dfocus");
|
|
95
|
+
if (focus && focus.scrollIntoView) focus.scrollIntoView({ block: "center" });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A unified diff as something you can read: one block per file, coloured rows, and the two
|
|
100
|
+
* line-number gutters git leaves out. The noise git puts between files — index lines, mode
|
|
101
|
+
* changes, the +++/--- pair — is dropped, because the filename is already the heading.
|
|
102
|
+
*/
|
|
103
|
+
export function renderDiff(body, highlight) {
|
|
104
|
+
const blocks = [];
|
|
105
|
+
let block = null, oldNo = 0, newNo = 0;
|
|
106
|
+
|
|
107
|
+
const start = (name) => {
|
|
108
|
+
block = { name, added: 0, removed: 0, rows: el("div", { className: "diff" }) };
|
|
109
|
+
blocks.push(block);
|
|
110
|
+
};
|
|
111
|
+
const push = (cls, a, b, text) => {
|
|
112
|
+
if (!block) start("");
|
|
113
|
+
const r = el("div", { className: "dline" + (cls ? " " + cls : "") });
|
|
114
|
+
r.append(el("span", { className: "ln", textContent: a }),
|
|
115
|
+
el("span", { className: "ln", textContent: b }),
|
|
116
|
+
el("span", { className: "tx", textContent: text }));
|
|
117
|
+
// The fact whose change you clicked, marked so the scroll lands on it.
|
|
118
|
+
if (highlight && text.includes("`" + highlight + "`")) r.classList.add("dfocus");
|
|
119
|
+
block.rows.append(r);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
for (const line of body.split("\n")) {
|
|
123
|
+
if (line.startsWith("diff --git")) {
|
|
124
|
+
const m = /\sb\/(.+)$/.exec(line);
|
|
125
|
+
start(m ? m[1] : line.replace(/^diff --git\s*/, ""));
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (/^(index |--- |\+\+\+ |new file|deleted file|similarity |rename |old mode|new mode|Binary )/.test(line)) continue;
|
|
129
|
+
|
|
130
|
+
const hunk = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/.exec(line);
|
|
131
|
+
if (hunk) {
|
|
132
|
+
oldNo = Number(hunk[1]);
|
|
133
|
+
newNo = Number(hunk[2]);
|
|
134
|
+
push("dhunk", "", "", (hunk[3] || "").trim() || "@@");
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (!line) continue; // a blank context line is " ", not ""
|
|
138
|
+
if (line.startsWith("+")) { push("dadd", "", String(newNo++), line.slice(1)); block.added++; continue; }
|
|
139
|
+
if (line.startsWith("-")) { push("ddel", String(oldNo++), "", line.slice(1)); block.removed++; continue; }
|
|
140
|
+
if (line.startsWith("\\")) { push("dmeta", "", "", line); continue; }
|
|
141
|
+
push("", String(oldNo++), String(newNo++), line.slice(1));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!blocks.length) {
|
|
145
|
+
return [el("p", { className: "dempty", textContent: "This commit changed nothing in that file." })];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return blocks.map((b) => {
|
|
149
|
+
const wrap = el("div", { className: "dblock" });
|
|
150
|
+
const h = el("div", { className: "dfile" });
|
|
151
|
+
h.innerHTML = "<b>" + esc(b.name || "changes") + "</b>" +
|
|
152
|
+
'<span class="p">+' + b.added + "</span>" +
|
|
153
|
+
'<span class="m">−' + b.removed + "</span>";
|
|
154
|
+
wrap.append(h, b.rows);
|
|
155
|
+
return wrap;
|
|
156
|
+
});
|
|
157
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/* What is still wrong, and one button that hands it all to a coding agent.
|
|
2
|
+
*
|
|
3
|
+
* Derived from `roster doctor` every time it is drawn. Nothing here remembers that you clicked
|
|
4
|
+
* something: setup takes days, not minutes, and a stored step counter would disagree with the
|
|
5
|
+
* world about five minutes after it was written. */
|
|
6
|
+
|
|
7
|
+
import { getDoctor, getFix } from "../api.js";
|
|
8
|
+
import { el, toClipboard } from "../dom.js";
|
|
9
|
+
|
|
10
|
+
const LEVEL_ORDER = { fail: 0, warn: 1, ok: 2 };
|
|
11
|
+
|
|
12
|
+
export async function checklist(host, opts = {}) {
|
|
13
|
+
host.replaceChildren(el("p", { className: "sub", textContent: "Checking…" }));
|
|
14
|
+
|
|
15
|
+
let report;
|
|
16
|
+
try {
|
|
17
|
+
report = await getDoctor(opts.offline);
|
|
18
|
+
} catch (err) {
|
|
19
|
+
host.replaceChildren(el("p", { className: "err", textContent: String(err.message || err) }));
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const findings = (report.findings ?? []).slice().sort(
|
|
24
|
+
(a, b) => (LEVEL_ORDER[a.level] ?? 3) - (LEVEL_ORDER[b.level] ?? 3),
|
|
25
|
+
);
|
|
26
|
+
const bad = findings.filter((f) => f.level !== "ok");
|
|
27
|
+
|
|
28
|
+
host.replaceChildren();
|
|
29
|
+
host.append(
|
|
30
|
+
el("p", {
|
|
31
|
+
className: "sub",
|
|
32
|
+
textContent: bad.length
|
|
33
|
+
? `${bad.filter((f) => f.level === "fail").length} failing, ${bad.filter((f) => f.level === "warn").length} to look at.` +
|
|
34
|
+
(report.online ? "" : " Local checks only.")
|
|
35
|
+
: "Everything doctor checks is clean." + (report.online ? "" : " Local checks only."),
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
for (const f of bad) {
|
|
40
|
+
const row = el("div", { className: "check " + f.level });
|
|
41
|
+
row.append(
|
|
42
|
+
el("span", { className: "checkmark", textContent: f.level === "fail" ? "✗" : "!" }),
|
|
43
|
+
el("div", {}, [
|
|
44
|
+
el("b", { textContent: f.title }),
|
|
45
|
+
el("span", { className: "meta", textContent: f.scope + " · " + f.id }),
|
|
46
|
+
...(f.fix ? [el("p", { textContent: f.fix })] : []),
|
|
47
|
+
]),
|
|
48
|
+
);
|
|
49
|
+
host.append(row);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* The isitagentready move: the findings as instructions, for the agent you already have
|
|
53
|
+
pointed at this workspace. What only a person can do is deliberately not in that text. */
|
|
54
|
+
const copy = el("button", { className: "btn primary", textContent: "Copy all instructions" });
|
|
55
|
+
const note = el("span", { className: "meta" });
|
|
56
|
+
copy.onclick = async () => {
|
|
57
|
+
copy.disabled = true;
|
|
58
|
+
copy.textContent = "Collecting…";
|
|
59
|
+
try {
|
|
60
|
+
const data = await getFix(opts.offline);
|
|
61
|
+
const mine = (data.items ?? []).filter((i) => i.who === "agent").length;
|
|
62
|
+
const yours = (data.items ?? []).filter((i) => i.who === "human").length;
|
|
63
|
+
await toClipboard(data.text, copy, "Copied");
|
|
64
|
+
note.textContent =
|
|
65
|
+
`${mine} for your agent` + (yours ? `, ${yours} only you can do (listed, not asked for)` : "");
|
|
66
|
+
} catch (err) {
|
|
67
|
+
note.textContent = String(err.message || err);
|
|
68
|
+
} finally {
|
|
69
|
+
copy.disabled = false;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const again = el("button", { className: "btn", textContent: "Check again" });
|
|
74
|
+
again.onclick = () => checklist(host, opts);
|
|
75
|
+
|
|
76
|
+
host.append(el("div", { className: "row" }, [copy, again, note]));
|
|
77
|
+
if (bad.length) {
|
|
78
|
+
host.append(
|
|
79
|
+
el("p", {
|
|
80
|
+
className: "sub",
|
|
81
|
+
textContent:
|
|
82
|
+
"Paste it into Cursor, Claude Code or anything else that can edit files here, then " +
|
|
83
|
+
"check again. The ids above should be gone.",
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/* The framework's own documentation, read where you already are. Same renderer as a brain
|
|
2
|
+
file, so a link between pages behaves like a link between files rather than a dead end. */
|
|
3
|
+
|
|
4
|
+
import { getDoc, getDocs } from "../api.js";
|
|
5
|
+
import { el, slug } from "../dom.js";
|
|
6
|
+
import { mdlite } from "../md.js";
|
|
7
|
+
import { S, writeHash } from "../state.js";
|
|
8
|
+
|
|
9
|
+
export function viewDocs(m) {
|
|
10
|
+
m.append(el("h1", { textContent: "Docs" }));
|
|
11
|
+
m.append(
|
|
12
|
+
el("p", {
|
|
13
|
+
className: "sub",
|
|
14
|
+
textContent:
|
|
15
|
+
"How roster works, what it will not do for you, and every trap worth knowing about.",
|
|
16
|
+
}),
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
const split = el("div", { className: "split" });
|
|
20
|
+
const list = el("div", { className: "tree" });
|
|
21
|
+
const viewer = el("div", { className: "viewer" });
|
|
22
|
+
split.append(list, viewer);
|
|
23
|
+
m.append(split);
|
|
24
|
+
|
|
25
|
+
if (S.docs) paint();
|
|
26
|
+
else {
|
|
27
|
+
list.append(el("p", { className: "empty", textContent: "Loading…" }));
|
|
28
|
+
getDocs()
|
|
29
|
+
.then((d) => { S.docs = d; paint(); })
|
|
30
|
+
.catch((e) => list.replaceChildren(el("p", { className: "empty err", textContent: e.message })));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function paint() {
|
|
34
|
+
list.replaceChildren();
|
|
35
|
+
for (const d of S.docs) {
|
|
36
|
+
const b = el("button", { className: "tfile tmem", title: d.file });
|
|
37
|
+
b.dataset.key = d.file;
|
|
38
|
+
b.append(el("span", { className: "t", textContent: d.title }));
|
|
39
|
+
b.setAttribute("aria-current", String((S.openDoc ?? S.docs[0].file) === d.file));
|
|
40
|
+
b.onclick = () => open(d.file);
|
|
41
|
+
list.append(b);
|
|
42
|
+
}
|
|
43
|
+
show(S.openDoc ?? S.docs[0].file);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function open(file) {
|
|
47
|
+
S.openDoc = file;
|
|
48
|
+
writeHash(false);
|
|
49
|
+
for (const o of list.querySelectorAll(".tfile")) {
|
|
50
|
+
o.setAttribute("aria-current", String(o.dataset.key === file));
|
|
51
|
+
}
|
|
52
|
+
show(file);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function show(file) {
|
|
56
|
+
viewer.replaceChildren(el("p", { className: "empty", textContent: "Loading…" }));
|
|
57
|
+
let text;
|
|
58
|
+
try {
|
|
59
|
+
text = await getDoc(file);
|
|
60
|
+
} catch (e) {
|
|
61
|
+
viewer.replaceChildren(el("p", { className: "empty err", textContent: e.message }));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const doc = el("div", { className: "md doc" });
|
|
65
|
+
// The pages carry YAML frontmatter for the collective's docs site. It is metadata, not
|
|
66
|
+
// content, so it is dropped rather than rendered as a rule and a stray paragraph.
|
|
67
|
+
doc.innerHTML = mdlite(text.replace(/^---\n[\s\S]*?\n---\n/, ""), { docs: true });
|
|
68
|
+
doc.addEventListener("click", (e) => {
|
|
69
|
+
const link = e.target.closest?.("[data-doc]");
|
|
70
|
+
if (!link) return;
|
|
71
|
+
e.preventDefault();
|
|
72
|
+
const [page, anchor] = link.dataset.doc.split("#");
|
|
73
|
+
if (page && S.docs.some((d) => d.file === page)) open(page);
|
|
74
|
+
if (anchor) {
|
|
75
|
+
const to = [...viewer.querySelectorAll("h1,h2,h3,h4")].find(
|
|
76
|
+
(h) => slug(h.textContent) === anchor,
|
|
77
|
+
);
|
|
78
|
+
if (to?.scrollIntoView) to.scrollIntoView({ block: "start" });
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
viewer.replaceChildren(doc);
|
|
82
|
+
viewer.scrollTop = 0;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/* Rendering one file out of a brain: markdown, image, CSV, JSON, or source. */
|
|
2
|
+
|
|
3
|
+
import { el, kb } from "../dom.js";
|
|
4
|
+
import { fileUrl, getFile } from "../api.js";
|
|
5
|
+
import { mdlite } from "../md.js";
|
|
6
|
+
|
|
7
|
+
const IMG = ["png", "jpg", "jpeg", "gif", "svg", "webp", "ico"];
|
|
8
|
+
|
|
9
|
+
export async function showFile(viewer, s, f, pick) {
|
|
10
|
+
viewer.replaceChildren(el("p", { className: "empty", textContent: "Loading…" }));
|
|
11
|
+
const path = s.dir + "/" + f.path;
|
|
12
|
+
const head = el("div", { className: "meta", style: "margin-bottom:12px" });
|
|
13
|
+
/* A file the export did not walk (the charter, the manifest) has no size on it, so the
|
|
14
|
+
header says what it knows and fills the rest in once the text arrives. */
|
|
15
|
+
const stamp = (bytes) =>
|
|
16
|
+
f.path + (bytes ? " · " + kb(bytes) : "") +
|
|
17
|
+
(f.bytes ? " · " + new Date(f.modified).toLocaleDateString() : "");
|
|
18
|
+
head.textContent = stamp(f.bytes);
|
|
19
|
+
|
|
20
|
+
if (IMG.includes(f.ext)) {
|
|
21
|
+
viewer.replaceChildren(head, el("img", { src: fileUrl(path), alt: f.path }));
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const text = await getFile(path);
|
|
26
|
+
head.textContent = stamp(f.bytes || text.length);
|
|
27
|
+
if (f.ext === "csv") {
|
|
28
|
+
viewer.replaceChildren(head, csvTable(text));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// A brain is mostly markdown, and reading it as source was the thing that made the file
|
|
33
|
+
// tree feel like a worse GitHub rather than a better one.
|
|
34
|
+
if (f.ext === "md" || f.ext === "markdown") {
|
|
35
|
+
const dir = f.path.includes("/") ? f.path.slice(0, f.path.lastIndexOf("/") + 1) : "";
|
|
36
|
+
const doc = el("div", { className: "md doc" });
|
|
37
|
+
doc.innerHTML = mdlite(text, { repo: s.brain, file: { dir, staffDir: s.dir } });
|
|
38
|
+
doc.addEventListener("click", (e) => {
|
|
39
|
+
const link = e.target.closest?.("[data-file]");
|
|
40
|
+
if (!link) return;
|
|
41
|
+
e.preventDefault();
|
|
42
|
+
pick?.(link.dataset.file);
|
|
43
|
+
});
|
|
44
|
+
viewer.replaceChildren(head, doc);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (f.ext === "json") {
|
|
49
|
+
let pretty = text;
|
|
50
|
+
try {
|
|
51
|
+
pretty = JSON.stringify(JSON.parse(text), null, 2);
|
|
52
|
+
} catch {
|
|
53
|
+
/* malformed: show it as it is */
|
|
54
|
+
}
|
|
55
|
+
viewer.replaceChildren(head, el("pre", { className: "code", textContent: pretty }));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
viewer.replaceChildren(head, el("pre", { className: "code", textContent: text }));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function csvTable(text) {
|
|
63
|
+
const rows = text.trim().split("\n").slice(0, 300).map((r) => r.split(","));
|
|
64
|
+
const t = el("table");
|
|
65
|
+
const [head, ...body] = rows;
|
|
66
|
+
t.append(el("tr", {}, (head ?? []).map((h) => el("th", { textContent: h }))));
|
|
67
|
+
for (const r of body) t.append(el("tr", {}, r.map((c) => el("td", { textContent: c }))));
|
|
68
|
+
return t;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function showGallery(viewer, s, surface) {
|
|
72
|
+
const imgs = surface.files.filter((f) => IMG.includes(f.ext));
|
|
73
|
+
if (!imgs.length) {
|
|
74
|
+
viewer.replaceChildren(el("p", { className: "empty", textContent: "No images here." }));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const g = el("div", { className: "gallery" });
|
|
78
|
+
for (const f of imgs.slice(0, 200)) {
|
|
79
|
+
const fig = el("figure");
|
|
80
|
+
const img = el("img", {
|
|
81
|
+
src: fileUrl(s.dir + "/" + f.path),
|
|
82
|
+
loading: "lazy",
|
|
83
|
+
alt: f.path,
|
|
84
|
+
});
|
|
85
|
+
img.onclick = () => showFile(viewer, s, f);
|
|
86
|
+
fig.append(img, el("figcaption", { textContent: f.path.split("/").pop() }));
|
|
87
|
+
g.append(fig);
|
|
88
|
+
}
|
|
89
|
+
viewer.replaceChildren(
|
|
90
|
+
el("div", { className: "meta", style: "margin-bottom:12px" }, [
|
|
91
|
+
imgs.length + " images in " + surface.path,
|
|
92
|
+
]),
|
|
93
|
+
g,
|
|
94
|
+
);
|
|
95
|
+
}
|