Skip to content

Commit 3cb5824

Browse files
authored
Merge pull request microsoft#157783 from babakks/support-other-terminals-cwd-sequence
🎁 Support other terminals CWD escape sequence
2 parents 3400ec4 + 0d950ab commit 3cb5824

File tree

2 files changed

+203
-25
lines changed

2 files changed

+203
-25
lines changed

src/vs/platform/terminal/common/xterm/shellIntegrationAddon.ts

Lines changed: 103 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import type { ITerminalAddon, Terminal } from 'xterm-headless';
1717
import { ISerializedCommandDetectionCapability } from 'vs/platform/terminal/common/terminalProcess';
1818
import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry';
1919
import { Emitter } from 'vs/base/common/event';
20+
import { URI } from 'vs/base/common/uri';
21+
2022

2123
/**
2224
* Shell integration is a feature that enhances the terminal's understanding of what's happening
@@ -48,7 +50,9 @@ const enum ShellIntegrationOscPs {
4850
/**
4951
* Sequences pioneered by iTerm.
5052
*/
51-
ITerm = 1337
53+
ITerm = 1337,
54+
SetCwd = 7,
55+
SetWindowsFriendlyCwd = 9
5256
}
5357

5458
/**
@@ -158,7 +162,12 @@ const enum ITermOscPt {
158162
/**
159163
* Sets a mark/point-of-interest in the buffer. `OSC 1337 ; SetMark`
160164
*/
161-
SetMark = 'SetMark'
165+
SetMark = 'SetMark',
166+
167+
/**
168+
* Reports current working directory (CWD). `OSC 1337 ; CurrentDir=<Cwd> ST`
169+
*/
170+
CurrentDir = 'CurrentDir'
162171
}
163172

164173
/**
@@ -204,6 +213,8 @@ export class ShellIntegrationAddon extends Disposable implements IShellIntegrati
204213
this._commonProtocolDisposables.push(
205214
xterm.parser.registerOscHandler(ShellIntegrationOscPs.FinalTerm, data => this._handleFinalTermSequence(data))
206215
);
216+
this._register(xterm.parser.registerOscHandler(ShellIntegrationOscPs.SetCwd, data => this._doHandleSetCwd(data)));
217+
this._register(xterm.parser.registerOscHandler(ShellIntegrationOscPs.SetWindowsFriendlyCwd, data => this._doHandleSetWindowsFriendlyCwd(data)));
207218
this._ensureCapabilitiesOrAddFailureTelemetry();
208219
}
209220

@@ -306,7 +317,7 @@ export class ShellIntegrationAddon extends Disposable implements IShellIntegrati
306317
case VSCodeOscPt.CommandLine: {
307318
let commandLine: string;
308319
if (args.length === 1) {
309-
commandLine = this._deserializeMessage(args[0]);
320+
commandLine = deserializeMessage(args[0]);
310321
} else {
311322
commandLine = '';
312323
}
@@ -330,20 +341,13 @@ export class ShellIntegrationAddon extends Disposable implements IShellIntegrati
330341
return true;
331342
}
332343
case VSCodeOscPt.Property: {
333-
const [key, rawValue] = args[0].split('=');
334-
if (rawValue === undefined) {
344+
const { key, value } = parseKeyValueAssignment(args[0]);
345+
if (value === undefined) {
335346
return true;
336347
}
337-
const value = this._deserializeMessage(rawValue);
338348
switch (key) {
339349
case 'Cwd': {
340-
// TODO: Ideally we would also support the following to supplement our own:
341-
// - OSC 1337 ; CurrentDir=<Cwd> ST (iTerm)
342-
// - OSC 7 ; scheme://cwd ST (Unknown origin)
343-
// - OSC 9 ; 9 ; <cwd> ST (cmder)
344-
this._createOrGetCwdDetection().updateCwd(value);
345-
const commandDetection = this.capabilities.get(TerminalCapability.CommandDetection);
346-
commandDetection?.setCwd(value);
350+
this._updateCwd(value);
347351
return true;
348352
}
349353
case 'IsWindows': {
@@ -361,6 +365,12 @@ export class ShellIntegrationAddon extends Disposable implements IShellIntegrati
361365
return false;
362366
}
363367

368+
private _updateCwd(value: string) {
369+
this._createOrGetCwdDetection().updateCwd(value);
370+
const commandDetection = this.capabilities.get(TerminalCapability.CommandDetection);
371+
commandDetection?.setCwd(value);
372+
}
373+
364374
private _doHandleITermSequence(data: string): boolean {
365375
if (!this._terminal) {
366376
return false;
@@ -371,7 +381,65 @@ export class ShellIntegrationAddon extends Disposable implements IShellIntegrati
371381
case ITermOscPt.SetMark: {
372382
this._createOrGetCommandDetection(this._terminal).handleGenericCommand({ genericMarkProperties: { disableCommandStorage: true } });
373383
}
384+
default: {
385+
// Checking for known `<key>=<value>` pairs.
386+
const { key, value } = parseKeyValueAssignment(command);
387+
388+
if (value === undefined) {
389+
// No '=' was found, so it's not a property assignment.
390+
return true;
391+
}
392+
393+
switch (key) {
394+
case ITermOscPt.CurrentDir:
395+
// Encountered: `OSC 1337 ; CurrentDir=<Cwd> ST`
396+
this._updateCwd(value);
397+
return true;
398+
}
399+
}
374400
}
401+
402+
// Unrecognized sequence
403+
return false;
404+
}
405+
406+
private _doHandleSetWindowsFriendlyCwd(data: string): boolean {
407+
if (!this._terminal) {
408+
return false;
409+
}
410+
411+
const [command, ...args] = data.split(';');
412+
switch (command) {
413+
case '9':
414+
// Encountered `OSC 9 ; 9 ; <cwd> ST`
415+
if (args.length) {
416+
this._updateCwd(args[0]);
417+
}
418+
return true;
419+
}
420+
421+
// Unrecognized sequence
422+
return false;
423+
}
424+
425+
/**
426+
* Handles the sequence: `OSC 7 ; scheme://cwd ST`
427+
*/
428+
private _doHandleSetCwd(data: string): boolean {
429+
if (!this._terminal) {
430+
return false;
431+
}
432+
433+
const [command] = data.split(';');
434+
435+
if (command.match(/^file:\/\/.*\//)) {
436+
const uri = URI.parse(command);
437+
if (uri.path && uri.path.length > 0) {
438+
this._updateCwd(uri.path);
439+
return true;
440+
}
441+
}
442+
375443
// Unrecognized sequence
376444
return false;
377445
}
@@ -411,17 +479,29 @@ export class ShellIntegrationAddon extends Disposable implements IShellIntegrati
411479
}
412480
return commandDetection;
413481
}
482+
}
414483

