Skip to content

Commit 84b8121

Browse files
authored
Merge pull request #4288 from jerch/fix_weblinks
Fix weblinks
2 parents cb4102c + 218cfd2 commit 84b8121

File tree

9 files changed

+291
-130
lines changed

9 files changed

+291
-130
lines changed

addons/xterm-addon-web-links/src/WebLinkProvider.ts

Lines changed: 118 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* @license MIT
44
*/
55

6-
import { ILinkProvider, ILink, Terminal, IViewportRange } from 'xterm';
6+
import { ILinkProvider, ILink, Terminal, IViewportRange, IBufferLine } from 'xterm';
77

88
export interface ILinkProviderOptions {
99
hover?(event: MouseEvent, text: string, location: IViewportRange): void;
@@ -45,61 +45,51 @@ export class LinkComputer {
4545
public static computeLink(y: number, regex: RegExp, terminal: Terminal, activate: (event: MouseEvent, uri: string) => void): ILink[] {
4646
const rex = new RegExp(regex.source, (regex.flags || '') + 'g');
4747

48-
const [line, startLineIndex] = LinkComputer._translateBufferLineToStringWithWrap(y - 1, false, terminal);
49-
50-
// Don't try if the wrapped line if excessively large as the regex matching will block the main
51-
// thread.
52-
if (line.length > 1024) {
53-
return [];
54-
}
48+
const [lines, startLineIndex] = LinkComputer._getWindowedLineStrings(y - 1, terminal);
49+
const line = lines.join('');
5550

5651
let match;
57-
let stringIndex = -1;
5852
const result: ILink[] = [];
5953

60-
while ((match = rex.exec(line)) !== null) {
61-
const text = match[1];
62-
if (!text) {
63-
// something matched but does not comply with the given matchIndex
64-
// since this is most likely a bug the regex itself we simply do nothing here
65-
console.log('match found without corresponding matchIndex');
66-
break;
67-
}
68-
69-
// Get index, match.index is for the outer match which includes negated chars
70-
// therefore we cannot use match.index directly, instead we search the position
71-
// of the match group in text again
72-
// also correct regex and string search offsets for the next loop run
73-
stringIndex = line.indexOf(text, stringIndex + 1);
74-
rex.lastIndex = stringIndex + text.length;
75-
if (stringIndex < 0) {
76-
// invalid stringIndex (should not have happened)
77-
break;
54+
while (match = rex.exec(line)) {
55+
const text = match[0];
56+
57+
// check via URL if the matched text would form a proper url
58+
// NOTE: This outsources the ugly url parsing to the browser.
59+
// To avoid surprising auto expansion from URL we additionally
60+
// check afterwards if the provided string resembles the parsed
61+
// one close enough:
62+
// - decodeURI decode path segement back to byte repr
63+
// to detect unicode auto conversion correctly
64+
// - append / also match domain urls w'o any path notion
65+
try {
66+
const url = new URL(text);
67+
const urlText = decodeURI(url.toString());
68+
if (text !== urlText && text + '/' !== urlText) {
69+
continue;
70+
}
71+
} catch (e) {
72+
continue;
7873
}
7974

80-
let endX = stringIndex + text.length;
81-
let endY = startLineIndex + 1;
75+
// map string positions back to buffer positions
76+
// values are 0-based right side excluding
77+
const [startY, startX] = LinkComputer._mapStrIdx(terminal, startLineIndex, 0, match.index);
78+
const [endY, endX] = LinkComputer._mapStrIdx(terminal, startY, startX, text.length);
8279

83-
while (endX > terminal.cols) {
84-
endX -= terminal.cols;
85-
endY++;
86-
}
87-
88-
let startX = stringIndex + 1;
89-
let startY = startLineIndex + 1;
90-
while (startX > terminal.cols) {
91-
startX -= terminal.cols;
92-
startY++;
80+
if (startY === -1 || startX === -1 || endY === -1 || endX === -1) {
81+
continue;
9382
}
9483

84+
// range expects values 1-based right side including, thus +1 except for endX
9585
const range = {
9686
start: {
97-
x: startX,
98-
y: startY
87+
x: startX + 1,
88+
y: startY + 1
9989
},
10090
end: {
10191
x: endX,
102-
y: endY
92+
y: endY + 1
10393
}
10494
};
10595

@@ -110,41 +100,99 @@ export class LinkComputer {
110100
}
111101

112102
/**
113-
* Gets the entire line for the buffer line
114-
* @param lineIndex The index of the line being translated.
115-
* @param trimRight Whether to trim whitespace to the right.
103+
* Get wrapped content lines for the current line index.
104+
* The top/bottom line expansion stops at whitespaces or length > 2048.
105+
* Returns an array with line strings and the top line index.
106+
*
107+
* NOTE: We pull line strings with trimRight=true on purpose to make sure
108+
* to correctly match urls with early wrapped wide chars. This corrupts the string index
109+
* for 1:1 backmapping to buffer positions, thus needs an additional correction in _mapStrIdx.
116110
*/
117-
private static _translateBufferLineToStringWithWrap(lineIndex: number, trimRight: boolean, terminal: Terminal): [string, number] {
118-
let lineString = '';
119-
let lineWrapsToNext: boolean;
120-
let prevLinesToWrap: boolean;
121-
122-
do {
123-
const line = terminal.buffer.active.getLine(lineIndex);
124-
if (!line) {
125-
break;
111+
private static _getWindowedLineStrings(lineIndex: number, terminal: Terminal): [string[], number] {
112+
let line: IBufferLine | undefined;
113+
let topIdx = lineIndex;
114+
let bottomIdx = lineIndex;
115+
let length = 0;
116+
let content = '';
117+
const lines: string[] = [];
118+
119+
if ((line = terminal.buffer.active.getLine(lineIndex))) {
120+
const currentContent = line.translateToString(true);
121+
122+
// expand top, stop on whitespaces or length > 2048
123+
if (line.isWrapped && currentContent[0] !== ' ') {
124+
length = 0;
125+
while ((line = terminal.buffer.active.getLine(--topIdx)) && length < 2048) {
126+
content = line.translateToString(true);
127+
length += content.length;
128+
lines.push(content);
129+
if (!line.isWrapped || content.indexOf(' ') !== -1) {
130+
break;
131+
}
132+
}
133+
lines.reverse();
126134
}
127135

128-
if (line.isWrapped) {
129-
lineIndex--;
136+
// append current line
137+
lines.push(currentContent);
138+
139+
// expand bottom, stop on whitespaces or length > 2048
140+
length = 0;
141+
while ((line = terminal.buffer.active.getLine(++bottomIdx)) && line.isWrapped && length < 2048) {
142+
content = line.translateToString(true);
143+
length += content.length;
144+
lines.push(content);
145+
if (content.indexOf(' ') !== -1) {
146+
break;
147+
}
130148
}
149+
}
150+
return [lines, topIdx];
151+
}
131152

132-
prevLinesToWrap = line.isWrapped;
133-
} while (prevLinesToWrap);
134-
135-
const startLineIndex = lineIndex;
136-
137-
do {
138-
const nextLine = terminal.buffer.active.getLine(lineIndex + 1);
139-
lineWrapsToNext = nextLine ? nextLine.isWrapped : false;
140-
const line = terminal.buffer.active.getLine(lineIndex);
153+
/**
154+
* Map a string index back to buffer positions.
155+
* Returns buffer position as [lineIndex, columnIndex] 0-based,
156+
* or [-1, -1] in case the lookup ran into a non-existing line.
157+
*/
158+
private static _mapStrIdx(terminal: Terminal, lineIndex: number, rowIndex: number, stringIndex: number): [number, number] {
159+
const buf = terminal.buffer.active;
160+
const cell = buf.getNullCell();
161+
let start = rowIndex;
162+
while (stringIndex) {
163+
const line = buf.getLine(lineIndex);
141164
if (!line) {
142-
break;
165+
return [-1, -1];
166+
}
167+
for (let i = start; i < line.length; ++i) {
168+
line.getCell(i, cell);
169+
const chars = cell.getChars();
170+
const width = cell.getWidth();
171+
if (width) {
172+
stringIndex -= chars.length || 1;
173+
174+
// correct stringIndex for early wrapped wide chars:
175+
// - currently only happens at last cell
176+
// - cells to the right are reset with chars='' and width=1 in InputHandler.print
177+
// - follow-up line must be wrapped and contain wide char at first cell
178+
// --> if all these conditions are met, correct stringIndex by +1
179+
if (i === line.length - 1 && chars === '') {
180+
const line = buf.getLine(lineIndex + 1);
181+
if (line && line.isWrapped) {
182+
line.getCell(0, cell);
183+
if (cell.getWidth() === 2) {
184+
stringIndex += 1;
185+
}
186+
}
187+
}
188+
}
189+
if (stringIndex < 0) {
190+
return [lineIndex, i];
191+
}
143192
}
144-
lineString += line.translateToString(!lineWrapsToNext && trimRight).substring(0, terminal.cols);
145193
lineIndex++;
146-
} while (lineWrapsToNext);
147-
148-
return [lineString, startLineIndex];
194+
start = 0;
195+
}
196+
return [lineIndex, start];
149197
}
150198
}

addons/xterm-addon-web-links/src/WebLinksAddon.ts

Lines changed: 13 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,25 +6,19 @@
66
import { Terminal, ITerminalAddon, IDisposable } from 'xterm';
77
import { ILinkProviderOptions, WebLinkProvider } from './WebLinkProvider';
88

9-
const protocolClause = '(https?:\\/\\/)';
10-
const domainCharacterSet = '[\\da-z\\.-]+';
11-
const negatedDomainCharacterSet = '[^\\da-z\\.-]+';
12-
const domainBodyClause = '(' + domainCharacterSet + ')';
13-
const tldClause = '([a-z\\.]{2,18})';
14-
const ipClause = '((\\d{1,3}\\.){3}\\d{1,3})';
15-
const localHostClause = '(localhost)';
16-
const portClause = '(:\\d{1,5})';
17-
const hostClause = '((' + domainBodyClause + '\\.' + tldClause + ')|' + ipClause + '|' + localHostClause + ')' + portClause + '?';
18-
const pathCharacterSet = '(\\/[\\/\\w\\.\\-%~:+@]*)*([^:"\'\\s])';
19-
const pathClause = '(' + pathCharacterSet + ')?';
20-
const queryStringHashFragmentCharacterSet = '[0-9\\w\\[\\]\\(\\)\\/\\?\\!#@$%&\'*+,:;~\\=\\.\\-]*';
21-
const queryStringClause = '(\\?' + queryStringHashFragmentCharacterSet + ')?';
22-
const hashFragmentClause = '(#' + queryStringHashFragmentCharacterSet + ')?';
23-
const negatedPathCharacterSet = '[^\\/\\w\\.\\-%]+';
24-
const bodyClause = hostClause + pathClause + queryStringClause + hashFragmentClause;
25-
const start = '(?:^|' + negatedDomainCharacterSet + ')(';
26-
const end = ')($|' + negatedPathCharacterSet + ')';
27-
const strictUrlRegex = new RegExp(start + protocolClause + bodyClause + end);
9+
// consider everthing starting with http:// or https://
10+
// up to first whitespace, `"` or `'` as url
11+
// NOTE: The repeated end clause is needed to not match a dangling `:`
12+
// resembling the old (...)*([^:"\'\\s]) final path clause
13+
// additionally exclude early + final:
14+
// - unsafe from rfc3986: !*'()
15+
// - unsafe chars from rfc1738: {}|\^~[]` (minus [] as we need them for ipv6 adresses, also allow ~)
16+
// also exclude as finals:
17+
// - final interpunction like ,.!?
18+
// - any sort of brackets <>()[]{} (not spec conform, but often used to enclose urls)
19+
// - unsafe chars from rfc1738: {}|\^~[]`
20+
const strictUrlRegex = /https?:[/]{2}[^\s"'!*(){}|\\\^<>`]*[^\s"':,.!?{}|\\\^~\[\]`()<>]/;
21+
2822

2923
function handleLink(event: MouseEvent, uri: string): void {
3024
const newWindow = window.open();

addons/xterm-addon-web-links/test/WebLinksAddon.api.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,22 @@ let page: Page;
1414
const width = 800;
1515
const height = 600;
1616

17+
18+
interface ILinkStateData {
19+
uri?: string;
20+
range?: {
21+
start: {
22+
x: number;
23+
y: number;
24+
};
25+
end: {
26+
x: number;
27+
y: number;
28+
};
29+
};
30+
}
31+
32+
1733
describe('WebLinksAddon', () => {
1834
before(async function(): Promise<any> {
1935
browser = await launchBrowser();
@@ -35,6 +51,43 @@ describe('WebLinksAddon', () => {
3551
it('.io', async function(): Promise<any> {
3652
await testHostName('foo.io');
3753
});
54+
55+
describe('correct buffer offsets & uri', () => {
56+
it('all half width', async () => {
57+
setupCustom();
58+
await writeSync(page, 'aaa http://example.com aaa http://example.com aaa');
59+
await resetAndHover(5, 1);
60+
await evalLinkStateData('http://example.com', { start: { x: 5, y: 1 }, end: { x: 22, y: 1 } });
61+
await resetAndHover(1, 2);
62+
await evalLinkStateData('http://example.com', { start: { x: 28, y: 1 }, end: { x: 5, y: 2 } });
63+
});
64+
it('url after full width', async () => {
65+
setupCustom();
66+
await writeSync(page, '¥¥¥ http://example.com ¥¥¥ http://example.com aaa');
67+
await resetAndHover(8, 1);
68+
await evalLinkStateData('http://example.com', { start: { x: 8, y: 1 }, end: { x: 25, y: 1 } });
69+
await resetAndHover(1, 2);
70+
await evalLinkStateData('http://example.com', { start: { x: 34, y: 1 }, end: { x: 11, y: 2 } });
71+
});
72+
it('full width within url and before', async () => {
73+
setupCustom();
74+
await writeSync(page, '¥¥¥ https://ko.wikipedia.org/wiki/위키백과:대문 aaa https://ko.wikipedia.org/wiki/위키백과:대문 ¥¥¥');
75+
await resetAndHover(8, 1);
76+
await evalLinkStateData('https://ko.wikipedia.org/wiki/위키백과:대문', { start: { x: 8, y: 1 }, end: { x: 11, y: 2 } });
77+
await resetAndHover(1, 2);
78+
await evalLinkStateData('https://ko.wikipedia.org/wiki/위키백과:대문', { start: { x: 8, y: 1 }, end: { x: 11, y: 2 } });
79+
await resetAndHover(17, 2);
80+
await evalLinkStateData('https://ko.wikipedia.org/wiki/위키백과:대문', { start: { x: 17, y: 2 }, end: { x: 19, y: 3 } });
81+
});
82+
it('name + password url after full width and combining', async () => {
83+
setupCustom();
84+
await writeSync(page, '¥¥¥cafe\u0301 http://test:[email protected]/some_path');
85+
await resetAndHover(12, 1);
86+
await evalLinkStateData('http://test:[email protected]/some_path', { start: { x: 12, y: 1 }, end: { x: 13, y: 2 } });
87+
await resetAndHover(13, 2);
88+
await evalLinkStateData('http://test:[email protected]/some_path', { start: { x: 12, y: 1 }, end: { x: 13, y: 2 } });
89+
});
90+
});
3891
});
3992

4093
async function testHostName(hostname: string): Promise<void> {
@@ -64,3 +117,23 @@ async function pollForLinkAtCell(col: number, row: number, value: string): Promi
64117
await pollFor(page, `document.querySelectorAll('${rowSelector} > span[style]').length >= ${value.length}`, true, async () => page.hover(`${rowSelector} > :nth-child(${col})`));
65118
assert.equal(await page.evaluate(`Array.prototype.reduce.call(document.querySelectorAll('${rowSelector} > span[style]'), (a, b) => a + b.textContent, '');`), value);
66119
}
120+
121+
async function setupCustom(): Promise<void> {
122+
await openTerminal(page, { cols: 40 });
123+
await page.evaluate(`window._linkStateData = {};
124+
window._linkaddon = new window.WebLinksAddon();
125+
window._linkaddon._options.hover = (event, uri, range) => { window._linkStateData = { uri, range }; };
126+
window.term.loadAddon(window._linkaddon);`);
127+
}
128+
129+
async function resetAndHover(col: number, row: number): Promise<void> {
130+
await page.evaluate(`window._linkStateData = {};`);
131+
const rowSelector = `.xterm-rows > :nth-child(${row})`;
132+
await page.hover(`${rowSelector} > :nth-child(${col})`);
133+
}
134+
135+
async function evalLinkStateData(uri: string, range: any): Promise<void> {
136+
const data: ILinkStateData = await page.evaluate(`window._linkStateData`);
137+
assert.equal(data.uri, uri);
138+
assert.deepEqual(data.range, range);
139+
}

demo/client.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ if (document.location.pathname === '/test') {
228228
document.getElementById('sgr-test').addEventListener('click', sgrTest);
229229
document.getElementById('add-decoration').addEventListener('click', addDecoration);
230230
document.getElementById('add-overview-ruler').addEventListener('click', addOverviewRuler);
231+
document.getElementById('weblinks-test').addEventListener('click', testWeblinks);
231232
addVtButtons();
232233
}
233234

@@ -1148,3 +1149,25 @@ function addVtButtons(): void {
11481149

11491150
document.querySelector('#vt-container').appendChild(vtFragment);
11501151
}
1152+
1153+
function testWeblinks(): void {
1154+
const linkExamples = `
1155+
aaa http://example.com aaa http://example.com aaa
1156+
¥¥¥ http://example.com aaa http://example.com aaa
1157+
aaa http://example.com ¥¥¥ http://example.com aaa
1158+
¥¥¥ http://example.com ¥¥¥ http://example.com aaa
1159+
aaa https://ko.wikipedia.org/wiki/위키백과:대문 aaa https://ko.wikipedia.org/wiki/위키백과:대문 aaa
1160+
¥¥¥ https://ko.wikipedia.org/wiki/위키백과:대문 aaa https://ko.wikipedia.org/wiki/위키백과:대문 ¥¥¥
1161+
aaa http://test:[email protected]/some_path aaa
1162+
brackets enclosed:
1163+
aaa [http://example.de] aaa
1164+
aaa (http://example.de) aaa
1165+
aaa <http://example.de> aaa
1166+
aaa {http://example.de} aaa
1167+
ipv6 https://[::1]/with/some?vars=and&a#hash aaa
1168+
stop at final '.': This is a sentence with an url to http://example.com.
1169+
stop at final '?': Is this the right url http://example.com/?
1170+
stop at final '?': Maybe this one http://example.com/with?arguments=false?
1171+
`;
1172+
term.write(linkExamples.split('\n').join('\r\n'));
1173+
}

0 commit comments

Comments
 (0)