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";...@@ -9,7 +9,15 @@ import { usePreparedConnection } from "~/state/session";
99
10export { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";10export { 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 {
13 const preparedConnection = usePreparedConnection(environmentId);21 const preparedConnection = usePreparedConnection(environmentId);
14 const result = useAtomValue(22 const result = useAtomValue(
15 assetEnvironment.createUrl({23 assetEnvironment.createUrl({
...@@ -17,10 +25,22 @@ export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResourc...@@ -17,10 +25,22 @@ export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResourc
17 input: { resource },25 input: { resource },
18 }),26 }),
19 );27 );
28 if (result._tag === "Failure") {
29 return { _tag: "Failure" };
30 }
20 if (preparedConnection._tag === "None" || result._tag !== "Success") {31 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") {
21 return null;41 return null;
22 }42 }
23 return resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl);43 return result.url;
24}44}
2545
26export function useAssetUrls(46export function useAssetUrls(
apps/web/src/components/files/FilePreviewPanel.tsx+50-2
...@@ -4,6 +4,7 @@ import type {...@@ -4,6 +4,7 @@ import type {
4 ResolvedKeybindingsConfig,4 ResolvedKeybindingsConfig,
5 ScopedThreadRef,5 ScopedThreadRef,
6} from "@t3tools/contracts";6} from "@t3tools/contracts";
7import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview";
7import { VirtualizedFile, type SelectedLineRange } from "@pierre/diffs";8import { VirtualizedFile, type SelectedLineRange } from "@pierre/diffs";
8import { Editor } from "@pierre/diffs/editor";9import { Editor } from "@pierre/diffs/editor";
9import { EditorProvider, File, type FileOptions, Virtualizer } from "@pierre/diffs/react";10import { EditorProvider, File, type FileOptions, Virtualizer } from "@pierre/diffs/react";
...@@ -16,6 +17,7 @@ import * as Schema from "effect/Schema";...@@ -16,6 +17,7 @@ import * as Schema from "effect/Schema";
16import { useCallback, useEffect, useMemo, useRef, useState } from "react";17import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1718
18import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview";19import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview";
20import { useAssetUrlState } from "~/assets/assetUrls";
19import ChatMarkdown from "~/components/ChatMarkdown";21import ChatMarkdown from "~/components/ChatMarkdown";
20import { OpenInPicker } from "~/components/chat/OpenInPicker";22import { OpenInPicker } from "~/components/chat/OpenInPicker";
21import { useClientSettings } from "~/hooks/useSettings";23import { useClientSettings } from "~/hooks/useSettings";
...@@ -113,6 +115,43 @@ const FILE_LINK_REVEAL_UNSAFE_CSS = `...@@ -113,6 +115,43 @@ const FILE_LINK_REVEAL_UNSAFE_CSS = `
113`;115`;
114type FilePostRender = NonNullable<FileOptions<unknown>["onPostRender"]>;116type 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
116function clampFileLine(contents: string, requestedLine: number): number {155function clampFileLine(contents: string, requestedLine: number): number {
117 let lineCount = 1;156 let lineCount = 1;
118 for (let index = 0; index < contents.length; index += 1) {157 for (let index = 0; index < contents.length; index += 1) {
...@@ -630,7 +669,8 @@ export default function FilePreviewPanel({...@@ -630,7 +669,8 @@ export default function FilePreviewPanel({
630 const openPreview = useAtomCommand(previewEnvironment.open, {669 const openPreview = useAtomCommand(previewEnvironment.open, {
631 reportFailure: false,670 reportFailure: false,
632 });671 });
633 const file = useProjectFileQuery(environmentId, cwd, relativePath);672 const isImage = relativePath !== null && isWorkspaceImagePreviewPath(relativePath);
673 const file = useProjectFileQuery(environmentId, cwd, relativePath, !isImage);
634 const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen);674 const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen);
635 const [markdownView, setMarkdownView] = useState<{675 const [markdownView, setMarkdownView] = useState<{
636 path: string | null;676 path: string | null;
...@@ -818,7 +858,15 @@ export default function FilePreviewPanel({...@@ -818,7 +858,15 @@ export default function FilePreviewPanel({
818 relativePath ? "flex" : "hidden",858 relativePath ? "flex" : "hidden",
819 )}859 )}
820 >860 >
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 ? (
822 <div className="flex min-h-0 flex-1 items-center justify-center px-6 text-center text-xs leading-relaxed text-destructive">870 <div className="flex min-h-0 flex-1 items-center justify-center px-6 text-center text-xs leading-relaxed text-destructive">
823 {file.error}871 {file.error}
824 </div>872 </div>
apps/web/src/components/files/projectFilesQueryState.ts+8-2
...@@ -6,7 +6,7 @@ import type {...@@ -6,7 +6,7 @@ import type {
6} from "@t3tools/contracts";6} from "@t3tools/contracts";
7import * as Cause from "effect/Cause";7import * as Cause from "effect/Cause";
8import * as Option from "effect/Option";8import * as Option from "effect/Option";
9import { AsyncResult } from "effect/unstable/reactivity";9import { AsyncResult, Atom } from "effect/unstable/reactivity";
10import { useCallback } from "react";10import { useCallback } from "react";
1111
12import { appAtomRegistry } from "~/rpc/atomRegistry";12import { appAtomRegistry } from "~/rpc/atomRegistry";
...@@ -14,6 +14,9 @@ import { projectEnvironment } from "~/state/projects";...@@ -14,6 +14,9 @@ import { projectEnvironment } from "~/state/projects";
14import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime";14import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime";
1515
16const EMPTY_PROJECT_FILE_PATH = "";16const 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"));
17function optimisticFileAtom(environmentId: EnvironmentId, cwd: string, relativePath: string) {20function optimisticFileAtom(environmentId: EnvironmentId, cwd: string, relativePath: string) {
18 return projectEnvironment.optimisticFile({ environmentId, cwd, relativePath });21 return projectEnvironment.optimisticFile({ environmentId, cwd, relativePath });
19}22}
...@@ -137,8 +140,11 @@ export function useProjectFileQuery(...@@ -137,8 +140,11 @@ export function useProjectFileQuery(
137 environmentId: EnvironmentId,140 environmentId: EnvironmentId,
138 cwd: string,141 cwd: string,
139 relativePath: string | null,142 relativePath: string | null,
143 enabled = true,
140): ProjectQueryState<ProjectReadFileResult> {144): ProjectQueryState<ProjectReadFileResult> {
141 const atom = getProjectFileQueryAtom(environmentId, cwd, relativePath);145 const atom = enabled
146 ? getProjectFileQueryAtom(environmentId, cwd, relativePath)
147 : EMPTY_PROJECT_FILE_QUERY_ATOM;
142 const result = useAtomValue(atom);148 const result = useAtomValue(atom);
143 const refreshAtom = useAtomRefresh(atom);149 const refreshAtom = useAtomRefresh(atom);
144 const refresh = useCallback(() => refreshAtom(), [refreshAtom]);150 const refresh = useCallback(() => refreshAtom(), [refreshAtom]);