Skip to content

Commit f391794

Browse files
fix(shared): classify workspace previews by literal filenames (pingdotgg#10311)
Co-authored-by: yashranaway <yashranaway@users.noreply.github.com> Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com>
1 parent 31a9da1 commit f391794

4 files changed

Lines changed: 65 additions & 10 deletions

File tree

‎apps/server/src/assets/AssetAccess.test.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,5 +274,8 @@ On web and desktop, HTML and PDF files open as rendered pages. Switch an HTML
274274
file to source view to read its markup; a link to a specific line opens source
275275
automatically. HTML previews cannot access your T3 Code session.
276276

277+
The file viewer recognizes images, HTML, and PDF files by their filename extension,
278+
including filenames or folders containing `#` or `?`.
279+
277280
On mobile, select a PDF attachment or link to open it. iOS uses the native viewer;
278281
Android opens a compatible installed file viewer.

‎packages/shared/src/filePreview.test.ts‎

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
} from "./filePreview.ts";
1515

1616
describe("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,20 +26,29 @@ 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) => {
3335
expect(isWorkspaceImagePreviewPath(path)).toBe(true);
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‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,8 +171,8 @@ export function mediaKindFromPath(path: string): "image" | "video" | null {
171171
}
172172

173173
function 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

178178
export function isWorkspaceBrowserPreviewPath(path: string): boolean {

0 commit comments

Comments
 (0)