@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 +3 -3
- package/README.md +1 -1
- package/index.ts +7 -22
- package/package.json +4 -4
- package/{move.ts → src/move.ts} +9 -54
- package/{overlay.ts → src/overlay.ts} +25 -81
- package/src/utils.ts +91 -0
- package/tsconfig.json +1 -1
- package/utils.ts +0 -264
package/Makefile
CHANGED
|
@@ -4,17 +4,17 @@
|
|
|
4
4
|
|
|
5
5
|
fmt:
|
|
6
6
|
@echo "=== Formatting with Prettier ==="
|
|
7
|
-
npx prettier --write '
|
|
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 '
|
|
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 '
|
|
17
|
+
npx eslint 'index.ts' 'src/*.ts'
|
|
18
18
|
|
|
19
19
|
# --- Type Checking ---
|
|
20
20
|
|
package/README.md
CHANGED
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" &&
|
|
9
|
+
(e) => e.type === "message" &&
|
|
10
|
+
(e.message.role === "user" || e.message.role === "assistant"),
|
|
17
11
|
);
|
|
18
|
-
if (hasRealMessages) return;
|
|
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
|
-
|
|
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.
|
|
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 '
|
|
11
|
-
"fmt:check": "prettier --check '
|
|
12
|
-
"lint": "eslint '
|
|
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"
|
package/{move.ts → src/move.ts}
RENAMED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
119
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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, "
|
|
95
|
-
|
|
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.
|
|
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
|
-
|
|
185
|
-
}
|
|
136
|
+
invalidate(): void {}
|
|
137
|
+
dispose(): void {}
|
|
186
138
|
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
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
|
-
}
|