allaboard 0.0.0-stage → 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 +21 -0
- package/README.md +43 -2
- package/dist/agent/claude/backend.js +124 -0
- package/dist/agent/claude/capabilities.js +30 -0
- package/dist/agent/claude/chrome.js +42 -0
- package/dist/agent/claude/hooks.js +37 -0
- package/dist/agent/claude/info.js +61 -0
- package/dist/agent/claude/listing.js +230 -0
- package/dist/agent/claude/module.js +82 -0
- package/dist/agent/claude/prompt.js +24 -0
- package/dist/agent/claude/record.js +9 -0
- package/dist/agent/claude/requests.js +122 -0
- package/dist/agent/claude/session.js +441 -0
- package/dist/agent/claude/translate.js +581 -0
- package/dist/agent/claude/trust.js +576 -0
- package/dist/agent/claude/usage-probe.js +103 -0
- package/dist/agent/claude/usage.js +110 -0
- package/dist/agent/claude/wire.js +22 -0
- package/dist/agent/seam.js +21 -0
- package/dist/chat/seam.js +10 -0
- package/dist/chat/slack/app/answers.js +209 -0
- package/dist/chat/slack/app/app.js +262 -0
- package/dist/chat/slack/app/clip.js +191 -0
- package/dist/chat/slack/app/guards.js +162 -0
- package/dist/chat/slack/app/home-controls.js +91 -0
- package/dist/chat/slack/app/messages.js +181 -0
- package/dist/chat/slack/app/open.js +392 -0
- package/dist/chat/slack/app/order.js +37 -0
- package/dist/chat/slack/app/requests.js +482 -0
- package/dist/chat/slack/app/resume-bind.js +71 -0
- package/dist/chat/slack/app/wire.js +83 -0
- package/dist/chat/slack/attachments.js +348 -0
- package/dist/chat/slack/bind.js +67 -0
- package/dist/chat/slack/channel.js +67 -0
- package/dist/chat/slack/channels.js +43 -0
- package/dist/chat/slack/delete.js +278 -0
- package/dist/chat/slack/footer.js +43 -0
- package/dist/chat/slack/hold.js +37 -0
- package/dist/chat/slack/home.js +1069 -0
- package/dist/chat/slack/module.js +174 -0
- package/dist/chat/slack/notice.js +64 -0
- package/dist/chat/slack/openfile/modal.js +362 -0
- package/dist/chat/slack/openfile/read.js +29 -0
- package/dist/chat/slack/quiet-logger.js +52 -0
- package/dist/chat/slack/repair.js +123 -0
- package/dist/chat/slack/reply/blocks.js +372 -0
- package/dist/chat/slack/reply/chars.js +59 -0
- package/dist/chat/slack/reply/clients.js +171 -0
- package/dist/chat/slack/reply/errors.js +125 -0
- package/dist/chat/slack/reply/escape.js +25 -0
- package/dist/chat/slack/reply/limiter.js +86 -0
- package/dist/chat/slack/reply/markdown.js +210 -0
- package/dist/chat/slack/reply/places.js +262 -0
- package/dist/chat/slack/reply/sinks.js +2403 -0
- package/dist/chat/slack/reply/status.js +433 -0
- package/dist/chat/slack/reply/tasks.js +11 -0
- package/dist/chat/slack/requests.js +552 -0
- package/dist/chat/slack/resume.js +127 -0
- package/dist/chat/slack/setup.js +114 -0
- package/dist/chat/slack/texts.js +185 -0
- package/dist/chat/slack/thread.js +537 -0
- package/dist/chat/slack/update.js +71 -0
- package/dist/chat/slack/voice.js +84 -0
- package/dist/cli.js +326 -0
- package/dist/clock.js +21 -0
- package/dist/core/cleanup.js +145 -0
- package/dist/core/commands.js +172 -0
- package/dist/core/config.js +174 -0
- package/dist/core/files/changed.js +125 -0
- package/dist/core/files/listing.js +382 -0
- package/dist/core/files/paths.js +9 -0
- package/dist/core/files/read.js +191 -0
- package/dist/core/files/waiting.js +34 -0
- package/dist/core/flows/answers.js +133 -0
- package/dist/core/flows/ask-owner.js +95 -0
- package/dist/core/flows/asking.js +14 -0
- package/dist/core/flows/bind.js +95 -0
- package/dist/core/flows/contract.js +1 -0
- package/dist/core/flows/flows.js +35 -0
- package/dist/core/flows/hold.js +22 -0
- package/dist/core/flows/inbound.js +24 -0
- package/dist/core/flows/lists.js +21 -0
- package/dist/core/flows/messages.js +293 -0
- package/dist/core/flows/notices.js +51 -0
- package/dist/core/flows/repair.js +106 -0
- package/dist/core/flows/resume.js +163 -0
- package/dist/core/flows/setup.js +130 -0
- package/dist/core/flows/status.js +98 -0
- package/dist/core/flows/talk.js +207 -0
- package/dist/core/flows/update.js +370 -0
- package/dist/core/flows/words.js +171 -0
- package/dist/core/folders.js +93 -0
- package/dist/core/footer.js +313 -0
- package/dist/core/guide.js +50 -0
- package/dist/core/hold.js +126 -0
- package/dist/core/instance.js +46 -0
- package/dist/core/lock.js +412 -0
- package/dist/core/models.js +10 -0
- package/dist/core/paths.js +75 -0
- package/dist/core/port/file-flows.js +1 -0
- package/dist/core/port/home.js +1 -0
- package/dist/core/port/requests.js +1 -0
- package/dist/core/port/threads.js +1 -0
- package/dist/core/port/updates.js +1 -0
- package/dist/core/port.js +17 -0
- package/dist/core/reply/fold.js +175 -0
- package/dist/core/reply/previews.js +258 -0
- package/dist/core/reply/renderer.js +846 -0
- package/dist/core/reply/tail.js +24 -0
- package/dist/core/reply/words.js +32 -0
- package/dist/core/requests.js +158 -0
- package/dist/core/sessions/constants.js +61 -0
- package/dist/core/sessions/deps.js +1 -0
- package/dist/core/sessions/directory.js +86 -0
- package/dist/core/sessions/errors.js +79 -0
- package/dist/core/sessions/manager.js +378 -0
- package/dist/core/sessions/session.js +3297 -0
- package/dist/core/sessions/tasks.js +266 -0
- package/dist/core/sessions/turn.js +45 -0
- package/dist/core/setup.js +28 -0
- package/dist/core/state.js +724 -0
- package/dist/core/texts.js +451 -0
- package/dist/core/updates.js +332 -0
- package/dist/daemon.js +566 -0
- package/dist/host/npm.js +134 -0
- package/dist/host/program.js +49 -0
- package/dist/host/run.js +52 -0
- package/dist/host/service/launchd.js +385 -0
- package/dist/host/service/listing.js +42 -0
- package/dist/host/service/seam.js +1 -0
- package/dist/log.js +37 -0
- package/dist/main.js +29 -0
- package/dist/node-version.js +40 -0
- package/dist/registry.js +12 -0
- package/dist/semver.js +53 -0
- package/dist/text.js +89 -0
- package/dist/updates-host.js +113 -0
- package/dist/values.js +30 -0
- package/dist/version.js +54 -0
- package/package.json +32 -4
- package/slack-app-manifest.json +50 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/** Load the daemon's configuration from the `.env` of its instance's configuration folder. */
|
|
2
|
+
import { lstatSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { isAbsolute, join } from "node:path";
|
|
4
|
+
import { parseEnv } from "node:util";
|
|
5
|
+
import { strip } from "../text.js";
|
|
6
|
+
import { errorCode } from "../values.js";
|
|
7
|
+
import { SETUP_SECTIONS, setupGuide } from "../version.js";
|
|
8
|
+
import { expandUser, resolvePath } from "./paths.js";
|
|
9
|
+
/** The variables that select the agent and the chat; absent, the registry's defaults hold. */
|
|
10
|
+
export const SELECTORS = ["ALLABOARD_AGENT", "ALLABOARD_CHAT"];
|
|
11
|
+
const [AGENT_VARIABLE, CHAT_VARIABLE] = SELECTORS;
|
|
12
|
+
/** The variables the core itself requires; each module names its own. */
|
|
13
|
+
export const REQUIRED = ["ALLOWED_ROOT"];
|
|
14
|
+
/** The configuration is missing, unsafe or invalid; the message says what to fix. */
|
|
15
|
+
export class ConfigError extends Error {
|
|
16
|
+
constructor(message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = "ConfigError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
// python-dotenv's own pattern (`dotenv/variables.py`, 1.2.4): `${NAME}` and `${NAME:-default}`.
|
|
22
|
+
// `$NAME` without braces, a lone `$` and `${NAME:other}` stay as they are.
|
|
23
|
+
const VARIABLE = /\$\{([^}:]*)(?::-([^}]*))?\}/g;
|
|
24
|
+
/**
|
|
25
|
+
* The key names of `text` in the order the file defines them. `util.parseEnv` returns its keys
|
|
26
|
+
* sorted, which loses the order python-dotenv resolves in: a name defined later in the file does
|
|
27
|
+
* not exist yet for the values before it. A key `parseEnv` read that no line start names (should
|
|
28
|
+
* a syntax differ) goes last, so no key is dropped.
|
|
29
|
+
*/
|
|
30
|
+
function fileOrder(text, keys) {
|
|
31
|
+
const wanted = new Set(keys);
|
|
32
|
+
const ordered = new Set();
|
|
33
|
+
for (const [, key] of text.matchAll(/^[ \t]*(?:export[ \t]+)?([^\s=#]+)[ \t]*=/gm)) {
|
|
34
|
+
if (key !== undefined && wanted.has(key))
|
|
35
|
+
ordered.add(key);
|
|
36
|
+
}
|
|
37
|
+
return [...ordered, ...keys.filter((key) => !ordered.has(key))];
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The values of a `.env` text with `${NAME}` and `${NAME:-default}` expanded as python-dotenv does
|
|
41
|
+
* by default (`interpolate=True`), which is how the Python daemon read this file. A name
|
|
42
|
+
* resolves from the values the file defined above it, else from `env`, else to its default, else
|
|
43
|
+
* to nothing; an `env` entry that is empty counts as set. The expanded value is final: nothing in
|
|
44
|
+
* it is expanded again. Quotes were dropped by `parseEnv` and python-dotenv expands inside single
|
|
45
|
+
* quotes too, so every value is expanded alike. Differences from python-dotenv: a key repeated in
|
|
46
|
+
* the file (the last value is used and expanded once, where python-dotenv expands each
|
|
47
|
+
* occurrence) and a bare `NAME` line with no `=` (python-dotenv records it as unset and
|
|
48
|
+
* `${NAME:-d}` then gives nothing; here `d`).
|
|
49
|
+
*
|
|
50
|
+
* The syntax is Node's. Of 24 cases run through `util.parseEnv` of Node 26.5.1 and python-dotenv
|
|
51
|
+
* 1.2.1 (2026-10-11), 7 read differently: a `#` right after an unquoted
|
|
52
|
+
* value starts a comment (`C=a#b` is `a`; python-dotenv keeps `a#b`), and `A= # note` is empty
|
|
53
|
+
* (python-dotenv: `# note`); inside quotes a backslash is kept as written, `\n` in double quotes
|
|
54
|
+
* aside, so `\t` and `\\` stay two characters and a `\"` ends the value at that quote
|
|
55
|
+
* (python-dotenv decodes them); and three shapes python-dotenv skips as malformed are read: a
|
|
56
|
+
* name with a space (`KEY WITH SPACE=1`), text after a closing quote (`B="p"q` is `p`) and an
|
|
57
|
+
* unclosed quote (the value keeps the quote).
|
|
58
|
+
*/
|
|
59
|
+
export function expandEnv(text, env = process.env) {
|
|
60
|
+
const raw = new Map(Object.entries(parseEnv(text)));
|
|
61
|
+
const resolved = new Map();
|
|
62
|
+
for (const key of fileOrder(text, [...raw.keys()])) {
|
|
63
|
+
const value = raw.get(key);
|
|
64
|
+
if (value === undefined)
|
|
65
|
+
continue;
|
|
66
|
+
resolved.set(key, value.replace(VARIABLE, (_match, name, fallback) => {
|
|
67
|
+
const defined = resolved.get(name);
|
|
68
|
+
if (defined !== undefined)
|
|
69
|
+
return defined;
|
|
70
|
+
const inherited = Object.hasOwn(env, name) ? env[name] : undefined;
|
|
71
|
+
return inherited ?? fallback ?? "";
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
return Object.fromEntries(resolved);
|
|
75
|
+
}
|
|
76
|
+
function isDirectory(path) {
|
|
77
|
+
try {
|
|
78
|
+
return statSync(path).isDirectory();
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
const code = errorCode(error);
|
|
82
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
83
|
+
return false;
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** The values of `names` that `values` holds, readable by lookup and shown by nothing. */
|
|
88
|
+
function hiddenVariables(names, values) {
|
|
89
|
+
const hidden = Object.create(null);
|
|
90
|
+
for (const name of names) {
|
|
91
|
+
const value = values[name];
|
|
92
|
+
if (Object.hasOwn(values, name) && value !== undefined) {
|
|
93
|
+
Object.defineProperty(hidden, name, { value, enumerable: false });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return hidden;
|
|
97
|
+
}
|
|
98
|
+
/** The module `variable` selects (its default when the file does not set it), or a `ConfigError`. */
|
|
99
|
+
function selected(variable, chosen, fallback, held) {
|
|
100
|
+
const name = chosen ?? fallback;
|
|
101
|
+
const module = Object.hasOwn(held, name) ? held[name] : undefined;
|
|
102
|
+
if (module === undefined) {
|
|
103
|
+
throw new ConfigError(`${variable} is "${name}", which this daemon does not hold; the valid names are: ` +
|
|
104
|
+
Object.keys(held).sort().join(", "));
|
|
105
|
+
}
|
|
106
|
+
return [name, module];
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Read and validate `.env` for the modules it selects; throws ConfigError, never touches
|
|
110
|
+
* `process.env`. The values a module's own checks refuse (the shape of a token) are its to
|
|
111
|
+
* judge when it is built.
|
|
112
|
+
*/
|
|
113
|
+
export function loadConfig(modules, configDir, env = process.env) {
|
|
114
|
+
const envPath = join(configDir, ".env");
|
|
115
|
+
let st;
|
|
116
|
+
try {
|
|
117
|
+
st = lstatSync(envPath);
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
if (errorCode(error) === "ENOENT") {
|
|
121
|
+
throw new ConfigError(`${envPath} does not exist; see ${setupGuide(SETUP_SECTIONS.configuration)}`);
|
|
122
|
+
}
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
if (!st.isFile()) {
|
|
126
|
+
throw new ConfigError(`${envPath} must be a regular file, not a link`);
|
|
127
|
+
}
|
|
128
|
+
// Ownership and mode are POSIX facts; the daemon is not supported on Windows yet.
|
|
129
|
+
if (process.platform !== "win32") {
|
|
130
|
+
if (st.uid !== process.getuid?.()) {
|
|
131
|
+
throw new ConfigError(`${envPath} must belong to the user running allaboard`);
|
|
132
|
+
}
|
|
133
|
+
// The tokens drive a shell on this machine: nobody but the owner may read them.
|
|
134
|
+
if (st.mode & 0o077) {
|
|
135
|
+
throw new ConfigError(`${envPath} is readable by others; run: chmod 600 ${envPath}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const values = {};
|
|
139
|
+
// A byte order mark would become part of the first variable's name.
|
|
140
|
+
const text = readFileSync(envPath, "utf8").replace(/^\ufeff/, "");
|
|
141
|
+
for (const [key, raw] of Object.entries(expandEnv(text, env))) {
|
|
142
|
+
const value = raw === undefined ? "" : strip(raw);
|
|
143
|
+
if (value)
|
|
144
|
+
values[key] = value;
|
|
145
|
+
}
|
|
146
|
+
const [agent, agentModule] = selected(AGENT_VARIABLE, values[AGENT_VARIABLE], modules.defaults.agent, modules.agents);
|
|
147
|
+
const [chat, chatModule] = selected(CHAT_VARIABLE, values[CHAT_VARIABLE], modules.defaults.chat, modules.chats);
|
|
148
|
+
// The chat's, then the agent's, then the core's own.
|
|
149
|
+
const missing = [...chatModule.required, ...agentModule.required, ...REQUIRED].filter((key) => !Object.hasOwn(values, key));
|
|
150
|
+
if (missing.length > 0) {
|
|
151
|
+
throw new ConfigError(`missing in ${envPath}: ${missing.join(", ")}`);
|
|
152
|
+
}
|
|
153
|
+
const written = values.ALLOWED_ROOT;
|
|
154
|
+
const expanded = expandUser(written);
|
|
155
|
+
// A relative path would read from the working directory: the terminal's folder at Checkpoint 2,
|
|
156
|
+
// `/` under launchd.
|
|
157
|
+
if (!isAbsolute(expanded)) {
|
|
158
|
+
throw new ConfigError(`ALLOWED_ROOT must be an absolute path, or one starting with ~/; it is "${written}"`);
|
|
159
|
+
}
|
|
160
|
+
const root = resolvePath(expanded);
|
|
161
|
+
if (!isDirectory(root)) {
|
|
162
|
+
throw new ConfigError(`ALLOWED_ROOT is not a directory: ${root}`);
|
|
163
|
+
}
|
|
164
|
+
return Object.defineProperties({ agent, chat, allowedRoot: root, configDir }, {
|
|
165
|
+
agentVariables: {
|
|
166
|
+
value: hiddenVariables([...agentModule.required, ...agentModule.optional], values),
|
|
167
|
+
enumerable: false,
|
|
168
|
+
},
|
|
169
|
+
chatVariables: {
|
|
170
|
+
value: hiddenVariables([...chatModule.required, ...chatModule.optional], values),
|
|
171
|
+
enumerable: false,
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The changed files of a session: those added or modified since the thread started, committed or
|
|
3
|
+
* not, and the untracked ones that are not ignored. The measurements behind the git commands are
|
|
4
|
+
* in the `listing.ts` header.
|
|
5
|
+
*/
|
|
6
|
+
import { monotonicClock } from "../../clock.js";
|
|
7
|
+
import { len, strip } from "../../text.js";
|
|
8
|
+
import { GIT_TIMEOUT, runGit } from "../footer.js";
|
|
9
|
+
import { Semaphore } from "../sessions/tasks.js";
|
|
10
|
+
import { GIT_CHAINS, relation, scoped, under } from "./listing.js";
|
|
11
|
+
import { TIMED_OUT, within } from "./waiting.js";
|
|
12
|
+
const DECIMAL = /^\p{Nd}+$/u;
|
|
13
|
+
/**
|
|
14
|
+
* Where the repository's HEAD was when the thread started, which `startedAt` (seconds since the
|
|
15
|
+
* epoch) says: `HEAD@{<seconds> +0000}`, read from HEAD's own reflog (a linked worktree has its
|
|
16
|
+
* own). A commit, or the empty tree when the repository was made after the thread began (below),
|
|
17
|
+
* so that every file counts. Null when git has no answer: no reflog (a repository with
|
|
18
|
+
* `core.logAllRefUpdates` off, or a first commit not made yet), or a `startedAt` that is no time.
|
|
19
|
+
*
|
|
20
|
+
* Measured on git 2.54.0 (2026-10-05, 2026-10-06). The bare number is read as a time
|
|
21
|
+
* (`HEAD@{1790000150}`) and so is the internal format with its zone, which is the one used: it
|
|
22
|
+
* leaves no room for git's approximate dates. A time before the oldest entry gives that entry's
|
|
23
|
+
* old commit (a log that `git reflog expire` shortened) or, when its old value is null, its new
|
|
24
|
+
* one: the first commit, or a clone's tip, which would leave that commit's own files out of what
|
|
25
|
+
* changed. The two are told apart by commands that read the log and write nothing:
|
|
26
|
+
*
|
|
27
|
+
* - `rev-list -g --until=<seconds> +0000 --count HEAD` is 0 when every entry is after the thread's
|
|
28
|
+
* start (it counts the entries up to that time, as `HEAD@{<time>}` does);
|
|
29
|
+
* - `rev-list -g --count HEAD` is the number of entries, N, and `rev-parse --verify --quiet
|
|
30
|
+
* HEAD@{N}` is the oldest entry's old value, which fails when it is null (the repository was
|
|
31
|
+
* made, or cloned, since); a shortened log answers;
|
|
32
|
+
* - `hash-object -t tree /dev/null` is the empty tree of the repository's hash algorithm.
|
|
33
|
+
*
|
|
34
|
+
* A log that git cannot be asked this about keeps the answer `HEAD@{<time>}` gave.
|
|
35
|
+
*/
|
|
36
|
+
export async function startCommit(repository, startedAt, signal) {
|
|
37
|
+
if (!Number.isSafeInteger(startedAt) || startedAt < 0)
|
|
38
|
+
return null;
|
|
39
|
+
const when = `${startedAt} +0000`;
|
|
40
|
+
const found = await runGit(repository, ["rev-parse", "--verify", "--quiet", `HEAD@{${when}}`], signal);
|
|
41
|
+
const start = strip(found ?? "") || null;
|
|
42
|
+
if (start === null)
|
|
43
|
+
return null;
|
|
44
|
+
const until = await runGit(repository, ["rev-list", "-g", `--until=${when}`, "--count", "HEAD"], signal);
|
|
45
|
+
if (until === null || strip(until) !== "0")
|
|
46
|
+
return start;
|
|
47
|
+
const entries = await runGit(repository, ["rev-list", "-g", "--count", "HEAD"], signal);
|
|
48
|
+
if (entries === null || !DECIMAL.test(strip(entries)))
|
|
49
|
+
return start;
|
|
50
|
+
const oldest = await runGit(repository, ["rev-parse", "--verify", "--quiet", `HEAD@{${strip(entries)}}`], signal);
|
|
51
|
+
if (oldest)
|
|
52
|
+
return start;
|
|
53
|
+
const emptied = await runGit(repository, ["hash-object", "-t", "tree", "/dev/null"], signal);
|
|
54
|
+
return strip(emptied ?? "") || start;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The files in the part of the repository that is `folder` or inside it, added or modified since
|
|
58
|
+
* `start`, committed or not, and the untracked ones that are not ignored, as paths from `folder`.
|
|
59
|
+
* Not checked for existence: a deleted file may be named (`newestFirst` drops it). With no `start`,
|
|
60
|
+
* only what is not committed. Null when git fails.
|
|
61
|
+
*/
|
|
62
|
+
export async function changedSince(repository, folder, start, signal) {
|
|
63
|
+
const found = await relation(repository, folder);
|
|
64
|
+
if (found === null)
|
|
65
|
+
return null;
|
|
66
|
+
const [stripped, add] = found;
|
|
67
|
+
const scope = scoped(stripped);
|
|
68
|
+
const status = await runGit(repository, [
|
|
69
|
+
"--literal-pathspecs",
|
|
70
|
+
"--no-optional-locks",
|
|
71
|
+
"status",
|
|
72
|
+
"--porcelain=v1",
|
|
73
|
+
"-z",
|
|
74
|
+
"--untracked-files=all",
|
|
75
|
+
"--ignore-submodules=all",
|
|
76
|
+
"--no-renames",
|
|
77
|
+
...scope,
|
|
78
|
+
], signal);
|
|
79
|
+
if (status === null)
|
|
80
|
+
return null;
|
|
81
|
+
// `XY path`: two status letters and a space come before the path.
|
|
82
|
+
const names = status
|
|
83
|
+
.split("\0")
|
|
84
|
+
.filter((entry) => len(entry) > 3)
|
|
85
|
+
.map((entry) => entry.slice(3));
|
|
86
|
+
if (start !== null) {
|
|
87
|
+
const committed = await runGit(repository, [
|
|
88
|
+
"--literal-pathspecs",
|
|
89
|
+
"diff-tree",
|
|
90
|
+
"-r",
|
|
91
|
+
"-z",
|
|
92
|
+
"--name-only",
|
|
93
|
+
"--no-renames",
|
|
94
|
+
"--diff-filter=AMT",
|
|
95
|
+
start,
|
|
96
|
+
"HEAD",
|
|
97
|
+
...scope,
|
|
98
|
+
], signal);
|
|
99
|
+
// A start that no longer resolves (rewritten history): the uncommitted files alone.
|
|
100
|
+
names.push(...(committed ?? "").split("\0"));
|
|
101
|
+
}
|
|
102
|
+
return under(names.join("\0"), stripped, add);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The changed files of every repository of `repositories` (see `changedSince`), each from where
|
|
106
|
+
* its own HEAD was when the thread started, at `startedAt` seconds since the epoch
|
|
107
|
+
* (`startCommit`; null when the chat cannot tell, which counts from no start), as paths from `folder`,
|
|
108
|
+
* each once. A repository whose git fails or runs over `GIT_TIMEOUT` adds none.
|
|
109
|
+
*/
|
|
110
|
+
export async function changedIn(folder, repositories, startedAt, options = {}) {
|
|
111
|
+
const clock = options.clock ?? monotonicClock;
|
|
112
|
+
const start = options.start ?? startCommit;
|
|
113
|
+
const since = options.since ?? changedSince;
|
|
114
|
+
const gate = new Semaphore(options.chains ?? GIT_CHAINS);
|
|
115
|
+
// The turn is waited for outside the time limit: that is git's own.
|
|
116
|
+
const changed = (repository) => gate.run(async () => {
|
|
117
|
+
const found = await within(clock, GIT_TIMEOUT / 1000, async (signal) => {
|
|
118
|
+
const from = startedAt === null ? null : await start(repository, startedAt, signal);
|
|
119
|
+
return (await since(repository, folder, from, signal)) ?? [];
|
|
120
|
+
});
|
|
121
|
+
return found === TIMED_OUT ? [] : found;
|
|
122
|
+
});
|
|
123
|
+
const parts = await Promise.all(repositories.map(changed));
|
|
124
|
+
return [...new Set(parts.flat())];
|
|
125
|
+
}
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which files the search offers. Inside a repository the daemon's git may run in
|
|
3
|
+
* (`trustedRepository`: the one holding the session's folder, or the ones found at most two levels
|
|
4
|
+
* below it) the files come from git, through the footer's `runGit`: the tracked ones and the
|
|
5
|
+
* untracked ones that are not ignored. That is what the terminal's `@` file picker does, since its
|
|
6
|
+
* setting `respectGitignore` defaults to `true` and leaves out the files that match `.gitignore`
|
|
7
|
+
* patterns (code.claude.com/docs/en/settings-reference, read 2026-10-05). Everywhere else the
|
|
8
|
+
* folder is walked: regular files only, no symlinked folder entered, no `.git` entered.
|
|
9
|
+
*
|
|
10
|
+
* Every git command of `!open` (here and in `changed.ts`) is plumbing or `status` under
|
|
11
|
+
* `--no-optional-locks`, because `git diff` refreshes and rewrites the index under `index.lock`
|
|
12
|
+
* (see `changesOf` in the footer), and a lock left by a killed command would stop the owner's own
|
|
13
|
+
* `git add` and commit. Measured on git 2.54.0 (2026-10-05, and 2026-10-06 for the reflog
|
|
14
|
+
* commands) on a scratch repository, with the index's inode, mtime and sha256 compared before and
|
|
15
|
+
* after, in four states of the work tree (clean; a tracked file touched and left unchanged, which
|
|
16
|
+
* is a stat-dirty file; a tracked file edited; an untracked file):
|
|
17
|
+
*
|
|
18
|
+
* - never written, in any state: `rev-parse --verify HEAD@{<time>}` and `HEAD@{<n>}`, `rev-list
|
|
19
|
+
* -g --count` (also with `--until`), `hash-object -t tree /dev/null`, `ls-files --cached
|
|
20
|
+
* --others --exclude-standard`, `diff-tree -r`, `diff-files`, `diff-index` and
|
|
21
|
+
* `--no-optional-locks status`;
|
|
22
|
+
* - written for the stat-dirty file: `status` without `--no-optional-locks`, `diff --name-only`
|
|
23
|
+
* and `--no-optional-locks diff --name-only`;
|
|
24
|
+
* - reported as changed when only touched: `diff-files` and `diff-index` (the footer's commands);
|
|
25
|
+
* not reported: `status`, which compares the content of a file whose stat differs.
|
|
26
|
+
*
|
|
27
|
+
* So the committed changes come from `diff-tree` and the rest from `status`, which also lists the
|
|
28
|
+
* untracked files. `test/core/files/changed.test.ts` repeats the measurement on every run.
|
|
29
|
+
*/
|
|
30
|
+
import { lstat, readdir } from "node:fs/promises";
|
|
31
|
+
import { join, sep } from "node:path";
|
|
32
|
+
import { monotonicClock } from "../../clock.js";
|
|
33
|
+
import { getLogger } from "../../log.js";
|
|
34
|
+
import { compareCodePoints, len } from "../../text.js";
|
|
35
|
+
import { errorName } from "../../values.js";
|
|
36
|
+
import { foldersWithin } from "../folders.js";
|
|
37
|
+
import { runGit } from "../footer.js";
|
|
38
|
+
import { cancellable, Semaphore } from "../sessions/tasks.js";
|
|
39
|
+
import { relativeTo } from "./paths.js";
|
|
40
|
+
import { realpathLoose, regularFiles } from "./read.js";
|
|
41
|
+
import { TIMED_OUT, within } from "./waiting.js";
|
|
42
|
+
export const logger = getLogger("allaboard.core.files");
|
|
43
|
+
// A folder's listing is kept for LISTING_TTL (LISTING_PARTIAL_TTL when it is not complete), so
|
|
44
|
+
// the keystrokes of one search do not walk the folder again, and a listing stops at
|
|
45
|
+
// LISTING_BUDGET with what it found; LISTING_KEPT folders are kept at most. All in seconds.
|
|
46
|
+
export const LISTING_BUDGET = 2.0;
|
|
47
|
+
export const LISTING_TTL = 30.0;
|
|
48
|
+
export const LISTING_PARTIAL_TTL = 3.0;
|
|
49
|
+
export const LISTING_KEPT = 16;
|
|
50
|
+
// The repositories found in a folder are kept for a shorter time than its files: one the owner
|
|
51
|
+
// trusts while the session runs should show within moments. At most GIT_CHAINS
|
|
52
|
+
// repositories are asked at once, the rest wait their turn.
|
|
53
|
+
export const REPOSITORIES_TTL = 5.0;
|
|
54
|
+
export const GIT_CHAINS = 4;
|
|
55
|
+
/** `repository` when it has a work tree git can list, else null. */
|
|
56
|
+
export function usable(repository) {
|
|
57
|
+
if (repository === null || repository.gitDir === null || repository.insideGitDir)
|
|
58
|
+
return null;
|
|
59
|
+
return repository;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* How the folder and the repository's root meet, as `[strip, add]`: git's paths are relative to
|
|
63
|
+
* the root, and a path from the folder is that path less `strip` and with `add` before it. A
|
|
64
|
+
* folder inside the repository strips its own path from the root (`""` at the root itself), a
|
|
65
|
+
* repository inside the folder adds its own path from the folder, and any other pair is null.
|
|
66
|
+
*/
|
|
67
|
+
export async function relation(repository, folder) {
|
|
68
|
+
const real = await realpathLoose(folder);
|
|
69
|
+
const inside = relativeTo(repository.root, real);
|
|
70
|
+
if (inside !== null)
|
|
71
|
+
return [inside.split(sep).join("/"), ""];
|
|
72
|
+
const held = relativeTo(real, repository.root);
|
|
73
|
+
if (held !== null)
|
|
74
|
+
return ["", `${held.split(sep).join("/")}/`];
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The paths of a NUL-separated git output that lie under `strip`, as paths from the folder, each
|
|
79
|
+
* once.
|
|
80
|
+
*/
|
|
81
|
+
export function under(output, strip, add) {
|
|
82
|
+
const start = strip ? `${strip}/` : "";
|
|
83
|
+
const names = output
|
|
84
|
+
.split("\0")
|
|
85
|
+
.filter((name) => name.startsWith(start))
|
|
86
|
+
.map((name) => name.slice(start.length));
|
|
87
|
+
return [...new Set(names.filter((name) => name).map((name) => `${add}${name}`))];
|
|
88
|
+
}
|
|
89
|
+
export function scoped(strip) {
|
|
90
|
+
return strip ? ["--", strip] : [];
|
|
91
|
+
}
|
|
92
|
+
/** The folders under `folder`, as `!bind` lists them, that hold a `.git` entry. */
|
|
93
|
+
async function repositoryFolders(folder) {
|
|
94
|
+
let below;
|
|
95
|
+
try {
|
|
96
|
+
below = await foldersWithin(folder);
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
// A system error has a code.
|
|
100
|
+
if (typeof error?.code === "string")
|
|
101
|
+
return [];
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
const holding = await Promise.all(below.map((path) => path === folder
|
|
105
|
+
? false
|
|
106
|
+
: lstat(join(path, ".git")).then(() => true, () => false)));
|
|
107
|
+
return below.filter((_path, index) => holding[index]);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* The repositories the daemon's git may run in for a session started in `folder`: the one holding
|
|
111
|
+
* the folder when `lookup` finds it usable, else the usable ones found at most two levels below
|
|
112
|
+
* it (the depth `!bind` lists folders to), none of them inside another.
|
|
113
|
+
*/
|
|
114
|
+
export async function repositoriesOf(folder, lookup) {
|
|
115
|
+
const held = usable(await lookup(folder));
|
|
116
|
+
if (held !== null)
|
|
117
|
+
return [held];
|
|
118
|
+
const below = await repositoryFolders(folder);
|
|
119
|
+
const looked = await Promise.all(below.map((candidate) => lookup(candidate)));
|
|
120
|
+
const found = new Map();
|
|
121
|
+
for (const repository of looked.map(usable)) {
|
|
122
|
+
if (repository !== null)
|
|
123
|
+
found.set(repository.root, repository);
|
|
124
|
+
}
|
|
125
|
+
return [...found.values()];
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* The regular files under `root` as paths from it, the shallower first: a symlink is neither
|
|
129
|
+
* followed nor listed, no `.git` is entered, nor any folder in `skip` (paths). Stops when
|
|
130
|
+
* `expired()` is true, with what it has found; the flag says the walk reached its end.
|
|
131
|
+
*/
|
|
132
|
+
export async function walkFiles(root, skip, expired) {
|
|
133
|
+
const found = [];
|
|
134
|
+
const queue = [[root, ""]];
|
|
135
|
+
let next = 0;
|
|
136
|
+
while (next < queue.length && !expired()) {
|
|
137
|
+
const [directory, prefix] = queue[next];
|
|
138
|
+
next += 1;
|
|
139
|
+
try {
|
|
140
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
141
|
+
if (entry.name === ".git")
|
|
142
|
+
continue;
|
|
143
|
+
const path = join(directory, entry.name);
|
|
144
|
+
// `Dirent` describes the entry itself: a symlink is neither a directory nor a file.
|
|
145
|
+
if (entry.isDirectory()) {
|
|
146
|
+
if (!skip.has(path))
|
|
147
|
+
queue.push([path, `${prefix}${entry.name}/`]);
|
|
148
|
+
}
|
|
149
|
+
else if (entry.isFile()) {
|
|
150
|
+
found.push(`${prefix}${entry.name}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
// A folder the daemon may not open (macOS privacy, permissions) lists nothing.
|
|
156
|
+
logger.debug(`skipped an unreadable folder: ${error.code}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return [found, next >= queue.length];
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* The tracked files and the untracked ones that are not ignored, in the part of the repository
|
|
163
|
+
* that is `folder` or inside it, as paths from `folder`; null when git fails. Not checked for
|
|
164
|
+
* existence: a tracked file deleted from the work tree is listed (`regularFiles` drops it).
|
|
165
|
+
*/
|
|
166
|
+
export async function projectFiles(repository, folder, signal) {
|
|
167
|
+
const found = await relation(repository, folder);
|
|
168
|
+
if (found === null)
|
|
169
|
+
return null;
|
|
170
|
+
const [strip, add] = found;
|
|
171
|
+
const out = await runGit(repository, [
|
|
172
|
+
// Literal: the folder's name is not a glob.
|
|
173
|
+
"--literal-pathspecs",
|
|
174
|
+
"ls-files",
|
|
175
|
+
"-z",
|
|
176
|
+
"--cached",
|
|
177
|
+
"--others",
|
|
178
|
+
"--exclude-standard",
|
|
179
|
+
...scoped(strip),
|
|
180
|
+
], signal);
|
|
181
|
+
return out === null ? null : under(out, strip, add);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* The files `!open` can name under `folder`, as paths from it: from git inside a repository of
|
|
185
|
+
* `repositories`, the folder's own disk anywhere else. Within `budget` seconds: what is found by
|
|
186
|
+
* then is returned, a repository whose listing is not done adding nothing. The flag says nothing
|
|
187
|
+
* was left out: no walk or git listing ran out of time or failed.
|
|
188
|
+
*/
|
|
189
|
+
export async function folderFiles(folder, repositories, budget, options = {}) {
|
|
190
|
+
const clock = options.clock ?? monotonicClock;
|
|
191
|
+
const list = options.list ?? projectFiles;
|
|
192
|
+
const deadline = clock.time() + budget;
|
|
193
|
+
const real = await realpathLoose(folder);
|
|
194
|
+
const gate = new Semaphore(options.chains ?? GIT_CHAINS);
|
|
195
|
+
const listed = async (repository) => {
|
|
196
|
+
// Waiting for a turn is part of the budget: it is the listing's own.
|
|
197
|
+
const found = await within(clock, budget, (signal) => gate.run(() => list(repository, folder, signal), signal));
|
|
198
|
+
if (found === TIMED_OUT)
|
|
199
|
+
return [[], false];
|
|
200
|
+
return [found ?? [], found !== null];
|
|
201
|
+
};
|
|
202
|
+
let walked = [];
|
|
203
|
+
let walkDone = true;
|
|
204
|
+
let parts;
|
|
205
|
+
if (repositories.some((r) => relativeTo(r.root, real) !== null)) {
|
|
206
|
+
// Every file of the folder is in the repository that holds it.
|
|
207
|
+
parts = await Promise.all(repositories.map(listed));
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
const skip = new Set(repositories.map((r) => r.root));
|
|
211
|
+
const [walk, ...listings] = await Promise.all([
|
|
212
|
+
walkFiles(real, skip, () => clock.time() >= deadline),
|
|
213
|
+
...repositories.map(listed),
|
|
214
|
+
]);
|
|
215
|
+
[walked, walkDone] = walk;
|
|
216
|
+
parts = listings;
|
|
217
|
+
}
|
|
218
|
+
const files = [...new Set([...walked, ...parts.flatMap(([found]) => found)])];
|
|
219
|
+
return [files, walkDone && parts.every(([, done]) => done)];
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* What is made for a folder, kept for as long as its maker says, at most `limit` folders (the
|
|
223
|
+
* oldest go first) with the expired ones removed on the next request. Requests for a folder whose
|
|
224
|
+
* value is being made wait for that one.
|
|
225
|
+
*/
|
|
226
|
+
class Kept {
|
|
227
|
+
#limit;
|
|
228
|
+
#clock;
|
|
229
|
+
#kept = new Map();
|
|
230
|
+
#running = new Map();
|
|
231
|
+
constructor(limit, clock) {
|
|
232
|
+
this.#limit = limit;
|
|
233
|
+
this.#clock = clock;
|
|
234
|
+
}
|
|
235
|
+
/** The folders whose value is kept. */
|
|
236
|
+
folders() {
|
|
237
|
+
return [...this.#kept.keys()];
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* The value, and whether it was made for this request (not kept). `make` answers the value and
|
|
241
|
+
* how long to keep it, null for not at all. `again` skips what is kept. A request whose `signal`
|
|
242
|
+
* aborts stops waiting; the work goes on for the others.
|
|
243
|
+
*/
|
|
244
|
+
async get(folder, make, options = {}) {
|
|
245
|
+
const now = this.#clock.time();
|
|
246
|
+
for (const [path, entry] of [...this.#kept]) {
|
|
247
|
+
if (entry.until <= now)
|
|
248
|
+
this.#kept.delete(path);
|
|
249
|
+
}
|
|
250
|
+
const kept = options.again ? undefined : this.#kept.get(folder);
|
|
251
|
+
if (kept !== undefined)
|
|
252
|
+
return [kept.value, false];
|
|
253
|
+
let task = this.#running.get(folder);
|
|
254
|
+
if (task === undefined) {
|
|
255
|
+
const started = this.#make(folder, make);
|
|
256
|
+
task = started;
|
|
257
|
+
this.#running.set(folder, started);
|
|
258
|
+
const done = () => {
|
|
259
|
+
if (this.#running.get(folder) === started)
|
|
260
|
+
this.#running.delete(folder);
|
|
261
|
+
};
|
|
262
|
+
started.then(done, done);
|
|
263
|
+
}
|
|
264
|
+
return [await cancellable(task, options.signal), true];
|
|
265
|
+
}
|
|
266
|
+
async #make(folder, make) {
|
|
267
|
+
const started = this.#clock.time();
|
|
268
|
+
const [value, keep] = await make();
|
|
269
|
+
if (keep !== null) {
|
|
270
|
+
this.#kept.delete(folder);
|
|
271
|
+
this.#kept.set(folder, { until: started + keep, value });
|
|
272
|
+
while (this.#kept.size > this.#limit) {
|
|
273
|
+
this.#kept.delete(this.#kept.keys().next().value);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return value;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/** The paths that contain `words`, ignoring case: those whose file name does first, then the shorter paths, then by path. */
|
|
280
|
+
export function rank(paths, words) {
|
|
281
|
+
const wanted = words.toLowerCase();
|
|
282
|
+
const found = [...paths].filter((path) => path.toLowerCase().includes(wanted));
|
|
283
|
+
const named = (path) => path
|
|
284
|
+
.slice(path.lastIndexOf("/") + 1)
|
|
285
|
+
.toLowerCase()
|
|
286
|
+
.includes(wanted);
|
|
287
|
+
return found.toSorted((left, right) => Number(!named(left)) - Number(!named(right)) ||
|
|
288
|
+
len(left) - len(right) ||
|
|
289
|
+
compareCodePoints(left, right));
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* The files of a session's folder for the search, and the repositories found in it, kept in
|
|
293
|
+
* memory: the chat asks again on every keystroke, and a folder is not walked each time. A listing is
|
|
294
|
+
* kept `ttl` seconds when it is complete and `partialTtl` when it ran out of time or git failed
|
|
295
|
+
* for part of it, so a folder too large for the budget is not walked on each keystroke but is not
|
|
296
|
+
* taken for complete either. The repositories are kept `repositoriesTtl` seconds. At most `limit`
|
|
297
|
+
* folders of each are kept (the oldest go first), and the expired ones are removed on the next
|
|
298
|
+
* request. Requests for a folder that is being listed wait for that listing. A kept listing is
|
|
299
|
+
* never the reason for "no match": `search` makes it again first.
|
|
300
|
+
*/
|
|
301
|
+
export class Listings {
|
|
302
|
+
#lookup;
|
|
303
|
+
#ttl;
|
|
304
|
+
#partialTtl;
|
|
305
|
+
#repositoriesTtl;
|
|
306
|
+
#budget;
|
|
307
|
+
#clock;
|
|
308
|
+
#regular;
|
|
309
|
+
#listed;
|
|
310
|
+
#found;
|
|
311
|
+
constructor(lookup, options = {}) {
|
|
312
|
+
const limit = options.limit ?? LISTING_KEPT;
|
|
313
|
+
this.#lookup = lookup;
|
|
314
|
+
this.#ttl = options.ttl ?? LISTING_TTL;
|
|
315
|
+
this.#partialTtl = options.partialTtl ?? LISTING_PARTIAL_TTL;
|
|
316
|
+
this.#repositoriesTtl = options.repositoriesTtl ?? REPOSITORIES_TTL;
|
|
317
|
+
this.#budget = options.budget ?? LISTING_BUDGET;
|
|
318
|
+
this.#clock = options.clock ?? monotonicClock;
|
|
319
|
+
this.#regular = options.regular ?? regularFiles;
|
|
320
|
+
this.#listed = new Kept(limit, this.#clock);
|
|
321
|
+
this.#found = new Kept(limit, this.#clock);
|
|
322
|
+
}
|
|
323
|
+
/** The folders whose listing is kept. */
|
|
324
|
+
keptListings() {
|
|
325
|
+
return this.#listed.folders();
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* The repositories the daemon's git may run in for a session started in `folder`
|
|
329
|
+
* (`repositoriesOf`). Rejects with what the lookup rejects with.
|
|
330
|
+
*/
|
|
331
|
+
async repositories(folder, options = {}) {
|
|
332
|
+
const find = async () => [
|
|
333
|
+
await repositoriesOf(folder, this.#lookup),
|
|
334
|
+
this.#repositoriesTtl,
|
|
335
|
+
];
|
|
336
|
+
return (await this.#found.get(folder, find, options))[0];
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* The folder's files as paths from it. Never rejects but for the signal: a listing that fails
|
|
340
|
+
* is empty, incomplete and not kept. `again` makes it anew whatever is kept.
|
|
341
|
+
*/
|
|
342
|
+
async of(folder, options = {}) {
|
|
343
|
+
const [listing, fresh] = await this.#listed.get(folder, () => this.#list(folder, options.again ?? false), options);
|
|
344
|
+
return fresh ? listing : { ...listing, fresh: false };
|
|
345
|
+
}
|
|
346
|
+
async #list(folder, again) {
|
|
347
|
+
try {
|
|
348
|
+
const repositories = await this.repositories(folder, { again });
|
|
349
|
+
const [files, complete] = await folderFiles(folder, repositories, this.#budget, {
|
|
350
|
+
clock: this.#clock,
|
|
351
|
+
});
|
|
352
|
+
return [{ files, complete, fresh: true }, complete ? this.#ttl : this.#partialTtl];
|
|
353
|
+
}
|
|
354
|
+
catch (error) {
|
|
355
|
+
// A search that finds nothing beats a modal that never loads.
|
|
356
|
+
logger.warning(`could not list a folder's files: ${errorName(error)}`);
|
|
357
|
+
return [{ files: [], complete: false, fresh: true }, null];
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* The files of the folder whose path contains `words`, in `rank`'s order, that exist
|
|
362
|
+
* (`regularFiles`), at most `limit` of them checked. Past the limit `count` is the number of
|
|
363
|
+
* matches by name. When a kept listing finds none, the folder is listed again and searched
|
|
364
|
+
* anew: a file made since the listing is not told to be missing.
|
|
365
|
+
*/
|
|
366
|
+
async search(folder, words, options = {}) {
|
|
367
|
+
const { limit, signal } = options;
|
|
368
|
+
const listing = await this.of(folder, { signal });
|
|
369
|
+
let found = await this.#matching(folder, listing, words, limit);
|
|
370
|
+
if (found.paths.length === 0 && !listing.fresh) {
|
|
371
|
+
const again = await this.of(folder, { again: true, signal });
|
|
372
|
+
found = await this.#matching(folder, again, words, limit);
|
|
373
|
+
}
|
|
374
|
+
return found;
|
|
375
|
+
}
|
|
376
|
+
async #matching(folder, listing, words, limit) {
|
|
377
|
+
const ranked = rank(listing.files, words);
|
|
378
|
+
const paths = await this.#regular(folder, ranked, limit);
|
|
379
|
+
const count = limit !== undefined && paths.length >= limit ? ranked.length : paths.length;
|
|
380
|
+
return { paths, count, complete: listing.complete };
|
|
381
|
+
}
|
|
382
|
+
}
|