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
package/src/ui/App.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useEffect, useState } from "react";
|
|
1
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
2
2
|
import { activePluginPins } from "../services/active-closure.js";
|
|
3
3
|
import { isClaudeAvailable } from "../services/claude-cli.js";
|
|
4
4
|
import {
|
|
@@ -11,10 +11,7 @@ import {
|
|
|
11
11
|
type StartupGitignoreNeed,
|
|
12
12
|
startupGitignoreNeed,
|
|
13
13
|
} from "../services/gitignore-check.js";
|
|
14
|
-
import {
|
|
15
|
-
applyStartupFixes,
|
|
16
|
-
describeStartupFix,
|
|
17
|
-
} from "../services/gitignore-service.js";
|
|
14
|
+
import { applyStartupFixes } from "../services/gitignore-service.js";
|
|
18
15
|
import { repairAllMarketplaces } from "../services/local-marketplace.js";
|
|
19
16
|
import { autoAddMissingMarketplaces } from "../services/marketplace-sync.js";
|
|
20
17
|
import {
|
|
@@ -27,16 +24,20 @@ import {
|
|
|
27
24
|
checkForUpdates,
|
|
28
25
|
getCurrentVersion,
|
|
29
26
|
} from "../services/version-check.js";
|
|
27
|
+
import {
|
|
28
|
+
gitignoreStartupReport,
|
|
29
|
+
marketplaceRefreshReport,
|
|
30
|
+
profileStartupReport,
|
|
31
|
+
staleRepairReport,
|
|
32
|
+
} from "./adapters/startupReports.js";
|
|
30
33
|
import { ScreenErrorBoundary } from "./components/ScreenErrorBoundary.js";
|
|
31
34
|
import { TABS } from "./components/TabBar.js";
|
|
32
35
|
import { ProgressBar } from "./components/layout/ProgressBar.js";
|
|
33
36
|
import { ModalContainer } from "./components/modals/index.js";
|
|
37
|
+
import { nextStartupStep } from "./hooks/startupQueue.js";
|
|
34
38
|
import { useGitignoreModal } from "./hooks/useGitignoreModal.js";
|
|
35
39
|
import { useProfileStartupModal } from "./hooks/useProfileStartupModal.js";
|
|
36
|
-
import {
|
|
37
|
-
staleLoadRepairSummary,
|
|
38
|
-
useStaleLoadModal,
|
|
39
|
-
} from "./hooks/useStaleLoadModal.js";
|
|
40
|
+
import { useStaleLoadModal } from "./hooks/useStaleLoadModal.js";
|
|
40
41
|
import { KeyRouter, useKeyLayer } from "./input/KeyRouter.js";
|
|
41
42
|
import { type GlobalHandlers, runGlobalBindings } from "./keymap.js";
|
|
42
43
|
import {
|
|
@@ -62,7 +63,7 @@ import {
|
|
|
62
63
|
DimensionsProvider,
|
|
63
64
|
useDimensions,
|
|
64
65
|
} from "./state/DimensionsContext.js";
|
|
65
|
-
import type { Screen } from "./state/types.js";
|
|
66
|
+
import type { FailureReport, Screen } from "./state/types.js";
|
|
66
67
|
import { theme } from "./theme.js";
|
|
67
68
|
|
|
68
69
|
export const VERSION = getCurrentVersion();
|
|
@@ -207,17 +208,9 @@ function ProgressIndicator({
|
|
|
207
208
|
interface AppContentInnerProps {
|
|
208
209
|
updateInfo: VersionCheckResult | null;
|
|
209
210
|
onExit: () => void;
|
|
210
|
-
recoveryReport: string | null;
|
|
211
|
-
/** What the startup profile gate did: absorbed edits, regeneration, repairs. */
|
|
212
|
-
profileNotices: string[];
|
|
213
211
|
}
|
|
214
212
|
|
|
215
|
-
function AppContentInner({
|
|
216
|
-
updateInfo,
|
|
217
|
-
onExit,
|
|
218
|
-
recoveryReport,
|
|
219
|
-
profileNotices,
|
|
220
|
-
}: AppContentInnerProps) {
|
|
213
|
+
function AppContentInner({ updateInfo, onExit }: AppContentInnerProps) {
|
|
221
214
|
const { state } = useApp();
|
|
222
215
|
const { progress } = state;
|
|
223
216
|
const dimensions = useDimensions();
|
|
@@ -225,16 +218,6 @@ function AppContentInner({
|
|
|
225
218
|
return (
|
|
226
219
|
<box flexDirection="column" height={dimensions.terminalHeight}>
|
|
227
220
|
{updateInfo?.updateAvailable && <UpdateBanner result={updateInfo} />}
|
|
228
|
-
{recoveryReport && (
|
|
229
|
-
<box paddingLeft={1} paddingRight={1}>
|
|
230
|
-
<text fg={theme.colors.success}>✓ Fixed: {recoveryReport}</text>
|
|
231
|
-
</box>
|
|
232
|
-
)}
|
|
233
|
-
{profileNotices.map((line) => (
|
|
234
|
-
<box key={line} paddingLeft={1} paddingRight={1}>
|
|
235
|
-
<text fg={theme.colors.success}>• {line}</text>
|
|
236
|
-
</box>
|
|
237
|
-
))}
|
|
238
221
|
{progress && <ProgressIndicator {...progress} />}
|
|
239
222
|
<box
|
|
240
223
|
flexDirection="column"
|
|
@@ -250,6 +233,27 @@ function AppContentInner({
|
|
|
250
233
|
);
|
|
251
234
|
}
|
|
252
235
|
|
|
236
|
+
/** A gitignore check's answer, waiting for the modal slot. */
|
|
237
|
+
interface QueuedGitignore {
|
|
238
|
+
need: StartupGitignoreNeed;
|
|
239
|
+
/** The `gitignoreCheckRequest` it answers. */
|
|
240
|
+
request: number;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The gitignore check's bookkeeping. A ref, not state: a landing check reads
|
|
245
|
+
* the latest request and what was last shown at that moment, not as of the
|
|
246
|
+
* render that started it.
|
|
247
|
+
*/
|
|
248
|
+
interface GitignoreCheck {
|
|
249
|
+
/** A check is running. There is never more than one. */
|
|
250
|
+
inFlight: boolean;
|
|
251
|
+
/** The newest `gitignoreCheckRequest` seen. */
|
|
252
|
+
latest: number;
|
|
253
|
+
/** The request whose answer was last shown as a question; -1 for none. */
|
|
254
|
+
shown: number;
|
|
255
|
+
}
|
|
256
|
+
|
|
253
257
|
/**
|
|
254
258
|
* AppContent Component
|
|
255
259
|
* Wraps app with DimensionsProvider and manages state for debug/updates
|
|
@@ -262,38 +266,55 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
262
266
|
const { state, dispatch } = useApp();
|
|
263
267
|
const { progress } = state;
|
|
264
268
|
const [updateInfo, setUpdateInfo] = useState<VersionCheckResult | null>(null);
|
|
265
|
-
const [
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
+
const [gitignoreData, setGitignoreData] = useState<QueuedGitignore | null>(
|
|
270
|
+
null,
|
|
271
|
+
);
|
|
272
|
+
// The last request whose check has landed. Behind the latest request, a
|
|
273
|
+
// check is pending, and `gitignoreData` — if any — is an answer to an
|
|
274
|
+
// earlier question about a project that has since changed.
|
|
275
|
+
const [gitignoreSettled, setGitignoreSettled] = useState(-1);
|
|
276
|
+
const gitignorePending = gitignoreSettled < state.gitignoreCheckRequest;
|
|
277
|
+
const gitignoreCheck = useRef<GitignoreCheck>({
|
|
278
|
+
inFlight: false,
|
|
279
|
+
latest: 0,
|
|
280
|
+
shown: -1,
|
|
281
|
+
});
|
|
269
282
|
const [profileNeed, setProfileNeed] = useState<ProfileStartupNeed | null>(
|
|
270
283
|
null,
|
|
271
284
|
);
|
|
285
|
+
// What startup work could not do — the profile gate's refusals and its
|
|
286
|
+
// activations, a marketplace refresh — waiting for the modal slot, oldest
|
|
287
|
+
// first. Each is the one failure popup; the TUI says nothing else.
|
|
288
|
+
const [startupReports, setStartupReports] = useState<FailureReport[]>([]);
|
|
289
|
+
const queueReports = React.useCallback(
|
|
290
|
+
(...reports: Array<FailureReport | null>) => {
|
|
291
|
+
const present = reports.filter((r): r is FailureReport => r !== null);
|
|
292
|
+
if (present.length > 0) {
|
|
293
|
+
setStartupReports((queued) => [...queued, ...present]);
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
[],
|
|
297
|
+
);
|
|
272
298
|
const gitignoreModal = useGitignoreModal();
|
|
273
299
|
const [brokenPluginCount, setBrokenPluginCount] = useState<number | null>(
|
|
274
300
|
null,
|
|
275
301
|
);
|
|
276
302
|
const staleLoadModal = useStaleLoadModal();
|
|
277
|
-
// The gitignore question, asked fresh: on every open, and again once the
|
|
278
|
-
// profile modal has activated a profile — a first adoption is what turns a
|
|
279
|
-
// committed settings.json into a generated file git should not track.
|
|
280
|
-
// Nothing remembers an earlier answer.
|
|
281
|
-
const checkGitignoreNow = React.useCallback(() => {
|
|
282
|
-
startupGitignoreNeed(process.cwd())
|
|
283
|
-
.then((need) => {
|
|
284
|
-
if (need) setGitignoreData(need);
|
|
285
|
-
})
|
|
286
|
-
.catch(() => {
|
|
287
|
-
// non-fatal: the Git State tab still shows the same rows
|
|
288
|
-
});
|
|
289
|
-
}, []);
|
|
290
303
|
// Stable, so `profileModal.show` is not rebuilt for it on every render.
|
|
291
304
|
const profileAnswered = React.useCallback(() => setProfileNeed(null), []);
|
|
305
|
+
// Activating a profile changes which plugins are on, so the open screen must
|
|
306
|
+
// reload. The result modal that used to follow sent the user to the Profiles
|
|
307
|
+
// tab, which remounted it; without that modal the user stays where they are,
|
|
308
|
+
// and the screen under them would keep showing the pre-activation state.
|
|
309
|
+
// The same action re-runs the gitignore check — see the effect below.
|
|
310
|
+
const profileActivated = React.useCallback(() => {
|
|
311
|
+
dispatch({ type: "PROFILE_ACTIVATED" });
|
|
312
|
+
}, [dispatch]);
|
|
292
313
|
// The profile modal cannot be dismissed, so it needs the app's own exit: its
|
|
293
314
|
// only non-action is to leave.
|
|
294
315
|
const profileModal = useProfileStartupModal({
|
|
295
316
|
onQuit: onExit,
|
|
296
|
-
onActivated:
|
|
317
|
+
onActivated: profileActivated,
|
|
297
318
|
onAnswered: profileAnswered,
|
|
298
319
|
});
|
|
299
320
|
|
|
@@ -315,21 +336,6 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
315
336
|
return () => clearInterval(id);
|
|
316
337
|
}, []);
|
|
317
338
|
|
|
318
|
-
// Auto-dismiss the profile gate's notices after 10 seconds — long enough to
|
|
319
|
-
// read a line per absorbed change.
|
|
320
|
-
useEffect(() => {
|
|
321
|
-
if (profileNotices.length === 0) return;
|
|
322
|
-
const timer = setTimeout(() => setProfileNotices([]), 10000);
|
|
323
|
-
return () => clearTimeout(timer);
|
|
324
|
-
}, [profileNotices]);
|
|
325
|
-
|
|
326
|
-
// Auto-dismiss recovery banner after 5 seconds
|
|
327
|
-
useEffect(() => {
|
|
328
|
-
if (!recoveryReport) return;
|
|
329
|
-
const timer = setTimeout(() => setRecoveryReport(null), 5000);
|
|
330
|
-
return () => clearTimeout(timer);
|
|
331
|
-
}, [recoveryReport]);
|
|
332
|
-
|
|
333
339
|
// The startup questions — gitignore, then broken plugins, then profile — are
|
|
334
340
|
// asked ONE at a time, from ONE effect, and only while the modal slot is free.
|
|
335
341
|
//
|
|
@@ -339,12 +345,13 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
339
345
|
// need had already been cleared — so it never came back. The profile question
|
|
340
346
|
// was lost exactly that way, and the user updated plugins believing a profile
|
|
341
347
|
// was active. So: show at most one per run and return; the next question
|
|
342
|
-
// follows once that one's
|
|
343
|
-
// again.
|
|
348
|
+
// follows once that one's flow has ended and the slot is empty again.
|
|
344
349
|
//
|
|
345
350
|
// The hooks keep the slot occupied for their whole flow (a loading modal
|
|
346
351
|
// while their fix runs, never HIDE_MODAL mid-flow), because an empty slot is
|
|
347
|
-
// what lets the next question in.
|
|
352
|
+
// what lets the next question in. A flow that succeeds ends by hiding its
|
|
353
|
+
// loading modal and reports nothing: the screen it leaves shows the result.
|
|
354
|
+
// That does not stop a SCREEN from emptying
|
|
348
355
|
// it: `useModal().confirm` hides its modal when answered and the screen shows
|
|
349
356
|
// its loading modal a microtask later. A key pressed before the profile check
|
|
350
357
|
// resolved lands the profile question in exactly that gap, and the screen's
|
|
@@ -363,24 +370,60 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
363
370
|
// re-showing would re-ask a question the user just declined. Losing one
|
|
364
371
|
// costs nothing lasting: the Git State and Plugins tabs show the same rows,
|
|
365
372
|
// and gitignore is asked again after a profile activates.
|
|
373
|
+
//
|
|
374
|
+
// The gitignore question also waits while a check is pending. An activation
|
|
375
|
+
// both empties the slot and asks for a fresh check, so the slot is free
|
|
376
|
+
// while an answer from BEFORE the activation may still be queued: showing
|
|
377
|
+
// it and then the fresh one asked the same question twice. The fresh result
|
|
378
|
+
// replaces the queued one instead (see the check effect below).
|
|
366
379
|
useEffect(() => {
|
|
367
|
-
|
|
368
|
-
|
|
380
|
+
// The order lives in `nextStartupStep`, where it is tested: failure
|
|
381
|
+
// reports first — something that already happened, and the question
|
|
382
|
+
// after it may be answered differently once it is known — so the profile
|
|
383
|
+
// gate's refusals land before the profile question, which they do not
|
|
384
|
+
// replace; then gitignore, broken plugins, and the profile last.
|
|
385
|
+
const step = nextStartupStep({
|
|
386
|
+
modalOpen: !!state.modal,
|
|
387
|
+
reports: startupReports,
|
|
388
|
+
gitignoreReady: !!gitignoreData && !gitignorePending,
|
|
389
|
+
brokenCount: brokenPluginCount,
|
|
390
|
+
profileNeed,
|
|
391
|
+
});
|
|
392
|
+
if (!step) return;
|
|
393
|
+
if (step.kind === "report") {
|
|
394
|
+
setStartupReports(step.rest);
|
|
395
|
+
dispatch({
|
|
396
|
+
type: "SHOW_MODAL",
|
|
397
|
+
modal: {
|
|
398
|
+
type: "failures",
|
|
399
|
+
report: step.report,
|
|
400
|
+
onDismiss: () => dispatch({ type: "HIDE_MODAL" }),
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
if (step.kind === "gitignore" && gitignoreData) {
|
|
406
|
+
// Anything older than what is now on screen is dropped on arrival.
|
|
407
|
+
gitignoreCheck.current.shown = gitignoreData.request;
|
|
408
|
+
const need = gitignoreData.need;
|
|
369
409
|
gitignoreModal.show({
|
|
370
|
-
violationCount:
|
|
371
|
-
safeCount:
|
|
372
|
-
generated:
|
|
410
|
+
violationCount: need.violationCount,
|
|
411
|
+
safeCount: need.safeCount,
|
|
412
|
+
generated: need.generated,
|
|
373
413
|
// In place, then back to the app: tracked generated files are
|
|
374
414
|
// untracked, missing entries added; other items stay for the tab.
|
|
415
|
+
// A file git would not untrack is named with git's reason.
|
|
375
416
|
onFixSafe: async () =>
|
|
376
|
-
|
|
417
|
+
gitignoreStartupReport(
|
|
418
|
+
(await applyStartupFixes(state.projectPath)).failed,
|
|
419
|
+
),
|
|
377
420
|
});
|
|
378
421
|
setGitignoreData(null);
|
|
379
422
|
return;
|
|
380
423
|
}
|
|
381
|
-
if (
|
|
424
|
+
if (step.kind === "stale-load") {
|
|
382
425
|
staleLoadModal.show({
|
|
383
|
-
brokenCount:
|
|
426
|
+
brokenCount: step.brokenCount,
|
|
384
427
|
// Re-surveys before repairing, like the gitignore fixer: the registry
|
|
385
428
|
// may have moved between the startup scan and the user pressing Enter,
|
|
386
429
|
// and repairing from the stale count would act on rows that are no
|
|
@@ -390,8 +433,10 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
390
433
|
// The pins are re-read with it, from the same helper the survey used: a
|
|
391
434
|
// repair that capped at a different ceiling than the offer was counted
|
|
392
435
|
// against would move rows the dialog never mentioned.
|
|
436
|
+
//
|
|
437
|
+
// A repair that did not take is named, with its reason.
|
|
393
438
|
onFix: async () =>
|
|
394
|
-
|
|
439
|
+
staleRepairReport(
|
|
395
440
|
await repairAllStaleLoads(
|
|
396
441
|
state.projectPath,
|
|
397
442
|
undefined,
|
|
@@ -404,11 +449,14 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
404
449
|
}
|
|
405
450
|
// Not cleared here — see above. Re-called on every empty slot until the
|
|
406
451
|
// hook reports it answered.
|
|
407
|
-
if (
|
|
452
|
+
if (step.kind === "profile") profileModal.show(step.need);
|
|
408
453
|
}, [
|
|
409
454
|
state.modal,
|
|
410
455
|
state.projectPath,
|
|
456
|
+
dispatch,
|
|
457
|
+
startupReports,
|
|
411
458
|
gitignoreData,
|
|
459
|
+
gitignorePending,
|
|
412
460
|
gitignoreModal,
|
|
413
461
|
brokenPluginCount,
|
|
414
462
|
staleLoadModal,
|
|
@@ -439,12 +487,46 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
439
487
|
})();
|
|
440
488
|
}, []);
|
|
441
489
|
|
|
442
|
-
// The gitignore question on every open
|
|
443
|
-
//
|
|
444
|
-
//
|
|
490
|
+
// The gitignore question, asked fresh: on every open (request 0), and again
|
|
491
|
+
// on each PROFILE_ACTIVATED — a first adoption is what turns a committed
|
|
492
|
+
// settings.json into a generated file git should not track, and a
|
|
493
|
+
// Profiles-tab switch can do the same. Nothing remembers an earlier answer.
|
|
494
|
+
//
|
|
495
|
+
// At most ONE check runs at a time. A request that arrives while one runs
|
|
496
|
+
// is not started beside it: the running check, on landing, sees that it is
|
|
497
|
+
// no longer the latest, throws its answer away — it describes the project
|
|
498
|
+
// before whatever prompted the request — and runs again. Only the latest
|
|
499
|
+
// request's answer is kept, and it REPLACES anything still queued, including
|
|
500
|
+
// with nothing when the project no longer needs the question.
|
|
501
|
+
//
|
|
502
|
+
// Its own effect, NOT part of the marketplace block below, which returns
|
|
503
|
+
// early on `--no-refresh`: that flag is about network calls and must not
|
|
504
|
+
// silence this question.
|
|
445
505
|
useEffect(() => {
|
|
446
|
-
|
|
447
|
-
|
|
506
|
+
const check = gitignoreCheck.current;
|
|
507
|
+
check.latest = state.gitignoreCheckRequest;
|
|
508
|
+
if (check.inFlight) return;
|
|
509
|
+
const run = (request: number): void => {
|
|
510
|
+
check.inFlight = true;
|
|
511
|
+
startupGitignoreNeed(process.cwd())
|
|
512
|
+
// non-fatal: the Git State tab still shows the same rows
|
|
513
|
+
.catch(() => null)
|
|
514
|
+
.then((need) => {
|
|
515
|
+
if (request !== check.latest) {
|
|
516
|
+
run(check.latest);
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
check.inFlight = false;
|
|
520
|
+
setGitignoreSettled(request);
|
|
521
|
+
// Defence in depth: an answer to a request no newer than the
|
|
522
|
+
// question last shown predates the user's reply to it.
|
|
523
|
+
setGitignoreData(
|
|
524
|
+
need && request > check.shown ? { need, request } : null,
|
|
525
|
+
);
|
|
526
|
+
});
|
|
527
|
+
};
|
|
528
|
+
run(check.latest);
|
|
529
|
+
}, [state.gitignoreCheckRequest]);
|
|
448
530
|
|
|
449
531
|
// Is this project actually running a profile? Detection only — nothing is
|
|
450
532
|
// written until the user picks something in the modal. The Profiles tab could
|
|
@@ -462,19 +544,21 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
462
544
|
// edits into the active profile, regenerate from a changed
|
|
463
545
|
// profiles.json, restore a lost marker — then the question, if one
|
|
464
546
|
// is left to ask.
|
|
465
|
-
|
|
547
|
+
// A refusal never blocks the TUI at startup: the user is still asked
|
|
548
|
+
// whatever is left, and the screens show the state as it is. What
|
|
549
|
+
// the gate refused, and what its re-render could not do, go into ONE
|
|
550
|
+
// failure popup first — one step, one popup — queued in the same
|
|
551
|
+
// tick as the question, so it is shown before it.
|
|
552
|
+
const { need, problems, activations } = await prepareProfileStartup(
|
|
466
553
|
process.cwd(),
|
|
467
554
|
);
|
|
468
|
-
|
|
469
|
-
// the gate did, and the user is still asked whatever is left.
|
|
470
|
-
const shown = [...notices, ...problems];
|
|
471
|
-
if (shown.length > 0) setProfileNotices(shown);
|
|
555
|
+
queueReports(profileStartupReport(problems, activations ?? []));
|
|
472
556
|
if (!needsNothing(need)) setProfileNeed(need);
|
|
473
557
|
} catch {
|
|
474
558
|
// non-fatal: the Profiles tab still works by hand
|
|
475
559
|
}
|
|
476
560
|
})();
|
|
477
|
-
}, []);
|
|
561
|
+
}, [queueReports]);
|
|
478
562
|
|
|
479
563
|
// Auto-refresh marketplaces on startup
|
|
480
564
|
useEffect(() => {
|
|
@@ -496,41 +580,29 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
496
580
|
(async () => {
|
|
497
581
|
try {
|
|
498
582
|
if (!(await isClaudeAvailable())) return;
|
|
499
|
-
|
|
500
|
-
if (added.length > 0) {
|
|
501
|
-
setRecoveryReport(`added marketplace(s): ${added.join(", ")}`);
|
|
502
|
-
}
|
|
583
|
+
await autoAddMissingMarketplaces(process.cwd());
|
|
503
584
|
} catch {
|
|
504
585
|
// non-fatal: user can still install marketplaces manually
|
|
505
586
|
}
|
|
506
587
|
})();
|
|
507
588
|
|
|
508
|
-
// Recover stale marketplace registry entries (e.g. "directory" -> "github")
|
|
589
|
+
// Recover stale marketplace registry entries (e.g. "directory" -> "github"),
|
|
590
|
+
// then refresh the clones whose source that fixed. A refresh that fails
|
|
591
|
+
// leaves that marketplace's plugins read from an old copy, so it is named
|
|
592
|
+
// in the failure popup with why.
|
|
509
593
|
recoverMarketplaceSettings()
|
|
510
594
|
.then(async (recovery) => {
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
try {
|
|
519
|
-
await updateMarketplace(mp);
|
|
520
|
-
parts.push(`${mp} refreshed`);
|
|
521
|
-
} catch {
|
|
522
|
-
parts.push(`${mp} (update failed)`);
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
if (recovery.removed.length > 0) {
|
|
527
|
-
parts.push(`removed: ${recovery.removed.join(", ")}`);
|
|
528
|
-
}
|
|
529
|
-
if (parts.length > 0) {
|
|
530
|
-
setRecoveryReport(parts.join(" | "));
|
|
595
|
+
if (recovery.reregistered.length === 0) return;
|
|
596
|
+
const { updateMarketplace } = await import("../services/claude-cli.js");
|
|
597
|
+
const failed: Array<{ marketplace: string; error: unknown }> = [];
|
|
598
|
+
for (const mp of recovery.reregistered) {
|
|
599
|
+
await updateMarketplace(mp).catch((error: unknown) => {
|
|
600
|
+
failed.push({ marketplace: mp, error });
|
|
601
|
+
});
|
|
531
602
|
}
|
|
603
|
+
queueReports(marketplaceRefreshReport(failed));
|
|
532
604
|
})
|
|
533
|
-
.catch(() => {}); // non-fatal
|
|
605
|
+
.catch(() => {}); // the recovery itself: non-fatal
|
|
534
606
|
|
|
535
607
|
repairAllMarketplaces()
|
|
536
608
|
.then(async () => {
|
|
@@ -540,23 +612,14 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
540
612
|
.catch(() => {
|
|
541
613
|
dispatch({ type: "HIDE_PROGRESS" });
|
|
542
614
|
});
|
|
543
|
-
}, [dispatch]);
|
|
544
|
-
|
|
545
|
-
// Count transient banners for dimension calculation
|
|
546
|
-
const transientBannerCount = recoveryReport ? 1 : 0;
|
|
615
|
+
}, [dispatch, queueReports]);
|
|
547
616
|
|
|
548
617
|
return (
|
|
549
618
|
<DimensionsProvider
|
|
550
619
|
showProgress={!!progress}
|
|
551
620
|
showUpdateBanner={!!updateInfo?.updateAvailable}
|
|
552
|
-
transientBannerCount={transientBannerCount}
|
|
553
621
|
>
|
|
554
|
-
<AppContentInner
|
|
555
|
-
updateInfo={updateInfo}
|
|
556
|
-
onExit={onExit}
|
|
557
|
-
recoveryReport={recoveryReport}
|
|
558
|
-
profileNotices={profileNotices}
|
|
559
|
-
/>
|
|
622
|
+
<AppContentInner updateInfo={updateInfo} onExit={onExit} />
|
|
560
623
|
</DimensionsProvider>
|
|
561
624
|
);
|
|
562
625
|
}
|