From 3df0318047a404c233f422ab12b7a21339072625 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sat, 19 Sep 2026 10:00:00 +0200 Subject: [PATCH] fix(terminal): a dropped file types its quoted path; the terminal answers a right-click (#16) Both lived in Prism's split dock and went with it when the dock was stripped, while the README and the spec went on listing the drop as shipped. They are on App's terminal host now: the drop goes through quotePaths and termInput and never presses Enter; the menu is Paste, Find in scrollback and Close tab. The e2e performs a real drop with Chromium's drag events and fails on the old code. Co-Authored-By: Claude Fable 5.1 --- CLAUDE.md | 10 +++++++ src/renderer/src/App.tsx | 59 ++++++++++++++++++++++++++++++++++++++-- tools/e2e/run.mjs | 45 +++++++++++++++++++++++++++++- 3 files changed, 110 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 23a7e4c..9666767 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,6 +110,16 @@ terminal theme, anything that reads or shows files. on a resize (reflow) and on a theme change; not on the alternate screen. Columns are counted in CELLS, since a wide character is one character and two cells. xterm splits a row into spans as it likes, so the e2e finds a link's span by POSITION, never by its text. +- **A file dropped on the terminal types its quoted path, and the terminal answers a right-click** + (2026-09-19, #16). Both lived in Prism's `TermDock.tsx`, the split dock, and went with it when + the dock was stripped, while the README, the spec and PR #3 went on listing the drop as shipped + for a day: NOTHING IN A FEATURE LIST IS TRUE UNTIL A TEST HAS DONE IT. They are on App's terminal + host now (`data-term-host`): the drop goes through `quotePaths` and `termInput`, never Enter, and + only over a shell; the menu is Paste (the terminal's own paste rule, via `pasteInto`), Find in + scrollback and Close tab, with no Copy row because xterm owns its selection. The e2e performs a + REAL drop with Chromium's drag events (`Input.dispatchDragEvent` carrying a file path): a + synthetic DataTransfer holds a File with no path and proves nothing about `getPathForFile`. + When a Prism component is stripped, grep what ELSE it owned before calling a feature kept. - **Opacity is a number read defensively** (`termOpacity`): `Number(null)` is 0, and never-set must read as opaque. - **Explorer verbs**: HKCU, `reg.exe` with argv only, on `Directory` and `Directory\Background`, no diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 77236f5..751567e 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -25,8 +25,10 @@ import { } from './lib/tabs' import { useAgentIndicator } from './lib/useAgentIndicator' import { humanFor, workingFor } from './lib/agentClock' -import { forgetSession, markResume } from './lib/termActivity' -import { onCwd } from './lib/termBus' +import { forgetSession, markResume, markTouched } from './lib/termActivity' +import { onCwd, pasteInto } from './lib/termBus' +import { quotePaths } from './lib/termPaste' +import { ContextMenu } from './components/ContextMenu' import { rememberRoot } from './lib/recentRoots' import { savedShellId } from './lib/termPrefs' import { newTabFolder, newTabMode } from './lib/newTabPrefs' @@ -73,6 +75,8 @@ export default function App(): JSX.Element { /** The shell the find bar was opened over; it belongs to that shell alone. */ const [findFor, setFindFor] = useState(null) const [ask, setAsk] = useState(null) + /** The terminal's own right-click menu, at the pointer. */ + const [termMenu, setTermMenu] = useState<{ x: number; y: number } | null>(null) const { tabs, activeId } = state const active = tabs.find((t) => t.id === activeId) ?? null const activeShell = active && active.kind !== 'settings' ? active : null @@ -316,7 +320,39 @@ export default function App(): JSX.Element { onOpenRecent={openRecent} /> )} -
+
{ + if (!activeShell || !e.dataTransfer.types.includes('Files')) return + e.preventDefault() + e.dataTransfer.dropEffect = 'copy' + }} + onDrop={(e) => { + if (!activeShell) return + e.preventDefault() + e.stopPropagation() + const paths = [...e.dataTransfer.files] + .map((f) => window.prism.getDroppedPath(f)) + .filter((path) => path.length > 0) + if (!paths.length) return + markTouched(activeShell.id) // input like any other: its echo is not the agent working + window.prism.termInput(activeShell.id, quotePaths(paths)) + focusTermSession(activeShell.id) + }} + onContextMenu={(e) => { + if (!activeShell || !(e.target as HTMLElement).closest('[data-term-region]')) return + e.preventDefault() + setTermMenu({ x: e.clientX, y: e.clientY }) + }} + > {activeShell && ( + {termMenu && activeShell && ( + setTermMenu(null)} + items={[ + // Paste through the terminal's own rule (an image forwards the + // keystroke to the agent, files become quoted paths, text is a + // bracketed paste). No Copy row: xterm owns its selection, and + // Ctrl+C over one already copies. + { label: 'Paste', hint: 'Ctrl+V', onPick: () => void pasteInto(activeShell.id) }, + { label: 'Find in scrollback', hint: 'Ctrl+Shift+F', onPick: () => setFindFor(activeShell.id) }, + { label: 'Close tab', hint: 'Ctrl+Shift+W', onPick: () => requestClose(activeShell.id) } + ]} + /> + )} + {ask && ( {}) }, + /** A file dropped on the terminal types its quoted path and never sends it; + * the terminal answers a right-click. */ + async dropAndMenu(ok) { + const w = world() + const file = join(w.alpha, 'my notes.txt') // a space, so the quoting is visible + writeFileSync(file, 'x') + const { app, page } = await launch(w, { args: [w.alpha] }) + await until(async () => (await tabLabels(page)).length === 1) + await typeLine(page, 'cls') + await sleep(600) + // A REAL drop: Chromium's own drag events carrying a file path, which is + // what Explorer hands over. A synthetic DataTransfer would carry a File + // with no path, and prove nothing about getPathForFile. + const box = await page.locator('[data-term-region]').boundingBox() + const x = Math.round(box.x + box.width / 2) + const y = Math.round(box.y + box.height / 2) + const cdp = await page.context().newCDPSession(page) + const data = { items: [], files: [file], dragOperationsMask: 1 } + for (const type of ['dragEnter', 'dragOver', 'drop']) { + await cdp.send('Input.dispatchDragEvent', { type, x, y, data }) + } + const typed = await until(async () => (await termText(page)).includes('my notes.txt'), 8000) + ok(typed, 'a file dropped on the terminal types its path') + const text = (await termText(page)).replace(/\s+/g, ' ') + ok(/["']?[A-Z]:\\[^"']*my notes\.txt["']/.test(text), `and the path is quoted, since it holds a space (${text.slice(-90)})`) + ok(!/is not recognized|CommandNotFound|ObjectNotFound/.test(text), 'and it was NOT sent: nothing ran') + // Clear what the drop typed, so the shell is at a clean prompt again. + await page.keyboard.press('Escape') + // The right-click menu: Paste, Find, Close tab. + await page.locator('[data-term-region]').click({ button: 'right', position: { x: 200, y: 120 } }) + const rows = await until( + async () => { + const t = await page.locator('[role="menu"] [role="menuitem"]').allTextContents() + return t.length ? t : null + }, + 4000 + ) + ok(!!rows && ['Paste', 'Find in scrollback', 'Close tab'].every((l) => rows.some((r) => r.includes(l))), `the terminal answers a right-click (${JSON.stringify(rows)})`) + await page.locator('[role="menu"] [role="menuitem"]', { hasText: 'Find in scrollback' }).click() + ok(await until(async () => (await page.locator('[data-term-find], input[placeholder*="ind"]').count()) > 0, 4000), 'and its Find row opens the find bar') + await app.close().catch(() => {}) + }, + /** `exit` closes the tab it was typed in. */ async exitClosesTab(ok) { const w = world()