Skip to content

Commit 620f726

Browse files
committed
web: Add loading and error states to the file viewer
Introduces a loading dialog to show immediate feedback while files are being retrieved or viewers are preparing. Displays helpful error dialogs with localized messages if loading fails or viewers crash. Also clears stale state during loads and correctly aborts in-flight fetch requests.
1 parent 775d2f9 commit 620f726

7 files changed

Lines changed: 321 additions & 50 deletions

File tree

web/messages/en.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,11 @@
261261
"error_try_again": "Try again",
262262
"error_unexpected": "Something went wrong",
263263
"error_unexpected_description": "An unexpected error occurred. Please try again.",
264+
"file_viewer_open_failed": "Unable to open file",
265+
"file_viewer_opening_file": "Opening file...",
266+
"file_viewer_opening_title": "Opening file",
267+
"file_viewer_prepare_failed": "Unable to prepare the file viewer.",
268+
"file_viewer_preparing_viewer": "Preparing viewer...",
264269
"filetree_check_project_path": "Project path may be inaccessible",
265270
"filetree_clear_search": "Clear search",
266271
"filetree_days_ago": "{count} days ago",

web/src/lib/components/files/FileViewerHost.svelte

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
import { Button } from '$lib/components/ui/button';
2929
import { getFileViewer } from '$lib/context';
3030
import { FileViewerHostState } from './file-viewer-host-state.svelte';
31+
import FileViewerLoadingDialog from './FileViewerLoadingDialog.svelte';
32+
import * as m from '$lib/paraglide/messages.js';
3133
3234
const viewer = getFileViewer();
3335
const host = new FileViewerHostState({
@@ -45,28 +47,71 @@
4547
if (!req) return;
4648
void host.openFromRequest(req);
4749
});
50+
51+
function getViewerLoadError(error: unknown): string {
52+
if (error instanceof Error && error.message) return error.message;
53+
return m.file_viewer_prepare_failed();
54+
}
4855
</script>
4956

