@miadi/stateloom-web 0.1.3 → 0.1.4
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/package.json +1 -1
- package/server/.next/BUILD_ID +1 -1
- package/server/.next/app-path-routes-manifest.json +1 -0
- package/server/.next/build-manifest.json +2 -2
- package/server/.next/routes-manifest.json +6 -0
- package/server/.next/server/app/_global-error.html +2 -2
- package/server/.next/server/app/_global-error.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/server/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/server/.next/server/app/_not-found.html +1 -1
- package/server/.next/server/app/_not-found.rsc +2 -2
- package/server/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/server/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/server/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
- package/server/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/server/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/server/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/server/.next/server/app/api/docs/route/app-paths-manifest.json +3 -0
- package/server/.next/server/app/api/docs/route/build-manifest.json +11 -0
- package/server/.next/server/app/api/docs/route/server-reference-manifest.json +4 -0
- package/server/.next/server/app/api/docs/route.js +8 -0
- package/server/.next/server/app/api/docs/route.js.map +5 -0
- package/server/.next/server/app/api/docs/route.js.nft.json +1 -0
- package/server/.next/server/app/api/docs/route_client-reference-manifest.js +2 -0
- package/server/.next/server/app/api/file/route.js.nft.json +1 -1
- package/server/.next/server/app/api/rispec/route.js.nft.json +1 -1
- package/server/.next/server/app/api/watch/route.js.nft.json +1 -1
- package/server/.next/server/app/index.html +1 -1
- package/server/.next/server/app/index.rsc +3 -3
- package/server/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/server/.next/server/app/index.segments/_full.segment.rsc +3 -3
- package/server/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/server/.next/server/app/index.segments/_index.segment.rsc +2 -2
- package/server/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/server/.next/server/app/page_client-reference-manifest.js +1 -1
- package/server/.next/server/app-paths-manifest.json +1 -0
- package/server/.next/server/chunks/[root-of-the-server]__13c3d2a8._.js +3 -0
- package/server/.next/server/chunks/[root-of-the-server]__2046b812._.js +2 -2
- package/server/.next/server/chunks/[root-of-the-server]__87bf4b91._.js +1 -1
- package/server/.next/server/chunks/[root-of-the-server]__95b05635._.js +3 -3
- package/server/.next/server/chunks/ssr/[root-of-the-server]__edbb995c._.js +1 -1
- package/server/.next/server/chunks/web__next-internal_server_app_api_docs_route_actions_1f543c30.js +3 -0
- package/server/.next/server/pages/404.html +1 -1
- package/server/.next/server/pages/500.html +2 -2
- package/server/.next/static/chunks/1cbe108679f9af04.css +2 -0
- package/server/.next/static/chunks/{b8543b198154e75c.js → c8da5c76cf5bb070.js} +3 -3
- package/server/package-lock.json +4 -4
- package/server/package.json +16 -2
- package/server/src/app/api/docs/route.ts +31 -0
- package/server/src/components/DocSwitcher.tsx +119 -31
- package/server/src/lib/projectFile.ts +106 -5
- package/server/stateloom-web.json +2 -2
- package/server/tsconfig.tsbuildinfo +1 -1
- package/server/.next/static/chunks/50f310b5d321b604.css +0 -2
- /package/server/.next/static/{WuhCSMxaHZvNZ4hY20AEo → yHDbm89IPqExJUSbiWtnD}/_buildManifest.js +0 -0
- /package/server/.next/static/{WuhCSMxaHZvNZ4hY20AEo → yHDbm89IPqExJUSbiWtnD}/_clientMiddlewareManifest.json +0 -0
- /package/server/.next/static/{WuhCSMxaHZvNZ4hY20AEo → yHDbm89IPqExJUSbiWtnD}/_ssgManifest.js +0 -0
package/server/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
|
-
"name": "stateloom-web",
|
|
3
|
-
"version": "0.1.
|
|
2
|
+
"name": "@miadi/stateloom-web-source",
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
|
-
"name": "stateloom-web",
|
|
9
|
-
"version": "0.1.
|
|
8
|
+
"name": "@miadi/stateloom-web-source",
|
|
9
|
+
"version": "0.1.4",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@miadi/stateloom-canvas": "file:../bridge-canvas",
|
|
12
12
|
"@miadi/stateloom-client": "file:../bridge-client",
|
package/server/package.json
CHANGED
|
@@ -1,7 +1,21 @@
|
|
|
1
1
|
{
|
|
2
|
-
"name": "stateloom-web",
|
|
3
|
-
"version": "0.1.
|
|
2
|
+
"name": "@miadi/stateloom-web-source",
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"private": true,
|
|
5
|
+
"//": [
|
|
6
|
+
"This is the designer's SOURCE and is never published — hence private: true.",
|
|
7
|
+
"What ships is its BUILD: `next build` (output: standalone) produces",
|
|
8
|
+
".next/standalone, which web-dist/scripts/sync-web.mjs assembles into",
|
|
9
|
+
"web-dist/server/ and publishes as @miadi/stateloom-web. So `npx -y",
|
|
10
|
+
"@miadi/stateloom-web` runs this app without a Next.js toolchain anywhere.",
|
|
11
|
+
"",
|
|
12
|
+
"Until 2026-08-16 this package was also called `stateloom-web`, and two",
|
|
13
|
+
"different things answering to one name is why it kept looking as though the",
|
|
14
|
+
"designer was the one package nobody had published.",
|
|
15
|
+
"",
|
|
16
|
+
"The version here is the DESIGNER's version, reported by `stateloom-web",
|
|
17
|
+
"--version` as `designer`. Bump it with web-dist's when the canvas changes."
|
|
18
|
+
],
|
|
5
19
|
"scripts": {
|
|
6
20
|
"dev": "next dev -p ${PORT:-4598}",
|
|
7
21
|
"build": "next build",
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/docs — the documents this server would let you open.
|
|
3
|
+
*
|
|
4
|
+
* The `?doc=` parameter and its allowlist shipped together; the way to SEE what
|
|
5
|
+
* the allowlist admits did not, so the switcher was a path prompt with no
|
|
6
|
+
* autocomplete and the paths it wanted were the server's, not the human's. In a
|
|
7
|
+
* container those differ — your `~/diagrams` is the server's `/data` — which
|
|
8
|
+
* made "switch diagrams" a thing you had to already know how to do.
|
|
9
|
+
*
|
|
10
|
+
* Enumeration is bounded in lib/projectFile.ts (depth, count, no dotfiles) and
|
|
11
|
+
* confined to the same roots `resolveDocPath` enforces, so this endpoint can
|
|
12
|
+
* never reveal a path the file API would refuse to serve.
|
|
13
|
+
*/
|
|
14
|
+
import { NextResponse } from "next/server";
|
|
15
|
+
import { allowedDocRoots, getProjectFilePath, listDocuments } from "@/lib/projectFile";
|
|
16
|
+
|
|
17
|
+
export const dynamic = "force-dynamic";
|
|
18
|
+
export const revalidate = 0;
|
|
19
|
+
|
|
20
|
+
export async function GET(): Promise<Response> {
|
|
21
|
+
const { docs, truncated } = listDocuments();
|
|
22
|
+
return NextResponse.json(
|
|
23
|
+
{
|
|
24
|
+
roots: allowedDocRoots(),
|
|
25
|
+
current: getProjectFilePath(),
|
|
26
|
+
docs,
|
|
27
|
+
truncated,
|
|
28
|
+
},
|
|
29
|
+
{ headers: { "cache-control": "no-store" } },
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -1,20 +1,60 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useState } from "react";
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
4
|
import { navigateToDoc, useRequestedDoc } from "@/lib/docParam";
|
|
5
5
|
import { rememberedDocs } from "@/lib/layoutMemory";
|
|
6
6
|
|
|
7
|
+
interface DocEntry {
|
|
8
|
+
path: string;
|
|
9
|
+
name: string;
|
|
10
|
+
dir: string;
|
|
11
|
+
mtime: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
7
14
|
/**
|
|
8
15
|
* A human switches diagrams without an agent and without a terminal
|
|
9
|
-
* (chart_1785683062725).
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
16
|
+
* (chart_1785683062725). Choosing navigates via `?doc=` — the providers re-key
|
|
17
|
+
* (step 3), the server allowlist guards (step 1), no reload, no new transport.
|
|
18
|
+
*
|
|
19
|
+
* The list comes from `GET /api/docs`, which enumerates exactly what that
|
|
20
|
+
* allowlist admits. Before it existed this was a free-text field over this
|
|
21
|
+
* browser's layout memory — empty on first use — so switching required knowing
|
|
22
|
+
* the server's path for a directory you were looking at under a different name,
|
|
23
|
+
* and typing it by hand every time. In a container that is `/data`, which is
|
|
24
|
+
* not a thing anyone can guess. The field stays, for a path the walk did not
|
|
25
|
+
* reach; it is no longer the only way in.
|
|
13
26
|
*/
|
|
14
27
|
export default function DocSwitcher() {
|
|
15
28
|
const requested = useRequestedDoc();
|
|
16
29
|
const [open, setOpen] = useState(false);
|
|
17
30
|
const [typed, setTyped] = useState("");
|
|
31
|
+
const [docs, setDocs] = useState<DocEntry[] | null>(null);
|
|
32
|
+
const [roots, setRoots] = useState<string[]>([]);
|
|
33
|
+
const [error, setError] = useState<string | null>(null);
|
|
34
|
+
|
|
35
|
+
// Fetched on each open, not once: an agent creating a document while the
|
|
36
|
+
// panel is shut is the normal case, and a stale list is the whole complaint.
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (!open) return;
|
|
39
|
+
let live = true;
|
|
40
|
+
setError(null);
|
|
41
|
+
fetch("/api/docs", { cache: "no-store" })
|
|
42
|
+
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
|
|
43
|
+
.then((body: { docs?: DocEntry[]; roots?: string[] }) => {
|
|
44
|
+
if (!live) return;
|
|
45
|
+
setDocs(body.docs ?? []);
|
|
46
|
+
setRoots(body.roots ?? []);
|
|
47
|
+
})
|
|
48
|
+
.catch((e: Error) => {
|
|
49
|
+
if (!live) return;
|
|
50
|
+
// An older server has no /api/docs. Recents and the field still work.
|
|
51
|
+
setDocs([]);
|
|
52
|
+
setError(e.message);
|
|
53
|
+
});
|
|
54
|
+
return () => {
|
|
55
|
+
live = false;
|
|
56
|
+
};
|
|
57
|
+
}, [open]);
|
|
18
58
|
|
|
19
59
|
const go = (doc: string | null) => {
|
|
20
60
|
setOpen(false);
|
|
@@ -22,10 +62,22 @@ export default function DocSwitcher() {
|
|
|
22
62
|
navigateToDoc(doc);
|
|
23
63
|
};
|
|
24
64
|
|
|
65
|
+
// Layout memory still contributes: a document reached once and since deleted,
|
|
66
|
+
// or one outside the walk's depth, is worth keeping in reach.
|
|
67
|
+
const listed = new Set((docs ?? []).map((d) => d.path));
|
|
25
68
|
const recents = open
|
|
26
|
-
? rememberedDocs().filter((d) => d.startsWith("/") && d !== requested)
|
|
69
|
+
? rememberedDocs().filter((d) => d.startsWith("/") && d !== requested && !listed.has(d))
|
|
27
70
|
: [];
|
|
28
71
|
|
|
72
|
+
const available = (docs ?? []).filter((d) => d.path !== requested);
|
|
73
|
+
const filter = typed.trim().toLowerCase();
|
|
74
|
+
const shown = filter
|
|
75
|
+
? available.filter((d) => d.path.toLowerCase().includes(filter))
|
|
76
|
+
: available;
|
|
77
|
+
|
|
78
|
+
const rowClass =
|
|
79
|
+
"block w-full truncate rounded px-1.5 py-1 text-left text-xs text-gray-300 hover:bg-gray-800";
|
|
80
|
+
|
|
29
81
|
return (
|
|
30
82
|
<span className="relative inline-flex">
|
|
31
83
|
<button
|
|
@@ -36,47 +88,83 @@ export default function DocSwitcher() {
|
|
|
36
88
|
⇄
|
|
37
89
|
</button>
|
|
38
90
|
{open && (
|
|
39
|
-
<div className="absolute left-0 top-full z-50 mt-1 w-
|
|
91
|
+
<div className="absolute left-0 top-full z-50 mt-1 w-80 rounded border border-gray-700 bg-gray-900 p-2 shadow-lg">
|
|
40
92
|
<div className="mb-1 text-[10px] uppercase tracking-wide text-gray-500">
|
|
41
93
|
switch document
|
|
42
94
|
</div>
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
className="block w-full truncate rounded px-1.5 py-1 text-left text-xs text-gray-300 hover:bg-gray-800"
|
|
47
|
-
title="Return to the default project file"
|
|
48
|
-
>
|
|
49
|
-
⌂ default document
|
|
50
|
-
</button>
|
|
51
|
-
)}
|
|
52
|
-
{recents.map((d) => (
|
|
53
|
-
<button
|
|
54
|
-
key={d}
|
|
55
|
-
onClick={() => go(d)}
|
|
56
|
-
className="block w-full truncate rounded px-1.5 py-1 text-left text-xs text-gray-300 hover:bg-gray-800"
|
|
57
|
-
title={d}
|
|
58
|
-
>
|
|
59
|
-
{d.split("/").pop()}
|
|
60
|
-
<span className="ml-1 text-[10px] text-gray-600">{d}</span>
|
|
61
|
-
</button>
|
|
62
|
-
))}
|
|
95
|
+
|
|
96
|
+
{/* Doubles as filter and as free-text path — one field, because with a
|
|
97
|
+
list present the typing is almost always narrowing, not addressing. */}
|
|
63
98
|
<form
|
|
64
99
|
onSubmit={(e) => {
|
|
65
100
|
e.preventDefault();
|
|
66
|
-
|
|
101
|
+
const value = typed.trim();
|
|
102
|
+
if (!value) return;
|
|
103
|
+
// A single match means the human already chose it by narrowing.
|
|
104
|
+
if (shown.length === 1) return go(shown[0]!.path);
|
|
105
|
+
if (value.startsWith("/")) return go(value);
|
|
67
106
|
}}
|
|
68
|
-
className="
|
|
107
|
+
className="mb-1 flex gap-1"
|
|
69
108
|
>
|
|
70
109
|
<input
|
|
71
110
|
value={typed}
|
|
72
111
|
onChange={(e) => setTyped(e.target.value)}
|
|
73
|
-
placeholder="/absolute/path
|
|
112
|
+
placeholder={docs === null ? "loading…" : "filter, or /absolute/path.json"}
|
|
74
113
|
className="min-w-0 flex-1 rounded border border-gray-700 bg-gray-950 px-1.5 py-1 text-xs text-gray-200 placeholder:text-gray-600"
|
|
75
114
|
/>
|
|
76
|
-
<button type="submit" className="toolbar-btn" title="Open
|
|
115
|
+
<button type="submit" className="toolbar-btn" title="Open">
|
|
77
116
|
→
|
|
78
117
|
</button>
|
|
79
118
|
</form>
|
|
119
|
+
|
|
120
|
+
<div className="max-h-64 overflow-y-auto">
|
|
121
|
+
{requested && (
|
|
122
|
+
<button onClick={() => go(null)} className={rowClass} title="Return to the default project file">
|
|
123
|
+
⌂ default document
|
|
124
|
+
</button>
|
|
125
|
+
)}
|
|
126
|
+
|
|
127
|
+
{shown.map((d) => (
|
|
128
|
+
<button key={d.path} onClick={() => go(d.path)} className={rowClass} title={d.path}>
|
|
129
|
+
{d.name}
|
|
130
|
+
<span className="ml-1 text-[10px] text-gray-600">{d.dir}</span>
|
|
131
|
+
</button>
|
|
132
|
+
))}
|
|
133
|
+
|
|
134
|
+
{recents.length > 0 && (
|
|
135
|
+
<div className="mt-1 border-t border-gray-800 pt-1">
|
|
136
|
+
<div className="px-1.5 pb-0.5 text-[10px] uppercase tracking-wide text-gray-600">
|
|
137
|
+
recent
|
|
138
|
+
</div>
|
|
139
|
+
{recents.map((d) => (
|
|
140
|
+
<button key={d} onClick={() => go(d)} className={rowClass} title={d}>
|
|
141
|
+
{d.split("/").pop()}
|
|
142
|
+
<span className="ml-1 text-[10px] text-gray-600">{d}</span>
|
|
143
|
+
</button>
|
|
144
|
+
))}
|
|
145
|
+
</div>
|
|
146
|
+
)}
|
|
147
|
+
|
|
148
|
+
{docs !== null && shown.length === 0 && recents.length === 0 && (
|
|
149
|
+
<div className="px-1.5 py-1 text-[11px] text-gray-500">
|
|
150
|
+
{filter ? "nothing matches" : "no other documents here"}
|
|
151
|
+
</div>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
|
|
155
|
+
{/* The server's view of where documents live. In a container this is
|
|
156
|
+
/data, and saying so is the difference between a refusal that
|
|
157
|
+
reads as a bug and one that reads as an instruction. */}
|
|
158
|
+
{roots.length > 0 && (
|
|
159
|
+
<div className="mt-1 truncate border-t border-gray-800 pt-1 text-[10px] text-gray-600">
|
|
160
|
+
paths are as the server sees them: {roots.join(", ")}
|
|
161
|
+
</div>
|
|
162
|
+
)}
|
|
163
|
+
{error && (
|
|
164
|
+
<div className="mt-1 text-[10px] text-gray-600">
|
|
165
|
+
this server does not list documents ({error}) — type a path
|
|
166
|
+
</div>
|
|
167
|
+
)}
|
|
80
168
|
</div>
|
|
81
169
|
)}
|
|
82
170
|
</span>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { existsSync, realpathSync } from "fs";
|
|
2
|
-
import { dirname, isAbsolute, resolve, sep } from "path";
|
|
1
|
+
import { existsSync, readdirSync, realpathSync, statSync } from "fs";
|
|
2
|
+
import { basename, dirname, isAbsolute, join, resolve, sep } from "path";
|
|
3
3
|
import { envAlias } from "@miadi/stateloom-protocol";
|
|
4
4
|
|
|
5
5
|
export function getProjectFilePath(): string {
|
|
@@ -61,13 +61,114 @@ export function resolveDocPath(doc: string | null | undefined): DocResolution {
|
|
|
61
61
|
? realpathSync(lexical)
|
|
62
62
|
: resolve(realpathSync(dirname(lexical)), lexical.slice(lexical.lastIndexOf(sep) + 1));
|
|
63
63
|
} catch {
|
|
64
|
-
|
|
64
|
+
// Almost always a host path handed to a containerised server: the directory
|
|
65
|
+
// exists where the human is looking and not where the server is.
|
|
66
|
+
return { ok: false, error: outsideRootsMessage(doc, allowedDocRoots()) };
|
|
65
67
|
}
|
|
66
68
|
|
|
67
|
-
|
|
69
|
+
const roots = allowedDocRoots();
|
|
70
|
+
for (const root of roots) {
|
|
68
71
|
if (anchored === root || anchored.startsWith(root + sep)) {
|
|
69
72
|
return { ok: true, path: anchored };
|
|
70
73
|
}
|
|
71
74
|
}
|
|
72
|
-
return { ok: false, error:
|
|
75
|
+
return { ok: false, error: outsideRootsMessage(doc, roots) };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A refusal that is true from the READER's side.
|
|
80
|
+
*
|
|
81
|
+
* "doc is outside the allowed roots" and "doc directory does not exist" are both
|
|
82
|
+
* accurate from the server's point of view and both false from the human's: in a
|
|
83
|
+
* container they are looking straight at the directory, on the host, where it
|
|
84
|
+
* plainly does exist. What they are missing is that the server sees it somewhere
|
|
85
|
+
* else. So the message names the roots and, when they differ from the path being
|
|
86
|
+
* asked for, says what the translation is.
|
|
87
|
+
*/
|
|
88
|
+
function outsideRootsMessage(doc: string, roots: string[]): string {
|
|
89
|
+
const where = roots.length ? roots.join(", ") : "(no readable root is configured)";
|
|
90
|
+
const hint =
|
|
91
|
+
roots.length === 1 && roots[0] === "/data"
|
|
92
|
+
? " — this server is in a container, and it sees your mounted directory as /data," +
|
|
93
|
+
` so ask for /data/${basename(doc)}`
|
|
94
|
+
: "";
|
|
95
|
+
return `${doc} is not under the permitted document root${roots.length === 1 ? "" : "s"} ${where}${hint}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface DocEntry {
|
|
99
|
+
/** Absolute path as the SERVER sees it — the value `?doc=` wants. */
|
|
100
|
+
path: string;
|
|
101
|
+
/** Basename, for display. */
|
|
102
|
+
name: string;
|
|
103
|
+
/** Directory, for display when two documents share a name. */
|
|
104
|
+
dir: string;
|
|
105
|
+
size: number;
|
|
106
|
+
mtime: number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Every document the allowlist would admit, so the switcher can be a switcher.
|
|
111
|
+
*
|
|
112
|
+
* Both the compose file and `.env.docker.example` promised that other documents
|
|
113
|
+
* in the mounted directory are "reachable from the canvas's document switcher".
|
|
114
|
+
* That was true of the ALLOWLIST and false of the UI: recents came from this
|
|
115
|
+
* browser's layout memory, empty on first use, and nothing enumerated the
|
|
116
|
+
* directory — so switching meant knowing the container sees your folder as
|
|
117
|
+
* /data and typing the path by hand, every time. (Found in review.)
|
|
118
|
+
*
|
|
119
|
+
* Bounded deliberately: depth and count caps, no dotfiles, no node_modules. A
|
|
120
|
+
* mount pointed at a home directory must not turn a dropdown into a filesystem
|
|
121
|
+
* crawl.
|
|
122
|
+
*/
|
|
123
|
+
export function listDocuments(limit = 300, maxDepth = 4): { docs: DocEntry[]; truncated: boolean } {
|
|
124
|
+
const docs: DocEntry[] = [];
|
|
125
|
+
const seen = new Set<string>();
|
|
126
|
+
let truncated = false;
|
|
127
|
+
|
|
128
|
+
const walk = (dir: string, depth: number): void => {
|
|
129
|
+
if (docs.length >= limit) {
|
|
130
|
+
truncated = true;
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
let entries: string[];
|
|
134
|
+
try {
|
|
135
|
+
entries = readdirSync(dir);
|
|
136
|
+
} catch {
|
|
137
|
+
return; // unreadable directory is not an error, it is just not listed
|
|
138
|
+
}
|
|
139
|
+
for (const entry of entries) {
|
|
140
|
+
if (entry.startsWith(".") || entry === "node_modules") continue;
|
|
141
|
+
const full = join(dir, entry);
|
|
142
|
+
let stat;
|
|
143
|
+
try {
|
|
144
|
+
stat = statSync(full);
|
|
145
|
+
} catch {
|
|
146
|
+
continue; // a broken symlink lists as nothing, not as a crash
|
|
147
|
+
}
|
|
148
|
+
if (stat.isDirectory()) {
|
|
149
|
+
if (depth < maxDepth) walk(full, depth + 1);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (!entry.endsWith(".json")) continue;
|
|
153
|
+
if (docs.length >= limit) {
|
|
154
|
+
truncated = true;
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (seen.has(full)) continue;
|
|
158
|
+
seen.add(full);
|
|
159
|
+
docs.push({ path: full, name: entry, dir, size: stat.size, mtime: stat.mtimeMs });
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
for (const root of allowedDocRoots()) walk(root, 0);
|
|
164
|
+
|
|
165
|
+
// `.smdf.json` first — the rest are `.json` files the allowlist tolerates but
|
|
166
|
+
// nobody came here looking for — then most recently touched.
|
|
167
|
+
docs.sort((a, b) => {
|
|
168
|
+
const aSmdf = a.name.endsWith(".smdf.json") ? 0 : 1;
|
|
169
|
+
const bSmdf = b.name.endsWith(".smdf.json") ? 0 : 1;
|
|
170
|
+
return aSmdf - bSmdf || b.mtime - a.mtime || a.path.localeCompare(b.path);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
return { docs, truncated };
|
|
73
174
|
}
|