Skip to content

Commit 6d88f92

Browse files
committed
feat(desktop): v2 terminal shows hover tooltip describing cmd-click action
Adds onLinkHover/onLinkLeave callbacks to TerminalLinkHandlers, wired through LinkDetectorAdapter, UrlLinkProvider, and WordLinkDetector so every detected link participates. In v2 TerminalPane, a new LinkHoverTooltip component tracks hover + live modifier state (global keydown/keyup listeners scoped to hover duration) and portals a positioned tooltip to document.body when meta/ctrl is held. Content flips on shift: - File: Open in editor | shift: Open externally - Folder: Reveal in sidebar | shift: Open externally - URL: Open in browser | shift: Open in external browser v1's helpers.ts doesn't opt into the new callbacks, so v1 hover behavior is unchanged.
1 parent 9e87499 commit 6d88f92

File tree

9 files changed

+179
-4
lines changed

9 files changed

+179
-4
lines changed

apps/desktop/src/renderer/lib/terminal/links/link-detector-adapter.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ export class LinkDetectorAdapter implements ILinkProvider {
4545
event: MouseEvent,
4646
link: DetectedLink,
4747
) => void,
48+
private readonly _onHover?: (event: MouseEvent, link: DetectedLink) => void,
49+
private readonly _onLeave?: () => void,
4850
) {}
4951

5052
provideLinks(
@@ -192,6 +194,12 @@ export class LinkDetectorAdapter implements ILinkProvider {
192194
activate: (event: MouseEvent) => {
193195
this._onActivate?.(event, detected);
194196
},
197+
hover: (event: MouseEvent) => {
198+
this._onHover?.(event, detected);
199+
},
200+
leave: () => {
201+
this._onLeave?.();
202+
},
195203
});
196204
}
197205
}
@@ -233,6 +241,12 @@ export class LinkDetectorAdapter implements ILinkProvider {
233241
activate: (event: MouseEvent) => {
234242
this._onActivate?.(event, detected);
235243
},
244+
hover: (event: MouseEvent) => {
245+
this._onHover?.(event, detected);
246+
},
247+
leave: () => {
248+
this._onLeave?.();
249+
},
236250
});
237251
}
238252

apps/desktop/src/renderer/lib/terminal/links/word-link-detector.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ export class WordLinkDetector implements ILinkProvider {
5252
event: MouseEvent,
5353
resolvedPath: string,
5454
) => void,
55+
private readonly _onHover?: (
56+
event: MouseEvent,
57+
resolvedPath: string,
58+
) => void,
59+
private readonly _onLeave?: () => void,
5560
) {
5661
this._separatorRegex = buildSeparatorRegex(DEFAULT_WORD_SEPARATORS);
5762
}
@@ -108,6 +113,12 @@ export class WordLinkDetector implements ILinkProvider {
108113
activate: (event: MouseEvent) => {
109114
this._onActivate?.(event, resolved.path);
110115
},
116+
hover: (event: MouseEvent) => {
117+
this._onHover?.(event, resolved.path);
118+
},
119+
leave: () => {
120+
this._onLeave?.();
121+
},
111122
});
112123
}
113124

apps/desktop/src/renderer/lib/terminal/terminal-link-manager.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ import {
1818
WordLinkDetector,
1919
} from "./links";
2020

