From dc88d9ff69b43581f05a060108d923f1bd55c16e Mon Sep 17 00:00:00 2001 From: hasak21 Date: Sun, 6 Sep 2026 12:26:02 +0800 Subject: [PATCH] fix(components): add download action to HTML preview surface (#321) --- .../sessions/session-file-content-view.tsx | 47 ++++++++++++--- .../tests/session-file-content-view.test.tsx | 58 +++++++++++++++++++ 2 files changed, 97 insertions(+), 8 deletions(-) diff --git a/packages/components/src/components/sessions/session-file-content-view.tsx b/packages/components/src/components/sessions/session-file-content-view.tsx index 10a3789df..ae1c8a1ba 100644 --- a/packages/components/src/components/sessions/session-file-content-view.tsx +++ b/packages/components/src/components/sessions/session-file-content-view.tsx @@ -1,6 +1,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { Copy, + Download, Eye, EyeClosed, Loader2, @@ -67,6 +68,7 @@ import { RecentLocalTextEchoTracker, } from '@/lib/code-collab-live-text-update'; import { getSessionFileMonacoLanguageId, isSessionMarkdownPath } from '@/lib/session-file-language'; +import { downloadBytesAsFile } from '@/lib/download-file'; import { useCodeCollabLiveText } from '@/hooks/use-code-collab-live-text'; import { useMachineFlockRows } from '@/hooks/use-machine-flock-rows'; import { @@ -934,6 +936,12 @@ function SessionFileContentViewImpl({ lastCopyMarkdownRequestSeqRef.current = copyMarkdownRequestSeq; void handleCopyMarkdown(); }, [copyMarkdownRequestSeq, data, handleCopyMarkdown, normalizedPath]); + const isHtmlFile = isHtmlPath(normalizedPath); + const handleDownloadHtml = useCallback(() => { + if (data.status !== 'ready' || data.snapshot.kind !== 'text') return; + const content = latestEditorTextRef.current ?? data.snapshot.text; + downloadBytesAsFile(normalizedPath, new TextEncoder().encode(content)); + }, [data, normalizedPath]); const saveViewState = useMemo( () => ({ dirty: isProviderEditorDirty, @@ -1005,6 +1013,16 @@ function SessionFileContentViewImpl({ shouldUseProviderFileContent, ]); + const handleReloadHtmlPreview = useCallback(() => { + setHtmlPreviewCommand((current) => ({ + id: (current?.id ?? 0) + 1, + action: 'reload', + })); + if (shouldUseProviderFileContent) { + handleProviderRefresh(); + } + }, [handleProviderRefresh, shouldUseProviderFileContent]); + // LSP entry points. Enabled whenever the provider supplies content // for this view (including read-only mode — read roles can // still request LSP per spec L651). The hook handles the RPC round @@ -1329,10 +1347,12 @@ function SessionFileContentViewImpl({ const showWordWrapButton = isTextFileReady && !showSvgRendered && !showMarkdownRendered && !showHtmlRendered; const showSaveButton = isProviderFileEditable && isTextFileReady; - const showRefreshButton = shouldUseProviderFileContent && isTextFileReady; + const showRefreshButton = + shouldUseProviderFileContent && isTextFileReady && !showHtmlRendered; const showViewerTopBar = showPreviewToggle || isMarkdownTextFile || + (isHtmlFile && isTextFileReady) || showSearchButton || showSaveButton || showRefreshButton; @@ -1407,17 +1427,28 @@ function SessionFileContentViewImpl({ {showHtmlRendered ? ( + ) : null} + {isHtmlFile && isTextFileReady ? ( + ) : null} {showWordWrapButton ? ( diff --git a/packages/components/tests/session-file-content-view.test.tsx b/packages/components/tests/session-file-content-view.test.tsx index 5dde42691..caad43ae3 100644 --- a/packages/components/tests/session-file-content-view.test.tsx +++ b/packages/components/tests/session-file-content-view.test.tsx @@ -51,6 +51,11 @@ vi.mock('../src/lib/clipboard', () => ({ writeTextToClipboard: vi.fn(async () => true), })); +const downloadBytesAsFile = vi.fn(); +vi.mock('../src/lib/download-file', () => ({ + downloadBytesAsFile: (...args: unknown[]) => downloadBytesAsFile(...args), +})); + const monacoMockState = vi.hoisted(() => ({ mountCount: 0, unmountCount: 0, @@ -1284,6 +1289,15 @@ describe('SessionFileContentView', () => { expect(view.querySelector('[data-testid="managed-html-preview"]')).not.toBeNull(); expect(view.querySelector('[data-testid="monaco-viewer"]')).toBeNull(); + expect(view.querySelector('button[aria-label="Reload"]')).not.toBeNull(); + expect(view.querySelector('button[aria-label="Refresh"]')).toBeNull(); + + const openFile = vi.spyOn(provider, 'openFile'); + await act(async () => { + view.querySelector('button[aria-label="Reload"]')?.click(); + }); + await flushMicrotasks(); + expect(openFile).toHaveBeenCalledWith('artifacts/result.html'); }); it('previews the latest editor text instead of the opened HTML snapshot', async () => { @@ -1341,6 +1355,50 @@ describe('SessionFileContentView', () => { ); }); + it('renders a download action in the toolbar and downloads HTML file content', async () => { + downloadBytesAsFile.mockClear(); + const provider = createFakeSessionFileProvider({ + files: [ + { + path: 'artifacts/result.html', + fileId: 't:result-html', + kind: 'text', + sourceState: 'live-readonly', + }, + ], + snapshots: { + 'artifacts/result.html': { kind: 'text', text: '

Hello

' }, + }, + }); + const view = await render( + createElement(SessionFileContentView, { + sessionId: session.id, + session, + filePath: 'artifacts/result.html', + fileId: 't:result-html', + fileProvider: provider, + fileProviderPending: false, + htmlPreviewRequestSeq: 1, + }) + ); + await flushMicrotasks(); + + const downloadButton = view.querySelector( + 'button[aria-label="Download file"]' + ); + expect(downloadButton).not.toBeNull(); + + await act(async () => { + downloadButton?.click(); + }); + + expect(downloadBytesAsFile).toHaveBeenCalledTimes(1); + expect(downloadBytesAsFile).toHaveBeenCalledWith( + 'artifacts/result.html', + new TextEncoder().encode('

Hello

') + ); + }); + it('returns to code mode when the keyed session target changes', async () => { const provider = createFakeSessionFileProvider({ files: [{ path: 'result.html', kind: 'text', sourceState: 'live-readonly' }],