diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 71a2c769ef4e..a596ca55c32a 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -713,6 +713,49 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(testLayer)), ); + it.effect("previews workspace files with literal fragment characters in their paths", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-preview-literal-" }); + const directory = path.join(root, "assets#archive"); + yield* fileSystem.makeDirectory(directory); + + for (const name of ["icon#v2.png", "report#draft.pdf"]) { + const filePath = path.join(directory, name); + yield* fileSystem.writeFileString(filePath, "preview fixture"); + const canonicalFile = yield* fileSystem.realPath(filePath); + const result = yield* issueAssetUrl({ + resource: { + _tag: "workspace-file", + threadId: ThreadId.make("thread-1"), + path: filePath, + }, + workspaceRoot: root, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const token = suffix.slice(0, suffix.indexOf("/")); + + expect(yield* resolveAsset(token, name)).toEqual({ kind: "file", path: canonicalFile }); + if (name.endsWith(".png")) { + expect(yield* resolveAsset(token, "other.png")).toBeNull(); + } + } + + const disguisedPath = path.join(root, "image.png#notes.txt"); + yield* fileSystem.writeFileString(disguisedPath, "not an image"); + const error = yield* issueAssetUrl({ + resource: { + _tag: "workspace-file", + threadId: ThreadId.make("thread-1"), + path: disguisedPath, + }, + workspaceRoot: root, + }).pipe(Effect.flip); + expect(error).toBeInstanceOf(AssetPreviewTypeValidationError); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("issues exact attachment capabilities by attachment id", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; diff --git a/docs/user/composer.md b/docs/user/composer.md index 59efc3c30f01..8cb8d7805ac9 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -274,5 +274,8 @@ On web and desktop, HTML and PDF files open as rendered pages. Switch an HTML file to source view to read its markup; a link to a specific line opens source automatically. HTML previews cannot access your T3 Code session. +The file viewer recognizes images, HTML, and PDF files by their filename extension, +including filenames or folders containing `#` or `?`. + On mobile, select a PDF attachment or link to open it. iOS uses the native viewer; Android opens a compatible installed file viewer. diff --git a/packages/shared/src/filePreview.test.ts b/packages/shared/src/filePreview.test.ts index 34d619e7a782..602fb8299cdd 100644 --- a/packages/shared/src/filePreview.test.ts +++ b/packages/shared/src/filePreview.test.ts @@ -14,7 +14,7 @@ import { } from "./filePreview.ts"; describe("workspace file previews", () => { - it.each(["report.html", "report.HTM", "document.pdf?download=1"])( + it.each(["report.html", "report.HTM", "document#draft.pdf", "reports?old/document.pdf"])( "recognizes browser preview path %s", (path) => { expect(isWorkspaceBrowserPreviewPath(path)).toBe(true); @@ -26,7 +26,9 @@ describe("workspace file previews", () => { "icon.png", "photo.JPEG", "animation.gif", - "vector.svg#mark", + "vector#mark.svg", + "photo?edited.JPEG", + "images#archive/icon.png", "texture.webp", "image.avif", ])("recognizes image preview path %s", (path) => { @@ -34,12 +36,19 @@ describe("workspace file previews", () => { expect(isWorkspacePreviewEntryPath(path)).toBe(true); }); - it.each(["README.md", "src/index.ts", "image.png.ts", "png"])( - "rejects non-preview path %s", - (path) => { - expect(isWorkspacePreviewEntryPath(path)).toBe(false); - }, - ); + it.each([ + "README.md", + "src/index.ts", + "image.png.ts", + "png", + "image.png#notes.txt", + "image.svg?notes.txt", + "document.pdf?download=1", + "report.html#notes.txt", + "image%2Epng", + ])("rejects non-preview path %s", (path) => { + expect(isWorkspacePreviewEntryPath(path)).toBe(false); + }); it("serves audio in place from the host like video and browser documents", () => { expect(isWorkspaceAudioPreviewPath("notes/recording.WAV")).toBe(true); diff --git a/packages/shared/src/filePreview.ts b/packages/shared/src/filePreview.ts index da62d2e689ec..04e285eff224 100644 --- a/packages/shared/src/filePreview.ts +++ b/packages/shared/src/filePreview.ts @@ -171,8 +171,8 @@ export function mediaKindFromPath(path: string): "image" | "video" | null { } function hasPreviewExtension(path: string, extensions: ReadonlyArray): boolean { - const pathWithoutQuery = path.split(/[?#]/, 1)[0]?.toLowerCase() ?? ""; - return extensions.some((extension) => pathWithoutQuery.endsWith(extension)); + const literalPath = path.toLowerCase(); + return extensions.some((extension) => literalPath.endsWith(extension)); } export function isWorkspaceBrowserPreviewPath(path: string): boolean {