@theia/terminal 1.74.0-next.5 → 1.74.0-next.53

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 (30) 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-module.d.ts.map +1 -1
  22. package/lib/browser/terminal-frontend-module.js +3 -0
  23. package/lib/browser/terminal-frontend-module.js.map +1 -1
  24. package/package.json +9 -9
  25. package/src/browser/terminal-file-link-provider.spec.ts +95 -0
  26. package/src/browser/terminal-file-link-provider.ts +11 -28
  27. package/src/browser/terminal-file-opening-link-provider.ts +57 -0
  28. package/src/browser/terminal-file-uri-link-provider.spec.ts +203 -0
  29. package/src/browser/terminal-file-uri-link-provider.ts +91 -0
  30. package/src/browser/terminal-frontend-module.ts +3 -0
@@ -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
+ }
@@ -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();