@medicine-wheel/app 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,66 @@
1
+ import { NextResponse } from "next/server";
2
+ import { fileResponse } from "@medicine-wheel/episode-vessel/http";
3
+ import { ANONYMOUS_CAPABILITIES } from "@medicine-wheel/episode-vessel/permissions";
4
+ import { episodeFolderOf, openEpisodeGate } from "@/lib/episode-files";
5
+
6
+ /**
7
+ * One file of an episode's room, by kind, read-only.
8
+ *
9
+ * GET /api/episodes/<chronicle:folder>/files/<file>
10
+ *
11
+ * This is the address `@medicine-wheel/episode-ui` reads a file from: text as
12
+ * JSON with its revision, an image inline, media whole or by byte range, a
13
+ * document as an attachment. The serving is `@medicine-wheel/episode-vessel/http`'s,
14
+ * the same door Miadi's room uses. The wheel only decides who is asking: a
15
+ * visitor, because it has no sign-in, so `ceremonies/` and `captures/` stay
16
+ * outside the room and an episode for the community stays closed.
17
+ *
18
+ * There is no PUT. The wheel reads an episode; Miadi writes it.
19
+ * Ref jgwill/medicine-wheel#166.
20
+ */
21
+
22
+ export const dynamic = "force-dynamic";
23
+ export const runtime = "nodejs";
24
+
25
+ const STATUS_OF_STATE: Record<string, number> = {
26
+ "no-root": 503,
27
+ absent: 404,
28
+ community: 403,
29
+ };
30
+
31
+ function answer(body: Record<string, unknown>, status: number) {
32
+ return NextResponse.json(body, { status, headers: { "cache-control": "no-store" } });
33
+ }
34
+
35
+ function decoded(value: string): string {
36
+ try {
37
+ return decodeURIComponent(value);
38
+ } catch {
39
+ return value;
40
+ }
41
+ }
42
+
43
+ export async function GET(
44
+ request: Request,
45
+ { params }: { params: Promise<{ id: string; path: string[] }> },
46
+ ) {
47
+ const { id: rawId, path: segments } = await params;
48
+ const id = decoded(rawId);
49
+ const folder = episodeFolderOf(id);
50
+ if (!folder) {
51
+ return answer(
52
+ { state: "not-an-episode", error: `Not an episode id: ${id}. Expected chronicle:<episode folder>.` },
53
+ 400,
54
+ );
55
+ }
56
+
57
+ const gate = openEpisodeGate(folder);
58
+ if (gate.state !== "open") return answer(gate, STATUS_OF_STATE[gate.state] ?? 400);
59
+
60
+ const file = (segments ?? []).map(decoded).join("/");
61
+ return fileResponse(request, folder, file, {
62
+ root: gate.root,
63
+ capabilities: ANONYMOUS_CAPABILITIES,
64
+ mayReadOutsideRoom: false,
65
+ });
66
+ }
@@ -1,5 +1,5 @@
1
1
  import { NextResponse } from "next/server";
2
- import { EpisodePathError } from "@miadi/episode-vessel";
2
+ import { EpisodePathError } from "@medicine-wheel/episode-vessel";
3
3
  import { episodeFolderOf, openEpisodeRoom, readEpisodeRoomText } from "@/lib/episode-files";
4
4
 
