Archived
Add workspace terminal panel
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
export { api, filesApi, gitApi, projectsApi, sessionsApi, workspacesApi } from "./api/clients";
|
||||
export { globalSessionEvents, sessionEvents } from "./api/sockets";
|
||||
export type { CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, Project, SessionActivity, SessionInfo, SessionStatus, SlashCommand, SessionUiEvent, Workspace } from "../../shared/apiTypes";
|
||||
export { api, filesApi, gitApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
|
||||
export { globalSessionEvents, sessionEvents, terminalSocket } from "./api/sockets";
|
||||
export type { CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, Project, SessionActivity, SessionInfo, SessionStatus, SlashCommand, SessionUiEvent, TerminalInfo, Workspace } from "../../shared/apiTypes";
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
parseSessionStatus,
|
||||
parseSlashCommand,
|
||||
parseStopped,
|
||||
parseTerminalInfo,
|
||||
parseWorkspace,
|
||||
} from "./parsers";
|
||||
import { gitDiffUrl, messageUrl } from "./urls";
|
||||
@@ -52,6 +53,12 @@ export const sessionsApi = {
|
||||
restore: (sessionId: string) => request(`/api/sessions/${sessionId}/restore`, parseRestored, { method: "POST" }),
|
||||
};
|
||||
|
||||
export const terminalsApi = {
|
||||
terminals: (projectId: string, workspaceId: string) => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals`, arrayOf(parseTerminalInfo)),
|
||||
startTerminal: (projectId: string, workspaceId: string, options?: { name?: string; cols?: number; rows?: number }) => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals`, parseTerminalInfo, { method: "POST", body: JSON.stringify(options ?? {}) }),
|
||||
closeTerminal: (projectId: string, workspaceId: string, terminalId: string) => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}`, parseClosed, { method: "DELETE" }),
|
||||
};
|
||||
|
||||
export const filesApi = {
|
||||
files: (cwd: string, query: string, kind?: FileSuggestion["kind"], mode?: "file" | "path") => request(`/api/files?cwd=${encodeURIComponent(cwd)}&q=${encodeURIComponent(query)}${kind !== undefined ? `&kind=${encodeURIComponent(kind)}` : ""}${mode !== undefined ? `&mode=${encodeURIComponent(mode)}` : ""}`, arrayOf(parseFileSuggestion)),
|
||||
};
|
||||
@@ -65,6 +72,7 @@ export const api = {
|
||||
...projectsApi,
|
||||
...workspacesApi,
|
||||
...sessionsApi,
|
||||
...terminalsApi,
|
||||
...filesApi,
|
||||
...gitApi,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, Project, SessionInfo, SessionStatus, SlashCommand, Workspace } from "../../../shared/apiTypes";
|
||||
import type { CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, Project, SessionInfo, SessionStatus, SlashCommand, TerminalInfo, Workspace } from "../../../shared/apiTypes";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
@@ -194,6 +194,11 @@ export function parseGitDiffResponse(value: unknown): GitDiffResponse {
|
||||
return { ...optionalField("path", optionalString(record, "path")), staged: requireBoolean(record, "staged"), hash: requireString(record, "hash"), diff: requireString(record, "diff"), truncated: requireBoolean(record, "truncated") };
|
||||
}
|
||||
|
||||
export function parseTerminalInfo(value: unknown): TerminalInfo {
|
||||
const record = requireRecord(value);
|
||||
return { id: requireString(record, "id"), cwd: requireString(record, "cwd"), name: requireString(record, "name"), createdAt: requireString(record, "createdAt"), exited: requireBoolean(record, "exited"), ...optionalField("exitCode", optionalNumber(record, "exitCode")) };
|
||||
}
|
||||
|
||||
export function parseCommandResult(value: unknown): CommandResult {
|
||||
const record = requireRecord(value);
|
||||
const type = requireString(record, "type");
|
||||
|
||||
@@ -6,6 +6,10 @@ export function globalSessionEvents(): WebSocket {
|
||||
return new WebSocket(`${webSocketBaseUrl()}/api/sessions/events`);
|
||||
}
|
||||
|
||||
export function terminalSocket(projectId: string, workspaceId: string, terminalId: string): WebSocket {
|
||||
return new WebSocket(`${webSocketBaseUrl()}/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}/socket`);
|
||||
}
|
||||
|
||||
function webSocketBaseUrl(): string {
|
||||
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${protocol}//${location.host}`;
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators.js";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { terminalSocket, terminalsApi, type TerminalInfo, type Workspace } from "../api";
|
||||
|
||||
@customElement("terminal-panel")
|
||||
export class TerminalPanel extends LitElement {
|
||||
@property({ attribute: false }) workspace: Workspace | undefined;
|
||||
@query(".terminal-host") private terminalHost?: HTMLDivElement;
|
||||
@state() private terminals: TerminalInfo[] = [];
|
||||
@state() private selectedId: string | undefined;
|
||||
@state() private loading = false;
|
||||
@state() private error: string | undefined;
|
||||
@state() private visible = false;
|
||||
|
||||
private terminal: Terminal | undefined;
|
||||
private fitAddon: FitAddon | undefined;
|
||||
private socket: WebSocket | undefined;
|
||||
private resizeObserver: ResizeObserver | undefined;
|
||||
private intersectionObserver: IntersectionObserver | undefined;
|
||||
private observedCwd: string | undefined;
|
||||
private loadedCwd: string | undefined;
|
||||
|
||||
override firstUpdated(): void {
|
||||
this.intersectionObserver = new IntersectionObserver((entries) => {
|
||||
this.visible = entries[0]?.isIntersecting === true;
|
||||
});
|
||||
this.intersectionObserver.observe(this);
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
this.intersectionObserver?.disconnect();
|
||||
this.intersectionObserver = undefined;
|
||||
this.disposeTerminalView();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
override willUpdate(): void {
|
||||
const cwd = this.workspace?.path;
|
||||
if (cwd !== this.observedCwd) {
|
||||
this.observedCwd = cwd;
|
||||
this.loadedCwd = undefined;
|
||||
this.terminals = [];
|
||||
this.selectedId = undefined;
|
||||
this.disposeTerminalView();
|
||||
}
|
||||
}
|
||||
|
||||
override updated(): void {
|
||||
this.loadVisibleWorkspaceTerminals();
|
||||
this.ensureTerminalView();
|
||||
}
|
||||
|
||||
private loadVisibleWorkspaceTerminals(): void {
|
||||
const cwd = this.workspace?.path;
|
||||
if (!this.visible || cwd === undefined || cwd === this.loadedCwd) return;
|
||||
this.loadedCwd = cwd;
|
||||
void this.loadTerminals();
|
||||
}
|
||||
|
||||
private async loadTerminals(): Promise<void> {
|
||||
this.loading = true;
|
||||
this.error = undefined;
|
||||
try {
|
||||
if (this.workspace === undefined) return;
|
||||
const terminals = await terminalsApi.terminals(this.workspace.projectId, this.workspace.id);
|
||||
this.terminals = terminals;
|
||||
this.selectedId = terminals.find((terminal) => !terminal.exited)?.id ?? terminals[0]?.id;
|
||||
if (terminals.length === 0) await this.startTerminal();
|
||||
} catch (error) {
|
||||
this.error = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async startTerminal(): Promise<void> {
|
||||
if (this.workspace === undefined) return;
|
||||
this.error = undefined;
|
||||
try {
|
||||
const terminal = await terminalsApi.startTerminal(this.workspace.projectId, this.workspace.id, { cols: 100, rows: 30 });
|
||||
this.terminals = [...this.terminals, terminal];
|
||||
this.selectTerminal(terminal.id);
|
||||
} catch (error) {
|
||||
this.error = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async closeTerminal(id: string, event: Event): Promise<void> {
|
||||
event.stopPropagation();
|
||||
try {
|
||||
if (this.workspace === undefined) return;
|
||||
await terminalsApi.closeTerminal(this.workspace.projectId, this.workspace.id, id);
|
||||
const next = this.terminals.filter((terminal) => terminal.id !== id);
|
||||
this.terminals = next;
|
||||
if (this.selectedId === id) {
|
||||
this.selectedId = next[0]?.id;
|
||||
this.disposeTerminalView();
|
||||
}
|
||||
} catch (error) {
|
||||
this.error = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
private selectTerminal(id: string): void {
|
||||
if (this.selectedId === id) return;
|
||||
this.selectedId = id;
|
||||
this.disposeTerminalView();
|
||||
}
|
||||
|
||||
private ensureTerminalView(): void {
|
||||
const workspace = this.workspace;
|
||||
if (!this.visible || this.terminal !== undefined || this.selectedId === undefined || this.terminalHost === undefined || workspace === undefined) return;
|
||||
const terminal = new Terminal({
|
||||
cursorBlink: true,
|
||||
convertEol: true,
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
|
||||
fontSize: 13,
|
||||
theme: { background: "#05070a", foreground: "#e6edf3", cursor: "#58a6ff", selectionBackground: "#264f78" },
|
||||
});
|
||||
const fitAddon = new FitAddon();
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.open(this.terminalHost);
|
||||
this.terminal = terminal;
|
||||
this.fitAddon = fitAddon;
|
||||
this.resizeObserver = new ResizeObserver(() => { this.fitAndNotify(); });
|
||||
this.resizeObserver.observe(this.terminalHost);
|
||||
terminal.onData((data) => { this.send({ type: "input", data }); });
|
||||
this.connectSocket(workspace.projectId, workspace.id, this.selectedId, terminal);
|
||||
requestAnimationFrame(() => { this.fitAndNotify(); });
|
||||
terminal.focus();
|
||||
}
|
||||
|
||||
private connectSocket(projectId: string, workspaceId: string, terminalId: string, terminal: Terminal): void {
|
||||
const socket = terminalSocket(projectId, workspaceId, terminalId);
|
||||
socket.binaryType = "arraybuffer";
|
||||
this.socket = socket;
|
||||
socket.addEventListener("open", () => { this.fitAndNotify(); });
|
||||
socket.addEventListener("message", (event) => {
|
||||
void this.handleSocketMessage(event.data, terminalId, terminal);
|
||||
});
|
||||
socket.addEventListener("close", () => {
|
||||
if (this.socket === socket) this.socket = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
private async handleSocketMessage(data: unknown, terminalId: string, terminal: Terminal): Promise<void> {
|
||||
try {
|
||||
const message = parseServerMessage(await socketDataToString(data));
|
||||
if (message.type === "output") terminal.write(message.data);
|
||||
if (message.type === "exit") {
|
||||
terminal.writeln(`\r\n[process exited${message.exitCode === undefined ? "" : ` with code ${String(message.exitCode)}`}]`);
|
||||
this.terminals = this.terminals.map((item) => item.id === terminalId ? { ...item, exited: true, ...(message.exitCode === undefined ? {} : { exitCode: message.exitCode }) } : item);
|
||||
}
|
||||
if (message.type === "error") terminal.writeln(`\r\n[terminal error: ${message.message}]`);
|
||||
} catch (error) {
|
||||
terminal.writeln(`\r\n[terminal error: ${error instanceof Error ? error.message : String(error)}]`);
|
||||
}
|
||||
}
|
||||
|
||||
private fitAndNotify(): void {
|
||||
if (this.fitAddon === undefined || this.terminal === undefined) return;
|
||||
this.fitAddon.fit();
|
||||
this.send({ type: "resize", cols: this.terminal.cols, rows: this.terminal.rows });
|
||||
}
|
||||
|
||||
private send(message: { type: "input"; data: string } | { type: "resize"; cols: number; rows: number }): void {
|
||||
if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(JSON.stringify(message));
|
||||
}
|
||||
|
||||
private disposeTerminalView(): void {
|
||||
this.resizeObserver?.disconnect();
|
||||
this.resizeObserver = undefined;
|
||||
this.socket?.close();
|
||||
this.socket = undefined;
|
||||
this.terminal?.dispose();
|
||||
this.terminal = undefined;
|
||||
this.fitAddon = undefined;
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<section class="terminal-shell">
|
||||
<div class="terminal-tabs">
|
||||
${this.terminals.map((terminal) => html`
|
||||
<button class=${this.selectedId === terminal.id ? "selected" : ""} @click=${() => { this.selectTerminal(terminal.id); }}>
|
||||
<span>${terminal.name}${terminal.exited ? " · exited" : ""}</span>
|
||||
<small @click=${(event: Event) => { void this.closeTerminal(terminal.id, event); }}>×</small>
|
||||
</button>
|
||||
`)}
|
||||
<button class="new" ?disabled=${this.workspace === undefined} @click=${() => { void this.startTerminal(); }}>+ Shell</button>
|
||||
</div>
|
||||
${this.error === undefined ? null : html`<p class="error">${this.error}</p>`}
|
||||
${this.loading ? html`<p class="muted">Loading terminals…</p>` : null}
|
||||
<div class="terminal-host"></div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
static override styles = css`
|
||||
:host { flex: 1 1 auto; min-height: 0; display: flex; }
|
||||
.terminal-shell { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; overflow: hidden; background: #05070a; }
|
||||
.terminal-tabs { flex: 0 0 auto; display: flex; gap: 6px; align-items: center; padding: 6px; border-bottom: 1px solid #21262d; background: #0d1117; overflow: auto; }
|
||||
button { display: inline-flex; align-items: center; gap: 6px; min-width: 0; max-width: 180px; border: 1px solid #30363d; border-radius: 7px; background: #161b22; color: #e6edf3; padding: 5px 7px; cursor: pointer; }
|
||||
button.selected { border-color: #58a6ff; background: #0d2847; }
|
||||
button.new { flex: 0 0 auto; color: #8b949e; }
|
||||
button span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
button small { color: #8b949e; font-size: 14px; line-height: 1; }
|
||||
button small:hover { color: #ff7b72; }
|
||||
button:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.terminal-host { flex: 1 1 auto; min-height: 0; padding: 6px; box-sizing: border-box; overflow: hidden; }
|
||||
.terminal-host .xterm { height: 100%; cursor: text; position: relative; user-select: none; }
|
||||
.terminal-host .xterm.focus, .terminal-host .xterm:focus { outline: none; }
|
||||
.terminal-host .xterm-helpers { position: absolute; top: 0; z-index: 5; }
|
||||
.terminal-host .xterm-helper-textarea { position: absolute !important; left: -9999em !important; top: 0 !important; width: 0 !important; height: 0 !important; min-width: 0 !important; min-height: 0 !important; padding: 0 !important; border: 0 !important; margin: 0 !important; opacity: 0 !important; z-index: -5 !important; white-space: nowrap !important; overflow: hidden !important; resize: none !important; outline: 0 !important; appearance: none !important; }
|
||||
.terminal-host .xterm-viewport { position: absolute; inset: 0; overflow-y: scroll; cursor: default; background-color: #05070a; }
|
||||
.terminal-host .xterm-screen { position: relative; }
|
||||
.terminal-host .xterm-screen canvas { position: absolute; left: 0; top: 0; }
|
||||
.terminal-host .xterm-char-measure-element { display: inline-block; visibility: hidden; position: absolute; top: 0; left: -9999em; line-height: normal; }
|
||||
.terminal-host .xterm-accessibility:not(.debug), .terminal-host .xterm-message { position: absolute; inset: 0; z-index: 10; color: transparent; pointer-events: none; }
|
||||
.terminal-host .xterm-accessibility-tree:not(.debug) *::selection { color: transparent; }
|
||||
.terminal-host .xterm-accessibility-tree { font-family: monospace; user-select: text; white-space: pre; }
|
||||
.terminal-host .xterm-accessibility-tree > div { transform-origin: left; width: fit-content; }
|
||||
.terminal-host .live-region { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }
|
||||
.error { flex: 0 0 auto; margin: 0; padding: 8px; color: #ff7b72; border-bottom: 1px solid #30363d; background: #161b22; }
|
||||
.muted { margin: 10px; color: #8b949e; }
|
||||
.xterm { height: 100%; }
|
||||
`;
|
||||
}
|
||||
|
||||
type ServerTerminalMessage =
|
||||
| { type: "output"; data: string }
|
||||
| { type: "exit"; exitCode?: number }
|
||||
| { type: "error"; message: string };
|
||||
|
||||
function parseServerMessage(data: string): ServerTerminalMessage {
|
||||
const value: unknown = JSON.parse(data);
|
||||
if (!isRecord(value)) return { type: "error", message: "Invalid terminal message" };
|
||||
const record = value;
|
||||
if (record["type"] === "output" && typeof record["data"] === "string") return { type: "output", data: record["data"] };
|
||||
if (record["type"] === "exit") return { type: "exit", ...(typeof record["exitCode"] === "number" ? { exitCode: record["exitCode"] } : {}) };
|
||||
if (record["type"] === "error" && typeof record["message"] === "string") return { type: "error", message: record["message"] };
|
||||
return { type: "error", message: "Invalid terminal message" };
|
||||
}
|
||||
|
||||
async function socketDataToString(data: unknown): Promise<string> {
|
||||
if (typeof data === "string") return data;
|
||||
if (data instanceof ArrayBuffer) return new TextDecoder().decode(data);
|
||||
if (data instanceof Blob) return await data.text();
|
||||
return String(data);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
@@ -27,8 +27,9 @@ export class WorkspacePanel extends LitElement {
|
||||
@property({ attribute: false }) onSelectDiff: (path: string) => void = () => undefined;
|
||||
|
||||
override render() {
|
||||
if (!this.workspace) return html`<section class="empty">Select a workspace.</section>`;
|
||||
const visiblePanels = this.panels.filter((panel) => panel.visible?.(this.workspace as Workspace) ?? true);
|
||||
const workspace = this.workspace;
|
||||
if (workspace === undefined) return html`<section class="empty">Select a workspace.</section>`;
|
||||
const visiblePanels = this.panels.filter((panel) => panel.visible?.(workspace) ?? true);
|
||||
const selectedPanel = visiblePanels.find((panel) => panel.id === this.tool) ?? visiblePanels[0];
|
||||
return html`
|
||||
<header>
|
||||
@@ -37,9 +38,9 @@ export class WorkspacePanel extends LitElement {
|
||||
<button class=${selectedPanel?.id === panel.id ? "selected" : ""} @click=${() => { this.onSelectTool(panel.id); }}>${panel.title}</button>
|
||||
`)}
|
||||
</div>
|
||||
<small title=${this.workspace.path}>${this.workspace.label}</small>
|
||||
<small title=${workspace.path}>${workspace.label}</small>
|
||||
</header>
|
||||
${selectedPanel === undefined ? html`<section class="empty">No workspace panels registered.</section>` : selectedPanel.render(this.createPanelContext(this.workspace))}
|
||||
${selectedPanel === undefined ? html`<section class="empty">No workspace panels registered.</section>` : selectedPanel.render(this.createPanelContext(workspace))}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { html, type TemplateResult } from "lit";
|
||||
import type { FileTreeEntry, GitDiffResponse, GitStatusResponse } from "../../api";
|
||||
import type { WorkspacePanelContribution, WorkspacePanelContext } from "../types";
|
||||
import "../../components/CodeViewer";
|
||||
import "../../components/TerminalPanel";
|
||||
|
||||
export function createCoreWorkspacePanels(): WorkspacePanelContribution[] {
|
||||
return [
|
||||
@@ -18,6 +19,12 @@ export function createCoreWorkspacePanels(): WorkspacePanelContribution[] {
|
||||
visible: (workspace) => workspace.isGitRepo,
|
||||
render: renderGit,
|
||||
},
|
||||
{
|
||||
id: "workspace.terminal",
|
||||
title: "Terminal",
|
||||
order: 30,
|
||||
render: renderTerminal,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -67,6 +74,10 @@ function renderFileViewer(context: WorkspacePanelContext): TemplateResult {
|
||||
`;
|
||||
}
|
||||
|
||||
function renderTerminal(context: WorkspacePanelContext): TemplateResult {
|
||||
return html`<terminal-panel .workspace=${context.workspace}></terminal-panel>`;
|
||||
}
|
||||
|
||||
function renderGit(context: WorkspacePanelContext): TemplateResult {
|
||||
const status = context.gitStatus;
|
||||
return html`
|
||||
|
||||
@@ -28,7 +28,7 @@ describe("PluginRegistry", () => {
|
||||
registry.register(corePlugin);
|
||||
|
||||
expect(registry.getActions(createContext().context).some((action) => action.id === "core:actions.show")).toBe(true);
|
||||
expect(registry.getWorkspacePanels().map((panel) => panel.id)).toEqual(["core:workspace.files", "core:workspace.git"]);
|
||||
expect(registry.getWorkspacePanels().map((panel) => panel.id)).toEqual(["core:workspace.files", "core:workspace.git", "core:workspace.terminal"]);
|
||||
});
|
||||
|
||||
it("rejects duplicate ids within the same namespace", () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { listDirectorySuggestions } from "./projects/directorySuggestions.js";
|
||||
import { registerSessionProxyRoutes } from "./sessiond/sessionProxyRoutes.js";
|
||||
import { registerWorkspaceExplorerRoutes } from "./workspaceExplorerRoutes.js";
|
||||
import { registerGitRoutes } from "./gitRoutes.js";
|
||||
import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js";
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
await app.register(fastifyWebsocket);
|
||||
@@ -57,6 +58,7 @@ app.get<{ Params: { projectId: string } }>("/api/projects/:projectId/workspaces"
|
||||
registerSessionProxyRoutes(app);
|
||||
registerWorkspaceExplorerRoutes(app, projects, workspaces);
|
||||
registerGitRoutes(app, projects, workspaces);
|
||||
registerTerminalProxyRoutes(app, projects, workspaces);
|
||||
|
||||
app.get<{ Querystring: { cwd?: string; q?: string; kind?: "tracked" | "untracked" | "other"; mode?: "file" | "path" } }>("/api/files", async (request, reply) => {
|
||||
if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" });
|
||||
|
||||
@@ -6,13 +6,17 @@ import { SessionEventHub } from "./realtime/sessionEventHub.js";
|
||||
import { PiSessionService } from "./sessions/piSessionService.js";
|
||||
import { registerSessionRoutes } from "./sessions/sessionRoutes.js";
|
||||
import { sessiondSocketPath } from "./sessiond/config.js";
|
||||
import { TerminalService } from "./terminals/terminalService.js";
|
||||
import { registerTerminalRoutes } from "./terminals/terminalRoutes.js";
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
await app.register(fastifyWebsocket);
|
||||
|
||||
const eventHub = new SessionEventHub();
|
||||
const sessions = new PiSessionService(eventHub);
|
||||
const terminals = new TerminalService();
|
||||
registerSessionRoutes(app, sessions, eventHub);
|
||||
registerTerminalRoutes(app, terminals);
|
||||
|
||||
app.get("/health", () => ({ ok: true, activeSessions: sessions.activeCount(), checkedAt: new Date().toISOString() }));
|
||||
|
||||
@@ -21,6 +25,7 @@ async function shutdown(signal: NodeJS.Signals): Promise<void> {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
app.log.info({ signal }, "shutting down session daemon");
|
||||
terminals.dispose();
|
||||
await sessions.dispose();
|
||||
await app.close();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { FastifyInstance, FastifyReply } from "fastify";
|
||||
import { WebSocket, type RawData } from "ws";
|
||||
import type { ProjectService } from "./projects/projectService.js";
|
||||
import { SessionDaemonClient } from "./sessiond/sessionDaemonClient.js";
|
||||
import { resolveWorkspaceContext } from "./workspaces/workspaceContext.js";
|
||||
import type { WorkspaceService } from "./workspaces/workspaceService.js";
|
||||
|
||||
export function registerTerminalProxyRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, daemon = new SessionDaemonClient()): void {
|
||||
app.get<{ Params: { projectId: string; workspaceId: string } }>("/api/projects/:projectId/workspaces/:workspaceId/terminals", async (request, reply) => {
|
||||
try {
|
||||
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||
return await proxyJson(daemon, "GET", `/terminals?cwd=${encodeURIComponent(context.root)}`, undefined, reply);
|
||||
} catch (error) {
|
||||
requestFailed(reply, error);
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { projectId: string; workspaceId: string }; Body: { name?: string; cols?: number; rows?: number } }>("/api/projects/:projectId/workspaces/:workspaceId/terminals", async (request, reply) => {
|
||||
try {
|
||||
const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||
return await proxyJson(daemon, "POST", "/terminals", { ...request.body, cwd: context.root }, reply);
|
||||
} catch (error) {
|
||||
requestFailed(reply, error);
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
app.delete<{ Params: { projectId: string; workspaceId: string; terminalId: string } }>("/api/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId", async (request, reply) => {
|
||||
try {
|
||||
await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||
return await proxyJson(daemon, "DELETE", `/terminals/${encodeURIComponent(request.params.terminalId)}`, undefined, reply);
|
||||
} catch (error) {
|
||||
requestFailed(reply, error);
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{ Params: { projectId: string; workspaceId: string; terminalId: string } }>("/api/projects/:projectId/workspaces/:workspaceId/terminals/:terminalId/socket", { websocket: true }, async (socket, request) => {
|
||||
try {
|
||||
await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId);
|
||||
bridgeSockets(socket, daemon.connectWebSocket(`/terminals/${request.params.terminalId}/socket`));
|
||||
} catch (error) {
|
||||
socket.send(JSON.stringify({ type: "error", message: error instanceof Error ? error.message : String(error) }));
|
||||
socket.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function proxyJson(daemon: SessionDaemonClient, method: string, path: string, body: unknown, reply: FastifyReply): Promise<unknown> {
|
||||
const upstream = await daemon.request(method, path, body);
|
||||
reply.code(upstream.statusCode);
|
||||
const contentType = upstream.headers["content-type"];
|
||||
if (contentType !== undefined && contentType !== "") reply.header("content-type", contentType);
|
||||
const value: unknown = upstream.body !== "" ? JSON.parse(upstream.body) : undefined;
|
||||
return value;
|
||||
}
|
||||
|
||||
function requestFailed(reply: FastifyReply, error: unknown): void {
|
||||
reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
|
||||
function bridgeSockets(client: WebSocket, upstream: WebSocket): void {
|
||||
client.on("message", (data) => { sendIfOpen(upstream, data); });
|
||||
upstream.on("message", (data) => { sendIfOpen(client, data); });
|
||||
client.on("close", () => { upstream.close(); });
|
||||
upstream.on("close", () => { client.close(); });
|
||||
upstream.on("error", () => { client.close(); });
|
||||
client.on("error", () => { upstream.close(); });
|
||||
}
|
||||
|
||||
function sendIfOpen(socket: WebSocket, data: RawData): void {
|
||||
if (socket.readyState === WebSocket.OPEN) socket.send(data);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { RawData } from "ws";
|
||||
import type { TerminalService } from "./terminalService.js";
|
||||
|
||||
export function registerTerminalRoutes(app: FastifyInstance, terminals: TerminalService, prefix = ""): void {
|
||||
app.get<{ Querystring: { cwd?: string } }>(`${prefix}/terminals`, (request, reply) => {
|
||||
if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" });
|
||||
return terminals.list(request.query.cwd);
|
||||
});
|
||||
|
||||
app.post<{ Body: { cwd: string; name?: string; cols?: number; rows?: number } }>(`${prefix}/terminals`, (request, reply) => {
|
||||
try {
|
||||
return terminals.create(request.body);
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete<{ Params: { terminalId: string } }>(`${prefix}/terminals/:terminalId`, (request) => {
|
||||
terminals.close(request.params.terminalId);
|
||||
return { closed: true };
|
||||
});
|
||||
|
||||
app.get<{ Params: { terminalId: string } }>(`${prefix}/terminals/:terminalId/socket`, { websocket: true }, (socket, request) => {
|
||||
let detach: (() => void) | undefined;
|
||||
try {
|
||||
detach = terminals.attach(request.params.terminalId, {
|
||||
output: (data) => { socket.send(JSON.stringify({ type: "output", data })); },
|
||||
exit: (exitCode) => { socket.send(JSON.stringify({ type: "exit", exitCode })); },
|
||||
});
|
||||
} catch (error) {
|
||||
socket.send(JSON.stringify({ type: "error", message: error instanceof Error ? error.message : String(error) }));
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
|
||||
socket.on("message", (data) => {
|
||||
try {
|
||||
const message = parseClientMessage(data);
|
||||
if (message.type === "input") terminals.write(request.params.terminalId, message.data);
|
||||
if (message.type === "resize") terminals.resize(request.params.terminalId, message.cols, message.rows);
|
||||
} catch (error) {
|
||||
socket.send(JSON.stringify({ type: "error", message: error instanceof Error ? error.message : String(error) }));
|
||||
}
|
||||
});
|
||||
socket.on("close", () => { detach(); });
|
||||
socket.on("error", () => { detach(); });
|
||||
});
|
||||
}
|
||||
|
||||
type ClientTerminalMessage =
|
||||
| { type: "input"; data: string }
|
||||
| { type: "resize"; cols: number; rows: number };
|
||||
|
||||
function parseClientMessage(data: RawData): ClientTerminalMessage {
|
||||
const value: unknown = JSON.parse(rawDataToString(data));
|
||||
if (!isRecord(value) || typeof value["type"] !== "string") throw new Error("Invalid terminal message");
|
||||
if (value["type"] === "input" && typeof value["data"] === "string") return { type: "input", data: value["data"] };
|
||||
if (value["type"] === "resize" && typeof value["cols"] === "number" && typeof value["rows"] === "number") return { type: "resize", cols: value["cols"], rows: value["rows"] };
|
||||
throw new Error("Invalid terminal message");
|
||||
}
|
||||
|
||||
function rawDataToString(data: RawData): string {
|
||||
if (typeof data === "string") return data;
|
||||
if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8");
|
||||
if (Array.isArray(data)) return Buffer.concat(data).toString("utf8");
|
||||
return data.toString("utf8");
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import * as pty from "node-pty";
|
||||
|
||||
const MAX_REPLAY_BUFFER = 200_000;
|
||||
|
||||
export interface TerminalInfo {
|
||||
id: string;
|
||||
cwd: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
exited: boolean;
|
||||
exitCode?: number;
|
||||
}
|
||||
|
||||
interface TerminalRecord extends TerminalInfo {
|
||||
pty: pty.IPty;
|
||||
buffer: string;
|
||||
events: EventEmitter;
|
||||
}
|
||||
|
||||
export class TerminalService {
|
||||
private readonly terminals = new Map<string, TerminalRecord>();
|
||||
|
||||
list(cwd: string): TerminalInfo[] {
|
||||
return [...this.terminals.values()]
|
||||
.filter((terminal) => terminal.cwd === cwd)
|
||||
.map(toInfo);
|
||||
}
|
||||
|
||||
create(options: { cwd: string; name?: string; cols?: number; rows?: number }): TerminalInfo {
|
||||
if (options.cwd === "") throw new Error("cwd is required");
|
||||
const id = randomUUID();
|
||||
const createdAt = new Date().toISOString();
|
||||
const shell = process.env["SHELL"] ?? "/bin/bash";
|
||||
const terminal = pty.spawn(shell, [], {
|
||||
name: "xterm-256color",
|
||||
cwd: options.cwd,
|
||||
cols: options.cols ?? 100,
|
||||
rows: options.rows ?? 30,
|
||||
env: { ...process.env, TERM: "xterm-256color" },
|
||||
});
|
||||
const requestedName = options.name?.trim();
|
||||
const record: TerminalRecord = {
|
||||
id,
|
||||
cwd: options.cwd,
|
||||
name: requestedName !== undefined && requestedName !== "" ? requestedName : `Shell ${String(this.list(options.cwd).length + 1)}`,
|
||||
createdAt,
|
||||
exited: false,
|
||||
pty: terminal,
|
||||
buffer: "",
|
||||
events: new EventEmitter(),
|
||||
};
|
||||
terminal.onData((data) => {
|
||||
record.buffer = trimReplayBuffer(record.buffer + data);
|
||||
record.events.emit("output", data);
|
||||
});
|
||||
terminal.onExit(({ exitCode }) => {
|
||||
record.exited = true;
|
||||
record.exitCode = exitCode;
|
||||
record.events.emit("exit", exitCode);
|
||||
});
|
||||
this.terminals.set(id, record);
|
||||
return toInfo(record);
|
||||
}
|
||||
|
||||
get(id: string): TerminalInfo | undefined {
|
||||
const terminal = this.terminals.get(id);
|
||||
return terminal === undefined ? undefined : toInfo(terminal);
|
||||
}
|
||||
|
||||
attach(id: string, handlers: { output: (data: string) => void; exit: (exitCode: number | undefined) => void }): () => void {
|
||||
const terminal = this.require(id);
|
||||
if (terminal.buffer !== "") handlers.output(terminal.buffer);
|
||||
if (terminal.exited) handlers.exit(terminal.exitCode);
|
||||
const onOutput = (data: string) => { handlers.output(data); };
|
||||
const onExit = (exitCode: number | undefined) => { handlers.exit(exitCode); };
|
||||
terminal.events.on("output", onOutput);
|
||||
terminal.events.on("exit", onExit);
|
||||
return () => {
|
||||
terminal.events.off("output", onOutput);
|
||||
terminal.events.off("exit", onExit);
|
||||
};
|
||||
}
|
||||
|
||||
write(id: string, data: string): void {
|
||||
const terminal = this.require(id);
|
||||
if (!terminal.exited) terminal.pty.write(data);
|
||||
}
|
||||
|
||||
resize(id: string, cols: number, rows: number): void {
|
||||
const terminal = this.require(id);
|
||||
if (!terminal.exited && Number.isFinite(cols) && Number.isFinite(rows) && cols > 0 && rows > 0) {
|
||||
terminal.pty.resize(Math.floor(cols), Math.floor(rows));
|
||||
}
|
||||
}
|
||||
|
||||
close(id: string): void {
|
||||
const terminal = this.terminals.get(id);
|
||||
if (terminal === undefined) return;
|
||||
this.terminals.delete(id);
|
||||
terminal.events.removeAllListeners();
|
||||
if (!terminal.exited) terminal.pty.kill();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const id of [...this.terminals.keys()]) this.close(id);
|
||||
}
|
||||
|
||||
private require(id: string): TerminalRecord {
|
||||
const terminal = this.terminals.get(id);
|
||||
if (terminal === undefined) throw new Error("Terminal not found");
|
||||
return terminal;
|
||||
}
|
||||
}
|
||||
|
||||
function toInfo(record: TerminalRecord): TerminalInfo {
|
||||
return {
|
||||
id: record.id,
|
||||
cwd: record.cwd,
|
||||
name: record.name,
|
||||
createdAt: record.createdAt,
|
||||
exited: record.exited,
|
||||
...(record.exitCode === undefined ? {} : { exitCode: record.exitCode }),
|
||||
};
|
||||
}
|
||||
|
||||
function trimReplayBuffer(buffer: string): string {
|
||||
if (buffer.length <= MAX_REPLAY_BUFFER) return buffer;
|
||||
return buffer.slice(buffer.length - MAX_REPLAY_BUFFER);
|
||||
}
|
||||
@@ -114,6 +114,15 @@ export interface GitDiffResponse {
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export interface TerminalInfo {
|
||||
id: string;
|
||||
cwd: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
exited: boolean;
|
||||
exitCode?: number;
|
||||
}
|
||||
|
||||
export interface CommandOption {
|
||||
value: string;
|
||||
label: string;
|
||||
|
||||
Reference in New Issue
Block a user