diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index 5e75f6d7..6452099d 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -904,7 +904,6 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { const [fileLoading, setFileLoading] = React.useState(false); const [fileError, setFileError] = React.useState(null); const [desktopImageSrc, setDesktopImageSrc] = React.useState(''); - const desktopImageBlobUrlRef = React.useRef(''); const [loadedFilePath, setLoadedFilePath] = React.useState(null); @@ -3006,10 +3005,6 @@ export const FilesView: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ mode = 'full' }) => { {!selectedFile ? (
{t('filesView.editor.pickFileFromTree')}
- ) : (fileLoading || isImageAssetAuthLoading || isPdfAssetAuthLoading) ? ( + ) : (fileLoading || isPdfAssetAuthLoading) ? ( suppressFileLoadingIndicator ?
: ( @@ -3884,7 +3849,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { ) : isSelectedImage ? (
{selectedFile?.name = ({ mode = 'full' }) => { {renderFloatingFileControls({ exitFullscreenOnly: true })}
- {(fileLoading || isImageAssetAuthLoading || isPdfAssetAuthLoading) ? ( + {(fileLoading || isPdfAssetAuthLoading) ? ( suppressFileLoadingIndicator ?
: ( @@ -4278,7 +4243,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { ) : isSelectedImage ? (
{selectedFile.name} { + 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' })] }); + }); }); diff --git a/packages/web/src/api/files.ts b/packages/web/src/api/files.ts index 3d1c91ae..6655d365 100644 --- a/packages/web/src/api/files.ts +++ b/packages/web/src/api/files.ts @@ -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);