Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
d34e168
feat(web): preview archives, sqlite databases, and decoded images
maria-rcks Oct 19, 2026
26597b3
fix(web): keep binary previews lazy and align heif file icons
maria-rcks Oct 19, 2026
1b88a01
fix(web): keep lazy image decoding compatible with react compiler
maria-rcks Oct 19, 2026
98422ab
test(shared): cover binary file downloads across clients
maria-rcks Oct 19, 2026
4e08914
chore: merge t3/large-file-previews
maria-rcks Oct 19, 2026
54d0a12
chore: merge large file preview base
maria-rcks Oct 20, 2026
5de6c34
chore: merge t3/large-file-previews
maria-rcks Oct 20, 2026
b5891fe
chore: merge t3/large-file-previews
maria-rcks Oct 20, 2026
5b24af6
fix(web): bound binary parsers and preserve archive entries
maria-rcks Oct 20, 2026
fbcbe07
chore: merge large file preview fixes
maria-rcks Oct 20, 2026
53395e9
fix(web): preserve pax payloads and generated sqlite columns
maria-rcks Oct 20, 2026
2ebed51
chore: merge tall row preview fix
maria-rcks Oct 20, 2026
1ca6db3
chore: merge native row scrolling fix
maria-rcks Oct 20, 2026
c8957ed
fix(web): preserve pax attributes and sqlite table names
maria-rcks Oct 20, 2026
d38615d
chore: merge trailing row scrolling fix
maria-rcks Oct 20, 2026
337b5c1
test(web): allow cold preview workers to finish in CI
maria-rcks Oct 20, 2026
b1aeeb6
chore: merge current large file preview base
maria-rcks Oct 20, 2026
04bce05
fix(web): preserve invalid office document errors
maria-rcks Oct 20, 2026
1ce8c0a
chore: merge t3/large-file-previews
maria-rcks Oct 20, 2026
286339f
chore: merge large file preview fixes
maria-rcks Oct 20, 2026
54bf5e8
fix(web): validate zip64 targets and retry binary previews
maria-rcks Oct 20, 2026
c73439a
fix(web): carry workspace refresh fixes into binary previews
maria-rcks Oct 20, 2026
0be0d40
fix(web): carry parser notices into binary previews
maria-rcks Oct 20, 2026
a0478a5
chore: merge large previews into binary file previews
maria-rcks Oct 21, 2026
d937010
chore: merge large preview test fix
maria-rcks Oct 21, 2026
1e80fa9
chore: merge large previews
maria-rcks Oct 21, 2026
132ee24
fix(web): preserve sqlite table picker keyboard focus
maria-rcks Oct 21, 2026
844e590
chore: merge lazy large previews into binary file previews
maria-rcks Oct 21, 2026
1d674ea
fix(shared): remove unused preview metadata reexports
maria-rcks Oct 21, 2026
db71405
chore: merge inline audio pills
maria-rcks Oct 21, 2026
7d42822
fix(web): merge audio duration readiness into binary previews
maria-rcks Oct 21, 2026
64b685d
fix(web): merge large file viewer improvements into binary previews
maria-rcks Oct 25, 2026
de6bd02
fix(web): add image fit controls and binary preview metadata
maria-rcks Oct 25, 2026
2612448
fix(web): merge office engines into binary file previews
maria-rcks Oct 25, 2026
199e984
chore: merge large file preview fixes
maria-rcks Oct 25, 2026
5550a2f
chore: merge file preview review fixes
maria-rcks Oct 25, 2026
dd7b6b2
chore: merge wrapped file tail fixes
maria-rcks Oct 25, 2026
32de835
chore: merge t3/large-file-previews
maria-rcks Oct 25, 2026
33c7a0c
fix(web): align binary previews with current lint rules
maria-rcks Oct 25, 2026
13fd5ec
chore: merge t3/large-file-previews
maria-rcks Oct 25, 2026
51f5de7
chore: merge t3/large-file-previews
maria-rcks Oct 25, 2026
dec4674
chore: merge t3/large-file-previews
maria-rcks Oct 25, 2026
12e7e98
fix(web): reject redirected ZIP64 archive comments
maria-rcks Oct 25, 2026
37842c9
chore: merge t3/large-file-previews
maria-rcks Oct 25, 2026
d47f223
chore: merge t3/large-file-previews
maria-rcks Oct 25, 2026
5d8f1ac
chore: merge Office fixes through large file previews
maria-rcks Oct 25, 2026
d4b9a8a
refactor(web): reuse ZIP directory preflight for binary previews
maria-rcks Oct 25, 2026
c1deef5
chore: merge large file preview fixes
maria-rcks Oct 25, 2026
ce3d108
chore: reconcile binary viewer lockfile peers
maria-rcks Oct 25, 2026
4f51d3d
chore: merge latest large file base
maria-rcks Oct 25, 2026
0300612
chore: retain mobile audio compatibility
maria-rcks Oct 25, 2026
12a35e1
chore: merge large file preview base
maria-rcks Oct 25, 2026
fe6181a
Merge branch 't3/large-file-previews' into t3/binary-file-previews
maria-rcks Oct 25, 2026
bd20ac6
chore: merge large file preview updates
maria-rcks Oct 25, 2026
1164429
fix(web): copy cached tar entry bytes
maria-rcks Oct 25, 2026
f914a03
chore: merge large file preview retry
maria-rcks Oct 25, 2026
2c9fca5
chore: merge large file previews into binary file previews
maria-rcks Oct 26, 2026
c031c7d
chore(web): binary viewer effects state their reasons
maria-rcks Oct 26, 2026
1b0022d
chore: merge current large file base into binary file previews
maria-rcks Oct 26, 2026
4313a53
Merge branch 'fix/large-r6' into fix/binary-r6
maria-rcks Oct 26, 2026
39cb737
Merge branch 'fix/large-r6' into fix/binary-r6
maria-rcks Oct 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/src/ipc/methods/window.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,7 @@ describe("pickProjectFavicon", () => {
"gif",
"ico",
"heic",
"heif",
"jpeg",
"jpg",
"png",
Expand Down
10 changes: 9 additions & 1 deletion apps/mobile/src/features/threads/fileChipMenu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@ describe("file chip downloads", () => {
["/tmp/take%2520%23one.mp4:12", "/tmp/take%20#one.mp4", "video/mp4"],
["/tmp/report.pdf", "/tmp/report.pdf", "application/pdf"],
["screens/image.PNG", "/repo/screens/image.PNG", "image/png"],
["/tmp/archive.zip", "/tmp/archive.zip", "application/zip"],
["/tmp/archive.jar", "/tmp/archive.jar", "application/java-archive"],
["/tmp/archive.tar", "/tmp/archive.tar", "application/x-tar"],
["/tmp/archive.gz", "/tmp/archive.gz", "application/gzip"],
["/tmp/archive.tgz", "/tmp/archive.tgz", "application/gzip"],
["/tmp/data.sqlite", "/tmp/data.sqlite", "application/vnd.sqlite3"],
["/tmp/data.sqlite3", "/tmp/data.sqlite3", "application/vnd.sqlite3"],
["/tmp/data.db", "/tmp/data.db", "application/vnd.sqlite3"],
])("offers a host download for %s", (href, path, mimeType) => {
const target = resolveFileChipTarget(href, "/repo")!;
expect(fileChipMenu(target).actions).toContainEqual({
Expand All @@ -94,7 +102,7 @@ describe("file chip downloads", () => {
});

it("does not offer downloads the host asset endpoint cannot serve", () => {
for (const href of ["src/app.ts", "/tmp/archive.zip", "/tmp/clip.mp4.txt"]) {
for (const href of ["src/app.ts", "/tmp/archive.7z", "/tmp/clip.mp4.txt"]) {
const target = resolveFileChipTarget(href, "/repo")!;
expect(fileChipShareSource(target, threadId)).toBeNull();
expect(fileChipMenu(target).actions.some(({ id }) => id === "save")).toBe(false);
Expand Down
24 changes: 20 additions & 4 deletions apps/server/src/assets/AssetAccess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,14 @@ describe("AssetAccess", () => {
["report.pdf", "application/pdf"],
["tone.wav", "audio/wav"],
["tone.webm-audio", "audio/webm"],
["archive.zip", "application/zip"],
["archive.jar", "application/java-archive"],
["archive.tar", "application/x-tar"],
["archive.gz", "application/gzip"],
["archive.tgz", "application/gzip"],
["data.sqlite", "application/vnd.sqlite3"],
["data.sqlite3", "application/vnd.sqlite3"],
["data.db", "application/vnd.sqlite3"],
] as const) {
const filePath = path.join(outside, name);
yield* fs.writeFileString(filePath, "media");
Expand Down Expand Up @@ -1270,6 +1278,14 @@ describe("AssetAccess", () => {
["woff", "font/woff"],
["woff2", "font/woff2"],
["weba", "audio/webm"],
["zip", "application/zip"],
["jar", "application/java-archive"],
["tar", "application/x-tar"],
["gz", "application/gzip"],
["tgz", "application/gzip"],
["sqlite", "application/vnd.sqlite3"],
["sqlite3", "application/vnd.sqlite3"],
["db", "application/vnd.sqlite3"],
] as const) {
const attachmentId = `thread-1-00000000-0000-4000-8000-000000000001-${extension}`;
const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.${extension}`);
Expand All @@ -1280,7 +1296,7 @@ describe("AssetAccess", () => {
_tag: "attachment",
attachmentId,
fileName: `report.${extension}`,
mimeType: "application/octet-stream",
mimeType: "text/html",
disposition: "inline",
},
});
Expand Down Expand Up @@ -1338,16 +1354,16 @@ describe("AssetAccess", () => {
const config = yield* ServerConfig.ServerConfig;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const attachmentId = "thread-1-00000000-0000-4000-8000-000000000002-zip";
const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.zip`);
const attachmentId = "thread-1-00000000-0000-4000-8000-000000000002-7z";
const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.7z`);
yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true });
yield* fileSystem.writeFile(attachmentPath, new Uint8Array([1, 2, 3]));

const result = yield* issueAssetUrl({
resource: {
_tag: "attachment",
attachmentId,
fileName: "archive.zip",
fileName: "archive.7z",
mimeType: "text/html",
disposition: "inline",
},
Expand Down
3 changes: 3 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,9 @@
"remark-gfm": "catalog:",
"remark-math": "6.0.0",
"remark-parse": "catalog:",
"sql.js": "1.15.0",
"tailwind-merge": "^3.4.0",
"tiff": "7.1.3",
"unbash": "^5.0.0",
"unified": "catalog:",
"use-sync-external-store": "^1.6.0",
Expand All @@ -93,6 +95,7 @@
"@types/mdast": "^4.0.4",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@types/sql.js": "1.4.11",
"@types/use-sync-external-store": "^0.0.6",
"@vercel/config": "^0.3.0",
"@vitejs/plugin-react": "^6.1.2",
Expand Down
264 changes: 264 additions & 0 deletions apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,270 @@ vi.mock(import("./ReadOnlySourcePreview"), () => ({
import { AttachmentFilePreview } from "./AttachmentFilePreview";
import LargeTextViewer from "./LargeTextViewer";
import VirtualTablePreview from "./VirtualTablePreview";
import JSZip from "jszip";
import { boundedZip } from "./boundedZip";
import BinaryViewer from "./BinaryViewer";
import { decodeImagePreview } from "./decodeImagePreview";

describe("bounded binary previews", () => {
it("can leave an archive folder with a repeated separator", async () => {
const bytes = new Uint8Array(2048);
const write = (offset: number, value: string) =>
bytes.set(new TextEncoder().encode(value), offset);
write(0, "a//hello.txt");
write(124, "00000000014\0");
bytes.fill(32, 148, 156);
write(156, "0");
write(
148,
bytes
.subarray(0, 512)
.reduce((sum, byte) => sum + byte, 0)
.toString(8)
.padStart(6, "0") + "\0 ",
);
write(512, "nested hello");
const src = URL.createObjectURL(new Blob([bytes]));
try {
await using screen = await render(<BinaryViewer src={src} name="paths.tar" kind="archive" />);
const child = () => screen.container.querySelector<HTMLButtonElement>("tbody button");
await vi.waitFor(() => expect(child()?.textContent).toBe("a"));
await act(() => child()!.click());
await act(() => child()!.click());
await vi.waitFor(() => expect(child()?.textContent).toBe("hello.txt"));
const parent = () =>
screen.container.querySelector<HTMLButtonElement>('[aria-label="Parent folder"]')!;
await act(() => parent().click());
await act(() => parent().click());
expect(parent().disabled).toBe(true);
expect(child()?.textContent).toBe("a");
} finally {
URL.revokeObjectURL(src);
}
});

it("uses a PAX size override for the following TAR payload and bounds it", async () => {
const archive = (size: string) => {
const header = (name: string, size: number, type: string) => {
const bytes = new Uint8Array(512);
const write = (offset: number, value: string) =>
bytes.set(new TextEncoder().encode(value), offset);
write(0, name);
write(124, size.toString(8).padStart(11, "0") + "\0");
bytes.fill(32, 148, 156);
write(156, type);
write(
148,
bytes
.reduce((sum, byte) => sum + byte, 0)
.toString(8)
.padStart(6, "0") + "\0 ",
);
return bytes;
};
const payload = ` size=${size}\n`;
let record = `${payload.length + 1}${payload}`;
while (record.length !== Number.parseInt(record, 10)) record = `${record.length}${payload}`;
const bytes = new Uint8Array(3072);
bytes.set(header("PaxHeader", record.length, "x"));
bytes.set(new TextEncoder().encode(record), 512);
bytes.set(header("hello.txt", 0, "0"), 1024);
bytes.set(new TextEncoder().encode("hello"), 1536);
return bytes;
};
for (const size of ["5", "999999999999999999999"]) {
const src = URL.createObjectURL(new Blob([archive(size)]));
try {
await using screen = await render(<BinaryViewer src={src} name="pax.tar" kind="archive" />);
if (size === "5") {
await vi.waitFor(() =>
expect(screen.container.querySelector("tbody button")?.textContent).toBe("hello.txt"),
);
await act(() =>
screen.container.querySelector<HTMLButtonElement>("tbody button")!.click(),
);
await vi.waitFor(() =>
expect(screen.container.querySelector("pre")?.textContent).toBe("hello"),
);
} else {
await vi.waitFor(() =>
expect(screen.container.querySelector('[role="alert"]')?.textContent).toContain(
"too large or damaged",
),
);
}
} finally {
URL.revokeObjectURL(src);
}
}
});

it("shows stored and virtual generated SQLite columns", async () => {
const [{ default: initSqlJs }, { default: wasmUrl }] = await Promise.all([
import("sql.js"),
import("sql.js/dist/sql-wasm.wasm?url"),
]);
const SQL = await initSqlJs({ locateFile: () => wasmUrl });
const database = new SQL.Database();
database.run(
"CREATE TABLE orders (price INTEGER, quantity INTEGER, total INTEGER GENERATED ALWAYS AS (price * quantity) STORED, doubled INTEGER GENERATED ALWAYS AS (price * 2) VIRTUAL); INSERT INTO orders(price, quantity) VALUES (7, 3)",
);
const src = URL.createObjectURL(new Blob([new Uint8Array(database.export())]));
database.close();
try {
await using screen = await render(
<BinaryViewer src={src} name="orders.sqlite" kind="sqlite" />,
);
await vi.waitFor(() =>
expect([...screen.container.querySelectorAll("th")].map((x) => x.textContent)).toEqual([
"price",
"quantity",
"total",
"doubled",
]),
);
expect([...screen.container.querySelectorAll("td")].map((x) => x.textContent)).toEqual([
"7",
"3",
"21",
"14",
]);
} finally {
URL.revokeObjectURL(src);
}
});

it("reauthorizes a failed image when retry is clicked", async () => {
const image =
'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="2" height="2"/>';
refresh
.mockReset()
.mockResolvedValueOnce("data:image/png;base64,broken")
.mockResolvedValue(image);
await using screen = await render(
<AttachmentFilePreview
name="photo.png"
mimeType="image/png"
sizeBytes={100}
asset={{ environmentId: EnvironmentId.make("test-environment"), attachmentId: "image" }}
/>,
);
await vi.waitFor(() =>
expect(screen.container.querySelector('[role="alert"]')?.textContent).toContain(
"Unable to load image.",
),
);
await act(() =>
screen.container.querySelector<HTMLButtonElement>('[role="alert"] button')!.click(),
);
await vi.waitFor(() =>
expect(screen.container.querySelector("img")?.getAttribute("src")).toBe(image),
);
expect(refresh).toHaveBeenCalledTimes(2);
});

it("reads a small ZIP64 archive and rejects understated directory counts", async () => {
const zip = new JSZip().file("readme.txt", "hello");
const bytes = await zip.generateAsync({ type: "uint8array" });
const end = bytes.length - 22;
const legacy = new DataView(bytes.buffer);
const extended = new Uint8Array(bytes.length + 76);
extended.set(bytes.subarray(0, end));
extended.set(bytes.subarray(end), end + 76);
const view = new DataView(extended.buffer);
view.setUint32(end, 0x06064b50, true);
view.setBigUint64(end + 4, 44n, true);
view.setBigUint64(end + 24, 1n, true);
view.setBigUint64(end + 32, 1n, true);
view.setBigUint64(end + 40, BigInt(legacy.getUint32(end + 12, true)), true);
view.setBigUint64(end + 48, BigInt(legacy.getUint32(end + 16, true)), true);
view.setUint32(end + 56, 0x07064b50, true);
view.setBigUint64(end + 64, BigInt(end), true);
view.setUint32(end + 72, 1, true);
view.setUint16(end + 76 + 10, 0xffff, true);
expect(
new TextDecoder().decode((await boundedZip(extended.buffer)).get("readme.txt")?.bytes),
).toBe("hello");

const many = new JSZip();
for (let i = 0; i < 2049; i++) many.file(`file-${i}`, "");
const crowded = await many.generateAsync({ type: "arraybuffer" });
new DataView(crowded).setUint16(crowded.byteLength - 12, 1, true);
await expect(boundedZip(crowded)).rejects.toThrow("more than 2,048 entries");
});

it("keeps a file and directory with the same basename accessible", async () => {
const bytes = await new JSZip()
.file("foo.txt", "root file")
.file("foo.txt/bar.txt", "child")
.generateAsync({ type: "arraybuffer" });
const src = URL.createObjectURL(new Blob([bytes]));
try {
await using screen = await render(<BinaryViewer src={src} name="names.zip" kind="archive" />);
await vi.waitFor(() =>
expect(screen.container.querySelectorAll("tbody button")).toHaveLength(2),
);
const buttons = [...screen.container.querySelectorAll<HTMLButtonElement>("tbody button")];
expect(buttons.map((button) => button.textContent)).toEqual(["foo.txt", "foo.txt"]);
await act(() => buttons[1]!.click());
await vi.waitFor(() => expect(screen.container.textContent).toContain("root file"));
} finally {
URL.revokeObjectURL(src);
}
});

it("keeps WhiteIsZero TIFF brightness and rejects oversized metadata before decoding", async () => {
const tags = [
[256, 2],
[257, 1],
[258, 8],
[259, 1],
[262, 0],
[273, 134],
[277, 1],
[278, 1],
[279, 2],
[284, 1],
];
const bytes = new ArrayBuffer(136);
const view = new DataView(bytes);
view.setUint16(0, 0x4949);
view.setUint16(2, 42, true);
view.setUint32(4, 8, true);
view.setUint16(8, tags.length, true);
tags.forEach(([tag, value], index) => {
const offset = 10 + index * 12;
view.setUint16(offset, tag!, true);
view.setUint16(offset + 2, 4, true);
view.setUint32(offset + 4, 1, true);
view.setUint32(offset + 8, value!, true);
});
view.setUint8(135, 255);
const src = URL.createObjectURL(new Blob([bytes]));
try {
const blob = await decodeImagePreview(src, false, new AbortController().signal);
const bitmap = await createImageBitmap(blob);
const canvas = new OffscreenCanvas(2, 1);
const context = canvas.getContext("2d")!;
context.drawImage(bitmap, 0, 0);
expect([...context.getImageData(0, 0, 2, 1).data]).toEqual([
255, 255, 255, 255, 0, 0, 0, 255,
]);
bitmap.close();
} finally {
URL.revokeObjectURL(src);
}
view.setUint32(14, 1_000_000, true);
const invalid = URL.createObjectURL(new Blob([bytes]));
try {
await expect(
decodeImagePreview(invalid, false, new AbortController().signal),
).rejects.toThrow("metadata");
} finally {
URL.revokeObjectURL(invalid);
}
});
});

describe("large text preview recovery", () => {
it("rejects malformed UTF-8 bytes through the real text worker", async () => {
Expand Down
Loading
Loading