Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import {
Copy,
Download,
Eye,
EyeClosed,
Loader2,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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));
Comment on lines +940 to +943

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Gate HTML downloads through the shared action model

For a same-machine Electron HTML file with a resolvable host path, this unconditional handler bypasses sessionFileActions.download even though that action is intentionally null; it therefore exposes the forbidden download alternative and, when a local viewer read has truncated: true, saves the partial preview bytes as though they were the complete file. Invoke and gate the toolbar control through sessionFileActions.download so local files retain open/reveal actions and remote downloads retain the centralized size/error handling.

AGENTS.md reference: packages/components/src/components/sessions/AGENTS.md:L865-L887

Useful? React with 👍 / 👎.

}, [data, normalizedPath]);
const saveViewState = useMemo<SessionFileSaveViewState>(
() => ({
dirty: isProviderEditorDirty,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1407,17 +1427,28 @@ function SessionFileContentViewImpl({
{showHtmlRendered ? (
<button
type="button"
onClick={() =>
setHtmlPreviewCommand((current) => ({
id: (current?.id ?? 0) + 1,
action: 'reload',
}))
}
onClick={handleReloadHtmlPreview}
disabled={isRefreshing}
title={t('sessions.browser.reload', 'Reload')}
aria-label={t('sessions.browser.reload', 'Reload')}
aria-busy={isRefreshing}
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
>
<RefreshCw
className={cn('h-3.5 w-3.5', isRefreshing && 'animate-spin')}
aria-hidden="true"
/>
</button>
) : null}
{isHtmlFile && isTextFileReady ? (
<button
type="button"
onClick={handleDownloadHtml}
title={t('sessions.fileActions.download', 'Download file')}
aria-label={t('sessions.fileActions.download', 'Download file')}
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
<Download className="h-3.5 w-3.5" aria-hidden="true" />
</button>
) : null}
{showWordWrapButton ? (
Expand Down
58 changes: 58 additions & 0 deletions packages/components/tests/session-file-content-view.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<HTMLButtonElement>('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 () => {
Expand Down Expand Up @@ -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: '<!doctype html><h1>Hello</h1>' },
},
});
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<HTMLButtonElement>(
'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('<!doctype html><h1>Hello</h1>')
);
});

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