magus-cli 7.7.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 +6 -32
- package/src/__tests__/gitignore-profile.test.ts +1 -9
- 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 -57
- 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/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-activate.ts +1 -2
- package/src/services/profile-startup.ts +18 -43
- package/src/services/profile-state.ts +72 -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 +90 -146
- package/src/ui/adapters/cliToolsAdapter.ts +203 -1
- package/src/ui/adapters/failureReport.ts +137 -0
- package/src/ui/adapters/pluginsAdapter.ts +297 -45
- 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 -12
- 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 +30 -52
- package/src/ui/hooks/useProfileStartupModal.ts +119 -142
- package/src/ui/hooks/useStaleLoadModal.ts +35 -65
- 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 -45
- package/src/ui/screens/CliToolsScreen.tsx +65 -54
- package/src/ui/screens/EnvVarsScreen.tsx +17 -5
- package/src/ui/screens/GitignoreScreen.tsx +48 -67
- package/src/ui/screens/McpRegistryScreen.tsx +67 -38
- package/src/ui/screens/McpScreen.tsx +46 -19
- package/src/ui/screens/ModelsScreen.tsx +46 -86
- package/src/ui/screens/PluginsScreen.tsx +313 -221
- package/src/ui/screens/ProfilesScreen.tsx +79 -40
- package/src/ui/screens/SkillsScreen.tsx +83 -80
- package/src/ui/screens/StylesScreen.tsx +211 -275
- package/src/ui/state/AppContext.tsx +66 -44
- package/src/ui/state/DimensionsContext.tsx +3 -22
- package/src/ui/state/reducer.ts +12 -100
- package/src/ui/state/types.ts +48 -69
- package/src/utils/user-path.ts +60 -0
- package/src/__tests__/notices-rules.test.ts +0 -476
- package/src/__tests__/notices.test.tsx +0 -604
- 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
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The failure popup's words for everything outside the Plugins screen's apply
|
|
3
|
+
* paths: the profile gate at startup, a profile activation, the startup
|
|
4
|
+
* repairs (stale plugin loads, marketplace refreshes, gitignore fixes) and the
|
|
5
|
+
* Git State screen. Each is a pure builder over what the service returned, on
|
|
6
|
+
* top of {@link failureReport}, so they read like every other failure.
|
|
7
|
+
*
|
|
8
|
+
* Each returns null when nothing failed: success says nothing, because the
|
|
9
|
+
* screen shows it.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { StaleLoadRepairResult } from "../../services/claude-settings.js";
|
|
13
|
+
import type { ActivationResult } from "../../services/profile-activate.js";
|
|
14
|
+
import { SettingsParseError } from "../../services/settings-file.js";
|
|
15
|
+
import { pluginName } from "../../services/update-engine.js";
|
|
16
|
+
import { userFacingPath, withUserFacingPath } from "../../utils/user-path.js";
|
|
17
|
+
import type { FailureReport, FailureRow } from "../state/types.js";
|
|
18
|
+
import {
|
|
19
|
+
counted,
|
|
20
|
+
failureReport,
|
|
21
|
+
mergeFailureReports,
|
|
22
|
+
} from "./failureReport.js";
|
|
23
|
+
import { exceptionReason, pluginFailureReport } from "./pluginsAdapter.js";
|
|
24
|
+
|
|
25
|
+
/** `run magus install` → `Run magus install`. */
|
|
26
|
+
function sentence(text: string): string {
|
|
27
|
+
return `${text.charAt(0).toUpperCase()}${text.slice(1)}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* One line the profile gate refused on, as a row. The gate words them
|
|
32
|
+
* "<file> cannot be read (<why>) — fix it by hand; magus left it as it is and
|
|
33
|
+
* did not switch": the file is the subject, the instruction the fix, and the
|
|
34
|
+
* rest the reason. A line of another shape is about the profile itself.
|
|
35
|
+
*/
|
|
36
|
+
export function profileProblemRow(problem: string): FailureRow {
|
|
37
|
+
const dash = problem.indexOf(" — ");
|
|
38
|
+
const head = dash >= 0 ? problem.slice(0, dash) : problem;
|
|
39
|
+
const tail = dash >= 0 ? problem.slice(dash + 3) : "";
|
|
40
|
+
const file = /^(\S+) (cannot be read\b.*)$/.exec(head);
|
|
41
|
+
const subject = file?.[1] ?? "profile";
|
|
42
|
+
let reason = file?.[2] ?? head;
|
|
43
|
+
let fix: string | undefined;
|
|
44
|
+
if (tail) {
|
|
45
|
+
const [instruction = "", ...rest] = tail.split("; ");
|
|
46
|
+
fix = sentence(instruction);
|
|
47
|
+
if (rest.length > 0) reason = `${reason} — ${rest.join("; ")}`;
|
|
48
|
+
}
|
|
49
|
+
return fix ? { subject, reason, fix } : { subject, reason };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* H2: what the startup gate refused — `prepareProfileStartup(...).problems`.
|
|
54
|
+
* Shown before the profile question, which it does not replace: the question
|
|
55
|
+
* is still asked about whatever is left.
|
|
56
|
+
*/
|
|
57
|
+
export function profileProblemsReport(
|
|
58
|
+
problems: readonly string[],
|
|
59
|
+
headline = "This project's profile was not brought up to date",
|
|
60
|
+
): FailureReport | null {
|
|
61
|
+
return failureReport(headline, problems.map(profileProblemRow));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* H3: what a profile activation could not do, from its {@link
|
|
66
|
+
* ActivationResult}s — the startup re-render or format update, the startup
|
|
67
|
+
* choice, a Profiles-tab switch. ONE builder for all of them.
|
|
68
|
+
*
|
|
69
|
+
* Listed: plugins that will not load because the profile turns a dependency
|
|
70
|
+
* off; plugins whose dependencies could not be read because they are not
|
|
71
|
+
* downloaded; skills that did not install, with why; a skill that exists in
|
|
72
|
+
* two copies, saying which was kept and what to do. Everything else an
|
|
73
|
+
* activation reports is either done or shown as the screen's state.
|
|
74
|
+
*/
|
|
75
|
+
export function activationFailureReport(
|
|
76
|
+
results: ReadonlyArray<ActivationResult | null | undefined>,
|
|
77
|
+
): FailureReport | null {
|
|
78
|
+
const rows: FailureRow[] = [];
|
|
79
|
+
const seen = new Set<string>();
|
|
80
|
+
const add = (row: FailureRow) => {
|
|
81
|
+
const key = `${row.subject}\u0000${row.reason}`;
|
|
82
|
+
if (seen.has(key)) return;
|
|
83
|
+
seen.add(key);
|
|
84
|
+
rows.push(row);
|
|
85
|
+
};
|
|
86
|
+
let profile: string | null = null;
|
|
87
|
+
|
|
88
|
+
for (const result of results) {
|
|
89
|
+
if (!result) continue;
|
|
90
|
+
profile ??= result.profileId;
|
|
91
|
+
for (const { plugin, dependency } of result.dependencies?.blocked ?? []) {
|
|
92
|
+
add({
|
|
93
|
+
subject: pluginName(plugin),
|
|
94
|
+
reason: `will not load: it depends on ${pluginName(dependency)}, which profile "${result.profileId}" turns off`,
|
|
95
|
+
fix: `Remove "${dependency}": false from the profile to let it load`,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
for (const plugin of result.dependencies?.unreadable ?? []) {
|
|
99
|
+
add({
|
|
100
|
+
subject: pluginName(plugin),
|
|
101
|
+
reason:
|
|
102
|
+
"is not downloaded on this machine, so nothing it depends on was turned on",
|
|
103
|
+
fix: "Run magus install",
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
for (const f of result.skills.failed) {
|
|
107
|
+
add({
|
|
108
|
+
subject: `skill ${f.skill}`,
|
|
109
|
+
reason: `was not installed: ${exceptionReason(f.reason).reason}`,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
for (const c of result.conversion.conflicts) {
|
|
113
|
+
add({
|
|
114
|
+
subject: `skill ${c.folder}`,
|
|
115
|
+
reason: `exists in two copies: ${c.kept} is the one in use, the other is at ${c.other}`,
|
|
116
|
+
fix: "Compare the two and delete the one you do not want",
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (rows.length === 0) return null;
|
|
121
|
+
return failureReport(
|
|
122
|
+
`Profile "${profile}" is active, but ${counted(rows.length, "thing")} did not work`,
|
|
123
|
+
rows,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The headline when the startup gate and the re-render both have something. */
|
|
128
|
+
export const PROFILE_STARTUP_HEADLINE =
|
|
129
|
+
"This project's profile was not fully brought up to date";
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* R8: everything the startup profile gate could not do, as ONE popup — what
|
|
133
|
+
* it refused ({@link profileProblemsReport}) and what its activation could not
|
|
134
|
+
* do ({@link activationFailureReport}). Both come from the same startup step,
|
|
135
|
+
* so they are one event: two popups in a row made the user dismiss twice for
|
|
136
|
+
* one thing. Either alone keeps its own headline.
|
|
137
|
+
*/
|
|
138
|
+
export function profileStartupReport(
|
|
139
|
+
problems: readonly string[],
|
|
140
|
+
activations: ReadonlyArray<ActivationResult | null | undefined>,
|
|
141
|
+
): FailureReport | null {
|
|
142
|
+
return mergeFailureReports(PROFILE_STARTUP_HEADLINE, [
|
|
143
|
+
profileProblemsReport(problems),
|
|
144
|
+
activationFailureReport(activations),
|
|
145
|
+
]);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* H4: the startup "Repair plugins" — each repair that did not bring the
|
|
150
|
+
* installed version forward, with its own reason.
|
|
151
|
+
*/
|
|
152
|
+
export function staleRepairReport(
|
|
153
|
+
results: readonly StaleLoadRepairResult[],
|
|
154
|
+
): FailureReport | null {
|
|
155
|
+
return pluginFailureReport(
|
|
156
|
+
results
|
|
157
|
+
.filter((r) => !r.ok)
|
|
158
|
+
.map((r) => ({
|
|
159
|
+
pluginId: r.pluginId,
|
|
160
|
+
verb: "repaired" as const,
|
|
161
|
+
detail:
|
|
162
|
+
r.error ??
|
|
163
|
+
`Claude Code still does not load v${r.target} in this folder`,
|
|
164
|
+
})),
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The fix for a settings file that does not parse: magus will not write over it. */
|
|
169
|
+
export const FIX_BY_HAND = "Fix the file by hand, then try again";
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* A thrown error from a whole step, as ONE row about `subject`.
|
|
173
|
+
*
|
|
174
|
+
* The reason is the error's first line. A cause magus recognises carries its
|
|
175
|
+
* own fix: no `claude` (a `spawn … claude ENOENT` included), no network, or a
|
|
176
|
+
* settings file that does not parse. That last one is about the FILE, so the
|
|
177
|
+
* file is the row's subject — named the way the user knows it
|
|
178
|
+
* (`.claude/settings.json`, `~/.claude/settings.json`), never by absolute path
|
|
179
|
+
* — because fixing it by hand is the only way on. `fallbackFix` is the fix for
|
|
180
|
+
* anything else: the command to run yourself, say. Without one, an
|
|
181
|
+
* unrecognised error has no fix line, because there is no real action to offer.
|
|
182
|
+
*/
|
|
183
|
+
export function stepFailureReport(
|
|
184
|
+
headline: string,
|
|
185
|
+
subject: string,
|
|
186
|
+
error: unknown,
|
|
187
|
+
fallbackFix?: string,
|
|
188
|
+
): FailureReport {
|
|
189
|
+
if (error instanceof SettingsParseError) {
|
|
190
|
+
const detail = withUserFacingPath(
|
|
191
|
+
exceptionReason(error.detail).reason,
|
|
192
|
+
error.path,
|
|
193
|
+
);
|
|
194
|
+
return {
|
|
195
|
+
headline,
|
|
196
|
+
rows: [
|
|
197
|
+
{
|
|
198
|
+
subject: userFacingPath(error.path),
|
|
199
|
+
reason: `${error.problem} (${detail})`,
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
fix: FIX_BY_HAND,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
const e = exceptionReason(
|
|
206
|
+
error instanceof Error ? error.message : String(error),
|
|
207
|
+
);
|
|
208
|
+
return {
|
|
209
|
+
headline,
|
|
210
|
+
rows: [{ subject, reason: e.reason }],
|
|
211
|
+
fix: e.fix ?? fallbackFix ?? null,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* M8: marketplaces whose refresh failed after startup re-registered them.
|
|
217
|
+
* Until one refreshes, its plugins are read from a copy that may be old.
|
|
218
|
+
*/
|
|
219
|
+
export function marketplaceRefreshReport(
|
|
220
|
+
failures: ReadonlyArray<{ marketplace: string; error: unknown }>,
|
|
221
|
+
): FailureReport | null {
|
|
222
|
+
return failureReport(
|
|
223
|
+
`${counted(failures.length, "marketplace")} could not be refreshed`,
|
|
224
|
+
failures.map(({ marketplace, error }) => {
|
|
225
|
+
const e = exceptionReason(
|
|
226
|
+
error instanceof Error ? error.message : String(error),
|
|
227
|
+
);
|
|
228
|
+
return {
|
|
229
|
+
subject: marketplace,
|
|
230
|
+
reason: e.reason,
|
|
231
|
+
...(e.fix ? { fix: e.fix } : {}),
|
|
232
|
+
};
|
|
233
|
+
}),
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* M9: generated files the startup gitignore fix could not untrack, each with
|
|
239
|
+
* git's own reason.
|
|
240
|
+
*/
|
|
241
|
+
export function gitignoreStartupReport(
|
|
242
|
+
failed: ReadonlyArray<{ path: string; reason: string }>,
|
|
243
|
+
): FailureReport | null {
|
|
244
|
+
return failureReport(
|
|
245
|
+
`${counted(failed.length, "generated file")} ${failed.length === 1 ? "is" : "are"} still tracked by git`,
|
|
246
|
+
failed.map((f) => ({
|
|
247
|
+
subject: f.path,
|
|
248
|
+
reason: exceptionReason(f.reason).reason,
|
|
249
|
+
})),
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Git State fixes that did not apply, each with its reason. */
|
|
254
|
+
export function gitignoreFixReport(
|
|
255
|
+
failed: ReadonlyArray<{ path: string; message: string }>,
|
|
256
|
+
): FailureReport | null {
|
|
257
|
+
return failureReport(
|
|
258
|
+
`${counted(failed.length, "fix", "fixes")} did not apply`,
|
|
259
|
+
failed.map((f) => ({
|
|
260
|
+
subject: f.path,
|
|
261
|
+
reason: exceptionReason(f.message).reason,
|
|
262
|
+
})),
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* M9: template rules a merge skipped because they contradict a rule the
|
|
268
|
+
* manifest already has. They are not in the list, so this is the only place
|
|
269
|
+
* the user learns of them.
|
|
270
|
+
*/
|
|
271
|
+
export function gitignoreTemplateReport(
|
|
272
|
+
template: string,
|
|
273
|
+
conflicts: readonly string[],
|
|
274
|
+
): FailureReport | null {
|
|
275
|
+
return failureReport(
|
|
276
|
+
`${counted(conflicts.length, "rule")} from "${template}" ${conflicts.length === 1 ? "was" : "were"} not added`,
|
|
277
|
+
conflicts.map((pattern) => ({
|
|
278
|
+
subject: pattern,
|
|
279
|
+
reason: "skipped: conflicts with existing rules",
|
|
280
|
+
})),
|
|
281
|
+
);
|
|
282
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import type { FailureReport, FailureRow } from "../../state/types.js";
|
|
2
|
+
import { theme } from "../../theme.js";
|
|
3
|
+
|
|
4
|
+
/** Border (2) and horizontal padding (3 + 3). */
|
|
5
|
+
const CHROME = 8;
|
|
6
|
+
const MAX_WIDTH = 88;
|
|
7
|
+
const MIN_WIDTH = 40;
|
|
8
|
+
/** "✗ " before each row's subject. */
|
|
9
|
+
const MARK = "✗ ";
|
|
10
|
+
/**
|
|
11
|
+
* Rows of the popup that are not failure rows: border (2), padding (2), the
|
|
12
|
+
* headline and its gap (2), the footer and its gap (2), and a row of air
|
|
13
|
+
* between the popup and the terminal's edge (2).
|
|
14
|
+
*/
|
|
15
|
+
const FIXED_ROWS = 10;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The popup's width for a terminal `terminalWidth` columns wide: as wide as the
|
|
19
|
+
* rows need, never wider than the terminal leaves room for.
|
|
20
|
+
*/
|
|
21
|
+
export function failureListWidth(terminalWidth: number): number {
|
|
22
|
+
return Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, terminalWidth - 4));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Whether a row's reason goes on its own line under the subject. The subject
|
|
27
|
+
* never wraps — it is what the user scans for — so a long one (several plugins
|
|
28
|
+
* sharing a reason) takes the line to itself and the reason goes beneath it,
|
|
29
|
+
* rather than being squeezed into a sliver beside it.
|
|
30
|
+
*/
|
|
31
|
+
export function reasonBelow(subject: string, innerWidth: number): boolean {
|
|
32
|
+
return MARK.length + subject.length + 2 > innerWidth / 2;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Lines `text` takes when word-wrapped at `width` columns. */
|
|
36
|
+
function wrappedLines(text: string, width: number): number {
|
|
37
|
+
if (width <= 0) return text.length;
|
|
38
|
+
let lines = 1;
|
|
39
|
+
let used = 0;
|
|
40
|
+
for (const word of text.split(/\s+/).filter(Boolean)) {
|
|
41
|
+
const len = word.length;
|
|
42
|
+
if (used === 0) {
|
|
43
|
+
used = len;
|
|
44
|
+
} else if (used + 1 + len <= width) {
|
|
45
|
+
used += 1 + len;
|
|
46
|
+
} else {
|
|
47
|
+
lines++;
|
|
48
|
+
used = len;
|
|
49
|
+
}
|
|
50
|
+
while (used > width) {
|
|
51
|
+
lines++;
|
|
52
|
+
used -= width;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return lines;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Terminal rows one failure row takes at an inner width of `inner`. */
|
|
59
|
+
function rowHeight(row: FailureRow, inner: number): number {
|
|
60
|
+
if (reasonBelow(row.subject, inner)) {
|
|
61
|
+
const column = inner - MARK.length;
|
|
62
|
+
return (
|
|
63
|
+
1 +
|
|
64
|
+
wrappedLines(row.reason, column) +
|
|
65
|
+
(row.fix ? wrappedLines(row.fix, column) : 0)
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
const column = inner - (MARK.length + row.subject.length + 2);
|
|
69
|
+
return (
|
|
70
|
+
wrappedLines(row.reason, column) +
|
|
71
|
+
(row.fix ? wrappedLines(row.fix, column) : 0)
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The rows that fit a terminal `terminalHeight` rows tall, and how many did
|
|
77
|
+
* not. Without a height every row is drawn. A popup taller than the terminal
|
|
78
|
+
* would push its own headline and footer off screen, so it stops early and
|
|
79
|
+
* says `+N more` instead — and always shows at least one row.
|
|
80
|
+
*/
|
|
81
|
+
export function visibleFailureRows(
|
|
82
|
+
report: FailureReport,
|
|
83
|
+
terminalWidth: number,
|
|
84
|
+
terminalHeight?: number,
|
|
85
|
+
): { rows: FailureRow[]; hidden: number } {
|
|
86
|
+
if (terminalHeight === undefined) return { rows: report.rows, hidden: 0 };
|
|
87
|
+
const inner = failureListWidth(terminalWidth) - CHROME;
|
|
88
|
+
const fixRows = report.fix ? 1 + wrappedLines(report.fix, inner) : 0;
|
|
89
|
+
const budget = terminalHeight - FIXED_ROWS - fixRows;
|
|
90
|
+
const total = report.rows.reduce(
|
|
91
|
+
(sum, row) => sum + rowHeight(row, inner),
|
|
92
|
+
0,
|
|
93
|
+
);
|
|
94
|
+
if (total <= budget) return { rows: report.rows, hidden: 0 };
|
|
95
|
+
|
|
96
|
+
// One row is kept back for the "+N more" line.
|
|
97
|
+
const rows: FailureRow[] = [];
|
|
98
|
+
let used = 0;
|
|
99
|
+
for (const row of report.rows) {
|
|
100
|
+
const height = rowHeight(row, inner);
|
|
101
|
+
if (rows.length > 0 && used + height > budget - 1) break;
|
|
102
|
+
rows.push(row);
|
|
103
|
+
used += height;
|
|
104
|
+
}
|
|
105
|
+
return { rows, hidden: report.rows.length - rows.length };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A row's reason and, when it has its own, its fix beneath it. A plain call,
|
|
110
|
+
* not a component, so the whole popup is one element tree.
|
|
111
|
+
*/
|
|
112
|
+
function reasonColumn(row: FailureRow) {
|
|
113
|
+
return (
|
|
114
|
+
<box flexDirection="column" flexGrow={1} flexShrink={1}>
|
|
115
|
+
<text fg={theme.colors.muted} wrapMode="word">
|
|
116
|
+
{row.reason}
|
|
117
|
+
</text>
|
|
118
|
+
{row.fix ? (
|
|
119
|
+
<text fg={theme.colors.text} wrapMode="word">
|
|
120
|
+
{row.fix}
|
|
121
|
+
</text>
|
|
122
|
+
) : null}
|
|
123
|
+
</box>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* THE failure popup: what did not happen, counted; each thing that failed with
|
|
129
|
+
* why; and the one fix, when there is one. The shape is {@link FailureReport},
|
|
130
|
+
* built by a pure function per screen, so every failure reads the same way.
|
|
131
|
+
*
|
|
132
|
+
* Theme tokens only: the ✗ in danger, the subject in the text colour, the
|
|
133
|
+
* reason muted — the reason is the detail, the subject is what the user scans.
|
|
134
|
+
* A fix is in the text colour, under its row when only that row has it, or
|
|
135
|
+
* once under all of them. Reasons wrap; subjects do not. Given the terminal's
|
|
136
|
+
* height, it draws only the rows that fit and ends with `+N more`. Any key
|
|
137
|
+
* dismisses it (ModalContainer).
|
|
138
|
+
*/
|
|
139
|
+
export function FailureListModal({
|
|
140
|
+
report,
|
|
141
|
+
terminalWidth,
|
|
142
|
+
terminalHeight,
|
|
143
|
+
}: {
|
|
144
|
+
report: FailureReport;
|
|
145
|
+
terminalWidth: number;
|
|
146
|
+
/** Cap the rows to this many terminal rows; every row is drawn without it. */
|
|
147
|
+
terminalHeight?: number;
|
|
148
|
+
}) {
|
|
149
|
+
const width = failureListWidth(terminalWidth);
|
|
150
|
+
const inner = width - CHROME;
|
|
151
|
+
const { rows, hidden } = visibleFailureRows(
|
|
152
|
+
report,
|
|
153
|
+
terminalWidth,
|
|
154
|
+
terminalHeight,
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<box
|
|
159
|
+
flexDirection="column"
|
|
160
|
+
border
|
|
161
|
+
borderStyle="rounded"
|
|
162
|
+
borderColor={theme.colors.dim}
|
|
163
|
+
backgroundColor={theme.surface.bg}
|
|
164
|
+
paddingLeft={3}
|
|
165
|
+
paddingRight={3}
|
|
166
|
+
paddingTop={1}
|
|
167
|
+
paddingBottom={1}
|
|
168
|
+
width={width}
|
|
169
|
+
>
|
|
170
|
+
<box marginBottom={1}>
|
|
171
|
+
<text fg={theme.colors.text}>
|
|
172
|
+
<span fg={theme.colors.danger}>{MARK}</span>
|
|
173
|
+
<strong>{report.headline}</strong>
|
|
174
|
+
</text>
|
|
175
|
+
</box>
|
|
176
|
+
|
|
177
|
+
{/*
|
|
178
|
+
* The index is part of the key: a report built with `group: false`
|
|
179
|
+
* keeps rows apart even when subject and reason match, and a repeated
|
|
180
|
+
* key makes React drop or reuse one of them.
|
|
181
|
+
*/}
|
|
182
|
+
{rows.map((row, index) =>
|
|
183
|
+
reasonBelow(row.subject, inner) ? (
|
|
184
|
+
<box
|
|
185
|
+
key={`${row.subject}:${row.reason}:${index}`}
|
|
186
|
+
flexDirection="column"
|
|
187
|
+
>
|
|
188
|
+
<text fg={theme.colors.text}>
|
|
189
|
+
<span fg={theme.colors.danger}>{MARK}</span>
|
|
190
|
+
<span fg={theme.colors.text}>{row.subject}</span>
|
|
191
|
+
</text>
|
|
192
|
+
<box paddingLeft={MARK.length}>{reasonColumn(row)}</box>
|
|
193
|
+
</box>
|
|
194
|
+
) : (
|
|
195
|
+
<box
|
|
196
|
+
key={`${row.subject}:${row.reason}:${index}`}
|
|
197
|
+
flexDirection="row"
|
|
198
|
+
>
|
|
199
|
+
<box flexShrink={0}>
|
|
200
|
+
<text fg={theme.colors.text}>
|
|
201
|
+
<span fg={theme.colors.danger}>{MARK}</span>
|
|
202
|
+
<span fg={theme.colors.text}>{`${row.subject} `}</span>
|
|
203
|
+
</text>
|
|
204
|
+
</box>
|
|
205
|
+
{reasonColumn(row)}
|
|
206
|
+
</box>
|
|
207
|
+
),
|
|
208
|
+
)}
|
|
209
|
+
|
|
210
|
+
{hidden > 0 ? (
|
|
211
|
+
<box paddingLeft={MARK.length}>
|
|
212
|
+
<text fg={theme.colors.muted}>{`+${hidden} more`}</text>
|
|
213
|
+
</box>
|
|
214
|
+
) : null}
|
|
215
|
+
|
|
216
|
+
{report.fix ? (
|
|
217
|
+
<box marginTop={1}>
|
|
218
|
+
<text fg={theme.colors.text} wrapMode="word">
|
|
219
|
+
{report.fix}
|
|
220
|
+
</text>
|
|
221
|
+
</box>
|
|
222
|
+
) : null}
|
|
223
|
+
|
|
224
|
+
<box marginTop={1}>
|
|
225
|
+
<text fg={theme.colors.muted}>Press any key to continue</text>
|
|
226
|
+
</box>
|
|
227
|
+
</box>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export default FailureListModal;
|
|
@@ -6,19 +6,14 @@ interface MessageModalProps {
|
|
|
6
6
|
title: string;
|
|
7
7
|
/** Modal message */
|
|
8
8
|
message: string;
|
|
9
|
-
/** Message variant */
|
|
10
|
-
variant: "info" | "error";
|
|
11
9
|
}
|
|
12
10
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
export function MessageModal({ title, message, variant }: MessageModalProps) {
|
|
20
|
-
const config = variantConfig[variant];
|
|
21
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Draws information to read: help text, a command to run. Never a failure —
|
|
13
|
+
* those go in `FailureListModal`. Any key dismisses it — ModalContainer
|
|
14
|
+
* handles that.
|
|
15
|
+
*/
|
|
16
|
+
export function MessageModal({ title, message }: MessageModalProps) {
|
|
22
17
|
return (
|
|
23
18
|
<box
|
|
24
19
|
flexDirection="column"
|
|
@@ -33,7 +28,7 @@ export function MessageModal({ title, message, variant }: MessageModalProps) {
|
|
|
33
28
|
width={60}
|
|
34
29
|
>
|
|
35
30
|
<box marginBottom={1}>
|
|
36
|
-
<text fg={
|
|
31
|
+
<text fg={theme.colors.info}>ℹ </text>
|
|
37
32
|
<text fg={theme.colors.text}>
|
|
38
33
|
<strong>{title}</strong>
|
|
39
34
|
</text>
|
|
@@ -5,6 +5,7 @@ import { type KeyPress, isScrollKey } from "../../input/key-dispatch.js";
|
|
|
5
5
|
import { useApp, useModal } from "../../state/AppContext.js";
|
|
6
6
|
import { useDimensions } from "../../state/DimensionsContext.js";
|
|
7
7
|
import { ConfirmModal } from "./ConfirmModal.js";
|
|
8
|
+
import { FailureListModal } from "./FailureListModal.js";
|
|
8
9
|
import { HelpOverlay, helpOverlaySize } from "./HelpOverlay.js";
|
|
9
10
|
import { InputModal } from "./InputModal.js";
|
|
10
11
|
import { LoadingModal } from "./LoadingModal.js";
|
|
@@ -37,7 +38,7 @@ const isPlain = (key: KeyPress) => !key.ctrl && !key.meta;
|
|
|
37
38
|
* Only an <input> that was ALREADY focused when the key arrived:
|
|
38
39
|
* the Enter that opens an input modal from a select never reaches
|
|
39
40
|
* it (KeyRouter's `dispatchThenGuardFocus`).
|
|
40
|
-
* - message: any key dismisses.
|
|
41
|
+
* - message, failures: any key dismisses.
|
|
41
42
|
* - loading: keys are swallowed until the work finishes.
|
|
42
43
|
* - help: ↑↓ / PgUp / PgDn / Ctrl+U / Ctrl+D scroll; Esc or `?` close.
|
|
43
44
|
*/
|
|
@@ -102,6 +103,7 @@ export function ModalContainer() {
|
|
|
102
103
|
break;
|
|
103
104
|
|
|
104
105
|
case "message":
|
|
106
|
+
case "failures":
|
|
105
107
|
modal.onDismiss();
|
|
106
108
|
break;
|
|
107
109
|
|
|
@@ -174,11 +176,14 @@ export function ModalContainer() {
|
|
|
174
176
|
);
|
|
175
177
|
|
|
176
178
|
case "message":
|
|
179
|
+
return <MessageModal title={modal.title} message={modal.message} />;
|
|
180
|
+
|
|
181
|
+
case "failures":
|
|
177
182
|
return (
|
|
178
|
-
<
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
183
|
+
<FailureListModal
|
|
184
|
+
report={modal.report}
|
|
185
|
+
terminalWidth={dimensions.terminalWidth}
|
|
186
|
+
terminalHeight={dimensions.terminalHeight}
|
|
182
187
|
/>
|
|
183
188
|
);
|
|
184
189
|
|
|
@@ -2,5 +2,6 @@ export { ConfirmModal } from "./ConfirmModal.js";
|
|
|
2
2
|
export { InputModal } from "./InputModal.js";
|
|
3
3
|
export { SelectModal } from "./SelectModal.js";
|
|
4
4
|
export { MessageModal } from "./MessageModal.js";
|
|
5
|
+
export { FailureListModal } from "./FailureListModal.js";
|
|
5
6
|
export { LoadingModal } from "./LoadingModal.js";
|
|
6
7
|
export { ModalContainer } from "./ModalContainer.js";
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the app shows next at startup, when the modal slot is free — the ONE
|
|
3
|
+
* order every startup popup and question is asked in. Pure, so the order is
|
|
4
|
+
* tested without mounting the app; `App.tsx`'s startup effect acts on it.
|
|
5
|
+
*
|
|
6
|
+
* The order:
|
|
7
|
+
*
|
|
8
|
+
* 1. Failure reports, oldest first. A failure is something that already
|
|
9
|
+
* happened, and the question after it may be answered differently once
|
|
10
|
+
* it is known — so the profile gate's refusals, queued in the same tick as
|
|
11
|
+
* the profile question, are read BEFORE it and never replace it.
|
|
12
|
+
* 2. The gitignore question, once no newer check is pending.
|
|
13
|
+
* 3. The broken-plugins question.
|
|
14
|
+
* 4. The profile question — last, and asked again whenever the slot is free
|
|
15
|
+
* and it has not been answered (it cannot be dismissed).
|
|
16
|
+
*
|
|
17
|
+
* Nothing is shown while any modal is open: two needs arriving in one commit
|
|
18
|
+
* would otherwise both see an empty slot, and the later would replace the
|
|
19
|
+
* earlier after its need was cleared.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { FailureReport } from "../state/types.js";
|
|
23
|
+
|
|
24
|
+
export interface StartupQueueState<ProfileNeed> {
|
|
25
|
+
/** A modal (any modal) is on screen. */
|
|
26
|
+
modalOpen: boolean;
|
|
27
|
+
/** Failure reports not yet shown, oldest first. */
|
|
28
|
+
reports: readonly FailureReport[];
|
|
29
|
+
/** A gitignore answer is waiting and no newer check is running. */
|
|
30
|
+
gitignoreReady: boolean;
|
|
31
|
+
/** Plugins found loading an older version than installed, or null. */
|
|
32
|
+
brokenCount: number | null;
|
|
33
|
+
/** The unanswered profile question, or null. */
|
|
34
|
+
profileNeed: ProfileNeed | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type StartupStep<ProfileNeed> =
|
|
38
|
+
| { kind: "report"; report: FailureReport; rest: FailureReport[] }
|
|
39
|
+
| { kind: "gitignore" }
|
|
40
|
+
| { kind: "stale-load"; brokenCount: number }
|
|
41
|
+
| { kind: "profile"; need: ProfileNeed }
|
|
42
|
+
| null;
|
|
43
|
+
|
|
44
|
+
export function nextStartupStep<ProfileNeed>(
|
|
45
|
+
s: StartupQueueState<ProfileNeed>,
|
|
46
|
+
): StartupStep<ProfileNeed> {
|
|
47
|
+
if (s.modalOpen) return null;
|
|
48
|
+
const [report, ...rest] = s.reports;
|
|
49
|
+
if (report) return { kind: "report", report, rest };
|
|
50
|
+
if (s.gitignoreReady) return { kind: "gitignore" };
|
|
51
|
+
if (s.brokenCount !== null) {
|
|
52
|
+
return { kind: "stale-load", brokenCount: s.brokenCount };
|
|
53
|
+
}
|
|
54
|
+
if (s.profileNeed !== null) return { kind: "profile", need: s.profileNeed };
|
|
55
|
+
return null;
|
|
56
|
+
}
|