kanna-code 0.40.1 → 0.41.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.
- package/dist/client/assets/index-BfH91BNU.js +2638 -0
- package/dist/client/assets/index-QcIznlNf.css +32 -0
- package/dist/client/index.html +2 -2
- package/dist/export-viewer/assets/index-CGQNrnEQ.css +1 -0
- package/dist/export-viewer/index.html +2 -2
- package/package.json +1 -1
- package/src/server/local-http-servers.test.ts +57 -0
- package/src/server/local-http-servers.ts +279 -0
- package/src/server/project-quick-actions.test.ts +35 -0
- package/src/server/project-quick-actions.ts +67 -0
- package/src/server/terminal-manager.ts +12 -0
- package/src/server/ws-router.ts +34 -0
- package/src/shared/protocol.ts +20 -0
- package/dist/client/assets/index-BeXcQaWC.js +0 -2618
- package/dist/client/assets/index-BtWOksKc.css +0 -32
- package/dist/export-viewer/assets/index-B1qCJVBc.css +0 -1
- /package/dist/export-viewer/assets/{index-DRoGJUAU.js → index-Dg5TRbWQ.js} +0 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { extractHtmlTitle, filterLocalHttpServers, isDescendantPid, isPathWithin, parseLsofListeningEntries, parseLsofListeningPorts } from "./local-http-servers"
|
|
3
|
+
|
|
4
|
+
describe("local http servers", () => {
|
|
5
|
+
test("extracts html titles", () => {
|
|
6
|
+
expect(extractHtmlTitle("<html><head><title> Vite App </title></head></html>")).toBe("Vite App")
|
|
7
|
+
expect(extractHtmlTitle("<html></html>")).toBe("")
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
test("parses listening tcp ports from lsof output", () => {
|
|
11
|
+
const output = `
|
|
12
|
+
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
|
|
13
|
+
node 12345 jake 23u IPv4 123456 0t0 TCP *:5174 (LISTEN)
|
|
14
|
+
bun 12346 jake 23u IPv4 123457 0t0 TCP localhost:3210 (LISTEN)
|
|
15
|
+
other 12347 jake 23u IPv4 123458 0t0 TCP 127.0.0.1:8080 (LISTEN)
|
|
16
|
+
`
|
|
17
|
+
|
|
18
|
+
expect(parseLsofListeningPorts(output)).toEqual([3210, 5174, 8080])
|
|
19
|
+
expect(parseLsofListeningEntries(output)).toEqual([
|
|
20
|
+
{ port: 3210, owners: [{ command: "bun", pid: 12346 }] },
|
|
21
|
+
{ port: 5174, owners: [{ command: "node", pid: 12345 }] },
|
|
22
|
+
{ port: 8080, owners: [{ command: "other", pid: 12347 }] },
|
|
23
|
+
])
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test("detects project paths", () => {
|
|
27
|
+
expect(isPathWithin("/tmp/project", "/tmp/project")).toBe(true)
|
|
28
|
+
expect(isPathWithin("/tmp/project", "/tmp/project/app")).toBe(true)
|
|
29
|
+
expect(isPathWithin("/tmp/project", "/tmp/project-other")).toBe(false)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test("detects descendant processes", () => {
|
|
33
|
+
const parentByPid = new Map([
|
|
34
|
+
[20, 10],
|
|
35
|
+
[30, 20],
|
|
36
|
+
[40, 1],
|
|
37
|
+
])
|
|
38
|
+
|
|
39
|
+
expect(isDescendantPid(30, new Set([10]), parentByPid)).toBe(true)
|
|
40
|
+
expect(isDescendantPid(40, new Set([10]), parentByPid)).toBe(false)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test("filters internal responders without collapsing duplicate page titles", () => {
|
|
44
|
+
expect(filterLocalHttpServers([
|
|
45
|
+
{ title: "localhost:3211", address: "http://localhost:3211", port: 3211, status: 404, ownerPath: "/tmp/app", processName: "bun", sameProject: true },
|
|
46
|
+
{ title: "Superwall Agents", address: "http://localhost:5174", port: 5174, status: 200, ownerPath: "/tmp/app", processName: "node", sameProject: true },
|
|
47
|
+
{ title: "Superwall Agents", address: "http://localhost:5175", port: 5175, status: 200, ownerPath: "/tmp/app", processName: "node", sameProject: true },
|
|
48
|
+
{ title: "Superwall Agents", address: "http://localhost:8787", port: 8787, status: 200, ownerPath: "/tmp/app", processName: "workerd", sameProject: true },
|
|
49
|
+
{ title: "Welcome to nginx!", address: "http://localhost:8080", port: 8080, status: 200, ownerPath: "/opt/homebrew", processName: "nginx", sameProject: false },
|
|
50
|
+
{ title: "wterm-demo", address: "http://localhost:5003", port: 5003, status: 200, ownerPath: "/tmp/wterm", processName: "node", sameProject: false },
|
|
51
|
+
])).toEqual([
|
|
52
|
+
{ title: "Superwall Agents", address: "http://localhost:5174", port: 5174, status: 200, ownerPath: "/tmp/app", processName: "node", sameProject: true },
|
|
53
|
+
{ title: "Superwall Agents", address: "http://localhost:5175", port: 5175, status: 200, ownerPath: "/tmp/app", processName: "node", sameProject: true },
|
|
54
|
+
{ title: "wterm-demo", address: "http://localhost:5003", port: 5003, status: 200, ownerPath: "/tmp/wterm", processName: "node", sameProject: false },
|
|
55
|
+
])
|
|
56
|
+
})
|
|
57
|
+
})
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { execFile } from "node:child_process"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { promisify } from "node:util"
|
|
4
|
+
|
|
5
|
+
const execFileAsync = promisify(execFile)
|
|
6
|
+
const LOCAL_HTTP_SCAN_TIMEOUT_MS = 450
|
|
7
|
+
const LOCAL_HTTP_CACHE_TTL_MS = 30_000
|
|
8
|
+
|
|
9
|
+
interface LocalHttpServerCacheEntry {
|
|
10
|
+
signature: string
|
|
11
|
+
scannedAt: number
|
|
12
|
+
results: LocalHttpServerInfo[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const localHttpServerCache = new Map<string, LocalHttpServerCacheEntry>()
|
|
16
|
+
|
|
17
|
+
export interface LocalHttpServerInfo {
|
|
18
|
+
title: string
|
|
19
|
+
address: string
|
|
20
|
+
port: number
|
|
21
|
+
status: number
|
|
22
|
+
ownerPath?: string
|
|
23
|
+
processName?: string
|
|
24
|
+
sameProject?: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface ListeningPortOwner {
|
|
28
|
+
command: string
|
|
29
|
+
pid: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ListeningPortEntry {
|
|
33
|
+
port: number
|
|
34
|
+
owners: ListeningPortOwner[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function extractHtmlTitle(html: string) {
|
|
38
|
+
const match = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)
|
|
39
|
+
if (!match) return ""
|
|
40
|
+
return match[1]
|
|
41
|
+
.replace(/<[^>]+>/g, "")
|
|
42
|
+
.replace(/\s+/g, " ")
|
|
43
|
+
.trim()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function parseLsofListeningEntries(output: string) {
|
|
47
|
+
const entriesByPort = new Map<number, ListeningPortEntry>()
|
|
48
|
+
|
|
49
|
+
for (const line of output.split("\n").slice(1)) {
|
|
50
|
+
const match = line.match(/^(\S+)\s+(\d+).*TCP\s+(?:.+?):(\d+)\s+\(LISTEN\)/)
|
|
51
|
+
if (!match) continue
|
|
52
|
+
|
|
53
|
+
const command = match[1]
|
|
54
|
+
const pid = Number(match[2])
|
|
55
|
+
const port = Number(match[3])
|
|
56
|
+
if (!Number.isInteger(pid) || !Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const entry = entriesByPort.get(port) ?? { port, owners: [] }
|
|
61
|
+
if (!entry.owners.some((owner) => owner.pid === pid)) {
|
|
62
|
+
entry.owners.push({ command, pid })
|
|
63
|
+
}
|
|
64
|
+
entriesByPort.set(port, entry)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return [...entriesByPort.values()].sort((a, b) => a.port - b.port)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function parseLsofListeningPorts(output: string) {
|
|
71
|
+
return parseLsofListeningEntries(output).map((entry) => entry.port)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function isPathWithin(parentPath: string | undefined, childPath: string | undefined) {
|
|
75
|
+
if (!parentPath || !childPath) return false
|
|
76
|
+
const normalizedParent = path.resolve(parentPath)
|
|
77
|
+
const normalizedChild = path.resolve(childPath)
|
|
78
|
+
return normalizedChild === normalizedParent || normalizedChild.startsWith(`${normalizedParent}${path.sep}`)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function isDescendantPid(pid: number, rootPids: Set<number>, parentByPid: Map<number, number>) {
|
|
82
|
+
let current: number | undefined = pid
|
|
83
|
+
const visited = new Set<number>()
|
|
84
|
+
|
|
85
|
+
while (current !== undefined && current > 0 && !visited.has(current)) {
|
|
86
|
+
if (rootPids.has(current)) return true
|
|
87
|
+
visited.add(current)
|
|
88
|
+
current = parentByPid.get(current)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return false
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isLikelyInternalResponder(server: LocalHttpServerInfo) {
|
|
95
|
+
if (server.status < 200 || server.status >= 400) return true
|
|
96
|
+
if (/^(localhost:\d+|502 Bad Gateway|Welcome to nginx!)$/i.test(server.title)) return true
|
|
97
|
+
if (/^(nginx|cloudflar|workerd|agent-bro|Google|Cursor|ControlCe|figma_age|Superhuma|Spotify|redis|postgres|mysqld)/i.test(server.processName ?? "")) {
|
|
98
|
+
return true
|
|
99
|
+
}
|
|
100
|
+
return false
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function filterLocalHttpServers(servers: LocalHttpServerInfo[]) {
|
|
104
|
+
return servers.filter((server) => !isLikelyInternalResponder(server))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function listListeningTcpEntries() {
|
|
108
|
+
try {
|
|
109
|
+
const { stdout } = await execFileAsync("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN"], {
|
|
110
|
+
timeout: 1_500,
|
|
111
|
+
maxBuffer: 1024 * 1024,
|
|
112
|
+
})
|
|
113
|
+
return parseLsofListeningEntries(stdout)
|
|
114
|
+
} catch {
|
|
115
|
+
return []
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function getListeningEntriesSignature(entries: ListeningPortEntry) {
|
|
120
|
+
return `${entries.port}:${entries.owners.map((owner) => `${owner.command}:${owner.pid}`).join(",")}`
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function getListeningEntryListSignature(entries: ListeningPortEntry[]) {
|
|
124
|
+
return entries.map(getListeningEntriesSignature).join("|")
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function findListeningTcpEntry(port: number) {
|
|
128
|
+
const entries = await listListeningTcpEntries()
|
|
129
|
+
return entries.find((entry) => entry.port === port)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function killLocalHttpServer(port: number) {
|
|
133
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
134
|
+
throw new Error("Port is invalid.")
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const entry = await findListeningTcpEntry(port)
|
|
138
|
+
const owner = entry?.owners[0]
|
|
139
|
+
if (!owner) {
|
|
140
|
+
throw new Error(`No listening process found on port ${port}.`)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
process.kill(owner.pid, "SIGTERM")
|
|
145
|
+
} catch (error) {
|
|
146
|
+
throw new Error(error instanceof Error ? error.message : `Unable to kill process on port ${port}.`)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
ok: true,
|
|
151
|
+
port,
|
|
152
|
+
processName: owner.command,
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function readProcessCwd(pid: number) {
|
|
157
|
+
try {
|
|
158
|
+
const { stdout } = await execFileAsync("lsof", ["-p", String(pid), "-a", "-d", "cwd", "-Fn"], {
|
|
159
|
+
timeout: 500,
|
|
160
|
+
maxBuffer: 128 * 1024,
|
|
161
|
+
})
|
|
162
|
+
return stdout.split("\n").find((line) => line.startsWith("n"))?.slice(1)
|
|
163
|
+
} catch {
|
|
164
|
+
return undefined
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function readParentProcessMap() {
|
|
169
|
+
const parentByPid = new Map<number, number>()
|
|
170
|
+
try {
|
|
171
|
+
const { stdout } = await execFileAsync("ps", ["-axo", "pid=", "-o", "ppid="], {
|
|
172
|
+
timeout: 500,
|
|
173
|
+
maxBuffer: 1024 * 1024,
|
|
174
|
+
})
|
|
175
|
+
for (const line of stdout.split("\n")) {
|
|
176
|
+
const [pidText, ppidText] = line.trim().split(/\s+/)
|
|
177
|
+
const pid = Number(pidText)
|
|
178
|
+
const ppid = Number(ppidText)
|
|
179
|
+
if (Number.isInteger(pid) && Number.isInteger(ppid)) {
|
|
180
|
+
parentByPid.set(pid, ppid)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
} catch {
|
|
184
|
+
// Process ancestry is an enrichment only; discovery still works without it.
|
|
185
|
+
}
|
|
186
|
+
return parentByPid
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function readHttpServer(
|
|
190
|
+
entry: ListeningPortEntry,
|
|
191
|
+
args: {
|
|
192
|
+
fetchImpl: typeof fetch
|
|
193
|
+
ownerPath?: string
|
|
194
|
+
processName?: string
|
|
195
|
+
projectPath?: string
|
|
196
|
+
projectTerminalRootPids: Set<number>
|
|
197
|
+
parentByPid: Map<number, number>
|
|
198
|
+
}
|
|
199
|
+
): Promise<LocalHttpServerInfo | null> {
|
|
200
|
+
const address = `http://localhost:${entry.port}`
|
|
201
|
+
const controller = new AbortController()
|
|
202
|
+
const timeout = setTimeout(() => controller.abort(), LOCAL_HTTP_SCAN_TIMEOUT_MS)
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
const response = await args.fetchImpl(address, {
|
|
206
|
+
signal: controller.signal,
|
|
207
|
+
redirect: "follow",
|
|
208
|
+
})
|
|
209
|
+
const contentType = response.headers.get("content-type") ?? ""
|
|
210
|
+
const text = await response.text().catch(() => "")
|
|
211
|
+
const title = contentType.toLowerCase().includes("text/html")
|
|
212
|
+
? extractHtmlTitle(text)
|
|
213
|
+
: ""
|
|
214
|
+
const sameProject = isPathWithin(args.projectPath, args.ownerPath)
|
|
215
|
+
|| entry.owners.some((owner) => isDescendantPid(owner.pid, args.projectTerminalRootPids, args.parentByPid))
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
title: title || `localhost:${entry.port}`,
|
|
219
|
+
address,
|
|
220
|
+
port: entry.port,
|
|
221
|
+
status: response.status,
|
|
222
|
+
ownerPath: args.ownerPath,
|
|
223
|
+
processName: args.processName,
|
|
224
|
+
sameProject,
|
|
225
|
+
}
|
|
226
|
+
} catch {
|
|
227
|
+
return null
|
|
228
|
+
} finally {
|
|
229
|
+
clearTimeout(timeout)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export async function listLocalHttpServers(options: {
|
|
234
|
+
fetchImpl?: typeof fetch
|
|
235
|
+
projectPath?: string
|
|
236
|
+
projectTerminalRootPids?: number[]
|
|
237
|
+
} = {}) {
|
|
238
|
+
const fetchImpl = options.fetchImpl ?? fetch
|
|
239
|
+
const entries = await listListeningTcpEntries()
|
|
240
|
+
const projectTerminalRootPids = new Set(options.projectTerminalRootPids ?? [])
|
|
241
|
+
const cacheKey = `${options.projectPath ?? ""}\u0000${[...projectTerminalRootPids].sort((a, b) => a - b).join(",")}`
|
|
242
|
+
const signature = getListeningEntryListSignature(entries)
|
|
243
|
+
const cached = localHttpServerCache.get(cacheKey)
|
|
244
|
+
if (cached && cached.signature === signature && Date.now() - cached.scannedAt < LOCAL_HTTP_CACHE_TTL_MS) {
|
|
245
|
+
return cached.results
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const parentByPid = await readParentProcessMap()
|
|
249
|
+
const ownerCwds = new Map<number, string | undefined>()
|
|
250
|
+
|
|
251
|
+
await Promise.all(entries.flatMap((entry) => entry.owners.map(async (owner) => {
|
|
252
|
+
if (ownerCwds.has(owner.pid)) return
|
|
253
|
+
ownerCwds.set(owner.pid, await readProcessCwd(owner.pid))
|
|
254
|
+
})))
|
|
255
|
+
|
|
256
|
+
const results = await Promise.all(entries.map((entry) => {
|
|
257
|
+
const primaryOwner = entry.owners[0]
|
|
258
|
+
const ownerPath = entry.owners.map((owner) => ownerCwds.get(owner.pid)).find(Boolean)
|
|
259
|
+
return readHttpServer(entry, {
|
|
260
|
+
fetchImpl,
|
|
261
|
+
ownerPath,
|
|
262
|
+
processName: primaryOwner?.command,
|
|
263
|
+
projectPath: options.projectPath,
|
|
264
|
+
projectTerminalRootPids,
|
|
265
|
+
parentByPid,
|
|
266
|
+
})
|
|
267
|
+
}))
|
|
268
|
+
|
|
269
|
+
const filteredResults = filterLocalHttpServers(results
|
|
270
|
+
.filter((result): result is LocalHttpServerInfo => result !== null)
|
|
271
|
+
).sort((a, b) => Number(b.sameProject) - Number(a.sameProject) || a.port - b.port)
|
|
272
|
+
localHttpServerCache.set(cacheKey, {
|
|
273
|
+
signature,
|
|
274
|
+
scannedAt: Date.now(),
|
|
275
|
+
results: filteredResults,
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
return filteredResults
|
|
279
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { mkdtemp } from "node:fs/promises"
|
|
2
|
+
import { tmpdir } from "node:os"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
import { describe, expect, test } from "bun:test"
|
|
5
|
+
import { normalizeQuickActions, readProjectQuickActions, writeProjectQuickActions } from "./project-quick-actions"
|
|
6
|
+
|
|
7
|
+
describe("project quick actions", () => {
|
|
8
|
+
test("normalizes persisted quick actions", () => {
|
|
9
|
+
expect(normalizeQuickActions({
|
|
10
|
+
quickActions: [
|
|
11
|
+
{ id: "dev", label: "Dev", command: "bun run dev" },
|
|
12
|
+
{ id: "dev", label: "Duplicate", command: "echo duplicate" },
|
|
13
|
+
{ id: "empty", label: "Empty", command: " " },
|
|
14
|
+
{ id: "test", command: "bun test" },
|
|
15
|
+
],
|
|
16
|
+
})).toEqual([
|
|
17
|
+
{ id: "dev", label: "Dev", command: "bun run dev" },
|
|
18
|
+
{ id: "test", label: "bun test", command: "bun test" },
|
|
19
|
+
])
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test("writes quick actions to the project .kanna directory", async () => {
|
|
23
|
+
const projectPath = await mkdtemp(path.join(tmpdir(), "kanna-project-quick-actions-"))
|
|
24
|
+
|
|
25
|
+
const written = await writeProjectQuickActions(projectPath, [
|
|
26
|
+
{ id: "dev", label: "Dev", command: "bun run dev" },
|
|
27
|
+
])
|
|
28
|
+
|
|
29
|
+
expect(written).toEqual([
|
|
30
|
+
{ id: "dev", label: "Dev", command: "bun run dev" },
|
|
31
|
+
])
|
|
32
|
+
await expect(readProjectQuickActions(projectPath)).resolves.toEqual(written)
|
|
33
|
+
await expect(Bun.file(path.join(projectPath, ".kanna", "quick-actions.json")).exists()).resolves.toBe(true)
|
|
34
|
+
})
|
|
35
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import type { ProjectQuickAction } from "../shared/protocol"
|
|
4
|
+
import { resolveLocalPath } from "./paths"
|
|
5
|
+
|
|
6
|
+
const QUICK_ACTIONS_FILE_NAME = "quick-actions.json"
|
|
7
|
+
const MAX_QUICK_ACTIONS = 50
|
|
8
|
+
const MAX_LABEL_LENGTH = 80
|
|
9
|
+
const MAX_COMMAND_LENGTH = 2_000
|
|
10
|
+
|
|
11
|
+
function getProjectQuickActionsPath(projectPath: string) {
|
|
12
|
+
return path.join(resolveLocalPath(projectPath), ".kanna", QUICK_ACTIONS_FILE_NAME)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function normalizeQuickAction(value: unknown): ProjectQuickAction | null {
|
|
16
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null
|
|
17
|
+
const record = value as Record<string, unknown>
|
|
18
|
+
const id = typeof record.id === "string" ? record.id.trim() : ""
|
|
19
|
+
const label = typeof record.label === "string" ? record.label.trim() : ""
|
|
20
|
+
const command = typeof record.command === "string" ? record.command.trim() : ""
|
|
21
|
+
if (!id || !command) return null
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
id,
|
|
25
|
+
label: (label || command).slice(0, MAX_LABEL_LENGTH),
|
|
26
|
+
command: command.slice(0, MAX_COMMAND_LENGTH),
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function normalizeQuickActions(value: unknown): ProjectQuickAction[] {
|
|
31
|
+
const rawActions = Array.isArray(value)
|
|
32
|
+
? value
|
|
33
|
+
: value && typeof value === "object" && Array.isArray((value as { quickActions?: unknown }).quickActions)
|
|
34
|
+
? (value as { quickActions: unknown[] }).quickActions
|
|
35
|
+
: []
|
|
36
|
+
const seenIds = new Set<string>()
|
|
37
|
+
const actions: ProjectQuickAction[] = []
|
|
38
|
+
|
|
39
|
+
for (const rawAction of rawActions) {
|
|
40
|
+
const action = normalizeQuickAction(rawAction)
|
|
41
|
+
if (!action || seenIds.has(action.id)) continue
|
|
42
|
+
seenIds.add(action.id)
|
|
43
|
+
actions.push(action)
|
|
44
|
+
if (actions.length >= MAX_QUICK_ACTIONS) break
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return actions
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function readProjectQuickActions(projectPath: string): Promise<ProjectQuickAction[]> {
|
|
51
|
+
const file = Bun.file(getProjectQuickActionsPath(projectPath))
|
|
52
|
+
if (!(await file.exists())) return []
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
return normalizeQuickActions(JSON.parse(await file.text()))
|
|
56
|
+
} catch {
|
|
57
|
+
return []
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function writeProjectQuickActions(projectPath: string, quickActions: ProjectQuickAction[]): Promise<ProjectQuickAction[]> {
|
|
62
|
+
const filePath = getProjectQuickActionsPath(projectPath)
|
|
63
|
+
const normalized = normalizeQuickActions(quickActions)
|
|
64
|
+
await mkdir(path.dirname(filePath), { recursive: true })
|
|
65
|
+
await Bun.write(filePath, `${JSON.stringify({ quickActions: normalized }, null, 2)}\n`)
|
|
66
|
+
return normalized
|
|
67
|
+
}
|
|
@@ -327,6 +327,18 @@ export class TerminalManager {
|
|
|
327
327
|
}
|
|
328
328
|
}
|
|
329
329
|
|
|
330
|
+
getRootPidsByCwd(cwd: string) {
|
|
331
|
+
const pids: number[] = []
|
|
332
|
+
for (const session of this.sessions.values()) {
|
|
333
|
+
if (session.cwd !== cwd || session.status !== "running") continue
|
|
334
|
+
const pid = session.process?.pid
|
|
335
|
+
if (typeof pid === "number") {
|
|
336
|
+
pids.push(pid)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return pids
|
|
340
|
+
}
|
|
341
|
+
|
|
330
342
|
private snapshotOf(session: TerminalSession): TerminalSnapshot {
|
|
331
343
|
return {
|
|
332
344
|
terminalId: session.terminalId,
|
package/src/server/ws-router.ts
CHANGED
|
@@ -14,7 +14,9 @@ import { DiffStore } from "./diff-store"
|
|
|
14
14
|
import { EventStore } from "./event-store"
|
|
15
15
|
import { openExternal } from "./external-open"
|
|
16
16
|
import { KeybindingsManager } from "./keybindings"
|
|
17
|
+
import { killLocalHttpServer, listLocalHttpServers } from "./local-http-servers"
|
|
17
18
|
import { ensureProjectDirectory, resolveLocalPath } from "./paths"
|
|
19
|
+
import { readProjectQuickActions, writeProjectQuickActions } from "./project-quick-actions"
|
|
18
20
|
import { writeStandaloneTranscriptExport } from "./standalone-export"
|
|
19
21
|
import { TerminalManager } from "./terminal-manager"
|
|
20
22
|
import type { UpdateManager } from "./update-manager"
|
|
@@ -1037,6 +1039,38 @@ export function createWsRouter({
|
|
|
1037
1039
|
send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
|
|
1038
1040
|
return
|
|
1039
1041
|
}
|
|
1042
|
+
case "browser.listLocalHttpServers": {
|
|
1043
|
+
const project = command.projectId ? store.getProject(command.projectId) : null
|
|
1044
|
+
const result = await listLocalHttpServers({
|
|
1045
|
+
projectPath: project?.localPath,
|
|
1046
|
+
projectTerminalRootPids: project ? terminals.getRootPidsByCwd(project.localPath) : [],
|
|
1047
|
+
})
|
|
1048
|
+
send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
|
|
1049
|
+
return
|
|
1050
|
+
}
|
|
1051
|
+
case "browser.killLocalHttpServer": {
|
|
1052
|
+
const result = await killLocalHttpServer(command.port)
|
|
1053
|
+
send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
|
|
1054
|
+
return
|
|
1055
|
+
}
|
|
1056
|
+
case "project.readQuickActions": {
|
|
1057
|
+
const project = store.getProject(command.projectId)
|
|
1058
|
+
if (!project) {
|
|
1059
|
+
throw new Error("Project not found")
|
|
1060
|
+
}
|
|
1061
|
+
const result = await readProjectQuickActions(project.localPath)
|
|
1062
|
+
send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
|
|
1063
|
+
return
|
|
1064
|
+
}
|
|
1065
|
+
case "project.writeQuickActions": {
|
|
1066
|
+
const project = store.getProject(command.projectId)
|
|
1067
|
+
if (!project) {
|
|
1068
|
+
throw new Error("Project not found")
|
|
1069
|
+
}
|
|
1070
|
+
const result = await writeProjectQuickActions(project.localPath, command.quickActions)
|
|
1071
|
+
send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
|
|
1072
|
+
return
|
|
1073
|
+
}
|
|
1040
1074
|
case "update.check": {
|
|
1041
1075
|
const snapshot = updateManager
|
|
1042
1076
|
? await updateManager.checkForUpdates({ force: command.force })
|
package/src/shared/protocol.ts
CHANGED
|
@@ -25,6 +25,22 @@ export interface EditorOpenSettings {
|
|
|
25
25
|
commandTemplate: string
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
export interface LocalHttpServerInfo {
|
|
29
|
+
title: string
|
|
30
|
+
address: string
|
|
31
|
+
port: number
|
|
32
|
+
status: number
|
|
33
|
+
ownerPath?: string
|
|
34
|
+
processName?: string
|
|
35
|
+
sameProject?: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ProjectQuickAction {
|
|
39
|
+
id: string
|
|
40
|
+
label: string
|
|
41
|
+
command: string
|
|
42
|
+
}
|
|
43
|
+
|
|
28
44
|
export type SubscriptionTopic =
|
|
29
45
|
| { type: "sidebar" }
|
|
30
46
|
| { type: "local-projects" }
|
|
@@ -61,6 +77,10 @@ export type ClientCommand =
|
|
|
61
77
|
| { type: "sidebar.reorderProjectGroups"; projectIds: string[] }
|
|
62
78
|
| { type: "project.readDiffPatch"; projectId: string; path: string }
|
|
63
79
|
| { type: "system.ping" }
|
|
80
|
+
| { type: "browser.listLocalHttpServers"; projectId?: string }
|
|
81
|
+
| { type: "browser.killLocalHttpServer"; port: number }
|
|
82
|
+
| { type: "project.readQuickActions"; projectId: string }
|
|
83
|
+
| { type: "project.writeQuickActions"; projectId: string; quickActions: ProjectQuickAction[] }
|
|
64
84
|
| { type: "update.check"; force?: boolean }
|
|
65
85
|
| { type: "update.install" }
|
|
66
86
|
| { type: "settings.readKeybindings" }
|