@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/pages/Settings.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useDocumentTitle } from "../app/title";
|
|
2
2
|
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
|
3
|
-
import { Link, Navigate, NavLink, Route, Routes } from "react-router";
|
|
3
|
+
import { Link, Navigate, NavLink, Route, Routes, useLocation } from "react-router";
|
|
4
4
|
import {
|
|
5
5
|
ArrowLeft,
|
|
6
6
|
Box,
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
ChevronDown,
|
|
9
9
|
CircleAlert,
|
|
10
10
|
FolderGit2,
|
|
11
|
+
GitBranch,
|
|
11
12
|
KeyRound,
|
|
12
13
|
LoaderCircle,
|
|
13
14
|
Play,
|
|
@@ -37,23 +38,29 @@ import {
|
|
|
37
38
|
forgetRepositoryCredential,
|
|
38
39
|
inspectRepository,
|
|
39
40
|
LocalApiError,
|
|
41
|
+
removeProject as removeLocalProject,
|
|
40
42
|
saveRepositoryCredential,
|
|
41
43
|
startGeneration,
|
|
42
44
|
startProjectTrial,
|
|
43
45
|
subscribeToRun,
|
|
46
|
+
undoProjectRemoval,
|
|
44
47
|
} from "../lib/local-api";
|
|
45
48
|
import type { Discovery, ProjectDraft, ProjectPlan, RunEvent } from "../lib/local-api";
|
|
46
49
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
47
50
|
import { localKeys, localStatusQuery } from "../lib/queries";
|
|
48
51
|
import { sourceHref, treeHref } from "../lib/source-link";
|
|
49
52
|
import { paths } from "../routes";
|
|
50
|
-
import { Empty, SectionTitle } from "../components/PageHeader";
|
|
53
|
+
import { CapabilityEmpty, Empty, SectionTitle } from "../components/PageHeader";
|
|
51
54
|
import { Modal } from "../components/Overlay";
|
|
52
55
|
import { MachineDocs } from "../components/MachineDocs";
|
|
53
56
|
import { DeliverySettings } from "./settings/DeliverySettings";
|
|
54
57
|
import { PreferencesSettings } from "./settings/PreferencesSettings";
|
|
58
|
+
import { AboutSettings } from "./settings/AboutSettings";
|
|
59
|
+
import { CatEmptyState, CatIllustration } from "../components/CatIllustration";
|
|
60
|
+
import { CommitLink } from "../components/CommitLink";
|
|
55
61
|
|
|
56
62
|
type Health = "healthy" | "changed" | "failed" | "unchecked";
|
|
63
|
+
type ProjectSource = ProjectDraft["source"];
|
|
57
64
|
|
|
58
65
|
const SetupContext = createContext<SetupInfo>(staticSetupInfo);
|
|
59
66
|
const useSetup = () => useContext(SetupContext);
|
|
@@ -205,6 +212,7 @@ function BuildHealth() {
|
|
|
205
212
|
|
|
206
213
|
function projectHref(project: SetupProject): string | null {
|
|
207
214
|
const group = project.group ?? project.context;
|
|
215
|
+
if (project.components?.length) return group && catalog.contexts.some((item) => item.id === group) ? paths.context(group) : null;
|
|
208
216
|
const component = project.component ?? project.service;
|
|
209
217
|
const context = catalog.contexts.find((item) => item.id === group);
|
|
210
218
|
if (!context) return null;
|
|
@@ -217,6 +225,13 @@ function projectHref(project: SetupProject): string | null {
|
|
|
217
225
|
return service ? paths.service(context.id, service.slug) : paths.context(context.id);
|
|
218
226
|
}
|
|
219
227
|
|
|
228
|
+
function starterProject(setupInfo: SetupInfo): SetupProject | undefined {
|
|
229
|
+
if (setupInfo.projects.length !== 1) return undefined;
|
|
230
|
+
const project = setupInfo.projects[0];
|
|
231
|
+
const steps = setupInfo.steps.filter((step) => step.projectId === project?.id);
|
|
232
|
+
return project?.root === "." && !project.groupKind && !project.componentKind && steps.length === 1 && steps[0]?.plugin === "project" ? project : undefined;
|
|
233
|
+
}
|
|
234
|
+
|
|
220
235
|
function forge(project: SetupProject): { href: string; title: string } | null {
|
|
221
236
|
if (project.repository) {
|
|
222
237
|
return {
|
|
@@ -286,7 +301,7 @@ function StepWarnings({ warnings }: { warnings: string[] }) {
|
|
|
286
301
|
);
|
|
287
302
|
}
|
|
288
303
|
|
|
289
|
-
function ProjectCard({ project }: { project: SetupProject }) {
|
|
304
|
+
function ProjectCard({ project, onRemove }: { project: SetupProject; onRemove?: (project: SetupProject) => void }) {
|
|
290
305
|
const setupInfo = useSetup();
|
|
291
306
|
const declared = setupInfo.steps.filter((step) => step.projectId === project.id);
|
|
292
307
|
const runSteps = setupInfo.run?.steps.filter((step) => step.projectId === project.id) ?? [];
|
|
@@ -319,13 +334,14 @@ function ProjectCard({ project }: { project: SetupProject }) {
|
|
|
319
334
|
) : null}
|
|
320
335
|
</div>
|
|
321
336
|
</div>
|
|
337
|
+
{onRemove ? <button type="button" className="tbtn shrink-0 p-1.5 text-unresolved" onClick={() => onRemove(project)} aria-label={`Remove ${project.name}`} title={`Remove ${project.name}`}><Trash2 size={15} aria-hidden /></button> : null}
|
|
322
338
|
</div>
|
|
323
339
|
|
|
324
340
|
<dl className="mono mt-4 grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-1 text-muted">
|
|
325
|
-
<dt>scope</dt><dd className="truncate text-ink">{[project.group ?? project.context, project.component ?? project.service].filter(Boolean).join(" · ") || "estate"}</dd>
|
|
341
|
+
<dt>scope</dt><dd className="truncate text-ink">{project.components?.length ? `${project.group ?? project.context} · ${project.components.length} components` : [project.group ?? project.context, project.component ?? project.service].filter(Boolean).join(" · ") || "estate"}</dd>
|
|
326
342
|
<dt>pipeline</dt><dd className="text-ink">{declared.length} {plural(declared.length, "step")}</dd>
|
|
327
343
|
<dt>fragments</dt><dd className="text-ink">{sources.length}</dd>
|
|
328
|
-
<dt>commit</dt><dd className="truncate text-ink" title={commits.join(", ")}>{commits.length === 0 ? "not stamped" : commits.length === 1 ? commits[0] : `${commits.length} source commits`}</dd>
|
|
344
|
+
<dt>commit</dt><dd className="truncate text-ink" title={commits.join(", ")}>{commits.length === 0 ? "not stamped" : commits.length === 1 ? <CommitLink commit={commits[0]!} repository={project.repository} length={12} /> : `${commits.length} source commits`}</dd>
|
|
329
345
|
</dl>
|
|
330
346
|
|
|
331
347
|
<div className="mt-4 flex flex-wrap gap-1.5" aria-label="Active plugins">
|
|
@@ -476,6 +492,21 @@ function PluginsList() {
|
|
|
476
492
|
}
|
|
477
493
|
|
|
478
494
|
const FIELD = "mono w-full rounded-control border border-line bg-canvas px-3 py-2 text-ink outline-none focus:border-accent";
|
|
495
|
+
const PROJECT_RESUME_KEY = "portolan.onboarding.project-source.v1";
|
|
496
|
+
const PROJECT_PRESETS: Array<{ label: string; kind: NonNullable<ProjectDraft["componentKind"]>; summary: string }> = [
|
|
497
|
+
{ label: "Service", kind: "service", summary: "A deployable business capability." },
|
|
498
|
+
{ label: "Web app", kind: "webapp", summary: "A browser-facing application." },
|
|
499
|
+
{ label: "Worker", kind: "worker", summary: "A long-running background consumer." },
|
|
500
|
+
{ label: "CLI", kind: "cli", summary: "A command-line application." },
|
|
501
|
+
{ label: "Library", kind: "library", summary: "Reusable code, not deployed alone." },
|
|
502
|
+
];
|
|
503
|
+
|
|
504
|
+
function readProjectResume(): { source: ProjectSource; path: string; repository: string; ref: string; sourcePath: string } | null {
|
|
505
|
+
try {
|
|
506
|
+
const value = JSON.parse(localStorage.getItem(PROJECT_RESUME_KEY) ?? "null");
|
|
507
|
+
return value && (value.source === "local" || value.source === "external") ? value : null;
|
|
508
|
+
} catch { return null; }
|
|
509
|
+
}
|
|
479
510
|
|
|
480
511
|
function Field({ label, value, onChange, placeholder, required = false }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; required?: boolean }) {
|
|
481
512
|
return (
|
|
@@ -486,15 +517,59 @@ function Field({ label, value, onChange, placeholder, required = false }: { labe
|
|
|
486
517
|
);
|
|
487
518
|
}
|
|
488
519
|
|
|
489
|
-
function AddProjectCard({
|
|
520
|
+
function AddProjectCard({ onAdd }: { onAdd: (source: ProjectSource) => void }) {
|
|
490
521
|
return (
|
|
491
|
-
<
|
|
492
|
-
<
|
|
493
|
-
<span className="flex size-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
522
|
+
<article className="rounded-card border border-line bg-canvas p-card shadow-xs xl:col-span-2">
|
|
523
|
+
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
|
524
|
+
<span className="flex size-10 shrink-0 items-center justify-center rounded-control border border-line-strong bg-surface text-accent">
|
|
525
|
+
<Plus size={19} aria-hidden />
|
|
526
|
+
</span>
|
|
527
|
+
<div className="min-w-0 flex-1">
|
|
528
|
+
<div className="font-semibold text-ink">Add a project</div>
|
|
529
|
+
<p className="mt-1 text-muted">Connect a local folder or Git repository. Portolan will detect the right extractors.</p>
|
|
530
|
+
</div>
|
|
531
|
+
<div className="flex flex-wrap gap-2 sm:justify-end">
|
|
532
|
+
<button type="button" className="tbtn px-3 py-1.5" onClick={() => onAdd("local")}>
|
|
533
|
+
<FolderGit2 size={15} aria-hidden /> Local folder
|
|
534
|
+
</button>
|
|
535
|
+
<button type="button" className="product-primary" onClick={() => onAdd("external")}>
|
|
536
|
+
<GitBranch size={15} aria-hidden /> Git repository
|
|
537
|
+
</button>
|
|
538
|
+
</div>
|
|
539
|
+
</div>
|
|
540
|
+
</article>
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function OnboardingCard({ starter, onAdd, onRemove }: { starter?: SetupProject; onAdd: (source: ProjectSource) => void; onRemove: (project: SetupProject) => void }) {
|
|
545
|
+
return (
|
|
546
|
+
<article className="mb-grid overflow-hidden rounded-card border border-accent bg-canvas shadow-xs">
|
|
547
|
+
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4 border-b border-line bg-surface px-card py-3">
|
|
548
|
+
<div>
|
|
549
|
+
<div className="font-semibold text-ink">Start with your architecture</div>
|
|
550
|
+
<p className="mt-1 text-muted">Replace the starter catalog with the project you actually want to describe.</p>
|
|
551
|
+
</div>
|
|
552
|
+
<CatIllustration scene="onboarding" className="cat-onboarding-illustration" />
|
|
553
|
+
</div>
|
|
554
|
+
<div className="grid gap-0 md:grid-cols-2">
|
|
555
|
+
<div className="flex items-start gap-3 p-card md:border-r md:border-line">
|
|
556
|
+
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-accent text-canvas"><Plus size={15} aria-hidden /></span>
|
|
557
|
+
<div className="min-w-0 flex-1">
|
|
558
|
+
<div className="flex flex-wrap items-center gap-2"><span className="font-medium text-ink">Add your first project</span><span className="chip status-verified">recommended</span></div>
|
|
559
|
+
<p className="mt-1 text-muted">A successful trial replaces the starter and adds your project in one reviewed change.</p>
|
|
560
|
+
<div className="mt-3 flex flex-wrap gap-2"><button type="button" className="product-primary" onClick={() => onAdd("local")}><FolderGit2 size={14} aria-hidden /> Local folder</button><button type="button" className="tbtn" onClick={() => onAdd("external")}><GitBranch size={14} aria-hidden /> Git repository</button></div>
|
|
561
|
+
</div>
|
|
562
|
+
</div>
|
|
563
|
+
<div className="flex items-start gap-3 p-card">
|
|
564
|
+
<span className={`flex size-7 shrink-0 items-center justify-center rounded-full ${starter ? "bg-surface text-muted" : "bg-verified text-canvas"}`}>{starter ? <Trash2 size={14} aria-hidden /> : <Check size={15} aria-hidden />}</span>
|
|
565
|
+
<div className="min-w-0 flex-1">
|
|
566
|
+
<div className="font-medium text-ink">Or start with an empty catalog</div>
|
|
567
|
+
<p className="mt-1 text-muted">Remove the starter now if you want to connect projects later. Application source code is never touched.</p>
|
|
568
|
+
{starter ? <button type="button" className="tbtn mt-3 text-unresolved" onClick={() => onRemove(starter)}><Trash2 size={14} aria-hidden /> Remove starter only</button> : <div className="mono mt-2 text-verified">starter removed</div>}
|
|
569
|
+
</div>
|
|
570
|
+
</div>
|
|
571
|
+
</div>
|
|
572
|
+
</article>
|
|
498
573
|
);
|
|
499
574
|
}
|
|
500
575
|
|
|
@@ -546,8 +621,11 @@ function RepositoryFailure({ failure, message, token, onTokenChange, onForget, b
|
|
|
546
621
|
);
|
|
547
622
|
}
|
|
548
623
|
|
|
549
|
-
function Wizard({ open, onClose, onAdded, onRunStarted }: { open: boolean; onClose: () => void; onAdded: (setup: SetupInfo) => void; onRunStarted: (runId: string) => void }) {
|
|
550
|
-
const
|
|
624
|
+
function Wizard({ open, initialSource, onClose, onAdded, onRunStarted }: { open: boolean; initialSource: ProjectSource; onClose: () => void; onAdded: (setup: SetupInfo) => void; onRunStarted: (runId: string) => void }) {
|
|
625
|
+
const setupInfo = useSetup();
|
|
626
|
+
const starter = starterProject(setupInfo);
|
|
627
|
+
const say = useToastStore((state) => state.say);
|
|
628
|
+
const [source, setSource] = useState<ProjectSource>(initialSource);
|
|
551
629
|
const [stage, setStage] = useState<"source" | "scope" | "configure" | "trial">("source");
|
|
552
630
|
const [path, setPath] = useState("");
|
|
553
631
|
const [repository, setRepository] = useState("");
|
|
@@ -568,12 +646,26 @@ function Wizard({ open, onClose, onAdded, onRunStarted }: { open: boolean; onClo
|
|
|
568
646
|
const [repositoryError, setRepositoryError] = useState<LocalApiError | null>(null);
|
|
569
647
|
const [retryComponent, setRetryComponent] = useState<{ path: string; batch: boolean } | null>(null);
|
|
570
648
|
const [credentialToken, setCredentialToken] = useState("");
|
|
649
|
+
const [resumed, setResumed] = useState(false);
|
|
571
650
|
|
|
572
651
|
useEffect(() => {
|
|
652
|
+
if (open) {
|
|
653
|
+
setSource(initialSource);
|
|
654
|
+
const saved = readProjectResume();
|
|
655
|
+
if (saved?.source === initialSource) {
|
|
656
|
+
setPath(saved.path); setRepository(saved.repository); setRef(saved.ref); setSourcePath(saved.sourcePath); setResumed(true);
|
|
657
|
+
}
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
573
660
|
if (!open) {
|
|
574
|
-
setStage("source"); setSource("local"); setPath(""); setRepository(""); setRef("main"); setSourcePath(""); setDiscovery(null); setScopeDiscovery(null); setInspectionCommit(""); setSelectedComponents([]); setPendingComponents([]); setBatchPosition(null); setDraft(null); setPlan(null); setTrialRunId(null); setTrialEvents([]); setError(""); setRepositoryError(null); setRetryComponent(null); setCredentialToken(""); setBusy(false);
|
|
661
|
+
setStage("source"); setSource("local"); setPath(""); setRepository(""); setRef("main"); setSourcePath(""); setDiscovery(null); setScopeDiscovery(null); setInspectionCommit(""); setSelectedComponents([]); setPendingComponents([]); setBatchPosition(null); setDraft(null); setPlan(null); setTrialRunId(null); setTrialEvents([]); setError(""); setRepositoryError(null); setRetryComponent(null); setCredentialToken(""); setResumed(false); setBusy(false);
|
|
575
662
|
}
|
|
576
|
-
}, [open]);
|
|
663
|
+
}, [initialSource, open]);
|
|
664
|
+
|
|
665
|
+
useEffect(() => {
|
|
666
|
+
if (!open || (!path.trim() && !repository.trim())) return;
|
|
667
|
+
try { localStorage.setItem(PROJECT_RESUME_KEY, JSON.stringify({ source, path, repository, ref, sourcePath })); } catch {}
|
|
668
|
+
}, [open, path, ref, repository, source, sourcePath]);
|
|
577
669
|
|
|
578
670
|
useEffect(() => {
|
|
579
671
|
if (!trialRunId) return;
|
|
@@ -593,6 +685,11 @@ function Wizard({ open, onClose, onAdded, onRunStarted }: { open: boolean; onClo
|
|
|
593
685
|
clearRepositoryFailure();
|
|
594
686
|
}
|
|
595
687
|
|
|
688
|
+
function startOver() {
|
|
689
|
+
try { localStorage.removeItem(PROJECT_RESUME_KEY); } catch {}
|
|
690
|
+
setSource(initialSource); setPath(""); setRepository(""); setRef("main"); setSourcePath(""); setResumed(false); clearRepositoryFailure();
|
|
691
|
+
}
|
|
692
|
+
|
|
596
693
|
async function detect() {
|
|
597
694
|
setBusy(true); setError("");
|
|
598
695
|
setRepositoryError(null); setRetryComponent(null);
|
|
@@ -613,7 +710,9 @@ function Wizard({ open, onClose, onAdded, onRunStarted }: { open: boolean; onClo
|
|
|
613
710
|
|
|
614
711
|
function configure(found: Discovery, commit: string, selectedSourcePath: string, projectId?: string) {
|
|
615
712
|
setDiscovery(found);
|
|
616
|
-
|
|
713
|
+
const plugins = found.detections.filter((item) => item.selected).map((item) => item.plugin);
|
|
714
|
+
const domainModel = plugins.some((plugin) => ["go-domain", "ts-domain", "rust-domain", "java-domain", "django-domain"].includes(plugin));
|
|
715
|
+
setDraft({ source, root: found.root, repository, ref, commit, sourcePath: selectedSourcePath, ...found.defaults, ...(projectId ? { id: projectId } : {}), contextName: found.defaults.group ? found.defaults.group.replace(/(^|-)([a-z])/g, (_, gap, letter) => `${gap ? " " : ""}${letter.toUpperCase()}`) : "", contextSummary: "", classification: "supporting", groupKind: domainModel ? "bounded-context" : "system", componentKind: domainModel ? "service" : "application", replaceStarter: Boolean(starter), plugins });
|
|
617
716
|
setStage("configure");
|
|
618
717
|
}
|
|
619
718
|
|
|
@@ -703,8 +802,19 @@ function Wizard({ open, onClose, onAdded, onRunStarted }: { open: boolean; onClo
|
|
|
703
802
|
setTrialRunId(null); setTrialEvents([]); setPlan(null);
|
|
704
803
|
await chooseComponent(next, true);
|
|
705
804
|
} else {
|
|
805
|
+
try { localStorage.removeItem(PROJECT_RESUME_KEY); } catch {}
|
|
706
806
|
onClose();
|
|
707
807
|
if (result.run) onRunStarted(result.run.runId);
|
|
808
|
+
else say(`${result.project.name} added to portolan.json.`, {
|
|
809
|
+
label: "Undo",
|
|
810
|
+
run: () => { void (async () => {
|
|
811
|
+
try {
|
|
812
|
+
const restored = await undoProjectRemoval(result.undoToken);
|
|
813
|
+
onAdded(restored.setup);
|
|
814
|
+
say(`${result.project.name} addition undone.`);
|
|
815
|
+
} catch (cause) { say(cause instanceof Error ? cause.message : String(cause)); }
|
|
816
|
+
})(); },
|
|
817
|
+
});
|
|
708
818
|
}
|
|
709
819
|
} catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); setBusy(false); }
|
|
710
820
|
}
|
|
@@ -716,9 +826,17 @@ function Wizard({ open, onClose, onAdded, onRunStarted }: { open: boolean; onClo
|
|
|
716
826
|
const trial = trialEvents.find((event): event is Extract<RunEvent, { type: "project-trial-ready" }> => event.type === "project-trial-ready");
|
|
717
827
|
const logs = trialEvents.filter((event): event is Extract<RunEvent, { type: "log" }> => event.type === "log");
|
|
718
828
|
const trialRunning = stage === "trial" && !finished;
|
|
829
|
+
const confirmedDeployables = discovery?.deployables.filter((candidate) => candidate.confidence === "high") ?? [];
|
|
830
|
+
const splitsDeployables = confirmedDeployables.length > 1 && draft?.component === discovery?.defaults.component;
|
|
831
|
+
const quality = trial && draft ? [
|
|
832
|
+
{ label: "Stable project identity", done: Boolean(draft.id.trim() && draft.name.trim()) },
|
|
833
|
+
{ label: "Architecture placement", done: Boolean(draft.group.trim() && (splitsDeployables || draft.component.trim())) },
|
|
834
|
+
{ label: "Catalog facts extracted", done: trial.facts.some((fact) => fact.count > 0) },
|
|
835
|
+
{ label: "Extraction without warnings", done: trial.warnings.length === 0 },
|
|
836
|
+
] : [];
|
|
719
837
|
const totalSteps = pipeline?.stepCount ?? plan?.steps.length ?? 0;
|
|
720
838
|
const percent = totalSteps ? Math.round((completedSteps.length / totalSteps) * 100) : 0;
|
|
721
|
-
const heading = stage === "source" ? "Add a project" : stage === "scope" ? "Choose a component" : stage === "configure" ? "
|
|
839
|
+
const heading = stage === "source" ? "Add a project" : stage === "scope" ? "Choose a component" : stage === "configure" ? "Place the project" : "Trial extraction";
|
|
722
840
|
function back() {
|
|
723
841
|
if (stage === "trial") { if (trialRunId) void disposeProjectTrial(trialRunId); setStage("configure"); setTrialRunId(null); setTrialEvents([]); setPlan(null); }
|
|
724
842
|
else if (stage === "configure" && scopeDiscovery) { setStage("scope"); setPendingComponents([]); setBatchPosition(null); }
|
|
@@ -729,14 +847,16 @@ function Wizard({ open, onClose, onAdded, onRunStarted }: { open: boolean; onClo
|
|
|
729
847
|
<Modal open={open} onClose={busy || trialRunning ? () => {} : onClose} label={heading} width="min(800px,94vw)">
|
|
730
848
|
<div className="flex items-center gap-3 border-b border-line px-5 py-4">
|
|
731
849
|
{stage !== "source" ? <button type="button" className="tbtn p-1.5" onClick={back} disabled={trialRunning || busy} aria-label="Back"><ArrowLeft size={16} /></button> : null}
|
|
732
|
-
<div className="min-w-0 flex-1"><div className="font-semibold text-ink">{heading}</div><div className="mono mt-0.5 text-muted">{stage === "source" ? "1 / 4 · source" : stage === "scope" ? "2 / 4 · scope" : stage === "configure" ? `3 / 4 ·
|
|
850
|
+
<div className="min-w-0 flex-1"><div className="font-semibold text-ink">{heading}</div><div className="mono mt-0.5 text-muted">{stage === "source" ? "1 / 4 · source" : stage === "scope" ? "2 / 4 · scope" : stage === "configure" ? `3 / 4 · identity & scope${batchPosition ? ` · ${batchPosition.current} / ${batchPosition.total}` : ""}` : `4 / 4 · proof${batchPosition ? ` · ${batchPosition.current} / ${batchPosition.total}` : ""}`}</div></div>
|
|
733
851
|
<button type="button" className="tbtn p-1.5" onClick={onClose} disabled={busy || trialRunning} aria-label="Close"><X size={16} /></button>
|
|
734
852
|
</div>
|
|
735
853
|
<div className="min-h-0 flex-1 overflow-y-auto p-5">
|
|
736
854
|
{stage === "source" ? (
|
|
737
855
|
repositoryError ? <div className="flex flex-wrap items-center justify-between gap-3 rounded-control border border-line bg-surface px-3 py-2"><div className="min-w-0"><div className="mono truncate text-ink">{repository}</div><div className="mono mt-0.5 text-muted">{ref || "HEAD"}{sourcePath ? ` · ${sourcePath}` : ""}</div></div><button type="button" className="tbtn" onClick={clearRepositoryFailure}>Edit source</button></div> : <div className="space-y-5">
|
|
856
|
+
{resumed ? <div className="flex flex-wrap items-center justify-between gap-2 rounded-control border border-accent bg-surface px-3 py-2"><span className="text-muted"><span className="font-medium text-ink">Draft restored.</span> Continue with the source from your last session.</span><button type="button" className="tbtn" onClick={startOver}>Start over</button></div> : null}
|
|
738
857
|
<div className="seg inline-flex" role="group" aria-label="Project source"><button type="button" className={source === "local" ? "is-on" : ""} aria-pressed={source === "local"} onClick={() => selectSource("local")}>local directory</button><button type="button" className={source === "external" ? "is-on" : ""} aria-pressed={source === "external"} onClick={() => selectSource("external")}>external repository</button></div>
|
|
739
858
|
<p className="text-muted">Detection reads project files but does not execute project code. External repositories are pinned and vendored through the built-in git fetcher.</p>
|
|
859
|
+
<div className="rounded-control border border-line bg-surface px-3 py-2 text-muted"><span className="font-medium text-ink">Tip:</span> local paths are relative to this workspace. To connect a separate repository such as <span className="mono text-ink">aviaadmin</span>, choose external repository.</div>
|
|
740
860
|
{source === "local" ? <><Field label="local path" value={path} onChange={setPath} placeholder="services/billing" required /><Field label="repository URL · optional" value={repository} onChange={(value) => { setRepository(value); clearRepositoryFailure(); }} placeholder="https://github.com/acme/billing" /></> : <><Field label="repository URL" value={repository} onChange={(value) => { setRepository(value); clearRepositoryFailure(); }} placeholder="https://github.com/acme/platform" required /><div className="grid gap-4 sm:grid-cols-2"><Field label="branch, tag or commit" value={ref} onChange={(value) => { setRef(value); clearRepositoryFailure(); }} placeholder="main" /><Field label="component path · optional" value={sourcePath} onChange={(value) => { setSourcePath(value); clearRepositoryFailure(); }} placeholder="services/billing" /></div></>}
|
|
741
861
|
</div>
|
|
742
862
|
) : stage === "scope" && scopeDiscovery ? (
|
|
@@ -748,12 +868,10 @@ function Wizard({ open, onClose, onAdded, onRunStarted }: { open: boolean; onClo
|
|
|
748
868
|
) : stage === "configure" && discovery && draft ? (
|
|
749
869
|
<div className="space-y-5">
|
|
750
870
|
<div className="rounded-control border border-line bg-surface px-3 py-2 text-muted"><span className="mono text-ink">{draft.source === "external" ? `${draft.repository}@${draft.commit.slice(0, 7)}${draft.sourcePath ? `/${draft.sourcePath}` : ""}` : discovery.root}</span> · scanned {discovery.filesScanned} files{discovery.truncated ? " (limit reached)" : ""}</div>
|
|
751
|
-
<
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
<Field label="component slug" value={draft.component} onChange={(component) => setDraft({ ...draft, component })} />
|
|
756
|
-
</div>
|
|
871
|
+
{starter ? <label className="flex cursor-pointer items-start gap-3 rounded-control border border-accent bg-surface px-3 py-3"><input className="mt-1" type="checkbox" checked={draft.replaceStarter ?? false} onChange={(event) => setDraft({ ...draft, replaceStarter: event.target.checked })} /><span><span className="font-medium text-ink">Replace {starter.name} starter after the trial succeeds</span><span className="mt-0.5 block text-muted">The new project and starter removal are written together, so a failed trial leaves the workspace unchanged.</span></span></label> : null}
|
|
872
|
+
{splitsDeployables ? <section><div className="label mb-2">detected deployables</div><div className="grid gap-2 sm:grid-cols-2">{confirmedDeployables.map((component) => <div key={component.slug} className="rounded-control border border-accent bg-surface px-3 py-2"><div className="flex items-center justify-between gap-2"><span className="font-medium text-ink">{component.name}</span><span className="chip status-verified">service</span></div><span className="mono mt-1 block text-muted">{component.path}</span><span className="mono mt-0.5 block truncate text-faint" title={component.evidence.join(", ")}>confirmed by {component.evidence.length} source/build files</span></div>)}</div><p className="mt-2 text-muted">These entrypoints share one repository root but will be catalogued as separate runtime components.</p></section> : <section><div className="label mb-2">component preset</div><div className="grid grid-cols-2 gap-2 md:grid-cols-3">{PROJECT_PRESETS.map((preset) => <button key={preset.kind} type="button" className={`rounded-control border px-3 py-2 text-left transition-colors ${draft.componentKind === preset.kind ? "border-accent bg-surface" : "border-line hover:bg-surface"}`} aria-pressed={draft.componentKind === preset.kind} onClick={() => setDraft({ ...draft, componentKind: preset.kind, ...(preset.kind === "service" ? { groupKind: "bounded-context" as const } : {}) })}><span className="font-medium text-ink">{preset.label}</span><span className="mt-0.5 block text-muted">{preset.summary}</span></button>)}</div></section>}
|
|
873
|
+
<section><div className="label mb-2">project identity</div><div className="grid gap-4 rounded-control border border-line p-4 sm:grid-cols-2"><Field label="display name" value={draft.name} onChange={(name) => setDraft({ ...draft, name })} required /><Field label="stable project id" value={draft.id} onChange={(id) => setDraft({ ...draft, id })} required /><p className="text-muted sm:col-span-2">The id connects this project to its extraction steps. Keep it stable even if the display name changes.</p></div></section>
|
|
874
|
+
<section><div className="mb-2 flex flex-wrap items-center justify-between gap-2"><div className="label">architecture placement</div><span className="chip status-declared">{draft.group || "group"} → {splitsDeployables ? `${confirmedDeployables.length} components` : draft.component || "component"}</span></div><div className="grid gap-4 rounded-control border border-line p-4 sm:grid-cols-2">{catalog.contexts.length ? <label className="block sm:col-span-2"><span className="label mb-1.5 block">start from</span><select className={FIELD} value={catalog.contexts.some((context) => context.id === draft.group) ? draft.group : "__new__"} onChange={(event) => { const existing = catalog.contexts.find((context) => context.id === event.target.value); setDraft(existing ? { ...draft, group: existing.id, contextName: existing.name, contextSummary: existing.summary, classification: existing.classification || "supporting", groupKind: existing.kind || "bounded-context" } : { ...draft, group: draft.id, contextName: draft.name, contextSummary: "", classification: "supporting", groupKind: "bounded-context" }); }}><option value="__new__">Create a new context or group</option>{catalog.contexts.map((context) => <option key={context.id} value={context.id}>Use {context.name} ({context.id})</option>)}</select></label> : null}<Field label="bounded context / group" value={draft.group} onChange={(group) => setDraft({ ...draft, group })} placeholder="avia" /><Field label="context name" value={draft.contextName ?? ""} onChange={(contextName) => setDraft({ ...draft, contextName })} placeholder="Aviation" /><label className="block sm:col-span-2"><span className="label mb-1.5 block">responsibility · optional</span><textarea className={`${FIELD} min-h-20 resize-y`} value={draft.contextSummary ?? ""} onChange={(event) => setDraft({ ...draft, contextSummary: event.target.value })} placeholder="What business decisions and language belong inside this boundary?" /></label>{splitsDeployables ? null : <><Field label="component slug" value={draft.component} onChange={(component) => setDraft({ ...draft, component })} placeholder="aviaadmin" /><label className="block"><span className="label mb-1.5 block">component kind</span><select className={FIELD} value={draft.componentKind ?? "application"} onChange={(event) => setDraft({ ...draft, componentKind: event.target.value as ProjectDraft["componentKind"] })}><option value="service">service</option><option value="application">application</option><option value="webapp">web app</option><option value="worker">worker</option><option value="job">job</option><option value="function">function</option><option value="cli">CLI</option><option value="library">library</option><option value="data-pipeline">data pipeline</option></select></label></>}<label className="block"><span className="label mb-1.5 block">group kind</span><select className={FIELD} value={draft.groupKind ?? "system"} onChange={(event) => setDraft({ ...draft, groupKind: event.target.value as ProjectDraft["groupKind"] })}><option value="bounded-context">bounded context</option><option value="system">system</option><option value="product">product</option><option value="team">team</option><option value="namespace">namespace</option></select></label>{draft.groupKind === "bounded-context" ? <label className="block"><span className="label mb-1.5 block">classification</span><select className={FIELD} value={draft.classification ?? "supporting"} onChange={(event) => setDraft({ ...draft, classification: event.target.value as ProjectDraft["classification"] })}><option value="core">core</option><option value="supporting">supporting</option><option value="generic">generic</option></select></label> : null}<p className="rounded-control bg-surface px-3 py-2 text-muted sm:col-span-2"><span className="font-medium text-ink">Tip:</span> {draft.groupKind === "bounded-context" ? "use one bounded context for components that share the same business language and rules — it does not have to match the folder tree." : "choose bounded context when this group owns a distinct business language; use system for a primarily technical boundary."}</p></div></section>
|
|
757
875
|
<div><div className="label mb-2">detected capabilities</div>
|
|
758
876
|
{discovery.detections.length ? <div className="grid gap-2">{discovery.detections.map((item) => {
|
|
759
877
|
const checked = draft.plugins.includes(item.plugin);
|
|
@@ -765,16 +883,20 @@ function Wizard({ open, onClose, onAdded, onRunStarted }: { open: boolean; onClo
|
|
|
765
883
|
) : stage === "trial" && plan ? (
|
|
766
884
|
<div className="space-y-5">
|
|
767
885
|
<div className={`rounded-control border px-3 py-3 ${trial ? "border-verified bg-surface" : finished?.status === "failed" ? "border-unresolved" : "border-line bg-surface"}`}>
|
|
768
|
-
<div className=
|
|
886
|
+
<div className={trial ? "grid items-center gap-3 sm:grid-cols-[minmax(0,1fr)_120px]" : ""}>
|
|
887
|
+
<div className="flex items-start gap-3">{trial ? <Check size={18} className="mt-0.5 shrink-0 text-verified" /> : finished?.status === "failed" ? <CircleAlert size={18} className="mt-0.5 shrink-0 text-unresolved" /> : <LoaderCircle size={18} className="mt-0.5 shrink-0 animate-spin text-accent" />}<div><div className="font-medium text-ink">{trial ? "Extraction succeeded — repository unchanged" : finished?.status === "failed" ? "Trial extraction failed" : activeStep ? `Running ${activeStep.plugin}` : "Creating an isolated workspace…"}</div><p className="mt-0.5 text-muted">{trial ? "These results came from real catalog fragments. Apply is now safe to continue." : "Portolan is running the selected extractors without writing to portolan.json or your project."}</p></div></div>
|
|
888
|
+
{trial ? <CatIllustration scene="trial" className="cat-trial-illustration" /> : null}
|
|
889
|
+
</div>
|
|
769
890
|
{trial?.previewUrl ? <a className="product-primary mt-3 inline-flex" href={trial.previewUrl} target="_blank" rel="noreferrer">Open catalog preview ↗</a> : null}
|
|
770
891
|
{trial?.previewError ? <p className="mt-2 text-declared">The extraction is valid, but the temporary site could not start: {trial.previewError}</p> : null}
|
|
771
892
|
{!finished ? <><div className="mt-3 h-1.5 overflow-hidden rounded-full bg-canvas"><div className="h-full bg-accent transition-[width]" style={{ width: `${percent}%` }} /></div><div className="mono mt-1 text-right text-muted">{completedSteps.length} / {totalSteps || "?"} steps</div></> : null}
|
|
772
893
|
</div>
|
|
773
|
-
{trial
|
|
894
|
+
{trial ? <section><div className="label mb-2">what Portolan found</div>{trial.facts.length ? <div className="grid grid-cols-2 gap-2 sm:grid-cols-3">{trial.facts.map((fact) => <div key={fact.key} className="rounded-control border border-line bg-canvas px-3 py-2"><div className="tnum text-lg font-semibold text-ink">{fact.count}</div><div className="text-muted">{fact.label}</div></div>)}</div> : <div className="rounded-control border border-line bg-surface px-3 py-3 text-muted">Only component metadata was found. You can add APIs, domain models and stores later without re-adding the project.</div>}</section> : null}
|
|
895
|
+
{trial ? <section><div className="label mb-2">catalog readiness</div><div className="grid gap-2 sm:grid-cols-2">{quality.map((item) => <div key={item.label} className="flex items-center gap-2 rounded-control border border-line px-3 py-2"><span className={`flex size-5 items-center justify-center rounded-full ${item.done ? "bg-verified text-canvas" : "bg-surface text-declared"}`}>{item.done ? <Check size={12} aria-hidden /> : <CircleAlert size={12} aria-hidden />}</span><span className={item.done ? "text-ink" : "text-muted"}>{item.label}</span></div>)}</div></section> : null}
|
|
774
896
|
<section><div className="label mb-2">extractor results</div><div className="divide-y divide-line rounded-control border border-line">{(trial?.steps ?? completedSteps).map((step) => <div key={`${step.plugin}:${"ordinal" in step ? step.ordinal : "trial"}`} className="grid gap-1 px-3 py-2 sm:grid-cols-[1fr_auto_auto]"><span><span className="font-medium text-ink">{CAPABILITIES[step.plugin]?.title ?? step.plugin}</span><span className="mono ml-2 text-faint">{step.plugin}</span>{step.message ? <span className="mt-1 block text-unresolved">{step.message}</span> : null}</span><span className="mono text-muted">{step.fileCount} {plural(step.fileCount, "file")}</span><span className={`chip ${step.status === "failed" ? "status-unresolved" : "status-verified"}`}>{step.status === "failed" ? "failed" : "read"}</span></div>)}</div></section>
|
|
775
897
|
{trial?.warnings.length ? <section><div className="label mb-2">warnings · {trial.warnings.length}</div><div className="space-y-2">{trial.warnings.map((warning, index) => <div key={`${warning.plugin}:${index}`} className="flex gap-2 rounded-control border border-declared px-3 py-2 text-muted"><CircleAlert size={15} className="mt-0.5 shrink-0 text-declared" /><span><span className="mono text-ink">{warning.plugin}</span> · {warning.message}</span></div>)}</div></section> : null}
|
|
776
898
|
{finished?.status === "failed" && logs.length ? <details><summary className="cursor-pointer text-muted">Generator log · {logs.length} lines</summary><pre className="mono mt-2 max-h-48 overflow-auto whitespace-pre-wrap rounded-control bg-surface p-3 text-muted">{logs.map((event) => event.message).join("\n")}</pre></details> : null}
|
|
777
|
-
{trial ? <div><div className="label mb-2">changes after apply</div><dl className="mono grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-2 text-muted"><dt>project</dt><dd className="text-ink">{plan.project.id}</dd>{plan.fetch ? <><dt>pin</dt><dd className="truncate text-ink"
|
|
899
|
+
{trial ? <div><div className="label mb-2">changes after apply</div><dl className="mono grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-2 text-muted"><dt>project</dt><dd className="text-ink">{plan.project.id}</dd>{plan.fetch ? <><dt>pin</dt><dd className="truncate text-ink"><CommitLink commit={plan.fetch.commit} repository={plan.fetch.repo} length={12} /></dd></> : null}<dt>source</dt><dd className="truncate text-ink">{plan.source}</dd><dt>pipeline</dt><dd className="text-ink">{plan.steps.length + (plan.fetch ? 1 : 0)} {plural(plan.steps.length + (plan.fetch ? 1 : 0), "extract step")}</dd><dt>fragments</dt><dd className="text-ink">{trial.generatedFiles} generated</dd></dl></div> : null}
|
|
778
900
|
</div>
|
|
779
901
|
) : null}
|
|
780
902
|
{error ? repositoryError ? <RepositoryFailure failure={repositoryError} message={error} token={credentialToken} onTokenChange={setCredentialToken} onForget={() => void forgetCredential()} busy={busy} /> : <div role="alert" className="mt-4 rounded-control border border-unresolved px-3 py-2 text-unresolved">{error}</div> : null}
|
|
@@ -822,7 +944,7 @@ function RunDialog({ runId, open, onClose, onFinished, onApply }: { runId: strin
|
|
|
822
944
|
{preview ? <section className="mt-5"><div className="label mb-2">generated diff · {preview.totalFiles} {plural(preview.totalFiles, "file")}</div>{preview.files.length ? <div className="divide-y divide-line overflow-hidden rounded-control border border-line">{preview.files.map((file) => <details key={file.path} className="group"><summary className="flex cursor-pointer items-center gap-2 px-3 py-2 hover:bg-surface"><span className={`chip ${file.status === "removed" ? "status-unresolved" : file.status === "added" ? "status-verified" : "status-declared"}`}>{file.status}</span><span className="mono truncate text-ink">{file.path}</span><ChevronDown size={15} className="ml-auto shrink-0 text-muted transition-transform group-open:rotate-180" /></summary><pre className="mono max-h-80 overflow-auto whitespace-pre p-3 text-muted bg-surface">{file.diff || "Binary file changed"}</pre></details>)}</div> : <div className="rounded-control border border-line bg-surface px-3 py-3 text-muted">Generated documentation is already up to date.</div>}{preview.truncated ? <p className="mt-2 text-muted">Showing the first {preview.files.length} changed files.</p> : null}</section> : null}
|
|
823
945
|
{logs.length ? <details className="mt-4"><summary className="cursor-pointer text-muted">Generator log · {logs.length} lines</summary><pre className="mono mt-2 max-h-48 overflow-auto whitespace-pre-wrap rounded-control bg-surface p-3 text-muted">{logs.map((event) => event.type === "log" ? event.message : "").join("\n")}</pre></details> : null}
|
|
824
946
|
</div>
|
|
825
|
-
<div className="flex justify-end gap-2 border-t border-line px-5 py-4">{finished ? <><button type="button" className="tbtn" onClick={onClose}>{preview?.files.length ? "Cancel" : "Done"}</button>{preview?.files.length && finished.status === "ok" && runId ? <button type="button" className="product-primary" onClick={() => onApply(runId)}><Play size={15} /> Apply changes</button> : null}</> : <button type="button" className="tbtn" onClick={() => runId && void cancelGeneration(runId)}>Cancel generation</button>}</div>
|
|
947
|
+
<div className="flex flex-wrap justify-end gap-2 border-t border-line px-5 py-4">{finished ? <>{run?.mode === "write" && finished.status === "ok" ? <><Link to={`${paths.map()}?tour=1`} className="product-primary" onClick={onClose}>Explore the map</Link><Link to={paths.settingsDelivery()} className="tbtn" onClick={onClose}>Set up publishing</Link></> : null}<button type="button" className="tbtn" onClick={onClose}>{preview?.files.length ? "Cancel" : "Done"}</button>{preview?.files.length && finished.status === "ok" && runId ? <button type="button" className="product-primary" onClick={() => onApply(runId)}><Play size={15} /> Apply changes</button> : null}</> : <button type="button" className="tbtn" onClick={() => runId && void cancelGeneration(runId)}>Cancel generation</button>}</div>
|
|
826
948
|
</Modal>
|
|
827
949
|
);
|
|
828
950
|
}
|
|
@@ -833,6 +955,7 @@ const SETTINGS_LINKS = [
|
|
|
833
955
|
["Pipeline", paths.settingsPipeline()],
|
|
834
956
|
["Delivery", paths.settingsDelivery()],
|
|
835
957
|
["Preferences", paths.settingsPreferences()],
|
|
958
|
+
["About", paths.settingsAbout()],
|
|
836
959
|
] as const;
|
|
837
960
|
|
|
838
961
|
function SettingsNav() {
|
|
@@ -860,11 +983,49 @@ function SettingsNav() {
|
|
|
860
983
|
);
|
|
861
984
|
}
|
|
862
985
|
|
|
863
|
-
function
|
|
986
|
+
function GettingStarted({ onGenerate }: { onGenerate: () => void }) {
|
|
987
|
+
const setupInfo = useSetup();
|
|
988
|
+
const starter = starterProject(setupInfo);
|
|
989
|
+
const ownProjects = starter ? [] : setupInfo.projects;
|
|
990
|
+
const connected = ownProjects.length > 0;
|
|
991
|
+
const placed = connected && ownProjects.some((project) => (project.group ?? project.context) && (project.component ?? project.service));
|
|
992
|
+
const generated = placed && Boolean(setupInfo.run && !setupInfo.reportStale && setupInfo.run.status === "ok");
|
|
993
|
+
const steps = [
|
|
994
|
+
{ label: "Clear the starter", detail: "Remove the placeholder project and catalog.", done: !starter },
|
|
995
|
+
{ label: "Connect a project", detail: placed ? "Project and architecture placement are configured." : "Choose a source and place it in a bounded context.", done: placed },
|
|
996
|
+
{ label: "Review the result", detail: "Preview and apply the generated documentation.", done: generated },
|
|
997
|
+
];
|
|
998
|
+
const completed = steps.filter((step) => step.done).length;
|
|
999
|
+
if (completed === steps.length) return null;
|
|
1000
|
+
const tip = starter
|
|
1001
|
+
? "The starter is only configuration and generated catalog data. Removing it never touches application source code."
|
|
1002
|
+
: !connected
|
|
1003
|
+
? "Start with one deployable component. A useful small catalog is easier to grow than a perfect estate-wide model."
|
|
1004
|
+
: !placed
|
|
1005
|
+
? "A bounded context describes a language and responsibility boundary, not necessarily a directory or team."
|
|
1006
|
+
: "Preview first: Portolan shows the exact generated-file diff before anything is applied.";
|
|
1007
|
+
return (
|
|
1008
|
+
<section className="mb-section overflow-hidden rounded-card border border-accent bg-canvas shadow-xs">
|
|
1009
|
+
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-line bg-surface px-card py-3">
|
|
1010
|
+
<div><div className="font-semibold text-ink">Getting started</div><p className="mt-1 text-muted">One path from an empty workspace to useful architecture documentation.</p></div>
|
|
1011
|
+
<div className="mono text-muted">{completed} / {steps.length} complete</div>
|
|
1012
|
+
</div>
|
|
1013
|
+
<div className="grid lg:grid-cols-[minmax(0,1.45fr)_minmax(16rem,0.75fr)]">
|
|
1014
|
+
<ol className="divide-y divide-line lg:border-r lg:border-line">
|
|
1015
|
+
{steps.map((step, index) => <li key={step.label} className="flex items-start gap-3 px-card py-3"><span className={`mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full ${step.done ? "bg-verified text-canvas" : index === completed ? "bg-accent text-canvas" : "bg-surface text-muted"}`}>{step.done ? <Check size={14} aria-hidden /> : index + 1}</span><span><span className="font-medium text-ink">{step.label}</span><span className="mt-0.5 block text-muted">{step.detail}</span></span></li>)}
|
|
1016
|
+
</ol>
|
|
1017
|
+
<div className="flex flex-col justify-between gap-4 p-card"><div><div className="label mb-2">tip for this step</div><p className="text-muted">{tip}</p></div>{starter || !placed ? <Link to={paths.settingsProjects()} className="product-primary self-start">{starter ? "Replace starter" : "Add a project"}</Link> : <button type="button" className="product-primary self-start" onClick={onGenerate}><Play size={15} aria-hidden /> Preview generated diff</button>}</div>
|
|
1018
|
+
</div>
|
|
1019
|
+
</section>
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function OverviewSettings({ local, onGenerate }: { local: boolean; onGenerate: () => void }) {
|
|
864
1024
|
const setupInfo = useSetup();
|
|
865
1025
|
const active = setupInfo.plugins.filter((plugin) => plugin.stepCount > 0);
|
|
866
1026
|
return (
|
|
867
1027
|
<>
|
|
1028
|
+
{local ? <GettingStarted onGenerate={onGenerate} /> : null}
|
|
868
1029
|
<BuildHealth />
|
|
869
1030
|
<div className="mt-4 grid grid-cols-2 gap-grid lg:grid-cols-4">
|
|
870
1031
|
<Metric value={setupInfo.projects.length} label="project" />
|
|
@@ -894,12 +1055,14 @@ function OverviewSettings() {
|
|
|
894
1055
|
);
|
|
895
1056
|
}
|
|
896
1057
|
|
|
897
|
-
function ProjectsSettings({ local, onAdd }: { local: boolean; onAdd: () => void }) {
|
|
1058
|
+
function ProjectsSettings({ local, onAdd, onRemove }: { local: boolean; onAdd: (source: ProjectSource) => void; onRemove: (project: SetupProject) => void }) {
|
|
898
1059
|
const setupInfo = useSetup();
|
|
1060
|
+
const starter = starterProject(setupInfo);
|
|
899
1061
|
return (
|
|
900
1062
|
<section>
|
|
901
|
-
<SectionTitle right={local ? "editable in local mode" : "declared in portolan.json"}>Projects</SectionTitle>
|
|
902
|
-
{setupInfo.projects.length === 0
|
|
1063
|
+
<SectionTitle right={local ? <div className="flex items-center gap-3"><span className="hidden sm:inline">editable in local mode</span><button type="button" className="tbtn text-ink" onClick={() => onAdd("local")}><Plus size={14} aria-hidden /> Add project</button></div> : "declared in portolan.json"}>Projects</SectionTitle>
|
|
1064
|
+
{local && (starter || setupInfo.projects.length === 0) ? <OnboardingCard starter={starter} onAdd={onAdd} onRemove={onRemove} /> : null}
|
|
1065
|
+
{setupInfo.projects.length === 0 && !local ? <CatEmptyState scene="onboarding" title="No projects connected">portolan.json names no projects — every input here is the estate's own.</CatEmptyState> : <div className="grid gap-grid xl:grid-cols-2">{setupInfo.projects.map((project) => <ProjectCard key={project.id} project={project} onRemove={local ? onRemove : undefined} />)}{local ? <AddProjectCard onAdd={onAdd} /> : null}</div>}
|
|
903
1066
|
</section>
|
|
904
1067
|
);
|
|
905
1068
|
}
|
|
@@ -911,8 +1074,23 @@ function PipelineSettings() {
|
|
|
911
1074
|
<div className="space-y-section">
|
|
912
1075
|
<section>
|
|
913
1076
|
<SectionTitle right={`${active.length} of ${setupInfo.plugins.length} active`}>Plugins</SectionTitle>
|
|
914
|
-
|
|
915
|
-
|
|
1077
|
+
{active.length === 0 ? (
|
|
1078
|
+
<CapabilityEmpty
|
|
1079
|
+
title={setupInfo.projects.length === 0 ? "Connect a project to choose extractors" : "No extractors are active"}
|
|
1080
|
+
signal="Project detection selects extractors from the files and contracts it can prove are present."
|
|
1081
|
+
actions={<Link className="product-primary" to={paths.settingsProjects()}>{setupInfo.projects.length === 0 ? "Connect a project" : "Review projects"}</Link>}
|
|
1082
|
+
>
|
|
1083
|
+
{setupInfo.projects.length === 0
|
|
1084
|
+
? "The pipeline is assembled from project evidence, so it starts after the first source is connected."
|
|
1085
|
+
: "The projects are configured, but no pipeline step currently reads them. Re-run project detection and review the proposed capabilities."
|
|
1086
|
+
}
|
|
1087
|
+
</CapabilityEmpty>
|
|
1088
|
+
) : (
|
|
1089
|
+
<>
|
|
1090
|
+
<PluginsList />
|
|
1091
|
+
<p className="mono mt-2 text-muted">WASM runs without network or environment access, and a generator without a filesystem. A host process runs with the permissions of the build; a plugin in the host is Portolan's own code doing what needs a socket, such as fetching another repository.</p>
|
|
1092
|
+
</>
|
|
1093
|
+
)}
|
|
916
1094
|
</section>
|
|
917
1095
|
<details className="rounded-card border border-line shadow-xs">
|
|
918
1096
|
<summary className="cursor-pointer select-none px-4 py-3 font-semibold text-ink">Advanced build inputs</summary>
|
|
@@ -929,7 +1107,11 @@ function PipelineSettings() {
|
|
|
929
1107
|
);
|
|
930
1108
|
}
|
|
931
1109
|
|
|
932
|
-
|
|
1110
|
+
|
|
1111
|
+
function SettingsContent({ local, onAdd, onRemove, onGenerate }: { local: boolean; onAdd: (source: ProjectSource) => void; onRemove: (project: SetupProject) => void; onGenerate: () => void }) {
|
|
1112
|
+
const pathname = useLocation().pathname.replace(/\/$/, "");
|
|
1113
|
+
const overview = pathname === paths.settings();
|
|
1114
|
+
const about = pathname === paths.settingsAbout();
|
|
933
1115
|
return (
|
|
934
1116
|
<div className="h-full overflow-y-auto p-gutter">
|
|
935
1117
|
<div className="max-w-table">
|
|
@@ -938,20 +1120,21 @@ function SettingsContent({ local, onAdd, onGenerate }: { local: boolean; onAdd:
|
|
|
938
1120
|
<div className="flex items-center gap-2"><h1 className="text-lg font-semibold">Settings</h1>{local ? <span className="chip status-verified">local mode</span> : null}</div>
|
|
939
1121
|
<p className="mt-1 max-w-prose text-muted">Configure projects, extraction, delivery automation and local preferences. {local ? "This local session can write reviewed changes." : "Build configuration is read-only here."}</p>
|
|
940
1122
|
</div>
|
|
941
|
-
{local ? <button type="button" className="product-primary" onClick={onGenerate}><Play size={15} /> Preview generated diff</button> : null}
|
|
1123
|
+
{local && !overview && !about ? <button type="button" className="product-primary" onClick={onGenerate}><Play size={15} /> Preview generated diff</button> : null}
|
|
942
1124
|
</div>
|
|
943
1125
|
<SettingsNav />
|
|
944
1126
|
<div className="mt-section">
|
|
945
1127
|
<Routes>
|
|
946
|
-
<Route index element={<OverviewSettings />} />
|
|
947
|
-
<Route path="projects" element={<ProjectsSettings local={local} onAdd={onAdd} />} />
|
|
1128
|
+
<Route index element={<OverviewSettings local={local} onGenerate={onGenerate} />} />
|
|
1129
|
+
<Route path="projects" element={<ProjectsSettings local={local} onAdd={onAdd} onRemove={onRemove} />} />
|
|
948
1130
|
<Route path="pipeline" element={<PipelineSettings />} />
|
|
949
1131
|
<Route path="delivery" element={<section><SectionTitle right={local ? "preview before writing" : "local mode required"}>Delivery presets</SectionTitle><DeliverySettings local={local} /></section>} />
|
|
950
1132
|
<Route path="preferences" element={<PreferencesSettings />} />
|
|
1133
|
+
<Route path="about" element={<AboutSettings />} />
|
|
951
1134
|
<Route path="*" element={<Navigate to={paths.settings()} replace />} />
|
|
952
1135
|
</Routes>
|
|
953
1136
|
</div>
|
|
954
|
-
<div className="mono mt-section flex items-center gap-2 pb-section text-muted"><Box size={14} aria-hidden />{local ? "Changes are written only after preview; generated files remain reviewable in git." : "Configuration is embedded at build time; changing it requires a new catalog build."}</div>
|
|
1137
|
+
{!about ? <div className="mono mt-section flex items-center gap-2 pb-section text-muted"><Box size={14} aria-hidden />{local ? "Changes are written only after preview; generated files remain reviewable in git." : "Configuration is embedded at build time; changing it requires a new catalog build."}</div> : null}
|
|
955
1138
|
</div>
|
|
956
1139
|
</div>
|
|
957
1140
|
);
|
|
@@ -964,9 +1147,11 @@ export function Settings() {
|
|
|
964
1147
|
// No local server means no local mode; the static build-time setup stands in.
|
|
965
1148
|
const local = status.isSuccess;
|
|
966
1149
|
const setup = status.data?.setup ?? staticSetupInfo;
|
|
967
|
-
const [
|
|
1150
|
+
const [wizardSource, setWizardSource] = useState<ProjectSource | null>(null);
|
|
968
1151
|
const [runId, setRunId] = useState<string | null>(null);
|
|
969
1152
|
const [runOpen, setRunOpen] = useState(false);
|
|
1153
|
+
const [removeTarget, setRemoveTarget] = useState<SetupProject | null>(null);
|
|
1154
|
+
const [removeBusy, setRemoveBusy] = useState(false);
|
|
970
1155
|
const say = useToastStore((state) => state.say);
|
|
971
1156
|
const activeRunId = status.data?.activeRun?.id ?? null;
|
|
972
1157
|
useEffect(() => {
|
|
@@ -990,10 +1175,38 @@ export function Settings() {
|
|
|
990
1175
|
say(cause instanceof Error ? cause.message : String(cause));
|
|
991
1176
|
}
|
|
992
1177
|
}
|
|
1178
|
+
async function confirmRemove() {
|
|
1179
|
+
if (!removeTarget) return;
|
|
1180
|
+
setRemoveBusy(true);
|
|
1181
|
+
try {
|
|
1182
|
+
const result = await removeLocalProject(removeTarget.id);
|
|
1183
|
+
setSetup(result.setup);
|
|
1184
|
+
setRemoveTarget(null);
|
|
1185
|
+
say(`${result.project.name} removed from portolan.json.`, {
|
|
1186
|
+
label: "Undo",
|
|
1187
|
+
run: () => { void (async () => {
|
|
1188
|
+
try {
|
|
1189
|
+
const restored = await undoProjectRemoval(result.undoToken);
|
|
1190
|
+
setSetup(restored.setup);
|
|
1191
|
+
say(`${result.project.name} restored.`);
|
|
1192
|
+
} catch (cause) { say(cause instanceof Error ? cause.message : String(cause)); }
|
|
1193
|
+
})(); },
|
|
1194
|
+
});
|
|
1195
|
+
} catch (cause) {
|
|
1196
|
+
say(cause instanceof Error ? cause.message : String(cause));
|
|
1197
|
+
} finally {
|
|
1198
|
+
setRemoveBusy(false);
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
993
1201
|
return (
|
|
994
1202
|
<SetupContext.Provider value={setup}>
|
|
995
|
-
<SettingsContent local={local} onAdd={
|
|
996
|
-
<Wizard open={
|
|
1203
|
+
<SettingsContent local={local} onAdd={setWizardSource} onRemove={setRemoveTarget} onGenerate={() => void generate()} />
|
|
1204
|
+
<Wizard open={wizardSource !== null} initialSource={wizardSource ?? "local"} onClose={() => setWizardSource(null)} onAdded={setSetup} onRunStarted={(id) => { setRunId(id); setRunOpen(true); }} />
|
|
1205
|
+
<Modal open={removeTarget !== null} onClose={removeBusy ? () => {} : () => setRemoveTarget(null)} label={`Remove ${removeTarget?.name ?? "project"}`} width="min(520px,92vw)">
|
|
1206
|
+
<div className="border-b border-line px-5 py-4"><div className="font-semibold text-ink">Remove {removeTarget?.name}?</div></div>
|
|
1207
|
+
<div className="p-5"><p className="text-muted">This removes the project, its extraction steps and its dedicated catalog configuration from <span className="mono text-ink">portolan.json</span>. Your source code stays untouched.</p><p className="mt-3 text-muted">You can undo immediately. Generated documentation changes remain pending until you preview and apply them.</p></div>
|
|
1208
|
+
<div className="flex justify-end gap-2 border-t border-line px-5 py-4"><button type="button" className="tbtn" onClick={() => setRemoveTarget(null)} disabled={removeBusy}>Cancel</button><button type="button" className="product-primary" onClick={() => void confirmRemove()} disabled={removeBusy}>{removeBusy ? <LoaderCircle size={15} className="animate-spin" /> : <Trash2 size={15} />} Remove project</button></div>
|
|
1209
|
+
</Modal>
|
|
997
1210
|
<RunDialog runId={runId} open={runOpen} onClose={() => setRunOpen(false)} onFinished={refresh} onApply={(preview) => void generate(preview)} />
|
|
998
1211
|
</SetupContext.Provider>
|
|
999
1212
|
);
|