magus-cli 7.0.0 → 7.2.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 +58 -17
- package/src/__tests__/blackbox-plugin-state.test.ts +41 -19
- package/src/__tests__/cli-update-view.test.ts +100 -21
- package/src/__tests__/local-marketplace-scan.test.ts +74 -0
- package/src/__tests__/marketplace-refresh.test.ts +12 -7
- package/src/__tests__/plugin-row-text.test.ts +33 -24
- package/src/__tests__/profile-migration-offer.test.ts +211 -0
- package/src/__tests__/profile-migration.test.ts +242 -0
- package/src/__tests__/profile-startup.test.ts +458 -0
- package/src/__tests__/theme-env.test.ts +29 -0
- package/src/__tests__/update-plan.test.ts +57 -34
- package/src/__tests__/version-unknown-state.test.ts +29 -14
- package/src/cli/ansi.ts +58 -11
- package/src/cli/bootstrap.ts +169 -3
- package/src/cli/install.ts +5 -1
- package/src/cli/profile.ts +5 -1
- package/src/cli/prompt.ts +44 -12
- package/src/cli/router.ts +59 -2
- package/src/cli/tui-prompt.tsx +143 -0
- package/src/cli/update-view.ts +237 -45
- package/src/cli/update.ts +59 -29
- package/src/main.tsx +11 -2
- package/src/services/claude-settings.ts +15 -40
- package/src/services/local-marketplace.ts +76 -6
- package/src/services/manifest.ts +21 -0
- package/src/services/marketplace-refresh.ts +7 -15
- package/src/services/plugin-manager.ts +0 -31
- package/src/services/profile-activate.ts +147 -0
- package/src/services/profile-adopt.ts +50 -4
- package/src/services/profile-startup.ts +311 -0
- package/src/services/profiles.ts +49 -11
- package/src/services/resolver.ts +33 -0
- package/src/services/symlink-manager.ts +184 -30
- package/src/services/update-plan.ts +23 -66
- package/src/types/index.ts +44 -23
- package/src/ui/App.tsx +60 -3
- package/src/ui/components/layout/FooterHints.tsx +21 -9
- package/src/ui/components/modals/ModalContainer.tsx +1 -0
- package/src/ui/components/modals/SelectModal.tsx +142 -21
- package/src/ui/hooks/useProfileStartupModal.ts +260 -0
- package/src/ui/renderers/profileRenderers.tsx +27 -4
- package/src/ui/screens/PluginsScreen.tsx +9 -50
- package/src/ui/screens/ProfilesScreen.tsx +1 -1
- package/src/ui/state/types.ts +18 -0
- package/src/ui/theme-mode.ts +142 -0
- package/src/utils/command-utils.ts +19 -14
- package/src/__tests__/moved-marketplace.test.ts +0 -86
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type React from "react";
|
|
2
|
+
import { hintChip } from "../../theme-mode.js";
|
|
2
3
|
import { theme } from "../../theme.js";
|
|
3
4
|
|
|
4
5
|
/** One footer hint: a key (or key group) and the action it performs. */
|
|
@@ -28,11 +29,12 @@ export function joinKeys(keys: string[]): string {
|
|
|
28
29
|
// The shared chip palette, not a local one. This file used to define its own
|
|
29
30
|
// (`theme.colors.dim` block + terminal-foreground ink), which is why the footer
|
|
30
31
|
// stayed a low-contrast grey-on-grey strip after the palette was made vivid —
|
|
31
|
-
// the new colours simply never reached it. Anything chip-shaped uses theme.
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
// the new colours simply never reached it. Anything chip-shaped uses the theme.
|
|
33
|
+
//
|
|
34
|
+
// `hintChip()` rather than `theme.hints`: the chip is two fills that must sit a
|
|
35
|
+
// measured step apart, and "a step quieter" is only definable against a page.
|
|
36
|
+
// The static palette cannot express it; `theme-mode` resolves it.
|
|
37
|
+
const GAP = " ";
|
|
36
38
|
|
|
37
39
|
/**
|
|
38
40
|
* Render a row of footer hints as key badges + labels:
|
|
@@ -45,25 +47,35 @@ const GAP = " ";
|
|
|
45
47
|
export function FooterHints({
|
|
46
48
|
hints,
|
|
47
49
|
}: { hints: FooterHint[] }): React.ReactNode {
|
|
50
|
+
const ink = hintChip();
|
|
48
51
|
const nodes: React.ReactNode[] = [];
|
|
49
52
|
hints.forEach((hint, i) => {
|
|
50
53
|
// A hint is identified by its key chord plus label, not its position.
|
|
51
54
|
const id = `${joinKeys(hint.keys)}:${hint.label}`;
|
|
52
55
|
if (i > 0) {
|
|
53
56
|
nodes.push(
|
|
54
|
-
<span key={`gap-${id}`} fg={
|
|
57
|
+
<span key={`gap-${id}`} fg={theme.colors.muted}>
|
|
55
58
|
{GAP}
|
|
56
59
|
</span>,
|
|
57
60
|
);
|
|
58
61
|
}
|
|
62
|
+
// The two halves butt together with no separator: the key's fill ends
|
|
63
|
+
// exactly where the label's begins, so the pair reads as one chip with a
|
|
64
|
+
// bright end rather than as a badge followed by a word.
|
|
65
|
+
//
|
|
66
|
+
// Both halves are padded on BOTH sides. The label used to carry a trailing
|
|
67
|
+
// space only, which left its first letter hard against the boundary between
|
|
68
|
+
// the two fills — the key's own trailing space sits on the bright fill, so
|
|
69
|
+
// there was no quiet gap on the label's side at all and `↑↓ select` read as
|
|
70
|
+
// one cramped word.
|
|
59
71
|
nodes.push(
|
|
60
|
-
<span key={`k-${id}`} bg={
|
|
72
|
+
<span key={`k-${id}`} bg={ink.keyBg} fg={ink.keyFg}>
|
|
61
73
|
{` ${joinKeys(hint.keys)} `}
|
|
62
74
|
</span>,
|
|
63
75
|
);
|
|
64
76
|
nodes.push(
|
|
65
|
-
<span key={`l-${id}`} fg={
|
|
66
|
-
{` ${hint.label}`}
|
|
77
|
+
<span key={`l-${id}`} bg={ink.labelBg} fg={ink.labelFg}>
|
|
78
|
+
{` ${hint.label} `}
|
|
67
79
|
</span>,
|
|
68
80
|
);
|
|
69
81
|
});
|
|
@@ -1,10 +1,41 @@
|
|
|
1
|
-
import React from "react";
|
|
1
|
+
import type React from "react";
|
|
2
2
|
import { useDimensions } from "../../state/DimensionsContext.js";
|
|
3
3
|
import type { SelectOption } from "../../state/types.js";
|
|
4
|
+
import { type ReportBadgeKind, reportBadge } from "../../theme-mode.js";
|
|
4
5
|
import { theme } from "../../theme.js";
|
|
6
|
+
import { FooterHints } from "../layout/FooterHints.js";
|
|
7
|
+
|
|
8
|
+
/** Pad a label so every badge and meta column starts at the same cell. */
|
|
9
|
+
function labelPad(label: string, to: number): string {
|
|
10
|
+
return " ".repeat(Math.max(0, to - label.length) + 2);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A discrete state as a chip: dark ink on a saturated fill, padded OUTSIDE the
|
|
15
|
+
* fill.
|
|
16
|
+
*
|
|
17
|
+
* Padding inside the label would extend the background, and a column of chips
|
|
18
|
+
* padded that way fuses into one solid rectangle with the words floating in it.
|
|
19
|
+
*/
|
|
20
|
+
function Chip({
|
|
21
|
+
badge,
|
|
22
|
+
width,
|
|
23
|
+
}: {
|
|
24
|
+
badge: { label: string; kind: ReportBadgeKind };
|
|
25
|
+
width: number;
|
|
26
|
+
}): React.ReactNode {
|
|
27
|
+
const ink = reportBadge(badge.kind);
|
|
28
|
+
const pad = Math.max(0, width - badge.label.length - 2);
|
|
29
|
+
return (
|
|
30
|
+
<>
|
|
31
|
+
<span bg={ink.fill} fg={ink.ink}>{` ${badge.label} `}</span>
|
|
32
|
+
<span fg={theme.colors.dim}>{" ".repeat(pad)}</span>
|
|
33
|
+
</>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
5
36
|
|
|
6
37
|
interface SelectModalProps {
|
|
7
|
-
/** Modal title */
|
|
38
|
+
/** Modal title — drawn in the border, not as a body row. */
|
|
8
39
|
title: string;
|
|
9
40
|
/** Modal message */
|
|
10
41
|
message: string;
|
|
@@ -16,6 +47,8 @@ interface SelectModalProps {
|
|
|
16
47
|
onSelect: (value: string) => void;
|
|
17
48
|
/** Callback when cancelled */
|
|
18
49
|
onCancel: () => void;
|
|
50
|
+
/** False for a question that must be answered — drops the `esc` hint. */
|
|
51
|
+
cancellable?: boolean;
|
|
19
52
|
}
|
|
20
53
|
|
|
21
54
|
export function SelectModal({
|
|
@@ -25,6 +58,7 @@ export function SelectModal({
|
|
|
25
58
|
defaultIndex,
|
|
26
59
|
onSelect: _onSelect,
|
|
27
60
|
onCancel: _onCancel,
|
|
61
|
+
cancellable = true,
|
|
28
62
|
}: SelectModalProps) {
|
|
29
63
|
// Keyboard handling is done by ModalContainer
|
|
30
64
|
// defaultIndex is the live selectedIndex from ModalContainer state
|
|
@@ -40,48 +74,135 @@ export function SelectModal({
|
|
|
40
74
|
Math.min(90, Math.floor(dimensions.terminalWidth * 0.6)),
|
|
41
75
|
);
|
|
42
76
|
|
|
77
|
+
// Columns a list row may paint: the box width less its border and padding.
|
|
78
|
+
// The selected row is a FULL-WIDTH fill, so it has to know the row width
|
|
79
|
+
// rather than ending wherever its label happens to end.
|
|
80
|
+
const inner = width - 6;
|
|
81
|
+
|
|
82
|
+
// Column widths measured over the whole list, so badges and bars line up
|
|
83
|
+
// down the column instead of drifting with each label's length.
|
|
84
|
+
const labelWidth = Math.max(...options.map((o) => o.label.length), 0);
|
|
85
|
+
const badgeWidth = Math.max(
|
|
86
|
+
...options.map((o) => (o.badge ? o.badge.label.length + 2 : 0)),
|
|
87
|
+
0,
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
// Meta is dim trailing detail, so it is the first thing to go when the row
|
|
91
|
+
// will not fit — dropped for EVERY row, not per row, because a column that
|
|
92
|
+
// appears on some rows and not others is worse than no column.
|
|
93
|
+
//
|
|
94
|
+
// Without this the row simply overflowed and the terminal wrapped it:
|
|
95
|
+
// MEASURED on screen, a 41-cell label put "14 plugins" on a line of its own
|
|
96
|
+
// under the option, reading as a second list item. The longest label sets the
|
|
97
|
+
// column, so one long option breaks every row's meta at once.
|
|
98
|
+
const metaWidth = Math.max(
|
|
99
|
+
...options.map((o) => (o.meta ? o.meta.length + 2 : 0)),
|
|
100
|
+
0,
|
|
101
|
+
);
|
|
102
|
+
const showMeta = 2 + labelWidth + 2 + badgeWidth + metaWidth <= inner;
|
|
103
|
+
|
|
43
104
|
return (
|
|
44
105
|
<box
|
|
45
106
|
flexDirection="column"
|
|
46
107
|
border
|
|
47
108
|
borderStyle="rounded"
|
|
48
|
-
|
|
109
|
+
// A modal is the focused surface by definition, and accent means
|
|
110
|
+
// focus everywhere else in this app — so the border wears it here
|
|
111
|
+
// too, and the title drawn in it comes along in the same tone.
|
|
112
|
+
borderColor={theme.colors.accent}
|
|
49
113
|
backgroundColor={theme.surface.bg}
|
|
50
|
-
|
|
51
|
-
|
|
114
|
+
title={title}
|
|
115
|
+
titleAlignment="left"
|
|
116
|
+
paddingLeft={2}
|
|
117
|
+
paddingRight={2}
|
|
52
118
|
paddingTop={1}
|
|
53
119
|
paddingBottom={1}
|
|
54
120
|
width={width}
|
|
55
121
|
>
|
|
56
|
-
<box marginBottom={1}>
|
|
57
|
-
<text fg={theme.colors.text}>
|
|
58
|
-
<strong>{title}</strong>
|
|
59
|
-
</text>
|
|
60
|
-
</box>
|
|
61
|
-
|
|
62
122
|
<box marginBottom={1}>
|
|
63
123
|
<text fg={theme.colors.muted}>{message}</text>
|
|
64
124
|
</box>
|
|
65
125
|
|
|
66
|
-
<box flexDirection="column"
|
|
126
|
+
<box flexDirection="column">
|
|
67
127
|
{options.map((option, idx) => {
|
|
68
128
|
const isSelected = idx === selectedIndex;
|
|
129
|
+
const pad = labelPad(option.label, labelWidth);
|
|
130
|
+
// The plain width of everything the row draws, so the
|
|
131
|
+
// selected fill can be padded out to the full row width.
|
|
132
|
+
const drawn =
|
|
133
|
+
2 +
|
|
134
|
+
option.label.length +
|
|
135
|
+
pad.length +
|
|
136
|
+
badgeWidth +
|
|
137
|
+
(showMeta && option.meta ? option.meta.length + 2 : 0);
|
|
138
|
+
const fill = " ".repeat(Math.max(0, inner - drawn));
|
|
139
|
+
|
|
140
|
+
if (isSelected) {
|
|
141
|
+
// The selected row owns the colour channel — see
|
|
142
|
+
// `theme.selection`. On its fill no page-level tone
|
|
143
|
+
// clears contrast, so the row has exactly two inks:
|
|
144
|
+
// `fg` for the choice, `dim` for what surrounds it.
|
|
145
|
+
// A badge surrenders its chip the same way a model
|
|
146
|
+
// ink does on the Models screen.
|
|
147
|
+
return (
|
|
148
|
+
<text
|
|
149
|
+
key={option.value}
|
|
150
|
+
bg={theme.selection.bg}
|
|
151
|
+
fg={theme.selection.fg}
|
|
152
|
+
>
|
|
153
|
+
<span>❯ </span>
|
|
154
|
+
<strong>{option.label}</strong>
|
|
155
|
+
<span>{pad}</span>
|
|
156
|
+
{option.badge ? (
|
|
157
|
+
<span fg={theme.selection.dim}>{option.badge.label}</span>
|
|
158
|
+
) : null}
|
|
159
|
+
{badgeWidth > 0 ? (
|
|
160
|
+
<span fg={theme.selection.dim}>{" ".repeat(badgeWidth)}</span>
|
|
161
|
+
) : null}
|
|
162
|
+
{showMeta && option.meta ? (
|
|
163
|
+
<span fg={theme.selection.dim}>{` ${option.meta}`}</span>
|
|
164
|
+
) : null}
|
|
165
|
+
<span>{fill}</span>
|
|
166
|
+
</text>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Unselected rows sit in the terminal's own foreground —
|
|
171
|
+
// NOT muted. Muting every unselected row turned the list
|
|
172
|
+
// into the wall of grey this dialog was rejected for; the
|
|
173
|
+
// choices are the signal, and only the chrome is dim.
|
|
69
174
|
return (
|
|
70
|
-
<text
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
{
|
|
76
|
-
|
|
77
|
-
|
|
175
|
+
<text key={option.value} fg={theme.colors.text}>
|
|
176
|
+
<span>{" "}</span>
|
|
177
|
+
<span>{option.label}</span>
|
|
178
|
+
<span>{pad}</span>
|
|
179
|
+
{option.badge ? (
|
|
180
|
+
<Chip badge={option.badge} width={badgeWidth} />
|
|
181
|
+
) : (
|
|
182
|
+
<span fg={theme.colors.dim}>{" ".repeat(badgeWidth)}</span>
|
|
183
|
+
)}
|
|
184
|
+
{showMeta && option.meta ? (
|
|
185
|
+
<span fg={theme.colors.muted}>{` ${option.meta}`}</span>
|
|
186
|
+
) : null}
|
|
78
187
|
</text>
|
|
79
188
|
);
|
|
80
189
|
})}
|
|
81
190
|
</box>
|
|
82
191
|
|
|
192
|
+
{/* The shared footer key chips, not a prose hint line: a chip reads
|
|
193
|
+
as a pressable key at a glance, and every other footer in the
|
|
194
|
+
app already renders this way. */}
|
|
83
195
|
<box marginTop={1}>
|
|
84
|
-
<
|
|
196
|
+
<FooterHints
|
|
197
|
+
hints={[
|
|
198
|
+
{ keys: ["↑", "↓"], label: "select" },
|
|
199
|
+
{ keys: ["↵"], label: "confirm" },
|
|
200
|
+
// Omitted when there is nothing to cancel to. A hint for a key
|
|
201
|
+
// that does nothing is worse than no hint: the user presses it,
|
|
202
|
+
// nothing happens, and they read the app as hung.
|
|
203
|
+
...(cancellable ? [{ keys: ["esc"], label: "cancel" }] : []),
|
|
204
|
+
]}
|
|
205
|
+
/>
|
|
85
206
|
</box>
|
|
86
207
|
</box>
|
|
87
208
|
);
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Startup profile modal: magus opens, finds the project is not running a
|
|
3
|
+
* profile, and either applies the repo's declared answer or requires one.
|
|
4
|
+
*
|
|
5
|
+
* This file is only the React wiring. What to ask, which options to offer, which
|
|
6
|
+
* row to start on and what a chosen value means all live in
|
|
7
|
+
* `services/profile-startup.ts`, and carrying a choice out lives in
|
|
8
|
+
* `services/profile-activate.ts` — both shared with the CLI, so the two
|
|
9
|
+
* surfaces cannot ask different questions or write different things in answer
|
|
10
|
+
* to the same one.
|
|
11
|
+
*
|
|
12
|
+
* The modal cannot be dismissed. Without an active profile there is no profile
|
|
13
|
+
* state to read and nowhere to write updates, so letting the user past it would
|
|
14
|
+
* drop them into a magus that cannot do its job. Its only non-action is to
|
|
15
|
+
* leave, which is why it needs the app's own exit.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { useCallback } from "react";
|
|
19
|
+
import {
|
|
20
|
+
type ProfileChoice,
|
|
21
|
+
applyProfileChoice,
|
|
22
|
+
} from "../../services/profile-activate.js";
|
|
23
|
+
import {
|
|
24
|
+
type ProfileStartupNeed,
|
|
25
|
+
buildProfileOptions,
|
|
26
|
+
defaultOptionIndex,
|
|
27
|
+
describeNeed,
|
|
28
|
+
parseProfileChoice,
|
|
29
|
+
} from "../../services/profile-startup.js";
|
|
30
|
+
import type { DisplacedArtifact } from "../../services/symlink-manager.js";
|
|
31
|
+
import { useApp, useNavigation } from "../state/AppContext.js";
|
|
32
|
+
import type { ModalState } from "../state/types.js";
|
|
33
|
+
|
|
34
|
+
export interface BuildProfileModalArgs {
|
|
35
|
+
need: ProfileStartupNeed;
|
|
36
|
+
onChoose: (choice: ProfileChoice) => void;
|
|
37
|
+
/**
|
|
38
|
+
* Leave magus. NOT "dismiss" — there is deliberately no way to carry on
|
|
39
|
+
* without a profile, because there is nothing to carry on with.
|
|
40
|
+
*/
|
|
41
|
+
onQuit: () => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function buildProfileStartupModal(
|
|
45
|
+
args: BuildProfileModalArgs,
|
|
46
|
+
): ModalState {
|
|
47
|
+
const { need, onChoose, onQuit } = args;
|
|
48
|
+
const { title, message } = describeNeed(need);
|
|
49
|
+
return {
|
|
50
|
+
type: "select",
|
|
51
|
+
title,
|
|
52
|
+
message,
|
|
53
|
+
options: buildProfileOptions(need),
|
|
54
|
+
// The row the repo declares, so Enter does the intended thing for a
|
|
55
|
+
// developer who has local work and therefore still gets asked.
|
|
56
|
+
defaultIndex: defaultOptionIndex(need),
|
|
57
|
+
onSelect: (value: string) => {
|
|
58
|
+
const choice = parseProfileChoice(value, need);
|
|
59
|
+
if (choice) onChoose(choice);
|
|
60
|
+
else onQuit();
|
|
61
|
+
},
|
|
62
|
+
// Escape and `q` route here, and here they do NOTHING. Dismissing would
|
|
63
|
+
// drop the user into the app this modal exists to say is unusable. Leaving
|
|
64
|
+
// is the "Quit magus" row, which says so on screen rather than making the
|
|
65
|
+
// user guess a keystroke.
|
|
66
|
+
onCancel: () => {},
|
|
67
|
+
// ...and the footer must not offer `esc cancel` when that is what Escape
|
|
68
|
+
// does. The two have to agree or the app reads as hung.
|
|
69
|
+
cancellable: false,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** What changed, phrased for a message modal rather than a terminal. */
|
|
74
|
+
export function describeOutcome(
|
|
75
|
+
profileId: string,
|
|
76
|
+
displaced: DisplacedArtifact[],
|
|
77
|
+
projectPath: string,
|
|
78
|
+
/** True when this happened without asking, following the committed default. */
|
|
79
|
+
automatic = false,
|
|
80
|
+
): string {
|
|
81
|
+
const lines = [
|
|
82
|
+
automatic
|
|
83
|
+
? `This project declares "${profileId}" as its profile, so magus activated it.`
|
|
84
|
+
: `"${profileId}" is now the active profile.`,
|
|
85
|
+
];
|
|
86
|
+
if (displaced.length > 0) {
|
|
87
|
+
lines.push(
|
|
88
|
+
"",
|
|
89
|
+
"These were moved aside to make room for links into the profile, and are kept:",
|
|
90
|
+
...displaced.map((d) => {
|
|
91
|
+
const from = d.link.startsWith(projectPath)
|
|
92
|
+
? d.link.slice(projectPath.length + 1)
|
|
93
|
+
: d.link;
|
|
94
|
+
const to = d.savedTo.startsWith(projectPath)
|
|
95
|
+
? d.savedTo.slice(projectPath.length + 1)
|
|
96
|
+
: d.savedTo;
|
|
97
|
+
return ` ${from} → ${to}`;
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
return lines.join("\n");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Project-relative, so a path does not wrap over two lines. */
|
|
105
|
+
function rel(p: string, projectPath: string): string {
|
|
106
|
+
return p.startsWith(projectPath) ? p.slice(projectPath.length + 1) : p;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* What to say when activation threw partway through.
|
|
111
|
+
*
|
|
112
|
+
* The hard part is that a failure is NOT always a no-op. Activation moves up to
|
|
113
|
+
* four artifacts in sequence and `settings.json` goes first, so a throw on the
|
|
114
|
+
* second leaves it already in the backup. The old message said "Nothing was
|
|
115
|
+
* activated" unconditionally, which in exactly that case is false about the
|
|
116
|
+
* user's own file — and `.magus-backups/` is gitignored, so `git status` showed
|
|
117
|
+
* the deletion with no sign of the copy. It read as "magus deleted my settings".
|
|
118
|
+
*
|
|
119
|
+
* So the sentence is chosen by what actually moved, and when anything did, the
|
|
120
|
+
* backup paths are named here rather than left in a return value the error path
|
|
121
|
+
* never receives.
|
|
122
|
+
*/
|
|
123
|
+
export function describeFailure(
|
|
124
|
+
error: unknown,
|
|
125
|
+
moved: DisplacedArtifact[],
|
|
126
|
+
projectPath: string,
|
|
127
|
+
declaredDefault: string | null,
|
|
128
|
+
): string {
|
|
129
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
130
|
+
if (moved.length === 0) {
|
|
131
|
+
return `${reason}\n\nNothing was moved, so this project is exactly as it was.`;
|
|
132
|
+
}
|
|
133
|
+
return [
|
|
134
|
+
reason,
|
|
135
|
+
"",
|
|
136
|
+
"This did not finish, and these were already moved aside. They are kept:",
|
|
137
|
+
...moved.map(
|
|
138
|
+
(d) => ` ${rel(d.link, projectPath)} → ${rel(d.savedTo, projectPath)}`,
|
|
139
|
+
),
|
|
140
|
+
"",
|
|
141
|
+
// The trap the reviewer found: re-choosing "save what you have" after a
|
|
142
|
+
// partial failure runs adoption again, and adoption reads settings.json —
|
|
143
|
+
// which is now in the backup. It would capture an empty plugin set into a
|
|
144
|
+
// second profile and call that the user's setup.
|
|
145
|
+
declaredDefault !== null
|
|
146
|
+
? `Activating ${declaredDefault} again is safe. Do NOT save-what-you-have — the files above have moved, so it would capture an empty set.`
|
|
147
|
+
: "Activating a profile again is safe. Do NOT save-what-you-have — the files above have moved, so it would capture an empty set.",
|
|
148
|
+
].join("\n");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function useProfileStartupModal(onQuit: () => void) {
|
|
152
|
+
const { state, dispatch } = useApp();
|
|
153
|
+
const { navigateToScreen } = useNavigation();
|
|
154
|
+
const projectPath = state.projectPath;
|
|
155
|
+
|
|
156
|
+
const show = useCallback(
|
|
157
|
+
(need: ProfileStartupNeed) => {
|
|
158
|
+
/** Apply a choice, reporting the outcome either way. */
|
|
159
|
+
const apply = (choice: ProfileChoice, automatic: boolean): void => {
|
|
160
|
+
dispatch({
|
|
161
|
+
type: "SHOW_MODAL",
|
|
162
|
+
modal: {
|
|
163
|
+
type: "loading",
|
|
164
|
+
message:
|
|
165
|
+
choice.kind === "adopt"
|
|
166
|
+
? `Creating "${choice.id}"…`
|
|
167
|
+
: `Activating ${choice.id}…`,
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
// Collected as they move, NOT read from the resolved result.
|
|
171
|
+
//
|
|
172
|
+
// Activation moves up to four artifacts in sequence and
|
|
173
|
+
// `settings.json` goes first, so a throw on the second leaves the
|
|
174
|
+
// user's file already in the backup — and the resolved value that
|
|
175
|
+
// would have listed it never arrives. This array is the only record
|
|
176
|
+
// the error path has, and without it the failure modal claimed
|
|
177
|
+
// "Nothing was activated" while their settings.json was gone from
|
|
178
|
+
// where they left it. `.magus-backups/` is gitignored, so git status
|
|
179
|
+
// showed the deletion and no sign of the copy.
|
|
180
|
+
const moved: DisplacedArtifact[] = [];
|
|
181
|
+
void applyProfileChoice(
|
|
182
|
+
choice,
|
|
183
|
+
need.manifest,
|
|
184
|
+
projectPath,
|
|
185
|
+
(artifact) => moved.push(artifact),
|
|
186
|
+
)
|
|
187
|
+
.then(({ profileId, displaced }) => {
|
|
188
|
+
dispatch({
|
|
189
|
+
type: "SHOW_MODAL",
|
|
190
|
+
modal: {
|
|
191
|
+
type: "message",
|
|
192
|
+
title: "Profile active",
|
|
193
|
+
message: describeOutcome(
|
|
194
|
+
profileId,
|
|
195
|
+
displaced,
|
|
196
|
+
projectPath,
|
|
197
|
+
automatic,
|
|
198
|
+
),
|
|
199
|
+
variant: "success",
|
|
200
|
+
onDismiss: () => {
|
|
201
|
+
dispatch({ type: "HIDE_MODAL" });
|
|
202
|
+
// The Profiles tab is where the result is visible, and
|
|
203
|
+
// where the user will want to look next.
|
|
204
|
+
navigateToScreen("profiles");
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
})
|
|
209
|
+
.catch((error: unknown) => {
|
|
210
|
+
dispatch({
|
|
211
|
+
type: "SHOW_MODAL",
|
|
212
|
+
modal: {
|
|
213
|
+
type: "message",
|
|
214
|
+
title:
|
|
215
|
+
choice.kind === "adopt"
|
|
216
|
+
? "Could not create the profile"
|
|
217
|
+
: "Could not activate the profile",
|
|
218
|
+
message: describeFailure(
|
|
219
|
+
error,
|
|
220
|
+
moved,
|
|
221
|
+
projectPath,
|
|
222
|
+
need.declaredDefault,
|
|
223
|
+
),
|
|
224
|
+
variant: "error",
|
|
225
|
+
// A failure leaves the project without an active profile, so
|
|
226
|
+
// dismissing returns to the question — including when the
|
|
227
|
+
// attempt was automatic, which is the one case where there
|
|
228
|
+
// was no question on screen yet.
|
|
229
|
+
onDismiss: ask,
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
function ask(): void {
|
|
236
|
+
dispatch({
|
|
237
|
+
type: "SHOW_MODAL",
|
|
238
|
+
modal: buildProfileStartupModal({
|
|
239
|
+
need,
|
|
240
|
+
onChoose: (choice) => apply(choice, false),
|
|
241
|
+
onQuit,
|
|
242
|
+
}),
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// The repo already answered and there is nothing of the user's to
|
|
247
|
+
// replace: asking here would ask someone to re-decide what their team
|
|
248
|
+
// decided, with nothing at stake. Reported in the outcome modal, so it
|
|
249
|
+
// is never invisible.
|
|
250
|
+
if (need.autoActivate && need.declaredDefault !== null) {
|
|
251
|
+
apply({ kind: "activate", id: need.declaredDefault }, true);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
ask();
|
|
255
|
+
},
|
|
256
|
+
[dispatch, navigateToScreen, projectPath, onQuit],
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
return { show };
|
|
260
|
+
}
|
|
@@ -68,7 +68,16 @@ export function wrapNames(names: string[], lineMax = 40): string[] {
|
|
|
68
68
|
return lines;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
/**
|
|
72
|
+
* A profile's timestamp, or an em dash when it has none.
|
|
73
|
+
*
|
|
74
|
+
* `createdAt`/`updatedAt` are bookkeeping the TUI writes and the CLI does not,
|
|
75
|
+
* so a CLI-authored or hand-written profile legitimately has neither. Rendering
|
|
76
|
+
* "—" states that plainly; the alternative, printing "Invalid Date", reads as a
|
|
77
|
+
* corrupt file rather than an absent field.
|
|
78
|
+
*/
|
|
79
|
+
export function formatDate(iso: string | undefined): string {
|
|
80
|
+
if (!iso) return "—";
|
|
72
81
|
try {
|
|
73
82
|
const d = new Date(iso);
|
|
74
83
|
return d.toLocaleDateString("en-US", {
|
|
@@ -232,18 +241,23 @@ const predefinedRenderer: ItemRenderer<{
|
|
|
232
241
|
const savedRenderer: ItemRenderer<{ kind: "saved"; entry: ProfileEntry }> = {
|
|
233
242
|
renderRow: ({ item, isSelected }) => {
|
|
234
243
|
const { entry } = item;
|
|
235
|
-
const pluginCount = Object.keys(entry.plugins).length;
|
|
244
|
+
const pluginCount = Object.keys(entry.plugins ?? {}).length;
|
|
236
245
|
const dateStr = formatDate(entry.updatedAt);
|
|
237
246
|
const label = truncate(
|
|
238
247
|
`${entry.name} — ${pluginCount} plugin${pluginCount !== 1 ? "s" : ""} · ${dateStr}`,
|
|
239
248
|
45,
|
|
240
249
|
);
|
|
250
|
+
// `●` for the profile the project is actually running, matching the glyph
|
|
251
|
+
// `magus profile list` prints. A column that is blank on every row costs
|
|
252
|
+
// two cells and is worth it: without the mark, activating a profile and
|
|
253
|
+
// landing on this tab shows two rows that look identical.
|
|
254
|
+
const marker = entry.active ? "●" : " ";
|
|
241
255
|
|
|
242
256
|
if (isSelected) {
|
|
243
257
|
return (
|
|
244
258
|
<text bg={theme.selection.bg} fg={theme.selection.fg}>
|
|
245
259
|
{" "}
|
|
246
|
-
{label}{" "}
|
|
260
|
+
{marker} {label}{" "}
|
|
247
261
|
</text>
|
|
248
262
|
);
|
|
249
263
|
}
|
|
@@ -254,6 +268,9 @@ const savedRenderer: ItemRenderer<{ kind: "saved"; entry: ProfileEntry }> = {
|
|
|
254
268
|
|
|
255
269
|
return (
|
|
256
270
|
<text fg={theme.colors.text}>
|
|
271
|
+
<span fg={entry.active ? theme.colors.success : theme.colors.text}>
|
|
272
|
+
{marker}{" "}
|
|
273
|
+
</span>
|
|
257
274
|
<span fg={scopeColor}>{scopeLabel} </span>
|
|
258
275
|
<span fg={theme.colors.text}>{label}</span>
|
|
259
276
|
</text>
|
|
@@ -262,7 +279,7 @@ const savedRenderer: ItemRenderer<{ kind: "saved"; entry: ProfileEntry }> = {
|
|
|
262
279
|
|
|
263
280
|
renderDetail: ({ item }) => {
|
|
264
281
|
const { entry: selectedProfile } = item;
|
|
265
|
-
const plugins = Object.keys(selectedProfile.plugins);
|
|
282
|
+
const plugins = Object.keys(selectedProfile.plugins ?? {});
|
|
266
283
|
const cleanPlugins = plugins.map(stripSuffix);
|
|
267
284
|
const scopeColor =
|
|
268
285
|
selectedProfile.scope === "user"
|
|
@@ -281,6 +298,12 @@ const savedRenderer: ItemRenderer<{ kind: "saved"; entry: ProfileEntry }> = {
|
|
|
281
298
|
<box marginTop={1}>
|
|
282
299
|
<text fg={theme.colors.muted}>Scope: </text>
|
|
283
300
|
<text fg={scopeColor}>{scopeLabel}</text>
|
|
301
|
+
{/* Stated in words as well as the row glyph: the detail pane is
|
|
302
|
+
where someone looks to check, and "●" alone is not an answer
|
|
303
|
+
to "is this the one that is running?". */}
|
|
304
|
+
{selectedProfile.active ? (
|
|
305
|
+
<text fg={theme.colors.success}> · active</text>
|
|
306
|
+
) : null}
|
|
284
307
|
</box>
|
|
285
308
|
<box marginTop={1}>
|
|
286
309
|
<text fg={theme.colors.muted}>
|