Skip to content

Commit 1acb5e6

Browse files
committed
fix(app): complete services history and export refs
1 parent 02119dd commit 1acb5e6

8 files changed

Lines changed: 69 additions & 8 deletions

File tree

packages/app/src/app/core/state/history.store.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export class HistoryStore {
1010
readonly error = signal<string | null>(null);
1111
readonly lastRestore = signal<IHistoryRestoreResult | null>(null);
1212
readonly lastExport = signal<unknown>(null);
13+
readonly compareConfiguration = signal<IHistoryConfiguration>({});
1314

1415
constructor(private readonly client: HistoryClient) {}
1516

@@ -21,9 +22,15 @@ export class HistoryStore {
2122

2223
async compare(projectRoot: string, leftId: string, rightId: string, configuration?: IHistoryConfiguration): Promise<void> {
2324
this.error.set(null);
25+
this.compareConfiguration.set(configuration ?? {});
2426
try { this.diff.set(await this.client.compare(projectRoot, leftId, rightId, configuration)); } catch (error) { this.error.set(error instanceof Error ? error.message : String(error)); }
2527
}
2628

29+
async compareConfig(projectRoot: string, leftId: string, rightId: string): Promise<void> {
30+
this.error.set(null);
31+
try { this.compareConfiguration.set(await this.client.compareConfig(projectRoot, leftId, rightId) ?? {}); } catch (error) { this.error.set(error instanceof Error ? error.message : String(error)); }
32+
}
33+
2734
async reExport(projectRoot: string, historyId: string, configuration?: IHistoryConfiguration): Promise<void> {
2835
this.error.set(null);
2936
try { this.lastExport.set(await this.client.reExport(projectRoot, historyId, configuration)); } catch (error) { this.error.set(error instanceof Error ? error.message : String(error)); }

packages/app/src/app/features/exports/export-preview.component.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,14 @@ import { ExportDiagnostic, ExportDryRun } from "../../core/api/exports.client";
66
selector: "tanit-export-preview",
77
standalone: true,
88
changeDetection: ChangeDetectionStrategy.OnPush,
9-
template: `<section class="preview" aria-labelledby="preview-title"><header><div><h2 id="preview-title">Dry run</h2><p>Files and overwrite risk before generation.</p></div><strong>{{ preview.canGenerate ? "Ready" : "Needs confirmation" }}</strong></header>@if (preview.combinedExport; as combined) { <aside class="combined" [attr.data-partial]="combined.partial"><strong>{{ combined.partial ? "Partial combined export" : "Combined export" }}</strong><p>{{ combined.explanation }}</p>@for (service of combined.services; track service.serviceId) { <small>{{ service.serviceId }}: {{ service.reason }}</small> }</aside> }<div class="files">@for (file of preview.files; track file.path) { <div class="file"><span>{{ file.change }}</span><code>{{ file.path }}</code>@if (file.overwriteRisk) { <small>Overwrite risk</small> } @if (file.outsideWorkspace) { <small>Outside workspace</small> }</div> }</div>@if (preview.diagnostics.length) { <div class="diagnostics" aria-live="polite">@for (diagnostic of preview.diagnostics; track diagnostic.code) { <button type="button" (click)="diagnosticSelected.emit(diagnostic)"><strong>{{ diagnostic.code }}</strong><span>{{ diagnostic.message }}</span><small>{{ diagnostic.suggestion }}</small></button> }</div> }</section>`,
9+
template: `<section class="preview" aria-labelledby="preview-title"><header><div><h2 id="preview-title">Dry run</h2><p>Files and overwrite risk before generation.</p></div><strong>{{ preview.canGenerate ? "Ready" : "Needs confirmation" }}</strong></header>@if (preview.combinedExport; as combined) { <aside class="combined" [attr.data-partial]="combined.partial"><strong>{{ combined.partial ? "Partial combined export" : "Combined export" }}</strong><p>{{ combined.explanation }}</p>@for (service of combined.services; track service.serviceId) { <small>{{ service.serviceId }}: {{ service.reason }}</small> }@for (reference of operationReferences(combined.operationRefs); track reference.id) { <small>{{ reference.id }}: server {{ reference.serverRef || "none" }} · auth {{ reference.authRef || "none" }}</small> }</aside> }<div class="files">@for (file of preview.files; track file.path) { <div class="file"><span>{{ file.change }}</span><code>{{ file.path }}</code>@if (file.overwriteRisk) { <small>Overwrite risk</small> } @if (file.outsideWorkspace) { <small>Outside workspace</small> }</div> }</div>@if (preview.diagnostics.length) { <div class="diagnostics" aria-live="polite">@for (diagnostic of preview.diagnostics; track diagnostic.code) { <button type="button" (click)="diagnosticSelected.emit(diagnostic)"><strong>{{ diagnostic.code }}</strong><span>{{ diagnostic.message }}</span><small>{{ diagnostic.suggestion }}</small></button> }</div> }</section>`,
1010
styles: `.preview { display: grid; gap: 12px; } header { display: flex; justify-content: space-between; gap: 12px; } h2 { margin: 0; font-size: 18px; } p { margin: 4px 0 0; color: var(--color-muted); } .files, .diagnostics { display: grid; gap: 6px; } .file { display: grid; grid-template-columns: 82px minmax(0, 1fr) auto; align-items: center; gap: 8px; padding: 9px 10px; border-bottom: 1px solid var(--color-border); } code { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } small { color: var(--color-muted); font-size: 11px; } .diagnostics button { display: grid; gap: 3px; padding: 9px; border: 1px solid var(--color-border); background: transparent; color: var(--color-ink); text-align: left; cursor: pointer; } .diagnostics span { color: var(--color-muted); }`,
1111
})
1212
export class ExportPreviewComponent {
1313
@Input() preview: ExportDryRun = { files: [], diagnostics: [], canGenerate: false, requiresOverwriteConfirmation: false, requiresOutsideWorkspaceConfirmation: false };
1414
@Output() readonly diagnosticSelected = new EventEmitter<ExportDiagnostic>();
15+
16+
operationReferences(references: Readonly<Record<string, { readonly serverRef?: string; readonly authRef?: string }>>): ReadonlyArray<{ readonly id: string; readonly serverRef?: string; readonly authRef?: string }> {
17+
return Object.entries(references).map(([id, value]) => ({ id, ...value }));
18+
}
1519
}

packages/app/src/app/features/history/history-diff.component.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,25 @@
11
import { ChangeDetectionStrategy, Component, inject, input } from "@angular/core";
2+
import { JsonPipe } from "@angular/common";
23
import { HistoryClient } from "../../core/api/history.client";
34
import { ProjectStore } from "../../core/state/project.store";
45

56
@Component({
67
selector: "tanit-history-diff",
8+
standalone: true,
9+
imports: [JsonPipe],
710
changeDetection: ChangeDetectionStrategy.OnPush,
811
template: `
9-
<section><header><h2>Snapshot diff</h2><label>Output <input [value]="outputDirectory" (input)="outputDirectory = inputValue($event)" /></label><button type="button" (click)="compare()">Compare</button></header>
12+
<section><header><h2>Snapshot diff</h2><label>Output <input [value]="outputDirectory" (input)="outputDirectory = inputValue($event)" /></label><button type="button" (click)="loadConfiguration()">Load config</button><button type="button" (click)="compare()">Compare</button></header>
1013
@if (error; as message) { <p role="alert">{{ message }}</p> }
1114
@if (result; as diff) {
1215
<p>{{ diff.added.length }} added · {{ diff.removed.length }} removed · {{ diff.changed.length }} changed</p>
1316
<p>Schemas: {{ diff.schemaChanges.length }} · Auth: {{ diff.authChanges.length }}</p>
1417
<p>Services: {{ diff.services.length }} · Operations: {{ diff.operations.length }}</p>
1518
@for (change of diff.serviceChanges; track change.key) { <div>{{ change.key }}: {{ change.before | json }} → {{ change.after | json }}</div> }
1619
@for (change of diff.operationChanges; track change.key) { <div>{{ change.key }}: {{ change.before | json }} → {{ change.after | json }}</div> }
20+
@for (change of diff.schemaChanges; track change.key) { <div>{{ change.key }} schema: {{ change.before | json }} → {{ change.after | json }}</div> }
21+
@for (change of diff.authChanges; track change.key) { <div>{{ change.key }} auth: {{ change.before | json }} → {{ change.after | json }}</div> }
22+
@if (diff.configuration; as configuration) { <p>Config: {{ configuration.before | json }} → {{ configuration.after | json }}</p> }
1723
}
1824
</section>
1925
`,
@@ -34,5 +40,15 @@ export class HistoryDiffComponent {
3440
try { this.result = await this.client.compare(root, this.leftId(), this.rightId(), { outputDirectory: this.outputDirectory }); } catch (error) { this.error = error instanceof Error ? error.message : String(error); }
3541
}
3642

43+
async loadConfiguration(): Promise<void> {
44+
const root = this.project.projectRoot();
45+
if (!root) return;
46+
try {
47+
const configuration = await this.client.compareConfig(root, this.leftId(), this.rightId());
48+
const after = configuration?.after;
49+
this.outputDirectory = after && typeof after === "object" && "outputDirectory" in after ? String(after.outputDirectory ?? "") : "";
50+
} catch (error) { this.error = error instanceof Error ? error.message : String(error); }
51+
}
52+
3753
inputValue(event: Event): string { return (event.target as HTMLInputElement).value; }
3854
}

packages/app/src/app/features/services/service-detail.component.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ChangeDetectionStrategy, Component, inject, input, signal } from "@angular/core";
1+
import { ChangeDetectionStrategy, Component, effect, inject, input, signal } from "@angular/core";
22
import { ServicesClient, type IServiceDetail } from "../../core/api/services.client";
33
import { ProjectStore } from "../../core/state/project.store";
44

@@ -23,8 +23,12 @@ export class ServiceDetailComponent {
2323
private readonly project = inject(ProjectStore);
2424
private readonly client = inject(ServicesClient);
2525

26-
async load(): Promise<void> {
26+
constructor() {
27+
effect(() => { void this.load(this.serviceId()); });
28+
}
29+
30+
async load(serviceId = this.serviceId()): Promise<void> {
2731
const root = this.project.projectRoot();
28-
if (root) this.service.set(await this.client.detail(root, this.serviceId()));
32+
if (root) this.service.set(await this.client.detail(root, serviceId));
2933
}
3034
}

packages/app/src/app/features/services/services-list.component.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import { ChangeDetectionStrategy, Component, inject, signal } from "@angular/core";
22
import { ServicesClient, type IServiceDetail } from "../../core/api/services.client";
33
import { ProjectStore } from "../../core/state/project.store";
4+
import { ServiceDetailComponent } from "./service-detail.component";
45

56
@Component({
67
selector: "tanit-services-list",
8+
standalone: true,
9+
imports: [ServiceDetailComponent],
710
changeDetection: ChangeDetectionStrategy.OnPush,
811
template: `
912
<section class="services-list">
@@ -13,7 +16,7 @@ import { ProjectStore } from "../../core/state/project.store";
1316
<strong>{{ service.serviceId }}</strong><span>{{ service.framework }}</span>
1417
<small>{{ service.operationCount }} operations · {{ service.transports.join(", ") }} · {{ service.baseUrl || "No base URL" }} · auth {{ service.auth ? "configured" : "none" }}</small>
1518
</button>
16-
} @empty { <p>No services discovered.</p> }
19+
} @empty { <p>No services discovered.</p> }@if (selected(); as serviceId) { <tanit-service-detail [serviceId]="serviceId" /> }
1720
</section>
1821
`,
1922
})

packages/core/session/history-recorder.service.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,12 @@ function fingerprint(value: unknown): string {
3939
});
4040
}
4141

