@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.
@@ -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
- context.setMode(busy(label));
213
+ const started = busy(label);
214
+ context.setMode(started);
206
215
  let result;
207
216
  try {
208
- result = await action();
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
- return (_jsxs(BorderPanel, { title: `Skill install preview · ${ready.length} ready`, children: [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)), "?"] })] }));
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 [state, setState] = useState({ status: "loading" });
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({ status: "loading" });
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({ status: "ready", skills, bundles, activeBundles, warnings, sources });
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
- const reload = useCallback(async () => {
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
- setState({ status: "loading" });
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
- setState({
103
- status: "ready",
104
- rows: rows.filter(row => row.inspection.status === "not installed"),
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 reload();
134
+ void load(retention?.catalogApps !== undefined);
114
135
  return () => { generation.current += 1; };
115
- }, [reload]);
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(false);
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
- }, [operations, env, mounted]);
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 { bundleRoot } from "../package-model.js";
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
- export const bundleKey = (entry) => `bundle:${entry.sourceId}:${entry.descriptor.host}:${bundleRoot(entry.descriptor.coordinates)}`;
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
- for (const bundle of bundles)
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.map((skill) => {
49
- const ownedBy = owned(skill.path);
50
- return { id: skillKey(skill), data: { kind: "skill", skill, ...(ownedBy === undefined ? {} : { ownedBy }) } };
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.flatMap((root) => (root.children ?? []).flatMap((child) => child.data.kind === "skill" ? [child.data.skill] : []));
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.flatMap((root) => (root.children ?? []).flatMap((child) => child.data.kind === "skill" && child.data.ownedBy === undefined ? [child.data.skill] : []));
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
- if (mounted.current && generation === loadGeneration.current)
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\u2026", 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"
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
  }