| ... | ... | @@ -92,6 +92,7 @@ interface FilePreviewPanelProps { |
| 92 | 92 | |
| 93 | 93 | const FILE_EXPLORER_STORAGE_KEY = "t3code.fileExplorerOpen"; |
| 94 | 94 | const RENDER_MARKDOWN_STORAGE_KEY = "t3code.renderMarkdown"; |
| 95 | const RENDER_BROWSER_FILE_STORAGE_KEY = "t3code.renderBrowserFile"; |
| 95 | 96 | const FILE_SAVE_DEBOUNCE_MS = 500; |
| 96 | 97 | const FILE_LINK_REVEAL_ATTRIBUTE = "data-file-link-reveal"; |
| 97 | 98 | const FILE_LINK_REVEAL_UNSAFE_CSS = ` |
| ... | ... | @@ -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 | 264 | function WorkspaceVideoPreview(props: { |
| 201 | 265 | readonly environmentId: EnvironmentId; |
| 202 | 266 | readonly threadRef: ScopedThreadRef; |
| ... | ... | @@ -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 | 911 | function initialExplorerOpen(): boolean { |
| 843 | 912 | try { |
| 844 | 913 | return getLocalStorageItem(FILE_EXPLORER_STORAGE_KEY, Schema.Boolean) ?? true; |
| ... | ... | @@ -878,9 +947,12 @@ export default function FilePreviewPanel({ |
| 878 | 947 | const isVideo = relativePath !== null && isWorkspaceVideoPreviewPath(relativePath); |
| 879 | 948 | const isImage = relativePath !== null && !isVideo && isWorkspaceImagePreviewPath(relativePath); |
| 880 | 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 | 953 | // A file outside the workspace (an absolute path) is shown, never edited. |
| 882 | 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 | 956 | const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen); |
| 885 | 957 | // Reading markdown rendered is a preference, not a property of one file. Keeping |
| 886 | 958 | // it on the panel meant a thread switch dropped it and forced source back. |
| ... | ... | @@ -889,6 +961,11 @@ export default function FilePreviewPanel({ |
| 889 | 961 | false, |
| 890 | 962 | Schema.Boolean, |
| 891 | 963 | ); |
| 964 | const [renderBrowserFilePreferred, setRenderBrowserFilePreferred] = useLocalStorage( |
| 965 | RENDER_BROWSER_FILE_STORAGE_KEY, |
| 966 | true, |
| 967 | Schema.Boolean, |
| 968 | ); |
| 892 | 969 | // Paired with the path on purpose: each file surface counts its reveals from |
| 893 | 970 | // one, so a bare id would let a dismissed reveal on one file swallow the first |
| 894 | 971 | // reveal on the next. |
| ... | ... | @@ -898,11 +975,16 @@ export default function FilePreviewPanel({ |
| 898 | 975 | const breadcrumbRef = useRef<HTMLDivElement>(null); |
| 899 | 976 | const isMarkdown = relativePath ? isMarkdownPreviewFile(relativePath) : false; |
| 900 | 977 | // A reveal still wins over the preference: the line only exists in the source. |
| 901 | | const renderMarkdown = |
| 902 | | isMarkdown && |
| 903 | | renderMarkdownPreferred && |
| 904 | | (revealLine === null || |
| 905 | | (handledReveal?.path === relativePath && handledReveal.requestId === revealRequestId)); |
| 978 | const revealHandled = |
| 979 | revealLine === null || |
| 980 | (handledReveal?.path === relativePath && handledReveal.requestId === revealRequestId); |
| 981 | const renderMarkdown = isMarkdown && renderMarkdownPreferred && revealHandled; |
| 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 | 988 | const canOpenInBrowser = |
| 907 | 989 | relativePath !== null && |
| 908 | 990 | !isVideo && |
| ... | ... | @@ -915,7 +997,7 @@ export default function FilePreviewPanel({ |
| 915 | 997 | ); |
| 916 | 998 | const onFilePostRender = useFileLineReveal(relativePath, revealLine, revealRequestId); |
| 917 | 999 | useWorkspaceMutationRefresh({ |
| 918 | | enabled: relativePath !== null && !isMedia && !selectedFilePending, |
| 1000 | enabled: relativePath !== null && !isMedia && !isPdf && !selectedFilePending, |
| 919 | 1001 | mutationId: workspaceMutationId, |
| 920 | 1002 | refresh: file.refresh, |
| 921 | 1003 | resourceKey: `file:${environmentId}:${cwd}:${relativePath ?? ""}`, |
| ... | ... | @@ -1023,32 +1105,30 @@ export default function FilePreviewPanel({ |
| 1023 | 1105 | enableShortcut={false} |
| 1024 | 1106 | /> |
| 1025 | 1107 | ) : null} |
| 1026 | | {isMarkdown ? ( |
| 1108 | {canToggleRendered ? ( |
| 1027 | 1109 | <Tooltip> |
| 1028 | 1110 | <TooltipTrigger |
| 1029 | 1111 | render={ |
| 1030 | 1112 | <Toggle |
| 1031 | 1113 | className="shrink-0" |
| 1032 | | pressed={renderMarkdown} |
| 1114 | pressed={rendered} |
| 1033 | 1115 | onPressedChange={(pressed) => { |
| 1034 | | setRenderMarkdownPreferred(pressed); |
| 1116 | setRenderedPreferred(pressed); |
| 1035 | 1117 | setHandledReveal( |
| 1036 | 1118 | pressed && relativePath !== null |
| 1037 | 1119 | ? { path: relativePath, requestId: revealRequestId } |
| 1038 | 1120 | : null, |
| 1039 | 1121 | ); |
| 1040 | 1122 | }} |
| 1041 | | aria-label={renderMarkdown ? "Show markdown source" : "Show rendered markdown"} |
| 1123 | aria-label={renderedToggleLabel(isMarkdown, rendered)} |
| 1042 | 1124 | variant="ghost" |
| 1043 | 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 | 1128 | </Toggle> |
| 1047 | 1129 | } |
| 1048 | 1130 | /> |
| 1049 | | <TooltipPopup> |
| 1050 | | {renderMarkdown ? "Show markdown source" : "Show rendered markdown"} |
| 1051 | | </TooltipPopup> |
| 1131 | <TooltipPopup>{renderedToggleLabel(isMarkdown, rendered)}</TooltipPopup> |
| 1052 | 1132 | </Tooltip> |
| 1053 | 1133 | ) : null} |
| 1054 | 1134 | {canOpenInBrowser ? ( |
| ... | ... | @@ -1091,7 +1171,7 @@ export default function FilePreviewPanel({ |
| 1091 | 1171 | </Tooltip> |
| 1092 | 1172 | </div> |
| 1093 | 1173 | ) : null} |
| 1094 | | {relativePath && !isMedia && file.data?.truncated ? ( |
| 1174 | {relativePath && !isMedia && !renderBrowserFile && file.data?.truncated ? ( |
| 1095 | 1175 | <div className="shrink-0 border-b border-warning/20 bg-warning-surface px-3 py-1.5 text-[11px] text-warning-foreground"> |
| 1096 | 1176 | Preview limited to the first 1 MB of a {file.data.byteLength.toLocaleString()} byte file. |
| 1097 | 1177 | </div> |
| ... | ... | @@ -1123,6 +1203,16 @@ export default function FilePreviewPanel({ |
| 1123 | 1203 | alt={relativePath} |
| 1124 | 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 | 1216 | ) : relativePath && file.error && file.data === null ? ( |
| 1127 | 1217 | <div className="flex min-h-0 flex-1 items-center justify-center px-6 text-center text-xs leading-relaxed text-destructive"> |
| 1128 | 1218 | {file.error} |
| ... | ... | @@ -1204,7 +1294,9 @@ export default function FilePreviewPanel({ |
| 1204 | 1294 | selectedPathRevealId={revealRequestId} |
| 1205 | 1295 | onOpenFile={onOpenFile} |
| 1206 | 1296 | workspaceMutationId={workspaceMutationId} |
| 1207 | | {...(relativePath && !isMedia ? { onRefreshSelectedFile: file.refresh } : {})} |
| 1297 | {...(relativePath && !isMedia && !isPdf |
| 1298 | ? { onRefreshSelectedFile: file.refresh } |
| 1299 | : {})} |
| 1208 | 1300 | /> |
| 1209 | 1301 | </aside> |
| 1210 | 1302 | ) : null} |