42-
export function diffSnapshots(left: ICanonicalSnapshot, right: ICanonicalSnapshot): IHistoryDiff {
42+
export function diffSnapshots(
43+
left: ICanonicalSnapshot,
44+
right: ICanonicalSnapshot,
45+
leftConfiguration?: Readonly<Record<string, unknown>>,
46+
rightConfiguration?: Readonly<Record<string, unknown>>,
47+
): IHistoryDiff {
4348
const leftServices = new Map(left.services.map((service) => [service.serviceId, service]));
4449
const rightServices = new Map(right.services.map((service) => [service.serviceId, service]));
4550
const services = new Set<string>();
@@ -84,6 +89,7 @@ export function diffSnapshots(left: ICanonicalSnapshot, right: ICanonicalSnapsho
8489
serviceChanges: serviceChanges.sort((left, right) => left.key.localeCompare(right.key)),
8590
operationChanges: operationChanges.sort((left, right) => left.key.localeCompare(right.key)),
8691
schemaChanges: schemaChangeDetails.sort((left, right) => left.key.localeCompare(right.key)),
92+
configuration: { before: leftConfiguration ?? {}, after: rightConfiguration ?? {} },
8793
};
8894
}
8995

@@ -138,7 +144,7 @@ export class HistoryRecorderService {
138144
const left = this.get(projectRoot, leftId);
139145
const right = this.get(projectRoot, rightId);
140146
if (!left || !right) throw new Error("Both history records are required for comparison");
141-
return diffSnapshots(left.snapshot, right.snapshot);
147+
return diffSnapshots(left.snapshot, right.snapshot, left.configuration, right.configuration);
142148
}
143149

144150
serialize(record: IHistoryRecord): string {

tests/app/export-center.spec.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,4 +130,15 @@ describe("Export Center", () => {
130130
expect(preview.diagnostics.at(-1)?.code).toBe("COMBINED_EXPORT_PARTIAL");
131131
expect(preview.diagnostics.at(-1)?.operationIds).toContain("catalog.list");
132132
});
133+
134+
it("renders operation references in the preview", () => {
135+
TestBed.resetTestingModule();
136+
const fixture = TestBed.configureTestingModule({ imports: [ExportPreviewComponent] }).createComponent(ExportPreviewComponent);
137+
fixture.componentRef.setInput("preview", {
138+
files: [], diagnostics: [], canGenerate: true, requiresOverwriteConfirmation: false, requiresOutsideWorkspaceConfirmation: false,
139+
combinedExport: { partial: true, explanation: "Partial", services: [], operationRefs: { "catalog.list": { serverRef: "server-catalog", authRef: "auth-catalog" } } },
140+
});
141+
fixture.detectChanges();
142+
expect(fixture.nativeElement.textContent).toContain("catalog.list: server server-catalog · auth auth-catalog");
143+
});
133144
});

tests/app/history.spec.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,16 @@ describe("canonical history", () => {
4343
expect(entry.snapshot.combinedExport?.operationRefs["api/get"]?.serverRef).toBe("server-api");
4444
});
4545

46+
it("diffs the recorded configuration before and after", () => {
47+
const recorder = new HistoryRecorderService();
48+
const left = recorder.record("/workspace", snapshot, { configuration: { formats: ["postman"], outputDirectory: "/workspace/one" } });
49+
const right = recorder.record("/workspace", { ...snapshot, capturedAt: "2026-09-08T00:01:00.000Z" }, { configuration: { formats: ["openapi"], outputDirectory: "/workspace/two" } });
50+
expect(recorder.compare("/workspace", left.id, right.id).configuration).toEqual({
51+
before: { formats: ["postman"], outputDirectory: "/workspace/one" },
52+
after: { formats: ["openapi"], outputDirectory: "/workspace/two" },
53+
});
54+
});
55+
4656
it("keeps divergent services scoped in detailed diffs", () => {
4757
const left: ICanonicalSnapshot = {
4858
...snapshot,

0 commit comments

Comments
 (0)