diff --git a/packages/web/package.json b/packages/web/package.json index 6d9195bb..d8d3100c 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -6,7 +6,8 @@ "dev": "concurrently -k \"next dev\" \"node scripts/terminal-ws.mjs\"", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "test": "vitest run" }, "dependencies": { "@effect-template/lib": "workspace:*", @@ -29,6 +30,7 @@ "eslint": "^9", "eslint-config-next": "16.1.6", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.0.17" } } diff --git a/packages/web/src/app/page.tsx b/packages/web/src/app/page.tsx index 3c6363c7..d36aa76a 100644 --- a/packages/web/src/app/page.tsx +++ b/packages/web/src/app/page.tsx @@ -533,9 +533,13 @@ export default function Home() { useEffect(() => () => disposeTerminal(), [disposeTerminal]) - const connectedLabel = activeProject?.displayName ?? "none" + const activeTerminalSession = terminalSessions.find((session) => session.id === activeSessionId) + const connectedContainerLabel = terminalStatus === "detached" + ? "none" + : activeTerminalSession?.containerName ?? activeProject?.containerName ?? "unknown" const statusLabel = activeProject?.statusLabel ?? "unknown" const sshLabel = activeProject?.ssh ?? "-" + const containerLabel = activeProject?.containerName ?? "-" const repoLabel = activeProject?.displayName ?? "-" const refLabel = activeProject?.repoRef ?? "-" const recreateStatus = activeProject?.recreateStatus @@ -558,7 +562,7 @@ export default function Home() {
docker-git
- Connected · {connectedLabel} + Connected SSH · {connectedContainerLabel}
@@ -603,6 +607,7 @@ export default function Home() {
SSH: {sshLabel} + Container: {containerLabel} Repo: {repoLabel} Ref: {refLabel} Status: {statusLabel} @@ -685,43 +690,46 @@ export default function Home() { {showDetails ? "No active terminals" : "None"}
) : ( - terminalSessions.map((session) => ( -
handleSessionSelect(session)} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault() - handleSessionSelect(session) - } - }} - > -
- {session.displayName} -
- - {session.status} - - + terminalSessions.map((session) => { + const sessionContainer = session.containerName ?? session.projectId + return ( +
handleSessionSelect(session)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault() + handleSessionSelect(session) + } + }} + > +
+ {session.displayName} +
+ + {session.status} + + +
+ + {session.source} · {session.mode} · {sessionContainer} +
- - {session.source} · {session.mode} · {session.projectId} - -
- )) + ) + }) )}
diff --git a/packages/web/src/lib/api-schema.ts b/packages/web/src/lib/api-schema.ts index d93cfe72..11cb10f8 100644 --- a/packages/web/src/lib/api-schema.ts +++ b/packages/web/src/lib/api-schema.ts @@ -108,6 +108,7 @@ const TerminalSessionSchema = Schema.Struct({ id: Schema.String, projectId: Schema.String, displayName: Schema.String, + containerName: Schema.optional(Schema.String), mode: TerminalSessionModeSchema, source: Schema.String, status: TerminalSessionStatusSchema, diff --git a/packages/web/src/lib/api-types.ts b/packages/web/src/lib/api-types.ts index 96916160..45712e15 100644 --- a/packages/web/src/lib/api-types.ts +++ b/packages/web/src/lib/api-types.ts @@ -65,6 +65,7 @@ export type TerminalSession = { readonly id: string readonly projectId: string readonly displayName: string + readonly containerName?: string readonly mode: TerminalSessionMode readonly source: string readonly status: TerminalSessionStatus diff --git a/packages/web/src/server/terminal-ws.ts b/packages/web/src/server/terminal-ws.ts index d7f280b7..a9d273ea 100644 --- a/packages/web/src/server/terminal-ws.ts +++ b/packages/web/src/server/terminal-ws.ts @@ -23,6 +23,7 @@ type TerminalSessionRegistry = { readonly id: string readonly projectId: string readonly displayName: string + readonly containerName?: string readonly mode: TerminalSessionMode readonly source: string readonly status: TerminalSessionStatus @@ -181,8 +182,15 @@ export const attachTerminalWs = (wss: WebSocketServer) => { const privateKey = fs.readFileSync(target.identityPath) client.on("ready", () => { - updateSession(sessionId, { status: "connected", displayName: details.displayName }) - sendMessage(socket, { type: "info", data: `[docker-git] attached to ${details.displayName}` }) + updateSession(sessionId, { + status: "connected", + displayName: details.displayName, + containerName: details.containerName + }) + sendMessage(socket, { + type: "info", + data: `[docker-git] attached to ${details.displayName} (${details.containerName})` + }) client.shell( { diff --git a/packages/web/tests/api/terminal-sessions-route.test.ts b/packages/web/tests/api/terminal-sessions-route.test.ts new file mode 100644 index 00000000..2f4d6e6b --- /dev/null +++ b/packages/web/tests/api/terminal-sessions-route.test.ts @@ -0,0 +1,69 @@ +import fs from "node:fs" + +import { afterEach, beforeEach, describe, expect, it } from "vitest" + +import { GET } from "../../src/app/api/terminal-sessions/route" + +const sessionsFile = "/tmp/docker-git-terminal-sessions.json" + +let previousSessionsFileContent: string | null = null + +beforeEach(() => { + previousSessionsFileContent = fs.existsSync(sessionsFile) + ? fs.readFileSync(sessionsFile, "utf8") + : null +}) + +afterEach(() => { + if (previousSessionsFileContent === null) { + if (fs.existsSync(sessionsFile)) { + fs.unlinkSync(sessionsFile) + } + return + } + fs.writeFileSync(sessionsFile, previousSessionsFileContent, "utf8") +}) + +describe("GET /api/terminal-sessions", () => { + it("returns sessions with containerName", async () => { + fs.writeFileSync( + sessionsFile, + JSON.stringify({ + sessions: [ + { + id: "session-1", + projectId: "/tmp/project", + displayName: "org/repo", + containerName: "dg-repo-issue-47", + mode: "default", + source: "web", + status: "connected", + connectedAt: "2026-02-16T15:00:00.000Z", + updatedAt: "2026-02-16T15:00:01.000Z" + } + ] + }), + "utf8" + ) + + const response = GET() + const body = await response.json() + const sessions = Reflect.get(body as object, "sessions") + expect(Array.isArray(sessions)).toBe(true) + const first = Array.isArray(sessions) ? sessions[0] : null + expect(first).toMatchObject({ + id: "session-1", + containerName: "dg-repo-issue-47", + status: "connected" + }) + }) + + it("returns an empty list when sessions file is missing", async () => { + if (fs.existsSync(sessionsFile)) { + fs.unlinkSync(sessionsFile) + } + const response = GET() + const body = await response.json() + expect(body).toEqual({ sessions: [] }) + }) +}) diff --git a/packages/web/tests/lib/api-schema-terminal-sessions.test.ts b/packages/web/tests/lib/api-schema-terminal-sessions.test.ts new file mode 100644 index 00000000..8a9f4832 --- /dev/null +++ b/packages/web/tests/lib/api-schema-terminal-sessions.test.ts @@ -0,0 +1,58 @@ +import { Either } from "effect" +import * as Schema from "effect/Schema" +import { describe, expect, it } from "vitest" + +import { ApiSchema } from "../../src/lib/api-schema" + +const decodeTerminalSessions = Schema.decodeUnknownEither(ApiSchema.TerminalSessions) + +describe("ApiSchema.TerminalSessions", () => { + it("decodes sessions with containerName", () => { + const payload = { + sessions: [ + { + id: "session-1", + projectId: "/tmp/project", + displayName: "org/repo", + containerName: "dg-repo-issue-47", + mode: "default", + source: "web", + status: "connected", + connectedAt: "2026-02-16T15:00:00.000Z", + updatedAt: "2026-02-16T15:00:01.000Z" + } + ] + } + + const decoded = decodeTerminalSessions(payload) + expect(Either.isRight(decoded)).toBe(true) + if (Either.isLeft(decoded)) { + return + } + expect(decoded.right.sessions[0]?.containerName).toBe("dg-repo-issue-47") + }) + + it("keeps backward compatibility when containerName is absent", () => { + const payload = { + sessions: [ + { + id: "session-legacy", + projectId: "/tmp/project-legacy", + displayName: "org/repo", + mode: "default", + source: "web", + status: "connected", + connectedAt: "2026-02-16T15:00:00.000Z", + updatedAt: "2026-02-16T15:00:01.000Z" + } + ] + } + + const decoded = decodeTerminalSessions(payload) + expect(Either.isRight(decoded)).toBe(true) + if (Either.isLeft(decoded)) { + return + } + expect(decoded.right.sessions[0]?.containerName).toBeUndefined() + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69e9e1ac..55ac4bdf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -431,6 +431,9 @@ importers: typescript: specifier: ^5 version: 5.9.3 + vitest: + specifier: ^4.0.17 + version: 4.0.17(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2) packages: @@ -6114,6 +6117,14 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2) + '@vitest/mocker@4.0.17(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 4.0.17 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2) + '@vitest/mocker@4.0.17(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.0.17 @@ -9116,6 +9127,21 @@ snapshots: - supports-color - typescript + vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2): + dependencies: + esbuild: 0.27.2 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.53.3 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 20.19.31 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.2 + yaml: 2.8.2 + vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2): dependencies: esbuild: 0.27.2 @@ -9172,6 +9198,43 @@ snapshots: - tsx - yaml + vitest@4.0.17(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2): + dependencies: + '@vitest/expect': 4.0.17 + '@vitest/mocker': 4.0.17(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2)) + '@vitest/pretty-format': 4.0.17 + '@vitest/runner': 4.0.17 + '@vitest/snapshot': 4.0.17 + '@vitest/spy': 4.0.17 + '@vitest/utils': 4.0.17 + es-module-lexer: 1.7.0 + expect-type: 1.2.2 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.31 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + vitest@4.0.17(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.17