feat: add file tabs and improve terminal rendering with WebGL

This commit is contained in:
Mohamed Boudra
2026-03-02 20:02:54 +07:00
parent 92b5f1e963
commit f418f3ab02
25 changed files with 1214 additions and 696 deletions
@@ -1,12 +1,16 @@
import { describe, expect, it } from "vitest";
import {
buildHostWorkspaceRoute,
buildHostWorkspaceFileRoute,
decodeFilePathFromPathSegment,
decodeWorkspaceIdFromPathSegment,
encodeFilePathForPathSegment,
encodeWorkspaceIdForPathSegment,
parseHostAgentDraftRouteFromPathname,
parseHostAgentRouteFromPathname,
parseHostDraftRouteFromPathname,
parseHostWorkspaceAgentRouteFromPathname,
parseHostWorkspaceFileRouteFromPathname,
parseHostWorkspaceTerminalRouteFromPathname,
parseHostWorkspaceRouteFromPathname,
} from "./host-routes";
@@ -54,6 +58,12 @@ describe("workspace route parsing", () => {
expect(decodeWorkspaceIdFromPathSegment("L3RtcC9yZXBv")).toBe("/tmp/repo");
});
it("encodes file paths as base64url (no padding)", () => {
const encoded = encodeFilePathForPathSegment("src/index.ts");
expect(encoded).toMatch(/^[A-Za-z0-9_-]+$/);
expect(decodeFilePathFromPathSegment(encoded)).toBe("src/index.ts");
});
it("parses workspace route", () => {
expect(
parseHostWorkspaceRouteFromPathname("/h/local/workspace/L3RtcC9yZXBv")
@@ -63,6 +73,19 @@ describe("workspace route parsing", () => {
});
});
it("parses workspace file route", () => {
const encodedPath = encodeFilePathForPathSegment("src/index.ts");
expect(
parseHostWorkspaceFileRouteFromPathname(
`/h/local/workspace/L3RtcC9yZXBv/file/${encodedPath}`
)
).toEqual({
serverId: "local",
workspaceId: "/tmp/repo",
filePath: "src/index.ts",
});
});
it("parses workspace agent route", () => {
expect(
parseHostWorkspaceAgentRouteFromPathname(
@@ -104,4 +127,10 @@ describe("workspace route parsing", () => {
"/h/local/workspace/L3RtcC9yZXBv"
);
});
it("builds base64url workspace file routes", () => {
expect(buildHostWorkspaceFileRoute("local", "/tmp/repo", "src/index.ts")).toBe(
`/h/local/workspace/L3RtcC9yZXBv/file/${encodeFilePathForPathSegment("src/index.ts")}`
);
});
});
+86
View File
@@ -94,6 +94,26 @@ export function decodeWorkspaceIdFromPathSegment(workspaceIdSegment: string): st
return normalizeWorkspaceId(decoded);
}
export function encodeFilePathForPathSegment(filePath: string): string {
const normalized = trimNonEmpty(filePath);
if (!normalized) {
return "";
}
return toBase64UrlNoPad(normalized);
}
export function decodeFilePathFromPathSegment(filePathSegment: string): string | null {
const normalizedSegment = trimNonEmpty(filePathSegment);
if (!normalizedSegment) {
return null;
}
const decoded = trimNonEmpty(decodeSegment(normalizedSegment));
if (!decoded) {
return null;
}
return tryDecodeBase64UrlNoPadUtf8(decoded);
}
export function parseServerIdFromPathname(pathname: string): string | null {
const match = pathname.match(/^\/h\/([^/]+)(?:\/|$)/);
if (!match) {
@@ -211,6 +231,10 @@ export function parseHostWorkspaceRouteFromPathname(
if (terminalIdx >= 0 && terminalIdx > workspaceIdStart) {
workspaceIdEnd = Math.min(workspaceIdEnd, terminalIdx);
}
const fileIdx = pathname.lastIndexOf("/file/");
if (fileIdx >= 0 && fileIdx > workspaceIdStart) {
workspaceIdEnd = Math.min(workspaceIdEnd, fileIdx);
}
const rawWorkspaceId = pathname.slice(workspaceIdStart, workspaceIdEnd).replace(/\/+$/, "");
const workspaceId = decodeWorkspaceIdFromPathSegment(rawWorkspaceId);
@@ -320,6 +344,55 @@ export function parseHostWorkspaceTerminalRouteFromPathname(
return { serverId, workspaceId, terminalId };
}
export function parseHostWorkspaceFileRouteFromPathname(
pathname: string
): { serverId: string; workspaceId: string; filePath: string } | null {
const prefix = "/h/";
if (!pathname.startsWith(prefix)) {
return null;
}
const serverIdStart = prefix.length;
const serverIdEnd = pathname.indexOf("/", serverIdStart);
if (serverIdEnd < 0) {
return null;
}
const rawServerId = pathname.slice(serverIdStart, serverIdEnd);
const serverId = trimNonEmpty(decodeSegment(rawServerId));
if (!serverId) {
return null;
}
const workspacePrefix = "/workspace/";
if (!pathname.startsWith(workspacePrefix, serverIdEnd)) {
return null;
}
const workspaceIdStart = serverIdEnd + workspacePrefix.length;
const fileMarker = "/file/";
const fileIdx = pathname.lastIndexOf(fileMarker);
if (fileIdx < 0 || fileIdx <= workspaceIdStart) {
return null;
}
const rawWorkspaceId = pathname.slice(workspaceIdStart, fileIdx).replace(/\/+$/, "");
const workspaceId = decodeWorkspaceIdFromPathSegment(rawWorkspaceId);
if (!workspaceId) {
return null;
}
const filePathStart = fileIdx + fileMarker.length;
const filePathEnd = pathname.indexOf("/", filePathStart);
const rawFilePath =
filePathEnd < 0 ? pathname.slice(filePathStart) : pathname.slice(filePathStart, filePathEnd);
const filePath = decodeFilePathFromPathSegment(rawFilePath);
if (!filePath) {
return null;
}
return { serverId, workspaceId, filePath };
}
export function buildHostWorkspaceRoute(
serverId: string,
workspaceId: string
@@ -362,6 +435,19 @@ export function buildHostWorkspaceTerminalRoute(
return `${base}/terminal/${encodeSegment(normalizedTerminalId)}`;
}
export function buildHostWorkspaceFileRoute(
serverId: string,
workspaceId: string,
filePath: string
): string {
const base = buildHostWorkspaceRoute(serverId, workspaceId);
const encodedFilePath = encodeFilePathForPathSegment(filePath);
if (base === "/" || !encodedFilePath) {
return "/";
}
return `${base}/file/${encodeSegment(encodedFilePath)}`;
}
export function buildHostAgentDetailRoute(
serverId: string,
agentId: string,
+35
View File
@@ -0,0 +1,35 @@
import type { ITheme } from "@xterm/xterm";
import type { Theme } from "@/styles/theme";
type TerminalPalette = Theme["colors"]["terminal"];
export function toXtermTheme(terminal: TerminalPalette): ITheme {
return {
background: terminal.background,
foreground: terminal.foreground,
cursor: terminal.cursor,
cursorAccent: terminal.cursorAccent,
selectionBackground: terminal.selectionBackground,
selectionForeground: terminal.selectionForeground,
black: terminal.black,
red: terminal.red,
green: terminal.green,
yellow: terminal.yellow,
blue: terminal.blue,
magenta: terminal.magenta,
cyan: terminal.cyan,
white: terminal.white,
brightBlack: terminal.brightBlack,
brightRed: terminal.brightRed,
brightGreen: terminal.brightGreen,
brightYellow: terminal.brightYellow,
brightBlue: terminal.brightBlue,
brightMagenta: terminal.brightMagenta,
brightCyan: terminal.brightCyan,
brightWhite: terminal.brightWhite,
};
}