kanna-code 0.22.0 → 0.23.1

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.
@@ -5,8 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <link rel="icon" type="image/png" href="/favicon.png" />
7
7
  <title>Kanna</title>
8
- <script type="module" crossorigin src="/assets/index-1BGyFTrZ.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-DKD9EePK.css">
8
+ <script type="module" crossorigin src="/assets/index-y8BVCflz.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-DADxOnBF.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kanna-code",
3
3
  "type": "module",
4
- "version": "0.22.0",
4
+ "version": "0.23.1",
5
5
  "description": "A beautiful web UI for Claude Code",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -68,6 +68,7 @@
68
68
  "@radix-ui/react-tooltip": "^1.2.8",
69
69
  "@tailwindcss/postcss": "^4.1.18",
70
70
  "@tailwindcss/typography": "^0.5.19",
71
+ "@tanstack/react-virtual": "^3.13.23",
71
72
  "@types/bun": "latest",
72
73
  "@types/node": "^24.10.1",
73
74
  "@types/qrcode": "^1.5.6",
@@ -1,8 +1,8 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test"
2
- import { mkdtemp, rm, writeFile } from "node:fs/promises"
2
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
3
3
  import { tmpdir } from "node:os"
4
4
  import path from "node:path"
5
- import { DiffStore } from "./diff-store"
5
+ import { appendGitIgnoreEntry, DiffStore } from "./diff-store"
6
6
 
