From 2591e9c98f8c36f21f81524d244b6585d1c2bc18 Mon Sep 17 00:00:00 2001 From: Allison Carter <31906811+allisonjoycarter@users.noreply.github.com> Date: Thu, 1 May 2025 18:01:42 -0400 Subject: [PATCH] feat: drag files into the window (#2412) --- ui/desktop/eslint.config.js | 1 + ui/desktop/src/components/ChatView.tsx | 24 +++++++++++++++++++++++- ui/desktop/src/components/Input.tsx | 16 ++++++++++++++++ ui/desktop/src/preload.ts | 4 +++- 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/ui/desktop/eslint.config.js b/ui/desktop/eslint.config.js index 4ce023c5..42b7e3f7 100644 --- a/ui/desktop/eslint.config.js +++ b/ui/desktop/eslint.config.js @@ -70,6 +70,7 @@ module.exports = [ HTMLTextAreaElement: 'readonly', HTMLButtonElement: 'readonly', HTMLDivElement: 'readonly', + File: 'readonly', FileList: 'readonly', FileReader: 'readonly', DOMParser: 'readonly', diff --git a/ui/desktop/src/components/ChatView.tsx b/ui/desktop/src/components/ChatView.tsx index 1762dc0f..68059782 100644 --- a/ui/desktop/src/components/ChatView.tsx +++ b/ui/desktop/src/components/ChatView.tsx @@ -93,6 +93,7 @@ function ChatContent({ const [showGame, setShowGame] = useState(false); const [isGeneratingRecipe, setIsGeneratingRecipe] = useState(false); const [sessionTokenCount, setSessionTokenCount] = useState(0); + const [droppedFiles, setDroppedFiles] = useState([]); const scrollRef = useRef(null); const { @@ -402,6 +403,22 @@ function ChatContent({ } }, [chat.id, messages]); + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + const files = e.dataTransfer.files; + if (files.length > 0) { + const paths: string[] = []; + for (let i = 0; i < files.length; i++) { + paths.push(window.electron.getPathForFile(files[i])); + } + setDroppedFiles(paths); + } + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + }; + return (
{/* Loader when generating recipe */} @@ -410,7 +427,11 @@ function ChatContent({
- + {recipeConfig?.title && messages.length > 0 && ( diff --git a/ui/desktop/src/components/Input.tsx b/ui/desktop/src/components/Input.tsx index 8ef93c02..6127d0bb 100644 --- a/ui/desktop/src/components/Input.tsx +++ b/ui/desktop/src/components/Input.tsx @@ -10,6 +10,7 @@ interface InputProps { onStop?: () => void; commandHistory?: string[]; initialValue?: string; + droppedFiles?: string[]; } export default function Input({ @@ -18,6 +19,7 @@ export default function Input({ onStop, commandHistory = [], initialValue = '', + droppedFiles = [], }: InputProps) { const [_value, setValue] = useState(initialValue); const [displayValue, setDisplayValue] = useState(initialValue); // For immediate visual feedback @@ -35,6 +37,7 @@ export default function Input({ const [historyIndex, setHistoryIndex] = useState(-1); const [savedInput, setSavedInput] = useState(''); const textAreaRef = useRef(null); + const [processedFilePaths, setProcessedFilePaths] = useState([]); useEffect(() => { if (textAreaRef.current) { @@ -45,6 +48,19 @@ export default function Input({ const minHeight = '1rem'; const maxHeight = 10 * 24; + // If we have dropped files, add them to the input and update our state. + if (processedFilePaths !== droppedFiles) { + // Append file paths that aren't in displayValue. + let joinedPaths = + displayValue.trim() + + ' ' + + droppedFiles.filter((path) => !displayValue.includes(path)).join(' '); + setDisplayValue(joinedPaths); + setValue(joinedPaths); + textAreaRef.current?.focus(); + setProcessedFilePaths(droppedFiles); + } + // Debounced function to update actual value const debouncedSetValue = useCallback((val: string) => { debounce((value: string) => { diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index 4f85092c..7bcd5b84 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -1,4 +1,4 @@ -import Electron, { contextBridge, ipcRenderer } from 'electron'; +import Electron, { contextBridge, ipcRenderer, webUtils } from 'electron'; interface RecipeConfig { id: string; @@ -50,6 +50,7 @@ type ElectronAPI = { readFile: (directory: string) => Promise; writeFile: (directory: string, content: string) => Promise; getAllowedExtensions: () => Promise; + getPathForFile: (file: File) => string; on: ( channel: string, callback: (event: Electron.IpcRendererEvent, ...args: unknown[]) => void @@ -101,6 +102,7 @@ const electronAPI: ElectronAPI = { readFile: (filePath: string) => ipcRenderer.invoke('read-file', filePath), writeFile: (filePath: string, content: string) => ipcRenderer.invoke('write-file', filePath, content), + getPathForFile: (file: File) => webUtils.getPathForFile(file), getAllowedExtensions: () => ipcRenderer.invoke('get-allowed-extensions'), on: ( channel: string,