diff --git a/frontend/app/aipanel/ai-utils.ts b/frontend/app/aipanel/ai-utils.ts index 0e80886b2f..1477db6af5 100644 --- a/frontend/app/aipanel/ai-utils.ts +++ b/frontend/app/aipanel/ai-utils.ts @@ -1,6 +1,13 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +const TextFileLimit = 200 * 1024; // 200KB +const PdfLimit = 5 * 1024 * 1024; // 5MB +const ImageLimit = 10 * 1024 * 1024; // 10MB +const ImagePreviewSize = 128; +const ImagePreviewWebPQuality = 0.8; +const ImageMaxEdge = 4096; + export const isAcceptableFile = (file: File): boolean => { const acceptableTypes = [ // Images @@ -34,10 +41,15 @@ export const isAcceptableFile = (file: File): boolean => { const extension = file.name.split(".").pop()?.toLowerCase(); const acceptableExtensions = [ "txt", + "log", "md", "js", + "mjs", + "cjs", "jsx", "ts", + "mts", + "cts", "tsx", "go", "py", @@ -47,10 +59,15 @@ export const isAcceptableFile = (file: File): boolean => { "h", "hpp", "html", + "htm", "css", "scss", "sass", "json", + "jsonc", + "json5", + "jsonl", + "ndjson", "xml", "yaml", "yml", @@ -69,9 +86,116 @@ export const isAcceptableFile = (file: File): boolean => { "clj", "ex", "exs", + "ini", + "toml", + "conf", + "cfg", + "env", + "zsh", + "fish", + "ps1", + "psm1", + "bazel", + "bzl", + "csv", + "tsv", + "properties", + "ipynb", + "rmd", + "gradle", + "groovy", + "cmake", ]; - return extension ? acceptableExtensions.includes(extension) : false; + if (extension && acceptableExtensions.includes(extension)) { + return true; + } + + // Check for specific filenames (case-insensitive) + const fileName = file.name.toLowerCase(); + const acceptableFilenames = [ + "makefile", + "dockerfile", + "containerfile", + "go.mod", + "go.sum", + "go.work", + "go.work.sum", + "package.json", + "package-lock.json", + "yarn.lock", + "pnpm-lock.yaml", + "composer.json", + "composer.lock", + "gemfile", + "gemfile.lock", + "podfile", + "podfile.lock", + "cargo.toml", + "cargo.lock", + "pipfile", + "pipfile.lock", + "requirements.txt", + "setup.py", + "pyproject.toml", + "poetry.lock", + "build.gradle", + "settings.gradle", + "pom.xml", + "build.xml", + "readme", + "readme.md", + "license", + "license.md", + "changelog", + "changelog.md", + "contributing", + "contributing.md", + "authors", + "codeowners", + "procfile", + "jenkinsfile", + "vagrantfile", + "rakefile", + "gruntfile.js", + "gulpfile.js", + "webpack.config.js", + "rollup.config.js", + "vite.config.js", + "jest.config.js", + "vitest.config.js", + ".dockerignore", + ".gitignore", + ".gitattributes", + ".gitmodules", + ".editorconfig", + ".eslintrc", + ".prettierrc", + ".pylintrc", + ".bashrc", + ".bash_profile", + ".bash_login", + ".bash_logout", + ".profile", + ".zshrc", + ".zprofile", + ".zshenv", + ".zlogin", + ".zlogout", + ".kshrc", + ".cshrc", + ".tcshrc", + ".xonshrc", + ".shrc", + ".aliases", + ".functions", + ".exports", + ".direnvrc", + ".vimrc", + ".gvimrc", + ]; + + return acceptableFilenames.includes(fileName); }; export const getFileIcon = (fileName: string, fileType: string): string => { @@ -182,34 +306,30 @@ export interface FileSizeError { } export const validateFileSize = (file: File): FileSizeError | null => { - const TEXT_FILE_LIMIT = 200 * 1024; // 200KB - const PDF_LIMIT = 5 * 1024 * 1024; // 5MB - const IMAGE_LIMIT = 10 * 1024 * 1024; // 10MB - if (file.type.startsWith("image/")) { - if (file.size > IMAGE_LIMIT) { + if (file.size > ImageLimit) { return { fileName: file.name, fileSize: file.size, - maxSize: IMAGE_LIMIT, + maxSize: ImageLimit, fileType: "image", }; } } else if (file.type === "application/pdf") { - if (file.size > PDF_LIMIT) { + if (file.size > PdfLimit) { return { fileName: file.name, fileSize: file.size, - maxSize: PDF_LIMIT, + maxSize: PdfLimit, fileType: "pdf", }; } } else { - if (file.size > TEXT_FILE_LIMIT) { + if (file.size > TextFileLimit) { return { fileName: file.name, fileSize: file.size, - maxSize: TEXT_FILE_LIMIT, + maxSize: TextFileLimit, fileType: "text", }; } @@ -218,6 +338,37 @@ export const validateFileSize = (file: File): FileSizeError | null => { return null; }; +export const validateFileSizeFromInfo = ( + fileName: string, + fileSize: number, + mimeType: string +): FileSizeError | null => { + let maxSize: number; + let fileType: "text" | "pdf" | "image"; + + if (mimeType.startsWith("image/")) { + maxSize = ImageLimit; + fileType = "image"; + } else if (mimeType === "application/pdf") { + maxSize = PdfLimit; + fileType = "pdf"; + } else { + maxSize = TextFileLimit; + fileType = "text"; + } + + if (fileSize > maxSize) { + return { + fileName, + fileSize, + maxSize, + fileType, + }; + } + + return null; +}; + export const formatFileSizeError = (error: FileSizeError): string => { const typeLabel = error.fileType === "image" ? "Image" : error.fileType === "pdf" ? "PDF" : "Text file"; return `${typeLabel} "${error.fileName}" is too large (${formatFileSize(error.fileSize)}). Maximum size is ${formatFileSize(error.maxSize)}.`; @@ -233,9 +384,6 @@ export const resizeImage = async (file: File): Promise => { return file; } - const MAX_EDGE = 4096; - const WEBP_QUALITY = 0.8; - return new Promise((resolve) => { const img = new Image(); const url = URL.createObjectURL(file); @@ -246,7 +394,7 @@ export const resizeImage = async (file: File): Promise => { let { width, height } = img; // Check if resizing is needed - if (width <= MAX_EDGE && height <= MAX_EDGE) { + if (width <= ImageMaxEdge && height <= ImageMaxEdge) { // Image is already small enough, just try WebP conversion const canvas = document.createElement("canvas"); canvas.width = width; @@ -272,18 +420,18 @@ export const resizeImage = async (file: File): Promise => { } }, "image/webp", - WEBP_QUALITY + ImagePreviewWebPQuality ); return; } // Calculate new dimensions while maintaining aspect ratio if (width > height) { - height = Math.round((height * MAX_EDGE) / width); - width = MAX_EDGE; + height = Math.round((height * ImageMaxEdge) / width); + width = ImageMaxEdge; } else { - width = Math.round((width * MAX_EDGE) / height); - height = MAX_EDGE; + width = Math.round((width * ImageMaxEdge) / height); + height = ImageMaxEdge; } // Create canvas and resize @@ -312,7 +460,7 @@ export const resizeImage = async (file: File): Promise => { } }, "image/webp", - WEBP_QUALITY + ImagePreviewWebPQuality ); }; @@ -333,9 +481,6 @@ export const createImagePreview = async (file: File): Promise => return null; } - const PREVIEW_SIZE = 128; - const WEBP_QUALITY = 0.8; - return new Promise((resolve) => { const img = new Image(); const url = URL.createObjectURL(file); @@ -346,11 +491,11 @@ export const createImagePreview = async (file: File): Promise => let { width, height } = img; if (width > height) { - height = Math.round((height * PREVIEW_SIZE) / width); - width = PREVIEW_SIZE; + height = Math.round((height * ImagePreviewSize) / width); + width = ImagePreviewSize; } else { - width = Math.round((width * PREVIEW_SIZE) / height); - height = PREVIEW_SIZE; + width = Math.round((width * ImagePreviewSize) / height); + height = ImagePreviewSize; } const canvas = document.createElement("canvas"); @@ -372,7 +517,7 @@ export const createImagePreview = async (file: File): Promise => } }, "image/webp", - WEBP_QUALITY + ImagePreviewWebPQuality ); }; diff --git a/frontend/app/aipanel/aipanel.tsx b/frontend/app/aipanel/aipanel.tsx index 96b8540726..43ace431b4 100644 --- a/frontend/app/aipanel/aipanel.tsx +++ b/frontend/app/aipanel/aipanel.tsx @@ -13,6 +13,7 @@ import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; import * as jotai from "jotai"; import { memo, useCallback, useEffect, useRef, useState } from "react"; +import { useDrop } from "react-dnd"; import { formatFileSizeError, isAcceptableFile, validateFileSize } from "./ai-utils"; import { AIDroppedFiles } from "./aidroppedfiles"; import { AIPanelHeader } from "./aipanelheader"; @@ -209,6 +210,7 @@ interface AIPanelProps { const AIPanelComponentInner = memo(({ className, onClose }: AIPanelProps) => { const [isDragOver, setIsDragOver] = useState(false); + const [isReactDndDragOver, setIsReactDndDragOver] = useState(false); const [initialLoadDone, setInitialLoadDone] = useState(false); const model = WaveAIModel.getInstance(); const containerRef = useRef(null); @@ -320,27 +322,43 @@ const AIPanelComponentInner = memo(({ className, onClose }: AIPanelProps) => { }; const handleDragOver = (e: React.DragEvent) => { + const hasFiles = hasFilesDragged(e.dataTransfer); + + // Only handle native file drags here, let react-dnd handle FILE_ITEM drags + if (!hasFiles) { + return; + } + e.preventDefault(); e.stopPropagation(); - const hasFiles = hasFilesDragged(e.dataTransfer); - if (hasFiles && !isDragOver) { + if (!isDragOver) { setIsDragOver(true); - } else if (!hasFiles && isDragOver) { - setIsDragOver(false); } }; const handleDragEnter = (e: React.DragEvent) => { + const hasFiles = hasFilesDragged(e.dataTransfer); + + // Only handle native file drags here, let react-dnd handle FILE_ITEM drags + if (!hasFiles) { + return; + } + e.preventDefault(); e.stopPropagation(); - if (hasFilesDragged(e.dataTransfer)) { - setIsDragOver(true); - } + setIsDragOver(true); }; const handleDragLeave = (e: React.DragEvent) => { + const hasFiles = hasFilesDragged(e.dataTransfer); + + // Only handle native file drags here, let react-dnd handle FILE_ITEM drags + if (!hasFiles) { + return; + } + e.preventDefault(); e.stopPropagation(); @@ -355,6 +373,12 @@ const AIPanelComponentInner = memo(({ className, onClose }: AIPanelProps) => { }; const handleDrop = async (e: React.DragEvent) => { + // Check if this is a FILE_ITEM drag from react-dnd + // If so, let react-dnd handle it instead + if (!e.dataTransfer.files.length) { + return; // Let react-dnd handle FILE_ITEM drags + } + e.preventDefault(); e.stopPropagation(); setIsDragOver(false); @@ -381,6 +405,39 @@ const AIPanelComponentInner = memo(({ className, onClose }: AIPanelProps) => { } }; + const handleFileItemDrop = useCallback( + (draggedFile: DraggedFile) => model.addFileFromRemoteUri(draggedFile), + [model] + ); + + const [{ isOver, canDrop }, drop] = useDrop( + () => ({ + accept: "FILE_ITEM", + drop: handleFileItemDrop, + collect: (monitor) => ({ + isOver: monitor.isOver(), + canDrop: monitor.canDrop(), + }), + }), + [handleFileItemDrop] + ); + + // Update drag over state for FILE_ITEM drags + useEffect(() => { + if (isOver && canDrop) { + setIsReactDndDragOver(true); + } else { + setIsReactDndDragOver(false); + } + }, [isOver, canDrop]); + + // Attach the drop ref to the container + useEffect(() => { + if (containerRef.current) { + drop(containerRef.current); + } + }, [drop]); + const handleFocusCapture = useCallback( (event: React.FocusEvent) => { // console.log("Wave AI focus capture", getElemAsStr(event.target)); @@ -453,7 +510,7 @@ const AIPanelComponentInner = memo(({ className, onClose }: AIPanelProps) => { "bg-gray-900 flex flex-col relative h-[calc(100%-4px)]", model.inBuilder ? "mt-0" : "mt-1", className, - isDragOver && "bg-gray-800 border-accent", + (isDragOver || isReactDndDragOver) && "bg-gray-800 border-accent", isFocused ? "border-2 border-accent" : "border-2 border-transparent" )} style={{ @@ -469,7 +526,7 @@ const AIPanelComponentInner = memo(({ className, onClose }: AIPanelProps) => { onClick={handleClick} inert={!isPanelVisible ? true : undefined} > - {isDragOver && } + {(isDragOver || isReactDndDragOver) && } {showBlockMask && } diff --git a/frontend/app/aipanel/waveai-model.tsx b/frontend/app/aipanel/waveai-model.tsx index 35c6df4778..c2ba16a794 100644 --- a/frontend/app/aipanel/waveai-model.tsx +++ b/frontend/app/aipanel/waveai-model.tsx @@ -16,10 +16,19 @@ import { TabRpcClient } from "@/app/store/wshrpcutil"; import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; import { BuilderFocusManager } from "@/builder/store/builder-focusmanager"; import { getWebServerEndpoint } from "@/util/endpoints"; +import { base64ToArrayBuffer } from "@/util/util"; import { ChatStatus } from "ai"; import * as jotai from "jotai"; import type React from "react"; -import { createDataUrl, createImagePreview, normalizeMimeType, resizeImage } from "./ai-utils"; +import { + createDataUrl, + createImagePreview, + formatFileSizeError, + isAcceptableFile, + normalizeMimeType, + resizeImage, + validateFileSizeFromInfo, +} from "./ai-utils"; import type { AIPanelInputRef } from "./aipanelinput"; export interface DroppedFile { @@ -154,6 +163,54 @@ export class WaveAIModel { return droppedFile; } + async addFileFromRemoteUri(draggedFile: DraggedFile): Promise { + if (draggedFile.isDir) { + this.setError("Cannot add directories to Wave AI. Please select a file."); + return; + } + + try { + const fileInfo = await RpcApi.FileInfoCommand(TabRpcClient, { info: { path: draggedFile.uri } }, null); + if (fileInfo.notfound) { + this.setError(`File not found: ${draggedFile.relName}`); + return; + } + if (fileInfo.isdir) { + this.setError("Cannot add directories to Wave AI. Please select a file."); + return; + } + + const mimeType = fileInfo.mimetype || "application/octet-stream"; + const fileSize = fileInfo.size || 0; + const sizeError = validateFileSizeFromInfo(draggedFile.relName, fileSize, mimeType); + if (sizeError) { + this.setError(formatFileSizeError(sizeError)); + return; + } + + const fileData = await RpcApi.FileReadCommand(TabRpcClient, { info: { path: draggedFile.uri } }, null); + if (!fileData.data64) { + this.setError(`Failed to read file: ${draggedFile.relName}`); + return; + } + + const buffer = base64ToArrayBuffer(fileData.data64); + const file = new File([buffer], draggedFile.relName, { type: mimeType }); + if (!isAcceptableFile(file)) { + this.setError( + `File type not supported: ${draggedFile.relName}. Supported: images, PDFs, and text/code files.` + ); + return; + } + + await this.addFile(file); + } catch (error) { + console.error("Error handling FILE_ITEM drop:", error); + const errorMsg = error instanceof Error ? error.message : String(error); + this.setError(`Failed to add file: ${errorMsg}`); + } + } + removeFile(fileId: string) { const currentFiles = globalStore.get(this.droppedFiles); const updatedFiles = currentFiles.filter((f) => f.id !== fileId); diff --git a/frontend/app/view/preview/preview-edit.tsx b/frontend/app/view/preview/preview-edit.tsx index a7ec656cc2..3e79dbced2 100644 --- a/frontend/app/view/preview/preview-edit.tsx +++ b/frontend/app/view/preview/preview-edit.tsx @@ -20,6 +20,7 @@ export const shellFileMap: Record = { ".profile": "shell", ".zshrc": "shell", ".zprofile": "shell", + ".zshenv": "shell", ".zlogin": "shell", ".zlogout": "shell", ".kshrc": "shell", @@ -31,6 +32,8 @@ export const shellFileMap: Record = { ".functions": "shell", ".exports": "shell", ".direnvrc": "shell", + ".vimrc": "shell", + ".gvimrc": "shell", }; function CodeEditPreview({ model }: SpecializedViewProps) {