Skip to content

Commit badd362

Browse files
refactor(web): keep code-preview header/toolbar stable during load
Render the path header, separator, and file toolbar (blame toggle + age legend) immediately from props, and scope the loading spinner / error to the body below. The line-count/size stat shows a skeleton while the file source is pending, then the real value once loaded. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 98aa8a7 commit badd362

1 file changed

Lines changed: 114 additions & 80 deletions

File tree

packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanelClient.tsx

Lines changed: 114 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { getFileBlame, getFileSource } from "@/app/api/(client)/client";
44
import { PathHeader } from "@/app/(app)/components/pathHeader";
55
import { Button } from "@/components/ui/button";
66
import { Separator } from "@/components/ui/separator";
7+
import { Skeleton } from "@/components/ui/skeleton";
78
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
89
import { cn, getCodeHostInfoForRepo, isServiceError, truncateSha } from "@/lib/utils";
910
import { useQuery } from "@tanstack/react-query";
@@ -57,41 +58,119 @@ export const CodePreviewPanelClient = ({ path, repoName, revisionName, previewRe
5758
enabled: !!blame,
5859
});
5960

60-
if (isFileSourcePending || (blame && isBlamePending)) {
61-
return (
62-
<div className="flex flex-col w-full min-h-full items-center justify-center">
63-
<Loader2 className="w-4 h-4 animate-spin" />
64-
Loading...
65-
</div>
66-
);
67-
}
68-
69-
if (!fileSourceResponse || isServiceError(fileSourceResponse)) {
70-
return <div>Error loading file source: {isServiceError(fileSourceResponse) ? fileSourceResponse.message : 'No response received'}</div>
71-
}
72-
73-
if (blameResponse !== undefined && isServiceError(blameResponse)) {
74-
return <div>Error loading blame: {blameResponse.message}</div>
75-
}
76-
77-
const source = fileSourceResponse.source;
78-
const lineCount = source.length === 0
79-
? 0
80-
: source.split('\n').length - (source.endsWith('\n') ? 1 : 0);
81-
const byteSize = new TextEncoder().encode(source).length;
82-
const fileSize = formatFileSize(byteSize);
83-
8461
const codeHostInfo = getCodeHostInfoForRepo({
8562
codeHostType: repo.codeHostType,
8663
name: repo.name,
8764
displayName: repo.displayName,
8865
externalWebUrl: repo.externalWebUrl,
8966
});
9067

91-
// @todo: this is a hack to support linking to files for ADO. ADO doesn't support web urls with HEAD so we replace it with main. THis
68+
const loadedFile = fileSourceResponse && !isServiceError(fileSourceResponse) ? fileSourceResponse : undefined;
69+
70+
// @todo: this is a hack to support linking to files for ADO. ADO doesn't support web urls with HEAD so we replace it with main. This
9271
// will break if the default branch is not main.
93-
const fileWebUrl = repo.codeHostType === "azuredevops" && fileSourceResponse.externalWebUrl ?
94-
fileSourceResponse.externalWebUrl.replace("version=GBHEAD", "version=GBmain") : fileSourceResponse.externalWebUrl;
72+
const fileWebUrl = loadedFile
73+
? (repo.codeHostType === "azuredevops" && loadedFile.externalWebUrl
74+
? loadedFile.externalWebUrl.replace("version=GBHEAD", "version=GBmain")
75+
: loadedFile.externalWebUrl)
76+
: undefined;
77+
78+
// Line/size stats are derived from the fetched file, so they only appear
79+
// once it has loaded. The surrounding toolbar (blame toggle) does not wait.
80+
const lineCount = loadedFile === undefined
81+
? undefined
82+
: loadedFile.source.length === 0
83+
? 0
84+
: loadedFile.source.split('\n').length - (loadedFile.source.endsWith('\n') ? 1 : 0);
85+
const fileSize = loadedFile === undefined
86+
? undefined
87+
: formatFileSize(new TextEncoder().encode(loadedFile.source).length);
88+
89+
// The path header and separator are derived entirely from props, so they
90+
// render immediately. Only the body below depends on the fetched source,
91+
// so the loading / error states live here rather than replacing the whole
92+
// panel.
93+
const renderBody = () => {
94+
if (isFileSourcePending || (blame && isBlamePending)) {
95+
return (
96+
<div className="flex flex-1 flex-col w-full items-center justify-center">
97+
<Loader2 className="w-4 h-4 animate-spin" />
98+
Loading...
99+
</div>
100+
);
101+
}
102+
103+
if (!fileSourceResponse || isServiceError(fileSourceResponse)) {
104+
return (
105+
<div className="p-4 text-sm text-destructive">
106+
Error loading file source: {isServiceError(fileSourceResponse) ? fileSourceResponse.message : 'No response received'}
107+
</div>
108+
);
109+
}
110+
111+
if (blameResponse !== undefined && isServiceError(blameResponse)) {
112+
return (
113+
<div className="p-4 text-sm text-destructive">
114+
Error loading blame: {blameResponse.message}
115+
</div>
116+
);
117+
}
118+
119+
return (
120+
<>
121+
{previewRef && (
122+
<div className="flex flex-row items-center justify-between gap-2 px-4 py-2 border-b shrink-0">
123+
<span className="text-sm">
124+
Previewing file at revision{" "}
125+
<Link
126+
href={getBrowsePath({
127+
repoName,
128+
revisionName,
129+
path: '',
130+
pathType: 'commit',
131+
commitSha: previewRef,
132+
})}
133+
className="font-mono text-link hover:underline"
134+
>
135+
{truncateSha(previewRef)}
136+
</Link>
137+
</span>
138+
<Tooltip key={previewRef}>
139+
<TooltipTrigger>
140+
<Button
141+
asChild
142+
variant="ghost"
143+
size="icon"
144+
className="h-6 w-6 text-muted-foreground"
145+
>
146+
<Link
147+
href={getBrowsePath({
148+
repoName,
149+
revisionName,
150+
path,
151+
pathType: 'blob',
152+
})}
153+
aria-label="Close preview"
154+
>
155+
<X className="h-4 w-4" />
156+
</Link>
157+
</Button>
158+
</TooltipTrigger>
159+
<TooltipContent>Close preview</TooltipContent>
160+
</Tooltip>
161+
</div>
162+
)}
163+
<PureCodePreviewPanel
164+
source={fileSourceResponse.source}
165+
language={fileSourceResponse.language}
166+
repoName={repoName}
167+
path={path}
168+
revisionName={contentRef ?? 'HEAD'}
169+
blame={blame ? blameResponse : undefined}
170+
/>
171+
</>
172+
);
173+
};
95174