5
5
  /**
@@ -7,9 +7,10 @@ import { episodeFolderOf, openEpisodeRoom, readEpisodeRoomText } from "@/lib/epi
7
7
  *
8
8
  * GET /api/episodes/<chronicle:folder>/files the room's listing
9
9
  * GET /api/episodes/<chronicle:folder>/files?path=<file> one text file, whole
10
+ * GET /api/episodes/<chronicle:folder>/files/<file> one file, by kind (`[...path]/route.ts`)
10
11
  *
11
12
  * The rules — containment, the room, the audience — are `lib/episode-files.ts`
12
- * and, beneath it, `@miadi/episode-vessel`. This file only says them in HTTP.
13
+ * and, beneath it, `@medicine-wheel/episode-vessel`. This file only says them in HTTP.
13
14
  * Every answer carries a `state`, so a page can tell "this host has no
14
15
  * chronicle" from "this episode is not on this host" from "this episode is for
15
16
  * the community" — three empty lists that mean three different things.
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * What this is, from the node itself, with the episodes before and after it in
8
8
  * the chronicle. What it holds, from its folder — read-only, through the rules
9
- * `@miadi/episode-vessel` states for every surface that opens one (see
9
+ * `@medicine-wheel/episode-vessel` states for every surface that opens one (see
10
10
  * `components/episode-files-panel.tsx`). What was said and gathered for it —
11
11
  * ceremonies, diary, inquiry weaves, plan perspectives, captures — from routes
12
12
  * that filtered by episode long before any page asked them
@@ -1,47 +1,64 @@
1
1
  "use client";
2
2
 
3
3
  /**
4
- * An episode's folder on the episode page — listed, and any text file read in
5
- * place. Read-only: the wheel reads an episode, Miadi writes it.
4
+ * An episode's folder on the episode page, drawn by `@medicine-wheel/episode-ui`:
5
+ * the files workspace Miadi's room and gmtermux mount too, here read-only. Its
6
+ * folders, markdown preview, source view, images and media come from the
7
+ * package; this file only knows the wheel's routes and its palette. The wheel
8
+ * reads an episode; Miadi writes it.
6
9
  *
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.
10
+ * The open file is the page's address (`?file=<path>&mode=preview|source`), so
11
+ * a reload or a shared link lands on the same file, and Back returns to the one
12
+ * opened before.
9
13
  *
10
- * Text is shown as text. Markdown is not rendered here: rendering it means a
11
- * renderer and a sanitizer between an episode's files and this page, and plain
12
- * text is already readable. Media and documents are listed, not played.
13
- *
14
- * @see app/api/episodes/[id]/files/route.ts — and the rules it states
14
+ * @see app/api/episodes/[id]/files/route.ts — the listing, and why a room is closed
15
+ * @see app/api/episodes/[id]/files/[...path]/route.ts — one file, by kind
15
16
  */
16
17
 
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";
20
-
21
- type EpisodeFile = {
22
- relativePath: string;
23
- name: string;
24
- kind: "text" | "document" | "media";
25
- size: number;
26
- modifiedAt: string;
27
- previewable: boolean;
28
- guidance: boolean;
29
- };
18
+ import { useEffect, useMemo, useState } from "react";
19
+ import {
20
+ EpisodeFiles,
21
+ httpEpisodeFiles,
22
+ useEpisodeFilesAddress,
23
+ type EpisodeCapabilitiesView,
24
+ type EpisodeFileView,
25
+ type EpisodeUITheme,
26
+ } from "@medicine-wheel/episode-ui";
27
+ import "@medicine-wheel/episode-ui/styles.css";
30
28
 
31
29
  type Room =
32
- | { state: "open"; episode_path: string; files: EpisodeFile[] }
30
+ | { state: "open"; episode_path: string; files: EpisodeFileView[] }
33
31
  | { state: "no-root" }
34
32
  | { state: "absent"; episode_path: string }
35
33
  | { state: "community"; episode_path: string }
36
34
  | { state: "not-an-episode" | "refused" | "error"; error?: string };
37
35
 
38
- type Opened = { path: string; content?: string; error?: string };
36
+ /** A visitor's capabilities: the wheel has no sign-in, so it browses and reads. */
37
+ const READ_ONLY: EpisodeCapabilitiesView = {
38
+ browse: true,
39
+ read: true,
40
+ edit: false,
41
+ upload: false,
42
+ attachCapture: false,
43
+ writeCeremonyNote: false,
44
+ attachToCeremony: false,
45
+ };
39
46
 
