saeeol 1.2.2 → 1.2.4
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/bin/saeeol.cjs +203 -0
- package/npm/bin/saeeol +0 -0
- package/package.json +2 -2
- package/src/cli/cmd/tui/component/dialog/dialog-mcp.tsx +3 -3
- package/src/cli/cmd/tui/component/dialog/dialog-model.tsx +1 -1
- package/src/cli/cmd/tui/component/dialog/dialog-provider.tsx +5 -5
- package/src/cli/cmd/tui/component/dialog/dialog-session-delete-failed.tsx +4 -4
- package/src/cli/cmd/tui/component/dialog/dialog-session-list.tsx +5 -5
- package/src/cli/cmd/tui/component/dialog/dialog-session-rename.tsx +3 -3
- package/src/cli/cmd/tui/component/dialog/dialog-stash.tsx +2 -2
- package/src/cli/cmd/tui/component/dialog/dialog-status.tsx +3 -3
- package/src/cli/cmd/tui/component/dialog/dialog-theme-list.tsx +4 -4
- package/src/cli/cmd/tui/component/dialog/dialog-workspace-create.tsx +4 -4
- package/src/cli/cmd/tui/component/dialog/dialog-workspace-unavailable.tsx +4 -4
- package/src/cli/cmd/tui/context/app/args.tsx +15 -0
- package/src/cli/cmd/tui/context/app/directory.ts +15 -0
- package/src/cli/cmd/tui/context/app/editor-zed.ts +281 -0
- package/src/cli/cmd/tui/context/app/editor.ts +425 -0
- package/src/cli/cmd/tui/context/app/helper.tsx +25 -0
- package/src/cli/cmd/tui/context/app/project.tsx +109 -0
- package/src/cli/cmd/tui/context/app/route.tsx +67 -0
- package/src/cli/cmd/tui/context/app/sdk.tsx +142 -0
- package/src/cli/cmd/tui/context/app/sync.tsx +713 -0
- package/src/cli/cmd/tui/context/app/theme.tsx +307 -0
- package/src/cli/cmd/tui/context/app/tui-config.tsx +9 -0
- package/src/cli/cmd/tui/context/args.tsx +1 -15
- package/src/cli/cmd/tui/context/directory.ts +1 -15
- package/src/cli/cmd/tui/context/editor-zed.ts +1 -281
- package/src/cli/cmd/tui/context/editor.ts +1 -425
- package/src/cli/cmd/tui/context/event.ts +1 -45
- package/src/cli/cmd/tui/context/exit.tsx +1 -67
- package/src/cli/cmd/tui/context/helper.tsx +1 -25
- package/src/cli/cmd/tui/context/keybind.tsx +1 -105
- package/src/cli/cmd/tui/context/kv.tsx +1 -76
- package/src/cli/cmd/tui/context/local.tsx +1 -478
- package/src/cli/cmd/tui/context/plugin-keybinds.ts +1 -41
- package/src/cli/cmd/tui/context/project.tsx +1 -109
- package/src/cli/cmd/tui/context/prompt.tsx +1 -18
- package/src/cli/cmd/tui/context/route.tsx +1 -67
- package/src/cli/cmd/tui/context/runtime/event.ts +45 -0
- package/src/cli/cmd/tui/context/runtime/exit.tsx +67 -0
- package/src/cli/cmd/tui/context/runtime/keybind.tsx +105 -0
- package/src/cli/cmd/tui/context/runtime/kv.tsx +76 -0
- package/src/cli/cmd/tui/context/runtime/local.tsx +478 -0
- package/src/cli/cmd/tui/context/runtime/plugin-keybinds.ts +41 -0
- package/src/cli/cmd/tui/context/sdk.tsx +1 -142
- package/src/cli/cmd/tui/context/session/prompt.tsx +18 -0
- package/src/cli/cmd/tui/context/sync.tsx +1 -713
- package/src/cli/cmd/tui/context/theme.tsx +1 -307
- package/src/cli/cmd/tui/context/tui-config.tsx +1 -9
- package/src/ltm/pipeline.ts +103 -1
- package/test/server/contract.test.ts +249 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { clamp } from "@saeeol/boxes/clamp"
|
|
2
|
+
import { Database } from "bun:sqlite"
|
|
3
|
+
import os from "node:os"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
import z from "zod"
|
|
6
|
+
import { Filesystem } from "@/util/filesystem"
|
|
7
|
+
import type { EditorSelection } from "./editor"
|
|
8
|
+
|
|
9
|
+
const ZedEditorRowSchema = z.object({
|
|
10
|
+
item_kind: z.string(),
|
|
11
|
+
editor_id: z.number().nullable(),
|
|
12
|
+
workspace_id: z.number(),
|
|
13
|
+
workspace_paths: z.string().nullable(),
|
|
14
|
+
timestamp: z.string(),
|
|
15
|
+
buffer_path: z.string().nullable(),
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const ZedSelectionRowSchema = z.object({
|
|
19
|
+
selection_start: z.number().nullable(),
|
|
20
|
+
selection_end: z.number().nullable(),
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const ZedEditorContentsSchema = z.object({
|
|
24
|
+
contents: z.string().nullable(),
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const utf8 = new TextEncoder()
|
|
28
|
+
|
|
29
|
+
type ZedEditorRow = z.infer<typeof ZedEditorRowSchema>
|
|
30
|
+
type ZedActiveEditorRow = ZedEditorRow & { item_kind: "Editor"; editor_id: number }
|
|
31
|
+
type ZedSelectionRow = z.infer<typeof ZedSelectionRowSchema>
|
|
32
|
+
|
|
33
|
+
export type ZedSelectionResult =
|
|
34
|
+
| { type: "selection"; selection: EditorSelection }
|
|
35
|
+
| { type: "empty" }
|
|
36
|
+
| { type: "unavailable" }
|
|
37
|
+
|
|
38
|
+
export async function resolveZedSelection(dbPath: string, cwd = process.cwd()): Promise<ZedSelectionResult> {
|
|
39
|
+
const active = queryZedActiveEditor(dbPath, cwd)
|
|
40
|
+
if (active.type !== "row") return active
|
|
41
|
+
|
|
42
|
+
const row = active.row
|
|
43
|
+
if (!row.buffer_path) return { type: "empty" }
|
|
44
|
+
|
|
45
|
+
const selections = queryZedEditorSelections(dbPath, row)
|
|
46
|
+
if (selections.type !== "selections") return selections
|
|
47
|
+
const byteRanges = selections.selections
|
|
48
|
+
.flatMap((selection) => {
|
|
49
|
+
if (selection.selection_start == null || selection.selection_end == null) return []
|
|
50
|
+
return [
|
|
51
|
+
{
|
|
52
|
+
start: Math.min(selection.selection_start, selection.selection_end),
|
|
53
|
+
end: Math.max(selection.selection_start, selection.selection_end),
|
|
54
|
+
},
|
|
55
|
+
]
|
|
56
|
+
})
|
|
57
|
+
.sort((left, right) => left.start - right.start || left.end - right.end)
|
|
58
|
+
if (byteRanges.length === 0) return { type: "unavailable" }
|
|
59
|
+
|
|
60
|
+
const contents = queryZedEditorContents(dbPath, row)
|
|
61
|
+
const text =
|
|
62
|
+
contents.type === "contents" && contents.contents != null
|
|
63
|
+
? contents.contents
|
|
64
|
+
: await Bun.file(row.buffer_path)
|
|
65
|
+
.text()
|
|
66
|
+
.catch(() => undefined)
|
|
67
|
+
if (text == null) return { type: "unavailable" }
|
|
68
|
+
|
|
69
|
+
const ranges = byteRanges.map((range) => {
|
|
70
|
+
const startOffset = utf8ByteOffsetToStringIndex(text, range.start)
|
|
71
|
+
const endOffset = utf8ByteOffsetToStringIndex(text, range.end)
|
|
72
|
+
return {
|
|
73
|
+
text: text.slice(startOffset, endOffset),
|
|
74
|
+
selection: offsetsToSelection(text, startOffset, endOffset),
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
type: "selection",
|
|
80
|
+
selection: {
|
|
81
|
+
filePath: row.buffer_path,
|
|
82
|
+
source: "zed",
|
|
83
|
+
ranges,
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function queryZedActiveEditor(dbPath: string, cwd: string) {
|
|
89
|
+
let db: Database | undefined
|
|
90
|
+
try {
|
|
91
|
+
db = new Database(dbPath, { readonly: true })
|
|
92
|
+
const raw = db
|
|
93
|
+
.query(
|
|
94
|
+
`select
|
|
95
|
+
i.kind as item_kind,
|
|
96
|
+
e.item_id as editor_id,
|
|
97
|
+
i.workspace_id as workspace_id,
|
|
98
|
+
w.paths as workspace_paths,
|
|
99
|
+
w.timestamp as timestamp,
|
|
100
|
+
e.buffer_path as buffer_path
|
|
101
|
+
from items i
|
|
102
|
+
join panes p on p.pane_id = i.pane_id and p.workspace_id = i.workspace_id
|
|
103
|
+
join workspaces w on w.workspace_id = i.workspace_id
|
|
104
|
+
left join editors e on e.item_id = i.item_id and e.workspace_id = i.workspace_id
|
|
105
|
+
where i.active = 1 and p.active = 1
|
|
106
|
+
order by w.timestamp desc`,
|
|
107
|
+
)
|
|
108
|
+
.all()
|
|
109
|
+
|
|
110
|
+
const rows = raw.flatMap((row) => {
|
|
111
|
+
const parsed = ZedEditorRowSchema.safeParse(row)
|
|
112
|
+
return parsed.success ? [parsed.data] : []
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
if (raw.length > 0 && rows.length === 0) return { type: "unavailable" as const }
|
|
116
|
+
|
|
117
|
+
const row = rows
|
|
118
|
+
.map((row) => ({ row, score: scoreZedWorkspace(row.workspace_paths, cwd) }))
|
|
119
|
+
.filter((entry) => entry.score > 0)
|
|
120
|
+
.sort((left, right) => right.score - left.score || right.row.timestamp.localeCompare(left.row.timestamp))[0]?.row
|
|
121
|
+
if (!row) return { type: "empty" as const }
|
|
122
|
+
if (row.item_kind !== "Editor") return { type: "unavailable" as const }
|
|
123
|
+
if (!isZedActiveEditorRow(row)) return { type: "empty" as const }
|
|
124
|
+
return { type: "row" as const, row }
|
|
125
|
+
} catch {
|
|
126
|
+
return { type: "unavailable" as const }
|
|
127
|
+
} finally {
|
|
128
|
+
db?.close()
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function queryZedEditorSelections(dbPath: string, row: ZedActiveEditorRow) {
|
|
133
|
+
let db: Database | undefined
|
|
134
|
+
try {
|
|
135
|
+
db = new Database(dbPath, { readonly: true })
|
|
136
|
+
const raw = db
|
|
137
|
+
.query(
|
|
138
|
+
`select
|
|
139
|
+
start as selection_start,
|
|
140
|
+
end as selection_end
|
|
141
|
+
from editor_selections
|
|
142
|
+
where editor_id = $editorID and workspace_id = $workspaceID`,
|
|
143
|
+
)
|
|
144
|
+
.all({ $editorID: row.editor_id, $workspaceID: row.workspace_id })
|
|
145
|
+
|
|
146
|
+
const selections = raw.flatMap((selection) => {
|
|
147
|
+
const parsed = ZedSelectionRowSchema.safeParse(selection)
|
|
148
|
+
return parsed.success ? [parsed.data] : []
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
if (raw.length > 0 && selections.length === 0) return { type: "unavailable" as const }
|
|
152
|
+
return { type: "selections" as const, selections }
|
|
153
|
+
} catch {
|
|
154
|
+
return { type: "unavailable" as const }
|
|
155
|
+
} finally {
|
|
156
|
+
db?.close()
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function queryZedEditorContents(dbPath: string, row: ZedActiveEditorRow) {
|
|
161
|
+
let db: Database | undefined
|
|
162
|
+
try {
|
|
163
|
+
db = new Database(dbPath, { readonly: true })
|
|
164
|
+
const parsed = ZedEditorContentsSchema.safeParse(
|
|
165
|
+
db
|
|
166
|
+
.query(
|
|
167
|
+
`select contents
|
|
168
|
+
from editors
|
|
169
|
+
where item_id = $editorID and workspace_id = $workspaceID`,
|
|
170
|
+
)
|
|
171
|
+
.get({ $editorID: row.editor_id, $workspaceID: row.workspace_id }),
|
|
172
|
+
)
|
|
173
|
+
if (!parsed.success) return { type: "unavailable" as const }
|
|
174
|
+
return { type: "contents" as const, contents: parsed.data.contents }
|
|
175
|
+
} catch {
|
|
176
|
+
return { type: "unavailable" as const }
|
|
177
|
+
} finally {
|
|
178
|
+
db?.close()
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function isZedActiveEditorRow(row: ZedEditorRow): row is ZedActiveEditorRow {
|
|
183
|
+
return row.item_kind === "Editor" && row.editor_id != null
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function resolveZedDbPath() {
|
|
187
|
+
const candidates = [
|
|
188
|
+
process.env.SAEEOL_ZED_DB,
|
|
189
|
+
path.join(os.homedir(), "Library", "Application Support", "Zed", "db", "0-stable", "db.sqlite"),
|
|
190
|
+
path.join(os.homedir(), ".local", "share", "zed", "db", "0-stable", "db.sqlite"),
|
|
191
|
+
].filter((item): item is string => Boolean(item))
|
|
192
|
+
|
|
193
|
+
return candidates.find((item) => isFile(item))
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function isFile(item: string) {
|
|
197
|
+
try {
|
|
198
|
+
return Filesystem.stat(item)?.isFile() === true
|
|
199
|
+
} catch {
|
|
200
|
+
return false
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function scoreZedWorkspace(workspacePaths: string | null, cwd: string) {
|
|
205
|
+
return zedWorkspacePaths(workspacePaths).reduce((score, item) => {
|
|
206
|
+
if (pathContains(item, cwd)) return Math.max(score, path.resolve(item).length)
|
|
207
|
+
return score
|
|
208
|
+
}, 0)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function zedWorkspacePaths(value: string | null) {
|
|
212
|
+
if (!value) return []
|
|
213
|
+
const parsed = parseJson(value)
|
|
214
|
+
if (Array.isArray(parsed)) return parsed.filter((item): item is string => typeof item === "string")
|
|
215
|
+
return value.split(/\r?\n/).filter(Boolean)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function offsetToPosition(text: string, offset: number) {
|
|
219
|
+
const stringOffset = utf8ByteOffsetToStringIndex(text, offset)
|
|
220
|
+
return offsetsToSelection(text, stringOffset, stringOffset).start
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function utf8ByteOffsetToStringIndex(text: string, byteOffset: number) {
|
|
224
|
+
if (byteOffset <= 0) return 0
|
|
225
|
+
|
|
226
|
+
let bytes = 0
|
|
227
|
+
for (let index = 0; index < text.length; ) {
|
|
228
|
+
const codePoint = text.codePointAt(index)
|
|
229
|
+
if (codePoint === undefined) return text.length
|
|
230
|
+
|
|
231
|
+
const nextIndex = index + (codePoint > 0xffff ? 2 : 1)
|
|
232
|
+
bytes += utf8.encode(text.slice(index, nextIndex)).length
|
|
233
|
+
if (bytes >= byteOffset) return nextIndex
|
|
234
|
+
index = nextIndex
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return text.length
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function offsetsToSelection(text: string, startOffset: number, endOffset: number) {
|
|
241
|
+
const start = clamp(startOffset, 0, text.length)
|
|
242
|
+
const end = clamp(endOffset, 0, text.length)
|
|
243
|
+
let line = 1
|
|
244
|
+
let lineStart = 0
|
|
245
|
+
let startPosition = position(line, lineStart, start)
|
|
246
|
+
let endPosition = position(line, lineStart, end)
|
|
247
|
+
|
|
248
|
+
for (let index = 0; index <= end; index++) {
|
|
249
|
+
if (index === start) startPosition = position(line, lineStart, index)
|
|
250
|
+
if (index === end) {
|
|
251
|
+
endPosition = position(line, lineStart, index)
|
|
252
|
+
break
|
|
253
|
+
}
|
|
254
|
+
if (text[index] === "\n") {
|
|
255
|
+
line += 1
|
|
256
|
+
lineStart = index + 1
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return { start: startPosition, end: endPosition }
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function position(line: number, lineStart: number, offset: number) {
|
|
264
|
+
return {
|
|
265
|
+
line,
|
|
266
|
+
character: offset - lineStart + 1,
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function pathContains(parent: string, child: string) {
|
|
271
|
+
const relative = path.relative(path.resolve(parent), path.resolve(child))
|
|
272
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function parseJson(value: string) {
|
|
276
|
+
try {
|
|
277
|
+
return JSON.parse(value) as unknown
|
|
278
|
+
} catch {
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
}
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import { expDelay } from "@saeeol/boxes/schedule"
|
|
2
|
+
import { readdirSync, readFileSync, statSync } from "node:fs"
|
|
3
|
+
import os from "node:os"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
import { onCleanup, onMount } from "solid-js"
|
|
6
|
+
import { createStore } from "solid-js/store"
|
|
7
|
+
import z from "zod"
|
|
8
|
+
import { isRecord } from "@/util/record"
|
|
9
|
+
import { createSimpleContext } from "../app/helper"
|
|
10
|
+
import { resolveZedDbPath, resolveZedSelection } from "./editor-zed"
|
|
11
|
+
|
|
12
|
+
const MCP_PROTOCOL_VERSION = "2025-11-25"
|
|
13
|
+
|
|
14
|
+
const JsonRpcMessageSchema = z.object({
|
|
15
|
+
id: z.union([z.number(), z.string(), z.null()]).optional(),
|
|
16
|
+
method: z.string().optional(),
|
|
17
|
+
params: z.unknown().optional(),
|
|
18
|
+
result: z.unknown().optional(),
|
|
19
|
+
error: z
|
|
20
|
+
.object({
|
|
21
|
+
code: z.number().optional(),
|
|
22
|
+
message: z.string().optional(),
|
|
23
|
+
})
|
|
24
|
+
.optional(),
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const PositionSchema = z.object({
|
|
28
|
+
line: z.number(),
|
|
29
|
+
character: z.number(),
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const EditorSelectionRangeSchema = z.object({
|
|
33
|
+
text: z.string(),
|
|
34
|
+
selection: z.object({
|
|
35
|
+
start: PositionSchema,
|
|
36
|
+
end: PositionSchema,
|
|
37
|
+
}),
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
const EditorSelectionSchema = z
|
|
41
|
+
.union([
|
|
42
|
+
z.object({
|
|
43
|
+
filePath: z.string(),
|
|
44
|
+
source: z.enum(["websocket", "zed"]).optional(),
|
|
45
|
+
ranges: z.array(EditorSelectionRangeSchema).min(1),
|
|
46
|
+
}),
|
|
47
|
+
z.object({
|
|
48
|
+
text: z.string(),
|
|
49
|
+
filePath: z.string(),
|
|
50
|
+
source: z.enum(["websocket", "zed"]).optional(),
|
|
51
|
+
selection: z.object({
|
|
52
|
+
start: PositionSchema,
|
|
53
|
+
end: PositionSchema,
|
|
54
|
+
}),
|
|
55
|
+
}),
|
|
56
|
+
])
|
|
57
|
+
.transform((value) =>
|
|
58
|
+
"ranges" in value
|
|
59
|
+
? value
|
|
60
|
+
: {
|
|
61
|
+
filePath: value.filePath,
|
|
62
|
+
source: value.source,
|
|
63
|
+
ranges: [
|
|
64
|
+
{
|
|
65
|
+
text: value.text,
|
|
66
|
+
selection: value.selection,
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
const EditorMentionSchema = z.object({
|
|
73
|
+
filePath: z.string(),
|
|
74
|
+
lineStart: z.number(),
|
|
75
|
+
lineEnd: z.number(),
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
const EditorServerInfoSchema = z.object({
|
|
79
|
+
protocolVersion: z.string().optional(),
|
|
80
|
+
serverInfo: z
|
|
81
|
+
.object({
|
|
82
|
+
name: z.string().optional(),
|
|
83
|
+
version: z.string().optional(),
|
|
84
|
+
})
|
|
85
|
+
.optional(),
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
type JsonRpcMessage = z.infer<typeof JsonRpcMessageSchema>
|
|
89
|
+
export type EditorSelection = z.infer<typeof EditorSelectionSchema>
|
|
90
|
+
export type EditorMention = z.infer<typeof EditorMentionSchema>
|
|
91
|
+
type EditorServerInfo = z.infer<typeof EditorServerInfoSchema>
|
|
92
|
+
|
|
93
|
+
type EditorConnection = {
|
|
94
|
+
url: string
|
|
95
|
+
authToken?: string
|
|
96
|
+
source: string
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
type EditorLockFile = {
|
|
100
|
+
port: number
|
|
101
|
+
authToken?: string
|
|
102
|
+
transport?: string
|
|
103
|
+
workspaceFolders: string[]
|
|
104
|
+
mtimeMs: number
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const { use: useEditorContext, provider: EditorContextProvider } = createSimpleContext({
|
|
108
|
+
name: "EditorContext",
|
|
109
|
+
init: (props: { WebSocketImpl?: typeof WebSocket }) => {
|
|
110
|
+
const mentionListeners = new Set<(mention: EditorMention) => void>()
|
|
111
|
+
const WebSocketImpl = props.WebSocketImpl ?? WebSocket
|
|
112
|
+
const [store, setStore] = createStore<{
|
|
113
|
+
status: "disabled" | "connecting" | "connected"
|
|
114
|
+
selection: EditorSelection | undefined
|
|
115
|
+
server: EditorServerInfo | undefined
|
|
116
|
+
}>({
|
|
117
|
+
status: "disabled",
|
|
118
|
+
selection: undefined,
|
|
119
|
+
server: undefined,
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
let socket: WebSocket | undefined
|
|
123
|
+
let closed = false
|
|
124
|
+
let reconnect: ReturnType<typeof setTimeout> | undefined
|
|
125
|
+
let attempt = 0
|
|
126
|
+
let requestID = 0
|
|
127
|
+
let zedSelection: Promise<void> | undefined
|
|
128
|
+
let lastZedSelectionKey: string | undefined
|
|
129
|
+
let directory = process.cwd()
|
|
130
|
+
const pending = new Map<number, string>()
|
|
131
|
+
|
|
132
|
+
const send = (payload: JsonRpcMessage) => {
|
|
133
|
+
if (!socket || socket.readyState !== 1) return
|
|
134
|
+
socket.send(JSON.stringify({ jsonrpc: "2.0", ...payload }))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const request = (method: string, params?: unknown) => {
|
|
138
|
+
requestID += 1
|
|
139
|
+
pending.set(requestID, method)
|
|
140
|
+
send({ id: requestID, method, params })
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const connect = () => {
|
|
144
|
+
if (closed) return
|
|
145
|
+
|
|
146
|
+
const connection = resolveEditorConnection(directory)
|
|
147
|
+
if (!connection) {
|
|
148
|
+
const dbPath = resolveZedDbPath()
|
|
149
|
+
if (!dbPath) {
|
|
150
|
+
setStore("status", "disabled")
|
|
151
|
+
scheduleReconnect()
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
zedSelection ??= resolveZedSelection(dbPath, directory)
|
|
155
|
+
.then((result) => {
|
|
156
|
+
if (closed || socket) return
|
|
157
|
+
if (result.type === "unavailable") return
|
|
158
|
+
const selection = result.type === "selection" ? result.selection : undefined
|
|
159
|
+
const key = editorSelectionKey(selection)
|
|
160
|
+
if (key !== lastZedSelectionKey) {
|
|
161
|
+
lastZedSelectionKey = key
|
|
162
|
+
setStore("selection", selection)
|
|
163
|
+
setStore("status", selection ? "connected" : "disabled")
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
.catch(() => {
|
|
167
|
+
// Keep the last known Zed selection for transient polling failures.
|
|
168
|
+
})
|
|
169
|
+
.finally(() => {
|
|
170
|
+
zedSelection = undefined
|
|
171
|
+
})
|
|
172
|
+
scheduleZedPoll()
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
setStore("status", "connecting")
|
|
177
|
+
const current = openEditorSocket(connection, WebSocketImpl)
|
|
178
|
+
socket = current
|
|
179
|
+
|
|
180
|
+
current.addEventListener("open", () => {
|
|
181
|
+
if (socket !== current) {
|
|
182
|
+
current.close()
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
attempt = 0
|
|
187
|
+
setStore("status", "connected")
|
|
188
|
+
request("initialize", {
|
|
189
|
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
190
|
+
capabilities: {},
|
|
191
|
+
clientInfo: { name: "saeeol", version: "0.0.0" },
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
current.addEventListener("message", (event) => {
|
|
196
|
+
const message = parseMessage(event.data)
|
|
197
|
+
if (!message) return
|
|
198
|
+
|
|
199
|
+
const selection =
|
|
200
|
+
message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined
|
|
201
|
+
if (selection?.success) {
|
|
202
|
+
setStore("selection", { ...selection.data, source: "websocket" })
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const mention = message.method === "at_mentioned" ? EditorMentionSchema.safeParse(message.params) : undefined
|
|
207
|
+
if (mention?.success) {
|
|
208
|
+
mentionListeners.forEach((listener) => listener(mention.data))
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (typeof message.id !== "number") return
|
|
213
|
+
|
|
214
|
+
const method = pending.get(message.id)
|
|
215
|
+
if (!method) return
|
|
216
|
+
|
|
217
|
+
pending.delete(message.id)
|
|
218
|
+
if (message.error) return
|
|
219
|
+
|
|
220
|
+
const initialize = method === "initialize" ? EditorServerInfoSchema.safeParse(message.result) : undefined
|
|
221
|
+
if (initialize?.success) {
|
|
222
|
+
setStore("server", initialize.data)
|
|
223
|
+
send({ method: "notifications/initialized" })
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
current.addEventListener("close", () => {
|
|
229
|
+
if (socket !== current) return
|
|
230
|
+
|
|
231
|
+
socket = undefined
|
|
232
|
+
pending.clear()
|
|
233
|
+
if (closed) return
|
|
234
|
+
|
|
235
|
+
setStore("status", "connecting")
|
|
236
|
+
scheduleReconnect()
|
|
237
|
+
})
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const scheduleReconnect = () => {
|
|
241
|
+
if (closed) return
|
|
242
|
+
if (reconnect) clearTimeout(reconnect)
|
|
243
|
+
attempt += 1
|
|
244
|
+
const delay = Math.min(expDelay(1000, attempt - 1), 10_000)
|
|
245
|
+
reconnect = setTimeout(connect, delay)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const scheduleZedPoll = () => {
|
|
249
|
+
if (closed) return
|
|
250
|
+
if (reconnect) clearTimeout(reconnect)
|
|
251
|
+
reconnect = setTimeout(connect, 1000)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const reconnectWithDirectory = (nextDirectory?: string) => {
|
|
255
|
+
const resolved = nextDirectory || process.cwd()
|
|
256
|
+
if (directory === resolved) return
|
|
257
|
+
|
|
258
|
+
directory = resolved
|
|
259
|
+
attempt = 0
|
|
260
|
+
pending.clear()
|
|
261
|
+
lastZedSelectionKey = undefined
|
|
262
|
+
if (reconnect) clearTimeout(reconnect)
|
|
263
|
+
reconnect = undefined
|
|
264
|
+
if (socket) {
|
|
265
|
+
const current = socket
|
|
266
|
+
socket = undefined
|
|
267
|
+
current.close()
|
|
268
|
+
}
|
|
269
|
+
setStore("status", "disabled")
|
|
270
|
+
setStore("selection", undefined)
|
|
271
|
+
setStore("server", undefined)
|
|
272
|
+
connect()
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
onMount(() => {
|
|
276
|
+
connect()
|
|
277
|
+
|
|
278
|
+
onCleanup(() => {
|
|
279
|
+
closed = true
|
|
280
|
+
if (reconnect) clearTimeout(reconnect)
|
|
281
|
+
socket?.close()
|
|
282
|
+
})
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
enabled() {
|
|
287
|
+
return Boolean(resolveEditorConnection(directory) || resolveZedDbPath())
|
|
288
|
+
},
|
|
289
|
+
connected() {
|
|
290
|
+
return store.status === "connected"
|
|
291
|
+
},
|
|
292
|
+
selection() {
|
|
293
|
+
return store.selection
|
|
294
|
+
},
|
|
295
|
+
clearSelection() {
|
|
296
|
+
lastZedSelectionKey = undefined
|
|
297
|
+
setStore("selection", undefined)
|
|
298
|
+
},
|
|
299
|
+
onMention(listener: (mention: EditorMention) => void) {
|
|
300
|
+
mentionListeners.add(listener)
|
|
301
|
+
return () => mentionListeners.delete(listener)
|
|
302
|
+
},
|
|
303
|
+
server() {
|
|
304
|
+
return store.server
|
|
305
|
+
},
|
|
306
|
+
reconnect(directory?: string) {
|
|
307
|
+
setStore("selection", undefined)
|
|
308
|
+
reconnectWithDirectory(directory)
|
|
309
|
+
},
|
|
310
|
+
}
|
|
311
|
+
},
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
function parsePort(value: string | undefined) {
|
|
315
|
+
if (!value) return
|
|
316
|
+
|
|
317
|
+
const parsed = Number.parseInt(value, 10)
|
|
318
|
+
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) return
|
|
319
|
+
return parsed
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function resolveEditorConnection(directory: string): EditorConnection | undefined {
|
|
323
|
+
const port = parsePort(process.env.CLAUDE_CODE_SSE_PORT || process.env.SAEEOL_EDITOR_SSE_PORT)
|
|
324
|
+
if (port) {
|
|
325
|
+
return {
|
|
326
|
+
url: `ws://127.0.0.1:${port}`,
|
|
327
|
+
source: `env:${port}`,
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const lock = resolveEditorLockFile(directory)
|
|
332
|
+
if (lock) {
|
|
333
|
+
return {
|
|
334
|
+
url: `ws://127.0.0.1:${lock.port}`,
|
|
335
|
+
authToken: lock.authToken,
|
|
336
|
+
source: `lock:${lock.port}`,
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function resolveEditorLockFile(activeDirectory: string) {
|
|
342
|
+
const directory = path.join(os.homedir(), ".claude", "ide")
|
|
343
|
+
let entries: string[]
|
|
344
|
+
|
|
345
|
+
try {
|
|
346
|
+
entries = readdirSync(directory)
|
|
347
|
+
} catch {
|
|
348
|
+
return
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// longest workspace folder that contains the active session directory; 0 if none match
|
|
352
|
+
const bestMatchLength = (lock: EditorLockFile) =>
|
|
353
|
+
Math.max(0, ...lock.workspaceFolders.map((folder) => pathContainsLength(folder, activeDirectory)))
|
|
354
|
+
const locks = entries
|
|
355
|
+
.filter((entry) => entry.endsWith(".lock"))
|
|
356
|
+
.map((entry) => readEditorLockFile(path.join(directory, entry)))
|
|
357
|
+
.filter((entry): entry is EditorLockFile => Boolean(entry))
|
|
358
|
+
.filter((entry) => bestMatchLength(entry) > 0)
|
|
359
|
+
// prefer locks with longer matching workspace folders, then more recent ones
|
|
360
|
+
.sort((left, right) => bestMatchLength(right) - bestMatchLength(left) || right.mtimeMs - left.mtimeMs)
|
|
361
|
+
return locks[0]
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function readEditorLockFile(filePath: string): EditorLockFile | undefined {
|
|
365
|
+
const port = parsePort(path.basename(filePath, ".lock"))
|
|
366
|
+
if (!port) return
|
|
367
|
+
|
|
368
|
+
try {
|
|
369
|
+
const parsed = JSON.parse(readFileSync(filePath, "utf-8")) as unknown
|
|
370
|
+
if (!isRecord(parsed)) return
|
|
371
|
+
if (parsed.transport !== undefined && parsed.transport !== "ws") return
|
|
372
|
+
|
|
373
|
+
return {
|
|
374
|
+
port,
|
|
375
|
+
authToken: typeof parsed.authToken === "string" ? parsed.authToken : undefined,
|
|
376
|
+
transport: typeof parsed.transport === "string" ? parsed.transport : undefined,
|
|
377
|
+
workspaceFolders: Array.isArray(parsed.workspaceFolders)
|
|
378
|
+
? parsed.workspaceFolders.filter((value): value is string => typeof value === "string")
|
|
379
|
+
: [],
|
|
380
|
+
mtimeMs: statSync(filePath).mtimeMs,
|
|
381
|
+
}
|
|
382
|
+
} catch {
|
|
383
|
+
return
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export function editorSelectionKey(selection: EditorSelection | undefined) {
|
|
388
|
+
if (!selection) return ""
|
|
389
|
+
return [
|
|
390
|
+
selection.filePath,
|
|
391
|
+
...selection.ranges.flatMap((range) => [
|
|
392
|
+
range.selection.start.line,
|
|
393
|
+
range.selection.start.character,
|
|
394
|
+
range.selection.end.line,
|
|
395
|
+
range.selection.end.character,
|
|
396
|
+
range.text,
|
|
397
|
+
]),
|
|
398
|
+
].join("\0")
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function pathContainsLength(parent: string, child: string) {
|
|
402
|
+
const resolved = path.resolve(parent)
|
|
403
|
+
const relative = path.relative(resolved, path.resolve(child))
|
|
404
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) ? resolved.length : 0
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function openEditorSocket(connection: EditorConnection, WebSocketImpl: typeof WebSocket) {
|
|
408
|
+
if (!connection.authToken) return new WebSocketImpl(connection.url)
|
|
409
|
+
|
|
410
|
+
return new WebSocketImpl(connection.url, {
|
|
411
|
+
headers: {
|
|
412
|
+
"x-claude-code-ide-authorization": connection.authToken,
|
|
413
|
+
},
|
|
414
|
+
} as any)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function parseMessage(value: unknown) {
|
|
418
|
+
if (typeof value !== "string") return
|
|
419
|
+
|
|
420
|
+
try {
|
|
421
|
+
return JsonRpcMessageSchema.parse(JSON.parse(value))
|
|
422
|
+
} catch {
|
|
423
|
+
return
|
|
424
|
+
}
|
|
425
|
+
}
|