feat: canonical workspace tab IDs with URL routing and draft agent tabs

Rewrite workspace tab management to use stable tab IDs (agent_, terminal_, draft_, file_) with
dedicated /tab/[tabId] routes, replace separate file-tabs store with unified workspace-tabs store,
support multiple concurrent draft agent tabs, and detect text files by content instead of extension allowlist.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Mohamed Boudra
2026-03-03 20:11:55 +07:00
co-authored by Claude Opus 4.6
parent 97f0f4b266
commit 73eabd2eea
17 changed files with 1732 additions and 768 deletions
@@ -2,7 +2,7 @@ import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { listDirectoryEntries } from "./service.js";
import { listDirectoryEntries, readExplorerFile } from "./service.js";
async function createTempDir(prefix: string): Promise<string> {
return mkdtemp(path.join(os.tmpdir(), prefix));
@@ -31,4 +31,69 @@ describe("file explorer service", () => {
await rm(root, { recursive: true, force: true });
}
});
it("reads .ex files as text", async () => {
const root = await createTempDir("paseo-file-explorer-");
try {
const filePath = path.join(root, "sample.ex");
const content = "defmodule Sample do\nend\n";
await writeFile(filePath, content, "utf-8");
const result = await readExplorerFile({
root,
relativePath: "sample.ex",
});
expect(result.kind).toBe("text");
expect(result.encoding).toBe("utf-8");
expect(result.mimeType).toBe("text/plain");
expect(result.content).toBe(content);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("reads unknown extension text files as text", async () => {
const root = await createTempDir("paseo-file-explorer-");
try {
const filePath = path.join(root, "notes.customext");
const content = "hello from a custom text file\n";
await writeFile(filePath, content, "utf-8");
const result = await readExplorerFile({
root,
relativePath: "notes.customext",
});
expect(result.kind).toBe("text");
expect(result.encoding).toBe("utf-8");
expect(result.mimeType).toBe("text/plain");
expect(result.content).toBe(content);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("classifies files with null bytes as binary", async () => {
const root = await createTempDir("paseo-file-explorer-");
try {
const filePath = path.join(root, "blob.weird");
await writeFile(filePath, Buffer.from([0x48, 0x65, 0x00, 0x6c, 0x6f]));
const result = await readExplorerFile({
root,
relativePath: "blob.weird",
});
expect(result.kind).toBe("binary");
expect(result.encoding).toBe("none");
expect(result.content).toBeUndefined();
expect(result.mimeType).toBe("application/octet-stream");
} finally {
await rm(root, { recursive: true, force: true });
}
});
});
@@ -38,24 +38,6 @@ export interface FileExplorerFile {
modifiedAt: string;
}
const TEXT_EXTENSIONS = new Set([
".ts",
".tsx",
".js",
".jsx",
".json",
".md",
".txt",
".yml",
".yaml",
".css",
".scss",
".html",
".mjs",
".cjs",
".sh",
]);
const TEXT_MIME_TYPES: Record<string, string> = {
".json": "application/json",
};
@@ -156,17 +138,6 @@ export async function readExplorerFile({
modifiedAt: stats.mtime.toISOString(),
};
if (TEXT_EXTENSIONS.has(ext)) {
const content = await fs.readFile(filePath, "utf-8");
return {
...basePayload,
kind: "text",
encoding: "utf-8",
content,
mimeType: TEXT_MIME_TYPES[ext] ?? DEFAULT_TEXT_MIME_TYPE,
};
}
if (ext in IMAGE_MIME_TYPES) {
const buffer = await fs.readFile(filePath);
return {
@@ -178,11 +149,22 @@ export async function readExplorerFile({
};
}
const buffer = await fs.readFile(filePath);
if (isLikelyBinary(buffer)) {
return {
...basePayload,
kind: "binary",
encoding: "none",
mimeType: "application/octet-stream",
};
}
return {
...basePayload,
kind: "binary",
encoding: "none",
mimeType: "application/octet-stream",
kind: "text",
encoding: "utf-8",
content: buffer.toString("utf-8"),
mimeType: textMimeTypeForExtension(ext),
};
}
@@ -204,11 +186,23 @@ export async function getDownloadableFileInfo({
}
const ext = path.extname(filePath).toLowerCase();
const mimeType = TEXT_EXTENSIONS.has(ext)
? TEXT_MIME_TYPES[ext] ?? DEFAULT_TEXT_MIME_TYPE
: ext in IMAGE_MIME_TYPES
? IMAGE_MIME_TYPES[ext]
: "application/octet-stream";
let mimeType = "application/octet-stream";
if (ext in IMAGE_MIME_TYPES) {
mimeType = IMAGE_MIME_TYPES[ext];
} else {
// Read only a small prefix to classify likely text vs binary.
const handle = await fs.open(filePath, "r");
const sample = Buffer.alloc(8192);
try {
const { bytesRead } = await handle.read(sample, 0, sample.length, 0);
const chunk = bytesRead < sample.length ? sample.subarray(0, bytesRead) : sample;
if (!isLikelyBinary(chunk)) {
mimeType = textMimeTypeForExtension(ext);
}
} finally {
await handle.close();
}
}
return {
path: normalizeRelativePath({ root, targetPath: filePath }),
@@ -270,3 +264,33 @@ function normalizeRelativePath({
const relative = path.relative(normalizedRoot, normalizedTarget);
return relative === "" ? "." : relative.split(path.sep).join("/");
}
function textMimeTypeForExtension(ext: string): string {
return TEXT_MIME_TYPES[ext] ?? DEFAULT_TEXT_MIME_TYPE;
}
function isLikelyBinary(buffer: Buffer): boolean {
if (buffer.length === 0) {
return false;
}
let suspicious = 0;
for (let idx = 0; idx < buffer.length; idx += 1) {
const byte = buffer[idx];
if (byte === 0) {
return true;
}
const isControl =
byte < 32 &&
byte !== 9 && // tab
byte !== 10 && // newline
byte !== 13; // carriage return
if (isControl || byte === 127) {
suspicious += 1;
}
}
return suspicious / buffer.length > 0.3;
}