Reorder tabs by dragging, and pop the browser view into its own window #19
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user