@theia/terminal 1.74.0-next.8 → 1.74.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/lib/browser/terminal-file-link-provider.d.ts +3 -8
  2. package/lib/browser/terminal-file-link-provider.d.ts.map +1 -1
  3. package/lib/browser/terminal-file-link-provider.js +13 -35
  4. package/lib/browser/terminal-file-link-provider.js.map +1 -1
  5. package/lib/browser/terminal-file-link-provider.spec.d.ts +2 -0
  6. package/lib/browser/terminal-file-link-provider.spec.d.ts.map +1 -0
  7. package/lib/browser/terminal-file-link-provider.spec.js +79 -0
  8. package/lib/browser/terminal-file-link-provider.spec.js.map +1 -0
  9. package/lib/browser/terminal-file-opening-link-provider.d.ts +20 -0
  10. package/lib/browser/terminal-file-opening-link-provider.d.ts.map +1 -0
  11. package/lib/browser/terminal-file-opening-link-provider.js +59 -0
  12. package/lib/browser/terminal-file-opening-link-provider.js.map +1 -0
  13. package/lib/browser/terminal-file-uri-link-provider.d.ts +29 -0
  14. package/lib/browser/terminal-file-uri-link-provider.d.ts.map +1 -0
  15. package/lib/browser/terminal-file-uri-link-provider.js +95 -0
  16. package/lib/browser/terminal-file-uri-link-provider.js.map +1 -0
  17. package/lib/browser/terminal-file-uri-link-provider.spec.d.ts +2 -0
  18. package/lib/browser/terminal-file-uri-link-provider.spec.d.ts.map +1 -0
  19. package/lib/browser/terminal-file-uri-link-provider.spec.js +180 -0
  20. package/lib/browser/terminal-file-uri-link-provider.spec.js.map +1 -0
  21. package/lib/browser/terminal-frontend-contribution.d.ts.map +1 -1
  22. package/lib/browser/terminal-frontend-contribution.js +6 -1
  23. package/lib/browser/terminal-frontend-contribution.js.map +1 -1
  24. package/lib/browser/terminal-frontend-module.d.ts.map +1 -1
  25. package/lib/browser/terminal-frontend-module.js +3 -0
  26. package/lib/browser/terminal-frontend-module.js.map +1 -1
  27. package/lib/browser/terminal-widget-impl.d.ts.map +1 -1
  28. package/lib/browser/terminal-widget-impl.js +9 -2
  29. package/lib/browser/terminal-widget-impl.js.map +1 -1
  30. package/package.json +10 -10
  31. package/src/browser/terminal-file-link-provider.spec.ts +95 -0
  32. package/src/browser/terminal-file-link-provider.ts +11 -28
  33. package/src/browser/terminal-file-opening-link-provider.ts +57 -0
  34. package/src/browser/terminal-file-uri-link-provider.spec.ts +203 -0
  35. package/src/browser/terminal-file-uri-link-provider.ts +91 -0
  36. package/src/browser/terminal-frontend-contribution.ts +6 -1
  37. package/src/browser/terminal-frontend-module.ts +3 -0
  38. package/src/browser/terminal-widget-impl.ts +9 -2
