Skip to content

Commit d2fea3e

Browse files
authored
Add project info, progress indicators (#3235)
1 parent 0fccf68 commit d2fea3e

24 files changed

Lines changed: 1356 additions & 8 deletions
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import * as vscode from "vscode";
2+
import {
3+
disabledSchemes,
4+
isJsConfigOrTsConfigFileName,
5+
isSupportedLanguageMode,
6+
} from "./util";
7+
8+
/**
9+
* Tracks the active JS/TS editor.
10+
*
11+
* This tries to handle the case where the user focuses in the output view / debug console.
12+
* When this happens, we want to treat the last real focused editor as the active editor,
13+
* instead of using `vscode.window.activeTextEditor`.
14+
*/
15+
export class ActiveJsTsEditorTracker implements vscode.Disposable {
16+
private _activeJsTsEditor: vscode.TextEditor | undefined;
17+
private disposables: vscode.Disposable[] = [];
18+
19+
private readonly _onDidChangeActiveJsTsEditor = new vscode.EventEmitter<vscode.TextEditor | undefined>();
20+
public readonly onDidChangeActiveJsTsEditor = this._onDidChangeActiveJsTsEditor.event;
21+
22+
constructor() {
23+
this.disposables.push(this._onDidChangeActiveJsTsEditor);
24+
this.disposables.push(vscode.window.onDidChangeActiveTextEditor(() => this.update()));
25+
this.disposables.push(vscode.window.onDidChangeVisibleTextEditors(() => this.update()));
26+
this.disposables.push(vscode.window.tabGroups.onDidChangeTabGroups(() => this.update()));
27+
this.update();
28+
}
29+
30+
public get activeJsTsEditor(): vscode.TextEditor | undefined {
31+
return this._activeJsTsEditor;
32+
}
33+
34+
private update(): void {
35+
const editorCandidates = this.getEditorCandidatesForActiveTab();
36+
const newActiveJsTsEditor = editorCandidates.find(editor => this.isManagedFile(editor));
37+
if (newActiveJsTsEditor !== undefined && this._activeJsTsEditor !== newActiveJsTsEditor) {
38+
this._activeJsTsEditor = newActiveJsTsEditor;
39+
this._onDidChangeActiveJsTsEditor.fire(this._activeJsTsEditor);
40+
}
41+
}
42+
43+
private getEditorCandidatesForActiveTab(): vscode.TextEditor[] {
44+
const tab = vscode.window.tabGroups.activeTabGroup.activeTab;
45+
if (!tab) {
46+
return [];
47+
}
48+
49+
// Basic text editor tab
50+
if (tab.input instanceof vscode.TabInputText) {
51+
const inputUriString = tab.input.uri.toString();
52+
const editor = vscode.window.visibleTextEditors.find(editor => {
53+
return editor.document.uri.toString() === inputUriString
54+
&& editor.viewColumn === tab.group.viewColumn;
55+
});
56+
return editor ? [editor] : [];
57+
}
58+
59+
// Diff editor tab
60+
if (tab.input instanceof vscode.TabInputTextDiff) {
61+
const original = tab.input.original;
62+
const modified = tab.input.modified;
63+
return [vscode.window.activeTextEditor, ...vscode.window.visibleTextEditors]
64+
.filter((editor): editor is vscode.TextEditor => editor !== undefined)
65+
.filter(editor => {
66+
return (editor.document.uri.toString() === original.toString() || editor.document.uri.toString() === modified.toString())
67+
&& editor.viewColumn === undefined;
68+
});
69+
}
70+
71+
return [];
72+
}
73+
74+
private isManagedFile(editor: vscode.TextEditor): boolean {
75+
if (disabledSchemes.has(editor.document.uri.scheme)) {
76+
return false;
77+
}
78+
return isSupportedLanguageMode(editor.document) || isJsConfigOrTsConfigFileName(editor.document.fileName);
79+
}
80+
81+
dispose(): void {
82+
this.disposables.forEach(d => d.dispose());
83+
}
84+
}

_extension/src/client.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,15 @@ export class Client implements vscode.Disposable {
291291
const result = await this.client.sendRequest<{ file: string; }>("custom/stopCPUProfile");
292292
return result.file;
293293
}
294+
295+
async getProjectInfo(uri: string, token?: vscode.CancellationToken): Promise<{ configFilePath: string; }> {
296+
if (!this.client) {
297+
throw new Error("Language client is not initialized");
298+
}
299+
return this.client.sendRequest<{ configFilePath: string; }>("custom/projectInfo", {
300+
textDocument: { uri },
301+
}, token);
302+
}
294303
}
295304

