The import/no-cycle refactor had moved IBuffer, IBufferSet, and related cell/line types into src/common/Types.ts, with buffer/Types.ts only re-exporting them. That inverted the natural module boundary and made buffer code depend on the top-level types hub. Define buffer-owned types in buffer/Types.ts instead: - CharData, IExtendedAttrs, IAttributeData, ICellData, IBufferLine - IMarker, IBuffer, IBufferSet, BufferIndex Restore UnderlineStyle to buffer/Constants.ts (used by IExtendedAttrs) and remove those definitions from Types.ts. buffer/Types.ts imports only what it needs from outside the buffer layer: ICircularList and ICharset from Types, IDisposable from Lifecycle, IEvent from Event, and UnderlineStyle from Constants. Types.ts does not import buffer/Types.ts, so the previous Types <-> buffer cycle stays broken. Update imports across common, browser, headless, addons, and tests to use common/buffer/Types for buffer types rather than common/Types. Co-authored-by: Cursor <cursoragent@cursor.com>
198 lines
6.3 KiB
TypeScript
198 lines
6.3 KiB
TypeScript
/**
|
|
* Copyright (c) 2022 The xterm.js authors. All rights reserved.
|
|
* @license MIT
|
|
*/
|
|
|
|
import { IBufferRange, ILink } from './Types';
|
|
import { ILinkProvider } from './services/Services';
|
|
import { CellData } from '../common/buffer/CellData';
|
|
import { IBufferLine } from '../common/buffer/Types';
|
|
import { IBufferService, IOptionsService, IOscLinkService } from '../common/services/Services';
|
|
|
|
export class OscLinkProvider implements ILinkProvider {
|
|
private readonly _workCell = new CellData();
|
|
|
|
constructor(
|
|
@IBufferService private readonly _bufferService: IBufferService,
|
|
@IOptionsService private readonly _optionsService: IOptionsService,
|
|
@IOscLinkService private readonly _oscLinkService: IOscLinkService
|
|
) {
|
|
}
|
|
|
|
public provideLinks(y: number, callback: (links: ILink[] | undefined) => void): void {
|
|
const line = this._bufferService.buffer.lines.get(y - 1);
|
|
if (!line) {
|
|
callback(undefined);
|
|
return;
|
|
}
|
|
|
|
const result: ILink[] = [];
|
|
const linkHandler = this._optionsService.rawOptions.linkHandler;
|
|
const cell = this._workCell;
|
|
const lineLength = line.getTrimmedLength();
|
|
let currentLinkId = -1;
|
|
let currentStart = -1;
|
|
let finishLink = false;
|
|
for (let x = 0; x < lineLength; x++) {
|
|
// Minor optimization, only check for content if there isn't a link in case the link ends with
|
|
// a null cell
|
|
if (currentStart === -1 && !line.hasContent(x)) {
|
|
continue;
|
|
}
|
|
|
|
line.loadCell(x, cell);
|
|
if (cell.hasExtendedAttrs() && cell.extended.urlId) {
|
|
if (currentStart === -1) {
|
|
currentStart = x;
|
|
currentLinkId = cell.extended.urlId;
|
|
continue;
|
|
} else {
|
|
finishLink = cell.extended.urlId !== currentLinkId;
|
|
}
|
|
} else {
|
|
if (currentStart !== -1) {
|
|
finishLink = true;
|
|
}
|
|
}
|
|
|
|
if (finishLink || (currentStart !== -1 && x === lineLength - 1)) {
|
|
const text = this._oscLinkService.getLinkData(currentLinkId)?.uri;
|
|
if (text) {
|
|
const endX = x + (!finishLink && x === lineLength - 1 ? 1 : 0);
|
|
const range = this._getRangeWithLineWrap(y, currentStart, endX, currentLinkId);
|
|
let ignoreLink = false;
|
|
if (!linkHandler?.allowNonHttpProtocols) {
|
|
try {
|
|
const parsed = new URL(text);
|
|
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
ignoreLink = true;
|
|
}
|
|
} catch {
|
|
// Ignore invalid URLs to prevent unexpected behaviors
|
|
ignoreLink = true;
|
|
}
|
|
}
|
|
|
|
if (!ignoreLink) {
|
|
// OSC links always use underline and pointer decorations
|
|
result.push({
|
|
text,
|
|
range,
|
|
activate: (e, text) => (linkHandler ? linkHandler.activate(e, text, range) : defaultActivate(e, text)),
|
|
hover: (e, text) => linkHandler?.hover?.(e, text, range),
|
|
leave: (e, text) => linkHandler?.leave?.(e, text, range)
|
|
});
|
|
}
|
|
}
|
|
finishLink = false;
|
|
|
|
// Clear link or start a new link if one starts immediately
|
|
if (cell.hasExtendedAttrs() && cell.extended.urlId) {
|
|
currentStart = x;
|
|
currentLinkId = cell.extended.urlId;
|
|
} else {
|
|
currentStart = -1;
|
|
currentLinkId = -1;
|
|
}
|
|
}
|
|
}
|
|
|
|
// TODO: Handle fetching and returning other link ranges to underline other links with the same
|
|
// id
|
|
callback(result);
|
|
}
|
|
|
|
/**
|
|
* Expand a single-line OSC 8 range to a contiguous wrapped range for the same link id.
|
|
*/
|
|
private _getRangeWithLineWrap(y: number, startX: number, endX: number, linkId: number): IBufferRange {
|
|
let startY = y;
|
|
let finalStartX = startX;
|
|
let endY = y;
|
|
let finalEndX = endX;
|
|
|
|
// Expand upward only when this segment starts at column 0 and the current line is wrapped.
|
|
while (finalStartX === 0) {
|
|
const currentLine = this._bufferService.buffer.lines.get(startY - 1);
|
|
if (!currentLine?.isWrapped) {
|
|
break;
|
|
}
|
|
const previousLine = this._bufferService.buffer.lines.get(startY - 2);
|
|
if (!previousLine) {
|
|
break;
|
|
}
|
|
const previousLineLength = previousLine.getTrimmedLength();
|
|
if (previousLineLength === 0 || !this._hasUrlId(previousLine, previousLineLength - 1, linkId)) {
|
|
break;
|
|
}
|
|
let previousStartX = previousLineLength - 1;
|
|
while (previousStartX > 0 && this._hasUrlId(previousLine, previousStartX - 1, linkId)) {
|
|
previousStartX--;
|
|
}
|
|
startY--;
|
|
finalStartX = previousStartX;
|
|
}
|
|
|
|
// Expand downward only when this segment reaches trimmed EOL and the next line is wrapped.
|
|
while (true) {
|
|
const currentLine = this._bufferService.buffer.lines.get(endY - 1);
|
|
if (!currentLine) {
|
|
break;
|
|
}
|
|
const currentLineLength = currentLine.getTrimmedLength();
|
|
if (finalEndX !== currentLineLength) {
|
|
break;
|
|
}
|
|
const nextLine = this._bufferService.buffer.lines.get(endY);
|
|
if (!nextLine?.isWrapped) {
|
|
break;
|
|
}
|
|
const nextLineLength = nextLine.getTrimmedLength();
|
|
if (nextLineLength === 0 || !this._hasUrlId(nextLine, 0, linkId)) {
|
|
break;
|
|
}
|
|
let nextEndX = 1;
|
|
while (nextEndX < nextLineLength && this._hasUrlId(nextLine, nextEndX, linkId)) {
|
|
nextEndX++;
|
|
}
|
|
endY++;
|
|
finalEndX = nextEndX;
|
|
}
|
|
|
|
// IBufferRange uses 1-based coordinates.
|
|
return {
|
|
start: {
|
|
x: finalStartX + 1,
|
|
y: startY
|
|
},
|
|
end: {
|
|
x: finalEndX,
|
|
y: endY
|
|
}
|
|
};
|
|
}
|
|
|
|
private _hasUrlId(line: IBufferLine, x: number, linkId: number): boolean {
|
|
const cell = this._workCell;
|
|
line.loadCell(x, cell);
|
|
return !!cell.hasExtendedAttrs() && cell.extended.urlId === linkId;
|
|
}
|
|
}
|
|
|
|
function defaultActivate(e: MouseEvent, uri: string): void {
|
|
const answer = confirm(`Do you want to navigate to ${uri}?\n\nWARNING: This link could potentially be dangerous`);
|
|
if (answer) {
|
|
const newWindow = window.open();
|
|
if (newWindow) {
|
|
try {
|
|
newWindow.opener = null;
|
|
} catch {
|
|
// no-op, Electron can throw
|
|
}
|
|
newWindow.location.href = uri;
|
|
} else {
|
|
console.warn('Opening link blocked as opener could not be cleared');
|
|
}
|
|
}
|
|
}
|