Files pane
- F16: a drag-out released back inside the app no longer re-imports its own
staged copy over the container original. An in-flight flag (cleared from the
drag plugin's `onEvent` channel, with a watchdog) suppresses the drop and the
"Drop files into …" hint, and an exact staged-path filter is the second line
of defence — the `path|size|modified` cache could otherwise write a
minutes-old snapshot over a file an agent had since rewritten.
- F17: a slow upload/rename no longer yanks the user back to the directory the
operation started in. Every operation captures its target path and re-lists
only if the user is still there; failures go to the toast host either way.
- The grid keeps keyboard focus. Roving tabindex (one tab stop, not one per
row) plus focus restore after navigation, rename commit/cancel and Escape.
- Transient failures now surface in `ToastHost` (z-[60], persistent aria-live)
instead of a `role="alert"` 300 rows down a scroller or behind a modal
overlay. The inline error is kept only for the listing failure.
- `navigate` is sequenced by generation; "Save to host…" sets `busy`.
- Grid a11y: column headers, a text affordance for folder vs file, a live
region that is mounted empty and announces completion, Label-in-Name fixed.
- FileViewerModal: the blob URL is released only once its replacement exists;
the preview is a focusable, named, scrollable region.
Native drop routing
- New `lib/dropTarget.ts`: the hit test now refuses a drop while any
`[aria-modal="true"]` dialog or `[data-blocks-drop]` overlay is up, and
checks z-order where the environment can answer it. Shared by FilesTab and
TerminalView; App's shutdown overlay opts in.
Disk
- A partially failed reclaim says so in words ("… — 2 of 5 failed"), not by hue
alone.
- The scan/reclaim race is closed: every mutation retires an in-flight scan, so
a scan can no longer repaint a pre-reclaim report plus a clickable plan of
objects that are gone. Scan is disabled while working; the status is a live
region; a failed destructive action keeps its dialog open and reports there.
- The "unknown" layer count gets a screen-reader fallback; `--text-disabled`
no longer carries live information.
Terminal / OAuth
- After the toast is dismissed, a truncated heuristic guess can no longer fill
the slot that an exact OSC 8 or relay URL occupied — the detector remembers
every exact URL and drops any candidate that is a strict prefix of one.
- The prompt is reachable by keyboard: Ctrl+Shift+O jumps to the default
action, Escape dismisses, focus returns to the terminal, and auto-dismiss
holds off while focus is inside. It deliberately does not steal focus.
- UrlToast renders through `ui/Button` and `--shadow-overlay`.
Elsewhere
- AuthBridgeRow: a pushed `auth-bridge-changed` status always outranks an older
awaited toggle result.
- The last two ad-hoc byte formatters route through `lib/formatBytes`.
Contract for the backend agent: `upload_file_to_container` refusing to
overwrite must satisfy `isFileExistsError` in `src/lib/uploadErrors.ts` (marker
`FILE_EXISTS`) and accept an `overwrite` argument; the frontend turns that into
an `ui/Modal` Replace/Skip prompt rather than a raw error string.
Tests: 536 -> 627 passing. `npm run build` and `npx tsc --noEmit` green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
360 lines
14 KiB
TypeScript
360 lines
14 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { UrlDetector, flatten, osc8Targets, usableLink } from "./urlDetector";
|
|
import type { UrlSource } from "./urlDetector";
|
|
|
|
const COLS = 80;
|
|
const enc = new TextEncoder();
|
|
|
|
/** Feed text and let the debounce + confirmation timers run. */
|
|
function feed(detector: UrlDetector, text: string) {
|
|
detector.feed(enc.encode(text));
|
|
vi.advanceTimersByTime(2000);
|
|
}
|
|
|
|
/** Hard-wrap the way a PTY does: a break every `cols` characters, nothing lost. */
|
|
function ptyWrap(text: string, cols = COLS): string {
|
|
const lines: string[] = [];
|
|
for (let i = 0; i < text.length; i += cols) lines.push(text.slice(i, i + cols));
|
|
return lines.join("\r\n");
|
|
}
|
|
|
|
/**
|
|
* One OSC 8 hyperlink emission: the whole URL in the parameter, a slice of it
|
|
* as visible text.
|
|
*
|
|
* This is what `claude setup-token` actually prints — measured against 2.1.226,
|
|
* a 346-character URL arrives as five of these, each carrying the complete URL
|
|
* and 80 characters of it on screen.
|
|
*/
|
|
function osc8(uri: string, visible: string): string {
|
|
return `\x1b]8;;${uri}\x07${visible}\x1b]8;;\x07`;
|
|
}
|
|
|
|
/** Slice `uri` into `width`-character visible pieces, each a full hyperlink. */
|
|
function slicedHyperlink(uri: string, width = COLS): string {
|
|
const parts: string[] = [];
|
|
for (let i = 0; i < uri.length; i += width) {
|
|
parts.push(osc8(uri, uri.slice(i, i + width)));
|
|
}
|
|
return parts.join("\r\n");
|
|
}
|
|
|
|
/** The URL Claude Code prints, at the length it really is. */
|
|
const SIGN_IN_URL =
|
|
"https://claude.ai/oauth/authorize?code=true&client_id=9d1c250a-e61b-44d9-88ed-5944d1962f5e" +
|
|
"&response_type=code&redirect_uri=https%3A%2F%2Fconsole.anthropic.com%2Foauth%2Fcode%2Fcallback" +
|
|
"&scope=org%3Acreate_api_key+user%3Aprofile+user%3Ainference&code_challenge=" +
|
|
"vJ8Kq2mN4pR7sT9wX1zA3bC5dE6fG8hJ0kL2mN4pQ6r&code_challenge_method=S256&state=aB3dE5gH7jK9";
|
|
|
|
beforeEach(() => vi.useFakeTimers());
|
|
afterEach(() => vi.useRealTimers());
|
|
|
|
describe("flatten", () => {
|
|
it("rejoins a break the terminal inserted at the width", () => {
|
|
expect(flatten("abcde\nfghij", 5)).toBe("abcdefghij");
|
|
});
|
|
|
|
it("keeps a break that arrived before the width as a separator", () => {
|
|
expect(flatten("abc\ndef", 5)).toBe("abc def");
|
|
});
|
|
|
|
it("rejoins nothing when the width isn't known", () => {
|
|
// Better to lose a wrapped URL than to invent one.
|
|
expect(flatten("abcde\nfghij", 0)).toBe("abcde fghij");
|
|
});
|
|
});
|
|
|
|
describe("UrlDetector", () => {
|
|
it("reconstructs a URL the PTY hard-wrapped mid-token", () => {
|
|
const seen: string[] = [];
|
|
const d = new UrlDetector((u) => seen.push(u), () => COLS);
|
|
const url =
|
|
"https://accounts.example.com/o/oauth2/auth?client_id=1234567890-abcdefghijklmnop.apps.example.com&redirect_uri=http%3A%2F%2Flocalhost%3A45678&scope=openid+email+profile";
|
|
|
|
feed(d, "Open this link:\r\n" + ptyWrap(url) + "\r\nWaiting for the browser…\r\n");
|
|
|
|
expect(seen).toEqual([url]);
|
|
});
|
|
|
|
it("does not glue the text that follows a link onto it", () => {
|
|
// The bug this file exists for. A terminal wrapping a paragraph emits the
|
|
// break *instead of* the space, so deleting every break produced
|
|
// `…/tag/preview-63f3c54Butitprovesyournitpick…` — a different host and
|
|
// path from the one on screen, opened on the user's machine.
|
|
const seen: string[] = [];
|
|
const d = new UrlDetector((u) => seen.push(u), () => COLS);
|
|
const url =
|
|
"https://repo.anhonesthost.net/CyberCoveLLC/Triple-C/releases/tag/preview-63f3c54-with-a-long-enough-suffix-to-scan";
|
|
|
|
feed(
|
|
d,
|
|
[
|
|
url,
|
|
"But it proves your nitpick perfectly: every file in it says 0.3.0.",
|
|
"That is the hard-coded patch number.",
|
|
].join("\r\n") + "\r\n",
|
|
);
|
|
|
|
expect(seen).toEqual([url]);
|
|
expect(seen[0]).not.toContain("But");
|
|
// And the host is exactly what was printed — no characters lost.
|
|
expect(new URL(seen[0]).host).toBe("repo.anhonesthost.net");
|
|
});
|
|
|
|
it("stops at the end of a short line even when more output follows", () => {
|
|
const seen: string[] = [];
|
|
const d = new UrlDetector((u) => seen.push(u), () => COLS);
|
|
const url = "https://example.com/" + "a".repeat(90);
|
|
|
|
feed(d, `${url}\r\nnext line of output\r\n`);
|
|
|
|
expect(seen).toEqual([url]);
|
|
});
|
|
|
|
it("joins the next line when a token ends exactly at the width", () => {
|
|
// The one case the width rule cannot decide: a URL whose length is an exact
|
|
// multiple of the column count looks identical to one that was cut. Pinned
|
|
// as known behaviour rather than pretended away — the toast still shows the
|
|
// whole candidate, and nothing opens without the user pressing Open.
|
|
const seen: string[] = [];
|
|
const d = new UrlDetector((u) => seen.push(u), () => COLS);
|
|
const url = "https://example.com/" + "c".repeat(2 * COLS - 20); // exactly 2 lines
|
|
|
|
feed(d, `${ptyWrap(url)}\r\nTAIL\r\n`);
|
|
|
|
expect(seen).toEqual([url + "TAIL"]);
|
|
});
|
|
|
|
it("ignores anything under the length threshold", () => {
|
|
const seen: string[] = [];
|
|
const d = new UrlDetector((u) => seen.push(u), () => COLS);
|
|
|
|
feed(d, "see https://example.com/short\r\nmore text\r\n");
|
|
|
|
expect(seen).toEqual([]);
|
|
});
|
|
|
|
it("emits a wrapped URL once, not once per chunk", () => {
|
|
const seen: string[] = [];
|
|
const d = new UrlDetector((u) => seen.push(u), () => COLS);
|
|
const url = "https://example.com/" + "b".repeat(120);
|
|
|
|
feed(d, ptyWrap(url));
|
|
feed(d, "\r\ndone\r\n");
|
|
|
|
expect(seen).toEqual([url]);
|
|
});
|
|
});
|
|
|
|
describe("usableLink", () => {
|
|
// Ports `usable_sign_in_link` from `commands/auth_token_commands.rs` — a junk
|
|
// filter, not the security decision. `sanitizeRelayUrl` is still what stands
|
|
// between any of this and `openUrl`.
|
|
it("accepts an ordinary authorize URL", () => {
|
|
expect(usableLink(SIGN_IN_URL)).toBe(true);
|
|
});
|
|
|
|
it("rejects a scheme that is not http(s)", () => {
|
|
expect(usableLink("file:///etc/passwd")).toBe(false);
|
|
expect(usableLink("javascript:alert(1)")).toBe(false);
|
|
});
|
|
|
|
it("rejects anything outside printable ASCII", () => {
|
|
// A control character is how a URL is smuggled past a display, and
|
|
// `new URL()` strips some of them silently.
|
|
expect(usableLink("https://example.com/\u0000x")).toBe(false);
|
|
expect(usableLink("https://exa\u200bmple.com/x")).toBe(false);
|
|
expect(usableLink("https://example.com/a b")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("osc8Targets", () => {
|
|
it("lifts the whole URL out of a sliced emission", () => {
|
|
const raw = slicedHyperlink(SIGN_IN_URL);
|
|
// Every piece carries the complete URL, however little of it is on screen.
|
|
expect(new Set(osc8Targets(raw))).toEqual(new Set([SIGN_IN_URL]));
|
|
});
|
|
|
|
it("ignores the closing half of a hyperlink", () => {
|
|
expect(osc8Targets("\x1b]8;;\x07")).toEqual([]);
|
|
});
|
|
|
|
it("ignores other OSCs, including the URL relay's own", () => {
|
|
expect(osc8Targets("\x1b]0;a window title\x07")).toEqual([]);
|
|
expect(osc8Targets("\x1b]7777;open;aHR0cHM6Ly9leGFtcGxlLmNvbQ==\x07")).toEqual([]);
|
|
});
|
|
|
|
it("reads a hyperlink terminated by ST as well as by BEL", () => {
|
|
expect(osc8Targets(`\x1b]8;id=1;${SIGN_IN_URL}\x1b\\text`)).toEqual([SIGN_IN_URL]);
|
|
});
|
|
});
|
|
|
|
describe("UrlDetector — OSC 8", () => {
|
|
it("recovers the complete URL from a sliced hyperlink", () => {
|
|
// The bug this branch exists for. `ANSI_RE` strips OSC sequences wholesale,
|
|
// so the scraper never saw the parameter and reassembled the *visible*
|
|
// pieces instead — a URL that parses, points at claude.ai, and cannot
|
|
// authorise anything.
|
|
const seen: [string, UrlSource][] = [];
|
|
const d = new UrlDetector((u, src) => seen.push([u, src]), () => COLS);
|
|
|
|
feed(d, "Open this link to sign in:\r\n" + slicedHyperlink(SIGN_IN_URL) + "\r\ndone\r\n");
|
|
|
|
expect(seen[0]).toEqual([SIGN_IN_URL, "osc8"]);
|
|
});
|
|
|
|
it("does not emit the same hyperlink again when it is repainted", () => {
|
|
const seen: [string, UrlSource][] = [];
|
|
const d = new UrlDetector((u, s) => seen.push([u, s]), () => COLS);
|
|
|
|
feed(d, slicedHyperlink(SIGN_IN_URL) + "\r\n");
|
|
feed(d, slicedHyperlink(SIGN_IN_URL) + "\r\n");
|
|
|
|
expect(seen.filter(([u]) => u === SIGN_IN_URL)).toHaveLength(1);
|
|
});
|
|
|
|
it("marks a scraped candidate as a guess, so the slot can refuse it", () => {
|
|
// Nothing here decides precedence — that is `supersedes` in TerminalView —
|
|
// but it is what makes the decision possible.
|
|
const seen: [string, UrlSource][] = [];
|
|
const d = new UrlDetector((u, s) => seen.push([u, s]), () => COLS);
|
|
const url = "https://example.com/" + "z".repeat(120);
|
|
|
|
feed(d, url + "\r\nnext\r\n");
|
|
|
|
expect(seen).toEqual([[url, "heuristic"]]);
|
|
});
|
|
|
|
it("never hands back a truncated guess at a link it has already seen exactly", () => {
|
|
// The defect: the prompt slot is emptied (dismissed, or auto-dismissed
|
|
// after 30 s), the OSC 8 target is deduped for the session and cannot come
|
|
// back, and the next repaint — sliced at a different offset, so a *new*
|
|
// string — reassembles into a prefix of the real link that fills the empty
|
|
// slot. It parses, it points at claude.ai, and it authorises nothing.
|
|
//
|
|
// Nothing here knows the slot was emptied, and that is the point: the rule
|
|
// holds however many times it is.
|
|
const seen: [string, UrlSource][] = [];
|
|
const d = new UrlDetector((u, s) => seen.push([u, s]), () => COLS);
|
|
|
|
feed(d, "Open this link to sign in:\r\n" + slicedHyperlink(SIGN_IN_URL) + "\r\ndone\r\n");
|
|
expect(seen).toEqual([[SIGN_IN_URL, "osc8"]]);
|
|
|
|
// …the user dismisses the toast; the TUI repaints the same link as plain
|
|
// text, cut short by the frame it was painted into.
|
|
feed(d, SIGN_IN_URL.slice(0, 150) + "\r\nWaiting for the browser…\r\n");
|
|
|
|
expect(seen).toHaveLength(1);
|
|
expect(seen.map(([u]) => u)).not.toContain(SIGN_IN_URL.slice(0, 150));
|
|
});
|
|
|
|
it("still offers a genuinely different link after an exact one", () => {
|
|
// The suppression is a prefix rule, not "one prompt per session".
|
|
const seen: string[] = [];
|
|
const d = new UrlDetector((u) => seen.push(u), () => COLS);
|
|
const other = "https://github.com/login/device?code=" + "x".repeat(90);
|
|
|
|
feed(d, slicedHyperlink(SIGN_IN_URL) + "\r\n");
|
|
feed(d, other + "\r\nnext\r\n");
|
|
|
|
expect(seen).toEqual([SIGN_IN_URL, other]);
|
|
});
|
|
|
|
it("suppresses a guess at a URL the consumer reported from the relay", () => {
|
|
// The OSC 7777 relay hands `TerminalView` a base64-encoded — therefore
|
|
// exact — URL that this detector never sees. `noteExactUrl` is how it gets
|
|
// told, so a dismissed relay prompt cannot be replaced by a scrape of the
|
|
// same link either.
|
|
const seen: string[] = [];
|
|
const d = new UrlDetector((u) => seen.push(u), () => COLS);
|
|
d.noteExactUrl(SIGN_IN_URL);
|
|
|
|
feed(d, SIGN_IN_URL.slice(0, 150) + "\r\nnext\r\n");
|
|
|
|
expect(seen).toEqual([]);
|
|
});
|
|
|
|
it("ignores a short hyperlink", () => {
|
|
// `ls --hyperlink` decorates every filename; none of that is a prompt.
|
|
const seen: string[] = [];
|
|
const d = new UrlDetector((u) => seen.push(u), () => COLS);
|
|
|
|
feed(d, osc8("https://example.com/a", "a") + "\r\nnext\r\n");
|
|
|
|
expect(seen).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("UrlDetector — repaints and resizes", () => {
|
|
it("treats a bare CR as a line break", () => {
|
|
// A TUI repaints by returning to column 0 without a line feed. Splitting on
|
|
// `\r?\n` alone leaves a whole frame on one "line", which is then longer
|
|
// than the width — so the `===` test says "not wrapped" and a break the
|
|
// terminal really did insert is never rejoined.
|
|
const seen: string[] = [];
|
|
const d = new UrlDetector((u) => seen.push(u), () => COLS);
|
|
const url = "https://example.com/" + "q".repeat(100);
|
|
|
|
feed(d, "spinner frame one\rspinner frame two\r" + ptyWrap(url) + "\r\ndone\r\n");
|
|
|
|
expect(seen).toEqual([url]);
|
|
});
|
|
|
|
it("does not glue two repainted frames into one token", () => {
|
|
const seen: string[] = [];
|
|
const d = new UrlDetector((u) => seen.push(u), () => COLS);
|
|
|
|
feed(
|
|
d,
|
|
"https://example.com/" + "a".repeat(90) + "\rhttps://evil.tld/" + "b".repeat(90) + "\r\n\r\ndone\r\n",
|
|
);
|
|
|
|
for (const url of seen) expect(new URL(url).host).not.toBe("example.comhttps");
|
|
});
|
|
|
|
it("reassembles with the width the bytes were printed at, not the current one", () => {
|
|
// The scan runs 300 ms after the print. A resize inside that window used to
|
|
// change every join decision retroactively: text wrapped at 80 columns,
|
|
// rejoined against a width of 120, comes back as separate lines glued with
|
|
// spaces — or, the other way round, as a URL nobody printed.
|
|
const seen: string[] = [];
|
|
let cols = COLS;
|
|
const d = new UrlDetector((u) => seen.push(u), () => cols);
|
|
const url =
|
|
"https://accounts.example.com/o/oauth2/auth?client_id=1234567890-abcdefghijklmnop.apps.example.com&redirect_uri=http%3A%2F%2Flocalhost%3A45678";
|
|
|
|
d.feed(new TextEncoder().encode(ptyWrap(url) + "\r\nWaiting…\r\n"));
|
|
cols = 120; // the user drags the window wider before the debounce fires
|
|
vi.advanceTimersByTime(2000);
|
|
|
|
expect(seen).toEqual([url]);
|
|
});
|
|
|
|
it("drops text buffered at a width that no longer applies", () => {
|
|
// Half printed at 80, half at 120: no single width reassembles both, so the
|
|
// older half goes rather than being joined by a rule that is wrong for it.
|
|
const seen: string[] = [];
|
|
let cols = COLS;
|
|
const d = new UrlDetector((u) => seen.push(u), () => cols);
|
|
const url = "https://example.com/" + "m".repeat(120);
|
|
|
|
d.feed(new TextEncoder().encode(ptyWrap(url.slice(0, 100))));
|
|
cols = 120;
|
|
feed(d, url.slice(100) + "\r\ndone\r\n");
|
|
|
|
// Whatever survives, it is never a URL that was not printed.
|
|
for (const u of seen) expect(url.startsWith(u) || u.startsWith(url)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("flatten — bare CR", () => {
|
|
it("splits on a lone CR as well as on LF", () => {
|
|
expect(flatten("abcde\rfghij", 5)).toBe("abcdefghij");
|
|
expect(flatten("abc\rdef", 5)).toBe("abc def");
|
|
});
|
|
|
|
it("counts CRLF as one break, not two", () => {
|
|
expect(flatten("abcde\r\nfghij", 5)).toBe("abcdefghij");
|
|
});
|
|
});
|