Skip to content

Commit 0936fd2

Browse files
fix(web): preview workspace images in the file panel (pingdotgg#3996)
Co-authored-by: Rhiz3K <rhiz3k@protonmail.com> Co-authored-by: Julius Marminge <julius0216@outlook.com>
1 parent fa69f05 commit 0936fd2

3 files changed

Lines changed: 80 additions & 6 deletions

File tree

apps/web/src/assets/assetUrls.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,38 @@ import { usePreparedConnection } from "~/state/session";
99

1010
export { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
1111

12-
export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResource): string | null {
12+
export type AssetUrlState =
13+
| { readonly _tag: "Loading" }
14+
| { readonly _tag: "Failure" }
15+
| { readonly _tag: "Success"; readonly url: string };
16+
17+
export function useAssetUrlState(
18+
environmentId: EnvironmentId,
19+
resource: AssetResource,
20+
): AssetUrlState {
1321
const preparedConnection = usePreparedConnection(environmentId);
1422
const result = useAtomValue(
1523
assetEnvironment.createUrl({
1624
environmentId,
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+
38+
export 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

2646
export function useAssetUrls(

apps/web/src/components/files/FilePreviewPanel.tsx

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
ResolvedKeybindingsConfig,
55
ScopedThreadRef,
66
} from "@t3tools/contracts";
7+
import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview";
78
import { VirtualizedFile, type SelectedLineRange } from "@pierre/diffs";
89
import { Editor } from "@pierre/diffs/editor";
910
import { EditorProvider, File, type FileOptions, Virtualizer } from "@pierre/diffs/react";
@@ -16,6 +17,7 @@ import * as Schema from "effect/Schema";
1617
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1718

1819
import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview";
20+
import { useAssetUrlState } from "~/assets/assetUrls";
1921
import ChatMarkdown from "~/components/ChatMarkdown";
2022
import { OpenInPicker } from "~/components/chat/OpenInPicker";
2123
import { useClientSettings } from "~/hooks/useSettings";
@@ -113,6 +115,43 @@ const FILE_LINK_REVEAL_UNSAFE_CSS = `
113115
`;
114116
type FilePostRender = NonNullable<FileOptions<unknown>["onPostRender"]>;
115117

118+
function 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+
116155
function 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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,17 @@ import type {
66
} from "@t3tools/contracts";
77
import * as Cause from "effect/Cause";
88
import * as Option from "effect/Option";
9-
import { AsyncResult } from "effect/unstable/reactivity";
9+
import { AsyncResult, Atom } from "effect/unstable/reactivity";
1010
import { useCallback } from "react";
1111

1212
import { appAtomRegistry } from "~/rpc/atomRegistry";
1313
import { projectEnvironment } from "~/state/projects";
1414
import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime";
1515

1616
const EMPTY_PROJECT_FILE_PATH = "";
17+
const EMPTY_PROJECT_FILE_QUERY_ATOM = Atom.make(
18+
AsyncResult.initial<ProjectReadFileResult, never>(false),
19+
).pipe(Atom.withLabel("project-file-query:empty"));
1720
function 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]);

0 commit comments

Comments
 (0)