feat(terminal): a text drag (a Files/Changes row, a skill) is pasted into the pane
xterm.js has no drop handling of its own, so dropped text was lost; the pane now pastes it as a local terminal does, with a "Drop to paste" hint. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,7 +17,7 @@
|
||||
* Files (images or any other) dropped or pasted on it — Ctrl+V included — are saved on the server
|
||||
* (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a
|
||||
* local terminal does on a drop, and how `claude` attaches an image or reads
|
||||
* a file. The pane also registers with
|
||||
* a file. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with
|
||||
* `lib/panePaste.ts`, so a diff-line comment from the Changes panel can be
|
||||
* pasted into whichever of the workspace's panes was focused last.
|
||||
* Keyboard: the terminal keeps Tab and Shift+Tab (claude cycles its permission
|
||||
@@ -63,7 +63,7 @@ import { notePaneFocus, registerPane } from "../../lib/panePaste";
|
||||
import { attachImeInput } from "../../lib/imeInput";
|
||||
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
|
||||
|
||||
type DropState = "hint" | "uploading" | "failed";
|
||||
type DropState = "hint" | "paste" | "uploading" | "failed";
|
||||
|
||||
/** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */
|
||||
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
|
||||
@@ -134,6 +134,10 @@ export function loadXterm(): Promise<Xterm> {
|
||||
const RETRY_MIN_MS = 1500;
|
||||
const RETRY_MAX_MS = 30_000;
|
||||
const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer.types).includes("Files");
|
||||
/** A text drag (a Files/Changes row, a skill, selected text): xterm.js has no
|
||||
* drop handling of its own, so the pane pastes it, as a local terminal does. */
|
||||
const hasText = (e: DragEvent) =>
|
||||
!hasFiles(e) && Array.from(e.dataTransfer.types).includes("text/plain");
|
||||
|
||||
interface TerminalViewProps {
|
||||
runId: string;
|
||||
@@ -200,7 +204,16 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
const ctrlVRef = useRef(false);
|
||||
|
||||
const onDrop = (e: DragEvent) => {
|
||||
if (!hasFiles(e)) return; // dragged text keeps xterm's own handling
|
||||
if (hasText(e)) {
|
||||
e.preventDefault();
|
||||
dragDepth.current = 0;
|
||||
setDrop(null);
|
||||
const text = e.dataTransfer.getData("text/plain");
|
||||
if (text) termRef.current?.paste(text + " ");
|
||||
termRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
if (!hasFiles(e)) return;
|
||||
e.preventDefault();
|
||||
dragDepth.current = 0;
|
||||
void attachFiles(Array.from(e.dataTransfer.files));
|
||||
@@ -673,20 +686,20 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
className="relative h-full w-full pb-1 pl-3 pr-1 pt-1.5"
|
||||
style={{ backgroundColor: palette.background }}
|
||||
onDragEnter={(e) => {
|
||||
if (!hasFiles(e)) return;
|
||||
if (!hasFiles(e) && !hasText(e)) return;
|
||||
e.preventDefault();
|
||||
dragDepth.current += 1;
|
||||
setDrop("hint");
|
||||
setDrop(hasFiles(e) ? "hint" : "paste");
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (!hasFiles(e)) return;
|
||||
if (!hasFiles(e) && !hasText(e)) return;
|
||||
e.preventDefault(); // without it the browser refuses the drop
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
if (!hasFiles(e)) return;
|
||||
if (!hasFiles(e) && !hasText(e)) return;
|
||||
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
||||
if (dragDepth.current === 0) setDrop((d) => (d === "hint" ? null : d));
|
||||
if (dragDepth.current === 0) setDrop((d) => (d === "hint" || d === "paste" ? null : d));
|
||||
}}
|
||||
onDrop={onDrop}
|
||||
onPasteCapture={onPaste}
|
||||
|
||||
@@ -512,6 +512,23 @@ describe("TerminalView", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("pastes the text of a dragged Files-panel row, uploading nothing", () => {
|
||||
dropFileMock.mockReset();
|
||||
pasteMock.mockClear();
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||
const data: Record<string, string> = {
|
||||
"application/x-agenthub-attach": '{"path":"@src/a.ts","name":"a.ts"}',
|
||||
"text/plain": "src/a.ts",
|
||||
};
|
||||
const dataTransfer = { types: Object.keys(data), files: [], getData: (k: string) => data[k] ?? "" };
|
||||
fireEvent.dragEnter(screen.getByTestId("terminal-view"), { dataTransfer });
|
||||
expect(screen.getByTestId("terminal-drop")).toHaveTextContent("Drop to paste into the terminal");
|
||||
fireEvent.drop(screen.getByTestId("terminal-view"), { dataTransfer });
|
||||
expect(pasteMock).toHaveBeenCalledWith("src/a.ts ");
|
||||
expect(screen.queryByTestId("terminal-drop")).toBeNull();
|
||||
expect(dropFileMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uploads a pasted image and pastes its path, like a drop", async () => {
|
||||
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-image.png" });
|
||||
pasteMock.mockClear();
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
},
|
||||
"drop": {
|
||||
"hint": "Drop files or images to attach",
|
||||
"paste": "Drop to paste into the terminal",
|
||||
"uploading": "Uploading…",
|
||||
"failed": "Could not attach the file",
|
||||
"remove": "Remove attachment",
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
},
|
||||
"drop": {
|
||||
"hint": "Thả file hoặc ảnh để đính kèm",
|
||||
"paste": "Thả để dán vào terminal",
|
||||
"uploading": "Đang tải lên…",
|
||||
"failed": "Không đính kèm được file",
|
||||
"remove": "Bỏ đính kèm",
|
||||
|
||||
Reference in New Issue
Block a user