fix(mobile): support file downloads and image previews
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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' })] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user