Fix three things found by actually using it
Build App / compute-version (pull_request) Successful in 5s
Build App / build-macos (pull_request) Successful in 2m33s
Build App / build-windows (pull_request) Successful in 5m16s
Build App / build-linux (pull_request) Successful in 5m25s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Build App / compute-version (pull_request) Successful in 5s
Build App / build-macos (pull_request) Successful in 2m33s
Build App / build-windows (pull_request) Successful in 5m16s
Build App / build-linux (pull_request) Successful in 5m25s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
**The drag showed no tab.** Moving to pointer events lost the drag image the OS used to supply, leaving a dimmed source tab and a 2px line — which reads as "some setting changed", not "I am holding this tab". A copy of the tab now follows the cursor, carrying its glyph and its real label, grabbed at the offset it was picked up by so it sits where the tab was. **The URL relay opened a different URL than the one on screen.** Observed: `repo.anhonesthost.net/…/tag/preview-63f3c54` arrived as `repo.anhonsthost.nt/…/preview-63f3c54Butitprovesyournitpick…`. The detector deleted *every* line break to undo PTY hard-wrapping, but a terminal that wraps at a space emits the break **instead of** the space — so deleting breaks also deletes the separators, gluing the following paragraph onto the link and running the match past the host. Only breaks the terminal inserted may be deleted, and those are exactly the ones at the column width. The detector now takes a live column getter and rejoins a line only when it is exactly that wide; every other break becomes a space, which is also what stops a URL match. Lines *longer* than the width are left alone — the stream had no break there, so the one that follows is the application's own. One case stays ambiguous: a URL whose length is an exact multiple of the width is indistinguishable from one that was cut. That is pinned in a test as known behaviour rather than papered over — the candidate is shown in full and nothing opens without the user pressing Open. **"In container" opened a page nobody could see.** It bound the browser and stopped, leaving the user to find the Browser tab and press Start, with nothing saying so — and from a terminal, no pane on screen at all. Opening a page now starts the viewer if it isn't running, and the terminal's prompt raises the pop-out window, because that caller has nowhere else to put it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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