@k3_2o/pi-move 0.1.0 → 0.1.1

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/Makefile CHANGED
@@ -4,17 +4,17 @@
4
4
 
5
5
  fmt:
6
6
  @echo "=== Formatting with Prettier ==="
7
- npx prettier --write '*.ts'
7
+ npx prettier --write '{index.ts,src/*.ts}'
8
8
 
9
9
  fmt-check:
10
10
  @echo "=== Checking format with Prettier ==="
11
- npx prettier --check '*.ts'
11
+ npx prettier --check '{index.ts,src/*.ts}'
12
12
 
13
13
  # --- Linting ---
14
14
 
15
15
  lint:
16
16
  @echo "=== Linting with ESLint ==="
17
- npx eslint '*.ts'
17
+ npx eslint 'index.ts' 'src/*.ts'
18
18
 
19
19
  # --- Type Checking ---
20
20
 
package/README.md CHANGED
@@ -25,7 +25,7 @@ no need to exit to make a new directory for your project
25
25
  ## Install
26
26
 
27
27
  ```bash
28
- pi install @k3_2o/pi-move
28
+ pi install npm:@k3_2o/pi-move
29
29
  # or
30
30
  pi install git:github.com/k3-2o/pi-move
31
31
  ```
package/index.ts CHANGED
@@ -1,39 +1,24 @@
1
- // --- pi-move: Directory Switcher for Pi ---
2
-
3
1
  import * as fs from "node:fs";
4
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
- import { handleMoveCommand } from "./move.js";
3
+ import { handleMoveCommand } from "./src/move.js";
6
4
 
