magus-cli 7.6.0 → 7.8.0
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/package.json +4 -4
- package/src/__tests__/blackbox-parity.test.ts +29 -15
- package/src/__tests__/blackbox-plugin-state.test.ts +25 -6
- package/src/__tests__/blind-780-cli-tools.test.ts +808 -0
- package/src/__tests__/blind-780-deprecated.test.ts +631 -0
- package/src/__tests__/blind-780-failure-reasons.test.ts +1004 -0
- package/src/__tests__/blind-780-plugin-resolution.test.ts +431 -0
- package/src/__tests__/blind-780-review2.test.ts +1203 -0
- package/src/__tests__/cli-update-view.test.ts +3 -3
- package/src/__tests__/deprecated-remove.test.ts +827 -0
- package/src/__tests__/doctor.test.ts +159 -1
- package/src/__tests__/failure-popup-review2.test.tsx +920 -0
- package/src/__tests__/failure-popup.test.tsx +419 -0
- package/src/__tests__/final-fixes-780.test.tsx +724 -0
- package/src/__tests__/format-update-outcome.test.ts +9 -34
- package/src/__tests__/gitignore-profile.test.ts +0 -4
- package/src/__tests__/models-screen-state.test.ts +18 -40
- package/src/__tests__/plugin-resolution.test.ts +52 -13
- package/src/__tests__/plugin-row-text.test.ts +30 -37
- package/src/__tests__/profile-dependencies.test.ts +7 -0
- package/src/__tests__/profile-startup-old-format.test.ts +0 -3
- package/src/__tests__/profile-startup.test.ts +40 -12
- package/src/__tests__/stale-load-repair.test.ts +31 -23
- package/src/__tests__/update-apply.test.ts +13 -29
- package/src/__tests__/update-plan.test.ts +23 -17
- package/src/__tests__/useStaleLoadModal.test.ts +1 -42
- package/src/__tests__/version-unknown-state.test.ts +15 -17
- package/src/cli/doctor.ts +151 -30
- package/src/cli/install.ts +5 -6
- package/src/cli/update-view.ts +58 -15
- package/src/cli/update.ts +27 -5
- package/src/services/catalog-cache-store.ts +8 -6
- package/src/services/claude-cli.ts +106 -19
- package/src/services/claude-settings.ts +84 -36
- package/src/services/gitignore-service.ts +0 -19
- package/src/services/plugin-env-needs.ts +69 -0
- package/src/services/plugin-manager.ts +31 -19
- package/src/services/plugin-resolution.ts +34 -42
- package/src/services/profile-absorb.ts +9 -0
- package/src/services/profile-activate.ts +1 -1
- package/src/services/profile-startup.ts +30 -27
- package/src/services/profile-state.ts +81 -2
- package/src/services/resolver.ts +95 -1
- package/src/services/update-engine.ts +326 -31
- package/src/services/update-plan.ts +293 -33
- package/src/types/index.ts +7 -0
- package/src/ui/App.tsx +186 -123
- package/src/ui/adapters/cliToolsAdapter.ts +203 -1
- package/src/ui/adapters/failureReport.ts +137 -0
- package/src/ui/adapters/pluginsAdapter.ts +299 -28
- package/src/ui/adapters/startupReports.ts +282 -0
- package/src/ui/components/modals/FailureListModal.tsx +231 -0
- package/src/ui/components/modals/MessageModal.tsx +7 -13
- package/src/ui/components/modals/ModalContainer.tsx +10 -5
- package/src/ui/components/modals/index.ts +1 -0
- package/src/ui/hooks/startupQueue.ts +56 -0
- package/src/ui/hooks/useGitignoreModal.ts +31 -31
- package/src/ui/hooks/useProfileStartupModal.ts +127 -122
- package/src/ui/hooks/useStaleLoadModal.ts +33 -53
- package/src/ui/keymap.ts +14 -1
- package/src/ui/renderers/mcpRenderers.tsx +110 -3
- package/src/ui/renderers/pluginRenderers.tsx +77 -6
- package/src/ui/renderers/pluginRowText.ts +25 -25
- package/src/ui/renderers/pluginStatusText.ts +18 -1
- package/src/ui/screens/AliasScreen.tsx +38 -44
- package/src/ui/screens/CliToolsScreen.tsx +65 -51
- package/src/ui/screens/EnvVarsScreen.tsx +21 -6
- package/src/ui/screens/GitignoreScreen.tsx +49 -75
- package/src/ui/screens/McpRegistryScreen.tsx +72 -40
- package/src/ui/screens/McpScreen.tsx +55 -24
- package/src/ui/screens/ModelsScreen.tsx +46 -86
- package/src/ui/screens/PluginsScreen.tsx +316 -245
- package/src/ui/screens/ProfilesScreen.tsx +88 -73
- package/src/ui/screens/SkillsScreen.tsx +83 -80
- package/src/ui/screens/StylesScreen.tsx +211 -275
- package/src/ui/state/AppContext.tsx +72 -7
- package/src/ui/state/DimensionsContext.tsx +3 -22
- package/src/ui/state/reducer.ts +25 -14
- package/src/ui/state/types.ts +61 -31
- package/src/utils/user-path.ts +60 -0
- package/src/__tests__/styles-status-line.test.ts +0 -72
- package/src/__tests__/version-snapshot.test.ts +0 -212
- package/src/services/version-snapshot.ts +0 -189
|
@@ -4,7 +4,7 @@ import React, { useEffect, useCallback, useState, useRef } from "react";
|
|
|
4
4
|
import { ScreenLayout } from "../components/layout/index.js";
|
|
5
5
|
import { useScreenKeys } from "../input/KeyRouter.js";
|
|
6
6
|
import { moveDirection } from "../keymap.js";
|
|
7
|
-
import { useApp, useModal } from "../state/AppContext.js";
|
|
7
|
+
import { useApp, useFailurePopup, useModal } from "../state/AppContext.js";
|
|
8
8
|
import { useDimensions } from "../state/DimensionsContext.js";
|
|
9
9
|
|
|
10
10
|
import { cliTools } from "../../data/cli-tools.js";
|
|
@@ -23,12 +23,18 @@ import {
|
|
|
23
23
|
specForCatalogTool,
|
|
24
24
|
} from "../../services/tool-probe.js";
|
|
25
25
|
import type { Command } from "../../utils/command-utils.js";
|
|
26
|
-
import { formatCommand } from "../../utils/command-utils.js";
|
|
27
26
|
import { createRequestGate } from "../../utils/request-gate.js";
|
|
28
27
|
import {
|
|
28
|
+
type CliToolCommandFailure,
|
|
29
29
|
type CliToolRow,
|
|
30
30
|
buildToolRow,
|
|
31
|
+
cliToolConflictPartialReport,
|
|
32
|
+
cliToolConflictStuckReport,
|
|
31
33
|
cliToolEnterAction,
|
|
34
|
+
cliToolFailureReport,
|
|
35
|
+
cliToolNotDone,
|
|
36
|
+
cliToolsNotUpdatedReport,
|
|
37
|
+
commandFailureReason,
|
|
32
38
|
countToolRows,
|
|
33
39
|
} from "../adapters/cliToolsAdapter.js";
|
|
34
40
|
import { ScrollableList } from "../components/ScrollableList.js";
|
|
@@ -39,6 +45,8 @@ import {
|
|
|
39
45
|
import { theme } from "../theme.js";
|
|
40
46
|
|
|
41
47
|
const execFileAsync = promisify(execFile);
|
|
48
|
+
/** How long one installer command may run before it is stopped. */
|
|
49
|
+
const COMMAND_TIMEOUT_MS = 60_000;
|
|
42
50
|
|
|
43
51
|
// ─── Session cache ─────────────────────────────────────────────────────────────
|
|
44
52
|
//
|
|
@@ -91,6 +99,7 @@ export function CliToolsScreen() {
|
|
|
91
99
|
const { state, dispatch } = useApp();
|
|
92
100
|
const { cliTools: cliToolsState } = state;
|
|
93
101
|
const modal = useModal();
|
|
102
|
+
const showFailures = useFailurePopup();
|
|
94
103
|
const dimensions = useDimensions();
|
|
95
104
|
|
|
96
105
|
const [rows, setRows] = useState<CliToolRow[]>(
|
|
@@ -208,23 +217,22 @@ export function CliToolsScreen() {
|
|
|
208
217
|
};
|
|
209
218
|
|
|
210
219
|
/**
|
|
211
|
-
* Run one installer command
|
|
220
|
+
* Run one installer command: null when it succeeded, else why it failed.
|
|
212
221
|
*
|
|
213
222
|
* `execFile`, so there is no shell to re-parse a package name — the same
|
|
214
223
|
* reason `utils/run.ts` exists. It does NOT call that shared `runCommand`:
|
|
215
224
|
* that one inherits stdio, and a TUI must never hand the terminal to a
|
|
216
|
-
* child. Output is captured
|
|
217
|
-
*
|
|
225
|
+
* child. Output is captured; the modal is the progress indicator, and on a
|
|
226
|
+
* failure the installer's own words become the popup's reason.
|
|
218
227
|
*/
|
|
219
|
-
const runCommand = async (
|
|
220
|
-
command: Command,
|
|
221
|
-
): Promise<{ ok: boolean; error?: string }> => {
|
|
228
|
+
const runCommand = async (command: Command): Promise<string | null> => {
|
|
222
229
|
try {
|
|
223
|
-
await execFileAsync(command.cmd, [...command.args], {
|
|
224
|
-
|
|
230
|
+
await execFileAsync(command.cmd, [...command.args], {
|
|
231
|
+
timeout: COMMAND_TIMEOUT_MS,
|
|
232
|
+
});
|
|
233
|
+
return null;
|
|
225
234
|
} catch (err) {
|
|
226
|
-
|
|
227
|
-
return { ok: false, error: msg };
|
|
235
|
+
return commandFailureReason(command, err, COMMAND_TIMEOUT_MS);
|
|
228
236
|
}
|
|
229
237
|
};
|
|
230
238
|
|
|
@@ -237,18 +245,19 @@ export function CliToolsScreen() {
|
|
|
237
245
|
const { command, progress: action } = enter;
|
|
238
246
|
|
|
239
247
|
modal.loading(`${action} ${tool.displayName}...`);
|
|
240
|
-
const
|
|
248
|
+
const reason = await runCommand(command);
|
|
241
249
|
modal.hideModal();
|
|
242
250
|
|
|
243
|
-
if (
|
|
251
|
+
if (reason === null) {
|
|
244
252
|
handleRefresh();
|
|
245
|
-
|
|
246
|
-
await modal.message(
|
|
247
|
-
"Error",
|
|
248
|
-
`Failed to ${action.toLowerCase()} ${tool.displayName}.\n\nTry running manually:\n${formatCommand(command)}`,
|
|
249
|
-
"error",
|
|
250
|
-
);
|
|
253
|
+
return;
|
|
251
254
|
}
|
|
255
|
+
await showFailures(
|
|
256
|
+
cliToolFailureReport(
|
|
257
|
+
`${tool.displayName} was not ${cliToolNotDone(enter.kind)}`,
|
|
258
|
+
[{ tool: tool.displayName, command, reason }],
|
|
259
|
+
),
|
|
260
|
+
);
|
|
252
261
|
};
|
|
253
262
|
|
|
254
263
|
/**
|
|
@@ -284,6 +293,11 @@ export function CliToolsScreen() {
|
|
|
284
293
|
methods
|
|
285
294
|
.map((m) => `The ${m} install stays: ${blockers.get(m)}.`)
|
|
286
295
|
.join("\n");
|
|
296
|
+
const blockerRows = (methods: readonly InstallMethod[]) =>
|
|
297
|
+
methods.map((method) => ({
|
|
298
|
+
method,
|
|
299
|
+
reason: blockers.get(method) ?? "magus cannot remove it",
|
|
300
|
+
}));
|
|
287
301
|
|
|
288
302
|
// One choice per install that can be KEPT — but only where keeping it
|
|
289
303
|
// removes something. "Keep npm" beside an unremovable native copy would
|
|
@@ -297,10 +311,8 @@ export function CliToolsScreen() {
|
|
|
297
311
|
}));
|
|
298
312
|
|
|
299
313
|
if (options.length === 0) {
|
|
300
|
-
await
|
|
301
|
-
|
|
302
|
-
`Installed via ${allMethods.join(" + ")}, and magus can remove none of them.\n\n${stuckList(allMethods)}\n\nRemove one by hand, then press r.`,
|
|
303
|
-
"error",
|
|
314
|
+
await showFailures(
|
|
315
|
+
cliToolConflictStuckReport(tool.displayName, blockerRows(allMethods)),
|
|
304
316
|
);
|
|
305
317
|
return;
|
|
306
318
|
}
|
|
@@ -318,35 +330,36 @@ export function CliToolsScreen() {
|
|
|
318
330
|
const leftBehind = stuck.filter((m) => m !== keep);
|
|
319
331
|
modal.loading(`Removing ${toRemove.join(", ")} install(s)...`);
|
|
320
332
|
|
|
321
|
-
const
|
|
333
|
+
const failed: Array<{
|
|
334
|
+
method: InstallMethod;
|
|
335
|
+
command: Command;
|
|
336
|
+
reason: string;
|
|
337
|
+
}> = [];
|
|
338
|
+
const unremovable = blockerRows(leftBehind);
|
|
322
339
|
for (const method of toRemove) {
|
|
323
340
|
const cmd = cliToolUninstall(tool, method, install.brewFormula);
|
|
324
341
|
// Unreachable: `removable` was built from this same call. Reported
|
|
325
342
|
// rather than skipped, because a silent skip is the defect above.
|
|
326
343
|
if (!cmd) {
|
|
327
|
-
|
|
344
|
+
unremovable.push({
|
|
345
|
+
method,
|
|
346
|
+
reason: "magus has no uninstall command for it",
|
|
347
|
+
});
|
|
328
348
|
continue;
|
|
329
349
|
}
|
|
330
|
-
const
|
|
331
|
-
if (
|
|
350
|
+
const reason = await runCommand(cmd);
|
|
351
|
+
if (reason !== null) failed.push({ method, command: cmd, reason });
|
|
332
352
|
}
|
|
333
353
|
|
|
334
354
|
modal.hideModal();
|
|
335
355
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
].filter((part) => part !== "");
|
|
344
|
-
|
|
345
|
-
if (report.length > 0) {
|
|
346
|
-
await modal.message(
|
|
347
|
-
errors.length > 0 ? "Partly removed" : "One install stays",
|
|
348
|
-
report.join("\n\n"),
|
|
349
|
-
errors.length > 0 ? "error" : "info",
|
|
356
|
+
// A failed removal goes into the failure popup with the command to run,
|
|
357
|
+
// and so does an install left behind beside it. An install magus could
|
|
358
|
+
// never remove is not, on its own, a failure: nothing failed, and the row
|
|
359
|
+
// still shows the conflict until it is dealt with.
|
|
360
|
+
if (failed.length > 0 || unremovable.length > leftBehind.length) {
|
|
361
|
+
await showFailures(
|
|
362
|
+
cliToolConflictPartialReport(tool.displayName, failed, unremovable),
|
|
350
363
|
);
|
|
351
364
|
}
|
|
352
365
|
|
|
@@ -358,23 +371,24 @@ export function CliToolsScreen() {
|
|
|
358
371
|
// swept up in a bulk action — that is exactly the "cannot tell, so run
|
|
359
372
|
// it anyway" the CLI no longer does either.
|
|
360
373
|
const updatable = rows.filter((r) => r.status?.state === "upgrade");
|
|
361
|
-
if (updatable.length === 0)
|
|
362
|
-
await modal.message(
|
|
363
|
-
"Up to Date",
|
|
364
|
-
"No tool is known to be behind.",
|
|
365
|
-
"info",
|
|
366
|
-
);
|
|
367
|
-
return;
|
|
368
|
-
}
|
|
374
|
+
if (updatable.length === 0) return;
|
|
369
375
|
|
|
370
376
|
modal.loading(`Updating ${updatable.length} tool(s)...`);
|
|
371
377
|
|
|
378
|
+
// Every failure is kept: the reload shows the tool still behind, and
|
|
379
|
+
// only this says why.
|
|
380
|
+
const failed: CliToolCommandFailure[] = [];
|
|
372
381
|
for (const row of updatable) {
|
|
373
|
-
|
|
382
|
+
const command = row.updateCommand ?? row.installCommand;
|
|
383
|
+
const reason = await runCommand(command);
|
|
384
|
+
if (reason !== null) {
|
|
385
|
+
failed.push({ tool: row.tool.displayName, command, reason });
|
|
386
|
+
}
|
|
374
387
|
}
|
|
375
388
|
|
|
376
389
|
modal.hideModal();
|
|
377
390
|
handleRefresh();
|
|
391
|
+
await showFailures(cliToolsNotUpdatedReport(failed));
|
|
378
392
|
};
|
|
379
393
|
|
|
380
394
|
const selectedRow = rows[cliToolsState.selectedIndex];
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type SettingsBrowserItem,
|
|
10
10
|
buildSettingsBrowserItems,
|
|
11
11
|
} from "../adapters/settingsAdapter.js";
|
|
12
|
+
import { stepFailureReport } from "../adapters/startupReports.js";
|
|
12
13
|
import { ScrollableList } from "../components/ScrollableList.js";
|
|
13
14
|
import { ScreenLayout } from "../components/layout/index.js";
|
|
14
15
|
import { ErrorNotice } from "../components/primitives/index.js";
|
|
@@ -18,7 +19,7 @@ import {
|
|
|
18
19
|
renderSettingDetail,
|
|
19
20
|
renderSettingRow,
|
|
20
21
|
} from "../renderers/settingsRenderers.js";
|
|
21
|
-
import { useApp, useModal } from "../state/AppContext.js";
|
|
22
|
+
import { useApp, useFailurePopup, useModal } from "../state/AppContext.js";
|
|
22
23
|
import { useDimensions } from "../state/DimensionsContext.js";
|
|
23
24
|
import { theme } from "../theme.js";
|
|
24
25
|
|
|
@@ -26,8 +27,19 @@ export function SettingsScreen() {
|
|
|
26
27
|
const { state, dispatch } = useApp();
|
|
27
28
|
const { settings } = state;
|
|
28
29
|
const modal = useModal();
|
|
30
|
+
const showFailures = useFailurePopup();
|
|
29
31
|
const dimensions = useDimensions();
|
|
30
32
|
|
|
33
|
+
/** A setting that did not save, at the scope it was being saved to. */
|
|
34
|
+
const reportNotSaved = (
|
|
35
|
+
name: string,
|
|
36
|
+
scope: "user" | "project",
|
|
37
|
+
error: unknown,
|
|
38
|
+
): Promise<void> =>
|
|
39
|
+
showFailures(
|
|
40
|
+
stepFailureReport(`${name} was not saved`, scope.toUpperCase(), error),
|
|
41
|
+
);
|
|
42
|
+
|
|
31
43
|
const fetchData = useCallback(async () => {
|
|
32
44
|
dispatch({ type: "SETTINGS_DATA_LOADING" });
|
|
33
45
|
try {
|
|
@@ -54,9 +66,12 @@ export function SettingsScreen() {
|
|
|
54
66
|
}
|
|
55
67
|
}, [dispatch, state.projectPath]);
|
|
56
68
|
|
|
69
|
+
// Load data on mount or when dataRefreshVersion changes — a profile
|
|
70
|
+
// activating changes what this screen shows.
|
|
71
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: dataRefreshVersion is the external refresh trigger; bumping it must re-run the fetch even though the body never reads it
|
|
57
72
|
useEffect(() => {
|
|
58
73
|
fetchData();
|
|
59
|
-
}, [fetchData]);
|
|
74
|
+
}, [fetchData, state.dataRefreshVersion]);
|
|
60
75
|
|
|
61
76
|
const listItems = useMemo((): SettingsBrowserItem[] => {
|
|
62
77
|
if (settings.values.status !== "success") return [];
|
|
@@ -81,7 +96,7 @@ export function SettingsScreen() {
|
|
|
81
96
|
await writeSettingValue(setting, newValue, scope, state.projectPath);
|
|
82
97
|
await fetchData();
|
|
83
98
|
} catch (error) {
|
|
84
|
-
await
|
|
99
|
+
await reportNotSaved(setting.name, scope, error);
|
|
85
100
|
}
|
|
86
101
|
} else if (setting.type === "select" && setting.options) {
|
|
87
102
|
const options = setting.options.map((o) => ({
|
|
@@ -107,7 +122,7 @@ export function SettingsScreen() {
|
|
|
107
122
|
await writeSettingValue(setting, val, scope, state.projectPath);
|
|
108
123
|
await fetchData();
|
|
109
124
|
} catch (error) {
|
|
110
|
-
await
|
|
125
|
+
await reportNotSaved(setting.name, scope, error);
|
|
111
126
|
}
|
|
112
127
|
} else {
|
|
113
128
|
// String type: if already set, clear it; if unset, show input modal
|
|
@@ -116,7 +131,7 @@ export function SettingsScreen() {
|
|
|
116
131
|
await writeSettingValue(setting, undefined, scope, state.projectPath);
|
|
117
132
|
await fetchData();
|
|
118
133
|
} catch (error) {
|
|
119
|
-
await
|
|
134
|
+
await reportNotSaved(setting.name, scope, error);
|
|
120
135
|
}
|
|
121
136
|
} else {
|
|
122
137
|
const newValue = await modal.input(
|
|
@@ -134,7 +149,7 @@ export function SettingsScreen() {
|
|
|
134
149
|
);
|
|
135
150
|
await fetchData();
|
|
136
151
|
} catch (error) {
|
|
137
|
-
await
|
|
152
|
+
await reportNotSaved(setting.name, scope, error);
|
|
138
153
|
}
|
|
139
154
|
}
|
|
140
155
|
}
|
|
@@ -12,6 +12,11 @@ import {
|
|
|
12
12
|
loadGitignoreState,
|
|
13
13
|
} from "../../services/gitignore-service.js";
|
|
14
14
|
import type { ResolvedRule, Tier, Violation } from "../../types/gitignore.js";
|
|
15
|
+
import {
|
|
16
|
+
gitignoreFixReport,
|
|
17
|
+
gitignoreTemplateReport,
|
|
18
|
+
stepFailureReport,
|
|
19
|
+
} from "../adapters/startupReports.js";
|
|
15
20
|
import { ScreenLayout } from "../components/layout/index.js";
|
|
16
21
|
import { useScreenKeys } from "../input/KeyRouter.js";
|
|
17
22
|
import { moveDirection } from "../keymap.js";
|
|
@@ -20,6 +25,7 @@ import {
|
|
|
20
25
|
renderRuleRow,
|
|
21
26
|
} from "../renderers/gitignoreRenderers.js";
|
|
22
27
|
import { useApp, useModal } from "../state/AppContext.js";
|
|
28
|
+
import type { FailureReport } from "../state/types.js";
|
|
23
29
|
import { theme } from "../theme.js";
|
|
24
30
|
|
|
25
31
|
// Clean rows are grouped by the manifest that defined them. Order = display
|
|
@@ -57,9 +63,12 @@ export function GitignoreScreen() {
|
|
|
57
63
|
}
|
|
58
64
|
}, [cwd]);
|
|
59
65
|
|
|
66
|
+
// Load on mount or when dataRefreshVersion changes — a profile activating
|
|
67
|
+
// can turn committed files into generated ones this screen lists.
|
|
68
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: dataRefreshVersion is the external refresh trigger; bumping it must re-run the load even though the body never reads it
|
|
60
69
|
useEffect(() => {
|
|
61
70
|
void reload();
|
|
62
|
-
}, [reload]);
|
|
71
|
+
}, [reload, state.dataRefreshVersion]);
|
|
63
72
|
|
|
64
73
|
// Build the managed list: every desired gitignore rule, with all matching
|
|
65
74
|
// violations grouped under the rule that owns them.
|
|
@@ -135,112 +144,75 @@ export function GitignoreScreen() {
|
|
|
135
144
|
const handleFixAll = useCallback(async () => {
|
|
136
145
|
if (!data || toFixRows.length === 0) return;
|
|
137
146
|
try {
|
|
138
|
-
const
|
|
139
|
-
const failures: string[] = [];
|
|
147
|
+
const failures: Array<{ path: string; message: string }> = [];
|
|
140
148
|
for (const { row } of toFixRows) {
|
|
141
149
|
for (const v of row.violations) {
|
|
142
150
|
const r = await applyDestructiveFix(cwd, v, row.rule.pattern);
|
|
143
|
-
(r.applied
|
|
151
|
+
if (!r.applied) failures.push({ path: v.path, message: r.message });
|
|
144
152
|
}
|
|
145
153
|
}
|
|
146
154
|
await reload();
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
[...failures, ...messages].join("\n"),
|
|
151
|
-
"error",
|
|
152
|
-
);
|
|
153
|
-
}
|
|
155
|
+
// Only what did not apply, each with why; the reloaded rows show the rest.
|
|
156
|
+
const report = gitignoreFixReport(failures);
|
|
157
|
+
if (report) await modal.failures(report);
|
|
154
158
|
} catch (err) {
|
|
155
|
-
await modal.
|
|
156
|
-
"
|
|
157
|
-
err instanceof Error ? err.message : String(err),
|
|
158
|
-
"error",
|
|
159
|
+
await modal.failures(
|
|
160
|
+
stepFailureReport("The fixes were not applied", ".gitignore", err),
|
|
159
161
|
);
|
|
160
162
|
}
|
|
161
163
|
}, [cwd, data, toFixRows, reload, modal]);
|
|
162
164
|
|
|
163
165
|
// Fix just the highlighted row.
|
|
164
166
|
const handleFixOne = useCallback(async () => {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
"No row selected",
|
|
168
|
-
"Use ↑↓ to highlight a row that needs fixing, then press f.",
|
|
169
|
-
"info",
|
|
170
|
-
);
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
|
-
if (selectedRow.violations.length === 0) {
|
|
174
|
-
await modal.message(
|
|
175
|
-
"Nothing to fix",
|
|
176
|
-
`${selectedRow.rule.pattern} already matches. f only applies to rows that need fixing.`,
|
|
177
|
-
"info",
|
|
178
|
-
);
|
|
179
|
-
return;
|
|
180
|
-
}
|
|
167
|
+
// Nothing highlighted, or a row that already matches: nothing to fix.
|
|
168
|
+
if (!selectedRow || selectedRow.violations.length === 0) return;
|
|
181
169
|
|
|
182
170
|
try {
|
|
183
|
-
const
|
|
171
|
+
const failures: Array<{ path: string; message: string }> = [];
|
|
184
172
|
for (const v of selectedRow.violations) {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
);
|
|
173
|
+
const r = await applyDestructiveFix(cwd, v, selectedRow.rule.pattern);
|
|
174
|
+
if (!r.applied) failures.push({ path: v.path, message: r.message });
|
|
188
175
|
}
|
|
189
|
-
const failed = results.filter((r) => !r.applied);
|
|
190
176
|
await reload();
|
|
191
|
-
// Success is silent — the reload removes the fixed row. Only
|
|
192
|
-
//
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
"Some fixes didn't apply",
|
|
196
|
-
results.map((r) => r.message).join("\n"),
|
|
197
|
-
"error",
|
|
198
|
-
);
|
|
199
|
-
}
|
|
177
|
+
// Success is silent — the reload removes the fixed row. Only what did
|
|
178
|
+
// not apply goes into the failure popup.
|
|
179
|
+
const report = gitignoreFixReport(failures);
|
|
180
|
+
if (report) await modal.failures(report);
|
|
200
181
|
} catch (err) {
|
|
201
|
-
await modal.
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
182
|
+
await modal.failures(
|
|
183
|
+
stepFailureReport(
|
|
184
|
+
"The fix was not applied",
|
|
185
|
+
selectedRow.rule.pattern,
|
|
186
|
+
err,
|
|
187
|
+
),
|
|
205
188
|
);
|
|
206
189
|
}
|
|
207
190
|
}, [selectedRow, modal, cwd, reload]);
|
|
208
191
|
|
|
209
192
|
const handleApplyTemplate = useCallback(async () => {
|
|
210
193
|
const names = Object.keys(BUILTIN_TEMPLATES);
|
|
211
|
-
if (names.length === 0)
|
|
212
|
-
await modal.message(
|
|
213
|
-
"No templates",
|
|
214
|
-
"No built-in templates available.",
|
|
215
|
-
"info",
|
|
216
|
-
);
|
|
217
|
-
return;
|
|
218
|
-
}
|
|
194
|
+
if (names.length === 0) return;
|
|
219
195
|
const selected = await modal.select(
|
|
220
196
|
"Apply template",
|
|
221
197
|
"Merge the template into the project manifest at .claude/gitignore.json.",
|
|
222
198
|
names.map((n) => ({ label: n, value: n })),
|
|
223
199
|
);
|
|
224
200
|
if (!selected) return;
|
|
201
|
+
let report: FailureReport | null = null;
|
|
225
202
|
try {
|
|
203
|
+
// What was merged shows in the list. What was skipped does not, so it
|
|
204
|
+
// is the one thing said — in the failure popup.
|
|
226
205
|
const r = await applyTemplate(cwd, selected);
|
|
227
|
-
|
|
228
|
-
r.conflicts.length > 0
|
|
229
|
-
? `\n\nSkipped due to conflicts: ${r.conflicts.join(", ")}`
|
|
230
|
-
: "";
|
|
231
|
-
await modal.message(
|
|
232
|
-
"Template applied",
|
|
233
|
-
`Wrote ${r.appliedTo}\n\nAdded ${r.added.ignore.length} ignore + ${r.added.track.length} track entries.${conflictNote}`,
|
|
234
|
-
"success",
|
|
235
|
-
);
|
|
206
|
+
report = gitignoreTemplateReport(selected, r.conflicts);
|
|
236
207
|
} catch (err) {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
208
|
+
report = stepFailureReport(
|
|
209
|
+
`Template "${selected}" was not applied`,
|
|
210
|
+
".claude/gitignore.json",
|
|
211
|
+
err,
|
|
241
212
|
);
|
|
242
213
|
}
|
|
243
214
|
await reload();
|
|
215
|
+
if (report) await modal.failures(report);
|
|
244
216
|
}, [cwd, modal, reload]);
|
|
245
217
|
|
|
246
218
|
/**
|
|
@@ -248,7 +220,7 @@ export function GitignoreScreen() {
|
|
|
248
220
|
*
|
|
249
221
|
* Every await sits inside the try: this runs fire-and-forget from a key, and a
|
|
250
222
|
* rejection nobody catches ends the app. A manifest that cannot be read or
|
|
251
|
-
* created is said in
|
|
223
|
+
* created is said in the failure popup instead.
|
|
252
224
|
*/
|
|
253
225
|
const handleEditManifest = useCallback(async () => {
|
|
254
226
|
let projectManifest: string;
|
|
@@ -259,10 +231,12 @@ export function GitignoreScreen() {
|
|
|
259
231
|
current.resolved,
|
|
260
232
|
);
|
|
261
233
|
} catch (err) {
|
|
262
|
-
await modal.
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
234
|
+
await modal.failures(
|
|
235
|
+
stepFailureReport(
|
|
236
|
+
"The project's rules were not opened for editing",
|
|
237
|
+
".claude/gitignore.json",
|
|
238
|
+
err,
|
|
239
|
+
),
|
|
266
240
|
);
|
|
267
241
|
return;
|
|
268
242
|
}
|