kanna-code 0.9.1 → 0.11.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.
@@ -1,372 +0,0 @@
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
- }