@zgeoff/atc 2.16.0 → 2.19.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/cli.ts +24 -1
- package/src/client/build-workspace-destination.ts +38 -0
- package/src/client/collect-target-picks.ts +53 -0
- package/src/client/dirs.ts +1 -1
- package/src/client/pick-refusal-step.ts +29 -0
- package/src/client/resolve-workspace-root.ts +49 -0
- package/src/client/spawn-picker.ts +1683 -84
- package/src/client/ui.ts +6 -1
- package/src/daemon/daemon-connection.ts +129 -0
- package/src/daemon/daemon-context.ts +23 -0
- package/src/daemon/daemon.ts +29 -0
- package/src/daemon/materialize-workspace.ts +42 -18
- package/src/daemon/require-git-transports.ts +23 -0
- package/src/mcp/require-daemon-features.ts +2 -0
- package/src/protocol/daemon-features.ts +8 -0
- package/src/protocol/protocol.ts +2 -0
- package/src/protocol/request-param-schemas.ts +67 -15
- package/src/shared/collect-workspaces-config.ts +120 -0
- package/src/shared/config.ts +30 -1
- package/src/shared/default-git-transports.ts +5 -0
- package/src/shared/is-git-url.ts +11 -0
- package/src/sources/build-sources.ts +40 -0
- package/src/sources/collect-builtin-sources.ts +41 -0
- package/src/sources/dirs/build-dirs-source.ts +70 -0
- package/src/sources/git/build-git-source.ts +25 -0
- package/src/sources/github/build-github-source.ts +113 -0
- package/src/sources/github/collect-github-repos.ts +165 -0
- package/src/sources/github/find-github-alternate-url.ts +20 -0
- package/src/sources/github/read-git-protocol.ts +11 -0
- package/src/sources/github/run-gh.ts +66 -0
- package/src/sources/types.ts +70 -0
- package/src/workspace/check-git-transport.ts +61 -0
- package/src/workspace/check-repository-access.ts +108 -0
- package/src/workspace/collect-remote-refs.ts +154 -0
- package/src/workspace/create-git-askpass.ts +84 -0
- package/src/workspace/create-workspace-clone.ts +29 -76
- package/src/workspace/expand-git-shorthand.ts +13 -0
- package/src/workspace/find-remote-ref.ts +34 -0
- package/src/workspace/normalize-git-url.ts +2 -7
- package/src/workspace/resolve-git-url.ts +46 -0
- package/src/workspace/resolve-path-source.ts +11 -4
- package/src/workspace/run-git.ts +52 -4
- package/src/workspace/workspace-source.ts +10 -2
- /package/src/{client → shared}/collect-root-dirs.ts +0 -0
- /package/src/{client → shared}/collect-zoxide-dirs.ts +0 -0
|
@@ -1,17 +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';
|
|
5
|
+
import { collectZoxideDirs } from '../shared/collect-zoxide-dirs';
|
|
4
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';
|
|
5
12
|
import { collectAgentPicks } from './collect-agent-picks';
|
|
6
13
|
import type { AgentPick } from './collect-agent-picks';
|
|
7
14
|
import { collectPathCompletions } from './collect-path-completions';
|
|
8
|
-
import {
|
|
15
|
+
import { collectTargetPicks } from './collect-target-picks';
|
|
16
|
+
import type { TargetPick } from './collect-target-picks';
|
|
9
17
|
import { collectDirs, formatDir, formatDirName, pickMatches } from './dirs';
|
|
10
|
-
import { planTextEdit } from './keys';
|
|
18
|
+
import { KEY, planTextEdit } from './keys';
|
|
19
|
+
import { pickRefusalStep } from './pick-refusal-step';
|
|
11
20
|
import { resolvePathInput } from './resolve-path-input';
|
|
21
|
+
import { resolveWorkspaceRoot } from './resolve-workspace-root';
|
|
12
22
|
import { ansi, cols, drawPicker } from './ui';
|
|
13
23
|
|
|
14
|
-
type PickerStep = 'agent' | 'dir' | 'name' | 'prompt';
|
|
24
|
+
type PickerStep = 'agent' | 'dir' | 'source' | 'ref' | 'target' | 'confirm' | 'name' | 'prompt';
|
|
15
25
|
|
|
16
26
|
// What the flow borrows from the client that owns the screen, the daemon
|
|
17
27
|
// connection and the fleet mirror.
|
|
@@ -30,17 +40,103 @@ export interface SpawnPickerDeps<TMirror> {
|
|
|
30
40
|
readonly upsertMirror: (session: Readonly<TMirror>) => void;
|
|
31
41
|
}
|
|
32
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
|
+
|
|
33
112
|
/**
|
|
34
|
-
* The modal flow behind n and r: agent, then
|
|
35
|
-
* optional first prompt.
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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.
|
|
38
127
|
*/
|
|
39
128
|
export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
40
129
|
private readonly deps: SpawnPickerDeps<TMirror>;
|
|
41
130
|
|
|
42
131
|
private step: PickerStep = 'agent';
|
|
43
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
|
+
|
|
44
140
|
private input = '';
|
|
45
141
|
|
|
46
142
|
private selected = 0;
|
|
@@ -49,35 +145,122 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
49
145
|
// edit or a fresh install lands without a client restart.
|
|
50
146
|
private picks: AgentPick[] = [];
|
|
51
147
|
|
|
148
|
+
// The directories the directory step lists, and the label each shows.
|
|
52
149
|
private dirs: string[] = [];
|
|
53
150
|
|
|
54
|
-
|
|
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.
|
|
55
155
|
private roots: readonly string[] = [];
|
|
56
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
|
+
|
|
57
165
|
private dir = '';
|
|
58
166
|
|
|
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.
|
|
169
|
+
private targets: TargetPick[] = [];
|
|
170
|
+
|
|
171
|
+
private target: TargetPick | null = null;
|
|
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
|
+
|
|
59
217
|
private name = '';
|
|
60
218
|
|
|
61
219
|
private resume = false;
|
|
62
220
|
|
|
63
221
|
private agent: AgentID = 'claude';
|
|
64
222
|
|
|
65
|
-
//
|
|
66
|
-
//
|
|
223
|
+
// Why the last action was refused, shown in place of the hint until the
|
|
224
|
+
// next key.
|
|
67
225
|
private refusal: string | null = null;
|
|
68
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
|
+
|
|
69
236
|
constructor(deps: SpawnPickerDeps<TMirror>) {
|
|
70
237
|
this.deps = deps;
|
|
71
238
|
}
|
|
72
239
|
|
|
73
240
|
open(resume = false) {
|
|
241
|
+
this.generation += 1;
|
|
74
242
|
this.resume = resume;
|
|
75
243
|
|
|
76
244
|
const config = loadConfig();
|
|
77
245
|
|
|
78
246
|
this.picks = collectAgentPicks(config);
|
|
79
247
|
this.roots = config.dirs.roots;
|
|
248
|
+
this.workspaces = config.workspaces;
|
|
80
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;
|
|
81
264
|
|
|
82
265
|
// A last-used agent that is no longer installed is not in the menu, so
|
|
83
266
|
// the selection falls to the first one that is.
|
|
@@ -89,6 +272,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
89
272
|
this.agent = this.picks[this.selected]?.agent ?? this.deps.getLastUsedAgent();
|
|
90
273
|
this.step = 'agent';
|
|
91
274
|
this.refusal = null;
|
|
275
|
+
this.pending = null;
|
|
92
276
|
|
|
93
277
|
process.stdout.write(ansi.clear);
|
|
94
278
|
this.render();
|
|
@@ -99,9 +283,46 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
99
283
|
|
|
100
284
|
const edit = planTextEdit(buf, this.input, {
|
|
101
285
|
isLeaderKey: this.deps.isLeaderKey,
|
|
102
|
-
moves:
|
|
286
|
+
moves:
|
|
287
|
+
this.step === 'agent' ||
|
|
288
|
+
this.step === 'dir' ||
|
|
289
|
+
this.step === 'source' ||
|
|
290
|
+
this.step === 'ref' ||
|
|
291
|
+
this.step === 'target',
|
|
103
292
|
});
|
|
104
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
|
+
|
|
105
326
|
switch (edit.kind) {
|
|
106
327
|
case 'none': {
|
|
107
328
|
return;
|
|
@@ -112,7 +333,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
112
333
|
return;
|
|
113
334
|
}
|
|
114
335
|
case 'leader': {
|
|
115
|
-
this.
|
|
336
|
+
this.quitFlow();
|
|
116
337
|
|
|
117
338
|
return;
|
|
118
339
|
}
|
|
@@ -131,9 +352,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
131
352
|
return;
|
|
132
353
|
}
|
|
133
354
|
case 'input': {
|
|
134
|
-
this.
|
|
135
|
-
|
|
136
|
-
this.render();
|
|
355
|
+
this.applyInput(edit.value);
|
|
137
356
|
}
|
|
138
357
|
}
|
|
139
358
|
}
|
|
@@ -159,25 +378,25 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
159
378
|
hint,
|
|
160
379
|
});
|
|
161
380
|
} else if (this.step === 'dir') {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
this.
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
hint: 'type to filter, or a path (/ ~ .) · ↑↓ move · ⏎ select · esc cancel',
|
|
172
|
-
});
|
|
381
|
+
this.renderDirStep(verb);
|
|
382
|
+
} else if (this.step === 'source') {
|
|
383
|
+
this.renderSourceStep();
|
|
384
|
+
} else if (this.step === 'ref') {
|
|
385
|
+
this.renderRefStep();
|
|
386
|
+
} else if (this.step === 'target') {
|
|
387
|
+
this.renderTargetStep(verb);
|
|
388
|
+
} else if (this.step === 'confirm') {
|
|
389
|
+
this.renderConfirmStep();
|
|
173
390
|
} else if (this.step === 'name') {
|
|
391
|
+
const where = this.isGitFlow() ? this.formatDestination() : formatDir(this.dir);
|
|
392
|
+
|
|
174
393
|
drawPicker({
|
|
175
394
|
title: `${verb}: name`,
|
|
176
395
|
items: [],
|
|
177
396
|
selected: -1,
|
|
178
397
|
input: this.input,
|
|
179
398
|
placeholder: formatDirName(this.dir),
|
|
180
|
-
hint: this.refusal ?? `session name for ${
|
|
399
|
+
hint: this.refusal ?? `session name for ${where} · ⏎ accept · esc back`,
|
|
181
400
|
});
|
|
182
401
|
} else {
|
|
183
402
|
drawPicker({
|
|
@@ -186,32 +405,262 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
186
405
|
selected: -1,
|
|
187
406
|
input: this.input,
|
|
188
407
|
placeholder: 'optional — ⏎ to start interactive',
|
|
189
|
-
hint:
|
|
408
|
+
hint:
|
|
409
|
+
this.pending?.label ??
|
|
410
|
+
this.refusal ??
|
|
411
|
+
'first message for the session · ⏎ spawn · esc back',
|
|
190
412
|
});
|
|
191
413
|
}
|
|
192
414
|
|
|
193
415
|
this.deps.scheduleStatus();
|
|
194
416
|
}
|
|
195
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
|
+
|
|
196
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
|
+
|
|
197
616
|
if (this.step === 'agent') {
|
|
198
|
-
this.
|
|
617
|
+
this.quitFlow();
|
|
618
|
+
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
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();
|
|
199
629
|
|
|
200
630
|
return;
|
|
201
631
|
}
|
|
202
632
|
|
|
633
|
+
const typed = this.input;
|
|
634
|
+
|
|
203
635
|
this.input = '';
|
|
204
636
|
|
|
205
637
|
if (this.step === 'dir') {
|
|
206
|
-
this.
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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';
|
|
647
|
+
} else if (this.step === 'target') {
|
|
648
|
+
this.applyTargetCancel();
|
|
210
649
|
|
|
211
|
-
|
|
650
|
+
return;
|
|
651
|
+
} else if (this.step === 'confirm') {
|
|
652
|
+
this.openRefStep();
|
|
212
653
|
} else if (this.step === 'name') {
|
|
213
|
-
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
|
+
}
|
|
214
662
|
} else {
|
|
663
|
+
this.input = this.name;
|
|
215
664
|
this.step = 'name';
|
|
216
665
|
}
|
|
217
666
|
|
|
@@ -219,6 +668,54 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
219
668
|
this.render();
|
|
220
669
|
}
|
|
221
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
|
+
|
|
222
719
|
private applySubmit() {
|
|
223
720
|
if (this.step === 'agent') {
|
|
224
721
|
const pick = this.picks[this.selected];
|
|
@@ -230,22 +727,43 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
230
727
|
}
|
|
231
728
|
|
|
232
729
|
this.agent = pick.agent;
|
|
233
|
-
void this.
|
|
730
|
+
void this.openFirstSource();
|
|
731
|
+
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
if (this.step === 'source') {
|
|
736
|
+
void this.applySourceSubmit();
|
|
737
|
+
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
if (this.step === 'ref') {
|
|
742
|
+
void this.applyRefSubmit();
|
|
234
743
|
|
|
235
744
|
return;
|
|
236
745
|
}
|
|
237
746
|
|
|
238
747
|
if (this.step === 'dir') {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
748
|
+
void this.applyDirSubmit();
|
|
749
|
+
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
if (this.step === 'target') {
|
|
754
|
+
this.applyTargetSubmit();
|
|
755
|
+
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
if (this.step === 'confirm') {
|
|
760
|
+
if (!this.applyDestination(this.input.trim())) {
|
|
761
|
+
this.render();
|
|
242
762
|
|
|
243
|
-
if (chosen === null) {
|
|
244
763
|
return;
|
|
245
764
|
}
|
|
246
765
|
|
|
247
|
-
this.
|
|
248
|
-
this.input = '';
|
|
766
|
+
this.input = this.name;
|
|
249
767
|
this.step = 'name';
|
|
250
768
|
} else if (this.step === 'name') {
|
|
251
769
|
this.name = this.input.trim();
|
|
@@ -270,6 +788,74 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
270
788
|
this.render();
|
|
271
789
|
}
|
|
272
790
|
|
|
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();
|
|
795
|
+
|
|
796
|
+
const chosen =
|
|
797
|
+
this.collectDirItems()[this.selected] ?? resolvePathInput(typed, process.cwd(), homedir());
|
|
798
|
+
|
|
799
|
+
if (chosen !== null) {
|
|
800
|
+
this.applyDir(chosen);
|
|
801
|
+
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
if (typed !== '' && this.sources !== null) {
|
|
806
|
+
await this.readTypedInput(typed, true);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
private applyDir(dir: string) {
|
|
811
|
+
this.dir = dir;
|
|
812
|
+
this.input = '';
|
|
813
|
+
|
|
814
|
+
this.openTargetStep(() => {
|
|
815
|
+
this.step = 'name';
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
process.stdout.write(ansi.clear);
|
|
819
|
+
this.render();
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
private applyTargetSubmit() {
|
|
823
|
+
const pick = this.targets[this.selected];
|
|
824
|
+
|
|
825
|
+
if (pick === undefined) {
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const refusal = findTargetRefusal(pick, this.isGitFlow());
|
|
830
|
+
|
|
831
|
+
if (refusal !== null) {
|
|
832
|
+
this.refusal = refusal;
|
|
833
|
+
|
|
834
|
+
this.render();
|
|
835
|
+
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
|
|
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
|
+
|
|
273
859
|
/**
|
|
274
860
|
* A typed path completes against the filesystem, like a shell; anything
|
|
275
861
|
* else fuzzy-filters the merged list.
|
|
@@ -280,64 +866,489 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
280
866
|
: collectPathCompletions(this.input, process.cwd(), homedir());
|
|
281
867
|
}
|
|
282
868
|
|
|
283
|
-
|
|
284
|
-
|
|
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
|
+
}
|
|
285
881
|
|
|
286
|
-
|
|
287
|
-
|
|
882
|
+
if (this.listNotice !== null) {
|
|
883
|
+
return [{ label: this.listNotice, notice: true }];
|
|
884
|
+
}
|
|
288
885
|
|
|
289
|
-
|
|
886
|
+
const filter = isGitURL(this.input) ? '' : this.input.trim();
|
|
290
887
|
|
|
291
|
-
|
|
292
|
-
|
|
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 [];
|
|
293
895
|
}
|
|
294
|
-
} catch {}
|
|
295
896
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
897
|
+
return [
|
|
898
|
+
{
|
|
899
|
+
label: candidate.detail === null ? label : `${label} ${candidate.detail}`,
|
|
900
|
+
candidate,
|
|
901
|
+
},
|
|
902
|
+
];
|
|
301
903
|
});
|
|
904
|
+
}
|
|
302
905
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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;
|
|
306
911
|
|
|
307
|
-
|
|
308
|
-
|
|
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));
|
|
309
920
|
}
|
|
310
921
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
cwd: this.dir,
|
|
315
|
-
name: this.name,
|
|
316
|
-
prompt,
|
|
317
|
-
cols: cols(),
|
|
318
|
-
rows: this.deps.ptyRows(),
|
|
319
|
-
...(this.resume ? { resume: true } : {}),
|
|
320
|
-
agent: this.agent,
|
|
321
|
-
});
|
|
922
|
+
// Opens the source after the current one, wrapping round.
|
|
923
|
+
private openNextSource() {
|
|
924
|
+
const sources = this.sources ?? [];
|
|
322
925
|
|
|
323
|
-
|
|
926
|
+
if (sources.length < 2) {
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
324
929
|
|
|
325
|
-
|
|
326
|
-
|
|
930
|
+
this.openSource((this.sourceIndex + 1) % sources.length);
|
|
931
|
+
}
|
|
327
932
|
|
|
328
|
-
|
|
933
|
+
private openSource(index: number) {
|
|
934
|
+
const source = this.sources?.[index];
|
|
329
935
|
|
|
330
|
-
|
|
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;
|
|
975
|
+
|
|
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;
|
|
987
|
+
}
|
|
988
|
+
|
|
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;
|
|
331
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) };
|
|
332
1320
|
} catch (error) {
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
// retry or back out.
|
|
336
|
-
const reason =
|
|
337
|
-
error instanceof DaemonError ? `${error.code}: ${error.message}` : String(error);
|
|
1321
|
+
result = { ok: false, error };
|
|
1322
|
+
}
|
|
338
1323
|
|
|
339
|
-
|
|
340
|
-
|
|
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;
|
|
341
1352
|
|
|
342
1353
|
process.stdout.write(ansi.clear);
|
|
343
1354
|
this.render();
|
|
@@ -345,6 +1356,594 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
345
1356
|
return;
|
|
346
1357
|
}
|
|
347
1358
|
|
|
348
|
-
|
|
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;
|
|
349
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 } };
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
function formatTargetPick(pick: TargetPick): string {
|
|
1791
|
+
const notes = [
|
|
1792
|
+
pick.provider,
|
|
1793
|
+
...(pick.isDefault ? ['default'] : []),
|
|
1794
|
+
...(pick.available ? [] : ['unavailable']),
|
|
1795
|
+
...(pick.available && !pick.takesWorkspace ? ['no workspace'] : []),
|
|
1796
|
+
];
|
|
1797
|
+
|
|
1798
|
+
return `${pick.id} ${notes.join(' · ')}`;
|
|
1799
|
+
}
|
|
1800
|
+
|
|
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 {
|
|
1805
|
+
if (!pick.available) {
|
|
1806
|
+
return `target '${pick.id}' is unavailable on this daemon · esc back`;
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
if (!pick.takesWorkspace && (git || !pick.inPlace)) {
|
|
1810
|
+
return `target '${pick.id}' cannot take a workspace · esc back`;
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
return null;
|
|
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
|
+
);
|
|
350
1949
|
}
|