kanna-code 0.1.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.
@@ -0,0 +1,478 @@
1
+ import { appendFile, mkdir } from "node:fs/promises"
2
+ import { homedir } from "node:os"
3
+ import path from "node:path"
4
+ import { getDataDir, LOG_PREFIX } from "../shared/branding"
5
+ import type { TranscriptEntry } from "../shared/types"
6
+ import { STORE_VERSION } from "../shared/types"
7
+ import {
8
+ type ChatEvent,
9
+ type MessageEvent,
10
+ type ProjectEvent,
11
+ type SnapshotFile,
12
+ type StoreEvent,
13
+ type StoreState,
14
+ type TurnEvent,
15
+ cloneTranscriptEntries,
16
+ createEmptyState,
17
+ } from "./events"
18
+ import { resolveLocalPath } from "./paths"
19
+
20
+ const DATA_DIR = getDataDir(homedir())
21
+ const SNAPSHOT_PATH = path.join(DATA_DIR, "snapshot.json")
22
+ const PROJECTS_LOG = path.join(DATA_DIR, "projects.jsonl")
23
+ const CHATS_LOG = path.join(DATA_DIR, "chats.jsonl")
24
+ const MESSAGES_LOG = path.join(DATA_DIR, "messages.jsonl")
25
+ const TURNS_LOG = path.join(DATA_DIR, "turns.jsonl")
26
+ const COMPACTION_THRESHOLD_BYTES = 2 * 1024 * 1024
27
+
28
+ export class EventStore {
29
+ readonly dataDir = DATA_DIR
30
+ readonly state: StoreState = createEmptyState()
31
+ private writeChain = Promise.resolve()
32
+
33
+ async initialize() {
34
+ await mkdir(DATA_DIR, { recursive: true })
35
+ await this.ensureFile(PROJECTS_LOG)
36
+ await this.ensureFile(CHATS_LOG)
37
+ await this.ensureFile(MESSAGES_LOG)
38
+ await this.ensureFile(TURNS_LOG)
39
+ await this.loadSnapshot()
40
+ await this.replayLogs()
41
+ if (await this.shouldCompact()) {
42
+ await this.compact()
43
+ }
44
+ }
45
+
46
+ private async ensureFile(filePath: string) {
47
+ const file = Bun.file(filePath)
48
+ if (!(await file.exists())) {
49
+ await Bun.write(filePath, "")
50
+ }
51
+ }
52
+
53
+ private async loadSnapshot() {
54
+ const file = Bun.file(SNAPSHOT_PATH)
55
+ if (!(await file.exists())) return
56
+
57
+ try {
58
+ const parsed = (await file.json()) as SnapshotFile
59
+ if (parsed.v !== STORE_VERSION) {
60
+ throw new Error(`Unsupported snapshot version ${String(parsed.v)}`)
61
+ }
62
+ for (const project of parsed.projects) {
63
+ this.state.projectsById.set(project.id, { ...project })
64
+ this.state.projectIdsByPath.set(project.localPath, project.id)
65
+ }
66
+ for (const chat of parsed.chats) {
67
+ this.state.chatsById.set(chat.id, { ...chat })
68
+ }
69
+ for (const messageSet of parsed.messages) {
70
+ this.state.messagesByChatId.set(messageSet.chatId, cloneTranscriptEntries(messageSet.entries))
71
+ }
72
+ } catch (error) {
73
+ console.warn(`${LOG_PREFIX} Failed to load snapshot, rebuilding from logs:`, error)
74
+ this.resetState()
75
+ }
76
+ }
77
+
78
+ private resetState() {
79
+ this.state.projectsById.clear()
80
+ this.state.projectIdsByPath.clear()
81
+ this.state.chatsById.clear()
82
+ this.state.messagesByChatId.clear()
83
+ }
84
+
85
+ private async replayLogs() {
86
+ await this.replayLog<ProjectEvent>(PROJECTS_LOG)
87
+ await this.replayLog<ChatEvent>(CHATS_LOG)
88
+ await this.replayLog<MessageEvent>(MESSAGES_LOG)
89
+ await this.replayLog<TurnEvent>(TURNS_LOG)
90
+ }
91
+
92
+ private async replayLog<TEvent extends StoreEvent>(filePath: string) {
93
+ const file = Bun.file(filePath)
94
+ if (!(await file.exists())) return
95
+ const text = await file.text()
96
+ if (!text.trim()) return
97
+
98
+ const lines = text.split("\n")
99
+ let lastNonEmpty = -1
100
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
101
+ if (lines[index].trim()) {
102
+ lastNonEmpty = index
103
+ break
104
+ }
105
+ }
106
+
107
+ for (let index = 0; index < lines.length; index += 1) {
108
+ const line = lines[index].trim()
109
+ if (!line) continue
110
+ try {
111
+ const event = JSON.parse(line) as TEvent
112
+ this.applyEvent(event)
113
+ } catch (error) {
114
+ if (index === lastNonEmpty) {
115
+ console.warn(`${LOG_PREFIX} Ignoring corrupt trailing line in ${path.basename(filePath)}`)
116
+ return
117
+ }
118
+ throw error
119
+ }
120
+ }
121
+ }
122
+
123
+ private applyEvent(event: StoreEvent) {
124
+ switch (event.type) {
125
+ case "project_opened": {
126
+ const localPath = resolveLocalPath(event.localPath)
127
+ const project = {
128
+ id: event.projectId,
129
+ localPath,
130
+ title: event.title,
131
+ createdAt: event.timestamp,
132
+ updatedAt: event.timestamp,
133
+ }
134
+ this.state.projectsById.set(project.id, project)
135
+ this.state.projectIdsByPath.set(localPath, project.id)
136
+ break
137
+ }
138
+ case "project_removed": {
139
+ const project = this.state.projectsById.get(event.projectId)
140
+ if (!project) break
141
+ project.deletedAt = event.timestamp
142
+ project.updatedAt = event.timestamp
143
+ this.state.projectIdsByPath.delete(project.localPath)
144
+ break
145
+ }
146
+ case "chat_created": {
147
+ const chat = {
148
+ id: event.chatId,
149
+ projectId: event.projectId,
150
+ title: event.title,
151
+ createdAt: event.timestamp,
152
+ updatedAt: event.timestamp,
153
+ planMode: false,
154
+ resumeSessionId: null,
155
+ lastTurnOutcome: null,
156
+ }
157
+ this.state.chatsById.set(chat.id, chat)
158
+ break
159
+ }
160
+ case "chat_renamed": {
161
+ const chat = this.state.chatsById.get(event.chatId)
162
+ if (!chat) break
163
+ chat.title = event.title
164
+ chat.updatedAt = event.timestamp
165
+ break
166
+ }
167
+ case "chat_deleted": {
168
+ const chat = this.state.chatsById.get(event.chatId)
169
+ if (!chat) break
170
+ chat.deletedAt = event.timestamp
171
+ chat.updatedAt = event.timestamp
172
+ break
173
+ }
174
+ case "chat_plan_mode_set": {
175
+ const chat = this.state.chatsById.get(event.chatId)
176
+ if (!chat) break
177
+ chat.planMode = event.planMode
178
+ chat.updatedAt = event.timestamp
179
+ break
180
+ }
181
+ case "message_appended": {
182
+ const chat = this.state.chatsById.get(event.chatId)
183
+ if (chat) {
184
+ // Only update lastMessageAt for user-sent messages so the sidebar
185
+ // sorts by "last sent" rather than "last received".
186
+ try {
187
+ const parsed = JSON.parse(event.entry.message)
188
+ if (parsed.type === "user_prompt") {
189
+ chat.lastMessageAt = event.entry.createdAt
190
+ }
191
+ } catch {
192
+ // non-JSON entry, skip
193
+ }
194
+ chat.updatedAt = Math.max(chat.updatedAt, event.entry.createdAt)
195
+ }
196
+ const existing = this.state.messagesByChatId.get(event.chatId) ?? []
197
+ existing.push({ ...event.entry })
198
+ this.state.messagesByChatId.set(event.chatId, existing)
199
+ break
200
+ }
201
+ case "turn_started": {
202
+ const chat = this.state.chatsById.get(event.chatId)
203
+ if (!chat) break
204
+ chat.updatedAt = event.timestamp
205
+ break
206
+ }
207
+ case "turn_finished": {
208
+ const chat = this.state.chatsById.get(event.chatId)
209
+ if (!chat) break
210
+ chat.updatedAt = event.timestamp
211
+ chat.lastTurnOutcome = "success"
212
+ break
213
+ }
214
+ case "turn_failed": {
215
+ const chat = this.state.chatsById.get(event.chatId)
216
+ if (!chat) break
217
+ chat.updatedAt = event.timestamp
218
+ chat.lastTurnOutcome = "failed"
219
+ break
220
+ }
221
+ case "turn_cancelled": {
222
+ const chat = this.state.chatsById.get(event.chatId)
223
+ if (!chat) break
224
+ chat.updatedAt = event.timestamp
225
+ chat.lastTurnOutcome = "cancelled"
226
+ break
227
+ }
228
+ case "resume_session_set": {
229
+ const chat = this.state.chatsById.get(event.chatId)
230
+ if (!chat) break
231
+ chat.resumeSessionId = event.sessionId
232
+ chat.updatedAt = event.timestamp
233
+ break
234
+ }
235
+ }
236
+ }
237
+
238
+ private append<TEvent extends StoreEvent>(filePath: string, event: TEvent) {
239
+ const payload = `${JSON.stringify(event)}\n`
240
+ this.writeChain = this.writeChain.then(async () => {
241
+ await appendFile(filePath, payload, "utf8")
242
+ this.applyEvent(event)
243
+ })
244
+ return this.writeChain
245
+ }
246
+
247
+ async openProject(localPath: string, title?: string) {
248
+ const normalized = resolveLocalPath(localPath)
249
+ const existingId = this.state.projectIdsByPath.get(normalized)
250
+ if (existingId) {
251
+ const existing = this.state.projectsById.get(existingId)
252
+ if (existing && !existing.deletedAt) {
253
+ return existing
254
+ }
255
+ }
256
+
257
+ const projectId = crypto.randomUUID()
258
+ const event: ProjectEvent = {
259
+ v: STORE_VERSION,
260
+ type: "project_opened",
261
+ timestamp: Date.now(),
262
+ projectId,
263
+ localPath: normalized,
264
+ title: title?.trim() || path.basename(normalized) || normalized,
265
+ }
266
+ await this.append(PROJECTS_LOG, event)
267
+ return this.state.projectsById.get(projectId)!
268
+ }
269
+
270
+ async removeProject(projectId: string) {
271
+ const project = this.getProject(projectId)
272
+ if (!project) {
273
+ throw new Error("Project not found")
274
+ }
275
+
276
+ const event: ProjectEvent = {
277
+ v: STORE_VERSION,
278
+ type: "project_removed",
279
+ timestamp: Date.now(),
280
+ projectId,
281
+ }
282
+ await this.append(PROJECTS_LOG, event)
283
+ }
284
+
285
+ async createChat(projectId: string) {
286
+ const project = this.state.projectsById.get(projectId)
287
+ if (!project || project.deletedAt) {
288
+ throw new Error("Project not found")
289
+ }
290
+ const chatId = crypto.randomUUID()
291
+ const event: ChatEvent = {
292
+ v: STORE_VERSION,
293
+ type: "chat_created",
294
+ timestamp: Date.now(),
295
+ chatId,
296
+ projectId,
297
+ title: "New Chat",
298
+ }
299
+ await this.append(CHATS_LOG, event)
300
+ return this.state.chatsById.get(chatId)!
301
+ }
302
+
303
+ async renameChat(chatId: string, title: string) {
304
+ const trimmed = title.trim()
305
+ if (!trimmed) return
306
+ const chat = this.requireChat(chatId)
307
+ if (chat.title === trimmed) return
308
+ const event: ChatEvent = {
309
+ v: STORE_VERSION,
310
+ type: "chat_renamed",
311
+ timestamp: Date.now(),
312
+ chatId,
313
+ title: trimmed,
314
+ }
315
+ await this.append(CHATS_LOG, event)
316
+ }
317
+
318
+ async deleteChat(chatId: string) {
319
+ this.requireChat(chatId)
320
+ const event: ChatEvent = {
321
+ v: STORE_VERSION,
322
+ type: "chat_deleted",
323
+ timestamp: Date.now(),
324
+ chatId,
325
+ }
326
+ await this.append(CHATS_LOG, event)
327
+ }
328
+
329
+ async setPlanMode(chatId: string, planMode: boolean) {
330
+ const chat = this.requireChat(chatId)
331
+ if (chat.planMode === planMode) return
332
+ const event: ChatEvent = {
333
+ v: STORE_VERSION,
334
+ type: "chat_plan_mode_set",
335
+ timestamp: Date.now(),
336
+ chatId,
337
+ planMode,
338
+ }
339
+ await this.append(CHATS_LOG, event)
340
+ }
341
+
342
+ async appendMessage(chatId: string, entry: TranscriptEntry) {
343
+ this.requireChat(chatId)
344
+ const event: MessageEvent = {
345
+ v: STORE_VERSION,
346
+ type: "message_appended",
347
+ timestamp: Date.now(),
348
+ chatId,
349
+ entry,
350
+ }
351
+ await this.append(MESSAGES_LOG, event)
352
+ }
353
+
354
+ async recordTurnStarted(chatId: string) {
355
+ this.requireChat(chatId)
356
+ const event: TurnEvent = {
357
+ v: STORE_VERSION,
358
+ type: "turn_started",
359
+ timestamp: Date.now(),
360
+ chatId,
361
+ }
362
+ await this.append(TURNS_LOG, event)
363
+ }
364
+
365
+ async recordTurnFinished(chatId: string) {
366
+ this.requireChat(chatId)
367
+ const event: TurnEvent = {
368
+ v: STORE_VERSION,
369
+ type: "turn_finished",
370
+ timestamp: Date.now(),
371
+ chatId,
372
+ }
373
+ await this.append(TURNS_LOG, event)
374
+ }
375
+
376
+ async recordTurnFailed(chatId: string, error: string) {
377
+ this.requireChat(chatId)
378
+ const event: TurnEvent = {
379
+ v: STORE_VERSION,
380
+ type: "turn_failed",
381
+ timestamp: Date.now(),
382
+ chatId,
383
+ error,
384
+ }
385
+ await this.append(TURNS_LOG, event)
386
+ }
387
+
388
+ async recordTurnCancelled(chatId: string) {
389
+ this.requireChat(chatId)
390
+ const event: TurnEvent = {
391
+ v: STORE_VERSION,
392
+ type: "turn_cancelled",
393
+ timestamp: Date.now(),
394
+ chatId,
395
+ }
396
+ await this.append(TURNS_LOG, event)
397
+ }
398
+
399
+ async setResumeSession(chatId: string, sessionId: string | null) {
400
+ const chat = this.requireChat(chatId)
401
+ if (chat.resumeSessionId === sessionId) return
402
+ const event: TurnEvent = {
403
+ v: STORE_VERSION,
404
+ type: "resume_session_set",
405
+ timestamp: Date.now(),
406
+ chatId,
407
+ sessionId,
408
+ }
409
+ await this.append(TURNS_LOG, event)
410
+ }
411
+
412
+ getProject(projectId: string) {
413
+ const project = this.state.projectsById.get(projectId)
414
+ if (!project || project.deletedAt) return null
415
+ return project
416
+ }
417
+
418
+ requireChat(chatId: string) {
419
+ const chat = this.state.chatsById.get(chatId)
420
+ if (!chat || chat.deletedAt) {
421
+ throw new Error("Chat not found")
422
+ }
423
+ return chat
424
+ }
425
+
426
+ getChat(chatId: string) {
427
+ const chat = this.state.chatsById.get(chatId)
428
+ if (!chat || chat.deletedAt) return null
429
+ return chat
430
+ }
431
+
432
+ getMessages(chatId: string) {
433
+ return cloneTranscriptEntries(this.state.messagesByChatId.get(chatId) ?? [])
434
+ }
435
+
436
+ listProjects() {
437
+ return [...this.state.projectsById.values()].filter((project) => !project.deletedAt)
438
+ }
439
+
440
+ listChatsByProject(projectId: string) {
441
+ return [...this.state.chatsById.values()]
442
+ .filter((chat) => chat.projectId === projectId && !chat.deletedAt)
443
+ .sort((a, b) => (b.lastMessageAt ?? b.updatedAt) - (a.lastMessageAt ?? a.updatedAt))
444
+ }
445
+
446
+ getChatCount(projectId: string) {
447
+ return this.listChatsByProject(projectId).length
448
+ }
449
+
450
+ async compact() {
451
+ const snapshot: SnapshotFile = {
452
+ v: STORE_VERSION,
453
+ generatedAt: Date.now(),
454
+ projects: this.listProjects().map((project) => ({ ...project })),
455
+ chats: [...this.state.chatsById.values()]
456
+ .filter((chat) => !chat.deletedAt)
457
+ .map((chat) => ({ ...chat })),
458
+ messages: [...this.state.messagesByChatId.entries()].map(([chatId, entries]) => ({
459
+ chatId,
460
+ entries: cloneTranscriptEntries(entries),
461
+ })),
462
+ }
463
+ await Bun.write(SNAPSHOT_PATH, JSON.stringify(snapshot, null, 2))
464
+ await Bun.write(PROJECTS_LOG, "")
465
+ await Bun.write(CHATS_LOG, "")
466
+ await Bun.write(MESSAGES_LOG, "")
467
+ await Bun.write(TURNS_LOG, "")
468
+ }
469
+
470
+ private async shouldCompact() {
471
+ const files = [PROJECTS_LOG, CHATS_LOG, MESSAGES_LOG, TURNS_LOG]
472
+ let total = 0
473
+ for (const filePath of files) {
474
+ total += Bun.file(filePath).size
475
+ }
476
+ return total >= COMPACTION_THRESHOLD_BYTES
477
+ }
478
+ }
@@ -0,0 +1,134 @@
1
+ import type { ProjectSummary, TranscriptEntry } from "../shared/types"
2
+
3
+ export interface ProjectRecord extends ProjectSummary {
4
+ deletedAt?: number
5
+ }
6
+
7
+ export interface ChatRecord {
8
+ id: string
9
+ projectId: string
10
+ title: string
11
+ createdAt: number
12
+ updatedAt: number
13
+ deletedAt?: number
14
+ planMode: boolean
15
+ resumeSessionId: string | null
16
+ lastMessageAt?: number
17
+ lastTurnOutcome: "success" | "failed" | "cancelled" | null
18
+ }
19
+
20
+ export interface StoreState {
21
+ projectsById: Map<string, ProjectRecord>
22
+ projectIdsByPath: Map<string, string>
23
+ chatsById: Map<string, ChatRecord>
24
+ messagesByChatId: Map<string, TranscriptEntry[]>
25
+ }
26
+
27
+ export interface SnapshotFile {
28
+ v: 1
29
+ generatedAt: number
30
+ projects: ProjectRecord[]
31
+ chats: ChatRecord[]
32
+ messages: Array<{ chatId: string; entries: TranscriptEntry[] }>
33
+ }
34
+
35
+ export type ProjectEvent = {
36
+ v: 1
37
+ type: "project_opened"
38
+ timestamp: number
39
+ projectId: string
40
+ localPath: string
41
+ title: string
42
+ } | {
43
+ v: 1
44
+ type: "project_removed"
45
+ timestamp: number
46
+ projectId: string
47
+ }
48
+
49
+ export type ChatEvent =
50
+ | {
51
+ v: 1
52
+ type: "chat_created"
53
+ timestamp: number
54
+ chatId: string
55
+ projectId: string
56
+ title: string
57
+ }
58
+ | {
59
+ v: 1
60
+ type: "chat_renamed"
61
+ timestamp: number
62
+ chatId: string
63
+ title: string
64
+ }
65
+ | {
66
+ v: 1
67
+ type: "chat_deleted"
68
+ timestamp: number
69
+ chatId: string
70
+ }
71
+ | {
72
+ v: 1
73
+ type: "chat_plan_mode_set"
74
+ timestamp: number
75
+ chatId: string
76
+ planMode: boolean
77
+ }
78
+
79
+ export type MessageEvent = {
80
+ v: 1
81
+ type: "message_appended"
82
+ timestamp: number
83
+ chatId: string
84
+ entry: TranscriptEntry
85
+ }
86
+
87
+ export type TurnEvent =
88
+ | {
89
+ v: 1
90
+ type: "turn_started"
91
+ timestamp: number
92
+ chatId: string
93
+ }
94
+ | {
95
+ v: 1
96
+ type: "turn_finished"
97
+ timestamp: number
98
+ chatId: string
99
+ }
100
+ | {
101
+ v: 1
102
+ type: "turn_failed"
103
+ timestamp: number
104
+ chatId: string
105
+ error: string
106
+ }
107
+ | {
108
+ v: 1
109
+ type: "turn_cancelled"
110
+ timestamp: number
111
+ chatId: string
112
+ }
113
+ | {
114
+ v: 1
115
+ type: "resume_session_set"
116
+ timestamp: number
117
+ chatId: string
118
+ sessionId: string | null
119
+ }
120
+
121
+ export type StoreEvent = ProjectEvent | ChatEvent | MessageEvent | TurnEvent
122
+
123
+ export function createEmptyState(): StoreState {
124
+ return {
125
+ projectsById: new Map(),
126
+ projectIdsByPath: new Map(),
127
+ chatsById: new Map(),
128
+ messagesByChatId: new Map(),
129
+ }
130
+ }
131
+
132
+ export function cloneTranscriptEntries(entries: TranscriptEntry[]): TranscriptEntry[] {
133
+ return entries.map((entry) => ({ ...entry }))
134
+ }
@@ -0,0 +1,105 @@
1
+ import process from "node:process"
2
+ import { spawn, spawnSync } from "node:child_process"
3
+ import type { ClientCommand } from "../shared/protocol"
4
+ import { resolveLocalPath } from "./paths"
5
+
6
+ type OpenExternalAction = Extract<ClientCommand, { type: "system.openExternal" }>["action"]
7
+
8
+ function spawnDetached(command: string, args: string[]) {
9
+ spawn(command, args, { stdio: "ignore", detached: true }).unref()
10
+ }
11
+
12
+ function hasCommand(command: string) {
13
+ const result = spawnSync("sh", ["-lc", `command -v ${command}`], { stdio: "ignore" })
14
+ return result.status === 0
15
+ }
16
+
17
+ function canOpenMacApp(appName: string) {
18
+ const result = spawnSync("open", ["-Ra", appName], { stdio: "ignore" })
19
+ return result.status === 0
20
+ }
21
+
22
+ export function openExternal(localPath: string, action: OpenExternalAction) {
23
+ const resolvedPath = resolveLocalPath(localPath)
24
+ const platform = process.platform
25
+
26
+ if (platform === "darwin") {
27
+ if (action === "open_finder") {
28
+ spawnDetached("open", [resolvedPath])
29
+ return
30
+ }
31
+ if (action === "open_terminal") {
32
+ spawnDetached("open", ["-a", "Terminal", resolvedPath])
33
+ return
34
+ }
35
+ if (action === "open_editor") {
36
+ if (hasCommand("cursor")) {
37
+ spawnDetached("cursor", [resolvedPath])
38
+ return
39
+ }
40
+ for (const appName of ["Cursor", "Visual Studio Code", "Windsurf"]) {
41
+ if (!canOpenMacApp(appName)) continue
42
+ spawnDetached("open", ["-a", appName, resolvedPath])
43
+ return
44
+ }
45
+ spawnDetached("open", [resolvedPath])
46
+ return
47
+ }
48
+ }
49
+
50
+ if (platform === "win32") {
51
+ if (action === "open_finder") {
52
+ spawnDetached("explorer", [resolvedPath])
53
+ return
54
+ }
55
+ if (action === "open_terminal") {
56
+ if (hasCommand("wt")) {
57
+ spawnDetached("wt", ["-d", resolvedPath])
58
+ return
59
+ }
60
+ spawnDetached("cmd", ["/c", "start", "", "cmd", "/K", `cd /d ${resolvedPath}`])
61
+ return
62
+ }
63
+ if (action === "open_editor") {
64
+ if (hasCommand("cursor")) {
65
+ spawnDetached("cursor", [resolvedPath])
66
+ return
67
+ }
68
+ if (hasCommand("code")) {
69
+ spawnDetached("code", [resolvedPath])
70
+ return
71
+ }
72
+ spawnDetached("explorer", [resolvedPath])
73
+ return
74
+ }
75
+ }
76
+
77
+ if (action === "open_finder") {
78
+ spawnDetached("xdg-open", [resolvedPath])
79
+ return
80
+ }
81
+ if (action === "open_terminal") {
82
+ for (const command of ["x-terminal-emulator", "gnome-terminal", "konsole"]) {
83
+ if (!hasCommand(command)) continue
84
+ if (command === "gnome-terminal") {
85
+ spawnDetached(command, ["--working-directory", resolvedPath])
86
+ } else if (command === "konsole") {
87
+ spawnDetached(command, ["--workdir", resolvedPath])
88
+ } else {
89
+ spawnDetached(command, ["--working-directory", resolvedPath])
90
+ }
91
+ return
92
+ }
93
+ spawnDetached("xdg-open", [resolvedPath])
94
+ return
95
+ }
96
+ if (hasCommand("cursor")) {
97
+ spawnDetached("cursor", [resolvedPath])
98
+ return
99
+ }
100
+ if (hasCommand("code")) {
101
+ spawnDetached("code", [resolvedPath])
102
+ return
103
+ }
104
+ spawnDetached("xdg-open", [resolvedPath])
105
+ }