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", () => {
|
||||
|
||||
Reference in New Issue
Block a user