40
- function formatSize(bytes: number): string {
41
- if (bytes < 1024) return `${bytes} B`;
42
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
43
- return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
44
- }
47
+ /** The wheel's tokens (`src/ui-components/src/tokens.css`), so the workspace reads as part of the page. */
48
+ const THEME: EpisodeUITheme = {
49
+ background: "var(--mw-bg)",
50
+ card: "var(--mw-card)",
51
+ text: "var(--mw-fg)",
52
+ textDim: "var(--mw-muted)",
53
+ muted: "var(--mw-muted)",
54
+ accent: "var(--mw-primary)",
55
+ border: "var(--mw-border)",
56
+ success: "#5ac37d",
57
+ warning: "#e0a458",
58
+ mono: "var(--font-mono)",
59
+ };
60
+
61
+ const encodePath = (relativePath: string) => relativePath.split("/").map(encodeURIComponent).join("/");
45
62
 
46
63
  /** Why a folder is not shown, in words a reader can act on. */
47
64
  function closedReason(room: Room): string | null {
@@ -62,18 +79,12 @@ function closedReason(room: Room): string | null {
62
79
  export function EpisodeFilesPanel({ episodeId }: { episodeId: string }) {
63
80
  const base = `/api/episodes/${encodeURIComponent(episodeId)}/files`;
64
81
  const [room, setRoom] = useState<Room | null>(null);
65
- const [opened, setOpened] = useState<Opened | null>(null);
66
- // The path last asked for, so a slow answer for an earlier click cannot
67
- // replace the file the reader opened after it.
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());
82
+ const address = useEpisodeFilesAddress();
71
83
 
72
84
  useEffect(() => {
73
85
  let live = true;
74
86
  setRoom(null);
75
- setOpened(null);
76
- fetch(base)
87
+ fetch(base, { cache: "no-store" })
77
88
  .then((res) => res.json())
78
89
  .then((body: Room) => live && setRoom(body))
79
90
  .catch((e) => live && setRoom({ state: "error", error: e instanceof Error ? e.message : String(e) }));
@@ -82,120 +93,25 @@ export function EpisodeFilesPanel({ episodeId }: { episodeId: string }) {
82
93
  };
83
94
  }, [base]);
84
95
 
85
- const toggle = useCallback(
86
- async (file: EpisodeFile) => {
87
- if (wanted.current === file.relativePath) {
88
- wanted.current = null;
89
- setOpened(null);
90
- return;
91
- }
92
- wanted.current = file.relativePath;
93
- setOpened({ path: file.relativePath });
94
- let next: Opened;
95
- try {
96
- const res = await fetch(`${base}?path=${encodeURIComponent(file.relativePath)}`);
97
- const body = await res.json();
98
- next = res.ok
99
- ? { path: file.relativePath, content: body.content }
100
- : { path: file.relativePath, error: body.error ?? body.state ?? `HTTP ${res.status}` };
101
- } catch (e) {
102
- next = { path: file.relativePath, error: e instanceof Error ? e.message : String(e) };
103
- }
104
- if (wanted.current === file.relativePath) setOpened(next);
105
- },
96
+ // `save` exists because the service type asks for it; with `edit: false` the
97
+ // workspace never offers it, and the route has no PUT to answer it.
98
+ const service = useMemo(
99
+ () => httpEpisodeFiles({ fileUrl: (file) => `${base}/${encodePath(file.relativePath)}` }),
106
100
  [base],
107
101
  );
108
102
 
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
- );
103
+ if (room === null) return <p className="text-sm text-muted-foreground">Opening the folder…</p>;
104
+ if (room.state !== "open") return <p className="text-sm text-muted-foreground">{closedReason(room)}</p>;
123
105
 
124
106
  return (
125
- <section>
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>
140
-
141
- {room === null ? (
142
- <p className="text-sm text-muted-foreground">Opening the folder…</p>
143
- ) : room.state !== "open" ? (
144
- <p className="text-sm text-muted-foreground">{closedReason(room)}</p>
145
- ) : files.length === 0 ? (
146
- <p className="text-sm text-muted-foreground">The folder holds nothing the room shows.</p>
147
- ) : (
148
- <EpisodeFileTree
149
- tree={tree}
150
- collapsed={collapsed}
151
- onToggleFolder={toggleFolder}
152
- renderFile={(file, name) => {
153
- const isOpen = opened?.path === file.relativePath;
154
- return (
155
- <>
156
- <div className="p-2 border rounded-lg bg-card flex items-baseline justify-between gap-3 flex-wrap">
157
- {file.previewable ? (
158
- <button
159
- onClick={() => toggle(file)}
160
- title={file.relativePath}
161
- className="text-sm font-mono text-left break-all hover:underline"
162
- aria-expanded={isOpen}
163
- >
164
- {isOpen ? "▾" : "▸"} {name}
165
- </button>
166
- ) : (
167
- <span
168
- title={file.relativePath}
169
- className="text-sm font-mono break-all text-muted-foreground"
170
- >
171
- {name}
172
- </span>
173
- )}
174
- <span className="text-xs text-muted-foreground shrink-0 flex gap-2 items-baseline">
175
- {file.guidance && <span className="mw-badge">guidance</span>}
176
- {!file.previewable && <span className="mw-badge">{file.kind}</span>}
177
- <span>{formatSize(file.size)}</span>
178
- <span>{file.modifiedAt.slice(0, 10)}</span>
179
- </span>
180
- </div>
181
- {isOpen && (
182
- <div className="mt-1 p-3 border rounded-lg bg-card">
183
- {opened?.error ? (
184
- <p className="text-sm text-muted-foreground">{opened.error}</p>
185
- ) : opened?.content === undefined ? (
186
- <p className="text-sm text-muted-foreground">Reading…</p>
187
- ) : (
188
- <pre className="text-xs font-mono whitespace-pre-wrap break-words max-h-[70vh] overflow-auto">
189
- {opened.content}
190
- </pre>
191
- )}
192
- </div>
193
- )}
194
- </>
195
- );
196
- }}
197
- />
198
- )}
199
- </section>
107
+ <EpisodeFiles
108
+ {...address}
109
+ files={room.files}
110
+ capabilities={READ_ONLY}
111
+ service={service}
112
+ theme={THEME}
113
+ title="Files"
114
+ emptyLabel="The folder holds nothing the room shows."
115
+ />
200
116
  );
201
117
  }
package/dist/cli/mw.js CHANGED
@@ -80,8 +80,15 @@ function resolveMcpPath() {
80
80
  const fromModules = path.join(packageRoot, 'node_modules', '@medicine-wheel', 'mcp', 'dist', 'index.js');
81
81
  if (fs.existsSync(fromModules))
82
82
  return fromModules;
83
+ // The package itself, not a file inside it. @medicine-wheel/mcp has an
84
+ // `exports` map whose "." is ./dist/index.js (the server) and which does not
85
+ // export "./dist/index.js", so resolving that subpath throws
86
+ // ERR_PACKAGE_PATH_NOT_EXPORTED. A global install of app and mcp side by side
87
+ // (`npm i -g @medicine-wheel/app @medicine-wheel/mcp`) therefore found no
88
+ // server, and every installed `mw chart` said "MCP server path not found"
89
+ // until 0.24.1. Found by RELEASING.md step 7 on 0.24.0.
83
90
  try {
84
- return require.resolve('@medicine-wheel/mcp/dist/index.js', {
91
+ return require.resolve('@medicine-wheel/mcp', {
85
92
  paths: [packageRoot, process.cwd()],
86
93
  });
87
94
  }
@@ -8,10 +8,12 @@
8
8
  * Episodes tab could show an episode's name and nothing it contains.
9
9
  *
10
10
  * The rules for what an episode folder holds and what may be read from it are
11
- * not written here. They live in `@miadi/episode-vessel`, published from
12
- * jgwill/Miadi and used by every other surface that opens a vessel — the Miadi
13
- * room and its file routes, the terminal browser, the phone. A second copy of
14
- * them here is how two surfaces come to disagree about what is private.
11
+ * not written here. They live in `@medicine-wheel/episode-vessel`, published
12
+ * from this repo's `src/episode-vessel`, and every other surface that opens a
13
+ * vessel takes them from there: the Miadi room through `@miadi/episode-vessel`,
14
+ * which re-exports it, the terminal browser, the phone. A second copy of them
15
+ * here is how two surfaces come to disagree about what is private
16
+ * (jgwill/medicine-wheel#166).
15
17
  *
16
18
  * Two of those rules decide what the wheel shows. Both are applied as for a
17
19
  * visitor, because the wheel has no signed-in reader:
@@ -38,7 +40,7 @@ import {
38
40
  resolveEpisodeFile,
39
41
  type EpisodeFile,
40
42
  type FileRevision,
41
- } from "@miadi/episode-vessel";
43
+ } from "@medicine-wheel/episode-vessel";
42
44
 
43
45
  export type { EpisodeFile };
44
46
 
@@ -116,6 +118,19 @@ function gate(folder: string, root: string | null): EpisodeRoomClosed | null {
116
118
  return null;
117
119
  }
118
120
 
121
+ /**
122
+ * Whether one episode's room opens on this host, and from which chronicle root.
123
+ * The gate a listing and a read pass through, for a route that hands the read
124
+ * itself to `@medicine-wheel/episode-vessel/http`.
125
+ */
126
+ export function openEpisodeGate(
127
+ folder: string,
128
+ options: EpisodeRootOptions = {},
129
+ ): { state: "open"; root: string } | EpisodeRoomClosed {
130
+ const root = rootOf(options);
131
+ return gate(folder, root) ?? { state: "open", root: root! };
132
+ }
133
+
119
134
  /** The files of one episode's room: everything the vessel lists except what is outside the room. */
120
135
  export function openEpisodeRoom(folder: string, options: EpisodeRootOptions = {}): EpisodeRoom {
121
136
  const root = rootOf(options);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@medicine-wheel/app",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "Medicine Wheel — Interactive visual layer for Indigenous relational research with Four Directions, ceremonies, and narrative arcs",
5
5
  "bin": {
6
6
  "mw": "dist/cli/mw.js",
@@ -27,6 +27,8 @@
27
27
  "//workspaces": "TOPOLOGICAL ORDER — do not alphabetize. `npm run <script> --workspaces` executes in THIS array's order, not in dependency order: a package listed before a package it imports builds first and fails TS2307 on any tree without a stale dist/. Roots first (ontology-core, creative-orientation, session-reader), then their dependents, mcp last. A new package goes after everything it imports.",
28
28
  "workspaces": [
29
29
  "src/ontology-core",
30
+ "src/episode-vessel",
31
+ "src/episode-ui",
30
32
  "src/infra",
31
33
  "src/capabilities",
32
34
  "src/developer-repository-mapping",
@@ -94,34 +96,35 @@
94
96
  "release:major": "npm run version:major && npm run publish:all && npm run release:commit"
95
97
  },
96
98
  "dependencies": {
97
- "@medicine-wheel/ceremonial-diary": "^0.24.0",
98
- "@medicine-wheel/ceremony-protocol": "^0.24.0",
99
- "@medicine-wheel/community-identity": "^0.24.0",
100
- "@medicine-wheel/community-review": "^0.24.0",
101
- "@medicine-wheel/consent-lifecycle": "^0.24.0",
102
- "@medicine-wheel/creative-orientation": "^0.24.0",
103
- "@medicine-wheel/data-store": "^0.24.0",
104
- "@medicine-wheel/data-store-postgres": "^0.24.0",
105
- "@medicine-wheel/events": "^0.24.0",
106
- "@medicine-wheel/fire-keeper": "^0.24.0",
107
- "@medicine-wheel/github-ceremony": "^0.24.0",
108
- "@medicine-wheel/graph-viz": "^0.24.0",
109
- "@medicine-wheel/honcho": "^0.24.0",
110
- "@medicine-wheel/importance-unit": "^0.24.0",
111
- "@medicine-wheel/mcp": "^4.24.0",
112
- "@medicine-wheel/memory": "^0.24.0",
113
- "@medicine-wheel/narrative-cluster": "^0.24.0",
114
- "@medicine-wheel/narrative-engine": "^0.24.0",
115
- "@medicine-wheel/ontology-core": "^0.24.0",
116
- "@medicine-wheel/perception-layer": "^0.24.0",
117
- "@medicine-wheel/prompt-decomposition": "^0.24.0",
118
- "@medicine-wheel/relational-index": "^0.24.0",
119
- "@medicine-wheel/relational-query": "^0.24.0",
120
- "@medicine-wheel/session-reader": "^0.24.0",
121
- "@medicine-wheel/storage-provider": "^0.24.0",
122
- "@medicine-wheel/transformation-tracker": "^0.24.0",
123
- "@medicine-wheel/ui-components": "^0.24.0",
124
- "@miadi/episode-vessel": "^0.2.1",
99
+ "@medicine-wheel/ceremonial-diary": "^0.25.0",
100
+ "@medicine-wheel/ceremony-protocol": "^0.25.0",
101
+ "@medicine-wheel/community-identity": "^0.25.0",
102
+ "@medicine-wheel/community-review": "^0.25.0",
103
+ "@medicine-wheel/consent-lifecycle": "^0.25.0",
104
+ "@medicine-wheel/creative-orientation": "^0.25.0",
105
+ "@medicine-wheel/data-store": "^0.25.0",
106
+ "@medicine-wheel/data-store-postgres": "^0.25.0",
107
+ "@medicine-wheel/episode-ui": "^0.25.0",
108
+ "@medicine-wheel/episode-vessel": "^0.25.0",
109
+ "@medicine-wheel/events": "^0.25.0",
110
+ "@medicine-wheel/fire-keeper": "^0.25.0",
111
+ "@medicine-wheel/github-ceremony": "^0.25.0",
112
+ "@medicine-wheel/graph-viz": "^0.25.0",
113
+ "@medicine-wheel/honcho": "^0.25.0",
114
+ "@medicine-wheel/importance-unit": "^0.25.0",
115
+ "@medicine-wheel/mcp": "^4.25.0",
116
+ "@medicine-wheel/memory": "^0.25.0",
117
+ "@medicine-wheel/narrative-cluster": "^0.25.0",
118
+ "@medicine-wheel/narrative-engine": "^0.25.0",
119
+ "@medicine-wheel/ontology-core": "^0.25.0",
120
+ "@medicine-wheel/perception-layer": "^0.25.0",
121
+ "@medicine-wheel/prompt-decomposition": "^0.25.0",
122
+ "@medicine-wheel/relational-index": "^0.25.0",
123
+ "@medicine-wheel/relational-query": "^0.25.0",
124
+ "@medicine-wheel/session-reader": "^0.25.0",
125
+ "@medicine-wheel/storage-provider": "^0.25.0",
126
+ "@medicine-wheel/transformation-tracker": "^0.25.0",
127
+ "@medicine-wheel/ui-components": "^0.25.0",
125
128
  "@neondatabase/serverless": "^0.10.0",
126
129
  "@xyflow/react": "^12.3.0",
127
130
  "clsx": "^2.1.1",
@@ -1,67 +0,0 @@
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
- }
@@ -1,135 +0,0 @@
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
- }