catherd-cli 0.2.1 → 1.1.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/README.md +141 -33
- package/THIRD_PARTY_NOTICES.md +37 -0
- package/catalog/jev.json +89 -0
- package/catalog/models.json +236 -0
- package/catalog/scores.json +366 -0
- package/package.json +24 -8
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.mcp.json +2 -2
- package/plugin/bin/catherd-mcp +30 -0
- package/plugin/commands/catherd.md +2 -2
- package/plugin/skills/catherd/SKILL.md +160 -97
- package/plugin/skills/catherd-setup/SKILL.md +34 -24
- package/src/adapters/access.ts +63 -0
- package/src/adapters/all.ts +8 -0
- package/src/adapters/backend.ts +159 -0
- package/src/adapters/claude-code/events.ts +86 -0
- package/src/adapters/claude-code/index.ts +317 -0
- package/src/adapters/claude-code/models-api.ts +65 -0
- package/src/adapters/claude-code/models.ts +26 -0
- package/src/adapters/cli.ts +74 -0
- package/src/adapters/codex/events.ts +65 -0
- package/src/adapters/codex/home.ts +47 -0
- package/src/adapters/codex/index.ts +282 -0
- package/src/adapters/discovery.ts +87 -0
- package/src/adapters/opencode/agents.ts +84 -0
- package/src/adapters/opencode/events.ts +109 -0
- package/src/adapters/opencode/index.ts +333 -0
- package/src/adapters/registry.ts +14 -0
- package/src/cli.ts +159 -19
- package/src/domain/agents.ts +60 -0
- package/src/domain/budget.ts +45 -0
- package/src/domain/catalog.ts +262 -0
- package/src/domain/changes.ts +52 -0
- package/src/domain/cost.ts +89 -0
- package/src/domain/errors.ts +60 -0
- package/src/domain/failover.ts +119 -0
- package/src/domain/hints.ts +22 -0
- package/src/domain/ids.ts +97 -0
- package/src/domain/jev.ts +283 -0
- package/src/domain/lane.ts +92 -0
- package/src/domain/notice.ts +95 -0
- package/src/domain/profile-rules.ts +239 -0
- package/src/domain/profile.ts +389 -0
- package/src/domain/record.ts +86 -0
- package/src/{profile → domain}/role-prompts.ts +75 -27
- package/src/domain/roles.ts +25 -0
- package/src/domain/route.ts +126 -0
- package/src/domain/runtime.ts +21 -0
- package/src/domain/sanitize.ts +29 -0
- package/src/domain/secrets.ts +57 -0
- package/src/domain/select.ts +136 -0
- package/src/domain/state.ts +49 -0
- package/src/domain/util.ts +18 -0
- package/src/entry/capture-fixtures-command.ts +57 -0
- package/src/entry/catalog-command.ts +106 -0
- package/src/entry/cli-kit.ts +36 -0
- package/src/entry/deps.ts +17 -0
- package/src/entry/doctor-command.ts +51 -0
- package/src/entry/glyphs.ts +34 -0
- package/src/entry/init-command.ts +182 -0
- package/src/entry/lock-command.ts +123 -0
- package/src/{mcp/commands.ts → entry/mcp/command.ts} +5 -1
- package/src/entry/mcp/dispatch-tools.ts +52 -0
- package/src/entry/mcp/handshake.ts +58 -0
- package/src/entry/mcp/lane-tools.ts +84 -0
- package/src/entry/mcp/protocol-tools.ts +57 -0
- package/src/entry/mcp/result.ts +51 -0
- package/src/entry/mcp/run-tools.ts +145 -0
- package/src/entry/mcp/server.ts +88 -0
- package/src/entry/mcp/setup-tools.ts +81 -0
- package/src/entry/profile-command.ts +318 -0
- package/src/entry/prompt.ts +103 -0
- package/src/entry/runs-command.ts +260 -0
- package/src/entry/supervise-bin.ts +18 -0
- package/src/entry/supervise-command.ts +37 -0
- package/src/entry/tui/commands.ts +545 -0
- package/src/entry/tui/effects.ts +312 -0
- package/src/entry/tui/fixtures.ts +585 -0
- package/src/entry/tui/profile-edits.ts +138 -0
- package/src/entry/tui/profile-tree.ts +420 -0
- package/src/entry/tui/providers/app.tsx +162 -0
- package/src/entry/tui/providers/boundary.tsx +57 -0
- package/src/entry/tui/providers/data.tsx +160 -0
- package/src/entry/tui/providers/keymap.tsx +166 -0
- package/src/entry/tui/providers/root.tsx +45 -0
- package/src/entry/tui/providers/theme.tsx +17 -0
- package/src/entry/tui/providers/toast.tsx +77 -0
- package/src/entry/tui/run.tsx +98 -0
- package/src/entry/tui/state.ts +356 -0
- package/src/entry/tui/stories.ts +107 -0
- package/src/entry/tui/text.ts +130 -0
- package/src/entry/tui/theme.ts +140 -0
- package/src/entry/tui/views/app.tsx +208 -0
- package/src/entry/tui/views/dialogs.tsx +41 -0
- package/src/entry/tui/views/profile-actions.ts +321 -0
- package/src/entry/tui/views/profiles.tsx +440 -0
- package/src/entry/tui/views/runs.tsx +573 -0
- package/src/entry/tui/views/save-dialog.tsx +332 -0
- package/src/entry/tui/views/status.tsx +209 -0
- package/src/entry/tui/widgets/chrome.tsx +114 -0
- package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
- package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
- package/src/entry/tui/widgets/dialog-select.tsx +212 -0
- package/src/entry/tui/widgets/dialog.tsx +68 -0
- package/src/entry/tui/widgets/line.tsx +92 -0
- package/src/entry/tui/widgets/list.tsx +197 -0
- package/src/entry/tui/widgets/spinner.tsx +49 -0
- package/src/entry/tui/widgets/toast.tsx +62 -0
- package/src/infra/assets.ts +4 -0
- package/src/infra/claude-session.ts +98 -0
- package/src/infra/dispatch-dir.ts +286 -0
- package/src/infra/env.ts +67 -0
- package/src/infra/filelock.ts +159 -0
- package/src/infra/git.ts +91 -0
- package/src/infra/heavy-lock.ts +36 -0
- package/src/infra/jev-client.ts +162 -0
- package/src/infra/launch.ts +25 -0
- package/src/infra/log.ts +84 -0
- package/src/infra/paths.ts +38 -0
- package/src/infra/peer-inbox.ts +92 -0
- package/src/infra/proc.ts +110 -0
- package/src/infra/store.ts +229 -0
- package/src/infra/supervisor.ts +287 -0
- package/src/infra/version.ts +6 -0
- package/src/services/admission.ts +303 -0
- package/src/services/agent-links.ts +139 -0
- package/src/services/backends.ts +44 -0
- package/src/services/budget.ts +47 -0
- package/src/services/capture.ts +233 -0
- package/src/services/catalog-service.ts +382 -0
- package/src/services/dispatch-service.ts +620 -0
- package/src/services/dispatches.ts +174 -0
- package/src/services/doctor-access.ts +231 -0
- package/src/services/doctor-backends.ts +170 -0
- package/src/services/doctor-checks.ts +148 -0
- package/src/services/doctor-push.ts +109 -0
- package/src/services/doctor.ts +338 -0
- package/src/services/finalize.ts +377 -0
- package/src/services/gate-service.ts +288 -0
- package/src/services/global-install.ts +99 -0
- package/src/services/jev-service.ts +183 -0
- package/src/services/lane-service.ts +390 -0
- package/src/services/milestones.ts +208 -0
- package/src/services/notifier.ts +296 -0
- package/src/services/peek.ts +103 -0
- package/src/services/ports.ts +135 -0
- package/src/services/preflight.ts +221 -0
- package/src/services/profile-service.ts +292 -0
- package/src/services/profile-store.ts +141 -0
- package/src/services/protocol.ts +212 -0
- package/src/services/questions.ts +102 -0
- package/src/services/reconcile.ts +130 -0
- package/src/services/reentry.ts +22 -0
- package/src/services/routing-service.ts +190 -0
- package/src/services/run-debug.ts +79 -0
- package/src/services/run-service.ts +194 -0
- package/src/services/run-store.ts +334 -0
- package/src/services/runs-page.ts +254 -0
- package/src/services/session-view.ts +152 -0
- package/src/services/sessions.ts +92 -0
- package/src/services/setup.ts +87 -0
- package/src/services/state.ts +111 -0
- package/src/services/summary.ts +241 -0
- package/catalog/catalog.json +0 -112
- package/catalog/models-dev.json +0 -56453
- package/src/core/codex-events.ts +0 -49
- package/src/core/codex.ts +0 -166
- package/src/core/harness.ts +0 -89
- package/src/core/lanes.ts +0 -67
- package/src/core/lock.ts +0 -111
- package/src/core/opencode.ts +0 -207
- package/src/core/reconcile.ts +0 -26
- package/src/core/reply.ts +0 -54
- package/src/core/runstore.ts +0 -163
- package/src/core/spawn.ts +0 -89
- package/src/core/status.ts +0 -193
- package/src/files.ts +0 -19
- package/src/mcp/dispatch.ts +0 -154
- package/src/mcp/lane-tools.ts +0 -200
- package/src/mcp/out.ts +0 -6
- package/src/mcp/preflight.ts +0 -84
- package/src/mcp/run-tools.ts +0 -121
- package/src/mcp/runs.ts +0 -118
- package/src/mcp/server.ts +0 -26
- package/src/mcp/setup-tools.ts +0 -175
- package/src/paths.ts +0 -20
- package/src/profile/agents.ts +0 -130
- package/src/profile/profile.ts +0 -228
- package/src/routing/catalog.ts +0 -227
- package/src/routing/commands.ts +0 -22
- package/src/routing/jev.ts +0 -185
- package/src/routing/route.ts +0 -155
- package/src/routing/select.ts +0 -90
- package/src/tui/backends.ts +0 -57
- package/src/tui/commands.ts +0 -53
- package/src/tui/dashboard.tsx +0 -245
- package/src/tui/editor.tsx +0 -214
- package/src/tui/init.tsx +0 -208
- package/src/tui/matrix-model.ts +0 -279
- package/src/tui/matrix.tsx +0 -512
- package/src/tui/profiles.ts +0 -38
- package/src/tui/theme.ts +0 -177
- package/src/tui/ui.tsx +0 -267
- package/src/tui/watch-model.ts +0 -83
- package/src/tui/watch.tsx +0 -236
- package/src/types.ts +0 -184
- package/src/version.ts +0 -7
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
import { errorMessage, isCatherdError } from "../../../domain/errors.ts";
|
|
3
|
+
import { PROFILE_NAME, patchBetween, type ProfileDoc } from "../../../domain/profile.ts";
|
|
4
|
+
import { CHANGED_ON_DISK } from "../effects.ts";
|
|
5
|
+
import { type AppApi, useApp, useDialogHandler } from "../providers/app.tsx";
|
|
6
|
+
import { type Data, useData } from "../providers/data.tsx";
|
|
7
|
+
import { errorToast } from "../providers/toast.tsx";
|
|
8
|
+
import { currentDraft, dirtyCount, isSaving } from "../state.ts";
|
|
9
|
+
import { plural } from "../text.ts";
|
|
10
|
+
|
|
11
|
+
const fail = (app: AppApi, e: unknown) => app.toast(errorToast(e));
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Shows a profile in the Profiles tab, starting its draft from disk unless one is staged. A read that
|
|
15
|
+
* fails is a toast, or with `quiet` only returned (the caller shows it); null when it was shown.
|
|
16
|
+
*/
|
|
17
|
+
export function showProfile(app: AppApi, name: string, o: { quiet?: boolean } = {}): unknown {
|
|
18
|
+
try {
|
|
19
|
+
app.dispatch({ type: "show", name, doc: app.effects.readProfile(name) });
|
|
20
|
+
return null;
|
|
21
|
+
} catch (e) {
|
|
22
|
+
if (!o.quiet) fail(app, e);
|
|
23
|
+
return e;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The profiles as they are now: the poll's value while its last read was good, else a read of them now
|
|
29
|
+
* (which throws as the poll's did, until the file is fixed); a good one also brings the poll up to date.
|
|
30
|
+
*/
|
|
31
|
+
function fresh(app: AppApi, data: Data): NonNullable<Data["profiles"]["value"]> {
|
|
32
|
+
if (data.profiles.value && data.profiles.error === null) return data.profiles.value;
|
|
33
|
+
const p = app.effects.profiles();
|
|
34
|
+
if (data.profiles.error !== null) data.profiles.refresh();
|
|
35
|
+
return p;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The profile list (`<leader>l`): enter shows one, ctrl+d twice deletes one. */
|
|
39
|
+
export function openProfileList(app: AppApi, data: Data): void {
|
|
40
|
+
let p: NonNullable<Data["profiles"]["value"]>;
|
|
41
|
+
try {
|
|
42
|
+
// the poll has not read them yet, or its last read failed (a kept value would be stale): read them now;
|
|
43
|
+
// a key handler's throw would end the TUI
|
|
44
|
+
p = fresh(app, data);
|
|
45
|
+
} catch (e) {
|
|
46
|
+
return fail(app, e);
|
|
47
|
+
}
|
|
48
|
+
app.dispatch({
|
|
49
|
+
type: "open",
|
|
50
|
+
dialog: {
|
|
51
|
+
kind: "select",
|
|
52
|
+
purpose: { type: "profiles" },
|
|
53
|
+
title: "Profiles",
|
|
54
|
+
deletable: true,
|
|
55
|
+
empty: "no profiles",
|
|
56
|
+
options: p.names.map((n) => {
|
|
57
|
+
const d = app.getState().drafts[n];
|
|
58
|
+
const unsaved = d ? dirtyCount(d) : 0;
|
|
59
|
+
return {
|
|
60
|
+
value: n,
|
|
61
|
+
title: n,
|
|
62
|
+
current: n === p.here,
|
|
63
|
+
detail: [
|
|
64
|
+
n === p.active ? "active" : "",
|
|
65
|
+
p.repo && n === p.here ? "this repo" : "",
|
|
66
|
+
unsaved ? `${unsaved} unsaved` : "",
|
|
67
|
+
]
|
|
68
|
+
.filter(Boolean)
|
|
69
|
+
.join(" · "),
|
|
70
|
+
};
|
|
71
|
+
}),
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const openNewProfile = (app: AppApi, from: string | null = null): void =>
|
|
77
|
+
app.dispatch({
|
|
78
|
+
type: "open",
|
|
79
|
+
dialog: {
|
|
80
|
+
kind: "prompt",
|
|
81
|
+
purpose: from ? { type: "copy", from } : { type: "new" },
|
|
82
|
+
title: from ? `Copy profile ${from}` : "New profile",
|
|
83
|
+
label: "name: lowercase letters, digits and -, up to 32",
|
|
84
|
+
value: "",
|
|
85
|
+
error: null,
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
/** Opens the save dialog, or says there is nothing to save. */
|
|
90
|
+
export function openSave(app: AppApi): void {
|
|
91
|
+
const d = currentDraft(app.getState());
|
|
92
|
+
if (!d || dirtyCount(d) === 0) return app.toast({ variant: "info", message: "No unsaved changes" });
|
|
93
|
+
app.dispatch({
|
|
94
|
+
type: "open",
|
|
95
|
+
dialog: { kind: "save", purpose: { type: "save", name: d.name }, error: null },
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function openActivate(app: AppApi, data: Data): void {
|
|
100
|
+
const d = currentDraft(app.getState());
|
|
101
|
+
if (!d) return;
|
|
102
|
+
let p: Data["profiles"]["value"];
|
|
103
|
+
try {
|
|
104
|
+
// a failed poll is read again here, so the prompt never names a stale active profile or binding
|
|
105
|
+
p = data.profiles.error === null ? data.profiles.value : fresh(app, data);
|
|
106
|
+
} catch (e) {
|
|
107
|
+
return fail(app, e);
|
|
108
|
+
}
|
|
109
|
+
askActivate(app, d.name, p);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The activate confirmation for `name`, worded for the scope `p` shows (its bound repo, or the global
|
|
114
|
+
* profile), which the answer then applies; `notice` says why it asks again.
|
|
115
|
+
*/
|
|
116
|
+
function askActivate(app: AppApi, name: string, p: Data["profiles"]["value"], notice?: string): void {
|
|
117
|
+
if (p?.here === name)
|
|
118
|
+
return app.toast({
|
|
119
|
+
variant: "info",
|
|
120
|
+
message: p.repo ? `${name} is already this repo's profile` : `${name} is already active`,
|
|
121
|
+
});
|
|
122
|
+
const d = app.getState().drafts[name];
|
|
123
|
+
const unsaved = d ? dirtyCount(d) : 0;
|
|
124
|
+
app.dispatch({
|
|
125
|
+
type: "open",
|
|
126
|
+
dialog: {
|
|
127
|
+
kind: "confirm",
|
|
128
|
+
purpose: { type: "activate", name, repo: p?.repo ?? null },
|
|
129
|
+
title: p?.repo ? `Use ${name} in this repo?` : `Make ${name} active?`,
|
|
130
|
+
message: [
|
|
131
|
+
...(notice ? [notice] : []),
|
|
132
|
+
p?.repo
|
|
133
|
+
? `Binds ${p.repo} to it; the active profile (${p.active}) and other repos keep theirs.`
|
|
134
|
+
: "New Claude Code sessions and catherd's next dispatch use it; repos bound to another profile keep theirs.",
|
|
135
|
+
...(unsaved
|
|
136
|
+
? [`Its ${unsaved} unsaved change${unsaved === 1 ? " is" : "s are"} not part of it until you save.`]
|
|
137
|
+
: []),
|
|
138
|
+
],
|
|
139
|
+
yes: p?.repo ? "Bind to this repo" : "Make active",
|
|
140
|
+
no: "Cancel",
|
|
141
|
+
destructive: false,
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function openRevert(app: AppApi): void {
|
|
147
|
+
const d = currentDraft(app.getState());
|
|
148
|
+
if (!d || dirtyCount(d) === 0) return app.toast({ variant: "info", message: "No unsaved changes" });
|
|
149
|
+
app.dispatch({
|
|
150
|
+
type: "open",
|
|
151
|
+
dialog: {
|
|
152
|
+
kind: "confirm",
|
|
153
|
+
purpose: { type: "revert", name: d.name },
|
|
154
|
+
title: "Discard unsaved changes?",
|
|
155
|
+
message: [
|
|
156
|
+
`${plural(dirtyCount(d), "change")} to ${d.name} go back to what is saved. ctrl+x u undoes this.`,
|
|
157
|
+
],
|
|
158
|
+
yes: "Discard",
|
|
159
|
+
no: "Keep them",
|
|
160
|
+
destructive: true,
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Keeps a line for after exit, and says it now. */
|
|
166
|
+
function sessionsNeeded(app: AppApi, agents: string[]): void {
|
|
167
|
+
if (agents.length === 0) return;
|
|
168
|
+
app.keep([`catherd: start a new Claude Code session to use: ${agents.join(", ")}`]);
|
|
169
|
+
app.toast({
|
|
170
|
+
variant: "info",
|
|
171
|
+
message: `New Claude Code session needed for ${plural(agents.length, "agent")}`,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Applies a confirmed activation to the scope the user was shown (`shown`: the bound repo, or null for the
|
|
177
|
+
* global profile). The binding is read again first: when another process bound or unbound this repo while
|
|
178
|
+
* the question was open, nothing is applied and the question comes back in the words for the scope now.
|
|
179
|
+
*/
|
|
180
|
+
function activateNow(app: AppApi, data: Data, name: string, shown: string | null): void {
|
|
181
|
+
try {
|
|
182
|
+
const now = app.effects.profiles();
|
|
183
|
+
if (now.repo !== shown) {
|
|
184
|
+
data.profiles.refresh();
|
|
185
|
+
return askActivate(
|
|
186
|
+
app,
|
|
187
|
+
name,
|
|
188
|
+
now,
|
|
189
|
+
`Changed since this was first asked: ${now.repo ? `${now.repo} is now bound to ${now.here}` : `${shown} is no longer bound`}.`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
const r = app.effects.activate(name, shown);
|
|
193
|
+
data.profiles.refresh();
|
|
194
|
+
app.toast({ variant: "success", message: shown ? `${name} is bound to ${shown}` : `${name} is active` });
|
|
195
|
+
sessionsNeeded(app, r.newSessionNeededFor);
|
|
196
|
+
} catch (e) {
|
|
197
|
+
fail(app, e);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The handlers of the profile dialogs that any tab can open; the App registers them once. */
|
|
202
|
+
export function useProfileDialogs(): void {
|
|
203
|
+
const app = useApp();
|
|
204
|
+
const data = useData();
|
|
205
|
+
const nameError = (name: string): string | null => {
|
|
206
|
+
if (!PROFILE_NAME.test(name))
|
|
207
|
+
return "use lowercase letters, digits and -, up to 32, starting with a letter or digit";
|
|
208
|
+
if ((data.profiles.value?.names ?? []).includes(name)) return `"${name}" already exists`;
|
|
209
|
+
return null;
|
|
210
|
+
};
|
|
211
|
+
const create = (value: string, from?: string) => {
|
|
212
|
+
const name = value.trim();
|
|
213
|
+
const err = nameError(name);
|
|
214
|
+
if (err) return app.dispatch({ type: "invalid", error: err });
|
|
215
|
+
try {
|
|
216
|
+
const r = app.effects.create(name, from);
|
|
217
|
+
if (!r.saved)
|
|
218
|
+
return app.dispatch({
|
|
219
|
+
type: "invalid",
|
|
220
|
+
error: r.errors.map((e) => `${e.path}: ${e.message}`).join("; "),
|
|
221
|
+
});
|
|
222
|
+
data.profiles.refresh();
|
|
223
|
+
app.dispatch({ type: "close" });
|
|
224
|
+
showProfile(app, name);
|
|
225
|
+
app.toast({ variant: "success", message: `Created profile ${name}` });
|
|
226
|
+
sessionsNeeded(app, r.newSessionNeededFor);
|
|
227
|
+
} catch (e) {
|
|
228
|
+
app.dispatch({
|
|
229
|
+
type: "invalid",
|
|
230
|
+
error: isCatherdError(e) && e.fix ? `${e.message}. ${e.fix}` : errorMessage(e),
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
useDialogHandler("new", (_p, value) => create(value));
|
|
235
|
+
useDialogHandler("copy", (p, value) => create(value, p.type === "copy" ? p.from : undefined));
|
|
236
|
+
useDialogHandler("profiles", (_p, value) => {
|
|
237
|
+
if (!value.startsWith("delete:")) {
|
|
238
|
+
app.dispatch({ type: "close" });
|
|
239
|
+
showProfile(app, value);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const name = value.slice("delete:".length);
|
|
243
|
+
try {
|
|
244
|
+
app.effects.remove(name);
|
|
245
|
+
app.dispatch({ type: "forget", name });
|
|
246
|
+
data.profiles.refresh();
|
|
247
|
+
app.dispatch({ type: "close" });
|
|
248
|
+
app.toast({ variant: "success", message: `Deleted profile ${name}` });
|
|
249
|
+
} catch (e) {
|
|
250
|
+
fail(app, e);
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
useDialogHandler("activate", (p) => {
|
|
254
|
+
app.dispatch({ type: "close" });
|
|
255
|
+
if (p.type === "activate") activateNow(app, data, p.name, p.repo);
|
|
256
|
+
});
|
|
257
|
+
useDialogHandler("revert", (p) => {
|
|
258
|
+
app.dispatch({ type: "close" });
|
|
259
|
+
if (p.type !== "revert") return;
|
|
260
|
+
const d = app.getState().drafts[p.name];
|
|
261
|
+
let doc: ProfileDoc;
|
|
262
|
+
try {
|
|
263
|
+
// "what is saved" is the file now: another process may have saved it since the draft opened
|
|
264
|
+
doc = app.effects.readProfile(p.name);
|
|
265
|
+
} catch (e) {
|
|
266
|
+
return fail(app, e);
|
|
267
|
+
}
|
|
268
|
+
app.dispatch({ type: "revert", name: p.name, doc });
|
|
269
|
+
if (d && !isDeepStrictEqual(d.base, doc))
|
|
270
|
+
app.toast({
|
|
271
|
+
variant: "info",
|
|
272
|
+
message: `${p.name} changed on disk: showing what is saved now`,
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
useDialogHandler("save", async (p, value, extra) => {
|
|
276
|
+
if (p.type !== "save") return;
|
|
277
|
+
// the scope "Save & make active" was worded for as it was answered
|
|
278
|
+
const shown = data.profiles.value?.repo ?? null;
|
|
279
|
+
const d = app.getState().drafts[p.name];
|
|
280
|
+
if (!d) return app.dispatch({ type: "close" });
|
|
281
|
+
// a second enter while the first save is writing is not a second save
|
|
282
|
+
if (isSaving(app.getState())) return;
|
|
283
|
+
// while it writes the dialog stays open and takes no answer, and the TUI does not quit
|
|
284
|
+
app.dispatch({ type: "saving", name: p.name, on: true });
|
|
285
|
+
let r: Awaited<ReturnType<AppApi["effects"]["save"]>>;
|
|
286
|
+
try {
|
|
287
|
+
r = await app.effects.save(p.name, patchBetween(d.base, d.doc), d.treatLikes, extra.shown);
|
|
288
|
+
} catch (e) {
|
|
289
|
+
app.dispatch({ type: "saving", name: p.name, on: false });
|
|
290
|
+
return app.dispatch({
|
|
291
|
+
type: "invalid",
|
|
292
|
+
error: isCatherdError(e) && e.fix ? `${e.message}. ${e.fix}` : errorMessage(e),
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
app.dispatch({ type: "saving", name: p.name, on: false });
|
|
296
|
+
// another process changed the profile while this one saved: nothing is written, and the dialog shows
|
|
297
|
+
// what saving would do now and asks again
|
|
298
|
+
if (!r.saved && r.errors.some((e) => e.path === CHANGED_ON_DISK) && extra.changed) {
|
|
299
|
+
app.dispatch({ type: "invalid", error: null });
|
|
300
|
+
return extra.changed();
|
|
301
|
+
}
|
|
302
|
+
if (!r.saved)
|
|
303
|
+
return app.dispatch({
|
|
304
|
+
type: "invalid",
|
|
305
|
+
error: r.errors.map((e) => `${e.path}: ${e.message}`).join("; "),
|
|
306
|
+
});
|
|
307
|
+
// saved: this dialog closes (only if it is still the top one), so what fails from here on is a toast
|
|
308
|
+
const top = app.getState().dialogs.at(-1);
|
|
309
|
+
if (top?.kind === "save" && top.purpose.name === p.name) app.dispatch({ type: "close" });
|
|
310
|
+
try {
|
|
311
|
+
// the draft as the save began: an edit made since stays staged over what was saved
|
|
312
|
+
const from = { doc: d.doc, treatLikes: d.treatLikes };
|
|
313
|
+
app.dispatch({ type: "saved", name: p.name, doc: app.effects.readProfile(p.name), from });
|
|
314
|
+
} catch (e) {
|
|
315
|
+
fail(app, e);
|
|
316
|
+
}
|
|
317
|
+
app.toast({ variant: "success", message: `Saved profile ${p.name}` });
|
|
318
|
+
sessionsNeeded(app, r.newSessionNeededFor);
|
|
319
|
+
if (value === "activate") activateNow(app, data, p.name, shown);
|
|
320
|
+
});
|
|
321
|
+
}
|