@liushihao456/pi-emacs 0.1.1 → 0.1.3
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/README.md +7 -0
- package/index.ts +879 -32
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -6,6 +6,8 @@ Pi extension for opening `emacsclient` from Pi's TUI.
|
|
|
6
6
|
|
|
7
7
|
- Starts an Emacs daemon automatically when Pi starts.
|
|
8
8
|
- Adds `/emacs` command.
|
|
9
|
+
- Adds `/emacs:find-file` command with a file explorer for opening files or directories.
|
|
10
|
+
- Adds `/emacs:project-find-file` command with a fuzzy picker over non-ignored project files from ripgrep.
|
|
9
11
|
- Adds `ctrl+g` shortcut to open `emacsclient -nw` in the terminal.
|
|
10
12
|
- Remembers the last file touched by Pi `edit` / `write` tools and opens it on next launch.
|
|
11
13
|
- Falls back to `dired` in the current working directory when no recent file exists.
|
|
@@ -30,12 +32,17 @@ pi install /path/to/pi-emacs
|
|
|
30
32
|
- Emacs available as `emacs`
|
|
31
33
|
- Emacs client available as `emacsclient`
|
|
32
34
|
- Pi interactive TUI mode
|
|
35
|
+
- `@vscode/ripgrep` installed with this package for `/emacs:project-find-file`
|
|
33
36
|
|
|
34
37
|
## Usage
|
|
35
38
|
|
|
36
39
|
- `/emacs` — open Emacs client
|
|
40
|
+
- `/emacs:find-file` — choose a file or directory and open it in Emacs
|
|
41
|
+
- `/emacs:project-find-file` — fuzzy-find a non-ignored project file and open it in Emacs
|
|
37
42
|
- `ctrl+g` — open Emacs client
|
|
38
43
|
|
|
44
|
+
Pi extension shortcuts currently support single key events, so Emacs-style multi-key chords such as `C-x C-f` and `C-c p f` are documented here as commands instead of registered as shortcuts.
|
|
45
|
+
|
|
39
46
|
## Publish
|
|
40
47
|
|
|
41
48
|
```bash
|
package/index.ts
CHANGED
|
@@ -2,27 +2,67 @@ import type {
|
|
|
2
2
|
ExtensionAPI,
|
|
3
3
|
ExtensionContext,
|
|
4
4
|
} from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { rgPath } from "@vscode/ripgrep";
|
|
6
|
+
import { readdirSync, statSync } from "node:fs";
|
|
7
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import path, { isAbsolute, resolve } from "node:path";
|
|
10
|
+
import {
|
|
11
|
+
fuzzyFilter,
|
|
12
|
+
getKeybindings,
|
|
13
|
+
Input,
|
|
14
|
+
Key,
|
|
15
|
+
matchesKey,
|
|
16
|
+
truncateToWidth,
|
|
17
|
+
visibleWidth,
|
|
18
|
+
type Component,
|
|
19
|
+
type Focusable,
|
|
20
|
+
} from "@earendil-works/pi-tui";
|
|
21
|
+
|
|
22
|
+
type Theme = {
|
|
23
|
+
fg(color: string, text: string): string;
|
|
24
|
+
};
|
|
7
25
|
|
|
8
26
|
type EmacsState = {
|
|
9
27
|
startedEmacsServer: boolean;
|
|
28
|
+
serverName: string;
|
|
29
|
+
legacyDefaultServer?: boolean;
|
|
10
30
|
serverStartPromise?: Promise<void>;
|
|
31
|
+
watchdogPid?: number;
|
|
32
|
+
processShutdownHandlerVersion?: number;
|
|
11
33
|
lastEditedFile?: string;
|
|
12
34
|
};
|
|
13
35
|
|
|
14
|
-
const state: EmacsState = ((globalThis as any).__piEmacsExtensionState ??= {
|
|
15
|
-
startedEmacsServer: false,
|
|
16
|
-
});
|
|
17
|
-
|
|
18
36
|
type SpawnOptions = {
|
|
19
37
|
cwd?: string;
|
|
20
|
-
stdio?: "inherit" | "ignore";
|
|
38
|
+
stdio?: "inherit" | "ignore" | "pipe";
|
|
21
39
|
timeoutMs?: number;
|
|
22
40
|
onBefore?: () => void;
|
|
23
41
|
onAfter?: () => void;
|
|
24
42
|
};
|
|
25
43
|
|
|
44
|
+
type FileEntry = {
|
|
45
|
+
name: string;
|
|
46
|
+
path: string;
|
|
47
|
+
isDirectory: boolean;
|
|
48
|
+
mode: string;
|
|
49
|
+
size: string;
|
|
50
|
+
modified: Date;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const state: EmacsState = ((globalThis as any).__piEmacsExtensionState ??= {
|
|
54
|
+
startedEmacsServer: false,
|
|
55
|
+
serverName: `pi-emacs-${process.pid}`,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (!state.serverName) {
|
|
59
|
+
state.legacyDefaultServer = state.startedEmacsServer;
|
|
60
|
+
state.serverName = `pi-emacs-${process.pid}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const FILE_EXPLORER_MAX_VISIBLE = 8;
|
|
64
|
+
const PROJECT_PICKER_MAX_VISIBLE = 12;
|
|
65
|
+
|
|
26
66
|
function run(command: string, args: string[], options: SpawnOptions = {}) {
|
|
27
67
|
return new Promise<void>((resolve, reject) => {
|
|
28
68
|
options.onBefore?.();
|
|
@@ -58,18 +98,189 @@ function run(command: string, args: string[], options: SpawnOptions = {}) {
|
|
|
58
98
|
});
|
|
59
99
|
}
|
|
60
100
|
|
|
101
|
+
function execText(command: string, args: string[], options: SpawnOptions = {}) {
|
|
102
|
+
return new Promise<string>((resolve, reject) => {
|
|
103
|
+
const child = spawn(command, args, {
|
|
104
|
+
cwd: options.cwd,
|
|
105
|
+
env: process.env,
|
|
106
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
107
|
+
});
|
|
108
|
+
const stdout: Buffer[] = [];
|
|
109
|
+
const stderr: Buffer[] = [];
|
|
110
|
+
|
|
111
|
+
child.stdout.on("data", (chunk) => stdout.push(Buffer.from(chunk)));
|
|
112
|
+
child.stderr.on("data", (chunk) => stderr.push(Buffer.from(chunk)));
|
|
113
|
+
child.on("error", reject);
|
|
114
|
+
child.on("close", (code, signal) => {
|
|
115
|
+
if (code === 0) {
|
|
116
|
+
resolve(Buffer.concat(stdout).toString("utf8"));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
reject(
|
|
120
|
+
new Error(
|
|
121
|
+
Buffer.concat(stderr).toString("utf8").trim() ||
|
|
122
|
+
`${command} exited with ${signal ?? code}`,
|
|
123
|
+
),
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function emacsClientServerArgs(args: string[]) {
|
|
130
|
+
return ["-s", state.serverName, ...args];
|
|
131
|
+
}
|
|
132
|
+
|
|
61
133
|
function emacsClient(args: string[], options: SpawnOptions = {}) {
|
|
62
|
-
return run("emacsclient", args, options);
|
|
134
|
+
return run("emacsclient", emacsClientServerArgs(args), options);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function emacsClientSync(args: string[], timeoutMs = 5000) {
|
|
138
|
+
return spawnSync("emacsclient", emacsClientServerArgs(args), {
|
|
139
|
+
stdio: "ignore",
|
|
140
|
+
timeout: timeoutMs,
|
|
141
|
+
env: process.env,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function sleep(ms: number) {
|
|
146
|
+
return new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function pidAlive(pid?: number) {
|
|
150
|
+
if (!pid) return false;
|
|
151
|
+
try {
|
|
152
|
+
process.kill(pid, 0);
|
|
153
|
+
return true;
|
|
154
|
+
} catch (error) {
|
|
155
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function emacsWatchdogScript() {
|
|
160
|
+
return `
|
|
161
|
+
const { spawn, spawnSync } = require("node:child_process");
|
|
162
|
+
const parentPid = Number(process.argv[1]);
|
|
163
|
+
const serverName = process.argv[2];
|
|
164
|
+
const startDaemon = process.argv[3] === "1";
|
|
165
|
+
let stopping = false;
|
|
166
|
+
let child;
|
|
167
|
+
function parentAlive() {
|
|
168
|
+
try {
|
|
169
|
+
process.kill(parentPid, 0);
|
|
170
|
+
return true;
|
|
171
|
+
} catch (error) {
|
|
172
|
+
return error && error.code === "EPERM";
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function killServer() {
|
|
176
|
+
spawnSync("emacsclient", ["-s", serverName, "--eval", "(kill-emacs)"], {
|
|
177
|
+
stdio: "ignore",
|
|
178
|
+
timeout: 5000,
|
|
179
|
+
env: process.env,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
function stop() {
|
|
183
|
+
if (stopping) return;
|
|
184
|
+
stopping = true;
|
|
185
|
+
killServer();
|
|
186
|
+
try {
|
|
187
|
+
if (child && child.pid) process.kill(child.pid, "SIGTERM");
|
|
188
|
+
} catch {}
|
|
189
|
+
setTimeout(() => {
|
|
190
|
+
try {
|
|
191
|
+
if (child && child.pid) process.kill(child.pid, "SIGKILL");
|
|
192
|
+
} catch {}
|
|
193
|
+
process.exit(0);
|
|
194
|
+
}, 1000);
|
|
195
|
+
}
|
|
196
|
+
if (startDaemon) {
|
|
197
|
+
child = spawn("emacs", ["--fg-daemon=" + serverName], {
|
|
198
|
+
stdio: "ignore",
|
|
199
|
+
env: process.env,
|
|
200
|
+
});
|
|
201
|
+
child.on("error", () => process.exit(1));
|
|
202
|
+
child.on("exit", () => process.exit(0));
|
|
203
|
+
}
|
|
204
|
+
let ticks = 0;
|
|
205
|
+
setInterval(() => {
|
|
206
|
+
if (!parentAlive()) {
|
|
207
|
+
stop();
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (!startDaemon && ++ticks % 10 === 0) {
|
|
211
|
+
const result = spawnSync("emacsclient", ["-s", serverName, "--eval", "(emacs-pid)"], {
|
|
212
|
+
stdio: "ignore",
|
|
213
|
+
timeout: 1000,
|
|
214
|
+
env: process.env,
|
|
215
|
+
});
|
|
216
|
+
if (result.status !== 0) process.exit(0);
|
|
217
|
+
}
|
|
218
|
+
}, 1000);
|
|
219
|
+
process.on("SIGTERM", stop);
|
|
220
|
+
process.on("SIGHUP", stop);
|
|
221
|
+
process.on("SIGINT", stop);
|
|
222
|
+
`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
type WatchdogMode = "start-daemon" | "watch-existing";
|
|
226
|
+
|
|
227
|
+
function startEmacsWatchdog(mode: WatchdogMode) {
|
|
228
|
+
const startDaemon = mode === "start-daemon";
|
|
229
|
+
if (!startDaemon && pidAlive(state.watchdogPid)) return;
|
|
230
|
+
const watchdog = spawn(
|
|
231
|
+
process.execPath,
|
|
232
|
+
[
|
|
233
|
+
"-e",
|
|
234
|
+
emacsWatchdogScript(),
|
|
235
|
+
String(process.pid),
|
|
236
|
+
state.serverName,
|
|
237
|
+
startDaemon ? "1" : "0",
|
|
238
|
+
],
|
|
239
|
+
{
|
|
240
|
+
detached: true,
|
|
241
|
+
stdio: "ignore",
|
|
242
|
+
env: process.env,
|
|
243
|
+
},
|
|
244
|
+
);
|
|
245
|
+
state.watchdogPid = watchdog.pid;
|
|
246
|
+
watchdog.unref();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function waitForEmacsServer(timeoutMs: number) {
|
|
250
|
+
const deadline = Date.now() + timeoutMs;
|
|
251
|
+
let lastError: unknown;
|
|
252
|
+
while (Date.now() < deadline) {
|
|
253
|
+
try {
|
|
254
|
+
await emacsClient(["--eval", "(emacs-pid)"], { timeoutMs: 1000 });
|
|
255
|
+
return;
|
|
256
|
+
} catch (error) {
|
|
257
|
+
lastError = error;
|
|
258
|
+
await sleep(250);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
throw lastError instanceof Error
|
|
262
|
+
? lastError
|
|
263
|
+
: new Error("emacs daemon did not start");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function stopLegacyDefaultServerIfNeeded() {
|
|
267
|
+
if (!state.legacyDefaultServer) return;
|
|
268
|
+
spawnSync("emacsclient", ["--eval", "(kill-emacs)"], {
|
|
269
|
+
stdio: "ignore",
|
|
270
|
+
timeout: 5000,
|
|
271
|
+
env: process.env,
|
|
272
|
+
});
|
|
273
|
+
state.legacyDefaultServer = false;
|
|
63
274
|
}
|
|
64
275
|
|
|
65
276
|
function withTerminalMouse(expression: string) {
|
|
66
277
|
return `(progn (xterm-mouse-mode 1) (mouse-wheel-mode 1) ${expression})`;
|
|
67
278
|
}
|
|
68
279
|
|
|
69
|
-
function findFileExpression(
|
|
280
|
+
function findFileExpression(filePath: string) {
|
|
70
281
|
return withTerminalMouse(
|
|
71
282
|
[
|
|
72
|
-
`(let* ((file ${JSON.stringify(
|
|
283
|
+
`(let* ((file ${JSON.stringify(filePath)})`,
|
|
73
284
|
"(buf (find-buffer-visiting file)))",
|
|
74
285
|
"(if buf",
|
|
75
286
|
"(progn",
|
|
@@ -86,18 +297,22 @@ function findFileExpression(path: string) {
|
|
|
86
297
|
function diredExpression(cwd: string) {
|
|
87
298
|
return withTerminalMouse(
|
|
88
299
|
[
|
|
89
|
-
`(let* ((dir ${JSON.stringify(cwd)})`,
|
|
90
|
-
"(buf (dired-find-buffer-nocreate dir)))",
|
|
91
|
-
"(if buf",
|
|
92
300
|
"(progn",
|
|
93
|
-
"(
|
|
94
|
-
"(
|
|
95
|
-
"(
|
|
96
|
-
"(
|
|
301
|
+
"(mapc (lambda (b)",
|
|
302
|
+
"(when (eq (buffer-local-value 'major-mode b) 'dired-mode)",
|
|
303
|
+
"(kill-buffer b)))",
|
|
304
|
+
"(buffer-list))",
|
|
305
|
+
`(dired ${JSON.stringify(cwd)}))`,
|
|
97
306
|
].join(" "),
|
|
98
307
|
);
|
|
99
308
|
}
|
|
100
309
|
|
|
310
|
+
function expressionForPath(targetPath: string) {
|
|
311
|
+
return statSync(targetPath).isDirectory()
|
|
312
|
+
? diredExpression(targetPath)
|
|
313
|
+
: findFileExpression(targetPath);
|
|
314
|
+
}
|
|
315
|
+
|
|
101
316
|
function emacsClientArgs(cwd: string) {
|
|
102
317
|
return state.lastEditedFile
|
|
103
318
|
? ["-nw", "-a", "", "-e", findFileExpression(state.lastEditedFile)]
|
|
@@ -105,51 +320,637 @@ function emacsClientArgs(cwd: string) {
|
|
|
105
320
|
}
|
|
106
321
|
|
|
107
322
|
function rememberEditedFile(input: unknown, cwd: string) {
|
|
108
|
-
const
|
|
109
|
-
if (!
|
|
110
|
-
state.lastEditedFile = isAbsolute(
|
|
323
|
+
const filePath = (input as { path?: string }).path;
|
|
324
|
+
if (!filePath) return;
|
|
325
|
+
state.lastEditedFile = isAbsolute(filePath)
|
|
326
|
+
? filePath
|
|
327
|
+
: resolve(cwd, filePath);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function fits(width: number, text: string): string {
|
|
331
|
+
return truncateToWidth(text, Math.max(0, width), "…");
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function indent(width: number, text: string): string {
|
|
335
|
+
return fits(width, ` ${text}`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function renderInputChild(input: Input, width: number): string {
|
|
339
|
+
const line = input.render(Math.max(1, width))[0] ?? "";
|
|
340
|
+
return line.startsWith("> ") ? line.slice(2) : line;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function setInputValueAtEnd(input: Input, value: string): void {
|
|
344
|
+
input.setValue(value);
|
|
345
|
+
(input as unknown as { cursor: number }).cursor = value.length;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function dirPrefix(value: string): string {
|
|
349
|
+
const slash = value.lastIndexOf("/");
|
|
350
|
+
return slash >= 0 ? value.slice(0, slash + 1) : "";
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function formatSize(bytes: number): string {
|
|
354
|
+
if (bytes < 1000) return `${bytes}`;
|
|
355
|
+
if (bytes < 1_000_000)
|
|
356
|
+
return `${(bytes / 1000).toFixed(bytes < 10_000 ? 1 : 0)}k`;
|
|
357
|
+
if (bytes < 1_000_000_000)
|
|
358
|
+
return `${(bytes / 1_000_000).toFixed(bytes < 10_000_000 ? 1 : 0)}M`;
|
|
359
|
+
return `${(bytes / 1_000_000_000).toFixed(1)}G`;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function modeString(mode: number, isDirectory: boolean): string {
|
|
363
|
+
const type = isDirectory ? "d" : "-";
|
|
364
|
+
const bits = [0o400, 0o200, 0o100, 0o040, 0o020, 0o010, 0o004, 0o002, 0o001]
|
|
365
|
+
.map((bit, index) => (mode & bit ? "rwx"[index % 3] : "-"))
|
|
366
|
+
.join("");
|
|
367
|
+
return `${type}${bits}`;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function relativeTime(date: Date): string {
|
|
371
|
+
const ms = Date.now() - date.getTime();
|
|
372
|
+
if (!Number.isFinite(ms) || ms < 0) return "now";
|
|
373
|
+
const sec = Math.floor(ms / 1000);
|
|
374
|
+
if (sec < 60) return "now";
|
|
375
|
+
const min = Math.floor(sec / 60);
|
|
376
|
+
if (min < 60) return `${min}m ago`;
|
|
377
|
+
const hour = Math.floor(min / 60);
|
|
378
|
+
if (hour < 24) return `${hour}h ago`;
|
|
379
|
+
const day = Math.floor(hour / 24);
|
|
380
|
+
if (day < 30) return `${day}d ago`;
|
|
381
|
+
const month = Math.floor(day / 30);
|
|
382
|
+
if (month < 12) return `${month}mo ago`;
|
|
383
|
+
return `${Math.floor(month / 12)}y ago`;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function normalizeExistingDir(input: string): string | null {
|
|
387
|
+
try {
|
|
388
|
+
const absolute = path.resolve(input.trim());
|
|
389
|
+
if (!statSync(absolute).isDirectory()) return null;
|
|
390
|
+
return absolute;
|
|
391
|
+
} catch {
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function readFileEntries(dir: string): FileEntry[] {
|
|
397
|
+
const entries: FileEntry[] = [
|
|
398
|
+
{
|
|
399
|
+
name: "./",
|
|
400
|
+
path: dir,
|
|
401
|
+
isDirectory: true,
|
|
402
|
+
mode: "drwxr-xr-x",
|
|
403
|
+
size: "",
|
|
404
|
+
modified: new Date(),
|
|
405
|
+
},
|
|
406
|
+
];
|
|
407
|
+
|
|
408
|
+
for (const dirent of readdirSync(dir, { withFileTypes: true })) {
|
|
409
|
+
try {
|
|
410
|
+
const entryPath = path.join(dir, dirent.name);
|
|
411
|
+
const stat = statSync(entryPath);
|
|
412
|
+
const isDirectory = stat.isDirectory();
|
|
413
|
+
entries.push({
|
|
414
|
+
name: `${dirent.name}${isDirectory ? "/" : ""}`,
|
|
415
|
+
path: entryPath,
|
|
416
|
+
isDirectory,
|
|
417
|
+
mode: modeString(stat.mode, isDirectory),
|
|
418
|
+
size: formatSize(stat.size),
|
|
419
|
+
modified: stat.mtime,
|
|
420
|
+
});
|
|
421
|
+
} catch {
|
|
422
|
+
// Ignore unreadable entries.
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return entries.sort((a, b) => {
|
|
427
|
+
if (a.name === "./") return -1;
|
|
428
|
+
if (b.name === "./") return 1;
|
|
429
|
+
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
|
|
430
|
+
return a.name.localeCompare(b.name);
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
class FileExplorer implements Component, Focusable {
|
|
435
|
+
private entries: FileEntry[] = [];
|
|
436
|
+
private selectedIndex = 0;
|
|
437
|
+
private readonly searchInput = new Input();
|
|
438
|
+
private error: string | undefined;
|
|
439
|
+
|
|
440
|
+
constructor(
|
|
441
|
+
initialCwd: string,
|
|
442
|
+
private readonly theme: Theme,
|
|
443
|
+
private readonly done: (path: string | null) => void,
|
|
444
|
+
private readonly requestRender: () => void,
|
|
445
|
+
) {
|
|
446
|
+
setInputValueAtEnd(
|
|
447
|
+
this.searchInput,
|
|
448
|
+
`${normalizeExistingDir(initialCwd) ?? homedir()}/`,
|
|
449
|
+
);
|
|
450
|
+
this.refresh();
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
render(width: number): string[] {
|
|
454
|
+
const lines: string[] = [];
|
|
455
|
+
lines.push(this.border(width));
|
|
456
|
+
lines.push(this.header(width));
|
|
457
|
+
lines.push(this.border(width, "dim"));
|
|
458
|
+
this.renderEntries(lines, width);
|
|
459
|
+
lines.push(this.border(width));
|
|
460
|
+
lines.push(
|
|
461
|
+
this.theme.fg(
|
|
462
|
+
"dim",
|
|
463
|
+
fits(
|
|
464
|
+
width,
|
|
465
|
+
"↑↓/<C-p>/<C-n> move · <tab> enter folder · <enter> open · <M-backspace> parent · <esc> cancel",
|
|
466
|
+
),
|
|
467
|
+
),
|
|
468
|
+
);
|
|
469
|
+
return lines;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
get focused(): boolean {
|
|
473
|
+
return this.searchInput.focused;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
set focused(value: boolean) {
|
|
477
|
+
this.searchInput.focused = value;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
invalidate(): void {
|
|
481
|
+
this.searchInput.invalidate();
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
handleInput(data: string): void {
|
|
485
|
+
if (matchesKey(data, Key.ctrl("c")) || matchesKey(data, Key.escape)) {
|
|
486
|
+
this.done(null);
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
if (matchesKey(data, Key.up) || matchesKey(data, Key.ctrl("p"))) {
|
|
490
|
+
this.move(-1);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
if (matchesKey(data, Key.down) || matchesKey(data, Key.ctrl("n"))) {
|
|
494
|
+
this.move(1);
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
if (matchesKey(data, Key.tab)) {
|
|
498
|
+
this.enterSelectedDirectory();
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (matchesKey(data, Key.enter)) {
|
|
502
|
+
this.chooseSelectedPath();
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
if (getKeybindings().matches(data, "tui.editor.deleteWordBackward")) {
|
|
506
|
+
this.deletePathSegmentBackward();
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const before = this.search;
|
|
511
|
+
const beforeDir = dirPrefix(before);
|
|
512
|
+
this.searchInput.handleInput(data);
|
|
513
|
+
const after = this.search;
|
|
514
|
+
if (after !== before) {
|
|
515
|
+
if (dirPrefix(after) !== beforeDir) this.refresh();
|
|
516
|
+
else this.clampSelection();
|
|
517
|
+
}
|
|
518
|
+
this.requestRender();
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
private get search(): string {
|
|
522
|
+
return this.searchInput.getValue();
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
private set search(value: string) {
|
|
526
|
+
setInputValueAtEnd(this.searchInput, value);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
private deletePathSegmentBackward(): void {
|
|
530
|
+
const before = this.search;
|
|
531
|
+
const trimmed = before.replace(/\/+$/, "");
|
|
532
|
+
const slash = trimmed.lastIndexOf("/");
|
|
533
|
+
if (slash < 0) return;
|
|
534
|
+
const next = trimmed.slice(0, slash + 1);
|
|
535
|
+
if (next === before) return;
|
|
536
|
+
this.search = next || "/";
|
|
537
|
+
this.refresh();
|
|
538
|
+
this.requestRender();
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
private refresh(): void {
|
|
542
|
+
try {
|
|
543
|
+
this.entries = readFileEntries(dirPrefix(this.search));
|
|
544
|
+
this.error = undefined;
|
|
545
|
+
this.selectedIndex = Math.min(1, Math.max(0, this.entries.length - 1));
|
|
546
|
+
} catch (error) {
|
|
547
|
+
this.entries = [];
|
|
548
|
+
this.selectedIndex = 0;
|
|
549
|
+
this.error = error instanceof Error ? error.message : String(error);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
private header(width: number): string {
|
|
554
|
+
const entries = this.filteredEntries();
|
|
555
|
+
const total = Math.max(1, entries.length);
|
|
556
|
+
const index = Math.min(this.selectedIndex + 1, total);
|
|
557
|
+
const prefix = `${index}/${total}\tFind file: `;
|
|
558
|
+
const input = renderInputChild(
|
|
559
|
+
this.searchInput,
|
|
560
|
+
Math.max(1, width - visibleWidth(prefix)),
|
|
561
|
+
);
|
|
562
|
+
return this.theme.fg("accent", fits(width, `${prefix}${input}`));
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
private border(width: number, color: "accent" | "dim" = "accent"): string {
|
|
566
|
+
return this.theme.fg(color, "─".repeat(Math.max(0, width)));
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
private renderEntries(lines: string[], width: number): void {
|
|
570
|
+
if (this.error) {
|
|
571
|
+
lines.push(this.theme.fg("dim", indent(width, this.error)));
|
|
572
|
+
this.padRows(lines, width, 1);
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
const entries = this.filteredEntries();
|
|
576
|
+
if (entries.length === 0) {
|
|
577
|
+
lines.push(
|
|
578
|
+
this.theme.fg(
|
|
579
|
+
"dim",
|
|
580
|
+
indent(width, this.search ? "No matches." : "No entries."),
|
|
581
|
+
),
|
|
582
|
+
);
|
|
583
|
+
this.padRows(lines, width, 1);
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
let rendered = 0;
|
|
588
|
+
const start = this.visibleStart(entries.length);
|
|
589
|
+
const end = Math.min(entries.length, start + FILE_EXPLORER_MAX_VISIBLE);
|
|
590
|
+
for (let i = start; i < end; i++) {
|
|
591
|
+
lines.push(
|
|
592
|
+
this.entryLine(width, entries[i]!, {
|
|
593
|
+
selected: i === this.selectedIndex,
|
|
594
|
+
}),
|
|
595
|
+
);
|
|
596
|
+
rendered++;
|
|
597
|
+
}
|
|
598
|
+
this.padRows(lines, width, rendered);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
private entryLine(
|
|
602
|
+
width: number,
|
|
603
|
+
entry: FileEntry,
|
|
604
|
+
options: { selected: boolean },
|
|
605
|
+
): string {
|
|
606
|
+
if (entry.name === "./") return this.currentDirLine(width, options);
|
|
607
|
+
const left = `${options.selected ? "›" : " "} ${entry.name}`;
|
|
608
|
+
const meta = `${entry.mode} ${entry.size.padStart(5)} ${relativeTime(entry.modified)}`;
|
|
609
|
+
const metaWidth = Math.min(38, Math.max(0, Math.floor(width * 0.48)));
|
|
610
|
+
const renderedMeta = fits(metaWidth, meta);
|
|
611
|
+
const renderedLeft = fits(
|
|
612
|
+
Math.max(0, width - visibleWidth(renderedMeta) - 1),
|
|
613
|
+
left,
|
|
614
|
+
);
|
|
615
|
+
const gap = " ".repeat(
|
|
616
|
+
Math.max(
|
|
617
|
+
1,
|
|
618
|
+
width - visibleWidth(renderedLeft) - visibleWidth(renderedMeta),
|
|
619
|
+
),
|
|
620
|
+
);
|
|
621
|
+
const styledLeft = options.selected
|
|
622
|
+
? this.theme.fg("accent", renderedLeft)
|
|
623
|
+
: renderedLeft;
|
|
624
|
+
return `${styledLeft}${gap}${this.theme.fg("dim", renderedMeta)}`;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
private currentDirLine(
|
|
628
|
+
width: number,
|
|
629
|
+
options: { selected: boolean },
|
|
630
|
+
): string {
|
|
631
|
+
const marker = options.selected ? "›" : " ";
|
|
632
|
+
const name = `${marker} ./`;
|
|
633
|
+
const note = " (open current dir)";
|
|
634
|
+
const availableNoteWidth = Math.max(0, width - visibleWidth(name));
|
|
635
|
+
const renderedNote = fits(availableNoteWidth, note);
|
|
636
|
+
const renderedName = fits(
|
|
637
|
+
Math.max(0, width - visibleWidth(renderedNote)),
|
|
638
|
+
name,
|
|
639
|
+
);
|
|
640
|
+
const padding = " ".repeat(
|
|
641
|
+
Math.max(
|
|
642
|
+
0,
|
|
643
|
+
width - visibleWidth(renderedName) - visibleWidth(renderedNote),
|
|
644
|
+
),
|
|
645
|
+
);
|
|
646
|
+
const styledName = options.selected
|
|
647
|
+
? this.theme.fg("accent", renderedName)
|
|
648
|
+
: renderedName;
|
|
649
|
+
return `${styledName}${this.theme.fg("dim", renderedNote)}${padding}`;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
private visibleStart(total: number): number {
|
|
653
|
+
if (total <= FILE_EXPLORER_MAX_VISIBLE) return 0;
|
|
654
|
+
const half = Math.floor(FILE_EXPLORER_MAX_VISIBLE / 2);
|
|
655
|
+
return Math.min(
|
|
656
|
+
Math.max(0, this.selectedIndex - half),
|
|
657
|
+
total - FILE_EXPLORER_MAX_VISIBLE,
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
private padRows(lines: string[], width: number, rendered: number): void {
|
|
662
|
+
for (let i = rendered; i < FILE_EXPLORER_MAX_VISIBLE; i++) {
|
|
663
|
+
lines.push(" ".repeat(Math.max(0, width)));
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
private filteredEntries(): FileEntry[] {
|
|
668
|
+
const query = this.search.trim().split("/").pop() ?? "";
|
|
669
|
+
if (!query) return this.entries;
|
|
670
|
+
return fuzzyFilter(this.entries, query, (entry) => entry.name);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
private clampSelection(): void {
|
|
674
|
+
const maxIndex = Math.max(0, this.filteredEntries().length - 1);
|
|
675
|
+
this.selectedIndex = Math.max(0, Math.min(this.selectedIndex, maxIndex));
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
private move(delta: number): void {
|
|
679
|
+
const entries = this.filteredEntries();
|
|
680
|
+
if (entries.length === 0) return;
|
|
681
|
+
this.selectedIndex =
|
|
682
|
+
(this.selectedIndex + delta + entries.length) % entries.length;
|
|
683
|
+
this.requestRender();
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
private selected(): FileEntry | undefined {
|
|
687
|
+
return this.filteredEntries()[this.selectedIndex];
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
private enterSelectedDirectory(): void {
|
|
691
|
+
const entry = this.selected();
|
|
692
|
+
if (!entry?.isDirectory) return;
|
|
693
|
+
const next = `${normalizeExistingDir(entry.path)}/`;
|
|
694
|
+
if (!next) return;
|
|
695
|
+
this.search = next;
|
|
696
|
+
this.refresh();
|
|
697
|
+
this.requestRender();
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
private chooseSelectedPath(): void {
|
|
701
|
+
const entry = this.selected();
|
|
702
|
+
if (entry) this.done(entry.path);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
class ProjectFilePicker implements Component, Focusable {
|
|
707
|
+
private selectedIndex = 0;
|
|
708
|
+
private readonly searchInput = new Input();
|
|
709
|
+
|
|
710
|
+
constructor(
|
|
711
|
+
private readonly cwd: string,
|
|
712
|
+
private readonly files: string[],
|
|
713
|
+
private readonly theme: Theme,
|
|
714
|
+
private readonly done: (path: string | null) => void,
|
|
715
|
+
private readonly requestRender: () => void,
|
|
716
|
+
) {}
|
|
717
|
+
|
|
718
|
+
render(width: number): string[] {
|
|
719
|
+
const lines: string[] = [];
|
|
720
|
+
const filtered = this.filteredFiles();
|
|
721
|
+
lines.push(this.border(width));
|
|
722
|
+
lines.push(this.header(width, filtered.length));
|
|
723
|
+
lines.push(this.border(width, "dim"));
|
|
724
|
+
this.renderFiles(lines, width, filtered);
|
|
725
|
+
lines.push(this.border(width));
|
|
726
|
+
lines.push(
|
|
727
|
+
this.theme.fg(
|
|
728
|
+
"dim",
|
|
729
|
+
fits(width, "↑↓/<C-p>/<C-n> move · <enter> open · <esc> cancel"),
|
|
730
|
+
),
|
|
731
|
+
);
|
|
732
|
+
return lines;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
get focused(): boolean {
|
|
736
|
+
return this.searchInput.focused;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
set focused(value: boolean) {
|
|
740
|
+
this.searchInput.focused = value;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
invalidate(): void {
|
|
744
|
+
this.searchInput.invalidate();
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
handleInput(data: string): void {
|
|
748
|
+
if (matchesKey(data, Key.ctrl("c")) || matchesKey(data, Key.escape)) {
|
|
749
|
+
this.done(null);
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
if (matchesKey(data, Key.up) || matchesKey(data, Key.ctrl("p"))) {
|
|
753
|
+
this.move(-1);
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
if (matchesKey(data, Key.down) || matchesKey(data, Key.ctrl("n"))) {
|
|
757
|
+
this.move(1);
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
if (matchesKey(data, Key.enter)) {
|
|
761
|
+
this.chooseSelectedFile();
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const before = this.searchInput.getValue();
|
|
766
|
+
this.searchInput.handleInput(data);
|
|
767
|
+
if (this.searchInput.getValue() !== before) this.clampSelection();
|
|
768
|
+
this.requestRender();
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
private header(width: number, count: number): string {
|
|
772
|
+
const total = Math.max(1, count);
|
|
773
|
+
const index = Math.min(this.selectedIndex + 1, total);
|
|
774
|
+
const prefix = `${index}/${total}\tProject file: `;
|
|
775
|
+
const input = renderInputChild(
|
|
776
|
+
this.searchInput,
|
|
777
|
+
Math.max(1, width - visibleWidth(prefix)),
|
|
778
|
+
);
|
|
779
|
+
return this.theme.fg("accent", fits(width, `${prefix}${input}`));
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
private border(width: number, color: "accent" | "dim" = "accent"): string {
|
|
783
|
+
return this.theme.fg(color, "─".repeat(Math.max(0, width)));
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
private renderFiles(lines: string[], width: number, files: string[]): void {
|
|
787
|
+
if (files.length === 0) {
|
|
788
|
+
lines.push(this.theme.fg("dim", indent(width, "No matches.")));
|
|
789
|
+
this.padRows(lines, width, 1);
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
let rendered = 0;
|
|
794
|
+
const start = this.visibleStart(files.length);
|
|
795
|
+
const end = Math.min(files.length, start + PROJECT_PICKER_MAX_VISIBLE);
|
|
796
|
+
for (let i = start; i < end; i++) {
|
|
797
|
+
const marker = i === this.selectedIndex ? "›" : " ";
|
|
798
|
+
const line = fits(width, `${marker} ${files[i]}`);
|
|
799
|
+
lines.push(
|
|
800
|
+
i === this.selectedIndex ? this.theme.fg("accent", line) : line,
|
|
801
|
+
);
|
|
802
|
+
rendered++;
|
|
803
|
+
}
|
|
804
|
+
this.padRows(lines, width, rendered);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
private padRows(lines: string[], width: number, rendered: number): void {
|
|
808
|
+
for (let i = rendered; i < PROJECT_PICKER_MAX_VISIBLE; i++) {
|
|
809
|
+
lines.push(" ".repeat(Math.max(0, width)));
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
private filteredFiles(): string[] {
|
|
814
|
+
const query = this.searchInput.getValue().trim();
|
|
815
|
+
return query ? fuzzyFilter(this.files, query, (file) => file) : this.files;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
private visibleStart(total: number): number {
|
|
819
|
+
if (total <= PROJECT_PICKER_MAX_VISIBLE) return 0;
|
|
820
|
+
const half = Math.floor(PROJECT_PICKER_MAX_VISIBLE / 2);
|
|
821
|
+
return Math.min(
|
|
822
|
+
Math.max(0, this.selectedIndex - half),
|
|
823
|
+
total - PROJECT_PICKER_MAX_VISIBLE,
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
private clampSelection(): void {
|
|
828
|
+
const maxIndex = Math.max(0, this.filteredFiles().length - 1);
|
|
829
|
+
this.selectedIndex = Math.max(0, Math.min(this.selectedIndex, maxIndex));
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
private move(delta: number): void {
|
|
833
|
+
const files = this.filteredFiles();
|
|
834
|
+
if (files.length === 0) return;
|
|
835
|
+
this.selectedIndex =
|
|
836
|
+
(this.selectedIndex + delta + files.length) % files.length;
|
|
837
|
+
this.requestRender();
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
private chooseSelectedFile(): void {
|
|
841
|
+
const file = this.filteredFiles()[this.selectedIndex];
|
|
842
|
+
if (file) this.done(path.join(this.cwd, file));
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
async function projectFiles(cwd: string): Promise<string[]> {
|
|
847
|
+
const output = await execText(
|
|
848
|
+
rgPath,
|
|
849
|
+
["--files", "--hidden", "--glob", "!.git/**"],
|
|
850
|
+
{ cwd },
|
|
851
|
+
);
|
|
852
|
+
return output
|
|
853
|
+
.split(/\r?\n/)
|
|
854
|
+
.map((file) => file.trim())
|
|
855
|
+
.filter(Boolean)
|
|
856
|
+
.sort((a, b) => a.localeCompare(b));
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
async function choosePath(ctx: ExtensionContext): Promise<string | null> {
|
|
860
|
+
if (!ctx.hasUI) return null;
|
|
861
|
+
return (
|
|
862
|
+
(await ctx.ui.custom<string | null>((tui, theme, _keybindings, done) => {
|
|
863
|
+
return new FileExplorer(ctx.cwd, theme as Theme, done, () =>
|
|
864
|
+
tui.requestRender(),
|
|
865
|
+
);
|
|
866
|
+
})) ?? null
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
async function chooseProjectFile(
|
|
871
|
+
ctx: ExtensionContext,
|
|
872
|
+
): Promise<string | null> {
|
|
873
|
+
if (!ctx.hasUI) return null;
|
|
874
|
+
const files = await projectFiles(ctx.cwd);
|
|
875
|
+
return (
|
|
876
|
+
(await ctx.ui.custom<string | null>((tui, theme, _keybindings, done) => {
|
|
877
|
+
return new ProjectFilePicker(ctx.cwd, files, theme as Theme, done, () =>
|
|
878
|
+
tui.requestRender(),
|
|
879
|
+
);
|
|
880
|
+
})) ?? null
|
|
881
|
+
);
|
|
111
882
|
}
|
|
112
883
|
|
|
113
884
|
async function ensureEmacsServer() {
|
|
114
885
|
state.serverStartPromise ??= (async () => {
|
|
886
|
+
stopLegacyDefaultServerIfNeeded();
|
|
887
|
+
|
|
115
888
|
try {
|
|
116
889
|
await emacsClient(["--eval", "(emacs-pid)"], { timeoutMs: 2000 });
|
|
890
|
+
state.startedEmacsServer = true;
|
|
891
|
+
startEmacsWatchdog("watch-existing");
|
|
117
892
|
return;
|
|
118
893
|
} catch {
|
|
119
|
-
// No reachable server. Start
|
|
894
|
+
// No reachable named server. Start one under watchdog supervision.
|
|
120
895
|
}
|
|
121
896
|
|
|
122
|
-
|
|
123
|
-
await
|
|
897
|
+
startEmacsWatchdog("start-daemon");
|
|
898
|
+
await waitForEmacsServer(15000);
|
|
124
899
|
state.startedEmacsServer = true;
|
|
125
|
-
|
|
126
|
-
|
|
900
|
+
})().catch((error) => {
|
|
901
|
+
state.serverStartPromise = undefined;
|
|
902
|
+
throw error;
|
|
903
|
+
});
|
|
127
904
|
|
|
128
905
|
return state.serverStartPromise;
|
|
129
906
|
}
|
|
130
907
|
|
|
131
908
|
async function stopEmacsServer() {
|
|
132
|
-
if (!state.startedEmacsServer) return;
|
|
909
|
+
if (!state.startedEmacsServer && !state.serverStartPromise) return;
|
|
133
910
|
|
|
134
911
|
try {
|
|
135
912
|
await state.serverStartPromise;
|
|
136
913
|
} catch {
|
|
137
|
-
|
|
914
|
+
// Start failure means there may be nothing to stop.
|
|
138
915
|
}
|
|
139
916
|
|
|
140
|
-
|
|
917
|
+
try {
|
|
918
|
+
await emacsClient(["--eval", "(kill-emacs)"], { timeoutMs: 5000 });
|
|
919
|
+
} catch {
|
|
920
|
+
// Watchdog still handles parent-death cleanup if graceful stop fails.
|
|
921
|
+
}
|
|
141
922
|
state.startedEmacsServer = false;
|
|
142
923
|
state.serverStartPromise = undefined;
|
|
924
|
+
state.watchdogPid = undefined;
|
|
143
925
|
}
|
|
144
926
|
|
|
145
|
-
|
|
927
|
+
function stopEmacsServerSync() {
|
|
928
|
+
if (!state.startedEmacsServer && !state.serverStartPromise) return;
|
|
929
|
+
emacsClientSync(["--eval", "(kill-emacs)"]);
|
|
930
|
+
state.startedEmacsServer = false;
|
|
931
|
+
state.serverStartPromise = undefined;
|
|
932
|
+
state.watchdogPid = undefined;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function installProcessShutdownHandler() {
|
|
936
|
+
const handlerVersion = 2;
|
|
937
|
+
if (state.processShutdownHandlerVersion === handlerVersion) return;
|
|
938
|
+
state.processShutdownHandlerVersion = handlerVersion;
|
|
939
|
+
process.on("exit", () => stopEmacsServerSync());
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
async function openEmacsWithArgs(
|
|
943
|
+
ctx: ExtensionContext,
|
|
944
|
+
args: string[],
|
|
945
|
+
errorPrefix = "Failed to start emacsclient",
|
|
946
|
+
) {
|
|
146
947
|
if (!ctx.hasUI) {
|
|
147
948
|
ctx.ui.notify("emacsclient requires TUI mode", "error");
|
|
148
949
|
return;
|
|
149
950
|
}
|
|
150
951
|
|
|
151
952
|
await ctx.ui.custom((tui, _theme, _keybindings, done) => {
|
|
152
|
-
emacsClient(
|
|
953
|
+
emacsClient(args, {
|
|
153
954
|
cwd: ctx.cwd,
|
|
154
955
|
stdio: "inherit",
|
|
155
956
|
onBefore: () => {
|
|
@@ -164,7 +965,7 @@ async function openEmacsClient(ctx: ExtensionContext) {
|
|
|
164
965
|
})
|
|
165
966
|
.then(() => done(null))
|
|
166
967
|
.catch((error) => {
|
|
167
|
-
ctx.ui.notify(
|
|
968
|
+
ctx.ui.notify(`${errorPrefix}: ${error.message}`, "error");
|
|
168
969
|
done(null);
|
|
169
970
|
});
|
|
170
971
|
|
|
@@ -172,7 +973,43 @@ async function openEmacsClient(ctx: ExtensionContext) {
|
|
|
172
973
|
});
|
|
173
974
|
}
|
|
174
975
|
|
|
976
|
+
async function openEmacsClient(ctx: ExtensionContext) {
|
|
977
|
+
await openEmacsWithArgs(ctx, emacsClientArgs(ctx.cwd));
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
async function openEmacsPath(ctx: ExtensionContext, targetPath: string) {
|
|
981
|
+
state.lastEditedFile = statSync(targetPath).isDirectory()
|
|
982
|
+
? state.lastEditedFile
|
|
983
|
+
: targetPath;
|
|
984
|
+
await openEmacsWithArgs(ctx, [
|
|
985
|
+
"-nw",
|
|
986
|
+
"-a",
|
|
987
|
+
"",
|
|
988
|
+
"-e",
|
|
989
|
+
expressionForPath(targetPath),
|
|
990
|
+
]);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
async function runFindFile(ctx: ExtensionContext) {
|
|
994
|
+
const targetPath = await choosePath(ctx);
|
|
995
|
+
if (targetPath) await openEmacsPath(ctx, targetPath);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
async function runProjectFindFile(ctx: ExtensionContext) {
|
|
999
|
+
try {
|
|
1000
|
+
const targetPath = await chooseProjectFile(ctx);
|
|
1001
|
+
if (targetPath) await openEmacsPath(ctx, targetPath);
|
|
1002
|
+
} catch (error) {
|
|
1003
|
+
ctx.ui.notify(
|
|
1004
|
+
`Failed to list project files: ${error instanceof Error ? error.message : String(error)}`,
|
|
1005
|
+
"error",
|
|
1006
|
+
);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
175
1010
|
export default function (pi: ExtensionAPI) {
|
|
1011
|
+
installProcessShutdownHandler();
|
|
1012
|
+
|
|
176
1013
|
pi.on("session_start", () => {
|
|
177
1014
|
ensureEmacsServer().catch((error) => {
|
|
178
1015
|
console.error(`[emacs] failed to start server: ${error.message}`);
|
|
@@ -194,10 +1031,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
194
1031
|
handler: async (_args, ctx) => openEmacsClient(ctx),
|
|
195
1032
|
});
|
|
196
1033
|
|
|
1034
|
+
pi.registerCommand("emacs:find-file", {
|
|
1035
|
+
description: "Find and open a file or directory in Emacs",
|
|
1036
|
+
handler: async (_args, ctx) => runFindFile(ctx),
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
pi.registerCommand("emacs:project-find-file", {
|
|
1040
|
+
description: "Fuzzy-find a project file and open it in Emacs",
|
|
1041
|
+
handler: async (_args, ctx) => runProjectFindFile(ctx),
|
|
1042
|
+
});
|
|
1043
|
+
|
|
197
1044
|
pi.registerShortcut("ctrl+g", {
|
|
198
1045
|
description: "Open emacsclient",
|
|
199
1046
|
handler: openEmacsClient,
|
|
200
1047
|
});
|
|
201
1048
|
}
|
|
202
1049
|
|
|
203
|
-
export { ensureEmacsServer, openEmacsClient, stopEmacsServer };
|
|
1050
|
+
export { ensureEmacsServer, openEmacsClient, openEmacsPath, stopEmacsServer };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@liushihao456/pi-emacs",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Pi extension that
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "Pi extension that allows switching to emacs seamlessly in a popup terminal.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"pi-package",
|
|
@@ -30,5 +30,9 @@
|
|
|
30
30
|
},
|
|
31
31
|
"engines": {
|
|
32
32
|
"node": ">=20"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@earendil-works/pi-tui": "^0.79.2",
|
|
36
|
+
"@vscode/ripgrep": "^1.18.0"
|
|
33
37
|
}
|
|
34
38
|
}
|