kanna-code 0.24.0 → 0.25.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.
@@ -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-0UyEIBeu.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-CYUEw41P.css">
8
+ <script type="module" crossorigin src="/assets/index-Tg96DNSK.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-Hy4LEj8j.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.24.0",
4
+ "version": "0.25.0",
5
5
  "description": "A beautiful web UI for Claude Code",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -31,6 +31,12 @@ async function createRepo() {
31
31
  return root
32
32
  }
33
33
 
34
+ async function createBareRemote() {
35
+ const root = await mkdtemp(path.join(tmpdir(), "kanna-diff-remote-"))
36
+ await run(["git", "init", "--bare"], root)
37
+ return root
38
+ }
39
+
34
40
  const tempDirs: string[] = []
35
41
 
36
42
  describe("DiffStore", () => {
@@ -48,15 +54,18 @@ describe("DiffStore", () => {
48
54
 
49
55
  const store = new DiffStore(repoRoot)
50
56
  await store.initialize()
51
- await store.refreshSnapshot("chat-1", repoRoot)
57
+ await store.refreshSnapshot("project-1", repoRoot)
52
58
 
53
- const snapshot = store.getSnapshot("chat-1")
59
+ const snapshot = store.getProjectSnapshot("project-1")
54
60
  expect(snapshot.status).toBe("ready")
55
61
  expect(snapshot.files).toHaveLength(1)
56
62
  expect(snapshot.files[0]?.path).toBe("app.txt")
57
63
  expect(snapshot.files[0]?.isUntracked).toBe(false)
58
- expect(snapshot.files[0]?.patch).toContain("-base")
59
- expect(snapshot.files[0]?.patch).toContain("+changed")
64
+ expect(snapshot.files[0]?.additions).toBe(1)
65
+ expect(snapshot.files[0]?.deletions).toBe(1)
66
+ await expect(store.readPatch({ projectPath: repoRoot, path: "app.txt" })).resolves.toMatchObject({
67
+ patch: expect.stringContaining("-base"),
68
+ })
60
69
  })
61
70
 
62
71
  test("returns no_repo outside a git repository", async () => {
@@ -65,9 +74,9 @@ describe("DiffStore", () => {
65
74
 
66
75
  const store = new DiffStore(root)
67
76
  await store.initialize()
68
- await store.refreshSnapshot("chat-1", root)
77
+ await store.refreshSnapshot("project-1", root)
69
78
 
70
- expect(store.getSnapshot("chat-1")).toEqual({
79
+ expect(store.getProjectSnapshot("project-1")).toEqual({
71
80
  status: "no_repo",
72
81
  branchName: undefined,
73
82
  files: [],
@@ -88,10 +97,10 @@ describe("DiffStore", () => {
88
97
 
89
98
  const store = new DiffStore(repoRoot)
90
99
  await store.initialize()
91
- await store.refreshSnapshot("chat-1", repoRoot)
100
+ await store.refreshSnapshot("project-1", repoRoot)
92
101
 
93
102
  await store.commitFiles({
94
- chatId: "chat-1",
103
+ projectId: "project-1",
95
104
  projectPath: repoRoot,
96
105
  paths: ["app.txt"],
97
106
  summary: "Update app",
@@ -99,7 +108,7 @@ describe("DiffStore", () => {
99
108
  mode: "commit_only",
100
109
  })
101
110
 
102
- const snapshot = store.getSnapshot("chat-1")
111
+ const snapshot = store.getProjectSnapshot("project-1")
103
112
  expect(snapshot.status).toBe("ready")
104
113
  expect(snapshot.files).toHaveLength(1)
105
114
  expect(snapshot.files[0]?.path).toBe("notes.txt")
@@ -108,6 +117,37 @@ describe("DiffStore", () => {
108
117
  expect(lastMessage).toBe("Update app\n\nOnly app changes")
109
118
  })
110
119
 
120
+ test("commit_and_push publishes an unpublished branch", async () => {
121
+ const repoRoot = await createRepo()
122
+ const remoteRoot = await createBareRemote()
123
+ tempDirs.push(repoRoot, remoteRoot)
124
+ await run(["git", "remote", "add", "origin", remoteRoot], repoRoot)
125
+ await writeFile(path.join(repoRoot, "app.txt"), "base\n", "utf8")
126
+ await run(["git", "add", "."], repoRoot)
127
+ await run(["git", "commit", "-m", "init"], repoRoot)
128
+ await run(["git", "switch", "-c", "feature/publish-me"], repoRoot)
129
+ await writeFile(path.join(repoRoot, "app.txt"), "changed\n", "utf8")
130
+
131
+ const store = new DiffStore(repoRoot)
132
+ await store.initialize()
133
+ await store.refreshSnapshot("project-1", repoRoot)
134
+
135
+ const result = await store.commitFiles({
136
+ projectId: "project-1",
137
+ projectPath: repoRoot,
138
+ paths: ["app.txt"],
139
+ summary: "Publish branch",
140
+ mode: "commit_and_push",
141
+ })
142
+
143
+ expect(result).toMatchObject({
144
+ ok: true,
145
+ mode: "commit_and_push",
146
+ pushed: true,
147
+ })
148
+ expect((await run(["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], repoRoot)).trim()).toBe("origin/feature/publish-me")
149
+ })
150
+
111
151
  test("detects renamed files", async () => {
112
152
  const repoRoot = await createRepo()
113
153
  tempDirs.push(repoRoot)
@@ -118,9 +158,9 @@ describe("DiffStore", () => {
118
158
 
119
159
  const store = new DiffStore(repoRoot)
120
160
  await store.initialize()
121
- await store.refreshSnapshot("chat-1", repoRoot)
161
+ await store.refreshSnapshot("project-1", repoRoot)
122
162
 
123
- const snapshot = store.getSnapshot("chat-1")
163
+ const snapshot = store.getProjectSnapshot("project-1")
124
164
  expect(snapshot.status).toBe("ready")
125
165
  expect(snapshot.files).toHaveLength(1)
126
166
  expect(snapshot.files[0]?.path).toBe("after.txt")
@@ -138,9 +178,9 @@ describe("DiffStore", () => {
138
178
 
139
179
  const store = new DiffStore(repoRoot)
140
180
  await store.initialize()
141
- await store.refreshSnapshot("chat-1", repoRoot)
181
+ await store.refreshSnapshot("project-1", repoRoot)
142
182
 
143
- const snapshot = store.getSnapshot("chat-1")
183
+ const snapshot = store.getProjectSnapshot("project-1")
144
184
  expect(snapshot.files).toHaveLength(1)
145
185
  expect(snapshot.files[0]).toMatchObject({
146
186
  path: "scratch.log",
@@ -159,15 +199,15 @@ describe("DiffStore", () => {
159
199
 
160
200
  const store = new DiffStore(repoRoot)
161
201
  await store.initialize()
162
- await store.refreshSnapshot("chat-1", repoRoot)
202
+ await store.refreshSnapshot("project-1", repoRoot)
163
203
  await store.discardFile({
164
- chatId: "chat-1",
204
+ projectId: "project-1",
165
205
  projectPath: repoRoot,
166
206
  path: "app.txt",
167
207
  })
168
208
 
169
209
  expect(await readFile(path.join(repoRoot, "app.txt"), "utf8")).toBe("base\n")
170
- expect(store.getSnapshot("chat-1").files).toHaveLength(0)
210
+ expect(store.getProjectSnapshot("project-1").files).toHaveLength(0)
171
211
  })
172
212
 
173
213
  test("discardFile deletes an untracked file", async () => {
@@ -180,15 +220,15 @@ describe("DiffStore", () => {
180
220
 
181
221
  const store = new DiffStore(repoRoot)
182
222
  await store.initialize()
183
- await store.refreshSnapshot("chat-1", repoRoot)
223
+ await store.refreshSnapshot("project-1", repoRoot)
184
224
  await store.discardFile({
185
- chatId: "chat-1",
225
+ projectId: "project-1",
186
226
  projectPath: repoRoot,
187
227
  path: "scratch.log",
188
228
  })
189
229
 
190
230
  expect(await Bun.file(path.join(repoRoot, "scratch.log")).exists()).toBe(false)
191
- expect(store.getSnapshot("chat-1").files).toHaveLength(0)
231
+ expect(store.getProjectSnapshot("project-1").files).toHaveLength(0)
192
232
  })
193
233
 
194
234
  test("discardFile reverts a renamed file", async () => {
@@ -201,16 +241,16 @@ describe("DiffStore", () => {
201
241
 
202
242
  const store = new DiffStore(repoRoot)
203
243
  await store.initialize()
204
- await store.refreshSnapshot("chat-1", repoRoot)
244
+ await store.refreshSnapshot("project-1", repoRoot)
205
245
  await store.discardFile({
206
- chatId: "chat-1",
246
+ projectId: "project-1",
207
247
  projectPath: repoRoot,
208
248
  path: "after.txt",
209
249
  })
210
250
 
211
251
  expect(await Bun.file(path.join(repoRoot, "before.txt")).exists()).toBe(true)
212
252
  expect(await Bun.file(path.join(repoRoot, "after.txt")).exists()).toBe(false)
213
- expect(store.getSnapshot("chat-1").files).toHaveLength(0)
253
+ expect(store.getProjectSnapshot("project-1").files).toHaveLength(0)
214
254
  })
215
255
 
216
256
  test("ignoreFile appends a .gitignore entry once", async () => {
@@ -223,9 +263,9 @@ describe("DiffStore", () => {
223
263
 
224
264
  const store = new DiffStore(repoRoot)
225
265
  await store.initialize()
226
- await store.refreshSnapshot("chat-1", repoRoot)
266
+ await store.refreshSnapshot("project-1", repoRoot)
227
267
  await store.ignoreFile({
228
- chatId: "chat-1",
268
+ projectId: "project-1",
229
269
  projectPath: repoRoot,
230
270
  path: "scratch.log",
231
271
  })
@@ -256,9 +296,9 @@ describe("DiffStore", () => {
256
296
 
257
297
  const store = new DiffStore(repoRoot)
258
298
  await store.initialize()
259
- await store.refreshSnapshot("chat-1", repoRoot)
299
+ await store.refreshSnapshot("project-1", repoRoot)
260
300
 
261
- const snapshot = store.getSnapshot("chat-1")
301
+ const snapshot = store.getProjectSnapshot("project-1")
262
302
  expect(snapshot.branchHistory?.entries).toHaveLength(1)
263
303
  expect(snapshot.branchHistory?.entries[0]).toMatchObject({
264
304
  summary: "Initial commit",
@@ -278,10 +318,10 @@ describe("DiffStore", () => {
278
318
 
279
319
  const store = new DiffStore(repoRoot)
280
320
  await store.initialize()
281
- await store.refreshSnapshot("chat-1", repoRoot)
321
+ await store.refreshSnapshot("project-1", repoRoot)
282
322
 
283
323
  await expect(store.ignoreFile({
284
- chatId: "chat-1",
324
+ projectId: "project-1",
285
325
  projectPath: repoRoot,
286
326
  path: "app.txt",
287
327
  })).rejects.toThrow("Only untracked files can be ignored from the diff viewer")
@@ -414,7 +454,7 @@ describe("DiffStore", () => {
414
454
  const store = new DiffStore(repoRoot)
415
455
  await store.initialize()
416
456
  const result = await store.checkoutBranch({
417
- chatId: "chat-1",
457
+ projectId: "project-1",
418
458
  projectPath: repoRoot,
419
459
  branch: { kind: "remote", name: "feature/remote", remoteRef: "origin/feature/remote" },
420
460
  })
@@ -436,7 +476,7 @@ describe("DiffStore", () => {
436
476
  const store = new DiffStore(repoRoot)
437
477
  await store.initialize()
438
478
  const result = await store.checkoutBranch({
439
- chatId: "chat-1",
479
+ projectId: "project-1",
440
480
  projectPath: repoRoot,
441
481
  branch: { kind: "local", name: "feature/other" },
442
482
  bringChanges: false,
@@ -459,7 +499,7 @@ describe("DiffStore", () => {
459
499
  const store = new DiffStore(repoRoot)
460
500
  await store.initialize()
461
501
  const result = await store.createBranch({
462
- chatId: "chat-1",
502
+ projectId: "project-1",
463
503
  projectPath: repoRoot,
464
504
  name: "feature/new",
465
505
  baseBranchName: "feature/base",
@@ -468,4 +508,106 @@ describe("DiffStore", () => {
468
508
  expect(result.ok).toBe(true)
469
509
  expect((await run(["git", "branch", "--show-current"], repoRoot)).trim()).toBe("feature/new")
470
510
  })
511
+
512
+ test("previewMergeBranch reports up-to-date and mergeable states", async () => {
513
+ const repoRoot = await createRepo()
514
+ tempDirs.push(repoRoot)
515
+ await writeFile(path.join(repoRoot, "app.txt"), "base\n", "utf8")
516
+ await run(["git", "add", "."], repoRoot)
517
+ await run(["git", "commit", "-m", "init"], repoRoot)
518
+ await run(["git", "switch", "-c", "feature/preview"], repoRoot)
519
+
520
+ const store = new DiffStore(repoRoot)
521
+ await store.initialize()
522
+
523
+ const upToDatePreview = await store.previewMergeBranch({
524
+ projectPath: repoRoot,
525
+ branch: { kind: "local", name: "main" },
526
+ })
527
+
528
+ expect(upToDatePreview.status).toBe("up_to_date")
529
+ expect(upToDatePreview.commitCount).toBe(0)
530
+
531
+ await writeFile(path.join(repoRoot, "app.txt"), "feature\n", "utf8")
532
+ await run(["git", "commit", "-am", "feature"], repoRoot)
533
+ await run(["git", "switch", "main"], repoRoot)
534
+
535
+ const mergeablePreview = await store.previewMergeBranch({
536
+ projectPath: repoRoot,
537
+ branch: { kind: "local", name: "feature/preview" },
538
+ })
539
+
540
+ expect(mergeablePreview.status).toBe("mergeable")
541
+ expect(mergeablePreview.commitCount).toBe(1)
542
+ expect(mergeablePreview.hasConflicts).toBe(false)
543
+ })
544
+
545
+ test("previewMergeBranch detects likely conflicts", async () => {
546
+ const repoRoot = await createRepo()
547
+ tempDirs.push(repoRoot)
548
+ await writeFile(path.join(repoRoot, "conflict.txt"), "base\n", "utf8")
549
+ await run(["git", "add", "."], repoRoot)
550
+ await run(["git", "commit", "-m", "init"], repoRoot)
551
+ await run(["git", "switch", "-c", "feature/conflict"], repoRoot)
552
+ await writeFile(path.join(repoRoot, "conflict.txt"), "feature\n", "utf8")
553
+ await run(["git", "commit", "-am", "feature"], repoRoot)
554
+ await run(["git", "switch", "main"], repoRoot)
555
+ await writeFile(path.join(repoRoot, "conflict.txt"), "main\n", "utf8")
556
+ await run(["git", "commit", "-am", "main"], repoRoot)
557
+
558
+ const store = new DiffStore(repoRoot)
559
+ await store.initialize()
560
+
561
+ const preview = await store.previewMergeBranch({
562
+ projectPath: repoRoot,
563
+ branch: { kind: "local", name: "feature/conflict" },
564
+ })
565
+
566
+ expect(preview.status).toBe("conflicts")
567
+ expect(preview.hasConflicts).toBe(true)
568
+ expect(preview.commitCount).toBe(1)
569
+ })
570
+
571
+ test("mergeBranch blocks dirty worktrees and merges clean branches", async () => {
572
+ const repoRoot = await createRepo()
573
+ tempDirs.push(repoRoot)
574
+ await writeFile(path.join(repoRoot, "app.txt"), "base\n", "utf8")
575
+ await run(["git", "add", "."], repoRoot)
576
+ await run(["git", "commit", "-m", "init"], repoRoot)
577
+ await run(["git", "switch", "-c", "feature/merge"], repoRoot)
578
+ await writeFile(path.join(repoRoot, "app.txt"), "feature\n", "utf8")
579
+ await run(["git", "commit", "-am", "feature"], repoRoot)
580
+ await run(["git", "switch", "main"], repoRoot)
581
+ await writeFile(path.join(repoRoot, "scratch.txt"), "dirty\n", "utf8")
582
+
583
+ const store = new DiffStore(repoRoot)
584
+ await store.initialize()
585
+
586
+ const blockedResult = await store.mergeBranch({
587
+ projectId: "project-1",
588
+ projectPath: repoRoot,
589
+ branch: { kind: "local", name: "feature/merge" },
590
+ })
591
+
592
+ expect(blockedResult).toMatchObject({
593
+ ok: false,
594
+ title: "Merge blocked",
595
+ snapshotChanged: false,
596
+ })
597
+
598
+ await rm(path.join(repoRoot, "scratch.txt"))
599
+
600
+ const mergeResult = await store.mergeBranch({
601
+ projectId: "project-1",
602
+ projectPath: repoRoot,
603
+ branch: { kind: "local", name: "feature/merge" },
604
+ })
605
+
606
+ expect(mergeResult).toMatchObject({
607
+ ok: true,
608
+ snapshotChanged: true,
609
+ })
610
+ expect((await run(["git", "branch", "--show-current"], repoRoot)).trim()).toBe("main")
611
+ expect((await run(["git", "log", "--format=%s", "-1"], repoRoot)).trim()).toBe("feature")
612
+ })
471
613
  })