@sagmans/dsh-tui 0.3.0 → 0.5.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/LICENSE +1 -1
- package/README.md +407 -53
- package/cordis.patch.yml +14 -0
- package/lib/agent/present.js +1 -1
- package/lib/agent/present.js.map +1 -1
- package/lib/agent/projections.d.ts +22 -6
- package/lib/agent/projections.d.ts.map +1 -1
- package/lib/agent/projections.js +21 -10
- package/lib/agent/projections.js.map +1 -1
- package/lib/agent/prompt-history.d.ts +89 -0
- package/lib/agent/prompt-history.d.ts.map +1 -0
- package/lib/agent/prompt-history.js +382 -0
- package/lib/agent/prompt-history.js.map +1 -0
- package/lib/agent/status.d.ts +6 -0
- package/lib/agent/status.d.ts.map +1 -1
- package/lib/agent/status.js +1 -0
- package/lib/agent/status.js.map +1 -1
- package/lib/cards.d.ts +83 -13
- package/lib/cards.d.ts.map +1 -1
- package/lib/cards.js +96 -28
- package/lib/cards.js.map +1 -1
- package/lib/export.d.ts +3 -3
- package/lib/export.d.ts.map +1 -1
- package/lib/export.js +23 -13
- package/lib/export.js.map +1 -1
- package/lib/fold-cursor.d.ts +12 -5
- package/lib/fold-cursor.d.ts.map +1 -1
- package/lib/fold-cursor.js +16 -5
- package/lib/fold-cursor.js.map +1 -1
- package/lib/gates.d.ts.map +1 -1
- package/lib/gates.js +42 -9
- package/lib/gates.js.map +1 -1
- package/lib/herdr/client.d.ts +57 -0
- package/lib/herdr/client.d.ts.map +1 -0
- package/lib/herdr/client.js +218 -0
- package/lib/herdr/client.js.map +1 -0
- package/lib/herdr/constants.d.ts +93 -0
- package/lib/herdr/constants.d.ts.map +1 -0
- package/lib/herdr/constants.js +79 -0
- package/lib/herdr/constants.js.map +1 -0
- package/lib/herdr/reporter.d.ts +60 -0
- package/lib/herdr/reporter.d.ts.map +1 -0
- package/lib/herdr/reporter.js +217 -0
- package/lib/herdr/reporter.js.map +1 -0
- package/lib/herdr/state.d.ts +57 -0
- package/lib/herdr/state.d.ts.map +1 -0
- package/lib/herdr/state.js +62 -0
- package/lib/herdr/state.js.map +1 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +700 -81
- package/lib/index.js.map +1 -1
- package/lib/injection.d.ts +9 -0
- package/lib/injection.d.ts.map +1 -0
- package/lib/injection.js +128 -0
- package/lib/injection.js.map +1 -0
- package/lib/input/actions.d.ts +19 -2
- package/lib/input/actions.d.ts.map +1 -1
- package/lib/input/actions.js +60 -11
- package/lib/input/actions.js.map +1 -1
- package/lib/input/cancel.d.ts +55 -0
- package/lib/input/cancel.d.ts.map +1 -0
- package/lib/input/cancel.js +47 -0
- package/lib/input/cancel.js.map +1 -0
- package/lib/input/completion.d.ts +5 -2
- package/lib/input/completion.d.ts.map +1 -1
- package/lib/input/completion.js +79 -4
- package/lib/input/completion.js.map +1 -1
- package/lib/input/file-search.d.ts +105 -0
- package/lib/input/file-search.d.ts.map +1 -0
- package/lib/input/file-search.js +665 -0
- package/lib/input/file-search.js.map +1 -0
- package/lib/input/fuzzy.d.ts +42 -0
- package/lib/input/fuzzy.d.ts.map +1 -0
- package/lib/input/fuzzy.js +289 -0
- package/lib/input/fuzzy.js.map +1 -0
- package/lib/input/ghost.d.ts +46 -0
- package/lib/input/ghost.d.ts.map +1 -0
- package/lib/input/ghost.js +58 -0
- package/lib/input/ghost.js.map +1 -0
- package/lib/input/keymap.d.ts +3 -1
- package/lib/input/keymap.d.ts.map +1 -1
- package/lib/input/keymap.js +16 -3
- package/lib/input/keymap.js.map +1 -1
- package/lib/input/match.d.ts +12 -4
- package/lib/input/match.d.ts.map +1 -1
- package/lib/input/match.js +50 -51
- package/lib/input/match.js.map +1 -1
- package/lib/input/submission.d.ts +34 -1
- package/lib/input/submission.d.ts.map +1 -1
- package/lib/input/submission.js +33 -5
- package/lib/input/submission.js.map +1 -1
- package/lib/keys-command.d.ts +19 -3
- package/lib/keys-command.d.ts.map +1 -1
- package/lib/keys-command.js +34 -31
- package/lib/keys-command.js.map +1 -1
- package/lib/settings-notice.d.ts +5 -0
- package/lib/settings-notice.d.ts.map +1 -1
- package/lib/settings-notice.js +9 -7
- package/lib/settings-notice.js.map +1 -1
- package/lib/stash/lock.d.ts +72 -0
- package/lib/stash/lock.d.ts.map +1 -0
- package/lib/stash/lock.js +374 -0
- package/lib/stash/lock.js.map +1 -0
- package/lib/stash/paths.d.ts +22 -0
- package/lib/stash/paths.d.ts.map +1 -0
- package/lib/stash/paths.js +84 -0
- package/lib/stash/paths.js.map +1 -0
- package/lib/stash/private-fs.d.ts +61 -0
- package/lib/stash/private-fs.d.ts.map +1 -0
- package/lib/stash/private-fs.js +379 -0
- package/lib/stash/private-fs.js.map +1 -0
- package/lib/stash/schema.d.ts +56 -0
- package/lib/stash/schema.d.ts.map +1 -0
- package/lib/stash/schema.js +108 -0
- package/lib/stash/schema.js.map +1 -0
- package/lib/stash/store.d.ts +92 -0
- package/lib/stash/store.d.ts.map +1 -0
- package/lib/stash/store.js +262 -0
- package/lib/stash/store.js.map +1 -0
- package/lib/stash.d.ts +122 -0
- package/lib/stash.d.ts.map +1 -0
- package/lib/stash.js +353 -0
- package/lib/stash.js.map +1 -0
- package/lib/terminal/external-editor.d.ts +93 -0
- package/lib/terminal/external-editor.d.ts.map +1 -0
- package/lib/terminal/external-editor.js +263 -0
- package/lib/terminal/external-editor.js.map +1 -0
- package/lib/terminal/host-writes.d.ts +39 -0
- package/lib/terminal/host-writes.d.ts.map +1 -0
- package/lib/terminal/host-writes.js +136 -0
- package/lib/terminal/host-writes.js.map +1 -0
- package/lib/terminal/signals.d.ts +25 -0
- package/lib/terminal/signals.d.ts.map +1 -0
- package/lib/terminal/signals.js +39 -0
- package/lib/terminal/signals.js.map +1 -0
- package/lib/terminal/warning-screen.d.ts +19 -1
- package/lib/terminal/warning-screen.d.ts.map +1 -1
- package/lib/terminal/warning-screen.js +37 -1
- package/lib/terminal/warning-screen.js.map +1 -1
- package/lib/terminal-text.d.ts +72 -0
- package/lib/terminal-text.d.ts.map +1 -0
- package/lib/terminal-text.js +603 -0
- package/lib/terminal-text.js.map +1 -0
- package/lib/text.d.ts +45 -6
- package/lib/text.d.ts.map +1 -1
- package/lib/text.js +106 -46
- package/lib/text.js.map +1 -1
- package/lib/theme-command.d.ts +7 -4
- package/lib/theme-command.d.ts.map +1 -1
- package/lib/theme-command.js +32 -12
- package/lib/theme-command.js.map +1 -1
- package/lib/theme-files.d.ts +110 -0
- package/lib/theme-files.d.ts.map +1 -0
- package/lib/theme-files.js +352 -0
- package/lib/theme-files.js.map +1 -0
- package/lib/theme-schema.d.ts +121 -0
- package/lib/theme-schema.d.ts.map +1 -0
- package/lib/theme-schema.js +87 -0
- package/lib/theme-schema.js.map +1 -0
- package/lib/theme-settings.d.ts +72 -10
- package/lib/theme-settings.d.ts.map +1 -1
- package/lib/theme-settings.js +162 -45
- package/lib/theme-settings.js.map +1 -1
- package/lib/theme-tokens.d.ts +39 -7
- package/lib/theme-tokens.d.ts.map +1 -1
- package/lib/theme-tokens.js +104 -13
- package/lib/theme-tokens.js.map +1 -1
- package/lib/theme.d.ts +24 -2
- package/lib/theme.d.ts.map +1 -1
- package/lib/theme.js +49 -6
- package/lib/theme.js.map +1 -1
- package/lib/todo-guard.d.ts +116 -0
- package/lib/todo-guard.d.ts.map +1 -0
- package/lib/todo-guard.js +259 -0
- package/lib/todo-guard.js.map +1 -0
- package/lib/tool-display.d.ts +57 -0
- package/lib/tool-display.d.ts.map +1 -0
- package/lib/tool-display.js +51 -0
- package/lib/tool-display.js.map +1 -0
- package/lib/transcript.d.ts +28 -0
- package/lib/transcript.d.ts.map +1 -1
- package/lib/transcript.js +126 -37
- package/lib/transcript.js.map +1 -1
- package/lib/ui/diff.d.ts +49 -0
- package/lib/ui/diff.d.ts.map +1 -0
- package/lib/ui/diff.js +208 -0
- package/lib/ui/diff.js.map +1 -0
- package/lib/ui/dock.d.ts.map +1 -1
- package/lib/ui/dock.js +9 -6
- package/lib/ui/dock.js.map +1 -1
- package/lib/ui/editor.d.ts +41 -1
- package/lib/ui/editor.d.ts.map +1 -1
- package/lib/ui/editor.js +100 -4
- package/lib/ui/editor.js.map +1 -1
- package/lib/ui/frame.d.ts +16 -5
- package/lib/ui/frame.d.ts.map +1 -1
- package/lib/ui/frame.js +32 -13
- package/lib/ui/frame.js.map +1 -1
- package/lib/ui/history-picker.d.ts +15 -0
- package/lib/ui/history-picker.d.ts.map +1 -0
- package/lib/ui/history-picker.js +26 -0
- package/lib/ui/history-picker.js.map +1 -0
- package/lib/ui/keymap-picker.d.ts +16 -0
- package/lib/ui/keymap-picker.d.ts.map +1 -0
- package/lib/ui/keymap-picker.js +41 -0
- package/lib/ui/keymap-picker.js.map +1 -0
- package/lib/ui/layout.d.ts +33 -0
- package/lib/ui/layout.d.ts.map +1 -0
- package/lib/ui/layout.js +33 -0
- package/lib/ui/layout.js.map +1 -0
- package/lib/ui/markdown.d.ts +25 -3
- package/lib/ui/markdown.d.ts.map +1 -1
- package/lib/ui/markdown.js +12 -8
- package/lib/ui/markdown.js.map +1 -1
- package/lib/ui/picker-card.d.ts +42 -0
- package/lib/ui/picker-card.d.ts.map +1 -0
- package/lib/ui/picker-card.js +148 -0
- package/lib/ui/picker-card.js.map +1 -0
- package/lib/ui/picker.d.ts +50 -2
- package/lib/ui/picker.d.ts.map +1 -1
- package/lib/ui/picker.js +66 -7
- package/lib/ui/picker.js.map +1 -1
- package/lib/ui/prompt.d.ts +12 -2
- package/lib/ui/prompt.d.ts.map +1 -1
- package/lib/ui/prompt.js +25 -2
- package/lib/ui/prompt.js.map +1 -1
- package/lib/ui/rows.d.ts +8 -6
- package/lib/ui/rows.d.ts.map +1 -1
- package/lib/ui/rows.js +10 -18
- package/lib/ui/rows.js.map +1 -1
- package/lib/ui/stash-picker.d.ts +43 -0
- package/lib/ui/stash-picker.d.ts.map +1 -0
- package/lib/ui/stash-picker.js +78 -0
- package/lib/ui/stash-picker.js.map +1 -0
- package/lib/ui/status.d.ts +2 -0
- package/lib/ui/status.d.ts.map +1 -1
- package/lib/ui/status.js +25 -16
- package/lib/ui/status.js.map +1 -1
- package/lib/ui/theme-picker.d.ts +18 -0
- package/lib/ui/theme-picker.d.ts.map +1 -0
- package/lib/ui/theme-picker.js +68 -0
- package/lib/ui/theme-picker.js.map +1 -0
- package/lib/ui/view.d.ts +146 -22
- package/lib/ui/view.d.ts.map +1 -1
- package/lib/ui/view.js +461 -154
- package/lib/ui/view.js.map +1 -1
- package/lib/work.d.ts +3 -1
- package/lib/work.d.ts.map +1 -1
- package/lib/work.js +9 -1
- package/lib/work.js.map +1 -1
- package/package.json +7 -1
- package/themes/deepseek-blue.yaml +183 -0
- package/themes/violet-orbit.yaml +178 -0
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { lstat, readdir, realpath, stat } from 'node:fs/promises';
|
|
3
|
+
import { dirname, isAbsolute, join, relative } from 'node:path';
|
|
4
|
+
import { fuzzyScore } from "./fuzzy.js";
|
|
5
|
+
/** Suggestions a completion menu is allowed to gather at once. */
|
|
6
|
+
export const SUGGESTION_LIMIT = 20;
|
|
7
|
+
/** Where one token ends and the next begins, as a shell reader expects. */
|
|
8
|
+
const TOKEN_DELIMITERS = new Set([' ', '\t', '"', "'", '=']);
|
|
9
|
+
const QUOTE = '"';
|
|
10
|
+
const ESCAPE = '\\';
|
|
11
|
+
/**
|
|
12
|
+
* Read the at-token the cursor sits in, or undefined when it sits in none.
|
|
13
|
+
*
|
|
14
|
+
* The reader may be mid-sentence, so only the last token counts. A closed
|
|
15
|
+
* quote ends a token: once the path is quoted whole there is nothing left to
|
|
16
|
+
* complete, and continuing to offer rows would replace text the reader already
|
|
17
|
+
* spelled out.
|
|
18
|
+
*/
|
|
19
|
+
export function atToken(text) {
|
|
20
|
+
// A quote still open swallows the spaces inside it, so it is read before any
|
|
21
|
+
// delimiter scan: otherwise the space inside a quoted path would look like
|
|
22
|
+
// the end of the token.
|
|
23
|
+
const quoteStart = openQuoteStart(text);
|
|
24
|
+
if (quoteStart !== null && quoteStart > 0 && text[quoteStart - 1] === '@' && isTokenStart(text, quoteStart - 1)) {
|
|
25
|
+
const raw = unescape(text.slice(quoteStart + 1));
|
|
26
|
+
return { prefix: text.slice(quoteStart - 1), query: withoutDotSlash(raw), quoted: true };
|
|
27
|
+
}
|
|
28
|
+
let start = 0;
|
|
29
|
+
for (let index = text.length - 1; index >= 0; index -= 1) {
|
|
30
|
+
if (TOKEN_DELIMITERS.has(text[index] ?? '')) {
|
|
31
|
+
start = index + 1;
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const token = text.slice(start);
|
|
36
|
+
if (!token.startsWith('@'))
|
|
37
|
+
return undefined;
|
|
38
|
+
return { prefix: token, query: withoutDotSlash(token.slice(1)), quoted: false };
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The text a pick inserts for a path.
|
|
42
|
+
*
|
|
43
|
+
* A quoted token is the only shape that can carry a space, a quote, or the
|
|
44
|
+
* other characters that end a token, so a path holding one is quoted even when
|
|
45
|
+
* the reader never typed a quote themselves; the reader of a token undoes it.
|
|
46
|
+
* A directory that had to be quoted stays open, because a quote closed behind
|
|
47
|
+
* the cursor would leave nowhere to keep typing the path below it.
|
|
48
|
+
*/
|
|
49
|
+
export function atValue(path, quoted, open) {
|
|
50
|
+
if (!quoted && !needsQuoting(path))
|
|
51
|
+
return '@' + path;
|
|
52
|
+
const escaped = path.replaceAll(ESCAPE, ESCAPE + ESCAPE).replaceAll(QUOTE, ESCAPE + QUOTE);
|
|
53
|
+
return '@' + QUOTE + escaped + (open ? '' : QUOTE);
|
|
54
|
+
}
|
|
55
|
+
function needsQuoting(path) {
|
|
56
|
+
for (const character of path) {
|
|
57
|
+
if (TOKEN_DELIMITERS.has(character))
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
/** Where the run's one still-open quote begins, or null when every quote closed. */
|
|
63
|
+
function openQuoteStart(text) {
|
|
64
|
+
let start = null;
|
|
65
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
66
|
+
const character = text[index];
|
|
67
|
+
// An escaped quote is a path character, not the end of the run, so the
|
|
68
|
+
// character after an escape is never read as a delimiter here.
|
|
69
|
+
if (character === ESCAPE) {
|
|
70
|
+
index += 1;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (character !== QUOTE)
|
|
74
|
+
continue;
|
|
75
|
+
start = start === null ? index : null;
|
|
76
|
+
}
|
|
77
|
+
return start;
|
|
78
|
+
}
|
|
79
|
+
/** Read a quoted run back, so the fragment is the path and not its escapes. */
|
|
80
|
+
function unescape(raw) {
|
|
81
|
+
let out = '';
|
|
82
|
+
for (let index = 0; index < raw.length; index += 1) {
|
|
83
|
+
const character = raw[index] ?? '';
|
|
84
|
+
if (character === ESCAPE && index + 1 < raw.length) {
|
|
85
|
+
const next = raw[index + 1] ?? '';
|
|
86
|
+
if (next === ESCAPE || next === QUOTE) {
|
|
87
|
+
out += next;
|
|
88
|
+
index += 1;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
out += character;
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
/** Whether an at-sign at this index opens a token rather than continuing a word. */
|
|
97
|
+
function isTokenStart(text, index) {
|
|
98
|
+
return index === 0 || TOKEN_DELIMITERS.has(text[index - 1] ?? '');
|
|
99
|
+
}
|
|
100
|
+
/** Drop the dot-slash habit a shell reader brings, which no path in the index carries. */
|
|
101
|
+
function withoutDotSlash(raw) {
|
|
102
|
+
return raw.startsWith('./') ? raw.slice(2) : raw;
|
|
103
|
+
}
|
|
104
|
+
/** Bytes a terminal executes rather than draws. */
|
|
105
|
+
const CONTROL_CHARACTERS = /[\u0000-\u001F\u007F-\u009F]/;
|
|
106
|
+
/**
|
|
107
|
+
* Whether a listed path may become a suggestion.
|
|
108
|
+
*
|
|
109
|
+
* A suggestion is inserted into the prompt the agent reads, so a path that
|
|
110
|
+
* climbs out of the workspace, or that carries bytes a terminal would act on,
|
|
111
|
+
* is never offered: the menu is the one place the reader cannot see the bytes
|
|
112
|
+
* for what they are.
|
|
113
|
+
*/
|
|
114
|
+
export function offerablePath(path) {
|
|
115
|
+
return path !== '' && !path.startsWith('/') && !path.split('/').includes('..') && !CONTROL_CHARACTERS.test(path);
|
|
116
|
+
}
|
|
117
|
+
/** What a token cannot carry without being split, as the editor's own trigger reads it. */
|
|
118
|
+
const WHITESPACE_CHARACTER = /\s/;
|
|
119
|
+
/**
|
|
120
|
+
* Whether a row may be offered at all.
|
|
121
|
+
*
|
|
122
|
+
* A directory is a place to keep typing, and the editor only asks for
|
|
123
|
+
* suggestions while the typed token holds no whitespace: offering a directory
|
|
124
|
+
* whose path has one would leave the reader stuck after the pick. Its files
|
|
125
|
+
* are still offered on their own, each with a value that needs no narrowing.
|
|
126
|
+
*/
|
|
127
|
+
export function offerableCandidate(candidate) {
|
|
128
|
+
if (!offerablePath(candidate.path))
|
|
129
|
+
return false;
|
|
130
|
+
return !candidate.isDirectory || !WHITESPACE_CHARACTER.test(candidate.path);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* The rows that best answer the fragment, best first.
|
|
134
|
+
*
|
|
135
|
+
* Only the paths are searched, the way fzf searches a path list, so a fragment
|
|
136
|
+
* may match a segment anywhere in the tree. Ties fall to a directory over a
|
|
137
|
+
* file — a directory is a place to keep typing, a file ends the search — then
|
|
138
|
+
* to the shallower, shorter path, which is the one the reader is likelier to
|
|
139
|
+
* mean.
|
|
140
|
+
*/
|
|
141
|
+
export function rankFiles(query, candidates, limit) {
|
|
142
|
+
const needle = query.trim();
|
|
143
|
+
if (needle === '')
|
|
144
|
+
return topLevel(candidates, limit);
|
|
145
|
+
const scored = [];
|
|
146
|
+
for (const candidate of candidates) {
|
|
147
|
+
const score = fuzzyScore(needle, candidate.path);
|
|
148
|
+
if (score === undefined)
|
|
149
|
+
continue;
|
|
150
|
+
scored.push({ candidate, score });
|
|
151
|
+
}
|
|
152
|
+
scored.sort((left, right) => compareScored(left, right));
|
|
153
|
+
return scored.slice(0, limit).map(entry => entry.candidate);
|
|
154
|
+
}
|
|
155
|
+
function compareScored(left, right) {
|
|
156
|
+
if (left.score !== right.score)
|
|
157
|
+
return right.score - left.score;
|
|
158
|
+
if (left.candidate.isDirectory !== right.candidate.isDirectory)
|
|
159
|
+
return left.candidate.isDirectory ? -1 : 1;
|
|
160
|
+
const depth = depthOf(left.candidate.path) - depthOf(right.candidate.path);
|
|
161
|
+
if (depth !== 0)
|
|
162
|
+
return depth;
|
|
163
|
+
const length = left.candidate.path.length - right.candidate.path.length;
|
|
164
|
+
if (length !== 0)
|
|
165
|
+
return length;
|
|
166
|
+
return left.candidate.path.localeCompare(right.candidate.path);
|
|
167
|
+
}
|
|
168
|
+
function depthOf(path) {
|
|
169
|
+
return path.split('/').length;
|
|
170
|
+
}
|
|
171
|
+
/** The entries a bare at-sign offers: what sits directly in the workspace. */
|
|
172
|
+
function topLevel(candidates, limit) {
|
|
173
|
+
return candidates
|
|
174
|
+
.filter(candidate => !candidate.path.includes('/'))
|
|
175
|
+
.sort((left, right) => {
|
|
176
|
+
if (left.isDirectory !== right.isDirectory)
|
|
177
|
+
return left.isDirectory ? -1 : 1;
|
|
178
|
+
return left.path.localeCompare(right.path);
|
|
179
|
+
})
|
|
180
|
+
.slice(0, limit);
|
|
181
|
+
}
|
|
182
|
+
/** How long a gathered listing is trusted before the tree is read again. */
|
|
183
|
+
const INDEX_TTL_MS = 1_000;
|
|
184
|
+
/** How long git is given before the menu stops waiting for its answer. */
|
|
185
|
+
const GIT_TIMEOUT_MS = 3_000;
|
|
186
|
+
/** How long one scan may run before the index abandons it, awaited or not. */
|
|
187
|
+
const SCAN_TIMEOUT_MS = 5_000;
|
|
188
|
+
/** How long proving one row may hold the menu before that row is left out. */
|
|
189
|
+
const ROW_PROOF_TIMEOUT_MS = 500;
|
|
190
|
+
/**
|
|
191
|
+
* The git listing that answers for a workspace.
|
|
192
|
+
*
|
|
193
|
+
* Tracked files and untracked ones that git would add, with ignored paths left
|
|
194
|
+
* out: that is the set the agent itself works on, so a suggestion can never
|
|
195
|
+
* name a build artifact or a secret the repository deliberately ignores. The
|
|
196
|
+
* stage information is asked for too, because it is the only place the listing
|
|
197
|
+
* says which entries are symbolic links.
|
|
198
|
+
*
|
|
199
|
+
* The two sets are asked for apart: an indexed record carries metadata before a
|
|
200
|
+
* tab and an untracked one is a bare path, so a tab inside an untracked name
|
|
201
|
+
* would otherwise look like metadata and hand back a path nobody wrote.
|
|
202
|
+
*/
|
|
203
|
+
const GIT_TRACKED_ARGUMENTS = ['ls-files', '-s', '-z', '--cached'];
|
|
204
|
+
const GIT_UNTRACKED_ARGUMENTS = ['ls-files', '-z', '--others', '--exclude-standard'];
|
|
205
|
+
/** The index modes that name something other than an ordinary file. */
|
|
206
|
+
const GIT_SYMLINK_MODE = '120000';
|
|
207
|
+
const GIT_SUBMODULE_MODE = '160000';
|
|
208
|
+
const GIT_MODE_LENGTH = 6;
|
|
209
|
+
/** Git's way of saying the directory was never under version control. */
|
|
210
|
+
const GIT_EXIT_FATAL = 128;
|
|
211
|
+
const GIT_NOT_A_REPOSITORY = /not a git repository/i;
|
|
212
|
+
const GIT_ERROR_LIMIT = 4_096;
|
|
213
|
+
/** The marker git leaves at the root of a worktree, which a walk must respect. */
|
|
214
|
+
const GIT_MARKER = '.git';
|
|
215
|
+
/** A path that cannot climb further than this many parents is not mounted anywhere real. */
|
|
216
|
+
const MAX_PARENT_DIRECTORIES = 40;
|
|
217
|
+
/**
|
|
218
|
+
* Directories a plain walk never descends into.
|
|
219
|
+
*
|
|
220
|
+
* These hold build output and caches rather than anything a reader attaches,
|
|
221
|
+
* and one of them can dwarf the real tree.
|
|
222
|
+
*/
|
|
223
|
+
const SKIP_DIRECTORIES = new Set([
|
|
224
|
+
'.git',
|
|
225
|
+
'node_modules',
|
|
226
|
+
'__pycache__',
|
|
227
|
+
'.venv',
|
|
228
|
+
'venv',
|
|
229
|
+
'.cache',
|
|
230
|
+
'.next',
|
|
231
|
+
'.turbo',
|
|
232
|
+
'dist',
|
|
233
|
+
'build',
|
|
234
|
+
'coverage',
|
|
235
|
+
'target',
|
|
236
|
+
]);
|
|
237
|
+
/** Caps that keep a walk over a home directory from stalling the editor. */
|
|
238
|
+
const MAX_WALK_ENTRIES = 20_000;
|
|
239
|
+
const MAX_WALK_DEPTH = 12;
|
|
240
|
+
/**
|
|
241
|
+
* The rows of a workspace, from git when it owns the directory and from a walk
|
|
242
|
+
* when it does not.
|
|
243
|
+
*/
|
|
244
|
+
export async function listWorkspaceFiles(cwd, signal) {
|
|
245
|
+
if (signal.aborted)
|
|
246
|
+
return [];
|
|
247
|
+
const listing = await gitFiles(cwd, signal);
|
|
248
|
+
if (signal.aborted)
|
|
249
|
+
return [];
|
|
250
|
+
if (listing.kind === 'listed')
|
|
251
|
+
return await candidatesFrom(cwd, listing.entries, signal);
|
|
252
|
+
// A tree a marker claims is a tree whose ignore-files must hold, whether git
|
|
253
|
+
// owns it, lost it, or could not read it: a walk here would offer exactly the
|
|
254
|
+
// paths those files keep out. Only a directory no marker reaches above may be
|
|
255
|
+
// walked, and there nothing is being ignored in the first place.
|
|
256
|
+
if (await hasGitMarker(cwd))
|
|
257
|
+
return [];
|
|
258
|
+
return await walkFiles(cwd, signal);
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Whether git owns this directory, or one above it, without asking git itself.
|
|
262
|
+
*
|
|
263
|
+
* A marker is the name itself, even when it is a link whose target is gone:
|
|
264
|
+
* git reads that link, so this has to see it too. A stat that fails for any
|
|
265
|
+
* reason but absence, and a climb that runs out of levels before a filesystem
|
|
266
|
+
* root, both leave the question open — and an open question is answered by
|
|
267
|
+
* refusing to walk, because a walk offers the very paths an ignore-file is
|
|
268
|
+
* there to keep out.
|
|
269
|
+
*/
|
|
270
|
+
async function hasGitMarker(cwd) {
|
|
271
|
+
let directory = cwd;
|
|
272
|
+
for (let level = 0; level < MAX_PARENT_DIRECTORIES; level += 1) {
|
|
273
|
+
try {
|
|
274
|
+
await lstat(join(directory, GIT_MARKER));
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
catch (error) {
|
|
278
|
+
if (!isAbsent(error))
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
281
|
+
const parent = dirname(directory);
|
|
282
|
+
if (parent === directory)
|
|
283
|
+
return false;
|
|
284
|
+
directory = parent;
|
|
285
|
+
}
|
|
286
|
+
return true;
|
|
287
|
+
}
|
|
288
|
+
/** Whether a stat failed because nothing carries that name. */
|
|
289
|
+
function isAbsent(error) {
|
|
290
|
+
const code = error?.code;
|
|
291
|
+
return code === 'ENOENT' || code === 'ENOTDIR';
|
|
292
|
+
}
|
|
293
|
+
/** Git's answer for a workspace, from the two listings a suggestion set needs. */
|
|
294
|
+
async function gitFiles(cwd, signal) {
|
|
295
|
+
if (signal.aborted)
|
|
296
|
+
return { kind: 'failed' };
|
|
297
|
+
const [tracked, untracked] = await Promise.all([
|
|
298
|
+
runGit(cwd, GIT_TRACKED_ARGUMENTS, signal),
|
|
299
|
+
runGit(cwd, GIT_UNTRACKED_ARGUMENTS, signal),
|
|
300
|
+
]);
|
|
301
|
+
if (tracked.kind === 'listed' && untracked.kind === 'listed') {
|
|
302
|
+
return {
|
|
303
|
+
kind: 'listed',
|
|
304
|
+
entries: [...parseTrackedListing(tracked.records), ...parseUntrackedListing(untracked.records)],
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
if (tracked.kind === 'not-a-repository' || untracked.kind === 'not-a-repository')
|
|
308
|
+
return { kind: 'not-a-repository' };
|
|
309
|
+
return { kind: 'failed' };
|
|
310
|
+
}
|
|
311
|
+
/** Ask git one question, and read its exit as a meaning rather than a number. */
|
|
312
|
+
async function runGit(cwd, args, signal) {
|
|
313
|
+
if (signal.aborted)
|
|
314
|
+
return { kind: 'failed' };
|
|
315
|
+
return await new Promise(resolve => {
|
|
316
|
+
let settled = false;
|
|
317
|
+
let timer;
|
|
318
|
+
const finish = (value) => {
|
|
319
|
+
if (settled)
|
|
320
|
+
return;
|
|
321
|
+
settled = true;
|
|
322
|
+
if (timer !== undefined)
|
|
323
|
+
clearTimeout(timer);
|
|
324
|
+
signal.removeEventListener('abort', onAbort);
|
|
325
|
+
resolve(value);
|
|
326
|
+
};
|
|
327
|
+
const child = spawn('git', [...args], { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
328
|
+
const onAbort = () => {
|
|
329
|
+
child.kill('SIGKILL');
|
|
330
|
+
finish({ kind: 'failed' });
|
|
331
|
+
};
|
|
332
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
333
|
+
timer = setTimeout(() => {
|
|
334
|
+
child.kill('SIGKILL');
|
|
335
|
+
finish({ kind: 'failed' });
|
|
336
|
+
}, GIT_TIMEOUT_MS);
|
|
337
|
+
let stdout = '';
|
|
338
|
+
let stderr = '';
|
|
339
|
+
child.stdout.setEncoding('utf8');
|
|
340
|
+
child.stdout.on('data', (chunk) => {
|
|
341
|
+
stdout += chunk;
|
|
342
|
+
});
|
|
343
|
+
child.stderr.setEncoding('utf8');
|
|
344
|
+
child.stderr.on('data', (chunk) => {
|
|
345
|
+
// Only the refusal is read; a repository cannot be diagnosed from here,
|
|
346
|
+
// and an unbounded buffer would grow with git's chatter.
|
|
347
|
+
if (stderr.length < GIT_ERROR_LIMIT)
|
|
348
|
+
stderr += chunk;
|
|
349
|
+
});
|
|
350
|
+
child.on('error', () => finish({ kind: 'failed' }));
|
|
351
|
+
child.on('close', code => {
|
|
352
|
+
if (code === 0) {
|
|
353
|
+
finish({ kind: 'listed', records: stdout.split('\0') });
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
if (code === GIT_EXIT_FATAL && GIT_NOT_A_REPOSITORY.test(stderr)) {
|
|
357
|
+
finish({ kind: 'not-a-repository' });
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
finish({ kind: 'failed' });
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Read the indexed listing.
|
|
366
|
+
*
|
|
367
|
+
* An entry arrives as its mode, its object, and its stage, then a tab and the
|
|
368
|
+
* path. Only the first tab separates, so a name that carries one is read whole.
|
|
369
|
+
* A path can appear once per merge stage, and the map keeps the last, so a
|
|
370
|
+
* conflicted file is not offered three times.
|
|
371
|
+
*/
|
|
372
|
+
function parseTrackedListing(records) {
|
|
373
|
+
const byPath = new Map();
|
|
374
|
+
for (const record of records) {
|
|
375
|
+
if (record === '')
|
|
376
|
+
continue;
|
|
377
|
+
const tab = record.indexOf('\t');
|
|
378
|
+
// Nothing but metadata precedes the tab, so a record without one is not a
|
|
379
|
+
// line git wrote; reading it as a path would invent a name.
|
|
380
|
+
if (tab < 0)
|
|
381
|
+
continue;
|
|
382
|
+
const mode = record.slice(0, GIT_MODE_LENGTH);
|
|
383
|
+
const path = record.slice(tab + 1);
|
|
384
|
+
if (path === '')
|
|
385
|
+
continue;
|
|
386
|
+
byPath.set(path, {
|
|
387
|
+
path,
|
|
388
|
+
symlink: mode === GIT_SYMLINK_MODE,
|
|
389
|
+
submodule: mode === GIT_SUBMODULE_MODE,
|
|
390
|
+
untracked: false,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
return [...byPath.values()];
|
|
394
|
+
}
|
|
395
|
+
/** Read the untracked listing, which carries no metadata to mistake for a path. */
|
|
396
|
+
function parseUntrackedListing(records) {
|
|
397
|
+
const entries = [];
|
|
398
|
+
for (const record of records) {
|
|
399
|
+
if (record === '')
|
|
400
|
+
continue;
|
|
401
|
+
entries.push({ path: record, symlink: false, submodule: false, untracked: true });
|
|
402
|
+
}
|
|
403
|
+
return entries;
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Turn git's entries into rows, deriving the directories they imply.
|
|
407
|
+
*
|
|
408
|
+
* Git lists files only, but a reader typing a path prefix wants to open a
|
|
409
|
+
* directory as readily as a file. A link is offered as whatever it names, and
|
|
410
|
+
* only while that target stays in the workspace: the reader would otherwise
|
|
411
|
+
* attach a path the menu never showed them.
|
|
412
|
+
*/
|
|
413
|
+
async function candidatesFrom(root, entries, signal) {
|
|
414
|
+
const canonicalRoot = await canonicalOrUndefined(root);
|
|
415
|
+
if (canonicalRoot === undefined)
|
|
416
|
+
return [];
|
|
417
|
+
const files = new Map();
|
|
418
|
+
const directories = new Set();
|
|
419
|
+
for (const entry of entries) {
|
|
420
|
+
// Every entry costs a stat at least, so a scan whose deadline passed stops
|
|
421
|
+
// here rather than walking the rest of a tree nobody waits for.
|
|
422
|
+
if (signal.aborted)
|
|
423
|
+
return [];
|
|
424
|
+
if (!offerablePath(entry.path))
|
|
425
|
+
continue;
|
|
426
|
+
const linked = entry.symlink || (entry.untracked && await isSymlink(root, entry.path));
|
|
427
|
+
let isDirectory = entry.submodule;
|
|
428
|
+
if (linked) {
|
|
429
|
+
const target = await linkedTarget(canonicalRoot, join(root, entry.path));
|
|
430
|
+
if (target === undefined)
|
|
431
|
+
continue;
|
|
432
|
+
isDirectory = target;
|
|
433
|
+
}
|
|
434
|
+
files.set(entry.path, { path: entry.path, isDirectory });
|
|
435
|
+
addParentDirectories(directories, entry.path);
|
|
436
|
+
}
|
|
437
|
+
const rows = new Map();
|
|
438
|
+
for (const directory of directories)
|
|
439
|
+
rows.set(directory, { path: directory, isDirectory: true });
|
|
440
|
+
for (const candidate of files.values()) {
|
|
441
|
+
if (!rows.has(candidate.path))
|
|
442
|
+
rows.set(candidate.path, candidate);
|
|
443
|
+
}
|
|
444
|
+
return [...rows.values()].sort((left, right) => left.path.localeCompare(right.path));
|
|
445
|
+
}
|
|
446
|
+
/** Every path implies the directories above it, which a reader may open. */
|
|
447
|
+
function addParentDirectories(directories, path) {
|
|
448
|
+
for (let at = path.indexOf('/'); at >= 0; at = path.indexOf('/', at + 1)) {
|
|
449
|
+
directories.add(path.slice(0, at));
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
/** Whether the entry is a link when the index did not say, which is the untracked case. */
|
|
453
|
+
async function isSymlink(root, path) {
|
|
454
|
+
try {
|
|
455
|
+
return (await lstat(join(root, path))).isSymbolicLink();
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
// A path that vanished between the listing and the stat is offered as the
|
|
459
|
+
// plain file git saw; there is no link left to follow.
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Whether a link names something the workspace holds, and whether that is a
|
|
465
|
+
* directory.
|
|
466
|
+
*/
|
|
467
|
+
async function linkedTarget(canonicalRoot, path) {
|
|
468
|
+
try {
|
|
469
|
+
const target = await realpath(path);
|
|
470
|
+
const inside = relative(canonicalRoot, target);
|
|
471
|
+
if (inside !== '' && (inside.startsWith('..') || isAbsolute(inside)))
|
|
472
|
+
return undefined;
|
|
473
|
+
return (await stat(target)).isDirectory();
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
return undefined;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
async function canonicalOrUndefined(path) {
|
|
480
|
+
try {
|
|
481
|
+
return await realpath(path);
|
|
482
|
+
}
|
|
483
|
+
catch {
|
|
484
|
+
return undefined;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
/** Walk a tree git does not own, breadth first, stopping at the caps above. */
|
|
488
|
+
export async function walkFiles(cwd, signal) {
|
|
489
|
+
if (signal.aborted)
|
|
490
|
+
return [];
|
|
491
|
+
const canonicalRoot = await canonicalOrUndefined(cwd);
|
|
492
|
+
if (canonicalRoot === undefined)
|
|
493
|
+
return [];
|
|
494
|
+
const found = [];
|
|
495
|
+
const queue = [''];
|
|
496
|
+
while (queue.length > 0) {
|
|
497
|
+
if (signal.aborted)
|
|
498
|
+
return [];
|
|
499
|
+
const directory = queue.shift() ?? '';
|
|
500
|
+
let entries;
|
|
501
|
+
try {
|
|
502
|
+
entries = await readdir(directory === '' ? cwd : join(cwd, directory), { withFileTypes: true });
|
|
503
|
+
}
|
|
504
|
+
catch {
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
for (const entry of entries) {
|
|
508
|
+
if (found.length >= MAX_WALK_ENTRIES)
|
|
509
|
+
return found;
|
|
510
|
+
const path = directory === '' ? entry.name : directory + '/' + entry.name;
|
|
511
|
+
if (!offerablePath(path))
|
|
512
|
+
continue;
|
|
513
|
+
if (entry.isSymbolicLink()) {
|
|
514
|
+
const target = await linkedTarget(canonicalRoot, join(cwd, path));
|
|
515
|
+
if (target === undefined)
|
|
516
|
+
continue;
|
|
517
|
+
// A link is offered as the thing it names and never followed: following
|
|
518
|
+
// one would let a single entry stand in for a tree outside the workspace.
|
|
519
|
+
found.push({ path, isDirectory: target });
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
if (!entry.isDirectory()) {
|
|
523
|
+
found.push({ path, isDirectory: false });
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
if (SKIP_DIRECTORIES.has(entry.name))
|
|
527
|
+
continue;
|
|
528
|
+
found.push({ path, isDirectory: true });
|
|
529
|
+
if (depthOf(path) < MAX_WALK_DEPTH)
|
|
530
|
+
queue.push(path);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return found;
|
|
534
|
+
}
|
|
535
|
+
/** A listing cache with a window, so a keystroke does not become a scan. */
|
|
536
|
+
export function createFileIndex(cwd, options = {}) {
|
|
537
|
+
const list = options.list ?? listWorkspaceFiles;
|
|
538
|
+
const now = options.now ?? Date.now;
|
|
539
|
+
const ttlMs = options.ttlMs ?? INDEX_TTL_MS;
|
|
540
|
+
const scanTimeoutMs = options.scanTimeoutMs ?? SCAN_TIMEOUT_MS;
|
|
541
|
+
const resolvePath = options.resolve ?? realpath;
|
|
542
|
+
const proofTimeoutMs = options.proofTimeoutMs ?? ROW_PROOF_TIMEOUT_MS;
|
|
543
|
+
let cached;
|
|
544
|
+
let cachedAt = 0;
|
|
545
|
+
let pending;
|
|
546
|
+
let root;
|
|
547
|
+
const startScan = () => {
|
|
548
|
+
// The scan is shared, so it cannot ride any one caller's signal: the first
|
|
549
|
+
// reader to look away would otherwise cancel the listing every other reader
|
|
550
|
+
// is still waiting on. It runs under its own bound and leaves its answer for
|
|
551
|
+
// the readers that follow.
|
|
552
|
+
const scan = new AbortController();
|
|
553
|
+
const run = list(cwd, scan.signal)
|
|
554
|
+
.then(found => {
|
|
555
|
+
// An answer that arrives after the deadline is the one the waiting
|
|
556
|
+
// readers were told to stop waiting for; keeping it would serve a
|
|
557
|
+
// listing the scan had already given up on.
|
|
558
|
+
if (!scan.signal.aborted) {
|
|
559
|
+
cached = found;
|
|
560
|
+
cachedAt = now();
|
|
561
|
+
}
|
|
562
|
+
return found;
|
|
563
|
+
})
|
|
564
|
+
.catch(() => []);
|
|
565
|
+
const entry = { run, signal: scan.signal };
|
|
566
|
+
pending = entry;
|
|
567
|
+
const timer = setTimeout(() => {
|
|
568
|
+
scan.abort();
|
|
569
|
+
// A caller arriving after the deadline starts fresh work instead of
|
|
570
|
+
// joining a scan that outlived its bound.
|
|
571
|
+
if (pending === entry)
|
|
572
|
+
pending = undefined;
|
|
573
|
+
}, scanTimeoutMs);
|
|
574
|
+
void run.finally(() => {
|
|
575
|
+
clearTimeout(timer);
|
|
576
|
+
// A settled scan is no longer pending even when its listing was refused
|
|
577
|
+
// for arriving late; a fresh scan is the next caller's own business.
|
|
578
|
+
if (pending === entry)
|
|
579
|
+
pending = undefined;
|
|
580
|
+
});
|
|
581
|
+
return entry;
|
|
582
|
+
};
|
|
583
|
+
return {
|
|
584
|
+
async candidates(signal) {
|
|
585
|
+
// A caller that has already looked away is answered from what the index
|
|
586
|
+
// holds, without starting work it would not wait for.
|
|
587
|
+
if (signal.aborted)
|
|
588
|
+
return cached ?? [];
|
|
589
|
+
if (cached !== undefined && now() - cachedAt < ttlMs)
|
|
590
|
+
return cached;
|
|
591
|
+
const entry = pending ?? startScan();
|
|
592
|
+
// The deadline releases a waiter even when the work beneath it cannot be
|
|
593
|
+
// interrupted, which is the only way a blocked filesystem call stops
|
|
594
|
+
// holding the menu.
|
|
595
|
+
const found = await untilAborted(entry.run, signal, entry.signal);
|
|
596
|
+
return found ?? cached ?? [];
|
|
597
|
+
},
|
|
598
|
+
async reachable(path, signal) {
|
|
599
|
+
if (signal.aborted)
|
|
600
|
+
return false;
|
|
601
|
+
root ??= canonicalOrUndefined(cwd);
|
|
602
|
+
// Both the root and the row are bound the same way: a filesystem that
|
|
603
|
+
// stopped answering must not hold a menu open, and a proof that did not
|
|
604
|
+
// arrive in time is a proof that never happened.
|
|
605
|
+
const canonical = await withinBound(root, signal, proofTimeoutMs);
|
|
606
|
+
// The caller may have looked away while the root was being proven, and row
|
|
607
|
+
// work started behind its back would have nothing left to answer for it.
|
|
608
|
+
if (canonical === undefined || signal.aborted)
|
|
609
|
+
return false;
|
|
610
|
+
const target = await withinBound(resolvePath(join(cwd, path)), signal, proofTimeoutMs);
|
|
611
|
+
if (target === undefined)
|
|
612
|
+
return false;
|
|
613
|
+
const inside = relative(canonical, target);
|
|
614
|
+
return inside !== '' && !inside.startsWith('..') && !isAbsolute(inside);
|
|
615
|
+
},
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
/** Wait for one filesystem answer, but only while the caller is still waiting. */
|
|
619
|
+
function withinBound(work, signal, timeoutMs) {
|
|
620
|
+
if (signal.aborted) {
|
|
621
|
+
// Work that was already started still has to be answered for, or a refusal
|
|
622
|
+
// it reports would reach the process instead of this caller.
|
|
623
|
+
void work.catch(() => undefined);
|
|
624
|
+
return Promise.resolve(undefined);
|
|
625
|
+
}
|
|
626
|
+
return new Promise(resolve => {
|
|
627
|
+
let timer;
|
|
628
|
+
const finish = (value) => {
|
|
629
|
+
if (timer !== undefined)
|
|
630
|
+
clearTimeout(timer);
|
|
631
|
+
signal.removeEventListener('abort', stop);
|
|
632
|
+
resolve(value);
|
|
633
|
+
};
|
|
634
|
+
const stop = () => finish(undefined);
|
|
635
|
+
timer = setTimeout(stop, timeoutMs);
|
|
636
|
+
signal.addEventListener('abort', stop, { once: true });
|
|
637
|
+
work.then(value => finish(value), () => finish(undefined));
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
/** Wait for shared work, but leave as soon as any reason to stop waiting fires. */
|
|
641
|
+
function untilAborted(work, ...signals) {
|
|
642
|
+
if (signals.some(signal => signal.aborted)) {
|
|
643
|
+
void work.catch(() => undefined);
|
|
644
|
+
return Promise.resolve(undefined);
|
|
645
|
+
}
|
|
646
|
+
return new Promise((resolve, reject) => {
|
|
647
|
+
const stopWaiting = () => {
|
|
648
|
+
for (const signal of signals)
|
|
649
|
+
signal.removeEventListener('abort', stopWaiting);
|
|
650
|
+
resolve(undefined);
|
|
651
|
+
};
|
|
652
|
+
for (const signal of signals)
|
|
653
|
+
signal.addEventListener('abort', stopWaiting, { once: true });
|
|
654
|
+
work.then(value => {
|
|
655
|
+
for (const signal of signals)
|
|
656
|
+
signal.removeEventListener('abort', stopWaiting);
|
|
657
|
+
resolve(value);
|
|
658
|
+
}, error => {
|
|
659
|
+
for (const signal of signals)
|
|
660
|
+
signal.removeEventListener('abort', stopWaiting);
|
|
661
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
662
|
+
});
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
//# sourceMappingURL=file-search.js.map
|