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,
|
url: String,
|
||||||
width: u32,
|
width: u32,
|
||||||
height: u32,
|
height: u32,
|
||||||
|
show_window: bool,
|
||||||
|
app_handle: AppHandle,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<page::PageState, String> {
|
) -> Result<page::PageState, String> {
|
||||||
let trimmed = url.trim();
|
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 container_id = running_container(&state, &project_id, "opening a page").await?;
|
||||||
let detection = crate::browser_view::detect::detect(&container_id).await?;
|
let detection = crate::browser_view::detect::detect(&container_id).await?;
|
||||||
page::open(
|
let opened = page::open(
|
||||||
&container_id,
|
&container_id,
|
||||||
&detection,
|
&detection,
|
||||||
trimmed,
|
trimmed,
|
||||||
page::Viewport::sane(width, height),
|
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
|
/// 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);
|
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", () => {
|
it("shows the drop marker only while a drag is under way", () => {
|
||||||
render(<MainTabs />);
|
render(<MainTabs />);
|
||||||
const tabs = laidOut();
|
const tabs = laidOut();
|
||||||
|
|||||||
@@ -55,9 +55,21 @@ export default function MainTabs() {
|
|||||||
/** The tab being dragged, and the slot it would drop into. */
|
/** The tab being dragged, and the slot it would drop into. */
|
||||||
const [dragKey, setDragKey] = useState<string | null>(null);
|
const [dragKey, setDragKey] = useState<string | null>(null);
|
||||||
const [dropIndex, setDropIndex] = useState<number | 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);
|
const stripRef = useRef<HTMLDivElement>(null);
|
||||||
/** A press that has not yet moved far enough to be a drag. */
|
/** 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);
|
const suppressClick = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -87,6 +99,7 @@ export default function MainTabs() {
|
|||||||
pending.current = null;
|
pending.current = null;
|
||||||
setDragKey(null);
|
setDragKey(null);
|
||||||
setDropIndex(null);
|
setDropIndex(null);
|
||||||
|
setGhost(null);
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKeyDown);
|
window.addEventListener("keydown", onKeyDown);
|
||||||
return () => window.removeEventListener("keydown", onKeyDown);
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
@@ -171,10 +184,29 @@ export default function MainTabs() {
|
|||||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||||
}${dragging ? " opacity-40" : ""}`;
|
}${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 = () => {
|
const endDrag = () => {
|
||||||
pending.current = null;
|
pending.current = null;
|
||||||
setDragKey(null);
|
setDragKey(null);
|
||||||
setDropIndex(null);
|
setDropIndex(null);
|
||||||
|
setGhost(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -212,7 +244,19 @@ export default function MainTabs() {
|
|||||||
// rename input is up — that drag is a text selection.
|
// rename input is up — that drag is a text selection.
|
||||||
if (e.button !== 0 || renaming) return;
|
if (e.button !== 0 || renaming) return;
|
||||||
if ((e.target as HTMLElement).closest("button, input")) 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);
|
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||||
},
|
},
|
||||||
onPointerMove: (e: React.PointerEvent<HTMLDivElement>) => {
|
onPointerMove: (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
@@ -223,6 +267,12 @@ export default function MainTabs() {
|
|||||||
drag.dragging = true;
|
drag.dragging = true;
|
||||||
setDragKey(drag.key);
|
setDragKey(drag.key);
|
||||||
setDropIndex(dropIndexAt(e.clientX));
|
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>) => {
|
onPointerUp: (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
const drag = pending.current;
|
const drag = pending.current;
|
||||||
@@ -408,6 +458,26 @@ export default function MainTabs() {
|
|||||||
the hand naturally goes to say "put it at the end". */}
|
the hand naturally goes to say "put it at the end". */}
|
||||||
<div className="flex-1 self-stretch">{markerPending && dropMarker}</div>
|
<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 && (() => {
|
{menu && (() => {
|
||||||
const session = sessions.find((s) => s.id === menu.sessionId);
|
const session = sessions.find((s) => s.id === menu.sessionId);
|
||||||
const hasCustom = session
|
const hasCustom = session
|
||||||
|
|||||||
@@ -374,8 +374,12 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
// Handle backend output -> terminal
|
// Handle backend output -> terminal
|
||||||
let aborted = false;
|
let aborted = false;
|
||||||
|
|
||||||
const detector = new UrlDetector((url) =>
|
// The width is read per scan, not captured: only a break the terminal
|
||||||
promptUrl(url, "Long URL detected"),
|
// 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;
|
detectorRef.current = detector;
|
||||||
|
|
||||||
@@ -552,7 +556,9 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
// A sign-in page is the one case where the *window* size matters least and
|
// 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.
|
// 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) => {
|
.then((result) => {
|
||||||
const push = useAppState.getState().pushToast;
|
const push = useAppState.getState().pushToast;
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
@@ -560,7 +566,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
} else {
|
} else {
|
||||||
push({
|
push({
|
||||||
kind: "success",
|
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 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
|
* container loopback, which is how you watch a UI Claude is building. Only
|
||||||
* http/https; the backend rejects anything else.
|
* 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 = (
|
export const openPageInContainerBrowser = (
|
||||||
projectId: string,
|
projectId: string,
|
||||||
url: string,
|
url: string,
|
||||||
width: number,
|
width: number,
|
||||||
height: 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. */
|
/** Resize that page. Real reflow, not a scaled screencast — see BrowserTab. */
|
||||||
export const setContainerPageViewport = (projectId: string, width: number, height: number) =>
|
export const setContainerPageViewport = (projectId: string, width: number, height: number) =>
|
||||||
invoke<void>("set_container_page_viewport", { projectId, width, height });
|
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,
|
* 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
|
* which breaks xterm.js WebLinksAddon URL detection. This class flattens
|
||||||
* the buffer (stripping PTY wraps, converting blank lines to spaces) and
|
* the buffer (rejoining hard wraps, treating every other break as a
|
||||||
* matches URLs with a single regex, firing a callback for ones >= 100 chars.
|
* 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
|
* 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
|
* 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;
|
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 {
|
export class UrlDetector {
|
||||||
private decoder = new TextDecoder();
|
private decoder = new TextDecoder();
|
||||||
private buffer = "";
|
private buffer = "";
|
||||||
@@ -29,9 +83,11 @@ export class UrlDetector {
|
|||||||
private lastEmitted = "";
|
private lastEmitted = "";
|
||||||
private pendingUrl: string | null = null;
|
private pendingUrl: string | null = null;
|
||||||
private callback: UrlCallback;
|
private callback: UrlCallback;
|
||||||
|
private columns: ColumnsGetter;
|
||||||
|
|
||||||
constructor(callback: UrlCallback) {
|
constructor(callback: UrlCallback, columns: ColumnsGetter) {
|
||||||
this.callback = callback;
|
this.callback = callback;
|
||||||
|
this.columns = columns;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Feed raw PTY output chunks. */
|
/** Feed raw PTY output chunks. */
|
||||||
@@ -61,12 +117,8 @@ export class UrlDetector {
|
|||||||
// 1. Strip ANSI escape sequences
|
// 1. Strip ANSI escape sequences
|
||||||
const clean = this.buffer.replace(ANSI_RE, "");
|
const clean = this.buffer.replace(ANSI_RE, "");
|
||||||
|
|
||||||
// 2. Flatten the buffer:
|
// 2. Flatten the buffer: rejoin hard wraps, terminate on everything else.
|
||||||
// - Blank lines (2+ consecutive line breaks) → space (real paragraph break / URL terminator)
|
const flat = flatten(clean, this.columns());
|
||||||
// - Remaining \r and \n → removed (PTY hard-wrap artifacts)
|
|
||||||
const flat = clean
|
|
||||||
.replace(/(\r?\n){2,}/g, " ")
|
|
||||||
.replace(/[\r\n]/g, "");
|
|
||||||
|
|
||||||
if (!flat) return;
|
if (!flat) return;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user