authorgravatar for 153842990+yashranaway@users.noreply.github.comAditya Garud <153842990+yashranaway@users.noreply.github.com> 2026-10-03 18:46:57+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-03 18:46:57+05:30
logf391794a35c604d57e166a3ab48d56fc6e4e469a
treeeb9453546549f50ef401bef59323b88954c1e7a4
parent31a9da179ed0763335f05681c577474aec5d2309
signature Signed by PGP key B5690EEEBB952194

fix(shared): classify workspace previews by literal filenames (#10311)

Co-authored-by: yashranaway <yashranaway@users.noreply.github.com> Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com>

4 files changed, 65 insertions(+), 10 deletions(-)

apps/server/src/assets/AssetAccess.test.ts+43
......@@ -713,6 +713,49 @@ describe("AssetAccess", () => {
713713 }).pipe(Effect.provide(testLayer)),
714714 );
715715
716 it.effect("previews workspace files with literal fragment characters in their paths", () =>
717 Effect.gen(function* () {
718 const fileSystem = yield* FileSystem.FileSystem;
719 const path = yield* Path.Path;
720 const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-preview-literal-" });
721 const directory = path.join(root, "assets#archive");
722 yield* fileSystem.makeDirectory(directory);
723
724 for (const name of ["icon#v2.png", "report#draft.pdf"]) {
725 const filePath = path.join(directory, name);
726 yield* fileSystem.writeFileString(filePath, "preview fixture");
727 const canonicalFile = yield* fileSystem.realPath(filePath);
728 const result = yield* issueAssetUrl({
729 resource: {
730 _tag: "workspace-file",
731 threadId: ThreadId.make("thread-1"),
732 path: filePath,
733 },
734 workspaceRoot: root,
735 });
736 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
737 const token = suffix.slice(0, suffix.indexOf("/"));
738
739 expect(yield* resolveAsset(token, name)).toEqual({ kind: "file", path: canonicalFile });
740 if (name.endsWith(".png")) {
741 expect(yield* resolveAsset(token, "other.png")).toBeNull();
742 }
743 }
744
745 const disguisedPath = path.join(root, "image.png#notes.txt");
746 yield* fileSystem.writeFileString(disguisedPath, "not an image");
747 const error = yield* issueAssetUrl({
748 resource: {
749 _tag: "workspace-file",
750 threadId: ThreadId.make("thread-1"),
751 path: disguisedPath,
752 },
753 workspaceRoot: root,
754 }).pipe(Effect.flip);
755 expect(error).toBeInstanceOf(AssetPreviewTypeValidationError);
756 }).pipe(Effect.provide(testLayer)),
757 );
758
716759 it.effect("issues exact attachment capabilities by attachment id", () =>
717760 Effect.gen(function* () {
718761 const config = yield* ServerConfig.ServerConfig;
docs/user/composer.md+3
......@@ -274,5 +274,8 @@ On web and desktop, HTML and PDF files open as rendered pages. Switch an HTML
274274file to source view to read its markup; a link to a specific line opens source
275275automatically. HTML previews cannot access your T3 Code session.
276276
277The file viewer recognizes images, HTML, and PDF files by their filename extension,
278including filenames or folders containing `#` or `?`.
279
277280On mobile, select a PDF attachment or link to open it. iOS uses the native viewer;
278281Android opens a compatible installed file viewer.
packages/shared/src/filePreview.test.ts+17-8
......@@ -14,7 +14,7 @@ import {
1414} from "./filePreview.ts";
1515
1616describe("workspace file previews", () => {
17 it.each(["report.html", "report.HTM", "document.pdf?download=1"])(
17 it.each(["report.html", "report.HTM", "document#draft.pdf", "reports?old/document.pdf"])(
1818 "recognizes browser preview path %s",
1919 (path) => {
2020 expect(isWorkspaceBrowserPreviewPath(path)).toBe(true);
......@@ -26,7 +26,9 @@ describe("workspace file previews", () => {
2626 "icon.png",
2727 "photo.JPEG",
2828 "animation.gif",
29 "vector.svg#mark",
29 "vector#mark.svg",
30 "photo?edited.JPEG",
31 "images#archive/icon.png",
3032 "texture.webp",
3133 "image.avif",
3234 ])("recognizes image preview path %s", (path) => {
......@@ -34,12 +36,19 @@ describe("workspace file previews", () => {
3436 expect(isWorkspacePreviewEntryPath(path)).toBe(true);
3537 });
3638
37 it.each(["README.md", "src/index.ts", "image.png.ts", "png"])(
38 "rejects non-preview path %s",
39 (path) => {
40 expect(isWorkspacePreviewEntryPath(path)).toBe(false);
41 },
42 );
39 it.each([
40 "README.md",
41 "src/index.ts",
42 "image.png.ts",
43 "png",
44 "image.png#notes.txt",
45 "image.svg?notes.txt",
46 "document.pdf?download=1",
47 "report.html#notes.txt",
48 "image%2Epng",
49 ])("rejects non-preview path %s", (path) => {
50 expect(isWorkspacePreviewEntryPath(path)).toBe(false);
51 });
4352
4453 it("serves audio in place from the host like video and browser documents", () => {
4554 expect(isWorkspaceAudioPreviewPath("notes/recording.WAV")).toBe(true);
packages/shared/src/filePreview.ts+2-2
......@@ -171,8 +171,8 @@ export function mediaKindFromPath(path: string): "image" | "video" | null {
171171}
172172
173173function hasPreviewExtension(path: string, extensions: ReadonlyArray<string>): boolean {
174 const pathWithoutQuery = path.split(/[?#]/, 1)[0]?.toLowerCase() ?? "";
175 return extensions.some((extension) => pathWithoutQuery.endsWith(extension));
174 const literalPath = path.toLowerCase();
175 return extensions.some((extension) => literalPath.endsWith(extension));
176176}
177177
178178export function isWorkspaceBrowserPreviewPath(path: string): boolean {