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.
@@ -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
+ }
@@ -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
  })
@@ -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
  }