pi-x-ide 1.2.0 → 1.3.0
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 +41 -2
- package/README.zh.md +41 -2
- package/dist/package.json +7 -3
- package/dist/src/pi/index.js +14 -3
- package/dist/src/pi/index.js.map +1 -1
- package/dist/src/pi/state.d.ts +3 -0
- package/dist/src/pi/state.js.map +1 -1
- package/dist/src/pi/zed.d.ts +27 -0
- package/dist/src/pi/zed.js +434 -0
- package/dist/src/pi/zed.js.map +1 -0
- package/dist/test/zed.test.d.ts +1 -0
- package/dist/test/zed.test.js +578 -0
- package/dist/test/zed.test.js.map +1 -0
- package/package.json +7 -3
- package/src/pi/index.ts +19 -3
- package/src/pi/state.ts +3 -0
- package/src/pi/zed.ts +508 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-x-ide",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Pi extension package for IDE selection context integration.",
|
|
5
5
|
"files": [
|
|
6
6
|
"dist",
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
"pi-package",
|
|
11
11
|
"pi",
|
|
12
12
|
"ide",
|
|
13
|
-
"vscode"
|
|
13
|
+
"vscode",
|
|
14
|
+
"zed"
|
|
14
15
|
],
|
|
15
16
|
"license": "Apache-2.0",
|
|
16
17
|
"repository": {
|
|
@@ -21,6 +22,9 @@
|
|
|
21
22
|
"url": "https://github.com/balaenis/pi-x-ide/issues"
|
|
22
23
|
},
|
|
23
24
|
"homepage": "https://github.com/balaenis/pi-x-ide#readme",
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=26"
|
|
27
|
+
},
|
|
24
28
|
"type": "commonjs",
|
|
25
29
|
"main": "./dist/src/pi/index.js",
|
|
26
30
|
"dependencies": {
|
|
@@ -32,7 +36,7 @@
|
|
|
32
36
|
"devDependencies": {
|
|
33
37
|
"@earendil-works/pi-coding-agent": "^0.79.0",
|
|
34
38
|
"@eslint/js": "^10.0.1",
|
|
35
|
-
"@types/node": "^
|
|
39
|
+
"@types/node": "^25.9.2",
|
|
36
40
|
"@types/ws": "^8.5.13",
|
|
37
41
|
"@vscode/vsce": "^3.2.2",
|
|
38
42
|
"esbuild": "^0.27.0",
|
package/src/pi/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { registerIdeCommand } from "./commands";
|
|
|
17
17
|
import { clearLatestSelection, registerContextHandlers, setLatestSelection } from "./context";
|
|
18
18
|
import { createRuntime, type PiIdeRuntime } from "./state";
|
|
19
19
|
import { clearIdeUi, updateIdeUi } from "./ui";
|
|
20
|
+
import { startZedPolling, stopZedPolling } from "./zed";
|
|
20
21
|
|
|
21
22
|
const RECONNECT_DELAY_MS = 2_000;
|
|
22
23
|
const INSTALL_RECONNECT_RETRY_MS = 1_500;
|
|
@@ -28,7 +29,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
28
29
|
registerContextHandlers(pi, runtime);
|
|
29
30
|
registerIdeCommand(pi, runtime, {
|
|
30
31
|
refreshCandidates: (ctx) => refreshCandidates(runtime, ctx),
|
|
31
|
-
connectAuto: (ctx) =>
|
|
32
|
+
connectAuto: (ctx) => connectAutoWithZedFallback(runtime, ctx),
|
|
32
33
|
connectCandidate: (candidate, ctx) => connectCandidate(runtime, candidate, ctx),
|
|
33
34
|
disconnect: (ctx, disabled) => disconnect(runtime, ctx, disabled),
|
|
34
35
|
installExtension: (ctx) => installExtension(runtime, ctx),
|
|
@@ -39,18 +40,20 @@ export default function (pi: ExtensionAPI): void {
|
|
|
39
40
|
const generation = runtime.sessionGeneration;
|
|
40
41
|
runtime.ctx = ctx;
|
|
41
42
|
runtime.cwd = ctx.cwd;
|
|
43
|
+
stopZedPolling(runtime);
|
|
42
44
|
if (!runtime.enabled) {
|
|
43
45
|
runtime.connectionStatus = "disabled";
|
|
44
46
|
updateIdeUi(runtime, ctx);
|
|
45
47
|
return;
|
|
46
48
|
}
|
|
47
49
|
void maybeAutoInstallAndReconnect(runtime, ctx, generation);
|
|
48
|
-
await
|
|
50
|
+
await connectAutoWithZedFallback(runtime, ctx, generation);
|
|
49
51
|
});
|
|
50
52
|
|
|
51
53
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
52
54
|
runtime.sessionGeneration += 1;
|
|
53
55
|
runtime.ctx = ctx;
|
|
56
|
+
stopZedPolling(runtime);
|
|
54
57
|
if (runtime.reconnectTimer) clearTimeout(runtime.reconnectTimer);
|
|
55
58
|
runtime.reconnectTimer = undefined;
|
|
56
59
|
runtime.connection?.disconnect();
|
|
@@ -228,6 +231,17 @@ async function refreshCandidates(
|
|
|
228
231
|
return runtime.candidates;
|
|
229
232
|
}
|
|
230
233
|
|
|
234
|
+
async function connectAutoWithZedFallback(
|
|
235
|
+
runtime: PiIdeRuntime,
|
|
236
|
+
ctx: ExtensionContext | ExtensionCommandContext,
|
|
237
|
+
generation = runtime.sessionGeneration,
|
|
238
|
+
): Promise<void> {
|
|
239
|
+
await connectAuto(runtime, ctx);
|
|
240
|
+
if (runtime.connectionStatus !== "connected") {
|
|
241
|
+
startZedPolling(runtime, ctx, { generation });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
231
245
|
async function connectAuto(runtime: PiIdeRuntime, ctx: ExtensionContext | ExtensionCommandContext): Promise<void> {
|
|
232
246
|
runtime.enabled = true;
|
|
233
247
|
const candidates = await refreshCandidates(runtime, ctx);
|
|
@@ -258,6 +272,7 @@ async function connectCandidate(
|
|
|
258
272
|
runtime.ctx = ctx;
|
|
259
273
|
runtime.cwd = ctx.cwd;
|
|
260
274
|
runtime.enabled = true;
|
|
275
|
+
stopZedPolling(runtime);
|
|
261
276
|
if (runtime.reconnectTimer) clearTimeout(runtime.reconnectTimer);
|
|
262
277
|
runtime.reconnectTimer = undefined;
|
|
263
278
|
|
|
@@ -346,6 +361,7 @@ function isCurrentConnection(runtime: PiIdeRuntime, connection: IdeConnection |
|
|
|
346
361
|
|
|
347
362
|
function disconnect(runtime: PiIdeRuntime, ctx: ExtensionContext | ExtensionCommandContext, disabled = false): void {
|
|
348
363
|
runtime.ctx = ctx;
|
|
364
|
+
stopZedPolling(runtime);
|
|
349
365
|
if (runtime.reconnectTimer) clearTimeout(runtime.reconnectTimer);
|
|
350
366
|
runtime.reconnectTimer = undefined;
|
|
351
367
|
const connection = runtime.connection;
|
|
@@ -370,7 +386,7 @@ function scheduleReconnect(runtime: PiIdeRuntime): void {
|
|
|
370
386
|
runtime.reconnectTimer = undefined;
|
|
371
387
|
const ctx = runtime.ctx;
|
|
372
388
|
if (!ctx || !runtime.enabled) return;
|
|
373
|
-
|
|
389
|
+
connectAutoWithZedFallback(runtime, ctx).catch((error: unknown) => {
|
|
374
390
|
runtime.connectionStatus = "error";
|
|
375
391
|
runtime.connectionMessage = error instanceof Error ? error.message : String(error);
|
|
376
392
|
updateIdeUi(runtime);
|
package/src/pi/state.ts
CHANGED
|
@@ -17,6 +17,9 @@ export interface PiIdeRuntime {
|
|
|
17
17
|
attachState: AttachState;
|
|
18
18
|
turnSelection?: EditorSelectionSnapshot;
|
|
19
19
|
reconnectTimer?: NodeJS.Timeout;
|
|
20
|
+
zedPollTimer?: NodeJS.Timeout;
|
|
21
|
+
zedPollSelectionKey?: string;
|
|
22
|
+
zedPollWalMtimeMs?: number;
|
|
20
23
|
installingIdeIds: Set<string>;
|
|
21
24
|
sessionGeneration: number;
|
|
22
25
|
}
|
package/src/pi/zed.ts
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
import { copyFileSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir, tmpdir } from "node:os";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { DatabaseSync } from "node:sqlite";
|
|
5
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent" with {
|
|
6
|
+
"resolution-mode": "import",
|
|
7
|
+
};
|
|
8
|
+
import type { EditorSelectionSnapshot, SelectionRange } from "../shared/protocol";
|
|
9
|
+
import { snapshotKey } from "../shared/format";
|
|
10
|
+
import { isPathInsideOrEqual } from "../shared/paths";
|
|
11
|
+
import { setLatestSelection, clearLatestSelection } from "./context";
|
|
12
|
+
import type { PiIdeRuntime } from "./state";
|
|
13
|
+
import { updateIdeUi } from "./ui";
|
|
14
|
+
|
|
15
|
+
export const PI_X_IDE_ZED_DB_ENV = "PI_X_IDE_ZED_DB";
|
|
16
|
+
export const ZED_POLL_INTERVAL_MS = 1000;
|
|
17
|
+
|
|
18
|
+
// ── Terminal detection ──────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
export function isZedTerminal(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
21
|
+
return env.ZED_TERM === "true" || env.TERM_PROGRAM?.toLowerCase() === "zed";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function isWsl(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
25
|
+
if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) return true;
|
|
26
|
+
if (env !== process.env) return false;
|
|
27
|
+
try {
|
|
28
|
+
return /microsoft|wsl/i.test(readFileSync("/proc/version", "utf8"));
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function normalizeZedPathForHost(input: string, env: NodeJS.ProcessEnv = process.env): string {
|
|
35
|
+
if (!input || !isWsl(env)) return input;
|
|
36
|
+
|
|
37
|
+
const driveMatch = input.match(/^([a-zA-Z]):[\\/](.*)$/);
|
|
38
|
+
if (driveMatch) {
|
|
39
|
+
const drive = driveMatch[1].toLowerCase();
|
|
40
|
+
const rest = driveMatch[2].replaceAll("\\", "/");
|
|
41
|
+
return `/mnt/${drive}/${rest}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const uncMatch = input.match(/^\\\\(?:wsl\$|wsl\.localhost)\\([^\\]+)\\(.*)$/i);
|
|
45
|
+
if (uncMatch) {
|
|
46
|
+
const distro = uncMatch[1];
|
|
47
|
+
const rest = uncMatch[2].replaceAll("\\", "/");
|
|
48
|
+
const currentDistro = env.WSL_DISTRO_NAME;
|
|
49
|
+
if (!currentDistro || distro.toLowerCase() === currentDistro.toLowerCase()) {
|
|
50
|
+
return `/${rest}`;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return input;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── DB path resolution ─────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
export function resolveZedDbPath(env: NodeJS.ProcessEnv = process.env, home: string = homedir()): string | undefined {
|
|
60
|
+
const override = env[PI_X_IDE_ZED_DB_ENV]?.trim();
|
|
61
|
+
if (override) {
|
|
62
|
+
const normalizedOverride = normalizeZedPathForHost(override, env);
|
|
63
|
+
return isFile(normalizedOverride) ? normalizedOverride : undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const candidates = [
|
|
67
|
+
resolve(home, ".local", "share", "zed", "db", "0-stable", "db.sqlite"), // Linux
|
|
68
|
+
resolve(home, "Library", "Application Support", "Zed", "db", "0-stable", "db.sqlite"), // macOS
|
|
69
|
+
resolve(home, "AppData", "Local", "Zed", "db", "0-stable", "db.sqlite"), // Windows
|
|
70
|
+
...zedDbCandidatesFromWindowsEnv(env),
|
|
71
|
+
...zedDbCandidatesFromWslMount(env),
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
return candidates.find(isFile);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function zedDbCandidatesFromWindowsEnv(env: NodeJS.ProcessEnv): string[] {
|
|
78
|
+
const localAppData = env.LOCALAPPDATA?.trim();
|
|
79
|
+
if (localAppData) return [resolve(normalizeZedPathForHost(localAppData, env), "Zed", "db", "0-stable", "db.sqlite")];
|
|
80
|
+
|
|
81
|
+
const userProfile = env.USERPROFILE?.trim();
|
|
82
|
+
if (userProfile) {
|
|
83
|
+
return [
|
|
84
|
+
resolve(normalizeZedPathForHost(userProfile, env), "AppData", "Local", "Zed", "db", "0-stable", "db.sqlite"),
|
|
85
|
+
];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function zedDbCandidatesFromWslMount(env: NodeJS.ProcessEnv): string[] {
|
|
92
|
+
if (!isWsl(env)) return [];
|
|
93
|
+
const usersRoot = "/mnt/c/Users";
|
|
94
|
+
try {
|
|
95
|
+
return readdirSync(usersRoot, { withFileTypes: true })
|
|
96
|
+
.filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
|
|
97
|
+
.map((entry) => resolve(usersRoot, entry.name, "AppData", "Local", "Zed", "db", "0-stable", "db.sqlite"));
|
|
98
|
+
} catch {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function isFile(path: string): boolean {
|
|
104
|
+
try {
|
|
105
|
+
return statSync(path).isFile();
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface ZedDatabaseHandle {
|
|
112
|
+
db: DatabaseSync;
|
|
113
|
+
cleanup: () => void;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function openZedDatabase(dbPath: string, env: NodeJS.ProcessEnv = process.env): ZedDatabaseHandle | undefined {
|
|
117
|
+
// Direct open on live WAL-mode databases can succeed at construction time
|
|
118
|
+
// but fail on the first query on WSL/Windows mounts. Always snapshot
|
|
119
|
+
// under WSL to avoid "disk I/O error" during SQL execution.
|
|
120
|
+
if (isWsl(env)) return openZedDatabaseSnapshot(dbPath);
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
return { db: new DatabaseSync(dbPath, { readOnly: true }), cleanup: () => undefined };
|
|
124
|
+
} catch {
|
|
125
|
+
// Fall back to snapshot if direct open throws (e.g. file lock on non-WSL).
|
|
126
|
+
return openZedDatabaseSnapshot(dbPath);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function openZedDatabaseSnapshot(dbPath: string): ZedDatabaseHandle | undefined {
|
|
131
|
+
let snapshotDir: string | undefined;
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
snapshotDir = mkdtempSync(join(tmpdir(), "pi-x-ide-zed-db-"));
|
|
135
|
+
const cleanupDir = snapshotDir;
|
|
136
|
+
const snapshotPath = join(cleanupDir, "db.sqlite");
|
|
137
|
+
|
|
138
|
+
// Copy the main DB file plus any WAL/SHM sidecars.
|
|
139
|
+
copyFileSync(dbPath, snapshotPath);
|
|
140
|
+
for (const suffix of ["-wal", "-shm"]) {
|
|
141
|
+
const sidecarPath = `${dbPath}${suffix}`;
|
|
142
|
+
if (isFile(sidecarPath)) copyFileSync(sidecarPath, `${snapshotPath}${suffix}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Merge WAL changes into the main database file so we can open
|
|
146
|
+
// read-only afterwards without hitting a disk I/O error on
|
|
147
|
+
// cross-filesystem mounts (WSL / Windows).
|
|
148
|
+
try {
|
|
149
|
+
const dbRW = new DatabaseSync(snapshotPath);
|
|
150
|
+
dbRW.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
151
|
+
dbRW.close();
|
|
152
|
+
} catch {
|
|
153
|
+
// Checkpoint may fail—continue with the stale main DB.
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
db: new DatabaseSync(snapshotPath, { readOnly: true }),
|
|
158
|
+
cleanup: () => rmSync(cleanupDir, { recursive: true, force: true }),
|
|
159
|
+
};
|
|
160
|
+
} catch {
|
|
161
|
+
if (snapshotDir) rmSync(snapshotDir, { recursive: true, force: true });
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── Workspace path parsing ─────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
export function parseZedWorkspacePaths(value: string | null): string[] {
|
|
169
|
+
if (!value) return [];
|
|
170
|
+
const trimmed = value.trim();
|
|
171
|
+
if (!trimmed) return [];
|
|
172
|
+
|
|
173
|
+
// Try JSON array. If it looks like JSON but is malformed, treat it as invalid
|
|
174
|
+
// instead of accidentally interpreting the raw JSON-ish text as a path.
|
|
175
|
+
if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
|
|
176
|
+
try {
|
|
177
|
+
const parsed = JSON.parse(trimmed) as unknown;
|
|
178
|
+
return Array.isArray(parsed)
|
|
179
|
+
? parsed.filter((item): item is string => typeof item === "string" && item.length > 0)
|
|
180
|
+
: [];
|
|
181
|
+
} catch {
|
|
182
|
+
return [];
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return trimmed
|
|
187
|
+
.split(/\r?\n/)
|
|
188
|
+
.map((line) => line.trim())
|
|
189
|
+
.filter((line) => line.length > 0);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── UTF-8 byte-offset conversion ──────────────────────────────
|
|
193
|
+
|
|
194
|
+
function utf8ByteOffsetToStringIndex(text: string, byteOffset: number): number {
|
|
195
|
+
if (byteOffset <= 0) return 0;
|
|
196
|
+
|
|
197
|
+
const encoder = new TextEncoder();
|
|
198
|
+
let bytes = 0;
|
|
199
|
+
|
|
200
|
+
for (let index = 0; index < text.length; ) {
|
|
201
|
+
const codePoint = text.codePointAt(index);
|
|
202
|
+
if (codePoint === undefined) return text.length;
|
|
203
|
+
|
|
204
|
+
const nextIndex = index + (codePoint > 0xffff ? 2 : 1);
|
|
205
|
+
bytes += encoder.encode(text.slice(index, nextIndex)).length;
|
|
206
|
+
if (bytes >= byteOffset) return nextIndex;
|
|
207
|
+
index = nextIndex;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return text.length;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function byteOffsetToSelection(
|
|
214
|
+
text: string,
|
|
215
|
+
startByte: number,
|
|
216
|
+
endByte: number,
|
|
217
|
+
): { start: { line: number; character: number }; end: { line: number; character: number } } {
|
|
218
|
+
const startIndex = utf8ByteOffsetToStringIndex(text, startByte);
|
|
219
|
+
const endIndex = utf8ByteOffsetToStringIndex(text, endByte);
|
|
220
|
+
|
|
221
|
+
const lines = text.split("\n");
|
|
222
|
+
let startLine = 0;
|
|
223
|
+
let startChar = 0;
|
|
224
|
+
let remaining = startIndex;
|
|
225
|
+
for (let lineIdx = 0; lineIdx < lines.length && remaining >= 0; lineIdx += 1) {
|
|
226
|
+
const lineLen = lines[lineIdx].length + 1; // +1 for the \n
|
|
227
|
+
if (remaining <= lines[lineIdx].length) {
|
|
228
|
+
startLine = lineIdx;
|
|
229
|
+
startChar = remaining;
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
remaining -= lineLen;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let endLine = 0;
|
|
236
|
+
let endChar = 0;
|
|
237
|
+
remaining = endIndex;
|
|
238
|
+
for (let lineIdx = 0; lineIdx < lines.length && remaining >= 0; lineIdx += 1) {
|
|
239
|
+
const lineLen = lines[lineIdx].length + 1;
|
|
240
|
+
if (remaining <= lines[lineIdx].length) {
|
|
241
|
+
endLine = lineIdx;
|
|
242
|
+
endChar = remaining;
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
remaining -= lineLen;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
start: { line: startLine, character: startChar },
|
|
250
|
+
end: { line: endLine, character: endChar },
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── SQLite query ───────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
function scoreWorkspace(workspacePaths: string | null, cwd: string, env: NodeJS.ProcessEnv): number {
|
|
257
|
+
const normalizedCwd = normalizeZedPathForHost(cwd, env);
|
|
258
|
+
return parseZedWorkspacePaths(workspacePaths).reduce((best, workspacePath) => {
|
|
259
|
+
const normalizedWorkspacePath = normalizeZedPathForHost(workspacePath, env);
|
|
260
|
+
if (isPathInsideOrEqual(normalizedWorkspacePath, normalizedCwd)) {
|
|
261
|
+
const resolved = resolve(normalizedWorkspacePath);
|
|
262
|
+
return Math.max(best, resolved.length);
|
|
263
|
+
}
|
|
264
|
+
return best;
|
|
265
|
+
}, 0);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export interface ResolveZedSelectionOptions {
|
|
269
|
+
dbPath: string;
|
|
270
|
+
cwd: string;
|
|
271
|
+
now?: number;
|
|
272
|
+
readFile?: (path: string) => string;
|
|
273
|
+
env?: NodeJS.ProcessEnv;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function resolveZedSelection(options: ResolveZedSelectionOptions): EditorSelectionSnapshot | undefined {
|
|
277
|
+
const { dbPath, cwd, readFile = (path) => readFileSync(path, "utf8"), env = process.env } = options;
|
|
278
|
+
const dbHandle = openZedDatabase(dbPath, env);
|
|
279
|
+
if (!dbHandle) return undefined;
|
|
280
|
+
|
|
281
|
+
const { db, cleanup } = dbHandle;
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
const rows = db
|
|
285
|
+
.prepare(
|
|
286
|
+
`SELECT i.kind AS item_kind,
|
|
287
|
+
e.item_id AS editor_id,
|
|
288
|
+
i.workspace_id,
|
|
289
|
+
w.paths AS workspace_paths,
|
|
290
|
+
w.timestamp,
|
|
291
|
+
e.buffer_path
|
|
292
|
+
FROM items i
|
|
293
|
+
JOIN panes p ON p.pane_id = i.pane_id AND p.workspace_id = i.workspace_id
|
|
294
|
+
JOIN workspaces w ON w.workspace_id = i.workspace_id
|
|
295
|
+
LEFT JOIN editors e ON e.item_id = i.item_id AND e.workspace_id = i.workspace_id
|
|
296
|
+
WHERE i.active = 1 AND p.active = 1
|
|
297
|
+
ORDER BY w.timestamp DESC`,
|
|
298
|
+
)
|
|
299
|
+
.all() as Array<{
|
|
300
|
+
item_kind: string;
|
|
301
|
+
editor_id: string | null;
|
|
302
|
+
workspace_id: string;
|
|
303
|
+
workspace_paths: string | null;
|
|
304
|
+
timestamp: number;
|
|
305
|
+
buffer_path: string | null;
|
|
306
|
+
}>;
|
|
307
|
+
|
|
308
|
+
// Filter to Editor rows only, score by workspace match.
|
|
309
|
+
// editor_id may be string or number — Zed uses INTEGER primary keys,
|
|
310
|
+
// node:sqlite returns them as JS numbers.
|
|
311
|
+
const scored = rows
|
|
312
|
+
.filter(
|
|
313
|
+
(row): row is typeof row & { editor_id: string | number; buffer_path: string } =>
|
|
314
|
+
row.item_kind === "Editor" && row.editor_id != null && typeof row.buffer_path === "string",
|
|
315
|
+
)
|
|
316
|
+
.map((row) => ({
|
|
317
|
+
...row,
|
|
318
|
+
score: scoreWorkspace(row.workspace_paths, cwd, env),
|
|
319
|
+
}))
|
|
320
|
+
.filter((row) => row.score > 0);
|
|
321
|
+
|
|
322
|
+
if (scored.length === 0) return undefined;
|
|
323
|
+
|
|
324
|
+
// Pick best: highest score, then latest timestamp
|
|
325
|
+
scored.sort((a, b) => b.score - a.score || b.timestamp - a.timestamp);
|
|
326
|
+
const best = scored[0];
|
|
327
|
+
if (!best) return undefined;
|
|
328
|
+
|
|
329
|
+
const { editor_id, workspace_id, buffer_path, workspace_paths } = best;
|
|
330
|
+
const normalizedBufferPath = normalizeZedPathForHost(buffer_path, env);
|
|
331
|
+
|
|
332
|
+
// Determine workspace folder from matching path
|
|
333
|
+
const workspaceFolder = bestWorkspaceFolder(workspace_paths, cwd, env);
|
|
334
|
+
|
|
335
|
+
// Query editor contents
|
|
336
|
+
let contents: string | undefined;
|
|
337
|
+
const contentRow = db
|
|
338
|
+
.prepare("SELECT contents FROM editors WHERE item_id = ? AND workspace_id = ?")
|
|
339
|
+
.get(editor_id, workspace_id) as { contents: string | null | undefined } | undefined;
|
|
340
|
+
|
|
341
|
+
if (contentRow && typeof contentRow.contents === "string") {
|
|
342
|
+
contents = contentRow.contents;
|
|
343
|
+
} else {
|
|
344
|
+
// Fall back to reading the file on disk.
|
|
345
|
+
try {
|
|
346
|
+
contents = readFile(normalizedBufferPath);
|
|
347
|
+
} catch {
|
|
348
|
+
return undefined;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (contents === undefined) return undefined;
|
|
353
|
+
|
|
354
|
+
// Query selections
|
|
355
|
+
const selectionRows = db
|
|
356
|
+
.prepare(
|
|
357
|
+
"SELECT start AS selection_start, end AS selection_end FROM editor_selections WHERE editor_id = ? AND workspace_id = ?",
|
|
358
|
+
)
|
|
359
|
+
.all(editor_id, workspace_id) as Array<{ selection_start: number; selection_end: number }>;
|
|
360
|
+
|
|
361
|
+
const ranges: SelectionRange[] = [];
|
|
362
|
+
for (const sel of selectionRows) {
|
|
363
|
+
const rawStart = sel.selection_start;
|
|
364
|
+
const rawEnd = sel.selection_end;
|
|
365
|
+
|
|
366
|
+
// Normalize reversed ranges
|
|
367
|
+
const start = Math.min(rawStart, rawEnd);
|
|
368
|
+
const end = Math.max(rawStart, rawEnd);
|
|
369
|
+
|
|
370
|
+
// Skip empty caret positions
|
|
371
|
+
if (start >= end) continue;
|
|
372
|
+
|
|
373
|
+
const text = contents.slice(
|
|
374
|
+
utf8ByteOffsetToStringIndex(contents, start),
|
|
375
|
+
utf8ByteOffsetToStringIndex(contents, end),
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
if (!text) continue;
|
|
379
|
+
|
|
380
|
+
const selection = byteOffsetToSelection(contents, start, end);
|
|
381
|
+
|
|
382
|
+
ranges.push({ text, selection });
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return {
|
|
386
|
+
source: "zed",
|
|
387
|
+
filePath: normalizedBufferPath,
|
|
388
|
+
workspaceFolder,
|
|
389
|
+
ranges,
|
|
390
|
+
receivedAt: options.now ?? Date.now(),
|
|
391
|
+
};
|
|
392
|
+
} catch {
|
|
393
|
+
return undefined;
|
|
394
|
+
} finally {
|
|
395
|
+
try {
|
|
396
|
+
db.close();
|
|
397
|
+
} finally {
|
|
398
|
+
cleanup();
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function bestWorkspaceFolder(workspacePaths: string | null, cwd: string, env: NodeJS.ProcessEnv): string | undefined {
|
|
404
|
+
const paths = parseZedWorkspacePaths(workspacePaths).map((workspacePath) =>
|
|
405
|
+
normalizeZedPathForHost(workspacePath, env),
|
|
406
|
+
);
|
|
407
|
+
const normalizedCwd = normalizeZedPathForHost(cwd, env);
|
|
408
|
+
const matches = paths.filter((workspacePath) => isPathInsideOrEqual(workspacePath, normalizedCwd));
|
|
409
|
+
if (matches.length === 0) return paths[0];
|
|
410
|
+
return matches.sort((a, b) => resolve(b).length - resolve(a).length)[0];
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ── Polling lifecycle ─────────────────────────────────────────
|
|
414
|
+
|
|
415
|
+
export function stopZedPolling(runtime: PiIdeRuntime): void {
|
|
416
|
+
if (runtime.zedPollTimer) {
|
|
417
|
+
clearTimeout(runtime.zedPollTimer);
|
|
418
|
+
runtime.zedPollTimer = undefined;
|
|
419
|
+
}
|
|
420
|
+
runtime.zedPollSelectionKey = undefined;
|
|
421
|
+
runtime.zedPollWalMtimeMs = undefined;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export function startZedPolling(
|
|
425
|
+
runtime: PiIdeRuntime,
|
|
426
|
+
ctx: Pick<ExtensionContext, "cwd" | "hasUI" | "ui">,
|
|
427
|
+
options?: {
|
|
428
|
+
dbPath?: string;
|
|
429
|
+
intervalMs?: number;
|
|
430
|
+
generation?: number;
|
|
431
|
+
env?: NodeJS.ProcessEnv;
|
|
432
|
+
},
|
|
433
|
+
): boolean {
|
|
434
|
+
const env = options?.env ?? process.env;
|
|
435
|
+
if (!isZedTerminal(env)) return false;
|
|
436
|
+
|
|
437
|
+
const dbPath = options?.dbPath ?? resolveZedDbPath(env);
|
|
438
|
+
if (!dbPath) return false;
|
|
439
|
+
|
|
440
|
+
const intervalMs = options?.intervalMs ?? ZED_POLL_INTERVAL_MS;
|
|
441
|
+
const generation = options?.generation;
|
|
442
|
+
|
|
443
|
+
// Set connection status and clear any stale WebSocket candidate state.
|
|
444
|
+
runtime.connection?.disconnect();
|
|
445
|
+
runtime.connection = undefined;
|
|
446
|
+
runtime.currentCandidate = undefined;
|
|
447
|
+
runtime.connectionStatus = "connected";
|
|
448
|
+
runtime.connectedServer = { name: "Zed", ide: "zed" };
|
|
449
|
+
runtime.connectionMessage = undefined;
|
|
450
|
+
updateIdeUi(runtime, ctx as ExtensionContext);
|
|
451
|
+
|
|
452
|
+
const schedule = () => {
|
|
453
|
+
if (runtime.zedPollTimer) return; // already stopped
|
|
454
|
+
runtime.zedPollTimer = setTimeout(() => {
|
|
455
|
+
runtime.zedPollTimer = undefined;
|
|
456
|
+
|
|
457
|
+
// Guard: session generation changed
|
|
458
|
+
if (generation !== undefined && runtime.sessionGeneration !== generation) return;
|
|
459
|
+
// Guard: WebSocket has taken over
|
|
460
|
+
if (runtime.connection && runtime.connection !== undefined) {
|
|
461
|
+
// connection is an IdeConnection — if WebSocket took over, stop
|
|
462
|
+
if (runtime.connectedServer?.ide !== "zed") return;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Check whether the WAL sidecar has changed since the last poll.
|
|
466
|
+
// On WSL the DB snapshot is expensive (~10 MB copy + checkpoint),
|
|
467
|
+
// so skip the work when nothing changed in the editor.
|
|
468
|
+
const walPath = `${dbPath}-wal`;
|
|
469
|
+
let walMtimeMs: number | undefined;
|
|
470
|
+
try {
|
|
471
|
+
walMtimeMs = statSync(walPath).mtimeMs;
|
|
472
|
+
} catch {
|
|
473
|
+
// WAL absent — always poll (Zed may be in a different journal mode).
|
|
474
|
+
}
|
|
475
|
+
if (
|
|
476
|
+
walMtimeMs !== undefined &&
|
|
477
|
+
runtime.zedPollWalMtimeMs !== undefined &&
|
|
478
|
+
walMtimeMs === runtime.zedPollWalMtimeMs
|
|
479
|
+
) {
|
|
480
|
+
schedule();
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
runtime.zedPollWalMtimeMs = walMtimeMs;
|
|
484
|
+
|
|
485
|
+
let snapshot: EditorSelectionSnapshot | undefined;
|
|
486
|
+
try {
|
|
487
|
+
snapshot = resolveZedSelection({ dbPath, cwd: ctx.cwd, env });
|
|
488
|
+
} catch {
|
|
489
|
+
snapshot = undefined;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (snapshot) {
|
|
493
|
+
const key = snapshotKey(snapshot);
|
|
494
|
+
if (key !== runtime.zedPollSelectionKey) {
|
|
495
|
+
runtime.zedPollSelectionKey = key;
|
|
496
|
+
setLatestSelection(runtime, snapshot, ctx as ExtensionContext);
|
|
497
|
+
}
|
|
498
|
+
} else {
|
|
499
|
+
clearLatestSelection(runtime, ctx as ExtensionContext);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
schedule();
|
|
503
|
+
}, intervalMs);
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
schedule();
|
|
507
|
+
return true;
|
|
508
|
+
}
|