@iseoane/agent-depot 0.6.8 → 0.6.10
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 +45 -0
- package/README.md +24 -2
- package/dist/src/app-flow.js +3 -3
- package/dist/src/app-recipe-removal.js +3 -1
- package/dist/src/atomic-file.js +24 -1
- package/dist/src/cli.js +47 -13
- package/dist/src/git-source.js +42 -28
- package/dist/src/path-safety.js +4 -1
- package/dist/src/process-runner.js +12 -1
- package/dist/src/profile-export.js +1 -1
- package/dist/src/profile-import.js +94 -18
- package/dist/src/project-installation.js +2 -1
- package/dist/src/project-manifest.js +7 -0
- package/dist/src/project-target.js +58 -0
- package/dist/src/skill-discovery.js +86 -6
- package/dist/src/skill-install.js +20 -8
- package/dist/src/source-snapshot.js +59 -0
- package/dist/src/tui/app-actions.js +18 -7
- package/dist/src/tui/app.js +19 -4
- package/dist/src/tui/batch.js +2 -1
- package/dist/src/tui/bulk-actions.js +21 -8
- package/dist/src/tui/catalog-installs.js +3 -2
- package/dist/src/tui/catalog-panel.js +3 -2
- package/dist/src/tui/catalog-state.js +47 -21
- package/dist/src/tui/catalog-tree.js +47 -10
- package/dist/src/tui/catalog-view.js +11 -5
- package/dist/src/tui/installations-view.js +19 -6
- package/dist/src/tui/manage-panel.js +1 -1
- package/dist/src/tui/profile-view.js +68 -31
- package/dist/src/tui/sources-recipes.js +44 -8
- package/dist/src/tui/sources-view.js +12 -5
- package/dist/src/tui/updates.js +1 -0
- package/dist/src/tui/view-retention.js +17 -0
- package/dist/src/update-flow.js +3 -1
- package/package.json +1 -1
package/dist/src/tui/batch.js
CHANGED
|
@@ -3,9 +3,10 @@ export const errorText = (error) => (error instanceof Error ? error.message : St
|
|
|
3
3
|
* Applies the items one after another. A failure is reported for that item and
|
|
4
4
|
* the rest still run, so the outcomes always cover every item in order.
|
|
5
5
|
*/
|
|
6
|
-
export async function runBatch(items, run, describeFailure) {
|
|
6
|
+
export async function runBatch(items, run, describeFailure, onStart) {
|
|
7
7
|
const outcomes = [];
|
|
8
8
|
for (const item of items) {
|
|
9
|
+
onStart?.(item, outcomes.length + 1, items.length, outcomes);
|
|
9
10
|
try {
|
|
10
11
|
outcomes.push({ item, ok: true, line: await run(item) });
|
|
11
12
|
}
|
|
@@ -78,8 +78,16 @@ function resultOf(outcomes, skipped) {
|
|
|
78
78
|
applied: outcomes.filter((outcome) => outcome.ok).map((outcome) => outcome.item.id),
|
|
79
79
|
};
|
|
80
80
|
}
|
|
81
|
+
/** `Removing name (2/5)…` for each item as it starts. */
|
|
82
|
+
function startOf(verb, progress) {
|
|
83
|
+
if (!progress)
|
|
84
|
+
return undefined;
|
|
85
|
+
return (item, position, total, finished) => {
|
|
86
|
+
progress(`${verb} ${item.label} (${position}/${total})…`, finished.map(outcome => outcome.line));
|
|
87
|
+
};
|
|
88
|
+
}
|
|
81
89
|
/** Execute step: each item is rechecked and applied on its own against the records the previous items were expected to leave. */
|
|
82
|
-
export async function runBulkUninstall(plan, operations, env) {
|
|
90
|
+
export async function runBulkUninstall(plan, operations, env, progress) {
|
|
83
91
|
let expected = plan.installations;
|
|
84
92
|
const outcomes = await runBatch(plan.items, async (item) => {
|
|
85
93
|
await assertNoDrift(operations, expected);
|
|
@@ -95,7 +103,7 @@ export async function runBulkUninstall(plan, operations, env) {
|
|
|
95
103
|
return item.prepared.plan.inspections.every((inspection) => inspection.recordOnly)
|
|
96
104
|
? `Forgot missing installation record for ${item.label}; no files were removed`
|
|
97
105
|
: `Removed ${item.label}`;
|
|
98
|
-
}, (item, error) => (item.kind === "unmanaged" ? errorText(error) : `Failed ${item.label}: ${errorText(error)}`));
|
|
106
|
+
}, (item, error) => (item.kind === "unmanaged" ? errorText(error) : `Failed ${item.label}: ${errorText(error)}`), startOf("Removing", progress));
|
|
99
107
|
return resultOf(outcomes, plan.skipped);
|
|
100
108
|
}
|
|
101
109
|
/** Managed user-global leaves that can gain hosts, and the leaves that cannot with their reasons. */
|
|
@@ -146,7 +154,7 @@ export async function prepareBulkHostAddition(context, targets, alreadySkipped,
|
|
|
146
154
|
return { items, skipped, installations };
|
|
147
155
|
}
|
|
148
156
|
/** Execute step: the single exposure confirmation covers every item; each one is rechecked on its own. */
|
|
149
|
-
export async function runBulkHostAddition(plan, operations, env) {
|
|
157
|
+
export async function runBulkHostAddition(plan, operations, env, progress) {
|
|
150
158
|
let expected = plan.installations;
|
|
151
159
|
const outcomes = await runBatch(plan.items, async (item) => {
|
|
152
160
|
await assertNoDrift(operations, expected);
|
|
@@ -155,7 +163,7 @@ export async function runBulkHostAddition(plan, operations, env) {
|
|
|
155
163
|
const { selection, updated } = item.prepared.plan;
|
|
156
164
|
expected = expected.map((record) => (sameRecord(record, selection) ? updated : record));
|
|
157
165
|
return line;
|
|
158
|
-
}, (item, error) => `Failed ${item.label}: ${errorText(error)}
|
|
166
|
+
}, (item, error) => `Failed ${item.label}: ${errorText(error)}`, startOf("Changing hosts of", progress));
|
|
159
167
|
return resultOf(outcomes, plan.skipped);
|
|
160
168
|
}
|
|
161
169
|
function cancel(context, text) {
|
|
@@ -202,10 +210,15 @@ function prepareHosts(context, mode) {
|
|
|
202
210
|
return preparePlan(context, `Preparing host change of ${count(mode.targets.length, "item")}…`, () => prepareBulkHostAddition(context, mode.targets, mode.skipped, mode.selected), (plan) => ({ kind: "confirm-bulk-hosts", plan }));
|
|
203
211
|
}
|
|
204
212
|
async function apply(context, label, action) {
|
|
205
|
-
|
|
213
|
+
const started = busy(label);
|
|
214
|
+
context.setMode(started);
|
|
206
215
|
let result;
|
|
207
216
|
try {
|
|
208
|
-
|
|
217
|
+
// Each item names itself, but the clock of the whole batch keeps running.
|
|
218
|
+
result = await action((itemLabel, done) => {
|
|
219
|
+
if (context.isMounted())
|
|
220
|
+
context.setMode({ ...busy(itemLabel, started), done });
|
|
221
|
+
});
|
|
209
222
|
}
|
|
210
223
|
catch (error) {
|
|
211
224
|
result = { message: { kind: "error", text: errorText(error) }, applied: [] };
|
|
@@ -229,7 +242,7 @@ export function handleBulkKey(context, mode, input, key) {
|
|
|
229
242
|
}
|
|
230
243
|
case "confirm-bulk-uninstall":
|
|
231
244
|
answerYesNo(input, key, () => {
|
|
232
|
-
void apply(context, `Removing ${count(current.plan.items.length, "item")}…`, () => runBulkUninstall(current.plan, operations, env));
|
|
245
|
+
void apply(context, `Removing ${count(current.plan.items.length, "item")}…`, (progress) => runBulkUninstall(current.plan, operations, env, progress));
|
|
233
246
|
}, () => cancel(context, "Uninstall cancelled"));
|
|
234
247
|
return true;
|
|
235
248
|
case "confirm-bulk-hosts":
|
|
@@ -237,7 +250,7 @@ export function handleBulkKey(context, mode, input, key) {
|
|
|
237
250
|
return true;
|
|
238
251
|
case "confirm-bulk-exposure":
|
|
239
252
|
answerYesNo(input, key, () => {
|
|
240
|
-
void apply(context, `Changing hosts of ${count(current.plan.items.length, "item")}…`, () => runBulkHostAddition(current.plan, operations, env));
|
|
253
|
+
void apply(context, `Changing hosts of ${count(current.plan.items.length, "item")}…`, (progress) => runBulkHostAddition(current.plan, operations, env, progress));
|
|
241
254
|
}, () => cancel(context, "Add hosts cancelled"));
|
|
242
255
|
return true;
|
|
243
256
|
default:
|
|
@@ -83,17 +83,18 @@ export async function prepareInstall(operations, environment, skill, choice) {
|
|
|
83
83
|
root: path.resolve(environment.projectRoot ?? process.cwd()),
|
|
84
84
|
existing: (await manifestStore.load()).skills,
|
|
85
85
|
recordedIn: manifestStore.path,
|
|
86
|
+
manifestPath: manifestStore.path,
|
|
86
87
|
});
|
|
87
88
|
}
|
|
88
89
|
const lines = [];
|
|
89
90
|
outputSingleInstallPreview(plan, (line) => lines.push(line));
|
|
90
91
|
// The TUI confirms interactively, so the CLI's --yes wording does not apply.
|
|
91
|
-
const preview = lines.filter((line) => !line.startsWith(" confirmation:"));
|
|
92
|
+
const preview = lines.filter((line) => !plan.projectTarget.includes(line) && !line.startsWith(" confirmation:"));
|
|
92
93
|
const additionalHostExposure = needsAdditionalHostExposure(plan.inspection, choice.hosts);
|
|
93
94
|
if (additionalHostExposure) {
|
|
94
95
|
preview.push(" ADDITIONAL HOST EXPOSURE: an identical Skill already exists; installing adds the missing Host location (equivalent to --confirm-additional-host) and needs a separate confirmation");
|
|
95
96
|
}
|
|
96
|
-
return { plan, manifestStore, preview, runsExternalCommand: plan.resolved.method !== undefined, additionalHostExposure };
|
|
97
|
+
return { plan, manifestStore, projectTarget: plan.projectTarget, preview, runsExternalCommand: plan.resolved.method !== undefined, additionalHostExposure };
|
|
97
98
|
}
|
|
98
99
|
/** Execute step: installs and persists the record; returns the result lines. */
|
|
99
100
|
export async function runInstall(prepared, operations, confirmAdditionalHostExposure = false) {
|
|
@@ -19,7 +19,7 @@ export function CatalogRowLine({ row, selected, marked, installed }) {
|
|
|
19
19
|
const isGroup = data.kind === "source" || data.kind === "group";
|
|
20
20
|
const ownedBy = data.kind === "skill" ? data.ownedBy : undefined;
|
|
21
21
|
return (_jsxs(Text, { ...rowStyle(selected), color: isGroup ? theme.group : undefined, wrap: "truncate-end", children: [selected ? "> " : " ", " ".repeat(row.depth), data.kind === "skill" ? marked ? "[x] " : "[ ] " : "", data.kind === "skill" && ownedBy !== undefined
|
|
22
|
-
? _jsxs(_Fragment, { children: [data.skill.name, _jsxs(Text, { color: theme.marker, children: [" ", "[", packageOwnerBadge(ownedBy), "]"] }), " ", truncateDescription(data.skill.description)] })
|
|
22
|
+
? _jsxs(_Fragment, { children: [data.skill.name, _jsxs(Text, { color: theme.marker, children: [" ", "[", packageOwnerBadge(ownedBy), "]"] }), " ", truncateDescription(data.skill.description), data.variant === true ? ` (${data.skill.path})` : ""] })
|
|
23
23
|
: data.kind === "app" ? `App ${data.row.entry.recipe?.name ?? data.row.entry.file}` : describeRow(row), data.kind === "app" ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.legend, children: " " }), _jsx(Text, { color: data.row.inspection.status === "not installed" ? theme.warning : theme.value, children: data.row.inspection.status })] })) : null, data.kind === "skill" && hasUnmanagedCopy(data.skill, installed)
|
|
24
24
|
? _jsxs(Text, { color: theme.marker, children: [" ", UNMANAGED_MARKER] })
|
|
25
25
|
: null] }));
|
|
@@ -47,7 +47,8 @@ export function ActionPanel({ mode }) {
|
|
|
47
47
|
case "confirm-install": {
|
|
48
48
|
const ready = mode.items.filter((item) => item.prepared !== undefined);
|
|
49
49
|
const single = mode.items.length === 1;
|
|
50
|
-
|
|
50
|
+
const projectTarget = ready[0]?.prepared?.projectTarget ?? [];
|
|
51
|
+
return (_jsxs(BorderPanel, { title: `Skill install preview · ${ready.length} ready`, children: [projectTarget.length === 0 ? null : _jsx(PreviewLines, { lines: projectTarget }), mode.items.map(({ skill, prepared, error }) => prepared === undefined ? (_jsxs(Text, { color: theme.error, children: ["Cannot install ", skill.name, ": ", error] }, skillKey(skill))) : (_jsxs(Box, { flexDirection: "column", children: [single ? null : _jsx(Text, { color: theme.group, children: skill.name }), _jsx(PreviewLines, { lines: prepared.preview })] }, skillKey(skill)))), ready.some((item) => item.prepared?.runsExternalCommand) ? _jsx(Text, { color: theme.warning, children: "This install runs an external command (see above)." }) : null, _jsxs(DecisionPrompt, { children: ["Install ", describeSkills(ready.map((item) => item.skill)), "?"] })] }));
|
|
51
52
|
}
|
|
52
53
|
case "confirm-exposure": {
|
|
53
54
|
const names = mode.items.filter((item) => item.prepared?.additionalHostExposure).map((item) => item.skill.name);
|
|
@@ -6,15 +6,22 @@ import { loadInstalledSkills, NO_INSTALLED } from "./catalog-installs.js";
|
|
|
6
6
|
import { sourceLabel } from "./catalog-tree.js";
|
|
7
7
|
import { packageOperationsOf } from "./environment.js";
|
|
8
8
|
/** Discovers the Skills of one Source, or of all of them, and again whenever those inputs or `reloadKey` change. */
|
|
9
|
-
export function useCatalogSkills(operations, sourceId, all, env, reloadKey = 0) {
|
|
10
|
-
const
|
|
9
|
+
export function useCatalogSkills(operations, sourceId, all, env, reloadKey = 0, retention) {
|
|
10
|
+
const key = `${sourceId ?? ""}|${all}`;
|
|
11
|
+
/** The last result of this scope, shown while it is discovered again. */
|
|
12
|
+
const retained = () => {
|
|
13
|
+
const kept = retention?.catalogKey === key ? retention.catalogSkills : undefined;
|
|
14
|
+
return kept ? { ...kept, stale: true } : { status: "loading" };
|
|
15
|
+
};
|
|
16
|
+
const [state, setState] = useState(retained);
|
|
11
17
|
useEffect(() => {
|
|
12
18
|
let cancelled = false;
|
|
19
|
+
const generation = retention?.generation;
|
|
13
20
|
if (!operations.discoverSkills) {
|
|
14
21
|
setState({ status: "error", message: "Discovery is not supported by the configured operations" });
|
|
15
22
|
return;
|
|
16
23
|
}
|
|
17
|
-
setState(
|
|
24
|
+
setState(retained());
|
|
18
25
|
void (async () => {
|
|
19
26
|
try {
|
|
20
27
|
const sources = await operations.listSources();
|
|
@@ -50,6 +57,9 @@ export function useCatalogSkills(operations, sourceId, all, env, reloadKey = 0)
|
|
|
50
57
|
try {
|
|
51
58
|
const projectSource = projectSourceOf(source);
|
|
52
59
|
for (const descriptor of await packages.discover([id])) {
|
|
60
|
+
// A Package Agent Depot cannot install is not a choice; its warning stays in `package inspect`.
|
|
61
|
+
if (!descriptor.installable)
|
|
62
|
+
continue;
|
|
53
63
|
bundles.push({ sourceId: id, selection: packageSelectionFor(descriptor, projectSource), descriptor });
|
|
54
64
|
discovered.push({ source: projectSource, descriptor });
|
|
55
65
|
}
|
|
@@ -73,8 +83,14 @@ export function useCatalogSkills(operations, sourceId, all, env, reloadKey = 0)
|
|
|
73
83
|
const detail = error instanceof Error ? error.message : String(error);
|
|
74
84
|
warnings.push(`Package ownership unavailable: ${detail}`);
|
|
75
85
|
}
|
|
86
|
+
const ready = { status: "ready", skills, bundles, activeBundles, warnings, sources };
|
|
87
|
+
// A result that began before an invalidation may predate the change, so it is not kept.
|
|
88
|
+
if (!cancelled && retention && retention.generation === generation) {
|
|
89
|
+
retention.catalogKey = key;
|
|
90
|
+
retention.catalogSkills = ready;
|
|
91
|
+
}
|
|
76
92
|
if (!cancelled)
|
|
77
|
-
setState(
|
|
93
|
+
setState(ready);
|
|
78
94
|
}
|
|
79
95
|
catch (error) {
|
|
80
96
|
if (!cancelled)
|
|
@@ -87,49 +103,59 @@ export function useCatalogSkills(operations, sourceId, all, env, reloadKey = 0)
|
|
|
87
103
|
}, [operations, sourceId, all, env, reloadKey]);
|
|
88
104
|
return state;
|
|
89
105
|
}
|
|
90
|
-
export function useCatalogApps(apps) {
|
|
91
|
-
const [state, setState] = useState({ status: "loading" });
|
|
106
|
+
export function useCatalogApps(apps, retention) {
|
|
107
|
+
const [state, setState] = useState(() => retention?.catalogApps ? { ...retention.catalogApps, stale: true } : { status: "loading" });
|
|
92
108
|
const generation = useRef(0);
|
|
93
|
-
|
|
109
|
+
/** `keepShown` reloads behind the rows already on screen instead of replacing them with a loading state. */
|
|
110
|
+
const load = useCallback(async (keepShown) => {
|
|
94
111
|
const current = ++generation.current;
|
|
95
|
-
|
|
112
|
+
const retained = retention?.generation;
|
|
113
|
+
if (!keepShown)
|
|
114
|
+
setState({ status: "loading" });
|
|
96
115
|
try {
|
|
97
116
|
const rows = await Promise.all((await apps.load()).filter(entry => entry.applicable).map(async (entry) => ({
|
|
98
117
|
entry,
|
|
99
118
|
inspection: await apps.inspect(entry),
|
|
100
119
|
})));
|
|
101
|
-
if (generation.current === current)
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
120
|
+
if (generation.current === current) {
|
|
121
|
+
const ready = { status: "ready", rows: rows.filter(row => row.inspection.status === "not installed") };
|
|
122
|
+
if (retention && retention.generation === retained)
|
|
123
|
+
retention.catalogApps = ready;
|
|
124
|
+
setState(ready);
|
|
125
|
+
}
|
|
106
126
|
}
|
|
107
127
|
catch (error) {
|
|
108
128
|
if (generation.current === current)
|
|
109
129
|
setState({ status: "ready", rows: [], error: error instanceof Error ? error.message : String(error) });
|
|
110
130
|
}
|
|
111
|
-
}, [apps]);
|
|
131
|
+
}, [apps, retention]);
|
|
132
|
+
const reload = useCallback(() => load(false), [load]);
|
|
112
133
|
useEffect(() => {
|
|
113
|
-
void
|
|
134
|
+
void load(retention?.catalogApps !== undefined);
|
|
114
135
|
return () => { generation.current += 1; };
|
|
115
|
-
}, [
|
|
136
|
+
}, [load, retention]);
|
|
116
137
|
return { state, reload };
|
|
117
138
|
}
|
|
118
139
|
/** The Skills already installed, loaded on mount and on demand. */
|
|
119
|
-
export function useInstalledSkills(operations, env, mounted) {
|
|
120
|
-
const [installed, setInstalled] = useState(NO_INSTALLED);
|
|
121
|
-
const [installedReady, setInstalledReady] = useState(
|
|
140
|
+
export function useInstalledSkills(operations, env, mounted, retention) {
|
|
141
|
+
const [installed, setInstalled] = useState(retention?.installed ?? NO_INSTALLED);
|
|
142
|
+
const [installedReady, setInstalledReady] = useState(retention?.installed !== undefined);
|
|
143
|
+
const [installedStale, setInstalledStale] = useState(retention?.installed !== undefined);
|
|
122
144
|
const reloadInstalled = useCallback(async () => {
|
|
145
|
+
const generation = retention?.generation;
|
|
123
146
|
const loaded = await loadInstalledSkills(operations, env);
|
|
147
|
+
if (retention && retention.generation === generation)
|
|
148
|
+
retention.installed = loaded;
|
|
124
149
|
if (!mounted.current)
|
|
125
150
|
return;
|
|
126
151
|
setInstalled(loaded);
|
|
127
152
|
setInstalledReady(true);
|
|
128
|
-
|
|
153
|
+
setInstalledStale(false);
|
|
154
|
+
}, [operations, env, mounted, retention]);
|
|
129
155
|
useEffect(() => {
|
|
130
156
|
void reloadInstalled();
|
|
131
157
|
}, [reloadInstalled]);
|
|
132
|
-
return { installed, installedReady, reloadInstalled };
|
|
158
|
+
return { installed, installedReady, installedStale, reloadInstalled };
|
|
133
159
|
}
|
|
134
160
|
/**
|
|
135
161
|
* The filter box. Its state is mirrored in refs, updated first, so the shell
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { githubTreeLocation } from "../git-source.js";
|
|
2
|
-
import {
|
|
2
|
+
import { selectionKey } from "../package-model.js";
|
|
3
3
|
import { packageOwnedSkillMatcher } from "../package-owned-skills.js";
|
|
4
4
|
import { packageVersionLabel } from "./package-rows.js";
|
|
5
5
|
import { defaultExpanded } from "./tree.js";
|
|
6
6
|
const DESCRIPTION_LIMIT = 60;
|
|
7
7
|
export const UNMANAGED_MARKER = "on disk, unmanaged · adopt it from Installations";
|
|
8
8
|
export const skillKey = (skill) => `skill:${skill.sourceId}:${skill.path}`;
|
|
9
|
-
|
|
9
|
+
/** Keyed on the full selection, so two plugins of one marketplace root stay distinct nodes. */
|
|
10
|
+
export const bundleKey = (entry) => `bundle:${entry.sourceId}:${selectionKey(entry.selection)}`;
|
|
10
11
|
export function truncateDescription(text) {
|
|
11
12
|
return text.length > DESCRIPTION_LIMIT ? `${text.slice(0, DESCRIPTION_LIMIT - 3)}...` : text;
|
|
12
13
|
}
|
|
@@ -30,11 +31,20 @@ export function buildTree(skills, sources = [], bundles = [], activeDescriptors
|
|
|
30
31
|
for (const skill of skills)
|
|
31
32
|
bySource.set(skill.sourceId, [...(bySource.get(skill.sourceId) ?? []), skill]);
|
|
32
33
|
const bundlesBySource = new Map();
|
|
33
|
-
|
|
34
|
+
const seenBundles = new Set();
|
|
35
|
+
for (const bundle of bundles) {
|
|
36
|
+
// Descriptors with identical coordinates are one Package; a duplicate node id would corrupt the rows.
|
|
37
|
+
if (seenBundles.has(bundleKey(bundle)))
|
|
38
|
+
continue;
|
|
39
|
+
seenBundles.add(bundleKey(bundle));
|
|
34
40
|
bundlesBySource.set(bundle.sourceId, [...(bundlesBySource.get(bundle.sourceId) ?? []), bundle]);
|
|
41
|
+
}
|
|
35
42
|
const sourcesTree = [...new Set([...bySource.keys(), ...bundlesBySource.keys()])].map((sourceId) => {
|
|
36
43
|
const members = bySource.get(sourceId) ?? [];
|
|
37
44
|
const sourceBundles = bundlesBySource.get(sourceId) ?? [];
|
|
45
|
+
const variants = new Map();
|
|
46
|
+
for (const skill of members)
|
|
47
|
+
variants.set(skill.name, [...(variants.get(skill.name) ?? []), skill]);
|
|
38
48
|
const owned = packageOwnedSkillMatcher(sourceBundles.map((entry) => entry.descriptor).filter((descriptor) => active.has(descriptor)));
|
|
39
49
|
return {
|
|
40
50
|
id: `source:${sourceId}`,
|
|
@@ -45,10 +55,21 @@ export function buildTree(skills, sources = [], bundles = [], activeDescriptors
|
|
|
45
55
|
data: { kind: "group", label: `Packages (${sourceBundles.length})` },
|
|
46
56
|
children: sourceBundles.map((entry) => ({ id: bundleKey(entry), data: { kind: "bundle", entry } })),
|
|
47
57
|
}]),
|
|
48
|
-
...members.
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
58
|
+
...(members.length === 0 ? [] : [{
|
|
59
|
+
id: `skills:${sourceId}`,
|
|
60
|
+
data: { kind: "group", label: `Skills (${members.length})` },
|
|
61
|
+
children: [...variants.values()].map((group) => {
|
|
62
|
+
const variant = group.length > 1;
|
|
63
|
+
const nodes = group.map((skill) => {
|
|
64
|
+
const ownedBy = owned(skill.path);
|
|
65
|
+
return { id: skillKey(skill), data: { kind: "skill", skill, ...(ownedBy === undefined ? {} : { ownedBy }), ...(variant ? { variant } : {}) } };
|
|
66
|
+
});
|
|
67
|
+
// Same-named Skills of one Source differ in content, so each stays its own installable row under one group.
|
|
68
|
+
return variant
|
|
69
|
+
? { id: `variants:${sourceId}:${group[0].name}`, data: { kind: "group", label: `${group[0].name} (${group.length} variants)` }, children: nodes }
|
|
70
|
+
: nodes[0];
|
|
71
|
+
}),
|
|
72
|
+
}]),
|
|
52
73
|
],
|
|
53
74
|
};
|
|
54
75
|
});
|
|
@@ -86,13 +107,29 @@ export function describeRow(row) {
|
|
|
86
107
|
}
|
|
87
108
|
if (data.kind === "app")
|
|
88
109
|
return `App ${data.row.entry.recipe?.name ?? data.row.entry.file} ${data.row.inspection.status}`;
|
|
89
|
-
return `${data.skill.name} ${truncateDescription(data.skill.description)}`;
|
|
110
|
+
return `${data.skill.name} ${truncateDescription(data.skill.description)}${data.variant === true ? ` (${data.skill.path})` : ""}`;
|
|
111
|
+
}
|
|
112
|
+
/** Skill nodes inside a Source's Skills group, directly or within one of its variant groups. */
|
|
113
|
+
function skillNodesOf(roots) {
|
|
114
|
+
const found = [];
|
|
115
|
+
const visit = (node) => {
|
|
116
|
+
if (node.data.kind === "skill")
|
|
117
|
+
found.push(node);
|
|
118
|
+
else if (node.id.startsWith("variants:"))
|
|
119
|
+
node.children?.forEach(visit);
|
|
120
|
+
};
|
|
121
|
+
for (const root of roots) {
|
|
122
|
+
for (const group of root.children ?? [])
|
|
123
|
+
if (group.id.startsWith("skills:"))
|
|
124
|
+
group.children?.forEach(visit);
|
|
125
|
+
}
|
|
126
|
+
return found;
|
|
90
127
|
}
|
|
91
128
|
/** The Skills under the tree's Sources, in display order. */
|
|
92
129
|
export function leavesOf(roots) {
|
|
93
|
-
return roots
|
|
130
|
+
return skillNodesOf(roots).map((node) => node.data.skill);
|
|
94
131
|
}
|
|
95
132
|
/** Skills that can still be installed as loose Skills, excluding active Package-owned rows. */
|
|
96
133
|
export function looseLeavesOf(roots) {
|
|
97
|
-
return roots.
|
|
134
|
+
return skillNodesOf(roots).filter((node) => node.data.ownedBy === undefined).map((node) => node.data.skill);
|
|
98
135
|
}
|
|
@@ -17,6 +17,7 @@ import { useCatalogApps, useCatalogFilter, useCatalogSkills, useInstalledSkills
|
|
|
17
17
|
import { buildTree, leavesOf, openByDefault, skillKey } from "./catalog-tree.js";
|
|
18
18
|
import { theme } from "./theme.js";
|
|
19
19
|
import { useTreeNavigation } from "./tree-navigation.js";
|
|
20
|
+
import { invalidateRetention } from "./view-retention.js";
|
|
20
21
|
import { useLoadingSince, useMarks, useMounted } from "./view-state.js";
|
|
21
22
|
import { computeWindow, useListHeight } from "./window.js";
|
|
22
23
|
import { useKeys } from "./keys.js";
|
|
@@ -39,7 +40,7 @@ function wrappedLineCount(text, columns) {
|
|
|
39
40
|
}
|
|
40
41
|
return lines;
|
|
41
42
|
}
|
|
42
|
-
export function CatalogView({ operations, sourceId, onCapturingChange, environment, listHeight }) {
|
|
43
|
+
export function CatalogView({ operations, sourceId, onCapturingChange, environment, listHeight, retention }) {
|
|
43
44
|
const { columns, rows: terminalRows } = useWindowSize();
|
|
44
45
|
const env = environment ?? NO_ENVIRONMENT;
|
|
45
46
|
const [all, setAll] = useState(sourceId === undefined);
|
|
@@ -52,12 +53,12 @@ export function CatalogView({ operations, sourceId, onCapturingChange, environme
|
|
|
52
53
|
// Mirrors the action mode synchronously, like the filter, so later keystrokes and the shell never see stale state.
|
|
53
54
|
const actionRef = useRef(BROWSE);
|
|
54
55
|
const filter = useCatalogFilter(actionRef, onCapturingChange);
|
|
55
|
-
const state = useCatalogSkills(operations, sourceId, all, env, reloadKey);
|
|
56
|
+
const state = useCatalogSkills(operations, sourceId, all, env, reloadKey, retention);
|
|
56
57
|
const packages = useMemo(() => packageOperationsOf(env), [env]);
|
|
57
58
|
const apps = useMemo(() => createAppOperations({ homeDirectory: env.homeDirectory, ...env.appEnvironment }), [env]);
|
|
58
|
-
const appCatalogState = useCatalogApps(apps);
|
|
59
|
+
const appCatalogState = useCatalogApps(apps, retention);
|
|
59
60
|
const appCatalog = appCatalogState.state;
|
|
60
|
-
const { installed, installedReady, reloadInstalled } = useInstalledSkills(operations, env, mounted);
|
|
61
|
+
const { installed, installedReady, installedStale, reloadInstalled } = useInstalledSkills(operations, env, mounted, retention);
|
|
61
62
|
const [appMode, setAppMode] = useState();
|
|
62
63
|
const appModeRef = useRef(undefined);
|
|
63
64
|
const setAppAction = (mode) => {
|
|
@@ -104,6 +105,8 @@ export function CatalogView({ operations, sourceId, onCapturingChange, environme
|
|
|
104
105
|
}
|
|
105
106
|
}, [loading]);
|
|
106
107
|
const loadingSince = useLoadingSince(loading, runningSince ?? resumedSince);
|
|
108
|
+
const refreshing = !loading && ((state.status === "ready" && state.stale === true) || (appCatalog.status === "ready" && appCatalog.stale === true) || installedStale);
|
|
109
|
+
const refreshingSince = useLoadingSince(refreshing);
|
|
107
110
|
const flow = {
|
|
108
111
|
operations,
|
|
109
112
|
env,
|
|
@@ -112,6 +115,7 @@ export function CatalogView({ operations, sourceId, onCapturingChange, environme
|
|
|
112
115
|
isMounted: () => mounted.current,
|
|
113
116
|
keepMarked: marks.setMarked,
|
|
114
117
|
finish: async (result) => {
|
|
118
|
+
invalidateRetention(retention);
|
|
115
119
|
await reloadInstalled();
|
|
116
120
|
if (!mounted.current)
|
|
117
121
|
return;
|
|
@@ -126,6 +130,7 @@ export function CatalogView({ operations, sourceId, onCapturingChange, environme
|
|
|
126
130
|
isMounted: () => mounted.current,
|
|
127
131
|
unmark: ids => marks.setMarked(new Set([...marks.markedRef.current].filter(id => !ids.includes(id)))),
|
|
128
132
|
finish: async (result) => {
|
|
133
|
+
invalidateRetention(retention);
|
|
129
134
|
await reloadInstalled();
|
|
130
135
|
if (!mounted.current)
|
|
131
136
|
return;
|
|
@@ -142,6 +147,7 @@ export function CatalogView({ operations, sourceId, onCapturingChange, environme
|
|
|
142
147
|
setMessage,
|
|
143
148
|
isMounted: () => mounted.current,
|
|
144
149
|
finish: async (result) => {
|
|
150
|
+
invalidateRetention(retention);
|
|
145
151
|
await reloadInstalled();
|
|
146
152
|
if (!mounted.current)
|
|
147
153
|
return;
|
|
@@ -194,5 +200,5 @@ export function CatalogView({ operations, sourceId, onCapturingChange, environme
|
|
|
194
200
|
// A paged declaration owns the view: rows of the tree above it would not be counted in its height.
|
|
195
201
|
if (isPackageMode(action))
|
|
196
202
|
return _jsx(PackagePanel, { mode: action });
|
|
197
|
-
return (_jsxs(Box, { flexDirection: "column", children: [state.warnings.map((warning) => _jsx(Text, { color: theme.warning, children: warning }, warning)), appCatalog.status === "ready" && appCatalog.error ? _jsxs(Text, { color: theme.error, children: ["Apps unavailable: ", appCatalog.error] }) : null, _jsx(CatalogHeader, { scope: scope, listed: visible.length, installable: installable.length, total: skills.length, appCount: visibleApps.length, filter: filter.filter }), rows.slice(window.start, window.end).map((row, offset) => (_jsx(CatalogRowLine, { row: row, selected: window.start + offset === index, marked: marks.marked.has(row.node.id), installed: installed }, row.node.id))), _jsx(CatalogSelectionHelp, { row: rows[index], height: helpLines }), _jsx(ListFooter, { indicator: window.indicator, selectedCount: selectedCount, message: message }), appMode ? _jsx(AppPanel, { mode: appMode }) : _jsx(ActionPanel, { mode: action })] }));
|
|
203
|
+
return (_jsxs(Box, { flexDirection: "column", children: [refreshing ? _jsx(Working, { label: "Refreshing catalog (last data shown)\u2026", since: refreshingSince }) : null, state.warnings.map((warning) => _jsx(Text, { color: theme.warning, children: warning }, warning)), appCatalog.status === "ready" && appCatalog.error ? _jsxs(Text, { color: theme.error, children: ["Apps unavailable: ", appCatalog.error] }) : null, _jsx(CatalogHeader, { scope: scope, listed: visible.length, installable: installable.length, total: skills.length, appCount: visibleApps.length, filter: filter.filter }), rows.slice(window.start, window.end).map((row, offset) => (_jsx(CatalogRowLine, { row: row, selected: window.start + offset === index, marked: marks.marked.has(row.node.id), installed: installed }, row.node.id))), _jsx(CatalogSelectionHelp, { row: rows[index], height: helpLines }), _jsx(ListFooter, { indicator: window.indicator, selectedCount: selectedCount, message: message }), appMode ? _jsx(AppPanel, { mode: appMode }) : _jsx(ActionPanel, { mode: action })] }));
|
|
198
204
|
}
|
|
@@ -22,15 +22,18 @@ import { busy } from "./working-model.js";
|
|
|
22
22
|
import { useLoadingSince, useMarks, useMounted } from "./view-state.js";
|
|
23
23
|
import { computeWindow, useListHeight } from "./window.js";
|
|
24
24
|
import { useKeys } from "./keys.js";
|
|
25
|
+
import { invalidateRetention } from "./view-retention.js";
|
|
25
26
|
const NO_ENVIRONMENT = {};
|
|
26
27
|
const LEGEND = "adopted: tracked in place · modified: changed on disk";
|
|
27
|
-
export function InstallationsView({ operations, environment, onCapturingChange, listHeight }) {
|
|
28
|
+
export function InstallationsView({ operations, environment, onCapturingChange, listHeight, retention }) {
|
|
28
29
|
const { rows: terminalRows, columns: terminalColumns } = useWindowSize();
|
|
29
30
|
const height = useListHeight(listHeight, 7);
|
|
30
31
|
const env = environment ?? NO_ENVIRONMENT;
|
|
31
32
|
const apps = useMemo(() => createAppOperations({ homeDirectory: env.homeDirectory, ...env.appEnvironment }), [env]);
|
|
32
33
|
const packages = useMemo(() => packageOperationsOf(env), [env]);
|
|
33
|
-
const [state, setState] = useState({ status: "loading" });
|
|
34
|
+
const [state, setState] = useState(() => retention?.installations ? { status: "ready", data: retention.installations } : { status: "loading" });
|
|
35
|
+
// The data shown comes from an earlier visit until the first reload of this one ends.
|
|
36
|
+
const [stale, setStale] = useState(retention?.installations !== undefined);
|
|
34
37
|
const [mode, setModeState] = useState(BROWSE);
|
|
35
38
|
// Mirrors the mode synchronously so later keystrokes and the shell never see stale state.
|
|
36
39
|
const modeRef = useRef(BROWSE);
|
|
@@ -50,22 +53,30 @@ export function InstallationsView({ operations, environment, onCapturingChange,
|
|
|
50
53
|
const loadGeneration = useRef(0);
|
|
51
54
|
const reload = useCallback(async () => {
|
|
52
55
|
const generation = ++loadGeneration.current;
|
|
56
|
+
const retained = retention?.generation;
|
|
53
57
|
if (reloads.current++ === 0 && mounted.current)
|
|
54
58
|
setRefreshingSince(Date.now());
|
|
55
59
|
try {
|
|
56
60
|
const data = await loadInstallations(operations, env, apps, packages);
|
|
57
|
-
|
|
61
|
+
// A result that began before an invalidation may predate the change, so it is not kept.
|
|
62
|
+
if (retention && retention.generation === retained)
|
|
63
|
+
retention.installations = data;
|
|
64
|
+
if (mounted.current && generation === loadGeneration.current) {
|
|
58
65
|
setState({ status: "ready", data });
|
|
66
|
+
setStale(false);
|
|
67
|
+
}
|
|
59
68
|
}
|
|
60
69
|
catch (error) {
|
|
61
|
-
if (mounted.current && generation === loadGeneration.current)
|
|
70
|
+
if (mounted.current && generation === loadGeneration.current) {
|
|
62
71
|
setState({ status: "error", message: errorText(error) });
|
|
72
|
+
setStale(false);
|
|
73
|
+
}
|
|
63
74
|
}
|
|
64
75
|
finally {
|
|
65
76
|
if (--reloads.current === 0 && mounted.current)
|
|
66
77
|
setRefreshingSince(undefined);
|
|
67
78
|
}
|
|
68
|
-
}, [operations, env, apps, packages, mounted]);
|
|
79
|
+
}, [operations, env, apps, packages, mounted, retention]);
|
|
69
80
|
useEffect(() => {
|
|
70
81
|
void reload();
|
|
71
82
|
}, [reload]);
|
|
@@ -129,6 +140,8 @@ export function InstallationsView({ operations, environment, onCapturingChange,
|
|
|
129
140
|
const current = modeRef.current;
|
|
130
141
|
if ("startedAt" in current && mounted.current)
|
|
131
142
|
setMode(busy("Refreshing installations…", current));
|
|
143
|
+
// The action changed what is installed, so what was kept no longer describes it.
|
|
144
|
+
invalidateRetention(retention);
|
|
132
145
|
await reload();
|
|
133
146
|
};
|
|
134
147
|
const manage = {
|
|
@@ -197,6 +210,6 @@ export function InstallationsView({ operations, environment, onCapturingChange,
|
|
|
197
210
|
const markedApps = markedLeaves.filter(node => node.data.kind === "app").length;
|
|
198
211
|
// The legend explains the markers, so it shows only while a rendered row carries one.
|
|
199
212
|
const legendNeeded = listed.some((row) => row.node.data.kind === "installation" && markersOf(row.node.data.row) !== "");
|
|
200
|
-
return (_jsxs(Box, { flexDirection: "column", children: [refreshingSince !== undefined && mode.kind === "browse" ? _jsx(Working, { label: "Refreshing installations
|
|
213
|
+
return (_jsxs(Box, { flexDirection: "column", children: [refreshingSince !== undefined && mode.kind === "browse" ? _jsx(Working, { label: stale ? "Refreshing installations (last data shown)…" : "Refreshing installations…", since: refreshingSince }) : null, ready.global === undefined ? _jsx(Text, { children: "User-global installations are not supported by the configured operations" }) : null, ready.projectError === undefined ? null : _jsxs(Text, { color: theme.error, children: ["Error: ", ready.projectError] }), listed.map((row, offset) => (_jsx(TreeRowLine, { row: row, selected: window.start + offset === index, marked: marks.marked.has(row.node.id) }, row.node.id))), _jsx(ListFooter, { indicator: window.indicator, selectedCount: selectedCount, message: message }), markedApps > 0 ? _jsxs(Text, { color: theme.muted, children: [markedApps, " Apps marked"] }) : null, legendNeeded ? _jsx(Text, { color: theme.muted, children: LEGEND }) : null, checkingSince !== undefined && refreshingSince === undefined && mode.kind === "browse"
|
|
201
214
|
? _jsx(Working, { label: "Checking App status\u2026", since: checkingSince }) : null, _jsx(ModePanel, { mode: mode })] }));
|
|
202
215
|
}
|
|
@@ -66,6 +66,6 @@ export function ManagePanel({ mode }) {
|
|
|
66
66
|
case "confirm-bulk-exposure":
|
|
67
67
|
return (_jsxs(Text, { color: theme.warning, children: ["Adding hosts exposes the installed ", mode.plan.items.map((item) => item.label).join(", "), " to more hosts and needs separate confirmation (CLI: --confirm-additional-host). ", _jsx(DecisionPrompt, { children: "Confirm?" })] }));
|
|
68
68
|
case "busy":
|
|
69
|
-
return _jsx(Working, { label: mode.label, since: mode.startedAt });
|
|
69
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Working, { label: mode.label, since: mode.startedAt }), (mode.done ?? []).length > 0 ? _jsxs(Text, { color: theme.muted, children: [mode.done.length, " finished"] }) : null] }));
|
|
70
70
|
}
|
|
71
71
|
}
|