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.
- package/dist/client/assets/index-BBq_6S76.js +503 -0
- package/dist/client/assets/index-C6-Y890P.css +32 -0
- package/dist/client/index.html +2 -2
- package/package.json +2 -2
- package/src/server/cli-runtime.test.ts +21 -1
- package/src/server/cli-runtime.ts +4 -0
- package/src/server/event-store.test.ts +22 -0
- package/src/server/event-store.ts +52 -44
- package/src/server/keybindings.test.ts +132 -0
- package/src/server/keybindings.ts +175 -0
- package/src/server/server.ts +5 -6
- package/src/server/ws-router.test.ts +107 -63
- package/src/server/ws-router.ts +22 -35
- package/src/shared/branding.test.ts +31 -0
- package/src/shared/branding.ts +41 -6
- package/src/shared/protocol.ts +6 -20
- package/src/shared/types.ts +30 -33
- package/dist/client/assets/index-Yjf7kxJf.js +0 -533
- package/dist/client/assets/index-gEOLdGK-.css +0 -32
- package/src/server/file-tree-manager.test.ts +0 -116
- package/src/server/file-tree-manager.ts +0 -372
|
@@ -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
|
-
}
|