415-
private _deserializeMessage(message: string): string {
416-
let result = message.replace(/\\\\/g, '\\');
417-
const deserializeRegex = /\\x([0-9a-f]{2})/i;
418-
while (true) {
419-
const match = result.match(deserializeRegex);
420-
if (!match?.index || match.length < 2) {
421-
break;
422-
}
423-
result = result.slice(0, match.index) + String.fromCharCode(parseInt(match[1], 16)) + result.slice(match.index + 4);
484+
export function deserializeMessage(message: string): string {
485+
let result = message.replace(/\\\\/g, '\\');
486+
const deserializeRegex = /\\x([0-9a-f]{2})/i;
487+
while (true) {
488+
const match = result.match(deserializeRegex);
489+
if (!match?.index || match.length < 2) {
490+
break;
424491
}
425-
return result;
492+
result = result.slice(0, match.index) + String.fromCharCode(parseInt(match[1], 16)) + result.slice(match.index + 4);
493+
}
494+
return result;
495+
}
496+
497+
export function parseKeyValueAssignment(message: string): { key: string; value: string | undefined } {
498+
const deserialized = deserializeMessage(message);
499+
const separatorIndex = deserialized.indexOf('=');
500+
if (separatorIndex === -1) {
501+
return { key: deserialized, value: undefined }; // No '=' was found.
426502
}
503+
return {
504+
key: deserialized.substring(0, separatorIndex),
505+
value: deserialized.substring(1 + separatorIndex)
506+
};
427507
}

src/vs/workbench/contrib/terminal/test/browser/xterm/shellIntegrationAddon.test.ts

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@
44
*--------------------------------------------------------------------------------------------*/
55

66
import { Terminal } from 'xterm';
7-
import { strictEqual } from 'assert';
7+
import { strictEqual, deepStrictEqual } from 'assert';
88
import { timeout } from 'vs/base/common/async';
99
import * as sinon from 'sinon';
10-
import { ShellIntegrationAddon } from 'vs/platform/terminal/common/xterm/shellIntegrationAddon';
10+
import { parseKeyValueAssignment, ShellIntegrationAddon } from 'vs/platform/terminal/common/xterm/shellIntegrationAddon';
1111
import { ITerminalCapabilityStore, TerminalCapability } from 'vs/platform/terminal/common/capabilities/capabilities';
1212
import { TestInstantiationService } from 'vs/platform/instantiation/test/common/instantiationServiceMock';
1313
import { ILogService, NullLogService } from 'vs/platform/log/common/log';
@@ -58,12 +58,90 @@ suite('ShellIntegrationAddon', () => {
5858
await writeP(xterm, '\x1b]633;P;Cwd=/foo\x07');
5959
strictEqual(capabilities.has(TerminalCapability.CwdDetection), true);
6060
});
61+
6162
test('should pass cwd sequence to the capability', async () => {
6263
const mock = shellIntegrationAddon.getCwdDectionMock();
6364
mock.expects('updateCwd').once().withExactArgs('/foo');
6465
await writeP(xterm, '\x1b]633;P;Cwd=/foo\x07');
6566
mock.verify();
6667
});
68+
69+
test('detect ITerm sequence: `OSC 1337 ; CurrentDir=<Cwd> ST`', async () => {
70+
type TestCase = [title: string, input: string, expected: string];
71+
const cases: TestCase[] = [
72+
['root', '/', '/'],
73+
['non-root', '/some/path', '/some/path'],
74+
];
75+
for (const x of cases) {
76+
const [title, input, expected] = x;
77+
const mock = shellIntegrationAddon.getCwdDectionMock();
78+
mock.expects('updateCwd').once().withExactArgs(expected).named(title);
79+
await writeP(xterm, `\x1b]1337;CurrentDir=${input}\x07`);
80+
mock.verify();
81+
}
82+
});
83+
84+
suite('detect `SetCwd` sequence: `OSC 7; scheme://cwd ST`', async () => {
85+
test('should accept well-formatted URLs', async () => {
86+
type TestCase = [title: string, input: string, expected: string];
87+
const cases: TestCase[] = [
88+
// Different hostname values:
89+
['empty hostname, pointing root', 'file:///', '/'],
90+
['empty hostname', 'file:///test-root/local', '/test-root/local'],
91+
['non-empty hostname', 'file://some-hostname/test-root/local', '/test-root/local'],
92+
// URL-encoded chars:
93+
['URL-encoded value (1)', 'file:///test-root/%6c%6f%63%61%6c', '/test-root/local'],
94+
['URL-encoded value (2)', 'file:///test-root/local%22', '/test-root/local"'],
95+
['URL-encoded value (3)', 'file:///test-root/local"', '/test-root/local"'],
96+
];
97+
for (const x of cases) {
98+
const [title, input, expected] = x;
99+
const mock = shellIntegrationAddon.getCwdDectionMock();
100+
mock.expects('updateCwd').once().withExactArgs(expected).named(title);
101+
await writeP(xterm, `\x1b]7;${input}\x07`);
102+
mock.verify();
103+
}
104+
});
105+
106+
test('should ignore ill-formatted URLs', async () => {
107+
type TestCase = [title: string, input: string];
108+
const cases: TestCase[] = [
109+
// Different hostname values:
110+
['no hostname, pointing root', 'file://'],
111+
// Non-`file` scheme values:
112+
['no scheme (1)', '/test-root'],
113+
['no scheme (2)', '//test-root'],
114+
['no scheme (3)', '///test-root'],
115+
['no scheme (4)', ':///test-root'],
116+
['http', 'http:///test-root'],
117+
['ftp', 'ftp:///test-root'],
118+
['ssh', 'ssh:///test-root'],
119+
];
120+
121+
for (const x of cases) {
122+
const [title, input] = x;
123+
const mock = shellIntegrationAddon.getCwdDectionMock();
124+
mock.expects('updateCwd').never().named(title);
125+
await writeP(xterm, `\x1b]7;${input}\x07`);
126+
mock.verify();
127+
}
128+
});
129+
});
130+
131+
test('detect `SetWindowsFrindlyCwd` sequence: `OSC 9 ; 9 ; <cwd> ST`', async () => {
132+
type TestCase = [title: string, input: string, expected: string];
133+
const cases: TestCase[] = [
134+
['root', '/', '/'],
135+
['non-root', '/some/path', '/some/path'],
136+
];
137+
for (const x of cases) {
138+
const [title, input, expected] = x;
139+
const mock = shellIntegrationAddon.getCwdDectionMock();
140+
mock.expects('updateCwd').once().withExactArgs(expected).named(title);
141+
await writeP(xterm, `\x1b]9;9;${input}\x07`);
142+
mock.verify();
143+
}
144+
});
67145
});
68146

69147
suite('command tracking', async () => {
@@ -134,3 +212,23 @@ suite('ShellIntegrationAddon', () => {
134212
});
135213
});
136214
});
215+
216+
test('parseKeyValueAssignment', () => {
217+
type TestCase = [title: string, input: string, expected: [key: string, value: string | undefined]];
218+
const cases: TestCase[] = [
219+
['empty', '', ['', undefined]],
220+
['no "=" sign', 'some-text', ['some-text', undefined]],
221+
['empty value', 'key=', ['key', '']],
222+
['empty key', '=value', ['', 'value']],
223+
['normal', 'key=value', ['key', 'value']],
224+
['multiple "=" signs (1)', 'key==value', ['key', '=value']],
225+
['multiple "=" signs (2)', 'key=value===true', ['key', 'value===true']],
226+
['just a "="', '=', ['', '']],
227+
['just a "=="', '==', ['', '=']],
228+
];
229+
230+
cases.forEach(x => {
231+
const [title, input, [key, value]] = x;
232+
deepStrictEqual(parseKeyValueAssignment(input), { key, value }, title);
233+
});
234+
});

0 commit comments

Comments
 (0)