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:
co-authored by
Claude Opus 4.6
parent
97f0f4b266
commit
73eabd2eea
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user