Skip to content

Commit ba7aea7

Browse files
authored
Better integration for terminal links (better context-menu and add a tooltip) (#2934)
addresses issue #2901
1 parent ed7fa40 commit ba7aea7

5 files changed

Lines changed: 212 additions & 34 deletions

File tree

frontend/app/element/errorboundary.tsx

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export class ErrorBoundary extends React.Component<
1313
}
1414

1515
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
16-
console.log("ErrorBoundary caught an error:", error, errorInfo);
16+
console.error("ErrorBoundary caught an error:", error, errorInfo);
1717
this.setState({ error: error });
1818
}
1919

@@ -31,3 +31,28 @@ export class ErrorBoundary extends React.Component<
3131
}
3232
}
3333
}
34+
35+
export class NullErrorBoundary extends React.Component<
36+
{ children: React.ReactNode; debugName?: string },
37+
{ hasError: boolean }
38+
> {
39+
constructor(props: { children: React.ReactNode; debugName?: string }) {
40+
super(props);
41+
this.state = { hasError: false };
42+
}
43+
44+
static getDerivedStateFromError() {
45+
return { hasError: true };
46+
}
47+
48+
componentDidCatch(error: Error, info: React.ErrorInfo) {
49+
console.error(`${this.props.debugName ?? "NullErrorBoundary"} error boundary caught error`, error, info);
50+
}
51+
52+
render() {
53+
if (this.state.hasError) {
54+
return null;
55+
}
56+
return this.props.children;
57+
}
58+
}

frontend/app/view/term/term-model.ts

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -798,40 +798,37 @@ export class TermViewModel implements ViewModel {
798798
},
799799
});
800800

801-
let selectionURL: URL = null;
802-
if (selection) {
803-
try {
804-
const trimmedSelection = selection.trim();
805-
const url = new URL(trimmedSelection);
806-
if (url.protocol.startsWith("http")) {
807-
selectionURL = url;
808-
}
809-
} catch (e) {
810-
// not a valid URL
811-
}
812-
}
801+
menu.push({ type: "separator" });
802+
}
813803

