Merge PR #3401: feat(move): interactive /move overlay with fresh session (@oldschoola)
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as fsp from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { Settings } from "../../../config/settings";
|
||||
import { getThemeByName, setThemeInstance, type Theme } from "../../theme/theme";
|
||||
import { MoveOverlay, type MoveOverlayResult, resolveExistingDirectory, resolveMovePath } from "../move-overlay";
|
||||
|
||||
// Strip SGR colors so assertions see visible text only.
|
||||
const strip = (lines: readonly string[]): string => lines.join("\n").replace(/\x1b\[[0-9;]*m/g, "");
|
||||
|
||||
describe("resolveMovePath", () => {
|
||||
it("expands ~ to homedir", () => {
|
||||
expect(resolveMovePath("~", "/anywhere")).toBe(os.homedir());
|
||||
});
|
||||
it("expands ~/sub to homedir/sub", () => {
|
||||
expect(resolveMovePath("~/foo", "/anywhere")).toBe(path.join(os.homedir(), "foo"));
|
||||
});
|
||||
it("resolves relative paths against cwd", () => {
|
||||
expect(resolveMovePath("foo/bar", "/parent")).toBe(path.resolve("/parent", "foo/bar"));
|
||||
});
|
||||
it("passes absolute paths through (normalized)", () => {
|
||||
expect(resolveMovePath("/abs/path", "/anywhere")).toBe(path.normalize("/abs/path"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveExistingDirectory", () => {
|
||||
let tmp: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-move-resolve-"));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await fsp.rm(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns the resolved path for an existing directory", () => {
|
||||
const sub = path.join(tmp, "sub");
|
||||
fs.mkdirSync(sub);
|
||||
expect(resolveExistingDirectory(sub, "/anywhere")).toBe(path.resolve(sub));
|
||||
});
|
||||
it("returns null for a non-existent path", () => {
|
||||
expect(resolveExistingDirectory(path.join(tmp, "nope"), "/anywhere")).toBeNull();
|
||||
});
|
||||
it("returns null for a file (not a directory)", () => {
|
||||
const file = path.join(tmp, "file.txt");
|
||||
fs.writeFileSync(file, "x");
|
||||
expect(resolveExistingDirectory(file, "/anywhere")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("MoveOverlay", () => {
|
||||
let tmp: string;
|
||||
let cwd: string;
|
||||
let uiTheme: Theme;
|
||||
|
||||
beforeAll(async () => {
|
||||
await Settings.init({ inMemory: true });
|
||||
const loaded = await getThemeByName("dark");
|
||||
if (!loaded) throw new Error("theme unavailable");
|
||||
uiTheme = loaded;
|
||||
setThemeInstance(uiTheme);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-move-overlay-"));
|
||||
cwd = tmp;
|
||||
fs.mkdirSync(path.join(tmp, "alpha"));
|
||||
fs.mkdirSync(path.join(tmp, "beta"));
|
||||
fs.mkdirSync(path.join(tmp, ".hidden"));
|
||||
fs.writeFileSync(path.join(tmp, "file.txt"), "x");
|
||||
});
|
||||
afterEach(async () => {
|
||||
await fsp.rm(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("renders a box with a title and input prompt", () => {
|
||||
const overlay = new MoveOverlay(cwd, () => {});
|
||||
const text = strip(overlay.render(80));
|
||||
expect(text).toContain("Move to directory");
|
||||
expect(text).toContain("Path:");
|
||||
});
|
||||
|
||||
it("lists child directories (excluding hidden and files) on empty input", () => {
|
||||
const overlay = new MoveOverlay(cwd, () => {});
|
||||
const text = strip(overlay.render(80));
|
||||
expect(text).toContain("alpha/");
|
||||
expect(text).toContain("beta/");
|
||||
expect(text).not.toContain(".hidden/");
|
||||
expect(text).not.toContain("file.txt");
|
||||
});
|
||||
|
||||
it("filters results as the user types", () => {
|
||||
const overlay = new MoveOverlay(cwd, () => {});
|
||||
overlay.handleInput("a");
|
||||
overlay.handleInput("l");
|
||||
const text = strip(overlay.render(80));
|
||||
expect(text).toContain("alpha/");
|
||||
expect(text).not.toContain("beta/");
|
||||
});
|
||||
|
||||
it("calls done with undefined on Escape", () => {
|
||||
let result: MoveOverlayResult | undefined = "sentinel" as unknown as MoveOverlayResult;
|
||||
const overlay = new MoveOverlay(cwd, r => {
|
||||
result = r;
|
||||
});
|
||||
overlay.handleInput("\x1b");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("calls done with the highlighted directory on Enter", () => {
|
||||
let result: MoveOverlayResult | undefined;
|
||||
const overlay = new MoveOverlay(cwd, r => {
|
||||
result = r;
|
||||
});
|
||||
// First result should be "alpha/" (sorted alphabetically).
|
||||
overlay.handleInput("\r");
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.directory).toBe(path.join(cwd, "alpha"));
|
||||
});
|
||||
|
||||
it("calls done with the typed path on Enter when no results match", () => {
|
||||
let result: MoveOverlayResult | undefined;
|
||||
const overlay = new MoveOverlay(cwd, r => {
|
||||
result = r;
|
||||
});
|
||||
// Type a path that won't match any directory in cwd.
|
||||
overlay.handleInput("z");
|
||||
overlay.handleInput("z");
|
||||
overlay.handleInput("\r");
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.directory).toBe("zz");
|
||||
});
|
||||
|
||||
it("Tab accepts the highlighted suggestion into the input", () => {
|
||||
let result: MoveOverlayResult | undefined;
|
||||
const overlay = new MoveOverlay(cwd, r => {
|
||||
result = r;
|
||||
});
|
||||
overlay.handleInput("\t");
|
||||
// After tab, the input should be the full path of the first result.
|
||||
// Press Enter to confirm — the result should be the alpha directory.
|
||||
overlay.handleInput("\r");
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.directory).toBe(path.join(cwd, "alpha"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* `/move` overlay: a path input with live directory autocomplete.
|
||||
*
|
||||
* Rendered as a centered modal via `showHookCustom(..., { overlay: true })`.
|
||||
* The user types a path, Tab autocomtes the highlighted directory, and Enter
|
||||
* confirms — yielding the resolved directory string (or `undefined` on cancel).
|
||||
*/
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { type Component, CURSOR_MARKER, type Focusable, Key, matchesKey } from "@oh-my-pi/pi-tui";
|
||||
import { theme } from "../theme/theme";
|
||||
import { matchesSelectCancel, matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers";
|
||||
import { bottomBorder, row, topBorder } from "./overlay-box";
|
||||
|
||||
export interface MoveOverlayResult {
|
||||
directory: string;
|
||||
}
|
||||
|
||||
interface DirEntry {
|
||||
/** Full absolute path. */
|
||||
value: string;
|
||||
/** Display label (basename + trailing slash). */
|
||||
label: string;
|
||||
}
|
||||
|
||||
const MAX_RESULTS = 15;
|
||||
const OVERLAY_WIDTH = 68;
|
||||
|
||||
/** TTL for the directory listing cache (ms). */
|
||||
const DIR_CACHE_TTL = 500;
|
||||
const dirCache = new Map<string, { time: number; entries: string[] }>();
|
||||
|
||||
function readDirCached(dir: string): string[] {
|
||||
const now = Date.now();
|
||||
const cached = dirCache.get(dir);
|
||||
if (cached && now - cached.time < DIR_CACHE_TTL) return cached.entries;
|
||||
try {
|
||||
const entries = fs.readdirSync(dir);
|
||||
dirCache.set(dir, { time: now, entries });
|
||||
return entries;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a user-typed path (`~`, absolute, or relative to `cwd`) to an absolute path. */
|
||||
export function resolveMovePath(input: string, cwd: string): string {
|
||||
const trimmed = input.trim();
|
||||
if (trimmed === "~") return os.homedir();
|
||||
if (trimmed.startsWith("~/")) return path.join(os.homedir(), trimmed.slice(2));
|
||||
if (path.isAbsolute(trimmed)) return path.normalize(trimmed);
|
||||
return path.resolve(cwd, trimmed);
|
||||
}
|
||||
|
||||
/** If `input` resolves to an existing directory, return it; otherwise `null`. */
|
||||
export function resolveExistingDirectory(input: string, cwd: string): string | null {
|
||||
const resolved = resolveMovePath(input, cwd);
|
||||
try {
|
||||
return fs.statSync(resolved).isDirectory() ? resolved : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function listChildDirectories(dirPath: string, max: number): DirEntry[] {
|
||||
const results: DirEntry[] = [];
|
||||
const names = readDirCached(dirPath);
|
||||
for (const name of names) {
|
||||
if (results.length >= max) break;
|
||||
if (name.startsWith(".")) continue;
|
||||
const full = path.join(dirPath, name);
|
||||
try {
|
||||
if (!fs.statSync(full).isDirectory()) continue;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
results.push({ value: full, label: `${name}/` });
|
||||
}
|
||||
results.sort((a, b) => a.label.localeCompare(b.label));
|
||||
return results;
|
||||
}
|
||||
|
||||
function searchDirectories(prefix: string, cwd: string, max: number): DirEntry[] {
|
||||
if (!prefix) return listChildDirectories(cwd, max);
|
||||
|
||||
// If the prefix already resolves to an existing directory, list its children.
|
||||
const resolved = resolveExistingDirectory(prefix, cwd);
|
||||
if (resolved) return listChildDirectories(resolved, max);
|
||||
|
||||
// Otherwise, split into base dir + query and filter the base dir's children.
|
||||
const norm = prefix.replace(/\\/g, "/");
|
||||
const slashIdx = norm.lastIndexOf("/");
|
||||
let baseDir: string;
|
||||
let query: string;
|
||||
if (slashIdx === -1) {
|
||||
baseDir = cwd;
|
||||
query = prefix;
|
||||
} else {
|
||||
const base = norm.slice(0, slashIdx + 1);
|
||||
query = norm.slice(slashIdx + 1);
|
||||
baseDir = resolveMovePath(base, cwd);
|
||||
}
|
||||
|
||||
const lower = query.toLowerCase();
|
||||
const results: DirEntry[] = [];
|
||||
const names = readDirCached(baseDir);
|
||||
for (const name of names) {
|
||||
if (results.length >= max) break;
|
||||
if (name.startsWith(".")) continue;
|
||||
const full = path.join(baseDir, name);
|
||||
try {
|
||||
if (!fs.statSync(full).isDirectory()) continue;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!query || name.toLowerCase().includes(lower)) {
|
||||
results.push({ value: full, label: `${name}/` });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overlay component for `/move`: a single-line path input with a live-filtered
|
||||
* list of matching directories. Tab accepts the highlighted suggestion; Enter
|
||||
* confirms the current input (or the highlighted suggestion if the input is
|
||||
* empty); Escape cancels.
|
||||
*/
|
||||
export class MoveOverlay implements Component, Focusable {
|
||||
#focused = false;
|
||||
#input = "";
|
||||
#cursor = 0;
|
||||
#selectedIndex = 0;
|
||||
#results: DirEntry[] = [];
|
||||
#cwd: string;
|
||||
#done: (result: MoveOverlayResult | undefined) => void;
|
||||
|
||||
constructor(cwd: string, done: (result: MoveOverlayResult | undefined) => void) {
|
||||
this.#cwd = cwd;
|
||||
this.#done = done;
|
||||
// Warm the cache for the current directory so the first keystroke is instant.
|
||||
readDirCached(cwd);
|
||||
this.#updateResults();
|
||||
}
|
||||
|
||||
get focused(): boolean {
|
||||
return this.#focused;
|
||||
}
|
||||
|
||||
set focused(value: boolean) {
|
||||
this.#focused = value;
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (matchesSelectCancel(data) || matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
||||
this.#done(undefined);
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.enter) || matchesKey(data, Key.return)) {
|
||||
this.#confirm();
|
||||
return;
|
||||
}
|
||||
if (matchesSelectUp(data) || matchesKey(data, Key.up)) {
|
||||
if (this.#results.length > 0) this.#selectedIndex = Math.max(0, this.#selectedIndex - 1);
|
||||
return;
|
||||
}
|
||||
if (matchesSelectDown(data) || matchesKey(data, Key.down)) {
|
||||
if (this.#results.length > 0)
|
||||
this.#selectedIndex = Math.min(this.#results.length - 1, this.#selectedIndex + 1);
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.tab)) {
|
||||
const selected = this.#results[this.#selectedIndex];
|
||||
if (selected) {
|
||||
this.#input = selected.value;
|
||||
this.#cursor = this.#input.length;
|
||||
this.#selectedIndex = 0;
|
||||
this.#updateResults();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.left)) {
|
||||
this.#cursor = Math.max(0, this.#cursor - 1);
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.right)) {
|
||||
this.#cursor = Math.min(this.#input.length, this.#cursor + 1);
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.backspace) && this.#cursor > 0) {
|
||||
this.#input = this.#input.slice(0, this.#cursor - 1) + this.#input.slice(this.#cursor);
|
||||
this.#cursor--;
|
||||
this.#selectedIndex = 0;
|
||||
this.#updateResults();
|
||||
return;
|
||||
}
|
||||
// Printable character
|
||||
if (data.length === 1 && data.charCodeAt(0) >= 32 && data !== "\x7f") {
|
||||
this.#input = this.#input.slice(0, this.#cursor) + data + this.#input.slice(this.#cursor);
|
||||
this.#cursor++;
|
||||
this.#selectedIndex = 0;
|
||||
this.#updateResults();
|
||||
}
|
||||
}
|
||||
|
||||
render(_width: number): readonly string[] {
|
||||
const w = OVERLAY_WIDTH;
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(topBorder(w, "Move to directory"));
|
||||
lines.push(row(this.#renderInput(), w));
|
||||
lines.push(row("", w));
|
||||
|
||||
if (this.#results.length === 0 && this.#input.length > 0) {
|
||||
lines.push(row(theme.fg("dim", "No matching directories"), w));
|
||||
} else {
|
||||
for (let i = 0; i < Math.min(this.#results.length, MAX_RESULTS); i++) {
|
||||
const item = this.#results[i]!;
|
||||
const selected = i === this.#selectedIndex;
|
||||
const marker = selected ? theme.fg("accent", "▶ ") : " ";
|
||||
const label = selected ? theme.fg("accent", item.label) : theme.fg("text", item.label);
|
||||
lines.push(row(`${marker}${label}`, w));
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(row("", w));
|
||||
lines.push(row(theme.fg("dim", "Type to filter · ↑↓ navigate · Tab accept · Enter confirm · Esc cancel"), w));
|
||||
lines.push(bottomBorder(w));
|
||||
return lines;
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
#renderInput(): string {
|
||||
const prompt = theme.fg("dim", "Path: ");
|
||||
if (this.#input.length === 0) {
|
||||
const placeholder = theme.fg("dim", "Type a directory path…");
|
||||
const marker = this.#focused ? CURSOR_MARKER : "";
|
||||
return `${prompt}${placeholder}${marker}\x1b[7m \x1b[27m`;
|
||||
}
|
||||
const before = this.#input.slice(0, this.#cursor);
|
||||
const cursorChar = this.#cursor < this.#input.length ? this.#input[this.#cursor] : " ";
|
||||
const after = this.#input.slice(this.#cursor + 1);
|
||||
const marker = this.#focused ? CURSOR_MARKER : "";
|
||||
return `${prompt}${before}${marker}\x1b[7m${cursorChar}\x1b[27m${after}`;
|
||||
}
|
||||
|
||||
#updateResults(): void {
|
||||
this.#results = searchDirectories(this.#input, this.#cwd, MAX_RESULTS + 5);
|
||||
if (this.#selectedIndex >= this.#results.length) {
|
||||
this.#selectedIndex = Math.max(0, this.#results.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
#confirm(): void {
|
||||
const selected = this.#results[this.#selectedIndex];
|
||||
if (selected) {
|
||||
this.#done({ directory: selected.value });
|
||||
return;
|
||||
}
|
||||
if (this.#input.trim().length > 0) {
|
||||
this.#done({ directory: this.#input.trim() });
|
||||
return;
|
||||
}
|
||||
this.#done(undefined);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user