96175
return (
97176
<>
@@ -128,9 +207,13 @@ export const CodePreviewPanelClient = ({ path, repoName, revisionName, previewRe
128207
path={path}
129208
blame={blame ?? false}
130209
/>
131-
<span className="text-sm text-muted-foreground">
132-
{lineCount.toLocaleString()} lines · {fileSize}
133-
</span>
210+
{isFileSourcePending ? (
211+
<Skeleton className="h-4 w-32" />
212+
) : lineCount !== undefined ? (
213+
<span className="text-sm text-muted-foreground">
214+
{lineCount.toLocaleString()} lines · {fileSize}
215+
</span>
216+
) : null}
134217
{blame && (
135218
<>
136219
<Separator orientation="vertical" className="h-4" />
@@ -139,56 +222,7 @@ export const CodePreviewPanelClient = ({ path, repoName, revisionName, previewRe
139222
)}
140223
</div>
141224
)}
142-
{previewRef && (
143-
<div className="flex flex-row items-center justify-between gap-2 px-4 py-2 border-b shrink-0">
144-
<span className="text-sm">
145-
Previewing file at revision{" "}
146-
<Link
147-
href={getBrowsePath({
148-
repoName,
149-
revisionName,
150-
path: '',
151-
pathType: 'commit',
152-
commitSha: previewRef,
153-
})}
154-
className="font-mono text-link hover:underline"
155-
>
156-
{truncateSha(previewRef)}
157-
</Link>
158-
</span>
159-
<Tooltip key={previewRef}>
160-
<TooltipTrigger>
161-
<Button
162-
asChild
163-
variant="ghost"
164-
size="icon"
165-
className="h-6 w-6 text-muted-foreground"
166-
>
167-
<Link
168-
href={getBrowsePath({
169-
repoName,
170-
revisionName,
171-
path,
172-
pathType: 'blob',
173-
})}
174-
aria-label="Close preview"
175-
>
176-
<X className="h-4 w-4" />
177-
</Link>
178-
</Button>
179-
</TooltipTrigger>
180-
<TooltipContent>Close preview</TooltipContent>
181-
</Tooltip>
182-
</div>
183-
)}
184-
<PureCodePreviewPanel
185-
source={fileSourceResponse.source}
186-
language={fileSourceResponse.language}
187-
repoName={repoName}
188-
path={path}
189-
revisionName={contentRef ?? 'HEAD'}
190-
blame={blame ? blameResponse : undefined}
191-
/>
225+
{renderBody()}
192226
</>
193227
)
194228
}

0 commit comments

Comments
 (0)