7
5
  export default function piMoveExtension(pi: ExtensionAPI): void {
8
- // --- Lifecycle: session_shutdown ---
9
- // When leaving an empty session (user moved here via /move but sent
10
- // no messages), delete the file so it doesn't clutter /resume.
11
6
  pi.on("session_shutdown", (_event, ctx) => {
12
7
  const entries = ctx.sessionManager.getEntries();
13
- // Check for actual user/assistant messages, not just metadata entries
14
- // like model_change and thinking_level_change that pi auto-appends.
15
8
  const hasRealMessages = entries.some(
16
- (e) => e.type === "message" && (e.message.role === "user" || e.message.role === "assistant"),
9
+ (e) => e.type === "message" &&
10
+ (e.message.role === "user" || e.message.role === "assistant"),
17
11
  );
18
- if (hasRealMessages) return; // Has real messages — keep it
12
+ if (hasRealMessages) return;
19
13
 
20
14
  const sessionFile = ctx.sessionManager.getSessionFile();
21
15
  if (!sessionFile) return;
22
-
23
- try {
24
- fs.unlinkSync(sessionFile);
25
- } catch {
26
- // File already gone — skip
27
- }
16
+ try { fs.unlinkSync(sessionFile); } catch { /* file gone, that's fine */ }
28
17
  });
29
18
 
30
- // --- Command: /move ---
31
-
32
19
  pi.registerCommand("move", {
33
- description:
34
- "Move to a different directory — starts a fresh Pi session in the target directory",
35
- getArgumentCompletions: (_argumentPrefix: string) => {
36
- // We don't use inline arg completion — the overlay handles it
20
+ description: "Move to a different directory — starts a fresh Pi session in the target directory",
21
+ getArgumentCompletions: (_argumentPrefix: string): null => {
37
22
  return null;
38
23
  },
39
24
  handler: async (args, ctx) => {
package/package.json CHANGED
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "name": "@k3_2o/pi-move",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Pi extension — /move command to switch directories from inside Pi",
5
5
  "keywords": ["pi-package", "extension", "directory", "navigation"],
6
6
  "author": "k3_2o",
7
7
  "license": "MIT",
8
8
  "type": "module",
9
9
  "scripts": {
10
- "fmt": "prettier --write '*.ts'",
11
- "fmt:check": "prettier --check '*.ts'",
12
- "lint": "eslint '*.ts'",
10
+ "fmt": "prettier --write '{index.ts,src/*.ts}'",
11
+ "fmt:check": "prettier --check '{index.ts,src/*.ts}'",
12
+ "lint": "eslint 'index.ts' 'src/*.ts'",
13
13
  "typecheck": "tsc --noEmit",
14
14
  "check": "npm run fmt:check && npm run lint && npm run typecheck",
15
15
  "security": "npm audit --audit-level=high"
@@ -6,17 +6,12 @@ import { SessionManager } from "@earendil-works/pi-coding-agent";
6
6
  import { MoveOverlay, type MoveOverlayResult } from "./overlay.js";
7
7
  import { resolveDirectory } from "./utils.js";
8
8
 
9
- /**
10
- * Handle the /move command.
11
- */
12
9
  export async function handleMoveCommand(args: string, ctx: ExtensionCommandContext): Promise<void> {
13
10
  const trimmedArg = args.trim();
14
11
 
15
12
  if (trimmedArg.length > 0) {
16
13
  const target = await resolveOrCreateDirectory(trimmedArg, ctx);
17
- if (target !== null) {
18
- await switchToNewSession(target, ctx);
19
- }
14
+ if (target !== null) await switchToNewSession(target, ctx);
20
15
  return;
21
16
  }
22
17
 
@@ -33,25 +28,13 @@ export async function handleMoveCommand(args: string, ctx: ExtensionCommandConte
33
28
  if (result === undefined) return;
34
29
 
35
30
  const target = await resolveOrCreateDirectory(result.directory, ctx);
36
- if (target !== null) {
37
- await switchToNewSession(target, ctx);
38
- }
31
+ if (target !== null) await switchToNewSession(target, ctx);
39
32
  }
40
33
 
41
- /**
42
- * Resolve a directory path. If it doesn't exist, prompt the user to create it.
43
- * Returns the resolved path, or null if the user cancels or the path is invalid.
44
- */
45
- async function resolveOrCreateDirectory(
46
- input: string,
47
- ctx: ExtensionCommandContext,
48
- ): Promise<string | null> {
49
- // Try normal resolution first
34
+ async function resolveOrCreateDirectory(input: string, ctx: ExtensionCommandContext): Promise<string | null> {
50
35
  const resolved = resolveDirectory(input, ctx.cwd);
51
36
  if (resolved !== null) return resolved;
52
37
 
53
- // Directory doesn't exist. Check if we can create it.
54
- // Resolve the path to get the intended full path.
55
38
  let targetPath: string;
56
39
  if (input.startsWith("~/")) {
57
40
  targetPath = path.join(os.homedir(), input.slice(2));
@@ -61,51 +44,29 @@ async function resolveOrCreateDirectory(
61
44
  targetPath = path.resolve(ctx.cwd, input);
62
45
  }
63
46
 
64
- // Check if parent directory exists
65
47
  const parentDir = path.dirname(targetPath);
66
48
  if (!fs.existsSync(parentDir)) {
67
- ctx.ui.notify(
68
- `Cannot create "${path.basename(targetPath)}": parent directory does not exist`,
69
- "error",
70
- );
49
+ ctx.ui.notify(`Cannot create "${path.basename(targetPath)}": parent directory does not exist`, "error");
71
50
  return null;
72
51
  }
73
52
 
74
- // Prompt user to create it
75
53
  const basename = path.basename(targetPath);
76
- const confirmed = await ctx.ui.confirm(
77
- "Create directory?",
78
- `"${basename}" does not exist. Create it?`,
79
- );
80
-
54
+ const confirmed = await ctx.ui.confirm("Create directory?", `"${basename}" does not exist. Create it?`);
81
55
  if (!confirmed) return null;
82
56
 
83
57
  try {
84
58
  fs.mkdirSync(targetPath, { recursive: true });
85
59
  return targetPath;
86
60
  } catch (err) {
87
- const message = err instanceof Error ? err.message : String(err);
88
- ctx.ui.notify(`Failed to create directory: ${message}`, "error");
61
+ ctx.ui.notify(`Failed to create directory: ${err instanceof Error ? err.message : String(err)}`, "error");
89
62
  return null;
90
63
  }
91
64
  }
92
65
 
93
- /**
94
- * Switch to a fresh Pi session in the target directory.
95
- *
96
- * Strategy: Create a new session file in the CURRENT session directory
97
- * (so /resume still scans the same folder) but with the target directory
98
- * as the session's CWD. Then switch to that session.
99
- *
100
- * IMPORTANT: SessionManager.create() does NOT write the file to disk.
101
- * We must write the session header ourselves before switchSession reads it.
102
- */
103
66
  async function switchToNewSession(targetDir: string, ctx: ExtensionCommandContext): Promise<void> {
104
67
  ctx.ui.notify(`Moving to ${targetDir}...`, "info");
105
68
 
106
69
  try {
107
- // Create the session manager — this generates a file path but does NOT
108
- // write the file. We need the file on disk so switchSession can open it.
109
70
  const currentSessionDir = ctx.sessionManager.getSessionDir();
110
71
  const newSession = SessionManager.create(targetDir, currentSessionDir);
111
72
  const sessionFile = newSession.getSessionFile();
@@ -115,9 +76,8 @@ async function switchToNewSession(targetDir: string, ctx: ExtensionCommandContex
115
76
  return;
116
77
  }
117
78
 
118
- // Write the session header to disk. Without this, SessionManager.open()
119
- // in switchSession cannot read the CWD from the header and falls back
120
- // to process.cwd() — which is the OLD directory.
79
+ // SessionManager.create() doesn't write the file. Must write it manually
80
+ // so switchSession can read the cwd from the header.
121
81
  const sessionId = newSession.getSessionId();
122
82
  const header = {
123
83
  type: "session",
@@ -127,18 +87,13 @@ async function switchToNewSession(targetDir: string, ctx: ExtensionCommandContex
127
87
  cwd: targetDir,
128
88
  };
129
89
 
130
- // Ensure the session directory exists
131
90
  if (!fs.existsSync(currentSessionDir)) {
132
91
  fs.mkdirSync(currentSessionDir, { recursive: true });
133
92
  }
134
93
 
135
94
  fs.writeFileSync(sessionFile, JSON.stringify(header) + "\n", "utf-8");
136
-
137
- // Switch to the new session. Pi opens the file, reads cwd: targetDir
138
- // from the header, and creates a fresh runtime with the target CWD.
139
95
  await ctx.switchSession(sessionFile);
140
96
  } catch (err) {
141
- const message = err instanceof Error ? err.message : String(err);
142
- ctx.ui.notify(`Failed to move: ${message}`, "error");
97
+ ctx.ui.notify(`Failed to move: ${err instanceof Error ? err.message : String(err)}`, "error");
143
98
  }
144
99
  }
@@ -2,27 +2,14 @@ import type { Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { CURSOR_MARKER, type Focusable, matchesKey, visibleWidth } from "@earendil-works/pi-tui";
3
3
  import { findDirectories } from "./utils.js";
4
4
 
5
- /**
6
- * Result emitted by the move overlay when the user confirms a selection.
7
- */
8
5
  export interface MoveOverlayResult {
9
- /** The selected directory path (resolved, absolute). */
10
6
  directory: string;
11
7
  }
12
8
 
13
- /**
14
- * Overlay component for the /move command.
15
- *
16
- * Renders a floating modal with:
17
- * - A text input field for typing a directory path
18
- * - A list of matching directories below (updated on each keystroke)
19
- * - Keyboard navigation: type to filter, ↑↓ to select, Enter to confirm, Esc to cancel
20
- */
21
9
  export class MoveOverlay implements Focusable {
22
10
  readonly width = 68;
23
11
  readonly maxResults = 15;
24
12
 
25
- /** Focusable interface — set by TUI when focus changes */
26
13
  focused = false;
27
14
 
28
15
  private input = "";
@@ -32,6 +19,7 @@ export class MoveOverlay implements Focusable {
32
19
  private theme: Theme;
33
20
  private done: (result: MoveOverlayResult | undefined) => void;
34
21
  private cwd: string;
22
+ private _updateTimeout: ReturnType<typeof setTimeout> | undefined;
35
23
 
36
24
  constructor(theme: Theme, cwd: string, done: (result: MoveOverlayResult | undefined) => void) {
37
25
  this.theme = theme;
@@ -41,32 +29,20 @@ export class MoveOverlay implements Focusable {
41
29
  }
42
30
 
43
31
  handleInput(data: string): void {
44
- if (matchesKey(data, "escape")) {
45
- this.done(undefined);
46
- return;
47
- }
48
-
49
- if (matchesKey(data, "return")) {
50
- this.confirmSelection();
51
- return;
52
- }
32
+ if (matchesKey(data, "escape")) { this.done(undefined); return; }
33
+ if (matchesKey(data, "return")) { this.confirmSelection(); return; }
53
34
 
54
- if (matchesKey(data, "up")) {
55
- if (this.results.length > 0) {
56
- this.selectedIndex = Math.max(0, this.selectedIndex - 1);
57
- }
35
+ if (matchesKey(data, "up") && this.results.length > 0) {
36
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
58
37
  return;
59
38
  }
60
39
 
61
- if (matchesKey(data, "down")) {
62
- if (this.results.length > 0) {
63
- this.selectedIndex = Math.min(this.results.length - 1, this.selectedIndex + 1);
64
- }
40
+ if (matchesKey(data, "down") && this.results.length > 0) {
41
+ this.selectedIndex = Math.min(this.results.length - 1, this.selectedIndex + 1);
65
42
  return;
66
43
  }
67
44
 
68
45
  if (matchesKey(data, "tab")) {
69
- // Tab auto-completes the selected item into the input
70
46
  const selected = this.results[this.selectedIndex];
71
47
  if (selected) {
72
48
  this.input = selected.value;
@@ -76,32 +52,22 @@ export class MoveOverlay implements Focusable {
76
52
  return;
77
53
  }
78
54
 
79
- if (matchesKey(data, "backspace")) {
80
- if (this.cursor > 0) {
81
- this.input = this.input.slice(0, this.cursor - 1) + this.input.slice(this.cursor);
82
- this.cursor--;
83
- this.selectedIndex = 0;
84
- this.updateResults();
85
- }
86
- return;
87
- }
88
-
89
- if (matchesKey(data, "left")) {
90
- this.cursor = Math.max(0, this.cursor - 1);
55
+ if (matchesKey(data, "backspace") && this.cursor > 0) {
56
+ this.input = this.input.slice(0, this.cursor - 1) + this.input.slice(this.cursor);
57
+ this.cursor--;
58
+ this.selectedIndex = 0;
59
+ this.scheduleUpdate();
91
60
  return;
92
61
  }
93
62
 
94
- if (matchesKey(data, "right")) {
95
- this.cursor = Math.min(this.input.length, this.cursor + 1);
96
- return;
97
- }
63
+ if (matchesKey(data, "left")) { this.cursor = Math.max(0, this.cursor - 1); return; }
64
+ if (matchesKey(data, "right")) { this.cursor = Math.min(this.input.length, this.cursor + 1); return; }
98
65
 
99
- // Regular character input
100
66
  if (data.length === 1 && data.charCodeAt(0) >= 32) {
101
67
  this.input = this.input.slice(0, this.cursor) + data + this.input.slice(this.cursor);
102
68
  this.cursor++;
103
69
  this.selectedIndex = 0;
104
- this.updateResults();
70
+ this.scheduleUpdate();
105
71
  }
106
72
  }
107
73
 
@@ -125,14 +91,10 @@ export class MoveOverlay implements Focusable {
125
91
  );
126
92
  };
127
93
 
128
- // ── Top border ──
129
94
  lines.push(th.fg("border", `╭${"─".repeat(innerW)}╮`));
130
-
131
- // ── Title ──
132
95
  lines.push(row(` ${th.fg("accent", "📂 Move to directory")}`));
133
96
  lines.push(row(""));
134
97
 
135
- // ── Input field ──
136
98
  const inputPrompt = th.fg("text", " Path: ");
137
99
  let inputDisplay = this.input;
138
100
  if (this.input.length > 0) {
@@ -149,43 +111,37 @@ export class MoveOverlay implements Focusable {
149
111
  lines.push(row(`${inputPrompt}${inputDisplay}`));
150
112
  lines.push(row(""));
151
113
 
152
- // ── Results list ──
153
114
  if (this.results.length === 0 && this.input.length > 0) {
154
115
  lines.push(row(` ${th.fg("dim", "No matching directories")}`));
155
116
  } else {
156
117
  const visibleResults = this.results.slice(0, this.maxResults);
157
-
158
118
  for (let i = 0; i < visibleResults.length; i++) {
159
119
  const item = visibleResults[i];
160
120
  if (!item) continue;
161
121
  const isSelected = i === this.selectedIndex;
162
-
163
122
  const prefix = isSelected ? th.fg("accent", " ▶") : " ";
164
123
  const label = isSelected ? th.fg("accent", item.label) : th.fg("text", item.label);
165
-
166
124
  const desc = item.description ? th.fg("dim", ` ${item.description}`) : "";
167
-
168
125
  lines.push(row(`${prefix} ${label}${desc}`));
169
126
  }
170
127
  }
171
128
 
172
129
  lines.push(row(""));
173
-
174
- // ── Footer ──
175
130
  lines.push(row(` ${th.fg("dim", "Type to filter · ↑↓·Tab·Enter·Esc")}`));
176
-
177
- // ── Bottom border ──
178
131
  lines.push(th.fg("border", `╰${"─".repeat(innerW)}╯`));
179
132
 
180
133
  return lines;
181
134
  }
182
135
 
183
- invalidate(): void {
184
- // No external state to invalidate
185
- }
136
+ invalidate(): void {}
137
+ dispose(): void {}
186
138
 
187
- dispose(): void {
188
- // No resources to clean up
139
+ private scheduleUpdate(): void {
140
+ if (this._updateTimeout) clearTimeout(this._updateTimeout);
141
+ this._updateTimeout = setTimeout(() => {
142
+ this.updateResults();
143
+ this._updateTimeout = undefined;
144
+ }, 150);
189
145
  }
190
146
 
191
147
  private updateResults(): void {
@@ -196,21 +152,9 @@ export class MoveOverlay implements Focusable {
196
152
  }
197
153
 
198
154
  private confirmSelection(): void {
199
- // If there's a selection, use it
200
155
  const selectedItem = this.results[this.selectedIndex];
201
- if (selectedItem) {
202
- this.done({ directory: selectedItem.value });
203
- return;
204
- }
205
-
206
- // If input is non-empty, try resolving it directly
207
- if (this.input.trim().length > 0) {
208
- // Resolve will be done by the handler
209
- this.done({ directory: this.input.trim() });
210
- return;
211
- }
212
-
213
- // Nothing to select
156
+ if (selectedItem) { this.done({ directory: selectedItem.value }); return; }
157
+ if (this.input.trim().length > 0) { this.done({ directory: this.input.trim() }); return; }
214
158
  this.done(undefined);
215
159
  }
216
160
  }
package/src/utils.ts ADDED
@@ -0,0 +1,91 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import * as os from "node:os";
4
+ export function resolveDirectory(input: string, cwd: string): string | null {
5
+ let resolved = input;
6
+ if (input.startsWith("~/") || input === "~") {
7
+ resolved = path.join(os.homedir(), input.slice(1));
8
+ } else if (!path.isAbsolute(input)) {
9
+ resolved = path.resolve(cwd, input);
10
+ }
11
+ resolved = path.normalize(resolved);
12
+ try { return fs.statSync(resolved).isDirectory() ? resolved : null; } catch { return null; }
13
+ }
14
+
15
+ export function findDirectories(
16
+ prefix: string, cwd: string, maxResults = 30,
17
+ ): Array<{ value: string; label: string; description?: string }> {
18
+ const resolved = resolveDirectory(prefix, cwd);
19
+ if (resolved !== null && fs.statSync(resolved).isDirectory()) {
20
+ return listDirectoryContents(resolved, maxResults);
21
+ }
22
+ const searchDir = getSearchBase(prefix, cwd);
23
+ const query = getQuery(prefix);
24
+ return searchDirectories(searchDir, query, maxResults);
25
+ }
26
+
27
+ function listDirectoryContents(
28
+ dirPath: string, maxResults: number,
29
+ ): Array<{ value: string; label: string; description?: string }> {
30
+ try {
31
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
32
+ const results: Array<{ value: string; label: string; description?: string }> = [];
33
+ for (const entry of entries) {
34
+ if (results.length >= maxResults) break;
35
+ if (entry.name.startsWith(".")) continue;
36
+ let isDir: boolean;
37
+ try {
38
+ isDir = entry.isDirectory();
39
+ if (!isDir && entry.isSymbolicLink()) isDir = fs.statSync(path.join(dirPath, entry.name)).isDirectory();
40
+ } catch { continue; }
41
+ if (isDir) {
42
+ const fullPath = path.join(dirPath, entry.name);
43
+ results.push({ value: fullPath, label: entry.name + "/", description: fullPath });
44
+ }
45
+ }
46
+ results.sort((a, b) => a.label.localeCompare(b.label));
47
+ return results;
48
+ } catch { return []; }
49
+ }
50
+
51
+ function searchDirectories(
52
+ baseDir: string, query: string, maxResults: number,
53
+ ): Array<{ value: string; label: string; description?: string }> {
54
+ const results: Array<{ value: string; label: string; description?: string }> = [];
55
+ try {
56
+ const entries = fs.readdirSync(baseDir, { withFileTypes: true });
57
+ const lowerQuery = query.toLowerCase();
58
+ for (const entry of entries) {
59
+ if (results.length >= maxResults) break;
60
+ let isDir: boolean;
61
+ try {
62
+ isDir = entry.isDirectory();
63
+ if (!isDir && entry.isSymbolicLink()) isDir = fs.statSync(path.join(baseDir, entry.name)).isDirectory();
64
+ } catch { continue; }
65
+ if (!isDir) continue;
66
+ if (query === "" || entry.name.toLowerCase().includes(lowerQuery)) {
67
+ results.push({ value: path.join(baseDir, entry.name), label: entry.name + "/", description: path.join(baseDir, entry.name) });
68
+ }
69
+ }
70
+ } catch { /* directory not accessible */ }
71
+ return results;
72
+ }
73
+
74
+ function getSearchBase(prefix: string, cwd: string): string {
75
+ if (prefix === "" || prefix === "~") return cwd;
76
+ const normalized = prefix.replace(/\\/g, "/");
77
+ const lastSlash = normalized.lastIndexOf("/");
78
+ if (lastSlash === -1) return cwd;
79
+ const basePart = normalized.slice(0, lastSlash + 1);
80
+ if (basePart.startsWith("~")) return path.join(os.homedir(), basePart.slice(1));
81
+ if (path.isAbsolute(basePart)) return basePart;
82
+ return path.resolve(cwd, basePart);
83
+ }
84
+
85
+ function getQuery(prefix: string): string {
86
+ if (prefix === "" || prefix === "~") return "";
87
+ const normalized = prefix.replace(/\\/g, "/");
88
+ const lastSlash = normalized.lastIndexOf("/");
89
+ if (lastSlash === -1) return normalized;
90
+ return normalized.slice(lastSlash + 1);
91
+ }
package/tsconfig.json CHANGED
@@ -28,6 +28,6 @@
28
28
  ]
29
29
  }
30
30
  },
31
- "include": ["*.ts", "scripts/**/*.ts"],
31
+ "include": ["index.ts", "src/*.ts"],
32
32
  "exclude": ["node_modules", "dist", "tests"]
33
33
  }
package/utils.ts DELETED
@@ -1,264 +0,0 @@
1
- import * as fs from "node:fs";
2
- import * as path from "node:path";
3
- import * as os from "node:os";
4
- import { spawn } from "node:child_process";
5
-
6
- /**
7
- * Resolve a user-provided directory path against the given CWD.
8
- * Handles ~ expansion, relative paths, . and .. segments.
9
- *
10
- * Returns null if the resolved path doesn't exist or isn't a directory.
11
- */
12
- export function resolveDirectory(input: string, cwd: string): string | null {
13
- let resolved: string;
14
-
15
- if (input.startsWith("~/") || input === "~") {
16
- resolved = path.join(os.homedir(), input.slice(1));
17
- } else if (path.isAbsolute(input)) {
18
- resolved = input;
19
- } else {
20
- resolved = path.resolve(cwd, input);
21
- }
22
-
23
- resolved = path.normalize(resolved);
24
-
25
- try {
26
- if (fs.statSync(resolved).isDirectory()) {
27
- return resolved;
28
- }
29
- } catch {
30
- // Does not exist or permission denied
31
- }
32
-
33
- return null;
34
- }
35
-
36
- /**
37
- * Get a sorted list of directories matching the given prefix.
38
- *
39
- * Uses `fd` for fast, recursive, .gitignore-respecting search when available.
40
- * Falls back to a basic readdirSync scan if fd isn't installed.
41
- */
42
- export function findDirectories(
43
- prefix: string,
44
- cwd: string,
45
- maxResults = 30,
46
- ): Array<{ value: string; label: string; description?: string }> {
47
- const resolved = resolveDirectory(prefix, cwd);
48
- if (resolved !== null && fs.statSync(resolved).isDirectory()) {
49
- // If prefix is already a valid directory, list its contents
50
- return listDirectoryContents(resolved, maxResults);
51
- }
52
-
53
- // Fuzzy search from cwd (or from the partial path)
54
- const searchDir = getSearchBase(prefix, cwd);
55
- const query = getQuery(prefix);
56
-
57
- return searchDirectories(searchDir, query, maxResults);
58
- }
59
-
60
- /**
61
- * Search for directories using fd (fast) or readdirSync (fallback).
62
- */
63
- async function searchDirectoriesFd(
64
- baseDir: string,
65
- query: string,
66
- maxResults: number,
67
- ): Promise<Array<{ path: string; isDirectory: boolean }>> {
68
- return new Promise((resolve) => {
69
- const args = [
70
- "--base-directory",
71
- baseDir,
72
- "--max-results",
73
- String(maxResults),
74
- "--type",
75
- "d",
76
- "--follow",
77
- "--hidden",
78
- "--exclude",
79
- ".git",
80
- "--exclude",
81
- "node_modules",
82
- ];
83
-
84
- if (query) {
85
- args.push(query);
86
- }
87
-
88
- const child = spawn("fd", args, {
89
- stdio: ["ignore", "pipe", "pipe"],
90
- });
91
-
92
- let stdout = "";
93
-
94
- child.stdout.setEncoding("utf-8");
95
- child.stdout.on("data", (chunk: string) => {
96
- stdout += chunk;
97
- });
98
-
99
- child.on("error", () => {
100
- resolve([]);
101
- });
102
-
103
- child.on("close", (code) => {
104
- if (code !== 0 || !stdout) {
105
- resolve([]);
106
- return;
107
- }
108
-
109
- const lines = stdout.trim().split("\n").filter(Boolean);
110
- const results = lines.map((line) => {
111
- const normalized = line.replace(/\\/g, "/");
112
- const isDir = normalized.endsWith("/");
113
- return {
114
- path: isDir ? normalized.slice(0, -1) : normalized,
115
- isDirectory: true,
116
- };
117
- });
118
-
119
- resolve(results);
120
- });
121
- });
122
- }
123
-
124
- /**
125
- * List directory contents (all subdirectories).
126
- */
127
- function listDirectoryContents(
128
- dirPath: string,
129
- maxResults: number,
130
- ): Array<{ value: string; label: string; description?: string }> {
131
- try {
132
- const entries = fs.readdirSync(dirPath, { withFileTypes: true });
133
- const results: Array<{ value: string; label: string; description?: string }> = [];
134
-
135
- for (const entry of entries) {
136
- if (results.length >= maxResults) break;
137
- if (entry.name.startsWith(".")) continue; // skip hidden
138
-
139
- let isDir: boolean;
140
- try {
141
- isDir = entry.isDirectory();
142
- if (!isDir && entry.isSymbolicLink()) {
143
- isDir = fs.statSync(path.join(dirPath, entry.name)).isDirectory();
144
- }
145
- } catch {
146
- continue;
147
- }
148
-
149
- if (isDir) {
150
- const fullPath = path.join(dirPath, entry.name);
151
- results.push({
152
- value: fullPath,
153
- label: entry.name + "/",
154
- description: fullPath,
155
- });
156
- }
157
- }
158
-
159
- // Alphabetical sort
160
- results.sort((a, b) => a.label.localeCompare(b.label));
161
- return results;
162
- } catch {
163
- return [];
164
- }
165
- }
166
-
167
- /**
168
- * Search directories using readdirSync recursively (fallback when fd is unavailable).
169
- */
170
- function searchDirectories(
171
- baseDir: string,
172
- query: string,
173
- maxResults: number,
174
- ): Array<{ value: string; label: string; description?: string }> {
175
- // First, try fd for speed
176
- searchDirectoriesFd(baseDir, query, maxResults)
177
- .then((fdResults) => {
178
- if (fdResults.length > 0) {
179
- return fdResults.map((r) => ({
180
- value: path.resolve(baseDir, r.path),
181
- label: r.path + "/",
182
- description: path.resolve(baseDir, r.path),
183
- }));
184
- }
185
- return null;
186
- })
187
- .catch(() => null);
188
-
189
- // Synchronous fallback: iterate parent directories
190
- // This is simpler and immediate
191
- const results: Array<{ value: string; label: string; description?: string }> = [];
192
-
193
- try {
194
- const entries = fs.readdirSync(baseDir, { withFileTypes: true });
195
- const lowerQuery = query.toLowerCase();
196
-
197
- for (const entry of entries) {
198
- if (results.length >= maxResults) break;
199
-
200
- let isDir: boolean;
201
- try {
202
- isDir = entry.isDirectory();
203
- if (!isDir && entry.isSymbolicLink()) {
204
- isDir = fs.statSync(path.join(baseDir, entry.name)).isDirectory();
205
- }
206
- } catch {
207
- continue;
208
- }
209
-
210
- if (!isDir) continue;
211
-
212
- if (query === "" || entry.name.toLowerCase().includes(lowerQuery)) {
213
- const fullPath = path.join(baseDir, entry.name);
214
- results.push({
215
- value: fullPath,
216
- label: entry.name + "/",
217
- description: fullPath,
218
- });
219
- }
220
- }
221
- } catch {
222
- // Permission denied or non-existent
223
- }
224
-
225
- return results;
226
- }
227
-
228
- /**
229
- * Extract the search base directory and the query part from a path prefix.
230
- * E.g., "~/projects/my" → base: "~/projects", query: "my"
231
- * "foo/bar" → base: "cwd/foo", query: "bar"
232
- */
233
- function getSearchBase(prefix: string, cwd: string): string {
234
- if (prefix === "" || prefix === "~") {
235
- return cwd;
236
- }
237
-
238
- const normalized = prefix.replace(/\\/g, "/");
239
- const lastSlash = normalized.lastIndexOf("/");
240
-
241
- if (lastSlash === -1) {
242
- return cwd;
243
- }
244
-
245
- const basePart = normalized.slice(0, lastSlash + 1);
246
-
247
- if (basePart.startsWith("~")) {
248
- return path.join(os.homedir(), basePart.slice(1));
249
- }
250
-
251
- if (path.isAbsolute(basePart)) {
252
- return basePart;
253
- }
254
-
255
- return path.resolve(cwd, basePart);
256
- }
257
-
258
- function getQuery(prefix: string): string {
259
- if (prefix === "" || prefix === "~") return "";
260
- const normalized = prefix.replace(/\\/g, "/");
261
- const lastSlash = normalized.lastIndexOf("/");
262
- if (lastSlash === -1) return normalized;
263
- return normalized.slice(lastSlash + 1);
264
- }