@narumitw/pi-sync 0.54.0 → 0.54.1
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 +8 -0
- package/dist/chunks/{chunk-QDPED3XJ.ts → chunk-HUTYAPFP.ts} +407 -142
- package/dist/chunks/chunk-HUTYAPFP.ts.map +7 -0
- package/dist/chunks/{conflict-review-JANNPKKS.ts → conflict-review-JRFJIBEN.ts} +2 -2
- package/dist/chunks/{sync-operations-OWTE3ZL2.ts → sync-operations-QQ2GXKVC.ts} +2 -2
- package/dist/index.ts +2 -2
- package/package.json +1 -1
- package/src/sync/merged-sync.ts +28 -24
- package/src/ui/merge-review.ts +79 -19
- package/src/ui/merged-sync-summary.ts +128 -0
- package/src/ui/sync-confirmation.ts +153 -0
- package/dist/chunks/chunk-QDPED3XJ.ts.map +0 -7
- /package/dist/chunks/{conflict-review-JANNPKKS.ts.map → conflict-review-JRFJIBEN.ts.map} +0 -0
- /package/dist/chunks/{sync-operations-OWTE3ZL2.ts.map → sync-operations-QQ2GXKVC.ts.map} +0 -0
package/src/ui/merge-review.ts
CHANGED
|
@@ -1,28 +1,88 @@
|
|
|
1
1
|
import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
2
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { defineMenu, runCustomInteraction, runDocumentReview, runMenu } from "@narumitw/pi-tui-kit";
|
|
4
|
+
import { createSyncConfirmation, type SyncConfirmationChoice } from "./sync-confirmation.js";
|
|
5
|
+
import { safeTerminalText } from "./terminal-text.js";
|
|
3
6
|
|
|
4
|
-
|
|
7
|
+
type ReviewContext = ExtensionCommandContext | ExtensionContext;
|
|
8
|
+
|
|
9
|
+
/** One approval screen; full details are optional and never authorize a transfer. */
|
|
5
10
|
export async function confirmMergeReview(
|
|
6
|
-
ctx:
|
|
11
|
+
ctx: ReviewContext,
|
|
7
12
|
title: string,
|
|
8
13
|
content: string,
|
|
9
14
|
signal: AbortSignal | undefined,
|
|
10
15
|
isCurrent: () => boolean,
|
|
11
|
-
confirmationLabel = "
|
|
16
|
+
confirmationLabel = "apply changes",
|
|
17
|
+
summaryLines?: readonly string[],
|
|
12
18
|
) {
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
19
|
+
const lines = summaryLines ?? [
|
|
20
|
+
...content
|
|
21
|
+
.split("\n")
|
|
22
|
+
.slice(0, 6)
|
|
23
|
+
.map((line) => truncateToWidth(safeTerminalText(line), 100)),
|
|
24
|
+
"View details for the complete review.",
|
|
25
|
+
];
|
|
26
|
+
while (isCurrent() && !signal?.aborted) {
|
|
27
|
+
const choice: { value: "cancel" | "approve" | "details" } = { value: "cancel" };
|
|
28
|
+
const menu = defineMenu<undefined, "confirm", "choose", ReviewContext>({
|
|
29
|
+
start: "confirm",
|
|
30
|
+
screens: {
|
|
31
|
+
confirm: () => ({
|
|
32
|
+
kind: "choice",
|
|
33
|
+
title,
|
|
34
|
+
lines,
|
|
35
|
+
initialItemId: "cancel",
|
|
36
|
+
items: [
|
|
37
|
+
{ id: "cancel", label: "No, cancel" },
|
|
38
|
+
{ id: "approve", label: `Yes, ${confirmationLabel}` },
|
|
39
|
+
{ id: "details", label: "View details" },
|
|
40
|
+
],
|
|
41
|
+
action: "choose",
|
|
42
|
+
hint: "close",
|
|
43
|
+
}),
|
|
44
|
+
},
|
|
45
|
+
actions: {
|
|
46
|
+
choose: ({ itemId }) => {
|
|
47
|
+
if (itemId === "approve" || itemId === "details") choice.value = itemId;
|
|
48
|
+
return { kind: "close" };
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
const result =
|
|
53
|
+
ctx.mode === "tui" && ctx.hasUI
|
|
54
|
+
? await runCustomInteraction<SyncConfirmationChoice, ReviewContext>(ctx, {
|
|
55
|
+
create: ({ tui, theme, keybindings, complete }) =>
|
|
56
|
+
createSyncConfirmation({ title, lines, confirmationLabel, tui, theme, keybindings, complete }),
|
|
57
|
+
signal,
|
|
58
|
+
isCurrent,
|
|
59
|
+
onError: () => {},
|
|
60
|
+
})
|
|
61
|
+
: await runMenu(ctx, menu, { getState: () => undefined, signal, isCurrent, onError: () => {} });
|
|
62
|
+
if (!isCurrent() || signal?.aborted) return false;
|
|
63
|
+
if (result.kind === "error") throw new Error("Merge review failed; no transfer was performed.");
|
|
64
|
+
if (result.kind === "unsupported")
|
|
65
|
+
throw new Error("Merge review requires observable TUI or RPC; review the plan before using --yes.");
|
|
66
|
+
if (result.kind === "completed") choice.value = result.value;
|
|
67
|
+
else if (result.kind !== "closed") return false;
|
|
68
|
+
// Keep authorization separate from UI termination in both adapters.
|
|
69
|
+
if (choice.value === "approve") return true;
|
|
70
|
+
if (choice.value !== "details") return false;
|
|
71
|
+
const detail = await runDocumentReview(ctx, {
|
|
72
|
+
title: "Sync change details",
|
|
73
|
+
content,
|
|
74
|
+
format: { kind: "text" },
|
|
75
|
+
viewportSize: "adaptive",
|
|
76
|
+
hint: "back",
|
|
77
|
+
signal,
|
|
78
|
+
isCurrent,
|
|
79
|
+
onError: () => {},
|
|
80
|
+
});
|
|
81
|
+
if (!isCurrent() || signal?.aborted) return false;
|
|
82
|
+
if (detail.kind === "error") throw new Error("Merge review failed; no transfer was performed.");
|
|
83
|
+
if (detail.kind !== "cancelled" || detail.reason !== "back") return false;
|
|
84
|
+
// A fresh menu discards remembered selection: returning from details always selects No.
|
|
85
|
+
// Unlike Pi's native Yes-first dialog, approval requires moving off the safe default.
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
28
88
|
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { PublicationCapability } from "../backends/sync-backend.js";
|
|
3
|
+
import type { Snapshot } from "../snapshot/snapshot-types.js";
|
|
4
|
+
import type { FileMergeDecision } from "../sync/file-merge-planner.js";
|
|
5
|
+
import { fileHashMap } from "../sync/sync-state.js";
|
|
6
|
+
import { safeTerminalText, snapshotPathLabel } from "./terminal-text.js";
|
|
7
|
+
|
|
8
|
+
interface FileChange {
|
|
9
|
+
path: string;
|
|
10
|
+
action: "Add" | "Update" | "Delete";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface MergedSyncSummaryInput {
|
|
14
|
+
setupName: string;
|
|
15
|
+
destination: string;
|
|
16
|
+
localBefore: Snapshot;
|
|
17
|
+
localAfter: Snapshot;
|
|
18
|
+
remoteBefore: Snapshot;
|
|
19
|
+
remoteAfter: Snapshot;
|
|
20
|
+
publish: boolean;
|
|
21
|
+
sessions: boolean;
|
|
22
|
+
groups: readonly { paths: readonly string[] }[];
|
|
23
|
+
decisions: readonly FileMergeDecision[];
|
|
24
|
+
capability: PublicationCapability;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Presentation only: counts describe snapshot differences, not planner decisions. */
|
|
28
|
+
export function mergedSyncSummary(input: MergedSyncSummaryInput) {
|
|
29
|
+
const local = changes(input.localBefore, input.localAfter);
|
|
30
|
+
const remote = changes(input.remoteBefore, input.remoteAfter);
|
|
31
|
+
const all = [...local, ...remote];
|
|
32
|
+
const updatesOnly = all.length > 0 && all.every((change) => change.action === "Update");
|
|
33
|
+
const verb = updatesOnly ? "Update" : "Apply changes to";
|
|
34
|
+
const title =
|
|
35
|
+
local.length && remote.length
|
|
36
|
+
? "Apply local and remote changes?"
|
|
37
|
+
: all.length
|
|
38
|
+
? `${verb} ${all.length} ${local.length ? "local" : "remote"} ${all.length === 1 ? "file" : "files"}?`
|
|
39
|
+
: input.publish
|
|
40
|
+
? "Publish a new remote snapshot?"
|
|
41
|
+
: "Pi Sync is already up to date.";
|
|
42
|
+
const confirmationLabel =
|
|
43
|
+
local.length && remote.length
|
|
44
|
+
? "apply local and remote changes"
|
|
45
|
+
: all.length
|
|
46
|
+
? `${updatesOnly ? "update" : "apply changes to"} ${local.length ? "local" : "remote"} ${all.length === 1 ? "file" : "files"}`
|
|
47
|
+
: "publish remote snapshot";
|
|
48
|
+
const sources = new Map(
|
|
49
|
+
input.decisions.filter((item) => item.kind === "accepted").map((item) => [item.path, item.source]),
|
|
50
|
+
);
|
|
51
|
+
const label = (change: FileChange, side: "local" | "remote") => {
|
|
52
|
+
const source = sources.get(change.path);
|
|
53
|
+
const suffix =
|
|
54
|
+
source === "merged"
|
|
55
|
+
? " (merged content)"
|
|
56
|
+
: side === "local" && source === "remote"
|
|
57
|
+
? change.action === "Delete"
|
|
58
|
+
? " (deleted remotely)"
|
|
59
|
+
: " from remote"
|
|
60
|
+
: "";
|
|
61
|
+
return `${change.action} ${snapshotPathLabel(change.path)}${suffix}`;
|
|
62
|
+
};
|
|
63
|
+
const section = (items: FileChange[], side: "local" | "remote", compact: boolean) => {
|
|
64
|
+
const heading = side === "local" ? "Local changes" : "Remote file changes";
|
|
65
|
+
if (!items.length) return [`${heading}: none`];
|
|
66
|
+
const counts = ["Add", "Update", "Delete"].flatMap((action) => {
|
|
67
|
+
const count = items.filter((item) => item.action === action).length;
|
|
68
|
+
return count ? [`${count} ${action.toLowerCase()}`] : [];
|
|
69
|
+
});
|
|
70
|
+
return [
|
|
71
|
+
`${heading}: ${counts.join(", ")}`,
|
|
72
|
+
...(compact ? items.slice(0, 3) : items).map(
|
|
73
|
+
(item) => ` ${compact ? truncateToWidth(label(item, side), 80) : label(item, side)}`,
|
|
74
|
+
),
|
|
75
|
+
...(compact && items.length > 3 ? [` ${items.length - 3} more files — View details`] : []),
|
|
76
|
+
];
|
|
77
|
+
};
|
|
78
|
+
const context = [`Setup: ${safeTerminalText(input.setupName)}`, `Remote: ${safeTerminalText(input.destination)}`];
|
|
79
|
+
const safety = [
|
|
80
|
+
`Remote snapshot publication: ${input.publish ? "yes" : "no"}`,
|
|
81
|
+
`Sessions: ${input.sessions ? "included — may contain private conversations" : "not included"}`,
|
|
82
|
+
input.groups.length
|
|
83
|
+
? `Unresolved groups: ${input.groups.length} — these paths stay unchanged on each side`
|
|
84
|
+
: "Conflicts: none",
|
|
85
|
+
...(input.publish && input.capability === "read-check-write-verify"
|
|
86
|
+
? ["Warning: simultaneous remote writes can still race; visible changes are rejected."]
|
|
87
|
+
: []),
|
|
88
|
+
...(input.publish && input.capability === "conditional-required"
|
|
89
|
+
? ["Remote publication requires verified conditional writes; verification failure stops the transfer."]
|
|
90
|
+
: []),
|
|
91
|
+
"The current session is protected. A backup and recovery journal will be saved.",
|
|
92
|
+
"Resources will not reload automatically.",
|
|
93
|
+
];
|
|
94
|
+
return {
|
|
95
|
+
title,
|
|
96
|
+
confirmationLabel,
|
|
97
|
+
lines: [
|
|
98
|
+
...context.map((line) => truncateToWidth(line, 100)),
|
|
99
|
+
...section(local, "local", true),
|
|
100
|
+
...section(remote, "remote", true),
|
|
101
|
+
...safety,
|
|
102
|
+
],
|
|
103
|
+
content: [
|
|
104
|
+
...context,
|
|
105
|
+
"",
|
|
106
|
+
...section(local, "local", false),
|
|
107
|
+
"",
|
|
108
|
+
...section(remote, "remote", false),
|
|
109
|
+
"",
|
|
110
|
+
...safety,
|
|
111
|
+
...input.groups.flatMap((group) =>
|
|
112
|
+
group.paths.map((filePath) => `Unchanged (unresolved): ${snapshotPathLabel(filePath)}`),
|
|
113
|
+
),
|
|
114
|
+
].join("\n"),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function changes(before: Snapshot, after: Snapshot): FileChange[] {
|
|
119
|
+
const beforeMap = fileHashMap(before);
|
|
120
|
+
const afterMap = fileHashMap(after);
|
|
121
|
+
return [...new Set([...Object.keys(beforeMap), ...Object.keys(afterMap)])]
|
|
122
|
+
.sort()
|
|
123
|
+
.flatMap((filePath): FileChange[] => {
|
|
124
|
+
if (!Object.hasOwn(beforeMap, filePath)) return [{ path: filePath, action: "Add" }];
|
|
125
|
+
if (!Object.hasOwn(afterMap, filePath)) return [{ path: filePath, action: "Delete" }];
|
|
126
|
+
return beforeMap[filePath] !== afterMap[filePath] ? [{ path: filePath, action: "Update" }] : [];
|
|
127
|
+
});
|
|
128
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { KeybindingsManager, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Key, matchesKey, SelectList, type TUI, type TuiMouseEvent, truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { HorizontalRule, hardWrapTerminalDocument, sanitizeTerminalText } from "@narumitw/pi-tui-kit";
|
|
4
|
+
|
|
5
|
+
export type SyncConfirmationChoice = "cancel" | "approve" | "details";
|
|
6
|
+
|
|
7
|
+
/** Kit choice context is clipped on short terminals; this summary must remain reviewable. */
|
|
8
|
+
export function createSyncConfirmation(options: {
|
|
9
|
+
title: string;
|
|
10
|
+
lines: readonly string[];
|
|
11
|
+
confirmationLabel: string;
|
|
12
|
+
tui: TUI;
|
|
13
|
+
theme: Theme;
|
|
14
|
+
keybindings: KeybindingsManager;
|
|
15
|
+
complete(choice: SyncConfirmationChoice): void;
|
|
16
|
+
}) {
|
|
17
|
+
const { tui, theme, keybindings } = options;
|
|
18
|
+
const choices: SyncConfirmationChoice[] = ["cancel", "approve", "details"];
|
|
19
|
+
const list = new SelectList(
|
|
20
|
+
[
|
|
21
|
+
{ value: "cancel", label: "No, cancel" },
|
|
22
|
+
{ value: "approve", label: `Yes, ${sanitizeTerminalText(options.confirmationLabel)}` },
|
|
23
|
+
{ value: "details", label: "View details" },
|
|
24
|
+
],
|
|
25
|
+
3,
|
|
26
|
+
{
|
|
27
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
28
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
29
|
+
description: (text) => theme.fg("muted", text),
|
|
30
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
31
|
+
noMatch: (text) => theme.fg("muted", text),
|
|
32
|
+
},
|
|
33
|
+
);
|
|
34
|
+
const rule = new HorizontalRule({ ruleStyle: (text) => theme.fg("border", text) });
|
|
35
|
+
let selected = 0;
|
|
36
|
+
let offset = 0;
|
|
37
|
+
let maximumOffset = 0;
|
|
38
|
+
let lastWidth = 0;
|
|
39
|
+
let lastRows = 0;
|
|
40
|
+
let seen = new Set<number>();
|
|
41
|
+
let summary: readonly string[] = [];
|
|
42
|
+
let reviewed = false;
|
|
43
|
+
let disposed = false;
|
|
44
|
+
let finished = false;
|
|
45
|
+
let summaryStart = 0;
|
|
46
|
+
let summaryEnd = 0;
|
|
47
|
+
let actionsStart = 0;
|
|
48
|
+
const finish = (choice: SyncConfirmationChoice) => {
|
|
49
|
+
if (disposed || finished) return;
|
|
50
|
+
finished = true;
|
|
51
|
+
options.complete(choice);
|
|
52
|
+
};
|
|
53
|
+
const select = (index: number) => {
|
|
54
|
+
selected = (index + choices.length) % choices.length;
|
|
55
|
+
list.setSelectedIndex(selected);
|
|
56
|
+
};
|
|
57
|
+
list.onSelectionChange = (item) => {
|
|
58
|
+
selected = Math.max(0, choices.indexOf(item.value as SyncConfirmationChoice));
|
|
59
|
+
};
|
|
60
|
+
list.onSelect = (item) => {
|
|
61
|
+
const choice = choices[selected] ?? "cancel";
|
|
62
|
+
if (item.value === choice && (choice !== "approve" || reviewed)) finish(choice);
|
|
63
|
+
};
|
|
64
|
+
const component = {
|
|
65
|
+
render(width: number, expose = true): string[] {
|
|
66
|
+
if (!Number.isFinite(width) || width <= 0) {
|
|
67
|
+
reviewed = false;
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
const cells = Math.floor(width);
|
|
71
|
+
const rows = Math.max(1, Math.floor(tui.terminal.rows) - 3);
|
|
72
|
+
if (cells !== lastWidth || rows !== lastRows) {
|
|
73
|
+
// Reflow can expose previously unseen rows or hide the selected action.
|
|
74
|
+
// Reset proof and selection rather than carry approval across a resize.
|
|
75
|
+
summary = options.lines.flatMap((line) => hardWrapTerminalDocument(line, cells));
|
|
76
|
+
seen = new Set();
|
|
77
|
+
offset = 0;
|
|
78
|
+
select(0);
|
|
79
|
+
lastWidth = cells;
|
|
80
|
+
lastRows = rows;
|
|
81
|
+
}
|
|
82
|
+
const framed = rows >= 9;
|
|
83
|
+
const actions = list.render(cells);
|
|
84
|
+
const viewport = Math.max(0, rows - actions.length - 3 - (framed ? 2 : 0));
|
|
85
|
+
maximumOffset = Math.max(0, summary.length - viewport);
|
|
86
|
+
offset = Math.min(offset, maximumOffset);
|
|
87
|
+
const visible = summary.slice(offset, offset + viewport);
|
|
88
|
+
if (expose) for (let index = offset; index < offset + visible.length; index++) seen.add(index);
|
|
89
|
+
reviewed = viewport > 0 && seen.size === summary.length;
|
|
90
|
+
// Describe actions without advertising configured keys that can be invalid,
|
|
91
|
+
// aliased, or shadowed by an earlier cancellation/navigation binding.
|
|
92
|
+
const hint = reviewed ? "Select an action." : "Navigation keys scroll the summary before selecting Yes.";
|
|
93
|
+
const body = [
|
|
94
|
+
theme.fg("accent", theme.bold(hardWrapTerminalDocument(options.title, cells).join(" "))),
|
|
95
|
+
...visible.map((line) => theme.fg("text", line)),
|
|
96
|
+
theme.fg(
|
|
97
|
+
"muted",
|
|
98
|
+
`Summary ${visible.length ? offset + 1 : 0}-${offset + visible.length}/${summary.length}${reviewed ? "" : " — review before Yes"}`,
|
|
99
|
+
),
|
|
100
|
+
...actions,
|
|
101
|
+
theme.fg("dim", hint),
|
|
102
|
+
];
|
|
103
|
+
summaryStart = Number(framed) + 1;
|
|
104
|
+
summaryEnd = summaryStart + visible.length;
|
|
105
|
+
actionsStart = summaryEnd + 1;
|
|
106
|
+
const frame = framed ? [...rule.render(cells), ...body, ...rule.render(cells)] : body;
|
|
107
|
+
// Very small terminals cannot display the summary and all choices together.
|
|
108
|
+
// Keep cancellation available, but never approve a zero-row summary viewport.
|
|
109
|
+
return frame.slice(0, rows).map((line) => truncateToWidth(line, cells, ""));
|
|
110
|
+
},
|
|
111
|
+
invalidate() {
|
|
112
|
+
list.invalidate();
|
|
113
|
+
rule.invalidate();
|
|
114
|
+
},
|
|
115
|
+
handleInput(data: string) {
|
|
116
|
+
if (disposed || finished) return;
|
|
117
|
+
if (matchesKey(data, Key.ctrl("c")) || keybindings.matches(data, "tui.select.cancel")) {
|
|
118
|
+
finish("cancel");
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
// Refresh height/width proof before handling an input racing with terminal resize.
|
|
122
|
+
component.render(tui.terminal.columns ?? lastWidth, false);
|
|
123
|
+
if (keybindings.matches(data, "tui.select.up")) {
|
|
124
|
+
if (!reviewed || (selected === 0 && offset > 0)) offset = Math.max(0, offset - 1);
|
|
125
|
+
else select(selected - 1);
|
|
126
|
+
} else if (keybindings.matches(data, "tui.select.down")) {
|
|
127
|
+
if (!reviewed || (selected === 0 && offset < maximumOffset)) offset = Math.min(maximumOffset, offset + 1);
|
|
128
|
+
else select(selected + 1);
|
|
129
|
+
} else if (keybindings.matches(data, "tui.select.confirm")) {
|
|
130
|
+
const choice = choices[selected] ?? "cancel";
|
|
131
|
+
if (choice !== "approve" || reviewed) finish(choice);
|
|
132
|
+
}
|
|
133
|
+
tui.requestRender();
|
|
134
|
+
},
|
|
135
|
+
handleMouse(event: TuiMouseEvent) {
|
|
136
|
+
if (disposed || finished || event.width !== lastWidth) return undefined;
|
|
137
|
+
component.render(event.width, false);
|
|
138
|
+
if (event.y >= summaryStart && event.y < summaryEnd && event.type === "wheel" && event.wheelDelta) {
|
|
139
|
+
offset = Math.max(0, Math.min(maximumOffset, offset + (event.wheelDelta < 0 ? -1 : 1)));
|
|
140
|
+
select(0);
|
|
141
|
+
tui.requestRender();
|
|
142
|
+
return { handled: true, render: true };
|
|
143
|
+
}
|
|
144
|
+
if (event.y < actionsStart || event.y >= actionsStart + choices.length || event.y >= lastRows) return undefined;
|
|
145
|
+
return list.handleMouse({ ...event, y: event.y - actionsStart, height: choices.length });
|
|
146
|
+
},
|
|
147
|
+
dispose() {
|
|
148
|
+
disposed = true;
|
|
149
|
+
seen.clear();
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
return component;
|
|
153
|
+
}
|