814-
if (selectionURL) {
815-
menu.push({ type: "separator" });
804+
const hoveredLinkUri = this.termRef.current?.hoveredLinkUri;
805+
if (hoveredLinkUri) {
806+
let hoveredURL: URL = null;
807+
try {
808+
hoveredURL = new URL(hoveredLinkUri);
809+
} catch (e) {
810+
// not a valid URL
811+
}
812+
if (hoveredURL) {
816813
menu.push({
817-
label: "Open URL (" + selectionURL.hostname + ")",
814+
label: hoveredURL.hostname ? "Open URL (" + hoveredURL.hostname + ")" : "Open URL",
818815
click: () => {
819816
createBlock({
820817
meta: {
821818
view: "web",
822-
url: selectionURL.toString(),
819+
url: hoveredURL.toString(),
823820
},
824821
});
825822
},
826823
});
827824
menu.push({
828825
label: "Open URL in External Browser",
829826
click: () => {
830-
getApi().openExternal(selectionURL.toString());
827+
getApi().openExternal(hoveredURL.toString());
831828
},
832829
});
830+
menu.push({ type: "separator" });
833831
}
834-
menu.push({ type: "separator" });
835832
}
836833

837834
menu.push({
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
// Copyright 2025, Command Line Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { PLATFORM, PlatformMacOS } from "@/util/platformutil";
5+
import { FloatingPortal, VirtualElement, flip, offset, shift, useFloating } from "@floating-ui/react";
6+
import * as React from "react";
7+
import type { TermWrap } from "./termwrap";
8+
9+
// ── low-level primitive ──────────────────────────────────────────────────────
10+
11+
interface TermTooltipProps {
12+
/** Screen-space mouse position (clientX/clientY). null means hidden. */
13+
mousePos: { x: number; y: number } | null;
14+
content: React.ReactNode;
15+
}
16+
17+
/**
18+
* A floating tooltip anchored to the current mouse position.
19+
* Uses a floating-ui virtual element (via refs.setPositionReference) so no
20+
* real DOM reference is required. Renders into a FloatingPortal.
21+
*/
22+
export const TermTooltip = React.memo(function TermTooltip({ mousePos, content }: TermTooltipProps) {
23+
const isOpen = mousePos != null;
24+
25+
// Keep latest mousePos in a ref so the virtual element always reflects it.
26+
const mousePosRef = React.useRef(mousePos);
27+
mousePosRef.current = mousePos;
28+
29+
const { refs, floatingStyles } = useFloating({
30+
open: isOpen,
31+
placement: "top-start",
32+
middleware: [offset({ mainAxis: 12, crossAxis: -20 }), flip(), shift({ padding: 0 })],
33+
});
34+
35+
// Update the position reference whenever mousePos changes.
36+
React.useLayoutEffect(() => {
37+
if (!isOpen) {
38+
return;
39+
}
40+
const virtualEl: VirtualElement = {
41+
getBoundingClientRect() {
42+
const pos = mousePosRef.current ?? { x: 0, y: 0 };
43+
return new DOMRect(pos.x, pos.y, 0, 0);
44+
},
45+
};
46+
refs.setPositionReference(virtualEl);
47+
}, [isOpen, mousePos?.x, mousePos?.y]);
48+
49+
if (!isOpen) {
50+
return null;
51+
}
52+
53+
return (
54+
<FloatingPortal>
55+
<div
56+
ref={refs.setFloating}
57+
style={floatingStyles}
58+
className="bg-zinc-800/70 rounded-md px-2 py-1 text-xs text-secondary shadow-xl z-50 pointer-events-none select-none"
59+
>
60+
{content}
61+
</div>
62+
</FloatingPortal>
63+
);
64+
});
65+
66+
// ── wired-up sub-component ───────────────────────────────────────────────────
67+
68+
function clearTimeoutRef(ref: React.RefObject<number | null>) {
69+
if (ref.current == null) {
70+
return;
71+
}
72+
window.clearTimeout(ref.current);
73+
ref.current = null;
74+
}
75+
76+
const HoverDelayMs = 600;
77+
const MaxHoverTimeMs = 2200;
78+
const modKey = PLATFORM === PlatformMacOS ? "Cmd" : "Ctrl";
79+
80+
interface TermLinkTooltipProps {
81+
/**
82+
* The live TermWrap instance. Pass the instance directly (not a ref) so
83+
* React re-runs the effect when it changes (e.g. on terminal recreate).
84+
*/
85+
termWrap: TermWrap | null;
86+
}
87+
88+
/**
89+
* Self-contained sub-component that subscribes to the termWrap link-hover
90+
* callback and renders a tooltip after a short delay. Keeping state here
91+
* prevents unnecessary re-renders of the parent TerminalView.
92+
*/
93+
export const TermLinkTooltip = React.memo(function TermLinkTooltip({ termWrap }: TermLinkTooltipProps) {
94+
const [mousePos, setMousePos] = React.useState<{ x: number; y: number } | null>(null);
95+
const timeoutRef = React.useRef<number | null>(null);
96+
const maxTimeoutRef = React.useRef<number | null>(null);
97+
98+
React.useEffect(() => {
99+
if (termWrap == null) {
100+
return;
101+
}
102+
103+
termWrap.onLinkHover = (uri: string | null, mouseX: number, mouseY: number) => {
104+
clearTimeoutRef(timeoutRef);
105+
106+
if (uri == null) {
107+
clearTimeoutRef(maxTimeoutRef);
108+
setMousePos(null);
109+
return;
110+
}
111+
112+
// Show after a short delay so fast mouse movements don't flicker.
113+
timeoutRef.current = window.setTimeout(() => {
114+
timeoutRef.current = null;
115+
setMousePos({ x: mouseX, y: mouseY });
116+
// Auto-dismiss after MaxHoverTimeMs so the tooltip doesn't linger forever.
117+
clearTimeoutRef(maxTimeoutRef);
118+
maxTimeoutRef.current = window.setTimeout(() => {
119+
maxTimeoutRef.current = null;
120+
setMousePos(null);
121+
}, MaxHoverTimeMs);
122+
}, HoverDelayMs);
123+
};
124+
125+
return () => {
126+
termWrap.onLinkHover = null;
127+
clearTimeoutRef(timeoutRef);
128+
clearTimeoutRef(maxTimeoutRef);
129+
setMousePos(null);
130+
};
131+
}, [termWrap]);
132+
133+
return <TermTooltip mousePos={mousePos} content={<span>{modKey}-click to open link</span>} />;
134+
});

frontend/app/view/term/term.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
// Copyright 2025, Command Line Inc.
22
// SPDX-License-Identifier: Apache-2.0
33

4-
import { Block, SubBlock } from "@/app/block/block";
4+
import { SubBlock } from "@/app/block/block";
55
import type { BlockNodeModel } from "@/app/block/blocktypes";
6+
import { NullErrorBoundary } from "@/app/element/errorboundary";
67
import { Search, useSearch } from "@/app/element/search";
78
import { ContextMenuModel } from "@/app/store/contextmenu";
89
import { useTabModel } from "@/app/store/tab-model";
@@ -18,6 +19,7 @@ import clsx from "clsx";
1819
import debug from "debug";
1920
import * as jotai from "jotai";
2021
import * as React from "react";
22+
import { TermLinkTooltip } from "./term-tooltip";
2123
import { TermStickers } from "./termsticker";
2224
import { TermThemeUpdater } from "./termtheme";
2325
import { computeTheme, normalizeCursorStyle } from "./termutil";
@@ -167,6 +169,7 @@ const TermToolbarVDomNode = ({ blockId, model }: TerminalViewProps) => {
167169
const TerminalView = ({ blockId, model }: ViewComponentProps<TermViewModel>) => {
168170
const viewRef = React.useRef<HTMLDivElement>(null);
169171
const connectElemRef = React.useRef<HTMLDivElement>(null);
172+
const [termWrapInst, setTermWrapInst] = React.useState<TermWrap | null>(null);
170173
const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId));
171174
const termSettingsAtom = getSettingsPrefixAtom("term");
172175
const termSettings = jotai.useAtomValue(termSettingsAtom);
@@ -306,6 +309,7 @@ const TerminalView = ({ blockId, model }: ViewComponentProps<TermViewModel>) =>
306309
);
307310
(window as any).term = termWrap;
308311
model.termRef.current = termWrap;
312+
setTermWrapInst(termWrap);
309313
const rszObs = new ResizeObserver(() => {
310314
termWrap.handleResize_debounced();
311315
});
@@ -323,6 +327,7 @@ const TerminalView = ({ blockId, model }: ViewComponentProps<TermViewModel>) =>
323327
return () => {
324328
termWrap.dispose();
325329
rszObs.disconnect();
330+
setTermWrapInst(null);
326331
};
327332
}, [blockId, termSettings, termFontSize, connFontFamily]);
328333

