fix(mobile): support file downloads and image previews

This commit is contained in:
Bohdan Triapitsyn
2026-08-13 23:44:36 +03:00
parent 0da89f3f88
commit 55fcd5092e
3 changed files with 48 additions and 53 deletions
+16 -51
View File
@@ -904,7 +904,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const [fileLoading, setFileLoading] = React.useState(false); const [fileLoading, setFileLoading] = React.useState(false);
const [fileError, setFileError] = React.useState<string | null>(null); const [fileError, setFileError] = React.useState<string | null>(null);
const [desktopImageSrc, setDesktopImageSrc] = React.useState<string>(''); const [desktopImageSrc, setDesktopImageSrc] = React.useState<string>('');
const desktopImageBlobUrlRef = React.useRef<string>('');
const [loadedFilePath, setLoadedFilePath] = React.useState<string | null>(null); const [loadedFilePath, setLoadedFilePath] = React.useState<string | null>(null);
@@ -3006,10 +3005,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
[lightTheme.metadata.id, darkTheme.metadata.id], [lightTheme.metadata.id, darkTheme.metadata.id],
); );
const imageAssetAuthKey = selectedFile?.path && isSelectedImage && !runtime.isDesktop && !isSelectedSvg
? `${selectedFile.path}|${selectedFileReadOptions.allowOutsideWorkspace ? 'outside' : 'workspace'}|${selectedFileReadOptions.outsideFileGrant ?? ''}`
: '';
const pdfAssetAuthKey = selectedFile?.path && isSelectedPdf const pdfAssetAuthKey = selectedFile?.path && isSelectedPdf
? `${selectedFile.path}|${selectedFileReadOptions.allowOutsideWorkspace ? 'outside' : 'workspace'}|${selectedFileReadOptions.outsideFileGrant ?? ''}` ? `${selectedFile.path}|${selectedFileReadOptions.allowOutsideWorkspace ? 'outside' : 'workspace'}|${selectedFileReadOptions.outsideFileGrant ?? ''}`
: ''; : '';
@@ -3019,30 +3014,18 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
: ''; : '';
const assetAuthErrorFallback = t('filesView.error.readFileFailed'); const assetAuthErrorFallback = t('filesView.error.readFileFailed');
const { readyKey: imageAssetAuthReadyKey, nonce: imagePreviewNonce } =
useAssetAuthRefresh(imageAssetAuthKey, setFileError, assetAuthErrorFallback);
const { readyKey: htmlAssetAuthReadyKey, nonce: htmlPreviewNonce } = const { readyKey: htmlAssetAuthReadyKey, nonce: htmlPreviewNonce } =
useAssetAuthRefresh(htmlAssetAuthKey, setFileError, assetAuthErrorFallback); useAssetAuthRefresh(htmlAssetAuthKey, setFileError, assetAuthErrorFallback);
const { readyKey: pdfAssetAuthReadyKey, nonce: pdfPreviewNonce } = const { readyKey: pdfAssetAuthReadyKey, nonce: pdfPreviewNonce } =
useAssetAuthRefresh(pdfAssetAuthKey, setFileError, assetAuthErrorFallback); useAssetAuthRefresh(pdfAssetAuthKey, setFileError, assetAuthErrorFallback);
const isImageAssetAuthLoading = Boolean(imageAssetAuthKey && imageAssetAuthReadyKey !== imageAssetAuthKey);
const isHtmlAssetAuthLoading = Boolean(htmlAssetAuthKey && htmlAssetAuthReadyKey !== htmlAssetAuthKey); const isHtmlAssetAuthLoading = Boolean(htmlAssetAuthKey && htmlAssetAuthReadyKey !== htmlAssetAuthKey);
const isPdfAssetAuthLoading = Boolean(pdfAssetAuthKey && pdfAssetAuthReadyKey !== pdfAssetAuthKey); const isPdfAssetAuthLoading = Boolean(pdfAssetAuthKey && pdfAssetAuthReadyKey !== pdfAssetAuthKey);
const imageSrc = selectedFile?.path && isSelectedImage const imageSrc = selectedFile?.path && isSelectedImage
? (runtime.isDesktop ? (isSelectedSvg
? (isSelectedSvg ? `data:${getImageMimeType(selectedFile.path)};utf8,${encodeURIComponent(fileContent)}`
? `data:${getImageMimeType(selectedFile.path)};utf8,${encodeURIComponent(fileContent)}` : desktopImageSrc)
: desktopImageSrc)
: (isSelectedSvg
? `data:${getImageMimeType(selectedFile.path)};utf8,${encodeURIComponent(fileContent)}`
: imageAssetAuthReadyKey === imageAssetAuthKey ? getRuntimeUrlResolver().authenticatedAsset('/api/fs/raw', {
path: selectedFile.path,
allowOutsideWorkspace: selectedFileReadOptions.allowOutsideWorkspace ? 'true' : undefined,
outsideFileGrant: selectedFileReadOptions.outsideFileGrant,
directory: root || undefined,
}) : ''))
: ''; : '';
const pdfSrc = selectedFile?.path && isSelectedPdf && pdfAssetAuthReadyKey === pdfAssetAuthKey const pdfSrc = selectedFile?.path && isSelectedPdf && pdfAssetAuthReadyKey === pdfAssetAuthKey
@@ -3067,24 +3050,16 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
React.useEffect(() => { React.useEffect(() => {
let cancelled = false; let cancelled = false;
let objectUrl = '';
const resolveDesktopImage = async () => { const resolveDesktopImage = async () => {
if (!runtime.isDesktop || !selectedFile?.path || !isSelectedImage || isSelectedSvg) { if (!selectedFile?.path || !isSelectedImage || isSelectedSvg) {
if (desktopImageBlobUrlRef.current) {
URL.revokeObjectURL(desktopImageBlobUrlRef.current);
desktopImageBlobUrlRef.current = '';
}
setDesktopImageSrc(''); setDesktopImageSrc('');
return; return;
} }
setFileError(null); setFileError(null);
if (desktopImageBlobUrlRef.current) {
URL.revokeObjectURL(desktopImageBlobUrlRef.current);
desktopImageBlobUrlRef.current = '';
}
const srcPromise = files.readFileBinary const srcPromise = files.readFileBinary
? files.readFileBinary(selectedFile.path, selectedFileReadOptions).then((result) => result.dataUrl) ? files.readFileBinary(selectedFile.path, selectedFileReadOptions).then((result) => result.dataUrl)
: (async () => { : (async () => {
@@ -3100,13 +3075,13 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
throw new Error(t('filesView.error.readFileFailed')); throw new Error(t('filesView.error.readFileFailed'));
} }
const blob = await response.blob(); const blob = await response.blob();
const url = URL.createObjectURL(blob); objectUrl = URL.createObjectURL(blob);
if (cancelled) { if (cancelled) {
URL.revokeObjectURL(url); URL.revokeObjectURL(objectUrl);
objectUrl = '';
return ''; return '';
} }
desktopImageBlobUrlRef.current = url; return objectUrl;
return url;
})(); })();
await srcPromise await srcPromise
@@ -3117,10 +3092,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
} }
}) })
.catch((error) => { .catch((error) => {
if (desktopImageBlobUrlRef.current) {
URL.revokeObjectURL(desktopImageBlobUrlRef.current);
desktopImageBlobUrlRef.current = '';
}
if (!cancelled) { if (!cancelled) {
setDesktopImageSrc(''); setDesktopImageSrc('');
setFileError(error instanceof Error ? error.message : t('filesView.error.readFileFailed')); setFileError(error instanceof Error ? error.message : t('filesView.error.readFileFailed'));
@@ -3138,17 +3109,11 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
return () => { return () => {
cancelled = true; cancelled = true;
}; if (objectUrl) {
}, [files, isSelectedImage, isSelectedSvg, root, runtime.isDesktop, selectedFile?.path, selectedFileReadOptions, t]); URL.revokeObjectURL(objectUrl);
React.useEffect(() => {
return () => {
if (desktopImageBlobUrlRef.current) {
URL.revokeObjectURL(desktopImageBlobUrlRef.current);
desktopImageBlobUrlRef.current = '';
} }
}; };
}, []); }, [files, isSelectedImage, isSelectedSvg, root, selectedFile?.path, selectedFileReadOptions, t]);
const handleCloseDialog = React.useCallback(() => setActiveDialog(null), []); const handleCloseDialog = React.useCallback(() => setActiveDialog(null), []);
@@ -3870,7 +3835,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
<ScrollableOverlay outerClassName="h-full min-w-0" className="h-full min-w-0"> <ScrollableOverlay outerClassName="h-full min-w-0" className="h-full min-w-0">
{!selectedFile ? ( {!selectedFile ? (
<div className="p-3 typography-ui text-muted-foreground">{t('filesView.editor.pickFileFromTree')}</div> <div className="p-3 typography-ui text-muted-foreground">{t('filesView.editor.pickFileFromTree')}</div>
) : (fileLoading || isImageAssetAuthLoading || isPdfAssetAuthLoading) ? ( ) : (fileLoading || isPdfAssetAuthLoading) ? (
suppressFileLoadingIndicator suppressFileLoadingIndicator
? <div className="p-3" /> ? <div className="p-3" />
: ( : (
@@ -3884,7 +3849,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
) : isSelectedImage ? ( ) : isSelectedImage ? (
<div className="flex h-full items-center justify-center p-3"> <div className="flex h-full items-center justify-center p-3">
<img <img
key={imagePreviewNonce} key={selectedFile.path}
src={imageSrc} src={imageSrc}
alt={selectedFile?.name ?? t('filesView.editor.imageAltFallback')} alt={selectedFile?.name ?? t('filesView.editor.imageAltFallback')}
className="max-w-full max-h-[70vh] object-contain rounded-md border border-border/30 bg-primary/10" className="max-w-full max-h-[70vh] object-contain rounded-md border border-border/30 bg-primary/10"
@@ -4264,7 +4229,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
{renderFloatingFileControls({ exitFullscreenOnly: true })} {renderFloatingFileControls({ exitFullscreenOnly: true })}
</div> </div>
<ScrollableOverlay outerClassName="h-full min-w-0" className="h-full min-w-0"> <ScrollableOverlay outerClassName="h-full min-w-0" className="h-full min-w-0">
{(fileLoading || isImageAssetAuthLoading || isPdfAssetAuthLoading) ? ( {(fileLoading || isPdfAssetAuthLoading) ? (
suppressFileLoadingIndicator suppressFileLoadingIndicator
? <div className="p-4" /> ? <div className="p-4" />
: ( : (
@@ -4278,7 +4243,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
) : isSelectedImage ? ( ) : isSelectedImage ? (
<div className="flex h-full items-center justify-center p-4"> <div className="flex h-full items-center justify-center p-4">
<img <img
key={imagePreviewNonce} key={selectedFile.path}
src={imageSrc} src={imageSrc}
alt={selectedFile.name} alt={selectedFile.name}
className="max-w-full max-h-full object-contain rounded-md border border-border/30 bg-primary/10" className="max-w-full max-h-full object-contain rounded-md border border-border/30 bg-primary/10"
+21 -1
View File
@@ -1,9 +1,13 @@
import { describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import type { RuntimeUrlQuery, RuntimeUrlResolver } from '@openchamber/ui/lib/runtime-url'; import type { RuntimeUrlQuery, RuntimeUrlResolver } from '@openchamber/ui/lib/runtime-url';
const runtimeFetchMock = vi.fn(); const runtimeFetchMock = vi.fn();
afterEach(() => {
vi.unstubAllGlobals();
});
vi.mock('@openchamber/ui/lib/runtime-fetch', () => ({ vi.mock('@openchamber/ui/lib/runtime-fetch', () => ({
runtimeFetch: runtimeFetchMock, runtimeFetch: runtimeFetchMock,
})); }));
@@ -87,4 +91,20 @@ describe('createWebFilesAPI', () => {
headers: { 'x-opencode-directory': '/current-workspace' }, headers: { 'x-opencode-directory': '/current-workspace' },
}); });
}); });
it('opens the native share sheet for downloads in the Capacitor app', async () => {
const { createWebFilesAPI } = await import('./files');
const api = createWebFilesAPI({ urls, getDirectory: () => '/workspace' });
const share = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal('window', globalThis);
vi.stubGlobal('navigator', {});
Object.defineProperty(window, 'Capacitor', { configurable: true, value: { isNativePlatform: () => true } });
Object.defineProperty(navigator, 'canShare', { configurable: true, value: () => true });
Object.defineProperty(navigator, 'share', { configurable: true, value: share });
runtimeFetchMock.mockResolvedValueOnce(new Response('hello', { headers: { 'Content-Type': 'text/plain' } }));
await api.downloadFile?.('/workspace/hello.txt');
expect(share).toHaveBeenCalledWith({ files: [expect.objectContaining({ name: 'hello.txt', type: 'text/plain' })] });
});
}); });
+11 -1
View File
@@ -285,10 +285,20 @@ export const createWebFilesAPI = ({ getDirectory }: WebFilesAPIOptions): FilesAP
} }
const blob = await response.blob(); const blob = await response.blob();
const filename = target.split('/').pop() || 'file';
const capacitor = (window as typeof window & {
Capacitor?: { isNativePlatform?: () => boolean };
}).Capacitor;
const file = new File([blob], filename, { type: blob.type || 'application/octet-stream' });
if (capacitor?.isNativePlatform?.() === true && navigator.canShare?.({ files: [file] })) {
await navigator.share({ files: [file] });
return;
}
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = target.split('/').pop() || 'file'; a.download = filename;
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);