296305
// Adapted from the default error handler in vscode-languageclient.
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import * as vscode from "vscode";
2+
import { ActiveJsTsEditorTracker } from "./activeJsTsEditorTracker";
3+
import {
4+
disabledSchemes,
5+
isJsConfigOrTsConfigFileName,
6+
isSupportedLanguageMode,
7+
} from "./util";
8+
9+
/**
10+
* When-clause context set when the current file is managed by tsgo.
11+
*/
12+
export class ManagedFileContextManager implements vscode.Disposable {
13+
private static readonly contextName = "typescript.isManagedFile";
14+
15+
private isInManagedFileContext = false;
16+
private disposables: vscode.Disposable[] = [];
17+
18+
constructor(activeJsTsEditorTracker: ActiveJsTsEditorTracker) {
19+
this.disposables.push(
20+
activeJsTsEditorTracker.onDidChangeActiveJsTsEditor(
21+
editor => this.onDidChangeActiveTextEditor(editor),
22+
),
23+
);
24+
this.onDidChangeActiveTextEditor(activeJsTsEditorTracker.activeJsTsEditor);
25+
}
26+
27+
private onDidChangeActiveTextEditor(editor?: vscode.TextEditor): void {
28+
if (editor) {
29+
this.updateContext(this.isManagedFile(editor));
30+
}
31+
else {
32+
this.updateContext(false);
33+
}
34+
}
35+
36+
private isManagedFile(editor: vscode.TextEditor): boolean {
37+
if (disabledSchemes.has(editor.document.uri.scheme)) {
38+
return false;
39+
}
40+
return isSupportedLanguageMode(editor.document)
41+
|| isJsConfigOrTsConfigFileName(editor.document.fileName);
42+
}
43+
44+
private updateContext(newValue: boolean): void {
45+
if (newValue === this.isInManagedFileContext) {
46+
return;
47+
}
48+
vscode.commands.executeCommand("setContext", ManagedFileContextManager.contextName, newValue);
49+
this.isInManagedFileContext = newValue;
50+
}
51+
52+
dispose(): void {
53+
this.updateContext(false);
54+
this.disposables.forEach(d => d.dispose());
55+
}
56+
}

