@: directories complete, not just files
walk({includeDirs}) yields src/ and src/ui/ with trailing slash, only
on the @ path. matchPaths ranks dirs before files for the same prefix
so @src/ surfaces the directory itself. Insertion keeps the slash.
This commit is contained in:
@@ -108,8 +108,6 @@ Not bugs exactly, but things that will bite someone.
|
|||||||
screen through later ones until the turn ends.
|
screen through later ones until the turn ends.
|
||||||
- **An interrupted command's effects are unknown, and the model is told so.** Nothing can know
|
- **An interrupted command's effects are unknown, and the model is told so.** Nothing can know
|
||||||
how far a half-run migration got.
|
how far a half-run migration got.
|
||||||
- **`@` completion lists files, not directories.** `@src/` narrows correctly, but you cannot
|
|
||||||
complete to `src/` itself, because the walker only yields files.
|
|
||||||
- **An installed skill is a stranger's words in your system prompt.** The install shows the
|
- **An installed skill is a stranger's words in your system prompt.** The install shows the
|
||||||
body first and `/skills` records the origin, but nothing re-checks it later: a registry that
|
body first and `/skills` records the origin, but nothing re-checks it later: a registry that
|
||||||
changes a URL's contents affects the next install, not one already on disk.
|
changes a URL's contents affects the next install, not one already on disk.
|
||||||
|
|||||||
+1
-1
@@ -403,7 +403,7 @@ const hooks: AppHooks = {
|
|||||||
instructionFiles: () => instructions.map((i) => i.path),
|
instructionFiles: () => instructions.map((i) => i.path),
|
||||||
listPaths: async () => {
|
listPaths: async () => {
|
||||||
const found: string[] = [];
|
const found: string[] = [];
|
||||||
for await (const rel of walk({ limit: 5000 })) found.push(rel);
|
for await (const rel of walk({ limit: 5000, includeDirs: true })) found.push(rel);
|
||||||
return found;
|
return found;
|
||||||
},
|
},
|
||||||
fileChangeSeq: () => session.fileChangeSeq,
|
fileChangeSeq: () => session.fileChangeSeq,
|
||||||
|
|||||||
+25
-7
@@ -43,25 +43,43 @@ const MAX_MATCHES = 8;
|
|||||||
* Ties break on path length: the shallower file is more often the one meant.
|
* Ties break on path length: the shallower file is more often the one meant.
|
||||||
*/
|
*/
|
||||||
export function matchPaths(paths: readonly string[], query: string, limit = MAX_MATCHES): string[] {
|
export function matchPaths(paths: readonly string[], query: string, limit = MAX_MATCHES): string[] {
|
||||||
if (!query) return [...paths].sort((a, b) => a.length - b.length || a.localeCompare(b)).slice(0, limit);
|
if (!query) {
|
||||||
|
const dirs = paths.filter((p) => p.endsWith('/'));
|
||||||
|
const files = paths.filter((p) => !p.endsWith('/'));
|
||||||
|
const byLength = (a: string, b: string) => a.length - b.length || a.localeCompare(b);
|
||||||
|
// Empty query: prefer directories (the user just typed `@src/` and wants to go deeper)
|
||||||
|
// when available, otherwise fall back to the shallowest files.
|
||||||
|
if (dirs.length > 0) return [...dirs.sort(byLength), ...files.sort(byLength)].slice(0, limit);
|
||||||
|
return [...files.sort(byLength)].slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
const needle = query.toLowerCase();
|
const needle = query.toLowerCase();
|
||||||
const prefix: string[] = [];
|
const prefixDirs: string[] = [];
|
||||||
const substring: string[] = [];
|
const prefixFiles: string[] = [];
|
||||||
|
const substringDirs: string[] = [];
|
||||||
|
const substringFiles: string[] = [];
|
||||||
|
|
||||||
for (const path of paths) {
|
for (const path of paths) {
|
||||||
const lower = path.toLowerCase();
|
const lower = path.toLowerCase();
|
||||||
if (lower.startsWith(needle)) prefix.push(path);
|
const isDir = path.endsWith('/');
|
||||||
else if (lower.includes(needle)) substring.push(path);
|
const hit = lower.startsWith(needle) ? 'prefix' : lower.includes(needle) ? 'substring' : undefined;
|
||||||
|
if (!hit) continue;
|
||||||
|
if (hit === 'prefix') (isDir ? prefixDirs : prefixFiles).push(path);
|
||||||
|
else (isDir ? substringDirs : substringFiles).push(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
const byLength = (a: string, b: string) => a.length - b.length || a.localeCompare(b);
|
const byLength = (a: string, b: string) => a.length - b.length || a.localeCompare(b);
|
||||||
return [...prefix.sort(byLength), ...substring.sort(byLength)].slice(0, limit);
|
return [
|
||||||
|
...prefixDirs.sort(byLength),
|
||||||
|
...prefixFiles.sort(byLength),
|
||||||
|
...substringDirs.sort(byLength),
|
||||||
|
...substringFiles.sort(byLength),
|
||||||
|
].slice(0, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Completion = { value: string; cursor: number };
|
export type Completion = { value: string; cursor: number };
|
||||||
|
|
||||||
/** Replaces the token with a plain relative path and a trailing space. */
|
/** Replaces the token with a plain relative path and a trailing space (dirs keep their trailing `/`). */
|
||||||
export function completePath(value: string, token: PathToken, path: string): Completion {
|
export function completePath(value: string, token: PathToken, path: string): Completion {
|
||||||
const next = `${value.slice(0, token.start)}${path} ${value.slice(token.end)}`;
|
const next = `${value.slice(0, token.start)}${path} ${value.slice(token.end)}`;
|
||||||
return { value: next, cursor: token.start + path.length + 1 };
|
return { value: next, cursor: token.start + path.length + 1 };
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ export type WalkOptions = {
|
|||||||
root?: string;
|
root?: string;
|
||||||
/** Include files git would ignore. */
|
/** Include files git would ignore. */
|
||||||
noIgnore?: boolean;
|
noIgnore?: boolean;
|
||||||
|
/** Also yield directories (with trailing `/`), so `@` can complete to them. */
|
||||||
|
includeDirs?: boolean;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -130,6 +132,10 @@ export async function* walk(options: WalkOptions = {}): AsyncGenerator<string> {
|
|||||||
if (!options.noIgnore && ignored(rel, entry.isDirectory, rules)) continue;
|
if (!options.noIgnore && ignored(rel, entry.isDirectory, rules)) continue;
|
||||||
|
|
||||||
if (entry.isDirectory) {
|
if (entry.isDirectory) {
|
||||||
|
if (options.includeDirs) {
|
||||||
|
yield `${rel}/`;
|
||||||
|
if (++yielded >= limit) return;
|
||||||
|
}
|
||||||
// A symlinked directory is not descended into: it can point anywhere,
|
// A symlinked directory is not descended into: it can point anywhere,
|
||||||
// including back into the tree.
|
// including back into the tree.
|
||||||
if (entry.isLink) continue;
|
if (entry.isLink) continue;
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { expect, test } from 'bun:test';
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { walk } from '../src/ignore';
|
||||||
|
import { matchPaths, completePath, pathToken } from '../src/complete';
|
||||||
|
|
||||||
|
test('walk includeDirs yields directories with trailing slash', async () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'shiro-dirs-'));
|
||||||
|
const orig = process.cwd();
|
||||||
|
try {
|
||||||
|
process.chdir(dir);
|
||||||
|
await Bun.write('a.ts', '');
|
||||||
|
mkdirSync('src/ui', { recursive: true });
|
||||||
|
await Bun.write('src/ui/App.tsx', '');
|
||||||
|
await Bun.write('src/session.ts', '');
|
||||||
|
|
||||||
|
const out: string[] = [];
|
||||||
|
for await (const p of walk({ includeDirs: true })) out.push(p);
|
||||||
|
expect(out).toContain('src/');
|
||||||
|
expect(out).toContain('src/ui/');
|
||||||
|
expect(out).toContain('src/session.ts');
|
||||||
|
expect(out).toContain('a.ts');
|
||||||
|
} finally {
|
||||||
|
process.chdir(orig);
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('walk without includeDirs still file-only (existing behaviour)', async () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'shiro-nodirs-'));
|
||||||
|
const orig = process.cwd();
|
||||||
|
try {
|
||||||
|
process.chdir(dir);
|
||||||
|
mkdirSync('src', { recursive: true });
|
||||||
|
await Bun.write('src/a.ts', '');
|
||||||
|
const out: string[] = [];
|
||||||
|
for await (const p of walk()) out.push(p);
|
||||||
|
expect(out).not.toContain('src/');
|
||||||
|
expect(out).toContain('src/a.ts');
|
||||||
|
} finally {
|
||||||
|
process.chdir(orig);
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('matchPaths surfaces dirs for a directory prefix and keeps trailing slash through completion', () => {
|
||||||
|
const paths = ['src/', 'src/ui/', 'src/ui/App.tsx', 'src/session.ts', 'README.md'];
|
||||||
|
const hits = matchPaths(paths, 'src/');
|
||||||
|
expect(hits).toContain('src/');
|
||||||
|
expect(hits).toContain('src/ui/');
|
||||||
|
// dirs rank before files for the same prefix
|
||||||
|
expect(hits.indexOf('src/ui/')).toBeLessThan(hits.indexOf('src/ui/App.tsx'));
|
||||||
|
|
||||||
|
// inserted dir keeps trailing slash and trailing space
|
||||||
|
const tok = pathToken('@src/u', 6)!;
|
||||||
|
const { value } = completePath('@src/u', tok, 'src/ui/');
|
||||||
|
expect(value).toBe('src/ui/ ');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('matchPaths with empty query prefers dirs', () => {
|
||||||
|
const hits = matchPaths(['src/', 'a.ts', 'src/ui/', 'b.ts'], '', 3);
|
||||||
|
expect(hits[0]).toBe('src/');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user