@shortlink-org/portolan 0.2.1 → 0.2.3
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 +7 -2
- package/cli/init.mjs +14 -6
- package/cli/init.test.mjs +8 -1
- package/cli/portolan.mjs +92 -13
- package/cli/portolan.test.mjs +47 -0
- package/package.json +7 -6
- package/plugins/extract-django/README.md +1 -1
- package/plugins/extract-django/extract_test.py +1 -1
- package/plugins/extract-django/openapi.py +4 -14
- package/plugins/extract-django/openapi_test.py +4 -4
- package/plugins/extract-go/options.schema.json +5 -0
- package/plugins/extract-project/options.schema.json +21 -0
- package/plugins/extract-ts/README.md +1 -1
- package/plugins/extract-ts/clients.ts +1 -1
- package/plugins/extract-ts/extract.test.ts +2 -2
- package/plugins/extract-ts/graphql.test.ts +2 -2
- package/plugins/extract-ts/openapi.test.ts +6 -7
- package/plugins/extract-ts/openapi.ts +3 -10
- package/plugins/portolan-go.wasm +0 -0
- package/public/404-cat-v1.webp +0 -0
- package/public/cat-about-v1.webp +0 -0
- package/public/cat-clear-skies-v1.webp +0 -0
- package/public/cat-no-changes-v1.webp +0 -0
- package/public/cat-onboarding-v1.webp +0 -0
- package/public/cat-trial-success-v1.webp +0 -0
- package/schema/portolan.schema.json +61 -0
- package/scripts/builtin-plugins.mjs +3 -2
- package/scripts/catalog-sources.mjs +14 -2
- package/scripts/catalog-sources.test.mjs +45 -0
- package/scripts/delivery-presets.mjs +207 -24
- package/scripts/diff.mjs +5 -1
- package/scripts/gen-likec4.mjs +242 -85
- package/scripts/gen-likec4.test.mjs +168 -14
- package/scripts/gen.mjs +5 -3
- package/scripts/host-plugins/fetch-git.mjs +22 -12
- package/scripts/host-plugins/fetch-git.test.mjs +25 -5
- package/scripts/local-api.mjs +330 -346
- package/scripts/local-api.test.mjs +245 -1
- package/scripts/local-discovery.mjs +378 -0
- package/scripts/manifest.mjs +42 -1
- package/scripts/manifest.test.mjs +24 -1
- package/scripts/plugin-host.mjs +14 -3
- package/scripts/plugin-host.test.mjs +8 -0
- package/scripts/run-builtin.mjs +4 -2
- package/scripts/schema.mjs +11 -0
- package/scripts/site-docs.mjs +2 -2
- package/src/app/CatalogApp.tsx +23 -8
- package/src/app/CommandPalette.tsx +7 -2
- package/src/app/Sidebar.tsx +13 -730
- package/src/app/SidebarFlowSections.tsx +251 -0
- package/src/app/SidebarFooter.tsx +160 -0
- package/src/app/SidebarTree.tsx +322 -0
- package/src/app/toast.tsx +11 -7
- package/src/catalog-index.ts +485 -0
- package/src/catalog-model.ts +1339 -0
- package/src/catalog-validation.ts +1570 -0
- package/src/catalog.test.ts +49 -0
- package/src/catalog.ts +6 -3124
- package/src/components/CatIllustration.tsx +30 -0
- package/src/components/CommitLink.tsx +154 -0
- package/src/components/EditorLink.tsx +15 -8
- package/src/components/GrpcMethodReference.tsx +93 -0
- package/src/components/Markdown.tsx +17 -0
- package/src/components/MethodRows.tsx +8 -1
- package/src/components/PageHeader.test.tsx +23 -0
- package/src/components/PageHeader.tsx +34 -0
- package/src/components/ProblemRow.tsx +3 -0
- package/src/components/ShapeRows.tsx +12 -0
- package/src/components/SourceDoc.tsx +27 -12
- package/src/components/SourcePreview.tsx +125 -37
- package/src/enrich.test.ts +209 -2
- package/src/enrich.ts +284 -15
- package/src/er/RedisSchema.tsx +86 -5
- package/src/flow/FlowTable.tsx +19 -4
- package/src/flow/StepDetail.tsx +461 -101
- package/src/flow/StepRail.tsx +36 -13
- package/src/flow/answers-response.test.ts +100 -0
- package/src/flow/answers.ts +11 -8
- package/src/flow/chapters.ts +2 -1
- package/src/flow/labels.ts +10 -0
- package/src/flow/mermaid.test.ts +87 -3
- package/src/flow/mermaid.ts +26 -14
- package/src/index.css +132 -17
- package/src/landing/LandingPage.tsx +16 -12
- package/src/lib/all-problems.ts +1 -1
- package/src/lib/api.test.ts +4 -6
- package/src/lib/build-info.test.ts +29 -0
- package/src/lib/build-info.ts +21 -0
- package/src/lib/data-model.ts +14 -0
- package/src/lib/derive.ts +1 -0
- package/src/lib/flow-tree.ts +1 -1
- package/src/lib/github-catalog.test.ts +51 -0
- package/src/lib/github-catalog.ts +69 -0
- package/src/lib/grpc-reference.test.ts +72 -0
- package/src/lib/grpc-reference.ts +117 -0
- package/src/lib/local-api.ts +36 -11
- package/src/lib/motion.test.ts +4 -2
- package/src/lib/motion.tsx +5 -4
- package/src/lib/product.ts +7 -0
- package/src/lib/proto-problems.test.ts +170 -3
- package/src/lib/proto-problems.ts +176 -4
- package/src/lib/queries.ts +16 -1
- package/src/lib/readme-assets.test.ts +19 -0
- package/src/lib/readme-assets.ts +34 -0
- package/src/lib/setup-info.test.ts +7 -0
- package/src/lib/setup-info.ts +5 -0
- package/src/lib/source-link.test.ts +106 -18
- package/src/lib/source-link.ts +22 -6
- package/src/merge.test.ts +101 -0
- package/src/merge.ts +65 -1
- package/src/pages/AdrDetail.tsx +3 -2
- package/src/pages/AdrIndex.tsx +10 -7
- package/src/pages/AggregatePage.tsx +34 -4
- package/src/pages/BlockPage.tsx +39 -32
- package/src/pages/Changes.tsx +7 -9
- package/src/pages/ContextMap.tsx +11 -2
- package/src/pages/ContextPage.tsx +11 -4
- package/src/pages/FlowIndex.tsx +16 -7
- package/src/pages/GraphPage.tsx +21 -7
- package/src/pages/Language.tsx +9 -9
- package/src/pages/NotFound.tsx +58 -15
- package/src/pages/Overview.tsx +6 -0
- package/src/pages/Problems.tsx +10 -8
- package/src/pages/RegistryIndex.tsx +10 -8
- package/src/pages/ServicePage.tsx +1 -1
- package/src/pages/Settings.tsx +257 -44
- package/src/pages/settings/AboutSettings.tsx +129 -0
- package/src/pages/settings/DeliverySettings.tsx +71 -15
- package/src/routes.test.ts +2 -0
- package/src/routes.ts +2 -1
- package/src/selection/DetailPanel.tsx +213 -113
- package/src/selection/pages.test.ts +14 -1
- package/src/selection/pages.ts +9 -3
- package/src/testing/setup.ts +14 -0
- package/vite.config.ts +3 -1
package/src/app/Sidebar.tsx
CHANGED
|
@@ -2,24 +2,9 @@ import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"
|
|
|
2
2
|
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
|
|
3
3
|
import { TREE_KEYS, treeKey } from "./tree-keys";
|
|
4
4
|
import type { TreeRow } from "./tree-keys";
|
|
5
|
-
import { Link, NavLink
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
LayoutGroup,
|
|
9
|
-
Unfold,
|
|
10
|
-
m,
|
|
11
|
-
transitions,
|
|
12
|
-
} from "../lib/motion";
|
|
13
|
-
import {
|
|
14
|
-
Check,
|
|
15
|
-
ChevronRight,
|
|
16
|
-
GripVertical,
|
|
17
|
-
PanelLeftOpen,
|
|
18
|
-
PinOff,
|
|
19
|
-
Settings2,
|
|
20
|
-
TriangleAlert,
|
|
21
|
-
} from "lucide-react";
|
|
22
|
-
import { catalog, index } from "../data";
|
|
5
|
+
import { Link, NavLink } from "react-router";
|
|
6
|
+
import { DURATION, LayoutGroup, Unfold } from "../lib/motion";
|
|
7
|
+
import { catalog } from "../data";
|
|
23
8
|
import type {
|
|
24
9
|
Aggregate,
|
|
25
10
|
Block,
|
|
@@ -37,19 +22,10 @@ import type {
|
|
|
37
22
|
} from "../catalog";
|
|
38
23
|
import { allModules, allTerms, enumsOf, storeViews } from "../catalog";
|
|
39
24
|
import { adrNumber, newestAccepted } from "../lib/adr";
|
|
40
|
-
import {
|
|
41
|
-
import { contextStats, problems } from "../lib/derive";
|
|
42
|
-
import { dataProblems } from "../lib/data-problems";
|
|
43
|
-
import { protoProblems } from "../lib/proto-problems";
|
|
25
|
+
import { contextStats } from "../lib/derive";
|
|
44
26
|
import { vocabularies } from "../language/cards";
|
|
45
|
-
import {
|
|
46
|
-
|
|
47
|
-
groupFlowsByOwner,
|
|
48
|
-
reachDots,
|
|
49
|
-
visibleEntries,
|
|
50
|
-
} from "../lib/flow-tree";
|
|
51
|
-
import type { FlowEntry, FlowHealth, FlowGroup } from "../lib/flow-tree";
|
|
52
|
-
import type { Kind, LeafKind } from "../lib/kinds";
|
|
27
|
+
import { groupFlowsByOwner } from "../lib/flow-tree";
|
|
28
|
+
import type { LeafKind } from "../lib/kinds";
|
|
53
29
|
import { plural } from "../lib/format";
|
|
54
30
|
import {
|
|
55
31
|
FLOW_GROUPS_KEY,
|
|
@@ -59,15 +35,15 @@ import {
|
|
|
59
35
|
} from "../lib/sidebar-prefs";
|
|
60
36
|
import type { Flags } from "../lib/sidebar-prefs";
|
|
61
37
|
import { KindIcon } from "../components/kind";
|
|
62
|
-
import {
|
|
38
|
+
import { Wordmark } from "../components/logo";
|
|
63
39
|
import { isStruck } from "../components/primitives";
|
|
64
40
|
import { packageAnchor, paths } from "../routes";
|
|
65
|
-
import { selectionHash } from "../selection/hash";
|
|
66
41
|
import { STORE_KIND_LABEL, StoreKindMark } from "../er/StoreHeader";
|
|
67
|
-
import {
|
|
68
|
-
import { resolveSelection, selectionFor } from "../selection/model";
|
|
69
|
-
import { selectsInPlace } from "../selection/pages";
|
|
42
|
+
import { resolveSelection } from "../selection/model";
|
|
70
43
|
import { useSelectionStore } from "../selection/store";
|
|
44
|
+
import { SidebarBottomGroup, SidebarIconRail } from "./SidebarFooter";
|
|
45
|
+
import { FlowGroupNode, PinnedSection } from "./SidebarFlowSections";
|
|
46
|
+
import { Branch, Group, Leaf, Section, indent } from "./SidebarTree";
|
|
71
47
|
|
|
72
48
|
// ---------------------------------------------------------------------------
|
|
73
49
|
// The rows under a service, grouped the way the tree draws them. The tree
|
|
@@ -181,316 +157,7 @@ const CLASSIFICATION_NOTE: Record<Classification, string> = {
|
|
|
181
157
|
// Rows
|
|
182
158
|
// ---------------------------------------------------------------------------
|
|
183
159
|
|
|
184
|
-
const indent = (depth: number) => 8 + depth * 12;
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* The selection's light: the raised background and the accent edge of the
|
|
188
|
-
* row that is selected. One element for the whole tree - a new selection is
|
|
189
|
-
* the same light arriving on another row, and it travels there on the settle
|
|
190
|
-
* spring rather than going out here and coming on over there. `-left-0.5`
|
|
191
|
-
* puts its edge over the row's own transparent 2px border, so the text does
|
|
192
|
-
* not move.
|
|
193
|
-
*/
|
|
194
|
-
function SelectionLight() {
|
|
195
|
-
return (
|
|
196
|
-
<m.span
|
|
197
|
-
layoutId="selection"
|
|
198
|
-
aria-hidden
|
|
199
|
-
className="pointer-events-none absolute inset-y-0 right-0 -left-0.5 -z-10 border-l-2 border-accent"
|
|
200
|
-
style={{ background: "var(--surface-2)" }}
|
|
201
|
-
transition={transitions.settle}
|
|
202
|
-
/>
|
|
203
|
-
);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
function Chevron({ open }: { open: boolean }) {
|
|
207
|
-
return (
|
|
208
|
-
<ChevronRight
|
|
209
|
-
size={14}
|
|
210
|
-
aria-hidden
|
|
211
|
-
className="block shrink-0 t-micro transition-transform"
|
|
212
|
-
style={{
|
|
213
|
-
transform: open ? "rotate(90deg)" : "none",
|
|
214
|
-
color: "var(--fg-muted)",
|
|
215
|
-
}}
|
|
216
|
-
/>
|
|
217
|
-
);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/**
|
|
221
|
-
* Clicking a row always selects. Whether it also navigates is the question the
|
|
222
|
-
* page on screen answers: on a flow page, picking something that flow already
|
|
223
|
-
* draws is a question about that sequence, so the reader stays put.
|
|
224
|
-
*
|
|
225
|
-
* Only the in-place case writes the store here. A row that navigates carries
|
|
226
|
-
* its selection in the link's hash instead (see `rowTarget`): writing the
|
|
227
|
-
* store first and then letting the link go would race SelectionSync, whose
|
|
228
|
-
* `replace` lands with the pathname it rendered with - the page being left -
|
|
229
|
-
* and undoes the navigation. A selection in the URL wins on arrival, so one
|
|
230
|
-
* write does both jobs. The palette works the same way.
|
|
231
|
-
*/
|
|
232
|
-
function useRowClick(selId: string | undefined) {
|
|
233
|
-
const select = useSelectionStore((s) => s.select);
|
|
234
|
-
const { pathname } = useLocation();
|
|
235
|
-
return (e: React.MouseEvent) => {
|
|
236
|
-
if (selId === undefined) return;
|
|
237
|
-
if (!selectsInPlace(pathname, selectionFor(selId))) return;
|
|
238
|
-
e.preventDefault();
|
|
239
|
-
select(selId, "sidebar");
|
|
240
|
-
};
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/** Where a row's link goes: its page, with the row's selection in the hash. */
|
|
244
|
-
function rowTarget(to: string, selId: string | undefined): string {
|
|
245
|
-
if (selId === undefined || to.includes("#")) return to;
|
|
246
|
-
return `${to}${selectionHash(selectionFor(selId))}`;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/**
|
|
250
|
-
* A leaf: the whole row navigates. Rows that stand for a selectable entity also
|
|
251
|
-
* carry `selId`, which makes them both a writer of the selection and a reader
|
|
252
|
-
* of it - the row lights up whether it was clicked here, on a diagram, or in
|
|
253
|
-
* the palette.
|
|
254
|
-
*/
|
|
255
|
-
function Leaf({
|
|
256
|
-
to,
|
|
257
|
-
depth,
|
|
258
|
-
children,
|
|
259
|
-
title,
|
|
260
|
-
selId,
|
|
261
|
-
}: {
|
|
262
|
-
to: string;
|
|
263
|
-
depth: number;
|
|
264
|
-
children: React.ReactNode;
|
|
265
|
-
title?: string;
|
|
266
|
-
selId?: string;
|
|
267
|
-
}) {
|
|
268
|
-
const selected = useSelectionStore(
|
|
269
|
-
(s) => selId !== undefined && s.selection?.id === selId,
|
|
270
|
-
);
|
|
271
|
-
const onClick = useRowClick(selId);
|
|
272
|
-
return (
|
|
273
|
-
<NavLink
|
|
274
|
-
to={rowTarget(to, selId)}
|
|
275
|
-
end
|
|
276
|
-
title={title}
|
|
277
|
-
data-sel={selId}
|
|
278
|
-
data-tree-row
|
|
279
|
-
data-tree-depth={depth}
|
|
280
|
-
onClick={onClick}
|
|
281
|
-
style={({ isActive }) => ({
|
|
282
|
-
paddingLeft: indent(depth),
|
|
283
|
-
background: isActive && !selected ? "var(--surface-2)" : undefined,
|
|
284
|
-
borderLeftWidth: 2,
|
|
285
|
-
borderLeftStyle: "solid",
|
|
286
|
-
borderLeftColor:
|
|
287
|
-
isActive && !selected ? "var(--accent)" : "transparent",
|
|
288
|
-
})}
|
|
289
|
-
/* The edge is always 2px, transparent when idle: lighting a row must not
|
|
290
|
-
shift the text beside it. The route's own row paints its light in
|
|
291
|
-
place; the SELECTION's light is one element that slides from the row
|
|
292
|
-
it was on to this one. */
|
|
293
|
-
data-nav-item
|
|
294
|
-
className="tree-row relative isolate flex items-center gap-1.5 py-[3px] pr-2 t-micro transition-colors hover:bg-surface"
|
|
295
|
-
>
|
|
296
|
-
{selected ? <SelectionLight /> : null}
|
|
297
|
-
{children}
|
|
298
|
-
</NavLink>
|
|
299
|
-
);
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
/**
|
|
303
|
-
* A branch: a disclosure triangle that only expands, next to a link that only
|
|
304
|
-
* navigates. Keeping them separate means neither is nested inside the other.
|
|
305
|
-
*
|
|
306
|
-
* `end` is for controls that must be their own target - the unresolved-edge
|
|
307
|
-
* count on a context, which goes somewhere else entirely. It sits OUTSIDE the
|
|
308
|
-
* link, because an anchor may not contain another one, and the link gives up
|
|
309
|
-
* its claim on the rest of the row to make room for it.
|
|
310
|
-
*/
|
|
311
|
-
function Branch({
|
|
312
|
-
to,
|
|
313
|
-
depth,
|
|
314
|
-
open,
|
|
315
|
-
onToggle,
|
|
316
|
-
label,
|
|
317
|
-
children,
|
|
318
|
-
right,
|
|
319
|
-
end,
|
|
320
|
-
under,
|
|
321
|
-
selId,
|
|
322
|
-
}: {
|
|
323
|
-
to: string;
|
|
324
|
-
depth: number;
|
|
325
|
-
open: boolean;
|
|
326
|
-
onToggle: () => void;
|
|
327
|
-
label: string;
|
|
328
|
-
children: React.ReactNode;
|
|
329
|
-
right?: React.ReactNode;
|
|
330
|
-
end?: React.ReactNode;
|
|
331
|
-
under?: React.ReactNode;
|
|
332
|
-
selId?: string;
|
|
333
|
-
}) {
|
|
334
|
-
const selected = useSelectionStore(
|
|
335
|
-
(s) => selId !== undefined && s.selection?.id === selId,
|
|
336
|
-
);
|
|
337
|
-
const onClick = useRowClick(selId);
|
|
338
|
-
const active = useMatch({ path: to, end: true }) !== null && !selected;
|
|
339
|
-
return (
|
|
340
|
-
<>
|
|
341
|
-
<div
|
|
342
|
-
className="tree-row group relative isolate flex items-stretch t-micro transition-colors hover:bg-surface"
|
|
343
|
-
style={{
|
|
344
|
-
paddingLeft: indent(depth),
|
|
345
|
-
background: active ? "var(--surface-2)" : undefined,
|
|
346
|
-
borderLeftWidth: 2,
|
|
347
|
-
borderLeftStyle: "solid",
|
|
348
|
-
borderLeftColor: active ? "var(--accent)" : "transparent",
|
|
349
|
-
}}
|
|
350
|
-
>
|
|
351
|
-
{selected ? <SelectionLight /> : null}
|
|
352
|
-
<button
|
|
353
|
-
type="button"
|
|
354
|
-
onClick={onToggle}
|
|
355
|
-
aria-expanded={open}
|
|
356
|
-
aria-label={`${open ? "Collapse" : "Expand"} ${label}`}
|
|
357
|
-
data-tree-toggle
|
|
358
|
-
className="flex shrink-0 items-center pr-1"
|
|
359
|
-
>
|
|
360
|
-
<Chevron open={open} />
|
|
361
|
-
</button>
|
|
362
|
-
<NavLink
|
|
363
|
-
to={rowTarget(to, selId)}
|
|
364
|
-
end
|
|
365
|
-
data-sel={selId}
|
|
366
|
-
data-nav-item
|
|
367
|
-
data-tree-row
|
|
368
|
-
data-tree-depth={depth}
|
|
369
|
-
data-tree-open={open}
|
|
370
|
-
onClick={onClick}
|
|
371
|
-
className={`flex min-w-0 items-center gap-1.5 py-[3px] pr-2 ${
|
|
372
|
-
end ? "shrink" : "flex-1"
|
|
373
|
-
}`}
|
|
374
|
-
>
|
|
375
|
-
{children}
|
|
376
|
-
{right && !end ? (
|
|
377
|
-
<span className="ml-auto shrink-0">{right}</span>
|
|
378
|
-
) : null}
|
|
379
|
-
</NavLink>
|
|
380
|
-
{end}
|
|
381
|
-
{right && end ? (
|
|
382
|
-
<span className="flex shrink-0 items-center pr-2">{right}</span>
|
|
383
|
-
) : null}
|
|
384
|
-
</div>
|
|
385
|
-
{under}
|
|
386
|
-
</>
|
|
387
|
-
);
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
/**
|
|
391
|
-
* A group header inside an aggregate - "value objects (3)". It names a kind
|
|
392
|
-
* rather than a thing, so it has no page and does nothing but open and close.
|
|
393
|
-
*/
|
|
394
|
-
function Group({
|
|
395
|
-
kind,
|
|
396
|
-
label,
|
|
397
|
-
count,
|
|
398
|
-
open,
|
|
399
|
-
onToggle,
|
|
400
|
-
depth,
|
|
401
|
-
children,
|
|
402
|
-
}: {
|
|
403
|
-
kind: LeafKind;
|
|
404
|
-
/** Overrides the kind's own plural — the stores group is "data", not "tables". */
|
|
405
|
-
label?: string;
|
|
406
|
-
count: number;
|
|
407
|
-
open: boolean;
|
|
408
|
-
onToggle: () => void;
|
|
409
|
-
depth: number;
|
|
410
|
-
children: React.ReactNode;
|
|
411
|
-
}) {
|
|
412
|
-
if (count === 0) return null;
|
|
413
|
-
return (
|
|
414
|
-
<>
|
|
415
|
-
<button
|
|
416
|
-
type="button"
|
|
417
|
-
onClick={onToggle}
|
|
418
|
-
aria-expanded={open}
|
|
419
|
-
data-tree-row
|
|
420
|
-
data-tree-depth={depth}
|
|
421
|
-
data-tree-open={open}
|
|
422
|
-
style={{ paddingLeft: indent(depth) }}
|
|
423
|
-
className="tree-row group-row"
|
|
424
|
-
>
|
|
425
|
-
<Chevron open={open} />
|
|
426
|
-
<KindIcon kind={kind} />
|
|
427
|
-
<span>
|
|
428
|
-
{label ?? KIND_GROUP_LABEL[kind]} ({count})
|
|
429
|
-
</span>
|
|
430
|
-
</button>
|
|
431
|
-
<Unfold open={open}>{children}</Unfold>
|
|
432
|
-
</>
|
|
433
|
-
);
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
const KIND_GROUP_LABEL: Record<LeafKind, string> = {
|
|
437
|
-
vo: "value objects",
|
|
438
|
-
entity: "entities",
|
|
439
|
-
enum: "enums",
|
|
440
|
-
event: "events",
|
|
441
|
-
command: "commands",
|
|
442
|
-
query: "queries",
|
|
443
|
-
endpoint: "api",
|
|
444
|
-
table: "tables",
|
|
445
|
-
view: "views",
|
|
446
|
-
};
|
|
447
160
|
|
|
448
|
-
// ---------------------------------------------------------------------------
|
|
449
|
-
// Sections
|
|
450
|
-
// ---------------------------------------------------------------------------
|
|
451
|
-
|
|
452
|
-
/**
|
|
453
|
-
* A top-level band of the tree. Its header folds it and always states its size:
|
|
454
|
-
* a folded section that also hid its count would be indistinguishable from an
|
|
455
|
-
* empty one, and the reader would have to open it to find out which.
|
|
456
|
-
*
|
|
457
|
-
* 12px above the header and nothing else between sections. The tree is one
|
|
458
|
-
* continuous list; boxing each band would make a reader count boxes.
|
|
459
|
-
*/
|
|
460
|
-
function Section({
|
|
461
|
-
title,
|
|
462
|
-
count,
|
|
463
|
-
open,
|
|
464
|
-
onToggle,
|
|
465
|
-
first = false,
|
|
466
|
-
children,
|
|
467
|
-
}: {
|
|
468
|
-
title: string;
|
|
469
|
-
count: number;
|
|
470
|
-
open: boolean;
|
|
471
|
-
onToggle: () => void;
|
|
472
|
-
/** The first section needs no air above it - the header is already there. */
|
|
473
|
-
first?: boolean;
|
|
474
|
-
children: React.ReactNode;
|
|
475
|
-
}) {
|
|
476
|
-
return (
|
|
477
|
-
<div className={first ? "" : "mt-3"}>
|
|
478
|
-
<button
|
|
479
|
-
type="button"
|
|
480
|
-
onClick={onToggle}
|
|
481
|
-
aria-expanded={open}
|
|
482
|
-
className="sb-section"
|
|
483
|
-
>
|
|
484
|
-
<Chevron open={open} />
|
|
485
|
-
{title}
|
|
486
|
-
<span className="sb-count">{count}</span>
|
|
487
|
-
</button>
|
|
488
|
-
<Unfold open={open}>{children}</Unfold>
|
|
489
|
-
</div>
|
|
490
|
-
);
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
/** A section of the tree with nothing under it, and why. */
|
|
494
161
|
// ---------------------------------------------------------------------------
|
|
495
162
|
// The rows as the arrow keys see them. Every leaf, branch link and group
|
|
496
163
|
// header carries `data-tree-row`; a closed branch's children are not in the
|
|
@@ -529,390 +196,6 @@ function TreeNote({ children }: { children: React.ReactNode }) {
|
|
|
529
196
|
// Flows
|
|
530
197
|
// ---------------------------------------------------------------------------
|
|
531
198
|
|
|
532
|
-
const HEALTH_COLOR: Record<FlowHealth, string> = {
|
|
533
|
-
unresolved: "var(--status-unresolved)",
|
|
534
|
-
declared: "var(--fg-faint)",
|
|
535
|
-
verified: "var(--status-verified)",
|
|
536
|
-
};
|
|
537
|
-
|
|
538
|
-
/**
|
|
539
|
-
* One flow. Its name is what the row is for; the two marks at the end answer
|
|
540
|
-
* the two questions a reader has before opening it - how far does it travel,
|
|
541
|
-
* and can I believe it.
|
|
542
|
-
*/
|
|
543
|
-
function FlowRow({ entry }: { entry: FlowEntry }) {
|
|
544
|
-
const { flow, health, reach } = entry;
|
|
545
|
-
const { dots, more } = reachDots(reach);
|
|
546
|
-
const crosses =
|
|
547
|
-
reach.length === 0
|
|
548
|
-
? "stays inside its own context"
|
|
549
|
-
: `crosses into ${reach.join(", ")}`;
|
|
550
|
-
return (
|
|
551
|
-
<Leaf
|
|
552
|
-
to={paths.flow(flow.slug)}
|
|
553
|
-
depth={1}
|
|
554
|
-
title={`${flow.slug} — ${crosses}`}
|
|
555
|
-
>
|
|
556
|
-
<KindIcon kind="flow" />
|
|
557
|
-
<span className="truncate">{flow.name}</span>
|
|
558
|
-
<span className="ml-auto flex shrink-0 items-center gap-2 pl-2">
|
|
559
|
-
<span className="flex items-center gap-0.5" aria-hidden>
|
|
560
|
-
{dots.map((c) => (
|
|
561
|
-
<span key={c} className="sb-reach" style={ctxStyle(c)} />
|
|
562
|
-
))}
|
|
563
|
-
{more > 0 ? (
|
|
564
|
-
<span className="mono" style={{ color: "var(--fg-faint)" }}>
|
|
565
|
-
+{more}
|
|
566
|
-
</span>
|
|
567
|
-
) : null}
|
|
568
|
-
</span>
|
|
569
|
-
<span
|
|
570
|
-
className="sb-health"
|
|
571
|
-
title={FLOW_HEALTH_NOTE[health]}
|
|
572
|
-
style={{ background: HEALTH_COLOR[health] }}
|
|
573
|
-
/>
|
|
574
|
-
</span>
|
|
575
|
-
</Leaf>
|
|
576
|
-
);
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
/**
|
|
580
|
-
* The flows one context owns. Grouping them by owner rather than listing them
|
|
581
|
-
* flat is what makes the section answer "whose flows are these" - and the
|
|
582
|
-
* unowned group is a defect report: with a valid catalog it is never drawn.
|
|
583
|
-
*/
|
|
584
|
-
function FlowGroupNode({
|
|
585
|
-
group,
|
|
586
|
-
open,
|
|
587
|
-
onToggle,
|
|
588
|
-
}: {
|
|
589
|
-
group: FlowGroup;
|
|
590
|
-
open: boolean;
|
|
591
|
-
onToggle: () => void;
|
|
592
|
-
}) {
|
|
593
|
-
const { shown, hidden } = visibleEntries(group.entries);
|
|
594
|
-
const owner = group.owner;
|
|
595
|
-
const label = owner === null ? "unowned" : contextName(owner);
|
|
596
|
-
return (
|
|
597
|
-
<div>
|
|
598
|
-
<button
|
|
599
|
-
type="button"
|
|
600
|
-
onClick={onToggle}
|
|
601
|
-
aria-expanded={open}
|
|
602
|
-
style={{ paddingLeft: indent(0) }}
|
|
603
|
-
title={
|
|
604
|
-
owner === null
|
|
605
|
-
? "these flows name no owner — a valid catalog has none of these"
|
|
606
|
-
: `flows owned by ${owner}`
|
|
607
|
-
}
|
|
608
|
-
className="tree-row flex w-full items-center gap-1.5 py-[3px] pr-2 text-left t-micro transition-colors hover:bg-surface"
|
|
609
|
-
>
|
|
610
|
-
<Chevron open={open} />
|
|
611
|
-
{owner === null ? (
|
|
612
|
-
<TriangleAlert
|
|
613
|
-
size={13}
|
|
614
|
-
aria-hidden
|
|
615
|
-
className="block shrink-0 text-unresolved"
|
|
616
|
-
/>
|
|
617
|
-
) : (
|
|
618
|
-
<KindIcon kind="context" contextId={owner} />
|
|
619
|
-
)}
|
|
620
|
-
<span
|
|
621
|
-
className="truncate"
|
|
622
|
-
style={owner === null ? { color: "var(--status-unresolved)" } : {}}
|
|
623
|
-
>
|
|
624
|
-
{label}
|
|
625
|
-
</span>
|
|
626
|
-
<span className="sb-count">{group.entries.length}</span>
|
|
627
|
-
</button>
|
|
628
|
-
<Unfold open={open}>
|
|
629
|
-
<>
|
|
630
|
-
{shown.map((entry) => (
|
|
631
|
-
<FlowRow key={entry.flow.slug} entry={entry} />
|
|
632
|
-
))}
|
|
633
|
-
{hidden > 0 && owner !== null ? (
|
|
634
|
-
<NavLink
|
|
635
|
-
to={`${paths.flows()}?owner=${encodeURIComponent(owner)}`}
|
|
636
|
-
data-nav-item
|
|
637
|
-
style={{ paddingLeft: indent(1) }}
|
|
638
|
-
className="tree-row mono flex items-center py-[3px] pr-2 text-accent hover:bg-surface"
|
|
639
|
-
>
|
|
640
|
-
view all {group.entries.length} →
|
|
641
|
-
</NavLink>
|
|
642
|
-
) : null}
|
|
643
|
-
</>
|
|
644
|
-
</Unfold>
|
|
645
|
-
</div>
|
|
646
|
-
);
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
// ---------------------------------------------------------------------------
|
|
650
|
-
// Pins
|
|
651
|
-
// ---------------------------------------------------------------------------
|
|
652
|
-
|
|
653
|
-
/**
|
|
654
|
-
* The reader's own shortlist, above everything the catalog decided. It only
|
|
655
|
-
* exists while there is something on it: an empty band with a header would
|
|
656
|
-
* teach the feature by taking up room, which is the one thing the pane cannot
|
|
657
|
-
* spare.
|
|
658
|
-
*/
|
|
659
|
-
function PinnedSection({
|
|
660
|
-
open,
|
|
661
|
-
onToggle,
|
|
662
|
-
}: {
|
|
663
|
-
open: boolean;
|
|
664
|
-
onToggle: () => void;
|
|
665
|
-
}) {
|
|
666
|
-
const pins = usePinsStore((s) => s.pins);
|
|
667
|
-
const toggle = usePinsStore((s) => s.toggle);
|
|
668
|
-
const reorder = usePinsStore((s) => s.reorder);
|
|
669
|
-
// Which row is under the cursor is a ref, not state: a drop fires in the same
|
|
670
|
-
// gesture that started the drag, and a state write scheduled by `dragstart`
|
|
671
|
-
// is not guaranteed to have landed by the time `drop` reads it. The state
|
|
672
|
-
// beside it only dims the row, so it is allowed to arrive late.
|
|
673
|
-
const dragFrom = useRef<number | null>(null);
|
|
674
|
-
const [dragging, setDragging] = useState<number | null>(null);
|
|
675
|
-
|
|
676
|
-
// Resolved at render, so a pin taken against a build that no longer has the
|
|
677
|
-
// event simply stops being drawn rather than pointing into nothing.
|
|
678
|
-
const rows = useMemo(
|
|
679
|
-
() =>
|
|
680
|
-
pins
|
|
681
|
-
.map((pin, at) => ({ at, resolved: resolvePin(pin) }))
|
|
682
|
-
.filter(
|
|
683
|
-
(r): r is { at: number; resolved: NonNullable<typeof r.resolved> } =>
|
|
684
|
-
r.resolved !== null,
|
|
685
|
-
),
|
|
686
|
-
[pins],
|
|
687
|
-
);
|
|
688
|
-
if (rows.length === 0) return null;
|
|
689
|
-
|
|
690
|
-
return (
|
|
691
|
-
<Section
|
|
692
|
-
title="Pinned"
|
|
693
|
-
count={rows.length}
|
|
694
|
-
open={open}
|
|
695
|
-
onToggle={onToggle}
|
|
696
|
-
first
|
|
697
|
-
>
|
|
698
|
-
{rows.map(({ at, resolved }) => (
|
|
699
|
-
<div
|
|
700
|
-
key={`${resolved.pin.kind}:${resolved.pin.id}`}
|
|
701
|
-
draggable
|
|
702
|
-
onDragStart={(e) => {
|
|
703
|
-
dragFrom.current = at;
|
|
704
|
-
e.dataTransfer.effectAllowed = "move";
|
|
705
|
-
setDragging(at);
|
|
706
|
-
}}
|
|
707
|
-
onDragEnd={() => {
|
|
708
|
-
dragFrom.current = null;
|
|
709
|
-
setDragging(null);
|
|
710
|
-
}}
|
|
711
|
-
onDragOver={(e) => {
|
|
712
|
-
e.preventDefault();
|
|
713
|
-
e.dataTransfer.dropEffect = "move";
|
|
714
|
-
}}
|
|
715
|
-
onDrop={(e) => {
|
|
716
|
-
e.preventDefault();
|
|
717
|
-
const from = dragFrom.current;
|
|
718
|
-
if (from !== null) reorder(from, at);
|
|
719
|
-
dragFrom.current = null;
|
|
720
|
-
setDragging(null);
|
|
721
|
-
}}
|
|
722
|
-
className={`group relative flex items-stretch ${
|
|
723
|
-
dragging === at ? "opacity-50" : ""
|
|
724
|
-
}`}
|
|
725
|
-
>
|
|
726
|
-
<NavLink
|
|
727
|
-
to={resolved.path}
|
|
728
|
-
end
|
|
729
|
-
draggable={false}
|
|
730
|
-
data-nav-item
|
|
731
|
-
title={resolved.title}
|
|
732
|
-
style={({ isActive }) => ({
|
|
733
|
-
paddingLeft: indent(0),
|
|
734
|
-
background: isActive ? "var(--surface-2)" : undefined,
|
|
735
|
-
borderLeftWidth: 2,
|
|
736
|
-
borderLeftStyle: "solid",
|
|
737
|
-
borderLeftColor: isActive ? "var(--accent)" : "transparent",
|
|
738
|
-
})}
|
|
739
|
-
className="tree-row flex min-w-0 flex-1 items-center gap-1.5 py-[3px] pr-2 t-micro transition-colors hover:bg-surface"
|
|
740
|
-
>
|
|
741
|
-
<GripVertical
|
|
742
|
-
size={12}
|
|
743
|
-
aria-hidden
|
|
744
|
-
className="block shrink-0 opacity-0 group-hover:opacity-100"
|
|
745
|
-
style={{ color: "var(--fg-faint)" }}
|
|
746
|
-
/>
|
|
747
|
-
<KindIcon
|
|
748
|
-
kind={resolved.kind}
|
|
749
|
-
{...(resolved.contextId ? { contextId: resolved.contextId } : {})}
|
|
750
|
-
/>
|
|
751
|
-
<span className="truncate">{resolved.name}</span>
|
|
752
|
-
</NavLink>
|
|
753
|
-
<button
|
|
754
|
-
type="button"
|
|
755
|
-
onClick={() => toggle(resolved.pin)}
|
|
756
|
-
aria-label={`Unpin ${resolved.name}`}
|
|
757
|
-
title={`Unpin ${resolved.name}`}
|
|
758
|
-
className="flex shrink-0 items-center px-2 text-muted opacity-0 t-micro transition-opacity hover:text-ink group-hover:opacity-100 focus-visible:opacity-100"
|
|
759
|
-
>
|
|
760
|
-
<PinOff size={13} aria-hidden />
|
|
761
|
-
</button>
|
|
762
|
-
</div>
|
|
763
|
-
))}
|
|
764
|
-
</Section>
|
|
765
|
-
);
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
// ---------------------------------------------------------------------------
|
|
769
|
-
// The bottom group. A sibling of the scroller rather than a row inside it, so
|
|
770
|
-
// "pinned to the bottom" is a fact about the layout and not about how far the
|
|
771
|
-
// tree happens to have been scrolled.
|
|
772
|
-
// ---------------------------------------------------------------------------
|
|
773
|
-
|
|
774
|
-
function BottomGroup() {
|
|
775
|
-
// Both halves of the Problems page, because that is what the row opens. The
|
|
776
|
-
// badge is red while anything on it is an error and amber when only the
|
|
777
|
-
// schema disagrees with itself - a page of warnings is not a clean estate,
|
|
778
|
-
// and a green tick over it would be the one lie the row can tell.
|
|
779
|
-
const found = useMemo(
|
|
780
|
-
() => [
|
|
781
|
-
...problems(catalog),
|
|
782
|
-
...protoProblems(catalog, index),
|
|
783
|
-
...dataProblems(catalog, index),
|
|
784
|
-
],
|
|
785
|
-
[],
|
|
786
|
-
);
|
|
787
|
-
const errors = found.filter((p) => p.severity === "error").length;
|
|
788
|
-
const colour =
|
|
789
|
-
errors > 0 ? "var(--status-unresolved)" : "var(--status-declared)";
|
|
790
|
-
|
|
791
|
-
return (
|
|
792
|
-
<div className="shrink-0 border-t bg-canvas border-line py-0.5">
|
|
793
|
-
<NavLink
|
|
794
|
-
to={paths.problems()}
|
|
795
|
-
end
|
|
796
|
-
data-nav-item
|
|
797
|
-
aria-keyshortcuts="g p"
|
|
798
|
-
title={
|
|
799
|
-
found.length === 0
|
|
800
|
-
? "Problems — every edge in the catalog lands somewhere"
|
|
801
|
-
: `Problems — ${found.length} to look at`
|
|
802
|
-
}
|
|
803
|
-
style={({ isActive }) => ({
|
|
804
|
-
paddingLeft: indent(0),
|
|
805
|
-
background: isActive ? "var(--surface-2)" : undefined,
|
|
806
|
-
borderLeftWidth: 2,
|
|
807
|
-
borderLeftStyle: "solid",
|
|
808
|
-
borderLeftColor: isActive ? "var(--accent)" : "transparent",
|
|
809
|
-
})}
|
|
810
|
-
className="tree-row flex items-center gap-1.5 py-[3px] pr-2 t-micro transition-colors hover:bg-surface"
|
|
811
|
-
>
|
|
812
|
-
<TriangleAlert
|
|
813
|
-
size={14}
|
|
814
|
-
aria-hidden
|
|
815
|
-
className="block shrink-0"
|
|
816
|
-
style={{ color: found.length === 0 ? "var(--fg-muted)" : colour }}
|
|
817
|
-
/>
|
|
818
|
-
<span className="truncate">Problems</span>
|
|
819
|
-
{found.length === 0 ? (
|
|
820
|
-
<Check
|
|
821
|
-
size={13}
|
|
822
|
-
aria-hidden
|
|
823
|
-
className="ml-auto block shrink-0 text-muted"
|
|
824
|
-
/>
|
|
825
|
-
) : (
|
|
826
|
-
<span
|
|
827
|
-
className="mono tnum ml-auto shrink-0"
|
|
828
|
-
style={{ color: colour }}
|
|
829
|
-
>
|
|
830
|
-
{found.length}
|
|
831
|
-
</span>
|
|
832
|
-
)}
|
|
833
|
-
</NavLink>
|
|
834
|
-
<NavLink
|
|
835
|
-
to={paths.settings()}
|
|
836
|
-
data-nav-item
|
|
837
|
-
title="Settings — projects, plugins and appearance"
|
|
838
|
-
style={({ isActive }) => ({
|
|
839
|
-
paddingLeft: indent(0),
|
|
840
|
-
background: isActive ? "var(--surface-2)" : undefined,
|
|
841
|
-
borderLeftWidth: 2,
|
|
842
|
-
borderLeftStyle: "solid",
|
|
843
|
-
borderLeftColor: isActive ? "var(--accent)" : "transparent",
|
|
844
|
-
})}
|
|
845
|
-
className="tree-row flex items-center gap-1.5 py-[3px] pr-2 text-muted t-micro transition-colors hover:bg-surface hover:text-ink"
|
|
846
|
-
>
|
|
847
|
-
<Settings2 size={14} aria-hidden className="block shrink-0" />
|
|
848
|
-
<span className="truncate">Settings</span>
|
|
849
|
-
</NavLink>
|
|
850
|
-
</div>
|
|
851
|
-
);
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
/**
|
|
855
|
-
* What is left of the tree at 48px. Not a menu: every button here does the one
|
|
856
|
-
* thing the rail can honestly offer, which is to give the tree its width back
|
|
857
|
-
* and land the reader on the section they pointed at.
|
|
858
|
-
*/
|
|
859
|
-
function IconRail({ onExpand }: { onExpand: () => void }) {
|
|
860
|
-
const sections: { key: string; kind: Kind; label: string }[] = [
|
|
861
|
-
{ key: "flows", kind: "flow", label: "Flows" },
|
|
862
|
-
{ key: "domains", kind: "context", label: "Contexts" },
|
|
863
|
-
// Conditional for the same reason the band is: at 48px a button that opens
|
|
864
|
-
// an empty section is worse than no button.
|
|
865
|
-
...(allModules(catalog).length > 0
|
|
866
|
-
? [{ key: "registry", kind: "module" as Kind, label: "Registry" }]
|
|
867
|
-
: []),
|
|
868
|
-
{ key: "adrs", kind: "adr", label: "Decisions" },
|
|
869
|
-
];
|
|
870
|
-
return (
|
|
871
|
-
<nav
|
|
872
|
-
className="flex h-full flex-col items-center gap-1 border-r py-3 border-line bg-canvas"
|
|
873
|
-
aria-label="Catalog (collapsed)"
|
|
874
|
-
>
|
|
875
|
-
{/* The mark survives the collapse; the wordmark does not fit and is not
|
|
876
|
-
missed - at 48px the rose is the whole identity. */}
|
|
877
|
-
<CompassRose size={18} className="mb-1 text-ink" />
|
|
878
|
-
<button
|
|
879
|
-
type="button"
|
|
880
|
-
onClick={onExpand}
|
|
881
|
-
title="Expand the catalog"
|
|
882
|
-
aria-label="Expand the catalog"
|
|
883
|
-
aria-expanded={false}
|
|
884
|
-
className="flex size-8 items-center justify-center rounded-control text-muted t-micro transition-colors hover:bg-surface hover:text-ink"
|
|
885
|
-
>
|
|
886
|
-
<PanelLeftOpen size={16} aria-hidden />
|
|
887
|
-
</button>
|
|
888
|
-
<span aria-hidden className="my-1 h-px w-6 bg-line" />
|
|
889
|
-
{sections.map(({ key, kind, label }) => (
|
|
890
|
-
<button
|
|
891
|
-
key={key}
|
|
892
|
-
type="button"
|
|
893
|
-
onClick={onExpand}
|
|
894
|
-
title={`${label} — expand the catalog`}
|
|
895
|
-
aria-label={`${label} — expand the catalog`}
|
|
896
|
-
className="flex size-8 items-center justify-center rounded-control t-micro transition-colors hover:bg-surface"
|
|
897
|
-
>
|
|
898
|
-
<KindIcon kind={kind} size={16} />
|
|
899
|
-
</button>
|
|
900
|
-
))}
|
|
901
|
-
<NavLink
|
|
902
|
-
to={paths.settings()}
|
|
903
|
-
title="Settings"
|
|
904
|
-
aria-label="Settings"
|
|
905
|
-
className={({ isActive }) =>
|
|
906
|
-
`mt-auto flex size-8 items-center justify-center rounded-control t-micro transition-colors hover:bg-surface ${
|
|
907
|
-
isActive ? "bg-surface text-accent" : "text-muted hover:text-ink"
|
|
908
|
-
}`
|
|
909
|
-
}
|
|
910
|
-
>
|
|
911
|
-
<Settings2 size={16} aria-hidden />
|
|
912
|
-
</NavLink>
|
|
913
|
-
</nav>
|
|
914
|
-
);
|
|
915
|
-
}
|
|
916
199
|
|
|
917
200
|
// ---------------------------------------------------------------------------
|
|
918
201
|
|
|
@@ -1082,7 +365,7 @@ export function Sidebar({
|
|
|
1082
365
|
return () => clearTimeout(timer);
|
|
1083
366
|
}, [selection, collapsed, sections, groups]);
|
|
1084
367
|
|
|
1085
|
-
if (railed) return <
|
|
368
|
+
if (railed) return <SidebarIconRail onExpand={() => onExpand?.()} />;
|
|
1086
369
|
|
|
1087
370
|
// Namespaces the selection light's layoutId to this tree.
|
|
1088
371
|
const treeId = useId();
|
|
@@ -1444,7 +727,7 @@ export function Sidebar({
|
|
|
1444
727
|
) : null}
|
|
1445
728
|
</div>
|
|
1446
729
|
|
|
1447
|
-
<
|
|
730
|
+
<SidebarBottomGroup />
|
|
1448
731
|
</LayoutGroup>
|
|
1449
732
|
</nav>
|
|
1450
733
|
);
|