Reorder tabs by dragging, and pop the browser view into its own window #19

Merged
jknapp merged 11 commits from feature/tab-reorder-browser-popout into main 2026-08-11 18:20:15 +00:00
7 changed files with 351 additions and 18 deletions
Showing only changes of commit f239fa1c82 - Show all commits
+36 -2
View File
@@ -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<page::PageState, String> {
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
@@ -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(<MainTabs />);
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(<MainTabs />);
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(<MainTabs />);
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(<MainTabs />);
const tabs = laidOut();
+72 -2
View File
@@ -55,9 +55,21 @@ export default function MainTabs() {
/** The tab being dragged, and the slot it would drop into. */
const [dragKey, setDragKey] = useState<string | null>(null);
const [dropIndex, setDropIndex] = useState<number | null>(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<HTMLDivElement>(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<HTMLDivElement>) => {
@@ -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<HTMLDivElement>) => {
const drag = pending.current;
@@ -408,6 +458,26 @@ export default function MainTabs() {
the hand naturally goes to say "put it at the end". */}
<div className="flex-1 self-stretch">{markerPending && dropMarker}</div>
{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".
<div
aria-hidden="true"
data-testid="tab-drag-ghost"
className="fixed z-50 flex items-center gap-1.5 px-3 h-8 text-xs rounded-[var(--radius-control)] bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--accent)] pointer-events-none"
style={{
left: ghost.x,
top: ghost.y,
boxShadow: "var(--shadow-overlay)",
opacity: 0.9,
}}
>
<span className="text-[var(--text-secondary)]">{ghost.icon}</span>
<span className="truncate max-w-[180px]">{ghost.label}</span>
</div>
)}
{menu && (() => {
const session = sessions.find((s) => s.id === menu.sessionId);
const hasCustom = session
+10 -4
View File
@@ -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 containers browser — see the projects Browser tab",
message: "Opened in the containers browser",
});
}
})
+14 -1
View File
@@ -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<BrowserPageState>("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<BrowserPageState>("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<void>("set_container_page_viewport", { projectId, width, height });
+118
View File
@@ -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]);
});
});
+61 -9
View File
@@ -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;