@platless/axom-cli 0.3.13 → 0.5.9
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/dist/axom-patch/cli/README.md +30 -8
- package/dist/axom-patch/cli/account-limits/README.md +27 -0
- package/dist/axom-patch/cli/account-limits/antigravity-proxy.mjs +47 -0
- package/dist/axom-patch/cli/account-limits/antigravity-read.mjs +90 -0
- package/dist/axom-patch/cli/account-limits/antigravity.mjs +46 -0
- package/dist/axom-patch/cli/account-limits/generate.py +120 -0
- package/dist/axom-patch/cli/account-limits/kimi-proxy.mjs +64 -0
- package/dist/axom-patch/cli/account-limits/normalize.mjs +151 -0
- package/dist/axom-patch/cli/claude-account-limits.json +37 -0
- package/dist/axom-patch/cli/codex-account-limits.json +37 -0
- package/dist/axom-patch/cli/commandcode-account-limits.json +30 -0
- package/dist/axom-patch/cli/nightly-task.md +152 -0
- package/dist/axom-patch/cli/pi-subagent-subwork.json +23 -0
- package/dist/axom-patch/cli/probes/claude-late-async-terminal.mjs +5 -1
- package/dist/axom-patch/cli/probes/pi-subagent-subwork.mjs +211 -0
- package/dist/axom-patch/cli/probes/qoder-steering.mjs +1 -1
- package/dist/axom-patch/cli/probes/qoder-subagent-origin.mjs +20 -8
- package/dist/axom-patch/cli/probes/zcode-subagent-subwork.mjs +257 -0
- package/dist/axom-patch/cli/qoder-background-terminal.md +2 -1
- package/dist/axom-patch/cli/qoder-foreground-output.md +4 -3
- package/dist/axom-patch/cli/qoder-steering.json +15 -15
- package/dist/axom-patch/cli/qoder-subagent-origin.json +21 -21
- package/dist/axom-patch/cli/runtime.json +34 -8
- package/dist/axom-patch/cli/zcode-account-limits.json +31 -0
- package/dist/axom-patch/cli/zcode-models.json +38 -0
- package/dist/axom-patch/cli/zcode-subagent-subwork.json +73 -0
- package/dist/cli.js +33 -6
- package/dist/keepers/acp/keeper.mjs +3083 -0
- package/dist/keepers/acp/manifest.json +9 -0
- package/dist/keepers/terminal/keeper.mjs +8469 -0
- package/dist/keepers/terminal/manifest.json +9 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/conpty_console_list_agent.js +25 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/eventEmitter2.js +47 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/index.js +52 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/interfaces.js +7 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/shared/conout.js +10 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/terminal.js +190 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/types.js +7 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/unixTerminal.js +351 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/utils.js +38 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/windowsConoutConnection.js +131 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/windowsPtyAgent.js +344 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/windowsTerminal.js +209 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/worker/conoutSocketWorker.js +22 -0
- package/dist/keepers/terminal/node_modules/node-pty/package.json +66 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-arm64/pty.node +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-arm64/spawn-helper +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-x64/pty.node +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-x64/spawn-helper +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/linux-arm64/pty.node +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/linux-x64/pty.node +0 -0
- package/dist/runtime-deps/qoder-sdk/package-lock.json +1189 -0
- package/dist/runtime-deps/qoder-sdk/package.json +8 -0
- package/dist/test/keeperKit.js +67 -0
- package/dist/workstation/acp/acpAgents.js +2163 -611
- package/dist/workstation/acp/acpClient.js +60 -497
- package/dist/workstation/acp/adoption.js +10 -396
- package/dist/workstation/acp/binaryInstall.js +100 -27
- package/dist/workstation/acp/builds/gc.js +245 -0
- package/dist/workstation/acp/builds/install/binary.js +28 -0
- package/dist/workstation/acp/builds/install/npm.js +235 -0
- package/dist/workstation/acp/builds/install/uv.js +254 -0
- package/dist/workstation/acp/builds/migrate.js +195 -0
- package/dist/workstation/acp/builds/model.js +128 -0
- package/dist/workstation/acp/builds/nodeGate.js +100 -0
- package/dist/workstation/acp/builds/store.js +352 -0
- package/dist/workstation/acp/commandTableProbe.js +68 -0
- package/dist/workstation/acp/configOptions.js +103 -1
- package/dist/workstation/acp/envSecrets.js +27 -0
- package/dist/workstation/acp/initializeResponse.js +285 -0
- package/dist/workstation/acp/installErrors.js +12 -2
- package/dist/workstation/acp/installProbe.js +1 -1
- package/dist/workstation/acp/managedPatches.js +76 -160
- package/dist/workstation/acp/outbox.js +330 -0
- package/dist/workstation/acp/patchAssets.js +20 -3
- package/dist/workstation/acp/permission.js +42 -10
- package/dist/workstation/acp/processor/checkpoint.js +87 -0
- package/dist/workstation/acp/processor/commands.js +35 -0
- package/dist/workstation/acp/processor/control.js +355 -0
- package/dist/workstation/acp/processor/emit.js +26 -0
- package/dist/workstation/acp/processor/jsonrpc.js +172 -0
- package/dist/workstation/acp/processor/memoryLog.js +49 -0
- package/dist/workstation/acp/processor/origins.js +48 -0
- package/dist/workstation/acp/processor/output.js +67 -0
- package/dist/workstation/acp/processor/projection.js +42 -0
- package/dist/workstation/acp/processor/record.js +128 -0
- package/dist/workstation/acp/processor/runtime.js +181 -0
- package/dist/workstation/acp/processor/sdkSchemas.js +61 -0
- package/dist/workstation/acp/processor/session.js +525 -0
- package/dist/workstation/acp/processor/sideChannel.js +80 -0
- package/dist/workstation/acp/processor/state.js +74 -0
- package/dist/workstation/acp/processor/step.js +459 -0
- package/dist/workstation/acp/processor/test/fixtureLog.js +65 -0
- package/dist/workstation/acp/processor/test/harness.js +120 -0
- package/dist/workstation/acp/processor/test/largeIdEcho.js +50 -0
- package/dist/workstation/acp/processor/test/rawAgent.js +70 -0
- package/dist/workstation/acp/processor/transport/generations.js +76 -0
- package/dist/workstation/acp/processor/transport/keeper.js +130 -0
- package/dist/workstation/acp/processor/transport/local.js +269 -0
- package/dist/workstation/acp/processor/transport/types.js +78 -0
- package/dist/workstation/acp/processor/updates.js +364 -0
- package/dist/workstation/acp/processorSession.js +1512 -0
- package/dist/workstation/acp/qoderStartup.js +1 -1
- package/dist/workstation/acp/registry/catalog.js +154 -32
- package/dist/workstation/acp/registry/launch.js +61 -119
- package/dist/workstation/acp/sessionUpdateMapper.js +18 -7
- package/dist/workstation/acp/steering.js +58 -0
- package/dist/workstation/acp/switching.js +114 -0
- package/dist/workstation/acp/terminalLogin.js +16 -42
- package/dist/workstation/acp/terminalLoginSpec.js +41 -0
- package/dist/workstation/acp/test/adapterFixtures.js +256 -0
- package/dist/workstation/acp/test/daemonLife.js +106 -0
- package/dist/workstation/acp/test/keeperWorld.js +267 -0
- package/dist/workstation/acp/test/memoryOutbox.js +55 -0
- package/dist/workstation/acp/test/raw/fixtures.js +196 -0
- package/dist/workstation/acp/test/raw/qoderCaptures.js +125 -0
- package/dist/workstation/acp/test/raw/sanitize.js +344 -0
- package/dist/workstation/acp/test/raw/sources.js +249 -0
- package/dist/workstation/acp/test/realAdapter.js +377 -0
- package/dist/workstation/acp/test/scriptAgent.js +206 -0
- package/dist/workstation/acp/test/sessionFrames.js +42 -0
- package/dist/workstation/acp/timedCommand.js +4 -2
- package/dist/workstation/acp/uvxInstall.js +72 -122
- package/dist/workstation/agentFlow.js +103 -0
- package/dist/workstation/agentSessions.js +1324 -474
- package/dist/workstation/channels/agent.js +7 -5
- package/dist/workstation/channels/computer.js +69 -21
- package/dist/workstation/channels/computerExecs.js +200 -0
- package/dist/workstation/channels/management.js +84 -4
- package/dist/workstation/daemon.js +288 -7
- package/dist/workstation/daemonctl.js +28 -2
- package/dist/workstation/exec/attach.js +126 -0
- package/dist/workstation/exec/command.js +40 -0
- package/dist/workstation/exec/durable.js +517 -0
- package/dist/workstation/exec/fallback.js +106 -0
- package/dist/workstation/exec/policy.js +837 -0
- package/dist/workstation/exec/shaping.js +211 -0
- package/dist/workstation/exec.js +3 -112
- package/dist/workstation/files/adapters/computerFileFrames.js +51 -0
- package/dist/workstation/files/adapters/toolRequests.js +121 -0
- package/dist/workstation/files/client.js +455 -0
- package/dist/workstation/files/errors.js +28 -0
- package/dist/workstation/files/fence.js +350 -0
- package/dist/workstation/files/find/fileIndex.js +153 -0
- package/dist/workstation/files/find/fuzzy.js +155 -0
- package/dist/workstation/files/host.js +122 -0
- package/dist/workstation/files/hostProtocol.js +2 -0
- package/dist/workstation/files/media/cache.js +620 -0
- package/dist/workstation/files/media/encoding.js +308 -0
- package/dist/workstation/files/media/estimator.js +100 -0
- package/dist/workstation/files/media/media.js +325 -0
- package/dist/workstation/files/media/packedAudio.js +23 -0
- package/dist/workstation/files/media/scheduler.js +551 -0
- package/dist/workstation/files/media/types.js +5 -0
- package/dist/workstation/files/scope.js +45 -0
- package/dist/workstation/files/structured.js +284 -0
- package/dist/workstation/{fileOps.js → files/text.js} +117 -38
- package/dist/workstation/files/uploads.js +351 -0
- package/dist/workstation/journal.js +65 -236
- package/dist/workstation/keepers/acp/client.js +101 -0
- package/dist/workstation/keepers/acp/envelope.js +62 -0
- package/dist/workstation/keepers/acp/host.js +419 -0
- package/dist/workstation/keepers/acp/main.js +18 -0
- package/dist/workstation/keepers/acp/memoryStream.js +121 -0
- package/dist/workstation/keepers/acp/params.js +137 -0
- package/dist/workstation/keepers/acp/protocol.js +66 -0
- package/dist/workstation/keepers/acp/recover.js +120 -0
- package/dist/workstation/keepers/acp/session.js +584 -0
- package/dist/workstation/keepers/acp/stderr.js +154 -0
- package/dist/workstation/keepers/kit/activity.js +103 -0
- package/dist/workstation/keepers/kit/bootstrap.js +164 -0
- package/dist/workstation/keepers/kit/buildVersion.js +51 -0
- package/dist/workstation/keepers/kit/bundle.js +120 -0
- package/dist/workstation/keepers/kit/client.js +432 -0
- package/dist/workstation/keepers/kit/identity.js +21 -0
- package/dist/workstation/keepers/kit/ipc.js +112 -0
- package/dist/workstation/keepers/kit/launchConfig.js +8 -0
- package/dist/workstation/keepers/kit/launcher.js +249 -0
- package/dist/workstation/keepers/kit/lock.js +123 -0
- package/dist/workstation/keepers/kit/orphanSweep.js +86 -0
- package/dist/workstation/keepers/kit/paths.js +75 -0
- package/dist/workstation/keepers/kit/protocol.js +43 -0
- package/dist/workstation/keepers/kit/server.js +518 -0
- package/dist/workstation/keepers/kit/streamLog.js +697 -0
- package/dist/workstation/keepers/legacy.js +296 -0
- package/dist/workstation/keepers/policy.js +576 -0
- package/dist/workstation/keepers/stop.js +34 -0
- package/dist/workstation/keepers/terminal/client.js +213 -0
- package/dist/workstation/keepers/terminal/exec.js +981 -0
- package/dist/workstation/keepers/terminal/generations.js +129 -0
- package/dist/workstation/keepers/terminal/keeper.js +62 -0
- package/dist/workstation/keepers/terminal/main.js +43 -0
- package/dist/workstation/keepers/terminal/orphanSweep.js +3 -0
- package/dist/workstation/keepers/terminal/params.js +27 -0
- package/dist/workstation/keepers/terminal/protocol.js +56 -0
- package/dist/workstation/keepers/terminal/pty.js +330 -0
- package/dist/workstation/protocol/accountLimits.js +47 -0
- package/dist/workstation/protocol/agent.js +48 -4
- package/dist/workstation/protocol/common.js +30 -0
- package/dist/workstation/protocol/computer.js +15 -4
- package/dist/workstation/protocol/management.js +50 -2
- package/dist/workstation/qoderAdapter/backgroundProcess.js +18 -6
- package/dist/workstation/qoderAdapter/backgroundTerminal.js +78 -147
- package/dist/workstation/qoderAdapter/backgroundTerminalFold.js +372 -0
- package/dist/workstation/qoderAdapter/callbackContext.js +76 -0
- package/dist/workstation/qoderAdapter/foregroundOutput.js +15 -9
- package/dist/workstation/qoderAdapter/history.js +1 -3
- package/dist/workstation/qoderAdapter/main.js +4 -2
- package/dist/workstation/qoderAdapter/sdkDriver.js +1 -2
- package/dist/workstation/qoderAdapter/sdkRuntime.js +93 -0
- package/dist/workstation/qoderAdapter/sdkRuntimeInstall.js +145 -0
- package/dist/workstation/qoderAdapter/session.js +1 -1
- package/dist/workstation/qoderAdapter/subwork.js +15 -7
- package/dist/workstation/systemMetrics.js +110 -1
- package/dist/workstation/tools/client.js +9 -1
- package/dist/workstation/tools/info.js +50 -0
- package/dist/workstation/tools/keeperProcess.js +15 -5
- package/dist/workstation/tools/router.js +55 -0
- package/dist/workstation/tools/terminalRouter.js +222 -0
- package/dist/workstation/tools/validation.js +37 -1
- package/dist/workstation/update/coordinator.js +3 -0
- package/dist/workstation/update/ptyPreflight.js +21 -0
- package/dist/workstation/update/taskRegistry.js +105 -11
- package/package.json +10 -7
- package/dist/workstation/acp/acpSession.js +0 -1475
- package/dist/workstation/acp/contract.js +0 -499
- package/dist/workstation/acp/draftInterceptor.js +0 -163
- package/dist/workstation/acp/sessionScope.js +0 -19
- package/dist/workstation/filePaths.js +0 -174
- package/dist/workstation/fileWorker.js +0 -81
- package/dist/workstation/fileWorkerClient.js +0 -121
- package/dist/workstation/qoderAdapter/taskEvent.js +0 -26
- package/dist/workstation/tools/core.js +0 -50
- package/dist/workstation/tools/files.js +0 -525
- package/dist/workstation/tools/terminal.js +0 -214
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import { constants, promises as fs } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { cancelledError, FileError, throwIfAborted, toFileError } from './errors.js';
|
|
5
|
+
import { assertScopeAllows, inside } from './scope.js';
|
|
6
|
+
export { FileError, toFileError, cancelledError, throwIfAborted } from './errors.js';
|
|
7
|
+
/*
|
|
8
|
+
* The one file fence: validate -> pin the parent directory -> touch only a leaf -> O_NOFOLLOW -> atomic commit.
|
|
9
|
+
*
|
|
10
|
+
* Invariant: the process cwd is shared by everything running in the process, so ONLY code inside `pinned()` may use a
|
|
11
|
+
* relative path, and it may only use the leaf names its `Pin` hands out. Everything else (find, media encoding, git,
|
|
12
|
+
* spawned children) uses absolute paths and explicit child cwds. `pinned()` sections are mutually exclusive through an
|
|
13
|
+
* async mutex; nothing outside them waits for it.
|
|
14
|
+
*/
|
|
15
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
16
|
+
// pinned(): async mutex + chdir + post-chdir realpath check + finally restore
|
|
17
|
+
class AsyncMutex {
|
|
18
|
+
tail = Promise.resolve();
|
|
19
|
+
holders = 0;
|
|
20
|
+
/** When the current holder acquired the lock (the files host's hung-IO watchdog reads it). */
|
|
21
|
+
heldSince = null;
|
|
22
|
+
get busy() { return this.holders > 0; }
|
|
23
|
+
/** FIFO. A waiter whose signal aborts leaves the queue without ever holding the lock (and without blocking it). */
|
|
24
|
+
async acquire(signal) {
|
|
25
|
+
throwIfAborted(signal);
|
|
26
|
+
const previous = this.tail;
|
|
27
|
+
let open;
|
|
28
|
+
const mine = new Promise(resolve => { open = resolve; });
|
|
29
|
+
this.tail = previous.then(() => mine);
|
|
30
|
+
if (signal) {
|
|
31
|
+
let onAbort;
|
|
32
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
33
|
+
onAbort = () => reject(cancelledError());
|
|
34
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
35
|
+
});
|
|
36
|
+
try {
|
|
37
|
+
await Promise.race([previous, aborted]);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
void previous.then(open);
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
signal.removeEventListener('abort', onAbort);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
else
|
|
48
|
+
await previous;
|
|
49
|
+
this.holders++;
|
|
50
|
+
this.heldSince = Date.now();
|
|
51
|
+
let released = false;
|
|
52
|
+
return () => { if (released)
|
|
53
|
+
return; released = true; this.holders--; this.heldSince = null; open(); };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const cwdLock = new AsyncMutex();
|
|
57
|
+
/** Marks the async context of a running section, so a nested `pinned()` fails instead of waiting for itself forever. */
|
|
58
|
+
const section = new AsyncLocalStorage();
|
|
59
|
+
/** True while some `pinned()` section holds the cwd (diagnostics and tests). */
|
|
60
|
+
export function cwdPinned() { return cwdLock.busy; }
|
|
61
|
+
/** How long the current `pinned()` section has held the cwd, or 0 when none does (hung-IO watchdog). */
|
|
62
|
+
export function pinnedForMs(now = Date.now()) { return cwdLock.heldSince === null ? 0 : Math.max(0, now - cwdLock.heldSince); }
|
|
63
|
+
/** configured-roots / read-only: the pinned directory must be exactly the validated (canonical) parent. */
|
|
64
|
+
export const exactParent = (pinnedReal, parent) => {
|
|
65
|
+
if (pinnedReal !== parent)
|
|
66
|
+
throw new FileError('invalid_path', 'File parent escaped allowed roots');
|
|
67
|
+
};
|
|
68
|
+
/** request-root: the pinned directory must still be the canonical root or below it (today's file worker check). */
|
|
69
|
+
export function withinRoot(canonicalRoot) {
|
|
70
|
+
return pinnedReal => {
|
|
71
|
+
const rel = path.relative(canonicalRoot, pinnedReal);
|
|
72
|
+
if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
|
|
73
|
+
throw new FileError('invalid_path', 'File parent escaped the configured workspace');
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
export async function pinned(parent, action, options = {}) {
|
|
78
|
+
// The lock is not re-entrant. (A continuation that outlives its section sees `active: false` and may pin normally.)
|
|
79
|
+
if (section.getStore()?.active)
|
|
80
|
+
throw new FileError('io_error', 'Nested pinned() section');
|
|
81
|
+
const release = await cwdLock.acquire(options.signal);
|
|
82
|
+
let active = false;
|
|
83
|
+
const leaf = (target) => {
|
|
84
|
+
if (!active)
|
|
85
|
+
throw new FileError('io_error', 'File directory is not pinned');
|
|
86
|
+
if (target === parent)
|
|
87
|
+
return '.';
|
|
88
|
+
if (path.dirname(target) !== parent)
|
|
89
|
+
throw new FileError('invalid_path', 'File path escaped pinned directory');
|
|
90
|
+
return path.basename(target);
|
|
91
|
+
};
|
|
92
|
+
const pin = {
|
|
93
|
+
parent,
|
|
94
|
+
leaf,
|
|
95
|
+
async regularHandle(target, flags = constants.O_RDONLY) {
|
|
96
|
+
const handle = await fs.open(leaf(target), flags | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
97
|
+
try {
|
|
98
|
+
if (!(await handle.stat()).isFile())
|
|
99
|
+
throw new FileError('not_regular_file', 'Not a regular file');
|
|
100
|
+
return handle;
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
await handle.close();
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
let previous;
|
|
109
|
+
const marker = { active: true };
|
|
110
|
+
try {
|
|
111
|
+
throwIfAborted(options.signal);
|
|
112
|
+
previous = process.cwd();
|
|
113
|
+
process.chdir(parent);
|
|
114
|
+
(options.verify ?? exactParent)(await fs.realpath('.'), parent);
|
|
115
|
+
active = true;
|
|
116
|
+
return await section.run(marker, () => action(pin));
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
active = false;
|
|
120
|
+
marker.active = false;
|
|
121
|
+
try {
|
|
122
|
+
if (previous !== undefined)
|
|
123
|
+
process.chdir(previous);
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
release();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
131
|
+
// request-root: relative slash paths below a server-given root (was filePaths.ts)
|
|
132
|
+
/** Validate portable slash components before touching the filesystem. */
|
|
133
|
+
export function validateRelativePath(relative) {
|
|
134
|
+
if (!relative || relative.includes('\\') || relative.includes(':') || relative.includes('\0') || path.isAbsolute(relative)) {
|
|
135
|
+
throw new FileError('invalid_path', 'Path must be relative to the selected workspace');
|
|
136
|
+
}
|
|
137
|
+
const components = relative.split('/');
|
|
138
|
+
if (components.some(component => !component || component === '.' || component === '..')) {
|
|
139
|
+
throw new FileError('invalid_path', 'Path contains an unsafe component');
|
|
140
|
+
}
|
|
141
|
+
return components;
|
|
142
|
+
}
|
|
143
|
+
/** A request root must be absolute: a relative one would resolve against whatever directory a section has pinned. */
|
|
144
|
+
export function assertAbsoluteRoot(root) {
|
|
145
|
+
if (typeof root !== 'string' || !path.isAbsolute(root) || root.includes('\0')) {
|
|
146
|
+
throw new FileError('invalid_path', 'Workspace root must be an absolute path');
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
export async function canonicalRoot(root) {
|
|
150
|
+
assertAbsoluteRoot(root);
|
|
151
|
+
try {
|
|
152
|
+
return await fs.realpath(root);
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
throw toFileError(error);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/** The configured root may itself be a symlink; none of its descendants may be. */
|
|
159
|
+
export async function resolveRelativeFile(root, relative, allowMissingLeaf) {
|
|
160
|
+
const components = validateRelativePath(relative);
|
|
161
|
+
let cursor = await canonicalRoot(root);
|
|
162
|
+
for (let index = 0; index < components.length; index++) {
|
|
163
|
+
cursor = path.join(cursor, components[index]);
|
|
164
|
+
let stat;
|
|
165
|
+
try {
|
|
166
|
+
stat = await fs.lstat(cursor);
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
if (error.code === 'ENOENT' && allowMissingLeaf && index === components.length - 1)
|
|
170
|
+
return cursor;
|
|
171
|
+
throw toFileError(error);
|
|
172
|
+
}
|
|
173
|
+
if (stat.isSymbolicLink())
|
|
174
|
+
throw new FileError('invalid_path', 'Symbolic links are not allowed');
|
|
175
|
+
if (index === components.length - 1) {
|
|
176
|
+
if (!stat.isFile())
|
|
177
|
+
throw new FileError('not_regular_file', 'Target is not a regular file');
|
|
178
|
+
}
|
|
179
|
+
else if (!stat.isDirectory()) {
|
|
180
|
+
throw new FileError('not_regular_file', 'Parent is not a directory');
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return cursor;
|
|
184
|
+
}
|
|
185
|
+
/** Resolve only a leaf relative to an already pinned process cwd; never turn it back into an absolute pathname. */
|
|
186
|
+
export async function resolvePinnedLeaf(_root, relative, allowMissingLeaf) {
|
|
187
|
+
const components = validateRelativePath(relative);
|
|
188
|
+
if (components.length !== 1)
|
|
189
|
+
throw new FileError('invalid_path', 'Pinned file operation requires one path component');
|
|
190
|
+
try {
|
|
191
|
+
const entry = await fs.lstat(relative);
|
|
192
|
+
if (entry.isSymbolicLink())
|
|
193
|
+
throw new FileError('invalid_path', 'Symbolic links are not allowed');
|
|
194
|
+
if (!entry.isFile())
|
|
195
|
+
throw new FileError('not_regular_file', 'Target is not a regular file');
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
if (error.code !== 'ENOENT' || !allowMissingLeaf)
|
|
199
|
+
throw toFileError(error);
|
|
200
|
+
}
|
|
201
|
+
return relative;
|
|
202
|
+
}
|
|
203
|
+
/** Every component of a directory listing target must exist, be a directory, and not be a symlink; '' is the root. */
|
|
204
|
+
export async function resolveRelativeDir(root, relative) {
|
|
205
|
+
const components = relative === '' ? [] : validateRelativePath(relative);
|
|
206
|
+
let cursor = await canonicalRoot(root);
|
|
207
|
+
for (const component of components) {
|
|
208
|
+
cursor = path.join(cursor, component);
|
|
209
|
+
let entry;
|
|
210
|
+
try {
|
|
211
|
+
entry = await fs.lstat(cursor);
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
throw toFileError(error);
|
|
215
|
+
}
|
|
216
|
+
if (entry.isSymbolicLink())
|
|
217
|
+
throw new FileError('invalid_path', 'Symbolic links are not allowed');
|
|
218
|
+
if (!entry.isDirectory())
|
|
219
|
+
throw new FileError('not_regular_file', 'Target is not a directory');
|
|
220
|
+
}
|
|
221
|
+
return cursor;
|
|
222
|
+
}
|
|
223
|
+
/** Pinned variant: '' lists the pinned cwd itself, otherwise exactly one component. */
|
|
224
|
+
export async function resolvePinnedDir(_root, relative) {
|
|
225
|
+
const components = relative === '' ? [] : validateRelativePath(relative);
|
|
226
|
+
if (components.length > 1)
|
|
227
|
+
throw new FileError('invalid_path', 'Pinned directory operation requires at most one path component');
|
|
228
|
+
const target = relative === '' ? '.' : path.join('.', relative);
|
|
229
|
+
let entry;
|
|
230
|
+
try {
|
|
231
|
+
entry = await fs.lstat(target);
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
throw toFileError(error);
|
|
235
|
+
}
|
|
236
|
+
if (entry.isSymbolicLink())
|
|
237
|
+
throw new FileError('invalid_path', 'Symbolic links are not allowed');
|
|
238
|
+
if (!entry.isDirectory())
|
|
239
|
+
throw new FileError('not_regular_file', 'Target is not a directory');
|
|
240
|
+
return target;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Locate the deepest existing ancestor for a directory creation. Without parents the anchor must be the
|
|
244
|
+
* target's own parent (only the leaf may be missing); with parents any prefix may be missing. Existing
|
|
245
|
+
* components must be directories and never symlinks; an existing leaf counts as "already created".
|
|
246
|
+
*/
|
|
247
|
+
export async function resolveRelativeDirCreate(root, relative, parents) {
|
|
248
|
+
const components = validateRelativePath(relative);
|
|
249
|
+
let cursor = await canonicalRoot(root);
|
|
250
|
+
let anchorIndex = 0;
|
|
251
|
+
for (; anchorIndex < components.length; anchorIndex++) {
|
|
252
|
+
const candidate = path.join(cursor, components[anchorIndex]);
|
|
253
|
+
let entry;
|
|
254
|
+
try {
|
|
255
|
+
entry = await fs.lstat(candidate);
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
if (error.code !== 'ENOENT')
|
|
259
|
+
throw toFileError(error);
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
if (entry.isSymbolicLink())
|
|
263
|
+
throw new FileError('invalid_path', 'Symbolic links are not allowed');
|
|
264
|
+
if (!entry.isDirectory())
|
|
265
|
+
throw new FileError('not_found', 'Path is not a directory');
|
|
266
|
+
cursor = candidate;
|
|
267
|
+
}
|
|
268
|
+
const missing = components.slice(anchorIndex);
|
|
269
|
+
if (!parents && missing.length > 1) {
|
|
270
|
+
throw new FileError('not_found', 'Parent directory not found');
|
|
271
|
+
}
|
|
272
|
+
return { anchor: cursor, missing };
|
|
273
|
+
}
|
|
274
|
+
/** Pinned variant: create the missing components below the pinned cwd, one leaf at a time. */
|
|
275
|
+
export async function resolvePinnedDirCreate(_root, relative, _parents) {
|
|
276
|
+
const components = relative === '' ? [] : validateRelativePath(relative);
|
|
277
|
+
return { anchor: '.', missing: components };
|
|
278
|
+
}
|
|
279
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
280
|
+
// configured-roots / read-only: absolute paths (was StructuredFiles.resolve)
|
|
281
|
+
/**
|
|
282
|
+
* Canonical roots and no descendant symlinks. Read-only previews may follow an existing alias, then pin and check its
|
|
283
|
+
* canonical parent; writable operations keep their allowed-root and no-symlink fence. Errors that come straight from
|
|
284
|
+
* the filesystem (ENOENT, EACCES, ...) are rethrown as they are: their messages are what `files.*` callers see today.
|
|
285
|
+
*/
|
|
286
|
+
export async function resolveAbsolute(scope, raw, missingLeaf = false, mutation = false, followLeaf = true) {
|
|
287
|
+
if (scope.kind !== 'configured-roots' && scope.kind !== 'read-only' && scope.kind !== 'filesystem') {
|
|
288
|
+
throw new FileError('invalid_argument', 'Absolute paths are not accepted in this scope');
|
|
289
|
+
}
|
|
290
|
+
const readOnly = scope.kind === 'read-only';
|
|
291
|
+
if (!path.isAbsolute(raw) || raw.includes('\0') || raw.includes('\\') || raw.split(path.sep).includes('..') || raw.length > 4096) {
|
|
292
|
+
throw new FileError('invalid_path', 'Invalid file path');
|
|
293
|
+
}
|
|
294
|
+
if (readOnly && (mutation || missingLeaf))
|
|
295
|
+
throw new FileError('permission_denied', 'Read-only file access cannot modify files');
|
|
296
|
+
if (scope.kind === 'filesystem') {
|
|
297
|
+
let target;
|
|
298
|
+
try {
|
|
299
|
+
if (followLeaf)
|
|
300
|
+
target = await fs.realpath(raw);
|
|
301
|
+
else {
|
|
302
|
+
target = path.join(await fs.realpath(path.dirname(raw)), path.basename(raw));
|
|
303
|
+
if ((await fs.lstat(target)).isSymbolicLink())
|
|
304
|
+
throw new FileError('invalid_path', 'Cannot mutate a symbolic link');
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
if (!missingLeaf || error.code !== 'ENOENT')
|
|
309
|
+
throw error;
|
|
310
|
+
target = path.join(await fs.realpath(path.dirname(raw)), path.basename(raw));
|
|
311
|
+
}
|
|
312
|
+
if (mutation && target === path.parse(target).root)
|
|
313
|
+
throw new FileError('permission_denied', 'Cannot modify filesystem root');
|
|
314
|
+
return target;
|
|
315
|
+
}
|
|
316
|
+
const target = readOnly ? await fs.realpath(raw) : path.resolve(raw);
|
|
317
|
+
const root = readOnly ? path.parse(target).root : scope.roots.filter(root => inside(target, root)).sort((a, b) => b.length - a.length)[0];
|
|
318
|
+
if (!root)
|
|
319
|
+
throw new FileError('invalid_path', 'Path outside allowed roots');
|
|
320
|
+
if (mutation && !readOnly && scope.roots.includes(target))
|
|
321
|
+
throw new FileError('permission_denied', 'Cannot modify file root');
|
|
322
|
+
// Detect root replacement as well as symlink components below it.
|
|
323
|
+
if (await fs.realpath(root) !== root)
|
|
324
|
+
throw new FileError('conflict', 'File root changed');
|
|
325
|
+
let cursor = root;
|
|
326
|
+
const parts = path.relative(root, target).split(path.sep).filter(Boolean);
|
|
327
|
+
for (let i = 0; i < parts.length; i++) {
|
|
328
|
+
cursor = path.join(cursor, parts[i]);
|
|
329
|
+
let stat;
|
|
330
|
+
try {
|
|
331
|
+
stat = await fs.lstat(cursor);
|
|
332
|
+
}
|
|
333
|
+
catch (error) {
|
|
334
|
+
if (error.code === 'ENOENT' && missingLeaf && i === parts.length - 1)
|
|
335
|
+
return target;
|
|
336
|
+
throw error;
|
|
337
|
+
}
|
|
338
|
+
if (stat.isSymbolicLink())
|
|
339
|
+
throw new FileError('invalid_path', 'Symbolic links are not allowed');
|
|
340
|
+
if (i < parts.length - 1 && !stat.isDirectory())
|
|
341
|
+
throw new FileError('not_regular_file', 'Parent is not a directory');
|
|
342
|
+
}
|
|
343
|
+
return target;
|
|
344
|
+
}
|
|
345
|
+
/** The descriptor stays valid after the pin ends; media children inherit it and never re-open the path. */
|
|
346
|
+
export async function openMediaSource(scope, raw) {
|
|
347
|
+
assertScopeAllows(scope, 'media');
|
|
348
|
+
const target = await resolveAbsolute(scope, raw, false, false);
|
|
349
|
+
return pinned(path.dirname(target), pin => pin.regularHandle(target));
|
|
350
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
export const MAX_ENTRIES = 200_000;
|
|
5
|
+
const WALK_BUDGET_MS = 1_500;
|
|
6
|
+
const GIT_TIMEOUT_MS = 3_000;
|
|
7
|
+
// Short: agents write files through terminals the keeper never sees.
|
|
8
|
+
const CACHE_TTL_MS = 10_000;
|
|
9
|
+
const CACHE_SCOPES = 4;
|
|
10
|
+
const SKIPPED_DIRECTORIES = new Set(['.git', '.hg', '.svn', 'node_modules']);
|
|
11
|
+
function fromEntries(entries, truncated, source) {
|
|
12
|
+
const paths = [];
|
|
13
|
+
const directories = new Set();
|
|
14
|
+
for (const entry of entries) {
|
|
15
|
+
if (entry.directory)
|
|
16
|
+
directories.add(paths.length);
|
|
17
|
+
paths.push(entry.relative);
|
|
18
|
+
}
|
|
19
|
+
return { paths, directories, truncated, source };
|
|
20
|
+
}
|
|
21
|
+
/** Files git tracks or would track under [scope], plus every directory on the way to them. */
|
|
22
|
+
export async function gitIndex(scope, skip = () => false) {
|
|
23
|
+
const output = await new Promise(resolve => {
|
|
24
|
+
execFile('git', [
|
|
25
|
+
// A repository's own config must not run a command: fsmonitor is the one ls-files would call.
|
|
26
|
+
'-c', 'core.fsmonitor=false', '-c', 'core.quotePath=false',
|
|
27
|
+
'ls-files', '-z', '--cached', '--others', '--exclude-standard',
|
|
28
|
+
], { cwd: scope, timeout: GIT_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' } }, (error, stdout) => resolve(error ? null : stdout));
|
|
29
|
+
});
|
|
30
|
+
if (output === null)
|
|
31
|
+
return null;
|
|
32
|
+
const seen = new Set();
|
|
33
|
+
const entries = [];
|
|
34
|
+
let truncated = false;
|
|
35
|
+
for (const file of output.split('\0')) {
|
|
36
|
+
if (!file || seen.has(file))
|
|
37
|
+
continue;
|
|
38
|
+
// Same hiding rule as the walk and files.list (upload staging directories, …), per segment.
|
|
39
|
+
if (file.split('/').some(segment => segment && skip(segment)))
|
|
40
|
+
continue;
|
|
41
|
+
if (entries.length >= MAX_ENTRIES) {
|
|
42
|
+
truncated = true;
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
let slash = file.indexOf('/');
|
|
46
|
+
while (slash > 0) {
|
|
47
|
+
const directory = file.slice(0, slash);
|
|
48
|
+
if (!seen.has(directory)) {
|
|
49
|
+
seen.add(directory);
|
|
50
|
+
entries.push({ relative: directory, directory: true });
|
|
51
|
+
}
|
|
52
|
+
slash = file.indexOf('/', slash + 1);
|
|
53
|
+
}
|
|
54
|
+
// An untracked nested repository is listed once, as `name/`: a directory, not a file.
|
|
55
|
+
if (file.endsWith('/'))
|
|
56
|
+
continue;
|
|
57
|
+
seen.add(file);
|
|
58
|
+
entries.push({ relative: file, directory: false });
|
|
59
|
+
}
|
|
60
|
+
return entries.length ? fromEntries(entries, truncated, 'git') : null;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Breadth-first under [scope]; symbolic links, special files and [skip]-named entries are left out.
|
|
64
|
+
* Directories on another device are listed but never entered: a mounted volume or network share
|
|
65
|
+
* (`/Volumes/*`, autofs, NFS) can be enormous or hang an `opendir` that no time budget interrupts,
|
|
66
|
+
* which would stall that search indefinitely.
|
|
67
|
+
*/
|
|
68
|
+
export async function walkIndex(scope, skip = () => false, budgetMs = WALK_BUDGET_MS) {
|
|
69
|
+
const deadline = Date.now() + budgetMs;
|
|
70
|
+
const entries = [];
|
|
71
|
+
const queue = [''];
|
|
72
|
+
let truncated = false;
|
|
73
|
+
let visited = 0;
|
|
74
|
+
let device;
|
|
75
|
+
try {
|
|
76
|
+
device = (await fs.stat(scope)).dev;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return fromEntries([], false, 'walk');
|
|
80
|
+
}
|
|
81
|
+
outer: for (let head = 0; head < queue.length; head++) {
|
|
82
|
+
const relative = queue[head];
|
|
83
|
+
if (Date.now() > deadline) {
|
|
84
|
+
truncated = true;
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
let listing;
|
|
88
|
+
try {
|
|
89
|
+
listing = await fs.opendir(relative ? path.join(scope, relative) : scope);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
for await (const entry of listing) {
|
|
96
|
+
if (entries.length >= MAX_ENTRIES || (++visited % 256 === 0 && Date.now() > deadline)) {
|
|
97
|
+
truncated = true;
|
|
98
|
+
break outer;
|
|
99
|
+
}
|
|
100
|
+
if (entry.isSymbolicLink() || skip(entry.name))
|
|
101
|
+
continue;
|
|
102
|
+
const child = relative ? `${relative}/${entry.name}` : entry.name;
|
|
103
|
+
if (entry.isDirectory()) {
|
|
104
|
+
entries.push({ relative: child, directory: true });
|
|
105
|
+
if (SKIPPED_DIRECTORIES.has(entry.name))
|
|
106
|
+
continue;
|
|
107
|
+
try {
|
|
108
|
+
if ((await fs.lstat(path.join(scope, child))).dev === device)
|
|
109
|
+
queue.push(child);
|
|
110
|
+
}
|
|
111
|
+
catch { /* Gone: nothing to enter. */ }
|
|
112
|
+
}
|
|
113
|
+
else if (entry.isFile())
|
|
114
|
+
entries.push({ relative: child, directory: false });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch { /* A directory that vanished or denied mid-read keeps what it already gave. */ }
|
|
118
|
+
finally {
|
|
119
|
+
await listing.close().catch(() => { });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return fromEntries(entries, truncated, 'walk');
|
|
123
|
+
}
|
|
124
|
+
export class FileIndexCache {
|
|
125
|
+
skip;
|
|
126
|
+
now;
|
|
127
|
+
avoidGit;
|
|
128
|
+
scopes = new Map();
|
|
129
|
+
constructor(skip = () => false, now = Date.now,
|
|
130
|
+
/** Scopes where git's answer is not the file tree (a dotfiles repo at home ignores `*`). */
|
|
131
|
+
avoidGit = () => false) {
|
|
132
|
+
this.skip = skip;
|
|
133
|
+
this.now = now;
|
|
134
|
+
this.avoidGit = avoidGit;
|
|
135
|
+
}
|
|
136
|
+
// Bumped by every invalidate(): a build that overlapped one may hold a pre-mutation list, so it is not cached.
|
|
137
|
+
generation = 0;
|
|
138
|
+
async get(scope) {
|
|
139
|
+
const cached = this.scopes.get(scope);
|
|
140
|
+
if (cached && this.now() - cached.at < CACHE_TTL_MS)
|
|
141
|
+
return cached.index;
|
|
142
|
+
const generation = this.generation;
|
|
143
|
+
const index = (this.avoidGit(scope) ? null : await gitIndex(scope, this.skip)) ?? (await walkIndex(scope, this.skip));
|
|
144
|
+
if (generation !== this.generation)
|
|
145
|
+
return index;
|
|
146
|
+
this.scopes.delete(scope);
|
|
147
|
+
this.scopes.set(scope, { index, at: this.now() });
|
|
148
|
+
while (this.scopes.size > CACHE_SCOPES)
|
|
149
|
+
this.scopes.delete(this.scopes.keys().next().value);
|
|
150
|
+
return index;
|
|
151
|
+
}
|
|
152
|
+
invalidate() { this.generation++; this.scopes.clear(); }
|
|
153
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quick-open style fuzzy matching over relative paths (fzf v1 shape, no dependency).
|
|
3
|
+
*
|
|
4
|
+
* A query is whitespace-separated terms; every term must match as a subsequence. Lowercase terms
|
|
5
|
+
* match case-insensitively, a term with an uppercase letter matches case-sensitively (smart case).
|
|
6
|
+
* A term without `/` is tried against the basename first: `ctrl` should rank `SessionFilesController.kt`
|
|
7
|
+
* above `controllers/readme.md`. A term with `/` always matches the whole path.
|
|
8
|
+
*
|
|
9
|
+
* Scoring per matched char, bonuses for word starts (after `/`, `_`, `-`, `.`, space, or a camelCase
|
|
10
|
+
* hump) and for runs; gaps cost. Ties go to the shorter path, then to the path itself.
|
|
11
|
+
*/
|
|
12
|
+
const SCORE_MATCH = 16;
|
|
13
|
+
const GAP_START = -3;
|
|
14
|
+
const GAP_EXTENSION = -1;
|
|
15
|
+
const BONUS_SLASH = 9;
|
|
16
|
+
const BONUS_BOUNDARY = 8;
|
|
17
|
+
const BONUS_CAMEL = 7;
|
|
18
|
+
const BONUS_CONSECUTIVE = -(GAP_START + GAP_EXTENSION);
|
|
19
|
+
const BONUS_FIRST_CHAR_MULTIPLIER = 2;
|
|
20
|
+
const BONUS_BASENAME = 24;
|
|
21
|
+
const BONUS_BASENAME_PREFIX = 16;
|
|
22
|
+
const BONUS_BASENAME_EXACT = 48;
|
|
23
|
+
function isUpper(c) { return c >= 'A' && c <= 'Z'; }
|
|
24
|
+
function isLower(c) { return c >= 'a' && c <= 'z'; }
|
|
25
|
+
function isDigit(c) { return c >= '0' && c <= '9'; }
|
|
26
|
+
function bonusAt(text, i, start) {
|
|
27
|
+
if (i === start)
|
|
28
|
+
return BONUS_SLASH;
|
|
29
|
+
const prev = text[i - 1];
|
|
30
|
+
const cur = text[i];
|
|
31
|
+
if (prev === '/')
|
|
32
|
+
return BONUS_SLASH;
|
|
33
|
+
if (prev === '_' || prev === '-' || prev === '.' || prev === ' ')
|
|
34
|
+
return BONUS_BOUNDARY;
|
|
35
|
+
if (isLower(prev) && isUpper(cur))
|
|
36
|
+
return BONUS_CAMEL;
|
|
37
|
+
if (!isDigit(prev) && isDigit(cur))
|
|
38
|
+
return BONUS_CAMEL;
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
/** Greedy left-to-right from [from] (where `term[0]` sits), scored; null when the term runs out of text. */
|
|
42
|
+
function scoreFrom(text, subject, term, from, start) {
|
|
43
|
+
const positions = [];
|
|
44
|
+
let score = 0;
|
|
45
|
+
let inGap = false;
|
|
46
|
+
let run = 0;
|
|
47
|
+
let runBonus = 0;
|
|
48
|
+
let t = 0;
|
|
49
|
+
for (let i = from; i < subject.length && t < term.length; i++) {
|
|
50
|
+
if (subject[i] === term[t]) {
|
|
51
|
+
let bonus = bonusAt(text, i, start);
|
|
52
|
+
if (run > 0)
|
|
53
|
+
bonus = Math.max(bonus, runBonus, BONUS_CONSECUTIVE);
|
|
54
|
+
else
|
|
55
|
+
runBonus = bonus;
|
|
56
|
+
score += SCORE_MATCH + (t === 0 ? bonus * BONUS_FIRST_CHAR_MULTIPLIER : bonus);
|
|
57
|
+
positions.push(i);
|
|
58
|
+
t++;
|
|
59
|
+
run++;
|
|
60
|
+
inGap = false;
|
|
61
|
+
}
|
|
62
|
+
else if (t > 0) {
|
|
63
|
+
score += inGap ? GAP_EXTENSION : GAP_START;
|
|
64
|
+
inGap = true;
|
|
65
|
+
run = 0;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return t === term.length ? { score, positions } : null;
|
|
69
|
+
}
|
|
70
|
+
const WORD_START_CANDIDATES = 8;
|
|
71
|
+
/**
|
|
72
|
+
* One term against `text[start..]`. fzf v1: the forward pass finds the leftmost end, the backward
|
|
73
|
+
* pass the latest start for it, and that tight window is scored. Pure v1 misses `sfc` →
|
|
74
|
+
* **S**ession**F**iles**C**ontroller (it settles on the last `s` of "Session"), so the first few
|
|
75
|
+
* word starts holding `term[0]` before that window are scored greedily too, and the best one wins.
|
|
76
|
+
*/
|
|
77
|
+
export function matchTerm(text, term, start = 0) {
|
|
78
|
+
if (!term.length)
|
|
79
|
+
return { score: 0, positions: [] };
|
|
80
|
+
const caseSensitive = term !== term.toLowerCase();
|
|
81
|
+
const subject = caseSensitive ? text : text.toLowerCase();
|
|
82
|
+
let t = 0;
|
|
83
|
+
let end = -1;
|
|
84
|
+
for (let i = start; i < subject.length; i++) {
|
|
85
|
+
if (subject[i] === term[t] && ++t === term.length) {
|
|
86
|
+
end = i;
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (end < 0)
|
|
91
|
+
return null;
|
|
92
|
+
let first = end;
|
|
93
|
+
t = term.length - 1;
|
|
94
|
+
for (let i = end; i >= start; i--) {
|
|
95
|
+
if (subject[i] === term[t]) {
|
|
96
|
+
first = i;
|
|
97
|
+
if (--t < 0)
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
let best = scoreFrom(text, subject, term, first, start);
|
|
102
|
+
let tried = 0;
|
|
103
|
+
for (let i = start; i < first && tried < WORD_START_CANDIDATES; i++) {
|
|
104
|
+
if (subject[i] !== term[0] || bonusAt(text, i, start) === 0)
|
|
105
|
+
continue;
|
|
106
|
+
tried++;
|
|
107
|
+
const candidate = scoreFrom(text, subject, term, i, start);
|
|
108
|
+
if (candidate && candidate.score > best.score)
|
|
109
|
+
best = candidate;
|
|
110
|
+
}
|
|
111
|
+
return best;
|
|
112
|
+
}
|
|
113
|
+
/** All terms of [query] against one relative path, or null when any term misses. */
|
|
114
|
+
export function matchPath(relative, terms) {
|
|
115
|
+
const base = relative.lastIndexOf('/') + 1;
|
|
116
|
+
const basename = relative.slice(base).toLowerCase();
|
|
117
|
+
let score = 0;
|
|
118
|
+
const positions = new Set();
|
|
119
|
+
for (const term of terms) {
|
|
120
|
+
let match = null;
|
|
121
|
+
let bonus = 0;
|
|
122
|
+
if (!term.includes('/')) {
|
|
123
|
+
match = matchTerm(relative, term, base);
|
|
124
|
+
if (match) {
|
|
125
|
+
const lower = term.toLowerCase();
|
|
126
|
+
bonus = basename === lower ? BONUS_BASENAME_EXACT : basename.startsWith(lower) ? BONUS_BASENAME_PREFIX : BONUS_BASENAME;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
match ??= matchTerm(relative, term);
|
|
130
|
+
if (!match)
|
|
131
|
+
return null;
|
|
132
|
+
score += match.score + bonus;
|
|
133
|
+
for (const position of match.positions)
|
|
134
|
+
positions.add(position);
|
|
135
|
+
}
|
|
136
|
+
return { score, positions: [...positions].sort((a, b) => a - b) };
|
|
137
|
+
}
|
|
138
|
+
export function queryTerms(query) {
|
|
139
|
+
return query.trim().split(/\s+/).filter(Boolean);
|
|
140
|
+
}
|
|
141
|
+
/** Every matching index of [paths], best first: score, then shorter path, then the path itself. */
|
|
142
|
+
export function rankPaths(paths, query) {
|
|
143
|
+
const terms = queryTerms(query);
|
|
144
|
+
if (!terms.length)
|
|
145
|
+
return [];
|
|
146
|
+
const ranked = [];
|
|
147
|
+
for (let index = 0; index < paths.length; index++) {
|
|
148
|
+
const match = matchPath(paths[index], terms);
|
|
149
|
+
if (match)
|
|
150
|
+
ranked.push({ index, score: match.score, positions: match.positions });
|
|
151
|
+
}
|
|
152
|
+
ranked.sort((a, b) => b.score - a.score || paths[a.index].length - paths[b.index].length
|
|
153
|
+
|| (paths[a.index] < paths[b.index] ? -1 : paths[a.index] > paths[b.index] ? 1 : 0));
|
|
154
|
+
return ranked;
|
|
155
|
+
}
|