7
7
  async function run(command: string[], cwd: string) {
8
8
  const process = Bun.spawn(command, {
@@ -54,6 +54,7 @@ describe("DiffStore", () => {
54
54
  expect(snapshot.status).toBe("ready")
55
55
  expect(snapshot.files).toHaveLength(1)
56
56
  expect(snapshot.files[0]?.path).toBe("app.txt")
57
+ expect(snapshot.files[0]?.isUntracked).toBe(false)
57
58
  expect(snapshot.files[0]?.patch).toContain("-base")
58
59
  expect(snapshot.files[0]?.patch).toContain("+changed")
59
60
  })
@@ -123,5 +124,135 @@ describe("DiffStore", () => {
123
124
  expect(snapshot.files).toHaveLength(1)
124
125
  expect(snapshot.files[0]?.path).toBe("after.txt")
125
126
  expect(snapshot.files[0]?.changeType).toBe("renamed")
127
+ expect(snapshot.files[0]?.isUntracked).toBe(false)
128
+ })
129
+
130
+ test("marks untracked files so they can be ignored", async () => {
131
+ const repoRoot = await createRepo()
132
+ tempDirs.push(repoRoot)
133
+ await writeFile(path.join(repoRoot, "tracked.txt"), "base\n", "utf8")
134
+ await run(["git", "add", "."], repoRoot)
135
+ await run(["git", "commit", "-m", "init"], repoRoot)
136
+ await writeFile(path.join(repoRoot, "scratch.log"), "tmp\n", "utf8")
137
+
138
+ const store = new DiffStore(repoRoot)
139
+ await store.initialize()
140
+ await store.refreshSnapshot("chat-1", repoRoot)
141
+
142
+ const snapshot = store.getSnapshot("chat-1")
143
+ expect(snapshot.files).toHaveLength(1)
144
+ expect(snapshot.files[0]).toMatchObject({
145
+ path: "scratch.log",
146
+ changeType: "added",
147
+ isUntracked: true,
148
+ })
149
+ })
150
+
151
+ test("discardFile reverts a tracked modified file", async () => {
152
+ const repoRoot = await createRepo()
153
+ tempDirs.push(repoRoot)
154
+ await writeFile(path.join(repoRoot, "app.txt"), "base\n", "utf8")
155
+ await run(["git", "add", "."], repoRoot)
156
+ await run(["git", "commit", "-m", "init"], repoRoot)
157
+ await writeFile(path.join(repoRoot, "app.txt"), "changed\n", "utf8")
158
+
159
+ const store = new DiffStore(repoRoot)
160
+ await store.initialize()
161
+ await store.refreshSnapshot("chat-1", repoRoot)
162
+ await store.discardFile({
163
+ chatId: "chat-1",
164
+ projectPath: repoRoot,
165
+ path: "app.txt",
166
+ })
167
+
168
+ expect(await readFile(path.join(repoRoot, "app.txt"), "utf8")).toBe("base\n")
169
+ expect(store.getSnapshot("chat-1").files).toHaveLength(0)
170
+ })
171
+
172
+ test("discardFile deletes an untracked file", async () => {
173
+ const repoRoot = await createRepo()
174
+ tempDirs.push(repoRoot)
175
+ await writeFile(path.join(repoRoot, "tracked.txt"), "base\n", "utf8")
176
+ await run(["git", "add", "."], repoRoot)
177
+ await run(["git", "commit", "-m", "init"], repoRoot)
178
+ await writeFile(path.join(repoRoot, "scratch.log"), "tmp\n", "utf8")
179
+
180
+ const store = new DiffStore(repoRoot)
181
+ await store.initialize()
182
+ await store.refreshSnapshot("chat-1", repoRoot)
183
+ await store.discardFile({
184
+ chatId: "chat-1",
185
+ projectPath: repoRoot,
186
+ path: "scratch.log",
187
+ })
188
+
189
+ expect(await Bun.file(path.join(repoRoot, "scratch.log")).exists()).toBe(false)
190
+ expect(store.getSnapshot("chat-1").files).toHaveLength(0)
191
+ })
192
+
193
+ test("discardFile reverts a renamed file", async () => {
194
+ const repoRoot = await createRepo()
195
+ tempDirs.push(repoRoot)
196
+ await writeFile(path.join(repoRoot, "before.txt"), "same\n", "utf8")
197
+ await run(["git", "add", "."], repoRoot)
198
+ await run(["git", "commit", "-m", "init"], repoRoot)
199
+ await run(["git", "mv", "before.txt", "after.txt"], repoRoot)
200
+
201
+ const store = new DiffStore(repoRoot)
202
+ await store.initialize()
203
+ await store.refreshSnapshot("chat-1", repoRoot)
204
+ await store.discardFile({
205
+ chatId: "chat-1",
206
+ projectPath: repoRoot,
207
+ path: "after.txt",
208
+ })
209
+
210
+ expect(await Bun.file(path.join(repoRoot, "before.txt")).exists()).toBe(true)
211
+ expect(await Bun.file(path.join(repoRoot, "after.txt")).exists()).toBe(false)
212
+ expect(store.getSnapshot("chat-1").files).toHaveLength(0)
213
+ })
214
+
215
+ test("ignoreFile appends a .gitignore entry once", async () => {
216
+ const repoRoot = await createRepo()
217
+ tempDirs.push(repoRoot)
218
+ await writeFile(path.join(repoRoot, "tracked.txt"), "base\n", "utf8")
219
+ await run(["git", "add", "."], repoRoot)
220
+ await run(["git", "commit", "-m", "init"], repoRoot)
221
+ await writeFile(path.join(repoRoot, "scratch.log"), "tmp\n", "utf8")
222
+
223
+ const store = new DiffStore(repoRoot)
224
+ await store.initialize()
225
+ await store.refreshSnapshot("chat-1", repoRoot)
226
+ await store.ignoreFile({
227
+ chatId: "chat-1",
228
+ projectPath: repoRoot,
229
+ path: "scratch.log",
230
+ })
231
+
232
+ expect(await readFile(path.join(repoRoot, ".gitignore"), "utf8")).toBe("scratch.log\n")
233
+ })
234
+
235
+ test("appendGitIgnoreEntry does not duplicate an existing identical entry", () => {
236
+ expect(appendGitIgnoreEntry("scratch.log\n", "scratch.log")).toBe("scratch.log\n")
237
+ expect(appendGitIgnoreEntry("scratch.log", "scratch.log")).toBe("scratch.log\n")
238
+ })
239
+
240
+ test("ignoreFile rejects tracked files", async () => {
241
+ const repoRoot = await createRepo()
242
+ tempDirs.push(repoRoot)
243
+ await writeFile(path.join(repoRoot, "app.txt"), "base\n", "utf8")
244
+ await run(["git", "add", "."], repoRoot)
245
+ await run(["git", "commit", "-m", "init"], repoRoot)
246
+ await writeFile(path.join(repoRoot, "app.txt"), "changed\n", "utf8")
247
+
248
+ const store = new DiffStore(repoRoot)
249
+ await store.initialize()
250
+ await store.refreshSnapshot("chat-1", repoRoot)
251
+
252
+ await expect(store.ignoreFile({
253
+ chatId: "chat-1",
254
+ projectPath: repoRoot,
255
+ path: "app.txt",
256
+ })).rejects.toThrow("Only untracked files can be ignored from the diff viewer")
126
257
  })
127
258
  })
@@ -42,6 +42,7 @@ interface DirtyPathEntry {
42
42
  path: string
43
43
  previousPath?: string
44
44
  changeType: ChatDiffFile["changeType"]
45
+ isUntracked: boolean
45
46
  }
46
47
 
47
48
  async function fileExists(filePath: string) {
@@ -167,9 +168,10 @@ function parseStatusPaths(output: string): DirtyPathEntry[] {
167
168
  const statusCode = line.slice(0, 2)
168
169
  const value = line.slice(3)
169
170
  if (!value) continue
171
+ const isUntracked = statusCode === "??"
170
172
  const isRename = statusCode.includes("R")
171
173
  const isDelete = statusCode.includes("D")
172
- const isAdd = statusCode.includes("A") || statusCode === "??"
174
+ const isAdd = statusCode.includes("A") || isUntracked
173
175
  const changeType: ChatDiffFile["changeType"] = isRename
174
176
  ? "renamed"
175
177
  : isDelete
@@ -185,6 +187,7 @@ function parseStatusPaths(output: string): DirtyPathEntry[] {
185
187
  path: nextPath,
186
188
  previousPath: previousPath || undefined,
187
189
  changeType,
190
+ isUntracked,
188
191
  })
189
192
  }
190
193
  continue
@@ -193,6 +196,7 @@ function parseStatusPaths(output: string): DirtyPathEntry[] {
193
196
  entries.push({
194
197
  path: value,
195
198
  changeType,
199
+ isUntracked,
196
200
  })
197
201
  }
198
202
  return entries.sort((left, right) => left.path.localeCompare(right.path))
@@ -289,6 +293,7 @@ async function computeCurrentFiles(repoRoot: string, baseCommit: string | null):
289
293
  files.push({
290
294
  path: relativePath,
291
295
  changeType: entry.changeType,
296
+ isUntracked: entry.isUntracked,
292
297
  patch,
293
298
  mimeType,
294
299
  size,
@@ -306,6 +311,64 @@ function normalizeRepoRelativePath(inputPath: string) {
306
311
  return normalized
307
312
  }
308
313
 
314
+ async function findDirtyPath(repoRoot: string, relativePath: string) {
315
+ const dirtyPaths = await listDirtyPaths(repoRoot)
316
+ return dirtyPaths.find((entry) => entry.path === relativePath)
317
+ }
318
+
319
+ async function discardAddedPath(repoRoot: string, repoHasHead: boolean, relativePath: string) {
320
+ if (repoHasHead) {
321
+ const resetResult = await runGit(["reset", "--quiet", "HEAD", "--", relativePath], repoRoot)
322
+ if (resetResult.exitCode !== 0) {
323
+ throw new Error(formatGitFailure(resetResult) || "Failed to unstage added file")
324
+ }
325
+ } else {
326
+ const rmCachedResult = await runGit(["rm", "--cached", "--force", "--", relativePath], repoRoot)
327
+ if (rmCachedResult.exitCode !== 0) {
328
+ throw new Error(formatGitFailure(rmCachedResult) || "Failed to unstage added file")
329
+ }
330
+ }
331
+ }
332
+
333
+ async function discardRenamedPath(repoRoot: string, entry: DirtyPathEntry) {
334
+ if (!entry.previousPath) {
335
+ throw new Error(`Missing previous path for renamed file: ${entry.path}`)
336
+ }
337
+
338
+ const resetResult = await runGit(["reset", "--quiet", "HEAD", "--", entry.path], repoRoot)
339
+ if (resetResult.exitCode !== 0) {
340
+ throw new Error(formatGitFailure(resetResult) || "Failed to unstage renamed file")
341
+ }
342
+
343
+ const restoreResult = await runGit(["restore", "--staged", "--worktree", "--source=HEAD", "--", entry.previousPath], repoRoot)
344
+ if (restoreResult.exitCode !== 0) {
345
+ throw new Error(formatGitFailure(restoreResult) || "Failed to restore renamed file")
346
+ }
347
+
348
+ await rm(path.join(repoRoot, entry.path), { recursive: true, force: true })
349
+ }
350
+
351
+ export function appendGitIgnoreEntry(currentContents: string | null, entry: string) {
352
+ const normalizedContents = currentContents ?? ""
353
+ const existingEntries = normalizedContents
354
+ .split(/\r?\n/u)
355
+ .map((line) => line.trim())
356
+ .filter(Boolean)
357
+
358
+ if (existingEntries.includes(entry)) {
359
+ return normalizedContents.length > 0 && !normalizedContents.endsWith("\n")
360
+ ? `${normalizedContents}\n`
361
+ : normalizedContents
362
+ }
363
+
364
+ const prefix = normalizedContents.length === 0
365
+ ? ""
366
+ : normalizedContents.endsWith("\n")
367
+ ? normalizedContents
368
+ : `${normalizedContents}\n`
369
+ return `${prefix}${entry}\n`
370
+ }
371
+
309
372
  export class DiffStore {
310
373
  private readonly states = new Map<string, StoredChatDiffState>()
311
374
 
@@ -454,4 +517,76 @@ export class DiffStore {
454
517
  snapshotChanged,
455
518
  } satisfies DiffCommitResult
456
519
  }
520
+
521
+ async discardFile(args: {
522
+ chatId: string
523
+ projectPath: string
524
+ path: string
525
+ }) {
526
+ const relativePath = normalizeRepoRelativePath(args.path)
527
+ const repo = await resolveRepo(args.projectPath)
528
+ if (!repo) {
529
+ throw new Error("Project is not in a git repository")
530
+ }
531
+
532
+ const entry = await findDirtyPath(repo.repoRoot, relativePath)
533
+ if (!entry) {
534
+ throw new Error(`File is no longer changed: ${relativePath}`)
535
+ }
536
+
537
+ if (entry.isUntracked) {
538
+ await rm(path.join(repo.repoRoot, entry.path), { recursive: true, force: true })
539
+ } else if (entry.changeType === "added") {
540
+ await discardAddedPath(repo.repoRoot, repo.baseCommit !== null, entry.path)
541
+ await rm(path.join(repo.repoRoot, entry.path), { recursive: true, force: true })
542
+ } else if (entry.changeType === "renamed") {
543
+ if (!repo.baseCommit) {
544
+ throw new Error("Cannot discard a rename before the repository has an initial commit")
545
+ }
546
+ await discardRenamedPath(repo.repoRoot, entry)
547
+ } else {
548
+ if (!repo.baseCommit) {
549
+ throw new Error("Cannot discard tracked changes before the repository has an initial commit")
550
+ }
551
+ const restoreResult = await runGit(["restore", "--staged", "--worktree", "--source=HEAD", "--", entry.path], repo.repoRoot)
552
+ if (restoreResult.exitCode !== 0) {
553
+ throw new Error(formatGitFailure(restoreResult) || "Failed to discard file changes")
554
+ }
555
+ }
556
+
557
+ return {
558
+ snapshotChanged: await this.refreshSnapshot(args.chatId, args.projectPath),
559
+ }
560
+ }
561
+
562
+ async ignoreFile(args: {
563
+ chatId: string
564
+ projectPath: string
565
+ path: string
566
+ }) {
567
+ const relativePath = normalizeRepoRelativePath(args.path)
568
+ const repo = await resolveRepo(args.projectPath)
569
+ if (!repo) {
570
+ throw new Error("Project is not in a git repository")
571
+ }
572
+
573
+ const entry = await findDirtyPath(repo.repoRoot, relativePath)
574
+ if (!entry) {
575
+ throw new Error(`File is no longer changed: ${relativePath}`)
576
+ }
577
+ if (!entry.isUntracked) {
578
+ throw new Error("Only untracked files can be ignored from the diff viewer")
579
+ }
580
+
581
+ const gitignorePath = path.join(repo.repoRoot, ".gitignore")
582
+ const currentContents = await readFile(gitignorePath, "utf8").catch(() => null)
583
+ const nextContents = appendGitIgnoreEntry(currentContents, relativePath)
584
+ if (nextContents !== currentContents) {
585
+ await writeFile(gitignorePath, nextContents, "utf8")
586
+ }
587
+
588
+ return {
589
+ snapshotChanged: await this.refreshSnapshot(args.chatId, args.projectPath),
590
+ }
591
+ }
457
592
  }
@@ -11,6 +11,7 @@ describe("generateCommitMessageDetailed", () => {
11
11
  files: [{
12
12
  path: "app.ts",
13
13
  changeType: "modified",
14
+ isUntracked: false,
14
15
  patch: "diff --git a/app.ts b/app.ts\n--- a/app.ts\n+++ b/app.ts\n@@\n-old\n+new\n",
15
16
  }],
16
17
  },
@@ -38,6 +39,7 @@ describe("generateCommitMessageDetailed", () => {
38
39
  files: [{
39
40
  path: "src/feature.ts",
40
41
  changeType: "modified",
42
+ isUntracked: false,
41
43
  patch: "diff --git a/src/feature.ts b/src/feature.ts\n",
42
44
  }],
43
45
  },
@@ -62,11 +62,11 @@ function buildCommitMessagePrompt(args: {
62
62
  const combinedPatch = args.files.map((file) => file.patch).join("\n\n")
63
63
 
64
64
  return [
65
- "Generate a concise git commit message for the selected changes.",
65
+ "Generate a git commit message for the selected changes.",
66
66
  "Return JSON with keys: subject, body.",
67
67
  "Rules:",
68
68
  "- subject must be imperative, under 72 chars, and have no trailing period",
69
- "- body may be an empty string or short bullet points",
69
+ "- body may be an empty string or 3-5 bullet points",
70
70
  "- capture the primary user-visible or developer-visible change",
71
71
  "",
72
72
  `Branch: ${args.branchName ?? "current branch"}`,