@@ -0,0 +1,203 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 JuliaHub, Inc. and others.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom';
18
+ const disableJSDOM = enableJSDOM();
19
+ // Importing the provider pulls in xterm, which probes a canvas 2d context on load; JSDOM has no
20
+ // canvas backend, so stub it out to keep this spec runnable without the native `canvas` package.
21
+ (HTMLCanvasElement.prototype as unknown as { getContext: () => unknown }).getContext = () => undefined;
22
+
23
+ import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider';
24
+ FrontendApplicationConfigProvider.set({});
25
+
26
+ import * as chai from 'chai';
27
+ import { ILogger } from '@theia/core';
28
+ import { OpenerService, OpenHandler } from '@theia/core/lib/browser';
29
+ import URI from '@theia/core/lib/common/uri';
30
+ import { FileService } from '@theia/filesystem/lib/browser/file-service';
31
+ import { TerminalWidget } from './base/terminal-widget';
32
+ import { FileUriLinkProvider } from './terminal-file-uri-link-provider';
33
+
34
+ disableJSDOM();
35
+
36
+ const expect = chai.expect;
37
+
38
+ class TestFileUriLinkProvider extends FileUriLinkProvider {
39
+ readonly loggedErrors: string[] = [];
40
+ readonly opened: string[] = [];
41
+ readonly openedOptions: unknown[] = [];
42
+
43
+ constructor(existingFiles: string[], directories: string[] = []) {
44
+ super();
45
+ const files = new Set(existingFiles);
46
+ const dirs = new Set(directories.map(uri => new URI(uri).toString()));
47
+ (this as unknown as { logger: ILogger }).logger = {
48
+ error: (message: unknown) => { this.loggedErrors.push(String(message)); }
49
+ } as unknown as ILogger;
50
+ (this as unknown as { fileService: FileService }).fileService = {
51
+ resolve: async (uri: URI) => {
52
+ if (dirs.has(uri.toString())) {
53
+ return { isDirectory: true };
54
+ }
55
+ if (files.has(uri.toString())) {
56
+ return { isDirectory: false };
57
+ }
58
+ throw new Error('does not exist');
59
+ }
60
+ } as unknown as FileService;
61
+ const opener: OpenHandler = {
62
+ id: 'test',
63
+ canHandle: () => 1,
64
+ open: async (uri: URI, options?: unknown) => {
65
+ this.opened.push(uri.toString());
66
+ this.openedOptions.push(options);
67
+ return undefined;
68
+ }
69
+ };
70
+ (this as unknown as { openerService: OpenerService }).openerService = {
71
+ getOpener: async () => opener,
72
+ getOpeners: async () => [opener]
73
+ } as unknown as OpenerService;
74
+ }
75
+ }
76
+
77
+ const terminal = {} as unknown as TerminalWidget;
78
+
79
+ describe('FileUriLinkProvider', () => {
80
+
81
+ it('should linkify and open a POSIX file:// URL that resolves to a file', async () => {
82
+ const uri = 'file:///home/user/project/file.ts';
83
+ const provider = new TestFileUriLinkProvider([uri]);
84
+ const links = await provider.provideLinks(`open ${uri} now`, terminal);
85
+ expect(links).to.have.lengthOf(1);
86
+ expect(links[0].startIndex).to.equal('open '.length);
87
+ expect(links[0].length).to.equal(uri.length);
88
+ expect(provider.loggedErrors).to.deep.equal([]);
89
+ await links[0].handle();
90
+ expect(provider.opened).to.deep.equal([new URI(uri).toString()]);
91
+ });
92
+
93
+ it('should linkify a Windows drive file:// URL', async () => {
94
+ const uri = 'file:///C:/Users/dev/file.ts';
95
+ const provider = new TestFileUriLinkProvider([new URI(uri).toString()]);
96
+ const links = await provider.provideLinks(`see ${uri}`, terminal);
97
+ expect(links).to.have.lengthOf(1);
98
+ expect(provider.loggedErrors).to.deep.equal([]);
99
+ });
100
+
101
+ it('should linkify a UNC file:// URL with an authority', async () => {
102
+ const uri = 'file://server/share/file.ts';
103
+ const provider = new TestFileUriLinkProvider([new URI(uri).toString()]);
104
+ const links = await provider.provideLinks(uri, terminal);
105
+ expect(links).to.have.lengthOf(1);
106
+ expect(provider.loggedErrors).to.deep.equal([]);
107
+ });
108
+
109
+ it('should not produce a link for a file:// URL that does not resolve, without throwing or logging', async () => {
110
+ const provider = new TestFileUriLinkProvider([]);
111
+ const links = await provider.provideLinks('open file:///home/user/missing.ts now', terminal);
112
+ expect(links).to.deep.equal([]);
113
+ expect(provider.loggedErrors).to.deep.equal([]);
114
+ });
115
+
116
+ it('should ignore http(s) URLs', async () => {
117
+ const provider = new TestFileUriLinkProvider([]);
118
+ const links = await provider.provideLinks('see https://example.com/path for details', terminal);
119
+ expect(links).to.deep.equal([]);
120
+ });
121
+
122
+ it('should trim trailing sentence punctuation from the matched URL', async () => {
123
+ const uri = 'file:///home/user/project/file.ts';
124
+ const provider = new TestFileUriLinkProvider([uri]);
125
+ const links = await provider.provideLinks(`the file is ${uri}.`, terminal);
126
+ expect(links).to.have.lengthOf(1);
127
+ expect(links[0].length).to.equal(uri.length);
128
+ });
129
+
130
+ it('should open at the given line and column for a file://…:line:column suffix', async () => {
131
+ const uri = 'file:///home/user/project/file.ts';
132
+ const provider = new TestFileUriLinkProvider([uri]);
133
+ const match = `${uri}:10:5`;
134
+ const links = await provider.provideLinks(`error at ${match}`, terminal);
135
+ expect(links).to.have.lengthOf(1);
136
+ // The whole `file://…:10:5` is clickable, but the URI drops the position suffix.
137
+ expect(links[0].length).to.equal(match.length);
138
+ await links[0].handle();
139
+ expect(provider.opened).to.deep.equal([new URI(uri).toString()]);
140
+ // one-based `10:5` becomes a zero-based editor position.
141
+ expect(provider.openedOptions).to.deep.equal([{ selection: { start: { line: 9, character: 4 } } }]);
142
+ });
143
+
144
+ it('should open at the given line with a file://…:line suffix (no column)', async () => {
145
+ const uri = 'file:///home/user/project/file.ts';
146
+ const provider = new TestFileUriLinkProvider([uri]);
147
+ const links = await provider.provideLinks(`${uri}:42`, terminal);
148
+ expect(links).to.have.lengthOf(1);
149
+ await links[0].handle();
150
+ expect(provider.opened).to.deep.equal([new URI(uri).toString()]);
151
+ expect(provider.openedOptions).to.deep.equal([{ selection: { start: { line: 41, character: 0 } } }]);
152
+ });
153
+
154
+ it('should keep the Windows drive colon and still apply a trailing position', async () => {
155
+ const uri = 'file:///C:/Users/dev/file.ts';
156
+ const provider = new TestFileUriLinkProvider([new URI(uri).toString()]);
157
+ const links = await provider.provideLinks(`${uri}:7:3`, terminal);
158
+ expect(links).to.have.lengthOf(1);
159
+ await links[0].handle();
160
+ expect(provider.opened).to.deep.equal([new URI(uri).toString()]);
161
+ expect(provider.openedOptions).to.deep.equal([{ selection: { start: { line: 6, character: 2 } } }]);
162
+ });
163
+
164
+ it('should clamp a zero line/column to the first position', async () => {
165
+ const uri = 'file:///home/user/project/file.ts';
166
+ const provider = new TestFileUriLinkProvider([uri]);
167
+ const links = await provider.provideLinks(`${uri}:0`, terminal);
168
+ expect(links).to.have.lengthOf(1);
169
+ await links[0].handle();
170
+ expect(provider.openedOptions).to.deep.equal([{ selection: { start: { line: 0, character: 0 } } }]);
171
+ });
172
+
173
+ it('should not produce a link for a file:// URL that resolves to a directory', async () => {
174
+ const uri = 'file:///home/user/project';
175
+ const provider = new TestFileUriLinkProvider([], [uri]);
176
+ const links = await provider.provideLinks(`open ${uri} now`, terminal);
177
+ expect(links).to.deep.equal([]);
178
+ expect(provider.loggedErrors).to.deep.equal([]);
179
+ });
180
+
181
+ it('should linkify every file:// URL on a line at the correct offsets', async () => {
182
+ const a = 'file:///home/user/a.ts';
183
+ const b = 'file:///home/user/b.ts';
184
+ const provider = new TestFileUriLinkProvider([a, b]);
185
+ const line = `first ${a} then ${b}`;
186
+ const links = await provider.provideLinks(line, terminal);
187
+ expect(links).to.have.lengthOf(2);
188
+ expect(links.map(link => link.startIndex)).to.deep.equal([line.indexOf(a), line.indexOf(b)]);
189
+ expect(links.map(link => line.substr(link.startIndex, link.length))).to.deep.equal([a, b]);
190
+ });
191
+
192
+ it('should not leak regex state across concurrent invocations', async () => {
193
+ const a = 'file:///home/user/a.ts';
194
+ const b = 'file:///home/user/b.ts';
195
+ const provider = new TestFileUriLinkProvider([a, b]);
196
+ const [linksA, linksB] = await Promise.all([
197
+ provider.provideLinks(`x ${a}`, terminal),
198
+ provider.provideLinks(b, terminal)
199
+ ]);
200
+ expect(linksA.map(link => link.length)).to.deep.equal([a.length]);
201
+ expect(linksB.map(link => link.length)).to.deep.equal([b.length]);
202
+ });
203
+ });
@@ -0,0 +1,91 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 JuliaHub, Inc. and others.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ import { ILogger } from '@theia/core';
18
+ import URI from '@theia/core/lib/common/uri';
19
+ import { inject, injectable, named } from '@theia/core/shared/inversify';
20
+ import { Position } from '@theia/editor/lib/browser';
21
+ import { AbstractFileOpeningLinkProvider } from './terminal-file-opening-link-provider';
22
+ import { TerminalWidget } from './base/terminal-widget';
23
+ import { TerminalLink } from './terminal-link-provider';
24
+
25
+ /**
26
+ * Linkifies `file://` URLs printed in the terminal and opens the referenced local file in the editor.
27
+ *
28
+ * A dedicated provider is needed because `UrlLinkProvider` only matches `http(s)` and
29
+ * `FileLinkProvider`'s path regex excludes `:`, so neither recognizes the `file:` scheme.
30
+ */
31
+ @injectable()
32
+ export class FileUriLinkProvider extends AbstractFileOpeningLinkProvider {
33
+
34
+ @inject(ILogger) @named('terminal:FileUriLinkProvider')
35
+ protected readonly logger: ILogger;
36
+
37
+ // The optional third slash denotes an empty authority (`file:///path`); the match runs to the first
38
+ // whitespace or character that commonly delimits a URL in prose.
39
+ protected readonly fileUriRegExp = /file:\/\/\/?[^\s'"`<>()[\]]+/g;
40
+
41
+ async provideLinks(line: string, terminal: TerminalWidget): Promise<TerminalLink[]> {
42
+ // Collect all matches synchronously before awaiting: the `g`-flag regex is instance-shared, so
43
+ // yielding mid-iteration would let a concurrent call corrupt its `lastIndex`.
44
+ const candidates: Array<{ match: string, startIndex: number }> = [];
45
+ this.fileUriRegExp.lastIndex = 0;
46
+ let regExpResult: RegExpExecArray | null;
47
+ while (regExpResult = this.fileUriRegExp.exec(line)) {
48
+ candidates.push({ match: this.trimTrailingPunctuation(regExpResult[0]), startIndex: regExpResult.index });
49
+ }
50
+ const links = await Promise.all(candidates.map(async ({ match, startIndex }) => {
51
+ const { fileUri, position } = this.splitPosition(match);
52
+ const uri = this.toURI(fileUri);
53
+ if (uri && await this.isValidFileURI(uri)) {
54
+ return { startIndex, length: match.length, handle: () => this.openURI(uri, position) };
55
+ }
56
+ return undefined;
57
+ }));
58
+ return links.filter((link): link is TerminalLink => link !== undefined);
59
+ }
60
+
61
+ /**
62
+ * Splits a trailing `:line` or `:line:column` suffix (as in `file:///x.ts:10:5`) off the matched
63
+ * URL into a one-based editor position. The drive colon of `file:///C:/x.ts` is not affected, as
64
+ * it is not followed by digits at the end of the string.
65
+ */
66
+ protected splitPosition(match: string): { fileUri: string, position?: Position } {
67
+ const positionMatch = /:(\d+)(?::(\d+))?$/.exec(match);
68
+ if (!positionMatch) {
69
+ return { fileUri: match };
70
+ }
71
+ const line = Math.max(0, parseInt(positionMatch[1], 10) - 1);
72
+ const character = positionMatch[2] !== undefined ? Math.max(0, parseInt(positionMatch[2], 10) - 1) : 0;
73
+ return {
74
+ fileUri: match.slice(0, positionMatch.index),
75
+ position: { line, character }
76
+ };
77
+ }
78
+
79
+ protected toURI(match: string): URI | undefined {
80
+ try {
81
+ const uri = new URI(match);
82
+ return uri.scheme === 'file' ? uri : undefined;
83
+ } catch {
84
+ return undefined;
85
+ }
86
+ }
87
+
88
+ protected trimTrailingPunctuation(match: string): string {
89
+ return match.replace(/[.,;:]+$/, '');
90
+ }
91
+ }
@@ -946,8 +946,13 @@ export class TerminalFrontendContribution implements FrontendApplicationContribu
946
946
  keybinding: 'ctrlcmd+k',
947
947
  when: 'terminalFocus'
948
948
  });