50-
{#if host.session && host.file}
51-
{#if host.session.mode === 'image'}
52-
{#await loadImageViewer() then { default: ImageViewer }}
57+
{#if host.session}
58+
{#if host.loadError}
59+
<FileViewerLoadingDialog
60+
session={host.session}
61+
error={host.loadError}
62+
onClose={() => host.closeViewer()}
63+
/>
64+
{:else if host.loading || !host.file}
65+
<FileViewerLoadingDialog session={host.session} onClose={() => host.closeViewer()} />
66+
{:else if host.session.mode === 'image'}
67+
{#await loadImageViewer()}
68+
<FileViewerLoadingDialog
69+
session={host.session}
70+
message={m.file_viewer_preparing_viewer()}
71+
onClose={() => host.closeViewer()}
72+
/>
73+
{:then { default: ImageViewer }}
5374
<ImageViewer
5475
src={host.getImageUrl()}
5576
alt={host.file.name}
5677
onClose={() => host.closeViewer()}
5778
/>
79+
{:catch error}
80+
<FileViewerLoadingDialog
81+
session={host.session}
82+
error={getViewerLoadError(error)}
83+
onClose={() => host.closeViewer()}
84+
/>
5885
{/await}
5986
{:else if host.session.mode === 'markdown'}
60-
{#await loadMarkdownViewer() then { default: MarkdownViewer }}
87+
{#await loadMarkdownViewer()}
88+
<FileViewerLoadingDialog
89+
session={host.session}
90+
message={m.file_viewer_preparing_viewer()}
91+
onClose={() => host.closeViewer()}
92+
/>
93+
{:then { default: MarkdownViewer }}
6194
<MarkdownViewer
6295
filePath={host.file.path}
6396
content={host.file.content}
6497
onClose={() => host.closeViewer()}
6598
onEdit={() => host.switchToCodeView()}
6699
/>
100+
{:catch error}
101+
<FileViewerLoadingDialog
102+
session={host.session}
103+
error={getViewerLoadError(error)}
104+
onClose={() => host.closeViewer()}
105+
/>
67106
{/await}
68107
{:else}
69-
{#await loadFileEditorDialog() then { default: FileEditorDialog }}
108+
{#await loadFileEditorDialog()}
109+
<FileViewerLoadingDialog
110+
session={host.session}
111+
message={m.file_viewer_preparing_viewer()}
112+
onClose={() => host.closeViewer()}
113+
/>
114+
{:then { default: FileEditorDialog }}
70115
<FileEditorDialog
71116
file={host.toEditorFile()}
72117
onRequestClose={() => host.closeViewer()}
@@ -79,6 +124,12 @@
79124
showMarkdownViewButton={host.isCurrentFileMarkdownInCodeMode}
80125
onRequestMarkdownView={() => host.switchToMarkdownView()}
81126
/>
127+
{:catch error}
128+
<FileViewerLoadingDialog
129+
session={host.session}
130+
error={getViewerLoadError(error)}
131+
onClose={() => host.closeViewer()}
132+
/>
82133
{/await}
83134
{/if}
84135
{/if}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<script lang="ts">
2+
import * as Dialog from '$lib/components/ui/dialog';
3+
import { Button } from '$lib/components/ui/button';
4+
import type { ActiveFileViewerSession } from '$lib/stores/file-viewer.svelte';
5+
import Loader2 from '@lucide/svelte/icons/loader-2';
6+
import TriangleAlert from '@lucide/svelte/icons/triangle-alert';
7+
import X from '@lucide/svelte/icons/x';
8+
import * as m from '$lib/paraglide/messages.js';
9+
10+
interface Props {
11+
session: ActiveFileViewerSession;
12+
message?: string;
13+
error?: string | null;
14+
onClose: () => void;
15+
}
16+
17+
let { session, message, error = null, onClose }: Props = $props();
18+
19+
const fileName = $derived(session.relativePath.split('/').pop() ?? session.relativePath);
20+
const statusMessage = $derived(message ?? m.file_viewer_opening_file());
21+
const title = $derived(error ? m.file_viewer_open_failed() : m.file_viewer_opening_title());
22+
23+
function handleOpenChange(open: boolean): void {
24+
if (!open) onClose();
25+
}
26+
</script>
27+
28+
<Dialog.Root open={true} onOpenChange={handleOpenChange}>
29+
<Dialog.Content
30+
class="flex h-dvh w-full max-w-full flex-col gap-0 overflow-hidden rounded-none border-0 p-0 sm:h-[85vh] sm:max-w-5xl sm:rounded-lg sm:border"
31+
showCloseButton={false}
32+
>
33+
<div
34+
class="flex items-center justify-between border-b border-border bg-card p-3 text-foreground"
35+
>
36+
<div class="min-w-0">
37+
<Dialog.Title class="truncate text-base font-medium leading-normal">
38+
{fileName}
39+
</Dialog.Title>
40+
<Dialog.Description class="truncate text-xs text-muted-foreground">
41+
{session.relativePath}
42+
</Dialog.Description>
43+
</div>
44+
<Button
45+
variant="ghost"
46+
size="icon-sm"
47+
onclick={onClose}
48+
title={m.editor_actions_close()}
49+
aria-label={m.editor_actions_close()}
50+
>
51+
<X class="h-4 w-4" />
52+
</Button>
53+
</div>
54+
55+
<div class="flex flex-1 items-center justify-center bg-background p-6 text-foreground">
56+
<div class="max-w-md text-center" aria-live="polite">
57+
{#if error}
58+
<TriangleAlert class="mx-auto mb-3 h-8 w-8 text-destructive" />
59+
<p class="text-sm font-medium">{title}</p>
60+
<p class="mt-2 break-words text-sm text-muted-foreground">{error}</p>
61+
{:else}
62+
<Loader2 class="mx-auto mb-3 h-8 w-8 animate-spin text-interactive-accent" />
63+
<p class="text-sm font-medium">{statusMessage}</p>
64+
<p class="mt-2 truncate text-xs text-muted-foreground">{session.relativePath}</p>
65+
{/if}
66+
</div>
67+
</div>
68+
</Dialog.Content>
69+
</Dialog.Root>
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { render, screen } from '@testing-library/svelte';
2+
import { describe, expect, it, vi } from 'vitest';
3+
import * as filesApi from '$lib/api/files';
4+
import { FileViewerStore } from '$lib/stores/file-viewer.svelte';
5+
import FileViewerHostTestHost from './FileViewerHostTestHost.svelte';
6+
7+
vi.mock('$lib/api/files', () => ({
8+
readText: vi.fn(),
9+
saveText: vi.fn(),
10+
getContentUrl: vi.fn(),
11+
}));
12+
13+
describe('FileViewerHost', () => {
14+
it('renders a loading dialog immediately after a file open request', async () => {
15+
vi.mocked(filesApi.readText).mockReturnValue(new Promise(() => {}));
16+
const viewer = new FileViewerStore();
17+
18+
render(FileViewerHostTestHost, { viewer });
19+
viewer.openAuto({
20+
chatId: 'chat-1',
21+
fileRootPath: '/workspace',
22+
relativePath: 'src/app.ts',
23+
source: 'markdown-link',
24+
});
25+
26+
expect(await screen.findByText('Opening file...')).toBeTruthy();
27+
expect(screen.getByText('app.ts')).toBeTruthy();
28+
expect(screen.getAllByText('src/app.ts')).toHaveLength(2);
29+
});
30+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<script lang="ts">
2+
import FileViewerHost from '../FileViewerHost.svelte';
3+
import { setFileViewer } from '$lib/context';
4+
import type { FileViewerStore } from '$lib/stores/file-viewer.svelte';
5+
6+
interface Props {
7+
viewer: FileViewerStore;
8+
}
9+
10+
let { viewer }: Props = $props();
11+
12+
function currentViewer(): FileViewerStore {
13+
return viewer;
14+
}
15+
16+
setFileViewer({
17+
get pending() {
18+
return currentViewer().pending;
19+
},
20+
consumePending: () => currentViewer().consumePending(),
21+
} as never);
22+
</script>
23+
24+
<FileViewerHost />

0 commit comments

Comments
 (0)