diff --git a/src/apps/chat/components/message/fragments-attachment-doc/livefile-sync/LiveFileControlButton.tsx b/src/apps/chat/components/message/fragments-attachment-doc/livefile-sync/LiveFileControlButton.tsx index fd60813fe..6306478ba 100644 --- a/src/apps/chat/components/message/fragments-attachment-doc/livefile-sync/LiveFileControlButton.tsx +++ b/src/apps/chat/components/message/fragments-attachment-doc/livefile-sync/LiveFileControlButton.tsx @@ -5,7 +5,7 @@ import { Box, Button, ColorPaletteProp, SvgIcon } from '@mui/joy'; import UploadFileRoundedIcon from '@mui/icons-material/UploadFileRounded'; import { TooltipOutlined } from '~/common/components/TooltipOutlined'; -import { getDataTransferFilesOrPromises } from '~/common/util/fileSystemUtils'; +import { getFirstFileSystemFileHandle } from '~/common/util/fileSystemUtils'; import { useDragDropDataTransfer } from '~/common/components/useDragDropDataTransfer'; import { LiveFileChooseIcon, LiveFileIcon } from '~/common/livefile/liveFile.icons'; @@ -41,19 +41,9 @@ export function LiveFileControlButton(props: { // state const handleDataTransfer = React.useCallback(async (dataTransfer: DataTransfer) => { - // get FileSystemFileHandle objects from the DataTransfer - const fileOrFSHandlePromises = getDataTransferFilesOrPromises(dataTransfer.items, false); - if (!fileOrFSHandlePromises.length) - return; - - // resolve the promises to get the actual files/handles - const filesOrHandles = await Promise.all(fileOrFSHandlePromises); - for (let filesOrHandle of filesOrHandles) { - if (!(filesOrHandle instanceof File) && filesOrHandle?.kind === 'file' && filesOrHandle) { - await onPairWithFSFHandle(filesOrHandle); - break; - } - } + const fsfHandle = await getFirstFileSystemFileHandle(dataTransfer); + if (fsfHandle) + await onPairWithFSFHandle(fsfHandle); }, [onPairWithFSFHandle]); const { dragContainerSx, dropComponent, handleContainerDragEnter, handleContainerDragStart } = diff --git a/src/common/stores/workspace/WorkspaceLiveFilePicker.tsx b/src/common/stores/workspace/WorkspaceLiveFilePicker.tsx index d247b7f36..cc027a3de 100644 --- a/src/common/stores/workspace/WorkspaceLiveFilePicker.tsx +++ b/src/common/stores/workspace/WorkspaceLiveFilePicker.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; -import { Box, Button, IconButton, ListDivider, ListItem, ListItemDecorator, MenuItem, Typography } from '@mui/joy'; +import { Box, Button, IconButton, ListDivider, ListItem, ListItemDecorator, MenuItem, SvgIcon, Typography } from '@mui/joy'; import ClearIcon from '@mui/icons-material/Clear'; import CodeIcon from '@mui/icons-material/Code'; @@ -8,10 +8,13 @@ import type { LiveFileId, LiveFileMetadata } from '~/common/livefile/liveFile.ty import { CloseableMenu } from '~/common/components/CloseableMenu'; import { LiveFileChooseIcon } from '~/common/livefile/liveFile.icons'; import { LiveFilePatchIcon } from '~/common/components/icons/LiveFilePatchIcon'; +import { TooltipOutlined } from '~/common/components/TooltipOutlined'; +import { getFirstFileSystemFileHandle } from '~/common/util/fileSystemUtils'; +import { useDragDropDataTransfer } from '~/common/components/useDragDropDataTransfer'; import type { DWorkspaceId } from './workspace.types'; +import { useContextWorkspaceId } from './WorkspaceIdProvider'; import { useWorkspaceContentsMetadata } from './useWorkspaceContentsMetadata'; -import { useContextWorkspaceId } from '~/common/stores/workspace/WorkspaceIdProvider'; // configuration @@ -22,13 +25,14 @@ const ENABLE_AUTO_WORKSPACE_PICK = false; * Allows selection of LiveFiles in the current Workspace */ export function WorkspaceLiveFilePicker(props: { - autoSelectName: string | null; - buttonLabel: string; - liveFileId: LiveFileId | null; allowRemove?: boolean; + autoSelectName: string | null; + labelButton: string; + labelTooltip?: string; + liveFileId: LiveFileId | null; onSelectLiveFile: (id: LiveFileId | null) => void; - onSelectNewFile?: (workspaceId: DWorkspaceId | null) => void; - // tooltipLabel?: string; + onSelectFileOpen?: (workspaceId: DWorkspaceId | null) => Promise; + onSelectFileSystemFileHandle?: (workspaceId: DWorkspaceId | null, fsHandle: FileSystemFileHandle) => Promise; }) { // state for anchor @@ -40,7 +44,7 @@ export function WorkspaceLiveFilePicker(props: { // set as disabled when empty const haveLiveFiles = wLiveFiles.length > 0; - const { autoSelectName, liveFileId, onSelectLiveFile, onSelectNewFile } = props; + const { autoSelectName, liveFileId, onSelectLiveFile, onSelectFileOpen, onSelectFileSystemFileHandle } = props; // [effect] auto-select a LiveFileId @@ -72,16 +76,29 @@ export function WorkspaceLiveFilePicker(props: { }, []); const handleSelectLiveFile = React.useCallback((id: LiveFileId | null) => { - onSelectLiveFile(id); setMenuAnchor(null); + onSelectLiveFile(id); }, [onSelectLiveFile]); - const handleSelectNewFile = React.useCallback(() => { - if (onSelectNewFile) { - onSelectNewFile(workspaceId); + const handleSelectNewFile = React.useCallback(async () => { + if (onSelectFileOpen) { setMenuAnchor(null); + await onSelectFileOpen(workspaceId); } - }, [onSelectNewFile, workspaceId]); + }, [onSelectFileOpen, workspaceId]); + + const handleDataTransferDrop = React.useCallback(async (dataTransfer: DataTransfer) => { + if (onSelectFileSystemFileHandle) { + const fsfHandle = await getFirstFileSystemFileHandle(dataTransfer); + if (fsfHandle) { + setMenuAnchor(null); + await onSelectFileSystemFileHandle(workspaceId, fsfHandle); + } + } + }, [onSelectFileSystemFileHandle, workspaceId]); + + const { dragContainerSx, dropComponent, handleContainerDragEnter, handleContainerDragStart } = + useDragDropDataTransfer(true, 'Select', LiveFileChooseIcon as typeof SvgIcon, 'startDecorator', true, handleDataTransferDrop); // Note: in the future let this be, we can show a file picker that adds LiveFiles to the workspace @@ -92,35 +109,42 @@ export function WorkspaceLiveFilePicker(props: { return <> - {/**/} - {liveFileId ? ( - - - - ) : ( - - )} - {/**/} + {/* Main Button, also a drop target */} + + {liveFileId && ( + + + + )} + + {!liveFileId && ( + + + + )} + + {dropComponent} + - {/* Menu: list of workspace files */} + {/* Select/Upload file menu */} {!!menuAnchor && ( diff --git a/src/common/util/fileSystemUtils.ts b/src/common/util/fileSystemUtils.ts index 76ba9227e..d187689e1 100644 --- a/src/common/util/fileSystemUtils.ts +++ b/src/common/util/fileSystemUtils.ts @@ -96,3 +96,25 @@ export function getDataTransferFilesOrPromises(items: DataTransferItemList, fall return results; } + + +/** + * Utility function to get the first file system handle from a DataTransfer object. + * Note that a DataTransfer object can contain multiple files, but we assume the first is the one. + */ +export async function getFirstFileSystemFileHandle(dataTransfer: DataTransfer): Promise { + + // get FileSystemFileHandle objects from the DataTransfer + const fileOrFSHandlePromises = getDataTransferFilesOrPromises(dataTransfer.items, false); + if (!fileOrFSHandlePromises.length) + return null; + + // resolve the promises to get the actual files/handles + const filesOrHandles = await Promise.all(fileOrFSHandlePromises); + for (let filesOrHandle of filesOrHandles) + if (!(filesOrHandle instanceof File) && filesOrHandle?.kind === 'file' && filesOrHandle) + return filesOrHandle; + + // no file system handle found + return null; +} diff --git a/src/modules/blocks/enhanced-code/livefile-patch/useLiveFilePatch.tsx b/src/modules/blocks/enhanced-code/livefile-patch/useLiveFilePatch.tsx index 01982fbde..e50bbcf3c 100644 --- a/src/modules/blocks/enhanced-code/livefile-patch/useLiveFilePatch.tsx +++ b/src/modules/blocks/enhanced-code/livefile-patch/useLiveFilePatch.tsx @@ -28,23 +28,15 @@ export function useLiveFilePatch(title: string, code: string, isPartial: boolean // handlers - const handleLiveFileSelected = React.useCallback((id: LiveFileId | null) => { + + const handleSelectLiveFile = React.useCallback((id: LiveFileId | null) => { setLiveFileId(id); }, []); - const handleSelectNewFile = React.useCallback(async (workspaceId: DWorkspaceId | null) => { - // pick a file - const fileWithHandle = await fileOpen({ description: 'Insert into file...' }).catch(() => null /* The User closed the files picker */); - if (!fileWithHandle) - return; - const fileSystemFileHandle = fileWithHandle.handle; - if (!fileSystemFileHandle) { - // setStatus({ message: `Browser does not support LiveFile operations. ${isLiveFileSupported() ? 'No filesystem handles.' : ''}`, mtype: 'error' }); - return; - } - + const handleSelectFileSystemFileHandle = React.useCallback(async (workspaceId: DWorkspaceId | null, fsfHandle: FileSystemFileHandle) => { + // Create a new LiveFile and attach it to the workspace try { - const newLiveFileId = await liveFileCreateOrThrow(fileSystemFileHandle); + const newLiveFileId = await liveFileCreateOrThrow(fsfHandle); setLiveFileId(newLiveFileId); // Pair the file with the workspace @@ -59,9 +51,24 @@ export function useLiveFilePatch(title: string, code: string, isPartial: boolean } catch (error) { console.error('Error creating new file:', error); + // setStatus({ message: `Error pairing the file: ${error?.message || typeof error === 'string' ? error : 'Unknown error'}`, mtype: 'error' }); } }, []); + const handleSelectFilePicker = React.useCallback(async (workspaceId: DWorkspaceId | null) => { + // pick a file + const fileWithHandle = await fileOpen({ description: 'Insert into file...' }).catch(() => null /* The User closed the files picker */); + if (!fileWithHandle) + return; + const fileSystemFileHandle = fileWithHandle.handle; + if (!fileSystemFileHandle) { + // setStatus({ message: `Browser does not support LiveFile operations. ${isLiveFileSupported() ? 'No filesystem handles.' : ''}`, mtype: 'error' }); + return; + } + // proceed + await handleSelectFileSystemFileHandle(workspaceId, fileSystemFileHandle); + }, [handleSelectFileSystemFileHandle]); + // components @@ -88,14 +95,16 @@ export function useLiveFilePatch(title: string, code: string, isPartial: boolean {/* Pick LiveFile */} - ), [handleLiveFileSelected, handleSelectNewFile, isEnabled, liveFileId, title]); + ), [handleSelectLiveFile, handleSelectFilePicker, handleSelectFileSystemFileHandle, isEnabled, liveFileId, title]); const actionBar = React.useMemo(() => (!isEnabled || !liveFileId || true) ? null : (