@@ -394,6 +399,9 @@ const TerminalView = ({ blockId, model }: ViewComponentProps<TermViewModel>) =>
394399
onPointerOver={onScrollbarHideObserver}
395400
/>
396401
</div>
402+
<NullErrorBoundary debugName="TermLinkTooltip">
403+
<TermLinkTooltip termWrap={termWrapInst} />
404+
</NullErrorBoundary>
397405
<Search {...searchProps} />
398406
</div>
399407
);

frontend/app/view/term/termwrap.ts

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ export class TermWrap {
8989
shellIntegrationStatusAtom: jotai.PrimitiveAtom<ShellIntegrationStatus | null>;
9090
lastCommandAtom: jotai.PrimitiveAtom<string | null>;
9191
nodeModel: BlockNodeModel; // this can be null
92+
hoveredLinkUri: string | null = null;
93+
onLinkHover?: (uri: string | null, mouseX: number, mouseY: number) => void;
9294

9395
// IME composition state tracking
9496
// Prevents duplicate input when switching input methods during composition (e.g., using Capslock)
@@ -132,21 +134,33 @@ export class TermWrap {
132134
this.terminal.loadAddon(this.fitAddon);
133135
this.terminal.loadAddon(this.serializeAddon);
134136
this.terminal.loadAddon(
135-
new WebLinksAddon((e, uri) => {
136-
e.preventDefault();
137-
switch (PLATFORM) {
138-
case PlatformMacOS:
139-
if (e.metaKey) {
140-
fireAndForget(() => openLink(uri));
141-
}
142-
break;
143-
default:
144-
if (e.ctrlKey) {
145-
fireAndForget(() => openLink(uri));
146-
}
147-
break;
137+
new WebLinksAddon(
138+
(e, uri) => {
139+
e.preventDefault();
140+
switch (PLATFORM) {
141+
case PlatformMacOS:
142+
if (e.metaKey) {
143+
fireAndForget(() => openLink(uri));
144+
}
145+
break;
146+
default:
147+
if (e.ctrlKey) {
148+
fireAndForget(() => openLink(uri));
149+
}
150+
break;
151+
}
152+
},
153+
{
154+
hover: (e, uri) => {
155+
this.hoveredLinkUri = uri;
156+
this.onLinkHover?.(uri, e.clientX, e.clientY);
157+
},
158+
leave: () => {
159+
this.hoveredLinkUri = null;
160+
this.onLinkHover?.(null, 0, 0);
161+
},
148162
}
149-
})
163+
)
150164
);
151165
if (WebGLSupported && waveOptions.useWebGl) {
152166
const webglAddon = new WebglAddon();

0 commit comments

Comments
 (0)