| ... | @@ -92,6 +92,7 @@ interface FilePreviewPanelProps { | ... | @@ -92,6 +92,7 @@ interface FilePreviewPanelProps { |
| 92 | | 92 | |
| 93 | const FILE_EXPLORER_STORAGE_KEY = "t3code.fileExplorerOpen"; | 93 | const FILE_EXPLORER_STORAGE_KEY = "t3code.fileExplorerOpen"; |
| 94 | const RENDER_MARKDOWN_STORAGE_KEY = "t3code.renderMarkdown"; | 94 | const RENDER_MARKDOWN_STORAGE_KEY = "t3code.renderMarkdown"; |
| | 95 | const RENDER_BROWSER_FILE_STORAGE_KEY = "t3code.renderBrowserFile"; |
| 95 | const FILE_SAVE_DEBOUNCE_MS = 500; | 96 | const FILE_SAVE_DEBOUNCE_MS = 500; |
| 96 | const FILE_LINK_REVEAL_ATTRIBUTE = "data-file-link-reveal"; | 97 | const FILE_LINK_REVEAL_ATTRIBUTE = "data-file-link-reveal"; |
| 97 | const FILE_LINK_REVEAL_UNSAFE_CSS = ` | 98 | const FILE_LINK_REVEAL_UNSAFE_CSS = ` |
| ... | @@ -197,6 +198,69 @@ function WorkspaceImagePreview(props: { | ... | @@ -197,6 +198,69 @@ function WorkspaceImagePreview(props: { |
| 197 | ); | 198 | ); |
| 198 | } | 199 | } |
| 199 | | 200 | |
| | 201 | const isPdfPreviewFile = (path: string): boolean => /\.pdf$/i.test(path.split(/[?#]/, 1)[0] ?? ""); |
| | 202 | |
| | 203 | /** |
| | 204 | * Renders an HTML or PDF file in place from its signed asset URL. HTML runs in |
| | 205 | * a sandboxed frame with an opaque origin, so a page cannot reach the app's |
| | 206 | * session or storage. A file inside the workspace may load sibling assets; a |
| | 207 | * host file outside it is served on its own. |
| | 208 | */ |
| | 209 | function WorkspaceBrowserPreview(props: { |
| | 210 | readonly environmentId: EnvironmentId; |
| | 211 | readonly threadRef: ScopedThreadRef; |
| | 212 | readonly absolutePath: string; |
| | 213 | readonly workspaceRoot: string; |
| | 214 | readonly title: string; |
| | 215 | readonly workspaceMutationId: string | null; |
| | 216 | }) { |
| | 217 | const insideWorkspace = |
| | 218 | mediaFileReference(props.absolutePath, props.workspaceRoot).relativePath !== undefined; |
| | 219 | const resource = useMemo( |
| | 220 | () => ({ |
| | 221 | _tag: insideWorkspace ? ("workspace-file" as const) : ("media-file" as const), |
| | 222 | threadId: props.threadRef.threadId, |
| | 223 | path: props.absolutePath, |
| | 224 | }), |
| | 225 | [insideWorkspace, props.threadRef.threadId, props.absolutePath], |
| | 226 | ); |
| | 227 | const assetUrl = useAssetUrlState(props.environmentId, resource); |
| | 228 | const revisionSuffix = |
| | 229 | props.workspaceMutationId === null |
| | 230 | ? "" |
| | 231 | : `${assetUrl._tag === "Success" && assetUrl.url.includes("?") ? "&" : "?"}workspace-revision=${encodeURIComponent(props.workspaceMutationId)}`; |
| | 232 | |
| | 233 | if (assetUrl._tag === "Failure") { |
| | 234 | return ( |
| | 235 | <div className="flex min-h-0 flex-1 items-center justify-center px-6 text-center text-xs leading-relaxed text-destructive"> |
| | 236 | Unable to load file preview. |
| | 237 | </div> |
| | 238 | ); |
| | 239 | } |
| | 240 | if (assetUrl._tag !== "Success") { |
| | 241 | return ( |
| | 242 | <div className="flex min-h-0 flex-1 items-center justify-center text-muted-foreground"> |
| | 243 | <LoaderCircle className="size-5 animate-spin" /> |
| | 244 | </div> |
| | 245 | ); |
| | 246 | } |
| | 247 | const src = `${assetUrl.url}${revisionSuffix}`; |
| | 248 | const className = "min-h-0 flex-1 border-0 bg-white"; |
| | 249 | // The built-in PDF viewer needs an unsandboxed frame; a PDF runs no scripts. |
| | 250 | return isPdfPreviewFile(props.absolutePath) ? ( |
| | 251 | // oxlint-disable-next-line react/iframe-missing-sandbox |
| | 252 | <iframe key={src} src={src} title={props.title} className={className} /> |
| | 253 | ) : ( |
| | 254 | <iframe |
| | 255 | key={src} |
| | 256 | src={src} |
| | 257 | title={props.title} |
| | 258 | className={className} |
| | 259 | sandbox="allow-scripts allow-forms allow-popups allow-modals" |
| | 260 | /> |
| | 261 | ); |
| | 262 | } |
| | 263 | |
| 200 | function WorkspaceVideoPreview(props: { | 264 | function WorkspaceVideoPreview(props: { |
| 201 | readonly environmentId: EnvironmentId; | 265 | readonly environmentId: EnvironmentId; |
| 202 | readonly threadRef: ScopedThreadRef; | 266 | readonly threadRef: ScopedThreadRef; |
| ... | @@ -839,6 +903,11 @@ function RenderedMarkdownSurface({ | ... | @@ -839,6 +903,11 @@ function RenderedMarkdownSurface({ |
| 839 | ); | 903 | ); |
| 840 | } | 904 | } |
| 841 | | 905 | |
| | 906 | function renderedToggleLabel(isMarkdown: boolean, rendered: boolean): string { |
| | 907 | if (isMarkdown) return rendered ? "Show markdown source" : "Show rendered markdown"; |
| | 908 | return rendered ? "Show HTML source" : "Show rendered page"; |
| | 909 | } |
| | 910 | |
| 842 | function initialExplorerOpen(): boolean { | 911 | function initialExplorerOpen(): boolean { |
| 843 | try { | 912 | try { |
| 844 | return getLocalStorageItem(FILE_EXPLORER_STORAGE_KEY, Schema.Boolean) ?? true; | 913 | return getLocalStorageItem(FILE_EXPLORER_STORAGE_KEY, Schema.Boolean) ?? true; |
| ... | @@ -878,9 +947,12 @@ export default function FilePreviewPanel({ | ... | @@ -878,9 +947,12 @@ export default function FilePreviewPanel({ |
| 878 | const isVideo = relativePath !== null && isWorkspaceVideoPreviewPath(relativePath); | 947 | const isVideo = relativePath !== null && isWorkspaceVideoPreviewPath(relativePath); |
| 879 | const isImage = relativePath !== null && !isVideo && isWorkspaceImagePreviewPath(relativePath); | 948 | const isImage = relativePath !== null && !isVideo && isWorkspaceImagePreviewPath(relativePath); |
| 880 | const isMedia = isImage || isVideo; | 949 | const isMedia = isImage || isVideo; |
| | 950 | // PDFs have no text to show; HTML has, and can toggle between page and source. |
| | 951 | const isPdf = relativePath !== null && isPdfPreviewFile(relativePath); |
| | 952 | const isHtml = relativePath !== null && !isPdf && isBrowserPreviewFile(relativePath); |
| 881 | // A file outside the workspace (an absolute path) is shown, never edited. | 953 | // A file outside the workspace (an absolute path) is shown, never edited. |
| 882 | const isHostFile = relativePath !== null && isAbsolutePath(relativePath); | 954 | const isHostFile = relativePath !== null && isAbsolutePath(relativePath); |
| 883 | const file = useProjectFileQuery(environmentId, cwd, relativePath, !isMedia); | 955 | const file = useProjectFileQuery(environmentId, cwd, relativePath, !isMedia && !isPdf); |
| 884 | const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen); | 956 | const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen); |
| 885 | // Reading markdown rendered is a preference, not a property of one file. Keeping | 957 | // Reading markdown rendered is a preference, not a property of one file. Keeping |
| 886 | // it on the panel meant a thread switch dropped it and forced source back. | 958 | // it on the panel meant a thread switch dropped it and forced source back. |
| ... | @@ -889,6 +961,11 @@ export default function FilePreviewPanel({ | ... | @@ -889,6 +961,11 @@ export default function FilePreviewPanel({ |
| 889 | false, | 961 | false, |
| 890 | Schema.Boolean, | 962 | Schema.Boolean, |
| 891 | ); | 963 | ); |
| | 964 | const [renderBrowserFilePreferred, setRenderBrowserFilePreferred] = useLocalStorage( |
| | 965 | RENDER_BROWSER_FILE_STORAGE_KEY, |
| | 966 | true, |
| | 967 | Schema.Boolean, |
| | 968 | ); |
| 892 | // Paired with the path on purpose: each file surface counts its reveals from | 969 | // Paired with the path on purpose: each file surface counts its reveals from |
| 893 | // one, so a bare id would let a dismissed reveal on one file swallow the first | 970 | // one, so a bare id would let a dismissed reveal on one file swallow the first |
| 894 | // reveal on the next. | 971 | // reveal on the next. |
| ... | @@ -898,11 +975,16 @@ export default function FilePreviewPanel({ | ... | @@ -898,11 +975,16 @@ export default function FilePreviewPanel({ |
| 898 | const breadcrumbRef = useRef<HTMLDivElement>(null); | 975 | const breadcrumbRef = useRef<HTMLDivElement>(null); |
| 899 | const isMarkdown = relativePath ? isMarkdownPreviewFile(relativePath) : false; | 976 | const isMarkdown = relativePath ? isMarkdownPreviewFile(relativePath) : false; |
| 900 | // A reveal still wins over the preference: the line only exists in the source. | 977 | // A reveal still wins over the preference: the line only exists in the source. |
| 901 | const renderMarkdown = | 978 | const revealHandled = |
| 902 | isMarkdown && | 979 | revealLine === null || |
| 903 | renderMarkdownPreferred && | 980 | (handledReveal?.path === relativePath && handledReveal.requestId === revealRequestId); |
| 904 | (revealLine === null || | 981 | const renderMarkdown = isMarkdown && renderMarkdownPreferred && revealHandled; |
| 905 | (handledReveal?.path === relativePath && handledReveal.requestId === revealRequestId)); | 982 | const renderBrowserFile = isPdf || (isHtml && renderBrowserFilePreferred && revealHandled); |
| | 983 | const canToggleRendered = isMarkdown || isHtml; |
| | 984 | const rendered = isMarkdown ? renderMarkdown : renderBrowserFile; |
| | 985 | const setRenderedPreferred = isMarkdown |
| | 986 | ? setRenderMarkdownPreferred |
| | 987 | : setRenderBrowserFilePreferred; |
| 906 | const canOpenInBrowser = | 988 | const canOpenInBrowser = |
| 907 | relativePath !== null && | 989 | relativePath !== null && |
| 908 | !isVideo && | 990 | !isVideo && |
| ... | @@ -915,7 +997,7 @@ export default function FilePreviewPanel({ | ... | @@ -915,7 +997,7 @@ export default function FilePreviewPanel({ |
| 915 | ); | 997 | ); |
| 916 | const onFilePostRender = useFileLineReveal(relativePath, revealLine, revealRequestId); | 998 | const onFilePostRender = useFileLineReveal(relativePath, revealLine, revealRequestId); |
| 917 | useWorkspaceMutationRefresh({ | 999 | useWorkspaceMutationRefresh({ |
| 918 | enabled: relativePath !== null && !isMedia && !selectedFilePending, | 1000 | enabled: relativePath !== null && !isMedia && !isPdf && !selectedFilePending, |
| 919 | mutationId: workspaceMutationId, | 1001 | mutationId: workspaceMutationId, |
| 920 | refresh: file.refresh, | 1002 | refresh: file.refresh, |
| 921 | resourceKey: `file:${environmentId}:${cwd}:${relativePath ?? ""}`, | 1003 | resourceKey: `file:${environmentId}:${cwd}:${relativePath ?? ""}`, |
| ... | @@ -1023,32 +1105,30 @@ export default function FilePreviewPanel({ | ... | @@ -1023,32 +1105,30 @@ export default function FilePreviewPanel({ |
| 1023 | enableShortcut={false} | 1105 | enableShortcut={false} |
| 1024 | /> | 1106 | /> |
| 1025 | ) : null} | 1107 | ) : null} |
| 1026 | {isMarkdown ? ( | 1108 | {canToggleRendered ? ( |
| 1027 | <Tooltip> | 1109 | <Tooltip> |
| 1028 | <TooltipTrigger | 1110 | <TooltipTrigger |
| 1029 | render={ | 1111 | render={ |
| 1030 | <Toggle | 1112 | <Toggle |
| 1031 | className="shrink-0" | 1113 | className="shrink-0" |
| 1032 | pressed={renderMarkdown} | 1114 | pressed={rendered} |
| 1033 | onPressedChange={(pressed) => { | 1115 | onPressedChange={(pressed) => { |
| 1034 | setRenderMarkdownPreferred(pressed); | 1116 | setRenderedPreferred(pressed); |
| 1035 | setHandledReveal( | 1117 | setHandledReveal( |
| 1036 | pressed && relativePath !== null | 1118 | pressed && relativePath !== null |
| 1037 | ? { path: relativePath, requestId: revealRequestId } | 1119 | ? { path: relativePath, requestId: revealRequestId } |
| 1038 | : null, | 1120 | : null, |
| 1039 | ); | 1121 | ); |
| 1040 | }} | 1122 | }} |
| 1041 | aria-label={renderMarkdown ? "Show markdown source" : "Show rendered markdown"} | 1123 | aria-label={renderedToggleLabel(isMarkdown, rendered)} |
| 1042 | variant="ghost" | 1124 | variant="ghost" |
| 1043 | size="sm" | 1125 | size="sm" |
| 1044 | > | 1126 | > |
| 1045 | {renderMarkdown ? <Code2 className="size-3.5" /> : <Eye className="size-3.5" />} | 1127 | {rendered ? <Code2 className="size-3.5" /> : <Eye className="size-3.5" />} |
| 1046 | </Toggle> | 1128 | </Toggle> |
| 1047 | } | 1129 | } |
| 1048 | /> | 1130 | /> |
| 1049 | <TooltipPopup> | 1131 | <TooltipPopup>{renderedToggleLabel(isMarkdown, rendered)}</TooltipPopup> |
| 1050 | {renderMarkdown ? "Show markdown source" : "Show rendered markdown"} | | |
| 1051 | </TooltipPopup> | | |
| 1052 | </Tooltip> | 1132 | </Tooltip> |
| 1053 | ) : null} | 1133 | ) : null} |
| 1054 | {canOpenInBrowser ? ( | 1134 | {canOpenInBrowser ? ( |
| ... | @@ -1091,7 +1171,7 @@ export default function FilePreviewPanel({ | ... | @@ -1091,7 +1171,7 @@ export default function FilePreviewPanel({ |
| 1091 | </Tooltip> | 1171 | </Tooltip> |
| 1092 | </div> | 1172 | </div> |
| 1093 | ) : null} | 1173 | ) : null} |
| 1094 | {relativePath && !isMedia && file.data?.truncated ? ( | 1174 | {relativePath && !isMedia && !renderBrowserFile && file.data?.truncated ? ( |
| 1095 | <div className="shrink-0 border-b border-warning/20 bg-warning-surface px-3 py-1.5 text-[11px] text-warning-foreground"> | 1175 | <div className="shrink-0 border-b border-warning/20 bg-warning-surface px-3 py-1.5 text-[11px] text-warning-foreground"> |
| 1096 | Preview limited to the first 1 MB of a {file.data.byteLength.toLocaleString()} byte file. | 1176 | Preview limited to the first 1 MB of a {file.data.byteLength.toLocaleString()} byte file. |
| 1097 | </div> | 1177 | </div> |
| ... | @@ -1123,6 +1203,16 @@ export default function FilePreviewPanel({ | ... | @@ -1123,6 +1203,16 @@ export default function FilePreviewPanel({ |
| 1123 | alt={relativePath} | 1203 | alt={relativePath} |
| 1124 | workspaceMutationId={workspaceMutationId} | 1204 | workspaceMutationId={workspaceMutationId} |
| 1125 | /> | 1205 | /> |
| | 1206 | ) : relativePath && renderBrowserFile && absolutePath ? ( |
| | 1207 | <WorkspaceBrowserPreview |
| | 1208 | key={absolutePath} |
| | 1209 | environmentId={environmentId} |
| | 1210 | threadRef={threadRef} |
| | 1211 | absolutePath={absolutePath} |
| | 1212 | workspaceRoot={cwd} |
| | 1213 | title={relativePath} |
| | 1214 | workspaceMutationId={workspaceMutationId} |
| | 1215 | /> |
| 1126 | ) : relativePath && file.error && file.data === null ? ( | 1216 | ) : relativePath && file.error && file.data === null ? ( |
| 1127 | <div className="flex min-h-0 flex-1 items-center justify-center px-6 text-center text-xs leading-relaxed text-destructive"> | 1217 | <div className="flex min-h-0 flex-1 items-center justify-center px-6 text-center text-xs leading-relaxed text-destructive"> |
| 1128 | {file.error} | 1218 | {file.error} |
| ... | @@ -1204,7 +1294,9 @@ export default function FilePreviewPanel({ | ... | @@ -1204,7 +1294,9 @@ export default function FilePreviewPanel({ |
| 1204 | selectedPathRevealId={revealRequestId} | 1294 | selectedPathRevealId={revealRequestId} |
| 1205 | onOpenFile={onOpenFile} | 1295 | onOpenFile={onOpenFile} |
| 1206 | workspaceMutationId={workspaceMutationId} | 1296 | workspaceMutationId={workspaceMutationId} |
| 1207 | {...(relativePath && !isMedia ? { onRefreshSelectedFile: file.refresh } : {})} | 1297 | {...(relativePath && !isMedia && !isPdf |
| | 1298 | ? { onRefreshSelectedFile: file.refresh } |
| | 1299 | : {})} |
| 1208 | /> | 1300 | /> |
| 1209 | </aside> | 1301 | </aside> |
| 1210 | ) : null} | 1302 | ) : null} |