@zgeoff/atc 2.17.0 → 2.20.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 +1 -1
- package/src/agents/agent-adapter.ts +5 -0
- package/src/agents/gateway-adapter.ts +25 -2
- package/src/cli.ts +6 -1
- package/src/client/build-workspace-destination.ts +38 -0
- package/src/client/collect-agent-picks.ts +5 -2
- package/src/client/pick-refusal-step.ts +29 -0
- package/src/client/resolve-workspace-root.ts +49 -0
- package/src/client/spawn-picker.ts +1655 -182
- package/src/daemon/build-agent-list.ts +8 -6
- package/src/daemon/build-auth-binding.ts +58 -0
- package/src/daemon/build-imp-name.ts +11 -0
- package/src/daemon/build-imp-provider.ts +25 -2
- package/src/daemon/daemon-connection.ts +7 -0
- package/src/daemon/imp-port.ts +4 -2
- package/src/daemon/imp-provider.ts +24 -19
- package/src/daemon/restore-fleet.ts +2 -1
- package/src/daemon/sessions.ts +6 -0
- package/src/protocol/protocol.ts +1 -0
- package/src/shared/collect-auth-profiles.ts +122 -0
- package/src/shared/collect-gateways.ts +223 -6
- package/src/shared/collect-workspaces-config.ts +25 -10
- package/src/shared/config.ts +49 -6
- package/src/shared/resolve-auth-profiles.ts +186 -0
- package/src/store/run-migrations.ts +77 -0
- package/src/store/runtime-auth-binding.ts +110 -0
- package/src/store/state-store.ts +186 -0
|
@@ -1,19 +1,27 @@
|
|
|
1
1
|
import { homedir } from 'node:os';
|
|
2
2
|
import { DaemonError } from '../protocol/daemon-error';
|
|
3
3
|
import type { AgentID } from '../shared/agent-id';
|
|
4
|
+
import type { WorkspacesConfig } from '../shared/collect-workspaces-config';
|
|
4
5
|
import { collectZoxideDirs } from '../shared/collect-zoxide-dirs';
|
|
5
6
|
import { loadConfig } from '../shared/config';
|
|
7
|
+
import { DEFAULT_GIT_TRANSPORTS } from '../shared/default-git-transports';
|
|
8
|
+
import { isGitURL } from '../shared/is-git-url';
|
|
9
|
+
import { isRecord } from '../shared/report';
|
|
10
|
+
import { resolveHomeDir } from '../shared/resolve-home-dir';
|
|
11
|
+
import { buildWorkspaceDestination } from './build-workspace-destination';
|
|
6
12
|
import { collectAgentPicks } from './collect-agent-picks';
|
|
7
13
|
import type { AgentPick } from './collect-agent-picks';
|
|
8
14
|
import { collectPathCompletions } from './collect-path-completions';
|
|
9
15
|
import { collectTargetPicks } from './collect-target-picks';
|
|
10
16
|
import type { TargetPick } from './collect-target-picks';
|
|
11
17
|
import { collectDirs, formatDir, formatDirName, pickMatches } from './dirs';
|
|
12
|
-
import { planTextEdit } from './keys';
|
|
18
|
+
import { KEY, planTextEdit } from './keys';
|
|
19
|
+
import { pickRefusalStep } from './pick-refusal-step';
|
|
13
20
|
import { resolvePathInput } from './resolve-path-input';
|
|
21
|
+
import { resolveWorkspaceRoot } from './resolve-workspace-root';
|
|
14
22
|
import { ansi, cols, drawPicker } from './ui';
|
|
15
23
|
|
|
16
|
-
type PickerStep = 'agent' | 'dir' | 'target' | 'name' | 'prompt';
|
|
24
|
+
type PickerStep = 'agent' | 'dir' | 'source' | 'ref' | 'target' | 'confirm' | 'name' | 'prompt';
|
|
17
25
|
|
|
18
26
|
// What the flow borrows from the client that owns the screen, the daemon
|
|
19
27
|
// connection and the fleet mirror.
|
|
@@ -32,18 +40,103 @@ export interface SpawnPickerDeps<TMirror> {
|
|
|
32
40
|
readonly upsertMirror: (session: Readonly<TMirror>) => void;
|
|
33
41
|
}
|
|
34
42
|
|
|
43
|
+
// One source `agents.list` returned, in the order the daemon offers them.
|
|
44
|
+
interface AnnouncedSource {
|
|
45
|
+
readonly id: string;
|
|
46
|
+
readonly label: string;
|
|
47
|
+
readonly kind: 'git' | 'path';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// What a candidate or a reading of typed input resolves to.
|
|
51
|
+
type SourcePick =
|
|
52
|
+
| { readonly kind: 'path'; readonly dir: string }
|
|
53
|
+
| { readonly kind: 'git'; readonly url: string };
|
|
54
|
+
|
|
55
|
+
// One candidate `sources.list` returned.
|
|
56
|
+
interface ListedCandidate {
|
|
57
|
+
readonly label: string;
|
|
58
|
+
readonly detail: string | null;
|
|
59
|
+
readonly pick: SourcePick;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// What `sources.interpret` read typed input as.
|
|
63
|
+
type Interpretation =
|
|
64
|
+
| { readonly kind: 'browse'; readonly scope: string }
|
|
65
|
+
| SourcePick
|
|
66
|
+
| { readonly kind: 'none' };
|
|
67
|
+
|
|
68
|
+
// One branch or tag `git.probe` returned.
|
|
69
|
+
interface ProbedRef {
|
|
70
|
+
readonly name: string;
|
|
71
|
+
readonly kind: 'branch' | 'tag';
|
|
72
|
+
readonly sha: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The repository the probe proved the daemon's host can read.
|
|
76
|
+
interface ProbedRepo {
|
|
77
|
+
// What the user chose, shown as the source step's input on the way back
|
|
78
|
+
// to it.
|
|
79
|
+
readonly label: string;
|
|
80
|
+
|
|
81
|
+
// The URL the clone fetches, as the daemon resolved it.
|
|
82
|
+
readonly url: string;
|
|
83
|
+
readonly head: string | null;
|
|
84
|
+
readonly refs: readonly ProbedRef[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The commit the spawn checks out, and the branch or tag it came from.
|
|
88
|
+
interface ChosenRef {
|
|
89
|
+
readonly ref: string | null;
|
|
90
|
+
readonly sha: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const FULL_SHA_PATTERN = /^(?:[\da-f]{40}|[\da-f]{64})$/u;
|
|
94
|
+
const SHORT_SHA_PATTERN = /^[\da-f]{7,63}$/u;
|
|
95
|
+
|
|
96
|
+
// A request the step waits on, and the number that tells its answer from a
|
|
97
|
+
// cancelled one's.
|
|
98
|
+
interface PendingRequest {
|
|
99
|
+
readonly label: string;
|
|
100
|
+
readonly seq: number;
|
|
101
|
+
readonly kind: 'interpret' | 'probe' | 'spawn';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// What a git source step does once it opens: nothing, probe a URL a
|
|
105
|
+
// reading of typed input already resolved, or list the scope a reading
|
|
106
|
+
// asked for, once the step's target is known.
|
|
107
|
+
type SourceStepAction =
|
|
108
|
+
| { readonly kind: 'none' }
|
|
109
|
+
| { readonly kind: 'probe'; readonly url: string }
|
|
110
|
+
| { readonly kind: 'browse'; readonly scope: string };
|
|
111
|
+
|
|
35
112
|
/**
|
|
36
|
-
* The modal flow behind n and r: agent, then
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
113
|
+
* The modal flow behind n and r: agent, then source, then name, then an
|
|
114
|
+
* optional first prompt. The sources are the ones the daemon offers, in
|
|
115
|
+
* its order, and Tab cycles them; a daemon that offers none gets the local
|
|
116
|
+
* directory flow alone. A source of directories is followed by the
|
|
117
|
+
* execution target when the daemon has more than one. A source of git
|
|
118
|
+
* repositories opens the target first, then its candidates, the ref, and a
|
|
119
|
+
* confirm screen with the destination the checkout lands in, so every
|
|
120
|
+
* request about the repository is made for the target the spawn runs on.
|
|
121
|
+
* Typed input a source does not match against its candidates is read by
|
|
122
|
+
* the source, then by the others in order. Every path out of the flow
|
|
123
|
+
* either attaches the new session or returns the client to the screen it
|
|
124
|
+
* came from. A spawn the daemon refuses is no path out: the flow returns to
|
|
125
|
+
* the step that can fix the cause and shows why. Esc cancels a request in
|
|
126
|
+
* flight and drops its late answer.
|
|
41
127
|
*/
|
|
42
128
|
export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
43
129
|
private readonly deps: SpawnPickerDeps<TMirror>;
|
|
44
130
|
|
|
45
131
|
private step: PickerStep = 'agent';
|
|
46
132
|
|
|
133
|
+
// The sources the daemon offers, or null when it offers none, and the
|
|
134
|
+
// one the flow is on; -1 is the local directory flow of a daemon that
|
|
135
|
+
// offers none.
|
|
136
|
+
private sources: readonly AnnouncedSource[] | null = null;
|
|
137
|
+
|
|
138
|
+
private sourceIndex = -1;
|
|
139
|
+
|
|
47
140
|
private input = '';
|
|
48
141
|
|
|
49
142
|
private selected = 0;
|
|
@@ -52,42 +145,122 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
52
145
|
// edit or a fresh install lands without a client restart.
|
|
53
146
|
private picks: AgentPick[] = [];
|
|
54
147
|
|
|
148
|
+
// The directories the directory step lists, and the label each shows.
|
|
55
149
|
private dirs: string[] = [];
|
|
56
150
|
|
|
57
|
-
|
|
151
|
+
private dirLabels: ReadonlyMap<string, string> = new Map();
|
|
152
|
+
|
|
153
|
+
// The configured roots and workspace settings, read with the agents each
|
|
154
|
+
// time the menu opens.
|
|
58
155
|
private roots: readonly string[] = [];
|
|
59
156
|
|
|
157
|
+
private workspaces: WorkspacesConfig = {
|
|
158
|
+
githubOwner: null,
|
|
159
|
+
sources: null,
|
|
160
|
+
gitTransports: DEFAULT_GIT_TRANSPORTS,
|
|
161
|
+
root: null,
|
|
162
|
+
targetRoots: new Map(),
|
|
163
|
+
};
|
|
164
|
+
|
|
60
165
|
private dir = '';
|
|
61
166
|
|
|
62
|
-
// The daemon's execution targets, read when the
|
|
63
|
-
//
|
|
167
|
+
// The daemon's execution targets, read when the agent is chosen, and the
|
|
168
|
+
// one chosen; null spawns on the daemon's default target.
|
|
64
169
|
private targets: TargetPick[] = [];
|
|
65
170
|
|
|
66
171
|
private target: TargetPick | null = null;
|
|
67
172
|
|
|
173
|
+
// The git source's candidates and the scope they were listed under, or
|
|
174
|
+
// the notice that stands in for a list the daemon could not give, with
|
|
175
|
+
// the source and target they were listed for. A listing runs behind the
|
|
176
|
+
// step, so typing goes on.
|
|
177
|
+
private candidates: ListedCandidate[] = [];
|
|
178
|
+
|
|
179
|
+
private listedScope: string | null = null;
|
|
180
|
+
|
|
181
|
+
private listNotice: string | null = null;
|
|
182
|
+
|
|
183
|
+
private listedKey: string | null = null;
|
|
184
|
+
|
|
185
|
+
private listingSeq: number | null = null;
|
|
186
|
+
|
|
187
|
+
// The directory listing the flow waits on before it opens the directory
|
|
188
|
+
// step, or null when it waits on none.
|
|
189
|
+
private pathListingSeq: number | null = null;
|
|
190
|
+
|
|
191
|
+
// The source step's text while the flow is on another step, and what the
|
|
192
|
+
// step does as soon as it opens.
|
|
193
|
+
private sourceDraft = '';
|
|
194
|
+
|
|
195
|
+
private sourceAction: SourceStepAction = { kind: 'none' };
|
|
196
|
+
|
|
197
|
+
// The other URL form of a repository whose probe failed, offered as the
|
|
198
|
+
// source step's one item until the input changes.
|
|
199
|
+
private alternateURL: string | null = null;
|
|
200
|
+
|
|
201
|
+
private repo: ProbedRepo | null = null;
|
|
202
|
+
|
|
203
|
+
private ref: ChosenRef | null = null;
|
|
204
|
+
|
|
205
|
+
// The destination on the confirm screen, before any suffix a refused
|
|
206
|
+
// spawn adds, that suffix's number, and whether the user typed the
|
|
207
|
+
// destination rather than taking the default.
|
|
208
|
+
private destination = '';
|
|
209
|
+
|
|
210
|
+
private destinationAttempt = 1;
|
|
211
|
+
|
|
212
|
+
private destinationEdited = false;
|
|
213
|
+
|
|
214
|
+
// The repository the destination was built or typed for.
|
|
215
|
+
private destinationRepoURL: string | null = null;
|
|
216
|
+
|
|
68
217
|
private name = '';
|
|
69
218
|
|
|
70
219
|
private resume = false;
|
|
71
220
|
|
|
72
221
|
private agent: AgentID = 'claude';
|
|
73
222
|
|
|
74
|
-
//
|
|
75
|
-
//
|
|
223
|
+
// Why the last action was refused, shown in place of the hint until the
|
|
224
|
+
// next key.
|
|
76
225
|
private refusal: string | null = null;
|
|
77
226
|
|
|
227
|
+
private pending: PendingRequest | null = null;
|
|
228
|
+
|
|
229
|
+
private requestSeq = 0;
|
|
230
|
+
|
|
231
|
+
// The flow's generation. Opening the flow and every way out of it moves
|
|
232
|
+
// it on, and an answer that arrives for an earlier generation is dropped
|
|
233
|
+
// before it changes any state or draws anything.
|
|
234
|
+
private generation = 0;
|
|
235
|
+
|
|
78
236
|
constructor(deps: SpawnPickerDeps<TMirror>) {
|
|
79
237
|
this.deps = deps;
|
|
80
238
|
}
|
|
81
239
|
|
|
82
240
|
open(resume = false) {
|
|
241
|
+
this.generation += 1;
|
|
83
242
|
this.resume = resume;
|
|
84
|
-
this.target = null;
|
|
85
243
|
|
|
86
244
|
const config = loadConfig();
|
|
87
245
|
|
|
88
246
|
this.picks = collectAgentPicks(config);
|
|
89
247
|
this.roots = config.dirs.roots;
|
|
248
|
+
this.workspaces = config.workspaces;
|
|
90
249
|
this.input = '';
|
|
250
|
+
this.sources = null;
|
|
251
|
+
this.sourceIndex = -1;
|
|
252
|
+
this.target = null;
|
|
253
|
+
this.repo = null;
|
|
254
|
+
this.ref = null;
|
|
255
|
+
this.sourceDraft = '';
|
|
256
|
+
this.sourceAction = { kind: 'none' };
|
|
257
|
+
this.listedKey = null;
|
|
258
|
+
this.listingSeq = null;
|
|
259
|
+
this.pathListingSeq = null;
|
|
260
|
+
this.alternateURL = null;
|
|
261
|
+
this.destination = '';
|
|
262
|
+
this.destinationEdited = false;
|
|
263
|
+
this.destinationRepoURL = null;
|
|
91
264
|
|
|
92
265
|
// A last-used agent that is no longer installed is not in the menu, so
|
|
93
266
|
// the selection falls to the first one that is.
|
|
@@ -99,6 +272,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
99
272
|
this.agent = this.picks[this.selected]?.agent ?? this.deps.getLastUsedAgent();
|
|
100
273
|
this.step = 'agent';
|
|
101
274
|
this.refusal = null;
|
|
275
|
+
this.pending = null;
|
|
102
276
|
|
|
103
277
|
process.stdout.write(ansi.clear);
|
|
104
278
|
this.render();
|
|
@@ -109,9 +283,46 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
109
283
|
|
|
110
284
|
const edit = planTextEdit(buf, this.input, {
|
|
111
285
|
isLeaderKey: this.deps.isLeaderKey,
|
|
112
|
-
moves:
|
|
286
|
+
moves:
|
|
287
|
+
this.step === 'agent' ||
|
|
288
|
+
this.step === 'dir' ||
|
|
289
|
+
this.step === 'source' ||
|
|
290
|
+
this.step === 'ref' ||
|
|
291
|
+
this.step === 'target',
|
|
113
292
|
});
|
|
114
293
|
|
|
294
|
+
// A request in flight owns the step: Esc cancels it, the leader leaves,
|
|
295
|
+
// and every other key waits for the answer.
|
|
296
|
+
if (this.pending !== null) {
|
|
297
|
+
if (edit.kind === 'cancel') {
|
|
298
|
+
this.applyPendingCancel();
|
|
299
|
+
} else if (edit.kind === 'leader') {
|
|
300
|
+
this.quitFlow();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const tab = buf.length === 1 && buf[0] === KEY.tab;
|
|
307
|
+
|
|
308
|
+
// A tab to a source of directories waits for its listing on the step it
|
|
309
|
+
// left. Any other key there acts on that step, so the flow stays on it
|
|
310
|
+
// and the listing no longer opens its step.
|
|
311
|
+
if (!tab && (this.step === 'dir' || this.step === 'source')) {
|
|
312
|
+
this.pathListingSeq = null;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Tab moves a spawn to the next source the daemon offers. Adopt resumes
|
|
316
|
+
// a session in a directory that already holds one, so it stays on its
|
|
317
|
+
// directories.
|
|
318
|
+
if (tab) {
|
|
319
|
+
if (!this.resume && (this.step === 'dir' || this.step === 'source')) {
|
|
320
|
+
this.openNextSource();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
115
326
|
switch (edit.kind) {
|
|
116
327
|
case 'none': {
|
|
117
328
|
return;
|
|
@@ -122,7 +333,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
122
333
|
return;
|
|
123
334
|
}
|
|
124
335
|
case 'leader': {
|
|
125
|
-
this.
|
|
336
|
+
this.quitFlow();
|
|
126
337
|
|
|
127
338
|
return;
|
|
128
339
|
}
|
|
@@ -141,14 +352,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
141
352
|
return;
|
|
142
353
|
}
|
|
143
354
|
case 'input': {
|
|
144
|
-
|
|
145
|
-
if (this.step === 'target') {
|
|
146
|
-
return;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
this.input = edit.value;
|
|
150
|
-
|
|
151
|
-
this.render();
|
|
355
|
+
this.applyInput(edit.value);
|
|
152
356
|
}
|
|
153
357
|
}
|
|
154
358
|
}
|
|
@@ -174,40 +378,25 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
174
378
|
hint,
|
|
175
379
|
});
|
|
176
380
|
} else if (this.step === 'dir') {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
this.
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
title: `${verb}: directory`,
|
|
183
|
-
items,
|
|
184
|
-
selected: this.selected,
|
|
185
|
-
input: this.input,
|
|
186
|
-
hint: 'type to filter, or a path (/ ~ .) · ↑↓ move · ⏎ select · esc cancel',
|
|
187
|
-
});
|
|
381
|
+
this.renderDirStep(verb);
|
|
382
|
+
} else if (this.step === 'source') {
|
|
383
|
+
this.renderSourceStep();
|
|
384
|
+
} else if (this.step === 'ref') {
|
|
385
|
+
this.renderRefStep();
|
|
188
386
|
} else if (this.step === 'target') {
|
|
189
|
-
this.
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
title: `${verb}: target`,
|
|
193
|
-
items: this.targets.map((t) => formatTargetPick(t)),
|
|
194
|
-
selected: this.selected,
|
|
195
|
-
input: '',
|
|
196
|
-
hint: this.refusal ?? `where ${formatDir(this.dir)} runs · ↑↓ move · ⏎ select · esc back`,
|
|
197
|
-
dimmed: new Set(
|
|
198
|
-
this.targets.flatMap((t, i) =>
|
|
199
|
-
t.takesWorkspace || (t.available && t.inPlace) ? [] : [i],
|
|
200
|
-
),
|
|
201
|
-
),
|
|
202
|
-
});
|
|
387
|
+
this.renderTargetStep(verb);
|
|
388
|
+
} else if (this.step === 'confirm') {
|
|
389
|
+
this.renderConfirmStep();
|
|
203
390
|
} else if (this.step === 'name') {
|
|
391
|
+
const where = this.isGitFlow() ? this.formatDestination() : formatDir(this.dir);
|
|
392
|
+
|
|
204
393
|
drawPicker({
|
|
205
394
|
title: `${verb}: name`,
|
|
206
395
|
items: [],
|
|
207
396
|
selected: -1,
|
|
208
397
|
input: this.input,
|
|
209
398
|
placeholder: formatDirName(this.dir),
|
|
210
|
-
hint: this.refusal ?? `session name for ${
|
|
399
|
+
hint: this.refusal ?? `session name for ${where} · ⏎ accept · esc back`,
|
|
211
400
|
});
|
|
212
401
|
} else {
|
|
213
402
|
drawPicker({
|
|
@@ -216,34 +405,262 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
216
405
|
selected: -1,
|
|
217
406
|
input: this.input,
|
|
218
407
|
placeholder: 'optional — ⏎ to start interactive',
|
|
219
|
-
hint:
|
|
408
|
+
hint:
|
|
409
|
+
this.pending?.label ??
|
|
410
|
+
this.refusal ??
|
|
411
|
+
'first message for the session · ⏎ spawn · esc back',
|
|
220
412
|
});
|
|
221
413
|
}
|
|
222
414
|
|
|
223
415
|
this.deps.scheduleStatus();
|
|
224
416
|
}
|
|
225
417
|
|
|
418
|
+
// Leaves the flow for the screen it came from, dropping every answer
|
|
419
|
+
// still on its way.
|
|
420
|
+
private quitFlow() {
|
|
421
|
+
this.stopFlow();
|
|
422
|
+
this.deps.toBase();
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Ends the flow's generation: no request it sent changes anything when
|
|
426
|
+
// its answer comes.
|
|
427
|
+
private stopFlow() {
|
|
428
|
+
this.generation += 1;
|
|
429
|
+
this.pending = null;
|
|
430
|
+
this.listingSeq = null;
|
|
431
|
+
this.pathListingSeq = null;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Whether an answer belongs to a flow that has since ended.
|
|
435
|
+
private isStale(generation: number): boolean {
|
|
436
|
+
return generation !== this.generation;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
private renderDirStep(verb: string) {
|
|
440
|
+
const items = this.collectDirItems().map((d) => this.dirLabels.get(d) ?? formatDir(d));
|
|
441
|
+
|
|
442
|
+
this.selected = Math.min(this.selected, Math.max(0, Math.min(items.length, 10) - 1));
|
|
443
|
+
|
|
444
|
+
drawPicker({
|
|
445
|
+
title: `${verb}: ${this.findSource()?.label ?? 'directory'}`,
|
|
446
|
+
items,
|
|
447
|
+
selected: this.selected,
|
|
448
|
+
input: this.input,
|
|
449
|
+
hint:
|
|
450
|
+
this.pending?.label ??
|
|
451
|
+
this.refusal ??
|
|
452
|
+
`type to filter, or a path (/ ~ .) · ↑↓ move · ⏎ select${this.formatTabHint()} · esc cancel`,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
private renderTargetStep(verb: string) {
|
|
457
|
+
this.selected = Math.min(this.selected, this.targets.length - 1);
|
|
458
|
+
|
|
459
|
+
const git = this.isGitFlow();
|
|
460
|
+
const what = git ? 'the repository' : formatDir(this.dir);
|
|
461
|
+
|
|
462
|
+
drawPicker({
|
|
463
|
+
title: `${verb}: target`,
|
|
464
|
+
items: this.targets.map((t) => formatTargetPick(t)),
|
|
465
|
+
selected: this.selected,
|
|
466
|
+
input: '',
|
|
467
|
+
hint: this.refusal ?? `where ${what} runs · ↑↓ move · ⏎ select · esc back`,
|
|
468
|
+
dimmed: new Set(
|
|
469
|
+
this.targets.flatMap((t, i) =>
|
|
470
|
+
t.takesWorkspace || (!git && t.available && t.inPlace) ? [] : [i],
|
|
471
|
+
),
|
|
472
|
+
),
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
private renderSourceStep() {
|
|
477
|
+
const items = this.collectCandidateItems();
|
|
478
|
+
|
|
479
|
+
this.selected = Math.min(this.selected, Math.max(0, Math.min(items.length, 10) - 1));
|
|
480
|
+
|
|
481
|
+
const scope = this.listedScope === null ? '' : ` · ${this.listedScope}`;
|
|
482
|
+
const listing = this.listingSeq === null ? null : 'listing… · esc stops';
|
|
483
|
+
|
|
484
|
+
drawPicker({
|
|
485
|
+
title: `spawn: ${this.findSource()?.label ?? ''}${scope}`,
|
|
486
|
+
items: items.map((item) => item.label),
|
|
487
|
+
selected: items.length === 0 || items[0]?.notice === true ? -1 : this.selected,
|
|
488
|
+
input: this.input,
|
|
489
|
+
hint:
|
|
490
|
+
this.pending?.label ??
|
|
491
|
+
this.refusal ??
|
|
492
|
+
listing ??
|
|
493
|
+
`type to filter, or a git URL · ↑↓ move · ⏎ select${this.formatTabHint()} · esc back`,
|
|
494
|
+
dimmed: new Set(items.flatMap((item, i) => (item.notice === true ? [i] : []))),
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
private renderRefStep() {
|
|
499
|
+
const items = this.collectRefItems();
|
|
500
|
+
|
|
501
|
+
this.selected = Math.min(this.selected, Math.max(0, Math.min(items.length, 10) - 1));
|
|
502
|
+
|
|
503
|
+
drawPicker({
|
|
504
|
+
title: `spawn: ref · ${this.repo?.label ?? ''}`,
|
|
505
|
+
items: items.map((item) => formatRef(item, this.repo?.head ?? null)),
|
|
506
|
+
selected: items.length === 0 ? -1 : this.selected,
|
|
507
|
+
input: this.input,
|
|
508
|
+
hint:
|
|
509
|
+
this.pending?.label ??
|
|
510
|
+
this.refusal ??
|
|
511
|
+
'branch, tag, or full commit id · ↑↓ move · ⏎ select · esc back',
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
private renderConfirmStep() {
|
|
516
|
+
const ref = this.ref;
|
|
517
|
+
const target = this.findEffectiveTarget();
|
|
518
|
+
let refRow = '';
|
|
519
|
+
|
|
520
|
+
if (ref !== null) {
|
|
521
|
+
refRow = ref.ref === null ? `commit ${ref.sha}` : `${ref.ref} → ${ref.sha.slice(0, 12)}`;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
drawPicker({
|
|
525
|
+
title: 'spawn: confirm',
|
|
526
|
+
items: [
|
|
527
|
+
`source ${this.repo?.url ?? ''}`,
|
|
528
|
+
`ref ${refRow}`,
|
|
529
|
+
`target ${target === null ? 'default' : `${target.id} (${target.provider})`}`,
|
|
530
|
+
`dest ${this.formatDestination()}`,
|
|
531
|
+
`agent ${this.agent}`,
|
|
532
|
+
],
|
|
533
|
+
selected: -1,
|
|
534
|
+
input: this.input,
|
|
535
|
+
placeholder: 'destination on the target',
|
|
536
|
+
hint: this.refusal ?? 'destination on the target · ⏎ continue · esc back',
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// The source the flow is on, or null on the local directory flow.
|
|
541
|
+
private findSource(): AnnouncedSource | null {
|
|
542
|
+
return this.sources?.[this.sourceIndex] ?? null;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Whether the flow is choosing a git repository rather than a directory.
|
|
546
|
+
private isGitFlow(): boolean {
|
|
547
|
+
return this.findSource()?.kind === 'git';
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// The hint that Tab moves to the next source, when there is one to move
|
|
551
|
+
// to.
|
|
552
|
+
private formatTabHint(): string {
|
|
553
|
+
const sources = this.sources ?? [];
|
|
554
|
+
|
|
555
|
+
if (this.resume || sources.length < 2) {
|
|
556
|
+
return '';
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const next = sources[(this.sourceIndex + 1) % sources.length];
|
|
560
|
+
|
|
561
|
+
return next === undefined ? '' : ` · tab ${next.label}`;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
private applyInput(value: string) {
|
|
565
|
+
// The target step takes no text: its list is short and fixed.
|
|
566
|
+
if (this.step === 'target') {
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
this.input = value;
|
|
571
|
+
|
|
572
|
+
if (this.step === 'source') {
|
|
573
|
+
this.alternateURL = null;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// A pasted URL in the directory step is a repository, not a path, so
|
|
577
|
+
// the flow moves to the first source of repositories with it.
|
|
578
|
+
if (this.step === 'dir' && !this.resume && isGitURL(value) && !value.startsWith('/')) {
|
|
579
|
+
const index = this.sources?.findIndex((source) => source.kind === 'git') ?? -1;
|
|
580
|
+
|
|
581
|
+
if (index !== -1) {
|
|
582
|
+
this.openGitSource(index, value, { kind: 'none' });
|
|
583
|
+
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
this.render();
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Stops waiting on the request in flight. Its answer, when it comes, is
|
|
592
|
+
// dropped. A spawn goes on on the daemon, which lists the session once
|
|
593
|
+
// it starts, so the flow leaves rather than offer to spawn it again.
|
|
594
|
+
private applyPendingCancel() {
|
|
595
|
+
const cancelled = this.pending;
|
|
596
|
+
|
|
597
|
+
this.pending = null;
|
|
598
|
+
|
|
599
|
+
if (cancelled?.kind === 'spawn') {
|
|
600
|
+
this.quitFlow();
|
|
601
|
+
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
this.refusal = 'cancelled · esc back';
|
|
606
|
+
|
|
607
|
+
process.stdout.write(ansi.clear);
|
|
608
|
+
this.render();
|
|
609
|
+
}
|
|
610
|
+
|
|
226
611
|
private applyCancel() {
|
|
612
|
+
// Esc leaves whatever step the flow is on, so a directory listing it
|
|
613
|
+
// was waiting on no longer opens its step.
|
|
614
|
+
this.pathListingSeq = null;
|
|
615
|
+
|
|
227
616
|
if (this.step === 'agent') {
|
|
228
|
-
this.
|
|
617
|
+
this.quitFlow();
|
|
229
618
|
|
|
230
619
|
return;
|
|
231
620
|
}
|
|
232
621
|
|
|
622
|
+
// Esc first stops a listing in flight, keeping the step.
|
|
623
|
+
if (this.step === 'source' && this.listingSeq !== null) {
|
|
624
|
+
this.listingSeq = null;
|
|
625
|
+
this.listedKey = null;
|
|
626
|
+
this.listNotice = 'listing stopped — type to enter one';
|
|
627
|
+
|
|
628
|
+
this.render();
|
|
629
|
+
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const typed = this.input;
|
|
634
|
+
|
|
233
635
|
this.input = '';
|
|
234
636
|
|
|
235
637
|
if (this.step === 'dir') {
|
|
236
|
-
this.
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
638
|
+
this.openAgentStep();
|
|
639
|
+
} else if (this.step === 'source') {
|
|
640
|
+
this.sourceDraft = typed;
|
|
641
|
+
|
|
642
|
+
this.applySourceCancel();
|
|
643
|
+
} else if (this.step === 'ref') {
|
|
644
|
+
this.input = this.repo?.label ?? '';
|
|
645
|
+
this.repo = null;
|
|
646
|
+
this.step = 'source';
|
|
242
647
|
} else if (this.step === 'target') {
|
|
243
|
-
this.
|
|
648
|
+
this.applyTargetCancel();
|
|
649
|
+
|
|
650
|
+
return;
|
|
651
|
+
} else if (this.step === 'confirm') {
|
|
652
|
+
this.openRefStep();
|
|
244
653
|
} else if (this.step === 'name') {
|
|
245
|
-
this.
|
|
654
|
+
if (this.isGitFlow()) {
|
|
655
|
+
this.input = this.buildDestination();
|
|
656
|
+
this.step = 'confirm';
|
|
657
|
+
} else {
|
|
658
|
+
this.openTargetStep(() => {
|
|
659
|
+
this.step = 'dir';
|
|
660
|
+
});
|
|
661
|
+
}
|
|
246
662
|
} else {
|
|
663
|
+
this.input = this.name;
|
|
247
664
|
this.step = 'name';
|
|
248
665
|
}
|
|
249
666
|
|
|
@@ -251,6 +668,54 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
251
668
|
this.render();
|
|
252
669
|
}
|
|
253
670
|
|
|
671
|
+
private openAgentStep() {
|
|
672
|
+
this.selected = Math.max(
|
|
673
|
+
0,
|
|
674
|
+
this.picks.findIndex((p) => p.agent === this.agent),
|
|
675
|
+
);
|
|
676
|
+
|
|
677
|
+
this.step = 'agent';
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Leaves a git source step backwards: to the target step when the daemon
|
|
681
|
+
// has more than one target, else to the agent.
|
|
682
|
+
private applySourceCancel() {
|
|
683
|
+
if (this.targets.length >= 2) {
|
|
684
|
+
this.openTargetStep(() => {});
|
|
685
|
+
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
this.openAgentStep();
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Leaves the target step backwards: a directory goes back to its step,
|
|
693
|
+
// and a repository to the first source of directories, else the agent.
|
|
694
|
+
private applyTargetCancel() {
|
|
695
|
+
this.sourceAction = { kind: 'none' };
|
|
696
|
+
|
|
697
|
+
if (!this.isGitFlow()) {
|
|
698
|
+
this.step = 'dir';
|
|
699
|
+
|
|
700
|
+
process.stdout.write(ansi.clear);
|
|
701
|
+
this.render();
|
|
702
|
+
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const index = this.sources?.findIndex((source) => source.kind === 'path') ?? -1;
|
|
707
|
+
|
|
708
|
+
if (index === -1) {
|
|
709
|
+
this.openAgentStep();
|
|
710
|
+
process.stdout.write(ansi.clear);
|
|
711
|
+
this.render();
|
|
712
|
+
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
void this.openPathSource(index, null);
|
|
717
|
+
}
|
|
718
|
+
|
|
254
719
|
private applySubmit() {
|
|
255
720
|
if (this.step === 'agent') {
|
|
256
721
|
const pick = this.picks[this.selected];
|
|
@@ -262,43 +727,43 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
262
727
|
}
|
|
263
728
|
|
|
264
729
|
this.agent = pick.agent;
|
|
265
|
-
void this.
|
|
730
|
+
void this.openFirstSource();
|
|
266
731
|
|
|
267
732
|
return;
|
|
268
733
|
}
|
|
269
734
|
|
|
270
|
-
if (this.step === '
|
|
271
|
-
|
|
272
|
-
this.collectDirItems()[this.selected] ??
|
|
273
|
-
resolvePathInput(this.input.trim(), process.cwd(), homedir());
|
|
735
|
+
if (this.step === 'source') {
|
|
736
|
+
void this.applySourceSubmit();
|
|
274
737
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
}
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
278
740
|
|
|
279
|
-
|
|
280
|
-
this.
|
|
741
|
+
if (this.step === 'ref') {
|
|
742
|
+
void this.applyRefSubmit();
|
|
281
743
|
|
|
282
|
-
|
|
283
|
-
}
|
|
284
|
-
const pick = this.targets[this.selected];
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
285
746
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
}
|
|
747
|
+
if (this.step === 'dir') {
|
|
748
|
+
void this.applyDirSubmit();
|
|
289
749
|
|
|
290
|
-
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
291
752
|
|
|
292
|
-
|
|
293
|
-
|
|
753
|
+
if (this.step === 'target') {
|
|
754
|
+
this.applyTargetSubmit();
|
|
755
|
+
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
294
758
|
|
|
759
|
+
if (this.step === 'confirm') {
|
|
760
|
+
if (!this.applyDestination(this.input.trim())) {
|
|
295
761
|
this.render();
|
|
296
762
|
|
|
297
763
|
return;
|
|
298
764
|
}
|
|
299
765
|
|
|
300
|
-
this.
|
|
301
|
-
this.input = '';
|
|
766
|
+
this.input = this.name;
|
|
302
767
|
this.step = 'name';
|
|
303
768
|
} else if (this.step === 'name') {
|
|
304
769
|
this.name = this.input.trim();
|
|
@@ -323,131 +788,1002 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
323
788
|
this.render();
|
|
324
789
|
}
|
|
325
790
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
private collectDirItems(): string[] {
|
|
331
|
-
return resolvePathInput(this.input, process.cwd(), homedir()) === null
|
|
332
|
-
? pickMatches(this.dirs, this.input)
|
|
333
|
-
: collectPathCompletions(this.input, process.cwd(), homedir());
|
|
334
|
-
}
|
|
791
|
+
// Takes the directory step's choice: a listed or completed directory, a
|
|
792
|
+
// typed path, or else what the sources read the text as.
|
|
793
|
+
private async applyDirSubmit() {
|
|
794
|
+
const typed = this.input.trim();
|
|
335
795
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
if (
|
|
340
|
-
|
|
341
|
-
// and its provider decides whether a directory runs in place.
|
|
342
|
-
this.target = this.targets[0] ?? null;
|
|
343
|
-
this.step = fallback;
|
|
796
|
+
const chosen =
|
|
797
|
+
this.collectDirItems()[this.selected] ?? resolvePathInput(typed, process.cwd(), homedir());
|
|
798
|
+
|
|
799
|
+
if (chosen !== null) {
|
|
800
|
+
this.applyDir(chosen);
|
|
344
801
|
|
|
345
802
|
return;
|
|
346
803
|
}
|
|
347
804
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
this.selected = Math.max(
|
|
351
|
-
0,
|
|
352
|
-
this.targets.findIndex((t) => (preferred === undefined ? t.isDefault : t.id === preferred)),
|
|
353
|
-
);
|
|
354
|
-
|
|
355
|
-
this.step = 'target';
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
private async openDirStep() {
|
|
359
|
-
let recent: string[] = [];
|
|
360
|
-
|
|
361
|
-
try {
|
|
362
|
-
const listed = await this.deps.sendRequest('agents.list');
|
|
363
|
-
|
|
364
|
-
const picks = collectTargetPicks(listed);
|
|
365
|
-
|
|
366
|
-
// An adopt resumes a session from its history on this host, which a
|
|
367
|
-
// fresh checkout elsewhere does not hold, so it is offered only the
|
|
368
|
-
// targets that run here.
|
|
369
|
-
this.targets = this.resume ? picks.filter((t) => t.available && t.inPlace) : picks;
|
|
370
|
-
} catch {
|
|
371
|
-
this.targets = [];
|
|
805
|
+
if (typed !== '' && this.sources !== null) {
|
|
806
|
+
await this.readTypedInput(typed, true);
|
|
372
807
|
}
|
|
808
|
+
}
|
|
373
809
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
const dirs = answer['dirs'];
|
|
378
|
-
|
|
379
|
-
if (Array.isArray(dirs)) {
|
|
380
|
-
recent = dirs.filter((d): d is string => typeof d === 'string');
|
|
381
|
-
}
|
|
382
|
-
} catch {}
|
|
810
|
+
private applyDir(dir: string) {
|
|
811
|
+
this.dir = dir;
|
|
812
|
+
this.input = '';
|
|
383
813
|
|
|
384
|
-
this.
|
|
385
|
-
|
|
386
|
-
recent,
|
|
387
|
-
roots: this.roots,
|
|
388
|
-
zoxide: await collectZoxideDirs(),
|
|
814
|
+
this.openTargetStep(() => {
|
|
815
|
+
this.step = 'name';
|
|
389
816
|
});
|
|
390
817
|
|
|
391
|
-
this.input = '';
|
|
392
|
-
this.selected = 0;
|
|
393
|
-
this.step = 'dir';
|
|
394
|
-
|
|
395
818
|
process.stdout.write(ansi.clear);
|
|
396
819
|
this.render();
|
|
397
820
|
}
|
|
398
821
|
|
|
399
|
-
private
|
|
400
|
-
|
|
401
|
-
const ok = await this.deps.sendRequest('session.spawn', {
|
|
402
|
-
cwd: this.dir,
|
|
403
|
-
name: this.name,
|
|
404
|
-
prompt,
|
|
405
|
-
cols: cols(),
|
|
406
|
-
rows: this.deps.ptyRows(),
|
|
407
|
-
...(this.resume ? { resume: true } : {}),
|
|
408
|
-
agent: this.agent,
|
|
409
|
-
...this.buildTargetParams(),
|
|
410
|
-
});
|
|
411
|
-
|
|
412
|
-
const spawned = this.deps.toMirrorSession(ok['session']);
|
|
413
|
-
|
|
414
|
-
if (spawned !== null) {
|
|
415
|
-
this.deps.upsertMirror(spawned);
|
|
822
|
+
private applyTargetSubmit() {
|
|
823
|
+
const pick = this.targets[this.selected];
|
|
416
824
|
|
|
417
|
-
|
|
825
|
+
if (pick === undefined) {
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
418
828
|
|
|
419
|
-
|
|
420
|
-
}
|
|
421
|
-
} catch (error) {
|
|
422
|
-
// A refused spawn keeps the picker on the step that sent it, with the
|
|
423
|
-
// entered text back in the input, so the user can fix the cause and
|
|
424
|
-
// retry or back out.
|
|
425
|
-
const reason =
|
|
426
|
-
error instanceof DaemonError ? `${error.code}: ${error.message}` : String(error);
|
|
829
|
+
const refusal = findTargetRefusal(pick, this.isGitFlow());
|
|
427
830
|
|
|
428
|
-
|
|
429
|
-
this.
|
|
831
|
+
if (refusal !== null) {
|
|
832
|
+
this.refusal = refusal;
|
|
430
833
|
|
|
431
|
-
process.stdout.write(ansi.clear);
|
|
432
834
|
this.render();
|
|
433
835
|
|
|
434
836
|
return;
|
|
435
837
|
}
|
|
436
838
|
|
|
437
|
-
|
|
438
|
-
|
|
839
|
+
// A default destination belongs to the target it was built for.
|
|
840
|
+
if (pick.id !== this.target?.id && !this.destinationEdited) {
|
|
841
|
+
this.destination = '';
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
this.target = pick;
|
|
845
|
+
this.input = '';
|
|
846
|
+
|
|
847
|
+
if (this.isGitFlow()) {
|
|
848
|
+
this.openSourceStep();
|
|
849
|
+
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
this.step = 'name';
|
|
854
|
+
|
|
855
|
+
process.stdout.write(ansi.clear);
|
|
856
|
+
this.render();
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* A typed path completes against the filesystem, like a shell; anything
|
|
861
|
+
* else fuzzy-filters the merged list.
|
|
862
|
+
*/
|
|
863
|
+
private collectDirItems(): string[] {
|
|
864
|
+
return resolvePathInput(this.input, process.cwd(), homedir()) === null
|
|
865
|
+
? pickMatches(this.dirs, this.input)
|
|
866
|
+
: collectPathCompletions(this.input, process.cwd(), homedir());
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// The git source step's rows: the other URL form after a failed probe,
|
|
870
|
+
// the notice in place of a list the daemon could not give, or the listed
|
|
871
|
+
// candidates the input matches.
|
|
872
|
+
private collectCandidateItems(): {
|
|
873
|
+
label: string;
|
|
874
|
+
notice?: boolean;
|
|
875
|
+
candidate?: ListedCandidate;
|
|
876
|
+
url?: string;
|
|
877
|
+
}[] {
|
|
878
|
+
if (this.alternateURL !== null) {
|
|
879
|
+
return [{ label: `try ${this.alternateURL} instead`, url: this.alternateURL }];
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
if (this.listNotice !== null) {
|
|
883
|
+
return [{ label: this.listNotice, notice: true }];
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
const filter = isGitURL(this.input) ? '' : this.input.trim();
|
|
887
|
+
|
|
888
|
+
const byLabel = new Map(this.candidates.map((candidate) => [candidate.label, candidate]));
|
|
889
|
+
|
|
890
|
+
return pickMatches([...byLabel.keys()], filter).flatMap((label) => {
|
|
891
|
+
const candidate = byLabel.get(label);
|
|
892
|
+
|
|
893
|
+
if (candidate === undefined) {
|
|
894
|
+
return [];
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
return [
|
|
898
|
+
{
|
|
899
|
+
label: candidate.detail === null ? label : `${label} ${candidate.detail}`,
|
|
900
|
+
candidate,
|
|
901
|
+
},
|
|
902
|
+
];
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// The ref step's rows: the default branch first, then every branch and
|
|
907
|
+
// tag in the order the upstream lists them, filtered by name.
|
|
908
|
+
private collectRefItems(): ProbedRef[] {
|
|
909
|
+
const refs = this.repo?.refs ?? [];
|
|
910
|
+
const head = this.repo?.head ?? null;
|
|
911
|
+
|
|
912
|
+
const ordered = [
|
|
913
|
+
...refs.filter((ref) => ref.kind === 'branch' && ref.name === head),
|
|
914
|
+
...refs.filter((ref) => !(ref.kind === 'branch' && ref.name === head)),
|
|
915
|
+
];
|
|
916
|
+
|
|
917
|
+
const names = pickMatches([...new Set(ordered.map((ref) => ref.name))], this.input.trim());
|
|
918
|
+
|
|
919
|
+
return names.flatMap((name) => ordered.filter((ref) => ref.name === name));
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Opens the source after the current one, wrapping round.
|
|
923
|
+
private openNextSource() {
|
|
924
|
+
const sources = this.sources ?? [];
|
|
925
|
+
|
|
926
|
+
if (sources.length < 2) {
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
this.openSource((this.sourceIndex + 1) % sources.length);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
private openSource(index: number) {
|
|
934
|
+
const source = this.sources?.[index];
|
|
935
|
+
|
|
936
|
+
if (source?.kind === 'path') {
|
|
937
|
+
void this.openPathSource(index, null);
|
|
938
|
+
} else if (source?.kind === 'git') {
|
|
939
|
+
this.openGitSource(index, '', { kind: 'none' });
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// Lists a source of directories and opens the directory step on it, with
|
|
944
|
+
// the client's own directory first. The flow stays where it is until the
|
|
945
|
+
// listing answers, and an answer that comes after the flow moved on is
|
|
946
|
+
// dropped.
|
|
947
|
+
private async openPathSource(index: number, scope: string | null) {
|
|
948
|
+
const source = this.sources?.[index];
|
|
949
|
+
const generation = this.generation;
|
|
950
|
+
|
|
951
|
+
this.requestSeq += 1;
|
|
952
|
+
|
|
953
|
+
const seq = this.requestSeq;
|
|
954
|
+
|
|
955
|
+
this.pathListingSeq = seq;
|
|
956
|
+
|
|
957
|
+
let candidates: ListedCandidate[] = [];
|
|
958
|
+
|
|
959
|
+
if (source !== undefined) {
|
|
960
|
+
try {
|
|
961
|
+
const answer = await this.deps.sendRequest('sources.list', {
|
|
962
|
+
source: source.id,
|
|
963
|
+
...(scope === null ? {} : { scope }),
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
candidates = parseCandidates(answer['candidates']);
|
|
967
|
+
} catch {}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
if (this.isStale(generation) || this.pathListingSeq !== seq) {
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
this.pathListingSeq = null;
|
|
439
975
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
976
|
+
const listed = candidates.flatMap((candidate) =>
|
|
977
|
+
candidate.pick.kind === 'path' ? [[candidate.pick.dir, candidate.label] as const] : [],
|
|
978
|
+
);
|
|
979
|
+
|
|
980
|
+
this.sourceIndex = index;
|
|
981
|
+
|
|
982
|
+
// A git listing dropped here never fills its candidates, so its source
|
|
983
|
+
// step lists again when it reopens.
|
|
984
|
+
if (this.listingSeq !== null) {
|
|
985
|
+
this.listingSeq = null;
|
|
986
|
+
this.listedKey = null;
|
|
446
987
|
}
|
|
447
988
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
989
|
+
this.dirLabels = new Map(listed);
|
|
990
|
+
|
|
991
|
+
this.dirs = [...new Set([process.cwd(), ...listed.map(([dir]) => dir)])];
|
|
992
|
+
this.input = '';
|
|
993
|
+
this.selected = 0;
|
|
994
|
+
this.step = 'dir';
|
|
995
|
+
|
|
996
|
+
process.stdout.write(ansi.clear);
|
|
997
|
+
this.render();
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// Moves the flow to a source of git repositories, carrying the typed text
|
|
1001
|
+
// across: the target step first when the daemon has more than one target
|
|
1002
|
+
// and the flow had no repository target yet, then the source step, which
|
|
1003
|
+
// acts as asked once it opens.
|
|
1004
|
+
private openGitSource(index: number, draft: string, action: SourceStepAction) {
|
|
1005
|
+
const fromGit = this.isGitFlow();
|
|
1006
|
+
|
|
1007
|
+
this.pathListingSeq = null;
|
|
1008
|
+
this.sourceIndex = index;
|
|
1009
|
+
this.sourceDraft = draft;
|
|
1010
|
+
this.sourceAction = action;
|
|
1011
|
+
|
|
1012
|
+
if (fromGit) {
|
|
1013
|
+
this.openSourceStep();
|
|
1014
|
+
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
if (this.targets.length >= 2) {
|
|
1019
|
+
this.openTargetStep(() => {});
|
|
1020
|
+
process.stdout.write(ansi.clear);
|
|
1021
|
+
this.render();
|
|
1022
|
+
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
this.target = this.targets[0] ?? null;
|
|
1027
|
+
|
|
1028
|
+
this.openSourceStep();
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// Opens the git source step with its draft, listing the source again
|
|
1032
|
+
// when the source or the target changed since the last listing.
|
|
1033
|
+
private openSourceStep() {
|
|
1034
|
+
this.step = 'source';
|
|
1035
|
+
this.input = this.sourceDraft;
|
|
1036
|
+
this.selected = 0;
|
|
1037
|
+
this.alternateURL = null;
|
|
1038
|
+
|
|
1039
|
+
const action = this.sourceAction;
|
|
1040
|
+
|
|
1041
|
+
this.sourceAction = { kind: 'none' };
|
|
1042
|
+
|
|
1043
|
+
const key = `${this.findSource()?.id ?? ''}\n${this.target?.id ?? ''}`;
|
|
1044
|
+
const stale = this.listedKey !== key;
|
|
1045
|
+
|
|
1046
|
+
if (stale) {
|
|
1047
|
+
this.listedKey = key;
|
|
1048
|
+
this.candidates = [];
|
|
1049
|
+
this.listedScope = null;
|
|
1050
|
+
this.listNotice = null;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
process.stdout.write(ansi.clear);
|
|
1054
|
+
this.render();
|
|
1055
|
+
|
|
1056
|
+
// A scope a reading asked for is listed in place of the source's own
|
|
1057
|
+
// default, for the target the step now has.
|
|
1058
|
+
if (action.kind === 'browse') {
|
|
1059
|
+
void this.loadCandidates(action.scope);
|
|
1060
|
+
} else if (stale) {
|
|
1061
|
+
void this.loadCandidates(null);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
if (action.kind === 'probe') {
|
|
1065
|
+
void this.checkRepoAccess(this.sourceDraft, action.url);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// The target every source request is made for: the chosen one, or none
|
|
1070
|
+
// for the daemon's default when there is no choice to make.
|
|
1071
|
+
private buildTargetParam(): Readonly<Record<string, string>> {
|
|
1072
|
+
return this.target === null ? {} : { target: this.target.id };
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
// Lists the git source's candidates behind the step, under a scope or
|
|
1076
|
+
// the source's own default. A daemon that cannot list them leaves a
|
|
1077
|
+
// notice row. Typing goes on meanwhile, and Esc stops the listing.
|
|
1078
|
+
private async loadCandidates(scope: string | null) {
|
|
1079
|
+
const source = this.findSource();
|
|
1080
|
+
const generation = this.generation;
|
|
1081
|
+
|
|
1082
|
+
if (source === null) {
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
this.requestSeq += 1;
|
|
1087
|
+
|
|
1088
|
+
const seq = this.requestSeq;
|
|
1089
|
+
|
|
1090
|
+
this.listingSeq = seq;
|
|
1091
|
+
|
|
1092
|
+
this.render();
|
|
1093
|
+
|
|
1094
|
+
let answer: Readonly<Record<string, unknown>> | null = null;
|
|
1095
|
+
let failure: unknown = null;
|
|
1096
|
+
|
|
1097
|
+
try {
|
|
1098
|
+
const params = {
|
|
1099
|
+
source: source.id,
|
|
1100
|
+
...(scope === null ? {} : { scope }),
|
|
1101
|
+
...this.buildTargetParam(),
|
|
1102
|
+
};
|
|
1103
|
+
|
|
1104
|
+
answer = await this.deps.sendRequest('sources.list', params);
|
|
1105
|
+
} catch (error) {
|
|
1106
|
+
failure = error;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
if (this.isStale(generation) || this.listingSeq !== seq) {
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// An answer for a source the flow has left fills nothing, so that
|
|
1114
|
+
// source lists again when its step reopens.
|
|
1115
|
+
if (this.findSource()?.id !== source.id) {
|
|
1116
|
+
this.listingSeq = null;
|
|
1117
|
+
this.listedKey = null;
|
|
1118
|
+
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
this.listingSeq = null;
|
|
1123
|
+
|
|
1124
|
+
if (answer === null) {
|
|
1125
|
+
this.candidates = [];
|
|
1126
|
+
this.listedScope = scope;
|
|
1127
|
+
this.listNotice = `${formatListFailure(failure)} — type to enter one`;
|
|
1128
|
+
} else {
|
|
1129
|
+
this.candidates = parseCandidates(answer['candidates']);
|
|
1130
|
+
this.listedScope = typeof answer['scope'] === 'string' ? answer['scope'] : scope;
|
|
1131
|
+
|
|
1132
|
+
this.listNotice =
|
|
1133
|
+
this.candidates.length === 0 && this.listedScope !== null
|
|
1134
|
+
? `nothing listed for ${this.listedScope}`
|
|
1135
|
+
: null;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
// The list answers behind the flow, so only the source step's own
|
|
1139
|
+
// selection starts over.
|
|
1140
|
+
if (this.step === 'source') {
|
|
1141
|
+
this.selected = 0;
|
|
1142
|
+
|
|
1143
|
+
process.stdout.write(ansi.clear);
|
|
1144
|
+
this.render();
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
// Takes the git source step's choice: the other URL form on offer, a
|
|
1149
|
+
// typed git URL, what the source reads typed text as, the selected
|
|
1150
|
+
// candidate, or else what another source reads the text as.
|
|
1151
|
+
private async applySourceSubmit() {
|
|
1152
|
+
const typed = this.input.trim();
|
|
1153
|
+
const item = this.collectCandidateItems()[this.selected];
|
|
1154
|
+
|
|
1155
|
+
if (item?.url !== undefined) {
|
|
1156
|
+
await this.checkRepoAccess(item.url, item.url);
|
|
1157
|
+
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
if (isGitURL(typed)) {
|
|
1162
|
+
await this.checkRepoAccess(typed, typed);
|
|
1163
|
+
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
if (typed !== '') {
|
|
1168
|
+
const reading = await this.sendInterpret(this.sourceIndex, typed);
|
|
1169
|
+
|
|
1170
|
+
if (reading === null) {
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
if (canTakeReading('git', reading)) {
|
|
1175
|
+
this.applyReading(this.sourceIndex, reading, typed);
|
|
1176
|
+
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
const candidate = item?.candidate;
|
|
1182
|
+
|
|
1183
|
+
if (candidate?.pick.kind === 'git') {
|
|
1184
|
+
await this.checkRepoAccess(candidate.label, candidate.pick.url);
|
|
1185
|
+
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
if (typed !== '') {
|
|
1190
|
+
await this.readTypedInput(typed, false);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// Asks the sources in order what typed text is, the flow's own first when
|
|
1195
|
+
// asked, and moves the flow to the first that reads it as something its
|
|
1196
|
+
// kind takes. Text none reads is refused with the reason.
|
|
1197
|
+
private async readTypedInput(typed: string, withOwn: boolean) {
|
|
1198
|
+
const order = (this.sources ?? []).flatMap((_, index) =>
|
|
1199
|
+
index === this.sourceIndex ? [] : [index],
|
|
1200
|
+
);
|
|
1201
|
+
|
|
1202
|
+
for (const index of withOwn ? [this.sourceIndex, ...order] : order) {
|
|
1203
|
+
const reading = await this.sendInterpret(index, typed);
|
|
1204
|
+
|
|
1205
|
+
if (reading === null) {
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
if (canTakeReading(this.sources?.[index]?.kind, reading)) {
|
|
1210
|
+
this.applyReading(index, reading, typed);
|
|
1211
|
+
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
this.refusal = `no source reads ${typed} · esc back`;
|
|
1217
|
+
|
|
1218
|
+
process.stdout.write(ansi.clear);
|
|
1219
|
+
this.render();
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
// Asks one source what typed text is. Null when Esc cancelled the
|
|
1223
|
+
// request; a refused request reads as nothing.
|
|
1224
|
+
private async sendInterpret(index: number, typed: string): Promise<Interpretation | null> {
|
|
1225
|
+
const source = this.sources?.[index];
|
|
1226
|
+
|
|
1227
|
+
if (source === undefined) {
|
|
1228
|
+
return { kind: 'none' };
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
const answered = await this.sendPending('interpret', `reading ${typed}…`, 'sources.interpret', {
|
|
1232
|
+
source: source.id,
|
|
1233
|
+
input: typed,
|
|
1234
|
+
...(source.kind === 'git' ? this.buildTargetParam() : {}),
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
if (answered === null) {
|
|
1238
|
+
return null;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
return answered.ok ? parseInterpretation(answered.answer) : { kind: 'none' };
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// Moves the flow to what a source read typed text as: its scope listed,
|
|
1245
|
+
// the directory chosen, or the repository probed.
|
|
1246
|
+
private applyReading(index: number, reading: Interpretation, typed: string) {
|
|
1247
|
+
if (reading.kind === 'none') {
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
if (reading.kind === 'path') {
|
|
1252
|
+
this.sourceIndex = index;
|
|
1253
|
+
|
|
1254
|
+
this.applyDir(reading.dir);
|
|
1255
|
+
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
const scope = reading.kind === 'browse' ? reading.scope : null;
|
|
1260
|
+
|
|
1261
|
+
if (this.sources?.[index]?.kind === 'path') {
|
|
1262
|
+
void this.openPathSource(index, scope);
|
|
1263
|
+
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
// The step lists the scope or probes the repository once it opens,
|
|
1268
|
+
// after any target step, so the request is made for the spawn's target.
|
|
1269
|
+
if (index !== this.sourceIndex || this.step !== 'source') {
|
|
1270
|
+
const action: SourceStepAction =
|
|
1271
|
+
reading.kind === 'git'
|
|
1272
|
+
? { kind: 'probe', url: reading.url }
|
|
1273
|
+
: { kind: 'browse', scope: reading.scope };
|
|
1274
|
+
|
|
1275
|
+
const draft = reading.kind === 'git' ? typed : '';
|
|
1276
|
+
|
|
1277
|
+
this.openGitSource(index, draft, action);
|
|
1278
|
+
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
if (reading.kind === 'browse') {
|
|
1283
|
+
this.input = '';
|
|
1284
|
+
void this.loadCandidates(reading.scope);
|
|
1285
|
+
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
void this.checkRepoAccess(typed, reading.url);
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
// Sends a request the step waits on, and answers with its result, or
|
|
1293
|
+
// null when Esc cancelled it before the answer came.
|
|
1294
|
+
private async sendPending(
|
|
1295
|
+
kind: PendingRequest['kind'],
|
|
1296
|
+
label: string,
|
|
1297
|
+
m: string,
|
|
1298
|
+
p: Readonly<Record<string, unknown>>,
|
|
1299
|
+
): Promise<
|
|
1300
|
+
| { readonly ok: true; readonly answer: Readonly<Record<string, unknown>> }
|
|
1301
|
+
| { readonly ok: false; readonly error: unknown }
|
|
1302
|
+
| null
|
|
1303
|
+
> {
|
|
1304
|
+
this.requestSeq += 1;
|
|
1305
|
+
|
|
1306
|
+
const seq = this.requestSeq;
|
|
1307
|
+
const generation = this.generation;
|
|
1308
|
+
const esc = kind === 'spawn' ? 'esc stops waiting; the session still lists' : 'esc cancels';
|
|
1309
|
+
|
|
1310
|
+
this.pending = { label: `${label} · ${esc}`, seq, kind };
|
|
1311
|
+
|
|
1312
|
+
this.render();
|
|
1313
|
+
|
|
1314
|
+
let result:
|
|
1315
|
+
| { readonly ok: true; readonly answer: Readonly<Record<string, unknown>> }
|
|
1316
|
+
| { readonly ok: false; readonly error: unknown };
|
|
1317
|
+
|
|
1318
|
+
try {
|
|
1319
|
+
result = { ok: true, answer: await this.deps.sendRequest(m, p) };
|
|
1320
|
+
} catch (error) {
|
|
1321
|
+
result = { ok: false, error };
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
if (this.isStale(generation) || this.pending?.seq !== seq) {
|
|
1325
|
+
return null;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
this.pending = null;
|
|
1329
|
+
|
|
1330
|
+
return result;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
// Checks that the daemon's host can read the repository at this URL for
|
|
1334
|
+
// the spawn's target, and opens the ref step with its refs. A failure
|
|
1335
|
+
// stays on the source step with git's error and offers the other URL
|
|
1336
|
+
// form the daemon returns, when there is one.
|
|
1337
|
+
private async checkRepoAccess(label: string, url: string) {
|
|
1338
|
+
const probed = await this.sendPending('probe', `checking access to ${url}…`, 'git.probe', {
|
|
1339
|
+
url,
|
|
1340
|
+
...this.buildTargetParam(),
|
|
1341
|
+
});
|
|
1342
|
+
|
|
1343
|
+
if (probed === null) {
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
if (!probed.ok) {
|
|
1348
|
+
this.alternateURL = findAlternateURL(probed.error);
|
|
1349
|
+
this.selected = 0;
|
|
1350
|
+
this.refusal = `${formatError(probed.error)} · esc back`;
|
|
1351
|
+
this.input = label;
|
|
1352
|
+
|
|
1353
|
+
process.stdout.write(ansi.clear);
|
|
1354
|
+
this.render();
|
|
1355
|
+
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
const repo = buildProbedRepo(label, url, probed.answer);
|
|
1360
|
+
|
|
1361
|
+
// A destination typed for one repository is not meant for another.
|
|
1362
|
+
if (repo.url !== this.destinationRepoURL) {
|
|
1363
|
+
this.destinationEdited = false;
|
|
1364
|
+
this.destination = '';
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
this.repo = repo;
|
|
1368
|
+
this.ref = null;
|
|
1369
|
+
|
|
1370
|
+
this.openRefStep();
|
|
1371
|
+
process.stdout.write(ansi.clear);
|
|
1372
|
+
this.render();
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
// Reads the repository's refs again after the commit the spawn pinned
|
|
1376
|
+
// left the upstream, and opens the ref step on them with the refusal.
|
|
1377
|
+
private async refreshRefs(reason: string) {
|
|
1378
|
+
const repo = this.repo;
|
|
1379
|
+
|
|
1380
|
+
if (repo === null) {
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
this.openRefStep();
|
|
1385
|
+
|
|
1386
|
+
const probed = await this.sendPending('probe', `re-reading refs of ${repo.url}…`, 'git.probe', {
|
|
1387
|
+
url: repo.url,
|
|
1388
|
+
...this.buildTargetParam(),
|
|
1389
|
+
});
|
|
1390
|
+
|
|
1391
|
+
if (probed === null) {
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
if (probed.ok) {
|
|
1396
|
+
this.repo = buildProbedRepo(repo.label, repo.url, probed.answer);
|
|
1397
|
+
this.refusal = `refs re-read · ${reason} · esc back`;
|
|
1398
|
+
} else {
|
|
1399
|
+
this.refusal = `${reason} · ${formatError(probed.error)} · esc back`;
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
process.stdout.write(ansi.clear);
|
|
1403
|
+
this.render();
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
private openRefStep() {
|
|
1407
|
+
this.step = 'ref';
|
|
1408
|
+
this.input = this.ref === null ? '' : (this.ref.ref ?? this.ref.sha);
|
|
1409
|
+
this.selected = 0;
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
private async applyRefSubmit() {
|
|
1413
|
+
const typed = this.input.trim();
|
|
1414
|
+
const named = this.repo?.refs.find((ref) => ref.name === typed);
|
|
1415
|
+
const item = this.collectRefItems()[this.selected];
|
|
1416
|
+
|
|
1417
|
+
if (named !== undefined) {
|
|
1418
|
+
this.applyRef({ ref: named.name, sha: named.sha });
|
|
1419
|
+
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
if (FULL_SHA_PATTERN.test(typed)) {
|
|
1424
|
+
this.applyRef({ ref: null, sha: typed });
|
|
1425
|
+
|
|
1426
|
+
return;
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
// An abbreviated commit id could name another commit by the time the
|
|
1430
|
+
// clone runs, so only a full one pins the checkout.
|
|
1431
|
+
if (SHORT_SHA_PATTERN.test(typed)) {
|
|
1432
|
+
this.refusal = 'full commit id required · esc back';
|
|
1433
|
+
|
|
1434
|
+
this.render();
|
|
1435
|
+
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
if (item !== undefined) {
|
|
1440
|
+
this.applyRef({ ref: item.name, sha: item.sha });
|
|
1441
|
+
|
|
1442
|
+
return;
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
if (typed === '' || this.repo === null) {
|
|
1446
|
+
return;
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
// A ref the listing leaves out, such as `refs/tags/v1`, is resolved by
|
|
1450
|
+
// the daemon the way the spawn would resolve it.
|
|
1451
|
+
const probed = await this.sendPending('probe', `resolving ${typed}…`, 'git.probe', {
|
|
1452
|
+
url: this.repo.url,
|
|
1453
|
+
ref: typed,
|
|
1454
|
+
...this.buildTargetParam(),
|
|
1455
|
+
});
|
|
1456
|
+
|
|
1457
|
+
if (probed === null) {
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
const resolved =
|
|
1462
|
+
probed.ok && isRecord(probed.answer['resolved']) ? probed.answer['resolved'] : {};
|
|
1463
|
+
|
|
1464
|
+
if (typeof resolved['sha'] === 'string') {
|
|
1465
|
+
this.applyRef({ ref: typed, sha: resolved['sha'] });
|
|
1466
|
+
|
|
1467
|
+
return;
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
this.refusal = probed.ok
|
|
1471
|
+
? `the daemon resolved no commit for ${typed} · esc back`
|
|
1472
|
+
: `${formatError(probed.error)} · esc back`;
|
|
1473
|
+
|
|
1474
|
+
process.stdout.write(ansi.clear);
|
|
1475
|
+
this.render();
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
private applyRef(ref: ChosenRef) {
|
|
1479
|
+
// A default destination belongs to the commit it was built for.
|
|
1480
|
+
if (!this.destinationEdited) {
|
|
1481
|
+
this.destination = '';
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
this.ref = ref;
|
|
1485
|
+
this.input = '';
|
|
1486
|
+
|
|
1487
|
+
this.openConfirmStep();
|
|
1488
|
+
process.stdout.write(ansi.clear);
|
|
1489
|
+
this.render();
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
// Opens the confirm screen with the destination the user last accepted,
|
|
1493
|
+
// else the default under the target's workspace root. A root the target
|
|
1494
|
+
// cannot take leaves the destination empty with the reason.
|
|
1495
|
+
private openConfirmStep() {
|
|
1496
|
+
this.step = 'confirm';
|
|
1497
|
+
this.destinationAttempt = 1;
|
|
1498
|
+
|
|
1499
|
+
if (this.destination === '' && this.repo !== null && this.ref !== null) {
|
|
1500
|
+
const target = this.findEffectiveTarget();
|
|
1501
|
+
|
|
1502
|
+
const root = resolveWorkspaceRoot(this.workspaces, {
|
|
1503
|
+
id: target?.id ?? 'local',
|
|
1504
|
+
inPlace: target?.inPlace ?? true,
|
|
1505
|
+
});
|
|
1506
|
+
|
|
1507
|
+
if (root.ok) {
|
|
1508
|
+
this.destinationRepoURL = this.repo.url;
|
|
1509
|
+
|
|
1510
|
+
this.destination = buildWorkspaceDestination({
|
|
1511
|
+
root: root.root,
|
|
1512
|
+
url: this.repo.url,
|
|
1513
|
+
ref: this.ref.ref,
|
|
1514
|
+
sha: this.ref.sha,
|
|
1515
|
+
});
|
|
1516
|
+
} else {
|
|
1517
|
+
this.refusal = root.message;
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
this.input = this.buildDestination();
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
// Takes the confirm screen's destination: absolute on its target, with a
|
|
1525
|
+
// leading `~` expanded only on the daemon's own machine. False, with the
|
|
1526
|
+
// reason shown, when the target cannot take it.
|
|
1527
|
+
private applyDestination(typed: string): boolean {
|
|
1528
|
+
const inPlace = this.findEffectiveTarget()?.inPlace ?? true;
|
|
1529
|
+
|
|
1530
|
+
const dir =
|
|
1531
|
+
inPlace && (typed === '~' || typed.startsWith('~/'))
|
|
1532
|
+
? `${resolveHomeDir()}${typed.slice(1)}`
|
|
1533
|
+
: typed;
|
|
1534
|
+
|
|
1535
|
+
if (!dir.startsWith('/')) {
|
|
1536
|
+
this.refusal = inPlace
|
|
1537
|
+
? 'the destination must be an absolute path'
|
|
1538
|
+
: 'the destination must be an absolute path on the target; ~ is not expanded there';
|
|
1539
|
+
|
|
1540
|
+
return false;
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
if (dir !== this.buildDestination()) {
|
|
1544
|
+
this.destination = dir;
|
|
1545
|
+
this.destinationAttempt = 1;
|
|
1546
|
+
this.destinationEdited = true;
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
this.destinationRepoURL = this.repo?.url ?? null;
|
|
1550
|
+
this.dir = this.buildDestination();
|
|
1551
|
+
|
|
1552
|
+
return true;
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
// The destination with the suffix a refused spawn added.
|
|
1556
|
+
private buildDestination(): string {
|
|
1557
|
+
return this.destinationAttempt === 1
|
|
1558
|
+
? this.destination
|
|
1559
|
+
: `${this.destination}-${this.destinationAttempt}`;
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
private formatDestination(): string {
|
|
1563
|
+
return `${this.findEffectiveTarget()?.id ?? 'local'}:${this.buildDestination()}`;
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
// The target the spawn runs on: the chosen one, else the daemon's
|
|
1567
|
+
// default, else null when the daemon lists no targets.
|
|
1568
|
+
private findEffectiveTarget(): TargetPick | null {
|
|
1569
|
+
return this.target ?? this.targets.find((t) => t.isDefault) ?? null;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
// Opens the target step when the daemon has more than one target, and
|
|
1573
|
+
// the fallback step otherwise. The chosen target is selected, else for a
|
|
1574
|
+
// repository the default when it takes a workspace and the first target
|
|
1575
|
+
// that does when it does not, else the default.
|
|
1576
|
+
private openTargetStep(fallback: () => void) {
|
|
1577
|
+
if (this.targets.length < 2) {
|
|
1578
|
+
// The one target is the spawn's, default or not, so the spawn names it
|
|
1579
|
+
// and its provider decides whether a directory runs in place.
|
|
1580
|
+
this.target = this.targets[0] ?? null;
|
|
1581
|
+
|
|
1582
|
+
fallback();
|
|
1583
|
+
|
|
1584
|
+
return;
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
const preferred =
|
|
1588
|
+
this.target ??
|
|
1589
|
+
(this.isGitFlow()
|
|
1590
|
+
? (this.targets.find((t) => t.isDefault && t.takesWorkspace) ??
|
|
1591
|
+
this.targets.find((t) => t.takesWorkspace))
|
|
1592
|
+
: undefined) ??
|
|
1593
|
+
this.targets.find((t) => t.isDefault);
|
|
1594
|
+
|
|
1595
|
+
this.selected = Math.max(
|
|
1596
|
+
0,
|
|
1597
|
+
this.targets.findIndex((t) => t.id === preferred?.id),
|
|
1598
|
+
);
|
|
1599
|
+
|
|
1600
|
+
this.input = '';
|
|
1601
|
+
this.step = 'target';
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
// Reads the daemon's targets and sources once the agent is chosen, and
|
|
1605
|
+
// opens the first source: the first of directories for an adopt, and the
|
|
1606
|
+
// local directory flow for a daemon that offers no sources.
|
|
1607
|
+
private async openFirstSource() {
|
|
1608
|
+
const generation = this.generation;
|
|
1609
|
+
let listed: Readonly<Record<string, unknown>> = {};
|
|
1610
|
+
|
|
1611
|
+
try {
|
|
1612
|
+
listed = await this.deps.sendRequest('agents.list');
|
|
1613
|
+
} catch {}
|
|
1614
|
+
|
|
1615
|
+
if (this.isStale(generation)) {
|
|
1616
|
+
return;
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
const picks = collectTargetPicks(listed);
|
|
1620
|
+
|
|
1621
|
+
// An adopt resumes a session from its history on this host, which a
|
|
1622
|
+
// fresh checkout elsewhere does not hold, so it is offered only the
|
|
1623
|
+
// targets that run here.
|
|
1624
|
+
this.targets = this.resume ? picks.filter((t) => t.available && t.inPlace) : picks;
|
|
1625
|
+
|
|
1626
|
+
const announced = parseAnnouncedSources(listed['sources']);
|
|
1627
|
+
const index = this.resume ? announced.findIndex((source) => source.kind === 'path') : 0;
|
|
1628
|
+
|
|
1629
|
+
if (announced.length > 0 && index !== -1) {
|
|
1630
|
+
this.sources = announced;
|
|
1631
|
+
|
|
1632
|
+
this.openSource(index);
|
|
1633
|
+
|
|
1634
|
+
return;
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
this.sources = null;
|
|
1638
|
+
|
|
1639
|
+
await this.openLocalDirs(generation);
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
// The local directory flow of a daemon that offers no sources: the
|
|
1643
|
+
// client's own directory, the daemon's spawn history, the configured
|
|
1644
|
+
// roots, and zoxide's list on this host.
|
|
1645
|
+
private async openLocalDirs(generation: number) {
|
|
1646
|
+
let recent: string[] = [];
|
|
1647
|
+
|
|
1648
|
+
try {
|
|
1649
|
+
const answer = await this.deps.sendRequest('dirs.list');
|
|
1650
|
+
|
|
1651
|
+
const dirs = answer['dirs'];
|
|
1652
|
+
|
|
1653
|
+
if (Array.isArray(dirs)) {
|
|
1654
|
+
recent = dirs.filter((d): d is string => typeof d === 'string');
|
|
1655
|
+
}
|
|
1656
|
+
} catch {}
|
|
1657
|
+
|
|
1658
|
+
const zoxide = await collectZoxideDirs();
|
|
1659
|
+
|
|
1660
|
+
if (this.isStale(generation)) {
|
|
1661
|
+
return;
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
this.dirs = collectDirs({ cwd: process.cwd(), recent, roots: this.roots, zoxide });
|
|
1665
|
+
|
|
1666
|
+
this.dirLabels = new Map();
|
|
1667
|
+
|
|
1668
|
+
this.sourceIndex = -1;
|
|
1669
|
+
this.input = '';
|
|
1670
|
+
this.selected = 0;
|
|
1671
|
+
this.step = 'dir';
|
|
1672
|
+
|
|
1673
|
+
process.stdout.write(ansi.clear);
|
|
1674
|
+
this.render();
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
private async spawn(prompt: string) {
|
|
1678
|
+
const params = {
|
|
1679
|
+
cwd: this.dir,
|
|
1680
|
+
name: this.name,
|
|
1681
|
+
prompt,
|
|
1682
|
+
cols: cols(),
|
|
1683
|
+
rows: this.deps.ptyRows(),
|
|
1684
|
+
...(this.resume ? { resume: true } : {}),
|
|
1685
|
+
agent: this.agent,
|
|
1686
|
+
...this.buildSourceParams(),
|
|
1687
|
+
};
|
|
1688
|
+
|
|
1689
|
+
const label = this.isGitFlow() ? 'materializing the workspace…' : 'spawning…';
|
|
1690
|
+
|
|
1691
|
+
const spawned = await this.sendPending('spawn', label, 'session.spawn', params);
|
|
1692
|
+
|
|
1693
|
+
if (spawned === null) {
|
|
1694
|
+
return;
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
if (!spawned.ok) {
|
|
1698
|
+
this.applySpawnRefusal(spawned.error, prompt);
|
|
1699
|
+
|
|
1700
|
+
return;
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
const session = this.deps.toMirrorSession(spawned.answer['session']);
|
|
1704
|
+
|
|
1705
|
+
if (session === null) {
|
|
1706
|
+
this.quitFlow();
|
|
1707
|
+
|
|
1708
|
+
return;
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
this.stopFlow();
|
|
1712
|
+
this.deps.upsertMirror(session);
|
|
1713
|
+
|
|
1714
|
+
await this.deps.attach(session.id);
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
// A refused git workspace spawn returns to the step that can fix it. Any
|
|
1718
|
+
// other refusal keeps the picker on the step that sent it, with the
|
|
1719
|
+
// entered text back in the input, so the user can fix the cause and retry
|
|
1720
|
+
// or back out.
|
|
1721
|
+
private applySpawnRefusal(error: unknown, prompt: string) {
|
|
1722
|
+
const reason = formatError(error);
|
|
1723
|
+
const code = error instanceof DaemonError ? error.code : 'internal';
|
|
1724
|
+
const step = this.isGitFlow() ? pickRefusalStep(code) : null;
|
|
1725
|
+
|
|
1726
|
+
if (step === 'ref') {
|
|
1727
|
+
void this.refreshRefs(reason);
|
|
1728
|
+
|
|
1729
|
+
return;
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
if (step === 'destination') {
|
|
1733
|
+
this.destinationAttempt += 1;
|
|
1734
|
+
this.step = 'confirm';
|
|
1735
|
+
this.input = this.buildDestination();
|
|
1736
|
+
this.refusal = `${reason} · ⏎ use ${this.buildDestination()} · esc back`;
|
|
1737
|
+
} else if (step === 'source') {
|
|
1738
|
+
this.input = this.repo?.label ?? '';
|
|
1739
|
+
this.repo = null;
|
|
1740
|
+
this.step = 'source';
|
|
1741
|
+
this.refusal = `${reason} · esc back`;
|
|
1742
|
+
} else if (step === 'confirm') {
|
|
1743
|
+
const phase =
|
|
1744
|
+
error instanceof DaemonError && typeof error.data?.['phase'] === 'string'
|
|
1745
|
+
? ` while ${error.data['phase']}`
|
|
1746
|
+
: '';
|
|
1747
|
+
|
|
1748
|
+
this.step = 'confirm';
|
|
1749
|
+
this.input = this.buildDestination();
|
|
1750
|
+
this.refusal = `${reason}${phase} · esc back`;
|
|
1751
|
+
} else {
|
|
1752
|
+
this.refusal = `${reason} · ⏎ retry · esc back`;
|
|
1753
|
+
this.input = this.resume ? this.name : prompt;
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
process.stdout.write(ansi.clear);
|
|
1757
|
+
this.render();
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
// What the spawn sends for its source. A git repository goes as a git
|
|
1761
|
+
// workspace at the commit the confirm screen showed, with the ref it was
|
|
1762
|
+
// chosen by. A chosen target goes with the spawn; a local directory runs
|
|
1763
|
+
// in place when the spawn's target is on the daemon's own machine, and is
|
|
1764
|
+
// materialized at the same path from its pushed HEAD on any other.
|
|
1765
|
+
private buildSourceParams(): Readonly<Record<string, unknown>> {
|
|
1766
|
+
const target = this.buildTargetParam();
|
|
1767
|
+
|
|
1768
|
+
if (this.isGitFlow() && this.repo !== null && this.ref !== null) {
|
|
1769
|
+
return {
|
|
1770
|
+
...target,
|
|
1771
|
+
workspace: {
|
|
1772
|
+
kind: 'git',
|
|
1773
|
+
url: this.repo.url,
|
|
1774
|
+
...(this.ref.ref === null ? {} : { ref: this.ref.ref }),
|
|
1775
|
+
sha: this.ref.sha,
|
|
1776
|
+
},
|
|
1777
|
+
};
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
const effective = this.findEffectiveTarget();
|
|
1781
|
+
|
|
1782
|
+
if (effective === null || effective.inPlace) {
|
|
1783
|
+
return target;
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
return { ...target, workspace: { kind: 'path', path: this.dir } };
|
|
451
1787
|
}
|
|
452
1788
|
}
|
|
453
1789
|
|
|
@@ -462,15 +1798,152 @@ function formatTargetPick(pick: TargetPick): string {
|
|
|
462
1798
|
return `${pick.id} ${notes.join(' · ')}`;
|
|
463
1799
|
}
|
|
464
1800
|
|
|
465
|
-
// Why a
|
|
466
|
-
|
|
1801
|
+
// Why a source cannot run on a target, or null when it can. A git
|
|
1802
|
+
// repository needs a target that takes a workspace; a local directory also
|
|
1803
|
+
// runs in place on the daemon's own machine.
|
|
1804
|
+
function findTargetRefusal(pick: TargetPick, git: boolean): string | null {
|
|
467
1805
|
if (!pick.available) {
|
|
468
1806
|
return `target '${pick.id}' is unavailable on this daemon · esc back`;
|
|
469
1807
|
}
|
|
470
1808
|
|
|
471
|
-
if (!pick.
|
|
1809
|
+
if (!pick.takesWorkspace && (git || !pick.inPlace)) {
|
|
472
1810
|
return `target '${pick.id}' cannot take a workspace · esc back`;
|
|
473
1811
|
}
|
|
474
1812
|
|
|
475
1813
|
return null;
|
|
476
1814
|
}
|
|
1815
|
+
|
|
1816
|
+
// Whether a source of a kind can act on a reading: a scope it lists, or a
|
|
1817
|
+
// pick of its own kind.
|
|
1818
|
+
function canTakeReading(
|
|
1819
|
+
kind: AnnouncedSource['kind'] | undefined,
|
|
1820
|
+
reading: Interpretation,
|
|
1821
|
+
): boolean {
|
|
1822
|
+
return reading.kind === 'browse' || (reading.kind !== 'none' && reading.kind === kind);
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
function formatRef(ref: ProbedRef, head: string | null): string {
|
|
1826
|
+
const notes = [
|
|
1827
|
+
...(ref.kind === 'tag' ? ['tag'] : []),
|
|
1828
|
+
...(ref.kind === 'branch' && ref.name === head ? ['default'] : []),
|
|
1829
|
+
ref.sha.slice(0, 7),
|
|
1830
|
+
];
|
|
1831
|
+
|
|
1832
|
+
return `${ref.name} ${notes.join(' · ')}`;
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
function formatError(error: unknown): string {
|
|
1836
|
+
return error instanceof DaemonError ? `${error.code}: ${error.message}` : String(error);
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
// Why the daemon gave no list, in the words the notice row uses.
|
|
1840
|
+
function formatListFailure(error: unknown): string {
|
|
1841
|
+
if (!(error instanceof DaemonError)) {
|
|
1842
|
+
return String(error);
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
if (error.code === 'unknown_method') {
|
|
1846
|
+
return 'this daemon cannot list sources';
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
return error.message;
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
// The other URL form a refused probe holds, or null when it holds none.
|
|
1853
|
+
function findAlternateURL(error: unknown): string | null {
|
|
1854
|
+
const alternates = error instanceof DaemonError ? error.data?.['alternates'] : undefined;
|
|
1855
|
+
const first: unknown = Array.isArray(alternates) ? alternates[0] : undefined;
|
|
1856
|
+
|
|
1857
|
+
return typeof first === 'string' ? first : null;
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
function buildProbedRepo(
|
|
1861
|
+
label: string,
|
|
1862
|
+
url: string,
|
|
1863
|
+
answer: Readonly<Record<string, unknown>>,
|
|
1864
|
+
): ProbedRepo {
|
|
1865
|
+
return {
|
|
1866
|
+
label,
|
|
1867
|
+
url: typeof answer['url'] === 'string' ? answer['url'] : url,
|
|
1868
|
+
head: typeof answer['head'] === 'string' ? answer['head'] : null,
|
|
1869
|
+
refs: parseProbedRefs(answer['refs']),
|
|
1870
|
+
};
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
function parseAnnouncedSources(raw: unknown): AnnouncedSource[] {
|
|
1874
|
+
if (!Array.isArray(raw)) {
|
|
1875
|
+
return [];
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
return raw.flatMap((entry: unknown): AnnouncedSource[] =>
|
|
1879
|
+
isRecord(entry) &&
|
|
1880
|
+
typeof entry['id'] === 'string' &&
|
|
1881
|
+
typeof entry['label'] === 'string' &&
|
|
1882
|
+
(entry['kind'] === 'git' || entry['kind'] === 'path')
|
|
1883
|
+
? [{ id: entry['id'], label: entry['label'], kind: entry['kind'] }]
|
|
1884
|
+
: [],
|
|
1885
|
+
);
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
function parseCandidates(raw: unknown): ListedCandidate[] {
|
|
1889
|
+
if (!Array.isArray(raw)) {
|
|
1890
|
+
return [];
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
return raw.flatMap((entry: unknown): ListedCandidate[] => {
|
|
1894
|
+
if (!isRecord(entry) || typeof entry['label'] !== 'string') {
|
|
1895
|
+
return [];
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
const pick = parsePick(entry['pick']);
|
|
1899
|
+
|
|
1900
|
+
return pick === null
|
|
1901
|
+
? []
|
|
1902
|
+
: [
|
|
1903
|
+
{
|
|
1904
|
+
label: entry['label'],
|
|
1905
|
+
detail: typeof entry['detail'] === 'string' ? entry['detail'] : null,
|
|
1906
|
+
pick,
|
|
1907
|
+
},
|
|
1908
|
+
];
|
|
1909
|
+
});
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
function parseInterpretation(answer: Readonly<Record<string, unknown>>): Interpretation {
|
|
1913
|
+
if (answer['kind'] === 'browse' && typeof answer['scope'] === 'string') {
|
|
1914
|
+
return { kind: 'browse', scope: answer['scope'] };
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
return parsePick(answer) ?? { kind: 'none' };
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
function parsePick(raw: unknown): SourcePick | null {
|
|
1921
|
+
if (!isRecord(raw)) {
|
|
1922
|
+
return null;
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
if (raw['kind'] === 'path' && typeof raw['dir'] === 'string') {
|
|
1926
|
+
return { kind: 'path', dir: raw['dir'] };
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
if (raw['kind'] === 'git' && typeof raw['url'] === 'string') {
|
|
1930
|
+
return { kind: 'git', url: raw['url'] };
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
return null;
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
function parseProbedRefs(raw: unknown): ProbedRef[] {
|
|
1937
|
+
if (!Array.isArray(raw)) {
|
|
1938
|
+
return [];
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
return raw.flatMap((entry: unknown): ProbedRef[] =>
|
|
1942
|
+
isRecord(entry) &&
|
|
1943
|
+
typeof entry['name'] === 'string' &&
|
|
1944
|
+
typeof entry['sha'] === 'string' &&
|
|
1945
|
+
(entry['kind'] === 'branch' || entry['kind'] === 'tag')
|
|
1946
|
+
? [{ name: entry['name'], kind: entry['kind'], sha: entry['sha'] }]
|
|
1947
|
+
: [],
|
|
1948
|
+
);
|
|
1949
|
+
}
|