949
+ // A passthrough binding claims ctrlcmd+v for the terminal (winning over the global
950
+ // CommonCommands.PASTE binding via the local terminalFocus context) but runs no command,
951
+ // letting the keystroke reach xterm's native paste event. That avoids the permission-gated
952
+ // Clipboard API (navigator.clipboard.readText) that the PASTE_TERMINAL command requires, while
953
+ // preserving bracketed paste. terminal.enablePaste is enforced in TerminalWidgetImpl.customKeyHandler.
949
954
  keybindings.registerKeybinding({
950
- command: TerminalCommands.PASTE_TERMINAL.id,
955
+ command: KeybindingRegistry.PASSTHROUGH_PSEUDO_COMMAND,
951
956
  keybinding: 'ctrlcmd+v',
952
957
  when: 'terminalFocus'
953
958
  });
@@ -43,6 +43,7 @@ import { QuickAccessContribution } from '@theia/core/lib/browser/quick-input/qui
43
43
  import { createXtermLinkFactory, TerminalLinkProvider, TerminalLinkProviderContribution, XtermLinkFactory } from './terminal-link-provider';
44
44
  import { UrlLinkProvider } from './terminal-url-link-provider';
45
45
  import { FileDiffPostLinkProvider, FileDiffPreLinkProvider, FileLinkProvider, LocalFileLinkProvider } from './terminal-file-link-provider';
