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
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test"
|
|
2
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
import { DEFAULT_KEYBINDINGS } from "../shared/types"
|
|
6
|
+
import { KeybindingsManager, normalizeKeybindings, readKeybindingsSnapshot } from "./keybindings"
|
|
7
|
+
|
|
8
|
+
let tempDirs: string[] = []
|
|
9
|
+
const TEST_FILE_PATH = "/tmp/kanna-test-keybindings.json"
|
|
10
|
+
|
|
11
|
+
afterEach(async () => {
|
|
12
|
+
await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })))
|
|
13
|
+
tempDirs = []
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
async function createTempFilePath() {
|
|
17
|
+
const dir = await mkdtemp(path.join(tmpdir(), "kanna-keybindings-"))
|
|
18
|
+
tempDirs.push(dir)
|
|
19
|
+
return path.join(dir, "keybindings.json")
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("normalizeKeybindings", () => {
|
|
23
|
+
test("falls back to defaults for invalid entries", () => {
|
|
24
|
+
const snapshot = normalizeKeybindings({
|
|
25
|
+
toggleEmbeddedTerminal: [],
|
|
26
|
+
toggleRightSidebar: "Ctrl+B",
|
|
27
|
+
}, TEST_FILE_PATH)
|
|
28
|
+
|
|
29
|
+
expect(snapshot.bindings).toEqual(DEFAULT_KEYBINDINGS)
|
|
30
|
+
expect(snapshot.warning).toContain("toggleEmbeddedTerminal")
|
|
31
|
+
expect(snapshot.warning).toContain("toggleRightSidebar")
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test("keeps valid shortcut arrays", () => {
|
|
35
|
+
const snapshot = normalizeKeybindings({
|
|
36
|
+
toggleEmbeddedTerminal: [" Cmd+K ", "Ctrl+`"],
|
|
37
|
+
toggleRightSidebar: ["Ctrl+Shift+B"],
|
|
38
|
+
openInFinder: ["Cmd+Alt+F"],
|
|
39
|
+
openInEditor: ["Cmd+Shift+O"],
|
|
40
|
+
addSplitTerminal: ["Cmd+Shift+J"],
|
|
41
|
+
}, TEST_FILE_PATH)
|
|
42
|
+
|
|
43
|
+
expect(snapshot).toEqual({
|
|
44
|
+
bindings: {
|
|
45
|
+
toggleEmbeddedTerminal: ["cmd+k", "ctrl+`"],
|
|
46
|
+
toggleRightSidebar: ["ctrl+shift+b"],
|
|
47
|
+
openInFinder: ["cmd+alt+f"],
|
|
48
|
+
openInEditor: ["cmd+shift+o"],
|
|
49
|
+
addSplitTerminal: ["cmd+shift+j"],
|
|
50
|
+
},
|
|
51
|
+
warning: null,
|
|
52
|
+
filePathDisplay: TEST_FILE_PATH,
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe("readKeybindingsSnapshot", () => {
|
|
58
|
+
test("returns defaults when the file does not exist", async () => {
|
|
59
|
+
const filePath = await createTempFilePath()
|
|
60
|
+
const snapshot = await readKeybindingsSnapshot(filePath)
|
|
61
|
+
expect(snapshot).toEqual({
|
|
62
|
+
bindings: DEFAULT_KEYBINDINGS,
|
|
63
|
+
warning: null,
|
|
64
|
+
filePathDisplay: filePath,
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test("returns a warning when the file contains invalid json", async () => {
|
|
69
|
+
const filePath = await createTempFilePath()
|
|
70
|
+
await writeFile(filePath, "{not-json", "utf8")
|
|
71
|
+
|
|
72
|
+
const snapshot = await readKeybindingsSnapshot(filePath)
|
|
73
|
+
expect(snapshot.bindings).toEqual(DEFAULT_KEYBINDINGS)
|
|
74
|
+
expect(snapshot.warning).toContain("invalid JSON")
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe("KeybindingsManager", () => {
|
|
79
|
+
test("creates the keybindings file with defaults during initialization", async () => {
|
|
80
|
+
const filePath = await createTempFilePath()
|
|
81
|
+
const manager = new KeybindingsManager(filePath)
|
|
82
|
+
|
|
83
|
+
await manager.initialize()
|
|
84
|
+
|
|
85
|
+
expect(await Bun.file(filePath).json()).toEqual(DEFAULT_KEYBINDINGS)
|
|
86
|
+
manager.dispose()
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test("writes normalized bindings to disk", async () => {
|
|
90
|
+
const filePath = await createTempFilePath()
|
|
91
|
+
const manager = new KeybindingsManager(filePath)
|
|
92
|
+
|
|
93
|
+
await manager.initialize()
|
|
94
|
+
const snapshot = await manager.write({
|
|
95
|
+
toggleEmbeddedTerminal: ["Cmd+K"],
|
|
96
|
+
toggleRightSidebar: ["Ctrl+Shift+B"],
|
|
97
|
+
openInFinder: ["Cmd+Alt+F"],
|
|
98
|
+
openInEditor: ["Cmd+Shift+O"],
|
|
99
|
+
addSplitTerminal: ["Cmd+Shift+J"],
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
expect(snapshot).toEqual({
|
|
103
|
+
bindings: {
|
|
104
|
+
toggleEmbeddedTerminal: ["cmd+k"],
|
|
105
|
+
toggleRightSidebar: ["ctrl+shift+b"],
|
|
106
|
+
openInFinder: ["cmd+alt+f"],
|
|
107
|
+
openInEditor: ["cmd+shift+o"],
|
|
108
|
+
addSplitTerminal: ["cmd+shift+j"],
|
|
109
|
+
},
|
|
110
|
+
warning: null,
|
|
111
|
+
filePathDisplay: filePath,
|
|
112
|
+
})
|
|
113
|
+
expect(JSON.parse(await Bun.file(filePath).text())).toEqual(snapshot.bindings)
|
|
114
|
+
|
|
115
|
+
manager.dispose()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test("uses the runtime profile for the default keybindings path", () => {
|
|
119
|
+
const previous = process.env.KANNA_RUNTIME_PROFILE
|
|
120
|
+
process.env.KANNA_RUNTIME_PROFILE = "dev"
|
|
121
|
+
|
|
122
|
+
const manager = new KeybindingsManager()
|
|
123
|
+
|
|
124
|
+
expect(manager.filePath).toEndWith("/.kanna-dev/keybindings.json")
|
|
125
|
+
|
|
126
|
+
if (previous === undefined) {
|
|
127
|
+
delete process.env.KANNA_RUNTIME_PROFILE
|
|
128
|
+
} else {
|
|
129
|
+
process.env.KANNA_RUNTIME_PROFILE = previous
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
})
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { watch, type FSWatcher } from "node:fs"
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises"
|
|
3
|
+
import { homedir } from "node:os"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
import { getKeybindingsFilePath, LOG_PREFIX } from "../shared/branding"
|
|
6
|
+
import { DEFAULT_KEYBINDINGS, type KeybindingAction, type KeybindingsSnapshot } from "../shared/types"
|
|
7
|
+
|
|
8
|
+
const KEYBINDING_ACTIONS = Object.keys(DEFAULT_KEYBINDINGS) as KeybindingAction[]
|
|
9
|
+
|
|
10
|
+
type KeybindingsFile = Partial<Record<KeybindingAction, unknown>>
|
|
11
|
+
|
|
12
|
+
export class KeybindingsManager {
|
|
13
|
+
readonly filePath: string
|
|
14
|
+
private watcher: FSWatcher | null = null
|
|
15
|
+
private snapshot: KeybindingsSnapshot
|
|
16
|
+
private readonly listeners = new Set<(snapshot: KeybindingsSnapshot) => void>()
|
|
17
|
+
|
|
18
|
+
constructor(filePath = getKeybindingsFilePath(homedir())) {
|
|
19
|
+
this.filePath = filePath
|
|
20
|
+
this.snapshot = createDefaultSnapshot(this.filePath)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async initialize() {
|
|
24
|
+
await mkdir(path.dirname(this.filePath), { recursive: true })
|
|
25
|
+
const file = Bun.file(this.filePath)
|
|
26
|
+
if (!(await file.exists())) {
|
|
27
|
+
await writeFile(this.filePath, `${JSON.stringify(DEFAULT_KEYBINDINGS, null, 2)}\n`, "utf8")
|
|
28
|
+
}
|
|
29
|
+
await this.reload()
|
|
30
|
+
this.startWatching()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
dispose() {
|
|
34
|
+
this.watcher?.close()
|
|
35
|
+
this.watcher = null
|
|
36
|
+
this.listeners.clear()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
getSnapshot() {
|
|
40
|
+
return this.snapshot
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
onChange(listener: (snapshot: KeybindingsSnapshot) => void) {
|
|
44
|
+
this.listeners.add(listener)
|
|
45
|
+
return () => {
|
|
46
|
+
this.listeners.delete(listener)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async reload() {
|
|
51
|
+
const nextSnapshot = await readKeybindingsSnapshot(this.filePath)
|
|
52
|
+
this.setSnapshot(nextSnapshot)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async write(bindings: Partial<Record<KeybindingAction, string[]>>) {
|
|
56
|
+
const nextSnapshot = normalizeKeybindings(bindings, this.filePath)
|
|
57
|
+
await mkdir(path.dirname(this.filePath), { recursive: true })
|
|
58
|
+
await writeFile(this.filePath, `${JSON.stringify(nextSnapshot.bindings, null, 2)}\n`, "utf8")
|
|
59
|
+
this.setSnapshot(nextSnapshot)
|
|
60
|
+
return nextSnapshot
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private setSnapshot(snapshot: KeybindingsSnapshot) {
|
|
64
|
+
this.snapshot = snapshot
|
|
65
|
+
for (const listener of this.listeners) {
|
|
66
|
+
listener(snapshot)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private startWatching() {
|
|
71
|
+
this.watcher?.close()
|
|
72
|
+
try {
|
|
73
|
+
this.watcher = watch(path.dirname(this.filePath), { persistent: false }, (_eventType, filename) => {
|
|
74
|
+
if (filename && filename !== path.basename(this.filePath)) {
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
void this.reload().catch((error: unknown) => {
|
|
78
|
+
console.warn(`${LOG_PREFIX} Failed to reload keybindings:`, error)
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
} catch (error) {
|
|
82
|
+
console.warn(`${LOG_PREFIX} Failed to watch keybindings file:`, error)
|
|
83
|
+
this.watcher = null
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function readKeybindingsSnapshot(filePath: string) {
|
|
89
|
+
try {
|
|
90
|
+
const text = await readFile(filePath, "utf8")
|
|
91
|
+
if (!text.trim()) {
|
|
92
|
+
return createDefaultSnapshot(filePath, "Keybindings file was empty. Using defaults.")
|
|
93
|
+
}
|
|
94
|
+
const parsed = JSON.parse(text) as KeybindingsFile
|
|
95
|
+
return normalizeKeybindings(parsed, filePath)
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
|
|
98
|
+
return createDefaultSnapshot(filePath)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (error instanceof SyntaxError) {
|
|
102
|
+
return createDefaultSnapshot(filePath, "Keybindings file is invalid JSON. Using defaults.")
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
throw error
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function normalizeKeybindings(value: KeybindingsFile | null | undefined, filePath = getKeybindingsFilePath(homedir())): KeybindingsSnapshot {
|
|
110
|
+
const warnings: string[] = []
|
|
111
|
+
const source = value && typeof value === "object" && !Array.isArray(value)
|
|
112
|
+
? value
|
|
113
|
+
: null
|
|
114
|
+
|
|
115
|
+
if (!source) {
|
|
116
|
+
return createDefaultSnapshot(filePath, "Keybindings file must contain a JSON object. Using defaults.")
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const bindings = {} as Record<KeybindingAction, string[]>
|
|
120
|
+
for (const action of KEYBINDING_ACTIONS) {
|
|
121
|
+
const rawValue = source[action]
|
|
122
|
+
if (!Array.isArray(rawValue)) {
|
|
123
|
+
bindings[action] = [...DEFAULT_KEYBINDINGS[action]]
|
|
124
|
+
if (rawValue !== undefined) {
|
|
125
|
+
warnings.push(`${action} must be an array of shortcut strings`)
|
|
126
|
+
}
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const normalized = rawValue
|
|
131
|
+
.filter((entry): entry is string => typeof entry === "string")
|
|
132
|
+
.map((entry) => entry.trim())
|
|
133
|
+
.map((entry) => entry.toLowerCase())
|
|
134
|
+
.filter(Boolean)
|
|
135
|
+
|
|
136
|
+
if (normalized.length === 0) {
|
|
137
|
+
bindings[action] = [...DEFAULT_KEYBINDINGS[action]]
|
|
138
|
+
if (rawValue.length > 0 || source[action] !== undefined) {
|
|
139
|
+
warnings.push(`${action} did not contain any valid shortcut strings`)
|
|
140
|
+
}
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
bindings[action] = normalized
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
bindings,
|
|
149
|
+
warning: warnings.length > 0 ? `Some keybindings were reset to defaults: ${warnings.join("; ")}` : null,
|
|
150
|
+
filePathDisplay: formatDisplayPath(filePath),
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function createDefaultSnapshot(filePath: string, warning: string | null = null): KeybindingsSnapshot {
|
|
155
|
+
return {
|
|
156
|
+
bindings: {
|
|
157
|
+
toggleEmbeddedTerminal: [...DEFAULT_KEYBINDINGS.toggleEmbeddedTerminal],
|
|
158
|
+
toggleRightSidebar: [...DEFAULT_KEYBINDINGS.toggleRightSidebar],
|
|
159
|
+
openInFinder: [...DEFAULT_KEYBINDINGS.openInFinder],
|
|
160
|
+
openInEditor: [...DEFAULT_KEYBINDINGS.openInEditor],
|
|
161
|
+
addSplitTerminal: [...DEFAULT_KEYBINDINGS.addSplitTerminal],
|
|
162
|
+
},
|
|
163
|
+
warning,
|
|
164
|
+
filePathDisplay: formatDisplayPath(filePath),
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function formatDisplayPath(filePath: string) {
|
|
169
|
+
const homePath = homedir()
|
|
170
|
+
if (filePath === homePath) return "~"
|
|
171
|
+
if (filePath.startsWith(`${homePath}${path.sep}`)) {
|
|
172
|
+
return `~${filePath.slice(homePath.length)}`
|
|
173
|
+
}
|
|
174
|
+
return filePath
|
|
175
|
+
}
|
package/src/server/server.ts
CHANGED
|
@@ -3,7 +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 {
|
|
6
|
+
import { KeybindingsManager } from "./keybindings"
|
|
7
7
|
import { getMachineDisplayName } from "./machine-name"
|
|
8
8
|
import { TerminalManager } from "./terminal-manager"
|
|
9
9
|
import { createWsRouter, type ClientState } from "./ws-router"
|
|
@@ -31,9 +31,8 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
|
|
|
31
31
|
let server: ReturnType<typeof Bun.serve<ClientState>>
|
|
32
32
|
let router: ReturnType<typeof createWsRouter>
|
|
33
33
|
const terminals = new TerminalManager()
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
})
|
|
34
|
+
const keybindings = new KeybindingsManager()
|
|
35
|
+
await keybindings.initialize()
|
|
37
36
|
const agent = new AgentCoordinator({
|
|
38
37
|
store,
|
|
39
38
|
onStateChange: () => {
|
|
@@ -44,7 +43,7 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
|
|
|
44
43
|
store,
|
|
45
44
|
agent,
|
|
46
45
|
terminals,
|
|
47
|
-
|
|
46
|
+
keybindings,
|
|
48
47
|
refreshDiscovery,
|
|
49
48
|
getDiscoveredProjects: () => discoveredProjects,
|
|
50
49
|
machineDisplayName,
|
|
@@ -106,7 +105,7 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
|
|
|
106
105
|
await agent.cancel(chatId)
|
|
107
106
|
}
|
|
108
107
|
router.dispose()
|
|
109
|
-
|
|
108
|
+
keybindings.dispose()
|
|
110
109
|
terminals.closeAll()
|
|
111
110
|
await store.compact()
|
|
112
111
|
server.stop(true)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import type { KeybindingsSnapshot } from "../shared/types"
|
|
2
3
|
import { PROTOCOL_VERSION } from "../shared/types"
|
|
3
4
|
import { createEmptyState } from "./events"
|
|
4
5
|
import { createWsRouter } from "./ws-router"
|
|
@@ -14,6 +15,18 @@ class FakeWebSocket {
|
|
|
14
15
|
}
|
|
15
16
|
}
|
|
16
17
|
|
|
18
|
+
const DEFAULT_KEYBINDINGS_SNAPSHOT: KeybindingsSnapshot = {
|
|
19
|
+
bindings: {
|
|
20
|
+
toggleEmbeddedTerminal: ["cmd+j", "ctrl+`"],
|
|
21
|
+
toggleRightSidebar: ["ctrl+b"],
|
|
22
|
+
openInFinder: ["cmd+alt+f"],
|
|
23
|
+
openInEditor: ["cmd+shift+o"],
|
|
24
|
+
addSplitTerminal: ["cmd+shift+j"],
|
|
25
|
+
},
|
|
26
|
+
warning: null,
|
|
27
|
+
filePathDisplay: "~/.kanna/keybindings.json",
|
|
28
|
+
}
|
|
29
|
+
|
|
17
30
|
describe("ws-router", () => {
|
|
18
31
|
test("acks system.ping without broadcasting snapshots", () => {
|
|
19
32
|
const router = createWsRouter({
|
|
@@ -23,9 +36,9 @@ describe("ws-router", () => {
|
|
|
23
36
|
getSnapshot: () => null,
|
|
24
37
|
onEvent: () => () => {},
|
|
25
38
|
} as never,
|
|
26
|
-
|
|
27
|
-
getSnapshot: () =>
|
|
28
|
-
|
|
39
|
+
keybindings: {
|
|
40
|
+
getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
|
|
41
|
+
onChange: () => () => {},
|
|
29
42
|
} as never,
|
|
30
43
|
refreshDiscovery: async () => [],
|
|
31
44
|
getDiscoveredProjects: () => [],
|
|
@@ -62,9 +75,9 @@ describe("ws-router", () => {
|
|
|
62
75
|
onEvent: () => () => {},
|
|
63
76
|
write: () => {},
|
|
64
77
|
} as never,
|
|
65
|
-
|
|
66
|
-
getSnapshot: () =>
|
|
67
|
-
|
|
78
|
+
keybindings: {
|
|
79
|
+
getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
|
|
80
|
+
onChange: () => () => {},
|
|
68
81
|
} as never,
|
|
69
82
|
refreshDiscovery: async () => [],
|
|
70
83
|
getDiscoveredProjects: () => [],
|
|
@@ -96,31 +109,7 @@ describe("ws-router", () => {
|
|
|
96
109
|
])
|
|
97
110
|
})
|
|
98
111
|
|
|
99
|
-
test("subscribes and unsubscribes
|
|
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
|
-
|
|
112
|
+
test("subscribes and unsubscribes chat topics", () => {
|
|
124
113
|
const router = createWsRouter({
|
|
125
114
|
store: { state: createEmptyState() } as never,
|
|
126
115
|
agent: { getActiveStatuses: () => new Map() } as never,
|
|
@@ -128,7 +117,10 @@ describe("ws-router", () => {
|
|
|
128
117
|
getSnapshot: () => null,
|
|
129
118
|
onEvent: () => () => {},
|
|
130
119
|
} as never,
|
|
131
|
-
|
|
120
|
+
keybindings: {
|
|
121
|
+
getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
|
|
122
|
+
onChange: () => () => {},
|
|
123
|
+
} as never,
|
|
132
124
|
refreshDiscovery: async () => [],
|
|
133
125
|
getDiscoveredProjects: () => [],
|
|
134
126
|
machineDisplayName: "Local Machine",
|
|
@@ -140,24 +132,18 @@ describe("ws-router", () => {
|
|
|
140
132
|
JSON.stringify({
|
|
141
133
|
v: 1,
|
|
142
134
|
type: "subscribe",
|
|
143
|
-
id: "
|
|
144
|
-
topic: { type: "
|
|
135
|
+
id: "chat-sub-1",
|
|
136
|
+
topic: { type: "chat", chatId: "chat-1" },
|
|
145
137
|
})
|
|
146
138
|
)
|
|
147
139
|
|
|
148
|
-
expect(fileTree.subscribeCalls).toEqual(["project-1"])
|
|
149
140
|
expect(ws.sent[0]).toEqual({
|
|
150
141
|
v: PROTOCOL_VERSION,
|
|
151
142
|
type: "snapshot",
|
|
152
|
-
id: "
|
|
143
|
+
id: "chat-sub-1",
|
|
153
144
|
snapshot: {
|
|
154
|
-
type: "
|
|
155
|
-
data:
|
|
156
|
-
projectId: "project-1",
|
|
157
|
-
rootPath: "/tmp/project-1",
|
|
158
|
-
pageSize: 200,
|
|
159
|
-
supportsRealtime: true,
|
|
160
|
-
},
|
|
145
|
+
type: "chat",
|
|
146
|
+
data: null,
|
|
161
147
|
},
|
|
162
148
|
})
|
|
163
149
|
|
|
@@ -165,26 +151,63 @@ describe("ws-router", () => {
|
|
|
165
151
|
ws as never,
|
|
166
152
|
JSON.stringify({
|
|
167
153
|
v: 1,
|
|
168
|
-
type: "
|
|
169
|
-
id: "
|
|
170
|
-
command: {
|
|
171
|
-
type: "file-tree.readDirectory",
|
|
172
|
-
projectId: "project-1",
|
|
173
|
-
directoryPath: "",
|
|
174
|
-
},
|
|
154
|
+
type: "unsubscribe",
|
|
155
|
+
id: "chat-sub-1",
|
|
175
156
|
})
|
|
176
157
|
)
|
|
177
158
|
|
|
178
|
-
await Promise.resolve()
|
|
179
159
|
expect(ws.sent[1]).toEqual({
|
|
180
160
|
v: PROTOCOL_VERSION,
|
|
181
161
|
type: "ack",
|
|
182
|
-
id: "
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
162
|
+
id: "chat-sub-1",
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test("subscribes to keybindings snapshots and writes keybindings through the router", async () => {
|
|
167
|
+
const initialSnapshot: KeybindingsSnapshot = DEFAULT_KEYBINDINGS_SNAPSHOT
|
|
168
|
+
const keybindings = {
|
|
169
|
+
snapshot: initialSnapshot,
|
|
170
|
+
getSnapshot() {
|
|
171
|
+
return this.snapshot
|
|
172
|
+
},
|
|
173
|
+
onChange: () => () => {},
|
|
174
|
+
async write(bindings: KeybindingsSnapshot["bindings"]) {
|
|
175
|
+
this.snapshot = { bindings, warning: null, filePathDisplay: "~/.kanna/keybindings.json" }
|
|
176
|
+
return this.snapshot
|
|
177
|
+
},
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const router = createWsRouter({
|
|
181
|
+
store: { state: createEmptyState() } as never,
|
|
182
|
+
agent: { getActiveStatuses: () => new Map() } as never,
|
|
183
|
+
terminals: {
|
|
184
|
+
getSnapshot: () => null,
|
|
185
|
+
onEvent: () => () => {},
|
|
186
|
+
} as never,
|
|
187
|
+
keybindings: keybindings as never,
|
|
188
|
+
refreshDiscovery: async () => [],
|
|
189
|
+
getDiscoveredProjects: () => [],
|
|
190
|
+
machineDisplayName: "Local Machine",
|
|
191
|
+
})
|
|
192
|
+
const ws = new FakeWebSocket()
|
|
193
|
+
|
|
194
|
+
router.handleMessage(
|
|
195
|
+
ws as never,
|
|
196
|
+
JSON.stringify({
|
|
197
|
+
v: 1,
|
|
198
|
+
type: "subscribe",
|
|
199
|
+
id: "keybindings-sub-1",
|
|
200
|
+
topic: { type: "keybindings" },
|
|
201
|
+
})
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
expect(ws.sent[0]).toEqual({
|
|
205
|
+
v: PROTOCOL_VERSION,
|
|
206
|
+
type: "snapshot",
|
|
207
|
+
id: "keybindings-sub-1",
|
|
208
|
+
snapshot: {
|
|
209
|
+
type: "keybindings",
|
|
210
|
+
data: keybindings.snapshot,
|
|
188
211
|
},
|
|
189
212
|
})
|
|
190
213
|
|
|
@@ -192,16 +215,37 @@ describe("ws-router", () => {
|
|
|
192
215
|
ws as never,
|
|
193
216
|
JSON.stringify({
|
|
194
217
|
v: 1,
|
|
195
|
-
type: "
|
|
196
|
-
id: "
|
|
218
|
+
type: "command",
|
|
219
|
+
id: "keybindings-write-1",
|
|
220
|
+
command: {
|
|
221
|
+
type: "settings.writeKeybindings",
|
|
222
|
+
bindings: {
|
|
223
|
+
toggleEmbeddedTerminal: ["cmd+k"],
|
|
224
|
+
toggleRightSidebar: ["ctrl+shift+b"],
|
|
225
|
+
openInFinder: ["cmd+shift+g"],
|
|
226
|
+
openInEditor: ["cmd+shift+p"],
|
|
227
|
+
addSplitTerminal: ["cmd+alt+j"],
|
|
228
|
+
},
|
|
229
|
+
},
|
|
197
230
|
})
|
|
198
231
|
)
|
|
199
232
|
|
|
200
|
-
|
|
201
|
-
expect(ws.sent[
|
|
233
|
+
await Promise.resolve()
|
|
234
|
+
expect(ws.sent[1]).toEqual({
|
|
202
235
|
v: PROTOCOL_VERSION,
|
|
203
236
|
type: "ack",
|
|
204
|
-
id: "
|
|
205
|
-
|
|
237
|
+
id: "keybindings-write-1",
|
|
238
|
+
result: {
|
|
239
|
+
bindings: {
|
|
240
|
+
toggleEmbeddedTerminal: ["cmd+k"],
|
|
241
|
+
toggleRightSidebar: ["ctrl+shift+b"],
|
|
242
|
+
openInFinder: ["cmd+shift+g"],
|
|
243
|
+
openInEditor: ["cmd+shift+p"],
|
|
244
|
+
addSplitTerminal: ["cmd+alt+j"],
|
|
245
|
+
},
|
|
246
|
+
warning: null,
|
|
247
|
+
filePathDisplay: "~/.kanna/keybindings.json",
|
|
248
|
+
},
|
|
249
|
+
})
|
|
206
250
|
})
|
|
207
251
|
})
|