Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 45 additions & 4 deletions src/tui/components/at-mention/list.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { mkdir, writeFile, rm, symlink, mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import {
mkdir,
writeFile,
rm,
symlink,
mkdtemp,
readdir,
stat,
} from "node:fs/promises";
import { tmpdir, homedir } from "node:os";
import { join } from "node:path";
import { listPathSuggestions } from "./list.js";

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

test("does not browse home-relative paths", async () => {
expect(await listPathSuggestions("~/", fixture)).toEqual([]);
// Why the old `[]` pin changed (CL-7930): the submit path already expands
// `~/…` via expandHome, so completing to nothing was a dead end — the popup
// offered no way to reach a path submit accepts. Completions now list home
// in `~/` display form, so both paths agree.
test("offers home-relative completions for ~/", async () => {
const names = await readdir(homedir());
if (names.length === 0) return;
const results = await listPathSuggestions("~/", fixture);
expect(results.length).toBeGreaterThan(0);
expect(results.every((r) => r.startsWith("~/"))).toBe(true);
});

test("offers home-relative completions for bare ~", async () => {
const names = await readdir(homedir());
if (names.length === 0) return;
const results = await listPathSuggestions("~", fixture);
expect(results.length).toBeGreaterThan(0);
expect(results.every((r) => r.startsWith("~/"))).toBe(true);
});

test("filters home-relative completions by fragment", async () => {
const names = await readdir(homedir());
const probe = names.find((n) => !n.startsWith(".")) ?? names[0];
if (probe === undefined) return;
const info = await stat(join(homedir(), probe));
const expected = `~/${probe}${info.isDirectory() ? "/" : ""}`;
const results = await listPathSuggestions(`~/${probe}`, fixture);
expect(results).toContain(expected);
expect(results.every((r) => r.startsWith("~/"))).toBe(true);
});

test("returns [] for a nonexistent home-relative path", async () => {
expect(
await listPathSuggestions("~/nonexistent-path-12345-xyz/", fixture),
).toEqual([]);
});

test("follows symlinked directories", async () => {
Expand Down
34 changes: 25 additions & 9 deletions src/tui/components/at-mention/list.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { opendir, realpath } from "node:fs/promises";
import { homedir } from "node:os";
import { resolve, dirname, basename } from "node:path";

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

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

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

// Reconstruct the path the user would type. For bare-fragment prefixes
// (no slash) entries are shown relative to cwd so dirPrefix is "".
const dirPrefix = endsWithSep
? prefix
// Home-relative results keep the `~/` form so the completion inserts
// text the submit path expands verbatim.
const innerPrefix = endsWithSep
? rest
: hasSlash
? prefix.slice(0, prefix.length - fragment.length)
? rest.slice(0, rest.length - fragment.length)
: "";
const dirPrefix = homeRelative
? `~/${innerPrefix}`
: endsWithSep
? prefix
: innerPrefix;
matched.push(dirPrefix + entry.name + (entry.isDirectory() ? "/" : ""));
}

Expand Down
Loading