authorgravatar for 33246262+Rhiz3K@users.noreply.github.comRhiz3K <33246262+Rhiz3K@users.noreply.github.com> 2026-07-20 14:22:03+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-20 14:22:03+02:00
log0936fd271f866f5f58cce067865c8d0262b9ea20
tree4c18ac1a60d07b1d2dbdf659bf94396dd625d513
parentfa69f05b69d05003a6994194c12edd1d8aee080c
signature Signed by PGP key B5690EEEBB952194

fix(web): preview workspace images in the file panel (#3996)

Co-authored-by: Rhiz3K <rhiz3k@protonmail.com> Co-authored-by: Julius Marminge <julius0216@outlook.com>

3 files changed, 80 insertions(+), 6 deletions(-)

apps/web/src/assets/assetUrls.ts+22-2
......@@ -9,7 +9,15 @@ import { usePreparedConnection } from "~/state/session";
99
1010export { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
1111
12export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResource): string | null {
12export type AssetUrlState =
13 | { readonly _tag: "Loading" }
14 | { readonly _tag: "Failure" }
15 | { readonly _tag: "Success"; readonly url: string };
16
17export function useAssetUrlState(
18 environmentId: EnvironmentId,
19 resource: AssetResource,
20): AssetUrlState {
1321 const preparedConnection = usePreparedConnection(environmentId);
1422 const result = useAtomValue(
1523 assetEnvironment.createUrl({
......@@ -17,10 +25,22 @@ export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResourc
1725 input: { resource },
1826 }),
1927 );
28 if (result._tag === "Failure") {
29 return { _tag: "Failure" };
30 }
2031 if (preparedConnection._tag === "None" || result._tag !== "Success") {
32 return { _tag: "Loading" };
33 }
34 const url = resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl);
35 return url === null ? { _tag: "Failure" } : { _tag: "Success", url };
36}
37
38export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResource): string | null {
39 const result = useAssetUrlState(environmentId, resource);
40 if (result._tag !== "Success") {
2141 return null;
2242 }
23 return resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl);
43 return result.url;
2444}
2545
2646export function useAssetUrls(
apps/web/src/components/files/FilePreviewPanel.tsx+50-2
......@@ -4,6 +4,7 @@ import type {
44 ResolvedKeybindingsConfig,
55 ScopedThreadRef,
66} from "@t3tools/contracts";
7import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview";
78import { VirtualizedFile, type SelectedLineRange } from "@pierre/diffs";
89import { Editor } from "@pierre/diffs/editor";
910import { EditorProvider, File, type FileOptions, Virtualizer } from "@pierre/diffs/react";
......@@ -16,6 +17,7 @@ import * as Schema from "effect/Schema";
1617import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1718
1819import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview";
20import { useAssetUrlState } from "~/assets/assetUrls";
1921import ChatMarkdown from "~/components/ChatMarkdown";
2022import { OpenInPicker } from "~/components/chat/OpenInPicker";
2123import { useClientSettings } from "~/hooks/useSettings";
......@@ -113,6 +115,43 @@ const FILE_LINK_REVEAL_UNSAFE_CSS = `
113115`;
114116type FilePostRender = NonNullable<FileOptions<unknown>["onPostRender"]>;
115117
118function WorkspaceImagePreview(props: {
119 readonly environmentId: EnvironmentId;
120 readonly threadRef: ScopedThreadRef;
121 readonly absolutePath: string;
122 readonly alt: string;
123}) {
124 const assetUrl = useAssetUrlState(props.environmentId, {
125 _tag: "workspace-file",
126 threadId: props.threadRef.threadId,
127 path: props.absolutePath,
128 });
129 const [failedUrl, setFailedUrl] = useState<string | null>(null);
130
131 if (assetUrl._tag === "Failure" || (assetUrl._tag === "Success" && failedUrl === assetUrl.url)) {
132 return (
133 <div className="flex min-h-0 flex-1 items-center justify-center px-6 text-center text-xs leading-relaxed text-destructive">
134 Unable to load workspace image.
135 </div>
136 );
137 }
138
139 return assetUrl._tag === "Success" ? (
140 <div className="flex min-h-0 flex-1 items-center justify-center overflow-auto p-4">
141 <img
142 className="max-h-full max-w-full object-contain"
143 src={assetUrl.url}
144 alt={props.alt}
145 onError={() => setFailedUrl(assetUrl.url)}
146 />
147 </div>
148 ) : (
149 <div className="flex min-h-0 flex-1 items-center justify-center text-muted-foreground">
150 <LoaderCircle className="size-5 animate-spin" />
151 </div>
152 );
153}
154
116155function clampFileLine(contents: string, requestedLine: number): number {
117156 let lineCount = 1;
118157 for (let index = 0; index < contents.length; index += 1) {
......@@ -630,7 +669,8 @@ export default function FilePreviewPanel({
630669 const openPreview = useAtomCommand(previewEnvironment.open, {
631670 reportFailure: false,
632671 });
633 const file = useProjectFileQuery(environmentId, cwd, relativePath);
672 const isImage = relativePath !== null && isWorkspaceImagePreviewPath(relativePath);
673 const file = useProjectFileQuery(environmentId, cwd, relativePath, !isImage);
634674 const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen);
635675 const [markdownView, setMarkdownView] = useState<{
636676 path: string | null;
......@@ -818,7 +858,15 @@ export default function FilePreviewPanel({
818858 relativePath ? "flex" : "hidden",
819859 )}
820860 >
821 {relativePath && file.error && file.data === null ? (
861 {relativePath && isImage && absolutePath ? (
862 <WorkspaceImagePreview
863 key={absolutePath}
864 environmentId={environmentId}
865 threadRef={threadRef}
866 absolutePath={absolutePath}
867 alt={relativePath}
868 />
869 ) : relativePath && file.error && file.data === null ? (
822870 <div className="flex min-h-0 flex-1 items-center justify-center px-6 text-center text-xs leading-relaxed text-destructive">
823871 {file.error}
824872 </div>
apps/web/src/components/files/projectFilesQueryState.ts+8-2
......@@ -6,7 +6,7 @@ import type {
66} from "@t3tools/contracts";
77import * as Cause from "effect/Cause";
88import * as Option from "effect/Option";
9import { AsyncResult } from "effect/unstable/reactivity";
9import { AsyncResult, Atom } from "effect/unstable/reactivity";
1010import { useCallback } from "react";
1111
1212import { appAtomRegistry } from "~/rpc/atomRegistry";
......@@ -14,6 +14,9 @@ import { projectEnvironment } from "~/state/projects";
1414import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime";
1515
1616const EMPTY_PROJECT_FILE_PATH = "";
17const EMPTY_PROJECT_FILE_QUERY_ATOM = Atom.make(
18 AsyncResult.initial<ProjectReadFileResult, never>(false),
19).pipe(Atom.withLabel("project-file-query:empty"));
1720function optimisticFileAtom(environmentId: EnvironmentId, cwd: string, relativePath: string) {
1821 return projectEnvironment.optimisticFile({ environmentId, cwd, relativePath });
1922}
......@@ -137,8 +140,11 @@ export function useProjectFileQuery(
137140 environmentId: EnvironmentId,
138141 cwd: string,
139142 relativePath: string | null,
143 enabled = true,
140144): ProjectQueryState<ProjectReadFileResult> {
141 const atom = getProjectFileQueryAtom(environmentId, cwd, relativePath);
145 const atom = enabled
146 ? getProjectFileQueryAtom(environmentId, cwd, relativePath)
147 : EMPTY_PROJECT_FILE_QUERY_ATOM;
142148 const result = useAtomValue(atom);
143149 const refreshAtom = useAtomRefresh(atom);
144150 const refresh = useCallback(() => refreshAtom(), [refreshAtom]);