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 [fileError, setFileError] = React.useState<string | null>(null);
const [desktopImageSrc, setDesktopImageSrc] = React.useState<string>('');
const desktopImageBlobUrlRef = React.useRef<string>('');
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],
);
const imageAssetAuthKey = selectedFile?.path && isSelectedImage && !runtime.isDesktop && !isSelectedSvg
? `${selectedFile.path}|${selectedFileReadOptions.allowOutsideWorkspace ? 'outside' : 'workspace'}|${selectedFileReadOptions.outsideFileGrant ?? ''}`
: '';
const pdfAssetAuthKey = selectedFile?.path && isSelectedPdf
? `${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 { readyKey: imageAssetAuthReadyKey, nonce: imagePreviewNonce } =
useAssetAuthRefresh(imageAssetAuthKey, setFileError, assetAuthErrorFallback);
const { readyKey: htmlAssetAuthReadyKey, nonce: htmlPreviewNonce } =
useAssetAuthRefresh(htmlAssetAuthKey, setFileError, assetAuthErrorFallback);
const { readyKey: pdfAssetAuthReadyKey, nonce: pdfPreviewNonce } =
useAssetAuthRefresh(pdfAssetAuthKey, setFileError, assetAuthErrorFallback);
const isImageAssetAuthLoading = Boolean(imageAssetAuthKey && imageAssetAuthReadyKey !== imageAssetAuthKey);
const isHtmlAssetAuthLoading = Boolean(htmlAssetAuthKey && htmlAssetAuthReadyKey !== htmlAssetAuthKey);
const isPdfAssetAuthLoading = Boolean(pdfAssetAuthKey && pdfAssetAuthReadyKey !== pdfAssetAuthKey);
const imageSrc = selectedFile?.path && isSelectedImage
? (runtime.isDesktop
? (isSelectedSvg
? `data:${getImageMimeType(selectedFile.path)};utf8,${encodeURIComponent(fileContent)}`
: 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,
}) : ''))
? (isSelectedSvg
? `data:${getImageMimeType(selectedFile.path)};utf8,${encodeURIComponent(fileContent)}`
: desktopImageSrc)
: '';
const pdfSrc = selectedFile?.path && isSelectedPdf && pdfAssetAuthReadyKey === pdfAssetAuthKey
@@ -3067,24 +3050,16 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
React.useEffect(() => {
let cancelled = false;
let objectUrl = '';
const resolveDesktopImage = async () => {
if (!runtime.isDesktop || !selectedFile?.path || !isSelectedImage || isSelectedSvg) {
if (desktopImageBlobUrlRef.current) {
URL.revokeObjectURL(desktopImageBlobUrlRef.current);
desktopImageBlobUrlRef.current = '';
}
if (!selectedFile?.path || !isSelectedImage || isSelectedSvg) {
setDesktopImageSrc('');
return;
}
setFileError(null);
if (desktopImageBlobUrlRef.current) {
URL.revokeObjectURL(desktopImageBlobUrlRef.current);
desktopImageBlobUrlRef.current = '';
}
const srcPromise = files.readFileBinary
? files.readFileBinary(selectedFile.path, selectedFileReadOptions).then((result) => result.dataUrl)
: (async () => {
@@ -3100,13 +3075,13 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
throw new Error(t('filesView.error.readFileFailed'));
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
objectUrl = URL.createObjectURL(blob);
if (cancelled) {
URL.revokeObjectURL(url);
URL.revokeObjectURL(objectUrl);
objectUrl = '';
return '';
}
desktopImageBlobUrlRef.current = url;
return url;
return objectUrl;
})();
await srcPromise
@@ -3117,10 +3092,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
}
})
.catch((error) => {
if (desktopImageBlobUrlRef.current) {
URL.revokeObjectURL(desktopImageBlobUrlRef.current);
desktopImageBlobUrlRef.current = '';
}
if (!cancelled) {
setDesktopImageSrc('');
setFileError(error instanceof Error ? error.message : t('filesView.error.readFileFailed'));
@@ -3138,17 +3109,11 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
return () => {
cancelled = true;
};
}, [files, isSelectedImage, isSelectedSvg, root, runtime.isDesktop, selectedFile?.path, selectedFileReadOptions, t]);
React.useEffect(() => {
return () => {
if (desktopImageBlobUrlRef.current) {
URL.revokeObjectURL(desktopImageBlobUrlRef.current);
desktopImageBlobUrlRef.current = '';
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, []);
}, [files, isSelectedImage, isSelectedSvg, root, selectedFile?.path, selectedFileReadOptions, t]);
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">
{!selectedFile ? (
<div className="p-3 typography-ui text-muted-foreground">{t('filesView.editor.pickFileFromTree')}</div>
) : (fileLoading || isImageAssetAuthLoading || isPdfAssetAuthLoading) ? (
) : (fileLoading || isPdfAssetAuthLoading) ? (
suppressFileLoadingIndicator
? <div className="p-3" />
: (
@@ -3884,7 +3849,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
) : isSelectedImage ? (
<div className="flex h-full items-center justify-center p-3">
<img
key={imagePreviewNonce}
key={selectedFile.path}
src={imageSrc}
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"
@@ -4264,7 +4229,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
{renderFloatingFileControls({ exitFullscreenOnly: true })}
</div>
<ScrollableOverlay outerClassName="h-full min-w-0" className="h-full min-w-0">
{(fileLoading || isImageAssetAuthLoading || isPdfAssetAuthLoading) ? (
{(fileLoading || isPdfAssetAuthLoading) ? (
suppressFileLoadingIndicator
? <div className="p-4" />
: (
@@ -4278,7 +4243,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
) : isSelectedImage ? (
<div className="flex h-full items-center justify-center p-4">
<img
key={imagePreviewNonce}
key={selectedFile.path}
src={imageSrc}
alt={selectedFile.name}
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';
const runtimeFetchMock = vi.fn();
afterEach(() => {
vi.unstubAllGlobals();
});
vi.mock('@openchamber/ui/lib/runtime-fetch', () => ({
runtimeFetch: runtimeFetchMock,
}));
@@ -87,4 +91,20 @@ describe('createWebFilesAPI', () => {
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 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 a = document.createElement('a');
a.href = url;
a.download = target.split('/').pop() || 'file';
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);