-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Expand file tree
/
Copy pathterminal-link-manager.ts
More file actions
225 lines (205 loc) · 7.23 KB
/
Copy pathterminal-link-manager.ts
File metadata and controls
225 lines (205 loc) · 7.23 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
/*---------------------------------------------------------------------------------------------
* Adapted from VSCode's terminalLinkManager.ts
* https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkManager.ts
*
* Manages link provider registration for a terminal instance.
* Handles lifecycle (dispose old providers before re-registering),
* resolver caching, and priority ordering.
*--------------------------------------------------------------------------------------------*/
import type { ILinkHandler, Terminal as XTerm } from "@xterm/xterm";
import { UrlLinkProvider } from "../../screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers";
import type { DetectedLink } from "./links";
import {
LinkDetectorAdapter,
LocalLinkDetector,
type StatCallback,
TerminalLinkResolver,
WordLinkDetector,
} from "./links";
export type LinkHoverInfo =
| { kind: "file"; isDirectory: boolean; resolvedPath?: string }
| { kind: "url" };
/**
* Link handler callbacks for the v2 terminal.
*/
export interface TerminalLinkHandlers {
/** Called when a file path link is activated (Cmd/Ctrl+click). */
onFileLinkClick?: (event: MouseEvent, link: DetectedLink) => void;
/** Called when a URL link is activated. */
onUrlClick?: (event: MouseEvent, url: string) => void;
/** Called when the mouse enters a detected link (file path or URL). */
onLinkHover?: (event: MouseEvent, info: LinkHoverInfo) => void;
/** Called when the mouse leaves a previously hovered link. */
onLinkLeave?: () => void;
/**
* Stat callback to validate file paths exist. Called via the host service
* which handles all path resolution (relative, tilde, etc.) server-side.
*/
stat?: StatCallback;
}
interface LinkProviderDisposable {
dispose(): void;
}
/**
* Manages all link providers for a single terminal instance.
*
* Providers are registered in priority order (xterm uses first match):
* 1. LocalLinkDetector (file paths with validation) + styled-text fallback
* 2. UrlLinkProvider (hard-wrapped URL detection)
* 3. WordLinkDetector (bare filenames like "AGENTS.md")
*/
export class TerminalLinkManager {
private _disposables: LinkProviderDisposable[] = [];
private _resolver: TerminalLinkResolver | null = null;
private _handlers: TerminalLinkHandlers | null = null;
private _oscLinkHandler: ILinkHandler | null = null;
constructor(private readonly _terminal: XTerm) {}
/**
* Set link handlers and register providers. Safe to call multiple times —
* old providers are disposed before new ones are registered. The resolver
* is reused to preserve the stat cache.
*/
setHandlers(handlers: TerminalLinkHandlers): void {
this._handlers = handlers;
this._register();
}
/**
* Re-register providers (e.g. after terminal is created).
* No-op if handlers haven't been set yet.
*/
ensureRegistered(): void {
if (this._handlers) {
this._register();
}
}
dispose(): void {
for (const d of this._disposables) d.dispose();
this._disposables = [];
this._clearOscLinkHandler();
this._resolver?.clearCache();
this._resolver = null;
this._handlers = null;
}
private _clearOscLinkHandler(): void {
if (this._terminal.options.linkHandler === this._oscLinkHandler) {
this._terminal.options.linkHandler = null;
}
this._oscLinkHandler = null;
}
/**
* xterm activates a link whenever mousedown and mouseup hit the same link,
* which includes the tail of a double-click word-select or a drag within
* the link's own text. When an unmodified click ends with text selected,
* the gesture was selection — not navigation — so activation is skipped
* (also prevents double-click from activating twice). Modifier clicks
* always activate: shift-click extends a selection as a side effect, and
* suppressing it would make the binding unreachable.
*/
private _isSelectionGesture(event: MouseEvent): boolean {
return (
!event.metaKey &&
!event.ctrlKey &&
!event.shiftKey &&
this._terminal.hasSelection()
);
}
private _register(): void {
const handlers = this._handlers;
if (!handlers?.stat) return;
// Dispose old providers to prevent duplicates
for (const d of this._disposables) d.dispose();
this._disposables = [];
this._clearOscLinkHandler();
// Reuse resolver to preserve stat cache across re-registrations.
if (!this._resolver) {
this._resolver = new TerminalLinkResolver(handlers.stat);
}
const onLinkHover = handlers.onLinkHover;
const onLinkLeave = handlers.onLinkLeave;
const rawFileClick = handlers.onFileLinkClick;
const onFileClick = rawFileClick
? (event: MouseEvent, link: DetectedLink) => {
if (this._isSelectionGesture(event)) return;
rawFileClick(event, link);
}
: undefined;
// 1. File path detector (highest priority)
const detector = new LocalLinkDetector(this._resolver);
const adapter = new LinkDetectorAdapter(
this._terminal,
detector,
onFileClick,
onLinkHover
? (event, link) =>
onLinkHover(event, {
kind: "file",
isDirectory: link.isDirectory,
resolvedPath: link.resolvedPath,
})
: undefined,
onLinkLeave,
);
this._disposables.push(this._terminal.registerLinkProvider(adapter));
// 2. URL link provider (handles hard-wrapped URLs)
if (handlers.onUrlClick) {
const onUrlClick = handlers.onUrlClick;
const urlProvider = new UrlLinkProvider(
this._terminal,
(event, uri) => {
if (this._isSelectionGesture(event)) return;
onUrlClick(event, uri);
},
onLinkHover
? (event) => onLinkHover(event, { kind: "url" })
: undefined,
onLinkLeave,
);
this._disposables.push(this._terminal.registerLinkProvider(urlProvider));
// xterm always registers its own OSC 8 hyperlink provider first. Without
// this, OSC 8 links use xterm's default confirm() + window.open() path,
// which is blocked in Electron and also bypasses our link preferences.
this._oscLinkHandler = {
allowNonHttpProtocols: false,
activate: (event, uri) => {
if (this._isSelectionGesture(event)) return;
onUrlClick(event, uri);
},
hover: onLinkHover
? (event) => onLinkHover(event, { kind: "url" })
: undefined,
leave: onLinkLeave ? () => onLinkLeave() : undefined,
};
this._terminal.options.linkHandler = this._oscLinkHandler;
}
// 3. SUPERSET ADDITION: Word link detector (lowest priority).
// Adapted from VSCode's TerminalWordLinkDetector. VSCode opens a
// workspace search on click; ours opens the file directly if it
// exists (validated via stat). Catches bare filenames like
// "AGENTS.md" that have no path separator or line suffix.
// To disable: remove or comment out this block.
if (onFileClick) {
const wordDetector = new WordLinkDetector(
this._terminal,
this._resolver,
(event, resolvedPath) => {
onFileClick(event, {
text: resolvedPath,
startIndex: 0,
endIndex: 0,
resolvedPath,
isDirectory: false,
row: undefined,
col: undefined,
rowEnd: undefined,
colEnd: undefined,
});
},
onLinkHover
? (event) => onLinkHover(event, { kind: "file", isDirectory: false })
: undefined,
onLinkLeave,
);
this._disposables.push(this._terminal.registerLinkProvider(wordDetector));
}
}
}