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
|
@@ -2,7 +2,12 @@ import React, { useEffect, useCallback, useMemo } from "react";
|
|
|
2
2
|
import { ScreenLayout } from "../components/layout/index.js";
|
|
3
3
|
import { useScreenKeys } from "../input/KeyRouter.js";
|
|
4
4
|
import { moveDirection } from "../keymap.js";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
useApp,
|
|
7
|
+
useModal,
|
|
8
|
+
useNavigation,
|
|
9
|
+
useNotify,
|
|
10
|
+
} from "../state/AppContext.js";
|
|
6
11
|
import { useDimensions } from "../state/DimensionsContext.js";
|
|
7
12
|
|
|
8
13
|
import {
|
|
@@ -30,6 +35,7 @@ export function McpScreen() {
|
|
|
30
35
|
const { state, dispatch } = useApp();
|
|
31
36
|
const { mcp } = state;
|
|
32
37
|
const modal = useModal();
|
|
38
|
+
const notify = useNotify();
|
|
33
39
|
const { navigateToScreen } = useNavigation();
|
|
34
40
|
const dimensions = useDimensions();
|
|
35
41
|
|
|
@@ -69,9 +75,12 @@ export function McpScreen() {
|
|
|
69
75
|
}
|
|
70
76
|
}, [dispatch, state.projectPath]);
|
|
71
77
|
|
|
78
|
+
// Load data on mount or when dataRefreshVersion changes — a profile
|
|
79
|
+
// activating changes what this screen shows.
|
|
80
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: dataRefreshVersion is the external refresh trigger; bumping it must re-run the fetch even though the body never reads it
|
|
72
81
|
useEffect(() => {
|
|
73
82
|
fetchData();
|
|
74
|
-
}, [fetchData]);
|
|
83
|
+
}, [fetchData, state.dataRefreshVersion]);
|
|
75
84
|
|
|
76
85
|
const allListItems = useMemo((): McpListItem[] => {
|
|
77
86
|
if (mcp.servers.status !== "success") return [];
|
|
@@ -138,11 +147,7 @@ export function McpScreen() {
|
|
|
138
147
|
try {
|
|
139
148
|
await removeMcpServer(server.name, state.projectPath);
|
|
140
149
|
modal.hideModal();
|
|
141
|
-
|
|
142
|
-
"Removed",
|
|
143
|
-
`${server.name} has been removed.`,
|
|
144
|
-
"success",
|
|
145
|
-
);
|
|
150
|
+
// The row turns uninstalled; nothing else to say.
|
|
146
151
|
fetchData();
|
|
147
152
|
} catch (error) {
|
|
148
153
|
modal.hideModal();
|
|
@@ -216,11 +221,10 @@ export function McpScreen() {
|
|
|
216
221
|
try {
|
|
217
222
|
await addMcpServer(server.name, config, state.projectPath);
|
|
218
223
|
modal.hideModal();
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
);
|
|
224
|
+
// The row shows it installed; the restart it needs does not show,
|
|
225
|
+
// and it is something the user has to do — so a warning, which also
|
|
226
|
+
// stays long enough to be read.
|
|
227
|
+
notify(`Added ${server.name} — restart Claude Code to use it`, "warning");
|
|
224
228
|
fetchData();
|
|
225
229
|
} catch (error) {
|
|
226
230
|
modal.hideModal();
|
|
@@ -91,7 +91,12 @@ import {
|
|
|
91
91
|
renderPluginDetail,
|
|
92
92
|
renderPluginRow,
|
|
93
93
|
} from "../renderers/pluginRenderers.js";
|
|
94
|
-
import {
|
|
94
|
+
import {
|
|
95
|
+
useApp,
|
|
96
|
+
useModal,
|
|
97
|
+
useNotify,
|
|
98
|
+
useProgress,
|
|
99
|
+
} from "../state/AppContext.js";
|
|
95
100
|
import { useDimensions } from "../state/DimensionsContext.js";
|
|
96
101
|
import { asyncValue } from "../state/types.js";
|
|
97
102
|
import { theme } from "../theme.js";
|
|
@@ -121,6 +126,7 @@ export function PluginsScreen() {
|
|
|
121
126
|
const { plugins: pluginsState } = state;
|
|
122
127
|
const modal = useModal();
|
|
123
128
|
const progress = useProgress();
|
|
129
|
+
const notify = useNotify();
|
|
124
130
|
const dimensions = useDimensions();
|
|
125
131
|
|
|
126
132
|
const isSearchActive =
|
|
@@ -558,21 +564,15 @@ export function PluginsScreen() {
|
|
|
558
564
|
clearContentDriftCache();
|
|
559
565
|
progress.hide();
|
|
560
566
|
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
0,
|
|
571
|
-
);
|
|
572
|
-
message += `\n\nAuto-repaired ${totalRepaired} plugin(s) with missing agents/commands.`;
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
await modal.message("Refreshed", message, "success");
|
|
567
|
+
const totalRepaired = result.repair.reduce(
|
|
568
|
+
(sum, r) => sum + r.repaired.length,
|
|
569
|
+
0,
|
|
570
|
+
);
|
|
571
|
+
notify(
|
|
572
|
+
totalRepaired > 0
|
|
573
|
+
? `Refreshed — repaired ${totalRepaired} plugin(s) with missing files`
|
|
574
|
+
: "Refreshed",
|
|
575
|
+
);
|
|
576
576
|
fetchData();
|
|
577
577
|
} catch (error) {
|
|
578
578
|
progress.hide();
|
|
@@ -697,10 +697,9 @@ export function PluginsScreen() {
|
|
|
697
697
|
);
|
|
698
698
|
|
|
699
699
|
if (!wantToConfigure) {
|
|
700
|
-
|
|
701
|
-
"
|
|
702
|
-
|
|
703
|
-
"info",
|
|
700
|
+
notify(
|
|
701
|
+
`${serverNames.join(", ")} not configured — set the variables in the Settings tab (press ${tabKey("settings")})`,
|
|
702
|
+
"warning",
|
|
704
703
|
);
|
|
705
704
|
return true;
|
|
706
705
|
}
|
|
@@ -725,10 +724,9 @@ export function PluginsScreen() {
|
|
|
725
724
|
);
|
|
726
725
|
|
|
727
726
|
if (value === null) {
|
|
728
|
-
|
|
729
|
-
"
|
|
730
|
-
|
|
731
|
-
"info",
|
|
727
|
+
notify(
|
|
728
|
+
`${serverNames.join(", ")} partly configured — set the rest in the Settings tab (press ${tabKey("settings")})`,
|
|
729
|
+
"warning",
|
|
732
730
|
);
|
|
733
731
|
return true;
|
|
734
732
|
}
|
|
@@ -807,11 +805,7 @@ export function PluginsScreen() {
|
|
|
807
805
|
"error",
|
|
808
806
|
);
|
|
809
807
|
} else if (result.installed.length > 0) {
|
|
810
|
-
|
|
811
|
-
"Dependencies Installed",
|
|
812
|
-
`Installed ${result.installed.length} package(s):\n${result.installed.join(", ")}`,
|
|
813
|
-
"success",
|
|
814
|
-
);
|
|
808
|
+
notify(`Installed ${result.installed.join(", ")}`);
|
|
815
809
|
}
|
|
816
810
|
} catch (error) {
|
|
817
811
|
console.error("Error installing plugin deps:", error);
|
|
@@ -1047,13 +1041,8 @@ export function PluginsScreen() {
|
|
|
1047
1041
|
// Ran, exited 0, moved nothing — and the row will not have moved
|
|
1048
1042
|
// either. Saying nothing here is what made "press U, press U,
|
|
1049
1043
|
// forever": the CLI names the cause, so the screen does too.
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
"No change",
|
|
1053
|
-
reasons.lines(result.unchanged),
|
|
1054
|
-
"info",
|
|
1055
|
-
);
|
|
1056
|
-
}
|
|
1044
|
+
const unchanged = reasons.notice(result.unchanged);
|
|
1045
|
+
if (unchanged) notify(unchanged, "warning");
|
|
1057
1046
|
fetchData();
|
|
1058
1047
|
} catch (error) {
|
|
1059
1048
|
modal.hideModal();
|
|
@@ -1121,13 +1110,10 @@ export function PluginsScreen() {
|
|
|
1121
1110
|
"error",
|
|
1122
1111
|
);
|
|
1123
1112
|
}
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
"info",
|
|
1129
|
-
);
|
|
1130
|
-
}
|
|
1113
|
+
// One line however many were left where they were: a line each
|
|
1114
|
+
// filled the banner and pushed out everything else it had to say.
|
|
1115
|
+
const unchanged = reasons.notice(result.unchanged);
|
|
1116
|
+
if (unchanged) notify(unchanged, "warning");
|
|
1131
1117
|
fetchData();
|
|
1132
1118
|
} catch (error) {
|
|
1133
1119
|
modal.hideModal();
|
|
@@ -1278,10 +1264,8 @@ export function PluginsScreen() {
|
|
|
1278
1264
|
try {
|
|
1279
1265
|
await saveProfile(name.trim(), enabledPlugins, scope, state.projectPath);
|
|
1280
1266
|
modal.hideModal();
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
`Profile "${name.trim()}" saved.\nPress ${tabKey("profiles")} to manage profiles.`,
|
|
1284
|
-
"success",
|
|
1267
|
+
notify(
|
|
1268
|
+
`Saved profile "${name.trim()}" at ${scope === "user" ? "USER" : "PROJECT"} scope`,
|
|
1285
1269
|
);
|
|
1286
1270
|
} catch (error) {
|
|
1287
1271
|
modal.hideModal();
|
|
@@ -1379,18 +1363,13 @@ export function PluginsScreen() {
|
|
|
1379
1363
|
width: dimensions.terminalWidth,
|
|
1380
1364
|
now: nowTick,
|
|
1381
1365
|
});
|
|
1382
|
-
// Without a resolved profile
|
|
1383
|
-
//
|
|
1384
|
-
//
|
|
1366
|
+
// Without a resolved profile every row is judged against "latest". That is
|
|
1367
|
+
// not said here: the startup question already tells the user there is no
|
|
1368
|
+
// active profile, and "profile not resolved" was mechanism, not state.
|
|
1385
1369
|
const notice = isReloading ? (
|
|
1386
1370
|
<text fg={theme.colors.muted}>⟳ Refreshing catalogs from GitHub…</text>
|
|
1387
1371
|
) : (
|
|
1388
|
-
|
|
1389
|
-
(pins === null ? (
|
|
1390
|
-
<text fg={theme.colors.muted}>
|
|
1391
|
-
profile not resolved — states judged against latest
|
|
1392
|
-
</text>
|
|
1393
|
-
) : null))
|
|
1372
|
+
catalogNotice
|
|
1394
1373
|
);
|
|
1395
1374
|
|
|
1396
1375
|
return (
|
|
@@ -2,7 +2,7 @@ import React, { useEffect, useCallback } from "react";
|
|
|
2
2
|
import { ScreenLayout } from "../components/layout/index.js";
|
|
3
3
|
import { useScreenKeys } from "../input/KeyRouter.js";
|
|
4
4
|
import { moveDirection } from "../keymap.js";
|
|
5
|
-
import { useApp, useModal } from "../state/AppContext.js";
|
|
5
|
+
import { useApp, useModal, useNotify } from "../state/AppContext.js";
|
|
6
6
|
import { useDimensions } from "../state/DimensionsContext.js";
|
|
7
7
|
|
|
8
8
|
import {
|
|
@@ -15,7 +15,6 @@ import {
|
|
|
15
15
|
writeSettings,
|
|
16
16
|
} from "../../services/claude-settings.js";
|
|
17
17
|
import { reconcileActiveProfile } from "../../services/profile-absorb.js";
|
|
18
|
-
import { activationNotes } from "../../services/profile-activate.js";
|
|
19
18
|
import { switchToProfile } from "../../services/profile-state.js";
|
|
20
19
|
import {
|
|
21
20
|
applyUserProfile,
|
|
@@ -32,7 +31,7 @@ import {
|
|
|
32
31
|
writeClipboard,
|
|
33
32
|
} from "../../utils/clipboard.js";
|
|
34
33
|
import { ScrollableList } from "../components/ScrollableList.js";
|
|
35
|
-
import {
|
|
34
|
+
import { activationNotices } from "../hooks/useProfileStartupModal.js";
|
|
36
35
|
import {
|
|
37
36
|
type ProfileListItem,
|
|
38
37
|
buildProfileListItems,
|
|
@@ -45,6 +44,7 @@ export function ProfilesScreen() {
|
|
|
45
44
|
const { state, dispatch } = useApp();
|
|
46
45
|
const { profiles: profilesState } = state;
|
|
47
46
|
const modal = useModal();
|
|
47
|
+
const notify = useNotify();
|
|
48
48
|
const dimensions = useDimensions();
|
|
49
49
|
|
|
50
50
|
const fetchData = useCallback(async () => {
|
|
@@ -165,14 +165,10 @@ export function ProfilesScreen() {
|
|
|
165
165
|
const recorded = await reconcileActiveProfile(state.projectPath);
|
|
166
166
|
modal.hideModal();
|
|
167
167
|
dispatch({ type: "DATA_REFRESH_COMPLETE" });
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
...recorded.lines,
|
|
173
|
-
].join("\n"),
|
|
174
|
-
"success",
|
|
175
|
-
);
|
|
168
|
+
notify(`Merged "${profile.name}" into this project`);
|
|
169
|
+
// What was recorded is routine; a refusal to record it is not, and
|
|
170
|
+
// the user has to fix it before the merge reaches the profile.
|
|
171
|
+
for (const problem of recorded.problems) notify(problem, "warning");
|
|
176
172
|
} catch (error) {
|
|
177
173
|
modal.hideModal();
|
|
178
174
|
await modal.message(
|
|
@@ -215,8 +211,10 @@ export function ProfilesScreen() {
|
|
|
215
211
|
{ onDisplace: () => {} },
|
|
216
212
|
);
|
|
217
213
|
modal.hideModal();
|
|
218
|
-
dispatch({ type: "DATA_REFRESH_COMPLETE" });
|
|
219
214
|
if (result.problems.length > 0 || !result.activation) {
|
|
215
|
+
// Nothing activated, but what was recorded on the way may have
|
|
216
|
+
// changed the list.
|
|
217
|
+
dispatch({ type: "DATA_REFRESH_COMPLETE" });
|
|
220
218
|
await modal.message(
|
|
221
219
|
"Could not switch",
|
|
222
220
|
[...result.lines, ...result.problems].join("\n"),
|
|
@@ -224,20 +222,16 @@ export function ProfilesScreen() {
|
|
|
224
222
|
);
|
|
225
223
|
return;
|
|
226
224
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
? `${result.lines.join("\n")}\n\n${outcome}`
|
|
238
|
-
: outcome,
|
|
239
|
-
"success",
|
|
240
|
-
);
|
|
225
|
+
// The same signal as the startup question: every screen reloads,
|
|
226
|
+
// and the app re-runs its gitignore check — the profile switched
|
|
227
|
+
// to may generate files git still tracks, and that question is how
|
|
228
|
+
// the user hears of them (activationNotices leaves them out).
|
|
229
|
+
dispatch({ type: "PROFILE_ACTIVATED" });
|
|
230
|
+
// `result.lines` (edits saved into the profile left behind) are not
|
|
231
|
+
// repeated: the confirmation above already said that happens.
|
|
232
|
+
for (const line of activationNotices(result.activation)) {
|
|
233
|
+
notify(line.text, line.tone);
|
|
234
|
+
}
|
|
241
235
|
} catch (error) {
|
|
242
236
|
modal.hideModal();
|
|
243
237
|
await modal.message(
|
|
@@ -266,11 +260,7 @@ export function ProfilesScreen() {
|
|
|
266
260
|
await applyUserProfile(selectedProfile);
|
|
267
261
|
modal.hideModal();
|
|
268
262
|
dispatch({ type: "DATA_REFRESH_COMPLETE" });
|
|
269
|
-
|
|
270
|
-
"Applied",
|
|
271
|
-
`Profile "${selectedProfile.name}" applied to ~/.claude/settings.json.`,
|
|
272
|
-
"success",
|
|
273
|
-
);
|
|
263
|
+
notify(`Applied "${selectedProfile.name}" at USER scope`);
|
|
274
264
|
} catch (error) {
|
|
275
265
|
modal.hideModal();
|
|
276
266
|
await modal.message(
|
|
@@ -301,12 +291,8 @@ export function ProfilesScreen() {
|
|
|
301
291
|
state.projectPath,
|
|
302
292
|
);
|
|
303
293
|
modal.hideModal();
|
|
294
|
+
// The row shows the new name; nothing else to say.
|
|
304
295
|
await fetchData();
|
|
305
|
-
await modal.message(
|
|
306
|
-
"Renamed",
|
|
307
|
-
`Profile renamed to "${newName.trim()}".`,
|
|
308
|
-
"success",
|
|
309
|
-
);
|
|
310
296
|
} catch (error) {
|
|
311
297
|
modal.hideModal();
|
|
312
298
|
await modal.message("Error", `Failed to rename: ${error}`, "error");
|
|
@@ -336,8 +322,8 @@ export function ProfilesScreen() {
|
|
|
336
322
|
Math.min(profilesState.selectedIndex, allItems.length - 2),
|
|
337
323
|
);
|
|
338
324
|
dispatch({ type: "PROFILES_SELECT", index: newIndex });
|
|
325
|
+
// The row is gone from the list; nothing else to say.
|
|
339
326
|
await fetchData();
|
|
340
|
-
await modal.message("Deleted", "Profile deleted.", "success");
|
|
341
327
|
} catch (error) {
|
|
342
328
|
modal.hideModal();
|
|
343
329
|
await modal.message("Error", `Failed to delete: ${error}`, "error");
|
|
@@ -358,10 +344,8 @@ export function ProfilesScreen() {
|
|
|
358
344
|
modal.hideModal();
|
|
359
345
|
try {
|
|
360
346
|
await writeClipboard(json);
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
"Profile JSON copied to clipboard.\nShare it with teammates to import.",
|
|
364
|
-
"success",
|
|
347
|
+
notify(
|
|
348
|
+
`Copied "${selectedProfile.name}" — share it for teammates to import`,
|
|
365
349
|
);
|
|
366
350
|
} catch (err) {
|
|
367
351
|
if (err instanceof ClipboardUnavailableError) {
|
|
@@ -409,18 +393,10 @@ export function ProfilesScreen() {
|
|
|
409
393
|
|
|
410
394
|
modal.loading("Importing...");
|
|
411
395
|
try {
|
|
412
|
-
|
|
413
|
-
json,
|
|
414
|
-
targetScope,
|
|
415
|
-
state.projectPath,
|
|
416
|
-
);
|
|
396
|
+
await importProfileFromJson(json, targetScope, state.projectPath);
|
|
417
397
|
modal.hideModal();
|
|
398
|
+
// The imported profile appears in the list; nothing else to say.
|
|
418
399
|
await fetchData();
|
|
419
|
-
await modal.message(
|
|
420
|
-
"Imported",
|
|
421
|
-
`Profile imported successfully (id: ${id}).`,
|
|
422
|
-
"success",
|
|
423
|
-
);
|
|
424
400
|
} catch (error) {
|
|
425
401
|
modal.hideModal();
|
|
426
402
|
await modal.message("Error", `Failed to import: ${error}`, "error");
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import type React from "react";
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import {
|
|
3
|
+
type ReactNode,
|
|
4
|
+
createContext,
|
|
5
|
+
useCallback,
|
|
6
|
+
useContext,
|
|
7
|
+
useReducer,
|
|
8
|
+
} from "react";
|
|
9
|
+
import { NOTICE_DURATION_MS, appReducer, initialState } from "./reducer.js";
|
|
4
10
|
import type {
|
|
5
11
|
AppAction,
|
|
6
12
|
AppState,
|
|
7
13
|
ModalState,
|
|
14
|
+
NoticeTone,
|
|
8
15
|
ProgressState,
|
|
9
16
|
Route,
|
|
10
17
|
Screen,
|
|
@@ -177,7 +184,7 @@ export function useModal() {
|
|
|
177
184
|
message: (
|
|
178
185
|
title: string,
|
|
179
186
|
message: string,
|
|
180
|
-
variant: "info" | "
|
|
187
|
+
variant: "info" | "error" = "info",
|
|
181
188
|
): Promise<void> => {
|
|
182
189
|
return new Promise((resolve) => {
|
|
183
190
|
dispatch({
|
|
@@ -213,6 +220,42 @@ export function useModal() {
|
|
|
213
220
|
};
|
|
214
221
|
}
|
|
215
222
|
|
|
223
|
+
// ============================================================================
|
|
224
|
+
// Hook: useNotify
|
|
225
|
+
// ============================================================================
|
|
226
|
+
|
|
227
|
+
export { NOTICE_DURATION_MS };
|
|
228
|
+
|
|
229
|
+
let nextNoticeId = 0;
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* `notify(text, tone)` puts one line in the app's notice banner (rendered by
|
|
233
|
+
* `NoticeBanner` in ui/App.tsx) and returns at once. It is how a screen or hook
|
|
234
|
+
* reports the result of an action: it never opens a modal, never takes a key,
|
|
235
|
+
* and the line clears itself after {@link NOTICE_DURATION_MS}. Time spent
|
|
236
|
+
* under a modal does not count (see PAUSE_NOTICES in the reducer), so a line
|
|
237
|
+
* posted while a question is on screen is still there once it is answered.
|
|
238
|
+
*
|
|
239
|
+
* Stable across renders, so it is safe in a `useCallback` dependency list.
|
|
240
|
+
*/
|
|
241
|
+
export function useNotify(): (text: string, tone?: NoticeTone) => void {
|
|
242
|
+
const { dispatch } = useApp();
|
|
243
|
+
return useCallback(
|
|
244
|
+
(text: string, tone: NoticeTone = "success") => {
|
|
245
|
+
dispatch({
|
|
246
|
+
type: "NOTIFY",
|
|
247
|
+
notice: {
|
|
248
|
+
id: nextNoticeId++,
|
|
249
|
+
text,
|
|
250
|
+
tone,
|
|
251
|
+
expiresAt: Date.now() + NOTICE_DURATION_MS[tone],
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
},
|
|
255
|
+
[dispatch],
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
216
259
|
// ============================================================================
|
|
217
260
|
// Hook: useProgress
|
|
218
261
|
// ============================================================================
|
package/src/ui/state/reducer.ts
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
|
-
import type { AppAction, AppState } from "./types.js";
|
|
1
|
+
import type { AppAction, AppState, Notice, NoticeTone } from "./types.js";
|
|
2
2
|
import { asyncValue } from "./types.js";
|
|
3
3
|
|
|
4
|
+
/** How many notice lines the banner shows at once. */
|
|
5
|
+
export const MAX_NOTICES = 4;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* How long a notice stays. A warning may ask the user to do something, so it
|
|
9
|
+
* gets long enough to read and act on; a success only confirms what happened.
|
|
10
|
+
*
|
|
11
|
+
* Here rather than beside `useNotify`, because RESUME_NOTICES caps a held line
|
|
12
|
+
* at its full duration and the reducer cannot import from the context module
|
|
13
|
+
* that imports it.
|
|
14
|
+
*/
|
|
15
|
+
export const NOTICE_DURATION_MS: Record<NoticeTone, number> = {
|
|
16
|
+
success: 4000,
|
|
17
|
+
warning: 10000,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Add `notice` and keep at most {@link MAX_NOTICES}, dropping successes first.
|
|
22
|
+
*
|
|
23
|
+
* A success only confirms what the screen already shows; a warning may be the
|
|
24
|
+
* one thing telling the user to act. So a burst of lines evicts the oldest
|
|
25
|
+
* success, and the oldest warning goes only when nothing but warnings is left.
|
|
26
|
+
*/
|
|
27
|
+
function withNotice(notices: readonly Notice[], notice: Notice): Notice[] {
|
|
28
|
+
const next = [...notices, notice];
|
|
29
|
+
while (next.length > MAX_NOTICES) {
|
|
30
|
+
const success = next.findIndex((n) => n.tone === "success");
|
|
31
|
+
next.splice(success === -1 ? 0 : success, 1);
|
|
32
|
+
}
|
|
33
|
+
return next;
|
|
34
|
+
}
|
|
35
|
+
|
|
4
36
|
export const initialState: AppState = {
|
|
5
37
|
// Navigation - start on plugins screen
|
|
6
38
|
currentRoute: { screen: "plugins" },
|
|
@@ -10,12 +42,17 @@ export const initialState: AppState = {
|
|
|
10
42
|
|
|
11
43
|
// Global UI state
|
|
12
44
|
modal: null,
|
|
45
|
+
notices: [],
|
|
46
|
+
noticesPausedAt: null,
|
|
13
47
|
progress: null,
|
|
14
48
|
isSearching: false,
|
|
15
49
|
|
|
16
50
|
// Refresh counter for triggering data refetch
|
|
17
51
|
dataRefreshVersion: 0,
|
|
18
52
|
|
|
53
|
+
// 0 is the check every open runs; each bump asks for another
|
|
54
|
+
gitignoreCheckRequest: 0,
|
|
55
|
+
|
|
19
56
|
// Screen-specific state
|
|
20
57
|
plugins: {
|
|
21
58
|
scope: "project",
|
|
@@ -777,6 +814,57 @@ export function appReducer(state: AppState, action: AppAction): AppState {
|
|
|
777
814
|
case "HIDE_MODAL":
|
|
778
815
|
return { ...state, modal: null };
|
|
779
816
|
|
|
817
|
+
// =========================================================================
|
|
818
|
+
// Notices
|
|
819
|
+
// =========================================================================
|
|
820
|
+
// The same text again replaces the old line rather than stacking a copy,
|
|
821
|
+
// and only a few are kept: the banner takes rows from the screen, and a
|
|
822
|
+
// burst must not push the content off the terminal. Which few is
|
|
823
|
+
// `withNotice`'s call — warnings outlast successes.
|
|
824
|
+
case "NOTIFY": {
|
|
825
|
+
const rest = state.notices.filter((n) => n.text !== action.notice.text);
|
|
826
|
+
return { ...state, notices: withNotice(rest, action.notice) };
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
case "EXPIRE_NOTICES": {
|
|
830
|
+
// Held behind a modal: nothing runs down, so nothing expires.
|
|
831
|
+
if (state.noticesPausedAt !== null) return state;
|
|
832
|
+
const live = state.notices.filter((n) => n.expiresAt > action.now);
|
|
833
|
+
// Same state when nothing expired, so the timer effect is not re-run.
|
|
834
|
+
return live.length === state.notices.length
|
|
835
|
+
? state
|
|
836
|
+
: { ...state, notices: live };
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// A modal opened. Only the first pause counts: modal to modal (a confirm
|
|
840
|
+
// followed by its loading modal) is one uninterrupted hold.
|
|
841
|
+
case "PAUSE_NOTICES":
|
|
842
|
+
return state.noticesPausedAt !== null
|
|
843
|
+
? state
|
|
844
|
+
: { ...state, noticesPausedAt: action.now };
|
|
845
|
+
|
|
846
|
+
// The modal slot emptied: every line gets back the time it spent held.
|
|
847
|
+
// A line posted DURING the hold would be over-credited by the time
|
|
848
|
+
// between the pause and its posting, so it is capped at its full
|
|
849
|
+
// duration from now — which is exactly what it would have had, had it
|
|
850
|
+
// been posted the moment the modal closed.
|
|
851
|
+
case "RESUME_NOTICES": {
|
|
852
|
+
const pausedAt = state.noticesPausedAt;
|
|
853
|
+
if (pausedAt === null) return state;
|
|
854
|
+
const held = action.now - pausedAt;
|
|
855
|
+
return {
|
|
856
|
+
...state,
|
|
857
|
+
noticesPausedAt: null,
|
|
858
|
+
notices: state.notices.map((n) => ({
|
|
859
|
+
...n,
|
|
860
|
+
expiresAt: Math.min(
|
|
861
|
+
n.expiresAt + held,
|
|
862
|
+
action.now + NOTICE_DURATION_MS[n.tone],
|
|
863
|
+
),
|
|
864
|
+
})),
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
|
|
780
868
|
// =========================================================================
|
|
781
869
|
// Progress
|
|
782
870
|
// =========================================================================
|
|
@@ -801,6 +889,17 @@ export function appReducer(state: AppState, action: AppAction): AppState {
|
|
|
801
889
|
case "DATA_REFRESH_COMPLETE":
|
|
802
890
|
return { ...state, dataRefreshVersion: state.dataRefreshVersion + 1 };
|
|
803
891
|
|
|
892
|
+
// One action for both, so no activation path can do one and forget the
|
|
893
|
+
// other: the screens reload what the profile turned on, and the gitignore
|
|
894
|
+
// question is asked again, because a first activation is what turns a
|
|
895
|
+
// committed settings file into a generated one git should not track.
|
|
896
|
+
case "PROFILE_ACTIVATED":
|
|
897
|
+
return {
|
|
898
|
+
...state,
|
|
899
|
+
dataRefreshVersion: state.dataRefreshVersion + 1,
|
|
900
|
+
gitignoreCheckRequest: state.gitignoreCheckRequest + 1,
|
|
901
|
+
};
|
|
902
|
+
|
|
804
903
|
default:
|
|
805
904
|
return state;
|
|
806
905
|
}
|