Files
learnharness/components/file-path-drop-zone.tsx
T

107 lines
4.7 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import { FileSymlinkIcon, XIcon } from "lucide-react";
import { quoteFilePath } from "@/src/core";
import { appendToPrimaryInput } from "@/components/dictation";
import { INSERT_FILE_PATHS_EVENT } from "@/components/path-input";
const BROWSER_NOTICE = "Local file paths can only be attached in the desktop app. You can paste a full path instead.";
/** The content area only: the sidebar is deliberately outside this zone.
* Existing files stay where they are; only quoted path strings enter drafts. */
export function FilePathDropZone({ children, className }: { children: React.ReactNode; className?: string }) {
const root = useRef<HTMLElement>(null);
const depth = useRef(0);
const [dragging, setDragging] = useState(false);
const [notice, setNotice] = useState("");
const hasFiles = (event: React.DragEvent) => Array.from(event.dataTransfer.types).includes("Files");
const clearDrag = () => { depth.current = 0; setDragging(false); };
useEffect(() => {
// The sidebar remains a non-target, but dropping a file there must not
// navigate the app away. No indicator or attachment is produced there.
const preventOutsideNavigation = (event: DragEvent) => {
if (Array.from(event.dataTransfer?.types ?? []).includes("Files")
&& event.target instanceof Node && !root.current?.contains(event.target)) event.preventDefault();
};
window.addEventListener("dragover", preventOutsideNavigation);
window.addEventListener("drop", preventOutsideNavigation);
return () => {
window.removeEventListener("dragover", preventOutsideNavigation);
window.removeEventListener("drop", preventOutsideNavigation);
};
}, []);
return (
<main
ref={root}
className={className}
data-testid="file-path-drop-zone"
onDragEnter={(event) => {
if (!hasFiles(event)) return;
event.preventDefault();
depth.current++;
setDragging(true);
setNotice("");
}}
onDragOver={(event) => {
if (!hasFiles(event)) return;
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
}}
onDragLeave={(event) => {
if (!hasFiles(event)) return;
depth.current = Math.max(0, depth.current - 1);
if (depth.current === 0) setDragging(false);
}}
onDragEnd={clearDrag}
onDrop={(event) => {
if (!hasFiles(event)) return;
event.preventDefault();
event.stopPropagation();
clearDrag();
const resolve = window.lh?.getPathForFile;
if (!resolve) { setNotice(BROWSER_NOTICE); return; }
// Directories are not file references. This check uses drag metadata,
// never a server request or a new filesystem capability.
const entries = Array.from(event.dataTransfer.items).filter((item) => item.kind === "file");
const paths: string[] = [];
let skipped = 0;
for (const [index, file] of Array.from(event.dataTransfer.files).entries()) {
try {
if (entries[index]?.webkitGetAsEntry?.()?.isDirectory) { skipped++; continue; }
const path = resolve(file);
quoteFilePath(path); // Same rooted-path validity rule as typed chips.
paths.push(path);
} catch { skipped++; }
}
if (paths.length === 0) {
setNotice("No local file paths were available. Drop existing files from your computer.");
return;
}
const focused = document.activeElement?.closest<HTMLElement>("[data-file-path-input]");
if (focused && event.currentTarget.contains(focused)) {
focused.dispatchEvent(new CustomEvent(INSERT_FILE_PATHS_EVENT, { detail: paths }));
} else if (!appendToPrimaryInput(paths.map(quoteFilePath).join(" "))) {
setNotice("Open a chat to attach file paths.");
return;
}
setNotice(skipped ? "File paths attached. Some dropped items had no available file path." : "File paths attached.");
}}
>
{children}
{dragging && (
<div data-testid="file-path-drop-indicator" className="pointer-events-none absolute inset-2 z-50 flex items-center justify-center rounded-xl border-2 border-dashed border-primary bg-background/85 p-6 text-center backdrop-blur-sm">
<div className="flex max-w-md flex-col items-center gap-3">
<FileSymlinkIcon className="size-8 text-primary" aria-hidden />
<span className="font-ui text-sm">{window.lh?.getPathForFile ? "Drop to attach file paths" : BROWSER_NOTICE}</span>
</div>
</div>
)}
{notice && <div role="status" className="absolute bottom-2 left-1/2 z-50 flex max-w-[90%] -translate-x-1/2 items-center gap-2 rounded-md border bg-card px-3 py-2 font-ui text-xs shadow-sm">
<span>{notice}</span>
<button type="button" aria-label="Dismiss attachment notice" onClick={() => setNotice("")} className="shrink-0 rounded-sm p-1 hover:bg-accent"><XIcon className="size-3" aria-hidden /></button>
</div>}
</main>
);
}