21+
export type LinkHoverInfo =
22+
| { kind: "file"; isDirectory: boolean }
23+
| { kind: "url" };
24+
2125
/**
2226
* Link handler callbacks for the v2 terminal.
2327
*/
@@ -26,6 +30,10 @@ export interface TerminalLinkHandlers {
2630
onFileLinkClick?: (event: MouseEvent, link: DetectedLink) => void;
2731
/** Called when a URL link is activated. */
2832
onUrlClick?: (event: MouseEvent, url: string) => void;
33+
/** Called when the mouse enters a detected link (file path or URL). */
34+
onLinkHover?: (event: MouseEvent, info: LinkHoverInfo) => void;
35+
/** Called when the mouse leaves a previously hovered link. */
36+
onLinkLeave?: () => void;
2937
/**
3038
* Stat callback to validate file paths exist. Called via the host service
3139
* which handles all path resolution (relative, tilde, etc.) server-side.
@@ -93,21 +101,39 @@ export class TerminalLinkManager {
93101
this._resolver = new TerminalLinkResolver(handlers.stat);
94102
}
95103

104+
const onLinkHover = handlers.onLinkHover;
105+
const onLinkLeave = handlers.onLinkLeave;
106+
96107
// 1. File path detector (highest priority)
97108
const detector = new LocalLinkDetector(this._resolver);
98109
const adapter = new LinkDetectorAdapter(
99110
this._terminal,
100111
detector,
101112
handlers.onFileLinkClick,
113+
onLinkHover
114+
? (event, link) =>
115+
onLinkHover(event, {
116+
kind: "file",
117+
isDirectory: link.isDirectory,
118+
})
119+
: undefined,
120+
onLinkLeave,
102121
);
103122
this._disposables.push(this._terminal.registerLinkProvider(adapter));
104123

105124
// 2. URL link provider (handles hard-wrapped URLs)
106125
if (handlers.onUrlClick) {
107126
const onUrlClick = handlers.onUrlClick;
108-
const urlProvider = new UrlLinkProvider(this._terminal, (event, uri) => {
109-
onUrlClick(event, uri);
110-
});
127+
const urlProvider = new UrlLinkProvider(
128+
this._terminal,
129+
(event, uri) => {
130+
onUrlClick(event, uri);
131+
},
132+
onLinkHover
133+
? (event) => onLinkHover(event, { kind: "url" })
134+
: undefined,
135+
onLinkLeave,
136+
);
111137
this._disposables.push(this._terminal.registerLinkProvider(urlProvider));
112138
}
113139

@@ -135,6 +161,10 @@ export class TerminalLinkManager {
135161
colEnd: undefined,
136162
});
137163
},
164+
onLinkHover
165+
? (event) => onLinkHover(event, { kind: "file", isDirectory: false })
166+
: undefined,
167+
onLinkLeave,
138168
);
139169
this._disposables.push(this._terminal.registerLinkProvider(wordDetector));
140170
}

apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { ProgressAddon } from "@xterm/addon-progress";
22
import type { SearchAddon } from "@xterm/addon-search";
33
import type { TerminalAppearance } from "./appearance";
44
import {
5+
type LinkHoverInfo,
56
type TerminalLinkHandlers,
67
TerminalLinkManager,
78
} from "./terminal-link-manager";
@@ -216,4 +217,4 @@ if (import.meta.hot) {
216217
import.meta.hot.data.registry = terminalRuntimeRegistry;
217218
}
218219

219-
export type { ConnectionState, TerminalLinkHandlers };
220+
export type { ConnectionState, LinkHoverInfo, TerminalLinkHandlers };

apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ import { ScrollToBottomButton } from "renderer/screens/main/components/Workspace
2525
import { TerminalSearch } from "renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/TerminalSearch";
2626
import { useTheme } from "renderer/stores/theme";
2727
import { resolveTerminalThemeType } from "renderer/stores/theme/utils";
28+
import {
29+
LinkHoverTooltip,
30+
useLinkHoverState,
31+
} from "./components/LinkHoverTooltip";
2832
import { useTerminalAppearance } from "./hooks/useTerminalAppearance";
2933

3034
interface TerminalPaneProps {
@@ -50,6 +54,11 @@ export function TerminalPane({
5054
onRevealPath,
5155
}: TerminalPaneProps) {
5256
const openInExternalEditor = useOpenInExternalEditor(workspaceId);
57+
const {
58+
hoveredLink,
59+
onHover: onLinkHover,
60+
onLeave: onLinkLeave,
61+
} = useLinkHoverState();
5362
const paneData = ctx.pane.data as TerminalPaneData;
5463
const { terminalId } = paneData;
5564
const containerRef = useRef<HTMLDivElement | null>(null);
@@ -180,6 +189,8 @@ export function TerminalPane({
180189
},
181190
});
182191
},
192+
onLinkHover,
193+
onLinkLeave,
183194
});
184195
}, [
185196
terminalId,
@@ -188,6 +199,8 @@ export function TerminalPane({
188199
onOpenFile,
189200
onRevealPath,
190201
openInExternalEditor,
202+
onLinkHover,
203+
onLinkLeave,
191204
]);
192205

193206
useHotkey(
@@ -244,6 +257,7 @@ export function TerminalPane({
244257
<span>Disconnected</span>
245258
</div>
246259
)}
260+
<LinkHoverTooltip hoveredLink={hoveredLink} />
247261
</div>
248262
);
249263
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { useCallback, useEffect, useState } from "react";
2+
import { createPortal } from "react-dom";
3+
import type { LinkHoverInfo } from "renderer/lib/terminal/terminal-runtime-registry";
4+
5+
interface HoveredLink {
6+
clientX: number;
7+
clientY: number;
8+
info: LinkHoverInfo;
9+
modifier: boolean;
10+
shift: boolean;
11+
}
12+
13+
interface LinkHoverTooltipProps {
14+
hoveredLink: HoveredLink | null;
15+
}
16+
17+
function getLabel(info: LinkHoverInfo, shift: boolean): string {
18+
if (info.kind === "url") {
19+
return shift ? "Open in external browser" : "Open in browser";
20+
}
21+
if (info.isDirectory) {
22+
return shift ? "Open externally" : "Reveal in sidebar";
23+
}
24+
return shift ? "Open externally" : "Open in editor";
25+
}
26+
27+
export function LinkHoverTooltip({ hoveredLink }: LinkHoverTooltipProps) {
28+
if (!hoveredLink || !hoveredLink.modifier) return null;
29+
30+
const label = getLabel(hoveredLink.info, hoveredLink.shift);
31+
32+
return createPortal(
33+
<div
34+
className="pointer-events-none fixed z-50 rounded-md border border-border bg-popover px-2 py-1 text-xs text-popover-foreground shadow-md"
35+
style={{
36+
left: hoveredLink.clientX + 14,
37+
top: hoveredLink.clientY + 14,
38+
}}
39+
>
40+
{label}
41+
</div>,
42+
document.body,
43+
);
44+
}
45+
46+
export function useLinkHoverState() {
47+
const [hoveredLink, setHoveredLink] = useState<HoveredLink | null>(null);
48+
49+
useEffect(() => {
50+
if (!hoveredLink) return;
51+
const update = (event: KeyboardEvent) => {
52+
setHoveredLink((prev) => {
53+
if (!prev) return null;
54+
return {
55+
...prev,
56+
modifier: event.metaKey || event.ctrlKey,
57+
shift: event.shiftKey,
58+
};
59+
});
60+
};
61+
window.addEventListener("keydown", update);
62+
window.addEventListener("keyup", update);
63+
return () => {
64+
window.removeEventListener("keydown", update);
65+
window.removeEventListener("keyup", update);
66+
};
67+
}, [hoveredLink]);
68+
69+
const onHover = useCallback((event: MouseEvent, info: LinkHoverInfo) => {
70+
setHoveredLink({
71+
clientX: event.clientX,
72+
clientY: event.clientY,
73+
info,
74+
modifier: event.metaKey || event.ctrlKey,
75+
shift: event.shiftKey,
76+
});
77+
}, []);
78+
79+
const onLeave = useCallback(() => {
80+
setHoveredLink(null);
81+
}, []);
82+
83+
return { hoveredLink, onHover, onLeave };
84+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { LinkHoverTooltip, useLinkHoverState } from "./LinkHoverTooltip";

apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/multi-line-link-provider.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ export abstract class MultiLineLinkProvider implements ILinkProvider {
4242
regexMatch: RegExpMatchArray,
4343
): void;
4444

45+
/** Optional hooks fired when the mouse enters/leaves a detected link. */
46+
protected handleHover?(event: MouseEvent, text: string): void;
47+
protected handleLeave?(): void;
48+
4549
/**
4650
* Optional hook to transform a match before creating the link.
4751
* Useful for stripping trailing characters. Return null to skip the match.
@@ -177,6 +181,12 @@ export abstract class MultiLineLinkProvider implements ILinkProvider {
177181
activate: (event: MouseEvent, text: string) => {
178182
this.handleActivation(event, text, match);
179183
},
184+
hover: (event: MouseEvent, text: string) => {
185+
this.handleHover?.(event, text);
186+
},
187+
leave: () => {
188+
this.handleLeave?.();
189+
},
180190
});
181191
}
182192
}

apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/url-link-provider.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,10 +313,20 @@ export class UrlLinkProvider extends MultiLineLinkProvider {
313313
constructor(
314314
terminal: Terminal,
315315
private readonly onOpen: (event: MouseEvent, uri: string) => void,
316+
private readonly onHover?: (event: MouseEvent, uri: string) => void,
317+
private readonly onLeave?: () => void,
316318
) {
317319
super(terminal);
318320
}
319321

322+
protected handleHover(event: MouseEvent, text: string): void {
323+
this.onHover?.(event, text);
324+
}
325+
326+
protected handleLeave(): void {
327+
this.onLeave?.();
328+
}
329+
320330
protected getPattern(): RegExp {
321331
return new RegExp(this.URL_PATTERN.source, "g");
322332
}

0 commit comments

Comments
 (0)