pi-x-ide 1.4.0 → 1.4.2
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 +183 -114
- package/README.zh.md +183 -114
- package/dist/package.json +10 -4
- package/dist/src/nvim/sidecar-schema.d.ts +25 -0
- package/dist/src/nvim/sidecar-schema.js +64 -0
- package/dist/src/nvim/sidecar-schema.js.map +1 -0
- package/dist/src/nvim/sidecar.d.ts +16 -0
- package/dist/src/nvim/sidecar.js +173 -0
- package/dist/src/nvim/sidecar.js.map +1 -0
- package/dist/src/pi/index.js +10 -2
- package/dist/src/pi/index.js.map +1 -1
- package/dist/src/pi/install.js +22 -1
- package/dist/src/pi/install.js.map +1 -1
- package/dist/src/shared/ide-server.d.ts +20 -0
- package/dist/src/shared/ide-server.js +144 -0
- package/dist/src/shared/ide-server.js.map +1 -0
- package/dist/src/shared/lock-file.d.ts +16 -0
- package/dist/src/shared/lock-file.js +58 -0
- package/dist/src/shared/lock-file.js.map +1 -0
- package/dist/src/shared/paths.js +8 -1
- package/dist/src/shared/paths.js.map +1 -1
- package/dist/src/shared/protocol.d.ts +1 -1
- package/dist/src/shared/schema.js +1 -1
- package/dist/src/shared/schema.js.map +1 -1
- package/dist/test/nvim-sidecar.test.d.ts +1 -0
- package/dist/test/nvim-sidecar.test.js +148 -0
- package/dist/test/nvim-sidecar.test.js.map +1 -0
- package/dist/test/shared.test.js +10 -0
- package/dist/test/shared.test.js.map +1 -1
- package/nvim/bin/pi-x-ide-nvim-sidecar.cjs +21 -0
- package/nvim/doc/pi-x-ide.txt +112 -0
- package/nvim/lua/pi_x_ide/init.lua +442 -0
- package/nvim/plugin/pi-x-ide.lua +15 -0
- package/package.json +10 -4
- package/src/nvim/sidecar-schema.ts +71 -0
- package/src/nvim/sidecar.ts +219 -0
- package/src/pi/index.ts +12 -2
- package/src/pi/install.ts +24 -1
- package/src/shared/ide-server.ts +120 -0
- package/src/shared/lock-file.ts +65 -0
- package/src/shared/paths.ts +8 -1
- package/src/shared/protocol.ts +1 -1
- package/src/shared/schema.ts +1 -1
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createInterface } from "node:readline";
|
|
3
|
+
import { LOCK_DIR_ENV, type EditorSelectionSnapshot, type IdeLockFile } from "../shared/protocol";
|
|
4
|
+
import { formatRangeMention } from "../shared/format";
|
|
5
|
+
import { IdeWebSocketServer } from "../shared/ide-server";
|
|
6
|
+
import {
|
|
7
|
+
createAuthToken,
|
|
8
|
+
createIdeLockFile,
|
|
9
|
+
createIdeLockFilePath,
|
|
10
|
+
refreshIdeLockFile,
|
|
11
|
+
removeIdeLockFile,
|
|
12
|
+
writeIdeLockFile,
|
|
13
|
+
} from "../shared/lock-file";
|
|
14
|
+
import { parseJsonLine, parseNvimSidecarMessage, parseSidecarConfig, type NvimSidecarMessage } from "./sidecar-schema";
|
|
15
|
+
|
|
16
|
+
export interface NvimSidecarOptions {
|
|
17
|
+
workspaceFolders?: string[];
|
|
18
|
+
name?: string;
|
|
19
|
+
lockDir?: string;
|
|
20
|
+
stdin?: NodeJS.ReadableStream;
|
|
21
|
+
stdout?: NodeJS.WritableStream;
|
|
22
|
+
stderr?: NodeJS.WritableStream;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface NvimSidecarHandle {
|
|
26
|
+
server: IdeWebSocketServer;
|
|
27
|
+
lockFilePath: string;
|
|
28
|
+
stop: () => Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface RuntimeState {
|
|
32
|
+
latestSelection?: EditorSelectionSnapshot;
|
|
33
|
+
workspaceFolders: string[];
|
|
34
|
+
lockFile?: IdeLockFile;
|
|
35
|
+
lockFilePath?: string;
|
|
36
|
+
stopped: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function startNvimSidecar(options: NvimSidecarOptions = {}): Promise<NvimSidecarHandle> {
|
|
40
|
+
const previousLockDir = process.env[LOCK_DIR_ENV];
|
|
41
|
+
if (options.lockDir) process.env[LOCK_DIR_ENV] = options.lockDir;
|
|
42
|
+
|
|
43
|
+
const state: RuntimeState = {
|
|
44
|
+
workspaceFolders: normalizeWorkspaceFolders(options.workspaceFolders),
|
|
45
|
+
stopped: false,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const authToken = createAuthToken();
|
|
49
|
+
const server = new IdeWebSocketServer(
|
|
50
|
+
authToken,
|
|
51
|
+
{ name: options.name ?? "Neovim", ide: "nvim" },
|
|
52
|
+
() => state.latestSelection,
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const port = await server.start();
|
|
56
|
+
state.lockFilePath = createIdeLockFilePath("nvim", port);
|
|
57
|
+
state.lockFile = createIdeLockFile({
|
|
58
|
+
ide: "nvim",
|
|
59
|
+
name: options.name ?? "Neovim",
|
|
60
|
+
port,
|
|
61
|
+
authToken,
|
|
62
|
+
workspaceFolders: state.workspaceFolders,
|
|
63
|
+
});
|
|
64
|
+
await writeIdeLockFile(state.lockFilePath, state.lockFile);
|
|
65
|
+
|
|
66
|
+
const stdout = options.stdout ?? process.stdout;
|
|
67
|
+
stdout.write(JSON.stringify({ type: "ready", port, lockFilePath: state.lockFilePath }) + "\n");
|
|
68
|
+
|
|
69
|
+
const stop = async () => {
|
|
70
|
+
if (state.stopped) return;
|
|
71
|
+
state.stopped = true;
|
|
72
|
+
await removeIdeLockFile(state.lockFilePath);
|
|
73
|
+
await server.stop();
|
|
74
|
+
if (options.lockDir) {
|
|
75
|
+
if (previousLockDir === undefined) delete process.env[LOCK_DIR_ENV];
|
|
76
|
+
else process.env[LOCK_DIR_ENV] = previousLockDir;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const stdin = options.stdin ?? process.stdin;
|
|
81
|
+
const stderr = options.stderr ?? process.stderr;
|
|
82
|
+
const rl = createInterface({ input: stdin });
|
|
83
|
+
rl.on("line", (line) => {
|
|
84
|
+
const trimmed = line.trim();
|
|
85
|
+
if (!trimmed) return;
|
|
86
|
+
const parsed = parseJsonLine(trimmed);
|
|
87
|
+
const config = parseSidecarConfig(parsed);
|
|
88
|
+
if (config && !("type" in (parsed as Record<string, unknown>))) {
|
|
89
|
+
void applyConfig(state, config, stderr);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const message = parseNvimSidecarMessage(parsed);
|
|
94
|
+
if (!message) {
|
|
95
|
+
stderr.write(`pi-x-ide nvim sidecar: ignored malformed message: ${trimmed}\n`);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
void handleMessage(state, server, message, stderr, stop);
|
|
99
|
+
});
|
|
100
|
+
rl.on("close", () => void stop());
|
|
101
|
+
|
|
102
|
+
const cleanup = () => void stop();
|
|
103
|
+
process.once("SIGINT", cleanup);
|
|
104
|
+
process.once("SIGTERM", cleanup);
|
|
105
|
+
process.once("beforeExit", cleanup);
|
|
106
|
+
|
|
107
|
+
return { server, lockFilePath: state.lockFilePath, stop };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function applyConfig(
|
|
111
|
+
state: RuntimeState,
|
|
112
|
+
config: { workspaceFolders?: string[] },
|
|
113
|
+
stderr: NodeJS.WritableStream,
|
|
114
|
+
) {
|
|
115
|
+
if (config.workspaceFolders) state.workspaceFolders = normalizeWorkspaceFolders(config.workspaceFolders);
|
|
116
|
+
if (!state.lockFilePath || !state.lockFile) return;
|
|
117
|
+
state.lockFile = refreshIdeLockFile(state.lockFile, state.workspaceFolders);
|
|
118
|
+
try {
|
|
119
|
+
await writeIdeLockFile(state.lockFilePath, state.lockFile);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
stderr.write(`pi-x-ide nvim sidecar: failed to refresh lock file: ${errorMessage(error)}\n`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function handleMessage(
|
|
126
|
+
state: RuntimeState,
|
|
127
|
+
server: IdeWebSocketServer,
|
|
128
|
+
message: NvimSidecarMessage,
|
|
129
|
+
stderr: NodeJS.WritableStream,
|
|
130
|
+
stop: () => Promise<void>,
|
|
131
|
+
): Promise<void> {
|
|
132
|
+
switch (message.type) {
|
|
133
|
+
case "selection_changed": {
|
|
134
|
+
const snapshot = withNvimSource(message.snapshot);
|
|
135
|
+
state.latestSelection = snapshot;
|
|
136
|
+
server.broadcast({
|
|
137
|
+
jsonrpc: "2.0",
|
|
138
|
+
method: "selection_changed",
|
|
139
|
+
params: { ...snapshot, receivedAt: snapshot.receivedAt ?? Date.now() },
|
|
140
|
+
});
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
case "selection_cleared":
|
|
144
|
+
state.latestSelection = undefined;
|
|
145
|
+
server.broadcast({
|
|
146
|
+
jsonrpc: "2.0",
|
|
147
|
+
method: "selection_cleared",
|
|
148
|
+
params: { source: "nvim", reason: message.reason ?? "no-active-editor", receivedAt: Date.now() },
|
|
149
|
+
});
|
|
150
|
+
return;
|
|
151
|
+
case "at_mentioned": {
|
|
152
|
+
const snapshot = withNvimSource(message.snapshot);
|
|
153
|
+
state.latestSelection = snapshot;
|
|
154
|
+
const rangeText = message.rangeText || formatRangeMention(snapshot);
|
|
155
|
+
server.broadcast({
|
|
156
|
+
jsonrpc: "2.0",
|
|
157
|
+
method: "at_mentioned",
|
|
158
|
+
params: { ...snapshot, rangeText, receivedAt: snapshot.receivedAt ?? Date.now() },
|
|
159
|
+
});
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
case "workspace_changed":
|
|
163
|
+
await applyConfig(state, { workspaceFolders: message.workspaceFolders }, stderr);
|
|
164
|
+
return;
|
|
165
|
+
case "shutdown":
|
|
166
|
+
await stop();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function withNvimSource<T extends EditorSelectionSnapshot>(snapshot: T): T {
|
|
172
|
+
return { ...snapshot, source: "nvim" };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function normalizeWorkspaceFolders(workspaceFolders: string[] | undefined): string[] {
|
|
176
|
+
const folders = workspaceFolders?.filter((folder) => folder.length > 0) ?? [];
|
|
177
|
+
return folders.length > 0 ? folders : [process.cwd()];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function errorMessage(error: unknown): string {
|
|
181
|
+
return error instanceof Error ? error.message : String(error);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function parseCliArgs(argv: string[]): NvimSidecarOptions | "help" {
|
|
185
|
+
const options: NvimSidecarOptions = {};
|
|
186
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
187
|
+
const arg = argv[index];
|
|
188
|
+
if (arg === "--help" || arg === "-h") return "help";
|
|
189
|
+
if (arg === "--name") options.name = argv[++index];
|
|
190
|
+
else if (arg === "--lock-dir") options.lockDir = argv[++index];
|
|
191
|
+
else if (arg === "--workspace-folder") {
|
|
192
|
+
options.workspaceFolders = [...(options.workspaceFolders ?? []), argv[++index]].filter(Boolean);
|
|
193
|
+
} else if (arg === "--workspace-folders") {
|
|
194
|
+
const value = argv[++index];
|
|
195
|
+
const parsed = parseJsonLine(value ?? "");
|
|
196
|
+
const config = parseSidecarConfig({ workspaceFolders: parsed });
|
|
197
|
+
if (config?.workspaceFolders) options.workspaceFolders = config.workspaceFolders;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return options;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function printHelp(): void {
|
|
204
|
+
process.stdout.write(
|
|
205
|
+
`Usage: pi-x-ide-nvim-sidecar [options]\n\nOptions:\n --workspace-folder <path> Add a workspace folder. May be repeated.\n --workspace-folders <json> JSON array of workspace folders.\n --lock-dir <path> Override PI_X_IDE_LOCK_DIR for tests.\n --name <name> Display name reported to Pi.\n --help Show this help.\n`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (require.main === module) {
|
|
210
|
+
const parsed = parseCliArgs(process.argv.slice(2));
|
|
211
|
+
if (parsed === "help") {
|
|
212
|
+
printHelp();
|
|
213
|
+
} else {
|
|
214
|
+
startNvimSidecar(parsed).catch((error: unknown) => {
|
|
215
|
+
process.stderr.write(`pi-x-ide nvim sidecar: ${errorMessage(error)}\n`);
|
|
216
|
+
process.exitCode = 1;
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
package/src/pi/index.ts
CHANGED
|
@@ -91,10 +91,20 @@ async function maybeAutoInstallAndReconnect(
|
|
|
91
91
|
|
|
92
92
|
notifyInstall(ctx, `Pi x IDE extension installed for ${candidate.label}. Trying to connect...`, "info");
|
|
93
93
|
const connected = await retryConnectAfterInstall(runtime, ctx, generation);
|
|
94
|
-
if (!
|
|
94
|
+
if (!isInstallSessionActive(runtime, generation)) return;
|
|
95
|
+
|
|
96
|
+
if (connected && runtime.connectionStatus === "connected") {
|
|
97
|
+
notifyInstall(ctx, `Pi x IDE extension installed for ${candidate.label}. Connected!`, "info");
|
|
98
|
+
} else if (!connected) {
|
|
99
|
+
notifyInstall(
|
|
100
|
+
ctx,
|
|
101
|
+
`Pi x IDE extension installed for ${candidate.label}. If Pi does not connect automatically, reload the IDE window and run /ide.`,
|
|
102
|
+
"warning",
|
|
103
|
+
);
|
|
104
|
+
} else {
|
|
95
105
|
notifyInstall(
|
|
96
106
|
ctx,
|
|
97
|
-
`Pi x IDE extension installed for ${candidate.label}.
|
|
107
|
+
`Pi x IDE extension installed for ${candidate.label}. Connection attempt completed. Run /ide to retry if needed.`,
|
|
98
108
|
"warning",
|
|
99
109
|
);
|
|
100
110
|
}
|
package/src/pi/install.ts
CHANGED
|
@@ -177,6 +177,14 @@ export async function runCli(
|
|
|
177
177
|
args: string[],
|
|
178
178
|
timeoutMs = 15_000,
|
|
179
179
|
): Promise<{ stdout: string; stderr: string }> {
|
|
180
|
+
if (process.platform === "win32" && isCmdOrBatFile(cliPath)) {
|
|
181
|
+
const { stdout, stderr } = await execFileAsync(resolveCmdExe(), ["/d", "/c", cliPath, ...args], {
|
|
182
|
+
timeout: timeoutMs,
|
|
183
|
+
windowsHide: true,
|
|
184
|
+
maxBuffer: 1024 * 1024,
|
|
185
|
+
});
|
|
186
|
+
return { stdout: String(stdout), stderr: String(stderr) };
|
|
187
|
+
}
|
|
180
188
|
const { stdout, stderr } = await execFileAsync(cliPath, args, {
|
|
181
189
|
timeout: timeoutMs,
|
|
182
190
|
windowsHide: true,
|
|
@@ -304,7 +312,13 @@ function parsePathExt(env: NodeJS.ProcessEnv): string[] {
|
|
|
304
312
|
.split(";")
|
|
305
313
|
.map((extension) => extension.trim())
|
|
306
314
|
.filter(Boolean);
|
|
307
|
-
|
|
315
|
+
// On Windows, skip extensionless search to avoid matching
|
|
316
|
+
// non-executable shell scripts (e.g., VS Code ships both
|
|
317
|
+
// `code` (sh) and `code.cmd` — only `.cmd` is executable).
|
|
318
|
+
if (process.platform !== "win32") {
|
|
319
|
+
return values.length > 0 ? ["", ...values] : [""];
|
|
320
|
+
}
|
|
321
|
+
return values;
|
|
308
322
|
}
|
|
309
323
|
|
|
310
324
|
function withExtension(command: string, extension: string): string {
|
|
@@ -312,6 +326,15 @@ function withExtension(command: string, extension: string): string {
|
|
|
312
326
|
return command.toLowerCase().endsWith(extension.toLowerCase()) ? command : `${command}${extension}`;
|
|
313
327
|
}
|
|
314
328
|
|
|
329
|
+
function isCmdOrBatFile(cliPath: string): boolean {
|
|
330
|
+
const lowerPath = cliPath.toLowerCase();
|
|
331
|
+
return lowerPath.endsWith(".cmd") || lowerPath.endsWith(".bat");
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function resolveCmdExe(): string {
|
|
335
|
+
return process.env.ComSpec ?? "cmd.exe";
|
|
336
|
+
}
|
|
337
|
+
|
|
315
338
|
function getExecOutput(error: unknown, key: "stdout" | "stderr"): string {
|
|
316
339
|
if (typeof error !== "object" || error === null || !(key in error)) return "";
|
|
317
340
|
const value = (error as Record<typeof key, unknown>)[key];
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { createServer, type Server } from "node:http";
|
|
2
|
+
import WebSocket, { WebSocketServer } from "ws";
|
|
3
|
+
import {
|
|
4
|
+
AUTH_HEADER,
|
|
5
|
+
PROTOCOL_VERSION,
|
|
6
|
+
type EditorSelectionSnapshot,
|
|
7
|
+
type IdeSource,
|
|
8
|
+
type InitializeResult,
|
|
9
|
+
} from "./protocol";
|
|
10
|
+
import { isJsonRpcRequest } from "./schema";
|
|
11
|
+
import { decodeRawData } from "./ws";
|
|
12
|
+
|
|
13
|
+
export class IdeWebSocketServer {
|
|
14
|
+
private httpServer?: Server;
|
|
15
|
+
private wss?: WebSocketServer;
|
|
16
|
+
private readonly sockets = new Set<WebSocket>();
|
|
17
|
+
|
|
18
|
+
constructor(
|
|
19
|
+
private readonly authToken: string,
|
|
20
|
+
private readonly serverInfo: { name: string; version?: string; ide?: IdeSource },
|
|
21
|
+
private readonly getInitialSelection?: () => EditorSelectionSnapshot | undefined,
|
|
22
|
+
) {}
|
|
23
|
+
|
|
24
|
+
get port(): number {
|
|
25
|
+
const address = this.httpServer?.address();
|
|
26
|
+
if (!address || typeof address === "string") return 0;
|
|
27
|
+
return address.port;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
get clientCount(): number {
|
|
31
|
+
return this.sockets.size;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async start(): Promise<number> {
|
|
35
|
+
this.httpServer = createServer();
|
|
36
|
+
this.wss = new WebSocketServer({
|
|
37
|
+
server: this.httpServer,
|
|
38
|
+
verifyClient: ({ req }, done) => {
|
|
39
|
+
const header = req.headers[AUTH_HEADER];
|
|
40
|
+
const token = Array.isArray(header) ? header[0] : header;
|
|
41
|
+
done(token === this.authToken, token === this.authToken ? undefined : 401, "Unauthorized");
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
this.wss.on("connection", (socket) => {
|
|
46
|
+
this.sockets.add(socket);
|
|
47
|
+
socket.on("close", () => this.sockets.delete(socket));
|
|
48
|
+
socket.on("error", () => this.sockets.delete(socket));
|
|
49
|
+
socket.on("message", (raw) => this.handleMessage(socket, decodeRawData(raw)));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
await new Promise<void>((resolve, reject) => {
|
|
53
|
+
this.httpServer!.once("error", reject);
|
|
54
|
+
this.httpServer!.listen(0, "127.0.0.1", () => {
|
|
55
|
+
this.httpServer!.off("error", reject);
|
|
56
|
+
resolve();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return this.port;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
broadcast(value: unknown): void {
|
|
64
|
+
const text = JSON.stringify(value);
|
|
65
|
+
for (const socket of this.sockets) {
|
|
66
|
+
if (socket.readyState === WebSocket.OPEN) socket.send(text);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async stop(): Promise<void> {
|
|
71
|
+
for (const socket of this.sockets) socket.close();
|
|
72
|
+
this.sockets.clear();
|
|
73
|
+
await Promise.all([
|
|
74
|
+
new Promise<void>((resolve) => this.wss?.close(() => resolve()) ?? resolve()),
|
|
75
|
+
new Promise<void>((resolve) => this.httpServer?.close(() => resolve()) ?? resolve()),
|
|
76
|
+
]);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private handleMessage(socket: WebSocket, text: string): void {
|
|
80
|
+
let parsed: unknown;
|
|
81
|
+
try {
|
|
82
|
+
parsed = JSON.parse(text) as unknown;
|
|
83
|
+
} catch {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!isJsonRpcRequest(parsed)) return;
|
|
88
|
+
if (parsed.method !== "initialize") return;
|
|
89
|
+
|
|
90
|
+
const ide = this.serverInfo.ide ?? "vscode";
|
|
91
|
+
const result: InitializeResult = {
|
|
92
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
93
|
+
server: {
|
|
94
|
+
name: this.serverInfo.name,
|
|
95
|
+
version: this.serverInfo.version,
|
|
96
|
+
ide,
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
socket.send(JSON.stringify({ jsonrpc: "2.0", id: parsed.id, result }));
|
|
101
|
+
|
|
102
|
+
const snapshot = this.getInitialSelection?.();
|
|
103
|
+
socket.send(
|
|
104
|
+
JSON.stringify({
|
|
105
|
+
jsonrpc: "2.0",
|
|
106
|
+
method: snapshot ? "selection_changed" : "selection_cleared",
|
|
107
|
+
params: snapshot
|
|
108
|
+
? {
|
|
109
|
+
...snapshot,
|
|
110
|
+
receivedAt: Date.now(),
|
|
111
|
+
}
|
|
112
|
+
: {
|
|
113
|
+
source: ide,
|
|
114
|
+
reason: "no-active-editor",
|
|
115
|
+
receivedAt: Date.now(),
|
|
116
|
+
},
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { chmod, mkdir, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { LOCK_FILE_EXTENSION, type IdeLockFile, type IdeSource } from "./protocol";
|
|
5
|
+
import { resolveLockDir } from "./paths";
|
|
6
|
+
|
|
7
|
+
export function createAuthToken(): string {
|
|
8
|
+
return randomBytes(32).toString("hex");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createIdeLockFilePath(source: IdeSource, port: number, pid = process.pid): string {
|
|
12
|
+
return join(resolveLockDir(), `${source}-${pid}-${port}${LOCK_FILE_EXTENSION}`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CreateIdeLockFileOptions {
|
|
16
|
+
ide: IdeSource;
|
|
17
|
+
name: string;
|
|
18
|
+
port: number;
|
|
19
|
+
authToken: string;
|
|
20
|
+
workspaceFolders: string[];
|
|
21
|
+
pid?: number;
|
|
22
|
+
now?: Date;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createIdeLockFile(options: CreateIdeLockFileOptions): IdeLockFile {
|
|
26
|
+
const now = (options.now ?? new Date()).toISOString();
|
|
27
|
+
return {
|
|
28
|
+
version: 1,
|
|
29
|
+
ide: options.ide,
|
|
30
|
+
name: options.name,
|
|
31
|
+
transport: "ws",
|
|
32
|
+
host: "127.0.0.1",
|
|
33
|
+
port: options.port,
|
|
34
|
+
authToken: options.authToken,
|
|
35
|
+
workspaceFolders: options.workspaceFolders,
|
|
36
|
+
pid: options.pid ?? process.pid,
|
|
37
|
+
createdAt: now,
|
|
38
|
+
updatedAt: now,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function writeIdeLockFile(path: string, lock: IdeLockFile): Promise<void> {
|
|
43
|
+
const dir = resolveLockDir();
|
|
44
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
45
|
+
await chmod(dir, 0o700).catch(() => undefined);
|
|
46
|
+
|
|
47
|
+
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
48
|
+
await writeFile(tmp, `${JSON.stringify(lock, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
49
|
+
await chmod(tmp, 0o600).catch(() => undefined);
|
|
50
|
+
await rename(tmp, path);
|
|
51
|
+
await chmod(path, 0o600).catch(() => undefined);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function removeIdeLockFile(path: string | undefined): Promise<void> {
|
|
55
|
+
if (!path) return;
|
|
56
|
+
await rm(path, { force: true }).catch(() => undefined);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function refreshIdeLockFile(lock: IdeLockFile, workspaceFolders: string[], now = new Date()): IdeLockFile {
|
|
60
|
+
return {
|
|
61
|
+
...lock,
|
|
62
|
+
workspaceFolders,
|
|
63
|
+
updatedAt: now.toISOString(),
|
|
64
|
+
};
|
|
65
|
+
}
|
package/src/shared/paths.ts
CHANGED
|
@@ -10,7 +10,14 @@ export function resolveLockDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
export function normalizePath(input: string): string {
|
|
13
|
-
|
|
13
|
+
const resolved = resolve(input);
|
|
14
|
+
// Normalize drive letter to uppercase on Windows so that path comparison
|
|
15
|
+
// is case-insensitive (VS Code may write workspace paths with lowercase
|
|
16
|
+
// drive letters while process.cwd() uses uppercase).
|
|
17
|
+
if (process.platform === "win32" && resolved.length >= 2 && resolved[1] === ":") {
|
|
18
|
+
return resolved[0].toUpperCase() + resolved.slice(1);
|
|
19
|
+
}
|
|
20
|
+
return resolved;
|
|
14
21
|
}
|
|
15
22
|
|
|
16
23
|
export function isPathInsideOrEqual(parent: string, child: string): boolean {
|
package/src/shared/protocol.ts
CHANGED
|
@@ -3,7 +3,7 @@ export const LOCK_DIR_ENV = "PI_X_IDE_LOCK_DIR";
|
|
|
3
3
|
export const AUTH_HEADER = "x-pi-x-ide-authorization";
|
|
4
4
|
export const LOCK_FILE_EXTENSION = ".lock";
|
|
5
5
|
|
|
6
|
-
export type IdeSource = "vscode" | "zed" | "unknown";
|
|
6
|
+
export type IdeSource = "vscode" | "zed" | "nvim" | "unknown";
|
|
7
7
|
export type Transport = "ws";
|
|
8
8
|
|
|
9
9
|
export interface IdeLockFile {
|
package/src/shared/schema.ts
CHANGED
|
@@ -22,7 +22,7 @@ function isFiniteNumber(value: unknown): value is number {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
function isIdeSource(value: unknown): value is IdeSource {
|
|
25
|
-
return value === "vscode" || value === "zed" || value === "unknown";
|
|
25
|
+
return value === "vscode" || value === "zed" || value === "nvim" || value === "unknown";
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
export function isIdeLockFile(value: unknown): value is IdeLockFile {
|