kanna-code 0.8.2 → 0.9.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/dist/client/assets/{index-24WSqBXT.js → index-CRDe-Lt2.js} +156 -121
- package/dist/client/assets/index-gEOLdGK-.css +32 -0
- package/dist/client/index.html +2 -2
- package/package.json +1 -1
- package/src/server/external-open.ts +15 -4
- package/src/server/file-tree-manager.test.ts +116 -0
- package/src/server/file-tree-manager.ts +372 -0
- package/src/server/server.ts +6 -0
- package/src/server/ws-router.test.ts +117 -0
- package/src/server/ws-router.ts +47 -0
- package/src/shared/protocol.ts +27 -2
- package/src/shared/types.ts +24 -0
- package/dist/client/assets/index-BE26J9-a.css +0 -32
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import { readdir, watch, type FSWatcher } from "node:fs"
|
|
2
|
+
import { lstat, realpath } from "node:fs/promises"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
import { spawnSync } from "node:child_process"
|
|
5
|
+
import type { FileTreeEvent } from "../shared/protocol"
|
|
6
|
+
import type { FileTreeDirectoryPage, FileTreeSnapshot } from "../shared/types"
|
|
7
|
+
|
|
8
|
+
const DEFAULT_PAGE_SIZE = 200
|
|
9
|
+
const MAX_PAGE_SIZE = 500
|
|
10
|
+
const INVALIDATION_DEBOUNCE_MS = 90
|
|
11
|
+
|
|
12
|
+
interface ProjectLookup {
|
|
13
|
+
localPath: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface ProjectRuntime {
|
|
17
|
+
subscriberCount: number
|
|
18
|
+
watchers: Map<string, FSWatcher>
|
|
19
|
+
pendingInvalidations: Set<string>
|
|
20
|
+
invalidateTimer: Timer | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface GitIgnoreCacheEntry {
|
|
24
|
+
repoRoot: string | null
|
|
25
|
+
projectRealPath: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface CreateFileTreeManagerArgs {
|
|
29
|
+
getProject: (projectId: string) => ProjectLookup | null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface DirectoryCandidate {
|
|
33
|
+
name: string
|
|
34
|
+
absolutePath: string
|
|
35
|
+
relativePath: string
|
|
36
|
+
kind: "file" | "directory" | "symlink"
|
|
37
|
+
extension?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class FileTreeManager {
|
|
41
|
+
private readonly getProject: CreateFileTreeManagerArgs["getProject"]
|
|
42
|
+
private readonly projectRuntimes = new Map<string, ProjectRuntime>()
|
|
43
|
+
private readonly gitIgnoreCache = new Map<string, GitIgnoreCacheEntry>()
|
|
44
|
+
private readonly invalidateListeners = new Set<(event: FileTreeEvent) => void>()
|
|
45
|
+
|
|
46
|
+
constructor(args: CreateFileTreeManagerArgs) {
|
|
47
|
+
this.getProject = args.getProject
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
getSnapshot(projectId: string): FileTreeSnapshot {
|
|
51
|
+
const project = this.requireProject(projectId)
|
|
52
|
+
return {
|
|
53
|
+
projectId,
|
|
54
|
+
rootPath: project.localPath,
|
|
55
|
+
pageSize: DEFAULT_PAGE_SIZE,
|
|
56
|
+
supportsRealtime: true,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async readDirectory(args: {
|
|
61
|
+
projectId: string
|
|
62
|
+
directoryPath: string
|
|
63
|
+
cursor?: string
|
|
64
|
+
limit?: number
|
|
65
|
+
}): Promise<FileTreeDirectoryPage> {
|
|
66
|
+
const project = this.requireProject(args.projectId)
|
|
67
|
+
const rootPath = await realpath(project.localPath)
|
|
68
|
+
const directoryPath = normalizeRelativeDirectoryPath(args.directoryPath)
|
|
69
|
+
const absoluteDirectoryPath = resolveProjectPath(rootPath, directoryPath)
|
|
70
|
+
const info = await lstat(absoluteDirectoryPath).catch(() => null)
|
|
71
|
+
|
|
72
|
+
if (!info) {
|
|
73
|
+
return {
|
|
74
|
+
directoryPath,
|
|
75
|
+
entries: [],
|
|
76
|
+
nextCursor: null,
|
|
77
|
+
hasMore: false,
|
|
78
|
+
error: "Directory not found",
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!info.isDirectory()) {
|
|
83
|
+
return {
|
|
84
|
+
directoryPath,
|
|
85
|
+
entries: [],
|
|
86
|
+
nextCursor: null,
|
|
87
|
+
hasMore: false,
|
|
88
|
+
error: "Path is not a directory",
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const pageSize = clampPageSize(args.limit)
|
|
93
|
+
const candidates = await new Promise<DirectoryCandidate[]>((resolve, reject) => {
|
|
94
|
+
readdir(absoluteDirectoryPath, { withFileTypes: true }, async (error, dirents) => {
|
|
95
|
+
if (error) {
|
|
96
|
+
reject(error)
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const nextCandidates = await Promise.all(
|
|
102
|
+
dirents.map(async (dirent): Promise<DirectoryCandidate | null> => {
|
|
103
|
+
const name = dirent.name
|
|
104
|
+
if (name === ".git") return null
|
|
105
|
+
|
|
106
|
+
const childRelativePath = joinRelativePath(directoryPath, name)
|
|
107
|
+
const childAbsolutePath = resolveProjectPath(rootPath, childRelativePath)
|
|
108
|
+
|
|
109
|
+
let kind: DirectoryCandidate["kind"] = "file"
|
|
110
|
+
if (dirent.isDirectory()) {
|
|
111
|
+
kind = "directory"
|
|
112
|
+
} else if (dirent.isSymbolicLink()) {
|
|
113
|
+
kind = "symlink"
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (kind === "symlink") {
|
|
117
|
+
const stat = await lstat(childAbsolutePath).catch(() => null)
|
|
118
|
+
if (stat?.isDirectory()) {
|
|
119
|
+
kind = "symlink"
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
name,
|
|
125
|
+
absolutePath: childAbsolutePath,
|
|
126
|
+
relativePath: childRelativePath,
|
|
127
|
+
kind,
|
|
128
|
+
extension: getExtension(name),
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
resolve(nextCandidates.filter((candidate): candidate is DirectoryCandidate => candidate !== null))
|
|
134
|
+
} catch (caughtError) {
|
|
135
|
+
reject(caughtError)
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
}).catch((error: unknown) => {
|
|
139
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
140
|
+
return Promise.reject(new Error(message))
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
const visibleCandidates = await this.filterIgnored(args.projectId, rootPath, candidates)
|
|
144
|
+
visibleCandidates.sort(compareCandidates)
|
|
145
|
+
|
|
146
|
+
const start = parseCursor(args.cursor)
|
|
147
|
+
const page = visibleCandidates.slice(start, start + pageSize)
|
|
148
|
+
const nextCursor = start + page.length < visibleCandidates.length ? String(start + page.length) : null
|
|
149
|
+
|
|
150
|
+
this.ensureWatchedDirectory(args.projectId, directoryPath)
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
directoryPath,
|
|
154
|
+
entries: page.map((candidate) => ({
|
|
155
|
+
name: candidate.name,
|
|
156
|
+
relativePath: candidate.relativePath,
|
|
157
|
+
kind: candidate.kind,
|
|
158
|
+
extension: candidate.extension,
|
|
159
|
+
})),
|
|
160
|
+
nextCursor,
|
|
161
|
+
hasMore: nextCursor !== null,
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
subscribe(projectId: string) {
|
|
166
|
+
const runtime = this.ensureRuntime(projectId)
|
|
167
|
+
runtime.subscriberCount += 1
|
|
168
|
+
this.ensureWatchedDirectory(projectId, "")
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
unsubscribe(projectId: string) {
|
|
172
|
+
const runtime = this.projectRuntimes.get(projectId)
|
|
173
|
+
if (!runtime) return
|
|
174
|
+
runtime.subscriberCount = Math.max(0, runtime.subscriberCount - 1)
|
|
175
|
+
if (runtime.subscriberCount > 0) return
|
|
176
|
+
this.disposeRuntime(projectId)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
onInvalidate(listener: (event: FileTreeEvent) => void) {
|
|
180
|
+
this.invalidateListeners.add(listener)
|
|
181
|
+
return () => {
|
|
182
|
+
this.invalidateListeners.delete(listener)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
dispose() {
|
|
187
|
+
for (const projectId of [...this.projectRuntimes.keys()]) {
|
|
188
|
+
this.disposeRuntime(projectId)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private requireProject(projectId: string) {
|
|
193
|
+
const project = this.getProject(projectId)
|
|
194
|
+
if (!project) {
|
|
195
|
+
throw new Error("Project not found")
|
|
196
|
+
}
|
|
197
|
+
return project
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private ensureRuntime(projectId: string) {
|
|
201
|
+
const existing = this.projectRuntimes.get(projectId)
|
|
202
|
+
if (existing) {
|
|
203
|
+
return existing
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const runtime: ProjectRuntime = {
|
|
207
|
+
subscriberCount: 0,
|
|
208
|
+
watchers: new Map(),
|
|
209
|
+
pendingInvalidations: new Set(),
|
|
210
|
+
invalidateTimer: null,
|
|
211
|
+
}
|
|
212
|
+
this.projectRuntimes.set(projectId, runtime)
|
|
213
|
+
return runtime
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private ensureWatchedDirectory(projectId: string, directoryPath: string) {
|
|
217
|
+
const runtime = this.projectRuntimes.get(projectId)
|
|
218
|
+
if (!runtime || runtime.subscriberCount === 0) return
|
|
219
|
+
if (runtime.watchers.has(directoryPath)) return
|
|
220
|
+
|
|
221
|
+
const project = this.requireProject(projectId)
|
|
222
|
+
const absolutePath = resolveProjectPath(project.localPath, directoryPath)
|
|
223
|
+
const watcher = watch(absolutePath, { persistent: false }, () => {
|
|
224
|
+
this.queueInvalidation(projectId, directoryPath)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
watcher.on("error", () => {
|
|
228
|
+
this.queueInvalidation(projectId, directoryPath)
|
|
229
|
+
watcher.close()
|
|
230
|
+
runtime.watchers.delete(directoryPath)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
runtime.watchers.set(directoryPath, watcher)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private queueInvalidation(projectId: string, directoryPath: string) {
|
|
237
|
+
const runtime = this.projectRuntimes.get(projectId)
|
|
238
|
+
if (!runtime) return
|
|
239
|
+
runtime.pendingInvalidations.add(directoryPath)
|
|
240
|
+
if (runtime.invalidateTimer) return
|
|
241
|
+
|
|
242
|
+
runtime.invalidateTimer = setTimeout(() => {
|
|
243
|
+
runtime.invalidateTimer = null
|
|
244
|
+
const directoryPaths = [...runtime.pendingInvalidations]
|
|
245
|
+
runtime.pendingInvalidations.clear()
|
|
246
|
+
if (directoryPaths.length === 0) return
|
|
247
|
+
const event: FileTreeEvent = {
|
|
248
|
+
type: "file-tree.invalidate",
|
|
249
|
+
projectId,
|
|
250
|
+
directoryPaths: directoryPaths.sort(),
|
|
251
|
+
}
|
|
252
|
+
for (const listener of this.invalidateListeners) {
|
|
253
|
+
listener(event)
|
|
254
|
+
}
|
|
255
|
+
}, INVALIDATION_DEBOUNCE_MS)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private disposeRuntime(projectId: string) {
|
|
259
|
+
const runtime = this.projectRuntimes.get(projectId)
|
|
260
|
+
if (!runtime) return
|
|
261
|
+
for (const watcher of runtime.watchers.values()) {
|
|
262
|
+
watcher.close()
|
|
263
|
+
}
|
|
264
|
+
runtime.watchers.clear()
|
|
265
|
+
if (runtime.invalidateTimer) {
|
|
266
|
+
clearTimeout(runtime.invalidateTimer)
|
|
267
|
+
}
|
|
268
|
+
runtime.pendingInvalidations.clear()
|
|
269
|
+
this.projectRuntimes.delete(projectId)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private async filterIgnored(projectId: string, rootPath: string, candidates: DirectoryCandidate[]) {
|
|
273
|
+
if (candidates.length === 0) return candidates
|
|
274
|
+
|
|
275
|
+
const cache = this.getGitIgnoreCache(projectId, rootPath)
|
|
276
|
+
if (!cache.repoRoot) return candidates
|
|
277
|
+
|
|
278
|
+
const visible = candidates.filter((candidate) => isWithinPath(cache.repoRoot as string, candidate.absolutePath))
|
|
279
|
+
const pathsToCheck = visible.map((candidate) => path.relative(cache.repoRoot as string, candidate.absolutePath))
|
|
280
|
+
if (pathsToCheck.length === 0) return candidates
|
|
281
|
+
|
|
282
|
+
const result = spawnSync("git", ["-C", cache.repoRoot, "check-ignore", "--stdin"], {
|
|
283
|
+
input: pathsToCheck.join("\n"),
|
|
284
|
+
encoding: "utf8",
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
if (result.status !== 0 && result.status !== 1) {
|
|
288
|
+
return candidates
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const ignored = new Set(
|
|
292
|
+
result.stdout
|
|
293
|
+
.split("\n")
|
|
294
|
+
.map((line) => line.trim())
|
|
295
|
+
.filter(Boolean)
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
return candidates.filter((candidate) => {
|
|
299
|
+
if (!isWithinPath(cache.repoRoot as string, candidate.absolutePath)) {
|
|
300
|
+
return true
|
|
301
|
+
}
|
|
302
|
+
const repoRelative = path.relative(cache.repoRoot as string, candidate.absolutePath)
|
|
303
|
+
return !ignored.has(repoRelative)
|
|
304
|
+
})
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private getGitIgnoreCache(projectId: string, rootPath: string) {
|
|
308
|
+
const cached = this.gitIgnoreCache.get(projectId)
|
|
309
|
+
if (cached?.projectRealPath === rootPath) {
|
|
310
|
+
return cached
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const result = spawnSync("git", ["-C", rootPath, "rev-parse", "--show-toplevel"], { encoding: "utf8" })
|
|
314
|
+
const repoRoot = result.status === 0 ? result.stdout.trim() || null : null
|
|
315
|
+
const nextEntry = {
|
|
316
|
+
repoRoot,
|
|
317
|
+
projectRealPath: rootPath,
|
|
318
|
+
}
|
|
319
|
+
this.gitIgnoreCache.set(projectId, nextEntry)
|
|
320
|
+
return nextEntry
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function clampPageSize(limit?: number) {
|
|
325
|
+
if (!limit || !Number.isFinite(limit)) return DEFAULT_PAGE_SIZE
|
|
326
|
+
return Math.min(MAX_PAGE_SIZE, Math.max(1, Math.floor(limit)))
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function parseCursor(cursor?: string) {
|
|
330
|
+
if (!cursor) return 0
|
|
331
|
+
const parsed = Number.parseInt(cursor, 10)
|
|
332
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function normalizeRelativeDirectoryPath(directoryPath: string) {
|
|
336
|
+
if (!directoryPath || directoryPath === ".") return ""
|
|
337
|
+
const normalized = directoryPath.replaceAll("\\", "/").replace(/^\/+|\/+$/g, "")
|
|
338
|
+
if (!normalized) return ""
|
|
339
|
+
const segments = normalized.split("/")
|
|
340
|
+
if (segments.includes("..")) {
|
|
341
|
+
throw new Error("Directory path must stay within the project root")
|
|
342
|
+
}
|
|
343
|
+
return normalized
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function resolveProjectPath(rootPath: string, relativePath: string) {
|
|
347
|
+
const resolved = path.resolve(rootPath, relativePath || ".")
|
|
348
|
+
if (!isWithinPath(rootPath, resolved)) {
|
|
349
|
+
throw new Error("Path must stay within the project root")
|
|
350
|
+
}
|
|
351
|
+
return resolved
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function isWithinPath(rootPath: string, candidatePath: string) {
|
|
355
|
+
const relative = path.relative(rootPath, candidatePath)
|
|
356
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function joinRelativePath(parent: string, name: string) {
|
|
360
|
+
return parent ? `${parent}/${name}` : name
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function compareCandidates(left: DirectoryCandidate, right: DirectoryCandidate) {
|
|
364
|
+
if (left.kind === "directory" && right.kind !== "directory") return -1
|
|
365
|
+
if (left.kind !== "directory" && right.kind === "directory") return 1
|
|
366
|
+
return left.name.localeCompare(right.name, undefined, { sensitivity: "base", numeric: true })
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function getExtension(name: string) {
|
|
370
|
+
const extension = path.extname(name)
|
|
371
|
+
return extension ? extension.slice(1).toLowerCase() : undefined
|
|
372
|
+
}
|
package/src/server/server.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { APP_NAME } from "../shared/branding"
|
|
|
3
3
|
import { EventStore } from "./event-store"
|
|
4
4
|
import { AgentCoordinator } from "./agent"
|
|
5
5
|
import { discoverProjects, type DiscoveredProject } from "./discovery"
|
|
6
|
+
import { FileTreeManager } from "./file-tree-manager"
|
|
6
7
|
import { getMachineDisplayName } from "./machine-name"
|
|
7
8
|
import { TerminalManager } from "./terminal-manager"
|
|
8
9
|
import { createWsRouter, type ClientState } from "./ws-router"
|
|
@@ -30,6 +31,9 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
|
|
|
30
31
|
let server: ReturnType<typeof Bun.serve<ClientState>>
|
|
31
32
|
let router: ReturnType<typeof createWsRouter>
|
|
32
33
|
const terminals = new TerminalManager()
|
|
34
|
+
const fileTree = new FileTreeManager({
|
|
35
|
+
getProject: (projectId) => store.getProject(projectId),
|
|
36
|
+
})
|
|
33
37
|
const agent = new AgentCoordinator({
|
|
34
38
|
store,
|
|
35
39
|
onStateChange: () => {
|
|
@@ -40,6 +44,7 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
|
|
|
40
44
|
store,
|
|
41
45
|
agent,
|
|
42
46
|
terminals,
|
|
47
|
+
fileTree,
|
|
43
48
|
refreshDiscovery,
|
|
44
49
|
getDiscoveredProjects: () => discoveredProjects,
|
|
45
50
|
machineDisplayName,
|
|
@@ -101,6 +106,7 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
|
|
|
101
106
|
await agent.cancel(chatId)
|
|
102
107
|
}
|
|
103
108
|
router.dispose()
|
|
109
|
+
fileTree.dispose()
|
|
104
110
|
terminals.closeAll()
|
|
105
111
|
await store.compact()
|
|
106
112
|
server.stop(true)
|
|
@@ -23,6 +23,10 @@ describe("ws-router", () => {
|
|
|
23
23
|
getSnapshot: () => null,
|
|
24
24
|
onEvent: () => () => {},
|
|
25
25
|
} as never,
|
|
26
|
+
fileTree: {
|
|
27
|
+
getSnapshot: () => ({ projectId: "project-1", rootPath: "/tmp/project-1", pageSize: 200, supportsRealtime: true }),
|
|
28
|
+
onInvalidate: () => () => {},
|
|
29
|
+
} as never,
|
|
26
30
|
refreshDiscovery: async () => [],
|
|
27
31
|
getDiscoveredProjects: () => [],
|
|
28
32
|
machineDisplayName: "Local Machine",
|
|
@@ -58,6 +62,10 @@ describe("ws-router", () => {
|
|
|
58
62
|
onEvent: () => () => {},
|
|
59
63
|
write: () => {},
|
|
60
64
|
} as never,
|
|
65
|
+
fileTree: {
|
|
66
|
+
getSnapshot: () => ({ projectId: "project-1", rootPath: "/tmp/project-1", pageSize: 200, supportsRealtime: true }),
|
|
67
|
+
onInvalidate: () => () => {},
|
|
68
|
+
} as never,
|
|
61
69
|
refreshDiscovery: async () => [],
|
|
62
70
|
getDiscoveredProjects: () => [],
|
|
63
71
|
machineDisplayName: "Local Machine",
|
|
@@ -87,4 +95,113 @@ describe("ws-router", () => {
|
|
|
87
95
|
},
|
|
88
96
|
])
|
|
89
97
|
})
|
|
98
|
+
|
|
99
|
+
test("subscribes and unsubscribes file-tree topics and acks directory reads", async () => {
|
|
100
|
+
const fileTree = {
|
|
101
|
+
subscribeCalls: [] as string[],
|
|
102
|
+
unsubscribeCalls: [] as string[],
|
|
103
|
+
subscribe(projectId: string) {
|
|
104
|
+
this.subscribeCalls.push(projectId)
|
|
105
|
+
},
|
|
106
|
+
unsubscribe(projectId: string) {
|
|
107
|
+
this.unsubscribeCalls.push(projectId)
|
|
108
|
+
},
|
|
109
|
+
getSnapshot: (projectId: string) => ({
|
|
110
|
+
projectId,
|
|
111
|
+
rootPath: "/tmp/project-1",
|
|
112
|
+
pageSize: 200,
|
|
113
|
+
supportsRealtime: true as const,
|
|
114
|
+
}),
|
|
115
|
+
readDirectory: async () => ({
|
|
116
|
+
directoryPath: "",
|
|
117
|
+
entries: [],
|
|
118
|
+
nextCursor: null,
|
|
119
|
+
hasMore: false,
|
|
120
|
+
}),
|
|
121
|
+
onInvalidate: () => () => {},
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const router = createWsRouter({
|
|
125
|
+
store: { state: createEmptyState() } as never,
|
|
126
|
+
agent: { getActiveStatuses: () => new Map() } as never,
|
|
127
|
+
terminals: {
|
|
128
|
+
getSnapshot: () => null,
|
|
129
|
+
onEvent: () => () => {},
|
|
130
|
+
} as never,
|
|
131
|
+
fileTree: fileTree as never,
|
|
132
|
+
refreshDiscovery: async () => [],
|
|
133
|
+
getDiscoveredProjects: () => [],
|
|
134
|
+
machineDisplayName: "Local Machine",
|
|
135
|
+
})
|
|
136
|
+
const ws = new FakeWebSocket()
|
|
137
|
+
|
|
138
|
+
router.handleMessage(
|
|
139
|
+
ws as never,
|
|
140
|
+
JSON.stringify({
|
|
141
|
+
v: 1,
|
|
142
|
+
type: "subscribe",
|
|
143
|
+
id: "tree-sub-1",
|
|
144
|
+
topic: { type: "file-tree", projectId: "project-1" },
|
|
145
|
+
})
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
expect(fileTree.subscribeCalls).toEqual(["project-1"])
|
|
149
|
+
expect(ws.sent[0]).toEqual({
|
|
150
|
+
v: PROTOCOL_VERSION,
|
|
151
|
+
type: "snapshot",
|
|
152
|
+
id: "tree-sub-1",
|
|
153
|
+
snapshot: {
|
|
154
|
+
type: "file-tree",
|
|
155
|
+
data: {
|
|
156
|
+
projectId: "project-1",
|
|
157
|
+
rootPath: "/tmp/project-1",
|
|
158
|
+
pageSize: 200,
|
|
159
|
+
supportsRealtime: true,
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
router.handleMessage(
|
|
165
|
+
ws as never,
|
|
166
|
+
JSON.stringify({
|
|
167
|
+
v: 1,
|
|
168
|
+
type: "command",
|
|
169
|
+
id: "tree-read-1",
|
|
170
|
+
command: {
|
|
171
|
+
type: "file-tree.readDirectory",
|
|
172
|
+
projectId: "project-1",
|
|
173
|
+
directoryPath: "",
|
|
174
|
+
},
|
|
175
|
+
})
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
await Promise.resolve()
|
|
179
|
+
expect(ws.sent[1]).toEqual({
|
|
180
|
+
v: PROTOCOL_VERSION,
|
|
181
|
+
type: "ack",
|
|
182
|
+
id: "tree-read-1",
|
|
183
|
+
result: {
|
|
184
|
+
directoryPath: "",
|
|
185
|
+
entries: [],
|
|
186
|
+
nextCursor: null,
|
|
187
|
+
hasMore: false,
|
|
188
|
+
},
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
router.handleMessage(
|
|
192
|
+
ws as never,
|
|
193
|
+
JSON.stringify({
|
|
194
|
+
v: 1,
|
|
195
|
+
type: "unsubscribe",
|
|
196
|
+
id: "tree-sub-1",
|
|
197
|
+
})
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
expect(fileTree.unsubscribeCalls).toEqual(["project-1"])
|
|
201
|
+
expect(ws.sent[2]).toEqual({
|
|
202
|
+
v: PROTOCOL_VERSION,
|
|
203
|
+
type: "ack",
|
|
204
|
+
id: "tree-sub-1",
|
|
205
|
+
})
|
|
206
|
+
})
|
|
90
207
|
})
|
package/src/server/ws-router.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { AgentCoordinator } from "./agent"
|
|
|
6
6
|
import type { DiscoveredProject } from "./discovery"
|
|
7
7
|
import { EventStore } from "./event-store"
|
|
8
8
|
import { openExternal } from "./external-open"
|
|
9
|
+
import { FileTreeManager } from "./file-tree-manager"
|
|
9
10
|
import { ensureProjectDirectory } from "./paths"
|
|
10
11
|
import { TerminalManager } from "./terminal-manager"
|
|
11
12
|
import { deriveChatSnapshot, deriveLocalProjectsSnapshot, deriveSidebarData } from "./read-models"
|
|
@@ -18,6 +19,7 @@ interface CreateWsRouterArgs {
|
|
|
18
19
|
store: EventStore
|
|
19
20
|
agent: AgentCoordinator
|
|
20
21
|
terminals: TerminalManager
|
|
22
|
+
fileTree: FileTreeManager
|
|
21
23
|
refreshDiscovery: () => Promise<DiscoveredProject[]>
|
|
22
24
|
getDiscoveredProjects: () => DiscoveredProject[]
|
|
23
25
|
machineDisplayName: string
|
|
@@ -31,6 +33,7 @@ export function createWsRouter({
|
|
|
31
33
|
store,
|
|
32
34
|
agent,
|
|
33
35
|
terminals,
|
|
36
|
+
fileTree,
|
|
34
37
|
refreshDiscovery,
|
|
35
38
|
getDiscoveredProjects,
|
|
36
39
|
machineDisplayName,
|
|
@@ -77,6 +80,18 @@ export function createWsRouter({
|
|
|
77
80
|
}
|
|
78
81
|
}
|
|
79
82
|
|
|
83
|
+
if (topic.type === "file-tree") {
|
|
84
|
+
return {
|
|
85
|
+
v: PROTOCOL_VERSION,
|
|
86
|
+
type: "snapshot",
|
|
87
|
+
id,
|
|
88
|
+
snapshot: {
|
|
89
|
+
type: "file-tree",
|
|
90
|
+
data: fileTree.getSnapshot(topic.projectId),
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
80
95
|
return {
|
|
81
96
|
v: PROTOCOL_VERSION,
|
|
82
97
|
type: "snapshot",
|
|
@@ -127,6 +142,20 @@ export function createWsRouter({
|
|
|
127
142
|
pushTerminalEvent(event.terminalId, event)
|
|
128
143
|
})
|
|
129
144
|
|
|
145
|
+
const disposeFileTreeEvents = fileTree.onInvalidate((event) => {
|
|
146
|
+
for (const ws of sockets) {
|
|
147
|
+
for (const [id, topic] of ws.data.subscriptions.entries()) {
|
|
148
|
+
if (topic.type !== "file-tree" || topic.projectId !== event.projectId) continue
|
|
149
|
+
send(ws, {
|
|
150
|
+
v: PROTOCOL_VERSION,
|
|
151
|
+
type: "event",
|
|
152
|
+
id,
|
|
153
|
+
event,
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
|
|
130
159
|
async function handleCommand(ws: ServerWebSocket<ClientState>, message: Extract<ClientEnvelope, { type: "command" }>) {
|
|
131
160
|
const { command, id } = message
|
|
132
161
|
try {
|
|
@@ -228,6 +257,11 @@ export function createWsRouter({
|
|
|
228
257
|
pushTerminalSnapshot(command.terminalId)
|
|
229
258
|
return
|
|
230
259
|
}
|
|
260
|
+
case "file-tree.readDirectory": {
|
|
261
|
+
const result = await fileTree.readDirectory(command)
|
|
262
|
+
send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
|
|
263
|
+
return
|
|
264
|
+
}
|
|
231
265
|
}
|
|
232
266
|
|
|
233
267
|
broadcastSnapshots()
|
|
@@ -242,6 +276,11 @@ export function createWsRouter({
|
|
|
242
276
|
sockets.add(ws)
|
|
243
277
|
},
|
|
244
278
|
handleClose(ws: ServerWebSocket<ClientState>) {
|
|
279
|
+
for (const topic of ws.data.subscriptions.values()) {
|
|
280
|
+
if (topic.type === "file-tree") {
|
|
281
|
+
fileTree.unsubscribe(topic.projectId)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
245
284
|
sockets.delete(ws)
|
|
246
285
|
},
|
|
247
286
|
broadcastSnapshots,
|
|
@@ -261,6 +300,9 @@ export function createWsRouter({
|
|
|
261
300
|
|
|
262
301
|
if (parsed.type === "subscribe") {
|
|
263
302
|
ws.data.subscriptions.set(parsed.id, parsed.topic)
|
|
303
|
+
if (parsed.topic.type === "file-tree") {
|
|
304
|
+
fileTree.subscribe(parsed.topic.projectId)
|
|
305
|
+
}
|
|
264
306
|
if (parsed.topic.type === "local-projects") {
|
|
265
307
|
void refreshDiscovery().then(() => {
|
|
266
308
|
if (ws.data.subscriptions.has(parsed.id)) {
|
|
@@ -273,7 +315,11 @@ export function createWsRouter({
|
|
|
273
315
|
}
|
|
274
316
|
|
|
275
317
|
if (parsed.type === "unsubscribe") {
|
|
318
|
+
const topic = ws.data.subscriptions.get(parsed.id)
|
|
276
319
|
ws.data.subscriptions.delete(parsed.id)
|
|
320
|
+
if (topic?.type === "file-tree") {
|
|
321
|
+
fileTree.unsubscribe(topic.projectId)
|
|
322
|
+
}
|
|
277
323
|
send(ws, { v: PROTOCOL_VERSION, type: "ack", id: parsed.id })
|
|
278
324
|
return
|
|
279
325
|
}
|
|
@@ -282,6 +328,7 @@ export function createWsRouter({
|
|
|
282
328
|
},
|
|
283
329
|
dispose() {
|
|
284
330
|
disposeTerminalEvents()
|
|
331
|
+
disposeFileTreeEvents()
|
|
285
332
|
},
|
|
286
333
|
}
|
|
287
334
|
}
|