@iseoane/agent-depot 0.6.3 → 0.6.5
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/CHANGELOG.md +94 -0
- package/README.es.md +14 -10
- package/README.md +22 -8
- package/dist/src/app-cli.js +10 -5
- package/dist/src/app-flow.js +2 -2
- package/dist/src/atomic-file.js +9 -0
- package/dist/src/cli.js +98 -67
- package/dist/src/executable-resolution.js +9 -3
- package/dist/src/git-source.js +3 -5
- package/dist/src/package-cli.js +9 -4
- package/dist/src/package-flow.js +2 -1
- package/dist/src/package-host-state.js +44 -6
- package/dist/src/package-state.js +2 -5
- package/dist/src/process-runner.js +234 -16
- package/dist/src/profile-cli.js +8 -6
- package/dist/src/progress.js +157 -0
- package/dist/src/source-state.js +2 -5
- package/dist/src/tui/adoption-actions.js +4 -3
- package/dist/src/tui/app-actions.js +16 -9
- package/dist/src/tui/bulk-actions.js +7 -6
- package/dist/src/tui/catalog-flow.js +3 -2
- package/dist/src/tui/catalog-panel.js +2 -2
- package/dist/src/tui/catalog-view.js +26 -6
- package/dist/src/tui/file-picker.js +52 -20
- package/dist/src/tui/installations-view.js +44 -11
- package/dist/src/tui/manage-actions.js +7 -6
- package/dist/src/tui/manage-panel.js +2 -2
- package/dist/src/tui/package-actions.js +63 -31
- package/dist/src/tui/panel-parts.js +20 -0
- package/dist/src/tui/profile-view.js +84 -15
- package/dist/src/tui/sources-mode.js +2 -1
- package/dist/src/tui/sources-recipes.js +22 -10
- package/dist/src/tui/sources-view.js +13 -12
- package/dist/src/tui/text-wrap.js +13 -0
- package/dist/src/tui/unmanaged-actions.js +3 -2
- package/dist/src/tui/updates-flow.js +10 -7
- package/dist/src/tui/updates-keys.js +3 -2
- package/dist/src/tui/updates-model.js +2 -2
- package/dist/src/tui/updates-panel.js +3 -2
- package/dist/src/tui/updates-view.js +6 -3
- package/dist/src/tui/view-state.js +12 -0
- package/dist/src/tui/working-model.js +33 -0
- package/package.json +2 -2
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { describeCoordinates } from "../package-model.js";
|
|
3
3
|
import { errorText } from "./batch.js";
|
|
4
|
-
import {
|
|
4
|
+
import { movePreviewCursor } from "./mode-keys.js";
|
|
5
5
|
import { installedEvidenceLabel, packagePlanLines } from "./package-rows.js";
|
|
6
6
|
import { PreviewConfirm } from "./panel-parts.js";
|
|
7
|
+
import { previewViewport } from "./preview-window.js";
|
|
8
|
+
import { busy } from "./working-model.js";
|
|
7
9
|
/** The per-row keys of a Package; Enter is approval, handled beside these. */
|
|
8
10
|
export const PACKAGE_ACTION_KEYS = new Map([
|
|
9
11
|
["i", "install"],
|
|
@@ -22,16 +24,16 @@ export async function startPackageAction(context, target, input) {
|
|
|
22
24
|
manage.setMessage(undefined);
|
|
23
25
|
try {
|
|
24
26
|
if (input === "approve") {
|
|
25
|
-
manage.setMode(
|
|
27
|
+
manage.setMode(busy(`Preparing the declaration of ${target.name}…`));
|
|
26
28
|
const plan = await packages.planLifecycle(target.selection, "install");
|
|
27
29
|
if (manage.isMounted()) {
|
|
28
|
-
manage.setMode({ kind: "package-approve", target, lines: packagePlanLines(plan, "needs approval") });
|
|
30
|
+
manage.setMode({ kind: "package-approve", target, lines: packagePlanLines(plan, "needs approval"), cursor: 0 });
|
|
29
31
|
}
|
|
30
32
|
return;
|
|
31
33
|
}
|
|
32
34
|
const action = PACKAGE_ACTION_KEYS.get(input);
|
|
33
35
|
if (action === "forget") {
|
|
34
|
-
manage.setMode(
|
|
36
|
+
manage.setMode(busy(`Preparing to forget ${target.name}…`));
|
|
35
37
|
const inspection = await packages.inspect(target.selection);
|
|
36
38
|
if (manage.isMounted()) {
|
|
37
39
|
manage.setMode({
|
|
@@ -43,16 +45,17 @@ export async function startPackageAction(context, target, input) {
|
|
|
43
45
|
`Installed: ${installedEvidenceLabel(inspection.installed)}`,
|
|
44
46
|
"Forgetting drops the Agent Depot selection record only; the host install is left as it is and no host command runs.",
|
|
45
47
|
],
|
|
48
|
+
cursor: 0,
|
|
46
49
|
});
|
|
47
50
|
}
|
|
48
51
|
return;
|
|
49
52
|
}
|
|
50
53
|
if (action !== undefined) {
|
|
51
|
-
manage.setMode(
|
|
54
|
+
manage.setMode(busy(`Preparing the ${action} of ${target.name}…`));
|
|
52
55
|
const plan = await packages.planLifecycle(target.selection, action);
|
|
53
56
|
const approval = await packages.approvalStatus(target.selection, action);
|
|
54
57
|
if (manage.isMounted())
|
|
55
|
-
manage.setMode({ kind: "package-confirm", plan, approval });
|
|
58
|
+
manage.setMode({ kind: "package-confirm", plan, approval, cursor: 0 });
|
|
56
59
|
return;
|
|
57
60
|
}
|
|
58
61
|
await manage.finish({ kind: "error", text: `The action ${JSON.stringify(input)} is not available for Packages` });
|
|
@@ -64,7 +67,7 @@ export async function startPackageAction(context, target, input) {
|
|
|
64
67
|
/** The `y` of a lifecycle confirmation whose receipt is missing approves it first, then asks again. */
|
|
65
68
|
async function approveThenConfirm(context, mode) {
|
|
66
69
|
const { manage, packages } = context;
|
|
67
|
-
manage.setMode(
|
|
70
|
+
manage.setMode(busy(`Approving the ${mode.plan.action} declaration of ${mode.plan.descriptor.name}…`));
|
|
68
71
|
try {
|
|
69
72
|
await packages.approve(mode.plan.selection, mode.plan.action);
|
|
70
73
|
if (manage.isMounted())
|
|
@@ -100,7 +103,7 @@ function resultLine(plan, result) {
|
|
|
100
103
|
}
|
|
101
104
|
async function executePackagePlan(context, plan) {
|
|
102
105
|
const { manage, packages } = context;
|
|
103
|
-
manage.setMode(
|
|
106
|
+
manage.setMode(busy(`Running the ${plan.action} of ${plan.descriptor.name}…`));
|
|
104
107
|
let result;
|
|
105
108
|
try {
|
|
106
109
|
result = await packages.executeLifecycle(plan, true);
|
|
@@ -114,7 +117,7 @@ async function executePackagePlan(context, plan) {
|
|
|
114
117
|
}
|
|
115
118
|
async function forgetSelection(context, mode) {
|
|
116
119
|
const { manage, packages } = context;
|
|
117
|
-
manage.setMode(
|
|
120
|
+
manage.setMode(busy(`Forgetting ${mode.target.name}…`));
|
|
118
121
|
try {
|
|
119
122
|
await packages.forget(mode.target.selection, true);
|
|
120
123
|
await manage.finish({ kind: "ok", text: `Forgot ${mode.target.name}; no host command ran` });
|
|
@@ -123,43 +126,72 @@ async function forgetSelection(context, mode) {
|
|
|
123
126
|
await manage.finish({ kind: "error", text: errorText(error) });
|
|
124
127
|
}
|
|
125
128
|
}
|
|
126
|
-
/**
|
|
129
|
+
/** What the preview shows for a mode, so the key handler and the panel page the same rows. */
|
|
130
|
+
function packagePreview(mode) {
|
|
131
|
+
switch (mode.kind) {
|
|
132
|
+
case "package-approve":
|
|
133
|
+
return {
|
|
134
|
+
lines: mode.lines,
|
|
135
|
+
chrome: reviewChrome("Package approval", `Approve this install declaration for ${mode.target.name}? y/n`),
|
|
136
|
+
};
|
|
137
|
+
case "package-forget":
|
|
138
|
+
return {
|
|
139
|
+
lines: mode.lines,
|
|
140
|
+
chrome: reviewChrome("Forget Package selection", `Forget the selection record of ${mode.target.name}? y/n`),
|
|
141
|
+
};
|
|
142
|
+
case "package-confirm":
|
|
143
|
+
return {
|
|
144
|
+
lines: packagePlanLines(mode.plan, mode.approval),
|
|
145
|
+
chrome: reviewChrome(`Package ${mode.plan.action} confirmation`, mode.approval === "needs approval"
|
|
146
|
+
? "Approve this declaration and continue? y/n"
|
|
147
|
+
: `${mode.plan.action} ${mode.plan.descriptor.name}? y/n`),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function reviewChrome(title, question) {
|
|
152
|
+
return { title, questions: [question], footerRows: 2 };
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Pages the declaration with the arrows, PgUp/PgDn and j/k. `y` answers from any page: the panel shows
|
|
156
|
+
* the `Review N of M` position and the row window while more remains. `n` and Esc run nothing.
|
|
157
|
+
*/
|
|
127
158
|
export function handlePackageKey(context, mode, input, key) {
|
|
128
159
|
const { manage, packages } = context;
|
|
129
160
|
const cancel = (text) => {
|
|
130
161
|
manage.setMessage({ kind: "ok", text });
|
|
131
162
|
manage.browse();
|
|
132
163
|
};
|
|
164
|
+
const { lines, chrome } = packagePreview(mode);
|
|
165
|
+
const viewport = previewViewport(lines, mode.cursor, context.terminalRows, context.terminalColumns, chrome);
|
|
166
|
+
const cursor = movePreviewCursor(mode.cursor, viewport.total, input, key, viewport.height);
|
|
167
|
+
if (cursor !== undefined) {
|
|
168
|
+
manage.setMode({ ...mode, cursor });
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (input === "n" || key.escape) {
|
|
172
|
+
cancel("Package action cancelled");
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (input !== "y")
|
|
176
|
+
return;
|
|
133
177
|
switch (mode.kind) {
|
|
134
178
|
case "package-approve":
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
void packages.approve(mode.target.selection, "install").then(() => manage.finish({ kind: "ok", text: `Approved the install declaration of ${mode.target.name}` }), error => manage.finish({ kind: "error", text: errorText(error) }));
|
|
138
|
-
}, () => cancel("Package action cancelled"));
|
|
179
|
+
manage.setMode(busy(`Approving the install declaration of ${mode.target.name}…`));
|
|
180
|
+
void packages.approve(mode.target.selection, "install").then(() => manage.finish({ kind: "ok", text: `Approved the install declaration of ${mode.target.name}` }), error => manage.finish({ kind: "error", text: errorText(error) }));
|
|
139
181
|
return;
|
|
140
182
|
case "package-forget":
|
|
141
|
-
|
|
183
|
+
void forgetSelection(context, mode);
|
|
142
184
|
return;
|
|
143
185
|
case "package-confirm":
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
void executePackagePlan(context, mode.plan);
|
|
149
|
-
}, () => cancel("Package action cancelled"));
|
|
186
|
+
if (mode.approval === "needs approval")
|
|
187
|
+
void approveThenConfirm(context, mode);
|
|
188
|
+
else
|
|
189
|
+
void executePackagePlan(context, mode.plan);
|
|
150
190
|
return;
|
|
151
191
|
}
|
|
152
192
|
}
|
|
153
193
|
/** Renders a Package prompt with the existing preview panel and y/n confirmation. */
|
|
154
194
|
export function PackagePanel({ mode }) {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
return _jsx(PreviewConfirm, { lines: mode.lines, question: `Approve this install declaration for ${mode.target.name}? y/n` });
|
|
158
|
-
case "package-forget":
|
|
159
|
-
return _jsx(PreviewConfirm, { lines: mode.lines, question: `Forget the selection record of ${mode.target.name}? y/n` });
|
|
160
|
-
case "package-confirm":
|
|
161
|
-
return (_jsx(PreviewConfirm, { lines: packagePlanLines(mode.plan, mode.approval), question: mode.approval === "needs approval"
|
|
162
|
-
? "Approve this declaration and continue? y/n"
|
|
163
|
-
: `${mode.plan.action} ${mode.plan.descriptor.name}? y/n` }));
|
|
164
|
-
}
|
|
195
|
+
const { lines, chrome } = packagePreview(mode);
|
|
196
|
+
return _jsx(PreviewConfirm, { title: chrome.title, lines: lines, cursor: mode.cursor, question: chrome.questions[0], chrome: chrome });
|
|
165
197
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text, useWindowSize } from "ink";
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
3
4
|
import { theme } from "./theme.js";
|
|
4
5
|
import { previewViewport } from "./preview-window.js";
|
|
6
|
+
import { animationEnabled, formatElapsed, SPINNER_FRAMES, SPINNER_TICK_MS, STATIC_GLYPH } from "./working-model.js";
|
|
5
7
|
/** A titled, color-independent boundary for a focused interaction or result. */
|
|
6
8
|
export function BorderPanel({ title, children }) {
|
|
7
9
|
return (_jsxs(Box, { borderStyle: "round", borderColor: theme.border, paddingX: 1, flexDirection: "column", children: [_jsx(Text, { color: theme.title, bold: true, children: title }), children] }));
|
|
@@ -9,6 +11,24 @@ export function BorderPanel({ title, children }) {
|
|
|
9
11
|
export function FieldLine({ label, value }) {
|
|
10
12
|
return _jsxs(Text, { children: [_jsx(Text, { color: theme.field, children: label }), _jsxs(Text, { color: theme.value, children: [" ", value] })] });
|
|
11
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* A spinner, the label and, from one second on, the time since `since` (when the operation began, not
|
|
16
|
+
* when this line mounted, so the clock survives label changes). Without animation it is a static line.
|
|
17
|
+
*/
|
|
18
|
+
export function Working({ label, since, now, animate }) {
|
|
19
|
+
const animated = animate ?? animationEnabled();
|
|
20
|
+
const [tick, setTick] = useState(0);
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (!animated)
|
|
23
|
+
return undefined;
|
|
24
|
+
const timer = setInterval(() => setTick((value) => value + 1), SPINNER_TICK_MS);
|
|
25
|
+
// A redraw timer alone must never keep the process alive.
|
|
26
|
+
timer.unref();
|
|
27
|
+
return () => clearInterval(timer);
|
|
28
|
+
}, [animated]);
|
|
29
|
+
const elapsed = animated ? formatElapsed((now ?? Date.now)() - since) : "";
|
|
30
|
+
return (_jsxs(Text, { children: [_jsx(Text, { color: theme.accent, children: animated ? SPINNER_FRAMES[tick % SPINNER_FRAMES.length] : STATIC_GLYPH }), _jsxs(Text, { color: theme.value, children: [" ", label] }), elapsed ? _jsxs(Text, { color: theme.legend, children: [" ", elapsed] }) : null] }));
|
|
31
|
+
}
|
|
12
32
|
export function DecisionPrompt({ children }) {
|
|
13
33
|
return (_jsxs(Text, { children: [_jsx(Text, { color: theme.warning, children: children }), " ", _jsx(Text, { color: theme.success, bold: true, children: "y" }), _jsx(Text, { color: theme.legend, children: "/" }), _jsx(Text, { color: theme.error, children: "n" })] }));
|
|
14
34
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { Box, Text, useWindowSize } from "ink";
|
|
4
4
|
import stringWidth from "string-width";
|
|
@@ -12,12 +12,14 @@ import { packageOperationsOf } from "./environment.js";
|
|
|
12
12
|
import { DEFAULT_PROFILE_FILE_NAME, exportDestination, fileNameNotice, pathExists, pickerStartDirectory, resolveExportFileName, } from "./file-picker-model.js";
|
|
13
13
|
import { FilePicker } from "./file-picker.js";
|
|
14
14
|
import { useKeys } from "./keys.js";
|
|
15
|
+
import { previewViewport } from "./preview-window.js";
|
|
15
16
|
import { answerYesNo, moveCursor, movePreviewCursor, toggleChoice } from "./mode-keys.js";
|
|
16
|
-
import { BorderPanel, PreviewConfirm, PreviewLines } from "./panel-parts.js";
|
|
17
|
-
import { fitLines, wrapText } from "./text-wrap.js";
|
|
17
|
+
import { BorderPanel, PreviewConfirm, PreviewLines, Working } from "./panel-parts.js";
|
|
18
|
+
import { elideMiddle, fitLines, wrapText } from "./text-wrap.js";
|
|
18
19
|
import { theme } from "./theme.js";
|
|
19
20
|
import { useMirrored, useMounted } from "./view-state.js";
|
|
20
21
|
import { computeWindow, useListHeight } from "./window.js";
|
|
22
|
+
import { busy } from "./working-model.js";
|
|
21
23
|
function rowsFromExport(plan) {
|
|
22
24
|
return [
|
|
23
25
|
...plan.profile.sources.map((value, index) => ({
|
|
@@ -105,6 +107,37 @@ function physicalListWindow(rows, rowHeights, selected, height) {
|
|
|
105
107
|
return { ...range, indicator: hidden ? `${range.start + 1}–${range.end} of ${rows.length}` : undefined, stickyGroup };
|
|
106
108
|
}
|
|
107
109
|
/** The four import blocks in one indexed list, so the checklist and the confirmation share positions. */
|
|
110
|
+
/** An error naming a long path keeps its first and last rows, where the path starts and the reason ends. */
|
|
111
|
+
const NAME_ERROR_ROWS = 3;
|
|
112
|
+
const NAME_HINT = "Type to edit · Ctrl+U clear · Enter continue · Esc back to folder";
|
|
113
|
+
/**
|
|
114
|
+
* A `Label: value` line of the export name stage, wrapped to the panel and kept within `rows` rows. The
|
|
115
|
+
* end of a path (its last folder, the file name) and the end of the typed name (where the cursor is) are
|
|
116
|
+
* what matter, so with fewer than three rows the field keeps its label and its tail behind a `…`; with
|
|
117
|
+
* three or more a path keeps its first row too, around a count of the rows left out.
|
|
118
|
+
*/
|
|
119
|
+
function NameField({ label, value, rows, width, tail = false }) {
|
|
120
|
+
const wrapped = wrapText(`${label}${value}`, width);
|
|
121
|
+
const cut = wrapped.length > rows;
|
|
122
|
+
const keepsMiddleCount = cut && !tail && rows > 2;
|
|
123
|
+
let shown = wrapped;
|
|
124
|
+
if (keepsMiddleCount)
|
|
125
|
+
shown = elideMiddle(wrapped, rows, width);
|
|
126
|
+
else if (cut) {
|
|
127
|
+
const characters = Array.from(value);
|
|
128
|
+
for (let dropped = 1; dropped <= characters.length; dropped += 1) {
|
|
129
|
+
shown = wrapText(`${label}…${characters.slice(dropped).join("")}`, width);
|
|
130
|
+
if (shown.length <= rows)
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return _jsx(_Fragment, { children: shown.map((line, index) => {
|
|
135
|
+
if (index === 0 && line.startsWith(label)) {
|
|
136
|
+
return _jsxs(Text, { children: [_jsx(Text, { color: theme.field, children: label }), _jsx(Text, { color: theme.value, children: line.slice(label.length) })] }, index);
|
|
137
|
+
}
|
|
138
|
+
return _jsx(Text, { color: keepsMiddleCount && index === 1 ? theme.muted : theme.value, children: line }, index);
|
|
139
|
+
}) });
|
|
140
|
+
}
|
|
108
141
|
function planEntries(plan) {
|
|
109
142
|
return [...plan.items, ...plan.packages];
|
|
110
143
|
}
|
|
@@ -159,6 +192,9 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
|
|
|
159
192
|
const [cursor, cursorRef, cursorTo] = useMirrored(0);
|
|
160
193
|
const [text, typed, setText] = useMirrored("");
|
|
161
194
|
const [nameError, setNameError] = useState(undefined);
|
|
195
|
+
// What the busy mode is doing, and when the name check of the export file began, if one is running.
|
|
196
|
+
const [working, setWorking] = useState(undefined);
|
|
197
|
+
const [nameChecking, setNameChecking] = useState(undefined);
|
|
162
198
|
const [importNotice, setImportNotice] = useState(undefined);
|
|
163
199
|
const [exportDirectory, setExportDirectory] = useState(undefined);
|
|
164
200
|
const [importDirectory, setImportDirectory] = useState(undefined);
|
|
@@ -170,7 +206,7 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
|
|
|
170
206
|
const [profile, setProfile] = useState();
|
|
171
207
|
const [plan, setPlan] = useState();
|
|
172
208
|
const diagnostics = useRef([]);
|
|
173
|
-
const { columns: terminalColumns } = useWindowSize();
|
|
209
|
+
const { rows: terminalRows, columns: terminalColumns } = useWindowSize();
|
|
174
210
|
const panelWidth = Math.max((terminalColumns || 80) - 4, 1);
|
|
175
211
|
const hintCursorRow = (mode === "export-list" ? exportDisplayRows(rows, expandedGroupsRef.current) : rows)[cursorRef.current];
|
|
176
212
|
const enterHint = hintCursorRow === undefined ? undefined
|
|
@@ -184,13 +220,24 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
|
|
|
184
220
|
const titleRows = Math.max(Math.ceil(stringWidth("Export Profile") / panelWidth), 1);
|
|
185
221
|
const hintRows = Math.max(Math.ceil(stringWidth(hintText) / panelWidth), 1);
|
|
186
222
|
const height = useListHeight(listHeight, 4 + titleRows + hintRows);
|
|
223
|
+
const confirmChrome = () => ({
|
|
224
|
+
title: "Confirmation",
|
|
225
|
+
questions: [confirmRequest.current?.kind === "export"
|
|
226
|
+
? "Save/Export Profile? (y/n) · arrows/PgUp/PgDn page · j/k line"
|
|
227
|
+
: "Apply Profile? (y/n) · arrows/PgUp/PgDn page · j/k line"],
|
|
228
|
+
footerRows: 2,
|
|
229
|
+
});
|
|
187
230
|
const showLines = (next, nextMode) => {
|
|
188
231
|
setLines(next);
|
|
189
232
|
cursorTo(0);
|
|
190
233
|
setMode(nextMode);
|
|
191
234
|
};
|
|
192
|
-
const
|
|
235
|
+
const startBusy = (label) => {
|
|
236
|
+
setWorking(busy(label));
|
|
193
237
|
setMode("busy");
|
|
238
|
+
};
|
|
239
|
+
const run = async (label, work) => {
|
|
240
|
+
startBusy(label);
|
|
194
241
|
try {
|
|
195
242
|
await work();
|
|
196
243
|
}
|
|
@@ -240,7 +287,10 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
|
|
|
240
287
|
setNameError(undefined);
|
|
241
288
|
setMode("export-folder");
|
|
242
289
|
};
|
|
243
|
-
const abandonNameCheck = () => {
|
|
290
|
+
const abandonNameCheck = () => {
|
|
291
|
+
nameRequest.current += 1;
|
|
292
|
+
setNameChecking(undefined);
|
|
293
|
+
};
|
|
244
294
|
const chooseExportDirectory = (directory) => {
|
|
245
295
|
abandonNameCheck();
|
|
246
296
|
setExportDirectory(directory);
|
|
@@ -259,6 +309,8 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
|
|
|
259
309
|
const fileName = outcome.fileName;
|
|
260
310
|
const finalPath = exportDestination(directory, fileName);
|
|
261
311
|
const id = ++nameRequest.current;
|
|
312
|
+
// The check never blocks keys, so a line beside the name field shows it is running.
|
|
313
|
+
setNameChecking(Date.now());
|
|
262
314
|
void (async () => {
|
|
263
315
|
let exists;
|
|
264
316
|
try {
|
|
@@ -267,9 +319,12 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
|
|
|
267
319
|
catch (error) {
|
|
268
320
|
if (!mounted.current || nameRequest.current !== id || modeRef.current !== "export-name")
|
|
269
321
|
return;
|
|
322
|
+
setNameChecking(undefined);
|
|
270
323
|
setNameError(`Error: ${error instanceof Error ? error.message : String(error)}`);
|
|
271
324
|
return;
|
|
272
325
|
}
|
|
326
|
+
if (mounted.current && nameRequest.current === id)
|
|
327
|
+
setNameChecking(undefined);
|
|
273
328
|
const current = resolveExportFileName(typed.current);
|
|
274
329
|
if (!mounted.current || nameRequest.current !== id || modeRef.current !== "export-name")
|
|
275
330
|
return;
|
|
@@ -288,7 +343,7 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
|
|
|
288
343
|
const request = confirmRequest.current;
|
|
289
344
|
if (request === undefined)
|
|
290
345
|
return;
|
|
291
|
-
void run(async () => {
|
|
346
|
+
void run(request.kind === "export" ? "Exporting…" : "Applying import…", async () => {
|
|
292
347
|
let result;
|
|
293
348
|
if (request.kind === "export") {
|
|
294
349
|
await writeProfileExport(request.file, chosenProfile());
|
|
@@ -304,7 +359,7 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
|
|
|
304
359
|
});
|
|
305
360
|
};
|
|
306
361
|
const previewImport = () => {
|
|
307
|
-
void run(async () => {
|
|
362
|
+
void run("Preparing import preview…", async () => {
|
|
308
363
|
// Reuse the CLI's core preview for the exact selected subset, including argv and Source notes.
|
|
309
364
|
const entries = selectedPlanEntries(plan, selectedRef.current);
|
|
310
365
|
const subset = selectedImportProfile(profile, entries);
|
|
@@ -320,7 +375,7 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
|
|
|
320
375
|
return;
|
|
321
376
|
setImportDirectory(selection.directory);
|
|
322
377
|
const id = ++importRequest.current;
|
|
323
|
-
|
|
378
|
+
startBusy("Loading Profile…");
|
|
324
379
|
void (async () => {
|
|
325
380
|
try {
|
|
326
381
|
const importedProfile = parseProfile(JSON.parse(await readFile(selection.file, "utf8")));
|
|
@@ -347,7 +402,7 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
|
|
|
347
402
|
return;
|
|
348
403
|
if (current === "browse" || current === "results") {
|
|
349
404
|
if (input === "e") {
|
|
350
|
-
void run(async () => {
|
|
405
|
+
void run("Loading Profile…", async () => {
|
|
351
406
|
const exported = await buildProfileExport(operations, apps, {
|
|
352
407
|
homeDirectory: environment.homeDirectory, packageOperations: packages,
|
|
353
408
|
});
|
|
@@ -416,7 +471,9 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
|
|
|
416
471
|
answerYesNo(input, key, confirmApply, () => setMode(confirmRequest.current?.kind === "import" ? "import-list" : "export-name"));
|
|
417
472
|
if (input === "y" || input === "n" || key.escape)
|
|
418
473
|
return;
|
|
419
|
-
|
|
474
|
+
// The preview cursor counts wrapped rows, not source lines, or a long destination would cap the paging.
|
|
475
|
+
const viewport = previewViewport(lines, cursorRef.current, terminalRows || 24, terminalColumns || 80, confirmChrome());
|
|
476
|
+
const next = movePreviewCursor(cursorRef.current, viewport.total, input, key, viewport.height);
|
|
420
477
|
if (next !== undefined)
|
|
421
478
|
cursorTo(Math.max(0, next));
|
|
422
479
|
return;
|
|
@@ -521,9 +578,21 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
|
|
|
521
578
|
const liveDestination = nameOutcome.kind === "ok"
|
|
522
579
|
? exportDestination(exportDirectory ?? startDirectory, nameOutcome.fileName) : undefined;
|
|
523
580
|
const nameNote = nameOutcome.kind === "ok" ? fileNameNotice(nameOutcome.fileName) : undefined;
|
|
581
|
+
const extensionNote = nameOutcome.kind === "ok" && nameOutcome.appendedExtension
|
|
582
|
+
? `A name without an extension is saved as ${nameOutcome.fileName}.` : undefined;
|
|
583
|
+
const textRows = (value) => value === undefined ? 0 : wrapText(value, panelWidth).length;
|
|
584
|
+
const nameErrorLines = nameError === undefined ? [] : wrapText(nameError, panelWidth);
|
|
585
|
+
// Keys type into the name, so the stage cannot scroll; instead each long field keeps its end (and, given
|
|
586
|
+
// three rows or more, its first row) within a share of the terminal. The confirmation pages the full destination.
|
|
587
|
+
const nameFixedRows = 3 + textRows(nameNote) + textRows(extensionNote) + Math.min(nameErrorLines.length, NAME_ERROR_ROWS)
|
|
588
|
+
+ (nameChecking === undefined ? 0 : 1) + textRows(NAME_HINT);
|
|
589
|
+
const fieldRows = useListHeight(listHeight, nameFixedRows, 3);
|
|
590
|
+
// Leftover rows go to the paths, which are the long fields, before the typed name.
|
|
591
|
+
const folderRows = Math.floor(fieldRows / 3) + (fieldRows % 3 >= 2 ? 1 : 0);
|
|
592
|
+
const destinationRows = Math.floor(fieldRows / 3) + (fieldRows % 3 >= 1 ? 1 : 0);
|
|
593
|
+
const nameRows = fieldRows - folderRows - destinationRows;
|
|
524
594
|
return _jsxs(Box, { flexDirection: "column", children: [mode === "export-folder" ? _jsx(FilePicker, { purpose: "directory", listHeight: listHeight, startDirectory: exportDirectory ?? startDirectory, onCancel: () => setMode("export-list"), onChoose: selection => { if (selection.kind === "directory")
|
|
525
|
-
chooseExportDirectory(selection.directory); } }) : null, mode === "import-browse" ? _jsx(FilePicker, { purpose: "file", listHeight: listHeight, startDirectory: importDirectory ?? startDirectory, notice: importNotice, onCancel: () => setMode("browse"), onChoose: chooseImportFile }) : null, listing ? _jsxs(BorderPanel, { title: mode === "export-list" ? "Export Profile" : "Import Profile", children: [listContent, _jsx(Text, { color: theme.legend, children: hintText }), window.indicator ? _jsx(Text, { color: theme.muted, children: window.indicator }) : null] }) : null, mode === "browse" ? _jsx(BorderPanel, { title: "Portable Profile", children: _jsx(Text, { color: theme.legend, children: "e export \u00B7 i import" }) }) : null, mode === "busy" ? _jsx(
|
|
526
|
-
?
|
|
527
|
-
|
|
528
|
-
: "Apply Profile? (y/n) · arrows/PgUp/PgDn page · j/k line" }) : null, mode === "detail" ? _jsx(PreviewConfirm, { title: "Export note", lines: lines, cursor: cursor, question: "Esc close \u00B7 arrows/PgUp/PgDn page \u00B7 j/k line" }) : null, mode === "results" ? _jsxs(_Fragment, { children: [_jsx(PreviewLines, { lines: lines.slice(window.start, window.end) }), _jsx(Text, { children: "j/k scroll" })] }) : null, window.indicator && mode === "results" ? _jsx(Text, { children: window.indicator }) : null] });
|
|
595
|
+
chooseExportDirectory(selection.directory); } }) : null, mode === "import-browse" ? _jsx(FilePicker, { purpose: "file", listHeight: listHeight, startDirectory: importDirectory ?? startDirectory, notice: importNotice, onCancel: () => setMode("browse"), onChoose: chooseImportFile }) : null, listing ? _jsxs(BorderPanel, { title: mode === "export-list" ? "Export Profile" : "Import Profile", children: [listContent, _jsx(Text, { color: theme.legend, children: hintText }), window.indicator ? _jsx(Text, { color: theme.muted, children: window.indicator }) : null] }) : null, mode === "browse" ? _jsx(BorderPanel, { title: "Portable Profile", children: _jsx(Text, { color: theme.legend, children: "e export \u00B7 i import" }) }) : null, mode === "busy" && working ? _jsx(Working, { label: working.label, since: working.startedAt }) : null, mode === "export-name" ? _jsxs(BorderPanel, { title: "Export file name", children: [_jsx(NameField, { label: "Folder: ", value: exportDirectory ?? startDirectory, rows: folderRows, width: panelWidth }), _jsx(NameField, { label: "Name: ", value: `${text}_`, rows: nameRows, width: panelWidth, tail: true }), liveDestination === undefined ? null : _jsx(NameField, { label: "Destination: ", value: liveDestination, rows: destinationRows, width: panelWidth }), nameNote === undefined ? null : _jsx(Text, { color: theme.warning, children: nameNote }), extensionNote === undefined ? null : _jsx(Text, { color: theme.muted, children: extensionNote }), nameError === undefined ? null : nameErrorLines.length <= NAME_ERROR_ROWS
|
|
596
|
+
? _jsx(Text, { color: theme.error, children: nameError })
|
|
597
|
+
: elideMiddle(nameErrorLines, NAME_ERROR_ROWS, panelWidth).map((line, index) => _jsx(Text, { color: theme.error, children: line }, index)), nameChecking === undefined ? null : _jsx(Working, { label: "Checking name\u2026", since: nameChecking }), _jsx(Text, { color: theme.legend, children: NAME_HINT })] }) : null, mode === "confirm" ? _jsx(PreviewConfirm, { lines: lines, cursor: cursor, chrome: confirmChrome(), question: confirmChrome().questions[0] }) : null, mode === "detail" ? _jsx(PreviewConfirm, { title: "Export note", lines: lines, cursor: cursor, question: "Esc close \u00B7 arrows/PgUp/PgDn page \u00B7 j/k line" }) : null, mode === "results" ? _jsxs(_Fragment, { children: [_jsx(PreviewLines, { lines: lines.slice(window.start, window.end) }), _jsx(Text, { children: "j/k scroll" })] }) : null, window.indicator && mode === "results" ? _jsx(Text, { children: window.indicator }) : null] });
|
|
529
598
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { busy } from "./working-model.js";
|
|
1
2
|
export const initialMode = { kind: "browse" };
|
|
2
3
|
export function modeReducer(mode, event) {
|
|
3
4
|
switch (event.type) {
|
|
@@ -14,7 +15,7 @@ export function modeReducer(mode, event) {
|
|
|
14
15
|
case "preview-cursor":
|
|
15
16
|
return mode.kind === "confirm" && mode.action === "refresh" ? { ...mode, cursor: event.cursor } : mode;
|
|
16
17
|
case "busy":
|
|
17
|
-
return
|
|
18
|
+
return busy(event.label, mode, () => event.startedAt);
|
|
18
19
|
case "done":
|
|
19
20
|
return initialMode;
|
|
20
21
|
}
|
|
@@ -10,10 +10,11 @@ import { hasUnmanagedCopy, isInstalled, loadInstalledSkills } from "./catalog-in
|
|
|
10
10
|
import { appName, appRecipePreviewLines, appStepLines } from "./app-actions.js";
|
|
11
11
|
import { errorText } from "./batch.js";
|
|
12
12
|
import { useMounted } from "./view-state.js";
|
|
13
|
-
import { BorderPanel, DecisionPrompt, PreviewConfirm } from "./panel-parts.js";
|
|
13
|
+
import { BorderPanel, DecisionPrompt, PreviewConfirm, Working } from "./panel-parts.js";
|
|
14
14
|
import { answerYesNo, movePreviewCursor } from "./mode-keys.js";
|
|
15
15
|
import { previewViewport } from "./preview-window.js";
|
|
16
16
|
import { theme } from "./theme.js";
|
|
17
|
+
import { busy } from "./working-model.js";
|
|
17
18
|
function recipeApprovalChrome(entry) {
|
|
18
19
|
return {
|
|
19
20
|
title: "Recipe approval · does not install",
|
|
@@ -43,6 +44,9 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
|
|
|
43
44
|
const [error, setError] = useState();
|
|
44
45
|
const [notice, setNotice] = useState();
|
|
45
46
|
const [loadError, setLoadError] = useState();
|
|
47
|
+
// When the oldest running reload began; reloads can overlap, so a count decides when loading ends.
|
|
48
|
+
const [loadingSince, setLoadingSince] = useState(() => Date.now());
|
|
49
|
+
const reloading = useRef(0);
|
|
46
50
|
const [mode, setModeState] = useState({ kind: "browse" });
|
|
47
51
|
const modeRef = useRef(mode);
|
|
48
52
|
const setMode = (next) => {
|
|
@@ -50,6 +54,8 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
|
|
|
50
54
|
onCapturingChange?.(next.kind !== "browse");
|
|
51
55
|
setModeState(next);
|
|
52
56
|
};
|
|
57
|
+
/** Shows the busy panel; an operation already busy keeps its start time. */
|
|
58
|
+
const setBusy = (label) => setMode(busy(label, modeRef.current));
|
|
53
59
|
const finish = async () => {
|
|
54
60
|
await reload();
|
|
55
61
|
if (mounted.current)
|
|
@@ -57,7 +63,7 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
|
|
|
57
63
|
};
|
|
58
64
|
const preview = async (entry, status, removeAfterApproval = false) => {
|
|
59
65
|
setError(undefined);
|
|
60
|
-
|
|
66
|
+
setBusy("Preparing App recipe preview…");
|
|
61
67
|
try {
|
|
62
68
|
const steps = await apps.preview(entry);
|
|
63
69
|
if (mounted.current) {
|
|
@@ -95,7 +101,7 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
|
|
|
95
101
|
},
|
|
96
102
|
};
|
|
97
103
|
const startAI = async () => {
|
|
98
|
-
|
|
104
|
+
setBusy("Checking the authoring Skill…");
|
|
99
105
|
try {
|
|
100
106
|
if (!operations.discoverSkills)
|
|
101
107
|
throw new Error("Discovery is not supported by the configured operations");
|
|
@@ -124,7 +130,7 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
|
|
|
124
130
|
};
|
|
125
131
|
const startRemoval = async (entry) => {
|
|
126
132
|
setError(undefined);
|
|
127
|
-
|
|
133
|
+
setBusy("Checking App recipe…");
|
|
128
134
|
try {
|
|
129
135
|
const approval = await apps.approvalStatus(entry);
|
|
130
136
|
if (approval.status === "needs approval") {
|
|
@@ -143,7 +149,7 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
|
|
|
143
149
|
}
|
|
144
150
|
};
|
|
145
151
|
const uninstall = async (current) => {
|
|
146
|
-
|
|
152
|
+
setBusy(current.kind === "uninstall-manual" ? "Checking App uninstall…" : "Uninstalling App…");
|
|
147
153
|
try {
|
|
148
154
|
const result = current.kind === "uninstall-manual"
|
|
149
155
|
? await apps.completeManual(current.plan, true)
|
|
@@ -200,7 +206,7 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
|
|
|
200
206
|
else if (input === "n" || key.escape)
|
|
201
207
|
cancel();
|
|
202
208
|
else if (input === "y" && viewport.reachedEnd) {
|
|
203
|
-
|
|
209
|
+
setBusy("Approving App recipe…");
|
|
204
210
|
void apps.approve(current.entry).then(async () => {
|
|
205
211
|
await reload();
|
|
206
212
|
if (!mounted.current)
|
|
@@ -220,7 +226,7 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
|
|
|
220
226
|
}
|
|
221
227
|
if (current.kind === "uninstall-question") {
|
|
222
228
|
answerYesNo(input, key, () => {
|
|
223
|
-
|
|
229
|
+
setBusy("Preparing App uninstall…");
|
|
224
230
|
void apps.planLifecycle(current.removal.entry, "uninstall").then(plan => {
|
|
225
231
|
if (mounted.current)
|
|
226
232
|
setMode({ kind: !plan.argv && plan.manual ? "uninstall-manual" : "uninstall-confirm", removal: current.removal, plan, cursor: 0 });
|
|
@@ -245,7 +251,7 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
|
|
|
245
251
|
}
|
|
246
252
|
if (current.kind === "remove") {
|
|
247
253
|
answerYesNo(input, key, () => {
|
|
248
|
-
|
|
254
|
+
setBusy("Removing App recipe…");
|
|
249
255
|
void removeAppRecipe(apps, current.removal, true).then(async () => {
|
|
250
256
|
if (mounted.current)
|
|
251
257
|
setNotice(`Removed App recipe: ${current.removal.file}`);
|
|
@@ -276,6 +282,8 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
|
|
|
276
282
|
};
|
|
277
283
|
/** Reloads parsing and approval receipts, never version/latest commands. */
|
|
278
284
|
const reload = useCallback(async () => {
|
|
285
|
+
if (reloading.current++ === 0 && mounted.current)
|
|
286
|
+
setLoadingSince(Date.now());
|
|
279
287
|
try {
|
|
280
288
|
const entries = await apps.load();
|
|
281
289
|
const rows = await Promise.all(entries.map(async (entry) => {
|
|
@@ -294,9 +302,13 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
|
|
|
294
302
|
if (mounted.current)
|
|
295
303
|
setLoadError(errorText(error));
|
|
296
304
|
}
|
|
305
|
+
finally {
|
|
306
|
+
if (--reloading.current === 0 && mounted.current)
|
|
307
|
+
setLoadingSince(undefined);
|
|
308
|
+
}
|
|
297
309
|
}, [apps, mounted]);
|
|
298
310
|
useEffect(() => { void reload(); }, [reload]);
|
|
299
|
-
const panel = (_jsxs(Box, { flexDirection: "column", children: [loadError ? _jsxs(Text, { color: theme.error, children: ["Error loading App recipes: ", loadError] }) : null, error ? _jsx(Text, { color: theme.error, children: error }) : null, notice ? _jsx(Text, { color: theme.success, children: notice }) : null, mode.kind === "guide" ? (_jsxs(BorderPanel, { title: "Add an App recipe", children: [_jsx(Text, { children: "1 With the AI skill \u00B7 install if missing, then copy the prompt" }), _jsx(Text, { children: "2 Manual \u00B7 write a recipe using the schema" }), _jsx(Text, { children: "n or Esc closes" })] })) : null, mode.kind === "manual" ? (_jsxs(BorderPanel, { title: "Write an App recipe", children: [_jsxs(Text, { children: ["Write a JSON recipe in ", apps.directory] }), _jsx(Text, { children: "Schema: agent-depot app schema \u00B7 n or Esc closes" })] })) : null, mode.kind === "prompt" ? (_jsxs(BorderPanel, { title: "App recipe authoring prompt", children: [_jsx(Text, { children: "Use the agent-depot-apprecipe skill to create the recipe for <app>" }), _jsxs(Text, { children: ["Recipes: ", apps.directory, " \u00B7 n or Esc closes"] })] })) : null, mode.kind === "install" ? _jsx(ActionPanel, { mode: mode.action }) : null, mode.kind === "busy" ? _jsx(BorderPanel, { title: "App recipe workflow", children: _jsx(
|
|
311
|
+
const panel = (_jsxs(Box, { flexDirection: "column", children: [loadError ? _jsxs(Text, { color: theme.error, children: ["Error loading App recipes: ", loadError] }) : null, error ? _jsx(Text, { color: theme.error, children: error }) : null, notice ? _jsx(Text, { color: theme.success, children: notice }) : null, mode.kind === "guide" ? (_jsxs(BorderPanel, { title: "Add an App recipe", children: [_jsx(Text, { children: "1 With the AI skill \u00B7 install if missing, then copy the prompt" }), _jsx(Text, { children: "2 Manual \u00B7 write a recipe using the schema" }), _jsx(Text, { children: "n or Esc closes" })] })) : null, mode.kind === "manual" ? (_jsxs(BorderPanel, { title: "Write an App recipe", children: [_jsxs(Text, { children: ["Write a JSON recipe in ", apps.directory] }), _jsx(Text, { children: "Schema: agent-depot app schema \u00B7 n or Esc closes" })] })) : null, mode.kind === "prompt" ? (_jsxs(BorderPanel, { title: "App recipe authoring prompt", children: [_jsx(Text, { children: "Use the agent-depot-apprecipe skill to create the recipe for <app>" }), _jsxs(Text, { children: ["Recipes: ", apps.directory, " \u00B7 n or Esc closes"] })] })) : null, mode.kind === "install" ? _jsx(ActionPanel, { mode: mode.action }) : null, mode.kind === "busy" ? _jsx(BorderPanel, { title: "App recipe workflow", children: _jsx(Working, { label: mode.label, since: mode.startedAt }) }) : null, mode.kind === "approve" ? (() => {
|
|
300
312
|
const chrome = recipeApprovalChrome(mode.entry);
|
|
301
313
|
const viewport = previewViewport(mode.lines, mode.cursor, terminalRows, terminalColumns, chrome);
|
|
302
314
|
return _jsx(PreviewConfirm, { title: chrome.title, lines: mode.lines, cursor: mode.cursor, question: viewport.reachedEnd ? chrome.questions[0] : chrome.questions[1], chrome: chrome });
|
|
@@ -306,5 +318,5 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
|
|
|
306
318
|
const viewport = previewViewport(lines, mode.cursor, terminalRows, terminalColumns, chrome);
|
|
307
319
|
return _jsx(PreviewConfirm, { title: chrome.title, lines: lines, cursor: mode.cursor, question: viewport.reachedEnd ? chrome.questions[0] : chrome.questions[1], chrome: chrome });
|
|
308
320
|
})() : null, mode.kind === "remove" ? (_jsx(PreviewConfirm, { title: "Recipe removal", lines: [`Delete recipe: ${mode.removal.file}`, "Only this recipe file or symlink will be removed; App data and Host configuration are not deleted."], question: `Remove recipe for ${appName(mode.removal.entry)}? y/n` })) : null] }));
|
|
309
|
-
return { rows, handleKey, startAdd, reload, clearMessage: () => { setError(undefined); setNotice(undefined); }, panel, directory: apps.directory, capturing: mode.kind !== "browse" };
|
|
321
|
+
return { rows, loadingSince, handleKey, startAdd, reload, clearMessage: () => { setError(undefined); setNotice(undefined); }, panel, directory: apps.directory, capturing: mode.kind !== "browse" };
|
|
310
322
|
}
|