_extension/src/projectStatus.ts

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import * as vscode from "vscode";
2+
import { ActiveJsTsEditorTracker } from "./activeJsTsEditorTracker";
3+
import { Client } from "./client";
4+
import {
5+
isSupportedLanguageMode,
6+
jsTsLanguageModes,
7+
} from "./util";
8+
9+
namespace ProjectInfoState {
10+
export const enum Type {
11+
None,
12+
Pending,
13+
Resolved,
14+
}
15+
16+
export const None = Object.freeze({ type: Type.None } as const);
17+
18+
export class Pending {
19+
public readonly type = Type.Pending;
20+
public readonly cancellation = new vscode.CancellationTokenSource();
21+
22+
constructor(
23+
public readonly resource: vscode.Uri,
24+
) {}
25+
}
26+
27+
export class Resolved {
28+
public readonly type = Type.Resolved;
29+
30+
constructor(
31+
public readonly resource: vscode.Uri,
32+
public readonly configFile: string,
33+
) {}
34+
}
35+
36+
export type State = typeof None | Pending | Resolved;
37+
}
38+
39+
/**
40+
* Shows which tsconfig/jsconfig the current file belongs to.
41+
*/
42+
export class ProjectStatus implements vscode.Disposable {
43+
private statusItem?: vscode.LanguageStatusItem;
44+
private state: ProjectInfoState.State = ProjectInfoState.None;
45+
private disposables: vscode.Disposable[] = [];
46+
private ready = false;
47+
48+
constructor(
49+
private readonly client: Client,
50+
private readonly activeEditorTracker: ActiveJsTsEditorTracker,
51+
onReady: vscode.Event<void>,
52+
) {
53+
this.disposables.push(
54+
activeEditorTracker.onDidChangeActiveJsTsEditor(() => this.updateStatus()),
55+
);
56+
this.disposables.push(
57+
onReady(() => {
58+
this.ready = true;
59+
this.updateStatus();
60+
}),
61+
);
62+
}
63+
64+
private async updateStatus(): Promise<void> {
65+
const doc = this.activeEditorTracker.activeJsTsEditor?.document;
66+
if (!doc || !isSupportedLanguageMode(doc)) {
67+
this.updateState(ProjectInfoState.None);
68+
return;
69+
}
70+
71+
if (doc.uri.scheme !== "file" && doc.uri.scheme !== "untitled") {
72+
this.updateState(ProjectInfoState.None);
73+
return;
74+
}
75+
76+
if (!this.ready) {
77+
return;
78+
}
79+
80+
const pendingState = new ProjectInfoState.Pending(doc.uri);
81+
this.updateState(pendingState);
82+
83+
try {
84+
const result = await this.client.getProjectInfo(doc.uri.toString(), pendingState.cancellation.token);
85+
if (this.state === pendingState) {
86+
this.updateState(new ProjectInfoState.Resolved(doc.uri, result.configFilePath));
87+
}
88+
}
89+
catch {
90+
// If we fail to get project info, just go back to no status
91+
if (this.state === pendingState) {
92+
this.updateState(ProjectInfoState.None);
93+
}
94+
}
95+
}
96+
97+
private updateState(newState: ProjectInfoState.State): void {
98+
if (this.state === newState) {
99+
return;
100+
}
101+
102+
if (this.state.type === ProjectInfoState.Type.Pending) {
103+
this.state.cancellation.cancel();
104+
this.state.cancellation.dispose();
105+
}
106+
107+
this.state = newState;
108+
109+
switch (this.state.type) {
110+
case ProjectInfoState.Type.None: {
111+
this.statusItem?.dispose();
112+
this.statusItem = undefined;
113+
break;
114+
}
115+
case ProjectInfoState.Type.Pending: {
116+
const statusItem = this.ensureStatusItem();
117+
statusItem.severity = vscode.LanguageStatusSeverity.Information;
118+
statusItem.text = "Loading project info...";
119+
statusItem.detail = undefined;
120+
statusItem.command = undefined;
121+
statusItem.busy = true;
122+
break;
123+
}
124+
case ProjectInfoState.Type.Resolved: {
125+
const currentLanguageId = this.activeEditorTracker.activeJsTsEditor?.document.languageId;
126+
const isTypeScript = currentLanguageId === "typescript"
127+
|| currentLanguageId === "typescriptreact";
128+
const noConfigFileText = isTypeScript ? "No tsconfig" : "No jsconfig";
129+
130+
const rootPath = this.getWorkspaceRootForResource(this.state.resource);
131+
if (!rootPath) {
132+
if (this.statusItem) {
133+
this.statusItem.text = noConfigFileText;
134+
this.statusItem.detail = !vscode.workspace.workspaceFolders
135+
? "No opened folders"
136+
: "File is not part of opened folders";
137+
this.statusItem.busy = false;
138+
}
139+
return;
140+
}
141+
142+
const statusItem = this.ensureStatusItem();
143+
statusItem.busy = false;
144+
statusItem.detail = undefined;
145+
statusItem.severity = vscode.LanguageStatusSeverity.Information;
146+
147+
if (this.state.configFile) {
148+
statusItem.text = vscode.workspace.asRelativePath(this.state.configFile);
149+
statusItem.command = {
150+
command: "vscode.open",
151+
title: "Open Config File",
152+
arguments: [vscode.Uri.file(this.state.configFile)],
153+
};
154+
}
155+
else {
156+
statusItem.text = noConfigFileText;
157+
statusItem.command = undefined;
158+
}
159+
break;
160+
}
161+
}
162+
}
163+
164+
private ensureStatusItem(): vscode.LanguageStatusItem {
165+
if (!this.statusItem) {
166+
this.statusItem = vscode.languages.createLanguageStatusItem("typescript.native-preview.projectStatus", jsTsLanguageModes);
167+
this.statusItem.name = "TypeScript Native Preview Project Status";
168+
}
169+
return this.statusItem;
170+
}
171+
172+
private getWorkspaceRootForResource(resource: vscode.Uri): vscode.Uri | undefined {
173+
const folder = vscode.workspace.getWorkspaceFolder(resource);
174+
return folder?.uri;
175+
}
176+
177+
dispose(): void {
178+
this.statusItem?.dispose();
179+
if (this.state.type === ProjectInfoState.Type.Pending) {
180+
this.state.cancellation.cancel();
181+
this.state.cancellation.dispose();
182+
}
183+
this.disposables.forEach(d => d.dispose());
184+
}
185+
}

