@shortlink-org/portolan 0.2.1 → 0.2.2
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/cli/init.mjs +14 -6
- package/cli/init.test.mjs +8 -1
- package/cli/portolan.mjs +56 -7
- 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/catalog-sources.mjs +12 -1
- package/scripts/catalog-sources.test.mjs +35 -0
- 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 +363 -28
- package/scripts/local-api.test.mjs +195 -1
- package/scripts/plugin-host.mjs +14 -3
- package/scripts/plugin-host.test.mjs +8 -0
- package/scripts/schema.mjs +7 -0
- package/src/app/CatalogApp.tsx +19 -4
- package/src/app/CommandPalette.tsx +7 -2
- package/src/app/toast.tsx +11 -7
- package/src/catalog.test.ts +33 -0
- package/src/catalog.ts +190 -8
- 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.ts +24 -0
- package/src/components/PageHeader.tsx +34 -0
- package/src/components/ShapeRows.tsx +12 -0
- package/src/components/SourceDoc.tsx +27 -12
- package/src/components/SourcePreview.tsx +124 -36
- 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 -0
- package/src/landing/LandingPage.tsx +12 -8
- 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/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 +17 -4
- package/src/lib/product.ts +7 -0
- 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 +55 -0
- package/src/merge.ts +30 -0
- 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 +382 -44
- package/src/routes.test.ts +2 -0
- package/src/routes.ts +2 -1
- package/src/selection/DetailPanel.tsx +213 -113
- package/src/testing/setup.ts +14 -0
package/src/catalog.ts
CHANGED
|
@@ -705,12 +705,7 @@ export interface Store {
|
|
|
705
705
|
}
|
|
706
706
|
|
|
707
707
|
export type RedisOperation =
|
|
708
|
-
| "
|
|
709
|
-
| "write"
|
|
710
|
-
| "delete"
|
|
711
|
-
| "exists"
|
|
712
|
-
| "expire"
|
|
713
|
-
| "count";
|
|
708
|
+
"read" | "write" | "delete" | "exists" | "expire" | "count";
|
|
714
709
|
|
|
715
710
|
export const REDIS_OPERATIONS: readonly RedisOperation[] = [
|
|
716
711
|
"read",
|
|
@@ -730,6 +725,19 @@ export interface RedisKeyspace {
|
|
|
730
725
|
/** Value type where a write or marshal call proves it. */
|
|
731
726
|
value?: string;
|
|
732
727
|
source?: string;
|
|
728
|
+
/** Aggregate or block whose value this key family holds, when provable. */
|
|
729
|
+
persists?: { aggregate?: string; block?: string };
|
|
730
|
+
/** Individual client calls, before they are folded into `operations`. */
|
|
731
|
+
accesses?: RedisAccess[];
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
export interface RedisAccess {
|
|
735
|
+
operation: RedisOperation;
|
|
736
|
+
/** Enclosing adapter method, for example `Store.Get`. */
|
|
737
|
+
method?: string;
|
|
738
|
+
ttl?: string;
|
|
739
|
+
value?: string;
|
|
740
|
+
source?: string;
|
|
733
741
|
}
|
|
734
742
|
|
|
735
743
|
/**
|
|
@@ -880,18 +888,37 @@ export interface Step {
|
|
|
880
888
|
id: string;
|
|
881
889
|
from: string;
|
|
882
890
|
to: string; // participant ids; from === to is a self-message
|
|
883
|
-
kind: "rpc" | "event" | "call";
|
|
891
|
+
kind: "rpc" | "event" | "call" | "response";
|
|
884
892
|
ref?: string; // Event.id or RpcCall.id - resolvable, or status must be unresolved
|
|
885
893
|
label?: string;
|
|
886
894
|
status: Status;
|
|
887
895
|
note?: string;
|
|
888
896
|
line?: string;
|
|
897
|
+
/** Synchronous request step this synthesized response returns from. */
|
|
898
|
+
replyTo?: string;
|
|
899
|
+
/** Proven HTTP wire contract for a response step. */
|
|
900
|
+
http?: HTTPResponse;
|
|
889
901
|
/** Source function execution enters here, when an extractor can prove it. */
|
|
890
902
|
continuesAt?: string;
|
|
891
903
|
/** Source functions proven to execute on the path represented by this step. */
|
|
892
904
|
reaches?: string[];
|
|
893
905
|
/** Exact asynchronous send/receive evidence used for flow composition. */
|
|
894
906
|
handoff?: FlowHandoff;
|
|
907
|
+
/** Repository call resolved to a concrete store operation after merge. */
|
|
908
|
+
storeAccess?: FlowStoreAccess;
|
|
909
|
+
}
|
|
910
|
+
export interface HTTPResponse {
|
|
911
|
+
status?: number;
|
|
912
|
+
contentType?: string;
|
|
913
|
+
body?: string;
|
|
914
|
+
/** RPC method whose response value is serialized into this body. */
|
|
915
|
+
bodyRef?: string;
|
|
916
|
+
encoding?: string;
|
|
917
|
+
outcome?: "success" | "error";
|
|
918
|
+
warning?: string;
|
|
919
|
+
source?: string;
|
|
920
|
+
/** Shape recovered directly from a literal response body. */
|
|
921
|
+
fields?: Field[];
|
|
895
922
|
}
|
|
896
923
|
export interface FlowHandoff {
|
|
897
924
|
kind: "message" | "job";
|
|
@@ -900,6 +927,14 @@ export interface FlowHandoff {
|
|
|
900
927
|
message?: string;
|
|
901
928
|
direction: "send" | "receive";
|
|
902
929
|
}
|
|
930
|
+
export interface FlowStoreAccess {
|
|
931
|
+
store: string;
|
|
932
|
+
method?: string;
|
|
933
|
+
operation?: RedisOperation;
|
|
934
|
+
keyspace?: string;
|
|
935
|
+
/** Concrete adapter call rather than the use-case-side repository call. */
|
|
936
|
+
source?: string;
|
|
937
|
+
}
|
|
903
938
|
export interface Parallel {
|
|
904
939
|
type: "parallel";
|
|
905
940
|
id: string;
|
|
@@ -1410,6 +1445,8 @@ export interface CatalogIndex {
|
|
|
1410
1445
|
tablesByAggregate: Map<string, Table[]>;
|
|
1411
1446
|
/** aggregate id -> views naming it in `persists`, in catalog order */
|
|
1412
1447
|
viewsByAggregate: Map<string, View[]>;
|
|
1448
|
+
/** aggregate id -> Redis key families holding it, in catalog order */
|
|
1449
|
+
keyspacesByAggregate: Map<string, RedisKeyspaceOwner[]>;
|
|
1413
1450
|
/** block id -> columns whose `maps` path lands in that block */
|
|
1414
1451
|
columnsByBlock: Map<string, ColumnOwner[]>;
|
|
1415
1452
|
/** table id -> the columns pointing at it through a foreign key */
|
|
@@ -1444,6 +1481,11 @@ export interface InterfaceOwner {
|
|
|
1444
1481
|
provided: RpcService;
|
|
1445
1482
|
}
|
|
1446
1483
|
|
|
1484
|
+
export interface RedisKeyspaceOwner {
|
|
1485
|
+
keyspace: RedisKeyspace;
|
|
1486
|
+
store: Store;
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1447
1489
|
export function buildIndex(catalog: Catalog): CatalogIndex {
|
|
1448
1490
|
const serviceById = new Map<string, Service>();
|
|
1449
1491
|
const serviceContext = new Map<string, BoundedContext>();
|
|
@@ -1492,6 +1534,7 @@ export function buildIndex(catalog: Catalog): CatalogIndex {
|
|
|
1492
1534
|
const storesOwnedBy = new Map<string, Store[]>();
|
|
1493
1535
|
const tablesByAggregate = new Map<string, Table[]>();
|
|
1494
1536
|
const viewsByAggregate = new Map<string, View[]>();
|
|
1537
|
+
const keyspacesByAggregate = new Map<string, RedisKeyspaceOwner[]>();
|
|
1495
1538
|
const columnsByBlock = new Map<string, ColumnOwner[]>();
|
|
1496
1539
|
const fkIntoTable = new Map<string, ColumnOwner[]>();
|
|
1497
1540
|
|
|
@@ -1587,6 +1630,14 @@ export function buildIndex(catalog: Catalog): CatalogIndex {
|
|
|
1587
1630
|
owned.push(store);
|
|
1588
1631
|
storesOwnedBy.set(store.owner, owned);
|
|
1589
1632
|
|
|
1633
|
+
for (const keyspace of store.keyspaces ?? []) {
|
|
1634
|
+
const aggregateId = keyspace.persists?.aggregate;
|
|
1635
|
+
if (!aggregateId) continue;
|
|
1636
|
+
const list = keyspacesByAggregate.get(aggregateId) ?? [];
|
|
1637
|
+
list.push({ keyspace, store });
|
|
1638
|
+
keyspacesByAggregate.set(aggregateId, list);
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1590
1641
|
for (const table of store.tables) {
|
|
1591
1642
|
tableById.set(table.id, { table, store });
|
|
1592
1643
|
// A table may name only the block it holds. That block belongs to an
|
|
@@ -1720,6 +1771,7 @@ export function buildIndex(catalog: Catalog): CatalogIndex {
|
|
|
1720
1771
|
servicesUsingModule,
|
|
1721
1772
|
tablesByAggregate,
|
|
1722
1773
|
viewsByAggregate,
|
|
1774
|
+
keyspacesByAggregate,
|
|
1723
1775
|
columnsByBlock,
|
|
1724
1776
|
fkIntoTable,
|
|
1725
1777
|
};
|
|
@@ -1839,6 +1891,7 @@ export function validateCatalog(catalog: Catalog): Catalog {
|
|
|
1839
1891
|
|
|
1840
1892
|
const eventIds = new Set<string>();
|
|
1841
1893
|
const rpcIds = new Set<string>();
|
|
1894
|
+
const storeIds = new Set(allStores(catalog).map((store) => store.id));
|
|
1842
1895
|
|
|
1843
1896
|
assertUniqueSlugs(
|
|
1844
1897
|
catalog.contexts.map((c) => c.id),
|
|
@@ -2161,6 +2214,7 @@ export function validateCatalog(catalog: Catalog): Catalog {
|
|
|
2161
2214
|
|
|
2162
2215
|
const steps = walkSteps(flow.steps);
|
|
2163
2216
|
const stepIds = new Set<string>();
|
|
2217
|
+
const stepById = new Map<string, Step>();
|
|
2164
2218
|
for (const step of steps) {
|
|
2165
2219
|
if (stepIds.has(step.id)) {
|
|
2166
2220
|
fail(
|
|
@@ -2169,6 +2223,16 @@ export function validateCatalog(catalog: Catalog): Catalog {
|
|
|
2169
2223
|
);
|
|
2170
2224
|
}
|
|
2171
2225
|
stepIds.add(step.id);
|
|
2226
|
+
stepById.set(step.id, step);
|
|
2227
|
+
|
|
2228
|
+
if (
|
|
2229
|
+
!(["rpc", "event", "call", "response"] as const).includes(step.kind)
|
|
2230
|
+
) {
|
|
2231
|
+
fail(
|
|
2232
|
+
`flow "${flow.slug}" step "${step.id}" has unknown kind "${step.kind}"`,
|
|
2233
|
+
`flow ${flow.id} / step ${step.id}`,
|
|
2234
|
+
);
|
|
2235
|
+
}
|
|
2172
2236
|
|
|
2173
2237
|
if (step.reaches?.some((entrypoint) => entrypoint.length === 0)) {
|
|
2174
2238
|
fail(
|
|
@@ -2208,6 +2272,55 @@ export function validateCatalog(catalog: Catalog): Catalog {
|
|
|
2208
2272
|
);
|
|
2209
2273
|
}
|
|
2210
2274
|
}
|
|
2275
|
+
if (step.storeAccess) {
|
|
2276
|
+
if (step.kind !== "call") {
|
|
2277
|
+
fail(
|
|
2278
|
+
`flow "${flow.slug}" step "${step.id}" has store access metadata but is not a call`,
|
|
2279
|
+
`flow ${flow.id} / step ${step.id}`,
|
|
2280
|
+
);
|
|
2281
|
+
}
|
|
2282
|
+
if (!storeIds.has(step.storeAccess.store)) {
|
|
2283
|
+
fail(
|
|
2284
|
+
`flow "${flow.slug}" step "${step.id}" names unknown store "${step.storeAccess.store}"`,
|
|
2285
|
+
`flow ${flow.id} / step ${step.id}`,
|
|
2286
|
+
);
|
|
2287
|
+
}
|
|
2288
|
+
if (
|
|
2289
|
+
step.storeAccess.operation !== undefined &&
|
|
2290
|
+
!REDIS_OPERATIONS.includes(step.storeAccess.operation)
|
|
2291
|
+
) {
|
|
2292
|
+
fail(
|
|
2293
|
+
`flow "${flow.slug}" step "${step.id}" has unknown store operation "${step.storeAccess.operation}"`,
|
|
2294
|
+
`flow ${flow.id} / step ${step.id}`,
|
|
2295
|
+
);
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
if (step.http) {
|
|
2299
|
+
if (step.kind !== "response") {
|
|
2300
|
+
fail(
|
|
2301
|
+
`flow "${flow.slug}" step "${step.id}" has HTTP response metadata but is not a response`,
|
|
2302
|
+
`flow ${flow.id} / step ${step.id}`,
|
|
2303
|
+
);
|
|
2304
|
+
}
|
|
2305
|
+
if (
|
|
2306
|
+
step.http.status !== undefined &&
|
|
2307
|
+
(step.http.status < 100 || step.http.status > 599)
|
|
2308
|
+
) {
|
|
2309
|
+
fail(
|
|
2310
|
+
`flow "${flow.slug}" response "${step.id}" has invalid HTTP status ${step.http.status}`,
|
|
2311
|
+
`flow ${flow.id} / step ${step.id}`,
|
|
2312
|
+
);
|
|
2313
|
+
}
|
|
2314
|
+
if (
|
|
2315
|
+
step.http.outcome !== undefined &&
|
|
2316
|
+
!(["success", "error"] as const).includes(step.http.outcome)
|
|
2317
|
+
) {
|
|
2318
|
+
fail(
|
|
2319
|
+
`flow "${flow.slug}" response "${step.id}" has unknown HTTP outcome "${step.http.outcome}"`,
|
|
2320
|
+
`flow ${flow.id} / step ${step.id}`,
|
|
2321
|
+
);
|
|
2322
|
+
}
|
|
2323
|
+
}
|
|
2211
2324
|
|
|
2212
2325
|
if (!lanes.has(step.from)) {
|
|
2213
2326
|
fail(
|
|
@@ -2231,6 +2344,37 @@ export function validateCatalog(catalog: Catalog): Catalog {
|
|
|
2231
2344
|
}
|
|
2232
2345
|
}
|
|
2233
2346
|
}
|
|
2347
|
+
|
|
2348
|
+
for (const step of steps) {
|
|
2349
|
+
if (step.kind !== "response") {
|
|
2350
|
+
if (step.replyTo !== undefined) {
|
|
2351
|
+
fail(
|
|
2352
|
+
`flow "${flow.slug}" step "${step.id}" is not a response but names replyTo "${step.replyTo}"`,
|
|
2353
|
+
`flow ${flow.id} / step ${step.id}`,
|
|
2354
|
+
);
|
|
2355
|
+
}
|
|
2356
|
+
continue;
|
|
2357
|
+
}
|
|
2358
|
+
if (!step.replyTo) {
|
|
2359
|
+
fail(
|
|
2360
|
+
`flow "${flow.slug}" response "${step.id}" names no request in replyTo`,
|
|
2361
|
+
`flow ${flow.id} / step ${step.id}`,
|
|
2362
|
+
);
|
|
2363
|
+
}
|
|
2364
|
+
const request = stepById.get(step.replyTo);
|
|
2365
|
+
if (!request || request.kind !== "rpc") {
|
|
2366
|
+
fail(
|
|
2367
|
+
`flow "${flow.slug}" response "${step.id}" replies to "${step.replyTo}", which is not an rpc request`,
|
|
2368
|
+
`flow ${flow.id} / step ${step.id}`,
|
|
2369
|
+
);
|
|
2370
|
+
}
|
|
2371
|
+
if (step.from !== request.to || step.to !== request.from) {
|
|
2372
|
+
fail(
|
|
2373
|
+
`flow "${flow.slug}" response "${step.id}" does not reverse request "${request.id}"`,
|
|
2374
|
+
`flow ${flow.id} / step ${step.id}`,
|
|
2375
|
+
);
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2234
2378
|
}
|
|
2235
2379
|
|
|
2236
2380
|
validateExternals(catalog);
|
|
@@ -2511,7 +2655,10 @@ function validateStores(catalog: Catalog): void {
|
|
|
2511
2655
|
}
|
|
2512
2656
|
keyPatterns.add(keyspace.pattern);
|
|
2513
2657
|
if (keyspace.operations.length === 0) {
|
|
2514
|
-
fail(
|
|
2658
|
+
fail(
|
|
2659
|
+
`Redis key pattern "${keyspace.pattern}" has no operations`,
|
|
2660
|
+
where,
|
|
2661
|
+
);
|
|
2515
2662
|
}
|
|
2516
2663
|
const operations = new Set<string>();
|
|
2517
2664
|
for (const operation of keyspace.operations) {
|
|
@@ -2529,6 +2676,41 @@ function validateStores(catalog: Catalog): void {
|
|
|
2529
2676
|
}
|
|
2530
2677
|
operations.add(operation);
|
|
2531
2678
|
}
|
|
2679
|
+
const aggregateId = keyspace.persists?.aggregate;
|
|
2680
|
+
if (aggregateId && !aggregates.has(aggregateId)) {
|
|
2681
|
+
fail(
|
|
2682
|
+
`Redis key pattern "${keyspace.pattern}" persists unknown aggregate "${aggregateId}"`,
|
|
2683
|
+
where,
|
|
2684
|
+
);
|
|
2685
|
+
}
|
|
2686
|
+
const blockId = keyspace.persists?.block;
|
|
2687
|
+
if (blockId) {
|
|
2688
|
+
const blockAggregate =
|
|
2689
|
+
aggregates.get(blockId.split(".").slice(0, -1).join("."));
|
|
2690
|
+
const belongs = blockAggregate
|
|
2691
|
+
? aggregateBlocks(blockAggregate).some(({ block }) => block.id === blockId)
|
|
2692
|
+
: false;
|
|
2693
|
+
if (!belongs) {
|
|
2694
|
+
fail(
|
|
2695
|
+
`Redis key pattern "${keyspace.pattern}" persists unknown block "${blockId}"`,
|
|
2696
|
+
where,
|
|
2697
|
+
);
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2700
|
+
for (const access of keyspace.accesses ?? []) {
|
|
2701
|
+
if (!REDIS_OPERATIONS.includes(access.operation)) {
|
|
2702
|
+
fail(
|
|
2703
|
+
`Redis key pattern "${keyspace.pattern}" has access operation "${access.operation}"; expected one of ${REDIS_OPERATIONS.join(", ")}`,
|
|
2704
|
+
where,
|
|
2705
|
+
);
|
|
2706
|
+
}
|
|
2707
|
+
if (!operations.has(access.operation)) {
|
|
2708
|
+
fail(
|
|
2709
|
+
`Redis key pattern "${keyspace.pattern}" has ${access.operation} access absent from its operations summary`,
|
|
2710
|
+
where,
|
|
2711
|
+
);
|
|
2712
|
+
}
|
|
2713
|
+
}
|
|
2532
2714
|
}
|
|
2533
2715
|
|
|
2534
2716
|
for (const table of store.tables) {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
type CatScene = "about" | "clear" | "unchanged" | "onboarding" | "search" | "trial";
|
|
4
|
+
|
|
5
|
+
const IMAGE: Record<CatScene, string> = {
|
|
6
|
+
about: "cat-about-v1.webp",
|
|
7
|
+
clear: "cat-clear-skies-v1.webp",
|
|
8
|
+
unchanged: "cat-no-changes-v1.webp",
|
|
9
|
+
onboarding: "cat-onboarding-v1.webp",
|
|
10
|
+
search: "404-cat-v1.webp",
|
|
11
|
+
trial: "cat-trial-success-v1.webp",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function CatIllustration({ scene, className = "" }: { scene: CatScene; className?: string }) {
|
|
15
|
+
return <img src={`${import.meta.env.BASE_URL}${IMAGE[scene]}`} alt="" aria-hidden draggable={false} className={`cat-illustration ${className}`} />;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A rare, positive empty state. Ordinary missing rows keep using `Empty`. */
|
|
19
|
+
export function CatEmptyState({ scene, title, children, meta, className = "" }: { scene: Extract<CatScene, "clear" | "unchanged" | "onboarding">; title: string; children: ReactNode; meta?: ReactNode; className?: string }) {
|
|
20
|
+
return (
|
|
21
|
+
<div className={`cat-empty-state ${className}`}>
|
|
22
|
+
<CatIllustration scene={scene} className="cat-empty-illustration" />
|
|
23
|
+
<div className="min-w-0">
|
|
24
|
+
<h2 className="text-md font-semibold text-ink">{title}</h2>
|
|
25
|
+
<div className="mt-1 text-muted">{children}</div>
|
|
26
|
+
{meta ? <div className="mono mt-3 text-faint">{meta}</div> : null}
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
FloatingPortal,
|
|
4
|
+
autoUpdate,
|
|
5
|
+
flip,
|
|
6
|
+
offset,
|
|
7
|
+
shift,
|
|
8
|
+
useDismiss,
|
|
9
|
+
useFloating,
|
|
10
|
+
useFocus,
|
|
11
|
+
useHover,
|
|
12
|
+
useInteractions,
|
|
13
|
+
useRole,
|
|
14
|
+
} from "@floating-ui/react";
|
|
15
|
+
import { useQuery } from "@tanstack/react-query";
|
|
16
|
+
import { useForgeAccess } from "../app/forge-access";
|
|
17
|
+
import { buildInfo, repositoryCommitHref } from "../lib/build-info";
|
|
18
|
+
import { forgeRepoFromUrl } from "../lib/github-catalog";
|
|
19
|
+
import type { ForgeCommit, ForgeRepo } from "../lib/github-catalog";
|
|
20
|
+
import { forgeCommitQuery } from "../lib/queries";
|
|
21
|
+
import { absoluteTime, relativeTime } from "../lib/format";
|
|
22
|
+
import { Ident } from "./Ident";
|
|
23
|
+
|
|
24
|
+
const buildRepo = forgeRepoFromUrl(buildInfo.repoUrl, buildInfo.forge);
|
|
25
|
+
|
|
26
|
+
function repoFrom(value: string): ForgeRepo | null {
|
|
27
|
+
const url = /^https?:\/\//.test(value) ? value : `https://${value}`;
|
|
28
|
+
return forgeRepoFromUrl(url);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function commitHref(repo: ForgeRepo, commit: string): string {
|
|
32
|
+
const route = repo.provider === "gitlab" ? "/-/commit/" : "/commit/";
|
|
33
|
+
return `${repo.webUrl.replace(/\/$/, "")}${route}${encodeURIComponent(commit)}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function CommitLink({
|
|
37
|
+
commit,
|
|
38
|
+
repo,
|
|
39
|
+
repository,
|
|
40
|
+
href,
|
|
41
|
+
length = 7,
|
|
42
|
+
className = "",
|
|
43
|
+
}: {
|
|
44
|
+
commit: string;
|
|
45
|
+
/** Pass null when the repository is known to be unavailable. */
|
|
46
|
+
repo?: ForgeRepo | null;
|
|
47
|
+
/** A repository URL or host/owner/name, used when a ForgeRepo is not already available. */
|
|
48
|
+
repository?: string;
|
|
49
|
+
href?: string | null;
|
|
50
|
+
length?: number;
|
|
51
|
+
className?: string;
|
|
52
|
+
}) {
|
|
53
|
+
const parsed = repository === undefined ? undefined : repoFrom(repository);
|
|
54
|
+
const resolvedRepo = repo !== undefined ? repo : parsed !== undefined ? parsed : buildRepo;
|
|
55
|
+
const hasExplicitRepo = repo !== undefined || repository !== undefined;
|
|
56
|
+
const resolvedHref = href !== undefined
|
|
57
|
+
? href
|
|
58
|
+
: resolvedRepo
|
|
59
|
+
? commitHref(resolvedRepo, commit)
|
|
60
|
+
: hasExplicitRepo
|
|
61
|
+
? null
|
|
62
|
+
: repositoryCommitHref(commit);
|
|
63
|
+
const label = commit.slice(0, length);
|
|
64
|
+
|
|
65
|
+
if (!resolvedHref) return <Ident value={commit} className={className}>{label}</Ident>;
|
|
66
|
+
if (!resolvedRepo) {
|
|
67
|
+
return <a href={resolvedHref} target="_blank" rel="noreferrer" className={`rounded-[4px] text-accent underline-offset-4 hover:underline ${className}`}>{label}</a>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return <CommitPopover commit={commit} repo={resolvedRepo} href={resolvedHref} label={label} className={className} />;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function CommitPopover({ commit, repo, href, label, className }: { commit: string; repo: ForgeRepo; href: string; label: string; className: string }) {
|
|
74
|
+
const [open, setOpen] = useState(false);
|
|
75
|
+
const { refs, floatingStyles, context } = useFloating({
|
|
76
|
+
open,
|
|
77
|
+
onOpenChange: setOpen,
|
|
78
|
+
placement: "bottom-start",
|
|
79
|
+
strategy: "fixed",
|
|
80
|
+
middleware: [offset(6), flip({ padding: 8 }), shift({ padding: 8 })],
|
|
81
|
+
whileElementsMounted: autoUpdate,
|
|
82
|
+
});
|
|
83
|
+
const hover = useHover(context, { move: false, delay: { open: 100, close: 80 } });
|
|
84
|
+
const focus = useFocus(context);
|
|
85
|
+
const dismiss = useDismiss(context);
|
|
86
|
+
const role = useRole(context, { role: "tooltip" });
|
|
87
|
+
const { getReferenceProps, getFloatingProps } = useInteractions([hover, focus, dismiss, role]);
|
|
88
|
+
const access = useForgeAccess();
|
|
89
|
+
const token = access.tokenFor(repo);
|
|
90
|
+
const details = useQuery({
|
|
91
|
+
...forgeCommitQuery(repo, token, commit),
|
|
92
|
+
enabled: open,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<>
|
|
97
|
+
<a
|
|
98
|
+
ref={refs.setReference}
|
|
99
|
+
href={href}
|
|
100
|
+
target="_blank"
|
|
101
|
+
rel="noreferrer"
|
|
102
|
+
aria-label={`Open commit ${commit} on the forge`}
|
|
103
|
+
className={`rounded-[4px] text-accent underline-offset-4 hover:underline focus-visible:outline focus-visible:outline-1 focus-visible:outline-accent ${className}`}
|
|
104
|
+
{...getReferenceProps()}
|
|
105
|
+
>
|
|
106
|
+
{label}
|
|
107
|
+
</a>
|
|
108
|
+
{open ? (
|
|
109
|
+
<FloatingPortal>
|
|
110
|
+
<div
|
|
111
|
+
ref={refs.setFloating}
|
|
112
|
+
style={floatingStyles}
|
|
113
|
+
className="z-50"
|
|
114
|
+
{...getFloatingProps()}
|
|
115
|
+
>
|
|
116
|
+
<div className="palette-in pointer-events-none w-80 max-w-[calc(100vw-1rem)] rounded-control border bg-canvas p-3 mono text-xs normal-case border-line-strong text-muted shadow-md">
|
|
117
|
+
<div className="label">commit</div>
|
|
118
|
+
{details.isPending ? (
|
|
119
|
+
<div className="mt-2">loading commit…</div>
|
|
120
|
+
) : details.isError ? (
|
|
121
|
+
<div className="mt-2">commit details unavailable</div>
|
|
122
|
+
) : (
|
|
123
|
+
<CommitHoverContent commit={details.data} />
|
|
124
|
+
)}
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
</FloatingPortal>
|
|
128
|
+
) : null}
|
|
129
|
+
</>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function CommitHoverContent({ commit }: { commit: ForgeCommit }) {
|
|
134
|
+
const hasStats = commit.additions !== null || commit.deletions !== null;
|
|
135
|
+
const body = commit.body.replace(/\s+/g, " ").trim();
|
|
136
|
+
const summary = body.length > 180 ? `${body.slice(0, 177).trimEnd()}…` : body;
|
|
137
|
+
return (
|
|
138
|
+
<div className="mt-2">
|
|
139
|
+
<div className="flex items-center gap-2">
|
|
140
|
+
{commit.avatarUrl ? <img src={commit.avatarUrl} alt="" className="size-7 rounded-full" /> : null}
|
|
141
|
+
<div className="min-w-0">
|
|
142
|
+
<div className="truncate text-ink">{commit.author}</div>
|
|
143
|
+
{commit.authoredAt ? <div className="text-faint" title={absoluteTime(commit.authoredAt)}>{relativeTime(commit.authoredAt)}</div> : null}
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
<div className="mt-2 font-sans font-medium text-ink">{commit.title}</div>
|
|
147
|
+
{summary ? <div className="mt-1 font-sans text-muted">{summary}</div> : null}
|
|
148
|
+
<div className="mt-2 flex items-center gap-3 border-t pt-2 border-line text-faint">
|
|
149
|
+
<span className="truncate" title={commit.sha}>{commit.sha}</span>
|
|
150
|
+
{hasStats ? <span className="ml-auto shrink-0"><span className="text-verified">+{commit.additions ?? 0}</span>{" "}<span className="text-unresolved">−{commit.deletions ?? 0}</span></span> : null}
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
@@ -44,6 +44,18 @@ export function useWorkspace(): string | null {
|
|
|
44
44
|
: null;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
export function useEditorTarget(location: SourceLocation | null): {
|
|
48
|
+
href: string;
|
|
49
|
+
name: string;
|
|
50
|
+
} | null {
|
|
51
|
+
const workspace = useWorkspace();
|
|
52
|
+
const editor = useEditorStore((s) => s.editor);
|
|
53
|
+
if (!workspace || !location) return null;
|
|
54
|
+
const where = editorWhere(location);
|
|
55
|
+
const href = where ? editorHref(editor, workspace, where) : null;
|
|
56
|
+
return href ? { href, name: editorName(editor) } : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
47
59
|
export function EditorLink({
|
|
48
60
|
location,
|
|
49
61
|
variant = "icon",
|
|
@@ -54,14 +66,9 @@ export function EditorLink({
|
|
|
54
66
|
variant?: "icon" | "text";
|
|
55
67
|
className?: string;
|
|
56
68
|
}) {
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const where = editorWhere(location);
|
|
61
|
-
const href = where ? editorHref(editor, workspace, where) : null;
|
|
62
|
-
if (!href) return null;
|
|
63
|
-
|
|
64
|
-
const name = editorName(editor);
|
|
69
|
+
const target = useEditorTarget(location);
|
|
70
|
+
if (!target) return null;
|
|
71
|
+
const { href, name } = target;
|
|
65
72
|
// No target: a scheme URL hands off to the editor and leaves the page
|
|
66
73
|
// where it is. A new tab would be a blank tab.
|
|
67
74
|
return variant === "icon" ? (
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Check, Copy, Terminal } from "lucide-react";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import type { RpcMethod, RpcService } from "../catalog";
|
|
4
|
+
import { catalog } from "../data";
|
|
5
|
+
import { toClipboard } from "../lib/clipboard";
|
|
6
|
+
import {
|
|
7
|
+
grpcRequestJson,
|
|
8
|
+
grpcurlCommand,
|
|
9
|
+
} from "../lib/grpc-reference";
|
|
10
|
+
|
|
11
|
+
const SHOWN_MS = 1000;
|
|
12
|
+
|
|
13
|
+
function CopyButton({ value, label }: { value: string; label: string }) {
|
|
14
|
+
const [copied, setCopied] = useState(false);
|
|
15
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
16
|
+
|
|
17
|
+
useEffect(
|
|
18
|
+
() => () => {
|
|
19
|
+
if (timer.current) clearTimeout(timer.current);
|
|
20
|
+
},
|
|
21
|
+
[],
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const copy = () => {
|
|
25
|
+
void toClipboard(value).then((ok) => {
|
|
26
|
+
setCopied(ok);
|
|
27
|
+
if (timer.current) clearTimeout(timer.current);
|
|
28
|
+
timer.current = setTimeout(() => setCopied(false), SHOWN_MS);
|
|
29
|
+
});
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<button
|
|
34
|
+
type="button"
|
|
35
|
+
onClick={copy}
|
|
36
|
+
className="row-action opacity-100"
|
|
37
|
+
aria-label={`Copy ${label}`}
|
|
38
|
+
title={`Copy ${label}`}
|
|
39
|
+
>
|
|
40
|
+
{copied ? <Check size={12} aria-hidden /> : <Copy size={12} aria-hidden />}
|
|
41
|
+
{copied ? "copied" : "copy"}
|
|
42
|
+
</button>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The executable-looking part of a protobuf reference, collapsed per method. */
|
|
47
|
+
export function GrpcMethodReference({
|
|
48
|
+
provided,
|
|
49
|
+
method,
|
|
50
|
+
}: {
|
|
51
|
+
provided: RpcService;
|
|
52
|
+
method: RpcMethod;
|
|
53
|
+
}) {
|
|
54
|
+
const json = grpcRequestJson(provided, method, catalog.defs);
|
|
55
|
+
const command = grpcurlCommand(provided, method, catalog.defs);
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<details className="group mt-2 overflow-hidden rounded-control border border-line bg-surface">
|
|
59
|
+
<summary className="flex cursor-pointer list-none items-center gap-2 px-2.5 py-1.5 text-muted hover:text-ink">
|
|
60
|
+
<Terminal size={13} aria-hidden />
|
|
61
|
+
<span>Call locally</span>
|
|
62
|
+
<span className="chip mono">grpcurl</span>
|
|
63
|
+
<span className="ml-auto text-faint group-open:hidden">show</span>
|
|
64
|
+
<span className="ml-auto hidden text-faint group-open:inline">hide</span>
|
|
65
|
+
</summary>
|
|
66
|
+
<div className="grid gap-3 border-t border-line p-3 lg:grid-cols-2">
|
|
67
|
+
<div className="min-w-0">
|
|
68
|
+
<div className="mb-1.5 flex items-center justify-between gap-2">
|
|
69
|
+
<span className="label">ProtoJSON request</span>
|
|
70
|
+
<CopyButton value={json} label="request JSON" />
|
|
71
|
+
</div>
|
|
72
|
+
<pre className="mono max-h-64 overflow-auto whitespace-pre rounded-control border border-line bg-canvas p-3 text-muted">
|
|
73
|
+
{json}
|
|
74
|
+
</pre>
|
|
75
|
+
</div>
|
|
76
|
+
<div className="min-w-0">
|
|
77
|
+
<div className="mb-1.5 flex items-center justify-between gap-2">
|
|
78
|
+
<span className="label">Command</span>
|
|
79
|
+
<CopyButton value={command} label="grpcurl command" />
|
|
80
|
+
</div>
|
|
81
|
+
<pre className="mono max-h-64 overflow-auto whitespace-pre rounded-control border border-line bg-canvas p-3 text-muted">
|
|
82
|
+
{command}
|
|
83
|
+
</pre>
|
|
84
|
+
<p className="mt-2 text-muted">
|
|
85
|
+
Replace <span className="mono text-ink">localhost:50051</span> and
|
|
86
|
+
remove <span className="mono text-ink">-plaintext</span> when the
|
|
87
|
+
endpoint uses TLS.
|
|
88
|
+
</p>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
</details>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -17,6 +17,7 @@ import { headingSlug } from "../lib/derive";
|
|
|
17
17
|
import { Mermaid } from "./Mermaid";
|
|
18
18
|
import { MarkdownTable } from "./MarkdownTable";
|
|
19
19
|
import { remarkTableDirective } from "../lib/table-directive";
|
|
20
|
+
import { readmeAssetHref } from "../lib/readme-assets";
|
|
20
21
|
|
|
21
22
|
function textOf(node: React.ReactNode): string {
|
|
22
23
|
if (typeof node === "string" || typeof node === "number") return String(node);
|
|
@@ -146,9 +147,11 @@ function Pre(props: ComponentPropsWithoutRef<"pre">) {
|
|
|
146
147
|
export function Markdown({
|
|
147
148
|
children,
|
|
148
149
|
mermaid = false,
|
|
150
|
+
sourceRoot,
|
|
149
151
|
}: {
|
|
150
152
|
children: string;
|
|
151
153
|
mermaid?: boolean;
|
|
154
|
+
sourceRoot?: string;
|
|
152
155
|
}) {
|
|
153
156
|
return (
|
|
154
157
|
<div className="prose">
|
|
@@ -164,6 +167,20 @@ export function Markdown({
|
|
|
164
167
|
// said not to.
|
|
165
168
|
table: MarkdownTable,
|
|
166
169
|
...(mermaid ? { pre: Pre } : {}),
|
|
170
|
+
...(sourceRoot ? {
|
|
171
|
+
a: (props: ComponentPropsWithoutRef<"a">) => {
|
|
172
|
+
const href = typeof props.href === "string"
|
|
173
|
+
? readmeAssetHref(sourceRoot, props.href, import.meta.env.BASE_URL)
|
|
174
|
+
: null;
|
|
175
|
+
return <a {...props} href={href ?? props.href} {...(href ? { target: "_blank", rel: "noreferrer" } : {})} />;
|
|
176
|
+
},
|
|
177
|
+
img: (props: ComponentPropsWithoutRef<"img">) => {
|
|
178
|
+
const src = typeof props.src === "string"
|
|
179
|
+
? readmeAssetHref(sourceRoot, props.src, import.meta.env.BASE_URL)
|
|
180
|
+
: null;
|
|
181
|
+
return <img {...props} src={src ?? props.src} />;
|
|
182
|
+
},
|
|
183
|
+
} : {}),
|
|
167
184
|
}}
|
|
168
185
|
>
|
|
169
186
|
{children}
|