diff --git a/app/src-tauri/src/browser_view/commands.rs b/app/src-tauri/src/browser_view/commands.rs index c1f5d72..8770b2c 100644 --- a/app/src-tauri/src/browser_view/commands.rs +++ b/app/src-tauri/src/browser_view/commands.rs @@ -180,6 +180,8 @@ pub async fn open_page_in_container_browser( url: String, width: u32, height: u32, + show_window: bool, + app_handle: AppHandle, state: State<'_, AppState>, ) -> Result { let trimmed = url.trim(); @@ -188,13 +190,45 @@ pub async fn open_page_in_container_browser( } let container_id = running_container(&state, &project_id, "opening a page").await?; let detection = crate::browser_view::detect::detect(&container_id).await?; - page::open( + let opened = page::open( &container_id, &detection, trimmed, page::Viewport::sane(width, height), ) - .await + .await?; + + // A page nobody can see is not an opened page. Opening one used to leave + // the user to go and press Start in the Browser tab themselves — and from + // the terminal's URL prompt, with no indication that was even needed. + // Asking for a page *is* asking to watch it, so the viewer comes up too. + let status = manager().status(&project_id).await; + if status.state != BrowserViewState::Running { + manager() + .start( + project_id.clone(), + container_id, + app_handle.clone(), + state.projects_store.clone(), + ) + .await?; + } + + // From the terminal there is no pane on screen to fill, so the page needs a + // window of its own or it lands somewhere the user isn't looking. + if show_window { + let status = manager().status(&project_id).await; + if let Some(url) = status.url.as_deref() { + let name = state + .projects_store + .get(&project_id) + .map(|p| p.name) + .unwrap_or_else(|| "Triple-C".to_string()); + popout::open(&app_handle, &project_id, &name, url, false)?; + } + } + + Ok(opened) } /// Resize the page this opened. The pop-out's "match window" mode calls this on diff --git a/app/src/components/layout/MainTabs.test.tsx b/app/src/components/layout/MainTabs.test.tsx index 0507812..c967d63 100644 --- a/app/src/components/layout/MainTabs.test.tsx +++ b/app/src/components/layout/MainTabs.test.tsx @@ -127,6 +127,46 @@ describe("MainTabs reordering", () => { expect(useAppState.getState().activeTabKey).toBe(HOME); }); + it("shows the tab itself under the cursor while dragging", () => { + // A dimmed source tab and a thin line do not read as "I am holding this + // tab" — the dragged copy is what makes the gesture legible. + render(); + const tabs = laidOut(); + expect(screen.queryByTestId("tab-drag-ghost")).toBeNull(); + + pointer(tabs[2], "pointerdown", 250); + pointer(tabs[2], "pointermove", 120); + + const ghost = screen.getByTestId("tab-drag-ghost"); + expect(ghost).toHaveTextContent("shell (bash)"); + expect(ghost).toHaveTextContent("▣"); + + pointer(tabs[2], "pointerup", 120); + expect(screen.queryByTestId("tab-drag-ghost")).toBeNull(); + }); + + it("carries the project name when a home tab is dragged", () => { + render(); + const tabs = laidOut(); + + pointer(tabs[0], "pointerdown", 50); + pointer(tabs[0], "pointermove", 250); + + expect(screen.getByTestId("tab-drag-ghost")).toHaveTextContent("api-server"); + expect(screen.getByTestId("tab-drag-ghost")).toHaveTextContent("⌂"); + }); + + it("drops the dragged copy when the drag is abandoned", () => { + render(); + const tabs = laidOut(); + + pointer(tabs[2], "pointerdown", 250); + pointer(tabs[2], "pointermove", 10); + fireEvent.keyDown(window, { key: "Escape" }); + + expect(screen.queryByTestId("tab-drag-ghost")).toBeNull(); + }); + it("shows the drop marker only while a drag is under way", () => { render(); const tabs = laidOut(); diff --git a/app/src/components/layout/MainTabs.tsx b/app/src/components/layout/MainTabs.tsx index d0bf25e..c962503 100644 --- a/app/src/components/layout/MainTabs.tsx +++ b/app/src/components/layout/MainTabs.tsx @@ -55,9 +55,21 @@ export default function MainTabs() { /** The tab being dragged, and the slot it would drop into. */ const [dragKey, setDragKey] = useState(null); const [dropIndex, setDropIndex] = useState(null); + /** Where the dragged tab is drawn, and how it looked when the drag started. */ + const [ghost, setGhost] = useState<{ x: number; y: number; label: string; icon: string } | null>( + null, + ); const stripRef = useRef(null); /** A press that has not yet moved far enough to be a drag. */ - const pending = useRef<{ key: string; startX: number; dragging: boolean } | null>(null); + const pending = useRef<{ + key: string; + startX: number; + dragging: boolean; + offsetX: number; + width: number; + height: number; + top: number; + } | null>(null); const suppressClick = useRef(false); useEffect(() => { @@ -87,6 +99,7 @@ export default function MainTabs() { pending.current = null; setDragKey(null); setDropIndex(null); + setGhost(null); }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); @@ -171,10 +184,29 @@ export default function MainTabs() { : "text-[var(--text-secondary)] hover:text-[var(--text-primary)]" }${dragging ? " opacity-40" : ""}`; + /** + * What a tab reads as, for the dragged copy. Same sources the tab itself + * uses — a ghost showing a different name from the tab it came from would be + * worse than no ghost. + */ + const tabLabel = (key: string): string => { + if (isHomeTab(key)) { + return projects.find((p) => p.id === tabKeyId(key))?.name ?? ""; + } + const session = sessions.find((s) => s.id === tabKeyId(key)); + if (!session) return ""; + const custom = getCustomName(session.projectId, session.id); + return custom + ? `${session.projectName}: ${custom}` + : (session.sessionName ?? session.projectName) + + (session.sessionType === "bash" ? " (bash)" : ""); + }; + const endDrag = () => { pending.current = null; setDragKey(null); setDropIndex(null); + setGhost(null); }; /** @@ -212,7 +244,19 @@ export default function MainTabs() { // rename input is up — that drag is a text selection. if (e.button !== 0 || renaming) return; if ((e.target as HTMLElement).closest("button, input")) return; - pending.current = { key, startX: e.clientX, dragging: false }; + const rect = e.currentTarget.getBoundingClientRect(); + pending.current = { + key, + startX: e.clientX, + dragging: false, + // Where inside the tab the pointer grabbed it, so the ghost sits under + // the cursor exactly where the real tab was — the thing that makes a + // drag feel like moving an object rather than nudging a setting. + offsetX: e.clientX - rect.left, + width: rect.width, + height: rect.height, + top: rect.top, + }; e.currentTarget.setPointerCapture?.(e.pointerId); }, onPointerMove: (e: React.PointerEvent) => { @@ -223,6 +267,12 @@ export default function MainTabs() { drag.dragging = true; setDragKey(drag.key); setDropIndex(dropIndexAt(e.clientX)); + setGhost({ + x: e.clientX - drag.offsetX, + y: drag.top, + label: tabLabel(drag.key), + icon: isHomeTab(drag.key) ? "⌂" : "▣", + }); }, onPointerUp: (e: React.PointerEvent) => { const drag = pending.current; @@ -408,6 +458,26 @@ export default function MainTabs() { the hand naturally goes to say "put it at the end". */}
{markerPending && dropMarker}
+ {ghost && ( + // A copy of the tab, following the pointer. Without it the only + // feedback is a dimmed source and a thin line, which reads as "some + // setting changed" rather than "I am holding this tab". + + )} + {menu && (() => { const session = sessions.find((s) => s.id === menu.sessionId); const hasCustom = session diff --git a/app/src/components/terminal/TerminalView.tsx b/app/src/components/terminal/TerminalView.tsx index 3f8f7c4..7ec51a2 100644 --- a/app/src/components/terminal/TerminalView.tsx +++ b/app/src/components/terminal/TerminalView.tsx @@ -374,8 +374,12 @@ export default function TerminalView({ sessionId, active }: Props) { // Handle backend output -> terminal let aborted = false; - const detector = new UrlDetector((url) => - promptUrl(url, "Long URL detected"), + // The width is read per scan, not captured: only a break the terminal + // itself inserted may be deleted, and where that is moves with every + // resize. + const detector = new UrlDetector( + (url) => promptUrl(url, "Long URL detected"), + () => termRef.current?.cols ?? 0, ); detectorRef.current = detector; @@ -552,7 +556,9 @@ export default function TerminalView({ sessionId, active }: Props) { if (!projectId) return; // A sign-in page is the one case where the *window* size matters least and // the layout matters most, so it gets the ordinary desktop viewport. - openPageInContainerBrowser(projectId, safe, 1280, 720) + // `true`: from a terminal there is no Browser pane on screen, so the page + // needs a window of its own or it opens somewhere the user isn't looking. + openPageInContainerBrowser(projectId, safe, 1280, 720, true) .then((result) => { const push = useAppState.getState().pushToast; if (result.error) { @@ -560,7 +566,7 @@ export default function TerminalView({ sessionId, active }: Props) { } else { push({ kind: "success", - message: "Opened in the container’s browser — see the project’s Browser tab", + message: "Opened in the container’s browser", }); } }) diff --git a/app/src/lib/tauri-commands.ts b/app/src/lib/tauri-commands.ts index 86745b5..f80607e 100644 --- a/app/src/lib/tauri-commands.ts +++ b/app/src/lib/tauri-commands.ts @@ -229,13 +229,26 @@ export const getBrowserViewPopoutState = (projectId: string) => * container too, so the loop closes without the host — and a dev server on * container loopback, which is how you watch a UI Claude is building. Only * http/https; the backend rejects anything else. + * + * The viewer is started if it isn't already: asking for a page is asking to + * watch it, and leaving the user to go and press Start themselves — with no + * hint that they had to — is what the first version did. */ export const openPageInContainerBrowser = ( projectId: string, url: string, width: number, height: number, -) => invoke("open_page_in_container_browser", { projectId, url, width, height }); + /** Also raise the pop-out window — for callers with no pane on screen. */ + showWindow = false, +) => + invoke("open_page_in_container_browser", { + projectId, + url, + width, + height, + showWindow, + }); /** Resize that page. Real reflow, not a scaled screencast — see BrowserTab. */ export const setContainerPageViewport = (projectId: string, width: number, height: number) => invoke("set_container_page_viewport", { projectId, width, height }); diff --git a/app/src/lib/urlDetector.test.ts b/app/src/lib/urlDetector.test.ts new file mode 100644 index 0000000..bc856d4 --- /dev/null +++ b/app/src/lib/urlDetector.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { UrlDetector, flatten } 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"); +} + +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]); + }); +}); diff --git a/app/src/lib/urlDetector.ts b/app/src/lib/urlDetector.ts index d9fd103..e0d48a1 100644 --- a/app/src/lib/urlDetector.ts +++ b/app/src/lib/urlDetector.ts @@ -3,8 +3,23 @@ * * The Linux PTY hard-wraps long lines with \r\n at the terminal column width, * which breaks xterm.js WebLinksAddon URL detection. This class flattens - * the buffer (stripping PTY wraps, converting blank lines to spaces) and - * matches URLs with a single regex, firing a callback for ones >= 100 chars. + * the buffer (rejoining hard wraps, treating every other break as a + * terminator) and matches URLs with a single regex, firing a callback for ones + * >= 100 chars. + * + * ## Which line breaks may be deleted + * + * Only the ones the *terminal* inserted. A hard wrap happens at exactly the + * column width, so a line that reached the width was cut mid-token and its + * break must be removed to put the token back together; a line that stopped + * short ended for its own reasons and its break is a real separator. + * + * Deleting every break instead — which this did — glues unrelated output onto + * the end of a URL. Observed for real: a wrapped paragraph following a link + * became `…/tag/preview-63f3c54Butitprovesyournitpick…`, because a terminal + * that wraps at a space emits the break *instead of* the space, so removing + * the break removes the separator too. That candidate is a different URL from + * the one on screen, and the user is the one who has to notice. * * When a URL match extends to the end of the flattened buffer, emission is * deferred (more chunks may still be arriving). A confirmation timer emits @@ -21,6 +36,45 @@ const MIN_URL_LENGTH = 100; export type UrlCallback = (url: string) => void; +/** + * How wide the terminal is right now. + * + * A getter, not a number: the width changes with every window resize, and a + * stale one silently turns joining back into guesswork. + */ +export type ColumnsGetter = () => number; + +/** + * Rejoin the line breaks the terminal inserted; turn the rest into spaces. + * + * A line of exactly `columns` visible characters was cut by the terminal, so + * its break is deleted and the two halves are put back together. Anything + * shorter ended on its own and becomes a space — a URL cannot contain one, so + * that is also what stops a match running into whatever followed. + * + * `columns` of 0 or less means "not known yet"; nothing is rejoined, which + * costs a wrapped URL rather than inventing one. + * + * One case stays ambiguous and cannot be resolved here: a token that happens to + * end exactly at the width is indistinguishable from one the terminal cut, so + * the following line is joined to it. The candidate is still shown in full and + * confirmed by the user before anything opens. + */ +export function flatten(clean: string, columns: number): string { + const lines = clean.split(/\r?\n/); + let out = ""; + for (let i = 0; i < lines.length; i++) { + out += lines[i]; + if (i === lines.length - 1) break; + // `===`, not `>=`. A line *longer* than the width was never cut by the + // terminal — the stream simply contained no break there, so the break that + // follows it is the application's own and separates two things. + const wrapped = columns > 0 && lines[i].length === columns; + if (!wrapped) out += " "; + } + return out; +} + export class UrlDetector { private decoder = new TextDecoder(); private buffer = ""; @@ -29,9 +83,11 @@ export class UrlDetector { private lastEmitted = ""; private pendingUrl: string | null = null; private callback: UrlCallback; + private columns: ColumnsGetter; - constructor(callback: UrlCallback) { + constructor(callback: UrlCallback, columns: ColumnsGetter) { this.callback = callback; + this.columns = columns; } /** Feed raw PTY output chunks. */ @@ -61,12 +117,8 @@ export class UrlDetector { // 1. Strip ANSI escape sequences const clean = this.buffer.replace(ANSI_RE, ""); - // 2. Flatten the buffer: - // - Blank lines (2+ consecutive line breaks) → space (real paragraph break / URL terminator) - // - Remaining \r and \n → removed (PTY hard-wrap artifacts) - const flat = clean - .replace(/(\r?\n){2,}/g, " ") - .replace(/[\r\n]/g, ""); + // 2. Flatten the buffer: rejoin hard wraps, terminate on everything else. + const flat = flatten(clean, this.columns()); if (!flat) return;