Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 196 additions & 0 deletions docs/adrs/021.client.file-drop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
# ADR 021: Client — File drag-and-drop path injection into PTY

**SPEC:** [client](../specs/client.md)
**Status:** Rejected
**Date:** 2026-03-31

---

## Context

Dragging a local file into the webtty browser terminal and dropping it onto a
running vim session does not open the file in vim. The same vim config (vimrc
with drag-and-drop support) works correctly in native terminals such as iTerm2.

### What native terminals do

When a file is dragged onto a native terminal emulator (iTerm2, Ghostty, etc.),
the OS drag-and-drop API delivers the **full absolute filesystem path** of the
dropped file (e.g. `/Users/alice/projects/notes.txt`). The terminal emulator
injects that path as keystrokes into the PTY. vim receives the path as text, and
vimrc DnD handlers act on it normally.

### The first problem: no drop handler — browser navigates away

`src/client/index.ts` registers no drag event listeners. Without a `dragover`
handler calling `preventDefault()`, the browser treats a file drop as a
navigation request and replaces the terminal tab with the file's content — the
session is gone. This is fixable with two listeners regardless of which path
resolution approach is taken.

### The hard problem: the browser hides the filesystem path

The [HTML File API](https://developer.mozilla.org/en-US/docs/Web/API/File)
intentionally withholds the local filesystem path from JavaScript. A drop event
gives only `File.name` (basename) and `File.type`. This is a deliberate privacy
boundary — a web page learning `/Users/alice/...` would expose the user's
directory structure to any site they visit.

The core requirement is: **inject the original file path into the PTY so vim
opens the real file in place**. Every approach below was evaluated against this.

---

## Approaches Investigated

### Approach A: `text/uri-list` from the drop event

When a file is dragged from the OS file manager, the drag transfer may include
a `text/uri-list` entry containing `file:///path/to/file`. Reading it:

```ts
const uri = event.dataTransfer.getData('text/uri-list');
// e.g. "file:///home/alice/projects/notes.txt"
const path = decodeURIComponent(uri.replace('file://', ''));
ws.send(path); // inject real path into PTY
```

**On Linux** (X11/Wayland, Chrome/Firefox): this works. The desktop DnD protocol
passes `file://` URIs through, and browsers on Linux do not strip them. The real
path is available, no upload required.

**On macOS and Windows** (Chrome, Safari, Edge): browsers explicitly block
`file://` URIs from `getData('text/uri-list')` — the call returns an empty
string. This is a security policy, not a bug, and applies equally to `localhost`
and remote origins.

**Verdict**: viable on Linux only. Not cross-platform.

---

### Approach B: `File.name` only (no upload)

Send only the basename from `event.dataTransfer.files[0].name`.

**Verdict**: rejected. A bare filename is not a path. It works only when a file
of the same name already exists in the PTY's current working directory — not a
reliable workflow.

---

### Approach C: File System Access API — `getAsFileSystemHandle()`

Chrome 86+ / Edge 86+ support `DataTransferItem.getAsFileSystemHandle()`, which
returns a `FileSystemFileHandle`. The handle supports `getFile()` (read) and
`createWritable()` (write back to the original file). This is how code-server
(VS Code in the browser) handles DnD in "no folder opened" mode:

```ts
const handle = await item.getAsFileSystemHandle();
// handle.name → "notes.txt" (filename only, no path)
// handle.getFile() → File (content, no path)
// handle.createWritable() (write back to original)
```

**The critical finding**: `FileSystemHandle` has no `.path` or `.fullPath`
property. The spec intentionally omits it for the same privacy reason as the
File API. The handle gives read/write capability, not location information.

VS Code works around this by assigning a synthetic internal URI
(`file:///notes.txt`) and routing all I/O through the handle via
`HTMLFileSystemProvider`. This works because Monaco runs in the browser and
never needs a real path — it talks to its own file service abstraction.

vim is different: vim needs a real server-side path to `open()` a file. There
is no way to hand vim a `FileSystemHandle`. Even with this API the file content
must still be uploaded to the server to get a path vim can use.

**Verdict**: does not provide a path. Cannot skip the upload step for a PTY
use case.

---

### Approach D: Electron's `webUtils.getPathForFile()`

VS Code Desktop (the native Electron app) uses:

```ts
// src/vs/platform/dnd/browser/dnd.ts
export function getPathForFile(file: File): string | undefined {
if (isNative && typeof globalThis.vscode?.webUtils?.getPathForFile === 'function') {
return globalThis.vscode.webUtils.getPathForFile(file);
}
return undefined; // always undefined in a browser
}
```

This returns the real filesystem path from a `File` object. It is injected by
Electron's preload script and is only available in Electron renderer processes.

webtty is a Node.js HTTP server; the browser connecting to it is a standard
Chrome/Firefox/Safari tab. Electron is not involved.

**Verdict**: not applicable. Returns `undefined` in any browser context.

---

### Approach E: Upload to server, inject path

Read file content in the browser, POST to a webtty server endpoint, server
saves the file, server responds with the path, client injects path into PTY.

This is confirmed to be exactly how code-server handles browser DnD when a
workspace folder is open:

```
browser: DataTransferItem.webkitGetAsEntry() → entry.file() → File
→ fileService.writeFile(targetPath, content) [streams to server]
server: writes file to workspace directory
editor: opens file at workspace path
```

code-server uses this approach because it is the only reliable cross-platform
mechanism available in a browser. It works on all platforms and all browsers.

**The sticking point for webtty**: the upload creates a **copy** of the file on
the server. When webtty runs locally (the common case), "server" and "local
machine" are the same host — so the copy lands on the same disk. But the copy
is at a server-chosen path (e.g. PTY cwd or `/tmp/webtty-<session>/`), not the
original location. Any edits vim makes are to the copy; the original file is
not touched.

**Verdict**: cross-platform and implementable, but does not satisfy the
requirement of editing the original file in place. Rejected on those grounds.

---

## Why Rejected

No browser API — on macOS or Windows — delivers the real filesystem path of a
dragged file to JavaScript. The three concrete paths investigated:

| Approach | Path available? | Cross-platform? |
|---|---|---|
| `text/uri-list` | ✅ Linux only | ❌ |
| File System Access API | ❌ handle only, no path | Chrome/Edge only |
| Electron `webUtils` | ❌ not in browser | ❌ |

The one approach that works cross-platform (upload + path injection) creates a
copy rather than editing the original, which is the wrong behaviour for the
intended workflow.

The Linux `text/uri-list` path is viable and zero-cost (no upload, real path),
but implementing it for Linux only — while leaving macOS and Windows with
degraded or no behaviour — is not a useful feature boundary for a
cross-platform tool.

This decision is deferred until a viable cross-platform approach emerges, or
until the scope is explicitly narrowed to Linux only.

---

## Related Decisions

- [ADR 014 — Non-text paste via Ctrl+V PTY forwarding](014.client.image-paste.md):
same class of problem — browser security model blocking a native terminal
feature; same constraint that the browser withholds OS-level data.
Loading
Loading