46
+ import { FileUriLinkProvider } from './terminal-file-uri-link-provider';
46
47
  import {
47
48
  ContributedTerminalProfileStore, DefaultProfileStore, DefaultTerminalProfileService,
48
49
  TerminalProfileService, TerminalProfileStore, UserTerminalProfileStore
@@ -120,6 +121,8 @@ export default new ContainerModule(bind => {
120
121
  // default terminal link provider
121
122
  bind(UrlLinkProvider).toSelf().inSingletonScope();
122
123
  bind(TerminalLinkProvider).toService(UrlLinkProvider);
124
+ bind(FileUriLinkProvider).toSelf().inSingletonScope();
125
+ bind(TerminalLinkProvider).toService(FileUriLinkProvider);
123
126
  bind(FileLinkProvider).toSelf().inSingletonScope();
124
127
  bind(TerminalLinkProvider).toService(FileLinkProvider);
125
128
  bind(FileDiffPreLinkProvider).toSelf().inSingletonScope();
@@ -1196,8 +1196,15 @@ export class TerminalWidgetImpl extends TerminalWidget implements StatefulWidget
1196
1196
  if (ctrlCmdCopy && this.enableCopy && this.term.hasSelection()) {
1197
1197
  return false;
1198
1198
  }
1199
- if (ctrlCmdPaste && this.enablePaste) {
1200
- return false;
1199
+ if (ctrlCmdPaste) {
1200
+ if (this.enablePaste) {
1201
+ // Defer to the browser's native paste event (see the ctrlcmd+v passthrough keybinding).
1202
+ return false;
1203
+ }
1204
+ // Paste disabled: cancel the native paste so the preference is honored even where xterm
1205
+ // would otherwise let the key through to the browser (e.g. cmd+v on macOS). Non-macOS
1206
+ // ctrl+v still reaches the shell as ^V below.
1207
+ event.preventDefault();
1201
1208
  }
1202
1209
  return true;
1203
1210
  }