@mythicalos/ui-core 0.2.2 → 0.3.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/dist/index.d.ts +6 -0
- package/dist/index.js +23 -0
- package/dist/logic/file-explorer.d.ts +374 -0
- package/dist/logic/file-explorer.js +624 -0
- package/dist/logic/popover.d.ts +242 -0
- package/dist/logic/popover.js +303 -0
- package/dist/logic/queue.d.ts +142 -0
- package/dist/logic/queue.js +143 -0
- package/dist/logic/sendbar.d.ts +105 -0
- package/dist/logic/sendbar.js +136 -0
- package/dist/logic/session-card.d.ts +263 -0
- package/dist/logic/session-card.js +443 -0
- package/dist/logic/terminal.d.ts +222 -0
- package/dist/logic/terminal.js +264 -0
- package/package.json +1 -1
- package/styles.css +652 -0
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
// @mythicalos/ui-core — the file-explorer / markdown-preview derivations
|
|
2
|
+
// (ds/components-file-explorer.html). Framework-agnostic: tree flattening, node classification,
|
|
3
|
+
// path/breadcrumb composition, git marks, size/time formatting, the honest-state classification and
|
|
4
|
+
// every class string the bindings render. ZERO preact/react — both bindings import from here so they
|
|
5
|
+
// can never drift.
|
|
6
|
+
//
|
|
7
|
+
// ── WHAT THIS COMPONENT IS ────────────────────────────────────────────────────
|
|
8
|
+
// A READ-ONLY two-pane explorer: a rail (header + scope picker + lazy tree) and a preview pane
|
|
9
|
+
// (breadcrumb + size/mtime header + rendered body). Nothing here edits, moves, renames or deletes,
|
|
10
|
+
// and no derivation below emits an affordance that implies otherwise.
|
|
11
|
+
//
|
|
12
|
+
// ── THE TWO TREE MODES (the card's central distinction) ───────────────────────
|
|
13
|
+
// · "all-mounts" — the roots are the container's BIND MOUNTS, drawn mono + muted with the ⌂ glyph;
|
|
14
|
+
// the git repos ONE LEVEL DOWN are drawn accent-strong with the ⎇ glyph.
|
|
15
|
+
// · "project" — the selected project's REPOS ARE THE ROOTS (accent-strong, ⎇). A repo carries a
|
|
16
|
+
// `primary` badge within its project, and a repo shared by several projects reports how many
|
|
17
|
+
// reference it ("2 projects").
|
|
18
|
+
// These are not two skins of one tree: which nodes count as a repo differs, and so does the badge
|
|
19
|
+
// vocabulary. `classifyDirNode` is the single place that decides it.
|
|
20
|
+
//
|
|
21
|
+
// ── THE FOUR HONEST STATES ────────────────────────────────────────────────────
|
|
22
|
+
// `unavailable` · `empty` · `too-large` · `binary` are FIRST-CLASS OUTCOMES, not errors. Each gets
|
|
23
|
+
// its own distinct modifier class and its own sentence, and NONE of them is styled as a failure
|
|
24
|
+
// (see `honestNoteClass` — the tone is deliberately informational for all four). An unreadable
|
|
25
|
+
// directory and a binary file are things the filesystem legitimately contains; the component says so
|
|
26
|
+
// plainly instead of accusing the user of a fault.
|
|
27
|
+
export const HONEST_STATUSES = ["unavailable", "empty", "too-large", "binary"];
|
|
28
|
+
/** Whether a string names one of the four honest states. */
|
|
29
|
+
export function isHonestStatus(value) {
|
|
30
|
+
return typeof value === "string" && HONEST_STATUSES.includes(value);
|
|
31
|
+
}
|
|
32
|
+
// ── glyphs ────────────────────────────────────────────────────────────────────
|
|
33
|
+
/** The directory glyph per node kind. A plain sub-directory deliberately carries NO glyph: the
|
|
34
|
+
* chevron already renders "▸", and repeating it would draw "▸ ▸ name". The bindings still emit the
|
|
35
|
+
* (empty) glyph cell so every row's name stays on one vertical rule. */
|
|
36
|
+
export const NODE_GLYPH = {
|
|
37
|
+
mount: "⌂",
|
|
38
|
+
repo: "⎇",
|
|
39
|
+
dir: "",
|
|
40
|
+
};
|
|
41
|
+
/** The disclosure chevron — the card's ▾ / ▸. The WHOLE ROW is the hit target (the bindings render
|
|
42
|
+
* one button per row); this is the affordance, never a separate control. */
|
|
43
|
+
export const CHEVRON_OPEN = "▾";
|
|
44
|
+
export const CHEVRON_CLOSED = "▸";
|
|
45
|
+
export function chevronGlyph(open) {
|
|
46
|
+
return open ? CHEVRON_OPEN : CHEVRON_CLOSED;
|
|
47
|
+
}
|
|
48
|
+
/** The scope picker’s disclosure caret. Lives here, not in the bindings, so the two cannot drift. */
|
|
49
|
+
export const SCOPE_CARET = "⌄";
|
|
50
|
+
/** The scope-picker glyph for a mode: ⌂ for all-mounts, ◈ for a project. */
|
|
51
|
+
export function scopeGlyph(mode) {
|
|
52
|
+
return mode === "all-mounts" ? "⌂" : "◈";
|
|
53
|
+
}
|
|
54
|
+
/** A mono type glyph for a FILE row: `#` markdown · `{}` json · `·` anything else. */
|
|
55
|
+
export function fileGlyph(name) {
|
|
56
|
+
const lower = name.toLowerCase();
|
|
57
|
+
if (lower.endsWith(".md") || lower.endsWith(".markdown"))
|
|
58
|
+
return "#";
|
|
59
|
+
if (lower.endsWith(".json"))
|
|
60
|
+
return "{}";
|
|
61
|
+
return "·";
|
|
62
|
+
}
|
|
63
|
+
/** Whether a name should render as MARKDOWN rather than as plain mono text. */
|
|
64
|
+
export function isMarkdownName(name) {
|
|
65
|
+
const lower = name.toLowerCase();
|
|
66
|
+
return lower.endsWith(".md") || lower.endsWith(".markdown");
|
|
67
|
+
}
|
|
68
|
+
// ── path composition ──────────────────────────────────────────────────────────
|
|
69
|
+
/**
|
|
70
|
+
* A stable identity for one node: root key + relative path, joined on NUL.
|
|
71
|
+
*
|
|
72
|
+
* The join is unambiguous — and `splitNodeId` is an exact inverse — precisely WHEN neither field
|
|
73
|
+
* contains the separator. That is not assumed: `deriveFileTreeRows` screens every root key through
|
|
74
|
+
* `isUsableRootKey` and every entry name through `isUsableEntryName`, so no id the component emits
|
|
75
|
+
* can round-trip lossily. A caller building an id by hand from an UNSCREENED root key is outside
|
|
76
|
+
* that guarantee (a key containing the separator splits back into a different pair) — screen with
|
|
77
|
+
* `isUsableRootKey` first, exactly as the walk does.
|
|
78
|
+
*/
|
|
79
|
+
export const NODE_ID_SEP = "\u0000";
|
|
80
|
+
export function nodeId(rootKey, relPath) {
|
|
81
|
+
return `${rootKey}${NODE_ID_SEP}${relPath}`;
|
|
82
|
+
}
|
|
83
|
+
/** The inverse of `nodeId`, split at the FIRST NUL. A string without one is not a node id and
|
|
84
|
+
* yields null rather than a half-parsed pair. */
|
|
85
|
+
export function splitNodeId(id) {
|
|
86
|
+
const i = id.indexOf(NODE_ID_SEP);
|
|
87
|
+
if (i < 0)
|
|
88
|
+
return null;
|
|
89
|
+
return { rootKey: id.slice(0, i), relPath: id.slice(i + 1) };
|
|
90
|
+
}
|
|
91
|
+
/** Join a parent relative path with a child name (at a root, the bare name). */
|
|
92
|
+
export function childRelPath(relPath, name) {
|
|
93
|
+
return relPath.length > 0 ? `${relPath}/${name}` : name;
|
|
94
|
+
}
|
|
95
|
+
/** Split a relative path into its parent path and its own name
|
|
96
|
+
* (`"a/b/c"` ⇒ `{"a/b","c"}` · `"c"` ⇒ `{"","c"}`). */
|
|
97
|
+
export function parentRelPath(relPath) {
|
|
98
|
+
const cut = relPath.lastIndexOf("/");
|
|
99
|
+
return cut < 0 ? { parentRel: "", name: relPath } : { parentRel: relPath.slice(0, cut), name: relPath.slice(cut + 1) };
|
|
100
|
+
}
|
|
101
|
+
/** The ancestor DIRECTORIES of a relative file path, root-first and including the root itself:
|
|
102
|
+
* `"docs/api/x.md"` ⇒ `["", "docs", "docs/api"]`. */
|
|
103
|
+
export function ancestorRelPaths(relPath) {
|
|
104
|
+
const segs = relPath.split("/").filter((s) => s.length > 0);
|
|
105
|
+
const out = [""];
|
|
106
|
+
let cur = "";
|
|
107
|
+
for (let i = 0; i + 1 < segs.length; i++) {
|
|
108
|
+
cur = cur.length === 0 ? segs[i] : `${cur}/${segs[i]}`;
|
|
109
|
+
out.push(cur);
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
// ── breadcrumb ────────────────────────────────────────────────────────────────
|
|
114
|
+
/** The card's breadcrumb separator — a wide-spaced "›". */
|
|
115
|
+
export const BREADCRUMB_SEP = " › ";
|
|
116
|
+
/** Breadcrumb segments from a root label down through the path. The ROOT IS ALWAYS INCLUDED (the
|
|
117
|
+
* card: "Breadcrumb always includes the project root"), so a bare filename can never read as
|
|
118
|
+
* belonging to nowhere. */
|
|
119
|
+
export function breadcrumbSegments(rootLabel, relPath) {
|
|
120
|
+
return [rootLabel, ...relPath.split("/").filter((s) => s.length > 0)];
|
|
121
|
+
}
|
|
122
|
+
/** The breadcrumb as one string. */
|
|
123
|
+
export function buildBreadcrumb(rootLabel, relPath) {
|
|
124
|
+
return breadcrumbSegments(rootLabel, relPath).join(BREADCRUMB_SEP);
|
|
125
|
+
}
|
|
126
|
+
// ── node classification (the two modes' real difference) ──────────────────────
|
|
127
|
+
/** Classify a DIRECTORY node for the active mode.
|
|
128
|
+
*
|
|
129
|
+
* · project mode — depth 0 IS a repo (the card: the project's repos ARE the roots); deeper
|
|
130
|
+
* directories are plain directories.
|
|
131
|
+
* · all-mounts mode — depth 0 is a bind MOUNT; the level immediately beneath it is where git repos
|
|
132
|
+
* live, so depth 1 classifies as "repo"; deeper is a plain directory.
|
|
133
|
+
*
|
|
134
|
+
* `entryRepo` is the caller's own knowledge and always wins when supplied: a data source that can
|
|
135
|
+
* actually tell a repo from an ordinary folder should say so, and a depth-1 directory that is NOT a
|
|
136
|
+
* repo then renders as the plain directory it is. When it is undefined the card's structural rule
|
|
137
|
+
* above applies. Pure. */
|
|
138
|
+
export function classifyDirNode(mode, depth, entryRepo) {
|
|
139
|
+
if (mode === "project")
|
|
140
|
+
return depth === 0 ? "repo" : "dir";
|
|
141
|
+
if (depth === 0)
|
|
142
|
+
return "mount";
|
|
143
|
+
if (depth === 1)
|
|
144
|
+
return entryRepo === false ? "dir" : "repo";
|
|
145
|
+
return entryRepo === true ? "repo" : "dir";
|
|
146
|
+
}
|
|
147
|
+
/** The badges for a repo row — PROJECT MODE ONLY, because both facts are statements about project
|
|
148
|
+
* membership and neither is meaningful for a bind mount.
|
|
149
|
+
*
|
|
150
|
+
* · `primary` — this repo is its project's primary repo.
|
|
151
|
+
* · `N projects` — this repo is referenced by N (> 1) projects, i.e. it is shared.
|
|
152
|
+
*
|
|
153
|
+
* Both can hold at once (a shared repo that is also primary here), and both are then rendered: they
|
|
154
|
+
* are independent facts, and suppressing the sharing count would hide it exactly where it matters
|
|
155
|
+
* most. Pure. */
|
|
156
|
+
export function repoBadges(root, mode) {
|
|
157
|
+
if (mode !== "project")
|
|
158
|
+
return [];
|
|
159
|
+
const out = [];
|
|
160
|
+
if (root.primary === true)
|
|
161
|
+
out.push({ text: "primary", tone: "accent" });
|
|
162
|
+
const n = root.projectCount;
|
|
163
|
+
if (typeof n === "number" && Number.isFinite(n)) {
|
|
164
|
+
// Floor BEFORE the shared test, not after: a fractional 1.9 would otherwise pass `> 1` and then
|
|
165
|
+
// floor down to the nonsensical "1 projects" — a claim of sharing where there is none.
|
|
166
|
+
const count = Math.floor(n);
|
|
167
|
+
if (count > 1)
|
|
168
|
+
out.push({ text: `${count} projects`, tone: "muted" });
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
export function badgeClass(tone) {
|
|
173
|
+
return `my-files__badge my-files__badge--${tone}`;
|
|
174
|
+
}
|
|
175
|
+
// ── git marks ─────────────────────────────────────────────────────────────────
|
|
176
|
+
/** The tone for a mark, per the card's legend (`M` warn · `A` ok) extended to the marks a real
|
|
177
|
+
* status also yields. A RENAME reduces to one glyph whether it is staged or unstaged, so it takes
|
|
178
|
+
* the conservative `warn` rather than a false all-good green for still-uncommitted work. */
|
|
179
|
+
export function gitMarkTone(mark) {
|
|
180
|
+
if (mark === "M" || mark === "R")
|
|
181
|
+
return "warn";
|
|
182
|
+
if (mark === "A")
|
|
183
|
+
return "ok";
|
|
184
|
+
return "muted"; // D, ?
|
|
185
|
+
}
|
|
186
|
+
/** A human word for a mark — the accessible name, because a bare "M" is opaque to a screen reader. */
|
|
187
|
+
export function gitMarkLabel(mark) {
|
|
188
|
+
switch (mark) {
|
|
189
|
+
case "M":
|
|
190
|
+
return "modified";
|
|
191
|
+
case "A":
|
|
192
|
+
return "added";
|
|
193
|
+
case "D":
|
|
194
|
+
return "deleted";
|
|
195
|
+
case "R":
|
|
196
|
+
return "renamed";
|
|
197
|
+
default:
|
|
198
|
+
return "untracked";
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/** The mono 10px mark cell's class. */
|
|
202
|
+
export function gitMarkClass(mark) {
|
|
203
|
+
return `my-files__mark my-files__mark--${gitMarkTone(mark)}`;
|
|
204
|
+
}
|
|
205
|
+
export function previewBadge(state) {
|
|
206
|
+
if (state === null)
|
|
207
|
+
return null;
|
|
208
|
+
if (state.kind === "clean")
|
|
209
|
+
return { text: "unchanged", tone: "muted" };
|
|
210
|
+
return { text: gitMarkLabel(state.mark), tone: gitMarkTone(state.mark) };
|
|
211
|
+
}
|
|
212
|
+
export function previewBadgeClass(tone) {
|
|
213
|
+
return `my-files__pill my-files__pill--${tone}`;
|
|
214
|
+
}
|
|
215
|
+
// ── formatting (the card's size / mtime header) ───────────────────────────────
|
|
216
|
+
const BYTE_UNITS = ["KB", "MB", "GB", "TB", "PB"];
|
|
217
|
+
/** Human file size — "512 B", "14.2 KB". A negative or non-finite input reads "0 B" rather than
|
|
218
|
+
* rendering NaN. Pure. */
|
|
219
|
+
export function formatFileSize(bytes) {
|
|
220
|
+
const n = Number(bytes);
|
|
221
|
+
if (!Number.isFinite(n) || n < 0)
|
|
222
|
+
return "0 B";
|
|
223
|
+
if (n < 1024)
|
|
224
|
+
return `${Math.round(n)} B`;
|
|
225
|
+
let v = n / 1024;
|
|
226
|
+
let i = 0;
|
|
227
|
+
while (v >= 1024 && i < BYTE_UNITS.length - 1) {
|
|
228
|
+
v /= 1024;
|
|
229
|
+
i++;
|
|
230
|
+
}
|
|
231
|
+
return `${v.toFixed(1)} ${BYTE_UNITS[i] ?? "PB"}`;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Relative time — "2m ago". `now` is injected so this stays pure and testable. A future timestamp
|
|
235
|
+
* clamps to "0s ago" rather than rendering a negative age. Pure.
|
|
236
|
+
*
|
|
237
|
+
* Seconds-vs-milliseconds is disambiguated by magnitude, with the threshold at 1e11 rather than the
|
|
238
|
+
* 1e12 some sibling surfaces use. 1e12 ms is 2001-09-09, so under that rule ANY millisecond
|
|
239
|
+
* timestamp older than 2001 is mistaken for seconds, multiplied by 1000 into the far future, and
|
|
240
|
+
* then clamped — reporting a genuinely old file as "0s ago", i.e. a plausible-looking lie. At 1e11
|
|
241
|
+
* the seconds branch still covers every second-timestamp up to the year 5138, while millisecond
|
|
242
|
+
* timestamps are read correctly all the way back to 1973.
|
|
243
|
+
*/
|
|
244
|
+
export const SECONDS_THRESHOLD = 1e11;
|
|
245
|
+
export function formatRelativeTime(t, now = Date.now()) {
|
|
246
|
+
if (t === null || t === undefined)
|
|
247
|
+
return "—";
|
|
248
|
+
const ms = typeof t === "number" ? (t < SECONDS_THRESHOLD ? t * 1000 : t) : Date.parse(String(t));
|
|
249
|
+
if (!Number.isFinite(ms))
|
|
250
|
+
return String(t);
|
|
251
|
+
let s = Math.floor((now - ms) / 1000);
|
|
252
|
+
if (s < 0)
|
|
253
|
+
s = 0;
|
|
254
|
+
if (s < 60)
|
|
255
|
+
return `${s}s ago`;
|
|
256
|
+
if (s < 3600)
|
|
257
|
+
return `${Math.floor(s / 60)}m ago`;
|
|
258
|
+
if (s < 86400)
|
|
259
|
+
return `${Math.floor(s / 3600)}h ago`;
|
|
260
|
+
return `${Math.floor(s / 86400)}d ago`;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* The sentence for an honest state. Plain, specific, and never apologetic — each says what IS true,
|
|
264
|
+
* not what went wrong. `bytes` is woven in for the two size-bearing states when it is known.
|
|
265
|
+
*/
|
|
266
|
+
export function honestNoteText(status, surface = "file", bytes) {
|
|
267
|
+
const size = bytes === undefined ? null : formatFileSize(bytes);
|
|
268
|
+
switch (status) {
|
|
269
|
+
case "unavailable":
|
|
270
|
+
return surface === "dir" ? "This directory isn't available right now." : "This file isn't available right now.";
|
|
271
|
+
case "empty":
|
|
272
|
+
return surface === "dir" ? "This directory is empty." : "This file is empty.";
|
|
273
|
+
case "too-large":
|
|
274
|
+
return size === null ? "This file is too large to display here." : `This file is too large to display here (${size}).`;
|
|
275
|
+
case "binary":
|
|
276
|
+
return size === null ? "This is a binary file — no text preview." : `This is a binary file (${size}) — no text preview.`;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* The class for an honest-state note. Every one of the four gets its OWN modifier (so each is
|
|
281
|
+
* distinctly renderable and independently styleable) while the base class carries a single,
|
|
282
|
+
* deliberately INFORMATIONAL tone.
|
|
283
|
+
*
|
|
284
|
+
* There is no `--error` / `--warn` variant here, and that is the point: an unreadable directory, an
|
|
285
|
+
* empty file, an oversized file and a binary file are all ordinary things a filesystem contains. The
|
|
286
|
+
* component reports them; it does not accuse. Pure.
|
|
287
|
+
*/
|
|
288
|
+
export function honestNoteClass(status) {
|
|
289
|
+
return `my-files__note my-files__note--${status}`;
|
|
290
|
+
}
|
|
291
|
+
/** The note class for the tree's two NON-honest informational rows (an in-flight listing and a
|
|
292
|
+
* server-truncated one). Same neutral base — neither is a failure either. */
|
|
293
|
+
export function treeNoteClass(status) {
|
|
294
|
+
return `my-files__note my-files__note--${status}`;
|
|
295
|
+
}
|
|
296
|
+
/** A note row INSIDE the tree — the note class plus the depth indent, so a note lines up under the
|
|
297
|
+
* node it describes instead of sitting flush against the rail. The indent is composed here rather
|
|
298
|
+
* than inside `honestNoteClass` because the same honest-state classes are reused by the PREVIEW
|
|
299
|
+
* pane, where a tree indent would be meaningless. */
|
|
300
|
+
export function treeNoteRowClass(status, depth) {
|
|
301
|
+
const base = status === "loading" || status === "truncated" ? treeNoteClass(status) : honestNoteClass(status);
|
|
302
|
+
return `${base} ${indentClass(depth)}`;
|
|
303
|
+
}
|
|
304
|
+
/** The preview body's sentence for any state that has no content to render, or null for `text`
|
|
305
|
+
* (where the real content renders instead). */
|
|
306
|
+
export function previewNoteText(state) {
|
|
307
|
+
switch (state.status) {
|
|
308
|
+
case "loading":
|
|
309
|
+
return "Loading…";
|
|
310
|
+
case "unavailable":
|
|
311
|
+
return honestNoteText("unavailable", "file");
|
|
312
|
+
case "empty":
|
|
313
|
+
return honestNoteText("empty", "file");
|
|
314
|
+
case "too-large":
|
|
315
|
+
return honestNoteText("too-large", "file", state.bytes);
|
|
316
|
+
case "binary":
|
|
317
|
+
return honestNoteText("binary", "file", state.bytes);
|
|
318
|
+
case "text":
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
/** The preview body note's class, or null for `text`. */
|
|
323
|
+
export function previewNoteClass(state) {
|
|
324
|
+
if (state.status === "text")
|
|
325
|
+
return null;
|
|
326
|
+
if (state.status === "loading")
|
|
327
|
+
return treeNoteClass("loading");
|
|
328
|
+
return honestNoteClass(state.status);
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* The size/mtime the preview HEADER should show. The state's own values win where it carries them —
|
|
332
|
+
* they describe the bytes actually on screen, so the header can never show a stale size next to
|
|
333
|
+
* fresh content — and the caller's listing values (`fallback`) fill the rest.
|
|
334
|
+
*
|
|
335
|
+
* `too-large` / `binary` carry a size but no mtime (nothing was read), so the mtime falls back to
|
|
336
|
+
* the listing's. A value that is genuinely unknown stays `undefined` rather than becoming a zero the
|
|
337
|
+
* formatter would render as a real "0 B" / "56y ago". Pure.
|
|
338
|
+
*/
|
|
339
|
+
export function previewMeta(state, fallback = {}) {
|
|
340
|
+
if (state.status === "text") {
|
|
341
|
+
return { bytes: state.bytes ?? fallback.bytes, mtime: state.mtime ?? fallback.mtime };
|
|
342
|
+
}
|
|
343
|
+
if (state.status === "too-large" || state.status === "binary") {
|
|
344
|
+
return { bytes: state.bytes ?? fallback.bytes, mtime: fallback.mtime };
|
|
345
|
+
}
|
|
346
|
+
return { bytes: fallback.bytes, mtime: fallback.mtime };
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* How the preview should render the body — the SINGLE decision both bindings follow, so neither
|
|
350
|
+
* re-derives it and the two cannot drift:
|
|
351
|
+
* · "note" — there is no content to show (loading, or any of the honest states);
|
|
352
|
+
* · "markdown" — a markdown name AND a renderer to hand it to;
|
|
353
|
+
* · "plain" — everything else, including a markdown file with NO renderer supplied.
|
|
354
|
+
*
|
|
355
|
+
* `hasMarkdownRenderer` is part of the decision rather than a binding-side afterthought: markdown
|
|
356
|
+
* rendering is the caller's to provide, and without it the honest outcome is plain text — never the
|
|
357
|
+
* component inventing a renderer or injecting raw HTML.
|
|
358
|
+
*/
|
|
359
|
+
export function previewBodyMode(name, state, hasMarkdownRenderer = true) {
|
|
360
|
+
if (state.status !== "text")
|
|
361
|
+
return "note";
|
|
362
|
+
return isMarkdownName(name) && hasMarkdownRenderer ? "markdown" : "plain";
|
|
363
|
+
}
|
|
364
|
+
// ── row derivation ────────────────────────────────────────────────────────────
|
|
365
|
+
/** Depth-class ceiling — rows deeper than this share the last indent step rather than growing the
|
|
366
|
+
* stylesheet without bound. */
|
|
367
|
+
export const MAX_INDENT_DEPTH = 10;
|
|
368
|
+
/** Hard ceiling on TRAVERSAL depth. Unlike `MAX_INDENT_DEPTH` (a purely visual cap) this bounds the
|
|
369
|
+
* recursion itself, so no caller-supplied listing can walk the derivation into a stack overflow. */
|
|
370
|
+
export const MAX_TREE_DEPTH = 64;
|
|
371
|
+
/**
|
|
372
|
+
* Whether an entry name is structurally usable as one path segment.
|
|
373
|
+
*
|
|
374
|
+
* An EMPTY name is the dangerous one: `childRelPath("", "")` returns `""`, which is the ROOT's own
|
|
375
|
+
* relative path — so an expanded root listing itself as a nameless directory would re-enter the same
|
|
376
|
+
* node id forever. A name containing "/" or a NUL is rejected for the same class of reason: it would
|
|
377
|
+
* silently compose a path that addresses a different node than the one listed (and NUL would break
|
|
378
|
+
* `nodeId`'s separator outright). None of these can name a real filesystem entry.
|
|
379
|
+
*/
|
|
380
|
+
export function isUsableEntryName(name) {
|
|
381
|
+
if (typeof name !== "string" || name.length === 0)
|
|
382
|
+
return false;
|
|
383
|
+
if (name.includes("/") || name.includes(NODE_ID_SEP))
|
|
384
|
+
return false;
|
|
385
|
+
// "." and ".." are directory-table artefacts, never real entries. Composing them would yield a
|
|
386
|
+
// non-canonical path (`docs/../x.md`) that is handed back through the selection callbacks and used
|
|
387
|
+
// as a cache identity — a plausible-but-wrong address for a node that is really somewhere else.
|
|
388
|
+
if (name === "." || name === "..")
|
|
389
|
+
return false;
|
|
390
|
+
return true;
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Whether a ROOT key can serve as half of a node id. This is what ENFORCES the invariant `nodeId`
|
|
394
|
+
* documents: a key containing the NUL separator would make `splitNodeId` return a different pair
|
|
395
|
+
* than went in (`"a\0b"` + `""` splits back to `"a"` + `"b\0"`), silently corrupting the identity
|
|
396
|
+
* that `dirs` / `marks` are keyed by. Root keys are caller-supplied and generic, so the component
|
|
397
|
+
* checks rather than assumes.
|
|
398
|
+
*/
|
|
399
|
+
export function isUsableRootKey(key) {
|
|
400
|
+
return typeof key === "string" && key.length > 0 && !key.includes(NODE_ID_SEP);
|
|
401
|
+
}
|
|
402
|
+
/** The indent class for a depth. */
|
|
403
|
+
export function indentClass(depth) {
|
|
404
|
+
const d = Number.isFinite(depth) ? Math.max(0, Math.min(Math.floor(depth), MAX_INDENT_DEPTH)) : 0;
|
|
405
|
+
return `my-files__d${d}`;
|
|
406
|
+
}
|
|
407
|
+
/** A directory row's class. */
|
|
408
|
+
export function dirRowClass(kind, depth, selected = false) {
|
|
409
|
+
return `my-files__row my-files__row--dir my-files__row--${kind} ${indentClass(depth)}${selected ? " my-files__row--sel" : ""}`;
|
|
410
|
+
}
|
|
411
|
+
/** A file row's class. */
|
|
412
|
+
export function fileRowClass(depth, selected) {
|
|
413
|
+
return `my-files__row my-files__row--file ${indentClass(depth)}${selected ? " my-files__row--sel" : ""}`;
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Flatten the tree into the ordered rows the bindings render. Depth-first, roots in the order given;
|
|
417
|
+
* a closed node contributes only its own row.
|
|
418
|
+
*
|
|
419
|
+
* Every non-entry outcome becomes an explicit NOTE row rather than silence:
|
|
420
|
+
* · a listing still in flight ⇒ `loading`
|
|
421
|
+
* · a listing that could not be read ⇒ `unavailable` (honest state)
|
|
422
|
+
* · a listing that is genuinely empty⇒ `empty` (honest state)
|
|
423
|
+
* · a listing the source capped ⇒ `truncated`
|
|
424
|
+
* An expanded node with NO cache entry at all is `loading`, because "the caller has not fetched it
|
|
425
|
+
* yet" is exactly that and must never read as empty.
|
|
426
|
+
*
|
|
427
|
+
* Marks are applied only where the caller supplied one for that exact path — an absent mark renders
|
|
428
|
+
* no glyph, never a fabricated "clean". Pure: no fetching, no caching, no policy about WHEN a
|
|
429
|
+
* listing should be loaded (that is the product's lazy-expansion concern).
|
|
430
|
+
*/
|
|
431
|
+
export function deriveFileTreeRows(input) {
|
|
432
|
+
const { mode, roots, dirs, expanded, selectedId = null, marks = {} } = input;
|
|
433
|
+
const rows = [];
|
|
434
|
+
const markFor = (id) => Object.prototype.hasOwnProperty.call(marks, id) ? marks[id] : null;
|
|
435
|
+
// OWN keys only — the same guard `markFor` applies. `dirs` is caller-supplied and may be a plain
|
|
436
|
+
// object whose prototype chain carries a colliding node id; reading through it would render a
|
|
437
|
+
// directory the caller never listed, or (worse) treat an unfetched node as loaded and so silently
|
|
438
|
+
// replace the honest "Loading…" row with fabricated entries.
|
|
439
|
+
const dirState = (id) => Object.prototype.hasOwnProperty.call(dirs, id) ? dirs[id] : undefined;
|
|
440
|
+
const walk = (rootKey, relPath, name, depth, entryRepo, root) => {
|
|
441
|
+
// Depth ceiling — belt and braces against a pathological listing. A real tree is nowhere near
|
|
442
|
+
// this; without it a caller's malformed data could recurse until the stack overflows.
|
|
443
|
+
if (depth > MAX_TREE_DEPTH)
|
|
444
|
+
return;
|
|
445
|
+
const id = nodeId(rootKey, relPath);
|
|
446
|
+
const open = expanded.has(id);
|
|
447
|
+
const kind = classifyDirNode(mode, depth, entryRepo);
|
|
448
|
+
rows.push({
|
|
449
|
+
type: "dir",
|
|
450
|
+
id,
|
|
451
|
+
rootKey,
|
|
452
|
+
relPath,
|
|
453
|
+
name,
|
|
454
|
+
depth,
|
|
455
|
+
kind,
|
|
456
|
+
open,
|
|
457
|
+
chevron: chevronGlyph(open),
|
|
458
|
+
glyph: NODE_GLYPH[kind],
|
|
459
|
+
// Badges are a statement about a ROOT repo's project membership; a nested directory never
|
|
460
|
+
// carries them even when it classifies as a repo.
|
|
461
|
+
badges: depth === 0 ? repoBadges(root, mode) : [],
|
|
462
|
+
className: dirRowClass(kind, depth),
|
|
463
|
+
});
|
|
464
|
+
if (!open)
|
|
465
|
+
return;
|
|
466
|
+
const state = dirState(id);
|
|
467
|
+
if (state === undefined || state.status === "loading") {
|
|
468
|
+
rows.push({
|
|
469
|
+
type: "note",
|
|
470
|
+
id: `${id}\u0000note`,
|
|
471
|
+
depth: depth + 1,
|
|
472
|
+
status: "loading",
|
|
473
|
+
text: "Loading…",
|
|
474
|
+
className: treeNoteRowClass("loading", depth + 1),
|
|
475
|
+
});
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
if (state.status === "unavailable") {
|
|
479
|
+
rows.push({
|
|
480
|
+
type: "note",
|
|
481
|
+
id: `${id}\u0000note`,
|
|
482
|
+
depth: depth + 1,
|
|
483
|
+
status: "unavailable",
|
|
484
|
+
text: honestNoteText("unavailable", "dir"),
|
|
485
|
+
className: treeNoteRowClass("unavailable", depth + 1),
|
|
486
|
+
});
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
// At the traversal ceiling the children cannot be rendered at all. Say so — an open directory
|
|
490
|
+
// that silently showed nothing would read as empty, which is precisely the false claim the
|
|
491
|
+
// depth bound must not introduce. Reported BEFORE the empty check for the same reason.
|
|
492
|
+
if (depth + 1 > MAX_TREE_DEPTH) {
|
|
493
|
+
rows.push({
|
|
494
|
+
type: "note",
|
|
495
|
+
id: `${id}depth`,
|
|
496
|
+
depth,
|
|
497
|
+
status: "truncated",
|
|
498
|
+
text: "This tree is nested too deeply to show further levels.",
|
|
499
|
+
className: treeNoteRowClass("truncated", depth),
|
|
500
|
+
});
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
// EMPTY is a positive claim — "there is nothing here" — and only a COMPLETE listing can support
|
|
504
|
+
// it. A truncated listing that happens to carry no entries proves nothing about the directory,
|
|
505
|
+
// so it falls through to the truncated note alone rather than asserting both "this directory is
|
|
506
|
+
// empty" and "some entries are hidden", which contradict each other.
|
|
507
|
+
if (state.entries.length === 0 && state.truncated !== true) {
|
|
508
|
+
rows.push({
|
|
509
|
+
type: "note",
|
|
510
|
+
id: `${id}\u0000note`,
|
|
511
|
+
depth: depth + 1,
|
|
512
|
+
status: "empty",
|
|
513
|
+
text: honestNoteText("empty", "dir"),
|
|
514
|
+
className: treeNoteRowClass("empty", depth + 1),
|
|
515
|
+
});
|
|
516
|
+
// a truncated-but-empty listing is still worth reporting below
|
|
517
|
+
}
|
|
518
|
+
// Names already emitted in THIS listing. A node id is (root, path), so two entries sharing a
|
|
519
|
+
// name in one directory address the same node — even when their kinds differ, since a directory
|
|
520
|
+
// "x" and a file "x" both compose to `<root>\0<parent>/x`. Emitting both would duplicate a
|
|
521
|
+
// framework key (invalid keyed rendering) and make a click ambiguous between two rows; a
|
|
522
|
+
// duplicated DIRECTORY would additionally re-emit its whole subtree. First occurrence wins, the
|
|
523
|
+
// same order-defined rule applied to duplicate roots below.
|
|
524
|
+
const seenNames = new Set();
|
|
525
|
+
for (const entry of state.entries) {
|
|
526
|
+
// A structurally unusable name is dropped rather than composed into a path that would address
|
|
527
|
+
// the wrong node (or, for the empty name, this very node — an infinite descent).
|
|
528
|
+
if (!isUsableEntryName(entry.name))
|
|
529
|
+
continue;
|
|
530
|
+
if (seenNames.has(entry.name))
|
|
531
|
+
continue;
|
|
532
|
+
seenNames.add(entry.name);
|
|
533
|
+
const childRel = childRelPath(relPath, entry.name);
|
|
534
|
+
if (entry.kind === "dir") {
|
|
535
|
+
walk(rootKey, childRel, entry.name, depth + 1, entry.repo, root);
|
|
536
|
+
}
|
|
537
|
+
else {
|
|
538
|
+
const fid = nodeId(rootKey, childRel);
|
|
539
|
+
rows.push({
|
|
540
|
+
type: "file",
|
|
541
|
+
id: fid,
|
|
542
|
+
rootKey,
|
|
543
|
+
relPath: childRel,
|
|
544
|
+
name: entry.name,
|
|
545
|
+
depth: depth + 1,
|
|
546
|
+
glyph: fileGlyph(entry.name),
|
|
547
|
+
mark: markFor(fid),
|
|
548
|
+
selected: selectedId === fid,
|
|
549
|
+
className: fileRowClass(depth + 1, selectedId === fid),
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
if (state.truncated === true) {
|
|
554
|
+
rows.push({
|
|
555
|
+
type: "note",
|
|
556
|
+
id: `${id}\u0000trunc`,
|
|
557
|
+
depth: depth + 1,
|
|
558
|
+
status: "truncated",
|
|
559
|
+
text: "This directory is very large — some entries are hidden.",
|
|
560
|
+
className: treeNoteRowClass("truncated", depth + 1),
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
};
|
|
564
|
+
// A root whose key cannot round-trip through `nodeId` is dropped rather than rendered under a
|
|
565
|
+
// corrupted identity — the same policy applied to malformed entry names below.
|
|
566
|
+
//
|
|
567
|
+
// DUPLICATE keys are dropped for the same reason: two roots sharing a key produce the SAME node id
|
|
568
|
+
// for themselves and for every descendant, so they would collide in `dirs` / `marks`, render under
|
|
569
|
+
// duplicate framework keys, and make expanding or selecting one silently act on the other. Only
|
|
570
|
+
// the first occurrence is kept — a stable, order-defined choice.
|
|
571
|
+
const seenRootKeys = new Set();
|
|
572
|
+
for (const root of roots) {
|
|
573
|
+
if (!isUsableRootKey(root.key) || seenRootKeys.has(root.key))
|
|
574
|
+
continue;
|
|
575
|
+
seenRootKeys.add(root.key);
|
|
576
|
+
walk(root.key, "", root.label, 0, undefined, root);
|
|
577
|
+
}
|
|
578
|
+
return rows;
|
|
579
|
+
}
|
|
580
|
+
/** The number of FILE rows actually rendered — the rail header's default count, and the only one
|
|
581
|
+
* that is provably equal to what the operator can see. Pure. */
|
|
582
|
+
export function countFileRows(rows) {
|
|
583
|
+
let n = 0;
|
|
584
|
+
for (const row of rows)
|
|
585
|
+
if (row.type === "file")
|
|
586
|
+
n++;
|
|
587
|
+
return n;
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* Total FILE entries DISCOVERED across every cached listing — a different number from
|
|
591
|
+
* `countFileRows`, and deliberately so.
|
|
592
|
+
*
|
|
593
|
+
* This counts what the caller has listed so far ANYWHERE in its cache, including directories that
|
|
594
|
+
* are currently collapsed or no longer reachable from the active roots. It is therefore NOT the
|
|
595
|
+
* count of visible rows and must not be presented as one: a collapsed `docs/` whose listing is
|
|
596
|
+
* cached still contributes its files here while rendering none. `FileTree` defaults its header to
|
|
597
|
+
* `countFileRows` for exactly that reason; pass this explicitly as `count` only when "files
|
|
598
|
+
* discovered so far" is the number you actually mean.
|
|
599
|
+
*
|
|
600
|
+
* Malformed and duplicate names are excluded on the same tests the walk applies, and `Object.keys`
|
|
601
|
+
* is own-enumerable only, matching the walk's own-key guard. Pure.
|
|
602
|
+
*/
|
|
603
|
+
export function countLoadedFiles(dirs) {
|
|
604
|
+
let n = 0;
|
|
605
|
+
for (const key of Object.keys(dirs)) {
|
|
606
|
+
const state = dirs[key];
|
|
607
|
+
if (state.status !== "loaded")
|
|
608
|
+
continue;
|
|
609
|
+
// Same screening AND same per-listing name dedupe the walk applies, so the header can never
|
|
610
|
+
// count a row the tree collapses away.
|
|
611
|
+
const seen = new Set();
|
|
612
|
+
for (const e of state.entries) {
|
|
613
|
+
if (!isUsableEntryName(e.name) || seen.has(e.name))
|
|
614
|
+
continue;
|
|
615
|
+
seen.add(e.name);
|
|
616
|
+
if (e.kind === "file")
|
|
617
|
+
n++;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return n;
|
|
621
|
+
}
|
|
622
|
+
export function scopeItemClass(active) {
|
|
623
|
+
return active ? "my-files__scope-it my-files__scope-it--on" : "my-files__scope-it";
|
|
624
|
+
}
|