Add drag-and-drop from preview directory to WaveAI panel (#2502)

Enables dragging files from preview directory listings directly into the
WaveAI panel for analysis.

## Changes

**Modified `frontend/app/aipanel/aipanel.tsx`:**

- Added `useDrop` hook to accept `FILE_ITEM` drag type from preview
directory
- Implemented `handleFileItemDrop` to:
  - Read file content via `RpcApi.FileReadCommand` using the remote URI
  - Convert base64 data to browser `File` object with proper MIME type
  - Validate and add to panel using existing `model.addFile()` flow
- Integrated with existing drag overlay for visual feedback
- Rejects directories with appropriate error messaging

## Implementation

```typescript
const handleFileItemDrop = useCallback(
    async (draggedFile: DraggedFile) => {
        if (draggedFile.isDir) {
            model.setError("Cannot add directories to Wave AI. Please select a file.");
            return;
        }
        
        const fileData = await RpcApi.FileReadCommand(TabRpcClient, {
            info: { path: draggedFile.uri }
        }, null);
        
        const bytes = new Uint8Array(atob(fileData.data64).split('').map(c => c.charCodeAt(0)));
        const file = new File([bytes], draggedFile.relName, { 
            type: fileData.info?.mimetype || "application/octet-stream" 
        });
        
        // Existing validation and addFile flow
        await model.addFile(file);
    },
    [model]
);

const [{ isOver, canDrop }, drop] = useDrop(() => ({
    accept: "FILE_ITEM",
    drop: handleFileItemDrop,
    collect: (monitor) => ({ isOver: monitor.isOver(), canDrop: monitor.canDrop() })
}), [handleFileItemDrop]);
```

No changes required to preview directory—it already exports `FILE_ITEM`
drag items. Works independently from native file system drag-and-drop.

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: sawka <2722291+sawka@users.noreply.github.com>
This commit is contained in:
Copilot 2025-10-31 16:28:51 -07:00 committed by GitHub
parent a19cb6f300
commit bf0312f69d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 301 additions and 39 deletions

View file

@ -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<File> => {
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<File> => {
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<File> => {
}
},
"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<File> => {
}
},
"image/webp",
WEBP_QUALITY
ImagePreviewWebPQuality
);
};
@ -333,9 +481,6 @@ export const createImagePreview = async (file: File): Promise<string | null> =>
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<string | null> =>
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<string | null> =>
}
},
"image/webp",
WEBP_QUALITY
ImagePreviewWebPQuality
);
};

View file

@ -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<HTMLDivElement>(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 && <AIDragOverlay />}
{(isDragOver || isReactDndDragOver) && <AIDragOverlay />}
{showBlockMask && <AIBlockMask />}
<AIPanelHeader onClose={onClose} model={model} onClearChat={handleClearChat} />
<AIRateLimitStrip />

View file

@ -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<void> {
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);

View file

@ -20,6 +20,7 @@ export const shellFileMap: Record<string, string> = {
".profile": "shell",
".zshrc": "shell",
".zprofile": "shell",
".zshenv": "shell",
".zlogin": "shell",
".zlogout": "shell",
".kshrc": "shell",
@ -31,6 +32,8 @@ export const shellFileMap: Record<string, string> = {
".functions": "shell",
".exports": "shell",
".direnvrc": "shell",
".vimrc": "shell",
".gvimrc": "shell",
};
function CodeEditPreview({ model }: SpecializedViewProps) {