@medicine-wheel/app 0.17.11 → 0.19.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/README.md +52 -0
- package/app/api/ceremonies/[id]/route.ts +3 -0
- package/app/api/ceremonies/route.ts +6 -0
- package/app/api/diary/[id]/route.ts +3 -0
- package/app/api/diary/route.ts +6 -0
- package/app/api/edges/route.ts +14 -1
- package/app/api/events/git/route.ts +23 -0
- package/app/api/events/publish/route.ts +42 -0
- package/app/api/events/route.ts +39 -0
- package/app/api/health/route.ts +5 -0
- package/app/api/memory/conclusions/[id]/route.ts +4 -0
- package/app/api/memory/conclusions/route.ts +5 -0
- package/app/api/narrative/beats/[id]/route.ts +6 -0
- package/app/api/narrative/beats/route.ts +9 -1
- package/app/api/narrative/cycles/route.ts +10 -3
- package/app/api/nodes/[id]/route.ts +7 -1
- package/app/api/nodes/route.ts +6 -0
- package/app/narrative/cycles/page.tsx +2 -0
- package/components/cycle-events.tsx +82 -0
- package/components/episode-file-tree.tsx +67 -0
- package/components/episode-files-panel.tsx +52 -18
- package/dist/cli/mw.js +103 -0
- package/instrumentation.ts +11 -0
- package/lib/episode-file-tree.ts +135 -0
- package/lib/event-log.ts +279 -0
- package/package.json +30 -27
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The folders of an episode, drawn — every one open to begin with, so a file's
|
|
3
|
+
* folder is plain to see. Presentational: the panel owns which folders are
|
|
4
|
+
* closed and how a file's own row looks.
|
|
5
|
+
*
|
|
6
|
+
* @see lib/episode-file-tree.ts — how the paths are grouped and in what order
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ReactNode } from "react";
|
|
10
|
+
import type { FileTreeFolder, TreeableFile } from "@/lib/episode-file-tree";
|
|
11
|
+
|
|
12
|
+
export interface EpisodeFileTreeProps<T extends TreeableFile> {
|
|
13
|
+
tree: FileTreeFolder<T>;
|
|
14
|
+
/** Paths of the folders the reader closed. Any folder not here is open. */
|
|
15
|
+
collapsed: ReadonlySet<string>;
|
|
16
|
+
onToggleFolder: (path: string) => void;
|
|
17
|
+
/** One file's row, and whatever opens beneath it. `name` is the path's last segment. */
|
|
18
|
+
renderFile: (file: T, name: string) => ReactNode;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function EpisodeFileTree<T extends TreeableFile>({
|
|
22
|
+
tree,
|
|
23
|
+
collapsed,
|
|
24
|
+
onToggleFolder,
|
|
25
|
+
renderFile,
|
|
26
|
+
}: EpisodeFileTreeProps<T>) {
|
|
27
|
+
return (
|
|
28
|
+
<ul className="space-y-1">
|
|
29
|
+
{tree.children.map((node) => {
|
|
30
|
+
if (node.type === "file") {
|
|
31
|
+
return <li key={node.path}>{renderFile(node.file, node.name)}</li>;
|
|
32
|
+
}
|
|
33
|
+
const isOpen = !collapsed.has(node.path);
|
|
34
|
+
return (
|
|
35
|
+
<li key={node.path} data-folder={node.path}>
|
|
36
|
+
<button
|
|
37
|
+
type="button"
|
|
38
|
+
onClick={() => onToggleFolder(node.path)}
|
|
39
|
+
aria-expanded={isOpen}
|
|
40
|
+
title={node.path}
|
|
41
|
+
className="w-full p-2 flex items-baseline gap-2 text-left text-sm font-semibold hover:underline"
|
|
42
|
+
>
|
|
43
|
+
<span aria-hidden="true" className="text-muted-foreground w-[1ch]">
|
|
44
|
+
{isOpen ? "▾" : "▸"}
|
|
45
|
+
</span>
|
|
46
|
+
<span aria-hidden="true">{isOpen ? "📂" : "📁"}</span>
|
|
47
|
+
<span className="font-mono break-all">{node.name}</span>
|
|
48
|
+
<span className="ml-auto text-xs font-normal text-muted-foreground shrink-0">
|
|
49
|
+
{node.fileCount} {node.fileCount === 1 ? "file" : "files"}
|
|
50
|
+
</span>
|
|
51
|
+
</button>
|
|
52
|
+
{isOpen && (
|
|
53
|
+
<div className="ml-4 pl-2 border-l">
|
|
54
|
+
<EpisodeFileTree
|
|
55
|
+
tree={node}
|
|
56
|
+
collapsed={collapsed}
|
|
57
|
+
onToggleFolder={onToggleFolder}
|
|
58
|
+
renderFile={renderFile}
|
|
59
|
+
/>
|
|
60
|
+
</div>
|
|
61
|
+
)}
|
|
62
|
+
</li>
|
|
63
|
+
);
|
|
64
|
+
})}
|
|
65
|
+
</ul>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
* An episode's folder on the episode page — listed, and any text file read in
|
|
5
5
|
* place. Read-only: the wheel reads an episode, Miadi writes it.
|
|
6
6
|
*
|
|
7
|
+
* Files are listed in the folders their paths name, every one open to begin
|
|
8
|
+
* with, so a reader can tell which folder a file is in.
|
|
9
|
+
*
|
|
7
10
|
* Text is shown as text. Markdown is not rendered here: rendering it means a
|
|
8
11
|
* renderer and a sanitizer between an episode's files and this page, and plain
|
|
9
12
|
* text is already readable. Media and documents are listed, not played.
|
|
@@ -11,7 +14,9 @@
|
|
|
11
14
|
* @see app/api/episodes/[id]/files/route.ts — and the rules it states
|
|
12
15
|
*/
|
|
13
16
|
|
|
14
|
-
import { useCallback, useEffect, useRef, useState } from "react";
|
|
17
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
18
|
+
import { EpisodeFileTree } from "@/components/episode-file-tree";
|
|
19
|
+
import { buildFileTree, folderPaths } from "@/lib/episode-file-tree";
|
|
15
20
|
|
|
16
21
|
type EpisodeFile = {
|
|
17
22
|
relativePath: string;
|
|
@@ -61,6 +66,8 @@ export function EpisodeFilesPanel({ episodeId }: { episodeId: string }) {
|
|
|
61
66
|
// The path last asked for, so a slow answer for an earlier click cannot
|
|
62
67
|
// replace the file the reader opened after it.
|
|
63
68
|
const wanted = useRef<string | null>(null);
|
|
69
|
+
// Folders the reader closed. The tree opens fully; only a closed folder is remembered.
|
|
70
|
+
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set());
|
|
64
71
|
|
|
65
72
|
useEffect(() => {
|
|
66
73
|
let live = true;
|
|
@@ -99,17 +106,37 @@ export function EpisodeFilesPanel({ episodeId }: { episodeId: string }) {
|
|
|
99
106
|
[base],
|
|
100
107
|
);
|
|
101
108
|
|
|
102
|
-
const files =
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
109
|
+
const files = useMemo(() => (room?.state === "open" ? room.files : []), [room]);
|
|
110
|
+
const tree = useMemo(() => buildFileTree(files), [files]);
|
|
111
|
+
const folders = useMemo(() => folderPaths(tree), [tree]);
|
|
112
|
+
const allClosed = folders.length > 0 && folders.every((path) => collapsed.has(path));
|
|
113
|
+
|
|
114
|
+
const toggleFolder = useCallback(
|
|
115
|
+
(path: string) =>
|
|
116
|
+
setCollapsed((current) => {
|
|
117
|
+
const next = new Set(current);
|
|
118
|
+
if (!next.delete(path)) next.add(path);
|
|
119
|
+
return next;
|
|
120
|
+
}),
|
|
121
|
+
[],
|
|
122
|
+
);
|
|
107
123
|
|
|
108
124
|
return (
|
|
109
125
|
<section>
|
|
110
|
-
<
|
|
111
|
-
|
|
112
|
-
|
|
126
|
+
<div className="flex items-baseline justify-between gap-3 mb-2">
|
|
127
|
+
<h2 className="text-sm font-semibold">
|
|
128
|
+
Files {files.length > 0 && `(${files.length})`}
|
|
129
|
+
</h2>
|
|
130
|
+
{folders.length > 0 && (
|
|
131
|
+
<button
|
|
132
|
+
type="button"
|
|
133
|
+
onClick={() => setCollapsed(allClosed ? new Set() : new Set(folders))}
|
|
134
|
+
className="text-xs text-muted-foreground hover:underline"
|
|
135
|
+
>
|
|
136
|
+
{allClosed ? "Expand all" : "Collapse all"}
|
|
137
|
+
</button>
|
|
138
|
+
)}
|
|
139
|
+
</div>
|
|
113
140
|
|
|
114
141
|
{room === null ? (
|
|
115
142
|
<p className="text-sm text-muted-foreground">Opening the folder…</p>
|
|
@@ -118,23 +145,30 @@ export function EpisodeFilesPanel({ episodeId }: { episodeId: string }) {
|
|
|
118
145
|
) : files.length === 0 ? (
|
|
119
146
|
<p className="text-sm text-muted-foreground">The folder holds nothing the room shows.</p>
|
|
120
147
|
) : (
|
|
121
|
-
<
|
|
122
|
-
{
|
|
148
|
+
<EpisodeFileTree
|
|
149
|
+
tree={tree}
|
|
150
|
+
collapsed={collapsed}
|
|
151
|
+
onToggleFolder={toggleFolder}
|
|
152
|
+
renderFile={(file, name) => {
|
|
123
153
|
const isOpen = opened?.path === file.relativePath;
|
|
124
154
|
return (
|
|
125
|
-
|
|
155
|
+
<>
|
|
126
156
|
<div className="p-2 border rounded-lg bg-card flex items-baseline justify-between gap-3 flex-wrap">
|
|
127
157
|
{file.previewable ? (
|
|
128
158
|
<button
|
|
129
159
|
onClick={() => toggle(file)}
|
|
160
|
+
title={file.relativePath}
|
|
130
161
|
className="text-sm font-mono text-left break-all hover:underline"
|
|
131
162
|
aria-expanded={isOpen}
|
|
132
163
|
>
|
|
133
|
-
{isOpen ? "▾" : "▸"} {
|
|
164
|
+
{isOpen ? "▾" : "▸"} {name}
|
|
134
165
|
</button>
|
|
135
166
|
) : (
|
|
136
|
-
<span
|
|
137
|
-
{file.relativePath}
|
|
167
|
+
<span
|
|
168
|
+
title={file.relativePath}
|
|
169
|
+
className="text-sm font-mono break-all text-muted-foreground"
|
|
170
|
+
>
|
|
171
|
+
{name}
|
|
138
172
|
</span>
|
|
139
173
|
)}
|
|
140
174
|
<span className="text-xs text-muted-foreground shrink-0 flex gap-2 items-baseline">
|
|
@@ -157,10 +191,10 @@ export function EpisodeFilesPanel({ episodeId }: { episodeId: string }) {
|
|
|
157
191
|
)}
|
|
158
192
|
</div>
|
|
159
193
|
)}
|
|
160
|
-
|
|
194
|
+
</>
|
|
161
195
|
);
|
|
162
|
-
}
|
|
163
|
-
|
|
196
|
+
}}
|
|
197
|
+
/>
|
|
164
198
|
)}
|
|
165
199
|
</section>
|
|
166
200
|
);
|
package/dist/cli/mw.js
CHANGED
|
@@ -48,6 +48,7 @@ const fs = __importStar(require("fs"));
|
|
|
48
48
|
const skills_1 = require("./skills");
|
|
49
49
|
const beats_register_1 = require("./beats-register");
|
|
50
50
|
const orientation_1 = require("./orientation");
|
|
51
|
+
const events_1 = require("@medicine-wheel/events");
|
|
51
52
|
// ── Config ────────────────────────────────────────────────────────
|
|
52
53
|
const MW_API_URL = process.env.MW_API_URL ?? 'http://localhost:8040';
|
|
53
54
|
const MW_FORMAT = process.env.MW_FORMAT ?? 'pretty';
|
|
@@ -301,6 +302,15 @@ ${C.bold}🌿 mw — Medicine Wheel CLI${C.reset}
|
|
|
301
302
|
MEMORY
|
|
302
303
|
mw memory store <key> <value> [dir] Store relational memory
|
|
303
304
|
|
|
305
|
+
EVENTS (what happened on the wheel, kept in events.jsonl)
|
|
306
|
+
mw events [--type cycle.] [--subject <id>] [--correlation <id>]
|
|
307
|
+
[--since <iso>] [--limit 20] List events, newest first
|
|
308
|
+
mw events status What the publisher still owes
|
|
309
|
+
mw events publish Send what waits now
|
|
310
|
+
mw events replay <event-id|all> Send again from that event
|
|
311
|
+
mw events sync Commit (and push) events.jsonl now
|
|
312
|
+
mw events schema [--asyncapi] Print the JSON Schema (or AsyncAPI)
|
|
313
|
+
|
|
304
314
|
SYSTEM
|
|
305
315
|
mw status Show system status
|
|
306
316
|
mw tools List all MCP tools
|
|
@@ -758,6 +768,95 @@ function cmdMemory(positional) {
|
|
|
758
768
|
unknownSub('memory', sub, ['store']);
|
|
759
769
|
}
|
|
760
770
|
}
|
|
771
|
+
async function eventsApi(method, urlPath, body) {
|
|
772
|
+
const res = await fetch(`${MW_API_URL}${urlPath}`, {
|
|
773
|
+
method,
|
|
774
|
+
headers: { 'Content-Type': 'application/json' },
|
|
775
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
776
|
+
});
|
|
777
|
+
return { status: res.status, data: (await res.json().catch(() => ({}))) };
|
|
778
|
+
}
|
|
779
|
+
function printEvent(e) {
|
|
780
|
+
const when = e.observed_at.replace('T', ' ').slice(0, 19);
|
|
781
|
+
const payload = e.payload;
|
|
782
|
+
const changed = Array.isArray(payload.changed) ? ` ${C.dim}changed ${payload.changed.join(', ')}${C.reset}` : '';
|
|
783
|
+
const corr = e.correlation_id && e.correlation_id !== e.subject.id ? ` ${C.dim}↳ ${e.correlation_id}${C.reset}` : '';
|
|
784
|
+
console.log(` ${C.dim}${when}${C.reset} ${C.bold}${(0, events_1.shortName)(e.event_type)}${C.reset} ${e.subject.kind} ${e.subject.id}${changed}${corr}`);
|
|
785
|
+
}
|
|
786
|
+
async function cmdEvents(positional, flags) {
|
|
787
|
+
const sub = positional[0] ?? 'list';
|
|
788
|
+
const failed = (status, data) => {
|
|
789
|
+
console.error(`${C.south}${data?.error ?? `HTTP ${status}`}${C.reset}`);
|
|
790
|
+
if (data?.hint)
|
|
791
|
+
console.error(` ${data.hint}`);
|
|
792
|
+
process.exitCode = 1;
|
|
793
|
+
};
|
|
794
|
+
switch (sub) {
|
|
795
|
+
case 'list': {
|
|
796
|
+
const params = new URLSearchParams();
|
|
797
|
+
for (const key of ['type', 'subject', 'correlation', 'since', 'limit']) {
|
|
798
|
+
const value = flags[key];
|
|
799
|
+
if (typeof value === 'string')
|
|
800
|
+
params.set(key, value);
|
|
801
|
+
}
|
|
802
|
+
if (!params.has('limit'))
|
|
803
|
+
params.set('limit', '20');
|
|
804
|
+
const { status, data } = await eventsApi('GET', `/api/events?${params}`);
|
|
805
|
+
if (status !== 200)
|
|
806
|
+
return failed(status, data);
|
|
807
|
+
if (MW_FORMAT !== 'pretty')
|
|
808
|
+
return ppValue(data);
|
|
809
|
+
const events = data.events ?? [];
|
|
810
|
+
console.log(`${C.bold}${events.length} of ${data.total ?? events.length} event(s)${C.reset}`);
|
|
811
|
+
for (const e of events)
|
|
812
|
+
printEvent(e);
|
|
813
|
+
break;
|
|
814
|
+
}
|
|
815
|
+
case 'status': {
|
|
816
|
+
const { status, data } = await eventsApi('GET', '/api/events/publish');
|
|
817
|
+
if (status !== 200)
|
|
818
|
+
return failed(status, data);
|
|
819
|
+
ppValue(data);
|
|
820
|
+
break;
|
|
821
|
+
}
|
|
822
|
+
case 'publish': {
|
|
823
|
+
const { status, data } = await eventsApi('POST', '/api/events/publish', {});
|
|
824
|
+
if (status !== 200)
|
|
825
|
+
return failed(status, data);
|
|
826
|
+
ppValue(data);
|
|
827
|
+
if (data.stopped)
|
|
828
|
+
process.exitCode = 1;
|
|
829
|
+
break;
|
|
830
|
+
}
|
|
831
|
+
case 'replay': {
|
|
832
|
+
const from = positional[1];
|
|
833
|
+
if (!from) {
|
|
834
|
+
console.error(`${C.south}mw events replay needs an event id, or "all"${C.reset}`);
|
|
835
|
+
process.exitCode = EXIT_USAGE;
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
const { status, data } = await eventsApi('POST', '/api/events/publish', { replay_from: from });
|
|
839
|
+
if (status !== 200)
|
|
840
|
+
return failed(status, data);
|
|
841
|
+
ppValue(data);
|
|
842
|
+
if (data.stopped)
|
|
843
|
+
process.exitCode = 1;
|
|
844
|
+
break;
|
|
845
|
+
}
|
|
846
|
+
case 'sync': {
|
|
847
|
+
const { status, data } = await eventsApi('POST', '/api/events/git');
|
|
848
|
+
if (status !== 200)
|
|
849
|
+
return failed(status, data);
|
|
850
|
+
ppValue(data);
|
|
851
|
+
break;
|
|
852
|
+
}
|
|
853
|
+
case 'schema':
|
|
854
|
+
console.log(JSON.stringify(flags['asyncapi'] ? (0, events_1.asyncApiDocument)() : (0, events_1.wheelEventsJsonSchema)(), null, 2));
|
|
855
|
+
break;
|
|
856
|
+
default:
|
|
857
|
+
unknownSub('events', sub, ['list', 'status', 'publish', 'replay', 'sync', 'schema']);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
761
860
|
// ── Main dispatch ─────────────────────────────────────────────────
|
|
762
861
|
async function main() {
|
|
763
862
|
const { flags, positional } = parseArgs(process.argv);
|
|
@@ -824,6 +923,10 @@ async function main() {
|
|
|
824
923
|
case 'mem':
|
|
825
924
|
cmdMemory(rest);
|
|
826
925
|
break;
|
|
926
|
+
case 'events':
|
|
927
|
+
case 'ev':
|
|
928
|
+
await cmdEvents(rest, flags);
|
|
929
|
+
break;
|
|
827
930
|
case 'skill':
|
|
828
931
|
case 'sk':
|
|
829
932
|
cmdSkill(rest);
|
package/instrumentation.ts
CHANGED
|
@@ -4,9 +4,20 @@
|
|
|
4
4
|
* Starts the Honcho river's retry clock (#147): records Honcho could not
|
|
5
5
|
* receive while it was away wait on the pending ledger, and are sent again on
|
|
6
6
|
* start and every few minutes after. With `HONCHO_URL` unset this does nothing.
|
|
7
|
+
*
|
|
8
|
+
* Starts the event publisher's clock (#164): events in `events.jsonl` that the
|
|
9
|
+
* configured publisher has not taken are sent on start and every minute after.
|
|
10
|
+
* With `MW_EVENTS_PUBLISH_URL` unset this does nothing.
|
|
11
|
+
*
|
|
12
|
+
* Starts the event log's git carriage (#164): with `MW_EVENTS_GIT_SYNC` set,
|
|
13
|
+
* `events.jsonl` is committed in its data repository (and pushed) on start
|
|
14
|
+
* and every ten minutes, besides soon after each recorded event.
|
|
7
15
|
*/
|
|
8
16
|
export async function register() {
|
|
9
17
|
if (process.env.NEXT_RUNTIME !== "nodejs") return;
|
|
10
18
|
const { startPendingRetries } = await import("@/lib/honcho-projection");
|
|
11
19
|
startPendingRetries();
|
|
20
|
+
const { startEventPublishing, startEventGitSync } = await import("@/lib/event-log");
|
|
21
|
+
startEventPublishing();
|
|
22
|
+
startEventGitSync();
|
|
12
23
|
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An episode's files, grouped into the folders their paths name.
|
|
3
|
+
*
|
|
4
|
+
* `listEpisodeFiles` returns files with the path each sits at, and no folders,
|
|
5
|
+
* so the Files section printed whole paths one under another and a reader could
|
|
6
|
+
* not tell which folder a file was in. This builds the folders back from the
|
|
7
|
+
* paths, so the panel can draw them.
|
|
8
|
+
*
|
|
9
|
+
* This is layout, not a rule about what an episode shows — those live in
|
|
10
|
+
* `@miadi/episode-vessel` and in `lib/episode-files.ts`, and nothing here
|
|
11
|
+
* decides what may be listed or read. The same grouping is drawn in Miadi's room
|
|
12
|
+
* by `@miadi/episode-ui` (`buildFileTree`); the wheel keeps its own small copy
|
|
13
|
+
* until it takes that package, and the two are meant to agree on order:
|
|
14
|
+
*
|
|
15
|
+
* 1. a `README.md`;
|
|
16
|
+
* 2. the folders, by name;
|
|
17
|
+
* 3. the files — text, documents, guidance, then media — by name.
|
|
18
|
+
*
|
|
19
|
+
* Names compare numerically, so `chapter-2` comes before `chapter-10`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** The part of a listed file the grouping reads. Any richer shape passes through untouched. */
|
|
23
|
+
export interface TreeableFile {
|
|
24
|
+
relativePath: string;
|
|
25
|
+
name: string;
|
|
26
|
+
kind: string;
|
|
27
|
+
guidance: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface FileTreeFile<T extends TreeableFile> {
|
|
31
|
+
type: "file";
|
|
32
|
+
/** The last segment of the path. */
|
|
33
|
+
name: string;
|
|
34
|
+
path: string;
|
|
35
|
+
file: T;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface FileTreeFolder<T extends TreeableFile> {
|
|
39
|
+
type: "folder";
|
|
40
|
+
/** The last segment of the path. Empty for the root. */
|
|
41
|
+
name: string;
|
|
42
|
+
/** The path from the episode root. Empty for the root. */
|
|
43
|
+
path: string;
|
|
44
|
+
children: FileTreeNode<T>[];
|
|
45
|
+
/** Every file beneath this folder, at any depth. */
|
|
46
|
+
fileCount: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type FileTreeNode<T extends TreeableFile> = FileTreeFile<T> | FileTreeFolder<T>;
|
|
50
|
+
|
|
51
|
+
const compareNames = (left: string, right: string): number =>
|
|
52
|
+
left.localeCompare(right, undefined, { numeric: true, sensitivity: "base" }) ||
|
|
53
|
+
(left < right ? -1 : left > right ? 1 : 0);
|
|
54
|
+
|
|
55
|
+
function rank(file: TreeableFile): number {
|
|
56
|
+
if (/^README\.md$/i.test(file.name)) return 0;
|
|
57
|
+
if (file.guidance) return 3;
|
|
58
|
+
if (file.kind === "text") return 1;
|
|
59
|
+
if (file.kind === "document") return 2;
|
|
60
|
+
return 4;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function order<T extends TreeableFile>(folder: FileTreeFolder<T>): void {
|
|
64
|
+
const folders = folder.children.filter((n): n is FileTreeFolder<T> => n.type === "folder");
|
|
65
|
+
const files = folder.children.filter((n): n is FileTreeFile<T> => n.type === "file");
|
|
66
|
+
folders.sort((a, b) => compareNames(a.name, b.name));
|
|
67
|
+
files.sort((a, b) => rank(a.file) - rank(b.file) || compareNames(a.name, b.name));
|
|
68
|
+
folder.children = [
|
|
69
|
+
...files.filter((n) => rank(n.file) === 0),
|
|
70
|
+
...folders,
|
|
71
|
+
...files.filter((n) => rank(n.file) !== 0),
|
|
72
|
+
];
|
|
73
|
+
folders.forEach(order);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Group `files` into folders by path. A path listed twice is kept once, and a
|
|
78
|
+
* folder exists only because a file is beneath it.
|
|
79
|
+
*/
|
|
80
|
+
export function buildFileTree<T extends TreeableFile>(files: readonly T[]): FileTreeFolder<T> {
|
|
81
|
+
const root: FileTreeFolder<T> = { type: "folder", name: "", path: "", children: [], fileCount: 0 };
|
|
82
|
+
const folders = new Map<string, FileTreeFolder<T>>([["", root]]);
|
|
83
|
+
const seen = new Set<string>();
|
|
84
|
+
|
|
85
|
+
const folderAt = (path: string): FileTreeFolder<T> => {
|
|
86
|
+
const known = folders.get(path);
|
|
87
|
+
if (known) return known;
|
|
88
|
+
const cut = path.lastIndexOf("/");
|
|
89
|
+
const parent = folderAt(cut === -1 ? "" : path.slice(0, cut));
|
|
90
|
+
const created: FileTreeFolder<T> = {
|
|
91
|
+
type: "folder",
|
|
92
|
+
name: cut === -1 ? path : path.slice(cut + 1),
|
|
93
|
+
path,
|
|
94
|
+
children: [],
|
|
95
|
+
fileCount: 0,
|
|
96
|
+
};
|
|
97
|
+
folders.set(path, created);
|
|
98
|
+
parent.children.push(created);
|
|
99
|
+
return created;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
for (const file of files) {
|
|
103
|
+
if (seen.has(file.relativePath)) continue;
|
|
104
|
+
seen.add(file.relativePath);
|
|
105
|
+
const cut = file.relativePath.lastIndexOf("/");
|
|
106
|
+
const parent = folderAt(cut === -1 ? "" : file.relativePath.slice(0, cut));
|
|
107
|
+
parent.children.push({
|
|
108
|
+
type: "file",
|
|
109
|
+
name: cut === -1 ? file.relativePath : file.relativePath.slice(cut + 1),
|
|
110
|
+
path: file.relativePath,
|
|
111
|
+
file,
|
|
112
|
+
});
|
|
113
|
+
for (let path = parent.path; ; path = path.slice(0, Math.max(path.lastIndexOf("/"), 0))) {
|
|
114
|
+
folders.get(path)!.fileCount += 1;
|
|
115
|
+
if (!path) break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
order(root);
|
|
120
|
+
return root;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Every folder path in the tree, outermost first. The root is not one. */
|
|
124
|
+
export function folderPaths<T extends TreeableFile>(tree: FileTreeFolder<T>): string[] {
|
|
125
|
+
const paths: string[] = [];
|
|
126
|
+
const walk = (folder: FileTreeFolder<T>) => {
|
|
127
|
+
for (const child of folder.children) {
|
|
128
|
+
if (child.type !== "folder") continue;
|
|
129
|
+
paths.push(child.path);
|
|
130
|
+
walk(child);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
walk(tree);
|
|
134
|
+
return paths;
|
|
135
|
+
}
|