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 [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"
|
||||||
|
|||||||
@@ -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' })] });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user