_extension/src/session.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import * as vscode from "vscode";
2+
import { ActiveJsTsEditorTracker } from "./activeJsTsEditorTracker";
23
import { Client } from "./client";
34
import { registerCodeLensShowLocationsCommand } from "./commands";
5+
import { ManagedFileContextManager } from "./managedFileContext";
6+
import { ProjectStatus } from "./projectStatus";
47
import { setupStatusBar } from "./statusBar";
58
import { TelemetryReporter } from "./telemetryReporting";
69
import { getExe } from "./util";
@@ -91,6 +94,7 @@ class Session implements vscode.Disposable {
9194
private outputChannel: vscode.LogOutputChannel;
9295
private traceOutputChannel: vscode.LogOutputChannel;
9396
private telemetryReporter: TelemetryReporter;
97+
private initializedEventEmitter: vscode.EventEmitter<void>;
9498

9599
constructor(
96100
outputChannel: vscode.LogOutputChannel,
@@ -103,13 +107,30 @@ class Session implements vscode.Disposable {
103107
this.outputChannel = outputChannel;
104108
this.traceOutputChannel = traceOutputChannel;
105109
this.telemetryReporter = telemetryReporter;
110+
this.initializedEventEmitter = initializedEventEmitter;
106111
this.registerCommands();
107112
}
108113

109114
async start(context: vscode.ExtensionContext): Promise<void> {
110115
const exe = await getExe(context);
111116
await this.client.start(exe);
112117
this.disposables.push(setupStatusBar(exe.version));
118+
119+
// Set up active editor tracker and UI features
120+
const activeEditorTracker = new ActiveJsTsEditorTracker();
121+
this.disposables.push(activeEditorTracker);
122+
123+
const managedFileContext = new ManagedFileContextManager(activeEditorTracker);
124+
this.disposables.push(managedFileContext);
125+
126+
const projectStatus = new ProjectStatus(this.client, activeEditorTracker, this.initializedEventEmitter.event);
127+
this.disposables.push(projectStatus);
128+
129+
// If already initialized, fire immediately so projectStatus picks it up
130+
if (this.client.isInitialized) {
131+
this.initializedEventEmitter.fire();
132+
}
133+
113134
await vscode.commands.executeCommand("setContext", "typescript.native-preview.serverRunning", true);
114135
}
115136

0 commit comments

Comments
 (0)