magus-cli 7.6.0 → 7.7.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__/format-update-outcome.test.ts +9 -8
- package/src/__tests__/gitignore-profile.test.ts +9 -5
- package/src/__tests__/notices-rules.test.ts +476 -0
- package/src/__tests__/notices.test.tsx +604 -0
- package/src/__tests__/update-apply.test.ts +3 -3
- package/src/__tests__/useStaleLoadModal.test.ts +27 -12
- package/src/services/gitignore-service.ts +0 -19
- package/src/services/profile-absorb.ts +9 -0
- package/src/services/profile-activate.ts +2 -1
- package/src/services/profile-startup.ts +45 -17
- package/src/services/profile-state.ts +9 -0
- package/src/ui/App.tsx +216 -97
- package/src/ui/adapters/pluginsAdapter.ts +28 -9
- package/src/ui/components/modals/MessageModal.tsx +1 -2
- package/src/ui/hooks/useGitignoreModal.ts +45 -23
- package/src/ui/hooks/useProfileStartupModal.ts +112 -84
- package/src/ui/hooks/useStaleLoadModal.ts +38 -28
- package/src/ui/screens/AliasScreen.tsx +7 -6
- package/src/ui/screens/CliToolsScreen.tsx +19 -16
- package/src/ui/screens/EnvVarsScreen.tsx +4 -1
- package/src/ui/screens/GitignoreScreen.tsx +20 -27
- package/src/ui/screens/McpRegistryScreen.tsx +9 -6
- package/src/ui/screens/McpScreen.tsx +16 -12
- package/src/ui/screens/PluginsScreen.tsx +35 -56
- package/src/ui/screens/ProfilesScreen.tsx +27 -51
- package/src/ui/state/AppContext.tsx +46 -3
- package/src/ui/state/reducer.ts +100 -1
- package/src/ui/state/types.ts +53 -2
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 {
|
|
@@ -31,10 +28,16 @@ import { ScreenErrorBoundary } from "./components/ScreenErrorBoundary.js";
|
|
|
31
28
|
import { TABS } from "./components/TabBar.js";
|
|
32
29
|
import { ProgressBar } from "./components/layout/ProgressBar.js";
|
|
33
30
|
import { ModalContainer } from "./components/modals/index.js";
|
|
34
|
-
import { useGitignoreModal } from "./hooks/useGitignoreModal.js";
|
|
35
|
-
import { useProfileStartupModal } from "./hooks/useProfileStartupModal.js";
|
|
36
31
|
import {
|
|
37
|
-
|
|
32
|
+
startupFixNotices,
|
|
33
|
+
useGitignoreModal,
|
|
34
|
+
} from "./hooks/useGitignoreModal.js";
|
|
35
|
+
import {
|
|
36
|
+
activationWarnings,
|
|
37
|
+
useProfileStartupModal,
|
|
38
|
+
} from "./hooks/useProfileStartupModal.js";
|
|
39
|
+
import {
|
|
40
|
+
staleLoadRepairNotices,
|
|
38
41
|
useStaleLoadModal,
|
|
39
42
|
} from "./hooks/useStaleLoadModal.js";
|
|
40
43
|
import { KeyRouter, useKeyLayer } from "./input/KeyRouter.js";
|
|
@@ -57,12 +60,14 @@ import {
|
|
|
57
60
|
useApp,
|
|
58
61
|
useModal,
|
|
59
62
|
useNavigation,
|
|
63
|
+
useNotify,
|
|
60
64
|
} from "./state/AppContext.js";
|
|
61
65
|
import {
|
|
62
66
|
DimensionsProvider,
|
|
63
67
|
useDimensions,
|
|
64
68
|
} from "./state/DimensionsContext.js";
|
|
65
|
-
import type { Screen } from "./state/types.js";
|
|
69
|
+
import type { Notice, Screen } from "./state/types.js";
|
|
70
|
+
import { truncate } from "./text-width.js";
|
|
66
71
|
import { theme } from "./theme.js";
|
|
67
72
|
|
|
68
73
|
export const VERSION = getCurrentVersion();
|
|
@@ -180,6 +185,72 @@ function UpdateBanner({ result }: { result: VersionCheckResult }) {
|
|
|
180
185
|
);
|
|
181
186
|
}
|
|
182
187
|
|
|
188
|
+
/**
|
|
189
|
+
* NoticeBanner Component
|
|
190
|
+
* The app's one notice line (or a few): what an action did, from `useNotify`.
|
|
191
|
+
* It sits above the screen and outside the modal slot, so it never takes a key
|
|
192
|
+
* and never stands in the way of a startup question. Each line clears itself
|
|
193
|
+
* at its `expiresAt` — counting only time with no modal open.
|
|
194
|
+
*
|
|
195
|
+
* One notice is exactly ONE row: the layout reserves one row per notice
|
|
196
|
+
* (`transientBannerCount`), so a line that wrapped would push the screen's
|
|
197
|
+
* last rows off the terminal. Hence no wrapping, and the text clipped to the
|
|
198
|
+
* width — the start of a line says what happened, and that is what survives.
|
|
199
|
+
*/
|
|
200
|
+
function NoticeBanner({ notices }: { notices: readonly Notice[] }) {
|
|
201
|
+
const { state, dispatch } = useApp();
|
|
202
|
+
const { terminalWidth } = useDimensions();
|
|
203
|
+
const modalOpen = state.modal !== null;
|
|
204
|
+
const paused = state.noticesPausedAt !== null;
|
|
205
|
+
|
|
206
|
+
// Hold the notices while a modal is open. A warning posted just before a
|
|
207
|
+
// startup question, or under it, would otherwise run down behind it and be
|
|
208
|
+
// gone by the time the user has answered.
|
|
209
|
+
useEffect(() => {
|
|
210
|
+
dispatch({
|
|
211
|
+
type: modalOpen ? "PAUSE_NOTICES" : "RESUME_NOTICES",
|
|
212
|
+
now: Date.now(),
|
|
213
|
+
});
|
|
214
|
+
}, [modalOpen, dispatch]);
|
|
215
|
+
|
|
216
|
+
// One timer, for the line that expires first; the reducer then drops every
|
|
217
|
+
// line past its time, and the next render schedules the one after. None
|
|
218
|
+
// while paused: RESUME_NOTICES moves every `expiresAt` on first.
|
|
219
|
+
useEffect(() => {
|
|
220
|
+
if (notices.length === 0 || paused) return;
|
|
221
|
+
const next = Math.min(...notices.map((n) => n.expiresAt));
|
|
222
|
+
const timer = setTimeout(
|
|
223
|
+
() => dispatch({ type: "EXPIRE_NOTICES", now: Date.now() }),
|
|
224
|
+
Math.max(0, next - Date.now()),
|
|
225
|
+
);
|
|
226
|
+
return () => clearTimeout(timer);
|
|
227
|
+
}, [notices, paused, dispatch]);
|
|
228
|
+
|
|
229
|
+
// The box's left and right padding.
|
|
230
|
+
const width = Math.max(0, terminalWidth - 2);
|
|
231
|
+
return (
|
|
232
|
+
<>
|
|
233
|
+
{notices.map((notice) => (
|
|
234
|
+
<box key={notice.id} paddingLeft={1} paddingRight={1} height={1}>
|
|
235
|
+
<text
|
|
236
|
+
wrapMode="none"
|
|
237
|
+
fg={
|
|
238
|
+
notice.tone === "warning"
|
|
239
|
+
? theme.colors.warning
|
|
240
|
+
: theme.colors.success
|
|
241
|
+
}
|
|
242
|
+
>
|
|
243
|
+
{truncate(
|
|
244
|
+
`${notice.tone === "warning" ? "⚠" : "✓"} ${notice.text}`,
|
|
245
|
+
width,
|
|
246
|
+
)}
|
|
247
|
+
</text>
|
|
248
|
+
</box>
|
|
249
|
+
))}
|
|
250
|
+
</>
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
183
254
|
/**
|
|
184
255
|
* ProgressIndicator Component
|
|
185
256
|
* Shows progress bar when operations are running
|
|
@@ -207,34 +278,17 @@ function ProgressIndicator({
|
|
|
207
278
|
interface AppContentInnerProps {
|
|
208
279
|
updateInfo: VersionCheckResult | null;
|
|
209
280
|
onExit: () => void;
|
|
210
|
-
recoveryReport: string | null;
|
|
211
|
-
/** What the startup profile gate did: absorbed edits, regeneration, repairs. */
|
|
212
|
-
profileNotices: string[];
|
|
213
281
|
}
|
|
214
282
|
|
|
215
|
-
function AppContentInner({
|
|
216
|
-
updateInfo,
|
|
217
|
-
onExit,
|
|
218
|
-
recoveryReport,
|
|
219
|
-
profileNotices,
|
|
220
|
-
}: AppContentInnerProps) {
|
|
283
|
+
function AppContentInner({ updateInfo, onExit }: AppContentInnerProps) {
|
|
221
284
|
const { state } = useApp();
|
|
222
|
-
const { progress } = state;
|
|
285
|
+
const { progress, notices } = state;
|
|
223
286
|
const dimensions = useDimensions();
|
|
224
287
|
|
|
225
288
|
return (
|
|
226
289
|
<box flexDirection="column" height={dimensions.terminalHeight}>
|
|
227
290
|
{updateInfo?.updateAvailable && <UpdateBanner result={updateInfo} />}
|
|
228
|
-
{
|
|
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
|
-
))}
|
|
291
|
+
<NoticeBanner notices={notices} />
|
|
238
292
|
{progress && <ProgressIndicator {...progress} />}
|
|
239
293
|
<box
|
|
240
294
|
flexDirection="column"
|
|
@@ -250,6 +304,27 @@ function AppContentInner({
|
|
|
250
304
|
);
|
|
251
305
|
}
|
|
252
306
|
|
|
307
|
+
/** A gitignore check's answer, waiting for the modal slot. */
|
|
308
|
+
interface QueuedGitignore {
|
|
309
|
+
need: StartupGitignoreNeed;
|
|
310
|
+
/** The `gitignoreCheckRequest` it answers. */
|
|
311
|
+
request: number;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The gitignore check's bookkeeping. A ref, not state: a landing check reads
|
|
316
|
+
* the latest request and what was last shown at that moment, not as of the
|
|
317
|
+
* render that started it.
|
|
318
|
+
*/
|
|
319
|
+
interface GitignoreCheck {
|
|
320
|
+
/** A check is running. There is never more than one. */
|
|
321
|
+
inFlight: boolean;
|
|
322
|
+
/** The newest `gitignoreCheckRequest` seen. */
|
|
323
|
+
latest: number;
|
|
324
|
+
/** The request whose answer was last shown as a question; -1 for none. */
|
|
325
|
+
shown: number;
|
|
326
|
+
}
|
|
327
|
+
|
|
253
328
|
/**
|
|
254
329
|
* AppContent Component
|
|
255
330
|
* Wraps app with DimensionsProvider and manages state for debug/updates
|
|
@@ -261,11 +336,21 @@ interface AppContentProps {
|
|
|
261
336
|
function AppContent({ onExit }: AppContentProps) {
|
|
262
337
|
const { state, dispatch } = useApp();
|
|
263
338
|
const { progress } = state;
|
|
339
|
+
const notify = useNotify();
|
|
264
340
|
const [updateInfo, setUpdateInfo] = useState<VersionCheckResult | null>(null);
|
|
265
|
-
const [
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
341
|
+
const [gitignoreData, setGitignoreData] = useState<QueuedGitignore | null>(
|
|
342
|
+
null,
|
|
343
|
+
);
|
|
344
|
+
// The last request whose check has landed. Behind the latest request, a
|
|
345
|
+
// check is pending, and `gitignoreData` — if any — is an answer to an
|
|
346
|
+
// earlier question about a project that has since changed.
|
|
347
|
+
const [gitignoreSettled, setGitignoreSettled] = useState(-1);
|
|
348
|
+
const gitignorePending = gitignoreSettled < state.gitignoreCheckRequest;
|
|
349
|
+
const gitignoreCheck = useRef<GitignoreCheck>({
|
|
350
|
+
inFlight: false,
|
|
351
|
+
latest: 0,
|
|
352
|
+
shown: -1,
|
|
353
|
+
});
|
|
269
354
|
const [profileNeed, setProfileNeed] = useState<ProfileStartupNeed | null>(
|
|
270
355
|
null,
|
|
271
356
|
);
|
|
@@ -274,26 +359,21 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
274
359
|
null,
|
|
275
360
|
);
|
|
276
361
|
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
362
|
// Stable, so `profileModal.show` is not rebuilt for it on every render.
|
|
291
363
|
const profileAnswered = React.useCallback(() => setProfileNeed(null), []);
|
|
364
|
+
// Activating a profile changes which plugins are on, so the open screen must
|
|
365
|
+
// reload. The result modal that used to follow sent the user to the Profiles
|
|
366
|
+
// tab, which remounted it; without that modal the user stays where they are,
|
|
367
|
+
// and the screen under them would keep showing the pre-activation state.
|
|
368
|
+
// The same action re-runs the gitignore check — see the effect below.
|
|
369
|
+
const profileActivated = React.useCallback(() => {
|
|
370
|
+
dispatch({ type: "PROFILE_ACTIVATED" });
|
|
371
|
+
}, [dispatch]);
|
|
292
372
|
// The profile modal cannot be dismissed, so it needs the app's own exit: its
|
|
293
373
|
// only non-action is to leave.
|
|
294
374
|
const profileModal = useProfileStartupModal({
|
|
295
375
|
onQuit: onExit,
|
|
296
|
-
onActivated:
|
|
376
|
+
onActivated: profileActivated,
|
|
297
377
|
onAnswered: profileAnswered,
|
|
298
378
|
});
|
|
299
379
|
|
|
@@ -315,21 +395,6 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
315
395
|
return () => clearInterval(id);
|
|
316
396
|
}, []);
|
|
317
397
|
|
|
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
398
|
// The startup questions — gitignore, then broken plugins, then profile — are
|
|
334
399
|
// asked ONE at a time, from ONE effect, and only while the modal slot is free.
|
|
335
400
|
//
|
|
@@ -339,12 +404,13 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
339
404
|
// need had already been cleared — so it never came back. The profile question
|
|
340
405
|
// was lost exactly that way, and the user updated plugins believing a profile
|
|
341
406
|
// was active. So: show at most one per run and return; the next question
|
|
342
|
-
// follows once that one's
|
|
343
|
-
// again.
|
|
407
|
+
// follows once that one's flow has ended and the slot is empty again.
|
|
344
408
|
//
|
|
345
409
|
// The hooks keep the slot occupied for their whole flow (a loading modal
|
|
346
410
|
// while their fix runs, never HIDE_MODAL mid-flow), because an empty slot is
|
|
347
|
-
// what lets the next question in.
|
|
411
|
+
// what lets the next question in. A flow that succeeds ends by hiding its
|
|
412
|
+
// loading modal and reporting through `useNotify`: a notice is not in the
|
|
413
|
+
// modal slot, so it cannot hold the next question back or be replaced by it. That does not stop a SCREEN from emptying
|
|
348
414
|
// it: `useModal().confirm` hides its modal when answered and the screen shows
|
|
349
415
|
// its loading modal a microtask later. A key pressed before the profile check
|
|
350
416
|
// resolved lands the profile question in exactly that gap, and the screen's
|
|
@@ -363,17 +429,26 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
363
429
|
// re-showing would re-ask a question the user just declined. Losing one
|
|
364
430
|
// costs nothing lasting: the Git State and Plugins tabs show the same rows,
|
|
365
431
|
// and gitignore is asked again after a profile activates.
|
|
432
|
+
//
|
|
433
|
+
// The gitignore question also waits while a check is pending. An activation
|
|
434
|
+
// both empties the slot and asks for a fresh check, so the slot is free
|
|
435
|
+
// while an answer from BEFORE the activation may still be queued: showing
|
|
436
|
+
// it and then the fresh one asked the same question twice. The fresh result
|
|
437
|
+
// replaces the queued one instead (see the check effect below).
|
|
366
438
|
useEffect(() => {
|
|
367
439
|
if (state.modal) return;
|
|
368
|
-
if (gitignoreData) {
|
|
440
|
+
if (gitignoreData && !gitignorePending) {
|
|
441
|
+
// Anything older than what is now on screen is dropped on arrival.
|
|
442
|
+
gitignoreCheck.current.shown = gitignoreData.request;
|
|
443
|
+
const need = gitignoreData.need;
|
|
369
444
|
gitignoreModal.show({
|
|
370
|
-
violationCount:
|
|
371
|
-
safeCount:
|
|
372
|
-
generated:
|
|
445
|
+
violationCount: need.violationCount,
|
|
446
|
+
safeCount: need.safeCount,
|
|
447
|
+
generated: need.generated,
|
|
373
448
|
// In place, then back to the app: tracked generated files are
|
|
374
449
|
// untracked, missing entries added; other items stay for the tab.
|
|
375
450
|
onFixSafe: async () =>
|
|
376
|
-
|
|
451
|
+
startupFixNotices(await applyStartupFixes(state.projectPath)),
|
|
377
452
|
});
|
|
378
453
|
setGitignoreData(null);
|
|
379
454
|
return;
|
|
@@ -391,7 +466,7 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
391
466
|
// repair that capped at a different ceiling than the offer was counted
|
|
392
467
|
// against would move rows the dialog never mentioned.
|
|
393
468
|
onFix: async () =>
|
|
394
|
-
|
|
469
|
+
staleLoadRepairNotices(
|
|
395
470
|
await repairAllStaleLoads(
|
|
396
471
|
state.projectPath,
|
|
397
472
|
undefined,
|
|
@@ -409,6 +484,7 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
409
484
|
state.modal,
|
|
410
485
|
state.projectPath,
|
|
411
486
|
gitignoreData,
|
|
487
|
+
gitignorePending,
|
|
412
488
|
gitignoreModal,
|
|
413
489
|
brokenPluginCount,
|
|
414
490
|
staleLoadModal,
|
|
@@ -439,12 +515,46 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
439
515
|
})();
|
|
440
516
|
}, []);
|
|
441
517
|
|
|
442
|
-
// The gitignore question on every open
|
|
443
|
-
//
|
|
444
|
-
//
|
|
518
|
+
// The gitignore question, asked fresh: on every open (request 0), and again
|
|
519
|
+
// on each PROFILE_ACTIVATED — a first adoption is what turns a committed
|
|
520
|
+
// settings.json into a generated file git should not track, and a
|
|
521
|
+
// Profiles-tab switch can do the same. Nothing remembers an earlier answer.
|
|
522
|
+
//
|
|
523
|
+
// At most ONE check runs at a time. A request that arrives while one runs
|
|
524
|
+
// is not started beside it: the running check, on landing, sees that it is
|
|
525
|
+
// no longer the latest, throws its answer away — it describes the project
|
|
526
|
+
// before whatever prompted the request — and runs again. Only the latest
|
|
527
|
+
// request's answer is kept, and it REPLACES anything still queued, including
|
|
528
|
+
// with nothing when the project no longer needs the question.
|
|
529
|
+
//
|
|
530
|
+
// Its own effect, NOT part of the marketplace block below, which returns
|
|
531
|
+
// early on `--no-refresh`: that flag is about network calls and must not
|
|
532
|
+
// silence this question.
|
|
445
533
|
useEffect(() => {
|
|
446
|
-
|
|
447
|
-
|
|
534
|
+
const check = gitignoreCheck.current;
|
|
535
|
+
check.latest = state.gitignoreCheckRequest;
|
|
536
|
+
if (check.inFlight) return;
|
|
537
|
+
const run = (request: number): void => {
|
|
538
|
+
check.inFlight = true;
|
|
539
|
+
startupGitignoreNeed(process.cwd())
|
|
540
|
+
// non-fatal: the Git State tab still shows the same rows
|
|
541
|
+
.catch(() => null)
|
|
542
|
+
.then((need) => {
|
|
543
|
+
if (request !== check.latest) {
|
|
544
|
+
run(check.latest);
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
check.inFlight = false;
|
|
548
|
+
setGitignoreSettled(request);
|
|
549
|
+
// Defence in depth: an answer to a request no newer than the
|
|
550
|
+
// question last shown predates the user's reply to it.
|
|
551
|
+
setGitignoreData(
|
|
552
|
+
need && request > check.shown ? { need, request } : null,
|
|
553
|
+
);
|
|
554
|
+
});
|
|
555
|
+
};
|
|
556
|
+
run(check.latest);
|
|
557
|
+
}, [state.gitignoreCheckRequest]);
|
|
448
558
|
|
|
449
559
|
// Is this project actually running a profile? Detection only — nothing is
|
|
450
560
|
// written until the user picks something in the modal. The Profiles tab could
|
|
@@ -462,19 +572,25 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
462
572
|
// edits into the active profile, regenerate from a changed
|
|
463
573
|
// profiles.json, restore a lost marker — then the question, if one
|
|
464
574
|
// is left to ask.
|
|
465
|
-
const { need, notices, problems } =
|
|
466
|
-
process.cwd()
|
|
467
|
-
);
|
|
575
|
+
const { need, notices, problems, activations } =
|
|
576
|
+
await prepareProfileStartup(process.cwd());
|
|
468
577
|
// A refusal never blocks the TUI at startup: it is shown beside what
|
|
469
|
-
// the gate did, and the user is still asked whatever
|
|
470
|
-
|
|
471
|
-
|
|
578
|
+
// the gate did, as a warning, and the user is still asked whatever
|
|
579
|
+
// is left. What a re-render left to act on — a plugin that will not
|
|
580
|
+
// load, one not downloaded yet — is a warning too, in user terms.
|
|
581
|
+
for (const line of notices) notify(line);
|
|
582
|
+
for (const activation of activations) {
|
|
583
|
+
for (const line of activationWarnings(activation)) {
|
|
584
|
+
notify(line.text, line.tone);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
for (const line of problems) notify(line, "warning");
|
|
472
588
|
if (!needsNothing(need)) setProfileNeed(need);
|
|
473
589
|
} catch {
|
|
474
590
|
// non-fatal: the Profiles tab still works by hand
|
|
475
591
|
}
|
|
476
592
|
})();
|
|
477
|
-
}, []);
|
|
593
|
+
}, [notify]);
|
|
478
594
|
|
|
479
595
|
// Auto-refresh marketplaces on startup
|
|
480
596
|
useEffect(() => {
|
|
@@ -498,7 +614,7 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
498
614
|
if (!(await isClaudeAvailable())) return;
|
|
499
615
|
const added = await autoAddMissingMarketplaces(process.cwd());
|
|
500
616
|
if (added.length > 0) {
|
|
501
|
-
|
|
617
|
+
notify(`Fixed: added marketplace(s): ${added.join(", ")}`);
|
|
502
618
|
}
|
|
503
619
|
} catch {
|
|
504
620
|
// non-fatal: user can still install marketplaces manually
|
|
@@ -509,6 +625,9 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
509
625
|
recoverMarketplaceSettings()
|
|
510
626
|
.then(async (recovery) => {
|
|
511
627
|
const parts: string[] = [];
|
|
628
|
+
// Apart from `parts`: a success line would carry them in green,
|
|
629
|
+
// and a marketplace left stale is something the user may act on.
|
|
630
|
+
const failed: string[] = [];
|
|
512
631
|
if (recovery.reregistered.length > 0) {
|
|
513
632
|
// Update the marketplace clone now that the source is fixed
|
|
514
633
|
const { updateMarketplace } = await import(
|
|
@@ -519,7 +638,7 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
519
638
|
await updateMarketplace(mp);
|
|
520
639
|
parts.push(`${mp} refreshed`);
|
|
521
640
|
} catch {
|
|
522
|
-
|
|
641
|
+
failed.push(mp);
|
|
523
642
|
}
|
|
524
643
|
}
|
|
525
644
|
}
|
|
@@ -527,7 +646,13 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
527
646
|
parts.push(`removed: ${recovery.removed.join(", ")}`);
|
|
528
647
|
}
|
|
529
648
|
if (parts.length > 0) {
|
|
530
|
-
|
|
649
|
+
notify(`Fixed: ${parts.join(" | ")}`);
|
|
650
|
+
}
|
|
651
|
+
if (failed.length > 0) {
|
|
652
|
+
notify(
|
|
653
|
+
`Could not refresh marketplace(s): ${failed.join(", ")}`,
|
|
654
|
+
"warning",
|
|
655
|
+
);
|
|
531
656
|
}
|
|
532
657
|
})
|
|
533
658
|
.catch(() => {}); // non-fatal
|
|
@@ -540,23 +665,17 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
540
665
|
.catch(() => {
|
|
541
666
|
dispatch({ type: "HIDE_PROGRESS" });
|
|
542
667
|
});
|
|
543
|
-
}, [dispatch]);
|
|
544
|
-
|
|
545
|
-
// Count transient banners for dimension calculation
|
|
546
|
-
const transientBannerCount = recoveryReport ? 1 : 0;
|
|
668
|
+
}, [dispatch, notify]);
|
|
547
669
|
|
|
548
670
|
return (
|
|
549
671
|
<DimensionsProvider
|
|
550
672
|
showProgress={!!progress}
|
|
551
673
|
showUpdateBanner={!!updateInfo?.updateAvailable}
|
|
552
|
-
|
|
674
|
+
// One row per notice line, so the screen below shrinks rather than
|
|
675
|
+
// being pushed off the bottom of the terminal.
|
|
676
|
+
transientBannerCount={state.notices.length}
|
|
553
677
|
>
|
|
554
|
-
<AppContentInner
|
|
555
|
-
updateInfo={updateInfo}
|
|
556
|
-
onExit={onExit}
|
|
557
|
-
recoveryReport={recoveryReport}
|
|
558
|
-
profileNotices={profileNotices}
|
|
559
|
-
/>
|
|
678
|
+
<AppContentInner updateInfo={updateInfo} onExit={onExit} />
|
|
560
679
|
</DimensionsProvider>
|
|
561
680
|
);
|
|
562
681
|
}
|
|
@@ -247,10 +247,27 @@ function isMadAppGang(mp: Marketplace): boolean {
|
|
|
247
247
|
*/
|
|
248
248
|
export function noMoveReasons(): {
|
|
249
249
|
reporter: ApplyReporter;
|
|
250
|
-
/**
|
|
251
|
-
|
|
250
|
+
/**
|
|
251
|
+
* One line per unchanged item, with its cause when one was reported, so one
|
|
252
|
+
* plugin's reason never runs into the next. The screen posts
|
|
253
|
+
* {@link notice} instead; this is the per-item form it is built from.
|
|
254
|
+
*/
|
|
255
|
+
lines(unchanged: readonly string[]): string[];
|
|
256
|
+
/**
|
|
257
|
+
* The same, as ONE notice line, or null when nothing was unchanged. "Update
|
|
258
|
+
* all" can leave many plugins where they were, and a line each would fill
|
|
259
|
+
* the banner and push out whatever else it had to say. So several become
|
|
260
|
+
* `3 plugins unchanged — a, b, c`, with the cause only when they all share
|
|
261
|
+
* it; one keeps its own cause.
|
|
262
|
+
*/
|
|
263
|
+
notice(unchanged: readonly string[]): string | null;
|
|
252
264
|
} {
|
|
253
265
|
const why = new Map<string, string>();
|
|
266
|
+
const lines = (unchanged: readonly string[]): string[] =>
|
|
267
|
+
unchanged.map((name) => {
|
|
268
|
+
const reason = why.get(name);
|
|
269
|
+
return reason ? `${name}: ${reason}` : name;
|
|
270
|
+
});
|
|
254
271
|
return {
|
|
255
272
|
reporter: {
|
|
256
273
|
begin: () => {},
|
|
@@ -260,13 +277,15 @@ export function noMoveReasons(): {
|
|
|
260
277
|
},
|
|
261
278
|
detach: (work) => work(),
|
|
262
279
|
},
|
|
263
|
-
lines
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
280
|
+
lines,
|
|
281
|
+
notice: (unchanged) => {
|
|
282
|
+
if (unchanged.length === 0) return null;
|
|
283
|
+
if (unchanged.length === 1) return `No change: ${lines(unchanged)[0]}`;
|
|
284
|
+
const reasons = new Set(unchanged.map((name) => why.get(name)));
|
|
285
|
+
const [shared] = reasons;
|
|
286
|
+
const cause = reasons.size === 1 && shared ? `: ${shared}` : "";
|
|
287
|
+
return `${unchanged.length} plugins unchanged — ${unchanged.join(", ")}${cause}`;
|
|
288
|
+
},
|
|
270
289
|
};
|
|
271
290
|
}
|
|
272
291
|
|
|
@@ -7,12 +7,11 @@ interface MessageModalProps {
|
|
|
7
7
|
/** Modal message */
|
|
8
8
|
message: string;
|
|
9
9
|
/** Message variant */
|
|
10
|
-
variant: "info" | "
|
|
10
|
+
variant: "info" | "error";
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
const variantConfig = {
|
|
14
14
|
info: { icon: "ℹ", color: theme.colors.info },
|
|
15
|
-
success: { icon: "✓", color: theme.colors.success },
|
|
16
15
|
error: { icon: "✗", color: theme.colors.danger },
|
|
17
16
|
} as const;
|
|
18
17
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useCallback } from "react";
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
2
|
+
import type { StartupFixResult } from "../../services/gitignore-service.js";
|
|
3
|
+
import { useApp, useNavigation, useNotify } from "../state/AppContext.js";
|
|
4
|
+
import type { ModalState, NoticeLine } from "../state/types.js";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Startup gitignore-violation modal: magus launches, detects a problem, and
|
|
@@ -79,17 +80,49 @@ export function buildGitignoreModal(args: BuildModalArgs): ModalState {
|
|
|
79
80
|
};
|
|
80
81
|
}
|
|
81
82
|
|
|
83
|
+
/**
|
|
84
|
+
* What the startup fix did, as notices. Untracking is a warning because it is
|
|
85
|
+
* not finished until the user commits the staged removal — the one thing here
|
|
86
|
+
* they must still do. The files are named only there, and in a failure, which
|
|
87
|
+
* the user has to deal with by hand.
|
|
88
|
+
*/
|
|
89
|
+
export function startupFixNotices(result: StartupFixResult): NoticeLine[] {
|
|
90
|
+
const lines: NoticeLine[] = [];
|
|
91
|
+
if (result.untracked.length > 0) {
|
|
92
|
+
lines.push({
|
|
93
|
+
text: `Stopped tracking ${result.untracked.join(", ")} — commit the staged removal`,
|
|
94
|
+
tone: "warning",
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
if (result.appended.length > 0) {
|
|
98
|
+
lines.push({
|
|
99
|
+
text: `Added ${entries(result.appended.length)}`,
|
|
100
|
+
tone: "success",
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
for (const f of result.failed) {
|
|
104
|
+
lines.push({
|
|
105
|
+
text: `Could not stop tracking ${f.path}: ${f.reason}`,
|
|
106
|
+
tone: "warning",
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return lines.length > 0
|
|
110
|
+
? lines
|
|
111
|
+
: [{ text: "Gitignore already up to date", tone: "success" }];
|
|
112
|
+
}
|
|
113
|
+
|
|
82
114
|
export function useGitignoreModal() {
|
|
83
115
|
const { dispatch } = useApp();
|
|
84
116
|
const { navigateToScreen } = useNavigation();
|
|
117
|
+
const notify = useNotify();
|
|
85
118
|
|
|
86
119
|
const show = useCallback(
|
|
87
120
|
(args: {
|
|
88
121
|
violationCount: number;
|
|
89
122
|
safeCount: number;
|
|
90
123
|
generated?: readonly string[];
|
|
91
|
-
/** Returns what it did,
|
|
92
|
-
onFixSafe: () => Promise<
|
|
124
|
+
/** Returns what it did, reported as notices once the fix settles. */
|
|
125
|
+
onFixSafe: () => Promise<readonly NoticeLine[]> | readonly NoticeLine[];
|
|
93
126
|
}) => {
|
|
94
127
|
if (args.violationCount === 0) return;
|
|
95
128
|
const dismiss = () => dispatch({ type: "HIDE_MODAL" });
|
|
@@ -101,9 +134,9 @@ export function useGitignoreModal() {
|
|
|
101
134
|
// A loading modal, NOT HIDE_MODAL. The fix is async, and an empty
|
|
102
135
|
// modal slot is App's signal that the next startup question may be
|
|
103
136
|
// asked: hiding here let the profile question show during the await,
|
|
104
|
-
// be marked as asked, and then be replaced by
|
|
137
|
+
// be marked as asked, and then be replaced by the fix's result —
|
|
105
138
|
// lost for the whole session. The slot stays occupied until the
|
|
106
|
-
//
|
|
139
|
+
// fix settles.
|
|
107
140
|
dispatch({
|
|
108
141
|
type: "SHOW_MODAL",
|
|
109
142
|
modal: { type: "loading", message: "Fixing gitignore…" },
|
|
@@ -114,22 +147,11 @@ export function useGitignoreModal() {
|
|
|
114
147
|
void Promise.resolve()
|
|
115
148
|
.then(() => args.onFixSafe())
|
|
116
149
|
.then((report) => {
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
}
|
|
123
|
-
dispatch({
|
|
124
|
-
type: "SHOW_MODAL",
|
|
125
|
-
modal: {
|
|
126
|
-
type: "message",
|
|
127
|
-
title: "Gitignore fixed",
|
|
128
|
-
message: report,
|
|
129
|
-
variant: "success",
|
|
130
|
-
onDismiss: dismiss,
|
|
131
|
-
},
|
|
132
|
-
});
|
|
150
|
+
// The loading modal must go — it swallows every key — and the
|
|
151
|
+
// result goes to the notice banner, which is outside the modal
|
|
152
|
+
// slot, so the next startup question can take the slot now.
|
|
153
|
+
dispatch({ type: "HIDE_MODAL" });
|
|
154
|
+
for (const line of report) notify(line.text, line.tone);
|
|
133
155
|
})
|
|
134
156
|
.catch((err) => {
|
|
135
157
|
dispatch({
|
|
@@ -152,7 +174,7 @@ export function useGitignoreModal() {
|
|
|
152
174
|
});
|
|
153
175
|
dispatch({ type: "SHOW_MODAL", modal });
|
|
154
176
|
},
|
|
155
|
-
[dispatch, navigateToScreen],
|
|
177
|
+
[dispatch, navigateToScreen, notify],
|
|
156
178
|
);
|
|
157
179
|
|
|
158
180
|
return { show };
|