Skip to content

Commit ade3048

Browse files
committed
feat(tui): offer home-relative completions for at-mentions
1 parent 2074b5e commit ade3048

2 files changed

Lines changed: 70 additions & 13 deletions

File tree

src/tui/components/at-mention/list.test.ts

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
2-
import { mkdir, writeFile, rm, symlink, mkdtemp } from "node:fs/promises";
3-
import { tmpdir } from "node:os";
2+
import {
3+
mkdir,
4+
writeFile,
5+
rm,
6+
symlink,
7+
mkdtemp,
8+
readdir,
9+
stat,
10+
} from "node:fs/promises";
11+
import { tmpdir, homedir } from "node:os";
412
import { join } from "node:path";
513
import { listPathSuggestions } from "./list.js";
614

@@ -81,8 +89,41 @@ describe("listPathSuggestions", () => {
8189
expect(results).toContain("../src/");
8290
});
8391

84-
test("does not browse home-relative paths", async () => {
85-
expect(await listPathSuggestions("~/", fixture)).toEqual([]);
92+
// Why the old `[]` pin changed (CL-7930): the submit path already expands
93+
// `~/…` via expandHome, so completing to nothing was a dead end — the popup
94+
// offered no way to reach a path submit accepts. Completions now list home
95+
// in `~/` display form, so both paths agree.
96+
test("offers home-relative completions for ~/", async () => {
97+
const names = await readdir(homedir());
98+
if (names.length === 0) return;
99+
const results = await listPathSuggestions("~/", fixture);
100+
expect(results.length).toBeGreaterThan(0);
101+
expect(results.every((r) => r.startsWith("~/"))).toBe(true);
102+
});
103+
104+
test("offers home-relative completions for bare ~", async () => {
105+
const names = await readdir(homedir());
106+
if (names.length === 0) return;
107+
const results = await listPathSuggestions("~", fixture);
108+
expect(results.length).toBeGreaterThan(0);
109+
expect(results.every((r) => r.startsWith("~/"))).toBe(true);
110+
});
111+
112+
test("filters home-relative completions by fragment", async () => {
113+
const names = await readdir(homedir());
114+
const probe = names.find((n) => !n.startsWith(".")) ?? names[0];
115+
if (probe === undefined) return;
116+
const info = await stat(join(homedir(), probe));
117+
const expected = `~/${probe}${info.isDirectory() ? "/" : ""}`;
118+
const results = await listPathSuggestions(`~/${probe}`, fixture);
119+
expect(results).toContain(expected);
120+
expect(results.every((r) => r.startsWith("~/"))).toBe(true);
121+
});
122+
123+
test("returns [] for a nonexistent home-relative path", async () => {
124+
expect(
125+
await listPathSuggestions("~/nonexistent-path-12345-xyz/", fixture),
126+
).toEqual([]);
86127
});
87128

88129
test("follows symlinked directories", async () => {

src/tui/components/at-mention/list.ts

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { opendir, realpath } from "node:fs/promises";
2+
import { homedir } from "node:os";
23
import { resolve, dirname, basename } from "node:path";
34

45
const MAX_SUGGESTIONS = 20;
@@ -18,25 +19,33 @@ async function resolveDirectory(
1819
// Given a path prefix the user has typed (e.g. after @ or in a path field),
1920
// return up to MAX_SUGGESTIONS matching filesystem entries. Directories get a
2021
// trailing / so the user can drill in. Never throws — returns [] on any fs error.
22+
// Home-relative prefixes (`~`, `~/…`) list the operator's home directory and
23+
// keep the `~/` display form, matching the submit-time expandHome behavior in
24+
// mention-resolution.ts (the completion popup previously offered nothing here).
2125
export async function listPathSuggestions(
2226
prefix: string,
2327
cwd: string,
2428
): Promise<string[]> {
25-
if (prefix === "~" || prefix.startsWith("~/")) return [];
29+
// `~` alone lists home, mirroring submit-time expandHome("~") -> homedir().
30+
const homeRelative = prefix === "~" || prefix.startsWith("~/");
31+
const rest = homeRelative ? (prefix === "~" ? "" : prefix.slice(2)) : prefix;
32+
const base = homeRelative ? homedir() : cwd;
2633

2734
try {
28-
const endsWithSep = prefix.endsWith("/");
35+
const endsWithSep = homeRelative
36+
? rest.endsWith("/") || rest === ""
37+
: prefix.endsWith("/");
2938
// When prefix ends with / the user wants to list that directory.
3039
// When prefix contains a slash but doesn't end with one, split on the last
3140
// slash: the left side is the dir to list, the right side is the filter.
3241
// When prefix has NO slash at all (including the empty string), list cwd
3342
// and use the whole prefix as a filter. This is the `@` alone case which
3443
// should behave like `ls` in the current directory.
35-
const lastSlash = prefix.lastIndexOf("/");
44+
const lastSlash = rest.lastIndexOf("/");
3645
const hasSlash = lastSlash !== -1;
37-
const dir = endsWithSep ? prefix : hasSlash ? dirname(prefix) : ".";
38-
const fragment = endsWithSep ? "" : hasSlash ? basename(prefix) : prefix;
39-
const realDir = await resolveDirectory(dir, cwd);
46+
const dir = endsWithSep ? rest || "." : hasSlash ? dirname(rest) : ".";
47+
const fragment = endsWithSep ? "" : hasSlash ? basename(rest) : rest;
48+
const realDir = await resolveDirectory(dir, base);
4049
if (realDir === null) return [];
4150

4251
const matched: string[] = [];
@@ -50,11 +59,18 @@ export async function listPathSuggestions(
5059

5160
// Reconstruct the path the user would type. For bare-fragment prefixes
5261
// (no slash) entries are shown relative to cwd so dirPrefix is "".
53-
const dirPrefix = endsWithSep
54-
? prefix
62+
// Home-relative results keep the `~/` form so the completion inserts
63+
// text the submit path expands verbatim.
64+
const innerPrefix = endsWithSep
65+
? rest
5566
: hasSlash
56-
? prefix.slice(0, prefix.length - fragment.length)
67+
? rest.slice(0, rest.length - fragment.length)
5768
: "";
69+
const dirPrefix = homeRelative
70+
? `~/${innerPrefix}`
71+
: endsWithSep
72+
? prefix
73+
: innerPrefix;
5874
matched.push(dirPrefix + entry.name + (entry.isDirectory() ? "/" : ""));
5975
}
6076

0 commit comments

Comments
 (0)