| author | |
| committer | |
| log | f391794a35c604d57e166a3ab48d56fc6e4e469a |
| tree | eb9453546549f50ef401bef59323b88954c1e7a4 |
| parent | 31a9da179ed0763335f05681c577474aec5d2309 |
| signature | Signed by PGP key B5690EEEBB952194 |
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", () => { | ... | @@ -713,6 +713,49 @@ describe("AssetAccess", () => { |
| 713 | }).pipe(Effect.provide(testLayer)), | 713 | }).pipe(Effect.provide(testLayer)), |
| 714 | ); | 714 | ); |
| 715 | 715 | ||
| 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 | |||
| 716 | it.effect("issues exact attachment capabilities by attachment id", () => | 759 | it.effect("issues exact attachment capabilities by attachment id", () => |
| 717 | Effect.gen(function* () { | 760 | Effect.gen(function* () { |
| 718 | const config = yield* ServerConfig.ServerConfig; | 761 | 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 | ... | @@ -274,5 +274,8 @@ On web and desktop, HTML and PDF files open as rendered pages. Switch an HTML |
| 274 | file to source view to read its markup; a link to a specific line opens source | 274 | file to source view to read its markup; a link to a specific line opens source |
| 275 | automatically. HTML previews cannot access your T3 Code session. | 275 | automatically. HTML previews cannot access your T3 Code session. |
| 276 | 276 | ||
| 277 | The file viewer recognizes images, HTML, and PDF files by their filename extension, | ||
| 278 | including filenames or folders containing `#` or `?`. | ||
| 279 | |||
| 277 | On mobile, select a PDF attachment or link to open it. iOS uses the native viewer; | 280 | On mobile, select a PDF attachment or link to open it. iOS uses the native viewer; |
| 278 | Android opens a compatible installed file viewer. | 281 | Android opens a compatible installed file viewer. |
packages/shared/src/filePreview.test.ts+17-8| ... | @@ -14,7 +14,7 @@ import { | ... | @@ -14,7 +14,7 @@ import { |
| 14 | } from "./filePreview.ts"; | 14 | } from "./filePreview.ts"; |
| 15 | 15 | ||
| 16 | describe("workspace file previews", () => { | 16 | 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"])( |
| 18 | "recognizes browser preview path %s", | 18 | "recognizes browser preview path %s", |
| 19 | (path) => { | 19 | (path) => { |
| 20 | expect(isWorkspaceBrowserPreviewPath(path)).toBe(true); | 20 | expect(isWorkspaceBrowserPreviewPath(path)).toBe(true); |
| ... | @@ -26,7 +26,9 @@ describe("workspace file previews", () => { | ... | @@ -26,7 +26,9 @@ describe("workspace file previews", () => { |
| 26 | "icon.png", | 26 | "icon.png", |
| 27 | "photo.JPEG", | 27 | "photo.JPEG", |
| 28 | "animation.gif", | 28 | "animation.gif", |
| 29 | "vector.svg#mark", | 29 | "vector#mark.svg", |
| 30 | "photo?edited.JPEG", | ||
| 31 | "images#archive/icon.png", | ||
| 30 | "texture.webp", | 32 | "texture.webp", |
| 31 | "image.avif", | 33 | "image.avif", |
| 32 | ])("recognizes image preview path %s", (path) => { | 34 | ])("recognizes image preview path %s", (path) => { |
| ... | @@ -34,12 +36,19 @@ describe("workspace file previews", () => { | ... | @@ -34,12 +36,19 @@ describe("workspace file previews", () => { |
| 34 | expect(isWorkspacePreviewEntryPath(path)).toBe(true); | 36 | expect(isWorkspacePreviewEntryPath(path)).toBe(true); |
| 35 | }); | 37 | }); |
| 36 | 38 | ||
| 37 | it.each(["README.md", "src/index.ts", "image.png.ts", "png"])( | 39 | it.each([ |
| 38 | "rejects non-preview path %s", | 40 | "README.md", |
| 39 | (path) => { | 41 | "src/index.ts", |
| 40 | expect(isWorkspacePreviewEntryPath(path)).toBe(false); | 42 | "image.png.ts", |
| 41 | }, | 43 | "png", |
| 42 | ); | 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 | }); | ||
| 43 | 52 | ||
| 44 | it("serves audio in place from the host like video and browser documents", () => { | 53 | it("serves audio in place from the host like video and browser documents", () => { |
| 45 | expect(isWorkspaceAudioPreviewPath("notes/recording.WAV")).toBe(true); | 54 | 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 { | ... | @@ -171,8 +171,8 @@ export function mediaKindFromPath(path: string): "image" | "video" | null { |
| 171 | } | 171 | } |
| 172 | 172 | ||
| 173 | function hasPreviewExtension(path: string, extensions: ReadonlyArray<string>): boolean { | 173 | function hasPreviewExtension(path: string, extensions: ReadonlyArray<string>): boolean { |
| 174 | const pathWithoutQuery = path.split(/[?#]/, 1)[0]?.toLowerCase() ?? ""; | 174 | const literalPath = path.toLowerCase(); |
| 175 | return extensions.some((extension) => pathWithoutQuery.endsWith(extension)); | 175 | return extensions.some((extension) => literalPath.endsWith(extension)); |
| 176 | } | 176 | } |
| 177 | 177 | ||
| 178 | export function isWorkspaceBrowserPreviewPath(path: string): boolean { | 178 | export function isWorkspaceBrowserPreviewPath(path: string): boolean { |